Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 52 additions & 3 deletions adapter/claude/hooks/on-session-start.sh
Original file line number Diff line number Diff line change
Expand Up @@ -418,6 +418,44 @@ register_section "open_in_progress_issues" "Open in-progress issues (max 5)" "$O
# Best-effort read only: silent skip when the file is absent.
CPD="${CLAUDE_PROJECT_DIR:-$PROJECT_ROOT}"
CCD_SLUG=$(printf '%s' "$CPD" | sed 's|[:/\\]|-|g')
# Scope guard for the two glob fallbacks below (#1796).
#
# The two named candidates can both miss while a populated memory directory sits
# under a slug the derivation above did not produce. Rescuing that mismatch is
# what the glob fallbacks are for and it stays. What they must not do is reach a
# *different workspace*: the self-eval head, the promotion candidates and the
# observation surface are this session's observe-stage input, and another
# workspace's memory is not an observation of this one. The populated condition
# below is right inside a slug and does not select between slugs; an empty memory
# directory must read as "no material" and skip silently rather than send the
# search next door.
#
# Accepted = a slug denoting CLAUDE_PROJECT_DIR itself or a directory enclosing
# it, i.e. the candidate slug is CCD_SLUG or a `-`-boundary prefix of it. That
# keeps the rescue whose answer is still this workspace (a session opened below
# the workspace root - a worktree, say - whose material lives at the root) and
# drops every reach sideways. The other direction is not added: no observation
# has needed it, and `rules/model/subtractive-structural-beauty.md` (B) leaves
# an unrequested reach out.
#
# The comparison is on the encoded form because the encoding is lossy and no
# decode exists - a sibling directory whose name is our own with a path-looking
# suffix appended reads as enclosing us. That residual is bounded to siblings
# under our own parent; the boundary the defect crossed is the one this holds.
memory_slug_encloses_project() {
local parent="${1%/memory}"
local slug="${parent##*/}"
# Either side empty would turn the boundary-prefix test into "accept every
# slug", which is the defect itself. Refuse instead. Not hypothetical on the
# CCD_SLUG side: PROJECT_ROOT falls back to "." when CLAUDE_PROJECT_DIR is
# unset, and a slug that names no workspace must select no workspace.
[ -n "$CCD_SLUG" ] && [ -n "$slug" ] || return 1
case "$CCD_SLUG" in
"$slug"|"$slug"-*) return 0 ;;
esac
return 1
}

SELFEVAL_FOUND=""
for candidate in \
"$HOME/.claude/projects/$CCD_SLUG/memory/self-evaluation_log.md" \
Expand All @@ -427,9 +465,16 @@ for candidate in \
break
fi
done
# Glob fallback: pick the most recently modified self-eval log under any project slug.
# Glob fallback: most recently modified self-eval log under a project slug that
# is in scope for this session (memory_slug_encloses_project above, #1796).
if [ -z "$SELFEVAL_FOUND" ]; then
SELFEVAL_FOUND=$(ls -1t "$HOME"/.claude/projects/*/memory/self-evaluation_log.md 2>/dev/null | head -n 1)
while IFS= read -r selfevalcandidate; do
[ -n "$selfevalcandidate" ] || continue
if memory_slug_encloses_project "${selfevalcandidate%/*}"; then
SELFEVAL_FOUND="$selfevalcandidate"
break
fi
done < <(ls -1t "$HOME"/.claude/projects/*/memory/self-evaluation_log.md 2>/dev/null)
fi
SELFEVAL_HEAD=""
if [ -n "$SELFEVAL_FOUND" ] && [ -f "$SELFEVAL_FOUND" ]; then
Expand Down Expand Up @@ -540,10 +585,14 @@ else
fi
done
# Glob fallback mirrors the self-eval log fallback: most recently modified
# memory dir under any project slug, populated ones only.
# memory dir under a project slug in scope for this session, populated ones
# only. The scope guard is what keeps the populated condition from reaching
# across a workspace boundary (memory_slug_encloses_project, #1796); the two
# conditions are separate and both must hold.
if [ -z "$MEMORY_DIR" ]; then
while IFS= read -r memcandidate; do
[ -n "$memcandidate" ] || continue
memory_slug_encloses_project "$memcandidate" || continue
if memory_dir_populated "$memcandidate"; then
MEMORY_DIR="$memcandidate"
break
Expand Down
5 changes: 5 additions & 0 deletions docs/6.-Adapter.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,11 @@ settings.json の hook command はプロジェクトディレクトリにスペ
- 検出器2・3が読む単位:host auto-memory は 1 memory = 1 file なので、entry の単位はファイルそのもの。title は frontmatter の `name:`、無ければファイル名 stem。検出器3の token は title 由来で、entry 種別を表す接頭辞(`feedback` / `project` / `reference` / `user`)は topic を指さないため除外する。pair の報告は同一 source file に別々の token が `THRESHOLD_N` 個以上落ちた場合に限り、token 数の多い順に並べる(1語だけの一致はこの規模のコーパスでは偶然)
- 並び順の決定性:3ポートの並び替えはすべてロケール/カルチャ非依存に固定する。bash 版は `LC_ALL=C sort`、PowerShell 版は `[System.StringComparer]::Ordinal`(`Sort-Object` は現在カルチャ依存のため使わない)。`SURFACE_CAP` で切り詰める一覧では並び順が「どの項目が残るか」を決めるため、順序の一致は表示上の問題ではない。加えて検出器出力は diff-only emission の sha256 対象なので、順序がぶれると差分が毎回発生する
- `MEMORY_DIR` の解決規則:候補ディレクトリは優先順に走査するが、採用条件は「ディレクトリが存在すること」ではなく「消費側が読むファイルを1つ以上持つこと」。marker 集合は `MEMORY_DIR` 経由で読まれるファイル(`self-evolution-observation.md`、および検出器が走査する per-topic entry の接頭辞 `feedback*.md` / `project*.md` / `reference*.md` / `user*.md`)に、2つの解決経路が「何を memory ディレクトリとみなすか」で一致するよう `self-evaluation_log.md` を加えたもの。ただし最後の1つが実際に判定を左右することはない — `self-evaluation_log.md` は同じ候補パスを走査する独自の探索で解決されるため、そのファイルが存在する場合は先に primary 経路が採用され、この populated 判定に到達しない。任意の `*.md` ではなく接頭辞で照合するのは意図的で、無関係なファイル1つでディレクトリが枠を取ってしまうのを防ぐ。存在するだけの空ディレクトリが上位候補にあると、下位候補に実体があっても全消費者がまとめて黙るため。どの候補も marker を持たない場合は未解決のままとし、各消費者が個別のファイル存在チェックで silent skip する(従来と同じ帰結)
- `MEMORY_DIR` の探索範囲(#1796):2つの名前つき候補がいずれも解決しなかったときの glob fallback(`~/.claude/projects/*/memory`)は、**`CLAUDE_PROJECT_DIR` 自身か、それを内包するディレクトリの slug** に限る。判定は slug 文字列で行い、候補 slug が `CCD_SLUG` と一致するか、`-` 境界での接頭辞であれば採用する。slug 不一致時の救済という fallback 本来の役目は残しつつ、横方向(別ワークスペース)への到達だけを落とす措置である。これは self-eval log の glob fallback(section key `self_eval_head`)にも同じ guard が掛かる — 2箇所は同形であり、射程も同一
- 理由:promotion candidates も observation surface も self-eval head も**当該セッションの observe stage 入力**であり、別ワークスペースの観測はこのワークスペースの観測ではない。#1796 は2つのワークスペースから同時にこの越境を実測した。いずれも自分の memory ディレクトリは存在するが空で、上記 populated 判定がそれを未 populated として飛ばし、glob が第三のワークスペースの entry を promotion candidates として掴んでいた。populated 判定は slug の内側で正しい規則であって slug 間の選択に使う道具ではない。空の memory ディレクトリは「観測材料が無い」と読まれ silent skip になるのが正しく、隣を探しに行くことではない
- 逆方向(自分より深い slug)は入れていない。必要とした観測が無く、`rules/model/subtractive-structural-beauty.md` (B) が要求されていない到達を落とすため
- 残余:slug 符号化は非可逆で復号手段が無いため、判定は符号化後の文字列で行う。自分と同じ親を持ち、名前が自分の名前 + パス様の接尾辞になっている兄弟ディレクトリは「内包している」と読まれる。この取りこぼしは自分の親配下の兄弟に限定され、実測された越境(`C--Users-smile-Claude-Lin` から `C--Users-smile-Code`)はこの境界で落ちる
- codex port(`adapter/codex/hooks/on-session-start.sh` / `adapter/codex/hooks/on-session-start.ps1`)は射程外。ホスト側に `~/.claude/projects/<slug>/memory` という per-slug レイアウトが存在せず、両 port とも workspace-local な `memory/` しか見ないため、構造的にこの欠陥に到達しない。この命題の検証面は claude 側 hook の内側ではなく codex 側の2ファイルにあり、`.claude/projects` で両ファイルを走査すれば足りる — 該当はレイアウト不在を述べたコメント各1行のみで、cross-slug glob は存在しない。parity を理由に同形の変更を持ち込まないこと
- self-evolution observation surface(`memory/self-evolution-observation.md` の check window が開いたエントリ):`verdict_state: pending` のうち `next_check <= today` を `DUE`、`expires < today` を `OVERDUE (human judgment needed)` として列挙する。ファイル解決は promotion candidates と同じ `MEMORY_DIR` 経路を再利用し、ファイル不在・該当エントリ無しは silent skip。**section key を持たない = diff-only 比較対象外**(理由は下記「Diff-only 出力」を参照)。動作契約の正本は `rules/evolution/cold-start-synthesis.md` の Self-Evolution Observation Surface 節

#### 起動時ステータスマーカー(Li+ update status / Li+ language contract / gh install)
Expand Down
123 changes: 116 additions & 7 deletions tests/test_on_session_start_observation_surface.py
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,15 @@ def posix_path(path: Path) -> str:
return text


def project_slug(path: Path) -> str:
"""The `~/.claude/projects/<slug>` name Claude Code derives from a path.

Same derivation the claude hook applies to `CLAUDE_PROJECT_DIR`: the POSIX
form of the path with `:`, `/` and the backslash all replaced by `-`.
"""
return re.sub(r"[:/\\]", "-", posix_path(path))


def slash_path(path: Path) -> str:
"""Native path with forward slashes; accepted by PowerShell on every host."""
return str(path).replace("\\", "/")
Expand Down Expand Up @@ -169,6 +178,19 @@ def no_new_material_marker(hook_output: str) -> str | None:
return None


def self_eval_section(hook_output: str) -> str | None:
"""Body of the self-evaluation head section, or None when it was empty.

Located by topic for the same reason `observation_section` is. An empty body
is never emitted at all, so None is also how "no self-eval log resolved"
reads.
"""
for banner, body in emitted_sections(hook_output):
if "self-evaluation" in banner.lower():
return body
return None


def promotion_section(hook_output: str) -> str | None:
"""Body of the promotion-candidates section, or None when it was empty.

Expand Down Expand Up @@ -356,10 +378,9 @@ def __init__(self) -> None:
self.stub_bin.mkdir(parents=True)
self._write_gh_stub()

slug = re.sub(r"[:/\\]", "-", posix_path(self.workspace))
# Memory directory candidates, in each adapter's own precedence order.
self.claude_projects = self.home / ".claude" / "projects"
self.claude_primary = self.claude_projects / slug / "memory"
self.claude_primary = self.slug_memory(self.workspace)
self.shared_memory = self.workspace / "memory"
self.codex_secondary = self.liplus / "memory"

Expand Down Expand Up @@ -407,6 +428,16 @@ def seed_coldstart_rule(self, token: str, h2_token: str | None = None) -> Path:
f"{token} anchor body.\n{h2}",
)

def slug_memory(self, path: Path) -> Path:
"""`~/.claude/projects/<slug>/memory` for an arbitrary directory.

The slug derivation is the hook's own, applied here to paths other than
the workspace so a test can plant a memory directory under a slug that
encloses this session's project directory, or under one that belongs to
a different workspace entirely.
"""
return self.claude_projects / project_slug(path) / "memory"

def memory_candidates(self, adapter: str) -> tuple[Path, Path]:
"""(higher precedence, lower precedence) memory directory for an adapter."""
if adapter == "claude_sh":
Expand Down Expand Up @@ -780,11 +811,11 @@ def setUp(self) -> None:
super().setUp()
self.observation_descriptors = ("reachable",)

def due_entry(self) -> str:
def due_entry(self, descriptor: str = "reachable", pr: str = "2000") -> str:
return "\n".join(
[
"## observation: reachable",
"pr: 2000",
f"## observation: {descriptor}",
f"pr: {pr}",
f"expires: {iso(7)}",
f"next_check: {iso(-1)}",
"verdict_state: pending",
Expand Down Expand Up @@ -832,10 +863,15 @@ def test_claude_glob_fallback_skips_unpopulated_project_slugs(self) -> None:
`~/.claude/projects/*/memory` newest-first. The populated-not-merely-
existing rule applies there too, which the two named-candidate cases
above cannot reach. Claude-only: the codex hooks have no glob stage.

Both slugs here enclose the project directory, so the scope guard (#1796)
admits both and the populated condition is what decides between them.
That separation is the point: this case measures the populated condition
alone, and the two cases below measure the scope guard alone.
"""
workspace = self.new_workspace()
populated = workspace.claude_projects / "other-project" / "memory"
empty = workspace.claude_projects / "empty-project" / "memory"
populated = workspace.slug_memory(workspace.workspace.parent)
empty = workspace.slug_memory(workspace.workspace.parent.parent)
workspace.write(populated, "self-evolution-observation.md", self.due_entry())
empty.mkdir(parents=True, exist_ok=True)
# `ls -1td` orders by mtime, so make the empty slug strictly newer: it is
Expand All @@ -847,6 +883,79 @@ def test_claude_glob_fallback_skips_unpopulated_project_slugs(self) -> None:
section = self.require_section(self.run_hook("claude_sh", workspace))
self.assertEqual(self.surfaced(section), self.expected())

def test_claude_glob_fallback_prefers_an_enclosing_slug_over_a_newer_outsider(
self,
) -> None:
"""Both edges of the #1796 scope, measured by one selection.

Two populated memory directories, neither of them a named candidate: one
under the slug of a directory containing this session's project
directory, one under a sibling workspace's slug made strictly newer so
that mtime order alone would take it. The guard has to reject the
outsider *and* still admit the enclosing slug, and only one of the two
can be selected, so a single assertion catches a guard that is missing
and a guard narrowed to an exact slug match alike.

Asserting only that the enclosing slug is reachable would not do that:
pre-#1796 the fallback admitted every populated slug, so a fixture whose
only populated directory is the enclosing one is satisfied before the
change as well and measures nothing. The outsider is what supplies the
discrimination — it gives the wrong implementations something to pick.
"""
workspace = self.new_workspace()
self.observation_descriptors = ("reachable", "outsider")
enclosing = workspace.slug_memory(workspace.workspace.parent)
outsider = workspace.slug_memory(workspace.workspace.parent / "elsewhere")
workspace.write(enclosing, "self-evolution-observation.md", self.due_entry())
workspace.write(enclosing, "self-evaluation_log.md", "# enclosing log\n")
workspace.write(
outsider,
"self-evolution-observation.md",
self.due_entry("outsider", "2001"),
)
workspace.write(outsider, "self-evaluation_log.md", "# outsider log\n")
now = time.time()
os.utime(outsider, (now, now))
os.utime(enclosing, (now - 600, now - 600))

output = self.run_hook("claude_sh", workspace)
self.assertEqual(self.surfaced(self.require_section(output)), self.expected())
self.assertIn("enclosing log", self_eval_section(output) or "")
self.assertNotIn("outsider log", self_eval_section(output) or "")

def test_claude_glob_fallback_does_not_cross_into_another_workspace(self) -> None:
"""#1796: the glob fallback stops at the project directory's own scope.

The measured defect: this session's own memory directories exist but are
empty, `memory_dir_populated` steps over both, and the glob then claims
a sibling workspace's memory as this session's observe-stage input —
promotion candidates and a self-eval head that were never observations
of this workspace. An empty memory directory must read as "no material",
which is a silent skip, not a search next door.

The sibling is made strictly newest so mtime order alone would pick it,
and it is populated so the populated condition alone would admit it: the
scope guard is the only thing that can refuse it.
"""
workspace = self.new_workspace()
outsider = workspace.slug_memory(workspace.workspace.parent / "elsewhere")
workspace.write(outsider, "self-evolution-observation.md", self.due_entry())
workspace.write(outsider, "self-evaluation_log.md", "# outsider log\n")
# This session's own candidates: present, empty, and older.
workspace.claude_primary.mkdir(parents=True, exist_ok=True)
workspace.shared_memory.mkdir(parents=True, exist_ok=True)
now = time.time()
os.utime(outsider, (now, now))
for own in (workspace.claude_primary, workspace.shared_memory):
os.utime(own, (now - 600, now - 600))

output = self.run_hook("claude_sh", workspace)
self.assertIsNone(
observation_section(output),
"another workspace's observation entry surfaced as this session's",
)
self.assertNotIn("outsider log", self_eval_section(output) or "")


class NoNewMaterialMarkerTest(ObservationSurfaceTestCase):
"""Coverage area 4: the marker's interaction with the observation surface.
Expand Down
Loading