@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
|
@@ -1,383 +1 @@
|
|
|
1
|
-
|
|
2
|
-
import { parse, quote } from "shell-quote";
|
|
3
|
-
import { lifecycleOperationForArgv } from "./lifecycle-contract.js";
|
|
4
|
-
/** Native hooks and the lightweight ingress must recognize the same shell. */
|
|
5
|
-
export function lifecycleCommandFromPayload(payload) {
|
|
6
|
-
const input = payload.tool_input && typeof payload.tool_input === "object" ? payload.tool_input : {};
|
|
7
|
-
const command = [payload.command, input.command, input.cmd].find(value => typeof value === "string" && value.length > 0);
|
|
8
|
-
return typeof command === "string" ? unwrapShellCommand(command) : undefined;
|
|
9
|
-
}
|
|
10
|
-
const controlOperators = new Set(["&&", "||", ";", "|", "|&", "&", "(", ")", "<(", ">("]);
|
|
11
|
-
const redirectionOperators = new Set(["<", ">", ">>", ">|", "<&", ">&", "<<<"]);
|
|
12
|
-
/** Parse the deliberately small direct-command surface accepted by lifecycle hooks. */
|
|
13
|
-
export function parseLifecycleCommand(command) {
|
|
14
|
-
return parseLifecycleCommandInner(command, false);
|
|
15
|
-
}
|
|
16
|
-
/** Exact diagnostic routes; these deliberately confer no lifecycle authority. */
|
|
17
|
-
export function continuationCommand(command) {
|
|
18
|
-
let entries;
|
|
19
|
-
try {
|
|
20
|
-
entries = parse(command, name => `$${name}`);
|
|
21
|
-
}
|
|
22
|
-
catch {
|
|
23
|
-
return null;
|
|
24
|
-
}
|
|
25
|
-
if (!entries.every(entry => typeof entry === "string"))
|
|
26
|
-
return null;
|
|
27
|
-
const words = entries;
|
|
28
|
-
const start = words.findIndex(word => ["stage", "work"].includes(word));
|
|
29
|
-
if (start < 1)
|
|
30
|
-
return null;
|
|
31
|
-
const launcher = words.slice(0, start);
|
|
32
|
-
while (launcher[0] && /^[A-Za-z_][A-Za-z0-9_]*=/.test(launcher[0]))
|
|
33
|
-
launcher.shift();
|
|
34
|
-
const direct = launcher.length === 1 && (path.basename(launcher[0]) === "dd-flow" || launcher[0] === "$DD_FLOW_BIN");
|
|
35
|
-
const node = launcher.length === 2 && path.basename(launcher[0]) === "node" && (path.basename(launcher[1]) === "cli.js" || launcher[1] === "$DD_FLOW_BIN");
|
|
36
|
-
if (!direct && !node)
|
|
37
|
-
return null;
|
|
38
|
-
const args = parseCommandArgs(words.slice(start));
|
|
39
|
-
const route = args.positional.slice(0, 3).join(" ");
|
|
40
|
-
const operation = route === "work repair add" ? "work_repair_add" : args.positional[0] === "stage" && args.positional[1] === "block" ? "stage_block" : null;
|
|
41
|
-
return operation ? { operation, args } : null;
|
|
42
|
-
}
|
|
43
|
-
/** Preserve the historical Desktop contract: execute the payload, not its one-command shell wrapper. */
|
|
44
|
-
export function unwrapShellCommand(command) {
|
|
45
|
-
let entries;
|
|
46
|
-
try {
|
|
47
|
-
entries = parse(command, (name) => `$${name}`);
|
|
48
|
-
}
|
|
49
|
-
catch {
|
|
50
|
-
return command;
|
|
51
|
-
}
|
|
52
|
-
if (!entries.every((entry) => typeof entry === "string"))
|
|
53
|
-
return command;
|
|
54
|
-
const words = entries;
|
|
55
|
-
let index = 0;
|
|
56
|
-
while (index < words.length && assignment(words[index]))
|
|
57
|
-
index += 1;
|
|
58
|
-
const executable = path.basename(words[index] ?? "");
|
|
59
|
-
if (!new Set(["bash", "sh", "zsh"]).has(executable))
|
|
60
|
-
return command;
|
|
61
|
-
if (!new Set(["-c", "-lc", "-cl"]).has(words[index + 1] ?? "") || words.length !== index + 3)
|
|
62
|
-
return command;
|
|
63
|
-
return words[index + 2] ?? command;
|
|
64
|
-
}
|
|
65
|
-
function parseLifecycleCommandInner(command, wrapped) {
|
|
66
|
-
const heredoc = stripHeredocBodies(command);
|
|
67
|
-
let entries;
|
|
68
|
-
try {
|
|
69
|
-
entries = parse(withoutFileDescriptors(heredoc.command), (name) => `$${name}`);
|
|
70
|
-
}
|
|
71
|
-
catch {
|
|
72
|
-
// A command that cannot be tokenized cannot create a trusted claim. The
|
|
73
|
-
// real lifecycle CLI will consequently fail its binding requirement.
|
|
74
|
-
return { kind: "none" };
|
|
75
|
-
}
|
|
76
|
-
const { segments, composed } = commandSegments(entries);
|
|
77
|
-
const found = [];
|
|
78
|
-
const exportedEnv = {};
|
|
79
|
-
for (const segment of segments) {
|
|
80
|
-
Object.assign(exportedEnv, exportedAssignments(segment));
|
|
81
|
-
const direct = invocationFromSegment(segment, heredoc.present, wrapped, exportedEnv);
|
|
82
|
-
if (direct) {
|
|
83
|
-
found.push({ invocation: direct, nestedCompound: segment.dynamic });
|
|
84
|
-
continue;
|
|
85
|
-
}
|
|
86
|
-
const nested = wrappedInvocationFromSegment(segment);
|
|
87
|
-
if (nested && nested.kind !== "none")
|
|
88
|
-
found.push({ invocation: { ...nested.invocation, heredoc: nested.invocation.heredoc || heredoc.present, rewriteSafe: false, wrapped: true }, nestedCompound: nested.kind === "compound" });
|
|
89
|
-
}
|
|
90
|
-
if (!found.length)
|
|
91
|
-
return { kind: "none" };
|
|
92
|
-
const first = found[0];
|
|
93
|
-
if (found.length !== 1 || composed || first.nestedCompound || (heredoc.present && !heredoc.safe)) {
|
|
94
|
-
return {
|
|
95
|
-
kind: "compound",
|
|
96
|
-
invocation: { ...first.invocation, rewriteSafe: false },
|
|
97
|
-
reason: found.length !== 1 ? "multiple_lifecycle_invocations" : first.nestedCompound ? "nested_compound_invocation" : heredoc.present && !heredoc.safe ? "unsafe_heredoc" : "shell_composition"
|
|
98
|
-
};
|
|
99
|
-
}
|
|
100
|
-
return { kind: "standalone", invocation: first.invocation };
|
|
101
|
-
}
|
|
102
|
-
function commandSegments(entries) {
|
|
103
|
-
const segments = [];
|
|
104
|
-
let words = [];
|
|
105
|
-
let dynamic = false;
|
|
106
|
-
let composed = false;
|
|
107
|
-
const flush = () => {
|
|
108
|
-
if (words.length || dynamic)
|
|
109
|
-
segments.push({ words, dynamic });
|
|
110
|
-
words = [];
|
|
111
|
-
dynamic = false;
|
|
112
|
-
};
|
|
113
|
-
for (let index = 0; index < entries.length; index += 1) {
|
|
114
|
-
const entry = entries[index];
|
|
115
|
-
if (typeof entry === "string") {
|
|
116
|
-
words.push(entry);
|
|
117
|
-
continue;
|
|
118
|
-
}
|
|
119
|
-
if ("comment" in entry)
|
|
120
|
-
break;
|
|
121
|
-
if (!("op" in entry) || typeof entry.op !== "string") {
|
|
122
|
-
dynamic = true;
|
|
123
|
-
continue;
|
|
124
|
-
}
|
|
125
|
-
const operator = entry.op;
|
|
126
|
-
if (operator === "glob") {
|
|
127
|
-
dynamic = true;
|
|
128
|
-
words.push("pattern" in entry && typeof entry.pattern === "string" ? entry.pattern : "");
|
|
129
|
-
continue;
|
|
130
|
-
}
|
|
131
|
-
if (controlOperators.has(operator)) {
|
|
132
|
-
composed = true;
|
|
133
|
-
flush();
|
|
134
|
-
continue;
|
|
135
|
-
}
|
|
136
|
-
if (redirectionOperators.has(operator)) {
|
|
137
|
-
const target = entries[index + 1];
|
|
138
|
-
if (typeof target === "string" || (target && typeof target === "object" && "op" in target && target.op === "glob"))
|
|
139
|
-
index += 1;
|
|
140
|
-
continue;
|
|
141
|
-
}
|
|
142
|
-
dynamic = true;
|
|
143
|
-
}
|
|
144
|
-
flush();
|
|
145
|
-
return { segments, composed };
|
|
146
|
-
}
|
|
147
|
-
function invocationFromSegment(segment, heredoc, wrapped, inheritedEnv) {
|
|
148
|
-
let index = 0;
|
|
149
|
-
const env = { ...inheritedEnv };
|
|
150
|
-
while (index < segment.words.length && assignment(segment.words[index])) {
|
|
151
|
-
addAssignment(env, segment.words[index]);
|
|
152
|
-
index += 1;
|
|
153
|
-
}
|
|
154
|
-
if (path.basename(segment.words[index] ?? "") === "env") {
|
|
155
|
-
index += 1;
|
|
156
|
-
if (segment.words[index]?.startsWith("-"))
|
|
157
|
-
return null;
|
|
158
|
-
while (index < segment.words.length && assignment(segment.words[index])) {
|
|
159
|
-
addAssignment(env, segment.words[index]);
|
|
160
|
-
index += 1;
|
|
161
|
-
}
|
|
162
|
-
}
|
|
163
|
-
let executable = segment.words[index];
|
|
164
|
-
let nodeLauncher = false;
|
|
165
|
-
let launcher;
|
|
166
|
-
if (executable && path.basename(executable) === "node"
|
|
167
|
-
&& (path.basename(segment.words[index + 1] ?? "") === "cli.js" || segment.words[index + 1] === "$DD_FLOW_BIN")) {
|
|
168
|
-
launcher = executable;
|
|
169
|
-
executable = segment.words[++index];
|
|
170
|
-
nodeLauncher = true;
|
|
171
|
-
}
|
|
172
|
-
if (!executable || path.basename(executable) !== "dd-flow" && executable !== "$DD_FLOW_BIN" && !(nodeLauncher && path.basename(executable) === "cli.js"))
|
|
173
|
-
return null;
|
|
174
|
-
const argv = segment.words.slice(index + 1);
|
|
175
|
-
const operation = lifecycleOperation(argv);
|
|
176
|
-
if (!operation)
|
|
177
|
-
return null;
|
|
178
|
-
return {
|
|
179
|
-
operation,
|
|
180
|
-
executable,
|
|
181
|
-
...(launcher ? { launcher } : {}),
|
|
182
|
-
argv,
|
|
183
|
-
env,
|
|
184
|
-
args: parseCommandArgs(argv.slice(operation === "recovery_accept" ? 3 : 2)),
|
|
185
|
-
command: quote(segment.words),
|
|
186
|
-
rewriteSafe: !heredoc && !wrapped && !segment.dynamic,
|
|
187
|
-
wrapped,
|
|
188
|
-
heredoc
|
|
189
|
-
};
|
|
190
|
-
}
|
|
191
|
-
function exportedAssignments(segment) {
|
|
192
|
-
const result = {};
|
|
193
|
-
if (segment.words[0] !== "export")
|
|
194
|
-
return result;
|
|
195
|
-
for (const word of segment.words.slice(1)) {
|
|
196
|
-
if (!assignment(word))
|
|
197
|
-
return {};
|
|
198
|
-
addAssignment(result, word);
|
|
199
|
-
}
|
|
200
|
-
return result;
|
|
201
|
-
}
|
|
202
|
-
function wrappedInvocationFromSegment(segment) {
|
|
203
|
-
let index = 0;
|
|
204
|
-
while (index < segment.words.length && assignment(segment.words[index]))
|
|
205
|
-
index += 1;
|
|
206
|
-
const executable = path.basename(segment.words[index] ?? "");
|
|
207
|
-
if (!new Set(["bash", "sh", "zsh"]).has(executable))
|
|
208
|
-
return null;
|
|
209
|
-
const flag = segment.words[index + 1];
|
|
210
|
-
const payload = segment.words[index + 2];
|
|
211
|
-
if (!payload || !new Set(["-c", "-lc", "-cl"]).has(flag ?? "") || segment.words.length !== index + 3)
|
|
212
|
-
return null;
|
|
213
|
-
return parseLifecycleCommandInner(payload, true);
|
|
214
|
-
}
|
|
215
|
-
function lifecycleOperation(argv) {
|
|
216
|
-
return lifecycleOperationForArgv(argv);
|
|
217
|
-
}
|
|
218
|
-
export function parseCommandArgs(argv) {
|
|
219
|
-
const positional = [];
|
|
220
|
-
const options = new Map();
|
|
221
|
-
for (let index = 0; index < argv.length; index += 1) {
|
|
222
|
-
const value = argv[index];
|
|
223
|
-
if (value?.startsWith("--")) {
|
|
224
|
-
const equals = value.indexOf("=");
|
|
225
|
-
if (equals > 2) {
|
|
226
|
-
const key = value.slice(2, equals);
|
|
227
|
-
options.set(key, [...(options.get(key) ?? []), value.slice(equals + 1)]);
|
|
228
|
-
continue;
|
|
229
|
-
}
|
|
230
|
-
const key = value.slice(2);
|
|
231
|
-
const next = argv[index + 1];
|
|
232
|
-
const optionValue = !next || next.startsWith("--") ? "__dd_flow_flag__" : next;
|
|
233
|
-
options.set(key, [...(options.get(key) ?? []), optionValue]);
|
|
234
|
-
if (optionValue !== "__dd_flow_flag__")
|
|
235
|
-
index += 1;
|
|
236
|
-
}
|
|
237
|
-
else if (value)
|
|
238
|
-
positional.push(value);
|
|
239
|
-
}
|
|
240
|
-
return { positional, options };
|
|
241
|
-
}
|
|
242
|
-
export function commandOption(invocation, name) {
|
|
243
|
-
const values = invocation.args.options.get(name);
|
|
244
|
-
const value = values?.[values.length - 1];
|
|
245
|
-
return value === "__dd_flow_flag__" ? undefined : value;
|
|
246
|
-
}
|
|
247
|
-
export function commandHasOption(invocation, name) {
|
|
248
|
-
return invocation.args.options.has(name);
|
|
249
|
-
}
|
|
250
|
-
export function commandPosition(invocation, index) {
|
|
251
|
-
return invocation.args.positional[index];
|
|
252
|
-
}
|
|
253
|
-
function assignment(word) {
|
|
254
|
-
return /^[A-Za-z_][A-Za-z0-9_]*=/.test(word);
|
|
255
|
-
}
|
|
256
|
-
/** Source offsets matter: shell-quote loses the difference between `2>&1`
|
|
257
|
-
* and the literal argument `2 >&1`. Only inspect unquoted shell syntax. */
|
|
258
|
-
function shellSyntaxOffsets(command) {
|
|
259
|
-
const offsets = [];
|
|
260
|
-
let quoted = null, escaped = false;
|
|
261
|
-
for (let i = 0; i < command.length; i++) {
|
|
262
|
-
const char = command[i];
|
|
263
|
-
if (escaped) {
|
|
264
|
-
escaped = false;
|
|
265
|
-
continue;
|
|
266
|
-
}
|
|
267
|
-
if (char === "\\" && quoted !== "'") {
|
|
268
|
-
escaped = true;
|
|
269
|
-
continue;
|
|
270
|
-
}
|
|
271
|
-
if (quoted) {
|
|
272
|
-
if (char === quoted)
|
|
273
|
-
quoted = null;
|
|
274
|
-
continue;
|
|
275
|
-
}
|
|
276
|
-
if (char === "'" || char === '"') {
|
|
277
|
-
quoted = char;
|
|
278
|
-
continue;
|
|
279
|
-
}
|
|
280
|
-
if (char === "#" && (i === 0 || /\s/.test(command[i - 1]))) {
|
|
281
|
-
offsets.push(i);
|
|
282
|
-
break;
|
|
283
|
-
}
|
|
284
|
-
if (char === "<" || char === ">")
|
|
285
|
-
offsets.push(i);
|
|
286
|
-
}
|
|
287
|
-
return offsets;
|
|
288
|
-
}
|
|
289
|
-
function descriptorStart(command, index) {
|
|
290
|
-
let start = index;
|
|
291
|
-
while (start > 0 && /[0-9]/.test(command[start - 1]))
|
|
292
|
-
start--;
|
|
293
|
-
return start === 0 || /[\s;&|]/.test(command[start - 1]) ? start : index;
|
|
294
|
-
}
|
|
295
|
-
export function shellSuffixOffset(command) {
|
|
296
|
-
const offset = shellSyntaxOffsets(command)[0];
|
|
297
|
-
return offset === undefined ? command.length : descriptorStart(command, offset);
|
|
298
|
-
}
|
|
299
|
-
function withoutFileDescriptors(command) {
|
|
300
|
-
for (const index of shellSyntaxOffsets(command).reverse()) {
|
|
301
|
-
if (command[index] === "#")
|
|
302
|
-
continue;
|
|
303
|
-
const start = descriptorStart(command, index);
|
|
304
|
-
command = command.slice(0, start) + command.slice(index);
|
|
305
|
-
}
|
|
306
|
-
return command;
|
|
307
|
-
}
|
|
308
|
-
function addAssignment(env, word) {
|
|
309
|
-
const separator = word.indexOf("=");
|
|
310
|
-
env[word.slice(0, separator)] = word.slice(separator + 1);
|
|
311
|
-
}
|
|
312
|
-
function stripHeredocBodies(command) {
|
|
313
|
-
const lines = command.split(/\r?\n/);
|
|
314
|
-
const output = [];
|
|
315
|
-
const pending = [];
|
|
316
|
-
let active;
|
|
317
|
-
let present = false;
|
|
318
|
-
let count = 0, quoted = true;
|
|
319
|
-
for (const line of lines) {
|
|
320
|
-
if (active) {
|
|
321
|
-
const candidate = active.stripTabs ? line.replace(/^\t+/, "") : line;
|
|
322
|
-
if (candidate === active.delimiter)
|
|
323
|
-
active = pending.shift();
|
|
324
|
-
continue;
|
|
325
|
-
}
|
|
326
|
-
if (line.trim())
|
|
327
|
-
output.push(line);
|
|
328
|
-
const found = heredocDelimiters(line);
|
|
329
|
-
count += found.length;
|
|
330
|
-
quoted &&= found.every(item => item.quoted);
|
|
331
|
-
if (found.length) {
|
|
332
|
-
present = true;
|
|
333
|
-
active = found[0];
|
|
334
|
-
pending.push(...found.slice(1));
|
|
335
|
-
}
|
|
336
|
-
}
|
|
337
|
-
return { command: output.join(" ; "), present, safe: count === 1 && quoted && !active && pending.length === 0 };
|
|
338
|
-
}
|
|
339
|
-
function heredocDelimiters(line) {
|
|
340
|
-
const result = [];
|
|
341
|
-
let quoteState = null;
|
|
342
|
-
let escaped = false;
|
|
343
|
-
for (let index = 0; index < line.length - 1; index += 1) {
|
|
344
|
-
const char = line[index];
|
|
345
|
-
if (escaped) {
|
|
346
|
-
escaped = false;
|
|
347
|
-
continue;
|
|
348
|
-
}
|
|
349
|
-
if (char === "\\" && quoteState !== "'") {
|
|
350
|
-
escaped = true;
|
|
351
|
-
continue;
|
|
352
|
-
}
|
|
353
|
-
if (quoteState) {
|
|
354
|
-
if (char === quoteState)
|
|
355
|
-
quoteState = null;
|
|
356
|
-
continue;
|
|
357
|
-
}
|
|
358
|
-
if (char === "'" || char === '"') {
|
|
359
|
-
quoteState = char;
|
|
360
|
-
continue;
|
|
361
|
-
}
|
|
362
|
-
if (char !== "<" || line[index + 1] !== "<" || line[index + 2] === "<")
|
|
363
|
-
continue;
|
|
364
|
-
index += 2;
|
|
365
|
-
const stripTabs = line[index] === "-";
|
|
366
|
-
if (stripTabs)
|
|
367
|
-
index += 1;
|
|
368
|
-
while (/\s/.test(line[index] ?? ""))
|
|
369
|
-
index += 1;
|
|
370
|
-
const delimiterQuote = line[index] === "'" || line[index] === '"' ? line[index++] : null;
|
|
371
|
-
const start = index;
|
|
372
|
-
if (delimiterQuote)
|
|
373
|
-
while (index < line.length && line[index] !== delimiterQuote)
|
|
374
|
-
index += 1;
|
|
375
|
-
else
|
|
376
|
-
while (index < line.length && !/[\s;&|<>]/.test(line[index]))
|
|
377
|
-
index += 1;
|
|
378
|
-
const delimiter = line.slice(start, index);
|
|
379
|
-
if (delimiter)
|
|
380
|
-
result.push({ delimiter, stripTabs, quoted: delimiterQuote !== null });
|
|
381
|
-
}
|
|
382
|
-
return result;
|
|
383
|
-
}
|
|
1
|
+
export { lifecycleCommandFromPayload, parseLifecycleCommand, continuationCommand, unwrapShellCommand, parseCommandArgs, commandOption, commandHasOption, commandPosition, shellSuffixOffset } from "../harness-runtime/lib/lifecycle-command.mjs";
|
|
@@ -1,30 +1 @@
|
|
|
1
|
-
export
|
|
2
|
-
"session register": "session_register",
|
|
3
|
-
"stage start": "stage_start",
|
|
4
|
-
"stage finish": "stage_finish",
|
|
5
|
-
"stage pause": "stage_pause",
|
|
6
|
-
"stage resume": "stage_resume",
|
|
7
|
-
"work start": "work_start",
|
|
8
|
-
"work finish": "work_finish",
|
|
9
|
-
"work fail": "work_fail",
|
|
10
|
-
"merge apply": "merge_apply",
|
|
11
|
-
"merge repair": "merge_repair",
|
|
12
|
-
"run recovery accept": "recovery_accept"
|
|
13
|
-
};
|
|
14
|
-
export const lifecycleInternalOptions = ["invocation-id", "hook-event-id"];
|
|
15
|
-
export function lifecycleOperationForArgv(argv) {
|
|
16
|
-
if (argv.some(arg => arg === "--help" || arg === "-h"))
|
|
17
|
-
return null;
|
|
18
|
-
for (const [route, operation] of Object.entries(lifecycleRoutes)) {
|
|
19
|
-
const parts = route.split(" ");
|
|
20
|
-
if (parts.every((part, index) => argv[index] === part))
|
|
21
|
-
return operation;
|
|
22
|
-
}
|
|
23
|
-
return null;
|
|
24
|
-
}
|
|
25
|
-
export function lifecycleRouteForOperation(operation) {
|
|
26
|
-
const entry = Object.entries(lifecycleRoutes).find(([, value]) => value === operation);
|
|
27
|
-
if (!entry)
|
|
28
|
-
throw new Error(`Unknown lifecycle operation: ${operation}`);
|
|
29
|
-
return entry[0];
|
|
30
|
-
}
|
|
1
|
+
export { lifecycleRoutes, lifecycleInternalOptions, lifecycleOperationForArgv, lifecycleRouteForOperation } from "../harness-runtime/lib/lifecycle-contract.mjs";
|
|
@@ -777,7 +777,8 @@ export function issueLifecycleInvocation(context, command, scope) {
|
|
|
777
777
|
}
|
|
778
778
|
function commandWithInvocationId(command, id) {
|
|
779
779
|
const at = shellSuffixOffset(command);
|
|
780
|
-
|
|
780
|
+
const suffix = command.slice(at);
|
|
781
|
+
return `${command.slice(0, at)} --invocation-id ${id}${suffix ? /^\s/.test(suffix) ? suffix : ` ${suffix}` : ""}`;
|
|
781
782
|
}
|
|
782
783
|
/** The attempt owns authority; the caller owns presentation-only shell input.
|
|
783
784
|
* Preserve current stdin/heredoc/response-file text while validating that it
|
|
@@ -909,11 +910,7 @@ export function observeLifecycleInvocation(context, input) {
|
|
|
909
910
|
storage(context);
|
|
910
911
|
const supplied = input.id ? context.db.get("SELECT * FROM lifecycle_invocations WHERE id = ?", [input.id]) : undefined;
|
|
911
912
|
const analysis = parseLifecycleCommand(input.command);
|
|
912
|
-
if (analysis.kind
|
|
913
|
-
if (analysis.kind !== "none" && ["grok-acp", "antigravity-cli"].includes(input.harness ?? "") && input.projectId
|
|
914
|
-
&& managedNativeOwner(context, input.identity, input.harness, input.projectId)) {
|
|
915
|
-
throw new AppError("invocation_command_invalid", "Managed native lifecycle call must be one direct standalone command", 1, { phase: "prepare", effect: "no_effect", recoverable: false });
|
|
916
|
-
}
|
|
913
|
+
if (analysis.kind === "none" || analysis.supported === false) {
|
|
917
914
|
const eventKey = input.recordReceipt();
|
|
918
915
|
if (analysis.kind !== "none") {
|
|
919
916
|
const retained = context.db.get("SELECT outcome_json FROM hook_events WHERE event_key = ?", [eventKey]);
|
|
@@ -928,22 +925,32 @@ export function observeLifecycleInvocation(context, input) {
|
|
|
928
925
|
...outcome,
|
|
929
926
|
lifecycle_observation: {
|
|
930
927
|
code: "lifecycle_shell_syntax_invalid",
|
|
931
|
-
reason: analysis.kind === "compound" ? analysis.reason : "
|
|
928
|
+
reason: analysis.kind === "compound" ? analysis.reason : "unsupported_lifecycle_command",
|
|
932
929
|
command_fingerprint: fingerprint(analysis.invocation),
|
|
933
930
|
effect: "no_effect",
|
|
934
931
|
recoverable: true
|
|
935
932
|
}
|
|
936
933
|
}), eventKey]);
|
|
937
934
|
}
|
|
935
|
+
if (analysis.kind !== "none")
|
|
936
|
+
throw new AppError("lifecycle_shell_syntax_invalid", "This shell form cannot bind exactly one lifecycle execution; use the exact issued lifecycle command without the unsupported shell context", 1, { phase: "prepare", effect: "no_effect", recoverable: true, disposition: "pre_cli_shell_refusal", reason: analysis.kind === "compound" ? analysis.reason : "unsupported_lifecycle_command", event_key: eventKey });
|
|
938
937
|
return { eventKey, duplicate: false, invocationId: null };
|
|
939
938
|
}
|
|
940
|
-
|
|
939
|
+
// Admit the one parsed leaf, never execute or fingerprint the prefix/suffix.
|
|
940
|
+
const leafCommand = analysis.invocation.command;
|
|
941
|
+
if (input.executionCwd !== undefined && !path.isAbsolute(input.executionCwd))
|
|
942
|
+
throw new AppError("invocation_directory_mismatch", "Bound lifecycle execution cwd must be absolute", 1, { phase: "prepare", effect: "no_effect", recoverable: false });
|
|
943
|
+
const deliveredIdentity = JSON.stringify([input.identity.daemonId, input.identity.rootSessionId, input.identity.sessionId, input.identity.parentSessionId, input.identity.toolCallId,
|
|
944
|
+
...(input.executionCwd === undefined ? [] : [{ executionCwd: canonicalPath(input.executionCwd) }])]);
|
|
945
|
+
const candidates = context.db.all("SELECT * FROM lifecycle_invocations WHERE status IN ('issued','observed','executing','settled') ORDER BY rowid DESC")
|
|
941
946
|
.filter(row => {
|
|
947
|
+
if (["executing", "settled"].includes(row.status) && row.identity_json !== deliveredIdentity)
|
|
948
|
+
return false;
|
|
942
949
|
const scope = JSON.parse(row.scope_json);
|
|
943
950
|
if (scope.daemonId !== input.identity.daemonId || scope.rootSessionId !== input.identity.rootSessionId)
|
|
944
951
|
return false;
|
|
945
952
|
try {
|
|
946
|
-
return fingerprint(invocation(canonicalManagedCommand(context,
|
|
953
|
+
return fingerprint(invocation(canonicalManagedCommand(context, leafCommand, scope))) === row.fingerprint;
|
|
947
954
|
}
|
|
948
955
|
catch {
|
|
949
956
|
return false;
|
|
@@ -979,7 +986,7 @@ export function observeLifecycleInvocation(context, input) {
|
|
|
979
986
|
const project = requireProjectByRoot(context, scope.projectRoot);
|
|
980
987
|
const identity = input.identity;
|
|
981
988
|
checkNativeIdentity(scope, identity, initial.id);
|
|
982
|
-
const identityJson =
|
|
989
|
+
const identityJson = deliveredIdentity;
|
|
983
990
|
return context.db.writeTransaction(() => {
|
|
984
991
|
const row = context.db.get("SELECT * FROM lifecycle_invocations WHERE id = ?", [initial.id]);
|
|
985
992
|
if (row.status === "settled")
|
|
@@ -992,6 +999,9 @@ export function observeLifecycleInvocation(context, input) {
|
|
|
992
999
|
if (row.identity_json === identityJson) {
|
|
993
1000
|
return { eventKey: row.event_key, duplicate: true, ...resolved };
|
|
994
1001
|
}
|
|
1002
|
+
if (row.identity_json && JSON.stringify(JSON.parse(row.identity_json).slice(0, 5)) === JSON.stringify(JSON.parse(identityJson).slice(0, 5))) {
|
|
1003
|
+
throw new AppError("invocation_identity_mismatch", "The same native tool call changed its bound execution route", 1, { phase: "prepare", effect: "no_effect", recoverable: false });
|
|
1004
|
+
}
|
|
995
1005
|
const eventKey = input.recordReceipt({ projectRoot: scope.projectRoot, runId: scope.runId, command: initial.command });
|
|
996
1006
|
// A native hook may execute from a provider worktree while the issued
|
|
997
1007
|
// lifecycle scope is anchored to the stable project root. Project
|
|
@@ -1019,7 +1029,7 @@ export function observedLifecycleNativeEvent(context, command, daemonId) {
|
|
|
1019
1029
|
return null;
|
|
1020
1030
|
const signature = (value) => {
|
|
1021
1031
|
const parsed = parseLifecycleCommand(value);
|
|
1022
|
-
const candidate = parsed.kind
|
|
1032
|
+
const candidate = parsed.kind !== "none" && parsed.supported ? parsed.invocation : continuationCommand(value);
|
|
1023
1033
|
if (!candidate)
|
|
1024
1034
|
return null;
|
|
1025
1035
|
return JSON.stringify([candidate.operation, candidate.args.positional,
|
|
@@ -1080,8 +1090,9 @@ export function observedLifecycleInvocation(context, command, daemonId) {
|
|
|
1080
1090
|
if (!context.db.get("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'lifecycle_invocations'"))
|
|
1081
1091
|
return null;
|
|
1082
1092
|
const analysis = parseLifecycleCommand(command);
|
|
1083
|
-
if (analysis.kind
|
|
1093
|
+
if (analysis.kind === "none" || analysis.supported === false)
|
|
1084
1094
|
return null;
|
|
1095
|
+
const leafCommand = analysis.invocation.command;
|
|
1085
1096
|
// `issued` is a rendezvous, not authority: the CLI still blocks in
|
|
1086
1097
|
// awaitLifecycleInvocation until the native observer commits the receipt.
|
|
1087
1098
|
const matches = context.db.all("SELECT * FROM lifecycle_invocations WHERE status IN ('issued','observed','executing') ORDER BY rowid DESC")
|
|
@@ -1090,7 +1101,7 @@ export function observedLifecycleInvocation(context, command, daemonId) {
|
|
|
1090
1101
|
const scope = JSON.parse(row.scope_json);
|
|
1091
1102
|
if (daemonId && scope.daemonId !== daemonId)
|
|
1092
1103
|
return false;
|
|
1093
|
-
return fingerprint(invocation(canonicalManagedCommand(context,
|
|
1104
|
+
return fingerprint(invocation(canonicalManagedCommand(context, leafCommand, scope))) === row.fingerprint;
|
|
1094
1105
|
}
|
|
1095
1106
|
catch {
|
|
1096
1107
|
return false;
|
|
@@ -1348,6 +1359,16 @@ export async function awaitLifecycleInvocation(context, input) {
|
|
|
1348
1359
|
}
|
|
1349
1360
|
input.assertCurrent(JSON.parse(row.scope_json));
|
|
1350
1361
|
if (row.status === "observed") {
|
|
1362
|
+
if (row.identity_json) {
|
|
1363
|
+
// Receipt display text is bounded/redacted and may not be valid JSON.
|
|
1364
|
+
// Only the native admission's typed identity binds an execution route.
|
|
1365
|
+
const identity = JSON.parse(row.identity_json);
|
|
1366
|
+
const route = identity[5]?.executionCwd;
|
|
1367
|
+
if (identity.length > 5 && (typeof route !== "string" || !path.isAbsolute(route)))
|
|
1368
|
+
throw new AppError("invocation_directory_mismatch", "Bound lifecycle execution cwd is invalid", 1, { phase: "prepare", effect: "no_effect", recoverable: false });
|
|
1369
|
+
if (typeof route === "string" && canonicalPath(process.cwd()) !== canonicalPath(route))
|
|
1370
|
+
throw new AppError("invocation_directory_mismatch", "Reached CLI cwd differs from the bound shell execution route", 1, { phase: "prepare", effect: "no_effect", recoverable: false, expected: route, actual: process.cwd(), actual_provenance: "cli_process_cwd" });
|
|
1371
|
+
}
|
|
1351
1372
|
const claimed = context.db.run("UPDATE lifecycle_invocations SET status = 'executing', updated_at = ? WHERE id = ? AND status = 'observed'", [context.now(), input.id]);
|
|
1352
1373
|
if (claimed.changes === 1)
|
|
1353
1374
|
return { eventKey: row.event_key, replay: false };
|