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.
package/bin/cli.mjs CHANGED
@@ -7,9 +7,14 @@
7
7
  //
8
8
  // opencode-turnstile init Install/sync artifacts into ~/.config/opencode
9
9
  // opencode-turnstile check Verify artifacts against the manifest
10
- // opencode-turnstile uninstall Remove installed artifacts (agents + block)
10
+ // opencode-turnstile uninstall Remove installed artifacts (manifest artifacts)
11
+ // opencode-turnstile status Report gate/plan state from the JSONL journal
11
12
  // opencode-turnstile version Print the installed package version
12
13
  //
14
+ // `init -- --print-model-config` writes nothing: the router runs install.mjs,
15
+ // which prints the snippet and short-circuits before any install work, so the
16
+ // router returns install.mjs's exit code verbatim.
17
+ //
13
18
  // Unknown subcommand or no args prints usage and exits 2.
14
19
 
15
20
  import { spawn } from "node:child_process"
@@ -20,14 +25,19 @@ import { fileURLToPath } from "node:url"
20
25
  const here = dirname(fileURLToPath(import.meta.url))
21
26
  const repoRoot = resolve(here, "..")
22
27
  const installScript = join(repoRoot, "scripts/install.mjs")
23
- const agentsScript = join(repoRoot, "scripts/install-agents.mjs")
28
+ const statusScript = join(repoRoot, "scripts/status.mjs")
24
29
 
25
30
  const USAGE = `Usage: opencode-turnstile <command>
26
31
 
27
32
  Commands:
28
33
  init Install or sync artifacts into ~/.config/opencode
34
+ (with --print-model-config, prints a {"agents": {...}} snippet and writes nothing)
29
35
  check Verify installed artifacts against the manifest
30
- uninstall Remove installed artifacts (agents + block)
36
+ (check --repair prints which targets would be auto-repaired,
37
+ writing nothing — a dry run of the --repair apply mode)
38
+ uninstall Remove installed artifacts (manifest artifacts)
39
+ status Report per-session gate/plan state from the journal
40
+ (status --json prints machine-readable JSON; read-only, nothing is written)
31
41
  version Print the installed package version
32
42
 
33
43
  init also accepts convenience flags (passthrough to scripts/install.mjs); separate them from the subcommand with a leading "--":
@@ -63,40 +73,42 @@ async function main() {
63
73
 
64
74
  switch (command) {
65
75
  case "init": {
66
- // Full install: manifest artifacts (plugin + agents), then the
67
- // managed contract block in ~/.config/opencode/AGENTS.md, mirroring
68
- // uninstall's two-step shape in reverse. Passthrough flags go to the
69
- // manifest installer; the agents installer needs none of them.
76
+ // Full install: manifest artifacts (plugin + agents) via a single
77
+ // install.mjs spawn. This installer never writes to AGENTS.md — the
78
+ // legacy contract-block installer was deleted (R1); any pre-existing
79
+ // block is removed by hand by the user, not by npm/init.
70
80
  //
71
- // --print-model-config is passthrough too: install.mjs prints the
72
- // snippet and short-circuits before any manifest work, but the router
73
- // still runs BOTH steps, so the contract block is idempotently
74
- // installed as usual. Print mode writes nothing; the two-step install
75
- // is the router's idempotent contract-block behavior, which is out of
76
- // scope for the write-free printer flag.
81
+ // Printer-mode short-circuit: --print-model-config makes init write
82
+ // completely free. install.mjs prints the snippet and short-circuits
83
+ // before any manifest work, so the router returns install.mjs's exit
84
+ // code verbatim. This is what makes "print mode writes nothing"
85
+ // literally true.
77
86
  //
78
87
  // A leading "--" separates the subcommand from its flags
79
88
  // (`init -- --print-model-config`); install.mjs rejects a literal "--"
80
89
  // as an unknown argument, so drop it before forwarding.
81
90
  const initPassthrough = passthrough[0] === "--" ? passthrough.slice(1) : passthrough
82
- const byInstall = await spawnInstaller(installScript, initPassthrough)
83
- const byAgents = await spawnInstaller(agentsScript, [])
84
- return process.exit(byInstall || byAgents ? 1 : 0)
91
+ return process.exit(await spawnInstaller(installScript, initPassthrough))
85
92
  }
86
93
  case "check":
87
94
  return spawnInstaller(installScript, ["--check", ...passthrough])
88
95
  case "uninstall": {
89
- // Reverse the copy/symlink install, then unmerge the managed block.
90
- // Inherited stdio surfaces both scripts' reports verbatim; exit non-zero
91
- // only if either step failed. No restart note is printed here; each
92
- // child prints its own, gated on whether it changed anything.
96
+ // Reverse the copy/symlink install via a single install.mjs spawn.
97
+ // Inherited stdio surfaces the report verbatim; exit non-zero only if
98
+ // it failed. No restart note is printed here; the child prints its own,
99
+ // gated on whether it changed anything.
93
100
  const byInstall = await spawnInstaller(installScript, ["--uninstall"])
94
- const byAgents = await spawnInstaller(agentsScript, ["--remove"])
95
- return process.exit(byInstall || byAgents ? 1 : 0)
101
+ return process.exit(byInstall ? 1 : 0)
96
102
  }
97
103
  case "version":
98
104
  console.log(JSON.parse(readFileSync(join(repoRoot, "package.json"), "utf8")).version)
99
105
  return
106
+ case "status": {
107
+ // Read-only status surface over the JSONL journal: spawn scripts/status.mjs
108
+ // with passthrough args (mirrors the init case's leading-"--" stripping).
109
+ const statusPassthrough = passthrough[0] === "--" ? passthrough.slice(1) : passthrough
110
+ return process.exit(await spawnInstaller(statusScript, statusPassthrough))
111
+ }
100
112
  default:
101
113
  console.log(USAGE)
102
114
  process.exit(2)
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "opencode-turnstile",
3
- "version": "0.2.0",
4
- "description": "OpenCode plugin that mechanically enforces agent pipeline gates (planner → executor → test-runner → reviewer)",
3
+ "version": "0.2.5",
4
+ "description": "OpenCode plugin that mechanically enforces an agent pipeline (planner → plan-reviewer → executor → test-runner → reviewer) for OpenCode",
5
5
  "type": "module",
6
6
  "license": "MIT",
7
7
  "bin": {
@@ -13,7 +13,6 @@
13
13
  "scripts",
14
14
  "bin",
15
15
  "install.json",
16
- "templates",
17
16
  "skills",
18
17
  "agents",
19
18
  "README.md",
@@ -26,16 +25,16 @@
26
25
  "format": "prettier --write .",
27
26
  "format:check": "prettier --check .",
28
27
  "check:markers": "node scripts/check-markers.mjs",
29
- "test": "node --test --experimental-strip-types tests/*.test.ts",
28
+ "test": "node --test --experimental-strip-types --experimental-test-coverage --test-coverage-lines=100 --test-coverage-branches=100 --test-coverage-functions=100 --test-coverage-include='**/src/**' --test-coverage-include='**/turnstile.ts' tests/*.test.ts",
30
29
  "init": "node scripts/install.mjs",
31
30
  "init:copy": "node scripts/install.mjs --copy",
32
31
  "init:check": "node scripts/install.mjs --check",
33
32
  "install:copy": "node scripts/install.mjs --copy",
34
33
  "install:check": "node scripts/install.mjs --check",
35
- "install:agents": "node scripts/install-agents.mjs",
36
34
  "pack:dry": "npm pack --dry-run",
37
35
  "ci:host": "npm run lint && npm run format:check && npm run typecheck && npm run check:markers && npm test",
38
- "ci": "npm run ci:host && npm run install:check"
36
+ "ci": "npm run ci:host",
37
+ "ci:installed": "npm run install:check"
39
38
  },
40
39
  "repository": {
41
40
  "type": "git",
@@ -0,0 +1,206 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * @fileoverview Branch-level coverage reporter for the turnstile test suite.
4
+ *
5
+ * Node's built-in coverage report prints percentages and uncovered *lines*
6
+ * only; the raw V8 JSON (via `NODE_V8_COVERAGE`) carries per-function *block*
7
+ * ranges whose zero counts pinpoint exactly which decision never executed —
8
+ * coverage.py-style arc diagnosis, derived from V8 block data.
9
+ *
10
+ * Usage: `node scripts/branch-coverage.mjs [test-file ...]`
11
+ * Runs each test file in its own child process (the same per-file isolation
12
+ * `node --test` uses) with `NODE_V8_COVERAGE` pointed at a shared temp dir,
13
+ * merges the V8 JSON across children, and prints every never-executed block
14
+ * inside a function that DID run, as `file:line:col-line` plus a snippet.
15
+ * Exit 0 always — this is a diagnostic, not a gate.
16
+ *
17
+ * Merge semantics (the non-obvious part — learned the hard way):
18
+ * - A function instance is identified by `(functionName, root range)`; the
19
+ * same closure re-recorded in several children merges into one entry.
20
+ * - V8 may record DIFFERENT block granularity for the same function across
21
+ * children (optimization feedback differs per run), so ranges merge by
22
+ * exact `(startOffset, endOffset)` span with max count. Never spread a
23
+ * coarse count across a finer record's blocks: a parent range's count is
24
+ * the *function/block entry* count, not evidence that nested blocks ran.
25
+ * - A merged zero block is only reported when no child has a POSITIVE range
26
+ * contained within it (code inside it demonstrably ran somewhere). Coarse
27
+ * zero spans that contain proven-covered sub-blocks are pruned — they are
28
+ * lumping artifacts, not branch evidence. This fails toward less noise.
29
+ * - Functions whose root never ran in any child are skipped (they show as
30
+ * uncovered lines in the standard report anyway).
31
+ */
32
+
33
+ import { spawnSync } from "node:child_process"
34
+ import { mkdtempSync, readdirSync, readFileSync, rmSync } from "node:fs"
35
+ import { tmpdir } from "node:os"
36
+ import { join } from "node:path"
37
+
38
+ /** Builds per-file line-offset tables for snippet and position rendering. */
39
+ function loadSources(files) {
40
+ const sources = {}
41
+ for (const file of files) {
42
+ try {
43
+ const text = readFileSync(file, "utf8")
44
+ const offsets = []
45
+ let off = 0
46
+ for (const line of text.split("\n")) {
47
+ offsets.push(off)
48
+ off += line.length + 1
49
+ }
50
+ sources[file] = { text, offsets }
51
+ } catch {
52
+ /* covered file may be outside this checkout; leave unknown */
53
+ }
54
+ }
55
+ return sources
56
+ }
57
+
58
+ /** Converts a character offset to a `line:col` string via the offsets table. */
59
+ function toPos(source, offset) {
60
+ let lo = 0
61
+ let hi = source.offsets.length - 1
62
+ while (lo < hi) {
63
+ const mid = (lo + hi + 1) >> 1
64
+ if (source.offsets[mid] <= offset) lo = mid
65
+ else hi = mid - 1
66
+ }
67
+ return `${lo + 1}:${offset - source.offsets[lo] + 1}`
68
+ }
69
+
70
+ /** Converts a character offset to a 1-based line number. */
71
+ function toLine(source, offset) {
72
+ return Number(toPos(source, offset).split(":")[0])
73
+ }
74
+
75
+ const args = process.argv.slice(2)
76
+ const testFiles = args.filter((a) => !a.startsWith("--"))
77
+ const files = testFiles.length
78
+ ? testFiles
79
+ : readdirSync("tests")
80
+ .filter((f) => f.endsWith(".test.ts"))
81
+ .map((f) => `tests/${f}`)
82
+ const covDir = mkdtempSync(join(tmpdir(), "branch-cov-"))
83
+
84
+ // One child per test file — exactly how `node --test` runs them — so module
85
+ // state isolation matches the real suite and the union over children is the
86
+ // same union the standard report computes. All children share one
87
+ // NODE_V8_COVERAGE dir; records are merged below.
88
+ let failures = 0
89
+ for (const file of files) {
90
+ const result = spawnSync(
91
+ process.execPath,
92
+ ["--test", "--experimental-strip-types", "--experimental-test-coverage", file],
93
+ {
94
+ env: { ...process.env, NODE_V8_COVERAGE: covDir },
95
+ stdio: ["ignore", "pipe", "pipe"],
96
+ encoding: "utf8",
97
+ },
98
+ )
99
+ if (result.status !== 0) {
100
+ failures++
101
+ console.error(`${file}: child exited ${result.status}`)
102
+ }
103
+ }
104
+
105
+ const records = readdirSync(covDir).map((f) => JSON.parse(readFileSync(join(covDir, f), "utf8")))
106
+ rmSync(covDir, { recursive: true, force: true })
107
+
108
+ /** Normalizes a V8 URL to a filesystem path. */
109
+ const toFile = (url) => url.replace(/^file:\/\//, "")
110
+
111
+ /**
112
+ * Merges one child's ranges into the accumulated span→maxCount map by exact
113
+ * span. Identical spans across children take the max count (a block executed
114
+ * in any child is covered); spans unique to one child are that child's own
115
+ * block structure and are kept for the containment pruning pass.
116
+ * @param acc The accumulated `Map<"start:end", { startOffset, endOffset, count }>` for a function.
117
+ * @param incoming One child's range list for that function.
118
+ */
119
+ function mergeRanges(acc, incoming) {
120
+ for (const r of incoming) {
121
+ const key = `${r.startOffset}:${r.endOffset}`
122
+ const prior = acc.get(key)
123
+ if (prior) prior.count = Math.max(prior.count, r.count)
124
+ else acc.set(key, { ...r })
125
+ }
126
+ }
127
+
128
+ /** Merges all records: url → (function key → span map). */
129
+ const scripts = new Map()
130
+ for (const record of records) {
131
+ for (const entry of record.result) {
132
+ if (!entry.functions) continue
133
+ let script = scripts.get(entry.url)
134
+ if (!script) {
135
+ script = new Map()
136
+ scripts.set(entry.url, script)
137
+ }
138
+ for (const fn of entry.functions) {
139
+ if (!fn.ranges) continue
140
+ const key = `${fn.functionName}@${fn.ranges[0].startOffset}`
141
+ const prior = script.get(key)
142
+ if (!prior) {
143
+ const spans = new Map()
144
+ const positives = []
145
+ for (const r of fn.ranges) {
146
+ const k = `${r.startOffset}:${r.endOffset}`
147
+ spans.set(k, { ...r })
148
+ if (r.count > 0 && r !== fn.ranges[0]) positives.push({ ...r })
149
+ }
150
+ script.set(key, { spans, positives })
151
+ } else {
152
+ mergeRanges(prior.spans, fn.ranges)
153
+ for (const r of fn.ranges) {
154
+ if (r.count > 0 && r !== fn.ranges[0]) prior.positives.push({ ...r })
155
+ }
156
+ }
157
+ }
158
+ }
159
+ }
160
+
161
+ const sources = loadSources([...scripts.keys()].map(toFile))
162
+ const findings = []
163
+ for (const [url, fns] of scripts) {
164
+ const file = toFile(url)
165
+ if (!/\/src\//.test(file) && !file.endsWith("turnstile.ts")) continue
166
+ const source = sources[file]
167
+ if (!source) continue
168
+ for (const [key, { spans, positives }] of fns) {
169
+ const root = [...spans.values()].find(
170
+ (s) => s.startOffset === Math.min(...[...spans.values()].map((v) => v.startOffset)),
171
+ )
172
+ if (!root || root.count === 0) continue // whole function never ran in any child
173
+ for (const span of spans.values()) {
174
+ if (span.count !== 0) continue
175
+ // Prune lumping artifacts: a zero span that CONTAINS a proven-covered
176
+ // sub-range from another child is not branch evidence.
177
+ const containedPositive = positives.some(
178
+ (p) =>
179
+ p.startOffset >= span.startOffset &&
180
+ p.endOffset <= span.endOffset &&
181
+ !(p.startOffset === span.startOffset && p.endOffset === span.endOffset),
182
+ )
183
+ if (containedPositive) continue
184
+ const start = toPos(source, span.startOffset)
185
+ const endLine = toLine(source, span.endOffset)
186
+ const snippet = source.text.slice(span.startOffset, span.startOffset + 80).split("\n")[0]
187
+ findings.push({ file, key: key.split("@")[0], start, endLine, snippet })
188
+ }
189
+ }
190
+ }
191
+ findings.sort(
192
+ (a, b) =>
193
+ a.file.localeCompare(b.file) || a.start.localeCompare(b.start, undefined, { numeric: true }),
194
+ )
195
+
196
+ console.log(`tests: ${files.length} files, child failures=${failures}`)
197
+ if (findings.length === 0) {
198
+ console.log("branch coverage: every executed function's blocks are covered")
199
+ } else {
200
+ console.log(`uncovered blocks: ${findings.length}`)
201
+ for (const f of findings) {
202
+ console.log(
203
+ ` ${f.file.replace(/^.*turnstile\//, "")}:${f.start}-${f.endLine} [${f.key}] ${f.snippet}`,
204
+ )
205
+ }
206
+ }
@@ -64,6 +64,28 @@ if (agentTemplates === 0) {
64
64
  failed++
65
65
  }
66
66
 
67
+ // Plugin policy: the backticked GATE:/VERDICT: examples in src/policy.ts are
68
+ // the orchestrator-side half of the marker contract — the runtime policy
69
+ // injected into the parent session. Same extraction rule as the agent
70
+ // templates: they spell <n> and must parse once the placeholder stands in
71
+ // for digits. (The template source that once lived here for this contract is
72
+ // gone; src/policy.ts is its successor, so its examples are validated too.)
73
+ const policyFile = join(root, "src", "policy.ts")
74
+ let policyTemplates = 0
75
+ for (const m of readFileSync(policyFile, "utf8").matchAll(/`((?:GATE|VERDICT): [^\n`]*)`/g)) {
76
+ const line = m[1].replaceAll("<n>", "1")
77
+ const re = line.startsWith("GATE") ? gateRe : verdictRe
78
+ policyTemplates++
79
+ if (!re.test(line)) {
80
+ console.error(`check-markers: policy marker template not accepted by parser: ${line}`)
81
+ failed++
82
+ }
83
+ }
84
+ if (policyTemplates === 0) {
85
+ console.error("check-markers: no marker templates found in src/policy.ts")
86
+ failed++
87
+ }
88
+
67
89
  // Inverse check: the regexes must also reject malformed markers.
68
90
  for (const [re, bad] of [
69
91
  [gateRe, "GATE: PASS tests=12"],
@@ -82,5 +104,5 @@ if (failed) {
82
104
  process.exit(1)
83
105
  }
84
106
  console.log(
85
- `check-markers: OK (${lines.length} README examples, ${agentTemplates} agent templates verified against parser)`,
107
+ `check-markers: OK (${lines.length} README examples, ${agentTemplates} agent + ${policyTemplates} policy templates verified against parser)`,
86
108
  )
@@ -7,14 +7,23 @@
7
7
  //
8
8
  // Usage:
9
9
  // node scripts/install.mjs [--manifest <path>] [--check] [--force]
10
- // [--uninstall] [--link | --copy]
10
+ // [--uninstall] [--link | --copy] [--repair]
11
11
  //
12
- // --manifest Manifest to install (default: install.json at the repo root)
13
- // --check Verify all targets without writing; exit 1 on drift
12
+ // --manifest Manifest to install (default: install.json at the repo root).
13
+ // Missing value, or a `--`-prefixed next token, exits 2 here
14
+ // ("requires a path") rather than crashing downstream.
15
+ // --check Verify all targets without writing; exit 1 on drift. With
16
+ // --repair, reportable drift is printed as `would repair`
17
+ // (write-free dry run) instead of failing.
14
18
  // --force Convert conflicting targets after backing them up
15
19
  // --uninstall Reverse the manifest: remove repo-owned links / ledger-owned
16
20
  // copies for every entry (then retire leftovers); exit 1 only
17
21
  // when something could not be removed (conflict/unowned).
22
+ // --repair Apply-mode: relink/copy each repairable entry (`missing`/`stale`
23
+ // link-mode; `missing`/ledger-`stale` copy-mode) via the same
24
+ // path as install, print `install: repair <dest> (<code>)`,
25
+ // exit 1 only if something unrepairable remains. `conflict`
26
+ // stays gated behind --force (no unprompted backup).
18
27
  // --link/--copy Override the mode. Resolution: CLI flag > manifest "mode"
19
28
  // field > auto-detect (.git present => link, else copy)
20
29
  //
@@ -78,7 +87,7 @@ try {
78
87
  /**
79
88
  * Parses command-line arguments.
80
89
  * @param {string[]} argv Raw argv (process.argv).
81
- * @returns {{manifest: string, check: boolean, force: boolean, uninstall: boolean, mode: "link"|"copy"|null, model: string[], printModelConfig: boolean, validate: boolean, modelSpecs: Record<string,string>}} Parsed arguments. A `--model`/`--validate` without `--print-model-config` is an error and exits 2 (checked before any parsing or I/O). Model specs are validated here only when the printer flag is set; a malformed or unknown-name spec prints an actionable message and exits 2 (matching the unknown-arg contract), and the parsed map is stashed as `modelSpecs` for `main`.
90
+ * @returns {{manifest: string, check: boolean, force: boolean, uninstall: boolean, mode: "link"|"copy"|null, model: string[], printModelConfig: boolean, validate: boolean, modelSpecs: Record<string,string>, repair: boolean}} Parsed arguments. A `--model`/`--validate` without `--print-model-config` is an error and exits 2 (checked before any parsing or I/O). Model specs are validated here only when the printer flag is set; a malformed or unknown-name spec prints an actionable message and exits 2 (matching the unknown-arg contract), and the parsed map is stashed as `modelSpecs` for `main`. A missing or flag-shaped value for `--manifest` or `--model` also exits 2 at the assignment site, naming the offending flag (so neither path reaches a late fs parse-crash).
82
91
  */
83
92
  function parseArgs(argv) {
84
93
  const args = {
@@ -91,15 +100,29 @@ function parseArgs(argv) {
91
100
  printModelConfig: false,
92
101
  validate: false,
93
102
  modelSpecs: {},
103
+ repair: false,
94
104
  }
95
105
  for (let i = 2; i < argv.length; i++) {
96
- if (argv[i] === "--manifest") args.manifest = argv[++i]
97
- else if (argv[i] === "--check") args.check = true
106
+ if (argv[i] === "--manifest") {
107
+ const val = argv[++i]
108
+ if (val === undefined || val.startsWith("--")) {
109
+ console.error("install: --manifest requires a path")
110
+ process.exit(2)
111
+ }
112
+ args.manifest = val
113
+ } else if (argv[i] === "--check") args.check = true
98
114
  else if (argv[i] === "--force") args.force = true
99
115
  else if (argv[i] === "--uninstall") args.uninstall = true
100
116
  else if (argv[i] === "--link") args.mode = "link"
101
117
  else if (argv[i] === "--copy") args.mode = "copy"
102
- else if (argv[i] === "--model") args.model.push(argv[++i])
118
+ else if (argv[i] === "--model") {
119
+ const val = argv[++i]
120
+ if (val === undefined || val.startsWith("--")) {
121
+ console.error("install: --model requires a spec")
122
+ process.exit(2)
123
+ }
124
+ args.model.push(val)
125
+ } else if (argv[i] === "--repair") args.repair = true
103
126
  else if (argv[i] === "--print-model-config") args.printModelConfig = true
104
127
  else if (argv[i] === "--validate") args.validate = true
105
128
  else {
@@ -475,6 +498,20 @@ async function inspectEntry(entry, mode, ledger) {
475
498
  return mode === "copy" ? inspectCopy(entry, ledger) : inspectLink(entry)
476
499
  }
477
500
 
501
+ /**
502
+ * Decides whether a drift code is repairable by `--repair` without prompting.
503
+ * Link mode reports `missing`/`stale`; `inspectCopy` only returns `stale` for
504
+ * a ledger-recorded match, so the same predicate covers both modes. A
505
+ * `conflict` is deliberately excluded: repairing it would back up user files,
506
+ * which `--repair` must never do unprompted (that path stays gated behind
507
+ * `--force`).
508
+ * @param {string} code Drift code from `inspectEntry`.
509
+ * @returns {boolean} True when `--repair` would fix it.
510
+ */
511
+ function isRepairable(code) {
512
+ return code === "missing" || code === "stale"
513
+ }
514
+
478
515
  /**
479
516
  * Backs up a real file by renaming it aside with a timestamp suffix.
480
517
  * @param {string} dest Destination path to back up.
@@ -664,15 +701,72 @@ async function apply(manifest, args) {
664
701
  return { ok, changed }
665
702
  }
666
703
 
704
+ /**
705
+ * Repairs repairable drift across every manifest entry, relinking `missing`
706
+ * /`stale` link-mode entries and re-copying `missing`/ledger-`stale` copy-mode
707
+ * entries via the same `installLink`/`installCopy` path `apply()` uses.
708
+ * `conflict` stays gated behind `--force`: `--repair` must never back up user
709
+ * files unprompted, so a conflict left without `--force` is reported as
710
+ * unrepairable and fails. Runs in apply mode; the `--check --repair` dry run
711
+ * is handled by `check()`.
712
+ * @param {{mode: "link"|"copy", ledgerPath: string, links: Array<{src: string, dest: string}>, retired: string[]}} manifest Validated manifest.
713
+ * @param {{force: boolean, repair: boolean}} args Parsed arguments.
714
+ * @returns {Promise<{ok: boolean, changed: boolean}>} Success flag and whether anything changed.
715
+ */
716
+ async function repair(manifest, args) {
717
+ const ledger = loadLedger(manifest.ledgerPath)
718
+ let ok = true
719
+ let changed = false
720
+
721
+ for (const entry of manifest.links) {
722
+ const report = await inspectEntry(entry, manifest.mode, ledger)
723
+ if (report.code === "ok") {
724
+ console.log(`install: up to date ${entry.dest}`)
725
+ continue
726
+ }
727
+ // Conflict without --force, or an invalid (missing-source) entry, can not
728
+ // be repaired here: neither would back up user files or invent a source.
729
+ if (!isRepairable(report.code) && !args.force) {
730
+ ok = false
731
+ console.error(
732
+ `install: ${entry.dest} — ${report.code}${
733
+ report.detail ? `: ${report.detail}` : ""
734
+ } (unrepairable; rerun with --force for conflicts)`,
735
+ )
736
+ continue
737
+ }
738
+ try {
739
+ const result =
740
+ manifest.mode === "copy"
741
+ ? await installCopy(entry, args, ledger, pkgVersion, manifest.mode)
742
+ : await installLink(entry, args, ledger)
743
+ if (result.acted) {
744
+ changed = true
745
+ console.log(`install: repair ${entry.dest} (${report.code})`)
746
+ } else console.log(`install: up to date ${entry.dest}`)
747
+ } catch (error) {
748
+ ok = false
749
+ console.error(String(error.message ?? error))
750
+ }
751
+ }
752
+ saveLedger(manifest.ledgerPath, ledger)
753
+ return { ok, changed }
754
+ }
755
+
667
756
  /**
668
757
  * Runs the installer in check mode: reports drift, writes nothing.
669
758
  * A ledger record whose version is older than the current package version
670
- * is reported as a warning and does not fail the check.
759
+ * is reported as a warning and does not fail the check. When `repairMode`
760
+ * is set (`--check --repair`), repairable drift (`missing`/`stale`) is
761
+ * reported as `would repair` instead of an error and does not fail the
762
+ * check (the entry is going to be fixed); unrepairable drift still fails
763
+ * as before. `check` alone (`repairMode` false) is byte-for-byte unchanged.
671
764
  * @param {{mode: "link"|"copy", ledgerPath: string, links: Array<{src: string, dest: string}>, retired: string[]}} manifest Validated manifest.
672
765
  * @param {string} version Current package version.
673
- * @returns {Promise<boolean>} True when no drift was found.
766
+ * @param {boolean} [repairMode] When true, report repairable drift as `would repair` rather than failing.
767
+ * @returns {Promise<boolean>} True when no unrepairable drift was found.
674
768
  */
675
- async function check(manifest, version) {
769
+ async function check(manifest, version, repairMode = false) {
676
770
  const ledger = loadLedger(manifest.ledgerPath)
677
771
  let ok = true
678
772
  for (const entry of manifest.links) {
@@ -686,6 +780,8 @@ async function check(manifest, version) {
686
780
  if (record && record.version && record.version !== version)
687
781
  console.log(`check: outdated ${entry.dest} (installed ${record.version}, package ${version})`)
688
782
  if (report.code === "ok") console.log(`check: OK ${entry.dest}`)
783
+ else if (repairMode && isRepairable(report.code))
784
+ console.log(`install: would repair ${entry.dest} (${report.code})`)
689
785
  else {
690
786
  ok = false
691
787
  console.error(
@@ -710,8 +806,7 @@ async function main(argv = process.argv) {
710
806
  // optionally validates them against `opencode models`, prints a ready-to
711
807
  // paste snippet, and returns. This short-circuits before the manifest is
712
808
  // even read (like --check), so no link/copy target or ledger entry is
713
- // touched. The router still idempotently installs the contract block
714
- // separately; that is out of scope for this write-free flag.
809
+ // touched.
715
810
  if (args.printModelConfig) {
716
811
  if (args.validate) {
717
812
  let available
@@ -735,7 +830,12 @@ async function main(argv = process.argv) {
735
830
  console.log(
736
831
  `install: mode=${manifest.mode} (${manifest.mode === "link" ? "checkout" : "package"})`,
737
832
  )
738
- if (args.check) return await check(manifest, pkgVersion)
833
+ if (args.check) return await check(manifest, pkgVersion, args.repair)
834
+ if (args.repair) {
835
+ const { ok, changed } = await repair(manifest, args)
836
+ if (changed) console.log("restart OpenCode sessions to pick up changes.")
837
+ return ok
838
+ }
739
839
  const { ok, changed } = await apply(manifest, args)
740
840
  if (changed) console.log("restart OpenCode sessions to pick up changes.")
741
841
  return ok