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/README.md CHANGED
@@ -2,39 +2,49 @@
2
2
 
3
3
  An [OpenCode](https://opencode.ai) v2 plugin that mechanically enforces an
4
4
  agent pipeline: planner → plan-reviewer → executor → test-runner → reviewer.
5
- The workflow
6
- itself lives in a policy instruction file (e.g. `AGENTS.md`) that tells the
7
- agents what the pipeline is; turnstile complements that file rather than
8
- replacing it. The policy steers the agents, and turnstile blocks the
9
- dispatches that violate it.
5
+ The pipeline policy is injected at runtime by the plugin: when the plugin is
6
+ loaded, it prepends the orchestrator policy to the primary session's first
7
+ task result every task (see [Policy injection](#policy-injection)), so a fresh
8
+ install can never lose it and nothing is written to any instructions file at
9
+ install time. Per-agent instruction lines live in each `agents/<name>.md`. The
10
+ policy steers the agents, and turnstile blocks the dispatches that violate it.
10
11
 
11
12
  The entry point is `turnstile.ts`; behavior lives in focused modules under
12
- `src/` (one boundary per file: types, journal, state, markers, gates,
13
- setup). The plugin has zero runtime dependencies and fails open.
13
+ `src/` (one boundary per file: types, config, policy, journal, state,
14
+ markers, evidence, gates, tool-policy, setup). The plugin has zero runtime
15
+ dependencies and fails open.
14
16
 
15
17
  Verify with:
16
18
 
17
19
  ```sh
18
20
  npm install
19
- npm run ci # ci:host (lint, format:check, typecheck, check:markers, test) + install:check
21
+ npm run ci # ci:host (lint, format:check, typecheck, check:markers, test with a 100% coverage gate) — host chain only; green on a fresh clone
22
+ npm run ci:installed # install:check — run after `npm run init` on a machine with installed artifacts
20
23
  ```
21
24
 
22
25
  ## What it enforces
23
26
 
24
- | Gate | Behavior |
25
- | ---------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
26
- | Plan-approval gate | `executor` subagent dispatches are blocked until a plan-reviewer result in the session recorded `VERDICT: APPROVE` (when any plan exists in `.opencode/plans/*.md`), or the dispatch prompt contains the literal marker `USER WAIVER:`; plan-file changes invalidate the approval |
27
- | Reviewer dispatch gate | `reviewer` subagent dispatches are blocked until a test-runner result in the session recorded `GATE: PASS`, or the dispatch prompt contains the literal marker `USER WAIVER:` |
28
- | Reviewer round cap | Max 2 `reviewer` dispatches per task without a `GATE: PASS` |
29
- | Decomposition gate | `executor` dispatches must cite `chunk-N` / `AC-n` when any plan in `.opencode/plans/*.md` has a `## Decomposition` block |
30
- | Read horizon | Warns at 8 whole-file `read` calls per session, blocks at 15 (prefer grep/glob) |
31
- | Marker parsing | `GATE:` / `VERDICT:` markers are parsed from task results and child-session text, deduped per task; marker-less outputs from marker-contracted agents (test-runner/reviewer/plan-reviewer) are journaled |
32
- | Trace | JSONL journal of dispatches, gate transitions, verdicts, blocked calls, bash commands, errors |
33
- | Decoding clamps | executor/test-runner/debugger temperature ≤ 0.2; reviewer and plan-reviewer temperature = 0 |
34
- | Compaction | Injects a "preserve verbatim" instruction for markers and findings |
35
-
36
- A new user prompt resets the session's gate state. Gate state survives
37
- restarts via journal replay (last 2000 lines of `GATE: PASS` entries).
27
+ | Gate | Behavior |
28
+ | ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
29
+ | Plan-approval gate | `executor` subagent dispatches are blocked until this task's plan-reviewer recorded `VERDICT: APPROVE` (when any plan exists in `.opencode/plans/*.md`), or the task has a human approval — a prompt-channel `PLAN APPROVED:` in the user's prompt, a prompt-channel `USER WAIVER:` waiver, or a permission-prompt reply (`always`/`once`/`reject`); plan-content changes invalidate approvals issued against the previous plans-dir signature (replayed approvals are accepted once, then stamped). See [Waivers and approvals](#waivers-and-approvals) |
30
+ | Reviewer dispatch gate | `reviewer` subagent dispatches are blocked until a test-runner result in the session recorded a `GATE: PASS` _verified_ by the current task's bash/shell executions — a matching exit-0 test command under the default `gateEvidence:enforce`; `flag` flips on trust (still stamped), `off` never stamps — or the user's prompt carries a prompt-channel `USER WAIVER:` waiver (a `USER WAIVER:` in an agent-composed dispatch prompt unlocks nothing; see [Waivers and approvals](#waivers-and-approvals)) |
31
+ | Reviewer round cap | Max 2 `reviewer` dispatches per task, regardless of gate state; a prompt-channel `USER WAIVER:` waiver bypasses the cap and does not count toward it (a `USER WAIVER:` in a dispatch prompt does not; see [Waivers and approvals](#waivers-and-approvals)) |
32
+ | Decomposition gate | `executor` dispatches must cite `chunk-N` / `AC-n` when any plan in `.opencode/plans/*.md` has a `## Decomposition` block |
33
+ | Read horizon | Warns at 8 whole-file `read` calls per session, blocks at 15 (prefer grep/glob) |
34
+ | Tool-call policy gate | `bash`/`shell` commands and `write`/`edit` target paths are matched in `execute.before` against a configurable denylist and blocked with `BLOCKED by turnstile:` under `toolPolicy.mode: enforce`; `shadow` journals the would-block without throwing (adoption path), `off` is a silent no-op. Default `bashDeny` blocks the git-protective operations in [`src/policy.ts`](src/policy.ts) (worktree, branch `switch`/`checkout`, `merge`, `gh pr create or merge`); `pathDeny` defaults empty so ordinary editing is never blocked until configured. `session_move` is journaled as telemetry only, never blocked |
35
+ | Commit-lint | `bash`/`shell` `git commit -m '<subject>'` messages are validated as conventional commits when `gitGates.commitMessages` is on (default): a non-conventional subject throws `BLOCKED by turnstile:` (journaled `reason:"invalid-commit-message"`) under `gitGates.mode: enforce`; `shadow` journals only, `off` is a no-op. An uninspectable form (`-F`/`-t`/`--edit`/interactive; `--amend -m` is inspectable) is blocked under `enforce` (`reason:"commit-message-uninspectable"`) — pass the subject with `git commit -m <subject>` |
36
+ | Landing gate | `git push` without a reviewer `VERDICT: APPROVE` for the current task is `BLOCKED by turnstile:` (journaled `reason:"no-landing-approval"`) under `gitGates.mode: enforce`; `shadow` journals only, `off` is a no-op. Approval comes only from a reviewer delivery `VERDICT: APPROVE` (streamed text is inert); `gh pr create or merge` stays denied by the default `bashDeny` list, and every `git push` is journaled as landing telemetry; a repeat push under `enforce` escalates to a human ask — the repeat push is the decision point |
37
+ | Marker parsing | `GATE:` / `VERDICT:` markers are parsed from task results and child-session text, deduped per task; marker-less outputs from marker-contracted agents (test-runner/reviewer/plan-reviewer) are journaled |
38
+ | Trace | JSONL journal of dispatches, gate transitions, verdicts, blocked calls, bash commands, errors; `setup` heartbeat per session (plugin version, config digest, hook count), stream-death errors, unresolvable-agent dispatch errors |
39
+ | Decoding clamps | executor/test-runner/debugger temperature ≤ 0.2; reviewer and plan-reviewer temperature = 0 |
40
+ | Compaction | Injects a "preserve verbatim" instruction for markers and findings |
41
+
42
+ A new user prompt resets the session's gate state. Replayed gate state
43
+ survives a restart and the next user prompt (the resume prompt), then
44
+ reverts: every later prompt starts a clean gate cycle. Replay reads the
45
+ last 2000 lines of the journal, spanning the current file and its previous
46
+ rotated generation (`GATE: PASS` and plan `VERDICT: APPROVE`
47
+ entries).
38
48
 
39
49
  ## How it fits the ecosystem
40
50
 
@@ -75,7 +85,8 @@ are visible in this repo's tests:
75
85
  - Verifiable state. `GATE:` and `VERDICT:` markers are parsed from
76
86
  child session text (`src/markers.ts`), stored per parent session
77
87
  (`src/state.ts`), and replayed from the JSONL journal on restart, so
78
- gate state survives sessions and reloads rather than living in a
88
+ replayed gate state survives a restart and the next user prompt, then
89
+ every later prompt starts a clean gate cycle rather than living in a
79
90
  prompt.
80
91
  - Failure posture. Hooks fail open: an internal error is journaled and
81
92
  the call proceeds. The only refusals are deliberate
@@ -101,17 +112,20 @@ ways to use it; pick one, and do not combine the plugin-array path with
101
112
  the CLI install.
102
113
 
103
114
  1. **Zero-config enforcement (plugin only).** Add the package to your
104
- OpenCode config's plugin array and restart. turnstile loads and
105
- enforces its gates, but neither the pipeline agent definitions nor the
106
- AGENTS.md contract block are installed:
115
+ OpenCode config's plugin array and restart. turnstile loads and enforces
116
+ its gates, injecting the orchestrator policy into the primary session at
117
+ runtime (see [Policy injection](#policy-injection)) — nothing is written to
118
+ any instructions file, and the pipeline agent definitions are not installed
119
+ by this option alone:
107
120
 
108
121
  ```json
109
122
  { "plugins": ["opencode-turnstile"] }
110
123
  ```
111
124
 
112
- 2. **Full install via the CLI.** `npx opencode-turnstile init` installs
113
- the plugin, the pipeline agent definitions, and the managed contract
114
- block in `~/.config/opencode/AGENTS.md` (see
125
+ 2. **Full install via the CLI.** `npx opencode-turnstile init` installs the
126
+ plugin and the pipeline agent definitions; the orchestrator policy is
127
+ injected at runtime by the plugin, so no contract block is written to
128
+ `~/.config/opencode/AGENTS.md` (see
115
129
  [From a clone](#from-a-clone) below for the same commands run from
116
130
  a clone).
117
131
 
@@ -124,9 +138,9 @@ the CLI install.
124
138
  Turnstile has **no** npm lifecycle hook (`postinstall`, etc.). Installing
125
139
  `opencode-turnstile` as a dependency never writes to
126
140
  `~/.config/opencode`; it only makes the gates available through the
127
- plugin array. Everything else (agent definitions, the AGENTS.md contract
128
- block) happens only when you run an explicit command. This is deliberate:
129
- writes to your config directory are opt-in, not automatic.
141
+ plugin array. Everything else (the agent definitions) happens only when you
142
+ run an explicit command. This is deliberate: writes to your config directory
143
+ are opt-in, not automatic.
130
144
 
131
145
  ### Modes
132
146
 
@@ -156,16 +170,21 @@ The plugin updates with your dependency manager (`npm upgrade
156
170
  opencode-turnstile`, or your registry's equivalent). Re-run `check` to see
157
171
  whether anything is outdated or drifted, then `init` to re-sync only what
158
172
  changed: `npx opencode-turnstile init --copy` for package installs,
159
- `npm run init -- --copy` from a clone. The AGENTS.md contract block is
160
- re-synced the same way, keeping only the managed block in step.
173
+ `npm run init -- --copy` from a clone. The injected orchestrator policy
174
+ travels with the plugin, so re-syncing the plugin re-supplies it — there is
175
+ no separate contract block to re-sync.
161
176
 
162
177
  ### Uninstall
163
178
 
164
179
  `npx opencode-turnstile uninstall` reverses the install: it removes
165
- ledger-owned copies and repo-owned symlinks and unmerges the managed
166
- contract block from AGENTS.md. It is idempotent and ownership-aware:
180
+ ledger-owned copies and repo-owned symlinks and stops injecting the
181
+ orchestrator policy. It is idempotent and ownership-aware:
167
182
  files it never installed (untracked files, symlinks pointing outside the
168
- repo) are left alone, and a second run is a clean no-op.
183
+ repo) are left alone, and a second run is a clean no-op. Injected policy is
184
+ gone the moment the plugin is unloaded; a legacy contract block once written
185
+ by hand into `~/.config/opencode/AGENTS.md` is not owned by turnstile and is
186
+ removed separately — see
187
+ [Migrating a legacy AGENTS.md block](#migrating-a-legacy-agentsmd-block).
169
188
 
170
189
  ### From a clone
171
190
 
@@ -295,9 +314,10 @@ Paste the output into `~/.config/opencode/opencode.json` (see the
295
314
  [Set the agents' models](#set-the-agents-models) section).
296
315
  `opencode-turnstile init -- --print-model-config` routes the same flags
297
316
  through `scripts/install.mjs` and adds nothing of its own: `install.mjs`
298
- recognizes `--print-model-config` and prints before writing, while the router
299
- still idempotently installs the contract block in
300
- `~/.config/opencode/AGENTS.md` in the same run.
317
+ recognizes `--print-model-config` and prints before writing, and the router
318
+ skips the agents install step entirely in that case — so the whole command
319
+ writes nothing (no ledger, no links, no agents, no injected policy). Plain
320
+ `init` still installs everything as usual.
301
321
 
302
322
  ### Customizing the pipeline agents
303
323
 
@@ -353,69 +373,115 @@ Shared caveats:
353
373
  - Symlinks point at the clone's absolute path. Do not move, rename, or
354
374
  delete the checkout; if you relocate it, re-run `npm run init`.
355
375
  - `npm run install:check` reports drift between the manifest and what is
356
- actually installed; local `npm run ci` gates on it. GitHub CI runs
357
- `npm run ci:host`, the same chain without the install check, because a
358
- runner has no installs to check. Verify a copy install with
376
+ actually installed. There is no `install:agents:check`: the orchestrator
377
+ policy is injected from code (`src/policy.ts`), not a manifest-managed
378
+ template, so nothing to verify there — `npm run ci:installed` is now just
379
+ `install:check`. These install checks must run after `npm run init` on a
380
+ machine with artifacts installed — a fresh clone has nothing to check. The
381
+ host chain (`npm run ci` = `npm run ci:host`) covers lint, format:check,
382
+ typecheck, check:markers, and test without the install checks. GitHub CI
383
+ runs `npm run ci:host`, the host chain only, because a runner has no
384
+ installs to check. Verify a copy install with
359
385
  `npm run install:check -- --copy`.
360
386
 
361
- ### Install the AGENTS.md contracts
362
-
363
- Turnstile enforces what your instructions file declares. Install the
364
- matching contract block into your global AGENTS.md:
387
+ ### Policy injection
388
+
389
+ Turnstile does not read orchestrator policy from a file at install time —
390
+ it makes that policy available by injecting it at runtime. When the plugin is
391
+ loaded, `src/setup.ts` prepends the orchestrator policy paragraph from
392
+ `src/policy.ts` to the primary session's first `task` (or `subagent`) result
393
+ delivery, once per task, via the `tool.hook("execute.after")` handler — the
394
+ same hook the plugin already uses to rewrite tool output reaching the model.
395
+ The source of truth is code shipped with the plugin, so a fresh install can
396
+ never lose it, and it follows the plugin through updates and
397
+ uninstall–reinstall cycles.
398
+
399
+ - **Fail-open.** The injection is fully guarded with an inner try/catch; on
400
+ failure it journals a `type:"policy"` entry with `injected:false` and
401
+ proceeds — a broken injection never blocks a tool call.
402
+ - **Per-task idempotency.** A `GateState.policyInjected` flag (cleared on
403
+ each new prompt, so the block re-injects per task) means the policy is
404
+ prepended exactly once per task, not once per turn.
405
+ - **Marker placeholders.** The injected paragraph quotes the gate/verdict
406
+ markers with `<n>` placeholders (e.g. `GATE: PASS tests=<n> passed=<n>
407
+ failed=<n>`, `VERDICT: APPROVE crit=<n> high=<n> med=<n> low=<n>`) rather
408
+ than concrete digits, and never the pipe-joined `PASS|FAIL` form. The
409
+ plugin parses markers from _any_ session text, so a fully concrete example
410
+ echoed back by the model could flip a gate; the placeholder form stays
411
+ unparseable while remaining human-readable.
412
+ - **Per-agent instructions** are not injected by the plugin: they live in
413
+ each `agents/<name>.md` installed alongside the plugin.
414
+
415
+ With the plugin **off**, none of this prose is present anywhere — there is no
416
+ AGENTS.md block to fall back on. That is the deliberate tradeoff: loss-proof
417
+ by construction while the plugin loads, and a known, documented gap when it
418
+ does not.
419
+
420
+ ### Migrating a legacy AGENTS.md block
421
+
422
+ If a previous turnstile install once wrote a managed contract block into
423
+ `~/.config/opencode/AGENTS.md`, that block is now orphaned: the plugin no
424
+ longer reads or manages it, and it will coexist with the injected policy
425
+ (duplicate prose until removed). There is deliberately no installer to remove
426
+ it — turnstile no longer owns that file, so it never rewrites it. Delete the
427
+ sentinel-delimited block by hand, once:
365
428
 
366
429
  ```sh
367
- node scripts/install-agents.mjs # or: npm run install:agents
430
+ # ~/.config/opencode/AGENTS.md — delete the section between the
431
+ # `<!-- turnstile:start -->` and `<!-- turnstile:end -->` sentinels, then save
368
432
  ```
369
433
 
370
- - Default target: `~/.config/opencode/AGENTS.md` (override with `--file`).
371
- - The block is delimited by `<!-- turnstile:start -->` …
372
- `<!-- turnstile:end -->` sentinels; re-running updates only that block
373
- and preserves all surrounding personal content.
374
- - Legacy targets are adopted automatically. If the file already contains
375
- pipeline-gates content _without_ sentinels (e.g. an install from before
376
- the block was managed), the installer classifies the existing section:
377
- equivalent to the template, it wraps the section in sentinels without
378
- changing its content; differing from the template, it backs the file up
379
- to `<name>.bak.<timestamp>` and replaces the section with the current
380
- block. Pass `--no-adopt` to refuse both and print reconciliation
381
- instructions instead.
382
- - The installer refuses (exit 1, no write) only when a target is
383
- ambiguous (more than one turnstile section) or corrupted (a start
384
- sentinel without an end sentinel); reconcile those by hand.
385
- - `--check` reports the outcome without writing: OK, or the adoption
386
- decision (would-adopt-equivalent / would-replace / drift), exiting 0
387
- only for OK. Useful in CI or dotfiles setup.
388
- - Agent definitions and the plugin itself are installed separately via
389
- `npm run init` (symlinks, for development) or
390
- `npm run init -- --copy` (see the Install section above).
391
-
392
- Alternatively, in an OpenCode session, invoke the bundled
393
- `turnstile-setup` skill, which runs the installer and verifies marker
394
- sync. The skill ships in the package and installs to
395
- `~/.config/opencode/skills/turnstile-setup/SKILL.md`; the template
396
- source lives at `templates/global-AGENTS.md`.
434
+ After that, the injected policy is the single source of orchestrator policy
435
+ for the session.
397
436
 
398
437
  ## Configuration
399
438
 
400
439
  Defaults live in code (`src/config.ts`); override via
401
440
  `~/.config/opencode/turnstile.json`, environment variables, or both.
441
+
442
+ **Quick start: copy the example config** — [`examples/turnstile.example.json`](examples/turnstile.example.json)
443
+ to `~/.config/opencode/turnstile.json` and edit from there. Every key in it
444
+ is optional and matches the code defaults; delete any key to fall back to the
445
+ default. One key is deliberately omitted from the example:
446
+
447
+ - `metricsDir`: computed at load time (`$XDG_DATA_HOME/opencode/metrics`,
448
+ fallback `~/.local/share/opencode/metrics`); if you set it yourself it must
449
+ be an absolute path (`~` is not expanded)
450
+
451
+ The example also carries `gateMarker` / `verdictMarker` verbatim so you can
452
+ see the format; leave them alone unless you are intentionally changing
453
+ the marker syntax (the shipped subagent definitions emit the canonical
454
+ markers).
455
+
402
456
  Precedence: defaults < config file < env. The plugin reads config once, at
403
457
  setup; restart sessions to apply. Invalid keys are ignored and journaled
404
458
  (fail-open); a malformed config file never breaks the host.
405
459
 
406
- | Key | Default | Meaning |
407
- | ------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------- | ----------------------------------------------------------------- |
408
- | `metricsDir` | `$XDG_DATA_HOME/opencode/metrics`, fallback `~/.local/share/opencode/metrics` | directory for both telemetry files |
409
- | `journalMaxBytes` | 5242880 | rotate a journal file past this size |
410
- | `journalMaxGenerations` | 5 | rotated generations kept per journal |
411
- | `maxReviewerRounds` | 2 | reviewer dispatches per task without `GATE: PASS` |
412
- | `readWarn` / `readBlock` | 8 / 15 | whole-file read horizon |
413
- | `waiverMarker` | `USER WAIVER:` | literal waiver marker |
414
- | `plansDir` | `.opencode/plans` | executor decomposition scan directory |
415
- | `trace` | `true` | write the human-readable trace file (JSONL journal always writes) |
416
- | `agents.reviewer` / `agents.planReviewer` / `agents.executor` / `agents.testRunner` / `agents.debugger` | `reviewer` / `plan-reviewer` / `executor` / `test-runner` / `debugger` | agent names recognized in dispatches |
417
- | `agents.bareOutput` | `["test-runner", "reviewer", "plan-reviewer"]` | agents whose marker-less output is a violation |
418
- | `gateMarker` / `verdictMarker` | canonical regex sources | marker patterns, compiled with `gm` |
460
+ | Key | Default | Meaning |
461
+ | ------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------- |
462
+ | `metricsDir` | `$XDG_DATA_HOME/opencode/metrics`, fallback `~/.local/share/opencode/metrics` | directory for both telemetry files |
463
+ | `journalMaxBytes` | 5242880 | rotate a journal file past this size |
464
+ | `journalMaxGenerations` | 5 | rotated generations kept per journal |
465
+ | `maxReviewerRounds` | 2 | reviewer dispatches per task, regardless of gate state (waived dispatches exempt) |
466
+ | `maxSessions` | 64 | LRU cap on tracked sessions (gate state, child→parent edges, verdicts); registered live parents are never evicted |
467
+ | `readWarn` / `readBlock` | 8 / 15 | whole-file read horizon |
468
+ | `waiverMarker` | `USER WAIVER:` | literal waiver marker |
469
+ | `approvalMarker` | `PLAN APPROVED:` | literal prompt-channel plan-approval marker |
470
+ | `plansDir` | `.opencode/plans` | executor decomposition scan directory |
471
+ | `trace` | `true` | write the human-readable trace file (JSONL journal always writes) |
472
+ | `agents.reviewer` / `agents.planReviewer` / `agents.executor` / `agents.testRunner` / `agents.debugger` | `reviewer` / `plan-reviewer` / `executor` / `test-runner` / `debugger` | agent names recognized in dispatches |
473
+ | `agents.bareOutput` | `["test-runner", "reviewer", "plan-reviewer"]` | agents whose marker-less output is a violation |
474
+ | `gateMarker` / `verdictMarker` | canonical regex sources | marker patterns, compiled with `gm` |
475
+ | `gateEvidence` | `enforce` | `enforce` requires a correlating exit-0 test command to flip the gate; `flag` flips on trust (stamps `verified`); `off` journals without it |
476
+ | `evidencePattern` | a npm/pnpm/yarn/bun/deno/node `--test`/pytest/`make test`/go/cargo pattern | regex matched (case-insensitively) against bash/shell commands; user-overridable for non-JS toolchains (e.g. Ruby/`rspec`) |
477
+ | `toolPolicy.mode` | `enforce` | `enforce` blocks matching commands/paths; `shadow` journals would-blocks without throwing (adoption path); `off` is a silent no-op |
478
+ | `toolPolicy.bashDeny` | the git-protective defaults (worktree, branch switch/checkout, merge, `gh pr`) | case-insensitive regex sources matched against `bash`/`shell` commands; an invalid source is dropped and journaled |
479
+ | `toolPolicy.pathDeny` | `[]` (empty) | case-insensitive regex sources matched against `write`/`edit` target paths; a dropped source is journaled |
480
+ | `toolPolicy.observeSessionMove` | `true` | journal `session_move` dispatches as telemetry (never blocks) |
481
+ | `gitGates.mode` | `shadow` | `shadow` would-block without throwing; `enforce` blocks invalid commits and unapproved pushes (opt-in), `off` is a silent no-op |
482
+ | `gitGates.commitMessages` | `true` | validate `git commit -m` subjects as conventional commits; `shadow` journals, `enforce` blocks, `off` is a no-op |
483
+ | `gitGates.commitFormat` | a conventional-commits regex source | conventional-commits regex source (compiled `^...$`) matched against each subject; a bad source is dropped and journaled |
484
+ | `gitGates.landingGate` | `true` | gate `git push` on this task reviewer `VERDICT: APPROVE`; `shadow` journals, `enforce` blocks then escalates, `off` is a no-op |
419
485
 
420
486
  Environment variables (override the file):
421
487
 
@@ -426,7 +492,7 @@ Environment variables (override the file):
426
492
  ## Markers
427
493
 
428
494
  Subagents must emit these as the first line of their output. These examples
429
- are the canonical syntax; CI asserts the parser in `turnstile.ts` accepts
495
+ are the canonical syntax; CI asserts the parser in `src/markers.ts` accepts
430
496
  exactly them.
431
497
 
432
498
  ```
@@ -437,44 +503,142 @@ VERDICT: APPROVE crit=0 high=0 med=2 low=1
437
503
  VERDICT: BLOCK crit=1 high=0 med=0 low=2
438
504
  ```
439
505
 
440
- Waiver marker (must appear in the reviewer dispatch prompt, verbatim):
506
+ Waiver and approval markers (must appear in the user's prompt, verbatim — a dispatch prompt that merely contains the marker unlocks nothing; see [Waivers and approvals](#waivers-and-approvals)):
441
507
 
442
508
  ```
443
509
  USER WAIVER: <the user's words>
510
+ PLAN APPROVED: <the user's words>
444
511
  ```
445
512
 
513
+ ## Waivers and approvals
514
+
515
+ Turnstile reads a waiver or a human plan approval only from the user's OWN
516
+ prompt — the plugin observes it at prompt admission, never from an agent's
517
+ dispatch prompt. This is the core of issue #34: a marker inside an
518
+ agent-composed prompt must never unlock a gate.
519
+
520
+ - **Prompt-channel waiver.** Put the literal marker `USER WAIVER:` in the text
521
+ of your prompt to waive the plan gate and the reviewer gate for the task.
522
+ The waiver is provenance-checked: it must come from the user prompt, and a
523
+ dispatch prompt that merely contains `USER WAIVER:` unlocks nothing.
524
+ - **Prompt-channel approval.** Put `PLAN APPROVED:` in your prompt to approve
525
+ the plan the same way a plan-reviewer `VERDICT: APPROVE` would; it sets the
526
+ plan verdict and records human provenance, and replays across restart
527
+ exactly like a reviewer verdict.
528
+ - **Permission-prompt escalation.** When no approval holds, the first
529
+ executor dispatch escalates the plan gate to the human through OpenCode's
530
+ permission surface: `permission.evaluate` forces an `ask` that names the
531
+ plan file and its head. The human replies — `always` approves for the rest
532
+ of the task, `once` grants a single dispatch, and `reject` keeps the gate
533
+ red (that single reply is consumed).
534
+
535
+ Do not create, paste, or relay a `USER WAIVER:` / `PLAN APPROVED:` marker to
536
+ open a gate — only the user's own prompt carries a real waiver or approval.
537
+
446
538
  ## What turnstile is not
447
539
 
448
540
  Turnstile is a speed bump and an audit trail, not a security boundary.
449
541
 
450
- - Any text matching the gate marker, including a marker an agent
451
- hallucinated or copied from documentation, flips `hasGatePass`. An agent
452
- that wants a green gate can emit `GATE: PASS tests=1 passed=1 failed=0`
453
- without running anything.
454
- - `USER WAIVER:` as a substring anywhere in a dispatch prompt unlocks the
455
- reviewer gate. A prompt-injected waiver is indistinguishable from a real
456
- one.
542
+ - Any text matching the gate marker — including a marker an agent
543
+ hallucinated or copied from documentation — is journaled `verified:false`
544
+ under the default `gateEvidence:enforce` mode and does not flip
545
+ `hasGatePass` unless the current task's bash/shell executions contain a
546
+ matching exit-0 test command (plausibility checked against
547
+ `evidencePattern`). An agent that wants a green gate can no longer emit
548
+ `GATE: PASS tests=1 passed=1 failed=0` without running anything — but
549
+ `gateEvidence:flag` reverts to the historical flip-on-trust semantics
550
+ (still stamped `verified:false`), and `gateEvidence:off` journals without
551
+ the field at all.
552
+ - A `GATE: PASS` is only as trustworthy as the journal: an attacker who can
553
+ write or replay the metrics directory can craft a `verified:true` entry,
554
+ and `flag`/`off` make a fabricated marker trust-lossless. Tests that run
555
+ outside the observable bash/shell surface (external CI, a differently
556
+ named execution tool) leave enforce mode with no evidence to verify
557
+ against — operators in that situation use `flag`/`off` at the cost of the
558
+ guarantee. The journal records what was _claimed_, never proof.
559
+ - The dispatch-prompt waiver-forgery vector is closed: a `USER WAIVER:` (or
560
+ `PLAN APPROVED:`) substring inside an agent-composed dispatch prompt
561
+ unlocks nothing — turnstile reads waivers and approvals only from the
562
+ user's prompt at admission. The residual trust stance is unchanged: the
563
+ journal is still the record of what was _claimed_ (an attacker who can
564
+ write or replay the metrics directory can still craft entries), so the
565
+ journal is treated as sensitive data (see SECURITY.md).
457
566
  - `scripts/check-markers.mjs` guards against accidental drift between the
458
567
  parser and documented markers; it cannot guard against forgery, because
459
568
  the markers are plain prose by design.
569
+ - The `bash`/`shell`/`write`/`edit` denylist is a deterrent, not a sandbox: it
570
+ matches strings, so `bash -c "$X"`, script files, `eval` indirection, and
571
+ values sourced from the environment still bypass it. A block names the matched
572
+ _rule_ (never the raw command, per the redaction contract); a dropped or
573
+ invalid regex _source_ means the operation it guarded is now **allowed**
574
+ (documented so a config typo weakens the policy visibly in the journal rather
575
+ than silently). The git rules are global-option tolerant, so `git -C dir
576
+ checkout main` is caught as well as `git checkout main`, and the checkout guard
577
+ allows the file-restore form `git [global options] checkout [<ref>] -- <path>`
578
+ while blocking `git [global options] checkout <branch>` and `-b`/`-C`; but two
579
+ gaps remain by design. `git` **aliases** (e.g. `git co main`, `git cm -m x`)
580
+ are not matched, because a rule names `git` plus a literal subcommand only; and
581
+ the wider base can _over-match_ — e.g. `git commit -m "… merge …"` trips the
582
+ `merge` rule. `session_move` never blocks.
583
+ - MCP tools and other tool names not named in `execute.before` (`task`,
584
+ `subagent`, `read`, `bash`, `shell`, `write`, `edit`, `session_move`) remain
585
+ unguarded. Governing them is a follow-on, not part of this policy layer.
460
586
 
461
587
  Use OpenCode's permission and sandboxing mechanisms against hostile agents.
462
588
  Treat turnstile's journal as the record of what was _claimed_, and treat the
463
589
  journal itself as sensitive data (see SECURITY.md).
464
590
 
591
+ ### Adopting the tool-call policy (shadow first)
592
+
593
+ The default `toolPolicy.mode: enforce` blocks the git-protective operations the
594
+ policy already demands, so it is safe to adopt immediately. To tune it with zero
595
+ risk of breaking work, boot one session in `toolPolicy.mode: shadow` first:
596
+ would-block commands and paths are journaled with `"reason":"tool-policy"` and
597
+ `mode:"shadow"` but never throw. Grep the journal for that reason, review the
598
+ rules that fired, adjust `toolPolicy.bashDeny`/`pathDeny` (each rule is
599
+ case-insensitive), then flip to `enforce`. Restart sessions to apply config —
600
+ it is read once at setup.
601
+ The same shadow-first adoption applies to `gitGates` (default `mode: shadow`):
602
+ under `shadow` an invalid commit message or an unapproved `git push` is journaled
603
+ (`"reason":"invalid-commit-message"` / `"reason":"no-landing-approval"`) without
604
+ throwing, so boot one session in `shadow`, review the journal, then flip to
605
+ `enforce` when you are ready to start blocking.
606
+
465
607
  ## Files and journals
466
608
 
467
609
  - Journals live outside the repo in `~/.local/share/opencode/metrics/`:
468
610
  - `turnstile.jsonl`: full JSONL trace; rotates at 5 MB, keeps 5
469
611
  generations
470
612
  - `gate-events.jsonl`: human-readable
471
- `YYYY-MM-DD HH:MM | agent | event | detail` lines (suppressed entirely
472
- by `trace: false`)
613
+ `YYYY-MM-DD HH:MM | agent | event | detail` lines, with an optional
614
+ trailing ` | <sessionID>` segment when the trace line is wired to a
615
+ session (the first four segments are unchanged when it is absent);
616
+ suppressed entirely by `trace: false`
473
617
  - Retention is bounded: 2 files × (1 current + 5 generations) × 5 MB ≈
474
618
  ~30 MB per journal, worst case ~60 MB total.
475
619
  - `.opencode/plans/*.md` containing `## Decomposition` are load-bearing:
476
620
  their presence activates the executor chunk-citation gate.
477
621
 
622
+ ## Status
623
+
624
+ `opencode-turnstile status` reports turnstile gate and plan state per parent
625
+ session, read from the journal, as a human-readable table by default or JSON
626
+ with `--json`:
627
+
628
+ ```
629
+ opencode-turnstile status
630
+ opencode-turnstile status --json
631
+ ```
632
+
633
+ It is read-only: it never writes to the journal or config. Per session it
634
+ replays the same gate/plan last-wins rules the runtime uses (a trailing
635
+ `GATE: FAIL` keeps the gate red; an enforce-mode `verified:false` PASS never
636
+ flips), plus last markers, waiver events (with channel), and dispatch/block/
637
+ permission counts. The journal reflects the last prompt-cycle; in-memory state
638
+ can be fresher (a session that has prompted since is newer than the journal
639
+ records), so treat status as the durable state of the previous cycle rather
640
+ than a live snapshot.
641
+
478
642
  ## Behavior contract
479
643
 
480
644
  - Fail-open by design. Every hook body is guarded; internal errors are
@@ -2,8 +2,9 @@
2
2
  description: Investigates failures, identifies root causes, implements targeted fixes, and verifies the result.
3
3
  mode: subagent
4
4
  # Runaway-loop backstop for the Horizon rule below: near the cap OpenCode
5
- # drops tools and asks for a summary. Tune to your workflow.
6
- steps: 50
5
+ # drops tools and asks for a summary. Tuned 2026-10-14: 150 capped once on the
6
+ # #34/#35 fix rounds; 200 fits a diagnose-fix-verify loop with full CI.
7
+ steps: 200
7
8
  ---
8
9
 
9
10
  You are a debugging specialist.
@@ -2,8 +2,13 @@
2
2
  description: Implements an approved plan, validates changes, and fixes test failures
3
3
  mode: subagent
4
4
  # Runaway-loop backstop for the Horizon rule below: near the cap OpenCode
5
- # drops tools and asks for a summary. Tune to your workflow.
6
- steps: 50
5
+ # drops tools and asks for a summary. Tuned 2026-10-12: 50 repeatedly cut off
6
+ # executor-sized chunks mid-implementation (issue #51 needed 3 continuations);
7
+ # 150 keeps the backstop while fitting a real chunk (tests + docs + CI + commit).
8
+ # Tuned 2026-10-14: 150 still capped mid-chunk three times on the #34/#35 work
9
+ # (coverage hunts + full CI loops are the cost driver); 200 fits a chunk
10
+ # including one coverage-debug iteration.
11
+ steps: 200
7
12
  ---
8
13
 
9
14
  You are the implementation agent.