diff --git a/adapter/claude/hooks/on-session-start.sh b/adapter/claude/hooks/on-session-start.sh index c378666..da9e61d 100755 --- a/adapter/claude/hooks/on-session-start.sh +++ b/adapter/claude/hooks/on-session-start.sh @@ -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" \ @@ -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 @@ -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 diff --git a/docs/6.-Adapter.md b/docs/6.-Adapter.md index 62f73a5..755e52a 100644 --- a/docs/6.-Adapter.md +++ b/docs/6.-Adapter.md @@ -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//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) diff --git a/tests/test_on_session_start_observation_surface.py b/tests/test_on_session_start_observation_surface.py index d0b5b6f..c7045d0 100644 --- a/tests/test_on_session_start_observation_surface.py +++ b/tests/test_on_session_start_observation_surface.py @@ -96,6 +96,15 @@ def posix_path(path: Path) -> str: return text +def project_slug(path: Path) -> str: + """The `~/.claude/projects/` 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("\\", "/") @@ -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. @@ -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" @@ -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//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": @@ -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", @@ -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 @@ -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.