@deksden-com/dd-flow-cli 0.9.0-beta.54 → 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,11 @@
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
+
3
9
  ## 0.9.0-beta.54
4
10
 
5
11
  ### Patch Changes
@@ -1,11 +1,11 @@
1
1
  {
2
2
  "cli_package": "@deksden-com/dd-flow-cli",
3
- "cli_version": "0.9.0-beta.54",
4
- "cli_commit": "eb2cb9746a9ee386d40f63791f5dbe0847bf5017",
5
- "built_at": "2026-09-13T12:29:37.211Z",
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",
@@ -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";
@@ -53,6 +53,7 @@ class Journal {
53
53
  this.file = file ? absolute(file, "--journal") : null;
54
54
  this.pending = this.file ? mkdir(path.dirname(this.file), { recursive: true }) : Promise.resolve();
55
55
  this.order = 0;
56
+ this.artifacts = new Set();
56
57
  }
57
58
  write(kind, payload) {
58
59
  if (!this.file) return;
@@ -60,9 +61,69 @@ class Journal {
60
61
  this.pending = this.pending.then(() => appendFile(this.file, `${line}\n`));
61
62
  return this.pending;
62
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
+ }
63
101
  async flush() { await this.pending; }
64
102
  }
65
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
+
66
127
  export class AcpBridge {
67
128
  constructor(options) {
68
129
  this.options = options;
@@ -118,7 +179,8 @@ export class AcpBridge {
118
179
  receive(line) {
119
180
  let message;
120
181
  try { message = JSON.parse(line); } catch { this.journal.write("malformed", { line }); return; }
121
- this.journal.write("inbound", message);
182
+ const request = message.id !== undefined ? this.pending.get(message.id) : null;
183
+ this.journal.writeInbound(message, request?.method);
122
184
  if (typeof message.params?.sessionId === "string" && isProductiveSessionNotification(message)) this.sessionActivity.set(message.params.sessionId, Date.now());
123
185
  const update = message.params?.update;
124
186
  // ZCode emits this as `session/update`; Grok Build wraps the same ACP
@@ -154,7 +216,7 @@ export class AcpBridge {
154
216
  }
155
217
  if (message.id !== undefined && message.method) { void this.answer(message).catch((error) => this.rejectAll(error)); return; }
156
218
  if (message.id !== undefined) {
157
- const pending = this.pending.get(message.id);
219
+ const pending = request ?? this.pending.get(message.id);
158
220
  if (!pending) return;
159
221
  clearTimeout(pending.timer); if (pending.idleTimer) clearInterval(pending.idleTimer); this.pending.delete(message.id);
160
222
  if (message.error) {
@@ -367,7 +429,7 @@ export function latestAssistantText(read) {
367
429
  return null;
368
430
  }
369
431
 
370
- async function cancelTree(bridge, sessionId, before) {
432
+ async function cancelTree(bridge, sessionId, before, providerSessionId) {
371
433
  const cancellations = [];
372
434
  for (const child of before.running ?? []) {
373
435
  const taskId = child.taskId ?? child.agentId;
@@ -376,20 +438,41 @@ async function cancelTree(bridge, sessionId, before) {
376
438
  catch (error) { cancellations.push({ taskId, cancelled: false, error: { code: error.code ?? "cancel_failed", message: error.message } }); }
377
439
  }
378
440
  }
379
- // ZCode's stop is deliberately idempotent. Send it once: session/read
380
- // returns the full transcript, so polling it while the turn is stopping can
381
- // 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.
382
444
  bridge.notify("session/cancel", { sessionId });
383
- let after = before;
384
- 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) {
385
448
  await new Promise((resolve) => setTimeout(resolve, 1000));
386
- after = await bridge.request("zcode/session/subagents", { sessionId }).catch(error => ({ observation_error: { code: error.code, message: error.message } }));
387
- 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
+ }
388
459
  }
389
- const read = await bridge.request("zcode/session/read", { sessionId }).catch(error => ({ observation_error: { code: error.code, message: error.message } }));
390
- const root_status = read?.projection?.status ?? read?.session?.status ?? null;
391
- const settled = !after.observation_error && !(after.running ?? []).length && ["idle", "completed", "cancelled", "failed", "stopped"].includes(root_status);
392
- 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) };
393
476
  }
394
477
 
395
478
  // Cancel one known child without cancelling its parent turn. The parent keeps
@@ -560,7 +643,7 @@ export function zcodeLifecycleQualification(sha256, bridgeCommit, harnessContrac
560
643
  // Behavioural evidence: dd-eval production-invocations probe, 2026-09-13.
561
644
  // Pin both providers of native event identity, not a string in the bundle.
562
645
  const qualified = sha256 === "e9f1868c0fdb863537ed910ee3828b9be96b8c2fd805473f63b439e1113266b8"
563
- && bridgeCommit === "43f654bccdbb1aa4f4fb4617f7315c4336dbcde0" && harnessContract === ZCODE_HARNESS_CONTRACT;
646
+ && bridgeCommit === "e0600fe3c46e215695f257e65aa6f674ced998d8" && harnessContract === ZCODE_HARNESS_CONTRACT;
564
647
  return { status: qualified ? "qualified" : "unqualified", mechanism: "cli-invocation@1", sha256,
565
648
  capabilities: qualified ? ["root", "concurrent_children", "child_continuation"] : [],
566
649
  native_nested_children: false, reason: qualified ? null : "native_event_contract_unqualified" };
@@ -640,7 +723,7 @@ export async function promptSessionWithBridge(bridge, options) {
640
723
  throw error;
641
724
  }
642
725
  if (!options.allowBackground && (evidence.subagents.running ?? []).length) {
643
- await cancelTree(bridge, identity.adapterSessionId, evidence.subagents);
726
+ await cancelTree(bridge, identity.adapterSessionId, evidence.subagents, identity.providerSessionId);
644
727
  fail("background ZCode subagents cannot outlive the one-shot dd-zcode connection; the live child tree was cancelled");
645
728
  }
646
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) });
@@ -666,7 +749,7 @@ export async function cancelSessionWithBridge(bridge, options) {
666
749
  requireJournal(options);
667
750
  const identity = await controlledIdentity(bridge, options);
668
751
  const before = await bridge.request("zcode/session/subagents", { sessionId: identity.adapterSessionId }).catch(error => ({ observation_error: { code: error.code, message: error.message } }));
669
- 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) });
670
753
  }
671
754
 
672
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
  }
@@ -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)));
@@ -99,8 +99,18 @@ export function managedLifecycleCommand(context, command) {
99
99
  return command;
100
100
  const scope = JSON.parse(configured);
101
101
  const parsed = invocation(command);
102
- 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);
103
112
  return command;
113
+ }
104
114
  if (!context.db.writable || context.env.DD_FLOW_INVOCATION_READONLY === "1") {
105
115
  const retained = context.db.get("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'lifecycle_invocations'")
106
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;
@@ -133,10 +143,20 @@ export function retryLifecycleInvocationCommand(context, id) {
133
143
  const prior = load(context, id);
134
144
  if (prior.status !== "executing")
135
145
  throw new AppError("invocation_retry_not_authorized", "Only the executing lifecycle operation can publish its next attempt", 1);
136
- const marker = ` --invocation-id ${id}`;
137
- 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)
138
150
  throw new AppError("invocation_command_invalid", "Retained issued command lost its invocation marker", 1);
139
- 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);
140
160
  }
141
161
  /** Settle a conclusive rejection and publish its only legal successor atomically.
142
162
  * A crash must leave either the old attempt executing, or both the settled
@@ -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
  }
@@ -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 } : {}) });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@deksden-com/dd-flow-cli",
3
- "version": "0.9.0-beta.54",
3
+ "version": "0.9.0-beta.55",
4
4
  "description": "Mechanical runtime CLI for dd-flow workflows.",
5
5
  "type": "module",
6
6
  "bin": {