@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
@@ -6,7 +6,7 @@
6
6
  *
7
7
  * on [--no-open] start contract-server.mjs in the background with the
8
8
  * installed panel page (../office/index.html, passed as
9
- * MA_OFFICE_PAGE) and an 8 hour lifetime, or reuse the one this script
9
+ * MA_OFFICE_PAGE), running until `off`, or reuse the one this script
10
10
  * started if it is still alive, then open the browser at
11
11
  * /office with a one-time code in the URL fragment,
12
12
  * through a 0600 launcher file so the code is never on a
@@ -30,8 +30,8 @@
30
30
  * script only reports it; it carries no remote code.
31
31
  *
32
32
  * State: one 0600 file, ~/.claude/logs/multi-agent/office/office.json, with
33
- * the pid, port and token file of the server this script started. Nothing runs
34
- * while the panel is closed: the server exits by itself after its idle time.
33
+ * the pid, port and token file of the server this script started. The server
34
+ * runs until office-off stops it; no daemon, hook or watcher runs beside it.
35
35
  *
36
36
  * Usage:
37
37
  * office-control.mjs on [--no-open] [--json]
@@ -56,11 +56,16 @@ import {
56
56
  } from "node:fs";
57
57
  import { homedir } from "node:os";
58
58
  import { dirname, join } from "node:path";
59
+
60
+ import { codeDigest } from "../lib/code-digest.mjs";
59
61
  import { fileURLToPath } from "node:url";
60
62
 
61
63
  const HERE = dirname(fileURLToPath(import.meta.url));
62
- const IDLE_MINUTES = 15;
63
- const MAX_HOURS = 8;
64
+ // The panel runs until office-off: no idle stop and no lifetime, because a
65
+ // person keeps it open beside their work. The server still binds 127.0.0.1
66
+ // only, and every office-on that starts one mints a new token.
67
+ const IDLE_MINUTES = 0;
68
+ const MAX_HOURS = 0;
64
69
  /** How far a process's start may sit before its token file's startedAt. */
65
70
  const START_SKEW_MS = 120 * 1000;
66
71
 
@@ -248,10 +253,23 @@ function openBrowser(target, env) {
248
253
  return r.status === 0;
249
254
  }
250
255
 
251
- /** Start (or reuse) the panel server and open the browser. */
256
+ const digestOf = (server) => (server ? codeDigest(dirname(server)) : null);
257
+
258
+ /**
259
+ * Start (or reuse) the panel server and open the browser. A running server
260
+ * whose code on disk has changed since it started (an update installed a new
261
+ * pipeline) is stopped and a new one started, so the panel never runs on a
262
+ * server older than its page.
263
+ */
252
264
  export async function officeOn({ env = process.env, open = true } = {}) {
253
265
  let s = current(env);
254
266
  let reused = true;
267
+ let restarted = false;
268
+ if (s && s.serverDigest && s.serverDigest !== digestOf(serverScript(env))) {
269
+ officeOff({ env });
270
+ s = null;
271
+ restarted = true;
272
+ }
255
273
  if (!s) {
256
274
  reused = false;
257
275
  const server = serverScript(env);
@@ -289,13 +307,22 @@ export async function officeOn({ env = process.env, open = true } = {}) {
289
307
  idleMinutes: info.idleMinutes,
290
308
  maxHours: info.maxHours ?? null,
291
309
  startedAt: new Date().toISOString(),
310
+ serverDigest: digestOf(server),
292
311
  });
293
312
  s = current(env);
294
313
  if (!s) throw new Error("the server stopped right after it started");
295
314
  }
296
315
  const page = `${s.url}/office`;
297
316
  const opened = open ? openBrowser(`${page}#code=${await panelCode(s)}`, env) : false;
298
- return { running: true, reused, url: page, pid: s.pid, idleMinutes: s.idleMinutes, opened };
317
+ return {
318
+ running: true,
319
+ reused,
320
+ restarted,
321
+ url: page,
322
+ pid: s.pid,
323
+ idleMinutes: s.idleMinutes,
324
+ opened,
325
+ };
299
326
  }
300
327
 
301
328
  /** Stop the server this script started. */
@@ -0,0 +1,106 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * @file office-credentials.mjs - the key cabinet: which services the pipeline
4
+ * holds a credential for and whether each one works, never a value.
5
+ *
6
+ * The answer comes from lib/credential-inventory.sh, the same inventory a run
7
+ * consults before asking the user for data an external system holds. That
8
+ * script probes a credential through the credential store with stdout
9
+ * discarded, so a secret never reaches this process; this file keeps only the
10
+ * logical name, the state, the probe verdict and what the credential is for,
11
+ * each checked against a fixed vocabulary.
12
+ *
13
+ * Renewing a credential stays in the terminal (`/multi-agent:setup`): the
14
+ * secret never passes through the browser.
15
+ *
16
+ * Usage:
17
+ * office-credentials.mjs [--json] [--probe]
18
+ *
19
+ * Exit: 0 printed, 2 usage.
20
+ *
21
+ * @module pipeline/scripts/office-credentials
22
+ */
23
+
24
+ import { existsSync } from "node:fs";
25
+ import { dirname, join } from "node:path";
26
+ import { fileURLToPath } from "node:url";
27
+
28
+ import { invokedDirectly } from "../lib/invoked-directly.mjs";
29
+ import { spawnAsync } from "./_spawn-async.mjs";
30
+
31
+ export const CONTRACT_VERSION = "1.0.0";
32
+ const HERE = dirname(fileURLToPath(import.meta.url));
33
+ const STATES = new Set(["present", "mapped-but-missing", "unmapped"]);
34
+ const REACH = new Set([
35
+ "not-probed",
36
+ "reachable",
37
+ "auth-rejected",
38
+ "unreachable",
39
+ "no-host-configured",
40
+ "well-formed",
41
+ "not-probeable",
42
+ "malformed",
43
+ "no-credential",
44
+ "probe-error",
45
+ "server-error",
46
+ "tier-1-ready",
47
+ "tier-2-ready",
48
+ "tier-1-no-grant",
49
+ ]);
50
+ const NAME = /^[a-z][a-z0-9_]{0,60}$/;
51
+
52
+ function inventoryPath(env) {
53
+ if (env.MA_CREDENTIAL_INVENTORY) return env.MA_CREDENTIAL_INVENTORY;
54
+ for (const p of [join(HERE, "..", "lib", "credential-inventory.sh")]) if (existsSync(p)) return p;
55
+ return null;
56
+ }
57
+
58
+ export async function officeCredentials({ probe = false, env = process.env } = {}) {
59
+ const script = inventoryPath(env);
60
+ const base = {
61
+ contractVersion: CONTRACT_VERSION,
62
+ generatedAt: new Date().toISOString(),
63
+ probed: probe,
64
+ };
65
+ if (!script) return { ...base, available: false, credentials: [] };
66
+ const r = await spawnAsync("bash", [script, "--json", ...(probe ? ["--probe"] : [])], {
67
+ env,
68
+ timeoutMs: probe ? 45000 : 10000,
69
+ });
70
+ const doc = (() => {
71
+ try {
72
+ return JSON.parse(r.stdout || "");
73
+ } catch {
74
+ return null;
75
+ }
76
+ })();
77
+ const rows = Array.isArray(doc?.credentials) ? doc.credentials : [];
78
+ return {
79
+ ...base,
80
+ available: Boolean(doc),
81
+ credentials: rows
82
+ .filter((c) => typeof c?.logical === "string" && NAME.test(c.logical))
83
+ .map((c) => ({
84
+ logical: c.logical,
85
+ state: STATES.has(c.state) ? c.state : "mapped-but-missing",
86
+ // A verdict this file does not know is reported as unknown, never as
87
+ // not checked: a probe that ran said something.
88
+ reachability: REACH.has(c.reachability) ? c.reachability : probe ? "unknown" : "not-probed",
89
+ capability: typeof c.capability === "string" ? c.capability.slice(0, 160) : null,
90
+ })),
91
+ };
92
+ }
93
+
94
+ if (invokedDirectly(import.meta.url)) {
95
+ const args = process.argv.slice(2);
96
+ if (args.some((a) => a !== "--json" && a !== "--probe")) {
97
+ console.error("usage: office-credentials.mjs [--json] [--probe]");
98
+ process.exit(2);
99
+ }
100
+ const doc = await officeCredentials({ probe: args.includes("--probe") });
101
+ process.stdout.write(
102
+ args.includes("--json")
103
+ ? JSON.stringify(doc) + "\n"
104
+ : doc.credentials.map((c) => `${c.logical}: ${c.state}, ${c.reachability}`).join("\n") + "\n",
105
+ );
106
+ }
@@ -0,0 +1,200 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * @file office-memory.mjs - what the pipeline has learned, per repository.
4
+ *
5
+ * The learning loop writes four stores, and this reads them without changing
6
+ * any:
7
+ *
8
+ * code graph ~/.claude/knowledge/<repo>/code-graph.json (graph-build.mjs)
9
+ * learnings ~/.claude/memory/multi-agent/<repo>/learnings-ledger.jsonl
10
+ * (learnings-ledger.mjs: durable facts, conventions, rejected
11
+ * preferences replayed into later runs)
12
+ * past findings .../triage-corpus.jsonl (triage-memory.mjs: review findings)
13
+ * session notes .../session-notes.jsonl (learn-from-transcripts.mjs)
14
+ *
15
+ * For each repository: when the graph was built and how large it is, how many
16
+ * learnings, findings and session notes there are, the newest learnings'
17
+ * statements (redacted and cut short) and when anything was last written. An
18
+ * empty shelf is reported as empty, so a repository the loop never fed shows.
19
+ *
20
+ * Usage:
21
+ * office-memory.mjs [--json]
22
+ *
23
+ * MA_MEMORY_ROOT and MA_KNOWLEDGE_ROOT override the two roots. Each store is
24
+ * read asynchronously: lines are counted in chunks and only the last
25
+ * MAX_BYTES are parsed, so a store that keeps growing never blocks the server.
26
+ *
27
+ * Exit: 0 printed, 2 usage.
28
+ *
29
+ * @module pipeline/scripts/office-memory
30
+ */
31
+
32
+ import { constants, createReadStream, existsSync, readdirSync, statSync } from "node:fs";
33
+ import { open } from "node:fs/promises";
34
+ import { homedir } from "node:os";
35
+ import { join } from "node:path";
36
+
37
+ import { invokedDirectly } from "../lib/invoked-directly.mjs";
38
+ import { createRedactor } from "./transcript-index.mjs";
39
+
40
+ export const CONTRACT_VERSION = "1.0.0";
41
+ const MAX_REPOS = 60;
42
+ const RECENT = 5;
43
+ const REPO = /^[A-Za-z0-9._-]{1,100}$/;
44
+ const MAX_BYTES = 1024 * 1024;
45
+
46
+ const roots = (env) => ({
47
+ memory: env.MA_MEMORY_ROOT || join(homedir(), ".claude", "memory", "multi-agent"),
48
+ knowledge: env.MA_KNOWLEDGE_ROOT || join(homedir(), ".claude", "knowledge"),
49
+ });
50
+
51
+ const dirsOf = (root) => {
52
+ try {
53
+ return readdirSync(root, { withFileTypes: true })
54
+ .filter((d) => d.isDirectory() && REPO.test(d.name))
55
+ .map((d) => d.name);
56
+ } catch {
57
+ return [];
58
+ }
59
+ };
60
+
61
+ // A line that is not a JSON object is skipped like an unreadable one.
62
+ const parse = (line) => {
63
+ try {
64
+ const v = JSON.parse(line);
65
+ return v && typeof v === "object" && !Array.isArray(v) ? [v] : [];
66
+ } catch {
67
+ return [];
68
+ }
69
+ };
70
+
71
+ // A store's lines, the same way for a small file read whole and a large one
72
+ // counted in chunks: every newline, and a last line that has none.
73
+ function linesIn(buf) {
74
+ let n = 0;
75
+ for (let i = buf.indexOf(10); i !== -1; i = buf.indexOf(10, i + 1)) n++;
76
+ return n + (buf.length && buf[buf.length - 1] !== 10 ? 1 : 0);
77
+ }
78
+
79
+ // Counted through the handle already open, so the count is of the same file.
80
+ async function countLines(fh, size) {
81
+ let n = 0;
82
+ let last = 10;
83
+ const stream = createReadStream(null, { fd: fh.fd, start: 0, end: size - 1, autoClose: false });
84
+ for await (const chunk of stream) {
85
+ for (let i = chunk.indexOf(10); i !== -1; i = chunk.indexOf(10, i + 1)) n++;
86
+ if (chunk.length) last = chunk[chunk.length - 1];
87
+ }
88
+ return n + (size > 0 && last !== 10 ? 1 : 0);
89
+ }
90
+
91
+ /**
92
+ * A store's line count and its last lines parsed (unreadable lines skipped);
93
+ * { count: 0, tail: [] } for no file. Only the last `maxBytes` are parsed.
94
+ */
95
+ async function store(path, maxBytes) {
96
+ let fh;
97
+ try {
98
+ // Non-blocking and not through a link: a FIFO or a symlink planted in a
99
+ // store's place is skipped instead of holding a worker thread.
100
+ fh = await open(path, constants.O_RDONLY | constants.O_NONBLOCK | constants.O_NOFOLLOW);
101
+ const st = await fh.stat();
102
+ if (!st.isFile()) return { count: 0, tail: [] };
103
+ const { size } = st;
104
+ const start = Math.max(0, size - maxBytes);
105
+ const buf = Buffer.alloc(size - start);
106
+ await fh.read(buf, 0, buf.length, start);
107
+ let text = buf.toString("utf8");
108
+ if (start > 0) text = text.slice(text.indexOf("\n") + 1);
109
+ const lines = text.split("\n").filter(Boolean);
110
+ const count = start > 0 ? await countLines(fh, size) : linesIn(buf);
111
+ return { count, tail: lines.flatMap(parse) };
112
+ } catch {
113
+ return { count: 0, tail: [] };
114
+ } finally {
115
+ await fh?.close();
116
+ }
117
+ }
118
+
119
+ const ms = (v) => {
120
+ const t = Date.parse(v ?? "");
121
+ return Number.isFinite(t) ? t : null;
122
+ };
123
+ const iso = (t) => (t === null ? null : new Date(t).toISOString());
124
+
125
+ async function shelf(name, r, now, R, maxBytes) {
126
+ const dir = join(r.memory, name);
127
+ const graphPath = join(r.knowledge, name, "code-graph.json");
128
+ let graph = null;
129
+ if (existsSync(graphPath)) {
130
+ const st = statSync(graphPath);
131
+ graph = {
132
+ builtAt: iso(st.mtimeMs),
133
+ ageDays: Math.max(0, Math.floor((now - st.mtimeMs) / 86_400_000)),
134
+ sizeKb: Math.round(st.size / 1024),
135
+ };
136
+ }
137
+ const [learnings, findings, notes] = await Promise.all(
138
+ ["learnings-ledger.jsonl", "triage-corpus.jsonl", "session-notes.jsonl"].map((f) =>
139
+ store(join(dir, f), maxBytes),
140
+ ),
141
+ );
142
+ const times = [...learnings.tail, ...findings.tail, ...notes.tail]
143
+ .map((x) => ms(x.ts))
144
+ .filter((t) => t !== null);
145
+ if (graph) times.push(ms(graph.builtAt));
146
+ return {
147
+ repo: name,
148
+ graph,
149
+ learnings: learnings.count,
150
+ findings: findings.count,
151
+ sessionNotes: notes.count,
152
+ recent: learnings.tail
153
+ .slice(-RECENT)
154
+ .reverse()
155
+ .map((x) => ({
156
+ kind: typeof x.kind === "string" ? x.kind.slice(0, 30) : null,
157
+ statement: R.excerpt(String(x.statement ?? ""), 120) ?? "",
158
+ at: iso(ms(x.ts)),
159
+ })),
160
+ lastWrite: times.length ? iso(Math.max(...times)) : null,
161
+ };
162
+ }
163
+
164
+ export async function officeMemory({
165
+ env = process.env,
166
+ now = Date.now(),
167
+ maxBytes = MAX_BYTES,
168
+ } = {}) {
169
+ const r = roots(env);
170
+ // The transcript redactor: credentials by provider rule as well as paths
171
+ // and addresses, the same pass the activity view uses.
172
+ const R = createRedactor();
173
+ const names = [...new Set([...dirsOf(r.memory), ...dirsOf(r.knowledge)])];
174
+ // One shelf that cannot be read is left out; the others still answer.
175
+ const read = await Promise.all(names.map((n) => shelf(n, r, now, R, maxBytes).catch(() => null)));
176
+ const shelves = read
177
+ .filter(Boolean)
178
+ .sort((a, b) => (b.lastWrite ?? "").localeCompare(a.lastWrite ?? ""))
179
+ .slice(0, MAX_REPOS);
180
+ return { contractVersion: CONTRACT_VERSION, generatedAt: new Date(now).toISOString(), shelves };
181
+ }
182
+
183
+ if (invokedDirectly(import.meta.url)) {
184
+ const args = process.argv.slice(2);
185
+ if (args.some((a) => a !== "--json")) {
186
+ console.error("usage: office-memory.mjs [--json]");
187
+ process.exit(2);
188
+ }
189
+ const doc = await officeMemory();
190
+ process.stdout.write(
191
+ args.includes("--json")
192
+ ? JSON.stringify(doc) + "\n"
193
+ : doc.shelves
194
+ .map(
195
+ (s) =>
196
+ `${s.repo}: graph ${s.graph ? s.graph.ageDays + "d old" : "none"}, ${s.learnings} learnings, ${s.findings} findings, ${s.sessionNotes} notes`,
197
+ )
198
+ .join("\n") + "\n",
199
+ );
200
+ }
@@ -0,0 +1,125 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * @file office-prune.mjs - old run logs, cleared from the restroom.
4
+ *
5
+ * A thin wrapper over prune-logs.sh, the same script /multi-agent:prune-logs
6
+ * runs: its dry run is the preview (which task log folders would go and the
7
+ * space freed). On the office's route (pruneApply) the confirmed step lists
8
+ * the folders again, refuses with code "changed" when they differ from the
9
+ * preview, and then prunes only that list (prune-logs.sh --only-from); the
10
+ * CLI's `apply` prunes the age directly, as /multi-agent:prune-logs does.
11
+ * Everything prune-logs.sh guarantees holds here: the audit trail,
12
+ * the metrics corpus and the task counters are never touched, the log root is
13
+ * refused when it is /, the home folder or a git checkout, a folder written
14
+ * in the last minutes is spared so a live run keeps its log, and so is a run
15
+ * that can still be resumed.
16
+ *
17
+ * The age is one of a fixed set of days; nothing else reaches the script.
18
+ *
19
+ * Usage:
20
+ * office-prune.mjs preview <days> [--json]
21
+ * office-prune.mjs apply <days> [--json]
22
+ *
23
+ * PRUNE_LOG_ROOT overrides the log root, as for prune-logs.sh.
24
+ *
25
+ * Exit: 0 printed, 1 prune-logs.sh refused, 2 usage.
26
+ *
27
+ * @module pipeline/scripts/office-prune
28
+ */
29
+
30
+ import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
31
+ import { tmpdir } from "node:os";
32
+ import { dirname, join } from "node:path";
33
+ import { fileURLToPath } from "node:url";
34
+
35
+ import { invokedDirectly } from "../lib/invoked-directly.mjs";
36
+ import { spawnAsync } from "./_spawn-async.mjs";
37
+
38
+ export const CONTRACT_VERSION = "1.0.0";
39
+ export const AGES = [7, 30, 90];
40
+ const HERE = dirname(fileURLToPath(import.meta.url));
41
+ const MAX_LISTED = 200;
42
+
43
+ /** What a prune-logs.sh run printed: the folders and the space freed. */
44
+ export function parsePrune(text) {
45
+ const lines = String(text).split("\n");
46
+ const head = lines.find((l) => /would remove|removed/i.test(l)) || "";
47
+ const removed = /removed (\d+)\/\d+ task log dir/.exec(head);
48
+ const n = Number(removed ? removed[1] : ((/(\d+) task log dir/.exec(head) || [])[1] ?? 0));
49
+ const size = /~(\d+(?:\.\d+)?) ?(KB|MB)/.exec(head);
50
+ const mb = size
51
+ ? size[2] === "KB"
52
+ ? Math.round((Number(size[1]) / 1024) * 10) / 10
53
+ : Number(size[1])
54
+ : 0;
55
+ const all = lines
56
+ .map((l) => /^\s{2}(\S(?:.*\S)?)\s*$/.exec(l))
57
+ .filter(Boolean)
58
+ .map((m) => m[1]);
59
+ return { count: n || all.length, freedMb: mb, folders: all.slice(0, MAX_LISTED), all };
60
+ }
61
+
62
+ // The page sees at most MAX_LISTED folders; the confirmation compares the
63
+ // whole list.
64
+ async function run(days, apply, env, { keepAll = false, only = null } = {}) {
65
+ if (!AGES.includes(days)) throw new Error(`age must be one of ${AGES.join(", ")}`);
66
+ const dir = only ? mkdtempSync(join(tmpdir(), "office-prune-")) : null;
67
+ const list = dir ? join(dir, "only.txt") : null;
68
+ if (list) writeFileSync(list, only.join("\n") + "\n", { mode: 0o600 });
69
+ const r = await spawnAsync(
70
+ "bash",
71
+ [
72
+ join(HERE, "prune-logs.sh"),
73
+ `--older-than=${days}`,
74
+ ...(list ? [`--only-from=${list}`] : []),
75
+ ...(apply ? ["--yes"] : []),
76
+ ],
77
+ { env, timeoutMs: 120_000 },
78
+ ).finally(() => dir && rmSync(dir, { recursive: true, force: true }));
79
+ if (r.status !== 0) throw new Error("prune-logs.sh refused (exit " + r.status + ")");
80
+ const { all, ...parsed } = parsePrune(r.stdout);
81
+ return {
82
+ contractVersion: CONTRACT_VERSION,
83
+ olderThanDays: days,
84
+ applied: apply,
85
+ ...parsed,
86
+ ...(keepAll ? { all } : {}),
87
+ };
88
+ }
89
+
90
+ export const prunePreview = (days, env = process.env, opts = {}) => run(days, false, env, opts);
91
+
92
+ /**
93
+ * Prunes only what the preview showed: the folders are listed again and the
94
+ * prune runs only when they are the same set, and then only on that list, so
95
+ * a run that aged into the window or a folder that appeared since is never
96
+ * removed unseen.
97
+ */
98
+ export async function pruneApply(days, env = process.env, expected = null) {
99
+ if (expected && Array.isArray(expected.all)) {
100
+ const now = await run(days, false, env, { keepAll: true });
101
+ const want = new Set(expected.all);
102
+ const same =
103
+ now.count === expected.count &&
104
+ now.all.length === want.size &&
105
+ now.all.every((f) => want.has(f));
106
+ if (!same) {
107
+ const e = new Error("the folders changed since the preview; preview again");
108
+ e.code = "changed";
109
+ throw e;
110
+ }
111
+ return run(days, true, env, { only: expected.all });
112
+ }
113
+ return run(days, true, env);
114
+ }
115
+
116
+ if (invokedDirectly(import.meta.url)) {
117
+ const [cmd, d, flag] = process.argv.slice(2);
118
+ const days = Number(d);
119
+ if (!["preview", "apply"].includes(cmd) || !AGES.includes(days) || (flag && flag !== "--json")) {
120
+ console.error(`usage: office-prune.mjs preview|apply <${AGES.join("|")}> [--json]`);
121
+ process.exit(2);
122
+ }
123
+ const doc = await run(days, cmd === "apply", process.env);
124
+ process.stdout.write(JSON.stringify(doc) + "\n");
125
+ }
@@ -0,0 +1,145 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * @file office-settings.mjs - the three switches the office can flip, read
4
+ * and changed through the same code the terminal commands use.
5
+ *
6
+ * language prefs.global.outputLanguage, en or tr, as /multi-agent:language
7
+ * writes it (promptLanguage forced back to en in the same write),
8
+ * then localize-commands.mjs swaps the installed picker text
9
+ * model the fable rung, through lib/model-rung.sh on|off
10
+ * route model routing, through lib/route-state.sh on|off; `on` keeps
11
+ * the saved configuration, and anything that would widen it (a
12
+ * free provider, new rules) stays in the terminal
13
+ *
14
+ * Every value is one of a fixed set; nothing else reaches a script.
15
+ *
16
+ * Usage:
17
+ * office-settings.mjs [--json]
18
+ * office-settings.mjs set <language|model|route> <value> [--json]
19
+ *
20
+ * MULTI_AGENT_PREFS overrides the preferences file, as for the scripts it calls.
21
+ *
22
+ * Exit: 0 printed or applied, 1 the change failed, 2 usage.
23
+ *
24
+ * @module pipeline/scripts/office-settings
25
+ */
26
+
27
+ import { existsSync } from "node:fs";
28
+ import { homedir } from "node:os";
29
+ import { dirname, join } from "node:path";
30
+ import { fileURLToPath } from "node:url";
31
+
32
+ import { invokedDirectly } from "../lib/invoked-directly.mjs";
33
+ import { updateJsonFileSync } from "../lib/json-file-lock.mjs";
34
+ import { readRegularJson } from "../lib/regular-file.mjs";
35
+ import { spawnAsync } from "./_spawn-async.mjs";
36
+
37
+ export const CONTRACT_VERSION = "1.0.0";
38
+ export const CHOICES = {
39
+ language: ["en", "tr"],
40
+ model: ["on", "off"],
41
+ route: ["on", "off"],
42
+ };
43
+ const STRATEGIES = ["manual", "task-fit", "cost-ceiling"];
44
+ const HERE = dirname(fileURLToPath(import.meta.url));
45
+
46
+ const prefsPath = (env) =>
47
+ env.MULTI_AGENT_PREFS || join(env.HOME || homedir(), ".claude", "multi-agent-preferences.json");
48
+
49
+ function libScript(name) {
50
+ const p = join(HERE, "..", "lib", name);
51
+ return existsSync(p) ? p : null;
52
+ }
53
+
54
+ /** What the switches are set to now. */
55
+ export function readSettings(env = process.env) {
56
+ const g = readRegularJson(prefsPath(env), {})?.global ?? {};
57
+ return {
58
+ contractVersion: CONTRACT_VERSION,
59
+ language: g.outputLanguage === "tr" ? "tr" : "en",
60
+ model: g.modelFallback?.fableEnabled === true ? "on" : "off",
61
+ route: g.modelRouting?.enabled === true ? "on" : "off",
62
+ };
63
+ }
64
+
65
+ /**
66
+ * The free provider chain a saved routing configuration keeps across `route
67
+ * off`, which `route on` brings back; null when none is saved.
68
+ */
69
+ export function freeChainOf(env = process.env) {
70
+ const free = readRegularJson(prefsPath(env), {})?.global?.modelRouting?.free;
71
+ if (free?.enabled !== true) return null;
72
+ return { enabled: true, allowTraining: free.allowTraining === true };
73
+ }
74
+
75
+ export class SettingsError extends Error {}
76
+
77
+ // route-state.sh on resets the strategy to manual unless told otherwise; the
78
+ // saved one is passed back so `on` changes only the switch.
79
+ function routeArgs(setting, value, env) {
80
+ if (setting !== "route" || value !== "on") return [];
81
+ const saved = readRegularJson(prefsPath(env), {})?.global?.modelRouting?.strategy;
82
+ return [`--strategy=${STRATEGIES.includes(saved) ? saved : "manual"}`];
83
+ }
84
+
85
+ /** Flip one switch to one of its values; returns the settings afterwards. */
86
+ export async function applySetting(setting, value, env = process.env) {
87
+ if (!CHOICES[setting]?.includes(value)) throw new SettingsError(`${setting}: not a choice`);
88
+ if (setting === "language") {
89
+ const path = prefsPath(env);
90
+ if (!existsSync(path)) throw new SettingsError("no preferences file");
91
+ let written = false;
92
+ updateJsonFileSync(path, (cur) => {
93
+ if (!cur || typeof cur !== "object" || Array.isArray(cur)) return undefined;
94
+ written = true;
95
+ const global = { ...(cur.global ?? {}), promptLanguage: "en", outputLanguage: value };
96
+ return { ...cur, global };
97
+ });
98
+ if (!written)
99
+ throw new SettingsError("language: the preferences file is not an object, not written");
100
+ const loc = join(HERE, "localize-commands.mjs");
101
+ if (existsSync(loc)) {
102
+ const r = await spawnAsync(process.execPath, [loc, value === "tr" ? "apply" : "restore"], {
103
+ env,
104
+ timeoutMs: 30_000,
105
+ });
106
+ if (r.status !== 0)
107
+ throw new SettingsError(
108
+ `language: saved, but the command descriptions were not switched (exit ${r.status})`,
109
+ );
110
+ }
111
+ } else {
112
+ const script = libScript(setting === "model" ? "model-rung.sh" : "route-state.sh");
113
+ if (!script) throw new SettingsError(`${setting}: script not installed`);
114
+ const r = await spawnAsync("bash", [script, value, ...routeArgs(setting, value, env)], {
115
+ env,
116
+ timeoutMs: 30_000,
117
+ });
118
+ if (r.status !== 0)
119
+ throw new SettingsError(`${setting}: the script refused (exit ${r.status})`);
120
+ }
121
+ return readSettings(env);
122
+ }
123
+
124
+ if (invokedDirectly(import.meta.url)) {
125
+ const args = process.argv.slice(2).filter((a) => a !== "--json");
126
+ const json = process.argv.includes("--json");
127
+ const out = (doc) =>
128
+ process.stdout.write(
129
+ json
130
+ ? JSON.stringify(doc) + "\n"
131
+ : `language ${doc.language}, model ${doc.model}, route ${doc.route}\n`,
132
+ );
133
+ if (!args.length) out(readSettings());
134
+ else if (args[0] === "set" && args.length === 3) {
135
+ try {
136
+ out(await applySetting(args[1], args[2]));
137
+ } catch (e) {
138
+ console.error(String(e.message));
139
+ process.exit(1);
140
+ }
141
+ } else {
142
+ console.error("usage: office-settings.mjs [set <language|model|route> <value>] [--json]");
143
+ process.exit(2);
144
+ }
145
+ }