@deksden-com/dd-flow-cli 0.9.0-beta.82 → 0.9.0-beta.86

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,32 @@
1
1
  # @deksden-com/dd-flow-cli
2
2
 
3
+ ## 0.9.0-beta.86
4
+
5
+ ### Patch Changes
6
+
7
+ - 8abe471: Settle invalid managed lifecycle arguments atomically before dispatch and return one safe corrected command.
8
+
9
+ ## 0.9.0-beta.85
10
+
11
+ ### Patch Changes
12
+
13
+ - fe8f493: Restore runtime-owned response publication options before dispatching a managed lifecycle command, and update the controller fixture to validate the public command contract.
14
+
15
+ ## 0.9.0-beta.84
16
+
17
+ ### Patch Changes
18
+
19
+ - 644d181: Keep RUN identity, project paths, context files, response destinations and
20
+ integrity hashes inside retained lifecycle authority instead of asking agents
21
+ to copy them. Restore those inputs deterministically when the observed command
22
+ executes, including MERGE apply and repair commands.
23
+
24
+ ## 0.9.0-beta.83
25
+
26
+ ### Patch Changes
27
+
28
+ - bf69c56: Accept shell-escaped lifecycle path aliases deterministically and render model-facing aliases with stable quoting.
29
+
3
30
  ## 0.9.0-beta.82
4
31
 
5
32
  ### Patch Changes
@@ -1230,6 +1257,7 @@ record` command with the bounded 15-probe contract.
1230
1257
  - 16f68a3: Add router-native engine snapshot commands and routed project-command dispatch.
1231
1258
 
1232
1259
  Also include the runtime/dashboard compatibility wave needed by the current Memory Bank canon:
1260
+
1233
1261
  - target-based dashboard `open`/`refresh` commands and related-command help;
1234
1262
  - persisted FIFO lane waiters and merge wait-acquire specialization;
1235
1263
  - `migration plan/report/verify` evidence helpers for controlled `mb-upgrade` runtime migrations;
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "cli_package": "@deksden-com/dd-flow-cli",
3
- "cli_version": "0.9.0-beta.82",
4
- "cli_commit": "e6ab8fb3cc284cc7f3c2ca4dd77c4490ef980f76",
5
- "built_at": "2026-09-20T10:58:21.835Z",
3
+ "cli_version": "0.9.0-beta.86",
4
+ "cli_commit": "979a6151c910228ed3da8daa015bcf89098b04f1",
5
+ "built_at": "2026-09-20T19:12:05.808Z",
6
6
  "built_with_canon": {
7
7
  "version": "4.1.1",
8
8
  "commit": "97f811d33c212ae3497020178b1ed825c7c3ebac",
@@ -4,7 +4,7 @@ import { randomUUID } from "node:crypto";
4
4
  import { quote } from "shell-quote";
5
5
  import { observeLifecycleCommand } from "../services/lifecycle-invocations.js";
6
6
  import { verifyCodexHookDelivery } from "../services/codex-hook-delivery.js";
7
- import { assertLifecycleInvocationCurrent, awaitLifecycleInvocation, expandLifecycleInvocationArgs, issueLifecycleInvocation, prepareLifecycleIssuance, lifecycleInvocationScope, observedLifecycleInvocation, settleLifecycleInvocation, settleLifecycleRejection } from "../services/lifecycle-invocations.js";
7
+ import { assertLifecycleInvocationCurrent, awaitLifecycleInvocation, expandLifecycleInvocationArgs, issueLifecycleInvocation, prepareLifecycleIssuance, lifecycleInvocationScope, observedLifecycleInvocation, resolveObservedLifecycleArgs, settleLifecycleInvocation, settleLifecyclePreparationRejection, settleLifecycleRejection, validateLifecyclePathAliases } from "../services/lifecycle-invocations.js";
8
8
  import { createContext, createRouterContext } from "../runtime/context.js";
9
9
  import { migrateStoreWriter } from "../storage/writer-migration.js";
10
10
  import { helpForArgs } from "./help.js";
@@ -130,21 +130,44 @@ export async function runCli(args, io = defaultIo, env = process.env) {
130
130
  const routerContext = createRouterContext(env);
131
131
  const publicCommand = quote(["dd-flow", ...output.args]);
132
132
  const lifecycleCommand = parseLifecycleCommand(publicCommand);
133
+ if (lifecycleCommand.kind === "standalone")
134
+ validateLifecyclePathAliases(output.args, lifecycleCommand.invocation.operation);
133
135
  const observedInvocation = observedLifecycleInvocation(routerContext, publicCommand);
134
136
  if (!observedInvocation && !suppliedInvocationId && env.DD_FLOW_DAEMON_ID && lifecycleCommand.kind === "standalone") {
135
137
  throw new AppError("invocation_receipt_missing", "Managed lifecycle command has no committed native receipt", 1, { effect: "no_effect", recoverable: false });
136
138
  }
137
139
  const invocationId = observedInvocation?.id ?? suppliedInvocationId;
138
- const boundInvocationArgs = invocationId && invocationId !== suppliedInvocationId
139
- ? [...output.args.filter((_, index, values) => values[index - 1] !== "--invocation-id" && values[index] !== "--invocation-id"), "--invocation-id", invocationId]
140
- : output.args;
140
+ let boundInvocationArgs;
141
+ try {
142
+ boundInvocationArgs = observedInvocation
143
+ ? resolveObservedLifecycleArgs(routerContext, observedInvocation.id, output.args)
144
+ : invocationId && invocationId !== suppliedInvocationId
145
+ ? [...output.args.filter((_, index, values) => values[index - 1] !== "--invocation-id" && values[index] !== "--invocation-id"), "--invocation-id", invocationId]
146
+ : output.args;
147
+ }
148
+ catch (error) {
149
+ if (!(observedInvocation && error instanceof AppError))
150
+ throw error;
151
+ const rejectionContext = createContext({ ...env }, "hook");
152
+ try {
153
+ Object.assign(error.details, settleLifecyclePreparationRejection(rejectionContext, observedInvocation.id, error));
154
+ }
155
+ finally {
156
+ rejectionContext.db.close?.();
157
+ }
158
+ throw error;
159
+ }
141
160
  const observedOperation = lifecycleCommand.kind === "standalone" ? lifecycleCommand.invocation.operation : null;
142
161
  if (observedInvocation && !observedOperation)
143
162
  throw new AppError("invocation_command_invalid", "Observed lifecycle command could not be parsed", 1);
144
163
  const invocationScope = invocationId ? lifecycleInvocationScope({ ...routerContext, env }, invocationId) : undefined;
145
- const invocationArgs = invocationScope && observedOperation
164
+ const expandedInvocationArgs = invocationScope && observedOperation
146
165
  ? expandLifecycleInvocationArgs(routerContext, boundInvocationArgs, invocationScope, observedOperation)
147
166
  : boundInvocationArgs;
167
+ const retainedOutput = observedInvocation ? parseOutputOptions(expandedInvocationArgs) : null;
168
+ if (retainedOutput?.responseFile)
169
+ output.responseFile = retainedOutput.responseFile;
170
+ const invocationArgs = retainedOutput?.args ?? expandedInvocationArgs;
148
171
  emitHumanProgress(io, output, progress, "start");
149
172
  if (output.progressJsonl) {
150
173
  writeProgressJsonl(io, "start", `Starting ${output.args.slice(0, 3).join(" ")}`);
@@ -5,11 +5,11 @@ import { dispatchVnextPlanReview } from "./vnext-plan-review.js";
5
5
  import { resolveExecutionPolicy } from "./execution-policy.js";
6
6
  import { renderDelegationInstructions } from "../harness-runtime/lib/delegation-instructions.mjs";
7
7
  /** Stage entry acknowledges the packet before the controller issues child commands. */
8
- export function controllerStageEntryPrompt(stage, command, responseFile) {
8
+ export function controllerStageEntryPrompt(stage, command) {
9
9
  return [
10
10
  `Start the assigned ${stage} Stage now. Your first tool call must be this exact standalone command:`,
11
11
  command,
12
- `Read the complete authoritative packet saved to ${JSON.stringify(responseFile)}, including worker_prompt_markdown. If a read is truncated, continue until the entire packet has been read.`,
12
+ "Read the complete authoritative packet from the command's response_file result, including worker_prompt_markdown. If a read is truncated, continue until the entire packet has been read.",
13
13
  "If the returned packet has orchestration.kind = work_fanout, this Turn is only Stage entry: acknowledge the packet and end your Turn immediately. The packet describes the whole Stage, but its graph/dispatch/finish instructions belong to later controller continuations. Do not list, start, launch or finish Work, create children, change files, run checks, or finish the Stage in this entry Turn. The controller will inspect the graph and send the next assignment with exact issued commands (or dispatch external workers itself).",
14
14
  "Otherwise perform this Stage's semantic work and stop at its boundary or a declared HITL boundary. Do not start a successor Stage.",
15
15
  "Execute each runtime-issued lifecycle operation and target as one standalone call. Internal invocation IDs are intentionally absent; never add, copy or invent one. For a no-effect input rejection, correct the input and use its returned retry_command. If a required command is missing or no safe retry is returned, report the exact error and end the Turn so the controller can handle it."
@@ -352,7 +352,7 @@ export function handleCodexHook(context, input) {
352
352
  ?? (bindingOwnerId ? sessionBinding(context, project.id, bindingOwnerId)?.transcript_path ?? locateCodexTranscript(bindingOwnerId, context.env.CODEX_HOME) : null);
353
353
  const persist = (scope) => {
354
354
  if (scope)
355
- matchKey = lifecycleMatchKey(lifecycle, project.root, scope);
355
+ matchKey = lifecycleMatchKey(lifecycleFacts(scope.command), project.root, scope);
356
356
  const binding = bindingOwnerId ? upsertSessionBindingFromPayload(context, project, bindingOwnerId, payload, transcriptPath) : undefined;
357
357
  const effectiveSessionId = storageId;
358
358
  const protocolId = binding?.protocol_id ?? null;
@@ -549,7 +549,7 @@ function recordZcodeLifecycle(context, input, invocationId) {
549
549
  const eventNameForReceipt = invocationId ? "ToolCallObserved" : "PreToolUse";
550
550
  const persist = (scope) => {
551
551
  if (scope)
552
- matchKey = lifecycleMatchKey(lifecycle, expectedRoot, scope);
552
+ matchKey = lifecycleMatchKey(lifecycleFacts(scope.command), expectedRoot, scope);
553
553
  assertHookEventReplay(context, { projectId: project.id, eventKey, harness: "zcode-acp", providerSessionId, parentSessionId, daemonId, sessionId, turnId: null, eventName: eventNameForReceipt, toolName: toolName ?? "Bash", matchKey, cwd: expectedRoot });
554
554
  const payload = { ...hook, session_id: providerSessionId, cwd: expectedRoot, tool_name: toolName ?? "Bash", tool_input: rawInput,
555
555
  command, provider: stringValue(observedProfile.provider), model: stringValue(observedProfile.model), reasoning: stringValue(observedProfile.reasoning), mode: stringValue(observedProfile.mode) };
@@ -198,6 +198,10 @@ function lifecycleOperation(argv) {
198
198
  return "work_finish";
199
199
  if (key === "work fail")
200
200
  return "work_fail";
201
+ if (key === "merge apply")
202
+ return "merge_apply";
203
+ if (key === "merge repair")
204
+ return "merge_repair";
201
205
  return null;
202
206
  }
203
207
  export function parseCommandArgs(argv) {
@@ -58,12 +58,53 @@ function invocation(command) {
58
58
  // Input data may be supplied later (stdin/file contents); authority is bound to
59
59
  // the exact operation and literal argv. Existing lifecycle validation owns data.
60
60
  function fingerprint(parsed) {
61
- const options = [...parsed.args.options].filter(([key]) => !["invocation-id", "hook-event-id", "json", "progress-jsonl", "response-file"].includes(key))
61
+ const projected = publicInvocationArgs(parsed);
62
+ const publicParsed = invocation(`${quote([parsed.executable])} ${quoteModelFacingArgs(projected)}`);
63
+ const options = [...publicParsed.args.options].filter(([key]) => !["invocation-id", "hook-event-id", "json", "progress-jsonl"].includes(key))
62
64
  .map(([key, values]) => [key, key === "reason" ? ["<semantic-reason>"] : values.map(value => normalizedInvocationValue(key, value))])
63
65
  .sort(([a], [b]) => a.localeCompare(b));
64
- return crypto.createHash("sha256").update(JSON.stringify([parsed.operation, parsed.args.positional.map(value => normalizedInvocationValue("positional", value)), options])).digest("hex");
66
+ return crypto.createHash("sha256").update(JSON.stringify([parsed.operation, publicParsed.args.positional.map(value => normalizedInvocationValue("positional", value)), options])).digest("hex");
67
+ }
68
+ const runtimeOwnedOptions = {
69
+ stage_start: new Set(["project-root", "context-file", "context-sha256", "response-file", "require-session-binding"]),
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"]),
72
+ work_start: new Set(["project-root", "recovery-id"]),
73
+ work_finish: new Set(["project-root"]), work_fail: new Set(["project-root"]),
74
+ merge_apply: new Set(["project-root"]), merge_repair: new Set(["project-root"]),
75
+ recovery_accept: new Set(["project-root"])
76
+ };
77
+ function publicInvocationArgs(parsed) {
78
+ const hidden = runtimeOwnedOptions[parsed.operation] ?? new Set();
79
+ const args = [];
80
+ for (let index = 0; index < parsed.argv.length; index += 1) {
81
+ const value = parsed.argv[index];
82
+ if (!value.startsWith("--")) {
83
+ args.push(value);
84
+ continue;
85
+ }
86
+ const key = value.slice(2).split("=", 1)[0];
87
+ const inline = value.includes("=");
88
+ const next = parsed.argv[index + 1];
89
+ if (hidden.has(key) || key === "invocation-id" || key === "hook-event-id") {
90
+ if (!inline && next && !next.startsWith("--"))
91
+ index += 1;
92
+ continue;
93
+ }
94
+ args.push(value);
95
+ if (!inline && next && !next.startsWith("--"))
96
+ args.push(parsed.argv[++index]);
97
+ }
98
+ // A managed coordinator Session is already bound to its RUN. Work IDs stay
99
+ // public because sibling Work assignments can coexist under one native root.
100
+ const operationOffset = parsed.operation === "recovery_accept" ? 3 : 2;
101
+ if (["stage_start", "stage_finish", "recovery_accept"].includes(parsed.operation)
102
+ && !parsed.args.options.has("bootstrap") && args[operationOffset] && !args[operationOffset].startsWith("--"))
103
+ args.splice(operationOffset, 1);
104
+ return args;
65
105
  }
66
106
  function normalizedInvocationValue(key, value) {
107
+ value = normalizedPathAlias(value);
67
108
  if (key === "project-root" && (path.isAbsolute(value) || value === "@project"))
68
109
  return "@project";
69
110
  if (["result-file", "decision-file", "verification-file"].includes(key))
@@ -79,12 +120,19 @@ function normalizedInvocationValue(key, value) {
79
120
  return shortEntityReference(value);
80
121
  return key === "retry-check" ? /(?:^|\/)(RCP-\d{3,})$/.exec(value)?.[1] ?? value : value;
81
122
  }
123
+ /** Shell/JSON builders sometimes preserve the shell escape before an alias.
124
+ * Accept it only for the small declared alias vocabulary. */
125
+ function normalizedPathAlias(value) {
126
+ return value.replace(/^\\+(?=@(?:project|workspace|run)(?:\/|$))/, "");
127
+ }
128
+ function quoteModelFacingArgs(argv) {
129
+ return argv.map(value => /^@(project|workspace|run)(?:\/.*)?$/.test(value)
130
+ ? `'${value.replaceAll("'", `'\\''`)}'`
131
+ : quote([value])).join(" ");
132
+ }
82
133
  function withoutInvocationId(command, aliases) {
83
134
  const parsed = invocation(command);
84
- const argv = [...parsed.argv];
85
- const at = argv.indexOf("--invocation-id");
86
- if (at >= 0)
87
- argv.splice(at, 2);
135
+ const argv = publicInvocationArgs(parsed);
88
136
  for (let index = 0; index < argv.length; index += 1) {
89
137
  const option = argv[index - 1]?.replace(/^--/, "");
90
138
  if (option === "project-root")
@@ -109,14 +157,23 @@ function withoutInvocationId(command, aliases) {
109
157
  const executable = parsed.env.DD_FLOW_BIN || parsed.executable === "$DD_FLOW_BIN" ? '"$DD_FLOW_BIN"' : quote([parsed.executable]);
110
158
  const prefix = Object.entries(env).map(([key, value]) => `${key}=${quote([value])}`).join(" ");
111
159
  const suffix = command.slice(shellSuffixOffset(command)).trimStart();
112
- return `${prefix ? `${prefix} ` : ""}${executable}${argv.length ? ` ${quote(argv)}` : ""}${suffix ? ` ${suffix}` : ""}`.trim();
160
+ return `${prefix ? `${prefix} ` : ""}${executable}${argv.length ? ` ${quoteModelFacingArgs(argv)}` : ""}${suffix ? ` ${suffix}` : ""}`.trim();
113
161
  }
114
162
  function publicInvocationCommand(context, row, command) {
163
+ if (!commandOption(invocation(command), "project-root"))
164
+ command = retainedCommandWithoutInvocationId(row);
115
165
  const rendered = renderInvocationCommand(row, command);
116
166
  const scope = JSON.parse(row.scope_json);
117
167
  const run = scope.runId ? context.db.get("SELECT workspace_root, run_root FROM runs WHERE id = ?", [scope.runId]) : undefined;
118
168
  return withoutInvocationId(rendered, { project: scope.projectRoot, ...(run?.workspace_root ? { workspace: run.workspace_root } : {}), ...(run?.run_root ? { run: run.run_root } : {}) });
119
169
  }
170
+ function retainedCommandWithoutInvocationId(row) {
171
+ const marker = ` --invocation-id ${row.id}`;
172
+ const at = row.command.indexOf(marker);
173
+ if (at < 0 || row.command.indexOf(marker, at + marker.length) >= 0)
174
+ throw new AppError("invocation_command_invalid", "Retained issued command has an ambiguous invocation marker", 1);
175
+ return `${row.command.slice(0, at)}${row.command.slice(at + marker.length)}`;
176
+ }
120
177
  /** Resolve the small, declared alias vocabulary used by model-facing lifecycle commands.
121
178
  * Both native-hook admission and CLI execution call this function so they cannot
122
179
  * disagree about the command that an alias denotes. */
@@ -128,7 +185,10 @@ export function expandLifecycleInvocationArgs(context, args, scope, operation) {
128
185
  if (run && !args[index - 1]?.startsWith("--") && [run.short_id, normalizedInvocationValue("positional", run.id)].includes(value) && !operation.startsWith("work_"))
129
186
  return run.id;
130
187
  const option = args[index - 1]?.replace(/^--/, "");
131
- if (!option || !pathOptions.has(option) || !value.startsWith("@"))
188
+ if (!option || !pathOptions.has(option))
189
+ return value;
190
+ value = normalizedPathAlias(value);
191
+ if (!value.startsWith("@"))
132
192
  return value;
133
193
  const match = /^@(project|workspace|run)(?:\/(.*))?$/.exec(value);
134
194
  if (!match)
@@ -139,6 +199,22 @@ export function expandLifecycleInvocationArgs(context, args, scope, operation) {
139
199
  return assertPathWithin(root, path.join(root, match[2] ?? ""), `${match[1]}_alias`);
140
200
  });
141
201
  }
202
+ /** Validate alias spelling before native-receipt lookup, so a correctable
203
+ * command typo is never reported as missing infrastructure evidence. */
204
+ export function validateLifecyclePathAliases(args, operation) {
205
+ const pathOptions = contextualPathOptionsForLifecycle(operation);
206
+ for (let index = 0; index < args.length; index += 1) {
207
+ const option = args[index - 1]?.replace(/^--/, "");
208
+ if (!option || !pathOptions.has(option))
209
+ continue;
210
+ const original = args[index];
211
+ const value = normalizedPathAlias(original);
212
+ if ((original.startsWith("\\") && original.replace(/^\\+/, "").startsWith("@") && value === original)
213
+ || (value.startsWith("@") && !/^@(project|workspace|run)(?:\/.*)?$/.test(value))) {
214
+ throw new AppError("usage", `Unknown path alias: ${original}`, 2, { phase: "prepare", effect: "no_effect", recoverable: true, parameter: option });
215
+ }
216
+ }
217
+ }
142
218
  function canonicalManagedCommand(context, command, scope) {
143
219
  const parsed = invocation(command);
144
220
  const argv = expandLifecycleInvocationArgs(context, parsed.argv, scope, parsed.operation);
@@ -424,11 +500,7 @@ function successorLifecycleInvocationCommand(context, prior) {
424
500
  // The parser proves the marker belongs to argv. Remove that one rendered
425
501
  // token from the retained text so response-file/stdin/heredoc presentation
426
502
  // remains byte-for-byte stable across a retry.
427
- const marker = ` --invocation-id ${prior.id}`;
428
- const markerAt = prior.command.indexOf(marker);
429
- if (markerAt < 0 || prior.command.indexOf(marker, markerAt + marker.length) >= 0)
430
- throw new AppError("invocation_command_invalid", "Retained issued command has an ambiguous invocation marker", 1);
431
- const command = `${prior.command.slice(0, markerAt)}${prior.command.slice(markerAt + marker.length)}`;
503
+ const command = retainedCommandWithoutInvocationId(prior);
432
504
  return managedLifecycleCommand({ ...context, env: { ...context.env, DD_FLOW_INVOCATION_SCOPE: prior.scope_json, DD_FLOW_CURRENT_INVOCATION: prior.id } }, command);
433
505
  }
434
506
  // Caller holds the write transaction and has checked authority. Issued (or
@@ -538,6 +610,11 @@ export function assertLifecycleInvocationCurrent(context, scope, command) {
538
610
  throw new AppError("invocation_scope_mismatch", "Work does not belong unambiguously to the issued RUN scope", 1);
539
611
  }
540
612
  }
613
+ else if (parsed.operation.startsWith("merge_")) {
614
+ const request = context.db.get("SELECT project_id, run_id FROM merge_requests WHERE merge_request_id = ?", [parsed.args.positional[0] ?? ""]);
615
+ if (!request || request.project_id !== project.id || request.run_id !== scope.runId)
616
+ throw new AppError("invocation_scope_mismatch", "MERGE request does not belong to the issued RUN scope", 1);
617
+ }
541
618
  else if (parsed.operation !== "session_register" && !parsed.args.options.has("bootstrap")) {
542
619
  const target = parsed.args.positional[0];
543
620
  const run = context.db.get("SELECT id FROM runs WHERE project_id = ? AND (id = ? OR short_id = ?)", [project.id, target ?? null, target ?? null]);
@@ -683,7 +760,7 @@ export function observeLifecycleInvocation(context, input) {
683
760
  context.db.exec("COMMIT");
684
761
  return { eventKey: row.event_key, duplicate: true, ...resolved };
685
762
  }
686
- const eventKey = input.recordReceipt(scope);
763
+ const eventKey = input.recordReceipt({ projectRoot: scope.projectRoot, runId: scope.runId, command: initial.command });
687
764
  // A native hook may execute from a provider worktree while the issued
688
765
  // lifecycle scope is anchored to the stable project root. Project
689
766
  // identity is the canonical boundary; comparing raw cwd would reject a
@@ -733,6 +810,43 @@ export function observedLifecycleInvocation(context, command) {
733
810
  throw new AppError("invocation_ambiguous", "More than one observed lifecycle attempt matches this command", 1, { effect: "no_effect", matches: matches.map(row => row.id) });
734
811
  return { id: matches[0].id, scope: JSON.parse(matches[0].scope_json) };
735
812
  }
813
+ /** Replace presentation argv with the exact retained machine assignment.
814
+ * Public semantic arguments are already covered by the projected fingerprint;
815
+ * any explicitly repeated private value must agree instead of being ignored. */
816
+ export function resolveObservedLifecycleArgs(context, id, actualArgs) {
817
+ const row = context.db.get("SELECT * FROM lifecycle_invocations WHERE id = ?", [id]);
818
+ if (!row)
819
+ throw new AppError("invocation_unknown", "Lifecycle invocation is not registered", 1, { invocation_id: id });
820
+ const retained = invocation(row.command), actual = invocation(`${quote([retained.executable])} ${quoteModelFacingArgs(actualArgs)}`);
821
+ if (fingerprint(actual) !== row.fingerprint)
822
+ throw new AppError("invocation_command_mismatch", "Lifecycle command does not match its issued assignment", 2, { phase: "prepare", effect: "no_effect", recoverable: true });
823
+ for (const key of runtimeOwnedOptions[retained.operation] ?? []) {
824
+ const supplied = commandOption(actual, key), expected = commandOption(retained, key);
825
+ if (supplied !== undefined && normalizedInvocationValue(key, supplied) !== normalizedInvocationValue(key, expected ?? "")) {
826
+ throw new AppError("invocation_argument_mismatch", `--${key} is owned by the managed assignment and does not match it`, 2, { phase: "prepare", effect: "no_effect", recoverable: true, parameter: key });
827
+ }
828
+ }
829
+ return [...retained.argv];
830
+ }
831
+ export function settleLifecyclePreparationRejection(context, id, error) {
832
+ return context.db.writeTransaction(() => {
833
+ const row = load(context, id);
834
+ if (row.status === "settled") {
835
+ const details = row.outcome_json ? JSON.parse(row.outcome_json)?.error?.details : undefined;
836
+ return details?.effect === "no_effect" && details.retry_command ? details : { effect: "unknown", recoverable: false };
837
+ }
838
+ if (!["issued", "observed"].includes(row.status))
839
+ return { effect: "unknown", recoverable: false };
840
+ const scope = JSON.parse(row.scope_json);
841
+ if (scope.runId)
842
+ assertLifecycleInvocationCurrent(context, scope, row.command);
843
+ const details = { ...error.details, effect: "no_effect", recoverable: true,
844
+ retry_command: successorLifecycleInvocationCommand(context, row),
845
+ retry_instruction: "Execute retry_command verbatim in this same Session. The rejected call changed no lifecycle state." };
846
+ 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]);
847
+ return details;
848
+ });
849
+ }
736
850
  /** One caller owns execution; a lost process leaves an explicit unknown
737
851
  * outcome, never an automatic replay. Existing recovery decides the next step. */
738
852
  export async function awaitLifecycleInvocation(context, input) {
@@ -752,27 +866,7 @@ export async function awaitLifecycleInvocation(context, input) {
752
866
  catch (error) {
753
867
  if (!(error instanceof AppError) || !(["usage", "invocation_command_mismatch", "invocation_command_invalid"].includes(error.code) || error.details.phase === "prepare"))
754
868
  throw error;
755
- const rejectionDetails = context.db.writeTransaction(() => {
756
- const row = load(context, input.id);
757
- if (row.status === "settled") {
758
- const details = row.outcome_json ? JSON.parse(row.outcome_json)?.error?.details : undefined;
759
- return details?.effect === "no_effect" && details.retry_command ? details : { effect: "unknown", recoverable: false };
760
- }
761
- if (!["issued", "observed"].includes(row.status)) {
762
- return { effect: "unknown", recoverable: false };
763
- }
764
- const scope = JSON.parse(row.scope_json);
765
- if (scope.runId)
766
- assertLifecycleInvocationCurrent(context, scope, row.command);
767
- // No executor has claimed this attempt. Atomically retain the rejected
768
- // call and issue one corrected command; old IDs only replay the outcome.
769
- const details = { ...error.details, effect: "no_effect", recoverable: true,
770
- retry_command: successorLifecycleInvocationCommand(context, row),
771
- retry_instruction: "Execute retry_command verbatim in this same Session. The rejected call changed no lifecycle state." };
772
- 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]);
773
- return details;
774
- });
775
- Object.assign(error.details, rejectionDetails);
869
+ Object.assign(error.details, settleLifecyclePreparationRejection(context, input.id, error));
776
870
  throw error;
777
871
  }
778
872
  const timeoutMs = input.timeoutMs ?? 30000;
@@ -693,7 +693,7 @@ async function executeController(context, row, manifest, state, assertOwnership,
693
693
  const responseFile = path.join(row.state_dir, `${next.stage}-${next.attempt ?? 1}.stage-start-response.json`);
694
694
  const command = managedLifecycleCommand(executionContext, `${flowCommand(executionContext)} stage start ${run.id} --stage ${next.stage} --project-root ${JSON.stringify(run.project_root)} --context-file ${JSON.stringify(stageContext.file)} --context-sha256 ${stageContext.sha256} --require-session-binding --response-file ${JSON.stringify(responseFile)} --json --progress-jsonl`);
695
695
  appendEvent(context, row.controller_id, "stage_entered", { stage: next.stage, session_id: session.id, context_sha256: stageContext.sha256 });
696
- await prompt(session, controllerStageEntryPrompt(next.stage, command, responseFile));
696
+ await prompt(session, controllerStageEntryPrompt(next.stage, command));
697
697
  continue;
698
698
  }
699
699
  if (state.current_session === null)
@@ -290,7 +290,7 @@ export async function finishVnextMerge(context, input, prepared = prepareVnextMe
290
290
  const failed = receipts.filter((receipt) => receipt.status !== "passed");
291
291
  if (failed.length) {
292
292
  context.db.run("UPDATE merge_requests SET status = 'action_required', last_error_json = ?, updated_at = ? WHERE merge_request_id = ?", [JSON.stringify({ code: "merge_gate_failed", failures: failed }), context.now(), request.merge_request_id]);
293
- throw new AppError("merge_gate_failed", "Integrated target checks failed. Classify retained evidence: source defects use source repair; restored environment uses retry.", 2, { failures: failed, repair_command: `${flowCommand(context)} merge repair ${request.merge_request_id} --project-root ${JSON.stringify(projectRoot)} --json`, retry_command: finishCommand(context, run.id, request, projectRoot, failed[0].id) });
293
+ throw new AppError("merge_gate_failed", "Integrated target checks failed. Classify retained evidence: source defects use source repair; restored environment uses retry.", 2, { failures: failed, repair_command: repairCommand(context, request, projectRoot), retry_command: finishCommand(context, run.id, request, projectRoot, failed[0].id) });
294
294
  }
295
295
  const passedRefs = new Set(receipts.flatMap((receipt) => receipt.check_refs));
296
296
  const missingRefs = frozenGateRefs(gate).filter((ref) => !passedRefs.has(ref));
@@ -429,7 +429,7 @@ export async function repairVnextMerge(context, input) {
429
429
  appendFlowRunTimelineEvent(context, project.id, request.run_id, { type: "merge_source_repair_created", merge_request_id: request.merge_request_id, repair_work_id: repair.repair_work_id, cycle: prepared.cycle, failed_receipt_ids: failed });
430
430
  return { ok: true, run_id: request.run_id, merge_request_id: request.merge_request_id, status: "superseded", source_repair: repair, next: { kind: "start_stage", stage: "code", command: managedLifecycleCommand(context, `${flowCommand(context)} stage start ${request.run_id} --stage code --project-root ${JSON.stringify(projectRoot)} --json --progress-jsonl`) } };
431
431
  }
432
- function mergePrompt(context, input, checks, settings) { const pause = managedLifecycleCommand(context, `${flowCommand(context)} stage pause ${input.run.id} --stage merge --work ${input.request.executor_work_id} --project-root ${JSON.stringify(input.projectRoot)} --question-stdin --json`); const repair = `${flowCommand(context)} merge repair ${input.request.merge_request_id} --project-root ${JSON.stringify(input.projectRoot)} --json`; return ["<stage_identity>", `- RUN: ${input.run.id}`, `- MERGE request: ${input.request.merge_request_id}`, `- Work: ${input.request.executor_work_id}`, "- stage: merge", "</stage_identity>", "", "<trusted_runtime_context>", `- integration workspace: ${input.request.target_workspace}`, `- source workspace: ${input.request.source_workspace}`, `- frozen source commit: ${input.request.source_commit}`, `- target branch: ${input.request.target_branch}`, `- execution target baseline: ${input.request.execution_target_head}`, `- queue route: ${input.request.execution_route}`, `- delivery: ${JSON.stringify(settings.merge_delivery)}`, `- cleanup: ${JSON.stringify(settings.merge_cleanup)}`, "These facts and the acquired project integration lane were established by dd-flow. Do not repeat discovery and do not run git merge/rebase/squash yourself.", "</trusted_runtime_context>", "", "<effective_merge_gate>", ...checks.map((check) => `- ${check.canonical_ref ?? check.id}: ${check.command} — ${check.purpose}`), "</effective_merge_gate>", "", "<execution_contract>", `1. Run this exact standalone command first: ${applyCommand(context, input.request, input.projectRoot)}`, "2. If it reports conflicts, resolve only the actual unmerged paths in the integration workspace. Do not repeat merge apply and do not edit product code, tests, documentation or configuration merely to make a gate pass.", `3. Write the compact semantic result to ${input.resultPath}:`, "```json", JSON.stringify({ schema_id: "dd-flow/merge-result@1", outcome: "completed", summary: "What was integrated.", conflict_resolution: "How material conflicts were resolved, or empty when none.", verification_summary: "Why the integrated result is ready for deterministic checks.", residual_risks: [] }, null, 2), "```", `4. Finish with this exact standalone command and wait for all progress: ${finishCommand(context, input.run.id, input.request, input.projectRoot)}`, `If a gate fails, read its receipt and logs and determine the cause. For a product defect use ${repair}; it restores the target baseline and opens CODE → independent CODE-REVIEW → replacement MRG. For an environment failure restore the declared environment in this MERGE and use the returned finish command with --retry-check <receipt-id> --reason "<what was restored>". The CLI reruns the real gate and retains the old receipt. Never edit product code in the integration target to make a gate pass.`, "If a material conflict has no reasonable answer in accepted evidence, pause this same Work with the exact heredoc below, ask the returned user_message, then use the exact resume command returned by CLI:", "```sh", stagePauseCommandTemplate(pause), "```", "</execution_contract>", ""].join("\n"); }
432
+ function mergePrompt(context, input, checks, settings) { const pause = managedLifecycleCommand(context, `${flowCommand(context)} stage pause ${input.run.id} --stage merge --work ${input.request.executor_work_id} --project-root ${JSON.stringify(input.projectRoot)} --question-stdin --json`); const repair = repairCommand(context, input.request, input.projectRoot); return ["<stage_identity>", `- RUN: ${input.run.id}`, `- MERGE request: ${input.request.merge_request_id}`, `- Work: ${input.request.executor_work_id}`, "- stage: merge", "</stage_identity>", "", "<trusted_runtime_context>", `- integration workspace: ${input.request.target_workspace}`, `- source workspace: ${input.request.source_workspace}`, `- frozen source commit: ${input.request.source_commit}`, `- target branch: ${input.request.target_branch}`, `- execution target baseline: ${input.request.execution_target_head}`, `- queue route: ${input.request.execution_route}`, `- delivery: ${JSON.stringify(settings.merge_delivery)}`, `- cleanup: ${JSON.stringify(settings.merge_cleanup)}`, "These facts and the acquired project integration lane were established by dd-flow. Do not repeat discovery and do not run git merge/rebase/squash yourself.", "</trusted_runtime_context>", "", "<effective_merge_gate>", ...checks.map((check) => `- ${check.canonical_ref ?? check.id}: ${check.command} — ${check.purpose}`), "</effective_merge_gate>", "", "<execution_contract>", `1. Run this exact standalone command first: ${applyCommand(context, input.request, input.projectRoot)}`, "2. If it reports conflicts, resolve only the actual unmerged paths in the integration workspace. Do not repeat merge apply and do not edit product code, tests, documentation or configuration merely to make a gate pass.", `3. Write the compact semantic result to ${input.resultPath}:`, "```json", JSON.stringify({ schema_id: "dd-flow/merge-result@1", outcome: "completed", summary: "What was integrated.", conflict_resolution: "How material conflicts were resolved, or empty when none.", verification_summary: "Why the integrated result is ready for deterministic checks.", residual_risks: [] }, null, 2), "```", `4. Finish with this exact standalone command and wait for all progress: ${finishCommand(context, input.run.id, input.request, input.projectRoot)}`, `If a gate fails, read its receipt and logs and determine the cause. For a product defect use ${repair}; it restores the target baseline and opens CODE → independent CODE-REVIEW → replacement MRG. For an environment failure restore the declared environment in this MERGE and use the returned finish command with --retry-check <receipt-id> --reason "<what was restored>". The CLI reruns the real gate and retains the old receipt. Never edit product code in the integration target to make a gate pass.`, "If a material conflict has no reasonable answer in accepted evidence, pause this same Work with the exact heredoc below, ask the returned user_message, then use the exact resume command returned by CLI:", "```sh", stagePauseCommandTemplate(pause), "```", "</execution_contract>", ""].join("\n"); }
433
433
  function mergeReport(context, run, request, semantic, receipts) { const now = context.now(); const cleanup = cleanupReceiptPath(run); return { schema_id: "dd-flow/stage-report@2", run_id: run.id, stage, generated_at: now, verdict: "done", summary: semantic.summary, semantic: { result: semantic.summary, acceptance: ["source_commit_frozen", "integration_commit_created", "merge_gate_passed", "delivery_confirmed"], changed_files: [], checks: receipts.map((item) => item.command), evidence: [applyReceiptPath(context, request), ...receipts.map((item) => item.receipt_path), ...(fs.existsSync(cleanup) ? [cleanup] : [])], next_action: "merge_completed", merge: { merge_request_id: request.merge_request_id, work_id: request.executor_work_id, protocols: JSON.parse(request.protocol_ids_json), source_commit: request.source_commit, execution_target_head: request.execution_target_head, accepted_tree: request.accepted_tree, integration_commit: request.integration_commit, route: request.execution_route, delivery: executionSettings(run).merge_delivery, cleanup: executionSettings(run).merge_cleanup, verification_summary: semantic.verification_summary, residual_risks: semantic.residual_risks } }, mechanical: { started_at: request.lock_acquired_at, finished_at: now, git: gitFacts(request.target_workspace), queue: queueStatus(context, request) }, artifacts: { json: "stage-report.json", markdown: "stage-report.md", html: "stage-report.html" }, validation: { status: "passed" } }; }
434
434
  function effectiveMergeChecks(run, request) { return readFrozenMergeGate(path.join(requireHome(run), stageDir, "merge-gate.json"), request.merge_request_id).checks; }
435
435
  function planChecks(workspace, protocols) { return protocols.flatMap((protocol) => { const file = path.join(workspace, ".memory-bank", "protocol", protocol, "plan.json"); if (!fs.existsSync(file))
@@ -588,5 +588,6 @@ function findRootWork(context, projectId, runId) { const work = context.db.get("
588
588
  throw new AppError("runtime_missing", "vNext RUN has no root Work", 1); return work; }
589
589
  function requireRootWork(context, projectId, runId) { const work = findRootWork(context, projectId, runId); if (work.status !== "running")
590
590
  throw new AppError("runtime_missing", "vNext RUN has no running root Work", 1); return work; }
591
- function applyCommand(context, request, projectRoot) { return `${flowCommand(context)} merge apply ${request.merge_request_id} --work ${request.executor_work_id} --project-root ${JSON.stringify(projectRoot)} --json --progress-jsonl`; }
591
+ function applyCommand(context, request, projectRoot) { return managedLifecycleCommand(context, `${flowCommand(context)} merge apply ${request.merge_request_id} --work ${request.executor_work_id} --project-root ${JSON.stringify(projectRoot)} --json --progress-jsonl`); }
592
+ function repairCommand(context, request, projectRoot) { return managedLifecycleCommand(context, `${flowCommand(context)} merge repair ${request.merge_request_id} --project-root ${JSON.stringify(projectRoot)} --json`); }
592
593
  export function finishCommand(context, runId, request, projectRoot, retryCheckId) { const retry = retryCheckId ? ` --retry-check ${retryCheckId} --reason "<environment recovery evidence>"` : ""; return managedLifecycleCommand(context, `${flowCommand(context)} stage finish ${runId} --stage merge --request ${request.merge_request_id} --work ${request.executor_work_id} --project-root ${JSON.stringify(projectRoot)} --json --progress-jsonl${retry}`); }
@@ -988,7 +988,7 @@ function renderWorkerPrompt(context, work, run, dependencies) {
988
988
  codeContext.push("<document_updates>", JSON.stringify(packet.document_updates, null, 2), "Materialize every listed update. dd-flow verifies the resulting file against its PLAN-time baseline.", "</document_updates>", "", "<completion_contract>", "Successful completion requires empty deviations and blockers and every assigned document update in changed_paths. A necessary path outside planned_write_areas is normal coordination drift, not a blocker; include it in changed_paths and continue.", "</completion_contract>", "");
989
989
  if (packet)
990
990
  codeContext.push("<temporary_services>", "Prefer the declared check launcher: it already owns check resources. If the planned scenario genuinely requires an interactive HTTP service, use the managed supervisor below. This is a template: replace the project service command, port names and readiness path from the plan/project instructions; do not invent a fixed port.", `${command} runtime process start --run ${run.id} --project-root ${JSON.stringify(run.project_root)} --command '<project-service-command>' --ports api --ready-port api --ready-path /health --json --progress-jsonl`, "The service receives DD_FLOW_PORT_API (and equivalent variables for all declared names). The command stays running as its supervisor. Retain its tool handle; wait for the service ready event and read its service.json receipt. Pass those exact ports and the same project environment to reset/seed, API and browser operations.", "A ready receipt proves service readiness only. Record the scenario outcome and real evidence separately. After the scenario, execute the exact stop_command from that receipt, then wait for the supervisor to exit. Never use pkill/killall or stop a sibling's process. If cleanup fails, retain the process id and report the failure; do not claim the resource is free.", "</temporary_services>", "");
991
- return ["<work>", `- work_id: ${work.work_id}`, `- run_id: ${work.run_id}`, `- project_root: ${run.project_root}`, `- workspace_root: ${run.workspace_root}`, `- run_home: ${requireRunHome(run)}`, "</work>", "", "<cli_context>", `- Work commands use the short ID ${shortWorkId(work.work_id)}; dd-flow binds it to this RUN.`, `- @project = ${run.project_root}`, `- @workspace = ${run.workspace_root}`, `- @run = ${requireRunHome(run)}`, "- @ aliases are accepted only by declared dd-flow path parameters. Shell tools such as cat and rg require ordinary paths relative to cwd or absolute paths.", "- Lifecycle invocation IDs are internal runtime authority and are intentionally omitted from commands.", "</cli_context>", "", "<hard_write_boundary>", ...writeBoundary, "</hard_write_boundary>", "", ...codeContext, "<dependency_results>", JSON.stringify(dependencies.filter(Boolean), null, 2), "</dependency_results>", "", "<task>", resolveRunReferences(work.task, work.run_id, requireRunHome(run)), "</task>", "", ...(work.result_schema ? ["<result_contract>", `Return JSON matching \`${work.result_schema}\`.`, ...resultSchemaGuidance(work, run.id), "Do not create result.json yourself. Send the JSON to dd-flow on stdin; it atomically validates and stores the canonical receipt.", "</result_contract>", ""] : []), "<completion>", packet?.repair?.verification_check_refs?.length ? "Work finish runs its normal work-scoped checks plus the listed causal repair checks. Their original run_at remains an aggregate obligation; this is the additional proof required before accepting this repair." : "Work finish runs only declared run_at=work checks. Stage finish owns readiness/code/merge gates; successful Work completion does not mean those gates have passed.", "A failed receipt means only that the check failed; it is not proof of an engine, harness, dependency, or environment blocker.", ...completionRepair, "Use Fail only for a concrete external blocker after deterministic bootstrap or a contradiction with an accepted requirement/non-goal. Never fail merely because a necessary project path was absent from planned_write_areas.", "Finish may run for several minutes. Preserve the shell tool's process/session handle and poll that same invocation until it exits; progress arrives as JSONL on stderr. Never reissue Finish merely because final stdout has not arrived.", `Finish as one standalone command with a quoted heredoc (replace the example JSON with your result):\n${finishWorkCommand} <<'DD_FLOW_RESULT'\n{}\nDD_FLOW_RESULT`, `Fail only for an evidenced external or semantic-contract blocker: ${failWorkCommand}`, "</completion>", ""].join("\n");
991
+ return ["<work>", `- work_id: ${work.work_id}`, `- run_id: ${work.run_id}`, `- project_root: ${run.project_root}`, `- workspace_root: ${run.workspace_root}`, `- run_home: ${requireRunHome(run)}`, "</work>", "", "<cli_context>", `- Work commands use the short ID ${shortWorkId(work.work_id)}; dd-flow binds it to this RUN.`, `- @project = ${run.project_root}`, `- @workspace = ${run.workspace_root}`, `- @run = ${requireRunHome(run)}`, "- @ aliases are accepted only by declared dd-flow path parameters. Copy the quoted alias literally; do not add backslashes. Shell tools such as cat and rg require ordinary paths relative to cwd or absolute paths.", "- Lifecycle invocation IDs are internal runtime authority and are intentionally omitted from commands.", "</cli_context>", "", "<hard_write_boundary>", ...writeBoundary, "</hard_write_boundary>", "", ...codeContext, "<dependency_results>", JSON.stringify(dependencies.filter(Boolean), null, 2), "</dependency_results>", "", "<task>", resolveRunReferences(work.task, work.run_id, requireRunHome(run)), "</task>", "", ...(work.result_schema ? ["<result_contract>", `Return JSON matching \`${work.result_schema}\`.`, ...resultSchemaGuidance(work, run.id), "Do not create result.json yourself. Send the JSON to dd-flow on stdin; it atomically validates and stores the canonical receipt.", "</result_contract>", ""] : []), "<completion>", packet?.repair?.verification_check_refs?.length ? "Work finish runs its normal work-scoped checks plus the listed causal repair checks. Their original run_at remains an aggregate obligation; this is the additional proof required before accepting this repair." : "Work finish runs only declared run_at=work checks. Stage finish owns readiness/code/merge gates; successful Work completion does not mean those gates have passed.", "A failed receipt means only that the check failed; it is not proof of an engine, harness, dependency, or environment blocker.", ...completionRepair, "Use Fail only for a concrete external blocker after deterministic bootstrap or a contradiction with an accepted requirement/non-goal. Never fail merely because a necessary project path was absent from planned_write_areas.", "Finish may run for several minutes. Preserve the shell tool's process/session handle and poll that same invocation until it exits; progress arrives as JSONL on stderr. Never reissue Finish merely because final stdout has not arrived.", `Finish as one standalone command with a quoted heredoc (replace the example JSON with your result):\n${finishWorkCommand} <<'DD_FLOW_RESULT'\n{}\nDD_FLOW_RESULT`, `Fail only for an evidenced external or semantic-contract blocker: ${failWorkCommand}`, "</completion>", ""].join("\n");
992
992
  }
993
993
  export function resultSchemaGuidance(work, runId) {
994
994
  const schema = work.result_schema;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@deksden-com/dd-flow-cli",
3
- "version": "0.9.0-beta.82",
3
+ "version": "0.9.0-beta.86",
4
4
  "description": "Mechanical runtime CLI for dd-flow workflows.",
5
5
  "type": "module",
6
6
  "bin": {