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/README.md +263 -99
- package/agents/debugger.md +3 -2
- package/agents/executor.md +7 -2
- package/bin/cli.mjs +34 -22
- package/package.json +4 -5
- package/scripts/branch-coverage.mjs +206 -0
- package/scripts/check-markers.mjs +23 -1
- package/scripts/install.mjs +113 -13
- package/scripts/status.mjs +333 -0
- package/skills/turnstile-setup/SKILL.md +22 -38
- package/src/config.ts +295 -6
- package/src/evidence.ts +162 -0
- package/src/gates.ts +338 -93
- package/src/journal.ts +10 -3
- package/src/markers.ts +117 -15
- package/src/policy.ts +33 -0
- package/src/setup.ts +1265 -96
- package/src/state.ts +316 -21
- package/src/tool-policy.ts +683 -0
- package/src/types.ts +300 -1
- package/turnstile.ts +150 -14
- package/scripts/install-agents.mjs +0 -504
- package/templates/global-AGENTS.md +0 -55
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
|
|
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
|
-
|
|
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
|
|
705
|
+
sessionID: string,
|
|
110
706
|
source: string,
|
|
111
707
|
dedupScope = "",
|
|
112
|
-
):
|
|
113
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
157
|
-
//
|
|
158
|
-
|
|
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")
|
|
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,
|
|
1133
|
+
gates.guardReviewerDispatch(sessionID, ledger)
|
|
295
1134
|
}
|
|
296
1135
|
|
|
297
1136
|
if (agent === config.agents.executor) {
|
|
298
|
-
gates.enforcePlanApproval(sessionID,
|
|
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
|
-
//
|
|
307
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1258
|
+
child,
|
|
327
1259
|
"task-result",
|
|
328
1260
|
`task-result\u0000${++taskDeliverySeq}`,
|
|
329
1261
|
)
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
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.
|
|
335
|
-
//
|
|
336
|
-
//
|
|
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
|
-
|
|
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) &&
|
|
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:
|
|
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
|
-
|
|
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.
|