@deksden-com/dd-flow-cli 0.8.0 → 0.9.0-beta.0

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.
@@ -0,0 +1,288 @@
1
+ import path from "node:path";
2
+ import { parse, quote } from "shell-quote";
3
+ const controlOperators = new Set(["&&", "||", ";", "|", "|&", "&", "(", ")", "<(", ">("]);
4
+ const redirectionOperators = new Set(["<", ">", ">>", ">|", "<&", ">&", "<<<"]);
5
+ /** Parse the deliberately small direct-command surface accepted by lifecycle hooks. */
6
+ export function parseLifecycleCommand(command) {
7
+ return parseLifecycleCommandInner(command, false);
8
+ }
9
+ /** Preserve the historical Desktop contract: execute the payload, not its one-command shell wrapper. */
10
+ export function unwrapShellCommand(command) {
11
+ let entries;
12
+ try {
13
+ entries = parse(command, (name) => `$${name}`);
14
+ }
15
+ catch {
16
+ return command;
17
+ }
18
+ if (!entries.every((entry) => typeof entry === "string"))
19
+ return command;
20
+ const words = entries;
21
+ let index = 0;
22
+ while (index < words.length && assignment(words[index]))
23
+ index += 1;
24
+ const executable = path.basename(words[index] ?? "");
25
+ if (!new Set(["bash", "sh", "zsh"]).has(executable))
26
+ return command;
27
+ if (!new Set(["-c", "-lc", "-cl"]).has(words[index + 1] ?? "") || words.length !== index + 3)
28
+ return command;
29
+ return words[index + 2] ?? command;
30
+ }
31
+ function parseLifecycleCommandInner(command, wrapped) {
32
+ const heredoc = stripHeredocBodies(command);
33
+ let entries;
34
+ try {
35
+ entries = parse(heredoc.command, (name) => `$${name}`);
36
+ }
37
+ catch {
38
+ // A command that cannot be tokenized cannot create a trusted claim. The
39
+ // real lifecycle CLI will consequently fail its binding requirement.
40
+ return { kind: "none" };
41
+ }
42
+ const { segments, composed } = commandSegments(entries);
43
+ const found = [];
44
+ const exportedEnv = {};
45
+ for (const segment of segments) {
46
+ Object.assign(exportedEnv, exportedAssignments(segment));
47
+ const direct = invocationFromSegment(segment, heredoc.present, wrapped, exportedEnv);
48
+ if (direct) {
49
+ found.push({ invocation: direct, nestedCompound: segment.dynamic });
50
+ continue;
51
+ }
52
+ const nested = wrappedInvocationFromSegment(segment);
53
+ if (nested && nested.kind !== "none")
54
+ found.push({ invocation: { ...nested.invocation, heredoc: nested.invocation.heredoc || heredoc.present, rewriteSafe: false, wrapped: true }, nestedCompound: nested.kind === "compound" });
55
+ }
56
+ if (!found.length)
57
+ return { kind: "none" };
58
+ const first = found[0];
59
+ if (found.length !== 1 || composed || first.nestedCompound) {
60
+ return {
61
+ kind: "compound",
62
+ invocation: { ...first.invocation, rewriteSafe: false },
63
+ reason: found.length !== 1 ? "multiple_lifecycle_invocations" : first.nestedCompound ? "nested_compound_invocation" : "shell_composition"
64
+ };
65
+ }
66
+ return { kind: "standalone", invocation: first.invocation };
67
+ }
68
+ function commandSegments(entries) {
69
+ const segments = [];
70
+ let words = [];
71
+ let dynamic = false;
72
+ let composed = false;
73
+ const flush = () => {
74
+ if (words.length || dynamic)
75
+ segments.push({ words, dynamic });
76
+ words = [];
77
+ dynamic = false;
78
+ };
79
+ for (let index = 0; index < entries.length; index += 1) {
80
+ const entry = entries[index];
81
+ if (typeof entry === "string") {
82
+ words.push(entry);
83
+ continue;
84
+ }
85
+ if ("comment" in entry)
86
+ break;
87
+ if (!("op" in entry) || typeof entry.op !== "string") {
88
+ dynamic = true;
89
+ continue;
90
+ }
91
+ const operator = entry.op;
92
+ if (operator === "glob") {
93
+ dynamic = true;
94
+ words.push("pattern" in entry && typeof entry.pattern === "string" ? entry.pattern : "");
95
+ continue;
96
+ }
97
+ if (controlOperators.has(operator)) {
98
+ composed = true;
99
+ flush();
100
+ continue;
101
+ }
102
+ if (redirectionOperators.has(operator)) {
103
+ const target = entries[index + 1];
104
+ if (typeof target === "string" || (target && typeof target === "object" && "op" in target && target.op === "glob"))
105
+ index += 1;
106
+ continue;
107
+ }
108
+ dynamic = true;
109
+ }
110
+ flush();
111
+ return { segments, composed };
112
+ }
113
+ function invocationFromSegment(segment, heredoc, wrapped, inheritedEnv) {
114
+ let index = 0;
115
+ const env = { ...inheritedEnv };
116
+ while (index < segment.words.length && assignment(segment.words[index])) {
117
+ addAssignment(env, segment.words[index]);
118
+ index += 1;
119
+ }
120
+ if (path.basename(segment.words[index] ?? "") === "env") {
121
+ index += 1;
122
+ if (segment.words[index]?.startsWith("-"))
123
+ return null;
124
+ while (index < segment.words.length && assignment(segment.words[index])) {
125
+ addAssignment(env, segment.words[index]);
126
+ index += 1;
127
+ }
128
+ }
129
+ const executable = segment.words[index];
130
+ if (!executable || path.basename(executable) !== "dd-flow")
131
+ return null;
132
+ const argv = segment.words.slice(index + 1);
133
+ const operation = lifecycleOperation(argv);
134
+ if (!operation)
135
+ return null;
136
+ return {
137
+ operation,
138
+ executable,
139
+ argv,
140
+ env,
141
+ args: parseCommandArgs(argv.slice(2)),
142
+ command: quote(segment.words),
143
+ rewriteSafe: !heredoc && !wrapped && !segment.dynamic,
144
+ wrapped,
145
+ heredoc
146
+ };
147
+ }
148
+ function exportedAssignments(segment) {
149
+ const result = {};
150
+ if (segment.words[0] !== "export")
151
+ return result;
152
+ for (const word of segment.words.slice(1)) {
153
+ if (!assignment(word))
154
+ return {};
155
+ addAssignment(result, word);
156
+ }
157
+ return result;
158
+ }
159
+ function wrappedInvocationFromSegment(segment) {
160
+ let index = 0;
161
+ while (index < segment.words.length && assignment(segment.words[index]))
162
+ index += 1;
163
+ const executable = path.basename(segment.words[index] ?? "");
164
+ if (!new Set(["bash", "sh", "zsh"]).has(executable))
165
+ return null;
166
+ const flag = segment.words[index + 1];
167
+ const payload = segment.words[index + 2];
168
+ if (!payload || !new Set(["-c", "-lc", "-cl"]).has(flag ?? "") || segment.words.length !== index + 3)
169
+ return null;
170
+ return parseLifecycleCommandInner(payload, true);
171
+ }
172
+ function lifecycleOperation(argv) {
173
+ const key = `${argv[0] ?? ""} ${argv[1] ?? ""}`;
174
+ if (key === "session register")
175
+ return "session_register";
176
+ if (key === "stage start")
177
+ return "stage_start";
178
+ if (key === "stage resume")
179
+ return "stage_resume";
180
+ if (key === "work start")
181
+ return "work_start";
182
+ return null;
183
+ }
184
+ export function parseCommandArgs(argv) {
185
+ const positional = [];
186
+ const options = new Map();
187
+ for (let index = 0; index < argv.length; index += 1) {
188
+ const value = argv[index];
189
+ if (value?.startsWith("--")) {
190
+ const key = value.slice(2);
191
+ const next = argv[index + 1];
192
+ const optionValue = !next || next.startsWith("--") ? "__dd_flow_flag__" : next;
193
+ options.set(key, [...(options.get(key) ?? []), optionValue]);
194
+ if (optionValue !== "__dd_flow_flag__")
195
+ index += 1;
196
+ }
197
+ else if (value)
198
+ positional.push(value);
199
+ }
200
+ return { positional, options };
201
+ }
202
+ export function commandOption(invocation, name) {
203
+ const values = invocation.args.options.get(name);
204
+ const value = values?.[values.length - 1];
205
+ return value === "__dd_flow_flag__" ? undefined : value;
206
+ }
207
+ export function commandHasOption(invocation, name) {
208
+ return invocation.args.options.has(name);
209
+ }
210
+ export function commandPosition(invocation, index) {
211
+ return invocation.args.positional[index];
212
+ }
213
+ function assignment(word) {
214
+ return /^[A-Za-z_][A-Za-z0-9_]*=/.test(word);
215
+ }
216
+ function addAssignment(env, word) {
217
+ const separator = word.indexOf("=");
218
+ env[word.slice(0, separator)] = word.slice(separator + 1);
219
+ }
220
+ function stripHeredocBodies(command) {
221
+ const lines = command.split(/\r?\n/);
222
+ const output = [];
223
+ const pending = [];
224
+ let active;
225
+ let present = false;
226
+ for (const line of lines) {
227
+ if (active) {
228
+ const candidate = active.stripTabs ? line.replace(/^\t+/, "") : line;
229
+ if (candidate === active.delimiter)
230
+ active = pending.shift();
231
+ continue;
232
+ }
233
+ if (line.trim())
234
+ output.push(line);
235
+ const found = heredocDelimiters(line);
236
+ if (found.length) {
237
+ present = true;
238
+ active = found[0];
239
+ pending.push(...found.slice(1));
240
+ }
241
+ }
242
+ return { command: output.join(" ; "), present };
243
+ }
244
+ function heredocDelimiters(line) {
245
+ const result = [];
246
+ let quoteState = null;
247
+ let escaped = false;
248
+ for (let index = 0; index < line.length - 1; index += 1) {
249
+ const char = line[index];
250
+ if (escaped) {
251
+ escaped = false;
252
+ continue;
253
+ }
254
+ if (char === "\\" && quoteState !== "'") {
255
+ escaped = true;
256
+ continue;
257
+ }
258
+ if (quoteState) {
259
+ if (char === quoteState)
260
+ quoteState = null;
261
+ continue;
262
+ }
263
+ if (char === "'" || char === '"') {
264
+ quoteState = char;
265
+ continue;
266
+ }
267
+ if (char !== "<" || line[index + 1] !== "<" || line[index + 2] === "<")
268
+ continue;
269
+ index += 2;
270
+ const stripTabs = line[index] === "-";
271
+ if (stripTabs)
272
+ index += 1;
273
+ while (/\s/.test(line[index] ?? ""))
274
+ index += 1;
275
+ const delimiterQuote = line[index] === "'" || line[index] === '"' ? line[index++] : null;
276
+ const start = index;
277
+ if (delimiterQuote)
278
+ while (index < line.length && line[index] !== delimiterQuote)
279
+ index += 1;
280
+ else
281
+ while (index < line.length && !/[\s;&|<>]/.test(line[index]))
282
+ index += 1;
283
+ const delimiter = line.slice(start, index);
284
+ if (delimiter)
285
+ result.push({ delimiter, stripTabs });
286
+ }
287
+ return result;
288
+ }
@@ -0,0 +1,124 @@
1
+ import crypto from "node:crypto";
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ import { AppError } from "../shared/errors.js";
5
+ import { flowCommand } from "./stage-pause.js";
6
+ import { validateSchema } from "./schema-validation.js";
7
+ import { adapterSessionId, runHarnessAdapter } from "./harness-adapter.js";
8
+ /** A deterministic dispatcher; only the launched harness Session performs agent work. */
9
+ export async function serveMergeRequests(context, input) {
10
+ const running = context.db.get("SELECT server_id, pid FROM merge_servers WHERE status IN ('running','stopping') ORDER BY started_at LIMIT 1");
11
+ if (running && processAlive(running.pid))
12
+ throw new AppError("merge_server_already_running", "Only one merge-server may run in one DD_FLOW_HOME", 1, { server_id: running.server_id });
13
+ if (running)
14
+ context.db.run("UPDATE merge_servers SET status = 'failed', stopped_at = ?, heartbeat_at = ? WHERE server_id = ?", [context.now(), context.now(), running.server_id]);
15
+ const profile = loadProfile(context, input.profileId);
16
+ const serverId = `MSV-${crypto.randomUUID()}`;
17
+ const root = path.join(context.ddFlowHome, "merge-servers", serverId);
18
+ fs.mkdirSync(root, { recursive: true });
19
+ const journal = path.join(root, "events.jsonl");
20
+ const now = context.now();
21
+ reconcileExpiredDispatches(context);
22
+ context.db.run("INSERT INTO merge_servers (server_id, profile_id, status, pid, started_at, heartbeat_at, stopped_at, journal_path) VALUES (?, ?, 'running', ?, ?, ?, NULL, ?)", [serverId, profile.id, process.pid, now, now, journal]);
23
+ event(journal, { type: "server_started", server_id: serverId, profile, at: now });
24
+ let handled = 0;
25
+ try {
26
+ let running = true;
27
+ while (running) {
28
+ const status = context.db.get("SELECT status FROM merge_servers WHERE server_id = ?", [serverId])?.status;
29
+ if (status === "stopping")
30
+ break;
31
+ const requests = nextServerRequests(context, Math.max(1, input.maxParallelProjects ?? 4));
32
+ context.db.run("UPDATE merge_servers SET heartbeat_at = ? WHERE server_id = ?", [context.now(), serverId]);
33
+ if (!requests.length) {
34
+ input.progress?.(`merge-server ${serverId} idle; next poll in ${input.pollSeconds ?? 10} seconds`);
35
+ if (input.once)
36
+ break;
37
+ await delay((input.pollSeconds ?? 10) * 1000);
38
+ continue;
39
+ }
40
+ const settled = await Promise.allSettled(requests.map((request) => dispatch(context, { serverId, profile, request, root, journal, ...(input.progress ? { progress: input.progress } : {}) })));
41
+ handled += settled.filter((item) => item.status === "fulfilled").length;
42
+ const failed = settled.find((item) => item.status === "rejected");
43
+ if (failed)
44
+ throw failed.reason;
45
+ running = !input.once;
46
+ }
47
+ context.db.run("UPDATE merge_servers SET status = 'stopped', heartbeat_at = ?, stopped_at = ? WHERE server_id = ?", [context.now(), context.now(), serverId]);
48
+ event(journal, { type: "server_stopped", server_id: serverId, handled, at: context.now() });
49
+ return { ok: true, server_id: serverId, profile_id: profile.id, handled, status: "stopped", journal_path: journal };
50
+ }
51
+ catch (error) {
52
+ context.db.run("UPDATE merge_servers SET status = 'failed', heartbeat_at = ?, stopped_at = ? WHERE server_id = ?", [context.now(), context.now(), serverId]);
53
+ event(journal, { type: "server_failed", server_id: serverId, error: String(error), at: context.now() });
54
+ throw error;
55
+ }
56
+ }
57
+ export function mergeServerStatus(context) { return { ok: true, servers: context.db.all("SELECT server_id, profile_id, status, pid, started_at, heartbeat_at, stopped_at, journal_path FROM merge_servers ORDER BY started_at DESC") }; }
58
+ export function stopMergeServer(context, input) { const changed = context.db.run("UPDATE merge_servers SET status = 'stopping', heartbeat_at = ? WHERE server_id = ? AND status = 'running'", [context.now(), input.serverId]); if (changed.changes !== 1)
59
+ throw new AppError("not_found", "Running merge-server was not found", 1, { server_id: input.serverId }); return { ok: true, server_id: input.serverId, status: "stopping" }; }
60
+ async function dispatch(context, input) {
61
+ const token = crypto.randomUUID();
62
+ const leaseUntil = new Date(Date.now() + 120_000).toISOString();
63
+ const claimed = context.db.run("UPDATE merge_requests SET status = 'dispatching', dispatch_owner = ?, dispatch_lease_token = ?, dispatch_lease_expires_at = ?, updated_at = ? WHERE merge_request_id = ? AND status = 'queued' AND execution_route = 'server'", [input.serverId, token, leaseUntil, context.now(), input.request.merge_request_id]);
64
+ if (claimed.changes !== 1)
65
+ return;
66
+ const stateDir = path.join(input.root, input.request.merge_request_id);
67
+ fs.mkdirSync(stateDir, { recursive: true });
68
+ const promptFile = path.join(stateDir, "launch.md");
69
+ const adapterJournal = path.join(stateDir, "adapter.events.jsonl");
70
+ const stageCommand = `${flowCommand(context)} stage start ${input.request.run_id} --stage merge --project-root ${JSON.stringify(input.request.target_workspace)} --json --progress-jsonl`;
71
+ 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`);
72
+ event(input.journal, { type: "launch_intent", server_id: input.serverId, request_id: input.request.merge_request_id, token, profile: input.profile, prompt_file: promptFile, at: context.now() });
73
+ input.progress?.(`launching ${input.request.merge_request_id} with ${input.profile.id}`);
74
+ const executable = context.env[`DD_FLOW_${input.profile.harness.toUpperCase()}_ADAPTER`] ?? `dd-${input.profile.harness}`;
75
+ const common = ["--state-dir", stateDir, "--journal", adapterJournal, "--cwd", input.request.target_workspace, "--provider", input.profile.provider, "--model", input.profile.model, "--reasoning", input.profile.reasoning, "--mode", input.profile.mode, "--permission", input.profile.permission, "--project-root", input.request.target_workspace, "--dd-flow-home", context.ddFlowHome, "--json"];
76
+ try {
77
+ const adapterCall = (args, timeoutMs, progressMessage) => runHarnessAdapter({ executable, args, env: context.env, ...(timeoutMs ? { timeoutMs } : {}), ...(input.progress ? { progress: input.progress } : {}), ...(progressMessage ? { progressMessage } : {}), onEvidence: (value) => event(input.journal, { type: "adapter_call", ...value }) });
78
+ await adapterCall(["daemon", "start", ...common]);
79
+ const created = await adapterCall(["session", "create", ...common]);
80
+ const sessionId = adapterSessionId(created);
81
+ if (!sessionId)
82
+ throw new AppError("merge_adapter_invalid", "Harness adapter did not return a Session id", 1, { harness: input.profile.harness, response: created });
83
+ 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`);
84
+ const request = context.db.get("SELECT status FROM merge_requests WHERE merge_request_id = ?", [input.request.merge_request_id]);
85
+ context.db.run("UPDATE merge_requests SET adapter_receipt_json = ?, updated_at = ? WHERE merge_request_id = ?", [JSON.stringify({ profile: input.profile, executable, session_id: sessionId, response: prompted }), context.now(), input.request.merge_request_id]);
86
+ event(input.journal, { type: "adapter_settled", request_id: input.request.merge_request_id, session_id: sessionId, request_status: request?.status ?? null, at: context.now() });
87
+ if (request?.status === "dispatching")
88
+ context.db.run("UPDATE merge_requests SET status = 'queued', dispatch_owner = NULL, dispatch_lease_token = NULL, dispatch_lease_expires_at = NULL, last_error_json = ?, updated_at = ? WHERE merge_request_id = ?", [JSON.stringify({ code: "adapter_did_not_start_stage", response: prompted }), context.now(), input.request.merge_request_id]);
89
+ }
90
+ catch (error) {
91
+ const request = context.db.get("SELECT status FROM merge_requests WHERE merge_request_id = ?", [input.request.merge_request_id]);
92
+ if (request?.status === "dispatching")
93
+ context.db.run("UPDATE merge_requests SET status = 'queued', dispatch_owner = NULL, dispatch_lease_token = NULL, dispatch_lease_expires_at = NULL, last_error_json = ?, updated_at = ? WHERE merge_request_id = ?", [JSON.stringify({ code: "adapter_launch_failed", error: String(error) }), context.now(), input.request.merge_request_id]);
94
+ else if (request?.status === "active")
95
+ context.db.run("UPDATE merge_requests SET status = 'recovery_required', last_error_json = ?, updated_at = ? WHERE merge_request_id = ?", [JSON.stringify({ code: "adapter_lost_after_stage_start", error: String(error) }), context.now(), input.request.merge_request_id]);
96
+ throw error;
97
+ }
98
+ }
99
+ function nextServerRequests(context, limit) { const selected = new Set(); return context.db.all("SELECT merge_request_id, project_id, run_id, executor_work_id, target_workspace, execution_route, status, created_at FROM merge_requests WHERE status = 'queued' AND execution_route = 'server' ORDER BY created_at, merge_request_id").filter((request) => { if (selected.has(request.project_id) || context.db.get("SELECT 1 FROM merge_requests WHERE project_id = ? AND status NOT IN ('completed','failed','cancelled') AND (created_at < ? OR (created_at = ? AND merge_request_id < ?)) LIMIT 1", [request.project_id, request.created_at, request.created_at, request.merge_request_id]))
100
+ return false; selected.add(request.project_id); return true; }).slice(0, limit); }
101
+ function reconcileExpiredDispatches(context) { const expired = context.db.all("SELECT merge_request_id, executor_work_id FROM merge_requests WHERE status = 'dispatching' AND dispatch_lease_expires_at < ?", [context.now()]); for (const request of expired) {
102
+ const work = context.db.get("SELECT status FROM works WHERE work_id = ?", [request.executor_work_id]);
103
+ if (work?.status === "created")
104
+ context.db.run("UPDATE merge_requests SET status = 'queued', dispatch_owner = NULL, dispatch_lease_token = NULL, dispatch_lease_expires_at = NULL, last_error_json = ?, updated_at = ? WHERE merge_request_id = ?", [JSON.stringify({ code: "expired_dispatch_requeued" }), context.now(), request.merge_request_id]);
105
+ else
106
+ context.db.run("UPDATE merge_requests SET status = 'recovery_required', last_error_json = ?, updated_at = ? WHERE merge_request_id = ?", [JSON.stringify({ code: "expired_dispatch_with_started_work", work_status: work?.status ?? null }), context.now(), request.merge_request_id]);
107
+ } }
108
+ function loadProfile(context, id) { const file = path.join(context.ddFlowHome, "agent-profiles", `${id}.json`); let value; try {
109
+ value = JSON.parse(fs.readFileSync(file, "utf8"));
110
+ }
111
+ catch (error) {
112
+ throw new AppError("agent_profile_missing", "Merge-server agent profile is unavailable", 1, { id, file, cause: String(error) });
113
+ } validateSchema({ schemaName: "agent-profile", file }); if (value.id !== id)
114
+ throw new AppError("agent_profile_invalid", "Agent profile id does not match its requested filename", 2, { file, expected: id, actual: value.id ?? null }); return value; }
115
+ function event(file, value) { fs.appendFileSync(file, `${JSON.stringify(value)}\n`); }
116
+ function delay(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); }
117
+ function processAlive(pid) { if (!pid)
118
+ return false; try {
119
+ process.kill(pid, 0);
120
+ return true;
121
+ }
122
+ catch {
123
+ return false;
124
+ } }
@@ -182,6 +182,9 @@ function snapshotVnextExecutionProfile(projectRoot) {
182
182
  stage_session_mode: profile.stage_session_mode,
183
183
  plan_review_mode: profile.plan_review_mode,
184
184
  code_review_mode: profile.code_review_mode,
185
+ merge_mode: profile.merge_mode,
186
+ merge_delivery: profile.merge_delivery,
187
+ merge_cleanup: profile.merge_cleanup,
185
188
  stop_target: profile.stop_target,
186
189
  code_bootstrap: profile.code_bootstrap
187
190
  }
@@ -925,7 +928,9 @@ function vnextProtocolizeGuidance(run, index) {
925
928
  const planReviewDone = index.stage_runs.some((stage) => stage.stage === "plan-review" && stage.status === "done");
926
929
  const codeDone = index.stage_runs.some((stage) => stage.stage === "code" && stage.status === "done");
927
930
  const codeReviewDone = index.stage_runs.some((stage) => stage.stage === "code-review" && stage.status === "done");
928
- const nextStage = !specifyDone ? "specify" : !protocolizeDone ? "protocolize" : !planDone ? "plan" : !planReviewDone ? "plan-review" : !codeDone ? "code" : !codeReviewDone ? "code-review" : null;
931
+ const mergeDone = index.stage_runs.some((stage) => stage.stage === "merge" && stage.status === "done");
932
+ const stopTarget = index.execution_profile?.settings.stop_target;
933
+ const nextStage = !specifyDone ? "specify" : !protocolizeDone ? "protocolize" : !planDone ? "plan" : !planReviewDone ? "plan-review" : !codeDone ? "code" : stopTarget === "code_completed" ? null : !codeReviewDone ? "code-review" : stopTarget === "merge_completed" && !mergeDone ? "merge" : null;
929
934
  const currentStage = index.current_stage ?? [...index.stage_runs].reverse().find((stage) => stage.status === "running" || stage.status === "done")?.stage ?? nextStage;
930
935
  const terminal = run.status === "done" || run.status === "cancelled" || run.status === "failed" || run.status === "discarded";
931
936
  const paused = run.status === "paused";
@@ -936,7 +941,7 @@ function vnextProtocolizeGuidance(run, index) {
936
941
  allowed_next_stages: terminal || paused || !nextStage ? [] : [nextStage],
937
942
  recommended_next_action: terminal || !nextStage ? "none" : blocked ? "resolve_blocker_then_unblock_same_stage" : paused ? "await_user_answer_then_resume_same_stage" : `start_${nextStage.replace("-", "_")}`,
938
943
  recommended_prompt: terminal || !nextStage ? "none" : blocked ? "unblock_command_from_blocker" : paused ? "resume_command_from_pause" : `.memory-bank/dd-flow/vnext/${nextStage}.md`,
939
- required_predecessor_evidence: !specifyDone ? [] : !protocolizeDone ? ["01-specify/specify.json"] : !planDone ? ["01-specify/specify.json", "02-protocolize/protocolize-result.json"] : !planReviewDone ? ["03-plan/stage-report.json"] : !codeDone ? ["04-plan-review/stage-report.json"] : ["05-code/stage-report.json"],
944
+ required_predecessor_evidence: !specifyDone ? [] : !protocolizeDone ? ["01-specify/specify.json"] : !planDone ? ["01-specify/specify.json", "02-protocolize/protocolize-result.json"] : !planReviewDone ? ["03-plan/stage-report.json"] : !codeDone ? ["04-plan-review/stage-report.json"] : !codeReviewDone && stopTarget !== "code_completed" ? ["05-code/stage-report.json"] : stopTarget === "merge_completed" ? ["05-code/stage-report.json", "06-code-review/stage-report.json"] : ["05-code/stage-report.json"],
940
945
  guards: [],
941
946
  blocked_if_missing: []
942
947
  };
@@ -2,6 +2,7 @@ import crypto from "node:crypto";
2
2
  import fs from "node:fs";
3
3
  import { continuationPolicies, flowKinds } from "../domain/contracts.js";
4
4
  import { AppError } from "../shared/errors.js";
5
+ import { commandOption, commandPosition, parseLifecycleCommand } from "./lifecycle-command.js";
5
6
  import { parseJsonObject } from "../shared/json.js";
6
7
  import { resolveProjectRoot } from "../storage/paths.js";
7
8
  import { appendAudit } from "./audit.js";
@@ -185,23 +186,25 @@ export function updateFlowSessionContinuation(context, projectId, sessionId, act
185
186
  return nextCount;
186
187
  }
187
188
  export function flowSessionPayloadFromRegisterCommand(command) {
188
- if (/\bdd-flow\s+stage\s+start\b/.test(command))
189
- return flowSessionPayloadFromStageStartCommand(command);
190
- if (!/\bdd-flow\s+session\s+register\b/.test(command))
189
+ const invocation = typeof command === "string" ? invocationFromCommand(command) : command;
190
+ if (!invocation)
191
191
  return undefined;
192
- const payloadBase64 = optionFromCommand(command, "payload-base64");
193
- const payloadJson = optionFromCommand(command, "payload-json");
194
- const payloadFile = optionFromCommand(command, "payload-file");
192
+ if (invocation.operation === "stage_start")
193
+ return flowSessionPayloadFromStageStartCommand(invocation);
194
+ if (invocation.operation !== "session_register")
195
+ return undefined;
196
+ const payloadBase64 = commandOption(invocation, "payload-base64");
197
+ const payloadJson = commandOption(invocation, "payload-json");
198
+ const payloadFile = commandOption(invocation, "payload-file");
195
199
  if (!payloadBase64 && !payloadJson && !payloadFile) {
196
200
  return undefined;
197
201
  }
198
202
  return decodeFlowSessionPayload({ payloadBase64, payloadJson, payloadFile });
199
203
  }
200
- function flowSessionPayloadFromStageStartCommand(command) {
201
- const projectRoot = optionFromCommand(command, "project-root");
202
- const stage = optionFromCommand(command, "stage");
203
- const rawRun = command.match(/\bdd-flow\s+stage\s+start\s+("(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'|[^\s]+)/)?.[1];
204
- const run = rawRun ? shellArgument(rawRun) : undefined;
204
+ function flowSessionPayloadFromStageStartCommand(invocation) {
205
+ const projectRoot = commandOption(invocation, "project-root");
206
+ const stage = commandOption(invocation, "stage");
207
+ const run = commandPosition(invocation, 0);
205
208
  if (!projectRoot || !stage || !run || run.startsWith("--"))
206
209
  return undefined;
207
210
  return {
@@ -217,6 +220,10 @@ function flowSessionPayloadFromStageStartCommand(command) {
217
220
  coverage_units: []
218
221
  };
219
222
  }
223
+ function invocationFromCommand(command) {
224
+ const analysis = parseLifecycleCommand(command);
225
+ return analysis.kind === "none" ? undefined : analysis.invocation;
226
+ }
220
227
  function decodeFlowSessionPayload(input) {
221
228
  if (input.payloadFile) {
222
229
  return normalizeFlowSessionPayload(parseJsonObject(fs.readFileSync(input.payloadFile, "utf8"), "session payload"));
@@ -422,22 +429,6 @@ function releaseMergeLockIfOwned(context, projectRoot, workerId, reason) {
422
429
  });
423
430
  return { ok: true, released: true, lock_id: lock.id };
424
431
  }
425
- function optionFromCommand(command, key) {
426
- const pattern = new RegExp(`--${key}(?:\\s+|=)(?:"([^"]*)"|'([^']*)'|([^\\s]+))`);
427
- const match = command.match(pattern);
428
- return match?.[1] ?? match?.[2] ?? match?.[3];
429
- }
430
- function shellArgument(raw) {
431
- if (raw.startsWith('"')) {
432
- try {
433
- return JSON.parse(raw);
434
- }
435
- catch {
436
- return undefined;
437
- }
438
- }
439
- return raw.startsWith("'") ? raw.slice(1, -1) : raw;
440
- }
441
432
  function sanitizeValue(value) {
442
433
  if (Array.isArray(value)) {
443
434
  return value.map(sanitizeValue);
@@ -136,6 +136,19 @@ function stageResumeCommand(context, input) {
136
136
  }
137
137
  export function flowCommand(context) {
138
138
  const defaultHome = path.resolve(path.join(os.homedir(), ".dd-flow"));
139
+ // Desktop/ACP tool processes are not required to inherit the environment of
140
+ // the adapter that created their Session. In an isolated runtime, a bare
141
+ // `dd-flow` can therefore select the host-global router despite a correct
142
+ // DD_FLOW_HOME. The runner supplies an absolute executable for that case;
143
+ // make every generated lifecycle command self-contained.
144
+ const executable = context.env.DD_FLOW_BIN;
145
+ if (executable && path.isAbsolute(executable)) {
146
+ return `DD_FLOW_HOME=${JSON.stringify(context.ddFlowHome)} ${JSON.stringify(executable)}`;
147
+ }
148
+ const isolatedExecutable = path.join(context.ddFlowHome, "bin", "dd-flow");
149
+ if (context.ddFlowHome !== defaultHome && fs.existsSync(isolatedExecutable)) {
150
+ return `DD_FLOW_HOME=${JSON.stringify(context.ddFlowHome)} ${JSON.stringify(isolatedExecutable)}`;
151
+ }
139
152
  return context.ddFlowHome === defaultHome ? "dd-flow" : `DD_FLOW_HOME=${JSON.stringify(context.ddFlowHome)} dd-flow`;
140
153
  }
141
154
  function requireRun(context, projectId, id) {