opencode-turnstile 0.2.1 → 0.2.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/state.ts CHANGED
@@ -6,7 +6,7 @@
6
6
 
7
7
  import * as fs from "node:fs"
8
8
 
9
- import type { GateState } from "./types.ts"
9
+ import type { ChildVerdict, GateState } from "./types.ts"
10
10
 
11
11
  /**
12
12
  * Creates a fresh gate state bucket for one parent session.
@@ -15,14 +15,57 @@ import type { GateState } from "./types.ts"
15
15
  export function newState(): GateState {
16
16
  return {
17
17
  hasGatePass: false,
18
+ unverifiedPass: "",
18
19
  hasPlanVerdict: false,
19
- verdicts: [],
20
+ hasReviewApprove: false,
21
+ userWaiver: "",
22
+ humanApproval: false,
23
+ // Per-reason single-dispatch grants (plan / landing): fresh "" on every
24
+ // task reset, task-scoped, never carried through mergeAfterReplay.
25
+ pendingOnceGrant: { plan: "", landing: "" },
26
+ policyInjected: false,
27
+ seenFrom: new Map(),
28
+ suppressedPending: 0,
20
29
  reviewRounds: 0,
30
+ // Task-scoped landing-gate block counter: fresh 0 on every prompt reset.
31
+ // Restored by journal replay across a restart (chunk-7).
32
+ landingBlocks: 0,
21
33
  reads: 0,
22
34
  seen: new Set(),
35
+ plansSignature: "",
36
+ // A freshly-built bucket has no outstanding replay grace; only a journal
37
+ // replay (createLedger) grants it, and only for one prompt.
38
+ replayGrace: false,
23
39
  }
24
40
  }
25
41
 
42
+ /**
43
+ * Builds the gate state for the first post-restart prompt (the grace prompt):
44
+ * carries the replayed bucket's current flag _values_ forward — not provenance
45
+ * — so a live `GATE: FAIL` after replay is never resurrected. All other fields
46
+ * (`seenFrom`, `suppressedPending`, `reviewRounds`, `reads`, `seen`, the empty
47
+ * `plansSignature` stamp) come fresh; the returned bucket's `replayGrace` is
48
+ * consumed (false).
49
+ * @param prior The replayed bucket being reset.
50
+ * @returns A reset bucket with the prior flags; grace consumed.
51
+ */
52
+ function mergeAfterReplay(prior: GateState): GateState {
53
+ const s = newState()
54
+ s.hasGatePass = prior.hasGatePass
55
+ s.hasPlanVerdict = prior.hasPlanVerdict
56
+ // Restart-sticky like hasPlanVerdict: a restored human approval survives the
57
+ // first post-restart prompt (restart-sticky provenance, never task-scoped).
58
+ // userWaiver/pendingOnceGrant stay fresh here: they are task-scoped and never
59
+ // carried through the replay-merge path.
60
+ s.humanApproval = prior.humanApproval
61
+ // hasReviewApprove is the landing gate's release signal, restart-sticky like
62
+ // hasPlanVerdict: a reviewer APPROVE restored by replay survives the first
63
+ // post-restart prompt (and the reset below clears it on the next prompt).
64
+ s.hasReviewApprove = prior.hasReviewApprove
65
+ s.plansSignature = prior.hasPlanVerdict ? prior.plansSignature : ""
66
+ return s
67
+ }
68
+
26
69
  /** Live gate bookkeeping shared by the gate, marker, and wiring layers. */
27
70
  export interface GateLedger {
28
71
  /** Child→parent session-ID mapping; readable for membership checks. */
@@ -35,8 +78,21 @@ export interface GateLedger {
35
78
  */
36
79
  state(sessionID: string): GateState
37
80
  /**
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.
81
+ * Returns the gate state for a session without creating one, for read-only
82
+ * checks that must not insert a fresh bucket into the LRU (e.g. promotion
83
+ * lookups for unknown bash sessions).
84
+ * @param sessionID The session ID to look up.
85
+ * @returns The session's mutable gate state, or `undefined` when none exists.
86
+ */
87
+ peek(sessionID: string): GateState | undefined
88
+ /**
89
+ * Resets a session's gate state for a new user prompt (a new prompt starts a
90
+ * new task). The first prompt after a restart carries the journal-replayed
91
+ * flags forward with a single-prompt grace (values, not provenance — a live
92
+ * GATE: FAIL after replay is carried as false); every later prompt fully
93
+ * resets (fresh flags; `reviewRounds`/`reads`/`seen`/`seenFrom`/
94
+ * `suppressedPending` are always fresh). Fail-open: pure object map
95
+ * operations.
40
96
  * @param sessionID The parent session ID to reset.
41
97
  */
42
98
  resetState(sessionID: string): void
@@ -54,27 +110,124 @@ export interface GateLedger {
54
110
  * @param parentID The parent session ID.
55
111
  */
56
112
  setParent(sessionID: string, parentID: string): void
113
+ /**
114
+ * Registers a session as a live parent so the `states` cap never evicts
115
+ * its bucket while it stays tracked. Registration is the
116
+ * eviction-protection signal; refreshed by every prompt, guarded
117
+ * dispatch, task delivery, edge resolution, and root-session event, so an
118
+ * active parent re-registers long before the registry's LRU churn can evict
119
+ * it (bounded by `maxTrackedParents`). No-op on an empty id.
120
+ * @param sessionID The parent session ID.
121
+ */
122
+ markParent(sessionID: string): void
123
+ /**
124
+ * Whether a session is a currently-registered live parent (membership with
125
+ * LRU promotion — the registry is LRU by use).
126
+ * @param sessionID The session ID to test.
127
+ * @returns True when the session is a registered live parent.
128
+ */
129
+ isParent(sessionID: string): boolean
130
+ /**
131
+ *
132
+ * Overwrites the child's latest `VERDICT:` marker observation and its
133
+ * provenance source label. Recording is verdict-kind only (only `VERDICT:`
134
+ * markers are ever stored; never `GATE:`) and is unconditioned on agent
135
+ * type: the plan gate is the sole consumer, and because attribution is
136
+ * keyed by child ID this cannot leak a verdict to a different child or let
137
+ * a GATE-only child suppress a plan-reviewer bare-output violation.
138
+ *
139
+ * Returns whether the stored observation actually changed (its `marker` or
140
+ * `source` differed from the prior entry): the setup/store layer journals a
141
+ * streamed verdict observation iff this is true, so per (child, marker,
142
+ * source) first-sight journaling is owned here rather than by the parent
143
+ * bucket's dedup set.
144
+ * @param childID The child session ID.
145
+ * @param marker The last `VERDICT:` marker observed in the child's text.
146
+ * @param source Pipeline provenance label the marker was observed in.
147
+ * @returns True when the stored `marker` or `source` changed; false on an
148
+ * identical re-record (a dedup hit for streamed sources).
149
+ */
150
+ recordChildVerdict(childID: string, marker: string, source: string): boolean
151
+ /**
152
+ /**
153
+ * Returns the child's latest `VERDICT:` marker observation, or an empty
154
+ * verdict (`{ marker: "", source: "", recordedAt: 0 }`) when the child has
155
+ * none. Touches the child to keep it most-recently-used (LRU).
156
+ * @param childID The child session ID.
157
+ * @returns The stored observation, or the empty verdict default.
158
+ */
159
+ childVerdict(childID: string): ChildVerdict
160
+ /**
161
+ * Snapshot of children with a stored (non-empty) verdict, as
162
+ * `{ child, verdict }` copies. Pure read — never promotes and never caps, so
163
+ * it is safe to call from the attribution layer without disturbing the LRU
164
+ * order (the plausibility scan reads, never writes).
165
+ * @returns A new array of `{ child, verdict }` pairs with non-empty verdicts.
166
+ */
167
+ evidencedChildren(): Array<{ child: string; verdict: ChildVerdict }>
57
168
  }
58
169
 
59
170
  /**
60
171
  * Replays the journal's tail so completed `GATE: PASS` entries restore
61
172
  * 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.
173
+ * lines. Only the *last flipping* gate result per session decides the
174
+ * restored state, mirroring the runtime latest-wins rule (a trailing
175
+ * `GATE: FAIL` keeps the gate red). Fail-open.
176
+ *
177
+ * A replayed green is journaled audited evidence, not runtime proof: the
178
+ * flipping-entry filter skips enforce-mode `verified:false` PASSes (they
179
+ * never flipped at runtime, so they must not restore), while flag-mode
180
+ * `verified:false` PASSes restore green (they DID flip at runtime) and
181
+ * field-less legacy PASSes restore (fail-open). `unverifiedPass` is never
182
+ * restored here and is task-scoped like `seen`/`reads`: as a consequence, a
183
+ * runtime `gate-unverified` block (unverified PASS awaiting evidence) can
184
+ * become a `no-gate-pass` block after a restart — same enforcement strength,
185
+ * different diagnostic message. Both restore loops
186
+ * tag the bucket with `replayGrace` — the one-prompt survival window for the
187
+ * first post-restart prompt; both loops touch the same bucket object when a
188
+ * session has both a gate and a plan result, so the tag is set once. A
189
+ * restored `hasPlanVerdict` is trusted for exactly the current plans-dir state
190
+ * (no signature is journaled, so it stamps an empty ""), which the grace
191
+ * prompt's executor dispatch accepts once and re-stamps via reconcilePlanStamp
192
+ * — edits _after_ restart still invalidate the restored approval.
65
193
  * @param states The session→state map to populate.
66
- * @param journalPath Absolute path of the JSONL journal to replay.
194
+ * @param journalPath Absolute path of the JSONL journal to replay; the prior
195
+ * generation (`${journalPath}.1`) is read first so the 2000-line tail
196
+ * bound holds across both files. Exactly one generation is replayed: the
197
+ * README documents 5 kept generations, but replaying all five would cost
198
+ * ~25 MB of boot I/O worst case.
67
199
  */
68
200
  function replayGatePasses(states: Map<string, GateState>, journalPath: string): void {
69
201
  try {
70
- if (fs.existsSync(journalPath)) {
71
- const lines = fs.readFileSync(journalPath, "utf8").split("\n").filter(Boolean)
202
+ // Read the rotated generation first (`.1`, older) then the current file,
203
+ // oldest-first, so the slice(-2000) tail bound holds across both files.
204
+ // Generations are time-ordered, so latest-wins still falls out naturally.
205
+ const generations = [`${journalPath}.1`, journalPath].filter((p) => fs.existsSync(p))
206
+ if (generations.length > 0) {
207
+ const lines = generations.flatMap((p) =>
208
+ fs.readFileSync(p, "utf8").split("\n").filter(Boolean),
209
+ )
72
210
  const lastGateResult = new Map<string, string>()
73
211
  const lastPlanResult = new Map<string, string>()
212
+ const lastReviewVerdict = new Map<string, string>()
74
213
  for (const line of lines.slice(-2000)) {
75
214
  try {
76
- const e = JSON.parse(line) as { type?: unknown; sessionID?: unknown; result?: unknown }
215
+ const e = JSON.parse(line) as {
216
+ type?: unknown
217
+ sessionID?: unknown
218
+ result?: unknown
219
+ marker?: unknown
220
+ source?: unknown
221
+ verified?: unknown
222
+ mode?: unknown
223
+ }
77
224
  if (e.type === "gate" && e.sessionID && (e.result === "PASS" || e.result === "FAIL")) {
225
+ // Flipping-entry rule (map-fill, not the restore loop): an
226
+ // enforce-mode unverified PASS never flips (nor did it at runtime),
227
+ // so it is not recorded as the deciding last result. A flag-mode
228
+ // verified:false PASS DID flip at runtime and restores green; a
229
+ // legacy field-less PASS restores (fail-open, documented).
230
+ if (e.result === "PASS" && e.verified === false && e.mode !== "flag") continue
78
231
  lastGateResult.set(e.sessionID as string, e.result)
79
232
  }
80
233
  if (
@@ -84,6 +237,27 @@ function replayGatePasses(states: Map<string, GateState>, journalPath: string):
84
237
  ) {
85
238
  lastPlanResult.set(e.sessionID as string, e.result)
86
239
  }
240
+ // Reviewer-APPROVE restore (landing gate, chunk-7): a `VERDICT:
241
+ // APPROVE` verdict delivered on a task-result restores
242
+ // hasReviewApprove with the same latest-flipping rule hasPlanVerdict
243
+ // uses. Only `source:"task-result"` verdicts are credited — the
244
+ // landing gate releases only on a delivery-attributed reviewer
245
+ // verdict, so a verdict streamed in child text (`source:"child-*"`)
246
+ // never restores the flag (this is how AC-12 holds across a restart
247
+ // too). A trailing `VERDICT: BLOCK` leaves the flag off (the recency
248
+ // boundary is the last verdict, not task reset — a GATE: FAIL never
249
+ // clears it).
250
+ if (
251
+ e.type === "verdict" &&
252
+ e.sessionID &&
253
+ typeof e.marker === "string" &&
254
+ e.source === "task-result"
255
+ ) {
256
+ lastReviewVerdict.set(
257
+ e.sessionID as string,
258
+ e.marker.includes("APPROVE") ? "APPROVE" : "BLOCK",
259
+ )
260
+ }
87
261
  } catch {
88
262
  // skip malformed lines
89
263
  }
@@ -92,12 +266,21 @@ function replayGatePasses(states: Map<string, GateState>, journalPath: string):
92
266
  if (result !== "PASS") continue
93
267
  const s = states.get(sessionID) ?? newState()
94
268
  s.hasGatePass = true
269
+ s.replayGrace = true
95
270
  states.set(sessionID, s)
96
271
  }
97
272
  for (const [sessionID, result] of lastPlanResult) {
98
273
  if (result !== "APPROVE") continue
99
274
  const s = states.get(sessionID) ?? newState()
100
275
  s.hasPlanVerdict = true
276
+ s.replayGrace = true
277
+ states.set(sessionID, s)
278
+ }
279
+ for (const [sessionID, result] of lastReviewVerdict) {
280
+ if (result !== "APPROVE") continue
281
+ const s = states.get(sessionID) ?? newState()
282
+ s.hasReviewApprove = true
283
+ s.replayGrace = true
101
284
  states.set(sessionID, s)
102
285
  }
103
286
  }
@@ -109,7 +292,15 @@ function replayGatePasses(states: Map<string, GateState>, journalPath: string):
109
292
  /**
110
293
  * Creates the gate ledger and replays prior gate passes from the journal.
111
294
  * Both session maps are LRU-capped at `maxSessions` (promote-on-touch);
112
- * beyond the cap the least-recently-used entry is evicted.
295
+ * beyond the cap the least-recently-used entry is evicted. A replayed bucket
296
+ * evicted before its grace prompt loses the one-prompt survival (fail-toward-
297
+ * strict); without the tag the next prompt is an ordinary full reset.
298
+ * Live parents register via `markParent` (per prompt, guarded dispatch, task
299
+ * delivery, edge resolution, and root-session event). A registered parent is
300
+ * never evicted from `states` while it stays in the bounded registry — leaked child buckets now only evict each other. Protection never
301
+ * creates a bucket (minting is unchanged) and boot replay stays strict (the
302
+ * registry is empty at replay, so the `replayGrace` loss behavior is
303
+ * unchanged).
113
304
  * @param journalPath Absolute path of the JSONL journal to replay.
114
305
  * @param maxSessions LRU cap for tracked sessions (states + parent map).
115
306
  * @returns A ledger bound to a fresh in-memory session map.
@@ -117,6 +308,18 @@ function replayGatePasses(states: Map<string, GateState>, journalPath: string):
117
308
  export function createLedger(journalPath: string, maxSessions: number = 64): GateLedger {
118
309
  const states = new Map<string, GateState>()
119
310
  const childToParent = new Map<string, string>()
311
+ // Latest VERDICT marker observation per child session, LRU-capped and
312
+ // promote-on-touch like the other session maps. Verdict-kind only, keyed by
313
+ // child ID so it never leaks across children. A re-observation that changes
314
+ // marker/source re-journals its verdict entry (the store is the dedup
315
+ // ledger, not a deduplicator); eviction above maxSessions re-journals on a
316
+ // later re-observation — bounded and acceptable, documented at recordChildVerdict.
317
+ const childVerdicts = new Map<string, ChildVerdict>()
318
+ // Sessions registered as live parents (orchestrator/primary). LRU-touched
319
+ // like the other maps, bounded by `maxTrackedParents`; registration is the
320
+ // eviction-protection signal for the `states` cap.
321
+ const parents = new Map<string, true>()
322
+ const maxTrackedParents = Math.max(256, maxSessions * 4) // mirrors maxTrackedLookups in setup.ts
120
323
 
121
324
  /**
122
325
  * Promotes a key to most-recently-used in an insertion-ordered map.
@@ -132,37 +335,100 @@ export function createLedger(journalPath: string, maxSessions: number = 64): Gat
132
335
  }
133
336
 
134
337
  /**
135
- * Evicts least-recently-used entries until the map is within the cap.
338
+ * Evicts least-recently-used entries until the map is within the cap,
339
+ * skipping any entry for which `protect` returns true. Scans insertion
340
+ * order for the first unprotected entry; when every remaining entry is
341
+ * protected, over-cap is tolerated (bounded by the registry bound).
136
342
  * @param map The map to trim.
343
+ * @param protect Optional predicate on keys; a `true` result is never
344
+ * evicted from this map.
137
345
  */
138
- function cap<K, V>(map: Map<K, V>): void {
346
+ function cap<K, V>(map: Map<K, V>, protect?: (key: K) => boolean): void {
139
347
  while (map.size > maxSessions) {
140
- const oldest = map.keys().next()
141
- if (oldest.done) break
142
- map.delete(oldest.value)
348
+ let evicted = false
349
+ for (const key of map.keys()) {
350
+ // Insertion order: the first unprotected entry is the victim.
351
+ if (protect?.(key)) continue
352
+ map.delete(key)
353
+ evicted = true
354
+ break
355
+ }
356
+ // Every remaining entry is protected; over-cap is tolerated (bounded by
357
+ // the registry bound), so the scan-found-empty case stops the loop.
358
+ if (!evicted) break
143
359
  }
144
360
  }
145
361
 
362
+ /**
363
+ * Registers a session as a live parent: keep it MRU — promote +
364
+ * re-set — so an active parent re-registers long before the registry's LRU
365
+ * churn can evict it. No-op on an empty id. Bounded by `maxTrackedParents`.
366
+ * @param sessionID The parent session ID.
367
+ */
368
+ function markParent(sessionID: string): void {
369
+ if (!sessionID) return
370
+ promote(parents, sessionID)
371
+ parents.set(sessionID, true)
372
+ while (parents.size > maxTrackedParents) {
373
+ // The loop condition guarantees a first entry exists.
374
+ const oldest = parents.keys().next().value as string
375
+ parents.delete(oldest)
376
+ }
377
+ }
378
+
379
+ /**
380
+ * Whether a session is a currently-registered live parent, with LRU
381
+ * promotion (the registry is LRU by use).
382
+ * @param sessionID The session ID to test.
383
+ * @returns True when the session is a registered live parent.
384
+ */
385
+ function isParent(sessionID: string): boolean {
386
+ const known = parents.get(sessionID) === true
387
+ if (known) promote(parents, sessionID)
388
+ return known
389
+ }
390
+
146
391
  // Replay runs before capping so the restored set honors the LRU cap too.
147
392
  replayGatePasses(states, journalPath)
148
- cap(states)
393
+ cap(states, isParent)
149
394
 
150
395
  return {
151
396
  childToParent,
397
+ markParent,
398
+ isParent,
152
399
  state(sessionID: string): GateState {
153
400
  promote(states, sessionID)
154
401
  let s = states.get(sessionID)
155
402
  if (!s) {
156
403
  s = newState()
157
404
  states.set(sessionID, s)
158
- cap(states)
405
+ cap(states, isParent)
159
406
  }
160
407
  return s
161
408
  },
409
+ peek(sessionID: string): GateState | undefined {
410
+ // Pure read: no insertion, no LRU reorder.
411
+ return states.get(sessionID)
412
+ },
162
413
  resetState(sessionID: string): void {
414
+ // delete+set keeps the LRU MRU position and the cap, as a plain reset.
415
+ const prior = states.get(sessionID)
163
416
  states.delete(sessionID)
164
- states.set(sessionID, newState())
165
- cap(states)
417
+ states.set(sessionID, prior?.replayGrace ? mergeAfterReplay(prior) : newState())
418
+ cap(states, isParent)
419
+ // Cross-task staleness: child-verdict evidence is scoped
420
+ // to a task. A new prompt for this parent drops the previous task's
421
+ // child verdicts so a marker-less re-fire can never re-attribute (or
422
+ // clobber against) a stale streamed APPROVE. Scoped to this parent's
423
+ // registered children only, so a concurrent task on another parent keeps
424
+ // its own child-verdict evidence intact.
425
+ for (const c of childToParent.keys()) {
426
+ if (childToParent.get(c) === sessionID) childVerdicts.delete(c)
427
+ }
428
+ // Unresolved children (no registered edge) keep their verdicts across a
429
+ // reset by design: there is no child→parent key to drop them on. The
430
+ // timestamp join makes this safe — a previous
431
+ // task's streamed APPROVE cannot approve the next task.
166
432
  },
167
433
  parentOf(sessionID: string | undefined): string {
168
434
  if (!sessionID) return ""
@@ -176,6 +442,35 @@ export function createLedger(journalPath: string, maxSessions: number = 64): Gat
176
442
  setParent(sessionID: string, parentID: string): void {
177
443
  childToParent.set(sessionID, parentID)
178
444
  cap(childToParent)
445
+ // Edge resolution marks the parent live: its `states` bucket
446
+ // is now protected from eviction until an unrelated parent churn evicts
447
+ // this registration from the registry.
448
+ markParent(parentID)
449
+ },
450
+ recordChildVerdict(childID: string, marker: string, source: string): boolean {
451
+ // changed = the marker or source actually moved; the setup layer journals
452
+ // a streamed verdict observation iff this is true (per (child, marker,
453
+ // source) first-sight dedup). LRU promotion + cap are unchanged; the
454
+ // recordedAt stamp refreshes on every re-observation (the plausibility
455
+ // join keeps a verdict plausible while its parent's task is live).
456
+ const prev = childVerdicts.get(childID) ?? { marker: "", source: "", recordedAt: 0 }
457
+ const changed = prev.marker !== marker || prev.source !== source
458
+ promote(childVerdicts, childID)
459
+ childVerdicts.set(childID, { marker, source, recordedAt: Date.now() })
460
+ cap(childVerdicts)
461
+ return changed
462
+ },
463
+ childVerdict(childID: string): ChildVerdict {
464
+ promote(childVerdicts, childID)
465
+ return childVerdicts.get(childID) ?? { marker: "", source: "", recordedAt: 0 }
466
+ },
467
+ // Snapshot only: no LRU promotion, no cap (pure read for the attribution
468
+ // scan). Returns copies so callers cannot mutate the store in place.
469
+ evidencedChildren(): Array<{ child: string; verdict: ChildVerdict }> {
470
+ const out: Array<{ child: string; verdict: ChildVerdict }> = []
471
+ for (const [child, v] of childVerdicts)
472
+ if (v.marker !== "") out.push({ child, verdict: { ...v } })
473
+ return out
179
474
  },
180
475
  }
181
476
  }