@deksden-com/dd-flow-cli 0.9.0-beta.120 → 0.9.0-beta.122

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 (38) hide show
  1. package/CHANGELOG.md +9 -0
  2. package/dist/build-info.json +5 -5
  3. package/dist/cli/hook-ingress.js +4 -3
  4. package/dist/cli/native-hook-ingress.js +34 -2
  5. package/dist/harness-runtime/bin/dd-agy.mjs +6 -3
  6. package/dist/harness-runtime/lib/dd-agy-daemon.mjs +16 -0
  7. package/dist/harness-runtime/lib/dd-grok-daemon.mjs +12 -2
  8. package/dist/harness-runtime/lib/dd-zcode-daemon.mjs +60 -8
  9. package/dist/harness-runtime/lib/dd-zcode.mjs +28 -13
  10. package/dist/harness-runtime/lib/delegation-instructions.mjs +3 -3
  11. package/dist/harness-runtime/lib/lifecycle-command.d.mts +20 -0
  12. package/dist/harness-runtime/lib/lifecycle-command.mjs +218 -0
  13. package/dist/harness-runtime/lib/lifecycle-contract.d.mts +10 -0
  14. package/dist/harness-runtime/lib/lifecycle-contract.mjs +19 -0
  15. package/dist/harness-runtime/lib/native-hook-command.d.mts +2 -2
  16. package/dist/harness-runtime/lib/native-hook-command.mjs +36 -5
  17. package/dist/runtime/context.js +1 -1
  18. package/dist/schemas/flow-run.schema.json +1 -0
  19. package/dist/services/code-checks.js +40 -0
  20. package/dist/services/codex-hook-delivery.js +6 -2
  21. package/dist/services/hooks.js +51 -59
  22. package/dist/services/lifecycle-command.js +1 -383
  23. package/dist/services/lifecycle-contract.js +1 -30
  24. package/dist/services/lifecycle-invocations.js +34 -13
  25. package/dist/services/managed-processes.js +42 -6
  26. package/dist/services/run-control.js +7 -1
  27. package/dist/services/runs.js +60 -47
  28. package/dist/services/runtime-scope-stop.js +23 -2
  29. package/dist/services/stage-report-renderer.js +19 -0
  30. package/dist/services/verification-matrix.js +325 -0
  31. package/dist/services/vnext-code-review.js +25 -8
  32. package/dist/services/vnext-code.js +38 -11
  33. package/dist/services/vnext-merge.js +30 -7
  34. package/dist/services/vnext-plan-review.js +22 -6
  35. package/dist/services/vnext-plan.js +45 -17
  36. package/dist/services/work-registry.js +13 -1
  37. package/dist/storage/database.js +43 -9
  38. package/package.json +4 -1
@@ -0,0 +1,218 @@
1
+ import path from "node:path";
2
+ import Parser from "tree-sitter";
3
+ import Bash from "tree-sitter-bash";
4
+ import { parse, quote } from "shell-quote";
5
+ import { lifecycleOperationForArgv } from "./lifecycle-contract.mjs";
6
+
7
+ // One grammar owns structure. shell-quote decodes identified command words;
8
+ // neither parser grants authority or executes expansions.
9
+ const parser = new Parser();
10
+ parser.setLanguage(Bash);
11
+ const shells = new Set(["bash", "sh", "zsh"]);
12
+ const repeatedContexts = new Set(["for_statement", "while_statement", "function_definition", "c_style_for_statement"]);
13
+ const dynamicTypes = ["command_substitution", "process_substitution", "arithmetic_expansion", "brace_expression", "extglob_pattern"];
14
+ function words(text) {
15
+ try { const entries = parse(text, name => `$${name}`); return entries.every(entry => typeof entry === "string") ? entries : null; }
16
+ catch { return null; }
17
+ }
18
+ function ancestors(node) {
19
+ const result = [];
20
+ for (let parent = node.parent; parent; parent = parent.parent) result.push(parent.type);
21
+ return result;
22
+ }
23
+ function backgroundContext(node) {
24
+ for (let unit = node; unit.parent; unit = unit.parent) {
25
+ const siblings = unit.parent.children;
26
+ if (siblings[siblings.findIndex(child => child.id === unit.id) + 1]?.type === "&") return true;
27
+ }
28
+ return false;
29
+ }
30
+ function dynamicWord(node) {
31
+ return node.descendantsOfType(dynamicTypes).length > 0
32
+ || node.descendantsOfType(["simple_expansion", "expansion"]).some(expansion => !["$DD_FLOW_BIN", "${DD_FLOW_BIN}"].includes(expansion.text))
33
+ || node.type === "word" && /[*?[]/.test(node.text);
34
+ }
35
+ function launcherIndex(values, env) {
36
+ let index = 0;
37
+ while (values[index] && /^[A-Za-z_][A-Za-z0-9_]*=/.test(values[index])) {
38
+ const value = values[index++], equals = value.indexOf("="); env[value.slice(0, equals)] = value.slice(equals + 1);
39
+ }
40
+ if (path.basename(values[index] ?? "") === "env") {
41
+ index++; if (values[index] === "--") index++;
42
+ if (values[index]?.startsWith("-")) return null;
43
+ while (values[index] && /^[A-Za-z_][A-Za-z0-9_]*=/.test(values[index])) {
44
+ const value = values[index++], equals = value.indexOf("="); env[value.slice(0, equals)] = value.slice(equals + 1);
45
+ }
46
+ }
47
+ if (["command", "exec"].includes(values[index] ?? "")) { index++; if (values[index] === "--") index++; }
48
+ return index;
49
+ }
50
+ function executionCwd(node, nodes) {
51
+ const lineage = unit => { const result = []; for (; unit; unit = unit.parent) result.push(unit); return result; };
52
+ const target = lineage(node), targetIds = new Set(target.map(unit => unit.id));
53
+ let cwd;
54
+ for (const previous of nodes) {
55
+ if (previous.endIndex >= node.startIndex) continue;
56
+ const values = words(previous.text);
57
+ if (!values) continue;
58
+ let index = launcherIndex(values, {});
59
+ if (values[index - 1] === "command" && values[index] === "-p") { index++; if (values[index] === "--") index++; }
60
+ if (values[index] === "builtin") { index++; if (values[index] === "--") index++; }
61
+ if (!["cd", "pushd", "popd"].includes(values[index])) continue;
62
+ const directoryIndex = index + (values[index + 1] === "--" ? 2 : 1);
63
+ const source = lineage(previous);
64
+ // A different subshell/substitution cannot change this leaf's directory.
65
+ // Pipelines are not treated as isolated: bash lastpipe is configurable.
66
+ if (source.some(unit => ["subshell", "command_substitution", "process_substitution"].includes(unit.type) && !targetIds.has(unit.id))) continue;
67
+ const common = source.find(unit => targetIds.has(unit.id));
68
+ const andList = unit => unit.type === "list" && unit.children.some(child => child.type === "&&");
69
+ const sourcePath = source.slice(1, source.indexOf(common));
70
+ const targetPath = target.slice(1, target.findIndex(unit => unit.id === common?.id));
71
+ // Only a successful, foreground absolute cd dominating the leaf is a
72
+ // route. This walks the grammar's nested left-associative && lists;
73
+ // it does not evaluate conditionals, directory stacks or shell programs.
74
+ // `command cd` is a builtin in bash but an external child in zsh;
75
+ // without native dialect proof none of these prefixes proves parent cwd.
76
+ if (values.slice(0, index).some(value => ["env", "exec", "command"].includes(path.basename(value)))
77
+ || values[index] !== "cd" || values.length !== directoryIndex + 1 || !path.isAbsolute(values[directoryIndex]) || dynamicWord(previous)
78
+ || backgroundContext(previous) || !common || !andList(common)
79
+ || sourcePath.some(unit => unit.type !== "redirected_statement" && !andList(unit))
80
+ || targetPath.some(unit => !["redirected_statement", "subshell", "compound_statement"].includes(unit.type) && !andList(unit))) return { unresolved: true };
81
+ cwd = path.resolve(values[directoryIndex]);
82
+ }
83
+ return { cwd };
84
+ }
85
+ function invocationFromWords(values, node, wrapped, env) {
86
+ let index = launcherIndex(values, env);
87
+ if (index === null) return null;
88
+ let executable = values[index], launcher;
89
+ if (path.basename(executable ?? "") === "node" && (path.basename(values[index + 1] ?? "") === "cli.js" || values[index + 1] === "$DD_FLOW_BIN")) {
90
+ launcher = executable; executable = values[++index];
91
+ }
92
+ if (!executable || !(path.basename(executable) === "dd-flow" || executable === "$DD_FLOW_BIN" || launcher && path.basename(executable) === "cli.js")) return null;
93
+ const argv = values.slice(index + 1), operation = lifecycleOperationForArgv(argv);
94
+ if (!operation) return null;
95
+ const contexts = ancestors(node);
96
+ const heredoc = node.parent?.type === "redirected_statement" && node.parent.descendantsOfType("heredoc_redirect").length > 0;
97
+ return { operation, executable, ...(launcher ? { launcher } : {}), argv, env, args: parseCommandArgs(argv.slice(operation === "recovery_accept" ? 3 : 2)),
98
+ command: quote(values), rewriteSafe: !wrapped, wrapped, heredoc, contexts, ...(!wrapped ? { sourceRange: { start: node.startIndex, end: node.endIndex } } : {}) };
99
+ }
100
+ export function parseLifecycleCommand(command) { return analyze(command, false, 0); }
101
+ function analyze(command, wrapped, depth) {
102
+ if (depth > 8 || command.length > 1_000_000) return { kind: "none", unresolved: true, reason: "shell_input_limit" };
103
+ const tree = parser.parse(command);
104
+ if (!tree) return { kind: "none", unresolved: true, reason: "shell_parse_error" };
105
+ try {
106
+ const root = tree.rootNode;
107
+ if (root.hasError) return { kind: "none", unresolved: true, reason: "shell_parse_error" };
108
+ const nodes = root.descendantsOfType("command"), found = [];
109
+ let unsupported;
110
+ for (const node of nodes) {
111
+ const name = node.childForFieldName("name"), values = words(node.text);
112
+ if (!name || !values) { if (name && dynamicWord(name)) unsupported ??= "dynamic_executable"; continue; }
113
+ const inherited = {};
114
+ // Conditional exports cannot become unconditional facts in a sibling.
115
+ for (const previous of root.namedChildren) {
116
+ if (previous.endIndex > node.startIndex) break;
117
+ if (previous.type !== "declaration_command" || !previous.text.startsWith("export ")) continue;
118
+ for (const assignment of previous.descendantsOfType("variable_assignment")) {
119
+ if (dynamicWord(assignment)) continue;
120
+ const decoded = words(assignment.text)?.[0], equals = decoded?.indexOf("=") ?? -1;
121
+ if (decoded && equals > 0) inherited[decoded.slice(0, equals)] = decoded.slice(equals + 1);
122
+ }
123
+ }
124
+ const direct = invocationFromWords(values, node, wrapped, inherited);
125
+ if (direct) {
126
+ if (backgroundContext(node)) unsupported ??= "background_lifecycle_execution";
127
+ if (node.namedChildren.some(dynamicWord)) unsupported ??= "unresolved_lifecycle_word";
128
+ if (direct.contexts.some(type => repeatedContexts.has(type))) unsupported ??= "repeated_lifecycle_context";
129
+ if (direct.contexts.some(type => ["command_substitution", "process_substitution"].includes(type))) unsupported ??= "executable_substitution";
130
+ const route = executionCwd(node, nodes);
131
+ if (route.unresolved) unsupported ??= "unresolved_execution_cwd";
132
+ else if (route.cwd) direct.expectedCwd = route.cwd;
133
+ found.push(direct); continue;
134
+ }
135
+ const outerEnv = { ...inherited }, shellIndex = launcherIndex(values, outerEnv);
136
+ const wrapper = path.basename(values[shellIndex] ?? "");
137
+ if (["time", "nohup", "timeout"].includes(wrapper)) {
138
+ let nestedIndex = shellIndex + 1;
139
+ if (wrapper === "time" && values[nestedIndex] === "-p") nestedIndex++;
140
+ if (values[nestedIndex] === "--") nestedIndex++;
141
+ if (wrapper === "timeout" && /^\d+(?:\.\d+)?[smhd]?$/.test(values[nestedIndex] ?? "")) nestedIndex++;
142
+ const nested = analyze(quote(values.slice(nestedIndex)), true, depth + 1);
143
+ if (nested.kind !== "none") { found.push(...(nested.invocations ?? [nested.invocation])); unsupported ??= "opaque_execution_wrapper"; continue; }
144
+ if (nested.unresolved) unsupported ??= nested.reason;
145
+ }
146
+ if (shellIndex !== null && shells.has(path.basename(values[shellIndex] ?? "")) && /^-(?:l?c|cl)$/.test(values[shellIndex + 1] ?? "") && values[shellIndex + 2] !== undefined) {
147
+ const payloadNode = node.namedChildren[shellIndex + 2];
148
+ if (!payloadNode || dynamicWord(payloadNode)) { unsupported ??= "dynamic_shell_payload"; continue; }
149
+ if (node.namedChildren.some(child => child !== payloadNode && dynamicWord(child))) unsupported ??= "unresolved_shell_environment";
150
+ const nested = analyze(values[shellIndex + 2], true, depth + 1);
151
+ if (nested.kind !== "none") {
152
+ const route = executionCwd(node, nodes);
153
+ if (route.unresolved) unsupported ??= "unresolved_execution_cwd";
154
+ for (const invocation of nested.invocations ?? [nested.invocation]) {
155
+ invocation.env = { ...outerEnv, ...invocation.env };
156
+ if (!invocation.expectedCwd && route.cwd) invocation.expectedCwd = route.cwd;
157
+ }
158
+ found.push(...(nested.invocations ?? [nested.invocation]));
159
+ if (backgroundContext(node)) unsupported ??= "background_lifecycle_execution";
160
+ if (ancestors(node).some(type => repeatedContexts.has(type))) unsupported ??= "repeated_lifecycle_context";
161
+ if (ancestors(node).some(type => ["command_substitution", "process_substitution"].includes(type))) unsupported ??= "executable_substitution";
162
+ if (nested.supported === false) unsupported ??= nested.reason ?? "unsupported_wrapper";
163
+ }
164
+ else if (nested.unresolved) unsupported ??= nested.reason;
165
+ } else if (values[shellIndex] === "eval") unsupported ??= "dynamic_eval";
166
+ else if (values[shellIndex] === "coproc") unsupported ??= "background_lifecycle_execution";
167
+ else if (dynamicWord(name)) unsupported ??= "dynamic_executable";
168
+ }
169
+ if (!found.length) return unsupported ? { kind: "none", unresolved: true, reason: unsupported } : { kind: "none" };
170
+ if (found.length > 1) unsupported ??= "multiple_lifecycle_invocations";
171
+ const invocation = found[0];
172
+ const composed = nodes.length > 1 || root.namedChildren.length !== 1 || invocation.contexts.some(type => !["program", "redirected_statement", "command"].includes(type));
173
+ return composed || unsupported ? { kind: "compound", invocation, invocations: found, supported: !unsupported, reason: unsupported ?? "shell_composition" }
174
+ : { kind: "standalone", invocation, invocations: found, supported: true };
175
+ } finally { /* Native Tree lifetime is managed by the Node binding. */ }
176
+ }
177
+ export function lifecycleCommandFromPayload(payload) {
178
+ const input = payload.tool_input && typeof payload.tool_input === "object" ? payload.tool_input : {};
179
+ const command = [payload.command, input.command, input.cmd].find(value => typeof value === "string" && value.length > 0);
180
+ return typeof command === "string" ? unwrapShellCommand(command) : undefined;
181
+ }
182
+ export function continuationCommand(command) {
183
+ const values = words(command); if (!values) return null;
184
+ const start = values.findIndex(word => ["stage", "work"].includes(word)); if (start < 1) return null;
185
+ const launcher = values.slice(0, start);
186
+ while (launcher[0] && /^[A-Za-z_][A-Za-z0-9_]*=/.test(launcher[0])) launcher.shift();
187
+ const direct = launcher.length === 1 && (path.basename(launcher[0]) === "dd-flow" || launcher[0] === "$DD_FLOW_BIN");
188
+ const node = launcher.length === 2 && path.basename(launcher[0]) === "node" && (path.basename(launcher[1]) === "cli.js" || launcher[1] === "$DD_FLOW_BIN");
189
+ if (!direct && !node) return null;
190
+ const args = parseCommandArgs(values.slice(start)), route = args.positional.slice(0, 3).join(" ");
191
+ const operation = route === "work repair add" ? "work_repair_add" : args.positional[0] === "stage" && args.positional[1] === "block" ? "stage_block" : null;
192
+ return operation ? { operation, args } : null;
193
+ }
194
+ export function unwrapShellCommand(command) {
195
+ const values = words(command); if (!values || values.length !== 3 || !shells.has(path.basename(values[0])) || !/^-(?:l?c|cl)$/.test(values[1])) return command;
196
+ const tree = parser.parse(command);
197
+ try { const node = tree?.rootNode.namedChildren[0]?.namedChildren[2]; return node && !dynamicWord(node) ? values[2] : command; }
198
+ finally { /* Native Tree lifetime is managed by the Node binding. */ }
199
+ }
200
+ export function parseCommandArgs(argv) {
201
+ const positional = [], options = new Map();
202
+ for (let index = 0; index < argv.length; index++) {
203
+ const value = argv[index];
204
+ if (value?.startsWith("--")) {
205
+ const equals = value.indexOf("=");
206
+ if (equals > 2) { const key = value.slice(2, equals); options.set(key, [...(options.get(key) ?? []), value.slice(equals + 1)]); continue; }
207
+ const key = value.slice(2), next = argv[index + 1], optionValue = !next || next.startsWith("--") ? "__dd_flow_flag__" : next;
208
+ options.set(key, [...(options.get(key) ?? []), optionValue]); if (optionValue !== "__dd_flow_flag__") index++;
209
+ } else if (value) positional.push(value);
210
+ }
211
+ return { positional, options };
212
+ }
213
+ export function commandOption(invocation, name) { const value = invocation.args.options.get(name)?.at(-1); return value === "__dd_flow_flag__" ? undefined : value; }
214
+ export function commandHasOption(invocation, name) { return invocation.args.options.has(name); }
215
+ export function commandPosition(invocation, index) { return invocation.args.positional[index]; }
216
+ export function shellSuffixOffset(command) {
217
+ const analysis = parseLifecycleCommand(command); return analysis.kind === "none" ? command.length : analysis.invocation.sourceRange?.end ?? command.length;
218
+ }
@@ -0,0 +1,10 @@
1
+ export const lifecycleRoutes: {
2
+ readonly "session register": "session_register"; readonly "stage start": "stage_start"; readonly "stage finish": "stage_finish";
3
+ readonly "stage pause": "stage_pause"; readonly "stage resume": "stage_resume"; readonly "work start": "work_start";
4
+ readonly "work finish": "work_finish"; readonly "work fail": "work_fail"; readonly "merge apply": "merge_apply";
5
+ readonly "merge repair": "merge_repair"; readonly "run recovery accept": "recovery_accept";
6
+ };
7
+ export type LifecycleOperation = typeof lifecycleRoutes[keyof typeof lifecycleRoutes];
8
+ export const lifecycleInternalOptions: readonly ["invocation-id", "hook-event-id"];
9
+ export function lifecycleOperationForArgv(argv: readonly string[]): LifecycleOperation | null;
10
+ export function lifecycleRouteForOperation(operation: LifecycleOperation): keyof typeof lifecycleRoutes;
@@ -0,0 +1,19 @@
1
+ export const lifecycleRoutes = {
2
+ "session register": "session_register", "stage start": "stage_start", "stage finish": "stage_finish",
3
+ "stage pause": "stage_pause", "stage resume": "stage_resume", "work start": "work_start",
4
+ "work finish": "work_finish", "work fail": "work_fail", "merge apply": "merge_apply",
5
+ "merge repair": "merge_repair", "run recovery accept": "recovery_accept"
6
+ };
7
+ export const lifecycleInternalOptions = ["invocation-id", "hook-event-id"];
8
+ export function lifecycleOperationForArgv(argv) {
9
+ if (argv.some(arg => arg === "--help" || arg === "-h")) return null;
10
+ for (const [route, operation] of Object.entries(lifecycleRoutes)) {
11
+ if (route.split(" ").every((part, index) => argv[index] === part)) return operation;
12
+ }
13
+ return null;
14
+ }
15
+ export function lifecycleRouteForOperation(operation) {
16
+ const entry = Object.entries(lifecycleRoutes).find(([, value]) => value === operation);
17
+ if (!entry) throw new Error(`Unknown lifecycle operation: ${operation}`);
18
+ return entry[0];
19
+ }
@@ -1,3 +1,3 @@
1
- export function invokeNativeHook(input: { bin: string; args: string[]; payload: unknown; home?: string | undefined; timeoutMs?: number }): Promise<Record<string, unknown> & { timing?: Record<string, number> }>;
2
- export function hookBudget(timeoutMs?: number, deadlineAt?: number | null): { remaining(phase: string): number };
1
+ export function invokeNativeHook(input: { bin: string; args: string[]; payload: unknown; home?: string | undefined; cwd?: string; env?: NodeJS.ProcessEnv; timeoutMs?: number }): Promise<Record<string, unknown> & { timing?: Record<string, number> }>;
2
+ export function hookBudget(timeoutMs?: number, deadlineAt?: number | null): { deadlineAt: number; remaining(phase: string, maximum?: number): number; run<T>(action: () => T | Promise<T>, phase: string): Promise<T> };
3
3
  export function readNativeHookInput(stream: NodeJS.ReadStream, budget: { remaining(phase: string): number }, maxBytes?: number): Promise<string>;
@@ -1,8 +1,9 @@
1
1
  import { spawn } from "node:child_process";
2
2
  import { reportedFailure } from "./process-json.mjs";
3
3
 
4
- /** Native command hooks have a 30s outer limit; leave room for transport and cleanup. */
5
- export function hookBudget(timeoutMs = 25_000, deadlineAt = null) {
4
+ /** The caller supplies its qualified native limit; phases share that deadline. */
5
+ export function hookBudget(timeoutMs = 25_000, deadlineAt = process.env.DD_FLOW_HOOK_DEADLINE_AT === undefined ? null : Number(process.env.DD_FLOW_HOOK_DEADLINE_AT)) {
6
+ if (deadlineAt !== null && (!Number.isSafeInteger(deadlineAt) || deadlineAt < 0)) throw Object.assign(new Error("Invalid native hook deadline"), { code: "hook_deadline_invalid", details: { effect: "unknown" } });
6
7
  const started = performance.now();
7
8
  const duration = Math.max(0, Math.min(timeoutMs, Number.isFinite(deadlineAt) ? deadlineAt - Date.now() : timeoutMs));
8
9
  const remaining = (phase, maximum = duration) => {
@@ -10,7 +11,7 @@ export function hookBudget(timeoutMs = 25_000, deadlineAt = null) {
10
11
  if (value <= 0) throw Object.assign(new Error(`Native hook deadline exceeded during ${phase}`), { code: "native_hook_timeout", details: { phase, elapsed_ms: Math.round(performance.now() - started), effect: "unknown" } });
11
12
  return Math.max(1, Math.min(maximum, value));
12
13
  };
13
- return { deadlineAt: Date.now() + duration, remaining, async run(action, phase) {
14
+ return { deadlineAt: Math.floor(Date.now() + duration), remaining, async run(action, phase) {
14
15
  const timeoutMs = remaining(phase);
15
16
  let timer;
16
17
  try { return await Promise.race([Promise.resolve().then(action), new Promise((_, reject) => { timer = setTimeout(() => reject(Object.assign(new Error(`Native hook deadline exceeded during ${phase}`), { code: "native_hook_timeout", details: { phase, effect: "unknown" } })), timeoutMs); })]); }
@@ -57,14 +58,31 @@ export async function terminateCommandTree(child, { platform = process.platform,
57
58
 
58
59
  /** Preserve the native hook protocol across the CLI process boundary. */
59
60
  export async function runBoundedCommand({ bin, args, input = null, cwd, env = {}, timeoutMs = 15_000, timeoutCode = "native_hook_timeout" }) {
61
+ const inherited = env.DD_FLOW_HOOK_DEADLINE_AT ?? process.env.DD_FLOW_HOOK_DEADLINE_AT;
62
+ if (inherited !== undefined) {
63
+ const deadline = Number(inherited);
64
+ if (!Number.isSafeInteger(deadline) || deadline < 0) throw Object.assign(new Error("Invalid native hook deadline"), { code: "hook_deadline_invalid", details: { effect: "no_effect" } });
65
+ timeoutMs = Math.min(timeoutMs, deadline - Date.now());
66
+ }
67
+ if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) throw Object.assign(new Error("Native hook budget exhausted before subprocess launch"), { code: timeoutCode, details: { phase: "subprocess", effect: "no_effect" } });
60
68
  const started = performance.now();
69
+ // Transport/physical cleanup has its own small tail inside the outer limit.
70
+ const deadlineAt = Math.floor(Date.now() + Math.max(0, timeoutMs - Math.min(250, timeoutMs / 10)));
61
71
  const script = /\.[cm]?js$/.test(bin);
62
72
  const output = await new Promise((resolve, reject) => {
63
73
  const child = spawn(script ? process.execPath : bin, [...(script ? [bin] : []), ...args], {
64
- cwd, env: { ...process.env, ...env }, detached: process.platform !== "win32", stdio: ["pipe", "pipe", "pipe"]
74
+ cwd, env: { ...process.env, ...env, DD_FLOW_HOOK_DEADLINE_AT: String(deadlineAt) }, detached: process.platform !== "win32", stdio: ["pipe", "pipe", "pipe"]
65
75
  });
66
76
  let stdout = "", stderr = "", failure, cleanupTimer, settled = false, termination;
67
- const finish = (error, value) => { if (settled) return; settled = true; clearTimeout(timer); clearTimeout(cleanupTimer); error ? reject(error) : resolve(value); };
77
+ const finish = (error, value) => {
78
+ if (settled) return;
79
+ settled = true; clearTimeout(timer); clearTimeout(cleanupTimer);
80
+ if (error) {
81
+ const diagnostics = hookDiagnostics(stderr);
82
+ if (diagnostics.length) error.details = { ...error.details, hook_diagnostics: diagnostics };
83
+ reject(error);
84
+ } else resolve(value);
85
+ };
68
86
  const stop = error => {
69
87
  if (termination || settled) return;
70
88
  failure ??= error;
@@ -98,6 +116,19 @@ export async function runBoundedCommand({ bin, args, input = null, cwd, env = {}
98
116
  return output;
99
117
  }
100
118
 
119
+ /** Only our structured phase allowlist crosses a timeout, never raw stderr/env. */
120
+ function hookDiagnostics(stderr) {
121
+ return stderr.split("\n").flatMap(line => {
122
+ try {
123
+ const value = JSON.parse(line);
124
+ if (value?.kind !== "native_hook_phase") return [];
125
+ const phase = typeof value.phase === "string" && /^[a-z_]{1,48}$/.test(value.phase) ? value.phase : null;
126
+ if (!phase) return [];
127
+ return [{ phase, ...Object.fromEntries(["elapsed_ms", "remaining_ms", "sqlite_extended_code"].filter(key => Number.isFinite(value[key])).map(key => [key, value[key]])) }];
128
+ } catch { return []; }
129
+ }).slice(-16);
130
+ }
131
+
101
132
  export async function invokeNativeHook({ bin, args, payload, home, cwd, env, timeoutMs = 15_000 }) {
102
133
  const output = await runBoundedCommand({ bin, args, input: `${JSON.stringify(payload)}\n`, cwd, env: { ...env, DD_FLOW_HOME: home }, timeoutMs });
103
134
  let result;
@@ -12,7 +12,7 @@ export function createContext(env, mode = "initialize", importPermitId) {
12
12
  const ddFlowHome = resolveDdFlowHome(env);
13
13
  return {
14
14
  ddFlowHome,
15
- db: getDatabase(ddFlowHome, mode, importPermitId),
15
+ db: getDatabase(ddFlowHome, mode, importPermitId, mode === "hook" && env.DD_FLOW_HOOK_DEADLINE_AT ? Number(env.DD_FLOW_HOOK_DEADLINE_AT) : undefined),
16
16
  env,
17
17
  now: () => new Date().toISOString()
18
18
  };
@@ -12,6 +12,7 @@
12
12
  "project": {"$ref": "#/definitions/identity"},
13
13
  "subject": {"$ref": "#/definitions/identity"},
14
14
  "flow_kind": {"type": "string", "minLength": 1},
15
+ "verification_matrix_contract": {"const": "dd-flow/verification-matrix@1"},
15
16
  "status": {"enum": ["created", "running", "paused", "waiting_for_user", "done", "failed", "blocked", "cancelled", "discarded"]},
16
17
  "verdict": {"type": ["string", "null"]},
17
18
  "started_at": {"type": "string", "format": "date-time"},
@@ -9,11 +9,51 @@ import { assertRunMutationAllowed } from "./run-recovery.js";
9
9
  import { AppError } from "../shared/errors.js";
10
10
  import { confirmManagedProcess, finishManagedProcess, heartbeatManagedProcess, managedProcessStatus, managedProcessRuntimeHome, managedOwnerIsAlive, processTreeIsAlive, registerManagedProcess, reservePorts } from "./managed-processes.js";
11
11
  import { admitRuntimeCheck, runtimeBudgetForRun } from "./runtime-budget.js";
12
+ import { getResourceDatabase } from "../storage/database.js";
12
13
  const profileRelativePath = path.join(".memory-bank", "spec", "engineering", "code-check-profile.json");
13
14
  // Receipt reservation precedes child spawn so an interrupted CLI leaves an
14
15
  // auditable attempt. A second caller must not mistake that small handoff window
15
16
  // for a dead check and overwrite its outcome.
16
17
  const receiptStartingGraceMs = 30_000;
18
+ /** The check writer proves an empty tree before marking its registration terminal. */
19
+ export function terminalCheckSettled(context, record) {
20
+ if (record.kind !== "check" || !record.check_id || record.owner_id !== `check:${record.check_id}` || !record.project_id || !record.run_id
21
+ || !record.pid || !record.pid_started_at || !["stopped", "failed"].includes(record.state) || !record.finished_at)
22
+ return null;
23
+ let metadata;
24
+ try {
25
+ metadata = JSON.parse(record.metadata_json);
26
+ }
27
+ catch {
28
+ return null;
29
+ }
30
+ if (metadata.role !== "check" || managedProcessRuntimeHome(record, context) !== context.ddFlowHome)
31
+ return null;
32
+ let resource;
33
+ try {
34
+ resource = getResourceDatabase(context.env.DD_FLOW_RESOURCE_HOME ?? context.ddFlowHome, "read_existing");
35
+ }
36
+ catch {
37
+ return null;
38
+ }
39
+ try {
40
+ const current = resource.get("SELECT * FROM managed_processes WHERE id = ?", [record.id]);
41
+ if (!current || current.lease_token !== record.lease_token || current.owner_id !== record.owner_id || current.pid !== record.pid
42
+ || current.pid_started_at !== record.pid_started_at || current.metadata_json !== record.metadata_json || current.state !== record.state
43
+ || current.project_id !== record.project_id || current.run_id !== record.run_id || current.work_id !== record.work_id
44
+ || current.check_id !== record.check_id || current.finished_at !== record.finished_at)
45
+ return null;
46
+ const receipt = context.db.get("SELECT status, finished_at, work_id FROM check_receipts WHERE id = ? AND project_id = ? AND run_id = ?", [record.check_id, record.project_id, record.run_id]);
47
+ return receipt && receipt.work_id === record.work_id && ["passed", "failed", "aborted"].includes(receipt.status) && receipt.finished_at
48
+ ? { status: receipt.status, finished_at: receipt.finished_at } : null;
49
+ }
50
+ catch {
51
+ return null;
52
+ }
53
+ finally {
54
+ resource.close?.();
55
+ }
56
+ }
17
57
  export function codeExecutionEnvironment(workspaceRoot) {
18
58
  const suffix = crypto.createHash("sha256").update(path.resolve(workspaceRoot)).digest("hex").slice(0, 12);
19
59
  // The managed CLI's Node must win over a provider app's PATH: a Rosetta
@@ -6,17 +6,21 @@ import { commandOption, lifecycleCommandFromPayload, parseLifecycleCommand, cont
6
6
  export async function verifyCodexHookDelivery(env, payload) {
7
7
  const command = lifecycleCommandFromPayload(payload);
8
8
  const parsed = command ? parseLifecycleCommand(command) : { kind: "none" };
9
- if (parsed.kind === "none" && !(command && continuationCommand(command)))
9
+ if (parsed.kind === "none" && !parsed.unresolved && !(command && continuationCommand(command)))
10
10
  return undefined;
11
11
  const managed = (parsed.kind !== "none" && commandOption(parsed.invocation, "invocation-id")) || env.DD_FLOW_DAEMON_ID;
12
12
  if (!managed)
13
13
  return undefined; // Explicit legacy/unmanaged transport.
14
14
  if (!env.DD_FLOW_CODEX_STATE_DIR || !env.DD_FLOW_DAEMON_ID)
15
15
  throw new AppError("native_hook_unproven", "Managed hook has no owning native daemon", 1, { effect: "no_effect" });
16
+ const deadline = Number(env.DD_FLOW_HOOK_DEADLINE_AT);
17
+ const remaining = Number.isFinite(deadline) && deadline > 0 ? deadline - Date.now() : 5000;
18
+ if (remaining <= 0)
19
+ throw new AppError("native_hook_timeout", "Native hook deadline expired before delivery verification", 1, { phase: "verification", effect: "no_effect", recoverable: false });
16
20
  const proof = await callDaemon(env.DD_FLOW_CODEX_STATE_DIR, "hook.verify", {
17
21
  daemonId: env.DD_FLOW_DAEMON_ID, rootSessionId: payload.session_id,
18
22
  sessionId: payload.agent_id ?? payload.session_id, toolCallId: payload.tool_use_id, turnId: payload.turn_id
19
- }, 5000);
23
+ }, Math.min(5000, remaining));
20
24
  if (typeof proof.projectRoot !== "string"
21
25
  || typeof proof.ddFlowHome !== "string" || !env.DD_FLOW_HOME || path.resolve(env.DD_FLOW_HOME) !== path.resolve(proof.ddFlowHome)
22
26
  || proof.daemonId !== env.DD_FLOW_DAEMON_ID || typeof proof.rootSessionId !== "string" || typeof proof.sessionId !== "string"