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/gates.ts
CHANGED
|
@@ -9,11 +9,12 @@
|
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
11
|
import * as fs from "node:fs"
|
|
12
|
+
import { createHash } from "node:crypto"
|
|
12
13
|
import * as path from "node:path"
|
|
13
14
|
|
|
14
15
|
import type { Journaler } from "./journal.ts"
|
|
15
16
|
import type { GateLedger } from "./state.ts"
|
|
16
|
-
import type { TurnstileConfig } from "./types.ts"
|
|
17
|
+
import type { GateState, TurnstileConfig } from "./types.ts"
|
|
17
18
|
|
|
18
19
|
/**
|
|
19
20
|
* The only error type turnstile deliberately throws at the host: an
|
|
@@ -31,16 +32,18 @@ export class BlockError extends Error {
|
|
|
31
32
|
export interface Gates {
|
|
32
33
|
/**
|
|
33
34
|
* Enforces the reviewer dispatch gate for a pending `task` dispatch:
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
35
|
+
* waives the dispatch when the task's prompt-channel waiver
|
|
36
|
+
* (`ledger.state().userWaiver`, captured from the user prompt in the setup
|
|
37
|
+
* hook) is set, then blocks when the dispatch cap is exhausted or the test
|
|
38
|
+
* gate has not gone green. A waiver bypasses the cap without counting toward
|
|
39
|
+
* it and is consumed by the dispatch that unlocks it (per-dispatch); the
|
|
40
|
+
* task waiver otherwise stays live.
|
|
37
41
|
* @param sessionID The parent session ID.
|
|
38
|
-
* @param prompt The dispatch prompt text.
|
|
39
42
|
* @param ledger The gate ledger to read and mutate state from.
|
|
40
43
|
* @throws Error with a `BLOCKED by turnstile:` prefix when the dispatch
|
|
41
44
|
* must not proceed.
|
|
42
45
|
*/
|
|
43
|
-
guardReviewerDispatch(sessionID: string,
|
|
46
|
+
guardReviewerDispatch(sessionID: string, ledger: GateLedger): void
|
|
44
47
|
/**
|
|
45
48
|
* Enforces the whole-file read horizon for a session.
|
|
46
49
|
* @param sessionID The parent session ID.
|
|
@@ -65,21 +68,58 @@ export interface Gates {
|
|
|
65
68
|
* Enforces the plan-approval gate: when any plan file exists in the
|
|
66
69
|
* configured plans directory, executor dispatches require a current
|
|
67
70
|
* plan-reviewer `VERDICT: APPROVE` for the session (or a waiver).
|
|
68
|
-
* Approval is invalidated when
|
|
69
|
-
* after a re-plan.
|
|
71
|
+
* Approval is invalidated when this session's approval was issued against
|
|
72
|
+
* a different plans-dir signature, forcing re-review after a re-plan.
|
|
73
|
+
* The waiver is the task's prompt-channel waiver
|
|
74
|
+
* (`ledger.state().userWaiver`, captured from the user prompt): non-empty
|
|
75
|
+
* waives this gate for the whole task (not per dispatch).
|
|
70
76
|
* @param sessionID The parent session ID.
|
|
71
|
-
* @param prompt The dispatch prompt text (checked for the waiver marker).
|
|
72
77
|
* @param projectDir The current project directory.
|
|
73
78
|
* @param ledger The gate ledger to read and mutate plan state from.
|
|
74
79
|
* @throws Error with a `BLOCKED by turnstile:` prefix when no plan file
|
|
75
80
|
* exists yet is approved for this task.
|
|
76
81
|
*/
|
|
77
|
-
enforcePlanApproval(
|
|
78
|
-
|
|
79
|
-
|
|
82
|
+
enforcePlanApproval(sessionID: string, projectDir: string, ledger: GateLedger): void
|
|
83
|
+
/**
|
|
84
|
+
* Returns the current plans-dir signature for approval-time stamping, or
|
|
85
|
+
* null when the dir is absent or unreadable (fail-open: an unstampable
|
|
86
|
+
* approval behaves as unknown and is validated on the next enforcing
|
|
87
|
+
* dispatch).
|
|
88
|
+
* @param projectDir The current project directory.
|
|
89
|
+
* @returns The plans-dir content signature, or null when absent/unreadable.
|
|
90
|
+
*/
|
|
91
|
+
plansSignature(projectDir: string): string | null
|
|
92
|
+
/**
|
|
93
|
+
* Returns the first plan file's name and a short body preview, or undefined
|
|
94
|
+
* when the plans dir is absent, unreadable, or empty (fail-open). The preview
|
|
95
|
+
* is the first ~40 non-heading lines; shared by the escalatable plan gate so
|
|
96
|
+
* the ask the evaluate handler surfaces names the same file and shows its
|
|
97
|
+
* body.
|
|
98
|
+
* @param projectDir The current project directory.
|
|
99
|
+
* @returns `{ file, head }` for the first sorted plan, or undefined.
|
|
100
|
+
*/
|
|
101
|
+
planSummary(projectDir: string): { file: string; head: string } | undefined
|
|
102
|
+
/**
|
|
103
|
+
* Returns whether the plan-approval gate is currently red for a parent, and
|
|
104
|
+
* when red, the plan file + body preview to surface in a permission ask.
|
|
105
|
+
* Mirrors `enforcePlanApproval`'s guard (waived, absent/empty dir, or an
|
|
106
|
+
* approved verdict is not red; otherwise red) so the evaluate handler and the
|
|
107
|
+
* enforcing dispatch decide escalation from the exact same scan. Waiver and
|
|
108
|
+
* approval are folded into `red`: a red result implies a non-empty waiver is
|
|
109
|
+
* absent and `hasPlanVerdict` is false, so the caller only tests `red`.
|
|
110
|
+
* @param projectDir The current project directory.
|
|
111
|
+
* @param ledger The gate ledger to read (and reconcile) state from.
|
|
112
|
+
* @param sessionID The parent session whose plan-gate state is read.
|
|
113
|
+
* @returns `{ red: false }` when the gate is not red, or `{ red: true, summary }` when it is.
|
|
114
|
+
*/
|
|
115
|
+
planGateStatus(
|
|
80
116
|
projectDir: string,
|
|
81
117
|
ledger: GateLedger,
|
|
82
|
-
|
|
118
|
+
sessionID: string,
|
|
119
|
+
): {
|
|
120
|
+
red: boolean
|
|
121
|
+
summary?: { file: string; head: string }
|
|
122
|
+
}
|
|
83
123
|
}
|
|
84
124
|
|
|
85
125
|
/**
|
|
@@ -91,33 +131,38 @@ export interface Gates {
|
|
|
91
131
|
export function createGates(config: TurnstileConfig, journaler: Journaler): Gates {
|
|
92
132
|
/**
|
|
93
133
|
* Enforces the reviewer dispatch gate for a pending `task` dispatch.
|
|
94
|
-
*
|
|
95
|
-
*
|
|
96
|
-
* exhausted or the test gate has not
|
|
97
|
-
* dispatches themselves (per the
|
|
98
|
-
* per task" contract), not BLOCK
|
|
99
|
-
*
|
|
100
|
-
*
|
|
134
|
+
* Waives the dispatch when the task's prompt-channel waiver
|
|
135
|
+
* (`s.userWaiver`, captured from the user prompt in the setup hook) is set,
|
|
136
|
+
* then blocks when the dispatch cap is exhausted or the test gate has not
|
|
137
|
+
* gone green. The cap counts reviewer dispatches themselves (per the
|
|
138
|
+
* documented "max 2 reviewer dispatches per task" contract), not BLOCK
|
|
139
|
+
* verdicts. A waiver bypasses the cap without counting toward it and is
|
|
140
|
+
* consumed by the dispatch that unlocks it (per-dispatch): a stale waiver
|
|
141
|
+
* cannot outlive that single reviewer dispatch. Provenance matters (issue
|
|
142
|
+
* #34, AC-1): the waiver is read from the task prompt, never the dispatch
|
|
143
|
+
* prompt.
|
|
101
144
|
* @param sessionID The parent session ID.
|
|
102
|
-
* @param prompt The dispatch prompt text.
|
|
103
145
|
* @param ledger The gate ledger to read and mutate state from.
|
|
104
146
|
* @throws Error with a `BLOCKED by turnstile:` prefix when the dispatch
|
|
105
147
|
* must not proceed.
|
|
106
148
|
*/
|
|
107
|
-
function guardReviewerDispatch(sessionID: string,
|
|
149
|
+
function guardReviewerDispatch(sessionID: string, ledger: GateLedger): void {
|
|
108
150
|
const s = ledger.state(sessionID)
|
|
109
|
-
const
|
|
110
|
-
const waived = at !== -1
|
|
151
|
+
const waived = s.userWaiver !== ""
|
|
111
152
|
if (waived) {
|
|
112
153
|
journaler.journal({
|
|
113
154
|
type: "gate",
|
|
114
155
|
sessionID,
|
|
115
156
|
result: "WAIVED",
|
|
157
|
+
// Provenance: this waiver opened the gate from the user prompt, not a
|
|
158
|
+
// dispatch prompt (issue #34, AC-1).
|
|
159
|
+
channel: "prompt",
|
|
116
160
|
// Waiver quotes carry raw prompt context; journal them only when
|
|
117
|
-
// the trace is enabled (SECURITY.md redaction contract).
|
|
118
|
-
|
|
161
|
+
// the trace is enabled (SECURITY.md redaction contract). `s.userWaiver`
|
|
162
|
+
// is already trace-gated by the prompt-hook capture.
|
|
163
|
+
waiver: s.userWaiver,
|
|
119
164
|
})
|
|
120
|
-
journaler.traceEvent("user", "waiver",
|
|
165
|
+
journaler.traceEvent("user", "waiver", s.userWaiver, sessionID)
|
|
121
166
|
}
|
|
122
167
|
if (s.reviewRounds >= config.maxReviewerRounds && !waived) {
|
|
123
168
|
journaler.journal({
|
|
@@ -131,6 +176,25 @@ export function createGates(config: TurnstileConfig, journaler: Journaler): Gate
|
|
|
131
176
|
)
|
|
132
177
|
}
|
|
133
178
|
if (!s.hasGatePass && !waived) {
|
|
179
|
+
// An unverified PASS (evidence correlation, gateEvidence:enforce) leaves
|
|
180
|
+
// unverifiedPass set: the gate is genuinely not green, so the block names
|
|
181
|
+
// the evidence contract. The legacy no-gate-pass path stays byte-identical
|
|
182
|
+
// when no unverified claim was recorded (s.unverifiedPass === "").
|
|
183
|
+
if (s.unverifiedPass !== "") {
|
|
184
|
+
journaler.journal({
|
|
185
|
+
type: "blocked",
|
|
186
|
+
sessionID,
|
|
187
|
+
agent: config.agents.reviewer,
|
|
188
|
+
reason: "gate-unverified",
|
|
189
|
+
})
|
|
190
|
+
throw new BlockError(
|
|
191
|
+
"BLOCKED by turnstile: reviewer dispatch without gate evidence: a GATE: PASS " +
|
|
192
|
+
"was recorded without a journaled successful test-command execution for this " +
|
|
193
|
+
`session. Run the ${config.agents.testRunner} subagent first, or obtain an ` +
|
|
194
|
+
`explicit user waiver ` +
|
|
195
|
+
`(a user prompt this task must contain the literal marker '${config.waiverMarker}' followed by the user's words).`,
|
|
196
|
+
)
|
|
197
|
+
}
|
|
134
198
|
journaler.journal({
|
|
135
199
|
type: "blocked",
|
|
136
200
|
sessionID,
|
|
@@ -139,14 +203,20 @@ export function createGates(config: TurnstileConfig, journaler: Journaler): Gate
|
|
|
139
203
|
})
|
|
140
204
|
throw new BlockError(
|
|
141
205
|
"BLOCKED by turnstile: no reviewer dispatch before the test gate is green. " +
|
|
142
|
-
|
|
206
|
+
`Run the ${config.agents.testRunner} subagent first; dispatch ${config.agents.reviewer} only after its first line is ` +
|
|
143
207
|
"`GATE: PASS tests=<n> passed=<n> failed=<n>`, or obtain an explicit user waiver " +
|
|
144
|
-
|
|
208
|
+
`(a user prompt this task must contain the literal marker '${config.waiverMarker}' followed by the user's words).`,
|
|
145
209
|
)
|
|
146
210
|
}
|
|
147
211
|
// Check-then-increment: the Nth dispatch is allowed and the (N+1)th is
|
|
148
212
|
// blocked, so exactly `maxReviewerRounds` dispatches succeed per task.
|
|
149
|
-
|
|
213
|
+
// A waiver is an explicit per-dispatch user escape, so it bypasses the
|
|
214
|
+
// cap without counting toward it — the cap bounds the unwaived re-dispatch
|
|
215
|
+
// loop only. The waiver is consumed by the dispatch that unlocks it (per
|
|
216
|
+
// the historical quote-consumption semantics), so a stale task waiver
|
|
217
|
+
// cannot grant a later reviewer dispatch.
|
|
218
|
+
if (!waived) s.reviewRounds++
|
|
219
|
+
else s.userWaiver = ""
|
|
150
220
|
}
|
|
151
221
|
|
|
152
222
|
/**
|
|
@@ -175,13 +245,21 @@ export function createGates(config: TurnstileConfig, journaler: Journaler): Gate
|
|
|
175
245
|
* @throws Error with a `BLOCKED by turnstile:` prefix when a decomposed
|
|
176
246
|
* plan exists and the prompt cites no chunk.
|
|
177
247
|
*/
|
|
178
|
-
// Cached plans-dir
|
|
179
|
-
// (dir
|
|
180
|
-
//
|
|
248
|
+
// Cached plans-dir scan keyed by the directory's content signature
|
|
249
|
+
// (`dir|name:sha256hex,…`). A key hit returns the cached scan and skips the
|
|
250
|
+
// utf8 decode and the `## Decomposition` search; a key miss recomputes
|
|
251
|
+
// decompFile. Any fs error throws to the caller's fail-open.
|
|
181
252
|
let plansCache: { key: string; scan: PlansScan } | null = null
|
|
182
253
|
|
|
183
254
|
/** What one plans-dir scan establishes for both plan gates. */
|
|
184
255
|
interface PlansScan {
|
|
256
|
+
/**
|
|
257
|
+
* Content signature of the plans dir: `dir|name:sha256hex,…`. Two scans
|
|
258
|
+
* with different keys mean the plans dir _content_ changed (an add,
|
|
259
|
+
* delete, rename, or same-size/content edit); a utimes-only or dir touch
|
|
260
|
+
* leaves it identical — dir mtime is deliberately not hashed.
|
|
261
|
+
*/
|
|
262
|
+
key: string
|
|
185
263
|
/** Sorted plan file names (empty when the dir holds no plans). */
|
|
186
264
|
names: string[]
|
|
187
265
|
/** First (sorted) plan carrying a `## Decomposition` block, or null. */
|
|
@@ -189,37 +267,188 @@ export function createGates(config: TurnstileConfig, journaler: Journaler): Gate
|
|
|
189
267
|
}
|
|
190
268
|
|
|
191
269
|
/**
|
|
192
|
-
*
|
|
193
|
-
*
|
|
270
|
+
* Sorted `.md` names with per-file content buffers, one readdir + read
|
|
271
|
+
* each; the buffers feed the digest so `scanPlans` never re-reads a file.
|
|
194
272
|
* @param dir Absolute plans directory path.
|
|
195
|
-
* @returns
|
|
196
|
-
* scan so a journal-replayed approval survives a restart).
|
|
273
|
+
* @returns Sorted `{ name, bytes }` for every `.md` entry (empty when none).
|
|
197
274
|
*/
|
|
198
|
-
function
|
|
275
|
+
function readEntries(dir: string): Array<{ name: string; bytes: Buffer }> {
|
|
199
276
|
const names = fs
|
|
200
277
|
.readdirSync(dir)
|
|
201
278
|
.filter((f) => f.endsWith(".md"))
|
|
202
279
|
.sort()
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
280
|
+
return names.map((name) => ({ name, bytes: fs.readFileSync(path.join(dir, name)) }))
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* Content signature: `dir|name:sha256hex,…`. Identical iff the plans dir's
|
|
285
|
+
* content is identical — an add, delete, rename, or same-size/content edit
|
|
286
|
+
* changes it; a utimes-only or dir touch does not.
|
|
287
|
+
* @param dir Absolute plans directory path.
|
|
288
|
+
* @param entries The plan entries from `readEntries`.
|
|
289
|
+
* @returns The content signature for `dir` and its entries.
|
|
290
|
+
*/
|
|
291
|
+
function plansKey(dir: string, entries: Array<{ name: string; bytes: Buffer }>): string {
|
|
292
|
+
const files = entries
|
|
293
|
+
.map((e) => `${e.name}:${createHash("sha256").update(e.bytes).digest("hex")}`)
|
|
294
|
+
.join(",")
|
|
295
|
+
return `${dir}|${files}`
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* Scans the plans directory, returning the scan whose `key` is the dir's
|
|
300
|
+
* content signature. The cache skips a redundant rescan: a key hit returns
|
|
301
|
+
* the cached scan, skipping the utf8 decode and `## Decomposition` search;
|
|
302
|
+
* a key miss replaces it — either way the returned key reports the dir's
|
|
303
|
+
* current content.
|
|
304
|
+
* @param dir Absolute plans directory path.
|
|
305
|
+
* @returns The current scan; `scan.key` is the dir's content signature.
|
|
306
|
+
*/
|
|
307
|
+
function scanPlans(dir: string): PlansScan {
|
|
308
|
+
const entries = readEntries(dir)
|
|
309
|
+
const key = plansKey(dir, entries)
|
|
310
|
+
const cached = plansCache
|
|
311
|
+
if (cached !== null && cached.key === key) return cached.scan
|
|
312
|
+
const scan: PlansScan = {
|
|
313
|
+
key,
|
|
314
|
+
names: entries.map((e) => e.name),
|
|
315
|
+
decompFile:
|
|
316
|
+
entries.find((e) => e.bytes.toString("utf8").includes("## Decomposition"))?.name ?? null,
|
|
207
317
|
}
|
|
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
318
|
plansCache = { key, scan }
|
|
214
|
-
return
|
|
319
|
+
return scan
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/**
|
|
323
|
+
* Reconciles a session's approval with the current scan: a non-empty stamp
|
|
324
|
+
* that differs from `scan.key` means the plan was re-planned since approval,
|
|
325
|
+
* so the verdict is stale and re-review is required; a green pass re-stamps
|
|
326
|
+
* the current key (idempotent, and how a replay-restored "" becomes
|
|
327
|
+
* validated). A "" stamp is unknown — journal-replayed approvals must
|
|
328
|
+
* survive restart, so it is accepted once, mirroring the old first-scan
|
|
329
|
+
* rule. The accept-once window also covers a session that approved but never
|
|
330
|
+
* ran an enforcing dispatch: its stamp is still empty until the first green
|
|
331
|
+
* dispatch stamps it.
|
|
332
|
+
* @param s The session's gate state (mutated).
|
|
333
|
+
* @param scan The current plans-dir scan.
|
|
334
|
+
*/
|
|
335
|
+
function reconcilePlanStamp(s: GateState, scan: PlansScan): void {
|
|
336
|
+
if (s.hasPlanVerdict && s.plansSignature !== "" && s.plansSignature !== scan.key)
|
|
337
|
+
s.hasPlanVerdict = false
|
|
338
|
+
if (s.hasPlanVerdict) s.plansSignature = scan.key
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/**
|
|
342
|
+
* The leading preview of one plan file's body: the first ~40 non-heading
|
|
343
|
+
* lines (lines not beginning with '#' after optional leading whitespace),
|
|
344
|
+
* joined and trimmed. Feeds the permission-ask message so the human sees the
|
|
345
|
+
* plan's body without the heading scaffold.
|
|
346
|
+
* @param bytes The plan file bytes.
|
|
347
|
+
* @returns The head text, or "" when the file has no non-heading lines.
|
|
348
|
+
*/
|
|
349
|
+
function planHead(bytes: Buffer): string {
|
|
350
|
+
const lines = bytes.toString("utf8").split("\n")
|
|
351
|
+
const body = lines
|
|
352
|
+
.filter((line) => !/^\s*#/.test(line))
|
|
353
|
+
.slice(0, 40)
|
|
354
|
+
.join("\n")
|
|
355
|
+
return body.trim()
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/**
|
|
359
|
+
* Builds the `{ file, head }` preview for the first sorted plan of a scan,
|
|
360
|
+
* reading that plan's bytes once (they were already read to compute the scan
|
|
361
|
+
* key). Shared by both `planSummary` and the escalatable plan gate so the
|
|
362
|
+
* ask names exactly the plan `enforcePlanApproval` would block on.
|
|
363
|
+
* @param scan The current plans-dir scan.
|
|
364
|
+
* @param plansDir The absolute plans directory of `scan`.
|
|
365
|
+
* @returns The first plan's `{ file, head }`.
|
|
366
|
+
*/
|
|
367
|
+
function firstPlanSummary(scan: PlansScan, plansDir: string): { file: string; head: string } {
|
|
368
|
+
// The callers guard names.length === 0 before calling, so names[0] exists;
|
|
369
|
+
// the non-null assertion is enforced by that guard, not silently assumed.
|
|
370
|
+
const name = scan.names[0]!
|
|
371
|
+
const bytes = fs.readFileSync(path.join(plansDir, name))
|
|
372
|
+
return { file: name, head: planHead(bytes) }
|
|
215
373
|
}
|
|
216
374
|
|
|
375
|
+
/**
|
|
376
|
+
* Returns the first plan file's name and a short body preview, or undefined
|
|
377
|
+
* when the plans dir is absent, unreadable, or empty (fail-open). The preview
|
|
378
|
+
* is the first ~40 non-heading lines; shared with the escalatable plan gate
|
|
379
|
+
* so the ask the evaluate handler surfaces names the same file and shows its
|
|
380
|
+
* body.
|
|
381
|
+
* @param projectDir The current project directory.
|
|
382
|
+
* @returns `{ file, head }` for the first sorted plan, or undefined.
|
|
383
|
+
*/
|
|
384
|
+
function planSummary(projectDir: string): { file: string; head: string } | undefined {
|
|
385
|
+
const plansDir = path.join(projectDir, config.plansDir)
|
|
386
|
+
try {
|
|
387
|
+
if (!fs.existsSync(plansDir)) return undefined
|
|
388
|
+
const scan = scanPlans(plansDir)
|
|
389
|
+
if (scan.names.length === 0) return undefined
|
|
390
|
+
return firstPlanSummary(scan, plansDir)
|
|
391
|
+
} catch (e) {
|
|
392
|
+
// Journal the fail-open so the unreadable plans dir stays observable.
|
|
393
|
+
journaler.journal({ type: "error", error: `plans dir unreadable: ${e}` })
|
|
394
|
+
return undefined
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
/**
|
|
399
|
+
* Returns whether the plan-approval gate is currently red for a parent, and
|
|
400
|
+
* when red, the plan file + body preview to surface in a permission ask.
|
|
401
|
+
* Mirrors `enforcePlanApproval`'s guard (waived, absent/empty dir, or an
|
|
402
|
+
* approved verdict is not red; otherwise red) so the evaluate handler and the
|
|
403
|
+
* enforcing dispatch decide escalation from the exact same scan. Waiver and
|
|
404
|
+
* approval are folded into `red`: a red result implies a non-empty waiver is
|
|
405
|
+
* absent and `hasPlanVerdict` is false, so the caller only tests `red`.
|
|
406
|
+
* @param projectDir The current project directory.
|
|
407
|
+
* @param ledger The gate ledger to read (and reconcile) state from.
|
|
408
|
+
* @param sessionID The parent session whose plan-gate state is read.
|
|
409
|
+
* @returns `{ red: false }` when not red, or `{ red: true, summary }` when red.
|
|
410
|
+
*/
|
|
411
|
+
function planGateStatus(
|
|
412
|
+
projectDir: string,
|
|
413
|
+
ledger: GateLedger,
|
|
414
|
+
sessionID: string,
|
|
415
|
+
): { red: boolean; summary?: { file: string; head: string } } {
|
|
416
|
+
const s = ledger.state(sessionID)
|
|
417
|
+
if (s.userWaiver !== "") return { red: false }
|
|
418
|
+
const plansDir = path.join(projectDir, config.plansDir)
|
|
419
|
+
try {
|
|
420
|
+
if (!fs.existsSync(plansDir)) return { red: false }
|
|
421
|
+
const scan = scanPlans(plansDir)
|
|
422
|
+
if (scan.names.length === 0) return { red: false }
|
|
423
|
+
// Reconcile this session's approval with the current scan: a stamp that
|
|
424
|
+
// differs from the current key means re-plan → stale verdict. Same rule
|
|
425
|
+
// as enforcePlanApproval, so the handler and the dispatch agree.
|
|
426
|
+
reconcilePlanStamp(s, scan)
|
|
427
|
+
if (s.hasPlanVerdict) return { red: false }
|
|
428
|
+
return { red: true, summary: firstPlanSummary(scan, plansDir) }
|
|
429
|
+
} catch (e) {
|
|
430
|
+
// Journal the fail-open so an unreadable plans dir stays observable.
|
|
431
|
+
journaler.journal({ type: "error", error: `plans dir unreadable: ${e}` })
|
|
432
|
+
return { red: false }
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
/**
|
|
437
|
+
* Enforces the decomposition gate: when any plan file in the configured
|
|
438
|
+
* plans directory carries a `## Decomposition` block, executor
|
|
439
|
+
* dispatches must cite the chunk-N / AC-n they implement.
|
|
440
|
+
* @param sessionID The parent session ID (used in journal entries).
|
|
441
|
+
* @param prompt The dispatch prompt text to check for chunk citations.
|
|
442
|
+
* @param projectDir The current project directory.
|
|
443
|
+
* @throws Error with a `BLOCKED by turnstile:` prefix when a decomposed
|
|
444
|
+
* plan exists and the prompt cites no chunk.
|
|
445
|
+
*/
|
|
217
446
|
function enforceDecomposition(sessionID: string, prompt: string, projectDir: string): void {
|
|
218
447
|
if (/\b(?:chunk-\d+|AC-\d+)\b/.test(prompt)) return
|
|
219
448
|
const plansDir = path.join(projectDir, config.plansDir)
|
|
220
449
|
try {
|
|
221
450
|
if (!fs.existsSync(plansDir)) return
|
|
222
|
-
const
|
|
451
|
+
const scan = scanPlans(plansDir)
|
|
223
452
|
if (scan.decompFile !== null) {
|
|
224
453
|
journaler.journal({
|
|
225
454
|
type: "blocked",
|
|
@@ -234,62 +463,78 @@ export function createGates(config: TurnstileConfig, journaler: Journaler): Gate
|
|
|
234
463
|
}
|
|
235
464
|
} catch (e) {
|
|
236
465
|
if (e instanceof BlockError) throw e
|
|
237
|
-
// unreadable plans dir
|
|
466
|
+
// Journal the fail-open so an unreadable plans dir stays observable.
|
|
467
|
+
journaler.journal({ type: "error", error: `plans dir unreadable: ${e}` })
|
|
238
468
|
}
|
|
239
469
|
}
|
|
240
470
|
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
471
|
+
/**
|
|
472
|
+
* Returns the current plans-dir signature for approval-time stamping, or
|
|
473
|
+
* null when the dir is absent or unreadable (fail-open: an unstampable
|
|
474
|
+
* approval behaves as unknown and is validated on the next enforcing
|
|
475
|
+
* dispatch).
|
|
476
|
+
* @param projectDir The current project directory.
|
|
477
|
+
* @returns The plans-dir content signature, or null when absent/unreadable.
|
|
478
|
+
*/
|
|
479
|
+
function plansSignature(projectDir: string): string | null {
|
|
480
|
+
const plansDir = path.join(projectDir, config.plansDir)
|
|
481
|
+
if (!fs.existsSync(plansDir)) return null
|
|
482
|
+
try {
|
|
483
|
+
return scanPlans(plansDir).key
|
|
484
|
+
} catch (e) {
|
|
485
|
+
// Journal the fail-open so the unreadable plans dir stays observable.
|
|
486
|
+
journaler.journal({ type: "error", error: `plans dir unreadable: ${e}` })
|
|
487
|
+
return null
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
function enforcePlanApproval(sessionID: string, projectDir: string, ledger: GateLedger): void {
|
|
492
|
+
const s = ledger.state(sessionID)
|
|
493
|
+
if (s.userWaiver !== "") {
|
|
249
494
|
journaler.journal({
|
|
250
495
|
type: "plan",
|
|
251
496
|
sessionID,
|
|
252
497
|
result: "WAIVED",
|
|
498
|
+
// Provenance: this waiver opened the gate from the user prompt, not a
|
|
499
|
+
// dispatch prompt (issue #34, AC-1).
|
|
500
|
+
channel: "prompt",
|
|
253
501
|
// Waiver quotes carry raw prompt context; journal them only when
|
|
254
|
-
// the trace is enabled (SECURITY.md redaction contract).
|
|
255
|
-
|
|
502
|
+
// the trace is enabled (SECURITY.md redaction contract). `s.userWaiver`
|
|
503
|
+
// is already trace-gated by the prompt-hook capture.
|
|
504
|
+
waiver: s.userWaiver,
|
|
256
505
|
})
|
|
257
|
-
journaler.traceEvent("user", "plan-waiver",
|
|
506
|
+
journaler.traceEvent("user", "plan-waiver", s.userWaiver, sessionID)
|
|
258
507
|
return
|
|
259
508
|
}
|
|
260
|
-
const
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
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
|
|
509
|
+
const status = planGateStatus(projectDir, ledger, sessionID)
|
|
510
|
+
// status.red ⇒ summary is always present: planGateStatus builds it from a
|
|
511
|
+
// scan whose names[0] was already read to compute the scan key, so the
|
|
512
|
+
// non-null assertion is sound. The block message is byte-identical to the
|
|
513
|
+
// pre-refactor scan; only the source of `plan` changed.
|
|
514
|
+
if (status.red) {
|
|
515
|
+
const plan = status.summary!.file
|
|
516
|
+
journaler.journal({
|
|
517
|
+
type: "blocked",
|
|
518
|
+
sessionID,
|
|
519
|
+
agent: config.agents.executor,
|
|
520
|
+
reason: "no-plan-verdict",
|
|
521
|
+
plan,
|
|
522
|
+
})
|
|
523
|
+
throw new BlockError(
|
|
524
|
+
`BLOCKED by turnstile: no plan approval for "${plan}". Dispatch the ${config.agents.planReviewer} subagent first; proceed with ${config.agents.executor} only after the ${config.agents.planReviewer}'s first line is ` +
|
|
525
|
+
"`VERDICT: APPROVE crit=<n> high=<n> med=<n> low=<n>`, or obtain an explicit user waiver " +
|
|
526
|
+
`(a user prompt this task must contain the literal marker '${config.waiverMarker}' followed by the user's words).`,
|
|
527
|
+
)
|
|
291
528
|
}
|
|
292
529
|
}
|
|
293
530
|
|
|
294
|
-
return {
|
|
531
|
+
return {
|
|
532
|
+
guardReviewerDispatch,
|
|
533
|
+
guardReadLimit,
|
|
534
|
+
enforceDecomposition,
|
|
535
|
+
enforcePlanApproval,
|
|
536
|
+
plansSignature,
|
|
537
|
+
planSummary,
|
|
538
|
+
planGateStatus,
|
|
539
|
+
}
|
|
295
540
|
}
|
package/src/journal.ts
CHANGED
|
@@ -113,8 +113,11 @@ export interface Journaler {
|
|
|
113
113
|
* @param agent The acting agent label (e.g. `orchestrator`, `reviewer`).
|
|
114
114
|
* @param event The event kind (e.g. `dispatch`, `gate`, `verdict`).
|
|
115
115
|
* @param detail Free-form event detail, truncated to 200 characters.
|
|
116
|
+
* @param sessionID Optional session ID; when provided it is appended as a
|
|
117
|
+
* trailing ` | <id>` segment after `detail`. Omitting it leaves the
|
|
118
|
+
* four documented segments (`ts | agent | event | detail`) byte-identical.
|
|
116
119
|
*/
|
|
117
|
-
traceEvent(agent: string, event: string, detail: string): void
|
|
120
|
+
traceEvent(agent: string, event: string, detail: string, sessionID?: string): void
|
|
118
121
|
}
|
|
119
122
|
|
|
120
123
|
/**
|
|
@@ -141,16 +144,20 @@ export function createJournaler(options: {
|
|
|
141
144
|
rotateIfLarge(journalPath, options.maxBytes, options.maxGenerations)
|
|
142
145
|
appendLine(journalPath, JSON.stringify({ ts: now(), ...entry }))
|
|
143
146
|
},
|
|
144
|
-
traceEvent(agent: string, event: string, detail: string): void {
|
|
147
|
+
traceEvent(agent: string, event: string, detail: string, sessionID?: string): void {
|
|
145
148
|
if (!options.trace) return
|
|
146
149
|
// The trace file shares the journal's rotation bound (AGENTS.md
|
|
147
150
|
// retention contract: bounded at (N + 1) × maxBytes per file).
|
|
148
151
|
rotateIfLarge(tracePath, options.maxBytes, options.maxGenerations)
|
|
149
152
|
const d = new Date()
|
|
150
153
|
const pad = (n: number): string => String(n).padStart(2, "0")
|
|
154
|
+
// The session segment is optional and appended last; the first four
|
|
155
|
+
// segments (ts | agent | event | detail) stay byte-identical for every
|
|
156
|
+
// existing `split("|")` consumer when it is omitted.
|
|
157
|
+
const tail = sessionID ? ` | ${sessionID}` : ""
|
|
151
158
|
appendLine(
|
|
152
159
|
tracePath,
|
|
153
|
-
`${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())} | ${agent} | ${event} | ${String(detail).slice(0, 200)}`,
|
|
160
|
+
`${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())} | ${agent} | ${event} | ${String(detail).slice(0, 200)}${tail}`,
|
|
154
161
|
)
|
|
155
162
|
},
|
|
156
163
|
}
|