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/config.ts ADDED
@@ -0,0 +1,272 @@
1
+ /**
2
+ * @fileoverview Config boundary: layered configuration for the plugin.
3
+ * Precedence: code defaults ⊕ JSON config file ⊕ environment variables ⊕
4
+ * injected overrides (tests). Config is read once at setup; restart
5
+ * sessions to apply. Invalid keys are ignored and journaled to the
6
+ * env-resolved metrics directory (the journaler does not exist until
7
+ * config resolves): config errors never crash the host, per the fail-open
8
+ * contract.
9
+ */
10
+
11
+ import * as fs from "node:fs"
12
+ import * as os from "node:os"
13
+ import * as path from "node:path"
14
+
15
+ import { appendLine, defaultMetricsDir } from "./journal.ts"
16
+ import { GATE_RE, VERDICT_RE } from "./markers.ts"
17
+ import type { TurnstileConfig } from "./types.ts"
18
+
19
+ /** Default config file location. */
20
+ export const CONFIG_PATH: string = path.join(os.homedir(), ".config", "opencode", "turnstile.json")
21
+
22
+ /** Environment variable names honored by the env layer. */
23
+ export const ENV_KEYS = {
24
+ trace: "TURNSTILE_TRACE",
25
+ metricsDir: "TURNSTILE_METRICS_DIR",
26
+ config: "TURNSTILE_CONFIG",
27
+ } as const
28
+
29
+ /**
30
+ * Builds the code defaults for every config key.
31
+ * @param env Environment consulted for XDG_DATA_HOME only.
32
+ * @returns The default TurnstileConfig (byte-equivalent to the historical
33
+ * hardcoded constants).
34
+ */
35
+ export function defaultConfig(
36
+ env: Record<string, string | undefined> = process.env,
37
+ ): TurnstileConfig {
38
+ return {
39
+ metricsDir: defaultMetricsDir(env),
40
+ journalMaxBytes: 5 * 1024 * 1024,
41
+ journalMaxGenerations: 5,
42
+ maxReviewerRounds: 2,
43
+ maxSessions: 64,
44
+ readWarn: 8,
45
+ readBlock: 15,
46
+ waiverMarker: "USER WAIVER:",
47
+ plansDir: path.join(".opencode", "plans"),
48
+ trace: true,
49
+ agents: {
50
+ reviewer: "reviewer",
51
+ planReviewer: "plan-reviewer",
52
+ executor: "executor",
53
+ testRunner: "test-runner",
54
+ debugger: "debugger",
55
+ bareOutput: ["test-runner", "reviewer", "plan-reviewer"],
56
+ },
57
+ gateMarker: GATE_RE.source,
58
+ verdictMarker: VERDICT_RE.source,
59
+ }
60
+ }
61
+
62
+ /**
63
+ * Validates a patch value for a scalar key.
64
+ * @param key Config key being set.
65
+ * @param value Candidate value.
66
+ * @returns true when the value has the right type for the key.
67
+ */
68
+ function isValidScalar(key: keyof TurnstileConfig, value: unknown): boolean {
69
+ if (key === "trace") return typeof value === "boolean"
70
+ if (key === "metricsDir" || key === "waiverMarker" || key === "plansDir") {
71
+ return typeof value === "string" && value.trim().length > 0
72
+ }
73
+ if (key === "gateMarker" || key === "verdictMarker") return typeof value === "string"
74
+ return typeof value === "number" && Number.isFinite(value) && value > 0
75
+ }
76
+
77
+ /** Scalar config keys whose valid values are finite positive numbers. */
78
+ type NumericConfigKey = Extract<
79
+ keyof TurnstileConfig,
80
+ | "journalMaxBytes"
81
+ | "journalMaxGenerations"
82
+ | "maxReviewerRounds"
83
+ | "maxSessions"
84
+ | "readWarn"
85
+ | "readBlock"
86
+ >
87
+
88
+ /**
89
+ * Assigns a validated scalar into the config without erasing the key→type
90
+ * correlation the validator just established.
91
+ * @param config The config to mutate.
92
+ * @param key The validated config key.
93
+ * @param value The validated value.
94
+ */
95
+ function assignScalar(config: TurnstileConfig, key: keyof TurnstileConfig, value: unknown): void {
96
+ if (key === "trace") config.trace = value as boolean
97
+ else if (key === "metricsDir") config.metricsDir = value as string
98
+ else if (key === "waiverMarker") config.waiverMarker = value as string
99
+ else if (key === "plansDir") config.plansDir = value as string
100
+ else if (key === "gateMarker") config.gateMarker = value as string
101
+ else if (key === "verdictMarker") config.verdictMarker = value as string
102
+ else config[key as NumericConfigKey] = value as number
103
+ }
104
+
105
+ /**
106
+ * Merges a patch into a base config with per-key validation. Unknown keys
107
+ * and wrongly-typed values are skipped and reported as warnings; the
108
+ * `agents` sub-object merges one level deep.
109
+ * @param base The config to merge into.
110
+ * @param patch Candidate overrides (typically parsed JSON or injected opts).
111
+ * @returns The merged config plus one warning string per skipped key.
112
+ */
113
+ export function mergeConfig(
114
+ base: TurnstileConfig,
115
+ patch: unknown,
116
+ ): { config: TurnstileConfig; warnings: string[] } {
117
+ const warnings: string[] = []
118
+ const config: TurnstileConfig = { ...base, agents: { ...base.agents } }
119
+ if (patch === null || typeof patch !== "object" || Array.isArray(patch)) {
120
+ if (patch !== undefined) {
121
+ warnings.push(
122
+ `config: expected an object, got ${Array.isArray(patch) ? "array" : typeof patch}`,
123
+ )
124
+ }
125
+ return { config, warnings }
126
+ }
127
+ for (const [key, value] of Object.entries(patch as Record<string, unknown>)) {
128
+ if (key === "agents") {
129
+ if (value === null || typeof value !== "object" || Array.isArray(value)) {
130
+ warnings.push(`config: agents must be an object; keeping defaults`)
131
+ continue
132
+ }
133
+ for (const [agentKey, agentValue] of Object.entries(value as Record<string, unknown>)) {
134
+ if (agentKey === "bareOutput") {
135
+ if (
136
+ Array.isArray(agentValue) &&
137
+ agentValue.every((v) => typeof v === "string" && v.trim().length > 0)
138
+ ) {
139
+ // Copy: a mutated injected options object must not retro-mutate
140
+ // the live config.
141
+ config.agents.bareOutput = [...(agentValue as string[])]
142
+ } else {
143
+ warnings.push(
144
+ `config: agents.bareOutput must be an array of non-empty strings; keeping default`,
145
+ )
146
+ }
147
+ continue
148
+ }
149
+ if (!(agentKey in config.agents)) {
150
+ warnings.push(`config: unknown agents key "${agentKey}" ignored`)
151
+ continue
152
+ }
153
+ if (typeof agentValue === "string" && agentValue.trim().length > 0) {
154
+ const name = agentKey as
155
+ "reviewer" | "planReviewer" | "executor" | "testRunner" | "debugger"
156
+ config.agents[name] = agentValue
157
+ } else {
158
+ warnings.push(`config: agents.${agentKey} must be a non-empty string; keeping default`)
159
+ }
160
+ }
161
+ continue
162
+ }
163
+ if (!(key in config)) {
164
+ warnings.push(`config: unknown key "${key}" ignored`)
165
+ continue
166
+ }
167
+ if (isValidScalar(key as keyof TurnstileConfig, value)) {
168
+ assignScalar(config, key as keyof TurnstileConfig, value)
169
+ } else {
170
+ warnings.push(`config: invalid value for "${key}"; keeping default`)
171
+ }
172
+ }
173
+ return { config, warnings }
174
+ }
175
+
176
+ /**
177
+ * Journals a config warning to the env-resolved metrics directory. Used
178
+ * only for problems detected before the real journaler exists, so the
179
+ * sink honors the caller's injected env (tests stay off the user journal).
180
+ * @param error The warning/error text.
181
+ * @param env Environment providing TURNSTILE_METRICS_DIR / XDG_DATA_HOME.
182
+ */
183
+ function journalConfigError(error: string, env: Record<string, string | undefined>): void {
184
+ const metricsDir =
185
+ env[ENV_KEYS.metricsDir] ??
186
+ process.env[ENV_KEYS.metricsDir] ??
187
+ defaultMetricsDir({ ...process.env, ...env })
188
+ appendLine(
189
+ path.join(metricsDir, "turnstile.jsonl"),
190
+ JSON.stringify({ ts: new Date().toISOString(), type: "error", error }),
191
+ )
192
+ }
193
+
194
+ /**
195
+ * Reads and parses the JSON config file, returning a patch object.
196
+ * @param env Environment providing the config path override.
197
+ * @returns The parsed patch (or undefined) plus any warning.
198
+ */
199
+ function readFileLayer(env: Record<string, string | undefined>): {
200
+ patch: unknown
201
+ warnings: string[]
202
+ } {
203
+ const configPath = env[ENV_KEYS.config] ?? CONFIG_PATH
204
+ try {
205
+ return { patch: JSON.parse(fs.readFileSync(configPath, "utf8")), warnings: [] }
206
+ } catch (e) {
207
+ const code = (e as NodeJS.ErrnoException).code
208
+ if (code === "ENOENT") return { patch: undefined, warnings: [] }
209
+ const warning =
210
+ code === "EISDIR"
211
+ ? `config file ${configPath} is a directory; ignored`
212
+ : `config file ${configPath} unreadable/malformed: ${e}`
213
+ journalConfigError(warning, env)
214
+ return { patch: undefined, warnings: [warning] }
215
+ }
216
+ }
217
+
218
+ /**
219
+ * Applies the env layer on top of a merged config.
220
+ * @param config The config to mutate.
221
+ * @param env The environment to read.
222
+ * @returns One warning string per invalid env value.
223
+ */
224
+ function applyEnvLayer(config: TurnstileConfig, env: Record<string, string | undefined>): string[] {
225
+ const warnings: string[] = []
226
+ const trace = env[ENV_KEYS.trace]
227
+ if (trace !== undefined) {
228
+ if (trace === "0" || trace === "false") config.trace = false
229
+ else if (trace === "1" || trace === "true") config.trace = true
230
+ else
231
+ warnings.push(
232
+ `env ${ENV_KEYS.trace}=${trace} invalid (use 0/1/true/false); keeping current value`,
233
+ )
234
+ }
235
+ const metricsDir = env[ENV_KEYS.metricsDir]
236
+ if (metricsDir !== undefined) {
237
+ if (metricsDir.trim().length > 0) config.metricsDir = metricsDir
238
+ else warnings.push(`env ${ENV_KEYS.metricsDir} empty; keeping current value`)
239
+ }
240
+ return warnings
241
+ }
242
+
243
+ /**
244
+ * Resolves the full layered configuration: defaults ⊕ config file ⊕ env ⊕
245
+ * injected overrides. Config-file and validation problems are journaled to
246
+ * the default metrics directory and returned as warnings, never thrown.
247
+ * @param env Environment providing XDG_DATA_HOME, TURNSTILE_* variables.
248
+ * @param overrides Final-layer overrides (injected options; tests).
249
+ * @returns The resolved config plus one warning string per skipped/invalid
250
+ * key or file problem.
251
+ */
252
+ export function loadConfig(
253
+ env: Record<string, string | undefined> = process.env,
254
+ overrides?: Partial<TurnstileConfig>,
255
+ ): { config: TurnstileConfig; warnings: string[] } {
256
+ const warnings: string[] = []
257
+ const fileLayer = readFileLayer(env)
258
+ const fileMerge = mergeConfig(defaultConfig(env), fileLayer.patch)
259
+ const config = fileMerge.config
260
+ warnings.push(...fileLayer.warnings, ...fileMerge.warnings)
261
+ warnings.push(...applyEnvLayer(config, env))
262
+ let resolved = config
263
+ if (overrides !== undefined) {
264
+ const merged = mergeConfig(config, overrides)
265
+ resolved = merged.config
266
+ warnings.push(...merged.warnings)
267
+ }
268
+ for (const warning of warnings) {
269
+ if (!warning.startsWith("config file")) journalConfigError(warning, env)
270
+ }
271
+ return { config: resolved, warnings }
272
+ }
package/src/gates.ts ADDED
@@ -0,0 +1,295 @@
1
+ /**
2
+ * @fileoverview Policy boundary: enforcement decisions: the reviewer
3
+ * dispatch gate, the plan-approval gate, the whole-file read horizon, and
4
+ * the decomposition gate.
5
+ * Functions here either return normally or throw a deliberate BLOCKED
6
+ * error; every other failure fails open upstream (hooks guard and journal).
7
+ * All tunables (thresholds, marker, agent names) come from the injected
8
+ * config; persistence goes through the injected Journaler.
9
+ */
10
+
11
+ import * as fs from "node:fs"
12
+ import * as path from "node:path"
13
+
14
+ import type { Journaler } from "./journal.ts"
15
+ import type { GateLedger } from "./state.ts"
16
+ import type { TurnstileConfig } from "./types.ts"
17
+
18
+ /**
19
+ * The only error type turnstile deliberately throws at the host: an
20
+ * enforcement block. Hook guards rethrow `BlockError` instances and
21
+ * journal everything else (fail-open contract).
22
+ */
23
+ export class BlockError extends Error {
24
+ constructor(message: string) {
25
+ super(message)
26
+ this.name = "BlockError"
27
+ }
28
+ }
29
+
30
+ /** Enforcement functions bound to one resolved config + journaler. */
31
+ export interface Gates {
32
+ /**
33
+ * Enforces the reviewer dispatch gate for a pending `task` dispatch:
34
+ * records a waiver when the prompt carries the waiver marker (per
35
+ * dispatch, not sticky), then blocks when the dispatch cap is exhausted
36
+ * or the test gate has not gone green.
37
+ * @param sessionID The parent session ID.
38
+ * @param prompt The dispatch prompt text.
39
+ * @param ledger The gate ledger to read and mutate state from.
40
+ * @throws Error with a `BLOCKED by turnstile:` prefix when the dispatch
41
+ * must not proceed.
42
+ */
43
+ guardReviewerDispatch(sessionID: string, prompt: string, ledger: GateLedger): void
44
+ /**
45
+ * Enforces the whole-file read horizon for a session.
46
+ * @param sessionID The parent session ID.
47
+ * @param ledger The gate ledger to read state from.
48
+ * @throws Error with a `BLOCKED by turnstile:` prefix once the read
49
+ * count has reached the configured block threshold.
50
+ */
51
+ guardReadLimit(sessionID: string, ledger: GateLedger): void
52
+ /**
53
+ * Enforces the decomposition gate: when any plan file in the configured
54
+ * plans directory carries a `## Decomposition` block, executor
55
+ * dispatches must cite the chunk-N / AC-n they implement. Unreadable
56
+ * plans dirs fail open.
57
+ * @param sessionID The parent session ID (used in journal entries).
58
+ * @param prompt The dispatch prompt text to check for chunk citations.
59
+ * @param projectDir The current project directory.
60
+ * @throws Error with a `BLOCKED by turnstile:` prefix when a decomposed
61
+ * plan exists and the prompt cites no chunk.
62
+ */
63
+ enforceDecomposition(sessionID: string, prompt: string, projectDir: string): void
64
+ /**
65
+ * Enforces the plan-approval gate: when any plan file exists in the
66
+ * configured plans directory, executor dispatches require a current
67
+ * plan-reviewer `VERDICT: APPROVE` for the session (or a waiver).
68
+ * Approval is invalidated when plan files change, forcing re-review
69
+ * after a re-plan.
70
+ * @param sessionID The parent session ID.
71
+ * @param prompt The dispatch prompt text (checked for the waiver marker).
72
+ * @param projectDir The current project directory.
73
+ * @param ledger The gate ledger to read and mutate plan state from.
74
+ * @throws Error with a `BLOCKED by turnstile:` prefix when no plan file
75
+ * exists yet is approved for this task.
76
+ */
77
+ enforcePlanApproval(
78
+ sessionID: string,
79
+ prompt: string,
80
+ projectDir: string,
81
+ ledger: GateLedger,
82
+ ): void
83
+ }
84
+
85
+ /**
86
+ * Binds enforcement functions to a resolved config and journaler.
87
+ * @param config The resolved plugin configuration.
88
+ * @param journaler The telemetry writer for gate and blocked entries.
89
+ * @returns The three enforcement functions.
90
+ */
91
+ export function createGates(config: TurnstileConfig, journaler: Journaler): Gates {
92
+ /**
93
+ * Enforces the reviewer dispatch gate for a pending `task` dispatch.
94
+ * Records a waiver (with journal + trace) when the prompt carries the
95
+ * waiver marker, then blocks the dispatch when the dispatch cap is
96
+ * exhausted or the test gate has not gone green. The cap counts reviewer
97
+ * dispatches themselves (per the documented "max 2 reviewer dispatches
98
+ * per task" contract), not BLOCK verdicts. A waiver applies to the
99
+ * dispatch carrying it only; every dispatch prompt must contain the
100
+ * literal marker to be exempt.
101
+ * @param sessionID The parent session ID.
102
+ * @param prompt The dispatch prompt text.
103
+ * @param ledger The gate ledger to read and mutate state from.
104
+ * @throws Error with a `BLOCKED by turnstile:` prefix when the dispatch
105
+ * must not proceed.
106
+ */
107
+ function guardReviewerDispatch(sessionID: string, prompt: string, ledger: GateLedger): void {
108
+ const s = ledger.state(sessionID)
109
+ const at = prompt.indexOf(config.waiverMarker)
110
+ const waived = at !== -1
111
+ if (waived) {
112
+ journaler.journal({
113
+ type: "gate",
114
+ sessionID,
115
+ result: "WAIVED",
116
+ // Waiver quotes carry raw prompt context; journal them only when
117
+ // the trace is enabled (SECURITY.md redaction contract).
118
+ waiver: config.trace ? prompt.slice(at, at + 200) : "<present>",
119
+ })
120
+ journaler.traceEvent("user", "waiver", prompt.slice(at, at + 120))
121
+ }
122
+ if (s.reviewRounds >= config.maxReviewerRounds && !waived) {
123
+ journaler.journal({
124
+ type: "blocked",
125
+ sessionID,
126
+ agent: config.agents.reviewer,
127
+ reason: "round-limit",
128
+ })
129
+ throw new BlockError(
130
+ `BLOCKED by turnstile: reviewer round limit (${config.maxReviewerRounds}) reached. Escalate the open findings to the user, or start a new task (a new user prompt resets the cap).`,
131
+ )
132
+ }
133
+ if (!s.hasGatePass && !waived) {
134
+ journaler.journal({
135
+ type: "blocked",
136
+ sessionID,
137
+ agent: config.agents.reviewer,
138
+ reason: "no-gate-pass",
139
+ })
140
+ throw new BlockError(
141
+ "BLOCKED by turnstile: no reviewer dispatch before the test gate is green. " +
142
+ "Run the test-runner subagent first; dispatch reviewer only after its first line is " +
143
+ "`GATE: PASS tests=<n> passed=<n> failed=<n>`, or obtain an explicit user waiver " +
144
+ "(the dispatch prompt must contain the literal marker 'USER WAIVER:' followed by the user's words).",
145
+ )
146
+ }
147
+ // Check-then-increment: the Nth dispatch is allowed and the (N+1)th is
148
+ // blocked, so exactly `maxReviewerRounds` dispatches succeed per task.
149
+ s.reviewRounds++
150
+ }
151
+
152
+ /**
153
+ * Enforces the whole-file read horizon for a session.
154
+ * @param sessionID The parent session ID.
155
+ * @param ledger The gate ledger to read state from.
156
+ * @throws Error with a `BLOCKED by turnstile:` prefix once the read
157
+ * count has reached the configured block threshold.
158
+ */
159
+ function guardReadLimit(sessionID: string, ledger: GateLedger): void {
160
+ if (ledger.state(sessionID).reads >= config.readBlock) {
161
+ journaler.journal({ type: "blocked", sessionID, tool: "read", reason: "read-limit" })
162
+ throw new BlockError(
163
+ `BLOCKED by turnstile: ${config.readBlock} whole-file reads this session. Switch to grep/glob for targeted retrieval, or report being stuck and escalate to the user.`,
164
+ )
165
+ }
166
+ }
167
+
168
+ /**
169
+ * Enforces the decomposition gate: when any plan file in the configured
170
+ * plans directory carries a `## Decomposition` block, executor
171
+ * dispatches must cite the chunk-N / AC-n they implement.
172
+ * @param sessionID The parent session ID (used in journal entries).
173
+ * @param prompt The dispatch prompt text to check for chunk citations.
174
+ * @param projectDir The current project directory.
175
+ * @throws Error with a `BLOCKED by turnstile:` prefix when a decomposed
176
+ * plan exists and the prompt cites no chunk.
177
+ */
178
+ // Cached plans-dir verdict keyed by the directory's content signature
179
+ // (dir mtime + sorted plan names + per-file mtimes). Saves the
180
+ // per-dispatch rescan; any fs error throws to the caller's fail-open.
181
+ let plansCache: { key: string; scan: PlansScan } | null = null
182
+
183
+ /** What one plans-dir scan establishes for both plan gates. */
184
+ interface PlansScan {
185
+ /** Sorted plan file names (empty when the dir holds no plans). */
186
+ names: string[]
187
+ /** First (sorted) plan carrying a `## Decomposition` block, or null. */
188
+ decompFile: string | null
189
+ }
190
+
191
+ /**
192
+ * Scans the plans directory, returning the scan and whether it differs
193
+ * from the previously cached scan (used to invalidate plan approval).
194
+ * @param dir Absolute plans directory path.
195
+ * @returns The scan plus a `changed` flag (false on the host's first
196
+ * scan so a journal-replayed approval survives a restart).
197
+ */
198
+ function scanPlans(dir: string): { scan: PlansScan; changed: boolean } {
199
+ const names = fs
200
+ .readdirSync(dir)
201
+ .filter((f) => f.endsWith(".md"))
202
+ .sort()
203
+ const files = names.map((n) => `${n}:${fs.statSync(path.join(dir, n)).mtimeMs}`)
204
+ const key = `${dir}|${fs.statSync(dir).mtimeMs}|${files.join(",")}`
205
+ if (plansCache !== null && plansCache.key === key) {
206
+ return { scan: plansCache.scan, changed: false }
207
+ }
208
+ const decompFile =
209
+ names.find((n) => fs.readFileSync(path.join(dir, n), "utf8").includes("## Decomposition")) ??
210
+ null
211
+ const scan: PlansScan = { names, decompFile }
212
+ const changed = plansCache !== null
213
+ plansCache = { key, scan }
214
+ return { scan, changed }
215
+ }
216
+
217
+ function enforceDecomposition(sessionID: string, prompt: string, projectDir: string): void {
218
+ if (/\b(?:chunk-\d+|AC-\d+)\b/.test(prompt)) return
219
+ const plansDir = path.join(projectDir, config.plansDir)
220
+ try {
221
+ if (!fs.existsSync(plansDir)) return
222
+ const { scan } = scanPlans(plansDir)
223
+ if (scan.decompFile !== null) {
224
+ journaler.journal({
225
+ type: "blocked",
226
+ sessionID,
227
+ agent: config.agents.executor,
228
+ reason: "missing-chunk-ref",
229
+ plan: scan.decompFile,
230
+ })
231
+ throw new BlockError(
232
+ `BLOCKED by turnstile: plan "${scan.decompFile}" has a ## Decomposition block — executor dispatches must cite the chunk-N / AC-n they implement.`,
233
+ )
234
+ }
235
+ } catch (e) {
236
+ if (e instanceof BlockError) throw e
237
+ // unreadable plans dir: fail-open
238
+ }
239
+ }
240
+
241
+ function enforcePlanApproval(
242
+ sessionID: string,
243
+ prompt: string,
244
+ projectDir: string,
245
+ ledger: GateLedger,
246
+ ): void {
247
+ const at = prompt.indexOf(config.waiverMarker)
248
+ if (at !== -1) {
249
+ journaler.journal({
250
+ type: "plan",
251
+ sessionID,
252
+ result: "WAIVED",
253
+ // Waiver quotes carry raw prompt context; journal them only when
254
+ // the trace is enabled (SECURITY.md redaction contract).
255
+ waiver: config.trace ? prompt.slice(at, at + 200) : "<present>",
256
+ })
257
+ journaler.traceEvent("user", "plan-waiver", prompt.slice(at, at + 120))
258
+ return
259
+ }
260
+ const plansDir = path.join(projectDir, config.plansDir)
261
+ try {
262
+ if (!fs.existsSync(plansDir)) return
263
+ const { scan, changed } = scanPlans(plansDir)
264
+ if (scan.names.length === 0) return
265
+ const s = ledger.state(sessionID)
266
+ if (changed) {
267
+ // Plan files changed since the last scan → any prior approval is
268
+ // stale and re-review is required. The host's very first scan does
269
+ // not clear (changed=false there) so a journal-replayed approval
270
+ // survives a restart.
271
+ s.hasPlanVerdict = false
272
+ }
273
+ if (!s.hasPlanVerdict) {
274
+ const plan = scan.names[0]
275
+ journaler.journal({
276
+ type: "blocked",
277
+ sessionID,
278
+ agent: config.agents.executor,
279
+ reason: "no-plan-verdict",
280
+ plan,
281
+ })
282
+ throw new BlockError(
283
+ `BLOCKED by turnstile: no plan approval for "${plan}". Dispatch the plan-reviewer subagent first; proceed with executor only after the plan-reviewer's first line is ` +
284
+ "`VERDICT: APPROVE crit=<n> high=<n> med=<n> low=<n>`, or obtain an explicit user waiver " +
285
+ "(the dispatch prompt must contain the literal marker 'USER WAIVER:' followed by the user's words).",
286
+ )
287
+ }
288
+ } catch (e) {
289
+ if (e instanceof BlockError) throw e
290
+ // unreadable plans dir: fail-open
291
+ }
292
+ }
293
+
294
+ return { guardReviewerDispatch, guardReadLimit, enforceDecomposition, enforcePlanApproval }
295
+ }