@deksden-com/dd-flow-cli 0.9.0-beta.1 → 0.9.0-beta.10

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.
Files changed (43) hide show
  1. package/CHANGELOG.md +70 -0
  2. package/dist/build-info.json +5 -5
  3. package/dist/cli/help.js +3 -3
  4. package/dist/cli/run-cli.js +127 -8
  5. package/dist/runtime/context.js +3 -1
  6. package/dist/schemas/code-review-result.schema.json +1 -1
  7. package/dist/schemas/code-work-batch.schema.json +4 -3
  8. package/dist/schemas/code-work-result.schema.json +1 -1
  9. package/dist/schemas/harness-config.schema.json +23 -0
  10. package/dist/schemas/plan-review-decision.schema.json +1 -1
  11. package/dist/schemas/vnext-protocol-plan.schema.json +1 -1
  12. package/dist/services/cleanup.js +18 -8
  13. package/dist/services/code-checks.js +194 -44
  14. package/dist/services/engines.js +4 -4
  15. package/dist/services/eval-snapshots.js +10 -5
  16. package/dist/services/harness-config.js +66 -0
  17. package/dist/services/hooks.js +25 -22
  18. package/dist/services/lanes.js +1 -0
  19. package/dist/services/managed-processes.js +169 -0
  20. package/dist/services/merge-server.js +8 -2
  21. package/dist/services/portable-refs.js +57 -0
  22. package/dist/services/prompts.js +4 -2
  23. package/dist/services/run-engine-bindings.js +19 -61
  24. package/dist/services/run-projection.js +10 -8
  25. package/dist/services/runs.js +71 -9
  26. package/dist/services/schema-validation.js +11 -11
  27. package/dist/services/session-identity.js +19 -0
  28. package/dist/services/sessions.js +26 -11
  29. package/dist/services/stage-lifecycle.js +15 -8
  30. package/dist/services/stage-pause.js +35 -20
  31. package/dist/services/usage.js +74 -42
  32. package/dist/services/vnext-code-review.js +82 -41
  33. package/dist/services/vnext-code.js +98 -34
  34. package/dist/services/vnext-fanout.js +5 -12
  35. package/dist/services/vnext-merge.js +144 -65
  36. package/dist/services/vnext-plan-review.js +50 -35
  37. package/dist/services/vnext-plan.js +69 -21
  38. package/dist/services/vnext-protocolize.js +6 -6
  39. package/dist/services/vnext-specify.js +6 -6
  40. package/dist/services/work-registry.js +150 -40
  41. package/dist/storage/database.js +128 -2
  42. package/package.json +1 -1
  43. package/tools/audit-runtime-fix-boundaries.mjs +96 -0
@@ -3,7 +3,12 @@ import { spawn, spawnSync } from "node:child_process";
3
3
  import fs from "node:fs";
4
4
  import path from "node:path";
5
5
  import { AppError } from "../shared/errors.js";
6
+ import { confirmManagedProcess, finishManagedProcess, heartbeatManagedProcess, managedProcessStatus, processIsAlive, registerManagedProcess, reservePorts } from "./managed-processes.js";
6
7
  const profileRelativePath = path.join(".memory-bank", "spec", "engineering", "code-check-profile.json");
8
+ // Receipt reservation precedes child spawn so an interrupted CLI leaves an
9
+ // auditable attempt. A second caller must not mistake that small handoff window
10
+ // for a dead check and overwrite its outcome.
11
+ const receiptStartingGraceMs = 30_000;
7
12
  export function codeExecutionEnvironment(workspaceRoot) { const suffix = crypto.createHash("sha256").update(path.resolve(workspaceRoot)).digest("hex").slice(0, 12); return { ...process.env, DD_FLOW_LOCAL_DATABASE_SUFFIX: suffix }; }
8
13
  export function readCodeCheckProfile(workspaceRoot) {
9
14
  const file = path.join(workspaceRoot, profileRelativePath);
@@ -11,14 +16,17 @@ export function readCodeCheckProfile(workspaceRoot) {
11
16
  return { profile: null, hash: null, file };
12
17
  const bytes = fs.readFileSync(file);
13
18
  const value = JSON.parse(bytes.toString("utf8"));
14
- if (value.schema_id !== "dd-flow/code-check-profile@5")
15
- throw new AppError("invalid_code_check_profile", "CODE check profile has an unsupported schema", 2, { file, schema_id: value.schema_id ?? null, required: "dd-flow/code-check-profile@5" });
16
- if (!isStringRecord(value.aliases) || !isStringRecord(value.require_alias_for) || !isGateRecord(value.mandatory_by_gate))
17
- throw new AppError("invalid_code_check_profile", "CODE check profile aliases, require_alias_for and mandatory_by_gate are invalid", 2, { file });
19
+ if (value.schema_id !== "dd-flow/code-check-profile@6")
20
+ throw new AppError("invalid_code_check_profile", "CODE check profile has an unsupported schema", 2, { file, schema_id: value.schema_id ?? null, required: "dd-flow/code-check-profile@6" });
21
+ if (!isStringRecord(value.aliases) || !isStringRecord(value.require_alias_for) || !isGateRecord(value.mandatory_by_gate) || !isPortAliasRecord(value.ports_by_alias))
22
+ throw new AppError("invalid_code_check_profile", "CODE check profile aliases, require_alias_for, mandatory_by_gate or ports_by_alias are invalid", 2, { file });
18
23
  for (const [gate, aliases] of Object.entries(value.mandatory_by_gate))
19
24
  for (const alias of aliases ?? [])
20
25
  if (!value.aliases[alias])
21
26
  throw new AppError("invalid_code_check_profile", "A mandatory gate references an unknown alias", 2, { file, gate, alias });
27
+ for (const alias of Object.keys(value.ports_by_alias ?? {}))
28
+ if (!value.aliases[alias])
29
+ throw new AppError("invalid_code_check_profile", "A port declaration references an unknown alias", 2, { file, alias });
22
30
  return { profile: value, hash: crypto.createHash("sha256").update(bytes).digest("hex"), file };
23
31
  }
24
32
  export function validateCodeCheckCommands(workspaceRoot, commands) {
@@ -33,14 +41,20 @@ export function validateCodeCheckCommands(workspaceRoot, commands) {
33
41
  if (command === prefix || command.startsWith(`${prefix} `))
34
42
  throw new AppError("raw_code_check_forbidden", "CODE check must use the project alias instead of a raw guarded command", 2, { command, required_alias: alias, file }); return command; });
35
43
  }
36
- export function resolveCodeCheckCommands(workspaceRoot, runId, commands) { const validated = validateCodeCheckCommands(workspaceRoot, commands); const { profile } = readCodeCheckProfile(workspaceRoot); return validated.map((command) => command.startsWith("@check/") ? profile.aliases[command].replaceAll("{run_id}", runId) : command); }
44
+ export function resolveCodeCheckCommands(workspaceRoot, runId, commands) {
45
+ const validated = validateCodeCheckCommands(workspaceRoot, commands);
46
+ const { profile } = readCodeCheckProfile(workspaceRoot);
47
+ // `{run_id}` is a declaration-level placeholder, not an alias feature.
48
+ // Plans may use it in a focused raw command as well as in project aliases.
49
+ return validated.map((command) => (command.startsWith("@check/") ? profile.aliases[command] : command).replaceAll("{run_id}", runId));
50
+ }
37
51
  export function projectPolicyCheckDeclarations(workspaceRoot, gate) {
38
52
  const { profile } = readCodeCheckProfile(workspaceRoot);
39
- return (profile?.mandatory_by_gate[gate] ?? []).map((alias) => { const ref = `POLICY/${gate}/${alias.slice(7).replace(/[^a-z0-9]+/gi, "-").replace(/^-|-$/g, "").toLowerCase()}`; return { id: ref, canonical_ref: ref, source: "project_policy", command: alias, purpose: `Mandatory project ${gate} gate.`, run_at: gate, availability: "available" }; });
53
+ return (profile?.mandatory_by_gate[gate] ?? []).map((alias) => { const ref = `POLICY/${gate}/${alias.slice(7).replace(/[^a-z0-9]+/gi, "-").replace(/^-|-$/g, "").toLowerCase()}`; return { id: ref, canonical_ref: ref, source: "project_policy", command: alias, purpose: `Mandatory project ${gate} gate.`, run_at: gate, availability: "available", ...(profile?.ports_by_alias?.[alias] ? { ports: profile.ports_by_alias[alias] } : {}) }; });
40
54
  }
41
55
  export function effectiveCheckDeclarations(workspaceRoot, declared, gates) { return gates.flatMap((gate) => [...declared.filter((check) => check.run_at === gate), ...projectPolicyCheckDeclarations(workspaceRoot, gate)]); }
42
56
  export function aggregateCheckDeclarations(workspaceRoot) { return projectPolicyCheckDeclarations(workspaceRoot, "code"); }
43
- export function finalCodeCheckDeclarations(workspaceRoot, declared) { return effectiveCheckDeclarations(workspaceRoot, declared, ["code", "readiness"]); }
57
+ export function finalCodeCheckDeclarations(workspaceRoot, declared) { return effectiveCheckDeclarations(workspaceRoot, declared, ["work", "code", "readiness"]); }
44
58
  export function checksForRunAt(checks, runAt) { return checks.filter((check) => check.run_at === runAt); }
45
59
  export function validateCheckPlacement(workspaceRoot, checks) { const { profile } = readCodeCheckProfile(workspaceRoot); const aggregateCommands = new Set(projectPolicyCheckDeclarations(workspaceRoot, "code").flatMap((check) => [check.command, profile?.aliases[check.command] ?? check.command])); for (const check of checks)
46
60
  if (check.run_at === "work" && aggregateCommands.has(check.command))
@@ -64,64 +78,157 @@ export function validateCheckDeclaration(workspaceRoot, check) {
64
78
  throw new AppError("planned_check_not_materialized", "A planned check alias was not materialized by its provider Work", 2, { check_id: check.id, command: check.command, provider: check.provided_by ?? null });
65
79
  }
66
80
  export async function runCodeChecks(context, input) {
81
+ reconcileUnfinishedChecks(context, input);
67
82
  for (const check of input.checks)
68
83
  validateCheckDeclaration(input.workspaceRoot, check);
69
- const { hash: profileHash } = readCodeCheckProfile(input.workspaceRoot);
70
- const resolved = resolveCodeCheckCommands(input.workspaceRoot, input.runId, input.checks.map((check) => check.command));
71
- const groups = deduplicate(input.checks, resolved);
84
+ const { hash: profileHash, profile } = readCodeCheckProfile(input.workspaceRoot);
85
+ const checks = input.checks.map((check) => { const ports = profile?.ports_by_alias?.[check.command]; return check.ports || !ports ? check : { ...check, ports }; });
86
+ const resolved = resolveCodeCheckCommands(input.workspaceRoot, input.runId, checks.map((check) => check.command));
87
+ const groups = deduplicate(checks, resolved);
72
88
  const receipts = [];
73
89
  for (let index = 0; index < groups.length; index += 1) {
74
90
  const group = groups[index];
75
- const ordinal = (context.db.get("SELECT COUNT(*) AS count FROM check_receipts WHERE work_id IS ? AND run_id = ?", [input.workId ?? null, input.runId])?.count ?? 0) + 1;
76
- const localId = `RCP-${String(ordinal).padStart(3, "0")}`;
77
- const id = `${input.workId ?? input.runId}/${localId}`;
78
- const directory = path.join(input.runHome, input.artifactDir ?? "05-code", "checks", localId);
79
- const evidenceDir = path.join(directory, "artifacts");
80
- fs.mkdirSync(evidenceDir, { recursive: true });
81
- const stdoutPath = path.join(directory, "stdout.log");
82
- const stderrPath = path.join(directory, "stderr.log");
83
- const receiptPath = path.join(directory, "receipt.json");
84
- const before = workspaceState(input.workspaceRoot);
85
- const inputHash = crypto.createHash("sha256").update(JSON.stringify({ gate: group.gate, command: group.command, environment: String(codeExecutionEnvironment(input.workspaceRoot).DD_FLOW_LOCAL_DATABASE_SUFFIX), required_artifacts: group.requiredArtifacts })).digest("hex");
86
- const epoch = crypto.createHash("sha256").update(`${group.gate}\0${before.fingerprint}`).digest("hex");
91
+ const before = workspaceState(input.workspaceRoot, [input.runHome]);
87
92
  const startedAt = context.now();
88
- input.progress?.(`check ${index + 1}/${groups.length} started: ${group.command}`);
89
- const stdout = fs.createWriteStream(stdoutPath);
90
- const stderr = fs.createWriteStream(stderrPath);
91
- const result = await runCheck(group.command, input.workspaceRoot, { ...codeExecutionEnvironment(input.workspaceRoot), DD_FLOW_EVIDENCE_DIR: evidenceDir }, stdout, stderr, (elapsed) => input.progress?.(`check ${index + 1}/${groups.length} still running (${elapsed}s): ${group.command}`));
92
- await Promise.all([closeStream(stdout), closeStream(stderr)]);
93
- const after = workspaceState(input.workspaceRoot);
93
+ const inputHash = crypto.createHash("sha256").update(JSON.stringify({ gate: group.gate, command: group.command, environment: String(codeExecutionEnvironment(input.workspaceRoot).DD_FLOW_LOCAL_DATABASE_SUFFIX), required_artifacts: group.requiredArtifacts, ports: [...group.ports].sort() })).digest("hex");
94
+ const epoch = crypto.createHash("sha256").update(`${group.gate}\0${before.fingerprint}`).digest("hex");
95
+ const reserved = reserveReceipt(context, { ...input, group, before, profileHash, inputHash, epoch, startedAt });
96
+ const { id, evidenceDir, stdoutPath, stderrPath, receiptPath, completionPath } = reserved;
97
+ let resources = {};
98
+ let result = { exitCode: null, error: "check did not start", aborted: true };
99
+ try {
100
+ fs.mkdirSync(evidenceDir, { recursive: true });
101
+ writeReceipt(receiptPath, receiptFor({ ...reserved, resources, result, status: "running", after: before, mutationPaths: [], artifacts: [] }));
102
+ const managed = registerManagedProcess(context, { kind: "check", ownerId: `check:${id}`, projectId: input.projectId, runId: input.runId, workId: input.workId ?? null, checkId: id, stdoutPath, stderrPath, metadata: { command: group.command } });
103
+ const allocation = await reservePorts(context, { ownerId: managed.owner_id, processId: managed.id, names: group.ports });
104
+ resources = allocation.ports;
105
+ input.progress?.(`check ${index + 1}/${groups.length} started: ${group.command}`);
106
+ const stdout = fs.openSync(stdoutPath, "a");
107
+ const stderr = fs.openSync(stderrPath, "a");
108
+ try {
109
+ result = await runCheck(group.command, input.workspaceRoot, { ...codeExecutionEnvironment(input.workspaceRoot), DD_FLOW_EVIDENCE_DIR: evidenceDir, DD_FLOW_CHECK_COMPLETION_FILE: completionPath, ...portEnvironment(resources) }, stdout, stderr, (elapsed) => input.progress?.(`check ${index + 1}/${groups.length} still running (${elapsed}s): ${group.command}`), (pid) => confirmManagedProcess(context, { id: managed.id, leaseToken: managed.lease_token, pid, processGroupId: process.platform === "win32" ? null : pid }), () => heartbeatManagedProcess(context, { id: managed.id, leaseToken: managed.lease_token }));
110
+ }
111
+ finally {
112
+ allocation.release();
113
+ fs.closeSync(stdout);
114
+ fs.closeSync(stderr);
115
+ finishManagedProcess(context, { id: managed.id, leaseToken: managed.lease_token, state: result.aborted || result.exitCode !== 0 || Boolean(result.error) ? "failed" : "stopped", reason: result.error });
116
+ }
117
+ }
118
+ catch (error) {
119
+ result = { exitCode: null, error: error instanceof Error ? error.message : String(error), aborted: true };
120
+ }
121
+ const after = workspaceState(input.workspaceRoot, [input.runHome]);
94
122
  const mutationPaths = changedPaths(before.files, after.files);
95
123
  const artifacts = collectRequiredArtifacts(evidenceDir, group.requiredArtifacts);
96
- if (!artifacts.complete)
97
- fs.appendFileSync(stderrPath, `\nmissing required evidence artifacts: ${artifacts.missing.join(", ")}\n`);
98
- if (result.error)
99
- fs.appendFileSync(stderrPath, `\n${result.error}\n`);
100
- if (mutationPaths.length)
101
- fs.appendFileSync(stderrPath, `\ncheck mutated project workspace: ${mutationPaths.join(", ")}\n`);
124
+ if (fs.existsSync(stderrPath)) {
125
+ if (!artifacts.complete)
126
+ fs.appendFileSync(stderrPath, `\nmissing required evidence artifacts: ${artifacts.missing.join(", ")}\n`);
127
+ if (result.error)
128
+ fs.appendFileSync(stderrPath, `\n${result.error}\n`);
129
+ if (mutationPaths.length)
130
+ fs.appendFileSync(stderrPath, `\ncheck mutated project workspace: ${mutationPaths.join(", ")}\n`);
131
+ }
102
132
  const status = result.aborted ? "aborted" : result.exitCode === 0 && !result.error && artifacts.complete && mutationPaths.length === 0 ? "passed" : "failed";
103
- const receipt = { id, local_id: localId, declaration_id: group.declarations[0].id, check_refs: group.declarations.map(checkRef), gate: group.gate, scope: input.scope, work_id: input.workId ?? null, command: group.command, input_hash: inputHash, verification_epoch: epoch, status, exit_code: result.exitCode, stdout_path: stdoutPath, stderr_path: stderrPath, receipt_path: receiptPath, workspace_fingerprint: before.fingerprint, before_fingerprint: before.fingerprint, after_fingerprint: after.fingerprint, profile_hash: profileHash, mutation_paths: mutationPaths, artifacts: artifacts.items, started_at: startedAt, finished_at: context.now() };
104
- fs.writeFileSync(receiptPath, `${JSON.stringify(receipt, null, 2)}\n`);
105
- context.db.run("INSERT INTO check_receipts (id, project_id, run_id, work_id, scope, declaration_id, check_refs_json, gate, command, input_hash, verification_epoch, status, exit_code, stdout_path, stderr_path, receipt_path, workspace_fingerprint, before_fingerprint, after_fingerprint, profile_hash, mutation_paths_json, artifacts_json, started_at, finished_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", [id, input.projectId, input.runId, input.workId ?? null, input.scope, receipt.declaration_id, JSON.stringify(receipt.check_refs), receipt.gate, receipt.command, receipt.input_hash, receipt.verification_epoch, receipt.status, receipt.exit_code, stdoutPath, stderrPath, receiptPath, receipt.workspace_fingerprint, receipt.before_fingerprint, receipt.after_fingerprint, receipt.profile_hash, JSON.stringify(receipt.mutation_paths), JSON.stringify(receipt.artifacts), receipt.started_at, receipt.finished_at]);
133
+ const receipt = receiptFor({ ...reserved, resources, result, status, after, mutationPaths, artifacts: artifacts.items });
134
+ context.db.run("UPDATE check_receipts SET status = ?, exit_code = ?, after_fingerprint = ?, mutation_paths_json = ?, artifacts_json = ?, finished_at = ? WHERE id = ? AND project_id = ? AND status = 'running'", [receipt.status, receipt.exit_code, receipt.after_fingerprint, JSON.stringify(receipt.mutation_paths), JSON.stringify(receipt.artifacts), receipt.finished_at, id, input.projectId]);
135
+ writeReceipt(receiptPath, receipt);
106
136
  receipts.push(receipt);
107
137
  input.progress?.(`check ${index + 1}/${groups.length} ${status}: ${group.command}`);
108
138
  }
109
139
  return receipts;
110
140
  }
141
+ /**
142
+ * A check receipt is durable even when its invoking CLI disappears. We never
143
+ * guess that an orphan passed: a live process blocks a second execution, and
144
+ * a dead/terminal process becomes an explicit aborted receipt that can retry.
145
+ */
146
+ export function reconcileUnfinishedChecks(context, input) {
147
+ const where = input.workId ? "project_id = ? AND run_id = ? AND work_id = ? AND status = 'running'" : "project_id = ? AND run_id = ? AND status = 'running'";
148
+ const params = input.workId ? [input.projectId, input.runId, input.workId] : [input.projectId, input.runId];
149
+ const pending = context.db.all(`SELECT id, receipt_path, started_at FROM check_receipts WHERE ${where}`, params);
150
+ if (pending.length === 0)
151
+ return [];
152
+ const processes = managedProcessStatus(context);
153
+ const reconciled = [];
154
+ for (const pendingReceipt of pending) {
155
+ const process = processes.filter((item) => item.check_id === pendingReceipt.id).at(-1);
156
+ const receipt = readReceipt(pendingReceipt.receipt_path);
157
+ const completion = receipt ? readCompletion(receipt.completion_path || path.join(path.dirname(pendingReceipt.receipt_path), "completion.json")) : null;
158
+ if (process && ["starting", "running", "stopping"].includes(process.state) && processLeaseIsCurrent(process) && (process.state === "starting" || processIsAlive(process))) {
159
+ throw new AppError("check_in_progress", "A previous check invocation is still running", 1, { check_receipt_id: pendingReceipt.id, process_id: process.id, pid: process.pid });
160
+ }
161
+ if (!process && !completion && Date.now() - Date.parse(pendingReceipt.started_at) < receiptStartingGraceMs) {
162
+ throw new AppError("check_in_progress", "A previous check invocation is still initializing", 1, { check_receipt_id: pendingReceipt.id, initializing: true });
163
+ }
164
+ const after = workspaceState(input.workspaceRoot, input.runHome ? [input.runHome] : []);
165
+ const artifacts = receipt ? collectRequiredArtifacts(path.join(path.dirname(receipt.completion_path || pendingReceipt.receipt_path), "artifacts"), receipt.required_artifacts ?? []) : { complete: false, missing: [], items: [] };
166
+ const workspaceUnchanged = receipt?.before_fingerprint === after.fingerprint;
167
+ const status = completion === null ? "aborted" : completion.exit_code === 0 && workspaceUnchanged && artifacts.complete ? "passed" : "failed";
168
+ const exitCode = completion?.exit_code ?? null;
169
+ const finishedAt = completion?.finished_at ?? context.now();
170
+ context.db.run("UPDATE check_receipts SET status = ?, exit_code = ?, after_fingerprint = ?, mutation_paths_json = ?, artifacts_json = ?, finished_at = ? WHERE id = ? AND project_id = ? AND status = 'running'", [status, exitCode, after.fingerprint, JSON.stringify(workspaceUnchanged ? [] : ["<workspace changed while detached check ran>"]), JSON.stringify(artifacts.items), finishedAt, pendingReceipt.id, input.projectId]);
171
+ if (process)
172
+ finishManagedProcess(context, { id: process.id, leaseToken: process.lease_token, state: status === "passed" ? "stopped" : "failed", reason: status === "passed" ? "reconciled_completion" : completion === null ? "completion_marker_missing" : "reconciled_check_failed" });
173
+ if (receipt) {
174
+ const repaired = { ...receipt, completion_path: receipt.completion_path || path.join(path.dirname(pendingReceipt.receipt_path), "completion.json"), required_artifacts: receipt.required_artifacts ?? [], status, exit_code: exitCode, after_fingerprint: after.fingerprint, mutation_paths: workspaceUnchanged ? [] : ["<workspace changed while detached check ran>"], artifacts: artifacts.items, finished_at: finishedAt };
175
+ writeReceipt(pendingReceipt.receipt_path, repaired);
176
+ reconciled.push(repaired);
177
+ }
178
+ }
179
+ return reconciled;
180
+ }
181
+ function reserveReceipt(context, input) {
182
+ context.db.exec("BEGIN IMMEDIATE");
183
+ try {
184
+ const rows = context.db.all("SELECT id FROM check_receipts WHERE work_id IS ? AND run_id = ?", [input.workId ?? null, input.runId]);
185
+ const ordinal = rows.reduce((max, row) => Math.max(max, Number(/\/RCP-(\d+)$/.exec(row.id)?.[1] ?? 0)), 0) + 1;
186
+ const localId = `RCP-${String(ordinal).padStart(3, "0")}`;
187
+ const id = `${input.workId ?? input.runId}/${localId}`;
188
+ const directory = path.join(input.runHome, input.artifactDir ?? "05-code", "checks", localId);
189
+ const reserved = { id, localId, directory, evidenceDir: path.join(directory, "artifacts"), stdoutPath: path.join(directory, "stdout.log"), stderrPath: path.join(directory, "stderr.log"), receiptPath: path.join(directory, "receipt.json"), completionPath: path.join(directory, "completion.json"), group: input.group, before: input.before, profileHash: input.profileHash, inputHash: input.inputHash, epoch: input.epoch, startedAt: input.startedAt, scope: input.scope, ...(input.workId ? { workId: input.workId } : {}), projectId: input.projectId, runId: input.runId };
190
+ context.db.run("INSERT INTO check_receipts (id, project_id, run_id, work_id, scope, declaration_id, check_refs_json, gate, command, input_hash, verification_epoch, status, exit_code, stdout_path, stderr_path, receipt_path, workspace_fingerprint, before_fingerprint, after_fingerprint, profile_hash, mutation_paths_json, artifacts_json, started_at, finished_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'running', NULL, ?, ?, ?, ?, ?, ?, ?, '[]', '[]', ?, ?)", [id, input.projectId, input.runId, input.workId ?? null, input.scope, input.group.declarations[0].id, JSON.stringify(input.group.declarations.map(checkRef)), input.group.gate, input.group.command, input.inputHash, input.epoch, reserved.stdoutPath, reserved.stderrPath, reserved.receiptPath, input.before.fingerprint, input.before.fingerprint, input.before.fingerprint, input.profileHash, input.startedAt, input.startedAt]);
191
+ context.db.exec("COMMIT");
192
+ return reserved;
193
+ }
194
+ catch (error) {
195
+ context.db.exec("ROLLBACK");
196
+ throw error;
197
+ }
198
+ }
199
+ function receiptFor(input) { return { id: input.id, local_id: input.localId, declaration_id: input.group.declarations[0].id, check_refs: input.group.declarations.map(checkRef), gate: input.group.gate, scope: input.scope, work_id: input.workId ?? null, command: input.group.command, input_hash: input.inputHash, verification_epoch: input.epoch, status: input.status, exit_code: input.status === "running" ? null : input.result.exitCode, stdout_path: input.stdoutPath, stderr_path: input.stderrPath, receipt_path: input.receiptPath, completion_path: input.completionPath, required_artifacts: input.group.requiredArtifacts, workspace_fingerprint: input.before.fingerprint, before_fingerprint: input.before.fingerprint, after_fingerprint: input.after.fingerprint, profile_hash: input.profileHash, mutation_paths: input.mutationPaths, artifacts: input.artifacts, resources: { ports: input.resources }, started_at: input.startedAt, finished_at: input.status === "running" ? input.startedAt : new Date().toISOString() }; }
200
+ function writeReceipt(file, receipt) { const temporary = `${file}.tmp-${process.pid}-${crypto.randomUUID()}`; fs.writeFileSync(temporary, `${JSON.stringify(receipt, null, 2)}\n`); fs.renameSync(temporary, file); }
201
+ function readReceipt(file) { try {
202
+ return JSON.parse(fs.readFileSync(file, "utf8"));
203
+ }
204
+ catch {
205
+ return null;
206
+ } }
207
+ function readCompletion(file) { try {
208
+ const value = JSON.parse(fs.readFileSync(file, "utf8"));
209
+ const done = value;
210
+ return value && typeof value === "object" && Number.isInteger(done.exit_code) ? { exit_code: Number(done.exit_code), finished_at: typeof done.finished_at === "string" && Number.isFinite(Date.parse(done.finished_at)) ? done.finished_at : null } : null;
211
+ }
212
+ catch {
213
+ return null;
214
+ } }
215
+ function processLeaseIsCurrent(process) { return Date.parse(process.lease_expires_at ?? "") >= Date.now(); }
111
216
  export function unchangedFinalGateFailures(context, input) { const wanted = new Set(input.declarations.flatMap((check) => [check.id, checkRef(check)])); const current = workspaceFingerprint(input.workspaceRoot); const latest = new Map(); for (const receipt of checkReceipts(context, { projectId: input.projectId, runId: input.runId }).filter((item) => item.scope === "aggregate" && item.check_refs.some((ref) => wanted.has(ref))))
112
217
  for (const ref of receipt.check_refs)
113
218
  latest.set(ref, receipt); return [...new Set(latest.values())].filter((receipt) => receipt.status === "failed" && receipt.before_fingerprint === current); }
114
219
  export function checkReceipts(context, input) {
115
220
  const where = input.workId ? "project_id = ? AND run_id = ? AND work_id = ?" : "project_id = ? AND run_id = ?";
116
221
  const params = input.workId ? [input.projectId, input.runId, input.workId] : [input.projectId, input.runId];
117
- return context.db.all(`SELECT id, declaration_id, check_refs_json, gate, scope, work_id, command, input_hash, verification_epoch, status, exit_code, stdout_path, stderr_path, receipt_path, workspace_fingerprint, before_fingerprint, after_fingerprint, profile_hash, mutation_paths_json, artifacts_json, started_at, finished_at FROM check_receipts WHERE ${where} ORDER BY started_at, id`, params).map((row) => ({ ...row, local_id: row.id.slice(row.id.lastIndexOf("/") + 1), check_refs: parseArray(row.check_refs_json, [row.declaration_id]), mutation_paths: parseArray(row.mutation_paths_json, []), artifacts: JSON.parse(row.artifacts_json) }));
222
+ return context.db.all(`SELECT id, declaration_id, check_refs_json, gate, scope, work_id, command, input_hash, verification_epoch, status, exit_code, stdout_path, stderr_path, receipt_path, workspace_fingerprint, before_fingerprint, after_fingerprint, profile_hash, mutation_paths_json, artifacts_json, started_at, finished_at FROM check_receipts WHERE ${where} ORDER BY started_at, id`, params).map((row) => ({ ...row, local_id: row.id.slice(row.id.lastIndexOf("/") + 1), check_refs: parseArray(row.check_refs_json, [row.declaration_id]), mutation_paths: parseArray(row.mutation_paths_json, []), artifacts: JSON.parse(row.artifacts_json), resources: readReceiptResources(row.receipt_path) }));
118
223
  }
119
224
  export function workspaceFingerprint(root) { return workspaceState(root).fingerprint; }
120
- function deduplicate(checks, commands) { const groups = new Map(); checks.forEach((check, index) => { const artifacts = [...(check.required_artifacts ?? [])].sort(); const key = JSON.stringify([check.run_at, commands[index], artifacts]); const current = groups.get(key); if (current)
225
+ export function workspaceChangedPaths(root) { const status = spawnSync("git", ["status", "--porcelain=v1", "-z", "--untracked-files=all"], { cwd: root, encoding: "utf8", maxBuffer: 64 * 1024 * 1024 }); if (status.status !== 0)
226
+ return null; return status.stdout.split("\0").filter(Boolean).map((entry) => entry.slice(3)).filter(Boolean).sort(); }
227
+ function deduplicate(checks, commands) { const groups = new Map(); checks.forEach((check, index) => { const artifacts = [...(check.required_artifacts ?? [])].sort(); const ports = [...(check.ports ?? [])].sort(); const key = JSON.stringify([check.run_at, commands[index], artifacts, ports]); const current = groups.get(key); if (current)
121
228
  current.declarations.push(check);
122
229
  else
123
- groups.set(key, { gate: check.run_at, command: commands[index], declarations: [check], requiredArtifacts: artifacts }); }); return [...groups.values()]; }
124
- function workspaceState(root) { const git = spawnSync("git", ["ls-files", "-co", "--exclude-standard", "-z"], { cwd: root, encoding: "utf8", maxBuffer: 64 * 1024 * 1024 }); const listing = git.status === 0 ? git.stdout.split("\0").filter(Boolean).sort() : listFiles(root); const files = new Map(); const hash = crypto.createHash("sha256"); for (const relative of listing) {
230
+ groups.set(key, { gate: check.run_at, command: commands[index], declarations: [check], requiredArtifacts: artifacts, ports }); }); return [...groups.values()]; }
231
+ function workspaceState(root, ignoredRoots = []) { const git = spawnSync("git", ["ls-files", "-co", "--exclude-standard", "-z"], { cwd: root, encoding: "utf8", maxBuffer: 64 * 1024 * 1024 }); const ignored = ignoredRoots.map((entry) => path.resolve(entry)); const listing = (git.status === 0 ? git.stdout.split("\0").filter(Boolean).sort() : listFiles(root)).filter((relative) => { const file = path.resolve(root, relative); return !ignored.some((ignoredRoot) => file === ignoredRoot || file.startsWith(`${ignoredRoot}${path.sep}`)); }); const files = new Map(); const hash = crypto.createHash("sha256"); for (const relative of listing) {
125
232
  const file = path.join(root, relative);
126
233
  const content = fs.existsSync(file) && fs.statSync(file).isFile() ? fs.readFileSync(file) : Buffer.from("<missing>");
127
234
  const digest = crypto.createHash("sha256").update(content).digest("hex");
@@ -139,6 +246,15 @@ catch {
139
246
  } }
140
247
  function isStringRecord(value) { return Boolean(value) && typeof value === "object" && !Array.isArray(value) && Object.values(value).every((item) => typeof item === "string" && item.trim().length > 0); }
141
248
  function isGateRecord(value) { return Boolean(value) && typeof value === "object" && !Array.isArray(value) && Object.entries(value).every(([gate, aliases]) => ["work", "code", "readiness", "merge", "release", "external"].includes(gate) && Array.isArray(aliases) && aliases.every((item) => typeof item === "string" && item.startsWith("@check/"))); }
249
+ function isPortAliasRecord(value) { return value === undefined || (Boolean(value) && typeof value === "object" && !Array.isArray(value) && Object.entries(value).every(([alias, ports]) => alias.startsWith("@check/") && Array.isArray(ports) && ports.length > 0 && new Set(ports).size === ports.length && ports.every((port) => typeof port === "string" && /^[A-Za-z][A-Za-z0-9_]*$/.test(port)))); }
250
+ function portEnvironment(ports) { return Object.fromEntries(Object.entries(ports).map(([name, value]) => [`DD_FLOW_PORT_${name.toUpperCase()}`, String(value)])); }
251
+ function readReceiptResources(file) { try {
252
+ const value = JSON.parse(fs.readFileSync(file, "utf8"));
253
+ return { ports: value.resources?.ports ?? {} };
254
+ }
255
+ catch {
256
+ return { ports: {} };
257
+ } }
142
258
  function collectRequiredArtifacts(root, required) { const missing = []; const items = []; for (const relative of required) {
143
259
  if (path.isAbsolute(relative) || relative.split(/[\\/]/).includes(".."))
144
260
  throw new AppError("invalid_evidence_path", "Required evidence paths must be relative to DD_FLOW_EVIDENCE_DIR", 2, { path: relative });
@@ -151,11 +267,45 @@ function collectRequiredArtifacts(root, required) { const missing = []; const it
151
267
  } return { complete: missing.length === 0, missing, items }; }
152
268
  function listFiles(root, current = root) { return fs.readdirSync(current, { withFileTypes: true }).flatMap((entry) => { if ([".git", "node_modules"].includes(entry.name))
153
269
  return []; const absolute = path.join(current, entry.name); return entry.isDirectory() ? listFiles(root, absolute) : [path.relative(root, absolute)]; }).sort(); }
154
- function runCheck(command, cwd, environment, stdout, stderr, heartbeat) { return new Promise((resolve) => { const started = Date.now(); const child = spawn("/bin/sh", ["-lc", command], { cwd, env: environment, stdio: ["ignore", "pipe", "pipe"], detached: process.platform !== "win32" }); child.stdout.pipe(stdout, { end: false }); child.stderr.pipe(stderr, { end: false }); const progress = setInterval(() => heartbeat(Math.floor((Date.now() - started) / 1000)), 15_000); let timedOut = false; let error = null; let escalation; const timeout = setTimeout(() => { timedOut = true; terminateProcessGroup(child.pid, "SIGTERM"); escalation = setTimeout(() => terminateProcessGroup(child.pid, "SIGKILL"), 5_000); }, 15 * 60 * 1000); child.once("error", (value) => { error = value.message; }); child.once("close", (exitCode, signal) => { clearInterval(progress); clearTimeout(timeout); if (escalation)
155
- clearTimeout(escalation); resolve({ exitCode, error: error ?? (timedOut ? "timed out after 900 seconds" : signal ? `terminated by ${signal}` : null), aborted: timedOut || Boolean(signal) || Boolean(error) }); }); }); }
270
+ function runCheck(command, cwd, environment, stdout, stderr, heartbeat, onSpawn, renewLease) { return new Promise((resolve) => { const started = Date.now(); const child = spawn("/bin/sh", ["-lc", checkShellScript()], { cwd, env: { ...environment, DD_FLOW_CHECK_COMMAND: command }, stdio: ["ignore", stdout, stderr], detached: process.platform !== "win32" }); if (!child.pid) {
271
+ resolve({ exitCode: null, error: "check process did not provide a PID", aborted: true });
272
+ return;
273
+ } try {
274
+ onSpawn(child.pid);
275
+ }
276
+ catch (error) {
277
+ terminateProcessGroup(child.pid, "SIGTERM");
278
+ resolve({ exitCode: null, error: error instanceof Error ? error.message : String(error), aborted: true });
279
+ return;
280
+ } const inactivityMs = 15 * 60 * 1000; let timeout; const lastOutputSize = new Map(); const armInactivityTimeout = () => { if (timeout)
281
+ clearTimeout(timeout); timeout = setTimeout(() => { timedOut = true; terminateProcessGroup(child.pid, "SIGTERM"); escalation = setTimeout(() => terminateProcessGroup(child.pid, "SIGKILL"), 5_000); }, inactivityMs); }; const progress = setInterval(() => { const elapsed = Math.floor((Date.now() - started) / 1000); if (!renewLease()) {
282
+ error = "managed process lease lost";
283
+ timedOut = true;
284
+ terminateProcessGroup(child.pid, "SIGTERM");
285
+ return;
286
+ } for (const file of [stdout, stderr]) {
287
+ try {
288
+ const size = fs.fstatSync(file).size, previous = lastOutputSize.get(file) ?? 0;
289
+ if (size > previous) {
290
+ lastOutputSize.set(file, size);
291
+ armInactivityTimeout();
292
+ }
293
+ }
294
+ catch { /* terminal stream */ }
295
+ } heartbeat(elapsed); }, 15_000); let timedOut = false; let error = null; let escalation; armInactivityTimeout(); child.once("error", (value) => { error = value.message; }); child.once("close", (exitCode, signal) => { clearInterval(progress); if (timeout)
296
+ clearTimeout(timeout); if (escalation)
297
+ clearTimeout(escalation); resolve({ exitCode, error: error ?? (timedOut ? "timed out after 900 seconds without process output" : signal ? `terminated by ${signal}` : null), aborted: timedOut || Boolean(signal) || Boolean(error) }); }); }); }
298
+ function checkShellScript() {
299
+ return [
300
+ 'completion="${DD_FLOW_CHECK_COMPLETION_FILE:-}"',
301
+ '/bin/sh -lc "$DD_FLOW_CHECK_COMMAND"',
302
+ "code=$?",
303
+ 'if [ -n "$completion" ]; then temporary="$completion.tmp.$$"; printf \'{"exit_code":%s,"finished_at":"%s"}\\n\' "$code" "$(date -u +%Y-%m-%dT%H:%M:%SZ)" > "$temporary" && mv "$temporary" "$completion"; fi',
304
+ 'exit "$code"'
305
+ ].join("\n");
306
+ }
156
307
  function terminateProcessGroup(pid, signal) { if (!pid)
157
308
  return; try {
158
309
  process.kill(process.platform === "win32" ? pid : -pid, signal);
159
310
  }
160
311
  catch { /* already exited */ } }
161
- function closeStream(stream) { return new Promise((resolve, reject) => stream.end((error) => error ? reject(error) : resolve())); }
@@ -12,7 +12,7 @@ import { requireProtocol } from "./protocols.js";
12
12
  import { resolveCanonRootBeforeContext } from "./canon.js";
13
13
  import { appendAudit } from "./audit.js";
14
14
  import { findProjectByRoot } from "./projects.js";
15
- import { allRunEngineBindings, findRunHome, readRunEngineBinding, runEngineBindingSchemaId, writeRunEngineBinding } from "./run-engine-bindings.js";
15
+ import { allRunEngineBindings, locateRunRoot, readRunEngineBinding, runEngineBindingSchemaId, writeRunEngineBinding } from "./run-engine-bindings.js";
16
16
  export const engineManifestSchemaId = "dd-flow/engine-manifest@1";
17
17
  const routerNativeFamilies = new Set(["engine", "version", "schema"]);
18
18
  const sourceFile = fileURLToPath(import.meta.url);
@@ -182,7 +182,7 @@ export function routeArgsThroughEngine(context, args, io, stdin, env, resolvedPr
182
182
  }
183
183
  export function bindRunEngine(context, input) {
184
184
  const projectRoot = resolveProjectRoot(input.projectRoot);
185
- const located = findRunHome(context.ddFlowHome, projectRoot, input.runId);
185
+ const located = locateRunRoot(context.db, projectRoot, input.runId);
186
186
  if (!located)
187
187
  throw new AppError("not_found", `RUN is not registered in router storage: ${input.runId}`, 1, { run_id: input.runId, project_root: projectRoot });
188
188
  const existing = readRunEngineBinding(located.binding_path, { allowMissing: true });
@@ -250,13 +250,13 @@ export function bindCurrentEngineToRun(context, input) {
250
250
  engine: engineIdentity(manifest),
251
251
  probe: { status: "not_required", command: null }
252
252
  };
253
- return writeRunEngineBinding(path.join(input.runHome, "engine-binding.json"), binding).binding;
253
+ return writeRunEngineBinding(path.join(input.runRoot, "engine-binding.json"), binding).binding;
254
254
  }
255
255
  function boundRunEngineForArgs(context, args, projectRoot) {
256
256
  const runId = runIdForArgs(args);
257
257
  if (!runId || !projectRoot)
258
258
  return null;
259
- const located = findRunHome(context.ddFlowHome, projectRoot, runId);
259
+ const located = locateRunRoot(context.db, projectRoot, runId);
260
260
  if (!located)
261
261
  return null;
262
262
  const binding = readRunEngineBinding(located.binding_path, { allowMissing: true });
@@ -80,7 +80,10 @@ export function createEvalRunSnapshot(context, input) {
80
80
  fs.cpSync(context.ddFlowHome, path.join(output, "runtime"), {
81
81
  recursive: true,
82
82
  verbatimSymlinks: true,
83
- filter: (source) => path.basename(source) !== "engines"
83
+ // Managed checkouts are restored from their Git bundle and workspace
84
+ // payload. Copying their .git indirection into runtime makes the snapshot
85
+ // dependent on the source machine's worktree metadata.
86
+ filter: (source) => !["engines", "checkouts"].includes(path.basename(source))
84
87
  });
85
88
  fs.cpSync(projectRoot, path.join(output, "project"), {
86
89
  recursive: true,
@@ -135,20 +138,22 @@ export function restoreEvalRunSnapshot(context, input) {
135
138
  clearProjectTree(projectRoot, false);
136
139
  restoreProjectGit(snapshot, manifest.project_git, projectRoot);
137
140
  replaceProjectTree(projectRoot, sourceProject);
138
- const workspaceRoot = restoreWorkspace(snapshot, manifest, projectRoot, context.ddFlowHome);
139
141
  context.db.close?.();
140
142
  fs.rmSync(context.ddFlowHome, { recursive: true, force: true });
141
143
  fs.cpSync(sourceRuntime, context.ddFlowHome, { recursive: true, verbatimSymlinks: true });
144
+ const workspaceRoot = restoreWorkspace(snapshot, manifest, projectRoot, context.ddFlowHome);
142
145
  // The dispatch context was intentionally closed before replacing db.sqlite.
143
146
  // A fresh process owns all subsequent normal commands.
144
147
  const restored = createContext({ ...context.env, DD_FLOW_HOME: context.ddFlowHome });
145
148
  try {
146
149
  rebaseRuntime(restored, manifest.project_root, projectRoot, sourceWorkspaceRoot(manifest), workspaceRoot, manifest.dd_flow_home, context.ddFlowHome);
147
150
  registerProject(restored, { root: projectRoot });
148
- const project = requireProjectByRoot(restored, projectRoot);
151
+ requireProjectByRoot(restored, projectRoot);
149
152
  const status = getFlowRunStatus(restored, { projectRoot, runId: manifest.run_id });
150
153
  assertStageEntry(status.index.stage_runs ?? [], manifest.stage_entry);
151
- const runHome = status.run.run_home_path ?? path.join(context.ddFlowHome, "projects", project.id, "runs", manifest.run_id);
154
+ if (!status.run.run_root)
155
+ throw new AppError("runtime_missing", "Restored RUN has no artifact root", 1, { run_id: manifest.run_id });
156
+ const runHome = status.run.run_root;
152
157
  return {
153
158
  ok: true,
154
159
  schema_id: "dd-flow/eval-run-restore@1",
@@ -179,7 +184,7 @@ export function prepareVnextSpecifyRun(context, input) {
179
184
  slug: input.slug,
180
185
  nextAction: "start_specify"
181
186
  });
182
- return { ok: true, run_id: started.run.id, run_home: started.run.run_home_path, target_stage: "specify" };
187
+ return { ok: true, run_id: started.run.id, run_root: started.run.run_root, target_stage: "specify" };
183
188
  }
184
189
  function assertDedicatedHome(context, projectId, runId) {
185
190
  const projects = context.db.get("SELECT COUNT(*) AS count FROM projects")?.count ?? 0;
@@ -0,0 +1,66 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { AppError } from "../shared/errors.js";
4
+ export function harnessConfigPath(ddFlowHome) {
5
+ return path.join(ddFlowHome, "harnesses.json");
6
+ }
7
+ export function harnessKey(value) {
8
+ const keys = {
9
+ codex: "codex-desktop", "codex-desktop": "codex-desktop",
10
+ zcode: "zcode-acp", "zcode-acp": "zcode-acp",
11
+ grok: "grok-acp", "grok-acp": "grok-acp",
12
+ agy: "antigravity-cli", "antigravity-cli": "antigravity-cli",
13
+ opencode: "opencode-server", "opencode-server": "opencode-server"
14
+ };
15
+ const resolved = keys[value];
16
+ if (!resolved)
17
+ throw new AppError("harness_config_invalid", `Unsupported harness: ${value}`, 2, { harness: value });
18
+ return resolved;
19
+ }
20
+ export function loadHarnessConfig(ddFlowHome) {
21
+ const file = harnessConfigPath(ddFlowHome);
22
+ let value;
23
+ try {
24
+ value = JSON.parse(fs.readFileSync(file, "utf8"));
25
+ }
26
+ catch (cause) {
27
+ throw new AppError("harness_config_missing", "Harness configuration is missing or invalid", 1, { path: file, cause: String(cause) });
28
+ }
29
+ if (!value || typeof value !== "object" || Array.isArray(value))
30
+ throw new AppError("harness_config_invalid", "Harness configuration must be an object", 2, { path: file });
31
+ const config = value;
32
+ if (config.schema_id !== "dd-flow/harness-config@1" || !config.harnesses || typeof config.harnesses !== "object" || Array.isArray(config.harnesses))
33
+ throw new AppError("harness_config_invalid", "Harness configuration has an unsupported schema", 2, { path: file });
34
+ for (const [name, entry] of Object.entries(config.harnesses)) {
35
+ harnessKey(name);
36
+ if (!entry || typeof entry !== "object" || Array.isArray(entry) || typeof entry.adapter_command !== "string" || typeof entry.runtime_command !== "string")
37
+ throw new AppError("harness_config_invalid", "Each harness requires adapter_command and runtime_command", 2, { path: file, harness: name });
38
+ }
39
+ return config;
40
+ }
41
+ export function resolveHarnessCommand(ddFlowHome, harness, command) {
42
+ const key = harnessKey(harness);
43
+ const entry = loadHarnessConfig(ddFlowHome).harnesses[key];
44
+ if (!entry)
45
+ throw new AppError("harness_config_missing", "Selected harness is not configured", 1, { path: harnessConfigPath(ddFlowHome), harness: key });
46
+ const value = command === "adapter" ? entry.adapter_command : entry.runtime_command;
47
+ if (!path.isAbsolute(value))
48
+ throw new AppError("harness_config_invalid", "Harness commands must be absolute paths", 2, { harness: key, command, value });
49
+ if (!fs.existsSync(value))
50
+ throw new AppError("harness_command_missing", "Configured harness command does not exist", 1, { harness: key, command, value });
51
+ return value;
52
+ }
53
+ /** The adapter interface is intentionally small; native binary flag names stay here,
54
+ * rather than leaking through MERGE orchestration or agent prompts. */
55
+ export function harnessRuntimeArguments(ddFlowHome, harness) {
56
+ const key = harnessKey(harness);
57
+ const runtime = resolveHarnessCommand(ddFlowHome, key, "runtime");
58
+ const option = {
59
+ "codex-desktop": "--codex-bin",
60
+ "zcode-acp": "--zcode-acp-bin",
61
+ "grok-acp": "--grok-bin",
62
+ "antigravity-cli": "--agy-bin",
63
+ "opencode-server": "--opencode-bin"
64
+ };
65
+ return [option[key], runtime];
66
+ }