From 401b721329153dc79d3fffb16193bca8eadb16fc Mon Sep 17 00:00:00 2001 From: Claude Lin & Lay Date: Mon, 24 Aug 2026 20:26:06 +0900 Subject: [PATCH 1/2] fix(adapter): scope the memory dir glob fallback to the project dir's own slug MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `adapter/claude/hooks/on-session-start.sh` の 2 箇所の glob fallback (self-eval log lookup / MEMORY_DIR 解決)に scope guard を追加し、別ワークスペースの memory ディレクトリへ到達する経路を塞いだ。 背景:#1796 は 2 つのワークスペースから同時にこの越境を実測している。いずれも自分の memory ディレクトリは存在するが空で、`memory_dir_populated()` がそれを未 populated として 飛ばし、glob が第三のワークスペース(`C--Users-smile-Code`)の 5 件を promotion candidates として掴んでいた。populated 判定は slug の内側で正しい規則であって、slug 間の選択に使う 道具ではない。promotion candidates も observation surface も self-eval head も当該セッションの observe stage 入力であり、別ワークスペースの観測はこのワークスペースの観測ではない。 変更内容: - `memory_slug_encloses_project()` を追加。候補 slug が `CCD_SLUG` と一致するか、`-` 境界での 接頭辞(= `CLAUDE_PROJECT_DIR` を内包するディレクトリ)である場合のみ採用する。slug 不一致時の 救済という fallback 本来の役目は残し、横方向への到達だけを落とす。逆方向(自分より深い slug)は 必要とした観測が無いため入れていない - self-eval log の glob fallback を `head -n 1` から guard 付きの走査へ変更。MEMORY_DIR 側は 既存の while ループに guard 条件を 1 行追加した。populated 条件と scope 条件は別軸で、両方が成立 する必要がある - 空の memory ディレクトリは「観測材料が無い」と読まれ silent skip になる、という既存の振る舞いは 維持している - `tests/` に契約テストを 2 本追加:自 scope 外の slug を掴まないこと(observation surface と self_eval_head の両方で確認)、および内包 slug には従来どおり到達すること。既存の `test_claude_glob_fallback_skips_unpopulated_project_slugs` は、populated 条件だけを測るよう 両候補を scope 内の slug へ寄せた - `docs/6.-Adapter.md` の `MEMORY_DIR` 解決規則に探索範囲の項を追加 codex port(`.sh` / `.ps1` の両方)は射程外。ホスト側に per-slug memory レイアウトが存在せず workspace-local な `memory/` しか見ないため、構造的にこの欠陥に到達しない。 #1796 --- adapter/claude/hooks/on-session-start.sh | 58 ++++++++++- docs/6.-Adapter.md | 5 + ...st_on_session_start_observation_surface.py | 95 ++++++++++++++++++- 3 files changed, 151 insertions(+), 7 deletions(-) diff --git a/adapter/claude/hooks/on-session-start.sh b/adapter/claude/hooks/on-session-start.sh index c378666..1ecedfc 100755 --- a/adapter/claude/hooks/on-session-start.sh +++ b/adapter/claude/hooks/on-session-start.sh @@ -418,6 +418,47 @@ 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. #1796 measured the +# crossing from two workspaces at once - each had its own memory directory +# present but empty, `memory_dir_populated` stepped over it as unpopulated, and +# the glob then claimed a third workspace's entries as promotion candidates. The +# populated condition is right inside a slug and was never meant to 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 +468,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 +588,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..191fba2 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(`.sh` / `.ps1` の両方)は射程外。ホスト側に `~/.claude/projects//memory` という per-slug レイアウトが存在せず、workspace-local な `memory/` しか見ないため、構造的にこの欠陥に到達しない。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..b635d45 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": @@ -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,57 @@ 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_reaches_an_enclosing_project_slug(self) -> None: + """The rescue the glob fallback keeps after #1796. + + Neither named candidate resolves and the only populated memory directory + sits under the slug of a directory that contains this session's project + directory. That is a slug the derivation did not produce but whose + material is still this workspace's, so it is admitted. Paired with the + case below, which is the same fixture with the slug moved sideways. + """ + workspace = self.new_workspace() + enclosing = workspace.slug_memory(workspace.workspace.parent) + workspace.write(enclosing, "self-evolution-observation.md", self.due_entry()) + workspace.write(enclosing, "self-evaluation_log.md", "# enclosing log\n") + + 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 "") + + 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. From 9a6c06051861615b41192e9a1db82c16b1945542 Mon Sep 17 00:00:00 2001 From: Claude Lin & Lay Date: Mon, 24 Aug 2026 22:38:57 +0900 Subject: [PATCH 2/2] fix(adapter): adjudicate brake 1 findings on the memory dir scope guard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit brake 1(N=3、単一ラウンド、対象 SHA `401b721`)の集約所見3件を突き合わせ、 3件とも採用した。#1796 所見1(軸3 テストの弁別力 / 3体中3体)— accept。 `test_claude_glob_fallback_reaches_an_enclosing_project_slug` が変更前 body でも 通ることを、当該テストのみを変更前 hook に対して実行して確認した(pass)。所見の literal は 成立する。ただし「内包 slug に到達する」ことだけを主張する fixture は、変更前コードが populated な slug をすべて採用する以上、原理的に変更前で落ちない。テスト自体は guard を exact-slug 一致へ絞りすぎる回帰を捕まえる面で load-bearing なので、削除ではなく 弁別力を持たせる方向で修正した:strictly newer な非内包 slug を同じ fixture に置き、 どちらか一方しか選択されない状況を作って「内包側が選ばれること」を assert する。 検証:変更前 hook で fail、guard を exact 一致へ絞った版でも fail、本 PR の実装で pass。 `test_claude_glob_fallback_prefers_an_enclosing_slug_over_a_newer_outsider` へ改名。 所見2(軸4 docs と source の対応 / 3体中3体)— accept(修正して採用)。 `docs/6.-Adapter.md` の codex port 記述に、本 PR が変更した claude 側 hook source の 内側で照合できる対応物が無い、という所見は成立する。ただし当該命題は issue #1796 の制約「codex port には手を入れない(parity を理由に同形の変更を 持ち込まないこと)」を運ぶ面であり、`docs/6.-Adapter.md` は両 port を扱う文書なので、 記述の置き場所自体は正しい。欠けていたのは解決可能な参照先だったため、削除ではなく 検証面の明示(codex 側2ファイルのパスと、`.claude/projects` による再実行可能な走査)を 追加した。命題自体が事実として正しいことは3体とも確認済みで、こちらでも再確認した。 所見3(固定軸 impression-literal / 3体中2体)— accept。 `skills/evolution-impression-literal-detection/SKILL.md` Aggregation は 「2 or more of N=3 flag the same literal → refine immediately」を絶対しきい値として 定めており、多数決読みではなくこのしきい値が発火する。flagged された `adapter/claude/hooks/on-session-start.sh:428-431` の4行(#1796 が何を実測したかの 経緯叙述)を削除し、規範内容を運んでいる直後の文だけを残した。removal test の結果は 2体の判定どおりで、削除後も behavior semantic は変わらない。実測記録は `docs/6.-Adapter.md` の理由項と本 commit body が保持しているため、失われていない。 Divergence handling(本軸は 2-of-3 split なので実施): - same-question check = No。flag した2体は文単位で removal test を適用し、clean と 判定した1体は追加ブロック全体を1単位として Negative の保護カテゴリ (explanatory rationale that prevents a known misinterpretation)に当てている。 同じ問いに答えていないため、所見は criteria ではなく軸の wording 側に立つ。 - why-diverged は question 1 が No のため問わない(spec の順序どおり)。 - 記録内容:固定軸の prompt literal は判定単位を「each phrase」と述べる一方、 Negative リストの protected カテゴリは explanatory rationale というブロック規模の 単位で書かれており、両者の単位が一致していない。文単位で読めば Positive の provenance-in-text tell に当たり、ブロック単位で読めば Negative の保護対象に当たる、 という今回の split はここから出ている。しきい値の発火はこの結果とは独立に成立する (spec 明記)。merge を gate するものではなく、spec-gap observation として `rules/evolution/promotion-judgment.md` の経路へ回す。tally は parent 側の memory に 属するため本 subagent は書き込まず、report で surface する。 #1796 --- adapter/claude/hooks/on-session-start.sh | 11 ++--- docs/6.-Adapter.md | 2 +- ...st_on_session_start_observation_surface.py | 44 ++++++++++++++----- 3 files changed, 38 insertions(+), 19 deletions(-) diff --git a/adapter/claude/hooks/on-session-start.sh b/adapter/claude/hooks/on-session-start.sh index 1ecedfc..da9e61d 100755 --- a/adapter/claude/hooks/on-session-start.sh +++ b/adapter/claude/hooks/on-session-start.sh @@ -425,13 +425,10 @@ CCD_SLUG=$(printf '%s' "$CPD" | sed 's|[:/\\]|-|g') # 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. #1796 measured the -# crossing from two workspaces at once - each had its own memory directory -# present but empty, `memory_dir_populated` stepped over it as unpopulated, and -# the glob then claimed a third workspace's entries as promotion candidates. The -# populated condition is right inside a slug and was never meant to select -# between slugs; an empty memory directory must read as "no material" and skip -# silently rather than send the search next door. +# 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 diff --git a/docs/6.-Adapter.md b/docs/6.-Adapter.md index 191fba2..755e52a 100644 --- a/docs/6.-Adapter.md +++ b/docs/6.-Adapter.md @@ -76,7 +76,7 @@ settings.json の hook command はプロジェクトディレクトリにスペ - 理由: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(`.sh` / `.ps1` の両方)は射程外。ホスト側に `~/.claude/projects//memory` という per-slug レイアウトが存在せず、workspace-local な `memory/` しか見ないため、構造的にこの欠陥に到達しない。parity を理由に同形の変更を持ち込まないこと + - 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 b635d45..c7045d0 100644 --- a/tests/test_on_session_start_observation_surface.py +++ b/tests/test_on_session_start_observation_surface.py @@ -811,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", @@ -883,23 +883,45 @@ 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_reaches_an_enclosing_project_slug(self) -> None: - """The rescue the glob fallback keeps after #1796. - - Neither named candidate resolves and the only populated memory directory - sits under the slug of a directory that contains this session's project - directory. That is a slug the derivation did not produce but whose - material is still this workspace's, so it is admitted. Paired with the - case below, which is the same fixture with the slug moved sideways. + 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.