@deksden-com/dd-flow-cli 0.9.0-beta.120 → 0.9.0-beta.122

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.
Files changed (38) hide show
  1. package/CHANGELOG.md +9 -0
  2. package/dist/build-info.json +5 -5
  3. package/dist/cli/hook-ingress.js +4 -3
  4. package/dist/cli/native-hook-ingress.js +34 -2
  5. package/dist/harness-runtime/bin/dd-agy.mjs +6 -3
  6. package/dist/harness-runtime/lib/dd-agy-daemon.mjs +16 -0
  7. package/dist/harness-runtime/lib/dd-grok-daemon.mjs +12 -2
  8. package/dist/harness-runtime/lib/dd-zcode-daemon.mjs +60 -8
  9. package/dist/harness-runtime/lib/dd-zcode.mjs +28 -13
  10. package/dist/harness-runtime/lib/delegation-instructions.mjs +3 -3
  11. package/dist/harness-runtime/lib/lifecycle-command.d.mts +20 -0
  12. package/dist/harness-runtime/lib/lifecycle-command.mjs +218 -0
  13. package/dist/harness-runtime/lib/lifecycle-contract.d.mts +10 -0
  14. package/dist/harness-runtime/lib/lifecycle-contract.mjs +19 -0
  15. package/dist/harness-runtime/lib/native-hook-command.d.mts +2 -2
  16. package/dist/harness-runtime/lib/native-hook-command.mjs +36 -5
  17. package/dist/runtime/context.js +1 -1
  18. package/dist/schemas/flow-run.schema.json +1 -0
  19. package/dist/services/code-checks.js +40 -0
  20. package/dist/services/codex-hook-delivery.js +6 -2
  21. package/dist/services/hooks.js +51 -59
  22. package/dist/services/lifecycle-command.js +1 -383
  23. package/dist/services/lifecycle-contract.js +1 -30
  24. package/dist/services/lifecycle-invocations.js +34 -13
  25. package/dist/services/managed-processes.js +42 -6
  26. package/dist/services/run-control.js +7 -1
  27. package/dist/services/runs.js +60 -47
  28. package/dist/services/runtime-scope-stop.js +23 -2
  29. package/dist/services/stage-report-renderer.js +19 -0
  30. package/dist/services/verification-matrix.js +325 -0
  31. package/dist/services/vnext-code-review.js +25 -8
  32. package/dist/services/vnext-code.js +38 -11
  33. package/dist/services/vnext-merge.js +30 -7
  34. package/dist/services/vnext-plan-review.js +22 -6
  35. package/dist/services/vnext-plan.js +45 -17
  36. package/dist/services/work-registry.js +13 -1
  37. package/dist/storage/database.js +43 -9
  38. package/package.json +4 -1
package/CHANGELOG.md CHANGED
@@ -1,5 +1,14 @@
1
1
  # @deksden-com/dd-flow-cli
2
2
 
3
+ ## 0.9.0-beta.122
4
+
5
+ ### Patch Changes
6
+
7
+ - Generate a deterministic RUN-local verification matrix from accepted requirements, PLAN and exact check receipts; publish retained input/output packets and show their paths in shared Stage/Work instructions.
8
+ - Recognize one unambiguous lifecycle command through the shared Bash grammar instead of rejecting all shell composition; retain scoped authority, execution-directory and foreground checks.
9
+ - Share native hook deadlines through subprocess and SQLite admission, retain bounded phase diagnostics and the first ZCode failure, and prepare Stage archive/Git work outside the writer.
10
+ - Admit the tested ZCode ACP 0.52 bridge and retain explicitly configured native storage routes across daemon lifecycle calls.
11
+
3
12
  ## 0.9.0-beta.120
4
13
 
5
14
  ### Patch Changes
@@ -1,11 +1,11 @@
1
1
  {
2
2
  "cli_package": "@deksden-com/dd-flow-cli",
3
- "cli_version": "0.9.0-beta.120",
4
- "cli_commit": "a9385404a7a9fced5e4cfdcdb656e534c06d1401",
5
- "built_at": "2026-09-28T22:25:15.635Z",
3
+ "cli_version": "0.9.0-beta.122",
4
+ "cli_commit": "b03c699ac77fa596e684408c8e46e8710edbe9f6",
5
+ "built_at": "2026-09-30T01:56:09.576Z",
6
6
  "built_with_canon": {
7
- "version": "4.1.1",
8
- "commit": "678daa038287c948ada5b2d785a6dcc925c7b891",
7
+ "version": "4.1.2",
8
+ "commit": "0cf38231406614cfeb0a96001ba0201de2316e47",
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",
@@ -21,11 +21,12 @@ export async function runHookIngress(args) {
21
21
  throw new AppError("usage", "--project-root requires a value", 2);
22
22
  if (parsed.options.has("json") && parsed.options.get("json")?.[0] !== "__dd_flow_flag__")
23
23
  throw new AppError("usage", "--json does not take a value", 2);
24
- const budget = hookBudget(ingressBudgetMs);
24
+ const budget = hookBudget(ingressBudgetMs, process.env.DD_FLOW_HOOK_DEADLINE_AT ? Number(process.env.DD_FLOW_HOOK_DEADLINE_AT) : null);
25
25
  const stdin = await readNativeHookInput(process.stdin, budget);
26
26
  const payload = parseJsonObject(stdin || "{}", "native hook stdin");
27
27
  const command = lifecycleCommandFromPayload(payload);
28
- if (event !== "PreToolUse" || !command || (parseLifecycleCommand(command).kind === "none" && !continuationCommand(command))) {
28
+ const analysis = command ? parseLifecycleCommand(command) : { kind: "none", unresolved: false };
29
+ if (event !== "PreToolUse" || !command || (analysis.kind === "none" && !analysis.unresolved && !continuationCommand(command))) {
29
30
  process.stdout.write("{}\n");
30
31
  return 0;
31
32
  }
@@ -46,7 +47,7 @@ export async function runHookIngress(args) {
46
47
  // A bounded CLI subprocess, not a detached writer: its successful reply follows COMMIT.
47
48
  if (process.argv[1] === workerFile) {
48
49
  try {
49
- const stdin = await readNativeHookInput(process.stdin, hookBudget(ingressBudgetMs));
50
+ const stdin = await readNativeHookInput(process.stdin, hookBudget(ingressBudgetMs, process.env.DD_FLOW_HOOK_DEADLINE_AT ? Number(process.env.DD_FLOW_HOOK_DEADLINE_AT) : null));
50
51
  const payload = parseJsonObject(stdin, "native hook stdin");
51
52
  const started = performance.now();
52
53
  const { verifyCodexHookDelivery } = await import("../services/codex-hook-delivery.js");
@@ -1,11 +1,22 @@
1
1
  import { AppError, errorRecord } from "../shared/errors.js";
2
2
  import { parseCommandArgs } from "../services/lifecycle-command.js";
3
+ import { channel } from "node:diagnostics_channel";
3
4
  /** These ingress commands consume a prepared store; they never prepare it. */
4
5
  export function isNativeHookIngress(args) {
5
6
  return args[2] === "handle" && ((args[1] === "event" && ["agy", "grok", "droid", "opencode", "zcode"].includes(args[0] ?? ""))
6
7
  || args[0] === "zcode" && args[1] === "hook");
7
8
  }
8
9
  export async function dispatchNativeHook(args, stdin, env) {
10
+ const started = performance.now();
11
+ const deadline = env.DD_FLOW_HOOK_DEADLINE_AT === undefined ? undefined : Number(env.DD_FLOW_HOOK_DEADLINE_AT);
12
+ if (deadline !== undefined && !Number.isSafeInteger(deadline))
13
+ throw new AppError("hook_deadline_invalid", "Native hook deadline must be an absolute millisecond timestamp", 1, { effect: "unknown" });
14
+ const phase = (name) => {
15
+ if (deadline !== undefined && deadline <= Date.now())
16
+ throw new AppError("native_hook_timeout", "Native hook deadline expired", 1, { phase: name, effect: "unknown" });
17
+ process.stderr.write(`${JSON.stringify({ kind: "native_hook_phase", phase: name, elapsed_ms: Math.round(performance.now() - started), ...(deadline !== undefined ? { remaining_ms: Math.max(0, deadline - Date.now()) } : {}) })}\n`);
18
+ };
19
+ phase("import");
9
20
  const parsed = parseCommandArgs(args.slice(3));
10
21
  const projectRoot = parsed.options.get("project-root")?.[0];
11
22
  if (!isNativeHookIngress(args) || parsed.positional.length || !projectRoot || projectRoot === "__dd_flow_flag__")
@@ -16,27 +27,48 @@ export async function dispatchNativeHook(args, stdin, env) {
16
27
  const [hooks, { createContext }, { resolveDdFlowHome }] = await Promise.all([
17
28
  import("../services/hooks.js"), import("../runtime/context.js"), import("../storage/paths.js")
18
29
  ]);
30
+ phase("imported");
19
31
  let opened;
20
32
  // Reuse the actual envelope validators and shell parser. They return for
21
33
  // unrelated tools before their first DB access, without duplicating policy.
22
34
  const context = {
23
35
  env, ddFlowHome: resolveDdFlowHome(env), now: () => new Date().toISOString(),
24
- get db() { return (opened ??= createContext(env, "hook")).db; }
36
+ get db() {
37
+ if (!opened) {
38
+ phase("store_open");
39
+ opened = createContext(env, "hook");
40
+ phase("store_opened");
41
+ }
42
+ return opened.db;
43
+ }
25
44
  };
26
45
  const handler = args[0] === "agy" ? hooks.handleAgyEvent : args[0] === "grok" ? hooks.handleGrokEvent
27
46
  : args[0] === "droid" ? hooks.handleDroidEvent : args[0] === "opencode" ? hooks.handleOpenCodeEvent
28
47
  : args[1] === "hook" ? hooks.handleZcodeHook : hooks.handleZcodeEvent;
48
+ const timing = channel("dd-flow.sqlite.ingress");
49
+ // This command owns one synchronous store; there is no concurrent handler here.
50
+ let previousPhase;
51
+ const log = (value) => {
52
+ const record = value;
53
+ if (record.phase === previousPhase)
54
+ return;
55
+ previousPhase = record.phase;
56
+ process.stderr.write(`${JSON.stringify({ kind: "native_hook_phase", phase: record.phase, elapsed_ms: Math.round(performance.now() - started), ...(record.remaining_ms !== undefined ? { remaining_ms: record.remaining_ms } : {}) })}\n`);
57
+ };
58
+ timing.subscribe(log);
29
59
  try {
60
+ phase("receipt");
30
61
  return await handler(context, { projectRoot, stdin });
31
62
  }
32
63
  finally {
64
+ timing.unsubscribe(log);
33
65
  opened?.db.close?.();
34
66
  }
35
67
  }
36
68
  export async function runNativeHookIngress(args) {
37
69
  try {
38
70
  const { readNativeHookInput, hookBudget } = await import("../harness-runtime/lib/native-hook-command.mjs");
39
- const stdin = await readNativeHookInput(process.stdin, hookBudget());
71
+ const stdin = await readNativeHookInput(process.stdin, hookBudget(25_000, process.env.DD_FLOW_HOOK_DEADLINE_AT ? Number(process.env.DD_FLOW_HOOK_DEADLINE_AT) : null));
40
72
  process.stdout.write(`${JSON.stringify(await dispatchNativeHook(args, stdin, process.env))}\n`);
41
73
  return 0;
42
74
  }
@@ -15,7 +15,7 @@ async function hook(options) {
15
15
  const payload = JSON.parse(await readNativeHookInput(process.stdin, budget) || "{}"), event = options.event;
16
16
  try {
17
17
  const observationMs = budget.remaining("observation");
18
- const identity = await callDaemon(options["state-dir"], "hook.observe", { event, payload, daemonId: options["daemon-id"], deadlineAt: Date.now() + observationMs }, observationMs, requestId);
18
+ const identity = await callDaemon(options["state-dir"], "hook.observe", { event, payload, daemonId: options["daemon-id"], deadlineAt: budget.deadlineAt }, observationMs, requestId);
19
19
  if (identity.stale) return event === "PreToolUse" ? { decision: "deny", reason: "agy_stale_hook" } : {};
20
20
  if (event === "PreToolUse" || event === "PostToolUse") {
21
21
  const phase = event === "PreToolUse" ? "before" : "after", args = payload.toolCall?.args ?? {};
@@ -28,7 +28,10 @@ async function hook(options) {
28
28
  } catch (error) {
29
29
  // A failed socket cannot be used to report its own failure. The daemon
30
30
  // reads only receipts matching request IDs it actually accepted this turn.
31
- try { await writeJsonAtomic(path.join(options["state-dir"], "hook-failures", `${requestId}.json`), { request_id: requestId, observed_at: new Date().toISOString(), code: error.code ?? "agy_hook_rejected", message: error.message, details: { ...error.details, effect: "unknown" } }); }
31
+ const correctable = event === "PreToolUse" && error.code === "lifecycle_shell_syntax_invalid" && error.details?.effect === "no_effect"
32
+ && error.details?.recoverable === true && error.details?.disposition === "pre_cli_shell_refusal";
33
+ try { await writeJsonAtomic(path.join(options["state-dir"], "hook-failures", `${requestId}.json`), { request_id: requestId, observed_at: new Date().toISOString(), code: error.code ?? "agy_hook_rejected", message: error.message,
34
+ details: { ...error.details, effect: correctable ? "no_effect" : "unknown", ...(correctable ? { native_pre_cli_refusal: true } : {}) } }); }
32
35
  catch (storage) { process.stderr.write(`${JSON.stringify({ code: error.code, receipt_error: storage.message })}\n`); }
33
36
  throw error;
34
37
  }
@@ -49,4 +52,4 @@ try {
49
52
  else if (family === "session") { if (command === "fork") throw Object.assign(new Error("Antigravity native headless fork is unsupported; use deterministic_replay"), { code: "agy_headless_fork_unsupported" }); const operation = command === "status" ? "inspect" : command; result = await callDaemon(common.stateDir, `session.${operation}`, { sessionId: options["session-id"], ...(command === "create" || command === "prompt" ? { prompt: await prompt(options) } : {}), tree: options.tree === true }, common.timeoutMs ?? 1_800_000); }
50
53
  else throw new Error("usage: dd-agy doctor | hooks install|check | daemon start|status|stop | session create|prompt|inspect|resume|cancel|fork [options] --json");
51
54
  process.stdout.write(`${JSON.stringify({ ok: true, ...result })}\n`);
52
- } catch (error) { if (process.argv.includes("hook") && process.argv.includes("handle")) { process.stdout.write(`${JSON.stringify({ decision: "deny", reason: error.code ?? "agy_hook_rejected" })}\n`); process.exit(0); } process.stderr.write(`${JSON.stringify({ ok: false, error: error.message, ...(error.code ? { code: error.code, retryable: error.retryable === true, details: error.details } : {}) })}\n`); process.exit(1); }
55
+ } catch (error) { if (process.argv.includes("hook") && process.argv.includes("handle")) { process.stdout.write(`${JSON.stringify({ decision: "deny", reason: `${error.code ?? "agy_hook_rejected"}: ${error.message ?? "Native hook refused this tool call"}` })}\n`); process.exit(0); } process.stderr.write(`${JSON.stringify({ ok: false, error: error.message, ...(error.code ? { code: error.code, retryable: error.retryable === true, details: error.details } : {}) })}\n`); process.exit(1); }
@@ -404,6 +404,21 @@ export class Runtime {
404
404
  }
405
405
  async rejectHook(error, event, payload, current = this.active) {
406
406
  if (current && this.active !== current) return;
407
+ const correctable = current && event === "PreToolUse" && error.code === "lifecycle_shell_syntax_invalid"
408
+ && error.details?.effect === "no_effect" && error.details?.recoverable === true
409
+ && error.details?.disposition === "pre_cli_shell_refusal" && error.details?.native_pre_cli_refusal === true;
410
+ if (correctable && current.hookCorrectionGeneration !== current.generation) {
411
+ // The request was correlated with this still-active Turn by
412
+ // observeHookFailures. Persist before allowing native same-Turn recovery.
413
+ const correction = { code: error.code, message: error.message, details: error.details, event, conversation_id: payload.conversationId ?? null,
414
+ daemon_id: this.config.daemonId, operation_id: current.operationId, turn_generation: current.generation };
415
+ try {
416
+ await this.journal("hook_correction", { correction });
417
+ await this.persist({ last_hook_correction: correction });
418
+ current.hookCorrectionGeneration = current.generation;
419
+ return;
420
+ } catch (storage) { error = Object.assign(new Error("Could not persist native hook correction"), { code: "native_hook_receipt_invalid", details: { effect: "unknown", primary_error: correction, receipt_error: storage.message } }); }
421
+ }
407
422
  const rejection = this.state.last_hook_rejection && this.state.last_hook_rejection.turn_generation === current?.generation ? this.state.last_hook_rejection : { code: error.code ?? "agy_hook_rejected", message: error.message ?? String(error), details: error.details, event, conversation_id: payload.conversationId ?? null, tool: payload.toolCall?.name ?? null, daemon_id: this.config.daemonId, operation_id: current?.operationId ?? null, turn_generation: current?.generation ?? null };
408
423
  this.state.last_hook_rejection = rejection;
409
424
  if (current && this.active === current) { this.active = null; current.reject(new DaemonError(rejection.code, rejection.message, false, rejection)); }
@@ -417,6 +432,7 @@ export class Runtime {
417
432
  try { failure = JSON.parse(await readFile(path.join(this.paths.dir, "hook-failures", `${requestId}.json`), "utf8")); }
418
433
  catch (error) { if (error.code === "ENOENT") continue; failure = { code: "native_hook_receipt_invalid", message: error.message }; }
419
434
  if (failure.request_id !== undefined && failure.request_id !== requestId) continue;
435
+ this.hookRequests.delete(requestId);
420
436
  await this.rejectHook(Object.assign(new Error(failure.message ?? "Native hook failed"), { code: failure.code, details: failure.details }), binding.event, binding.payload, binding.current);
421
437
  }
422
438
  }
@@ -315,7 +315,7 @@ export class Runtime {
315
315
  // creation/end_turn receipt can then establish an idle root. Dispatch
316
316
  // clears that receipt before sending any subsequent productive request.
317
317
  const receipt = this.sessions.get(result?.provider_session_id)?.native_root_receipt;
318
- if (status === "unknown" && ["created", "end_turn", "cancelled"].includes(receipt?.kind)) return false;
318
+ if (status === "unknown" && ["created", "end_turn", "cancelled", "failed"].includes(receipt?.kind)) return false;
319
319
  return !["completed", "failed", "cancelled", "stopped", "idle"].includes(status);
320
320
  }
321
321
  async refreshTree(cancel = false) {
@@ -356,7 +356,17 @@ export class Runtime {
356
356
  const running = name === "session.archive" ? false : this.treeRunning(result);
357
357
  await this.persist({ active_tree: running, active_operation: null });
358
358
  return { ...result, descendants: this.topology(result.provider_session_id), settled: !running };
359
- } catch (error) { await this.persist({ active_tree: true, active_operation: null }); throw error; }
359
+ } catch (error) {
360
+ // A matched JSON-RPC error completes this native prompt request. It is
361
+ // root-turn proof, not proof that any descendants have stopped. A lost
362
+ // response, bridge error, or failure before dispatch earns no receipt.
363
+ const response = error?.details?.terminal_response;
364
+ if (name === "session.prompt" && response?.method === "session/prompt" && response.session_id === sessionId) {
365
+ const session = this.sessions.get(sessionId);
366
+ if (session) session.native_root_receipt = { kind: "failed", daemon_id: this.state.daemon_id, request_id: response.request_id };
367
+ }
368
+ await this.persist({ active_tree: true, active_operation: null }); throw error;
369
+ }
360
370
  finally { this.active = null; }
361
371
  }
362
372
  async dispatch(operation, params) {
@@ -124,8 +124,19 @@ export async function callDaemon(stateDir, operation, params = {}, timeoutMs = 3
124
124
  });
125
125
  }
126
126
 
127
+ const ZCODE_ROUTING_ENV = ["ZCODE_HOME", "ZCODE_STORAGE_DIR", "ZCODE_SESSION_DB_PATH"];
128
+
129
+ function nativeRoutingEnv() {
130
+ return Object.fromEntries(ZCODE_ROUTING_ENV.filter(key => process.env[key] !== undefined)
131
+ .map(key => [key, absolute(process.env[key], key)]));
132
+ }
133
+
134
+ function sameNativeRouting(previous, config) {
135
+ return ZCODE_ROUTING_ENV.every(key => (previous?.env?.[key] ?? null) === (config.env?.[key] ?? null));
136
+ }
137
+
127
138
  function sameConfig(state, config) {
128
- return ["cwd", "journal", "bin", "zcodePath", "ddFlowBin", "ddFlowHome", "projectRoot", "resourceHome", "livenessTimeoutMs", "requestTimeoutMs"].every((key) => (state.config?.[key] ?? null) === (config[key] ?? null));
139
+ return sameNativeRouting(state.config, config) && ["cwd", "journal", "bin", "zcodePath", "ddFlowBin", "ddFlowHome", "projectRoot", "resourceHome", "livenessTimeoutMs", "requestTimeoutMs"].every((key) => (state.config?.[key] ?? null) === (config[key] ?? null));
129
140
  }
130
141
 
131
142
  export async function startDaemon(options) {
@@ -145,7 +156,7 @@ export async function startDaemon(options) {
145
156
  ddFlowHome: options.ddFlowHome ? absolute(options.ddFlowHome, "--dd-flow-home") : null,
146
157
  projectRoot: options.projectRoot ? absolute(options.projectRoot, "--project-root") : null,
147
158
  resourceHome: process.env.DD_FLOW_RESOURCE_HOME ? absolute(process.env.DD_FLOW_RESOURCE_HOME, "DD_FLOW_RESOURCE_HOME") : null,
148
- env: options.ddFlowHome ? { DD_FLOW_HOME: absolute(options.ddFlowHome, "--dd-flow-home"), ...(options.ddFlowBin ? { DD_FLOW_BIN: absolute(options.ddFlowBin, "--dd-flow-bin") } : {}) } : {},
159
+ env: { ...nativeRoutingEnv(), ...(options.ddFlowHome ? { DD_FLOW_HOME: absolute(options.ddFlowHome, "--dd-flow-home"), ...(options.ddFlowBin ? { DD_FLOW_BIN: absolute(options.ddFlowBin, "--dd-flow-bin") } : {}) } : {}) },
149
160
  livenessTimeoutMs: positiveDuration(options.livenessTimeoutMs, "livenessTimeoutMs", DEFAULT_LIVENESS_TIMEOUT_MS),
150
161
  requestTimeoutMs: optionalDuration(options.requestTimeoutMs, "requestTimeoutMs", DEFAULT_REQUEST_TIMEOUT_MS),
151
162
  };
@@ -159,7 +170,10 @@ export async function startDaemon(options) {
159
170
  } catch (error) { if (error.code !== "daemon_not_running" && error.code !== "daemon_timeout") throw error; }
160
171
 
161
172
  const previous = await readState(paths.dir);
162
- if (previous?.shutdown_state === "clean") await authorizeRetainedDaemonResume(paths.dir, previous, options.sessionId, config);
173
+ if (previous?.shutdown_state === "clean") {
174
+ if (!sameNativeRouting(previous.config, config)) throw new DaemonError("daemon_config_mismatch", "retained ZCode native storage routing changed");
175
+ await authorizeRetainedDaemonResume(paths.dir, previous, options.sessionId, config);
176
+ }
163
177
  if (previous?.shutdown_state === "running" && previous.active_tree) {
164
178
  throw new DaemonError("invalid_harness_crash", "the previous daemon died with an active or unproven Session tree", false, { daemon_id: previous.daemon_id });
165
179
  }
@@ -237,6 +251,7 @@ export class DaemonRuntime {
237
251
  const generation = this.activeGeneration ?? this.controlGeneration ?? 0;
238
252
  return {
239
253
  ...this.state.config, ...params, knownChildSessionIds: known?.known_child_session_ids ?? [], adapterSessionId: adapter,
254
+ neverPromptedRootProof: this.neverPromptedRootProof(params.sessionId),
240
255
  daemonId: this.state.daemon_id, allowBackground: true, liveSession: this.liveAdapters.has(adapter),
241
256
  assertDispatch: () => { if (generation !== (this.controlGeneration ?? 0)) throw new DaemonError("operation_cancelled", "ZCode prompt was cancelled before native dispatch"); },
242
257
  // The bridge owns the quiet timer; the daemon owns the durable status.
@@ -304,6 +319,12 @@ export class DaemonRuntime {
304
319
  // A late identity is evidence, not permission to resurrect a completed
305
320
  // request or overwrite another operation's ownership.
306
321
  this.track(identity);
322
+ const allocated = this.sessions.get(identity.provider_session_id);
323
+ if (allocated && this.activeProductive === "session.create" && current?.allocation?.adapter_session_id === identity.adapter_session_id
324
+ && allocated.prompt_dispatched === undefined) {
325
+ allocated.prompt_dispatched = false;
326
+ allocated.created_by_daemon_id = this.state.daemon_id;
327
+ }
307
328
  if (!current || current.allocation?.adapter_session_id !== identity.adapter_session_id) {
308
329
  await this.persist({});
309
330
  return;
@@ -344,6 +365,8 @@ export class DaemonRuntime {
344
365
  topology: topology ?? existing?.topology ?? null,
345
366
  known_child_session_ids: [...new Set([...(existing?.known_child_session_ids ?? zcodeChildSessionIds(existing?.topology)), ...zcodeChildSessionIds(topology)])],
346
367
  completed_turns: existing?.completed_turns ?? 0,
368
+ prompt_dispatched: existing?.prompt_dispatched,
369
+ created_by_daemon_id: existing?.created_by_daemon_id,
347
370
  close_receipt: result.close?.closed === true ? result : existing?.close_receipt ?? null,
348
371
  }); }
349
372
  if (result?.adapter_session_id) this.liveAdapters.add(result.adapter_session_id);
@@ -397,10 +420,9 @@ export class DaemonRuntime {
397
420
 
398
421
  async requireSettled(params) {
399
422
  if (this.sessions.get(params.sessionId)?.close_receipt) throw new DaemonError("session_closed", "Closed Session requires explicit recovery");
400
- const known = this.sessions.get(params.sessionId);
401
423
  // A freshly created ZCode Session has no prior Turn and is not yet exposed
402
424
  // by the provider's subagent index. Its first prompt is therefore safe.
403
- if (known && (known.completed_turns ?? 0) === 0) return null;
425
+ if (this.neverPromptedRootProof(params.sessionId)) return null;
404
426
  const observed = await inspectSessionWithBridge(this.bridge, this.options(params));
405
427
  const runningChildren = observed.subagents?.running ?? [];
406
428
  if (this.treeRunning(observed)) {
@@ -410,6 +432,21 @@ export class DaemonRuntime {
410
432
  return observed;
411
433
  }
412
434
 
435
+ neverPromptedRootProof(sessionId) {
436
+ const known = this.sessions.get(sessionId);
437
+ return known?.prompt_dispatched === false && known.created_by_daemon_id === this.state.daemon_id
438
+ && known.parent_provider_session_id === null && known.root_provider_session_id === sessionId
439
+ && Array.isArray(known.known_child_session_ids) && known.known_child_session_ids.length === 0
440
+ ? { source: "owned_daemon_create_without_prompt", daemon_id: this.state.daemon_id, provider_session_id: sessionId }
441
+ : null;
442
+ }
443
+
444
+ async markPromptDispatched(sessionId) {
445
+ const known = this.sessions.get(sessionId);
446
+ if (known) known.prompt_dispatched = true;
447
+ await this.persist();
448
+ }
449
+
413
450
  async dispatch(operation, params) {
414
451
  if (operation === "daemon.status") return {
415
452
  daemon_id: this.state.daemon_id, pid: process.pid, socket: this.paths.socket,
@@ -447,11 +484,11 @@ export class DaemonRuntime {
447
484
  dispatched_at: new Date().toISOString(),
448
485
  request_timeout_ms: this.state.config.requestTimeoutMs,
449
486
  };
450
- const result = await this.productive(operation, async () => { await this.requireSettled(params); return await promptSessionWithBridge(this.bridge, { ...this.options(params), timeoutMs: request.request_timeout_ms }); }, request);
487
+ const result = await this.productive(operation, async () => { await this.requireSettled(params); await this.markPromptDispatched(params.sessionId); return await promptSessionWithBridge(this.bridge, { ...this.options(params), timeoutMs: request.request_timeout_ms }); }, request);
451
488
  const session = this.sessions.get(result.provider_session_id); if (session) session.completed_turns = (session.completed_turns ?? 0) + 1;
452
489
  await this.persist(); return result;
453
490
  }
454
- if (operation === "session.fork") return await this.productive(operation, async () => { await this.requireSettled(params); return await forkSessionWithBridge(this.bridge, this.options(params)); });
491
+ if (operation === "session.fork") return await this.productive(operation, async () => { await this.requireSettled(params); await this.markPromptDispatched(params.sessionId); return await forkSessionWithBridge(this.bridge, this.options(params)); });
455
492
  if (operation === "session.inspect" || operation === "session.resume") {
456
493
  const closed = this.sessions.get(params.sessionId)?.close_receipt;
457
494
  if (closed) {
@@ -467,7 +504,8 @@ export class DaemonRuntime {
467
504
  && Array.isArray(receipt.topology.child_session_ids)) return receipt;
468
505
  }
469
506
  const known = [...new Set([...topology.child_session_ids, ...(previous?.topology?.child_session_ids ?? [])])];
470
- const observation = { ...closed, ...await observeClosedZcodeTree(this.bridge, params.sessionId, known), observed_at: new Date().toISOString() };
507
+ const emptyRoot = this.neverPromptedRootProof(params.sessionId);
508
+ const observation = { ...closed, ...await observeClosedZcodeTree(this.bridge, params.sessionId, known, emptyRoot && closed.close?.closed === true ? {...emptyRoot,close_confirmed:true} : null), observed_at: new Date().toISOString() };
471
509
  // Observations are not ownership inventory: their timestamps must not
472
510
  // invalidate a drain fence on every read.
473
511
  this.state.settlement_observations ??= {};
@@ -525,6 +563,20 @@ export class DaemonRuntime {
525
563
  }
526
564
  const options = this.options({ sessionId: session.provider_session_id, adapterSessionId: session.adapter_session_id });
527
565
  let observed;
566
+ // A daemon-owned allocation with no productive dispatch cannot have
567
+ // children, and native ZCode has no durable read index for it yet. Close
568
+ // that resident directly; do not send cancellation for default stop.
569
+ const emptyRoot = !cancel && !this.activeProductive ? this.neverPromptedRootProof(session.provider_session_id) : null;
570
+ if (emptyRoot) {
571
+ const close = await this.bridge.request("zcode/session/close", { sessionId: session.adapter_session_id });
572
+ const observation = close?.closed === true
573
+ ? await observeClosedZcodeTree(this.bridge, session.provider_session_id, [], { ...emptyRoot, close_confirmed: true })
574
+ : { settled: false, topology: { root_session_id: session.provider_session_id, child_session_ids: [], complete: false } };
575
+ this.track({ provider_session_id: session.provider_session_id, adapter_session_id: session.adapter_session_id, cancellation_requested: false, close_requested: true, close, ...observation });
576
+ await this.persist({ active_tree: observation.settled !== true });
577
+ if (observation.settled !== true) unsettled.push(session.provider_session_id);
578
+ continue;
579
+ }
528
580
  if (!cancel) observed = await inspectSessionWithBridge(this.bridge, options);
529
581
  if (cancel || !observed || this.treeRunning(observed)) {
530
582
  if (!cancel) unsettled.push(session.provider_session_id);
@@ -1,6 +1,7 @@
1
1
  import { observedTimeout } from "./observation-clock.mjs";
2
2
  import { ToolObservations, acpToolObservation, zcodePhysicalSession } from "./tool-observations.mjs";
3
3
  import { invokeNativeHook } from "./native-hook-command.mjs";
4
+ import { parseLifecycleCommand, continuationCommand } from "./lifecycle-command.mjs";
4
5
  import { spawn } from "node:child_process";
5
6
  import { createHash } from "node:crypto";
6
7
  import { appendFile, mkdir, readFile, writeFile } from "node:fs/promises";
@@ -19,7 +20,7 @@ const LATE_RESPONSE_GRACE_MS = 5_000;
19
20
 
20
21
  function fail(message, code) { const error = new Error(message); if (code) error.code = code; throw error; }
21
22
  function fatalLifecycleError(error) {
22
- 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);
23
+ 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", "native_hook_timeout", "native_hook_failed", "native_hook_response_invalid", "hook_storage_unprepared", "storage_write_failed"].includes(error?.code);
23
24
  }
24
25
  function absolute(value, label) { if (!value || !path.isAbsolute(value)) fail(`${label} must be an absolute path`); return path.resolve(value); }
25
26
  function text(value, label) { if (typeof value !== "string" || !value.trim()) fail(`${label} is required`); return value.trim(); }
@@ -233,13 +234,14 @@ export class AcpBridge {
233
234
  // A validated no-effect rejection is returned through the invocation
234
235
  // ledger. It is evidence, not a poisoned ACP Session.
235
236
  const fatal = fatalLifecycleError(error);
236
- if (!this.notificationError || fatal) this.notificationError = error;
237
+ const current = this.options.onLifecycleNotification === observer;
238
+ if (current && (!this.notificationError || fatal && !fatalLifecycleError(this.notificationError) && this.notificationError.code !== "profile_integrity_violation")) this.notificationError = error;
237
239
  // A missing/foreign authority is not a recoverable CLI validation
238
240
  // result. Stop admitting productive requests immediately; the managed
239
241
  // controller still owns physical stop/drain and evidence capture.
240
- if (fatal) this.rejectAll(error);
242
+ if (fatal && current) this.rejectAll(this.notificationError ?? error);
241
243
  try {
242
- await this.journal.write("notification_failure", { code: error.code ?? "invocation_observation_failed", message: error.message,
244
+ await this.journal.write("notification_failure", { code: error.code ?? "invocation_observation_failed", message: error.message, ...(error.details ? { details: error.details } : {}),
243
245
  session_id: message.params?.sessionId ?? null, tool_call_id: update?.toolCallId ?? null, event_time: now(), delivery_stage: "lifecycle_receipt", completeness: "incomplete" });
244
246
  } catch (persistenceError) {
245
247
  process.stderr.write(`${JSON.stringify({ code: error.code, message: error.message, persistence_error: persistenceError.message })}\n`);
@@ -270,7 +272,9 @@ export class AcpBridge {
270
272
  const unknown = ["native_timeout", "native_backend_dead", "native_backend_pipe_broken", "native_outcome_unknown"].includes(nativeCode);
271
273
  error.code = unknown ? nativeCode : /payment required|quota|usage balance exhausted|insufficient.?credit|billing/i.test(messageText) ? "provider_quota_exhausted" : /rate.?limit|too many requests/i.test(messageText) ? "provider_rate_limited" : /\b429\b/.test(messageText) ? "provider_limit_unknown" : "acp_request_failed";
272
274
  error.retryable = error.code === "provider_rate_limited";
273
- error.details = { acp_code: message.error.code ?? null, ...(data ? { native: data } : {}), ...(details ? { provider_details: details } : {}), ...(providerDetails ? { provider_update: provider } : {}) };
275
+ error.details = { acp_code: message.error.code ?? null,
276
+ terminal_response: { method: pending.method, session_id: pending.sessionId, request_id: message.id },
277
+ ...(data ? { native: data } : {}), ...(details ? { provider_details: details } : {}), ...(providerDetails ? { provider_update: provider } : {}) };
274
278
  pending.reject(error);
275
279
  }
276
280
  else { pending.resolve(message.result ?? {}); }
@@ -279,7 +283,7 @@ export class AcpBridge {
279
283
  if (message.method) this.notifications = this.notifications
280
284
  .then(async () => { await this.observeModelUpdate(message); await this.options.onNotification?.(message); })
281
285
  .catch(async (error) => {
282
- const failure = { code: error.code ?? "notification_observation_failed", message: error.message, session_id: message.params?.sessionId ?? null, tool_call_id: message.params?.update?.toolCallId ?? null, event_time: now(), delivery_stage: "acp_evidence", completeness: "incomplete" };
286
+ const failure = { code: error.code ?? "notification_observation_failed", message: error.message, ...(error.details ? { details: error.details } : {}), session_id: message.params?.sessionId ?? null, tool_call_id: message.params?.update?.toolCallId ?? null, event_time: now(), delivery_stage: "acp_evidence", completeness: "incomplete" };
283
287
  try { await this.journal.write("notification_failure", failure); }
284
288
  catch (persistenceError) { process.stderr.write(`${JSON.stringify({ ...failure, persistence_error: persistenceError.message })}\n`); }
285
289
  // Evidence loss does not retroactively revoke a committed native hook.
@@ -549,7 +553,7 @@ export function zcodeChildSessionIds(snapshot) {
549
553
  // Read residency BEFORE each durable topology snapshot. An inactive node cannot
550
554
  // add descendants; a resident node never proves settlement, even when idle.
551
555
  // No resolve/read/subagents extension here: those may implicitly resume.
552
- export async function observeClosedZcodeTree(bridge, root, retainedChildren = []) {
556
+ export async function observeClosedZcodeTree(bridge, root, retainedChildren = [], neverPromptedRootProof = null) {
553
557
  const pending = new Set([root, ...retainedChildren]), residents = [], snapshots = [];
554
558
  const deadline = performance.now() + 30_000;
555
559
  try {
@@ -559,6 +563,15 @@ export async function observeClosedZcodeTree(bridge, root, retainedChildren = []
559
563
  const resident = await bridge.request("zcode/session/resident", { sessionId }, timeout());
560
564
  if (resident?.sessionId !== sessionId || typeof resident.resident !== "boolean") throw new Error("Invalid residency identity or state");
561
565
  residents.push(resident);
566
+ // A newly allocated root is not durable in ZCode until its first prompt.
567
+ // This is an explicit owned-daemon ledger fact, never error→empty inference.
568
+ if (sessionId === root && resident.resident === false && pending.size === 1
569
+ && neverPromptedRootProof?.source === "owned_daemon_create_without_prompt"
570
+ && neverPromptedRootProof.close_confirmed === true
571
+ && neverPromptedRootProof.provider_session_id === root && typeof neverPromptedRootProof.daemon_id === "string") {
572
+ snapshots.push({ sessionId, childSessionIds: [], running: [], ended: {total:0,items:[]}, provenance: neverPromptedRootProof });
573
+ continue;
574
+ }
562
575
  const tree = await readZcodeSubagents(bridge, sessionId, deadline - performance.now(), true);
563
576
  if (!Array.isArray(tree?.childSessionIds)
564
577
  || tree.childSessionIds.some(id => typeof id !== "string" || !id || id === sessionId)
@@ -575,7 +588,7 @@ export async function observeClosedZcodeTree(bridge, root, retainedChildren = []
575
588
  }
576
589
  }
577
590
 
578
- async function cancelTree(bridge, sessionId, before, providerSessionId, retainedChildren = []) {
591
+ async function cancelTree(bridge, sessionId, before, providerSessionId, retainedChildren = [], neverPromptedRootProof = null) {
579
592
  const knownChildren = new Set(retainedChildren);
580
593
  const remember = snapshot => {
581
594
  for (const id of zcodeChildSessionIds(snapshot)) knownChildren.add(id);
@@ -614,7 +627,7 @@ async function cancelTree(bridge, sessionId, before, providerSessionId, retained
614
627
  let close = null;
615
628
  try { close = await bridge.request("zcode/session/close", { sessionId }); }
616
629
  catch (error) { close = { closed: false, error: { code: error.code ?? "close_failed", message: error.message } }; }
617
- const observation = close?.closed === true ? await observeClosedZcodeTree(bridge, providerSessionId, [...knownChildren])
630
+ const observation = close?.closed === true ? await observeClosedZcodeTree(bridge, providerSessionId, [...knownChildren], neverPromptedRootProof ? {...neverPromptedRootProof,close_confirmed:true} : null)
618
631
  : { topology: { root_session_id: providerSessionId, child_session_ids: [...knownChildren], complete: false }, settled: false };
619
632
  return { cancellations, before, after, root_status, cancellation_requested: true, close_requested: true, close, ...observation };
620
633
  }
@@ -711,9 +724,8 @@ function flowForwarder(options, rootProviderSessionId, bridge) {
711
724
  const childId = notification.params?.update?._meta?.zcodeRuntime?.childSessionId;
712
725
  const observed = bridge?.modelProfiles?.get(childId ?? rootProviderSessionId) ?? {};
713
726
  if (childId && !observed.model) await observeModel({ journal: options.journal, harness: "zcode-acp", sessionId: childId, parentSessionId: rootProviderSessionId, requested: profile(options), observed, source: "zcode.child_tool_event", evidence: "unavailable" });
714
- // Native PreToolUse has already admitted (or denied) the command. ACP is
715
- // intentionally only journal/model evidence: its serialized delivery can
716
- // lag behind execution and must never mint a second lifecycle receipt.
727
+ // This serialized path is model evidence only. The independent observer
728
+ // below owns qualified ACP admission; do not mint a second receipt here.
717
729
  };
718
730
  }
719
731
 
@@ -751,6 +763,8 @@ export async function zcodeInvocationObserver(options, rootProviderSessionId, se
751
763
  const command = update.rawInput?.command ?? update.rawInput?.cmd;
752
764
  const toolName = update._meta?.claudeCode?.toolName;
753
765
  if (!["Bash", "bash", "execute"].includes(toolName) || typeof command !== "string") return;
766
+ const analysis = parseLifecycleCommand(command);
767
+ if (analysis.kind === "none" && !analysis.unresolved && !continuationCommand(command)) return;
754
768
  const parent = session === rootProviderSessionId ? null : owners.get(native.parentToolCallId);
755
769
  if (session !== rootProviderSessionId && !parent) throw Object.assign(new Error("Native child has no confirmed immediate parent tool owner"), { code: "zcode_parent_identity_missing" });
756
770
  await send({ bin: options.ddFlowBin, home: options.ddFlowHome,
@@ -806,6 +820,7 @@ export function zcodeLifecycleQualification(sha256, bridgeCommit, harnessContrac
806
820
  ["91a1470650f5fd6105f9863fd31d1cad7b90a109", "dd-zcode-harness@2"],
807
821
  ["b9e0f5106dddb439b76186278bd9be5f45dbf3ef", "dd-zcode-harness@2"],
808
822
  ["636b14181d5c9a95476055079836fd6b58169f58", "dd-zcode-harness@2"],
823
+ ["4621fabd3f517ecdd349dfebebae9077c01d2f2b", "dd-zcode-harness@2"],
809
824
  ]);
810
825
  const qualified = qualifiedBridgeCommits.get(bridgeCommit) === harnessContract;
811
826
  return { status: qualified ? "qualified" : "unqualified", mechanism: "cli-invocation@1", sha256,
@@ -963,7 +978,7 @@ export async function cancelSessionWithBridge(bridge, options) {
963
978
  requireJournal(options);
964
979
  const identity = await controlledIdentity(bridge, options);
965
980
  const before = await readZcodeSubagents(bridge, identity.adapterSessionId).catch(error => ({ observation_error: { code: error.code, message: error.message } }));
966
- return receipt(options, { harness: "zcode-acp", provider_session_id: identity.providerSessionId, adapter_session_id: identity.adapterSessionId, ...await cancelTree(bridge, identity.adapterSessionId, before, identity.providerSessionId, options.knownChildSessionIds) });
981
+ return receipt(options, { harness: "zcode-acp", provider_session_id: identity.providerSessionId, adapter_session_id: identity.adapterSessionId, ...await cancelTree(bridge, identity.adapterSessionId, before, identity.providerSessionId, options.knownChildSessionIds, options.neverPromptedRootProof) });
967
982
  }
968
983
 
969
984
  export async function forkSession(options) {
@@ -56,7 +56,7 @@ export const lifecycleRetryInstruction = `${lifecycleCommandCompletionInstructio
56
56
  export const stageLifecycleInstruction = [
57
57
  "The project root identifies this RUN; use the Stage packet's declared execution workspace and absolute artifact paths. Do not change a native Session's working directory to a workspace provisioned for a later Stage.",
58
58
  "In a controller-managed fanout, the coordinator ends the entry Turn after the Work graph is returned; the controller dispatches children. A completed child or Work is not Stage success: settle the declared graph and Stage gate before finishing.",
59
- "Execute each runtime-issued lifecycle operation as a standalone call. Invocation IDs are internal authority; never add, copy or invent one.",
59
+ "Execute each runtime-issued lifecycle operation and its arguments exactly. One literal lifecycle call may be composed with ordinary shell commands; preserve conditions, stdin and redirects, and never replay the surrounding shell chain after failure. Invocation IDs are internal authority; never add, copy or invent one.",
60
60
  lifecycleRetryInstruction
61
61
  ].join(" ");
62
62
 
@@ -68,13 +68,13 @@ export function workerDelegationTask({ workId, startCommand, launchPolicy }) {
68
68
  return [
69
69
  `Complete one already-declared Work: ${workId}.`,
70
70
  context,
71
- "Your first technical action must be this exact standalone lifecycle command:",
71
+ "Your first technical action must be this exact lifecycle command:",
72
72
  startCommand,
73
73
  "Execute the issued operation and target exactly. Lifecycle invocation IDs are internal runtime authority and are intentionally omitted; never add or invent them. Preserve any explicit native receipt handle that the command does contain.",
74
74
  "Use the native shell's inherited working directory. Do not set or override a workdir/cwd field for this lifecycle command.",
75
75
  "Continue only if it succeeds and returns the authoritative Work packet.",
76
76
  lifecycleRetryInstruction,
77
- "After a successful start, complete the assigned Work, write its required result, invoke its exact standalone work finish command, then stop.",
77
+ "After a successful start, complete the assigned Work, write its required result, invoke its exact work finish command, then stop.",
78
78
  "Do not start another Work, create a child, change dependencies, ask the user, or treat a quiet sibling as failed. You cannot ask the user or pause the parent Stage. Missing facts go through this Work's declared result/failure contract; only the coordinator handles Stage HITL."
79
79
  ].join("\n");
80
80
  }
@@ -0,0 +1,20 @@
1
+ import type { LifecycleOperation } from "./lifecycle-contract.mjs";
2
+ export type { LifecycleOperation } from "./lifecycle-contract.mjs";
3
+ export interface ParsedCommandArgs { positional: string[]; options: Map<string, string[]> }
4
+ export interface LifecycleInvocation {
5
+ operation: LifecycleOperation; executable: string; launcher?: string; argv: string[]; env: Record<string, string>; args: ParsedCommandArgs; command: string;
6
+ rewriteSafe: boolean; wrapped: boolean; heredoc: boolean; sourceRange?: { start: number; end: number }; contexts?: string[]; expectedCwd?: string;
7
+ }
8
+ export type LifecycleAnalysis =
9
+ | { kind: "none"; unresolved?: true; reason?: string }
10
+ | { kind: "standalone"; invocation: LifecycleInvocation; supported?: boolean; invocations?: LifecycleInvocation[] }
11
+ | { kind: "compound"; invocation: LifecycleInvocation; reason: string; supported?: boolean; invocations?: LifecycleInvocation[] };
12
+ export function lifecycleCommandFromPayload(payload: { command?: unknown; tool_input?: unknown }): string | undefined;
13
+ export function parseLifecycleCommand(command: string): LifecycleAnalysis;
14
+ export function continuationCommand(command: string): { operation: string; args: ParsedCommandArgs } | null;
15
+ export function unwrapShellCommand(command: string): string;
16
+ export function parseCommandArgs(argv: string[]): ParsedCommandArgs;
17
+ export function commandOption(invocation: LifecycleInvocation, name: string): string | undefined;
18
+ export function commandHasOption(invocation: LifecycleInvocation, name: string): boolean;
19
+ export function commandPosition(invocation: LifecycleInvocation, index: number): string | undefined;
20
+ export function shellSuffixOffset(command: string): number;