cool-workflow 0.1.96 → 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 (74) 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/candidate-scoring.js +3 -3
  10. package/dist/capability-core.js +9 -1
  11. package/dist/capability-registry.js +7 -1
  12. package/dist/cli/command-surface.js +4 -0
  13. package/dist/cli/handlers/ledger.js +169 -0
  14. package/dist/cli/handlers/scheduling.js +7 -1
  15. package/dist/drive.js +108 -61
  16. package/dist/execution-backend/agent.js +84 -24
  17. package/dist/execution-backend.js +25 -5
  18. package/dist/ledger.js +313 -0
  19. package/dist/mcp/tool-call.js +36 -0
  20. package/dist/mcp/tool-definitions.js +26 -0
  21. package/dist/mcp-server.js +4 -0
  22. package/dist/onramp.js +2 -0
  23. package/dist/orchestrator/app-operations.js +6 -0
  24. package/dist/orchestrator/cli-options.js +8 -2
  25. package/dist/orchestrator/lifecycle-operations.js +40 -13
  26. package/dist/orchestrator/migration-operations.js +1 -1
  27. package/dist/orchestrator.js +11 -3
  28. package/dist/remote-source.js +10 -3
  29. package/dist/run-export.js +45 -5
  30. package/dist/sandbox-profile.js +6 -1
  31. package/dist/triggers.js +7 -1
  32. package/dist/version.js +1 -1
  33. package/dist/workbench-host.js +18 -2
  34. package/docs/agent-delegation-drive.7.md +4 -0
  35. package/docs/cli-mcp-parity.7.md +16 -2
  36. package/docs/contract-migration-tooling.7.md +4 -0
  37. package/docs/control-plane-scheduling.7.md +4 -0
  38. package/docs/cross-agent-ledger.7.md +217 -0
  39. package/docs/demo.7.md +80 -0
  40. package/docs/designs/handoff-ledger.md +145 -0
  41. package/docs/doctor.7.md +97 -0
  42. package/docs/durable-state-and-locking.7.md +4 -0
  43. package/docs/evidence-adoption-reasoning-chain.7.md +4 -0
  44. package/docs/execution-backends.7.md +4 -0
  45. package/docs/fix.7.md +44 -0
  46. package/docs/handoff-setup.md +120 -0
  47. package/docs/init.7.md +62 -0
  48. package/docs/multi-agent-cli-mcp-surface.7.md +4 -0
  49. package/docs/multi-agent-eval-replay-harness.7.md +4 -0
  50. package/docs/multi-agent-operator-ux.7.md +4 -0
  51. package/docs/node-snapshot-diff-replay.7.md +4 -0
  52. package/docs/observability-cost-accounting.7.md +4 -0
  53. package/docs/pipeline-verbs.7.md +93 -0
  54. package/docs/project-index.md +28 -5
  55. package/docs/real-execution-backends.7.md +4 -0
  56. package/docs/release-and-migration.7.md +4 -0
  57. package/docs/release-tooling.7.md +4 -0
  58. package/docs/routine.7.md +73 -0
  59. package/docs/run-registry-control-plane.7.md +4 -0
  60. package/docs/run-retention-reclamation.7.md +4 -0
  61. package/docs/state-explosion-management.7.md +4 -0
  62. package/docs/team-collaboration.7.md +4 -0
  63. package/docs/web-desktop-workbench.7.md +4 -0
  64. package/manifest/README.md +16 -10
  65. package/manifest/plugin.manifest.json +1 -1
  66. package/package.json +1 -1
  67. package/scripts/agents/agent-adapter-core.js +4 -1
  68. package/scripts/agents/codex-agent.js +34 -4
  69. package/scripts/canonical-apps.js +4 -4
  70. package/scripts/children/batch-delegate-child.js +40 -13
  71. package/scripts/children/http-delegate-child.js +2 -1
  72. package/scripts/dogfood-release.js +1 -1
  73. package/scripts/golden-path.js +4 -4
  74. package/scripts/release-flow.js +31 -17
@@ -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 {
@@ -453,7 +456,16 @@ function verifyReportBundle(archivePath, options = {}) {
453
456
  }
454
457
  if (options.extractReportTo && reportContent !== undefined) {
455
458
  reportExtractedTo = node_path_1.default.resolve(options.extractReportTo);
456
- node_fs_1.default.writeFileSync(reportExtractedTo, reportContent);
459
+ if (options.cwd) {
460
+ const baseCwd = node_path_1.default.resolve(options.cwd);
461
+ if (!(0, state_1.isContainedPath)(reportExtractedTo, baseCwd)) {
462
+ failedChecks.push({ name: "extract-report", code: "path-outside-working-directory" });
463
+ reportExtractedTo = undefined;
464
+ }
465
+ }
466
+ if (reportExtractedTo) {
467
+ node_fs_1.default.writeFileSync(reportExtractedTo, reportContent);
468
+ }
457
469
  }
458
470
  }
459
471
  catch (error) {
@@ -638,6 +650,28 @@ function normalizeArchiveFiles(raw) {
638
650
  };
639
651
  });
640
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
+ }
641
675
  /** NON-throwing digest/size/count/manifest verification: one structured check per
642
676
  * file (in import order), then the integrity file-count + manifest checks. Shared
643
677
  * by the throwing import path (verifyArchiveFileDigests) and the read-only
@@ -645,7 +679,12 @@ function normalizeArchiveFiles(raw) {
645
679
  function collectArchiveDigestChecks(files, integrity) {
646
680
  const checks = [];
647
681
  for (const file of files) {
648
- 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;
649
688
  const actual = sha256Bytes(bytes);
650
689
  const digestOk = actual === file.sha256;
651
690
  checks.push(digestOk
@@ -677,6 +716,7 @@ function archiveCheckMessage(check) {
677
716
  case "size-mismatch": return `Archive size mismatch for ${check.path}: expected ${check.expected}, got ${check.actual}`;
678
717
  case "file-count-mismatch": return `Archive file count mismatch: expected ${check.expected}, got ${check.actual}`;
679
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}`;
680
720
  default: return `Archive verification failed: ${check.name}`;
681
721
  }
682
722
  }
@@ -132,7 +132,12 @@ function resolveSandboxProfileById(id, context = defaultSandboxContext()) {
132
132
  // This closes the gap where `sandbox validate` accepted a custom profile that
133
133
  // dispatch/worker-isolation then refused — validated but never enforceable.
134
134
  // A non-bundled, non-file id still fails closed via showBundledSandboxProfile.
135
- const absolute = node_path_1.default.resolve(requested);
135
+ const absolute = node_path_1.default.resolve(context.cwd, requested);
136
+ if (!absolute.startsWith(node_path_1.default.resolve(context.cwd) + node_path_1.default.sep) && absolute !== node_path_1.default.resolve(context.cwd)) {
137
+ throw new SandboxProfileError("sandbox-profile-path-escape", `Custom profile path traversal denied: ${requested}`, {
138
+ details: { requested }
139
+ });
140
+ }
136
141
  if (node_fs_1.default.existsSync(absolute) && node_fs_1.default.statSync(absolute).isFile()) {
137
142
  const result = validateSandboxProfileFile(requested, context);
138
143
  if (!result.valid || !result.profile) {
package/dist/triggers.js CHANGED
@@ -147,7 +147,13 @@ function parseJsonObject(value) {
147
147
  return undefined;
148
148
  if (typeof value === "object")
149
149
  return value;
150
- const parsed = JSON.parse(String(value));
150
+ let parsed;
151
+ try {
152
+ parsed = JSON.parse(String(value));
153
+ }
154
+ catch {
155
+ throw new Error("Expected a JSON object, got invalid JSON");
156
+ }
151
157
  if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
152
158
  throw new Error("Expected JSON object");
153
159
  }
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.96";
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;
@@ -24,6 +24,7 @@ exports.WorkbenchHost = void 0;
24
24
  const node_http_1 = __importDefault(require("node:http"));
25
25
  const node_fs_1 = __importDefault(require("node:fs"));
26
26
  const node_path_1 = __importDefault(require("node:path"));
27
+ const node_crypto_1 = __importDefault(require("node:crypto"));
27
28
  const workbench_1 = require("./workbench");
28
29
  const ALLOWED_HOSTNAMES = new Set(["127.0.0.1", "localhost", "::1", "[::1]"]);
29
30
  const CONTENT_TYPES = {
@@ -93,11 +94,18 @@ class WorkbenchHost {
93
94
  if (requiredToken) {
94
95
  const bearer = (req.headers.authorization || "").replace(/^Bearer\s+/i, "").trim();
95
96
  const queryToken = url.searchParams.get("token") || "";
96
- if (bearer !== requiredToken && queryToken !== requiredToken) {
97
+ const tokenBuf = Buffer.from(requiredToken);
98
+ const bearerBuf = Buffer.from(bearer);
99
+ const queryBuf = Buffer.from(queryToken);
100
+ const tokenOk = bearerBuf.length === tokenBuf.length && node_crypto_1.default.timingSafeEqual(bearerBuf, tokenBuf);
101
+ const queryOk = queryBuf.length === tokenBuf.length && node_crypto_1.default.timingSafeEqual(queryBuf, tokenBuf);
102
+ if (!tokenOk && !queryOk) {
97
103
  return this.send(res, 401, { error: "unauthorized: token mismatch" });
98
104
  }
99
105
  }
100
- 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" });
101
109
  if (route === "/" || route === "/index.html")
102
110
  return this.sendAsset(res, "index.html");
103
111
  if (route.startsWith("/ui/"))
@@ -159,6 +167,14 @@ function isLocalHost(hostHeader) {
159
167
  const hostname = hostHeader.replace(/:\d+$/, "");
160
168
  return ALLOWED_HOSTNAMES.has(hostname);
161
169
  }
170
+ function decodeRoutePath(pathname) {
171
+ try {
172
+ return decodeURIComponent(pathname);
173
+ }
174
+ catch {
175
+ return undefined;
176
+ }
177
+ }
162
178
  function FALLBACK_HTML(uiRoot) {
163
179
  return [
164
180
  "<!doctype html><meta charset=utf-8><title>Cool Workflow Workbench</title>",
@@ -409,3 +409,7 @@ The one-command `cw -q` headline now routes the question and defaults the repo t
409
409
  0.1.95
410
410
 
411
411
  0.1.96
412
+
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
@@ -551,3 +561,7 @@ CLI golden-path fixes: `cw -q "…"` routes the question (was read as an app id
551
561
  0.1.95
552
562
 
553
563
  0.1.96
564
+
565
+ 0.1.97
566
+
567
+ 0.1.98
@@ -169,3 +169,7 @@ _No behavioral change in v0.1.89 (CLI-surface golden-path + help-output fixes on
169
169
  0.1.95
170
170
 
171
171
  0.1.96
172
+
173
+ 0.1.97
174
+
175
+ 0.1.98
@@ -153,3 +153,7 @@ _No behavioral change in v0.1.89 (CLI-surface golden-path + help-output fixes on
153
153
  0.1.95
154
154
 
155
155
  0.1.96
156
+
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).
package/docs/demo.7.md ADDED
@@ -0,0 +1,80 @@
1
+ # DEMO(7)
2
+
3
+ ## NAME
4
+
5
+ `cw demo` — prove CW trust guarantees with one command
6
+
7
+ ## SYNOPSIS
8
+
9
+ ```text
10
+ node dist/cli.js demo tamper [--json]
11
+ node dist/cli.js demo bundle [--json]
12
+ ```
13
+
14
+ ## DESCRIPTION
15
+
16
+ `cw demo` is a self-contained proof of CW's central trust claims. It works
17
+ without an agent and without a network connection. Every run is hermetic
18
+ (fully self-contained) — it builds its own state, tampers with it in known
19
+ ways, and checks that the tampering is caught. Nothing is read from or
20
+ written to the real file system outside a short-term temp directory.
21
+
22
+ No agent is needed; both demos work when the setup has no agent at all.
23
+
24
+ ## DEMO TAMPER
25
+
26
+ `cw demo tamper` proves that CW catches forged records offline — with only a
27
+ public key, no server. It:
28
+
29
+ 1. Builds a signed telemetry ledger with three hops.
30
+ 2. Tampers with it in three layers:
31
+ - **Hashes**: Changes a record's data and recomputes the record hash to hide
32
+ it. The hash chain breaks — the next record's `previousHash` does not
33
+ match, so the chain is no longer valid.
34
+ - **Signatures**: Inflates token counts and keeps the old signature. The
35
+ signature does not match the new data — the verifier catches it.
36
+ - **Findings**: Edits a signed finding (severity HIGH → LOW) after it was
37
+ signed by the agent. The signature check on the ed25519 envelope fails
38
+ because the signed bytes changed.
39
+ 3. Verifies each tampered ledger with only the public key.
40
+
41
+ If all three forgeries are caught, the proof holds and the demo exits 0.
42
+ If any tamper goes undetected, the demo exits 1 — this is a regression in
43
+ the integrity guarantee.
44
+
45
+ ## DEMO BUNDLE
46
+
47
+ `cw demo bundle` proves that exported report bundles are verifiable offline. It:
48
+
49
+ 1. Builds a full telemetry chain, signs it, and exports a sealed portable
50
+ bundle (archive bytes + telemetry chain + trust-audit chain + embedded
51
+ public key).
52
+ 2. Tampers with the bundle in two ways:
53
+ - **Telemetry chain**: Forges a record in the chain. The archive's file
54
+ digests stay valid (the archive was built from the tampered bytes), but
55
+ `report verify-bundle` re-checks the chain and catches it.
56
+ - **Signature + usage**: Inflates token counts and reseals. The signature
57
+ check and hash chain both break.
58
+ 3. Verifies each tampered bundle with `report verify-bundle`.
59
+
60
+ If all forgeries are caught with only the bundle's own public key, the proof
61
+ holds. No repo, no server, no key handed over.
62
+
63
+ ## EXIT CODES
64
+
65
+ | Exit | Meaning |
66
+ | --- | --- |
67
+ | 0 | All tampering was caught — trust guarantees hold |
68
+ | 1 | A tamper went undetected — integrity guarantee regression |
69
+
70
+ ## FILES
71
+
72
+ ```text
73
+ src/telemetry-demo.ts
74
+ ```
75
+
76
+ ## SEE ALSO
77
+
78
+ report-verifiable-bundle.7.md — offline bundle verification in detail
79
+ trust-model.md — the trust model and its limits
80
+ security-trust-hardening.7.md — security and trust hardening