opencode-turnstile 0.0.0-stage → 0.1.0
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/.opencode/agents/debugger.md +70 -0
- package/.opencode/agents/executor.md +108 -0
- package/.opencode/agents/plan-reviewer.md +91 -0
- package/.opencode/agents/planner.md +111 -0
- package/.opencode/agents/reviewer.md +92 -0
- package/.opencode/agents/test-runner.md +55 -0
- package/LICENSE +21 -0
- package/README.md +412 -2
- package/bin/cli.mjs +86 -0
- package/install.json +42 -0
- package/package.json +58 -4
- package/scripts/check-markers.mjs +86 -0
- package/scripts/install-agents.mjs +504 -0
- package/scripts/install.mjs +559 -0
- package/skills/turnstile-setup/SKILL.md +76 -0
- package/src/config.ts +272 -0
- package/src/gates.ts +295 -0
- package/src/journal.ts +157 -0
- package/src/markers.ts +113 -0
- package/src/setup.ts +512 -0
- package/src/state.ts +181 -0
- package/src/types.ts +176 -0
- package/templates/global-AGENTS.md +55 -0
- package/turnstile.ts +82 -0
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
|
+
}
|