@deksden-com/dd-flow-cli 0.9.0-beta.124 → 0.9.0-beta.127

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 (113) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/README.md +10 -1
  3. package/dist/build-info.json +7 -7
  4. package/dist/cli/argument-values.js +38 -0
  5. package/dist/cli/command-inputs.js +4 -3
  6. package/dist/cli/help.js +1 -0
  7. package/dist/cli/hook-ingress.js +1 -1
  8. package/dist/cli/run-cli.js +126 -193
  9. package/dist/cli/runtime-maintenance-diagnostics.js +46 -0
  10. package/dist/cli/runtime-maintenance-ingress.js +51 -0
  11. package/dist/cli/runtime-process.js +128 -0
  12. package/dist/cli.js +8 -5
  13. package/dist/harness-runtime/bin/dd-agy.mjs +3 -2
  14. package/dist/harness-runtime/bin/dd-codex.mjs +3 -2
  15. package/dist/harness-runtime/bin/dd-droid.mjs +3 -2
  16. package/dist/harness-runtime/bin/dd-grok.mjs +16 -6
  17. package/dist/harness-runtime/bin/dd-opencode.mjs +3 -2
  18. package/dist/harness-runtime/bin/dd-zcode.mjs +20 -16
  19. package/dist/harness-runtime/lib/adapter-timeouts.d.mts +5 -0
  20. package/dist/harness-runtime/lib/adapter-timeouts.mjs +34 -0
  21. package/dist/harness-runtime/lib/daemon-observation.d.mts +2 -0
  22. package/dist/harness-runtime/lib/daemon-observation.mjs +163 -0
  23. package/dist/harness-runtime/lib/daemon-operations.mjs +60 -13
  24. package/dist/harness-runtime/lib/dd-agy-daemon.mjs +151 -72
  25. package/dist/harness-runtime/lib/dd-agy.mjs +11 -23
  26. package/dist/harness-runtime/lib/dd-codex-daemon.mjs +9 -14
  27. package/dist/harness-runtime/lib/dd-codex.mjs +252 -80
  28. package/dist/harness-runtime/lib/dd-droid-daemon.mjs +14 -19
  29. package/dist/harness-runtime/lib/dd-droid.mjs +117 -31
  30. package/dist/harness-runtime/lib/dd-grok-daemon.mjs +56 -30
  31. package/dist/harness-runtime/lib/dd-grok.mjs +15 -13
  32. package/dist/harness-runtime/lib/dd-opencode-daemon.mjs +18 -27
  33. package/dist/harness-runtime/lib/dd-opencode.mjs +44 -14
  34. package/dist/harness-runtime/lib/dd-zcode-daemon.mjs +56 -53
  35. package/dist/harness-runtime/lib/dd-zcode.mjs +200 -111
  36. package/dist/harness-runtime/lib/lease-renewal.d.mts +8 -0
  37. package/dist/harness-runtime/lib/lease-renewal.mjs +23 -0
  38. package/dist/harness-runtime/lib/lifecycle-command.d.mts +3 -3
  39. package/dist/harness-runtime/lib/lifecycle-command.mjs +74 -20
  40. package/dist/harness-runtime/lib/managed-daemon.d.mts +10 -1
  41. package/dist/harness-runtime/lib/managed-daemon.mjs +228 -40
  42. package/dist/harness-runtime/lib/native-children.d.mts +13 -0
  43. package/dist/harness-runtime/lib/native-children.mjs +78 -0
  44. package/dist/harness-runtime/lib/native-hook-command.d.mts +4 -0
  45. package/dist/harness-runtime/lib/native-hook-command.mjs +115 -11
  46. package/dist/harness-runtime/lib/native-streams.mjs +45 -0
  47. package/dist/harness-runtime/lib/observation-clock.d.mts +18 -0
  48. package/dist/harness-runtime/lib/observation-clock.mjs +73 -17
  49. package/dist/harness-runtime/lib/operation-errors.d.mts +14 -0
  50. package/dist/harness-runtime/lib/operation-errors.mjs +105 -8
  51. package/dist/harness-runtime/lib/operation-progress.d.mts +7 -0
  52. package/dist/harness-runtime/lib/operation-progress.mjs +112 -0
  53. package/dist/harness-runtime/lib/process-json.mjs +136 -38
  54. package/dist/harness-runtime/lib/tool-observations.d.mts +1 -0
  55. package/dist/harness-runtime/lib/tool-observations.mjs +5 -2
  56. package/dist/harness-runtime/lib/zcode-usage.mjs +3 -2
  57. package/dist/runtime/context.js +8 -4
  58. package/dist/schemas/run-control-receipt.schema.json +19 -0
  59. package/dist/services/code-checks.js +79 -9
  60. package/dist/services/codex-capacity.js +117 -41
  61. package/dist/services/codex-hook-delivery.js +2 -2
  62. package/dist/services/compatibility-preflight.js +4 -1
  63. package/dist/services/controller-fanout.js +9 -76
  64. package/dist/services/engines.js +70 -20
  65. package/dist/services/eval-snapshots.js +179 -40
  66. package/dist/services/external-work-launch.js +13 -8
  67. package/dist/services/harness-adapter.js +175 -11
  68. package/dist/services/hooks.js +65 -21
  69. package/dist/services/lifecycle-invocations.js +404 -32
  70. package/dist/services/managed-lease-monitor.js +128 -0
  71. package/dist/services/managed-processes.js +103 -47
  72. package/dist/services/merge-server.js +58 -21
  73. package/dist/services/recovery-observation-budget.js +67 -7
  74. package/dist/services/recovery-snapshot-database.js +7 -4
  75. package/dist/services/run-control-receipt.js +1 -1
  76. package/dist/services/run-control-worker.js +177 -118
  77. package/dist/services/run-control.js +99 -8
  78. package/dist/services/run-controller-adapter.js +169 -46
  79. package/dist/services/run-controller-capture.js +14 -2
  80. package/dist/services/run-controller-process.js +11 -4
  81. package/dist/services/run-controller-state.js +68 -7
  82. package/dist/services/run-controller.js +166 -94
  83. package/dist/services/run-fork.js +8 -3
  84. package/dist/services/run-recovery-guard.js +75 -0
  85. package/dist/services/run-recovery-runtime.js +1 -56
  86. package/dist/services/run-recovery.js +17 -13
  87. package/dist/services/runs.js +39 -6
  88. package/dist/services/runtime-budget.js +32 -15
  89. package/dist/services/runtime-scope-control.js +8 -0
  90. package/dist/services/runtime-scope-resume.js +30 -8
  91. package/dist/services/runtime-scope-worker.js +118 -73
  92. package/dist/services/runtime-service.js +6 -7
  93. package/dist/services/scope-writer-cleanup.js +3 -2
  94. package/dist/services/stage-context.js +135 -0
  95. package/dist/services/stage-lifecycle.js +3 -15
  96. package/dist/services/stage-pause.js +3 -2
  97. package/dist/services/usage.js +14 -3
  98. package/dist/services/vnext-code-review.js +10 -9
  99. package/dist/services/vnext-code.js +10 -8
  100. package/dist/services/vnext-contracts.js +10 -1
  101. package/dist/services/vnext-execution-profile.js +75 -2
  102. package/dist/services/vnext-fanout.js +5 -0
  103. package/dist/services/vnext-merge.js +10 -9
  104. package/dist/services/vnext-plan-review.js +21 -17
  105. package/dist/services/vnext-plan.js +9 -7
  106. package/dist/services/vnext-protocolize.js +15 -9
  107. package/dist/services/vnext-specify.js +6 -6
  108. package/dist/services/work-registry.js +34 -4
  109. package/dist/services/workspace-bootstrap.js +14 -3
  110. package/dist/shared/errors.js +11 -6
  111. package/dist/storage/agent-output.js +26 -0
  112. package/dist/storage/database.js +80 -21
  113. package/package.json +7 -4
@@ -4,7 +4,12 @@ import path from "node:path";
4
4
  import { fileURLToPath, pathToFileURL } from "node:url";
5
5
  import { AppError, errorRecord } from "../shared/errors.js";
6
6
  import { stopProcessGroup } from "../harness-runtime/lib/managed-daemon.mjs";
7
- import { recoveryRpcTimeout } from "./recovery-observation-budget.js";
7
+ import { recoveryObservationBudget, recoveryRpcTimeout, withRecoveryObservation } from "./recovery-observation-budget.js";
8
+ import { ObservationClock, validateObservationDuration } from "../harness-runtime/lib/observation-clock.mjs";
9
+ import { canonicalNativeOperation, nativeOperationWait } from "../harness-runtime/lib/adapter-timeouts.mjs";
10
+ import { readOperationProgress, readOperationRecord, readOperationWaiting, suspendOperationClock } from "../harness-runtime/lib/operation-progress.mjs";
11
+ import { recoverDaemonReply } from "../harness-runtime/lib/daemon-observation.mjs";
12
+ import { isObservationLoss } from "../harness-runtime/lib/operation-errors.mjs";
8
13
  const maxAdapterOutputBytes = 1_000_000;
9
14
  /** Native hooks need a real executable, not a PATH-dependent shell expression. */
10
15
  export function prepareHarnessFlowExecutable(context, stateDir) {
@@ -37,11 +42,50 @@ export function prepareHarnessFlowExecutable(context, stateDir) {
37
42
  }
38
43
  return executable;
39
44
  }
40
- /** Runs one harness command with the lifecycle guarantees shared by agent services. */
41
45
  export function runHarnessAdapter(input) {
46
+ const index = input.args.indexOf("--state-dir"), stateDir = index >= 0 ? input.args[index + 1] : undefined;
47
+ const operation = canonicalNativeOperation(input.harness ?? "", `${input.args[0]}.${input.args[1]}`);
48
+ const boundOperation = ["session.create", "session.fork", "session.start", "session.resume"].includes(operation);
49
+ const binding = { operation, ...(input.runtimeOwner ? { owner_id: input.runtimeOwner.owner_id } : {}) };
50
+ let retainedOutcome;
51
+ const onOutcome = (value) => {
52
+ const fingerprint = JSON.stringify(value);
53
+ if (fingerprint !== retainedOutcome) {
54
+ input.onNativeOutcome?.(value);
55
+ retainedOutcome = fingerprint;
56
+ }
57
+ };
58
+ const dispatched = runAdapterTransport(input, stateDir, binding);
59
+ return dispatched.catch(async (error) => {
60
+ if (!stateDir || !boundOperation || input.signal?.aborted || !(isObservationLoss(error) || isObservationLoss(error?.details?.cause)))
61
+ throw error;
62
+ try {
63
+ return await recoverDaemonReply(stateDir, input.operationId, { timeoutMs: recoveryRpcTimeout(30_000), ...(input.signal ? { signal: input.signal } : {}), ...(error?.observationClock ? { observationClock: error.observationClock } : {}), binding, onOutcome,
64
+ // Reconcile only original bookkeeping. Work/MERGE share this path and
65
+ // must not replay creation merely because its release receipt is pending.
66
+ onPendingSettlement: async (retained) => {
67
+ input.signal?.throwIfAborted();
68
+ await withRecoveryObservation(recoveryObservationBudget(retained), () => runAdapterTransport({ ...input, args: ["daemon", "operation", "settle", "--state-dir", stateDir, "--operation-id", input.operationId, "--json"], operationId: `${input.operationId}:settlement`, timeoutMs: recoveryRpcTimeout(30_000) }, stateDir, {}), true);
69
+ }
70
+ });
71
+ }
72
+ catch (recovery) {
73
+ if (recovery && typeof recovery === "object")
74
+ Object.assign(recovery, { cause: error });
75
+ throw recovery;
76
+ }
77
+ });
78
+ }
79
+ function runAdapterTransport(input, stateDir, binding = {}) {
42
80
  // `null` is deliberate: a productive Turn has no adapter-wall-clock limit.
43
81
  // Preserve it instead of treating it like an omitted short control request.
44
- const timeoutMs = recoveryRpcTimeout(input.timeoutMs === undefined ? 120_000 : input.timeoutMs);
82
+ if (input.timeoutMs != null)
83
+ validateObservationDuration(input.timeoutMs);
84
+ const operation = canonicalNativeOperation(input.harness ?? "", `${input.args[0]}.${input.args[1]}`);
85
+ const productive = input.harness !== undefined && nativeOperationWait(input.harness, operation) === "native-work";
86
+ const timeoutMs = recoveryRpcTimeout(productive ? null : input.timeoutMs === undefined ? 120_000 : input.timeoutMs);
87
+ if (timeoutMs !== null)
88
+ validateObservationDuration(timeoutMs);
45
89
  if (!input.operationId.trim())
46
90
  throw new AppError("operation_id_required", "Harness calls require a scoped operation id", 2);
47
91
  // The current adapter transport uses this name; its identity is runtime-owned,
@@ -66,13 +110,20 @@ export function runHarnessAdapter(input) {
66
110
  let timedOut = false;
67
111
  let aborted = false;
68
112
  let cleanup;
113
+ let pendingDiagnostic = "", droppingDiagnostic = false, progressCursor = 0;
114
+ let prettyDiagnostic = "", retainedError;
115
+ const phases = new Set();
116
+ let operationSequence = 0;
117
+ let diagnosticReads = Promise.resolve();
118
+ const clock = timeoutMs === null ? null : new ObservationClock({ timeoutMs });
119
+ clock?.sample(progressCursor);
69
120
  let settled = false;
70
121
  let callbackError;
71
122
  let spawnError;
72
123
  const stopTransport = () => {
73
124
  cleanup ??= stopProcessGroup(child, 5_000, { detached }).catch(error => {
74
125
  if (timer)
75
- clearTimeout(timer);
126
+ clearInterval(timer);
76
127
  if (heartbeat)
77
128
  clearInterval(heartbeat);
78
129
  input.signal?.removeEventListener("abort", abort);
@@ -100,7 +151,43 @@ export function runHarnessAdapter(input) {
100
151
  }
101
152
  };
102
153
  const heartbeat = input.progress && input.progressMessage ? setInterval(() => deliver(() => input.progress?.(`${input.progressMessage}; waiting for adapter completion`)), 15_000) : undefined;
103
- const timer = timeoutMs === null ? undefined : setTimeout(() => { timedOut = true; stopTransport(); }, timeoutMs);
154
+ let observing = false;
155
+ const timer = timeoutMs === null ? undefined : setInterval(async () => {
156
+ if (settled || cleanup)
157
+ return;
158
+ if (observing)
159
+ return;
160
+ observing = true;
161
+ // Helper phase progress must never replenish its owner's independent
162
+ // physical-settlement episode. ALS retains that existing budget here.
163
+ try {
164
+ recoveryRpcTimeout(timeoutMs);
165
+ }
166
+ catch (error) {
167
+ callbackError ??= error;
168
+ stopTransport();
169
+ observing = false;
170
+ return;
171
+ }
172
+ try {
173
+ const waiting = stateDir && await readOperationWaiting(stateDir, input.operationId, binding);
174
+ if (settled || cleanup)
175
+ return;
176
+ if (waiting)
177
+ suspendOperationClock(clock);
178
+ else if (clock.sample(progressCursor)) {
179
+ timedOut = true;
180
+ stopTransport();
181
+ }
182
+ }
183
+ catch (error) {
184
+ callbackError ??= error;
185
+ stopTransport();
186
+ }
187
+ finally {
188
+ observing = false;
189
+ }
190
+ }, Math.max(1, Math.min(1000, timeoutMs / 4)));
104
191
  const abort = () => { aborted = true; stopTransport(); };
105
192
  if (input.signal?.aborted)
106
193
  abort();
@@ -115,19 +202,96 @@ export function runHarnessAdapter(input) {
115
202
  }
116
203
  return current + chunk;
117
204
  };
205
+ const observeDiagnostic = (line) => {
206
+ if (prettyDiagnostic === null) {
207
+ if (line.trimEnd() === "}")
208
+ prettyDiagnostic = "";
209
+ return;
210
+ }
211
+ const candidate = prettyDiagnostic ? `${prettyDiagnostic}\n${line}` : line;
212
+ if (candidate.length > 64_000) {
213
+ prettyDiagnostic = null;
214
+ return;
215
+ }
216
+ let value;
217
+ try {
218
+ value = JSON.parse(candidate);
219
+ prettyDiagnostic = "";
220
+ }
221
+ catch {
222
+ prettyDiagnostic = prettyDiagnostic || line.trimStart().startsWith("{") ? candidate : "";
223
+ return;
224
+ }
225
+ if (!value || typeof value !== "object" || Array.isArray(value))
226
+ return;
227
+ if (value.kind === "operation_progress" && stateDir && value.operation_id === input.operationId) {
228
+ // The diagnostic is only a wake-up: durable, bound evidence owns liveness.
229
+ diagnosticReads = diagnosticReads.then(async () => {
230
+ const record = await readOperationProgress(stateDir, input.operationId, binding);
231
+ if (!record || record.sequence <= operationSequence)
232
+ return;
233
+ const requested = await readOperationRecord(stateDir, input.operationId, "requested.json");
234
+ if (!requested)
235
+ throw new AppError("operation_progress_invalid", "Operation request disappeared while observing progress", 1);
236
+ for (const key of ["daemon_id", "params_sha256", "generation"])
237
+ binding[key] ??= requested[key] ?? null;
238
+ operationSequence = record.sequence;
239
+ clock?.sample(++progressCursor, Date.parse(record.observed_at));
240
+ }).catch(error => { callbackError ??= error; if (!settled)
241
+ stopTransport(); });
242
+ return;
243
+ }
244
+ const cause = adapterCause(candidate);
245
+ if (typeof cause.code === "string")
246
+ retainedError = cause;
247
+ if (clock && !(stateDir && ["session.create", "session.fork", "session.start", "session.resume", "session.prompt"].includes(operation))
248
+ && ["native_hook_phase", "process_maintenance_phase"].includes(String(value.kind))
249
+ && typeof value.phase === "string" && /^[a-z_]{1,48}$/.test(value.phase)
250
+ && !["heartbeat", "poll", "elapsed", "wait", "waiting", "status"].includes(value.phase)) {
251
+ const marker = JSON.stringify([value.kind, value.phase, value.operation_id ?? value.process_id]);
252
+ if (!phases.has(marker)) {
253
+ phases.add(marker);
254
+ clock.sample(++progressCursor);
255
+ }
256
+ }
257
+ };
118
258
  child.stdout.setEncoding("utf8").on("data", (chunk) => { stdout = append(stdout, chunk); });
119
- child.stderr.setEncoding("utf8").on("data", (chunk) => { stderr = append(stderr, chunk); });
259
+ // Diagnostics have no lifetime quota. Stream to the existing evidence sink
260
+ // and carry only a bounded tail in the disposable RPC receipt.
261
+ child.stderr.setEncoding("utf8").on("data", (chunk) => {
262
+ stderr = (stderr + chunk).slice(-64_000);
263
+ for (const piece of chunk.split(/(?<=\n)/)) {
264
+ if (!droppingDiagnostic)
265
+ pendingDiagnostic += piece;
266
+ if (pendingDiagnostic.length > 64_000) {
267
+ prettyDiagnostic = prettyDiagnostic || pendingDiagnostic.trimStart().startsWith("{") ? null : "";
268
+ pendingDiagnostic = "";
269
+ droppingDiagnostic = true;
270
+ }
271
+ if (!piece.endsWith("\n"))
272
+ continue;
273
+ if (!droppingDiagnostic)
274
+ observeDiagnostic(pendingDiagnostic);
275
+ pendingDiagnostic = "";
276
+ droppingDiagnostic = false;
277
+ }
278
+ if (input.onEvidence)
279
+ deliver(() => evidence({ type: "adapter_diagnostic", stderr: chunk }));
280
+ });
120
281
  child.once("error", (error) => { spawnError = error; });
121
282
  child.once("close", async (status, signal) => {
122
283
  if (settled)
123
284
  return;
124
285
  settled = true;
125
286
  if (timer)
126
- clearTimeout(timer);
287
+ clearInterval(timer);
127
288
  input.signal?.removeEventListener("abort", abort);
128
289
  if (heartbeat)
129
290
  clearInterval(heartbeat);
130
291
  await cleanup;
292
+ if (!droppingDiagnostic && pendingDiagnostic)
293
+ observeDiagnostic(pendingDiagnostic);
294
+ await diagnosticReads;
131
295
  const progressFailure = callbackError;
132
296
  deliver(() => evidence({ status, signal, timed_out: timedOut, aborted, stdout, stderr, ...(spawnError ? { error: spawnError.message } : {}) }));
133
297
  if (spawnError)
@@ -135,11 +299,11 @@ export function runHarnessAdapter(input) {
135
299
  if (outputTooLarge)
136
300
  return reject(new AppError("harness_adapter_output_limit", "Harness adapter exceeded the bounded reply limit", 1, { executable: input.executable, args: input.args.slice(0, 3), max_output_bytes: maxAdapterOutputBytes }));
137
301
  if (timedOut)
138
- return reject(new AppError("harness_adapter_timeout", "Harness adapter call timed out", 1, { executable: input.executable, args: input.args.slice(0, 3), timeout_ms: timeoutMs }));
302
+ return reject(Object.assign(new AppError("harness_adapter_timeout", "Harness adapter call timed out", 1, { executable: input.executable, args: input.args.slice(0, 3), timeout_ms: timeoutMs }), { observationClock: clock }));
139
303
  if (aborted)
140
304
  return reject(new AppError("harness_adapter_aborted", "Harness adapter call was cancelled", 1, { executable: input.executable, args: input.args.slice(0, 3) }));
141
305
  if (status !== 0)
142
- return reject(progressFailure ?? new AppError("harness_adapter_failed", "Harness adapter call failed", 1, { executable: input.executable, args: input.args.slice(0, 3), status, signal, stderr, cause: adapterCause(stdout, stderr), ...(callbackError ? { evidence_error: String(callbackError) } : {}) }));
306
+ return reject(progressFailure ?? new AppError("harness_adapter_failed", "Harness adapter call failed", 1, { executable: input.executable, args: input.args.slice(0, 3), status, signal, stderr, cause: adapterCause(stdout, stderr, retainedError ? JSON.stringify(retainedError) : ""), ...(callbackError ? { evidence_error: String(callbackError) } : {}) }));
143
307
  if (callbackError)
144
308
  return reject(callbackError);
145
309
  try {
@@ -164,9 +328,9 @@ function adapterCause(...outputs) {
164
328
  const value = JSON.parse(line);
165
329
  const nested = value.error && typeof value.error === "object" && !Array.isArray(value.error) ? value.error : null;
166
330
  const code = typeof nested?.code === "string" ? nested.code : typeof value.code === "string" ? value.code : null;
167
- const message = typeof nested?.message === "string" ? nested.message : typeof value.error === "string" ? value.error : code;
331
+ const message = typeof nested?.message === "string" ? nested.message : typeof value.error === "string" ? value.error : typeof value.message === "string" ? value.message : code;
168
332
  if (code)
169
- return { code, message, ...(nested?.details ?? value.details ? { details: nested?.details ?? value.details } : {}), ...((nested?.retryable ?? value.retryable) === true ? { retryable: true } : {}) };
333
+ return errorRecord({ ...(nested ?? value), code, message });
170
334
  }
171
335
  catch { /* Adapter diagnostics need not be JSON. */ }
172
336
  }
@@ -12,7 +12,7 @@ import { appendAudit } from "./audit.js";
12
12
  import { prepareTextFile, prepareJsonText, prepareError } from "../cli/input-preparation.js";
13
13
  import { nativeSessionIdentity, storageSessionId } from "./session-identity.js";
14
14
  import { assertRecoveryHookCurrent } from "./run-recovery.js";
15
- import { observeLifecycleInvocation, observedLifecycleInvocation } from "./lifecycle-invocations.js";
15
+ import { observeLifecycleInvocation, observedLifecycleInvocation, managedNativeInvocationScope } from "./lifecycle-invocations.js";
16
16
  import { commandHasOption, commandOption, commandPosition, shellSuffixOffset, parseLifecycleCommand, lifecycleCommandFromPayload, continuationCommand } from "./lifecycle-command.js";
17
17
  import { registerProject, requireProjectByRoot } from "./projects.js";
18
18
  import { activeFlowSessionsForProject, recordFlowSessionObservation } from "./sessions.js";
@@ -398,6 +398,8 @@ export function handleCodexHook(context, input) {
398
398
  protocol_id: null
399
399
  };
400
400
  if (!lifecycle) {
401
+ if (providerSessionId)
402
+ assignedLifecycleCwd(context, project.root, null, providerSessionId, command, stringValue(payload.cwd));
401
403
  const persisted = context.db.writeTransaction(() => persist());
402
404
  return { ...common, observed: persisted.inserted, duplicate: !persisted.inserted };
403
405
  }
@@ -406,10 +408,13 @@ export function handleCodexHook(context, input) {
406
408
  const toolCallId = stringValue(payload.tool_use_id) ?? stringValue(payload.event_id) ?? stringValue(payload.delivery_id) ?? stringValue(payload.id);
407
409
  if (!daemonId || !rootProviderSessionId || !providerSessionId || !toolCallId)
408
410
  throw new AppError("codex_identity_missing", "Managed Codex lifecycle hook has no complete native invocation identity", 1, { invocation_id: invocationId });
411
+ const executionCwd = assignedLifecycleCwd(context, project.root, lifecycle, providerSessionId, command, stringValue(payload.cwd), { daemonId, rootSessionId: rootProviderSessionId, sessionId: providerSessionId, parentSessionId: parentProviderSessionId, toolCallId }, "codex-desktop");
409
412
  const receipt = observeLifecycleInvocation(context, {
410
413
  ...(invocationId ? { id: invocationId } : {}),
411
414
  command,
412
415
  harness: "codex-desktop",
416
+ projectId: project.id,
417
+ ...(executionCwd ? { executionCwd } : {}),
413
418
  identity: { daemonId, rootSessionId: rootProviderSessionId, sessionId: providerSessionId, parentSessionId: parentProviderSessionId, toolCallId },
414
419
  recordReceipt: scope => { persist(scope); return eventKey; }
415
420
  });
@@ -493,17 +498,17 @@ export function handleZcodeEvent(context, input) {
493
498
  if (toolName !== "Bash" && toolName !== "bash" && toolName !== "execute")
494
499
  return { ok: true, observed: false, reason: "non_bash_tool" };
495
500
  const command = stringValue(rawInput.command) ?? stringValue(rawInput.cmd);
496
- const parsed = command ? parseLifecycleCommand(command) : { kind: "none" };
497
- if (parsed.kind === "none" && !parsed.unresolved)
501
+ const parsed = command ? parseLifecycleCommand(command) : { kind: "none", participation: "none" };
502
+ if (parsed.participation === "none" || parsed.participation === "unproven")
498
503
  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 });
504
+ if (parsed.kind === "none" && !continuationCommand(command))
505
+ throw new AppError("lifecycle_shell_syntax_invalid", "Known lifecycle shell execution is unsupported", 1, { reason: parsed.reason, effect: "unknown", recoverable: false });
501
506
  const controlled = objectRecord(objectRecord(notification._meta).ddZcode);
502
507
  const providerSessionId = stringValue(native.childSessionId) ?? stringValue(controlled.rootProviderSessionId);
503
508
  return recordZcodeLifecycle(context, { projectRoot: input.projectRoot, stdin: JSON.stringify({
504
509
  session_id: providerSessionId, tool_use_id: update.toolCallId, tool_name: toolName, tool_input: rawInput,
505
510
  agent_id: native.agentId, _meta: { ddZcode: controlled }
506
- }) }, commandOption(parsed.invocation, "invocation-id"));
511
+ }) }, parsed.kind !== "none" ? commandOption(parsed.invocation, "invocation-id") : undefined);
507
512
  }
508
513
  /**
509
514
  * Accept the synchronous native ZCode PreToolUse callback. Invocation-ID
@@ -576,12 +581,17 @@ function recordZcodeLifecycle(context, input, invocationId, nativePreTool = fals
576
581
  const result = { ok: true, observed: inserted, duplicate: !inserted, event_key: eventKey, harness: "zcode-acp", session: nativeSessionIdentity("zcode-acp", providerSessionId), ...(parentProviderSessionId ? { parent_session: nativeSessionIdentity("zcode-acp", parentProviderSessionId) } : {}), daemon_id: daemonId };
577
582
  return { ...result, hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "allow", updatedInput: { ...rawInput, command: rewritten } } };
578
583
  };
579
- if (!lifecycle)
584
+ if (!lifecycle) {
585
+ assignedLifecycleCwd(context, expectedRoot, null, providerSessionId, command, observedCwd ?? undefined);
580
586
  return context.db.writeTransaction(() => persist());
587
+ }
581
588
  let persistedResult;
589
+ const executionCwd = assignedLifecycleCwd(context, expectedRoot, lifecycle, providerSessionId, command, observedCwd ?? undefined, { daemonId, rootSessionId: rootProviderSessionId, sessionId: providerSessionId, parentSessionId: parentProviderSessionId ?? null, toolCallId: toolUseId }, "zcode-acp");
582
590
  const receipt = observeLifecycleInvocation(context, {
583
591
  ...(invocationId ? { id: invocationId } : {}), command,
584
592
  harness: "zcode-acp",
593
+ projectId: project.id,
594
+ ...(executionCwd ? { executionCwd } : {}),
585
595
  identity: { daemonId, rootSessionId: rootProviderSessionId, sessionId: providerSessionId, parentSessionId: parentProviderSessionId ?? null, toolCallId: toolUseId },
586
596
  recordReceipt: scope => { persistedResult = persist(scope); return eventKey; }
587
597
  });
@@ -668,11 +678,15 @@ export function handleGrokEvent(context, input) {
668
678
  };
669
679
  const result = (inserted) => ({ ok: true, observed: inserted, duplicate: !inserted, event_key: eventKey, session: nativeSessionIdentity("grok-acp", providerSessionId),
670
680
  ...(isChild ? { parent_session: nativeSessionIdentity("grok-acp", stringValue(ddGrok.parentProviderSessionId) ?? rootProviderSessionId) } : {}), daemon_id: daemonId ?? null });
671
- if (!lifecycle)
681
+ if (!lifecycle) {
682
+ assignedLifecycleCwd(context, expectedRoot, null, providerSessionId, command, observedCwd ?? undefined);
672
683
  return { ...result(context.db.writeTransaction(() => persist())), hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "allow", updatedInput: { ...rawInput, command: commandWithHookEvent(command, eventKey) } } };
684
+ }
673
685
  let inserted = false;
686
+ const executionCwd = assignedLifecycleCwd(context, expectedRoot, lifecycle, providerSessionId, command, observedCwd ?? undefined, { daemonId: daemonId ?? "", rootSessionId: rootProviderSessionId, sessionId: providerSessionId, parentSessionId, toolCallId: eventKey }, "grok-acp");
674
687
  const receipt = observeLifecycleInvocation(context, {
675
688
  command, harness: "grok-acp", projectId: project.id,
689
+ ...(executionCwd ? { executionCwd } : {}),
676
690
  identity: { daemonId: daemonId ?? "", rootSessionId: rootProviderSessionId, sessionId: providerSessionId, parentSessionId, toolCallId: eventKey },
677
691
  recordReceipt: scope => { inserted = persist(scope); return eventKey; }
678
692
  });
@@ -695,12 +709,35 @@ export function handleDroidEvent(context, input) {
695
709
  return handleControlledToolEvent(context, input, event, "droid-cli");
696
710
  }
697
711
  /** Stable project identity is not the native Session's execution workspace. */
712
+ function assignedLifecycleCwd(context, projectRoot, facts, providerSessionId, command, observedCwd, identity, harness) {
713
+ const auxiliary = continuationCommand(command);
714
+ if ((!facts && !auxiliary) || facts?.analysis.supported === false)
715
+ return undefined;
716
+ const proven = identity && harness ? managedNativeInvocationScope(context, projectRoot, identity, harness) : null;
717
+ const constraint = proven ?? (identity ? { projectRoot, daemonId: identity.daemonId, rootSessionId: identity.rootSessionId } : undefined);
718
+ const issuedRunId = facts && !facts.runId && !facts.workId && !facts.bootstrapStageStart && constraint ? observedLifecycleInvocation(context, command, constraint, identity ? { identity } : undefined)?.scope.runId : null;
719
+ if (facts?.invocation.operation.startsWith("stage_") && !facts.bootstrapStageStart && !facts.runId && !issuedRunId)
720
+ throw new AppError("invocation_assignment_missing", "Short lifecycle call has no unique issued RUN assignment; its workspace cannot be inferred", 1, { phase: "prepare", effect: "no_effect", recoverable: false });
721
+ const expected = controlledLifecycleCwd(context, projectRoot, facts, providerSessionId, auxiliary, issuedRunId);
722
+ // Unknown CLI targets have no assigned workspace. Recording their native
723
+ // observation must not turn the stable identity root into cwd authority.
724
+ if (!expected)
725
+ return undefined;
726
+ const route = facts?.invocation.expectedCwd ?? auxiliary?.expectedCwd;
727
+ if (route && resolveProjectRoot(route) !== expected)
728
+ throw new AppError("invocation_directory_mismatch", "Literal lifecycle route differs from the assigned workspace", 1, { expected, actual: route, actual_provenance: "shell_literal_route", phase: "prepare", effect: "no_effect", recoverable: false });
729
+ if (!route && observedCwd && resolveProjectRoot(observedCwd) !== expected)
730
+ throw new AppError("invocation_directory_mismatch", "Native lifecycle directory differs from the assigned workspace", 1, { expected, actual: observedCwd, actual_provenance: "native_tool_cwd", phase: "prepare", effect: "no_effect", recoverable: false });
731
+ // ACP without native cwd still binds assigned authority; CLI confirms the
732
+ // physical process directory, rather than inventing native observation.
733
+ return expected;
734
+ }
698
735
  function controlledLifecycleCwd(context, projectRoot, facts, providerSessionId, auxiliary, issuedRunId) {
699
736
  if (facts?.bootstrapStageStart)
700
737
  return projectRoot;
701
738
  const project = context.db.get("SELECT id FROM projects WHERE root = ?", [projectRoot]);
702
739
  if (!project)
703
- return projectRoot;
740
+ return undefined;
704
741
  if (facts?.invocation.operation === "merge_apply" || facts?.invocation.operation === "merge_repair") {
705
742
  const requestId = commandPosition(facts.invocation, 0);
706
743
  const request = requestId && context.db.get("SELECT target_workspace FROM merge_requests WHERE project_id = ? AND merge_request_id = ?", [project.id, requestId]);
@@ -716,13 +753,15 @@ function controlledLifecycleCwd(context, projectRoot, facts, providerSessionId,
716
753
  if (!(error instanceof AppError) || !["not_found", "ambiguous_work_alias"].includes(error.code))
717
754
  throw error;
718
755
  }
756
+ if (!work)
757
+ return undefined;
719
758
  }
720
759
  const reference = work?.run_id ?? facts?.runId ?? auxiliary?.args.options.get("run")?.[0] ?? auxiliary?.args.positional[2] ?? issuedRunId;
721
760
  if (!reference)
722
- return projectRoot;
761
+ return undefined;
723
762
  const run = context.db.get("SELECT id, run_root, workspace_root FROM runs WHERE project_id = ? AND (id = ? OR short_id = ?)", [project.id, reference, reference]);
724
763
  if (!run)
725
- return projectRoot; // Hook ingress does not validate a mistyped CLI target.
764
+ return undefined; // Hook ingress does not validate a mistyped CLI target.
726
765
  if (work) {
727
766
  // PROTOCOLIZE provisions the feature checkout after its coordinator
728
767
  // Session has launched in the stable project root. Its transition Work
@@ -809,13 +848,17 @@ function handleControlledToolEvent(context, input, event, harness) {
809
848
  // Syntax refusal precedes assignment/cwd lookup: an unsupported AST cannot
810
849
  // supply a route or execution authority, including a short public Stage call.
811
850
  const shellRefusal = lifecycle?.analysis.supported === false;
851
+ const nativeIdentity = { daemonId, rootSessionId: nativeParentId ?? providerSessionId, sessionId: providerSessionId, parentSessionId: nativeParentId ?? null, toolCallId: toolCallId };
852
+ const provenScope = lifecycle && !shellRefusal ? managedNativeInvocationScope(context, expectedRoot, nativeIdentity, harness) : null;
812
853
  const issuedRunId = lifecycle && !lifecycle.runId && !lifecycle.workId && !lifecycle.bootstrapStageStart
813
- ? observedLifecycleInvocation(context, command, daemonId)?.scope.runId : null;
854
+ ? observedLifecycleInvocation(context, command, provenScope ?? { projectRoot: expectedRoot, daemonId, rootSessionId: nativeIdentity.rootSessionId }, { identity: nativeIdentity })?.scope.runId : null;
814
855
  if (!shellRefusal && lifecycle && lifecycle.invocation.operation.startsWith("stage_") && !lifecycle.bootstrapStageStart && !lifecycle.runId && !issuedRunId) {
815
856
  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 });
816
857
  }
817
- const expectedCwd = shellRefusal ? observedCwd : controlledLifecycleCwd(context, expectedRoot, lifecycle, providerSessionId, auxiliary, issuedRunId);
818
- const executionRoute = shellRefusal ? undefined : lifecycle?.invocation.expectedCwd;
858
+ // These controlled platforms additionally qualify their native launch
859
+ // route against the stable project root when no CLI target is assigned.
860
+ const expectedCwd = shellRefusal ? observedCwd : controlledLifecycleCwd(context, expectedRoot, lifecycle, providerSessionId, auxiliary, issuedRunId) ?? expectedRoot;
861
+ const executionRoute = shellRefusal ? undefined : lifecycle?.invocation.expectedCwd ?? auxiliary?.expectedCwd;
819
862
  if (executionRoute && resolveProjectRoot(executionRoute) !== expectedCwd) {
820
863
  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
864
  }
@@ -854,13 +897,14 @@ function handleControlledToolEvent(context, input, event, harness) {
854
897
  return inserted;
855
898
  };
856
899
  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 });
857
- if ((agy || shellRefusal) && lifecycle) {
900
+ if (lifecycle) {
858
901
  let inserted = false;
859
902
  const receipt = observeLifecycleInvocation(context, {
860
- command, harness, projectId: project.id, ...(!shellRefusal && (agy || executionRoute) ? { executionCwd: expectedCwd } : {}), identity: { daemonId, rootSessionId: nativeParentId ?? providerSessionId, sessionId: providerSessionId, parentSessionId: nativeParentId ?? null, toolCallId: toolCallId },
903
+ command, harness, projectId: project.id, ...(!shellRefusal ? { executionCwd: expectedCwd } : {}), identity: { daemonId, rootSessionId: nativeParentId ?? providerSessionId, sessionId: providerSessionId, parentSessionId: nativeParentId ?? null, toolCallId: toolCallId },
861
904
  recordReceipt: scope => { inserted = persist(scope); return baseKey; }
862
905
  });
863
- return { ...result(inserted), ...(receipt.invocationId ? { invocation_id: receipt.invocationId } : {}) };
906
+ const admitted = { ...result(inserted), ...(receipt.invocationId ? { invocation_id: receipt.invocationId } : {}) };
907
+ return agy || shellRefusal ? admitted : { ...admitted, hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "allow", updatedInput: { ...rawInput, command: commandWithHookEvent(command, baseKey) } } };
864
908
  }
865
909
  const inserted = context.db.writeTransaction(() => persist());
866
910
  return agy ? result(inserted) : { ...result(inserted), hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "allow", updatedInput: { ...rawInput, command: commandWithHookEvent(command, baseKey) } } };
@@ -968,8 +1012,8 @@ export function analyzeLifecycleShellCommand(command, includeSessionRegister = f
968
1012
  }
969
1013
  function lifecycleFacts(command) {
970
1014
  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 });
1015
+ if (analysis.kind === "none" && analysis.unresolved && ["lifecycle", "auxiliary"].includes(analysis.participation))
1016
+ throw new AppError("lifecycle_shell_syntax_invalid", "Known lifecycle execution is unsupported; use a literal issued command", 1, { phase: "prepare", effect: "no_effect", recoverable: true, disposition: "pre_cli_shell_refusal", reason: analysis.reason });
973
1017
  if (analysis.kind === "none")
974
1018
  return null;
975
1019
  const invocation = analysis.invocation;
@@ -1431,8 +1475,8 @@ function commandHook(command, statusMessage) {
1431
1475
  }
1432
1476
  function hookCommand(event) {
1433
1477
  const args = `codex hook handle --event ${event} --json`;
1434
- const handler = `if [ -n "\${DD_FLOW_BIN:-}" ]; then "$DD_FLOW_BIN" ${args}; else PATH="\${DD_FLOW_HOME:+\${DD_FLOW_HOME}/bin:}\${PATH}:\${HOME}/Library/pnpm:/opt/homebrew/bin:/usr/local/bin" dd-flow ${args}; fi`;
1435
- return `/bin/bash -c 'log_dir="\${DD_FLOW_HOME:-\${HOME}/.dd-flow}/logs"; mkdir -p "$log_dir"; log="$log_dir/codex-hook.log"; printf "%s event=${event} phase=start pid=%s\\n" "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$$" >>"$log"; ${handler} 2> >(tee -a "$log" >&2); status=$?; printf "%s event=${event} phase=finish pid=%s exit=%s\\n" "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$$" "$status" >>"$log"; exit "$status"'`;
1478
+ const handler = `if [ -n "\${DD_FLOW_BIN:-}" ]; then "$DD_FLOW_BIN" ${args}; else dd-flow ${args}; fi`;
1479
+ return `/bin/bash -c 'export PATH="\${DD_FLOW_HOME:+\${DD_FLOW_HOME}/bin:}\${PATH}:\${HOME}/Library/pnpm:/opt/homebrew/bin:/usr/local/bin"; log_dir="\${DD_FLOW_HOME:-\${HOME}/.dd-flow}/logs"; mkdir -p "$log_dir"; log="$log_dir/codex-hook.log"; printf "%s event=${event} phase=start pid=%s\\n" "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$$" >>"$log"; ${handler} 2> >(tee -a "$log" >&2); status=$?; printf "%s event=${event} phase=finish pid=%s exit=%s\\n" "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$$" "$status" >>"$log"; exit "$status"'`;
1436
1480
  }
1437
1481
  function resolveHookTarget(target) {
1438
1482
  if (!target || target === "isolated") {