@raymondchins/agentmap 0.2.3 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +42 -16
- package/agentmap.mjs +155 -42
- package/hooks/agentmap-nudge.mjs +88 -38
- package/hooks/post-commit +15 -4
- package/package.json +4 -3
package/README.md
CHANGED
|
@@ -4,14 +4,14 @@
|
|
|
4
4
|
|
|
5
5
|
# agentmap
|
|
6
6
|
|
|
7
|
-
**The repo map your coding agent is _forced_ to use — ~98% fewer tokens to understand your TS/JS codebase.**
|
|
7
|
+
**The repo map your coding agent is _forced_ to use — ~98% fewer context tokens to understand your TS/JS codebase.**
|
|
8
8
|
|
|
9
9
|
Your AI coding agent re-learns your codebase every session — opening files and grepping to find
|
|
10
10
|
what connects to what, burning tokens before it writes a line. agentmap gives it a **queryable,
|
|
11
11
|
ranked code-relationship map for TypeScript/JavaScript repos** instead — a `ts-morph` import/symbol
|
|
12
12
|
graph ranked by personalized PageRank. Ask it to *"add a field"* or *"fix the login bug"* and it
|
|
13
13
|
finds the right files, their imports, and what already exists in
|
|
14
|
-
**~98% fewer tokens on average** (up to
|
|
14
|
+
**~98% fewer context tokens on average** (up to **~99.9% per task**; figures are chars/4 estimates applied equally to both sides) — kept current by a post-commit
|
|
15
15
|
auto-refresh and actually used via a `PreToolUse(Grep)` hook.
|
|
16
16
|
|
|
17
17
|
[](https://www.npmjs.com/package/@raymondchins/agentmap)
|
|
@@ -60,6 +60,19 @@ across [zod](https://github.com/colinhacks/zod) (367 files, **99.2%**) and
|
|
|
60
60
|
on a single whole-repo map. Reproducible at pinned shas; full per-scenario tables in
|
|
61
61
|
**[`./benchmark/RESULTS.md`](./benchmark/RESULTS.md)**.
|
|
62
62
|
|
|
63
|
+
> **Methodology note:** the 58× overall figure is dominated by the whole-repo-load scenario
|
|
64
|
+
> (Scenario F — 150 K vs 1 K tokens), which skews the combined ratio sharply upward. Excluding it,
|
|
65
|
+
> the per-task overall ratio on the same sample repo is approximately 32×. Both numbers are real;
|
|
66
|
+
> the headline captures the most common agent worst-case (repo-dump on session start), while the
|
|
67
|
+
> per-task average better represents typical individual queries. RESULTS.md has the full breakdown.
|
|
68
|
+
|
|
69
|
+
**Fewer tokens, but are they the _right_ tokens?** Token efficiency is only half the story — a
|
|
70
|
+
separate [`EVAL.md`](./EVAL.md) (`npm run eval`) scores **retrieval accuracy** against ground
|
|
71
|
+
truth derived live from real repos (zod, zustand, hono). Headline: agentmap returns the symbol
|
|
72
|
+
definition in the **top 3 ~95%** of the time (naive grep ~79%) at **~2.6× fewer tokens**, and
|
|
73
|
+
identifies a module's dependents at **~100% precision** (grep ~58%). Honest tradeoffs and method
|
|
74
|
+
in EVAL.md.
|
|
75
|
+
|
|
63
76
|
**Speed:** a cold build (parse + PageRank + symbol graph) takes **~1.2s**; a warm cached query
|
|
64
77
|
returns in **~0.1s** (the lazy-loaded path added in 0.2.2) — the agent has a ranked answer back
|
|
65
78
|
before it would have finished opening the first handful of files.
|
|
@@ -133,15 +146,24 @@ the installed `agentmap` binary, then `npx --no-install @raymondchins/agentmap`.
|
|
|
133
146
|
|
|
134
147
|
### 2. Force the agent to use it — `PreToolUse` hook
|
|
135
148
|
|
|
136
|
-
[`hooks/agentmap-nudge.mjs`](./hooks/agentmap-nudge.mjs) is a **non-blocking**
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
149
|
+
[`hooks/agentmap-nudge.mjs`](./hooks/agentmap-nudge.mjs) is a **non-blocking** hook for
|
|
150
|
+
Claude Code that covers **both** the `Grep` tool and raw Bash text-searchers
|
|
151
|
+
(`grep`/`rg`/`egrep`/`fgrep`/`ag`/`ack`). When either looks like a dependency /
|
|
152
|
+
who-imports / component-usage / reuse / where-is-symbol search, it injects a reminder
|
|
153
|
+
steering the agent to `agentmap --any` first. It never denies the call, and stays silent
|
|
154
|
+
for raw-string / Tailwind-class / lowercase-HTML-tag sweeps and for pipe-filtered commands
|
|
155
|
+
like `ps aux | grep node` — so it's high-signal, not nagging.
|
|
156
|
+
|
|
157
|
+
**Fires on:** `import`/`require`/`export`/`from '...'` patterns, JSX component tags
|
|
158
|
+
(`<Hero`, `<ProviderCard`), explicit intent words (`where is`, `who imports`, `reuse`,
|
|
159
|
+
`existing component`), and — in the Bash branch — bare multi-hump PascalCase identifiers
|
|
160
|
+
(`ProviderCard`, `TopProviders`) that almost always mean "where is this symbol / who uses
|
|
161
|
+
it". The Bash branch only fires when the searcher is the *primary* command (at the start,
|
|
162
|
+
or after `;`/`&&`); piped log-filters stay silent.
|
|
163
|
+
|
|
164
|
+
`--install-hooks` writes both matchers into `.claude/settings.json` for you (merge-safe —
|
|
165
|
+
preserves existing settings, won't duplicate on re-run). The single hook file dispatches
|
|
166
|
+
internally on `tool_name`. For reference, or to wire it by hand:
|
|
145
167
|
|
|
146
168
|
```json
|
|
147
169
|
{
|
|
@@ -149,9 +171,11 @@ it by hand:
|
|
|
149
171
|
"PreToolUse": [
|
|
150
172
|
{
|
|
151
173
|
"matcher": "Grep",
|
|
152
|
-
"hooks": [
|
|
153
|
-
|
|
154
|
-
|
|
174
|
+
"hooks": [{ "type": "command", "command": "node ./hooks/agentmap-nudge.mjs" }]
|
|
175
|
+
},
|
|
176
|
+
{
|
|
177
|
+
"matcher": "Bash",
|
|
178
|
+
"hooks": [{ "type": "command", "command": "node ./hooks/agentmap-nudge.mjs" }]
|
|
155
179
|
}
|
|
156
180
|
]
|
|
157
181
|
}
|
|
@@ -159,7 +183,7 @@ it by hand:
|
|
|
159
183
|
```
|
|
160
184
|
|
|
161
185
|
That's the "forced to use it" in the tagline: the map stays current on its own, and the
|
|
162
|
-
agent is steered to it the moment it reaches for a dependency-shaped grep.
|
|
186
|
+
agent is steered to it the moment it reaches for a dependency-shaped grep or Bash search.
|
|
163
187
|
|
|
164
188
|
---
|
|
165
189
|
|
|
@@ -493,7 +517,9 @@ Honesty first — this is deliberately a small, sharp tool, not a universal code
|
|
|
493
517
|
|
|
494
518
|
Issues and PRs welcome. High-value directions:
|
|
495
519
|
|
|
496
|
-
-
|
|
520
|
+
- Retrieval-accuracy eval — **done** ([`EVAL.md`](./EVAL.md), `npm run eval`). Next: a
|
|
521
|
+
type-aware dependents mode (the eval excludes type-only edges to match the value-import
|
|
522
|
+
graph) and an `app/`-router fixture so `--feature` retrieval can be scored too.
|
|
497
523
|
- A real tokenizer behind the `--map` budget.
|
|
498
524
|
- Hardening feature detection for non-`app/`-router layouts.
|
|
499
525
|
|
package/agentmap.mjs
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
// Near-zero deps (ts-morph only). Runs in the target repo's cwd.
|
|
14
14
|
// Algorithm credit: Aider's repo map (Apache-2.0) — github.com/Aider-AI/aider
|
|
15
15
|
// ============================================================================
|
|
16
|
-
import { writeFileSync, readFileSync, existsSync, mkdirSync, renameSync, readdirSync, statSync, chmodSync } from "node:fs";
|
|
16
|
+
import { writeFileSync, readFileSync, existsSync, mkdirSync, renameSync, readdirSync, statSync, lstatSync, chmodSync } from "node:fs";
|
|
17
17
|
import { execSync, execFileSync } from "node:child_process";
|
|
18
18
|
import { createHash } from "node:crypto";
|
|
19
19
|
import { createRequire } from "node:module";
|
|
@@ -26,7 +26,8 @@ const _require = createRequire(import.meta.url);
|
|
|
26
26
|
let _tsm = null;
|
|
27
27
|
const tsMorph = () => (_tsm ??= _require("ts-morph"));
|
|
28
28
|
|
|
29
|
-
const MAP = ".claude/agentmap.json";
|
|
29
|
+
const MAP = ".claude/agentmap/map.json";
|
|
30
|
+
const MAP_LEGACY = ".claude/agentmap.json"; // pre-namespacing path; read for migration
|
|
30
31
|
const SCHEMA_VERSION = 2;
|
|
31
32
|
|
|
32
33
|
// ---------------------------------------------------------------------------
|
|
@@ -58,9 +59,21 @@ const sh = (c) => { try { return execSync(c, { stdio: ["ignore", "pipe", "ignore
|
|
|
58
59
|
// untracked files (skips gitignored paths like node_modules). Reads DISK, so
|
|
59
60
|
// never stale. -F = fixed-string so literals like "bg-[#faf8f2]" aren't regex.
|
|
60
61
|
// stderr ignored so "fatal: not a git repository" stays quiet in non-git repos.
|
|
62
|
+
// Exclude sensitive files from the --untracked sweep so a local .env / key /
|
|
63
|
+
// secrets file never gets scanned and surfaced (and via MCP fed to an LLM).
|
|
64
|
+
// Mix of path globs (env/key/cert/SSH-key shapes) and case-insensitive name
|
|
65
|
+
// matches (anything *secret* / *credential* / *.password*). These are pathspecs,
|
|
66
|
+
// not regexes — git applies them as exclusions to the search tree.
|
|
67
|
+
const SENSITIVE_EXCLUDES = [
|
|
68
|
+
":!.env", ":!.env.*", ":!**/.env", ":!**/.env.*",
|
|
69
|
+
// also any *.env (e.g. prod.env, .env.local already covered above) at any depth
|
|
70
|
+
":!*.env", ":!**/*.env",
|
|
71
|
+
":!*.pem", ":!*.key", ":!*.p12", ":!*.pfx", ":!*.crt", ":!id_rsa*",
|
|
72
|
+
":(exclude,icase)*secret*", ":(exclude,icase)*credential*", ":(exclude,icase)*.password*",
|
|
73
|
+
];
|
|
61
74
|
const contentSearch = (q) => {
|
|
62
75
|
try {
|
|
63
|
-
return execFileSync("git", ["grep", "-F", "--untracked", "-n", "-i", "-I", "-e", q, "--", ".", ":!.claude/agentmap
|
|
76
|
+
return execFileSync("git", ["grep", "-F", "--untracked", "-n", "-i", "-I", "-e", q, "--", ".", ":!.claude/agentmap/", ...SENSITIVE_EXCLUDES], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], maxBuffer: MAXBUF }).trim();
|
|
64
77
|
} catch { return ""; }
|
|
65
78
|
};
|
|
66
79
|
const currentSha = () => sh("git rev-parse --short HEAD");
|
|
@@ -92,7 +105,12 @@ function sourceFingerprint() {
|
|
|
92
105
|
if (name === "node_modules" || name === ".git" || name === ".next") continue;
|
|
93
106
|
const full = dir + "/" + name;
|
|
94
107
|
let st;
|
|
95
|
-
|
|
108
|
+
// lstatSync (NOT statSync) so a symlink reports as a symlink instead of
|
|
109
|
+
// its target. Symlinked entries are SKIPPED entirely — never recursed
|
|
110
|
+
// into, never stat'd through — so a circular symlink can't cause infinite
|
|
111
|
+
// recursion / stack overflow.
|
|
112
|
+
try { st = lstatSync(full); } catch { continue; }
|
|
113
|
+
if (st.isSymbolicLink()) continue;
|
|
96
114
|
if (st.isDirectory()) walk(full);
|
|
97
115
|
else if (SRC_EXT.test(name)) entries.push(`${full}:${st.mtimeMs}:${st.size}`);
|
|
98
116
|
}
|
|
@@ -394,9 +412,9 @@ function build() {
|
|
|
394
412
|
fingerprint: sha ? undefined : sourceFingerprint(),
|
|
395
413
|
hubs, features, rankedSymbols: rankedSymbols.slice(0, RANKED_SYMBOLS_LIMIT), files,
|
|
396
414
|
};
|
|
397
|
-
mkdirSync(".claude", { recursive: true });
|
|
415
|
+
mkdirSync(".claude/agentmap", { recursive: true });
|
|
398
416
|
// Atomic write: tmp + rename so a concurrent background rebuild can never
|
|
399
|
-
// expose a torn/truncated
|
|
417
|
+
// expose a torn/truncated map.json to a reader.
|
|
400
418
|
const tmp = MAP + ".tmp";
|
|
401
419
|
writeFileSync(tmp, JSON.stringify(out));
|
|
402
420
|
renameSync(tmp, MAP);
|
|
@@ -500,9 +518,13 @@ function rankSymbols(files, focus) {
|
|
|
500
518
|
// clean tree. A dirty tree REBUILDS from disk so queries reflect in-flight edits.
|
|
501
519
|
function ensureFresh() {
|
|
502
520
|
const sha = currentSha();
|
|
503
|
-
|
|
521
|
+
// Read the namespaced path; fall back to the legacy '.claude/agentmap.json'
|
|
522
|
+
// when the new path is missing (migration from a pre-namespacing install — the
|
|
523
|
+
// legacy file is still trustworthy, the next build() rewrites to the new path).
|
|
524
|
+
const mapPath = existsSync(MAP) ? MAP : (existsSync(MAP_LEGACY) ? MAP_LEGACY : MAP);
|
|
525
|
+
if (existsSync(mapPath)) {
|
|
504
526
|
try {
|
|
505
|
-
const cached = JSON.parse(readFileSync(
|
|
527
|
+
const cached = JSON.parse(readFileSync(mapPath, "utf8"));
|
|
506
528
|
// Trust cache only if: same HEAD, known schema, it was built CLEAN
|
|
507
529
|
// (cached.dirty === 0 — never trust a map built mid-edit, even after a
|
|
508
530
|
// revert returns the tree to clean), AND the tree is clean right now.
|
|
@@ -542,67 +564,156 @@ function fileBlock(key, f) {
|
|
|
542
564
|
console.log(`dependents (${f.dependents.length}): ${f.dependents.join(", ") || "—"}`);
|
|
543
565
|
}
|
|
544
566
|
|
|
567
|
+
// Strip // line comments and /* */ block comments from a JSONC string WITHOUT
|
|
568
|
+
// touching comment-like sequences inside double-quoted strings (so a value like
|
|
569
|
+
// "https://x" or "a /* b */ c" is preserved verbatim). Single-pass state machine:
|
|
570
|
+
// tracks whether we're inside a string (and an escape inside it) vs a line/block
|
|
571
|
+
// comment. Trailing commas are NOT handled — only comments, which is what real-
|
|
572
|
+
// world settings.json files carry.
|
|
573
|
+
function stripJsonComments(src) {
|
|
574
|
+
let out = "";
|
|
575
|
+
let inStr = false, esc = false, inLine = false, inBlock = false;
|
|
576
|
+
for (let i = 0; i < src.length; i++) {
|
|
577
|
+
const c = src[i], n = src[i + 1];
|
|
578
|
+
if (inLine) { if (c === "\n") { inLine = false; out += c; } continue; }
|
|
579
|
+
if (inBlock) { if (c === "*" && n === "/") { inBlock = false; i++; } continue; }
|
|
580
|
+
if (inStr) {
|
|
581
|
+
out += c;
|
|
582
|
+
if (esc) esc = false;
|
|
583
|
+
else if (c === "\\") esc = true;
|
|
584
|
+
else if (c === '"') inStr = false;
|
|
585
|
+
continue;
|
|
586
|
+
}
|
|
587
|
+
if (c === '"') { inStr = true; out += c; continue; }
|
|
588
|
+
if (c === "/" && n === "/") { inLine = true; i++; continue; }
|
|
589
|
+
if (c === "/" && n === "*") { inBlock = true; i++; continue; }
|
|
590
|
+
out += c;
|
|
591
|
+
}
|
|
592
|
+
return out;
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
// Parse a settings.json that may be JSONC: try strict JSON first, then retry
|
|
596
|
+
// after stripping comments, and only then surface the caller's clear error.
|
|
597
|
+
function parseSettings(text, settingsPath) {
|
|
598
|
+
try { return JSON.parse(text) || {}; }
|
|
599
|
+
catch {
|
|
600
|
+
try { return JSON.parse(stripJsonComments(text)) || {}; }
|
|
601
|
+
catch { throw new Error(`${settingsPath} is not valid JSON — fix or remove it, then re-run --install-hooks`); }
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
|
|
545
605
|
// ---------------------------------------------------------------------------
|
|
546
|
-
// --install-hooks: copy the package post-commit hook into .git/hooks,
|
|
547
|
-
// .claude/agentmap.
|
|
548
|
-
// PreToolUse(Grep) nudge into
|
|
549
|
-
// enforcement is ON by default (no
|
|
550
|
-
//
|
|
606
|
+
// --install-hooks: copy the package post-commit hook into .git/hooks, copy the
|
|
607
|
+
// PreToolUse nudge into .claude/hooks/agentmap-nudge.mjs, ensure .claude/agentmap/
|
|
608
|
+
// is gitignored, and auto-wire the Claude Code PreToolUse(Grep|Bash) nudge into
|
|
609
|
+
// the project's .claude/settings.json so map enforcement is ON by default (no
|
|
610
|
+
// manual copy-paste). Merge-safe + idempotent. With { dryRun:true } it prints the
|
|
611
|
+
// files it WOULD touch and writes nothing. Throws on any failure so the caller
|
|
612
|
+
// can stderr+exit 1.
|
|
551
613
|
// ---------------------------------------------------------------------------
|
|
552
|
-
function installHooks() {
|
|
614
|
+
function installHooks({ dryRun = false } = {}) {
|
|
553
615
|
const src = new URL("./hooks/post-commit", import.meta.url);
|
|
554
616
|
// The package hooks/ dir must ship alongside agentmap.mjs.
|
|
555
617
|
if (!existsSync(src)) throw new Error(`packaged hook not found at ${src.pathname} (is the hooks/ dir present?)`);
|
|
618
|
+
// The PreToolUse nudge that gets COPIED into the project (see below). It must
|
|
619
|
+
// ship alongside agentmap.mjs too.
|
|
620
|
+
const nudgeSrc = new URL("./hooks/agentmap-nudge.mjs", import.meta.url);
|
|
621
|
+
if (!existsSync(nudgeSrc)) throw new Error(`packaged nudge not found at ${nudgeSrc.pathname} (is the hooks/ dir present?)`);
|
|
556
622
|
|
|
557
623
|
// Locate the git dir of the CURRENT repo (cwd), then copy in the hook.
|
|
558
624
|
const gitDir = sh("git rev-parse --git-dir");
|
|
559
625
|
if (!gitDir) throw new Error("not a git repository (cwd has no .git) — run inside the repo you want to wire up");
|
|
560
626
|
const hooksDir = `${gitDir}/hooks`;
|
|
561
|
-
mkdirSync(hooksDir, { recursive: true });
|
|
562
627
|
const dest = `${hooksDir}/post-commit`;
|
|
563
|
-
writeFileSync(dest, readFileSync(src, "utf8"), { mode: 0o755 });
|
|
564
|
-
chmodSync(dest, 0o755); // explicit: writeFileSync mode is masked by umask
|
|
565
628
|
|
|
566
|
-
//
|
|
567
|
-
|
|
629
|
+
// The nudge is copied into the PROJECT (not referenced inside node_modules) so
|
|
630
|
+
// the documented one-liner `npx @raymondchins/agentmap --install-hooks` works
|
|
631
|
+
// even though npx never populates ./node_modules — the old path
|
|
632
|
+
// node_modules/@raymondchins/agentmap/hooks/agentmap-nudge.mjs simply does not
|
|
633
|
+
// exist after an npx install, so the hook silently never fired. The nudge is
|
|
634
|
+
// self-contained (Node stdlib only, no relative package imports), so copying it
|
|
635
|
+
// standalone is safe. CLAUDE_PROJECT_DIR is set by Claude Code at hook time, so
|
|
636
|
+
// the wired command resolves the copied file regardless of cwd.
|
|
637
|
+
const nudgeDestRel = ".claude/hooks/agentmap-nudge.mjs";
|
|
638
|
+
const NUDGE_CMD = `node "$CLAUDE_PROJECT_DIR/.claude/hooks/agentmap-nudge.mjs"`;
|
|
639
|
+
|
|
640
|
+
// .gitignore line: ignore the namespaced map DIR (not the legacy single file).
|
|
641
|
+
const IGNORE_LINE = ".claude/agentmap/";
|
|
642
|
+
const settingsPath = ".claude/settings.json";
|
|
643
|
+
|
|
644
|
+
// --- Determine what WOULD change (so --dry-run and the pre-write notice both
|
|
645
|
+
// describe the real plan). ---
|
|
568
646
|
let ignoredAlready = false;
|
|
569
647
|
if (existsSync(".gitignore")) {
|
|
570
|
-
|
|
571
|
-
if (cur.split(/\r?\n/).some((l) => l.trim() === IGNORE_LINE)) ignoredAlready = true;
|
|
572
|
-
else writeFileSync(".gitignore", cur + (cur.endsWith("\n") || cur === "" ? "" : "\n") + IGNORE_LINE + "\n");
|
|
573
|
-
} else {
|
|
574
|
-
writeFileSync(".gitignore", IGNORE_LINE + "\n");
|
|
648
|
+
ignoredAlready = readFileSync(".gitignore", "utf8").split(/\r?\n/).some((l) => l.trim() === IGNORE_LINE);
|
|
575
649
|
}
|
|
576
|
-
|
|
577
|
-
// Auto-wire the PreToolUse(Grep) enforcement nudge into the PROJECT settings
|
|
578
|
-
// (.claude/settings.json) so "the agent is forced to use the map" is ON by
|
|
579
|
-
// default — not a manual paste. Merge-safe + idempotent: preserves any
|
|
580
|
-
// existing settings/hooks, never duplicates our entry. Uses a project-relative
|
|
581
|
-
// command so a committed settings.json stays portable across machines.
|
|
582
|
-
const NUDGE_CMD = "node node_modules/@raymondchins/agentmap/hooks/agentmap-nudge.mjs";
|
|
583
|
-
const settingsPath = ".claude/settings.json";
|
|
584
650
|
let settings = {};
|
|
585
651
|
if (existsSync(settingsPath)) {
|
|
586
|
-
|
|
587
|
-
catch { throw new Error(`${settingsPath} is not valid JSON — fix or remove it, then re-run --install-hooks`); }
|
|
652
|
+
settings = parseSettings(readFileSync(settingsPath, "utf8"), settingsPath);
|
|
588
653
|
}
|
|
589
654
|
settings.hooks ??= {};
|
|
590
655
|
settings.hooks.PreToolUse ??= [];
|
|
591
|
-
const
|
|
592
|
-
(e) => Array.isArray(e?.hooks) && e.hooks.some((h) => typeof h?.command === "string" && h.command.includes("agentmap-nudge")),
|
|
656
|
+
const hasGrep = settings.hooks.PreToolUse.some(
|
|
657
|
+
(e) => e?.matcher === "Grep" && Array.isArray(e?.hooks) && e.hooks.some((h) => typeof h?.command === "string" && h.command.includes("agentmap-nudge")),
|
|
658
|
+
);
|
|
659
|
+
const hasBash = settings.hooks.PreToolUse.some(
|
|
660
|
+
(e) => e?.matcher === "Bash" && Array.isArray(e?.hooks) && e.hooks.some((h) => typeof h?.command === "string" && h.command.includes("agentmap-nudge")),
|
|
593
661
|
);
|
|
662
|
+
const alreadyWired = hasGrep && hasBash;
|
|
663
|
+
|
|
664
|
+
// The set of files this run touches — used by both the dry-run report and the
|
|
665
|
+
// one-line pre-write notice in the normal path.
|
|
666
|
+
const targets = [
|
|
667
|
+
dest, // .git/hooks/post-commit
|
|
668
|
+
nudgeDestRel, // .claude/hooks/agentmap-nudge.mjs
|
|
669
|
+
...(ignoredAlready ? [] : [".gitignore"]), // only if the ignore line is missing
|
|
670
|
+
...(alreadyWired ? [] : [settingsPath]), // only if not already wired
|
|
671
|
+
];
|
|
672
|
+
|
|
673
|
+
if (dryRun) {
|
|
674
|
+
console.log("--dry-run: would create/overwrite the following files (no changes written):");
|
|
675
|
+
for (const t of targets) console.log(` ${t}`);
|
|
676
|
+
return;
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
// Normal path: announce the plan, then write.
|
|
680
|
+
console.log(`agentmap --install-hooks: writing ${targets.length} file(s): ${targets.join(", ")}`);
|
|
681
|
+
|
|
682
|
+
// 1) post-commit hook → .git/hooks
|
|
683
|
+
mkdirSync(hooksDir, { recursive: true });
|
|
684
|
+
writeFileSync(dest, readFileSync(src, "utf8"), { mode: 0o755 });
|
|
685
|
+
chmodSync(dest, 0o755); // explicit: writeFileSync mode is masked by umask
|
|
686
|
+
|
|
687
|
+
// 2) nudge → .claude/hooks/agentmap-nudge.mjs (idempotent overwrite-on-rerun)
|
|
688
|
+
mkdirSync(".claude/hooks", { recursive: true });
|
|
689
|
+
writeFileSync(nudgeDestRel, readFileSync(nudgeSrc, "utf8"));
|
|
690
|
+
|
|
691
|
+
// 3) .gitignore: ignore the namespaced map dir (append/create).
|
|
692
|
+
if (!ignoredAlready) {
|
|
693
|
+
if (existsSync(".gitignore")) {
|
|
694
|
+
const cur = readFileSync(".gitignore", "utf8");
|
|
695
|
+
writeFileSync(".gitignore", cur + (cur.endsWith("\n") || cur === "" ? "" : "\n") + IGNORE_LINE + "\n");
|
|
696
|
+
} else {
|
|
697
|
+
writeFileSync(".gitignore", IGNORE_LINE + "\n");
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
// 4) Auto-wire the PreToolUse(Grep|Bash) nudge into project settings. Merge-
|
|
702
|
+
// safe + idempotent: preserves existing settings/hooks, never duplicates ours.
|
|
703
|
+
if (!hasGrep) settings.hooks.PreToolUse.push({ matcher: "Grep", hooks: [{ type: "command", command: NUDGE_CMD }] });
|
|
704
|
+
if (!hasBash) settings.hooks.PreToolUse.push({ matcher: "Bash", hooks: [{ type: "command", command: NUDGE_CMD }] });
|
|
594
705
|
if (!alreadyWired) {
|
|
595
|
-
settings.hooks.PreToolUse.push({ matcher: "Grep", hooks: [{ type: "command", command: NUDGE_CMD }] });
|
|
596
706
|
mkdirSync(".claude", { recursive: true });
|
|
597
707
|
writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + "\n");
|
|
598
708
|
}
|
|
599
709
|
|
|
600
710
|
// Success report.
|
|
601
711
|
console.log(`installed post-commit hook → ${dest}`);
|
|
712
|
+
console.log(`installed PreToolUse nudge → ${nudgeDestRel}`);
|
|
602
713
|
console.log(ignoredAlready ? `.gitignore already has ${IGNORE_LINE}` : `added ${IGNORE_LINE} to .gitignore`);
|
|
603
714
|
console.log(alreadyWired
|
|
604
|
-
? `${settingsPath} already wires the PreToolUse(Grep) → agentmap nudge — left as-is`
|
|
605
|
-
: `wired PreToolUse(Grep) → agentmap nudge into ${settingsPath} (map enforcement on by default)`);
|
|
715
|
+
? `${settingsPath} already wires the PreToolUse(Grep|Bash) → agentmap nudge — left as-is`
|
|
716
|
+
: `wired PreToolUse(Grep|Bash) → agentmap nudge into ${settingsPath} (map enforcement on by default)`);
|
|
606
717
|
console.log("\nDone — the map auto-refreshes on commit, and greps are nudged to agentmap first.");
|
|
607
718
|
}
|
|
608
719
|
|
|
@@ -628,7 +739,7 @@ const out = (obj, prose) => { if (wantJson) console.log(JSON.stringify(obj)); el
|
|
|
628
739
|
// NOT in this set is an unknown flag → usage error (exit 2), not a silent build.
|
|
629
740
|
const KNOWN = new Set([
|
|
630
741
|
"--json", "--print",
|
|
631
|
-
"--help", "-h", "--version", "-v", "--install-hooks", "--mcp",
|
|
742
|
+
"--help", "-h", "--version", "-v", "--install-hooks", "--dry-run", "--mcp",
|
|
632
743
|
"--any", "--find", "--relates", "--map", "--focus", "--tokens",
|
|
633
744
|
"--symbols", "--feature", "--features", "--hubs",
|
|
634
745
|
]);
|
|
@@ -662,7 +773,9 @@ Global modifier:
|
|
|
662
773
|
--json emit exactly one JSON object (no prose) for the command
|
|
663
774
|
|
|
664
775
|
Maintenance:
|
|
665
|
-
--install-hooks
|
|
776
|
+
--install-hooks [--dry-run]
|
|
777
|
+
install git post-commit + copy the PreToolUse nudge +
|
|
778
|
+
wire .claude/settings.json (--dry-run = preview, no writes)
|
|
666
779
|
--mcp start a stdio MCP server (for MCP-capable agents)
|
|
667
780
|
--help, -h show this help
|
|
668
781
|
--version, -v print the version
|
|
@@ -695,7 +808,7 @@ if (has("--mcp")) {
|
|
|
695
808
|
// --install-hooks: wire the git post-commit refresh + emit the PreToolUse
|
|
696
809
|
// snippet. Self-contained (resolves the package hooks/ dir relative to here).
|
|
697
810
|
else if (has("--install-hooks")) {
|
|
698
|
-
try { installHooks(); process.exit(0); }
|
|
811
|
+
try { installHooks({ dryRun: has("--dry-run") }); process.exit(0); }
|
|
699
812
|
catch (e) { console.error(`agentmap --install-hooks failed: ${e?.message || e}`); process.exit(1); }
|
|
700
813
|
}
|
|
701
814
|
// Unknown-flag guard: any "-"-prefixed token not in KNOWN → usage error (exit
|
package/hooks/agentmap-nudge.mjs
CHANGED
|
@@ -1,24 +1,35 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
// SPDX-License-Identifier: MIT
|
|
3
3
|
// ============================================================================
|
|
4
|
-
// agentmap — PreToolUse(Grep
|
|
4
|
+
// agentmap — PreToolUse nudge hook (Grep tool + Bash text-searchers)
|
|
5
5
|
//
|
|
6
|
-
// Steers dependency / who-imports / reuse / component-usage
|
|
7
|
-
// agentmap repo-map instead of serial grep. NON-BLOCKING:
|
|
8
|
-
// reminder via `additionalContext`; never denies the
|
|
9
|
-
// path. Dependency-free (Node stdlib only) — Claude Code
|
|
10
|
-
// JSON on stdin.
|
|
6
|
+
// Steers dependency / who-imports / reuse / component-usage / where-is-symbol
|
|
7
|
+
// searches toward the agentmap repo-map instead of serial grep. NON-BLOCKING:
|
|
8
|
+
// only ever injects a reminder via `additionalContext`; never denies the call.
|
|
9
|
+
// Exits 0 on every path. Dependency-free (Node stdlib only) — Claude Code
|
|
10
|
+
// pipes the tool-call JSON on stdin.
|
|
11
11
|
//
|
|
12
|
-
//
|
|
12
|
+
// Why the Bash branch: the original hook only watched the Grep TOOL, so any
|
|
13
|
+
// search run as raw `grep`/`rg` via Bash bypassed the nudge entirely — the
|
|
14
|
+
// exact gap that let an agent forget agentmap and fall back to manual
|
|
15
|
+
// Read/sed/awk. This closes it.
|
|
16
|
+
//
|
|
17
|
+
// Heuristic: fires when the search looks like (a) a dependency hunt
|
|
13
18
|
// (import/require/export / "from '..." / who-imports), (b) a component /
|
|
14
19
|
// "where-is" / reuse lookup (a JSX component tag like <Heading, or where-is /
|
|
15
|
-
// who-uses / reuse / existing-component intent words)
|
|
16
|
-
//
|
|
17
|
-
//
|
|
20
|
+
// who-uses / reuse / existing-component intent words), (c) — Bash only — a
|
|
21
|
+
// bare multi-hump PascalCase identifier (ProviderCard, TopProviders), almost
|
|
22
|
+
// always a "where is this symbol / who uses it" hunt. A raw string or
|
|
23
|
+
// Tailwind-class search (bg-white, text-3xl) and lowercase HTML-tag sweeps
|
|
24
|
+
// (<div, <h1) produce NO output — no nagging.
|
|
25
|
+
//
|
|
26
|
+
// Bash branch only fires when grep/rg/ag is the PRIMARY command (at start, or
|
|
27
|
+
// after `;` / `&&` — NOT after a pipe, so `… | grep SomeError` log-filtering
|
|
28
|
+
// stays silent).
|
|
18
29
|
//
|
|
19
|
-
//
|
|
20
|
-
//
|
|
21
|
-
//
|
|
30
|
+
// Injection-safe: the user's pattern/command is ONLY regex-tested, never
|
|
31
|
+
// interpolated into the emitted message or executed. Output is a single fixed
|
|
32
|
+
// JSON object.
|
|
22
33
|
// ============================================================================
|
|
23
34
|
|
|
24
35
|
let raw = "";
|
|
@@ -27,14 +38,8 @@ process.stdin.on("data", (c) => (raw += c));
|
|
|
27
38
|
process.stdin.on("end", () => {
|
|
28
39
|
try {
|
|
29
40
|
const payload = JSON.parse(raw || "{}");
|
|
41
|
+
const tool = String(payload.tool_name || "");
|
|
30
42
|
const ti = payload.tool_input || {};
|
|
31
|
-
const pattern = String(ti.pattern || "");
|
|
32
|
-
|
|
33
|
-
// Defensive guard: pathological-input belt-and-suspenders.
|
|
34
|
-
// If the pattern is unreasonably long, skip nudging entirely.
|
|
35
|
-
if (pattern.length > 2000) {
|
|
36
|
-
process.exit(0);
|
|
37
|
-
}
|
|
38
43
|
|
|
39
44
|
// (a) Dependency / who-imports / reuse intent signals in the pattern itself.
|
|
40
45
|
const DEP_RE =
|
|
@@ -45,11 +50,14 @@ process.stdin.on("end", () => {
|
|
|
45
50
|
// raw HTML/content sweeps of <div>/<h1> silent, so it stays high-signal for
|
|
46
51
|
// "where is this component used/defined".
|
|
47
52
|
//
|
|
48
|
-
// Denylist: common TS generic/utility type containers
|
|
49
|
-
//
|
|
50
|
-
//
|
|
53
|
+
// Denylist: common TS generic/utility type containers (e.g. <Promise<,
|
|
54
|
+
// <Record<string) that look like a component tag but are NOT React components.
|
|
55
|
+
// NOT ^-anchored — matches ANYWHERE, because the Bash branch tests the whole
|
|
56
|
+
// command (the generic sits mid-string, e.g. `rg "<Promise<Foo>"`) and a
|
|
57
|
+
// generic can also appear mid-pattern in Grep (e.g. useState<Promise>). The
|
|
58
|
+
// `\b` after the name keeps real components like <PromiseCard / <MapView firing.
|
|
51
59
|
const GENERIC_DENYLIST =
|
|
52
|
-
|
|
60
|
+
/<(Promise|Array|Map|Set|Record|Partial|Readonly|Pick|Omit|Required|Exclude|Extract|NonNullable|ReturnType|Awaited|Parameters|InstanceType)\b/;
|
|
53
61
|
const COMPONENT_TAG_RE = /<[A-Z][\w.]*/;
|
|
54
62
|
|
|
55
63
|
// (c) Explicit reuse / "where-is" intent words (case-insensitive): "where is",
|
|
@@ -58,21 +66,63 @@ process.stdin.on("end", () => {
|
|
|
58
66
|
const INTENT_RE =
|
|
59
67
|
/\bwhere\s+is\b|\bwho\s+(imports|uses|renders)\b|\breuse\b|\b(existing|shared)\s+(util|component|hook|helper)\b|\bis\s+there\s+(an?\s+)?(existing|shared)\b/i;
|
|
60
68
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
69
|
+
// (d) Bare multi-hump PascalCase identifier (e.g. ProviderCard, TopProviders).
|
|
70
|
+
// Bash branch only. Two humps required so all-caps (TODO, API), single-word
|
|
71
|
+
// (Error, Button) and lowercase (useState) stay silent → high signal, no
|
|
72
|
+
// Tailwind/raw-string noise.
|
|
73
|
+
const SYMBOL_RE = /\b[A-Z][a-z0-9]+[A-Z][A-Za-z0-9]*\b/;
|
|
74
|
+
|
|
75
|
+
let fire = false;
|
|
76
|
+
|
|
77
|
+
if (tool === "Grep") {
|
|
78
|
+
const pattern = String(ti.pattern || "");
|
|
79
|
+
|
|
80
|
+
// Defensive guard: pathological-input belt-and-suspenders.
|
|
81
|
+
// If the pattern is unreasonably long, skip nudging entirely.
|
|
82
|
+
if (pattern.length > 2000) {
|
|
83
|
+
process.exit(0);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
fire =
|
|
87
|
+
!!pattern &&
|
|
88
|
+
(DEP_RE.test(pattern) ||
|
|
89
|
+
(COMPONENT_TAG_RE.test(pattern) && !GENERIC_DENYLIST.test(pattern)) ||
|
|
90
|
+
INTENT_RE.test(pattern));
|
|
91
|
+
} else if (tool === "Bash") {
|
|
92
|
+
const cmd = String(ti.command || "");
|
|
93
|
+
// Only when grep/rg/ag is the PRIMARY command (start, or after ; / && — NOT
|
|
94
|
+
// after a pipe, so `… | grep SomeError` log-filtering stays silent). Then
|
|
95
|
+
// test the whole command, plus the symbol rule for bare-identifier symbol
|
|
96
|
+
// hunts.
|
|
97
|
+
const SEARCHER_RE = /(^|[;&]\s*)(rg|ripgrep|grep|egrep|fgrep|ag|ack)\b/;
|
|
98
|
+
if (SEARCHER_RE.test(cmd)) {
|
|
99
|
+
// Guard: if any operand token references a non-source data file, stay
|
|
100
|
+
// silent — e.g. `rg TypeError app.log` is log-filtering, not a
|
|
101
|
+
// symbol/component hunt. Match on extension only (not inside quoted
|
|
102
|
+
// patterns) by scanning whitespace-separated tokens for a data-file ext.
|
|
103
|
+
const DATA_FILE_RE = /\.(log|txt|out|csv|tsv|jsonl|ndjson|json|md|ya?ml|xml)(\b|$)/i;
|
|
104
|
+
const hasDataFileTarget = cmd.split(/\s+/).some((tok) => DATA_FILE_RE.test(tok));
|
|
105
|
+
if (!hasDataFileTarget) {
|
|
106
|
+
fire =
|
|
107
|
+
DEP_RE.test(cmd) ||
|
|
108
|
+
(COMPONENT_TAG_RE.test(cmd) && !GENERIC_DENYLIST.test(cmd)) ||
|
|
109
|
+
INTENT_RE.test(cmd) ||
|
|
110
|
+
SYMBOL_RE.test(cmd);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (fire) {
|
|
116
|
+
const AM = "node node_modules/@raymondchins/agentmap/agentmap.mjs";
|
|
67
117
|
const msg =
|
|
68
|
-
"This
|
|
69
|
-
"Use agentmap FIRST — it's faster than serial grep. Easiest: " +
|
|
70
|
-
"`
|
|
71
|
-
"
|
|
72
|
-
"Or be specific:
|
|
73
|
-
"
|
|
74
|
-
"
|
|
75
|
-
"Rebuild the map with `
|
|
118
|
+
"This looks like a dependency / component / who-imports / reuse / where-is-symbol " +
|
|
119
|
+
"search. Use agentmap FIRST — it's faster than serial grep. Easiest: " +
|
|
120
|
+
"`" + AM + " --any <query>` " +
|
|
121
|
+
"(one command — auto-routes file → symbol → feature → live content). " +
|
|
122
|
+
"Or be specific: `" + AM + " --relates <path>` (blast radius / who-imports), " +
|
|
123
|
+
"`" + AM + " --find <symbol>` (reuse-before-rebuild / where a component is defined), " +
|
|
124
|
+
"`" + AM + " --feature <name>` (files in a feature). " +
|
|
125
|
+
"Rebuild the map with `npm run agentmap` (or `npx @raymondchins/agentmap`) if it's stale. " +
|
|
76
126
|
"Only fall back to grep if agentmap doesn't cover it.";
|
|
77
127
|
process.stdout.write(
|
|
78
128
|
JSON.stringify({
|
package/hooks/post-commit
CHANGED
|
@@ -28,15 +28,26 @@ for state in rebase-merge rebase-apply MERGE_HEAD CHERRY_PICK_HEAD BISECT_LOG RE
|
|
|
28
28
|
fi
|
|
29
29
|
done
|
|
30
30
|
|
|
31
|
-
#
|
|
31
|
+
# ---------------------------------------------------------------------------
|
|
32
|
+
# Runner resolution — security rationale:
|
|
33
|
+
#
|
|
34
|
+
# Only TWO repo-local paths are trusted: ./agentmap.mjs (this repo's own
|
|
35
|
+
# dogfooding entry-point, reviewed and version-controlled at the root) and
|
|
36
|
+
# nothing else. ./scripts/agentmap.mjs was removed because it is an unusual
|
|
37
|
+
# path that a malicious PR could introduce to gain arbitrary code execution
|
|
38
|
+
# on a victim's next commit without touching any obviously sensitive file.
|
|
39
|
+
#
|
|
40
|
+
# Set AGENTMAP_HOOK_NO_LOCAL=1 to skip even ./agentmap.mjs and rely solely
|
|
41
|
+
# on the installed binary / npx — useful in CI or when reviewing untrusted
|
|
42
|
+
# branches.
|
|
43
|
+
#
|
|
32
44
|
# We cd "$ROOT" before running, so use RELATIVE paths — avoids word-splitting on
|
|
33
45
|
# spaces in the repo path (POSIX sh has no arrays; quoting $RUNNER at invocation
|
|
34
46
|
# would bundle cmd+args into one token and break argument passing).
|
|
47
|
+
# ---------------------------------------------------------------------------
|
|
35
48
|
RUNNER=""
|
|
36
|
-
if [ -f "$ROOT/agentmap.mjs" ]; then
|
|
49
|
+
if [ -z "$AGENTMAP_HOOK_NO_LOCAL" ] && [ -f "$ROOT/agentmap.mjs" ]; then
|
|
37
50
|
RUNNER="node ./agentmap.mjs"
|
|
38
|
-
elif [ -f "$ROOT/scripts/agentmap.mjs" ]; then
|
|
39
|
-
RUNNER="node ./scripts/agentmap.mjs"
|
|
40
51
|
elif command -v agentmap >/dev/null 2>&1; then
|
|
41
52
|
# Bare binary name — the installed binary is named `agentmap` (no scope).
|
|
42
53
|
RUNNER="agentmap"
|
package/package.json
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@raymondchins/agentmap",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"publishConfig": {
|
|
5
5
|
"access": "public"
|
|
6
6
|
},
|
|
7
|
-
"description": "The repo map your coding agent is forced to use — ~98% fewer tokens (up to 99.9% per task) to understand a TS/JS codebase. A queryable, ranked ts-morph code-relationship map: PageRank hubs, Aider-style symbol ranking, a token-budgeted digest, and a single --any router (file → symbol → feature → live git-grep), wired into the agent loop via post-commit auto-refresh and a PreToolUse hook.",
|
|
7
|
+
"description": "The repo map your coding agent is forced to use — estimated ~98% fewer context tokens (up to ~99.9% per task, chars/4 estimate) to understand a TS/JS codebase. A queryable, ranked ts-morph code-relationship map: PageRank hubs, Aider-style symbol ranking, a token-budgeted digest, and a single --any router (file → symbol → feature → live git-grep), wired into the agent loop via post-commit auto-refresh and a PreToolUse hook.",
|
|
8
8
|
"type": "module",
|
|
9
9
|
"bin": {
|
|
10
10
|
"agentmap": "agentmap.mjs"
|
|
@@ -18,7 +18,8 @@
|
|
|
18
18
|
],
|
|
19
19
|
"scripts": {
|
|
20
20
|
"map": "node agentmap.mjs",
|
|
21
|
-
"test": "node --test test/*.test.mjs"
|
|
21
|
+
"test": "node --test test/*.test.mjs",
|
|
22
|
+
"eval": "node eval/eval.mjs"
|
|
22
23
|
},
|
|
23
24
|
"engines": {
|
|
24
25
|
"node": ">=18"
|