opencode-turnstile 0.2.0 → 0.2.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +263 -99
- package/agents/debugger.md +3 -2
- package/agents/executor.md +7 -2
- package/bin/cli.mjs +34 -22
- package/package.json +5 -6
- package/scripts/branch-coverage.mjs +206 -0
- package/scripts/check-markers.mjs +23 -1
- package/scripts/install.mjs +113 -13
- package/scripts/status.mjs +333 -0
- package/skills/turnstile-setup/SKILL.md +22 -38
- package/src/config.ts +295 -6
- package/src/evidence.ts +162 -0
- package/src/gates.ts +338 -93
- package/src/journal.ts +10 -3
- package/src/markers.ts +117 -15
- package/src/policy.ts +33 -0
- package/src/setup.ts +1265 -96
- package/src/state.ts +316 -21
- package/src/tool-policy.ts +683 -0
- package/src/types.ts +300 -1
- package/turnstile.ts +150 -14
- package/scripts/install-agents.mjs +0 -504
- package/templates/global-AGENTS.md +0 -55
package/src/markers.ts
CHANGED
|
@@ -2,9 +2,15 @@
|
|
|
2
2
|
* @fileoverview Marker boundary: `GATE:`/`VERDICT:` marker parsing with
|
|
3
3
|
* per-task dedup, plus agent attribution helpers. Pure with respect to
|
|
4
4
|
* journal and filesystem, so callers journal the returned marker events.
|
|
5
|
+
*
|
|
6
|
+
* The caller computes the evidence verdict (it owns the evidence tracker and
|
|
7
|
+
* config) and threads it in as optional trailing params: `passVerified` flips
|
|
8
|
+
* the gate only under `enforce`, while `stampVerified`/`stampMode` label the
|
|
9
|
+
* journal entry under `enforce` and `flag` (absent under `off`, keeping those
|
|
10
|
+
* events byte-identical to legacy output).
|
|
5
11
|
*/
|
|
6
12
|
|
|
7
|
-
import type { GateState, MarkerEvent, TaskInput } from "./types.ts"
|
|
13
|
+
import type { GateEvidenceMode, GateState, MarkerEvent, TaskInput } from "./types.ts"
|
|
8
14
|
|
|
9
15
|
// `export const` keeps the regex literals load-bearing for
|
|
10
16
|
// scripts/check-markers.mjs, which extracts them by name and asserts the
|
|
@@ -25,12 +31,20 @@ export const BARE_OUTPUT_AGENTS: readonly string[] = ["test-runner", "reviewer",
|
|
|
25
31
|
* not an issue; tests must strip `g` before `.test`.
|
|
26
32
|
* @param source Regex source string (no delimiters, no flags).
|
|
27
33
|
* @param fallback Regex to return when the source fails to compile.
|
|
34
|
+
* @param onFallback Optional callback invoked with the compile failure
|
|
35
|
+
* (the caught value) before the fallback is returned, so callers can
|
|
36
|
+
* journal the silent fallback without this module depending on journaling.
|
|
28
37
|
* @returns The compiled regex, or the fallback when the source is invalid.
|
|
29
38
|
*/
|
|
30
|
-
export function compileMarkerRegex(
|
|
39
|
+
export function compileMarkerRegex(
|
|
40
|
+
source: string,
|
|
41
|
+
fallback: RegExp,
|
|
42
|
+
onFallback?: (cause: unknown) => void,
|
|
43
|
+
): RegExp {
|
|
31
44
|
try {
|
|
32
45
|
return new RegExp(source, "gm")
|
|
33
|
-
} catch {
|
|
46
|
+
} catch (cause) {
|
|
47
|
+
onFallback?.(cause)
|
|
34
48
|
return fallback
|
|
35
49
|
}
|
|
36
50
|
}
|
|
@@ -45,10 +59,51 @@ export function agentFromTaskInput(input: TaskInput | undefined | null): string
|
|
|
45
59
|
return String(input?.subagent_type ?? input?.agent ?? input?.subagent ?? "")
|
|
46
60
|
}
|
|
47
61
|
|
|
62
|
+
/**
|
|
63
|
+
* Records first-sight provenance for a dedup key and accounts for a
|
|
64
|
+
* cross-child suppression. Returns false when the key was already seen (the
|
|
65
|
+
* marker is a dedup hit), true on first sight. On a dedup hit whose raw
|
|
66
|
+
* session differs from the stored provenance, the pending cross-child
|
|
67
|
+
* suppression counter is incremented — but only for gate-kind keys (see the
|
|
68
|
+
* MED note below); re-delivery by the same raw session stays silent.
|
|
69
|
+
* @param state Mutable gate state for the parent session.
|
|
70
|
+
* @param key The dedup key (scope + marker identity).
|
|
71
|
+
* @param raw Raw session ID that produced the marker.
|
|
72
|
+
* @param gate True when the key gates a gate-kind marker (a `GATE:` match),
|
|
73
|
+
* false for a verdict-kind marker (a `VERDICT:` match).
|
|
74
|
+
* @returns True on first sight (the caller should record it); false on a dedup hit.
|
|
75
|
+
*/
|
|
76
|
+
function observeMarker(state: GateState, key: string, raw: string, gate: boolean): boolean {
|
|
77
|
+
if (state.seen.has(key)) {
|
|
78
|
+
// Cross-child suppression is charged to `suppressedPending` only for
|
|
79
|
+
// gate-kind hits: a verdict-kind dedup hit is suppressed
|
|
80
|
+
// here and owned by the marker-gated store-diff layer, so it must not bleed
|
|
81
|
+
// into the next emitted event's `suppressed` field (which, for a gate
|
|
82
|
+
// event, is the gate-event `suppressed` reported in the journal).
|
|
83
|
+
if (gate && raw && state.seenFrom.get(key) !== raw) state.suppressedPending += 1
|
|
84
|
+
return false
|
|
85
|
+
}
|
|
86
|
+
state.seen.add(key)
|
|
87
|
+
state.seenFrom.set(key, raw)
|
|
88
|
+
return true
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Reads and zeroes the pending cross-child suppression counter. Returns the
|
|
93
|
+
* count accumulated since the last emitted event (0 on a fresh bucket).
|
|
94
|
+
* @param state Mutable gate state for the parent session.
|
|
95
|
+
* @returns The pending suppression count, leaving `suppressedPending` at 0.
|
|
96
|
+
*/
|
|
97
|
+
function takePending(state: GateState): number {
|
|
98
|
+
const pending = state.suppressedPending
|
|
99
|
+
state.suppressedPending = 0
|
|
100
|
+
return pending
|
|
101
|
+
}
|
|
102
|
+
|
|
48
103
|
/**
|
|
49
104
|
* Parses GATE and VERDICT markers out of assistant text, mutating the given
|
|
50
|
-
* gate state (dedup set, gate-pass flag,
|
|
51
|
-
* first-sight observations for the caller to journal. The reviewer round
|
|
105
|
+
* gate state (dedup set, gate-pass flag, first-sight provenance) and returning
|
|
106
|
+
* only first-sight observations for the caller to journal. The reviewer round
|
|
52
107
|
* cap is counted per dispatch in `gates.ts`, not here.
|
|
53
108
|
*
|
|
54
109
|
* Only the last GATE/VERDICT match per text is observed (latest-wins);
|
|
@@ -63,11 +118,28 @@ export function agentFromTaskInput(input: TaskInput | undefined | null): string
|
|
|
63
118
|
* byte-identical marker is still journaled, while streaming sources share
|
|
64
119
|
* the empty scope.
|
|
65
120
|
*
|
|
121
|
+
* Provenance note: the bucket `seen`/`seenFrom`/`suppressedPending` set now
|
|
122
|
+
* tracks gate-kind first-sight and cross-child suppression (the pending
|
|
123
|
+
* counter charges only gate-kind hits, so verdict-kind cross-child hits never
|
|
124
|
+
* surface as a gate-event suppression). Streaming VERDICT dedup keys on
|
|
125
|
+
* `(scope, marker)`, so identical re-delivery and a cross-source same-marker
|
|
126
|
+
* replay are both suppressed here, and only a marker flip is emitted for the
|
|
127
|
+
* setup layer to journal; the store records source changes for attribution but
|
|
128
|
+
* journaling stays marker-gated (the store records source changes for
|
|
129
|
+
* attribution; the journal sees only marker flips).
|
|
130
|
+
*
|
|
66
131
|
* @param state Mutable gate state for the parent session.
|
|
67
132
|
* @param text The assistant text to scan.
|
|
68
133
|
* @param gateRe The gate marker regex (defaults to the canonical GATE_RE).
|
|
69
134
|
* @param verdictRe The verdict marker regex (defaults to VERDICT_RE).
|
|
70
135
|
* @param dedupScope Key prefix partitioning the dedup set (default "").
|
|
136
|
+
* @param raw Raw session ID that produced the marker (child provenance).
|
|
137
|
+
* @param passVerified PASS flip switch: false (enforce-only) flags the last
|
|
138
|
+
* unverified claim without flipping; undefined (flag/off) or true flips.
|
|
139
|
+
* @param stampVerified Journal stamp for the PASS event, or undefined when the
|
|
140
|
+
* event must stay byte-identical to legacy output (the `"off"` mode).
|
|
141
|
+
* @param stampMode Evidence mode at stamp time (replay uses it to distinguish
|
|
142
|
+
* enforce non-flipping entries from flag flipping ones).
|
|
71
143
|
* @returns Marker events observed for the first time in this text.
|
|
72
144
|
*/
|
|
73
145
|
export function parseGateMarkers(
|
|
@@ -76,6 +148,10 @@ export function parseGateMarkers(
|
|
|
76
148
|
gateRe: RegExp = GATE_RE,
|
|
77
149
|
verdictRe: RegExp = VERDICT_RE,
|
|
78
150
|
dedupScope = "",
|
|
151
|
+
raw = "",
|
|
152
|
+
passVerified?: boolean,
|
|
153
|
+
stampVerified?: boolean,
|
|
154
|
+
stampMode?: GateEvidenceMode,
|
|
79
155
|
): MarkerEvent[] {
|
|
80
156
|
const events: MarkerEvent[] = []
|
|
81
157
|
if (!text) return events
|
|
@@ -85,27 +161,53 @@ export function parseGateMarkers(
|
|
|
85
161
|
const verdict = verdicts[verdicts.length - 1] ?? null
|
|
86
162
|
if (gate) {
|
|
87
163
|
const key = `${dedupScope}gate\u0000${gate}`
|
|
88
|
-
if (
|
|
89
|
-
state.seen.add(key)
|
|
164
|
+
if (observeMarker(state, key, raw, true)) {
|
|
90
165
|
const result: "PASS" | "FAIL" = gate.includes("PASS") ? "PASS" : "FAIL"
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
166
|
+
if (result === "PASS") {
|
|
167
|
+
// passVerified === false (enforce-only) flags the last unverified
|
|
168
|
+
// claim without flipping; undefined (flag/off) or true flips as before.
|
|
169
|
+
if (passVerified === false) {
|
|
170
|
+
state.unverifiedPass = gate
|
|
171
|
+
} else {
|
|
172
|
+
state.hasGatePass = true
|
|
173
|
+
state.unverifiedPass = ""
|
|
174
|
+
}
|
|
175
|
+
// Stamp verified/mode only when the caller supplied a stamp: the
|
|
176
|
+
// `"off"` mode leaves both undefined for byte-identical legacy output.
|
|
177
|
+
const stamp =
|
|
178
|
+
stampVerified !== undefined ? { verified: stampVerified, mode: stampMode } : {}
|
|
179
|
+
events.push({
|
|
180
|
+
kind: "gate",
|
|
181
|
+
marker: gate,
|
|
182
|
+
result,
|
|
183
|
+
suppressed: gates.length - 1 + takePending(state),
|
|
184
|
+
...stamp,
|
|
185
|
+
})
|
|
186
|
+
} else {
|
|
187
|
+
// A re-run's FAIL un-greens a gate set by an earlier PASS, even across
|
|
188
|
+
// different texts: the latest observed result wins. Clears any
|
|
189
|
+
// unverified PASS left by an earlier enforce-only rejection.
|
|
190
|
+
state.hasGatePass = false
|
|
191
|
+
state.unverifiedPass = ""
|
|
192
|
+
events.push({
|
|
193
|
+
kind: "gate",
|
|
194
|
+
marker: gate,
|
|
195
|
+
result,
|
|
196
|
+
suppressed: gates.length - 1 + takePending(state),
|
|
197
|
+
})
|
|
198
|
+
}
|
|
95
199
|
}
|
|
96
200
|
}
|
|
97
201
|
if (verdict) {
|
|
98
202
|
const key = `${dedupScope}verdict\u0000${verdict}`
|
|
99
|
-
if (
|
|
100
|
-
state.seen.add(key)
|
|
101
|
-
state.verdicts.push(verdict)
|
|
203
|
+
if (observeMarker(state, key, raw, false)) {
|
|
102
204
|
// `rounds` reflects the dispatch-based reviewer round count; verdicts
|
|
103
205
|
// no longer increment it (cap is enforced per dispatch in gates.ts).
|
|
104
206
|
events.push({
|
|
105
207
|
kind: "verdict",
|
|
106
208
|
marker: verdict,
|
|
107
209
|
rounds: state.reviewRounds,
|
|
108
|
-
suppressed: verdicts.length - 1,
|
|
210
|
+
suppressed: verdicts.length - 1 + takePending(state),
|
|
109
211
|
})
|
|
110
212
|
}
|
|
111
213
|
}
|
package/src/policy.ts
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Orchestrator-side turnstile policy, exported for runtime
|
|
3
|
+
* injection by the plugin (prepended into the parent session's
|
|
4
|
+
* task/subagent result). This is the runtime source of truth, not a file
|
|
5
|
+
* written to AGENTS.md at install: a fresh install can never lose orchestrator
|
|
6
|
+
* policy because the plugin supplies it whenever it is loaded. Per-agent
|
|
7
|
+
* instruction lines live in each `agents/<name>.md`. Marker wire format is
|
|
8
|
+
* unchanged; the plugin parses markers from output text regardless of which
|
|
9
|
+
* layer told the agent to emit them. Every GATE:/VERDICT: example uses `<n>`
|
|
10
|
+
* placeholders so an echoed copy never parses into a real gate/verdict —
|
|
11
|
+
* `parseMarkers` runs on any session text.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Orchestrator policy injected into the parent session per task.
|
|
16
|
+
*
|
|
17
|
+
* One line per concern: the injected block is prepended to the model-facing
|
|
18
|
+
* task output every task, so it costs tokens continuously. Marker examples use
|
|
19
|
+
* `<n>` placeholders (never concrete digits, never a pipe-joined PASS|FAIL) so
|
|
20
|
+
* `parseMarkers` on streamed session text can never mistake an echo for a gate.
|
|
21
|
+
*/
|
|
22
|
+
export const ORCHESTRATOR_POLICY: string = `Orchestrator policy is injected by the turnstile plugin (this runtime policy, not a file on disk): a fresh install can never lose it, because the plugin supplies it whenever it is loaded. Per-agent instruction lines live in each agents/<name>.md. Marker wire format is unchanged; the plugin parses markers from output text no matter which layer told the agent to emit them.
|
|
23
|
+
|
|
24
|
+
Pipeline order: planner → plan-reviewer → executor → test-runner → reviewer.
|
|
25
|
+
Waivers and approvals: a waiver or a human plan approval is a user-authored prompt the plugin observes at prompt admission — put the literal marker in the user's OWN prompt to obtain one (\`USER WAIVER: <your words>\` to waive a gate, \`PLAN APPROVED: <your words>\` to approve the plan). A dispatch prompt from an agent that merely contains the marker unlocks nothing; do not fabricate, paste, or relay a waiver/approval marker to open a gate — escalate to the user if none holds.
|
|
26
|
+
Plan gate: dispatch executor only after this task's plan-reviewer first line is \`VERDICT: APPROVE crit=<n> high=<n> med=<n> low=<n>\`, or a human approval (see waivers and approvals above: a \`PLAN APPROVED:\` in the user's prompt, or a \`USER WAIVER:\` waiver). If no approval holds, the first executor dispatch escalates to the human — see human-approval escalation below; do not attempt to bypass or auto-approve. plan-file changes invalidate the approval (re-plan, then re-dispatch plan-reviewer before resuming executor work).
|
|
27
|
+
Human-approval escalation: when turnstile raises the plan gate through OpenCode's permission prompt, that prompt appears with the plan file + head and the human decides the outcome (reply \`always\` to approve, \`once\` for a single dispatch, or \`reject\`); respond to it honestly, do not attempt to bypass it or auto-approve.
|
|
28
|
+
Dispatch gate: dispatch reviewer only after this task's test-runner first line is \`GATE: PASS tests=<n> passed=<n> failed=<n>\`, or an explicit user waiver (from the user's prompt, per waivers and approvals); max 2 reviewer dispatches per task (waived dispatches don't count toward the cap; a new user prompt resets it), then escalate the open findings to the user.
|
|
29
|
+
Marker parsing (orchestrator): the test-runner emits a machine-parseable first line, one of \`GATE: PASS tests=<n> passed=<n> failed=<n>\` or \`GATE: FAIL tests=<n> passed=<n> failed=<n>\`; a reviewer and a plan-reviewer each emit \`VERDICT: APPROVE crit=<n> high=<n> med=<n> low=<n>\` or \`VERDICT: BLOCK crit=<n> high=<n> med=<n> low=<n>\` on their first line. All numeric fields are <n> placeholders.
|
|
30
|
+
Reviewer ensemble: dispatch two reviewer subagents on the same diff; merge one finding per file+symbol at max severity; a singleton CRITICAL/HIGH blocks only after re-reading the cited location; LOW singletons become UNCONFIRMED notes.
|
|
31
|
+
Worktree/commit: git worktrees will be used, but it does not matter how they get created; work inside the checkout and branch your session starts in; do not create/add/remove worktrees, switch branches, merge, or open PRs unless the user explicitly asks. On success, commit the change set with a conventional message — turnstile mechanically validates each subject as \`type(scope)!: subject\` (a \`-F\`/\`-t\`/\`--edit\`/interactive commit is uninspectable and blocked under \`gitGates.mode:\` \`enforce\`; pass it with \`git commit -m '<subject>'\`) and stop; if a worktree is in use, assert a \`WORKTREE:\` header for logging. Pushing this branch or a PR lands it on a shared remote, so with \`gitGates:\` on (default) the landing gate (\`git push\`, \`gh pr\`) waits for this task's reviewer verdict \`VERDICT: APPROVE crit=<n> high=<n> med=<n> low=<n>\` (a reviewer-delivered verdict only; streamed child text does not satisfy it) — turnstile raises the permission prompt on a repeat push with no approval, and the human decides the outcome (reply \`always\` for a standing approval, \`once\` for a single push, or \`reject\`); do not attempt to bypass or auto-approve.
|
|
32
|
+
Trace (works with the plugin off): append every dispatch/verdict to ~/.local/share/opencode/metrics/gate-events.jsonl as \`YYYY-MM-DD HH:MM | agent | event | detail\` (session-bound lines carry an optional trailing \` | <sessionID>\` segment). The plugin also writes ~/.local/share/opencode/metrics/turnstile.jsonl ({ts,sessionID,type,...}); read: grep/glob/rg first, whole-file reads last.
|
|
33
|
+
Verification: the host chain (npm run ci, which is npm run ci:host) covers lint, format:check, typecheck, check:markers, and test on a fresh checkout and writes nothing under ~/.config; the install-drift chain (npm run ci:installed, which is npm run install:check) runs after npm run init, where a machine already has artifacts installed to check.`
|