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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .claude-plugin/marketplace.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 1 addition & 1 deletion .claude-plugin/plugin.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
29 changes: 22 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,30 +12,45 @@ 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
`/code-map:init`.
- **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:
Expand Down
14 changes: 12 additions & 2 deletions hooks/hooks.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@
],
"PreToolUse": [
{
"matcher": "Agent",
"matcher": "Agent|.*spawn_agent",
"hooks": [
{
"type": "command",
Expand All @@ -43,14 +43,24 @@
]
},
{
"matcher": "Read|Edit|Write",
"matcher": "Read|Edit|Write|Bash",
"hooks": [
{
"type": "command",
"command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/touch.mjs\""
}
]
}
],
"SubagentStart": [
{
"hooks": [
{
"type": "command",
"command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/subagent-start.mjs\""
}
]
}
]
}
}
28 changes: 14 additions & 14 deletions scripts/orphan-check.mjs
Original file line number Diff line number Diff line change
@@ -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"),
},
}));
4 changes: 3 additions & 1 deletion scripts/session-start.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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.` });
}
28 changes: 14 additions & 14 deletions scripts/spawn.mjs
Original file line number Diff line number Diff line change
@@ -1,27 +1,27 @@
#!/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 } 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;
if (typeof task?.prompt !== "string" || task.prompt.includes(MARKER)) process.exit(0);
/* 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(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)}` } },
});
35 changes: 35 additions & 0 deletions scripts/subagent-start.mjs
Original file line number Diff line number Diff line change
@@ -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(", ")}` : "",
});
57 changes: 28 additions & 29 deletions scripts/touch.mjs
Original file line number Diff line number Diff line change
@@ -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);
Expand Down
Loading