@mmerterden/multi-agent-pipeline 20.33.0 → 20.34.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.
Files changed (95) hide show
  1. package/CHANGELOG.md +43 -0
  2. package/README.md +2 -1
  3. package/README.tr.md +2 -1
  4. package/docs/adr/0018-cross-repo-links-not-a-global-graph.md +43 -0
  5. package/docs/adr/README.md +2 -1
  6. package/docs/facts.json +5 -4
  7. package/docs/features.md +1 -1
  8. package/install/_dev-only-files.mjs +1 -0
  9. package/install/catalog-history.json +1 -1
  10. package/install/claude.mjs +9 -2
  11. package/install/templates/claude-hooks.json +12 -1
  12. package/manifest.json +98 -75
  13. package/package.json +3 -1
  14. package/pipeline/agents/code-reviewer.md +9 -0
  15. package/pipeline/agents/explorer.md +4 -0
  16. package/pipeline/agents/plan-critic.md +4 -2
  17. package/pipeline/commands/multi-agent/SKILL.md +17 -136
  18. package/pipeline/commands/multi-agent/channels/SKILL.md +15 -297
  19. package/pipeline/commands/multi-agent/graph/SKILL.md +5 -2
  20. package/pipeline/commands/multi-agent/language/SKILL.md +3 -3
  21. package/pipeline/commands/multi-agent/resume/SKILL.md +8 -22
  22. package/pipeline/commands/multi-agent/review/SKILL.md +7 -131
  23. package/pipeline/commands/multi-agent/setup/SKILL.md +19 -224
  24. package/pipeline/contract/README.md +5 -2
  25. package/pipeline/multi-agent-refs/channels/generator.md +146 -0
  26. package/pipeline/multi-agent-refs/channels/menu.md +55 -0
  27. package/pipeline/multi-agent-refs/channels/multi-repo.md +6 -0
  28. package/pipeline/multi-agent-refs/channels/preview.md +25 -0
  29. package/pipeline/multi-agent-refs/channels/reference.md +98 -0
  30. package/pipeline/multi-agent-refs/features/autopilot-circuit-breaker.md +1 -1
  31. package/pipeline/multi-agent-refs/features/code-graph.md +27 -0
  32. package/pipeline/multi-agent-refs/features/office-threat-model.md +1 -1
  33. package/pipeline/multi-agent-refs/features/plan-critic.md +5 -2
  34. package/pipeline/multi-agent-refs/features/review-delta.md +4 -1
  35. package/pipeline/multi-agent-refs/features/scope-check.md +10 -1
  36. package/pipeline/multi-agent-refs/knowledge.md +9 -0
  37. package/pipeline/multi-agent-refs/orchestrator/dispatcher.md +163 -0
  38. package/pipeline/multi-agent-refs/phases/phase-1-plan.md +4 -4
  39. package/pipeline/multi-agent-refs/phases/phase-2-dev.md +9 -8
  40. package/pipeline/multi-agent-refs/phases/phase-3-review.md +12 -7
  41. package/pipeline/multi-agent-refs/phases/phase-4-commit.md +7 -3
  42. package/pipeline/multi-agent-refs/phases/phase-5-report.md +6 -3
  43. package/pipeline/multi-agent-refs/picker-contract.md +1 -1
  44. package/pipeline/multi-agent-refs/resume/details.md +35 -0
  45. package/pipeline/multi-agent-refs/review/inputs.md +87 -0
  46. package/pipeline/multi-agent-refs/review/module-guides.md +25 -0
  47. package/pipeline/multi-agent-refs/review/valven.md +42 -0
  48. package/pipeline/multi-agent-refs/setup/accounts.md +27 -0
  49. package/pipeline/multi-agent-refs/setup/app-store-connect.md +5 -0
  50. package/pipeline/multi-agent-refs/setup/firebase.md +5 -0
  51. package/pipeline/multi-agent-refs/setup/google-play.md +4 -0
  52. package/pipeline/multi-agent-refs/setup/hooks-and-plugins.md +21 -0
  53. package/pipeline/multi-agent-refs/setup/language.md +46 -0
  54. package/pipeline/multi-agent-refs/setup/multi-repo-group.md +51 -0
  55. package/pipeline/multi-agent-refs/setup/summaries.md +63 -0
  56. package/pipeline/multi-agent-refs/setup/token-save-flow.md +51 -0
  57. package/pipeline/office/index.html +12 -7
  58. package/pipeline/office/toolkit.json +1 -1
  59. package/pipeline/rules/response-shape.md +6 -7
  60. package/pipeline/schemas/agent-state.schema.json +18 -0
  61. package/pipeline/schemas/phases.json +4 -4
  62. package/pipeline/schemas/plan-critique.schema.json +9 -2
  63. package/pipeline/schemas/planning-output.schema.json +25 -2
  64. package/pipeline/schemas/prefs.schema.json +24 -1
  65. package/pipeline/schemas/reviewer-output.schema.json +20 -2
  66. package/pipeline/schemas/token-budget.json +4 -4
  67. package/pipeline/schemas/triage-output.schema.json +20 -2
  68. package/pipeline/scripts/_graph-analysis.mjs +270 -0
  69. package/pipeline/scripts/_memory-policy.mjs +165 -0
  70. package/pipeline/scripts/_secret-scan.mjs +105 -0
  71. package/pipeline/scripts/_unified-diff.mjs +2 -2
  72. package/pipeline/scripts/capture-flush.sh +12 -2
  73. package/pipeline/scripts/check-read-size.sh +9 -0
  74. package/pipeline/scripts/code-graph-rules/android.json +8 -3
  75. package/pipeline/scripts/contract-server.mjs +36 -15
  76. package/pipeline/scripts/debt-ledger.mjs +323 -0
  77. package/pipeline/scripts/file-context.mjs +261 -0
  78. package/pipeline/scripts/file-context.sh +20 -0
  79. package/pipeline/scripts/graph-links.mjs +230 -0
  80. package/pipeline/scripts/graph-query.mjs +133 -1
  81. package/pipeline/scripts/graph-report.mjs +65 -0
  82. package/pipeline/scripts/learn-from-transcripts.mjs +12 -48
  83. package/pipeline/scripts/learnings-ledger.mjs +143 -24
  84. package/pipeline/scripts/plan-critique-gate.mjs +11 -2
  85. package/pipeline/scripts/rework-rulings.mjs +106 -0
  86. package/pipeline/scripts/scope-check-gate.mjs +149 -2
  87. package/pipeline/scripts/triage-memory.mjs +64 -37
  88. package/pipeline/scripts/validate-planning.mjs +68 -4
  89. package/pipeline/scripts/validate-reviewer.mjs +20 -0
  90. package/pipeline/scripts/validate-triage.mjs +107 -2
  91. package/pipeline/skills/.skill-manifest.json +5 -5
  92. package/pipeline/skills/shared/core/multi-agent-graph/SKILL.md +5 -2
  93. package/pipeline/skills/shared/core/multi-agent-language/SKILL.md +4 -4
  94. package/pipeline/skills/shared/core/multi-agent-review/SKILL.md +2 -0
  95. package/pipeline/skills/shared/core/multi-agent-setup/SKILL.md +4 -4
@@ -907,20 +907,35 @@ async function control(args) {
907
907
  /**
908
908
  * For a read whose answer is the same for every caller: a request that
909
909
  * arrives while one runs gets that one's answer instead of starting another.
910
+ * With `limit`, at most `limit.max` distinct keys of `limit.group` run at
911
+ * once; one more is 429, like `oneAtATime`.
910
912
  */
911
913
  const inFlight = new Map();
912
- const shared = (keyOf, fn) => (ctx) => {
913
- const key = keyOf(ctx);
914
- if (!inFlight.has(key)) {
915
- inFlight.set(
916
- key,
917
- Promise.resolve()
918
- .then(() => fn(ctx))
919
- .finally(() => inFlight.delete(key)),
920
- );
921
- }
922
- return inFlight.get(key);
923
- };
914
+ const groupRunning = new Map();
915
+ const shared =
916
+ (keyOf, fn, limit = null) =>
917
+ (ctx) => {
918
+ const key = keyOf(ctx);
919
+ if (!inFlight.has(key)) {
920
+ if (limit) {
921
+ const n = groupRunning.get(limit.group) ?? 0;
922
+ if (n >= limit.max) {
923
+ throw new HttpError(429, "busy", "too many of these are running; try again shortly");
924
+ }
925
+ groupRunning.set(limit.group, n + 1);
926
+ }
927
+ inFlight.set(
928
+ key,
929
+ Promise.resolve()
930
+ .then(() => fn(ctx))
931
+ .finally(() => {
932
+ inFlight.delete(key);
933
+ if (limit) groupRunning.set(limit.group, groupRunning.get(limit.group) - 1);
934
+ }),
935
+ );
936
+ }
937
+ return inFlight.get(key);
938
+ };
924
939
 
925
940
  /** One request at a time per key; a second one while the first runs is 429. */
926
941
  const running = new Set();
@@ -1790,7 +1805,7 @@ export const ROUTES = [
1790
1805
  method: "GET",
1791
1806
  path: "/v1/autopilot",
1792
1807
  query: [],
1793
- handle: oneAtATime("autopilot-status", autopilotGet),
1808
+ handle: shared(() => "autopilot-status", autopilotGet),
1794
1809
  },
1795
1810
  {
1796
1811
  method: "POST",
@@ -1870,13 +1885,19 @@ export const ROUTES = [
1870
1885
  method: "GET",
1871
1886
  path: "/v1/issues/{key}/maturity",
1872
1887
  query: [],
1873
- handle: oneAtATime("maturity", maturityRoute),
1888
+ handle: shared(({ params }) => `maturity:${params.key}`, maturityRoute, {
1889
+ group: "maturity",
1890
+ max: 2,
1891
+ }),
1874
1892
  },
1875
1893
  {
1876
1894
  method: "GET",
1877
1895
  path: "/v1/prs",
1878
1896
  query: [],
1879
- handle: oneAtATime("prs", ({ redact }) => pullRequests({ redact })),
1897
+ handle: shared(
1898
+ ({ redact }) => `prs:${redact ? 1 : 0}`,
1899
+ ({ redact }) => pullRequests({ redact }),
1900
+ ),
1880
1901
  },
1881
1902
  { method: "GET", path: "/v1/tools", query: [], handle: toolsRoute },
1882
1903
  {
@@ -0,0 +1,323 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * @file debt-ledger.mjs - the known limits a repository carries, in one list.
4
+ *
5
+ * Three sources feed it, each a limit somebody accepted on purpose:
6
+ *
7
+ * shortcut a `shortcut: <limit>, <when to replace>` marker a diff adds
8
+ * (features/scope-check.md, "Known limits in the code")
9
+ * deferred a triage finding deferred as out of scope
10
+ * parked a finding parked by rework-rulings.mjs at the rework cap
11
+ *
12
+ * Storage: $HOME/.claude/memory/multi-agent/<repo-slug>/debt.jsonl, append-only,
13
+ * one row per item; the same item from the same task is written once. Every
14
+ * row passes the secret scan before it is written (_secret-scan.mjs).
15
+ *
16
+ * Subcommands:
17
+ * markers --patch <unified diff|-> print the markers the diff adds (no write)
18
+ * add --patch <diff> --task <id> record those markers
19
+ * from-run --triage <path> [--state <agent-state.json>] --task <id>
20
+ * record deferred findings and parked rulings
21
+ * list [--open] [--json] rows, newest first
22
+ * resolve --id <id> mark one row resolved (appends a resolution row)
23
+ * [--repo-slug <slug>] on every subcommand; else $MULTI_AGENT_REPO_SLUG, then the git repo name.
24
+ *
25
+ * Exit: 0 done · 1 IO error · 2 nothing to record or unknown id · 64 usage
26
+ *
27
+ * @module pipeline/scripts/debt-ledger
28
+ */
29
+
30
+ import { execFileSync } from "node:child_process";
31
+ import { appendFileSync, existsSync, mkdirSync, readFileSync, realpathSync } from "node:fs";
32
+ import { homedir } from "node:os";
33
+ import { basename, dirname, join } from "node:path";
34
+ import { fileURLToPath } from "node:url";
35
+
36
+ import { shortId } from "./_retrieval.mjs";
37
+ import { parseUnifiedDiff } from "./_unified-diff.mjs";
38
+ import { checkedSlug, memoryExcluded } from "./_memory-policy.mjs";
39
+ import { openScanner, stripPrivate } from "./_secret-scan.mjs";
40
+
41
+ const COMMENT_OPENERS = ["//", "#", "/*", "<!--", "--", ";", "*"];
42
+ const MAX_LINE = 2000;
43
+ const MAX_TEXT = 300;
44
+
45
+ /**
46
+ * True when position `at` of the line sits inside a string literal. With
47
+ * `apostrophes: false` a single quote opens nothing: before a `//` or `#`
48
+ * comment it is as often a lifetime or a word's apostrophe as a string.
49
+ */
50
+ function insideString(line, at, apostrophes = true) {
51
+ let quote = null;
52
+ for (let i = 0; i < at; i++) {
53
+ const c = line[i];
54
+ if (quote) {
55
+ if (c === "\\") i++;
56
+ else if (c === quote) quote = null;
57
+ } else if (c === '"' || c === "`" || (apostrophes && c === "'")) {
58
+ quote = c;
59
+ }
60
+ }
61
+ return quote !== null;
62
+ }
63
+
64
+ /**
65
+ * The text after `shortcut:` when it opens a comment (only spaces between the
66
+ * comment opener and the marker, the opener outside any string), else null.
67
+ * `--` counts only at the start of the line, so a CLI flag is not a comment.
68
+ * Lowercase only, as documented.
69
+ */
70
+ function markerIn(line) {
71
+ const at = line.indexOf("shortcut:");
72
+ if (at === -1) return null;
73
+ const before = line.slice(0, at).trimEnd();
74
+ const opener = COMMENT_OPENERS.find((o) => before.endsWith(o));
75
+ if (!opener) return null;
76
+ const openAt = before.length - opener.length;
77
+ if (opener === "--" && before.trim() !== "--") return null;
78
+ if (insideString(line, openAt, opener !== "//" && opener !== "#")) return null;
79
+ return line.slice(at + "shortcut:".length).trim();
80
+ }
81
+
82
+ /** Markers added by a unified diff: [{file, line, text}]. */
83
+ export function markersIn(patch) {
84
+ const out = [];
85
+ for (const [file, diff] of parseUnifiedDiff(patch)) {
86
+ for (const { line, text: raw } of diff.addedAt) {
87
+ const marker = markerIn(raw.slice(0, MAX_LINE));
88
+ if (marker === null) continue;
89
+ let text = marker;
90
+ for (const close of ["*/", "-->"])
91
+ if (text.endsWith(close)) text = text.slice(0, -close.length);
92
+ text = text.trimEnd();
93
+ if (text) out.push({ file, line, text });
94
+ }
95
+ }
96
+ return out;
97
+ }
98
+
99
+ /** Deferred findings and parked rulings of one run: [{kind, file, line, text}]. */
100
+ export function runItems(triage, state) {
101
+ const items = [];
102
+ for (const d of triage?.deferred ?? []) {
103
+ const f = d?.finding ?? {};
104
+ if (!f.issue) continue;
105
+ items.push({
106
+ kind: "deferred",
107
+ file: f.file ?? null,
108
+ line: Number.isInteger(f.line) ? f.line : null,
109
+ text: `${f.issue}${d.reason ? ` (deferred: ${d.reason})` : ""}`,
110
+ });
111
+ }
112
+ const iterations = Array.isArray(state?.reviewIterations) ? state.reviewIterations : [];
113
+ for (const r of iterations.at(-1)?.rulings ?? []) {
114
+ if (r?.ruling !== "parked" || !r.issue) continue;
115
+ items.push({
116
+ kind: "parked",
117
+ file: r.file ?? null,
118
+ line: Number.isInteger(r.line) ? r.line : null,
119
+ text: `${r.issue} (${r.reason})`,
120
+ });
121
+ }
122
+ return items;
123
+ }
124
+
125
+ const rowId = (r) => shortId("D", r.kind, r.file ?? "", r.text);
126
+
127
+ function parseFlags(argv) {
128
+ const flags = {};
129
+ for (let i = 0; i < argv.length; i++) {
130
+ const a = argv[i];
131
+ if (!a.startsWith("--")) continue;
132
+ const next = argv[i + 1];
133
+ if (next !== undefined && !next.startsWith("--")) {
134
+ flags[a.slice(2)] = next;
135
+ i++;
136
+ } else flags[a.slice(2)] = true;
137
+ }
138
+ return flags;
139
+ }
140
+
141
+ function repoSlug(flags) {
142
+ return checkedSlug(rawRepoSlug(flags));
143
+ }
144
+
145
+ function rawRepoSlug(flags) {
146
+ if (typeof flags["repo-slug"] === "string") return flags["repo-slug"];
147
+ if (process.env.MULTI_AGENT_REPO_SLUG) return process.env.MULTI_AGENT_REPO_SLUG;
148
+ try {
149
+ const common = execFileSync(
150
+ "git",
151
+ ["rev-parse", "--path-format=absolute", "--git-common-dir"],
152
+ {
153
+ encoding: "utf8",
154
+ stdio: ["ignore", "pipe", "ignore"],
155
+ },
156
+ ).trim();
157
+ const root = basename(common) === ".git" ? dirname(common) : common.replace(/\.git$/, "");
158
+ if (basename(root)) return basename(root);
159
+ } catch {
160
+ /* not a git repo */
161
+ }
162
+ return "default";
163
+ }
164
+
165
+ const ledgerPath = (slug) =>
166
+ join(homedir(), ".claude", "memory", "multi-agent", slug, "debt.jsonl");
167
+
168
+ function readRows(slug) {
169
+ const p = ledgerPath(slug);
170
+ if (!existsSync(p)) return [];
171
+ return readFileSync(p, "utf8")
172
+ .split("\n")
173
+ .filter((l) => l.trim())
174
+ .flatMap((l) => {
175
+ try {
176
+ return [JSON.parse(l)];
177
+ } catch {
178
+ return [];
179
+ }
180
+ });
181
+ }
182
+
183
+ /** Current state per id: the last row wins, so a resolution closes its item. */
184
+ function current(rows) {
185
+ const byId = new Map();
186
+ for (const r of rows) byId.set(r.id, { ...byId.get(r.id), ...r });
187
+ return [...byId.values()];
188
+ }
189
+
190
+ function record(slug, items, task) {
191
+ if (memoryExcluded(slug)) return { added: 0, droppedBySecretScan: 0, excluded: true };
192
+ const p = ledgerPath(slug);
193
+ const seen = new Set(readRows(slug).map((r) => `${r.id}::${r.task}`));
194
+ const scan = openScanner();
195
+ let added = 0;
196
+ let droppedBySecretScan = 0;
197
+ try {
198
+ for (const it of items) {
199
+ const whole = stripPrivate(it.text).replace(/\s+/g, " ").trim();
200
+ if (!whole) continue;
201
+ // Scanned whole and cut afterwards: a token across the cut would
202
+ // otherwise be shortened below the scanner's minimum and stored.
203
+ const text = whole.slice(0, MAX_TEXT);
204
+ const row = { kind: it.kind, file: it.file, line: it.line, text };
205
+ const id = rowId(row);
206
+ if (seen.has(`${id}::${task}`)) continue;
207
+ if (!scan.isClean(`${whole}\n${it.file ?? ""}`)) {
208
+ droppedBySecretScan++;
209
+ continue;
210
+ }
211
+ mkdirSync(dirname(p), { recursive: true });
212
+ appendFileSync(
213
+ p,
214
+ JSON.stringify({ v: 1, id, ts: new Date().toISOString(), task, status: "open", ...row }) +
215
+ "\n",
216
+ );
217
+ seen.add(`${id}::${task}`);
218
+ added++;
219
+ }
220
+ } finally {
221
+ scan.close();
222
+ }
223
+ return { added, droppedBySecretScan };
224
+ }
225
+
226
+ function readJson(path) {
227
+ try {
228
+ return JSON.parse(readFileSync(path, "utf8"));
229
+ } catch {
230
+ return null;
231
+ }
232
+ }
233
+
234
+ function readPatch(path) {
235
+ return path === "-" ? readFileSync(0, "utf8") : readFileSync(path, "utf8");
236
+ }
237
+
238
+ function main(argv) {
239
+ const sub = argv[0];
240
+ const flags = parseFlags(argv.slice(1));
241
+ const out = (doc) => process.stdout.write(JSON.stringify(doc) + "\n");
242
+ const slug = repoSlug(flags);
243
+ const task = typeof flags.task === "string" ? flags.task : null;
244
+
245
+ if (sub === "markers" || sub === "add") {
246
+ if (typeof flags.patch !== "string") return usage();
247
+ const markers = markersIn(readPatch(flags.patch));
248
+ if (sub === "markers") {
249
+ out({ markers });
250
+ return 0;
251
+ }
252
+ if (!task) return usage();
253
+ const r = record(
254
+ slug,
255
+ markers.map((m) => ({ kind: "shortcut", ...m })),
256
+ task,
257
+ );
258
+ out({ ok: true, slug, ...r });
259
+ return r.added ? 0 : 2;
260
+ }
261
+ if (sub === "from-run") {
262
+ if (typeof flags.triage !== "string" || !task) return usage();
263
+ const triage = readJson(flags.triage);
264
+ if (!triage) {
265
+ console.error(`debt-ledger: triage unreadable: ${flags.triage}`);
266
+ return 1;
267
+ }
268
+ const state = typeof flags.state === "string" ? readJson(flags.state) : null;
269
+ const r = record(slug, runItems(triage, state), task);
270
+ out({ ok: true, slug, ...r });
271
+ return r.added ? 0 : 2;
272
+ }
273
+ if (sub === "list") {
274
+ let rows = current(readRows(slug)).reverse();
275
+ if (flags.open) rows = rows.filter((r) => r.status === "open");
276
+ if (flags.json) out({ slug, rows });
277
+ else
278
+ for (const r of rows)
279
+ process.stdout.write(
280
+ `${r.id} ${r.status} ${r.kind} ${r.file ?? "-"}${r.line ? `:${r.line}` : ""} ${r.text}\n`,
281
+ );
282
+ return 0;
283
+ }
284
+ if (sub === "resolve") {
285
+ if (typeof flags.id !== "string") return usage();
286
+ if (!current(readRows(slug)).some((r) => r.id === flags.id)) {
287
+ console.error(`debt-ledger: no item ${flags.id}`);
288
+ return 2;
289
+ }
290
+ appendFileSync(
291
+ ledgerPath(slug),
292
+ JSON.stringify({ id: flags.id, status: "resolved", resolvedAt: new Date().toISOString() }) +
293
+ "\n",
294
+ );
295
+ out({ ok: true, slug, resolved: flags.id });
296
+ return 0;
297
+ }
298
+ return usage();
299
+ }
300
+
301
+ function usage() {
302
+ console.error(
303
+ "usage: debt-ledger.mjs markers --patch <diff|-> | add --patch <diff> --task <id> | from-run --triage <path> [--state <path>] --task <id> | list [--open] [--json] | resolve --id <id>",
304
+ );
305
+ return 64;
306
+ }
307
+
308
+ const invoked = () => {
309
+ try {
310
+ return realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url));
311
+ } catch {
312
+ return false;
313
+ }
314
+ };
315
+
316
+ if (invoked()) {
317
+ try {
318
+ process.exitCode = main(process.argv.slice(2));
319
+ } catch (e) {
320
+ console.error(`debt-ledger: ${e.message}`);
321
+ process.exitCode = 1;
322
+ }
323
+ }
@@ -0,0 +1,261 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * @file file-context.mjs - what the repository's memory says about a file,
4
+ * handed to the model right after it reads that file.
5
+ *
6
+ * PostToolUse on Read. For the file just read it looks up, in the per-repo
7
+ * stores under ~/.claude/memory/multi-agent/<repo-slug>/:
8
+ *
9
+ * learnings-ledger.jsonl durable facts and conventions whose scope covers the file,
10
+ * minus those the feedback suppressed (_memory-policy.mjs)
11
+ * triage-corpus.jsonl the latest review findings recorded against it
12
+ * debt.jsonl open known limits in it (debt-ledger.mjs)
13
+ *
14
+ * and returns at most MAX_LINES of them as PostToolUse additionalContext,
15
+ * inside an <untrusted-data> block: the rows hold model-written text, so they
16
+ * are evidence about the file, never an instruction.
17
+ *
18
+ * When it runs: inside a pipeline run bound to the session (_active-run.mjs),
19
+ * or anywhere when prefs.global.fileContext.enabled is true; never when that
20
+ * flag is false. Each file is answered once per session. The text passes the
21
+ * secret scan before it is handed over; a hit means nothing is said.
22
+ *
23
+ * `file-context.mjs --lines <path>` prints the same lines, secret-scanned and
24
+ * wrapped the same way, for a caller that already decided to show them
25
+ * (check-read-size.sh, when it turns a whole-file read away). It applies no
26
+ * session or run condition, but stays silent when the flag is false.
27
+ *
28
+ * Blocks nothing and prints nothing on any failure: a missing store, a slow
29
+ * scan or a malformed payload all end in exit 0 with no output.
30
+ *
31
+ * @module pipeline/scripts/file-context
32
+ */
33
+
34
+ import { spawnSync } from "node:child_process";
35
+ import {
36
+ existsSync,
37
+ lstatSync,
38
+ mkdirSync,
39
+ readFileSync,
40
+ realpathSync,
41
+ statSync,
42
+ writeFileSync,
43
+ } from "node:fs";
44
+ import { homedir, tmpdir } from "node:os";
45
+ import { basename, dirname, join, relative } from "node:path";
46
+
47
+ import { wrapUntrusted } from "../lib/untrusted.mjs";
48
+ import { findActiveRun } from "./_active-run.mjs";
49
+ import { ledgerRowId, standing } from "./_memory-policy.mjs";
50
+ import { openScanner } from "./_secret-scan.mjs";
51
+ import { invokedDirectly } from "../lib/invoked-directly.mjs";
52
+
53
+ const MAX_INPUT = 256 * 1024;
54
+ const MAX_LINES = 6;
55
+ const MAX_LINE_CHARS = 220;
56
+ const STORE_MAX_BYTES = 4 * 1024 * 1024;
57
+
58
+ const prefsPath = (env) =>
59
+ env.MULTI_AGENT_PREFS || join(env.HOME || homedir(), ".claude", "multi-agent-preferences.json");
60
+
61
+ /** off | on | run-only, from prefs.global.fileContext.enabled. */
62
+ export function mode(env = process.env) {
63
+ try {
64
+ const flag = JSON.parse(readFileSync(prefsPath(env), "utf8"))?.global?.fileContext?.enabled;
65
+ if (flag === false) return "off";
66
+ if (flag === true) return "on";
67
+ } catch {
68
+ /* no prefs: the default */
69
+ }
70
+ return "run-only";
71
+ }
72
+
73
+ /** Repo slug and the file's path inside the repo, as the stores key them. */
74
+ export function locate(filePath) {
75
+ const dir = dirname(filePath);
76
+ const git = (args) =>
77
+ spawnSync("git", ["-C", dir, ...args], { encoding: "utf8", timeout: 2000 }).stdout?.trim();
78
+ const top = git(["rev-parse", "--show-toplevel"]);
79
+ if (!top) return null;
80
+ const common = git(["rev-parse", "--path-format=absolute", "--git-common-dir"]) || "";
81
+ const root = basename(common) === ".git" ? dirname(common) : top;
82
+ // git answers with the resolved path (/private/var on macOS), so the file is
83
+ // resolved the same way before the two are compared.
84
+ return { slug: basename(root), rel: relative(realpathSync(top), realpathSync(filePath)) };
85
+ }
86
+
87
+ function rows(path) {
88
+ try {
89
+ if (statSync(path).size > STORE_MAX_BYTES) return [];
90
+ return readFileSync(path, "utf8")
91
+ .split("\n")
92
+ .filter((l) => l.trim())
93
+ .flatMap((l) => {
94
+ try {
95
+ return [JSON.parse(l)];
96
+ } catch {
97
+ return [];
98
+ }
99
+ });
100
+ } catch {
101
+ return [];
102
+ }
103
+ }
104
+
105
+ function scopeCovers(scope, rel) {
106
+ if (!scope || scope === "global") return false;
107
+ const dirOf = (g) =>
108
+ String(g)
109
+ .replace(/\/+$/, "")
110
+ .replace(/\/\*+$/, "");
111
+ const s = dirOf(scope);
112
+ return s === rel || rel.startsWith(s + "/");
113
+ }
114
+
115
+ const clip = (s) => {
116
+ const one = String(s ?? "")
117
+ .replace(/\s+/g, " ")
118
+ .trim();
119
+ return one.length > MAX_LINE_CHARS ? one.slice(0, MAX_LINE_CHARS - 3) + "..." : one;
120
+ };
121
+
122
+ /** The lines worth saying about one file, most specific first. */
123
+ export function linesFor(storeDir, rel) {
124
+ const out = [];
125
+ const feedback = new Map();
126
+ for (const f of rows(join(storeDir, "learnings-feedback.jsonl"))) {
127
+ if (!feedback.has(f.id)) feedback.set(f.id, []);
128
+ feedback.get(f.id).push(f);
129
+ }
130
+ for (const r of rows(join(storeDir, "learnings-ledger.jsonl"))) {
131
+ if (!scopeCovers(r.scope, rel)) continue;
132
+ if (standing(feedback.get(ledgerRowId(r)) ?? []).suppressed) continue;
133
+ out.push(`${r.kind}: ${clip(r.statement)}`);
134
+ }
135
+ const findings = rows(join(storeDir, "triage-corpus.jsonl"))
136
+ .filter((r) => r.file === rel)
137
+ .slice(-3)
138
+ .reverse();
139
+ for (const f of findings) {
140
+ out.push(`past review (${f.classification}, ${f.severity}): ${clip(f.issue)}`);
141
+ }
142
+ const debt = new Map();
143
+ for (const r of rows(join(storeDir, "debt.jsonl"))) debt.set(r.id, { ...debt.get(r.id), ...r });
144
+ for (const d of debt.values()) {
145
+ if (d.status === "open" && d.file === rel) out.push(`known limit (${d.kind}): ${clip(d.text)}`);
146
+ }
147
+ return out.slice(0, MAX_LINES);
148
+ }
149
+
150
+ /** The cache directory exists, is a real directory and belongs to this user. */
151
+ function ownDir(dir) {
152
+ try {
153
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
154
+ const st = lstatSync(dir);
155
+ return st.isDirectory() && (process.getuid === undefined || st.uid === process.getuid());
156
+ } catch {
157
+ return false;
158
+ }
159
+ }
160
+
161
+ function seenBefore(sessionId, rel, slug) {
162
+ if (!sessionId) return false;
163
+ const uid = process.getuid?.() ?? "user";
164
+ const dir = join(tmpdir(), `multi-agent-file-context-${uid}`);
165
+ const f = join(dir, `${String(sessionId).replace(/[^A-Za-z0-9_-]/g, "")}.json`);
166
+ if (!ownDir(dir)) return false;
167
+ let seen = [];
168
+ try {
169
+ seen = JSON.parse(readFileSync(f, "utf8"));
170
+ } catch {
171
+ /* first file this session */
172
+ }
173
+ const key = `${slug}:${rel}`;
174
+ if (seen.includes(key)) return true;
175
+ try {
176
+ writeFileSync(f, JSON.stringify([...seen, key].slice(-500)), { mode: 0o600 });
177
+ } catch {
178
+ /* a lost cache only means saying it twice */
179
+ }
180
+ return false;
181
+ }
182
+
183
+ /** The hook's answer for one payload, or null for silence. */
184
+ export function answer(payload, env = process.env) {
185
+ if (payload?.tool_name !== "Read") return null;
186
+ const filePath = payload?.tool_input?.file_path;
187
+ if (typeof filePath !== "string" || !filePath || !existsSync(filePath)) return null;
188
+ const m = mode(env);
189
+ if (m === "off") return null;
190
+ if (m === "run-only" && !findActiveRun({ sessionId: payload.session_id, cwd: payload.cwd, env }))
191
+ return null;
192
+ const where = locate(filePath);
193
+ if (!where || where.rel.startsWith("..")) return null;
194
+ const storeDir = join(env.HOME || homedir(), ".claude", "memory", "multi-agent", where.slug);
195
+ const lines = linesFor(storeDir, where.rel);
196
+ if (!lines.length || seenBefore(payload.session_id, where.rel, where.slug)) return null;
197
+ const text = lines.map((l) => `- ${l}`).join("\n");
198
+ const scan = openScanner();
199
+ try {
200
+ if (!scan.isClean(text)) return null;
201
+ } finally {
202
+ scan.close();
203
+ }
204
+ return {
205
+ hookSpecificOutput: {
206
+ hookEventName: "PostToolUse",
207
+ additionalContext: wrapUntrusted(`memory:${where.slug}/${where.rel}`, text),
208
+ },
209
+ };
210
+ }
211
+
212
+ /**
213
+ * The scanned lines for one file inside an untrusted-data block, or "" when
214
+ * file context is off, there is nothing, or the scan refused it.
215
+ */
216
+ export function blockForPath(filePath, env = process.env) {
217
+ if (mode(env) === "off" || !existsSync(filePath)) return "";
218
+ const where = locate(filePath);
219
+ if (!where || where.rel.startsWith("..")) return "";
220
+ const storeDir = join(env.HOME || homedir(), ".claude", "memory", "multi-agent", where.slug);
221
+ const text = linesFor(storeDir, where.rel)
222
+ .map((l) => `- ${l}`)
223
+ .join("\n");
224
+ if (!text) return "";
225
+ const scan = openScanner();
226
+ try {
227
+ return scan.isClean(text) ? wrapUntrusted(`memory:${where.slug}/${where.rel}`, text) : "";
228
+ } finally {
229
+ scan.close();
230
+ }
231
+ }
232
+
233
+ function main() {
234
+ const i = process.argv.indexOf("--lines");
235
+ if (i > 0) {
236
+ const block = process.argv[i + 1] ? blockForPath(process.argv[i + 1]) : "";
237
+ if (block) process.stdout.write(block);
238
+ return;
239
+ }
240
+ let raw;
241
+ try {
242
+ raw = readFileSync(0, "utf8");
243
+ } catch {
244
+ return;
245
+ }
246
+ if (!raw || raw.length > MAX_INPUT) return;
247
+ let payload;
248
+ try {
249
+ payload = JSON.parse(raw);
250
+ } catch {
251
+ return;
252
+ }
253
+ const out = answer(payload);
254
+ if (out) process.stdout.write(JSON.stringify(out) + "\n");
255
+ }
256
+
257
+ try {
258
+ if (invokedDirectly(import.meta.url)) main();
259
+ } catch {
260
+ /* silent by contract */
261
+ }
@@ -0,0 +1,20 @@
1
+ #!/usr/bin/env bash
2
+ # file-context.sh - PostToolUse hook (Read) for the pipeline.
3
+ #
4
+ # Hands the model what the repository's memory says about the file it just
5
+ # read: ledger facts, past review findings and open known limits. The logic is
6
+ # in file-context.mjs; this wrapper exists because every hook the pipeline
7
+ # registers is a `bash "$HOME/.<host>/scripts/<name>"` command.
8
+ #
9
+ # Exits 0 on every path: a missing node, a slow scan or any internal error
10
+ # means no context, never a blocked tool call.
11
+
12
+ set -u
13
+
14
+ HERE="$(cd "$(dirname "$0")" 2>/dev/null && pwd || true)"
15
+ CORE="$HERE/file-context.mjs"
16
+ [ -f "$CORE" ] || exit 0
17
+ command -v node >/dev/null 2>&1 || exit 0
18
+
19
+ node "$CORE" 2>/dev/null || true
20
+ exit 0