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/.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 +412 -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/src/journal.ts
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Telemetry boundary: JSONL journal and human-readable trace
|
|
3
|
+
* persistence. A `Journaler` instance is created per plugin setup from the
|
|
4
|
+
* resolved config and injected into the state, gate, and wiring layers,
|
|
5
|
+
* so no module-level paths exist and tests can redirect all writes. Journaling is
|
|
6
|
+
* fail-open by contract: no write ever breaks execution.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import * as fs from "node:fs"
|
|
10
|
+
import * as os from "node:os"
|
|
11
|
+
import * as path from "node:path"
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Resolves the default metrics directory from an environment. Relative
|
|
15
|
+
* `XDG_DATA_HOME` or `HOME` values are ignored: the XDG spec demands the
|
|
16
|
+
* former be absolute, and this plugin's contract is that the resulting
|
|
17
|
+
* path is never relative.
|
|
18
|
+
* @param env Environment to consult; defaults to `process.env`.
|
|
19
|
+
* @returns The absolute default metrics directory.
|
|
20
|
+
*/
|
|
21
|
+
export function defaultMetricsDir(env: Record<string, string | undefined> = process.env): string {
|
|
22
|
+
const abs = (v: string | undefined): string | undefined =>
|
|
23
|
+
v !== undefined && path.isAbsolute(v) ? v : undefined
|
|
24
|
+
const home = abs(env.HOME) ?? os.homedir()
|
|
25
|
+
const base = abs(env.XDG_DATA_HOME) ?? path.join(home, ".local/share")
|
|
26
|
+
return path.join(base, "opencode", "metrics")
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Default metrics directory, used only before config resolves (e.g. to
|
|
30
|
+
* journal a malformed config file). */
|
|
31
|
+
export const DEFAULT_METRICS_DIR: string = defaultMetricsDir()
|
|
32
|
+
|
|
33
|
+
/** Default JSONL journal path inside {@link DEFAULT_METRICS_DIR}. */
|
|
34
|
+
export const DEFAULT_JOURNAL_PATH: string = path.join(DEFAULT_METRICS_DIR, "turnstile.jsonl")
|
|
35
|
+
|
|
36
|
+
/** Rotation bound comment: a journal file rotates when it exceeds the
|
|
37
|
+
* configured byte size; generations `.1`…`.N` shift up, the oldest is
|
|
38
|
+
* pruned. Disk usage is bounded at (N + 1) × maxBytes per journal. The
|
|
39
|
+
* stat cost per append is negligible; the shift is a handful of renames
|
|
40
|
+
* that happens once per maxBytes of journal growth. */
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Returns the current time as an ISO-8601 timestamp.
|
|
44
|
+
* @returns The current UTC timestamp, e.g. `2026-10-08T12:00:00.000Z`.
|
|
45
|
+
*/
|
|
46
|
+
export function now(): string {
|
|
47
|
+
return new Date().toISOString()
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Rotates a journal file when it grows past the size limit, keeping at
|
|
52
|
+
* most `maxGenerations` numbered generations. Silent on any failure.
|
|
53
|
+
* @param file Path of the journal file to check and rotate.
|
|
54
|
+
* @param maxBytes Rotation threshold in bytes.
|
|
55
|
+
* @param maxGenerations Number of numbered generations to keep.
|
|
56
|
+
*/
|
|
57
|
+
export function rotateIfLarge(file: string, maxBytes: number, maxGenerations: number): void {
|
|
58
|
+
try {
|
|
59
|
+
const stat = fs.statSync(file)
|
|
60
|
+
if (stat.size <= maxBytes) return
|
|
61
|
+
// prune the oldest generation first so the shift below has a free slot
|
|
62
|
+
const oldest = `${file}.${maxGenerations}`
|
|
63
|
+
try {
|
|
64
|
+
fs.unlinkSync(oldest)
|
|
65
|
+
} catch {
|
|
66
|
+
// no generation at the limit yet; nothing to prune
|
|
67
|
+
}
|
|
68
|
+
for (let gen = maxGenerations - 1; gen >= 1; gen--) {
|
|
69
|
+
try {
|
|
70
|
+
fs.renameSync(`${file}.${gen}`, `${file}.${gen + 1}`)
|
|
71
|
+
} catch {
|
|
72
|
+
// missing generation: skip; lower generations still shift correctly
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
fs.renameSync(file, `${file}.1`)
|
|
76
|
+
} catch {
|
|
77
|
+
// missing file or stat failure: nothing to rotate
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Appends one line to a file, creating the parent directory first. The
|
|
83
|
+
* caller is responsible for rotating when a size bound applies (the
|
|
84
|
+
* JSONL journal and trace both call `rotateIfLarge` before appending).
|
|
85
|
+
* Silent on any failure: journaling must never break execution.
|
|
86
|
+
* @param file Path of the file to append to.
|
|
87
|
+
* @param text The line to append (a trailing newline is added).
|
|
88
|
+
*/
|
|
89
|
+
export function appendLine(file: string, text: string): void {
|
|
90
|
+
try {
|
|
91
|
+
fs.mkdirSync(path.dirname(file), { recursive: true })
|
|
92
|
+
fs.appendFileSync(file, text + "\n")
|
|
93
|
+
} catch {
|
|
94
|
+
// journaling must never break execution
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Telemetry writer injected into the state, gate, and wiring layers. */
|
|
99
|
+
export interface Journaler {
|
|
100
|
+
/** Absolute path of the JSONL journal file. */
|
|
101
|
+
journalPath: string
|
|
102
|
+
/** Absolute path of the human-readable trace file. */
|
|
103
|
+
tracePath: string
|
|
104
|
+
/**
|
|
105
|
+
* Appends one structured entry to the JSONL journal, timestamped.
|
|
106
|
+
* @param entry The journal entry; a `ts` field is prepended.
|
|
107
|
+
*/
|
|
108
|
+
journal(entry: Record<string, unknown>): void
|
|
109
|
+
/**
|
|
110
|
+
* Appends one line to the trace file in the AGENTS.md trace contract
|
|
111
|
+
* format (`YYYY-MM-DD HH:MM | agent | event | detail`). No-op when the
|
|
112
|
+
* config disables the trace.
|
|
113
|
+
* @param agent The acting agent label (e.g. `orchestrator`, `reviewer`).
|
|
114
|
+
* @param event The event kind (e.g. `dispatch`, `gate`, `verdict`).
|
|
115
|
+
* @param detail Free-form event detail, truncated to 200 characters.
|
|
116
|
+
*/
|
|
117
|
+
traceEvent(agent: string, event: string, detail: string): void
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Creates a telemetry writer bound to the given metrics directory.
|
|
122
|
+
* @param options.metricsDir Directory that receives both telemetry files.
|
|
123
|
+
* @param options.trace Whether trace lines are written (journal always is).
|
|
124
|
+
* @param options.maxBytes Rotation threshold per journal file.
|
|
125
|
+
* @param options.maxGenerations Rotated generations kept per journal file.
|
|
126
|
+
* @returns A Journaler writing `turnstile.jsonl` and `gate-events.jsonl`
|
|
127
|
+
* under `options.metricsDir`.
|
|
128
|
+
*/
|
|
129
|
+
export function createJournaler(options: {
|
|
130
|
+
metricsDir: string
|
|
131
|
+
trace: boolean
|
|
132
|
+
maxBytes: number
|
|
133
|
+
maxGenerations: number
|
|
134
|
+
}): Journaler {
|
|
135
|
+
const journalPath = path.join(options.metricsDir, "turnstile.jsonl")
|
|
136
|
+
const tracePath = path.join(options.metricsDir, "gate-events.jsonl")
|
|
137
|
+
return {
|
|
138
|
+
journalPath,
|
|
139
|
+
tracePath,
|
|
140
|
+
journal(entry: Record<string, unknown>): void {
|
|
141
|
+
rotateIfLarge(journalPath, options.maxBytes, options.maxGenerations)
|
|
142
|
+
appendLine(journalPath, JSON.stringify({ ts: now(), ...entry }))
|
|
143
|
+
},
|
|
144
|
+
traceEvent(agent: string, event: string, detail: string): void {
|
|
145
|
+
if (!options.trace) return
|
|
146
|
+
// The trace file shares the journal's rotation bound (AGENTS.md
|
|
147
|
+
// retention contract: bounded at (N + 1) × maxBytes per file).
|
|
148
|
+
rotateIfLarge(tracePath, options.maxBytes, options.maxGenerations)
|
|
149
|
+
const d = new Date()
|
|
150
|
+
const pad = (n: number): string => String(n).padStart(2, "0")
|
|
151
|
+
appendLine(
|
|
152
|
+
tracePath,
|
|
153
|
+
`${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())} | ${agent} | ${event} | ${String(detail).slice(0, 200)}`,
|
|
154
|
+
)
|
|
155
|
+
},
|
|
156
|
+
}
|
|
157
|
+
}
|
package/src/markers.ts
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Marker boundary: `GATE:`/`VERDICT:` marker parsing with
|
|
3
|
+
* per-task dedup, plus agent attribution helpers. Pure with respect to
|
|
4
|
+
* journal and filesystem, so callers journal the returned marker events.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { GateState, MarkerEvent, TaskInput } from "./types.ts"
|
|
8
|
+
|
|
9
|
+
// `export const` keeps the regex literals load-bearing for
|
|
10
|
+
// scripts/check-markers.mjs, which extracts them by name and asserts the
|
|
11
|
+
// README's marker examples still parse. Keep each regex on one line and
|
|
12
|
+
// edit the README examples and these literals together.
|
|
13
|
+
// prettier-ignore
|
|
14
|
+
export const GATE_RE = /^\s*[`>#*\s]*GATE: (PASS|FAIL) tests=\d+ passed=\d+ failed=\d+/gm
|
|
15
|
+
// prettier-ignore
|
|
16
|
+
export const VERDICT_RE = /^\s*[`>#*\s]*VERDICT: (APPROVE|BLOCK) crit=\d+ high=\d+ med=\d+ low=\d+/gm
|
|
17
|
+
|
|
18
|
+
/** Agents contracted to emit GATE/VERDICT markers; marker-less output from
|
|
19
|
+
* anything else is normal, not a violation. */
|
|
20
|
+
export const BARE_OUTPUT_AGENTS: readonly string[] = ["test-runner", "reviewer", "plan-reviewer"]
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Compiles a marker regex from a config-provided source string with `gm`
|
|
24
|
+
* flags. Production matchers use `String.match`, so `lastIndex` state is
|
|
25
|
+
* not an issue; tests must strip `g` before `.test`.
|
|
26
|
+
* @param source Regex source string (no delimiters, no flags).
|
|
27
|
+
* @param fallback Regex to return when the source fails to compile.
|
|
28
|
+
* @returns The compiled regex, or the fallback when the source is invalid.
|
|
29
|
+
*/
|
|
30
|
+
export function compileMarkerRegex(source: string, fallback: RegExp): RegExp {
|
|
31
|
+
try {
|
|
32
|
+
return new RegExp(source, "gm")
|
|
33
|
+
} catch {
|
|
34
|
+
return fallback
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Extracts the subagent name from a task dispatch input, trying the known
|
|
40
|
+
* host field spellings in order.
|
|
41
|
+
* @param input The dispatch input, or null/undefined.
|
|
42
|
+
* @returns The agent name as a string, or an empty string when unknown.
|
|
43
|
+
*/
|
|
44
|
+
export function agentFromTaskInput(input: TaskInput | undefined | null): string {
|
|
45
|
+
return String(input?.subagent_type ?? input?.agent ?? input?.subagent ?? "")
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Parses GATE and VERDICT markers out of assistant text, mutating the given
|
|
50
|
+
* gate state (dedup set, gate-pass flag, verdict list) and returning only
|
|
51
|
+
* first-sight observations for the caller to journal. The reviewer round
|
|
52
|
+
* cap is counted per dispatch in `gates.ts`, not here.
|
|
53
|
+
*
|
|
54
|
+
* Only the last GATE/VERDICT match per text is observed (latest-wins);
|
|
55
|
+
* earlier matches in the same text are reported via `suppressed` so the
|
|
56
|
+
* caller can journal their loss. Latest-wins also applies across texts:
|
|
57
|
+
* an observed `GATE: FAIL` clears a previously recorded `GATE: PASS`.
|
|
58
|
+
*
|
|
59
|
+
* Streaming sources (`message.part.updated`, text deltas) re-deliver the
|
|
60
|
+
* same accumulated text; dedup on marker identity ensures the journal only
|
|
61
|
+
* ever sees first sight. `dedupScope` partitions the dedup set: task-result
|
|
62
|
+
* deliveries pass a unique scope per delivery so a re-run emitting a
|
|
63
|
+
* byte-identical marker is still journaled, while streaming sources share
|
|
64
|
+
* the empty scope.
|
|
65
|
+
*
|
|
66
|
+
* @param state Mutable gate state for the parent session.
|
|
67
|
+
* @param text The assistant text to scan.
|
|
68
|
+
* @param gateRe The gate marker regex (defaults to the canonical GATE_RE).
|
|
69
|
+
* @param verdictRe The verdict marker regex (defaults to VERDICT_RE).
|
|
70
|
+
* @param dedupScope Key prefix partitioning the dedup set (default "").
|
|
71
|
+
* @returns Marker events observed for the first time in this text.
|
|
72
|
+
*/
|
|
73
|
+
export function parseGateMarkers(
|
|
74
|
+
state: GateState,
|
|
75
|
+
text: string,
|
|
76
|
+
gateRe: RegExp = GATE_RE,
|
|
77
|
+
verdictRe: RegExp = VERDICT_RE,
|
|
78
|
+
dedupScope = "",
|
|
79
|
+
): MarkerEvent[] {
|
|
80
|
+
const events: MarkerEvent[] = []
|
|
81
|
+
if (!text) return events
|
|
82
|
+
const gates = text.match(gateRe) ?? []
|
|
83
|
+
const verdicts = text.match(verdictRe) ?? []
|
|
84
|
+
const gate = gates[gates.length - 1] ?? null
|
|
85
|
+
const verdict = verdicts[verdicts.length - 1] ?? null
|
|
86
|
+
if (gate) {
|
|
87
|
+
const key = `${dedupScope}gate\u0000${gate}`
|
|
88
|
+
if (!state.seen.has(key)) {
|
|
89
|
+
state.seen.add(key)
|
|
90
|
+
const result: "PASS" | "FAIL" = gate.includes("PASS") ? "PASS" : "FAIL"
|
|
91
|
+
// A re-run's FAIL un-greens a gate set by an earlier PASS, even across
|
|
92
|
+
// different texts: the latest observed result wins.
|
|
93
|
+
state.hasGatePass = result === "PASS"
|
|
94
|
+
events.push({ kind: "gate", marker: gate, result, suppressed: gates.length - 1 })
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
if (verdict) {
|
|
98
|
+
const key = `${dedupScope}verdict\u0000${verdict}`
|
|
99
|
+
if (!state.seen.has(key)) {
|
|
100
|
+
state.seen.add(key)
|
|
101
|
+
state.verdicts.push(verdict)
|
|
102
|
+
// `rounds` reflects the dispatch-based reviewer round count; verdicts
|
|
103
|
+
// no longer increment it (cap is enforced per dispatch in gates.ts).
|
|
104
|
+
events.push({
|
|
105
|
+
kind: "verdict",
|
|
106
|
+
marker: verdict,
|
|
107
|
+
rounds: state.reviewRounds,
|
|
108
|
+
suppressed: verdicts.length - 1,
|
|
109
|
+
})
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
return events
|
|
113
|
+
}
|