cool-workflow 0.1.97 → 0.1.98

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/.codex-plugin/plugin.json +1 -1
  3. package/apps/architecture-review/app.json +1 -1
  4. package/apps/architecture-review-fast/app.json +1 -1
  5. package/apps/end-to-end-golden-path/app.json +1 -1
  6. package/apps/pr-review-fix-ci/app.json +1 -1
  7. package/apps/release-cut/app.json +1 -1
  8. package/apps/research-synthesis/app.json +1 -1
  9. package/dist/capability-core.js +3 -0
  10. package/dist/capability-registry.js +7 -1
  11. package/dist/cli/command-surface.js +4 -0
  12. package/dist/cli/handlers/ledger.js +169 -0
  13. package/dist/drive.js +98 -61
  14. package/dist/execution-backend/agent.js +84 -24
  15. package/dist/ledger.js +313 -0
  16. package/dist/mcp/tool-call.js +36 -0
  17. package/dist/mcp/tool-definitions.js +26 -0
  18. package/dist/orchestrator/lifecycle-operations.js +37 -13
  19. package/dist/orchestrator.js +11 -3
  20. package/dist/remote-source.js +10 -3
  21. package/dist/run-export.js +35 -4
  22. package/dist/version.js +1 -1
  23. package/dist/workbench-host.js +11 -1
  24. package/docs/agent-delegation-drive.7.md +2 -0
  25. package/docs/cli-mcp-parity.7.md +14 -2
  26. package/docs/contract-migration-tooling.7.md +2 -0
  27. package/docs/control-plane-scheduling.7.md +2 -0
  28. package/docs/cross-agent-ledger.7.md +217 -0
  29. package/docs/designs/handoff-ledger.md +145 -0
  30. package/docs/durable-state-and-locking.7.md +2 -0
  31. package/docs/evidence-adoption-reasoning-chain.7.md +2 -0
  32. package/docs/execution-backends.7.md +2 -0
  33. package/docs/handoff-setup.md +120 -0
  34. package/docs/multi-agent-cli-mcp-surface.7.md +2 -0
  35. package/docs/multi-agent-eval-replay-harness.7.md +2 -0
  36. package/docs/multi-agent-operator-ux.7.md +2 -0
  37. package/docs/node-snapshot-diff-replay.7.md +2 -0
  38. package/docs/observability-cost-accounting.7.md +2 -0
  39. package/docs/project-index.md +16 -5
  40. package/docs/real-execution-backends.7.md +2 -0
  41. package/docs/release-and-migration.7.md +2 -0
  42. package/docs/release-tooling.7.md +2 -0
  43. package/docs/run-registry-control-plane.7.md +2 -0
  44. package/docs/run-retention-reclamation.7.md +2 -0
  45. package/docs/state-explosion-management.7.md +2 -0
  46. package/docs/team-collaboration.7.md +2 -0
  47. package/docs/web-desktop-workbench.7.md +2 -0
  48. package/manifest/plugin.manifest.json +1 -1
  49. package/package.json +1 -1
  50. package/scripts/agents/codex-agent.js +34 -4
  51. package/scripts/canonical-apps.js +4 -4
  52. package/scripts/children/batch-delegate-child.js +30 -10
  53. package/scripts/dogfood-release.js +1 -1
  54. package/scripts/golden-path.js +4 -4
  55. package/scripts/release-flow.js +7 -1
@@ -12,6 +12,7 @@ exports.recordedAgentHandle = recordedAgentHandle;
12
12
  exports.extractEndpointResult = extractEndpointResult;
13
13
  exports.agentHandle = agentHandle;
14
14
  exports.prepareAgentSpawn = prepareAgentSpawn;
15
+ exports.reconcileBatchOutcomes = reconcileBatchOutcomes;
15
16
  exports.runAgentBatchOutcomes = runAgentBatchOutcomes;
16
17
  // Agent-delegation pure helpers + concurrent batch fulfillment for the
17
18
  // execution-backend driver layer. Carved out of execution-backend.ts
@@ -259,36 +260,95 @@ function prepareAgentSpawn(request) {
259
260
  // `node -e` string — F11). It reads jobs JSON on stdin, spawns ALL concurrently
260
261
  // (shell:false, inherited env — the agent's own credentials resolve; CW never
261
262
  // reads them), per-job SIGTERM at timeoutMs + SIGKILL at +5s, caps each captured
262
- // stdout at 32MB, and prints the outcome array when every job has settled. stderr
263
- // is drained (a full pipe must never wedge a child). A kill yields exitCode null —
264
- // the no-exit-code refusal. We spawn it BY PATH (shell:false); the path is
265
- // resolved from this compiled module (dist/execution-backend/agent.js) up to the
266
- // package's `scripts/children/` dir, which package.json ships in "files".
263
+ // stdout at 32MB, and streams ONE NDJSON line per job the instant it settles.
264
+ // stderr is drained (a full pipe must never wedge a child). A kill yields
265
+ // exitCode null — the no-exit-code refusal. We spawn it BY PATH (shell:false);
266
+ // the path is resolved from this compiled module (dist/execution-backend/agent.js)
267
+ // up to the package's `scripts/children/` dir, which package.json ships in "files".
267
268
  const BATCH_DELEGATE_CHILD_SCRIPT = node_path_1.default.resolve(__dirname, "..", "..", "scripts", "children", "batch-delegate-child.js");
269
+ /** Parse the delegate child's NDJSON stdout and reconcile it against `jobs` by
270
+ * index. Runs even when `child.error` is set (ENOBUFS from the combined
271
+ * output exceeding maxBuffer, ETIMEDOUT from the parent backstop, or a
272
+ * nonzero/null exit) — a batch-level failure must fail-close ONLY the jobs
273
+ * whose line never fully arrived, never every job in the batch: a job whose
274
+ * line already streamed through keeps its REAL outcome.
275
+ *
276
+ * `stdout` is split on the raw newline BYTE, on a Buffer, before any UTF-8
277
+ * decoding — never on a decoded string. 0x0A never appears inside a UTF-8
278
+ * continuation byte, so this is a safe boundary; decoding is deferred to
279
+ * ONE LINE at a time (bounded by the delegate's own 32MB-per-job cap), so
280
+ * no single decode ever approaches V8's hard per-string character ceiling
281
+ * regardless of how large the COMBINED batch output is. Decoding the whole
282
+ * combined buffer as one string up front (the prior approach) could itself
283
+ * throw past that ceiling for a large-enough batch — an uncaught crash, not
284
+ * a graceful `child.error` — which this line-at-a-time approach avoids by
285
+ * construction. The trailing split segment is always dropped before
286
+ * parsing (empty from a clean trailing newline, or a line truncated
287
+ * mid-write by a hard kill — either way, never a complete line), and one
288
+ * corrupt line can never crash the reconciliation of its siblings. */
289
+ function reconcileBatchOutcomes(jobs, child) {
290
+ const buf = Buffer.isBuffer(child.stdout) ? child.stdout : Buffer.from(String(child.stdout || ""), "utf8");
291
+ const byIndex = new Map();
292
+ let lineStart = 0;
293
+ for (let i = 0; i < buf.length; i++) {
294
+ if (buf[i] !== 0x0a)
295
+ continue;
296
+ const lineBuf = buf.subarray(lineStart, i);
297
+ lineStart = i + 1;
298
+ if (lineBuf.length === 0)
299
+ continue;
300
+ let parsed;
301
+ try {
302
+ parsed = JSON.parse(lineBuf.toString("utf8"));
303
+ }
304
+ catch {
305
+ continue;
306
+ }
307
+ if (typeof parsed.i !== "number" || parsed.i < 0 || parsed.i >= jobs.length)
308
+ continue;
309
+ byIndex.set(parsed.i, {
310
+ ...(parsed.spawnError ? { spawnError: parsed.spawnError } : {}),
311
+ exitCode: typeof parsed.exitCode === "number" ? parsed.exitCode : null,
312
+ stdout: String(parsed.stdout || "")
313
+ });
314
+ }
315
+ const reason = child.error
316
+ ? (0, util_1.messageOf)(child.error)
317
+ : typeof child.status === "number" && child.status !== 0
318
+ ? `batch delegate exited with ${child.status}`
319
+ : "batch delegate produced no outcome for this job";
320
+ return jobs.map((_, index) => byIndex.get(index) || { spawnError: `batch delegate failed: ${reason}`, exitCode: null, stdout: "" });
321
+ }
268
322
  /** Run a batch of agent spawns concurrently; outcomes index-align with jobs. The
269
323
  * parent backstop timeout (max job timeout + 30s) means even a wedged delegate
270
- * child cannot deadlock the drive: on any batch-level failure EVERY job settles
271
- * as a fail-closed spawn refusal never a fabricated completion, never a hang. */
324
+ * child cannot deadlock the drive. `maxBuffer` scales with batch size (the
325
+ * delegate's own per-job 32MB cap is the real safety bound no separate outer
326
+ * ceiling here, since a flat ceiling that stops scaling with job count is
327
+ * exactly what let one verbose batch strand its siblings before this fix).
328
+ * Collect-all is a real guarantee even under buffer/timeout pressure: a job
329
+ * whose NDJSON line fully streamed through keeps its real outcome regardless
330
+ * of what happens to the rest of the batch. */
272
331
  function runAgentBatchOutcomes(jobs) {
273
332
  if (!jobs.length)
274
333
  return [];
275
334
  const maxTimeout = Math.max(...jobs.map((job) => job.timeoutMs));
276
- const child = (0, node_child_process_1.spawnSync)(process.execPath, [BATCH_DELEGATE_CHILD_SCRIPT], {
277
- input: JSON.stringify(jobs),
278
- encoding: "utf8",
279
- maxBuffer: Math.min(33 * 1024 * 1024 * jobs.length, 512 * 1024 * 1024),
280
- timeout: maxTimeout + 30000
281
- });
282
- if (!child.error && typeof child.status === "number" && child.status === 0) {
283
- try {
284
- const parsed = JSON.parse(String(child.stdout || ""));
285
- if (Array.isArray(parsed) && parsed.length === jobs.length)
286
- return parsed;
287
- }
288
- catch {
289
- // fall through to the fail-closed mapping below
290
- }
335
+ // No `encoding` option: keep stdout as a raw Buffer so reconcileBatchOutcomes
336
+ // can split on the newline byte and decode one line at a time — decoding the
337
+ // WHOLE combined buffer as one string up front could itself throw past V8's
338
+ // per-string character ceiling for a large-enough batch (an uncaught crash,
339
+ // not a graceful child.error). The try/catch below is a second backstop for
340
+ // any other unexpected native failure at this boundary — a wedged or
341
+ // over-limit delegate must fail every job closed, never crash the drive.
342
+ let child;
343
+ try {
344
+ child = (0, node_child_process_1.spawnSync)(process.execPath, [BATCH_DELEGATE_CHILD_SCRIPT], {
345
+ input: JSON.stringify(jobs),
346
+ maxBuffer: 34 * 1024 * 1024 * jobs.length,
347
+ timeout: maxTimeout + 30000
348
+ });
349
+ }
350
+ catch (error) {
351
+ child = { error: error instanceof Error ? error : new Error(String(error)), status: null, stdout: null };
291
352
  }
292
- 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}`}`;
293
- return jobs.map(() => ({ spawnError: `batch delegate failed: ${reason}`, exitCode: null, stdout: "" }));
353
+ return reconcileBatchOutcomes(jobs, child);
294
354
  }
package/dist/ledger.js ADDED
@@ -0,0 +1,313 @@
1
+ "use strict";
2
+ // Cross-agent handoff ledger — the core mechanism for two agents scoped to two
3
+ // separate repos to hand each other a CHANGE PROPOSAL or a REVIEW VERDICT as
4
+ // verifiable data, not chat. Design: docs/designs/handoff-ledger.md.
5
+ //
6
+ // Stage 1 (human-relay transport): a ledger entry is a self-contained JSON
7
+ // object carrying its own sha256 content digest. The producing side prints one;
8
+ // the operator carries it to the other session; the consuming side VERIFIES it
9
+ // fail-closed (a tampered or malformed entry is refused, never acted on) before
10
+ // turning a proposal into a real PR or recording a verdict.
11
+ //
12
+ // Zero-dependency (only node stdlib). `build*`/`verify*` are pure; the stage-2
13
+ // git transport adds `listLedgerEntries`, a READ-ONLY scan of a shared ledger
14
+ // directory (the working tree of a handoff repo) that verifies every entry
15
+ // fail-closed. No run state, no writes, no network.
16
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
17
+ if (k2 === undefined) k2 = k;
18
+ var desc = Object.getOwnPropertyDescriptor(m, k);
19
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
20
+ desc = { enumerable: true, get: function() { return m[k]; } };
21
+ }
22
+ Object.defineProperty(o, k2, desc);
23
+ }) : (function(o, m, k, k2) {
24
+ if (k2 === undefined) k2 = k;
25
+ o[k2] = m[k];
26
+ }));
27
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
28
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
29
+ }) : function(o, v) {
30
+ o["default"] = v;
31
+ });
32
+ var __importStar = (this && this.__importStar) || (function () {
33
+ var ownKeys = function(o) {
34
+ ownKeys = Object.getOwnPropertyNames || function (o) {
35
+ var ar = [];
36
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
37
+ return ar;
38
+ };
39
+ return ownKeys(o);
40
+ };
41
+ return function (mod) {
42
+ if (mod && mod.__esModule) return mod;
43
+ var result = {};
44
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
45
+ __setModuleDefault(result, mod);
46
+ return result;
47
+ };
48
+ })();
49
+ Object.defineProperty(exports, "__esModule", { value: true });
50
+ exports.computeLedgerDigest = computeLedgerDigest;
51
+ exports.buildLedgerProposal = buildLedgerProposal;
52
+ exports.buildLedgerReview = buildLedgerReview;
53
+ exports.verifyLedgerEntry = verifyLedgerEntry;
54
+ exports.applyLedgerProposal = applyLedgerProposal;
55
+ exports.listLedgerEntries = listLedgerEntries;
56
+ exports.unionLedgerEntries = unionLedgerEntries;
57
+ exports.resolveLedgerInbox = resolveLedgerInbox;
58
+ const crypto = __importStar(require("crypto"));
59
+ const fs = __importStar(require("fs"));
60
+ const path = __importStar(require("path"));
61
+ /** Deterministic JSON with recursively sorted object keys, so the digest is a
62
+ * function of content only — never key insertion order. */
63
+ function stableStringify(value) {
64
+ if (value === null || typeof value !== "object")
65
+ return JSON.stringify(value);
66
+ if (Array.isArray(value))
67
+ return `[${value.map(stableStringify).join(",")}]`;
68
+ const keys = Object.keys(value).sort();
69
+ const body = keys
70
+ .map((k) => `${JSON.stringify(k)}:${stableStringify(value[k])}`)
71
+ .join(",");
72
+ return `{${body}}`;
73
+ }
74
+ /** sha256 over the canonical content (every field except `id` and `digest`,
75
+ * which are derived FROM it). Returns the full `sha256:<hex>` form. */
76
+ function computeLedgerDigest(entry) {
77
+ const hash = crypto.createHash("sha256");
78
+ hash.update(stableStringify(entry));
79
+ return `sha256:${hash.digest("hex")}`;
80
+ }
81
+ /** Content-addressed id: `ldg-` + the first 16 hex chars of the digest. Two
82
+ * entries with the same content (and createdAt) get the same id. */
83
+ function deriveId(digest) {
84
+ return `ldg-${digest.replace(/^sha256:/, "").slice(0, 16)}`;
85
+ }
86
+ function seal(content) {
87
+ const digest = computeLedgerDigest(content);
88
+ return { ...content, id: deriveId(digest), digest };
89
+ }
90
+ function buildLedgerProposal(input) {
91
+ const content = {
92
+ kind: "proposal",
93
+ schemaVersion: 1,
94
+ from: input.from,
95
+ to: input.to,
96
+ title: input.title,
97
+ rationale: input.rationale,
98
+ targetFiles: [...input.targetFiles],
99
+ suggestedDiff: input.suggestedDiff || "",
100
+ createdAt: input.createdAt
101
+ };
102
+ return seal(content);
103
+ }
104
+ function buildLedgerReview(input) {
105
+ const content = {
106
+ kind: "review",
107
+ schemaVersion: 1,
108
+ from: input.from,
109
+ to: input.to,
110
+ target: input.target,
111
+ verdict: input.verdict,
112
+ findings: [...input.findings],
113
+ createdAt: input.createdAt
114
+ };
115
+ return seal(content);
116
+ }
117
+ function isRecord(value) {
118
+ return typeof value === "object" && value !== null && !Array.isArray(value);
119
+ }
120
+ const PROPOSAL_FIELDS = ["from", "to", "title", "rationale", "targetFiles", "suggestedDiff", "createdAt"];
121
+ const REVIEW_FIELDS = ["from", "to", "target", "verdict", "findings", "createdAt"];
122
+ /** Fail-closed verification. Any structural defect, unknown kind, or digest
123
+ * mismatch yields `ok:false` — the caller refuses to act on it. */
124
+ function verifyLedgerEntry(raw) {
125
+ const checks = [];
126
+ const fail = (name, code, detail) => {
127
+ checks.push({ name, pass: false, code, detail });
128
+ return {
129
+ ok: false,
130
+ id: isRecord(raw) && typeof raw.id === "string" ? raw.id : null,
131
+ kind: isRecord(raw) && typeof raw.kind === "string" ? raw.kind : null,
132
+ checks,
133
+ failedChecks: checks.filter((c) => !c.pass).map((c) => ({ name: c.name, code: c.code, detail: c.detail }))
134
+ };
135
+ };
136
+ if (!isRecord(raw))
137
+ return fail("structure", "ledger-not-object", "entry is not a JSON object");
138
+ checks.push({ name: "structure", pass: true });
139
+ const kind = raw.kind;
140
+ if (kind !== "proposal" && kind !== "review")
141
+ return fail("kind", "ledger-unknown-kind", `kind must be proposal|review, got ${JSON.stringify(kind)}`);
142
+ checks.push({ name: "kind", pass: true });
143
+ if (raw.schemaVersion !== 1)
144
+ return fail("schema", "ledger-bad-schema", `schemaVersion must be 1, got ${JSON.stringify(raw.schemaVersion)}`);
145
+ checks.push({ name: "schema", pass: true });
146
+ if (typeof raw.digest !== "string" || !raw.digest)
147
+ return fail("digest-present", "ledger-missing-digest", "digest is absent or not a string");
148
+ checks.push({ name: "digest-present", pass: true });
149
+ const fields = kind === "proposal" ? PROPOSAL_FIELDS : REVIEW_FIELDS;
150
+ const content = { kind, schemaVersion: 1 };
151
+ for (const field of fields) {
152
+ if (!(field in raw))
153
+ return fail("fields", "ledger-missing-field", `required field ${field} is absent`);
154
+ content[field] = raw[field];
155
+ }
156
+ if (kind === "review" && raw.verdict !== "APPROVED" && raw.verdict !== "REJECTED") {
157
+ return fail("verdict", "ledger-bad-verdict", `verdict must be APPROVED|REJECTED, got ${JSON.stringify(raw.verdict)}`);
158
+ }
159
+ checks.push({ name: "fields", pass: true });
160
+ const recomputed = computeLedgerDigest(content);
161
+ if (recomputed !== raw.digest) {
162
+ return fail("digest", "ledger-digest-mismatch", `stored digest does not match content (recomputed ${recomputed})`);
163
+ }
164
+ checks.push({ name: "digest", pass: true });
165
+ // Bind the id to the content: it MUST be the content-addressed id derived from
166
+ // the digest. Without this, `id` is a free, unverified field (it is excluded
167
+ // from the digest) — a forged entry could set `id` to collide with a legit
168
+ // one, and any id-keyed de-duplication (`cw ledger list` union) would silently
169
+ // drop one of them. Fail closed so a spoofed or absent id is refused, not
170
+ // trusted.
171
+ const expectedId = deriveId(raw.digest);
172
+ if (raw.id !== expectedId) {
173
+ return fail("id", "ledger-id-mismatch", `id ${JSON.stringify(raw.id)} is not the content-addressed id for this digest (expected ${expectedId})`);
174
+ }
175
+ checks.push({ name: "id", pass: true });
176
+ return { ok: true, id: expectedId, kind, checks, failedChecks: [] };
177
+ }
178
+ /** Fail-closed extraction of a proposal's `suggestedDiff` for `git apply`. The
179
+ * diff can ONLY escape after the entry verifies: a tampered entry, a review
180
+ * (not a proposal), or a proposal with no diff all yield `ok:false` and
181
+ * `diff:null`, so `cw ledger apply <file> | git apply` can never feed an
182
+ * unverified patch to git. The kernel never shells out to git — turning the
183
+ * diff into a patch stays the operator's step (mechanism, not policy). */
184
+ function applyLedgerProposal(raw) {
185
+ const verified = verifyLedgerEntry(raw);
186
+ if (!verified.ok) {
187
+ return { ok: false, id: verified.id, kind: verified.kind, diff: null, failedChecks: verified.failedChecks };
188
+ }
189
+ if (verified.kind !== "proposal") {
190
+ 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" }] };
191
+ }
192
+ const rec = isRecord(raw) ? raw : {};
193
+ const diff = typeof rec.suggestedDiff === "string" ? rec.suggestedDiff : "";
194
+ if (!diff) {
195
+ 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" }] };
196
+ }
197
+ return { ok: true, id: verified.id, kind: verified.kind, diff, failedChecks: [] };
198
+ }
199
+ /** Read every `*.json` in `dir`, verify each entry fail-closed, and report.
200
+ * `allOk` is false if any entry is tampered, malformed, or unreadable — so the
201
+ * receiving side refuses the whole inbox rather than acting on a mixed batch. */
202
+ function listLedgerEntries(dir) {
203
+ let names;
204
+ try {
205
+ names = fs.readdirSync(dir).filter((n) => n.endsWith(".json")).sort();
206
+ }
207
+ catch (error) {
208
+ 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 }] };
209
+ return { dir, count: 0, allOk: false, entries: [entry], resolution: resolveLedgerInbox([entry]) };
210
+ }
211
+ const entries = names.map((name) => {
212
+ const file = path.join(dir, name);
213
+ let raw;
214
+ try {
215
+ const stat = fs.lstatSync(file);
216
+ if (!stat.isFile()) {
217
+ 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" }] };
218
+ }
219
+ raw = JSON.parse(fs.readFileSync(file, "utf8"));
220
+ }
221
+ catch {
222
+ 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" }] };
223
+ }
224
+ const result = verifyLedgerEntry(raw);
225
+ const rec = isRecord(raw) ? raw : {};
226
+ return {
227
+ file: name,
228
+ id: result.id,
229
+ kind: result.kind,
230
+ from: typeof rec.from === "string" ? rec.from : null,
231
+ to: typeof rec.to === "string" ? rec.to : null,
232
+ title: typeof rec.title === "string" ? rec.title : null,
233
+ target: typeof rec.target === "string" ? rec.target : null,
234
+ verdict: typeof rec.verdict === "string" ? rec.verdict : null,
235
+ ok: result.ok,
236
+ failedChecks: result.failedChecks
237
+ };
238
+ });
239
+ return { dir, count: entries.length, allOk: entries.every((e) => e.ok), entries, resolution: resolveLedgerInbox(entries) };
240
+ }
241
+ /** Union-verify several mirror directories into ONE fail-closed inbox. Verified
242
+ * entries are de-duplicated by their content-addressed id (the same entry
243
+ * mirrored to N hosts collapses to one, recording every mirror it came from);
244
+ * failing entries are kept per-occurrence so every problem in every mirror is
245
+ * visible. `allOk` is false if ANY entry in ANY mirror does not verify — a
246
+ * tampered mirror fails the whole batch. Safe because entries are immutable and
247
+ * content-addressed, so a union is a conflict-free set-union, not a merge. */
248
+ function unionLedgerEntries(dirs) {
249
+ const byId = new Map();
250
+ const failures = [];
251
+ let allOk = true;
252
+ for (const dir of dirs) {
253
+ const listed = listLedgerEntries(dir);
254
+ if (!listed.allOk)
255
+ allOk = false;
256
+ for (const entry of listed.entries) {
257
+ if (entry.ok && entry.id) {
258
+ const existing = byId.get(entry.id);
259
+ if (existing) {
260
+ if (!existing.dirs.includes(dir))
261
+ existing.dirs.push(dir);
262
+ }
263
+ else {
264
+ byId.set(entry.id, { ...entry, dirs: [dir] });
265
+ }
266
+ }
267
+ else {
268
+ failures.push({ ...entry, dirs: [dir] });
269
+ }
270
+ }
271
+ }
272
+ const entries = [...byId.values(), ...failures];
273
+ return { dirs, count: entries.length, allOk, entries, resolution: resolveLedgerInbox(entries) };
274
+ }
275
+ /** Derive a machine-actionable inbox summary: pair each proposal with the
276
+ * review(s) that target it and report whether it is pending, approved,
277
+ * rejected, or contested. Only VERIFIED entries take part — a tampered review
278
+ * must never resolve a proposal, so a proposal with only a failing review
279
+ * stays `pending` (fail-closed). Pure derivation over content-addressed
280
+ * entries: no git, no network, no policy (it reports the decision, it does not
281
+ * enforce one). */
282
+ function resolveLedgerInbox(entries) {
283
+ const verified = entries.filter((e) => e.ok);
284
+ const reviews = verified.filter((e) => e.kind === "review" && e.target);
285
+ const proposals = verified
286
+ .filter((e) => e.kind === "proposal" && e.id)
287
+ .map((p) => {
288
+ const answering = reviews.filter((r) => r.target === p.id);
289
+ const verdicts = new Set(answering.map((r) => r.verdict));
290
+ let resolution;
291
+ if (answering.length === 0)
292
+ resolution = "pending";
293
+ else if (verdicts.size > 1)
294
+ resolution = "contested";
295
+ else
296
+ resolution = verdicts.has("APPROVED") ? "approved" : "rejected";
297
+ return {
298
+ id: p.id,
299
+ title: p.title,
300
+ resolution,
301
+ reviews: answering.map((r) => r.id).sort()
302
+ };
303
+ })
304
+ .sort((a, b) => a.id.localeCompare(b.id));
305
+ const tally = (s) => proposals.filter((p) => p.resolution === s).length;
306
+ return {
307
+ proposals,
308
+ pending: tally("pending"),
309
+ approved: tally("approved"),
310
+ rejected: tally("rejected"),
311
+ contested: tally("contested")
312
+ };
313
+ }
@@ -7,6 +7,7 @@ exports.callTool = callTool;
7
7
  const node_fs_1 = __importDefault(require("node:fs"));
8
8
  const node_path_1 = __importDefault(require("node:path"));
9
9
  const orchestrator_1 = require("../orchestrator");
10
+ const ledger_1 = require("../ledger");
10
11
  const scheduler_1 = require("../scheduler");
11
12
  const triggers_1 = require("../triggers");
12
13
  const workbench_1 = require("../workbench");
@@ -294,6 +295,41 @@ function callTool(name, args) {
294
295
  return runner.collaborationCommentList(String(args.runId || ""), args);
295
296
  case "cw_handoff":
296
297
  return runner.collaborationHandoff(String(args.runId || ""), String(args.targetKind || args.kind || ""), String(args.targetId || args.target || ""), args);
298
+ // ---- Cross-agent handoff ledger (stage 2 MCP surface) ----
299
+ case "cw_ledger_propose":
300
+ return (0, ledger_1.buildLedgerProposal)({
301
+ from: String(args.from || ""),
302
+ to: String(args.to || ""),
303
+ title: String(args.title || ""),
304
+ rationale: String(args.rationale || ""),
305
+ targetFiles: String(args.files || "").split(",").map((f) => f.trim()).filter(Boolean),
306
+ suggestedDiff: args.diff === undefined ? undefined : String(args.diff),
307
+ createdAt: new Date().toISOString()
308
+ });
309
+ case "cw_ledger_review": {
310
+ const verdict = String(args.verdict || "").toUpperCase();
311
+ if (verdict !== "APPROVED" && verdict !== "REJECTED")
312
+ throw new Error('verdict must be "approved" or "rejected".');
313
+ return (0, ledger_1.buildLedgerReview)({
314
+ from: String(args.from || ""),
315
+ to: String(args.to || ""),
316
+ target: String(args.target || ""),
317
+ verdict,
318
+ findings: String(args.findings || "").split(",").map((f) => f.trim()).filter(Boolean),
319
+ createdAt: new Date().toISOString()
320
+ });
321
+ }
322
+ case "cw_ledger_verify":
323
+ return (0, ledger_1.verifyLedgerEntry)(args.entry);
324
+ case "cw_ledger_apply":
325
+ return (0, ledger_1.applyLedgerProposal)(args.entry);
326
+ case "cw_ledger_list": {
327
+ // `dirs` (2+) union-verifies mirrors; a single `dir` keeps the original shape.
328
+ const dirs = Array.isArray(args.dirs) ? args.dirs.map(String).filter(Boolean) : [];
329
+ if (dirs.length > 1)
330
+ return (0, ledger_1.unionLedgerEntries)(dirs);
331
+ return (0, ledger_1.listLedgerEntries)(dirs[0] || String(args.dir || ""));
332
+ }
297
333
  case "cw_review_status":
298
334
  return runner.reviewStatus(String(args.runId || ""), args);
299
335
  case "cw_review_policy":
@@ -641,6 +641,31 @@ function toolDefinitions() {
641
641
  from: stringSchema("From-actor id (defaults to recorder)"),
642
642
  reason: stringSchema("Handoff reason")
643
643
  }),
644
+ capabilityTool("ledger.propose", "Build a verifiable cross-agent change proposal entry (digest-sealed JSON).", {
645
+ from: stringSchema("Proposing agent/repo"),
646
+ to: stringSchema("Target agent/repo"),
647
+ title: stringSchema("Short proposal title"),
648
+ rationale: stringSchema("Why this change"),
649
+ files: stringSchema("Comma-separated target files"),
650
+ diff: stringSchema("Suggested unified diff")
651
+ }),
652
+ capabilityTool("ledger.review", "Build a verifiable cross-agent review verdict entry (digest-sealed JSON).", {
653
+ from: stringSchema("Reviewing agent/repo"),
654
+ to: stringSchema("Target agent/repo"),
655
+ target: stringSchema("Proposal id or PR ref being judged"),
656
+ verdict: stringSchema("approved | rejected"),
657
+ findings: stringSchema("Comma-separated findings")
658
+ }),
659
+ capabilityTool("ledger.verify", "Verify a ledger entry against its content digest (fail-closed on tampering).", {
660
+ entry: objectSchema("The ledger entry object to verify")
661
+ }),
662
+ capabilityTool("ledger.apply", "Verify a proposal entry and return its suggestedDiff for `git apply` (fail-closed: no diff unless the entry verifies).", {
663
+ entry: objectSchema("The proposal entry object to verify and extract the diff from")
664
+ }),
665
+ capabilityTool("ledger.list", "Read + verify every entry in a shared ledger directory (fail-closed inbox).", {
666
+ dir: stringSchema("Path to the ledger directory (a handoff repo working tree)"),
667
+ dirs: arraySchema("Multiple ledger directories to union-verify as mirrors (2+)")
668
+ }),
644
669
  capabilityTool("review.status", "Read the derived per-target review state + collaboration timeline for a run.", {
645
670
  ...runIdSchema(),
646
671
  targetKind: stringSchema("Optional target kind filter"),
@@ -876,6 +901,7 @@ function toolDefinitions() {
876
901
  question: stringSchema("The question the audited report answers"),
877
902
  once: booleanSchema("Advance exactly one step then stop"),
878
903
  now: stringSchema("Injected ISO timestamp for deterministic scheduling"),
904
+ concurrency: numberSchema("Override the round width for parallel() phases (default: the workflow's own limits.maxConcurrentAgents)"),
879
905
  cwd: stringSchema("Run workspace")
880
906
  }),
881
907
  capabilityTool("queue.add", "Enqueue a pending/planned run with explicit ordering policy (lower priority drains first). Plain files; the host still executes workers.", {
@@ -29,6 +29,7 @@ const workflow_api_1 = require("../workflow-api");
29
29
  const observability_1 = require("../observability");
30
30
  const compare_1 = require("../compare");
31
31
  const loop_expansion_1 = require("../loop-expansion");
32
+ const evidence_grounding_1 = require("../evidence-grounding");
32
33
  const dispatch_1 = require("../dispatch");
33
34
  const verifier_1 = require("../verifier");
34
35
  const trust_audit_1 = require("../trust-audit");
@@ -179,7 +180,14 @@ function plan(appRecord, options) {
179
180
  (0, state_1.saveCheckpoint)(run);
180
181
  return run;
181
182
  }
183
+ /** `options.persistState === false` (concurrent-round callers ONLY — never from
184
+ * a CLI/MCP arg bag) skips commitState/saveCheckpoint/writeReport on every
185
+ * branch, success or error, so a caller driving many tasks through one
186
+ * in-memory `run` can defer the disk flush to a single call at round end
187
+ * instead of once per task. Default (absent) preserves today's exact
188
+ * per-call persistence. */
182
189
  function dispatch(run, options) {
190
+ const persistState = options.persistState !== false;
183
191
  try {
184
192
  const manifest = (0, dispatch_1.createDispatchManifest)(run, (0, cli_options_1.numberOption)(options.limit), {
185
193
  sandboxProfileId: (0, cli_options_1.stringOption)(options.sandbox) || (0, cli_options_1.stringOption)(options.sandboxProfile) || (0, cli_options_1.stringOption)(options.sandboxProfileId),
@@ -190,10 +198,12 @@ function dispatch(run, options) {
190
198
  multiAgentFanoutId: (0, cli_options_1.stringOption)(options.multiAgentFanout || options.multiAgentFanoutId || options.fanout || options["multi-agent-fanout"])
191
199
  });
192
200
  run.loopStage = "act";
193
- if (manifest.dispatchId)
194
- (0, commit_1.commitState)(run, `dispatch:${manifest.dispatchId}`);
195
- (0, state_1.saveCheckpoint)(run);
196
- (0, report_1.writeReport)(run);
201
+ if (persistState) {
202
+ if (manifest.dispatchId)
203
+ (0, commit_1.commitState)(run, `dispatch:${manifest.dispatchId}`);
204
+ (0, state_1.saveCheckpoint)(run);
205
+ (0, report_1.writeReport)(run);
206
+ }
197
207
  return manifest;
198
208
  }
199
209
  catch (error) {
@@ -212,8 +222,10 @@ function dispatch(run, options) {
212
222
  retryable: false,
213
223
  metadata: { sandboxProfileId: (0, cli_options_1.stringOption)(options.sandbox) || (0, cli_options_1.stringOption)(options.sandboxProfile) || (0, cli_options_1.stringOption)(options.sandboxProfileId) }
214
224
  }, { persist: false });
215
- (0, report_1.writeReport)(run);
216
- (0, state_1.saveCheckpoint)(run);
225
+ if (persistState) {
226
+ (0, report_1.writeReport)(run);
227
+ (0, state_1.saveCheckpoint)(run);
228
+ }
217
229
  }
218
230
  throw error;
219
231
  }
@@ -239,6 +251,10 @@ function recordResult(run, taskId, resultPath, options = {}) {
239
251
  const parsedResult = (0, verifier_1.parseResultEnvelope)(rawResult);
240
252
  run.loopStage = "adjust";
241
253
  (0, verifier_1.validateResultEnvelope)(task, parsedResult);
254
+ const unresolved = (0, evidence_grounding_1.unresolvedFileEvidence)(parsedResult.evidence, [run.cwd, process.cwd(), run.paths.runDir, node_path_1.default.dirname(absoluteResultPath)]);
255
+ if (unresolved.length) {
256
+ throw new Error(`Result cites file evidence that does not resolve on disk: ${unresolved.join(", ")}`);
257
+ }
242
258
  const destination = node_path_1.default.join(run.paths.resultsDir, `${(0, state_1.safeFileName)(taskId)}.md`);
243
259
  node_fs_1.default.copyFileSync(absoluteResultPath, destination);
244
260
  task.status = "completed";
@@ -327,6 +343,7 @@ function recordWorkerOutput(run, workerId, resultPath, options = {}) {
327
343
  // Track 1 fail-closed (opt-in): forward the policy so recordWorkerOutput can
328
344
  // park a hop whose telemetry isn't attested. Default (absent) ⇒ flag-and-surface.
329
345
  const requireAttestedTelemetry = options.requireAttestedTelemetry === true;
346
+ const persistState = options.persistState !== false;
330
347
  try {
331
348
  (0, worker_isolation_1.recordWorkerOutput)(run, workerId, resultPath, { persist: false, agentDelegation, requireAttestedTelemetry });
332
349
  if (usage) {
@@ -341,20 +358,25 @@ function recordWorkerOutput(run, workerId, resultPath, options = {}) {
341
358
  // and either append the next round or mark the loop done (no-op for non-loop runs).
342
359
  maybeExpandLoop(run);
343
360
  (0, verifier_1.validateRunGates)(run);
344
- (0, commit_1.commitState)(run, `worker:${workerId}:result`);
345
- (0, report_1.writeReport)(run);
346
- (0, state_1.saveCheckpoint)(run);
361
+ if (persistState) {
362
+ (0, commit_1.commitState)(run, `worker:${workerId}:result`);
363
+ (0, report_1.writeReport)(run);
364
+ (0, state_1.saveCheckpoint)(run);
365
+ }
347
366
  return (0, report_1.summarizeRun)(run);
348
367
  }
349
368
  catch (error) {
350
369
  run.loopStage = "adjust";
351
370
  (0, dispatch_1.updatePhaseStatuses)(run);
352
- (0, report_1.writeReport)(run);
353
- (0, state_1.saveCheckpoint)(run);
371
+ if (persistState) {
372
+ (0, report_1.writeReport)(run);
373
+ (0, state_1.saveCheckpoint)(run);
374
+ }
354
375
  throw error;
355
376
  }
356
377
  }
357
378
  function recordWorkerFailure(run, workerId, message, options = {}) {
379
+ const persistState = options.persistState !== false;
358
380
  const failure = (0, worker_isolation_1.recordWorkerFailure)(run, workerId, {
359
381
  code: String(options.code || "worker-runtime-error"),
360
382
  message,
@@ -364,8 +386,10 @@ function recordWorkerFailure(run, workerId, message, options = {}) {
364
386
  }, { persist: false, retryCount: typeof options.retryCount === "number" ? Number(options.retryCount) : undefined });
365
387
  run.loopStage = "adjust";
366
388
  (0, dispatch_1.updatePhaseStatuses)(run);
367
- (0, report_1.writeReport)(run);
368
- (0, state_1.saveCheckpoint)(run);
389
+ if (persistState) {
390
+ (0, report_1.writeReport)(run);
391
+ (0, state_1.saveCheckpoint)(run);
392
+ }
369
393
  return failure;
370
394
  }
371
395
  function checkState(runId, options = {}) {