@deksden-com/dd-flow-cli 0.9.0-beta.152 → 0.9.0-beta.155

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 (226) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/dist/build-info.json +8 -8
  3. package/dist/cli/command-inputs.js +33 -3
  4. package/dist/cli/command-metadata.js +73 -2
  5. package/dist/cli/delivery.js +108 -0
  6. package/dist/cli/diagnostic-invocation.js +23 -7
  7. package/dist/cli/diagnostic.js +14 -3
  8. package/dist/cli/help.js +117 -1
  9. package/dist/cli/hook-ingress.js +36 -49
  10. package/dist/cli/interactive-invocation.js +33 -0
  11. package/dist/cli/interactive.js +114 -0
  12. package/dist/cli/memory-fix-invocation.js +52 -0
  13. package/dist/cli/memory-fix.js +100 -0
  14. package/dist/cli/memory-flow-admission.js +7 -3
  15. package/dist/cli/memory-init.js +5 -4
  16. package/dist/cli/native-hook-ingress.js +5 -1
  17. package/dist/cli/native-hook-preparation.js +31 -4
  18. package/dist/cli/run-cli.js +273 -19
  19. package/dist/cli.js +17 -3
  20. package/dist/domain/delivery-flows.js +8 -0
  21. package/dist/domain/distill-flow.js +20 -0
  22. package/dist/domain/interactive-flow.js +1 -0
  23. package/dist/domain/memory-fix-flow.js +2 -0
  24. package/dist/domain/review-fix-flow.js +2 -0
  25. package/dist/domain/source-snapshot-flows.js +4 -0
  26. package/dist/harness-runtime/bin/dd-agy.mjs +18 -3
  27. package/dist/harness-runtime/bin/dd-grok.mjs +15 -5
  28. package/dist/harness-runtime/bin/dd-zcode.mjs +15 -7
  29. package/dist/harness-runtime/lib/agy-error-boundary.mjs +18 -10
  30. package/dist/harness-runtime/lib/agy-native-inspection.mjs +12 -3
  31. package/dist/harness-runtime/lib/agy-native-inventory.mjs +19 -0
  32. package/dist/harness-runtime/lib/agy-terminal-envelope.mjs +13 -6
  33. package/dist/harness-runtime/lib/agy-transcript-stream.mjs +54 -0
  34. package/dist/harness-runtime/lib/daemon-observation.d.mts +1 -0
  35. package/dist/harness-runtime/lib/daemon-observation.mjs +12 -4
  36. package/dist/harness-runtime/lib/daemon-operations.mjs +20 -5
  37. package/dist/harness-runtime/lib/dd-agy-daemon.d.mts +1 -0
  38. package/dist/harness-runtime/lib/dd-agy-daemon.mjs +371 -114
  39. package/dist/harness-runtime/lib/dd-codex-daemon.mjs +43 -3
  40. package/dist/harness-runtime/lib/dd-codex.mjs +36 -4
  41. package/dist/harness-runtime/lib/dd-droid-daemon.mjs +25 -11
  42. package/dist/harness-runtime/lib/dd-droid.mjs +124 -11
  43. package/dist/harness-runtime/lib/dd-grok-daemon.d.mts +1 -0
  44. package/dist/harness-runtime/lib/dd-grok-daemon.mjs +137 -25
  45. package/dist/harness-runtime/lib/dd-grok.mjs +4 -2
  46. package/dist/harness-runtime/lib/dd-opencode-daemon.mjs +114 -16
  47. package/dist/harness-runtime/lib/dd-opencode.mjs +10 -7
  48. package/dist/harness-runtime/lib/dd-zcode-daemon.d.mts +1 -0
  49. package/dist/harness-runtime/lib/dd-zcode-daemon.mjs +115 -22
  50. package/dist/harness-runtime/lib/dd-zcode.mjs +75 -36
  51. package/dist/harness-runtime/lib/delegation-instructions.d.mts +1 -0
  52. package/dist/harness-runtime/lib/delegation-instructions.mjs +10 -3
  53. package/dist/harness-runtime/lib/delivery-command.mjs +84 -0
  54. package/dist/harness-runtime/lib/driver-recovery.mjs +0 -19
  55. package/dist/harness-runtime/lib/droid-observation.mjs +9 -5
  56. package/dist/harness-runtime/lib/lifecycle-contract.d.mts +1 -0
  57. package/dist/harness-runtime/lib/lifecycle-contract.mjs +2 -1
  58. package/dist/harness-runtime/lib/native-hook-command.d.mts +3 -2
  59. package/dist/harness-runtime/lib/native-hook-command.mjs +63 -8
  60. package/dist/harness-runtime/lib/native-streams.mjs +3 -10
  61. package/dist/harness-runtime/lib/opencode-plugin.mjs +36 -9
  62. package/dist/harness-runtime/lib/operation-errors.mjs +13 -0
  63. package/dist/harness-runtime/lib/operation-progress.mjs +6 -2
  64. package/dist/harness-runtime/lib/process-json.mjs +4 -5
  65. package/dist/harness-runtime/lib/tool-observations.mjs +20 -10
  66. package/dist/harness-runtime/lib/zcode-native-hook-ownership.mjs +109 -0
  67. package/dist/schemas/code-work-result.schema.json +2 -1
  68. package/dist/schemas/delivery-stage-result.schema.json +286 -0
  69. package/dist/schemas/distill-stage-result.schema.json +667 -0
  70. package/dist/schemas/distill-work-result.schema.json +428 -0
  71. package/dist/schemas/execution-bundle.schema.json +6 -2
  72. package/dist/schemas/interactive-stage-result.schema.json +47 -0
  73. package/dist/schemas/interactive-work-result.schema.json +63 -0
  74. package/dist/schemas/mb-fix-stage-result.schema.json +93 -0
  75. package/dist/schemas/project-flow-pack-manifest.schema.json +2 -2
  76. package/dist/schemas/review-fix-stage-result.schema.json +210 -0
  77. package/dist/schemas/run-control-receipt.schema.json +1 -0
  78. package/dist/services/assigned-lifecycle.js +382 -0
  79. package/dist/services/check-runner-evidence.js +45 -8
  80. package/dist/services/cli-operation-classifier.js +6 -1
  81. package/dist/services/code-checks.js +46 -29
  82. package/dist/services/codex-hook-delivery.js +11 -2
  83. package/dist/services/controller-execution.js +4 -1
  84. package/dist/services/controller-fanout.js +9 -4
  85. package/dist/services/controller-memory-worker.js +6 -4
  86. package/dist/services/controller-result-authority.js +122 -0
  87. package/dist/services/dashboard.js +4 -2
  88. package/dist/services/delivery-access.js +123 -0
  89. package/dist/services/delivery-approval.js +68 -0
  90. package/dist/services/delivery-artifact.js +86 -0
  91. package/dist/services/delivery-checks.js +178 -0
  92. package/dist/services/delivery-execution.js +181 -0
  93. package/dist/services/delivery-intents.js +212 -0
  94. package/dist/services/delivery-operation.js +81 -0
  95. package/dist/services/delivery-policy.js +91 -0
  96. package/dist/services/delivery-procedures.js +43 -0
  97. package/dist/services/delivery-request.js +99 -0
  98. package/dist/services/delivery-result.js +144 -0
  99. package/dist/services/delivery-stage-commands.js +26 -0
  100. package/dist/services/delivery-stage-entry.js +56 -0
  101. package/dist/services/delivery-stage-handlers.js +135 -0
  102. package/dist/services/delivery-stage-results.js +94 -0
  103. package/dist/services/descriptor-coordinator-session.js +3 -1
  104. package/dist/services/descriptor-run-start.js +24 -4
  105. package/dist/services/descriptor-stage-coordinator.js +78 -10
  106. package/dist/services/descriptor-stage-settlement.js +59 -14
  107. package/dist/services/diagnostic-repair-intake.js +123 -0
  108. package/dist/services/diagnostic-repair-source-intake.js +102 -0
  109. package/dist/services/diagnostic-report.js +30 -0
  110. package/dist/services/diagnostic-run-bundle.js +48 -9
  111. package/dist/services/diagnostic-snapshot.js +2 -2
  112. package/dist/services/diagnostic-stage-handlers.js +3 -2
  113. package/dist/services/diagnostic-stage-results.js +26 -0
  114. package/dist/services/diagnostic-start.js +56 -6
  115. package/dist/services/diagnostic-work-collection.js +3 -3
  116. package/dist/services/distill-document-publication.js +280 -0
  117. package/dist/services/distill-promotion.js +197 -0
  118. package/dist/services/distill-publication-custody.js +162 -0
  119. package/dist/services/distill-stage-handlers.js +249 -0
  120. package/dist/services/distill-stage-results.js +105 -0
  121. package/dist/services/distill-user-inputs.js +4 -0
  122. package/dist/services/distill-work-batch.js +42 -0
  123. package/dist/services/distill-work-collection.js +74 -0
  124. package/dist/services/distill-work-registration.js +93 -0
  125. package/dist/services/distill-work-results.js +98 -0
  126. package/dist/services/eval-snapshots.js +17 -7
  127. package/dist/services/external-work-launch.js +8 -2
  128. package/dist/services/flow-catalog.js +24 -0
  129. package/dist/services/frozen-engine.js +19 -2
  130. package/dist/services/generic-execution.js +4 -3
  131. package/dist/services/harness-adapter.js +2 -14
  132. package/dist/services/hook-memory-routing.js +1 -1
  133. package/dist/services/hooks.js +31 -6
  134. package/dist/services/hybrid-contracts.js +6 -1
  135. package/dist/services/installed-flow-source.js +37 -2
  136. package/dist/services/interactive-attached-session.js +215 -0
  137. package/dist/services/interactive-baseline-custody.js +54 -0
  138. package/dist/services/interactive-before-versions.js +148 -0
  139. package/dist/services/interactive-bundle-data.js +85 -0
  140. package/dist/services/interactive-checks.js +179 -0
  141. package/dist/services/interactive-execution.js +33 -0
  142. package/dist/services/interactive-finish-integration.js +236 -0
  143. package/dist/services/interactive-git-baseline.js +230 -0
  144. package/dist/services/interactive-git-dispatch.js +278 -0
  145. package/dist/services/interactive-input-delivery.js +82 -0
  146. package/dist/services/interactive-mutation-scope.js +231 -0
  147. package/dist/services/interactive-request.js +287 -0
  148. package/dist/services/interactive-run-bundle.js +109 -0
  149. package/dist/services/interactive-stage-entry.js +41 -0
  150. package/dist/services/interactive-stage-handlers.js +289 -0
  151. package/dist/services/interactive-stage-observation.js +136 -0
  152. package/dist/services/interactive-stage-results.js +111 -0
  153. package/dist/services/interactive-user-inputs.js +142 -0
  154. package/dist/services/interactive-work-batch.js +43 -0
  155. package/dist/services/interactive-work-collection.js +119 -0
  156. package/dist/services/interactive-work-completion.js +180 -0
  157. package/dist/services/interactive-work-context.js +249 -0
  158. package/dist/services/interactive-work-history.js +112 -0
  159. package/dist/services/interactive-work-registration.js +124 -0
  160. package/dist/services/interactive-work-results.js +115 -0
  161. package/dist/services/interactive-work-start.js +155 -0
  162. package/dist/services/lifecycle-invocations.js +214 -24
  163. package/dist/services/managed-daemon-binding.js +52 -1
  164. package/dist/services/memory-compose-handler.js +2 -22
  165. package/dist/services/memory-finish-integration.js +58 -17
  166. package/dist/services/memory-fix-finish-integration.js +129 -0
  167. package/dist/services/memory-fix-git-dispatch.js +139 -0
  168. package/dist/services/memory-fix-intake-custody.js +11 -0
  169. package/dist/services/memory-fix-intake-settlement.js +127 -0
  170. package/dist/services/memory-fix-publication.js +196 -0
  171. package/dist/services/memory-fix-repair-admission.js +116 -0
  172. package/dist/services/memory-fix-request.js +141 -0
  173. package/dist/services/memory-fix-run-bundle.js +83 -0
  174. package/dist/services/memory-fix-scope.js +78 -0
  175. package/dist/services/memory-fix-sources.js +85 -0
  176. package/dist/services/memory-fix-stage-entry.js +24 -0
  177. package/dist/services/memory-fix-stage-handlers.js +282 -0
  178. package/dist/services/memory-fix-stage-results.js +106 -0
  179. package/dist/services/memory-fix-workspace.js +127 -0
  180. package/dist/services/memory-flow-user-inputs.js +88 -0
  181. package/dist/services/memory-init-contexts.js +55 -5
  182. package/dist/services/memory-init-controller-start.js +20 -4
  183. package/dist/services/memory-layer.js +7 -3
  184. package/dist/services/memory-native-review-copy.js +27 -3
  185. package/dist/services/memory-repair-document-delta.js +1 -0
  186. package/dist/services/project-flow-source.js +195 -0
  187. package/dist/services/project-git-integration.js +92 -0
  188. package/dist/services/repair-intents.js +2 -7
  189. package/dist/services/retained-native-hook.js +245 -0
  190. package/dist/services/review-fix-handoff.js +82 -0
  191. package/dist/services/review-fix-intake-custody.js +17 -0
  192. package/dist/services/review-fix-stage-entry.js +43 -0
  193. package/dist/services/review-fix-stage-handlers.js +176 -0
  194. package/dist/services/review-fix-stage-results.js +86 -0
  195. package/dist/services/run-control-worker.js +42 -1
  196. package/dist/services/run-control.js +40 -5
  197. package/dist/services/run-controller-adapter.js +37 -3
  198. package/dist/services/run-controller-process.js +12 -4
  199. package/dist/services/run-controller-recovery.js +46 -6
  200. package/dist/services/run-controller-state.js +3 -2
  201. package/dist/services/run-controller.js +209 -43
  202. package/dist/services/run-recovery.js +31 -2
  203. package/dist/services/run-timeline.js +59 -0
  204. package/dist/services/runs.js +18 -31
  205. package/dist/services/sessions.js +2 -1
  206. package/dist/services/stage-handlers.js +55 -3
  207. package/dist/services/stage-lifecycle.js +4 -3
  208. package/dist/services/stage-pause.js +26 -9
  209. package/dist/services/stage-report-renderer.js +2 -6
  210. package/dist/services/technical-notes.js +31 -0
  211. package/dist/services/verification-matrix.js +1 -1
  212. package/dist/services/version-status.js +1 -1
  213. package/dist/services/vnext-code-review.js +74 -5
  214. package/dist/services/vnext-code.js +8 -32
  215. package/dist/services/vnext-fanout.js +53 -8
  216. package/dist/services/vnext-merge.js +10 -5
  217. package/dist/services/vnext-plan-review.js +1 -1
  218. package/dist/services/vnext-plan.js +1 -1
  219. package/dist/services/vnext-protocolize.js +1 -1
  220. package/dist/services/vnext-specify.js +10 -5
  221. package/dist/services/work-registry.js +252 -66
  222. package/dist/services/worktrees.js +7 -4
  223. package/dist/storage/database.js +2 -1
  224. package/dist/verification-components.json +82 -0
  225. package/package.json +3 -2
  226. package/tools/verification-jobs.mjs +35 -10
@@ -3,8 +3,6 @@ import { readdir, readFile, mkdir, writeFile } from "node:fs/promises";
3
3
  import path from "node:path";
4
4
  import { inspectDaemonOperation } from "./daemon-operations.mjs";
5
5
  import { writeJsonAtomic } from "./runner-events.mjs";
6
- import { reportedError } from "./operation-errors.mjs";
7
- import { ObservationClock } from "./observation-clock.mjs";
8
6
  import { runtimeProcess } from "./managed-daemon.mjs";
9
7
  import { nativeShutdownConfirmed } from "./native-children.mjs";
10
8
 
@@ -103,23 +101,6 @@ export async function assertDaemonReplaceable(root) {
103
101
  }
104
102
  }
105
103
 
106
- /** Observe the original operation; never re-send its parameters. */
107
- export async function recoverDriverReply(root, id, { timeoutMs = 30_000, pollMs = 250 } = {}) {
108
- const clock = new ObservationClock({ timeoutMs });
109
- do {
110
- let receipt;
111
- try { receipt = await inspectDaemonOperation(root, id); }
112
- catch (error) { if (error.code !== "operation_not_found") throw error; }
113
- if (receipt?.state === "completed") return receipt.result;
114
- if (receipt?.state === "failed") throw reportedError(receipt.error, "provider operation failed");
115
- if (clock.sample()) break;
116
- await new Promise(resolve => setTimeout(resolve, pollMs));
117
- } while (true);
118
- throw Object.assign(new Error("original provider operation has no confirmed reply; reconcile it before starting another Turn"), {
119
- code: "operation_observation_lost", details: { operation_id: id, state_dir: root }
120
- });
121
- }
122
-
123
104
  /** Crash recovery barrier. Import late replies into the client ledger before
124
105
  * any subsequent productive call, even when a new runner process is used. */
125
106
  export async function reconcileDriverReplies(root) {
@@ -6,10 +6,12 @@ import path from "node:path";
6
6
  * New child discovery may walk the directory once; heartbeats never replay it. */
7
7
  export class DroidMetadataReader {
8
8
  constructor(factory) { this.factory = factory; this.files = new Map(); this.cache = new Map(); }
9
- async locate(id) {
9
+ async locate(id, onProgress = async () => {}) {
10
10
  if (this.files.has(id)) return this.files.get(id);
11
11
  const visit = async directory => {
12
- for (const entry of await readdir(directory, { withFileTypes: true }).catch(error => { if (error.code === "ENOENT") return []; throw error; })) {
12
+ const entries = await readdir(directory, { withFileTypes: true }).catch(error => { if (error.code === "ENOENT") return []; throw error; });
13
+ await onProgress(`directory:${createHash('sha256').update(JSON.stringify([directory, entries.map(entry => entry.name)])).digest('hex')}`);
14
+ for (const entry of entries) {
13
15
  const file = path.join(directory, entry.name);
14
16
  if (entry.isDirectory()) await visit(file);
15
17
  else if (entry.isFile() && entry.name.endsWith(".jsonl")) this.files.set(entry.name.slice(0, -6), file);
@@ -18,22 +20,24 @@ export class DroidMetadataReader {
18
20
  await visit(path.join(this.factory, "sessions"));
19
21
  return this.files.get(id) ?? null;
20
22
  }
21
- async read(id) {
22
- const file = await this.locate(id); if (!file) return null;
23
+ async read(id, onProgress = async () => {}) {
24
+ const file = await this.locate(id, onProgress); if (!file) return null;
23
25
  const settingsFile = file.replace(/\.jsonl$/, ".settings.json");
24
26
  const info = await stat(settingsFile).catch(error => { if (error.code === "ENOENT") return null; throw error; });
25
27
  if (!info || info.size > 1_048_576) return null;
26
28
  const identity = `${info.ino}:${info.mtimeMs}:${info.size}`;
27
- if (this.cache.get(id)?.identity === identity) return this.cache.get(id).value;
29
+ if (this.cache.get(id)?.identity === identity) { await onProgress(`metadata:${id}:${identity}`); return this.cache.get(id).value; }
28
30
  const handle = await open(file, "r"); const buffer = Buffer.alloc(65_536);
29
31
  let header;
30
32
  try { const { bytesRead } = await handle.read(buffer, 0, buffer.length, 0); header = JSON.parse(buffer.subarray(0, bytesRead).toString("utf8").split("\n")[0]); }
31
33
  finally { await handle.close(); }
34
+ await onProgress(`header:${id}:${createHash('sha256').update(JSON.stringify(header)).digest('hex')}`);
32
35
  if (header.type !== "session_start" || header.id !== id) throw Object.assign(new Error("native metadata Session identity differs"), { code: "session_identity_mismatch" });
33
36
  let bytes, settings;
34
37
  try { bytes = await readFile(settingsFile, "utf8"); settings = JSON.parse(bytes); }
35
38
  catch (error) { if (error instanceof SyntaxError || error.code === "ENOENT") return null; throw error; }
36
39
  const value = { header, settings, transcript_path: file, source: { path: settingsFile, sha256: createHash("sha256").update(bytes).digest("hex") } };
40
+ await onProgress(`metadata:${id}:${identity}`);
37
41
  this.cache.set(id, { identity, value }); return value;
38
42
  }
39
43
  }
@@ -3,6 +3,7 @@ export const lifecycleRoutes: {
3
3
  readonly "stage pause": "stage_pause"; readonly "stage resume": "stage_resume"; readonly "work start": "work_start";
4
4
  readonly "work finish": "work_finish"; readonly "work fail": "work_fail"; readonly "merge apply": "merge_apply";
5
5
  readonly "merge repair": "merge_repair"; readonly "run recovery accept": "recovery_accept";
6
+ readonly "delivery checks": "delivery_checks"; readonly "delivery execute": "delivery_execute"; readonly "delivery reconcile": "delivery_reconcile";
6
7
  };
7
8
  export type LifecycleOperation = typeof lifecycleRoutes[keyof typeof lifecycleRoutes];
8
9
  export const lifecycleInternalOptions: readonly ["invocation-id", "hook-event-id"];
@@ -2,7 +2,8 @@ export const lifecycleRoutes = {
2
2
  "session register": "session_register", "stage start": "stage_start", "stage finish": "stage_finish",
3
3
  "stage pause": "stage_pause", "stage resume": "stage_resume", "work start": "work_start",
4
4
  "work finish": "work_finish", "work fail": "work_fail", "merge apply": "merge_apply",
5
- "merge repair": "merge_repair", "run recovery accept": "recovery_accept"
5
+ "merge repair": "merge_repair", "run recovery accept": "recovery_accept",
6
+ "delivery checks": "delivery_checks", "delivery execute": "delivery_execute", "delivery reconcile": "delivery_reconcile"
6
7
  };
7
8
  export const lifecycleInternalOptions = ["invocation-id", "hook-event-id"];
8
9
  export function lifecycleOperationForArgv(argv) {
@@ -1,7 +1,8 @@
1
1
  export function invokeNativeHook(input: { bin: string; args: string[]; nodeBin?: string; payload: unknown; home?: string | undefined; cwd?: string; env?: NodeJS.ProcessEnv; timeoutMs?: number }): Promise<Record<string, unknown> & { timing?: Record<string, number> }>;
2
+ export function isStandaloneWorkStart(command: unknown): boolean;
2
3
  export function hookBudget(timeoutMs?: number, deadlineAt?: number | null): { deadlineAt: number; remaining(phase: string, maximum?: number): number; run<T>(action: () => T | Promise<T>, phase: string): Promise<T> };
3
4
  export function readNativeHookInput(stream: NodeJS.ReadStream, budget: { remaining(phase: string): number }, maxBytes?: number): Promise<string>;
4
5
  export function acceptNativeHookRequest(stateDir: string, binding: Record<string, unknown>, current?: { requests: Map<string, { binding: Record<string, unknown> }>; accepting?: Map<string, Promise<Record<string, unknown>>> } | null): Promise<Record<string, unknown>>;
5
- export function writeNativeHookFailure(stateDir: string, binding: Record<string, unknown> | undefined, error: unknown, budget: ReturnType<typeof hookBudget>): Promise<void>;
6
- export function readNativeHookFailures(stateDir: string, requests: Map<string, { current: unknown; binding: Record<string, unknown> }>, current: unknown, reject: (error: Error & { code?: string; details?: Record<string, unknown> }) => void): Promise<void>;
6
+ export function writeNativeHookFailure(stateDir: string, binding: Record<string, unknown> | undefined, error: unknown): Promise<void>;
7
+ export function readNativeHookFailures(stateDir: string, requests: Map<string, { current: unknown; binding: Record<string, unknown> }>, current: unknown, reject: (error: Error & { code?: string; details?: Record<string, unknown> }) => void | Promise<void>): Promise<void>;
7
8
  export function runBoundedCommand(input: { bin: string; args: string[]; nodeBin?: string; input?: string | null; cwd?: string; env?: NodeJS.ProcessEnv; timeoutMs?: number; timeoutCode?: string; inheritDeadline?: boolean; cleanupWithinBudget?: boolean; maintenanceProgress?: { action: string; processId: string | null; startupMs?: number; inactivityMs: number } | null; signal?: AbortSignal }): Promise<string>;
@@ -1,10 +1,43 @@
1
1
  import { spawn } from "node:child_process";
2
2
  import { createHash, randomUUID } from "node:crypto";
3
- import { readFile } from "node:fs/promises";
3
+ import { link, mkdir, open, readFile, unlink } from "node:fs/promises";
4
4
  import path from "node:path";
5
+ import { isDeepStrictEqual } from "node:util";
5
6
  import { reportedFailure } from "./process-json.mjs";
6
7
  import { errorRecord, reportedError } from "./operation-errors.mjs";
7
8
  import { writeJsonAtomic } from "./runner-events.mjs";
9
+ import { parseLifecycleCommand } from "./lifecycle-command.mjs";
10
+
11
+ export function isStandaloneWorkStart(command) {
12
+ if (typeof command !== "string") return false;
13
+ const parsed = parseLifecycleCommand(command);
14
+ return parsed.kind === "standalone" && parsed.supported !== false && !parsed.invocation.wrapped && parsed.invocation.operation === "work_start";
15
+ }
16
+
17
+ /** Record a native callback, not a grant and not a claim about its creator
18
+ * prompt. The CLI must match an issued command and ask the owning daemon to
19
+ * verify physical ancestry before its existing Work CAS can admit execution. */
20
+ export async function retainNativeWorkStart(stateDir, binding, observer, retained) {
21
+ if (!observer?.operationId || typeof observer.guard !== "function") return null;
22
+ observer.guard();
23
+ const prior = [...retained.values()].find(value => value.binding?.provider_session_id === binding.provider_session_id
24
+ && value.binding.tool_call_id === binding.tool_call_id && value.binding.event === binding.event);
25
+ if (prior) {
26
+ if (prior.binding.daemon_id !== binding.daemon_id || prior.binding.root_provider_session_id !== binding.root_provider_session_id
27
+ || prior.binding.native_command !== binding.native_command || prior.binding.native_cwd !== binding.native_cwd)
28
+ throw Object.assign(new Error("Native Work callback identity was reused with different input"), { code: "native_hook_identity_conflict" });
29
+ prior.guard(); return prior.binding;
30
+ }
31
+ if (!isStandaloneWorkStart(binding.native_command)) return null;
32
+ observer.requests ??= new Map();
33
+ const accepted = await acceptNativeHookRequest(stateDir, { ...binding, operation_id: observer.operationId,
34
+ turn_generation: observer.generation, native_creator: "unconfirmed", native_admission: "assigned_work_start",
35
+ native_ancestry: "pending", command_sha256: createHash("sha256").update(binding.native_command).digest("hex") }, observer);
36
+ observer.guard();
37
+ observer.requests.set(accepted.request_id, { current: observer, binding: accepted });
38
+ retained.set(accepted.request_id, { binding: accepted, guard: observer.guard });
39
+ return accepted;
40
+ }
8
41
 
9
42
  /** Retain accepted native requests in the existing operation hook ledger. */
10
43
  export async function acceptNativeHookRequest(stateDir, binding, current = null) {
@@ -25,7 +58,9 @@ export async function acceptNativeHookRequest(stateDir, binding, current = null)
25
58
  const requested = JSON.parse(await readFile(path.join(directory, "requested.json"), "utf8"));
26
59
  if (requested.operation_id !== binding.operation_id || requested.daemon_id !== binding.daemon_id || !["session.prompt", "session.create", "session.start"].includes(requested.operation)
27
60
  || requested.operation === "session.prompt" && requested.session_id && requested.session_id !== binding.root_provider_session_id) throw Object.assign(new Error("Native hook operation does not match its retained owner"), { code: "native_hook_unproven" });
28
- const receipt = { ...binding, schema_id: "dd-flow/hook-request@1", request_id: randomUUID() };
61
+ // The retained in-memory proof must be the same JSON value the CLI reads,
62
+ // including optional native fields omitted by JSON serialization.
63
+ const receipt = JSON.parse(JSON.stringify({ ...binding, schema_id: "dd-flow/hook-request@1", request_id: randomUUID() }));
29
64
  await writeJsonAtomic(path.join(directory, "hooks", `${receipt.request_id}.json`), receipt);
30
65
  return receipt;
31
66
  })();
@@ -33,11 +68,31 @@ export async function acceptNativeHookRequest(stateDir, binding, current = null)
33
68
  try { return await accepted; } finally { current?.accepting.delete(key); }
34
69
  }
35
70
 
36
- export async function writeNativeHookFailure(stateDir, binding, error, budget) {
71
+ export async function writeNativeHookFailure(stateDir, binding, error) {
37
72
  if (!binding?.request_id || !/^[A-Za-z0-9-]{1,128}$/.test(binding.request_id)) return;
38
- await budget.run(() => writeJsonAtomic(path.join(stateDir, "hook-failures", `${binding.request_id}.json`), {
73
+ // The observation budget may already have expired. It cannot also govern
74
+ // the single atomic write that records why that observation failed.
75
+ const directory = path.join(stateDir, "hook-failures"), file = path.join(directory, `${binding.request_id}.json`);
76
+ const value = {
39
77
  ...errorRecord(error), request_id: binding.request_id, observed_at: new Date().toISOString(), native_hook_binding: binding,
40
- }, () => budget.remaining("failure_receipt_commit")), "failure_receipt");
78
+ };
79
+ await mkdir(directory, { recursive: true });
80
+ const temporary = `${file}.${randomUUID()}.tmp`;
81
+ try {
82
+ const handle = await open(temporary, "wx", 0o600);
83
+ try { await handle.writeFile(`${JSON.stringify(value)}\n`); await handle.sync(); }
84
+ finally { await handle.close(); }
85
+ try { await link(temporary, file); }
86
+ catch (cause) {
87
+ if (cause.code !== "EEXIST") throw cause;
88
+ const retained = JSON.parse(await readFile(file, "utf8"));
89
+ if (retained.request_id !== binding.request_id || !isDeepStrictEqual(retained.native_hook_binding, binding))
90
+ throw Object.assign(new Error("Native hook failure receipt conflicts with its retained binding"), { code: "native_hook_identity_conflict" });
91
+ return; // The first bound failure remains authoritative on retry.
92
+ }
93
+ const handleDirectory = await open(directory, "r");
94
+ try { await handleDirectory.sync(); } finally { await handleDirectory.close(); }
95
+ } finally { await unlink(temporary).catch(cause => { if (cause.code !== "ENOENT") throw cause; }); }
41
96
  }
42
97
 
43
98
  /** Read only requests accepted for this exact live operation, never scan a mailbox. */
@@ -54,10 +109,10 @@ export async function readNativeHookFailures(stateDir, requests, current, reject
54
109
  }
55
110
  continue;
56
111
  }
57
- requests.delete(id);
58
112
  const error = reportedError(value, "Native hook failed");
59
113
  error.details = { ...error.details, native_hook_binding: accepted.binding };
60
- reject(error);
114
+ await reject(error);
115
+ requests.delete(id);
61
116
  }
62
117
  }
63
118
 
@@ -188,7 +243,7 @@ export async function runBoundedCommand({ bin, args, nodeBin = process.execPath,
188
243
  const abort = () => stop(Object.assign(new Error("Bounded CLI request cancelled"), { code: "process_maintenance_cancelled" }));
189
244
  signal?.addEventListener("abort", abort, { once: true });
190
245
  if (signal?.aborted) abort();
191
- child.stdout.setEncoding("utf8").on("data", chunk => { stdout += chunk; if (stdout.length > 1_048_576) stop(new Error("Native hook response exceeds protocol limit")); });
246
+ child.stdout.setEncoding("utf8").on("data", chunk => { stdout += chunk; });
192
247
  // Match the existing response bound: a legal bounded error tree may exceed
193
248
  // 64KiB when pretty-printed, plus maintenance phases preceding the receipt.
194
249
  child.stderr.setEncoding("utf8").on("data", chunk => {
@@ -1,5 +1,5 @@
1
1
  // Async iteration backpressures the native pipe while its existing evidence
2
- // sink drains. Bound one JSON reply, never lifetime diagnostics.
2
+ // sink drains. The input limit applies to requests, not owned replies.
3
3
  export const MAX_NATIVE_LINE_BYTES = 8 * 1024 * 1024;
4
4
  export async function readNativeLines(stream, onLine, onError) {
5
5
  let buffer = "";
@@ -10,26 +10,19 @@ export async function readNativeLines(stream, onLine, onError) {
10
10
  let at;
11
11
  while ((at = buffer.indexOf("\n")) >= 0) {
12
12
  const line = buffer.slice(0, at); buffer = buffer.slice(at + 1);
13
- assertLine(line);
14
13
  if (line.trim()) await onLine(line);
15
14
  }
16
- assertLine(buffer);
17
15
  }
18
- if (buffer.trim()) { assertLine(buffer); await onLine(buffer); }
16
+ if (buffer.trim()) await onLine(buffer);
19
17
  } catch (cause) { stream.pause(); await observationFailure(cause, onError); }
20
18
  }
21
19
  export function assertNativeLine(line, phase = "reply") {
22
- if (Buffer.byteLength(line) > MAX_NATIVE_LINE_BYTES) throw Object.assign(new Error(`Native JSON ${phase} exceeds the bounded capture limit`), { code: phase === "request" ? "native_request_limit" : "operation_observation_lost", details: { reason: `native_${phase}_limit`, limit_bytes: MAX_NATIVE_LINE_BYTES, ...(phase === "request" ? { effect: "no_effect" } : { native_outcome: "unknown" }) } });
20
+ if (phase === "request" && Buffer.byteLength(line) > MAX_NATIVE_LINE_BYTES) throw Object.assign(new Error("Native JSON request exceeds the bounded input limit"), { code: "native_request_limit", details: { reason: "native_request_limit", limit_bytes: MAX_NATIVE_LINE_BYTES, effect: "no_effect" } });
23
21
  }
24
- const assertLine = assertNativeLine;
25
22
  export function admitNativeRequestBuffer(buffer, socket, schema) {
26
23
  try { assertNativeLine(buffer, "request"); return true; }
27
24
  catch (error) { socket.pause(); socket.end(`${JSON.stringify({ schema_id: schema, id: null, ok: false, error: { code: error.code, message: error.message, details: error.details } })}\n`, () => socket.destroy()); return false; }
28
25
  }
29
- export function retainNativeReplyBuffer(buffer, finish) {
30
- try { assertNativeLine(buffer); return true; }
31
- catch (error) { finish(error); return false; }
32
- }
33
26
  export async function streamNativeDiagnostics(stream, onChunk, onError) {
34
27
  stream.setEncoding("utf8");
35
28
  try { for await (const chunk of stream.iterator({ destroyOnReturn: false })) await onChunk(String(chunk)); }
@@ -3,7 +3,7 @@ import { managedFlowEnv } from './managed-flow-env.mjs';
3
3
  import * as Plugin from "../vendor/opencode-plugin.mjs";
4
4
  import { hookBudget, invokeNativeHook, writeNativeHookFailure } from "./native-hook-command.mjs";
5
5
  import { callDaemon } from "./dd-opencode-daemon.mjs";
6
- import { errorRecord } from "./operation-errors.mjs";
6
+ import { errorRecord, isNativeHookObservationLoss } from "./operation-errors.mjs";
7
7
 
8
8
  /** Launch-bound server plugin; never uses a process-global 'last Session'. */
9
9
  export function makeOpenCodePlugin(binding, invoke = invokeNativeHook, resolve = callDaemon) {
@@ -14,7 +14,21 @@ export function makeOpenCodePlugin(binding, invoke = invokeNativeHook, resolve =
14
14
  const input = event.input;
15
15
  if (!input || typeof input !== "object" || typeof input.command !== "string") throw new Error("OpenCode shell input is unavailable");
16
16
  const identity = await budget.run(() => resolve(binding.stateDir, "hook.resolve", { sessionId: event.sessionID,
17
- payload: { phase, message_id: event.messageID, tool_call_id: event.id, input } }, budget.remaining("identity")), "identity");
17
+ payload: { phase, message_id: event.messageID, tool_call_id: event.id, input }, ...(binding.nativeWorkStartAdmission ? { deferWorkStart: true } : {}) }, budget.remaining("identity")), "identity");
18
+ if (identity.observationOnly === true) {
19
+ if (!binding.nativeWorkStartAdmission || identity.daemonId !== binding.daemonId || identity.sessionId !== event.sessionID
20
+ || identity.messageId !== event.messageID || identity.toolCallId !== event.id || identity.command !== input.command)
21
+ throw Object.assign(new Error("Ordinary native callback binding differs"), { code: "native_hook_unproven" });
22
+ return { observationOnly: true };
23
+ }
24
+ if (phase === "before" && identity.fastAck === true) {
25
+ const proof = identity.nativeHookBinding;
26
+ if (!binding.nativeWorkStartAdmission || proof?.native_admission !== "assigned_work_start" || proof.source_harness !== "opencode-server"
27
+ || proof.daemon_id !== binding.daemonId || proof.provider_session_id !== event.sessionID
28
+ || proof.tool_call_id !== `${event.messageID}:${event.id}` || proof.native_command !== input.command)
29
+ throw Object.assign(new Error("Deferred native callback binding differs"), { code: "native_hook_unproven" });
30
+ return { fastAck: true };
31
+ }
18
32
  const info = identity?.nativeSession;
19
33
  if (!identity?.nativeHookBinding || info?.id !== event.sessionID || !info.directory) throw Object.assign(new Error("OpenCode Session identity is unavailable"), { code: "native_hook_unproven" });
20
34
  const cwd = path.resolve(info.directory, input.workdir ?? ".");
@@ -28,23 +42,26 @@ export function makeOpenCodePlugin(binding, invoke = invokeNativeHook, resolve =
28
42
  profile: { provider: info.model?.providerID, model: info.model?.id, variant: info.model?.variant ?? "default", agent: event.agent },
29
43
  input: { ...input, workdir: cwd }, ...(outcome ? { outcome } : {}) };
30
44
  };
31
- const deliver = async (payload, budget) => {
45
+ const deliver = async (payload, budget, verified = false) => {
32
46
  const identity = { nativeHookBinding: payload.native_hook_binding };
33
47
  try {
34
48
  const result = await invoke({ bin: binding.ddFlowBin, nodeBin: binding.nodeBin,
35
49
  args: ["opencode", "event", "handle", "--project-root", binding.projectRoot, "--json"],
36
50
  payload: { ...payload, native_hook_binding: identity.nativeHookBinding }, home: binding.ddFlowHome, timeoutMs: budget.remaining("admission") });
37
- await budget.run(() => resolve(binding.stateDir, "hook.verify", { binding: identity.nativeHookBinding }, budget.remaining("generation")), "generation");
51
+ if (!verified) await budget.run(() => resolve(binding.stateDir, "hook.verify", { binding: identity.nativeHookBinding, retain_after: result?.observed === true }, budget.remaining("generation")), "generation");
38
52
  if (result?.permissionDecision === "deny" || result?.hookSpecificOutput?.permissionDecision === "deny") throw Object.assign(new Error("dd-flow denied native shell admission"), { code: "native_hook_rejected" });
39
53
  return result;
40
54
  } catch (error) {
41
55
  error.details = { ...error.details, native_hook_binding: identity.nativeHookBinding, ...(payload.phase === "after" ? { effect: "unknown", tool_outcome: payload.outcome } : {}) };
42
- try { await writeNativeHookFailure(binding.stateDir, identity.nativeHookBinding, error, budget); await resolve(binding.stateDir, "hook.failed", {}, budget.remaining("failure_notification")); }
56
+ let storageError;
57
+ try { await writeNativeHookFailure(binding.stateDir, identity.nativeHookBinding, error); }
43
58
  catch (storage) {
59
+ storageError = storage;
44
60
  error.details.receipt_error = { code: storage.code ?? "hook_receipt_write_failed", message: storage.message };
45
- try { await resolve(binding.stateDir, "hook.failed", { binding: identity.nativeHookBinding, failure: errorRecord(error) }, budget.remaining("failure_notification")); }
46
- catch (notify) { error.details.receipt_error.notification_error = notify.code ?? "hook_notification_failed"; }
47
61
  }
62
+ if (storageError?.code !== "native_hook_identity_conflict") try {
63
+ await resolve(binding.stateDir, "hook.failed", storageError ? { binding: identity.nativeHookBinding, failure: errorRecord(error) } : {}, storageError ? 5_000 : budget.remaining("failure_notification"));
64
+ } catch (notify) { if (storageError) error.details.receipt_error.notification_error = notify.code ?? "hook_notification_failed"; }
48
65
  throw error;
49
66
  }
50
67
  };
@@ -57,6 +74,7 @@ export function makeOpenCodePlugin(binding, invoke = invokeNativeHook, resolve =
57
74
  if (event.input.timeout === undefined) event.input.timeout = 0;
58
75
  if (!binding.ddFlowBin) return;
59
76
  const budget = hookBudget(), payload = await envelope("before", event, budget);
77
+ if (payload.fastAck === true || payload.observationOnly === true) return;
60
78
  event.input = { ...event.input, workdir: payload.session.directory };
61
79
  const result = await deliver({ ...payload, input: event.input }, budget);
62
80
  if (result?.observed) {
@@ -75,8 +93,17 @@ export function makeOpenCodePlugin(binding, invoke = invokeNativeHook, resolve =
75
93
  const success = event.status === "completed" && output?.status !== "running" && output?.exit === 0 && !output?.timeout && !output?.signal;
76
94
  const outcome = { status: success ? "completed" : output?.status === "running" ? "running" : "failed",
77
95
  exit_code: output?.exit ?? null, signal: output?.signal ?? null, shell_id: output?.shellID ?? null };
78
- const payload = await envelope("after", event, budget, outcome);
79
- await deliver({ ...payload, ...(original ? { event_id: original.event_id } : {}) }, budget);
96
+ // Use the exact admitted before delivery. A later prompt may already be
97
+ // active; resolving against that prompt would misattribute this result.
98
+ try {
99
+ await budget.run(() => resolve(binding.stateDir, "hook.verify", { binding: original.native_hook_binding, phase: "after" }, budget.remaining("generation")), "generation");
100
+ await deliver({ ...original, phase: "after", outcome }, budget, true);
101
+ } catch (error) {
102
+ if (!isNativeHookObservationLoss(error)) throw error;
103
+ // An observation timeout is not the already-finished tool's outcome.
104
+ process.stderr.write(`${JSON.stringify({ kind: "native_hook_observation_unavailable", code: error.code, event_id: original.event_id })}\n`);
105
+ return; // Retain the original binding for a later exact observation.
106
+ }
80
107
  participating.delete(key(event));
81
108
  await ctx.storage.remove(key(event));
82
109
  });
@@ -1,5 +1,18 @@
1
1
  // An observer losing its response does not establish that a provider Turn failed.
2
2
  export const OPERATION_ERROR_CONTRACT_VERSION = "operation-errors@2";
3
+ // A lost hook response does not prove that the issued CLI or Turn failed.
4
+ // Do not downgrade a structured rejection merely because a transport wrapped it.
5
+ export function isNativeHookObservationLoss(error) {
6
+ if (!["native_hook_timeout", "native_hook_failed"].includes(error?.code)) return false;
7
+ const details = error?.details;
8
+ const transport = code => ["ETIMEDOUT", "ECONNRESET", "ECONNREFUSED", "EPIPE", "native_hook_timeout", "native_hook_failed"].includes(code) || isObservationLoss({ code });
9
+ if (details?.decision === "deny" || details?.permissionDecision === "deny"
10
+ || details?.hookSpecificOutput?.permissionDecision === "deny" || details?.lifecycle_outcome?.effect === "no_effect"
11
+ || details?.disposition === "pre_cli_shell_refusal"
12
+ || typeof details?.primary_error?.code === "string" && !transport(details.primary_error.code)
13
+ || typeof error?.cause?.code === "string" && !transport(error.cause.code)) return false;
14
+ return true;
15
+ }
3
16
  export function redactDiagnostic(value) {
4
17
  return value.replace(/\b(Bearer|Basic)\s+[^\s"']+/gi, "$1 <redacted>")
5
18
  .replace(/(^|[^\w-])([\w-]+)(["']?\s*[=:]\s*["']?)([^\s"',;&}]+)/g,
@@ -8,7 +8,7 @@ import { writeJsonAtomic } from './runner-events.mjs';
8
8
  export const OPERATION_PROGRESS_CONTRACT = 'dd-flow/operation-progress@1';
9
9
  const context = new AsyncLocalStorage(), flights = new Map();
10
10
  const fields = ['operation_id', 'daemon_id', 'operation', 'params_sha256', 'owner_id', 'generation'];
11
- const phases = new Set(['accepted','ownership_confirmed','admission_confirmed','native_dispatched','native_progress','native_outcome_retained','settlement_started','settlement_completed','settlement_pending']);
11
+ const phases = new Set(['accepted','ownership_confirmed','admission_confirmed','native_dispatched','native_progress','verification_progress','native_outcome_retained','settlement_started','settlement_completed','settlement_pending']);
12
12
  export function operationDirectory(root,id) {
13
13
  if (typeof root!=='string' || !path.isAbsolute(root) || typeof id!=='string' || !id || id.length>1_024) throw Object.assign(new Error('invalid operation observation binding'),{code:'operation_progress_invalid'});
14
14
  return path.join(root, 'operations', createHash('sha256').update(id).digest('hex'));
@@ -43,6 +43,7 @@ export async function readOperationProgress(root, id, expected = {}) {
43
43
  || ['daemon_id','owner_id'].some(field=>progress[field]!==null && (typeof progress[field]!=='string' || !progress[field] || progress[field].length>1_024))
44
44
  || progress.generation!==null && (!Number.isSafeInteger(progress.generation) || progress.generation<0)
45
45
  || !Number.isSafeInteger(progress.sequence) || progress.sequence < 1 || !phases.has(progress.phase)
46
+ || progress.phase === 'verification_progress' && (typeof progress.native_request_id !== 'string' || !progress.native_request_id || progress.native_request_id.length > 256)
46
47
  || !Number.isFinite(Date.parse(requested.requested_at))
47
48
  || !Number.isFinite(Date.parse(progress.observed_at)) || Date.parse(progress.observed_at) > Date.now()
48
49
  || Date.parse(progress.observed_at) < Date.parse(requested.requested_at)
@@ -65,7 +66,10 @@ export function publishOperationProgress(root, id, phase, metadata = {}) {
65
66
  const observedAt = metadata.observed_at ?? new Date().toISOString();
66
67
  if (prior && Date.parse(observedAt)<Date.parse(prior.observed_at)) return prior;
67
68
  // A later successful ownership gate is fresh liveness evidence while admission waits.
68
- if (prior && phase === prior.phase && phase !== 'native_progress'
69
+ if (phase === 'verification_progress' && (typeof metadata.native_request_id !== 'string' || !metadata.native_request_id || metadata.native_request_id.length > 256))
70
+ throw Object.assign(new Error('verification progress requires an actual checkpoint'), { code: 'operation_progress_invalid' });
71
+ if (prior && phase === 'verification_progress' && prior.phase === phase && prior.native_request_id === metadata.native_request_id) return prior;
72
+ if (prior && phase === prior.phase && !['native_progress', 'verification_progress'].includes(phase)
69
73
  && (phase !== 'ownership_confirmed' || Date.parse(observedAt) <= Date.parse(prior.observed_at))) return prior;
70
74
  if (prior && phase === 'native_progress' && Date.parse(observedAt) - Date.parse(prior.observed_at) < 1_000) return prior;
71
75
  const value = { schema_id: OPERATION_PROGRESS_CONTRACT,
@@ -52,9 +52,9 @@ async function command(bin, args, json, { cwd, env = {}, input = null, onProgres
52
52
  return await new Promise((resolve, reject) => {
53
53
  const child = spawn(target.command, target.args, { cwd, env: { ...process.env, ...env },
54
54
  detached: process.platform !== "win32", stdio: [input === null ? "ignore" : "pipe", "pipe", "pipe"] });
55
- const maxOutput = 8 * 1024 * 1024, maxTail = 64 * 1024, maxLine = 1024 * 1024;
55
+ const maxTail = 64 * 1024, maxLine = 1024 * 1024;
56
56
  let stdout = "", stderr = "", pendingStderr = "", settled = false, primary = null, cursor = 0, droppingStructured = false;
57
- let outputBytes = 0, cleanupTimer = null, inputError = null, termination = null, retainedError = null, prettyError = "";
57
+ let cleanupTimer = null, inputError = null, termination = null, retainedError = null, prettyError = "";
58
58
  const phases = new Set();
59
59
  const clock = timeoutMs === null ? null : new ObservationClock({ timeoutMs: startupMs ?? timeoutMs });
60
60
  const observeSetup = setupObserver(env.DD_EVAL_OPERATION_ID ?? process.env.DD_EVAL_OPERATION_ID);
@@ -144,9 +144,8 @@ async function command(bin, args, json, { cwd, env = {}, input = null, onProgres
144
144
  signal?.addEventListener("abort", abort, { once: true });
145
145
  child.stdout.setEncoding("utf8").on("data", data => {
146
146
  productive();
147
- diagnostic(data, "stdout"); outputBytes += Buffer.byteLength(data);
148
- if (outputBytes > maxOutput) stop(failure("command final output exceeds 8 MiB", "command_output_limit"));
149
- else stdout += data;
147
+ diagnostic(data, "stdout");
148
+ stdout += data;
150
149
  });
151
150
  child.stderr.setEncoding("utf8").on("data", data => {
152
151
  diagnostic(data, "stderr"); stderr = (stderr + data).slice(-maxTail);
@@ -162,14 +162,22 @@ export function agyToolObservation(step, rootSessionId) {
162
162
  /** Native AGY JSONL records have planner tool invocations, not matching live
163
163
  * step IDs. GENERIC result text is deliberately not guessed into success. */
164
164
  export function agyTranscriptObservations(text, sessionId, parentSessionId, source = {}) {
165
- const events = [], lines = text.split("\n"); let recognized = false;
166
- const session = identity("antigravity-cli", sessionId);
165
+ const reducer = createAgyTranscriptObserver(sessionId, parentSessionId, source), lines = text.split("\n");
167
166
  for (const [line, bytes] of lines.entries()) {
168
167
  if (!bytes.trim()) continue;
169
- let record; try { record = JSON.parse(bytes); } catch { events.push({ session, reason: line === lines.length - 1 ? "agy_transcript_partial_tail" : "agy_transcript_invalid_record" }); continue; }
170
- if (!record || typeof record !== "object" || Array.isArray(record)) { events.push({ session, reason: "agy_transcript_shape_unsupported" }); continue; }
171
- if (record.conversation_id && record.conversation_id !== sessionId) { events.push({ session, reason: "agy_transcript_session_mismatch" }); continue; }
172
- if (!["SYSTEM_MESSAGE", "USER_INPUT", "PLANNER_RESPONSE", "GENERIC", "TOOL_RESPONSE", "CHECKPOINT"].includes(record.type)) { events.push({ session, reason: "agy_transcript_shape_unsupported" }); continue; }
168
+ try { reducer.add(JSON.parse(bytes), line + 1); }
169
+ catch { reducer.gap(line === lines.length - 1 ? "agy_transcript_partial_tail" : "agy_transcript_invalid_record"); }
170
+ }
171
+ return reducer.finish();
172
+ }
173
+
174
+ export function createAgyTranscriptObserver(sessionId, parentSessionId, source = {}) {
175
+ const events = []; let recognized = false;
176
+ const session = identity("antigravity-cli", sessionId);
177
+ function add(record, line) {
178
+ if (!record || typeof record !== "object" || Array.isArray(record)) { events.push({ session, reason: "agy_transcript_shape_unsupported" }); return; }
179
+ if (record.conversation_id && record.conversation_id !== sessionId) { events.push({ session, reason: "agy_transcript_session_mismatch" }); return; }
180
+ if (!["SYSTEM_MESSAGE", "USER_INPUT", "PLANNER_RESPONSE", "GENERIC", "TOOL_RESPONSE", "CHECKPOINT"].includes(record.type)) { events.push({ session, reason: "agy_transcript_shape_unsupported" }); return; }
173
181
  recognized = true;
174
182
  if (record.tool_calls !== undefined && !Array.isArray(record.tool_calls)) events.push({ session, reason: "agy_transcript_shape_unsupported" });
175
183
  if (record.truncated || record.truncation || typeof record.content === "string" && /\[.*truncat|<.*truncat/i.test(record.content)) events.push({ session, reason: "agy_transcript_truncated" });
@@ -177,12 +185,14 @@ export function agyTranscriptObservations(text, sessionId, parentSessionId, sour
177
185
  if (!Number.isInteger(record.step_index) || !call || typeof call.name !== "string" || !call.name) { events.push({ session, reason: "agy_transcript_tool_identity_missing" }); continue; }
178
186
  const id = call.id ?? call.tool_call_id;
179
187
  events.push({ session, parent_session: identity("antigravity-cli", parentSessionId), epoch: "native_transcript", call_id: id ?? `${record.step_index}:${index}`, name: call.name,
180
- status: "unknown", reason: "agy_transcript_tool_outcome_unproven", source: { ...source, channel: "agy.native_transcript", line: line + 1, step_index: record.step_index } });
188
+ status: "unknown", reason: "agy_transcript_tool_outcome_unproven", source: { ...source, channel: "agy.native_transcript", line, step_index: record.step_index } });
181
189
  }
182
190
  }
183
- if (!recognized) events.push({ session, reason: "agy_transcript_shape_unsupported" });
184
- else events.push({ session, parent_session: identity("antigravity-cli", parentSessionId), source_observed: true, source: { ...source, channel: "agy.native_transcript" } });
185
- return events;
191
+ return { add, gap: reason => events.push({ session, reason }), finish: () => {
192
+ if (!recognized) events.push({ session, reason: "agy_transcript_shape_unsupported" });
193
+ else events.push({ session, parent_session: identity("antigravity-cli", parentSessionId), source_observed: true, source: { ...source, channel: "agy.native_transcript" } });
194
+ return events;
195
+ } };
186
196
  }
187
197
 
188
198
  /** Read-only replay, shared by accounting and offline diagnosis. A journal
@@ -0,0 +1,109 @@
1
+ // This map is optional evidence, not a replacement for the ordinary hook.
2
+ // A turn becomes authoritative only after the bridge has matched the native
3
+ // inputId it supplied on session/send and echoed by native turn.started.
4
+ export class ZcodeNativeHookOwnership {
5
+ constructor() { this.turns = new Map(); this.invalidTurns = new Set(); this.requests = new Map(); this.observers = new Map(); this.managedSessions = new Set(); this.buffer = new Map(); this.hooks = new Map(); this.waiters = new Map(); }
6
+ key(...values) { return JSON.stringify(values); }
7
+ signature(message) { const update = message.params.update; return this.key(update.toolCallId, update.rawInput, update._meta?.zcodeRuntime); }
8
+ register(operationId, observer, sessionId) { if (operationId && observer) { this.observers.set(operationId, observer); if (typeof sessionId === 'string' && sessionId) this.managedSessions.add(sessionId); } }
9
+ isManaged(sessionId, pending) { return this.managedSessions.has(sessionId) || [...pending.values()].some(request => request.method === 'session/prompt' && request.sessionId === sessionId && request.nativeHookOwner); }
10
+ bind(params, pending) {
11
+ const { sessionId, requestId, nativeTurnId } = params ?? {};
12
+ const owner = pending?.nativeHookOwner;
13
+ if (!owner || pending.method !== 'session/prompt' || pending.id !== requestId || pending.sessionId !== sessionId
14
+ || typeof nativeTurnId !== 'string' || !nativeTurnId || !owner.operationId || !owner.rootProviderSessionId || !owner.valid()) return [];
15
+ const turnKey = this.key(sessionId, nativeTurnId), requestKey = this.key(sessionId, requestId);
16
+ if (this.invalidTurns.has(turnKey)) return [];
17
+ const priorTurn = this.turns.get(turnKey), priorRequest = this.requests.get(requestKey);
18
+ if (priorTurn && priorTurn.requestKey !== requestKey || priorRequest && priorRequest !== turnKey) {
19
+ this.invalidTurns.add(turnKey);
20
+ if (priorRequest && priorRequest !== 'conflict') this.invalidTurns.add(priorRequest);
21
+ if (priorTurn) priorTurn.invalid = true;
22
+ if (priorRequest && this.turns.has(priorRequest)) this.turns.get(priorRequest).invalid = true;
23
+ this.requests.set(requestKey, 'conflict'); this.buffer.delete(turnKey); return [];
24
+ }
25
+ if (!priorTurn) this.turns.set(turnKey, { requestKey, owner, tools: new Map(), waiting: [], seen: new Set(), invalid: false });
26
+ this.requests.set(requestKey, turnKey); this.managedSessions.add(sessionId); owner.retainNativeHooks = true;
27
+ const buffered = this.buffer.get(turnKey) ?? new Map(); this.buffer.delete(turnKey);
28
+ return [...buffered.values()].flatMap(message => this.observeBound(message, this.turns.get(turnKey)));
29
+ }
30
+ observe(message, mayBuffer, managed) {
31
+ const native = message?.params?.update?._meta?.zcodeRuntime;
32
+ if (message?.method !== 'session/update' || message.params.update?.sessionUpdate !== 'tool_call') return null;
33
+ if (!Object.hasOwn(native ?? {}, 'nativeTurnId')) return managed && (native?.source === 'subagent' || native?.childSessionId) ? [] : null;
34
+ const { sessionId } = message.params, { nativeTurnId } = native;
35
+ if (typeof sessionId !== 'string' || typeof nativeTurnId !== 'string' || !nativeTurnId) return managed ? [] : null;
36
+ const key = this.key(sessionId, nativeTurnId), turn = this.turns.get(key);
37
+ if (this.invalidTurns.has(key)) return [];
38
+ if (turn) return this.observeBound(message, turn);
39
+ if (!managed) return null;
40
+ if (mayBuffer) { const buffer = this.buffer.get(key) ?? new Map(); buffer.set(this.signature(message), message); this.buffer.set(key, buffer); }
41
+ return [];
42
+ }
43
+ discardUnbound(sessionId) {
44
+ for (const key of this.buffer.keys()) if (JSON.parse(key)[0] === sessionId) this.buffer.delete(key);
45
+ }
46
+ isCurrent(message, pending) {
47
+ const native = message?.params?.update?._meta?.zcodeRuntime;
48
+ if (!Object.hasOwn(native ?? {}, 'nativeTurnId')) return !(native?.source === 'subagent' || native?.childSessionId) || !this.isManaged(message.params.sessionId, pending);
49
+ const turn = this.turns.get(this.key(message.params.sessionId, native.nativeTurnId));
50
+ if (!turn) return !this.isManaged(message.params.sessionId, pending);
51
+ return Boolean(turn && !turn.invalid && turn.owner.valid() && [...pending.values()].some(request =>
52
+ request.method === 'session/prompt' && request.sessionId === message.params.sessionId && request.nativeHookOwner === turn.owner));
53
+ }
54
+ observeBound(message, turn) {
55
+ if (turn.invalid || !turn.owner.valid()) return [];
56
+ const signature = this.signature(message); if (turn.seen.has(signature)) return [];
57
+ turn.seen.add(signature);
58
+ // An unresolved native parent may arrive after the root turn returned;
59
+ // keep these exact, deduplicated facts only for this daemon lifetime.
60
+ turn.waiting.push(message);
61
+ const routed = [];
62
+ let progress = true;
63
+ while (progress) {
64
+ progress = false;
65
+ for (let i = 0; i < turn.waiting.length; i++) {
66
+ const event = turn.waiting[i], update = event.params.update, native = update._meta?.zcodeRuntime ?? {};
67
+ const tool = update.toolCallId, child = native.childSessionId;
68
+ if (typeof tool !== 'string' || !tool) { turn.waiting.splice(i--, 1); continue; }
69
+ if (native.source === 'subagent' && !child) { turn.waiting.splice(i--, 1); continue; }
70
+ const parent = child ? turn.tools.get(native.parentToolCallId) : null;
71
+ if (child && !parent) continue;
72
+ const session = child || turn.owner.rootProviderSessionId;
73
+ if (typeof session !== 'string' || !session || child && session === parent) { turn.waiting.splice(i--, 1); continue; }
74
+ const existing = turn.tools.get(tool);
75
+ if (existing && existing !== session) { turn.invalid = true; return []; }
76
+ turn.tools.set(tool, session); turn.waiting.splice(i--, 1); progress = true;
77
+ const command = update.rawInput?.command ?? update.rawInput?.cmd;
78
+ if (child && typeof native.childToolCallId === 'string' && native.childToolCallId && typeof command === 'string') {
79
+ const key = this.key(session, native.childToolCallId), fact = { owner: turn.owner, root: turn.owner.rootProviderSessionId, parent, session, tool: native.childToolCallId, command, message: event, turn };
80
+ const prior = this.hooks.get(key);
81
+ if (prior && (prior.owner !== fact.owner || prior.parent !== parent || prior.command !== command)) { this.hooks.set(key, null); turn.invalid = true; turn.waiting.length = 0; return []; }
82
+ else if (prior === undefined) this.hooks.set(key, fact);
83
+ for (const wake of this.waiters.get(key) ?? []) wake();
84
+ }
85
+ const observer = this.observers.get(turn.owner.operationId);
86
+ if (observer) routed.push({ message: event, observer, owner: turn.owner });
87
+ }
88
+ }
89
+ return routed;
90
+ }
91
+ fact(session, tool, command) {
92
+ const value = this.hooks.get(this.key(session, tool));
93
+ return value && !value.turn.invalid && value.owner.valid() && value.command === command ? value : null;
94
+ }
95
+ async wait(session, tool, command, deadlineAt) {
96
+ if (this.fact(session, tool, command)) return;
97
+ const remaining = deadlineAt - Date.now();
98
+ if (!Number.isSafeInteger(deadlineAt) || remaining <= 0 || remaining > 30_000) return;
99
+ const waitMs = Math.floor(remaining / 3); if (!waitMs) return;
100
+ const key = this.key(session, tool);
101
+ await new Promise(resolve => {
102
+ const waiters = this.waiters.get(key) ?? new Set();
103
+ const wake = () => { if (!this.fact(session, tool, command) && Date.now() < until) return; clearTimeout(timer); waiters.delete(wake); if (!waiters.size) this.waiters.delete(key); resolve(); };
104
+ const until = Date.now() + waitMs, timer = setTimeout(() => { waiters.delete(wake); if (!waiters.size) this.waiters.delete(key); resolve(); }, waitMs);
105
+ waiters.add(wake); this.waiters.set(key, waiters);
106
+ wake();
107
+ });
108
+ }
109
+ }
@@ -11,6 +11,7 @@
11
11
  "evidence": {"type": "array", "items": {"type": "object", "additionalProperties": false, "required": ["obligation_ref", "refs"], "properties": {"obligation_ref": {"type": "string", "minLength": 1}, "refs": {"type": "array", "uniqueItems": true, "items": {"type": "string", "minLength": 1}}}}},
12
12
  "deviations": {"type": "array", "items": {"type": "string", "minLength": 1}},
13
13
  "blockers": {"type": "array", "items": {"type": "string", "minLength": 1}},
14
- "resolutions": {"type": "array", "uniqueItems": true, "items": {"type": "object", "additionalProperties": false, "required": ["finding_ref", "summary", "evidence_refs"], "properties": {"finding_ref": {"type": "string", "minLength": 1}, "summary": {"type": "string", "minLength": 1}, "evidence_refs": {"type": "array", "minItems": 1, "uniqueItems": true, "items": {"type": "string", "minLength": 1}}}}}
14
+ "resolutions": {"type": "array", "uniqueItems": true, "items": {"type": "object", "additionalProperties": false, "required": ["finding_ref", "summary", "evidence_refs"], "properties": {"finding_ref": {"type": "string", "minLength": 1}, "disposition": {"enum": ["fixed", "already_satisfied", "disputed"]}, "summary": {"type": "string", "minLength": 1}, "evidence_refs": {"type": "array", "minItems": 1, "uniqueItems": true, "items": {"type": "string", "minLength": 1}}}}},
15
+ "document_resolutions": {"type": "array", "uniqueItems": true, "items": {"type": "object", "additionalProperties": false, "required": ["path", "disposition", "summary", "evidence_refs"], "properties": {"path": {"type": "string", "minLength": 1}, "disposition": {"const": "already_satisfied"}, "summary": {"type": "string", "minLength": 1}, "evidence_refs": {"type": "array", "minItems": 1, "uniqueItems": true, "items": {"type": "string", "minLength": 1}}}}}
15
16
  }
16
17
  }