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.
@@ -0,0 +1,333 @@
1
+ #!/usr/bin/env node
2
+ // Read-only report of turnstile gate/plan state over the JSONL journal.
3
+ //
4
+ // This is the `turnstile status` surface (issue #34): it reports, per parent
5
+ // session, what the journal says a task currently is WITHOUT touching the host
6
+ // or writing anything. It is deliberately a plain ESM script outside the 100%
7
+ // coverage gate (bin/ and scripts/ never are), and it is exercised by
8
+ // tests/status.test.ts via spawn rather than imported.
9
+ //
10
+ // Journal resolution precedence (mirrors config.ts's env → file → default):
11
+ // TURNSTILE_METRICS_DIR → TURNSTILE_CONFIG's metricsDir → ~ default
12
+ // The journal file is `turnstile.jsonl`; its rotated generation
13
+ // `${journal}.1` is read first so the 2000-line replay tail holds across both.
14
+ //
15
+ // opencode-turnstile status Human-readable table.
16
+ // opencode-turnstile status --json Machine-readable JSON.
17
+ // opencode-turnstile status --help This usage. Unknown flags exit 2.
18
+
19
+ import * as fs from "node:fs"
20
+ import * as os from "node:os"
21
+ import * as path from "node:path"
22
+
23
+ const USAGE = `Usage: opencode-turnstile status [--json]
24
+
25
+ Reports turnstile gate and plan state per parent session, read from the
26
+ JSONL journal. Read-only: nothing is written. The journal reflects the last
27
+ prompt-cycle; in-memory state can be fresher.
28
+
29
+ Options:
30
+ --json Print machine-readable JSON instead of the table
31
+ --help Show this help`
32
+
33
+ /**
34
+ * Resolves the default metrics directory (copy of the journaler default so the
35
+ * standalone script needs no TS source). Honors an absolute XDG_DATA_HOME or
36
+ * HOME; anything relative falls back to the OS-provided value.
37
+ * @param {Record<string, string | undefined>} env Environment to consult.
38
+ * @returns {string} The absolute default metrics directory.
39
+ */
40
+ function defaultMetricsDir(env) {
41
+ const abs = (v) => (typeof v === "string" && path.isAbsolute(v) ? v : undefined)
42
+ const home = abs(env.HOME) ?? os.homedir()
43
+ const base = abs(env.XDG_DATA_HOME) ?? path.join(home, ".local/share")
44
+ return path.join(base, "opencode", "metrics")
45
+ }
46
+
47
+ /**
48
+ * Resolves the metrics directory that holds the journal, honoring the same
49
+ * precedence the config layer uses: the TURNSTILE_METRICS_DIR env beats a
50
+ * configured metricsDir in the TURNSTILE_CONFIG file, which beats the default.
51
+ * @param {NodeJS.ProcessEnv} env The process environment.
52
+ * @returns {string} The resolved metrics directory.
53
+ */
54
+ function resolveMetricsDir(env) {
55
+ if (typeof env.TURNSTILE_METRICS_DIR === "string" && env.TURNSTILE_METRICS_DIR) {
56
+ return env.TURNSTILE_METRICS_DIR
57
+ }
58
+ const configPath =
59
+ env.TURNSTILE_CONFIG ?? path.join(os.homedir(), ".config", "opencode", "turnstile.json")
60
+ try {
61
+ const cfg = JSON.parse(fs.readFileSync(configPath, "utf8"))
62
+ if (cfg && typeof cfg.metricsDir === "string" && cfg.metricsDir) return cfg.metricsDir
63
+ } catch {
64
+ // missing/unreadable/malformed config: fall through to the default.
65
+ }
66
+ return defaultMetricsDir(env)
67
+ }
68
+
69
+ /**
70
+ * Reads the journal file and its `.1` generation as a single oldest-first line
71
+ * stream, applying the same 2000-line tail bound the runtime replay uses so the
72
+ * status flags stay replay-parity. Silently tolerates a missing generation.
73
+ * @param {string} journalPath Absolute path of `turnstile.jsonl`.
74
+ * @returns {{ files: string[], lines: string[] }} The files read and their lines.
75
+ */
76
+ function readJournal(journalPath) {
77
+ const generations = [`${journalPath}.1`, journalPath].filter((p) => fs.existsSync(p))
78
+ if (generations.length === 0) return { files: [], lines: [] }
79
+ const lines = generations.flatMap((p) => fs.readFileSync(p, "utf8").split("\n").filter(Boolean))
80
+ return { files: generations.map((p) => path.basename(p)), lines: lines.slice(-2000) }
81
+ }
82
+
83
+ /**
84
+ * Accumulator for one parent session, seeded to the empty status state.
85
+ * @returns {Record<string, unknown>} Fresh session accumulator.
86
+ */
87
+ function freshSession() {
88
+ return {
89
+ lastGateResult: null,
90
+ hasGatePass: false,
91
+ lastPlanResult: null,
92
+ hasPlanVerdict: false,
93
+ planApproval: { effective: null, verdict: null, source: null, promptApproval: false },
94
+ lastGate: { marker: "", ts: "" },
95
+ lastVerdict: { marker: "", ts: "" },
96
+ waivers: [],
97
+ dispatches: 0,
98
+ blocks: 0,
99
+ permissionAsks: 0,
100
+ permissionDecisions: { once: 0, always: 0, reject: 0 },
101
+ }
102
+ }
103
+
104
+ /**
105
+ * Aggregates the journal lines into per-session status. Replays the same
106
+ * gate/plan last-wins rules as state.ts's replayGatePasses: a trailing
107
+ * `GATE: FAIL` keeps the gate red, an enforce-mode `verified:false` PASS never
108
+ * flips, and a `type:plan` APPROVE restores plan approval. Malformed lines are
109
+ * skipped. Parent-keyed.
110
+ * @param {string[]} lines Journal lines, oldest-first.
111
+ * @returns {Map<string, Record<string, unknown>>} sessionID → accumulator.
112
+ */
113
+ function aggregate(lines) {
114
+ const sessions = new Map()
115
+ const forSession = (id) => {
116
+ let a = sessions.get(id)
117
+ if (!a) {
118
+ a = freshSession()
119
+ sessions.set(id, a)
120
+ }
121
+ return a
122
+ }
123
+
124
+ for (const line of lines) {
125
+ let e
126
+ try {
127
+ e = JSON.parse(line)
128
+ } catch {
129
+ continue // malformed line: skip, replay-parity.
130
+ }
131
+ if (!e || typeof e !== "object" || Array.isArray(e)) continue
132
+ const sid = typeof e.sessionID === "string" && e.sessionID ? e.sessionID : ""
133
+ if (!sid) continue
134
+ const a = forSession(sid)
135
+ const type = e.type
136
+ const result = e.result
137
+
138
+ if (type === "gate") {
139
+ // Gate flags restored exactly like replayGatePasses: last flipping result
140
+ // wins; an enforce-mode unverified PASS neither flips here nor at runtime.
141
+ if (result === "PASS" || result === "FAIL") {
142
+ const flips = !(result === "PASS" && e.verified === false && e.mode !== "flag")
143
+ if (flips) {
144
+ a.lastGateResult = result
145
+ a.hasGatePass = result === "PASS"
146
+ }
147
+ }
148
+ if (result === "WAIVED") {
149
+ a.waivers.push({
150
+ channel: typeof e.channel === "string" ? e.channel : "",
151
+ waiver: typeof e.waiver === "string" ? e.waiver : "",
152
+ })
153
+ } else if (typeof e.marker === "string" && e.marker) {
154
+ a.lastGate.marker = e.marker
155
+ a.lastGate.ts = typeof e.ts === "string" ? e.ts : ""
156
+ }
157
+ } else if (type === "plan") {
158
+ // Plan flags restored like replayGatePasses: last APPROVE/BLOCK wins.
159
+ if (result === "APPROVE" || result === "BLOCK") {
160
+ a.lastPlanResult = result
161
+ a.hasPlanVerdict = result === "APPROVE"
162
+ a.planApproval.effective = result
163
+ a.planApproval.verdict = result
164
+ a.planApproval.source = typeof e.source === "string" ? e.source : null
165
+ // A `PLAN APPROVED:` in the user prompt is journaled as a type:plan
166
+ // APPROVE (source "session-prompt"); replay restores it like any plan
167
+ // APPROVE above, and the status display surfaces the human provenance.
168
+ if (result === "APPROVE" && e.source === "session-prompt") {
169
+ a.planApproval.promptApproval = true
170
+ }
171
+ } else if (result === "WAIVED") {
172
+ a.waivers.push({ channel: typeof e.channel === "string" ? e.channel : "", waiver: "" })
173
+ }
174
+ } else if (type === "verdict") {
175
+ // VERDICT: APPROVE/BLOCK observations (plan-reviewer verdict markers).
176
+ const marker = typeof e.marker === "string" && e.marker ? e.marker : ""
177
+ if (marker) {
178
+ a.lastVerdict.marker = marker
179
+ a.lastVerdict.ts = typeof e.ts === "string" ? e.ts : ""
180
+ }
181
+ if (marker.includes("APPROVE")) {
182
+ a.planApproval.effective = "APPROVE"
183
+ a.planApproval.verdict = "APPROVE"
184
+ } else {
185
+ a.planApproval.effective = "BLOCK"
186
+ a.planApproval.verdict = "BLOCK"
187
+ }
188
+ a.planApproval.source = typeof e.source === "string" ? e.source : a.planApproval.source
189
+ } else if (type === "dispatch") {
190
+ a.dispatches++
191
+ } else if (type === "blocked") {
192
+ a.blocks++
193
+ } else if (type === "permission") {
194
+ if (e.action === "ask") {
195
+ a.permissionAsks++
196
+ } else if (e.decision === "once" || e.decision === "always" || e.decision === "reject") {
197
+ a.permissionDecisions[e.decision]++
198
+ }
199
+ }
200
+ }
201
+
202
+ return sessions
203
+ }
204
+
205
+ /**
206
+ * Truncates a session ID for a table cell (JSON keeps the full ID).
207
+ * @param {string} id The raw session ID.
208
+ * @returns {string} The display ID.
209
+ */
210
+ function fmtSession(id) {
211
+ return id.length > 12 ? `${id.slice(0, 9)}…` : id
212
+ }
213
+
214
+ /**
215
+ * Renders the human-readable table.
216
+ * @param {Map<string, Record<string, unknown>>} sessions Aggregated sessions.
217
+ * @param {string} journalPath The journal file path for the header.
218
+ * @returns {string} The table text.
219
+ */
220
+ function renderTable(sessions, journalPath) {
221
+ const rows = [...sessions.entries()].map(([id, a]) => ({
222
+ session: fmtSession(id),
223
+ gate: a.hasGatePass ? "PASS" : "red",
224
+ plan: a.hasPlanVerdict ? "APPROVE" : (a.planApproval.effective ?? "·"),
225
+ verdict: a.planApproval.effective ?? "·",
226
+ wv: a.waivers.length,
227
+ dis: a.dispatches,
228
+ bl: a.blocks,
229
+ ask: a.permissionAsks,
230
+ dec: a.permissionDecisions.once + a.permissionDecisions.always + a.permissionDecisions.reject,
231
+ }))
232
+
233
+ const headers = ["SESSION", "GATE", "PLAN", "VERDICT", "WV", "DIS", "BLK", "PERM"]
234
+ const cells = [
235
+ (r) => r.session,
236
+ (r) => r.gate,
237
+ (r) => r.plan,
238
+ (r) => r.verdict,
239
+ (r) => String(r.wv),
240
+ (r) => String(r.dis),
241
+ (r) => String(r.bl),
242
+ (r) => `${r.ask}/${r.dec}`,
243
+ ]
244
+
245
+ const widths = headers.map((h, i) => Math.max(h.length, ...rows.map((r) => cells[i](r).length)))
246
+ const line = (vals) =>
247
+ vals
248
+ .map((v, i) => v.padEnd(widths[i]))
249
+ .join(" ")
250
+ .replace(/\s+$/, "")
251
+
252
+ const out = [`turnstile status — ${journalPath}`]
253
+ if (rows.length === 0) {
254
+ out.push("(no sessions recorded yet)")
255
+ } else {
256
+ out.push(line(headers))
257
+ for (const r of rows) out.push(line(cells.map((c) => c(r))))
258
+ }
259
+ out.push("", "journal reflects the last prompt-cycle; in-memory state can be fresher.")
260
+ return out.join("\n")
261
+ }
262
+
263
+ /**
264
+ * Parses the status arguments. Recognizes --json and --help; anything else is
265
+ * an unknown flag and fails with exit code 2.
266
+ * @param {string[]} argv Arguments after the `status` subcommand.
267
+ * @returns {{ json: boolean, help: boolean }} Parsed flags.
268
+ */
269
+ function parseArgs(argv) {
270
+ const json = argv.includes("--json")
271
+ const help = argv.includes("--help")
272
+ for (const a of argv) {
273
+ if (a === "--json" || a === "--help") continue
274
+ throw new Error(`unknown argument "${a}"`)
275
+ }
276
+ return { json, help }
277
+ }
278
+
279
+ /**
280
+ * Entry point. Parses args, resolves the journal, aggregates, and prints.
281
+ * @param {string[]} argv Arguments after the `status` subcommand.
282
+ * @returns {Promise<number>} The process exit code.
283
+ */
284
+ async function main(argv) {
285
+ let flags
286
+ try {
287
+ flags = parseArgs(argv)
288
+ } catch (e) {
289
+ const msg = e instanceof Error ? e.message : String(e)
290
+ process.stderr.write(`${msg}\n`)
291
+ process.stderr.write("usage: opencode-turnstile status [--json]\n")
292
+ return 2
293
+ }
294
+ if (flags.help) {
295
+ process.stdout.write(`${USAGE}\n`)
296
+ return 0
297
+ }
298
+
299
+ const metricsDir = resolveMetricsDir(process.env)
300
+ const journalPath = path.join(metricsDir, "turnstile.jsonl")
301
+ const { files, lines } = readJournal(journalPath)
302
+
303
+ const base = {
304
+ journal: journalPath,
305
+ files,
306
+ noJournal: files.length === 0,
307
+ sessions: {},
308
+ }
309
+
310
+ if (files.length === 0) {
311
+ // Read-only no-op: nothing recorded yet.
312
+ if (flags.json) {
313
+ process.stdout.write(`${JSON.stringify(base, null, 2)}\n`)
314
+ } else {
315
+ process.stdout.write(`No turnstile journal yet at ${journalPath} — nothing to report.\n`)
316
+ }
317
+ return 0
318
+ }
319
+
320
+ const sessions = aggregate(lines)
321
+ for (const [id, a] of sessions) {
322
+ base.sessions[id] = a
323
+ }
324
+
325
+ if (flags.json) {
326
+ process.stdout.write(`${JSON.stringify(base, null, 2)}\n`)
327
+ } else {
328
+ process.stdout.write(`${renderTable(sessions, journalPath)}\n`)
329
+ }
330
+ return 0
331
+ }
332
+
333
+ main(process.argv.slice(2)).then((code) => process.exit(code ?? 0))
@@ -1,14 +1,15 @@
1
1
  ---
2
2
  name: turnstile-setup
3
- description: Install or update the turnstile plugin, pipeline agents, and contract block into the user's ~/.config/opencode, then verify sync. Use when the user asks to set up turnstile, add pipeline gates to AGENTS.md, refresh the installed contract block, or after changing marker syntax in this repo.
3
+ description: Install the turnstile plugin and pipeline agents into the user's ~/.config/opencode and verify sync. Use when the user asks to set up turnstile, run the pipeline gates, add pipeline gates to a session, or after changing marker syntax in this repo. The plugin injects the orchestrator policy at runtime (via the execute.after hook); it does not write a contract block into AGENTS.md.
4
4
  ---
5
5
 
6
6
  # Turnstile setup
7
7
 
8
- Install the artifacts this repo owns into `~/.config/opencode` — the
9
- plugin and pipeline agents as symlinks (`scripts/install.mjs`, driven by
10
- `install.json`), and the contract block this plugin enforces into the
11
- user's global instructions file (`scripts/install-agents.mjs`).
8
+ Install the artifacts this repo owns into `~/.config/opencode` — the plugin and
9
+ the pipeline agent definitions — as symlinks (`scripts/install.mjs`, driven by
10
+ `install.json`). This repo no longer owns a contract block: the orchestrator
11
+ policy is injected at runtime by the plugin, not written to the user's global
12
+ instructions file (`~/.config/opencode/AGENTS.md`) at install time.
12
13
 
13
14
  ## Steps
14
15
 
@@ -28,49 +29,32 @@ user's global instructions file (`scripts/install-agents.mjs`).
28
29
  - Verify with `npm run install:check` (add `-- --copy` for copy mode)
29
30
  — must exit 0.
30
31
 
31
- 2. Install the contract block into the global AGENTS.md:
32
+ 2. Confirm the runtime policy. The plugin injects the orchestrator policy
33
+ into the primary session per task from `src/policy.ts`; there is no
34
+ block to verify on disk. If the user's `~/.config/opencode/AGENTS.md`
35
+ still contains a legacy turnstile sentinel block (from an install before
36
+ the runtime-injection change), delete it by hand — the plugin does not
37
+ read or manage it, and there is deliberately no installer to remove it:
32
38
 
33
39
  ```
34
- node scripts/install-agents.mjs
40
+ # ~/.config/opencode/AGENTS.md — delete the section between the
41
+ # `<!-- turnstile:start -->` and `<!-- turnstile:end -->` sentinels
35
42
  ```
36
43
 
37
- - Default target is `~/.config/opencode/AGENTS.md`; pass
38
- `--file <path>` only if the user asks for a different file.
39
- - Preflight: adoption is automatic. When the target already contains
40
- turnstile contract content but **no** `<!-- turnstile:start -->` …
41
- `<!-- turnstile:end -->` sentinels, the installer adopts the section:
42
- it wraps an equivalent section with the sentinels (no content
43
- change), and for a section that differs it backs up the target first
44
- (`<file>.bak.<timestamp>`) and replaces the span with the current
45
- template. Run with `--check` to preview without writing, or
46
- `--no-adopt` to refuse and get reconciliation instructions instead.
47
- - The installer only refuses (exit 1, no write) for targets it cannot
48
- resolve safely: a corrupted one — a start sentinel with no end
49
- sentinel — or an ambiguous one — more than one turnstile section.
50
- Fix one of those by hand, then re-run.
51
- - The script touches only the managed block; everything else in the
52
- target file is preserved.
53
-
54
- 3. Verify the installed block matches the template:
55
-
56
- ```
57
- node scripts/install-agents.mjs --check
58
- ```
59
-
60
- Must exit 0. If it exits 1, re-run step 2 and investigate rather than
61
- hand-editing the installed file.
62
-
63
- 4. Verify the marker contract is still in sync with the parser:
44
+ 3. Verify the marker contract is still in sync with the parser:
64
45
 
65
46
  ```
66
47
  node scripts/check-markers.mjs
67
48
  ```
68
49
 
69
- Must pass. If it fails, the README's marker examples and the regex
50
+ Must pass. It validates the README's concrete examples, the `<n>`-placeholder
51
+ templates in `agents/*.md`, and those in `src/policy.ts` against the parser
52
+ regexes in `src/markers.ts`. If it fails, the README examples and the regex
70
53
  literals in `src/markers.ts` have drifted — fix both together.
71
54
 
72
55
  ## Report
73
56
 
74
- Tell the user: which files were linked or written, whether each was
75
- created or updated, and that OpenCode sessions restart to pick up the
76
- change. Do not quote the whole installed block back; summarize.
57
+ Tell the user: which files were linked or copied (created or updated), that the
58
+ orchestrator policy is injected at runtime (no instructions-file block), and —
59
+ if a legacy sentinel block was present — that it was removed by hand. Do not
60
+ quote the injected policy back; summarize.