@deksden-com/dd-flow-cli 0.9.0-beta.64 → 0.9.0-beta.74

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 (52) hide show
  1. package/dist/build-info.json +3 -3
  2. package/dist/cli/help.js +2 -0
  3. package/dist/cli/run-cli.js +15 -0
  4. package/dist/harness-runtime/bin/dd-agy.mjs +4 -2
  5. package/dist/harness-runtime/bin/dd-codex.mjs +4 -2
  6. package/dist/harness-runtime/bin/dd-droid.mjs +4 -2
  7. package/dist/harness-runtime/bin/dd-grok.mjs +4 -2
  8. package/dist/harness-runtime/bin/dd-opencode.mjs +4 -2
  9. package/dist/harness-runtime/bin/dd-zcode.mjs +15 -7
  10. package/dist/harness-runtime/lib/daemon-operations.mjs +108 -10
  11. package/dist/harness-runtime/lib/dd-agy-daemon.mjs +5 -4
  12. package/dist/harness-runtime/lib/dd-codex.mjs +38 -4
  13. package/dist/harness-runtime/lib/dd-droid.mjs +55 -20
  14. package/dist/harness-runtime/lib/dd-grok-daemon.mjs +3 -2
  15. package/dist/harness-runtime/lib/dd-grok.mjs +14 -8
  16. package/dist/harness-runtime/lib/dd-opencode-daemon.mjs +43 -8
  17. package/dist/harness-runtime/lib/dd-opencode.mjs +5 -4
  18. package/dist/harness-runtime/lib/dd-zcode-daemon.mjs +182 -16
  19. package/dist/harness-runtime/lib/dd-zcode.mjs +162 -63
  20. package/dist/harness-runtime/lib/driver-recovery.mjs +2 -2
  21. package/dist/harness-runtime/lib/managed-daemon.mjs +27 -5
  22. package/dist/harness-runtime/lib/operation-errors.mjs +1 -1
  23. package/dist/harness-runtime/lib/tool-observations.d.mts +17 -0
  24. package/dist/harness-runtime/lib/tool-observations.mjs +153 -0
  25. package/dist/services/code-checks.js +120 -19
  26. package/dist/services/controller-fanout.js +11 -0
  27. package/dist/services/engines.js +40 -9
  28. package/dist/services/eval-snapshots.js +30 -47
  29. package/dist/services/harness-adapter.js +12 -6
  30. package/dist/services/lifecycle-invocations.js +12 -8
  31. package/dist/services/native-session-control.js +6 -0
  32. package/dist/services/projects.js +6 -2
  33. package/dist/services/recovery-observation-budget.js +9 -1
  34. package/dist/services/run-control-worker.js +19 -0
  35. package/dist/services/run-control.js +87 -13
  36. package/dist/services/run-controller-adapter.js +134 -7
  37. package/dist/services/run-controller.js +11 -3
  38. package/dist/services/run-engine-bindings.js +1 -1
  39. package/dist/services/run-fork.js +124 -0
  40. package/dist/services/run-observations.js +112 -0
  41. package/dist/services/runs.js +1 -11
  42. package/dist/services/schema-validation.js +1 -1
  43. package/dist/services/stage-lifecycle.js +14 -2
  44. package/dist/services/usage.js +20 -87
  45. package/dist/services/vnext-code-review.js +35 -27
  46. package/dist/services/vnext-code.js +62 -26
  47. package/dist/services/vnext-merge.js +8 -8
  48. package/dist/services/vnext-plan-review.js +16 -16
  49. package/dist/services/vnext-plan.js +51 -11
  50. package/dist/services/work-registry.js +48 -27
  51. package/dist/shared/errors.js +12 -0
  52. package/package.json +1 -1
@@ -0,0 +1,153 @@
1
+ import fs from "node:fs";
2
+
3
+ /** Canonical physical-session tool accounting. Native decoding lives below;
4
+ * consumers never have to inspect provider envelopes or aggregate tokens. */
5
+ export class ToolObservations {
6
+ constructor() { this.calls = new Map(); this.gaps = new Set(); }
7
+ add(event) {
8
+ if (!event) return;
9
+ if (event.reason) { this.gaps.add(event.reason); return; }
10
+ const { harness_id, session_id } = event.session ?? {};
11
+ if (!harness_id || !session_id || !event.call_id) { this.gaps.add("tool_identity_missing"); return; }
12
+ const key = JSON.stringify([harness_id, session_id, event.epoch ?? "", event.call_id]);
13
+ const old = this.calls.get(key);
14
+ // Replay and late starts cannot undo a terminal outcome. Conflicting
15
+ // terminal facts remain visible rather than silently choosing success.
16
+ const terminal = value => ["completed", "failed"].includes(value);
17
+ if (old && terminal(old.status) && terminal(event.status) && old.status !== event.status) this.gaps.add("conflicting_tool_outcomes");
18
+ this.calls.set(key, { ...old, ...event, name: event.name ?? old?.name ?? "unknown",
19
+ status: old?.status === "failed" || event.status === "failed" ? "failed" : terminal(old?.status) ? old.status : event.status ?? old?.status ?? "unknown" });
20
+ }
21
+ summary(sessionId, cursor) {
22
+ const by_tool = {}, sessions = new Map(), reasons = new Set(this.gaps); let total = 0, failures = 0, pending = 0;
23
+ for (const [key, value] of this.calls) {
24
+ if (sessionId !== undefined && value.session.session_id !== sessionId || cursor?.has(key)) continue;
25
+ const id = JSON.stringify(value.session);
26
+ if (value.name === "unknown") reasons.add("tool_start_missing");
27
+ const session = sessions.get(id) ?? { session: value.session, parent_session: value.parent_session ?? null, total: 0, failures: 0, pending: 0, by_tool: {} };
28
+ total++; session.total++; by_tool[value.name] = (by_tool[value.name] ?? 0) + 1; session.by_tool[value.name] = (session.by_tool[value.name] ?? 0) + 1;
29
+ if (value.status === "failed") { failures++; session.failures++; }
30
+ if (!["completed", "failed"].includes(value.status)) { pending++; session.pending++; }
31
+ sessions.set(id, session);
32
+ }
33
+ return { schema_id: "dd-flow/tool-summary@1", scope: "physical_sessions", measurement: "observed_invocations", origin: "native_tool_events", aggregation: cursor ? "delta" : "cumulative", total, failures, pending, by_tool,
34
+ sessions: [...sessions.values()], completeness: reasons.size ? "partial" : "complete", outcome_completeness: pending ? "partial" : "complete", reasons: [...reasons] };
35
+ }
36
+ cursor() { return new Set(this.calls.keys()); }
37
+ }
38
+
39
+ /** For adapters whose raw transcript is not portable, retain only normalized
40
+ * tool facts in the existing owned journal. Repeated snapshots are idempotent. */
41
+ export function journalToolObservations(file, events, previous) {
42
+ const batch = new ToolObservations();
43
+ for (const event of events) batch.add(event);
44
+ const records = [...batch.calls.values(), ...[...batch.gaps].map(reason => ({ reason }))];
45
+ for (const event of records) {
46
+ const key = JSON.stringify([event.session, event.epoch, event.call_id, event.reason]);
47
+ const encoded = JSON.stringify(event);
48
+ if (previous.get(key) === encoded) continue;
49
+ fs.appendFileSync(file, `${JSON.stringify({ kind: "tool_observation", schema_id: "dd-flow/tool-observation@1", observed_at: new Date().toISOString(), event })}\n`, { mode: 0o600 });
50
+ previous.set(key, encoded);
51
+ }
52
+ }
53
+
54
+ const identity = (harness_id, session_id) => ({ harness_id, session_id });
55
+ const status = value => ["error", "failed", "ERROR"].includes(value) ? "failed" : ["completed", "success", "DONE"].includes(value) ? "completed" : "running";
56
+ export function zcodePhysicalSession(update, rootSessionId) {
57
+ const native = update?._meta?.zcodeRuntime ?? {};
58
+ if (native.source === "subagent" && !native.childSessionId) return null;
59
+ return native.childSessionId ?? rootSessionId;
60
+ }
61
+
62
+ export function acpToolObservation(message, { harness = "zcode-acp", rootSessionId, adapterSessionId } = {}) {
63
+ if (!["session/update", "_x.ai/session_notification"].includes(message?.method)) return null;
64
+ const update = message.params?.update;
65
+ if (!["tool_call", "tool_call_update"].includes(update?.sessionUpdate)) return null;
66
+ const transport = message.params?.sessionId;
67
+ if (adapterSessionId && transport !== adapterSessionId) return null;
68
+ const root = rootSessionId ?? transport;
69
+ const session = harness === "zcode-acp" ? zcodePhysicalSession(update, root) : root;
70
+ if (!session || !update.toolCallId) return { reason: "tool_identity_missing" };
71
+ return { session: identity(harness, session), parent_session: session !== root ? identity(harness, root) : null,
72
+ transport_session_id: transport, call_id: update.toolCallId, name: update._meta?.["x.ai/tool"]?.name ?? update._meta?.claudeCode?.toolName ?? update.title?.split(":", 1)[0], status: status(update.status) };
73
+ }
74
+
75
+ export function droidToolObservations(record, sessionId) {
76
+ return (Array.isArray(record.message?.content) ? record.message.content : []).flatMap(block => {
77
+ if (block.type === "tool_use") return [{ session: identity("droid-cli", sessionId), call_id: block.id, name: block.name, status: "running" }];
78
+ if (block.type === "tool_result") return [{ session: identity("droid-cli", sessionId), call_id: block.tool_use_id, status: block.is_error ? "failed" : "completed" }];
79
+ return [];
80
+ });
81
+ }
82
+
83
+ export function codexToolObservation(record, sessionId) {
84
+ if (record.type !== "response_item") return null;
85
+ const p = record.payload ?? {};
86
+ if (["function_call", "custom_tool_call"].includes(p.type)) return { session: identity("codex-desktop", sessionId), call_id: p.call_id, name: p.name, status: "running" };
87
+ if (["function_call_output", "custom_tool_call_output"].includes(p.type)) {
88
+ return { session: identity("codex-desktop", sessionId), call_id: p.call_id, status: containsFailure(p) ? "failed" : "completed" };
89
+ }
90
+ return null;
91
+ }
92
+
93
+ function containsFailure(value) {
94
+ if (Array.isArray(value)) return value.some(containsFailure);
95
+ if (typeof value === "string") {
96
+ if (/(?:^|\n)exit=(?:[1-9]\d*)\b|Process exited with code [1-9]\d*/.test(value)) return true;
97
+ if (/^[{[]/.test(value.trim())) { try { return containsFailure(JSON.parse(value)); } catch {} }
98
+ return false;
99
+ }
100
+ if (!value || typeof value !== "object") return false;
101
+ if (value.ok === false || value.is_error === true || value.isError === true || value.error === true || typeof value.error === "string") return true;
102
+ if (typeof value.exit_code === "number" && value.exit_code !== 0) return true;
103
+ return Object.values(value).some(containsFailure);
104
+ }
105
+
106
+ export function openCodeToolObservation(part, sessionId, messageId) {
107
+ if (part.type !== "tool") return null;
108
+ return { session: identity("opencode-server", part.sessionID ?? sessionId), call_id: part.callID ?? part.id,
109
+ epoch: messageId, name: part.tool, status: status(part.state?.status) };
110
+ }
111
+
112
+ export function agyToolObservation(step, rootSessionId) {
113
+ if (step.step_type !== "tool") return null;
114
+ const sessionId = step.conversation_id ?? rootSessionId;
115
+ return { session: identity("antigravity-cli", sessionId), parent_session: sessionId !== rootSessionId ? identity("antigravity-cli", rootSessionId) : null,
116
+ call_id: step.step_id ?? step.stepId ?? step.id ?? step.tool_call_id ?? step.toolCallId ?? (Number.isInteger(step.step_index) ? String(step.step_index) : null),
117
+ name: step.tool_name ?? step.tool_info?.name, status: step.tool_info?.error ? "failed" : status(step.state) };
118
+ }
119
+
120
+ /** Read-only replay, shared by accounting and offline diagnosis. A journal
121
+ * carries physical identities; an ACP connection identity never crosses out. */
122
+ export function replayToolJournal(file, options, reducer = new ToolObservations()) {
123
+ let bytes;
124
+ try { bytes = fs.readFileSync(file, "utf8"); } catch (error) { reducer.gaps.add(error.code === "ENOENT" ? "journal_missing" : "journal_unreadable"); return reducer; }
125
+ const lines = bytes.split("\n"); let session = options.rootSessionId, transport = options.adapterSessionId;
126
+ for (const [index, line] of lines.entries()) {
127
+ if (!line.trim()) continue;
128
+ let e; try { e = JSON.parse(line); } catch { reducer.gaps.add(index === lines.length - 1 ? "journal_partial_tail" : "journal_invalid_record"); continue; }
129
+ const add = event => { if (event) reducer.add({ ...event, source: { journal: file, line: index + 1 }, observed_at: e.observed_at ?? e.timestamp ?? e.at ?? e.ts ?? null }); };
130
+ if (e.kind === "tool_observation" && e.schema_id === "dd-flow/tool-observation@1") { add(e.event); continue; }
131
+ if (e.kind === "notification_failure") reducer.gaps.add("notification_failure");
132
+ const m = e.payload ?? e;
133
+ if (options.harness === "zcode-acp" || options.harness === "grok-acp") {
134
+ if (m.method === "zcode/session/allocated" && m.params?.provider_session_id === session) transport = m.params.adapter_session_id;
135
+ if (e.kind === "inbound" || m.method) add(acpToolObservation(m, { ...options, rootSessionId: session, adapterSessionId: transport }));
136
+ } else if (options.harness === "codex-desktop") {
137
+ if (e.type === "session_meta" && e.payload?.id !== session) { reducer.gaps.add("session_identity_mismatch"); break; }
138
+ add(codexToolObservation(e, session));
139
+ } else if (options.harness === "droid-cli") {
140
+ if (e.type === "session_start" && e.id !== session) { reducer.gaps.add("session_identity_mismatch"); break; }
141
+ for (const event of droidToolObservations(e, session)) add(event);
142
+ } else if (options.harness === "antigravity-cli") {
143
+ const native = m.event ?? e;
144
+ if (native.event === "step_update") add(agyToolObservation(native.step_update ?? {}, session));
145
+ } else if (options.harness === "opencode-server") {
146
+ // Native SSE message.part.updated, retained in the adapter journal.
147
+ const event = m.event ?? m;
148
+ const part = event.properties?.part;
149
+ if (part) add(openCodeToolObservation(part, session, part.messageID));
150
+ } else reducer.gaps.add("tool_observation_unsupported");
151
+ }
152
+ return reducer;
153
+ }
@@ -12,7 +12,14 @@ const profileRelativePath = path.join(".memory-bank", "spec", "engineering", "co
12
12
  // auditable attempt. A second caller must not mistake that small handoff window
13
13
  // for a dead check and overwrite its outcome.
14
14
  const receiptStartingGraceMs = 30_000;
15
- 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 }; }
15
+ export function codeExecutionEnvironment(workspaceRoot) {
16
+ const suffix = crypto.createHash("sha256").update(path.resolve(workspaceRoot)).digest("hex").slice(0, 12);
17
+ // Managed checks retain the launcher's executable resolution. A login shell
18
+ // or NODE_PATH can silently replace it, including with another architecture.
19
+ const environment = { ...process.env, DD_FLOW_LOCAL_DATABASE_SUFFIX: suffix };
20
+ delete environment.NODE_PATH;
21
+ return environment;
22
+ }
16
23
  export function workspaceOperationId(workspaceRoot) { return `workspace:${crypto.createHash("sha256").update(path.resolve(workspaceRoot)).digest("hex")}`; }
17
24
  export function readCodeCheckProfile(workspaceRoot) {
18
25
  const file = path.join(workspaceRoot, profileRelativePath);
@@ -144,9 +151,13 @@ async function runCodeChecksOwned(context, input, workspaceGateId) {
144
151
  for (const receiptId of retryIds) {
145
152
  const predecessor = validateFailedCheckRetry(context, { projectId: input.projectId, runId: input.runId, receiptId, declarations: checks, reason: input.retryReason });
146
153
  const snapshot = workspaceState(input.workspaceRoot, [input.runHome]);
147
- const group = groups.find(candidate => checkInputHash(input.workspaceRoot, candidate, profileHash, workspaceInputFingerprint(input.workspaceRoot, snapshot.files, candidate.inputs, [input.runHome])) === predecessor.input_hash);
154
+ const group = groups.find(candidate => candidate.declarations.some(check => check.run_at === predecessor.gate)
155
+ && checkInputHash(input.workspaceRoot, { ...candidate, gate: predecessor.gate }, profileHash, workspaceInputFingerprint(input.workspaceRoot, snapshot.files, candidate.inputs, [input.runHome])) === predecessor.input_hash);
148
156
  if (!group || retries.has(group))
149
157
  throw new AppError("retry_check_invalid", "Retry must match exactly one current execution contract", 2, { check_receipt_id: receiptId });
158
+ // Preserve the identity of a pre-deduplication failed execution when an
159
+ // upgraded RUN retries it; the new receipt still proves every grouped ref.
160
+ group.gate = predecessor.gate;
150
161
  retries.set(group, receiptId);
151
162
  }
152
163
  for (let index = 0; index < groups.length; index += 1) {
@@ -321,7 +332,11 @@ function reserveOrReuseReceipt(context, input) {
321
332
  throw new AppError("retry_check_invalid", "The requested retry already has a terminal outcome; choose its failed receipt for another retry", 2, { check_receipt_id: successor.id, retry_of_receipt_id: retryId, status: successor.status });
322
333
  }
323
334
  }
324
- if (prior && !retryIds.has(prior.id) && (prior.status !== "passed" || (input.group.reusable && checkReceiptEvidenceValid(prior)))) {
335
+ // Only success is reusable evidence. A new finish attempt must execute a
336
+ // failed check again: repairing dependencies/services need not change any
337
+ // tracked input. Lifecycle invocation replay owns command idempotency;
338
+ // the running-receipt guard above still prevents concurrent execution.
339
+ if (prior?.status === "passed" && !retryIds.has(prior.id) && input.group.reusable && checkReceiptEvidenceValid(prior)) {
325
340
  context.db.exec("COMMIT");
326
341
  return { receipt: prior };
327
342
  }
@@ -392,19 +407,105 @@ function resolvedReceiptArtifacts(receiptPath, artifacts) {
392
407
  return { completion_path: path.join(currentRoot, "completion.json"), required_artifacts: original.required_artifacts ?? [], inputs: original.inputs ?? [], artifacts: artifacts.map((artifact) => ({ ...artifact, path: relocate(artifact.path) })) };
393
408
  }
394
409
  export function workspaceFingerprint(root) { return workspaceState(root).fingerprint; }
395
- 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)
396
- return null; return status.stdout.split("\0").filter(Boolean).map((entry) => entry.slice(3)).filter(Boolean).sort(); }
397
- function deduplicate(checks, commands) { const groups = new Map(); checks.forEach((check, index) => { const artifacts = [...(check.required_artifacts ?? [])].sort(); const ports = [...(check.ports ?? [])].sort(); const inputs = normalizedInputs(check.inputs ?? []); const reusable = check.reuse === "deterministic"; const key = JSON.stringify([check.run_at, commands[index], artifacts, ports, inputs, reusable]); const current = groups.get(key); if (current)
398
- current.declarations.push(check);
399
- else
400
- groups.set(key, { gate: check.run_at, command: commands[index], declarations: [check], requiredArtifacts: artifacts, ports, inputs, reusable }); }); return [...groups.values()].map(group => ({ ...group, declarations: [...group.declarations].sort((left, right) => checkRef(left).localeCompare(checkRef(right))) })); }
401
- 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) {
402
- const file = path.join(root, relative);
403
- const content = fs.existsSync(file) && fs.statSync(file).isFile() ? fs.readFileSync(file) : Buffer.from("<missing>");
404
- const digest = crypto.createHash("sha256").update(content).digest("hex");
405
- files.set(relative, digest);
406
- hash.update(relative).update("\0").update(digest).update("\0");
407
- } return { fingerprint: hash.digest("hex"), files }; }
410
+ /** One NUL-aware source of truth for paths differing from HEAD. Rename/copy
411
+ * records carry a second path; retain both because the old side is a deletion. */
412
+ export function workspaceChangedPaths(root) {
413
+ const status = spawnSync("git", ["status", "--porcelain=v1", "-z", "--untracked-files=all"], { cwd: root, encoding: "utf8", maxBuffer: 64 * 1024 * 1024 });
414
+ // A workspace can be intentionally gitless. Keep the same conservative
415
+ // file inventory used for fingerprints instead of making result validation
416
+ // fail before the work contract can report its actual error.
417
+ if (status.status === 128 && /not a git repository|outside a repository/i.test(status.stderr ?? ""))
418
+ return listFiles(root);
419
+ if (status.status !== 0 || status.error)
420
+ throw new AppError("workspace_status_failed", "Could not determine changed workspace paths", 1, { workspace_root: root, stderr: status.stderr || null, cause: status.error?.message ?? null });
421
+ const records = status.stdout.split("\0");
422
+ const paths = [];
423
+ for (let index = 0; index < records.length; index += 1) {
424
+ const record = records[index];
425
+ if (!record)
426
+ continue;
427
+ if (record.length < 4 || record[2] !== " ")
428
+ throw new AppError("workspace_status_invalid", "Git returned an invalid porcelain-v1 record", 1, { workspace_root: root, record });
429
+ paths.push(normalizeWorkspacePath(root, record.slice(3)));
430
+ if (record[0] === "R" || record[0] === "C" || record[1] === "R" || record[1] === "C") {
431
+ const prior = records[++index];
432
+ if (!prior)
433
+ throw new AppError("workspace_status_invalid", "Git rename/copy record is missing its second path", 1, { workspace_root: root, record });
434
+ paths.push(normalizeWorkspacePath(root, prior));
435
+ }
436
+ }
437
+ return [...new Set(paths)].sort();
438
+ }
439
+ /** Ignored machine state cannot be reported as a product change. Tracked paths
440
+ * remain eligible regardless of their directory name. */
441
+ export function ignoredWorkspacePaths(root, paths) {
442
+ const normalized = [...new Set(paths.map((item) => normalizeWorkspacePath(root, item)))];
443
+ if (!normalized.length)
444
+ return [];
445
+ const status = spawnSync("git", ["check-ignore", "-z", "--stdin"], { cwd: root, encoding: "utf8", input: `${normalized.join("\0")}\0`, maxBuffer: 64 * 1024 * 1024 });
446
+ // Projects may intentionally run without Git (the flow supports a local
447
+ // workspace identity independently from version control). In that case
448
+ // there is no ignore database to consult; a declared path is a product
449
+ // path, not an infrastructure path. Other Git failures remain fail-closed.
450
+ if (status.status === 128 && /not a git repository|outside a repository/i.test(status.stderr ?? ""))
451
+ return [];
452
+ if (![0, 1].includes(status.status ?? -1) || status.error)
453
+ throw new AppError("workspace_ignore_check_failed", "Could not classify reported workspace paths", 1, { workspace_root: root, stderr: status.stderr || null, cause: status.error?.message ?? null });
454
+ return [...new Set(status.stdout.split("\0").filter(Boolean).map((item) => normalizeWorkspacePath(root, item)))].sort();
455
+ }
456
+ export function normalizeWorkspacePath(root, value) {
457
+ if (typeof value !== "string" || !value || value.includes("\0") || path.isAbsolute(value))
458
+ throw new AppError("workspace_path_invalid", "Workspace path must be a non-empty relative path", 2, { path: value });
459
+ const resolvedRoot = path.resolve(root);
460
+ const resolved = path.resolve(resolvedRoot, value);
461
+ const relative = path.relative(resolvedRoot, resolved);
462
+ if (!relative || relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative))
463
+ throw new AppError("workspace_path_invalid", "Workspace path escapes its root", 2, { path: value, workspace_root: root });
464
+ return relative.split(path.sep).join("/");
465
+ }
466
+ function deduplicate(checks, commands) {
467
+ const groups = new Map();
468
+ checks.forEach((check, index) => {
469
+ const artifacts = [...(check.required_artifacts ?? [])].sort(), ports = [...(check.ports ?? [])].sort(), inputs = normalizedInputs(check.inputs ?? []);
470
+ const reusable = check.reuse === "deterministic";
471
+ // Stage labels identify obligations, not different processes in this gate.
472
+ // Cross-invocation reuse still requires the explicit deterministic contract.
473
+ const key = JSON.stringify([commands[index], artifacts, ports, inputs, reusable]);
474
+ const current = groups.get(key);
475
+ if (current)
476
+ current.declarations.push(check);
477
+ else
478
+ groups.set(key, { gate: check.run_at, command: commands[index], declarations: [check], requiredArtifacts: artifacts, ports, inputs, reusable });
479
+ });
480
+ return [...groups.values()].map(group => ({ ...group, gate: [...group.declarations].map(check => check.run_at).sort()[0], declarations: [...group.declarations].sort((left, right) => checkRef(left).localeCompare(checkRef(right))) }));
481
+ }
482
+ function workspaceState(root, ignoredRoots = []) {
483
+ const git = spawnSync("git", ["ls-files", "-co", "--exclude-standard", "-z"], { cwd: root, encoding: "utf8", maxBuffer: 64 * 1024 * 1024 });
484
+ const ignored = ignoredRoots.map(entry => path.resolve(entry));
485
+ const listing = [...new Set(git.status === 0 ? git.stdout.split("\0").filter(Boolean) : listFiles(root))].sort();
486
+ const files = new Map(), hash = crypto.createHash("sha256");
487
+ for (const relative of listing) {
488
+ const file = path.resolve(root, relative);
489
+ if (ignored.some(ignoredRoot => file === ignoredRoot || file.startsWith(`${ignoredRoot}${path.sep}`)))
490
+ continue;
491
+ // Deleted tracked paths are absent content, both before and after commit.
492
+ // Do not mask other IO errors as an unchanged workspace.
493
+ let stat;
494
+ try {
495
+ stat = fs.lstatSync(file);
496
+ }
497
+ catch (error) {
498
+ if (error.code === "ENOENT")
499
+ continue;
500
+ throw error;
501
+ }
502
+ const content = stat.isSymbolicLink() ? Buffer.from(`symlink\0${fs.readlinkSync(file)}`) : stat.isFile() ? fs.readFileSync(file) : Buffer.from("<directory>");
503
+ const digest = crypto.createHash("sha256").update(content).digest("hex");
504
+ files.set(relative, digest);
505
+ hash.update(relative).update("\0").update(digest).update("\0");
506
+ }
507
+ return { fingerprint: hash.digest("hex"), files };
508
+ }
408
509
  function workspaceInputFingerprint(root, files, inputs, ignoredRoots) {
409
510
  const selected = new Map(inputs.length === 0 || inputs.includes(".") ? files : []);
410
511
  for (const [file, digest] of explicitInputFiles(root, inputs.filter(input => input !== "."), ignoredRoots))
@@ -522,8 +623,8 @@ function explicitInputFiles(root, inputs, ignoredRoots) {
522
623
  visit(path.resolve(root, input), input, new Set());
523
624
  return [...result.entries()];
524
625
  }
525
- function checkInputHash(workspaceRoot, group, profileHash, inputFingerprint) { return crypto.createHash("sha256").update(JSON.stringify({ workspace_root: path.resolve(workspaceRoot), gate: group.gate, command: group.command, required_artifacts: group.requiredArtifacts, ports: group.ports, inputs: group.inputs, profile_hash: profileHash, environment: environmentFingerprint(workspaceRoot), input_fingerprint: inputFingerprint })).digest("hex"); }
526
- function bindReceipt(receipt, group) { return { ...receipt, declaration_id: group.declarations[0].id, check_refs: group.declarations.map(checkRef), required_artifacts: group.requiredArtifacts, inputs: group.inputs }; }
626
+ function checkInputHash(workspaceRoot, group, profileHash, inputFingerprint) { return crypto.createHash("sha256").update(JSON.stringify({ workspace_root: path.resolve(workspaceRoot), gate: group.reusable ? null : group.gate, command: group.command, required_artifacts: group.requiredArtifacts, ports: group.ports, inputs: group.inputs, profile_hash: profileHash, environment: environmentFingerprint(workspaceRoot), input_fingerprint: inputFingerprint })).digest("hex"); }
627
+ function bindReceipt(receipt, group) { return { ...receipt, gate: group.gate, declaration_id: group.declarations[0].id, check_refs: group.declarations.map(checkRef), required_artifacts: group.requiredArtifacts, inputs: group.inputs }; }
527
628
  function latestApplicableReceipt(context, input) { const candidates = checkReceipts(context, { projectId: input.projectId, runId: input.runId }).filter((receipt) => receipt.input_hash === input.inputHash && receipt.profile_hash === input.profileHash); return candidates.at(-1) ?? null; }
528
629
  export function checkReceiptEvidenceValid(receipt) { if (receipt.status !== "passed" || receipt.exit_code !== 0 || receipt.before_fingerprint !== receipt.after_fingerprint || !fs.existsSync(receipt.completion_path) || readCompletion(receipt.completion_path)?.exit_code !== 0)
529
630
  return false; const stored = readReceipt(receipt.receipt_path); if (!stored || stored.status !== "passed" || stored.input_hash !== receipt.input_hash || stored.profile_hash !== receipt.profile_hash)
@@ -644,7 +745,7 @@ function checkShellScript() {
644
745
  // The actual command cannot run before PID ownership and admission persist.
645
746
  'IFS= read -r admission && [ "$admission" = admitted ] || exit 125',
646
747
  'completion="${DD_FLOW_CHECK_COMPLETION_FILE:-}"',
647
- '/bin/sh -lc "$DD_FLOW_CHECK_COMMAND"',
748
+ '/bin/sh -c "$DD_FLOW_CHECK_COMMAND"',
648
749
  "code=$?",
649
750
  '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',
650
751
  'exit "$code"'
@@ -3,6 +3,17 @@ import { getVnextFanoutStatus, observeVnextNativeChildren, reconcileVnextFanout,
3
3
  import { getFlowRunVariables } from "./runs.js";
4
4
  import { dispatchVnextPlanReview } from "./vnext-plan-review.js";
5
5
  import { resolveExecutionPolicy } from "./execution-policy.js";
6
+ /** Stage entry acknowledges the packet before the controller issues child commands. */
7
+ export function controllerStageEntryPrompt(stage, command, responseFile) {
8
+ return [
9
+ `Start the assigned ${stage} Stage now. Your first tool call must be this exact standalone command:`,
10
+ command,
11
+ `Read the complete authoritative packet saved to ${JSON.stringify(responseFile)}, including worker_prompt_markdown. If a read is truncated, continue until the entire packet has been read.`,
12
+ "If the returned packet has orchestration.kind = work_fanout, this Turn is only Stage entry: acknowledge the packet and end your Turn immediately. The packet describes the whole Stage, but its graph/dispatch/finish instructions belong to later controller continuations. Do not list, start, launch or finish Work, create children, change files, run checks, or finish the Stage in this entry Turn. The controller will inspect the graph and send the next assignment with exact issued commands (or dispatch external workers itself).",
13
+ "Otherwise perform this Stage's semantic work and stop at its boundary or a declared HITL boundary. Do not start a successor Stage.",
14
+ "Copy runtime-issued lifecycle commands verbatim as standalone calls. Never generate an invocation ID, substitute a shell variable for it, or borrow one from another command. If a required command is missing or rejected without a returned retry command, report the exact error and end the Turn so the controller can handle it."
15
+ ].join("\n\n");
16
+ }
6
17
  /** The shared controller owns this former eval-only Work-graph decision. */
7
18
  export async function nextControllerFanout(context, input) {
8
19
  const children = input.children.filter(child => child.status !== "settled_by_root");
@@ -117,7 +117,7 @@ export function installCurrentEngine(context, input = {}) {
117
117
  }
118
118
  export function listEngines(context) {
119
119
  const root = engineStoreRoot(context.ddFlowHome);
120
- const engines = readInstalledManifests(root).map(manifestSummary);
120
+ const engines = readInstalledManifests(root).map(manifest => manifestSummary(manifest));
121
121
  return { ok: true, schema_id: "dd-flow/engine-list@1", engine_store: root, engines };
122
122
  }
123
123
  export function engineInfo(context, input) {
@@ -138,11 +138,10 @@ export function resolveEngine(context, input = {}) {
138
138
  export function doctorEngines(context, input = {}) {
139
139
  const root = engineStoreRoot(context.ddFlowHome);
140
140
  const installed = readInstalledManifests(root);
141
- const checks = installed.map((manifest) => ({
142
- engine: manifestSummary(manifest),
143
- status: manifestHealthy(manifest) ? "ok" : "corrupt",
144
- diagnostics: manifestDiagnostics(manifest)
145
- }));
141
+ const checks = installed.map(manifest => {
142
+ const diagnostics = manifestDiagnostics(manifest);
143
+ return { engine: manifestSummary(manifest, diagnostics), status: diagnostics.length ? "corrupt" : "ok", diagnostics };
144
+ });
146
145
  const classification = input.classification ?? classifyCliOperation(["engine", "doctor"], input.env ?? context.env);
147
146
  const selection = selectEngine(context, input, {}, classification);
148
147
  return {
@@ -254,6 +253,38 @@ export function bindCurrentEngineToRun(context, input) {
254
253
  };
255
254
  return writeRunEngineBinding(path.join(input.runRoot, "engine-binding.json"), binding).binding;
256
255
  }
256
+ /** Forks are a new execution authority. The source binding stays immutable;
257
+ * this replaces only the copied binding after proving the selected engine can
258
+ * read the copied RUN. */
259
+ export function upgradeForkedRunEngine(context, input) {
260
+ const projectRoot = resolveProjectRoot(input.projectRoot);
261
+ const located = locateRunRoot(context.db, projectRoot, input.runId);
262
+ if (!located)
263
+ throw new AppError("not_found", `Forked RUN is not registered: ${input.runId}`, 1);
264
+ const manifest = requireBoundManifest(context.ddFlowHome, {
265
+ packageName: getCliBuildInfo().package_name,
266
+ packageVersion: input.engineVersion,
267
+ ...(input.integrityChecksum ? { integrityChecksum: input.integrityChecksum } : {})
268
+ });
269
+ const requested = engineIdentity(manifest);
270
+ readRunEngineBinding(located.binding_path);
271
+ const lineage = path.join(located.run_root, "fork-lineage.json");
272
+ if (!fs.existsSync(lineage))
273
+ throw new AppError("fork_lineage_missing", "Engine upgrade is permitted only for a derived RUN", 1, { run_id: located.run_id });
274
+ const probeArgs = ["run", "status", located.run_id, "--project-root", projectRoot, "--json"];
275
+ const probe = spawnSync(process.execPath, [resolveManifestEntrypoint(manifest), ...probeArgs], {
276
+ cwd: manifest.snapshot_root,
277
+ env: { ...process.env, ...context.env, DD_FLOW_HOME: context.ddFlowHome, DD_FLOW_ENGINE_MODE: "1", DD_FLOW_ENGINE_HOME: manifest.snapshot_root },
278
+ encoding: "utf8", timeout: 30_000
279
+ });
280
+ if (probe.status !== 0)
281
+ throw new AppError("fork_engine_probe_failed", "Selected engine cannot read the forked RUN", 1, { run_id: located.run_id, engine: manifestSummary(manifest), stderr: probe.stderr.slice(0, 2000) });
282
+ const binding = { schema_id: runEngineBindingSchemaId, run_id: located.run_id, project_root: projectRoot, bound_at: context.now(), source: "fork_upgrade", reason: requiredText(input.reason, "reason"), engine: requested, probe: { status: "passed", command: `dd-flow ${probeArgs.join(" ")}` } };
283
+ const temporary = `${located.binding_path}.fork-${process.pid}-${Date.now()}.tmp`;
284
+ fs.writeFileSync(temporary, `${JSON.stringify(binding, null, 2)}\n`);
285
+ fs.renameSync(temporary, located.binding_path);
286
+ return binding;
287
+ }
257
288
  function boundRunEngineForArgs(context, args, projectRoot) {
258
289
  const runId = runIdForArgs(args);
259
290
  if (!runId || !projectRoot)
@@ -847,7 +878,7 @@ function manifestDiagnostics(manifest, options = {}) {
847
878
  }
848
879
  return diagnostics;
849
880
  }
850
- function manifestSummary(manifest) {
881
+ function manifestSummary(manifest, diagnostics = manifestDiagnostics(manifest, { checkEntrypoint: false })) {
851
882
  return {
852
883
  schema_id: manifest.schema_id,
853
884
  package_name: manifest.package_name,
@@ -859,8 +890,8 @@ function manifestSummary(manifest) {
859
890
  snapshot_root: manifest.snapshot_root,
860
891
  resolved_entrypoint: safeResolveManifestEntrypoint(manifest),
861
892
  installed_at: manifest.installed_at,
862
- healthy: manifestHealthy(manifest),
863
- diagnostics: manifestDiagnostics(manifest)
893
+ healthy: diagnostics.length === 0,
894
+ diagnostics
864
895
  };
865
896
  }
866
897
  function resolveManifestEntrypoint(manifest) {
@@ -11,7 +11,6 @@ import { registerProject, requireProjectByRoot } from "./projects.js";
11
11
  import { freezeRunExecutionRouting, getFlowRunStatus, importFlowRunExecutionProfile, startFlowRun } from "./runs.js";
12
12
  import { loadExecutionRoutingInput, resolveExecutionPolicy } from "./execution-policy.js";
13
13
  import { readVnextFlowDefinition } from "./vnext-specify.js";
14
- import { assertRunWorkResultArtifacts, runWorkResultArtifactIssues } from "./work-registry.js";
15
14
  import { captureRecoveryDatabase, readRecoveryResourceEvidence } from "./recovery-snapshot-database.js";
16
15
  import { resourceHome } from "./managed-processes.js";
17
16
  import { codexHomeSharedEntries } from "./hooks.js";
@@ -104,8 +103,6 @@ function captureEvalRunSnapshot(context, input) {
104
103
  }
105
104
  if (!recovery)
106
105
  assertDedicatedHome(context, project.id, input.runId);
107
- if (!input.incomplete)
108
- assertRunWorkResultArtifacts(context, project.id, input.runId);
109
106
  if (recovery) {
110
107
  const guard = context.db.get("SELECT status, recovery_id FROM run_recovery_guards WHERE project_id = ? AND run_id = ? AND recovery_id = ?", [project.id, input.runId, input.recoveryId]);
111
108
  if (!guard || guard.status !== "sealed")
@@ -181,28 +178,17 @@ function captureEvalRunSnapshot(context, input) {
181
178
  });
182
179
  const projectGit = snapshotProjectGit(projectRoot, output);
183
180
  const workspace = snapshotWorkspace(workspaceRoot, projectRoot, output);
184
- // Compare the copied files with the copied SQLite receipt, not a later read
185
- // of the live database. Incomplete captures retain contradictions as evidence.
186
- const copiedContext = createContext({ ...context.env, DD_FLOW_HOME: path.join(output, "runtime") }, "read_existing");
187
- let receiptIssues;
188
- try {
189
- if (recovery)
181
+ // Accepted Work results are captured in SQLite. Their file representations
182
+ // are retained evidence, not a second authority or a checkpoint gate.
183
+ if (recovery) {
184
+ const copiedContext = createContext({ ...context.env, DD_FLOW_HOME: path.join(output, "runtime") }, "read_existing");
185
+ try {
190
186
  captureRecoveryAuxiliary(context, copiedContext, { projectId: project.id, runId: input.runId, output });
191
- receiptIssues = runWorkResultArtifactIssues(copiedContext, project.id, input.runId, file => {
192
- const roots = [[context.ddFlowHome, "runtime"], [workspaceRoot, workspace.kind === "git_worktree" ? "workspace" : "project"], [projectRoot, "project"]];
193
- for (const [source, payload] of roots) {
194
- const relative = path.relative(source, file);
195
- if (relative !== ".." && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative))
196
- return path.join(output, payload, relative);
197
- }
198
- throw new AppError("snapshot_receipt_path_invalid", "Work receipt is outside captured payloads", 1, { result_path: file });
199
- });
200
- }
201
- finally {
202
- copiedContext.db.close?.();
187
+ }
188
+ finally {
189
+ copiedContext.db.close?.();
190
+ }
203
191
  }
204
- if (!input.incomplete && receiptIssues.length)
205
- throw new AppError("snapshot_work_receipt_inconsistent", "Copied Work result does not match its authoritative receipt", 1, { issues: receiptIssues });
206
192
  const manifest = {
207
193
  schema_id: "dd-flow/eval-run-snapshot@5",
208
194
  run_id: input.runId,
@@ -213,7 +199,6 @@ function captureEvalRunSnapshot(context, input) {
213
199
  stage_entry: input.incomplete || recovery || input.candidate ? null : input.stageEntry,
214
200
  ...(input.incomplete ? { consistency: "sqlite_read_snapshot_files_copied_nonatomically", active_child_work_count: activeChildren, restorable: false } : recovery ? { recovery_id: input.recoveryId, consistency: "sealed_writer_barrier_required", active_child_work_count: activeChildren, restorable: "same-runtime-or-explicit-restore" } : {}),
215
201
  created_at: context.now(),
216
- work_result_integrity: { valid: receiptIssues.length === 0, issues: receiptIssues },
217
202
  runtime_sha256: treeHash(path.join(output, "runtime")),
218
203
  project_sha256: treeHash(path.join(output, "project")),
219
204
  project_git: projectGit,
@@ -477,30 +462,21 @@ function rebaseRuntime(context, oldRoot, newRoot, oldWorkspace, newWorkspace, ol
477
462
  finally {
478
463
  context.db.exec("PRAGMA foreign_keys = ON");
479
464
  }
480
- // SQLite owns operational paths. Imported packets, results and journals are
481
- // historical bytes; the relocation receipt records their path mapping.
482
- if (!recovery)
483
- rewriteStructuredFiles(context.ddFlowHome, oldRoot, newRoot, oldWorkspace, newWorkspace, oldHome, newHome);
465
+ // Only operational database paths move. Imported packets, results and
466
+ // journals retain their historical bytes, including interrupted JSONL tails.
467
+ // The frozen workspace route is an operational receipt consumed by later
468
+ // stages, so its relocated path is the sole structured-file exception.
469
+ rewriteOperationalRouteReceipts(context.ddFlowHome, oldRoot, newRoot, oldWorkspace, newWorkspace, oldHome, newHome);
484
470
  }
485
- function rewriteStructuredFiles(root, oldRoot, newRoot, oldWorkspace, newWorkspace, oldHome, newHome) {
486
- const visit = (dir) => {
487
- for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
488
- const file = path.join(dir, entry.name);
489
- // This is hashed input evidence, not a mutable runtime projection.
490
- if (/^execution-routing(?:\.[a-f0-9]{64})?\.input\.json$/.test(entry.name))
491
- continue;
492
- // DD_FLOW_HOME also contains the restored checkout and installed tools.
493
- // They are project/tool bytes, not flow records; parsing every *.json
494
- // there breaks on valid JSON-with-comments such as tsconfig files.
495
- if (entry.isDirectory() && ![".git", "checkouts", "engines", "node_modules", "tools", "snapshot-imports"].includes(entry.name))
471
+ function rewriteOperationalRouteReceipts(root, oldRoot, newRoot, oldWorkspace, newWorkspace, oldHome, newHome) {
472
+ const visit = (directory) => {
473
+ for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
474
+ const file = path.join(directory, entry.name);
475
+ if (entry.isDirectory())
496
476
  visit(file);
497
- else if (entry.isFile() && entry.name.endsWith(".json")) {
477
+ else if (entry.isFile() && entry.name === "workspace-route.json") {
498
478
  const value = JSON.parse(fs.readFileSync(file, "utf8"));
499
- writeJson(file, rebaseStructuredValue(value, oldRoot, newRoot, oldWorkspace, newWorkspace, oldHome, newHome));
500
- }
501
- else if (entry.isFile() && entry.name.endsWith(".jsonl")) {
502
- const lines = fs.readFileSync(file, "utf8").split("\n").filter(Boolean).map((line) => JSON.stringify(rebaseStructuredValue(JSON.parse(line), oldRoot, newRoot, oldWorkspace, newWorkspace, oldHome, newHome)));
503
- fs.writeFileSync(file, `${lines.join("\n")}\n`);
479
+ fs.writeFileSync(file, `${JSON.stringify(rebaseStructuredValue(value, oldRoot, newRoot, oldWorkspace, newWorkspace, oldHome, newHome), null, 2)}\n`);
504
480
  }
505
481
  }
506
482
  };
@@ -583,7 +559,9 @@ function restoreProjectGit(snapshot, source, projectRoot) {
583
559
  }
584
560
  const mergeStateFiles = ["MERGE_HEAD", "MERGE_MSG", "MERGE_MODE", "ORIG_HEAD", "AUTO_MERGE"];
585
561
  function indexEntries(root) {
586
- const entries = gitBytes(root, ["ls-files", "--stage", "-z", "--", ".", ":(exclude)**/.env", ":(exclude)**/.env.*"]);
562
+ const entries = Buffer.from(gitBytes(root, ["ls-files", "--stage", "-z"])
563
+ .toString("utf8").split("\0").filter(entry => entry && snapshotEnvironmentAllowed(entry.slice(entry.indexOf("\t") + 1)))
564
+ .map(entry => `${entry}\0`).join(""));
587
565
  const merge = {};
588
566
  for (const name of mergeStateFiles) {
589
567
  const file = git(root, ["rev-parse", "--git-path", name]);
@@ -710,10 +688,15 @@ function runGit(root, args) {
710
688
  throw new AppError("snapshot_workspace_restore_failed", "Could not restore feature worktree", 1, { stderr: result.stderr.trim(), args });
711
689
  }
712
690
  function samePath(left, right) { return path.resolve(left) === path.resolve(right); }
691
+ function snapshotEnvironmentAllowed(source) {
692
+ const name = path.basename(source);
693
+ // Public configuration template, not a local environment/credential file.
694
+ return name === ".env.example" || (name !== ".env" && !name.startsWith(".env."));
695
+ }
713
696
  function snapshotPayloadAllowed(source) {
714
697
  const name = path.basename(source);
715
698
  return ![".git", "node_modules", ".cache", ".npm", ".pnpm-store"].includes(name)
716
- && name !== ".env" && !name.startsWith(".env.");
699
+ && snapshotEnvironmentAllowed(source);
717
700
  }
718
701
  function runtimePayloadAllowed(source) {
719
702
  return snapshotPayloadAllowed(source) && !["auth.json", "auth.bk", "credentials.json", ".credentials.json", "credentials", ".credentials", "id_rsa", "id_ed25519", "engines", "checkouts"].includes(path.basename(source));