@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
@@ -1,383 +1 @@
1
- import path from "node:path";
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 const lifecycleRoutes = {
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
- return `${command.slice(0, at).trimEnd()} --invocation-id ${id} ${command.slice(at)}`.trimEnd();
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 !== "standalone" || analysis.invocation.wrapped) {
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 : "wrapped_lifecycle_command",
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
- const candidates = context.db.all("SELECT * FROM lifecycle_invocations WHERE status IN ('issued','observed') ORDER BY rowid DESC")
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, input.command, scope))) === row.fingerprint;
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 = JSON.stringify([identity.daemonId, identity.rootSessionId, identity.sessionId, identity.parentSessionId, identity.toolCallId]);
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 === "standalone" && !parsed.invocation.wrapped ? parsed.invocation : continuationCommand(value);
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 !== "standalone" || analysis.invocation.wrapped)
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, command, scope))) === row.fingerprint;
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 };