cool-workflow 0.1.97 → 0.1.98
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/apps/architecture-review/app.json +1 -1
- package/apps/architecture-review-fast/app.json +1 -1
- package/apps/end-to-end-golden-path/app.json +1 -1
- package/apps/pr-review-fix-ci/app.json +1 -1
- package/apps/release-cut/app.json +1 -1
- package/apps/research-synthesis/app.json +1 -1
- package/dist/capability-core.js +3 -0
- package/dist/capability-registry.js +7 -1
- package/dist/cli/command-surface.js +4 -0
- package/dist/cli/handlers/ledger.js +169 -0
- package/dist/drive.js +98 -61
- package/dist/execution-backend/agent.js +84 -24
- package/dist/ledger.js +313 -0
- package/dist/mcp/tool-call.js +36 -0
- package/dist/mcp/tool-definitions.js +26 -0
- package/dist/orchestrator/lifecycle-operations.js +37 -13
- package/dist/orchestrator.js +11 -3
- package/dist/remote-source.js +10 -3
- package/dist/run-export.js +35 -4
- package/dist/version.js +1 -1
- package/dist/workbench-host.js +11 -1
- package/docs/agent-delegation-drive.7.md +2 -0
- package/docs/cli-mcp-parity.7.md +14 -2
- package/docs/contract-migration-tooling.7.md +2 -0
- package/docs/control-plane-scheduling.7.md +2 -0
- package/docs/cross-agent-ledger.7.md +217 -0
- package/docs/designs/handoff-ledger.md +145 -0
- package/docs/durable-state-and-locking.7.md +2 -0
- package/docs/evidence-adoption-reasoning-chain.7.md +2 -0
- package/docs/execution-backends.7.md +2 -0
- package/docs/handoff-setup.md +120 -0
- package/docs/multi-agent-cli-mcp-surface.7.md +2 -0
- package/docs/multi-agent-eval-replay-harness.7.md +2 -0
- package/docs/multi-agent-operator-ux.7.md +2 -0
- package/docs/node-snapshot-diff-replay.7.md +2 -0
- package/docs/observability-cost-accounting.7.md +2 -0
- package/docs/project-index.md +16 -5
- package/docs/real-execution-backends.7.md +2 -0
- package/docs/release-and-migration.7.md +2 -0
- package/docs/release-tooling.7.md +2 -0
- package/docs/run-registry-control-plane.7.md +2 -0
- package/docs/run-retention-reclamation.7.md +2 -0
- package/docs/state-explosion-management.7.md +2 -0
- package/docs/team-collaboration.7.md +2 -0
- package/docs/web-desktop-workbench.7.md +2 -0
- package/manifest/plugin.manifest.json +1 -1
- package/package.json +1 -1
- package/scripts/agents/codex-agent.js +34 -4
- package/scripts/canonical-apps.js +4 -4
- package/scripts/children/batch-delegate-child.js +30 -10
- package/scripts/dogfood-release.js +1 -1
- package/scripts/golden-path.js +4 -4
- package/scripts/release-flow.js +7 -1
|
@@ -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
|
|
263
|
-
// is drained (a full pipe must never wedge a child). A kill yields
|
|
264
|
-
// the no-exit-code refusal. We spawn it BY PATH (shell:false);
|
|
265
|
-
// resolved from this compiled module (dist/execution-backend/agent.js)
|
|
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
|
|
271
|
-
*
|
|
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
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
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
|
-
|
|
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
|
+
}
|
package/dist/mcp/tool-call.js
CHANGED
|
@@ -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 (
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
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
|
-
|
|
216
|
-
|
|
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
|
-
(
|
|
345
|
-
|
|
346
|
-
|
|
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
|
-
|
|
353
|
-
|
|
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
|
-
|
|
368
|
-
|
|
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 = {}) {
|