cool-workflow 0.2.1 → 0.2.2

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 (93) 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 +18 -86
  10. package/dist/cli/parseargv.js +5 -2
  11. package/dist/core/capability-data.js +270 -0
  12. package/dist/core/capability-table.js +16 -3504
  13. package/dist/core/format/help.js +28 -5
  14. package/dist/core/multi-agent/collaboration.js +3 -2
  15. package/dist/core/multi-agent/coordinator.js +4 -3
  16. package/dist/core/pipeline/commit-gate.js +2 -1
  17. package/dist/core/state/state-explosion/digest.js +3 -2
  18. package/dist/core/state/state-explosion/graph.js +9 -8
  19. package/dist/core/state/state-explosion/helpers.js +2 -1
  20. package/dist/core/trust/ledger.js +2 -1
  21. package/dist/core/types/execution-backend.js +18 -0
  22. package/dist/core/types/observability.js +7 -0
  23. package/dist/core/util/collate.js +23 -0
  24. package/dist/core/version.js +1 -1
  25. package/dist/shell/audit-cli.js +46 -2
  26. package/dist/shell/commit-summary.js +2 -1
  27. package/dist/shell/drive.js +26 -10
  28. package/dist/shell/evidence-reasoning.js +4 -3
  29. package/dist/shell/execution-backend/registry.js +2 -1
  30. package/dist/shell/execution-backend/types.js +0 -9
  31. package/dist/shell/fs-atomic.js +14 -1
  32. package/dist/shell/multi-agent-operator-ux.js +4 -3
  33. package/dist/shell/observability.js +12 -11
  34. package/dist/shell/onramp.js +17 -1
  35. package/dist/shell/operator-ux-text.js +2 -1
  36. package/dist/shell/operator-ux.js +7 -6
  37. package/dist/shell/pipeline-cli.js +82 -72
  38. package/dist/shell/reclamation-io.js +2 -1
  39. package/dist/shell/report.js +2 -1
  40. package/dist/shell/run-store.js +17 -0
  41. package/dist/shell/state-explosion-cli.js +2 -1
  42. package/dist/shell/topology-io.js +2 -1
  43. package/dist/shell/trust-audit.js +41 -2
  44. package/dist/shell/worker-cli.js +9 -1
  45. package/dist/shell/worker-isolation.js +34 -9
  46. package/dist/shell/workflow-app-loader.js +3 -2
  47. package/dist/wiring/capability-table/basics.js +66 -0
  48. package/dist/wiring/capability-table/exec-backend.js +162 -0
  49. package/dist/wiring/capability-table/index.js +42 -0
  50. package/dist/wiring/capability-table/multi-agent.js +623 -0
  51. package/dist/wiring/capability-table/parity.js +466 -0
  52. package/dist/wiring/capability-table/pipeline.js +280 -0
  53. package/dist/wiring/capability-table/registry-core.js +189 -0
  54. package/dist/wiring/capability-table/reporting.js +394 -0
  55. package/dist/wiring/capability-table/scheduling-registry.js +558 -0
  56. package/dist/wiring/capability-table/state.js +173 -0
  57. package/dist/wiring/capability-table/trust-ledger.js +134 -0
  58. package/dist/wiring/capability-table/workflow-apps.js +366 -0
  59. package/docs/agent-delegation-drive.7.md +2 -0
  60. package/docs/cli-mcp-parity.7.md +7 -2
  61. package/docs/contract-migration-tooling.7.md +2 -0
  62. package/docs/control-plane-scheduling.7.md +2 -0
  63. package/docs/durable-state-and-locking.7.md +22 -0
  64. package/docs/evidence-adoption-reasoning-chain.7.md +2 -0
  65. package/docs/execution-backends.7.md +2 -0
  66. package/docs/index.md +1 -0
  67. package/docs/multi-agent-cli-mcp-surface.7.md +2 -0
  68. package/docs/multi-agent-eval-replay-harness.7.md +2 -0
  69. package/docs/multi-agent-operator-ux.7.md +2 -0
  70. package/docs/node-snapshot-diff-replay.7.md +2 -0
  71. package/docs/observability-cost-accounting.7.md +2 -0
  72. package/docs/project-index.md +25 -5
  73. package/docs/real-execution-backends.7.md +2 -0
  74. package/docs/release-and-migration.7.md +2 -0
  75. package/docs/release-tooling.7.md +2 -0
  76. package/docs/run-registry-control-plane.7.md +2 -0
  77. package/docs/run-retention-reclamation.7.md +2 -0
  78. package/docs/security-trust-hardening.7.md +30 -0
  79. package/docs/state-explosion-management.7.md +2 -0
  80. package/docs/team-collaboration.7.md +2 -0
  81. package/docs/trust-audit-anchor.7.md +69 -0
  82. package/docs/web-desktop-workbench.7.md +2 -0
  83. package/manifest/plugin.manifest.json +1 -1
  84. package/package.json +3 -1
  85. package/scripts/bump-version.js +24 -2
  86. package/scripts/canonical-apps.js +4 -4
  87. package/scripts/dogfood-release.js +1 -1
  88. package/scripts/golden-path.js +4 -4
  89. package/scripts/purity-baseline.json +68 -0
  90. package/scripts/purity-gate.js +239 -0
  91. package/scripts/release-check.js +8 -1
  92. package/scripts/version-sync-check.js +33 -12
  93. package/workflows/README.md +19 -0
@@ -66,6 +66,7 @@ const fs = __importStar(require("node:fs"));
66
66
  const path = __importStar(require("node:path"));
67
67
  const fs_atomic_1 = require("./fs-atomic");
68
68
  const telemetry_ledger_io_1 = require("./telemetry-ledger-io");
69
+ const collate_1 = require("../core/util/collate");
69
70
  exports.METRICS_SCHEMA_VERSION = 1;
70
71
  const VERIFIER_PASS_STATUSES = new Set(["verified", "completed", "committed"]);
71
72
  const VERIFIER_FAIL_STATUSES = new Set(["failed", "rejected", "blocked"]);
@@ -105,22 +106,22 @@ function usageKey(usage) {
105
106
  }
106
107
  function fingerprintMetricsSource(run) {
107
108
  const parts = [`id:${run.id}`, `createdAt:${run.createdAt}`, `updatedAt:${run.updatedAt}`, `app:${run.workflow.app?.id || run.workflow.id}`];
108
- for (const task of [...run.tasks].sort((a, b) => a.id.localeCompare(b.id))) {
109
+ for (const task of [...run.tasks].sort((a, b) => (0, collate_1.stableCompare)(a.id, b.id))) {
109
110
  parts.push(`task:${task.id}:${task.status}:${task.dispatchedAt || "-"}:${task.completedAt || "-"}:${usageKey(task.usage)}:${task.backendId || "-"}`);
110
111
  }
111
- for (const worker of [...(run.workers || [])].sort((a, b) => a.id.localeCompare(b.id))) {
112
+ for (const worker of [...(run.workers || [])].sort((a, b) => (0, collate_1.stableCompare)(a.id, b.id))) {
112
113
  parts.push(`worker:${worker.id}:${worker.status}:${worker.output?.recordedAt || "-"}:${usageKey(worker.usage)}:${worker.backendId || "-"}`);
113
114
  }
114
- for (const node of [...(run.nodes || [])].filter((n) => n.kind === "verifier").sort((a, b) => a.id.localeCompare(b.id))) {
115
+ for (const node of [...(run.nodes || [])].filter((n) => n.kind === "verifier").sort((a, b) => (0, collate_1.stableCompare)(a.id, b.id))) {
115
116
  parts.push(`verifier:${node.id}:${node.status}`);
116
117
  }
117
- for (const cand of [...(run.candidates || [])].sort((a, b) => a.id.localeCompare(b.id))) {
118
+ for (const cand of [...(run.candidates || [])].sort((a, b) => (0, collate_1.stableCompare)(a.id, b.id))) {
118
119
  parts.push(`candidate:${cand.id}:${cand.status}`);
119
120
  }
120
- for (const fb of [...(run.feedback || [])].sort((a, b) => a.id.localeCompare(b.id))) {
121
+ for (const fb of [...(run.feedback || [])].sort((a, b) => (0, collate_1.stableCompare)(a.id, b.id))) {
121
122
  parts.push(`feedback:${fb.id}:${fb.status}`);
122
123
  }
123
- for (const m of [...(run.multiAgent?.memberships || [])].sort((a, b) => a.id.localeCompare(b.id))) {
124
+ for (const m of [...(run.multiAgent?.memberships || [])].sort((a, b) => (0, collate_1.stableCompare)(a.id, b.id))) {
124
125
  parts.push(`membership:${m.id}:${m.status}`);
125
126
  }
126
127
  return fingerprintStrings(parts);
@@ -148,7 +149,7 @@ function usageUnits(run) {
148
149
  units.push({ unit: task.id, kind: "task", usage: task.usage });
149
150
  }
150
151
  }
151
- return units.sort((a, b) => a.unit.localeCompare(b.unit));
152
+ return units.sort((a, b) => (0, collate_1.stableCompare)(a.unit, b.unit));
152
153
  }
153
154
  function tokenTotal(usage) {
154
155
  if (typeof usage.totalTokens === "number")
@@ -349,12 +350,12 @@ function deriveCandidateAcceptanceRate(run) {
349
350
  function taskRows(run) {
350
351
  return run.tasks
351
352
  .map((task) => ({ id: task.id, kind: "task", status: task.status, duration: duration(task.dispatchedAt, task.completedAt) }))
352
- .sort((a, b) => a.id.localeCompare(b.id));
353
+ .sort((a, b) => (0, collate_1.stableCompare)(a.id, b.id));
353
354
  }
354
355
  function workerRows(run) {
355
356
  return (run.workers || [])
356
357
  .map((worker) => ({ id: worker.id, kind: "worker", status: worker.status, duration: duration(worker.createdAt, workerEndAt(worker)) }))
357
- .sort((a, b) => a.id.localeCompare(b.id));
358
+ .sort((a, b) => (0, collate_1.stableCompare)(a.id, b.id));
358
359
  }
359
360
  function targetCreatedAt(run, target) {
360
361
  if (target.kind === "candidate")
@@ -541,7 +542,7 @@ function deriveMetricsSummary(inputs, options) {
541
542
  const report = deriveMetricsReport(input.run, { now: options.now, policy: options.policy, persistedFingerprint: input.persistedFingerprint });
542
543
  perRun.push({ report, ref: { runId: report.runId, repo: input.repo, app: report.scope.app, backendIds: report.scope.backendIds, freshness: report.freshness.status, rates: report.rates, usage: report.usage, cost: report.cost } });
543
544
  }
544
- perRun.sort((a, b) => a.report.runId.localeCompare(b.report.runId));
545
+ perRun.sort((a, b) => (0, collate_1.stableCompare)(a.report.runId, b.report.runId));
545
546
  const groupBy = (keyOf) => {
546
547
  const map = new Map();
547
548
  for (const { report } of perRun) {
@@ -552,7 +553,7 @@ function deriveMetricsSummary(inputs, options) {
552
553
  }
553
554
  }
554
555
  return [...map.entries()]
555
- .sort((a, b) => a[0].localeCompare(b[0]))
556
+ .sort((a, b) => (0, collate_1.stableCompare)(a[0], b[0]))
556
557
  .map(([key, reports]) => ({
557
558
  key,
558
559
  runCount: reports.length,
@@ -294,10 +294,26 @@ function evaluateOnrampContract(files, options = {}) {
294
294
  const scriptFiles = normalized.filter((file) => file.startsWith("plugins/cool-workflow/scripts/"));
295
295
  const surfaceFiles = normalized.filter(isSurfaceFile);
296
296
  const smokeFiles = normalized.filter((file) => /^plugins\/cool-workflow\/test\/.+-smoke\.js$/.test(file));
297
+ // WP1.1 (#360) restored a second, parallel test layer: pure `core/`
298
+ // logic proven by `test/*.test.js` under `npm run test:unit`, run and
299
+ // gated separately from the black-box `test/*-smoke.js` suite. A cycle
300
+ // that proves its fix with a unit test only (no smoke touched) is a
301
+ // real, complete cycle — the gate must accept either kind, not just
302
+ // the one that existed before the unit-test layer came back.
303
+ const unitTestFiles = normalized.filter((file) => /^plugins\/cool-workflow\/test\/.+\.test\.js$/.test(file));
304
+ // The black-box conformance suite (v2/conformance/cases/*.case.js) is a
305
+ // third, equally real proof layer — CI-gated on every push, and the one
306
+ // North Star Track C leans on. A cycle proven end to end by a new or
307
+ // changed conformance case (with no test/*-smoke.js or test/*.test.js
308
+ // touched) is a real, complete cycle too.
309
+ const conformanceCaseFiles = normalized.filter((file) => /^v2\/conformance\/cases\/.+\.case\.js$/.test(file));
297
310
  const docFiles = normalized.filter(isDocFile);
298
311
  const iterationFiles = normalized.filter((file) => file === "ITERATION_LOG.md");
299
312
  const sourceAppOrScript = runtimeFiles.length > 0 || typeFiles.length > 0 || appFiles.length > 0 || scriptFiles.length > 0;
300
- if ((runtimeFiles.length > 0 || appFiles.length > 0) && smokeFiles.length === 0) {
313
+ if ((runtimeFiles.length > 0 || appFiles.length > 0) &&
314
+ smokeFiles.length === 0 &&
315
+ unitTestFiles.length === 0 &&
316
+ conformanceCaseFiles.length === 0) {
301
317
  issues.push({
302
318
  code: "runtime-smoke-required",
303
319
  detail: "Runtime or app changes must include at least one smoke test change.",
@@ -24,8 +24,9 @@ exports.formatCandidateSummaryText = formatCandidateSummaryText;
24
24
  exports.formatFeedbackSummaryText = formatFeedbackSummaryText;
25
25
  const term_1 = require("./term");
26
26
  const multi_agent_operator_ux_1 = require("./multi-agent-operator-ux");
27
+ const collate_1 = require("../core/util/collate");
27
28
  function formatCounts(counts) {
28
- const entries = Object.entries(counts).sort(([a], [b]) => a.localeCompare(b));
29
+ const entries = Object.entries(counts).sort(([a], [b]) => (0, collate_1.stableCompare)(a, b));
29
30
  if (!entries.length)
30
31
  return "none";
31
32
  return entries.map(([k, v]) => `${k}=${v}`).join(", ");
@@ -66,6 +66,7 @@ const coordinator_io_1 = require("./coordinator-io");
66
66
  const topology_io_1 = require("./topology-io");
67
67
  const multi_agent_operator_ux_1 = require("./multi-agent-operator-ux");
68
68
  const trust_policy_io_1 = require("./trust-policy-io");
69
+ const collate_1 = require("../core/util/collate");
69
70
  function countBy(values, key) {
70
71
  const counts = {};
71
72
  for (const value of values)
@@ -143,8 +144,8 @@ function summarizeTasks(tasks) {
143
144
  * verb and operator panel both read. */
144
145
  function summarizeOperatorCandidates(run) {
145
146
  const counts = (0, candidate_scoring_io_1.summarizeCandidates)(run);
146
- const candidates = [...(run.candidates || [])].sort((left, right) => left.id.localeCompare(right.id));
147
- const selections = [...(run.candidateSelections || [])].sort((left, right) => left.id.localeCompare(right.id));
147
+ const candidates = [...(run.candidates || [])].sort((left, right) => (0, collate_1.stableCompare)(left.id, right.id));
148
+ const selections = [...(run.candidateSelections || [])].sort((left, right) => (0, collate_1.stableCompare)(left.id, right.id));
148
149
  const commits = run.commits || [];
149
150
  const selectedIds = new Set(selections.map((selection) => selection.candidateId));
150
151
  const readyForCommit = selections
@@ -190,7 +191,7 @@ function summarizeOperatorFeedback(run) {
190
191
  };
191
192
  }
192
193
  function summarizeOperatorCommits(run) {
193
- const commits = [...(run.commits || [])].sort((left, right) => left.createdAt.localeCompare(right.createdAt) || left.id.localeCompare(right.id));
194
+ const commits = [...(run.commits || [])].sort((left, right) => (0, collate_1.stableCompare)(left.createdAt, right.createdAt) || (0, collate_1.stableCompare)(left.id, right.id));
194
195
  return {
195
196
  total: commits.length,
196
197
  verifierGated: commits.filter((c) => c.verifierGated).length,
@@ -200,7 +201,7 @@ function summarizeOperatorCommits(run) {
200
201
  };
201
202
  }
202
203
  function summarizeOperatorWorkers(run) {
203
- const workers = (run.workers || []).slice().sort((a, b) => a.id.localeCompare(b.id));
204
+ const workers = (run.workers || []).slice().sort((a, b) => (0, collate_1.stableCompare)(a.id, b.id));
204
205
  return {
205
206
  total: workers.length,
206
207
  byStatus: countBy(workers, (w) => w.status),
@@ -425,7 +426,7 @@ function buildOperatorGraph(run) {
425
426
  addNode(node.id, node.kind, node.status, node.label, node.path);
426
427
  for (const edge of blackboardGraph.edges)
427
428
  addEdge(edge.from, edge.to, edge.label);
428
- const sortedNodes = [...nodes.values()].sort((a, b) => a.kind.localeCompare(b.kind) || a.id.localeCompare(b.id));
429
- const sortedEdges = edges.slice().sort((a, b) => a.from.localeCompare(b.from) || a.to.localeCompare(b.to) || (a.label || "").localeCompare(b.label || ""));
429
+ const sortedNodes = [...nodes.values()].sort((a, b) => (0, collate_1.stableCompare)(a.kind, b.kind) || (0, collate_1.stableCompare)(a.id, b.id));
430
+ const sortedEdges = edges.slice().sort((a, b) => (0, collate_1.stableCompare)(a.from, b.from) || (0, collate_1.stableCompare)(a.to, b.to) || (0, collate_1.stableCompare)(a.label || "", b.label || ""));
430
431
  return { runId: run.id, nodes: sortedNodes, edges: sortedEdges };
431
432
  }
@@ -552,84 +552,94 @@ function quickstartRun(args) {
552
552
  }
553
553
  function dispatchRun(args) {
554
554
  const runId = String(args.runId);
555
- const run = (0, run_store_1.loadRunFromCwd)(runId, invocationCwd(args));
556
- // parseArgv keys long flags in kebab-case; accept camelCase as a fallback.
557
- const flag = (kebab, camel) => {
558
- const v = args[kebab] ?? args[camel];
559
- return typeof v === "string" && v.trim() ? v : undefined;
560
- };
561
- const manifest = (0, dispatch_1.createDispatchManifest)(run, args.limit !== undefined ? Number(args.limit) : undefined, {
562
- sandboxProfileId: typeof args.sandbox === "string" ? args.sandbox : undefined,
563
- sandbox: typeof args.sandbox === "string" ? args.sandbox : undefined,
564
- backendId: typeof args.backend === "string" ? args.backend : undefined,
565
- multiAgentRunId: flag("multi-agent-run", "multiAgentRun"),
566
- multiAgentGroupId: flag("multi-agent-group", "multiAgentGroup"),
567
- multiAgentRoleId: flag("multi-agent-role", "multiAgentRole"),
568
- multiAgentFanoutId: flag("multi-agent-fanout", "multiAgentFanout"),
555
+ // The whole load -> change -> save cycle holds the state.json lock so a
556
+ // concurrent dispatch/result on the same run cannot drop this update.
557
+ return (0, run_store_1.withRunStateLock)(runId, invocationCwd(args), (run) => {
558
+ // parseArgv keys long flags in kebab-case; accept camelCase as a fallback.
559
+ const flag = (kebab, camel) => {
560
+ const v = args[kebab] ?? args[camel];
561
+ return typeof v === "string" && v.trim() ? v : undefined;
562
+ };
563
+ const manifest = (0, dispatch_1.createDispatchManifest)(run, args.limit !== undefined ? Number(args.limit) : undefined, {
564
+ sandboxProfileId: typeof args.sandbox === "string" ? args.sandbox : undefined,
565
+ sandbox: typeof args.sandbox === "string" ? args.sandbox : undefined,
566
+ backendId: typeof args.backend === "string" ? args.backend : undefined,
567
+ multiAgentRunId: flag("multi-agent-run", "multiAgentRun"),
568
+ multiAgentGroupId: flag("multi-agent-group", "multiAgentGroup"),
569
+ multiAgentRoleId: flag("multi-agent-role", "multiAgentRole"),
570
+ multiAgentFanoutId: flag("multi-agent-fanout", "multiAgentFanout"),
571
+ });
572
+ if (manifest.dispatchId) {
573
+ (0, commit_1.commitState)(run, `dispatch:${manifest.dispatchId}`);
574
+ (0, run_store_1.saveCheckpoint)(run);
575
+ (0, report_1.writeReport)(run);
576
+ }
577
+ return manifest;
569
578
  });
570
- if (manifest.dispatchId) {
571
- (0, commit_1.commitState)(run, `dispatch:${manifest.dispatchId}`);
572
- (0, run_store_1.saveCheckpoint)(run);
573
- (0, report_1.writeReport)(run);
574
- }
575
- return manifest;
576
579
  }
577
580
  function recordResultRun(args) {
578
581
  const runId = String(args.runId);
579
582
  const taskId = String(args.taskId);
580
583
  const resultPath = String(args.resultPath);
581
- const run = (0, run_store_1.loadRunFromCwd)(runId, invocationCwd(args));
582
- const task = run.tasks.find((t) => t.id === taskId);
583
- if (!task || !task.workerId)
584
- throw new Error(`Unknown task id for run ${runId}: ${taskId}`);
585
- const absolute = path.resolve(resultPath);
586
- // A result path inside a system directory is never accepted (POLA): the
587
- // operator file gets copied into the worker's result.md, so a /etc/passwd
588
- // source would smuggle system content into a run. Byte-behavior port of the
589
- // old build's recordResult system-directory blacklist.
590
- if (/^\/(etc|bin|sbin|usr|Library|System|Applications|boot|dev|proc|sys|root|var\/log|var\/run)\//.test(absolute)) {
591
- throw new Error(`Result path must not be a system directory: ${resultPath}`);
592
- }
593
- if (!fs.existsSync(absolute))
594
- throw new Error(`Result file does not exist: ${resultPath}`);
595
- const workerId = String(task.workerId);
596
- // Host-attested `cw result <run> <task> <file>` intake: the operator hands CW
597
- // an EXTERNAL result file that lives OUTSIDE the worker's read-only write
598
- // boundary. The old task-level recordResult (lifecycle-operations.ts:279-280)
599
- // COPIED that external file into the run's results area and recorded the
600
- // internal path it never ran the external path through validateSandboxWrite.
601
- // v2 collapsed the two intakes into recordWorkerOutput, which sandbox-validates
602
- // its input against the worker boundary, so a bare external path is rejected
603
- // ("write path is outside sandbox profile <id>"). Restore the copy-in: stage
604
- // the operator file at the worker's OWN result.md (which IS inside the write
605
- // boundary), then record that internal path exactly like a driven worker.
606
- const manifest = (0, worker_isolation_1.showWorkerManifest)(run, workerId);
607
- fs.mkdirSync(path.dirname(manifest.resultPath), { recursive: true });
608
- fs.copyFileSync(absolute, manifest.resultPath);
609
- const output = (0, worker_isolation_1.recordWorkerOutput)(run, workerId, manifest.resultPath);
610
- // Host-attested token usage (v0.1.31): record it verbatim as provenance when
611
- // the operator supplied `--usage-*` flags; CW never synthesizes usage. The old
612
- // task-level recordResult set `task.usage = usage` (lifecycle-operations.ts:286)
613
- // and its unit was the TASK. v2 records through recordWorkerOutput, which gives
614
- // the worker an `output` record — so the observability usage UNIT becomes the
615
- // WORKER (deriveUsageTotals reads worker.usage for workers with output, and
616
- // EXCLUDES that task). Attach the usage to the worker scope so the report counts
617
- // it as an attested unit; also stamp task.usage for byte-parity with the old
618
- // task-level record.
619
- const usage = (0, observability_1.parseUsageFromArgs)(args, new Date().toISOString());
620
- if (usage) {
621
- task.usage = usage;
622
- const scope = (0, worker_isolation_1.getWorkerScope)(run, workerId);
623
- if (scope)
624
- scope.usage = usage;
625
- }
626
- // Byte-exact to the old build's orchestrator recordWorkerOutput()
627
- // wrapper: an accepted result is its own checkpoint commit, not just a
628
- // bare saveCheckpoint (SPEC/pipeline-run.md's persist-ordering rule).
629
- (0, commit_1.commitState)(run, `worker:${workerId}:result`);
630
- (0, run_store_1.saveCheckpoint)(run);
631
- (0, report_1.writeReport)(run);
632
- return output;
584
+ // Two processes recording results for two tasks of the SAME run used to
585
+ // race: both loaded, and the later saveCheckpoint dropped the earlier
586
+ // task's completion. The lock now covers the whole cycle.
587
+ return (0, run_store_1.withRunStateLock)(runId, invocationCwd(args), (run) => {
588
+ const task = run.tasks.find((t) => t.id === taskId);
589
+ if (!task || !task.workerId)
590
+ throw new Error(`Unknown task id for run ${runId}: ${taskId}`);
591
+ const absolute = path.resolve(resultPath);
592
+ // A result path inside a system directory is never accepted (POLA): the
593
+ // operator file gets copied into the worker's result.md, so a /etc/passwd
594
+ // source would smuggle system content into a run. Byte-behavior port of the
595
+ // old build's recordResult system-directory blacklist.
596
+ if (/^\/(etc|bin|sbin|usr|Library|System|Applications|boot|dev|proc|sys|root|var\/log|var\/run)\//.test(absolute)) {
597
+ throw new Error(`Result path must not be a system directory: ${resultPath}`);
598
+ }
599
+ if (!fs.existsSync(absolute))
600
+ throw new Error(`Result file does not exist: ${resultPath}`);
601
+ const workerId = String(task.workerId);
602
+ // Host-attested `cw result <run> <task> <file>` intake: the operator hands CW
603
+ // an EXTERNAL result file that lives OUTSIDE the worker's read-only write
604
+ // boundary. The old task-level recordResult (lifecycle-operations.ts:279-280)
605
+ // COPIED that external file into the run's results area and recorded the
606
+ // internal path it never ran the external path through validateSandboxWrite.
607
+ // v2 collapsed the two intakes into recordWorkerOutput, which sandbox-validates
608
+ // its input against the worker boundary, so a bare external path is rejected
609
+ // ("write path is outside sandbox profile <id>"). Restore the copy-in: stage
610
+ // the operator file at the worker's OWN result.md (which IS inside the write
611
+ // boundary), then record that internal path exactly like a driven worker.
612
+ const manifest = (0, worker_isolation_1.showWorkerManifest)(run, workerId);
613
+ fs.mkdirSync(path.dirname(manifest.resultPath), { recursive: true });
614
+ fs.copyFileSync(absolute, manifest.resultPath);
615
+ const output = (0, worker_isolation_1.recordWorkerOutput)(run, workerId, manifest.resultPath, {
616
+ requireAttestedTelemetry: (0, agent_config_1.resolveAgentConfig)(args).requireAttestedTelemetry,
617
+ allowUnattested: Boolean(args.allowUnattested ?? args["allow-unattested"]),
618
+ });
619
+ // Host-attested token usage (v0.1.31): record it verbatim as provenance when
620
+ // the operator supplied `--usage-*` flags; CW never synthesizes usage. The old
621
+ // task-level recordResult set `task.usage = usage` (lifecycle-operations.ts:286)
622
+ // and its unit was the TASK. v2 records through recordWorkerOutput, which gives
623
+ // the worker an `output` record — so the observability usage UNIT becomes the
624
+ // WORKER (deriveUsageTotals reads worker.usage for workers with output, and
625
+ // EXCLUDES that task). Attach the usage to the worker scope so the report counts
626
+ // it as an attested unit; also stamp task.usage for byte-parity with the old
627
+ // task-level record.
628
+ const usage = (0, observability_1.parseUsageFromArgs)(args, new Date().toISOString());
629
+ if (usage) {
630
+ task.usage = usage;
631
+ const scope = (0, worker_isolation_1.getWorkerScope)(run, workerId);
632
+ if (scope)
633
+ scope.usage = usage;
634
+ }
635
+ // Byte-exact to the old build's orchestrator recordWorkerOutput()
636
+ // wrapper: an accepted result is its own checkpoint commit, not just a
637
+ // bare saveCheckpoint (SPEC/pipeline-run.md's persist-ordering rule).
638
+ (0, commit_1.commitState)(run, `worker:${workerId}:result`);
639
+ (0, run_store_1.saveCheckpoint)(run);
640
+ (0, report_1.writeReport)(run);
641
+ return output;
642
+ });
633
643
  }
634
644
  /** `cw commit <run-id>` — byte-exact port of the old build's
635
645
  * `orchestrator/lifecycle-operations.ts`'s `commit()`: the CLI/MCP
@@ -106,6 +106,7 @@ const node_store_1 = require("./node-store");
106
106
  const node_snapshot_1 = require("../core/state/node-snapshot");
107
107
  const node_projection_1 = require("../core/state/node-projection");
108
108
  const hash_1 = require("../core/hash");
109
+ const collate_1 = require("../core/util/collate");
109
110
  const run_registry_io_1 = require("./run-registry-io");
110
111
  // ---------------------------------------------------------------------------
111
112
  // Content addressing + byte measurement (in-process, no `du`) — carried
@@ -1287,7 +1288,7 @@ function readCloneEntries(root) {
1287
1288
  bytes: dirSize(dir),
1288
1289
  });
1289
1290
  }
1290
- entries.sort((a, b) => (a.fetchedAt || "").localeCompare(b.fetchedAt || ""));
1291
+ entries.sort((a, b) => (0, collate_1.stableCompare)(a.fetchedAt || "", b.fetchedAt || ""));
1291
1292
  return entries;
1292
1293
  }
1293
1294
  /** `cw clones list` — every cached remote checkout with its origin,
@@ -59,13 +59,14 @@ const telemetry_ledger_io_1 = require("./telemetry-ledger-io");
59
59
  const multi_agent_io_1 = require("./multi-agent-io");
60
60
  const coordinator_io_1 = require("./coordinator-io");
61
61
  const multi_agent_operator_ux_1 = require("./multi-agent-operator-ux");
62
+ const collate_1 = require("../core/util/collate");
62
63
  function formatInputList(value) {
63
64
  if (Array.isArray(value))
64
65
  return value.join("; ");
65
66
  return value ? String(value) : "";
66
67
  }
67
68
  function formatCounts(counts) {
68
- const entries = Object.entries(counts).sort(([a], [b]) => a.localeCompare(b));
69
+ const entries = Object.entries(counts).sort(([a], [b]) => (0, collate_1.stableCompare)(a, b));
69
70
  if (!entries.length)
70
71
  return "none";
71
72
  return entries.map(([k, v]) => `${k}=${v}`).join(", ");
@@ -50,6 +50,7 @@ exports.loadRunStateFile = loadRunStateFile;
50
50
  exports.checkRunStateFile = checkRunStateFile;
51
51
  exports.migrateRunStateFile = migrateRunStateFile;
52
52
  exports.loadRunFromCwd = loadRunFromCwd;
53
+ exports.withRunStateLock = withRunStateLock;
53
54
  exports.saveCheckpoint = saveCheckpoint;
54
55
  exports.compactCheckpoint = compactCheckpoint;
55
56
  exports.createRun = createRun;
@@ -112,6 +113,22 @@ function loadRunFromCwd(runId, cwd = process.cwd()) {
112
113
  }
113
114
  return result.run;
114
115
  }
116
+ /** Hold the state.json lock over a WHOLE load -> change -> save cycle.
117
+ * A bare loadRunFromCwd + saveCheckpoint pair leaves a window where two
118
+ * processes both load the same state and the later save silently drops
119
+ * the earlier change (the same lost-update class PR #339 fixed for
120
+ * queue.json / triggers.json). `fn` gets the run loaded UNDER the lock;
121
+ * saveCheckpoint calls inside `fn` re-enter the same lock (withFileLock
122
+ * is re-entrant in-process) and write exactly as before. The probe load
123
+ * runs BEFORE the lock so an unknown run id throws the exact
124
+ * loadRunFromCwd error without first creating the run directory as a
125
+ * lock-file side effect — and it supplies `paths.state`, the same lock
126
+ * target saveCheckpoint uses. Keep `fn` short: a critical section past
127
+ * 30s can be stolen as a stale lock. */
128
+ function withRunStateLock(runId, cwd, fn) {
129
+ const probe = loadRunFromCwd(runId, cwd);
130
+ return (0, fs_atomic_1.withFileLock)(probe.paths.state, () => fn(loadRunFromCwd(runId, cwd)));
131
+ }
115
132
  /** state.json is the single source of truth — set `updatedAt`, then write
116
133
  * it DURABLY with a lock so concurrent processes never lose an update. */
117
134
  function saveCheckpoint(run) {
@@ -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
  }
@@ -134,7 +135,7 @@ function refreshStateExplosionSummaries(run, options = {}) {
134
135
  status: "valid",
135
136
  deterministic: true,
136
137
  nextAction: `node scripts/cw.js summary show ${run.id}`,
137
- entries: entries.sort((a, b) => a.id.localeCompare(b.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
  };
@@ -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
  }
@@ -307,7 +308,7 @@ function buildTopologyGraph(run) {
307
308
  return topo.buildTopologyGraphFromRuns(run.id, state.runs, (id) => topologyRunPath(run, id));
308
309
  }
309
310
  function formatTopologyCounts(counts) {
310
- const entries = Object.entries(counts).sort(([a], [b]) => a.localeCompare(b));
311
+ const entries = Object.entries(counts).sort(([a], [b]) => (0, collate_1.stableCompare)(a, b));
311
312
  if (!entries.length)
312
313
  return "none";
313
314
  return entries.map(([k, v]) => `${k}=${v}`).join(", ");
@@ -58,6 +58,7 @@ 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;
62
63
  exports.recordTrustAuditEvent = recordTrustAuditEvent;
63
64
  exports.recordSandboxPathDecision = recordSandboxPathDecision;
@@ -136,6 +137,21 @@ function readEventsRaw(eventLogPath) {
136
137
  function listTrustAuditEvents(run) {
137
138
  return readEventsRaw(ensureTrustAudit(run).eventLogPath);
138
139
  }
140
+ /** The current head of a run's trust-audit chain: the hash the NEXT
141
+ * appended event will link from (genesis when the log is empty), plus
142
+ * the event count. Read-only projection over existing data. Capture it
143
+ * (e.g. right after a run, or at export time) and later hand it to
144
+ * `verifyTrustAudit`'s anchor / `cw audit verify --expect-head` to
145
+ * re-prove the log was not shortened since the capture. */
146
+ function trustAuditHead(run) {
147
+ const audit = ensureTrustAudit(run);
148
+ const events = readEventsRaw(audit.eventLogPath);
149
+ let head = trustAuditGenesis(run.id);
150
+ for (const event of events) {
151
+ head = event.eventHash !== undefined ? event.eventHash : computeEventHash(event);
152
+ }
153
+ return { eventCount: events.length, headHash: head };
154
+ }
139
155
  /** Re-prove the run's trust-audit chain: prevEventHash linkage (append
140
156
  * order) + per-event hash recompute. A corrupt line, an edited event,
141
157
  * or a removed event flips verified=false. Legacy events without a
@@ -147,8 +163,14 @@ function listTrustAuditEvents(run) {
147
163
  * era) or all-legacy (pre-chain). An unchained (eventHash-less) line
148
164
  * mixed into an otherwise-chained log is a forgery attempt — dropping
149
165
  * the hash to be waved through as "legacy" — so it fails with
150
- * `trust-audit-unchained-event`, never silently accepted. */
151
- function verifyTrustAudit(run) {
166
+ * `trust-audit-unchained-event`, never silently accepted.
167
+ *
168
+ * ANCHOR (optional): the walk alone cannot see tail truncation — see
169
+ * TrustAuditAnchor. With an anchor, the head-hash trail (genesis plus
170
+ * the hash after each event) must contain `expectHead`, and the log
171
+ * must reach `expectCount` events; a shortfall fails closed with
172
+ * `trust-audit-truncated`. Without an anchor, behavior is unchanged. */
173
+ function verifyTrustAudit(run, anchor) {
152
174
  const audit = ensureTrustAudit(run);
153
175
  const { events, corruptLines } = readEventsRawCounted(audit.eventLogPath);
154
176
  const checks = [];
@@ -158,12 +180,14 @@ function verifyTrustAudit(run) {
158
180
  let chained = 0;
159
181
  let unchained = 0;
160
182
  let expectedPrev = trustAuditGenesis(run.id);
183
+ const headTrail = new Set([expectedPrev]);
161
184
  for (let i = 0; i < events.length; i++) {
162
185
  const event = events[i];
163
186
  const recomputed = computeEventHash(event);
164
187
  if (event.eventHash === undefined) {
165
188
  unchained += 1;
166
189
  expectedPrev = recomputed; // advance the chain over legacy events
190
+ headTrail.add(expectedPrev);
167
191
  continue;
168
192
  }
169
193
  chained += 1;
@@ -176,12 +200,27 @@ function verifyTrustAudit(run) {
176
200
  checks.push({ name: `chain-link[${i}]`, pass: false, code: "trust-audit-chain-broken" });
177
201
  }
178
202
  expectedPrev = event.eventHash;
203
+ headTrail.add(expectedPrev);
179
204
  }
180
205
  // Era rule: a log with ANY chained event must have EVERY event chained.
181
206
  if (chained > 0 && unchained > 0) {
182
207
  verified = false;
183
208
  checks.push({ name: "unchained-events", pass: false, code: "trust-audit-unchained-event" });
184
209
  }
210
+ // Anchor rule: the captured head must still be ON the chain, and the log
211
+ // must be at least as long as it was at capture time. A truncated-then-
212
+ // appended log fails the head check (new events link from an earlier
213
+ // point, so the old head is no longer in the trail).
214
+ if (anchor) {
215
+ if (anchor.expectCount !== undefined && events.length < anchor.expectCount) {
216
+ verified = false;
217
+ checks.push({ name: "anchor-count", pass: false, code: "trust-audit-truncated" });
218
+ }
219
+ if (anchor.expectHead !== undefined && !headTrail.has(anchor.expectHead)) {
220
+ verified = false;
221
+ checks.push({ name: "anchor-head", pass: false, code: "trust-audit-truncated" });
222
+ }
223
+ }
185
224
  return { present: events.length > 0, verified, eventCount: events.length, chained, unchained, corruptLines, checks };
186
225
  }
187
226
  function unique(values) {
@@ -51,6 +51,7 @@ const drive_1 = require("./drive");
51
51
  const commit_1 = require("./commit");
52
52
  const report_1 = require("./report");
53
53
  const operator_ux_1 = require("./operator-ux");
54
+ const agent_config_1 = require("./agent-config");
54
55
  function cwdFor(args) {
55
56
  return typeof args.cwd === "string" && args.cwd.trim() ? path.resolve(args.cwd) : process.cwd();
56
57
  }
@@ -60,6 +61,10 @@ function req(value, label) {
60
61
  throw new Error(`Missing ${label}`);
61
62
  return s;
62
63
  }
64
+ /** `--allow-unattested` (CLI: dashed key; MCP: allowUnattested). */
65
+ function allowUnattestedOption(args) {
66
+ return Boolean(args.allowUnattested ?? args["allow-unattested"]);
67
+ }
63
68
  function workerListCli(args) {
64
69
  const run = (0, run_store_1.loadRunFromCwd)(req(args.runId, "run id"), cwdFor(args));
65
70
  return (0, worker_isolation_1.listWorkerScopes)(run, { status: typeof args.status === "string" ? args.status : undefined });
@@ -92,7 +97,10 @@ function workerManifestCli(args) {
92
97
  * same steps itself around the bare accept, so it never routes through here. */
93
98
  function workerOutputCli(args) {
94
99
  const run = (0, run_store_1.loadRunFromCwd)(req(args.runId, "run id"), cwdFor(args));
95
- (0, worker_isolation_1.recordWorkerOutput)(run, req(args.workerId, "worker id"), req(args.resultPath, "result file"), {});
100
+ (0, worker_isolation_1.recordWorkerOutput)(run, req(args.workerId, "worker id"), req(args.resultPath, "result file"), {
101
+ requireAttestedTelemetry: (0, agent_config_1.resolveAgentConfig)(args).requireAttestedTelemetry,
102
+ allowUnattested: allowUnattestedOption(args),
103
+ });
96
104
  run.loopStage = "observe";
97
105
  (0, dispatch_1.updatePhaseStatuses)(run);
98
106
  (0, drive_1.maybeExpandLoop)(run);