opencode-turnstile 0.2.1 → 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/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
- * 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.
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, prompt: string, ledger: GateLedger): void
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 plan files change, forcing re-review
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
- sessionID: string,
79
- prompt: string,
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
- ): void
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
- * 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.
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, prompt: string, ledger: GateLedger): void {
149
+ function guardReviewerDispatch(sessionID: string, ledger: GateLedger): void {
108
150
  const s = ledger.state(sessionID)
109
- const at = prompt.indexOf(config.waiverMarker)
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
- waiver: config.trace ? prompt.slice(at, at + 200) : "<present>",
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", prompt.slice(at, at + 120))
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
- "Run the test-runner subagent first; dispatch reviewer only after its first line is " +
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
- "(the dispatch prompt must contain the literal marker 'USER WAIVER:' followed by the user's words).",
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
- s.reviewRounds++
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 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.
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
- * Scans the plans directory, returning the scan and whether it differs
193
- * from the previously cached scan (used to invalidate plan approval).
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 The scan plus a `changed` flag (false on the host's first
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 scanPlans(dir: string): { scan: PlansScan; changed: boolean } {
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
- 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 }
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 { scan, changed }
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 { scan } = scanPlans(plansDir)
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: fail-open
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
- 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) {
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
- waiver: config.trace ? prompt.slice(at, at + 200) : "<present>",
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", prompt.slice(at, at + 120))
506
+ journaler.traceEvent("user", "plan-waiver", s.userWaiver, sessionID)
258
507
  return
259
508
  }
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
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 { guardReviewerDispatch, guardReadLimit, enforceDecomposition, enforcePlanApproval }
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
  }