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
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
// CI guard: the marker examples in README.md must be accepted by the parser
|
|
2
|
+
// regexes in turnstile.ts. Exits non-zero on drift.
|
|
3
|
+
import { readdirSync, readFileSync } from "node:fs"
|
|
4
|
+
import { dirname, join } from "node:path"
|
|
5
|
+
import { fileURLToPath } from "node:url"
|
|
6
|
+
|
|
7
|
+
const root = join(dirname(fileURLToPath(import.meta.url)), "..")
|
|
8
|
+
// The marker regex literals live in src/markers.ts (the marker boundary);
|
|
9
|
+
// turnstile.ts stays in the list so older single-file layouts still guard.
|
|
10
|
+
const ts = ["turnstile.ts", "src/markers.ts"]
|
|
11
|
+
.map((f) => readFileSync(join(root, f), "utf8"))
|
|
12
|
+
.join("\n")
|
|
13
|
+
const readme = readFileSync(join(root, "README.md"), "utf8")
|
|
14
|
+
|
|
15
|
+
function extractRegex(name) {
|
|
16
|
+
const m = ts.match(new RegExp(`const ${name} = /(.+)/([a-z]*)`))
|
|
17
|
+
if (!m) throw new Error(`could not find ${name} in turnstile.ts or src/markers.ts`)
|
|
18
|
+
// strip the g flag: RegExp.test is stateful with /g (lastIndex advances)
|
|
19
|
+
return new RegExp(m[1], m[2].replace(/g/g, ""))
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const gateRe = extractRegex("GATE_RE")
|
|
23
|
+
const verdictRe = extractRegex("VERDICT_RE")
|
|
24
|
+
|
|
25
|
+
// Every fenced ```-block line that looks like a marker must match a regex.
|
|
26
|
+
const lines = [...readme.matchAll(/```[a-z]*\n([\s\S]*?)```/g)]
|
|
27
|
+
.flatMap((m) => m[1].split("\n"))
|
|
28
|
+
.filter((l) => /^(GATE|VERDICT): /.test(l.trim()) && !/[<>]/.test(l))
|
|
29
|
+
|
|
30
|
+
if (lines.length === 0) {
|
|
31
|
+
console.error("check-markers: no concrete marker examples found in README.md")
|
|
32
|
+
process.exit(1)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
let failed = 0
|
|
36
|
+
for (const line of lines) {
|
|
37
|
+
const ok = gateRe.test(line) || verdictRe.test(line)
|
|
38
|
+
if (!ok) {
|
|
39
|
+
console.error(`check-markers: README marker not accepted by parser: ${line}`)
|
|
40
|
+
failed++
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Agent templates: the backticked GATE:/VERDICT: examples in
|
|
45
|
+
// .opencode/agents/*.md are the prompt-side half of the marker contract.
|
|
46
|
+
// Templates spell the numeric fields <n>; they must parse once the
|
|
47
|
+
// placeholder stands in for digits.
|
|
48
|
+
const agentsDir = join(root, ".opencode", "agents")
|
|
49
|
+
let agentTemplates = 0
|
|
50
|
+
for (const f of readdirSync(agentsDir).filter((f) => f.endsWith(".md"))) {
|
|
51
|
+
const text = readFileSync(join(agentsDir, f), "utf8")
|
|
52
|
+
for (const m of text.matchAll(/`((?:GATE|VERDICT): [^\n`]*)`/g)) {
|
|
53
|
+
const line = m[1].replaceAll("<n>", "1")
|
|
54
|
+
const re = line.startsWith("GATE") ? gateRe : verdictRe
|
|
55
|
+
agentTemplates++
|
|
56
|
+
if (!re.test(line)) {
|
|
57
|
+
console.error(`check-markers: agent marker template not accepted by parser: ${f}: ${line}`)
|
|
58
|
+
failed++
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
if (agentTemplates === 0) {
|
|
63
|
+
console.error("check-markers: no marker templates found in .opencode/agents")
|
|
64
|
+
failed++
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Inverse check: the regexes must also reject malformed markers.
|
|
68
|
+
for (const [re, bad] of [
|
|
69
|
+
[gateRe, "GATE: PASS tests=12"],
|
|
70
|
+
[gateRe, "GATE: OK tests=1 passed=1 failed=0"],
|
|
71
|
+
[verdictRe, "VERDICT: APPROVE crit=1"],
|
|
72
|
+
[verdictRe, "VERDICT: MAYBE crit=0 high=0 med=0 low=0"],
|
|
73
|
+
]) {
|
|
74
|
+
if (re.test(bad)) {
|
|
75
|
+
console.error(`check-markers: parser accepted malformed marker: ${bad}`)
|
|
76
|
+
failed++
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (failed) {
|
|
81
|
+
console.error(`check-markers: ${failed} drift finding(s)`)
|
|
82
|
+
process.exit(1)
|
|
83
|
+
}
|
|
84
|
+
console.log(
|
|
85
|
+
`check-markers: OK (${lines.length} README examples, ${agentTemplates} agent templates verified against parser)`,
|
|
86
|
+
)
|
|
@@ -0,0 +1,504 @@
|
|
|
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
|
+
}
|