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.
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/apps/architecture-review/app.json +1 -1
- package/apps/architecture-review-fast/app.json +1 -1
- package/apps/end-to-end-golden-path/app.json +1 -1
- package/apps/pr-review-fix-ci/app.json +1 -1
- package/apps/release-cut/app.json +1 -1
- package/apps/research-synthesis/app.json +1 -1
- package/dist/capability-core.js +3 -0
- package/dist/capability-registry.js +7 -1
- package/dist/cli/command-surface.js +4 -0
- package/dist/cli/handlers/ledger.js +169 -0
- package/dist/drive.js +98 -61
- package/dist/execution-backend/agent.js +84 -24
- package/dist/ledger.js +313 -0
- package/dist/mcp/tool-call.js +36 -0
- package/dist/mcp/tool-definitions.js +26 -0
- package/dist/orchestrator/lifecycle-operations.js +37 -13
- package/dist/orchestrator.js +11 -3
- package/dist/remote-source.js +10 -3
- package/dist/run-export.js +35 -4
- package/dist/version.js +1 -1
- package/dist/workbench-host.js +11 -1
- package/docs/agent-delegation-drive.7.md +2 -0
- package/docs/cli-mcp-parity.7.md +14 -2
- package/docs/contract-migration-tooling.7.md +2 -0
- package/docs/control-plane-scheduling.7.md +2 -0
- package/docs/cross-agent-ledger.7.md +217 -0
- package/docs/designs/handoff-ledger.md +145 -0
- package/docs/durable-state-and-locking.7.md +2 -0
- package/docs/evidence-adoption-reasoning-chain.7.md +2 -0
- package/docs/execution-backends.7.md +2 -0
- package/docs/handoff-setup.md +120 -0
- package/docs/multi-agent-cli-mcp-surface.7.md +2 -0
- package/docs/multi-agent-eval-replay-harness.7.md +2 -0
- package/docs/multi-agent-operator-ux.7.md +2 -0
- package/docs/node-snapshot-diff-replay.7.md +2 -0
- package/docs/observability-cost-accounting.7.md +2 -0
- package/docs/project-index.md +16 -5
- package/docs/real-execution-backends.7.md +2 -0
- package/docs/release-and-migration.7.md +2 -0
- package/docs/release-tooling.7.md +2 -0
- package/docs/run-registry-control-plane.7.md +2 -0
- package/docs/run-retention-reclamation.7.md +2 -0
- package/docs/state-explosion-management.7.md +2 -0
- package/docs/team-collaboration.7.md +2 -0
- package/docs/web-desktop-workbench.7.md +2 -0
- package/manifest/plugin.manifest.json +1 -1
- package/package.json +1 -1
- package/scripts/agents/codex-agent.js +34 -4
- package/scripts/canonical-apps.js +4 -4
- package/scripts/children/batch-delegate-child.js +30 -10
- package/scripts/dogfood-release.js +1 -1
- package/scripts/golden-path.js +4 -4
- package/scripts/release-flow.js +7 -1
package/dist/orchestrator.js
CHANGED
|
@@ -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 =
|
|
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.
|
package/dist/remote-source.js
CHANGED
|
@@ -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
|
-
"
|
|
291
|
-
"
|
|
292
|
-
"
|
|
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);
|
package/dist/run-export.js
CHANGED
|
@@ -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,
|
|
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
|
-
|
|
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
|
|
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.
|
|
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;
|
package/dist/workbench-host.js
CHANGED
|
@@ -103,7 +103,9 @@ class WorkbenchHost {
|
|
|
103
103
|
return this.send(res, 401, { error: "unauthorized: token mismatch" });
|
|
104
104
|
}
|
|
105
105
|
}
|
|
106
|
-
const route =
|
|
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>",
|
package/docs/cli-mcp-parity.7.md
CHANGED
|
@@ -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:
|
|
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
|
-
|
|
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
|
|
@@ -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".
|