cool-workflow 0.2.4 → 0.2.6

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 (155) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/.codex-plugin/plugin.json +1 -1
  3. package/.gemini-plugin/mcp.json +10 -0
  4. package/.gemini-plugin/plugin.json +40 -0
  5. package/.opencode-plugin/mcp.json +10 -0
  6. package/.opencode-plugin/plugin.json +40 -0
  7. package/README.md +204 -48
  8. package/apps/architecture-review/app.json +1 -1
  9. package/apps/architecture-review-fast/app.json +1 -1
  10. package/apps/end-to-end-golden-path/app.json +1 -1
  11. package/apps/pr-review-fix-ci/app.json +1 -1
  12. package/apps/release-cut/app.json +1 -1
  13. package/apps/research-synthesis/app.json +1 -1
  14. package/dist/cli/dispatch.js +32 -4
  15. package/dist/cli/entry.js +11 -19
  16. package/dist/cli/global-flags.js +113 -0
  17. package/dist/cli/parseargv.js +7 -1
  18. package/dist/core/capability-data.js +337 -8
  19. package/dist/core/format/help.js +73 -3
  20. package/dist/core/format/recovery-hint.js +32 -0
  21. package/dist/core/multi-agent/collaboration.js +35 -6
  22. package/dist/core/multi-agent/runtime.js +7 -0
  23. package/dist/core/multi-agent/trust-policy.js +7 -1
  24. package/dist/core/pipeline/contract.js +7 -0
  25. package/dist/core/pipeline/error-feedback.js +2 -2
  26. package/dist/core/state/run-paths.js +3 -30
  27. package/dist/core/trust/evidence-grounding.js +13 -1
  28. package/dist/core/util/cli-args.js +22 -0
  29. package/dist/core/version.js +1 -1
  30. package/dist/mcp/dispatch.js +22 -2
  31. package/dist/mcp/server.js +215 -36
  32. package/dist/mcp/tool-process.js +181 -0
  33. package/dist/mcp-server.js +28 -1
  34. package/dist/shell/agent-config.js +11 -2
  35. package/dist/shell/audit-cli.js +1 -1
  36. package/dist/shell/commit.js +8 -2
  37. package/dist/shell/coordinator-io.js +73 -1
  38. package/dist/shell/drive.js +195 -128
  39. package/dist/shell/error-feedback-io.js +6 -0
  40. package/dist/shell/execution-backend/agent.js +201 -26
  41. package/dist/shell/execution-backend/container.js +44 -10
  42. package/dist/shell/execution-backend/local.js +33 -0
  43. package/dist/shell/fs-atomic.js +96 -12
  44. package/dist/shell/ledger-cli.js +9 -2
  45. package/dist/shell/metrics-cli.js +1 -1
  46. package/dist/shell/multi-agent-cli.js +9 -2
  47. package/dist/shell/multi-agent-operator-ux.js +1 -1
  48. package/dist/shell/observability.js +7 -4
  49. package/dist/shell/onramp.js +48 -5
  50. package/dist/shell/operator-ux.js +1 -1
  51. package/dist/shell/perf-trace.js +136 -0
  52. package/dist/shell/pipeline.js +3 -2
  53. package/dist/shell/reclamation-io.js +10 -9
  54. package/dist/shell/report-view-cli.js +2 -0
  55. package/dist/shell/run-export-cli.js +5 -2
  56. package/dist/shell/run-export.js +293 -31
  57. package/dist/shell/run-registry-io.js +8 -0
  58. package/dist/shell/run-store.js +183 -3
  59. package/dist/shell/scheduler-io.js +101 -10
  60. package/dist/shell/state-explosion-cli.js +4 -1
  61. package/dist/shell/telemetry-demo.js +1 -1
  62. package/dist/shell/telemetry-ledger-io.js +36 -24
  63. package/dist/shell/trust-audit.js +178 -36
  64. package/dist/shell/workbench-host.js +121 -10
  65. package/dist/shell/workbench.js +83 -6
  66. package/dist/shell/worker-isolation.js +1 -1
  67. package/dist/wiring/capability-table/basics.js +5 -0
  68. package/dist/wiring/capability-table/exec-backend.js +40 -22
  69. package/dist/wiring/capability-table/parity.js +10 -5
  70. package/dist/wiring/capability-table/pipeline.js +32 -0
  71. package/dist/wiring/capability-table/registry-core.js +26 -3
  72. package/dist/wiring/capability-table/reporting.js +7 -1
  73. package/dist/wiring/capability-table/scheduling-registry.js +8 -2
  74. package/dist/wiring/capability-table/trust-ledger.js +54 -12
  75. package/docs/agent-delegation-drive.7.md +17 -0
  76. package/docs/cli-mcp-parity.7.md +46 -5
  77. package/docs/contract-migration-tooling.7.md +4 -0
  78. package/docs/control-plane-scheduling.7.md +38 -0
  79. package/docs/cross-agent-ledger.7.md +20 -8
  80. package/docs/durable-state-and-locking.7.md +4 -0
  81. package/docs/evidence-adoption-reasoning-chain.7.md +4 -0
  82. package/docs/execution-backends.7.md +4 -0
  83. package/docs/fix.7.md +4 -4
  84. package/docs/getting-started.md +40 -32
  85. package/docs/index.md +17 -0
  86. package/docs/launch/demo.tape +4 -3
  87. package/docs/mcp-app-surface.7.md +27 -0
  88. package/docs/multi-agent-cli-mcp-surface.7.md +4 -0
  89. package/docs/multi-agent-eval-replay-harness.7.md +4 -0
  90. package/docs/multi-agent-operator-ux.7.md +4 -0
  91. package/docs/node-snapshot-diff-replay.7.md +4 -0
  92. package/docs/observability-cost-accounting.7.md +4 -0
  93. package/docs/project-index.md +53 -9
  94. package/docs/real-execution-backends.7.md +4 -0
  95. package/docs/release-and-migration.7.md +4 -0
  96. package/docs/release-tooling.7.md +12 -0
  97. package/docs/routine.7.md +22 -0
  98. package/docs/run-registry-control-plane.7.md +37 -9
  99. package/docs/run-retention-reclamation.7.md +12 -4
  100. package/docs/state-explosion-management.7.md +4 -0
  101. package/docs/team-collaboration.7.md +4 -0
  102. package/docs/trust-audit-anchor.7.md +4 -0
  103. package/docs/web-desktop-workbench.7.md +72 -2
  104. package/docs/workflow-app-framework.7.md +31 -0
  105. package/manifest/plugin.manifest.json +1 -1
  106. package/manifest/source-context-profiles.json +4 -3
  107. package/package.json +6 -3
  108. package/scripts/agents/agent-adapter-core.js +26 -0
  109. package/scripts/agents/claude-p-agent.js +4 -1
  110. package/scripts/agents/codex-agent.js +4 -0
  111. package/scripts/agents/cw-attest-wrap.js +1 -1
  112. package/scripts/agents/gemini-agent.js +4 -0
  113. package/scripts/agents/opencode-agent.js +5 -0
  114. package/scripts/block-unapproved-tag.js +160 -0
  115. package/scripts/canonical-apps.js +4 -4
  116. package/scripts/children/batch-delegate-child.js +72 -4
  117. package/scripts/children/http-batch-delegate-child.js +132 -0
  118. package/scripts/children/http-delegate-child.js +8 -0
  119. package/scripts/dogfood-release.js +2 -2
  120. package/scripts/fake-date-for-reproduction.js +1 -1
  121. package/scripts/gen-manifests.js +32 -61
  122. package/scripts/golden-path.js +4 -4
  123. package/scripts/lang-policy-check.js +82 -0
  124. package/scripts/parity-check.js +42 -23
  125. package/scripts/purity-baseline.json +0 -3
  126. package/scripts/purity-gate.js +16 -3
  127. package/scripts/release-check.js +2 -1
  128. package/scripts/release-flow.js +169 -41
  129. package/scripts/release-gate.js +208 -0
  130. package/scripts/release-oneclick.js +38 -7
  131. package/scripts/release-tags.js +47 -0
  132. package/scripts/schema-version-inventory.json +31 -0
  133. package/scripts/validate-run-state-schema.js +95 -4
  134. package/scripts/verdict-keygen.js +2 -2
  135. package/scripts/verify-bump-reproduction.js +193 -0
  136. package/scripts/verify-release-verdict.js +139 -0
  137. package/scripts/verify-verdict-signature.js +1 -1
  138. package/ui/workbench/app.css +50 -10
  139. package/ui/workbench/app.js +269 -34
  140. package/ui/workbench/index.html +4 -2
  141. package/ui/workbench/inspection.js +51 -0
  142. package/ui/workbench/navigation.js +44 -0
  143. package/docs/agent-framework.md +0 -177
  144. package/docs/designs/handoff-ledger.md +0 -145
  145. package/docs/dogfood/resume-drive-real-agent-2026-06-14.md +0 -40
  146. package/docs/launch/launch-kit.md +0 -195
  147. package/docs/launch/pre-launch-checklist.md +0 -53
  148. package/docs/readme-v0.1.87-full.md +0 -301
  149. package/docs/routines.md +0 -101
  150. package/docs/scheduled-tasks.md +0 -80
  151. package/scripts/agents/claude-p-agent.sh +0 -9
  152. package/scripts/block-unapproved-tag.sh +0 -75
  153. package/scripts/release-gate.sh +0 -94
  154. package/scripts/verify-bump-reproduction.sh +0 -148
  155. package/scripts/verify-container-selfref.js +0 -64
@@ -62,6 +62,7 @@ exports.trustAuditHead = trustAuditHead;
62
62
  exports.verifyTrustAudit = verifyTrustAudit;
63
63
  exports.repairTrustAuditTornTail = repairTrustAuditTornTail;
64
64
  exports.recordTrustAuditEvent = recordTrustAuditEvent;
65
+ exports.withTrustAuditBatch = withTrustAuditBatch;
65
66
  exports.recordSandboxPathDecision = recordSandboxPathDecision;
66
67
  exports.normalizeEvidence = normalizeEvidence;
67
68
  exports.writeTrustAuditIndexPlaceholder = writeTrustAuditIndexPlaceholder;
@@ -85,8 +86,15 @@ function ensureTrustAudit(run) {
85
86
  run.paths.auditDir = dir;
86
87
  const audit = { schemaVersion: 1, ...trustAuditPaths(run) };
87
88
  run.audit = audit;
89
+ // Create-if-missing must NEVER truncate: this runs OUTSIDE the append
90
+ // lock, so between an existsSync(false) here and a plain create, another
91
+ // process may have already created the log AND appended real events —
92
+ // a truncating create then silently deletes them (one event lost, chain
93
+ // still verifies "clean" from genesis; seen once on CI under coverage
94
+ // I/O). Flag "a" is O_CREAT without O_TRUNC: it makes the file when
95
+ // missing and adds zero bytes when not, with no exists-check window.
88
96
  if (!fs.existsSync(audit.eventLogPath))
89
- fs.writeFileSync(audit.eventLogPath, "", "utf8");
97
+ fs.writeFileSync(audit.eventLogPath, "", { encoding: "utf8", flag: "a" });
90
98
  return audit;
91
99
  }
92
100
  /** Genesis prevHash for a run's chain (no prior event). Exported so
@@ -107,6 +115,7 @@ function computeEventHash(event) {
107
115
  void eventHash;
108
116
  return (0, hash_1.sha256)((0, hash_1.eventHashInput)(rest));
109
117
  }
118
+ const ACTIVE_AUDIT_BATCHES = new Map();
110
119
  function tailCachePathFor(eventLogPath) {
111
120
  return path.join(path.dirname(eventLogPath), "tail-cache.json");
112
121
  }
@@ -467,34 +476,90 @@ function recordTrustAuditEvent(run, input) {
467
476
  parentEventIds: unique(input.parentEventIds || []).sort(),
468
477
  metadata: scrubMetadata(input.metadata || {}),
469
478
  });
479
+ const batch = ACTIVE_AUDIT_BATCHES.get(path.resolve(audit.eventLogPath));
480
+ if (batch)
481
+ return appendTrustAuditEvent(run, input, event, batch);
470
482
  return (0, fs_atomic_1.withFileLock)(audit.eventLogPath, () => {
471
- // The prior event count and last-event hash are the ONLY two things
472
- // this append needs from the existing log. A tail cache (keyed on the
473
- // log's own byte size) serves both without a full parse when nothing
474
- // else has touched the log since it was written; any size mismatch
475
- // (a repair, a torn write, this being the very first append) falls
476
- // back to the full parse, same as before this cache existed.
477
- const tailCachePath = tailCachePathFor(audit.eventLogPath);
478
483
  const currentBytes = fs.existsSync(audit.eventLogPath) ? fs.statSync(audit.eventLogPath).size : 0;
479
- const cached = readAuditTailCache(tailCachePath);
480
- let count;
481
- let prevHash;
482
- if (cached && cached.logBytes === currentBytes) {
483
- count = cached.count;
484
- prevHash = cached.lastHash;
484
+ const cached = readAuditTailCache(tailCachePathFor(audit.eventLogPath));
485
+ const prior = cached && cached.logBytes === currentBytes ? undefined : readEventsRaw(audit.eventLogPath);
486
+ const state = {
487
+ audit,
488
+ currentBytes,
489
+ count: cached && cached.logBytes === currentBytes ? cached.count : prior.length,
490
+ lastHash: cached && cached.logBytes === currentBytes ? cached.lastHash : (prior.length ? prior[prior.length - 1].eventHash || computeEventHash(prior[prior.length - 1]) : trustAuditGenesis(run.id)),
491
+ lines: [],
492
+ };
493
+ const result = appendTrustAuditEvent(run, input, event, state);
494
+ flushTrustAuditBatch(state);
495
+ return result;
496
+ });
497
+ }
498
+ function appendTrustAuditEvent(run, input, event, batch) {
499
+ // The prior event count and last-event hash are the ONLY two things
500
+ // this append needs from the existing log. A tail cache (keyed on the
501
+ // log's own byte size) serves both without a full parse when nothing
502
+ // else has touched the log since it was written; any size mismatch
503
+ // (a repair, a torn write, this being the very first append) falls
504
+ // back to the full parse, same as before this cache existed.
505
+ event.id = createEventId(input.kind, batch.count);
506
+ event.prevEventHash = batch.lastHash;
507
+ event.eventHash = computeEventHash(event);
508
+ // Newline-boundary safety (fail-closed). `durableAppendFileSync` only ever
509
+ // ADDS bytes at the end of file and never writes a separator of its own. A
510
+ // completed append always leaves the log ending in "\n"; if the last byte
511
+ // is NOT "\n", the previous append was torn by a crash (its bytes were
512
+ // never a confirmed event — the append never returned). Writing this new,
513
+ // already-cross-linked event straight onto that partial byte-run would
514
+ // MERGE the two into one line that no longer parses — losing THIS event and
515
+ // poisoning the forward chain (the next append's prevEventHash would point
516
+ // into an unparseable blob), with no repair for that shape. So put the new
517
+ // event on its own clean line: prepend a "\n" when the log does not already
518
+ // end in one, confining any crash artifact to its own now-orphaned line.
519
+ // (Reads only the last byte, so the O(1) tail-cache path is preserved.)
520
+ // An empty log (currentBytes === 0, e.g. the first append) has no tail to
521
+ // merge with, so it never needs a leading newline.
522
+ const leadingNewline = batch.currentBytes > 0 && batch.lines.length === 0 && !(0, fs_atomic_1.logEndsWithNewline)(batch.audit.eventLogPath, batch.currentBytes) ? "\n" : "";
523
+ const line = `${leadingNewline}${JSON.stringify(event)}\n`;
524
+ batch.lines.push(line);
525
+ batch.currentBytes += Buffer.byteLength(line, "utf8");
526
+ batch.count += 1;
527
+ batch.lastHash = event.eventHash;
528
+ return event;
529
+ }
530
+ function flushTrustAuditBatch(batch) {
531
+ if (!batch.lines.length)
532
+ return;
533
+ (0, fs_atomic_1.durableAppendFileSync)(batch.audit.eventLogPath, batch.lines.join(""));
534
+ writeAuditTailCache(tailCachePathFor(batch.audit.eventLogPath), { schemaVersion: 1, logBytes: batch.currentBytes, count: batch.count, lastHash: batch.lastHash });
535
+ }
536
+ /** Run a short, synchronous mutation group under one audit lock and append its
537
+ * exact NDJSON lines with one durable write before the caller checkpoints. */
538
+ function withTrustAuditBatch(run, fn) {
539
+ const audit = ensureTrustAudit(run);
540
+ const key = path.resolve(audit.eventLogPath);
541
+ if (ACTIVE_AUDIT_BATCHES.has(key))
542
+ return fn();
543
+ return (0, fs_atomic_1.withFileLock)(audit.eventLogPath, () => {
544
+ const currentBytes = fs.existsSync(audit.eventLogPath) ? fs.statSync(audit.eventLogPath).size : 0;
545
+ const cached = readAuditTailCache(tailCachePathFor(audit.eventLogPath));
546
+ const prior = cached && cached.logBytes === currentBytes ? undefined : readEventsRaw(audit.eventLogPath);
547
+ const batch = {
548
+ audit,
549
+ currentBytes,
550
+ count: cached && cached.logBytes === currentBytes ? cached.count : prior.length,
551
+ lastHash: cached && cached.logBytes === currentBytes ? cached.lastHash : (prior.length ? prior[prior.length - 1].eventHash || computeEventHash(prior[prior.length - 1]) : trustAuditGenesis(run.id)),
552
+ lines: [],
553
+ };
554
+ ACTIVE_AUDIT_BATCHES.set(key, batch);
555
+ try {
556
+ const result = fn();
557
+ flushTrustAuditBatch(batch);
558
+ return result;
485
559
  }
486
- else {
487
- const prior = readEventsRaw(audit.eventLogPath);
488
- count = prior.length;
489
- prevHash = prior.length ? prior[prior.length - 1].eventHash || computeEventHash(prior[prior.length - 1]) : trustAuditGenesis(run.id);
560
+ finally {
561
+ ACTIVE_AUDIT_BATCHES.delete(key);
490
562
  }
491
- event.id = createEventId(input.kind, count);
492
- event.prevEventHash = prevHash;
493
- event.eventHash = computeEventHash(event);
494
- const line = `${JSON.stringify(event)}\n`;
495
- (0, fs_atomic_1.durableAppendFileSync)(audit.eventLogPath, line);
496
- writeAuditTailCache(tailCachePath, { schemaVersion: 1, logBytes: currentBytes + Buffer.byteLength(line, "utf8"), count: count + 1, lastHash: event.eventHash });
497
- return event;
498
563
  });
499
564
  }
500
565
  function recordSandboxPathDecision(run, input) {
@@ -634,9 +699,68 @@ function commitRows(events, run) {
634
699
  };
635
700
  });
636
701
  }
637
- function summarizeTrustAudit(run) {
638
- const audit = ensureTrustAudit(run);
639
- const events = readEventsRaw(audit.eventLogPath);
702
+ // Perf cycle P1 (read side): every read command (`cw status`, each
703
+ // `writeReport` call, multi-agent status) called `summarizeTrustAudit`,
704
+ // which read+parsed the log via `readEventsRaw`, THEN called
705
+ // `verifyTrustAudit(run)`, which read+parsed the SAME log AGAIN and
706
+ // recomputed a sha256 for every event -- two full passes for one call.
707
+ // It then durably (fsync) rewrote summary.json AND index.json on EVERY
708
+ // call, even when nothing about the run or the log had changed since the
709
+ // last call (a live audit reaches ~50k events, so `writeReport` alone --
710
+ // which calls this once per pipeline step and per feedback op -- turned
711
+ // into a real, repeated cost).
712
+ //
713
+ // Fix, in two parts:
714
+ // 1. Read the log ONCE (`readEventsRawCounted`) and hand the SAME parsed
715
+ // array to `verifyEventsChain` directly -- the exact same check
716
+ // `verifyTrustAudit` runs with no anchor, so the integrity result is
717
+ // byte-identical to before. `verifyTrustAudit` itself is untouched and
718
+ // every OTHER caller (doctor.ts, `cw audit verify`, drive.ts,
719
+ // run-export.ts) still gets a real, independent, always-full check.
720
+ // 2. Skip the durable rewrite of summary.json/index.json when the freshly
721
+ // and FULLY recomputed content is unchanged from last time. This is
722
+ // NOT a shortcut on verification -- the full parse+rehash+chain-walk
723
+ // above always runs, on every call, no exceptions. A cache that
724
+ // reused a stale "verified" verdict would be unsound here: this
725
+ // codebase's chain check is a sequential field-comparison walk, not a
726
+ // content-addressed (Merkle) chain, so tampering with an EARLY event
727
+ // can leave the file's total size and its LAST event's bytes fully
728
+ // unchanged while still flipping `verified` to false. So the cache
729
+ // below only ever gates the DISK WRITE of an already fully-verified
730
+ // result, never the verification itself, and the function's RETURN
731
+ // VALUE is always the fresh `summary` object either way.
732
+ function summaryFingerprintPathFor(summaryPath) {
733
+ return path.join(path.dirname(summaryPath), "summary-fingerprint.json");
734
+ }
735
+ function readSummaryFingerprint(fingerprintPath) {
736
+ if (!fs.existsSync(fingerprintPath))
737
+ return undefined;
738
+ try {
739
+ const parsed = JSON.parse(fs.readFileSync(fingerprintPath, "utf8"));
740
+ if (parsed && parsed.schemaVersion === 1 && typeof parsed.hash === "string")
741
+ return parsed.hash;
742
+ }
743
+ catch {
744
+ // Corrupt/unreadable fingerprint -- fall back to a real rewrite below.
745
+ }
746
+ return undefined;
747
+ }
748
+ function writeSummaryFingerprint(fingerprintPath, fingerprint) {
749
+ try {
750
+ (0, fs_atomic_1.writeJson)(fingerprintPath, fingerprint);
751
+ }
752
+ catch {
753
+ // Best-effort: a failed fingerprint write must never break the real call
754
+ // -- it just means the next call falls back to a real rewrite.
755
+ }
756
+ }
757
+ function summarizeTrustAudit(run, options = {}) {
758
+ const persist = options.persist !== false;
759
+ // A read-only projection must not call ensureTrustAudit(): that helper is
760
+ // deliberately for mutation paths and creates the audit directory and an
761
+ // empty event log when they are absent.
762
+ const audit = persist ? ensureTrustAudit(run) : { schemaVersion: 1, ...trustAuditPaths(run) };
763
+ const { events, corruptLines } = readEventsRawCounted(audit.eventLogPath);
640
764
  const ma = run.multiAgent;
641
765
  const bb = run.blackboard;
642
766
  const summary = {
@@ -644,7 +768,7 @@ function summarizeTrustAudit(run) {
644
768
  runId: run.id,
645
769
  generatedAt: new Date().toISOString(),
646
770
  eventCount: events.length,
647
- integrity: verifyTrustAudit(run),
771
+ integrity: verifyEventsChain(run.id, events, corruptLines),
648
772
  eventLogPath: audit.eventLogPath,
649
773
  indexPath: audit.indexPath,
650
774
  summaryPath: audit.summaryPath,
@@ -688,11 +812,7 @@ function summarizeTrustAudit(run) {
688
812
  policyViolations: events.filter((e) => e.kind === "policy.violation").length,
689
813
  },
690
814
  };
691
- // Durable: the summary/index are the read-side view of the audit log; persist
692
- // them durably so a crash can't leave them pointing past the last durably-
693
- // appended event. Byte-behavior port of the old build's summarizeTrustAudit.
694
- (0, fs_atomic_1.writeJson)(audit.summaryPath, summary, { durable: true });
695
- (0, fs_atomic_1.writeJson)(audit.indexPath, {
815
+ const index = {
696
816
  schemaVersion: exports.TRUST_AUDIT_SCHEMA_VERSION,
697
817
  runId: run.id,
698
818
  events: events.map((event) => ({
@@ -707,7 +827,29 @@ function summarizeTrustAudit(run) {
707
827
  sandboxProfileId: event.sandboxProfileId,
708
828
  policyRef: event.policyRef,
709
829
  })),
710
- }, { durable: true });
711
- run.audit = { schemaVersion: exports.TRUST_AUDIT_SCHEMA_VERSION, ...audit };
830
+ };
831
+ // Durable: the summary/index are the read-side view of the audit log; persist
832
+ // them durably so a crash can't leave them pointing past the last durably-
833
+ // appended event. Byte-behavior port of the old build's summarizeTrustAudit.
834
+ //
835
+ // Skip the rewrite only when the freshly, fully recomputed content is
836
+ // unchanged from last time (see the comment on `summaryFingerprintPathFor`
837
+ // above) -- `generatedAt` is dropped before fingerprinting since it is the
838
+ // one field that legitimately changes on every call. The RETURNED
839
+ // `summary` below always carries a real, fresh `generatedAt` either way;
840
+ // only the ON-DISK bytes may keep the previous call's `generatedAt` when
841
+ // nothing else changed.
842
+ const { generatedAt: _generatedAt, ...summaryForFingerprint } = summary;
843
+ void _generatedAt;
844
+ const fingerprintPath = summaryFingerprintPathFor(audit.summaryPath);
845
+ const freshFingerprint = (0, hash_1.stableHash)({ summary: summaryForFingerprint, index });
846
+ const priorFingerprint = readSummaryFingerprint(fingerprintPath);
847
+ if (persist && priorFingerprint !== freshFingerprint) {
848
+ (0, fs_atomic_1.writeJson)(audit.summaryPath, summary, { durable: true });
849
+ (0, fs_atomic_1.writeJson)(audit.indexPath, index, { durable: true });
850
+ writeSummaryFingerprint(fingerprintPath, { schemaVersion: 1, hash: freshFingerprint });
851
+ }
852
+ if (persist)
853
+ run.audit = { schemaVersion: exports.TRUST_AUDIT_SCHEMA_VERSION, ...audit };
712
854
  return summary;
713
855
  }
@@ -52,13 +52,55 @@ var __importStar = (this && this.__importStar) || (function () {
52
52
  })();
53
53
  Object.defineProperty(exports, "__esModule", { value: true });
54
54
  exports.WorkbenchHost = void 0;
55
+ exports.formatServeHint = formatServeHint;
56
+ exports.printServeHint = printServeHint;
55
57
  const crypto = __importStar(require("node:crypto"));
56
58
  const fs = __importStar(require("node:fs"));
57
59
  const http = __importStar(require("node:http"));
58
60
  const path = __importStar(require("node:path"));
59
61
  const node_url_1 = require("node:url");
60
62
  const workbench_1 = require("./workbench");
63
+ // `[::1]` is the form Node's URL parser returns for a bracketed IPv6
64
+ // literal Host header (verified); the bare `::1` covers a non-bracketed
65
+ // header that our fallback split still yields. `.split(":")[0]` alone could
66
+ // never produce either (it returns `[` or ``), so those entries used to be
67
+ // dead — parseHostname below fixes that.
61
68
  const ALLOWED_HOSTNAMES = new Set(["127.0.0.1", "localhost", "::1", "[::1]"]);
69
+ /** Extract the hostname from a Host header, correctly handling a bracketed
70
+ * IPv6 literal (`[::1]:7717`) that a naive `split(":")[0]` mangles into
71
+ * `"["`. Returns undefined when the header cannot be parsed at all. */
72
+ function parseHostname(hostHeader) {
73
+ try {
74
+ return new node_url_1.URL(`http://${hostHeader}`).hostname;
75
+ }
76
+ catch {
77
+ // A non-bracketed IPv6 literal (`::1`) is not a valid URL authority, so
78
+ // URL() throws — fall back to the raw header for that one case; a
79
+ // hostname:port still trims the port.
80
+ if (hostHeader.includes("]") || hostHeader.split(":").length > 2)
81
+ return hostHeader;
82
+ return hostHeader.split(":")[0];
83
+ }
84
+ }
85
+ function isTTY(stream) {
86
+ return Boolean(stream.isTTY);
87
+ }
88
+ /** Pure text for the human-friendly serve hint line. Kept separate from
89
+ * the TTY gate below so it is directly unit-testable with no fake stream
90
+ * at all: just a port number in, one line of text out. */
91
+ function formatServeHint(boundPort) {
92
+ return `workbench serving at http://127.0.0.1:${boundPort} (Ctrl-C to stop)`;
93
+ }
94
+ /** Prints the human hint line to STDERR only, and only on a real
95
+ * interactive terminal — the same TTY-gated-nicety pattern as
96
+ * term.ts's printSuccessSummary: silent when the stream is not a TTY, so
97
+ * a piped/non-interactive run (and the existing STDOUT descriptor line)
98
+ * never changes at all. */
99
+ function printServeHint(boundPort, stream = process.stderr) {
100
+ if (!isTTY(stream))
101
+ return;
102
+ stream.write(`${formatServeHint(boundPort)}\n`);
103
+ }
62
104
  function timingSafeEqual(a, b) {
63
105
  const bufA = Buffer.from(a, "utf8");
64
106
  const bufB = Buffer.from(b, "utf8");
@@ -72,6 +114,13 @@ function timingSafeEqual(a, b) {
72
114
  return crypto.timingSafeEqual(bufA, bufB);
73
115
  }
74
116
  function sendJson(res, status, body) {
117
+ // If a handler already began the response (e.g. an asset read threw AFTER
118
+ // writeHead), a second writeHead throws ERR_HTTP_HEADERS_SENT synchronously
119
+ // inside the request listener and kills the whole server. Guard against it.
120
+ if (res.headersSent) {
121
+ res.end();
122
+ return;
123
+ }
75
124
  const text = `${JSON.stringify(body, null, 2)}\n`;
76
125
  res.writeHead(status, { "Content-Type": "application/json; charset=utf-8", "Cache-Control": "no-store", "X-Content-Type-Options": "nosniff" });
77
126
  res.end(text);
@@ -124,8 +173,8 @@ class WorkbenchHost {
124
173
  try {
125
174
  const hostHeader = req.headers.host;
126
175
  if (hostHeader) {
127
- const hostname = hostHeader.split(":")[0];
128
- if (!ALLOWED_HOSTNAMES.has(hostname)) {
176
+ const hostname = parseHostname(hostHeader);
177
+ if (hostname === undefined || !ALLOWED_HOSTNAMES.has(hostname)) {
129
178
  sendJson(res, 403, { error: "forbidden: non-localhost Host header" });
130
179
  return;
131
180
  }
@@ -143,10 +192,6 @@ class WorkbenchHost {
143
192
  sendJson(res, 400, { error: "bad request: invalid URL" });
144
193
  return;
145
194
  }
146
- if (!this.checkAuth(req, url)) {
147
- sendJson(res, 401, { error: "unauthorized: token mismatch" });
148
- return;
149
- }
150
195
  // Decode the route path ONCE (old build's decodeRoutePath). Node's
151
196
  // `new URL()` does NOT throw on a malformed percent-escape like
152
197
  // `/%E0%A4%A`; only decodeURIComponent does — so this is where a
@@ -162,13 +207,36 @@ class WorkbenchHost {
162
207
  sendJson(res, 400, { error: "bad request: malformed URL path" });
163
208
  return;
164
209
  }
210
+ // Auth is checked AFTER the route is decoded, and only where run data
211
+ // (or environment data) can flow. Before this, a set token made the
212
+ // browser's own follow-up requests for /ui/app.css and /ui/app.js fail
213
+ // 401 — the page rendered as broken unstyled HTML with no explanation.
214
+ // The three shipped UI files are generic static code with no run data,
215
+ // so they are served without a token. Every /api/* route carries run
216
+ // data and stays behind the token. The "/" route is split: an
217
+ // INSTALLED index.html is the same generic static code (open), but the
218
+ // FALLBACK page embeds the serve descriptor — which carries the
219
+ // absolute repo root path — so a missing UI keeps "/" behind the token.
220
+ if (!this.checkAuth(req, url)) {
221
+ const uiIndexInstalled = fs.existsSync(path.resolve((0, workbench_1.workbenchUiRoot)(), "index.html"));
222
+ if (route.startsWith("/api/") || (route === "/" && !uiIndexInstalled)) {
223
+ sendJson(res, 401, { error: "unauthorized: token mismatch" });
224
+ return;
225
+ }
226
+ }
165
227
  if (route === "/api/serve") {
166
228
  sendJson(res, 200, this.descriptor(false));
167
229
  return;
168
230
  }
169
231
  if (route === "/api/index") {
170
232
  const scope = this.args.scope === "home" ? "home" : "repo";
171
- sendJson(res, 200, (0, workbench_1.buildWorkbenchIndex)({ ...this.args, scope, ...Object.fromEntries(url.searchParams.entries()) }));
233
+ // Only `text` may be driven by the query string — it is the sidebar
234
+ // filter. Spreading ALL query params (as before) let a request
235
+ // override the server's computed `scope` or inject an arbitrary
236
+ // `cwd`, reading run data from any directory the serving user can
237
+ // read, past the repo the operator chose to serve.
238
+ const text = url.searchParams.get("text") || undefined;
239
+ sendJson(res, 200, (0, workbench_1.buildWorkbenchIndex)({ ...this.args, scope, ...(text ? { text } : {}) }));
172
240
  return;
173
241
  }
174
242
  const runMatch = route.match(/^\/api\/run\/([^/]+)$/);
@@ -204,15 +272,30 @@ class WorkbenchHost {
204
272
  sendJson(res, 404, { error: `UI asset not installed: ${relative}` });
205
273
  return;
206
274
  }
275
+ // Read the bytes BEFORE writeHead: if the asset vanished or became
276
+ // unreadable between the checks above and here (TOCTOU), the throw
277
+ // then lands on the sendJson(500) path with headers not yet sent,
278
+ // instead of a fatal double-writeHead.
279
+ const bytes = fs.readFileSync(resolved);
207
280
  res.writeHead(200, { "Content-Type": contentTypeFor(resolved), "Cache-Control": "no-store", "X-Content-Type-Options": "nosniff" });
208
- res.end(fs.readFileSync(resolved));
281
+ res.end(bytes);
209
282
  }
210
283
  /** Binds the loopback server and resolves once listening. Returns the
211
284
  * actually-bound port (useful for `--port 0`). */
212
285
  listen() {
213
286
  return new Promise((resolve, reject) => {
287
+ // Defense in depth: `run()` already validated the port before ever
288
+ // reaching here, but a direct caller of listen() still fails closed
289
+ // with the clear message rather than node's opaque ERR_SOCKET_BAD_PORT.
290
+ let requestedPort;
291
+ try {
292
+ requestedPort = (0, workbench_1.parseWorkbenchPort)(this.args.port);
293
+ }
294
+ catch (error) {
295
+ reject(error);
296
+ return;
297
+ }
214
298
  const server = http.createServer((req, res) => this.handleRequest(req, res));
215
- const requestedPort = this.args.port !== undefined ? Number(this.args.port) : undefined;
216
299
  server.on("error", reject);
217
300
  server.listen(requestedPort ?? 7717, "127.0.0.1", () => {
218
301
  this.server = server;
@@ -243,14 +326,42 @@ class WorkbenchHost {
243
326
  process.exitCode = 1;
244
327
  return;
245
328
  }
329
+ // Validate --port HERE, before any dispatch. The CLI binding calls this
330
+ // as `void host.run()` (not awaited), so a rejection from listen() would
331
+ // otherwise surface as an unhandled promise rejection stack dump on the
332
+ // real CLI. Fail closed the same clean way as --require-token: one `cw:`
333
+ // stderr line + exit 1 + return, never a crash and never a bound server.
334
+ try {
335
+ (0, workbench_1.parseWorkbenchPort)(this.args.port);
336
+ }
337
+ catch (error) {
338
+ process.stderr.write(`cw: ${error instanceof Error ? error.message : String(error)}\n`);
339
+ process.exitCode = 1;
340
+ return;
341
+ }
246
342
  const once = Boolean(this.args.once || this.args.json);
247
343
  if (once) {
248
344
  process.stdout.write(`${JSON.stringify(this.descriptor(true))}\n`);
249
345
  return;
250
346
  }
251
- const boundPort = await this.listen();
347
+ // The bind itself can still fail at runtime (EADDRINUSE when the port
348
+ // is taken, EACCES on a privileged port) — listen() rejects for those.
349
+ // The port-VALUE check above only covers a malformed --port. Fail
350
+ // closed the same clean way rather than an unhandled-rejection crash.
351
+ let boundPort;
352
+ try {
353
+ boundPort = await this.listen();
354
+ }
355
+ catch (error) {
356
+ const message = error instanceof Error ? error.message : String(error);
357
+ process.stderr.write(`cw: ${message}\n`);
358
+ process.stderr.write(`Try: pick another port with --port <n>, or stop whatever is already serving.\n`);
359
+ process.exitCode = 1;
360
+ return;
361
+ }
252
362
  const descriptor = this.descriptor(false, boundPort);
253
363
  process.stdout.write(`${JSON.stringify({ ...descriptor, boundPort })}\n`);
364
+ printServeHint(boundPort);
254
365
  // Block forever (until the process is killed) — a real serve.
255
366
  await new Promise(() => { });
256
367
  }
@@ -47,6 +47,7 @@ var __importStar = (this && this.__importStar) || (function () {
47
47
  })();
48
48
  Object.defineProperty(exports, "__esModule", { value: true });
49
49
  exports.WORKBENCH_UI_RELATIVE = exports.WORKBENCH_DEFAULT_PORT = void 0;
50
+ exports.parseWorkbenchPort = parseWorkbenchPort;
50
51
  exports.buildWorkbenchRunView = buildWorkbenchRunView;
51
52
  exports.workbenchUiRoot = workbenchUiRoot;
52
53
  exports.buildWorkbenchServeDescriptor = buildWorkbenchServeDescriptor;
@@ -56,6 +57,32 @@ const capability_table_1 = require("../core/capability-table");
56
57
  const run_store_1 = require("./run-store");
57
58
  exports.WORKBENCH_DEFAULT_PORT = 7717;
58
59
  exports.WORKBENCH_UI_RELATIVE = "ui/workbench";
60
+ /** Parse and range-check a workbench `--port` value. Returns `undefined`
61
+ * when no port was given (the caller then uses WORKBENCH_DEFAULT_PORT),
62
+ * the validated integer otherwise. Throws a clear Error on a bad value —
63
+ * a non-number (`NaN`), a float, a negative, or a number over 65535 — so
64
+ * callers fail closed with an actionable line instead of node's opaque
65
+ * ERR_SOCKET_BAD_PORT or a `"port": null` descriptor. A valid port is an
66
+ * integer in [0, 65535]; 0 is the legitimately-supported ephemeral port. */
67
+ function parseWorkbenchPort(raw) {
68
+ if (raw === undefined)
69
+ return undefined;
70
+ const reject = () => {
71
+ throw new Error(`workbench serve --port must be an integer 0-65535 (got ${JSON.stringify(raw)})`);
72
+ };
73
+ // Only a string (the argv form) or a number is a real port. A valueless
74
+ // `--port` flag parses to boolean `true`; reject it rather than let
75
+ // Number(true) === 1 silently bind to port 1. A blank string is bad input
76
+ // too — Number("") === 0 would otherwise pass as the ephemeral port.
77
+ if (typeof raw !== "string" && typeof raw !== "number")
78
+ reject();
79
+ if (typeof raw === "string" && raw.trim() === "")
80
+ reject();
81
+ const port = Number(raw);
82
+ if (!Number.isInteger(port) || port < 0 || port > 65535)
83
+ reject();
84
+ return port;
85
+ }
59
86
  /** Panel groups/members, in the SPEC's declared order. Each entry names
60
87
  * the capability id whose MCP handler is called to fill the panel. */
61
88
  const PANEL_MAP = {
@@ -104,8 +131,26 @@ function buildWorkbenchRunView(runId, args = {}) {
104
131
  resolved = false;
105
132
  resolveError = error instanceof Error ? error.message : String(error);
106
133
  }
134
+ // The run's lifecycle, from the SAME `run.show` capability handler the
135
+ // CLI/MCP use (the buildWorkbenchIndex composition style — never a
136
+ // duplicate implementation). This function's contract is never-throws,
137
+ // so any failure here just leaves the key out.
138
+ let lifecycle;
139
+ try {
140
+ const showRow = (0, capability_table_1.findCapability)("run.show");
141
+ const shown = showRow?.mcp ? showRow.mcp.handler({ ...args, runId, cwd }) : undefined;
142
+ if (shown && shown.found === true && shown.record && typeof shown.record.lifecycle === "string") {
143
+ lifecycle = shown.record.lifecycle;
144
+ }
145
+ }
146
+ catch {
147
+ lifecycle = undefined;
148
+ }
107
149
  const panels = {};
108
- const panelArgs = { ...args, runId, cwd };
150
+ // This is an internal mechanism flag, not a CLI/MCP option. The Workbench
151
+ // must use the same capability bodies as the other front doors, but its
152
+ // GET-only projection must not make derived audit or metrics files.
153
+ const panelArgs = { ...args, runId, cwd, __cwWorkbenchReadOnlyProjection: true };
109
154
  for (const [group, members] of Object.entries(PANEL_MAP)) {
110
155
  panels[group] = {};
111
156
  for (const [member, capability] of Object.entries(members)) {
@@ -114,7 +159,7 @@ function buildWorkbenchRunView(runId, args = {}) {
114
159
  : { capability, cli: cliCommandFor(capability), mcp: mcpToolFor(capability), status: "absent", error: resolveError };
115
160
  }
116
161
  }
117
- return { schemaVersion: 1, surface: "workbench", runId, resolved, ...(resolveError ? { error: resolveError } : {}), panels };
162
+ return { schemaVersion: 1, surface: "workbench", runId, resolved, ...(lifecycle ? { lifecycle } : {}), ...(resolveError ? { error: resolveError } : {}), panels };
118
163
  }
119
164
  /** Package-relative resolution only — never falls back to the invocation
120
165
  * cwd. `ui/` ships as a sibling of `dist/` in the published package
@@ -143,7 +188,10 @@ function buildWorkbenchServeDescriptor(args = {}, boundPort) {
143
188
  surface: "workbench",
144
189
  command: "serve",
145
190
  host: "127.0.0.1",
146
- port: boundPort ?? (args.port !== undefined ? Number(args.port) : exports.WORKBENCH_DEFAULT_PORT),
191
+ // `boundPort` (from a real listen()) is already a valid port. Otherwise
192
+ // validate the requested `--port` so the `--once`/`--json`/MCP descriptor
193
+ // path fails closed with a clear line instead of emitting `"port": null`.
194
+ port: boundPort ?? (parseWorkbenchPort(args.port) ?? exports.WORKBENCH_DEFAULT_PORT),
147
195
  once: Boolean(args.once),
148
196
  readOnly: true,
149
197
  scope: args.scope === "home" ? "home" : "repo",
@@ -158,13 +206,42 @@ function buildWorkbenchServeDescriptor(args = {}, boundPort) {
158
206
  * list`), each embedded VERBATIM from its own already-declared capability
159
207
  * handler — the Workbench adds no new source of truth. Composed the same
160
208
  * way the panels are (findCapability(...).mcp.handler), so `/api/index`
161
- * can never drift from the standalone `cw` commands. Read-only. */
209
+ * can never drift from the standalone `cw` commands. Read-only.
210
+ *
211
+ * When `args.text` names a non-blank filter (the Workbench UI's sidebar
212
+ * filter box, `ui/workbench/app.js`'s `loadIndex`), the run list is filled
213
+ * via the `run.search` capability instead of `run.list` — `run.list`'s own
214
+ * handler (`runListCli` -> `RunRegistry.list()`) never reads a `text`
215
+ * field, so calling it with a filter present would silently ignore it.
216
+ * With no `text` filter the call is unchanged: `run.list`, byte-identical
217
+ * to the payload before this branch existed. */
162
218
  function buildWorkbenchIndex(args = {}) {
163
219
  const scope = args.scope === "home" ? "home" : "repo";
164
220
  const scoped = { ...args, scope };
165
221
  const registryRow = (0, capability_table_1.findCapability)("registry.show");
166
- const runListRow = (0, capability_table_1.findCapability)("run.list");
222
+ const text = typeof args.text === "string" ? args.text.trim() : "";
223
+ const runListRow = (0, capability_table_1.findCapability)(text ? "run.search" : "run.list");
167
224
  const registry = registryRow?.mcp ? registryRow.mcp.handler(scoped) : undefined;
168
- const runs = runListRow?.mcp ? runListRow.mcp.handler(scoped) : [];
225
+ const runs = runListRow?.mcp ? newestRunPage(runListRow.mcp.handler, scoped) : [];
169
226
  return { schemaVersion: 1, surface: "workbench", command: "index", scope, registry, runs };
170
227
  }
228
+ /** The run list/search handler sorts oldest-first and returns only the
229
+ * first `limit` page — so the default page is the OLDEST runs, and a scope
230
+ * with more runs than the page size never shows the newest run at all. The
231
+ * Workbench wants the newest page: when the caller pinned no offset and the
232
+ * total exceeds one page, re-fetch with the offset that lands on the last
233
+ * page. The payload shape (total/offset/limit/records) is unchanged, so
234
+ * the UI can still show "showing latest N of M". */
235
+ function newestRunPage(handler, scoped) {
236
+ const first = handler(scoped);
237
+ if (scoped.offset !== undefined)
238
+ return first;
239
+ if (!first || typeof first !== "object")
240
+ return first;
241
+ const page = first;
242
+ const total = typeof page.total === "number" ? page.total : undefined;
243
+ const limit = typeof page.limit === "number" ? page.limit : undefined;
244
+ if (total === undefined || limit === undefined || total <= limit)
245
+ return first;
246
+ return handler({ ...scoped, offset: total - limit });
247
+ }
@@ -703,7 +703,7 @@ function recordWorkerOutput(run, workerId, resultPath, options = {}) {
703
703
  artifacts: [{ id: "result", kind: "markdown", path: destination }],
704
704
  evidence: resultNode.evidence.length ? resultNode.evidence : [{ id: "result:summary", source: "summary", summary: parsedResult.summary }],
705
705
  metadata: { taskId: task.id, workerId, resultNodeId: resultNode.id, sandboxProfileId: scope.sandboxProfileId },
706
- }, { persist: false, persistNode: node_store_1.appendRunNode });
706
+ }, { persist: false, persistNode: node_store_1.appendRunNode, pathExists: fs.existsSync });
707
707
  task.verifierNodeId = verifierResult.outputNodeId;
708
708
  // Step 5: completion — persist the worker scope with the verify-derived status.
709
709
  const output = { workerId, taskId: task.id, resultPath: absoluteResultPath, recordedAt: new Date().toISOString(), stateNodeId: resultNode.id, verifierNodeId: task.verifierNodeId, auditEventIds: [pathAudit.id, acceptedAudit.id] };
@@ -78,6 +78,11 @@ function loadWorkflowAppLoader() {
78
78
  path: ["list"],
79
79
  jsonMode: "default",
80
80
  handler: () => ({ json: (0, registry_core_1.listBundledWorkflows)() }),
81
+ // UI/UX fix: a person at a real terminal used to get the raw JSON array.
82
+ // On a TTY (and only there — piped bytes stay the exact same JSON, see
83
+ // cli/dispatch.ts's shouldRenderHuman) render one "<id> — <title>" line
84
+ // per workflow plus the next-step footer.
85
+ humanRender: help_1.formatWorkflowList,
81
86
  });
82
87
  (0, registry_core_1.attachCliBinding)("status", {
83
88
  path: ["status"],