@mmerterden/multi-agent-pipeline 20.30.0 → 20.33.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 (120) hide show
  1. package/CHANGELOG.md +80 -0
  2. package/README.md +45 -0
  3. package/README.tr.md +59 -0
  4. package/docs/facts.json +3 -3
  5. package/install/_unattended-profile.mjs +61 -0
  6. package/install/catalog-history.json +1 -1
  7. package/install/claude.mjs +9 -2
  8. package/install/index.mjs +10 -1
  9. package/install/templates/claude-hooks.json +12 -1
  10. package/manifest.json +123 -82
  11. package/package.json +2 -1
  12. package/pipeline/commands/multi-agent/analysis/SKILL.md +4 -4
  13. package/pipeline/commands/multi-agent/office-on/SKILL.md +8 -7
  14. package/pipeline/commands/multi-agent/office-status/SKILL.md +1 -1
  15. package/pipeline/commands/multi-agent/prune-logs/SKILL.md +4 -1
  16. package/pipeline/commands/multi-agent/review/SKILL.md +4 -3
  17. package/pipeline/commands/multi-agent/scenario-audit/SKILL.md +4 -2
  18. package/pipeline/commands/multi-agent/sync/SKILL.md +12 -0
  19. package/pipeline/commands/multi-agent/test-gen/SKILL.md +29 -3
  20. package/pipeline/contract/CHANGELOG.md +46 -0
  21. package/pipeline/contract/README.md +22 -8
  22. package/pipeline/contract/fixtures/error-invalid-request.json +1 -1
  23. package/pipeline/contract/fixtures/error-unauthorized.json +1 -1
  24. package/pipeline/contract/fixtures/error-unsigned.json +1 -1
  25. package/pipeline/contract/fixtures/launch-plan.json +1 -1
  26. package/pipeline/contract/fixtures/runs-awaiting-question.json +6 -1
  27. package/pipeline/contract/fixtures/runs-empty.json +1 -1
  28. package/pipeline/contract/fixtures/runs-failed.json +6 -1
  29. package/pipeline/contract/fixtures/runs-old-schema.json +6 -1
  30. package/pipeline/contract/fixtures/runs-pr-opened-redacted.json +5 -1
  31. package/pipeline/contract/fixtures/runs-pr-opened.json +6 -1
  32. package/pipeline/contract/fixtures/runs-running.json +6 -1
  33. package/pipeline/contract/manifest.json +153 -19
  34. package/pipeline/contract/types/index.d.ts +304 -6
  35. package/pipeline/lib/bg-launch.mjs +106 -0
  36. package/pipeline/lib/code-digest.mjs +49 -0
  37. package/pipeline/lib/credential-resolve.mjs +42 -2
  38. package/pipeline/multi-agent-refs/features/office-threat-model.md +24 -6
  39. package/pipeline/multi-agent-refs/features/unattended-security.md +1 -1
  40. package/pipeline/multi-agent-refs/help/en.md +2 -2
  41. package/pipeline/multi-agent-refs/help/tr.md +2 -2
  42. package/pipeline/multi-agent-refs/phases/modes.md +7 -7
  43. package/pipeline/multi-agent-refs/phases/phase-5-report.md +16 -10
  44. package/pipeline/multi-agent-refs/review-standalone-tracker.md +37 -0
  45. package/pipeline/multi-agent-refs/tracker-contract.md +8 -3
  46. package/pipeline/office/NOTICE.md +4 -1
  47. package/pipeline/office/assets/ARCHIVE_BOXES.png +0 -0
  48. package/pipeline/office/assets/COFFEE_MACHINE.png +0 -0
  49. package/pipeline/office/assets/COUNTER_SINK.png +0 -0
  50. package/pipeline/office/assets/DEVICE_STAND.png +0 -0
  51. package/pipeline/office/assets/FILING_CABINET.png +0 -0
  52. package/pipeline/office/assets/FRIDGE.png +0 -0
  53. package/pipeline/office/assets/MAILBOX.png +0 -0
  54. package/pipeline/office/assets/MOP_BUCKET.png +0 -0
  55. package/pipeline/office/assets/PRINTER.png +0 -0
  56. package/pipeline/office/assets/RECEPTION_DESK.png +0 -0
  57. package/pipeline/office/assets/SERVER_RACK.png +0 -0
  58. package/pipeline/office/assets/SERVER_RACK_2.png +0 -0
  59. package/pipeline/office/assets/TOILET_STALL.png +0 -0
  60. package/pipeline/office/assets/VENDING.png +0 -0
  61. package/pipeline/office/assets/WASHBASIN.png +0 -0
  62. package/pipeline/office/assets/WATER_COOLER.png +0 -0
  63. package/pipeline/office/index.html +3996 -884
  64. package/pipeline/office/toolkit.json +3 -0
  65. package/pipeline/schemas/activity.schema.json +188 -4
  66. package/pipeline/schemas/agent-state.schema.json +493 -115
  67. package/pipeline/schemas/contract-error.schema.json +12 -1
  68. package/pipeline/schemas/job-change.schema.json +72 -0
  69. package/pipeline/schemas/job-request.schema.json +38 -0
  70. package/pipeline/schemas/launch.json +4 -4
  71. package/pipeline/schemas/office-credentials.schema.json +71 -0
  72. package/pipeline/schemas/office-jobs.json +253 -0
  73. package/pipeline/schemas/office-jobs.schema.json +62 -0
  74. package/pipeline/schemas/office-memory.schema.json +105 -0
  75. package/pipeline/schemas/office-settings-request.schema.json +30 -0
  76. package/pipeline/schemas/office-settings.schema.json +67 -0
  77. package/pipeline/schemas/phases.json +10 -2
  78. package/pipeline/schemas/phases.schema.json +86 -19
  79. package/pipeline/schemas/prs.schema.json +5 -0
  80. package/pipeline/schemas/prune-logs-request.schema.json +26 -0
  81. package/pipeline/schemas/prune-logs.schema.json +52 -0
  82. package/pipeline/schemas/repo-change.schema.json +54 -0
  83. package/pipeline/schemas/repo-request.schema.json +29 -0
  84. package/pipeline/schemas/repos.schema.json +49 -1
  85. package/pipeline/schemas/runs-index.schema.json +257 -46
  86. package/pipeline/schemas/system-status.schema.json +111 -0
  87. package/pipeline/schemas/tools.schema.json +5 -0
  88. package/pipeline/schemas/unattended-profile.json +7 -2
  89. package/pipeline/scripts/_active-run.mjs +2 -1
  90. package/pipeline/scripts/_spawn-async.mjs +9 -5
  91. package/pipeline/scripts/activity-snapshot.mjs +219 -23
  92. package/pipeline/scripts/autopilot-runner.mjs +7 -5
  93. package/pipeline/scripts/contract-server.mjs +551 -25
  94. package/pipeline/scripts/gen-mode-dispatch.mjs +3 -1
  95. package/pipeline/scripts/job-launch.mjs +477 -0
  96. package/pipeline/scripts/launch-request.mjs +94 -9
  97. package/pipeline/scripts/office-build.mjs +148 -11
  98. package/pipeline/scripts/office-control.mjs +34 -7
  99. package/pipeline/scripts/office-credentials.mjs +106 -0
  100. package/pipeline/scripts/office-memory.mjs +200 -0
  101. package/pipeline/scripts/office-prune.mjs +125 -0
  102. package/pipeline/scripts/office-settings.mjs +145 -0
  103. package/pipeline/scripts/phase-tracker.sh +12 -5
  104. package/pipeline/scripts/prune-logs.sh +53 -2
  105. package/pipeline/scripts/runs-index.mjs +62 -2
  106. package/pipeline/scripts/system-status.mjs +196 -0
  107. package/pipeline/scripts/task-mirror.mjs +150 -0
  108. package/pipeline/scripts/task-mirror.sh +20 -0
  109. package/pipeline/scripts/toolkit-tools.mjs +33 -7
  110. package/pipeline/scripts/transcript-index.mjs +4 -1
  111. package/pipeline/scripts/work-items.mjs +191 -22
  112. package/pipeline/skills/.skill-manifest.json +6 -6
  113. package/pipeline/skills/.skills-index.json +1 -1
  114. package/pipeline/skills/shared/README.md +1 -1
  115. package/pipeline/skills/shared/core/multi-agent-office-on/SKILL.md +3 -4
  116. package/pipeline/skills/shared/core/multi-agent-prune-logs/SKILL.md +4 -1
  117. package/pipeline/skills/shared/core/multi-agent-review/SKILL.md +19 -0
  118. package/pipeline/skills/shared/core/multi-agent-scenario-audit/SKILL.md +4 -2
  119. package/pipeline/skills/shared/core/multi-agent-test-gen/SKILL.md +26 -3
  120. 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
  }