cool-workflow 0.2.2 → 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 (136) 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 +11 -5
  10. package/dist/cli/entry.js +41 -1
  11. package/dist/cli/io.js +6 -2
  12. package/dist/cli/parseargv.js +1 -0
  13. package/dist/core/capability-data.js +1 -0
  14. package/dist/core/format/completion.js +68 -0
  15. package/dist/core/format/help.js +25 -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/numeric-flag.js +40 -0
  30. package/dist/core/version.js +1 -1
  31. package/dist/mcp/server.js +99 -11
  32. package/dist/shell/audit-cli.js +57 -25
  33. package/dist/shell/coordinator-io.js +73 -13
  34. package/dist/shell/dispatch.js +1 -1
  35. package/dist/shell/doctor.js +80 -1
  36. package/dist/shell/drive.js +243 -49
  37. package/dist/shell/eval-text.js +2 -2
  38. package/dist/shell/evidence-reasoning.js +4 -4
  39. package/dist/shell/execution-backend/agent.js +20 -1
  40. package/dist/shell/execution-backend/container.js +4 -1
  41. package/dist/shell/execution-backend/local.js +4 -1
  42. package/dist/shell/feedback-cli.js +6 -6
  43. package/dist/shell/fs-atomic.js +218 -29
  44. package/dist/shell/man-cli.js +6 -0
  45. package/dist/shell/metrics-cli.js +2 -1
  46. package/dist/shell/multi-agent-cli.js +367 -323
  47. package/dist/shell/multi-agent-host.js +9 -9
  48. package/dist/shell/multi-agent-operator-ux.js +80 -38
  49. package/dist/shell/node-store.js +10 -4
  50. package/dist/shell/observability.js +1 -1
  51. package/dist/shell/operator-ux-text.js +22 -22
  52. package/dist/shell/operator-ux.js +15 -14
  53. package/dist/shell/orchestrator.js +49 -38
  54. package/dist/shell/pipeline-cli.js +87 -40
  55. package/dist/shell/reclamation-io.js +14 -4
  56. package/dist/shell/registry-cli.js +19 -17
  57. package/dist/shell/remote-source.js +13 -8
  58. package/dist/shell/report-cli.js +45 -0
  59. package/dist/shell/report.js +2 -1
  60. package/dist/shell/run-registry-io.js +77 -19
  61. package/dist/shell/run-store.js +74 -2
  62. package/dist/shell/scheduling-io.js +12 -13
  63. package/dist/shell/state-cli.js +2 -7
  64. package/dist/shell/state-explosion-cli.js +17 -9
  65. package/dist/shell/topology-io.js +36 -5
  66. package/dist/shell/trust-audit.js +224 -22
  67. package/dist/shell/trust-policy-io.js +1 -1
  68. package/dist/shell/worker-cli.js +35 -31
  69. package/dist/wiring/capability-table/basics.js +33 -8
  70. package/dist/wiring/capability-table/exec-backend.js +20 -11
  71. package/dist/wiring/capability-table/multi-agent.js +172 -165
  72. package/dist/wiring/capability-table/parity.js +1 -0
  73. package/dist/wiring/capability-table/pipeline.js +54 -42
  74. package/dist/wiring/capability-table/registry-core.js +29 -10
  75. package/dist/wiring/capability-table/reporting.js +137 -96
  76. package/dist/wiring/capability-table/scheduling-registry.js +195 -161
  77. package/dist/wiring/capability-table/state.js +36 -28
  78. package/dist/wiring/capability-table/trust-ledger.js +46 -22
  79. package/dist/wiring/capability-table/workflow-apps.js +83 -68
  80. package/docs/agent-delegation-drive.7.md +2 -0
  81. package/docs/candidate-scoring.7.md +1 -1
  82. package/docs/canonical-workflow-apps.7.md +7 -7
  83. package/docs/cli-mcp-parity.7.md +8 -3
  84. package/docs/contract-migration-tooling.7.md +2 -0
  85. package/docs/control-plane-scheduling.7.md +2 -0
  86. package/docs/coordinator-blackboard.7.md +17 -17
  87. package/docs/dogfood-one-real-repo.7.md +11 -11
  88. package/docs/durable-state-and-locking.7.md +2 -0
  89. package/docs/end-to-end-golden-path.7.md +14 -14
  90. package/docs/evidence-adoption-reasoning-chain.7.md +2 -0
  91. package/docs/execution-backends.7.md +2 -0
  92. package/docs/getting-started.md +37 -37
  93. package/docs/multi-agent-cli-mcp-surface.7.md +19 -17
  94. package/docs/multi-agent-eval-replay-harness.7.md +15 -13
  95. package/docs/multi-agent-operator-ux.7.md +21 -19
  96. package/docs/multi-agent-runtime-core.7.md +22 -22
  97. package/docs/multi-agent-topologies.7.md +6 -6
  98. package/docs/multi-agent-trust-policy-audit.7.md +11 -11
  99. package/docs/node-snapshot-diff-replay.7.md +2 -0
  100. package/docs/observability-cost-accounting.7.md +2 -0
  101. package/docs/operator-ux.7.md +34 -34
  102. package/docs/pipeline-runner.7.md +4 -4
  103. package/docs/project-index.md +27 -4
  104. package/docs/real-execution-backends.7.md +2 -0
  105. package/docs/release-and-migration.7.md +3 -1
  106. package/docs/release-tooling.7.md +2 -0
  107. package/docs/routines.md +4 -4
  108. package/docs/run-registry-control-plane.7.md +21 -19
  109. package/docs/run-retention-reclamation.7.md +2 -0
  110. package/docs/scheduled-tasks.md +14 -14
  111. package/docs/security-trust-hardening.7.md +15 -15
  112. package/docs/state-explosion-management.7.md +12 -10
  113. package/docs/team-collaboration.7.md +2 -0
  114. package/docs/trust-audit-anchor.7.md +2 -0
  115. package/docs/unix-principles.md +3 -1
  116. package/docs/verifier-gated-commit.7.md +1 -1
  117. package/docs/web-desktop-workbench.7.md +2 -0
  118. package/docs/workflow-app-framework.7.md +13 -13
  119. package/manifest/plugin.manifest.json +1 -1
  120. package/package.json +2 -2
  121. package/scripts/agents/claude-p-agent.js +2 -2
  122. package/scripts/block-unapproved-tag.sh +23 -2
  123. package/scripts/canonical-apps.js +4 -4
  124. package/scripts/children/batch-delegate-child.js +52 -2
  125. package/scripts/dogfood-release.js +1 -1
  126. package/scripts/fake-date-for-reproduction.js +44 -0
  127. package/scripts/golden-path.js +4 -4
  128. package/scripts/purity-baseline.json +3 -0
  129. package/scripts/release-flow.js +57 -1
  130. package/scripts/verdict-keygen.js +83 -0
  131. package/scripts/verify-bump-reproduction.sh +148 -0
  132. package/scripts/verify-verdict-signature.js +61 -0
  133. package/skills/cool-workflow/SKILL.md +9 -9
  134. package/skills/cool-workflow/references/commands.md +89 -88
  135. package/ui/workbench/app.css +37 -1
  136. package/ui/workbench/app.js +124 -6
@@ -207,10 +207,25 @@ function clampInt(value, fallback, min) {
207
207
  return Math.max(min, Math.floor(n));
208
208
  }
209
209
  let queueCounter = 0;
210
- function queueId() {
211
- queueCounter += 1;
210
+ /** Byte format is `q-${stamp14}-${NNN}` (SPEC/scheduling-registry.md "Id
211
+ * formats"), unchanged. `queueCounter` alone is not enough to keep ids
212
+ * unique: it resets to 0 for every fresh `cw` process, so two SEPARATE
213
+ * processes calling `queue add` within the same second both compute
214
+ * `queueCounter === 1` and mint the IDENTICAL id — `sched lease` then
215
+ * keys grants by id, so both queue entries get treated as one, breaking
216
+ * through `maxConcurrent`. `existingIds` is the current queue file's ids
217
+ * (read inside queueAdd's own file lock, so this sees every entry any
218
+ * earlier-completed `queue add` — same process or not — has already
219
+ * saved); bumping past any collision against that real, shared state
220
+ * keeps ids unique across processes without changing the format. */
221
+ function queueId(existingIds) {
212
222
  const stamp = new Date().toISOString().replace(/[-:.TZ]/g, "").slice(0, 14);
213
- return `q-${stamp}-${String(queueCounter).padStart(3, "0")}`;
223
+ let candidate;
224
+ do {
225
+ queueCounter += 1;
226
+ candidate = `q-${stamp}-${String(queueCounter).padStart(3, "0")}`;
227
+ } while (existingIds && existingIds.has(candidate));
228
+ return candidate;
214
229
  }
215
230
  function isRunLifecycleState(value) {
216
231
  return (typeof value === "string" &&
@@ -397,7 +412,7 @@ class RunRegistry {
397
412
  const entries = this.loadQueueEntries();
398
413
  const entry = {
399
414
  schemaVersion: 1,
400
- id: options.id || queueId(),
415
+ id: options.id || queueId(new Set(entries.map((e) => e.id))),
401
416
  runId: options.runId,
402
417
  appId: options.appId,
403
418
  workflowId: options.workflowId,
@@ -462,6 +477,13 @@ class RunRegistry {
462
477
  const result = (0, run_store_1.loadRunStateFile)(statePath, { dryRun: true });
463
478
  if (result.report.status === "unsupported")
464
479
  return null;
480
+ // Bulk scans (refreshRegistry, below) call deriveRecord directly and
481
+ // tolerate a null the same way as missing/unsupported — one bad run
482
+ // must not stop a listing of everything else. Single-run lookups
483
+ // (deriveRecordForRun, below) re-check this same condition WITHOUT
484
+ // swallowing it, so `cw run show`/`archive`/`rerun` on THIS run
485
+ // specifically get a clear reason instead of a misleading "not found."
486
+ (0, run_store_1.assertNotSuspectedDataLoss)(path.basename(runDir), result);
465
487
  run = result.run;
466
488
  }
467
489
  catch {
@@ -609,7 +631,7 @@ class RunRegistry {
609
631
  if (staleRuns.length || missingRuns.length)
610
632
  status = "stale";
611
633
  }
612
- const refreshCmd = scope === "home" ? "node scripts/cw.js registry refresh --scope home" : "node scripts/cw.js registry refresh";
634
+ const refreshCmd = scope === "home" ? "cw registry refresh --scope home" : "cw registry refresh";
613
635
  return {
614
636
  schemaVersion: 1,
615
637
  scope,
@@ -624,7 +646,7 @@ class RunRegistry {
624
646
  },
625
647
  index: current,
626
648
  counts: current.counts,
627
- nextAction: status === "valid" ? "node scripts/cw.js run search" : refreshCmd,
649
+ nextAction: status === "valid" ? "cw run search" : refreshCmd,
628
650
  };
629
651
  }
630
652
  search(raw = {}) {
@@ -657,7 +679,7 @@ class RunRegistry {
657
679
  offset: query.offset,
658
680
  limit: query.limit,
659
681
  records: page,
660
- nextAction: report.freshness.status === "valid" ? "node scripts/cw.js run show <run-id>" : "node scripts/cw.js registry refresh",
682
+ nextAction: report.freshness.status === "valid" ? "cw run show <run-id>" : "cw registry refresh",
661
683
  };
662
684
  }
663
685
  list(options = {}) {
@@ -680,7 +702,7 @@ class RunRegistry {
680
702
  resolvedFrom: located.from,
681
703
  repo: located.record.repo,
682
704
  record: located.record,
683
- nextAction: located.record.archived ? "node scripts/cw.js run resume " + runId : "node scripts/cw.js run show " + runId,
705
+ nextAction: located.record.archived ? "cw run resume " + runId : "cw run show " + runId,
684
706
  };
685
707
  }
686
708
  const persisted = this.findPersisted(runId, scope);
@@ -691,10 +713,11 @@ class RunRegistry {
691
713
  freshness: "missing",
692
714
  repo: persisted?.repo,
693
715
  persisted,
694
- nextAction: "node scripts/cw.js registry refresh" + (scope === "home" ? " --scope home" : ""),
716
+ nextAction: "cw registry refresh" + (scope === "home" ? " --scope home" : ""),
695
717
  };
696
718
  }
697
719
  locate(runId, scope) {
720
+ (0, fs_atomic_1.assertSafeRunId)(runId);
698
721
  const here = this.deriveRecordForRun(this.repoRoot, runId);
699
722
  if (here)
700
723
  return { record: here, from: "repo" };
@@ -711,9 +734,35 @@ class RunRegistry {
711
734
  }
712
735
  deriveRecordForRun(repo, runId) {
713
736
  const runDir = path.join(this.repoRunsDir(repo), runId);
714
- if (!fs.existsSync(path.join(runDir, "state.json")))
737
+ const statePath = path.join(runDir, "state.json");
738
+ if (!fs.existsSync(statePath))
715
739
  return null;
716
- return this.deriveRecord(repo, runDir);
740
+ const record = this.deriveRecord(repo, runDir);
741
+ if (record)
742
+ return record;
743
+ // deriveRecord returned null even though state.json existed a moment
744
+ // ago: it failed to parse, was flagged unsupported, tripped
745
+ // suspected-data-loss corroboration, or (a narrow race) a concurrent
746
+ // process deleted it in between. Re-derive without swallowing so a
747
+ // genuine corruption reaches THIS single-run lookup (backing `cw run
748
+ // show`/`archive`/`rerun`) instead of the misleading "not found" a bulk
749
+ // scan is content with — but if the file is ACTUALLY gone by the time
750
+ // we check again, that is genuinely missing, not corrupt, and must
751
+ // resolve to null like everywhere else, never a raw "File not found"
752
+ // exception surfacing from this fallback re-read.
753
+ try {
754
+ const result = (0, run_store_1.loadRunStateFile)(statePath, { dryRun: true });
755
+ if (result.report.status === "unsupported") {
756
+ 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.`);
757
+ }
758
+ (0, run_store_1.assertNotSuspectedDataLoss)(runId, result);
759
+ return record;
760
+ }
761
+ catch (error) {
762
+ if (!fs.existsSync(statePath))
763
+ return null;
764
+ throw error;
765
+ }
717
766
  }
718
767
  findPersisted(runId, scope) {
719
768
  for (const s of scope === "home" ? ["home", "repo"] : ["repo"]) {
@@ -725,6 +774,7 @@ class RunRegistry {
725
774
  return undefined;
726
775
  }
727
776
  loadRun(repo, runId) {
777
+ (0, fs_atomic_1.assertSafeRunId)(runId);
728
778
  const statePath = path.join(this.repoRunsDir(repo), runId, "state.json");
729
779
  if (!fs.existsSync(statePath))
730
780
  throw new Error(`Run not found: ${runId}`);
@@ -732,6 +782,7 @@ class RunRegistry {
732
782
  if (result.report.status === "unsupported") {
733
783
  throw new Error(`Unsupported run state for ${runId}: ${result.report.errors.join("; ")}`);
734
784
  }
785
+ (0, run_store_1.assertNotSuspectedDataLoss)(runId, result);
735
786
  return result.run;
736
787
  }
737
788
  resume(runId, options = {}) {
@@ -752,23 +803,23 @@ class RunRegistry {
752
803
  const nextActions = [];
753
804
  if (nextTasks.length) {
754
805
  nextActions.push({
755
- command: `node scripts/cw.js dispatch ${runId} --cwd ${record.repo}`,
806
+ command: `cw dispatch ${runId} --cwd ${record.repo}`,
756
807
  reason: `Continue ${nextTasks.length} pending/running task(s) from durable state.`,
757
808
  });
758
809
  nextActions.push({
759
- command: `node scripts/cw.js multi-agent step ${runId} --cwd ${record.repo}`,
810
+ command: `cw multi-agent step ${runId} --cwd ${record.repo}`,
760
811
  reason: "Take one deterministic host step without spawning agents.",
761
812
  });
762
813
  }
763
814
  else if (record.derivedLifecycle === "failed") {
764
815
  nextActions.push({
765
- command: `node scripts/cw.js run rerun ${runId}`,
816
+ command: `cw run rerun ${runId}`,
766
817
  reason: "Run terminated as failed with no runnable tasks; rerun as a new linked run.",
767
818
  });
768
819
  }
769
820
  else {
770
821
  nextActions.push({
771
- command: `node scripts/cw.js status ${runId} --cwd ${record.repo} --json`,
822
+ command: `cw status ${runId} --cwd ${record.repo} --json`,
772
823
  reason: "No runnable tasks remain; inspect status.",
773
824
  });
774
825
  }
@@ -807,7 +858,14 @@ class RunRegistry {
807
858
  }
808
859
  (0, fs_atomic_1.writeJson)(file, overlay, { durable: true });
809
860
  });
810
- const record = this.deriveRecord(repo, located.record.runDir);
861
+ // deriveRecordForRun (not a bare deriveRecord(...)!) so a run that
862
+ // became corrupt/unsupported/suspected-data-loss in the narrow window
863
+ // between locate() above and this re-fetch gets the same clear,
864
+ // specific error every other single-run lookup gets, instead of a raw
865
+ // TypeError from a non-null assertion on a surprise null.
866
+ const record = this.deriveRecordForRun(repo, runId);
867
+ if (!record)
868
+ throw new Error(`Cannot archive: run ${runId} state became unavailable while archiving (fail closed).`);
811
869
  return {
812
870
  runId,
813
871
  repo,
@@ -877,8 +935,8 @@ class RunRegistry {
877
935
  pendingTasks: newRun.tasks.filter((t) => t.status === "pending").length,
878
936
  provenance,
879
937
  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." },
938
+ { command: `cw run resume ${newRun.id}`, reason: "Continue the new linked run." },
939
+ { command: `cw run show ${runId}`, reason: "The original failed run is preserved for audit." },
882
940
  ],
883
941
  };
884
942
  }
@@ -918,7 +976,7 @@ class RunRegistry {
918
976
  limit,
919
977
  repos: index.repos,
920
978
  entries,
921
- nextAction: report.freshness.status === "valid" ? "node scripts/cw.js run show <run-id>" : "node scripts/cw.js registry refresh --scope home",
979
+ nextAction: report.freshness.status === "valid" ? "cw run show <run-id>" : "cw registry refresh --scope home",
922
980
  };
923
981
  }
924
982
  }
@@ -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) {