@deksden-com/dd-flow-cli 0.9.0-beta.53 → 0.9.0-beta.54

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,11 @@
1
1
  # @deksden-com/dd-flow-cli
2
2
 
3
+ ## 0.9.0-beta.54
4
+
5
+ ### Patch Changes
6
+
7
+ - Preserve current lifecycle command input, atomically settle retry authority, select current resume commands, and stop ZCode productive admission immediately on fatal identity errors. Reject ambiguous result inputs and keep diagnostic commands read-only.
8
+
3
9
  ## 0.9.0-beta.53
4
10
 
5
11
  ### Patch Changes
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "cli_package": "@deksden-com/dd-flow-cli",
3
- "cli_version": "0.9.0-beta.53",
4
- "cli_commit": "db14065f43f320bc69dacbec425cd04eac8236ae",
5
- "built_at": "2026-09-13T02:52:44.434Z",
3
+ "cli_version": "0.9.0-beta.54",
4
+ "cli_commit": "eb2cb9746a9ee386d40f63791f5dbe0847bf5017",
5
+ "built_at": "2026-09-13T12:29:37.211Z",
6
6
  "built_with_canon": {
7
7
  "version": "4.1.0",
8
8
  "commit": "ef349bf47cba1c987468e51d73a0dbadbd48dc1f",
@@ -1,7 +1,7 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
3
  import { quote } from "shell-quote";
4
- import { assertLifecycleInvocationCurrent, awaitLifecycleInvocation, issueLifecycleInvocation, isRetryableLifecycleRejection, lifecycleInvocationScope, retryLifecycleInvocationCommand, settleLifecycleInvocation } from "../services/lifecycle-invocations.js";
4
+ import { assertLifecycleInvocationCurrent, awaitLifecycleInvocation, issueLifecycleInvocation, lifecycleInvocationScope, settleLifecycleInvocation, settleLifecycleRejection } from "../services/lifecycle-invocations.js";
5
5
  import { createContext, createRouterContext } from "../runtime/context.js";
6
6
  import { migrateStoreWriter } from "../storage/writer-migration.js";
7
7
  import { helpForArgs } from "./help.js";
@@ -72,7 +72,15 @@ const defaultIo = {
72
72
  };
73
73
  const flagValue = "__dd_flow_flag__";
74
74
  export async function runCli(args, io = defaultIo, env = process.env) {
75
- const output = parseOutputOptions(args);
75
+ let output;
76
+ try {
77
+ output = parseOutputOptions(args);
78
+ }
79
+ catch (error) {
80
+ const message = error instanceof Error ? error.message : String(error);
81
+ writeJson(io.stderr, { ok: false, error: { code: "usage", message } });
82
+ return 2;
83
+ }
76
84
  const progress = progressForCommand(output.args);
77
85
  let heartbeat;
78
86
  let invocationSettlement;
@@ -90,6 +98,7 @@ export async function runCli(args, io = defaultIo, env = process.env) {
90
98
  io.stdout.write(`${help}\n`);
91
99
  return 0;
92
100
  }
101
+ assertSingletonOptions(output.args);
93
102
  if (io.stdin?.isTTY && ["work", "stage"].includes(output.args[0] ?? "") && output.args[1] === "finish"
94
103
  && hasOption(parseArgs(output.args.slice(2)), "result-stdin")) {
95
104
  throw new AppError("usage", "--result-stdin requires non-interactive input. Pipe or redirect the complete JSON into this command, or use --result-file; interactive terminals can truncate long JSON and wait indefinitely for EOF.", 2);
@@ -113,7 +122,9 @@ export async function runCli(args, io = defaultIo, env = process.env) {
113
122
  if (routed?.routed && !routed.inProcess) {
114
123
  if (heartbeat)
115
124
  clearInterval(heartbeat);
116
- emitHumanProgress(io, output, progress, "done");
125
+ emitHumanProgress(io, output, progress, routed.exitCode === 0 ? "done" : "failed");
126
+ if (output.progressJsonl && routed.exitCode !== 0)
127
+ writeProgressJsonl(io, "failed", "Routed engine failed", { exit_code: routed.exitCode });
117
128
  return routed.exitCode;
118
129
  }
119
130
  const dispatchContext = routed?.inProcess
@@ -168,21 +179,11 @@ export async function runCli(args, io = defaultIo, env = process.env) {
168
179
  if (heartbeat)
169
180
  clearInterval(heartbeat);
170
181
  if (invocationSettlement) {
171
- // Optional retry preparation must never prevent recording the primary
172
- // operation rejection. Otherwise a known failure becomes an unknown run.
173
- if (isAppError(error) && isRetryableLifecycleRejection(error.code)) {
174
- try {
175
- error.details.retry_command = retryLifecycleInvocationCommand(invocationSettlement.context, invocationSettlement.id);
176
- error.details.retry_instruction = "Correct only the evidenced defect, then use retry_command with the required result input. Reissuing the old command only reads its retained outcome.";
177
- }
178
- catch (retryError) {
179
- error.details.retry_preparation_error = String(retryError);
180
- }
181
- }
182
182
  try {
183
- settleLifecycleInvocation(invocationSettlement.context, invocationSettlement.id, { error: isAppError(error)
184
- ? { code: error.code, message: error.message, exitCode: error.exitCode, details: error.details }
185
- : { code: "unexpected", message: String(error), exitCode: 1, details: {} } });
183
+ if (isAppError(error))
184
+ settleLifecycleRejection(invocationSettlement.context, invocationSettlement.id, { code: error.code, message: error.message, exitCode: error.exitCode, details: error.details });
185
+ else
186
+ settleLifecycleInvocation(invocationSettlement.context, invocationSettlement.id, { error: { code: "unexpected", message: String(error), exitCode: 1, details: {} } });
186
187
  }
187
188
  catch (persistenceError) {
188
189
  io.stderr.write(`Invocation settlement persistence failed: ${String(persistenceError)}\n`);
@@ -2442,6 +2443,21 @@ function parseFlowFlagOverrides(values) {
2442
2443
  function parseArgs(args) {
2443
2444
  return parseCommandArgs(args);
2444
2445
  }
2446
+ /** Most CLI options name one authority, path or target. Silently taking the
2447
+ * last duplicate can widen a mutation after an operator typo. Keep only the
2448
+ * options consumed as intentional arrays repeatable. */
2449
+ function assertSingletonOptions(args) {
2450
+ const repeatable = new Set(["alias", "flag", "hide", "check-ref", "evidence", "from-check", "from-finding", "from-unresolved", "on", "origin-work"]);
2451
+ const parsed = parseCommandArgs(args);
2452
+ if (args[0] === "stage" && args[1] === "finish"
2453
+ && ["result-file", "semantic-file", "data", "result-stdin"].filter(name => parsed.options.has(name)).length > 1) {
2454
+ throw new AppError("usage", "Stage finish requires exactly one result input; result-file, semantic-file, data and result-stdin are mutually exclusive", 2);
2455
+ }
2456
+ for (const [name, values] of parsed.options) {
2457
+ if (!repeatable.has(name) && values.length > 1)
2458
+ throw new AppError("usage", `--${name} may be supplied only once`, 2);
2459
+ }
2460
+ }
2445
2461
  function requiredPosition(parsed, index, label) {
2446
2462
  const value = parsed.positional[index];
2447
2463
  if (!value) {
@@ -11,6 +11,9 @@ import { checkObservedProfile, observeModel, observedFields } from "./model-obse
11
11
  const ZCODE_HARNESS_CONTRACT = "dd-zcode-harness@1";
12
12
 
13
13
  function fail(message, code) { const error = new Error(message); if (code) error.code = code; throw error; }
14
+ function fatalLifecycleError(error) {
15
+ return ["invocation_unknown", "invocation_identity_mismatch", "invocation_identity_conflict", "invocation_receipt_missing", "invocation_command_mismatch", "invocation_scope_mismatch", "invocation_generation_stale", "invocation_expired", "zcode_parent_identity_missing", "zcode_tool_identity_conflict"].includes(error?.code);
16
+ }
14
17
  function absolute(value, label) { if (!value || !path.isAbsolute(value)) fail(`${label} must be an absolute path`); return path.resolve(value); }
15
18
  function text(value, label) { if (typeof value !== "string" || !value.trim()) fail(`${label} is required`); return value.trim(); }
16
19
  function now() { return new Date().toISOString(); }
@@ -134,9 +137,17 @@ export class AcpBridge {
134
137
  if (message.method === "session/update" && this.options.onLifecycleNotification) {
135
138
  const observer = this.options.onLifecycleNotification;
136
139
  const pending = Promise.resolve().then(() => observer(message)).catch(async error => {
137
- this.notificationError ??= error;
138
- await this.journal.write("notification_failure", { code: error.code ?? "invocation_observation_failed", message: error.message,
139
- session_id: message.params?.sessionId ?? null, tool_call_id: update?.toolCallId ?? null, event_time: now(), delivery_stage: "lifecycle_receipt", completeness: "incomplete" });
140
+ if (!this.notificationError || (fatalLifecycleError(error) && !fatalLifecycleError(this.notificationError))) this.notificationError = error;
141
+ // A missing/foreign authority is not a recoverable CLI validation
142
+ // result. Stop admitting productive requests immediately; the managed
143
+ // controller still owns physical stop/drain and evidence capture.
144
+ if (fatalLifecycleError(error)) this.rejectAll(error);
145
+ try {
146
+ await this.journal.write("notification_failure", { code: error.code ?? "invocation_observation_failed", message: error.message,
147
+ session_id: message.params?.sessionId ?? null, tool_call_id: update?.toolCallId ?? null, event_time: now(), delivery_stage: "lifecycle_receipt", completeness: "incomplete" });
148
+ } catch (persistenceError) {
149
+ process.stderr.write(`${JSON.stringify({ code: error.code, message: error.message, persistence_error: persistenceError.message })}\n`);
150
+ }
140
151
  });
141
152
  this.lifecycleNotifications.add(pending);
142
153
  void pending.then(() => this.lifecycleNotifications.delete(pending), () => this.lifecycleNotifications.delete(pending));
@@ -224,7 +235,7 @@ export class AcpBridge {
224
235
  }
225
236
 
226
237
  request(method, params = {}, timeoutMs = 30_000, { idleSessionId = null, idleTimeoutMs = null } = {}) {
227
- if (this.notificationError?.code === "profile_integrity_violation" && ["session/new", "session/prompt", "session/fork"].includes(method)) return Promise.reject(this.notificationError);
238
+ if ((this.notificationError?.code === "profile_integrity_violation" || fatalLifecycleError(this.notificationError)) && ["session/new", "session/prompt", "session/fork"].includes(method)) return Promise.reject(this.notificationError);
228
239
  const id = this.nextId++;
229
240
  return new Promise((resolve, reject) => {
230
241
  const finish = (error) => {
@@ -270,7 +281,11 @@ export class AcpBridge {
270
281
  notify(method, params = {}) { this.send({ jsonrpc: "2.0", method, params }); }
271
282
  send(message) { this.journal.write("outbound", message); this.child.stdin.write(`${JSON.stringify(message)}\n`); }
272
283
  rejectAll(error) { for (const pending of this.pending.values()) { clearTimeout(pending.timer); if (pending.idleTimer) clearInterval(pending.idleTimer); pending.reject(error); } this.pending.clear(); }
273
- async flush() { await Promise.all([...this.lifecycleNotifications]); await this.notifications; await this.journal.flush(); if (this.notificationError) throw this.notificationError; }
284
+ async flush() {
285
+ try { await Promise.all([...this.lifecycleNotifications]); await this.notifications; await this.journal.flush(); }
286
+ catch (error) { throw this.notificationError ?? error; }
287
+ if (this.notificationError) throw this.notificationError;
288
+ }
274
289
  async close() {
275
290
  let flushError = null;
276
291
  try { await this.flush(); } catch (error) { flushError = error; }
@@ -131,7 +131,11 @@ function isReadOnlyDiagnostic(family, command, action) {
131
131
  if (family === "plan")
132
132
  return command === "status";
133
133
  if (family === "run")
134
- return command === "status" || command === "list" || (["drive", "control"].includes(command ?? "") && action === "status");
134
+ return command === "status" || command === "list" || (command === "recovery" && action === "inspect") || (["drive", "control"].includes(command ?? "") && action === "status");
135
+ if (family === "work")
136
+ return ["status", "ls", "show"].includes(command ?? "");
137
+ if (family === "stage")
138
+ return command === "fanout" && action === "status";
135
139
  if (family === "lane") {
136
140
  return (command === "status" ||
137
141
  command === "waiters" ||
@@ -9,12 +9,13 @@ import { assertRunMutationAllowed, assertStageSettlementAllowed, assertWorkSettl
9
9
  import { requireManagedDaemonBinding } from "./managed-daemon-binding.js";
10
10
  // Conclusive result/check rejections, never transport, ownership, registry or
11
11
  // unknown-effect failures. These validators reject before accepting the result.
12
- export function isRetryableLifecycleRejection(code) {
12
+ export function isRetryableLifecycleRejection(code, details = {}) {
13
13
  return ["validation", "schema_validation", "work_checks_failed", "code_review_gate_failed",
14
14
  "review_repair_incomplete", "plan_review_incomplete", "semantic_result_invalid",
15
15
  "review_evidence_invalid", "evidence_obligation_unknown", "invalid_evidence_ref",
16
16
  "evidence_ref_missing", "evidence_line_out_of_range", "document_update_missing",
17
- "document_update_not_materialized", "review_repair_no_change"].includes(code);
17
+ "document_update_not_materialized", "review_repair_no_change"].includes(code)
18
+ || (code === "invalid_work_state" && details.retryable_no_effect === true);
18
19
  }
19
20
  function storage(context) {
20
21
  context.db.execSchema(`CREATE TABLE IF NOT EXISTS lifecycle_invocations (
@@ -51,6 +52,30 @@ function load(context, id) {
51
52
  export function lifecycleInvocationScope(context, id) {
52
53
  return JSON.parse(load(context, id).scope_json);
53
54
  }
55
+ /** Current, explicitly-issued replacements for commands from the resumed stage.
56
+ * Original prompts remain immutable evidence; callers may present this small
57
+ * overlay after a state transition made an earlier no-effect rejection retryable. */
58
+ export function lifecycleRetryCommands(context, stage) {
59
+ const scope = context.env.DD_FLOW_INVOCATION_SCOPE;
60
+ if (!scope || !context.db.get("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'lifecycle_invocations'"))
61
+ return [];
62
+ return context.db.all("SELECT * FROM lifecycle_invocations WHERE scope_json = ? AND status = 'settled' ORDER BY rowid", [scope])
63
+ .flatMap((row) => {
64
+ try {
65
+ const outcome = row.outcome_json ? JSON.parse(row.outcome_json) : null;
66
+ const retry = outcome?.error?.details?.retry_command;
67
+ const parsed = invocation(row.command);
68
+ if (typeof retry !== "string" || parsed.operation !== "stage_finish" || commandOption(parsed, "stage") !== stage)
69
+ return [];
70
+ const successor = load(context, commandOption(invocation(retry), "invocation-id") ?? "");
71
+ const latest = context.db.get("SELECT * FROM lifecycle_invocations WHERE scope_json = ? AND fingerprint = ? ORDER BY rowid DESC LIMIT 1", [row.scope_json, row.fingerprint]);
72
+ return successor.id === latest?.id && successor.status === "issued" ? [retry] : [];
73
+ }
74
+ catch {
75
+ return [];
76
+ }
77
+ });
78
+ }
54
79
  export function managedInvocationContext(context, input) {
55
80
  const env = { ...context.env, DD_FLOW_CURRENT_INVOCATION: undefined, DD_FLOW_INVOCATION_SCOPE: undefined };
56
81
  if (input.harness !== "zcode-acp")
@@ -81,15 +106,16 @@ export function managedLifecycleCommand(context, command) {
81
106
  ? context.db.get("SELECT * FROM lifecycle_invocations WHERE scope_json = ? AND fingerprint = ? ORDER BY rowid DESC LIMIT 1", [JSON.stringify(scope), fingerprint(parsed)]) : undefined;
82
107
  if (!retained)
83
108
  throw new AppError("invocation_command_unprepared", "Read-only inspection has no retained lifecycle command", 1);
84
- return retained.command;
109
+ return renderInvocationCommand(retained, command);
85
110
  }
86
111
  storage(context);
87
112
  context.db.exec("SAVEPOINT invocation_command");
88
113
  try {
89
114
  const prior = context.db.get("SELECT * FROM lifecycle_invocations WHERE scope_json = ? AND fingerprint = ? ORDER BY rowid DESC LIMIT 1", [JSON.stringify(scope), fingerprint(parsed)]);
90
115
  if (prior && prior.id !== context.env.DD_FLOW_CURRENT_INVOCATION) {
116
+ const rendered = renderInvocationCommand(prior, command);
91
117
  context.db.exec("RELEASE invocation_command");
92
- return prior.command;
118
+ return rendered;
93
119
  }
94
120
  if (context.env.DD_FLOW_INVOCATION_READONLY === "1")
95
121
  throw new AppError("invocation_command_unprepared", "No prepared lifecycle command is available; read-only inspection cannot issue one", 1);
@@ -112,6 +138,31 @@ export function retryLifecycleInvocationCommand(context, id) {
112
138
  throw new AppError("invocation_command_invalid", "Retained issued command lost its invocation marker", 1);
113
139
  return managedLifecycleCommand({ ...context, env: { ...context.env, DD_FLOW_INVOCATION_SCOPE: prior.scope_json, DD_FLOW_CURRENT_INVOCATION: id } }, prior.command.replace(marker, ""));
114
140
  }
141
+ /** Settle a conclusive rejection and publish its only legal successor atomically.
142
+ * A crash must leave either the old attempt executing, or both the settled
143
+ * outcome and its successor; never a usable successor beside an unknown effect. */
144
+ export function settleLifecycleRejection(context, id, error) {
145
+ storage(context);
146
+ const details = { ...error.details };
147
+ context.db.exec("BEGIN IMMEDIATE");
148
+ try {
149
+ const prior = load(context, id);
150
+ if (prior.status !== "executing")
151
+ throw new AppError("invocation_settlement_conflict", "Attempt is no longer executing", 1, { invocation_id: id });
152
+ if (isRetryableLifecycleRejection(error.code, error.details)) {
153
+ const retry = retryLifecycleInvocationCommand(context, id);
154
+ details.retry_command = retry;
155
+ details.retry_instruction = "Correct only the evidenced defect, then use retry_command with the required result input. Reissuing the old command only reads its retained outcome.";
156
+ }
157
+ settleLifecycleInvocation(context, id, { error: { ...error, details } });
158
+ context.db.exec("COMMIT");
159
+ Object.assign(error.details, details);
160
+ }
161
+ catch (error) {
162
+ context.db.exec("ROLLBACK");
163
+ throw error;
164
+ }
165
+ }
115
166
  /** Recovery preserves the original packet as evidence. Only its retained,
116
167
  * never-executed commands receive new authority; unknown effects never replay. */
117
168
  export function recoverLifecycleCommands(context, originalPrompt, scope) {
@@ -206,14 +257,28 @@ export function issueLifecycleInvocation(context, command, scope) {
206
257
  throw new AppError("invocation_scope_invalid", "Lifecycle issuance requires explicit managed scope and a new command", 1);
207
258
  }
208
259
  const id = crypto.randomUUID();
209
- const at = shellSuffixOffset(command);
210
- const prepared = `${command.slice(0, at).trimEnd()} --invocation-id ${id} ${command.slice(at)}`.trimEnd();
260
+ const prepared = commandWithInvocationId(command, id);
211
261
  // Validate the actual inserted argv, including quoted heredoc boundaries.
212
262
  const row = { id, scope_json: JSON.stringify(scope), fingerprint: fingerprint(parsed) };
213
263
  checkCommand(row, prepared);
214
264
  context.db.run("INSERT INTO lifecycle_invocations (id, scope_json, command, fingerprint, status, created_at, updated_at) VALUES (?, ?, ?, ?, 'issued', ?, ?)", [id, row.scope_json, prepared, row.fingerprint, context.now(), context.now()]);
215
265
  return { id, command: prepared };
216
266
  }
267
+ function commandWithInvocationId(command, id) {
268
+ const at = shellSuffixOffset(command);
269
+ return `${command.slice(0, at).trimEnd()} --invocation-id ${id} ${command.slice(at)}`.trimEnd();
270
+ }
271
+ /** The attempt owns authority; the caller owns presentation-only shell input.
272
+ * Preserve current stdin/heredoc/response-file text while validating that it
273
+ * still names the exact persisted operation and project. */
274
+ function renderInvocationCommand(row, command) {
275
+ const parsed = invocation(command);
276
+ if (commandOption(parsed, "invocation-id"))
277
+ throw new AppError("invocation_command_invalid", "Rendered lifecycle command already has an invocation id", 1);
278
+ const rendered = commandWithInvocationId(command, row.id);
279
+ checkCommand(row, rendered);
280
+ return rendered;
281
+ }
217
282
  /** Native observer and receipt writer commit together. Never await I/O inside
218
283
  * this transaction; the callback must write to this same SQLite connection. */
219
284
  export function observeLifecycleInvocation(context, input) {
@@ -3,7 +3,7 @@ import fs from "node:fs";
3
3
  import os from "node:os";
4
4
  import path from "node:path";
5
5
  import { quote } from "shell-quote";
6
- import { managedLifecycleCommand } from "./lifecycle-invocations.js";
6
+ import { lifecycleRetryCommands, managedLifecycleCommand } from "./lifecycle-invocations.js";
7
7
  import { AppError } from "../shared/errors.js";
8
8
  import { resolveProjectRoot } from "../storage/paths.js";
9
9
  import { findRecentMatchingHookEvent, claimStageResumeHookEvent, stageResumeMatchKey } from "./hooks.js";
@@ -73,16 +73,22 @@ export function resumeStageAfterUser(context, input) {
73
73
  errorCode: "trusted_stage_resume_required",
74
74
  operation: "stage resume"
75
75
  }).eventKey;
76
- const answerPath = path.join(path.dirname(stage.pause.question_path), "answer.md");
77
- fs.writeFileSync(answerPath, input.answer);
78
76
  const now = context.now();
79
77
  const workSession = context.db.get("SELECT id, session_id, prompt_path, result_path, status FROM work_sessions WHERE work_id = ? AND status = 'running' ORDER BY created_at DESC LIMIT 1", [work.work_id]);
80
78
  if (!workSession)
81
79
  throw new AppError("runtime_missing", "Paused Work has no open Session link", 1, { work_id: work.work_id });
80
+ const answerPath = path.join(path.dirname(stage.pause.question_path), `answer-${crypto.createHash("sha256").update(`${stage.pause.id}\0${input.answer}`).digest("hex")}.md`);
82
81
  context.db.exec("BEGIN IMMEDIATE");
83
82
  let identity;
84
83
  try {
85
84
  identity = claimStageResumeHookEvent(context, { projectId: project.id, runId: run.id, stage: stage.stage, workId: work.work_id, projectRoot, eventKey: hookEventId });
85
+ try {
86
+ fs.writeFileSync(answerPath, input.answer, { flag: "wx", mode: 0o600 });
87
+ }
88
+ catch (error) {
89
+ if (error.code !== "EEXIST" || fs.readFileSync(answerPath, "utf8") !== input.answer)
90
+ throw error;
91
+ }
86
92
  if (workSession.session_id !== identity.sessionId) {
87
93
  bindSessionForResume(context, work.work_id, identity, now);
88
94
  context.db.run("UPDATE work_sessions SET status = 'completed', updated_at = ?, completed_at = ? WHERE id = ?", [now, now, workSession.id]);
@@ -101,6 +107,7 @@ export function resumeStageAfterUser(context, input) {
101
107
  refreshRunWorkProjection(context, project.id, run.id);
102
108
  appendFlowRunTimelineEvent(context, project.id, run.id, { type: "stage_resume_session_bound", stage: stage.stage, work_id: work.work_id, session_id: identity.sessionId });
103
109
  const prompt = fs.readFileSync(workSession.prompt_path, "utf8");
110
+ const retryCommands = lifecycleRetryCommands(context, stage.stage);
104
111
  const continuation = [
105
112
  prompt.trimEnd(),
106
113
  "",
@@ -109,6 +116,7 @@ export function resumeStageAfterUser(context, input) {
109
116
  `- Question source: ${stage.pause.question_path}`,
110
117
  `- Answer source: ${answerPath}`,
111
118
  "- Continue the same stage, Work and attempt. Do not call stage start or repeat completed preparation.",
119
+ ...(retryCommands.length ? ["- A prior no-effect lifecycle rejection now has these authoritative replacement commands; use only these replacements, never invent an invocation id:", ...retryCommands.map(command => ` ${command}`)] : []),
112
120
  "",
113
121
  "<user_question>",
114
122
  fs.readFileSync(stage.pause.question_path, "utf8"),
@@ -128,7 +128,9 @@ export function submitVnextSpecify(context, input) {
128
128
  if (work.project_id !== project.id)
129
129
  throw new AppError("project_mismatch", "Work does not belong to --project-root", 1, { work_id: work.work_id });
130
130
  if (work.status !== "running") {
131
- throw new AppError("invalid_work_state", "Work is not waiting for a SPECIFY result", 1, { work_id: work.work_id, status: work.status });
131
+ // This check precedes all result reads and mutations. A later legal state
132
+ // transition may make a new invocation appropriate, but never replays this one.
133
+ throw new AppError("invalid_work_state", "Work is not waiting for a SPECIFY result", 1, { work_id: work.work_id, status: work.status, retryable_no_effect: true });
132
134
  }
133
135
  const run = requireRun(context, projectRoot, work.run_id);
134
136
  if (runStatus(context, project.id, run.id) !== "running") {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@deksden-com/dd-flow-cli",
3
- "version": "0.9.0-beta.53",
3
+ "version": "0.9.0-beta.54",
4
4
  "description": "Mechanical runtime CLI for dd-flow workflows.",
5
5
  "type": "module",
6
6
  "bin": {