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.
- 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/candidate-scoring.js +3 -3
- package/dist/capability-core.js +9 -1
- 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/cli/handlers/scheduling.js +7 -1
- package/dist/drive.js +108 -61
- package/dist/execution-backend/agent.js +84 -24
- package/dist/execution-backend.js +25 -5
- package/dist/ledger.js +313 -0
- package/dist/mcp/tool-call.js +36 -0
- package/dist/mcp/tool-definitions.js +26 -0
- package/dist/mcp-server.js +4 -0
- package/dist/onramp.js +2 -0
- package/dist/orchestrator/app-operations.js +6 -0
- package/dist/orchestrator/cli-options.js +8 -2
- package/dist/orchestrator/lifecycle-operations.js +40 -13
- package/dist/orchestrator/migration-operations.js +1 -1
- package/dist/orchestrator.js +11 -3
- package/dist/remote-source.js +10 -3
- package/dist/run-export.js +45 -5
- package/dist/sandbox-profile.js +6 -1
- package/dist/triggers.js +7 -1
- package/dist/version.js +1 -1
- package/dist/workbench-host.js +18 -2
- package/docs/agent-delegation-drive.7.md +4 -0
- package/docs/cli-mcp-parity.7.md +16 -2
- package/docs/contract-migration-tooling.7.md +4 -0
- package/docs/control-plane-scheduling.7.md +4 -0
- package/docs/cross-agent-ledger.7.md +217 -0
- package/docs/demo.7.md +80 -0
- package/docs/designs/handoff-ledger.md +145 -0
- package/docs/doctor.7.md +97 -0
- package/docs/durable-state-and-locking.7.md +4 -0
- package/docs/evidence-adoption-reasoning-chain.7.md +4 -0
- package/docs/execution-backends.7.md +4 -0
- package/docs/fix.7.md +44 -0
- package/docs/handoff-setup.md +120 -0
- package/docs/init.7.md +62 -0
- package/docs/multi-agent-cli-mcp-surface.7.md +4 -0
- package/docs/multi-agent-eval-replay-harness.7.md +4 -0
- package/docs/multi-agent-operator-ux.7.md +4 -0
- package/docs/node-snapshot-diff-replay.7.md +4 -0
- package/docs/observability-cost-accounting.7.md +4 -0
- package/docs/pipeline-verbs.7.md +93 -0
- package/docs/project-index.md +28 -5
- package/docs/real-execution-backends.7.md +4 -0
- package/docs/release-and-migration.7.md +4 -0
- package/docs/release-tooling.7.md +4 -0
- package/docs/routine.7.md +73 -0
- package/docs/run-registry-control-plane.7.md +4 -0
- package/docs/run-retention-reclamation.7.md +4 -0
- package/docs/state-explosion-management.7.md +4 -0
- package/docs/team-collaboration.7.md +4 -0
- package/docs/web-desktop-workbench.7.md +4 -0
- package/manifest/README.md +16 -10
- package/manifest/plugin.manifest.json +1 -1
- package/package.json +1 -1
- package/scripts/agents/agent-adapter-core.js +4 -1
- package/scripts/agents/codex-agent.js +34 -4
- package/scripts/canonical-apps.js +4 -4
- package/scripts/children/batch-delegate-child.js +40 -13
- package/scripts/children/http-delegate-child.js +2 -1
- package/scripts/dogfood-release.js +1 -1
- package/scripts/golden-path.js +4 -4
- package/scripts/release-flow.js +31 -17
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 {
|
|
@@ -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
|
-
|
|
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
|
|
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
|
}
|
package/dist/sandbox-profile.js
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
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
|
@@ -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
|
-
|
|
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 =
|
|
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>",
|
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
|
|
@@ -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
|
|
@@ -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
|