cool-workflow 0.2.1 → 0.2.3

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 (156) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/.codex-plugin/plugin.json +1 -1
  3. package/apps/architecture-review/app.json +1 -1
  4. package/apps/architecture-review-fast/app.json +1 -1
  5. package/apps/end-to-end-golden-path/app.json +1 -1
  6. package/apps/pr-review-fix-ci/app.json +1 -1
  7. package/apps/release-cut/app.json +1 -1
  8. package/apps/research-synthesis/app.json +1 -1
  9. package/dist/cli/dispatch.js +29 -91
  10. package/dist/cli/entry.js +41 -1
  11. package/dist/cli/io.js +6 -2
  12. package/dist/cli/parseargv.js +6 -2
  13. package/dist/core/capability-data.js +271 -0
  14. package/dist/core/capability-table.js +16 -3504
  15. package/dist/core/format/completion.js +68 -0
  16. package/dist/core/format/help.js +50 -8
  17. package/dist/core/format/safe-json.js +73 -0
  18. package/dist/core/format/state-explosion-text.js +1 -1
  19. package/dist/core/multi-agent/candidate-scoring.js +5 -1
  20. package/dist/core/multi-agent/collaboration.js +6 -5
  21. package/dist/core/multi-agent/coordinator.js +9 -8
  22. package/dist/core/multi-agent/runtime.js +4 -4
  23. package/dist/core/multi-agent/topology.js +3 -3
  24. package/dist/core/pipeline/commit-gate.js +2 -1
  25. package/dist/core/pipeline/dispatch.js +18 -4
  26. package/dist/core/pipeline/drive-decide.js +2 -1
  27. package/dist/core/state/migrations.js +16 -1
  28. package/dist/core/state/state-explosion/digest.js +16 -15
  29. package/dist/core/state/state-explosion/graph.js +26 -19
  30. package/dist/core/state/state-explosion/helpers.js +2 -1
  31. package/dist/core/state/state-explosion/report.js +6 -6
  32. package/dist/core/trust/ledger.js +2 -1
  33. package/dist/core/types/execution-backend.js +18 -0
  34. package/dist/core/types/observability.js +7 -0
  35. package/dist/core/util/collate.js +23 -0
  36. package/dist/core/util/numeric-flag.js +40 -0
  37. package/dist/core/version.js +1 -1
  38. package/dist/mcp/server.js +99 -11
  39. package/dist/shell/audit-cli.js +99 -23
  40. package/dist/shell/commit-summary.js +2 -1
  41. package/dist/shell/coordinator-io.js +73 -13
  42. package/dist/shell/dispatch.js +1 -1
  43. package/dist/shell/doctor.js +80 -1
  44. package/dist/shell/drive.js +269 -59
  45. package/dist/shell/eval-text.js +2 -2
  46. package/dist/shell/evidence-reasoning.js +8 -7
  47. package/dist/shell/execution-backend/agent.js +20 -1
  48. package/dist/shell/execution-backend/container.js +4 -1
  49. package/dist/shell/execution-backend/local.js +4 -1
  50. package/dist/shell/execution-backend/registry.js +2 -1
  51. package/dist/shell/execution-backend/types.js +0 -9
  52. package/dist/shell/feedback-cli.js +6 -6
  53. package/dist/shell/fs-atomic.js +232 -30
  54. package/dist/shell/man-cli.js +6 -0
  55. package/dist/shell/metrics-cli.js +2 -1
  56. package/dist/shell/multi-agent-cli.js +367 -323
  57. package/dist/shell/multi-agent-host.js +9 -9
  58. package/dist/shell/multi-agent-operator-ux.js +82 -39
  59. package/dist/shell/node-store.js +10 -4
  60. package/dist/shell/observability.js +13 -12
  61. package/dist/shell/onramp.js +17 -1
  62. package/dist/shell/operator-ux-text.js +24 -23
  63. package/dist/shell/operator-ux.js +22 -20
  64. package/dist/shell/orchestrator.js +49 -38
  65. package/dist/shell/pipeline-cli.js +168 -111
  66. package/dist/shell/reclamation-io.js +16 -5
  67. package/dist/shell/registry-cli.js +19 -17
  68. package/dist/shell/remote-source.js +13 -8
  69. package/dist/shell/report-cli.js +45 -0
  70. package/dist/shell/report.js +4 -2
  71. package/dist/shell/run-registry-io.js +77 -19
  72. package/dist/shell/run-store.js +91 -2
  73. package/dist/shell/scheduling-io.js +12 -13
  74. package/dist/shell/state-cli.js +2 -7
  75. package/dist/shell/state-explosion-cli.js +19 -10
  76. package/dist/shell/topology-io.js +38 -6
  77. package/dist/shell/trust-audit.js +264 -23
  78. package/dist/shell/trust-policy-io.js +1 -1
  79. package/dist/shell/worker-cli.js +41 -29
  80. package/dist/shell/worker-isolation.js +34 -9
  81. package/dist/shell/workflow-app-loader.js +3 -2
  82. package/dist/wiring/capability-table/basics.js +91 -0
  83. package/dist/wiring/capability-table/exec-backend.js +171 -0
  84. package/dist/wiring/capability-table/index.js +42 -0
  85. package/dist/wiring/capability-table/multi-agent.js +630 -0
  86. package/dist/wiring/capability-table/parity.js +467 -0
  87. package/dist/wiring/capability-table/pipeline.js +292 -0
  88. package/dist/wiring/capability-table/registry-core.js +208 -0
  89. package/dist/wiring/capability-table/reporting.js +435 -0
  90. package/dist/wiring/capability-table/scheduling-registry.js +592 -0
  91. package/dist/wiring/capability-table/state.js +181 -0
  92. package/dist/wiring/capability-table/trust-ledger.js +158 -0
  93. package/dist/wiring/capability-table/workflow-apps.js +381 -0
  94. package/docs/agent-delegation-drive.7.md +4 -0
  95. package/docs/candidate-scoring.7.md +1 -1
  96. package/docs/canonical-workflow-apps.7.md +7 -7
  97. package/docs/cli-mcp-parity.7.md +13 -3
  98. package/docs/contract-migration-tooling.7.md +4 -0
  99. package/docs/control-plane-scheduling.7.md +4 -0
  100. package/docs/coordinator-blackboard.7.md +17 -17
  101. package/docs/dogfood-one-real-repo.7.md +11 -11
  102. package/docs/durable-state-and-locking.7.md +24 -0
  103. package/docs/end-to-end-golden-path.7.md +14 -14
  104. package/docs/evidence-adoption-reasoning-chain.7.md +4 -0
  105. package/docs/execution-backends.7.md +4 -0
  106. package/docs/getting-started.md +37 -37
  107. package/docs/index.md +1 -0
  108. package/docs/multi-agent-cli-mcp-surface.7.md +21 -17
  109. package/docs/multi-agent-eval-replay-harness.7.md +17 -13
  110. package/docs/multi-agent-operator-ux.7.md +23 -19
  111. package/docs/multi-agent-runtime-core.7.md +22 -22
  112. package/docs/multi-agent-topologies.7.md +6 -6
  113. package/docs/multi-agent-trust-policy-audit.7.md +11 -11
  114. package/docs/node-snapshot-diff-replay.7.md +4 -0
  115. package/docs/observability-cost-accounting.7.md +4 -0
  116. package/docs/operator-ux.7.md +34 -34
  117. package/docs/pipeline-runner.7.md +4 -4
  118. package/docs/project-index.md +48 -5
  119. package/docs/real-execution-backends.7.md +4 -0
  120. package/docs/release-and-migration.7.md +5 -1
  121. package/docs/release-tooling.7.md +4 -0
  122. package/docs/routines.md +4 -4
  123. package/docs/run-registry-control-plane.7.md +23 -19
  124. package/docs/run-retention-reclamation.7.md +4 -0
  125. package/docs/scheduled-tasks.md +14 -14
  126. package/docs/security-trust-hardening.7.md +43 -13
  127. package/docs/state-explosion-management.7.md +14 -10
  128. package/docs/team-collaboration.7.md +4 -0
  129. package/docs/trust-audit-anchor.7.md +71 -0
  130. package/docs/unix-principles.md +3 -1
  131. package/docs/verifier-gated-commit.7.md +1 -1
  132. package/docs/web-desktop-workbench.7.md +4 -0
  133. package/docs/workflow-app-framework.7.md +13 -13
  134. package/manifest/plugin.manifest.json +1 -1
  135. package/package.json +4 -2
  136. package/scripts/agents/claude-p-agent.js +2 -2
  137. package/scripts/block-unapproved-tag.sh +23 -2
  138. package/scripts/bump-version.js +24 -2
  139. package/scripts/canonical-apps.js +4 -4
  140. package/scripts/children/batch-delegate-child.js +52 -2
  141. package/scripts/dogfood-release.js +1 -1
  142. package/scripts/fake-date-for-reproduction.js +44 -0
  143. package/scripts/golden-path.js +4 -4
  144. package/scripts/purity-baseline.json +71 -0
  145. package/scripts/purity-gate.js +239 -0
  146. package/scripts/release-check.js +8 -1
  147. package/scripts/release-flow.js +57 -1
  148. package/scripts/verdict-keygen.js +83 -0
  149. package/scripts/verify-bump-reproduction.sh +148 -0
  150. package/scripts/verify-verdict-signature.js +61 -0
  151. package/scripts/version-sync-check.js +33 -12
  152. package/skills/cool-workflow/SKILL.md +9 -9
  153. package/skills/cool-workflow/references/commands.md +89 -88
  154. package/ui/workbench/app.css +37 -1
  155. package/ui/workbench/app.js +124 -6
  156. package/workflows/README.md +19 -0
@@ -65,6 +65,7 @@ const node_store_1 = require("./node-store");
65
65
  const contract_migration_1 = require("../core/state/contract-migration");
66
66
  const node_snapshot_1 = require("../core/state/node-snapshot");
67
67
  const dispatch_1 = require("../core/pipeline/dispatch");
68
+ const numeric_flag_1 = require("../core/util/numeric-flag");
68
69
  function optionalStringArg(value) {
69
70
  return typeof value === "string" && value.trim() ? value.trim() : undefined;
70
71
  }
@@ -128,12 +129,6 @@ function loadRun(runId, options = {}) {
128
129
  const cwd = options.cwd ? path.resolve(String(options.cwd)) : process.cwd();
129
130
  return (0, run_store_1.loadRunFromCwd)(runId, cwd);
130
131
  }
131
- function numberOption(value) {
132
- if (value === undefined || value === null || value === true)
133
- return undefined;
134
- const n = Number(value);
135
- return Number.isFinite(n) ? n : undefined;
136
- }
137
132
  /** `cw next <run-id> [--limit N]` — the read-only "what would dispatch next"
138
133
  * preview: the runnable tasks the next `cw dispatch` would pick, in order,
139
134
  * without mutating state. A byte-behavior port of the old build's
@@ -141,7 +136,7 @@ function numberOption(value) {
141
136
  * the cw_next MCP tool render this same DispatchTask[] value, so their
142
137
  * payloads stay identical. */
143
138
  function nextCli(runId, options = {}) {
144
- return (0, dispatch_1.nextDispatchTasks)(loadRun(runId, options), numberOption(options.limit));
139
+ return (0, dispatch_1.nextDispatchTasks)(loadRun(runId, options), (0, numeric_flag_1.requiredNumberFlag)(options.limit, "--limit"));
145
140
  }
146
141
  function listNodes(runId, options = {}) {
147
142
  return loadRun(runId, options).nodes || [];
@@ -66,6 +66,7 @@ const report_1 = require("../core/state/state-explosion/report");
66
66
  Object.defineProperty(exports, "buildStateExplosionReport", { enumerable: true, get: function () { return report_1.buildStateExplosionReport; } });
67
67
  const size_2 = require("../core/state/state-explosion/size");
68
68
  const multi_agent_operator_ux_1 = require("./multi-agent-operator-ux");
69
+ const collate_1 = require("../core/util/collate");
69
70
  function summariesDir(run) {
70
71
  return path.join(run.paths.runDir, "summaries");
71
72
  }
@@ -133,8 +134,8 @@ function refreshStateExplosionSummaries(run, options = {}) {
133
134
  generatedAt: now,
134
135
  status: "valid",
135
136
  deterministic: true,
136
- nextAction: `node scripts/cw.js summary show ${run.id}`,
137
- entries: entries.sort((a, b) => a.id.localeCompare(b.id)),
137
+ nextAction: `cw summary show ${run.id}`,
138
+ entries: entries.sort((a, b) => (0, collate_1.stableCompare)(a.id, b.id)),
138
139
  views,
139
140
  paths: { summariesDir: dir, indexPath: path.join(dir, "index.json"), reportPath },
140
141
  };
@@ -183,15 +184,23 @@ function loadRun(runId, options = {}) {
183
184
  * only (the run-state checkpoint write is real and load-bearing today;
184
185
  * report.md rendering is not). */
185
186
  function summaryRefreshCli(runId, options = {}) {
186
- const run = loadRun(runId, options);
187
- const index = refreshStateExplosionSummaries(run);
188
- (0, run_store_1.saveCheckpoint)(run);
189
- return index;
187
+ // Hold the state.json lock across the whole load -> refresh -> save so a
188
+ // concurrent run mutation cannot drop this summary refresh (lost-update class).
189
+ const cwd = options.cwd ? path.resolve(String(options.cwd)) : process.cwd();
190
+ return (0, run_store_1.withRunStateLock)(runId, cwd, (run) => {
191
+ const index = refreshStateExplosionSummaries(run);
192
+ (0, run_store_1.saveCheckpoint)(run);
193
+ return index;
194
+ });
190
195
  }
191
- /** `cw summary show <run-id> [--json]` — also runs `saveCheckpoint`. */
196
+ /** `cw summary show <run-id> [--json]` — a plain read: `showStateExplosionSummary`
197
+ * only loads the persisted summary index and builds a report from it, it
198
+ * never mutates `run`, so there is nothing here for a checkpoint write to
199
+ * durably persist. No `saveCheckpoint` call (dropped one, see ITERATION_LOG
200
+ * — it used to durably rewrite the whole unchanged state.json on every
201
+ * `summary show`, only bumping `run.updatedAt` as a side effect of a
202
+ * command whose whole job is to look, not touch). */
192
203
  function summaryShowCli(runId, options = {}) {
193
204
  const run = loadRun(runId, options);
194
- const report = showStateExplosionSummary(run);
195
- (0, run_store_1.saveCheckpoint)(run);
196
- return report;
205
+ return showStateExplosionSummary(run);
197
206
  }
@@ -63,6 +63,7 @@ const hash_2 = require("../core/hash");
63
63
  const topo = __importStar(require("../core/multi-agent/topology"));
64
64
  const multi_agent_io_1 = require("./multi-agent-io");
65
65
  const coordinator_io_1 = require("./coordinator-io");
66
+ const collate_1 = require("../core/util/collate");
66
67
  function topologyRoot(run) {
67
68
  return run.paths.topologiesDir || path.join(run.paths.runDir, "topologies");
68
69
  }
@@ -70,6 +71,23 @@ function topologyRunPath(run, id) {
70
71
  const safe = id.replace(/[^a-zA-Z0-9_.:-]+/g, "_");
71
72
  return path.join(topologyRoot(run), "runs", `${safe}.json`);
72
73
  }
74
+ // Dirty-id tracking for persistTopologyState, mirroring coordinator-io.ts's
75
+ // blackboard dirty tracking: without it, every persist call rewrote every
76
+ // topology run's OWN file (topologyRunPath), even ones untouched this call —
77
+ // O(N) writes per call, O(N^2) across N applyTopology calls. Kept off the
78
+ // serialized TopologyState itself (a WeakMap keyed by the state object) so it
79
+ // can never leak into state.json's bytes. applyTopology is the only place
80
+ // state.runs is pushed to or an existing record mutated, so it is the only
81
+ // call site that needs to mark a run id dirty.
82
+ const topologyDirtyIds = new WeakMap();
83
+ function dirtyTopologyIds(state) {
84
+ let ids = topologyDirtyIds.get(state);
85
+ if (!ids) {
86
+ ids = new Set();
87
+ topologyDirtyIds.set(state, ids);
88
+ }
89
+ return ids;
90
+ }
73
91
  function ensureTopologyState(run) {
74
92
  run.paths.topologiesDir = topologyRoot(run);
75
93
  fs.mkdirSync(run.paths.topologiesDir, { recursive: true });
@@ -89,8 +107,13 @@ function persistTopologyState(run) {
89
107
  counts: { runs: state.runs.length },
90
108
  runs: state.runs.map((record) => ({ id: record.id, topologyId: record.topologyId, status: record.status, updatedAt: record.updatedAt })),
91
109
  });
92
- for (const record of state.runs)
93
- (0, fs_atomic_1.writeJson)(topologyRunPath(run, record.id), record);
110
+ const dirty = dirtyTopologyIds(state);
111
+ for (const id of dirty) {
112
+ const record = state.runs.find((entry) => entry.id === id);
113
+ if (record)
114
+ (0, fs_atomic_1.writeJson)(topologyRunPath(run, id), record);
115
+ }
116
+ dirty.clear();
94
117
  }
95
118
  function now() {
96
119
  return new Date().toISOString();
@@ -237,6 +260,7 @@ function applyTopology(run, topologyId, input = {}) {
237
260
  metadata: compact({ ...(input.metadata || {}), topology: definition }),
238
261
  };
239
262
  state.runs.push(record);
263
+ dirtyTopologyIds(state).add(record.id);
240
264
  appendTopologyNode(run, record, topo.statusToNodeStatus(record.status));
241
265
  (0, trust_audit_1.recordTrustAuditEvent)(run, {
242
266
  kind: "topology.verdict",
@@ -271,7 +295,15 @@ function summarizeTopologies(run) {
271
295
  const multi = run.multiAgent || {};
272
296
  const fanins = multi.fanins || [];
273
297
  const active = state.runs.map((record) => {
274
- const inferredFanins = fanins.filter((fanin) => record.groupIds.includes(fanin.groupId) || record.fanoutIds.includes(fanin.fanoutId || ""));
298
+ // `fanins` (multi.fanins) grows with total fan-in activity across the
299
+ // whole run, re-scanned here once per topology record -- Sets built
300
+ // from each record's own small groupIds/fanoutIds replace an
301
+ // O(fanins) `.includes()` scan per fanin with an O(1) lookup (the
302
+ // same array-scan-per-item shape 024b007 fixed for phase/task
303
+ // selection).
304
+ const groupIdSet = new Set(record.groupIds);
305
+ const fanoutIdSet = new Set(record.fanoutIds);
306
+ const inferredFanins = fanins.filter((fanin) => groupIdSet.has(fanin.groupId) || fanoutIdSet.has(fanin.fanoutId || ""));
275
307
  const allFaninIds = topo.unique([...record.faninIds, ...inferredFanins.map((fanin) => fanin.id)]);
276
308
  const blocked = inferredFanins.filter((fanin) => fanin.status === "blocked" || !fanin.verifierReady);
277
309
  const ready = inferredFanins.some((fanin) => fanin.verifierReady);
@@ -290,7 +322,7 @@ function summarizeTopologies(run) {
290
322
  missingEvidence,
291
323
  conflicts: record.conflicts,
292
324
  readiness: ready ? "fanin ready" : missingEvidence.length ? "missing evidence" : "awaiting worker output",
293
- nextActions: ready ? [`node scripts/cw.js candidate register ${run.id} --result-node <reducer-or-panel-result>`] : record.nextActions,
325
+ nextActions: ready ? [`cw candidate register ${run.id} --result-node <reducer-or-panel-result>`] : record.nextActions,
294
326
  };
295
327
  });
296
328
  return {
@@ -299,7 +331,7 @@ function summarizeTopologies(run) {
299
331
  runsByStatus: countBy(active, (record) => record.status),
300
332
  officialTopologies: topo.listTopologyDefinitions().map((definition) => definition.id),
301
333
  active,
302
- nextAction: active.find((record) => record.nextActions.length)?.nextActions[0] || `node scripts/cw.js topology apply ${run.id} map-reduce --task <task-id>`,
334
+ nextAction: active.find((record) => record.nextActions.length)?.nextActions[0] || `cw topology apply ${run.id} map-reduce --task <task-id>`,
303
335
  };
304
336
  }
305
337
  function buildTopologyGraph(run) {
@@ -307,7 +339,7 @@ function buildTopologyGraph(run) {
307
339
  return topo.buildTopologyGraphFromRuns(run.id, state.runs, (id) => topologyRunPath(run, id));
308
340
  }
309
341
  function formatTopologyCounts(counts) {
310
- const entries = Object.entries(counts).sort(([a], [b]) => a.localeCompare(b));
342
+ const entries = Object.entries(counts).sort(([a], [b]) => (0, collate_1.stableCompare)(a, b));
311
343
  if (!entries.length)
312
344
  return "none";
313
345
  return entries.map(([k, v]) => `${k}=${v}`).join(", ");
@@ -58,7 +58,9 @@ exports.TRUST_AUDIT_SCHEMA_VERSION = void 0;
58
58
  exports.ensureTrustAudit = ensureTrustAudit;
59
59
  exports.trustAuditGenesis = trustAuditGenesis;
60
60
  exports.listTrustAuditEvents = listTrustAuditEvents;
61
+ exports.trustAuditHead = trustAuditHead;
61
62
  exports.verifyTrustAudit = verifyTrustAudit;
63
+ exports.repairTrustAuditTornTail = repairTrustAuditTornTail;
62
64
  exports.recordTrustAuditEvent = recordTrustAuditEvent;
63
65
  exports.recordSandboxPathDecision = recordSandboxPathDecision;
64
66
  exports.normalizeEvidence = normalizeEvidence;
@@ -105,6 +107,43 @@ function computeEventHash(event) {
105
107
  void eventHash;
106
108
  return (0, hash_1.sha256)((0, hash_1.eventHashInput)(rest));
107
109
  }
110
+ function tailCachePathFor(eventLogPath) {
111
+ return path.join(path.dirname(eventLogPath), "tail-cache.json");
112
+ }
113
+ function readAuditTailCache(tailCachePath) {
114
+ if (!fs.existsSync(tailCachePath))
115
+ return undefined;
116
+ try {
117
+ const parsed = JSON.parse(fs.readFileSync(tailCachePath, "utf8"));
118
+ if (parsed && parsed.schemaVersion === 1 && typeof parsed.logBytes === "number" && typeof parsed.count === "number" && typeof parsed.lastHash === "string") {
119
+ return parsed;
120
+ }
121
+ }
122
+ catch {
123
+ // Corrupt/unreadable cache -- fall back to the full parse below.
124
+ }
125
+ return undefined;
126
+ }
127
+ function writeAuditTailCache(tailCachePath, cache) {
128
+ try {
129
+ (0, fs_atomic_1.writeJson)(tailCachePath, cache);
130
+ }
131
+ catch {
132
+ // Best-effort: a failed cache write must never break the real append.
133
+ }
134
+ }
135
+ /** Deletes the tail cache so the NEXT append always re-derives ground
136
+ * truth from the real log, rather than trusting a byte-size coincidence.
137
+ * Called whenever something OTHER than a plain append changes the log's
138
+ * content (currently: repair). */
139
+ function invalidateAuditTailCache(eventLogPath) {
140
+ try {
141
+ fs.unlinkSync(tailCachePathFor(eventLogPath));
142
+ }
143
+ catch {
144
+ // Nothing to invalidate (no cache existed yet) -- fine.
145
+ }
146
+ }
108
147
  /** Read events in FILE (append) order, tolerating corrupt lines — one
109
148
  * bad line must not brick the whole audit read surface (it is counted,
110
149
  * not thrown). The chain links append order, so this is the order
@@ -136,6 +175,21 @@ function readEventsRaw(eventLogPath) {
136
175
  function listTrustAuditEvents(run) {
137
176
  return readEventsRaw(ensureTrustAudit(run).eventLogPath);
138
177
  }
178
+ /** The current head of a run's trust-audit chain: the hash the NEXT
179
+ * appended event will link from (genesis when the log is empty), plus
180
+ * the event count. Read-only projection over existing data. Capture it
181
+ * (e.g. right after a run, or at export time) and later hand it to
182
+ * `verifyTrustAudit`'s anchor / `cw audit verify --expect-head` to
183
+ * re-prove the log was not shortened since the capture. */
184
+ function trustAuditHead(run) {
185
+ const audit = ensureTrustAudit(run);
186
+ const events = readEventsRaw(audit.eventLogPath);
187
+ let head = trustAuditGenesis(run.id);
188
+ for (const event of events) {
189
+ head = event.eventHash !== undefined ? event.eventHash : computeEventHash(event);
190
+ }
191
+ return { eventCount: events.length, headHash: head };
192
+ }
139
193
  /** Re-prove the run's trust-audit chain: prevEventHash linkage (append
140
194
  * order) + per-event hash recompute. A corrupt line, an edited event,
141
195
  * or a removed event flips verified=false. Legacy events without a
@@ -147,23 +201,32 @@ function listTrustAuditEvents(run) {
147
201
  * era) or all-legacy (pre-chain). An unchained (eventHash-less) line
148
202
  * mixed into an otherwise-chained log is a forgery attempt — dropping
149
203
  * the hash to be waved through as "legacy" — so it fails with
150
- * `trust-audit-unchained-event`, never silently accepted. */
151
- function verifyTrustAudit(run) {
152
- const audit = ensureTrustAudit(run);
153
- const { events, corruptLines } = readEventsRawCounted(audit.eventLogPath);
204
+ * `trust-audit-unchained-event`, never silently accepted.
205
+ *
206
+ * ANCHOR (optional): the walk alone cannot see tail truncation — see
207
+ * TrustAuditAnchor. With an anchor, the head-hash trail (genesis plus
208
+ * the hash after each event) must contain `expectHead`, and the log
209
+ * must reach `expectCount` events; a shortfall fails closed with
210
+ * `trust-audit-truncated`. Without an anchor, behavior is unchanged. */
211
+ /** Chain-walk core shared by `verifyTrustAudit` (reads from disk) and
212
+ * `repairTrustAuditTornTail` (re-checks an in-memory candidate result
213
+ * BEFORE ever writing it to disk). Pure — no fs. */
214
+ function verifyEventsChain(runId, events, corruptLines, anchor) {
154
215
  const checks = [];
155
216
  let verified = corruptLines === 0;
156
217
  if (corruptLines > 0)
157
218
  checks.push({ name: "parse", pass: false, code: "trust-audit-corrupt-line" });
158
219
  let chained = 0;
159
220
  let unchained = 0;
160
- let expectedPrev = trustAuditGenesis(run.id);
221
+ let expectedPrev = trustAuditGenesis(runId);
222
+ const headTrail = new Set([expectedPrev]);
161
223
  for (let i = 0; i < events.length; i++) {
162
224
  const event = events[i];
163
225
  const recomputed = computeEventHash(event);
164
226
  if (event.eventHash === undefined) {
165
227
  unchained += 1;
166
228
  expectedPrev = recomputed; // advance the chain over legacy events
229
+ headTrail.add(expectedPrev);
167
230
  continue;
168
231
  }
169
232
  chained += 1;
@@ -171,19 +234,126 @@ function verifyTrustAudit(run) {
171
234
  verified = false;
172
235
  checks.push({ name: `event-hash[${i}]`, pass: false, code: "trust-audit-digest-mismatch" });
173
236
  }
174
- if (event.prevEventHash !== undefined && event.prevEventHash !== expectedPrev) {
237
+ // No `undefined` skip here: the writer ALWAYS sets prevEventHash (the
238
+ // first event gets the genesis hash), so a chained event without it is
239
+ // a forgery — dropping the field and re-making eventHash must NOT let a
240
+ // cut or re-ordered chain verify green. Fail closed on the mismatch.
241
+ if (event.prevEventHash !== expectedPrev) {
175
242
  verified = false;
176
243
  checks.push({ name: `chain-link[${i}]`, pass: false, code: "trust-audit-chain-broken" });
177
244
  }
178
245
  expectedPrev = event.eventHash;
246
+ headTrail.add(expectedPrev);
179
247
  }
180
248
  // Era rule: a log with ANY chained event must have EVERY event chained.
181
249
  if (chained > 0 && unchained > 0) {
182
250
  verified = false;
183
251
  checks.push({ name: "unchained-events", pass: false, code: "trust-audit-unchained-event" });
184
252
  }
253
+ // Anchor rule: the captured head must still be ON the chain, and the log
254
+ // must be at least as long as it was at capture time. A truncated-then-
255
+ // appended log fails the head check (new events link from an earlier
256
+ // point, so the old head is no longer in the trail).
257
+ if (anchor) {
258
+ if (anchor.expectCount !== undefined && events.length < anchor.expectCount) {
259
+ verified = false;
260
+ checks.push({ name: "anchor-count", pass: false, code: "trust-audit-truncated" });
261
+ }
262
+ if (anchor.expectHead !== undefined && !headTrail.has(anchor.expectHead)) {
263
+ verified = false;
264
+ checks.push({ name: "anchor-head", pass: false, code: "trust-audit-truncated" });
265
+ }
266
+ }
185
267
  return { present: events.length > 0, verified, eventCount: events.length, chained, unchained, corruptLines, checks };
186
268
  }
269
+ function verifyTrustAudit(run, anchor) {
270
+ const audit = ensureTrustAudit(run);
271
+ const { events, corruptLines } = readEventsRawCounted(audit.eventLogPath);
272
+ return verifyEventsChain(run.id, events, corruptLines, anchor);
273
+ }
274
+ /** Repairs a torn TRAILING write in the audit event log — the ONE
275
+ * corruption shape a crash mid-append can produce (`durableAppendFileSync`
276
+ * only ever adds bytes at the current end of file, so an interruption can
277
+ * only ever leave the LAST append incomplete; it can never touch earlier,
278
+ * already-flushed lines). Every line is always actually parsed — the
279
+ * file's trailing-newline byte says only whether the LAST WRITE
280
+ * completed, nothing about whether any line parses: if a run is RESUMED
281
+ * after a torn write (another event appended right after the garbled
282
+ * remnant, with no separating newline of its own), the two merge into one
283
+ * unparseable line and the file ends in a newline again, even though
284
+ * content is still corrupt. Refuses (`outcome: "refused"`) rather than
285
+ * touching anything when:
286
+ * - more than one line is unparseable, or the sole bad line is NOT the
287
+ * last one (not a shape a crash can produce — treated as possible
288
+ * tampering, not auto-repaired);
289
+ * - removing the bad trailing line still leaves an unverifiable chain,
290
+ * OR (when `anchor` is given) the repaired chain doesn't reach
291
+ * `anchor.expectCount`/contain `anchor.expectHead` — an anchor
292
+ * captured before the corruption is the ONLY way to catch an attacker
293
+ * deleting real historical events and leaving a torn-looking fragment
294
+ * behind (an empty/short chain otherwise "verifies" trivially — the
295
+ * same documented blind spot `verifyTrustAudit` itself has without an
296
+ * anchor; this function must never launder that shape into a
297
+ * confidently-"repaired" empty log).
298
+ * `write: false` (default) reports what WOULD happen without touching
299
+ * disk, matching this codebase's `cw state check [--write]` convention.
300
+ * Held under the SAME `withFileLock` as `recordTrustAuditEvent` (below),
301
+ * so a repair can never interleave with a live append. */
302
+ function repairTrustAuditTornTail(run, options = {}) {
303
+ const audit = ensureTrustAudit(run);
304
+ return (0, fs_atomic_1.withFileLock)(audit.eventLogPath, () => {
305
+ const raw = fs.readFileSync(audit.eventLogPath, "utf8");
306
+ const lines = raw.split("\n").filter((line) => line.trim() !== "");
307
+ const badIndexes = [];
308
+ const events = [];
309
+ lines.forEach((line, i) => {
310
+ try {
311
+ events.push(JSON.parse(line));
312
+ }
313
+ catch {
314
+ badIndexes.push(i);
315
+ }
316
+ });
317
+ if (badIndexes.length === 0) {
318
+ return { outcome: "clean", reason: "every line parses — no torn trailing write to repair" };
319
+ }
320
+ if (badIndexes.length > 1 || badIndexes[0] !== lines.length - 1) {
321
+ return {
322
+ outcome: "refused",
323
+ reason: "corruption is not confined to exactly the trailing line — this is not a shape a crash mid-append can produce and will not be auto-repaired (looks like tampering)",
324
+ };
325
+ }
326
+ // `events` already holds every line EXCEPT the one bad trailing line
327
+ // (JSON.parse threw for it, so nothing was pushed) — exactly the "good"
328
+ // set, in file order.
329
+ const recheck = verifyEventsChain(run.id, events, 0, options.anchor);
330
+ if (!recheck.verified) {
331
+ return {
332
+ outcome: "refused",
333
+ reason: options.anchor
334
+ ? "removing the torn trailing write still doesn't reach the given --expect-head/--expect-count anchor — refusing to repair (this looks like deleted history, not a crash)"
335
+ : "removing the torn trailing write still leaves an unverifiable chain — refusing to repair (this looks like tampering, not a crash)",
336
+ };
337
+ }
338
+ const removedBytes = Buffer.byteLength(lines[lines.length - 1], "utf8");
339
+ const repairedContent = events.length > 0 ? `${lines.slice(0, -1).join("\n")}\n` : "";
340
+ if (options.write) {
341
+ (0, fs_atomic_1.writeTextDurable)(audit.eventLogPath, repairedContent, { durable: true });
342
+ // The log's bytes just changed out from under the append tail cache
343
+ // (perf cycle P1-2) -- invalidate rather than rely on the size check
344
+ // alone catching every case.
345
+ invalidateAuditTailCache(audit.eventLogPath);
346
+ }
347
+ return {
348
+ outcome: "repaired",
349
+ reason: options.write
350
+ ? `removed a torn trailing write (${removedBytes} bytes) and restored a verified chain of ${events.length} event(s)`
351
+ : `would remove a torn trailing write (${removedBytes} bytes) and restore a verified chain of ${events.length} event(s) — pass --write to apply`,
352
+ removedLines: 1,
353
+ removedBytes,
354
+ };
355
+ });
356
+ }
187
357
  function unique(values) {
188
358
  return Array.from(new Set(values.filter(Boolean)));
189
359
  }
@@ -210,9 +380,14 @@ function scrubMetadata(value) {
210
380
  function compact(value) {
211
381
  return Object.fromEntries(Object.entries(value).filter(([, entry]) => entry !== undefined));
212
382
  }
213
- function createEventId(run, kind) {
214
- const count = readEventsRaw(trustAuditPaths(run).eventLogPath).length + 1;
215
- return `audit-${(0, fs_atomic_1.safeFileName)(kind)}-${String(count).padStart(4, "0")}`;
383
+ /** `count` must be the number of PRIOR events, read under the SAME lock
384
+ * the append itself happens under (see `recordTrustAuditEvent`) an
385
+ * earlier version read this count separately, outside any lock, so two
386
+ * concurrent writers could mint the SAME id for two different events
387
+ * (the hash chain still forked-proof, but the id — referenced elsewhere
388
+ * as `auditEventIds`/`parentEventIds` for provenance — was not unique). */
389
+ function createEventId(kind, count) {
390
+ return `audit-${(0, fs_atomic_1.safeFileName)(kind)}-${String(count + 1).padStart(4, "0")}`;
216
391
  }
217
392
  /** Correlation-id keys copied verbatim (and no others) — byte-exact list/
218
393
  * order to the old build's CORRELATION_ID_FIELDS. */
@@ -255,11 +430,20 @@ function indexCorrelationIds(event) {
255
430
  }
256
431
  return picked;
257
432
  }
433
+ /** Read-modify-append: computes `prevEventHash` from the CURRENT last event
434
+ * and appends. Held under `withFileLock` (like every other read-modify-
435
+ * write in this codebase) so two processes recording events for the same
436
+ * run at once can never both read the same tail and compute the same
437
+ * `prevEventHash` — that would fork the hash chain, and a forked chain
438
+ * fails `verifyTrustAudit` for good, with no repair for THAT shape (unlike
439
+ * a torn trailing write, a fork is not confined to the last line). */
258
440
  function recordTrustAuditEvent(run, input) {
259
441
  const audit = ensureTrustAudit(run);
442
+ // `id` is NOT set here — it depends on the prior event count, which (like
443
+ // prevEventHash) must be read under the lock below, or two concurrent
444
+ // writers could mint the same id for two different events.
260
445
  const event = compact({
261
446
  schemaVersion: exports.TRUST_AUDIT_SCHEMA_VERSION,
262
- id: createEventId(run, input.kind),
263
447
  createdAt: new Date().toISOString(),
264
448
  runId: run.id,
265
449
  kind: input.kind,
@@ -283,11 +467,35 @@ function recordTrustAuditEvent(run, input) {
283
467
  parentEventIds: unique(input.parentEventIds || []).sort(),
284
468
  metadata: scrubMetadata(input.metadata || {}),
285
469
  });
286
- const prior = readEventsRaw(audit.eventLogPath);
287
- event.prevEventHash = prior.length ? prior[prior.length - 1].eventHash || computeEventHash(prior[prior.length - 1]) : trustAuditGenesis(run.id);
288
- event.eventHash = computeEventHash(event);
289
- (0, fs_atomic_1.durableAppendFileSync)(audit.eventLogPath, `${JSON.stringify(event)}\n`);
290
- return event;
470
+ return (0, fs_atomic_1.withFileLock)(audit.eventLogPath, () => {
471
+ // The prior event count and last-event hash are the ONLY two things
472
+ // this append needs from the existing log. A tail cache (keyed on the
473
+ // log's own byte size) serves both without a full parse when nothing
474
+ // else has touched the log since it was written; any size mismatch
475
+ // (a repair, a torn write, this being the very first append) falls
476
+ // back to the full parse, same as before this cache existed.
477
+ const tailCachePath = tailCachePathFor(audit.eventLogPath);
478
+ const currentBytes = fs.existsSync(audit.eventLogPath) ? fs.statSync(audit.eventLogPath).size : 0;
479
+ const cached = readAuditTailCache(tailCachePath);
480
+ let count;
481
+ let prevHash;
482
+ if (cached && cached.logBytes === currentBytes) {
483
+ count = cached.count;
484
+ prevHash = cached.lastHash;
485
+ }
486
+ else {
487
+ const prior = readEventsRaw(audit.eventLogPath);
488
+ count = prior.length;
489
+ prevHash = prior.length ? prior[prior.length - 1].eventHash || computeEventHash(prior[prior.length - 1]) : trustAuditGenesis(run.id);
490
+ }
491
+ event.id = createEventId(input.kind, count);
492
+ event.prevEventHash = prevHash;
493
+ event.eventHash = computeEventHash(event);
494
+ const line = `${JSON.stringify(event)}\n`;
495
+ (0, fs_atomic_1.durableAppendFileSync)(audit.eventLogPath, line);
496
+ writeAuditTailCache(tailCachePath, { schemaVersion: 1, logBytes: currentBytes + Buffer.byteLength(line, "utf8"), count: count + 1, lastHash: event.eventHash });
497
+ return event;
498
+ });
291
499
  }
292
500
  function recordSandboxPathDecision(run, input) {
293
501
  return recordTrustAuditEvent(run, {
@@ -348,11 +556,32 @@ function countBy(values, key) {
348
556
  * here — the old build's extra workers/candidates/commits/multiAgent/
349
557
  * blackboard rollups are milestone 9's own summarizeMultiAgent/
350
558
  * candidate-scoring-io/coordinator-io surfaces, not duplicated here. */
559
+ /** Groups events by a key field once, so each row below is an O(1) Map
560
+ * lookup instead of an O(events) `.filter()` re-scan per id -- found
561
+ * alongside the id.find() version of this same shape while pinning perf
562
+ * cycle P1-1's review-fix regression test (O(ids x events) otherwise). */
563
+ function groupEventsBy(events, key) {
564
+ const groups = new Map();
565
+ for (const event of events) {
566
+ const k = key(event);
567
+ if (!k)
568
+ continue;
569
+ const list = groups.get(k);
570
+ if (list)
571
+ list.push(event);
572
+ else
573
+ groups.set(k, [event]);
574
+ }
575
+ return groups;
576
+ }
351
577
  function workerRows(events, run) {
352
- const workerIds = unique([...(run.workers || []).map((w) => w.id), ...events.map((e) => e.workerId || "")]).sort();
578
+ const workers = run.workers || [];
579
+ const workersById = new Map(workers.map((w) => [w.id, w]));
580
+ const eventsByWorkerId = groupEventsBy(events, (e) => e.workerId);
581
+ const workerIds = unique([...workers.map((w) => w.id), ...events.map((e) => e.workerId || "")]).sort();
353
582
  return workerIds.filter(Boolean).map((workerId) => {
354
- const worker = (run.workers || []).find((w) => w.id === workerId);
355
- const scoped = events.filter((e) => e.workerId === workerId);
583
+ const worker = workersById.get(workerId);
584
+ const scoped = eventsByWorkerId.get(workerId) || [];
356
585
  return {
357
586
  workerId,
358
587
  taskId: worker?.taskId || scoped.find((e) => e.taskId)?.taskId,
@@ -366,11 +595,21 @@ function workerRows(events, run) {
366
595
  function candidateRows(events, run) {
367
596
  const cands = run.candidates || [];
368
597
  const selectionsAll = run.candidateSelections || [];
598
+ const candsById = new Map(cands.map((c) => [c.id, c]));
599
+ const selectionsByCandidateId = new Map();
600
+ for (const selection of selectionsAll) {
601
+ const list = selectionsByCandidateId.get(selection.candidateId);
602
+ if (list)
603
+ list.push(selection);
604
+ else
605
+ selectionsByCandidateId.set(selection.candidateId, [selection]);
606
+ }
607
+ const eventsByCandidateId = groupEventsBy(events, (e) => e.candidateId);
369
608
  const ids = unique([...cands.map((c) => c.id), ...events.map((e) => e.candidateId || "")]).sort();
370
609
  return ids.filter(Boolean).map((candidateId) => {
371
- const candidate = cands.find((c) => c.id === candidateId);
372
- const selections = selectionsAll.filter((s) => s.candidateId === candidateId);
373
- const scoped = events.filter((e) => e.candidateId === candidateId);
610
+ const candidate = candsById.get(candidateId);
611
+ const selections = selectionsByCandidateId.get(candidateId) || [];
612
+ const scoped = eventsByCandidateId.get(candidateId) || [];
374
613
  return {
375
614
  candidateId,
376
615
  scoreIds: unique([...(candidate?.scores || []), ...scoped.map((e) => e.scoreId || "")]).filter(Boolean).sort(),
@@ -380,9 +619,11 @@ function candidateRows(events, run) {
380
619
  });
381
620
  }
382
621
  function commitRows(events, run) {
383
- const ids = unique([...(run.commits || []).map((c) => c.id), ...events.map((e) => e.commitId || "")]).sort();
622
+ const commits = run.commits || [];
623
+ const commitsById = new Map(commits.map((c) => [c.id, c]));
624
+ const ids = unique([...commits.map((c) => c.id), ...events.map((e) => e.commitId || "")]).sort();
384
625
  return ids.filter(Boolean).map((commitId) => {
385
- const commit = (run.commits || []).find((c) => c.id === commitId);
626
+ const commit = commitsById.get(commitId);
386
627
  return {
387
628
  commitId,
388
629
  verifierGated: Boolean(commit?.verifierGated),
@@ -209,7 +209,7 @@ function summarizeMultiAgentTrust(run) {
209
209
  judgeRationales: byKind("judge.rationale"),
210
210
  panelDecisions: byKind("judge.panel-decision"),
211
211
  policyViolations,
212
- nextAction: policyViolations.length ? `node scripts/cw.js audit policy ${run.id}` : `node scripts/cw.js audit multi-agent ${run.id} --json`,
212
+ nextAction: policyViolations.length ? `cw audit policy ${run.id}` : `cw audit multi-agent ${run.id} --json`,
213
213
  };
214
214
  }
215
215
  function hasAcceptedJudgeRationale(run, input = {}) {