opencode-turnstile 0.2.1 → 0.2.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/setup.ts CHANGED
@@ -11,7 +11,11 @@
11
11
  * BLOCKED errors thrown by the gate layer propagate to the host.
12
12
  */
13
13
 
14
- import { loadConfig } from "./config.ts"
14
+ import * as fs from "node:fs"
15
+ import * as path from "node:path"
16
+ import { fileURLToPath } from "node:url"
17
+
18
+ import { configDigest, loadConfig } from "./config.ts"
15
19
  import { createJournaler, type Journaler } from "./journal.ts"
16
20
  import {
17
21
  agentFromTaskInput,
@@ -21,12 +25,24 @@ import {
21
25
  VERDICT_RE,
22
26
  } from "./markers.ts"
23
27
  import { BlockError, createGates, type Gates } from "./gates.ts"
28
+ import { createEvidenceTracker } from "./evidence.ts"
29
+ import {
30
+ LANDING_REQUIREMENT,
31
+ createToolPolicy,
32
+ isPushLanding,
33
+ type ToolPolicy,
34
+ } from "./tool-policy.ts"
24
35
  import { createLedger, type GateLedger } from "./state.ts"
36
+ import { ORCHESTRATOR_POLICY } from "./policy.ts"
25
37
  import type {
26
38
  CompactionDraft,
39
+ ChildVerdict,
27
40
  EventProperties,
41
+ GateState,
28
42
  GenerateDraft,
29
43
  Host,
44
+ MarkerEvent,
45
+ PermissionEvaluateEvent,
30
46
  PromptHookProperties,
31
47
  TaskInput,
32
48
  ToolHookEvent,
@@ -43,6 +59,22 @@ function asSessionID(value: unknown): string {
43
59
  return String(value ?? "")
44
60
  }
45
61
 
62
+ /**
63
+ * Picks the user prompt text from the host's candidate keys: the FIRST
64
+ * candidate that is a NON-EMPTY string wins. An empty `text` no longer
65
+ * shadows a marker-bearing `prompt` (and vice versa); when neither key holds a
66
+ * non-empty string the prompt text is unresolvable and "" is returned (the
67
+ * keyless-misconfiguration telemetry keys off exactly this).
68
+ * @param properties The `session.prompt` hook payload.
69
+ * @returns The resolved prompt text, or "" when unresolvable.
70
+ */
71
+ function promptTextOf(properties: PromptHookProperties | undefined): string {
72
+ for (const candidate of [properties?.text, properties?.prompt]) {
73
+ if (typeof candidate === "string" && candidate !== "") return candidate
74
+ }
75
+ return ""
76
+ }
77
+
46
78
  /**
47
79
  * Tracks a hook registration handle so it can be disposed on teardown.
48
80
  * @param reg Registration handle returned by a host hook call.
@@ -54,6 +86,42 @@ function track(reg: unknown, cleanups: Array<() => void>): void {
54
86
  }
55
87
  }
56
88
 
89
+ /**
90
+ * Reads the plugin version from package.json for the load heartbeat.
91
+ *
92
+ * Fail-open: returns null when package.json is unreadable (e.g. a bare
93
+ * `setup.ts` + `src/` copy without it), mirroring the `install.mjs` version
94
+ * read. Resolved from `import.meta.url` so it is correct under symlink
95
+ * installs (Node realpaths the module URL to the repo) and npm installs
96
+ * (`package.json` ships at the root). `pkgPath` is injectable so tests can
97
+ * exercise the unreadable path without mocking `fs`.
98
+ * @param pkgPath Optional package.json path override (tests).
99
+ * @returns The version string, or null when it cannot be read.
100
+ */
101
+ export function readPluginVersion(pkgPath?: string): string | null {
102
+ try {
103
+ const resolved =
104
+ pkgPath ?? path.join(path.dirname(fileURLToPath(import.meta.url)), "..", "package.json")
105
+ return JSON.parse(fs.readFileSync(resolved, "utf8")).version ?? null
106
+ } catch {
107
+ return null
108
+ }
109
+ }
110
+
111
+ /**
112
+ * Message prefix the evaluate handler stamps on every plan-gate ask, so the
113
+ * stream consumer can recognize a plan ask even when the host omits the
114
+ * session id on the `permission.asked` event.
115
+ */
116
+ const PLAN_ASK_MESSAGE_PREFIX = "turnstile: plan approval required"
117
+
118
+ /**
119
+ * Message prefix the evaluate handler stamps on every landing-gate ask, so the
120
+ * stream consumer can recognize a landing ask (even when the host omits the
121
+ * session id) and route its reply to the landing gate.
122
+ */
123
+ const LANDING_ASK_MESSAGE_PREFIX = "turnstile: push/PR requires a reviewer"
124
+
57
125
  /**
58
126
  * Builds the turnstile plugin for a host: resolves the layered config,
59
127
  * creates the telemetry writer and gate ledger (replaying the journal),
@@ -69,6 +137,9 @@ export function createTurnstile(
69
137
  ): (h: Host) => Promise<(() => void) | void> {
70
138
  const env = opts.env ?? process.env
71
139
  const { config } = loadConfig(env, opts.config)
140
+ // Version is read once here (fail-open, alongside loadConfig) for the load
141
+ // heartbeat; null on exotic installs where package.json is unreadable.
142
+ const version = readPluginVersion()
72
143
  const journaler: Journaler = createJournaler({
73
144
  metricsDir: config.metricsDir,
74
145
  trace: config.trace,
@@ -77,8 +148,18 @@ export function createTurnstile(
77
148
  })
78
149
  // Config warnings are already journaled by config.ts (the pre-journaler
79
150
  // sink); journaling them here too would double-write every entry.
80
- const gateRe = compileMarkerRegex(config.gateMarker, GATE_RE)
81
- const verdictRe = compileMarkerRegex(config.verdictMarker, VERDICT_RE)
151
+ const gateRe = compileMarkerRegex(config.gateMarker, GATE_RE, (cause) =>
152
+ journaler.journal({
153
+ type: "error",
154
+ error: `config gateMarker: invalid regex, using default: ${String(cause)}`,
155
+ }),
156
+ )
157
+ const verdictRe = compileMarkerRegex(config.verdictMarker, VERDICT_RE, (cause) =>
158
+ journaler.journal({
159
+ type: "error",
160
+ error: `config verdictMarker: invalid regex, using default: ${String(cause)}`,
161
+ }),
162
+ )
82
163
 
83
164
  /**
84
165
  * True when the text contains at least one GATE or VERDICT marker per the
@@ -91,12 +172,527 @@ export function createTurnstile(
91
172
  }
92
173
  const ledger: GateLedger = createLedger(journaler.journalPath, config.maxSessions)
93
174
  const gates: Gates = createGates(config, journaler)
175
+ // Stateless denylist for bash/shell and write/edit tools in execute.before
176
+ // regexes compile once here; it throws the same BlockError gates
177
+ // throw, so the hook's existing catch rethrows blocks and journals the rest.
178
+ const toolPolicy: ToolPolicy = createToolPolicy(config, journaler)
179
+ // Evidence tracker: correlates a GATE: PASS against journaled exit-0
180
+ // bash/shell test executions attributed to the parent. Owns only the
181
+ // *evidence* dimension; marker authority is separate.
182
+ const evidence = createEvidenceTracker(config, (raw) => ledger.parentOf(raw), journaler)
94
183
  let projectDir: string = process.cwd()
95
184
  // Per-delivery dedup scope counter: each task result is a distinct
96
185
  // delivery, so a re-run emitting a byte-identical marker is still
97
186
  // journaled; streaming sources share the empty scope (re-delivery dedup).
98
187
  let taskDeliverySeq = 0
99
188
 
189
+ // Host session API, bound at setup time (hooks already fire post-setup).
190
+ // Kept in factory scope so the shared `resolveParent` helper can call it
191
+ // without capturing the per-setup `h`; enables the plan-reviewer delivery
192
+ // path to await edge resolution via `session.get`.
193
+ let hostSession: Host["session"] | undefined = undefined
194
+
195
+ // Parent-lookup bookkeeping, keyed by child session ID.
196
+ // Values record how many lookups were attempted and why the record stayed
197
+ // unresolved: `"not-visible"` (host `session.get` threw) or `"no-parent-id"`
198
+ // (the record exists but has no parent edge); cleared on success. Capped so
199
+ // long-lived hosts cannot grow it unboundedly.
200
+ const failedLookups = new Map<string, { attempts: number; reason: string }>()
201
+ // `"not-visible"`: host `session.get` threw the lookup is retryable.
202
+ const LOOKUP_REASON_NOT_VISIBLE = "not-visible"
203
+ // `"no-parent-id"`: the record exists but has no parent edge (negative cache).
204
+ const LOOKUP_REASON_NO_PARENT_ID = "no-parent-id"
205
+ // Parent session ID → Date.now() of its LATEST plan-reviewer dispatch
206
+ // (last-wins; cleared on prompt reset for that parent). Plausibility
207
+ // input: an unresolved-child verdict is plausible only within the parent's
208
+ // current task scope (recorded at-or-after this time, and unique across
209
+ // parents). Capped via boundDispatches, the same eviction idiom as
210
+ // boundLookups.
211
+ const planReviewerDispatch = new Map<string, number>()
212
+ // Plan-gate permission asks, keyed by host request id → the parent session
213
+ // the ask was raised for + when it was recorded. Only asks escalated by the
214
+ // evaluate handler (reason "plan-gate") are recorded, so a reply to an
215
+ // unrelated permission ask (bash, write, …) can never match or release the
216
+ // plan gate, and concurrent sessions cannot clobber each other (values
217
+ // carry their own session). Bounded: evict oldest past the cap.
218
+ const planGateAsks = new Map<string, { sessionID: string; recordedAt: number }>()
219
+ // Cap on tracked plan-gate asks: evict oldest (insertion order) past it.
220
+ const maxTrackedPlanAsks = 50
221
+ // Parents whose evaluate handler has escalated to an ask but whose
222
+ // `permission.asked` stream event has not arrived yet: parent → escalation
223
+ // time. The evaluate handler does not know the host request id (it rides the
224
+ // later stream event), so the next ask for an escalated parent is treated as
225
+ // the plan ask and recorded. Bounded by boundAwaitingAsks, and latches
226
+ // expire (see awaitingPlanAskTtlMs) so a lost stream event cannot latch a
227
+ // parent indefinitely.
228
+ const awaitingPlanAsk = new Map<string, number>()
229
+ // Latch TTL for awaiting-plan-ask markers: an escalation whose stream event
230
+ // never arrives stops latching after this window, so a stale marker cannot
231
+ // record an unrelated later ask (whose approval would then release the plan
232
+ // gate). Fail-toward-strict: expired = not recorded = reply inert = the
233
+ // gate re-asks on the next dispatch.
234
+ const awaitingPlanAskTtlMs = 5_000
235
+ // Parents whose evaluate handler has been armed to force a landing ask (the
236
+ // guard sets this under `gitGates.mode: enforce` on a repeat `git push`
237
+ // attempt: no reviewer approval AND >= 1 prior landing block). Parent → arm
238
+ // time. The landing arm consults it (not the command, whose wire shape in the
239
+ // permission event is unverified — the guard is the single source of truth
240
+ // for landing-candidate detection via isPushLanding). Bounded by
241
+ // boundLandingAsks; expires like awaitingPlanAsk so a lost stream event
242
+ // cannot latch a parent indefinitely.
243
+ const pendingLandingAsk = new Map<string, number>()
244
+ const maxTrackedLandingAsks = 50
245
+ const pendingLandingAskTtlMs = 5_000
246
+ // Landing-gate permission asks, keyed by host request id → the parent session
247
+ // the ask was raised for + when it was recorded. Only asks by the evaluate
248
+ // handler's landing arm (message prefixed `turnstile: push/PR …`) are
249
+ // recorded, so a reply to an unrelated permission ask can never match or
250
+ // release the landing gate; per-reason keying keeps a landing `once` grant
251
+ // from ever being consumed by a plan-gate (task) dispatch and vice versa.
252
+ // Bounded: evict oldest past the cap.
253
+ const landingGateAsks = new Map<string, { sessionID: string; recordedAt: number }>()
254
+ const maxTrackedLandingGateAsks = 50
255
+ // A thrown lookup may mean the child record is not yet visible to the
256
+ // host API, so failures are retried up to this many times per session.
257
+ const maxLookupAttempts = 3
258
+ // Bound on tracked sessions: evict oldest (insertion order) when past.
259
+ const maxTrackedLookups = 256
260
+
261
+ /**
262
+ * Bounds the failed-lookup map by evicting its oldest entries.
263
+ */
264
+ function boundLookups(): void {
265
+ while (failedLookups.size > maxTrackedLookups) {
266
+ // The loop condition guarantees a first entry exists.
267
+ const oldest = failedLookups.keys().next().value as string
268
+ failedLookups.delete(oldest)
269
+ }
270
+ }
271
+
272
+ /**
273
+ * Bounds the dispatch-timestamp map by evicting its oldest entries.
274
+ * Mirrors boundLookups; see planReviewerDispatch.
275
+ */
276
+ function boundDispatches(): void {
277
+ while (planReviewerDispatch.size > maxTrackedLookups) {
278
+ // The loop condition guarantees a first entry exists.
279
+ const oldest = planReviewerDispatch.keys().next().value as string
280
+ planReviewerDispatch.delete(oldest)
281
+ }
282
+ }
283
+
284
+ /**
285
+ * Bounds the plan-gate ask map by evicting its oldest recorded asks.
286
+ * Mirrors boundLookups; see planGateAsks.
287
+ */
288
+ function boundPlanAsks(): void {
289
+ while (planGateAsks.size > maxTrackedPlanAsks) {
290
+ // The loop condition guarantees a first entry exists.
291
+ const oldest = planGateAsks.keys().next().value as string
292
+ planGateAsks.delete(oldest)
293
+ }
294
+ }
295
+
296
+ /**
297
+ * Bounds the awaiting-plan-ask map (parent → escalation time), evicting its
298
+ * oldest entries. Mirrors boundPlanAsks; see awaitingPlanAsk.
299
+ */
300
+ function boundAwaitingAsks(): void {
301
+ while (awaitingPlanAsk.size > maxTrackedPlanAsks) {
302
+ // The loop condition guarantees a first entry exists.
303
+ const oldest = awaitingPlanAsk.keys().next().value as string
304
+ awaitingPlanAsk.delete(oldest)
305
+ }
306
+ }
307
+
308
+ /**
309
+ * Reads (and expires) the awaiting-plan-ask latch for a session: a marker
310
+ * older than the TTL is dropped and reported as absent. Fail-toward-strict:
311
+ * an expired latch records nothing, so a reply to any later ask stays inert
312
+ * and the plan gate re-asks on the next dispatch.
313
+ * @param sid The session whose latch is consulted.
314
+ * @returns Whether a live (unexpired) latch exists for the session.
315
+ */
316
+ function hasLiveAwaitingAsk(sid: string): boolean {
317
+ const at = awaitingPlanAsk.get(sid)
318
+ if (at === undefined) return false
319
+ if (Date.now() - at > awaitingPlanAskTtlMs) {
320
+ awaitingPlanAsk.delete(sid)
321
+ return false
322
+ }
323
+ return true
324
+ }
325
+
326
+ /**
327
+ * Bounds the pending-landing-ask map (parent → arm time), evicting its oldest
328
+ * entries. Mirrors boundAwaitingAsks; see pendingLandingAsk.
329
+ */
330
+ function boundLandingAsks(): void {
331
+ while (pendingLandingAsk.size > maxTrackedLandingAsks) {
332
+ const oldest = pendingLandingAsk.keys().next().value as string
333
+ pendingLandingAsk.delete(oldest)
334
+ }
335
+ }
336
+
337
+ /**
338
+ * Bounds the landing-gate ask map (requestID → parent + record time),
339
+ * evicting its oldest entries. Mirrors boundPlanAsks; see landingGateAsks.
340
+ */
341
+ function boundLandingGateAsks(): void {
342
+ while (landingGateAsks.size > maxTrackedLandingGateAsks) {
343
+ const oldest = landingGateAsks.keys().next().value as string
344
+ landingGateAsks.delete(oldest)
345
+ }
346
+ }
347
+
348
+ /**
349
+ * Reads (and expires) the pending-landing-ask latch for a session: an arm
350
+ * older than the TTL is dropped and reported as absent. Fail-toward-strict:
351
+ * an expired latch arms nothing, so a later ask is inert and the landing gate
352
+ * re-blocks. Only armed under `gitGates.mode: enforce` on a repeat attempt, so
353
+ * shadow/off never ask.
354
+ * @param sid The parent session whose latch is consulted.
355
+ * @returns Whether a live (unexpired) latch exists for the session.
356
+ */
357
+ function hasLiveLandingAsk(sid: string): boolean {
358
+ const at = pendingLandingAsk.get(sid)
359
+ if (at === undefined) return false
360
+ if (Date.now() - at > pendingLandingAskTtlMs) {
361
+ pendingLandingAsk.delete(sid)
362
+ return false
363
+ }
364
+ return true
365
+ }
366
+
367
+ /**
368
+ * Resolves and caches the parent of a child session via the host API.
369
+ * Parent-less records are negatively cached (a stable signal: the session
370
+ * exists and has no parent edge); thrown lookups are retried up to
371
+ * `maxLookupAttempts` because the record may appear later. Each failure is
372
+ * recorded as `{ attempts, reason }`: `"not-visible"` when
373
+ * `session.get` threw, `"no-parent-id"` when the record has no parent edge;
374
+ * cleared on success.
375
+ * Without a usable edge the caller falls back to keying by the session
376
+ * itself. Lifted to factory scope so the plan-reviewer delivery path can
377
+ * await it before attribution; byte-identical to the original
378
+ * per-setup definition.
379
+ * @param sessionID Child session ID to resolve.
380
+ */
381
+ async function resolveParent(sessionID: string | undefined): Promise<void> {
382
+ if (!sessionID || ledger.childToParent.has(sessionID)) return
383
+ const attempts = failedLookups.get(sessionID)?.attempts ?? 0
384
+ if (attempts >= maxLookupAttempts) return
385
+ const get = hostSession?.get
386
+ if (typeof get !== "function") return
387
+ try {
388
+ const rec = await get({ sessionID })
389
+ const parentID = rec?.parentID ?? rec?.data?.parentID
390
+ if (typeof parentID === "string") {
391
+ ledger.setParent(sessionID, parentID)
392
+ failedLookups.delete(sessionID)
393
+ } else {
394
+ // Record present but parent-less: negatively cached (a stable signal).
395
+ failedLookups.set(sessionID, {
396
+ attempts: maxLookupAttempts,
397
+ reason: LOOKUP_REASON_NO_PARENT_ID,
398
+ })
399
+ boundLookups()
400
+ }
401
+ } catch {
402
+ // lookup failure: retryable; fall back to keying by the session
403
+ // itself until the record becomes visible or attempts run out
404
+ failedLookups.set(sessionID, { attempts: attempts + 1, reason: LOOKUP_REASON_NOT_VISIBLE })
405
+ boundLookups()
406
+ }
407
+ }
408
+
409
+ /**
410
+ * Resolves the read-budget key for a whole-file read: awaits the child→parent
411
+ * edge, then returns the parent session (or the session itself when the edge
412
+ * is genuinely absent). Journals a bounded signal only when a lookup was
413
+ * attempted but stayed inconclusive — a child whose record is not yet visible
414
+ * to `session.get`. Root sessions that resolve cleanly as parent-less, and
415
+ * children already resolved to a parent, never signal (no journal spam).
416
+ * @param sid The reading session ID, already normalized via `asSessionID`.
417
+ * @returns The session ID the read budget should be keyed against.
418
+ */
419
+ async function readBudgetKey(sid: string): Promise<string> {
420
+ await resolveParent(sid)
421
+ const key = ledger.parentOf(sid)
422
+ if (key === sid) {
423
+ // Byte-identical read: reads the attempts count off the
424
+ // structured record; the gate (`attempts > 0 && attempts < max`) is
425
+ // unchanged, so this signal is byte-identical to the pre-enrichment form.
426
+ const attempts = failedLookups.get(sid)?.attempts ?? 0
427
+ // Inconclusive window only: a lookup was tried and the record was not yet
428
+ // visible (attempts climb 1..max-1). Zero means no host session API
429
+ // (never knowable); at the cap means a resolved parent-less root. Bounded
430
+ // to ≤ maxLookupAttempts-1 signals per session.
431
+ if (attempts > 0 && attempts < maxLookupAttempts) {
432
+ journaler.journal({
433
+ type: "error",
434
+ sessionID: sid,
435
+ error: `read budget keyed by session; parent unresolved after ${attempts}/${maxLookupAttempts} lookup attempt(s)`,
436
+ })
437
+ }
438
+ }
439
+ return key
440
+ }
441
+
442
+ /**
443
+ * Returns the last verdict match in `text` for `re` (last-match-wins, the
444
+ * same rule `parseGateMarkers` uses); "" when there is no match. Independent
445
+ * of `String.match`'s global-regex lastIndex state because `.match` resets it.
446
+ * @param text The text to scan.
447
+ * @param re The compiled verdict regex.
448
+ * @returns The last match text, or "" when `text` has no verdict match.
449
+ */
450
+ function lastMatch(text: string, re: RegExp): string {
451
+ return text.match(re)?.at(-1) ?? ""
452
+ }
453
+
454
+ /**
455
+ * Stamps a plan-reviewer verdict's approval against the current plans-dir
456
+ * signature so a later re-plan is detectable at dispatch time. An APPROVE
457
+ * against an unreadable dir stamps "" (unknown): the approval is accepted
458
+ * once and stamped on the next enforcing dispatch (fail-open); a BLOCK
459
+ * clears the stamp (hygiene). Mutates `s` in place.
460
+ * @param s The session's gate state (mutated).
461
+ * @param result The verdict result ("APPROVE" or "BLOCK").
462
+ * @param projectDir The current project directory (for the plans-dir scan).
463
+ */
464
+ function stampPlanApproval(s: GateState, result: string, projectDir: string): void {
465
+ s.plansSignature = result === "APPROVE" ? (gates.plansSignature(projectDir) ?? "") : ""
466
+ }
467
+
468
+ /**
469
+ * Splits a self-keyed delivery's attributable children into the
470
+ * registered-edge scan (`edge`, today's scan) and every child with a stored
471
+ * verdict that is not the delivery session itself (`unresolved`). `edge`
472
+ * keeps its LRU promotion; `unresolved` is a pure snapshot that feeds the
473
+ * plausibility join in plausibleUnresolved.
474
+ * @param sid The delivery's parent id (=== child in the self-keyed branch).
475
+ * @returns Registered candidates and verdict-bearing children (minus sid).
476
+ */
477
+ function discoverCandidates(sid: string): {
478
+ edge: string[]
479
+ unresolved: Array<{ child: string; verdict: ChildVerdict }>
480
+ } {
481
+ const edge: string[] = []
482
+ for (const c of ledger.childToParent.keys()) {
483
+ if (ledger.childToParent.get(c) === sid && ledger.childVerdict(c).marker !== "") edge.push(c)
484
+ }
485
+ return {
486
+ edge,
487
+ unresolved: ledger.evidencedChildren().filter(({ child }) => child !== sid),
488
+ }
489
+ }
490
+
491
+ /**
492
+ * Unresolved children plausibly this parent's next task: a plan-reviewer
493
+ * dispatch is recorded for `sid` AND the verdict was recorded at-or-after it
494
+ * (inclusive, tolerating same-millisecond ties) AND no OTHER parent
495
+ * dispatched at-or-before that verdict (unique-owner rule; a verdict with two
496
+ * candidate parents is ambiguous and excluded → fail-open). Pure read.
497
+ * @param sid The delivery's parent id.
498
+ * @param unresolved The `{ child, verdict }` candidates from discoverCandidates.
499
+ * @returns The child ids plausibly belonging to `sid`.
500
+ */
501
+ function plausibleUnresolved(
502
+ sid: string,
503
+ unresolved: Array<{ child: string; verdict: ChildVerdict }>,
504
+ ): string[] {
505
+ const dispatchTs = planReviewerDispatch.get(sid)
506
+ if (dispatchTs === undefined) return []
507
+ const out: string[] = []
508
+ for (const { child, verdict } of unresolved) {
509
+ if (verdict.recordedAt < dispatchTs) continue
510
+ // Unique-owner: another parent dispatched at-or-before this verdict's
511
+ // recordedAt ⇒ ambiguous ⇒ excluded (never-guess).
512
+ let ambiguous = false
513
+ for (const [other, ts] of planReviewerDispatch) {
514
+ if (other === sid) continue
515
+ if (ts <= verdict.recordedAt) {
516
+ ambiguous = true
517
+ break
518
+ }
519
+ }
520
+ if (!ambiguous) out.push(child)
521
+ }
522
+ return out
523
+ }
524
+
525
+ /**
526
+ * The most-recently-recorded child among the given ids, by stored
527
+ * `recordedAt` (ties keep insertion order); undefined when none. Pure read.
528
+ * @param children The candidate child ids (from the plausible list).
529
+ * @returns The most recently recorded child id, or undefined.
530
+ */
531
+ function mostRecentUnresolved(children: string[]): string | undefined {
532
+ let best: string | undefined
533
+ let bestAt = -1
534
+ for (const c of children) {
535
+ const at = ledger.childVerdict(c).recordedAt
536
+ if (at > bestAt) {
537
+ bestAt = at
538
+ best = c
539
+ }
540
+ }
541
+ return best
542
+ }
543
+
544
+ /**
545
+ * Parent-lookup diagnostic for the delivery child, its parent (sid, when
546
+ * distinct), and each unresolved candidate: `{ attempts, reason }` — the
547
+ * current parent-lookup attempt count and why it stayed unresolved
548
+ * (`"not-visible"` / `"no-parent-id"`, or `"resolved-or-never-tried"` when
549
+ * clean). Distinct sessions are kept in the entry so the field can tell the
550
+ * three root-cause hypotheses apart. Pure read; clears nothing.
551
+ * @param child The delivery's session id.
552
+ * @param unresolved The unresolved candidate child ids.
553
+ * @returns A per-session `{ attempts, reason }` map.
554
+ */
555
+ function lookupDiag(
556
+ child: string,
557
+ unresolved: string[],
558
+ ): Record<string, { attempts: number; reason: string }> {
559
+ const out: Record<string, { attempts: number; reason: string }> = {}
560
+ for (const s of [child, ...unresolved])
561
+ out[s] = {
562
+ attempts: failedLookups.get(s)?.attempts ?? 0,
563
+ reason: failedLookups.get(s)?.reason ?? "resolved-or-never-tried",
564
+ }
565
+ for (const c of unresolved)
566
+ out[c] = {
567
+ attempts: failedLookups.get(c)?.attempts ?? 0,
568
+ reason: failedLookups.get(c)?.reason ?? "resolved-or-never-tried",
569
+ }
570
+ return out
571
+ }
572
+
573
+ /**
574
+ * Attributes a plan-reviewer result to the parent's plan-approval gate,
575
+ * ensuring the journaled `plan` entry's `raw` is always an actual child
576
+ * session ID (never the parent `sessionID`):
577
+ *
578
+ * - Edge-resolved (`childToParent.has(child)`): the delivery's own verdict
579
+ * wins over any stored child-text observation (delivery-wins only when the
580
+ * output itself carries a marker); the `source` names the winning pipeline.
581
+ * `raw = child`.
582
+ * - Self-keyed (`parentOf(child) === child`, e.g. a host re-fire reporting
583
+ * the parent's own session ID): never reads `childVerdict(child)` (that is
584
+ * the parent's own quoted text, not a child's evidence). It journals a
585
+ * forensic `type:"attribution"` entry first (before any mutation,
586
+ * replay-inert, never containing the word "unattributable" so the
587
+ * /unattributable/ error count stays exactly one), then discovers
588
+ * attributable children from registered edges AND from unresolved children
589
+ * whose streamed verdict plausibly belongs to this parent, and
590
+ * attributes through the unique unanimous pool (`raw = first edge child,
591
+ * else most-recently-recorded unresolved child`). Zero candidates, or more
592
+ * than one (contradictory), journals the existing `type:"error"` and
593
+ * mutates nothing: a marker-less, unattributable parent-keyed delivery can
594
+ * never clobber a live approval with a BLOCK, and any plausible streamed
595
+ * APPROVE makes a self-keyed BLOCK unreachable (structural fail-open).
596
+ *
597
+ * Never throws (fail-open).
598
+ * @param child The raw child session ID, for the `plan` entry's `raw` field.
599
+ * @param output The delivery's own result text.
600
+ */
601
+ async function attributePlanVerdict(child: string, output: string): Promise<void> {
602
+ const sid = ledger.parentOf(child)
603
+ const own = lastMatch(output, verdictRe)
604
+ // Edge-resolved: attribute through the child directly, delivery-wins merge.
605
+ if (ledger.childToParent.has(child)) {
606
+ const s = ledger.state(sid)
607
+ const rec = ledger.childVerdict(child)
608
+ const marker = own || rec.marker
609
+ const markerSource = own ? "task-result" : rec.source || "task-result"
610
+ const result = marker.includes("APPROVE") ? "APPROVE" : "BLOCK"
611
+ s.hasPlanVerdict = result === "APPROVE"
612
+ stampPlanApproval(s, result, projectDir)
613
+ journaler.journal({
614
+ type: "plan",
615
+ sessionID: sid,
616
+ raw: child,
617
+ result,
618
+ marker: marker || "<none>",
619
+ source: markerSource,
620
+ })
621
+ journaler.traceEvent("plan-reviewer", "verdict", marker || "<no verdict marker>", sid)
622
+ return
623
+ }
624
+ // Self-keyed: the delivery's own session id is not a registered child.
625
+ const { edge, unresolved } = discoverCandidates(sid)
626
+ const unresolvedIDs = unresolved.map(({ child: cid }) => cid)
627
+ const plausible = plausibleUnresolved(sid, unresolved)
628
+ const edgeCandidates = edge
629
+ const pool = [...edgeCandidates, ...plausible]
630
+ const verdictOf = (c: string): "APPROVE" | "BLOCK" =>
631
+ ledger.childVerdict(c).marker.includes("APPROVE") ? "APPROVE" : "BLOCK"
632
+ const unanimous = new Set(pool.map(verdictOf))
633
+ // Attribute only on a non-empty unanimous pool: today's lone-edge-candidate
634
+ // attribution (edge-only) is unchanged; a plausible streamed APPROVE either
635
+ // becomes the sole candidate (approved) or contradicts a BLOCK pool
636
+ // (fail-open) — so a self-keyed BLOCK is unreachable while such evidence
637
+ // exists.
638
+ const attributed =
639
+ pool.length > 0 && unanimous.size === 1
640
+ ? (edgeCandidates[0] ?? mostRecentUnresolved(plausible))
641
+ : undefined
642
+ const decision = attributed ? "attributed" : "fail-open"
643
+ // Journal the forensic entry first (before any gate mutation). A new journal
644
+ // type is replay-inert; its wording avoids "unattributable" so the
645
+ // single-error count is stable.
646
+ journaler.journal({
647
+ type: "attribution",
648
+ sessionID: sid,
649
+ raw: child,
650
+ decision,
651
+ deliveryIsParent: child === sid,
652
+ sessionApi: typeof hostSession?.get === "function",
653
+ candidates: {
654
+ edge: edgeCandidates.length,
655
+ unresolved: unresolved.length,
656
+ plausible: plausible.length,
657
+ },
658
+ unresolvedIDs,
659
+ lookup: lookupDiag(child, unresolvedIDs),
660
+ })
661
+ if (attributed) {
662
+ const s = ledger.state(sid)
663
+ const rec = ledger.childVerdict(attributed)
664
+ // Attributed candidates are evidenced (a non-empty stored marker and a
665
+ // non-empty source label), so marker needs no "<none>" fallback here —
666
+ // the edge-resolved branch above keeps its fallback for marker-less
667
+ // deliveries.
668
+ const marker = own || rec.marker
669
+ const markerSource = own ? "task-result" : rec.source
670
+ const result = marker.includes("APPROVE") ? "APPROVE" : "BLOCK"
671
+ s.hasPlanVerdict = result === "APPROVE"
672
+ stampPlanApproval(s, result, projectDir)
673
+ journaler.journal({
674
+ type: "plan",
675
+ sessionID: sid,
676
+ raw: attributed,
677
+ result,
678
+ marker,
679
+ source: markerSource,
680
+ })
681
+ journaler.traceEvent("plan-reviewer", "verdict", marker, sid)
682
+ return
683
+ }
684
+ // Empty or contradictory evidence: fail-open. Gate untouched — never a
685
+ // BLOCK, never a parent-keyed plan entry, never a clobber of a live
686
+ // approval (the attribution entry above carries the diagnostics). A
687
+ // one-candidate pool always attributes, so this error only fires with
688
+ // zero or multiple candidates — plural noun always correct.
689
+ journaler.journal({
690
+ type: "error",
691
+ sessionID: sid,
692
+ error: `plan attribution: unattributable parent-keyed delivery; gate unchanged (${pool.length} evidenced candidates)`,
693
+ })
694
+ }
695
+
100
696
  /**
101
697
  * Parses markers out of assistant text and journals first-sight events.
102
698
  * @param text The assistant text to scan.
@@ -106,19 +702,60 @@ export function createTurnstile(
106
702
  */
107
703
  function parseMarkers(
108
704
  text: string,
109
- sessionID: string | undefined,
705
+ sessionID: string,
110
706
  source: string,
111
707
  dedupScope = "",
112
- ): void {
113
- if (!text) return
708
+ ): MarkerEvent[] {
709
+ const events: MarkerEvent[] = []
710
+ if (!text) return events
711
+ // Record a child's latest VERDICT from the text itself, before the parent
712
+ // edge is needed, so a child with an unresolved edge still records. Only
713
+ // non-task-result sources enter the store: task-result markers are handled
714
+ // by the delivery's own match during attribution, and keeping them out
715
+ // preserves "a marker-less task-result delivery keeps the gate red".
716
+ //
717
+ // Store-backed verdict observations: the store records the child's latest
718
+ // VERDICT (per (child, marker, source) first-sight) for the plan gate's
719
+ // attribution reads; the parent-bucket dedup (markers.ts) keys on
720
+ // (scope, marker), so identical re-delivery and a cross-source same-marker
721
+ // replay (a source change with an unchanged marker, e.g. delta→ended) are
722
+ // both silently suppressed; only a marker flip reaches the journal.
723
+ if (sessionID && source !== "task-result") {
724
+ const m = lastMatch(text, verdictRe)
725
+ if (m) ledger.recordChildVerdict(sessionID, m, source)
726
+ }
114
727
  const sid = ledger.parentOf(sessionID)
115
- if (!sid) return
728
+ if (!sid) return events
116
729
  const s = ledger.state(sid)
117
- for (const event of parseGateMarkers(s, text, gateRe, verdictRe, dedupScope)) {
730
+ // Evidence verdict computed here: the caller owns the tracker + config.
731
+ // Under "enforce" the flip switch is the observed correlation; under "flag"
732
+ // the flip switch is omitted (always flips) while the journal is stamped;
733
+ // under "off" neither the flip switch nor the stamp is passed, keeping
734
+ // events byte-identical to legacy output.
735
+ const observed = config.gateEvidence === "off" ? undefined : evidence.has(sid)
736
+ const passVerified = config.gateEvidence === "enforce" ? observed : undefined
737
+ const stampVerified = observed
738
+ const stampMode = observed === undefined ? undefined : config.gateEvidence
739
+ // Thread the raw child session ID as `raw`: dedup provenance is per-child,
740
+ // so a dedup hit from a different child accounts as cross-child suppression
741
+ // while the same child's re-delivery stays silent.
742
+ for (const event of parseGateMarkers(
743
+ s,
744
+ text,
745
+ gateRe,
746
+ verdictRe,
747
+ dedupScope,
748
+ sessionID,
749
+ passVerified,
750
+ stampVerified,
751
+ stampMode,
752
+ )) {
118
753
  const suppressed = event.suppressed > 0 ? { suppressed: event.suppressed } : {}
119
754
  if (event.kind === "gate") {
120
755
  // Keyed by parent session so journal replay restores state where the
121
756
  // reviewer gate looks it up; raw child ID kept for forensics.
757
+ const evidenceStamp =
758
+ event.verified !== undefined ? { verified: event.verified, mode: event.mode } : {}
122
759
  journaler.journal({
123
760
  type: "gate",
124
761
  sessionID: sid,
@@ -126,10 +763,16 @@ export function createTurnstile(
126
763
  result: event.result,
127
764
  source,
128
765
  marker: event.marker,
766
+ ...evidenceStamp,
129
767
  ...suppressed,
130
768
  })
131
- journaler.traceEvent("test-runner", "gate", event.marker)
769
+ journaler.traceEvent("test-runner", "gate", event.marker, sid)
132
770
  } else {
771
+ // Streaming verdict dedup is per (child, marker, source) via the store
772
+ // (a source change with an unchanged marker re-journals; identical
773
+ // re-delivery never emits an event here because the parent-bucket
774
+ // dedup on (scope, marker) suppresses it first). Gate-kind events
775
+ // above are unaffected.
133
776
  journaler.journal({
134
777
  type: "verdict",
135
778
  sessionID: sid,
@@ -139,9 +782,13 @@ export function createTurnstile(
139
782
  source,
140
783
  ...suppressed,
141
784
  })
142
- journaler.traceEvent("reviewer", "verdict", event.marker)
785
+ journaler.traceEvent("reviewer", "verdict", event.marker, sid)
143
786
  }
787
+ // Collected for the caller: the execute.after reviewer branch uses these
788
+ // events to detect a first-sight verdict APPROVE and set hasReviewApprove.
789
+ events.push(event)
144
790
  }
791
+ return events
145
792
  }
146
793
 
147
794
  /**
@@ -152,68 +799,121 @@ export function createTurnstile(
152
799
  */
153
800
  return async function setup(h: Host): Promise<(() => void) | void> {
154
801
  const cleanups: Array<() => void> = []
802
+ // Count of successfully registered hooks (3 session + 2 tool + 1 event
803
+ // subscriber = 6 on a full host); feeds the load heartbeat below.
804
+ let hooks = 0
805
+ // Prompt-channel waiver capture (issue #34, AC-1): the waiver must
806
+ // provenance from the user's prompt, never the dispatch prompt. A counter
807
+ // tracks prompts where NEITHER candidate text key (`text`/`prompt`) holds a
808
+ // non-empty string, so an unresolvable source is reported once (keylessPrompts hits
809
+ // 1 exactly once) as a misconfiguration (mirrors the bash `command ?? cmd`
810
+ // telemetry).
811
+ let keylessPrompts = 0
155
812
 
156
- // Parent-lookup bookkeeping, keyed by child session ID. Values are
157
- // attempts made; capped so long-lived hosts cannot grow it unboundedly.
158
- const failedLookups = new Map<string, number>()
159
- // A thrown lookup may mean the child record is not yet visible to the
160
- // host API, so failures are retried up to this many times per session.
161
- const maxLookupAttempts = 3
162
- // Bound on tracked sessions: evict oldest (insertion order) when past.
163
- const maxTrackedLookups = 256
164
-
165
- /**
166
- * Bounds the failed-lookup map by evicting its oldest entries.
167
- */
168
- function boundLookups(): void {
169
- while (failedLookups.size > maxTrackedLookups) {
170
- const oldest = failedLookups.keys().next()
171
- if (oldest.done) break
172
- failedLookups.delete(oldest.value)
173
- }
174
- }
175
-
176
- /**
177
- * Resolves and caches the parent of a child session via the host API.
178
- * Parent-less records are negatively cached (a stable signal: the
179
- * session exists and has no parent edge); thrown lookups are retried
180
- * up to `maxLookupAttempts` because the record may appear later.
181
- * Without a usable edge the caller falls back to keying by the session
182
- * itself.
183
- * @param sessionID Child session ID to resolve.
184
- */
185
- async function resolveParent(sessionID: string | undefined): Promise<void> {
186
- if (!sessionID || ledger.childToParent.has(sessionID)) return
187
- const attempts = failedLookups.get(sessionID) ?? 0
188
- if (attempts >= maxLookupAttempts) return
189
- const get = h.session?.get
190
- if (typeof get !== "function") return
191
- try {
192
- const rec = await get({ sessionID })
193
- const parentID = rec?.parentID ?? rec?.data?.parentID
194
- if (typeof parentID === "string") {
195
- ledger.setParent(sessionID, parentID)
196
- failedLookups.delete(sessionID)
197
- } else {
198
- failedLookups.set(sessionID, maxLookupAttempts)
199
- boundLookups()
200
- }
201
- } catch {
202
- // lookup failure: retryable; fall back to keying by the session
203
- // itself until the record becomes visible or attempts run out
204
- failedLookups.set(sessionID, attempts + 1)
205
- boundLookups()
206
- }
207
- }
813
+ // Bind the host session API for the shared `resolveParent` helper (hooks
814
+ // already fire post-setup).
815
+ hostSession = h.session
208
816
 
209
817
  // Task-state reset: a new user prompt starts a new task.
210
818
  if (typeof h.session?.hook === "function") {
211
819
  try {
212
820
  const reg = await h.session.hook("prompt", (properties: PromptHookProperties) => {
213
821
  const sessionID = properties?.sessionID
214
- if (typeof sessionID === "string") ledger.resetState(sessionID)
822
+ if (typeof sessionID === "string") {
823
+ // Mark the session a live parent before the reset: resetState
824
+ // re-caps `states`, and a registered parent is never evicted,
825
+ // so the rebuilt MRU slot stays pinned.
826
+ ledger.markParent(sessionID)
827
+ ledger.resetState(sessionID)
828
+ // A prompt reset invalidates all prior dispatch-scoped
829
+ // evidence for this parent, so an unresolved-child verdict is only
830
+ // plausible within the parent's current task scope.
831
+ planReviewerDispatch.delete(sessionID)
832
+ // Evidence is task-scoped, exactly like gate state: a new user
833
+ // prompt drops the in-memory correlation.
834
+ evidence.clear(sessionID)
835
+ // A prompt reset also drops any pending plan-ask latch: the
836
+ // escalation's stream event belongs to the previous task scope, so
837
+ // a new prompt must not let a later unrelated ask be recorded as
838
+ // the plan ask.
839
+ awaitingPlanAsk.delete(sessionID)
840
+ // A landing-ask arm (an enforce repeat-attempt escalation) belongs
841
+ // to the previous task scope too: a new prompt drops it, so the new
842
+ // task starts from a pure first block again.
843
+ pendingLandingAsk.delete(sessionID)
844
+ // Waiver capture (prompt channel): the user's text following the
845
+ // waiver marker waives both gates for the whole task. Read + capture
846
+ // in one guarded block; a coercion that throws (a malformed prompt
847
+ // value) is journaled under the historical "waiver capture" key and
848
+ // swallowed — fail-open.
849
+ try {
850
+ const waiverText = promptTextOf(properties)
851
+ const at = waiverText.indexOf(config.waiverMarker)
852
+ if (at !== -1) {
853
+ ledger.state(sessionID).userWaiver = config.trace
854
+ ? waiverText.slice(at, at + 200)
855
+ : "<present>"
856
+ }
857
+ } catch (e) {
858
+ // Fail-open: capture never blocks a prompt.
859
+ journaler.journal({ type: "error", error: `waiver capture: ${e}` })
860
+ }
861
+ // Human approval (item 7): a `PLAN APPROVED:` marker in the user
862
+ // prompt releases the plan gate — the same release signal the
863
+ // plan-reviewer verdict sets. Read + capture in one guarded block:
864
+ // set hasPlanVerdict (execute.before reads it) and humanApproval
865
+ // (provenance), and journal an APPROVE entry that replays identically
866
+ // to a verdict (replayGatePasses accepts any type:"plan" APPROVE;
867
+ // mergeAfterReplay carries it). A coercion that throws is journaled
868
+ // and swallowed — fail-open.
869
+ try {
870
+ const approvalText = promptTextOf(properties)
871
+ const atPlan = approvalText.indexOf(config.approvalMarker)
872
+ if (atPlan !== -1) {
873
+ const s = ledger.state(sessionID)
874
+ s.hasPlanVerdict = true
875
+ s.humanApproval = true
876
+ journaler.journal({
877
+ type: "plan",
878
+ sessionID,
879
+ result: "APPROVE",
880
+ // Provenance only: replay reads only type+result, so this
881
+ // restores exactly like a VERDICT: APPROVE verdict.
882
+ source: "session-prompt",
883
+ marker: config.approvalMarker,
884
+ })
885
+ }
886
+ } catch (e) {
887
+ // Fail-open: approval capture never blocks a prompt.
888
+ journaler.journal({ type: "error", error: `approval capture: ${e}` })
889
+ }
890
+ // Misconfiguration telemetry: if neither candidate text key holds a
891
+ // non-empty string, the prompt text is unresolvable (an empty
892
+ // `text` does not count — it cannot carry a marker). Report the
893
+ // first such prompt once (keylessPrompts hits 1 exactly once) so a
894
+ // host-shape change stays observable without journaling on every
895
+ // prompt. An unreadable payload (a hostile getter) counts as
896
+ // unresolvable and is swallowed — fail-open.
897
+ let unresolvable: boolean
898
+ try {
899
+ unresolvable = promptTextOf(properties) === ""
900
+ } catch {
901
+ unresolvable = true
902
+ }
903
+ if (unresolvable) {
904
+ keylessPrompts++
905
+ if (keylessPrompts === 1)
906
+ journaler.journal({
907
+ type: "error",
908
+ sessionID,
909
+ error:
910
+ "waiver capture: neither prompt text key (text|prompt) is a string; does the host send the user prompt elsewhere?",
911
+ })
912
+ }
913
+ }
215
914
  })
216
915
  track(reg, cleanups)
916
+ hooks++
217
917
  } catch (e) {
218
918
  journaler.journal({ type: "error", error: `session.prompt hook registration: ${e}` })
219
919
  }
@@ -246,6 +946,7 @@ export function createTurnstile(
246
946
  }
247
947
  })
248
948
  track(reg, cleanups)
949
+ hooks++
249
950
  } catch (e) {
250
951
  journaler.journal({ type: "error", error: `session.generate hook registration: ${e}` })
251
952
  }
@@ -264,11 +965,103 @@ export function createTurnstile(
264
965
  }
265
966
  })
266
967
  track(reg, cleanups)
968
+ hooks++
267
969
  } catch (e) {
268
970
  journaler.journal({ type: "error", error: `session.compaction hook registration: ${e}` })
269
971
  }
270
972
  }
271
973
 
974
+ // Permission escalation (Phase 1): on a `task` dispatch the host asks the
975
+ // plugin to evaluate whether the call should proceed. The payload carries
976
+ // NO tool input, so escalation is driven by the parent session's plan-gate
977
+ // state (planGateStatus), never the dispatch target. Optional surface: an
978
+ // absent permission.hook degrades to today's behavior exactly.
979
+ if (typeof h.permission?.hook === "function") {
980
+ try {
981
+ const reg = await h.permission.hook("evaluate", (event: unknown) => {
982
+ try {
983
+ const pe = event as PermissionEvaluateEvent
984
+ const sessionID = asSessionID(pe?.sessionID)
985
+ // Plan-approval arm: the task/subagent dispatch exposes
986
+ // `action === "task"`; the payload carries no tool input, so
987
+ // escalation is driven purely by the parent's plan-gate state.
988
+ if (pe?.action === "task") {
989
+ const parent = ledger.parentOf(sessionID)
990
+ const status = gates.planGateStatus(projectDir, ledger, parent)
991
+ if (!status.red) return
992
+ // Red ⇒ summary is present by construction (planGateStatus builds
993
+ // it from a scan whose names[0] was already read). Escalate:
994
+ // force the host to ask the human, naming the plan file + head.
995
+ // Journal the ask so a reply can be matched (the requestID rides
996
+ // the permission.asked stream event).
997
+ const file = status.summary!.file
998
+ // The plan body is prompt context; journal/quote it only when the
999
+ // trace is enabled (SECURITY.md redaction contract), mirroring how
1000
+ // the waiver quote is trace-gated at capture. Without it the ask
1001
+ // still names the plan file.
1002
+ const head = config.trace ? status.summary!.head : ""
1003
+ pe.effect = "ask"
1004
+ pe.message = head
1005
+ ? `${PLAN_ASK_MESSAGE_PREFIX} — ${file}: ${head}`
1006
+ : `${PLAN_ASK_MESSAGE_PREFIX} — ${file}`
1007
+ // Mark this parent as awaiting its plan ask: the NEXT
1008
+ // permission.asked stream event for this session is recorded as
1009
+ // the plan-gate ask, so only escalated asks can ever match.
1010
+ awaitingPlanAsk.set(parent, Date.now())
1011
+ boundAwaitingAsks()
1012
+ journaler.journal({
1013
+ type: "permission",
1014
+ sessionID: parent,
1015
+ action: "ask",
1016
+ // The requestID is attached to the subsequent permission.asked
1017
+ // stream event (matched below by message prefix / latch).
1018
+ reason: "plan-gate",
1019
+ plan: file,
1020
+ })
1021
+ return
1022
+ }
1023
+ // Landing arm: a bash/shell permission.evaluate whose landing ask
1024
+ // is armed (the guard set the pending-landing-ask latch under
1025
+ // enforce on a repeat attempt). Candidate detection lives in
1026
+ // `isPushLanding` (used by the guard); the evaluate handler
1027
+ // consults the arm rather than re-reading the command from the
1028
+ // (host-shape-uncertain) permission event — the guard is the single
1029
+ // source of truth for landing-candidate detection. Approval
1030
+ // releases the gate; a live latch means the human must decide this
1031
+ // landing (the first block already threw, and shadow never arms).
1032
+ // The arm is ONE-SHOT: it is consumed by the first evaluate it
1033
+ // forces. The permission request the arm is meant to attach to is
1034
+ // the very next one (the repeat push the guard just allowed through
1035
+ // for escalation), so a one-shot latch cannot mislabel a later
1036
+ // unrelated command with the push/PR ask inside the TTL window.
1037
+ if (pe?.action === "bash" || pe?.action === "shell") {
1038
+ const parent = ledger.parentOf(sessionID)
1039
+ const st = ledger.state(parent)
1040
+ if (st.hasReviewApprove) return
1041
+ if (!hasLiveLandingAsk(parent)) return
1042
+ pendingLandingAsk.delete(parent)
1043
+ pe.effect = "ask"
1044
+ pe.message = `turnstile: ${LANDING_REQUIREMENT}`
1045
+ // No journal of the ask here: guardLanding already journaled
1046
+ // `{type:"permission", action:"ask", reason:"landing"}` when it
1047
+ // armed the latch, so there is a single landing-ask entry.
1048
+ return
1049
+ }
1050
+ return
1051
+ } catch (e) {
1052
+ // Fail-open: the evaluate handler NEVER blocks the host.
1053
+ journaler.journal({ type: "error", error: `permission evaluate: ${e}` })
1054
+ }
1055
+ })
1056
+ track(reg, cleanups)
1057
+ hooks++
1058
+ } catch (e) {
1059
+ // A registration failure journals (fail-open); a bad hook leaves
1060
+ // today's behavior exactly.
1061
+ journaler.journal({ type: "error", error: `permission.evaluate hook registration: ${e}` })
1062
+ }
1063
+ }
1064
+
272
1065
  // Tool-level gate + trace.
273
1066
  if (typeof h.tool?.hook === "function") {
274
1067
  try {
@@ -277,6 +1070,10 @@ export function createTurnstile(
277
1070
  if (event?.tool === "task" || event?.tool === "subagent") {
278
1071
  const agent = agentFromTaskInput(event.input)
279
1072
  const sessionID = asSessionID(event?.sessionID)
1073
+ // Sessions dispatching guarded subagents are parents by
1074
+ // definition: mark before the guards so their
1075
+ // state() inserts land pinned against LRU eviction.
1076
+ ledger.markParent(sessionID)
280
1077
  const prompt = String(event.input?.prompt ?? "")
281
1078
  journaler.journal({
282
1079
  type: "dispatch",
@@ -288,23 +1085,140 @@ export function createTurnstile(
288
1085
  ? String(event.input?.description ?? "").slice(0, 200)
289
1086
  : "",
290
1087
  })
291
- journaler.traceEvent("orchestrator", "dispatch", `task -> ${agent}`)
1088
+ journaler.traceEvent("orchestrator", "dispatch", `task -> ${agent}`, sessionID)
1089
+
1090
+ // Last-wins: record this parent's LATEST plan-reviewer
1091
+ // dispatch time for the plausibility join. A prompt reset clears it
1092
+ // (see the prompt hook), so a verdict is only plausible within the
1093
+ // parent's current task scope. Bounded by boundDispatches.
1094
+ if (agent === config.agents.planReviewer) {
1095
+ planReviewerDispatch.set(sessionID, Date.now())
1096
+ boundDispatches()
1097
+ }
1098
+
1099
+ // An empty agent name is the only reliable misconfiguration
1100
+ // signal: the host input shape no longer uses
1101
+ // subagent_type/agent/subagent, so every gate condition misses.
1102
+ // Telemetry only — journaling cannot throw.
1103
+ if (!agent)
1104
+ journaler.journal({
1105
+ type: "error",
1106
+ sessionID,
1107
+ error:
1108
+ "task dispatch: could not resolve the agent name; does the host input shape still use subagent_type/agent/subagent?",
1109
+ })
1110
+
1111
+ // Once-granted human approval (from a `permission.replied` reply)
1112
+ // grants exactly ONE EXECUTOR dispatch past the plan gate and is
1113
+ // cleared immediately, so a stale grant cannot outlive the single
1114
+ // dispatch it was given for, short-circuiting the plan-gate block
1115
+ // below. Scoped to the executor dispatch class: the grant exists
1116
+ // solely to release the plan-gate block, so any other agent
1117
+ // (reviewer, plan-reviewer, …) leaves it pending and still runs
1118
+ // its own guards (guardReviewerDispatch / …). execute.before
1119
+ // consults only hasPlanVerdict / pendingOnceGrant — never
1120
+ // humanApproval, which is provenance only.
1121
+ if (
1122
+ agent === config.agents.executor &&
1123
+ ledger.state(sessionID).pendingOnceGrant.plan !== ""
1124
+ ) {
1125
+ // Consume the plan-gate once grant (a `permission.replied`
1126
+ // `once`): clears just `.plan` so a landing `.landing` grant,
1127
+ // if any, survives for the push it was given for.
1128
+ ledger.state(sessionID).pendingOnceGrant.plan = ""
1129
+ return
1130
+ }
292
1131
 
293
1132
  if (agent === config.agents.reviewer) {
294
- gates.guardReviewerDispatch(sessionID, prompt, ledger)
1133
+ gates.guardReviewerDispatch(sessionID, ledger)
295
1134
  }
296
1135
 
297
1136
  if (agent === config.agents.executor) {
298
- gates.enforcePlanApproval(sessionID, prompt, projectDir, ledger)
1137
+ gates.enforcePlanApproval(sessionID, projectDir, ledger)
299
1138
  gates.enforceDecomposition(sessionID, prompt, projectDir)
300
1139
  }
301
1140
  return
302
1141
  }
303
1142
 
304
1143
  if (event?.tool === "read") {
305
- // Keyed off the parent session: a subagent's reads draw down
306
- // the parent's budget (AGENTS.md contract), not a per-child one.
307
- gates.guardReadLimit(ledger.parentOf(asSessionID(event?.sessionID)), ledger)
1144
+ // Keyed off the parent session: a subagent's reads draw down the
1145
+ // parent's budget (AGENTS.md contract), not a per-child one.
1146
+ // Resolve the child→parent edge first so an in-flight child keys
1147
+ // the parent's budget, not its own; a still-unresolved child
1148
+ // self-keys and journals a bounded signal.
1149
+ gates.guardReadLimit(await readBudgetKey(asSessionID(event?.sessionID)), ledger)
1150
+ }
1151
+
1152
+ // Tool-call denylist: bash/shell commands and write/edit
1153
+ // target paths are matched against the configured denylist. The
1154
+ // extraction coerces the unconfirmed host payload (candidate keys
1155
+ // documented on each guard); an empty result fails open with
1156
+ // misconfiguration telemetry. A block throws BlockError (rethrown by
1157
+ // the catch below); shadow journals only; off is a no-op.
1158
+ if (event?.tool === "bash" || event?.tool === "shell") {
1159
+ const command = event.input?.command ?? event.input?.cmd
1160
+ const sid = asSessionID(event?.sessionID)
1161
+ // Denylist first: `gh pr create|merge` is denied wholesale, so it
1162
+ // wins over the landing telemetry/gate below and the two journal
1163
+ // reasons stay disjoint.
1164
+ toolPolicy.guardBash(event.tool, command, sid)
1165
+ // Conventional-commit lint (fail-closed under enforce for
1166
+ // uninspectable forms; audit-only under shadow; silent under off).
1167
+ toolPolicy.guardCommitMessage(event.tool, command, sid)
1168
+ // Landing telemetry (always) then the reviewer-gated landing gate.
1169
+ const text = String(command ?? "")
1170
+ toolPolicy.observeLanding(sid, text)
1171
+ // The stateful ledger stays out of tool-policy: only a shaped
1172
+ // capability is handed over. `parent` is the parent session (a
1173
+ // subagent's bash draws on the parent's task scope) and `st` is
1174
+ // its bucket, so a still-unattributed child self-keys its own
1175
+ // fresh state.
1176
+ const parent = ledger.parentOf(sid)
1177
+ const st = ledger.state(parent)
1178
+ // Landing once-grant (from a `permission.replied` `once` for a
1179
+ // landing ask) releases exactly ONE push, consumed here, before
1180
+ // the gate decides, so a stale grant cannot outlive that single
1181
+ // dispatch. Scoped to landing candidates: a non-push bash command
1182
+ // leaves the grant pending (burning it on `git status` would
1183
+ // defeat the "single push" contract). Keyed per-reason
1184
+ // (`.landing`), so it can never be consumed by a plan-gate (task)
1185
+ // dispatch or vice versa.
1186
+ if (st.pendingOnceGrant.landing !== "" && isPushLanding(text)) {
1187
+ st.pendingOnceGrant.landing = ""
1188
+ return
1189
+ }
1190
+ toolPolicy.guardLanding(event.tool, text, sid, {
1191
+ hasApproval: () => st.hasReviewApprove,
1192
+ priorBlocks: () => st.landingBlocks,
1193
+ recordBlock: () => {
1194
+ st.landingBlocks += 1
1195
+ },
1196
+ escalate: () => {
1197
+ // Arm the pending-landing-ask latch: the evaluate handler
1198
+ // forces the human ask off this latch. Called by guardLanding
1199
+ // only under `enforce` on a repeat attempt (no approval,
1200
+ // >= 1 prior block); fail-toward-strict — a host reject leaves
1201
+ // approval absent, so the next attempt re-blocks and re-arms.
1202
+ pendingLandingAsk.set(parent, Date.now())
1203
+ boundLandingAsks()
1204
+ },
1205
+ })
1206
+ return
1207
+ }
1208
+ if (event?.tool === "write" || event?.tool === "edit") {
1209
+ toolPolicy.guardPath(
1210
+ event.tool,
1211
+ event.input?.filePath ?? event.input?.path,
1212
+ asSessionID(event?.sessionID),
1213
+ )
1214
+ return
1215
+ }
1216
+ if (event?.tool === "session_move") {
1217
+ // Telemetry only (never blocks): journal the dispatched move. The
1218
+ // target directory comes from the (unconfirmed) tool payload.
1219
+ const moveDir = (event.input as { directory?: unknown } | undefined)?.directory
1220
+ toolPolicy.observeSessionMove(asSessionID(event?.sessionID), moveDir)
1221
+ return
308
1222
  }
309
1223
  } catch (e) {
310
1224
  if (e instanceof BlockError) throw e
@@ -312,6 +1226,7 @@ export function createTurnstile(
312
1226
  }
313
1227
  })
314
1228
  track(reg, cleanups)
1229
+ hooks++
315
1230
  } catch (e) {
316
1231
  journaler.journal({ type: "error", error: `tool.before hook registration: ${e}` })
317
1232
  }
@@ -321,51 +1236,121 @@ export function createTurnstile(
321
1236
  try {
322
1237
  if (event?.tool === "task" || event?.tool === "subagent") {
323
1238
  const output = String(event.result?.output ?? "")
324
- parseMarkers(
1239
+ const child = asSessionID(event?.sessionID)
1240
+ const agent =
1241
+ agentFromTaskInput(event.input) ||
1242
+ agentFromTaskInput(event.result?.input as TaskInput | undefined)
1243
+ // Plan-reviewer delivery path only: await the child→parent edge
1244
+ // before marker parsing and attribution, so a continued subagent
1245
+ // session (no new session.created; edge resolved via session.get)
1246
+ // still attributes. Every other agent keeps today's byte-identical
1247
+ // sync ordering.
1248
+ if (agent === config.agents.planReviewer) {
1249
+ await resolveParent(child)
1250
+ }
1251
+ // Mark the resolved parent live: covers both host
1252
+ // delivery shapes (parent-keyed and child-keyed). A genuinely
1253
+ // unresolved child delivery self-marks, protecting one extra
1254
+ // bucket until the registry LRU ages it out — bounded, documented.
1255
+ ledger.markParent(ledger.parentOf(child))
1256
+ const markerEvents = parseMarkers(
325
1257
  output,
326
- asSessionID(event?.sessionID),
1258
+ child,
327
1259
  "task-result",
328
1260
  `task-result\u0000${++taskDeliverySeq}`,
329
1261
  )
330
- const agent =
331
- agentFromTaskInput(event.input) ||
332
- agentFromTaskInput(event.result?.input as TaskInput | undefined)
1262
+ // Reviewer-APPROVE capture (landing gate, chunk-7): a reviewer
1263
+ // task-result delivery whose first-sight verdict is APPROVE sets
1264
+ // hasReviewApprove — the landing gate's release signal. Mirrors
1265
+ // the plan-reviewer verdict lifecycle but for the landing gate.
1266
+ // Streaming child text does NOT set it: this branch runs only on
1267
+ // task-result delivery, keeping the stream attribution-neutral
1268
+ // (AC-12 — a streamed verdict in child text can't release the
1269
+ // landing gate).
1270
+ if (agent === config.agents.reviewer) {
1271
+ const approved = markerEvents.some(
1272
+ (e) => e.kind === "verdict" && e.marker.includes("APPROVE"),
1273
+ )
1274
+ if (approved) ledger.state(ledger.parentOf(child)).hasReviewApprove = true
1275
+ }
333
1276
  // Plan-approval attribution: only a plan-reviewer result moves
334
- // the plan gate. Latest verdict wins; a BLOCK or marker-less
335
- // result keeps the gate red. Keyed by parent session like the
336
- // other gate state.
1277
+ // the plan gate. See attributePlanVerdict — `raw` is always an
1278
+ // actual child session ID, never the parent; an unattributable,
1279
+ // marker-less parent-keyed re-fire journals type:"error" and
1280
+ // leaves the gate untouched (never clobbers a live approval).
337
1281
  if (agent === config.agents.planReviewer) {
338
- const sid = ledger.parentOf(asSessionID(event?.sessionID))
339
- const s = ledger.state(sid)
340
- const verdicts = output.match(verdictRe) ?? []
341
- const marker = verdicts[verdicts.length - 1] ?? ""
342
- const result = marker.includes("APPROVE") ? "APPROVE" : "BLOCK"
343
- s.hasPlanVerdict = result === "APPROVE"
344
- journaler.journal({
345
- type: "plan",
346
- sessionID: sid,
347
- raw: asSessionID(event?.sessionID),
348
- result,
349
- marker: marker || "<none>",
350
- source: "task-result",
351
- })
352
- journaler.traceEvent("plan-reviewer", "verdict", marker || "<no verdict marker>")
1282
+ await attributePlanVerdict(child, output)
353
1283
  }
1284
+ // The bare-output suppression — a streamed verdict in the
1285
+ // child's own text hiding a summarized, marker-less delivery —
1286
+ // is scoped to the plan-reviewer only. reviewer/test-runner keep
1287
+ // their pre-suppression behavior: a marker-less delivery
1288
+ // violates even when the child's streamed text quoted a VERDICT.
354
1289
  const bare =
355
- config.agents.bareOutput.includes(agent) && output !== "" && !hasMarker(output)
1290
+ config.agents.bareOutput.includes(agent) &&
1291
+ output !== "" &&
1292
+ !hasMarker(output) &&
1293
+ (agent !== config.agents.planReviewer || ledger.childVerdict(child).marker === "")
356
1294
  if (bare) {
357
1295
  journaler.journal({
358
1296
  type: "violation",
359
- sessionID: asSessionID(event?.sessionID),
1297
+ sessionID: child,
360
1298
  agent,
361
1299
  reason: "task output contained no GATE/VERDICT marker",
362
1300
  })
363
1301
  }
1302
+ // Orchestrator policy injection: prepend
1303
+ // the policy to the model-facing output text on the first
1304
+ // per-parent per-task delivery, so the model always carries the
1305
+ // pipeline contract. Scoped three ways: tool name (task/subagent
1306
+ // only), config-known subagent name (children dispatch under
1307
+ // their own name — defense in depth), and the policyInjected
1308
+ // flag (once per task; reset per prompt re-supplies it, also
1309
+ // after compaction). The mutation target is event.result.output
1310
+ // in place — the same production-proven target as the read-budget
1311
+ // warn prefix below. Fail-open via an inner try/catch scoped to
1312
+ // ONLY this phase; the handler's shared outer catch (marker
1313
+ // parsing, plan attribution, read-warn) is intentionally not
1314
+ // disturbed, so a policy failure journals injected:false and
1315
+ // swallows rather than blocking the tool.
1316
+ const subagentNames = new Set(
1317
+ (
1318
+ [
1319
+ "reviewer",
1320
+ "planReviewer",
1321
+ "executor",
1322
+ "testRunner",
1323
+ "debugger",
1324
+ "planner",
1325
+ ] as const
1326
+ ).map((name) => config.agents[name]),
1327
+ )
1328
+ if (typeof event.agent === "string" && subagentNames.has(event.agent)) return
1329
+ const policyState = ledger.state(child)
1330
+ if (policyState.policyInjected) return
1331
+ // A string result is the confirmed task/subagent shape; any other
1332
+ // shape fails open silently (no corruption, no journal entry).
1333
+ if (!event.result || typeof event.result.output !== "string") return
1334
+ try {
1335
+ event.result.output = ORCHESTRATOR_POLICY + "\n" + event.result.output
1336
+ policyState.policyInjected = true
1337
+ journaler.journal({ type: "policy", sessionID: child, injected: true })
1338
+ } catch (e) {
1339
+ journaler.journal({
1340
+ type: "policy",
1341
+ sessionID: child,
1342
+ injected: false,
1343
+ error: String(e),
1344
+ })
1345
+ }
364
1346
  return
365
1347
  }
366
1348
  if (event?.tool === "read") {
367
1349
  // Same parent keying as the guard: count and budget stay in
368
- // step regardless of which session ID the host reports.
1350
+ // step regardless of which session ID the host reports. This
1351
+ // stays synchronous on purpose — the before-hook already awaited
1352
+ // resolveParent(sid) for this same sessionID, so the edge is
1353
+ // cached by the time it fires.
369
1354
  const sessionID = ledger.parentOf(asSessionID(event?.sessionID))
370
1355
  const s = ledger.state(sessionID)
371
1356
  s.reads++
@@ -396,13 +1381,60 @@ export function createTurnstile(
396
1381
  : "<redacted>",
397
1382
  exit: meta.exit ?? meta.exitCode ?? meta.code,
398
1383
  })
1384
+ // Evidence correlation: record a plausible exit-0
1385
+ // test execution and promote an earlier unverified PASS the
1386
+ // moment its evidence arrives, within the same task lifetime.
1387
+ // No command text ever leaves memory (redaction contract).
1388
+ if (config.gateEvidence !== "off") {
1389
+ const obsSid = asSessionID(event?.sessionID)
1390
+ const exit = meta.exit ?? meta.exitCode ?? meta.code
1391
+ if (
1392
+ evidence.observe(
1393
+ obsSid,
1394
+ event.input?.command ?? event.input?.cmd ?? event.result?.title ?? "",
1395
+ exit,
1396
+ )
1397
+ ) {
1398
+ journaler.journal({
1399
+ type: "evidence",
1400
+ sessionID: obsSid,
1401
+ event: "observed",
1402
+ exit,
1403
+ })
1404
+ }
1405
+ const parent = ledger.parentOf(obsSid)
1406
+ // Peek, don't insert: a fresh bucket can never carry a
1407
+ // non-empty `unverifiedPass`, so unknown bash sessions must
1408
+ // not enter the LRU (behavior-preserving guard).
1409
+ const ps = ledger.peek(parent)
1410
+ if (ps && ps.unverifiedPass && evidence.has(parent)) {
1411
+ ps.hasGatePass = true
1412
+ ps.unverifiedPass = ""
1413
+ journaler.journal({
1414
+ type: "gate",
1415
+ sessionID: parent,
1416
+ result: "PASS",
1417
+ event: "promoted",
1418
+ verified: true,
1419
+ mode: config.gateEvidence,
1420
+ })
1421
+ journaler.traceEvent(
1422
+ "test-runner",
1423
+ "gate",
1424
+ "GATE: PASS promoted on evidence",
1425
+ parent,
1426
+ )
1427
+ }
1428
+ }
399
1429
  }
400
1430
  } catch (e) {
401
- if (e instanceof BlockError) throw e
1431
+ // execute.after performs no gating, so nothing here can throw a
1432
+ // BlockError; every failure is journaled and the call proceeds.
402
1433
  journaler.journal({ type: "error", error: `tool.execute.after: ${e}` })
403
1434
  }
404
1435
  })
405
1436
  track(reg, cleanups)
1437
+ hooks++
406
1438
  } catch (e) {
407
1439
  journaler.journal({ type: "error", error: `tool.after hook registration: ${e}` })
408
1440
  }
@@ -431,6 +1463,9 @@ export function createTurnstile(
431
1463
  ) {
432
1464
  ledger.setParent(properties.sessionID, properties.parentID)
433
1465
  } else if (typeof properties.sessionID === "string") {
1466
+ // Root sessions are live parents; the edge
1467
+ // branch above is covered by setParent marking the parent.
1468
+ ledger.markParent(properties.sessionID)
434
1469
  // Root sessions only: child-session created events must not
435
1470
  // move the project dir (any directory they carry belongs to
436
1471
  // the parent's context, and last-writer-wins across child
@@ -477,6 +1512,123 @@ export function createTurnstile(
477
1512
  })
478
1513
  break
479
1514
  }
1515
+ case "permission.asked": {
1516
+ // Record plan-gate AND landing-gate asks (disjoint by message
1517
+ // prefix). The value carries the ask's parent session so
1518
+ // replies apply per-session and concurrent sessions cannot
1519
+ // cross-apply. Fail-open — the outer loop journals any throw —
1520
+ // and a missing/non-string id is inert (nothing is recorded).
1521
+ const id = properties.id ?? properties.requestID
1522
+ if (typeof id === "string" && id !== "") {
1523
+ const sid = asSessionID(properties.sessionID)
1524
+ const message = typeof properties.message === "string" ? properties.message : ""
1525
+ // Landing-gate ask: message carries the landing-ask prefix
1526
+ // (the evaluate handler's echo). A landing ask is disjoint from
1527
+ // a plan ask (different prefix), so an approval of one never
1528
+ // releases the other.
1529
+ if (message.startsWith(LANDING_ASK_MESSAGE_PREFIX)) {
1530
+ landingGateAsks.set(id, {
1531
+ sessionID: ledger.parentOf(sid),
1532
+ recordedAt: Date.now(),
1533
+ })
1534
+ boundLandingGateAsks()
1535
+ } else {
1536
+ // Plan-gate ask: record when the message carries the
1537
+ // plan-ask prefix (the evaluate echo) or when the session
1538
+ // is live-latched and the ask carries no message of its own
1539
+ // (the escalated echo). A non-empty message WITHOUT either
1540
+ // prefix is an unrelated ask (bash, write, …) even under a
1541
+ // latched escalation — the evaluate handler always composes
1542
+ // a prefix into its message, so a prefixed-or-empty message
1543
+ // is the expected echo and anything else must never be
1544
+ // recorded (a human's approval of an unrelated ask cannot
1545
+ // release the plan or landing gate).
1546
+ const prefixed = message.startsWith(PLAN_ASK_MESSAGE_PREFIX)
1547
+ if (prefixed || (message === "" && hasLiveAwaitingAsk(sid))) {
1548
+ awaitingPlanAsk.delete(sid)
1549
+ planGateAsks.set(id, {
1550
+ sessionID: ledger.parentOf(sid),
1551
+ recordedAt: Date.now(),
1552
+ })
1553
+ boundPlanAsks()
1554
+ } else if (message === "" && hasLiveLandingAsk(sid)) {
1555
+ // Empty-message landing escalate echo: record for the
1556
+ // landing gate only. Reached only when the plan latch is
1557
+ // NOT live — a coincident double-escalation (both latches
1558
+ // live, one empty-message echo) resolves to the plan gate
1559
+ // by branch order, a documented edge: the human still
1560
+ // sees one ask, and the other latch survives for its own
1561
+ // prefixed echo.
1562
+ landingGateAsks.set(id, {
1563
+ sessionID: ledger.parentOf(sid),
1564
+ recordedAt: Date.now(),
1565
+ })
1566
+ boundLandingGateAsks()
1567
+ }
1568
+ }
1569
+ }
1570
+ break
1571
+ }
1572
+ case "permission.replied": {
1573
+ // Match the reply against RECORDED plan-gate asks only: a
1574
+ // Match the reply against RECORDED asks: plan-gate ask (keyed
1575
+ // by requestID, consumed once, releases/holds the plan gate)
1576
+ // OR landing-gate ask (consumed once, grants/holds a push). A
1577
+ // requestID absent from both maps was never an ask we raised, so
1578
+ // the reply is inert — an approval of an unrelated permission
1579
+ // ask cannot release either gate. Fail-open: the outer loop
1580
+ // journals any throw.
1581
+ const rid = String(properties.requestID ?? "")
1582
+ const reply = properties.reply
1583
+ const planRecord = planGateAsks.get(rid)
1584
+ if (planRecord) {
1585
+ planGateAsks.delete(rid)
1586
+ const parent = planRecord.sessionID
1587
+ const s = ledger.state(parent)
1588
+ if (reply === "always") {
1589
+ // A standing human approval releases the plan gate for the
1590
+ // rest of the task and records provenance.
1591
+ s.humanApproval = true
1592
+ s.hasPlanVerdict = true
1593
+ journaler.journal({ type: "permission", sessionID: parent, decision: "always" })
1594
+ } else if (reply === "once") {
1595
+ // A plan-gate single-dispatch grant: consumed by exactly one
1596
+ // executor dispatch in execute.before (the .plan
1597
+ // short-circuit), so a stale grant cannot outlive that
1598
+ // single dispatch.
1599
+ s.pendingOnceGrant.plan = rid
1600
+ journaler.journal({ type: "permission", sessionID: parent, decision: "once" })
1601
+ } else {
1602
+ // reject (or any non-allow reply) leaves the gate red: the
1603
+ // next execute.before dispatch re-blocks.
1604
+ journaler.journal({ type: "permission", sessionID: parent, decision: "reject" })
1605
+ }
1606
+ } else if (landingGateAsks.get(rid)) {
1607
+ const landingRecord = landingGateAsks.get(rid)
1608
+ landingGateAsks.delete(rid)
1609
+ const parent = landingRecord!.sessionID
1610
+ const s = ledger.state(parent)
1611
+ if (reply === "always") {
1612
+ // A standing human approval releases the landing gate for
1613
+ // the rest of the task (the bash guard sees hasReviewApprove
1614
+ // and allows every push).
1615
+ s.hasReviewApprove = true
1616
+ journaler.journal({ type: "permission", sessionID: parent, decision: "always" })
1617
+ } else if (reply === "once") {
1618
+ // A landing-gate single-dispatch push grant: consumed by
1619
+ // exactly one bash dispatch in execute.before (the
1620
+ // .landing short-circuit), so a stale grant cannot outlive
1621
+ // that single push.
1622
+ s.pendingOnceGrant.landing = rid
1623
+ journaler.journal({ type: "permission", sessionID: parent, decision: "once" })
1624
+ } else {
1625
+ // reject (or any non-allow reply) leaves the gate red: the
1626
+ // next execute.before push re-blocks and re-arms the ask.
1627
+ journaler.journal({ type: "permission", sessionID: parent, decision: "reject" })
1628
+ }
1629
+ }
1630
+ break
1631
+ }
480
1632
  default:
481
1633
  break
482
1634
  }
@@ -484,6 +1636,10 @@ export function createTurnstile(
484
1636
  journaler.journal({ type: "error", error: `event ${event.type}: ${e}` })
485
1637
  }
486
1638
  }
1639
+ // A normal completion (closed stream) is a silent death mode; an
1640
+ // abort/detach exit stays silent. Telemetry only.
1641
+ if (!controller.signal.aborted)
1642
+ journaler.journal({ type: "error", error: "event stream ended" })
487
1643
  }
488
1644
  const consuming = consume().catch((e: unknown) => {
489
1645
  if (!controller.signal.aborted)
@@ -493,8 +1649,21 @@ export function createTurnstile(
493
1649
  controller.abort()
494
1650
  void consuming
495
1651
  })
1652
+ hooks++
496
1653
  }
497
1654
 
1655
+ // Load heartbeat: one entry per host-session boot, emitted after every
1656
+ // registration so `hooks` is accurate. Telemetry only — it never blocks.
1657
+ journaler.journal({
1658
+ type: "setup",
1659
+ version,
1660
+ digest: configDigest(config),
1661
+ hooks,
1662
+ ...(config.trace
1663
+ ? { trace: true, agents: config.agents, metricsDir: config.metricsDir }
1664
+ : {}),
1665
+ })
1666
+
498
1667
  /**
499
1668
  * Disposes every hook registration tracked during setup.
500
1669
  * @returns Nothing; individual dispose failures are inert.