opencode-turnstile 0.0.0-stage → 0.1.1

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 ADDED
@@ -0,0 +1,512 @@
1
+ /**
2
+ * @fileoverview Host-wiring boundary: registers turnstile's hooks with the
3
+ * OpenCode v2 host and owns the plugin lifecycle: hook registration, the
4
+ * stream-event loop, and cleanup. Enforcement decisions delegate to
5
+ * src/gates.ts; marker parsing to src/markers.ts; persistence to
6
+ * src/journal.ts; bookkeeping to src/state.ts. Configuration is resolved
7
+ * once here (defaults ⊕ config file ⊕ env ⊕ injected overrides) and
8
+ * injected downward; no module reads config after this point.
9
+ *
10
+ * Fail-open by contract: every hook body is guarded; only the deliberate
11
+ * BLOCKED errors thrown by the gate layer propagate to the host.
12
+ */
13
+
14
+ import { loadConfig } from "./config.ts"
15
+ import { createJournaler, type Journaler } from "./journal.ts"
16
+ import {
17
+ agentFromTaskInput,
18
+ compileMarkerRegex,
19
+ parseGateMarkers,
20
+ GATE_RE,
21
+ VERDICT_RE,
22
+ } from "./markers.ts"
23
+ import { BlockError, createGates, type Gates } from "./gates.ts"
24
+ import { createLedger, type GateLedger } from "./state.ts"
25
+ import type {
26
+ CompactionDraft,
27
+ EventProperties,
28
+ GenerateDraft,
29
+ Host,
30
+ PromptHookProperties,
31
+ TaskInput,
32
+ ToolHookEvent,
33
+ TurnstileOptions,
34
+ } from "./types.ts"
35
+
36
+ /**
37
+ * Coerces an unknown host value into a session-ID string, mirroring the
38
+ * historical `String(value ?? "")` behavior at this boundary.
39
+ * @param value Raw value from a host payload.
40
+ * @returns The string form, or an empty string for nullish values.
41
+ */
42
+ function asSessionID(value: unknown): string {
43
+ return String(value ?? "")
44
+ }
45
+
46
+ /**
47
+ * Tracks a hook registration handle so it can be disposed on teardown.
48
+ * @param reg Registration handle returned by a host hook call.
49
+ * @param cleanups Disposal list to append to.
50
+ */
51
+ function track(reg: unknown, cleanups: Array<() => void>): void {
52
+ if (reg && typeof (reg as { dispose?: unknown }).dispose === "function") {
53
+ cleanups.push(() => (reg as { dispose: () => void }).dispose())
54
+ }
55
+ }
56
+
57
+ /**
58
+ * Builds the turnstile plugin for a host: resolves the layered config,
59
+ * creates the telemetry writer and gate ledger (replaying the journal),
60
+ * then returns the async setup function that registers every hook.
61
+ * @param _host The host context; unused at build time (hooks receive the
62
+ * live host at setup). Kept for factory-shape compatibility.
63
+ * @param opts Injected config/env overrides (tests); defaults to env.
64
+ * @returns The plugin setup function; resolves to a cleanup disposer.
65
+ */
66
+ export function createTurnstile(
67
+ _host: Host | undefined | null,
68
+ opts: TurnstileOptions = {},
69
+ ): (h: Host) => Promise<(() => void) | void> {
70
+ const env = opts.env ?? process.env
71
+ const { config } = loadConfig(env, opts.config)
72
+ const journaler: Journaler = createJournaler({
73
+ metricsDir: config.metricsDir,
74
+ trace: config.trace,
75
+ maxBytes: config.journalMaxBytes,
76
+ maxGenerations: config.journalMaxGenerations,
77
+ })
78
+ // Config warnings are already journaled by config.ts (the pre-journaler
79
+ // 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)
82
+
83
+ /**
84
+ * True when the text contains at least one GATE or VERDICT marker per the
85
+ * compiled config regexes. Uses `String.match`, so the `g` flag's
86
+ * `lastIndex` state is not an issue; scans the whole output (no window).
87
+ * @param text The output text to scan.
88
+ */
89
+ function hasMarker(text: string): boolean {
90
+ return (text.match(gateRe)?.length ?? 0) > 0 || (text.match(verdictRe)?.length ?? 0) > 0
91
+ }
92
+ const ledger: GateLedger = createLedger(journaler.journalPath, config.maxSessions)
93
+ const gates: Gates = createGates(config, journaler)
94
+ let projectDir: string = process.cwd()
95
+ // Per-delivery dedup scope counter: each task result is a distinct
96
+ // delivery, so a re-run emitting a byte-identical marker is still
97
+ // journaled; streaming sources share the empty scope (re-delivery dedup).
98
+ let taskDeliverySeq = 0
99
+
100
+ /**
101
+ * Parses markers out of assistant text and journals first-sight events.
102
+ * @param text The assistant text to scan.
103
+ * @param sessionID The raw (possibly child) session ID.
104
+ * @param source Provenance label for the text in journal entries.
105
+ * @param dedupScope Key prefix partitioning the marker dedup set.
106
+ */
107
+ function parseMarkers(
108
+ text: string,
109
+ sessionID: string | undefined,
110
+ source: string,
111
+ dedupScope = "",
112
+ ): void {
113
+ if (!text) return
114
+ const sid = ledger.parentOf(sessionID)
115
+ if (!sid) return
116
+ const s = ledger.state(sid)
117
+ for (const event of parseGateMarkers(s, text, gateRe, verdictRe, dedupScope)) {
118
+ const suppressed = event.suppressed > 0 ? { suppressed: event.suppressed } : {}
119
+ if (event.kind === "gate") {
120
+ // Keyed by parent session so journal replay restores state where the
121
+ // reviewer gate looks it up; raw child ID kept for forensics.
122
+ journaler.journal({
123
+ type: "gate",
124
+ sessionID: sid,
125
+ raw: sessionID,
126
+ result: event.result,
127
+ source,
128
+ marker: event.marker,
129
+ ...suppressed,
130
+ })
131
+ journaler.traceEvent("test-runner", "gate", event.marker)
132
+ } else {
133
+ journaler.journal({
134
+ type: "verdict",
135
+ sessionID: sid,
136
+ raw: sessionID,
137
+ marker: event.marker,
138
+ rounds: event.rounds,
139
+ source,
140
+ ...suppressed,
141
+ })
142
+ journaler.traceEvent("reviewer", "verdict", event.marker)
143
+ }
144
+ }
145
+ }
146
+
147
+ /**
148
+ * The plugin setup function handed to the host: registers the session,
149
+ * tool, and event hooks, and returns a disposer that unwinds them all.
150
+ * @param h The live OpenCode host context.
151
+ * @returns A disposer that disposes every registration; failures inert.
152
+ */
153
+ return async function setup(h: Host): Promise<(() => void) | void> {
154
+ const cleanups: Array<() => void> = []
155
+
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
+ }
208
+
209
+ // Task-state reset: a new user prompt starts a new task.
210
+ if (typeof h.session?.hook === "function") {
211
+ try {
212
+ const reg = await h.session.hook("prompt", (properties: PromptHookProperties) => {
213
+ const sessionID = properties?.sessionID
214
+ if (typeof sessionID === "string") ledger.resetState(sessionID)
215
+ })
216
+ track(reg, cleanups)
217
+ } catch (e) {
218
+ journaler.journal({ type: "error", error: `session.prompt hook registration: ${e}` })
219
+ }
220
+
221
+ // Decoding clamps + compaction context.
222
+ try {
223
+ const reg = await h.session.hook("generate", (draft: GenerateDraft) => {
224
+ try {
225
+ if (!draft) return
226
+ const agent = typeof draft.agent === "string" ? draft.agent : ""
227
+ if (agent === config.agents.reviewer || agent === config.agents.planReviewer) {
228
+ // Force 0 even when the host sent no options object at all:
229
+ // today's early return skipped the clamp in exactly that case.
230
+ if (!draft.options) draft.options = {}
231
+ draft.options.temperature = 0
232
+ } else if (
233
+ agent === config.agents.executor ||
234
+ agent === config.agents.testRunner ||
235
+ agent === config.agents.debugger
236
+ ) {
237
+ // Clamp also applies when the host sent no temperature: an
238
+ // unset temperature is whatever the host defaults to, which
239
+ // is exactly what the ≤ 0.2 contract bounds.
240
+ if (!draft.options) draft.options = {}
241
+ const t = draft.options.temperature
242
+ draft.options.temperature = typeof t === "number" ? Math.min(t, 0.2) : 0.2
243
+ }
244
+ } catch (e) {
245
+ journaler.journal({ type: "error", error: `generate hook: ${e}` })
246
+ }
247
+ })
248
+ track(reg, cleanups)
249
+ } catch (e) {
250
+ journaler.journal({ type: "error", error: `session.generate hook registration: ${e}` })
251
+ }
252
+
253
+ try {
254
+ const reg = await h.session.hook("compaction", (draft: CompactionDraft) => {
255
+ try {
256
+ if (Array.isArray(draft?.system)) {
257
+ draft.system.push({
258
+ type: "text",
259
+ text: "Preserve verbatim: open findings, AC status table, last GATE/VERDICT markers, current worktree/branch paths.",
260
+ })
261
+ }
262
+ } catch (e) {
263
+ journaler.journal({ type: "error", error: `compaction hook: ${e}` })
264
+ }
265
+ })
266
+ track(reg, cleanups)
267
+ } catch (e) {
268
+ journaler.journal({ type: "error", error: `session.compaction hook registration: ${e}` })
269
+ }
270
+ }
271
+
272
+ // Tool-level gate + trace.
273
+ if (typeof h.tool?.hook === "function") {
274
+ try {
275
+ const reg = await h.tool.hook("execute.before", async (event: ToolHookEvent) => {
276
+ try {
277
+ if (event?.tool === "task" || event?.tool === "subagent") {
278
+ const agent = agentFromTaskInput(event.input)
279
+ const sessionID = asSessionID(event?.sessionID)
280
+ const prompt = String(event.input?.prompt ?? "")
281
+ journaler.journal({
282
+ type: "dispatch",
283
+ sessionID,
284
+ agent,
285
+ // Prompt descriptions can carry sensitive context; record
286
+ // them only when the trace is enabled.
287
+ description: config.trace
288
+ ? String(event.input?.description ?? "").slice(0, 200)
289
+ : "",
290
+ })
291
+ journaler.traceEvent("orchestrator", "dispatch", `task -> ${agent}`)
292
+
293
+ if (agent === config.agents.reviewer) {
294
+ gates.guardReviewerDispatch(sessionID, prompt, ledger)
295
+ }
296
+
297
+ if (agent === config.agents.executor) {
298
+ gates.enforcePlanApproval(sessionID, prompt, projectDir, ledger)
299
+ gates.enforceDecomposition(sessionID, prompt, projectDir)
300
+ }
301
+ return
302
+ }
303
+
304
+ 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)
308
+ }
309
+ } catch (e) {
310
+ if (e instanceof BlockError) throw e
311
+ journaler.journal({ type: "error", error: `tool.execute.before: ${e}` })
312
+ }
313
+ })
314
+ track(reg, cleanups)
315
+ } catch (e) {
316
+ journaler.journal({ type: "error", error: `tool.before hook registration: ${e}` })
317
+ }
318
+
319
+ try {
320
+ const reg = await h.tool.hook("execute.after", async (event: ToolHookEvent) => {
321
+ try {
322
+ if (event?.tool === "task" || event?.tool === "subagent") {
323
+ const output = String(event.result?.output ?? "")
324
+ parseMarkers(
325
+ output,
326
+ asSessionID(event?.sessionID),
327
+ "task-result",
328
+ `task-result\u0000${++taskDeliverySeq}`,
329
+ )
330
+ const agent =
331
+ agentFromTaskInput(event.input) ||
332
+ agentFromTaskInput(event.result?.input as TaskInput | undefined)
333
+ // 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.
337
+ 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>")
353
+ }
354
+ const bare =
355
+ config.agents.bareOutput.includes(agent) && output !== "" && !hasMarker(output)
356
+ if (bare) {
357
+ journaler.journal({
358
+ type: "violation",
359
+ sessionID: asSessionID(event?.sessionID),
360
+ agent,
361
+ reason: "task output contained no GATE/VERDICT marker",
362
+ })
363
+ }
364
+ return
365
+ }
366
+ if (event?.tool === "read") {
367
+ // Same parent keying as the guard: count and budget stay in
368
+ // step regardless of which session ID the host reports.
369
+ const sessionID = ledger.parentOf(asSessionID(event?.sessionID))
370
+ const s = ledger.state(sessionID)
371
+ s.reads++
372
+ journaler.journal({ type: "tool", sessionID, tool: "read", reads: s.reads })
373
+ if (
374
+ s.reads === config.readWarn &&
375
+ event.result &&
376
+ typeof event.result.output === "string"
377
+ ) {
378
+ event.result.output =
379
+ `[turnstile] Warning: ${config.readWarn} whole-file reads this session; prefer grep/glob. Blocks at ${config.readBlock}.\n` +
380
+ event.result.output
381
+ }
382
+ return
383
+ }
384
+ if (event?.tool === "bash" || event?.tool === "shell") {
385
+ const meta = event.result?.metadata ?? {}
386
+ journaler.journal({
387
+ type: "tool",
388
+ sessionID: asSessionID(event?.sessionID),
389
+ tool: "bash",
390
+ // Shell commands can contain secrets; record them only when
391
+ // the trace is enabled.
392
+ command: config.trace
393
+ ? String(
394
+ event.input?.command ?? event.input?.cmd ?? event.result?.title ?? "",
395
+ ).slice(0, 500)
396
+ : "<redacted>",
397
+ exit: meta.exit ?? meta.exitCode ?? meta.code,
398
+ })
399
+ }
400
+ } catch (e) {
401
+ if (e instanceof BlockError) throw e
402
+ journaler.journal({ type: "error", error: `tool.execute.after: ${e}` })
403
+ }
404
+ })
405
+ track(reg, cleanups)
406
+ } catch (e) {
407
+ journaler.journal({ type: "error", error: `tool.after hook registration: ${e}` })
408
+ }
409
+ }
410
+
411
+ // Event mirror: child→parent resolution, marker parsing from child
412
+ // sessions (primary path if task results are summarized), errors.
413
+ if (typeof h.event?.subscribe === "function") {
414
+ const subscribe = h.event.subscribe
415
+ const controller = new AbortController()
416
+
417
+ /**
418
+ * Consumes the host event stream until aborted, dispatching each
419
+ * event type to its handler. Per-event failures are journaled, never
420
+ * fatal to the stream.
421
+ */
422
+ const consume = async (): Promise<void> => {
423
+ for await (const event of subscribe({ signal: controller.signal })) {
424
+ try {
425
+ const properties: EventProperties = event.data ?? event.properties ?? {}
426
+ switch (event.type) {
427
+ case "session.created": {
428
+ if (
429
+ typeof properties.sessionID === "string" &&
430
+ typeof properties.parentID === "string"
431
+ ) {
432
+ ledger.setParent(properties.sessionID, properties.parentID)
433
+ } else if (typeof properties.sessionID === "string") {
434
+ // Root sessions only: child-session created events must not
435
+ // move the project dir (any directory they carry belongs to
436
+ // the parent's context, and last-writer-wins across child
437
+ // events would point the plans scan at the wrong place).
438
+ if (typeof properties.location?.directory === "string")
439
+ projectDir = properties.location.directory
440
+ else if (typeof properties.directory === "string")
441
+ projectDir = properties.directory
442
+ }
443
+ break
444
+ }
445
+ case "message.part.updated": {
446
+ const part = properties.part ?? properties
447
+ const partSession = asSessionID(properties.sessionID ?? part?.sessionID)
448
+ if (part?.type === "text" && typeof part.text === "string" && partSession) {
449
+ await resolveParent(partSession)
450
+ parseMarkers(part.text, partSession, "child-message")
451
+ }
452
+ break
453
+ }
454
+ case "session.text.ended": {
455
+ const sid = properties.sessionID
456
+ const text = properties.text
457
+ if (typeof sid === "string" && typeof text === "string") {
458
+ await resolveParent(sid)
459
+ parseMarkers(text, sid, "child-text")
460
+ }
461
+ break
462
+ }
463
+ case "session.text.delta": {
464
+ const sid = properties.sessionID
465
+ const delta = properties.delta
466
+ if (typeof sid === "string" && typeof delta === "string") {
467
+ await resolveParent(sid)
468
+ parseMarkers(delta, sid, "child-text-delta")
469
+ }
470
+ break
471
+ }
472
+ case "session.error": {
473
+ journaler.journal({
474
+ type: "error",
475
+ sessionID: properties.sessionID,
476
+ error: String(properties.error ?? "").slice(0, 500),
477
+ })
478
+ break
479
+ }
480
+ default:
481
+ break
482
+ }
483
+ } catch (e) {
484
+ journaler.journal({ type: "error", error: `event ${event.type}: ${e}` })
485
+ }
486
+ }
487
+ }
488
+ const consuming = consume().catch((e: unknown) => {
489
+ if (!controller.signal.aborted)
490
+ journaler.journal({ type: "error", error: `event subscription: ${e}` })
491
+ })
492
+ cleanups.push(() => {
493
+ controller.abort()
494
+ void consuming
495
+ })
496
+ }
497
+
498
+ /**
499
+ * Disposes every hook registration tracked during setup.
500
+ * @returns Nothing; individual dispose failures are inert.
501
+ */
502
+ return () => {
503
+ for (const fn of cleanups) {
504
+ try {
505
+ fn()
506
+ } catch {
507
+ // dispose failures are inert
508
+ }
509
+ }
510
+ }
511
+ }
512
+ }
package/src/state.ts ADDED
@@ -0,0 +1,181 @@
1
+ /**
2
+ * @fileoverview State boundary: per-session gate state, the child→parent
3
+ * session mapping, and journal replay so a completed task's `GATE: PASS`
4
+ * survives restarts. Pure bookkeeping; no enforcement decisions here.
5
+ */
6
+
7
+ import * as fs from "node:fs"
8
+
9
+ import type { GateState } from "./types.ts"
10
+
11
+ /**
12
+ * Creates a fresh gate state bucket for one parent session.
13
+ * @returns An empty GateState with an empty marker-dedup set.
14
+ */
15
+ export function newState(): GateState {
16
+ return {
17
+ hasGatePass: false,
18
+ hasPlanVerdict: false,
19
+ verdicts: [],
20
+ reviewRounds: 0,
21
+ reads: 0,
22
+ seen: new Set(),
23
+ }
24
+ }
25
+
26
+ /** Live gate bookkeeping shared by the gate, marker, and wiring layers. */
27
+ export interface GateLedger {
28
+ /** Child→parent session-ID mapping; readable for membership checks. */
29
+ childToParent: Map<string, string>
30
+ /**
31
+ * Returns the gate state for a parent session, creating an empty one on
32
+ * first access.
33
+ * @param sessionID The parent session ID.
34
+ * @returns The session's mutable gate state.
35
+ */
36
+ state(sessionID: string): GateState
37
+ /**
38
+ * Replaces the gate state for a session with a fresh bucket. Used on
39
+ * each new user prompt: a new prompt starts a new task.
40
+ * @param sessionID The parent session ID to reset.
41
+ */
42
+ resetState(sessionID: string): void
43
+ /**
44
+ * Maps a session to its parent, falling back to the session itself when
45
+ * no parent edge is known.
46
+ * @param sessionID The (possibly undefined) session ID.
47
+ * @returns The parent session ID, the session ID itself, or an empty
48
+ * string for nullish input.
49
+ */
50
+ parentOf(sessionID: string | undefined): string
51
+ /**
52
+ * Records a resolved child→parent edge.
53
+ * @param sessionID The child session ID.
54
+ * @param parentID The parent session ID.
55
+ */
56
+ setParent(sessionID: string, parentID: string): void
57
+ }
58
+
59
+ /**
60
+ * Replays the journal's tail so completed `GATE: PASS` entries restore
61
+ * gate state after a restart. Reads the last 2000 lines; skips malformed
62
+ * lines. Only the *last* gate result per session decides the restored
63
+ * state, mirroring the runtime latest-wins rule: a trailing `GATE: FAIL`
64
+ * keeps the gate red. Fail-open.
65
+ * @param states The session→state map to populate.
66
+ * @param journalPath Absolute path of the JSONL journal to replay.
67
+ */
68
+ function replayGatePasses(states: Map<string, GateState>, journalPath: string): void {
69
+ try {
70
+ if (fs.existsSync(journalPath)) {
71
+ const lines = fs.readFileSync(journalPath, "utf8").split("\n").filter(Boolean)
72
+ const lastGateResult = new Map<string, string>()
73
+ const lastPlanResult = new Map<string, string>()
74
+ for (const line of lines.slice(-2000)) {
75
+ try {
76
+ const e = JSON.parse(line) as { type?: unknown; sessionID?: unknown; result?: unknown }
77
+ if (e.type === "gate" && e.sessionID && (e.result === "PASS" || e.result === "FAIL")) {
78
+ lastGateResult.set(e.sessionID as string, e.result)
79
+ }
80
+ if (
81
+ e.type === "plan" &&
82
+ e.sessionID &&
83
+ (e.result === "APPROVE" || e.result === "BLOCK")
84
+ ) {
85
+ lastPlanResult.set(e.sessionID as string, e.result)
86
+ }
87
+ } catch {
88
+ // skip malformed lines
89
+ }
90
+ }
91
+ for (const [sessionID, result] of lastGateResult) {
92
+ if (result !== "PASS") continue
93
+ const s = states.get(sessionID) ?? newState()
94
+ s.hasGatePass = true
95
+ states.set(sessionID, s)
96
+ }
97
+ for (const [sessionID, result] of lastPlanResult) {
98
+ if (result !== "APPROVE") continue
99
+ const s = states.get(sessionID) ?? newState()
100
+ s.hasPlanVerdict = true
101
+ states.set(sessionID, s)
102
+ }
103
+ }
104
+ } catch {
105
+ // fail-open
106
+ }
107
+ }
108
+
109
+ /**
110
+ * Creates the gate ledger and replays prior gate passes from the journal.
111
+ * Both session maps are LRU-capped at `maxSessions` (promote-on-touch);
112
+ * beyond the cap the least-recently-used entry is evicted.
113
+ * @param journalPath Absolute path of the JSONL journal to replay.
114
+ * @param maxSessions LRU cap for tracked sessions (states + parent map).
115
+ * @returns A ledger bound to a fresh in-memory session map.
116
+ */
117
+ export function createLedger(journalPath: string, maxSessions: number = 64): GateLedger {
118
+ const states = new Map<string, GateState>()
119
+ const childToParent = new Map<string, string>()
120
+
121
+ /**
122
+ * Promotes a key to most-recently-used in an insertion-ordered map.
123
+ * @param map The map to reorder.
124
+ * @param key The key to touch (no-op when absent).
125
+ */
126
+ function promote<K, V>(map: Map<K, V>, key: K): void {
127
+ const v = map.get(key)
128
+ if (v !== undefined) {
129
+ map.delete(key)
130
+ map.set(key, v)
131
+ }
132
+ }
133
+
134
+ /**
135
+ * Evicts least-recently-used entries until the map is within the cap.
136
+ * @param map The map to trim.
137
+ */
138
+ function cap<K, V>(map: Map<K, V>): void {
139
+ while (map.size > maxSessions) {
140
+ const oldest = map.keys().next()
141
+ if (oldest.done) break
142
+ map.delete(oldest.value)
143
+ }
144
+ }
145
+
146
+ // Replay runs before capping so the restored set honors the LRU cap too.
147
+ replayGatePasses(states, journalPath)
148
+ cap(states)
149
+
150
+ return {
151
+ childToParent,
152
+ state(sessionID: string): GateState {
153
+ promote(states, sessionID)
154
+ let s = states.get(sessionID)
155
+ if (!s) {
156
+ s = newState()
157
+ states.set(sessionID, s)
158
+ cap(states)
159
+ }
160
+ return s
161
+ },
162
+ resetState(sessionID: string): void {
163
+ states.delete(sessionID)
164
+ states.set(sessionID, newState())
165
+ cap(states)
166
+ },
167
+ parentOf(sessionID: string | undefined): string {
168
+ if (!sessionID) return ""
169
+ const parent = childToParent.get(sessionID)
170
+ if (parent !== undefined) {
171
+ promote(childToParent, sessionID)
172
+ return parent
173
+ }
174
+ return sessionID
175
+ },
176
+ setParent(sessionID: string, parentID: string): void {
177
+ childToParent.set(sessionID, parentID)
178
+ cap(childToParent)
179
+ },
180
+ }
181
+ }