cool-workflow 0.2.2 → 0.2.4

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 (141) 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 +13 -6
  10. package/dist/cli/entry.js +41 -1
  11. package/dist/cli/io.js +12 -22
  12. package/dist/cli/parseargv.js +1 -0
  13. package/dist/core/capability-data.js +3 -2
  14. package/dist/core/format/completion.js +68 -0
  15. package/dist/core/format/help.js +31 -6
  16. package/dist/core/format/safe-json.js +73 -0
  17. package/dist/core/format/state-explosion-text.js +1 -1
  18. package/dist/core/multi-agent/candidate-scoring.js +5 -1
  19. package/dist/core/multi-agent/collaboration.js +3 -3
  20. package/dist/core/multi-agent/coordinator.js +5 -5
  21. package/dist/core/multi-agent/runtime.js +4 -4
  22. package/dist/core/multi-agent/topology.js +3 -3
  23. package/dist/core/pipeline/dispatch.js +18 -4
  24. package/dist/core/pipeline/drive-decide.js +2 -1
  25. package/dist/core/state/migrations.js +16 -1
  26. package/dist/core/state/state-explosion/digest.js +13 -13
  27. package/dist/core/state/state-explosion/graph.js +17 -11
  28. package/dist/core/state/state-explosion/report.js +6 -6
  29. package/dist/core/util/cli-args.js +33 -0
  30. package/dist/core/util/numeric-flag.js +40 -0
  31. package/dist/core/version.js +1 -1
  32. package/dist/mcp/server.js +99 -11
  33. package/dist/shell/audit-cli.js +57 -25
  34. package/dist/shell/coordinator-io.js +73 -13
  35. package/dist/shell/dispatch.js +1 -1
  36. package/dist/shell/doctor.js +80 -1
  37. package/dist/shell/drive.js +251 -49
  38. package/dist/shell/eval-text.js +2 -2
  39. package/dist/shell/evidence-reasoning.js +4 -4
  40. package/dist/shell/execution-backend/agent.js +30 -2
  41. package/dist/shell/execution-backend/container.js +4 -1
  42. package/dist/shell/execution-backend/local.js +19 -11
  43. package/dist/shell/feedback-cli.js +6 -6
  44. package/dist/shell/fs-atomic.js +218 -29
  45. package/dist/shell/man-cli.js +6 -0
  46. package/dist/shell/metrics-cli.js +2 -1
  47. package/dist/shell/multi-agent-cli.js +367 -323
  48. package/dist/shell/multi-agent-host.js +9 -9
  49. package/dist/shell/multi-agent-operator-ux.js +80 -38
  50. package/dist/shell/node-store.js +10 -4
  51. package/dist/shell/observability.js +1 -1
  52. package/dist/shell/operator-ux-text.js +22 -22
  53. package/dist/shell/operator-ux.js +15 -14
  54. package/dist/shell/orchestrator.js +49 -38
  55. package/dist/shell/pipeline-cli.js +107 -42
  56. package/dist/shell/reclamation-io.js +88 -10
  57. package/dist/shell/registry-cli.js +23 -17
  58. package/dist/shell/remote-source.js +13 -8
  59. package/dist/shell/report-cli.js +45 -0
  60. package/dist/shell/report.js +2 -1
  61. package/dist/shell/run-registry-io.js +78 -19
  62. package/dist/shell/run-store.js +74 -2
  63. package/dist/shell/scheduling-io.js +12 -13
  64. package/dist/shell/state-cli.js +2 -7
  65. package/dist/shell/state-explosion-cli.js +17 -9
  66. package/dist/shell/topology-io.js +36 -5
  67. package/dist/shell/trust-audit.js +224 -22
  68. package/dist/shell/trust-policy-io.js +1 -1
  69. package/dist/shell/worker-cli.js +35 -31
  70. package/dist/shell/workflow-app-loader.js +67 -1
  71. package/dist/wiring/capability-table/basics.js +33 -8
  72. package/dist/wiring/capability-table/exec-backend.js +28 -19
  73. package/dist/wiring/capability-table/multi-agent.js +187 -180
  74. package/dist/wiring/capability-table/parity.js +1 -0
  75. package/dist/wiring/capability-table/pipeline.js +64 -52
  76. package/dist/wiring/capability-table/registry-core.js +30 -11
  77. package/dist/wiring/capability-table/reporting.js +147 -106
  78. package/dist/wiring/capability-table/scheduling-registry.js +201 -167
  79. package/dist/wiring/capability-table/state.js +59 -51
  80. package/dist/wiring/capability-table/trust-ledger.js +52 -28
  81. package/dist/wiring/capability-table/workflow-apps.js +85 -70
  82. package/docs/agent-delegation-drive.7.md +4 -0
  83. package/docs/candidate-scoring.7.md +1 -1
  84. package/docs/canonical-workflow-apps.7.md +7 -7
  85. package/docs/cli-mcp-parity.7.md +26 -16
  86. package/docs/contract-migration-tooling.7.md +4 -0
  87. package/docs/control-plane-scheduling.7.md +4 -0
  88. package/docs/coordinator-blackboard.7.md +17 -17
  89. package/docs/dogfood-one-real-repo.7.md +11 -11
  90. package/docs/durable-state-and-locking.7.md +4 -0
  91. package/docs/end-to-end-golden-path.7.md +14 -14
  92. package/docs/evidence-adoption-reasoning-chain.7.md +4 -0
  93. package/docs/execution-backends.7.md +4 -0
  94. package/docs/getting-started.md +37 -37
  95. package/docs/multi-agent-cli-mcp-surface.7.md +21 -17
  96. package/docs/multi-agent-eval-replay-harness.7.md +17 -13
  97. package/docs/multi-agent-operator-ux.7.md +23 -19
  98. package/docs/multi-agent-runtime-core.7.md +22 -22
  99. package/docs/multi-agent-topologies.7.md +6 -6
  100. package/docs/multi-agent-trust-policy-audit.7.md +11 -11
  101. package/docs/node-snapshot-diff-replay.7.md +4 -0
  102. package/docs/observability-cost-accounting.7.md +4 -0
  103. package/docs/operator-ux.7.md +34 -34
  104. package/docs/pipeline-runner.7.md +4 -4
  105. package/docs/project-index.md +31 -4
  106. package/docs/real-execution-backends.7.md +4 -0
  107. package/docs/release-and-migration.7.md +5 -1
  108. package/docs/release-tooling.7.md +22 -0
  109. package/docs/routines.md +4 -4
  110. package/docs/run-registry-control-plane.7.md +23 -19
  111. package/docs/run-retention-reclamation.7.md +24 -3
  112. package/docs/scheduled-tasks.md +14 -14
  113. package/docs/security-trust-hardening.7.md +15 -15
  114. package/docs/state-explosion-management.7.md +14 -10
  115. package/docs/team-collaboration.7.md +4 -0
  116. package/docs/trust-audit-anchor.7.md +4 -0
  117. package/docs/unix-principles.md +3 -1
  118. package/docs/verifier-gated-commit.7.md +1 -1
  119. package/docs/web-desktop-workbench.7.md +4 -0
  120. package/docs/workflow-app-framework.7.md +13 -13
  121. package/manifest/plugin.manifest.json +1 -1
  122. package/package.json +3 -2
  123. package/scripts/agents/claude-p-agent.js +2 -2
  124. package/scripts/block-unapproved-tag.sh +42 -6
  125. package/scripts/bump-version.js +27 -10
  126. package/scripts/canonical-apps.js +4 -4
  127. package/scripts/children/batch-delegate-child.js +52 -2
  128. package/scripts/dogfood-release.js +1 -1
  129. package/scripts/fake-date-for-reproduction.js +44 -0
  130. package/scripts/golden-path.js +4 -4
  131. package/scripts/purity-baseline.json +0 -27
  132. package/scripts/release-flow.js +194 -13
  133. package/scripts/release-oneclick.js +407 -0
  134. package/scripts/verdict-keygen.js +83 -0
  135. package/scripts/verify-bump-reproduction.sh +148 -0
  136. package/scripts/verify-verdict-signature.js +61 -0
  137. package/scripts/version-sync-check.js +39 -22
  138. package/skills/cool-workflow/SKILL.md +9 -9
  139. package/skills/cool-workflow/references/commands.md +89 -88
  140. package/ui/workbench/app.css +37 -1
  141. package/ui/workbench/app.js +124 -6
@@ -43,9 +43,11 @@ var __importStar = (this && this.__importStar) || (function () {
43
43
  Object.defineProperty(exports, "__esModule", { value: true });
44
44
  exports.reportBundleCli = reportBundleCli;
45
45
  exports.reportVerifyBundleCli = reportVerifyBundleCli;
46
+ exports.formatReportVerifyBundle = formatReportVerifyBundle;
46
47
  const path = __importStar(require("node:path"));
47
48
  const run_export_1 = require("./run-export");
48
49
  const run_store_1 = require("./run-store");
50
+ const term_1 = require("./term");
49
51
  function optionalString(value) {
50
52
  return typeof value === "string" && value.trim() ? value.trim() : undefined;
51
53
  }
@@ -99,3 +101,46 @@ function reportVerifyBundleCli(args) {
99
101
  requireSignatures: Boolean(args["require-signatures"] || args.requireSignatures || args.requireSigs),
100
102
  });
101
103
  }
104
+ /** `cw report verify-bundle`'s human render (default; `--json` prints
105
+ * `ReportBundleVerification` verbatim instead). Mirrors `doctor`'s
106
+ * checks-list-then-verdict shape (`shell/doctor.ts`'s
107
+ * `formatDoctorReport`) rather than `demo bundle`'s narrative one
108
+ * (`shell/telemetry-demo.ts`'s `formatBundleDemo`) — verify-bundle is a
109
+ * flat set of pass/fail checks on one already-sealed archive, not a
110
+ * multi-step forge-and-catch demonstration. */
111
+ function formatReportVerifyBundle(r) {
112
+ const lines = [(0, term_1.bold)(`cw report verify-bundle ${r.archivePath}`), ""];
113
+ const checks = [
114
+ [r.archiveOk, "archive intact — file digests match, no tamper"],
115
+ [r.telemetryVerified, "telemetry hash chain verifies"],
116
+ [r.trustAuditVerified, "trust-audit chain verifies"],
117
+ [r.reportFindingsVerified, "report.md matches every signed result"],
118
+ ];
119
+ for (const [ok, label] of checks)
120
+ lines.push(` ${(0, term_1.doctorGlyph)(ok ? "ok" : "fail")} ${label}`);
121
+ const sigDetail = r.signatureKeyProvided
122
+ ? `${r.signaturesReverified}/${r.signaturesChecked} signature(s) reverified (key source: ${r.trustKeySource})`
123
+ : "no public key available — signatures not checked";
124
+ lines.push(` trust: ${r.trustLevel} — ${sigDetail}`);
125
+ if (r.reportExtractedTo)
126
+ lines.push(` report.md extracted to: ${r.reportExtractedTo}`);
127
+ if (r.failedChecks.length > 0) {
128
+ lines.push("");
129
+ lines.push(" Failed checks");
130
+ for (const c of r.failedChecks) {
131
+ // Most check sites in run-export.ts's verifyReportBundle set `code`
132
+ // to a short slug (e.g. "digest-mismatch"); the top-level restore
133
+ // catch-all sets it to the caught error's full message instead
134
+ // (run-export.ts's "restore" failedChecks.push) — too long/sentence-
135
+ // shaped to cram into "name [code]" without reading as a run-on.
136
+ // Give it its own indented line instead.
137
+ const isSlug = c.code !== undefined && c.code.length <= 40 && !c.code.includes(" ");
138
+ lines.push(` - ${c.name}${isSlug ? ` [${c.code}]` : ""}`);
139
+ if (c.code !== undefined && !isSlug)
140
+ lines.push(` ${c.code}`);
141
+ }
142
+ }
143
+ lines.push("");
144
+ lines.push(`${(0, term_1.doctorGlyph)(r.ok ? "ok" : "fail")} ${r.ok ? "bundle verifies" : "bundle verification FAILED"}`);
145
+ return lines.join("\n");
146
+ }
@@ -338,7 +338,8 @@ function writeReport(run) {
338
338
  "| Phase | Status | Completed | Total |",
339
339
  "| --- | --- | ---: | ---: |",
340
340
  ...run.phases.map((phase) => {
341
- const phaseTasks = run.tasks.filter((t) => phase.taskIds.includes(t.id));
341
+ const taskIds = new Set(phase.taskIds);
342
+ const phaseTasks = run.tasks.filter((t) => taskIds.has(t.id));
342
343
  const completed = phaseTasks.filter((t) => t.status === "completed").length;
343
344
  return `| ${phase.name} | ${phase.status} | ${completed} | ${phaseTasks.length} |`;
344
345
  }),
@@ -87,6 +87,7 @@ exports.DEFAULT_RUN_REGISTRY_POLICY = {
87
87
  reclaimStates: ["completed", "failed"],
88
88
  keepSnapshots: false,
89
89
  keepScratch: false,
90
+ keepCommits: false,
90
91
  maxReclaimRuns: 0,
91
92
  maxReclaimBytes: 0,
92
93
  };
@@ -207,10 +208,25 @@ function clampInt(value, fallback, min) {
207
208
  return Math.max(min, Math.floor(n));
208
209
  }
209
210
  let queueCounter = 0;
210
- function queueId() {
211
- queueCounter += 1;
211
+ /** Byte format is `q-${stamp14}-${NNN}` (SPEC/scheduling-registry.md "Id
212
+ * formats"), unchanged. `queueCounter` alone is not enough to keep ids
213
+ * unique: it resets to 0 for every fresh `cw` process, so two SEPARATE
214
+ * processes calling `queue add` within the same second both compute
215
+ * `queueCounter === 1` and mint the IDENTICAL id — `sched lease` then
216
+ * keys grants by id, so both queue entries get treated as one, breaking
217
+ * through `maxConcurrent`. `existingIds` is the current queue file's ids
218
+ * (read inside queueAdd's own file lock, so this sees every entry any
219
+ * earlier-completed `queue add` — same process or not — has already
220
+ * saved); bumping past any collision against that real, shared state
221
+ * keeps ids unique across processes without changing the format. */
222
+ function queueId(existingIds) {
212
223
  const stamp = new Date().toISOString().replace(/[-:.TZ]/g, "").slice(0, 14);
213
- return `q-${stamp}-${String(queueCounter).padStart(3, "0")}`;
224
+ let candidate;
225
+ do {
226
+ queueCounter += 1;
227
+ candidate = `q-${stamp}-${String(queueCounter).padStart(3, "0")}`;
228
+ } while (existingIds && existingIds.has(candidate));
229
+ return candidate;
214
230
  }
215
231
  function isRunLifecycleState(value) {
216
232
  return (typeof value === "string" &&
@@ -397,7 +413,7 @@ class RunRegistry {
397
413
  const entries = this.loadQueueEntries();
398
414
  const entry = {
399
415
  schemaVersion: 1,
400
- id: options.id || queueId(),
416
+ id: options.id || queueId(new Set(entries.map((e) => e.id))),
401
417
  runId: options.runId,
402
418
  appId: options.appId,
403
419
  workflowId: options.workflowId,
@@ -462,6 +478,13 @@ class RunRegistry {
462
478
  const result = (0, run_store_1.loadRunStateFile)(statePath, { dryRun: true });
463
479
  if (result.report.status === "unsupported")
464
480
  return null;
481
+ // Bulk scans (refreshRegistry, below) call deriveRecord directly and
482
+ // tolerate a null the same way as missing/unsupported — one bad run
483
+ // must not stop a listing of everything else. Single-run lookups
484
+ // (deriveRecordForRun, below) re-check this same condition WITHOUT
485
+ // swallowing it, so `cw run show`/`archive`/`rerun` on THIS run
486
+ // specifically get a clear reason instead of a misleading "not found."
487
+ (0, run_store_1.assertNotSuspectedDataLoss)(path.basename(runDir), result);
465
488
  run = result.run;
466
489
  }
467
490
  catch {
@@ -609,7 +632,7 @@ class RunRegistry {
609
632
  if (staleRuns.length || missingRuns.length)
610
633
  status = "stale";
611
634
  }
612
- const refreshCmd = scope === "home" ? "node scripts/cw.js registry refresh --scope home" : "node scripts/cw.js registry refresh";
635
+ const refreshCmd = scope === "home" ? "cw registry refresh --scope home" : "cw registry refresh";
613
636
  return {
614
637
  schemaVersion: 1,
615
638
  scope,
@@ -624,7 +647,7 @@ class RunRegistry {
624
647
  },
625
648
  index: current,
626
649
  counts: current.counts,
627
- nextAction: status === "valid" ? "node scripts/cw.js run search" : refreshCmd,
650
+ nextAction: status === "valid" ? "cw run search" : refreshCmd,
628
651
  };
629
652
  }
630
653
  search(raw = {}) {
@@ -657,7 +680,7 @@ class RunRegistry {
657
680
  offset: query.offset,
658
681
  limit: query.limit,
659
682
  records: page,
660
- nextAction: report.freshness.status === "valid" ? "node scripts/cw.js run show <run-id>" : "node scripts/cw.js registry refresh",
683
+ nextAction: report.freshness.status === "valid" ? "cw run show <run-id>" : "cw registry refresh",
661
684
  };
662
685
  }
663
686
  list(options = {}) {
@@ -680,7 +703,7 @@ class RunRegistry {
680
703
  resolvedFrom: located.from,
681
704
  repo: located.record.repo,
682
705
  record: located.record,
683
- nextAction: located.record.archived ? "node scripts/cw.js run resume " + runId : "node scripts/cw.js run show " + runId,
706
+ nextAction: located.record.archived ? "cw run resume " + runId : "cw run show " + runId,
684
707
  };
685
708
  }
686
709
  const persisted = this.findPersisted(runId, scope);
@@ -691,10 +714,11 @@ class RunRegistry {
691
714
  freshness: "missing",
692
715
  repo: persisted?.repo,
693
716
  persisted,
694
- nextAction: "node scripts/cw.js registry refresh" + (scope === "home" ? " --scope home" : ""),
717
+ nextAction: "cw registry refresh" + (scope === "home" ? " --scope home" : ""),
695
718
  };
696
719
  }
697
720
  locate(runId, scope) {
721
+ (0, fs_atomic_1.assertSafeRunId)(runId);
698
722
  const here = this.deriveRecordForRun(this.repoRoot, runId);
699
723
  if (here)
700
724
  return { record: here, from: "repo" };
@@ -711,9 +735,35 @@ class RunRegistry {
711
735
  }
712
736
  deriveRecordForRun(repo, runId) {
713
737
  const runDir = path.join(this.repoRunsDir(repo), runId);
714
- if (!fs.existsSync(path.join(runDir, "state.json")))
738
+ const statePath = path.join(runDir, "state.json");
739
+ if (!fs.existsSync(statePath))
715
740
  return null;
716
- return this.deriveRecord(repo, runDir);
741
+ const record = this.deriveRecord(repo, runDir);
742
+ if (record)
743
+ return record;
744
+ // deriveRecord returned null even though state.json existed a moment
745
+ // ago: it failed to parse, was flagged unsupported, tripped
746
+ // suspected-data-loss corroboration, or (a narrow race) a concurrent
747
+ // process deleted it in between. Re-derive without swallowing so a
748
+ // genuine corruption reaches THIS single-run lookup (backing `cw run
749
+ // show`/`archive`/`rerun`) instead of the misleading "not found" a bulk
750
+ // scan is content with — but if the file is ACTUALLY gone by the time
751
+ // we check again, that is genuinely missing, not corrupt, and must
752
+ // resolve to null like everywhere else, never a raw "File not found"
753
+ // exception surfacing from this fallback re-read.
754
+ try {
755
+ const result = (0, run_store_1.loadRunStateFile)(statePath, { dryRun: true });
756
+ if (result.report.status === "unsupported") {
757
+ throw new Error(`Run state for ${runId} is corrupt (fail closed): ${result.report.errors.join("; ") || "unsupported run state"}. Restore ${statePath} from a backup, or remove the run directory to start over.`);
758
+ }
759
+ (0, run_store_1.assertNotSuspectedDataLoss)(runId, result);
760
+ return record;
761
+ }
762
+ catch (error) {
763
+ if (!fs.existsSync(statePath))
764
+ return null;
765
+ throw error;
766
+ }
717
767
  }
718
768
  findPersisted(runId, scope) {
719
769
  for (const s of scope === "home" ? ["home", "repo"] : ["repo"]) {
@@ -725,6 +775,7 @@ class RunRegistry {
725
775
  return undefined;
726
776
  }
727
777
  loadRun(repo, runId) {
778
+ (0, fs_atomic_1.assertSafeRunId)(runId);
728
779
  const statePath = path.join(this.repoRunsDir(repo), runId, "state.json");
729
780
  if (!fs.existsSync(statePath))
730
781
  throw new Error(`Run not found: ${runId}`);
@@ -732,6 +783,7 @@ class RunRegistry {
732
783
  if (result.report.status === "unsupported") {
733
784
  throw new Error(`Unsupported run state for ${runId}: ${result.report.errors.join("; ")}`);
734
785
  }
786
+ (0, run_store_1.assertNotSuspectedDataLoss)(runId, result);
735
787
  return result.run;
736
788
  }
737
789
  resume(runId, options = {}) {
@@ -752,23 +804,23 @@ class RunRegistry {
752
804
  const nextActions = [];
753
805
  if (nextTasks.length) {
754
806
  nextActions.push({
755
- command: `node scripts/cw.js dispatch ${runId} --cwd ${record.repo}`,
807
+ command: `cw dispatch ${runId} --cwd ${record.repo}`,
756
808
  reason: `Continue ${nextTasks.length} pending/running task(s) from durable state.`,
757
809
  });
758
810
  nextActions.push({
759
- command: `node scripts/cw.js multi-agent step ${runId} --cwd ${record.repo}`,
811
+ command: `cw multi-agent step ${runId} --cwd ${record.repo}`,
760
812
  reason: "Take one deterministic host step without spawning agents.",
761
813
  });
762
814
  }
763
815
  else if (record.derivedLifecycle === "failed") {
764
816
  nextActions.push({
765
- command: `node scripts/cw.js run rerun ${runId}`,
817
+ command: `cw run rerun ${runId}`,
766
818
  reason: "Run terminated as failed with no runnable tasks; rerun as a new linked run.",
767
819
  });
768
820
  }
769
821
  else {
770
822
  nextActions.push({
771
- command: `node scripts/cw.js status ${runId} --cwd ${record.repo} --json`,
823
+ command: `cw status ${runId} --cwd ${record.repo} --json`,
772
824
  reason: "No runnable tasks remain; inspect status.",
773
825
  });
774
826
  }
@@ -807,7 +859,14 @@ class RunRegistry {
807
859
  }
808
860
  (0, fs_atomic_1.writeJson)(file, overlay, { durable: true });
809
861
  });
810
- const record = this.deriveRecord(repo, located.record.runDir);
862
+ // deriveRecordForRun (not a bare deriveRecord(...)!) so a run that
863
+ // became corrupt/unsupported/suspected-data-loss in the narrow window
864
+ // between locate() above and this re-fetch gets the same clear,
865
+ // specific error every other single-run lookup gets, instead of a raw
866
+ // TypeError from a non-null assertion on a surprise null.
867
+ const record = this.deriveRecordForRun(repo, runId);
868
+ if (!record)
869
+ throw new Error(`Cannot archive: run ${runId} state became unavailable while archiving (fail closed).`);
811
870
  return {
812
871
  runId,
813
872
  repo,
@@ -877,8 +936,8 @@ class RunRegistry {
877
936
  pendingTasks: newRun.tasks.filter((t) => t.status === "pending").length,
878
937
  provenance,
879
938
  nextActions: [
880
- { command: `node scripts/cw.js run resume ${newRun.id}`, reason: "Continue the new linked run." },
881
- { command: `node scripts/cw.js run show ${runId}`, reason: "The original failed run is preserved for audit." },
939
+ { command: `cw run resume ${newRun.id}`, reason: "Continue the new linked run." },
940
+ { command: `cw run show ${runId}`, reason: "The original failed run is preserved for audit." },
882
941
  ],
883
942
  };
884
943
  }
@@ -918,7 +977,7 @@ class RunRegistry {
918
977
  limit,
919
978
  repos: index.repos,
920
979
  entries,
921
- nextAction: report.freshness.status === "valid" ? "node scripts/cw.js run show <run-id>" : "node scripts/cw.js registry refresh --scope home",
980
+ nextAction: report.freshness.status === "valid" ? "cw run show <run-id>" : "cw registry refresh --scope home",
922
981
  };
923
982
  }
924
983
  }
@@ -49,6 +49,7 @@ exports.hashArtifactFile = hashArtifactFile;
49
49
  exports.loadRunStateFile = loadRunStateFile;
50
50
  exports.checkRunStateFile = checkRunStateFile;
51
51
  exports.migrateRunStateFile = migrateRunStateFile;
52
+ exports.assertNotSuspectedDataLoss = assertNotSuspectedDataLoss;
52
53
  exports.loadRunFromCwd = loadRunFromCwd;
53
54
  exports.withRunStateLock = withRunStateLock;
54
55
  exports.saveCheckpoint = saveCheckpoint;
@@ -98,19 +99,90 @@ function migrateRunStateFile(statePath, options = {}) {
98
99
  }
99
100
  return result;
100
101
  }
102
+ /** ONLY tasksDir/commitsDir (plus the audit event log, checked separately
103
+ * below) — deliberately NOT every directory a run's `paths` can carry.
104
+ * Verified empirically (via test/run-fixture-compat-smoke.js's real
105
+ * fixtures) that several other candidate directories are unsafe signals:
106
+ * a plain `cw status`/`cw graph` READ already writes cache/derived files
107
+ * as a side effect — `audit/summary.json` + `audit/index.json`
108
+ * (summarizeTrustAudit, called from the status/report path) are non-empty
109
+ * the moment ANY read touches the run, and `blackboard/`/`candidates/`/
110
+ * `topologies/` subdirectories get created (though left empty) the same
111
+ * way. Using those as "real content" signals would refuse to load a
112
+ * perfectly healthy, merely-already-viewed-once run — report.md is the
113
+ * same story (a derived rendering of state, not independent evidence).
114
+ * tasksDir/commitsDir are the two directories confirmed to hold content
115
+ * ONLY from genuine task-dispatch/commit actions, never from an
116
+ * otherwise-read-only command. */
117
+ function contentDirs(paths) {
118
+ return [paths.tasksDir, paths.commitsDir];
119
+ }
120
+ /** True when the run directory already holds real task/commit files, or a
121
+ * non-empty audit event log — i.e. this was NOT a brand-new run dir. Used
122
+ * only to corroborate `report.suspectedDataLoss`: a bare
123
+ * `{workflow, paths}`-less state.json is unremarkable for a run dir that
124
+ * has nothing else in it either (e.g. a run whose creation crashed before
125
+ * anything else was written), but is a strong corruption signal when real
126
+ * content already sits next to it. A directory entry must not START WITH
127
+ * "." to count — cw never writes dot-prefixed names into these
128
+ * directories, so incidental filesystem debris (a stray `.DS_Store`) never
129
+ * by itself makes a genuinely fresh run look corrupted. */
130
+ function hasPreexistingRunContent(run) {
131
+ for (const dir of contentDirs(run.paths)) {
132
+ try {
133
+ if (fs.readdirSync(dir).some((name) => !name.startsWith(".")))
134
+ return true;
135
+ }
136
+ catch {
137
+ /* missing dir — not a signal either way */
138
+ }
139
+ }
140
+ try {
141
+ const eventLogPath = run.audit?.eventLogPath;
142
+ if (eventLogPath && fs.statSync(eventLogPath).size > 0)
143
+ return true;
144
+ }
145
+ catch {
146
+ /* missing/empty audit log — not a signal */
147
+ }
148
+ return false;
149
+ }
150
+ /** Throws when `result.report.suspectedDataLoss` is true AND the run
151
+ * directory already has real content on disk — see
152
+ * `hasPreexistingRunContent`. Shared by every state.json reader
153
+ * (`loadRunFromCwd` here, `RunRegistry.loadRun` in run-registry-io.ts) so
154
+ * a corrupted/wiped state.json is refused the same way regardless of
155
+ * which entry point reached it. */
156
+ function assertNotSuspectedDataLoss(runId, result) {
157
+ if (result.report.suspectedDataLoss && hasPreexistingRunContent(result.run)) {
158
+ throw new Error(`Refusing to load run ${runId}: state.json is missing its core fields (workflow, paths), but the run directory already has task, commit, or other content on disk. This looks like state.json was corrupted, truncated, or replaced by something outside cw, not a new run. Restore state.json from a backup, or remove the run directory to start over.`);
159
+ }
160
+ }
101
161
  /** Refuses an empty id with `Missing run id`; loads
102
162
  * `<cwd>/.cw/runs/<runId>/state.json` (dry-run — never writes); throws
103
- * `Unsupported CW run state: <errors joined by "; ">` on an unsupported
104
- * verdict; else returns the migrated WorkflowRun in memory. */
163
+ * `Run not found: <runId>` when no such run directory has a state.json
164
+ * (every caller report, status, audit, eval snapshot, and more — used
165
+ * to leak the raw `readJson` message, "File not found: <absolute
166
+ * path>/state.json", which named neither the run nor a next step, and
167
+ * didn't contain any of the words `cli/entry.ts`'s `recoveryHint` scans
168
+ * for, so no `Try: cw run list` line ever appeared); throws `Unsupported
169
+ * CW run state: <errors joined by "; ">` on an unsupported verdict;
170
+ * refuses a state.json that lost its core fields (workflow, paths) while
171
+ * the run dir still has real content next to it, rather than silently
172
+ * returning it as a fresh empty run; else returns the migrated
173
+ * WorkflowRun in memory. */
105
174
  function loadRunFromCwd(runId, cwd = process.cwd()) {
106
175
  if (!runId)
107
176
  throw new Error("Missing run id");
108
177
  (0, fs_atomic_1.assertSafeRunId)(runId);
109
178
  const statePath = path.join(cwd, ".cw", "runs", runId, "state.json");
179
+ if (!fs.existsSync(statePath))
180
+ throw new Error(`Run not found: ${runId}`);
110
181
  const result = loadRunStateFile(statePath, { dryRun: true });
111
182
  if (result.report.status === "unsupported") {
112
183
  throw new Error(`Unsupported CW run state: ${result.report.errors.join("; ")}`);
113
184
  }
185
+ assertNotSuspectedDataLoss(runId, result);
114
186
  return result.run;
115
187
  }
116
188
  /** Hold the state.json lock over a WHOLE load -> change -> save cycle.
@@ -67,6 +67,7 @@ const fs = __importStar(require("node:fs"));
67
67
  const path = __importStar(require("node:path"));
68
68
  const fs_atomic_1 = require("./fs-atomic");
69
69
  const run_registry_io_1 = require("./run-registry-io");
70
+ const numeric_flag_1 = require("../core/util/numeric-flag");
70
71
  exports.SCHEDULING_SCHEMA_VERSION = 1;
71
72
  /** Conservative fail-closed defaults: serial, bounded retries, exponential backoff. */
72
73
  exports.DEFAULT_SCHEDULING_POLICY = {
@@ -232,14 +233,7 @@ function loadSchedulingPolicy(registry) {
232
233
  return { schemaVersion: 1, policy: normalizeSchedulingPolicy(parsed), source: "file" };
233
234
  }
234
235
  function numericFlag(options, key) {
235
- if (!(key in options))
236
- return undefined;
237
- const raw = options[key];
238
- const n = Number(raw);
239
- if (!Number.isFinite(n)) {
240
- throw new Error(`Invalid --${key} "${raw}": expected a number (e.g. --${key} 4)`);
241
- }
242
- return n;
236
+ return (0, numeric_flag_1.requiredNumberFlag)(options[key], `--${key}`);
243
237
  }
244
238
  function schedPlanCli(options = {}) {
245
239
  const registry = new run_registry_io_1.RunRegistry(resolveCwd(options));
@@ -250,7 +244,7 @@ function schedLeaseCli(options = {}) {
250
244
  const registry = new run_registry_io_1.RunRegistry(resolveCwd(options));
251
245
  const now = nowIso(options);
252
246
  const policy = loadSchedulingPolicy(registry).policy;
253
- const limit = options.limit === undefined ? undefined : Number(options.limit);
247
+ const limit = (0, numeric_flag_1.requiredNumberFlag)(options.limit, "--limit");
254
248
  return (0, fs_atomic_1.withFileLock)(registry.queueFilePath(), () => {
255
249
  const { entries, leases } = applyLease(registry.loadQueueEntries(), policy, now, limit);
256
250
  registry.saveQueueEntries(entries);
@@ -305,16 +299,21 @@ function schedPolicyShowCli(options = {}) {
305
299
  }
306
300
  function schedPolicySetCli(options = {}) {
307
301
  const registry = new run_registry_io_1.RunRegistry(resolveCwd(options));
308
- const current = loadSchedulingPolicy(registry).policy;
309
302
  const patch = {};
310
303
  for (const key of ["maxConcurrent", "maxAttempts", "leaseTtlMs", "backoffBaseMs", "backoffFactor", "backoffCapMs"]) {
311
304
  const value = numericFlag(options, key);
312
305
  if (value !== undefined)
313
306
  patch[key] = value;
314
307
  }
315
- const policy = normalizeSchedulingPolicy({ ...current, ...patch });
316
- (0, fs_atomic_1.writeJson)(registry.schedulingPolicyPath(), policy);
317
- return { schemaVersion: 1, policy, source: "file" };
308
+ // Read-modify-write under the policy file's own lock: two concurrent
309
+ // `sched policy set` calls patching different fields must not drop each
310
+ // other's write.
311
+ return (0, fs_atomic_1.withFileLock)(registry.schedulingPolicyPath(), () => {
312
+ const current = loadSchedulingPolicy(registry).policy;
313
+ const policy = normalizeSchedulingPolicy({ ...current, ...patch });
314
+ (0, fs_atomic_1.writeJson)(registry.schedulingPolicyPath(), policy);
315
+ return { schemaVersion: 1, policy, source: "file" };
316
+ });
318
317
  }
319
318
  function isTrue(value) {
320
319
  return value === true || value === "true" || value === "1";
@@ -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 || [];
@@ -134,7 +134,7 @@ function refreshStateExplosionSummaries(run, options = {}) {
134
134
  generatedAt: now,
135
135
  status: "valid",
136
136
  deterministic: true,
137
- nextAction: `node scripts/cw.js summary show ${run.id}`,
137
+ nextAction: `cw summary show ${run.id}`,
138
138
  entries: entries.sort((a, b) => (0, collate_1.stableCompare)(a.id, b.id)),
139
139
  views,
140
140
  paths: { summariesDir: dir, indexPath: path.join(dir, "index.json"), reportPath },
@@ -184,15 +184,23 @@ function loadRun(runId, options = {}) {
184
184
  * only (the run-state checkpoint write is real and load-bearing today;
185
185
  * report.md rendering is not). */
186
186
  function summaryRefreshCli(runId, options = {}) {
187
- const run = loadRun(runId, options);
188
- const index = refreshStateExplosionSummaries(run);
189
- (0, run_store_1.saveCheckpoint)(run);
190
- 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
+ });
191
195
  }
192
- /** `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). */
193
203
  function summaryShowCli(runId, options = {}) {
194
204
  const run = loadRun(runId, options);
195
- const report = showStateExplosionSummary(run);
196
- (0, run_store_1.saveCheckpoint)(run);
197
- return report;
205
+ return showStateExplosionSummary(run);
198
206
  }
@@ -71,6 +71,23 @@ function topologyRunPath(run, id) {
71
71
  const safe = id.replace(/[^a-zA-Z0-9_.:-]+/g, "_");
72
72
  return path.join(topologyRoot(run), "runs", `${safe}.json`);
73
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
+ }
74
91
  function ensureTopologyState(run) {
75
92
  run.paths.topologiesDir = topologyRoot(run);
76
93
  fs.mkdirSync(run.paths.topologiesDir, { recursive: true });
@@ -90,8 +107,13 @@ function persistTopologyState(run) {
90
107
  counts: { runs: state.runs.length },
91
108
  runs: state.runs.map((record) => ({ id: record.id, topologyId: record.topologyId, status: record.status, updatedAt: record.updatedAt })),
92
109
  });
93
- for (const record of state.runs)
94
- (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();
95
117
  }
96
118
  function now() {
97
119
  return new Date().toISOString();
@@ -238,6 +260,7 @@ function applyTopology(run, topologyId, input = {}) {
238
260
  metadata: compact({ ...(input.metadata || {}), topology: definition }),
239
261
  };
240
262
  state.runs.push(record);
263
+ dirtyTopologyIds(state).add(record.id);
241
264
  appendTopologyNode(run, record, topo.statusToNodeStatus(record.status));
242
265
  (0, trust_audit_1.recordTrustAuditEvent)(run, {
243
266
  kind: "topology.verdict",
@@ -272,7 +295,15 @@ function summarizeTopologies(run) {
272
295
  const multi = run.multiAgent || {};
273
296
  const fanins = multi.fanins || [];
274
297
  const active = state.runs.map((record) => {
275
- 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 || ""));
276
307
  const allFaninIds = topo.unique([...record.faninIds, ...inferredFanins.map((fanin) => fanin.id)]);
277
308
  const blocked = inferredFanins.filter((fanin) => fanin.status === "blocked" || !fanin.verifierReady);
278
309
  const ready = inferredFanins.some((fanin) => fanin.verifierReady);
@@ -291,7 +322,7 @@ function summarizeTopologies(run) {
291
322
  missingEvidence,
292
323
  conflicts: record.conflicts,
293
324
  readiness: ready ? "fanin ready" : missingEvidence.length ? "missing evidence" : "awaiting worker output",
294
- 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,
295
326
  };
296
327
  });
297
328
  return {
@@ -300,7 +331,7 @@ function summarizeTopologies(run) {
300
331
  runsByStatus: countBy(active, (record) => record.status),
301
332
  officialTopologies: topo.listTopologyDefinitions().map((definition) => definition.id),
302
333
  active,
303
- 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>`,
304
335
  };
305
336
  }
306
337
  function buildTopologyGraph(run) {