@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.
- package/CHANGELOG.md +9 -0
- package/dist/build-info.json +5 -5
- package/dist/cli/hook-ingress.js +4 -3
- package/dist/cli/native-hook-ingress.js +34 -2
- package/dist/harness-runtime/bin/dd-agy.mjs +6 -3
- package/dist/harness-runtime/lib/dd-agy-daemon.mjs +16 -0
- package/dist/harness-runtime/lib/dd-zcode-daemon.mjs +60 -8
- package/dist/harness-runtime/lib/dd-zcode.mjs +25 -12
- package/dist/harness-runtime/lib/delegation-instructions.mjs +3 -3
- package/dist/harness-runtime/lib/lifecycle-command.d.mts +20 -0
- package/dist/harness-runtime/lib/lifecycle-command.mjs +218 -0
- package/dist/harness-runtime/lib/lifecycle-contract.d.mts +10 -0
- package/dist/harness-runtime/lib/lifecycle-contract.mjs +19 -0
- package/dist/harness-runtime/lib/native-hook-command.d.mts +2 -2
- package/dist/harness-runtime/lib/native-hook-command.mjs +36 -5
- package/dist/runtime/context.js +1 -1
- package/dist/schemas/flow-run.schema.json +1 -0
- package/dist/services/codex-hook-delivery.js +6 -2
- package/dist/services/hooks.js +51 -59
- package/dist/services/lifecycle-command.js +1 -383
- package/dist/services/lifecycle-contract.js +1 -30
- package/dist/services/lifecycle-invocations.js +34 -13
- package/dist/services/runs.js +60 -47
- package/dist/services/stage-report-renderer.js +19 -0
- package/dist/services/verification-matrix.js +325 -0
- package/dist/services/vnext-code-review.js +25 -8
- package/dist/services/vnext-code.js +31 -10
- package/dist/services/vnext-merge.js +30 -7
- package/dist/services/vnext-plan-review.js +22 -6
- package/dist/services/vnext-plan.js +16 -3
- package/dist/services/work-registry.js +13 -1
- package/dist/storage/database.js +43 -9
- 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
|
-
/**
|
|
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) => {
|
|
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;
|
package/dist/runtime/context.js
CHANGED
|
@@ -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"
|
package/dist/services/hooks.js
CHANGED
|
@@ -481,8 +481,8 @@ function codexNativeParentId(payload, childId) {
|
|
|
481
481
|
return null;
|
|
482
482
|
}
|
|
483
483
|
}
|
|
484
|
-
/** ACP
|
|
485
|
-
*
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
791
|
-
|
|
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
|
|
795
|
-
|
|
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
|
|
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
|
-
|
|
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.
|