@deksden-com/dd-flow-cli 0.9.0-beta.121 → 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 (33) 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-zcode-daemon.mjs +60 -8
  8. package/dist/harness-runtime/lib/dd-zcode.mjs +25 -12
  9. package/dist/harness-runtime/lib/delegation-instructions.mjs +3 -3
  10. package/dist/harness-runtime/lib/lifecycle-command.d.mts +20 -0
  11. package/dist/harness-runtime/lib/lifecycle-command.mjs +218 -0
  12. package/dist/harness-runtime/lib/lifecycle-contract.d.mts +10 -0
  13. package/dist/harness-runtime/lib/lifecycle-contract.mjs +19 -0
  14. package/dist/harness-runtime/lib/native-hook-command.d.mts +2 -2
  15. package/dist/harness-runtime/lib/native-hook-command.mjs +36 -5
  16. package/dist/runtime/context.js +1 -1
  17. package/dist/schemas/flow-run.schema.json +1 -0
  18. package/dist/services/codex-hook-delivery.js +6 -2
  19. package/dist/services/hooks.js +51 -59
  20. package/dist/services/lifecycle-command.js +1 -383
  21. package/dist/services/lifecycle-contract.js +1 -30
  22. package/dist/services/lifecycle-invocations.js +34 -13
  23. package/dist/services/runs.js +60 -47
  24. package/dist/services/stage-report-renderer.js +19 -0
  25. package/dist/services/verification-matrix.js +325 -0
  26. package/dist/services/vnext-code-review.js +25 -8
  27. package/dist/services/vnext-code.js +31 -10
  28. package/dist/services/vnext-merge.js +30 -7
  29. package/dist/services/vnext-plan-review.js +22 -6
  30. package/dist/services/vnext-plan.js +16 -3
  31. package/dist/services/work-registry.js +13 -1
  32. package/dist/storage/database.js +43 -9
  33. 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"},
@@ -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"
@@ -481,8 +481,8 @@ function codexNativeParentId(payload, childId) {
481
481
  return null;
482
482
  }
483
483
  }
484
- /** ACP is asynchronous evidence. Only an explicitly issued invocation may use
485
- * it as the receipt that its CLI waits for; this is not a PreToolUse hook. */
484
+ /** Qualified ACP admission binds public commands to an exact issued assignment.
485
+ * Its provenance is an observation, never a synchronous PreToolUse assertion. */
486
486
  export function handleZcodeEvent(context, input) {
487
487
  const notification = parseJsonObject(input.stdin || "{}", "zcode ACP notification");
488
488
  const params = objectRecord(notification.params), update = objectRecord(params.update);
@@ -494,8 +494,10 @@ export function handleZcodeEvent(context, input) {
494
494
  return { ok: true, observed: false, reason: "non_bash_tool" };
495
495
  const command = stringValue(rawInput.command) ?? stringValue(rawInput.cmd);
496
496
  const parsed = command ? parseLifecycleCommand(command) : { kind: "none" };
497
- if (parsed.kind === "none")
497
+ if (parsed.kind === "none" && !parsed.unresolved)
498
498
  return { ok: true, observed: false, reason: "acp_evidence_only" };
499
+ if (parsed.kind === "none")
500
+ throw new AppError("lifecycle_shell_syntax_invalid", "Shell participation could not be proven", 1, { reason: parsed.reason, effect: "unknown", recoverable: false });
499
501
  const controlled = objectRecord(objectRecord(notification._meta).ddZcode);
500
502
  const providerSessionId = stringValue(native.childSessionId) ?? stringValue(controlled.rootProviderSessionId);
501
503
  return recordZcodeLifecycle(context, { projectRoot: input.projectRoot, stdin: JSON.stringify({
@@ -557,7 +559,7 @@ function recordZcodeLifecycle(context, input, invocationId, nativePreTool = fals
557
559
  const observedProfile = objectRecord(ddZcode.observedProfile);
558
560
  const observedCwd = stringValue(hook.cwd) && path.isAbsolute(String(hook.cwd)) ? resolveProjectRoot(String(hook.cwd)) : null;
559
561
  let matchKey = lifecycle ? lifecycleMatchKey(lifecycle, expectedRoot) : null;
560
- const eventNameForReceipt = invocationId ? "ToolCallObserved" : "PreToolUse";
562
+ const eventNameForReceipt = nativePreTool ? "PreToolUse" : "ToolCallObserved";
561
563
  const persist = (scope) => {
562
564
  if (scope)
563
565
  matchKey = lifecycleMatchKey(lifecycleFacts(scope.command), expectedRoot, scope);
@@ -583,6 +585,24 @@ function recordZcodeLifecycle(context, input, invocationId, nativePreTool = fals
583
585
  identity: { daemonId, rootSessionId: rootProviderSessionId, sessionId: providerSessionId, parentSessionId: parentProviderSessionId ?? null, toolCallId: toolUseId },
584
586
  recordReceipt: scope => { persistedResult = persist(scope); return eventKey; }
585
587
  });
588
+ if (receipt.duplicate) {
589
+ // Keep first admission immutable. A second transport is independently
590
+ // retained corroboration, never another claim or an invented ACP cwd.
591
+ const retained = context.db.get("SELECT event_name, match_key, harness, provider_session_id, parent_session_id, daemon_id FROM hook_events WHERE project_id = ? AND event_key = ?", [project.id, receipt.eventKey]);
592
+ if (retained && retained.event_name !== eventNameForReceipt) {
593
+ if (retained.harness !== "zcode-acp" || retained.provider_session_id !== providerSessionId || retained.parent_session_id !== parentSessionId || retained.daemon_id !== daemonId) {
594
+ throw new AppError("hook_event_replay_mismatch", "Corroborating transport has different native ownership", 1, { event_key: receipt.eventKey });
595
+ }
596
+ context.db.writeTransaction(() => recordHookEvent(context, { projectId: project.id, protocolId: null, harness: "zcode-acp", providerSessionId, parentSessionId,
597
+ daemonId, sessionId, agentId: stringValue(hook.agent_id) ?? null, turnId: null, eventName: eventNameForReceipt, toolName: toolName ?? "Bash", status: "corroborating",
598
+ payload: { ...hook, command, corroborates_event_key: receipt.eventKey, admission_transport: nativePreTool ? "native_pre_tool" : "acp_observation" },
599
+ eventKey: `${receipt.eventKey}:${eventNameForReceipt}`, matchKey: null, transcriptPath: stringValue(hook.transcript_path) ?? null, cwd: observedCwd }));
600
+ }
601
+ else if (retained) {
602
+ assertHookEventReplay(context, { projectId: project.id, eventKey: receipt.eventKey, harness: "zcode-acp", providerSessionId, parentSessionId, daemonId,
603
+ sessionId, turnId: null, eventName: eventNameForReceipt, toolName: toolName ?? "Bash", matchKey: retained.match_key, cwd: observedCwd });
604
+ }
605
+ }
586
606
  const admittedInvocationId = receipt.invocationId ?? invocationId;
587
607
  if (admittedInvocationId)
588
608
  return { ok: true, observed: !receipt.duplicate, duplicate: receipt.duplicate, event_key: receipt.eventKey, invocation_id: admittedInvocationId };
@@ -706,8 +726,9 @@ function controlledLifecycleCwd(context, projectRoot, facts, providerSessionId,
706
726
  if (work) {
707
727
  // PROTOCOLIZE provisions the feature checkout after its coordinator
708
728
  // Session has launched in the stable project root. Its transition Work
709
- // must keep that physical route until the Stage is complete.
710
- if (context.db.get("SELECT 1 FROM sqlite_schema WHERE type = 'table' AND name = 'stage_work_memberships'")
729
+ // must keep that physical route until the Stage is complete. Child Works
730
+ // share stage membership, not the root coordinator's execution directory.
731
+ if (work.parent_work_id === null && context.db.get("SELECT 1 FROM sqlite_schema WHERE type = 'table' AND name = 'stage_work_memberships'")
711
732
  && context.db.get("SELECT 1 FROM stage_work_memberships WHERE project_id = ? AND run_id = ? AND work_id = ? AND stage IN ('specify','protocolize') LIMIT 1", [project.id, run.id, work.work_id]))
712
733
  return projectRoot;
713
734
  const merge = context.db.get("SELECT target_workspace FROM merge_requests WHERE executor_work_id = ? AND run_id = ?", [work.work_id, run.id]);
@@ -785,14 +806,24 @@ function handleControlledToolEvent(context, input, event, harness) {
785
806
  const auxiliary = continuationCommand(command);
786
807
  if (!lifecycle && !auxiliary)
787
808
  return { ok: true, observed: false, reason: "event_not_participating" };
809
+ // Syntax refusal precedes assignment/cwd lookup: an unsupported AST cannot
810
+ // supply a route or execution authority, including a short public Stage call.
811
+ const shellRefusal = lifecycle?.analysis.supported === false;
788
812
  const issuedRunId = lifecycle && !lifecycle.runId && !lifecycle.workId && !lifecycle.bootstrapStageStart
789
813
  ? observedLifecycleInvocation(context, command, daemonId)?.scope.runId : null;
790
- const expectedCwd = controlledLifecycleCwd(context, expectedRoot, lifecycle, providerSessionId, auxiliary, issuedRunId);
791
- if (observedCwd !== expectedCwd) {
792
- throw new AppError(`${errorPrefix}_directory_mismatch`, `${label} ${agy ? "launch route" : "Session directory"} does not match the assigned execution workspace`, 1, { expected: expectedCwd, actual: observedCwd, ...(agy ? { actual_provenance: stringValue(event.cwd_provenance) ?? "unknown" } : {}) });
814
+ if (!shellRefusal && lifecycle && lifecycle.invocation.operation.startsWith("stage_") && !lifecycle.bootstrapStageStart && !lifecycle.runId && !issuedRunId) {
815
+ throw new AppError("invocation_assignment_missing", "Short lifecycle call has no unique issued RUN assignment; its workspace cannot be inferred", 1, { effect: "no_effect", recoverable: false });
793
816
  }
794
- const bootstrapStageStart = lifecycle?.bootstrapStageStart;
795
- let matchKey = lifecycle ? lifecycleMatchKey(lifecycle, expectedRoot) : null;
817
+ const expectedCwd = shellRefusal ? observedCwd : controlledLifecycleCwd(context, expectedRoot, lifecycle, providerSessionId, auxiliary, issuedRunId);
818
+ const executionRoute = shellRefusal ? undefined : lifecycle?.invocation.expectedCwd;
819
+ if (executionRoute && resolveProjectRoot(executionRoute) !== expectedCwd) {
820
+ throw new AppError(`${errorPrefix}_directory_mismatch`, "Literal shell cd route does not match the assigned lifecycle workspace", 1, { expected: expectedCwd, actual: executionRoute, actual_provenance: "shell_literal_route", effect: "no_effect", recoverable: false });
821
+ }
822
+ if (!executionRoute && observedCwd !== expectedCwd) {
823
+ throw new AppError(`${errorPrefix}_directory_mismatch`, `${label} ${agy ? "launch route" : "Session directory"} does not match the assigned execution workspace`, 1, { expected: expectedCwd, actual: observedCwd, effect: "no_effect", recoverable: false, ...(agy ? { actual_provenance: stringValue(event.cwd_provenance) ?? "unknown" } : {}) });
824
+ }
825
+ const bootstrapStageStart = !shellRefusal && lifecycle?.bootstrapStageStart;
826
+ let matchKey = lifecycle && !shellRefusal ? lifecycleMatchKey(lifecycle, expectedRoot) : null;
796
827
  if (bootstrapStageStart)
797
828
  registerProject(context, { root: expectedRoot });
798
829
  const project = requireProjectByRoot(context, expectedRoot);
@@ -810,6 +841,7 @@ function handleControlledToolEvent(context, input, event, harness) {
810
841
  const payload = {
811
842
  session_id: providerSessionId, agent_id: agentId, cwd: observedCwd, tool_name: toolName, tool_input: rawInput, command,
812
843
  ...(agy ? { cwd_provenance: stringValue(event.cwd_provenance) ?? "unknown" } : {}),
844
+ ...(executionRoute ? { lifecycle_execution_cwd: expectedCwd, lifecycle_execution_cwd_provenance: "literal_cd_intent_requires_cli_confirmation" } : {}),
813
845
  provider: stringValue(profile.provider), model: stringValue(profile.model), reasoning: stringValue(droid ? profile.reasoning : profile.variant),
814
846
  mode: stringValue(droid ? profile.mode : profile.agent), agent_type: stringValue(droid ? profile.agent_type : profile.agent)
815
847
  };
@@ -822,10 +854,10 @@ function handleControlledToolEvent(context, input, event, harness) {
822
854
  return inserted;
823
855
  };
824
856
  const result = (inserted) => ({ ok: true, observed: inserted, duplicate: !inserted, event_key: baseKey, phase, harness, session: nativeSessionIdentity(harness, providerSessionId), ...(nativeParentId ? { parent_session: nativeSessionIdentity(harness, nativeParentId) } : {}), daemon_id: daemonId });
825
- if (agy && lifecycle) {
857
+ if ((agy || shellRefusal) && lifecycle) {
826
858
  let inserted = false;
827
859
  const receipt = observeLifecycleInvocation(context, {
828
- command, harness, projectId: project.id, identity: { daemonId, rootSessionId: nativeParentId ?? providerSessionId, sessionId: providerSessionId, parentSessionId: nativeParentId ?? null, toolCallId: toolCallId },
860
+ command, harness, projectId: project.id, ...(!shellRefusal && (agy || executionRoute) ? { executionCwd: expectedCwd } : {}), identity: { daemonId, rootSessionId: nativeParentId ?? providerSessionId, sessionId: providerSessionId, parentSessionId: nativeParentId ?? null, toolCallId: toolCallId },
829
861
  recordReceipt: scope => { inserted = persist(scope); return baseKey; }
830
862
  });
831
863
  return { ...result(inserted), ...(receipt.invocationId ? { invocation_id: receipt.invocationId } : {}) };
@@ -908,50 +940,19 @@ export function isolatedDdFlowHomeFromHookStdin(stdin) {
908
940
  */
909
941
  export function commandWithHookEvent(command, eventKey) {
910
942
  const analysis = parseLifecycleCommand(command);
911
- if (analysis.kind !== "standalone")
943
+ if (analysis.kind === "none" || analysis.supported === false)
912
944
  return command;
913
945
  if (commandHasOption(analysis.invocation, "hook-event-id")) {
914
946
  return command;
915
947
  }
916
948
  const option = `--hook-event-id '${eventKey.replaceAll("'", "'\\''")}'`;
917
949
  if (analysis.invocation.rewriteSafe) {
918
- const index = shellSuffixOffset(command);
919
- return `${command.slice(0, index).trimEnd()} ${option} ${command.slice(index)}`.trimEnd();
920
- }
921
- if (!analysis.invocation.wrapped && analysis.invocation.heredoc && allowsStdinLifecycleCompound(lifecycleFacts(command))) {
922
- const index = firstHeredoc(command);
923
- if (index >= 0)
924
- return `${command.slice(0, index).trimEnd()} ${option} ${command.slice(index)}`;
950
+ const index = analysis.invocation.sourceRange?.end ?? shellSuffixOffset(command);
951
+ const suffix = command.slice(index);
952
+ return `${command.slice(0, index)} ${option}${suffix ? /^\s/.test(suffix) ? suffix : ` ${suffix}` : ""}`;
925
953
  }
926
954
  return command;
927
955
  }
928
- function firstHeredoc(command) {
929
- let quoteState = null;
930
- let escaped = false;
931
- for (let index = 0; index < command.length - 1; index += 1) {
932
- const char = command[index];
933
- if (escaped) {
934
- escaped = false;
935
- continue;
936
- }
937
- if (char === "\\" && quoteState !== "'") {
938
- escaped = true;
939
- continue;
940
- }
941
- if (quoteState) {
942
- if (char === quoteState)
943
- quoteState = null;
944
- continue;
945
- }
946
- if (char === "'" || char === '"') {
947
- quoteState = char;
948
- continue;
949
- }
950
- if (char === "<" && command[index + 1] === "<" && command[index + 2] !== "<")
951
- return index;
952
- }
953
- return -1;
954
- }
955
956
  /**
956
957
  * Recognise the small shell surface used by generated dd-flow lifecycle
957
958
  * commands. This intentionally does not evaluate Bash: ambiguous composition
@@ -967,6 +968,8 @@ export function analyzeLifecycleShellCommand(command, includeSessionRegister = f
967
968
  }
968
969
  function lifecycleFacts(command) {
969
970
  const analysis = parseLifecycleCommand(command);
971
+ if (analysis.kind === "none" && analysis.unresolved)
972
+ throw new AppError("lifecycle_shell_syntax_invalid", "Shell participation could not be proven; use a literal issued command", 1, { phase: "prepare", effect: "no_effect", recoverable: true, disposition: "pre_cli_shell_refusal", reason: analysis.reason });
970
973
  if (analysis.kind === "none")
971
974
  return null;
972
975
  const invocation = analysis.invocation;
@@ -993,17 +996,6 @@ function lifecycleFacts(command) {
993
996
  contextSha256: commandOption(invocation, "context-sha256")
994
997
  };
995
998
  }
996
- // Worker completion and the two HITL commands intentionally take their
997
- // payload from stdin. Their generated pipe/heredoc is one lifecycle command,
998
- // not a second authority. Everything else remains standalone-only.
999
- function allowsStdinLifecycleCompound(facts) {
1000
- const operation = facts.invocation.operation;
1001
- return facts.analysis.kind === "standalone" && facts.invocation.heredoc && !facts.invocation.wrapped
1002
- && ((operation === "work_finish" && commandHasOption(facts.invocation, "result-stdin"))
1003
- || (operation === "stage_start" && commandHasOption(facts.invocation, "intake-stdin"))
1004
- || (operation === "stage_pause" && commandHasOption(facts.invocation, "question-stdin"))
1005
- || (operation === "stage_resume" && commandHasOption(facts.invocation, "answer-stdin")));
1006
- }
1007
999
  function lifecycleMatchKey(facts, projectRoot, scope) {
1008
1000
  // Observe the actual argument without validating it. A mistyped project
1009
1001
  // must not leave a receipt matching a later, corrected non-rewriting call.