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/.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 +414 -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/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
|
+
}
|