@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
@@ -53,20 +53,14 @@
53
53
  * Exit: 0 ok (including "nothing found") | 1 usage
54
54
  */
55
55
 
56
- import {
57
- existsSync,
58
- mkdtempSync,
59
- readFileSync,
60
- readdirSync,
61
- rmSync,
62
- statSync,
63
- writeFileSync,
64
- } from "node:fs";
56
+ import { existsSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs";
65
57
  import { execFileSync } from "node:child_process";
66
58
  import { basename, dirname, join } from "node:path";
67
- import { homedir, tmpdir } from "node:os";
59
+ import { homedir } from "node:os";
68
60
  import { fileURLToPath } from "node:url";
69
61
  import { runMain } from "../lib/fatal.mjs";
62
+ import { memoryExcluded } from "./_memory-policy.mjs";
63
+ import { dropSecrets } from "./_secret-scan.mjs";
70
64
 
71
65
  const HERE = dirname(fileURLToPath(import.meta.url));
72
66
  const argv = process.argv.slice(2);
@@ -382,41 +376,6 @@ function mine(events) {
382
376
  return found;
383
377
  }
384
378
 
385
- /* ----------------------------------------------------------------- privacy */
386
-
387
- /**
388
- * The single-source secret check. Candidates go to a temp file and through
389
- * pre-commit-check.sh, which owns the patterns; a hit is dropped, never written.
390
- */
391
- function dropSecrets(items) {
392
- const scanner = existsSync(join(HERE, "pre-commit-check.sh"))
393
- ? join(HERE, "pre-commit-check.sh")
394
- : join(homedir(), ".claude", "scripts", "pre-commit-check.sh");
395
- if (!existsSync(scanner)) {
396
- return { kept: items, dropped: 0, scanned: false };
397
- }
398
- const dir = mkdtempSync(join(tmpdir(), "lft-"));
399
- const kept = [];
400
- let dropped = 0;
401
- try {
402
- for (const it of items) {
403
- const f = join(dir, "candidate.txt");
404
- writeFileSync(f, `${it.statement}\n${it.diagnosis || ""}\n${it.scope}\n`);
405
- let clean = true;
406
- try {
407
- execFileSync("bash", [scanner, "--scan-file", f], { stdio: "ignore" });
408
- } catch {
409
- clean = false;
410
- }
411
- if (clean) kept.push(it);
412
- else dropped++;
413
- }
414
- } finally {
415
- rmSync(dir, { recursive: true, force: true });
416
- }
417
- return { kept, dropped, scanned: true };
418
- }
419
-
420
379
  /* -------------------------------------------------------------------- main */
421
380
 
422
381
  function main() {
@@ -462,12 +421,16 @@ function main() {
462
421
  uniq.push(it);
463
422
  }
464
423
 
465
- const { kept, dropped, scanned } = dropSecrets(uniq);
424
+ const { kept, dropped, scanned } = dropSecrets(
425
+ uniq,
426
+ (it) => `${it.statement}\n${it.diagnosis || ""}\n${it.scope}`,
427
+ );
466
428
  const slug = repoSlug();
429
+ const excluded = memoryExcluded(slug);
467
430
 
468
431
  let written = 0;
469
432
  let deduped = 0;
470
- if (APPLY && kept.length) {
433
+ if (APPLY && kept.length && !excluded) {
471
434
  const ledger = existsSync(join(HERE, "learnings-ledger.mjs"))
472
435
  ? join(HERE, "learnings-ledger.mjs")
473
436
  : join(homedir(), ".claude", "scripts", "learnings-ledger.mjs");
@@ -572,7 +535,7 @@ function main() {
572
535
  }
573
536
 
574
537
  let localFile = null;
575
- if (APPLY && kept.length) localFile = writeLocalBlock(kept);
538
+ if (APPLY && kept.length && !excluded) localFile = writeLocalBlock(kept);
576
539
 
577
540
  // Record how many lessons the machine found, kept apart from the ones a model
578
541
  // proposed. Pooling them lets a "lessons learned" number climb while the half
@@ -595,6 +558,7 @@ function main() {
595
558
  const result = {
596
559
  status: "ok",
597
560
  repo: slug,
561
+ excluded,
598
562
  transcripts: files.length,
599
563
  toolResultsExamined: examined,
600
564
  candidates: uniq.length,
@@ -14,7 +14,10 @@
14
14
  * run re-discovers the same structure and reviewers re-flag preferences the team
15
15
  * already rejected.
16
16
  *
17
- * Each line conforms to pipeline/schemas/learnings-ledger.schema.json.
17
+ * Each line conforms to pipeline/schemas/learnings-ledger.schema.json. Every
18
+ * entry passes the secret scan in _secret-scan.mjs before it is written, and
19
+ * `<private>` spans are removed first; a refused entry is counted in
20
+ * `droppedBySecretScan`.
18
21
  *
19
22
  * Subcommands:
20
23
  * add --kind <fact|convention|rejected-preference> --statement "..."
@@ -43,6 +46,12 @@
43
46
  * severity rejections are NOT distilled - a wrong rejection of a
44
47
  * blocking issue must never permanently suppress that class. Exit 2
45
48
  * when nothing new was distilled (a repeat of the same triage).
49
+ * feedback --id <id> --outcome <useful|dead_end|corrected> [--task <id>] [--repo-slug <slug>]
50
+ * Record how an injected entry fared. An entry marked corrected,
51
+ * or judged a dead end at least twice and more often than useful,
52
+ * leaves brief and profile; one useful at least twice ranks by
53
+ * its strength too (_memory-policy.mjs). Kept beside the ledger
54
+ * in learnings-feedback.jsonl.
46
55
  * forget [--statement "..."] [--kind <kind>] [--repo-slug <slug>]
47
56
  * Remove ledger entries matching the statement substring and/or
48
57
  * kind (rewrites the file). Use to clear a bad/stale learning.
@@ -70,7 +79,15 @@ import {
70
79
  import { homedir } from "node:os";
71
80
  import { execFileSync } from "node:child_process";
72
81
  import { join, basename, dirname } from "node:path";
73
- import { bm25, recencyRank, rrf, tokens, shortId, recallConfig } from "./_retrieval.mjs";
82
+ import { bm25, recencyRank, rrf, tokens, recallConfig } from "./_retrieval.mjs";
83
+ import {
84
+ checkedSlug,
85
+ ledgerRowId,
86
+ memoryExcluded,
87
+ nearDuplicate,
88
+ standing,
89
+ } from "./_memory-policy.mjs";
90
+ import { openScanner, stripPrivate } from "./_secret-scan.mjs";
74
91
 
75
92
  const argv = process.argv.slice(2);
76
93
  const SUB = argv[0];
@@ -110,6 +127,10 @@ function die(msg, code = 1) {
110
127
  // the learning replay always miss. `--git-common-dir` points at the main
111
128
  // repository's .git for every linked worktree.
112
129
  function repoSlug() {
130
+ return checkedSlug(rawRepoSlug());
131
+ }
132
+
133
+ function rawRepoSlug() {
113
134
  if (flags["repo-slug"]) return String(flags["repo-slug"]);
114
135
  if (process.env.MULTI_AGENT_REPO_SLUG) return process.env.MULTI_AGENT_REPO_SLUG;
115
136
  try {
@@ -150,6 +171,36 @@ function ledgerPath(slug) {
150
171
  );
151
172
  }
152
173
 
174
+ function feedbackPath(slug) {
175
+ return join(dirname(ledgerPath(slug)), "learnings-feedback.jsonl");
176
+ }
177
+
178
+ /** id -> outcome rows recorded against it. */
179
+ function readFeedback(slug) {
180
+ const p = feedbackPath(slug);
181
+ const byId = new Map();
182
+ if (!existsSync(p)) return byId;
183
+ for (const line of readFileSync(p, "utf8").split("\n")) {
184
+ if (!line.trim()) continue;
185
+ try {
186
+ const f = JSON.parse(line);
187
+ if (!byId.has(f.id)) byId.set(f.id, []);
188
+ byId.get(f.id).push(f);
189
+ } catch {
190
+ /* skip corrupted line */
191
+ }
192
+ }
193
+ return byId;
194
+ }
195
+
196
+ /** Rows with their standing; suppressed ones dropped. */
197
+ function standingRows(slug, rows) {
198
+ const fb = readFeedback(slug);
199
+ return rows
200
+ .map((r) => ({ r, s: standing(fb.get(rowId(r)) ?? []) }))
201
+ .filter((x) => !x.s.suppressed);
202
+ }
203
+
153
204
  function ensureDir(p) {
154
205
  mkdirSync(dirname(p), { recursive: true });
155
206
  }
@@ -171,9 +222,7 @@ function normStatement(s) {
171
222
  // Stable pointer for a ledger row, from the same fields the dedup key uses.
172
223
  // Not stored on the row: an existing ledger gets ids without a migration, and a
173
224
  // re-added entry keeps the id it already had.
174
- function rowId(row) {
175
- return shortId("L", row.kind, normStatement(row.statement));
176
- }
225
+ const rowId = ledgerRowId;
177
226
 
178
227
  function readLedger(slug) {
179
228
  const p = ledgerPath(slug);
@@ -197,8 +246,22 @@ function writeRow(slug, row) {
197
246
  appendFileSync(p, JSON.stringify(row) + "\n", "utf8");
198
247
  }
199
248
 
249
+ // One scanner per process; every write goes through it (_secret-scan.mjs).
250
+ let scanner = null;
251
+ const secretScan = { dropped: 0 };
252
+ function passesSecretScan(text) {
253
+ if (!scanner) {
254
+ scanner = openScanner();
255
+ process.on("exit", () => scanner.close());
256
+ }
257
+ if (scanner.isClean(text)) return true;
258
+ secretScan.dropped++;
259
+ return false;
260
+ }
261
+
200
262
  /** Append one entry unless an entry with the same kind + normalized statement
201
- * already exists (idempotent). Returns true when written. */
263
+ * already exists (idempotent), or the text fails the secret scan. Returns
264
+ * true when written. */
202
265
  function addEntry(slug, { kind, statement, scope, task, confidence, diagnosis, source }, existing) {
203
266
  // One line, enforced here rather than hoped for. The schema says "the
204
267
  // knowledge itself, in one line", and both blocks render one entry per line -
@@ -206,16 +269,23 @@ function addEntry(slug, { kind, statement, scope, task, confidence, diagnosis, s
206
269
  // `from-triage` builds statements out of model-written finding text, a
207
270
  // newline plus a plausible-looking `</repo-profile>` would break out of the
208
271
  // injected block itself. Collapsing whitespace closes both at the store.
209
- const stmt = oneLine(statement);
272
+ const stmt = oneLine(stripPrivate(statement));
210
273
  if (!stmt) return false;
211
274
  const seenKey = `${kind}::${normStatement(stmt)}`;
212
275
  if (existing.has(seenKey)) return false;
276
+ const sameKind = [...existing]
277
+ .filter((k) => k.startsWith(`${kind}::`))
278
+ .map((k) => k.slice(kind.length + 2));
279
+ if (nearDuplicate(stmt.toLowerCase(), sameKind)) return false;
280
+ const diag = oneLine(stripPrivate(diagnosis));
281
+ const scopeText = scope || "global";
282
+ if (!passesSecretScan(`${stmt}\n${diag}\n${scopeText}`)) return false;
213
283
  const row = {
214
284
  v: "1.0.0",
215
285
  ts: new Date().toISOString(),
216
286
  repo_slug: slug,
217
287
  kind,
218
- scope: scope || "global",
288
+ scope: scopeText,
219
289
  statement: stmt,
220
290
  source_task: task || null,
221
291
  confidence: CONFIDENCES.has(confidence) ? confidence : "medium",
@@ -223,7 +293,6 @@ function addEntry(slug, { kind, statement, scope, task, confidence, diagnosis, s
223
293
  // Optional causal diagnosis (Reflexion P5): the "why it failed" behind a
224
294
  // lesson. Verbal diagnosis is what turns a detected error into a prevented
225
295
  // one; a bare outcome does not. Stored only when supplied.
226
- const diag = oneLine(diagnosis);
227
296
  if (diag) row.diagnosis = diag;
228
297
  // Who found it. Optional so old rows stay valid, but written whenever the
229
298
  // caller knows: a machine-mined path correction and a model's architectural
@@ -235,11 +304,19 @@ function addEntry(slug, { kind, statement, scope, task, confidence, diagnosis, s
235
304
  return true;
236
305
  }
237
306
 
307
+ function refuseExcluded(slug) {
308
+ if (!memoryExcluded(slug)) return false;
309
+ process.stdout.write(JSON.stringify({ ok: true, slug, added: 0, excluded: true }) + "\n");
310
+ process.exitCode = 2;
311
+ return true;
312
+ }
313
+
238
314
  function cmdAdd() {
239
315
  const kind = flags.kind;
240
316
  if (!KINDS.has(kind)) die(`--kind must be one of: ${[...KINDS].join(", ")}`, 1);
241
317
  if (!flags.statement || flags.statement === true) die('add needs --statement "..."', 1);
242
318
  const slug = repoSlug();
319
+ if (refuseExcluded(slug)) return;
243
320
  const existing = new Set(readLedger(slug).map((r) => `${r.kind}::${normStatement(r.statement)}`));
244
321
  const wrote = addEntry(
245
322
  slug,
@@ -256,7 +333,13 @@ function cmdAdd() {
256
333
  existing,
257
334
  );
258
335
  process.stdout.write(
259
- JSON.stringify({ ok: true, slug, added: wrote ? 1 : 0, deduped: wrote ? 0 : 1 }) + "\n",
336
+ JSON.stringify({
337
+ ok: true,
338
+ slug,
339
+ added: wrote ? 1 : 0,
340
+ deduped: wrote || secretScan.dropped ? 0 : 1,
341
+ droppedBySecretScan: secretScan.dropped,
342
+ }) + "\n",
260
343
  );
261
344
  process.exit(wrote ? 0 : 2);
262
345
  }
@@ -267,6 +350,7 @@ function cmdFromTriage() {
267
350
  if (!existsSync(triagePath)) die(`triage file not found: ${triagePath}`, 1);
268
351
  const task = flags.task && flags.task !== true ? String(flags.task) : null;
269
352
  const slug = repoSlug();
353
+ if (refuseExcluded(slug)) return;
270
354
  let triage;
271
355
  try {
272
356
  triage = JSON.parse(readFileSync(triagePath, "utf8"));
@@ -311,7 +395,15 @@ function cmdFromTriage() {
311
395
  written++;
312
396
  }
313
397
  }
314
- process.stdout.write(JSON.stringify({ ok: true, slug, added: written, skippedBlocking }) + "\n");
398
+ process.stdout.write(
399
+ JSON.stringify({
400
+ ok: true,
401
+ slug,
402
+ added: written,
403
+ skippedBlocking,
404
+ droppedBySecretScan: secretScan.dropped,
405
+ }) + "\n",
406
+ );
315
407
  process.exitCode = written ? 0 : 2;
316
408
  return;
317
409
  }
@@ -430,12 +522,14 @@ function compareCodepoints(a, b) {
430
522
  }
431
523
 
432
524
  function cmdBrief() {
433
- const slug = flags["repo-slug"] ? String(flags["repo-slug"]) : repoSlug();
525
+ const slug = repoSlug();
434
526
  const cfg = recallConfig();
435
527
  const max = flags.max ? Math.max(1, Number(flags.max)) : 20;
436
528
  const task = flags.task && flags.task !== true ? String(flags.task) : null;
437
529
  const scope = flags.scope && flags.scope !== true ? String(flags.scope) : null;
438
- const rows = readLedger(slug);
530
+ const scored = standingRows(slug, readLedger(slug));
531
+ const rows = scored.map((x) => x.r);
532
+ const strengthOf = new Map(scored.map((x) => [x.r, x.s.corroborated ? x.s.strength : 0]));
439
533
  if (rows.length === 0) {
440
534
  process.exit(2); // nothing to inject; caller skips silently
441
535
  }
@@ -471,8 +565,13 @@ function cmdBrief() {
471
565
  );
472
566
  const fresh = recencyRank(pool, { halfLifeDays: cfg.halfLifeDays });
473
567
  const matched = new Set(relevance.filter((x) => x.score > 0).map((x) => x.index));
474
- const fused = rrf([relevance, fresh], { k: [10, 60], weights: [3, 1] }).filter((x) =>
475
- matched.has(x.index),
568
+ // Entries recorded useful at least twice rank by their strength as well;
569
+ // with no feedback this list is empty and the fusion is unchanged.
570
+ const earned = pool
571
+ .map((r, index) => ({ index, score: strengthOf.get(r) || 0 }))
572
+ .sort((a, b) => b.score - a.score);
573
+ const fused = rrf([relevance, fresh, earned], { k: [10, 60, 60], weights: [3, 1, 1] }).filter(
574
+ (x) => matched.has(x.index),
476
575
  );
477
576
  ordered = fused.slice(0, max).map((x) => pool[x.index]);
478
577
  // A task whose words match nothing in the ledger must not silently inject an
@@ -527,10 +626,10 @@ function scopeMatches(entryScope, target) {
527
626
  // everything after it. Detail is deliberately absent; each line carries the
528
627
  // pointer that buys it back.
529
628
  function cmdProfile() {
530
- const slug = flags["repo-slug"] ? String(flags["repo-slug"]) : repoSlug();
629
+ const slug = repoSlug();
531
630
  const cfg = recallConfig();
532
631
  const max = flags.max ? Math.max(1, Number(flags.max)) : 60;
533
- const rows = readLedger(slug);
632
+ const rows = standingRows(slug, readLedger(slug)).map((x) => x.r);
534
633
  if (rows.length === 0) process.exit(2);
535
634
 
536
635
  const ordered = rows
@@ -565,7 +664,7 @@ function cmdProfile() {
565
664
  function cmdShow() {
566
665
  const id = flags.id && flags.id !== true ? String(flags.id) : null;
567
666
  if (!id) die("show needs --id <id>", 1);
568
- const slug = flags["repo-slug"] ? String(flags["repo-slug"]) : repoSlug();
667
+ const slug = repoSlug();
569
668
  const want = id.startsWith("L:") ? id : `L:${id}`;
570
669
  const row = readLedger(slug).find((r) => rowId(r) === want);
571
670
  if (!row) {
@@ -592,7 +691,7 @@ function cmdShow() {
592
691
  function cmdTimeline() {
593
692
  const id = flags.anchor && flags.anchor !== true ? String(flags.anchor) : null;
594
693
  if (!id) die("timeline needs --anchor <id>", 1);
595
- const slug = flags["repo-slug"] ? String(flags["repo-slug"]) : repoSlug();
694
+ const slug = repoSlug();
596
695
  const before = flags.before && flags.before !== true ? Math.max(0, Number(flags.before)) : 3;
597
696
  const after = flags.after && flags.after !== true ? Math.max(0, Number(flags.after)) : 3;
598
697
  const want = id.startsWith("L:") ? id : `L:${id}`;
@@ -622,15 +721,32 @@ function cmdTimeline() {
622
721
  }
623
722
 
624
723
  function cmdPath() {
625
- process.stdout.write(
626
- ledgerPath(flags["repo-slug"] ? String(flags["repo-slug"]) : repoSlug()) + "\n",
627
- );
724
+ process.stdout.write(ledgerPath(repoSlug()) + "\n");
628
725
  process.exitCode = 0;
629
726
  return;
630
727
  }
631
728
 
729
+ const OUTCOMES = new Set(["useful", "dead_end", "corrected"]);
730
+
731
+ function cmdFeedback() {
732
+ const slug = repoSlug();
733
+ const raw = flags.id && flags.id !== true ? String(flags.id).replace(/^\[|\]$/g, "") : "";
734
+ const id = raw && !raw.startsWith("L:") ? `L:${raw}` : raw;
735
+ const outcome = String(flags.outcome || "");
736
+ if (!id || !OUTCOMES.has(outcome))
737
+ die("feedback needs --id <id> --outcome <useful|dead_end|corrected>", 1);
738
+ if (!readLedger(slug).some((r) => rowId(r) === id)) die(`no ledger entry ${id}`, 2);
739
+ if (refuseExcluded(slug)) return;
740
+ const row = { ts: new Date().toISOString(), id, outcome };
741
+ if (flags.task && flags.task !== true) row.task = String(flags.task);
742
+ ensureDir(feedbackPath(slug));
743
+ appendFileSync(feedbackPath(slug), JSON.stringify(row) + "\n", "utf8");
744
+ const s = standing(readFeedback(slug).get(id) ?? []);
745
+ process.stdout.write(JSON.stringify({ ok: true, slug, id, ...s }) + "\n");
746
+ }
747
+
632
748
  function cmdStats() {
633
- const slug = flags["repo-slug"] ? String(flags["repo-slug"]) : repoSlug();
749
+ const slug = repoSlug();
634
750
  const rows = readLedger(slug);
635
751
  const counts = { fact: 0, convention: 0, "rejected-preference": 0 };
636
752
  for (const r of rows) if (counts[r.kind] !== undefined) counts[r.kind]++;
@@ -651,6 +767,9 @@ switch (SUB) {
651
767
  case "forget":
652
768
  cmdForget();
653
769
  break;
770
+ case "feedback":
771
+ cmdFeedback();
772
+ break;
654
773
  case "brief":
655
774
  cmdBrief();
656
775
  break;
@@ -671,7 +790,7 @@ switch (SUB) {
671
790
  break;
672
791
  default:
673
792
  process.stderr.write(
674
- "usage: learnings-ledger.mjs <add|from-triage|forget|brief|profile|show|timeline|path|stats> [flags]\n",
793
+ "usage: learnings-ledger.mjs <add|from-triage|feedback|forget|brief|profile|show|timeline|path|stats> [flags]\n",
675
794
  );
676
795
  process.exit(64);
677
796
  }
@@ -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
  }