@mmerterden/multi-agent-pipeline 20.30.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 (195) hide show
  1. package/CHANGELOG.md +123 -0
  2. package/README.md +47 -1
  3. package/README.tr.md +61 -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 +6 -5
  7. package/docs/features.md +1 -1
  8. package/install/_dev-only-files.mjs +1 -0
  9. package/install/_unattended-profile.mjs +61 -0
  10. package/install/catalog-history.json +1 -1
  11. package/install/claude.mjs +16 -2
  12. package/install/index.mjs +10 -1
  13. package/install/templates/claude-hooks.json +23 -1
  14. package/manifest.json +198 -134
  15. package/package.json +4 -1
  16. package/pipeline/agents/code-reviewer.md +9 -0
  17. package/pipeline/agents/explorer.md +4 -0
  18. package/pipeline/agents/plan-critic.md +4 -2
  19. package/pipeline/commands/multi-agent/SKILL.md +17 -136
  20. package/pipeline/commands/multi-agent/analysis/SKILL.md +4 -4
  21. package/pipeline/commands/multi-agent/channels/SKILL.md +15 -297
  22. package/pipeline/commands/multi-agent/graph/SKILL.md +5 -2
  23. package/pipeline/commands/multi-agent/language/SKILL.md +3 -3
  24. package/pipeline/commands/multi-agent/office-on/SKILL.md +8 -7
  25. package/pipeline/commands/multi-agent/office-status/SKILL.md +1 -1
  26. package/pipeline/commands/multi-agent/prune-logs/SKILL.md +4 -1
  27. package/pipeline/commands/multi-agent/resume/SKILL.md +8 -22
  28. package/pipeline/commands/multi-agent/review/SKILL.md +11 -134
  29. package/pipeline/commands/multi-agent/scenario-audit/SKILL.md +4 -2
  30. package/pipeline/commands/multi-agent/setup/SKILL.md +19 -224
  31. package/pipeline/commands/multi-agent/sync/SKILL.md +12 -0
  32. package/pipeline/commands/multi-agent/test-gen/SKILL.md +29 -3
  33. package/pipeline/contract/CHANGELOG.md +46 -0
  34. package/pipeline/contract/README.md +27 -10
  35. package/pipeline/contract/fixtures/error-invalid-request.json +1 -1
  36. package/pipeline/contract/fixtures/error-unauthorized.json +1 -1
  37. package/pipeline/contract/fixtures/error-unsigned.json +1 -1
  38. package/pipeline/contract/fixtures/launch-plan.json +1 -1
  39. package/pipeline/contract/fixtures/runs-awaiting-question.json +6 -1
  40. package/pipeline/contract/fixtures/runs-empty.json +1 -1
  41. package/pipeline/contract/fixtures/runs-failed.json +6 -1
  42. package/pipeline/contract/fixtures/runs-old-schema.json +6 -1
  43. package/pipeline/contract/fixtures/runs-pr-opened-redacted.json +5 -1
  44. package/pipeline/contract/fixtures/runs-pr-opened.json +6 -1
  45. package/pipeline/contract/fixtures/runs-running.json +6 -1
  46. package/pipeline/contract/manifest.json +153 -19
  47. package/pipeline/contract/types/index.d.ts +304 -6
  48. package/pipeline/lib/bg-launch.mjs +106 -0
  49. package/pipeline/lib/code-digest.mjs +49 -0
  50. package/pipeline/lib/credential-resolve.mjs +42 -2
  51. package/pipeline/multi-agent-refs/channels/generator.md +146 -0
  52. package/pipeline/multi-agent-refs/channels/menu.md +55 -0
  53. package/pipeline/multi-agent-refs/channels/multi-repo.md +6 -0
  54. package/pipeline/multi-agent-refs/channels/preview.md +25 -0
  55. package/pipeline/multi-agent-refs/channels/reference.md +98 -0
  56. package/pipeline/multi-agent-refs/features/autopilot-circuit-breaker.md +1 -1
  57. package/pipeline/multi-agent-refs/features/code-graph.md +27 -0
  58. package/pipeline/multi-agent-refs/features/office-threat-model.md +24 -6
  59. package/pipeline/multi-agent-refs/features/plan-critic.md +5 -2
  60. package/pipeline/multi-agent-refs/features/review-delta.md +4 -1
  61. package/pipeline/multi-agent-refs/features/scope-check.md +10 -1
  62. package/pipeline/multi-agent-refs/features/unattended-security.md +1 -1
  63. package/pipeline/multi-agent-refs/help/en.md +2 -2
  64. package/pipeline/multi-agent-refs/help/tr.md +2 -2
  65. package/pipeline/multi-agent-refs/knowledge.md +9 -0
  66. package/pipeline/multi-agent-refs/orchestrator/dispatcher.md +163 -0
  67. package/pipeline/multi-agent-refs/phases/modes.md +7 -7
  68. package/pipeline/multi-agent-refs/phases/phase-1-plan.md +4 -4
  69. package/pipeline/multi-agent-refs/phases/phase-2-dev.md +9 -8
  70. package/pipeline/multi-agent-refs/phases/phase-3-review.md +12 -7
  71. package/pipeline/multi-agent-refs/phases/phase-4-commit.md +7 -3
  72. package/pipeline/multi-agent-refs/phases/phase-5-report.md +19 -10
  73. package/pipeline/multi-agent-refs/picker-contract.md +1 -1
  74. package/pipeline/multi-agent-refs/resume/details.md +35 -0
  75. package/pipeline/multi-agent-refs/review/inputs.md +87 -0
  76. package/pipeline/multi-agent-refs/review/module-guides.md +25 -0
  77. package/pipeline/multi-agent-refs/review/valven.md +42 -0
  78. package/pipeline/multi-agent-refs/review-standalone-tracker.md +37 -0
  79. package/pipeline/multi-agent-refs/setup/accounts.md +27 -0
  80. package/pipeline/multi-agent-refs/setup/app-store-connect.md +5 -0
  81. package/pipeline/multi-agent-refs/setup/firebase.md +5 -0
  82. package/pipeline/multi-agent-refs/setup/google-play.md +4 -0
  83. package/pipeline/multi-agent-refs/setup/hooks-and-plugins.md +21 -0
  84. package/pipeline/multi-agent-refs/setup/language.md +46 -0
  85. package/pipeline/multi-agent-refs/setup/multi-repo-group.md +51 -0
  86. package/pipeline/multi-agent-refs/setup/summaries.md +63 -0
  87. package/pipeline/multi-agent-refs/setup/token-save-flow.md +51 -0
  88. package/pipeline/multi-agent-refs/tracker-contract.md +8 -3
  89. package/pipeline/office/NOTICE.md +4 -1
  90. package/pipeline/office/assets/ARCHIVE_BOXES.png +0 -0
  91. package/pipeline/office/assets/COFFEE_MACHINE.png +0 -0
  92. package/pipeline/office/assets/COUNTER_SINK.png +0 -0
  93. package/pipeline/office/assets/DEVICE_STAND.png +0 -0
  94. package/pipeline/office/assets/FILING_CABINET.png +0 -0
  95. package/pipeline/office/assets/FRIDGE.png +0 -0
  96. package/pipeline/office/assets/MAILBOX.png +0 -0
  97. package/pipeline/office/assets/MOP_BUCKET.png +0 -0
  98. package/pipeline/office/assets/PRINTER.png +0 -0
  99. package/pipeline/office/assets/RECEPTION_DESK.png +0 -0
  100. package/pipeline/office/assets/SERVER_RACK.png +0 -0
  101. package/pipeline/office/assets/SERVER_RACK_2.png +0 -0
  102. package/pipeline/office/assets/TOILET_STALL.png +0 -0
  103. package/pipeline/office/assets/VENDING.png +0 -0
  104. package/pipeline/office/assets/WASHBASIN.png +0 -0
  105. package/pipeline/office/assets/WATER_COOLER.png +0 -0
  106. package/pipeline/office/index.html +3936 -819
  107. package/pipeline/office/toolkit.json +3 -0
  108. package/pipeline/rules/response-shape.md +6 -7
  109. package/pipeline/schemas/activity.schema.json +188 -4
  110. package/pipeline/schemas/agent-state.schema.json +511 -115
  111. package/pipeline/schemas/contract-error.schema.json +12 -1
  112. package/pipeline/schemas/job-change.schema.json +72 -0
  113. package/pipeline/schemas/job-request.schema.json +38 -0
  114. package/pipeline/schemas/launch.json +4 -4
  115. package/pipeline/schemas/office-credentials.schema.json +71 -0
  116. package/pipeline/schemas/office-jobs.json +253 -0
  117. package/pipeline/schemas/office-jobs.schema.json +62 -0
  118. package/pipeline/schemas/office-memory.schema.json +105 -0
  119. package/pipeline/schemas/office-settings-request.schema.json +30 -0
  120. package/pipeline/schemas/office-settings.schema.json +67 -0
  121. package/pipeline/schemas/phases.json +14 -6
  122. package/pipeline/schemas/phases.schema.json +86 -19
  123. package/pipeline/schemas/plan-critique.schema.json +9 -2
  124. package/pipeline/schemas/planning-output.schema.json +25 -2
  125. package/pipeline/schemas/prefs.schema.json +24 -1
  126. package/pipeline/schemas/prs.schema.json +5 -0
  127. package/pipeline/schemas/prune-logs-request.schema.json +26 -0
  128. package/pipeline/schemas/prune-logs.schema.json +52 -0
  129. package/pipeline/schemas/repo-change.schema.json +54 -0
  130. package/pipeline/schemas/repo-request.schema.json +29 -0
  131. package/pipeline/schemas/repos.schema.json +49 -1
  132. package/pipeline/schemas/reviewer-output.schema.json +20 -2
  133. package/pipeline/schemas/runs-index.schema.json +257 -46
  134. package/pipeline/schemas/system-status.schema.json +111 -0
  135. package/pipeline/schemas/token-budget.json +4 -4
  136. package/pipeline/schemas/tools.schema.json +5 -0
  137. package/pipeline/schemas/triage-output.schema.json +20 -2
  138. package/pipeline/schemas/unattended-profile.json +7 -2
  139. package/pipeline/scripts/_active-run.mjs +2 -1
  140. package/pipeline/scripts/_graph-analysis.mjs +270 -0
  141. package/pipeline/scripts/_memory-policy.mjs +165 -0
  142. package/pipeline/scripts/_secret-scan.mjs +105 -0
  143. package/pipeline/scripts/_spawn-async.mjs +9 -5
  144. package/pipeline/scripts/_unified-diff.mjs +2 -2
  145. package/pipeline/scripts/activity-snapshot.mjs +219 -23
  146. package/pipeline/scripts/autopilot-runner.mjs +7 -5
  147. package/pipeline/scripts/capture-flush.sh +12 -2
  148. package/pipeline/scripts/check-read-size.sh +9 -0
  149. package/pipeline/scripts/code-graph-rules/android.json +8 -3
  150. package/pipeline/scripts/contract-server.mjs +575 -28
  151. package/pipeline/scripts/debt-ledger.mjs +323 -0
  152. package/pipeline/scripts/file-context.mjs +261 -0
  153. package/pipeline/scripts/file-context.sh +20 -0
  154. package/pipeline/scripts/gen-mode-dispatch.mjs +3 -1
  155. package/pipeline/scripts/graph-links.mjs +230 -0
  156. package/pipeline/scripts/graph-query.mjs +133 -1
  157. package/pipeline/scripts/graph-report.mjs +65 -0
  158. package/pipeline/scripts/job-launch.mjs +477 -0
  159. package/pipeline/scripts/launch-request.mjs +94 -9
  160. package/pipeline/scripts/learn-from-transcripts.mjs +12 -48
  161. package/pipeline/scripts/learnings-ledger.mjs +143 -24
  162. package/pipeline/scripts/office-build.mjs +148 -11
  163. package/pipeline/scripts/office-control.mjs +34 -7
  164. package/pipeline/scripts/office-credentials.mjs +106 -0
  165. package/pipeline/scripts/office-memory.mjs +200 -0
  166. package/pipeline/scripts/office-prune.mjs +125 -0
  167. package/pipeline/scripts/office-settings.mjs +145 -0
  168. package/pipeline/scripts/phase-tracker.sh +12 -5
  169. package/pipeline/scripts/plan-critique-gate.mjs +11 -2
  170. package/pipeline/scripts/prune-logs.sh +53 -2
  171. package/pipeline/scripts/rework-rulings.mjs +106 -0
  172. package/pipeline/scripts/runs-index.mjs +62 -2
  173. package/pipeline/scripts/scope-check-gate.mjs +149 -2
  174. package/pipeline/scripts/system-status.mjs +196 -0
  175. package/pipeline/scripts/task-mirror.mjs +150 -0
  176. package/pipeline/scripts/task-mirror.sh +20 -0
  177. package/pipeline/scripts/toolkit-tools.mjs +33 -7
  178. package/pipeline/scripts/transcript-index.mjs +4 -1
  179. package/pipeline/scripts/triage-memory.mjs +64 -37
  180. package/pipeline/scripts/validate-planning.mjs +68 -4
  181. package/pipeline/scripts/validate-reviewer.mjs +20 -0
  182. package/pipeline/scripts/validate-triage.mjs +107 -2
  183. package/pipeline/scripts/work-items.mjs +191 -22
  184. package/pipeline/skills/.skill-manifest.json +9 -9
  185. package/pipeline/skills/.skills-index.json +1 -1
  186. package/pipeline/skills/shared/README.md +1 -1
  187. package/pipeline/skills/shared/core/multi-agent-graph/SKILL.md +5 -2
  188. package/pipeline/skills/shared/core/multi-agent-language/SKILL.md +4 -4
  189. package/pipeline/skills/shared/core/multi-agent-office-on/SKILL.md +3 -4
  190. package/pipeline/skills/shared/core/multi-agent-prune-logs/SKILL.md +4 -1
  191. package/pipeline/skills/shared/core/multi-agent-review/SKILL.md +21 -0
  192. package/pipeline/skills/shared/core/multi-agent-scenario-audit/SKILL.md +4 -2
  193. package/pipeline/skills/shared/core/multi-agent-setup/SKILL.md +4 -4
  194. package/pipeline/skills/shared/core/multi-agent-test-gen/SKILL.md +26 -3
  195. package/pipeline/skills/skills-index.md +1 -1
@@ -0,0 +1,196 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * @file system-status.mjs - what this machine has left: CPU load, memory,
4
+ * free disk, and what the pipeline itself is using (its sessions, the toolkit
5
+ * server, the log folder).
6
+ *
7
+ * Numbers only. No path, process argument, user name or host name leaves this
8
+ * script: a process is counted by what it is (a Claude Code session, the
9
+ * toolkit server), never listed, and a folder is reported by its size alone.
10
+ *
11
+ * CPU use is measured over a short window from two samples of the per-core
12
+ * counters, so a call takes about SAMPLE_MS. Folder sizes come from `du -sk`
13
+ * under a deadline; a folder too large to measure in time is null, not 0.
14
+ *
15
+ * Usage:
16
+ * system-status.mjs [--json]
17
+ *
18
+ * LOGS_ROOT overrides ~/.claude/logs/multi-agent.
19
+ *
20
+ * Exit: 0 printed, 2 usage.
21
+ *
22
+ * @module pipeline/scripts/system-status
23
+ */
24
+
25
+ import { statfsSync } from "node:fs";
26
+ import { cpus, freemem, homedir, loadavg, totalmem } from "node:os";
27
+ import { join } from "node:path";
28
+
29
+ import { invokedDirectly } from "../lib/invoked-directly.mjs";
30
+ import { spawnAsync } from "./_spawn-async.mjs";
31
+
32
+ export const CONTRACT_VERSION = "1.0.0";
33
+ export const SAMPLE_MS = 250;
34
+ /** Free disk below this share of the disk turns the panel red. */
35
+ export const DISK_LOW = 0.1;
36
+ const DU_TIMEOUT_MS = 3000;
37
+ const MB = 1024 * 1024;
38
+
39
+ const logsRoot = (env) => env.LOGS_ROOT || join(homedir(), ".claude", "logs", "multi-agent");
40
+
41
+ function cpuTimes() {
42
+ return cpus().map((c) => {
43
+ const t = c.times;
44
+ return {
45
+ busy: t.user + t.nice + t.sys + t.irq,
46
+ total: t.user + t.nice + t.sys + t.irq + t.idle,
47
+ };
48
+ });
49
+ }
50
+
51
+ /** Busy share of the CPU between two samples, 0..1, or null without counters. */
52
+ export function cpuShare(a, b) {
53
+ let busy = 0;
54
+ let total = 0;
55
+ for (let i = 0; i < Math.min(a.length, b.length); i++) {
56
+ busy += b[i].busy - a[i].busy;
57
+ total += b[i].total - a[i].total;
58
+ }
59
+ return total > 0 ? Math.min(1, Math.max(0, busy / total)) : null;
60
+ }
61
+
62
+ function disk(path) {
63
+ try {
64
+ const s = statfsSync(path);
65
+ const total = s.blocks * s.bsize;
66
+ const free = s.bavail * s.bsize;
67
+ return { totalMb: Math.round(total / MB), freeMb: Math.round(free / MB), device: s.type };
68
+ } catch {
69
+ return null;
70
+ }
71
+ }
72
+
73
+ // The log folder's size changes slowly and du over a large tree is the
74
+ // slowest part of the answer, so one measurement serves a minute of polls.
75
+ const FOLDER_TTL_MS = 60_000;
76
+ const folderCache = new Map();
77
+ async function folderMb(path) {
78
+ const hit = folderCache.get(path);
79
+ if (hit && Date.now() - hit.at < FOLDER_TTL_MS) return hit.mb;
80
+ const r = await spawnAsync("du", ["-sk", path], { timeoutMs: DU_TIMEOUT_MS });
81
+ const kb = r.status === 0 ? Number(r.stdout.split(/\s+/)[0]) : NaN;
82
+ const mb = Number.isFinite(kb) ? Math.round(kb / 1024) : null;
83
+ folderCache.set(path, { at: Date.now(), mb });
84
+ return mb;
85
+ }
86
+
87
+ /**
88
+ * Pipeline processes from `ps`, counted and summed, never listed. A Claude Code
89
+ * session is a process whose executable is `claude`; the toolkit server is a
90
+ * node, npx or npm process whose command line names multi-agent-toolkit.
91
+ */
92
+ export function countProcesses(psText) {
93
+ const out = { sessions: 0, toolkit: 0, rssMb: 0 };
94
+ for (const line of String(psText).split("\n")) {
95
+ const m = /^\s*(\d+)\s+(\S+)(?:\s+(.*))?$/.exec(line);
96
+ if (!m) continue;
97
+ const rssKb = Number(m[1]);
98
+ const exe = m[2].split("/").pop();
99
+ const rest = m[3] || "";
100
+ if (exe === "claude") out.sessions++;
101
+ // The server runs under node or npx; a grep or tail naming it is not it.
102
+ else if (/^(node|npx|npm)$/.test(exe) && /multi-agent-toolkit/.test(rest)) out.toolkit++;
103
+ else continue;
104
+ out.rssMb += rssKb / 1024;
105
+ }
106
+ out.rssMb = Math.round(out.rssMb);
107
+ return out;
108
+ }
109
+
110
+ async function processes() {
111
+ const r = await spawnAsync("ps", ["-A", "-o", "rss=", "-o", "args="], {
112
+ timeoutMs: 2000,
113
+ maxBytes: 8 * MB,
114
+ });
115
+ return r.status === 0 ? countProcesses(r.stdout) : null;
116
+ }
117
+
118
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
119
+
120
+ /**
121
+ * Bytes macOS can hand out right away: free pages plus the inactive,
122
+ * speculative and purgeable ones it reclaims on demand. os.freemem() counts
123
+ * free pages only, which reads as nearly full on any Mac that has run a while.
124
+ */
125
+ export function availableFromVmStat(text) {
126
+ const page = Number((/page size of (\d+) bytes/.exec(text) || [])[1]);
127
+ if (!page) return null;
128
+ const pages = (name) => Number((new RegExp(`Pages ${name}:\\s+(\\d+)`).exec(text) || [])[1] || 0);
129
+ const n = pages("free") + pages("inactive") + pages("speculative") + pages("purgeable");
130
+ return n ? n * page : null;
131
+ }
132
+
133
+ async function availableMemory() {
134
+ if (process.platform !== "darwin") return freemem();
135
+ const r = await spawnAsync("vm_stat", [], { timeoutMs: 2000, maxBytes: MB });
136
+ return (r.status === 0 && availableFromVmStat(r.stdout)) || freemem();
137
+ }
138
+
139
+ export async function systemStatus(env = process.env) {
140
+ const a = cpuTimes();
141
+ await sleep(SAMPLE_MS);
142
+ const b = cpuTimes();
143
+ const cores = b.length;
144
+ const [l1, l5, l15] = loadavg();
145
+ const memTotal = totalmem();
146
+ const memFree = await availableMemory();
147
+ const root = logsRoot(env);
148
+ const home = disk(homedir());
149
+ const logs = disk(root);
150
+ const sameDisk = Boolean(
151
+ home && logs && home.device === logs.device && home.totalMb === logs.totalMb,
152
+ );
153
+ const strip = (d) => (d ? { totalMb: d.totalMb, freeMb: d.freeMb } : null);
154
+ const share = home && home.totalMb ? home.freeMb / home.totalMb : null;
155
+ const cpuUse = cpuShare(a, b);
156
+ const [procs, logsMb] = await Promise.all([processes(), folderMb(root)]);
157
+ return {
158
+ contractVersion: CONTRACT_VERSION,
159
+ generatedAt: new Date().toISOString(),
160
+ cpu: {
161
+ cores,
162
+ usage: cpuUse === null ? null : Math.round(cpuUse * 100) / 100,
163
+ load: [l1, l5, l15].map((x) => Math.round(x * 100) / 100),
164
+ },
165
+ memory: {
166
+ totalMb: Math.round(memTotal / MB),
167
+ usedMb: Math.round((memTotal - memFree) / MB),
168
+ },
169
+ disk: {
170
+ home: strip(home),
171
+ logs: sameDisk ? null : strip(logs),
172
+ },
173
+ pipeline: {
174
+ ...(procs ?? { sessions: null, toolkit: null, rssMb: null }),
175
+ logsMb,
176
+ },
177
+ warnings: [
178
+ ...(share !== null && share < DISK_LOW ? ["disk-low"] : []),
179
+ ...(cores && l5 > cores ? ["cpu-busy"] : []),
180
+ ],
181
+ };
182
+ }
183
+
184
+ if (invokedDirectly(import.meta.url)) {
185
+ const args = process.argv.slice(2);
186
+ if (args.some((a) => a !== "--json")) {
187
+ console.error("usage: system-status.mjs [--json]");
188
+ process.exit(2);
189
+ }
190
+ const doc = await systemStatus();
191
+ process.stdout.write(
192
+ args.includes("--json")
193
+ ? JSON.stringify(doc) + "\n"
194
+ : `cpu ${doc.cpu.usage ?? "?"} of ${doc.cpu.cores} cores, memory ${doc.memory.usedMb}/${doc.memory.totalMb} MB, disk free ${doc.disk.home?.freeMb ?? "?"} MB\n`,
195
+ );
196
+ }
@@ -0,0 +1,150 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * @file task-mirror.mjs - PostToolUse hook: the host's task list, mirrored
4
+ * into the run's tracker without the agent having to remember it.
5
+ *
6
+ * tracker-contract.md asks for one `phase-tracker.sh task` call per native
7
+ * TaskCreate / TaskUpdate so readers that are not the host (runs-index.mjs and
8
+ * the office panel behind the contract server) see the same list under the
9
+ * same phase. That call is an instruction, and an instruction can be skipped;
10
+ * this hook makes the mirror a side effect of the native call itself.
11
+ *
12
+ * Which run: _active-run.mjs findActiveRun, and only a run bound to this
13
+ * session by `sessionId` or `hostSessionId`, the same rule run-stop-guard.mjs
14
+ * uses. No bound run: nothing is written.
15
+ *
16
+ * What is mirrored:
17
+ * - TaskCreate, unless its subject is a phase tile ("Phase 2 Dev"): the phase
18
+ * list already is the state file (tracker-contract.md, "Mirroring").
19
+ * - TaskUpdate of a task already mirrored, with its new status, subject or
20
+ * active form. An update of a phase tile or of a task this hook never saw
21
+ * is left alone.
22
+ * The task goes under the phase in progress when it was created.
23
+ *
24
+ * Exits 0 on every path and prints nothing: a mirror, not a gate. A failure is
25
+ * written to the hook failure log.
26
+ */
27
+
28
+ import { spawnSync } from "node:child_process";
29
+ import { readFileSync } from "node:fs";
30
+ import { dirname, join } from "node:path";
31
+ import { fileURLToPath } from "node:url";
32
+
33
+ import { invokedDirectly } from "../lib/invoked-directly.mjs";
34
+ import { findActiveRun, logHookFailure, runDirOf } from "./_active-run.mjs";
35
+
36
+ const HOOK = "task-mirror";
37
+ const SESSION_BOUND = new Set(["session", "host-session"]);
38
+ const STATUSES = new Set(["pending", "in_progress", "completed"]);
39
+ const TILE = /^Phase \d+\b/;
40
+ const MAX_INPUT = 256 * 1024;
41
+ const HERE = dirname(fileURLToPath(import.meta.url));
42
+
43
+ /** The id TaskCreate returned, from its structured result or its text. */
44
+ export function createdId(response) {
45
+ if (!response) return null;
46
+ if (typeof response === "object") {
47
+ const id = response.task?.id ?? response.id ?? response.taskId;
48
+ if (id !== undefined && id !== null && String(id) !== "") return String(id);
49
+ }
50
+ const text = typeof response === "string" ? response : JSON.stringify(response);
51
+ const m = /Task #(\w+)/.exec(text) || /"id"\s*:\s*"?(\w+)/.exec(text);
52
+ return m ? m[1] : null;
53
+ }
54
+
55
+ /**
56
+ * The `phase-tracker.sh task` arguments one native call maps to, or null when
57
+ * the call is not mirrored.
58
+ *
59
+ * @param {object} payload the PostToolUse hook JSON
60
+ * @param {object} tracker the run's tracker-state.json
61
+ */
62
+ export function mirrorArgs(payload, tracker) {
63
+ const tool = payload?.tool_name;
64
+ const input = payload?.tool_input || {};
65
+ const phases = Array.isArray(tracker?.phases) ? tracker.phases : [];
66
+ if (tool === "TaskCreate") {
67
+ const subject = typeof input.subject === "string" ? input.subject.trim() : "";
68
+ if (!subject || TILE.test(subject)) return null;
69
+ const id = createdId(payload.tool_response);
70
+ // Between two phases the task belongs to the one about to start: the
71
+ // first pending phase after the last finished one, else the last phase.
72
+ const lastDone = phases.findLastIndex((p) => ["completed", "skipped"].includes(p.status));
73
+ const phase =
74
+ phases.find((p) => p.status === "in_progress") ??
75
+ phases.slice(lastDone + 1).find((p) => p.status === "pending") ??
76
+ phases.at(-1);
77
+ if (!id || !phase) return null;
78
+ const args = ["task", String(phase.id), id, "pending", "--subject", subject];
79
+ if (typeof input.activeForm === "string" && input.activeForm)
80
+ args.push("--active-form", input.activeForm);
81
+ return args;
82
+ }
83
+ if (tool === "TaskUpdate") {
84
+ const id = input.taskId === undefined || input.taskId === null ? "" : String(input.taskId);
85
+ if (!id) return null;
86
+ // A resumed run in a new session can number its tasks from 1 again, so
87
+ // the phase in progress is asked first, then the newest phases back.
88
+ const ordered = [...phases.filter((p) => p.status === "in_progress"), ...[...phases].reverse()];
89
+ const phase = ordered.find((p) => (p.tasks || []).some((t) => String(t.id) === id));
90
+ if (!phase) return null;
91
+ const task = phase.tasks.find((t) => String(t.id) === id);
92
+ if (input.status === "deleted") return ["task", String(phase.id), id, "deleted"];
93
+ const status = STATUSES.has(input.status) ? input.status : task.status;
94
+ if (!STATUSES.has(status)) return null;
95
+ const args = ["task", String(phase.id), id, status];
96
+ if (typeof input.subject === "string" && input.subject && !TILE.test(input.subject))
97
+ args.push("--subject", input.subject);
98
+ if (typeof input.activeForm === "string" && input.activeForm)
99
+ args.push("--active-form", input.activeForm);
100
+ return args;
101
+ }
102
+ return null;
103
+ }
104
+
105
+ export function mirror(payload, env = process.env) {
106
+ if (!payload || !["TaskCreate", "TaskUpdate"].includes(payload.tool_name)) return null;
107
+ const run = findActiveRun({
108
+ sessionId: typeof payload.session_id === "string" ? payload.session_id : null,
109
+ cwd: typeof payload.cwd === "string" ? payload.cwd : null,
110
+ env,
111
+ });
112
+ if (!run || !SESSION_BOUND.has(run.matchedBy) || !run.state?.taskId) return null;
113
+ // The tracker it read is the one it writes: phase-tracker.sh would pick
114
+ // the newest folder for the task id, which another copy could be.
115
+ const trackerPath = join(runDirOf(run.statePath), "tracker-state.json");
116
+ let tracker;
117
+ try {
118
+ tracker = JSON.parse(readFileSync(trackerPath, "utf8"));
119
+ } catch {
120
+ return null;
121
+ }
122
+ const args = mirrorArgs(payload, tracker);
123
+ if (!args) return null;
124
+ const r = spawnSync("bash", [join(HERE, "phase-tracker.sh"), ...args], {
125
+ env: {
126
+ ...env,
127
+ MULTI_AGENT_TASK_ID: run.state.taskId,
128
+ TRACKER_FILE: trackerPath,
129
+ TRACKER_QUIET: "1",
130
+ },
131
+ encoding: "utf8",
132
+ timeout: 8000,
133
+ });
134
+ if (r.status !== 0) logHookFailure(HOOK, `phase-tracker task exited ${r.status}`, env);
135
+ return args;
136
+ }
137
+
138
+ if (invokedDirectly(import.meta.url)) {
139
+ try {
140
+ const raw = readFileSync(0, "utf8").slice(0, MAX_INPUT);
141
+ mirror(JSON.parse(raw));
142
+ } catch (e) {
143
+ try {
144
+ logHookFailure(HOOK, String(e?.message || e).slice(0, 200));
145
+ } catch {
146
+ /* the log is best effort */
147
+ }
148
+ }
149
+ process.exit(0);
150
+ }
@@ -0,0 +1,20 @@
1
+ #!/usr/bin/env bash
2
+ # task-mirror.sh - PostToolUse hook (TaskCreate|TaskUpdate) for the pipeline.
3
+ #
4
+ # Mirrors the host's native task list into the run's tracker-state.json, so the
5
+ # office panel and every other reader see what the task widget shows. The
6
+ # logic is in task-mirror.mjs; this wrapper exists because every hook the
7
+ # pipeline registers is a `bash "$HOME/.<host>/scripts/<name>"` command.
8
+ #
9
+ # Exits 0 on every path and prints nothing: a missing node or any internal
10
+ # error means no mirror, never a blocked tool call.
11
+
12
+ set -u
13
+
14
+ HERE="$(cd "$(dirname "$0")" 2>/dev/null && pwd || true)"
15
+ CORE="$HERE/task-mirror.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
@@ -29,8 +29,9 @@
29
29
  * MCP_TOOLKIT_URL_POLICY=strict, set after the registration's own environment
30
30
  * so neither can be overridden. A registration whose package spec has no exact
31
31
  * version (bare, a dist-tag such as `@latest`, or a range) is pinned to
32
- * MA_TOOLKIT_VERSION or prefs.global.office.toolkitVersion, and refused when
33
- * neither is set.
32
+ * MA_TOOLKIT_VERSION, else prefs.global.office.toolkitVersion, else the
33
+ * version the release ships beside the office page (office/toolkit.json, the
34
+ * one it was checked against), and refused when none is set.
34
35
  *
35
36
  * No tool that is not read-only is allowed today. Opening one up means routing
36
37
  * it through the server's two-step confirm, never a direct call.
@@ -58,7 +59,7 @@
58
59
  import { spawn } from "node:child_process";
59
60
  import { readFileSync } from "node:fs";
60
61
  import { homedir } from "node:os";
61
- import { basename, join } from "node:path";
62
+ import { basename, dirname, join } from "node:path";
62
63
  import { clearTimeout, setTimeout } from "node:timers";
63
64
  import { createRedactor } from "./transcript-index.mjs";
64
65
  import { runMain } from "../lib/fatal.mjs";
@@ -229,12 +230,15 @@ export function unpinned(entry) {
229
230
  /**
230
231
  * The registration with every unpinned spec set to `<name>@<version>`, or
231
232
  * null when it is unpinned and no exact version is known. The version comes
232
- * from MA_TOOLKIT_VERSION, else prefs.global.office.toolkitVersion.
233
+ * from MA_TOOLKIT_VERSION, else prefs.global.office.toolkitVersion, else
234
+ * office/toolkit.json beside the page (MA_OFFICE_PAGE's directory, or the
235
+ * office directory next to this script's).
233
236
  */
234
237
  export function pinnedEntry(entry, env = process.env) {
235
238
  if (!unpinned(entry)) return entry;
236
239
  const prefs = readJson(join(env.HOME || homedir(), ".claude", "multi-agent-preferences.json"));
237
- const version = env.MA_TOOLKIT_VERSION || prefs?.global?.office?.toolkitVersion;
240
+ const version =
241
+ env.MA_TOOLKIT_VERSION || prefs?.global?.office?.toolkitVersion || shippedToolkitVersion(env);
238
242
  if (typeof version !== "string" || !SEMVER.test(version)) return null;
239
243
  const loose = new Set(packageSpecs(entry).filter((s) => !SEMVER.test(splitSpec(s)[1] ?? "")));
240
244
  const pin = (a) => {
@@ -248,6 +252,24 @@ export function pinnedEntry(entry, env = process.env) {
248
252
  return { ...entry, args: Array.isArray(entry.args) ? entry.args.map(pin) : entry.args };
249
253
  }
250
254
 
255
+ /** The toolkit version the release ships beside the office page, or undefined. */
256
+ export function shippedToolkitVersion(env = process.env) {
257
+ const dir = env.MA_OFFICE_PAGE
258
+ ? dirname(env.MA_OFFICE_PAGE)
259
+ : join(import.meta.dirname, "..", "office");
260
+ const v = readJson(join(dir, "toolkit.json"))?.toolkitVersion;
261
+ return typeof v === "string" && SEMVER.test(v) ? v : undefined;
262
+ }
263
+
264
+ /** The exact toolkit version the Tools window starts, or null when none is known. */
265
+ export function activeToolkitVersion(env = process.env) {
266
+ const registered = toolkitEntry(env);
267
+ const entry = registered ? pinnedEntry(registered, env) : null;
268
+ const spec = entry ? packageSpecs(entry)[0] : null;
269
+ const v = spec ? splitSpec(spec)[1] : null;
270
+ return typeof v === "string" && SEMVER.test(v) ? v : null;
271
+ }
272
+
251
273
  /** read, low or high, from the tool's own annotations. */
252
274
  export function riskOf(tool) {
253
275
  const a = tool?.annotations ?? {};
@@ -386,7 +408,7 @@ export function createToolkitClient({ env = process.env } = {}) {
386
408
  return Promise.reject(
387
409
  new ToolError(
388
410
  "unpinned",
389
- "the toolkit registration starts a package without an exact version; register a pinned version or set prefs.global.office.toolkitVersion",
411
+ "no exact toolkit version is known: the release ships it in office/toolkit.json beside the page, which reinstalling (node install.js) restores; MA_TOOLKIT_VERSION or prefs.global.office.toolkitVersion override it",
390
412
  ),
391
413
  );
392
414
  }
@@ -565,7 +587,11 @@ async function main(argv) {
565
587
  try {
566
588
  let out;
567
589
  if (cmd === "list" && !name) {
568
- out = { contractVersion: CONTRACT_VERSION, tools: await client.listTools() };
590
+ out = {
591
+ contractVersion: CONTRACT_VERSION,
592
+ version: activeToolkitVersion(),
593
+ tools: await client.listTools(),
594
+ };
569
595
  } else if (cmd === "call" && name) {
570
596
  let args = {};
571
597
  if (raw !== undefined) {
@@ -139,9 +139,12 @@ const TOKEN_CHAR = /[A-Za-z0-9_\-./+=:@~%]/;
139
139
  * `text` cut to `limit` characters, backed off to the start of a token that
140
140
  * the cut would split, so no half-credential survives on the boundary.
141
141
  */
142
+ // The " ..." ending counts toward the limit, so a cut text is never longer
143
+ // than the field that holds it.
142
144
  function cut(text, limit) {
143
145
  if (text.length <= limit) return text;
144
- let end = limit;
146
+ if (limit < 5) return text.slice(0, limit);
147
+ let end = limit - 4;
145
148
  while (end > 0 && TOKEN_CHAR.test(text[end]) && TOKEN_CHAR.test(text[end - 1])) end--;
146
149
  return `${text.slice(0, end).trimEnd()} ...`;
147
150
  }
@@ -10,7 +10,9 @@
10
10
  * Storage: append-only JSONL, one file per repo:
11
11
  * $HOME/.claude/memory/multi-agent/<repo-slug>/triage-corpus.jsonl
12
12
  *
13
- * Each line conforms to pipeline/schemas/triage-corpus.schema.json.
13
+ * Each line conforms to pipeline/schemas/triage-corpus.schema.json. Every row
14
+ * passes the secret scan in _secret-scan.mjs before it is written; one that
15
+ * fails is counted in `droppedBySecretScan` and left out.
14
16
  *
15
17
  * Why JSONL not SQLite: zero deps, Node 18+ compatible, easy to grep/diff,
16
18
  * easy to delete a single row by line. The cost of a full corpus scan is
@@ -55,6 +57,8 @@ import { readFileSync, existsSync, mkdirSync, appendFileSync, statSync } from "n
55
57
  import { homedir } from "node:os";
56
58
  import { execFileSync } from "node:child_process";
57
59
  import { join, basename, dirname, posix } from "node:path";
60
+ import { checkedSlug, memoryExcluded } from "./_memory-policy.mjs";
61
+ import { openScanner, stripPrivate } from "./_secret-scan.mjs";
58
62
  import {
59
63
  bm25,
60
64
  recencyRank,
@@ -91,6 +95,10 @@ function die(msg, code = 1) {
91
95
  // prior-art recall always miss. `--git-common-dir` points at the main
92
96
  // repository's .git for every linked worktree.
93
97
  function repoSlug() {
98
+ return checkedSlug(rawRepoSlug());
99
+ }
100
+
101
+ function rawRepoSlug() {
94
102
  if (flags["repo-slug"]) return String(flags["repo-slug"]);
95
103
  if (process.env.MULTI_AGENT_REPO_SLUG) return process.env.MULTI_AGENT_REPO_SLUG;
96
104
  try {
@@ -249,9 +257,14 @@ function ingest() {
249
257
  const fromState = stateDefaults();
250
258
  const taskId = flags["task-id"] || fromState.taskId;
251
259
  if (!taskId) die("ingest needs --task-id <id> (or --state with a taskId)", 1);
252
- const taskTitle = flags["task-title"] || fromState.taskTitle || null;
260
+ const taskTitle = stripPrivate(flags["task-title"] || fromState.taskTitle || "").trim() || null;
253
261
  const stack = flags.stack || fromState.stack || null;
254
262
  const slug = repoSlug();
263
+ if (memoryExcluded(slug)) {
264
+ process.stdout.write(JSON.stringify({ ok: true, slug, ingested: 0, excluded: true }) + "\n");
265
+ process.exitCode = 2;
266
+ return;
267
+ }
255
268
 
256
269
  let triage;
257
270
  try {
@@ -274,35 +287,51 @@ function ingest() {
274
287
  ];
275
288
 
276
289
  let written = 0;
277
- for (const [classification, rows] of buckets) {
278
- for (const f of rows) {
279
- const issue = (f.issue || "").trim();
280
- if (!issue) continue;
281
- const file = f.file || "";
282
- const key = `${taskId}::${classification}::${file}::${issue}`;
283
- if (seen.has(key)) continue;
284
- const row = {
285
- v: "1.0.0",
286
- ts,
287
- task_id: taskId,
288
- task_title: taskTitle,
289
- stack,
290
- classification,
291
- severity: f.severity || "suggestion",
292
- file,
293
- line: typeof f.line === "number" ? f.line : null,
294
- issue,
295
- fix: f.fix || null,
296
- reason: f._reason || null,
297
- reviewer: f.reviewer || null,
298
- file_sha: fileSha(file, top),
299
- };
300
- writeRow(slug, row);
301
- seen.add(key);
302
- written++;
290
+ let droppedBySecretScan = 0;
291
+ const scan = openScanner();
292
+ try {
293
+ for (const [classification, rows] of buckets) {
294
+ for (const f of rows) {
295
+ const issue = stripPrivate(f.issue || "").trim();
296
+ if (!issue) continue;
297
+ const file = f.file || "";
298
+ const key = `${taskId}::${classification}::${file}::${issue}`;
299
+ if (seen.has(key)) continue;
300
+ const fix = f.fix ? stripPrivate(f.fix).trim() || null : null;
301
+ const reason = f._reason ? stripPrivate(f._reason).trim() || null : null;
302
+ const reviewer = f.reviewer || null;
303
+ const stored = [issue, fix, reason, file, taskTitle, taskId, stack, reviewer, f.severity];
304
+ if (!scan.isClean(stored.filter(Boolean).join("\n"))) {
305
+ droppedBySecretScan++;
306
+ continue;
307
+ }
308
+ const row = {
309
+ v: "1.0.0",
310
+ ts,
311
+ task_id: taskId,
312
+ task_title: taskTitle,
313
+ stack,
314
+ classification,
315
+ severity: f.severity || "suggestion",
316
+ file,
317
+ line: typeof f.line === "number" ? f.line : null,
318
+ issue,
319
+ fix,
320
+ reason,
321
+ reviewer,
322
+ file_sha: fileSha(file, top),
323
+ };
324
+ writeRow(slug, row);
325
+ seen.add(key);
326
+ written++;
327
+ }
303
328
  }
329
+ } finally {
330
+ scan.close();
304
331
  }
305
- process.stdout.write(JSON.stringify({ ok: true, slug, ingested: written }) + "\n");
332
+ process.stdout.write(
333
+ JSON.stringify({ ok: true, slug, ingested: written, droppedBySecretScan }) + "\n",
334
+ );
306
335
  // exitCode, not exit(): even this one-line write is on the same rule, and a
307
336
  // rule with an exception is the one that gets copied into the next branch.
308
337
  // 2 = nothing new, which callers treat as an idempotent success.
@@ -313,7 +342,7 @@ function ingest() {
313
342
  function query() {
314
343
  const cfg = recallConfig();
315
344
  const topN = flags.top ? Math.max(1, Number(flags.top)) : cfg.maxResults;
316
- const slug = flags["repo-slug"] ? String(flags["repo-slug"]) : repoSlug();
345
+ const slug = repoSlug();
317
346
  const corpus = readCorpus(slug);
318
347
  if (corpus.length === 0) {
319
348
  process.stdout.write(JSON.stringify({ ok: true, slug, hits: [], total: 0 }) + "\n");
@@ -347,7 +376,7 @@ function priorArt() {
347
376
  : [];
348
377
  const cfg = recallConfig();
349
378
  const topN = flags.top ? Math.max(1, Number(flags.top)) : cfg.maxResults;
350
- const slug = flags["repo-slug"] ? String(flags["repo-slug"]) : repoSlug();
379
+ const slug = repoSlug();
351
380
  const corpus = readCorpus(slug);
352
381
  const out = list.map((f) => {
353
382
  if (corpus.length === 0) return [];
@@ -436,7 +465,7 @@ function rank(corpus, issueText, fileGlob, topN, cfg) {
436
465
  function showCmd() {
437
466
  const id = flags.id && flags.id !== true ? String(flags.id) : null;
438
467
  if (!id) die("show needs --id <id>", 1);
439
- const slug = flags["repo-slug"] ? String(flags["repo-slug"]) : repoSlug();
468
+ const slug = repoSlug();
440
469
  const want = id.startsWith("T:") ? id : `T:${id}`;
441
470
  const row = readCorpus(slug).find((r) => rowId(r) === want);
442
471
  if (!row) {
@@ -465,7 +494,7 @@ function showCmd() {
465
494
  function timelineCmd() {
466
495
  const id = flags.anchor && flags.anchor !== true ? String(flags.anchor) : null;
467
496
  if (!id) die("timeline needs --anchor <id>", 1);
468
- const slug = flags["repo-slug"] ? String(flags["repo-slug"]) : repoSlug();
497
+ const slug = repoSlug();
469
498
  const before = flags.before && flags.before !== true ? Math.max(0, Number(flags.before)) : 3;
470
499
  const after = flags.after && flags.after !== true ? Math.max(0, Number(flags.after)) : 3;
471
500
  const want = id.startsWith("T:") ? id : `T:${id}`;
@@ -495,15 +524,13 @@ function timelineCmd() {
495
524
  }
496
525
 
497
526
  function pathCmd() {
498
- process.stdout.write(
499
- corpusPath(flags["repo-slug"] ? String(flags["repo-slug"]) : repoSlug()) + "\n",
500
- );
527
+ process.stdout.write(corpusPath(repoSlug()) + "\n");
501
528
  process.exitCode = 0;
502
529
  return;
503
530
  }
504
531
 
505
532
  function statsCmd() {
506
- const slug = flags["repo-slug"] ? String(flags["repo-slug"]) : repoSlug();
533
+ const slug = repoSlug();
507
534
  const corpus = readCorpus(slug);
508
535
  const counts = { accepted: 0, deferred: 0, rejected: 0 };
509
536
  for (const r of corpus) if (counts[r.classification] !== undefined) counts[r.classification]++;