@deksden-com/dd-flow-cli 0.9.0-beta.105 → 0.9.0-beta.107

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,17 @@
1
1
  # @deksden-com/dd-flow-cli
2
2
 
3
+ ## 0.9.0-beta.107
4
+
5
+ ### Patch Changes
6
+
7
+ - Give a corrective successor only for proven no-effect PROTOCOLIZE, PLAN, PLAN-REVIEW, and Work input errors, including invalid corrected PLAN schemas and missing evidence references; keep broken runtime artifacts fatal. Tell PLAN reviewers the exact workspace-relative PLAN path. Apply ZCode's profile as one unarmed configuration phase and verify the final native profile before productive work, so transient native mode notifications cannot masquerade as drift.
8
+
9
+ ## 0.9.0-beta.106
10
+
11
+ ### Patch Changes
12
+
13
+ - Keep all Stage and Work lifecycle commands attached to their shell process until final output arrives, including HITL resume and standalone launch prompts. Resolve a model-facing command's hidden RUN from its issued authority when checking Antigravity workspace routing. Reserve the SQLite writer before reading and publishing the RUN projection so concurrent reviewer completions cannot invalidate it.
14
+
3
15
  ## 0.9.0-beta.105
4
16
 
5
17
  ### Patch Changes
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "cli_package": "@deksden-com/dd-flow-cli",
3
- "cli_version": "0.9.0-beta.105",
4
- "cli_commit": "e55ba4f273ae0873e06ebe9ea75e6a6e1eb8b095",
5
- "built_at": "2026-09-26T22:58:06.111Z",
3
+ "cli_version": "0.9.0-beta.107",
4
+ "cli_commit": "b1e4fba26b8d9dea7b9b4f6de05aec6be3e21dc2",
5
+ "built_at": "2026-09-27T03:02:31.051Z",
6
6
  "built_with_canon": {
7
7
  "version": "4.1.1",
8
8
  "commit": "678daa038287c948ada5b2d785a6dcc925c7b891",
@@ -666,6 +666,8 @@ async function controlledIdentity(bridge, options) {
666
666
  }
667
667
 
668
668
  async function applyProfile(bridge, sessionId, requested, assertDispatch = () => {}) {
669
+ const identity = bridge.modelSessions?.get(sessionId);
670
+ if (identity) identity.armed = false;
669
671
  assertDispatch();
670
672
  if (requested.mode) await bridge.request("session/set_mode", { sessionId, modeId: requested.mode });
671
673
  assertDispatch();
@@ -674,10 +676,11 @@ async function applyProfile(bridge, sessionId, requested, assertDispatch = () =>
674
676
  if (requested.model) await bridge.request("session/setModel", { sessionId, modelId: requested.provider ? `${requested.provider}\\${requested.model}` : requested.model });
675
677
  assertDispatch();
676
678
  const read = await bridge.request("zcode/session/read", { sessionId });
679
+ await bridge.notifications;
677
680
  const observed = observedProfile(read);
678
681
  await bridge.recordModel?.(sessionId, observed, "zcode/session/read.applied");
679
682
  const receipt = assertProfile(requested, observed);
680
- const identity = bridge.modelSessions?.get(sessionId); if (identity) identity.armed = true;
683
+ if (identity) identity.armed = true;
681
684
  return receipt;
682
685
  }
683
686
 
@@ -3,6 +3,7 @@ export type DelegationInput = { harness: string; stage: string; capacity: number
3
3
  export function delegationAdapter(harness: string): string | null;
4
4
  export function delegationContract(harness: string): string;
5
5
  export const lifecycleRetryInstruction: string;
6
+ export const lifecycleCommandCompletionInstruction: string;
6
7
  export const stageLifecycleInstruction: string;
7
8
  export function workerDelegationTask(input: { workId: string; startCommand: string; launchPolicy?: string }): string;
8
9
  export function renderDelegationInstructions(input: DelegationInput): string;
@@ -49,7 +49,9 @@ export function delegationContract(harness) {
49
49
  return `dd-flow/delegation/${key}@${key === "codex" ? 2 : 1}`;
50
50
  }
51
51
 
52
- export const lifecycleRetryInstruction = "A failure is not permission to replay a lifecycle command. For proven effect=no_effect with recoverable=true and retry_command, correct the input and execute that exact issued successor. A check failure can retain receipts while leaving Work running: correct the evidenced cause first, then use the CLI's exact retry_command for an intentional fresh check attempt; never replay a check merely because Work is unsettled. If continuation.kind=repair_required, or a successful result has outcome=repair_required and a repair Work ID, end this Turn for the registered repair child; use any retry_command only after that repair settles and verification is refreshed. Unknown effects require reconciliation, never replay. A committed effect without an explicit continuation is not retryable. A native shell launch refusal before the CLI starts permits correcting only that shell-form error and retrying the same exact command. Otherwise report the primary error and stop; never invent IDs, call hooks directly, or run status as a substitute.";
52
+ export const lifecycleCommandCompletionInstruction = "A shell tool may yield before the lifecycle process exits. Retain and poll its exact process/session handle to completion, including inside a tool wrapper; empty interim output is not a CLI result. Read final stdout or response_file before deciding continuation, and never reissue the command while it is pending.";
53
+
54
+ export const lifecycleRetryInstruction = `${lifecycleCommandCompletionInstruction} A failure is not permission to replay a lifecycle command. For proven effect=no_effect with recoverable=true and retry_command, correct the input and execute that exact issued successor. A check failure can retain receipts while leaving Work running: correct the evidenced cause first, then use the CLI's exact retry_command for an intentional fresh check attempt; never replay a check merely because Work is unsettled. If continuation.kind=repair_required, or a successful result has outcome=repair_required and a repair Work ID, end this Turn for the registered repair child; use any retry_command only after that repair settles and verification is refreshed. Unknown effects require reconciliation, never replay. A committed effect without an explicit continuation is not retryable. A native shell launch refusal before the CLI starts permits correcting only that shell-form error and retrying the same exact command. Otherwise report the primary error and stop; never invent IDs, call hooks directly, or run status as a substitute.`;
53
55
 
54
56
  export const stageLifecycleInstruction = [
55
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.",
@@ -3,7 +3,7 @@ import { getVnextFanoutStatus, observeVnextNativeChildren, reconcileVnextFanout,
3
3
  import { getFlowRunVariables } from "./runs.js";
4
4
  import { dispatchVnextPlanReview } from "./vnext-plan-review.js";
5
5
  import { resolveExecutionPolicy } from "./execution-policy.js";
6
- import { renderDelegationInstructions, stageLifecycleInstruction } from "../harness-runtime/lib/delegation-instructions.mjs";
6
+ import { lifecycleCommandCompletionInstruction, renderDelegationInstructions, stageLifecycleInstruction } from "../harness-runtime/lib/delegation-instructions.mjs";
7
7
  import { assertWorkLaunchReady } from "./work-registry.js";
8
8
  function semanticCompletionPrompt(stage, completed) {
9
9
  const task = stage === "plan-review"
@@ -30,6 +30,14 @@ export function controllerStageEntryPrompt(stage, command) {
30
30
  stageLifecycleInstruction
31
31
  ].join("\n\n");
32
32
  }
33
+ export function controllerStageResumePrompt(pauseId, command) {
34
+ return [
35
+ `The user supplied the raw answer for pause ${pauseId}. Your first tool call must be this exact standalone command:`,
36
+ command,
37
+ "Do not alter the answer. Follow the returned packet, then stop at this Stage boundary.",
38
+ stageLifecycleInstruction
39
+ ].join("\n\n");
40
+ }
33
41
  /** The shared controller owns this former eval-only Work-graph decision. */
34
42
  export async function nextControllerFanout(context, input) {
35
43
  // Tree settlement is not a Work result. Keep these children visible to the
@@ -62,7 +70,7 @@ export async function nextControllerFanout(context, input) {
62
70
  if (fanout.state === "dispatch_required" || fanout.state === "capacity_required")
63
71
  return { kind: "wait", reason: "qualified_capacity_required" };
64
72
  }
65
- const prompt = (action, text) => ({ kind: "prompt", action, prompt: text, fingerprint: JSON.stringify({ stage: input.stage, parent: fanout.parent_work_id, state: fanout.state, graph: fanout.graph_revision }) });
73
+ const prompt = (action, text) => ({ kind: "prompt", action, prompt: action === "dispatch_work" ? text : `${text}\n\n${lifecycleCommandCompletionInstruction}`, fingerprint: JSON.stringify({ stage: input.stage, parent: fanout.parent_work_id, state: fanout.state, graph: fanout.graph_revision }) });
66
74
  if (fanout.state === "coordinator_required")
67
75
  return prompt("materialize_graph", `Continue the current ${input.stage} Stage using its authoritative packet. Materialize only its required Work graph with real dependencies. Do not launch children, finish the Stage, or start a successor. Return after registering the graph; the runtime will dispatch its ready Work.`);
68
76
  if (fanout.works.failed || fanout.works.cancelled)
@@ -15,6 +15,7 @@ import { managedInvocationContext } from "./lifecycle-invocations.js";
15
15
  import { reviewExecutionCopyHash, reviewSourceFilter, reviewSourceHash, reviewSourcePaths } from "./review-copy.js";
16
16
  import { workspaceGitFiles } from "./workspace-files.js";
17
17
  import { qualifyAgyHooks } from "../harness-runtime/lib/dd-agy-daemon.mjs";
18
+ import { lifecycleRetryInstruction } from "../harness-runtime/lib/delegation-instructions.mjs";
18
19
  /**
19
20
  * Creates an external provider root for one already-created Work. The launch
20
21
  * is not a Work claim: only the worker's trusted `work start` hook binds it.
@@ -106,7 +107,7 @@ export async function launchExternalWork(context, input) {
106
107
  if (reviewCopy && reviewSourceHash(run.workspace_root, reviewInputs, reviewBase) !== reviewCopy.source_sha256)
107
108
  throw new AppError("read_only_input_changed", "Authoritative review workspace changed after the execution copy was made", 2, { work_id: work.work_id });
108
109
  const promptFile = path.join(root, "launch.md");
109
- fs.writeFileSync(promptFile, `Start the assigned Work now. Your first tool call must be this exact standalone command:\n\n${workStartCommand(runtimeContext, work)}\n\nUse the packet returned by that command. Do not claim another Work or run this command more than once.${isolatedWorkspace ? `\n\nThe review copy has no .git. Read the captured status/diff or gitless note in ${path.join(isolatedWorkspace, ".dd-flow", "review-evidence")}; do not run Git inside the copy.\n` : "\n"}`);
110
+ fs.writeFileSync(promptFile, `Start the assigned Work now. Your first tool call must be this exact standalone command:\n\n${workStartCommand(runtimeContext, work)}\n\nUse the packet returned by that command. Do not claim another Work or run this command more than once.\n\n${lifecycleRetryInstruction}${isolatedWorkspace ? `\n\nThe review copy has no .git. Read the captured status/diff or gitless note in ${path.join(isolatedWorkspace, ".dd-flow", "review-evidence")}; do not run Git inside the copy.\n` : "\n"}`);
110
111
  const prompted = await call(["session", "prompt", "--session-id", sessionId, "--prompt-file", promptFile, ...common], 45 * 60_000);
111
112
  assertRunMutationAllowed(context, work.project_id, work.run_id, generation);
112
113
  context.db.run("UPDATE work_launches SET status = 'start_requested', provider_session_id = ?, adapter_receipt_json = ?, updated_at = ? WHERE operation_id = ?", [sessionId, JSON.stringify({ create: created, prompt: prompted }), context.now(), operationId]);
@@ -12,7 +12,7 @@ import { appendAudit } from "./audit.js";
12
12
  import { prepareTextFile, prepareJsonText, prepareError } from "../cli/input-preparation.js";
13
13
  import { nativeSessionIdentity, storageSessionId } from "./session-identity.js";
14
14
  import { assertRecoveryHookCurrent } from "./run-recovery.js";
15
- import { observeLifecycleInvocation } from "./lifecycle-invocations.js";
15
+ import { observeLifecycleInvocation, observedLifecycleInvocation } from "./lifecycle-invocations.js";
16
16
  import { commandHasOption, commandOption, commandPosition, shellSuffixOffset, parseLifecycleCommand, lifecycleCommandFromPayload, continuationCommand } from "./lifecycle-command.js";
17
17
  import { registerProject, requireProjectByRoot } from "./projects.js";
18
18
  import { activeFlowSessionsForProject, recordFlowSessionObservation } from "./sessions.js";
@@ -670,7 +670,7 @@ export function handleDroidEvent(context, input) {
670
670
  return handleControlledToolEvent(context, input, event, "droid-cli");
671
671
  }
672
672
  /** Stable project identity is not the native Session's execution workspace. */
673
- function controlledLifecycleCwd(context, projectRoot, facts, providerSessionId, auxiliary) {
673
+ function controlledLifecycleCwd(context, projectRoot, facts, providerSessionId, auxiliary, issuedRunId) {
674
674
  if (facts?.bootstrapStageStart)
675
675
  return projectRoot;
676
676
  const project = context.db.get("SELECT id FROM projects WHERE root = ?", [projectRoot]);
@@ -686,7 +686,7 @@ function controlledLifecycleCwd(context, projectRoot, facts, providerSessionId,
686
686
  throw error;
687
687
  }
688
688
  }
689
- const reference = work?.run_id ?? facts?.runId ?? auxiliary?.args.options.get("run")?.[0] ?? auxiliary?.args.positional[2];
689
+ const reference = work?.run_id ?? facts?.runId ?? auxiliary?.args.options.get("run")?.[0] ?? auxiliary?.args.positional[2] ?? issuedRunId;
690
690
  if (!reference)
691
691
  return projectRoot;
692
692
  const run = context.db.get("SELECT id, run_root, workspace_root FROM runs WHERE project_id = ? AND (id = ? OR short_id = ?)", [project.id, reference, reference]);
@@ -774,7 +774,9 @@ function handleControlledToolEvent(context, input, event, harness) {
774
774
  const auxiliary = continuationCommand(command);
775
775
  if (!lifecycle && !auxiliary)
776
776
  return { ok: true, observed: false, reason: "event_not_participating" };
777
- const expectedCwd = controlledLifecycleCwd(context, expectedRoot, lifecycle, providerSessionId, auxiliary);
777
+ const issuedRunId = lifecycle && !lifecycle.runId && !lifecycle.workId && !lifecycle.bootstrapStageStart
778
+ ? observedLifecycleInvocation(context, command, daemonId)?.scope.runId : null;
779
+ const expectedCwd = controlledLifecycleCwd(context, expectedRoot, lifecycle, providerSessionId, auxiliary, issuedRunId);
778
780
  if (observedCwd !== expectedCwd) {
779
781
  throw new AppError(`${errorPrefix}_directory_mismatch`, `${label} ${agy ? "launch route" : "Session directory"} does not match the assigned execution workspace`, 1, { expected: expectedCwd, actual: observedCwd, ...(agy ? { actual_provenance: stringValue(event.cwd_provenance) ?? "unknown" } : {}) });
780
782
  }
@@ -1302,8 +1302,6 @@ export async function awaitLifecycleInvocation(context, input) {
1302
1302
  checkCommand(initial, input.command);
1303
1303
  // A terminal command is immutable evidence. Do not require a now-gone
1304
1304
  // caller payload merely to replay its recorded result.
1305
- if (["issued", "observed"].includes(initial.status))
1306
- await input.prepare?.();
1307
1305
  }
1308
1306
  catch (error) {
1309
1307
  if (!(error instanceof AppError) || !(["usage", "invocation_command_mismatch", "invocation_command_invalid"].includes(error.code) || error.details.phase === "prepare"))
@@ -1311,6 +1309,22 @@ export async function awaitLifecycleInvocation(context, input) {
1311
1309
  Object.assign(error.details, settleLifecyclePreparationRejection(context, input.id, error));
1312
1310
  throw error;
1313
1311
  }
1312
+ if (["issued", "observed"].includes(initial.status)) {
1313
+ try {
1314
+ await input.prepare?.();
1315
+ }
1316
+ catch (error) {
1317
+ if (!(error instanceof AppError) || (error.details.effect !== undefined && error.details.effect !== "no_effect"))
1318
+ throw error;
1319
+ const details = { ...error.details, phase: "prepare", effect: "no_effect" };
1320
+ const recoverable = error.exitCode === 2 && ["validation", "schema_validation"].includes(error.code)
1321
+ ? error.details.recoverable !== false
1322
+ : isRetryableLifecycleRejection(error.code, details);
1323
+ Object.assign(error.details, { phase: "prepare", effect: "no_effect", recoverable });
1324
+ Object.assign(error.details, settleLifecyclePreparationRejection(context, input.id, error));
1325
+ throw error;
1326
+ }
1327
+ }
1314
1328
  const timeoutMs = input.timeoutMs ?? 30000;
1315
1329
  if (!Number.isFinite(timeoutMs) || timeoutMs <= 0 || timeoutMs > 60000)
1316
1330
  throw new AppError("invocation_timeout_invalid", "Receipt timeout must be within 1..60000 ms", 1);
@@ -1,4 +1,5 @@
1
1
  import { managedInvocationContext, managedLifecycleCommand } from "./lifecycle-invocations.js";
2
+ import { stageLifecycleInstruction } from "../harness-runtime/lib/delegation-instructions.mjs";
2
3
  import crypto from "node:crypto";
3
4
  import fs from "node:fs";
4
5
  import path from "node:path";
@@ -119,7 +120,7 @@ async function dispatch(context, input) {
119
120
  retain();
120
121
  runtimeContext = managedInvocationContext(runtimeContext, { harness: runtimeHarness(input.profile), stateDir, sessionId, projectRoot: run.project_root, runId: input.request.run_id, generation });
121
122
  const stageCommand = managedLifecycleCommand(runtimeContext, `${flowCommand(runtimeContext)} stage start ${input.request.run_id} --stage merge --project-root ${JSON.stringify(run.project_root)} --json --progress-jsonl`);
122
- fs.writeFileSync(promptFile, `Start the assigned MERGE stage now. Your first tool call must be this exact standalone command:\n\n${stageCommand}\n\nTrust and follow the complete stage packet it returns. Continue through merge apply and stage finish. Stop only when the stage reaches a terminal result or explicitly requests user/operator action.\n`);
123
+ fs.writeFileSync(promptFile, `Start the assigned MERGE stage now. Your first tool call must be this exact standalone command:\n\n${stageCommand}\n\nTrust and follow the complete stage packet it returns. Continue through merge apply and stage finish. Stop only when the stage reaches a terminal result or explicitly requests user/operator action.\n\n${stageLifecycleInstruction}\n`);
123
124
  const prompted = await adapterCall(["session", "prompt", "--session-id", sessionId, "--prompt-file", promptFile, ...common], 45 * 60 * 1000, `MERGE ${input.request.merge_request_id} Session ${sessionId} is still running`);
124
125
  Object.assign(retained, { response: prompted });
125
126
  retain();
@@ -23,7 +23,7 @@ import { assertManagedProcessLease, confirmManagedProcess, heartbeatManagedProce
23
23
  import { readNativeDaemonState, requireManagedDaemonBinding } from "./managed-daemon-binding.js";
24
24
  import { initCodexHome } from "./hooks.js";
25
25
  import { launchExternalWork } from "./external-work-launch.js";
26
- import { controllerNativeChildren, controllerStageEntryPrompt, nextControllerFanout } from "./controller-fanout.js";
26
+ import { controllerNativeChildren, controllerStageEntryPrompt, controllerStageResumePrompt, nextControllerFanout } from "./controller-fanout.js";
27
27
  import { claimQueuedMergeRequest } from "./merge-server.js";
28
28
  import { prepareVnextCodeWorkspace } from "./vnext-code.js";
29
29
  import { requireRun, requireController, controllerReceipt, appendEvent, saveState, retainControllerContinuation, digest, digestBytes, errorRecord } from "./run-controller-state.js";
@@ -613,7 +613,7 @@ async function executeController(context, row, manifest, state, assertOwnership,
613
613
  // The accepted answer is logical intent; only its generation-bound
614
614
  // adapter operation represents a physical dispatch that stop must drain.
615
615
  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 });
616
- 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 receipt = await prompt(state.sessions[state.current_session], controllerStageResumePrompt(pause.id, command), `${answer.operation_id}:prompt:${row.generation}`);
617
617
  const afterAnswer = getFlowRunStatus(executionContext, { projectRoot: run.project_root, runId: run.id });
618
618
  if (afterAnswer.index.stage_runs.some(stage => stage.status === "paused" && stage.pause?.id === pause.id)) {
619
619
  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" });
@@ -3,6 +3,11 @@ import { reconcileSessionCoverageRows } from "../domain/session-coverage.js";
3
3
  import { publicSessionIdentity } from "./session-identity.js";
4
4
  import { publishRunProjections } from "./runs.js";
5
5
  export function refreshRunSessionProjection(context, projectId, runId) {
6
+ // Review children can finish concurrently in separate processes. Reserve the
7
+ // writer before reading the RUN index so a sibling cannot invalidate it.
8
+ context.db.writeTransaction(() => refreshRunSessionProjectionLocked(context, projectId, runId), "run-projection.refresh");
9
+ }
10
+ function refreshRunSessionProjectionLocked(context, projectId, runId) {
6
11
  const run = context.db.get("SELECT id, flow_kind, status, project_root, subject_type, subject_id, runtime_path, run_index_path, index_json FROM runs WHERE project_id = ? AND id = ?", [projectId, runId]);
7
12
  if (!run)
8
13
  return;
@@ -60,12 +65,10 @@ export function refreshRunSessionProjection(context, projectId, runId) {
60
65
  index.workers = workers;
61
66
  index.updated_at = context.now();
62
67
  index.runtime_revision = typeof index.runtime_revision === "number" ? index.runtime_revision + 1 : 1;
63
- context.db.writeTransaction(() => {
64
- const updated = context.db.run("UPDATE runs SET index_json = ?, updated_at = ? WHERE project_id = ? AND id = ? AND index_json = ?", [JSON.stringify(index), index.updated_at, projectId, runId, run.index_json]);
65
- if (updated.changes !== 1)
66
- throw new Error("RUN projection changed during preparation");
67
- context.db.afterCommit(() => publishRunProjections(context, projectId, runId));
68
- }, "run-projection.refresh");
68
+ const updated = context.db.run("UPDATE runs SET index_json = ?, updated_at = ? WHERE project_id = ? AND id = ? AND index_json = ?", [JSON.stringify(index), index.updated_at, projectId, runId, run.index_json]);
69
+ if (updated.changes !== 1)
70
+ throw new Error("RUN projection changed during preparation");
71
+ context.db.afterCommit(() => publishRunProjections(context, projectId, runId));
69
72
  }
70
73
  function planProjection(context, projectId, protocolId, projectRoot, index) {
71
74
  const binding = context.db.get("SELECT plan_id, plan_path, plan_revision, plan_sha256 FROM plan_bindings WHERE project_id = ? AND protocol_id = ?", [projectId, protocolId]);
@@ -7,6 +7,7 @@ import { acceptanceCommand, recoveryGuard, recoveryPacketReceipt, requireRecover
7
7
  import { managedProcessBinding, processIsAlive, resourceHome } from "./managed-processes.js";
8
8
  import { getResourceDatabase } from "../storage/database.js";
9
9
  import { requireManagedDaemonBinding } from "./managed-daemon-binding.js";
10
+ import { lifecycleRetryInstruction } from "../harness-runtime/lib/delegation-instructions.mjs";
10
11
  /** Pin retained Session loading and its first acknowledgement Turn, never a general fence bypass. */
11
12
  export function prepareRunRecoveryDispatch(context, input) {
12
13
  if (!/^[A-Za-z0-9][A-Za-z0-9._:-]{0,255}$/.test(input.operationId) || !path.isAbsolute(input.stateDir))
@@ -37,7 +38,7 @@ export function prepareRunRecoveryDispatch(context, input) {
37
38
  });
38
39
  let dispatch = prepared.dispatch;
39
40
  if (!dispatch) {
40
- const prompt = `Resume this retained RUN in its existing native Session. Your first tool call must be this exact standalone command:\n\n${acceptanceCommand(context, run.id, prepared.guard.recovery_id, run.project_root)}\n\nFollow the verified recovery packet returned by that command. Preserve completed Work and existing HITL; do not start a successor Stage yourself. Stop at the Stage or declared Work-graph boundary.\n`;
41
+ const prompt = `Resume this retained RUN in its existing native Session. Your first tool call must be this exact standalone command:\n\n${acceptanceCommand(context, run.id, prepared.guard.recovery_id, run.project_root)}\n\nFollow the verified recovery packet returned by that command. Preserve completed Work and existing HITL; do not start a successor Stage yourself. Stop at the Stage or declared Work-graph boundary.\n\n${lifecycleRetryInstruction}\n`;
41
42
  dispatch = { process_id: prepared.record.id, owner_id: prepared.record.owner_id, state_dir: input.stateDir, daemon_id: prepared.binding.daemon_id, operation_id: input.operationId, resume_operation_id: `${input.operationId}:load`, prompt_sha256: crypto.createHash("sha256").update(prompt).digest("hex"), prompt };
42
43
  }
43
44
  const registry = prepared.record ? getResourceDatabase(resourceHome(context)) : undefined;
@@ -265,7 +265,7 @@ function finishPlanReviewOwned(context, input) {
265
265
  if (evidenceFailures.length)
266
266
  throw new AppError("review_evidence_invalid", "PLAN-REVIEW reviewer evidence is incomplete, stale, or not isolated", 2, { errors: evidenceFailures });
267
267
  if (decision.outcome !== "accepted")
268
- throw new AppError("validation", "Unsupported PLAN-REVIEW decision outcome", 2, { outcome: decision.outcome });
268
+ throw new AppError("validation", "Unsupported PLAN-REVIEW decision outcome", 2, { outcome: decision.outcome, phase: "prepare", effect: "no_effect", recoverable: true });
269
269
  // A completed reviewer can legitimately report `blocked` when an upstream
270
270
  // plan uncertainty prevents it from approving its aspect. That is evidence
271
271
  // for the coordinator to classify and fix in this one review pass, not an
@@ -283,21 +283,21 @@ function finishPlanReviewOwned(context, input) {
283
283
  throw new AppError("review_evidence_invalid", "Reviewer findings and coordinator decisions must use unique known canonical references, with every material finding decided", 2, { material_finding_ids: materialFindingIds, decided_finding_ids: decisionFindingIds, unknown_decisions: unknownDecisions });
284
284
  if (needsCorrection) {
285
285
  if (decision.correction.status !== "applied")
286
- throw new AppError("validation", "Reviewer findings require an in-place correction receipt", 2);
286
+ throw new AppError("validation", "Reviewer findings require an in-place correction receipt", 2, { phase: "prepare", effect: "no_effect", recoverable: true });
287
287
  if (decision.correction.previous_plan_revision !== reviewedPlanRevision || planRevision <= reviewedPlanRevision || decision.correction.changed_paths.length === 0)
288
- throw new AppError("validation", "Applied PLAN correction must advance revision and list changed paths", 2, { reviewed_plan_revision: reviewedPlanRevision, final_plan_revision: planRevision });
288
+ throw new AppError("validation", "Applied PLAN correction must advance revision and list changed paths", 2, { reviewed_plan_revision: reviewedPlanRevision, final_plan_revision: planRevision, phase: "prepare", effect: "no_effect", recoverable: true });
289
289
  }
290
290
  else if (decision.correction.status !== "not_required" || planRevision !== reviewedPlanRevision || currentPlanChecksum !== contextFile.system?.plan_checksum) {
291
- throw new AppError("validation", "A clean PLAN review must not claim an unverified correction", 2);
291
+ throw new AppError("validation", "A clean PLAN review must not claim an unverified correction", 2, { phase: "prepare", effect: "no_effect", recoverable: true });
292
292
  }
293
293
  const prepared = prepareVnextPlanArtifacts(context, { projectRoot, workspaceRoot: run.workspace_root, runId: run.id, home, protocols: protocolIdsForReview(home) });
294
294
  if (prepared.failures.length)
295
- throw new AppError("validation", "Corrected PLAN artifacts have validation errors", 2, { errors: prepared.failures });
295
+ throw new AppError("validation", "Corrected PLAN artifacts have validation errors", 2, { errors: prepared.failures, phase: "prepare", effect: "no_effect", recoverable: true });
296
296
  if (needsCorrection) {
297
297
  // Regeneration above is authoritative. A valid PLAN correction need not
298
298
  // change the derived CODE tasks (for example, a clarified explanation).
299
299
  if (decision.correction.changed_paths.some((item) => item.endsWith("code-work-batch.json")))
300
- throw new AppError("validation", "Generated CODE batch must not be listed as an agent-authored correction", 2, { changed_paths: decision.correction.changed_paths });
300
+ throw new AppError("validation", "Generated CODE batch must not be listed as an agent-authored correction", 2, { changed_paths: decision.correction.changed_paths, phase: "prepare", effect: "no_effect", recoverable: true });
301
301
  }
302
302
  const finalPlanChecksum = planSetChecksum(home, run.workspace_root);
303
303
  const finalBatchChecksum = prepared.batchChecksum;
@@ -358,7 +358,7 @@ function reviewGroups(home, workspaceRoot) {
358
358
  if (dependencyGroup && dependencyGroup !== key)
359
359
  dependencies.add(dependencyGroup);
360
360
  }
361
- groups.push({ key, protocol_id: protocolId, aspect_ids: group.aspect_ids, depends_on: [...dependencies].sort(), task: `Review PLAN group ${key}: ${group.aspect_ids.join(", ")}. Read run://${path.basename(home)}/03-plan/${protocolId}/aspect-map.json and the referenced plan. Try to falsify ambiguous behavior and missing verification. For every selected check that starts fixture/reset, service, or client processes, verify that they share the intended data/configuration world and that any runtime entrypoint requiring change is explicit in a Work task and verification and ordered before its consumer. planned_write_areas are coordination hints, not ownership. Do not edit plan or product files. Return one aspect entry for every assigned aspect, with exact evidence references and concise findings. Finish with the JSON contract supplied in your work prompt.` });
361
+ groups.push({ key, protocol_id: protocolId, aspect_ids: group.aspect_ids, depends_on: [...dependencies].sort(), task: `Review PLAN group ${key}: ${group.aspect_ids.join(", ")}. Read run://${path.basename(home)}/03-plan/${protocolId}/aspect-map.json and .memory-bank/protocol/${protocolId}/plan.json under workspace_root. Cite the PLAN using that workspace-relative path, not a run:// ref. Try to falsify ambiguous behavior and missing verification. For every selected check that starts fixture/reset, service, or client processes, verify that they share the intended data/configuration world and that any runtime entrypoint requiring change is explicit in a Work task and verification and ordered before its consumer. planned_write_areas are coordination hints, not ownership. Do not edit plan or product files. Return one aspect entry for every assigned aspect, with exact evidence references and concise findings. Finish with the JSON contract supplied in your work prompt.` });
362
362
  }
363
363
  }
364
364
  return groups;
@@ -126,7 +126,7 @@ export function prepareVnextPlanFinish(context, input) {
126
126
  assertProtocolWorkspace(run.workspace_root, protocols);
127
127
  const prepared = prepareVnextPlanArtifacts(context, { projectRoot, workspaceRoot: run.workspace_root, runId: run.id, home, protocols });
128
128
  if (prepared.failures.length)
129
- throw new AppError("validation", "PLAN artifacts have validation errors", 2, { errors: prepared.failures });
129
+ throw new AppError("validation", "PLAN artifacts have validation errors", 2, { errors: prepared.failures, phase: "prepare", effect: "no_effect", recoverable: true });
130
130
  return prepared;
131
131
  }
132
132
  export function finishVnextPlan(context, input) {
@@ -148,7 +148,7 @@ function finishVnextPlanOwned(context, input) {
148
148
  const batch = path.join(root, "code-work-batch.json");
149
149
  const prepared = input.prepared ?? prepareVnextPlanFinish(context, input);
150
150
  if (prepared.failures.length)
151
- throw new AppError("validation", "PLAN artifacts have validation errors", 2, { errors: prepared.failures });
151
+ throw new AppError("validation", "PLAN artifacts have validation errors", 2, { errors: prepared.failures, phase: "prepare", effect: "no_effect", recoverable: true });
152
152
  const work = context.db.get("SELECT work_id FROM works WHERE project_id = ? AND run_id = ? AND task = ? AND status = 'running' ORDER BY created_at DESC LIMIT 1", [project.id, run.id, planTask]);
153
153
  if (!work)
154
154
  throw new AppError("invalid_work_state", "PLAN has no running Work", 1);
@@ -183,11 +183,19 @@ export function finishVnextProtocolize(context, input) {
183
183
  const resultFile = prepareProtocolizeResultPath(requireRunHome(run), input.resultFile);
184
184
  const result = input.result ?? readResult(resultFile, { projectRoot, ddFlowHome: context.ddFlowHome, runId: run.id, runRoot: requireRunHome(run) });
185
185
  validateSchema({ schemaName: "vnext-protocolize-result", file: resultFile, data: result, projectRoot, ddFlowHome: context.ddFlowHome, runId: run.id, runRoot: requireRunHome(run) });
186
- if (result.outcome !== "protocolized")
187
- throw new AppError("validation", "PROTOCOLIZE may finish only as protocolized; use stage pause for every user question", 2);
188
186
  const acceptedObligations = acceptedSpecifyObligations(context, projectRoot, run.id, path.join(requireRunHome(run), "01-specify", "specify.json"));
189
- const obligations = effectiveObligations(result, acceptedObligations);
190
- validateDelivery(projectRoot, result, obligations);
187
+ let obligations;
188
+ try {
189
+ if (result.outcome !== "protocolized")
190
+ throw new AppError("validation", "PROTOCOLIZE may finish only as protocolized; use stage pause for every user question", 2);
191
+ obligations = effectiveObligations(result, acceptedObligations);
192
+ validateDelivery(projectRoot, result, obligations);
193
+ }
194
+ catch (error) {
195
+ if (!(error instanceof AppError) || error.code !== "validation" || error.exitCode !== 2)
196
+ throw error;
197
+ throw new AppError(error.code, error.message, error.exitCode, { ...error.details, phase: "prepare", effect: "no_effect", recoverable: true });
198
+ }
191
199
  const resolvedPolicy = requireVnextWorkspaceRoute({ projectRoot, runId: run.id, runHome: requireRunHome(run), workspaceRoot: run.workspace_root, stage: "protocolize" });
192
200
  const receipt = readWorkspaceRouteReceipt(root);
193
201
  const workspaceRoot = run.workspace_root;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@deksden-com/dd-flow-cli",
3
- "version": "0.9.0-beta.105",
3
+ "version": "0.9.0-beta.107",
4
4
  "description": "Mechanical runtime CLI for dd-flow workflows.",
5
5
  "type": "module",
6
6
  "bin": {