@deksden-com/dd-flow-cli 0.9.0-beta.87 → 0.9.0-beta.88

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,13 @@
1
1
  # @deksden-com/dd-flow-cli
2
2
 
3
+ ## 0.9.0-beta.88
4
+
5
+ ### Patch Changes
6
+
7
+ - 5f3806c: Keep managed HITL answer paths inside retained runtime authority, preserve early
8
+ native CLI failures, and fail controllers conclusively when an answer turn does
9
+ not resume its assigned pause.
10
+
3
11
  ## 0.9.0-beta.87
4
12
 
5
13
  ### Patch Changes
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "cli_package": "@deksden-com/dd-flow-cli",
3
- "cli_version": "0.9.0-beta.87",
4
- "cli_commit": "68245785d197ab491e80db40faf2e57a18a6a055",
5
- "built_at": "2026-09-20T22:46:39.592Z",
3
+ "cli_version": "0.9.0-beta.88",
4
+ "cli_commit": "6cd7da3104a366d4b2866fa31ef8635949e79143",
5
+ "built_at": "2026-09-21T00:22:16.324Z",
6
6
  "built_with_canon": {
7
7
  "version": "4.1.1",
8
8
  "commit": "acaae844a871ddfbc49fd66355373f166dc3e683",
package/dist/cli/help.js CHANGED
@@ -233,6 +233,8 @@ Usage:
233
233
  dd-flow stage start --bootstrap --project-root <root> --stage specify --subject <safe-subject> (--intake-file <path>|--intake-stdin) [--require-session-binding] --json
234
234
  dd-flow stage pause <RUN-ID|RUN-short-id> --project-root <root> --stage <name> --work <WORK-ID> (--question-file <path>|--question-stdin) --json
235
235
  dd-flow stage resume <RUN-ID|RUN-short-id> --project-root <root> --stage <name> --work <WORK-ID> (--answer-file <path>|--answer-stdin) --json
236
+
237
+ Managed resume commands restore the accepted answer file from runtime state. Execute the returned command without adding an answer path or recreating the answer. The explicit answer flags above are for unmanaged calls.
236
238
  dd-flow stage block <RUN-ID|RUN-short-id> --project-root <root> --stage <name> --work <WORK-ID> --kind engine|harness|environment --code <code> (--summary-file <path>|--summary-stdin) [--retryable] --json
237
239
  dd-flow stage unblock <RUN-ID|RUN-short-id> --project-root <root> --stage <name> --work <WORK-ID> --json
238
240
  dd-flow stage fanout status <RUN-ID|RUN-short-id> --project-root <root> --stage <name> --json
@@ -2,7 +2,7 @@ import fs from "node:fs";
2
2
  import path from "node:path";
3
3
  import { randomUUID } from "node:crypto";
4
4
  import { quote } from "shell-quote";
5
- import { observeLifecycleCommand } from "../services/lifecycle-invocations.js";
5
+ import { observeLifecycleCommand, observedLifecycleNativeEvent } from "../services/lifecycle-invocations.js";
6
6
  import { verifyCodexHookDelivery } from "../services/codex-hook-delivery.js";
7
7
  import { assertLifecycleInvocationCurrent, awaitLifecycleInvocation, expandLifecycleInvocationArgs, issueLifecycleInvocation, prepareLifecycleIssuance, lifecycleInvocationScope, observedLifecycleDiagnostic, observedLifecycleInvocation, resolveObservedLifecycleArgs, settleLifecycleInvocation, settleLifecyclePreparationRejection, settleLifecycleRejection, validateLifecyclePathAliases } from "../services/lifecycle-invocations.js";
8
8
  import { createContext, createRouterContext } from "../runtime/context.js";
@@ -132,12 +132,13 @@ export async function runCli(args, io = defaultIo, env = process.env) {
132
132
  const lifecycleCommand = parseLifecycleCommand(publicCommand);
133
133
  if (lifecycleCommand.kind === "standalone")
134
134
  validateLifecyclePathAliases(output.args, lifecycleCommand.invocation.operation);
135
- const observedInvocation = observedLifecycleInvocation(routerContext, publicCommand);
135
+ const observedInvocation = observedLifecycleInvocation(routerContext, publicCommand, env.DD_FLOW_DAEMON_ID);
136
136
  if (!observedInvocation && !suppliedInvocationId && env.DD_FLOW_DAEMON_ID && lifecycleCommand.kind === "standalone") {
137
137
  const diagnostic = observedLifecycleDiagnostic(routerContext, publicCommand, env.DD_FLOW_DAEMON_ID);
138
138
  if (diagnostic)
139
139
  throw new AppError("lifecycle_shell_syntax_invalid", `Native observer rejected the shell form: ${String(diagnostic.reason ?? "unsupported shell composition")}. Write structured input to a file and run the returned dd-flow command separately.`, 2, { ...diagnostic, phase: "prepare", effect: "no_effect", recoverable: true });
140
- throw new AppError("invocation_receipt_missing", "Managed lifecycle command has no committed native receipt", 1, { effect: "no_effect", recoverable: false });
140
+ const nativeEvent = observedLifecycleNativeEvent(routerContext, publicCommand, env.DD_FLOW_DAEMON_ID);
141
+ throw new AppError(nativeEvent ? "invocation_assignment_missing" : "invocation_receipt_missing", nativeEvent ? "Native call was observed, but its arguments do not match an issued lifecycle assignment" : "Managed lifecycle command has no committed native receipt", 1, { effect: "no_effect", recoverable: false });
141
142
  }
142
143
  const invocationId = observedInvocation?.id ?? suppliedInvocationId;
143
144
  let boundInvocationArgs;
@@ -266,6 +267,23 @@ export async function runCli(args, io = defaultIo, env = process.env) {
266
267
  }
267
268
  catch (caught) {
268
269
  const error = isAppError(caught) ? caught : new AppError("unexpected", caught instanceof Error ? caught.message : String(caught), 1, { effect: "unknown", cause: { code: caught?.code ?? null, sqlite_extended_code: caught?.errcode ?? null } });
270
+ if (!settleHook && !invocationSettlement && env.DD_FLOW_DAEMON_ID) {
271
+ let earlyContext;
272
+ try {
273
+ const command = quote(["dd-flow", ...output.args]);
274
+ const event = observedLifecycleNativeEvent(createRouterContext(env), command, env.DD_FLOW_DAEMON_ID);
275
+ if (event) {
276
+ earlyContext = createContext({ ...env }, "hook");
277
+ observeLifecycleCommand(earlyContext, command, event)({ error });
278
+ }
279
+ }
280
+ catch (persistence) {
281
+ error.details.lifecycle_diagnostic_error = String(persistence);
282
+ }
283
+ finally {
284
+ earlyContext?.db.close?.();
285
+ }
286
+ }
269
287
  if (diagnosticHome && (!isAppError(caught) || error.code === "storage_write_failed")) {
270
288
  try {
271
289
  const directory = path.join(diagnosticHome, "diagnostics", "cli");
@@ -6,7 +6,7 @@ import { setTimeout as delay } from "node:timers/promises";
6
6
  import { AppError, errorRecord } from "../shared/errors.js";
7
7
  import { shortEntityReference } from "../shared/entity-references.js";
8
8
  import { resolveWorkReference } from "../storage/work-references.js";
9
- import { commandOption, parseLifecycleCommand, shellSuffixOffset } from "./lifecycle-command.js";
9
+ import { commandOption, lifecycleCommandFromPayload, parseLifecycleCommand, shellSuffixOffset } from "./lifecycle-command.js";
10
10
  import { requireProjectByRoot } from "./projects.js";
11
11
  import { assertRunMutationAllowed, assertStageSettlementAllowed, assertWorkSettlementAllowed, recoveryGuard } from "./run-recovery.js";
12
12
  import { readNativeDaemonState, requireManagedDaemonBinding } from "./managed-daemon-binding.js";
@@ -68,7 +68,7 @@ function fingerprint(parsed) {
68
68
  const runtimeOwnedOptions = {
69
69
  stage_start: new Set(["project-root", "context-file", "context-sha256", "response-file", "require-session-binding"]),
70
70
  stage_finish: new Set(["project-root", "result-file", "decision-file", "verification-file"]),
71
- stage_pause: new Set(["project-root"]), stage_resume: new Set(["project-root"]),
71
+ stage_pause: new Set(["project-root"]), stage_resume: new Set(["project-root", "answer-file"]),
72
72
  work_start: new Set(["project-root", "recovery-id"]),
73
73
  work_finish: new Set(["project-root"]), work_fail: new Set(["project-root"]),
74
74
  merge_apply: new Set(["project-root"]), merge_repair: new Set(["project-root"]),
@@ -278,7 +278,7 @@ export function assertLifecycleOutcomes(context, scope) {
278
278
  }
279
279
  /** Hooks already own native identity. Retain command outcomes there, with an
280
280
  * atomic controller notification; missing notifications reconcile from the row. */
281
- export function observeLifecycleCommand(context, command) {
281
+ export function observeLifecycleCommand(context, command, nativeEvent) {
282
282
  const parsed = parseLifecycleCommand(command);
283
283
  if (parsed.kind !== "standalone")
284
284
  return () => { };
@@ -335,9 +335,12 @@ export function observeLifecycleCommand(context, command) {
335
335
  return;
336
336
  anchor = { project_id: projectId, run_id: runId, generation: generation ?? recoveryGuard(context, projectId, runId)?.generation ?? 0, hook_event_id: eventId, session_id: event.session_id, harness: event.harness, operation: parsed.invocation.operation };
337
337
  };
338
- observeCommandHook(context, command);
338
+ if (nativeEvent)
339
+ context.observeLifecycleHook(nativeEvent.project_id, nativeEvent.id);
340
+ else
341
+ observeCommandHook(context, command);
339
342
  const invocationId = commandOption(parsed.invocation, "invocation-id");
340
- if (!anchor && invocationId) {
343
+ if (!anchor && invocationId && !nativeEvent) {
341
344
  const attempt = load(context, invocationId);
342
345
  checkCommand(attempt, command);
343
346
  if (attempt.event_key && ["observed", "executing"].includes(attempt.status)) {
@@ -441,9 +444,10 @@ export function managedInvocationContext(context, input) {
441
444
  env.DD_FLOW_INVOCATION_SCOPE = JSON.stringify(scope);
442
445
  return { ...context, env };
443
446
  }
444
- /** Generated commands retain their attempt across repeated rendering. Only a
445
- * currently executing command can emit its own successor with the same argv. */
446
- export function managedLifecycleCommand(context, command) {
447
+ /** Generated commands retain their attempt across repeated rendering. Execution
448
+ * can issue a retry; accepted HITL intent can replace an unobserved placeholder
449
+ * or issue the next pause's assignment after successful settlement. */
450
+ export function managedLifecycleCommand(context, command, options = {}) {
447
451
  const configured = context.env.DD_FLOW_INVOCATION_SCOPE;
448
452
  if (!configured)
449
453
  return command;
@@ -474,7 +478,18 @@ export function managedLifecycleCommand(context, command) {
474
478
  return context.db.writeTransaction(() => {
475
479
  const prior = context.db.get("SELECT * FROM lifecycle_invocations WHERE scope_json = ? AND fingerprint = ? ORDER BY rowid DESC LIMIT 1", [JSON.stringify(scope), fingerprint(parsed)]);
476
480
  if (prior && prior.id !== context.env.DD_FLOW_CURRENT_INVOCATION) {
477
- return publicInvocationCommand(context, prior, command);
481
+ const replacesAnswer = options.replaceUnobservedAnswer && parsed.operation === "stage_resume"
482
+ && (prior.status === "settled" || commandOption(invocation(prior.command), "answer-file") !== commandOption(parsed, "answer-file"));
483
+ if (!replacesAnswer)
484
+ return publicInvocationCommand(context, prior, command);
485
+ // The pause packet can prepare a placeholder before the controller has
486
+ // accepted an answer. Replace only an unobserved assignment; never change
487
+ // the authority of a native call already in flight.
488
+ const completed = prior.status === "settled" && prior.outcome_json && !JSON.parse(prior.outcome_json).error;
489
+ if (prior.status !== "issued" && !completed)
490
+ throw new AppError("invocation_answer_conflict", "Cannot replace an answer while its lifecycle assignment is in flight or failed", 1, { invocation_id: prior.id });
491
+ if (prior.status === "issued")
492
+ context.db.run("UPDATE lifecycle_invocations SET status = 'expired' WHERE id = ?", [prior.id]);
478
493
  }
479
494
  if (context.env.DD_FLOW_INVOCATION_READONLY === "1")
480
495
  throw new AppError("invocation_command_unprepared", "No prepared lifecycle command is available; read-only inspection cannot issue one", 1);
@@ -807,8 +822,38 @@ export function observeLifecycleInvocation(context, input) {
807
822
  throw error;
808
823
  }
809
824
  }
810
- /** Return a precise native-observer rejection for the same argv. This keeps
811
- * shell diagnostics distinct from a genuinely absent hook receipt. */
825
+ /** Correlate exact native argv for diagnostics, independently of assignment
826
+ * matching. A hook-added event key must name the same persisted native call. */
827
+ export function observedLifecycleNativeEvent(context, command, daemonId) {
828
+ if (!context.db.get("SELECT 1 FROM sqlite_master WHERE name = 'hook_events'"))
829
+ return null;
830
+ const signature = (value) => {
831
+ const parsed = parseLifecycleCommand(value);
832
+ if (parsed.kind !== "standalone" || parsed.invocation.wrapped)
833
+ return null;
834
+ return JSON.stringify([parsed.invocation.operation, parsed.invocation.args.positional,
835
+ [...parsed.invocation.args.options].filter(([key]) => !["json", "progress-jsonl", "hook-event-id"].includes(key)).sort(([a], [b]) => a.localeCompare(b))]);
836
+ };
837
+ const expected = signature(command);
838
+ if (!expected)
839
+ return null;
840
+ const eventKey = commandOption(invocation(command), "hook-event-id");
841
+ const matches = context.db.all("SELECT project_id, id, event_key, sanitized_summary FROM hook_events WHERE daemon_id = ? AND status = 'observed' AND outcome_json IS NULL", [daemonId]).filter(row => {
842
+ if (eventKey && row.event_key !== eventKey)
843
+ return false;
844
+ try {
845
+ const observed = lifecycleCommandFromPayload(JSON.parse(row.sanitized_summary));
846
+ return typeof observed === "string" && signature(observed) === expected;
847
+ }
848
+ catch {
849
+ return false;
850
+ }
851
+ });
852
+ // Diagnostic correlation only: never use this as execution authority, and
853
+ // never choose the latest of several identical native calls.
854
+ return matches.length === 1 ? { project_id: matches[0].project_id, id: matches[0].id } : null;
855
+ }
856
+ /** Keep shell diagnostics distinct from genuinely absent native delivery. */
812
857
  export function observedLifecycleDiagnostic(context, command, daemonId) {
813
858
  const analysis = parseLifecycleCommand(command);
814
859
  if (analysis.kind !== "standalone")
@@ -833,7 +878,7 @@ export function observedLifecycleDiagnostic(context, command, daemonId) {
833
878
  /** Resolve only a command that a native hook has already bound to an issued
834
879
  * attempt. This is the CLI-side half of admission for model-facing commands
835
880
  * that intentionally omit the internal UUID. */
836
- export function observedLifecycleInvocation(context, command) {
881
+ export function observedLifecycleInvocation(context, command, daemonId) {
837
882
  if (!context.db.get("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'lifecycle_invocations'"))
838
883
  return null;
839
884
  const analysis = parseLifecycleCommand(command);
@@ -843,6 +888,8 @@ export function observedLifecycleInvocation(context, command) {
843
888
  .filter(row => {
844
889
  try {
845
890
  const scope = JSON.parse(row.scope_json);
891
+ if (daemonId && scope.daemonId !== daemonId)
892
+ return false;
846
893
  return fingerprint(invocation(canonicalManagedCommand(context, command, scope))) === row.fingerprint;
847
894
  }
848
895
  catch {
@@ -598,19 +598,25 @@ async function executeController(context, row, manifest, state, assertOwnership,
598
598
  const pause = view.index.stage_runs.find(stage => stage.stage === next.stage)?.pause;
599
599
  if (!pause || state.current_session === null)
600
600
  throw new AppError("controller_hitl_missing", "Paused RUN has no managed Session or HITL receipt", 1);
601
- const answer = context.db.get("SELECT operation_id, input_json FROM run_controller_operations WHERE controller_id = ? AND status = 'accepted' AND json_extract(input_json, '$.action') = 'user_answer' AND json_extract(input_json, '$.pause_id') = ? ORDER BY created_at LIMIT 1", [row.controller_id, pause.id]);
601
+ const answer = context.db.get("SELECT operation_id, input_json, status FROM run_controller_operations WHERE controller_id = ? AND status IN ('accepted','completed') AND json_extract(input_json, '$.action') = 'user_answer' AND json_extract(input_json, '$.pause_id') = ? ORDER BY created_at LIMIT 1", [row.controller_id, pause.id]);
602
602
  if (!answer) {
603
603
  await delay(1000);
604
604
  continue;
605
605
  }
606
+ if (answer.status === "completed")
607
+ throw new AppError("controller_answer_not_applied", "Completed answer operation still has its original HITL pause", 1, { pause_id: pause.id, operation_id: answer.operation_id, stage: next.stage });
606
608
  const input = JSON.parse(answer.input_json);
607
609
  if (digestBytes(fs.readFileSync(input.file)) !== input.sha256)
608
610
  throw new AppError("context_checksum_mismatch", "Accepted HITL answer bytes changed", 1);
609
611
  assertOwner();
610
612
  // The accepted answer is logical intent; only its generation-bound
611
613
  // adapter operation represents a physical dispatch that stop must drain.
612
- const command = managedLifecycleCommand(executionContext, `${flowCommand(executionContext)} stage resume ${run.id} --stage ${next.stage} --work ${input.work_id} --project-root ${quote([run.project_root])} --answer-file ${quote([input.file])} --json`);
614
+ const command = managedLifecycleCommand(executionContext, `${flowCommand(executionContext)} stage resume ${run.id} --stage ${next.stage} --work ${input.work_id} --project-root ${quote([run.project_root])} --answer-file ${quote([input.file])} --json`, { replaceUnobservedAnswer: true });
613
615
  const receipt = await prompt(state.sessions[state.current_session], `The user supplied the raw answer for pause ${pause.id}. Your first tool call must be this exact standalone command:\n\n${command}\n\nDo not alter the answer. Follow the returned packet, then stop at this Stage boundary.`, `${answer.operation_id}:prompt:${row.generation}`);
616
+ const afterAnswer = getFlowRunStatus(executionContext, { projectRoot: run.project_root, runId: run.id });
617
+ if (afterAnswer.index.stage_runs.some(stage => stage.status === "paused" && stage.pause?.id === pause.id)) {
618
+ throw new AppError("controller_answer_not_applied", "The answer Turn completed without resuming the assigned HITL pause", 1, { pause_id: pause.id, operation_id: answer.operation_id, stage: next.stage, effect: "no_progress" });
619
+ }
614
620
  context.db.run("UPDATE run_controller_operations SET status = 'completed', receipt_json = ?, updated_at = ? WHERE operation_id = ?", [JSON.stringify({ delivered: true, session_id: adapterSessionId(receipt) }), context.now(), answer.operation_id]);
615
621
  state.last_continuation = null;
616
622
  saveState(context, row, state);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@deksden-com/dd-flow-cli",
3
- "version": "0.9.0-beta.87",
3
+ "version": "0.9.0-beta.88",
4
4
  "description": "Mechanical runtime CLI for dd-flow workflows.",
5
5
  "type": "module",
6
6
  "bin": {