@deksden-com/dd-flow-cli 0.9.0-beta.53 → 0.9.0-beta.55
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/CHANGELOG.md +12 -0
- package/dist/build-info.json +5 -5
- package/dist/cli/run-cli.js +33 -17
- package/dist/harness-runtime/lib/dd-zcode-daemon.mjs +32 -6
- package/dist/harness-runtime/lib/dd-zcode.mjs +121 -23
- package/dist/schemas/code-work-batch.schema.json +1 -1
- package/dist/schemas/vnext-protocol-plan.schema.json +2 -2
- package/dist/services/cli-operation-classifier.js +5 -1
- package/dist/services/code-checks.js +44 -11
- package/dist/services/lifecycle-invocations.js +95 -10
- package/dist/services/native-session-control.js +4 -4
- package/dist/services/run-control.js +2 -2
- package/dist/services/stage-pause.js +11 -3
- package/dist/services/vnext-code-review.js +3 -5
- package/dist/services/vnext-code.js +8 -9
- package/dist/services/vnext-merge.js +2 -2
- package/dist/services/vnext-plan.js +5 -2
- package/dist/services/vnext-specify.js +3 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
# @deksden-com/dd-flow-cli
|
|
2
2
|
|
|
3
|
+
## 0.9.0-beta.55
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- Require autonomous check evidence, preflight commands in their actual workspace, bind final retry arguments, and settle ZCode cancellation through verified native residency. Retain closed sessions without implicit resume and store growing transcripts in reusable content-addressed chunks.
|
|
8
|
+
|
|
9
|
+
## 0.9.0-beta.54
|
|
10
|
+
|
|
11
|
+
### Patch Changes
|
|
12
|
+
|
|
13
|
+
- Preserve current lifecycle command input, atomically settle retry authority, select current resume commands, and stop ZCode productive admission immediately on fatal identity errors. Reject ambiguous result inputs and keep diagnostic commands read-only.
|
|
14
|
+
|
|
3
15
|
## 0.9.0-beta.53
|
|
4
16
|
|
|
5
17
|
### Patch Changes
|
package/dist/build-info.json
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"cli_package": "@deksden-com/dd-flow-cli",
|
|
3
|
-
"cli_version": "0.9.0-beta.
|
|
4
|
-
"cli_commit": "
|
|
5
|
-
"built_at": "2026-09-
|
|
3
|
+
"cli_version": "0.9.0-beta.55",
|
|
4
|
+
"cli_commit": "84e76cfa798732b3121534551515b02f4a238425",
|
|
5
|
+
"built_at": "2026-09-13T19:08:39.320Z",
|
|
6
6
|
"built_with_canon": {
|
|
7
|
-
"version": "4.1.
|
|
8
|
-
"commit": "
|
|
7
|
+
"version": "4.1.1",
|
|
8
|
+
"commit": "97f811d33c212ae3497020178b1ed825c7c3ebac",
|
|
9
9
|
"flow_contract": "dd-flow-canonical-2026-08",
|
|
10
10
|
"repo_root": "/home/runner/work/dd-flow-cli/dd-flow-cli/dd-memorybank",
|
|
11
11
|
"memorybank_root": "/home/runner/work/dd-flow-cli/dd-flow-cli/dd-memorybank/.memory-bank",
|
package/dist/cli/run-cli.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { quote } from "shell-quote";
|
|
4
|
-
import { assertLifecycleInvocationCurrent, awaitLifecycleInvocation, issueLifecycleInvocation,
|
|
4
|
+
import { assertLifecycleInvocationCurrent, awaitLifecycleInvocation, issueLifecycleInvocation, lifecycleInvocationScope, settleLifecycleInvocation, settleLifecycleRejection } from "../services/lifecycle-invocations.js";
|
|
5
5
|
import { createContext, createRouterContext } from "../runtime/context.js";
|
|
6
6
|
import { migrateStoreWriter } from "../storage/writer-migration.js";
|
|
7
7
|
import { helpForArgs } from "./help.js";
|
|
@@ -72,7 +72,15 @@ const defaultIo = {
|
|
|
72
72
|
};
|
|
73
73
|
const flagValue = "__dd_flow_flag__";
|
|
74
74
|
export async function runCli(args, io = defaultIo, env = process.env) {
|
|
75
|
-
|
|
75
|
+
let output;
|
|
76
|
+
try {
|
|
77
|
+
output = parseOutputOptions(args);
|
|
78
|
+
}
|
|
79
|
+
catch (error) {
|
|
80
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
81
|
+
writeJson(io.stderr, { ok: false, error: { code: "usage", message } });
|
|
82
|
+
return 2;
|
|
83
|
+
}
|
|
76
84
|
const progress = progressForCommand(output.args);
|
|
77
85
|
let heartbeat;
|
|
78
86
|
let invocationSettlement;
|
|
@@ -90,6 +98,7 @@ export async function runCli(args, io = defaultIo, env = process.env) {
|
|
|
90
98
|
io.stdout.write(`${help}\n`);
|
|
91
99
|
return 0;
|
|
92
100
|
}
|
|
101
|
+
assertSingletonOptions(output.args);
|
|
93
102
|
if (io.stdin?.isTTY && ["work", "stage"].includes(output.args[0] ?? "") && output.args[1] === "finish"
|
|
94
103
|
&& hasOption(parseArgs(output.args.slice(2)), "result-stdin")) {
|
|
95
104
|
throw new AppError("usage", "--result-stdin requires non-interactive input. Pipe or redirect the complete JSON into this command, or use --result-file; interactive terminals can truncate long JSON and wait indefinitely for EOF.", 2);
|
|
@@ -113,7 +122,9 @@ export async function runCli(args, io = defaultIo, env = process.env) {
|
|
|
113
122
|
if (routed?.routed && !routed.inProcess) {
|
|
114
123
|
if (heartbeat)
|
|
115
124
|
clearInterval(heartbeat);
|
|
116
|
-
emitHumanProgress(io, output, progress, "done");
|
|
125
|
+
emitHumanProgress(io, output, progress, routed.exitCode === 0 ? "done" : "failed");
|
|
126
|
+
if (output.progressJsonl && routed.exitCode !== 0)
|
|
127
|
+
writeProgressJsonl(io, "failed", "Routed engine failed", { exit_code: routed.exitCode });
|
|
117
128
|
return routed.exitCode;
|
|
118
129
|
}
|
|
119
130
|
const dispatchContext = routed?.inProcess
|
|
@@ -168,21 +179,11 @@ export async function runCli(args, io = defaultIo, env = process.env) {
|
|
|
168
179
|
if (heartbeat)
|
|
169
180
|
clearInterval(heartbeat);
|
|
170
181
|
if (invocationSettlement) {
|
|
171
|
-
// Optional retry preparation must never prevent recording the primary
|
|
172
|
-
// operation rejection. Otherwise a known failure becomes an unknown run.
|
|
173
|
-
if (isAppError(error) && isRetryableLifecycleRejection(error.code)) {
|
|
174
|
-
try {
|
|
175
|
-
error.details.retry_command = retryLifecycleInvocationCommand(invocationSettlement.context, invocationSettlement.id);
|
|
176
|
-
error.details.retry_instruction = "Correct only the evidenced defect, then use retry_command with the required result input. Reissuing the old command only reads its retained outcome.";
|
|
177
|
-
}
|
|
178
|
-
catch (retryError) {
|
|
179
|
-
error.details.retry_preparation_error = String(retryError);
|
|
180
|
-
}
|
|
181
|
-
}
|
|
182
182
|
try {
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
183
|
+
if (isAppError(error))
|
|
184
|
+
settleLifecycleRejection(invocationSettlement.context, invocationSettlement.id, { code: error.code, message: error.message, exitCode: error.exitCode, details: error.details });
|
|
185
|
+
else
|
|
186
|
+
settleLifecycleInvocation(invocationSettlement.context, invocationSettlement.id, { error: { code: "unexpected", message: String(error), exitCode: 1, details: {} } });
|
|
186
187
|
}
|
|
187
188
|
catch (persistenceError) {
|
|
188
189
|
io.stderr.write(`Invocation settlement persistence failed: ${String(persistenceError)}\n`);
|
|
@@ -2442,6 +2443,21 @@ function parseFlowFlagOverrides(values) {
|
|
|
2442
2443
|
function parseArgs(args) {
|
|
2443
2444
|
return parseCommandArgs(args);
|
|
2444
2445
|
}
|
|
2446
|
+
/** Most CLI options name one authority, path or target. Silently taking the
|
|
2447
|
+
* last duplicate can widen a mutation after an operator typo. Keep only the
|
|
2448
|
+
* options consumed as intentional arrays repeatable. */
|
|
2449
|
+
function assertSingletonOptions(args) {
|
|
2450
|
+
const repeatable = new Set(["alias", "flag", "hide", "check-ref", "evidence", "from-check", "from-finding", "from-unresolved", "on", "origin-work"]);
|
|
2451
|
+
const parsed = parseCommandArgs(args);
|
|
2452
|
+
if (args[0] === "stage" && args[1] === "finish"
|
|
2453
|
+
&& ["result-file", "semantic-file", "data", "result-stdin"].filter(name => parsed.options.has(name)).length > 1) {
|
|
2454
|
+
throw new AppError("usage", "Stage finish requires exactly one result input; result-file, semantic-file, data and result-stdin are mutually exclusive", 2);
|
|
2455
|
+
}
|
|
2456
|
+
for (const [name, values] of parsed.options) {
|
|
2457
|
+
if (!repeatable.has(name) && values.length > 1)
|
|
2458
|
+
throw new AppError("usage", `--${name} may be supplied only once`, 2);
|
|
2459
|
+
}
|
|
2460
|
+
}
|
|
2445
2461
|
function requiredPosition(parsed, index, label) {
|
|
2446
2462
|
const value = parsed.positional[index];
|
|
2447
2463
|
if (!value) {
|
|
@@ -245,6 +245,7 @@ export class DaemonRuntime {
|
|
|
245
245
|
root_provider_session_id: existing?.root_provider_session_id ?? (parent ? parentRoot : result.provider_session_id),
|
|
246
246
|
topology: topology ?? existing?.topology ?? null,
|
|
247
247
|
completed_turns: existing?.completed_turns ?? 0,
|
|
248
|
+
close_receipt: result.close?.closed === true ? result : existing?.close_receipt ?? null,
|
|
248
249
|
}); }
|
|
249
250
|
if (result?.adapter_session_id) this.liveAdapters.add(result.adapter_session_id);
|
|
250
251
|
for (const child of topology?.running ?? []) {
|
|
@@ -254,6 +255,8 @@ export class DaemonRuntime {
|
|
|
254
255
|
}
|
|
255
256
|
|
|
256
257
|
treeRunning(result) {
|
|
258
|
+
const closed = this.sessions.get(result?.provider_session_id)?.close_receipt;
|
|
259
|
+
if (closed) return closed.settled !== true;
|
|
257
260
|
const evidence = result?.evidence ?? result;
|
|
258
261
|
const children = evidence?.subagents?.running;
|
|
259
262
|
return evidence?.read?.projection?.status !== "idle" || !Array.isArray(children) || children.length > 0 || Boolean(evidence?.subagents?.observation_error);
|
|
@@ -277,6 +280,7 @@ export class DaemonRuntime {
|
|
|
277
280
|
}
|
|
278
281
|
|
|
279
282
|
async requireSettled(params) {
|
|
283
|
+
if (this.sessions.get(params.sessionId)?.close_receipt) throw new DaemonError("session_closed", "Closed Session requires explicit recovery");
|
|
280
284
|
const known = this.sessions.get(params.sessionId);
|
|
281
285
|
// A freshly created ZCode Session has no prior Turn and is not yet exposed
|
|
282
286
|
// by the provider's subagent index. Its first prompt is therefore safe.
|
|
@@ -307,12 +311,18 @@ export class DaemonRuntime {
|
|
|
307
311
|
}
|
|
308
312
|
if (operation === "session.create") return await this.productive(operation, async () => await createSessionWithBridge(this.bridge, this.options(params), this.initialized));
|
|
309
313
|
if (operation === "session.prompt") {
|
|
314
|
+
if (this.sessions.get(params.sessionId)?.close_receipt) throw new DaemonError("session_closed", "Closed Session requires explicit recovery before another prompt");
|
|
310
315
|
const result = await this.productive(operation, async () => { await this.requireSettled(params); return await promptSessionWithBridge(this.bridge, this.options(params)); });
|
|
311
316
|
const session = this.sessions.get(result.provider_session_id); if (session) session.completed_turns = (session.completed_turns ?? 0) + 1;
|
|
312
317
|
await this.persist(); return result;
|
|
313
318
|
}
|
|
314
319
|
if (operation === "session.fork") return await this.productive(operation, async () => { await this.requireSettled(params); return await forkSessionWithBridge(this.bridge, this.options(params)); });
|
|
315
320
|
if (operation === "session.inspect" || operation === "session.resume") {
|
|
321
|
+
const closed = this.sessions.get(params.sessionId)?.close_receipt;
|
|
322
|
+
if (closed) {
|
|
323
|
+
if (operation === "session.resume") throw new DaemonError("session_closed", "Closed Session requires explicit recovery");
|
|
324
|
+
return { ...closed, read: { projection: { status: "stopped" } }, subagents: closed.after, settled: closed.settled === true };
|
|
325
|
+
}
|
|
316
326
|
const adapter = params.adapterSessionId ?? params.sessionId;
|
|
317
327
|
if (!this.liveAdapters.has(adapter) && this.activeProductive) {
|
|
318
328
|
throw new DaemonError(
|
|
@@ -334,10 +344,12 @@ export class DaemonRuntime {
|
|
|
334
344
|
}
|
|
335
345
|
if (operation === "session.cancel") {
|
|
336
346
|
if (!this.sessions.has(params.sessionId)) throw new DaemonError("session_identity_mismatch", "cancellation Session is not owned by this daemon");
|
|
347
|
+
const closed = this.sessions.get(params.sessionId)?.close_receipt;
|
|
348
|
+
if (closed) return closed;
|
|
337
349
|
this.controlGeneration = (this.controlGeneration ?? 0) + 1;
|
|
338
350
|
const result = await cancelSessionWithBridge(this.bridge, this.options(params));
|
|
339
351
|
this.track(result, result.after);
|
|
340
|
-
await this.persist({ active_tree: this.treeRunning(result) || Boolean(this.activeProductive) });
|
|
352
|
+
await this.persist({ active_tree: result.settled !== true && (this.treeRunning(result) || Boolean(this.activeProductive)) });
|
|
341
353
|
return result;
|
|
342
354
|
}
|
|
343
355
|
if (operation === "session.cancel-child") {
|
|
@@ -354,6 +366,10 @@ export class DaemonRuntime {
|
|
|
354
366
|
if (cancel) this.controlGeneration = (this.controlGeneration ?? 0) + 1;
|
|
355
367
|
const unsettled = [];
|
|
356
368
|
for (const session of this.sessions.values()) {
|
|
369
|
+
if (session.close_receipt) {
|
|
370
|
+
if (!session.close_receipt.settled) unsettled.push(session.provider_session_id);
|
|
371
|
+
continue;
|
|
372
|
+
}
|
|
357
373
|
const options = this.options({ sessionId: session.provider_session_id, adapterSessionId: session.adapter_session_id });
|
|
358
374
|
let observed;
|
|
359
375
|
if (!cancel) observed = await inspectSessionWithBridge(this.bridge, options);
|
|
@@ -363,11 +379,21 @@ export class DaemonRuntime {
|
|
|
363
379
|
let cancelled;
|
|
364
380
|
try { cancelled = await cancelSessionWithBridge(this.bridge, options); }
|
|
365
381
|
catch (error) { unsettled.push(session.provider_session_id); continue; }
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
382
|
+
this.track(cancelled, cancelled.after);
|
|
383
|
+
await this.persist({ active_tree: cancelled.settled !== true });
|
|
384
|
+
if (cancelled.close?.closed === true) {
|
|
385
|
+
if (!cancelled.settled) unsettled.push(session.provider_session_id);
|
|
386
|
+
continue;
|
|
387
|
+
}
|
|
388
|
+
// A cooperative cancel needs a final read. A close receipt is
|
|
389
|
+
// terminal itself: native session/read correctly rejects it as no
|
|
390
|
+
// longer resident, so treating that rejection as unsettled would
|
|
391
|
+
// turn a successful close into an immortal daemon.
|
|
392
|
+
if (!cancelled.settled) {
|
|
393
|
+
try { observed = await inspectSessionWithBridge(this.bridge, options); }
|
|
394
|
+
catch (error) { unsettled.push(session.provider_session_id); continue; }
|
|
395
|
+
if (this.treeRunning(observed)) unsettled.push(session.provider_session_id);
|
|
396
|
+
}
|
|
371
397
|
}
|
|
372
398
|
}
|
|
373
399
|
}
|
|
@@ -2,7 +2,7 @@ import { observedTimeout } from "./observation-clock.mjs";
|
|
|
2
2
|
import { invokeNativeHook } from "./native-hook-command.mjs";
|
|
3
3
|
import { spawn } from "node:child_process";
|
|
4
4
|
import { createHash } from "node:crypto";
|
|
5
|
-
import { appendFile, mkdir, readFile } from "node:fs/promises";
|
|
5
|
+
import { appendFile, mkdir, readFile, writeFile } from "node:fs/promises";
|
|
6
6
|
import path from "node:path";
|
|
7
7
|
import readline from "node:readline";
|
|
8
8
|
import { stopProcessGroup } from "./managed-daemon.mjs";
|
|
@@ -11,6 +11,9 @@ import { checkObservedProfile, observeModel, observedFields } from "./model-obse
|
|
|
11
11
|
const ZCODE_HARNESS_CONTRACT = "dd-zcode-harness@1";
|
|
12
12
|
|
|
13
13
|
function fail(message, code) { const error = new Error(message); if (code) error.code = code; throw error; }
|
|
14
|
+
function fatalLifecycleError(error) {
|
|
15
|
+
return ["invocation_unknown", "invocation_identity_mismatch", "invocation_identity_conflict", "invocation_receipt_missing", "invocation_command_mismatch", "invocation_scope_mismatch", "invocation_generation_stale", "invocation_expired", "zcode_parent_identity_missing", "zcode_tool_identity_conflict"].includes(error?.code);
|
|
16
|
+
}
|
|
14
17
|
function absolute(value, label) { if (!value || !path.isAbsolute(value)) fail(`${label} must be an absolute path`); return path.resolve(value); }
|
|
15
18
|
function text(value, label) { if (typeof value !== "string" || !value.trim()) fail(`${label} is required`); return value.trim(); }
|
|
16
19
|
function now() { return new Date().toISOString(); }
|
|
@@ -50,6 +53,7 @@ class Journal {
|
|
|
50
53
|
this.file = file ? absolute(file, "--journal") : null;
|
|
51
54
|
this.pending = this.file ? mkdir(path.dirname(this.file), { recursive: true }) : Promise.resolve();
|
|
52
55
|
this.order = 0;
|
|
56
|
+
this.artifacts = new Set();
|
|
53
57
|
}
|
|
54
58
|
write(kind, payload) {
|
|
55
59
|
if (!this.file) return;
|
|
@@ -57,9 +61,69 @@ class Journal {
|
|
|
57
61
|
this.pending = this.pending.then(() => appendFile(this.file, `${line}\n`));
|
|
58
62
|
return this.pending;
|
|
59
63
|
}
|
|
64
|
+
writeInbound(payload, method) {
|
|
65
|
+
if (!this.file || method !== "zcode/session/read" || !payload?.result || typeof payload.result !== "object") return this.write("inbound", payload);
|
|
66
|
+
const raw = JSON.stringify(payload.result);
|
|
67
|
+
const sha256 = createHash("sha256").update(raw).digest("hex");
|
|
68
|
+
const artifactDir = `${this.file}.artifacts`, artifact = path.join(artifactDir, `${sha256}.json`);
|
|
69
|
+
const blobs = new Map();
|
|
70
|
+
const messages = Array.isArray(payload.result.messages) ? payload.result.messages.map(message => {
|
|
71
|
+
const bytes = Buffer.from(JSON.stringify(message));
|
|
72
|
+
const chunks = [];
|
|
73
|
+
for (let offset = 0; offset < bytes.length; offset += 65536) {
|
|
74
|
+
const chunk = bytes.subarray(offset, offset + 65536);
|
|
75
|
+
const hash = createHash("sha256").update(chunk).digest("hex");
|
|
76
|
+
const file = path.join(artifactDir, `${hash}.chunk`);
|
|
77
|
+
blobs.set(file, chunk); chunks.push({ path: path.basename(file), sha256: hash, bytes: chunk.length });
|
|
78
|
+
}
|
|
79
|
+
return { chunks };
|
|
80
|
+
}) : null;
|
|
81
|
+
const manifest = JSON.stringify({ encoding: "dd-zcode/session-read@1", result: { ...payload.result, ...(messages ? { messages } : {}) } });
|
|
82
|
+
const result = { ...payload.result };
|
|
83
|
+
if (Array.isArray(result.messages)) result.messages = { omitted_count: result.messages.length };
|
|
84
|
+
const compact = { ...payload, result, raw_result: { sha256, bytes: Buffer.byteLength(raw), path: artifact, encoding: "dd-zcode/session-read@1" } };
|
|
85
|
+
const line = JSON.stringify({ order: ++this.order, observed_at: now(), kind: "inbound", payload: compact });
|
|
86
|
+
this.pending = this.pending.then(async () => {
|
|
87
|
+
await mkdir(artifactDir, { recursive: true });
|
|
88
|
+
for (const [file, bytes] of [...blobs, [artifact, manifest]]) {
|
|
89
|
+
if (this.artifacts.has(file)) continue;
|
|
90
|
+
try { await writeFile(file, bytes, { flag: "wx" }); }
|
|
91
|
+
catch (error) {
|
|
92
|
+
if (error?.code !== "EEXIST") throw error;
|
|
93
|
+
if (!Buffer.from(await readFile(file)).equals(Buffer.from(bytes))) throw new Error(`Journal artifact conflict: ${file}`);
|
|
94
|
+
}
|
|
95
|
+
this.artifacts.add(file);
|
|
96
|
+
}
|
|
97
|
+
await appendFile(this.file, `${line}\n`);
|
|
98
|
+
});
|
|
99
|
+
return this.pending;
|
|
100
|
+
}
|
|
60
101
|
async flush() { await this.pending; }
|
|
61
102
|
}
|
|
62
103
|
|
|
104
|
+
/** Reconstruct a retained read, verifying every chunk and the original result. */
|
|
105
|
+
export async function readSessionJournalArtifact(reference) {
|
|
106
|
+
const stored = JSON.parse(await readFile(reference.path, "utf8"));
|
|
107
|
+
const result = reference.encoding === "dd-zcode/session-read@1" ? stored.result : stored;
|
|
108
|
+
if (reference.encoding === "dd-zcode/session-read@1" && Array.isArray(result.messages)) {
|
|
109
|
+
const messages = [];
|
|
110
|
+
for (const message of result.messages) {
|
|
111
|
+
const parts = [];
|
|
112
|
+
for (const chunk of message.chunks) {
|
|
113
|
+
if (chunk.path !== `${chunk.sha256}.chunk` || !/^[a-f0-9]{64}$/.test(chunk.sha256)) throw new Error("Invalid journal chunk reference");
|
|
114
|
+
const bytes = await readFile(path.join(path.dirname(reference.path), chunk.path));
|
|
115
|
+
if (bytes.length !== chunk.bytes || createHash("sha256").update(bytes).digest("hex") !== chunk.sha256) throw new Error("Journal chunk integrity mismatch");
|
|
116
|
+
parts.push(bytes);
|
|
117
|
+
}
|
|
118
|
+
messages.push(JSON.parse(Buffer.concat(parts).toString("utf8")));
|
|
119
|
+
}
|
|
120
|
+
result.messages = messages;
|
|
121
|
+
}
|
|
122
|
+
const raw = JSON.stringify(result);
|
|
123
|
+
if (Buffer.byteLength(raw) !== reference.bytes || createHash("sha256").update(raw).digest("hex") !== reference.sha256) throw new Error("Journal result integrity mismatch");
|
|
124
|
+
return result;
|
|
125
|
+
}
|
|
126
|
+
|
|
63
127
|
export class AcpBridge {
|
|
64
128
|
constructor(options) {
|
|
65
129
|
this.options = options;
|
|
@@ -115,7 +179,8 @@ export class AcpBridge {
|
|
|
115
179
|
receive(line) {
|
|
116
180
|
let message;
|
|
117
181
|
try { message = JSON.parse(line); } catch { this.journal.write("malformed", { line }); return; }
|
|
118
|
-
this.
|
|
182
|
+
const request = message.id !== undefined ? this.pending.get(message.id) : null;
|
|
183
|
+
this.journal.writeInbound(message, request?.method);
|
|
119
184
|
if (typeof message.params?.sessionId === "string" && isProductiveSessionNotification(message)) this.sessionActivity.set(message.params.sessionId, Date.now());
|
|
120
185
|
const update = message.params?.update;
|
|
121
186
|
// ZCode emits this as `session/update`; Grok Build wraps the same ACP
|
|
@@ -134,16 +199,24 @@ export class AcpBridge {
|
|
|
134
199
|
if (message.method === "session/update" && this.options.onLifecycleNotification) {
|
|
135
200
|
const observer = this.options.onLifecycleNotification;
|
|
136
201
|
const pending = Promise.resolve().then(() => observer(message)).catch(async error => {
|
|
137
|
-
this.notificationError
|
|
138
|
-
|
|
139
|
-
|
|
202
|
+
if (!this.notificationError || (fatalLifecycleError(error) && !fatalLifecycleError(this.notificationError))) this.notificationError = error;
|
|
203
|
+
// A missing/foreign authority is not a recoverable CLI validation
|
|
204
|
+
// result. Stop admitting productive requests immediately; the managed
|
|
205
|
+
// controller still owns physical stop/drain and evidence capture.
|
|
206
|
+
if (fatalLifecycleError(error)) this.rejectAll(error);
|
|
207
|
+
try {
|
|
208
|
+
await this.journal.write("notification_failure", { code: error.code ?? "invocation_observation_failed", message: error.message,
|
|
209
|
+
session_id: message.params?.sessionId ?? null, tool_call_id: update?.toolCallId ?? null, event_time: now(), delivery_stage: "lifecycle_receipt", completeness: "incomplete" });
|
|
210
|
+
} catch (persistenceError) {
|
|
211
|
+
process.stderr.write(`${JSON.stringify({ code: error.code, message: error.message, persistence_error: persistenceError.message })}\n`);
|
|
212
|
+
}
|
|
140
213
|
});
|
|
141
214
|
this.lifecycleNotifications.add(pending);
|
|
142
215
|
void pending.then(() => this.lifecycleNotifications.delete(pending), () => this.lifecycleNotifications.delete(pending));
|
|
143
216
|
}
|
|
144
217
|
if (message.id !== undefined && message.method) { void this.answer(message).catch((error) => this.rejectAll(error)); return; }
|
|
145
218
|
if (message.id !== undefined) {
|
|
146
|
-
const pending = this.pending.get(message.id);
|
|
219
|
+
const pending = request ?? this.pending.get(message.id);
|
|
147
220
|
if (!pending) return;
|
|
148
221
|
clearTimeout(pending.timer); if (pending.idleTimer) clearInterval(pending.idleTimer); this.pending.delete(message.id);
|
|
149
222
|
if (message.error) {
|
|
@@ -224,7 +297,7 @@ export class AcpBridge {
|
|
|
224
297
|
}
|
|
225
298
|
|
|
226
299
|
request(method, params = {}, timeoutMs = 30_000, { idleSessionId = null, idleTimeoutMs = null } = {}) {
|
|
227
|
-
if (this.notificationError?.code === "profile_integrity_violation" && ["session/new", "session/prompt", "session/fork"].includes(method)) return Promise.reject(this.notificationError);
|
|
300
|
+
if ((this.notificationError?.code === "profile_integrity_violation" || fatalLifecycleError(this.notificationError)) && ["session/new", "session/prompt", "session/fork"].includes(method)) return Promise.reject(this.notificationError);
|
|
228
301
|
const id = this.nextId++;
|
|
229
302
|
return new Promise((resolve, reject) => {
|
|
230
303
|
const finish = (error) => {
|
|
@@ -270,7 +343,11 @@ export class AcpBridge {
|
|
|
270
343
|
notify(method, params = {}) { this.send({ jsonrpc: "2.0", method, params }); }
|
|
271
344
|
send(message) { this.journal.write("outbound", message); this.child.stdin.write(`${JSON.stringify(message)}\n`); }
|
|
272
345
|
rejectAll(error) { for (const pending of this.pending.values()) { clearTimeout(pending.timer); if (pending.idleTimer) clearInterval(pending.idleTimer); pending.reject(error); } this.pending.clear(); }
|
|
273
|
-
async flush() {
|
|
346
|
+
async flush() {
|
|
347
|
+
try { await Promise.all([...this.lifecycleNotifications]); await this.notifications; await this.journal.flush(); }
|
|
348
|
+
catch (error) { throw this.notificationError ?? error; }
|
|
349
|
+
if (this.notificationError) throw this.notificationError;
|
|
350
|
+
}
|
|
274
351
|
async close() {
|
|
275
352
|
let flushError = null;
|
|
276
353
|
try { await this.flush(); } catch (error) { flushError = error; }
|
|
@@ -352,7 +429,7 @@ export function latestAssistantText(read) {
|
|
|
352
429
|
return null;
|
|
353
430
|
}
|
|
354
431
|
|
|
355
|
-
async function cancelTree(bridge, sessionId, before) {
|
|
432
|
+
async function cancelTree(bridge, sessionId, before, providerSessionId) {
|
|
356
433
|
const cancellations = [];
|
|
357
434
|
for (const child of before.running ?? []) {
|
|
358
435
|
const taskId = child.taskId ?? child.agentId;
|
|
@@ -361,20 +438,41 @@ async function cancelTree(bridge, sessionId, before) {
|
|
|
361
438
|
catch (error) { cancellations.push({ taskId, cancelled: false, error: { code: error.code ?? "cancel_failed", message: error.message } }); }
|
|
362
439
|
}
|
|
363
440
|
}
|
|
364
|
-
// ZCode's stop is deliberately idempotent.
|
|
365
|
-
//
|
|
366
|
-
//
|
|
441
|
+
// ZCode's stop is deliberately idempotent. A stop acknowledgement is not a
|
|
442
|
+
// terminal receipt: native turns (and a root without children) can remain
|
|
443
|
+
// active after it. Observe a bounded drain, then close this owned Session.
|
|
367
444
|
bridge.notify("session/cancel", { sessionId });
|
|
368
|
-
let after = before;
|
|
369
|
-
|
|
445
|
+
let after = before, read = null, root_status = null;
|
|
446
|
+
const deadline = Date.now() + 6000;
|
|
447
|
+
for (let attempt = 0; attempt < 6 && Date.now() < deadline; attempt += 1) {
|
|
370
448
|
await new Promise((resolve) => setTimeout(resolve, 1000));
|
|
371
|
-
|
|
372
|
-
|
|
449
|
+
const timeout = Math.max(1, deadline - Date.now());
|
|
450
|
+
[after, read] = await Promise.all([
|
|
451
|
+
bridge.request("zcode/session/subagents", { sessionId }, timeout).catch(error => ({ observation_error: { code: error.code, message: error.message } })),
|
|
452
|
+
bridge.request("zcode/session/read", { sessionId }, timeout).catch(error => ({ observation_error: { code: error.code, message: error.message } })),
|
|
453
|
+
]);
|
|
454
|
+
root_status = read?.projection?.status ?? read?.session?.status ?? null;
|
|
455
|
+
if (after.observation_error || read?.observation_error) break;
|
|
456
|
+
if (Array.isArray(after.running) && !after.running.length && !(read?.projection?.activeToolCalls ?? []).length && ["idle", "completed", "cancelled", "failed", "stopped"].includes(root_status)) {
|
|
457
|
+
return { cancellations, before, after, root_status, cancellation_requested: false, settled: true, close_requested: false };
|
|
458
|
+
}
|
|
373
459
|
}
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
460
|
+
let close = null;
|
|
461
|
+
try { close = await bridge.request("zcode/session/close", { sessionId }); }
|
|
462
|
+
catch (error) { close = { closed: false, error: { code: error.code ?? "close_failed", message: error.message } }; }
|
|
463
|
+
const knownChildren = [...new Set([...(before.running ?? []), ...(after.running ?? [])].map(child => child.childSessionId).filter(Boolean))];
|
|
464
|
+
const residents = close?.closed === true ? await Promise.all([providerSessionId, ...knownChildren].map(async id => {
|
|
465
|
+
try {
|
|
466
|
+
const observed = await bridge.request("zcode/session/resident", { sessionId: id }, 5000);
|
|
467
|
+
if (observed.sessionId !== id) throw Object.assign(new Error("Residency evidence belongs to another Session"), { code: "session_identity_mismatch" });
|
|
468
|
+
return observed;
|
|
469
|
+
}
|
|
470
|
+
catch (error) { return { sessionId: id, observation_error: { code: error.code, message: error.message } }; }
|
|
471
|
+
})) : [];
|
|
472
|
+
const completeTopology = !before.observation_error && Array.isArray(before.running) && !after.observation_error && Array.isArray(after.running)
|
|
473
|
+
&& [...(before.running ?? []), ...after.running].every(child => typeof child.childSessionId === "string");
|
|
474
|
+
return { cancellations, before, after, root_status, cancellation_requested: true, close_requested: true, close, residents,
|
|
475
|
+
settled: close?.closed === true && completeTopology && residents.length > 0 && residents.every(item => item.resident === false) };
|
|
378
476
|
}
|
|
379
477
|
|
|
380
478
|
// Cancel one known child without cancelling its parent turn. The parent keeps
|
|
@@ -545,7 +643,7 @@ export function zcodeLifecycleQualification(sha256, bridgeCommit, harnessContrac
|
|
|
545
643
|
// Behavioural evidence: dd-eval production-invocations probe, 2026-09-13.
|
|
546
644
|
// Pin both providers of native event identity, not a string in the bundle.
|
|
547
645
|
const qualified = sha256 === "e9f1868c0fdb863537ed910ee3828b9be96b8c2fd805473f63b439e1113266b8"
|
|
548
|
-
&& bridgeCommit === "
|
|
646
|
+
&& bridgeCommit === "e0600fe3c46e215695f257e65aa6f674ced998d8" && harnessContract === ZCODE_HARNESS_CONTRACT;
|
|
549
647
|
return { status: qualified ? "qualified" : "unqualified", mechanism: "cli-invocation@1", sha256,
|
|
550
648
|
capabilities: qualified ? ["root", "concurrent_children", "child_continuation"] : [],
|
|
551
649
|
native_nested_children: false, reason: qualified ? null : "native_event_contract_unqualified" };
|
|
@@ -625,7 +723,7 @@ export async function promptSessionWithBridge(bridge, options) {
|
|
|
625
723
|
throw error;
|
|
626
724
|
}
|
|
627
725
|
if (!options.allowBackground && (evidence.subagents.running ?? []).length) {
|
|
628
|
-
await cancelTree(bridge, identity.adapterSessionId, evidence.subagents);
|
|
726
|
+
await cancelTree(bridge, identity.adapterSessionId, evidence.subagents, identity.providerSessionId);
|
|
629
727
|
fail("background ZCode subagents cannot outlive the one-shot dd-zcode connection; the live child tree was cancelled");
|
|
630
728
|
}
|
|
631
729
|
return receipt(options, { harness: "zcode-acp", provider_session_id: identity.providerSessionId, adapter_session_id: identity.adapterSessionId, assistant_text: bridge.assistantTextSince(identity.adapterSessionId, textCursor), turn, profile: profile_receipt, evidence: publicEvidence(evidence) });
|
|
@@ -651,7 +749,7 @@ export async function cancelSessionWithBridge(bridge, options) {
|
|
|
651
749
|
requireJournal(options);
|
|
652
750
|
const identity = await controlledIdentity(bridge, options);
|
|
653
751
|
const before = await bridge.request("zcode/session/subagents", { sessionId: identity.adapterSessionId }).catch(error => ({ observation_error: { code: error.code, message: error.message } }));
|
|
654
|
-
return receipt(options, { harness: "zcode-acp", provider_session_id: identity.providerSessionId, adapter_session_id: identity.adapterSessionId, ...await cancelTree(bridge, identity.adapterSessionId, before) });
|
|
752
|
+
return receipt(options, { harness: "zcode-acp", provider_session_id: identity.providerSessionId, adapter_session_id: identity.adapterSessionId, ...await cancelTree(bridge, identity.adapterSessionId, before, identity.providerSessionId) });
|
|
655
753
|
}
|
|
656
754
|
|
|
657
755
|
export async function forkSession(options) {
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
"spine": {"type": "object", "additionalProperties": false, "required": ["user_outcome", "component_responsibility", "must_preserve", "non_goals", "acceptance_contribution"], "properties": {"user_outcome": {"type": "string", "minLength": 1}, "component_responsibility": {"type": "string", "minLength": 1}, "must_preserve": {"$ref": "#/$defs/strings"}, "non_goals": {"$ref": "#/$defs/stringList"}, "acceptance_contribution": {"type": "string", "minLength": 1}}},
|
|
17
17
|
"obligation": {"type": "object", "additionalProperties": false, "required": ["id", "statement"], "properties": {"id": {"type": "string", "minLength": 1}, "statement": {"type": "string", "minLength": 1}}},
|
|
18
18
|
"acceptance": {"type": "object", "additionalProperties": false, "required": ["criterion_id", "path", "environment", "fixtures", "cleanup", "check_refs", "expected_evidence", "proof_limits", "gate"], "properties": {"criterion_id": {"type": "string", "pattern": "^AC-[0-9]+$"}, "path": {"type": "string", "minLength": 1}, "environment": {"type": "string", "minLength": 1}, "fixtures": {"$ref": "#/$defs/stringList"}, "cleanup": {"type": "string", "minLength": 1}, "check_refs": {"type": "array", "uniqueItems": true, "items": {"type": "string", "minLength": 1}}, "expected_evidence": {"$ref": "#/$defs/strings"}, "proof_limits": {"$ref": "#/$defs/strings"}, "gate": {"type": "string", "minLength": 1}}},
|
|
19
|
-
"check": {"type": "object", "additionalProperties": false, "required": ["id", "command", "purpose", "run_at", "availability"], "properties": {"id": {"type": "string", "pattern": "^CHK-[A-Za-z0-9-]+$"}, "command": {"type": "string", "minLength": 1}, "purpose": {"type": "string", "minLength": 1}, "run_at": {"enum": ["work", "code", "readiness", "merge", "release"
|
|
19
|
+
"check": {"type": "object", "additionalProperties": false, "required": ["id", "command", "purpose", "run_at", "availability"], "properties": {"id": {"type": "string", "pattern": "^CHK-[A-Za-z0-9-]+$"}, "command": {"type": "string", "minLength": 1}, "purpose": {"type": "string", "minLength": 1}, "run_at": {"enum": ["work", "code", "readiness", "merge", "release"]}, "availability": {"enum": ["available", "planned"]}, "provided_by": {"type": "string", "minLength": 1}, "definition": {"type": "string", "minLength": 1}, "required_artifacts": {"type": "array", "uniqueItems": true, "items": {"type": "string", "minLength": 1}}, "ports": {"type": "array", "uniqueItems": true, "items": {"type": "string", "pattern": "^[A-Za-z][A-Za-z0-9_]*$"}}, "inputs": {"type": "array", "uniqueItems": true, "items": {"type": "string", "minLength": 1}}, "reuse": {"const": "deterministic"}}, "allOf": [{"if": {"properties": {"availability": {"const": "planned"}}, "required": ["availability"]}, "then": {"required": ["provided_by", "definition"], "properties": {"command": {"pattern": "^@check/"}}}}]},
|
|
20
20
|
"documentUpdate": {"type": "object", "additionalProperties": false, "required": ["path", "action", "owner", "reason", "baseline_sha256"], "properties": {"path": {"type": "string", "minLength": 1}, "action": {"enum": ["create", "update"]}, "owner": {"type": "string", "minLength": 1}, "reason": {"type": "string", "minLength": 1}, "baseline_sha256": {"type": ["string", "null"], "pattern": "^[a-f0-9]{64}$"}}},
|
|
21
21
|
"reviewFinding": {"type": "object", "additionalProperties": false, "required": ["finding_ref", "priority", "problem", "impact", "required_outcome", "evidence_refs", "obligation_refs", "decision_reason", "check_refs"], "properties": {"finding_ref": {"type": "string", "minLength": 1}, "priority": {"enum": ["p0", "p1", "p2", "p3"]}, "problem": {"type": "string", "minLength": 1}, "impact": {"type": "string", "minLength": 1}, "required_outcome": {"type": "string", "minLength": 1}, "evidence_refs": {"$ref": "#/$defs/strings"}, "obligation_refs": {"$ref": "#/$defs/strings"}, "decision_reason": {"type": "string", "minLength": 1}, "check_refs": {"$ref": "#/$defs/strings"}}},
|
|
22
22
|
"repair": {"type": "object", "additionalProperties": false, "required": ["origin_work_ids", "verification_check_refs"], "anyOf": [{"required": ["check_receipt_id", "failure_receipt_path"]}, {"required": ["review_findings"]}, {"required": ["semantic_unresolved", "verification_path"]}], "properties": {"origin_work_ids": {"$ref": "#/$defs/strings"}, "check_receipt_id": {"type": "string", "minLength": 1}, "failure_receipt_path": {"type": "string", "minLength": 1}, "review_findings": {"type": "array", "minItems": 1, "items": {"$ref": "#/$defs/reviewFinding"}}, "semantic_unresolved": {"$ref": "#/$defs/strings"}, "verification_path": {"type": "string", "minLength": 1}, "verification_check_refs": {"type": "array", "minItems": 1, "items": {"$ref": "#/$defs/check"}}}},
|
|
@@ -29,9 +29,9 @@
|
|
|
29
29
|
"documentUpdate": {"type": "object", "additionalProperties": false, "required": ["path", "action", "owner", "reason"], "properties": {"path": {"type": "string", "minLength": 1}, "action": {"enum": ["create", "update"]}, "owner": {"type": "string", "minLength": 1}, "reason": {"type": "string", "minLength": 1}}},
|
|
30
30
|
"spine": {"type": "object", "additionalProperties": false, "required": ["user_outcome", "component_responsibility", "must_preserve", "non_goals", "acceptance_contribution"], "properties": {"user_outcome": {"type": "string", "minLength": 1}, "component_responsibility": {"type": "string", "minLength": 1}, "must_preserve": {"$ref": "#/$defs/strings"}, "non_goals": {"type": "array", "items": {"type": "string", "minLength": 1}}, "acceptance_contribution": {"type": "string", "minLength": 1}}},
|
|
31
31
|
"execution": {"type": "object", "additionalProperties": false, "required": ["required_read", "discovery_boundary", "planned_write_areas", "stop_conditions"], "properties": {"required_read": {"$ref": "#/$defs/strings", "description": "Mandatory starting sources. This is not a read allowlist."}, "discovery_boundary": {"$ref": "#/$defs/strings", "description": "Likely discovery areas. A worker may inspect other project-local sources when necessary."}, "planned_write_areas": {"type": "array", "items": {"type": "string", "minLength": 1}, "description": "Optional file or directory hints used only to coordinate concurrent Works. They never grant or deny write permission."}, "stop_conditions": {"$ref": "#/$defs/strings", "description": "Semantic contradictions or external blockers that require the worker to stop."}}},
|
|
32
|
-
"check": {"type": "object", "additionalProperties": false, "required": ["id", "command", "purpose", "run_at", "availability"], "properties": {"id": {"type": "string", "pattern": "^CHK-[A-Za-z0-9-]+$"}, "command": {"type": "string", "minLength": 1}, "purpose": {"type": "string", "minLength": 1}, "run_at": {"enum": ["work", "code", "readiness", "merge", "release"
|
|
32
|
+
"check": {"type": "object", "additionalProperties": false, "required": ["id", "command", "purpose", "run_at", "availability"], "properties": {"id": {"type": "string", "pattern": "^CHK-[A-Za-z0-9-]+$"}, "command": {"type": "string", "minLength": 1}, "purpose": {"type": "string", "minLength": 1}, "run_at": {"enum": ["work", "code", "readiness", "merge", "release"]}, "availability": {"enum": ["available", "planned"]}, "provided_by": {"type": "string", "pattern": "^P[0-9]+$"}, "definition": {"type": "string", "minLength": 1}, "required_artifacts": {"type": "array", "uniqueItems": true, "items": {"type": "string", "minLength": 1}}, "ports": {"type": "array", "uniqueItems": true, "items": {"type": "string", "pattern": "^[A-Za-z][A-Za-z0-9_]*$"}}, "inputs": {"type": "array", "uniqueItems": true, "items": {"type": "string", "minLength": 1}}, "reuse": {"const": "deterministic"}}, "allOf": [{"if": {"properties": {"availability": {"const": "planned"}}, "required": ["availability"]}, "then": {"required": ["provided_by", "definition"], "properties": {"command": {"pattern": "^@check/"}}}}, {"if": {"properties": {"command": {"pattern": "^@check/"}}}, "then": {"required": ["definition"]}}]},
|
|
33
33
|
"verification": {"type": "object", "additionalProperties": false, "required": ["check_refs"], "properties": {"check_refs": {"type": "array", "minItems": 1, "uniqueItems": true, "items": {"type": "string", "pattern": "^CHK-[A-Za-z0-9-]+$"}}}},
|
|
34
34
|
"item": {"type": "object", "additionalProperties": false, "required": ["id", "title", "summary", "details", "depends_on", "requirement_refs", "semantic_spine", "execution_context", "verification"], "properties": {"id": {"type": "string", "pattern": "^P[0-9]+$"}, "title": {"type": "string", "minLength": 1}, "summary": {"type": "string", "minLength": 1}, "details": {"type": "string", "minLength": 1}, "depends_on": {"type": "array", "uniqueItems": true, "items": {"type": "string", "pattern": "^P[0-9]+$"}}, "requirement_refs": {"$ref": "#/$defs/strings"}, "semantic_spine": {"$ref": "#/$defs/spine"}, "execution_context": {"$ref": "#/$defs/execution"}, "verification": {"$ref": "#/$defs/verification"}}},
|
|
35
|
-
"acceptance": {"type": "object", "additionalProperties": false, "required": ["criterion_id", "plan_item_ids", "changed_surfaces", "path", "environment", "fixtures", "cleanup", "check_refs", "expected_evidence", "proof_limits", "gate"], "properties": {"criterion_id": {"type": "string", "pattern": "^AC-[0-9]+$"}, "plan_item_ids": {"type": "array", "minItems": 1, "items": {"type": "string", "pattern": "^P[0-9]+$"}}, "changed_surfaces": {"$ref": "#/$defs/strings"}, "path": {"type": "string", "minLength": 1}, "environment": {"type": "string", "minLength": 1}, "fixtures": {"type": "array", "items": {"type": "string", "minLength": 1}}, "cleanup": {"type": "string", "minLength": 1}, "check_refs": {"type": "array", "minItems": 1, "uniqueItems": true, "items": {"type": "string", "pattern": "^CHK-[A-Za-z0-9-]+$"}}, "expected_evidence": {"$ref": "#/$defs/strings"}, "proof_limits": {"$ref": "#/$defs/strings"}, "gate": {"enum": ["work", "code", "readiness", "merge", "release"
|
|
35
|
+
"acceptance": {"type": "object", "additionalProperties": false, "required": ["criterion_id", "plan_item_ids", "changed_surfaces", "path", "environment", "fixtures", "cleanup", "check_refs", "expected_evidence", "proof_limits", "gate"], "properties": {"criterion_id": {"type": "string", "pattern": "^AC-[0-9]+$"}, "plan_item_ids": {"type": "array", "minItems": 1, "items": {"type": "string", "pattern": "^P[0-9]+$"}}, "changed_surfaces": {"$ref": "#/$defs/strings"}, "path": {"type": "string", "minLength": 1}, "environment": {"type": "string", "minLength": 1}, "fixtures": {"type": "array", "items": {"type": "string", "minLength": 1}}, "cleanup": {"type": "string", "minLength": 1}, "check_refs": {"type": "array", "minItems": 1, "uniqueItems": true, "items": {"type": "string", "pattern": "^CHK-[A-Za-z0-9-]+$"}}, "expected_evidence": {"$ref": "#/$defs/strings"}, "proof_limits": {"$ref": "#/$defs/strings"}, "gate": {"enum": ["work", "code", "readiness", "merge", "release"]}}}
|
|
36
36
|
}
|
|
37
37
|
}
|
|
@@ -131,7 +131,11 @@ function isReadOnlyDiagnostic(family, command, action) {
|
|
|
131
131
|
if (family === "plan")
|
|
132
132
|
return command === "status";
|
|
133
133
|
if (family === "run")
|
|
134
|
-
return command === "status" || command === "list" || (["drive", "control"].includes(command ?? "") && action === "status");
|
|
134
|
+
return command === "status" || command === "list" || (command === "recovery" && action === "inspect") || (["drive", "control"].includes(command ?? "") && action === "status");
|
|
135
|
+
if (family === "work")
|
|
136
|
+
return ["status", "ls", "show"].includes(command ?? "");
|
|
137
|
+
if (family === "stage")
|
|
138
|
+
return command === "fanout" && action === "status";
|
|
135
139
|
if (family === "lane") {
|
|
136
140
|
return (command === "status" ||
|
|
137
141
|
command === "waiters" ||
|
|
@@ -2,6 +2,7 @@ import crypto from "node:crypto";
|
|
|
2
2
|
import { spawn, spawnSync } from "node:child_process";
|
|
3
3
|
import fs from "node:fs";
|
|
4
4
|
import path from "node:path";
|
|
5
|
+
import { parse } from "shell-quote";
|
|
5
6
|
import { assertRunMutationAllowed } from "./run-recovery.js";
|
|
6
7
|
import { AppError } from "../shared/errors.js";
|
|
7
8
|
import { confirmManagedProcess, finishManagedProcess, heartbeatManagedProcess, managedProcessStatus, managedProcessRuntimeHome, managedOwnerIsAlive, processTreeIsAlive, registerManagedProcess, reservePorts } from "./managed-processes.js";
|
|
@@ -37,15 +38,17 @@ export function readCodeCheckProfile(workspaceRoot) {
|
|
|
37
38
|
}
|
|
38
39
|
export function validateCodeCheckCommands(workspaceRoot, commands) {
|
|
39
40
|
const { profile, file } = readCodeCheckProfile(workspaceRoot);
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
41
|
+
return commands.map((command) => {
|
|
42
|
+
if (command.startsWith("@check/")) {
|
|
43
|
+
if (!profile?.aliases[command]?.trim())
|
|
44
|
+
throw new AppError("unknown_code_check_alias", "CODE check alias is not defined by the project profile", 2, { alias: command, file });
|
|
45
|
+
return command;
|
|
46
|
+
}
|
|
47
|
+
for (const [prefix, alias] of Object.entries(profile?.require_alias_for ?? {}))
|
|
48
|
+
if (command === prefix || command.startsWith(`${prefix} `))
|
|
49
|
+
throw new AppError("raw_code_check_forbidden", "CODE check must use the project alias instead of a raw guarded command", 2, { command, required_alias: alias, file });
|
|
45
50
|
return command;
|
|
46
|
-
}
|
|
47
|
-
if (command === prefix || command.startsWith(`${prefix} `))
|
|
48
|
-
throw new AppError("raw_code_check_forbidden", "CODE check must use the project alias instead of a raw guarded command", 2, { command, required_alias: alias, file }); return command; });
|
|
51
|
+
});
|
|
49
52
|
}
|
|
50
53
|
export function resolveCodeCheckCommands(workspaceRoot, runId, commands) {
|
|
51
54
|
const validated = validateCodeCheckCommands(workspaceRoot, commands);
|
|
@@ -66,12 +69,14 @@ export function validateCheckPlacement(workspaceRoot, checks) { const { profile
|
|
|
66
69
|
if (check.run_at === "work" && aggregateCommands.has(check.command))
|
|
67
70
|
throw new AppError("aggregate_check_requires_code_gate", "A project aggregate check must run at CODE or readiness, not inside one scoped Work", 2, { check_id: check.id, command: check.command, run_at: check.run_at }); }
|
|
68
71
|
export function validateCheckDeclaration(workspaceRoot, check) {
|
|
69
|
-
if (
|
|
70
|
-
|
|
72
|
+
if (!["work", "code", "readiness", "merge", "release"].includes(check.run_at))
|
|
73
|
+
throw new AppError("unsupported_check_gate", "Check requires an autonomous executable gate; replan the incompatible declaration", 2, { check_id: check.id, run_at: check.run_at });
|
|
71
74
|
const { profile } = readCodeCheckProfile(workspaceRoot);
|
|
72
75
|
if (check.availability === "planned" && check.command.startsWith("@check/") && !profile?.aliases[check.command])
|
|
73
76
|
throw new AppError("planned_check_not_materialized", "A planned check alias was not materialized by its provider Work", 2, { check_id: check.id, command: check.command, provider: check.provided_by ?? null });
|
|
74
77
|
validateCodeCheckCommands(workspaceRoot, [check.command]);
|
|
78
|
+
if (check.availability === "available" && !check.command.startsWith("@check/"))
|
|
79
|
+
assertDirectCheckExecutable(workspaceRoot, check.command, check.id);
|
|
75
80
|
validateCheckInputs(workspaceRoot, check.inputs ?? []);
|
|
76
81
|
if (check.command.startsWith("@check/") && check.definition && profile?.aliases[check.command] !== check.definition)
|
|
77
82
|
throw new AppError("check_definition_drift", "The accepted check alias definition differs from the current project profile", 2, { check_id: check.id, alias: check.command, expected: check.definition, actual: profile?.aliases[check.command] ?? null });
|
|
@@ -84,6 +89,34 @@ export function validateCheckDeclaration(workspaceRoot, check) {
|
|
|
84
89
|
if (!profile?.aliases[check.command])
|
|
85
90
|
throw new AppError("planned_check_not_materialized", "A planned check alias was not materialized by its provider Work", 2, { check_id: check.id, command: check.command, provider: check.provided_by ?? null });
|
|
86
91
|
}
|
|
92
|
+
/** Reject a plan-time typo or a fictitious "manual" command before it becomes
|
|
93
|
+
* a late shell exit. Compound shell programs remain valid: only a simple,
|
|
94
|
+
* directly named executable is preflighted here. */
|
|
95
|
+
function assertDirectCheckExecutable(workspaceRoot, command, checkId) {
|
|
96
|
+
const trimmed = command.trim();
|
|
97
|
+
// A shell operator or expansion makes the launch path dynamic. Do not
|
|
98
|
+
// guess there; this preflight is deliberately limited to a direct program
|
|
99
|
+
// with ordinary arguments and optional literal environment assignments.
|
|
100
|
+
if (/[|&;<>()`$\n\r~]/.test(trimmed))
|
|
101
|
+
return;
|
|
102
|
+
const parsed = parse(trimmed);
|
|
103
|
+
if (parsed.some(word => typeof word !== "string"))
|
|
104
|
+
return;
|
|
105
|
+
const words = parsed;
|
|
106
|
+
let index = 0;
|
|
107
|
+
const env = codeExecutionEnvironment(workspaceRoot);
|
|
108
|
+
while (/^[A-Za-z_][A-Za-z0-9_]*=/.test(words[index] ?? "")) {
|
|
109
|
+
const assignment = words[index++];
|
|
110
|
+
const equals = assignment.indexOf("=");
|
|
111
|
+
env[assignment.slice(0, equals)] = assignment.slice(equals + 1);
|
|
112
|
+
}
|
|
113
|
+
const program = words[index];
|
|
114
|
+
if (!program)
|
|
115
|
+
return;
|
|
116
|
+
const probe = spawnSync("/bin/sh", ["-c", "command -v -- \"$1\" >/dev/null", "dd-flow-check", program], { cwd: workspaceRoot, env, encoding: "utf8", timeout: 5000 });
|
|
117
|
+
if (probe.status !== 0)
|
|
118
|
+
throw new AppError("check_command_unavailable", "Available CODE check names an executable that is unavailable in this environment", 2, { check_id: checkId, command, executable: program });
|
|
119
|
+
}
|
|
87
120
|
export async function runCodeChecks(context, input) {
|
|
88
121
|
reconcileUnfinishedChecks(context, input);
|
|
89
122
|
reconcileWorkspaceOwnership(context, input.workspaceRoot);
|
|
@@ -503,7 +536,7 @@ catch {
|
|
|
503
536
|
return fallback;
|
|
504
537
|
} }
|
|
505
538
|
function isStringRecord(value) { return Boolean(value) && typeof value === "object" && !Array.isArray(value) && Object.values(value).every((item) => typeof item === "string" && item.trim().length > 0); }
|
|
506
|
-
function isGateRecord(value) { return Boolean(value) && typeof value === "object" && !Array.isArray(value) && Object.entries(value).every(([gate, aliases]) => ["work", "code", "readiness", "merge", "release"
|
|
539
|
+
function isGateRecord(value) { return Boolean(value) && typeof value === "object" && !Array.isArray(value) && Object.entries(value).every(([gate, aliases]) => ["work", "code", "readiness", "merge", "release"].includes(gate) && Array.isArray(aliases) && aliases.every((item) => typeof item === "string" && item.startsWith("@check/"))); }
|
|
507
540
|
function isPortAliasRecord(value) { return value === undefined || (Boolean(value) && typeof value === "object" && !Array.isArray(value) && Object.entries(value).every(([alias, ports]) => alias.startsWith("@check/") && Array.isArray(ports) && ports.length > 0 && new Set(ports).size === ports.length && ports.every((port) => typeof port === "string" && /^[A-Za-z][A-Za-z0-9_]*$/.test(port)))); }
|
|
508
541
|
function isInputAliasRecord(workspaceRoot, value) { try {
|
|
509
542
|
return value === undefined || (Boolean(value) && typeof value === "object" && !Array.isArray(value) && Object.entries(value).every(([alias, inputs]) => alias.startsWith("@check/") && Array.isArray(inputs) && (validateCheckInputs(workspaceRoot, inputs), true)));
|
|
@@ -9,12 +9,13 @@ import { assertRunMutationAllowed, assertStageSettlementAllowed, assertWorkSettl
|
|
|
9
9
|
import { requireManagedDaemonBinding } from "./managed-daemon-binding.js";
|
|
10
10
|
// Conclusive result/check rejections, never transport, ownership, registry or
|
|
11
11
|
// unknown-effect failures. These validators reject before accepting the result.
|
|
12
|
-
export function isRetryableLifecycleRejection(code) {
|
|
12
|
+
export function isRetryableLifecycleRejection(code, details = {}) {
|
|
13
13
|
return ["validation", "schema_validation", "work_checks_failed", "code_review_gate_failed",
|
|
14
14
|
"review_repair_incomplete", "plan_review_incomplete", "semantic_result_invalid",
|
|
15
15
|
"review_evidence_invalid", "evidence_obligation_unknown", "invalid_evidence_ref",
|
|
16
16
|
"evidence_ref_missing", "evidence_line_out_of_range", "document_update_missing",
|
|
17
|
-
"document_update_not_materialized", "review_repair_no_change"].includes(code)
|
|
17
|
+
"document_update_not_materialized", "review_repair_no_change"].includes(code)
|
|
18
|
+
|| (code === "invalid_work_state" && details.retryable_no_effect === true);
|
|
18
19
|
}
|
|
19
20
|
function storage(context) {
|
|
20
21
|
context.db.execSchema(`CREATE TABLE IF NOT EXISTS lifecycle_invocations (
|
|
@@ -51,6 +52,30 @@ function load(context, id) {
|
|
|
51
52
|
export function lifecycleInvocationScope(context, id) {
|
|
52
53
|
return JSON.parse(load(context, id).scope_json);
|
|
53
54
|
}
|
|
55
|
+
/** Current, explicitly-issued replacements for commands from the resumed stage.
|
|
56
|
+
* Original prompts remain immutable evidence; callers may present this small
|
|
57
|
+
* overlay after a state transition made an earlier no-effect rejection retryable. */
|
|
58
|
+
export function lifecycleRetryCommands(context, stage) {
|
|
59
|
+
const scope = context.env.DD_FLOW_INVOCATION_SCOPE;
|
|
60
|
+
if (!scope || !context.db.get("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'lifecycle_invocations'"))
|
|
61
|
+
return [];
|
|
62
|
+
return context.db.all("SELECT * FROM lifecycle_invocations WHERE scope_json = ? AND status = 'settled' ORDER BY rowid", [scope])
|
|
63
|
+
.flatMap((row) => {
|
|
64
|
+
try {
|
|
65
|
+
const outcome = row.outcome_json ? JSON.parse(row.outcome_json) : null;
|
|
66
|
+
const retry = outcome?.error?.details?.retry_command;
|
|
67
|
+
const parsed = invocation(row.command);
|
|
68
|
+
if (typeof retry !== "string" || parsed.operation !== "stage_finish" || commandOption(parsed, "stage") !== stage)
|
|
69
|
+
return [];
|
|
70
|
+
const successor = load(context, commandOption(invocation(retry), "invocation-id") ?? "");
|
|
71
|
+
const latest = context.db.get("SELECT * FROM lifecycle_invocations WHERE scope_json = ? AND fingerprint = ? ORDER BY rowid DESC LIMIT 1", [row.scope_json, row.fingerprint]);
|
|
72
|
+
return successor.id === latest?.id && successor.status === "issued" ? [retry] : [];
|
|
73
|
+
}
|
|
74
|
+
catch {
|
|
75
|
+
return [];
|
|
76
|
+
}
|
|
77
|
+
});
|
|
78
|
+
}
|
|
54
79
|
export function managedInvocationContext(context, input) {
|
|
55
80
|
const env = { ...context.env, DD_FLOW_CURRENT_INVOCATION: undefined, DD_FLOW_INVOCATION_SCOPE: undefined };
|
|
56
81
|
if (input.harness !== "zcode-acp")
|
|
@@ -74,22 +99,33 @@ export function managedLifecycleCommand(context, command) {
|
|
|
74
99
|
return command;
|
|
75
100
|
const scope = JSON.parse(configured);
|
|
76
101
|
const parsed = invocation(command);
|
|
77
|
-
|
|
102
|
+
const suppliedId = commandOption(parsed, "invocation-id");
|
|
103
|
+
// An invocation id is authority, never decorative text. In particular,
|
|
104
|
+
// callers that add arguments after rendering must not be able to reuse an
|
|
105
|
+
// issued id for a different command.
|
|
106
|
+
if (suppliedId) {
|
|
107
|
+
const retained = load(context, suppliedId);
|
|
108
|
+
if (retained.scope_json !== JSON.stringify(scope)) {
|
|
109
|
+
throw new AppError("invocation_scope_mismatch", "Lifecycle attempt belongs to another managed scope", 1, { invocation_id: suppliedId });
|
|
110
|
+
}
|
|
111
|
+
checkCommand(retained, command);
|
|
78
112
|
return command;
|
|
113
|
+
}
|
|
79
114
|
if (!context.db.writable || context.env.DD_FLOW_INVOCATION_READONLY === "1") {
|
|
80
115
|
const retained = context.db.get("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'lifecycle_invocations'")
|
|
81
116
|
? context.db.get("SELECT * FROM lifecycle_invocations WHERE scope_json = ? AND fingerprint = ? ORDER BY rowid DESC LIMIT 1", [JSON.stringify(scope), fingerprint(parsed)]) : undefined;
|
|
82
117
|
if (!retained)
|
|
83
118
|
throw new AppError("invocation_command_unprepared", "Read-only inspection has no retained lifecycle command", 1);
|
|
84
|
-
return retained
|
|
119
|
+
return renderInvocationCommand(retained, command);
|
|
85
120
|
}
|
|
86
121
|
storage(context);
|
|
87
122
|
context.db.exec("SAVEPOINT invocation_command");
|
|
88
123
|
try {
|
|
89
124
|
const prior = context.db.get("SELECT * FROM lifecycle_invocations WHERE scope_json = ? AND fingerprint = ? ORDER BY rowid DESC LIMIT 1", [JSON.stringify(scope), fingerprint(parsed)]);
|
|
90
125
|
if (prior && prior.id !== context.env.DD_FLOW_CURRENT_INVOCATION) {
|
|
126
|
+
const rendered = renderInvocationCommand(prior, command);
|
|
91
127
|
context.db.exec("RELEASE invocation_command");
|
|
92
|
-
return
|
|
128
|
+
return rendered;
|
|
93
129
|
}
|
|
94
130
|
if (context.env.DD_FLOW_INVOCATION_READONLY === "1")
|
|
95
131
|
throw new AppError("invocation_command_unprepared", "No prepared lifecycle command is available; read-only inspection cannot issue one", 1);
|
|
@@ -107,10 +143,45 @@ export function retryLifecycleInvocationCommand(context, id) {
|
|
|
107
143
|
const prior = load(context, id);
|
|
108
144
|
if (prior.status !== "executing")
|
|
109
145
|
throw new AppError("invocation_retry_not_authorized", "Only the executing lifecycle operation can publish its next attempt", 1);
|
|
110
|
-
const
|
|
111
|
-
|
|
146
|
+
const parsed = invocation(prior.command);
|
|
147
|
+
const argv = [...parsed.argv];
|
|
148
|
+
const at = argv.indexOf("--invocation-id");
|
|
149
|
+
if (at < 0 || argv[at + 1] !== id)
|
|
112
150
|
throw new AppError("invocation_command_invalid", "Retained issued command lost its invocation marker", 1);
|
|
113
|
-
|
|
151
|
+
// The parser proves the marker belongs to argv. Remove that one rendered
|
|
152
|
+
// token from the retained text so response-file/stdin/heredoc presentation
|
|
153
|
+
// remains byte-for-byte stable across a retry.
|
|
154
|
+
const marker = ` --invocation-id ${id}`;
|
|
155
|
+
const markerAt = prior.command.indexOf(marker);
|
|
156
|
+
if (markerAt < 0 || prior.command.indexOf(marker, markerAt + marker.length) >= 0)
|
|
157
|
+
throw new AppError("invocation_command_invalid", "Retained issued command has an ambiguous invocation marker", 1);
|
|
158
|
+
const command = `${prior.command.slice(0, markerAt)}${prior.command.slice(markerAt + marker.length)}`;
|
|
159
|
+
return managedLifecycleCommand({ ...context, env: { ...context.env, DD_FLOW_INVOCATION_SCOPE: prior.scope_json, DD_FLOW_CURRENT_INVOCATION: id } }, command);
|
|
160
|
+
}
|
|
161
|
+
/** Settle a conclusive rejection and publish its only legal successor atomically.
|
|
162
|
+
* A crash must leave either the old attempt executing, or both the settled
|
|
163
|
+
* outcome and its successor; never a usable successor beside an unknown effect. */
|
|
164
|
+
export function settleLifecycleRejection(context, id, error) {
|
|
165
|
+
storage(context);
|
|
166
|
+
const details = { ...error.details };
|
|
167
|
+
context.db.exec("BEGIN IMMEDIATE");
|
|
168
|
+
try {
|
|
169
|
+
const prior = load(context, id);
|
|
170
|
+
if (prior.status !== "executing")
|
|
171
|
+
throw new AppError("invocation_settlement_conflict", "Attempt is no longer executing", 1, { invocation_id: id });
|
|
172
|
+
if (isRetryableLifecycleRejection(error.code, error.details)) {
|
|
173
|
+
const retry = retryLifecycleInvocationCommand(context, id);
|
|
174
|
+
details.retry_command = retry;
|
|
175
|
+
details.retry_instruction = "Correct only the evidenced defect, then use retry_command with the required result input. Reissuing the old command only reads its retained outcome.";
|
|
176
|
+
}
|
|
177
|
+
settleLifecycleInvocation(context, id, { error: { ...error, details } });
|
|
178
|
+
context.db.exec("COMMIT");
|
|
179
|
+
Object.assign(error.details, details);
|
|
180
|
+
}
|
|
181
|
+
catch (error) {
|
|
182
|
+
context.db.exec("ROLLBACK");
|
|
183
|
+
throw error;
|
|
184
|
+
}
|
|
114
185
|
}
|
|
115
186
|
/** Recovery preserves the original packet as evidence. Only its retained,
|
|
116
187
|
* never-executed commands receive new authority; unknown effects never replay. */
|
|
@@ -206,14 +277,28 @@ export function issueLifecycleInvocation(context, command, scope) {
|
|
|
206
277
|
throw new AppError("invocation_scope_invalid", "Lifecycle issuance requires explicit managed scope and a new command", 1);
|
|
207
278
|
}
|
|
208
279
|
const id = crypto.randomUUID();
|
|
209
|
-
const
|
|
210
|
-
const prepared = `${command.slice(0, at).trimEnd()} --invocation-id ${id} ${command.slice(at)}`.trimEnd();
|
|
280
|
+
const prepared = commandWithInvocationId(command, id);
|
|
211
281
|
// Validate the actual inserted argv, including quoted heredoc boundaries.
|
|
212
282
|
const row = { id, scope_json: JSON.stringify(scope), fingerprint: fingerprint(parsed) };
|
|
213
283
|
checkCommand(row, prepared);
|
|
214
284
|
context.db.run("INSERT INTO lifecycle_invocations (id, scope_json, command, fingerprint, status, created_at, updated_at) VALUES (?, ?, ?, ?, 'issued', ?, ?)", [id, row.scope_json, prepared, row.fingerprint, context.now(), context.now()]);
|
|
215
285
|
return { id, command: prepared };
|
|
216
286
|
}
|
|
287
|
+
function commandWithInvocationId(command, id) {
|
|
288
|
+
const at = shellSuffixOffset(command);
|
|
289
|
+
return `${command.slice(0, at).trimEnd()} --invocation-id ${id} ${command.slice(at)}`.trimEnd();
|
|
290
|
+
}
|
|
291
|
+
/** The attempt owns authority; the caller owns presentation-only shell input.
|
|
292
|
+
* Preserve current stdin/heredoc/response-file text while validating that it
|
|
293
|
+
* still names the exact persisted operation and project. */
|
|
294
|
+
function renderInvocationCommand(row, command) {
|
|
295
|
+
const parsed = invocation(command);
|
|
296
|
+
if (commandOption(parsed, "invocation-id"))
|
|
297
|
+
throw new AppError("invocation_command_invalid", "Rendered lifecycle command already has an invocation id", 1);
|
|
298
|
+
const rendered = commandWithInvocationId(command, row.id);
|
|
299
|
+
checkCommand(row, rendered);
|
|
300
|
+
return rendered;
|
|
301
|
+
}
|
|
217
302
|
/** Native observer and receipt writer commit together. Never await I/O inside
|
|
218
303
|
* this transaction; the callback must write to this same SQLite connection. */
|
|
219
304
|
export function observeLifecycleInvocation(context, input) {
|
|
@@ -9,14 +9,14 @@ export async function cancelControlledNativeSession(input) {
|
|
|
9
9
|
throw new AppError("controller_operation_mismatch", "Cancellation observation belongs to another operation or Session", 1);
|
|
10
10
|
const result = input.observe ? reply.state === "completed" ? reply.result : null : reply;
|
|
11
11
|
if (!input.observe || reply.state === "completed")
|
|
12
|
-
assertNativeCancellationReceipt(result, input.sessionId);
|
|
12
|
+
assertNativeCancellationReceipt(result, input.sessionId, input.requireSettled);
|
|
13
13
|
input.assertCurrent();
|
|
14
14
|
return { status: result ? "completed" : "outcome_unknown", receipt: (result ?? reply) };
|
|
15
15
|
}
|
|
16
|
-
export function assertNativeCancellationReceipt(value, sessionId) {
|
|
16
|
+
export function assertNativeCancellationReceipt(value, sessionId, requireSettled = false) {
|
|
17
17
|
const identity = adapterSessionId(value);
|
|
18
|
-
if (!value || typeof value !== "object" || Array.isArray(value) || identity && identity !== sessionId)
|
|
19
|
-
throw new AppError("controller_operation_mismatch", "Cancellation receipt has no valid result for its target Session", 1);
|
|
18
|
+
if (!value || typeof value !== "object" || Array.isArray(value) || identity && identity !== sessionId || (requireSettled && value.settled !== true))
|
|
19
|
+
throw new AppError("controller_operation_mismatch", requireSettled ? "Cancellation receipt has no terminal settlement for its target Session" : "Cancellation receipt has no valid result for its target Session", 1);
|
|
20
20
|
}
|
|
21
21
|
/** Native observation is shared by RUN and non-Work EVAL owners. */
|
|
22
22
|
export async function inspectControlledNativeSession(input) {
|
|
@@ -459,10 +459,10 @@ async function reconcileOwnedSessions(context, input, interrupt, boundary) {
|
|
|
459
459
|
try {
|
|
460
460
|
if (!inserted.changes && prior.status === "completed") {
|
|
461
461
|
const saved = JSON.parse(prior.receipt_json);
|
|
462
|
-
assertNativeCancellationReceipt(saved, session.id);
|
|
462
|
+
assertNativeCancellationReceipt(saved, session.id, harness === "zcode-acp");
|
|
463
463
|
return { operation_id: operationId, session_id: session.id, status: prior.status, receipt: saved, reused: true };
|
|
464
464
|
}
|
|
465
|
-
const { status, receipt } = await cancelControlledNativeSession({ executable, sessionId: session.id, stateDir: session.state_dir, env, operationId, observe: !inserted.changes, assertCurrent });
|
|
465
|
+
const { status, receipt } = await cancelControlledNativeSession({ executable, sessionId: session.id, stateDir: session.state_dir, env, operationId, observe: !inserted.changes, assertCurrent, requireSettled: harness === "zcode-acp" });
|
|
466
466
|
context.db.run("UPDATE run_control_operations SET status = ?, receipt_json = ?, error_json = NULL, updated_at = ? WHERE operation_id = ?", [status, JSON.stringify(receipt), context.now(), operationId]);
|
|
467
467
|
return { operation_id: operationId, session_id: session.id, status, receipt };
|
|
468
468
|
}
|
|
@@ -3,7 +3,7 @@ import fs from "node:fs";
|
|
|
3
3
|
import os from "node:os";
|
|
4
4
|
import path from "node:path";
|
|
5
5
|
import { quote } from "shell-quote";
|
|
6
|
-
import { managedLifecycleCommand } from "./lifecycle-invocations.js";
|
|
6
|
+
import { lifecycleRetryCommands, managedLifecycleCommand } from "./lifecycle-invocations.js";
|
|
7
7
|
import { AppError } from "../shared/errors.js";
|
|
8
8
|
import { resolveProjectRoot } from "../storage/paths.js";
|
|
9
9
|
import { findRecentMatchingHookEvent, claimStageResumeHookEvent, stageResumeMatchKey } from "./hooks.js";
|
|
@@ -73,16 +73,22 @@ export function resumeStageAfterUser(context, input) {
|
|
|
73
73
|
errorCode: "trusted_stage_resume_required",
|
|
74
74
|
operation: "stage resume"
|
|
75
75
|
}).eventKey;
|
|
76
|
-
const answerPath = path.join(path.dirname(stage.pause.question_path), "answer.md");
|
|
77
|
-
fs.writeFileSync(answerPath, input.answer);
|
|
78
76
|
const now = context.now();
|
|
79
77
|
const workSession = context.db.get("SELECT id, session_id, prompt_path, result_path, status FROM work_sessions WHERE work_id = ? AND status = 'running' ORDER BY created_at DESC LIMIT 1", [work.work_id]);
|
|
80
78
|
if (!workSession)
|
|
81
79
|
throw new AppError("runtime_missing", "Paused Work has no open Session link", 1, { work_id: work.work_id });
|
|
80
|
+
const answerPath = path.join(path.dirname(stage.pause.question_path), `answer-${crypto.createHash("sha256").update(`${stage.pause.id}\0${input.answer}`).digest("hex")}.md`);
|
|
82
81
|
context.db.exec("BEGIN IMMEDIATE");
|
|
83
82
|
let identity;
|
|
84
83
|
try {
|
|
85
84
|
identity = claimStageResumeHookEvent(context, { projectId: project.id, runId: run.id, stage: stage.stage, workId: work.work_id, projectRoot, eventKey: hookEventId });
|
|
85
|
+
try {
|
|
86
|
+
fs.writeFileSync(answerPath, input.answer, { flag: "wx", mode: 0o600 });
|
|
87
|
+
}
|
|
88
|
+
catch (error) {
|
|
89
|
+
if (error.code !== "EEXIST" || fs.readFileSync(answerPath, "utf8") !== input.answer)
|
|
90
|
+
throw error;
|
|
91
|
+
}
|
|
86
92
|
if (workSession.session_id !== identity.sessionId) {
|
|
87
93
|
bindSessionForResume(context, work.work_id, identity, now);
|
|
88
94
|
context.db.run("UPDATE work_sessions SET status = 'completed', updated_at = ?, completed_at = ? WHERE id = ?", [now, now, workSession.id]);
|
|
@@ -101,6 +107,7 @@ export function resumeStageAfterUser(context, input) {
|
|
|
101
107
|
refreshRunWorkProjection(context, project.id, run.id);
|
|
102
108
|
appendFlowRunTimelineEvent(context, project.id, run.id, { type: "stage_resume_session_bound", stage: stage.stage, work_id: work.work_id, session_id: identity.sessionId });
|
|
103
109
|
const prompt = fs.readFileSync(workSession.prompt_path, "utf8");
|
|
110
|
+
const retryCommands = lifecycleRetryCommands(context, stage.stage);
|
|
104
111
|
const continuation = [
|
|
105
112
|
prompt.trimEnd(),
|
|
106
113
|
"",
|
|
@@ -109,6 +116,7 @@ export function resumeStageAfterUser(context, input) {
|
|
|
109
116
|
`- Question source: ${stage.pause.question_path}`,
|
|
110
117
|
`- Answer source: ${answerPath}`,
|
|
111
118
|
"- Continue the same stage, Work and attempt. Do not call stage start or repeat completed preparation.",
|
|
119
|
+
...(retryCommands.length ? ["- A prior no-effect lifecycle rejection now has these authoritative replacement commands; use only these replacements, never invent an invocation id:", ...retryCommands.map(command => ` ${command}`)] : []),
|
|
112
120
|
"",
|
|
113
121
|
"<user_question>",
|
|
114
122
|
fs.readFileSync(stage.pause.question_path, "utf8"),
|
|
@@ -103,9 +103,7 @@ export function addVnextCodeReviewRepair(context, input) {
|
|
|
103
103
|
const unknown = requestedCheckRefs.filter((ref) => !checks.has(ref));
|
|
104
104
|
if (unknown.length)
|
|
105
105
|
throw new AppError("review_check_reference_unknown", "CODE-REVIEW finding cites a check absent from the accepted CODE handoff", 2, { check_refs: unknown });
|
|
106
|
-
const reviewChecks = requestedCheckRefs.map((ref) => checks.get(ref))
|
|
107
|
-
if (!reviewChecks.length)
|
|
108
|
-
throw new AppError("review_repair_executable_check_required", "CODE-REVIEW repair requires at least one executable causal check; external-only evidence cannot validate a repair Work", 2, { finding_ids: input.findingIds, check_refs: requestedCheckRefs });
|
|
106
|
+
const reviewChecks = requestedCheckRefs.map((ref) => checks.get(ref));
|
|
109
107
|
return addVnextCodeRepair(context, {
|
|
110
108
|
projectRoot,
|
|
111
109
|
runId: run.id,
|
|
@@ -192,7 +190,7 @@ export async function finishVnextCodeReview(context, input) {
|
|
|
192
190
|
retry_after_workspace_change: true,
|
|
193
191
|
workspace_fingerprint: failed[0].workspace_fingerprint,
|
|
194
192
|
repair_command: `${flowCommand(context)} work repair add --run ${run.id} --from-check ${failed[0].id} --origin-work <WORK-ID> --task-stdin --project-root ${JSON.stringify(projectRoot)} --json`,
|
|
195
|
-
retry_command:
|
|
193
|
+
retry_command: finishCommand(context, run.id, projectRoot, decisionFile, failed[0].id)
|
|
196
194
|
});
|
|
197
195
|
const stopTarget = executionStopTarget(run);
|
|
198
196
|
const nextAction = stopTarget === "merge_completed" ? "start_merge" : "code_review_completed";
|
|
@@ -421,7 +419,7 @@ function requireRun(context, projectId, runId) { const run = context.db.get("SEL
|
|
|
421
419
|
throw new AppError("not_found", "RUN is not registered", 1); return run; }
|
|
422
420
|
function requireHome(run) { if (!run.run_root)
|
|
423
421
|
throw new AppError("runtime_missing", "RUN artifact root is unavailable", 1); return run.run_root; }
|
|
424
|
-
function finishCommand(context, runId, projectRoot, decision) { return managedLifecycleCommand(context, `${flowCommand(context)} stage finish ${runId} --stage code-review --decision-file ${JSON.stringify(decision)} --project-root ${JSON.stringify(projectRoot)} --json --progress-jsonl`); }
|
|
422
|
+
export function finishCommand(context, runId, projectRoot, decision, retryCheckId) { const retry = retryCheckId ? ` --retry-check ${retryCheckId} --reason "<environment recovery evidence>"` : ""; return managedLifecycleCommand(context, `${flowCommand(context)} stage finish ${runId} --stage code-review --decision-file ${JSON.stringify(decision)} --project-root ${JSON.stringify(projectRoot)} --json --progress-jsonl${retry}`); }
|
|
425
423
|
function findFiles(root, name) { if (!fs.existsSync(root))
|
|
426
424
|
return []; const out = []; for (const entry of fs.readdirSync(root, { withFileTypes: true })) {
|
|
427
425
|
const file = path.join(root, entry.name);
|
|
@@ -179,7 +179,7 @@ export async function finishVnextCode(context, input) {
|
|
|
179
179
|
retry_after_workspace_change: true,
|
|
180
180
|
workspace_fingerprint: failed[0].workspace_fingerprint,
|
|
181
181
|
repair_command: `${flowCommand(context)} work repair add --run ${run.id} --from-check ${failed[0].id} --origin-work <WORK-ID> --task-stdin --project-root ${JSON.stringify(projectRoot)} --json`,
|
|
182
|
-
retry_command:
|
|
182
|
+
retry_command: finishCommand(context, run.id, projectRoot, input.verificationFile, failed[0].id)
|
|
183
183
|
});
|
|
184
184
|
}
|
|
185
185
|
const finalReceipts = receipts;
|
|
@@ -495,18 +495,16 @@ export function verificationProjection(works, receipts, options = {}) {
|
|
|
495
495
|
return [item.criterion_id, { evidenceRefs, historical }];
|
|
496
496
|
}));
|
|
497
497
|
const hasEvidence = (criterionId) => (acceptanceEvidence.get(criterionId)?.evidenceRefs.length ?? 0) > 0;
|
|
498
|
-
const isPassed = (checkId
|
|
498
|
+
const isPassed = (checkId) => receiptByDeclaration.get(checkId)?.status === "passed";
|
|
499
499
|
return {
|
|
500
500
|
checks: declarations.map((check) => {
|
|
501
501
|
const receipt = receiptByDeclaration.get(check.id);
|
|
502
|
-
|
|
503
|
-
return { id: check.id, run_at: check.run_at, status: receipt?.status ?? (externallyEvidenced ? "evidence_recorded" : ["work", "code", "readiness"].includes(check.run_at) ? "failed" : "not_due"), receipt_ref: receipt?.receipt_path ?? null };
|
|
502
|
+
return { id: check.id, run_at: check.run_at, status: receipt?.status ?? (["work", "code", "readiness"].includes(check.run_at) ? "failed" : "not_due"), receipt_ref: receipt?.receipt_path ?? null };
|
|
504
503
|
}),
|
|
505
504
|
acceptance: acceptance.map((item) => {
|
|
506
|
-
const
|
|
507
|
-
const confirmed = item.check_refs.every((id) => isPassed(id, item.criterion_id)) && hasEvidence(item.criterion_id);
|
|
505
|
+
const confirmed = item.check_refs.every((id) => isPassed(id)) && hasEvidence(item.criterion_id);
|
|
508
506
|
const evidence = acceptanceEvidence.get(item.criterion_id) ?? { evidenceRefs: [], historical: [] };
|
|
509
|
-
return { criterion_id: item.criterion_id, gate: item.gate, status: confirmed ?
|
|
507
|
+
return { criterion_id: item.criterion_id, gate: item.gate, status: confirmed ? "confirmed" : ["work", "code", "readiness"].includes(item.gate) ? "unresolved" : "not_due", check_refs: item.check_refs, evidence_refs: evidence.evidenceRefs, ...(evidence.historical.length ? { reported_evidence_refs: evidence.historical } : {}) };
|
|
510
508
|
})
|
|
511
509
|
};
|
|
512
510
|
}
|
|
@@ -604,8 +602,9 @@ function requireHome(run) {
|
|
|
604
602
|
throw new AppError("runtime_missing", "RUN artifact root is unavailable", 1);
|
|
605
603
|
return run.run_root;
|
|
606
604
|
}
|
|
607
|
-
function finishCommand(context, runId, projectRoot, verificationFile) {
|
|
608
|
-
|
|
605
|
+
export function finishCommand(context, runId, projectRoot, verificationFile, retryCheckId) {
|
|
606
|
+
const retry = retryCheckId ? ` --retry-check ${retryCheckId} --reason "<environment recovery evidence>"` : "";
|
|
607
|
+
return managedLifecycleCommand(context, `${flowCommand(context)} stage finish ${runId} --stage code --verification-file ${JSON.stringify(verificationFile)} --project-root ${JSON.stringify(projectRoot)} --json --progress-jsonl${retry}`);
|
|
609
608
|
}
|
|
610
609
|
function readVerification(context, input) {
|
|
611
610
|
const file = path.resolve(input.file);
|
|
@@ -260,7 +260,7 @@ export async function finishVnextMerge(context, input) {
|
|
|
260
260
|
const failed = receipts.filter((receipt) => receipt.status !== "passed");
|
|
261
261
|
if (failed.length) {
|
|
262
262
|
context.db.run("UPDATE merge_requests SET status = 'action_required', last_error_json = ?, updated_at = ? WHERE merge_request_id = ?", [JSON.stringify({ code: "merge_gate_failed", failures: failed }), context.now(), request.merge_request_id]);
|
|
263
|
-
throw new AppError("merge_gate_failed", "Integrated target checks failed. Classify retained evidence: source defects use source repair; restored environment uses retry.", 2, { failures: failed, repair_command: `${flowCommand(context)} merge repair ${request.merge_request_id} --project-root ${JSON.stringify(projectRoot)} --json`, retry_command:
|
|
263
|
+
throw new AppError("merge_gate_failed", "Integrated target checks failed. Classify retained evidence: source defects use source repair; restored environment uses retry.", 2, { failures: failed, repair_command: `${flowCommand(context)} merge repair ${request.merge_request_id} --project-root ${JSON.stringify(projectRoot)} --json`, retry_command: finishCommand(context, run.id, request, projectRoot, failed[0].id) });
|
|
264
264
|
}
|
|
265
265
|
const passedRefs = new Set(receipts.flatMap((receipt) => receipt.check_refs));
|
|
266
266
|
const missingRefs = frozenGateRefs(gate).filter((ref) => !passedRefs.has(ref));
|
|
@@ -561,4 +561,4 @@ function findRootWork(context, projectId, runId) { const work = context.db.get("
|
|
|
561
561
|
function requireRootWork(context, projectId, runId) { const work = findRootWork(context, projectId, runId); if (work.status !== "running")
|
|
562
562
|
throw new AppError("runtime_missing", "vNext RUN has no running root Work", 1); return work; }
|
|
563
563
|
function applyCommand(context, request, projectRoot) { return `${flowCommand(context)} merge apply ${request.merge_request_id} --work ${request.executor_work_id} --project-root ${JSON.stringify(projectRoot)} --json --progress-jsonl`; }
|
|
564
|
-
function finishCommand(context, runId, request, projectRoot) { return managedLifecycleCommand(context, `${flowCommand(context)} stage finish ${runId} --stage merge --request ${request.merge_request_id} --work ${request.executor_work_id} --project-root ${JSON.stringify(projectRoot)} --json --progress-jsonl`); }
|
|
564
|
+
export function finishCommand(context, runId, request, projectRoot, retryCheckId) { const retry = retryCheckId ? ` --retry-check ${retryCheckId} --reason "<environment recovery evidence>"` : ""; return managedLifecycleCommand(context, `${flowCommand(context)} stage finish ${runId} --stage merge --request ${request.merge_request_id} --work ${request.executor_work_id} --project-root ${JSON.stringify(projectRoot)} --json --progress-jsonl${retry}`); }
|
|
@@ -93,10 +93,13 @@ export function startVnextPlan(context, input) {
|
|
|
93
93
|
? [`This RUN must reach MERGE. Project policy already supplies the mandatory merge gate${policyMergeAliases.length === 1 ? "" : "s"}: ${policyMergeAliases.join(", ")}. Do not duplicate them in semantic checks[]. Add another merge check only when the task genuinely needs additional evidence.`]
|
|
94
94
|
: ["This RUN must reach MERGE and project policy supplies no merge gate. Select at least one real top-level checks[] entry with run_at: merge. It may use an existing project alias or a planned alias materialised by a named P* provider Work. This is a planning obligation: do not defer it to CODE-REVIEW or MERGE."]), "The CLI validates the effective merge gate but never invents one or migrates an incompatible project policy.", "</merge_gate_contract>", ""]
|
|
95
95
|
: [];
|
|
96
|
-
const prompt = ["<stage_identity>", `- RUN: ${run.id}`, `- Work: ${planWorkId}`, "- stage: plan", "</stage_identity>", "", "<trusted_runtime_context>", "These facts were collected by dd-flow. Trust them; do not repeat CLI, Git, compatibility or permission discovery.", `- Project root: ${projectRoot}`, `- Workspace: ${run.workspace_root}`, `- Stage workspace: ${root}`, `- Git: ${JSON.stringify(gitFacts(run.workspace_root))}`, capacityContext, "</trusted_runtime_context>", "", "<workspace_contract>", `- route: ${workspaceRoute.route}`, `- feature branch: ${workspaceRoute.feature_branch ?? "not applicable"}`, `- base commit: ${workspaceRoute.base_ref ?? "not applicable"}`, `- write workspace: ${run.workspace_root}`, "The CLI has verified this frozen route. All project reads and writes for PLAN and later CODE happen in the write workspace; project root is only the stable runtime identity for lifecycle commands. Do not create, switch, merge or delete branches/worktrees.", "Keep the task runner's current cwd. Use the absolute paths in this packet instead of trying to set the provisioned workspace as a tool workdir.", "</workspace_contract>", "", "<accepted_inputs>", `- ${path.join(home, "01-specify", "specify.json")}`, `- ${path.join(home, "02-protocolize", "protocolize-result.json")}`, ...protocols.map((id) => `- ${path.join(run.workspace_root, ".memory-bank", "protocol", id, "summary.md")}`), "</accepted_inputs>", "", ...(fs.existsSync(checkProfile) ? ["<code_check_policy>", "You, not the CLI, select evidence for every accepted requirement and acceptance criterion. The profile only lists reusable aliases, mandatory project policy gates and guarded raw command prefixes. Inspect relevant package/test manifests before choosing a check. Do not classify checks by weight and do not omit a needed check because it looks expensive.", fs.readFileSync(checkProfile, "utf8").trim(), "</code_check_policy>", ""] : []), ...mergeContract, "<artifacts>", "The CLI has already materialized every artifact below as a partially filled draft. Edit these files in place; do not create replacements elsewhere.", "Prefilled and CLI-owned plan fields: schema_id, plan_id, protocol_id, initial revision and source_refs.", "Prefilled and CLI-owned aspect-map fields: schema_id, protocol_id, plan_id, plan revision, catalog_ref and every catalog aspect_id.", "You own the remaining semantic fields. Empty or missing semantic values are intentional draft markers and must be completed before validation.", ...planPaths.map((value) => `- partially filled plan: ${value}`), ...mapPaths.map((value) => `- partially filled aspect map: ${value}`), "</artifacts>", "", "<output_contract>", "Complete every named plan and aspect map in place. Do not create or edit code-work-batch.json: dd-flow derives it after validation.", "The CLI owns schema_id, plan_id, protocol_id, revision and source_refs. Preserve them exactly.", "Use protocol-plan@6. Its top-level checks[] is the single check catalog. Every check has id, command, purpose, run_at and availability. available means executable now. planned means one named P* Work first creates a NEW @check/... alias: planned therefore always needs provided_by and the exact alias definition. Every semantic @check alias, including an existing one, repeats its exact accepted profile command in definition so later stages can detect drift. Items and acceptance entries use check_refs only; never duplicate command declarations.", "For each R-* and AC-*, choose an actually relevant proof: an existing focused test, a new planned alias plus its provider Work, a project policy gate, or an
|
|
96
|
+
const prompt = ["<stage_identity>", `- RUN: ${run.id}`, `- Work: ${planWorkId}`, "- stage: plan", "</stage_identity>", "", "<trusted_runtime_context>", "These facts were collected by dd-flow. Trust them; do not repeat CLI, Git, compatibility or permission discovery.", `- Project root: ${projectRoot}`, `- Workspace: ${run.workspace_root}`, `- Stage workspace: ${root}`, `- Git: ${JSON.stringify(gitFacts(run.workspace_root))}`, capacityContext, "</trusted_runtime_context>", "", "<workspace_contract>", `- route: ${workspaceRoute.route}`, `- feature branch: ${workspaceRoute.feature_branch ?? "not applicable"}`, `- base commit: ${workspaceRoute.base_ref ?? "not applicable"}`, `- write workspace: ${run.workspace_root}`, "The CLI has verified this frozen route. All project reads and writes for PLAN and later CODE happen in the write workspace; project root is only the stable runtime identity for lifecycle commands. Do not create, switch, merge or delete branches/worktrees.", "Keep the task runner's current cwd. Use the absolute paths in this packet instead of trying to set the provisioned workspace as a tool workdir.", "</workspace_contract>", "", "<accepted_inputs>", `- ${path.join(home, "01-specify", "specify.json")}`, `- ${path.join(home, "02-protocolize", "protocolize-result.json")}`, ...protocols.map((id) => `- ${path.join(run.workspace_root, ".memory-bank", "protocol", id, "summary.md")}`), "</accepted_inputs>", "", ...(fs.existsSync(checkProfile) ? ["<code_check_policy>", "You, not the CLI, select evidence for every accepted requirement and acceptance criterion. The profile only lists reusable aliases, mandatory project policy gates and guarded raw command prefixes. Inspect relevant package/test manifests before choosing a check. Do not classify checks by weight and do not omit a needed check because it looks expensive.", fs.readFileSync(checkProfile, "utf8").trim(), "</code_check_policy>", ""] : []), ...mergeContract, "<artifacts>", "The CLI has already materialized every artifact below as a partially filled draft. Edit these files in place; do not create replacements elsewhere.", "Prefilled and CLI-owned plan fields: schema_id, plan_id, protocol_id, initial revision and source_refs.", "Prefilled and CLI-owned aspect-map fields: schema_id, protocol_id, plan_id, plan revision, catalog_ref and every catalog aspect_id.", "You own the remaining semantic fields. Empty or missing semantic values are intentional draft markers and must be completed before validation.", ...planPaths.map((value) => `- partially filled plan: ${value}`), ...mapPaths.map((value) => `- partially filled aspect map: ${value}`), "</artifacts>", "", "<output_contract>", "Complete every named plan and aspect map in place. Do not create or edit code-work-batch.json: dd-flow derives it after validation.", "The CLI owns schema_id, plan_id, protocol_id, revision and source_refs. Preserve them exactly.", "Use protocol-plan@6. Its top-level checks[] is the single check catalog. Every check has id, command, purpose, run_at and availability. available means executable now. planned means one named P* Work first creates a NEW @check/... alias: planned therefore always needs provided_by and the exact alias definition. Every semantic @check alias, including an existing one, repeats its exact accepted profile command in definition so later stages can detect drift. Items and acceptance entries use check_refs only; never duplicate command declarations.", "For each R-* and AC-*, choose an actually relevant proof: an existing focused test, a new planned alias plus its provider Work, a project policy gate, or an autonomous executable check. Every plan item needs at least one check_ref. The CLI validates ids, provider ordering, materialization and guarded command policy; it never chooses a check for you. A provider Work may verify itself with the alias it has just created. A consumer must depend on that provider.", "Each plan item must name concrete existing source/test paths in required_read. planned_write_areas is optional: use stable component directories or files only when they help coordinate parallel Work; it is never a write allowlist. Reference every owned R-* and AC-* in one or more items; every AC-* needs an observable acceptance proof.", "For every selected check, inspect its command's launch path and the runtime entrypoints it starts. The fixture/reset process, service process and client process must observe one intended environment and data world. If a required runtime entrypoint needs a code change, make that change explicit in the Work task and its verification. Use planned_write_areas only to advertise likely concurrent overlap; do not treat it as ownership or assume another Work will repair an omitted change. If an independent infrastructure Work is clearer, plan that Work explicitly and order consumers after it.", reviewGroupingRule, "Complete compact contract and schema paths:", `- protocol plan schema: ${path.join(run.workspace_root, ".memory-bank", "dd-flow", "schemas", "vnext-protocol-plan.schema.json")}`, `- aspect map schema: ${path.join(run.workspace_root, ".memory-bank", "dd-flow", "schemas", "plan-aspect-map.schema.json")}`, "Minimal valid protocol-plan shape:", "```json", JSON.stringify(planExample(protocols[0]), null, 2), "```", "Minimal valid aspect-map shape:", "```json", JSON.stringify(aspectMapExample(protocols[0]), null, 2), "```", "</output_contract>", "", "<execution_commands>", "PLAN never launches independent reviewers or registers CODE Work.", "If PLAN needs a material user decision with no reasonable default, run this exact one-command heredoc, replacing only its placeholder body. The heredoc is the permitted stdin form; do not use cat, a pipe, a temporary file or a second shell command:", "```sh", pauseCommandTemplate, "```", "Ask the returned user_message, stop, and resume this same PLAN Work with the exact returned command.", "Validate both partially filled drafts after completing their semantic fields:", ...validationCommands.map((command) => `- ${command}`), "Finish PLAN only after all questions are resolved and both validation commands pass:", finishCommand, "The response returns the only PLAN-REVIEW start command. Follow it; do not start CODE directly.", "</execution_commands>", "", "<stage_instructions>", template, "</stage_instructions>", ""].join("\n");
|
|
97
97
|
const artifactMaterialization = { status: "materialized", completeness: "partially_filled", plan_paths: planPaths, aspect_map_paths: mapPaths, cli_owned_plan_fields: ["schema_id", "plan_id", "protocol_id", "revision", "source_refs"], cli_owned_aspect_map_fields: ["schema_id", "protocol_id", "plan_id", "plan_revision", "catalog_ref", "aspects[].aspect_id"], validation_commands: validationCommands };
|
|
98
98
|
const promptPath = path.join(root, "stage-prompt.md");
|
|
99
|
-
|
|
99
|
+
// This explicit final rule supersedes historical pack wording: a flow gate
|
|
100
|
+
// has only executable autonomous evidence. Human or external confirmation
|
|
101
|
+
// is neither a check nor a permitted completion condition.
|
|
102
|
+
fs.writeFileSync(promptPath, `${prompt}\n<verification_rule>Every flow check must be an autonomous executable command. Do not declare external/manual proof or a human review as a check, evidence substitute, DEF, or gate. The only user interaction supported by this flow is an explicit stage pause for a material unanswered question.</verification_rule>\n`);
|
|
100
103
|
const externalContext = applyExternalStageContext({ stageRoot: root, promptPath, ...(input.externalContext ? { loaded: input.externalContext } : {}) });
|
|
101
104
|
fs.writeFileSync(path.join(root, "work-context.json"), JSON.stringify({ schema_id: "dd-flow/work-context@1", system: { run_id: run.id, work_id: planWorkId, stage: "plan" }, workspace: { project_root: projectRoot, workspace_root: run.workspace_root, stage_root: root }, artifacts: artifactMaterialization, input: { protocols, owned_obligations: Object.fromEntries(owned) } }, null, 2));
|
|
102
105
|
const binding = bindStageCoordinatorWork(context, { workId: planWorkId, hookEventId: input.hookEventId, stage: "plan", promptPath, resultPath: path.join(root, "stage-report.json"), ...(input.contextSha256 ? { contextSha256: input.contextSha256 } : {}) });
|
|
@@ -128,7 +128,9 @@ export function submitVnextSpecify(context, input) {
|
|
|
128
128
|
if (work.project_id !== project.id)
|
|
129
129
|
throw new AppError("project_mismatch", "Work does not belong to --project-root", 1, { work_id: work.work_id });
|
|
130
130
|
if (work.status !== "running") {
|
|
131
|
-
|
|
131
|
+
// This check precedes all result reads and mutations. A later legal state
|
|
132
|
+
// transition may make a new invocation appropriate, but never replays this one.
|
|
133
|
+
throw new AppError("invalid_work_state", "Work is not waiting for a SPECIFY result", 1, { work_id: work.work_id, status: work.status, retryable_no_effect: true });
|
|
132
134
|
}
|
|
133
135
|
const run = requireRun(context, projectRoot, work.run_id);
|
|
134
136
|
if (runStatus(context, project.id, run.id) !== "running") {
|