opencode-turnstile 0.0.0-stage → 0.1.0

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/types.ts ADDED
@@ -0,0 +1,176 @@
1
+ /**
2
+ * @fileoverview Shared type definitions for the turnstile plugin: the
3
+ * OpenCode v2 host wire shape, hook payloads, and gate-state structures.
4
+ *
5
+ * The host boundary is intentionally permissive: payloads crossing the
6
+ * plugin boundary are typed as optional fields so a host that omits or
7
+ * reshapes a field fails open instead of failing hard.
8
+ */
9
+
10
+ /** Task dispatch input as delivered by the host's `task`/`subagent` tools. */
11
+ export interface TaskInput {
12
+ subagent_type?: unknown
13
+ agent?: unknown
14
+ subagent?: unknown
15
+ prompt?: unknown
16
+ description?: unknown
17
+ command?: unknown
18
+ cmd?: unknown
19
+ }
20
+
21
+ /** Tool result as delivered by the host's `execute.after` hook. */
22
+ export interface ToolResult {
23
+ output?: unknown
24
+ title?: unknown
25
+ input?: unknown
26
+ metadata?: { exit?: unknown; exitCode?: unknown; code?: unknown }
27
+ }
28
+
29
+ /** Payload of the `tool.execute.before` and `tool.execute.after` hooks. */
30
+ export interface ToolHookEvent {
31
+ tool?: unknown
32
+ sessionID?: unknown
33
+ input?: TaskInput
34
+ result?: ToolResult
35
+ }
36
+
37
+ /** Payload of the `session.prompt` hook. */
38
+ export interface PromptHookProperties {
39
+ sessionID?: unknown
40
+ }
41
+
42
+ /** Generation draft passed to the `session.generate` hook. */
43
+ export interface GenerateDraft {
44
+ agent?: unknown
45
+ options?: { temperature?: number }
46
+ }
47
+
48
+ /** Compaction draft passed to the `session.compaction` hook. */
49
+ export interface CompactionDraft {
50
+ system?: Array<{ type: string; text?: string }>
51
+ }
52
+
53
+ /** Session record returned by the host's `session.get`. */
54
+ export interface SessionRecord {
55
+ parentID?: unknown
56
+ data?: { parentID?: unknown }
57
+ }
58
+
59
+ /** Properties bag carried by host stream events. */
60
+ export interface EventProperties {
61
+ type?: unknown
62
+ sessionID?: unknown
63
+ parentID?: unknown
64
+ directory?: unknown
65
+ location?: { directory?: unknown }
66
+ part?: { type?: unknown; text?: unknown; sessionID?: unknown }
67
+ text?: unknown
68
+ delta?: unknown
69
+ error?: unknown
70
+ }
71
+
72
+ /** A single host stream event delivered by `event.subscribe`. */
73
+ export interface StreamEvent {
74
+ type: string
75
+ data?: EventProperties
76
+ properties?: EventProperties
77
+ }
78
+
79
+ /** Gate state for one parent session. */
80
+ export interface GateState {
81
+ hasGatePass: boolean
82
+ /** True when this task's plan has a current plan-reviewer APPROVE. */
83
+ hasPlanVerdict: boolean
84
+ verdicts: string[]
85
+ reviewRounds: number
86
+ reads: number
87
+ seen: Set<string>
88
+ }
89
+
90
+ /** One first-sight marker observation returned by the marker parser. */
91
+ export interface MarkerEvent {
92
+ kind: "gate" | "verdict"
93
+ marker: string
94
+ result?: "PASS" | "FAIL"
95
+ rounds?: number
96
+ /** Marker matches observed in the same text but not journaled
97
+ * (latest-wins policy); 0 when this was the only match. */
98
+ suppressed: number
99
+ }
100
+
101
+ /** Agent names turnstile recognizes in dispatches and generation drafts. */
102
+ export interface TurnstileAgents {
103
+ reviewer: string
104
+ /** Plan reviewer: adversarial review of the implementation plan. */
105
+ planReviewer: string
106
+ executor: string
107
+ testRunner: string
108
+ debugger: string
109
+ /** Agents contracted to emit GATE/VERDICT markers (bare-output check). */
110
+ bareOutput: string[]
111
+ }
112
+
113
+ /**
114
+ * Layered plugin configuration. Defaults in `src/config.ts`; user overrides
115
+ * via `~/.config/opencode/turnstile.json`, then env vars, then injected
116
+ * options (tests). Read once at setup; restart sessions to apply.
117
+ */
118
+ export interface TurnstileConfig {
119
+ /** Directory holding the JSONL journal and trace files. */
120
+ metricsDir: string
121
+ /** Rotate a journal file when it exceeds this many bytes. */
122
+ journalMaxBytes: number
123
+ /** Number of rotated journal generations to keep. */
124
+ journalMaxGenerations: number
125
+ /**
126
+ * Max reviewer dispatches per task; the cap counts dispatches, applies
127
+ * regardless of gate state, and is bypassed by an explicit waiver.
128
+ */
129
+ maxReviewerRounds: number
130
+ /** Cap on tracked sessions (LRU); oldest state evicted beyond this. */
131
+ maxSessions: number
132
+ /** Whole-file read count at which the horizon warning is injected. */
133
+ readWarn: number
134
+ /** Whole-file read count at which further reads are blocked. */
135
+ readBlock: number
136
+ /** Literal marker that waives the reviewer gate when in the prompt. */
137
+ waiverMarker: string
138
+ /** Project-relative directory scanned for `## Decomposition` plans. */
139
+ plansDir: string
140
+ /** Whether the human-readable trace file is written (JSONL journal always). */
141
+ trace: boolean
142
+ /** Agent names turnstile recognizes. */
143
+ agents: TurnstileAgents
144
+ /** Gate marker regex source (compiled with `gm`). */
145
+ gateMarker: string
146
+ /** Verdict marker regex source (compiled with `gm`). */
147
+ verdictMarker: string
148
+ }
149
+
150
+ /** Options accepted by the plugin factory; tests inject config and env here. */
151
+ export interface TurnstileOptions {
152
+ /** Overrides applied last, on top of file and env layers. */
153
+ config?: Partial<TurnstileConfig>
154
+ /** Environment for config resolution; defaults to `process.env`. */
155
+ env?: Record<string, string | undefined>
156
+ }
157
+
158
+ /**
159
+ * The v2 promise-plugin host shape (verified against opencode v2.0.22).
160
+ *
161
+ * Hook callback payloads are typed `any` at this wire boundary only; the
162
+ * host does not expose stable payload types. Every function turnstile
163
+ * defines has concrete parameter and return types; the `any` ends here.
164
+ */
165
+ export type Host = {
166
+ tool?: {
167
+ hook?: (name: string, cb: (event: any) => Promise<void> | void) => Promise<unknown>
168
+ }
169
+ session?: {
170
+ hook?: (name: string, cb: (input: any) => Promise<void> | void) => Promise<unknown>
171
+ get?: (input: { sessionID: string }, options?: any) => Promise<SessionRecord | undefined>
172
+ }
173
+ event?: {
174
+ subscribe?: (options?: { signal?: AbortSignal }) => AsyncIterable<StreamEvent>
175
+ }
176
+ }
@@ -0,0 +1,55 @@
1
+ <!-- turnstile:start (managed block — edit templates/global-AGENTS.md in the
2
+ turnstile repo, then rerun the installer; do not edit in place) -->
3
+
4
+ ## Turnstile pipeline gates
5
+
6
+ Order: planner → plan-reviewer → executor → test-runner → reviewer. This
7
+ file is the source of truth for policy; the `turnstile` plugin (installed
8
+ at `~/.config/opencode/plugins/turnstile.ts`) enforces it mechanically.
9
+ Enforcement mechanics (regexes, thresholds, journals) are canonical in
10
+ turnstile's README; if prose here diverges from enforcement, trust the
11
+ plugin and fix this file. Advisory only if the plugin is absent.
12
+
13
+ Plan gate: dispatch `executor` only after this task's plan-reviewer first
14
+ line is `VERDICT: APPROVE crit=<n> high=<n> med=<n> low=<n>`, or after an
15
+ explicit user waiver. Plan-file changes invalidate the approval — after a
16
+ re-plan, dispatch plan-reviewer again before resuming executor work.
17
+
18
+ Dispatch gate: dispatch `reviewer` only after this task's test-runner first
19
+ line is `GATE: PASS tests=<n> passed=<n> failed=<n>`, or after an explicit
20
+ user waiver. Max 2 reviewer dispatches per task; then escalate the open
21
+ findings to the user.
22
+
23
+ Markers (first line, machine-parseable):
24
+
25
+ - test-runner: `GATE: PASS|FAIL tests=<n> passed=<n> failed=<n>`
26
+ - reviewer: `VERDICT: APPROVE|BLOCK crit=<n> high=<n> med=<n> low=<n>`
27
+ - plan-reviewer: `VERDICT: APPROVE|BLOCK crit=<n> high=<n> med=<n> low=<n>`
28
+ (first line; then an AC traceability table with `Covered-by` per AC)
29
+ - reviewer acceptance table: add an `AC-ID` column referencing `AC-<n>`
30
+ - planner: number criteria `AC-1…AC-n`; if the change spans >5 files or >3
31
+ outcomes, add `## Decomposition` with ordered `chunk-N` entries (≤5 files)
32
+ - executor/debugger: 5-file budget; if 10 tool calls pass without a test
33
+ command, STOP, emit a state summary, re-anchor on the plan.
34
+
35
+ Reviewer ensemble: dispatch two `reviewer` subagents on the same diff; merge
36
+ one finding per `file+symbol` (max severity). A singleton CRITICAL/HIGH
37
+ blocks only after re-reading the cited location; LOW singletons become
38
+ `UNCONFIRMED` notes.
39
+
40
+ Worktree/commit: git worktrees will be used, but it does not matter how they
41
+ get created. Work inside the checkout and branch your session starts in; do
42
+ not create/add/remove worktrees, switch branches, merge, or open PRs unless
43
+ the user explicitly asks. On success, commit your change set with a
44
+ conventional message and stop. If a worktree is in use, assert a `WORKTREE:`
45
+ header for logging; otherwise skip it. Committing on a shared default branch
46
+ means your change lands there; treat main/branch commits as high
47
+ sensitivity; do not self-reset.
48
+
49
+ Trace (works with the plugin off): append every dispatch/verdict to
50
+ `~/.local/share/opencode/metrics/gate-events.jsonl` as
51
+ `YYYY-MM-DD HH:MM | agent | event | detail`. The plugin also writes
52
+ `~/.local/share/opencode/metrics/turnstile.jsonl`
53
+ (`{ts,sessionID,type,...}`). Read: grep/glob/rg first, whole-file reads last.
54
+
55
+ <!-- turnstile:end -->
package/turnstile.ts ADDED
@@ -0,0 +1,82 @@
1
+ /**
2
+ * turnstile: runtime enforcement of the AGENTS.md pipeline contracts.
3
+ *
4
+ * Written for OpenCode v2.x promise-plugin host (`{ id, setup }`):
5
+ * - `ctx.tool.hook("execute.before" | "execute.after", cb)`
6
+ * - `ctx.session.hook("prompt" | "generate" | "compaction", cb)`
7
+ * - `ctx.event.subscribe({ signal })`
8
+ *
9
+ * Enforced mechanically:
10
+ * - Plan-approval gate: `task` dispatches to `executor` require a current
11
+ * plan-reviewer `VERDICT: APPROVE` for the task whenever a plan file
12
+ * exists in `.opencode/plans/`, or an explicit user waiver (`USER
13
+ * WAIVER:` literal in the prompt). Plan-file changes invalidate the
14
+ * approval, forcing re-review after a re-plan.
15
+ * - Hard gate: `task` dispatches to `reviewer` require a recorded `GATE: PASS`
16
+ * from test-runner for the current task (a later `GATE: FAIL` un-sets
17
+ * it), or an explicit user waiver (the dispatch prompt must contain the
18
+ * literal marker `USER WAIVER:`).
19
+ * - Round cap: max 2 `task -> reviewer` dispatches per task.
20
+ * - Horizon: read-counter backstop (warn @8, block @15 whole-file reads).
21
+ * - Decomposition: `task -> executor` dispatches must cite `chunk-N` / `AC-n`
22
+ * when a plan file with a `## Decomposition` block exists.
23
+ * - Markers: `GATE:` / `VERDICT:` parsed from task results and child-session
24
+ * assistant text, deduped per task (streaming re-delivery is ignored);
25
+ * marker-less outputs from marker-contracted agents (test-runner/reviewer/
26
+ * plan-reviewer) are journaled as violations; a plan-reviewer result's
27
+ * last `VERDICT` moves the plan-approval gate (parent-keyed, journaled as
28
+ * type `plan`); gate journal entries are keyed by the parent session so
29
+ * replay restores where the gates look.
30
+ * - Trace: JSONL journal of dispatches, gate transitions, verdicts, blocked
31
+ * calls, bash commands, errors.
32
+ * - Decoding: executor/test-runner/debugger temperature clamped ≤ 0.2;
33
+ * reviewer temperature = 0.
34
+ * - Config: layered and read once at setup: code defaults ⊕
35
+ * `~/.config/opencode/turnstile.json` ⊕ env (`TURNSTILE_TRACE`,
36
+ * `TURNSTILE_METRICS_DIR`, `TURNSTILE_CONFIG`) ⊕ injected options (tests).
37
+ * Invalid keys are ignored and journaled (fail-open); restart to apply.
38
+ *
39
+ * Fail-open by design: every hook body is guarded; an internal error is
40
+ * journaled and the tool call proceeds. Removing this file leaves the
41
+ * pipeline functional (prompt contracts alone).
42
+ *
43
+ * Module layout: one boundary per file; this file is the thin entry point
44
+ * the host loads, holding only the module shape and setup composition:
45
+ * - `src/types.ts`: host wire shapes and gate-state structures.
46
+ * - `src/journal.ts`: telemetry persistence (JSONL journal + trace).
47
+ * - `src/state.ts`: per-session gate state, session mapping, journal replay.
48
+ * - `src/markers.ts`: GATE/VERDICT parsing and dedup.
49
+ * - `src/gates.ts`: enforcement decisions (plan-approval gate, reviewer
50
+ * gate, read horizon, decomposition).
51
+ * - `src/setup.ts`: hook registration and the stream-event loop.
52
+ */
53
+ import { createTurnstile } from "./src/setup.ts"
54
+ import type { Host, TurnstileOptions } from "./src/types.ts"
55
+
56
+ /** The async setup function a plugin instance hands to the host. */
57
+ export type SetupFn = (h: Host) => Promise<(() => void) | void>
58
+
59
+ /**
60
+ * Creates a turnstile plugin bound to the given host context.
61
+ * @param host The OpenCode host context, or null in degraded loads.
62
+ * @param opts Injected config/env overrides (tests); defaults to env.
63
+ * @returns The plugin's setup function; resolves to a cleanup disposer.
64
+ */
65
+ export const turnstile = (host: Host | undefined | null, opts?: TurnstileOptions): SetupFn =>
66
+ createTurnstile(host, opts)
67
+
68
+ /** The module object the OpenCode host loads. */
69
+ const module: {
70
+ id: string
71
+ server: () => Promise<Record<string, never>>
72
+ setup: (ctx: Host, opts?: TurnstileOptions) => Promise<(() => void) | void>
73
+ } = {
74
+ id: "turnstile",
75
+ // Legacy v1 shape kept for loaders that require `server`; the active v2
76
+ // path is `setup` (same pattern as the working orca-opencode-status plugin).
77
+ server: async (): Promise<Record<string, never>> => ({}),
78
+ setup: async (ctx: Host, opts?: TurnstileOptions): Promise<(() => void) | void> =>
79
+ turnstile(ctx, opts)(ctx),
80
+ }
81
+
82
+ export default module