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

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,306 @@
1
+ import crypto from "node:crypto";
2
+ import path from "node:path";
3
+ import { quote } from "shell-quote";
4
+ import { setTimeout as delay } from "node:timers/promises";
5
+ import { AppError } from "../shared/errors.js";
6
+ import { commandOption, parseLifecycleCommand, shellSuffixOffset } from "./lifecycle-command.js";
7
+ import { requireProjectByRoot } from "./projects.js";
8
+ import { assertRunMutationAllowed, assertStageSettlementAllowed, assertWorkSettlementAllowed, recoveryGuard } from "./run-recovery.js";
9
+ import { requireManagedDaemonBinding } from "./managed-daemon-binding.js";
10
+ // Conclusive result/check rejections, never transport, ownership, registry or
11
+ // unknown-effect failures. These validators reject before accepting the result.
12
+ export function isRetryableLifecycleRejection(code) {
13
+ return ["validation", "schema_validation", "work_checks_failed", "code_review_gate_failed",
14
+ "review_repair_incomplete", "plan_review_incomplete", "semantic_result_invalid",
15
+ "review_evidence_invalid", "evidence_obligation_unknown", "invalid_evidence_ref",
16
+ "evidence_ref_missing", "evidence_line_out_of_range", "document_update_missing",
17
+ "document_update_not_materialized", "review_repair_no_change"].includes(code);
18
+ }
19
+ function storage(context) {
20
+ context.db.execSchema(`CREATE TABLE IF NOT EXISTS lifecycle_invocations (
21
+ id TEXT PRIMARY KEY, scope_json TEXT NOT NULL, command TEXT NOT NULL,
22
+ fingerprint TEXT NOT NULL, status TEXT NOT NULL,
23
+ event_key TEXT UNIQUE, identity_json TEXT, deadline INTEGER, outcome_json TEXT,
24
+ created_at TEXT NOT NULL, updated_at TEXT NOT NULL
25
+ )`);
26
+ }
27
+ function invocation(command) {
28
+ const parsed = parseLifecycleCommand(command);
29
+ if (parsed.kind !== "standalone" || parsed.invocation.wrapped) {
30
+ throw new AppError("invocation_command_invalid", "A lifecycle attempt requires one direct standalone command", 1);
31
+ }
32
+ return parsed.invocation;
33
+ }
34
+ // Input data may be supplied later (stdin/file contents); authority is bound to
35
+ // the exact operation and literal argv. Existing lifecycle validation owns data.
36
+ function fingerprint(parsed) {
37
+ const options = [...parsed.args.options].filter(([key]) => !["invocation-id", "hook-event-id", "json", "progress-jsonl", "response-file"].includes(key))
38
+ .map(([key, values]) => [key, key === "reason" ? ["<semantic-reason>"] : values])
39
+ .sort(([a], [b]) => a.localeCompare(b));
40
+ return crypto.createHash("sha256").update(JSON.stringify([parsed.operation, parsed.args.positional, options])).digest("hex");
41
+ }
42
+ function load(context, id) {
43
+ if (!context.db.get("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'lifecycle_invocations'")) {
44
+ throw new AppError("invocation_unknown", "This runtime has no issued lifecycle attempts", 1, { invocation_id: id });
45
+ }
46
+ const row = context.db.get("SELECT * FROM lifecycle_invocations WHERE id = ?", [id]);
47
+ if (!row)
48
+ throw new AppError("invocation_unknown", "Lifecycle attempt was not issued by this runtime", 1, { invocation_id: id });
49
+ return row;
50
+ }
51
+ export function lifecycleInvocationScope(context, id) {
52
+ return JSON.parse(load(context, id).scope_json);
53
+ }
54
+ export function managedInvocationContext(context, input) {
55
+ const env = { ...context.env, DD_FLOW_CURRENT_INVOCATION: undefined, DD_FLOW_INVOCATION_SCOPE: undefined };
56
+ if (input.harness !== "zcode-acp")
57
+ return { ...context, env };
58
+ const project = requireProjectByRoot(context, input.projectRoot);
59
+ const { state, process: daemon } = requireManagedDaemonBinding(context, { stateDir: input.stateDir, projectId: project.id, runId: input.runId });
60
+ if (JSON.parse(daemon.metadata_json).run_generation !== input.generation)
61
+ throw new AppError("invocation_generation_stale", "Managed daemon belongs to another RUN generation", 1);
62
+ const session = state.sessions?.find(session => session.adapter_session_id === input.sessionId || session.provider_session_id === input.sessionId);
63
+ if (!state.daemon_id || !session?.root_provider_session_id)
64
+ throw new AppError("invocation_scope_unproven", "Managed ZCode Session has no persisted native root binding", 1);
65
+ const scope = { projectRoot: project.root, daemonId: state.daemon_id, rootSessionId: session.root_provider_session_id, runId: input.runId, generation: input.generation };
66
+ env.DD_FLOW_INVOCATION_SCOPE = JSON.stringify(scope);
67
+ return { ...context, env };
68
+ }
69
+ /** Generated commands retain their attempt across repeated rendering. Only a
70
+ * currently executing command can emit its own successor with the same argv. */
71
+ export function managedLifecycleCommand(context, command) {
72
+ const configured = context.env.DD_FLOW_INVOCATION_SCOPE;
73
+ if (!configured)
74
+ return command;
75
+ const scope = JSON.parse(configured);
76
+ const parsed = invocation(command);
77
+ if (commandOption(parsed, "invocation-id"))
78
+ return command;
79
+ if (!context.db.writable || context.env.DD_FLOW_INVOCATION_READONLY === "1") {
80
+ const retained = context.db.get("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'lifecycle_invocations'")
81
+ ? context.db.get("SELECT * FROM lifecycle_invocations WHERE scope_json = ? AND fingerprint = ? ORDER BY rowid DESC LIMIT 1", [JSON.stringify(scope), fingerprint(parsed)]) : undefined;
82
+ if (!retained)
83
+ throw new AppError("invocation_command_unprepared", "Read-only inspection has no retained lifecycle command", 1);
84
+ return retained.command;
85
+ }
86
+ storage(context);
87
+ context.db.exec("SAVEPOINT invocation_command");
88
+ try {
89
+ const prior = context.db.get("SELECT * FROM lifecycle_invocations WHERE scope_json = ? AND fingerprint = ? ORDER BY rowid DESC LIMIT 1", [JSON.stringify(scope), fingerprint(parsed)]);
90
+ if (prior && prior.id !== context.env.DD_FLOW_CURRENT_INVOCATION) {
91
+ context.db.exec("RELEASE invocation_command");
92
+ return prior.command;
93
+ }
94
+ if (context.env.DD_FLOW_INVOCATION_READONLY === "1")
95
+ throw new AppError("invocation_command_unprepared", "No prepared lifecycle command is available; read-only inspection cannot issue one", 1);
96
+ const prepared = issueLifecycleInvocation(context, command, scope);
97
+ context.db.exec("RELEASE invocation_command");
98
+ return prepared.command;
99
+ }
100
+ catch (error) {
101
+ context.db.exec("ROLLBACK TO invocation_command");
102
+ context.db.exec("RELEASE invocation_command");
103
+ throw error;
104
+ }
105
+ }
106
+ export function retryLifecycleInvocationCommand(context, id) {
107
+ const prior = load(context, id);
108
+ if (prior.status !== "executing")
109
+ throw new AppError("invocation_retry_not_authorized", "Only the executing lifecycle operation can publish its next attempt", 1);
110
+ const marker = ` --invocation-id ${id}`;
111
+ if (!prior.command.includes(marker))
112
+ throw new AppError("invocation_command_invalid", "Retained issued command lost its invocation marker", 1);
113
+ return managedLifecycleCommand({ ...context, env: { ...context.env, DD_FLOW_INVOCATION_SCOPE: prior.scope_json, DD_FLOW_CURRENT_INVOCATION: id } }, prior.command.replace(marker, ""));
114
+ }
115
+ /** Recovery preserves the original packet as evidence. Only its retained,
116
+ * never-executed commands receive new authority; unknown effects never replay. */
117
+ export function recoverLifecycleCommands(context, originalPrompt, scope) {
118
+ const ids = [...new Set([...originalPrompt.matchAll(/--invocation-id ([0-9a-f-]{36})\b/g)].map(match => match[1]))];
119
+ const recovered = [];
120
+ for (const id of ids) {
121
+ let prior = load(context, id);
122
+ const oldScope = JSON.parse(prior.scope_json);
123
+ if (oldScope.projectRoot !== scope.projectRoot || oldScope.runId !== scope.runId
124
+ || oldScope.rootSessionId !== scope.rootSessionId || oldScope.generation >= scope.generation) {
125
+ throw new AppError("invocation_recovery_scope_mismatch", "Retained command does not belong to the recovered root and an earlier generation", 1, { invocation_id: id });
126
+ }
127
+ // The immutable prompt can predate several recoveries. Follow the newest
128
+ // retained attempt, including an unknown successor, rather than reviving
129
+ // an older unexecuted command after the successor already produced effects.
130
+ prior = context.db.get(`SELECT * FROM lifecycle_invocations WHERE fingerprint = ?
131
+ AND json_extract(scope_json, '$.projectRoot') = ? AND json_extract(scope_json, '$.runId') IS ?
132
+ AND json_extract(scope_json, '$.rootSessionId') = ? AND json_extract(scope_json, '$.generation') < ?
133
+ ORDER BY json_extract(scope_json, '$.generation') DESC, rowid DESC LIMIT 1`, [prior.fingerprint, scope.projectRoot, scope.runId, scope.rootSessionId, scope.generation]);
134
+ if (prior.status === "executing" || prior.status === "settled") {
135
+ const outcome = prior.outcome_json ? JSON.parse(prior.outcome_json) : null;
136
+ recovered.push({ invocation_id: id, status: prior.status, outcome,
137
+ instruction: prior.status === "executing" ? "Outcome unknown: reconcile retained operation evidence; do not reissue this command." : "Read the retained outcome; do not repeat completed effects." });
138
+ continue;
139
+ }
140
+ const parsed = invocation(prior.command);
141
+ const argv = [...parsed.argv];
142
+ const at = argv.indexOf("--invocation-id");
143
+ if (at < 0 || argv[at + 1] !== prior.id)
144
+ throw new AppError("invocation_command_invalid", "Retained command lost its exact invocation marker", 1);
145
+ argv.splice(at, 2);
146
+ const env = { ...parsed.env, DD_FLOW_INVOCATION_SCOPE: JSON.stringify(scope) };
147
+ const command = `${Object.entries(env).map(([key, value]) => `${key}=${quote([value])}`).join(" ")} ${quote([parsed.executable, ...argv])} ${prior.command.slice(shellSuffixOffset(prior.command))}`.trim();
148
+ const prepared = managedLifecycleCommand({ ...context, env: { ...context.env, DD_FLOW_INVOCATION_SCOPE: JSON.stringify(scope), DD_FLOW_CURRENT_INVOCATION: undefined } }, command);
149
+ recovered.push({ invocation_id: id, status: "replaced_unexecuted", command: prepared });
150
+ }
151
+ return recovered;
152
+ }
153
+ export function assertLifecycleInvocationCurrent(context, scope, command) {
154
+ const parsed = invocation(command);
155
+ if (!scope.runId) {
156
+ if ((parsed.operation === "stage_start" && parsed.args.options.has("bootstrap")) || parsed.operation === "session_register")
157
+ return;
158
+ throw new AppError("invocation_scope_invalid", "This lifecycle operation requires an issued RUN scope", 1);
159
+ }
160
+ const project = requireProjectByRoot(context, scope.projectRoot);
161
+ let workId;
162
+ if (parsed.operation.startsWith("work_")) {
163
+ const target = parsed.args.positional[0] ?? "";
164
+ const works = context.db.all("SELECT work_id, run_id FROM works WHERE project_id = ? AND (work_id = ? OR (? = 1 AND work_id LIKE ?))", [project.id, target, /^WRK-\d{3,}$/.test(target) ? 1 : 0, `${target}-%`]);
165
+ if (works.length !== 1 || works[0].run_id !== scope.runId)
166
+ throw new AppError("invocation_scope_mismatch", "Work does not belong to the issued RUN scope", 1);
167
+ workId = works[0].work_id;
168
+ }
169
+ else if (parsed.operation !== "session_register" && !parsed.args.options.has("bootstrap")) {
170
+ const target = parsed.args.positional[0];
171
+ const run = context.db.get("SELECT id FROM runs WHERE project_id = ? AND (id = ? OR short_id = ?)", [project.id, target ?? null, target ?? null]);
172
+ if (run?.id !== scope.runId)
173
+ throw new AppError("invocation_scope_mismatch", "Command does not belong to the issued RUN scope", 1);
174
+ }
175
+ const guard = recoveryGuard(context, project.id, scope.runId);
176
+ // A cooperative pause advances the fence but explicitly admits settlement
177
+ // of the captured WorkSession/Stage attempt. Reuse that exact authority;
178
+ // never turn the invocation layer into a stricter, divergent pause policy.
179
+ if (guard?.status === "draining" && guard.generation === scope.generation + 1) {
180
+ if (workId && ["work_finish", "work_fail"].includes(parsed.operation))
181
+ return assertWorkSettlementAllowed(context, project.id, scope.runId, workId);
182
+ if (parsed.operation === "stage_finish")
183
+ return assertStageSettlementAllowed(context, project.id, scope.runId, commandOption(parsed, "stage") ?? "");
184
+ }
185
+ if ((guard?.generation ?? 0) !== scope.generation)
186
+ throw new AppError("invocation_generation_stale", "Lifecycle attempt belongs to another recovery generation", 1);
187
+ if (parsed.operation !== "recovery_accept")
188
+ assertRunMutationAllowed(context, project.id, scope.runId);
189
+ }
190
+ function checkCommand(row, command) {
191
+ const parsed = invocation(command);
192
+ const scope = JSON.parse(row.scope_json);
193
+ if (commandOption(parsed, "invocation-id") !== row.id || fingerprint(parsed) !== row.fingerprint
194
+ || path.resolve(commandOption(parsed, "project-root") ?? "") !== scope.projectRoot) {
195
+ throw new AppError("invocation_command_mismatch", "Lifecycle attempt belongs to another command or project", 1, { invocation_id: row.id });
196
+ }
197
+ }
198
+ /** Explicit issuance; callers retain this command. Read-only show/status must
199
+ * return that retained value, not issue a replacement on every observation. */
200
+ export function issueLifecycleInvocation(context, command, scope) {
201
+ storage(context);
202
+ const parsed = invocation(command);
203
+ if (!scope.daemonId || !scope.rootSessionId || !Number.isInteger(scope.generation) || scope.generation < 0
204
+ || !path.isAbsolute(scope.projectRoot) || commandOption(parsed, "project-root") !== scope.projectRoot
205
+ || commandOption(parsed, "invocation-id") || commandOption(parsed, "hook-event-id")) {
206
+ throw new AppError("invocation_scope_invalid", "Lifecycle issuance requires explicit managed scope and a new command", 1);
207
+ }
208
+ const id = crypto.randomUUID();
209
+ const at = shellSuffixOffset(command);
210
+ const prepared = `${command.slice(0, at).trimEnd()} --invocation-id ${id} ${command.slice(at)}`.trimEnd();
211
+ // Validate the actual inserted argv, including quoted heredoc boundaries.
212
+ const row = { id, scope_json: JSON.stringify(scope), fingerprint: fingerprint(parsed) };
213
+ checkCommand(row, prepared);
214
+ context.db.run("INSERT INTO lifecycle_invocations (id, scope_json, command, fingerprint, status, created_at, updated_at) VALUES (?, ?, ?, ?, 'issued', ?, ?)", [id, row.scope_json, prepared, row.fingerprint, context.now(), context.now()]);
215
+ return { id, command: prepared };
216
+ }
217
+ /** Native observer and receipt writer commit together. Never await I/O inside
218
+ * this transaction; the callback must write to this same SQLite connection. */
219
+ export function observeLifecycleInvocation(context, input) {
220
+ const initial = load(context, input.id);
221
+ checkCommand(initial, input.command);
222
+ // A completed invocation is a read, even from a new native tool call after
223
+ // reattach. Do not manufacture a new receipt or reject that read as a second
224
+ // executor; the CLI returns the immutable stored outcome without dispatch.
225
+ if (initial.status === "settled")
226
+ return { eventKey: initial.event_key, duplicate: true };
227
+ const scope = JSON.parse(initial.scope_json);
228
+ const identity = input.identity;
229
+ if (identity.daemonId !== scope.daemonId || identity.rootSessionId !== scope.rootSessionId
230
+ || !identity.sessionId || !identity.toolCallId
231
+ || (identity.sessionId === scope.rootSessionId ? identity.parentSessionId !== null : !identity.parentSessionId)) {
232
+ throw new AppError("invocation_identity_mismatch", "Native identity does not belong to the issued managed scope", 1, { invocation_id: input.id });
233
+ }
234
+ const identityJson = JSON.stringify([identity.daemonId, identity.rootSessionId, identity.sessionId, identity.parentSessionId, identity.toolCallId]);
235
+ context.db.exec("BEGIN IMMEDIATE");
236
+ try {
237
+ const row = context.db.get("SELECT * FROM lifecycle_invocations WHERE id = ?", [input.id]);
238
+ if (row.status === "settled") {
239
+ context.db.exec("COMMIT");
240
+ return { eventKey: row.event_key, duplicate: true };
241
+ }
242
+ if (row.status === "expired" || (row.deadline !== null && row.deadline <= Date.now() && !row.event_key)) {
243
+ throw new AppError("invocation_expired", "Late observation cannot revive an expired lifecycle attempt", 1, { invocation_id: input.id });
244
+ }
245
+ if (row.identity_json) {
246
+ if (row.identity_json !== identityJson)
247
+ throw new AppError("invocation_identity_conflict", "Another native tool call already owns this attempt", 1, { invocation_id: input.id });
248
+ context.db.exec("COMMIT");
249
+ return { eventKey: row.event_key, duplicate: true };
250
+ }
251
+ const eventKey = input.recordReceipt();
252
+ const receipt = eventKey && context.db.get("SELECT provider_session_id, daemon_id, status FROM hook_events WHERE event_key = ? AND harness = 'zcode-acp' AND cwd = ?", [eventKey, scope.projectRoot]);
253
+ if (!receipt || receipt.provider_session_id !== identity.sessionId || receipt.daemon_id !== identity.daemonId || receipt.status !== "observed") {
254
+ throw new AppError("invocation_receipt_missing", "Native observer did not persist the matching unclaimed receipt", 1);
255
+ }
256
+ context.db.run("UPDATE lifecycle_invocations SET identity_json = ?, event_key = ?, status = 'observed', updated_at = ? WHERE id = ? AND status = 'issued'", [identityJson, eventKey, context.now(), input.id]);
257
+ context.db.exec("COMMIT");
258
+ return { eventKey, duplicate: false };
259
+ }
260
+ catch (error) {
261
+ context.db.exec("ROLLBACK");
262
+ throw error;
263
+ }
264
+ }
265
+ /** One caller owns execution; a lost process leaves an explicit unknown
266
+ * outcome, never an automatic replay. Existing recovery decides the next step. */
267
+ export async function awaitLifecycleInvocation(context, input) {
268
+ const initial = load(context, input.id);
269
+ checkCommand(initial, input.command);
270
+ const timeoutMs = input.timeoutMs ?? 30000;
271
+ if (!Number.isFinite(timeoutMs) || timeoutMs <= 0 || timeoutMs > 60000)
272
+ throw new AppError("invocation_timeout_invalid", "Receipt timeout must be within 1..60000 ms", 1);
273
+ context.db.run("UPDATE lifecycle_invocations SET deadline = COALESCE(deadline, ?), updated_at = ? WHERE id = ? AND status = 'issued'", [Date.now() + timeoutMs, context.now(), input.id]);
274
+ for (;;) {
275
+ const row = load(context, input.id);
276
+ if (row.status === "settled")
277
+ return { outcome: JSON.parse(row.outcome_json), replay: true };
278
+ if (row.status === "executing")
279
+ throw new AppError("invocation_outcome_pending", "This attempt already started; inspect its retained operation outcome before retrying", 1, { invocation_id: input.id });
280
+ input.assertCurrent(JSON.parse(row.scope_json));
281
+ if (row.status === "observed") {
282
+ const claimed = context.db.run("UPDATE lifecycle_invocations SET status = 'executing', updated_at = ? WHERE id = ? AND status = 'observed'", [context.now(), input.id]);
283
+ if (claimed.changes === 1)
284
+ return { eventKey: row.event_key, replay: false };
285
+ continue;
286
+ }
287
+ if (row.status === "expired" || (row.deadline !== null && row.deadline <= Date.now())) {
288
+ const expired = context.db.run("UPDATE lifecycle_invocations SET status = 'expired', updated_at = ? WHERE id = ? AND status = 'issued'", [context.now(), input.id]);
289
+ if (!expired.changes && row.status !== "expired")
290
+ continue;
291
+ throw new AppError("invocation_receipt_timeout", "No native confirmation before the receipt deadline; lifecycle state was not changed", 1, { invocation_id: input.id });
292
+ }
293
+ await delay(Math.min(25, Math.max(1, (row.deadline ?? Date.now() + 25) - Date.now())));
294
+ }
295
+ }
296
+ export function settleLifecycleInvocation(context, id, outcome) {
297
+ const encoded = JSON.stringify(outcome);
298
+ if (encoded === undefined)
299
+ throw new AppError("invocation_outcome_invalid", "Invocation outcome must be JSON", 1);
300
+ const changed = context.db.run("UPDATE lifecycle_invocations SET status = 'settled', outcome_json = ?, updated_at = ? WHERE id = ? AND status = 'executing'", [encoded, context.now(), id]);
301
+ if (changed.changes !== 1) {
302
+ const row = load(context, id);
303
+ if (row.status !== "settled" || row.outcome_json !== encoded)
304
+ throw new AppError("invocation_settlement_conflict", "Attempt cannot be settled with a different outcome", 1, { invocation_id: id });
305
+ }
306
+ }
@@ -1,3 +1,4 @@
1
+ import { managedInvocationContext, managedLifecycleCommand } from "./lifecycle-invocations.js";
1
2
  import crypto from "node:crypto";
2
3
  import fs from "node:fs";
3
4
  import path from "node:path";
@@ -7,7 +8,7 @@ import { flowCommand } from "./stage-pause.js";
7
8
  import { adapterSessionId, prepareHarnessFlowExecutable, runHarnessAdapter } from "./harness-adapter.js";
8
9
  import { harnessRuntimeArguments, resolveHarnessCommand } from "./harness-config.js";
9
10
  import { assertRunMutationAllowed, recoveryGuard } from "./run-recovery.js";
10
- import { loadAgentProfile, resolveExecutionPolicy } from "./execution-policy.js";
11
+ import { loadAgentProfile, resolveExecutionPolicy, runtimeHarness } from "./execution-policy.js";
11
12
  /** A deterministic dispatcher; only the launched harness Session performs agent work. */
12
13
  export async function serveMergeRequests(context, input) {
13
14
  const running = context.db.get("SELECT server_id, pid FROM merge_servers WHERE status IN ('running','stopping') ORDER BY started_at LIMIT 1");
@@ -103,9 +104,7 @@ async function dispatch(context, input) {
103
104
  const promptFile = path.join(stateDir, "launch.md");
104
105
  const adapterJournal = path.join(stateDir, "adapter.events.jsonl");
105
106
  const flowExecutable = prepareHarnessFlowExecutable(context, stateDir);
106
- const runtimeContext = { ...context, env: { ...context.env, DD_FLOW_BIN: flowExecutable } };
107
- const stageCommand = `${flowCommand(runtimeContext)} stage start ${input.request.run_id} --stage merge --project-root ${JSON.stringify(run.project_root)} --json --progress-jsonl`;
108
- 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`);
107
+ let runtimeContext = { ...context, env: { ...context.env, DD_FLOW_BIN: flowExecutable } };
109
108
  const runtimeOwner = { schema_id: "dd-flow/runtime-owner@1", owner_id: `run:${input.request.run_id}:merge:${input.request.merge_request_id}`, role: "merge", project_id: input.request.project_id, run_id: input.request.run_id, work_id: input.request.executor_work_id, operation_id: `${input.request.merge_request_id}:${token}`, state_dir: stateDir, dd_flow_home: context.ddFlowHome, dd_flow_bin: flowExecutable, resource_home: context.env.DD_FLOW_RESOURCE_HOME ?? context.ddFlowHome, budget: runtimeBudgetForRun(context, input.request.project_id, input.request.run_id) };
110
109
  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() });
111
110
  input.progress?.(`launching ${input.request.merge_request_id} with ${input.profile.id}`);
@@ -126,6 +125,9 @@ async function dispatch(context, input) {
126
125
  throw new AppError("merge_adapter_invalid", "Harness adapter did not return a Session id", 1, { harness: input.profile.harness, response: created });
127
126
  Object.assign(retained, { session_id: sessionId, create: created });
128
127
  retain();
128
+ runtimeContext = managedInvocationContext(runtimeContext, { harness: runtimeHarness(input.profile), stateDir, sessionId, projectRoot: run.project_root, runId: input.request.run_id, generation });
129
+ const stageCommand = managedLifecycleCommand(runtimeContext, `${flowCommand(runtimeContext)} stage start ${input.request.run_id} --stage merge --project-root ${JSON.stringify(run.project_root)} --json --progress-jsonl`);
130
+ 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`);
129
131
  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`);
130
132
  Object.assign(retained, { response: prompted });
131
133
  retain();
@@ -26,6 +26,9 @@ export function captureRecoveryDatabase(source, output, projectId, runId, protoc
26
26
  throw new AppError("snapshot_scope_conflict", "Selected Work refers to a Session currently bound to another RUN", 1);
27
27
  const sessions = `SELECT session_id FROM sessions WHERE ${ownProject} AND (run_id IN (SELECT run_id FROM capture_scope) OR session_id IN (SELECT session_id FROM work_sessions WHERE work_id IN (${works})))`;
28
28
  const nativeSessions = `SELECT harness, provider_session_id FROM sessions WHERE ${ownProject} AND session_id IN (${sessions})`;
29
+ const invocations = "json_extract(scope_json, '$.runId') IN (SELECT run_id FROM capture_scope) AND json_extract(scope_json, '$.projectRoot') IN (SELECT root FROM projects WHERE id IN (SELECT project_id FROM capture_scope))";
30
+ const invocationReceipts = db.prepare("SELECT 1 FROM sqlite_schema WHERE type = 'table' AND name = 'lifecycle_invocations'").get()
31
+ ? ` OR event_key IN (SELECT event_key FROM lifecycle_invocations WHERE ${invocations})` : "";
29
32
  const protocols = `SELECT id FROM capture_protocols UNION SELECT subject_id FROM runs WHERE (project_id, id) IN (SELECT * FROM capture_scope) UNION SELECT value FROM merge_requests, json_each(merge_requests.protocol_ids_json) WHERE ${ownRun} UNION SELECT protocol_id FROM flow_jobs WHERE ${ownRun}`;
30
33
  const rules = {
31
34
  dd_flow_writer_contract: "1",
@@ -36,7 +39,8 @@ export function captureRecoveryDatabase(source, output, projectId, runId, protoc
36
39
  work_sessions: `work_id IN (${works})`, work_launches: `work_id IN (${works})`,
37
40
  work_recovery_segments: `work_id IN (${works})`,
38
41
  run_recovery_bindings: `recovery_id IN (SELECT recovery_id FROM run_recovery_guards WHERE ${ownRun})`,
39
- hook_events: `${ownProject} AND (session_id IN (${sessions}) OR (harness, provider_session_id) IN (${nativeSessions}))`,
42
+ lifecycle_invocations: invocations,
43
+ hook_events: `${ownProject} AND (session_id IN (${sessions}) OR (harness, provider_session_id) IN (${nativeSessions})${invocationReceipts})`,
40
44
  harness_usage_snapshots: `${ownProject} AND (harness, provider_session_id) IN (${nativeSessions})`,
41
45
  protocols: `${ownProject} AND id IN (${protocols})`,
42
46
  project_config: ownProject,
@@ -15,6 +15,7 @@ import { requireRun, requireController, controllerReceipt, appendEvent, saveStat
15
15
  import { controllerAdapter, sessionArguments } from "./run-controller-adapter.js";
16
16
  import { spawnController, recordControllerLauncher } from "./run-controller-process.js";
17
17
  import { readNativeDaemonState, requireManagedDaemonBinding } from "./managed-daemon-binding.js";
18
+ import { managedInvocationContext } from "./lifecycle-invocations.js";
18
19
  import { runControlStatus } from "./run-control.js";
19
20
  import { runtimeBudgetStatus, waitForRuntimeScopeRecovery } from "./runtime-budget.js";
20
21
  /** CLI observation is bounded; the physical controller owns the recovery work. */
@@ -123,7 +124,7 @@ export async function resumeControllerSession(context, row, manifest, state, ass
123
124
  const session = state.current_session === null ? undefined : state.sessions[state.current_session];
124
125
  if (!session?.id)
125
126
  throw new AppError("recovery_owner_missing", "Recovery lost its retained coordinator Session", 1);
126
- const executionContext = { ...context, env: { ...context.env, DD_FLOW_BIN: manifest.flow_executable, DD_FLOW_RESOURCE_HOME: manifest.resource_home } };
127
+ let executionContext = { ...context, env: { ...context.env, DD_FLOW_BIN: manifest.flow_executable, DD_FLOW_RESOURCE_HOME: manifest.resource_home } };
127
128
  const scopeControl = manifest.budget ? runtimeBudgetStatus(executionContext, manifest.budget.scope_id).control : null;
128
129
  const assertRecoveryOwner = () => {
129
130
  assertPhysicalOwner();
@@ -156,6 +157,8 @@ export async function resumeControllerSession(context, row, manifest, state, ass
156
157
  const daemon = readDaemon(), daemonId = daemon.daemon_id ?? daemon.config?.daemonId;
157
158
  if (!daemonId || daemonId === (retired.daemon_id ?? retired.config?.daemonId) || started.daemon_id !== daemonId || started.already_running === true)
158
159
  throw new AppError("recovery_daemon_stale", "Recovery requires a fresh daemon, not reuse of the retired physical owner", 1);
160
+ executionContext = managedInvocationContext(executionContext, { harness: runtimeHarness(session.profile), stateDir: session.state_dir, sessionId: session.id,
161
+ projectRoot: manifest.request.project_root, runId: row.run_id, generation: row.generation });
159
162
  assertRecoveryOwner();
160
163
  const scope = { projectRoot: manifest.request.project_root, runId: row.run_id, recoveryId: recovery.recovery_id };
161
164
  resumeRunRecovery(executionContext, { ...scope, daemonId, harness: runtimeHarness(session.profile), nativeSessionId: session.id });
@@ -1,3 +1,4 @@
1
+ import { managedInvocationContext, managedLifecycleCommand } from "./lifecycle-invocations.js";
1
2
  import crypto from "node:crypto";
2
3
  import fs from "node:fs";
3
4
  import path from "node:path";
@@ -345,7 +346,11 @@ export function supplyRunControllerContext(context, input) {
345
346
  async function executeController(context, row, manifest, state, assertOwner, assertPhysicalOwner) {
346
347
  const policy = manifest.execution_profile.settings.execution;
347
348
  const profiles = manifest.execution_profile.agent_profiles;
348
- const executionContext = { ...context, env: { ...context.env, DD_FLOW_BIN: manifest.flow_executable, DD_FLOW_RESOURCE_HOME: manifest.resource_home, DD_FLOW_RUNTIME_BUDGET: manifest.budget ? JSON.stringify(manifest.budget) : undefined } };
349
+ let executionContext = { ...context, env: { ...context.env, DD_FLOW_BIN: manifest.flow_executable, DD_FLOW_RESOURCE_HOME: manifest.resource_home, DD_FLOW_RUNTIME_BUDGET: manifest.budget ? JSON.stringify(manifest.budget) : undefined } };
350
+ const bindInvocationScope = (session) => {
351
+ executionContext = managedInvocationContext(executionContext, { harness: runtimeHarness(session.profile), stateDir: session.state_dir, sessionId: session.id,
352
+ projectRoot: manifest.request.project_root, runId: row.run_id, generation: row.generation });
353
+ };
349
354
  let lastCapture = null;
350
355
  const setStatus = (status) => {
351
356
  const changed = context.db.run("UPDATE run_controllers SET status = ?, updated_at = ? WHERE controller_id = ? AND owner_token = ? AND status <> ?", [status, context.now(), row.controller_id, row.owner_token, status]);
@@ -394,6 +399,8 @@ async function executeController(context, row, manifest, state, assertOwner, ass
394
399
  };
395
400
  for (;;) {
396
401
  assertOwner();
402
+ if (state.current_session !== null && !state.sessions[state.current_session].stopped)
403
+ bindInvocationScope(state.sessions[state.current_session]);
397
404
  const run = requireRun(context, row.project_id, row.run_id);
398
405
  const view = getFlowRunStatus(executionContext, { projectRoot: run.project_root, runId: run.id });
399
406
  const next = view.continuation;
@@ -433,7 +440,7 @@ async function executeController(context, row, manifest, state, assertOwner, ass
433
440
  assertOwner();
434
441
  // The accepted answer is logical intent; only its generation-bound
435
442
  // adapter operation represents a physical dispatch that stop must drain.
436
- const command = `${flowCommand(executionContext)} stage resume ${run.id} --stage ${next.stage} --work ${input.work_id} --project-root ${JSON.stringify(run.project_root)} --answer-stdin --json < ${JSON.stringify(input.file)}`;
443
+ const command = managedLifecycleCommand(executionContext, `${flowCommand(executionContext)} stage resume ${run.id} --stage ${next.stage} --work ${input.work_id} --project-root ${JSON.stringify(run.project_root)} --answer-stdin --json < ${JSON.stringify(input.file)}`);
437
444
  const receipt = await prompt(state.sessions[state.current_session], `The user supplied the raw answer for pause ${pause.id}. Your first tool call must be this exact standalone command:\n\n${command}\n\nDo not alter the answer. Follow the returned packet, then stop at this Stage boundary.`, `${answer.operation_id}:prompt:${row.generation}`);
438
445
  context.db.run("UPDATE run_controller_operations SET status = 'completed', receipt_json = ?, updated_at = ? WHERE operation_id = ?", [JSON.stringify({ delivered: true, session_id: adapterSessionId(receipt) }), context.now(), answer.operation_id]);
439
446
  state.last_continuation = null;
@@ -497,6 +504,7 @@ async function executeController(context, row, manifest, state, assertOwner, ass
497
504
  throw new AppError("harness_adapter_invalid", "Managed adapter did not return its Session id", 1);
498
505
  session.id = sessionId;
499
506
  saveState(context, row, state);
507
+ bindInvocationScope(session);
500
508
  if (next.stage === "merge" && state.merge_claim)
501
509
  context.db.run("UPDATE merge_requests SET adapter_receipt_json = ?, updated_at = ? WHERE merge_request_id = ? AND dispatch_owner = ? AND dispatch_lease_token = ?", [JSON.stringify({ controller_id: row.controller_id, profile: session.profile, session_id: session.id, state_dir: session.state_dir, create: created }), context.now(), state.merge_claim.request_id, row.controller_id, state.merge_claim.token]);
502
510
  appendEvent(context, row.controller_id, "session_created", { stage: next.stage, session_id: sessionId, harness: runtimeHarness(session.profile), profile: session.profile });
@@ -507,7 +515,7 @@ async function executeController(context, row, manifest, state, assertOwner, ass
507
515
  saveState(context, row, state);
508
516
  setStatus("running");
509
517
  const responseFile = path.join(row.state_dir, `${next.stage}-${next.attempt ?? 1}.stage-start-response.json`);
510
- const command = `${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`;
518
+ 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`);
511
519
  appendEvent(context, row.controller_id, "stage_entered", { stage: next.stage, session_id: session.id, context_sha256: stageContext.sha256 });
512
520
  await prompt(session, `Start the assigned ${next.stage} Stage now. Your first tool call must be this exact standalone command:\n\n${command}\n\nRead 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 before doing Stage work. Stop at this Stage boundary or a declared HITL/Work-graph boundary; do not start a successor Stage yourself.`);
513
521
  continue;
@@ -1,3 +1,4 @@
1
+ import { managedLifecycleCommand, recoverLifecycleCommands } from "./lifecycle-invocations.js";
1
2
  import crypto from "node:crypto";
2
3
  import fs from "node:fs";
3
4
  import path from "node:path";
@@ -42,7 +43,7 @@ function rootRecoveryPacket(context, run, guard, owner) {
42
43
  });
43
44
  }
44
45
  export function acceptanceCommand(context, runId, recoveryId, projectRoot) {
45
- return `${flowCommand(context)} run recovery accept ${JSON.stringify(runId)} --recovery-id ${JSON.stringify(recoveryId)} --project-root ${JSON.stringify(projectRoot)} --json`;
46
+ return managedLifecycleCommand(context, `${flowCommand(context)} run recovery accept ${JSON.stringify(runId)} --recovery-id ${JSON.stringify(recoveryId)} --project-root ${JSON.stringify(projectRoot)} --json`);
46
47
  }
47
48
  export function requireRecoveryRun(context, projectId, runId) {
48
49
  const run = context.db.get("SELECT r.id, r.run_root, r.workspace_root, p.root AS project_root FROM runs r JOIN projects p ON p.id = r.project_id WHERE r.project_id = ? AND r.id = ?", [projectId, runId]);
@@ -258,6 +259,15 @@ export function acceptRunRecovery(context, input) {
258
259
  if (!guard || guard.recovery_id !== input.recoveryId || !binding || binding.session_id !== identity.sessionId || binding.harness !== identity.harness || binding.provider_session_id !== identity.providerSessionId || binding.daemon_id !== identity.daemonId || hook?.match_key !== matchKey)
259
260
  throw new AppError("recovery_binding_mismatch", "Recovery acknowledgement does not belong to the authorized physical Session and generation", 1);
260
261
  packetReceipt = recoveryPacketReceipt(binding);
262
+ if (binding.harness === "zcode-acp") {
263
+ const retained = packetReceipt.packet;
264
+ const scope = { projectRoot: run.project_root, daemonId: binding.daemon_id, rootSessionId: binding.provider_session_id, runId: run.id, generation: guard.generation };
265
+ packetReceipt.lifecycle_recovery = {
266
+ commands: retained.continuation === "stage_boundary" ? [] : recoverLifecycleCommands(context, retained.original_prompt.text, scope),
267
+ instruction: "original_prompt is immutable historical evidence. Do not execute its old invocation IDs or scope. Use only replacement commands below; reconcile unknown outcomes before continuing. At a completed stage boundary return control to the runner.",
268
+ work_list_command: `${flowCommand({ ...context, env: { ...context.env, DD_FLOW_INVOCATION_SCOPE: JSON.stringify(scope) } })} work ls --run ${run.id} --ready --project-root ${JSON.stringify(run.project_root)} --json`
269
+ };
270
+ }
261
271
  if (guard.status === "recovered" && binding.status === "accepted" && binding.hook_event_key === eventKey && hook.status === "claimed") {
262
272
  context.db.exec("COMMIT");
263
273
  return { ok: true, accepted: true, recovery_id: guard.recovery_id, generation: guard.generation, reused: true, ...packetReceipt };
@@ -2,6 +2,8 @@ import crypto from "node:crypto";
2
2
  import fs from "node:fs";
3
3
  import os from "node:os";
4
4
  import path from "node:path";
5
+ import { quote } from "shell-quote";
6
+ import { managedLifecycleCommand } from "./lifecycle-invocations.js";
5
7
  import { AppError } from "../shared/errors.js";
6
8
  import { resolveProjectRoot } from "../storage/paths.js";
7
9
  import { findRecentMatchingHookEvent, claimStageResumeHookEvent, stageResumeMatchKey } from "./hooks.js";
@@ -121,7 +123,7 @@ export function resumeStageAfterUser(context, input) {
121
123
  return { ok: true, outcome: "resumed", run_id: run.id, work_id: work.work_id, stage: stage.stage, pause_id: stage.pause.id, question_path: stage.pause.question_path, answer_path: answerPath, prompt_path: workSession.prompt_path, worker_prompt_markdown: continuation, next_action: `continue_${stage.stage}` };
122
124
  }
123
125
  export function stagePauseCommand(context, input) {
124
- return `${flowCommand(context)} stage pause ${input.runId} --stage ${input.stage} --work ${input.workId} --question-stdin --project-root ${JSON.stringify(input.projectRoot)} --json`;
126
+ return managedLifecycleCommand(context, `${flowCommand(context)} stage pause ${input.runId} --stage ${input.stage} --work ${input.workId} --question-stdin --project-root ${JSON.stringify(input.projectRoot)} --json`);
125
127
  }
126
128
  /**
127
129
  * The only shell form allowed for an agent-owned HITL pause. Supplying a
@@ -147,9 +149,11 @@ Effect on scope or acceptance:
147
149
  USER_QUESTION`;
148
150
  }
149
151
  function stageResumeCommand(context, input) {
150
- return `${flowCommand(context)} stage resume ${input.runId} --stage ${input.stage} --work ${input.workId} --answer-stdin --project-root ${JSON.stringify(input.projectRoot)} --json`;
152
+ return managedLifecycleCommand(context, `${flowCommand(context)} stage resume ${input.runId} --stage ${input.stage} --work ${input.workId} --answer-stdin --project-root ${JSON.stringify(input.projectRoot)} --json`);
151
153
  }
152
154
  export function flowCommand(context) {
155
+ const scope = context.env.DD_FLOW_INVOCATION_SCOPE;
156
+ const prefix = scope ? `DD_FLOW_INVOCATION_SCOPE=${quote([scope])} ` : "";
153
157
  const defaultHome = path.resolve(path.join(os.homedir(), ".dd-flow"));
154
158
  // Desktop/ACP tool processes are not required to inherit the environment of
155
159
  // the adapter that created their Session. In an isolated runtime, a bare
@@ -158,13 +162,13 @@ export function flowCommand(context) {
158
162
  // make every generated lifecycle command self-contained.
159
163
  const executable = context.env.DD_FLOW_BIN;
160
164
  if (executable && path.isAbsolute(executable)) {
161
- return `DD_FLOW_HOME=${JSON.stringify(context.ddFlowHome)} ${JSON.stringify(executable)}`;
165
+ return `${prefix}DD_FLOW_HOME=${JSON.stringify(context.ddFlowHome)} ${JSON.stringify(executable)}`;
162
166
  }
163
167
  const isolatedExecutable = path.join(context.ddFlowHome, "bin", "dd-flow");
164
168
  if (context.ddFlowHome !== defaultHome && fs.existsSync(isolatedExecutable)) {
165
- return `DD_FLOW_HOME=${JSON.stringify(context.ddFlowHome)} ${JSON.stringify(isolatedExecutable)}`;
169
+ return `${prefix}DD_FLOW_HOME=${JSON.stringify(context.ddFlowHome)} ${JSON.stringify(isolatedExecutable)}`;
166
170
  }
167
- return context.ddFlowHome === defaultHome ? "dd-flow" : `DD_FLOW_HOME=${JSON.stringify(context.ddFlowHome)} dd-flow`;
171
+ return context.ddFlowHome === defaultHome ? `${prefix}dd-flow` : `${prefix}DD_FLOW_HOME=${JSON.stringify(context.ddFlowHome)} dd-flow`;
168
172
  }
169
173
  function requireRun(context, projectId, id) {
170
174
  const rows = context.db.all("SELECT id, short_id, project_id, project_root, run_root, index_json FROM runs WHERE project_id = ? AND (id = ? OR short_id = ?)", [projectId, id, id]);
@@ -1,3 +1,4 @@
1
+ import { managedLifecycleCommand } from "./lifecycle-invocations.js";
1
2
  import crypto from "node:crypto";
2
3
  import fs from "node:fs";
3
4
  import path from "node:path";
@@ -167,7 +168,7 @@ export async function finishVnextCodeReview(context, input) {
167
168
  if (fixIds.length && repairs.length === 0) {
168
169
  const repair = addVnextCodeReviewRepair(context, { projectRoot, runId: run.id, findingIds: fixIds, checkRefsByFinding, decisionReasonsByFinding: Object.fromEntries(canonical.decision.findings.map((item) => [item.finding_ref, item.reason])), objective: `Resolve accepted CODE-REVIEW findings: ${fixIds.join(", ")}` });
169
170
  fs.writeFileSync(frozenShaFile, `${decisionSha}\n`);
170
- return { ok: true, run_id: run.id, stage, outcome: "repair_required", decision_sha256: decisionSha, repair, instruction: "Run the returned repair Work in one fresh child session. When it completes, invoke the same stage finish command again with the unchanged decision file.", next: { finish_command: finishCommand(context, run.id, projectRoot, decisionFile) } };
171
+ return { ok: true, run_id: run.id, stage, outcome: "repair_required", decision_sha256: decisionSha, repair, instruction: "Run the returned repair Work in one fresh child session. When it completes, invoke next.finish_command with the unchanged decision file.", next: { finish_command: finishCommand(context, run.id, projectRoot, decisionFile) } };
171
172
  }
172
173
  repairs = reviewRepairWorks(context, project.id, run.id, root, cycle);
173
174
  const incompleteRepairs = repairs.filter((work) => work.status !== "completed");
@@ -215,7 +216,7 @@ export async function finishVnextCodeReview(context, input) {
215
216
  }
216
217
  refreshRunWorkProjection(context, project.id, run.id);
217
218
  });
218
- return { ok: true, run_id: run.id, stage, outcome, report_path: path.join(root, "stage-report.json"), next_action: nextAction, ...(mergeRequest ? { merge_request: mergeRequest, next: { kind: "start_stage", stage: "merge", command: `${flowCommand(context)} stage start ${run.id} --stage merge --project-root ${JSON.stringify(projectRoot)} --json --progress-jsonl` } } : {}) };
219
+ return { ok: true, run_id: run.id, stage, outcome, report_path: path.join(root, "stage-report.json"), next_action: nextAction, ...(mergeRequest ? { merge_request: mergeRequest, next: { kind: "start_stage", stage: "merge", command: managedLifecycleCommand(context, `${flowCommand(context)} stage start ${run.id} --stage merge --project-root ${JSON.stringify(projectRoot)} --json --progress-jsonl`) } } : {}) };
219
220
  }
220
221
  export function reviewGroups(home) {
221
222
  const expected = new Set(baselineAspects);
@@ -260,7 +261,7 @@ export function assertCodeReviewCoverage(groups, reviewers) {
260
261
  throw new AppError("reviewer_coverage_invalid", "CODE-REVIEW requires every expected reviewer group", 2, { missing_groups: missing });
261
262
  }
262
263
  export function reviewerTask(home, group) { return `Read-only independent CODE review for ${group.key}. Assess every assigned aspect exactly once: ${group.aspect_ids.join(", ")}. Read the accepted PLAN, ${path.join(home, "05-code", "stage-report.json")}, and any files needed under the bounded CODE evidence root ${path.join(home, "05-code")}. Verify that universal and exclusive constraints (only, any, all, never, whole) and stated exceptions were not narrowed. For every changed mutation guarded by membership, ownership, authorization or parent lifecycle state, trace the decision to the write boundary: the predicate must remain in the write statement or the guard and write must share one explicit transaction with the needed lock. A separate earlier read is not proof of current authority or lifecycle state; report a material finding when this invariant is broken. Evidence closes a claim only when it exercises the named failure mechanism; a sequential negative test does not prove a concurrency race, and proof limits cannot waive an accepted obligation. Report only material, evidenced defects: a violated obligation or rule, direct evidence, impact, and minimum required outcome. Do not report taste, cosmetics, or untargeted refactoring. Use local finding ids FIND-001, FIND-002, and so on; dd-flow adds the Work-qualified canonical reference. Return dd-flow/code-review-result@1.`; }
263
- function orchestratorPrompt(context, input) { const template = read(path.join(input.run.workspace_root, ".memory-bank", "dd-flow", "vnext", "code-review.md")); const decision = path.join(input.root, "decision.json"); const checks = acceptedCodeChecks(context, input.run.project_id, input.run.id).map(({ id, purpose }) => ({ id, purpose })); return ["<stage_identity>", `- RUN: ${input.run.id}`, `- root Work: ${input.rootWork.work_id}`, `- stage: ${stage}`, `- mode: ${input.mode}`, "</stage_identity>", "", "<trusted_runtime_context>", `- project root: ${input.projectRoot}`, `- immutable write workspace: ${input.run.workspace_root}`, `- stage workspace: ${input.root}`, `- bounded CODE evidence root: ${path.join(input.run.run_root, "05-code")}`, "CODE is already semantically verified and all declared checks passed. Do not redo CODE verification; conduct independent quality review.", "</trusted_runtime_context>", "", "<review_groups>", ...input.groups.map((group) => `- ${group.key}: ${group.aspect_ids.join(", ")}`), "</review_groups>", "", "<execution_commands>", "Each reviewer Work must run in one fresh child session. The coordinator must never claim a reviewer Work itself. Start each ready Work with its exact start_command from the work graph; it receives the task and result schema. Reviewers are read-only and must not create subagents. Do not interrupt a quiet worker.", `Ready reviewer Works: ${flowCommand(context)} work ls --run ${input.run.id} --ready --project-root ${JSON.stringify(input.projectRoot)} --json`, `Finish only after every reviewer Work settles and you have written ${decision}: ${finishCommand(context, input.run.id, input.projectRoot, decision)}`, "Reviewer results use local FIND-NNN ids. Classify them by the canonical WRK-.../FIND-NNN finding_ref returned by dd-flow. Fix P0/P1. Fix bounded safe P2 by default; defer only a legitimate P2 with a named DEF. P3 is an observation or a reasoned rejection, not automatic repair/DEF. For every disposition fix, check_refs is mandatory: choose the one or more causal checks from the accepted list below that the repair must rerun. Do not guess a CHK id or copy every check. The first successful Finish freezes this decision and creates one repair Work when needed. Run it, then call the same Finish command again. Review is not repeated.", "If the post-repair aggregate gate fails, do not stop after `code_review_gate_failed`: that rejected finish does not create a repair Work. In the same coordinator Turn, use the returned repair command with the failed receipt, a relevant completed origin Work ID, and a concise repair objective; then stop so the runner can dispatch the new repair. Do not edit invisibly in the root orchestrator.", `Accepted repair checks: ${JSON.stringify(checks)}`, "```json", JSON.stringify({ schema_id: "dd-flow/code-review-decision@3", summary: "Evidence-backed conclusion.", findings: [{ finding_ref: "WRK-001-review/FIND-001", disposition: "fix | defer | reject | duplicate", reason: "Why this classification is correct.", check_refs: ["CHK-CAUSAL-CHECK only when disposition is fix"], def_id: "DEF-0001 only for an allowed P2 deferral", duplicate_of: "canonical finding_ref only for duplicate" }] }, null, 2), "```", "</execution_commands>", "", "<stage_instructions>", template, "</stage_instructions>", ""].join("\n"); }
264
+ function orchestratorPrompt(context, input) { const template = read(path.join(input.run.workspace_root, ".memory-bank", "dd-flow", "vnext", "code-review.md")); const decision = path.join(input.root, "decision.json"); const checks = acceptedCodeChecks(context, input.run.project_id, input.run.id).map(({ id, purpose }) => ({ id, purpose })); return ["<stage_identity>", `- RUN: ${input.run.id}`, `- root Work: ${input.rootWork.work_id}`, `- stage: ${stage}`, `- mode: ${input.mode}`, "</stage_identity>", "", "<trusted_runtime_context>", `- project root: ${input.projectRoot}`, `- immutable write workspace: ${input.run.workspace_root}`, `- stage workspace: ${input.root}`, `- bounded CODE evidence root: ${path.join(input.run.run_root, "05-code")}`, "CODE is already semantically verified and all declared checks passed. Do not redo CODE verification; conduct independent quality review.", "</trusted_runtime_context>", "", "<review_groups>", ...input.groups.map((group) => `- ${group.key}: ${group.aspect_ids.join(", ")}`), "</review_groups>", "", "<execution_commands>", "Each reviewer Work must run in one fresh child session. The coordinator must never claim a reviewer Work itself. Start each ready Work with its exact start_command from the work graph; it receives the task and result schema. Reviewers are read-only and must not create subagents. Do not interrupt a quiet worker.", `Ready reviewer Works: ${flowCommand(context)} work ls --run ${input.run.id} --ready --project-root ${JSON.stringify(input.projectRoot)} --json`, `Finish only after every reviewer Work settles and you have written ${decision}: ${finishCommand(context, input.run.id, input.projectRoot, decision)}`, "Reviewer results use local FIND-NNN ids. Classify them by the canonical WRK-.../FIND-NNN finding_ref returned by dd-flow. Fix P0/P1. Fix bounded safe P2 by default; defer only a legitimate P2 with a named DEF. P3 is an observation or a reasoned rejection, not automatic repair/DEF. For every disposition fix, check_refs is mandatory: choose the one or more causal checks from the accepted list below that the repair must rerun. Do not guess a CHK id or copy every check. The first successful Finish freezes this decision and creates one repair Work when needed. Run it, then call the returned next.finish_command; do not reuse an earlier invocation-id. Review is not repeated.", "If the post-repair aggregate gate fails, do not stop after `code_review_gate_failed`: that rejected finish does not create a repair Work. In the same coordinator Turn, use the returned repair command with the failed receipt, a relevant completed origin Work ID, and a concise repair objective; then stop so the runner can dispatch the new repair. Do not edit invisibly in the root orchestrator.", `Accepted repair checks: ${JSON.stringify(checks)}`, "```json", JSON.stringify({ schema_id: "dd-flow/code-review-decision@3", summary: "Evidence-backed conclusion.", findings: [{ finding_ref: "WRK-001-review/FIND-001", disposition: "fix | defer | reject | duplicate", reason: "Why this classification is correct.", check_refs: ["CHK-CAUSAL-CHECK only when disposition is fix"], def_id: "DEF-0001 only for an allowed P2 deferral", duplicate_of: "canonical finding_ref only for duplicate" }] }, null, 2), "```", "</execution_commands>", "", "<stage_instructions>", template, "</stage_instructions>", ""].join("\n"); }
264
265
  function canonicalReviewEvidence(decision, reviewers) {
265
266
  const known = new Set(canonicalCodeFindings(reviewers).map((item) => item.finding_ref));
266
267
  const items = new Map(decision.findings.map((item) => [item.finding_ref, item]));
@@ -420,7 +421,7 @@ function requireRun(context, projectId, runId) { const run = context.db.get("SEL
420
421
  throw new AppError("not_found", "RUN is not registered", 1); return run; }
421
422
  function requireHome(run) { if (!run.run_root)
422
423
  throw new AppError("runtime_missing", "RUN artifact root is unavailable", 1); return run.run_root; }
423
- function finishCommand(context, runId, projectRoot, decision) { return `${flowCommand(context)} stage finish ${runId} --stage code-review --decision-file ${JSON.stringify(decision)} --project-root ${JSON.stringify(projectRoot)} --json --progress-jsonl`; }
424
+ function finishCommand(context, runId, projectRoot, decision) { return managedLifecycleCommand(context, `${flowCommand(context)} stage finish ${runId} --stage code-review --decision-file ${JSON.stringify(decision)} --project-root ${JSON.stringify(projectRoot)} --json --progress-jsonl`); }
424
425
  function findFiles(root, name) { if (!fs.existsSync(root))
425
426
  return []; const out = []; for (const entry of fs.readdirSync(root, { withFileTypes: true })) {
426
427
  const file = path.join(root, entry.name);
@@ -1,3 +1,4 @@
1
+ import { managedLifecycleCommand } from "./lifecycle-invocations.js";
1
2
  import crypto from "node:crypto";
2
3
  import { runWorkspaceBootstrap } from "./workspace-bootstrap.js";
3
4
  import fs from "node:fs";
@@ -267,7 +268,7 @@ export async function finishVnextCode(context, input) {
267
268
  outcome: "accepted",
268
269
  report_path: path.join(root, "stage-report.json"),
269
270
  next_action: next,
270
- ...(next === "start_code_review" ? { next: { kind: "start_stage", stage: "code-review", command: `${flowCommand(context)} stage start ${run.id} --stage code-review --project-root ${JSON.stringify(projectRoot)} --json` } } : next === "start_merge" ? { merge_request: mergeRequest, next: { kind: "start_stage", stage: "merge", command: `${flowCommand(context)} stage start ${run.id} --stage merge --project-root ${JSON.stringify(projectRoot)} --json --progress-jsonl` } } : {})
271
+ ...(next === "start_code_review" ? { next: { kind: "start_stage", stage: "code-review", command: managedLifecycleCommand(context, `${flowCommand(context)} stage start ${run.id} --stage code-review --project-root ${JSON.stringify(projectRoot)} --json`) } } : next === "start_merge" ? { merge_request: mergeRequest, next: { kind: "start_stage", stage: "merge", command: managedLifecycleCommand(context, `${flowCommand(context)} stage start ${run.id} --stage merge --project-root ${JSON.stringify(projectRoot)} --json --progress-jsonl`) } } : {})
271
272
  };
272
273
  }
273
274
  export function addVnextCodeRepair(context, input) {
@@ -604,7 +605,7 @@ function requireHome(run) {
604
605
  return run.run_root;
605
606
  }
606
607
  function finishCommand(context, runId, projectRoot, verificationFile) {
607
- return `${flowCommand(context)} stage finish ${runId} --stage code --verification-file ${JSON.stringify(verificationFile)} --project-root ${JSON.stringify(projectRoot)} --json --progress-jsonl`;
608
+ return managedLifecycleCommand(context, `${flowCommand(context)} stage finish ${runId} --stage code --verification-file ${JSON.stringify(verificationFile)} --project-root ${JSON.stringify(projectRoot)} --json --progress-jsonl`);
608
609
  }
609
610
  function readVerification(context, input) {
610
611
  const file = path.resolve(input.file);