🎩 You're Invited:Meet the Socket team at Black Hat in Las Vegas, August 3-6.RSVP
Sign In

cool-workflow

Package Overview
Dependencies
Maintainers
1
Versions
28
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

cool-workflow - npm Package Compare versions

Comparing version
0.1.97
to
0.1.98
+169
dist/cli/handlers/ledger.js
"use strict";
// `cw ledger propose|review|verify` — the cross-agent handoff ledger CLI surface.
// A proposing agent prints a proposal or a review verdict as a verifiable JSON
// entry; the receiving side verifies it fail-closed before acting. See
// docs/cross-agent-ledger.7.md and docs/designs/handoff-ledger.md.
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || (function () {
var ownKeys = function(o) {
ownKeys = Object.getOwnPropertyNames || function (o) {
var ar = [];
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
return ar;
};
return ownKeys(o);
};
return function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
__setModuleDefault(result, mod);
return result;
};
})();
Object.defineProperty(exports, "__esModule", { value: true });
exports.handleLedger = handleLedger;
const fs = __importStar(require("fs"));
const ledger_1 = require("../../ledger");
const io_1 = require("../io");
/** Coerce a repeatable/comma-joined list option to a clean string[]. */
function listOption(value) {
const parts = Array.isArray(value) ? value : typeof value === "string" ? value.split(",") : [];
return parts.map((p) => String(p).trim()).filter(Boolean);
}
function stringOption(value) {
return typeof value === "string" && value.trim() ? value.trim() : undefined;
}
function nowIso() {
return new Date().toISOString();
}
function handleLedger(args, _runner) {
const [subcommand] = args.positionals;
const opts = args.options;
switch (subcommand) {
case "propose": {
const entry = (0, ledger_1.buildLedgerProposal)({
from: (0, io_1.required)(stringOption(opts.from), "--from <agent/repo>"),
to: (0, io_1.required)(stringOption(opts.to), "--to <agent/repo>"),
title: (0, io_1.required)(stringOption(opts.title), "--title <text>"),
rationale: (0, io_1.required)(stringOption(opts.rationale), "--rationale <text>"),
targetFiles: listOption(opts.files),
// Do NOT trim the diff: it is a unified patch (payload, not a label), and
// trimming strips the trailing newline `git apply` requires — a trimmed
// diff is a corrupt patch. Presence is detected with a trimmed test, but
// the bytes are passed through verbatim (matching the MCP propose path).
suggestedDiff: typeof opts.diff === "string" && opts.diff.trim() ? opts.diff : undefined,
createdAt: nowIso()
});
(0, io_1.printJson)(entry);
return;
}
case "review": {
const verdictRaw = (0, io_1.required)(stringOption(opts.verdict), "--verdict <approved|rejected>").toUpperCase();
if (verdictRaw !== "APPROVED" && verdictRaw !== "REJECTED") {
throw new Error('--verdict must be "approved" or "rejected".');
}
const entry = (0, ledger_1.buildLedgerReview)({
from: (0, io_1.required)(stringOption(opts.from), "--from <agent/repo>"),
to: (0, io_1.required)(stringOption(opts.to), "--to <agent/repo>"),
target: (0, io_1.required)(stringOption(opts.target), "--target <proposal-id|pr-ref>"),
verdict: verdictRaw,
findings: listOption(opts.findings),
createdAt: nowIso()
});
(0, io_1.printJson)(entry);
return;
}
case "verify": {
const file = stringOption(opts.file);
let text;
try {
// --file <path>, else read the entry from stdin (fd 0).
text = fs.readFileSync(file || 0, "utf8");
}
catch (error) {
throw new Error(`Cannot read ledger entry${file ? ` from ${file}` : " from stdin"}: ${error.message}`);
}
let parsed;
try {
parsed = JSON.parse(text);
}
catch {
// A non-JSON input is itself a fail-closed refusal, not a crash.
(0, io_1.printJson)({ ok: false, id: null, kind: null, checks: [{ name: "parse", pass: false, code: "ledger-bad-json" }], failedChecks: [{ name: "parse", code: "ledger-bad-json" }] });
process.exitCode = 1;
return;
}
const result = (0, ledger_1.verifyLedgerEntry)(parsed);
(0, io_1.printJson)(result);
// Fail-closed: a tampered/malformed entry exits non-zero so
// `cw ledger verify <file> && open-pr` cannot proceed on a lie.
if (!result.ok)
process.exitCode = 1;
return;
}
case "apply": {
const file = stringOption(opts.file);
let text;
try {
// --file <path>, else read the entry from stdin (fd 0), same as verify.
text = fs.readFileSync(file || 0, "utf8");
}
catch (error) {
throw new Error(`Cannot read ledger entry${file ? ` from ${file}` : " from stdin"}: ${error.message}`);
}
let parsed;
try {
parsed = JSON.parse(text);
}
catch {
(0, io_1.printJson)({ ok: false, id: null, kind: null, diff: null, failedChecks: [{ name: "parse", code: "ledger-bad-json" }] });
process.exitCode = 1;
return;
}
const result = (0, ledger_1.applyLedgerProposal)(parsed);
(0, io_1.printJson)(result);
// Fail-closed: the diff only comes out (ok:true) when the proposal verifies,
// so `cw ledger apply <file> | git apply` never feeds git an unverified patch.
if (!result.ok)
process.exitCode = 1;
return;
}
case "list": {
// `--dir` is repeatable: 2+ dirs union-verify multiple mirrors into one
// inbox; a single --dir keeps the original single-directory output (POLA).
const dirs = Array.isArray(opts.dir) ? opts.dir.map(String).filter(Boolean) : [];
if (dirs.length > 1) {
const union = (0, ledger_1.unionLedgerEntries)(dirs);
(0, io_1.printJson)(union);
if (!union.allOk)
process.exitCode = 1;
return;
}
const dir = (0, io_1.required)(dirs[0] || stringOption(opts.dir), "--dir <ledger-directory>");
const result = (0, ledger_1.listLedgerEntries)(dir);
(0, io_1.printJson)(result);
// Fail-closed inbox: refuse the whole batch if any entry does not verify.
if (!result.allOk)
process.exitCode = 1;
return;
}
default:
throw new Error("Usage: cw ledger propose|review|verify|apply|list [options]");
}
}
"use strict";
// Cross-agent handoff ledger — the core mechanism for two agents scoped to two
// separate repos to hand each other a CHANGE PROPOSAL or a REVIEW VERDICT as
// verifiable data, not chat. Design: docs/designs/handoff-ledger.md.
//
// Stage 1 (human-relay transport): a ledger entry is a self-contained JSON
// object carrying its own sha256 content digest. The producing side prints one;
// the operator carries it to the other session; the consuming side VERIFIES it
// fail-closed (a tampered or malformed entry is refused, never acted on) before
// turning a proposal into a real PR or recording a verdict.
//
// Zero-dependency (only node stdlib). `build*`/`verify*` are pure; the stage-2
// git transport adds `listLedgerEntries`, a READ-ONLY scan of a shared ledger
// directory (the working tree of a handoff repo) that verifies every entry
// fail-closed. No run state, no writes, no network.
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || (function () {
var ownKeys = function(o) {
ownKeys = Object.getOwnPropertyNames || function (o) {
var ar = [];
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
return ar;
};
return ownKeys(o);
};
return function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
__setModuleDefault(result, mod);
return result;
};
})();
Object.defineProperty(exports, "__esModule", { value: true });
exports.computeLedgerDigest = computeLedgerDigest;
exports.buildLedgerProposal = buildLedgerProposal;
exports.buildLedgerReview = buildLedgerReview;
exports.verifyLedgerEntry = verifyLedgerEntry;
exports.applyLedgerProposal = applyLedgerProposal;
exports.listLedgerEntries = listLedgerEntries;
exports.unionLedgerEntries = unionLedgerEntries;
exports.resolveLedgerInbox = resolveLedgerInbox;
const crypto = __importStar(require("crypto"));
const fs = __importStar(require("fs"));
const path = __importStar(require("path"));
/** Deterministic JSON with recursively sorted object keys, so the digest is a
* function of content only — never key insertion order. */
function stableStringify(value) {
if (value === null || typeof value !== "object")
return JSON.stringify(value);
if (Array.isArray(value))
return `[${value.map(stableStringify).join(",")}]`;
const keys = Object.keys(value).sort();
const body = keys
.map((k) => `${JSON.stringify(k)}:${stableStringify(value[k])}`)
.join(",");
return `{${body}}`;
}
/** sha256 over the canonical content (every field except `id` and `digest`,
* which are derived FROM it). Returns the full `sha256:<hex>` form. */
function computeLedgerDigest(entry) {
const hash = crypto.createHash("sha256");
hash.update(stableStringify(entry));
return `sha256:${hash.digest("hex")}`;
}
/** Content-addressed id: `ldg-` + the first 16 hex chars of the digest. Two
* entries with the same content (and createdAt) get the same id. */
function deriveId(digest) {
return `ldg-${digest.replace(/^sha256:/, "").slice(0, 16)}`;
}
function seal(content) {
const digest = computeLedgerDigest(content);
return { ...content, id: deriveId(digest), digest };
}
function buildLedgerProposal(input) {
const content = {
kind: "proposal",
schemaVersion: 1,
from: input.from,
to: input.to,
title: input.title,
rationale: input.rationale,
targetFiles: [...input.targetFiles],
suggestedDiff: input.suggestedDiff || "",
createdAt: input.createdAt
};
return seal(content);
}
function buildLedgerReview(input) {
const content = {
kind: "review",
schemaVersion: 1,
from: input.from,
to: input.to,
target: input.target,
verdict: input.verdict,
findings: [...input.findings],
createdAt: input.createdAt
};
return seal(content);
}
function isRecord(value) {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
const PROPOSAL_FIELDS = ["from", "to", "title", "rationale", "targetFiles", "suggestedDiff", "createdAt"];
const REVIEW_FIELDS = ["from", "to", "target", "verdict", "findings", "createdAt"];
/** Fail-closed verification. Any structural defect, unknown kind, or digest
* mismatch yields `ok:false` — the caller refuses to act on it. */
function verifyLedgerEntry(raw) {
const checks = [];
const fail = (name, code, detail) => {
checks.push({ name, pass: false, code, detail });
return {
ok: false,
id: isRecord(raw) && typeof raw.id === "string" ? raw.id : null,
kind: isRecord(raw) && typeof raw.kind === "string" ? raw.kind : null,
checks,
failedChecks: checks.filter((c) => !c.pass).map((c) => ({ name: c.name, code: c.code, detail: c.detail }))
};
};
if (!isRecord(raw))
return fail("structure", "ledger-not-object", "entry is not a JSON object");
checks.push({ name: "structure", pass: true });
const kind = raw.kind;
if (kind !== "proposal" && kind !== "review")
return fail("kind", "ledger-unknown-kind", `kind must be proposal|review, got ${JSON.stringify(kind)}`);
checks.push({ name: "kind", pass: true });
if (raw.schemaVersion !== 1)
return fail("schema", "ledger-bad-schema", `schemaVersion must be 1, got ${JSON.stringify(raw.schemaVersion)}`);
checks.push({ name: "schema", pass: true });
if (typeof raw.digest !== "string" || !raw.digest)
return fail("digest-present", "ledger-missing-digest", "digest is absent or not a string");
checks.push({ name: "digest-present", pass: true });
const fields = kind === "proposal" ? PROPOSAL_FIELDS : REVIEW_FIELDS;
const content = { kind, schemaVersion: 1 };
for (const field of fields) {
if (!(field in raw))
return fail("fields", "ledger-missing-field", `required field ${field} is absent`);
content[field] = raw[field];
}
if (kind === "review" && raw.verdict !== "APPROVED" && raw.verdict !== "REJECTED") {
return fail("verdict", "ledger-bad-verdict", `verdict must be APPROVED|REJECTED, got ${JSON.stringify(raw.verdict)}`);
}
checks.push({ name: "fields", pass: true });
const recomputed = computeLedgerDigest(content);
if (recomputed !== raw.digest) {
return fail("digest", "ledger-digest-mismatch", `stored digest does not match content (recomputed ${recomputed})`);
}
checks.push({ name: "digest", pass: true });
// Bind the id to the content: it MUST be the content-addressed id derived from
// the digest. Without this, `id` is a free, unverified field (it is excluded
// from the digest) — a forged entry could set `id` to collide with a legit
// one, and any id-keyed de-duplication (`cw ledger list` union) would silently
// drop one of them. Fail closed so a spoofed or absent id is refused, not
// trusted.
const expectedId = deriveId(raw.digest);
if (raw.id !== expectedId) {
return fail("id", "ledger-id-mismatch", `id ${JSON.stringify(raw.id)} is not the content-addressed id for this digest (expected ${expectedId})`);
}
checks.push({ name: "id", pass: true });
return { ok: true, id: expectedId, kind, checks, failedChecks: [] };
}
/** Fail-closed extraction of a proposal's `suggestedDiff` for `git apply`. The
* diff can ONLY escape after the entry verifies: a tampered entry, a review
* (not a proposal), or a proposal with no diff all yield `ok:false` and
* `diff:null`, so `cw ledger apply <file> | git apply` can never feed an
* unverified patch to git. The kernel never shells out to git — turning the
* diff into a patch stays the operator's step (mechanism, not policy). */
function applyLedgerProposal(raw) {
const verified = verifyLedgerEntry(raw);
if (!verified.ok) {
return { ok: false, id: verified.id, kind: verified.kind, diff: null, failedChecks: verified.failedChecks };
}
if (verified.kind !== "proposal") {
return { ok: false, id: verified.id, kind: verified.kind, diff: null, failedChecks: [{ name: "kind", code: "ledger-not-a-proposal", detail: "apply expects a proposal entry, not a review" }] };
}
const rec = isRecord(raw) ? raw : {};
const diff = typeof rec.suggestedDiff === "string" ? rec.suggestedDiff : "";
if (!diff) {
return { ok: false, id: verified.id, kind: verified.kind, diff: null, failedChecks: [{ name: "diff", code: "ledger-empty-diff", detail: "proposal carries no suggestedDiff to apply" }] };
}
return { ok: true, id: verified.id, kind: verified.kind, diff, failedChecks: [] };
}
/** Read every `*.json` in `dir`, verify each entry fail-closed, and report.
* `allOk` is false if any entry is tampered, malformed, or unreadable — so the
* receiving side refuses the whole inbox rather than acting on a mixed batch. */
function listLedgerEntries(dir) {
let names;
try {
names = fs.readdirSync(dir).filter((n) => n.endsWith(".json")).sort();
}
catch (error) {
const entry = { file: dir, id: null, kind: null, from: null, to: null, title: null, target: null, verdict: null, ok: false, failedChecks: [{ name: "dir", code: "ledger-dir-unreadable", detail: error.message }] };
return { dir, count: 0, allOk: false, entries: [entry], resolution: resolveLedgerInbox([entry]) };
}
const entries = names.map((name) => {
const file = path.join(dir, name);
let raw;
try {
const stat = fs.lstatSync(file);
if (!stat.isFile()) {
return { file: name, id: null, kind: null, from: null, to: null, title: null, target: null, verdict: null, ok: false, failedChecks: [{ name: "file", code: "ledger-entry-not-regular" }] };
}
raw = JSON.parse(fs.readFileSync(file, "utf8"));
}
catch {
return { file: name, id: null, kind: null, from: null, to: null, title: null, target: null, verdict: null, ok: false, failedChecks: [{ name: "parse", code: "ledger-bad-json" }] };
}
const result = verifyLedgerEntry(raw);
const rec = isRecord(raw) ? raw : {};
return {
file: name,
id: result.id,
kind: result.kind,
from: typeof rec.from === "string" ? rec.from : null,
to: typeof rec.to === "string" ? rec.to : null,
title: typeof rec.title === "string" ? rec.title : null,
target: typeof rec.target === "string" ? rec.target : null,
verdict: typeof rec.verdict === "string" ? rec.verdict : null,
ok: result.ok,
failedChecks: result.failedChecks
};
});
return { dir, count: entries.length, allOk: entries.every((e) => e.ok), entries, resolution: resolveLedgerInbox(entries) };
}
/** Union-verify several mirror directories into ONE fail-closed inbox. Verified
* entries are de-duplicated by their content-addressed id (the same entry
* mirrored to N hosts collapses to one, recording every mirror it came from);
* failing entries are kept per-occurrence so every problem in every mirror is
* visible. `allOk` is false if ANY entry in ANY mirror does not verify — a
* tampered mirror fails the whole batch. Safe because entries are immutable and
* content-addressed, so a union is a conflict-free set-union, not a merge. */
function unionLedgerEntries(dirs) {
const byId = new Map();
const failures = [];
let allOk = true;
for (const dir of dirs) {
const listed = listLedgerEntries(dir);
if (!listed.allOk)
allOk = false;
for (const entry of listed.entries) {
if (entry.ok && entry.id) {
const existing = byId.get(entry.id);
if (existing) {
if (!existing.dirs.includes(dir))
existing.dirs.push(dir);
}
else {
byId.set(entry.id, { ...entry, dirs: [dir] });
}
}
else {
failures.push({ ...entry, dirs: [dir] });
}
}
}
const entries = [...byId.values(), ...failures];
return { dirs, count: entries.length, allOk, entries, resolution: resolveLedgerInbox(entries) };
}
/** Derive a machine-actionable inbox summary: pair each proposal with the
* review(s) that target it and report whether it is pending, approved,
* rejected, or contested. Only VERIFIED entries take part — a tampered review
* must never resolve a proposal, so a proposal with only a failing review
* stays `pending` (fail-closed). Pure derivation over content-addressed
* entries: no git, no network, no policy (it reports the decision, it does not
* enforce one). */
function resolveLedgerInbox(entries) {
const verified = entries.filter((e) => e.ok);
const reviews = verified.filter((e) => e.kind === "review" && e.target);
const proposals = verified
.filter((e) => e.kind === "proposal" && e.id)
.map((p) => {
const answering = reviews.filter((r) => r.target === p.id);
const verdicts = new Set(answering.map((r) => r.verdict));
let resolution;
if (answering.length === 0)
resolution = "pending";
else if (verdicts.size > 1)
resolution = "contested";
else
resolution = verdicts.has("APPROVED") ? "approved" : "rejected";
return {
id: p.id,
title: p.title,
resolution,
reviews: answering.map((r) => r.id).sort()
};
})
.sort((a, b) => a.id.localeCompare(b.id));
const tally = (s) => proposals.filter((p) => p.resolution === s).length;
return {
proposals,
pending: tally("pending"),
approved: tally("approved"),
rejected: tally("rejected"),
contested: tally("contested")
};
}
# Cross-Agent Handoff Ledger
CW adds `cw ledger` — a way for two agents scoped to two separate repos to hand
each other a CHANGE PROPOSAL or a REVIEW VERDICT as verifiable data, not chat.
One side proposes or reviews; the other side verifies the entry fail-closed and
turns a proposal into a real pull request. Design notes:
[handoff-ledger](designs/handoff-ledger.md).
Each entry is a self-contained JSON object that carries its own sha256 content
digest. The producing side prints one; it reaches the other session by human
relay or a shared git repo (below); the consuming side runs `cw ledger verify`
(one entry) or `cw ledger list` (a whole directory) before acting. A tampered or
malformed entry is refused with a non-zero exit, so
`cw ledger verify <file> && open-pr` can never proceed on a lie.
Every verb is on both surfaces: the CLI (`cw ledger ...`) and MCP
(`cw_ledger_propose`, `cw_ledger_review`, `cw_ledger_verify`, `cw_ledger_list`),
so an agent can mint and check entries in-process, not only from a shell.
`cw ledger` is a NEW verb. It does not touch `cw handoff`, which is a separate
collaboration primitive (ownership transfer of a run/task) — see
[team-collaboration](team-collaboration.7.md).
## Why a ledger, not a shared folder
The two agents run as two separate cloud sessions. They share no filesystem, and
each is scoped to one repo at launch, so a local folder cannot be the channel.
The only medium both sides can durably reach is git/GitHub. `cw ledger` therefore
produces and consumes portable, self-verifying entries; how an entry crosses
(operator relay now, a shared handoff repo later) is transport, kept separate
from the verb.
## Mechanism vs policy
The MECHANISM is small and lives in the kernel (`src/ledger.ts`): build a
proposal or a review entry, seal it with a sha256 digest over its canonical
content, and verify that digest fail-closed. No run state, no network, no new
runtime dependency. The POLICY — which repos, who may propose, whether a verdict
blocks a merge — stays outside, in the operator's hands and the transport.
The digest is computed over a deterministic serialization (object keys sorted
recursively) of every field except `id` and `digest`, which are derived from it.
The `id` is content-addressed: `ldg-` + the first 16 hex chars of the digest.
## Commands
```
cw ledger propose --from <a> --to <b> --title <t> --rationale <r> \
[--files a.ts,b.ts] [--diff <patch>]
cw ledger review --from <a> --to <b> --target <proposal-id|pr-ref> \
--verdict <approved|rejected> [--findings "a,b"]
cw ledger verify [--file <path>] # else reads the entry from stdin
cw ledger apply [--file <path>] # verify a proposal, then print its diff
cw ledger list --dir <ledger-dir> [--dir <mirror-2> ...] # verify a dir (or union of mirrors)
```
All write JSON to stdout (stdout is data). `propose` and `review` print a sealed
entry; `verify` prints a check report; `apply` prints a verify-plus-diff report;
`list` prints a per-entry report over a directory.
## Applying a proposal — fail-closed
A proposal carries a `suggestedDiff`, but a proposal never mutates the target
repo by itself: the write-capable side turns it into a real change. `cw ledger
apply` is the fail-closed bridge — it verifies the entry FIRST and only then
emits the diff, so an unverified patch can never reach `git`:
```
cw ledger apply --file proposal.json | jq -r 'select(.ok).diff' | git apply -
```
`apply` prints `{ ok, id, kind, diff }`. The `diff` is present **only** when
`ok` is `true` — a tampered entry (`ok:false`, the `verify` failure codes), a
review rather than a proposal (`ledger-not-a-proposal`), or a proposal with no
`suggestedDiff` (`ledger-empty-diff`) all yield `diff:null` and exit `1`. The
kernel never runs `git`; turning the verified diff into a commit stays the
operator's (or a wrapper's) step — mechanism, not policy.
## Git transport (T2a — shared handoff repo)
The two agents cannot share a local folder, but they can share a git repo both
are scoped to. The ledger rides on it with no git logic in the kernel — writing
is composition through files, and `git` is the operator's (or a wrapper's) step:
```
# Producing side (cool-workflow), inside the shared repo's working tree:
cw ledger propose --from cool-workflow --to chime \
--title "Add retry" --rationale "flaky net" --files src/net.ts \
> ledger/$(cw ledger propose ... | jq -r .id).json # or any unique name
git add ledger/ && git commit -m "propose: add retry" && git push
# Consuming side (chime):
git pull
cw ledger list --dir ledger && echo "inbox verified — safe to act"
```
`cw ledger list` reads every `*.json` in the directory, verifies each entry, and
reports `allOk`. It is a **fail-closed inbox**: if any single entry is tampered,
malformed, or unreadable, `allOk` is `false` and the command exits `1`, so the
receiving side refuses the whole batch rather than acting on a mixed one.
### Inbox resolution — which proposals are still open
`cw ledger list` also derives a `resolution` summary so the inbox is
machine-actionable without opening each file. It pairs every proposal with the
review(s) whose `target` is that proposal's id and reports one of four states:
```json
"resolution": {
"proposals": [
{ "id": "ldg-1de7c92172af1871", "title": "Add retry", "resolution": "approved", "reviews": ["ldg-…"] },
{ "id": "ldg-…", "title": "Rename thing", "resolution": "pending", "reviews": [] }
],
"pending": 1, "approved": 1, "rejected": 0, "contested": 0
}
```
- `pending` — no verified review targets the proposal yet.
- `approved` / `rejected` — every verified review targeting it agrees.
- `contested` — verified reviews targeting it disagree (both an APPROVED and a
REJECTED exist); the ledger REPORTS the disagreement, it does not pick a
winner (mechanism, not policy — whether a verdict blocks a merge stays
outside).
Only **verified** entries take part: a tampered review can never resolve a
proposal, so a proposal answered only by a failing review stays `pending`
(fail-closed). The fields are additive — the existing `entries[]` / `allOk` /
`count` output is byte-unchanged (POLA), with each entry now also carrying its
`title` (proposals) or `target`/`verdict` (reviews). The same `resolution` rides
on the mirror-union output and on the `cw_ledger_list` MCP tool.
### Mirrors — union-verifying several directories
`--dir` is repeatable. With two or more, `cw ledger list` **union-verifies** the
directories as mirrors of one ledger (e.g. the same handoff repo cloned from a
GitHub remote and one or more self-hosted Gitea remotes in different places):
```
cw ledger list --dir gh/ledger --dir gitea-eu/ledger --dir gitea-asia/ledger
```
The union is **conflict-free by construction**: entries are immutable and
content-addressed, so the same entry mirrored to several hosts collapses to one
result whose `dirs` records every mirror it was found in. It stays **fail-closed
across mirrors** — a tampered entry in ANY mirror sets `allOk:false` and exits
`1`. This is for redundancy and reachability, not load: the ledger's traffic is
tiny; multiple hosts guard against one being down or unreachable.
A single `--dir` keeps the original single-directory output (a `dir` field, no
`dirs`); two or more switch to the union shape (`dirs` plus a per-entry `dirs`).
The transport stays git-host-agnostic — adding a mirror is one more clone + one
more `--dir`, no code change.
## Entry shape
A proposal:
```json
{
"kind": "proposal",
"schemaVersion": 1,
"from": "cool-workflow",
"to": "chime",
"title": "Add retry to the fetch path",
"rationale": "the network is flaky under load",
"targetFiles": ["src/net.ts"],
"suggestedDiff": "@@ ... @@",
"createdAt": "<iso>",
"id": "ldg-<16 hex>",
"digest": "sha256:<64 hex>"
}
```
A review is the same envelope with `kind: "review"`, plus `target` (the proposal
id or a PR ref), `verdict` (`APPROVED` | `REJECTED`), and `findings` (a list).
## Verification contract
`cw ledger verify` re-proves the entry and exits fail-closed:
- Not a JSON object, or non-JSON bytes → `ok:false`, code `ledger-bad-json` /
`ledger-not-object`.
- Unknown `kind`, wrong `schemaVersion`, missing digest, missing a required
field, or a bad `verdict` → `ok:false` with the matching code.
- Stored digest does not match a fresh digest of the content →
`ok:false`, code `ledger-digest-mismatch`.
- `id` is not the content-addressed id for the digest (spoofed or absent) →
`ok:false`, code `ledger-id-mismatch`. `id` is excluded from the digest, so it
is bound to the content by this check — a forged entry cannot set its `id` to
collide with a legitimate one and slip through the mirror-union de-duplication.
Any `ok:false` exits `1`. An intact entry exits `0` with `ok:true`.
## Example round-trip
```
# On the proposing side (cool-workflow):
cw ledger propose --from cool-workflow --to chime \
--title "Add retry" --rationale "flaky net" \
--files src/net.ts --diff "$(git diff)" > proposal.json
# The operator carries proposal.json to the chime session, which checks it:
cw ledger verify --file proposal.json && echo "safe to open a PR"
# The reviewing side hands a verdict back:
cw ledger review --from chime --to cool-workflow \
--target ldg-1de7c92172af1871 --verdict approved \
--findings "tests pass,scope ok" > verdict.json
cw ledger verify --file verdict.json
```
## Roadmap
Stage 1 shipped the CLI verbs (human relay). Stage 2 adds the MCP surface and
the git-as-ledger transport (`cw ledger list` over a shared repo). Still open:
the operator creates the shared handoff repo and scopes both agent environments
into it. See [handoff-ledger](designs/handoff-ledger.md).
# Design — Cross-agent handoff ledger
Status: DRAFT / proposal. Nothing here is built yet. This file ships no
behavior, no new command, no man-page contract, and changes no existing
output. It exists so two people (the operator and the reviewer agent) can
agree on the shape before any code is written.
North Star track: **Track B** (portable, verifiable state — the same
`run export` → `run restore` recovery story, now used as the channel
between two agents).
## Goal
Two agents work on two repositories:
- one agent scoped to repo **A** (for example `cool-workflow`),
- one agent scoped to repo **B** (for example `chime`).
The operator wants them to "share data, review each other, and each be
able to raise a pull request to the other". In plain terms:
- each side can hand the other a **change proposal**, and
- each side can hand the other a **review verdict** on a diff or PR,
- with saved, inspectable, fail-closed state — never a fabricated hand-off.
## The hard constraint (why the obvious design does not work)
The first idea is a shared local folder (for example `~/.chime/handoff/`)
that both agents read and append to. That only works when both agents run
on **one machine** with **one filesystem**.
In the operator's setup the two agents run as **two separate cloud
sessions**. Each session is a fresh, throwaway VM. Two facts follow, and
the design must respect both:
1. **No shared filesystem.** A file the B-agent writes to `~/.chime/handoff/`
in its VM is invisible to the A-agent's VM, and is gone when the session
ends. A local folder cannot be the channel.
2. **Single-repo scope.** Each session's GitHub reach is scoped to one repo
at launch (A-agent → repo A, B-agent → repo B). The A-agent cannot read
repo B through its GitHub tools, and the reverse is also true. Scope is
fixed at launch and cannot be widened mid-session.
The only medium both sessions can durably reach is **git / GitHub**. So the
ledger is a set of committed files, not a local folder — and the scope wall
means the hand-off still needs either a shared repo or a human relay for the
cross-repo step. This document is honest about that; it does not pretend the
wall is not there.
## What we reuse (no new trust machinery)
CW already has the parts this needs. The design adds a thin verb layer over
them; it invents no new crypto and no new state format.
- `run export` produces a **verifiable bundle** (file digests, telemetry
ledger, trust-audit hash chains).
- `run restore` **imports fail-closed**: it inspects first, refuses a corrupt
or tampered bundle without writing anything, and exits non-zero when the
chain does not verify. (`run import` is the exit-0 sibling; the hand-off
path must use the fail-closed `restore` contract.)
- `report verify` checks a run's evidence and citations.
A hand-off entry is therefore just a CW bundle. The receiving side trusts it
the same way it trusts any restored run: by verification, not by good faith.
## Two verbs
Both live under a single new `cw ledger` verb, so the existing surface is
untouched and the new behavior is opt-in (POLA). (The name `handoff` was already
taken by an unrelated collaboration primitive — ownership transfer of a run/task
— so the cross-agent verb is `ledger`, not `handoff`.) Stage 1 ships as
`cw ledger propose|review|verify`; see
[cross-agent-ledger](../cross-agent-ledger.7.md) for the contract.
- **`propose`** — the read-only side writes a structured *change proposal*
(title, rationale, target files, suggested diff) as a ledger entry. It does
**not** mutate the other repo. The write-capable side picks the entry up,
verifies it, and turns it into a **real GitHub pull request**.
- **`review`** — the reviewing side writes a structured *review verdict*
(`APPROVED` / `REJECTED`, findings, the diff or PR it judged) as a ledger
entry. The other side surfaces it and can act on it.
This keeps a read-only agent honest: it emits proposals and verdicts as
**data**, and the write-capable side is the only one that opens PRs. Neither
side has to be trusted to have mutated the other's code.
## Transports (how an entry actually crosses)
The verbs above produce and consume entries; the transport is how an entry
moves from one VM to the other. Two are in scope, smallest first.
- **T1 — Human relay (MVP, works today, zero infra).** The producing side
prints the entry (a verifiable bundle, or its safe text form) to stdout;
the operator carries it to the other session; the consuming side verifies
it fail-closed and acts (opens the PR, or records the verdict). This is
exactly the loop the operator is already running by hand. It needs no new
code beyond a stable print/parse shape.
- **T2 — Git-as-ledger.** Each entry is committed to a repo under a known
path (for example `handoff/<from>-<to>/<id>.bundle`). Because scope is
single-repo, this needs one of:
- **T2a — a shared handoff repo** both agents are scoped to (cleanest, but
the operator must create it and launch both sessions against it), or
- **T2b — each side writes to its own repo** and a bridge (the operator, or
a scheduled job that *is* scoped to both) moves entries across. The
cross-repo read cannot be automatic inside a single scoped session — this
is the scope wall, stated plainly, not a gap to be quietly filled.
## Fail-closed rules (non-negotiable)
- An entry that does not verify is **refused**, never acted on. No PR is
opened, no verdict is recorded, and the refusal is explicit on stderr with
a non-zero exit — the same contract as `run restore`.
- A proposal is a **suggestion only**. It never edits the target repo by
itself; a human-or-agent on the write side always makes the real PR, so the
read-only vow of the proposing side holds.
- stdout stays data (the entry / the machine result); stderr stays
diagnostics; a piped run is silent on success. `--json` is stable and
decoration-free.
## Non-goals / POLA
- No existing command, output byte, exit code, or file layout changes.
- No new runtime dependency (zero-dependency red line holds).
- No vendor-specific logic in core; the verbs move opaque bundles.
- Nothing ships until its own cycle lands with a test that fails before and
passes after, and a `docs/*.7.md` contract page — this design file is not
that contract and claims no shipped behavior.
## Suggested rollout (each stage its own reviewed cycle)
0. **This design doc** (no behavior). ← you are here.
1. **T1 human-relay shape** — a stable, documented print/parse form for a
proposal and a verdict, plus a smoke that round-trips one of each and
proves a tampered entry is refused.
2. **`cw ledger propose` / `review`** over `run export` / `restore`, with the
fail-closed refusal test.
3. **T2 git-ledger** (shared-repo first), then optionally a scoped bridge job
for T2b.
## Open decisions for the operator
- T2a (shared handoff repo) or T2b (own repos + bridge)? T2a is simpler and
should be the default unless a shared repo is not acceptable.
- Should a verdict be able to **block** a PR merge on the other side, or only
advise? Advise-only is the safer default and matches "review as data".
# Handoff ledger — shared-repo setup (T2a)
How to stand up the shared repo that carries `cw ledger` entries between two
agents scoped to two separate repos (e.g. `cool-workflow` and `chime`), each
running in its own cloud session. The verbs are documented in
[cross-agent-ledger](cross-agent-ledger.7.md); this is the operator runbook.
Examples are portable — replace `<owner>`, `<src-repo>`, and the paths with your
own, and keep tokens in environment variables, never in files or commit text.
## What only the operator can do
- A cool-workflow-scoped web session cannot create the shared repo — the GitHub
integration returns `403 Resource not accessible by integration` for any repo
outside its scope. Create it yourself.
- Scoping the two agent environments (or granting them git credentials) is a
Claude Code web-UI step; it cannot be done from inside a session.
## Choosing a host: GitHub vs self-hosted (Gitea)
The transport is git-host-agnostic — the kernel has no git logic, so any git
remote works. The choice is about reachability and operations, not code.
| | GitHub (private repo) | Self-hosted Gitea (your VPS) |
|---|---|---|
| Reachability from cloud sessions | github.com is in the default **Trusted** allowlist — works with no network-policy change | Your VPS host is **not** in the default allowlist — the environment's network access must be configured to permit it |
| Scope wall | The GitHub MCP scope is per-repo; the ledger uses plain git (not MCP) so it works, but it runs against the grain of the scoping model | Not a GitHub repo at all, so the GitHub scope wall does not apply |
| Limits / quota | Disable Actions on this repo (it needs no CI) so it burns no minutes; git push/pull is not API-rate-limited; ledger traffic is tiny | Fully self-controlled, unlimited |
| Operations | Managed, backed up, zero maintenance | You run it: uptime, backups, TLS cert, patching |
| Data location | GitHub's servers (private) | Your own hardware |
**Recommendation.** Start on **GitHub private** — it is reachable out of the box
and the quota worry is practically moot for tiny ledger traffic. Move to **Gitea**
if you want full self-hosting AND have confirmed the cloud environment can reach
your VPS through its network policy (the deciding prerequisite). Migrating later
is only a change of git remote — no code change.
## GitHub private — setup
1. **Token.** GitHub → Settings → Developer settings → Personal access tokens →
Fine-grained. Repository access: only `<owner>/handoff`. Permissions:
Contents = Read and write. Copy the token.
2. **Repo.** Create `<owner>/handoff`, private, initialized with a README. In
Settings → Actions → General, disable Actions (no CI needed → no minutes).
3. **Environments.** In each agent environment (both the `cool-workflow` and the
`chime` environment), add an environment variable `GH_TOKEN=<token>` (`.env`
format, no quotes). A new session is required for it to take effect.
4. **Optional** — put the clone in each environment's setup script so the ledger
is present at session start:
```bash
#!/bin/bash
git clone https://oauth2:${GH_TOKEN}@github.com/<owner>/handoff.git /home/user/handoff || true
```
## Gitea (self-hosted) — setup
Same shape, two extra prerequisites:
1. Serve Gitea over HTTPS with a valid certificate (e.g. Let's Encrypt) so the
cloud VM's git can verify it.
2. Configure the agent environment's **network access** to permit your VPS host —
the default Trusted allowlist does not include it. If the loop cannot reach
the VPS, it cannot run.
3. Create a Gitea access token, store it as an environment variable, and clone
with an authenticated remote (`https://<user>:${GIT_TOKEN}@<vps-host>/<owner>/handoff.git`).
## Directory convention
Entries live under `ledger/` in the shared repo, one file per entry named by its
id:
```
handoff/
ledger/
ldg-1de7c92172af1871.json
ldg-2315e4b33b9a812f.json
```
## The loop
Producing side (propose a change, hand it over):
```bash
entry=$(cw ledger propose --from cool-workflow --to chime \
--title "Add retry" --rationale "flaky net" \
--files src/net.ts --diff "$(git -C <src-repo> diff)")
id=$(printf '%s' "$entry" | jq -r .id)
printf '%s\n' "$entry" > /home/user/handoff/ledger/$id.json
git -C /home/user/handoff add ledger/$id.json
git -C /home/user/handoff commit -m "propose $id"
git -C /home/user/handoff push
```
Note the single `cw ledger propose` call captured into `$entry` — calling it
twice would mint two different entries (each carries a fresh `createdAt`).
Consuming side (verify the inbox, then act or review back):
```bash
git -C /home/user/handoff pull
cw ledger list --dir /home/user/handoff/ledger && echo "inbox verified — safe to act"
# hand a verdict back:
entry=$(cw ledger review --from chime --to cool-workflow \
--target ldg-1de7c92172af1871 --verdict approved --findings "tests pass,scope ok")
id=$(printf '%s' "$entry" | jq -r .id)
printf '%s\n' "$entry" > /home/user/handoff/ledger/$id.json
git -C /home/user/handoff add ledger/$id.json
git -C /home/user/handoff commit -m "review $id"
git -C /home/user/handoff push
```
## Notes
- Keep private code out of a **public** handoff repo: omit `--diff` and reference
a commit/branch in the private source repo instead, so only metadata + a
pointer is exposed. On a private handoff repo, full diffs are fine.
- The other side may build entries without `cw` as long as they match the
digest/id rules in [cross-agent-ledger](cross-agent-ledger.7.md); otherwise
`cw ledger verify` refuses them with `ledger-digest-mismatch`.
+1
-1
{
"name": "cool-workflow",
"description": "A workflow control plane and run-time you are able to check: it sends out jobs in TypeScript, makes certain of work against facts before it goes through, puts state into fixed records, orders jobs by time, runs jobs again and again, gets a group of agents to do their parts together, and talks MCP. It gives the doing of the work to outside agents — it never runs the models itself.",
"version": "0.1.97",
"version": "0.1.98",
"author": {

@@ -6,0 +6,0 @@ "name": "COOLWHITE LLC"

{
"name": "cool-workflow",
"version": "0.1.97",
"version": "0.1.98",
"description": "A workflow control plane and run-time you are able to check: it sends out jobs in TypeScript, makes certain of work against facts before it goes through, puts state into fixed records, orders jobs by time, runs jobs again and again, gets a group of agents to do their parts together, and talks MCP. It gives the doing of the work to outside agents — it never runs the models itself.",

@@ -5,0 +5,0 @@ "author": {

@@ -6,3 +6,3 @@ {

"summary": "Run a shorter architecture review with parallel map and assess phases for faster first results.",
"version": "0.1.97",
"version": "0.1.98",
"author": "COOLWHITE LLC",

@@ -9,0 +9,0 @@ "inputs": [

@@ -6,3 +6,3 @@ {

"summary": "Map a repository architecture, assess risks, verify important findings, and synthesize an evidence-backed verdict.",
"version": "0.1.97",
"version": "0.1.98",
"author": "COOLWHITE LLC",

@@ -9,0 +9,0 @@ "inputs": [

@@ -6,3 +6,3 @@ {

"summary": "Deterministic one-worker workflow app for proving the CW integration chain.",
"version": "0.1.97",
"version": "0.1.98",
"author": "COOLWHITE LLC",

@@ -9,0 +9,0 @@ "inputs": [

@@ -6,3 +6,3 @@ {

"summary": "Review a pull request or branch, inspect CI failures, diagnose actionable issues, optionally patch, verify, and summarize with evidence.",
"version": "0.1.97",
"version": "0.1.98",
"author": "COOLWHITE LLC",

@@ -9,0 +9,0 @@ "inputs": [

@@ -6,3 +6,3 @@ {

"summary": "Prepare a release with checklist discipline: version checks, changelog, tests, packaging, release notes, and final verification.",
"version": "0.1.97",
"version": "0.1.98",
"author": "COOLWHITE LLC",

@@ -9,0 +9,0 @@ "inputs": [

@@ -6,3 +6,3 @@ {

"summary": "Split a research question into claims, investigate sources, cross-check evidence, verify claims, and synthesize a concise answer.",
"version": "0.1.97",
"version": "0.1.98",
"author": "COOLWHITE LLC",

@@ -9,0 +9,0 @@ "inputs": [

@@ -89,2 +89,3 @@ "use strict";

const result_normalize_1 = require("./result-normalize");
const cli_options_1 = require("./orchestrator/cli-options");
const node_fs_1 = __importDefault(require("node:fs"));

@@ -557,2 +558,3 @@ const node_path_1 = __importDefault(require("node:path"));

"incremental",
"concurrency",
// Remote-source flags (v0.1.91): materialized into a local checkout in the capability

@@ -597,2 +599,3 @@ // layer, never passed to plan as inputs (the resolved sourceUrl/sourceCommit ARE inputs).

incremental: isTrue(args.incremental),
concurrency: (0, cli_options_1.numberOption)(args.concurrency),
args

@@ -599,0 +602,0 @@ });

@@ -57,2 +57,3 @@ "use strict";

const collaboration_1 = require("./handlers/collaboration");
const ledger_1 = require("./handlers/ledger");
const blackboard_1 = require("./handlers/blackboard");

@@ -431,2 +432,5 @@ const eval_1 = require("./handlers/eval");

return;
case "ledger":
(0, ledger_1.handleLedger)(args, runner);
return;
case "loop": {

@@ -433,0 +437,0 @@ (0, io_1.printJson)(scheduler.create({ ...args.options, kind: "loop" }));

@@ -46,2 +46,4 @@ "use strict";

const state_1 = require("./state");
const commit_1 = require("./commit");
const report_1 = require("./orchestrator/report");
const trust_audit_1 = require("./trust-audit");

@@ -187,3 +189,3 @@ const compare_1 = require("./compare");

* branch by construction. */
function processSelectedTask(ctx, selected, preparedOutcome) {
function processSelectedTask(ctx, selected, preparedOutcome, deferPersist = false) {
const { runner, runId } = ctx;

@@ -196,3 +198,3 @@ let run = runner.loadRun(runId);

if (selected.status === "pending") {
const manifest = runner.dispatch(runId, { limit: 1, backend: selected.agentType || "agent" });
const manifest = runner.dispatch(runId, { limit: 1, backend: selected.agentType || "agent", ...(deferPersist ? { persistState: false } : {}) });
const task = manifest.tasks.find((entry) => entry.id === selected.id) || manifest.tasks[0];

@@ -222,6 +224,6 @@ if (!task || !task.workerId) {

node_fs_1.default.writeFileSync(manifest.resultPath, node_fs_1.default.readFileSync(cachePath, "utf8"), "utf8");
runner.recordWorkerOutput(runId, workerId, manifest.resultPath, {});
runner.recordWorkerOutput(runId, workerId, manifest.resultPath, deferPersist ? { persistState: false } : {});
}
catch (error) {
return handleHop(ctx, selected, workerId, `result cache rejected: ${error instanceof Error ? error.message : String(error)}`);
return handleHop(ctx, selected, workerId, `result cache rejected: ${error instanceof Error ? error.message : String(error)}`, deferPersist);
}

@@ -240,3 +242,3 @@ return step("accept", "ok", {

if (selected.subWorkflow) {
return runSubWorkflow(ctx, run, selected, workerId, manifest);
return runSubWorkflow(ctx, run, selected, workerId, manifest, deferPersist);
}

@@ -250,3 +252,3 @@ emitProgress(`→ ${selected.label || selected.id} (${selected.phase}) — ${dispatched ? "dispatched, " : ""}spawning agent, may take minutes…`);

if (envelope.status !== "completed") {
return handleHop(ctx, selected, workerId, `agent hop ${envelope.status}: ${envelope.result.summary}`);
return handleHop(ctx, selected, workerId, `agent hop ${envelope.status}: ${envelope.result.summary}`, deferPersist);
}

@@ -257,6 +259,7 @@ // 3. ACCEPT — the SEPARATE recordWorkerOutput layer validates + records result.md.

if (!manifest.resultPath || !node_fs_1.default.existsSync(manifest.resultPath)) {
return handleHop(ctx, selected, workerId, "agent produced no result.md");
return handleHop(ctx, selected, workerId, "agent produced no result.md", deferPersist);
}
try {
runner.recordWorkerOutput(runId, workerId, manifest.resultPath, {
...(deferPersist ? { persistState: false } : {}),
agentDelegation: {

@@ -280,3 +283,3 @@ handle: handle,

catch (error) {
return handleHop(ctx, selected, workerId, `result.md rejected: ${error instanceof Error ? error.message : String(error)}`);
return handleHop(ctx, selected, workerId, `result.md rejected: ${error instanceof Error ? error.message : String(error)}`, deferPersist);
}

@@ -415,37 +418,57 @@ if (cachePath && manifest.resultPath && node_fs_1.default.existsSync(manifest.resultPath)) {

function driveConcurrentRound(ctx, limit) {
const run = ctx.runner.loadRun(ctx.runId);
const selected = selectDriveTask(run);
const gate = terminalOrConfigStep(ctx, run, selected);
if (gate)
return [gate];
const phase = (0, dispatch_1.firstRunnablePhase)(run);
const width = Math.max(1, Math.floor(limit) || 1);
const batch = run.tasks
.filter((task) => phase.taskIds.includes(task.id) && (task.status === "pending" || task.status === "running"))
.slice(0, width)
.map((task) => task.id);
// Phase A+B: dispatch every batch task (sequential — dispatch mutates state),
// then collect ALL spawn-style child outcomes in one concurrent window. The
// token-budget gate ran at round entry; it is NOT re-checked between accepts —
// the spawns already happened, and refusing to RECORD finished work would
// discard real results (collect-all + never-claw-back). Overshoot is bounded
// by the round width; the next round blocks.
const prepared = prepareConcurrentOutcomes(ctx, batch);
// Phase C: settle + accept in deterministic batch order, regardless of the
// wall-clock order the children finished in.
const steps = [];
for (const taskId of batch) {
const failStep = prepared.failSteps.get(taskId);
if (failStep) {
steps.push(failStep);
continue;
// The whole round runs inside ONE cached in-memory run object (loadWithCache —
// reentrant, so a sub-workflow task's recursive drive() call cannot clobber this
// scope's cache). Every dispatch/accept in the round defers its own state.json
// write (persistState:false / deferPersist) and mutates that SAME shared object;
// the round flushes to disk exactly ONCE at the end instead of once per task —
// was O(N) full-state durable rewrites per round (measured: dominates wall time
// at scale), now O(1). A crash mid-round loses that round's dispatch/accept
// bookkeeping (bounded by the round width, i.e. limits.maxConcurrentAgents) and
// forces a safe-but-wasteful re-dispatch/re-spawn on the next drive — never
// disk corruption or double-counting, since the atomic-rename+fsync write
// itself is untouched; only the write FREQUENCY changed.
return ctx.runner.loadWithCache(() => {
const run = ctx.runner.loadRun(ctx.runId);
const selected = selectDriveTask(run);
const gate = terminalOrConfigStep(ctx, run, selected);
if (gate)
return [gate];
const phase = (0, dispatch_1.firstRunnablePhase)(run);
const width = Math.max(1, Math.floor(limit) || 1);
const batch = run.tasks
.filter((task) => phase.taskIds.includes(task.id) && (task.status === "pending" || task.status === "running"))
.slice(0, width)
.map((task) => task.id);
// Phase A+B: dispatch every batch task (sequential — dispatch mutates state),
// then collect ALL spawn-style child outcomes in one concurrent window. The
// token-budget gate ran at round entry; it is NOT re-checked between accepts —
// the spawns already happened, and refusing to RECORD finished work would
// discard real results (collect-all + never-claw-back). Overshoot is bounded
// by the round width; the next round blocks.
const prepared = prepareConcurrentOutcomes(ctx, batch);
// Phase C: settle + accept in deterministic batch order, regardless of the
// wall-clock order the children finished in.
const steps = [];
for (const taskId of batch) {
const failStep = prepared.failSteps.get(taskId);
if (failStep) {
steps.push(failStep);
continue;
}
// Re-read per task: a prior accept in this round mutated state (the SAME
// cached object — no disk round-trip until the round-end flush below).
const freshRun = ctx.runner.loadRun(ctx.runId);
const fresh = freshRun.tasks.find((task) => task.id === taskId);
if (!fresh || (fresh.status !== "pending" && fresh.status !== "running"))
continue;
steps.push(processSelectedTask(ctx, fresh, prepared.outcomes.get(taskId), true));
}
// Re-read per task: a prior accept in this round mutated state.
const freshRun = ctx.runner.loadRun(ctx.runId);
const fresh = freshRun.tasks.find((task) => task.id === taskId);
if (!fresh || (fresh.status !== "pending" && fresh.status !== "running"))
continue;
steps.push(processSelectedTask(ctx, fresh, prepared.outcomes.get(taskId)));
}
return steps.length > 0 ? steps : [driveStep(ctx)];
if (steps.length > 0) {
const settledRun = ctx.runner.loadRun(ctx.runId);
(0, commit_1.commitState)(settledRun, `concurrent-round:${batch.length}-tasks`);
(0, report_1.writeReport)(settledRun);
(0, state_1.saveCheckpoint)(settledRun);
}
return steps.length > 0 ? steps : [driveStep(ctx)];
});
}

@@ -469,3 +492,3 @@ /** Dispatch each batch task and run every spawn-style agent child concurrently

if (task.status === "pending") {
const manifest = runner.dispatch(runId, { limit: 1, backend: task.agentType || "agent" });
const manifest = runner.dispatch(runId, { limit: 1, backend: task.agentType || "agent", persistState: false });
const dispatchedTask = manifest.tasks.find((entry) => entry.id === task.id) || manifest.tasks[0];

@@ -512,3 +535,3 @@ if (!dispatchedTask || !dispatchedTask.workerId) {

* retry on the SAME worker scope next step, or PARK past the retry budget. */
function handleHop(ctx, task, workerId, reason) {
function handleHop(ctx, task, workerId, reason, deferPersist = false) {
const persisted = ctx.runner.showWorker(ctx.runId, workerId).retryCount || 0;

@@ -534,3 +557,4 @@ const prior = Math.max(ctx.attempts.get(task.id) || 0, persisted);

retryable: false,
retryCount: attempts
retryCount: attempts,
...(deferPersist ? { persistState: false } : {})
});

@@ -547,3 +571,3 @@ return step("park", "parked", {

// Retryable: leave the task running (scope reused) for the next step.
(0, worker_isolation_1.recordWorkerRetryAttempt)(ctx.runner.loadRun(ctx.runId), workerId, decided.attempts || prior + 1, reason);
(0, worker_isolation_1.recordWorkerRetryAttempt)(ctx.runner.loadRun(ctx.runId), workerId, decided.attempts || prior + 1, reason, deferPersist ? { persist: false } : {});
return step("fulfill", "failed", {

@@ -581,3 +605,3 @@ runId: ctx.runId,

* verdict) — nothing is summed or fabricated. */
function runSubWorkflow(ctx, run, selected, workerId, manifest) {
function runSubWorkflow(ctx, run, selected, workerId, manifest, deferPersist = false) {
const spec = selected.subWorkflow;

@@ -587,3 +611,3 @@ const parentApp = run.workflow.id;

if (ctx.depth + 1 > exports.MAX_SUB_WORKFLOW_DEPTH) {
return handleHop(ctx, selected, workerId, `sub-workflow depth limit exceeded (> ${exports.MAX_SUB_WORKFLOW_DEPTH})`);
return handleHop(ctx, selected, workerId, `sub-workflow depth limit exceeded (> ${exports.MAX_SUB_WORKFLOW_DEPTH})`, deferPersist);
}

@@ -593,3 +617,3 @@ // Include the CURRENT app on the path, so a direct self-cycle (A→A) is caught at

if ([...ctx.visitedAppIds, parentApp].includes(spec.appId)) {
return handleHop(ctx, selected, workerId, `sub-workflow cycle detected: ${[...ctx.visitedAppIds, parentApp, spec.appId].join(" -> ")}`);
return handleHop(ctx, selected, workerId, `sub-workflow cycle detected: ${[...ctx.visitedAppIds, parentApp, spec.appId].join(" -> ")}`, deferPersist);
}

@@ -611,3 +635,3 @@ // Deterministic child run id derived from the parent run + task (no clock/random).

catch (error) {
return handleHop(ctx, selected, workerId, `sub-workflow plan failed (${spec.appId}): ${errMessage(error)}`);
return handleHop(ctx, selected, workerId, `sub-workflow plan failed (${spec.appId}): ${errMessage(error)}`, deferPersist);
}

@@ -623,3 +647,3 @@ const childResult = drive(ctx.runner, childRun.id, {

if (childResult.status !== "complete") {
return handleHop(ctx, selected, workerId, `sub-workflow ${spec.appId} did not complete (status: ${childResult.status})`);
return handleHop(ctx, selected, workerId, `sub-workflow ${spec.appId} did not complete (status: ${childResult.status})`, deferPersist);
}

@@ -637,3 +661,3 @@ // Bind the child's bytes: the rendered report (default) or the verdict result.

if (childBytes === undefined) {
return handleHop(ctx, selected, workerId, `sub-workflow ${spec.appId} produced no ${spec.bindResult || "report"}`);
return handleHop(ctx, selected, workerId, `sub-workflow ${spec.appId} produced no ${spec.bindResult || "report"}`, deferPersist);
}

@@ -643,6 +667,6 @@ // Accept through the SAME path as any other result (verifier/schema/evidence gate).

node_fs_1.default.writeFileSync(manifest.resultPath, childBytes, "utf8");
ctx.runner.recordWorkerOutput(run.id, workerId, manifest.resultPath, {});
ctx.runner.recordWorkerOutput(run.id, workerId, manifest.resultPath, deferPersist ? { persistState: false } : {});
}
catch (error) {
return handleHop(ctx, selected, workerId, `sub-workflow result rejected by parent gate: ${errMessage(error)}`);
return handleHop(ctx, selected, workerId, `sub-workflow result rejected by parent gate: ${errMessage(error)}`, deferPersist);
}

@@ -753,2 +777,3 @@ // Honest cross-link (provenance only — never fails the accepted hop): one

};
let exhaustedMaxIterations = !options.once;
for (let i = 0; i < maxIterations; i++) {

@@ -775,6 +800,10 @@ const width = concurrency > 1 ? concurrency : autoWidth(runner.loadRun(runId));

const last = roundSteps[roundSteps.length - 1];
if (options.once)
if (options.once) {
exhaustedMaxIterations = false;
break;
if (last && (last.status === "complete" || last.status === "parked" || last.status === "blocked"))
}
if (last && (last.status === "complete" || last.status === "parked" || last.status === "blocked")) {
exhaustedMaxIterations = false;
break;
}
}

@@ -786,2 +815,8 @@ const run = runner.loadRun(runId);

const last = steps[steps.length - 1];
if (exhaustedMaxIterations) {
steps.push(step("blocked", "blocked", {
runId,
reason: `drive reached max iteration limit (${maxIterations}) before a terminal state`
}));
}
const status = options.once

@@ -793,7 +828,9 @@ ? completedWorkers === plannedWorkers && committed

: "in-progress"
: parkedWorkers > 0 || (last && last.status === "parked")
? "parked"
: last && last.status === "blocked"
? "blocked"
: "complete";
: exhaustedMaxIterations
? "blocked"
: parkedWorkers > 0 || (last && last.status === "parked")
? "parked"
: last && last.status === "blocked"
? "blocked"
: "complete";
return {

@@ -800,0 +837,0 @@ schemaVersion: 1,

@@ -15,2 +15,3 @@ "use strict";

exports.prepareAgentSpawn = prepareAgentSpawn;
exports.reconcileBatchOutcomes = reconcileBatchOutcomes;
exports.runAgentBatchOutcomes = runAgentBatchOutcomes;

@@ -263,12 +264,70 @@ // Agent-delegation pure helpers + concurrent batch fulfillment for the

// reads them), per-job SIGTERM at timeoutMs + SIGKILL at +5s, caps each captured
// stdout at 32MB, and prints the outcome array when every job has settled. stderr
// is drained (a full pipe must never wedge a child). A kill yields exitCode null —
// the no-exit-code refusal. We spawn it BY PATH (shell:false); the path is
// resolved from this compiled module (dist/execution-backend/agent.js) up to the
// package's `scripts/children/` dir, which package.json ships in "files".
// stdout at 32MB, and streams ONE NDJSON line per job the instant it settles.
// stderr is drained (a full pipe must never wedge a child). A kill yields
// exitCode null — the no-exit-code refusal. We spawn it BY PATH (shell:false);
// the path is resolved from this compiled module (dist/execution-backend/agent.js)
// up to the package's `scripts/children/` dir, which package.json ships in "files".
const BATCH_DELEGATE_CHILD_SCRIPT = node_path_1.default.resolve(__dirname, "..", "..", "scripts", "children", "batch-delegate-child.js");
/** Parse the delegate child's NDJSON stdout and reconcile it against `jobs` by
* index. Runs even when `child.error` is set (ENOBUFS from the combined
* output exceeding maxBuffer, ETIMEDOUT from the parent backstop, or a
* nonzero/null exit) — a batch-level failure must fail-close ONLY the jobs
* whose line never fully arrived, never every job in the batch: a job whose
* line already streamed through keeps its REAL outcome.
*
* `stdout` is split on the raw newline BYTE, on a Buffer, before any UTF-8
* decoding — never on a decoded string. 0x0A never appears inside a UTF-8
* continuation byte, so this is a safe boundary; decoding is deferred to
* ONE LINE at a time (bounded by the delegate's own 32MB-per-job cap), so
* no single decode ever approaches V8's hard per-string character ceiling
* regardless of how large the COMBINED batch output is. Decoding the whole
* combined buffer as one string up front (the prior approach) could itself
* throw past that ceiling for a large-enough batch — an uncaught crash, not
* a graceful `child.error` — which this line-at-a-time approach avoids by
* construction. The trailing split segment is always dropped before
* parsing (empty from a clean trailing newline, or a line truncated
* mid-write by a hard kill — either way, never a complete line), and one
* corrupt line can never crash the reconciliation of its siblings. */
function reconcileBatchOutcomes(jobs, child) {
const buf = Buffer.isBuffer(child.stdout) ? child.stdout : Buffer.from(String(child.stdout || ""), "utf8");
const byIndex = new Map();
let lineStart = 0;
for (let i = 0; i < buf.length; i++) {
if (buf[i] !== 0x0a)
continue;
const lineBuf = buf.subarray(lineStart, i);
lineStart = i + 1;
if (lineBuf.length === 0)
continue;
let parsed;
try {
parsed = JSON.parse(lineBuf.toString("utf8"));
}
catch {
continue;
}
if (typeof parsed.i !== "number" || parsed.i < 0 || parsed.i >= jobs.length)
continue;
byIndex.set(parsed.i, {
...(parsed.spawnError ? { spawnError: parsed.spawnError } : {}),
exitCode: typeof parsed.exitCode === "number" ? parsed.exitCode : null,
stdout: String(parsed.stdout || "")
});
}
const reason = child.error
? (0, util_1.messageOf)(child.error)
: typeof child.status === "number" && child.status !== 0
? `batch delegate exited with ${child.status}`
: "batch delegate produced no outcome for this job";
return jobs.map((_, index) => byIndex.get(index) || { spawnError: `batch delegate failed: ${reason}`, exitCode: null, stdout: "" });
}
/** Run a batch of agent spawns concurrently; outcomes index-align with jobs. The
* parent backstop timeout (max job timeout + 30s) means even a wedged delegate
* child cannot deadlock the drive: on any batch-level failure EVERY job settles
* as a fail-closed spawn refusal — never a fabricated completion, never a hang. */
* child cannot deadlock the drive. `maxBuffer` scales with batch size (the
* delegate's own per-job 32MB cap is the real safety bound — no separate outer
* ceiling here, since a flat ceiling that stops scaling with job count is
* exactly what let one verbose batch strand its siblings before this fix).
* Collect-all is a real guarantee even under buffer/timeout pressure: a job
* whose NDJSON line fully streamed through keeps its real outcome regardless
* of what happens to the rest of the batch. */
function runAgentBatchOutcomes(jobs) {

@@ -278,20 +337,21 @@ if (!jobs.length)

const maxTimeout = Math.max(...jobs.map((job) => job.timeoutMs));
const child = (0, node_child_process_1.spawnSync)(process.execPath, [BATCH_DELEGATE_CHILD_SCRIPT], {
input: JSON.stringify(jobs),
encoding: "utf8",
maxBuffer: Math.min(33 * 1024 * 1024 * jobs.length, 512 * 1024 * 1024),
timeout: maxTimeout + 30000
});
if (!child.error && typeof child.status === "number" && child.status === 0) {
try {
const parsed = JSON.parse(String(child.stdout || ""));
if (Array.isArray(parsed) && parsed.length === jobs.length)
return parsed;
}
catch {
// fall through to the fail-closed mapping below
}
// No `encoding` option: keep stdout as a raw Buffer so reconcileBatchOutcomes
// can split on the newline byte and decode one line at a time — decoding the
// WHOLE combined buffer as one string up front could itself throw past V8's
// per-string character ceiling for a large-enough batch (an uncaught crash,
// not a graceful child.error). The try/catch below is a second backstop for
// any other unexpected native failure at this boundary — a wedged or
// over-limit delegate must fail every job closed, never crash the drive.
let child;
try {
child = (0, node_child_process_1.spawnSync)(process.execPath, [BATCH_DELEGATE_CHILD_SCRIPT], {
input: JSON.stringify(jobs),
maxBuffer: 34 * 1024 * 1024 * jobs.length,
timeout: maxTimeout + 30000
});
}
const reason = child.error ? (0, util_1.messageOf)(child.error) : `batch delegate exited ${child.status === null ? "without an exit code (timed out or killed)" : `with ${child.status}`}`;
return jobs.map(() => ({ spawnError: `batch delegate failed: ${reason}`, exitCode: null, stdout: "" }));
catch (error) {
child = { error: error instanceof Error ? error : new Error(String(error)), status: null, stdout: null };
}
return reconcileBatchOutcomes(jobs, child);
}

@@ -10,2 +10,3 @@ "use strict";

const orchestrator_1 = require("../orchestrator");
const ledger_1 = require("../ledger");
const scheduler_1 = require("../scheduler");

@@ -298,2 +299,37 @@ const triggers_1 = require("../triggers");

return runner.collaborationHandoff(String(args.runId || ""), String(args.targetKind || args.kind || ""), String(args.targetId || args.target || ""), args);
// ---- Cross-agent handoff ledger (stage 2 MCP surface) ----
case "cw_ledger_propose":
return (0, ledger_1.buildLedgerProposal)({
from: String(args.from || ""),
to: String(args.to || ""),
title: String(args.title || ""),
rationale: String(args.rationale || ""),
targetFiles: String(args.files || "").split(",").map((f) => f.trim()).filter(Boolean),
suggestedDiff: args.diff === undefined ? undefined : String(args.diff),
createdAt: new Date().toISOString()
});
case "cw_ledger_review": {
const verdict = String(args.verdict || "").toUpperCase();
if (verdict !== "APPROVED" && verdict !== "REJECTED")
throw new Error('verdict must be "approved" or "rejected".');
return (0, ledger_1.buildLedgerReview)({
from: String(args.from || ""),
to: String(args.to || ""),
target: String(args.target || ""),
verdict,
findings: String(args.findings || "").split(",").map((f) => f.trim()).filter(Boolean),
createdAt: new Date().toISOString()
});
}
case "cw_ledger_verify":
return (0, ledger_1.verifyLedgerEntry)(args.entry);
case "cw_ledger_apply":
return (0, ledger_1.applyLedgerProposal)(args.entry);
case "cw_ledger_list": {
// `dirs` (2+) union-verifies mirrors; a single `dir` keeps the original shape.
const dirs = Array.isArray(args.dirs) ? args.dirs.map(String).filter(Boolean) : [];
if (dirs.length > 1)
return (0, ledger_1.unionLedgerEntries)(dirs);
return (0, ledger_1.listLedgerEntries)(dirs[0] || String(args.dir || ""));
}
case "cw_review_status":

@@ -300,0 +336,0 @@ return runner.reviewStatus(String(args.runId || ""), args);

@@ -117,4 +117,12 @@ "use strict";

* the same cached run state, collapsing 18 reads into 1. Returns fn's result
* and clears the cache afterwards (never leaks between requests). */
* and clears the cache afterwards (never leaks between requests).
*
* Reentrant: a nested call (e.g. a concurrent drive round whose sub-workflow
* task recursively drives a child run on the SAME runner instance) saves and
* restores the OUTER scope's cache rather than clobbering it to undefined —
* otherwise the inner call's `finally` would wipe the outer round's still-in-
* flight (not yet persisted) mutations, and the outer scope's next loadRun
* would silently fall back to a stale disk read. */
loadWithCache(fn) {
const previousCache = this._requestCache;
this._requestCache = new Map();

@@ -126,3 +134,3 @@ (0, trust_audit_1.setAuditEventCache)(new Map());

finally {
this._requestCache = undefined;
this._requestCache = previousCache;
(0, trust_audit_1.clearAuditEventCache)();

@@ -823,3 +831,3 @@ }

"blackboard coordinator metrics operator sched gc telemetry migration demo workbench " +
"approve reject comment handoff graph eval man version update fix").split(" ");
"approve reject comment handoff ledger graph eval man version update fix").split(" ");
// Wrap the command list into clean, indented, pipe-joined lines (<=76 cols) instead of

@@ -826,0 +834,0 @@ // one 400-char line that wraps raggedly and merges with the next shell prompt. Pipe-joined

@@ -32,2 +32,3 @@ "use strict";

const loop_expansion_1 = require("../loop-expansion");
const evidence_grounding_1 = require("../evidence-grounding");
const dispatch_1 = require("../dispatch");

@@ -183,3 +184,10 @@ const verifier_1 = require("../verifier");

}
/** `options.persistState === false` (concurrent-round callers ONLY — never from
* a CLI/MCP arg bag) skips commitState/saveCheckpoint/writeReport on every
* branch, success or error, so a caller driving many tasks through one
* in-memory `run` can defer the disk flush to a single call at round end
* instead of once per task. Default (absent) preserves today's exact
* per-call persistence. */
function dispatch(run, options) {
const persistState = options.persistState !== false;
try {

@@ -195,6 +203,8 @@ const manifest = (0, dispatch_1.createDispatchManifest)(run, (0, cli_options_1.numberOption)(options.limit), {

run.loopStage = "act";
if (manifest.dispatchId)
(0, commit_1.commitState)(run, `dispatch:${manifest.dispatchId}`);
(0, state_1.saveCheckpoint)(run);
(0, report_1.writeReport)(run);
if (persistState) {
if (manifest.dispatchId)
(0, commit_1.commitState)(run, `dispatch:${manifest.dispatchId}`);
(0, state_1.saveCheckpoint)(run);
(0, report_1.writeReport)(run);
}
return manifest;

@@ -218,4 +228,6 @@ }

}, { persist: false });
(0, report_1.writeReport)(run);
(0, state_1.saveCheckpoint)(run);
if (persistState) {
(0, report_1.writeReport)(run);
(0, state_1.saveCheckpoint)(run);
}
}

@@ -246,2 +258,6 @@ throw error;

(0, verifier_1.validateResultEnvelope)(task, parsedResult);
const unresolved = (0, evidence_grounding_1.unresolvedFileEvidence)(parsedResult.evidence, [run.cwd, process.cwd(), run.paths.runDir, node_path_1.default.dirname(absoluteResultPath)]);
if (unresolved.length) {
throw new Error(`Result cites file evidence that does not resolve on disk: ${unresolved.join(", ")}`);
}
const destination = node_path_1.default.join(run.paths.resultsDir, `${(0, state_1.safeFileName)(taskId)}.md`);

@@ -335,2 +351,3 @@ node_fs_1.default.copyFileSync(absoluteResultPath, destination);

const requireAttestedTelemetry = options.requireAttestedTelemetry === true;
const persistState = options.persistState !== false;
try {

@@ -350,5 +367,7 @@ (0, worker_isolation_1.recordWorkerOutput)(run, workerId, resultPath, { persist: false, agentDelegation, requireAttestedTelemetry });

(0, verifier_1.validateRunGates)(run);
(0, commit_1.commitState)(run, `worker:${workerId}:result`);
(0, report_1.writeReport)(run);
(0, state_1.saveCheckpoint)(run);
if (persistState) {
(0, commit_1.commitState)(run, `worker:${workerId}:result`);
(0, report_1.writeReport)(run);
(0, state_1.saveCheckpoint)(run);
}
return (0, report_1.summarizeRun)(run);

@@ -359,4 +378,6 @@ }

(0, dispatch_1.updatePhaseStatuses)(run);
(0, report_1.writeReport)(run);
(0, state_1.saveCheckpoint)(run);
if (persistState) {
(0, report_1.writeReport)(run);
(0, state_1.saveCheckpoint)(run);
}
throw error;

@@ -366,2 +387,3 @@ }

function recordWorkerFailure(run, workerId, message, options = {}) {
const persistState = options.persistState !== false;
const failure = (0, worker_isolation_1.recordWorkerFailure)(run, workerId, {

@@ -376,4 +398,6 @@ code: String(options.code || "worker-runtime-error"),

(0, dispatch_1.updatePhaseStatuses)(run);
(0, report_1.writeReport)(run);
(0, state_1.saveCheckpoint)(run);
if (persistState) {
(0, report_1.writeReport)(run);
(0, state_1.saveCheckpoint)(run);
}
return failure;

@@ -380,0 +404,0 @@ }

@@ -290,5 +290,9 @@ "use strict";

"if(cap&&len>cap){process.stderr.write('archive too large');process.exit(3);}",
"const buf=Buffer.from(await r.arrayBuffer());",
"if(cap&&buf.length>cap){process.stderr.write('archive too large');process.exit(3);}",
"fs.writeFileSync(out,buf);return;}",
"if(!r.body||!r.body.getReader){process.stderr.write('response body is not streamable');process.exit(4);}",
"const fd=fs.openSync(out,'w');let total=0;",
"try{const reader=r.body.getReader();for(;;){const x=await reader.read();if(x.done)break;",
"const chunk=Buffer.from(x.value);total+=chunk.length;",
"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);}",
"fs.writeSync(fd,chunk,0,chunk.length);}fs.closeSync(fd);return;}",
"catch(e){try{fs.closeSync(fd);}catch{};fs.rmSync(out,{force:true});throw e;}}",
"process.stderr.write('too many redirects');process.exit(6);",

@@ -319,2 +323,5 @@ "})().catch(e=>{process.stderr.write(String((e&&e.message)||e));process.exit(4);});"

}
if (!isZip && result.error?.code === "ENOENT") {
throw new Error("tar is required to review a .tar/.tgz link but was not found on PATH");
}
throw new Error(`could not read archive: ${String(result.stderr || "").trim() || `exit ${result.status}`}`);

@@ -321,0 +328,0 @@ }

@@ -111,3 +111,3 @@ "use strict";

node_fs_1.default.mkdirSync(node_path_1.default.dirname(destination), { recursive: true });
node_fs_1.default.writeFileSync(destination, Buffer.from(file.contentBase64, "base64"));
node_fs_1.default.writeFileSync(destination, decodeBase64Strict(file.contentBase64, file.relativePath));
}

@@ -368,4 +368,7 @@ const externalPathMap = new Map();

const reportFile = (raw.files || []).find((file) => file.relativePath === "report.md");
if (reportFile)
reportContent = Buffer.from(reportFile.contentBase64, "base64").toString("utf8");
if (reportFile) {
const decoded = decodeBase64StrictResult(reportFile.contentBase64, reportFile.relativePath);
if (decoded.ok)
reportContent = decoded.bytes.toString("utf8");
}
}

@@ -652,2 +655,24 @@ }

}
function decodeBase64StrictResult(value, relativePath) {
if (typeof value !== "string") {
return { ok: false, check: { name: "archive-file", pass: false, code: "archive-bad-base64", path: relativePath, actual: "contentBase64 is not a string" } };
}
const compact = value.replace(/\s+/g, "");
if (!/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(compact)) {
return { ok: false, check: { name: "archive-file", pass: false, code: "archive-bad-base64", path: relativePath, actual: "invalid base64 encoding" } };
}
const bytes = Buffer.from(compact, "base64");
const expected = compact.replace(/=+$/, "");
const actual = bytes.toString("base64").replace(/=+$/, "");
if (actual !== expected) {
return { ok: false, check: { name: "archive-file", pass: false, code: "archive-bad-base64", path: relativePath, actual: "non-canonical base64 encoding" } };
}
return { ok: true, bytes };
}
function decodeBase64Strict(value, relativePath) {
const decoded = decodeBase64StrictResult(value, relativePath);
if (!decoded.ok)
throw new Error(archiveCheckMessage(decoded.check));
return decoded.bytes;
}
/** NON-throwing digest/size/count/manifest verification: one structured check per

@@ -660,3 +685,8 @@ * file (in import order), then the integrity file-count + manifest checks. Shared

for (const file of files) {
const bytes = Buffer.from(file.contentBase64, "base64");
const decoded = decodeBase64StrictResult(file.contentBase64, file.relativePath);
if (!decoded.ok) {
checks.push(decoded.check);
continue;
}
const bytes = decoded.bytes;
const actual = sha256Bytes(bytes);

@@ -693,2 +723,3 @@ const digestOk = actual === file.sha256;

case "manifest-digest-mismatch": return `Archive manifest digest mismatch: expected ${check.expected}, got ${check.actual}`;
case "archive-bad-base64": return `Archive base64 invalid for ${check.path}: ${check.actual}`;
default: return `Archive verification failed: ${check.name}`;

@@ -695,0 +726,0 @@ }

"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
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;
exports.CURRENT_COOL_WORKFLOW_VERSION = "0.1.97";
exports.CURRENT_COOL_WORKFLOW_VERSION = "0.1.98";
exports.WORKFLOW_APP_SCHEMA_VERSION = 1;

@@ -6,0 +6,0 @@ exports.CURRENT_RUN_STATE_SCHEMA_VERSION = 1;

@@ -106,3 +106,5 @@ "use strict";

}
const route = decodeURIComponent(url.pathname);
const route = decodeRoutePath(url.pathname);
if (!route)
return this.send(res, 400, { error: "bad request: malformed URL path" });
if (route === "/" || route === "/index.html")

@@ -169,2 +171,10 @@ return this.sendAsset(res, "index.html");

}
function decodeRoutePath(pathname) {
try {
return decodeURIComponent(pathname);
}
catch {
return undefined;
}
}
function FALLBACK_HTML(uiRoot) {

@@ -171,0 +181,0 @@ return [

@@ -414,1 +414,3 @@ # Agent Delegation Drive

0.1.97
0.1.98

@@ -85,3 +85,3 @@ # CLI ↔ MCP Parity

<!-- gen:parity:count -->
machine-complete by design: 202 capabilities, 189 MCP tools.
machine-complete by design: 207 capabilities, 194 MCP tools.
<!-- /gen:parity:count -->

@@ -294,2 +294,7 @@

| `review.policy` | `cw review policy` | `cw_review_policy` | `reviewPolicy` | both | identical |
| `ledger.propose` | `cw ledger propose` | `cw_ledger_propose` | `buildLedgerProposal` | both | projected |
| `ledger.review` | `cw ledger review` | `cw_ledger_review` | `buildLedgerReview` | both | projected |
| `ledger.verify` | `cw ledger verify` | `cw_ledger_verify` | `verifyLedgerEntry` | both | projected |
| `ledger.apply` | `cw ledger apply` | `cw_ledger_apply` | `applyLedgerProposal` | both | projected |
| `ledger.list` | `cw ledger list` | `cw_ledger_list` | `listLedgerEntries` | both | projected |
<!-- /gen:parity:table -->

@@ -327,3 +332,3 @@

<!-- gen:parity:projected -->
Six capabilities are payload-divergent on purpose (`projected`):
Eleven capabilities are payload-divergent on purpose (`projected`):

@@ -336,2 +341,7 @@ - `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.

- `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.
- `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.
- `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.
- `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.
- `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.
- `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.
<!-- /gen:parity:projected -->

@@ -560,1 +570,3 @@

0.1.97
0.1.98

@@ -174,1 +174,3 @@ # Contract Migration Tooling

0.1.97
0.1.98

@@ -158,1 +158,3 @@ # Control-Plane Scheduling

0.1.97
0.1.98

@@ -157,1 +157,3 @@ # Durable State & Locking

0.1.97
0.1.98

@@ -318,1 +318,3 @@ # Evidence Adoption Reasoning Chain

0.1.97
0.1.98

@@ -348,1 +348,3 @@ # EXECUTION-BACKENDS(7)

0.1.97
0.1.98

@@ -324,1 +324,3 @@ # Multi-Agent CLI + MCP Surface

0.1.97
0.1.98

@@ -350,1 +350,3 @@ # Multi-Agent Eval & Replay Harness

0.1.97
0.1.98

@@ -362,1 +362,3 @@ # Multi-Agent Operator UX

0.1.97
0.1.98

@@ -183,1 +183,3 @@ # Node Snapshot / Diff / Replay

0.1.97
0.1.98

@@ -242,1 +242,3 @@ # Observability + Cost Accounting

0.1.97
0.1.98
# Cool Workflow Project Index
Generated from the current repository code on 2026-06-30 by `npm run sync:project-index`.
Generated from the current repository code on 2026-07-03 by `npm run sync:project-index`.

@@ -8,7 +8,7 @@ ## Snapshot

- Package: `cool-workflow`
- Version: `0.1.97`
- Source modules: `68`
- Version: `0.1.98`
- Source modules: `69`
- Workflow apps: `8`
- Docs: `59`
- Smoke tests: `164`
- Docs: `61`
- Smoke tests: `172`
- Repository: https://github.com/coo1white/cool-workflow

@@ -97,2 +97,3 @@

- [gates.ts](../src/gates.ts)
- [ledger.ts](../src/ledger.ts)
- [loop-expansion.ts](../src/loop-expansion.ts)

@@ -150,2 +151,3 @@ - [mcp-surface.ts](../src/mcp-surface.ts)

- [Coordinator / Blackboard](coordinator-blackboard.7.md)
- [Cross-Agent Handoff Ledger](cross-agent-ledger.7.md)
- [DEMO(7)](demo.7.md)

@@ -161,2 +163,3 @@ - [DOCTOR(7)](doctor.7.md)

- [Getting Started](getting-started.md)
- [Handoff ledger — shared-repo setup (T2a)](handoff-setup.md)
- [Cool Workflow Docs](index.md)

@@ -217,2 +220,3 @@ - [INIT(7)](init.7.md)

- [backend-registry-smoke.js](../test/backend-registry-smoke.js)
- [batch-output-overflow-smoke.js](../test/batch-output-overflow-smoke.js)
- [blackboard-state-explosion-management-smoke.js](../test/blackboard-state-explosion-management-smoke.js)

@@ -243,2 +247,3 @@ - [block-unapproved-tag-smoke.js](../test/block-unapproved-tag-smoke.js)

- [concurrent-failure-semantics-smoke.js](../test/concurrent-failure-semantics-smoke.js)
- [concurrent-subworkflow-cache-nesting-smoke.js](../test/concurrent-subworkflow-cache-nesting-smoke.js)
- [concurrent-workflow-dsl-smoke.js](../test/concurrent-workflow-dsl-smoke.js)

@@ -251,2 +256,3 @@ - [contract-migration-tooling-smoke.js](../test/contract-migration-tooling-smoke.js)

- [deepseek-agent-wrapper-smoke.js](../test/deepseek-agent-wrapper-smoke.js)
- [deferred-checkpoint-batching-smoke.js](../test/deferred-checkpoint-batching-smoke.js)
- [demo-bundle-smoke.js](../test/demo-bundle-smoke.js)

@@ -257,2 +263,4 @@ - [det-ids-b-smoke.js](../test/det-ids-b-smoke.js)

- [dogfood-release-smoke.js](../test/dogfood-release-smoke.js)
- [drive-concurrency-flag-smoke.js](../test/drive-concurrency-flag-smoke.js)
- [drive-exhaustion-blocked-smoke.js](../test/drive-exhaustion-blocked-smoke.js)
- [durable-atomic-write-smoke.js](../test/durable-atomic-write-smoke.js)

@@ -275,2 +283,5 @@ - [end-to-end-demo-smoke.js](../test/end-to-end-demo-smoke.js)

- [incremental-resume-smoke.js](../test/incremental-resume-smoke.js)
- [ledger-apply-smoke.js](../test/ledger-apply-smoke.js)
- [ledger-resolution-smoke.js](../test/ledger-resolution-smoke.js)
- [ledger-verify-smoke.js](../test/ledger-verify-smoke.js)
- [loop-bounded-expansion-smoke.js](../test/loop-bounded-expansion-smoke.js)

@@ -277,0 +288,0 @@ - [mcp-app-surface-smoke.js](../test/mcp-app-surface-smoke.js)

@@ -190,1 +190,3 @@ # Real Execution Backend Integrations

0.1.97
0.1.98

@@ -330,1 +330,3 @@ # Release And Migration Discipline

0.1.97
0.1.98

@@ -293,1 +293,3 @@ # Release Tooling

0.1.97
0.1.98

@@ -473,1 +473,3 @@ # Run Registry / Control Plane

0.1.97
0.1.98

@@ -241,1 +241,3 @@ # Run Retention & Provable Reclamation

0.1.97
0.1.98

@@ -319,1 +319,3 @@ # State Explosion Management

0.1.97
0.1.98

@@ -255,1 +255,3 @@ # Team Collaboration

0.1.97
0.1.98

@@ -263,1 +263,3 @@ # Web / Desktop Workbench

0.1.97
0.1.98

@@ -5,3 +5,3 @@ {

"name": "cool-workflow",
"version": "0.1.97",
"version": "0.1.98",
"license": "BSD-2-Clause",

@@ -8,0 +8,0 @@ "homepage": "https://github.com/coo1white/cool-workflow",

{
"name": "cool-workflow",
"version": "0.1.97",
"version": "0.1.98",
"bin": {

@@ -5,0 +5,0 @@ "cool-workflow": "scripts/cw.js",

@@ -24,2 +24,12 @@ #!/usr/bin/env node

// into more thinking.
//
// REVIEW MODE: the default low-effort / read-only sandbox is right for a fast
// delegated worker or a liveness probe — but WRONG for an independent RELEASE
// reviewer, which must actually RE-RUN the gate (build, tests, regenerate dist)
// to earn its verdict. A read-only sandbox can't execute that gate, so the model
// is structurally unable to verify and tends to fabricate a REJECTED. The release
// path therefore sets CW_RELEASE_REVIEW=1 (a vendor-agnostic signal from
// release-flow.js): on that signal this wrapper raises reasoning to "high" and
// opens the sandbox to "workspace-write" so codex can run the gate it is judging.
// Explicit CW_CODEX_REASONING_EFFORT / CW_CODEX_SANDBOX always win over the signal.

@@ -84,7 +94,27 @@ const fs = require("node:fs");

render.action("codex: reading the repo (read-only)…");
// A release review (CW_RELEASE_REVIEW=1) must execute the gate it judges, so it
// needs both stronger reasoning and a sandbox that can write inside the workspace.
// Explicit env overrides win; otherwise the review signal lifts the fast defaults.
const reviewMode = process.env.CW_RELEASE_REVIEW === "1";
// Cap codex's reasoning effort for CW runs (speed) — overrides config.toml for
// THIS invocation only. Default "low"; CW_CODEX_REASONING_EFFORT opts back up.
const effort = process.env.CW_CODEX_REASONING_EFFORT || "low";
// THIS invocation only. Default "low"; CW_CODEX_REASONING_EFFORT opts back up, and
// a release review defaults to "high".
const effort = process.env.CW_CODEX_REASONING_EFFORT || (reviewMode ? "high" : "low");
// Sandbox: read-only is the POLA default (a worker/probe only reads). A release
// review opens to workspace-write so codex can build/test/regenerate the gate.
// CW_CODEX_SANDBOX overrides both; an unknown value fails closed (never silently
// downgraded to read-only, which would re-create the can't-verify failure mode).
const SANDBOX_MODES = new Set(["read-only", "workspace-write", "danger-full-access"]);
const sandbox = process.env.CW_CODEX_SANDBOX || (reviewMode ? "workspace-write" : "read-only");
if (!SANDBOX_MODES.has(sandbox)) {
process.stderr.write(
`codex-agent: invalid CW_CODEX_SANDBOX="${sandbox}" — expected one of ${[...SANDBOX_MODES].join(", ")}\n`
);
process.exit(2);
}
render.action(`codex: reading the repo (${sandbox})…`);
const args = [

@@ -98,3 +128,3 @@ "exec",

"--sandbox",
"read-only",
sandbox,
"--color",

@@ -101,0 +131,0 @@ "never",

@@ -86,3 +86,3 @@ #!/usr/bin/env node

"--scope",
"Cool Workflow v0.1.97",
"Cool Workflow v0.1.98",
"--freshness",

@@ -121,3 +121,3 @@ "as of release preparation"

assert.equal(summary.legacy, false);
assert.equal(summary.version, "0.1.97");
assert.equal(summary.version, "0.1.98");

@@ -129,3 +129,3 @@ const validation = runJson(["app", "validate", manifestPath]);

assert.equal(shown.app.id, app.id);
assert.equal(shown.app.version, "0.1.97");
assert.equal(shown.app.version, "0.1.98");
assert.ok(shown.app.metadata.canonical, `${app.id} must be marked canonical`);

@@ -141,3 +141,3 @@ assert.ok(shown.app.sandboxProfiles.length > 0, `${app.id} must declare sandbox profiles`);

assert.equal(state.workflow.app.id, app.id);
assert.equal(state.workflow.app.version, "0.1.97");
assert.equal(state.workflow.app.version, "0.1.98");
assert.equal(state.workflow.app.metadata.canonical, true);

@@ -144,0 +144,0 @@ assert.ok(state.tasks.some((task) => task.requiresEvidence), `${app.id} plan must include evidence gates`);

@@ -11,9 +11,15 @@ #!/usr/bin/env node

// the agent's own credentials resolve; CW never reads them), per-job SIGTERM at
// timeoutMs + SIGKILL at +5s, caps each captured stdout at 32MB, and prints the
// outcome array when every job has settled. stderr is drained (a full pipe must
// never wedge a child). A kill yields exitCode null — the no-exit-code refusal.
// timeoutMs + SIGKILL at +5s, caps each captured stdout at 32MB. Streams ONE
// NDJSON line per job — `{i, spawnError?, exitCode, stdout}\n` — the INSTANT
// that job settles (not once at the end): the parent's spawnSync call has its
// own combined-output cap, so writing incrementally means a job whose line
// already flushed keeps its real outcome even if a LATER job's output pushes
// the combined stream over that cap and the whole child gets killed. `i` is
// the job's index (settle order is concurrent, not submission order — the
// parent cannot infer which line belongs to which job without it). stderr is
// drained (a full pipe must never wedge a child). A kill yields exitCode null
// — the no-exit-code refusal.
//
// THE RED LINE: this child only `spawn`s the operator-resolved agent binary with
// shell:false. It imports NO model SDK and reads NO credentials. Behavior MUST
// stay byte-identical to the previous embedded string.
// shell:false. It imports NO model SDK and reads NO credentials.

@@ -34,7 +40,7 @@ const { spawn } = require("node:child_process");

if (!jobs.length) { process.stdout.write("[]"); return; }
const out = new Array(jobs.length);
let pending = jobs.length;
const CAP = 32 * 1024 * 1024;
jobs.forEach((job, i) => {
let stdout = "";
let stdoutBytes = 0;
let stdoutTruncated = false;
let settled = false;

@@ -44,4 +50,3 @@ const settle = (o) => {

settled = true;
out[i] = o;
if (--pending === 0) process.stdout.write(JSON.stringify(out));
process.stdout.write(JSON.stringify({ i, ...o }) + "\n");
};

@@ -57,3 +62,14 @@ let child;

const kill = setTimeout(() => { try { child.kill("SIGKILL"); } catch {} }, job.timeoutMs + 5000);
child.stdout.on("data", (d) => { if (stdout.length < CAP) stdout += d; });
child.stdout.on("data", (d) => {
const chunk = Buffer.isBuffer(d) ? d : Buffer.from(String(d));
stdoutBytes += chunk.length;
if (stdoutTruncated) return;
const remaining = CAP - Buffer.byteLength(stdout);
if (remaining <= 0 || chunk.length > remaining) {
stdoutTruncated = true;
if (remaining > 0) stdout += chunk.subarray(0, remaining).toString();
return;
}
stdout += chunk.toString();
});
child.stderr.on("data", () => {});

@@ -66,2 +82,6 @@ child.on("error", (error) => {

clearTimeout(term); clearTimeout(kill);
if (stdoutTruncated) {
settle({ spawnError: `stdout exceeded ${CAP} byte cap (${stdoutBytes} bytes)`, exitCode: null, stdout: "" });
return;
}
settle({ exitCode: typeof code === "number" ? code : null, stdout });

@@ -68,0 +88,0 @@ });

@@ -9,3 +9,3 @@ #!/usr/bin/env node

const TARGET_VERSION = "0.1.97";
const TARGET_VERSION = "0.1.98";
const PREVIOUS_VERSION = "0.1.31";

@@ -12,0 +12,0 @@ const pluginRoot = path.resolve(__dirname, "..");

@@ -36,3 +36,3 @@ #!/usr/bin/env node

assert.equal(appValidation.summary.id, "end-to-end-golden-path");
assert.equal(appValidation.summary.version, "0.1.97");
assert.equal(appValidation.summary.version, "0.1.98");

@@ -46,3 +46,3 @@ const plan = runJson(

"--question",
"Prove the deterministic v0.1.97 end-to-end golden path."
"Prove the deterministic v0.1.98 end-to-end golden path."
],

@@ -57,3 +57,3 @@ pluginRoot

assert.equal(state.workflow.app.id, "end-to-end-golden-path");
assert.equal(state.workflow.app.version, "0.1.97");
assert.equal(state.workflow.app.version, "0.1.98");
assert.equal(state.loopStage, "interpret");

@@ -201,3 +201,3 @@

const report = fs.readFileSync(reportPath, "utf8");
assert.match(report, /Workflow App: end-to-end-golden-path@0\.1\.97/);
assert.match(report, /Workflow App: end-to-end-golden-path@0\.1\.98/);
assert.match(report, /## Candidates/);

@@ -204,0 +204,0 @@ assert.match(report, /## Trust Audit/);

@@ -286,3 +286,9 @@ #!/usr/bin/env node

cwd: repoRoot,
env: { ...process.env },
// CW_RELEASE_REVIEW=1 is a vendor-agnostic signal that THIS spawn is a
// release verdict, not a fast worker turn. Wrappers that can re-run the gate
// (e.g. codex-agent.js) read it to raise reasoning effort and open an
// exec-capable sandbox — a read-only/low-effort reviewer can't execute the
// gate it judges and degrades to fabricated verdicts. Preflight liveness
// probes never set it, so they stay fast and read-only.
env: { ...process.env, CW_RELEASE_REVIEW: "1" },
encoding: "utf8",

@@ -289,0 +295,0 @@ timeout: cfg.timeoutMs || REVIEWER_TIMEOUT_MS,

Sorry, the diff of this file is too big to display

Sorry, the diff of this file is too big to display