@bridge_gpt/mcp-server 0.2.62 → 0.2.66

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/README.md +66 -15
  2. package/build/agent-launchers/claude-executor-adapter.js +145 -20
  3. package/build/agent-launchers/executor-adapter.js +81 -2
  4. package/build/agents.generated.js +10 -1
  5. package/build/bounded-wait.js +32 -1
  6. package/build/bridge-config.js +104 -0
  7. package/build/codex-bridge-profile.js +10 -1
  8. package/build/commands.generated.js +7 -6
  9. package/build/conductor/bridge-api-client.js +565 -32
  10. package/build/conductor/bring-up-facts.js +3 -1
  11. package/build/conductor/doctor.js +23 -14
  12. package/build/conductor/epic-child-continuation.js +351 -0
  13. package/build/conductor/epic-implementer-cli.js +154 -17
  14. package/build/conductor/git-hooks.js +529 -118
  15. package/build/conductor/guided-repair.js +323 -0
  16. package/build/conductor/hook-runtime-qualification.js +340 -0
  17. package/build/conductor/readiness-cli.js +30 -9
  18. package/build/conductor/readiness-sections.js +5 -4
  19. package/build/conductor/readiness.js +45 -12
  20. package/build/conductor/recovery-cli.js +859 -27
  21. package/build/conductor/recovery-operations.js +574 -5
  22. package/build/conductor/repair-contract.js +69 -3
  23. package/build/conductor-bundle-artifacts.js +1 -0
  24. package/build/credential-store.js +52 -13
  25. package/build/diagnostic-text.js +36 -0
  26. package/build/docs.generated.js +3 -2
  27. package/build/doctor.js +147 -21
  28. package/build/drive-epic-key.js +12 -0
  29. package/build/drive-epic.js +1537 -125
  30. package/build/epic-coherence-input-cli.js +137 -0
  31. package/build/epic-coherence-input.js +920 -0
  32. package/build/epic-coherence-input.test-fixtures.js +175 -0
  33. package/build/epic-implementer-bin.js +41 -33
  34. package/build/epic-implementer-bundle-cli.js +14 -1
  35. package/build/epic-launch-diagnostic.js +31 -0
  36. package/build/epic-launch-profile.js +58 -19
  37. package/build/epic-run-status.js +5 -0
  38. package/build/epic-shared/bridge-client.js +46 -4
  39. package/build/executor/cli.js +39 -0
  40. package/build/executor/completion-contract.js +423 -0
  41. package/build/executor/completion-supervision.js +291 -0
  42. package/build/executor/correction-dispatch.js +60 -0
  43. package/build/executor/credentials.js +3 -1
  44. package/build/executor/deps.js +65 -1
  45. package/build/executor/env.js +12 -0
  46. package/build/executor/heartbeat.js +13 -3
  47. package/build/executor/http-client.js +91 -4
  48. package/build/executor/integration-job.js +6 -1
  49. package/build/executor/job-errors.js +61 -4
  50. package/build/executor/job-log-registry.js +340 -73
  51. package/build/executor/job-runner.js +1550 -55
  52. package/build/executor/job-types.js +9 -6
  53. package/build/executor/preflight.js +189 -1
  54. package/build/executor/process-containment.js +756 -0
  55. package/build/executor/process.js +235 -5
  56. package/build/executor/prompt-spec.js +366 -1
  57. package/build/executor/provider-checks.js +69 -0
  58. package/build/executor/rebase-finalization.js +351 -0
  59. package/build/executor/reconcile-overlap-job.js +77 -84
  60. package/build/executor/required-verification.js +313 -0
  61. package/build/executor/required-work-ledger.js +1121 -0
  62. package/build/executor/results.js +142 -0
  63. package/build/executor/resume-pre-spawn.js +87 -4
  64. package/build/executor/runner.js +168 -0
  65. package/build/executor/stale-artifacts.js +5 -4
  66. package/build/executor/test-declaration.js +20 -0
  67. package/build/executor/types.js +13 -0
  68. package/build/executor/watch-cli.js +35 -8
  69. package/build/executor/worker-command.js +70 -34
  70. package/build/executor/worker-finalization.js +168 -26
  71. package/build/executor/worker-guard-hook.js +75 -14
  72. package/build/executor/worker-log.js +137 -70
  73. package/build/executor/worktree-gc.js +30 -6
  74. package/build/executor/worktree.js +13 -0
  75. package/build/executor-launch.js +658 -0
  76. package/build/index.js +594 -1152
  77. package/build/init.js +107 -4
  78. package/build/install-bridge-conductor.js +231 -41
  79. package/build/install-bridge.js +284 -20
  80. package/build/install-doctor.js +172 -30
  81. package/build/install-reexec.js +69 -7
  82. package/build/macos-terminal-permission-guidance.js +16 -0
  83. package/build/merge-pull-request.js +1 -1
  84. package/build/model-thresholds-tool.js +185 -0
  85. package/build/pipelines.generated.js +9 -8
  86. package/build/plan-discovery.js +137 -0
  87. package/build/plan-epic-implementer-eligibility.js +2 -2
  88. package/build/plane/cli.js +37 -3
  89. package/build/plane/member-logs.js +3 -0
  90. package/build/plane/member-roster.js +7 -1
  91. package/build/plane/shutdown.js +59 -1
  92. package/build/plane/status.js +4 -0
  93. package/build/plane/supervisor.js +146 -0
  94. package/build/plane/worker-exit-code.js +2 -0
  95. package/build/project-root.js +99 -0
  96. package/build/readme.generated.js +1 -1
  97. package/build/review-tickets.js +156 -13
  98. package/build/scripts/executor-protocol-contract-driver.js +13 -3
  99. package/build/setup-epic.js +208 -125
  100. package/build/sfcc/setup-status.js +2 -0
  101. package/build/sfcc/tool-wrapper.js +2 -0
  102. package/build/sfcc/tools-configured.js +96 -0
  103. package/build/start-tickets.js +163 -58
  104. package/build/upgrade-cli.js +109 -24
  105. package/build/version.generated.js +3 -3
  106. package/build/windows-npm-entry.js +103 -0
  107. package/build/worker-guard-hook-bin.js +5 -4
  108. package/build/worktree-core.js +6 -5
  109. package/docs/CONDUCTOR.md +116 -2
  110. package/docs/install/mcp-tool-integrations.md +1 -0
  111. package/docs/install/sfcc-integration.md +12 -7
  112. package/package.json +2 -1
  113. package/pipelines/implement-ticket.json +1 -1
@@ -0,0 +1,1121 @@
1
+ /**
2
+ * The required-work ledger and closure supervisor (BAPI-1301, D4a, plan Step 5).
3
+ *
4
+ * A covered Claude attempt runs with `--input-format stream-json` and its stdin
5
+ * HELD OPEN, so the CLI stays idle between turns instead of exiting and killing
6
+ * background work. This module reads that stream line by line and decides two
7
+ * things from executor-observed lifecycle events only:
8
+ *
9
+ * 1. WHEN stdin may be closed (the closure rule), and
10
+ * 2. WHAT the terminal completion-evidence envelope says (the verdict).
11
+ *
12
+ * Why a ledger and not the exit code or the result text: the BAPI-1292 probe
13
+ * measured that a clean exit and a `result` event both happen while delegated or
14
+ * background work is still running (or was just killed by CLI teardown). Only
15
+ * the per-task lifecycle, keyed by the FULL `tool_use_id`, says whether the work
16
+ * the worker started actually finished.
17
+ *
18
+ * Two places deliberately differ from the probe reducer (`reducer-v2.mjs`), on
19
+ * the user-approved ticket's terms:
20
+ * - A REQUIRED task that ended `failed`, `stopped`, or `withdrawn` (the worker
21
+ * ran its own `TaskStop`) stays UNRESOLVED. The probe counted a worker
22
+ * withdrawal as complete; a worker giving up on required work is not success.
23
+ * - A denial links to an obligation only on the exact OPERATION and TARGET (for
24
+ * example the prepared branch on `origin`), never on the effect class alone.
25
+ *
26
+ * Purity: no I/O, no real timers (the supervisor takes injected timer deps), no
27
+ * retained prompt, command, argument, URL, or path. Tool inputs and denial
28
+ * targets are kept only as SHA-256 digests.
29
+ */
30
+ import { MAX_DENIALS, MAX_EVENT_BYTES, MAX_TASKS, TASK_RESOLVING_STATES, boundEnvelope, boundedIdentifier, deriveCompletionCause, digestStructured, runtimeMatchesSupported, successRefusalReason, COMPLETION_CONTRACT_VERSION, COMPLETION_CONTRACT_VERSION_V2, } from "./completion-contract.js";
31
+ // ---------------------------------------------------------------------------
32
+ // Bounds and small pure helpers
33
+ // ---------------------------------------------------------------------------
34
+ /** Tool-use index bound. Oldest entries are evicted; a denial on an evicted id resolves to `unknown`. */
35
+ export const MAX_TOOL_USE_INDEX = MAX_TASKS * 8;
36
+ /** Resolved (`succeeded`) obligations evicted to make room are remembered this far, for duplicate/contradiction and replacement checks. */
37
+ const MAX_EVICTED = MAX_TASKS * 4;
38
+ const MAX_WORKER_STOPS = MAX_TASKS * 2;
39
+ /**
40
+ * The digest an obligation's input is identified by, for optional-watcher
41
+ * matching and exact re-issue replacement.
42
+ *
43
+ * BAPI-1301: the top-level `description` is dropped before hashing. It is a
44
+ * worker-authored label, not the work: a server-owned watcher declaration could
45
+ * never predict it, and "similar description" must never decide identity. Every
46
+ * other field (the command, `run_in_background`, the agent prompt) is hashed
47
+ * exactly, so only a byte-identical re-issue of the same work matches.
48
+ */
49
+ export function obligationInputDigest(input) {
50
+ if (input && typeof input === "object" && !Array.isArray(input)) {
51
+ const { description: _label, ...rest } = input;
52
+ return digestStructured(rest);
53
+ }
54
+ return digestStructured(input ?? null);
55
+ }
56
+ function isRecord(v) {
57
+ return v !== null && typeof v === "object" && !Array.isArray(v);
58
+ }
59
+ /** Real stream-json nests content under `message`; the recorded fixtures flatten it. Accept both. */
60
+ function contentOf(e) {
61
+ const msg = e["message"];
62
+ if (isRecord(msg) && "content" in msg)
63
+ return msg["content"];
64
+ return e["content"];
65
+ }
66
+ /** A `tool_result` block's text, from either `text` or `content` (string or text blocks). Never retained. */
67
+ function toolResultText(b) {
68
+ if (typeof b["text"] === "string")
69
+ return b["text"];
70
+ const c = b["content"];
71
+ if (typeof c === "string")
72
+ return c;
73
+ if (Array.isArray(c)) {
74
+ return c
75
+ .map((x) => (isRecord(x) && typeof x["text"] === "string" ? x["text"] : ""))
76
+ .join("\n");
77
+ }
78
+ return "";
79
+ }
80
+ /** The measured PreToolUse hook denial and permission-denied shapes (reducer-v2 R8). */
81
+ const DENIAL_TEXT_RE = /^PreToolUse:\S+ hook error|permission to use .* (was|has been) denied|denied by/i;
82
+ /**
83
+ * A minimal, conservative shell-word splitter. Any expansion, substitution,
84
+ * redirection, compound operator, glob, or escape makes the command
85
+ * non-structured (null): its target is then unresolved, never guessed.
86
+ */
87
+ function plainShellWords(cmd) {
88
+ const words = [];
89
+ let cur = "";
90
+ let inWord = false;
91
+ let i = 0;
92
+ while (i < cmd.length) {
93
+ const c = cmd[i];
94
+ if (c === "'") {
95
+ const j = cmd.indexOf("'", i + 1);
96
+ if (j < 0)
97
+ return null;
98
+ cur += cmd.slice(i + 1, j);
99
+ inWord = true;
100
+ i = j + 1;
101
+ continue;
102
+ }
103
+ if (c === '"') {
104
+ let j = i + 1;
105
+ while (j < cmd.length && cmd[j] !== '"') {
106
+ if (cmd[j] === "$" || cmd[j] === "`" || cmd[j] === "\\")
107
+ return null;
108
+ j++;
109
+ }
110
+ if (j >= cmd.length)
111
+ return null;
112
+ cur += cmd.slice(i + 1, j);
113
+ inWord = true;
114
+ i = j + 1;
115
+ continue;
116
+ }
117
+ if (c === " " || c === "\t") {
118
+ if (inWord) {
119
+ words.push(cur);
120
+ cur = "";
121
+ inWord = false;
122
+ }
123
+ i++;
124
+ continue;
125
+ }
126
+ if (/[;&|<>()$`\\\n\r*?{}[\]~#!]/.test(c))
127
+ return null;
128
+ cur += c;
129
+ inWord = true;
130
+ i++;
131
+ }
132
+ if (inWord)
133
+ words.push(cur);
134
+ return words;
135
+ }
136
+ const REF_NAME_RE = /^[A-Za-z0-9._/-]+$/;
137
+ function normalizeRef(ref) {
138
+ const r = ref.trim();
139
+ if (!r || r === "HEAD" || !REF_NAME_RE.test(r))
140
+ return null;
141
+ return r.startsWith("refs/") ? r : `refs/heads/${r}`;
142
+ }
143
+ function normalizeBranch(name) {
144
+ let n = name.trim();
145
+ // `gh pr create --head owner:branch` names the branch after the owner prefix.
146
+ const colon = n.lastIndexOf(":");
147
+ if (colon >= 0)
148
+ n = n.slice(colon + 1);
149
+ if (n.startsWith("refs/heads/"))
150
+ n = n.slice("refs/heads/".length);
151
+ if (!n || n === "HEAD" || !REF_NAME_RE.test(n))
152
+ return null;
153
+ return n;
154
+ }
155
+ function normalizeRepository(repo) {
156
+ let r = repo.trim().toLowerCase();
157
+ r = r.replace(/^https?:\/\//, "").replace(/\.git$/, "").replace(/^github\.com\//, "");
158
+ return /^[a-z0-9._-]+\/[a-z0-9._-]+$/.test(r) ? r : null;
159
+ }
160
+ function pushLinkKey(remote, ref) {
161
+ return digestStructured({ operation: "push", remote, ref });
162
+ }
163
+ function prLinkKey(head, base, repository) {
164
+ return repository === null
165
+ ? digestStructured({ operation: "pr_create", head, base })
166
+ : digestStructured({ operation: "pr_create", head, base, repository });
167
+ }
168
+ const PUSH_TARGET_NEUTRAL_FLAGS = new Set(["-u", "--set-upstream", "--no-verify", "-q", "--quiet"]);
169
+ /** `git push <remote> <refspec>` with at most target-neutral flags; anything else is unresolved. */
170
+ function resolvePushTarget(words) {
171
+ if (!words || words[0] !== "git" || words[1] !== "push")
172
+ return null;
173
+ const positional = [];
174
+ for (const w of words.slice(2)) {
175
+ if (w.startsWith("-")) {
176
+ if (!PUSH_TARGET_NEUTRAL_FLAGS.has(w))
177
+ return null;
178
+ continue;
179
+ }
180
+ positional.push(w);
181
+ }
182
+ // Bare `git push` (or remote-only) pushes an implicit upstream: unresolved.
183
+ if (positional.length !== 2)
184
+ return null;
185
+ const [remote, refspecRaw] = positional;
186
+ const refspec = refspecRaw.startsWith("+") ? refspecRaw.slice(1) : refspecRaw;
187
+ const colon = refspec.indexOf(":");
188
+ let dst;
189
+ if (colon >= 0) {
190
+ if (colon === 0)
191
+ return null; // `:ref` deletes a ref — not the push obligation.
192
+ dst = refspec.slice(colon + 1);
193
+ }
194
+ else {
195
+ dst = refspec;
196
+ }
197
+ const ref = normalizeRef(dst);
198
+ if (!ref || !remote)
199
+ return null;
200
+ return { remote, ref };
201
+ }
202
+ /** `gh pr create` with explicit `--head` and `--base` (and optional `--repo`). */
203
+ function resolvePrTarget(words) {
204
+ if (!words || words[0] !== "gh" || words[1] !== "pr" || words[2] !== "create")
205
+ return null;
206
+ const found = {
207
+ head: undefined,
208
+ base: undefined,
209
+ repo: undefined,
210
+ };
211
+ const flagFor = (w) => w === "--head" || w === "-H" ? "head" : w === "--base" || w === "-B" ? "base" : w === "--repo" || w === "-R" ? "repo" : null;
212
+ for (let i = 3; i < words.length; i++) {
213
+ const w = words[i];
214
+ let key = null;
215
+ let value;
216
+ const eq = w.indexOf("=");
217
+ if (w.startsWith("--") && eq > 0) {
218
+ key = flagFor(w.slice(0, eq));
219
+ value = w.slice(eq + 1);
220
+ }
221
+ else {
222
+ key = flagFor(w);
223
+ if (key) {
224
+ value = words[i + 1];
225
+ i++;
226
+ }
227
+ }
228
+ if (!key)
229
+ continue;
230
+ if (value === undefined || value.startsWith("-"))
231
+ return null;
232
+ if (found[key] !== undefined)
233
+ return null; // repeated flag: ambiguous.
234
+ found[key] = value;
235
+ }
236
+ if (found.head === undefined || found.base === undefined)
237
+ return null;
238
+ const head = normalizeBranch(found.head);
239
+ const base = normalizeBranch(found.base);
240
+ if (!head || !base)
241
+ return null;
242
+ let repository = null;
243
+ if (found.repo !== undefined) {
244
+ repository = normalizeRepository(found.repo);
245
+ if (!repository)
246
+ return null;
247
+ }
248
+ return { head, base, repository };
249
+ }
250
+ /**
251
+ * Resolve a tool use's denial operation and target from its STRUCTURED input.
252
+ * The raw command is read here and then discarded; only digests are returned.
253
+ */
254
+ function resolveDenialOperation(name, input) {
255
+ if (name === "Agent" || name === "Task")
256
+ return { operation: "delegate", targetDigest: null, linkKeys: [] };
257
+ if (name !== "Bash")
258
+ return { operation: "other", targetDigest: null, linkKeys: [] };
259
+ const command = isRecord(input) && typeof input["command"] === "string" ? input["command"] : "";
260
+ const words = plainShellWords(command.trim());
261
+ if (/\bgit\b[^;&|\n]*?\bpush\b/.test(command)) {
262
+ const t = resolvePushTarget(words);
263
+ if (!t)
264
+ return { operation: "push", targetDigest: null, linkKeys: [] };
265
+ const key = pushLinkKey(t.remote, t.ref);
266
+ return { operation: "push", targetDigest: key, linkKeys: [key] };
267
+ }
268
+ if (/\bgh\s+pr\s+create\b/.test(command)) {
269
+ const t = resolvePrTarget(words);
270
+ if (!t)
271
+ return { operation: "pr_create", targetDigest: null, linkKeys: [] };
272
+ const noRepo = prLinkKey(t.head, t.base, null);
273
+ if (t.repository === null)
274
+ return { operation: "pr_create", targetDigest: noRepo, linkKeys: [noRepo] };
275
+ const full = prLinkKey(t.head, t.base, t.repository);
276
+ // A call naming a repository also satisfies an obligation that declared none.
277
+ return { operation: "pr_create", targetDigest: full, linkKeys: [full, noRepo] };
278
+ }
279
+ if (/\bcurl\b[^|;&\n]*-X\s*(POST|PUT|PATCH|DELETE)\b/i.test(command) ||
280
+ /\bgh\s+api\b[^|;&\n]*(-X|--method)\s*(POST|PUT|PATCH|DELETE)\b/i.test(command)) {
281
+ return { operation: "external_mutation", targetDigest: null, linkKeys: [] };
282
+ }
283
+ return { operation: "other", targetDigest: null, linkKeys: [] };
284
+ }
285
+ function normalizeObligation(o) {
286
+ if (o.operation === "push") {
287
+ const ref = normalizeRef(o.target.ref);
288
+ const remote = typeof o.target.remote === "string" ? o.target.remote.trim() : "";
289
+ if (!ref || !remote)
290
+ return { linkKey: "", satisfactionKey: "", valid: false };
291
+ const linkKey = pushLinkKey(remote, ref);
292
+ return {
293
+ linkKey,
294
+ satisfactionKey: digestStructured({ linkKey, head: o.target.head ?? null }),
295
+ valid: true,
296
+ };
297
+ }
298
+ const head = normalizeBranch(o.target.head);
299
+ const base = normalizeBranch(o.target.base);
300
+ const repository = o.target.repository === undefined ? null : normalizeRepository(o.target.repository);
301
+ if (!head || !base || (o.target.repository !== undefined && repository === null)) {
302
+ return { linkKey: "", satisfactionKey: "", valid: false };
303
+ }
304
+ const linkKey = prLinkKey(head, base, repository);
305
+ return { linkKey, satisfactionKey: linkKey, valid: true };
306
+ }
307
+ const TERMINAL_STATES = new Set([
308
+ "succeeded",
309
+ "failed",
310
+ "stopped",
311
+ "withdrawn",
312
+ "conflict",
313
+ ]);
314
+ const KNOWN_TASK_STATUSES = new Set(["pending", "running", "completed", "failed", "stopped", "killed"]);
315
+ export function createRequiredWorkLedger(init) {
316
+ const binding = { ...init.binding };
317
+ const lifecycleSupported = init.lifecycleSupported === true;
318
+ const optionalWatchers = (init.optionalWatchers ?? []).slice(0, 16).map((w) => ({ ...w }));
319
+ // An obligation whose target cannot be normalized links nothing: a denial of
320
+ // it then reads `unlinked`/`ambiguous`, and unmet finalization still refuses
321
+ // success on its own, so dropping it can never manufacture a success.
322
+ const declared = (init.obligations ?? []).slice(0, 8).map(normalizeObligation).filter((d) => d.valid);
323
+ let sessionId = null;
324
+ let sawInit = false;
325
+ const insufficient = new Set();
326
+ const obligations = new Map();
327
+ const evicted = new Map();
328
+ const toolUses = new Map();
329
+ const workerStops = new Set();
330
+ const denials = [];
331
+ let latestSnapshot = null;
332
+ let materialSeq = 0;
333
+ let activitySeq = 0;
334
+ let orderSeq = 0;
335
+ let resultsSeen = 0;
336
+ let lastResultSeq = -1;
337
+ /** No material event since the latest result. */
338
+ let latestIsResult = false;
339
+ /** An init/assistant/user turn event arrived after the latest result. */
340
+ let turnOpen = false;
341
+ let stdinClosed = false;
342
+ let streamEnd = null;
343
+ let processExit = null;
344
+ const isOptional = (kind, inputDigest) => inputDigest !== null && optionalWatchers.some((w) => w.kind === kind && w.input_digest === inputDigest);
345
+ // --- obligation tracking --------------------------------------------------
346
+ /** Evict the oldest resolved obligation to make room. Unresolved obligations are never evicted. */
347
+ const evictOneResolved = () => {
348
+ if (evicted.size >= MAX_EVICTED)
349
+ return false;
350
+ for (const o of obligations.values()) {
351
+ if (o.state !== "succeeded")
352
+ continue;
353
+ if (latestSnapshot?.includes(o.taskId ?? "\u0000"))
354
+ continue;
355
+ evicted.set(o.key, { state: o.state, taskId: o.taskId, kind: o.kind, inputDigest: o.inputDigest, order: o.order });
356
+ obligations.delete(o.key);
357
+ return true;
358
+ }
359
+ return false;
360
+ };
361
+ const track = (key, spec) => {
362
+ const existing = obligations.get(key);
363
+ if (existing)
364
+ return existing;
365
+ if (evicted.has(key))
366
+ return null; // already resolved and remembered.
367
+ if (obligations.size >= MAX_TASKS && !evictOneResolved()) {
368
+ insufficient.add("ledger_capacity_exhausted");
369
+ return null;
370
+ }
371
+ const o = {
372
+ key,
373
+ state: "pending",
374
+ order: ++orderSeq,
375
+ ...spec,
376
+ required: spec.required ?? !isOptional(spec.kind, spec.inputDigest),
377
+ };
378
+ obligations.set(key, o);
379
+ return o;
380
+ };
381
+ const findByTaskId = (taskId) => {
382
+ for (const o of obligations.values())
383
+ if (o.taskId === taskId && !o.placeholder)
384
+ return o;
385
+ return obligations.get(`t:${taskId}`);
386
+ };
387
+ /** Apply a terminal state. Identical duplicates are idempotent; a contradiction is `conflict`. */
388
+ const applyTerminal = (o, next) => {
389
+ if (!TERMINAL_STATES.has(o.state)) {
390
+ o.state = next;
391
+ return "applied";
392
+ }
393
+ if (o.state === next || o.state === "conflict")
394
+ return o.state === "conflict" ? "conflict" : "applied";
395
+ // A stop (CLI teardown or worker TaskStop) after the task already ended is
396
+ // measured noise (reducer-v2 R3), not a contradiction of the recorded end.
397
+ if ((next === "stopped" || next === "withdrawn") && (o.state === "succeeded" || o.state === "failed"))
398
+ return "applied";
399
+ // A killed foreground call reports `stopped` and then its own error
400
+ // tool_result (measured C5k, "Exit code 137"): one ending, not two.
401
+ if (next === "failed" && (o.state === "stopped" || o.state === "withdrawn"))
402
+ return "applied";
403
+ // `stopped` then `withdrawn` for the same stop (task_updated `killed` then a
404
+ // notification after the worker's TaskStop) describe one event.
405
+ if ((o.state === "stopped" && next === "withdrawn") || (o.state === "withdrawn" && next === "stopped")) {
406
+ o.state = "withdrawn";
407
+ return "applied";
408
+ }
409
+ o.state = "conflict";
410
+ insufficient.add("event_conflict");
411
+ return "conflict";
412
+ };
413
+ /** Fold a placeholder (snapshot-only or orphan terminal keyed by task id) into the real obligation. */
414
+ const mergePlaceholder = (real, taskId) => {
415
+ const ph = obligations.get(`t:${taskId}`);
416
+ if (!ph || ph === real)
417
+ return "applied";
418
+ obligations.delete(ph.key);
419
+ if (TERMINAL_STATES.has(ph.state))
420
+ return applyTerminal(real, ph.state);
421
+ return "applied";
422
+ };
423
+ const terminalOnEvicted = (key, next) => {
424
+ const ev = evicted.get(key);
425
+ if (!ev)
426
+ return "ignored";
427
+ if (ev.state === next || next === "stopped" || next === "withdrawn")
428
+ return "applied";
429
+ insufficient.add("event_conflict");
430
+ return "conflict";
431
+ };
432
+ // --- denials --------------------------------------------------------------
433
+ const addDenial = (toolUseId, seq, viaResult) => {
434
+ const existing = denials.find((d) => d.toolUseId === toolUseId);
435
+ if (existing) {
436
+ if (viaResult)
437
+ existing.viaResult = true;
438
+ return;
439
+ }
440
+ if (denials.length >= MAX_DENIALS) {
441
+ insufficient.add("denial_capacity_exhausted");
442
+ return;
443
+ }
444
+ const entry = toolUses.get(toolUseId);
445
+ const resolution = entry
446
+ ? entry.denial
447
+ : { operation: "unknown", targetDigest: null, linkKeys: [] };
448
+ denials.push({ toolUseId, resolution, seq, viaResult });
449
+ };
450
+ /** A denied tracked obligation never ran: it is `failed` (and stays unresolved if required). */
451
+ const failDeniedObligation = (toolUseId) => {
452
+ const o = obligations.get(toolUseId);
453
+ if (o)
454
+ applyTerminal(o, "failed");
455
+ };
456
+ // --- event handlers -------------------------------------------------------
457
+ const onTurnEvent = () => {
458
+ turnOpen = true;
459
+ if (stdinClosed)
460
+ insufficient.add("late_activity_after_close");
461
+ };
462
+ const handleInit = (e) => {
463
+ const sid = boundedIdentifier(e["session_id"]);
464
+ onTurnEvent();
465
+ if (!sid) {
466
+ insufficient.add("event_unknown");
467
+ return "unknown_shape";
468
+ }
469
+ sawInit = true;
470
+ return "applied";
471
+ };
472
+ const handleAssistant = (e) => {
473
+ onTurnEvent();
474
+ const content = contentOf(e);
475
+ if (!Array.isArray(content)) {
476
+ // A string or absent content carries no tool use; only a non-array object is malformed.
477
+ if (content === undefined || typeof content === "string")
478
+ return "applied";
479
+ insufficient.add("event_unknown");
480
+ return "unknown_shape";
481
+ }
482
+ let outcome = "applied";
483
+ for (const b of content) {
484
+ if (!isRecord(b) || b["type"] !== "tool_use")
485
+ continue;
486
+ const id = boundedIdentifier(b["id"]);
487
+ const name = typeof b["name"] === "string" ? b["name"] : null;
488
+ if (!id || !name) {
489
+ insufficient.add("event_unknown");
490
+ outcome = "unknown_shape";
491
+ continue;
492
+ }
493
+ const input = b["input"];
494
+ const inputDigest = obligationInputDigest(input);
495
+ // Bounded index; the input itself is dropped here, only digests remain.
496
+ if (!toolUses.has(id)) {
497
+ if (toolUses.size >= MAX_TOOL_USE_INDEX) {
498
+ const oldest = toolUses.keys().next().value;
499
+ if (oldest !== undefined)
500
+ toolUses.delete(oldest);
501
+ }
502
+ toolUses.set(id, { name, inputDigest, denial: resolveDenialOperation(name, input) });
503
+ }
504
+ const bg = isRecord(input) && input["run_in_background"] === true;
505
+ if (name === "Agent" || name === "Task") {
506
+ track(id, { toolUseId: id, taskId: null, kind: bg ? "delegate_bg" : "delegate", inputDigest, background: bg, placeholder: false });
507
+ }
508
+ else if (name === "Bash" && bg) {
509
+ track(id, { toolUseId: id, taskId: null, kind: "bash_bg", inputDigest, background: true, placeholder: false });
510
+ }
511
+ else if (name === "Monitor") {
512
+ track(id, { toolUseId: id, taskId: null, kind: "monitor", inputDigest, background: true, placeholder: false });
513
+ }
514
+ else if (name === "TaskStop") {
515
+ // R13: the worker's OWN stop request. It turns a later stop into
516
+ // `withdrawn` for the record — still unresolved when the task was required.
517
+ const tid = isRecord(input) ? boundedIdentifier(input["task_id"]) : null;
518
+ if (tid && workerStops.size < MAX_WORKER_STOPS)
519
+ workerStops.add(tid);
520
+ }
521
+ // Synchronous foreground Bash and every other tool are exploratory: not
522
+ // obligations. Their denials are still recorded from the tool_result.
523
+ }
524
+ return outcome;
525
+ };
526
+ const handleUser = (e, seq) => {
527
+ onTurnEvent();
528
+ const content = contentOf(e);
529
+ if (!Array.isArray(content))
530
+ return "applied"; // a plain user text message.
531
+ let outcome = "applied";
532
+ for (const b of content) {
533
+ if (!isRecord(b) || b["type"] !== "tool_result")
534
+ continue;
535
+ const id = boundedIdentifier(b["tool_use_id"]);
536
+ if (!id) {
537
+ insufficient.add("event_unknown");
538
+ outcome = "unknown_shape";
539
+ continue;
540
+ }
541
+ const isError = b["is_error"] === true;
542
+ const denied = isError && DENIAL_TEXT_RE.test(toolResultText(b));
543
+ if (denied) {
544
+ addDenial(id, seq, false);
545
+ failDeniedObligation(id);
546
+ continue;
547
+ }
548
+ const o = obligations.get(id);
549
+ if (!o || o.background)
550
+ continue; // background: a launch acknowledgement, not a result.
551
+ const r = applyTerminal(o, isError ? "failed" : "succeeded"); // foreground delegate result.
552
+ if (r === "conflict")
553
+ outcome = "conflict";
554
+ }
555
+ return outcome;
556
+ };
557
+ const handleTaskStarted = (e) => {
558
+ onTurnEvent();
559
+ const taskId = boundedIdentifier(e["task_id"]);
560
+ if (!taskId) {
561
+ insufficient.add("event_unknown");
562
+ return "unknown_shape";
563
+ }
564
+ const toolUseId = e["tool_use_id"] === undefined || e["tool_use_id"] === null ? null : boundedIdentifier(e["tool_use_id"]);
565
+ if (e["tool_use_id"] !== undefined && e["tool_use_id"] !== null && !toolUseId) {
566
+ insufficient.add("event_unknown");
567
+ return "unknown_shape";
568
+ }
569
+ const bg = e["is_backgrounded"] === true;
570
+ const kind = e["task_type"] === "local_agent" ? (bg ? "delegate_bg" : "delegate") : e["owned_by_subagent"] === true ? "subagent_bash" : "bash_task";
571
+ if (!toolUseId) {
572
+ track(`t:${taskId}`, { toolUseId: taskId, taskId, kind, inputDigest: null, background: bg, placeholder: true, required: true });
573
+ return "applied";
574
+ }
575
+ const entry = toolUses.get(toolUseId);
576
+ const o = track(toolUseId, {
577
+ toolUseId,
578
+ taskId,
579
+ kind,
580
+ inputDigest: entry?.inputDigest ?? null,
581
+ background: bg,
582
+ placeholder: false,
583
+ });
584
+ if (!o)
585
+ return "applied";
586
+ if (o.taskId !== null && o.taskId !== taskId) {
587
+ insufficient.add("event_conflict");
588
+ return "conflict";
589
+ }
590
+ o.taskId = taskId;
591
+ return mergePlaceholder(o, taskId);
592
+ };
593
+ const handleTaskTerminal = (e, status) => {
594
+ const taskId = boundedIdentifier(e["task_id"]);
595
+ if (!taskId || typeof status !== "string") {
596
+ insufficient.add("event_unknown");
597
+ return "unknown_shape";
598
+ }
599
+ if (!KNOWN_TASK_STATUSES.has(status)) {
600
+ insufficient.add("event_unknown");
601
+ return "unknown_shape";
602
+ }
603
+ let next = status === "completed" ? "succeeded" : status === "failed" ? "failed" : status === "stopped" || status === "killed" ? "stopped" : null;
604
+ if (next === null)
605
+ return "applied"; // pending/running: non-terminal progress.
606
+ if (next === "stopped" && workerStops.has(taskId))
607
+ next = "withdrawn";
608
+ const rawToolUse = e["tool_use_id"];
609
+ const toolUseId = rawToolUse === undefined || rawToolUse === null ? null : boundedIdentifier(rawToolUse);
610
+ if (rawToolUse !== undefined && rawToolUse !== null && !toolUseId) {
611
+ insufficient.add("event_unknown");
612
+ return "unknown_shape";
613
+ }
614
+ let o;
615
+ if (toolUseId) {
616
+ const byTask = findByTaskId(taskId);
617
+ if (byTask && !byTask.placeholder && byTask.key !== toolUseId) {
618
+ insufficient.add("event_conflict");
619
+ return "conflict";
620
+ }
621
+ if (evicted.has(toolUseId))
622
+ return terminalOnEvicted(toolUseId, next);
623
+ // R4: a terminal without a start is retained, not dropped.
624
+ o = track(toolUseId, {
625
+ toolUseId,
626
+ taskId,
627
+ kind: "task",
628
+ inputDigest: toolUses.get(toolUseId)?.inputDigest ?? null,
629
+ background: true,
630
+ placeholder: false,
631
+ });
632
+ if (!o)
633
+ return "applied";
634
+ if (o.taskId === null)
635
+ o.taskId = taskId;
636
+ const merged = mergePlaceholder(o, taskId);
637
+ if (merged === "conflict")
638
+ return merged;
639
+ }
640
+ else {
641
+ o = findByTaskId(taskId);
642
+ if (!o) {
643
+ for (const [k, ev] of evicted)
644
+ if (ev.taskId === taskId)
645
+ return terminalOnEvicted(k, next);
646
+ o = track(`t:${taskId}`, { toolUseId: taskId, taskId, kind: "task", inputDigest: null, background: true, placeholder: true, required: true });
647
+ if (!o)
648
+ return "applied";
649
+ }
650
+ }
651
+ return applyTerminal(o, next);
652
+ };
653
+ const handleSnapshot = (e) => {
654
+ const tasks = e["tasks"];
655
+ if (!Array.isArray(tasks)) {
656
+ insufficient.add("event_unknown");
657
+ return "unknown_shape";
658
+ }
659
+ const ids = [];
660
+ let outcome = "applied";
661
+ for (const t of tasks.slice(0, MAX_TASKS * 2)) {
662
+ const tid = isRecord(t) ? boundedIdentifier(t["task_id"]) : null;
663
+ if (!tid) {
664
+ insufficient.add("event_unknown");
665
+ outcome = "unknown_shape";
666
+ continue;
667
+ }
668
+ ids.push(tid);
669
+ }
670
+ if (tasks.length > MAX_TASKS * 2)
671
+ insufficient.add("ledger_capacity_exhausted");
672
+ latestSnapshot = ids; // latest wins.
673
+ // A task the snapshot lists but no start/tool use explains yet is an
674
+ // UNKNOWN obligation, required until a lifecycle event resolves it.
675
+ for (const tid of ids) {
676
+ if (findByTaskId(tid))
677
+ continue;
678
+ let wasEvicted = false;
679
+ for (const ev of evicted.values())
680
+ if (ev.taskId === tid)
681
+ wasEvicted = true;
682
+ if (wasEvicted)
683
+ continue;
684
+ track(`t:${tid}`, { toolUseId: tid, taskId: tid, kind: "task", inputDigest: null, background: true, placeholder: true, required: true });
685
+ }
686
+ return outcome;
687
+ };
688
+ const handleResult = (e, seq) => {
689
+ resultsSeen++;
690
+ lastResultSeq = seq;
691
+ turnOpen = false;
692
+ const pd = e["permission_denials"];
693
+ if (pd === undefined || pd === null)
694
+ return "applied";
695
+ if (!Array.isArray(pd)) {
696
+ insufficient.add("event_unknown");
697
+ return "unknown_shape";
698
+ }
699
+ let outcome = "applied";
700
+ for (const d of pd) {
701
+ const id = isRecord(d) ? boundedIdentifier(d["tool_use_id"]) : null;
702
+ if (!id) {
703
+ insufficient.add("event_unknown");
704
+ outcome = "unknown_shape";
705
+ continue;
706
+ }
707
+ // The result that reports the denial is itself a completed turn after it.
708
+ addDenial(id, seq, true);
709
+ failDeniedObligation(id);
710
+ }
711
+ return outcome;
712
+ };
713
+ const dispatch = (e) => {
714
+ const type = e["type"];
715
+ const subtype = e["subtype"];
716
+ const material = type === "assistant" ||
717
+ type === "user" ||
718
+ type === "result" ||
719
+ (type === "system" &&
720
+ (subtype === "init" ||
721
+ subtype === "task_started" ||
722
+ subtype === "task_notification" ||
723
+ subtype === "task_updated" ||
724
+ subtype === "background_tasks_changed"));
725
+ if (!material)
726
+ return "not_material";
727
+ // Session binding: one session per attempt. A different session id is a
728
+ // stream that does not belong to this binding.
729
+ const sidRaw = e["session_id"];
730
+ if (sidRaw !== undefined && sidRaw !== null) {
731
+ const sid = boundedIdentifier(sidRaw);
732
+ if (!sid) {
733
+ insufficient.add("event_unknown");
734
+ return "unknown_shape";
735
+ }
736
+ if (sessionId === null && type === "system" && subtype === "init")
737
+ sessionId = sid;
738
+ else if (sessionId !== null && sid !== sessionId) {
739
+ insufficient.add("event_conflict");
740
+ return "conflict";
741
+ }
742
+ }
743
+ const seq = ++materialSeq;
744
+ latestIsResult = type === "result";
745
+ if (type === "result")
746
+ return handleResult(e, seq);
747
+ if (type === "assistant")
748
+ return handleAssistant(e);
749
+ if (type === "user")
750
+ return handleUser(e, seq);
751
+ switch (subtype) {
752
+ case "init":
753
+ return handleInit(e);
754
+ case "task_started":
755
+ return handleTaskStarted(e);
756
+ case "task_notification":
757
+ return handleTaskTerminal(e, e["status"]);
758
+ case "task_updated": {
759
+ const patch = e["patch"];
760
+ if (!isRecord(patch)) {
761
+ insufficient.add("event_unknown");
762
+ return "unknown_shape";
763
+ }
764
+ // A patch without a status (end_time, progress) is not a lifecycle change.
765
+ if (patch["status"] === undefined)
766
+ return "applied";
767
+ return handleTaskTerminal(e, patch["status"]);
768
+ }
769
+ case "background_tasks_changed":
770
+ return handleSnapshot(e);
771
+ default:
772
+ return "ignored";
773
+ }
774
+ };
775
+ // --- derived views --------------------------------------------------------
776
+ const isLive = (o) => o.state === "pending";
777
+ /** Task ids in the latest snapshot that no terminal lifecycle event has resolved. */
778
+ const liveSnapshotIds = () => {
779
+ const required = [];
780
+ const optional = [];
781
+ for (const tid of latestSnapshot ?? []) {
782
+ const o = findByTaskId(tid);
783
+ if (!o) {
784
+ let evictedResolved = false;
785
+ for (const ev of evicted.values())
786
+ if (ev.taskId === tid)
787
+ evictedResolved = true;
788
+ if (!evictedResolved)
789
+ required.push(tid);
790
+ continue;
791
+ }
792
+ if (!isLive(o))
793
+ continue;
794
+ (o.required ? required : optional).push(tid);
795
+ }
796
+ return { required, optional };
797
+ };
798
+ /** Effective states with exact re-issue replacement applied (never mutates the record). */
799
+ const effectiveTasks = () => {
800
+ const all = [...obligations.values()];
801
+ const succeededLater = (o) => {
802
+ if (o.inputDigest === null)
803
+ return false;
804
+ for (const other of all) {
805
+ if (other !== o && other.kind === o.kind && other.inputDigest === o.inputDigest && other.state === "succeeded" && other.order > o.order)
806
+ return true;
807
+ }
808
+ for (const ev of evicted.values()) {
809
+ if (ev.kind === o.kind && ev.inputDigest === o.inputDigest && ev.state === "succeeded" && ev.order > o.order)
810
+ return true;
811
+ }
812
+ return false;
813
+ };
814
+ return all.map((o) => {
815
+ // Only a terminal non-success is replaceable: live work is not superseded,
816
+ // and a `conflict` is contradictory evidence, not a failure to retry.
817
+ const replaceable = o.state === "failed" || o.state === "stopped" || o.state === "withdrawn";
818
+ return { o, state: replaceable && succeededLater(o) ? "replaced" : o.state };
819
+ });
820
+ };
821
+ // --- public API -----------------------------------------------------------
822
+ const ingestLine = (line) => {
823
+ if (typeof line !== "string" || line.trim().length === 0)
824
+ return "blank";
825
+ activitySeq++;
826
+ if (Buffer.byteLength(line, "utf8") > MAX_EVENT_BYTES) {
827
+ // Never parsed: an event this large cannot be trusted to be complete.
828
+ insufficient.add("stream_oversize");
829
+ latestIsResult = false;
830
+ return "oversize";
831
+ }
832
+ let parsed;
833
+ try {
834
+ parsed = JSON.parse(line);
835
+ }
836
+ catch {
837
+ insufficient.add("stream_parse_error");
838
+ latestIsResult = false;
839
+ return "parse_error";
840
+ }
841
+ if (!isRecord(parsed) || typeof parsed["type"] !== "string") {
842
+ insufficient.add("stream_parse_error");
843
+ latestIsResult = false;
844
+ return "parse_error";
845
+ }
846
+ try {
847
+ const r = dispatch(parsed);
848
+ return r === "not_material" ? "ignored" : r;
849
+ }
850
+ catch {
851
+ // A reducer defect must degrade to "not proven", never to success.
852
+ insufficient.add("event_unknown");
853
+ latestIsResult = false;
854
+ return "unknown_shape";
855
+ }
856
+ };
857
+ const closureState = () => {
858
+ const liveRequired = [];
859
+ const liveOptional = [];
860
+ for (const o of obligations.values()) {
861
+ if (!isLive(o))
862
+ continue;
863
+ if (o.required)
864
+ liveRequired.push(o.toolUseId);
865
+ else
866
+ liveOptional.push(o.taskId ?? o.toolUseId);
867
+ }
868
+ const snap = liveSnapshotIds();
869
+ // A snapshot id whose obligation is already listed (by its tool_use_id or as
870
+ // a placeholder) is the same work: list it once, not under both handles.
871
+ for (const tid of snap.required)
872
+ if (!liveRequired.includes(tid) && !findByTaskId(tid))
873
+ liveRequired.push(tid);
874
+ for (const tid of snap.optional)
875
+ if (!liveOptional.includes(tid))
876
+ liveOptional.push(tid);
877
+ const readyToClose = resultsSeen > 0 && latestIsResult && !turnOpen && liveRequired.length === 0 && snap.required.length === 0;
878
+ return { readyToClose, liveRequired, liveOptional, activitySeq, latestIsResult };
879
+ };
880
+ const buildEnvelope = (facts) => {
881
+ const reasons = new Set(insufficient);
882
+ if (!lifecycleSupported || !runtimeMatchesSupported(facts.runtime))
883
+ reasons.add("unsupported_runtime");
884
+ if (!sawInit || sessionId === null)
885
+ reasons.add("no_init_event");
886
+ if (resultsSeen === 0)
887
+ reasons.add("no_result_turn");
888
+ else if (turnOpen)
889
+ reasons.add("active_turn_at_close");
890
+ if (streamEnd === null || streamEnd === "drain_deadline")
891
+ reasons.add("stdout_not_read_to_eof");
892
+ else if (streamEnd === "stream_error")
893
+ reasons.add("stream_lost");
894
+ if (processExit === null)
895
+ reasons.add("no_process_exit");
896
+ else if (processExit.signal === "SIGKILL")
897
+ reasons.add("forced_kill_no_cli_teardown");
898
+ const effective = effectiveTasks();
899
+ // R6: a required task with no terminal after the stream ended is missing evidence.
900
+ if (effective.some(({ o, state }) => o.required && state === "pending"))
901
+ reasons.add("live_required_work");
902
+ // R6b: a stale snapshot, or a snapshot-only task nothing ever explained.
903
+ const snap = liveSnapshotIds();
904
+ const unexplainedPlaceholder = effective.some(({ o, state }) => o.placeholder && state === "pending");
905
+ if (snap.required.length > 0 || unexplainedPlaceholder)
906
+ reasons.add("stale_task_snapshot");
907
+ const sufficient = reasons.size === 0;
908
+ const unresolvedRequired = effective.some(({ o, state }) => o.required && !TASK_RESOLVING_STATES.has(state));
909
+ const anyConflict = effective.some(({ state }) => state === "conflict");
910
+ let execution = unresolvedRequired || anyConflict || snap.required.length > 0 || unexplainedPlaceholder ? "incomplete" : "complete";
911
+ if (!sufficient && execution === "complete")
912
+ execution = "unknown";
913
+ const tasks = effective.slice(0, MAX_TASKS).map(({ o, state }) => ({
914
+ tool_use_id: o.toolUseId,
915
+ task_id: o.taskId,
916
+ kind: o.kind,
917
+ // A task still pending when the verdict is taken never reached a terminal.
918
+ state: state === "pending" ? "unknown" : state,
919
+ required: o.required,
920
+ input_digest: o.inputDigest,
921
+ }));
922
+ const satisfied = new Set((facts.satisfiedObligations ?? [])
923
+ .map(normalizeObligation)
924
+ .filter((n) => n.valid)
925
+ .map((n) => n.satisfactionKey));
926
+ const denialRows = denials.map((d) => {
927
+ const { operation, targetDigest, linkKeys } = d.resolution;
928
+ let state;
929
+ if (operation === "unknown" || ((operation === "push" || operation === "pr_create") && linkKeys.length === 0)) {
930
+ // The target cannot be established from structure: it may or may not be
931
+ // the required effect. Never guessed into blocking or historical.
932
+ state = "ambiguous";
933
+ }
934
+ else {
935
+ const linked = declared.filter((n) => linkKeys.includes(n.linkKey));
936
+ if (linked.length === 0)
937
+ state = "unlinked";
938
+ else if (linked.some((n) => !satisfied.has(n.satisfactionKey)))
939
+ state = "blocking";
940
+ else
941
+ state = "historical";
942
+ }
943
+ return {
944
+ tool_use_id: d.toolUseId,
945
+ operation,
946
+ target_digest: targetDigest,
947
+ state,
948
+ result_after_denial: d.viaResult || lastResultSeq > d.seq,
949
+ };
950
+ });
951
+ const candidate = {
952
+ evidence_version: facts.verificationFacts ? COMPLETION_CONTRACT_VERSION_V2 : COMPLETION_CONTRACT_VERSION,
953
+ runtime: { ...facts.runtime },
954
+ binding: { ...binding, final_head_sha: facts.finalHeadSha },
955
+ session_id: sessionId,
956
+ evidence: sufficient ? "sufficient" : "insufficient",
957
+ insufficient_reasons: [...reasons],
958
+ execution,
959
+ verification: facts.verification,
960
+ finalization: facts.finalization,
961
+ cleanup: { ...facts.cleanup, reasons: [...facts.cleanup.reasons], residual_not_covered: [...facts.cleanup.residual_not_covered] },
962
+ tasks,
963
+ denials: denialRows,
964
+ missing_capabilities: [...facts.missingCapabilities],
965
+ results_seen: resultsSeen,
966
+ ...(facts.verificationFacts ? { verification_facts: facts.verificationFacts } : {}),
967
+ };
968
+ const withNullCause = { ...candidate, cause: null };
969
+ const cause = successRefusalReason(withNullCause) === null ? null : deriveCompletionCause(candidate);
970
+ return boundEnvelope({ ...candidate, cause });
971
+ };
972
+ return {
973
+ ingestLine,
974
+ closureState,
975
+ recordStdinClosed: () => {
976
+ stdinClosed = true;
977
+ },
978
+ recordStreamEnd: (outcome) => {
979
+ // The first non-clean outcome wins; a later clean EOF cannot repair a lost stream.
980
+ if (streamEnd === null || (streamEnd === "eof" && outcome !== "eof"))
981
+ streamEnd = outcome;
982
+ },
983
+ recordProcessExit: (exit) => {
984
+ if (processExit === null)
985
+ processExit = { code: exit.code, signal: exit.signal };
986
+ },
987
+ recordTransportFailure: (beforeWork) => {
988
+ insufficient.add(beforeWork ? "transport_failed_before_work" : "transport_failed_after_progress");
989
+ },
990
+ recordAllowanceExhausted: () => {
991
+ insufficient.add("allowance_exhausted");
992
+ },
993
+ recordDeclarationRefused: () => {
994
+ insufficient.add("declaration_refused");
995
+ },
996
+ buildEnvelope,
997
+ };
998
+ }
999
+ /** The measured settle window after the closure rule first holds (BAPI-1292 C6an-hold2). */
1000
+ export const CLOSURE_DEBOUNCE_MS = 1500;
1001
+ /**
1002
+ * Hold stdin open until the closure rule holds for a full debounce window, then
1003
+ * close it exactly once.
1004
+ *
1005
+ * Why the debounce: the first probe rule closed stdin the moment a result
1006
+ * arrived and cut off a notification-triggered turn that was about to start.
1007
+ * Every new activity resets the window, and closing is never the verdict: the
1008
+ * caller drains to EOF and the ledger recomputes, so a late turn still
1009
+ * invalidates the apparent closure (`late_activity_after_close`).
1010
+ *
1011
+ * The nudge: at most one, only when a turn ended while ONLY server-declared
1012
+ * optional watchers are live. It is never sent while required work is live and
1013
+ * never asks for a broad stop — cancelling required work is not the executor's
1014
+ * call.
1015
+ */
1016
+ export function createClosureSupervisor(opts) {
1017
+ const { ledger, deps } = opts;
1018
+ const debounceMs = opts.debounceMs ?? CLOSURE_DEBOUNCE_MS;
1019
+ let closed = false;
1020
+ let closedReason = null;
1021
+ let nudged = false;
1022
+ let nudgeAwaitingReply = false;
1023
+ let disposed = false;
1024
+ let debounceTimer = null;
1025
+ let armedSeq = -1;
1026
+ let pending = Promise.resolve();
1027
+ const clearDebounce = () => {
1028
+ if (debounceTimer !== null)
1029
+ deps.clearTimer(debounceTimer);
1030
+ debounceTimer = null;
1031
+ armedSeq = -1;
1032
+ };
1033
+ const close = (reason) => {
1034
+ if (closed || disposed)
1035
+ return;
1036
+ closed = true;
1037
+ closedReason = reason;
1038
+ clearDebounce();
1039
+ if (deadlineTimer !== null)
1040
+ deps.clearTimer(deadlineTimer);
1041
+ deadlineTimer = null;
1042
+ ledger.recordStdinClosed();
1043
+ pending = pending.then(async () => {
1044
+ try {
1045
+ await opts.closeStdin();
1046
+ }
1047
+ catch {
1048
+ // A failed close is observed by the caller as a missing EOF; the ledger
1049
+ // then reports `stdout_not_read_to_eof`, never success.
1050
+ }
1051
+ });
1052
+ };
1053
+ let deadlineTimer = deps.setTimer(() => {
1054
+ deadlineTimer = null;
1055
+ close("deadline");
1056
+ }, Math.max(0, opts.deadlineAtMs - deps.now()));
1057
+ const onActivity = () => {
1058
+ if (closed || disposed)
1059
+ return;
1060
+ const s = ledger.closureState();
1061
+ if (armedSeq !== s.activitySeq)
1062
+ clearDebounce();
1063
+ if (!s.latestIsResult) {
1064
+ // The nudge was answered (or other activity began): the next result decides again.
1065
+ nudgeAwaitingReply = false;
1066
+ return;
1067
+ }
1068
+ if (!s.readyToClose)
1069
+ return;
1070
+ if (!nudged && s.liveOptional.length > 0) {
1071
+ nudged = true;
1072
+ nudgeAwaitingReply = true;
1073
+ const line = opts.buildNudge([...s.liveOptional]);
1074
+ pending = pending.then(async () => {
1075
+ let ok = false;
1076
+ try {
1077
+ ok = (await opts.writeMessage(line)).ok;
1078
+ }
1079
+ catch {
1080
+ ok = false;
1081
+ }
1082
+ // An undelivered nudge will never be answered: fall back to the rule.
1083
+ if (!ok && !closed && !disposed) {
1084
+ nudgeAwaitingReply = false;
1085
+ onActivity();
1086
+ }
1087
+ });
1088
+ return;
1089
+ }
1090
+ // While the worker has a delivered nudge to answer, do not close under it;
1091
+ // the absolute deadline still bounds the wait.
1092
+ if (nudgeAwaitingReply)
1093
+ return;
1094
+ if (debounceTimer !== null)
1095
+ return;
1096
+ armedSeq = s.activitySeq;
1097
+ const seqAtArm = s.activitySeq;
1098
+ debounceTimer = deps.setTimer(() => {
1099
+ debounceTimer = null;
1100
+ armedSeq = -1;
1101
+ if (closed || disposed)
1102
+ return;
1103
+ const now = ledger.closureState();
1104
+ if (now.readyToClose && now.activitySeq === seqAtArm)
1105
+ close("rule");
1106
+ }, debounceMs);
1107
+ };
1108
+ return {
1109
+ onActivity,
1110
+ abort: () => close("abort"),
1111
+ state: () => ({ closed, closedReason, nudged }),
1112
+ settled: () => pending,
1113
+ dispose: () => {
1114
+ clearDebounce();
1115
+ if (deadlineTimer !== null)
1116
+ deps.clearTimer(deadlineTimer);
1117
+ deadlineTimer = null;
1118
+ disposed = true;
1119
+ },
1120
+ };
1121
+ }