cool-workflow 0.1.86 → 0.1.88
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/README.md +111 -68
- 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/agent-config.js +42 -1
- package/dist/capability-core.js +9 -4
- package/dist/capability-registry.js +48 -0
- package/dist/cli/command-surface.js +182 -11
- package/dist/cli.js +2 -1
- package/dist/doctor.js +27 -5
- package/dist/drive.js +222 -16
- package/dist/execution-backend.js +12 -4
- package/dist/loop-expansion.js +60 -0
- package/dist/onramp.js +25 -0
- package/dist/operator-ux/format.js +21 -15
- package/dist/operator-ux.js +2 -1
- package/dist/orchestrator/lifecycle-operations.js +134 -3
- package/dist/orchestrator.js +122 -76
- package/dist/run-export.js +106 -2
- package/dist/state-node.js +13 -3
- package/dist/state.js +21 -0
- package/dist/telemetry-attestation.js +30 -6
- package/dist/telemetry-demo.js +29 -1
- package/dist/telemetry-ledger.js +6 -0
- package/dist/term.js +93 -0
- package/dist/version.js +1 -1
- package/dist/worker-accept/telemetry-ledger.js +12 -2
- package/dist/workflow-api.js +33 -0
- package/dist/workflow-app-framework.js +20 -0
- package/docs/agent-delegation-drive.7.md +28 -6
- package/docs/capability-topology-registry.7.md +69 -46
- package/docs/cli-mcp-parity.7.md +22 -2
- package/docs/contract-migration-tooling.7.md +8 -0
- package/docs/control-plane-scheduling.7.md +8 -0
- package/docs/durable-state-and-locking.7.md +8 -0
- package/docs/evidence-adoption-reasoning-chain.7.md +8 -0
- package/docs/execution-backends.7.md +8 -0
- package/docs/launch/launch-kit.md +9 -9
- package/docs/multi-agent-cli-mcp-surface.7.md +8 -0
- package/docs/multi-agent-eval-replay-harness.7.md +8 -0
- package/docs/multi-agent-operator-ux.7.md +8 -0
- package/docs/node-snapshot-diff-replay.7.md +8 -0
- package/docs/observability-cost-accounting.7.md +8 -0
- package/docs/project-index.md +26 -5
- package/docs/readme-v0.1.87-full.md +301 -0
- package/docs/real-execution-backends.7.md +8 -0
- package/docs/release-and-migration.7.md +8 -0
- package/docs/release-history.md +10 -0
- package/docs/release-tooling.7.md +16 -0
- package/docs/report-verifiable-bundle.7.md +34 -2
- package/docs/run-registry-control-plane.7.md +18 -0
- package/docs/run-retention-reclamation.7.md +8 -0
- package/docs/state-explosion-management.7.md +8 -0
- package/docs/team-collaboration.7.md +8 -0
- package/docs/trust-model.md +6 -4
- package/docs/web-desktop-workbench.7.md +8 -0
- package/manifest/plugin.manifest.json +1 -1
- package/manifest/source-context-profiles.json +1 -1
- package/package.json +8 -5
- package/scripts/agents/agent-adapter-core.js +180 -0
- package/scripts/agents/builtin-templates.json +5 -1
- package/scripts/agents/claude-p-agent.js +7 -33
- package/scripts/agents/codex-agent.js +134 -0
- package/scripts/agents/cw-attest-wrap.js +9 -1
- package/scripts/agents/gemini-agent.js +115 -0
- package/scripts/agents/opencode-agent.js +119 -0
- package/scripts/canonical-apps.js +4 -4
- package/scripts/coverage-gate.js +15 -1
- package/scripts/cw.js +0 -0
- package/scripts/dogfood-release.js +1 -1
- package/scripts/golden-path.js +4 -4
- package/scripts/parity-check.js +6 -5
- package/scripts/release-check.js +3 -3
- package/scripts/release-flow.js +49 -2
- package/scripts/release-gate.sh +1 -1
- package/tsconfig.json +3 -1
|
@@ -53,7 +53,11 @@ function canonicalTelemetryPayload(usage, ctx) {
|
|
|
53
53
|
usage: usage ?? null,
|
|
54
54
|
runId: ctx.runId,
|
|
55
55
|
taskId: ctx.taskId,
|
|
56
|
-
promptDigest: ctx.promptDigest
|
|
56
|
+
promptDigest: ctx.promptDigest,
|
|
57
|
+
// Present ONLY when the signer covered the result. Omitting the key keeps the
|
|
58
|
+
// canonical bytes byte-identical to the original 4-field payload, so every
|
|
59
|
+
// pre-result-coverage signature still verifies (POLA / back-compat).
|
|
60
|
+
...(ctx.resultDigest !== undefined ? { resultDigest: ctx.resultDigest } : {})
|
|
57
61
|
});
|
|
58
62
|
}
|
|
59
63
|
function messageOf(error) {
|
|
@@ -110,16 +114,26 @@ function verifyTelemetryAttestation(usage, signatureB64, trustPublicKeyPem, ctx)
|
|
|
110
114
|
catch {
|
|
111
115
|
return { status: "unattested", reason: "signature is not valid base64" };
|
|
112
116
|
}
|
|
113
|
-
const
|
|
117
|
+
const matches = (c) => node_crypto_1.default.verify(null, Buffer.from(canonicalTelemetryPayload(usage, c), "utf8"), publicKey, signature);
|
|
114
118
|
let ok = false;
|
|
119
|
+
let coversResult = false;
|
|
115
120
|
try {
|
|
116
|
-
ok =
|
|
121
|
+
ok = matches(ctx);
|
|
122
|
+
// A match on the first arm (which carries resultDigest when CW has one) means
|
|
123
|
+
// the signature covered the result — the findings — not just the usage.
|
|
124
|
+
coversResult = ok && ctx.resultDigest !== undefined;
|
|
125
|
+
// Back-compat: a signer that predates result coverage signed only the 4-field
|
|
126
|
+
// payload, so on a miss retry WITHOUT resultDigest. A NEW signer who covered
|
|
127
|
+
// the result fails BOTH arms when the result is edited (its resultDigest no
|
|
128
|
+
// longer matches), so result tampering is still caught.
|
|
129
|
+
if (!ok && ctx.resultDigest !== undefined)
|
|
130
|
+
ok = matches({ ...ctx, resultDigest: undefined });
|
|
117
131
|
}
|
|
118
132
|
catch (error) {
|
|
119
133
|
return { status: "unattested", reason: `verification error: ${messageOf(error)}` };
|
|
120
134
|
}
|
|
121
135
|
return ok
|
|
122
|
-
? { status: "attested", algorithm: "ed25519" }
|
|
136
|
+
? { status: "attested", algorithm: "ed25519", ...(coversResult ? { coversResult: true } : {}) }
|
|
123
137
|
: { status: "unattested", reason: "signature does not match reported usage (tampered, replayed, or wrong key)" };
|
|
124
138
|
}
|
|
125
139
|
/** Resolve a trust key from a config value that is EITHER an inline PEM or a path
|
|
@@ -160,6 +174,7 @@ function stableDigest(value) {
|
|
|
160
174
|
}
|
|
161
175
|
function verifyTelemetrySignatures(records, trustPublicKeyPem) {
|
|
162
176
|
const checks = [];
|
|
177
|
+
const resultBound = [];
|
|
163
178
|
let checked = 0;
|
|
164
179
|
let reverified = 0;
|
|
165
180
|
let failed = 0;
|
|
@@ -190,11 +205,20 @@ function verifyTelemetrySignatures(records, trustPublicKeyPem) {
|
|
|
190
205
|
const result = verifyTelemetryAttestation(record.reportedUsage, record.usageSignature, trustPublicKeyPem, {
|
|
191
206
|
runId: record.runId,
|
|
192
207
|
taskId: record.taskId,
|
|
193
|
-
promptDigest: record.promptDigest
|
|
208
|
+
promptDigest: record.promptDigest,
|
|
209
|
+
// Result-bound records carry the signed digest; verifyTelemetryAttestation
|
|
210
|
+
// reconstructs the 5-field payload (and falls back to 4-field for old records).
|
|
211
|
+
resultDigest: record.resultDigest
|
|
194
212
|
});
|
|
195
213
|
if (result.status === "attested") {
|
|
196
214
|
reverified += 1;
|
|
197
215
|
checks.push({ name: `signature[${i}]`, pass: true });
|
|
216
|
+
// Only a signature that actually COVERED the result digest anchors it — a
|
|
217
|
+
// 4-field fallback (coversResult false) must not let an injected resultDigest
|
|
218
|
+
// be trusted downstream.
|
|
219
|
+
if (result.coversResult && record.resultDigest) {
|
|
220
|
+
resultBound.push({ taskId: record.taskId, resultDigest: record.resultDigest });
|
|
221
|
+
}
|
|
198
222
|
}
|
|
199
223
|
else {
|
|
200
224
|
failed += 1;
|
|
@@ -207,5 +231,5 @@ function verifyTelemetrySignatures(records, trustPublicKeyPem) {
|
|
|
207
231
|
});
|
|
208
232
|
}
|
|
209
233
|
}
|
|
210
|
-
return { keyProvided: Boolean(trustPublicKeyPem), checked, reverified, failed, checks };
|
|
234
|
+
return { keyProvided: Boolean(trustPublicKeyPem), checked, reverified, failed, resultBound, checks };
|
|
211
235
|
}
|
package/dist/telemetry-demo.js
CHANGED
|
@@ -6,13 +6,17 @@
|
|
|
6
6
|
// Fully hermetic + deterministic: generates an EPHEMERAL ed25519 keypair, builds
|
|
7
7
|
// a REAL telemetry ledger through the production append API (appendTelemetryAttestation
|
|
8
8
|
// + signTelemetry — byte-identical to what a live attested run writes), then
|
|
9
|
-
// demonstrates
|
|
9
|
+
// demonstrates all THREE tamper-evidence layers catching a forgery:
|
|
10
10
|
// A) LEDGER layer — flip a recorded verdict on disk (unattested -> attested, the
|
|
11
11
|
// canonical "forge a green record" attack) -> verifyTelemetryLedger recomputes
|
|
12
12
|
// every hash independently, so the edited record's hash mismatches AND every
|
|
13
13
|
// record after it breaks the chain (cascade).
|
|
14
14
|
// B) SIGNATURE layer — inflate the reported tokens but keep the original ed25519
|
|
15
15
|
// signature -> verifyTelemetryAttestation rejects it ("signature does not match").
|
|
16
|
+
// C) RESULT layer — edit the agent's SIGNED FINDING after signing. The executor
|
|
17
|
+
// binds sha256(result.md) into the ed25519 payload; CW re-derives the digest
|
|
18
|
+
// from the result file at verify time, so an edited finding no longer joins the
|
|
19
|
+
// signature. This is the threat a usage-only signature never covered.
|
|
16
20
|
//
|
|
17
21
|
// No model, no network, no API key, no second repo — runs in a private tmpdir.
|
|
18
22
|
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
@@ -161,6 +165,30 @@ function runTamperDemo(options = {}) {
|
|
|
161
165
|
after: { verified: sigCheck.status === "attested", detail: sigCheck.reason || sigCheck.status },
|
|
162
166
|
failures: sigCheck.status === "attested" ? [] : [`signature: ${sigCheck.reason}`]
|
|
163
167
|
});
|
|
168
|
+
// 3c. RESULT layer — the REAL threat the result-bound signature closes: the agent's
|
|
169
|
+
// signed FINDING is edited after signing. The executor binds sha256(result.md)
|
|
170
|
+
// into the ed25519 payload (a 5-field, result-COVERING signature); CW re-derives
|
|
171
|
+
// the digest from the result file at verify time. An edited finding hashes to a
|
|
172
|
+
// different digest, so the signature joins neither the 5-field nor the 4-field
|
|
173
|
+
// payload and the verify rejects it. A usage-only signature never covered this.
|
|
174
|
+
const findingSigned = "Finding: auth bypass in login() — severity HIGH";
|
|
175
|
+
const findingEdited = findingSigned.replace("HIGH", "LOW");
|
|
176
|
+
const resultCtx = { runId, taskId: target.hop.taskId, promptDigest: target.hop.promptDigest };
|
|
177
|
+
const resultSignature = (0, telemetry_attestation_1.signTelemetry)(target.hop.usage, privateKeyPem, { ...resultCtx, resultDigest: (0, execution_backend_1.sha256)(findingSigned) });
|
|
178
|
+
// Baseline: the signed finding verifies, and the signature actually COVERED the result.
|
|
179
|
+
const resultClean = (0, telemetry_attestation_1.verifyTelemetryAttestation)(target.hop.usage, resultSignature, publicKeyPem, { ...resultCtx, resultDigest: (0, execution_backend_1.sha256)(findingSigned) });
|
|
180
|
+
// Tamper: CW re-derives the digest from the EDITED finding -> no longer the signed digest.
|
|
181
|
+
const resultCheck = (0, telemetry_attestation_1.verifyTelemetryAttestation)(target.hop.usage, resultSignature, publicKeyPem, { ...resultCtx, resultDigest: (0, execution_backend_1.sha256)(findingEdited) });
|
|
182
|
+
layers.push({
|
|
183
|
+
layer: "result",
|
|
184
|
+
tamper: `edited the agent's signed finding "severity HIGH" -> "severity LOW" after it was signed`,
|
|
185
|
+
before: {
|
|
186
|
+
verified: resultClean.status === "attested" && resultClean.coversResult === true,
|
|
187
|
+
detail: `the signed finding verifies — ed25519 binds usage + sha256(result), result-covering`
|
|
188
|
+
},
|
|
189
|
+
after: { verified: resultCheck.status === "attested", detail: resultCheck.reason || resultCheck.status },
|
|
190
|
+
failures: resultCheck.status === "attested" ? [] : [`result: ${resultCheck.reason}`]
|
|
191
|
+
});
|
|
164
192
|
if (!options.keepDir && !options.dir)
|
|
165
193
|
node_fs_1.default.rmSync(runDir, { recursive: true, force: true });
|
|
166
194
|
const proven = baseline.ledgerVerified &&
|
package/dist/telemetry-ledger.js
CHANGED
|
@@ -94,6 +94,9 @@ function recordHashInput(record) {
|
|
|
94
94
|
reportedUsageDigest: record.reportedUsageDigest,
|
|
95
95
|
...(record.reportedUsage !== undefined ? { reportedUsage: record.reportedUsage } : {}),
|
|
96
96
|
usageSignature: record.usageSignature || null,
|
|
97
|
+
// Chain-bind resultDigest only when present, so a usage-only record's hash is
|
|
98
|
+
// byte-identical to a pre-result-coverage one (back-compat with old ledgers).
|
|
99
|
+
...(record.resultDigest !== undefined ? { resultDigest: record.resultDigest } : {}),
|
|
97
100
|
attestation: record.attestation,
|
|
98
101
|
attestationReason: record.attestationReason || null,
|
|
99
102
|
prevHash: record.prevHash
|
|
@@ -131,6 +134,9 @@ function appendTelemetryAttestation(run, input) {
|
|
|
131
134
|
// signature can be independently re-verified offline at `telemetry verify`.
|
|
132
135
|
...(input.reportedUsage ? { reportedUsage: input.reportedUsage } : {}),
|
|
133
136
|
usageSignature: input.usageSignature,
|
|
137
|
+
// Present only for a result-bound signature, so usage-only records are
|
|
138
|
+
// byte-identical (and their recordHash unchanged) — back-compat.
|
|
139
|
+
...(input.resultDigest ? { resultDigest: input.resultDigest } : {}),
|
|
134
140
|
attestation: input.attestation,
|
|
135
141
|
attestationReason: input.attestationReason,
|
|
136
142
|
prevHash
|
package/dist/term.js
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// src/term.ts — zero-dependency terminal styling.
|
|
3
|
+
//
|
|
4
|
+
// Provides TTY-gated ANSI formatting for human-readable output. When output is
|
|
5
|
+
// piped (non-TTY), styled calls return plain text. This keeps data channels
|
|
6
|
+
// (stdout) and diagnostics (stderr) clean — never adds escape codes to pipes.
|
|
7
|
+
//
|
|
8
|
+
// Used by: doctor, help, error messages, status summaries.
|
|
9
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
10
|
+
exports.bold = bold;
|
|
11
|
+
exports.dim = dim;
|
|
12
|
+
exports.green = green;
|
|
13
|
+
exports.yellow = yellow;
|
|
14
|
+
exports.red = red;
|
|
15
|
+
exports.cyan = cyan;
|
|
16
|
+
exports.doctorGlyph = doctorGlyph;
|
|
17
|
+
exports.cwLabel = cwLabel;
|
|
18
|
+
exports.indent = indent;
|
|
19
|
+
exports.printSuccessSummary = printSuccessSummary;
|
|
20
|
+
function isTTY(stream = process.stderr) {
|
|
21
|
+
return Boolean(stream.isTTY);
|
|
22
|
+
}
|
|
23
|
+
// ---- ansi codes ----
|
|
24
|
+
const ansi = {
|
|
25
|
+
reset: "\x1b[0m",
|
|
26
|
+
bold: "\x1b[1m",
|
|
27
|
+
dim: "\x1b[2m",
|
|
28
|
+
green: "\x1b[32m",
|
|
29
|
+
yellow: "\x1b[33m",
|
|
30
|
+
red: "\x1b[31m",
|
|
31
|
+
cyan: "\x1b[36m",
|
|
32
|
+
};
|
|
33
|
+
// ---- styled text ----
|
|
34
|
+
function style(code, text, stream) {
|
|
35
|
+
if (!isTTY(stream))
|
|
36
|
+
return text;
|
|
37
|
+
return `${code}${text}${ansi.reset}`;
|
|
38
|
+
}
|
|
39
|
+
function bold(text, stream) {
|
|
40
|
+
return style(ansi.bold, text, stream);
|
|
41
|
+
}
|
|
42
|
+
function dim(text, stream) {
|
|
43
|
+
return style(ansi.dim, text, stream);
|
|
44
|
+
}
|
|
45
|
+
function green(text, stream) {
|
|
46
|
+
return style(ansi.green, text, stream);
|
|
47
|
+
}
|
|
48
|
+
function yellow(text, stream) {
|
|
49
|
+
return style(ansi.yellow, text, stream);
|
|
50
|
+
}
|
|
51
|
+
function red(text, stream) {
|
|
52
|
+
return style(ansi.red, text, stream);
|
|
53
|
+
}
|
|
54
|
+
function cyan(text, stream) {
|
|
55
|
+
return style(ansi.cyan, text, stream);
|
|
56
|
+
}
|
|
57
|
+
// ---- semantic helpers ----
|
|
58
|
+
/** Returns the styled glyph + label for a doctor check severity. */
|
|
59
|
+
function doctorGlyph(status, stream) {
|
|
60
|
+
const glyph = { ok: "✓", warn: "!", fail: "✗" };
|
|
61
|
+
const color = {
|
|
62
|
+
ok: green,
|
|
63
|
+
warn: yellow,
|
|
64
|
+
fail: red,
|
|
65
|
+
};
|
|
66
|
+
return color[status](`${glyph[status]}`, stream);
|
|
67
|
+
}
|
|
68
|
+
/** "cw:" prefix with bold and optional color. */
|
|
69
|
+
function cwLabel(stream) {
|
|
70
|
+
return bold("cw:", stream);
|
|
71
|
+
}
|
|
72
|
+
/** Render a multi-line block with consistent 2-space indentation. */
|
|
73
|
+
function indent(text, spaces = 2) {
|
|
74
|
+
const prefix = " ".repeat(spaces);
|
|
75
|
+
return text.split("\n").map((line) => `${prefix}${line}`).join("\n");
|
|
76
|
+
}
|
|
77
|
+
/** Print a success summary to stderr (TTY-gated). Shows the report path and a
|
|
78
|
+
* suggested next command. Pipe-friendly: silent when stderr is not a TTY. */
|
|
79
|
+
function printSuccessSummary(fields, stream) {
|
|
80
|
+
if (!isTTY(stream))
|
|
81
|
+
return;
|
|
82
|
+
const s = stream || process.stderr;
|
|
83
|
+
s.write(`\n${green("✓")} Report: ${fields.reportPath}\n`);
|
|
84
|
+
if (fields.status === "complete") {
|
|
85
|
+
s.write(` Next: cw status ${fields.runId} --brief\n`);
|
|
86
|
+
if (fields.bundle !== false) {
|
|
87
|
+
s.write(` Bundle: cw report bundle ${fields.runId}\n`);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
else {
|
|
91
|
+
s.write(` ${yellow("!")} Status: ${fields.status}. Next: cw status ${fields.runId}\n`);
|
|
92
|
+
}
|
|
93
|
+
}
|
package/dist/version.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.MIN_SUPPORTED_RUN_STATE_SCHEMA_VERSION = exports.LEGACY_RUN_STATE_SCHEMA_VERSION = exports.CURRENT_RUN_STATE_SCHEMA_VERSION = exports.WORKFLOW_APP_SCHEMA_VERSION = exports.CURRENT_COOL_WORKFLOW_VERSION = void 0;
|
|
4
|
-
exports.CURRENT_COOL_WORKFLOW_VERSION = "0.1.
|
|
4
|
+
exports.CURRENT_COOL_WORKFLOW_VERSION = "0.1.88";
|
|
5
5
|
exports.WORKFLOW_APP_SCHEMA_VERSION = 1;
|
|
6
6
|
exports.CURRENT_RUN_STATE_SCHEMA_VERSION = 1;
|
|
7
7
|
exports.LEGACY_RUN_STATE_SCHEMA_VERSION = 0;
|
|
@@ -24,7 +24,12 @@ function attestWorkerDelegation(accept, deps) {
|
|
|
24
24
|
// usage. Absent/invalid signature => `unattested`/`absent`, surfaced loudly,
|
|
25
25
|
// NEVER silently recorded as trusted.
|
|
26
26
|
const telemetry = options.agentDelegation
|
|
27
|
-
? (0, telemetry_attestation_1.verifyTelemetryAttestation)(options.agentDelegation.reportedUsage, options.agentDelegation.usageSignature, (0, telemetry_attestation_1.resolveTrustPublicKey)(options.agentDelegation.usageTrustPublicKey),
|
|
27
|
+
? (0, telemetry_attestation_1.verifyTelemetryAttestation)(options.agentDelegation.reportedUsage, options.agentDelegation.usageSignature, (0, telemetry_attestation_1.resolveTrustPublicKey)(options.agentDelegation.usageTrustPublicKey),
|
|
28
|
+
// resultDigest binds the agent's findings into the signature: CW recomputes
|
|
29
|
+
// the digest from the accepted result (the SAME raw bytes the executor
|
|
30
|
+
// signed) so a result edited after signing fails verification. A signer
|
|
31
|
+
// that did not cover the result still verifies (verifier back-compat).
|
|
32
|
+
{ runId: run.id, taskId: task.id, promptDigest: options.agentDelegation.promptDigest, resultDigest: (0, execution_backend_1.sha256)(rawResult) })
|
|
28
33
|
: undefined;
|
|
29
34
|
// Track 1 fail-closed (Decision 2 — OPT-IN, off by default). When the operator
|
|
30
35
|
// requires attested telemetry, a delegated hop whose verdict is not `attested`
|
|
@@ -59,7 +64,7 @@ function attestWorkerDelegation(accept, deps) {
|
|
|
59
64
|
* event can cross-link the record hash), then emits the worker.agent-delegation
|
|
60
65
|
* audit event. No-op for non-agent hops. */
|
|
61
66
|
function recordWorkerDelegationLedger(accept, delegation) {
|
|
62
|
-
const { agentDelegation } = delegation;
|
|
67
|
+
const { agentDelegation, telemetry } = delegation;
|
|
63
68
|
// The agent-hop attestation event — hung off worker.output, alongside
|
|
64
69
|
// worker.backend. Recorded in trust-audit/provenance, NEVER in node evidence.
|
|
65
70
|
if (!agentDelegation)
|
|
@@ -76,6 +81,11 @@ function recordWorkerDelegationLedger(accept, delegation) {
|
|
|
76
81
|
promptDigest: agentDelegation.promptDigest,
|
|
77
82
|
reportedUsage: agentDelegation.reportedUsage,
|
|
78
83
|
usageSignature: agentDelegation.usageSignature,
|
|
84
|
+
// Store the signed result digest ONLY when the signature actually covered
|
|
85
|
+
// it, so the offline re-verifier (telemetry verify --pubkey / report verify)
|
|
86
|
+
// can reconstruct the 5-field payload. A usage-only signature stores none
|
|
87
|
+
// (its record stays byte-identical to a pre-result-coverage one).
|
|
88
|
+
resultDigest: telemetry?.coversResult ? agentDelegation.resultDigest : undefined,
|
|
79
89
|
attestation: agentDelegation.usageAttestation,
|
|
80
90
|
attestationReason: agentDelegation.usageAttestationReason
|
|
81
91
|
})
|
package/dist/workflow-api.js
CHANGED
|
@@ -3,8 +3,10 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
3
3
|
exports.workflow = workflow;
|
|
4
4
|
exports.phase = phase;
|
|
5
5
|
exports.parallel = parallel;
|
|
6
|
+
exports.loop = loop;
|
|
6
7
|
exports.createWorkflowApi = createWorkflowApi;
|
|
7
8
|
exports.agent = agent;
|
|
9
|
+
exports.subWorkflow = subWorkflow;
|
|
8
10
|
exports.artifact = artifact;
|
|
9
11
|
exports.input = input;
|
|
10
12
|
exports.slugify = slugify;
|
|
@@ -46,19 +48,50 @@ function phase(name, tasks, options = {}) {
|
|
|
46
48
|
function parallel(name, tasks, options = {}) {
|
|
47
49
|
return phase(name, tasks, { mode: "parallel", ...options });
|
|
48
50
|
}
|
|
51
|
+
/** A BOUNDED DYNAMIC LOOP phase: `tasks` are a per-round template. After each round
|
|
52
|
+
* completes, the registered `until` predicate decides whether to run another round
|
|
53
|
+
* (a fresh appended phase with the same tasks, round-suffixed ids) or stop; capped
|
|
54
|
+
* at `maxRounds`. Sugar over phase() that sets `loop`; plain phases are unaffected. */
|
|
55
|
+
function loop(name, tasks, spec, options = {}) {
|
|
56
|
+
if (!spec || typeof spec.maxRounds !== "number" || spec.maxRounds < 1) {
|
|
57
|
+
throw new Error(`loop ${name} requires a positive integer maxRounds`);
|
|
58
|
+
}
|
|
59
|
+
const until = spec.until;
|
|
60
|
+
const valid = until
|
|
61
|
+
&& ((until.kind === "predicate" && Boolean(until.ref))
|
|
62
|
+
|| (until.kind === "budget-target" && typeof until.target === "number" && until.target > 0));
|
|
63
|
+
if (!valid) {
|
|
64
|
+
throw new Error(`loop ${name} requires until: { kind: "predicate", ref } or { kind: "budget-target", target }`);
|
|
65
|
+
}
|
|
66
|
+
return phase(name, tasks, { loop: { maxRounds: Math.floor(spec.maxRounds), until }, ...options });
|
|
67
|
+
}
|
|
49
68
|
function createWorkflowApi() {
|
|
50
69
|
return {
|
|
51
70
|
workflow,
|
|
52
71
|
phase,
|
|
53
72
|
parallel,
|
|
73
|
+
loop,
|
|
54
74
|
agent,
|
|
55
75
|
artifact,
|
|
76
|
+
subWorkflow,
|
|
56
77
|
input
|
|
57
78
|
};
|
|
58
79
|
}
|
|
59
80
|
function agent(id, prompt, options = {}) {
|
|
60
81
|
return task("agent", id, prompt, options);
|
|
61
82
|
}
|
|
83
|
+
/** A task fulfilled by an inline SUB-WORKFLOW: instead of spawning an agent, the
|
|
84
|
+
* drive plans + drives the child `appId` and binds its report back as this task's
|
|
85
|
+
* result. The prompt is recorded for provenance but is not sent to an agent. */
|
|
86
|
+
function subWorkflow(id, appId, options = {}) {
|
|
87
|
+
if (!appId)
|
|
88
|
+
throw new Error(`subWorkflow task ${id} requires an appId`);
|
|
89
|
+
const { inputs, bindResult, prompt, ...rest } = options;
|
|
90
|
+
return task("agent", id, prompt || `Delegate to sub-workflow app: ${appId}`, {
|
|
91
|
+
...rest,
|
|
92
|
+
subWorkflow: { appId, ...(inputs ? { inputs } : {}), ...(bindResult ? { bindResult } : {}) }
|
|
93
|
+
});
|
|
94
|
+
}
|
|
62
95
|
function artifact(id, prompt, options = {}) {
|
|
63
96
|
return task("artifact", id, prompt, options);
|
|
64
97
|
}
|
|
@@ -522,6 +522,26 @@ function validatePhase(phaseDefinition, issues, pathName, seenPhaseIds) {
|
|
|
522
522
|
if (!Array.isArray(phaseValue.tasks) || !phaseValue.tasks.length) {
|
|
523
523
|
issues.push(issue("workflow-phase-tasks", `Workflow phase ${String(phaseValue.id || phaseValue.name || "")} must have tasks`, joinPath(pathName, "tasks")));
|
|
524
524
|
}
|
|
525
|
+
// Bounded dynamic loop spec (fail-closed shape check; the predicate ref need not be
|
|
526
|
+
// registered at validation time — the expander stops fail-closed if it is missing).
|
|
527
|
+
if (phaseValue.loop !== undefined) {
|
|
528
|
+
const loop = phaseValue.loop;
|
|
529
|
+
if (!isRecord(loop)) {
|
|
530
|
+
issues.push(issue("workflow-phase-loop", "Workflow phase loop must be an object", joinPath(pathName, "loop")));
|
|
531
|
+
}
|
|
532
|
+
else {
|
|
533
|
+
if (typeof loop.maxRounds !== "number" || !Number.isInteger(loop.maxRounds) || loop.maxRounds < 1) {
|
|
534
|
+
issues.push(issue("workflow-phase-loop-maxrounds", "loop.maxRounds must be a positive integer", joinPath(pathName, "loop.maxRounds")));
|
|
535
|
+
}
|
|
536
|
+
const until = loop.until;
|
|
537
|
+
const validUntil = isRecord(until)
|
|
538
|
+
&& ((until.kind === "predicate" && isNonEmptyString(until.ref))
|
|
539
|
+
|| (until.kind === "budget-target" && typeof until.target === "number" && until.target > 0));
|
|
540
|
+
if (!validUntil) {
|
|
541
|
+
issues.push(issue("workflow-phase-loop-until", 'loop.until must be { kind: "predicate", ref } or { kind: "budget-target", target }', joinPath(pathName, "loop.until")));
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
}
|
|
525
545
|
}
|
|
526
546
|
function validateTask(taskDefinition, issues, pathName, seenTaskIds, options) {
|
|
527
547
|
if (!isRecord(taskDefinition)) {
|
|
@@ -204,13 +204,15 @@ A drive can show the agent's activity live, without touching the evidence
|
|
|
204
204
|
contract, when the operator opts in with `CW_AGENT_STREAM=1`:
|
|
205
205
|
|
|
206
206
|
- **Default stays buffered.** Without `CW_AGENT_STREAM=1`, the bundled wrapper
|
|
207
|
-
keeps the
|
|
208
|
-
|
|
207
|
+
keeps the buffered path and writes one JSON report to stdout after writing
|
|
208
|
+
`result.md`.
|
|
209
209
|
- **The opt-in wrapper renders; stderr only.** With `CW_AGENT_STREAM=1`, the
|
|
210
|
-
bundled wrapper runs claude in `--output-format stream-json
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
210
|
+
bundled Claude wrapper runs claude in `--output-format stream-json`, and the
|
|
211
|
+
bundled Codex wrapper runs `codex exec --json --output-last-message`.
|
|
212
|
+
Each wrapper renders a short human trace (tool uses, assistant text, per-turn
|
|
213
|
+
summaries where present) to its **stderr** — diagnostics, never data. It builds
|
|
214
|
+
the single `{model, usage, result}` object for stdout after the final answer is
|
|
215
|
+
captured.
|
|
214
216
|
- **Core forwards, never parses.** `runAgentProcess` passes the agent child's
|
|
215
217
|
stderr straight through to the operator's terminal (`stdio` inherit) only when
|
|
216
218
|
`CW_AGENT_STREAM=1`, CW's own stderr is a TTY, and `CW_NO_STREAM` is not set.
|
|
@@ -219,6 +221,18 @@ contract, when the operator opts in with `CW_AGENT_STREAM=1`:
|
|
|
219
221
|
- **Determinism intact.** The backend evidence triple hashes stdout only, so
|
|
220
222
|
the live stderr stream never changes recorded evidence or replay.
|
|
221
223
|
|
|
224
|
+
The built-in templates are:
|
|
225
|
+
|
|
226
|
+
```text
|
|
227
|
+
--agent-command builtin:claude
|
|
228
|
+
--agent-command builtin:codex
|
|
229
|
+
```
|
|
230
|
+
|
|
231
|
+
Gemini, OpenCode, DeepSeek, and GLM stay as external agent commands or HTTP
|
|
232
|
+
endpoints until their stream format is proven by a local, deterministic wrapper
|
|
233
|
+
smoke. DeepSeek and GLM are best reached through OpenCode or an HTTP agent
|
|
234
|
+
adapter first; CW still imports no model SDK.
|
|
235
|
+
|
|
222
236
|
## Compatibility
|
|
223
237
|
|
|
224
238
|
Agent Delegation Drive comes in first in CW v0.1.38. Adding the `agent` row leaves
|
|
@@ -291,3 +305,11 @@ No other change to this page in v0.1.84.
|
|
|
291
305
|
0.1.85
|
|
292
306
|
|
|
293
307
|
0.1.86
|
|
308
|
+
|
|
309
|
+
## 0.1.87 (v0.1.87)
|
|
310
|
+
|
|
311
|
+
npm test parallel, 4-vendor wrappers (Claude/Codex/Gemini/OpenCode), Homebrew-style CLI UX (colors/did-you-mean/categorized help/error tips/cw info/cw search/cw man/doctor --fix), post-success summaries, agent execution timing
|
|
312
|
+
|
|
313
|
+
## 0.1.88 (v0.1.88)
|
|
314
|
+
|
|
315
|
+
Orchestration-parity for the agent drive: `run --drive --incremental` step-level resume (unchanged-input tasks replay from a content-addressed cache, zero re-spawns), inline `subWorkflow()` nesting (a task runs a child app and binds its verified report back, bounded depth + cycle guard, no telemetry fabricated), bounded dynamic `loop()` phases that expand at runtime under a static replay-stable cap, and a `claude -p` wrapper now on the canonical result contract.
|
|
@@ -2,69 +2,92 @@
|
|
|
2
2
|
|
|
3
3
|
## Name
|
|
4
4
|
|
|
5
|
-
`capability-
|
|
5
|
+
`capability-registry`, `registerTopology` — the declared capability contract and the open topology registry for agent-driven CW extension
|
|
6
6
|
|
|
7
7
|
## Description
|
|
8
8
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
9
|
+
CW keeps two layers here. The **capability registry**
|
|
10
|
+
(`src/capability-registry.ts`) is the one declared source of truth for every
|
|
11
|
+
capability CW exposes; it is read at build/check time, not written to at
|
|
12
|
+
runtime. The **topology registry** (`src/topology.ts`) is an open runtime
|
|
13
|
+
registry: new topologies put themselves in it with `registerTopology()` and
|
|
14
|
+
then come up by themselves in `topology list`, `topology validate`, and
|
|
15
|
+
`topology apply`.
|
|
16
|
+
|
|
17
|
+
> History: v0.1.53 also shipped a dynamic *capability* dispatcher
|
|
18
|
+
> (`capability-dispatcher.ts`, `registerCapabilityHandler`,
|
|
19
|
+
> `dispatchCapability`, `resolveCliPath`, `resolveMcpTool`) meant to let
|
|
20
|
+
> capabilities register handlers and be routed at runtime. It had zero call
|
|
21
|
+
> sites — the handler map was always empty and every dispatch path was
|
|
22
|
+
> unreachable — so it was removed as dead code in v0.1.81 (#131). Capabilities
|
|
23
|
+
> are **declared**, not runtime-registered; see below. (The unrelated
|
|
24
|
+
> `cw dispatch` command, which writes a subagent dispatch manifest, is a normal
|
|
25
|
+
> declared capability, not that dispatcher.)
|
|
26
|
+
|
|
27
|
+
BSD way: keep **mechanism** (the registry / the open Map) apart from **policy**
|
|
28
|
+
(the entries). Fail-closed on unknown ids.
|
|
16
29
|
|
|
17
30
|
## Capability Registry
|
|
18
31
|
|
|
19
|
-
|
|
32
|
+
The capability registry, `src/capability-registry.ts`, is the SINGLE declared
|
|
33
|
+
source of truth for every capability CW exposes — and the contract both front
|
|
34
|
+
doors (CLI and MCP) are checked against. It is a static, read-only array of
|
|
35
|
+
descriptors, `CAPABILITY_REGISTRY`. There is no runtime "register a handler"
|
|
36
|
+
seam and no dynamic dispatcher: capabilities are data here, not code that wires
|
|
37
|
+
itself in.
|
|
20
38
|
|
|
21
|
-
|
|
22
|
-
interface CapabilityHandler {
|
|
23
|
-
descriptor: CapabilityDescriptor;
|
|
24
|
-
run(args: Record<string, unknown>, ctx: CapabilityContext): unknown | Promise<unknown>;
|
|
25
|
-
}
|
|
39
|
+
### Descriptor
|
|
26
40
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
41
|
+
```typescript
|
|
42
|
+
interface CapabilityDescriptor {
|
|
43
|
+
capability: string; // canonical dot-namespaced id, e.g. "worker.list"
|
|
44
|
+
summary: string; // one-line description
|
|
45
|
+
entry: string; // the ONE shared core entry both surfaces route through
|
|
46
|
+
surface: "both" | "cli-only" | "mcp-only";
|
|
47
|
+
cli?: { path: string[]; jsonMode: "default" | "flag" | "human" };
|
|
48
|
+
mcp?: { tool: string; requiredArgs?: string[] };
|
|
49
|
+
payloadIdentical?: boolean; // CLI --json === MCP result (default true for "both")
|
|
50
|
+
reason?: string; // required when surface !== "both" or payloadIdentical === false
|
|
30
51
|
}
|
|
31
52
|
```
|
|
32
53
|
|
|
33
|
-
###
|
|
54
|
+
### Adding a capability
|
|
55
|
+
|
|
56
|
+
A capability is declared once, as data, in `CAPABILITY_REGISTRY`, against one
|
|
57
|
+
shared core `entry`:
|
|
34
58
|
|
|
35
59
|
```typescript
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
cli: { path: ["my", "new-tool"], jsonMode: "default" },
|
|
45
|
-
mcp: { tool: "cw_my_new_tool" }
|
|
46
|
-
},
|
|
47
|
-
run: async (args, ctx) => {
|
|
48
|
-
// ctx.runner exposes all orchestrator methods
|
|
49
|
-
return ctx.runner.listWorkflows();
|
|
50
|
-
}
|
|
51
|
-
});
|
|
60
|
+
{
|
|
61
|
+
capability: "my.new.tool",
|
|
62
|
+
summary: "Does something useful.",
|
|
63
|
+
entry: "myNewTool",
|
|
64
|
+
surface: "both",
|
|
65
|
+
cli: { path: ["my", "new-tool"], jsonMode: "default" },
|
|
66
|
+
mcp: { tool: "cw_my_new_tool" }
|
|
67
|
+
}
|
|
52
68
|
```
|
|
53
69
|
|
|
70
|
+
`cli.ts` and `mcp-server.ts` each route their surface to the shared `entry` with
|
|
71
|
+
their own explicit `switch` routing (plus a few `if (action === …)` branches).
|
|
72
|
+
The registry is what those switches are validated against, not something they
|
|
73
|
+
call into — there is no fallback dynamic dispatch.
|
|
74
|
+
|
|
54
75
|
### How it works
|
|
55
76
|
|
|
56
|
-
1. `
|
|
57
|
-
2. CLI: `
|
|
58
|
-
3. MCP: `
|
|
59
|
-
4.
|
|
60
|
-
|
|
61
|
-
|
|
77
|
+
1. `CAPABILITY_REGISTRY` declares every capability once — one row per capability.
|
|
78
|
+
2. CLI: `cli.ts` matches the command in its explicit `switch` and calls the core `entry`.
|
|
79
|
+
3. MCP: `mcp-server.ts` matches the tool name in its explicit `switch` and calls the same `entry`.
|
|
80
|
+
4. The parity gate (`scripts/parity-check.js --check`) fails closed on any drift:
|
|
81
|
+
a CLI command or MCP tool live on one surface but absent on the other or
|
|
82
|
+
undeclared, an undeclared payload divergence, or a surface-specific capability
|
|
83
|
+
with no recorded `reason`.
|
|
62
84
|
|
|
63
|
-
###
|
|
85
|
+
### How many capabilities
|
|
64
86
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
87
|
+
Run `node plugins/cool-workflow/scripts/parity-check.js --check` and read
|
|
88
|
+
`registrySize` for the live count (199 at the time of writing). The full
|
|
89
|
+
CLI <-> MCP matrix lives in `docs/cli-mcp-parity.7.md`, which is generated from
|
|
90
|
+
the same registry.
|
|
68
91
|
|
|
69
92
|
## Topology Registry
|
|
70
93
|
|
|
@@ -160,8 +183,8 @@ expansion. Now it reads `role.count` on each role spec:
|
|
|
160
183
|
|
|
161
184
|
## See Also
|
|
162
185
|
|
|
163
|
-
- `capability-registry.ts` — the one
|
|
164
|
-
- `capability-
|
|
186
|
+
- `capability-registry.ts` — the one declared source for all capabilities
|
|
187
|
+
- `capability-core.ts` — the shared core entries both surfaces route through
|
|
165
188
|
- `topology.ts` — topology definitions and the registry
|
|
166
189
|
- `types/topology.ts` — topology type definitions
|
|
167
190
|
- `docs/cli-mcp-parity.7.md` — CLI <-> MCP parity gate
|
package/docs/cli-mcp-parity.7.md
CHANGED
|
@@ -82,14 +82,20 @@ relationship. `identical` means `cw <cmd> --json` is equal to the `cw_<tool>`
|
|
|
82
82
|
payload; `projected` means a declared divergence with a reason; `cli-only` marks
|
|
83
83
|
a surface-specific capability with a recorded reason. The matrix is
|
|
84
84
|
<!-- gen:parity:count -->
|
|
85
|
-
machine-complete by design:
|
|
85
|
+
machine-complete by design: 199 capabilities, 186 MCP tools.
|
|
86
86
|
<!-- /gen:parity:count -->
|
|
87
87
|
|
|
88
88
|
<!-- gen:parity:table -->
|
|
89
89
|
| Capability | CLI command | MCP tool | Core entry | Surface | Payload |
|
|
90
90
|
| --- | --- | --- | --- | --- | --- |
|
|
91
91
|
| `help` | `cw help` | `—` | `formatHelp` | cli-only | cli-only |
|
|
92
|
+
| `version` | `cw version` | `—` | `CURRENT_COOL_WORKFLOW_VERSION` | cli-only | cli-only |
|
|
93
|
+
| `update` | `cw update` | `—` | `npmUpdate` | cli-only | cli-only |
|
|
94
|
+
| `fix` | `cw fix` | `—` | `runDoctor` | cli-only | cli-only |
|
|
92
95
|
| `list` | `cw list` | `cw_list` | `listWorkflows` | both | identical |
|
|
96
|
+
| `info` | `cw info` | `—` | `showApp` | cli-only | cli-only |
|
|
97
|
+
| `search` | `cw search` | `—` | `listApps` | cli-only | cli-only |
|
|
98
|
+
| `man` | `cw man` | `—` | `n/a` | cli-only | cli-only |
|
|
93
99
|
| `doctor` | `cw doctor` | `—` | `runDoctor` | cli-only | cli-only |
|
|
94
100
|
| `init` | `cw init` | `cw_init` | `init` | both | identical |
|
|
95
101
|
| `plan` | `cw plan` | `cw_plan` | `planSummary` | both | identical |
|
|
@@ -295,9 +301,15 @@ A capability may be on one surface only, but never without word of it — it mus
|
|
|
295
301
|
carry a recorded reason in the registry.
|
|
296
302
|
|
|
297
303
|
<!-- gen:parity:cliOnly -->
|
|
298
|
-
|
|
304
|
+
13 capabilities are CLI-only:
|
|
299
305
|
|
|
300
306
|
- `help` — Human help text. MCP hosts enumerate capabilities via tools/list, not a help command.
|
|
307
|
+
- `version` — Version string — no structured data contract.
|
|
308
|
+
- `update` — Self-update via npm — inherently local shell operation, no MCP surface.
|
|
309
|
+
- `fix` — Environment fix commands are local diagnostics, same reasoning as doctor.
|
|
310
|
+
- `info` — Human-focused workflow discovery tool (like Homebrew's `brew info`). MCP agents discover workflows via cw_list and cw_app_show tools.
|
|
311
|
+
- `search` — Human-focused workflow discovery (like Homebrew's `brew search`). MCP agents discover workflows via cw_list tool.
|
|
312
|
+
- `man` — Human documentation viewer. MCP agents read docs/ directly via file tools.
|
|
301
313
|
- `doctor` — Environment diagnostics are inherently local to the CLI host — Node version, $PATH, $CW_HOME/cwd writability. An MCP client diagnosing the server process's environment is not meaningful; agents already receive the same readiness facts in their typed results (e.g. status: blocked, agentConfigured). Inspired by `brew doctor`.
|
|
302
314
|
- `loop` — Convenience alias of `schedule create` with kind=loop. MCP hosts use cw_schedule_create with kind=loop.
|
|
303
315
|
- `schedule daemon` — Long-running desktop daemon process, not a request/response tool. MCP hosts drive ticks via cw_schedule_due + cw_schedule_run_now.
|
|
@@ -497,3 +509,11 @@ No other change to this page in v0.1.84.
|
|
|
497
509
|
0.1.85
|
|
498
510
|
|
|
499
511
|
0.1.86
|
|
512
|
+
|
|
513
|
+
## 0.1.87 (v0.1.87)
|
|
514
|
+
|
|
515
|
+
npm test parallel, 4-vendor wrappers (Claude/Codex/Gemini/OpenCode), Homebrew-style CLI UX (colors/did-you-mean/categorized help/error tips/cw info/cw search/cw man/doctor --fix), post-success summaries, agent execution timing
|
|
516
|
+
|
|
517
|
+
## 0.1.88 (v0.1.88)
|
|
518
|
+
|
|
519
|
+
CLI surface simplified to 6 commands with agent stderr streaming on by default and vendor agent flags; the drive gains a `--incremental` flag (added to DRIVE_RUNTIME_KEYS so it never poisons run.inputs or the cache key). MCP tools stay the derived mirror of the same capabilities.
|
|
@@ -145,3 +145,11 @@ No other change to this page in v0.1.84.
|
|
|
145
145
|
0.1.85
|
|
146
146
|
|
|
147
147
|
0.1.86
|
|
148
|
+
|
|
149
|
+
## 0.1.87 (v0.1.87)
|
|
150
|
+
|
|
151
|
+
npm test parallel, 4-vendor wrappers (Claude/Codex/Gemini/OpenCode), Homebrew-style CLI UX (colors/did-you-mean/categorized help/error tips/cw info/cw search/cw man/doctor --fix), post-success summaries, agent execution timing
|
|
152
|
+
|
|
153
|
+
## 0.1.88 (v0.1.88)
|
|
154
|
+
|
|
155
|
+
_No behavioral change in v0.1.88 (no schema-migration edge was added; the incremental result cache uses a self-contained schemaVersion:2 key that never collides with the opt-in v1 cache and is not a run-state or app-schema migration)._
|