opencode-turnstile 0.2.0 → 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 +5 -6
- 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/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
|
-
|
|
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
|
-
*
|
|
39
|
-
*
|
|
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
|
|
63
|
-
* state, mirroring the runtime latest-wins rule
|
|
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
|
-
|
|
71
|
-
|
|
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 {
|
|
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
|
|
346
|
+
function cap<K, V>(map: Map<K, V>, protect?: (key: K) => boolean): void {
|
|
139
347
|
while (map.size > maxSessions) {
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
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
|
}
|