cool-workflow 0.1.97 → 0.1.98

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 (55) 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/capability-core.js +3 -0
  10. package/dist/capability-registry.js +7 -1
  11. package/dist/cli/command-surface.js +4 -0
  12. package/dist/cli/handlers/ledger.js +169 -0
  13. package/dist/drive.js +98 -61
  14. package/dist/execution-backend/agent.js +84 -24
  15. package/dist/ledger.js +313 -0
  16. package/dist/mcp/tool-call.js +36 -0
  17. package/dist/mcp/tool-definitions.js +26 -0
  18. package/dist/orchestrator/lifecycle-operations.js +37 -13
  19. package/dist/orchestrator.js +11 -3
  20. package/dist/remote-source.js +10 -3
  21. package/dist/run-export.js +35 -4
  22. package/dist/version.js +1 -1
  23. package/dist/workbench-host.js +11 -1
  24. package/docs/agent-delegation-drive.7.md +2 -0
  25. package/docs/cli-mcp-parity.7.md +14 -2
  26. package/docs/contract-migration-tooling.7.md +2 -0
  27. package/docs/control-plane-scheduling.7.md +2 -0
  28. package/docs/cross-agent-ledger.7.md +217 -0
  29. package/docs/designs/handoff-ledger.md +145 -0
  30. package/docs/durable-state-and-locking.7.md +2 -0
  31. package/docs/evidence-adoption-reasoning-chain.7.md +2 -0
  32. package/docs/execution-backends.7.md +2 -0
  33. package/docs/handoff-setup.md +120 -0
  34. package/docs/multi-agent-cli-mcp-surface.7.md +2 -0
  35. package/docs/multi-agent-eval-replay-harness.7.md +2 -0
  36. package/docs/multi-agent-operator-ux.7.md +2 -0
  37. package/docs/node-snapshot-diff-replay.7.md +2 -0
  38. package/docs/observability-cost-accounting.7.md +2 -0
  39. package/docs/project-index.md +16 -5
  40. package/docs/real-execution-backends.7.md +2 -0
  41. package/docs/release-and-migration.7.md +2 -0
  42. package/docs/release-tooling.7.md +2 -0
  43. package/docs/run-registry-control-plane.7.md +2 -0
  44. package/docs/run-retention-reclamation.7.md +2 -0
  45. package/docs/state-explosion-management.7.md +2 -0
  46. package/docs/team-collaboration.7.md +2 -0
  47. package/docs/web-desktop-workbench.7.md +2 -0
  48. package/manifest/plugin.manifest.json +1 -1
  49. package/package.json +1 -1
  50. package/scripts/agents/codex-agent.js +34 -4
  51. package/scripts/canonical-apps.js +4 -4
  52. package/scripts/children/batch-delegate-child.js +30 -10
  53. package/scripts/dogfood-release.js +1 -1
  54. package/scripts/golden-path.js +4 -4
  55. package/scripts/release-flow.js +7 -1
@@ -114,15 +114,23 @@ class CoolWorkflowRunner {
114
114
  }
115
115
  /** Run fn with a per-request loadRun cache. All loadRun calls inside fn share
116
116
  * the same cached run state, collapsing 18 reads into 1. Returns fn's result
117
- * and clears the cache afterwards (never leaks between requests). */
117
+ * and clears the cache afterwards (never leaks between requests).
118
+ *
119
+ * Reentrant: a nested call (e.g. a concurrent drive round whose sub-workflow
120
+ * task recursively drives a child run on the SAME runner instance) saves and
121
+ * restores the OUTER scope's cache rather than clobbering it to undefined —
122
+ * otherwise the inner call's `finally` would wipe the outer round's still-in-
123
+ * flight (not yet persisted) mutations, and the outer scope's next loadRun
124
+ * would silently fall back to a stale disk read. */
118
125
  loadWithCache(fn) {
126
+ const previousCache = this._requestCache;
119
127
  this._requestCache = new Map();
120
128
  (0, trust_audit_1.setAuditEventCache)(new Map());
121
129
  try {
122
130
  return fn(this);
123
131
  }
124
132
  finally {
125
- this._requestCache = undefined;
133
+ this._requestCache = previousCache;
126
134
  (0, trust_audit_1.clearAuditEventCache)();
127
135
  }
128
136
  }
@@ -818,7 +826,7 @@ function formatHelp() {
818
826
  "sandbox backend contract node feedback worker audit candidate review loop schedule " +
819
827
  "routine registry run queue clones history quickstart audit-run multi-agent topology summary " +
820
828
  "blackboard coordinator metrics operator sched gc telemetry migration demo workbench " +
821
- "approve reject comment handoff graph eval man version update fix").split(" ");
829
+ "approve reject comment handoff ledger graph eval man version update fix").split(" ");
822
830
  // Wrap the command list into clean, indented, pipe-joined lines (<=76 cols) instead of
823
831
  // one 400-char line that wraps raggedly and merges with the next shell prompt. Pipe-joined
824
832
  // (no internal spaces) keeps it parseable by the CLI/MCP parity help-token check.
@@ -287,9 +287,13 @@ function fetchArchiveBytes(rawUrl, sanitizedUrl, dest, opts) {
287
287
  "if(!r.ok){process.stderr.write('HTTP '+r.status+' '+r.statusText);process.exit(2);}",
288
288
  "const len=Number(r.headers.get('content-length')||0);",
289
289
  "if(cap&&len>cap){process.stderr.write('archive too large');process.exit(3);}",
290
- "const buf=Buffer.from(await r.arrayBuffer());",
291
- "if(cap&&buf.length>cap){process.stderr.write('archive too large');process.exit(3);}",
292
- "fs.writeFileSync(out,buf);return;}",
290
+ "if(!r.body||!r.body.getReader){process.stderr.write('response body is not streamable');process.exit(4);}",
291
+ "const fd=fs.openSync(out,'w');let total=0;",
292
+ "try{const reader=r.body.getReader();for(;;){const x=await reader.read();if(x.done)break;",
293
+ "const chunk=Buffer.from(x.value);total+=chunk.length;",
294
+ "if(cap&&total>cap){try{await reader.cancel();}catch{};fs.closeSync(fd);fs.rmSync(out,{force:true});process.stderr.write('archive too large');process.exit(3);}",
295
+ "fs.writeSync(fd,chunk,0,chunk.length);}fs.closeSync(fd);return;}",
296
+ "catch(e){try{fs.closeSync(fd);}catch{};fs.rmSync(out,{force:true});throw e;}}",
293
297
  "process.stderr.write('too many redirects');process.exit(6);",
294
298
  "})().catch(e=>{process.stderr.write(String((e&&e.message)||e));process.exit(4);});"
295
299
  ].join("");
@@ -315,6 +319,9 @@ function listArchive(file, isZip) {
315
319
  if (isZip && result.error?.code === "ENOENT") {
316
320
  throw new Error("unzip is required to review a .zip link but was not found on PATH (use a .tar.gz or a git URL)");
317
321
  }
322
+ if (!isZip && result.error?.code === "ENOENT") {
323
+ throw new Error("tar is required to review a .tar/.tgz link but was not found on PATH");
324
+ }
318
325
  throw new Error(`could not read archive: ${String(result.stderr || "").trim() || `exit ${result.status}`}`);
319
326
  }
320
327
  return String(result.stdout || "").split(/\r?\n/).filter(Boolean);
@@ -108,7 +108,7 @@ function importRun(exportPath, targetDir) {
108
108
  throw new Error(`Archive file escapes restore directory: ${file.relativePath}`);
109
109
  }
110
110
  node_fs_1.default.mkdirSync(node_path_1.default.dirname(destination), { recursive: true });
111
- node_fs_1.default.writeFileSync(destination, Buffer.from(file.contentBase64, "base64"));
111
+ node_fs_1.default.writeFileSync(destination, decodeBase64Strict(file.contentBase64, file.relativePath));
112
112
  }
113
113
  const externalPathMap = new Map();
114
114
  for (const file of files) {
@@ -364,8 +364,11 @@ function verifyReportBundle(archivePath, options = {}) {
364
364
  bundleKey = raw.trust?.publicKeyPem;
365
365
  if (options.extractReportTo) {
366
366
  const reportFile = (raw.files || []).find((file) => file.relativePath === "report.md");
367
- if (reportFile)
368
- reportContent = Buffer.from(reportFile.contentBase64, "base64").toString("utf8");
367
+ if (reportFile) {
368
+ const decoded = decodeBase64StrictResult(reportFile.contentBase64, reportFile.relativePath);
369
+ if (decoded.ok)
370
+ reportContent = decoded.bytes.toString("utf8");
371
+ }
369
372
  }
370
373
  }
371
374
  catch {
@@ -647,6 +650,28 @@ function normalizeArchiveFiles(raw) {
647
650
  };
648
651
  });
649
652
  }
653
+ function decodeBase64StrictResult(value, relativePath) {
654
+ if (typeof value !== "string") {
655
+ return { ok: false, check: { name: "archive-file", pass: false, code: "archive-bad-base64", path: relativePath, actual: "contentBase64 is not a string" } };
656
+ }
657
+ const compact = value.replace(/\s+/g, "");
658
+ if (!/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(compact)) {
659
+ return { ok: false, check: { name: "archive-file", pass: false, code: "archive-bad-base64", path: relativePath, actual: "invalid base64 encoding" } };
660
+ }
661
+ const bytes = Buffer.from(compact, "base64");
662
+ const expected = compact.replace(/=+$/, "");
663
+ const actual = bytes.toString("base64").replace(/=+$/, "");
664
+ if (actual !== expected) {
665
+ return { ok: false, check: { name: "archive-file", pass: false, code: "archive-bad-base64", path: relativePath, actual: "non-canonical base64 encoding" } };
666
+ }
667
+ return { ok: true, bytes };
668
+ }
669
+ function decodeBase64Strict(value, relativePath) {
670
+ const decoded = decodeBase64StrictResult(value, relativePath);
671
+ if (!decoded.ok)
672
+ throw new Error(archiveCheckMessage(decoded.check));
673
+ return decoded.bytes;
674
+ }
650
675
  /** NON-throwing digest/size/count/manifest verification: one structured check per
651
676
  * file (in import order), then the integrity file-count + manifest checks. Shared
652
677
  * by the throwing import path (verifyArchiveFileDigests) and the read-only
@@ -654,7 +679,12 @@ function normalizeArchiveFiles(raw) {
654
679
  function collectArchiveDigestChecks(files, integrity) {
655
680
  const checks = [];
656
681
  for (const file of files) {
657
- const bytes = Buffer.from(file.contentBase64, "base64");
682
+ const decoded = decodeBase64StrictResult(file.contentBase64, file.relativePath);
683
+ if (!decoded.ok) {
684
+ checks.push(decoded.check);
685
+ continue;
686
+ }
687
+ const bytes = decoded.bytes;
658
688
  const actual = sha256Bytes(bytes);
659
689
  const digestOk = actual === file.sha256;
660
690
  checks.push(digestOk
@@ -686,6 +716,7 @@ function archiveCheckMessage(check) {
686
716
  case "size-mismatch": return `Archive size mismatch for ${check.path}: expected ${check.expected}, got ${check.actual}`;
687
717
  case "file-count-mismatch": return `Archive file count mismatch: expected ${check.expected}, got ${check.actual}`;
688
718
  case "manifest-digest-mismatch": return `Archive manifest digest mismatch: expected ${check.expected}, got ${check.actual}`;
719
+ case "archive-bad-base64": return `Archive base64 invalid for ${check.path}: ${check.actual}`;
689
720
  default: return `Archive verification failed: ${check.name}`;
690
721
  }
691
722
  }
package/dist/version.js CHANGED
@@ -1,7 +1,7 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.MIN_SUPPORTED_RUN_STATE_SCHEMA_VERSION = exports.LEGACY_RUN_STATE_SCHEMA_VERSION = exports.CURRENT_RUN_STATE_SCHEMA_VERSION = exports.WORKFLOW_APP_SCHEMA_VERSION = exports.CURRENT_COOL_WORKFLOW_VERSION = void 0;
4
- exports.CURRENT_COOL_WORKFLOW_VERSION = "0.1.97";
4
+ exports.CURRENT_COOL_WORKFLOW_VERSION = "0.1.98";
5
5
  exports.WORKFLOW_APP_SCHEMA_VERSION = 1;
6
6
  exports.CURRENT_RUN_STATE_SCHEMA_VERSION = 1;
7
7
  exports.LEGACY_RUN_STATE_SCHEMA_VERSION = 0;
@@ -103,7 +103,9 @@ class WorkbenchHost {
103
103
  return this.send(res, 401, { error: "unauthorized: token mismatch" });
104
104
  }
105
105
  }
106
- const route = decodeURIComponent(url.pathname);
106
+ const route = decodeRoutePath(url.pathname);
107
+ if (!route)
108
+ return this.send(res, 400, { error: "bad request: malformed URL path" });
107
109
  if (route === "/" || route === "/index.html")
108
110
  return this.sendAsset(res, "index.html");
109
111
  if (route.startsWith("/ui/"))
@@ -165,6 +167,14 @@ function isLocalHost(hostHeader) {
165
167
  const hostname = hostHeader.replace(/:\d+$/, "");
166
168
  return ALLOWED_HOSTNAMES.has(hostname);
167
169
  }
170
+ function decodeRoutePath(pathname) {
171
+ try {
172
+ return decodeURIComponent(pathname);
173
+ }
174
+ catch {
175
+ return undefined;
176
+ }
177
+ }
168
178
  function FALLBACK_HTML(uiRoot) {
169
179
  return [
170
180
  "<!doctype html><meta charset=utf-8><title>Cool Workflow Workbench</title>",
@@ -411,3 +411,5 @@ The one-command `cw -q` headline now routes the question and defaults the repo t
411
411
  0.1.96
412
412
 
413
413
  0.1.97
414
+
415
+ 0.1.98
@@ -82,7 +82,7 @@ relationship. `identical` means `cw <cmd> --json` is equal to the `cw_<tool>`
82
82
  payload; `projected` means a declared divergence with a reason; `cli-only` marks
83
83
  a surface-specific capability with a recorded reason. The matrix is
84
84
  <!-- gen:parity:count -->
85
- machine-complete by design: 202 capabilities, 189 MCP tools.
85
+ machine-complete by design: 207 capabilities, 194 MCP tools.
86
86
  <!-- /gen:parity:count -->
87
87
 
88
88
  <!-- gen:parity:table -->
@@ -290,6 +290,11 @@ machine-complete by design: 202 capabilities, 189 MCP tools.
290
290
  | `handoff` | `cw handoff` | `cw_handoff` | `collaborationHandoff` | both | identical |
291
291
  | `review.status` | `cw review status` | `cw_review_status` | `reviewStatus` | both | identical |
292
292
  | `review.policy` | `cw review policy` | `cw_review_policy` | `reviewPolicy` | both | identical |
293
+ | `ledger.propose` | `cw ledger propose` | `cw_ledger_propose` | `buildLedgerProposal` | both | projected |
294
+ | `ledger.review` | `cw ledger review` | `cw_ledger_review` | `buildLedgerReview` | both | projected |
295
+ | `ledger.verify` | `cw ledger verify` | `cw_ledger_verify` | `verifyLedgerEntry` | both | projected |
296
+ | `ledger.apply` | `cw ledger apply` | `cw_ledger_apply` | `applyLedgerProposal` | both | projected |
297
+ | `ledger.list` | `cw ledger list` | `cw_ledger_list` | `listLedgerEntries` | both | projected |
293
298
  <!-- /gen:parity:table -->
294
299
 
295
300
  v0.1.27 closed the old gaps. It added MCP peers `cw_init`, `cw_next`,
@@ -322,7 +327,7 @@ carry a recorded reason in the registry.
322
327
  <!-- /gen:parity:cliOnly -->
323
328
 
324
329
  <!-- gen:parity:projected -->
325
- Six capabilities are payload-divergent on purpose (`projected`):
330
+ Eleven capabilities are payload-divergent on purpose (`projected`):
326
331
 
327
332
  - `commit` — Both surfaces route through the single core entry runner.commit. The CLI emits the raw StateCommitResult for scripting (commit.id, commit.evidence, commit.gate, commit.acceptanceRationale); cw_commit emits the operator commit envelope (commitId, verifierGated, checkpoint, evidenceCount, snapshotPath, nextActions, plus the raw result under `commit`). Declared projection via capability-core.commitEnvelope, not drift.
328
333
  - `backend.agent.config.set` — Mutating: persists $CW_HOME/agent-config.json (secret-stripped) before returning the effective config; both surfaces perform the same write — it is a surface-mutating verb, not a read probe.
@@ -330,6 +335,11 @@ Six capabilities are payload-divergent on purpose (`projected`):
330
335
  - `gc.run` — Mutating: frees disk and appends a tombstone; both surfaces perform the identical transaction but the payload reports now-derived bytesFreed/tombstone.
331
336
  - `clones.gc` — Mutating: removes cache directories and reports now-derived freedBytes/removed; both surfaces perform the identical reclamation.
332
337
  - `workbench.serve` — Both surfaces route through the single core entry buildWorkbenchServeDescriptor and return the IDENTICAL serve descriptor under `cw workbench serve --json`/`--once` and `cw_workbench_serve`. They diverge only in side effect, not payload: the CLI's default `cw workbench serve` (no --once) additionally STARTS the blocking localhost host (like `schedule daemon`), which an MCP stdio host cannot do, so cw_workbench_serve only ever returns the descriptor. Declared divergence, not drift.
338
+ - `ledger.propose` — Mints a fresh entry each call: createdAt is the wall-clock instant and the id/digest are derived from it, so the output is inherently non-deterministic and a byte-identity probe does not apply. Both surfaces call the same buildLedgerProposal core; round-trip + fail-closed behavior is covered by ledger-verify-smoke.
339
+ - `ledger.review` — Mints a fresh timestamped/digested verdict each call — non-deterministic output, same reasoning as ledger.propose. Both surfaces call the same buildLedgerReview core.
340
+ - `ledger.verify` — The entry arrives by --file/stdin on the CLI and as an `entry` argument over MCP; there is no shared arg-bag the byte-identity probe can feed both. Both surfaces call the same verifyLedgerEntry core; ledger-verify-smoke proves the fail-closed contract.
341
+ - `ledger.apply` — The entry arrives by --file/stdin on the CLI and as an `entry` argument over MCP; there is no shared arg-bag the byte-identity probe can feed both. Both surfaces call the same applyLedgerProposal core (a fail-closed wrapper over verifyLedgerEntry); ledger-apply-smoke proves the diff only escapes a verified proposal.
342
+ - `ledger.list` — Output depends on the on-disk contents of the named ledger directory/directories, which the generic payload probe does not populate. Both surfaces call the same listLedgerEntries/unionLedgerEntries core; ledger-verify-smoke covers the fail-closed inbox and the multi-mirror union.
333
343
  <!-- /gen:parity:projected -->
334
344
 
335
345
  ## Fail-Closed Rules
@@ -553,3 +563,5 @@ CLI golden-path fixes: `cw -q "…"` routes the question (was read as an app id
553
563
  0.1.96
554
564
 
555
565
  0.1.97
566
+
567
+ 0.1.98
@@ -171,3 +171,5 @@ _No behavioral change in v0.1.89 (CLI-surface golden-path + help-output fixes on
171
171
  0.1.96
172
172
 
173
173
  0.1.97
174
+
175
+ 0.1.98
@@ -155,3 +155,5 @@ _No behavioral change in v0.1.89 (CLI-surface golden-path + help-output fixes on
155
155
  0.1.96
156
156
 
157
157
  0.1.97
158
+
159
+ 0.1.98
@@ -0,0 +1,217 @@
1
+ # Cross-Agent Handoff Ledger
2
+
3
+ CW adds `cw ledger` — a way for two agents scoped to two separate repos to hand
4
+ each other a CHANGE PROPOSAL or a REVIEW VERDICT as verifiable data, not chat.
5
+ One side proposes or reviews; the other side verifies the entry fail-closed and
6
+ turns a proposal into a real pull request. Design notes:
7
+ [handoff-ledger](designs/handoff-ledger.md).
8
+
9
+ Each entry is a self-contained JSON object that carries its own sha256 content
10
+ digest. The producing side prints one; it reaches the other session by human
11
+ relay or a shared git repo (below); the consuming side runs `cw ledger verify`
12
+ (one entry) or `cw ledger list` (a whole directory) before acting. A tampered or
13
+ malformed entry is refused with a non-zero exit, so
14
+ `cw ledger verify <file> && open-pr` can never proceed on a lie.
15
+
16
+ Every verb is on both surfaces: the CLI (`cw ledger ...`) and MCP
17
+ (`cw_ledger_propose`, `cw_ledger_review`, `cw_ledger_verify`, `cw_ledger_list`),
18
+ so an agent can mint and check entries in-process, not only from a shell.
19
+
20
+ `cw ledger` is a NEW verb. It does not touch `cw handoff`, which is a separate
21
+ collaboration primitive (ownership transfer of a run/task) — see
22
+ [team-collaboration](team-collaboration.7.md).
23
+
24
+ ## Why a ledger, not a shared folder
25
+
26
+ The two agents run as two separate cloud sessions. They share no filesystem, and
27
+ each is scoped to one repo at launch, so a local folder cannot be the channel.
28
+ The only medium both sides can durably reach is git/GitHub. `cw ledger` therefore
29
+ produces and consumes portable, self-verifying entries; how an entry crosses
30
+ (operator relay now, a shared handoff repo later) is transport, kept separate
31
+ from the verb.
32
+
33
+ ## Mechanism vs policy
34
+
35
+ The MECHANISM is small and lives in the kernel (`src/ledger.ts`): build a
36
+ proposal or a review entry, seal it with a sha256 digest over its canonical
37
+ content, and verify that digest fail-closed. No run state, no network, no new
38
+ runtime dependency. The POLICY — which repos, who may propose, whether a verdict
39
+ blocks a merge — stays outside, in the operator's hands and the transport.
40
+
41
+ The digest is computed over a deterministic serialization (object keys sorted
42
+ recursively) of every field except `id` and `digest`, which are derived from it.
43
+ The `id` is content-addressed: `ldg-` + the first 16 hex chars of the digest.
44
+
45
+ ## Commands
46
+
47
+ ```
48
+ cw ledger propose --from <a> --to <b> --title <t> --rationale <r> \
49
+ [--files a.ts,b.ts] [--diff <patch>]
50
+ cw ledger review --from <a> --to <b> --target <proposal-id|pr-ref> \
51
+ --verdict <approved|rejected> [--findings "a,b"]
52
+ cw ledger verify [--file <path>] # else reads the entry from stdin
53
+ cw ledger apply [--file <path>] # verify a proposal, then print its diff
54
+ cw ledger list --dir <ledger-dir> [--dir <mirror-2> ...] # verify a dir (or union of mirrors)
55
+ ```
56
+
57
+ All write JSON to stdout (stdout is data). `propose` and `review` print a sealed
58
+ entry; `verify` prints a check report; `apply` prints a verify-plus-diff report;
59
+ `list` prints a per-entry report over a directory.
60
+
61
+ ## Applying a proposal — fail-closed
62
+
63
+ A proposal carries a `suggestedDiff`, but a proposal never mutates the target
64
+ repo by itself: the write-capable side turns it into a real change. `cw ledger
65
+ apply` is the fail-closed bridge — it verifies the entry FIRST and only then
66
+ emits the diff, so an unverified patch can never reach `git`:
67
+
68
+ ```
69
+ cw ledger apply --file proposal.json | jq -r 'select(.ok).diff' | git apply -
70
+ ```
71
+
72
+ `apply` prints `{ ok, id, kind, diff }`. The `diff` is present **only** when
73
+ `ok` is `true` — a tampered entry (`ok:false`, the `verify` failure codes), a
74
+ review rather than a proposal (`ledger-not-a-proposal`), or a proposal with no
75
+ `suggestedDiff` (`ledger-empty-diff`) all yield `diff:null` and exit `1`. The
76
+ kernel never runs `git`; turning the verified diff into a commit stays the
77
+ operator's (or a wrapper's) step — mechanism, not policy.
78
+
79
+ ## Git transport (T2a — shared handoff repo)
80
+
81
+ The two agents cannot share a local folder, but they can share a git repo both
82
+ are scoped to. The ledger rides on it with no git logic in the kernel — writing
83
+ is composition through files, and `git` is the operator's (or a wrapper's) step:
84
+
85
+ ```
86
+ # Producing side (cool-workflow), inside the shared repo's working tree:
87
+ cw ledger propose --from cool-workflow --to chime \
88
+ --title "Add retry" --rationale "flaky net" --files src/net.ts \
89
+ > ledger/$(cw ledger propose ... | jq -r .id).json # or any unique name
90
+ git add ledger/ && git commit -m "propose: add retry" && git push
91
+
92
+ # Consuming side (chime):
93
+ git pull
94
+ cw ledger list --dir ledger && echo "inbox verified — safe to act"
95
+ ```
96
+
97
+ `cw ledger list` reads every `*.json` in the directory, verifies each entry, and
98
+ reports `allOk`. It is a **fail-closed inbox**: if any single entry is tampered,
99
+ malformed, or unreadable, `allOk` is `false` and the command exits `1`, so the
100
+ receiving side refuses the whole batch rather than acting on a mixed one.
101
+
102
+ ### Inbox resolution — which proposals are still open
103
+
104
+ `cw ledger list` also derives a `resolution` summary so the inbox is
105
+ machine-actionable without opening each file. It pairs every proposal with the
106
+ review(s) whose `target` is that proposal's id and reports one of four states:
107
+
108
+ ```json
109
+ "resolution": {
110
+ "proposals": [
111
+ { "id": "ldg-1de7c92172af1871", "title": "Add retry", "resolution": "approved", "reviews": ["ldg-…"] },
112
+ { "id": "ldg-…", "title": "Rename thing", "resolution": "pending", "reviews": [] }
113
+ ],
114
+ "pending": 1, "approved": 1, "rejected": 0, "contested": 0
115
+ }
116
+ ```
117
+
118
+ - `pending` — no verified review targets the proposal yet.
119
+ - `approved` / `rejected` — every verified review targeting it agrees.
120
+ - `contested` — verified reviews targeting it disagree (both an APPROVED and a
121
+ REJECTED exist); the ledger REPORTS the disagreement, it does not pick a
122
+ winner (mechanism, not policy — whether a verdict blocks a merge stays
123
+ outside).
124
+
125
+ Only **verified** entries take part: a tampered review can never resolve a
126
+ proposal, so a proposal answered only by a failing review stays `pending`
127
+ (fail-closed). The fields are additive — the existing `entries[]` / `allOk` /
128
+ `count` output is byte-unchanged (POLA), with each entry now also carrying its
129
+ `title` (proposals) or `target`/`verdict` (reviews). The same `resolution` rides
130
+ on the mirror-union output and on the `cw_ledger_list` MCP tool.
131
+
132
+ ### Mirrors — union-verifying several directories
133
+
134
+ `--dir` is repeatable. With two or more, `cw ledger list` **union-verifies** the
135
+ directories as mirrors of one ledger (e.g. the same handoff repo cloned from a
136
+ GitHub remote and one or more self-hosted Gitea remotes in different places):
137
+
138
+ ```
139
+ cw ledger list --dir gh/ledger --dir gitea-eu/ledger --dir gitea-asia/ledger
140
+ ```
141
+
142
+ The union is **conflict-free by construction**: entries are immutable and
143
+ content-addressed, so the same entry mirrored to several hosts collapses to one
144
+ result whose `dirs` records every mirror it was found in. It stays **fail-closed
145
+ across mirrors** — a tampered entry in ANY mirror sets `allOk:false` and exits
146
+ `1`. This is for redundancy and reachability, not load: the ledger's traffic is
147
+ tiny; multiple hosts guard against one being down or unreachable.
148
+
149
+ A single `--dir` keeps the original single-directory output (a `dir` field, no
150
+ `dirs`); two or more switch to the union shape (`dirs` plus a per-entry `dirs`).
151
+ The transport stays git-host-agnostic — adding a mirror is one more clone + one
152
+ more `--dir`, no code change.
153
+
154
+ ## Entry shape
155
+
156
+ A proposal:
157
+
158
+ ```json
159
+ {
160
+ "kind": "proposal",
161
+ "schemaVersion": 1,
162
+ "from": "cool-workflow",
163
+ "to": "chime",
164
+ "title": "Add retry to the fetch path",
165
+ "rationale": "the network is flaky under load",
166
+ "targetFiles": ["src/net.ts"],
167
+ "suggestedDiff": "@@ ... @@",
168
+ "createdAt": "<iso>",
169
+ "id": "ldg-<16 hex>",
170
+ "digest": "sha256:<64 hex>"
171
+ }
172
+ ```
173
+
174
+ A review is the same envelope with `kind: "review"`, plus `target` (the proposal
175
+ id or a PR ref), `verdict` (`APPROVED` | `REJECTED`), and `findings` (a list).
176
+
177
+ ## Verification contract
178
+
179
+ `cw ledger verify` re-proves the entry and exits fail-closed:
180
+
181
+ - Not a JSON object, or non-JSON bytes → `ok:false`, code `ledger-bad-json` /
182
+ `ledger-not-object`.
183
+ - Unknown `kind`, wrong `schemaVersion`, missing digest, missing a required
184
+ field, or a bad `verdict` → `ok:false` with the matching code.
185
+ - Stored digest does not match a fresh digest of the content →
186
+ `ok:false`, code `ledger-digest-mismatch`.
187
+ - `id` is not the content-addressed id for the digest (spoofed or absent) →
188
+ `ok:false`, code `ledger-id-mismatch`. `id` is excluded from the digest, so it
189
+ is bound to the content by this check — a forged entry cannot set its `id` to
190
+ collide with a legitimate one and slip through the mirror-union de-duplication.
191
+
192
+ Any `ok:false` exits `1`. An intact entry exits `0` with `ok:true`.
193
+
194
+ ## Example round-trip
195
+
196
+ ```
197
+ # On the proposing side (cool-workflow):
198
+ cw ledger propose --from cool-workflow --to chime \
199
+ --title "Add retry" --rationale "flaky net" \
200
+ --files src/net.ts --diff "$(git diff)" > proposal.json
201
+
202
+ # The operator carries proposal.json to the chime session, which checks it:
203
+ cw ledger verify --file proposal.json && echo "safe to open a PR"
204
+
205
+ # The reviewing side hands a verdict back:
206
+ cw ledger review --from chime --to cool-workflow \
207
+ --target ldg-1de7c92172af1871 --verdict approved \
208
+ --findings "tests pass,scope ok" > verdict.json
209
+ cw ledger verify --file verdict.json
210
+ ```
211
+
212
+ ## Roadmap
213
+
214
+ Stage 1 shipped the CLI verbs (human relay). Stage 2 adds the MCP surface and
215
+ the git-as-ledger transport (`cw ledger list` over a shared repo). Still open:
216
+ the operator creates the shared handoff repo and scopes both agent environments
217
+ into it. See [handoff-ledger](designs/handoff-ledger.md).
@@ -0,0 +1,145 @@
1
+ # Design — Cross-agent handoff ledger
2
+
3
+ Status: DRAFT / proposal. Nothing here is built yet. This file ships no
4
+ behavior, no new command, no man-page contract, and changes no existing
5
+ output. It exists so two people (the operator and the reviewer agent) can
6
+ agree on the shape before any code is written.
7
+
8
+ North Star track: **Track B** (portable, verifiable state — the same
9
+ `run export` → `run restore` recovery story, now used as the channel
10
+ between two agents).
11
+
12
+ ## Goal
13
+
14
+ Two agents work on two repositories:
15
+
16
+ - one agent scoped to repo **A** (for example `cool-workflow`),
17
+ - one agent scoped to repo **B** (for example `chime`).
18
+
19
+ The operator wants them to "share data, review each other, and each be
20
+ able to raise a pull request to the other". In plain terms:
21
+
22
+ - each side can hand the other a **change proposal**, and
23
+ - each side can hand the other a **review verdict** on a diff or PR,
24
+ - with saved, inspectable, fail-closed state — never a fabricated hand-off.
25
+
26
+ ## The hard constraint (why the obvious design does not work)
27
+
28
+ The first idea is a shared local folder (for example `~/.chime/handoff/`)
29
+ that both agents read and append to. That only works when both agents run
30
+ on **one machine** with **one filesystem**.
31
+
32
+ In the operator's setup the two agents run as **two separate cloud
33
+ sessions**. Each session is a fresh, throwaway VM. Two facts follow, and
34
+ the design must respect both:
35
+
36
+ 1. **No shared filesystem.** A file the B-agent writes to `~/.chime/handoff/`
37
+ in its VM is invisible to the A-agent's VM, and is gone when the session
38
+ ends. A local folder cannot be the channel.
39
+ 2. **Single-repo scope.** Each session's GitHub reach is scoped to one repo
40
+ at launch (A-agent → repo A, B-agent → repo B). The A-agent cannot read
41
+ repo B through its GitHub tools, and the reverse is also true. Scope is
42
+ fixed at launch and cannot be widened mid-session.
43
+
44
+ The only medium both sessions can durably reach is **git / GitHub**. So the
45
+ ledger is a set of committed files, not a local folder — and the scope wall
46
+ means the hand-off still needs either a shared repo or a human relay for the
47
+ cross-repo step. This document is honest about that; it does not pretend the
48
+ wall is not there.
49
+
50
+ ## What we reuse (no new trust machinery)
51
+
52
+ CW already has the parts this needs. The design adds a thin verb layer over
53
+ them; it invents no new crypto and no new state format.
54
+
55
+ - `run export` produces a **verifiable bundle** (file digests, telemetry
56
+ ledger, trust-audit hash chains).
57
+ - `run restore` **imports fail-closed**: it inspects first, refuses a corrupt
58
+ or tampered bundle without writing anything, and exits non-zero when the
59
+ chain does not verify. (`run import` is the exit-0 sibling; the hand-off
60
+ path must use the fail-closed `restore` contract.)
61
+ - `report verify` checks a run's evidence and citations.
62
+
63
+ A hand-off entry is therefore just a CW bundle. The receiving side trusts it
64
+ the same way it trusts any restored run: by verification, not by good faith.
65
+
66
+ ## Two verbs
67
+
68
+ Both live under a single new `cw ledger` verb, so the existing surface is
69
+ untouched and the new behavior is opt-in (POLA). (The name `handoff` was already
70
+ taken by an unrelated collaboration primitive — ownership transfer of a run/task
71
+ — so the cross-agent verb is `ledger`, not `handoff`.) Stage 1 ships as
72
+ `cw ledger propose|review|verify`; see
73
+ [cross-agent-ledger](../cross-agent-ledger.7.md) for the contract.
74
+
75
+ - **`propose`** — the read-only side writes a structured *change proposal*
76
+ (title, rationale, target files, suggested diff) as a ledger entry. It does
77
+ **not** mutate the other repo. The write-capable side picks the entry up,
78
+ verifies it, and turns it into a **real GitHub pull request**.
79
+ - **`review`** — the reviewing side writes a structured *review verdict*
80
+ (`APPROVED` / `REJECTED`, findings, the diff or PR it judged) as a ledger
81
+ entry. The other side surfaces it and can act on it.
82
+
83
+ This keeps a read-only agent honest: it emits proposals and verdicts as
84
+ **data**, and the write-capable side is the only one that opens PRs. Neither
85
+ side has to be trusted to have mutated the other's code.
86
+
87
+ ## Transports (how an entry actually crosses)
88
+
89
+ The verbs above produce and consume entries; the transport is how an entry
90
+ moves from one VM to the other. Two are in scope, smallest first.
91
+
92
+ - **T1 — Human relay (MVP, works today, zero infra).** The producing side
93
+ prints the entry (a verifiable bundle, or its safe text form) to stdout;
94
+ the operator carries it to the other session; the consuming side verifies
95
+ it fail-closed and acts (opens the PR, or records the verdict). This is
96
+ exactly the loop the operator is already running by hand. It needs no new
97
+ code beyond a stable print/parse shape.
98
+ - **T2 — Git-as-ledger.** Each entry is committed to a repo under a known
99
+ path (for example `handoff/<from>-<to>/<id>.bundle`). Because scope is
100
+ single-repo, this needs one of:
101
+ - **T2a — a shared handoff repo** both agents are scoped to (cleanest, but
102
+ the operator must create it and launch both sessions against it), or
103
+ - **T2b — each side writes to its own repo** and a bridge (the operator, or
104
+ a scheduled job that *is* scoped to both) moves entries across. The
105
+ cross-repo read cannot be automatic inside a single scoped session — this
106
+ is the scope wall, stated plainly, not a gap to be quietly filled.
107
+
108
+ ## Fail-closed rules (non-negotiable)
109
+
110
+ - An entry that does not verify is **refused**, never acted on. No PR is
111
+ opened, no verdict is recorded, and the refusal is explicit on stderr with
112
+ a non-zero exit — the same contract as `run restore`.
113
+ - A proposal is a **suggestion only**. It never edits the target repo by
114
+ itself; a human-or-agent on the write side always makes the real PR, so the
115
+ read-only vow of the proposing side holds.
116
+ - stdout stays data (the entry / the machine result); stderr stays
117
+ diagnostics; a piped run is silent on success. `--json` is stable and
118
+ decoration-free.
119
+
120
+ ## Non-goals / POLA
121
+
122
+ - No existing command, output byte, exit code, or file layout changes.
123
+ - No new runtime dependency (zero-dependency red line holds).
124
+ - No vendor-specific logic in core; the verbs move opaque bundles.
125
+ - Nothing ships until its own cycle lands with a test that fails before and
126
+ passes after, and a `docs/*.7.md` contract page — this design file is not
127
+ that contract and claims no shipped behavior.
128
+
129
+ ## Suggested rollout (each stage its own reviewed cycle)
130
+
131
+ 0. **This design doc** (no behavior). ← you are here.
132
+ 1. **T1 human-relay shape** — a stable, documented print/parse form for a
133
+ proposal and a verdict, plus a smoke that round-trips one of each and
134
+ proves a tampered entry is refused.
135
+ 2. **`cw ledger propose` / `review`** over `run export` / `restore`, with the
136
+ fail-closed refusal test.
137
+ 3. **T2 git-ledger** (shared-repo first), then optionally a scoped bridge job
138
+ for T2b.
139
+
140
+ ## Open decisions for the operator
141
+
142
+ - T2a (shared handoff repo) or T2b (own repos + bridge)? T2a is simpler and
143
+ should be the default unless a shared repo is not acceptable.
144
+ - Should a verdict be able to **block** a PR merge on the other side, or only
145
+ advise? Advise-only is the safer default and matches "review as data".
@@ -154,3 +154,5 @@ _No behavioral change in v0.1.89 (CLI-surface golden-path + help-output fixes on
154
154
  0.1.96
155
155
 
156
156
  0.1.97
157
+
158
+ 0.1.98
@@ -315,3 +315,5 @@ _No behavioral change in v0.1.89 (CLI-surface golden-path + help-output fixes on
315
315
  0.1.96
316
316
 
317
317
  0.1.97
318
+
319
+ 0.1.98
@@ -345,3 +345,5 @@ _No behavioral change in v0.1.89 (CLI-surface golden-path + help-output fixes on
345
345
  0.1.96
346
346
 
347
347
  0.1.97
348
+
349
+ 0.1.98