@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
@@ -8,7 +8,10 @@
8
8
  * strings.json) and this script inlines them into
9
9
  * pipeline/office/index.html: the list of sprite names in pipeline/office/assets
10
10
  * (served by contract-server.mjs at /office/assets/), the strings as a JSON
11
- * literal, the two code files in order inside one IIFE.
11
+ * literal, the two code files in order inside one IIFE. It also renders the
12
+ * pixel grids in pipeline/office-art into PNG sprites in pipeline/office/assets
13
+ * and writes pipeline/office/toolkit.json, the toolkit version shipped with
14
+ * the page; --check holds all three to their sources.
12
15
  *
13
16
  * node pipeline/scripts/office-build.mjs write the page
14
17
  * node pipeline/scripts/office-build.mjs --check exit 1 when the page on
@@ -16,7 +19,9 @@
16
19
  * sources build to
17
20
  */
18
21
 
22
+ import { createHash } from "node:crypto";
19
23
  import { readFileSync, readdirSync, writeFileSync } from "node:fs";
24
+ import { deflateSync } from "node:zlib";
20
25
  import { basename, join } from "node:path";
21
26
  import { pathToFileURL } from "node:url";
22
27
 
@@ -24,8 +29,94 @@ const ROOT = join(import.meta.dirname, "..");
24
29
  const SRC = join(ROOT, "office-src");
25
30
  const OUT = join(ROOT, "office", "index.html");
26
31
  const ASSETS = join(ROOT, "office", "assets");
32
+ const TOOLKIT = join(ROOT, "office", "toolkit.json");
33
+ const ART = join(ROOT, "office-art");
34
+ const FACTS = join(ROOT, "..", "docs", "facts.json");
27
35
  const PNG_SIGNATURE = "89504e470d0a1a0a";
28
36
 
37
+ // --- the project's own sprites -----------------------------------------------
38
+ // Each office-art/<NAME>.txt is a "size WxH" line and H rows of W letters, one
39
+ // letter per pixel from office-art/palette.json, "." transparent. They are
40
+ // encoded here into office/assets/<NAME>.png byte for byte the same each time
41
+ // (no timestamp, fixed compression), so --check can hold the PNGs to their
42
+ // text and a review reads the drawing as a diff.
43
+
44
+ const CRC_TABLE = Array.from({ length: 256 }, (_, n) => {
45
+ let c = n;
46
+ for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
47
+ return c >>> 0;
48
+ });
49
+ function crc32(buf) {
50
+ let c = 0xffffffff;
51
+ for (const b of buf) c = CRC_TABLE[(c ^ b) & 0xff] ^ (c >>> 8);
52
+ return (c ^ 0xffffffff) >>> 0;
53
+ }
54
+ function chunk(type, data) {
55
+ const len = Buffer.alloc(4);
56
+ len.writeUInt32BE(data.length);
57
+ const body = Buffer.concat([Buffer.from(type, "ascii"), data]);
58
+ const crc = Buffer.alloc(4);
59
+ crc.writeUInt32BE(crc32(body));
60
+ return Buffer.concat([len, body, crc]);
61
+ }
62
+ /** An RGBA PNG, unfiltered rows, zlib level 9. */
63
+ export function encodePng(width, height, rgba) {
64
+ const header = Buffer.alloc(13);
65
+ header.writeUInt32BE(width, 0);
66
+ header.writeUInt32BE(height, 4);
67
+ header[8] = 8;
68
+ header[9] = 6;
69
+ const raw = Buffer.alloc((width * 4 + 1) * height);
70
+ for (let y = 0; y < height; y++) {
71
+ rgba.copy(raw, y * (width * 4 + 1) + 1, y * width * 4, (y + 1) * width * 4);
72
+ }
73
+ return Buffer.concat([
74
+ Buffer.from(PNG_SIGNATURE, "hex"),
75
+ chunk("IHDR", header),
76
+ chunk("IDAT", deflateSync(raw, { level: 9 })),
77
+ chunk("IEND", Buffer.alloc(0)),
78
+ ]);
79
+ }
80
+
81
+ /** One art file as a PNG; throws on a size line, row length or letter that does not fit. */
82
+ export function renderArt(text, colors, name = "sprite") {
83
+ const lines = text.replace(/\r/g, "").trimEnd().split("\n");
84
+ const m = /^size (\d{1,3})x(\d{1,3})$/.exec(lines[0] ?? "");
85
+ if (!m) throw new Error(`${name}: the first line is "size WxH"`);
86
+ const [w, h] = [Number(m[1]), Number(m[2])];
87
+ const rows = lines.slice(1);
88
+ if (rows.length !== h) throw new Error(`${name}: ${rows.length} rows, expected ${h}`);
89
+ const rgba = Buffer.alloc(w * h * 4);
90
+ rows.forEach((row, y) => {
91
+ if (row.length !== w)
92
+ throw new Error(`${name}: row ${y + 1} is ${row.length} wide, expected ${w}`);
93
+ [...row].forEach((ch, x) => {
94
+ if (ch === ".") return;
95
+ const hex = colors[ch];
96
+ if (!hex) throw new Error(`${name}: "${ch}" is not in the palette`);
97
+ const o = (y * w + x) * 4;
98
+ rgba[o] = parseInt(hex.slice(0, 2), 16);
99
+ rgba[o + 1] = parseInt(hex.slice(2, 4), 16);
100
+ rgba[o + 2] = parseInt(hex.slice(4, 6), 16);
101
+ rgba[o + 3] = 255;
102
+ });
103
+ });
104
+ return encodePng(w, h, rgba);
105
+ }
106
+
107
+ /** Every art sprite, name -> PNG bytes. */
108
+ export function artSprites() {
109
+ const { colors } = JSON.parse(readFileSync(join(ART, "palette.json"), "utf8"));
110
+ const out = new Map();
111
+ for (const f of readdirSync(ART).sort()) {
112
+ if (!f.endsWith(".txt")) continue;
113
+ const name = basename(f, ".txt");
114
+ if (!/^[A-Z0-9_]{1,48}$/.test(name)) throw new Error(`${f}: sprite names are A-Z, 0-9 and _`);
115
+ out.set(name, renderArt(readFileSync(join(ART, f), "utf8"), colors, name));
116
+ }
117
+ return out;
118
+ }
119
+
29
120
  // The sprite names the page may load from /office/assets/: every PNG beside
30
121
  // the page, checked to be one.
31
122
  function atlas() {
@@ -41,6 +132,20 @@ function atlas() {
41
132
  return out;
42
133
  }
43
134
 
135
+ /**
136
+ * The toolkit version the office's Tools window starts, shipped beside the
137
+ * page: the version this release was checked against, from docs/facts.json.
138
+ * The host runs the toolkit as @latest; the panel only runs an exact version,
139
+ * and every pipeline update brings the newer one with it.
140
+ */
141
+ export function toolkitDoc() {
142
+ const v = JSON.parse(readFileSync(FACTS, "utf8")).toolkitVersion;
143
+ if (typeof v !== "string" || !/^\d+\.\d+\.\d+(-[0-9A-Za-z.-]+)?$/.test(v)) {
144
+ throw new Error("docs/facts.json carries no exact toolkitVersion");
145
+ }
146
+ return JSON.stringify({ toolkitVersion: v }, null, 2) + "\n";
147
+ }
148
+
44
149
  export function build() {
45
150
  const read = (f) => readFileSync(join(SRC, f), "utf8");
46
151
  const strings = JSON.parse(read("strings.json"));
@@ -56,6 +161,12 @@ export function build() {
56
161
  "})();",
57
162
  ].join("\n");
58
163
  const style = read("style.css").trim();
164
+ // The build id the server reads back from the installed page: the page
165
+ // compares it with its own to tell that a newer one has been installed.
166
+ const hash = createHash("sha256").update(script).update(style).update(read("shell.html"));
167
+ // Redrawn sprites change the page too, though no source text did.
168
+ for (const [name, png] of artSprites()) hash.update(name).update(png);
169
+ const build = hash.digest("hex").slice(0, 12);
59
170
  // Text that would close the inline element early, or open an HTML comment
60
171
  // inside it, turns source into markup.
61
172
  if (/<\/script|<!--/i.test(script)) throw new Error("the script contains </script or <!--");
@@ -64,26 +175,52 @@ export function build() {
64
175
  for (const marker of ["/*STYLE*/", "/*SCRIPT*/"]) {
65
176
  if (shell.split(marker).length !== 2) throw new Error(`shell.html needs exactly one ${marker}`);
66
177
  }
67
- return shell.replace("/*STYLE*/", () => style).replace("/*SCRIPT*/", () => script);
178
+ const stamped = script.replace('"use strict";', () => `"use strict";\nconst BUILD = "${build}";`);
179
+ return shell.replace("/*STYLE*/", () => style).replace("/*SCRIPT*/", () => stamped);
68
180
  }
69
181
 
70
182
  const isMain = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href;
71
183
  if (isMain) {
184
+ const art = artSprites();
185
+ const check = process.argv.includes("--check");
186
+ if (!check) for (const [name, png] of art) writeFileSync(join(ASSETS, `${name}.png`), png);
72
187
  const html = build();
73
- if (process.argv.includes("--check")) {
74
- let current = "";
75
- try {
76
- current = readFileSync(OUT, "utf8");
77
- } catch {
78
- // A missing page is stale by definition.
188
+ const toolkit = toolkitDoc();
189
+ if (check) {
190
+ for (const [name, png] of art) {
191
+ let on = null;
192
+ try {
193
+ on = readFileSync(join(ASSETS, `${name}.png`));
194
+ } catch {
195
+ // Missing is stale.
196
+ }
197
+ if (!on || !on.equals(png)) {
198
+ console.error(
199
+ `office/assets/${name}.png is stale: run node pipeline/scripts/office-build.mjs`,
200
+ );
201
+ process.exit(1);
202
+ }
79
203
  }
80
- if (current !== html) {
204
+ const read = (f) => {
205
+ try {
206
+ return readFileSync(f, "utf8");
207
+ } catch {
208
+ // A missing file is stale by definition.
209
+ return "";
210
+ }
211
+ };
212
+ if (read(OUT) !== html) {
81
213
  console.error("office/index.html is stale: run node pipeline/scripts/office-build.mjs");
82
214
  process.exit(1);
83
215
  }
84
- console.log("office/index.html matches its sources");
216
+ if (read(TOOLKIT) !== toolkit) {
217
+ console.error("office/toolkit.json is stale: run node pipeline/scripts/office-build.mjs");
218
+ process.exit(1);
219
+ }
220
+ console.log("office/index.html and office/toolkit.json match their sources");
85
221
  } else {
86
222
  writeFileSync(OUT, html);
87
- console.log(`wrote ${OUT} (${Buffer.byteLength(html)} bytes)`);
223
+ writeFileSync(TOOLKIT, toolkit);
224
+ console.log(`wrote ${OUT} (${Buffer.byteLength(html)} bytes) and ${TOOLKIT}`);
88
225
  }
89
226
  }
@@ -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
+ }