opencode-turnstile 0.0.0-stage → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.opencode/agents/debugger.md +70 -0
- package/.opencode/agents/executor.md +108 -0
- package/.opencode/agents/plan-reviewer.md +91 -0
- package/.opencode/agents/planner.md +111 -0
- package/.opencode/agents/reviewer.md +92 -0
- package/.opencode/agents/test-runner.md +55 -0
- package/LICENSE +21 -0
- package/README.md +414 -2
- package/bin/cli.mjs +86 -0
- package/install.json +42 -0
- package/package.json +58 -4
- package/scripts/check-markers.mjs +86 -0
- package/scripts/install-agents.mjs +504 -0
- package/scripts/install.mjs +559 -0
- package/skills/turnstile-setup/SKILL.md +76 -0
- package/src/config.ts +272 -0
- package/src/gates.ts +295 -0
- package/src/journal.ts +157 -0
- package/src/markers.ts +113 -0
- package/src/setup.ts +512 -0
- package/src/state.ts +181 -0
- package/src/types.ts +176 -0
- package/templates/global-AGENTS.md +55 -0
- package/turnstile.ts +82 -0
package/README.md
CHANGED
|
@@ -1,3 +1,415 @@
|
|
|
1
|
-
#
|
|
1
|
+
# turnstile
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
An [OpenCode](https://opencode.ai) v2 plugin that mechanically enforces an
|
|
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.
|
|
10
|
+
|
|
11
|
+
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.
|
|
14
|
+
|
|
15
|
+
Verify with:
|
|
16
|
+
|
|
17
|
+
```sh
|
|
18
|
+
npm install
|
|
19
|
+
npm run ci # ci:host (lint, format:check, typecheck, check:markers, test) + install:check
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
## What it enforces
|
|
23
|
+
|
|
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).
|
|
38
|
+
|
|
39
|
+
## How it fits the ecosystem
|
|
40
|
+
|
|
41
|
+
Claude Code's agent workflows are backed by host-side governance:
|
|
42
|
+
permission prompts and tool hooks that can stop a call before it
|
|
43
|
+
happens. I built turnstile to bring that shape to OpenCode: gates the
|
|
44
|
+
runtime enforces, not requests the model honors. It borrows the
|
|
45
|
+
enforcement model without the implementation, and stays
|
|
46
|
+
OpenCode-native: zero dependencies, TypeScript, loaded from your own
|
|
47
|
+
checkout.
|
|
48
|
+
|
|
49
|
+
Most other agent-structure projects on OpenCode are orchestration
|
|
50
|
+
suites: an orchestrator plans work, dispatches specialists, and
|
|
51
|
+
reconciles results. Some add pipeline discipline on top, but the
|
|
52
|
+
discipline is carried by prompts: a phase header the model prints at
|
|
53
|
+
the start of a response, a "review gate" step inside a skill's
|
|
54
|
+
instructions, a warning when a write lands without a prior test run.
|
|
55
|
+
Those asks work when the model complies. Turnstile's gates aim a step
|
|
56
|
+
further: they are decided in tool and task-dispatch hooks, outside the
|
|
57
|
+
model loop, so a dispatch that violates a gate fails before a subagent
|
|
58
|
+
session is created.
|
|
59
|
+
|
|
60
|
+
Turnstile is a layer under orchestration suites, not a competitor: it
|
|
61
|
+
reads task dispatches to attribute markers and enforce gates; it never
|
|
62
|
+
plans work, routes models, or spawns specialists. An orchestration
|
|
63
|
+
suite that dispatches task calls with agent names will pass through
|
|
64
|
+
turnstile unchanged unless a gate applies. Other adjacent approaches
|
|
65
|
+
gate on written artifacts, like spec workflows, or on a single
|
|
66
|
+
human-approval point; turnstile gates the machine steps around those.
|
|
67
|
+
The bundled planner/executor/test-runner/reviewer agents in
|
|
68
|
+
`.opencode/agents/` are the reference pipeline, not a requirement; the
|
|
69
|
+
gates key off agent names and markers, and other suites' agents can
|
|
70
|
+
carry their own markers.
|
|
71
|
+
|
|
72
|
+
Three properties matter for comparing enforcement layers, and all three
|
|
73
|
+
are visible in this repo's tests:
|
|
74
|
+
|
|
75
|
+
- Verifiable state. `GATE:` and `VERDICT:` markers are parsed from
|
|
76
|
+
child session text (`src/markers.ts`), stored per parent session
|
|
77
|
+
(`src/state.ts`), and replayed from the JSONL journal on restart, so
|
|
78
|
+
gate state survives sessions and reloads rather than living in a
|
|
79
|
+
prompt.
|
|
80
|
+
- Failure posture. Hooks fail open: an internal error is journaled and
|
|
81
|
+
the call proceeds. The only refusals are deliberate
|
|
82
|
+
`BLOCKED by turnstile:` throws at a declared gate. A broken
|
|
83
|
+
enforcement plugin should degrade to prompt contracts, not break
|
|
84
|
+
every session.
|
|
85
|
+
- Auditability. Every dispatch, gate transition, verdict, and blocked
|
|
86
|
+
call is appended to `turnstile.jsonl` (bounded rotation, see
|
|
87
|
+
SECURITY.md). The journal is the record of what was claimed, which is
|
|
88
|
+
what makes the gates reviewable after the fact.
|
|
89
|
+
|
|
90
|
+
The tradeoff is scope: turnstile is host-coupled to the OpenCode v2
|
|
91
|
+
plugin API, verified against v2.0.22, and enforces exactly the
|
|
92
|
+
pipeline above. Multi-harness suites reach further; turnstile goes
|
|
93
|
+
deeper on one host and one pipeline.
|
|
94
|
+
|
|
95
|
+
## Install
|
|
96
|
+
|
|
97
|
+
Turnstile ships as the npm package
|
|
98
|
+
[`opencode-turnstile`](https://www.npmjs.com/package/opencode-turnstile).
|
|
99
|
+
There are three
|
|
100
|
+
ways to use it; pick one, and do not combine the plugin-array path with
|
|
101
|
+
the CLI install.
|
|
102
|
+
|
|
103
|
+
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:
|
|
107
|
+
|
|
108
|
+
```json
|
|
109
|
+
{ "plugins": ["opencode-turnstile"] }
|
|
110
|
+
```
|
|
111
|
+
|
|
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
|
|
115
|
+
[From a clone](#from-a-clone) below for the same commands run from
|
|
116
|
+
a clone).
|
|
117
|
+
|
|
118
|
+
3. **Develop from a clone.** Clone this repo and run `npm run init`
|
|
119
|
+
(symlink mode) so installed artifacts stay live against your edits.
|
|
120
|
+
See [From a clone](#from-a-clone) below.
|
|
121
|
+
|
|
122
|
+
### No postinstall
|
|
123
|
+
|
|
124
|
+
Turnstile has **no** npm lifecycle hook (`postinstall`, etc.). Installing
|
|
125
|
+
`opencode-turnstile` as a dependency never writes to
|
|
126
|
+
`~/.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.
|
|
130
|
+
|
|
131
|
+
### Modes
|
|
132
|
+
|
|
133
|
+
`init` auto-detects an install mode: a git checkout installs by
|
|
134
|
+
linking (the working tree stays the source of truth, so edits are
|
|
135
|
+
live); an `npx` package install copies files (the installed files are
|
|
136
|
+
the source of truth). Override with `--link` or `--copy`:
|
|
137
|
+
|
|
138
|
+
```sh
|
|
139
|
+
npx opencode-turnstile init --copy
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
The chosen mode is recorded in the ledger, so `check` stays consistent
|
|
143
|
+
across reruns.
|
|
144
|
+
|
|
145
|
+
### Check
|
|
146
|
+
|
|
147
|
+
`npx opencode-turnstile check` verifies the installed state against the
|
|
148
|
+
manifest and exits 0 (clean) or 1 (drift). A copy install whose recorded
|
|
149
|
+
version is older than the published package is reported as a warning
|
|
150
|
+
(`check: outdated <dest> (installed X, package Y)`) and does not fail the
|
|
151
|
+
check.
|
|
152
|
+
|
|
153
|
+
### Upgrade
|
|
154
|
+
|
|
155
|
+
The plugin updates with your dependency manager (`npm upgrade
|
|
156
|
+
opencode-turnstile`, or your registry's equivalent). Re-run `check` to see
|
|
157
|
+
whether anything is outdated or drifted, then `init` to re-sync only what
|
|
158
|
+
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.
|
|
161
|
+
|
|
162
|
+
### Uninstall
|
|
163
|
+
|
|
164
|
+
`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:
|
|
167
|
+
files it never installed (untracked files, symlinks pointing outside the
|
|
168
|
+
repo) are left alone, and a second run is a clean no-op.
|
|
169
|
+
|
|
170
|
+
### From a clone
|
|
171
|
+
|
|
172
|
+
This repo is the source of truth for what lands in `~/.config/opencode`:
|
|
173
|
+
the plugin and the pipeline agent definitions are installed per the
|
|
174
|
+
manifest in `install.json`; unowned files in the target directories are
|
|
175
|
+
never touched. Two install modes are available:
|
|
176
|
+
|
|
177
|
+
| | Symlink (default) | Copy (`--copy`) |
|
|
178
|
+
| ------------------------- | --------------------------------------------------- | ------------------------------------------------------- |
|
|
179
|
+
| Best for | developing turnstile | general use, dotfiles repos that reject foreign links |
|
|
180
|
+
| How targets point at repo | symlinks into the checkout | real copies |
|
|
181
|
+
| After `git pull` | nothing to do; edits are live on next session start | re-run `npm run init -- --copy` to re-sync |
|
|
182
|
+
| Editing installed files | edit the repo; links share it | edit the repo; user-edited copies conflict on next sync |
|
|
183
|
+
|
|
184
|
+
```sh
|
|
185
|
+
git clone <repo-url> && cd turnstile
|
|
186
|
+
npm install # typescript + @types/node, for typechecking
|
|
187
|
+
npm run init # symlink mode
|
|
188
|
+
# or: npm run install:copy
|
|
189
|
+
npm run install:check # verify installed state; exit 1 on drift (gates local CI)
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
A target that differs from the repo is a **conflict**: `install` fails with
|
|
193
|
+
a per-path summary. Re-run with `--force` to back the file up
|
|
194
|
+
(`<name>.bak.<timestamp>`) and replace it. Conflicts mean:
|
|
195
|
+
|
|
196
|
+
- symlink mode: a real file (or a link pointing outside the repo) sits at a
|
|
197
|
+
manifest target and differs from the source, typically pre-existing
|
|
198
|
+
files or hand edits.
|
|
199
|
+
- copy mode: an installed copy was edited after install (the ledger can
|
|
200
|
+
tell user edits from repo updates), or an untracked real file sits at a
|
|
201
|
+
target.
|
|
202
|
+
|
|
203
|
+
Copy mode records everything it installed in
|
|
204
|
+
`~/.config/opencode/.turnstile-install.json` (the ledger: destination
|
|
205
|
+
mapped to source, content hash, package version, and install mode). The
|
|
206
|
+
ledger makes re-syncs safe: repo
|
|
207
|
+
updates re-copy cleanly and user edits fail loudly. It also lets the
|
|
208
|
+
installer remove copies whose manifest entry was deleted. Deleting the
|
|
209
|
+
ledger turns every copy into an untracked file the installer will refuse
|
|
210
|
+
to touch.
|
|
211
|
+
|
|
212
|
+
### Set the agents' models
|
|
213
|
+
|
|
214
|
+
The six agent definitions in `.opencode/agents/` ship with `model:`
|
|
215
|
+
frontmatter pointing at the author's locally served models (`oMLX/...`).
|
|
216
|
+
You almost certainly do not have those models, and dispatches to the
|
|
217
|
+
pipeline agents will fail or fall back until you set your own.
|
|
218
|
+
|
|
219
|
+
Since installs share the repo's files (symlink mode) or conflict on
|
|
220
|
+
divergence (copy mode), set your models **in the clone**, not in
|
|
221
|
+
`~/.config/opencode/agents/`: edit the `model:` line of each agent file
|
|
222
|
+
(`planner`, `plan-reviewer`, `executor`, `test-runner`, `reviewer`,
|
|
223
|
+
`debugger`) to a model
|
|
224
|
+
you have, in `providerID/modelID` form, then install or re-sync per the
|
|
225
|
+
mode table above. Model overrides in installed copies are lost on the next
|
|
226
|
+
sync; the repo is the source of truth.
|
|
227
|
+
|
|
228
|
+
### Customizing the pipeline agents
|
|
229
|
+
|
|
230
|
+
The agent definitions are opinionated on purpose: turnstile recognizes
|
|
231
|
+
and clamps them by name, and the marker contract in the agent prompts is
|
|
232
|
+
what the plugin enforces against. Editing the installed files is therefore
|
|
233
|
+
not a supported customization path: symlink installs share the repo file,
|
|
234
|
+
and copy installs conflict on the next sync.
|
|
235
|
+
|
|
236
|
+
Two sanctioned ways to change the pipeline:
|
|
237
|
+
|
|
238
|
+
- **Bring your own agents.** Add your own agent definitions (any names) to
|
|
239
|
+
`~/.config/opencode/agents/`; the installer never touches files it does
|
|
240
|
+
not own. Then point turnstile at them via
|
|
241
|
+
`~/.config/opencode/turnstile.json`: `agents.planReviewer`,
|
|
242
|
+
`agents.reviewer`, `agents.executor`, `agents.testRunner`, and
|
|
243
|
+
`agents.debugger` take the names turnstile should recognize, clamp, and
|
|
244
|
+
hold to the marker contract. Your agents get the same gates the shipped
|
|
245
|
+
ones do.
|
|
246
|
+
- **Fork the repo.** To change the pipeline content itself, fork and edit
|
|
247
|
+
`.opencode/agents/*.md` there; your installer serves your fork.
|
|
248
|
+
|
|
249
|
+
### Updating
|
|
250
|
+
|
|
251
|
+
Symlink mode: because everything is linked into the checkout, updating is
|
|
252
|
+
just:
|
|
253
|
+
|
|
254
|
+
```sh
|
|
255
|
+
git pull # in this repo
|
|
256
|
+
npm install # only if dependencies changed
|
|
257
|
+
```
|
|
258
|
+
|
|
259
|
+
then restart OpenCode sessions (or `opencode service restart`) so the
|
|
260
|
+
running server reloads the plugin. There is no separate install step for
|
|
261
|
+
code or agent changes; the links are already live. Re-run
|
|
262
|
+
`npm run init` only when the **set** of artifacts changes (a new agent
|
|
263
|
+
added to `install.json`, an artifact removed or moved): it creates, fixes,
|
|
264
|
+
or removes repo-owned links to match the manifest.
|
|
265
|
+
|
|
266
|
+
Copy mode adds one extra step, because copies do not follow the repo:
|
|
267
|
+
|
|
268
|
+
```sh
|
|
269
|
+
git pull # in this repo
|
|
270
|
+
npm install # only if dependencies changed
|
|
271
|
+
npm run init -- --copy # re-sync: re-copies changed files, skips clean ones
|
|
272
|
+
```
|
|
273
|
+
|
|
274
|
+
then restart OpenCode sessions. Unchanged files are skipped; changed ones
|
|
275
|
+
are re-copied; user-edited copies conflict and wait for `--force`.
|
|
276
|
+
|
|
277
|
+
Shared caveats:
|
|
278
|
+
|
|
279
|
+
- Symlinks point at the clone's absolute path. Do not move, rename, or
|
|
280
|
+
delete the checkout; if you relocate it, re-run `npm run init`.
|
|
281
|
+
- `npm run install:check` reports drift between the manifest and what is
|
|
282
|
+
actually installed; local `npm run ci` gates on it. GitHub CI runs
|
|
283
|
+
`npm run ci:host`, the same chain without the install check, because a
|
|
284
|
+
runner has no installs to check. Verify a copy install with
|
|
285
|
+
`npm run install:check -- --copy`.
|
|
286
|
+
|
|
287
|
+
### Install the AGENTS.md contracts
|
|
288
|
+
|
|
289
|
+
Turnstile enforces what your instructions file declares. Install the
|
|
290
|
+
matching contract block into your global AGENTS.md:
|
|
291
|
+
|
|
292
|
+
```sh
|
|
293
|
+
node scripts/install-agents.mjs # or: npm run install:agents
|
|
294
|
+
```
|
|
295
|
+
|
|
296
|
+
- Default target: `~/.config/opencode/AGENTS.md` (override with `--file`).
|
|
297
|
+
- The block is delimited by `<!-- turnstile:start -->` …
|
|
298
|
+
`<!-- turnstile:end -->` sentinels; re-running updates only that block
|
|
299
|
+
and preserves all surrounding personal content.
|
|
300
|
+
- Legacy targets are adopted automatically. If the file already contains
|
|
301
|
+
pipeline-gates content _without_ sentinels (e.g. an install from before
|
|
302
|
+
the block was managed), the installer classifies the existing section:
|
|
303
|
+
equivalent to the template, it wraps the section in sentinels without
|
|
304
|
+
changing its content; differing from the template, it backs the file up
|
|
305
|
+
to `<name>.bak.<timestamp>` and replaces the section with the current
|
|
306
|
+
block. Pass `--no-adopt` to refuse both and print reconciliation
|
|
307
|
+
instructions instead.
|
|
308
|
+
- The installer refuses (exit 1, no write) only when a target is
|
|
309
|
+
ambiguous (more than one turnstile section) or corrupted (a start
|
|
310
|
+
sentinel without an end sentinel); reconcile those by hand.
|
|
311
|
+
- `--check` reports the outcome without writing: OK, or the adoption
|
|
312
|
+
decision (would-adopt-equivalent / would-replace / drift), exiting 0
|
|
313
|
+
only for OK. Useful in CI or dotfiles setup.
|
|
314
|
+
- Agent definitions and the plugin itself are installed separately via
|
|
315
|
+
`npm run init` (symlinks, for development) or
|
|
316
|
+
`npm run init -- --copy` (see the Install section above).
|
|
317
|
+
|
|
318
|
+
Alternatively, in an OpenCode session, invoke the bundled
|
|
319
|
+
`turnstile-setup` skill, which runs the installer and verifies marker
|
|
320
|
+
sync. The skill ships in the package and installs to
|
|
321
|
+
`~/.config/opencode/skills/turnstile-setup/SKILL.md`; the template
|
|
322
|
+
source lives at `templates/global-AGENTS.md`.
|
|
323
|
+
|
|
324
|
+
## Configuration
|
|
325
|
+
|
|
326
|
+
Defaults live in code (`src/config.ts`); override via
|
|
327
|
+
`~/.config/opencode/turnstile.json`, environment variables, or both.
|
|
328
|
+
Precedence: defaults < config file < env. The plugin reads config once, at
|
|
329
|
+
setup; restart sessions to apply. Invalid keys are ignored and journaled
|
|
330
|
+
(fail-open); a malformed config file never breaks the host.
|
|
331
|
+
|
|
332
|
+
| Key | Default | Meaning |
|
|
333
|
+
| ------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------- | ----------------------------------------------------------------- |
|
|
334
|
+
| `metricsDir` | `$XDG_DATA_HOME/opencode/metrics`, fallback `~/.local/share/opencode/metrics` | directory for both telemetry files |
|
|
335
|
+
| `journalMaxBytes` | 5242880 | rotate a journal file past this size |
|
|
336
|
+
| `journalMaxGenerations` | 5 | rotated generations kept per journal |
|
|
337
|
+
| `maxReviewerRounds` | 2 | reviewer dispatches per task without `GATE: PASS` |
|
|
338
|
+
| `readWarn` / `readBlock` | 8 / 15 | whole-file read horizon |
|
|
339
|
+
| `waiverMarker` | `USER WAIVER:` | literal waiver marker |
|
|
340
|
+
| `plansDir` | `.opencode/plans` | executor decomposition scan directory |
|
|
341
|
+
| `trace` | `true` | write the human-readable trace file (JSONL journal always writes) |
|
|
342
|
+
| `agents.reviewer` / `agents.planReviewer` / `agents.executor` / `agents.testRunner` / `agents.debugger` | `reviewer` / `plan-reviewer` / `executor` / `test-runner` / `debugger` | agent names recognized in dispatches |
|
|
343
|
+
| `agents.bareOutput` | `["test-runner", "reviewer", "plan-reviewer"]` | agents whose marker-less output is a violation |
|
|
344
|
+
| `gateMarker` / `verdictMarker` | canonical regex sources | marker patterns, compiled with `gm` |
|
|
345
|
+
|
|
346
|
+
Environment variables (override the file):
|
|
347
|
+
|
|
348
|
+
- `TURNSTILE_TRACE=0|1`: disable/enable the trace file
|
|
349
|
+
- `TURNSTILE_METRICS_DIR=<dir>`: relocate the telemetry directory
|
|
350
|
+
- `TURNSTILE_CONFIG=<path>`: use a different config file
|
|
351
|
+
|
|
352
|
+
## Markers
|
|
353
|
+
|
|
354
|
+
Subagents must emit these as the first line of their output. These examples
|
|
355
|
+
are the canonical syntax; CI asserts the parser in `turnstile.ts` accepts
|
|
356
|
+
exactly them.
|
|
357
|
+
|
|
358
|
+
```
|
|
359
|
+
GATE: PASS tests=12 passed=11 failed=1
|
|
360
|
+
GATE: FAIL tests=12 passed=10 failed=2
|
|
361
|
+
|
|
362
|
+
VERDICT: APPROVE crit=0 high=0 med=2 low=1
|
|
363
|
+
VERDICT: BLOCK crit=1 high=0 med=0 low=2
|
|
364
|
+
```
|
|
365
|
+
|
|
366
|
+
Waiver marker (must appear in the reviewer dispatch prompt, verbatim):
|
|
367
|
+
|
|
368
|
+
```
|
|
369
|
+
USER WAIVER: <the user's words>
|
|
370
|
+
```
|
|
371
|
+
|
|
372
|
+
## What turnstile is not
|
|
373
|
+
|
|
374
|
+
Turnstile is a speed bump and an audit trail, not a security boundary.
|
|
375
|
+
|
|
376
|
+
- Any text matching the gate marker, including a marker an agent
|
|
377
|
+
hallucinated or copied from documentation, flips `hasGatePass`. An agent
|
|
378
|
+
that wants a green gate can emit `GATE: PASS tests=1 passed=1 failed=0`
|
|
379
|
+
without running anything.
|
|
380
|
+
- `USER WAIVER:` as a substring anywhere in a dispatch prompt unlocks the
|
|
381
|
+
reviewer gate. A prompt-injected waiver is indistinguishable from a real
|
|
382
|
+
one.
|
|
383
|
+
- `scripts/check-markers.mjs` guards against accidental drift between the
|
|
384
|
+
parser and documented markers; it cannot guard against forgery, because
|
|
385
|
+
the markers are plain prose by design.
|
|
386
|
+
|
|
387
|
+
Use OpenCode's permission and sandboxing mechanisms against hostile agents.
|
|
388
|
+
Treat turnstile's journal as the record of what was _claimed_, and treat the
|
|
389
|
+
journal itself as sensitive data (see SECURITY.md).
|
|
390
|
+
|
|
391
|
+
## Files and journals
|
|
392
|
+
|
|
393
|
+
- Journals live outside the repo in `~/.local/share/opencode/metrics/`:
|
|
394
|
+
- `turnstile.jsonl`: full JSONL trace; rotates at 5 MB, keeps 5
|
|
395
|
+
generations
|
|
396
|
+
- `gate-events.jsonl`: human-readable
|
|
397
|
+
`YYYY-MM-DD HH:MM | agent | event | detail` lines (suppressed entirely
|
|
398
|
+
by `trace: false`)
|
|
399
|
+
- Retention is bounded: 2 files × (1 current + 5 generations) × 5 MB ≈
|
|
400
|
+
~30 MB per journal, worst case ~60 MB total.
|
|
401
|
+
- `.opencode/plans/*.md` containing `## Decomposition` are load-bearing:
|
|
402
|
+
their presence activates the executor chunk-citation gate.
|
|
403
|
+
|
|
404
|
+
## Behavior contract
|
|
405
|
+
|
|
406
|
+
- Fail-open by design. Every hook body is guarded; internal errors are
|
|
407
|
+
journaled and the tool call proceeds. Removing the plugin leaves the
|
|
408
|
+
pipeline functional (prompt contracts alone). The only thrown errors are
|
|
409
|
+
the deliberate `BLOCKED by turnstile:` ones.
|
|
410
|
+
- The doc comment at the top of `turnstile.ts` is the authoritative summary
|
|
411
|
+
of enforced contracts; this README mirrors it and CI keeps the marker
|
|
412
|
+
examples in sync with the parser regexes.
|
|
413
|
+
- Host API is the OpenCode v2 promise-plugin shape
|
|
414
|
+
(`{ tool.hook, session.hook, event.subscribe }`), verified against
|
|
415
|
+
opencode v2.0.22.
|
package/bin/cli.mjs
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Thin argv router for the opencode-turnstile package. Maps the four
|
|
3
|
+
// subcommands onto the installer scripts (spawned, not imported, so each
|
|
4
|
+
// keeps its own process.exit and stdio) or prints the package version. Paths
|
|
5
|
+
// are resolved against this file, not cwd, so `npx opencode-turnstile` works
|
|
6
|
+
// from any directory.
|
|
7
|
+
//
|
|
8
|
+
// opencode-turnstile init Install/sync artifacts into ~/.config/opencode
|
|
9
|
+
// opencode-turnstile check Verify artifacts against the manifest
|
|
10
|
+
// opencode-turnstile uninstall Remove installed artifacts (agents + block)
|
|
11
|
+
// opencode-turnstile version Print the installed package version
|
|
12
|
+
//
|
|
13
|
+
// Unknown subcommand or no args prints usage and exits 2.
|
|
14
|
+
|
|
15
|
+
import { spawn } from "node:child_process"
|
|
16
|
+
import { readFileSync } from "node:fs"
|
|
17
|
+
import { dirname, join, resolve } from "node:path"
|
|
18
|
+
import { fileURLToPath } from "node:url"
|
|
19
|
+
|
|
20
|
+
const here = dirname(fileURLToPath(import.meta.url))
|
|
21
|
+
const repoRoot = resolve(here, "..")
|
|
22
|
+
const installScript = join(repoRoot, "scripts/install.mjs")
|
|
23
|
+
const agentsScript = join(repoRoot, "scripts/install-agents.mjs")
|
|
24
|
+
|
|
25
|
+
const USAGE = `Usage: opencode-turnstile <command>
|
|
26
|
+
|
|
27
|
+
Commands:
|
|
28
|
+
init Install or sync artifacts into ~/.config/opencode
|
|
29
|
+
check Verify installed artifacts against the manifest
|
|
30
|
+
uninstall Remove installed artifacts (agents + block)
|
|
31
|
+
version Print the installed package version
|
|
32
|
+
|
|
33
|
+
A git checkout installs by linking (live edits); a package install copies.`
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Spawns an installer script with inherited stdio and resolves with its exit
|
|
37
|
+
* code so this router returns the child's status verbatim.
|
|
38
|
+
* @param {string} script Absolute path to the script to run.
|
|
39
|
+
* @param {string[]} passthrough Arguments forwarded to the installer.
|
|
40
|
+
* @returns {Promise<number>} The child's exit code.
|
|
41
|
+
*/
|
|
42
|
+
function spawnInstaller(script, passthrough) {
|
|
43
|
+
return new Promise((resolveChild) => {
|
|
44
|
+
const child = spawn(process.execPath, [script, ...passthrough], { stdio: "inherit" })
|
|
45
|
+
child.on("error", () => resolveChild(1))
|
|
46
|
+
child.on("exit", (code, signal) => {
|
|
47
|
+
if (signal) process.kill(process.pid, signal)
|
|
48
|
+
resolveChild(code ?? 0)
|
|
49
|
+
})
|
|
50
|
+
})
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async function main() {
|
|
54
|
+
const [command, ...passthrough] = process.argv.slice(2)
|
|
55
|
+
|
|
56
|
+
switch (command) {
|
|
57
|
+
case "init": {
|
|
58
|
+
// Full install: manifest artifacts (plugin + agents), then the
|
|
59
|
+
// managed contract block in ~/.config/opencode/AGENTS.md, mirroring
|
|
60
|
+
// uninstall's two-step shape in reverse. Passthrough flags go to the
|
|
61
|
+
// manifest installer; the agents installer needs none of them.
|
|
62
|
+
const byInstall = await spawnInstaller(installScript, passthrough)
|
|
63
|
+
const byAgents = await spawnInstaller(agentsScript, [])
|
|
64
|
+
return process.exit(byInstall || byAgents ? 1 : 0)
|
|
65
|
+
}
|
|
66
|
+
case "check":
|
|
67
|
+
return spawnInstaller(installScript, ["--check", ...passthrough])
|
|
68
|
+
case "uninstall": {
|
|
69
|
+
// Reverse the copy/symlink install, then unmerge the managed block.
|
|
70
|
+
// Inherited stdio surfaces both scripts' reports verbatim; exit non-zero
|
|
71
|
+
// only if either step failed. No restart note is printed here; each
|
|
72
|
+
// child prints its own, gated on whether it changed anything.
|
|
73
|
+
const byInstall = await spawnInstaller(installScript, ["--uninstall"])
|
|
74
|
+
const byAgents = await spawnInstaller(agentsScript, ["--remove"])
|
|
75
|
+
return process.exit(byInstall || byAgents ? 1 : 0)
|
|
76
|
+
}
|
|
77
|
+
case "version":
|
|
78
|
+
console.log(JSON.parse(readFileSync(join(repoRoot, "package.json"), "utf8")).version)
|
|
79
|
+
return
|
|
80
|
+
default:
|
|
81
|
+
console.log(USAGE)
|
|
82
|
+
process.exit(2)
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
await main().then((code) => process.exit(code ?? 0))
|
package/install.json
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
{
|
|
2
|
+
"ledger": "~/.config/opencode/.turnstile-install.json",
|
|
3
|
+
"links": [
|
|
4
|
+
{
|
|
5
|
+
"src": "turnstile.ts",
|
|
6
|
+
"dest": "~/.config/opencode/plugins/turnstile.ts"
|
|
7
|
+
},
|
|
8
|
+
{
|
|
9
|
+
"src": ".opencode/agents/plan-reviewer.md",
|
|
10
|
+
"dest": "~/.config/opencode/agents/plan-reviewer.md"
|
|
11
|
+
},
|
|
12
|
+
{
|
|
13
|
+
"src": ".opencode/agents/planner.md",
|
|
14
|
+
"dest": "~/.config/opencode/agents/planner.md"
|
|
15
|
+
},
|
|
16
|
+
{
|
|
17
|
+
"src": ".opencode/agents/executor.md",
|
|
18
|
+
"dest": "~/.config/opencode/agents/executor.md"
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
"src": ".opencode/agents/test-runner.md",
|
|
22
|
+
"dest": "~/.config/opencode/agents/test-runner.md"
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
"src": ".opencode/agents/reviewer.md",
|
|
26
|
+
"dest": "~/.config/opencode/agents/reviewer.md"
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
"src": ".opencode/agents/debugger.md",
|
|
30
|
+
"dest": "~/.config/opencode/agents/debugger.md"
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
"src": "skills/turnstile-setup/SKILL.md",
|
|
34
|
+
"dest": "~/.config/opencode/skills/turnstile-setup/SKILL.md"
|
|
35
|
+
}
|
|
36
|
+
],
|
|
37
|
+
"retired": [
|
|
38
|
+
{
|
|
39
|
+
"dest": "~/.config/opencode/plugin/turnstile.ts"
|
|
40
|
+
}
|
|
41
|
+
]
|
|
42
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,60 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "opencode-turnstile",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "OpenCode plugin that mechanically enforces agent pipeline gates (planner → executor → test-runner → reviewer)",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"bin": {
|
|
8
|
+
"opencode-turnstile": "bin/cli.mjs"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"turnstile.ts",
|
|
12
|
+
"src",
|
|
13
|
+
"scripts",
|
|
14
|
+
"bin",
|
|
15
|
+
"install.json",
|
|
16
|
+
"templates",
|
|
17
|
+
"skills",
|
|
18
|
+
".opencode/agents",
|
|
19
|
+
"README.md",
|
|
20
|
+
"LICENSE"
|
|
21
|
+
],
|
|
22
|
+
"scripts": {
|
|
23
|
+
"typecheck": "tsc --noEmit",
|
|
24
|
+
"lint": "eslint .",
|
|
25
|
+
"lint:fix": "eslint . --fix",
|
|
26
|
+
"format": "prettier --write .",
|
|
27
|
+
"format:check": "prettier --check .",
|
|
28
|
+
"check:markers": "node scripts/check-markers.mjs",
|
|
29
|
+
"test": "node --test --experimental-strip-types tests/*.test.ts",
|
|
30
|
+
"init": "node scripts/install.mjs",
|
|
31
|
+
"init:copy": "node scripts/install.mjs --copy",
|
|
32
|
+
"init:check": "node scripts/install.mjs --check",
|
|
33
|
+
"install:copy": "node scripts/install.mjs --copy",
|
|
34
|
+
"install:check": "node scripts/install.mjs --check",
|
|
35
|
+
"install:agents": "node scripts/install-agents.mjs",
|
|
36
|
+
"pack:dry": "npm pack --dry-run",
|
|
37
|
+
"ci:host": "npm run lint && npm run format:check && npm run typecheck && npm run check:markers && npm test",
|
|
38
|
+
"ci": "npm run ci:host && npm run install:check"
|
|
39
|
+
},
|
|
40
|
+
"repository": {
|
|
41
|
+
"type": "git",
|
|
42
|
+
"url": "git+https://github.com/edseabolt/turnstile.git"
|
|
43
|
+
},
|
|
44
|
+
"bugs": {
|
|
45
|
+
"url": "https://github.com/edseabolt/turnstile/issues"
|
|
46
|
+
},
|
|
47
|
+
"homepage": "https://github.com/edseabolt/turnstile#readme",
|
|
48
|
+
"engines": {
|
|
49
|
+
"node": ">=22.6"
|
|
50
|
+
},
|
|
51
|
+
"devDependencies": {
|
|
52
|
+
"@eslint/js": "^10.0.1",
|
|
53
|
+
"@types/node": "^22.10.0",
|
|
54
|
+
"eslint": "^10.12.0",
|
|
55
|
+
"globals": "^17.13.0",
|
|
56
|
+
"prettier": "^3.9.9",
|
|
57
|
+
"typescript": "^5.7.0",
|
|
58
|
+
"typescript-eslint": "^8.71.1"
|
|
59
|
+
}
|
|
60
|
+
}
|