diff --git a/README.ko.md b/README.ko.md
index 7a2ff99a..f1edad05 100644
--- a/README.ko.md
+++ b/README.ko.md
@@ -13,7 +13,7 @@
-
+
diff --git a/README.md b/README.md
index 4bc4a8c6..87da7fda 100644
--- a/README.md
+++ b/README.md
@@ -13,7 +13,7 @@
-
+
diff --git a/README.zh.md b/README.zh.md
index b4b6dd5c..1bba86c5 100644
--- a/README.zh.md
+++ b/README.zh.md
@@ -13,7 +13,7 @@
-
+
diff --git a/devlog/_plan/260910_memory-followup-roadmap/060_wp6_native-integration.md b/devlog/_plan/260910_memory-followup-roadmap/060_wp6_native-integration.md
index 7eb279f2..2d424ea0 100644
--- a/devlog/_plan/260910_memory-followup-roadmap/060_wp6_native-integration.md
+++ b/devlog/_plan/260910_memory-followup-roadmap/060_wp6_native-integration.md
@@ -770,3 +770,21 @@ B4(000과의 정합): 000 §work-phase 맵의 wp6 설명을 이 문서 기준(sr
| rollout_summaries 256 상한 영향 | 파일 수·날짜 분포와 `max_raw_memories_for_consolidation` 대조 | 동일 |
행 번호 정정: `hook.ts:110-145`, `hook.ts:499-531`, `format.ts:52-67`.
+
+
+## P 재검증 (wp6 사이클, 2026-09-10)
+
+기준 트리 = origin/dev(#128 머지 직후). 계획 이후 `recall/src/hook.ts`는 wp4(#127)가 `listCwdSessions` 호출부와 "Recent work — project" 문구를 바꿨고(cwd-context.ts repo_key 연합), `format.ts`·`cxc-ops/src/doctor.ts`·`hook-trust.ts`는 계획 시점과 동일하다. §4.1의 SessionStart 분기는 #127의 cwd-context 호출을 그대로 감싸고, `dedicatedToolsEnabled`는 A 감사 반영대로 `paths.ts codexHome()`을 쓴다. §4.4의 훅 결과 헬퍼는 `assertLegalHookResult({stdout, stderr, code})`(자식 프로세스 실행). §10 관측 항목은 C에서 실측해 receipt에 붙인다. B는 행이 아니라 심볼로 patch한다. 브랜치 `codex/memory-l1-wp6-native`.
+
+
+
+## A 감사 반영 (wp6 round 1, 2026-09-10)
+
+리뷰어(grok-4.6) GO-WITH-FIXES(blocker 3, Medium 1, Low 1). 구현 제약으로 접는다.
+
+1. resume 안내문은 기존 테스트가 요구하는 "recall is available" 문구를 유지한다(resume 전용 문구는 그 뒤에 덧붙이는 형태). `hook.test.ts:98-102`의 startup/resume/clear 루프는 그대로 통과해야 한다.
+2. `handleSessionStart`의 `dedicatedTools` 기본값은 이 머신 config(`[memories] dedicated_tools = true`)를 읽으므로, 기존 SessionStart 테스트(`hook.test.ts:74, :94` `/cxc chat search/`)는 `{ dedicatedTools: false }`를 명시하거나 테스트 홈의 config.toml 픽스처로 분기를 고정한다. §5의 "false/생략 → cxc chat search" 기대에서 "생략"을 뺀다. 분기 활성화 테스트는 두 값을 모두 명시.
+3. §4.2 활성화 행의 프롬프트를 `그때 그 작업 hook.ts MEMORY-WRITE-GATE`(현재 `detectRecallIntent` 참)로 바꾼다. `그때` 단독은 트리거가 아니다.
+4. Medium: §4.4 자식 프로세스 테스트는 `hook-e2e.test.mjs:10-16,124`의 `emptyCodexHome()` 패턴대로 `CODEX_HOME`·`CODEX_SQLITE_HOME`을 빈 임시 홈으로 고정한다(러너의 `CODEXCLAW_HOME`만으로는 라이브 `~/.codex`를 읽는다).
+5. Low: doctor WARN 강등이 바꾸는 기존 단언은 `hook-trust.test.ts:256`, `:543`(drifted FAIL→WARN, :240 제목)이고 `:527`(untrusted FAIL)은 유지.
+
diff --git a/devlog/_plan/260910_memory-followup-roadmap/076_wp6_receipt.md b/devlog/_plan/260910_memory-followup-roadmap/076_wp6_receipt.md
new file mode 100644
index 00000000..72985da3
--- /dev/null
+++ b/devlog/_plan/260910_memory-followup-roadmap/076_wp6_receipt.md
@@ -0,0 +1,39 @@
+# 076 — wp6 receipt: 네이티브 통합 보강
+
+날짜: 2026-09-10 (KST). 세션 01a0880e-149e-72d0-86db-53bd99bcac0e. 브랜치 `codex/memory-l1-wp6-native`(origin/dev 9384a4f5 위), 구현 커밋 `7f21ed65`. 구현은 opus-5 실행 서브에이전트, 검증·통합·관측은 메인.
+
+## 결론
+
+웹 조사(001 §3)가 권한 네 항목이 기존 훅과 인덱스 위에서 들어갔다. SessionStart는 source별로 브리핑 형태를 나누고 끝에 회수 안내 한 줄을 붙이며(`dedicated_tools = true`면 `memories.search`, 아니면 cxc 명령), UserPromptSubmit은 recall 의도가 잡힐 때 프롬프트에서 뽑은 표적 용어를 제안하고(검색 미실행), memory search 출력은 `[age: Nd]`·`[newer: ]` 라벨을 달며(랭킹 불변), 훅 출력 계약(exit 0·stderr ANSI 없음·stdout JSON 또는 빈 문자열)이 자식 프로세스 테스트로 고정됐다. doctor는 trusted_hash drift만 있으면 WARN, 항목 없음은 FAIL이다.
+
+## 무엇이 바뀌었나
+
+- `recall/src/hook.ts`: `handleSessionStart(…, opts?)` source 분기(startup/clear는 기존 문구, resume은 재개 문장 추가, compact는 기존 COMPACTED_BUDGET 경로) + 160자 캡 회수 안내; `dedicatedToolsEnabled()`(`codexHome()/config.toml` `[memories]` 테이블, fail-open false); `extractRecallTargets`(버전·파일명·오류 코드·CamelCase·따옴표, 최대 4) + "Suggested recall terms:"; 트리거 어휘 5개 추가(`그때` 단독 제외); `assertLegalHookResult` export.
+- `recall/src/format.ts`: `[age: Nd]`(nowMs 주입 가능), 다른 파일의 더 최신 히트가 같은 특징 토큰을 가지면 `[newer: …]`.
+- `cxc-ops/src/doctor.ts`·`hook-trust.ts`: drift만 WARN("trusted_hash drift (reinstall updates it; hooks still run)"), untrusted 포함 시 FAIL 유지, `file_sha256` 근거.
+- 테스트 +15(recall 191, cxc-ops 199), NEW `format-freshness.test.ts`, 자식 프로세스 계약 테스트(빈 임시 CODEX_HOME/CODEX_SQLITE_HOME/CODEXCLAW_HOME). dist 4파일. 배지 2993→3007.
+
+계획과의 차이: compact 브리핑은 두 줄 명령 대신 단일 회수 안내로 통일; resume 문구는 "recall is available" 뒤에 덧붙임(감사 제약 1); `fileSha256`은 `HookEntry`에 위치.
+
+## 증거
+
+- receipt `.codexclaw/evidence/01a0880e-149e-72d0-86db-53bd99bcac0e/test-receipt.json`: `/tmp/mfu-260910/check-wp6.sh` exit 0 @ 7f21ed65 — dist CLI로 SessionStart startup/resume/compact 3형태(350/429/336바이트, exit 0, ANSI 없음, 회수 안내 포함), config 픽스처 `dedicated_tools = true`에서 `memories.search` 안내, UserPromptSubmit 표적 제안(`hook.ts`·`2.49.0`), 실인덱스 `2.49.0 SLSA` 출력에 age 4·newer 3, 스위트 390 + 38 pass.
+- 이 머신의 doctor는 FAIL이다: #119 bg-wake·#116 fallback 훅이 `hooks.state`에 아직 없어(untrusted) 설계대로 FAIL, drift 1건은 같은 줄에 표시. 재설치 후 `cxc hooks retrust`가 처방이다.
+- 감사: grok-4.6 리뷰어 GO-WITH-FIXES(3 High + Medium + Low) → 구현 제약으로 접어 C 테스트가 확인.
+
+## §10 관측 항목 실측 (2026-09-10, 메인)
+
+| 항목 | 실측 |
+|---|---|
+| compaction 후 SessionStart 재점화 | 09-09 15:00 이후 rollout 526개 중 `compacted` 11건, 그 직후 6항목 안에 `[cxc-recall]`/compact 문맥 주입 7건(64%). 나머지 4건은 compaction 뒤 사용자 턴이 없었거나 SessionStart가 재발화되지 않은 것으로 보이며 원인 분리는 미완(unknown) |
+| `memories.search` 호출 빈도 | 같은 창에서 최상위 function_call로 `memor*` 이름 0건. Desktop이 `exec` 코드모드 안에서 호출하면 집계에 안 잡히므로 하한값이다(이 세션 자체가 `tools.memories__read`를 exec 안에서 썼다) |
+| rollout_summaries 상한 | 256개 = `max_raw_memories_for_consolidation` 상한 그대로(notes/02) |
+| `msgs_fts` 바이트 | `msgs_fts_data` 319,176 블록, 1,252,722,036 바이트(약 1.25GB, 인덱스 12.1GB의 10%) |
+| 훅 `--no-refresh`와 신규 워크트리 주입 | wp4로 워크트리가 같은 origin에 묶이므로 신규 슬롯에서도 메인 체크아웃 세션이 Recent work에 들어간다(074 골든). refresh 지연 자체는 별도 측정 안 함 |
+
+## 개선되지 않은 것 (LOOP-PESSIMIST-01)
+
+- 표적 회수 제안은 안내문일 뿐 검색을 대신 실행하지 않는다. 에이전트가 제안을 따르는 비율은 이 사이클이 측정하지 못한다.
+- 네이티브 요약 포화(92.5%)와 rollout_summaries 256 상한은 codexclaw 코드로 못 줄인다(000 wp7 결정 항목 그대로).
+- 방향이 틀렸다는 신호: 회수 안내 한 줄이 들어간 뒤에도 recall 도구 호출이 늘지 않으면 안내 위치(브리핑 끝)가 아니라 모델의 도구 선택 정책이 병목이다.
+
diff --git a/plugins/codexclaw/components/cxc-ops/dist/doctor.js b/plugins/codexclaw/components/cxc-ops/dist/doctor.js
index 4912de33..10f523e8 100644
--- a/plugins/codexclaw/components/cxc-ops/dist/doctor.js
+++ b/plugins/codexclaw/components/cxc-ops/dist/doctor.js
@@ -461,27 +461,41 @@ export function runHookTrustCheck(pluginRoot , options = {
// Distinguish "never trusted" from "drifted" so the repair line is the one
// the operator actually needs (issue #33).
const neverTrusted = failed.filter((result) => result.actual === null);
+ // Drift and absence are different facts. A recorded hash that no longer
+ // matches means the manifest moved on while the hooks kept running — the
+ // host decides trust from its own record, so codexclaw reporting this as a
+ // failure would paint every post-update machine red for a condition that
+ // blocks nothing (PLAN-BYPASS-NAMED-01). A MISSING entry is different: the
+ // hooks were never approved, and that stays a failure.
+ const driftedOnly = failed.length > 0 && neverTrusted.length === 0;
const repair =
failed.length === 0
? undefined
: neverTrusted.length === failed.length
? `${failed.length} hook(s) have no trust entry in ${join(codexHome, "config.toml")}; only Codex itself writes those on hook approval. Approve this plugin's hooks in Codex, or record them explicitly with: cxc hooks retrust --key ${pluginKey} --codex-home ${codexHome} --bootstrap-ok`
: `cxc hooks retrust --key ${pluginKey} --codex-home ${codexHome}`;
+ const failureDetail = failed
+ .map(
+ (result) =>
+ `${result.status} ${result.key} expected=${result.hash} actual=${result.actual ?? "(none)"} file_sha256=${result.fileSha256.slice(0, 16)}`,
+ )
+ .join("; ");
return {
name: "hook-trust",
// An EMPTY result set is not a pass. `diagnoseHookTrust` skips a handler it
// cannot hash (invalid matcher, empty command, async), so "0 failed" can also
// mean "0 examined" — a green check over hooks nobody verified.
- severity: results.length === 0 ? "WARN" : failed.length === 0 ? "PASS" : "FAIL",
+ severity:
+ results.length === 0 ? "WARN" : failed.length === 0 ? "PASS" : driftedOnly ? "WARN" : "FAIL",
repair,
evidence:
results.length === 0
? `no hook handler could be hashed for ${pluginKey}; nothing was verified`
: failed.length === 0
? `${results.length} hook hash(es) trusted for ${pluginKey}`
- : failed
- .map((result) => `${result.status} ${result.key} expected=${result.hash} actual=${result.actual ?? "(none)"}`)
- .join("; "),
+ : driftedOnly
+ ? `trusted_hash drift (reinstall updates it; hooks still run): ${failureDetail}`
+ : failureDetail,
};
} catch (error) {
return { name: "hook-trust", severity: "FAIL", evidence: error instanceof Error ? error.message : String(error) };
diff --git a/plugins/codexclaw/components/cxc-ops/dist/hook-trust.js b/plugins/codexclaw/components/cxc-ops/dist/hook-trust.js
index d59a18b9..1ce1da73 100644
--- a/plugins/codexclaw/components/cxc-ops/dist/hook-trust.js
+++ b/plugins/codexclaw/components/cxc-ops/dist/hook-trust.js
@@ -80,6 +80,12 @@ const MATCHER_DROPPED_EVENTS = new Set (["UserPromptSubmit", "Stop
+
+
+
+
+
+
@@ -166,7 +172,9 @@ export function listHookEntries(pluginRoot , pluginKey )
if (typeof hookRef !== "string") throw new Error("plugin manifest hook references must be strings");
const relativePath = normalizeHookPath(hookRef);
assertSafeHeaderValue(relativePath, "hook path");
- const document = JSON.parse(readFileSync(containedPluginFile(pluginRoot, relativePath), "utf8"))
+ const raw = readFileSync(containedPluginFile(pluginRoot, relativePath));
+ const fileSha256 = createHash("sha256").update(raw).digest("hex");
+ const document = JSON.parse(raw.toString("utf8"))
;
for (const [rawEventName, rawGroups] of Object.entries(document.hooks ?? {})) {
@@ -197,6 +205,7 @@ export function listHookEntries(pluginRoot , pluginKey )
entries.push({
key: `${pluginKey}:${relativePath}:${EVENT_LABELS[eventName]}:${groupIdx}:${handlerIdx}`,
hash: identityHash(eventName, group.matcher , handler),
+ fileSha256,
});
}
}
diff --git a/plugins/codexclaw/components/cxc-ops/src/doctor.ts b/plugins/codexclaw/components/cxc-ops/src/doctor.ts
index 098d0141..20fc9841 100644
--- a/plugins/codexclaw/components/cxc-ops/src/doctor.ts
+++ b/plugins/codexclaw/components/cxc-ops/src/doctor.ts
@@ -461,27 +461,41 @@ export function runHookTrustCheck(pluginRoot: string, options: DoctorOptions = {
// Distinguish "never trusted" from "drifted" so the repair line is the one
// the operator actually needs (issue #33).
const neverTrusted = failed.filter((result) => result.actual === null);
+ // Drift and absence are different facts. A recorded hash that no longer
+ // matches means the manifest moved on while the hooks kept running — the
+ // host decides trust from its own record, so codexclaw reporting this as a
+ // failure would paint every post-update machine red for a condition that
+ // blocks nothing (PLAN-BYPASS-NAMED-01). A MISSING entry is different: the
+ // hooks were never approved, and that stays a failure.
+ const driftedOnly = failed.length > 0 && neverTrusted.length === 0;
const repair =
failed.length === 0
? undefined
: neverTrusted.length === failed.length
? `${failed.length} hook(s) have no trust entry in ${join(codexHome, "config.toml")}; only Codex itself writes those on hook approval. Approve this plugin's hooks in Codex, or record them explicitly with: cxc hooks retrust --key ${pluginKey} --codex-home ${codexHome} --bootstrap-ok`
: `cxc hooks retrust --key ${pluginKey} --codex-home ${codexHome}`;
+ const failureDetail = failed
+ .map(
+ (result) =>
+ `${result.status} ${result.key} expected=${result.hash} actual=${result.actual ?? "(none)"} file_sha256=${result.fileSha256.slice(0, 16)}`,
+ )
+ .join("; ");
return {
name: "hook-trust",
// An EMPTY result set is not a pass. `diagnoseHookTrust` skips a handler it
// cannot hash (invalid matcher, empty command, async), so "0 failed" can also
// mean "0 examined" — a green check over hooks nobody verified.
- severity: results.length === 0 ? "WARN" : failed.length === 0 ? "PASS" : "FAIL",
+ severity:
+ results.length === 0 ? "WARN" : failed.length === 0 ? "PASS" : driftedOnly ? "WARN" : "FAIL",
repair,
evidence:
results.length === 0
? `no hook handler could be hashed for ${pluginKey}; nothing was verified`
: failed.length === 0
? `${results.length} hook hash(es) trusted for ${pluginKey}`
- : failed
- .map((result) => `${result.status} ${result.key} expected=${result.hash} actual=${result.actual ?? "(none)"}`)
- .join("; "),
+ : driftedOnly
+ ? `trusted_hash drift (reinstall updates it; hooks still run): ${failureDetail}`
+ : failureDetail,
};
} catch (error) {
return { name: "hook-trust", severity: "FAIL", evidence: error instanceof Error ? error.message : String(error) };
diff --git a/plugins/codexclaw/components/cxc-ops/src/hook-trust.ts b/plugins/codexclaw/components/cxc-ops/src/hook-trust.ts
index b45b9510..88e522c4 100644
--- a/plugins/codexclaw/components/cxc-ops/src/hook-trust.ts
+++ b/plugins/codexclaw/components/cxc-ops/src/hook-trust.ts
@@ -48,6 +48,12 @@ export interface HookHandler {
export interface HookEntry {
key: string;
hash: string;
+ /**
+ * sha256 of the hook JSON file's bytes. EVIDENCE ONLY — trust is decided by
+ * `hash` (the canonical event+matcher+handler identity), and this digest is
+ * expected to differ from a recorded trusted_hash even on a healthy install.
+ */
+ fileSha256: string;
}
export interface HookTrustResult extends HookEntry {
@@ -166,7 +172,9 @@ export function listHookEntries(pluginRoot: string, pluginKey: string): HookEntr
if (typeof hookRef !== "string") throw new Error("plugin manifest hook references must be strings");
const relativePath = normalizeHookPath(hookRef);
assertSafeHeaderValue(relativePath, "hook path");
- const document = JSON.parse(readFileSync(containedPluginFile(pluginRoot, relativePath), "utf8")) as {
+ const raw = readFileSync(containedPluginFile(pluginRoot, relativePath));
+ const fileSha256 = createHash("sha256").update(raw).digest("hex");
+ const document = JSON.parse(raw.toString("utf8")) as {
hooks?: Record;
};
for (const [rawEventName, rawGroups] of Object.entries(document.hooks ?? {})) {
@@ -197,6 +205,7 @@ export function listHookEntries(pluginRoot: string, pluginKey: string): HookEntr
entries.push({
key: `${pluginKey}:${relativePath}:${EVENT_LABELS[eventName]}:${groupIdx}:${handlerIdx}`,
hash: identityHash(eventName, group.matcher as string | undefined, handler),
+ fileSha256,
});
}
}
diff --git a/plugins/codexclaw/components/cxc-ops/test/hook-trust.test.ts b/plugins/codexclaw/components/cxc-ops/test/hook-trust.test.ts
index 6082f949..fe27ec0b 100644
--- a/plugins/codexclaw/components/cxc-ops/test/hook-trust.test.ts
+++ b/plugins/codexclaw/components/cxc-ops/test/hook-trust.test.ts
@@ -237,7 +237,7 @@ test("diagnoseHookTrust reports trusted, drifted, and untrusted independently",
assert.equal(diagnosed[2].actual, null);
});
-test("doctor hook-trust check warns on ambiguous keys and fails with per-hook drift evidence", () => {
+test("doctor hook-trust check warns on ambiguous keys and warns with per-hook drift evidence", () => {
const root = makePlugin({ hooks: { Stop: [{ hooks: [command("echo doctor")] }] } });
const entry = listHookEntries(root, "fixture@one")[0];
const ambiguousHome = makeCodexHome([
@@ -253,10 +253,16 @@ test("doctor hook-trust check warns on ambiguous keys and fails with per-hook dr
writeFileSync(join(ambiguousHome, "config.toml"), `${trustSection(entry, "sha256:stale")}`);
const drifted = runHookTrustCheck(root, { codexHome: ambiguousHome, pluginKey: "fixture@one" });
- assert.equal(drifted.severity, "FAIL");
+ // Drift means the recorded hash is stale, not that the hooks stopped running:
+ // the host decides trust from its own record (PLAN-BYPASS-NAMED-01).
+ assert.equal(drifted.severity, "WARN");
+ assert.match(drifted.evidence, /trusted_hash drift \(reinstall updates it; hooks still run\)/);
assert.match(drifted.evidence, new RegExp(entry.key.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")));
assert.match(drifted.evidence, new RegExp(`expected=${entry.hash}`));
assert.match(drifted.evidence, /actual=sha256:stale/);
+ // The file digest is evidence only — it never equals the identity hash.
+ assert.match(drifted.evidence, new RegExp(`file_sha256=${entry.fileSha256.slice(0, 16)}`));
+ assert.notEqual(`sha256:${entry.fileSha256}`, entry.hash);
});
test("retrustHooks replaces drift, appends missing sections, preserves unrelated bytes, and creates a backup", () => {
@@ -540,12 +546,29 @@ test("doctor's drift repair omits --bootstrap-ok, which would be the wrong advic
const entries = listHookEntries(root, PLUGIN_KEY);
const home = makeCodexHome(trustSection(entries[0], "sha256:stale"));
const check = runHookTrustCheck(root, { codexHome: home, pluginKey: PLUGIN_KEY });
- assert.equal(check.severity, "FAIL");
+ assert.equal(check.severity, "WARN");
assert.match(check.evidence, /drifted/);
assert.match(check.repair ?? "", /cxc hooks retrust/);
assert.ok(!(check.repair ?? "").includes("--bootstrap-ok"), "drift is not a bootstrap case");
});
+/**
+ * The WARN downgrade covers drift ALONE. A hook with no trust entry was never
+ * approved, and mixing one into a drifted set must not launder it into a warning.
+ */
+test("drift mixed with a missing trust entry still fails", () => {
+ const root = makePlugin({
+ hooks: { Stop: [{ hooks: [command("echo drifted")] }, { hooks: [command("echo missing")] }] },
+ });
+ const entries = listHookEntries(root, PLUGIN_KEY);
+ const home = makeCodexHome(trustSection(entries[0], "sha256:stale"));
+ const check = runHookTrustCheck(root, { codexHome: home, pluginKey: PLUGIN_KEY });
+ assert.equal(check.severity, "FAIL");
+ assert.match(check.evidence, /drifted/);
+ assert.match(check.evidence, /untrusted/);
+ assert.doesNotMatch(check.evidence, /hooks still run/);
+});
+
test("a fully trusted install carries no repair line", () => {
const root = makePlugin({ hooks: { Stop: [{ hooks: [command("echo trusted")] }] } });
const entries = listHookEntries(root, PLUGIN_KEY);
diff --git a/plugins/codexclaw/components/recall/dist/format.js b/plugins/codexclaw/components/recall/dist/format.js
index 46b6bb79..6e3d6e64 100644
--- a/plugins/codexclaw/components/recall/dist/format.js
+++ b/plugins/codexclaw/components/recall/dist/format.js
@@ -49,7 +49,63 @@ export function formatChatResult(result ) {
return lines.join("\n");
}
-export function formatMemoryResult(result ) {
+/**
+ * Tokens distinctive enough to say two hits are about the same thing: versions,
+ * source filenames, CamelCase symbols. Ordinary words are deliberately excluded —
+ * a shared "the" must never make one memory look like a correction of another.
+ */
+const TOPIC_TOKEN =
+ /\b\d+\.\d+(?:\.\d+)?\b|\b[\w.-]+\.(?:ts|tsx|js|mjs|json|md)\b|\b[A-Z][a-zA-Z]*[A-Z][A-Za-z0-9]*\b/g;
+
+/** Whole days between `updatedAt` and now; null when there is no usable stamp. */
+export function ageDays(updatedAt , nowMs ) {
+ if (!updatedAt) return null;
+ const stamp = Date.parse(updatedAt);
+ if (!Number.isFinite(stamp)) return null;
+ return Math.max(0, Math.floor((nowMs - stamp) / 86_400_000));
+}
+
+function topicTokens(hit ) {
+ const bag = `${hit.relpath} ${hit.excerpt}`;
+ const out = new Set ();
+ TOPIC_TOKEN.lastIndex = 0;
+ for (const match of bag.matchAll(TOPIC_TOKEN)) out.add(match[0].toLowerCase());
+ return out;
+}
+
+function sharesTopic(mine , theirs ) {
+ for (const token of mine) if (theirs.has(token)) return true;
+ return false;
+}
+
+/**
+ * The newest same-topic hit that sits in a DIFFERENT file, or null.
+ *
+ * Memory accumulates corrections: a 2.48 note and a 2.49 note both match
+ * "provenance", and relevance alone can rank the superseded one first. Ranking
+ * stays exactly as `rankAndTrim` left it — this only labels, so the reader can
+ * see that a fresher record of the same subject is in the same result set.
+ */
+export function newerRelpath(hit , hits ) {
+ if (!hit.updatedAt) return null;
+ const mine = Date.parse(hit.updatedAt);
+ if (!Number.isFinite(mine)) return null;
+ const myTopic = topicTokens(hit);
+ if (myTopic.size === 0) return null;
+ let best = null;
+ let bestStamp = mine;
+ for (const other of hits) {
+ if (other === hit || other.relpath === hit.relpath || !other.updatedAt) continue;
+ const stamp = Date.parse(other.updatedAt);
+ if (!Number.isFinite(stamp) || stamp <= bestStamp) continue;
+ if (!sharesTopic(myTopic, topicTokens(other))) continue;
+ best = other;
+ bestStamp = stamp;
+ }
+ return best ? best.relpath : null;
+}
+
+export function formatMemoryResult(result , nowMs = Date.now()) {
const lines = [];
lines.push(`# ${result.hits.length} memory hits (${result.scannedFiles} files scanned, ${result.elapsedMs}ms)`);
if (result.hits.length === 0) lines.push("(no matches)");
@@ -58,7 +114,13 @@ export function formatMemoryResult(result ) {
const loc = hit.startLine !== null ? `${hit.relpath}:${hit.startLine}` : hit.relpath;
const when = hit.updatedAt ? ` [${hit.updatedAt}]` : "";
const cwd = hit.cwd ? ` {${hit.cwd}}` : "";
- lines.push(`(${hit.origin}/${hit.kind}) ${loc}${when}${cwd}`);
+ // The ISO stamp says when; the age says how long ago, which is what decides
+ // whether a stored claim can still be asserted without checking it live.
+ const age = ageDays(hit.updatedAt, nowMs);
+ const ageBit = age === null ? "" : ` [age: ${age}d]`;
+ const newer = newerRelpath(hit, result.hits);
+ const newerBit = newer ? ` [newer: ${newer}]` : "";
+ lines.push(`(${hit.origin}/${hit.kind}) ${loc}${when}${cwd}${ageBit}${newerBit}`);
lines.push(clip(hit.excerpt, EXCERPT));
lines.push("---");
}
diff --git a/plugins/codexclaw/components/recall/dist/hook.js b/plugins/codexclaw/components/recall/dist/hook.js
index 384d6bcb..7a3b260b 100644
--- a/plugins/codexclaw/components/recall/dist/hook.js
+++ b/plugins/codexclaw/components/recall/dist/hook.js
@@ -29,8 +29,9 @@ import {
readHitCounts,
} from "./index-db.js";
import { cwdMatches, FOLD_CWD_CASE } from "./rollout.js";
-import { existsSync } from "node:fs";
-import { basename } from "node:path";
+import { codexHome } from "./paths.js";
+import { existsSync, readFileSync } from "node:fs";
+import { basename, join } from "node:path";
// Cross-component dist import (established precedent: messenger-bridge api-compat).
// Resolves from BOTH src (test-time ../../cxc-ops/dist) and shipped dist layouts.
// Cross-component dist import, LAZY + FAIL-OPEN (260724 WP1): the entry must keep
@@ -91,6 +92,13 @@ const RECALL_PATTERNS = [
/\b(as|we)\s+discussed\s+(earlier|before|previously|last\s+time)\b/i,
/\bdiscussed\s+previously\b/i,
/\bearlier\s+(session|conversation|work)\b/i,
+ // wp6: idioms the original set missed. Bare 그때 stays out — it reads as a
+ // plain time reference ("그때 봤어") far more often than as a recall request.
+ /이전에\s*(하|했|만든|작업|얘기|말)/,
+ /그\s*세션/,
+ /그때에(?:는|도)?/,
+ /\bprior\s+(work|session|conversation)\b/i,
+ /\ba\s+while\s+ago\b/i,
];
/**
@@ -107,16 +115,63 @@ export function detectRecallIntent(prompt ) {
return RECALL_PATTERNS.some((re) => re.test(prompt));
}
+/**
+ * Distinctive tokens worth searching for: versions, filenames, error/rule codes,
+ * CamelCase symbols, and short quoted strings. Regex only — this runs on the
+ * UserPromptSubmit path, which must return in well under its 5s hook budget, so
+ * it must never open an index or touch the corpus.
+ */
+const VERSION_RE = /\b\d+\.\d+(?:\.\d+)?\b/g;
+const FILE_RE = /\b[\w.-]+\.(?:ts|tsx|js|mjs|cjs|json|md|toml|py|rs)\b/g;
+const ERROR_RE = /\b(?:[A-Z]{2,}(?:-[A-Z0-9]+)+|ERR_[A-Z0-9_]+)\b/g;
+const CAMEL_RE = /\b[A-Z][a-zA-Z]*[A-Z][A-Za-z0-9]*\b/g;
+const QUOTED_RE = /["'\`]([^"'\n]{3,60})["'\`]/g;
+
+/** Recall idioms themselves are not search terms — they are why we are here. */
+const TARGET_STOP = new Set([
+ "그때", "지난번", "지난", "저번", "예전", "세션", "작업", "기억", "뭐였지",
+ "last", "time", "session", "previous", "previously", "remember",
+]);
+
+/** Max suggested terms. A long list is noise; the agent still writes the query. */
+const TARGET_CAP = 4;
+
+export function extractRecallTargets(prompt , cap = TARGET_CAP) {
+ const out = [];
+ const seen = new Set ();
+ const push = (raw ) => {
+ const term = raw.trim();
+ if (term.length < 2 || term.length > 60) return;
+ const key = term.toLowerCase();
+ if (TARGET_STOP.has(key) || seen.has(key)) return;
+ seen.add(key);
+ out.push(term);
+ };
+ for (const re of [VERSION_RE, FILE_RE, ERROR_RE, CAMEL_RE]) {
+ re.lastIndex = 0;
+ for (const match of prompt.matchAll(re)) push(match[0]);
+ }
+ QUOTED_RE.lastIndex = 0;
+ for (const match of prompt.matchAll(QUOTED_RE)) push(match[1] ?? "");
+ return out.slice(0, cap);
+}
+
// WHY a builder, not a const: the command prefix must be resolved per emit.
-function buildDirective() {
+function buildDirective(targets = []) {
const cxc = CXC();
- return [
+ const rows = [
"[cxc-recall] The prompt references past work. Before asking the user to re-explain,",
"search prior sessions (read-only):",
` ${cxc} chat search "" --days 0 # full-history FTS over ~/.codex`,
` ${cxc} memory search "" # durable per-thread summaries`,
"Add --context 2 to read around a hit, --cwd to scope. Details: $cxc-recall.",
- ].join("\n");
+ ];
+ // The hook suggests, it does not search: running a query here would spend the
+ // prompt's latency budget on a guess the agent may not need.
+ if (targets.length > 0) {
+ rows.push(`Suggested recall terms: ${targets.join(" ")} (search not run by this hook).`);
+ }
+ return rows.join("\n");
}
const MAX_CTX = 32_768;
@@ -139,7 +194,7 @@ export function handleUserPromptSubmit(payload )
if (payload.hook_event_name !== "UserPromptSubmit") return "";
const prompt = typeof payload.prompt === "string" ? payload.prompt : "";
if (!detectRecallIntent(prompt)) return "";
- return buildContextOutput("UserPromptSubmit", buildDirective());
+ return buildContextOutput("UserPromptSubmit", buildDirective(extractRecallTargets(prompt)));
} catch {
return "";
}
@@ -495,6 +550,91 @@ export function buildCwdContext(
}
}
+/** Injected by tests so a unit assertion never depends on the operator's config. */
+
+
+
+
+/**
+ * Is the native memories tool surface switched on for this Codex home?
+ *
+ * The recall hook has no other way to know: installation writes the managed key
+ * into config.toml, and nothing hands it to the hook on stdin. Resolution goes
+ * through paths.ts codexHome() (CODEX_HOME ?? ~/.codex) because CODEX_HOME is
+ * normally unset — reading the variable alone would leave this branch dead on
+ * every default install. Any read or parse problem is false: pointing at the
+ * `cxc` commands is correct whether or not the tools exist, while naming a tool
+ * the agent does not have is not.
+ */
+export function dedicatedToolsEnabled(home ) {
+ try {
+ const text = readFileSync(join(home ?? codexHome(), "config.toml"), "utf8");
+ const body = memoriesTableBody(text);
+ if (body === null) return false;
+ return /^[ \t]*dedicated_tools[ \t]*=[ \t]*true[ \t]*(?:#.*)?$/m.test(body);
+ } catch {
+ return false;
+ }
+}
+
+/** Body of the `[memories]` table, or null when the table is absent. */
+function memoriesTableBody(text ) {
+ const rows = text.split(/\r?\n/);
+ const start = rows.findIndex((line) => /^[ \t]*\[memories\][ \t]*(?:#.*)?$/.test(line));
+ if (start === -1) return null;
+ const rest = rows.slice(start + 1);
+ const end = rest.findIndex((line) => /^[ \t]*\[/.test(line));
+ return (end === -1 ? rest : rest.slice(0, end)).join("\n");
+}
+
+/**
+ * One line, hard-capped: a compaction just paid to free context, so the pointer
+ * that follows it must not start refilling the window.
+ */
+const RECOVERY_LINE_BUDGET = 160;
+
+function recoveryLine(cxc , dedicatedTools ) {
+ const line = dedicatedTools
+ ? `Recall: memories.search "" (native tool). Also: ${cxc} memory search ""`
+ : `Recall: ${cxc} chat search "" --days 0 | ${cxc} memory search ""`;
+ return line.length <= RECOVERY_LINE_BUDGET ? line : line.slice(0, RECOVERY_LINE_BUDGET);
+}
+
+/**
+ * The notice, shaped by why the session started.
+ *
+ * `compact` is a recovery moment: the detail the agent is missing was just
+ * dropped from a context it already had. `resume` keeps the availability
+ * wording — the agent has not seen this thread's history in this process — and
+ * adds why the gap exists. `startup` and `clear` are the plain availability
+ * form. Every shape ends on the same recall pointer.
+ */
+function sessionNotice(
+ source ,
+ cxc ,
+ status ,
+ dedicatedTools ,
+) {
+ const src = source ?? "startup";
+ const rows =
+ src === "compact"
+ ? [
+ "[cxc-recall] Context was just compacted. If any earlier detail is now missing,",
+ "recover it from past sessions before asking the user to repeat themselves.",
+ ]
+ : [
+ "[cxc-recall] Past-session recall is available (read-only). Before asking the user",
+ "about prior work \u2014 unfamiliar terms, lost context, \"\uadf8\ub54c/\uc9c0\ub09c\ubc88/last time\" \u2014 recover it.",
+ ];
+ if (src === "resume") {
+ rows.push("This session was resumed after a pause, so earlier turns may be missing here.");
+ }
+ rows.push(recoveryLine(cxc, dedicatedTools));
+ if (status !== "") rows.push(`Index: ${status}. Details: $cxc-recall.`);
+ else rows.push("Details: $cxc-recall.");
+ return rows.join("\n");
+}
+
/**
* SessionStart: inject CWD-scoped recent work context + recall availability notice.
* The `cwd` comes from the hook JSON payload; `status` is the index status line.
@@ -504,7 +644,12 @@ export function buildCwdContext(
* after a compaction), which is where the post-compaction recovery directive is
* delivered — PostCompact output itself cannot carry it (see handlePostCompact).
*/
-export function handleSessionStart(status , cwd , source ) {
+export function handleSessionStart(
+ status ,
+ cwd ,
+ source ,
+ opts = {},
+) {
const parts = [];
const compacted = source === "compact";
@@ -515,25 +660,10 @@ export function handleSessionStart(status , cwd , source
if (cwdCtx) parts.push(cwdCtx);
}
- const cxc = CXC();
- // Recall availability notice (pointer). After a compaction the same pointer is
- // framed as recovery: the detail the agent is missing was just dropped from the
- // context window, not never seen.
- const notice = compacted
- ? [
- "[cxc-recall] Context was just compacted. If any earlier detail is now missing,",
- "recover it from past sessions before asking the user to repeat themselves:",
- ` ${cxc} chat search "" --days 0 --context 2`,
- ` ${cxc} memory search ""`,
- ]
- : [
- "[cxc-recall] Past-session recall is available (read-only). Before asking the user",
- "about prior work \u2014 unfamiliar terms, lost context, \"\uadf8\ub54c/\uc9c0\ub09c\ubc88/last time\" \u2014 run:",
- ` ${cxc} chat search "" --days 0 | ${cxc} memory search ""`,
- ];
- if (status !== "") notice.push(`Index: ${status}. Details: $cxc-recall.`);
- else notice.push("Details: $cxc-recall.");
- parts.push(notice.join("\n"));
+ // Absent injection means "ask the machine": the branch must stay reachable on a
+ // default install, where nothing sets CODEX_HOME.
+ const dedicatedTools = opts.dedicatedTools ?? dedicatedToolsEnabled();
+ parts.push(sessionNotice(source, CXC(), status, dedicatedTools));
return buildContextOutput("SessionStart", parts.join("\n\n"));
}
@@ -558,3 +688,32 @@ export function handlePostCompact(cwd ) {
void cwd;
return "";
}
+
+/** One hook process run, as the runtime sees it. */
+
+
+
+
+
+
+/**
+ * The whole stdout/stderr/exit contract a Codex hook must satisfy, in one place
+ * so every handler can be held to it.
+ *
+ * claude-mem #621 is the failure this guards: a hook that printed a colored
+ * progress line and exited non-zero broke SessionStart for every session, because
+ * the runtime parses what the process wrote. So the legal shapes are exactly two —
+ * write nothing, or write one JSON object — and the process exits 0 either way.
+ * stderr is checked too: that is where the upstream break actually emitted its
+ * ANSI.
+ */
+export function assertLegalHookResult(result ) {
+ if (result.code !== 0) throw new Error(`hook exited ${result.code}`);
+ if (/\x1b\[/.test(result.stderr)) throw new Error("hook wrote ANSI to stderr");
+ if (result.stdout === "") return;
+ if (/\x1b\[/.test(result.stdout)) throw new Error("hook wrote ANSI to stdout");
+ const parsed = JSON.parse(result.stdout);
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
+ throw new Error("hook stdout is not a JSON object");
+ }
+}
diff --git a/plugins/codexclaw/components/recall/src/format.ts b/plugins/codexclaw/components/recall/src/format.ts
index 68540df7..8ad9fe37 100644
--- a/plugins/codexclaw/components/recall/src/format.ts
+++ b/plugins/codexclaw/components/recall/src/format.ts
@@ -9,7 +9,7 @@
* ---
*/
import type { ChatSearchResult, ChatHit } from "./chat-search.ts";
-import type { MemorySearchResult } from "./memory-search.ts";
+import type { MemoryHit, MemorySearchResult } from "./memory-search.ts";
const EXCERPT = 300;
@@ -49,7 +49,63 @@ export function formatChatResult(result: ChatSearchResult): string {
return lines.join("\n");
}
-export function formatMemoryResult(result: MemorySearchResult): string {
+/**
+ * Tokens distinctive enough to say two hits are about the same thing: versions,
+ * source filenames, CamelCase symbols. Ordinary words are deliberately excluded —
+ * a shared "the" must never make one memory look like a correction of another.
+ */
+const TOPIC_TOKEN =
+ /\b\d+\.\d+(?:\.\d+)?\b|\b[\w.-]+\.(?:ts|tsx|js|mjs|json|md)\b|\b[A-Z][a-zA-Z]*[A-Z][A-Za-z0-9]*\b/g;
+
+/** Whole days between `updatedAt` and now; null when there is no usable stamp. */
+export function ageDays(updatedAt: string | null, nowMs: number): number | null {
+ if (!updatedAt) return null;
+ const stamp = Date.parse(updatedAt);
+ if (!Number.isFinite(stamp)) return null;
+ return Math.max(0, Math.floor((nowMs - stamp) / 86_400_000));
+}
+
+function topicTokens(hit: MemoryHit): Set {
+ const bag = `${hit.relpath} ${hit.excerpt}`;
+ const out = new Set();
+ TOPIC_TOKEN.lastIndex = 0;
+ for (const match of bag.matchAll(TOPIC_TOKEN)) out.add(match[0].toLowerCase());
+ return out;
+}
+
+function sharesTopic(mine: Set, theirs: Set): boolean {
+ for (const token of mine) if (theirs.has(token)) return true;
+ return false;
+}
+
+/**
+ * The newest same-topic hit that sits in a DIFFERENT file, or null.
+ *
+ * Memory accumulates corrections: a 2.48 note and a 2.49 note both match
+ * "provenance", and relevance alone can rank the superseded one first. Ranking
+ * stays exactly as `rankAndTrim` left it — this only labels, so the reader can
+ * see that a fresher record of the same subject is in the same result set.
+ */
+export function newerRelpath(hit: MemoryHit, hits: readonly MemoryHit[]): string | null {
+ if (!hit.updatedAt) return null;
+ const mine = Date.parse(hit.updatedAt);
+ if (!Number.isFinite(mine)) return null;
+ const myTopic = topicTokens(hit);
+ if (myTopic.size === 0) return null;
+ let best: MemoryHit | null = null;
+ let bestStamp = mine;
+ for (const other of hits) {
+ if (other === hit || other.relpath === hit.relpath || !other.updatedAt) continue;
+ const stamp = Date.parse(other.updatedAt);
+ if (!Number.isFinite(stamp) || stamp <= bestStamp) continue;
+ if (!sharesTopic(myTopic, topicTokens(other))) continue;
+ best = other;
+ bestStamp = stamp;
+ }
+ return best ? best.relpath : null;
+}
+
+export function formatMemoryResult(result: MemorySearchResult, nowMs = Date.now()): string {
const lines: string[] = [];
lines.push(`# ${result.hits.length} memory hits (${result.scannedFiles} files scanned, ${result.elapsedMs}ms)`);
if (result.hits.length === 0) lines.push("(no matches)");
@@ -58,7 +114,13 @@ export function formatMemoryResult(result: MemorySearchResult): string {
const loc = hit.startLine !== null ? `${hit.relpath}:${hit.startLine}` : hit.relpath;
const when = hit.updatedAt ? ` [${hit.updatedAt}]` : "";
const cwd = hit.cwd ? ` {${hit.cwd}}` : "";
- lines.push(`(${hit.origin}/${hit.kind}) ${loc}${when}${cwd}`);
+ // The ISO stamp says when; the age says how long ago, which is what decides
+ // whether a stored claim can still be asserted without checking it live.
+ const age = ageDays(hit.updatedAt, nowMs);
+ const ageBit = age === null ? "" : ` [age: ${age}d]`;
+ const newer = newerRelpath(hit, result.hits);
+ const newerBit = newer ? ` [newer: ${newer}]` : "";
+ lines.push(`(${hit.origin}/${hit.kind}) ${loc}${when}${cwd}${ageBit}${newerBit}`);
lines.push(clip(hit.excerpt, EXCERPT));
lines.push("---");
}
diff --git a/plugins/codexclaw/components/recall/src/hook.ts b/plugins/codexclaw/components/recall/src/hook.ts
index 452e02e0..cec932f3 100644
--- a/plugins/codexclaw/components/recall/src/hook.ts
+++ b/plugins/codexclaw/components/recall/src/hook.ts
@@ -29,8 +29,9 @@ import {
readHitCounts,
} from "./index-db.ts";
import { cwdMatches, FOLD_CWD_CASE } from "./rollout.ts";
-import { existsSync } from "node:fs";
-import { basename } from "node:path";
+import { codexHome } from "./paths.ts";
+import { existsSync, readFileSync } from "node:fs";
+import { basename, join } from "node:path";
// Cross-component dist import (established precedent: messenger-bridge api-compat).
// Resolves from BOTH src (test-time ../../cxc-ops/dist) and shipped dist layouts.
// Cross-component dist import, LAZY + FAIL-OPEN (260724 WP1): the entry must keep
@@ -91,6 +92,13 @@ const RECALL_PATTERNS: readonly RegExp[] = [
/\b(as|we)\s+discussed\s+(earlier|before|previously|last\s+time)\b/i,
/\bdiscussed\s+previously\b/i,
/\bearlier\s+(session|conversation|work)\b/i,
+ // wp6: idioms the original set missed. Bare 그때 stays out — it reads as a
+ // plain time reference ("그때 봤어") far more often than as a recall request.
+ /이전에\s*(하|했|만든|작업|얘기|말)/,
+ /그\s*세션/,
+ /그때에(?:는|도)?/,
+ /\bprior\s+(work|session|conversation)\b/i,
+ /\ba\s+while\s+ago\b/i,
];
/**
@@ -107,16 +115,63 @@ export function detectRecallIntent(prompt: string): boolean {
return RECALL_PATTERNS.some((re) => re.test(prompt));
}
+/**
+ * Distinctive tokens worth searching for: versions, filenames, error/rule codes,
+ * CamelCase symbols, and short quoted strings. Regex only — this runs on the
+ * UserPromptSubmit path, which must return in well under its 5s hook budget, so
+ * it must never open an index or touch the corpus.
+ */
+const VERSION_RE = /\b\d+\.\d+(?:\.\d+)?\b/g;
+const FILE_RE = /\b[\w.-]+\.(?:ts|tsx|js|mjs|cjs|json|md|toml|py|rs)\b/g;
+const ERROR_RE = /\b(?:[A-Z]{2,}(?:-[A-Z0-9]+)+|ERR_[A-Z0-9_]+)\b/g;
+const CAMEL_RE = /\b[A-Z][a-zA-Z]*[A-Z][A-Za-z0-9]*\b/g;
+const QUOTED_RE = /["'\`]([^"'\n]{3,60})["'\`]/g;
+
+/** Recall idioms themselves are not search terms — they are why we are here. */
+const TARGET_STOP = new Set([
+ "그때", "지난번", "지난", "저번", "예전", "세션", "작업", "기억", "뭐였지",
+ "last", "time", "session", "previous", "previously", "remember",
+]);
+
+/** Max suggested terms. A long list is noise; the agent still writes the query. */
+const TARGET_CAP = 4;
+
+export function extractRecallTargets(prompt: string, cap = TARGET_CAP): string[] {
+ const out: string[] = [];
+ const seen = new Set();
+ const push = (raw: string): void => {
+ const term = raw.trim();
+ if (term.length < 2 || term.length > 60) return;
+ const key = term.toLowerCase();
+ if (TARGET_STOP.has(key) || seen.has(key)) return;
+ seen.add(key);
+ out.push(term);
+ };
+ for (const re of [VERSION_RE, FILE_RE, ERROR_RE, CAMEL_RE]) {
+ re.lastIndex = 0;
+ for (const match of prompt.matchAll(re)) push(match[0]);
+ }
+ QUOTED_RE.lastIndex = 0;
+ for (const match of prompt.matchAll(QUOTED_RE)) push(match[1] ?? "");
+ return out.slice(0, cap);
+}
+
// WHY a builder, not a const: the command prefix must be resolved per emit.
-function buildDirective(): string {
+function buildDirective(targets: readonly string[] = []): string {
const cxc = CXC();
- return [
+ const rows = [
"[cxc-recall] The prompt references past work. Before asking the user to re-explain,",
"search prior sessions (read-only):",
` ${cxc} chat search "" --days 0 # full-history FTS over ~/.codex`,
` ${cxc} memory search "" # durable per-thread summaries`,
"Add --context 2 to read around a hit, --cwd to scope. Details: $cxc-recall.",
- ].join("\n");
+ ];
+ // The hook suggests, it does not search: running a query here would spend the
+ // prompt's latency budget on a guess the agent may not need.
+ if (targets.length > 0) {
+ rows.push(`Suggested recall terms: ${targets.join(" ")} (search not run by this hook).`);
+ }
+ return rows.join("\n");
}
const MAX_CTX = 32_768;
@@ -139,7 +194,7 @@ export function handleUserPromptSubmit(payload: UserPromptSubmitPayload): string
if (payload.hook_event_name !== "UserPromptSubmit") return "";
const prompt = typeof payload.prompt === "string" ? payload.prompt : "";
if (!detectRecallIntent(prompt)) return "";
- return buildContextOutput("UserPromptSubmit", buildDirective());
+ return buildContextOutput("UserPromptSubmit", buildDirective(extractRecallTargets(prompt)));
} catch {
return "";
}
@@ -495,6 +550,91 @@ export function buildCwdContext(
}
}
+/** Injected by tests so a unit assertion never depends on the operator's config. */
+export interface SessionStartOptions {
+ dedicatedTools?: boolean;
+}
+
+/**
+ * Is the native memories tool surface switched on for this Codex home?
+ *
+ * The recall hook has no other way to know: installation writes the managed key
+ * into config.toml, and nothing hands it to the hook on stdin. Resolution goes
+ * through paths.ts codexHome() (CODEX_HOME ?? ~/.codex) because CODEX_HOME is
+ * normally unset — reading the variable alone would leave this branch dead on
+ * every default install. Any read or parse problem is false: pointing at the
+ * `cxc` commands is correct whether or not the tools exist, while naming a tool
+ * the agent does not have is not.
+ */
+export function dedicatedToolsEnabled(home?: string): boolean {
+ try {
+ const text = readFileSync(join(home ?? codexHome(), "config.toml"), "utf8");
+ const body = memoriesTableBody(text);
+ if (body === null) return false;
+ return /^[ \t]*dedicated_tools[ \t]*=[ \t]*true[ \t]*(?:#.*)?$/m.test(body);
+ } catch {
+ return false;
+ }
+}
+
+/** Body of the `[memories]` table, or null when the table is absent. */
+function memoriesTableBody(text: string): string | null {
+ const rows = text.split(/\r?\n/);
+ const start = rows.findIndex((line) => /^[ \t]*\[memories\][ \t]*(?:#.*)?$/.test(line));
+ if (start === -1) return null;
+ const rest = rows.slice(start + 1);
+ const end = rest.findIndex((line) => /^[ \t]*\[/.test(line));
+ return (end === -1 ? rest : rest.slice(0, end)).join("\n");
+}
+
+/**
+ * One line, hard-capped: a compaction just paid to free context, so the pointer
+ * that follows it must not start refilling the window.
+ */
+const RECOVERY_LINE_BUDGET = 160;
+
+function recoveryLine(cxc: string, dedicatedTools: boolean): string {
+ const line = dedicatedTools
+ ? `Recall: memories.search "" (native tool). Also: ${cxc} memory search ""`
+ : `Recall: ${cxc} chat search "" --days 0 | ${cxc} memory search ""`;
+ return line.length <= RECOVERY_LINE_BUDGET ? line : line.slice(0, RECOVERY_LINE_BUDGET);
+}
+
+/**
+ * The notice, shaped by why the session started.
+ *
+ * `compact` is a recovery moment: the detail the agent is missing was just
+ * dropped from a context it already had. `resume` keeps the availability
+ * wording — the agent has not seen this thread's history in this process — and
+ * adds why the gap exists. `startup` and `clear` are the plain availability
+ * form. Every shape ends on the same recall pointer.
+ */
+function sessionNotice(
+ source: string | undefined,
+ cxc: string,
+ status: string,
+ dedicatedTools: boolean,
+): string {
+ const src = source ?? "startup";
+ const rows =
+ src === "compact"
+ ? [
+ "[cxc-recall] Context was just compacted. If any earlier detail is now missing,",
+ "recover it from past sessions before asking the user to repeat themselves.",
+ ]
+ : [
+ "[cxc-recall] Past-session recall is available (read-only). Before asking the user",
+ "about prior work \u2014 unfamiliar terms, lost context, \"\uadf8\ub54c/\uc9c0\ub09c\ubc88/last time\" \u2014 recover it.",
+ ];
+ if (src === "resume") {
+ rows.push("This session was resumed after a pause, so earlier turns may be missing here.");
+ }
+ rows.push(recoveryLine(cxc, dedicatedTools));
+ if (status !== "") rows.push(`Index: ${status}. Details: $cxc-recall.`);
+ else rows.push("Details: $cxc-recall.");
+ return rows.join("\n");
+}
+
/**
* SessionStart: inject CWD-scoped recent work context + recall availability notice.
* The `cwd` comes from the hook JSON payload; `status` is the index status line.
@@ -504,7 +644,12 @@ export function buildCwdContext(
* after a compaction), which is where the post-compaction recovery directive is
* delivered — PostCompact output itself cannot carry it (see handlePostCompact).
*/
-export function handleSessionStart(status: string, cwd?: string, source?: string): string {
+export function handleSessionStart(
+ status: string,
+ cwd?: string,
+ source?: string,
+ opts: SessionStartOptions = {},
+): string {
const parts: string[] = [];
const compacted = source === "compact";
@@ -515,25 +660,10 @@ export function handleSessionStart(status: string, cwd?: string, source?: string
if (cwdCtx) parts.push(cwdCtx);
}
- const cxc = CXC();
- // Recall availability notice (pointer). After a compaction the same pointer is
- // framed as recovery: the detail the agent is missing was just dropped from the
- // context window, not never seen.
- const notice = compacted
- ? [
- "[cxc-recall] Context was just compacted. If any earlier detail is now missing,",
- "recover it from past sessions before asking the user to repeat themselves:",
- ` ${cxc} chat search "" --days 0 --context 2`,
- ` ${cxc} memory search ""`,
- ]
- : [
- "[cxc-recall] Past-session recall is available (read-only). Before asking the user",
- "about prior work \u2014 unfamiliar terms, lost context, \"\uadf8\ub54c/\uc9c0\ub09c\ubc88/last time\" \u2014 run:",
- ` ${cxc} chat search "" --days 0 | ${cxc} memory search ""`,
- ];
- if (status !== "") notice.push(`Index: ${status}. Details: $cxc-recall.`);
- else notice.push("Details: $cxc-recall.");
- parts.push(notice.join("\n"));
+ // Absent injection means "ask the machine": the branch must stay reachable on a
+ // default install, where nothing sets CODEX_HOME.
+ const dedicatedTools = opts.dedicatedTools ?? dedicatedToolsEnabled();
+ parts.push(sessionNotice(source, CXC(), status, dedicatedTools));
return buildContextOutput("SessionStart", parts.join("\n\n"));
}
@@ -558,3 +688,32 @@ export function handlePostCompact(cwd?: string): string {
void cwd;
return "";
}
+
+/** One hook process run, as the runtime sees it. */
+export interface HookResult {
+ stdout: string;
+ stderr: string;
+ code: number;
+}
+
+/**
+ * The whole stdout/stderr/exit contract a Codex hook must satisfy, in one place
+ * so every handler can be held to it.
+ *
+ * claude-mem #621 is the failure this guards: a hook that printed a colored
+ * progress line and exited non-zero broke SessionStart for every session, because
+ * the runtime parses what the process wrote. So the legal shapes are exactly two —
+ * write nothing, or write one JSON object — and the process exits 0 either way.
+ * stderr is checked too: that is where the upstream break actually emitted its
+ * ANSI.
+ */
+export function assertLegalHookResult(result: HookResult): void {
+ if (result.code !== 0) throw new Error(`hook exited ${result.code}`);
+ if (/\x1b\[/.test(result.stderr)) throw new Error("hook wrote ANSI to stderr");
+ if (result.stdout === "") return;
+ if (/\x1b\[/.test(result.stdout)) throw new Error("hook wrote ANSI to stdout");
+ const parsed: unknown = JSON.parse(result.stdout);
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
+ throw new Error("hook stdout is not a JSON object");
+ }
+}
diff --git a/plugins/codexclaw/components/recall/test/format-freshness.test.ts b/plugins/codexclaw/components/recall/test/format-freshness.test.ts
new file mode 100644
index 00000000..33e8d888
--- /dev/null
+++ b/plugins/codexclaw/components/recall/test/format-freshness.test.ts
@@ -0,0 +1,139 @@
+/**
+ * format-freshness.test.ts — wp6 item 3: the memory search text output carries
+ * an age per hit and points a superseded hit at the newer record of the same
+ * topic. Labels only: ranking belongs to memory-search's rankAndTrim, and this
+ * suite pins that the formatter never reorders what it was handed.
+ */
+import test from "node:test";
+import assert from "node:assert/strict";
+import { ageDays, formatMemoryResult, newerRelpath } from "../src/format.ts";
+import type { MemoryHit, MemorySearchResult } from "../src/memory-search.ts";
+
+const NOW = Date.parse("2026-09-10T00:00:00Z");
+const DAY = 86_400_000;
+
+function hit(
+ partial: Partial & Pick,
+): MemoryHit {
+ return {
+ origin: "file",
+ kind: "handbook",
+ threadId: null,
+ startLine: 1,
+ cwd: null,
+ score: 1,
+ ...partial,
+ };
+}
+
+function resultOf(hits: MemoryHit[]): MemorySearchResult {
+ return { hits, warnings: [], scannedFiles: hits.length, elapsedMs: 1 };
+}
+
+test("ageDays: ISO stamps become whole days, anything unusable stays unlabeled", () => {
+ assert.equal(ageDays(new Date(NOW - 3 * DAY).toISOString(), NOW), 3);
+ assert.equal(ageDays(new Date(NOW - 3 * DAY - 1000).toISOString(), NOW), 3, "floors, not rounds");
+ assert.equal(ageDays(new Date(NOW).toISOString(), NOW), 0);
+ assert.equal(ageDays(new Date(NOW + 5 * DAY).toISOString(), NOW), 0, "a future stamp is not negative");
+ assert.equal(ageDays(null, NOW), null);
+ assert.equal(ageDays("not a date", NOW), null);
+});
+
+test("a hit without a timestamp carries no age label", () => {
+ const text = formatMemoryResult(
+ resultOf([hit({ relpath: "MEMORY.md", excerpt: "no stamp here", updatedAt: null })]),
+ NOW,
+ );
+ assert.doesNotMatch(text, /\[age:/);
+ assert.match(text, /MEMORY\.md/);
+});
+
+test("the older same-topic hit points at the newer relpath, and order is unchanged", () => {
+ const older = hit({
+ relpath: "rollout_summaries/old.md",
+ excerpt: "2.49.0 provenance check",
+ updatedAt: new Date(NOW - 14 * DAY).toISOString(),
+ score: 9,
+ });
+ const newer = hit({
+ relpath: "MEMORY.md",
+ excerpt: "2.49.0 provenance verified",
+ updatedAt: new Date(NOW - 1 * DAY).toISOString(),
+ score: 3,
+ });
+ // Ranking already put the older, higher-scoring hit first; that is the case
+ // the label exists for.
+ const hits = [older, newer];
+ assert.equal(newerRelpath(older, hits), "MEMORY.md");
+ assert.equal(newerRelpath(newer, hits), null);
+
+ const before = hits.map((h) => h.relpath);
+ const text = formatMemoryResult(resultOf(hits), NOW);
+ assert.deepEqual(hits.map((h) => h.relpath), before, "format must not reorder or mutate hits");
+ const first = text.indexOf("rollout_summaries/old.md");
+ const second = text.indexOf("MEMORY.md");
+ assert.ok(first >= 0 && second > first, "the rendered order follows the input order");
+ assert.match(text, /\[age: 14d\]/);
+ assert.match(text, /\[age: 1d\]/);
+ assert.match(text, /\[newer: MEMORY\.md\]/);
+ assert.equal((text.match(/\[newer:/g) ?? []).length, 1, "only the superseded hit is labelled");
+});
+
+test("hits that share no distinctive token are not called corrections of each other", () => {
+ const alpha = hit({
+ relpath: "a.md",
+ excerpt: "alpha notes about the intake form",
+ updatedAt: new Date(NOW - 30 * DAY).toISOString(),
+ });
+ const beta = hit({
+ relpath: "b.md",
+ excerpt: "beta notes about the outbox",
+ updatedAt: new Date(NOW - 2 * DAY).toISOString(),
+ });
+ assert.equal(newerRelpath(alpha, [alpha, beta]), null);
+ assert.doesNotMatch(formatMemoryResult(resultOf([alpha, beta]), NOW), /\[newer:/);
+});
+
+test("a newer hit in the SAME file is a longer record, not a correction", () => {
+ const first = hit({
+ relpath: "MEMORY.md",
+ excerpt: "2.49.0 provenance check",
+ updatedAt: new Date(NOW - 9 * DAY).toISOString(),
+ });
+ const second = hit({
+ relpath: "MEMORY.md",
+ excerpt: "2.49.0 provenance rerun",
+ updatedAt: new Date(NOW - 1 * DAY).toISOString(),
+ startLine: 40,
+ });
+ assert.equal(newerRelpath(first, [first, second]), null);
+});
+
+test("the existing envelope survives: header, location, excerpt, delimiter, warnings", () => {
+ const text = formatMemoryResult(
+ {
+ hits: [
+ hit({
+ relpath: "MEMORY.md",
+ excerpt: "wp6 recall labels",
+ updatedAt: new Date(NOW - 2 * DAY).toISOString(),
+ startLine: 12,
+ cwd: "/repo/current",
+ }),
+ ],
+ warnings: ["index is stale"],
+ scannedFiles: 4,
+ elapsedMs: 7,
+ },
+ NOW,
+ );
+ assert.match(text, /^# 1 memory hits \(4 files scanned, 7ms\)/);
+ assert.match(text, /\(file\/handbook\) MEMORY\.md:12 \[2026-09-08T00:00:00\.000Z\] \{\/repo\/current\} \[age: 2d\]/);
+ assert.match(text, /^wp6 recall labels$/m);
+ assert.match(text, /^---$/m);
+ assert.match(text, /index is stale/);
+
+ const empty = formatMemoryResult(resultOf([]), NOW);
+ assert.match(empty, /^# 0 memory hits/);
+ assert.match(empty, /\(no matches\)/);
+});
diff --git a/plugins/codexclaw/components/recall/test/hook.test.ts b/plugins/codexclaw/components/recall/test/hook.test.ts
index b79fdf1b..bc71c10a 100644
--- a/plugins/codexclaw/components/recall/test/hook.test.ts
+++ b/plugins/codexclaw/components/recall/test/hook.test.ts
@@ -1,11 +1,19 @@
import test from "node:test";
import assert from "node:assert/strict";
+import { spawnSync } from "node:child_process";
+import { existsSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import { fileURLToPath } from "node:url";
// Pin the cxc-resolve seam (B1): assertions below expect literal `cxc ...`
// command lines, which would otherwise depend on the runner's PATH.
process.env.CODEXCLAW_CXC = "cxc";
import {
detectRecallIntent,
+ dedicatedToolsEnabled,
+ extractRecallTargets,
+ assertLegalHookResult,
handleUserPromptSubmit,
handleSessionStart,
handlePostCompact,
@@ -69,11 +77,17 @@ test("handler emits the pabcd-parity envelope only for recall intents", () => {
});
test("session-start advertises recall with and without index status", () => {
- const withStatus = JSON.parse(handleSessionStart("1769 files / 354798 messages, last ingest X"));
+ // dedicatedTools is pinned: the default reads THIS machine's config.toml, and a
+ // unit assertion about wording must not depend on the operator's install.
+ const withStatus = JSON.parse(
+ handleSessionStart("1769 files / 354798 messages, last ingest X", undefined, undefined, {
+ dedicatedTools: false,
+ }),
+ );
assert.equal(withStatus.hookSpecificOutput.hookEventName, "SessionStart");
assert.match(withStatus.hookSpecificOutput.additionalContext, /cxc chat search/);
assert.match(withStatus.hookSpecificOutput.additionalContext, /Index: 1769 files/);
- const bare = JSON.parse(handleSessionStart(""));
+ const bare = JSON.parse(handleSessionStart("", undefined, undefined, { dedicatedTools: false }));
assert.match(bare.hookSpecificOutput.additionalContext, /\$cxc-recall/);
assert.ok(!bare.hookSpecificOutput.additionalContext.includes("Index:"));
});
@@ -87,7 +101,9 @@ test("post-compact emits nothing: its output wire cannot carry context", () => {
});
test("session-start carries the recovery directive when the source is a compaction", () => {
- const compacted = JSON.parse(handleSessionStart("", undefined, "compact"));
+ const compacted = JSON.parse(
+ handleSessionStart("", undefined, "compact", { dedicatedTools: false }),
+ );
assert.equal(compacted.hookSpecificOutput.hookEventName, "SessionStart");
const text = compacted.hookSpecificOutput.additionalContext;
assert.match(text, /compacted/);
@@ -96,8 +112,9 @@ test("session-start carries the recovery directive when the source is a compacti
// A normal start keeps the availability wording and must not claim a compaction.
for (const source of [undefined, "startup", "resume", "clear"]) {
- const plain = JSON.parse(handleSessionStart("", undefined, source)).hookSpecificOutput
- .additionalContext;
+ const plain = JSON.parse(
+ handleSessionStart("", undefined, source, { dedicatedTools: false }),
+ ).hookSpecificOutput.additionalContext;
assert.doesNotMatch(plain, /compacted/, `source=${source} must not mention compaction`);
assert.match(plain, /recall is available/);
}
@@ -332,3 +349,211 @@ test("summary text is quoted and capped like every other untrusted field", () =>
assert.ok(line.length < 130, `summary line is capped: ${line.length}`);
}
});
+
+// ─── wp6: source-shaped briefings ───────────────────────────────────────────
+
+const notice = (source?: string, opts: { dedicatedTools?: boolean } = { dedicatedTools: false }) =>
+ JSON.parse(handleSessionStart("", undefined, source, opts)).hookSpecificOutput
+ .additionalContext as string;
+
+test("session-start briefings are shaped by source and always end on a recall pointer", () => {
+ const startup = notice("startup");
+ assert.match(startup, /recall is available/);
+ assert.doesNotMatch(startup, /resumed after a pause/);
+
+ // Resume keeps the availability wording and adds why the context may be thin.
+ const resume = notice("resume");
+ assert.match(resume, /recall is available/);
+ assert.match(resume, /resumed after a pause/);
+ assert.ok(
+ resume.indexOf("recall is available") < resume.indexOf("resumed after a pause"),
+ "the resume sentence follows the availability wording",
+ );
+
+ const compact = notice("compact");
+ assert.match(compact, /Context was just compacted/);
+ assert.doesNotMatch(compact, /resumed after a pause/);
+
+ for (const [label, text] of [
+ ["startup", startup],
+ ["resume", resume],
+ ["compact", compact],
+ ] as const) {
+ const pointer = text.split("\n").find((line) => line.startsWith("Recall: ")) ?? "";
+ assert.ok(pointer.length > 0, `${label} carries the recall pointer`);
+ assert.ok(pointer.length <= 160, `${label} pointer stays one capped line (${pointer.length})`);
+ assert.match(text.trimEnd().split("\n").at(-1) ?? "", /Details: \$cxc-recall\./);
+ }
+});
+
+test("a session with no cwd hits gets the notice and nothing else", () => {
+ const text = notice("startup");
+ assert.doesNotMatch(text, //);
+ assert.doesNotMatch(text, /Recent work/);
+ assert.match(text, /^\[cxc-recall\]/);
+});
+
+test("the recall pointer names the native tool only when config.toml enables it", () => {
+ const home = mkdtempSync(join(tmpdir(), "recall-wp6-home-"));
+ const previous = process.env.CODEX_HOME;
+ try {
+ process.env.CODEX_HOME = home;
+
+ // No [memories] table: fail-open to the commands, which are always correct.
+ writeFileSync(join(home, "config.toml"), "[other]\nx = 1\n");
+ assert.equal(dedicatedToolsEnabled(), false);
+ const off = JSON.parse(handleSessionStart("", undefined, "startup")).hookSpecificOutput
+ .additionalContext as string;
+ assert.match(off, /cxc chat search/);
+ assert.doesNotMatch(off, /memories\.search/);
+
+ writeFileSync(join(home, "config.toml"), "[memories]\ndedicated_tools = true\n\n[other]\nx = 1\n");
+ assert.equal(dedicatedToolsEnabled(), true);
+ const on = JSON.parse(handleSessionStart("", undefined, "startup")).hookSpecificOutput
+ .additionalContext as string;
+ assert.match(on, /memories\.search/);
+ assert.doesNotMatch(on, /cxc chat search/);
+
+ // The key counts only inside [memories], and a missing file is not a crash.
+ writeFileSync(join(home, "config.toml"), "[tools]\ndedicated_tools = true\n");
+ assert.equal(dedicatedToolsEnabled(), false);
+ rmSync(join(home, "config.toml"));
+ assert.equal(dedicatedToolsEnabled(), false);
+ } finally {
+ if (previous === undefined) delete process.env.CODEX_HOME;
+ else process.env.CODEX_HOME = previous;
+ rmSync(home, { recursive: true, force: true });
+ }
+});
+
+// ─── wp6: targeted recall hints (no search) ─────────────────────────────────
+
+test("wp6 trigger idioms widen without catching ordinary instructions", () => {
+ for (const p of [
+ "이전에 했던 배포 스크립트 다시 보자",
+ "그 세션에서 정한 예산이 뭐지",
+ "prior work on ingest?",
+ "we shipped that a while ago, right?",
+ ]) {
+ assert.ok(detectRecallIntent(p), `should trigger: ${p}`);
+ }
+ for (const p of ["add a --json flag to the status command", "hook.ts를 고쳐줘", "deploy 2.49.0 now"]) {
+ assert.equal(detectRecallIntent(p), false, `should NOT trigger: ${p}`);
+ }
+});
+
+test("recall intent appends suggested terms and still runs no search", () => {
+ const text = JSON.parse(
+ handleUserPromptSubmit({
+ hook_event_name: "UserPromptSubmit",
+ prompt: "그때 그 작업 hook.ts MEMORY-WRITE-GATE",
+ }),
+ ).hookSpecificOutput.additionalContext as string;
+ assert.match(text, /Suggested recall terms:/);
+ assert.match(text, /hook\.ts/);
+ assert.match(text, /MEMORY-WRITE-GATE/);
+ // The directive itself is untouched.
+ assert.match(text, /cxc chat search/);
+ assert.match(text, /cxc memory search/);
+ // Nothing was searched, so no search-output shape can appear.
+ assert.doesNotMatch(text, /memory hits/);
+ assert.doesNotMatch(text, /^Index:/m);
+ assert.doesNotMatch(text, /^---$/m);
+
+ const plain = JSON.parse(
+ handleUserPromptSubmit({ hook_event_name: "UserPromptSubmit", prompt: "지난번 세션 이어서" }),
+ ).hookSpecificOutput.additionalContext as string;
+ assert.doesNotMatch(plain, /Suggested recall terms:/);
+});
+
+test("extractRecallTargets keeps distinctive tokens, drops idioms, and stays fast", () => {
+ assert.deepEqual(extractRecallTargets("지난번 2.49.0 provenance와 hook.ts, 그리고 SessionStart"), [
+ "2.49.0",
+ "hook.ts",
+ "SessionStart",
+ ]);
+ assert.deepEqual(extractRecallTargets('그때 "the ingest race" 얘기했잖아'), ["the ingest race"]);
+ assert.deepEqual(extractRecallTargets("지난번 그 작업 이어서"), []);
+ assert.equal(extractRecallTargets("2.1 2.2 2.3 2.4 2.5 2.6").length, 4, "the list is capped");
+
+ // UserPromptSubmit runs on every prompt, so extraction is regex-only: an 8KB
+ // prompt must not spend a measurable slice of the 5s hook budget.
+ const prompt = `그때 그 작업 hook.ts 2.49.0 ${"수정하고 다시 검증하자 ".repeat(400)}`.slice(0, 8000);
+ const started = performance.now();
+ extractRecallTargets(prompt);
+ const elapsed = performance.now() - started;
+ assert.ok(elapsed < 20, `extraction stays inside the hook budget (${elapsed}ms)`);
+});
+
+// ─── wp6: hook stdout/stderr/exit contract, through the real entrypoint ─────
+
+const recallCli = fileURLToPath(new URL("../dist/cli.js", import.meta.url));
+
+test("every recall hook event writes empty stdout or one JSON object, and exits 0", (t) => {
+ if (!existsSync(recallCli)) return t.skip("dist/cli.js absent; run npm run build first");
+ const home = mkdtempSync(join(tmpdir(), "recall-wp6-hookhome-"));
+ try {
+ // The operator's real ~/.codex must stay out of this: CODEXCLAW_HOME alone
+ // still leaves the handler reading live sessions (hook-e2e emptyCodexHome).
+ const env = {
+ ...process.env,
+ CODEX_HOME: home,
+ CODEX_SQLITE_HOME: home,
+ CODEXCLAW_HOME: join(home, "cxc"),
+ CODEXCLAW_CXC: "cxc",
+ };
+ const run = (event: string, payload: unknown) => {
+ const child = spawnSync(process.execPath, [recallCli, "hook", event], {
+ input: payload === null ? "" : JSON.stringify(payload),
+ encoding: "utf8",
+ env,
+ });
+ return { stdout: child.stdout ?? "", stderr: child.stderr ?? "", code: child.status ?? 1 };
+ };
+ const cases: Array<[string, unknown]> = [
+ ["session-start", { hook_event_name: "SessionStart", cwd: home, source: "startup" }],
+ ["session-start", { hook_event_name: "SessionStart", cwd: home, source: "compact" }],
+ ["session-start", null],
+ [
+ "user-prompt-submit",
+ { hook_event_name: "UserPromptSubmit", cwd: home, prompt: "지난번 hook.ts 작업 이어서" },
+ ],
+ [
+ "user-prompt-submit",
+ { hook_event_name: "UserPromptSubmit", cwd: home, prompt: "\u001b[31m지난번\u001b[0m 그 작업" },
+ ],
+ ["post-compact", { hook_event_name: "PostCompact", cwd: home }],
+ ["not-an-event", { hook_event_name: "Nonsense", cwd: home }],
+ ];
+ for (const [event, payload] of cases) {
+ const result = run(event, payload);
+ assert.doesNotThrow(
+ () => assertLegalHookResult(result),
+ `${event}: ${result.code} ${JSON.stringify(result.stdout.slice(0, 80))}`,
+ );
+ }
+ assert.equal(
+ run("post-compact", { hook_event_name: "PostCompact", cwd: home }).stdout,
+ "",
+ "PostCompact still carries no envelope",
+ );
+ assert.equal(run("not-an-event", { hook_event_name: "Nonsense" }).stdout, "");
+ } finally {
+ rmSync(home, { recursive: true, force: true });
+ }
+});
+
+test("assertLegalHookResult rejects the shapes that broke SessionStart upstream", () => {
+ assert.throws(() => assertLegalHookResult({ stdout: "", stderr: "", code: 3 }), /exited 3/);
+ assert.throws(
+ () => assertLegalHookResult({ stdout: "", stderr: "\u001b[32mdone\u001b[0m", code: 0 }),
+ /ANSI to stderr/,
+ );
+ assert.throws(() => assertLegalHookResult({ stdout: "plain text", stderr: "", code: 0 }));
+ assert.throws(
+ () => assertLegalHookResult({ stdout: "[1,2]", stderr: "", code: 0 }),
+ /not a JSON object/,
+ );
+ assert.doesNotThrow(() => assertLegalHookResult({ stdout: "", stderr: "note\n", code: 0 }));
+ assert.doesNotThrow(() => assertLegalHookResult({ stdout: '{"a":1}\n', stderr: "", code: 0 }));
+});