@deksden-com/dd-flow-cli 0.9.0-beta.90 → 0.9.0-beta.91

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,12 @@
1
1
  # @deksden-com/dd-flow-cli
2
2
 
3
+ ## 0.9.0-beta.91
4
+
5
+ ### Patch Changes
6
+
7
+ - 8650f0f: Unify managed lifecycle command syntax, preserve terminal preparation errors,
8
+ bound unchanged retries, and retain causal controller failures through MERGE.
9
+
3
10
  ## 0.9.0-beta.90
4
11
 
5
12
  ### Patch Changes
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "cli_package": "@deksden-com/dd-flow-cli",
3
- "cli_version": "0.9.0-beta.90",
4
- "cli_commit": "a4e9733f871af5032fc372d324d8fd0dfd2ec44f",
5
- "built_at": "2026-09-21T11:54:11.387Z",
3
+ "cli_version": "0.9.0-beta.91",
4
+ "cli_commit": "82e57ba33fbc4e915c2b5809dd82fe9033969c3a",
5
+ "built_at": "2026-09-21T18:07:32.802Z",
6
6
  "built_with_canon": {
7
7
  "version": "4.1.1",
8
8
  "commit": "d1a6081ab15ab92ac917ff5d037121a40c709db1",
@@ -1,5 +1,6 @@
1
1
  import { AppError } from "../shared/errors.js";
2
2
  import { parseCommandArgs } from "../services/lifecycle-command.js";
3
+ import { lifecycleInternalOptions, lifecycleRouteForOperation } from "../services/lifecycle-contract.js";
3
4
  import { isUpgradeAllowlisted } from "../services/cli-operation-classifier.js";
4
5
  const words = (value) => value.split(" ").filter(Boolean);
5
6
  const route = (positions, required = "", values = "", switches = "", repeatable = "", enums = {}) => ({
@@ -8,7 +9,7 @@ const route = (positions, required = "", values = "", switches = "", repeatable
8
9
  });
9
10
  const project = "project-root";
10
11
  const scoped = "project-root"; // Optional when the router resolves a unique retained scope.
11
- const lifecycle = "invocation-id hook-event-id";
12
+ const lifecycle = lifecycleInternalOptions.join(" ");
12
13
  const worker = "worker-id";
13
14
  const control = "project-root run";
14
15
  /** Path-shaped arguments default to physical paths unless the route explicitly
@@ -154,8 +155,8 @@ export const commandInputs = {
154
155
  "merge serve": route(0, "agent-profile", "poll-seconds max-parallel-projects", "once"),
155
156
  "merge server status": route(0),
156
157
  "merge server stop": route(1),
157
- "merge apply": route(1, `${project} work`),
158
- "merge repair": route(1, project),
158
+ "merge apply": route(1, `${project} work`, lifecycle),
159
+ "merge repair": route(1, project, lifecycle),
159
160
  "merge request status": route(1, project),
160
161
  "merge request route": route(1, `${project} mode reason`, "", "", "", { mode: ["same_session", "server"] }),
161
162
  "merge status": route(0, project, "path"),
@@ -225,7 +226,7 @@ export const commandInputs = {
225
226
  "memory permissions preflight": route(0, "root memory-bank flow mode", "tasks")
226
227
  };
227
228
  export function contextualPathOptionsForLifecycle(operation) {
228
- const key = operation === "recovery_accept" ? "run recovery accept" : operation.replaceAll("_", " ");
229
+ const key = lifecycleRouteForOperation(operation);
229
230
  return new Set(commandInputs[key]?.contextualPaths ?? []);
230
231
  }
231
232
  const flag = "__dd_flow_flag__";
@@ -1,5 +1,6 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
+ import crypto from "node:crypto";
3
4
  import { AppError, isAppError } from "../shared/errors.js";
4
5
  import { validateSchema } from "../services/schema-validation.js";
5
6
  export function prepareError(error, input = {}) {
@@ -19,8 +20,8 @@ export function prepareError(error, input = {}) {
19
20
  throw new AppError(error.code, error.message, 2, {
20
21
  ...error.details,
21
22
  phase: "prepare",
22
- effect: "no_effect",
23
- recoverable: true,
23
+ effect: error.details.effect ?? "no_effect",
24
+ recoverable: error.details.recoverable ?? true,
24
25
  ...input
25
26
  });
26
27
  }
@@ -68,7 +69,7 @@ export function prepareJsonText(text, input) {
68
69
  // safe correction-and-retry condition.
69
70
  throw new AppError("invalid_json", `Invalid JSON in ${input.label ?? "input"}: ${input.file}`, 2, {
70
71
  phase: "prepare", effect: "no_effect", recoverable: true, file: input.file, label: input.label ?? "input",
71
- cause: "Invalid JSON syntax"
72
+ cause: "Invalid JSON syntax", input_sha256: crypto.createHash("sha256").update(text).digest("hex")
72
73
  });
73
74
  }
74
75
  }
@@ -217,7 +217,17 @@ export async function runCli(args, io = defaultIo, env = process.env) {
217
217
  if (invocationId) {
218
218
  const command = quote(["dd-flow", ...invocationArgs]);
219
219
  const admission = await awaitLifecycleInvocation(dispatchContext, { id: invocationId, command,
220
- validateArgs: () => { preparedRoute = prepareCommandRoute(invocationArgs); },
220
+ validateArgs: () => {
221
+ try {
222
+ preparedRoute = prepareCommandRoute(invocationArgs);
223
+ }
224
+ catch (error) {
225
+ if (error instanceof AppError && error.code === "usage" && ["invocation-id", "hook-event-id"].includes(String(error.details.parameter ?? ""))) {
226
+ throw new AppError("lifecycle_contract_invalid", `The runtime generated arguments rejected by its own CLI contract: ${error.message}`, 1, { ...error.details, recoverable: false, operation: observedOperation });
227
+ }
228
+ throw error;
229
+ }
230
+ },
221
231
  prepare: async () => { preparedInput = await prepareCliInput(invocationArgs, dispatchContext, io, scopeProjectRoot, output.responseFile, preparedRoute); },
222
232
  assertCurrent: scope => assertLifecycleInvocationCurrent(dispatchContext, scope, command) });
223
233
  if (admission.replay) {
@@ -3330,7 +3340,7 @@ function projectRootForMutation(context, args, result, resolvedScope) {
3330
3340
  return projectRootForMergeQueueMutation(context, command, parsed);
3331
3341
  }
3332
3342
  if (family === "merge" && ["one-shot", "apply", "repair", "request"].includes(command ?? "")) {
3333
- return requiredOption(parsed, "project-root");
3343
+ return resolvedScope ?? requiredOption(parsed, "project-root");
3334
3344
  }
3335
3345
  if (family === "merge-worker" && ["start", "stop"].includes(command ?? "")) {
3336
3346
  return requiredOption(parsed, "project-root");
@@ -3348,7 +3358,7 @@ function projectRootForMutation(context, args, result, resolvedScope) {
3348
3358
  return requiredOption(parsed, "project-root");
3349
3359
  }
3350
3360
  if (family === "stage" && ["start", "pause", "resume", "finish"].includes(command ?? "")) {
3351
- return requiredOption(parsed, "project-root");
3361
+ return resolvedScope ?? requiredOption(parsed, "project-root");
3352
3362
  }
3353
3363
  if (family === "worktree" && ["create", "bootstrap", "close"].includes(command ?? "")) {
3354
3364
  return protocolProjectRoot(context, requiredOption(parsed, "protocol-id"));
@@ -1,5 +1,6 @@
1
1
  import path from "node:path";
2
2
  import { parse, quote } from "shell-quote";
3
+ import { lifecycleOperationForArgv } from "./lifecycle-contract.js";
3
4
  /** Native hooks and the lightweight ingress must recognize the same shell. */
4
5
  export function lifecycleCommandFromPayload(payload) {
5
6
  const input = payload.tool_input && typeof payload.tool_input === "object" ? payload.tool_input : {};
@@ -203,33 +204,7 @@ function wrappedInvocationFromSegment(segment) {
203
204
  return parseLifecycleCommandInner(payload, true);
204
205
  }
205
206
  function lifecycleOperation(argv) {
206
- // The CLI handles help before dispatch, wherever the flag occurs.
207
- if (argv.some(arg => arg === "--help" || arg === "-h"))
208
- return null;
209
- if (argv[0] === "run" && argv[1] === "recovery" && argv[2] === "accept")
210
- return "recovery_accept";
211
- const key = `${argv[0] ?? ""} ${argv[1] ?? ""}`;
212
- if (key === "session register")
213
- return "session_register";
214
- if (key === "stage start")
215
- return "stage_start";
216
- if (key === "stage finish")
217
- return "stage_finish";
218
- if (key === "stage pause")
219
- return "stage_pause";
220
- if (key === "stage resume")
221
- return "stage_resume";
222
- if (key === "work start")
223
- return "work_start";
224
- if (key === "work finish")
225
- return "work_finish";
226
- if (key === "work fail")
227
- return "work_fail";
228
- if (key === "merge apply")
229
- return "merge_apply";
230
- if (key === "merge repair")
231
- return "merge_repair";
232
- return null;
207
+ return lifecycleOperationForArgv(argv);
233
208
  }
234
209
  export function parseCommandArgs(argv) {
235
210
  const positional = [];
@@ -0,0 +1,30 @@
1
+ export const lifecycleRoutes = {
2
+ "session register": "session_register",
3
+ "stage start": "stage_start",
4
+ "stage finish": "stage_finish",
5
+ "stage pause": "stage_pause",
6
+ "stage resume": "stage_resume",
7
+ "work start": "work_start",
8
+ "work finish": "work_finish",
9
+ "work fail": "work_fail",
10
+ "merge apply": "merge_apply",
11
+ "merge repair": "merge_repair",
12
+ "run recovery accept": "recovery_accept"
13
+ };
14
+ export const lifecycleInternalOptions = ["invocation-id", "hook-event-id"];
15
+ export function lifecycleOperationForArgv(argv) {
16
+ if (argv.some(arg => arg === "--help" || arg === "-h"))
17
+ return null;
18
+ for (const [route, operation] of Object.entries(lifecycleRoutes)) {
19
+ const parts = route.split(" ");
20
+ if (parts.every((part, index) => argv[index] === part))
21
+ return operation;
22
+ }
23
+ return null;
24
+ }
25
+ export function lifecycleRouteForOperation(operation) {
26
+ const entry = Object.entries(lifecycleRoutes).find(([, value]) => value === operation);
27
+ if (!entry)
28
+ throw new Error(`Unknown lifecycle operation: ${operation}`);
29
+ return entry[0];
30
+ }
@@ -67,6 +67,8 @@ export function isRetryableLifecycleRejection(code, details = {}) {
67
67
  return false;
68
68
  if (code === "validation" || code === "schema_validation")
69
69
  return details.phase === "prepare" && details.effect === "no_effect" && details.recoverable === true;
70
+ if (["invocation_command_mismatch", "invocation_argument_mismatch"].includes(code))
71
+ return details.recoverable !== false;
70
72
  return details.recoverable === true || ["work_checks_failed", "code_gate_failed", "code_review_gate_failed", "merge_gate_failed",
71
73
  "review_repair_incomplete", "plan_review_incomplete", "semantic_result_invalid",
72
74
  "review_evidence_invalid", "evidence_obligation_unknown", "invalid_evidence_ref",
@@ -966,13 +968,78 @@ export function settleLifecyclePreparationRejection(context, id, error) {
966
968
  const scope = JSON.parse(row.scope_json);
967
969
  if (scope.runId)
968
970
  assertLifecycleInvocationCurrent(context, scope, row.command);
969
- const details = { ...error.details, effect: "no_effect", recoverable: true,
970
- retry_command: successorLifecycleInvocationCommand(context, row),
971
- retry_instruction: "Execute retry_command verbatim in this same Session. The rejected call changed no lifecycle state." };
971
+ const signature = preparationRejectionSignature(error);
972
+ const repeated = context.db.all("SELECT outcome_json FROM lifecycle_invocations WHERE scope_json = ? AND fingerprint = ? AND id <> ? AND status = 'settled'", [row.scope_json, row.fingerprint, row.id])
973
+ .some(prior => { try {
974
+ return JSON.parse(prior.outcome_json ?? "null")?.error?.details?.rejection_signature === signature;
975
+ }
976
+ catch {
977
+ return false;
978
+ } });
979
+ const retryable = !repeated && isRetryableLifecycleRejection(error.code, error.details);
980
+ const details = { ...error.details, effect: "no_effect", recoverable: retryable, rejection_signature: signature,
981
+ ...(repeated ? { repeated_without_progress: true } : {}),
982
+ ...(retryable ? {
983
+ retry_command: successorLifecycleInvocationCommand(context, row),
984
+ retry_instruction: "Correct the reported input, then execute retry_command verbatim in this same Session. The rejected call changed no lifecycle state."
985
+ } : {}) };
972
986
  context.db.run("UPDATE lifecycle_invocations SET status = 'settled', outcome_json = ?, updated_at = ? WHERE id = ?", [JSON.stringify({ error: { ...errorRecord(error), details } }), context.now(), row.id]);
973
987
  return details;
974
988
  });
975
989
  }
990
+ function preparationRejectionSignature(error) {
991
+ const omitted = new Set(["retry_command", "retry_instruction", "invocation_id", "hook_event_id", "rejection_signature"]);
992
+ const normalize = (value) => {
993
+ if (Array.isArray(value))
994
+ return value.map(normalize);
995
+ if (!value || typeof value !== "object")
996
+ return value;
997
+ return Object.fromEntries(Object.entries(value).filter(([key]) => !omitted.has(key)).sort(([a], [b]) => a.localeCompare(b)).map(([key, item]) => [key, normalize(item)]));
998
+ };
999
+ return crypto.createHash("sha256").update(JSON.stringify([error.code, normalize(error.details)])).digest("hex");
1000
+ }
1001
+ export function currentLifecycleFailure(context, input) {
1002
+ const attempts = context.db.all("SELECT * FROM lifecycle_invocations ORDER BY updated_at DESC, rowid DESC");
1003
+ const scoped = attempts.filter(row => {
1004
+ try {
1005
+ const scope = JSON.parse(row.scope_json);
1006
+ return scope.runId === input.runId && scope.generation === input.generation;
1007
+ }
1008
+ catch {
1009
+ return false;
1010
+ }
1011
+ });
1012
+ const fingerprints = [...new Set(scoped.map(row => row.fingerprint))];
1013
+ for (const fingerprint of fingerprints) {
1014
+ const rows = scoped.filter(row => row.fingerprint === fingerprint);
1015
+ if (rows.some(row => ["issued", "observed", "executing"].includes(row.status)))
1016
+ continue;
1017
+ const row = rows.find(candidate => candidate.status === "settled");
1018
+ if (!row)
1019
+ continue;
1020
+ let outcome;
1021
+ try {
1022
+ outcome = JSON.parse(row.outcome_json ?? "null") ?? {};
1023
+ }
1024
+ catch {
1025
+ continue;
1026
+ }
1027
+ if (!outcome.error?.code || !outcome.error.message)
1028
+ continue;
1029
+ let parsed;
1030
+ try {
1031
+ parsed = invocation(row.command);
1032
+ }
1033
+ catch {
1034
+ continue;
1035
+ }
1036
+ const stage = parsed.operation.startsWith("merge_") ? "merge" : commandOption(parsed, "stage");
1037
+ if (stage !== input.stage)
1038
+ continue;
1039
+ return { code: outcome.error.code, message: outcome.error.message, details: outcome.error.details ?? {}, invocation_id: row.id };
1040
+ }
1041
+ return null;
1042
+ }
976
1043
  /** One caller owns execution; a lost process leaves an explicit unknown
977
1044
  * outcome, never an automatic replay. Existing recovery decides the next step. */
978
1045
  export async function awaitLifecycleInvocation(context, input) {
@@ -1,4 +1,4 @@
1
- import { assertLifecycleOutcomes, managedInvocationContext, managedLifecycleCommand } from "./lifecycle-invocations.js";
1
+ import { assertLifecycleOutcomes, currentLifecycleFailure, managedInvocationContext, managedLifecycleCommand } from "./lifecycle-invocations.js";
2
2
  import crypto from "node:crypto";
3
3
  import fs from "node:fs";
4
4
  import path from "node:path";
@@ -711,8 +711,12 @@ async function executeController(context, row, manifest, state, assertOwnership,
711
711
  const children = controllerNativeChildren(state.last_receipt ?? {}, session.id).filter(child => !baseline.has(child.session_id));
712
712
  const fanout = await nextControllerFanout(executionContext, { projectRoot: run.project_root, runId: run.id, stage: next.stage, harness: runtimeHarness(session.profile), sessionId: session.id, children, policy, profiles });
713
713
  assertOwner();
714
- if (fanout.kind === "none")
714
+ if (fanout.kind === "none") {
715
+ const cause = currentLifecycleFailure(executionContext, { runId: run.id, generation: row.generation, stage: next.stage });
716
+ if (cause)
717
+ throw new AppError(cause.code, cause.message, 1, { ...cause.details, invocation_id: cause.invocation_id, controller_wrapper: "incomplete_subject_turn", stage: next.stage });
715
718
  throw new AppError("incomplete_subject_turn", "Managed Turn ended without a completed Stage or declared continuation", 1, { stage: next.stage });
719
+ }
716
720
  if (fanout.kind === "wait") {
717
721
  setStatus(fanout.reason === "qualified_capacity_required" ? "waiting_for_capacity" : "waiting_for_children");
718
722
  await delay(1000);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@deksden-com/dd-flow-cli",
3
- "version": "0.9.0-beta.90",
3
+ "version": "0.9.0-beta.91",
4
4
  "description": "Mechanical runtime CLI for dd-flow workflows.",
5
5
  "type": "module",
6
6
  "bin": {