@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 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
@@ -1,11 +1,11 @@
1
1
  {
2
2
  "cli_package": "@deksden-com/dd-flow-cli",
3
- "cli_version": "0.9.0-beta.53",
4
- "cli_commit": "db14065f43f320bc69dacbec425cd04eac8236ae",
5
- "built_at": "2026-09-13T02:52:44.434Z",
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.0",
8
- "commit": "ef349bf47cba1c987468e51d73a0dbadbd48dc1f",
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",
@@ -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, isRetryableLifecycleRejection, lifecycleInvocationScope, retryLifecycleInvocationCommand, settleLifecycleInvocation } from "../services/lifecycle-invocations.js";
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
- const output = parseOutputOptions(args);
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
- settleLifecycleInvocation(invocationSettlement.context, invocationSettlement.id, { error: isAppError(error)
184
- ? { code: error.code, message: error.message, exitCode: error.exitCode, details: error.details }
185
- : { code: "unexpected", message: String(error), exitCode: 1, details: {} } });
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
- // Cancellation acceptance is not settlement. Re-read the same
367
- // controlled tree before letting the daemon close its endpoint.
368
- try { observed = await inspectSessionWithBridge(this.bridge, options); }
369
- catch (error) { unsettled.push(session.provider_session_id); continue; }
370
- if (!cancelled.settled || this.treeRunning(observed)) unsettled.push(session.provider_session_id);
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.journal.write("inbound", message);
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 ??= error;
138
- await this.journal.write("notification_failure", { code: error.code ?? "invocation_observation_failed", message: error.message,
139
- session_id: message.params?.sessionId ?? null, tool_call_id: update?.toolCallId ?? null, event_time: now(), delivery_stage: "lifecycle_receipt", completeness: "incomplete" });
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() { await Promise.all([...this.lifecycleNotifications]); await this.notifications; await this.journal.flush(); if (this.notificationError) throw this.notificationError; }
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. Send it once: session/read
365
- // returns the full transcript, so polling it while the turn is stopping can
366
- // make cancellation slower than the work we are trying to interrupt.
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
- for (let attempt = 0; attempt < 6; attempt += 1) {
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
- after = await bridge.request("zcode/session/subagents", { sessionId }).catch(error => ({ observation_error: { code: error.code, message: error.message } }));
372
- if (after.observation_error || !(after.running ?? []).length) break;
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
- const read = await bridge.request("zcode/session/read", { sessionId }).catch(error => ({ observation_error: { code: error.code, message: error.message } }));
375
- const root_status = read?.projection?.status ?? read?.session?.status ?? null;
376
- const settled = !after.observation_error && !(after.running ?? []).length && ["idle", "completed", "cancelled", "failed", "stopped"].includes(root_status);
377
- return { cancellations, before, after, root_status, cancellation_requested: !settled, settled };
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 === "43f654bccdbb1aa4f4fb4617f7315c4336dbcde0" && harnessContract === ZCODE_HARNESS_CONTRACT;
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", "external"]}, "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/"}}}}]},
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", "external"]}, "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"]}}]},
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", "external"]}}}
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
- if (!profile)
41
- return [...commands];
42
- return commands.map((command) => { 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 });
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
- } for (const [prefix, alias] of Object.entries(profile.require_alias_for))
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 (check.run_at === "external")
70
- return;
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", "external"].includes(gate) && Array.isArray(aliases) && aliases.every((item) => typeof item === "string" && item.startsWith("@check/"))); }
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
- if (commandOption(parsed, "invocation-id"))
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.command;
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 prior.command;
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 marker = ` --invocation-id ${id}`;
111
- if (!prior.command.includes(marker))
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
- return managedLifecycleCommand({ ...context, env: { ...context.env, DD_FLOW_INVOCATION_SCOPE: prior.scope_json, DD_FLOW_CURRENT_INVOCATION: id } }, prior.command.replace(marker, ""));
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 at = shellSuffixOffset(command);
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)).filter((check) => check.run_at !== "external");
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: `${finishCommand(context, run.id, projectRoot, decisionFile)} --retry-check ${failed[0].id} --reason "<environment recovery evidence>"`
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: `${finishCommand(context, run.id, projectRoot, input.verificationFile)} --retry-check ${failed[0].id} --reason "<environment recovery evidence>"`
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, criterionId) => receiptByDeclaration.get(checkId)?.status === "passed" || (declarations.find((check) => check.id === checkId)?.run_at === "external" && hasEvidence(criterionId));
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
- const externallyEvidenced = check.run_at === "external" && acceptance.some((item) => item.check_refs.includes(check.id) && hasEvidence(item.criterion_id));
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 external = item.check_refs.some((id) => declarations.find((check) => check.id === id)?.run_at === "external");
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 ? (external ? "confirmed_with_external_evidence" : "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 } : {}) };
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
- return managedLifecycleCommand(context, `${flowCommand(context)} stage finish ${runId} --stage code --verification-file ${JSON.stringify(verificationFile)} --project-root ${JSON.stringify(projectRoot)} --json --progress-jsonl`);
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: `${finishCommand(context, run.id, request, projectRoot)} --retry-check ${failed[0].id} --reason "<environment recovery evidence>"` });
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 honestly limited external/manual proof. 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");
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
- fs.writeFileSync(promptPath, prompt);
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
- throw new AppError("invalid_work_state", "Work is not waiting for a SPECIFY result", 1, { work_id: work.work_id, status: work.status });
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") {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@deksden-com/dd-flow-cli",
3
- "version": "0.9.0-beta.53",
3
+ "version": "0.9.0-beta.55",
4
4
  "description": "Mechanical runtime CLI for dd-flow workflows.",
5
5
  "type": "module",
6
6
  "bin": {