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.
@@ -1,504 +0,0 @@
1
- #!/usr/bin/env node
2
- // Installs the turnstile contract block into a global AGENTS.md file.
3
- // Idempotent: only the sentinel-delimited managed block is written or
4
- // replaced; surrounding personal content is preserved. (Agent definitions
5
- // are installed as symlinks by scripts/install.mjs; this script no longer
6
- // copies them.)
7
- //
8
- // Usage:
9
- // node scripts/install-agents.mjs [--file <path>] [--check] [--remove] [--no-adopt]
10
- //
11
- // --file Target AGENTS.md (default: ~/.config/opencode/AGENTS.md)
12
- // --check Report the adoption decision without writing; exit 0 only when
13
- // the managed block matches the template.
14
- // --remove Delete the sentinel-delimited managed block (surrounding
15
- // content preserved); refuses a start-sentinel-without-end.
16
- // Idempotent: running on a file without the block is a no-op.
17
- // --no-adopt Refuse to replace a differing legacy section: print
18
- // reconciliation instructions and exit 1 instead of adopting.
19
-
20
- import { readFileSync, writeFileSync, mkdirSync, existsSync } from "node:fs"
21
- import { homedir } from "node:os"
22
- import { dirname, join } from "node:path"
23
- import { fileURLToPath, pathToFileURL } from "node:url"
24
-
25
- const START = "<!-- turnstile:start"
26
- const END = "<!-- turnstile:end -->"
27
-
28
- const root = join(dirname(fileURLToPath(import.meta.url)), "..")
29
- const templatePath = join(root, "templates", "global-AGENTS.md")
30
-
31
- // Legacy pipeline-chain signature: the current pipeline order (planner →
32
- // plan-reviewer → executor → test-runner → reviewer). It is one of two section
33
- // locators; the other is a heading matching /turnstile/i.
34
- const CHAIN_RE = /planner\s*→\s*plan-reviewer\s*→\s*executor\s*→\s*test-runner\s*→\s*reviewer/
35
-
36
- // Distinct errors so the caller can tell "ambiguous (multiple sections)" apart
37
- // from "corrupted (start sentinel without end sentinel)". Both exit 1 but with
38
- // different advice.
39
- class AmbiguousError extends Error {
40
- constructor(message) {
41
- super(message)
42
- this.name = "AmbiguousError"
43
- this.ambiguous = true
44
- }
45
- }
46
-
47
- class CorruptedError extends Error {
48
- constructor(message) {
49
- super(message)
50
- this.name = "CorruptedError"
51
- this.corrupted = true
52
- }
53
- }
54
-
55
- function parseArgs(argv) {
56
- const args = {
57
- file: join(homedir(), ".config/opencode/AGENTS.md"),
58
- check: false,
59
- remove: false,
60
- noAdopt: false,
61
- }
62
- for (let i = 2; i < argv.length; i++) {
63
- if (argv[i] === "--file") args.file = argv[++i]
64
- else if (argv[i] === "--check") args.check = true
65
- else if (argv[i] === "--remove") args.remove = true
66
- else if (argv[i] === "--no-adopt") args.noAdopt = true
67
- else {
68
- console.error(`install-agents: unknown argument ${argv[i]}`)
69
- process.exit(2)
70
- }
71
- }
72
- return args
73
- }
74
-
75
- /**
76
- * Extracts the managed block (including sentinel markers) from the template.
77
- * @returns {string} The exact block to install.
78
- */
79
- function readBlock() {
80
- const template = readFileSync(templatePath, "utf8")
81
- const start = template.indexOf(START)
82
- const end = template.indexOf(END)
83
- if (start === -1 || end === -1 || end < start) {
84
- throw new Error(`template is missing a complete managed block: ${templatePath}`)
85
- }
86
- return template.slice(start, end + END.length) + "\n"
87
- }
88
-
89
- /**
90
- * The managed block minus its sentinel comment lines: the canonical inner
91
- * content that a legacy section is compared against for equivalence. The
92
- * start comment may span several lines; inner begins at the first "-->" that
93
- * closes it and ends at the end sentinel. Pure.
94
- * @param {string} block The full managed block (sentinels included).
95
- * @returns {string} The inner content between the sentinel comments.
96
- */
97
- function templateInner(block) {
98
- const start = block.indexOf(START)
99
- const endCommentStart = block.lastIndexOf(END)
100
- const startCommentEnd = block.indexOf("-->", start) + "-->".length
101
- return block.slice(startCommentEnd, endCommentStart)
102
- }
103
-
104
- /**
105
- * Removes the sentinel-delimited managed block from the given file content,
106
- * preserving the surrounding content. When the block is absent this is a
107
- * no-op; a start sentinel without an end sentinel is refused (throw), the
108
- * same corruption guard `merge()` applies. Pure: no filesystem access.
109
- * @param {string} current Existing file content.
110
- * @param {string} [file] File label for the refusal message.
111
- * @returns {{removed: boolean, next: string}} Whether a block was found and the new content.
112
- */
113
- function removeBlock(current, file) {
114
- const start = current.indexOf(START)
115
- if (start === -1) return { removed: false, next: current }
116
- const end = current.indexOf(END, start)
117
- if (end === -1) {
118
- throw new Error(
119
- `${file ?? "file"}: found a start sentinel without an end sentinel — refusing to touch it; fix the file manually`,
120
- )
121
- }
122
- const prefix = current.slice(0, start).replace(/\n*$/, "")
123
- const suffix = current.slice(end + END.length).replace(/^\n/, "")
124
- const next = prefix && suffix ? `${prefix}\n${suffix}` : suffix || (prefix ? `${prefix}\n` : "")
125
- return { removed: true, next }
126
- }
127
-
128
- /**
129
- * Replaces or inserts the managed block in the given file content.
130
- * @param {string} current Existing file content ("" when the file is new).
131
- * @param {string} block The managed block to write.
132
- * @param {string} [file] File label for the refusal message.
133
- * @returns {string} The new file content.
134
- */
135
- function merge(current, block, file) {
136
- const start = current.indexOf(START)
137
- if (start === -1) {
138
- const separator = current.trim().length ? "\n\n" : ""
139
- return current.replace(/\n*$/, "\n") + separator + block
140
- }
141
- const end = current.indexOf(END, start)
142
- if (end === -1) {
143
- throw new Error(
144
- `${file ?? "file"}: found a start sentinel without an end sentinel — refusing to touch it; fix the file manually`,
145
- )
146
- }
147
- // The block ends with its own newline; strip one leading newline from
148
- // the suffix so an unchanged file stays byte-identical on rerun.
149
- const suffix = current.slice(end + END.length).replace(/^\n/, "")
150
- return current.slice(0, start) + block + suffix
151
- }
152
-
153
- /**
154
- * Splices the managed block into `content` at the given span, preserving
155
- * everything outside the span. Pure.
156
- * @param {string} content Existing file content.
157
- * @param {{start: number, end: number}} span The span to replace.
158
- * @param {string} block The managed block to insert.
159
- * @returns {string} The new content.
160
- */
161
- function installBlock(content, span, block) {
162
- return content.slice(0, span.start) + block + content.slice(span.end)
163
- }
164
-
165
- /**
166
- * Locates a legacy (unmanaged) turnstile contract section in `content`.
167
- * Returns { start, end, level } spanning the section, or null when none is
168
- * present. Throws AmbiguousError when more than one section matches (the
169
- * caller distinguishes this from the corrupted case, which `merge()`/
170
- * `removeBlock()` refuse). Pure: no filesystem access.
171
- * @param {string} content Existing file content.
172
- * @returns {{start: number, end: number, level: number} | null} The section span, or null.
173
- */
174
- function findLegacySection(content) {
175
- if (content.includes(START)) return null // a managed block is not "legacy"
176
- const headings = []
177
- for (const m of content.matchAll(/^#{1,3}\s+/gm)) {
178
- const start = m.index
179
- const lineEnd = content.indexOf("\n", start)
180
- const end = lineEnd === -1 ? content.length : lineEnd
181
- const level = m[0].match(/^#*/)[0].length
182
- headings.push({ start, end, level, text: content.slice(start, end) })
183
- }
184
- const candidates = []
185
- for (const h of headings) {
186
- // Span first (next heading of <= level), then probe inside it. Nested
187
- // turnstile-named subsections are excluded from the probe: their content
188
- // belongs to them, not to an enclosing higher-level heading; otherwise
189
- // a `# Title` above a turnstile section would swallow the chain and look
190
- // like a second section.
191
- let spanEnd = content.length
192
- for (const x of headings) {
193
- if (x.start > h.start && x.level <= h.level) {
194
- spanEnd = x.start
195
- break
196
- }
197
- }
198
- let probeText = content.slice(h.start, spanEnd)
199
- for (const x of headings) {
200
- if (x.start <= h.start || x.start >= spanEnd) continue
201
- if (!/turnstile/i.test(x.text)) continue
202
- let xEnd = content.length
203
- for (const y of headings) {
204
- if (y.start > x.start && y.level <= x.level) {
205
- xEnd = y.start
206
- break
207
- }
208
- }
209
- probeText = probeText.replace(content.slice(x.start, xEnd), "")
210
- }
211
- const isTurnstileHeading = /turnstile/i.test(h.text)
212
- const hasChain = CHAIN_RE.test(probeText)
213
- if (isTurnstileHeading || hasChain) candidates.push(h)
214
- }
215
- if (candidates.length === 0) return null
216
- if (candidates.length > 1) {
217
- throw new AmbiguousError(
218
- "found more than one turnstile contract section — ambiguous; reconcile manually.",
219
- )
220
- }
221
- const h = candidates[0]
222
- let end = content.length
223
- for (const x of headings) {
224
- if (x.start > h.start && x.level <= h.level) {
225
- end = x.start
226
- break
227
- }
228
- }
229
- return { start: h.start, end, level: h.level }
230
- }
231
-
232
- /**
233
- * Normalizes a section for equivalence comparison: drops turnstile sentinel
234
- * comment lines, collapses every whitespace run to a single space, and trims.
235
- * Two sections are "equivalent" when their normalizations match. Pure.
236
- * @param {string} text A section of content.
237
- * @returns {string} The normalized form.
238
- */
239
- function normalizeForCompare(text) {
240
- return text
241
- .split("\n")
242
- .filter((line) => !line.trim().startsWith("<!-- turnstile:"))
243
- .join("\n")
244
- .replace(/\s+/g, " ")
245
- .trim()
246
- }
247
-
248
- /**
249
- * True when two contract sections carry the same content (modulo sentinel
250
- * comments and whitespace). Pure.
251
- * @param {string} a First section.
252
- * @param {string} b Second section.
253
- * @returns {boolean}
254
- */
255
- function sectionsEquivalent(a, b) {
256
- return normalizeForCompare(a) === normalizeForCompare(b)
257
- }
258
-
259
- /**
260
- * Classifies the adoption decision for `content` against the managed `block`.
261
- * Pure. Returns one of:
262
- * { decision: "ok" } managed block present and matches the template
263
- * { decision: "append" } no block and no legacy section
264
- * { decision: "adopt", span } legacy section equivalent to the template
265
- * { decision: "replace", span } legacy section differs from the template
266
- * { decision: "drift" } managed block present but out of date
267
- * Throws AmbiguousError (multiple sections) or CorruptedError (start sentinel
268
- * without end sentinel); callers convert these to exit-1 refusals with
269
- * distinct advice.
270
- * @param {string} content Existing file content ("" when the file is new).
271
- * @param {string} block The managed block to install.
272
- * @returns {{decision: string, span?: {start: number, end: number}}}
273
- */
274
- function analyze(content, block) {
275
- const start = content.indexOf(START)
276
- if (start !== -1) {
277
- const end = content.indexOf(END, start)
278
- if (end === -1) {
279
- throw new CorruptedError(
280
- `found a start sentinel without an end sentinel — refusing to touch it; fix the file manually`,
281
- )
282
- }
283
- const installed = content.slice(start, end + END.length) + "\n"
284
- return installed === block ? { decision: "ok" } : { decision: "drift" }
285
- }
286
- const span = findLegacySection(content)
287
- if (!span) return { decision: "append" }
288
- const section = content.slice(span.start, span.end)
289
- return sectionsEquivalent(section, templateInner(block))
290
- ? { decision: "adopt", span }
291
- : { decision: "replace", span }
292
- }
293
-
294
- /**
295
- * Resolves the write-path action for a classified analysis. Pure: mirrors
296
- * exactly what `main()` performs, so the --no-adopt refusal and the
297
- * adopt-vs-backup-replace choice are unit-testable without the filesystem.
298
- * @param {{decision: string, span?: {start: number, end: number}}} analysis Result of analyze().
299
- * @param {{noAdopt: boolean}} args Parsed arguments.
300
- * @returns {{action: "up-to-date"|"append"|"adopt"|"replace"|"resync"|"refuse"}} The action to take.
301
- */
302
- function applyPlan(analysis, args) {
303
- switch (analysis.decision) {
304
- case "ok":
305
- return { action: "up-to-date" }
306
- case "append":
307
- return { action: args.noAdopt ? "refuse" : "append" }
308
- case "adopt":
309
- return { action: args.noAdopt ? "refuse" : "adopt" }
310
- case "replace":
311
- return args.noAdopt ? { action: "refuse" } : { action: "replace" }
312
- case "drift":
313
- return { action: args.noAdopt ? "refuse" : "resync" }
314
- default:
315
- throw new Error(`unknown adoption decision: ${analysis.decision}`)
316
- }
317
- }
318
-
319
- /**
320
- * Turns a classified analysis into the check-mode report line and its exit
321
- * code. Pure.
322
- * @param {{decision: string, span?: {start: number, end: number}}} analysis Result of analyze().
323
- * @param {string} file Target file label.
324
- * @returns {{message: string, code: number}}
325
- */
326
- function checkReport(analysis, file) {
327
- switch (analysis.decision) {
328
- case "ok":
329
- return { message: `check-agents: OK (${file})`, code: 0 }
330
- case "append":
331
- return {
332
- message: `check-agents: drift: no managed contract block in ${file} — the contract block would be appended`,
333
- code: 1,
334
- }
335
- case "adopt":
336
- return {
337
- message:
338
- `check-agents: would-adopt-equivalent (${file}) — the existing turnstile section matches ` +
339
- `the template; adopting would wrap it with sentinels`,
340
- code: 1,
341
- }
342
- case "replace":
343
- return {
344
- message:
345
- `check-agents: would-replace (${file}) — the existing turnstile section differs from the ` +
346
- `template; adopting would back up and replace it`,
347
- code: 1,
348
- }
349
- case "drift":
350
- return {
351
- message: `check-agents: drift: the managed block in ${file} differs from the template`,
352
- code: 1,
353
- }
354
- default:
355
- throw new Error(`unknown adoption decision: ${analysis.decision}`)
356
- }
357
- }
358
-
359
- function reconcileAdvice() {
360
- return (
361
- "Reconcile first: wrap the existing section with <!-- turnstile:start --> and " +
362
- "<!-- turnstile:end --> (updating its content to match templates/global-AGENTS.md), " +
363
- "or remove the stale section, then re-run."
364
- )
365
- }
366
-
367
- function writeResult(file, next, verb) {
368
- mkdirSync(dirname(file), { recursive: true })
369
- writeFileSync(file, next)
370
- console.log(`install-agents: ${verb} ${file}`)
371
- console.log("restart OpenCode sessions to pick up the change.")
372
- }
373
-
374
- function main() {
375
- const args = parseArgs(process.argv)
376
- const block = readBlock()
377
- const current = existsSync(args.file) ? readFileSync(args.file, "utf8") : ""
378
-
379
- // --remove only deletes the managed block; it must not be blocked by the
380
- // adopt analysis (a corrupted file is refused inside removeBlock instead,
381
- // mirroring the uninstall contract).
382
- if (args.remove) {
383
- let result
384
- try {
385
- result = removeBlock(current, args.file)
386
- } catch (error) {
387
- console.error(`install-agents: ${error instanceof Error ? error.message : String(error)}`)
388
- process.exit(1)
389
- }
390
- if (!result.removed || result.next === current) {
391
- console.log(`install-agents: up to date (${args.file})`)
392
- process.exit(0)
393
- }
394
- writeResult(args.file, result.next, "removed managed block in")
395
- process.exit(0)
396
- }
397
-
398
- let analysis
399
- try {
400
- analysis = analyze(current, block)
401
- } catch (error) {
402
- const message = error instanceof Error ? error.message : String(error)
403
- if (error instanceof AmbiguousError) {
404
- console.error(
405
- `install-agents: refusing to write ${args.file} — ${message} ${reconcileAdvice()}`,
406
- )
407
- } else {
408
- console.error(`install-agents: refusing to write ${args.file} — ${message}`)
409
- }
410
- process.exit(1)
411
- }
412
-
413
- if (args.check) {
414
- const { message, code } = checkReport(analysis, args.file)
415
- if (code === 0) console.log(message)
416
- else console.error(message)
417
- process.exit(code)
418
- }
419
-
420
- const plan = applyPlan(analysis, args)
421
- const code = performAction(plan, analysis, current, block, args.file)
422
- process.exit(code)
423
- }
424
-
425
- /**
426
- * Performs the chosen write action for a classified, non-check analysis and
427
- * returns the process exit code (0 = success, 1 = refusal). Single exit keeps
428
- * each branch a plain return rather than a fall-through.
429
- * @param {{action: string}} plan Result of applyPlan().
430
- * @param {{decision: string, span?: {start: number, end: number}}} analysis Result of analyze().
431
- * @param {string} current Existing file content.
432
- * @param {string} block The managed block to install.
433
- * @param {string} file Target file path.
434
- * @returns {number} The exit code.
435
- */
436
- function performAction(plan, analysis, current, block, file) {
437
- switch (plan.action) {
438
- case "up-to-date":
439
- console.log(`install-agents: already up to date (${file})`)
440
- return 0
441
- case "refuse":
442
- console.error(
443
- `install-agents: refusing to write ${file} — --no-adopt is set and the target needs ` +
444
- `adoption or re-sync. ${reconcileAdvice()}`,
445
- )
446
- return 1
447
- case "append": {
448
- const next = merge(current, block, file)
449
- writeResult(file, next, current.trim().length ? "updated managed block in" : "created")
450
- return 0
451
- }
452
- case "adopt": {
453
- // Legacy section equivalent to the template: splice the block in place,
454
- // preserving the section's content and any surrounding content.
455
- const next = installBlock(current, analysis.span, block)
456
- writeResult(file, next, `adopted existing turnstile section in (wrapped with sentinels)`)
457
- return 0
458
- }
459
- case "replace": {
460
- // Legacy section differs: adopt-with-backup by default. The backup is
461
- // written first and guarded: a failed backup must not leave a
462
- // half-adopted file, and its loss must not crash with a stack trace.
463
- const stamp = new Date().toISOString().replace(/[-:T]/g, "").slice(0, 14)
464
- let backupPath = `${file}.bak.${stamp}`
465
- for (let n = 1; existsSync(backupPath); n++) backupPath = `${file}.bak.${stamp}-${n}`
466
- mkdirSync(dirname(file), { recursive: true })
467
- try {
468
- writeFileSync(backupPath, current)
469
- } catch (error) {
470
- console.error(
471
- `install-agents: could not write backup ${backupPath} — refusing to adopt ` +
472
- `(${error instanceof Error ? error.message : String(error)}); fix the file manually`,
473
- )
474
- return 1
475
- }
476
- const next = installBlock(current, analysis.span, block)
477
- writeResult(file, next, "replaced differing turnstile section in")
478
- console.log(`backup: ${backupPath}`)
479
- return 0
480
- }
481
- case "resync": {
482
- // The managed block is present but out of date; re-sync it in place
483
- // (no backup: it is turnstile's own managed content).
484
- const next = merge(current, block, file)
485
- writeResult(file, next, "updated managed block in")
486
- return 0
487
- }
488
- }
489
- return 0
490
- }
491
-
492
- if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) main()
493
-
494
- // Pure, side-effect-free helpers exported for unit tests: tests can
495
- // import these directly rather than spawning the CLI.
496
- export {
497
- removeBlock,
498
- installBlock,
499
- findLegacySection,
500
- sectionsEquivalent,
501
- analyze,
502
- applyPlan,
503
- checkReport,
504
- }
@@ -1,55 +0,0 @@
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 -->