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/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
+ }