From 1e63d3057b18766d69d2e068b87d0787e7766ae8 Mon Sep 17 00:00:00 2001 From: hungduong-projects Date: Fri, 11 Sep 2026 18:06:35 +0700 Subject: [PATCH 1/8] Read touched files from patches and shell reads Co-Authored-By: Claude Opus 5 (1M context) --- scripts/zones-core.mjs | 39 +++++++++++++++++++++++++++++++++++++-- test/hooks.test.mjs | 40 +++++++++++++++++++++++++++++++++++++++- 2 files changed, 76 insertions(+), 3 deletions(-) diff --git a/scripts/zones-core.mjs b/scripts/zones-core.mjs index 5b777c1..8b95290 100644 --- a/scripts/zones-core.mjs +++ b/scripts/zones-core.mjs @@ -1,9 +1,9 @@ /* Shared parsing for the code-zones map. Dependency-free so hooks and CI can * run it before any install step. */ -import { access, readFile, writeFile } from "node:fs/promises"; +import { access, readFile, stat, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; -import { join } from "node:path"; +import { join, relative, resolve } from "node:path"; /* Where a repo may keep its map, first hit wins. */ export const MAP_LOCATIONS = [ @@ -245,6 +245,41 @@ export function formatIndex({ relative, zones }) { ].join("\n"); } +/* The files one tool call touched, relative to root and inside it, as + * { path, edit }. Claude names one file_path; a Codex apply_patch names the + * files it adds, updates or moves to (a deleted file has nothing to route); + * a shell command counts when a segment is a plain cat, head, tail, sed or nl + * of existing files. Substitutions, redirects and a cd make shell paths + * uncertain, so those read as nothing. */ +const READERS = new Set(["cat", "head", "tail", "sed", "nl"]); + +export async function touchedFiles({ tool_name: tool, tool_input: args = {} }, root) { + const files = []; + if (["Read", "Edit", "Write"].includes(tool) && typeof args.file_path === "string") { + files.push({ path: args.file_path, edit: tool !== "Read" }); + } else if (tool === "apply_patch" && typeof args.command === "string") { + for (const [, path] of args.command.matchAll(/^\*\*\* (?:Add File|Update File|Move to): (.+)$/gm)) { + files.push({ path: path.trim(), edit: true }); + } + } else if (tool === "Bash" && typeof args.command === "string" && !/\$\(|`|>|< word.replace(/^(['"])(.*)\1$/, "$2")); + if (first === "cd") break; + if (!READERS.has(first)) continue; + for (const word of rest.filter((word) => !word.startsWith("-"))) { + if (await stat(resolve(root, word)).then((info) => info.isFile(), () => false)) files.push({ path: word, edit: false }); + } + } + } + const unique = new Map(); + for (const { path, edit } of files) { + const inside = relative(root, resolve(root, path)); + if (inside && !inside.startsWith("..") && !unique.has(inside)) unique.set(inside, { path: inside, edit }); + } + return [...unique.values()]; +} + /* Hook input arrives as one JSON object on stdin. Anything else is no event * to act on, so the hook ends quietly. */ export async function readInput() { diff --git a/test/hooks.test.mjs b/test/hooks.test.mjs index 5c1ce59..4af63fe 100644 --- a/test/hooks.test.mjs +++ b/test/hooks.test.mjs @@ -11,7 +11,7 @@ import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; import { - dependents, formatBlast, formatEntry, formatIndex, formatLine, namedIdentifiers, parseMap, + dependents, formatBlast, formatEntry, formatIndex, formatLine, namedIdentifiers, parseMap, touchedFiles, } from "../scripts/zones-core.mjs"; const REPO = join(dirname(fileURLToPath(import.meta.url)), ".."); @@ -123,6 +123,44 @@ test("namedIdentifiers lists the code-shaped names in purpose and invariants, on assert.deepEqual(namedIdentifiers(za), []); }); +const reads = (...paths) => paths.map((path) => ({ path, edit: false })); +const edits = (...paths) => paths.map((path) => ({ path, edit: true })); + +test("touchedFiles takes Claude's file path, relative to the repo and inside it", async () => { + const cwd = fixture(); + const files = (tool_name, tool_input) => touchedFiles({ tool_name, tool_input }, cwd); + assert.deepEqual(await files("Read", { file_path: join(cwd, "a/x.ts") }), reads("a/x.ts")); + assert.deepEqual(await files("Edit", { file_path: join(cwd, "a/x.ts") }), edits("a/x.ts")); + assert.deepEqual(await files("Write", { file_path: "b/new.ts" }), edits("b/new.ts")); + assert.deepEqual(await files("Read", { file_path: "/elsewhere/a/x.ts" }), []); + assert.deepEqual(await files("Read", {}), []); +}); + +test("touchedFiles lists the files a Codex patch adds, updates or moves to, once each, but not deletes", async () => { + const cwd = fixture(); + const command = [ + "*** Begin Patch", `*** Update File: ${join(cwd, "a/index.ts")}`, "@@", "+// rounding", + "*** Add File: notes/new.md", "+hello", `*** Delete File: ${join(cwd, "c/release.sh")}`, + "*** Update File: b/chart.ts", "*** Move to: b/graph.ts", `*** Update File: ${join(cwd, "a/index.ts")}`, "*** End Patch", + ].join("\n"); + assert.deepEqual(await touchedFiles({ tool_name: "apply_patch", tool_input: { command } }, cwd), + edits("a/index.ts", "notes/new.md", "b/chart.ts", "b/graph.ts")); +}); + +test("touchedFiles lists the existing repo files plain shell reads name", async () => { + const cwd = fixture(); + const outside = join(mkdtempSync(join(tmpdir(), "code-map-out-")), "o.ts"); + writeFileSync(outside, ""); + const bash = (command) => touchedFiles({ tool_name: "Bash", tool_input: { command } }, cwd); + assert.deepEqual(await bash("cat a/x.ts"), reads("a/x.ts")); + assert.deepEqual(await bash("sed -n '1,80p' a/x.ts"), reads("a/x.ts")); + assert.deepEqual(await bash(`nl -ba "${join(cwd, "a/x.ts")}" | sed -n '1,40p'`), reads("a/x.ts")); + assert.deepEqual(await bash("head -n 50 a/x.ts b/chart.ts && tail a/x.ts"), reads("a/x.ts", "b/chart.ts")); + for (const command of ["cd a && cat x.ts", "cat a/x.ts > out.txt", "cat $(ls a)", "cat a/missing.ts", `cat ${outside}`, "rg ledger a", "ls a"]) { + assert.deepEqual(await bash(command), [], command); + } +}); + test("route injects a fresh high-risk zone once per session, with a notice", () => { const cwd = fixture(); const session_id = session(); From 3c5bdd318e4c722b7c18fb39c70e5e10b3a45b3f Mon Sep 17 00:00:00 2001 From: hungduong-projects Date: Fri, 11 Sep 2026 18:07:39 +0700 Subject: [PATCH 2/8] Route Codex patches and shell reads through file touches Co-Authored-By: Claude Opus 5 (1M context) --- scripts/touch.mjs | 57 ++++++++++++++++++++++----------------------- test/hooks.test.mjs | 20 ++++++++++++++++ 2 files changed, 48 insertions(+), 29 deletions(-) diff --git a/scripts/touch.mjs b/scripts/touch.mjs index 635913d..703c77b 100644 --- a/scripts/touch.mjs +++ b/scripts/touch.mjs @@ -1,52 +1,51 @@ #!/usr/bin/env node -/* PostToolUse hook on Read|Edit|Write: the prompt router sees words, not +/* PostToolUse hook on Read|Edit|Write|Bash: the prompt router sees words, not * files. The first touch of a file in a zone this thread has not seen attaches * that zone: the full entry for high risk, one line otherwise. An edit to a - * zone's entrypoint also names the zones that depend on it, once. Silent for - * unowned files (orphan-check covers edits there), the map itself, and repos - * without a map. */ - -import { relative, resolve } from "node:path"; + * zone's entrypoint also names the zones that depend on it, once. Codex + * patches and plain shell reads count as touches too. Silent for unowned + * files (orphan-check covers edits there), the map itself, and repos without + * a map. */ import { dependents, emit, formatBlast, formatEntry, formatLine, loadSeen, loadZones, owningZone, plural, readInput, saveSeen, + touchedFiles, } from "./zones-core.mjs"; const input = await readInput(); -const filePath = input.tool_input?.file_path; const root = input.cwd ?? process.cwd(); -if (typeof filePath !== "string") process.exit(0); +const files = await touchedFiles(input, root); +if (!files.length) process.exit(0); const loaded = await loadZones(root).catch(() => null); if (!loaded || loaded.problems.length) process.exit(0); -const path = relative(root, resolve(root, filePath)); -if (path.startsWith("..") || path === loaded.relative) process.exit(0); -const zone = owningZone(loaded.zones, path); -if (!zone) process.exit(0); - const seen = await loadSeen(input.session_id, input.agent_id); const context = []; const notices = []; -if (!seen.zones[zone.id]) { - if (zone.risk === "high") { - context.push(`code-map: ${path} is in ${zone.id} (high). Its entry:`, formatEntry(zone, loaded.zones)); - notices.push(`code-map → ${zone.id} (high) via ${path}`); - seen.zones[zone.id] = "full"; - } else { - context.push(formatLine(path, zone, loaded.relative)); - seen.zones[zone.id] = "line"; +for (const { path, edit } of files) { + const zone = path !== loaded.relative && owningZone(loaded.zones, path); + if (!zone) continue; + + if (!seen.zones[zone.id]) { + if (zone.risk === "high") { + context.push(`code-map: ${path} is in ${zone.id} (high). Its entry:`, formatEntry(zone, loaded.zones)); + notices.push(`code-map → ${zone.id} (high) via ${path}`); + seen.zones[zone.id] = "full"; + } else { + context.push(formatLine(path, zone, loaded.relative)); + seen.zones[zone.id] = "line"; + } } -} -const edit = input.tool_name === "Edit" || input.tool_name === "Write"; -const blast = edit && zone.entrypoints.includes(path) && !seen.blast.includes(zone.id) && - formatBlast(path, zone, loaded.zones); -if (blast) { - context.push(blast); - notices.push(`code-map → ${path} is a ${zone.id} entrypoint used by ${plural(dependents(loaded.zones, zone.id).length, "zone")}`); - seen.blast.push(zone.id); + const blast = edit && zone.entrypoints.includes(path) && !seen.blast.includes(zone.id) && + formatBlast(path, zone, loaded.zones); + if (blast) { + context.push(blast); + notices.push(`code-map → ${path} is a ${zone.id} entrypoint used by ${plural(dependents(loaded.zones, zone.id).length, "zone")}`); + seen.blast.push(zone.id); + } } if (!context.length) process.exit(0); diff --git a/test/hooks.test.mjs b/test/hooks.test.mjs index 4af63fe..62bf9e9 100644 --- a/test/hooks.test.mjs +++ b/test/hooks.test.mjs @@ -281,6 +281,26 @@ test("touch reports blast radius for a zone the thread already holds", () => { assert.equal(out.systemMessage, "code-map → a/index.ts is a ZA entrypoint used by 1 zone"); }); +test("touch routes every file in a Codex patch in one output, with blast for an entrypoint", () => { + const cwd = fixture(); + const session_id = session(); + const command = ["*** Begin Patch", `*** Update File: ${join(cwd, "a/index.ts")}`, "@@", "+// rounding", + `*** Update File: ${join(cwd, "a/x.ts")}`, "@@", "+// rounding", "*** Update File: b/chart.ts", "@@", "+// scale", "*** End Patch"].join("\n"); + const out = run("touch.mjs", { session_id, tool_name: "apply_patch", tool_input: { command } }, { cwd }); + const context = out.hookSpecificOutput.additionalContext; + assert.ok(context.startsWith("code-map: a/index.ts is in ZA (high). Its entry:\nZA (high): ")); + assert.match(context, /\ncode-map: a\/index\.ts is a ZA entrypoint used by ZB\. verify: npm test b\ncode-map: b\/chart\.ts is in ZB \(low\), [^\n]+$/); + assert.equal(context.match(/ZA \(high\): /g).length, 1); + assert.equal(out.systemMessage, "code-map → ZA (high) via a/index.ts · code-map → a/index.ts is a ZA entrypoint used by 1 zone"); + assert.deepEqual(JSON.parse(readFileSync(seenFile(session_id), "utf-8")), { zones: { ZA: "full", ZB: "line" }, blast: ["ZA"] }); +}); + +test("touch routes a plain shell read like a Read", () => { + const out = run("touch.mjs", { session_id: session(), tool_name: "Bash", tool_input: { command: "sed -n '1,40p' b/chart.ts" } }, { cwd: fixture() }); + assert.equal(out.hookSpecificOutput.additionalContext, + "code-map: b/chart.ts is in ZB (low), Dashboard widgets and charts. verify: npm test b; 2 invariants in CODEMAP.md."); +}); + test("touch ignores unowned files, the map, outside paths, missing paths, and unmapped repos", () => { const cwd = fixture(); const session_id = session(); From 6c5c48910738d5f7e1f267df30cbcbfc22aa053d Mon Sep 17 00:00:00 2001 From: hungduong-projects Date: Fri, 11 Sep 2026 18:08:31 +0700 Subject: [PATCH 3/8] Flag unowned files in Codex patches Co-Authored-By: Claude Opus 5 (1M context) --- scripts/orphan-check.mjs | 28 ++++++++++++++-------------- test/hooks.test.mjs | 13 +++++++++++++ 2 files changed, 27 insertions(+), 14 deletions(-) diff --git a/scripts/orphan-check.mjs b/scripts/orphan-check.mjs index 1caa812..9a97b74 100644 --- a/scripts/orphan-check.mjs +++ b/scripts/orphan-check.mjs @@ -1,30 +1,30 @@ #!/usr/bin/env node -/* PostToolUse hook on Write|Edit: when an edit lands in a file no zone owns, - * say so — that is either a stale map or a new surface, and both deserve a - * sentence before the session moves on. Silent when the map is absent, the - * file is owned, or the file is the map itself. */ +/* PostToolUse hook on Write|Edit (Codex apply_patch matches too): when an + * edit lands in a file no zone owns, say so — that is either a stale map or a + * new surface, and both deserve a sentence before the session moves on. + * Silent when the map is absent, the file is owned, or the file is the map + * itself. */ -import { relative } from "node:path"; - -import { loadZones, owningZone, readInput } from "./zones-core.mjs"; +import { loadZones, owningZone, readInput, touchedFiles } from "./zones-core.mjs"; const input = await readInput(); -const filePath = input.tool_input?.file_path; const root = input.cwd ?? process.cwd(); -if (!filePath) process.exit(0); +const edited = (await touchedFiles(input, root)).filter((file) => file.edit).map((file) => file.path); +if (!edited.length) process.exit(0); const loaded = await loadZones(root).catch(() => null); if (!loaded || loaded.problems.length) process.exit(0); -const path = relative(root, filePath); -if (path.startsWith("..") || path === loaded.relative) process.exit(0); -if (/\.test\.[^/]+$/.test(path) || (!path.includes("/") && path.startsWith("."))) process.exit(0); -if (owningZone(loaded.zones, path)) process.exit(0); +const orphans = edited.filter((path) => path !== loaded.relative && + !/\.test\.[^/]+$/.test(path) && !(!path.includes("/") && path.startsWith(".")) && + !owningZone(loaded.zones, path)); +if (!orphans.length) process.exit(0); console.log(JSON.stringify({ hookSpecificOutput: { hookEventName: "PostToolUse", - additionalContext: `\`${path}\` belongs to no zone in ${loaded.relative}. Either this edit opened a new surface — add it to the owning zone's paths (or a new zone) — or it is deliberately unmapped; say which before finishing.`, + additionalContext: orphans.map((path) => + `\`${path}\` belongs to no zone in ${loaded.relative}. Either this edit opened a new surface — add it to the owning zone's paths (or a new zone) — or it is deliberately unmapped; say which before finishing.`).join("\n"), }, })); diff --git a/test/hooks.test.mjs b/test/hooks.test.mjs index 62bf9e9..7e96b5a 100644 --- a/test/hooks.test.mjs +++ b/test/hooks.test.mjs @@ -361,6 +361,19 @@ test("orphan-check still flags an edit to a file no zone owns", () => { assert.match(out.hookSpecificOutput.additionalContext, /^`notes\/todo\.md` belongs to no zone in CODEMAP\.md\./); }); +test("orphan-check flags each unowned file a Codex patch edits, one line each, and ignores reads", () => { + const cwd = fixture(); + const patch = (...lines) => ({ tool_name: "apply_patch", tool_input: { command: ["*** Begin Patch", ...lines, "*** End Patch"].join("\n") } }); + const out = run("orphan-check.mjs", patch("*** Add File: notes/new.md", "+hi", `*** Update File: ${join(cwd, "a/x.ts")}`, "@@", "+//", + "*** Update File: notes/todo.md", "@@", "+more"), { cwd }); + const lines = out.hookSpecificOutput.additionalContext.split("\n"); + assert.equal(lines.length, 2); + assert.match(lines[0], /^`notes\/new\.md` belongs to no zone in CODEMAP\.md\./); + assert.match(lines[1], /^`notes\/todo\.md` belongs to no zone in CODEMAP\.md\./); + assert.equal(run("orphan-check.mjs", patch("*** Update File: a/x.ts", "@@", "+//"), { cwd }), null); + assert.equal(run("orphan-check.mjs", { tool_name: "Bash", tool_input: { command: "cat notes/todo.md" } }, { cwd }), null); +}); + test("zones-check reports the token budget and warns past it without failing", () => { const check = (map) => { const cwd = fixture({ map }); From ba72559191333aed608be0b3f291b71c00e67a82 Mon Sep 17 00:00:00 2001 From: hungduong-projects Date: Fri, 11 Sep 2026 18:09:38 +0700 Subject: [PATCH 4/8] Record Codex spawn fork mode Co-Authored-By: Claude Opus 5 (1M context) --- scripts/spawn.mjs | 9 ++++++++- scripts/zones-core.mjs | 13 +++++++++++++ test/hooks.test.mjs | 12 ++++++++++++ 3 files changed, 33 insertions(+), 1 deletion(-) diff --git a/scripts/spawn.mjs b/scripts/spawn.mjs index fe005cd..6aea754 100644 --- a/scripts/spawn.mjs +++ b/scripts/spawn.mjs @@ -5,12 +5,19 @@ * because subagents treat a detached "fact" as outside their brief. Adds to * the input only: never approves or blocks the call. */ -import { emit, formatEntry, loadZones, readInput, routeZones } from "./zones-core.mjs"; +import { emit, formatEntry, loadZones, readInput, routeZones, saveFork } from "./zones-core.mjs"; const MARKER = "Zone context for this task (code-map"; const input = await readInput(); const task = input.tool_input; +/* A Codex spawn_agent message arrives encrypted, so there is no task to + * route. Record how much of this thread the child inherits instead; + * subagent-start hands it zones when it inherits none. */ +if (typeof task?.prompt !== "string" && String(input.tool_name).endsWith("spawn_agent")) { + await saveFork(input.session_id, task?.fork_turns); + process.exit(0); +} if (typeof task?.prompt !== "string" || task.prompt.includes(MARKER)) process.exit(0); const loaded = await loadZones(input.cwd ?? process.cwd()).catch(() => null); diff --git a/scripts/zones-core.mjs b/scripts/zones-core.mjs index 8b95290..e9fd55a 100644 --- a/scripts/zones-core.mjs +++ b/scripts/zones-core.mjs @@ -321,3 +321,16 @@ export async function loadSeen(session, agent) { export async function saveSeen(session, agent, seen) { await writeFile(seenPath(session, agent), JSON.stringify(seen)).catch(() => {}); } + +/* How much of the parent thread the next Codex subagent inherits: its spawn + * call's fork_turns, "all" when unset. Only a Codex spawn writes this, so a + * Claude session never has one. */ +const forkPath = (session) => seenPath(session, "fork"); + +export async function saveFork(session, fork) { + await writeFile(forkPath(session), JSON.stringify(String(fork ?? "all"))).catch(() => {}); +} + +export async function loadFork(session) { + return readFile(forkPath(session), "utf-8").then(JSON.parse).catch(() => null); +} diff --git a/test/hooks.test.mjs b/test/hooks.test.mjs index 7e96b5a..1fe3760 100644 --- a/test/hooks.test.mjs +++ b/test/hooks.test.mjs @@ -226,6 +226,18 @@ test("spawn points unrouted prompts at the map without a notice, then skips mark assert.equal(run("spawn.mjs", { session_id: session(), tool_name: "Agent", tool_input: { prompt } }, { cwd }), null); }); +const forkFile = (session_id) => join(TMP, `code-map-${session_id}-fork.json`); + +test("spawn records a Codex spawn's fork mode, all when unset, and prints nothing", () => { + const cwd = fixture({ map: null }); + const session_id = session(); + const spawn = (tool_input) => run("spawn.mjs", { session_id, tool_name: "collaborationspawn_agent", tool_input }, { cwd }); + assert.equal(spawn({ task_name: "scope", fork_turns: "none", message: "gAAAAABencrypted" }), null); + assert.equal(JSON.parse(readFileSync(forkFile(session_id), "utf-8")), "none"); + assert.equal(spawn({ task_name: "scope", message: "gAAAAABencrypted" }), null); + assert.equal(JSON.parse(readFileSync(forkFile(session_id), "utf-8")), "all"); +}); + test("spawn is silent without a map or a prompt", () => { assert.equal(run("spawn.mjs", { tool_name: "Agent", tool_input: { prompt: BILLING } }, { cwd: fixture({ map: null }) }), null); assert.equal(run("spawn.mjs", { tool_name: "Agent", tool_input: {} }, { cwd: fixture() }), null); From a57fecf219a33b6bad59e08aee266eae66daa967 Mon Sep 17 00:00:00 2001 From: hungduong-projects Date: Fri, 11 Sep 2026 18:11:32 +0700 Subject: [PATCH 5/8] Give Codex subagents their parent's zones Co-Authored-By: Claude Opus 5 (1M context) --- scripts/spawn.mjs | 21 ++++++----------- scripts/subagent-start.mjs | 35 +++++++++++++++++++++++++++++ scripts/zones-core.mjs | 11 +++++++++ test/hooks.test.mjs | 46 +++++++++++++++++++++++++++++++++++++- 4 files changed, 98 insertions(+), 15 deletions(-) create mode 100644 scripts/subagent-start.mjs diff --git a/scripts/spawn.mjs b/scripts/spawn.mjs index 6aea754..67fac93 100644 --- a/scripts/spawn.mjs +++ b/scripts/spawn.mjs @@ -1,13 +1,10 @@ #!/usr/bin/env node -/* PreToolUse hook on Agent: a subagent starts without the session's zone - * context, so append the entries its task routes to, or a one-line pointer to - * the map, to the prompt it receives. The block reads as part of the task, - * because subagents treat a detached "fact" as outside their brief. Adds to - * the input only: never approves or blocks the call. */ +/* PreToolUse hook on Agent (and Codex spawn_agent): a subagent starts without + * the session's zone context, so append the entries its task routes to, or a + * one-line pointer to the map, to the prompt it receives. Adds to the input + * only: never approves or blocks the call. */ -import { emit, formatEntry, loadZones, readInput, routeZones, saveFork } from "./zones-core.mjs"; - -const MARKER = "Zone context for this task (code-map"; +import { TASK_MARKER, emit, formatTaskBlock, loadZones, readInput, routeZones, saveFork } from "./zones-core.mjs"; const input = await readInput(); const task = input.tool_input; @@ -18,17 +15,13 @@ if (typeof task?.prompt !== "string" && String(input.tool_name).endsWith("spawn_ await saveFork(input.session_id, task?.fork_turns); process.exit(0); } -if (typeof task?.prompt !== "string" || task.prompt.includes(MARKER)) process.exit(0); +if (typeof task?.prompt !== "string" || task.prompt.includes(TASK_MARKER)) process.exit(0); const loaded = await loadZones(input.cwd ?? process.cwd()).catch(() => null); if (!loaded || loaded.problems.length) process.exit(0); const routed = routeZones(loaded.zones, task.prompt); -const block = routed.length - ? [`${MARKER}, ${loaded.relative}; source wins):`, ...routed.map((zone) => formatEntry(zone, loaded.zones))].join("\n") - : `${MARKER}): this repo's zone map is ${loaded.relative}; source wins.`; - emit("PreToolUse", { notice: routed.length ? `code-map → subagent: ${routed.map((zone) => `${zone.id} (${zone.risk})`).join(", ")}` : "", - extra: { updatedInput: { ...task, prompt: `${task.prompt}\n\n${block}` } }, + extra: { updatedInput: { ...task, prompt: `${task.prompt}\n\n${formatTaskBlock(loaded, routed)}` } }, }); diff --git a/scripts/subagent-start.mjs b/scripts/subagent-start.mjs new file mode 100644 index 0000000..a768d29 --- /dev/null +++ b/scripts/subagent-start.mjs @@ -0,0 +1,35 @@ +#!/usr/bin/env node +/* SubagentStart hook, for Codex: a spawn_agent message arrives encrypted, so + * spawn.mjs cannot route the child's task. A child that inherits none of the + * parent thread gets the last 3 zones the parent loaded in full, or a pointer + * to the map, and starts with those zones marked seen so its own touches do + * not repeat them. Silent for a child that inherits the thread, and in Claude + * Code, where spawn.mjs already put the zones in the prompt and no fork file + * exists. */ + +import { emit, formatTaskBlock, loadFork, loadSeen, loadZones, readInput, saveSeen } from "./zones-core.mjs"; + +const input = await readInput(); +const fork = await loadFork(input.session_id); +if (!fork || fork === "all") process.exit(0); + +const loaded = await loadZones(input.cwd ?? process.cwd()).catch(() => null); +if (!loaded || loaded.problems.length) process.exit(0); + +const parent = await loadSeen(input.session_id); +const picked = Object.keys(parent.zones) + .filter((id) => parent.zones[id] === "full") + .map((id) => loaded.zones.find((zone) => zone.id === id)) + .filter(Boolean) + .slice(-3); + +if (picked.length) { + const child = await loadSeen(input.session_id, input.agent_id); + for (const zone of picked) child.zones[zone.id] = "full"; + await saveSeen(input.session_id, input.agent_id, child); +} + +emit("SubagentStart", { + context: formatTaskBlock(loaded, picked), + notice: picked.length ? `code-map → subagent: ${picked.map((zone) => `${zone.id} (${zone.risk})`).join(", ")}` : "", +}); diff --git a/scripts/zones-core.mjs b/scripts/zones-core.mjs index e9fd55a..adc33b4 100644 --- a/scripts/zones-core.mjs +++ b/scripts/zones-core.mjs @@ -245,6 +245,17 @@ export function formatIndex({ relative, zones }) { ].join("\n"); } +/* The zone block a subagent starts with: the given zones' entries, or a + * pointer to the map when there are none. It reads as part of the task, + * because subagents treat a detached "fact" as outside their brief. */ +export const TASK_MARKER = "Zone context for this task (code-map"; + +export function formatTaskBlock({ relative, zones }, picked) { + return picked.length + ? [`${TASK_MARKER}, ${relative}; source wins):`, ...picked.map((zone) => formatEntry(zone, zones))].join("\n") + : `${TASK_MARKER}): this repo's zone map is ${relative}; source wins.`; +} + /* The files one tool call touched, relative to root and inside it, as * { path, edit }. Claude names one file_path; a Codex apply_patch names the * files it adds, updates or moves to (a deleted file has nothing to route); diff --git a/test/hooks.test.mjs b/test/hooks.test.mjs index 1fe3760..7116864 100644 --- a/test/hooks.test.mjs +++ b/test/hooks.test.mjs @@ -197,7 +197,7 @@ test("route reads a 1.1.0 seen file as empty", () => { assert.ok(run("route.mjs", { session_id, prompt: BILLING }, { cwd })); }); -const HOOKS = ["route.mjs", "spawn.mjs", "touch.mjs", "session-start.mjs", "orphan-check.mjs"]; +const HOOKS = ["route.mjs", "spawn.mjs", "touch.mjs", "session-start.mjs", "orphan-check.mjs", "subagent-start.mjs"]; test("hooks exit 0 and print nothing on invalid JSON", () => { const cwd = fixture(); @@ -238,6 +238,50 @@ test("spawn records a Codex spawn's fork mode, all when unset, and prints nothin assert.equal(JSON.parse(readFileSync(forkFile(session_id), "utf-8")), "all"); }); +const ZD = [ + "id: ZD", "risk: low", "read_first: []", 'purpose: "Docs site."', 'paths: ["docs/**"]', + 'entrypoints: ["docs/billing.md"]', "invariants: []", "deps: []", 'verify: "npm test d"', +]; +const childStart = (cwd, session_id, fork, agent_id = "child1") => { + if (fork) writeFileSync(forkFile(session_id), JSON.stringify(fork)); + return run("subagent-start.mjs", { session_id, agent_id, agent_type: "default", hook_event_name: "SubagentStart" }, { cwd }); +}; + +test("subagent-start hands a Codex child that inherits nothing its parent's last 3 full zones, seen", () => { + const cwd = fixture({ map: mapText([ZA, ZB, ZC, ZD]) }); + const session_id = session(); + writeFileSync(seenFile(session_id), JSON.stringify({ zones: { ZC: "full", ZB: "full", ZA: "full", ZD: "full" }, blast: [] })); + const out = childStart(cwd, session_id, "none"); + assert.equal(out.hookSpecificOutput.hookEventName, "SubagentStart"); + const context = out.hookSpecificOutput.additionalContext; + assert.ok(context.startsWith("Zone context for this task (code-map, CODEMAP.md; source wins):\nZB (low): ")); + assert.match(context, /\nZA \(high\): [^\n]+\n[\s\S]*\nZD \(low\): Docs site\./); + assert.doesNotMatch(context, /^ZC /m); + assert.equal(out.systemMessage, "code-map → subagent: ZB (low), ZA (high), ZD (low)"); + assert.deepEqual(JSON.parse(readFileSync(join(TMP, `code-map-${session_id}-child1.json`), "utf-8")), + { zones: { ZB: "full", ZA: "full", ZD: "full" }, blast: [] }); + assert.equal(touch(cwd, session_id, "Read", "a/x.ts", { agent_id: "child1" }), null, "the child's touch does not repeat ZA"); +}); + +test("subagent-start points a child at the map when the parent holds no full zone", () => { + const cwd = fixture(); + const session_id = session(); + writeFileSync(seenFile(session_id), JSON.stringify({ zones: { ZB: "line" }, blast: [] })); + const out = childStart(cwd, session_id, "none"); + assert.equal(out.hookSpecificOutput.additionalContext, + "Zone context for this task (code-map): this repo's zone map is CODEMAP.md; source wins."); + assert.equal(out.systemMessage, undefined); +}); + +test("subagent-start is silent for a full fork, without a fork file, and without a healthy map", () => { + const session_id = session(); + writeFileSync(seenFile(session_id), JSON.stringify({ zones: { ZA: "full" }, blast: [] })); + assert.equal(childStart(fixture(), session_id, "all"), null); + assert.equal(childStart(fixture(), session(), null), null, "Claude Code writes no fork file"); + assert.equal(childStart(fixture({ map: null }), session_id, "none"), null); + assert.equal(childStart(fixture({ map: mapText([ZA.filter((line) => !line.startsWith("verify:")), ZB, ZC]) }), session_id, "none"), null); +}); + test("spawn is silent without a map or a prompt", () => { assert.equal(run("spawn.mjs", { tool_name: "Agent", tool_input: { prompt: BILLING } }, { cwd: fixture({ map: null }) }), null); assert.equal(run("spawn.mjs", { tool_name: "Agent", tool_input: {} }, { cwd: fixture() }), null); From f6ec2ad1deeebb3beefb43af8d88e75aeaa62323 Mon Sep 17 00:00:00 2001 From: hungduong-projects Date: Fri, 11 Sep 2026 18:12:20 +0700 Subject: [PATCH 6/8] Show the Codex skill name in the unmapped-repo hint Co-Authored-By: Claude Opus 5 (1M context) --- scripts/session-start.mjs | 4 +++- test/hooks.test.mjs | 10 +++++++++- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/scripts/session-start.mjs b/scripts/session-start.mjs index 7e024f9..dc9114c 100644 --- a/scripts/session-start.mjs +++ b/scripts/session-start.mjs @@ -54,5 +54,7 @@ if (loaded?.problems.length) { notice: `code-map: ${plural(loaded.zones.length, "zone")} ready (${loaded.relative})`, }); } else if (!loaded && input.source === "startup" && process.env.CLAUDE_PLUGIN_DATA && (await firstNudge(root))) { - emit("SessionStart", { notice: "code-map: no zone map in this repo. Run /code-map:init to draft one." }); + /* Only Codex sets PLUGIN_ROOT; its users call a skill with $. */ + const skill = process.env.PLUGIN_ROOT ? "$code-map:init" : "/code-map:init"; + emit("SessionStart", { notice: `code-map: no zone map in this repo. Run ${skill} to draft one.` }); } diff --git a/test/hooks.test.mjs b/test/hooks.test.mjs index 7116864..dca5eca 100644 --- a/test/hooks.test.mjs +++ b/test/hooks.test.mjs @@ -61,7 +61,7 @@ function run(name, event, { cwd, env = {} }) { const result = spawnSync(process.execPath, [script(name)], { cwd, input: typeof event === "string" ? event : JSON.stringify({ cwd, ...event }), - env: { ...process.env, TMPDIR: TMP, CLAUDE_PLUGIN_DATA: "", ...env }, + env: { ...process.env, TMPDIR: TMP, CLAUDE_PLUGIN_DATA: "", PLUGIN_ROOT: "", ...env }, encoding: "utf-8", }); assert.equal(result.status, 0, result.stderr); @@ -398,6 +398,14 @@ test("session-start nudges once per unmapped git repo, from any subdirectory", ( assert.equal(run("session-start.mjs", { session_id: session(), source: "startup" }, { cwd, env }), null); }); +test("session-start names the skill the Codex way when Codex runs the hook", () => { + const cwd = fixture({ map: null }); + mkdirSync(join(cwd, ".git")); + const env = { CLAUDE_PLUGIN_DATA: mkdtempSync(join(tmpdir(), "code-map-data-")), PLUGIN_ROOT: REPO }; + assert.deepEqual(run("session-start.mjs", { session_id: session(), source: "startup" }, { cwd, env }), + { systemMessage: "code-map: no zone map in this repo. Run $code-map:init to draft one." }); +}); + test("session-start skips the nudge on clear, without plugin data, outside git, and under a mapped root", () => { const env = { CLAUDE_PLUGIN_DATA: mkdtempSync(join(tmpdir(), "code-map-data-")) }; const unmapped = fixture({ map: null }); From f0f9b96e07c6bcc66b77cb558444a7b20eeedd3e Mon Sep 17 00:00:00 2001 From: hungduong-projects Date: Fri, 11 Sep 2026 18:13:30 +0700 Subject: [PATCH 7/8] Wire Codex spawns, shell reads and subagent starts Co-Authored-By: Claude Opus 5 (1M context) --- hooks/hooks.json | 14 ++++++++++++-- test/hooks.test.mjs | 8 ++++++-- 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/hooks/hooks.json b/hooks/hooks.json index e0520e0..96cf936 100644 --- a/hooks/hooks.json +++ b/hooks/hooks.json @@ -23,7 +23,7 @@ ], "PreToolUse": [ { - "matcher": "Agent", + "matcher": "Agent|.*spawn_agent", "hooks": [ { "type": "command", @@ -43,7 +43,7 @@ ] }, { - "matcher": "Read|Edit|Write", + "matcher": "Read|Edit|Write|Bash", "hooks": [ { "type": "command", @@ -51,6 +51,16 @@ } ] } + ], + "SubagentStart": [ + { + "hooks": [ + { + "type": "command", + "command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/subagent-start.mjs\"" + } + ] + } ] } } diff --git a/test/hooks.test.mjs b/test/hooks.test.mjs index dca5eca..858e7ef 100644 --- a/test/hooks.test.mjs +++ b/test/hooks.test.mjs @@ -291,10 +291,14 @@ const hookScripts = (event) => JSON.parse(readFileSync(join(REPO, "hooks", "hook .map((group) => [group.matcher, group.hooks.map((hook) => hook.command.match(/scripts\/([\w-]+\.mjs)/)[1])]); test("hooks.json wires each script to its event and matcher", () => { - assert.deepEqual(hookScripts("PreToolUse"), [["Agent", ["spawn.mjs"]]]); - assert.deepEqual(hookScripts("PostToolUse"), [["Write|Edit", ["orphan-check.mjs"]], ["Read|Edit|Write", ["touch.mjs"]]]); + assert.deepEqual(hookScripts("PreToolUse"), [["Agent|.*spawn_agent", ["spawn.mjs"]]]); + assert.deepEqual(hookScripts("PostToolUse"), [["Write|Edit", ["orphan-check.mjs"]], ["Read|Edit|Write|Bash", ["touch.mjs"]]]); assert.deepEqual(hookScripts("SessionStart"), [["startup|clear|compact", ["session-start.mjs"]]]); assert.deepEqual(hookScripts("UserPromptSubmit"), [[undefined, ["route.mjs"]]]); + assert.deepEqual(hookScripts("SubagentStart"), [[undefined, ["subagent-start.mjs"]]]); + /* Codex matches the whole tool name, and names its spawn tool with a namespace. */ + const spawnMatcher = new RegExp(`^(?:${hookScripts("PreToolUse")[0][0]})$`); + for (const name of ["Agent", "spawn_agent", "collaborationspawn_agent"]) assert.match(name, spawnMatcher); }); const touch = (cwd, session_id, tool_name, file, extra = {}) => From 597a14e378a0512d2cc9176593ab40ef1643332f Mon Sep 17 00:00:00 2001 From: hungduong-projects Date: Fri, 11 Sep 2026 18:17:46 +0700 Subject: [PATCH 8/8] code-map 1.3.0: Codex install and manifests Co-Authored-By: Claude Opus 5 (1M context) --- .claude-plugin/marketplace.json | 2 +- .claude-plugin/plugin.json | 2 +- README.md | 29 ++++++++++++++++++++++------- 3 files changed, 24 insertions(+), 9 deletions(-) diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index d6d3ca9..47437b1 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -11,7 +11,7 @@ "source": "./", "displayName": "Code Map", "description": "Coding agents re-discover your repo every session and miss what they must not break. code-map hands each agent, subagent and file edit the right zone from one checked-in, validated map.", - "version": "1.2.1", + "version": "1.3.0", "license": "MIT", "homepage": "https://github.com/hungduong-projects/code-map", "category": "productivity", diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 458f344..79309ad 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -2,7 +2,7 @@ "name": "code-map", "displayName": "Code Map", "description": "Coding agents re-discover your repo every session and miss what they must not break. code-map hands each agent, subagent and file edit the right zone from one checked-in, validated map.", - "version": "1.2.1", + "version": "1.3.0", "author": { "name": "Harry Duong", "url": "https://github.com/hungduong-projects" diff --git a/README.md b/README.md index ad3958e..3501b85 100644 --- a/README.md +++ b/README.md @@ -12,18 +12,30 @@ The map routes, source decides, and the validator keeps the map honest. ## Install +Claude Code: + ``` /plugin marketplace add hungduong-projects/code-map /plugin install code-map@code-map ``` +Codex CLI reads the same plugin files: + +``` +codex plugin marketplace add hungduong-projects/code-map +codex plugin add code-map@code-map +``` + +Then open `/hooks` in Codex and trust code-map's hooks. Codex skips plugin +hooks until you do, and asks again when an update changes them. + Requires Node 18+ on PATH (the hooks and validator are dependency-free node scripts). MIT licensed. ## Use -- `/code-map:init` — scan the current repo and draft its map - (`docs/reference/code-zones.md`, or `CODEMAP.md` at the root). +- `/code-map:init` (`$code-map:init` in Codex) — scan the current repo and + draft its map (`docs/reference/code-zones.md`, or `CODEMAP.md` at the root). - **Session start** — a mapped repo opens with the zone index in context, about 400 tokens for a 15-zone map. A map with problems gets one line naming the first. A git repo without a map shows a one-time hint to run @@ -31,11 +43,14 @@ scripts). MIT licensed. - **Prompts** — when a prompt moves into a high-risk zone or spans zones the session has not seen, those zones' entries go in, including which zones depend on them. Same-zone follow-ups and single low-risk edits stay silent. -- **Subagents** — an `Agent` call gets its task's zone entries appended to the - subagent's prompt, or a one-line pointer to the map. Subagents start without - your session's context. -- **File touches** — the first Read, Edit or Write in a zone the agent has not - seen adds that zone: the full entry for high risk, one line otherwise. +- **Subagents** — subagents start without your session's context. A Claude + `Agent` call gets its task's zone entries appended to the subagent's prompt, + or a one-line pointer to the map. A Codex subagent that inherits none of the + thread starts with the zones your session already loaded in full. +- **File touches** — the first read or edit in a zone the agent has not seen + adds that zone: the full entry for high risk, one line otherwise. Reads and + edits count from Read, Edit, Write, a Codex patch, or a plain `cat`, + `sed -n`, `head`, `tail` or `nl`. Editing a zone's entrypoint names the zones that depend on it and their verify commands. - **Unowned edits** — an edit landing in a file no zone owns gets flagged: