opencode-harness-kit 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.
@@ -0,0 +1,977 @@
1
+ #!/usr/bin/env node
2
+ // oc-harness - pick a model profile and compile it into an opencode config.
3
+ //
4
+ // oc-harness list profiles with retention and caveats
5
+ // oc-harness init [--profile <id>] pick a free profile, then install + use + verify
6
+ // oc-harness use <id> write ~/.config/opencode/opencode.jsonc
7
+ // oc-harness use <id> --print print the config instead of writing it
8
+ // oc-harness show <id> show the resolved effort plan
9
+ // oc-harness doctor re-check profiles against live metadata
10
+ // oc-harness baseline measure the current install
11
+ //
12
+ // The point of the kit: switching models is a data edit, not a code change.
13
+ // Everything model-specific lives in profiles/<id>.json.
14
+
15
+ import { chmodSync, copyFileSync, mkdirSync, readFileSync, writeFileSync, existsSync, rmSync, symlinkSync, readdirSync, readlinkSync, realpathSync } from "node:fs"
16
+ import { homedir } from "node:os"
17
+ import { dirname, join, relative } from "node:path"
18
+ import { execFileSync } from "node:child_process"
19
+ import { fileURLToPath } from "node:url"
20
+ import { createInterface } from "node:readline/promises"
21
+
22
+ import {
23
+ loadProfile,
24
+ loadRetention,
25
+ listProfileIds,
26
+ profileIdFromConfig,
27
+ profilePath,
28
+ pruneThreshold,
29
+ reservedTokens,
30
+ resolveDefaultVariant,
31
+ resolveRoles,
32
+ ProfileError,
33
+ } from "../src/profile.js"
34
+ import { generateConfig, globalInstructionsPath } from "../src/config.js"
35
+ import { planEffort } from "../src/effort.js"
36
+
37
+ const CONFIG_DIR =
38
+ process.env.OPENCODE_CONFIG_DIR ?? join(homedir(), ".config/opencode")
39
+ const CONFIG_FILE = join(CONFIG_DIR, "opencode.jsonc")
40
+ const ROOT = import.meta.dirname ? join(import.meta.dirname, "..") : process.cwd()
41
+
42
+ const args = process.argv.slice(2)
43
+ const cmd = args[0] ?? "list"
44
+ const has = (f) => args.includes(f)
45
+ const opt = (f) => {
46
+ const i = args.indexOf(f)
47
+ return i >= 0 ? args[i + 1] : undefined
48
+ }
49
+
50
+ const stamp = (s) => `\x1b[2m${s}\x1b[0m`
51
+ const bold = (s) => `\x1b[1m${s}\x1b[0m`
52
+ const yellow = (s) => `\x1b[33m${s}\x1b[0m`
53
+ const green = (s) => `\x1b[32m${s}\x1b[0m`
54
+ const red = (s) => `\x1b[31m${s}\x1b[0m`
55
+ const interactive = () => process.stdin.isTTY && !has("--yes")
56
+
57
+ /**
58
+ * A yes question with a yes default. Only asked on a terminal; scripts and
59
+ * CI pass --yes or get the old silent behaviour, never a hung prompt.
60
+ */
61
+ async function confirm(question) {
62
+ const rl = createInterface({ input: process.stdin, output: process.stdout })
63
+ try {
64
+ const answer = (await rl.question(` ${question} [Y/n] `)).trim().toLowerCase()
65
+ return answer === "" || answer === "y" || answer === "yes"
66
+ } finally {
67
+ rl.close()
68
+ }
69
+ }
70
+
71
+ function index() {
72
+ const f = join(import.meta.dirname, "..", "profiles", "index.json")
73
+ return existsSync(f) ? JSON.parse(readFileSync(f, "utf8")) : { profiles: [], excluded: [] }
74
+ }
75
+
76
+ async function cmdList() {
77
+ const idx = index()
78
+ const retention = loadRetention()
79
+ const only = opt("--retention")
80
+ const freeOnly = has("--free")
81
+
82
+ console.log(`\n${bold("opencode-harness-kit")} ${stamp("profiles")}\n`)
83
+ const head = [
84
+ " " + "profile".padEnd(30),
85
+ "ctx".padStart(9),
86
+ "top".padStart(7),
87
+ "retention".padEnd(15),
88
+ ].join(" ")
89
+ console.log(head)
90
+ console.log(" " + "-".repeat(72))
91
+
92
+ for (const entry of idx.profiles ?? []) {
93
+ if (only && entry.retention !== only) continue
94
+ if (freeOnly && entry.free === false) continue
95
+ const r = retention[`opencode/${entry.id}`]
96
+ const isDefault = entry.default
97
+ const name = (isDefault ? green("▸ ") : " ") + entry.id
98
+ const top = entry.topEffort ?? stamp("—")
99
+ const ret =
100
+ entry.retention === "zero-retention"
101
+ ? green(entry.retention)
102
+ : yellow(entry.retention)
103
+ const mismatch =
104
+ r && r.retention && r.retention !== entry.retention
105
+ ? ` ${red(`(retention.yml says ${r.retention})`)}`
106
+ : ""
107
+ console.log(
108
+ [
109
+ " " + name.padEnd(32),
110
+ (entry.context ?? 0).toLocaleString("en-US").padStart(9),
111
+ String(top).padStart(7),
112
+ ret.padEnd(15),
113
+ ].join(" ") + mismatch,
114
+ )
115
+ if (entry.note) console.log(` ${stamp(entry.note)}`)
116
+ }
117
+
118
+ const ex = idx.excluded ?? []
119
+ if (ex.length && (!only || only === "may-train")) {
120
+ console.log("\n " + stamp("installable, not recommended"))
121
+ for (const entry of ex) {
122
+ console.log(` ${yellow("✗")} ${entry.id}`)
123
+ console.log(` ${yellow(entry.reason)}`)
124
+ }
125
+ }
126
+ console.log(
127
+ `\n ${stamp("install one:")} oc-harness use <profile>\n ${stamp("inspect:")} oc-harness show <profile>\n`,
128
+ )
129
+ }
130
+
131
+ function cmdShow(id) {
132
+ if (!id) fail("usage: oc-harness show <profile>")
133
+ const p = loadProfile(id)
134
+ const plan = planEffort(id)
135
+ const retention = loadRetention()[`opencode/${p.provider}/${p.model}`]
136
+
137
+ console.log(`\n${bold(p.id)} ${stamp(p.title ?? "")}\n`)
138
+ const row = (k, v) => console.log(` ${k.padEnd(20)} ${v}`)
139
+ row("provider/model", `${p.provider}/${p.model}`)
140
+ row("context", p.limits.context.toLocaleString("en-US"))
141
+ row("input cap", (p.limits.input ?? p.limits.context).toLocaleString("en-US"))
142
+ row("output cap", p.limits.output.toLocaleString("en-US"))
143
+ row("effort levels", p.effort?.levels?.length ? p.effort.levels.join(" < ") : "(not tunable)")
144
+ row("default variant", resolveDefaultVariant(p) ?? "(none)")
145
+ row("temperature", p.supports?.temperature ? "yes" : stamp("no - not emitted"))
146
+ row("attachment", p.supports?.attachment ? "yes" : stamp("no"))
147
+ row("reserved", reservedTokens(p).toLocaleString("en-US"))
148
+ row("prune at", pruneThreshold(p).toLocaleString("en-US"))
149
+ row("retention", p.retention === "zero-retention" ? green(p.retention) : yellow(p.retention))
150
+
151
+ console.log(`\n ${bold("role effort")}`)
152
+ for (const [role, level] of Object.entries(plan.roles)) {
153
+ const want = p.roles?.[role]
154
+ const arrow = want && want !== level ? ` ${stamp(`(requested ${want})`)}` : ""
155
+ console.log(` ${role.padEnd(10)} ${String(level ?? "(unset)").padEnd(10)}${arrow}`)
156
+ }
157
+
158
+ if (p.fallback?.length) {
159
+ console.log(`\n ${bold("fallback")} ${p.fallback.join(" → ")}`)
160
+ console.log(` ${stamp("selected at session start; no hook can switch a live session")}`)
161
+ }
162
+ if (retention?.note) {
163
+ console.log(`\n ${bold("retention note")}`)
164
+ console.log(` ${retention.note}`)
165
+ }
166
+ if (plan.notes.length) {
167
+ console.log(`\n ${bold("adjustments")}`)
168
+ for (const n of plan.notes) console.log(` ${yellow("!")} ${n}`)
169
+ }
170
+ if (p.notes) {
171
+ console.log(`\n ${bold("notes")}`)
172
+ console.log(` ${p.notes}`)
173
+ }
174
+ console.log()
175
+ }
176
+
177
+ /**
178
+ * Link the built plugin into ~/.config/opencode/plugins so opencode loads it
179
+ * without an npm install. This is the path to use while developing, and it
180
+ * means the kit is usable before anything is published.
181
+ */
182
+ function cmdLink() {
183
+ const dist = join(ROOT, "dist", "index.js")
184
+ if (!existsSync(dist)) {
185
+ fail("dist/index.js missing. Run: npm run build")
186
+ }
187
+ const dir = join(CONFIG_DIR, "plugins")
188
+ mkdirSync(dir, { recursive: true })
189
+ const target = join(dir, "opencode-harness-kit.js")
190
+ // Relative to wherever this package actually is. The previous version wrote a
191
+ // literal `../../../harness/space-harness/...`, which was right on one machine
192
+ // and broken everywhere else, CI included.
193
+ linkOne(dist, target)
194
+ console.log(`\n ${green("✓")} linked ${target}`)
195
+ console.log(
196
+ ` ${stamp("opencode loads ~/.config/opencode/plugins automatically")}\n` +
197
+ ` ${stamp("restart opencode, then: opencode debug config")}\n`,
198
+ )
199
+ }
200
+
201
+ /** Where the behavioural layer has to land for the config to mean anything. */
202
+ function installTargets() {
203
+ return [
204
+ { src: join(ROOT, "agents"), dest: join(CONFIG_DIR, "agents"), kind: "dir" },
205
+ { src: join(ROOT, "skills"), dest: join(CONFIG_DIR, "skills"), kind: "dir" },
206
+ { src: join(ROOT, "GLOBAL.md"), dest: join(CONFIG_DIR, "GLOBAL.md"), kind: "file" },
207
+ ]
208
+ }
209
+
210
+ /**
211
+ * Replace `link` with `link` plus the agents, skills and GLOBAL.md.
212
+ *
213
+ * `use` only ever wrote opencode.jsonc, so the rest of the kit - the five role
214
+ * prompts, the four skills, the global rules - was a `cp` line in the README
215
+ * that nobody ran. Nothing checked it afterwards, which is how a config ended
216
+ * up pointing at an instruction file that did not exist while still looking
217
+ * correct. Symlinks rather than copies, so the repo stays the source of truth.
218
+ */
219
+ async function cmdInstall() {
220
+ const dist = join(ROOT, "dist", "index.js")
221
+ if (!existsSync(dist)) fail("dist/index.js missing. Run: npm run build")
222
+
223
+ // The plugin link first: without it the guard is not enforcing anything.
224
+ cmdLink()
225
+
226
+ for (const { src, dest, kind } of installTargets()) {
227
+ if (!existsSync(src)) {
228
+ console.log(` ${yellow("!")} ${stamp(`${kind} missing in repo: ${src}`)}`)
229
+ continue
230
+ }
231
+ const parent = dirname(dest)
232
+ mkdirSync(parent, { recursive: true })
233
+
234
+ // A link that already points at us needs no permission. A file that holds
235
+ // something else would be destroyed by the link, so that gets a question.
236
+ if (kind === "file" && existsSync(dest) && interactive()) {
237
+ let ours = false
238
+ try {
239
+ ours =
240
+ readlinkSync(dest) ===
241
+ relative(realpathSync(dirname(dest)), realpathSync(src))
242
+ } catch {
243
+ ours = false
244
+ }
245
+ if (!ours && !(await confirm(`overwrite ${dest}?`))) {
246
+ console.log(` ${stamp(`left alone: ${dest}`)}`)
247
+ continue
248
+ }
249
+ }
250
+
251
+ if (kind === "dir") {
252
+ // Merge rather than replace: the user may keep their own skills beside
253
+ // the kit's, and clobbering a directory that holds unrelated content is
254
+ // not something an installer should do silently.
255
+ mkdirSync(dest, { recursive: true })
256
+ for (const entry of readdirSync(src, { withFileTypes: true })) {
257
+ if (!entry.isDirectory() && !entry.name.endsWith(".md") && !entry.name.endsWith(".json")) {
258
+ continue
259
+ }
260
+ linkOne(join(src, entry.name), join(dest, entry.name))
261
+ }
262
+ continue
263
+ }
264
+ linkOne(src, dest)
265
+ }
266
+
267
+ console.log(`\n ${green("✓")} installed into ${CONFIG_DIR}`)
268
+ for (const { dest } of installTargets()) {
269
+ console.log(` ${stamp(` ${dest.replace(`${homedir()}/`, "~/")}`)}`)
270
+ }
271
+
272
+ // The commit gate lives in the repository, not in the home directory, so it
273
+ // survives a reinstall and can be reviewed. An existing hook is never
274
+ // overwritten without asking.
275
+ const hookSrc = fileURLToPath(new URL("./git-hooks/pre-commit", import.meta.url))
276
+ const hookDest = join(ROOT, ".git/hooks/pre-commit")
277
+ let hookState = "skipped, not a git repository"
278
+ if (existsSync(join(ROOT, ".git"))) {
279
+ let existing = null
280
+ try {
281
+ existing = readFileSync(hookDest, "utf8")
282
+ } catch {}
283
+ if (existing && !existing.includes("space-harness")) {
284
+ hookState = `left alone, ${hookDest} already holds something else`
285
+ } else if (!existing && interactive() && !(await confirm("install the pre-commit hook into this repo?"))) {
286
+ hookState = "skipped at the prompt"
287
+ } else {
288
+ try {
289
+ mkdirSync(join(ROOT, ".git/hooks"), { recursive: true })
290
+ copyFileSync(hookSrc, hookDest)
291
+ chmodSync(hookDest, 0o755)
292
+ hookState = `installed ${hookDest}`
293
+ } catch (err) {
294
+ hookState = `could not install: ${err.message}`
295
+ }
296
+ }
297
+ }
298
+ console.log(` ${stamp(`pre-commit hook: ${hookState}`)}`)
299
+
300
+ console.log(
301
+ `\n ${stamp('then: oc-harness use <profile> && oc-harness verify')}\n` +
302
+ `${stamp("restart opencode for the agents to be picked up")}\n`,
303
+ )
304
+ }
305
+
306
+ /**
307
+ * One command from zero to working. Asks which free profile to run on, then
308
+ * chains the three commands the README used to make the newcomer memorise.
309
+ * Anything it would destroy still gets a question unless --yes is passed.
310
+ *
311
+ * oc-harness init [--profile <id>] [--yes]
312
+ */
313
+ async function cmdInit() {
314
+ const entries = index().profiles ?? []
315
+ const free = entries.filter((e) => e.free !== false)
316
+ if (!free.length) fail("no free profiles in profiles/index.json")
317
+ let id = opt("--profile")
318
+ if (!id) {
319
+ if (!process.stdin.isTTY) {
320
+ fail("usage: oc-harness init --profile <id> [--yes] (no terminal to ask)")
321
+ }
322
+ const def = Math.max(free.findIndex((e) => e.id === index().default), 0)
323
+ console.log(`\n ${bold("run on a free model:")}\n`)
324
+ free.forEach((e, i) =>
325
+ console.log(` ${i + 1}. ${e.id}${i === def ? " (default)" : ""} ${stamp(e.note ?? "")}`),
326
+ )
327
+ const answer = (await askChoice(` pick [1-${free.length}, default ${def + 1}]: `)).trim()
328
+ const n = answer === "" ? def + 1 : parseInt(answer, 10)
329
+ if (!Number.isInteger(n) || n < 1 || n > free.length) fail("not a valid choice")
330
+ id = free[n - 1].id
331
+ }
332
+ loadProfile(id) // fail here, before anything is written, on a bad --profile
333
+ console.log(`\n ${stamp(`profile: ${id}`)}`)
334
+ await cmdInstall()
335
+ await cmdUse(id)
336
+ cmdVerify()
337
+ console.log(`\n ${green("✓")} done. ${stamp("restart opencode, then ask it something")}\n`)
338
+ }
339
+
340
+ async function askChoice(question) {
341
+ const rl = createInterface({ input: process.stdin, output: process.stdout })
342
+ try {
343
+ return await rl.question(question)
344
+ } finally {
345
+ rl.close()
346
+ }
347
+ }
348
+
349
+ /**
350
+ * Symlink one path, replacing whatever is there, without following symlinks.
351
+ *
352
+ * The link is relative so the config directory survives the package moving. The
353
+ * path it is computed from is realpath'd first: on macOS /var is a symlink to
354
+ * /private/var, so a relative path computed from /var/... resolves against
355
+ * /private/var/... and a link built that way ends up one level short of its
356
+ * target - a broken symlink that only appears outside the home directory.
357
+ */
358
+ function linkOne(src, dest) {
359
+ const parent = realpathSync(dirname(dest))
360
+ const target = relative(parent, realpathSync(src))
361
+ let stale = false
362
+ try {
363
+ stale = readlinkSync(dest) !== target
364
+ } catch {
365
+ stale = true
366
+ }
367
+ if (stale) {
368
+ try {
369
+ rmSync(dest, { force: true, recursive: false })
370
+ } catch {
371
+ // Nothing to remove, or it is a real directory we must not delete.
372
+ }
373
+ }
374
+ if (!existsSync(dest)) {
375
+ try {
376
+ symlinkSync(target, dest)
377
+ } catch (err) {
378
+ console.log(` ${yellow("!")} ${stamp(`could not link ${dest}: ${err.message}`)}`)
379
+ }
380
+ }
381
+ }
382
+
383
+ /**
384
+ * The anti-rot check. Everything here corresponds to a silent failure that has
385
+ * already happened on a real install: a config whose model was hand-edited off
386
+ * the profile, an instruction path that resolves to nothing, and a set of role
387
+ * agents that were never installed while the config still declared them.
388
+ *
389
+ * Exits non-zero when anything fails, so it can gate a commit or a CI job.
390
+ */
391
+ function cmdVerify() {
392
+ const problems = []
393
+ const notes = []
394
+ const ok = []
395
+
396
+ const activeId = profileIdFromConfig() ?? index().default
397
+ let p = null
398
+ try {
399
+ p = loadProfile(activeId)
400
+ } catch (err) {
401
+ fail(`active profile "${activeId}" cannot be loaded: ${err.message}`)
402
+ }
403
+
404
+ // 1. The generated config must still be what the profile compiles to. A
405
+ // hand-edit anywhere in the model/variant/permission surface shows up
406
+ // here, which is the check that would have caught `build` being moved onto
407
+ // a may-train model.
408
+ const actual = existsSync(CONFIG_FILE)
409
+ ? stripComments(readFileSync(CONFIG_FILE, "utf8"))
410
+ : null
411
+ if (!actual) {
412
+ problems.push(`${CONFIG_FILE} does not exist. Run: oc-harness use ${p.id}`)
413
+ } else {
414
+ const drift = configDrift(p, actual)
415
+ if (!drift.length) {
416
+ ok.push(`config matches profile ${p.id}`)
417
+ } else {
418
+ for (const d of drift) problems.push(`config drift: ${d}`)
419
+ }
420
+ }
421
+
422
+ // 2. Instruction paths must resolve. An unresolved path is a no-op, not an
423
+ // error, which is why it can sit there for weeks unnoticed.
424
+ const instructions = (actual?.instructions ?? [globalInstructionsPath()])
425
+ for (const raw of instructions) {
426
+ const target = raw.startsWith("~")
427
+ ? join(homedir(), raw.slice(1))
428
+ : raw.startsWith("/")
429
+ ? raw
430
+ : join(process.cwd(), raw)
431
+ if (existsSync(target)) {
432
+ ok.push(`instruction ${raw} resolves`)
433
+ } else {
434
+ problems.push(
435
+ `instruction "${raw}" does not exist - the rules in it are not being loaded. Run: oc-harness install`,
436
+ )
437
+ }
438
+ }
439
+
440
+ // 3. Every role the profile tiers must have an installed agent file, or the
441
+ // config entry configures a model and a permission but no behaviour.
442
+ const agentsDir = join(CONFIG_DIR, "agents")
443
+ const roles = resolveRoles(p)
444
+ for (const [role, level] of Object.entries(roles)) {
445
+ if (level === undefined) continue
446
+ const file = join(agentsDir, `${role}.md`)
447
+ if (!existsSync(file)) {
448
+ // build and plan are built-in agents: opencode reads their config entry
449
+ // even with no markdown file, but they then carry no custom prompt.
450
+ const builtin = role === "build" || role === "plan"
451
+ const msg = builtin
452
+ ? `no agents/${role}.md - ${role} is a built-in agent, so it runs with opencode's default prompt, not this kit's`
453
+ : `no agents/${role}.md - ${role} is declared in the config but does not exist as an agent. Run: oc-harness install`
454
+ problems.push(msg)
455
+ continue
456
+ }
457
+ const front = stripFrontmatter(readFileSync(file, "utf8"))
458
+ const wantModel = `${p.provider}/${p.model}`
459
+ const gotModel = front.model
460
+ if (gotModel && gotModel !== wantModel) {
461
+ problems.push(`agents/${role}.md names model ${gotModel}, profile says ${wantModel}`)
462
+ } else if (!gotModel) {
463
+ notes.push(`agents/${role}.md has no model: inherits the session model, so effort tiering may not apply`)
464
+ }
465
+ if (!gotModel && front.variant) {
466
+ problems.push(`agents/${role}.md sets variant "${front.variant}" without a model; variant only applies to the agent's own model`)
467
+ }
468
+ }
469
+
470
+ // 4. No installed agent may name a model that is not the active one. A
471
+ // stale name in a prompt is the documentation rot `space-finish` warns
472
+ // about, and it also means the agent silently runs on another model.
473
+ if (existsSync(agentsDir)) {
474
+ for (const name of readdirSync(agentsDir).filter((f) => f.endsWith(".md"))) {
475
+ const text = readFileSync(join(agentsDir, name), "utf8")
476
+ const named = text.match(/opencode\/([a-z0-9.\-]+)/i)
477
+ if (named && named[1] !== p.model) {
478
+ problems.push(`agents/${name} mentions opencode/${named[1]}, active profile is ${p.model}`)
479
+ }
480
+ }
481
+ }
482
+
483
+ // 5. The plugin link has to exist or none of the guard is running.
484
+ const pluginLink = join(CONFIG_DIR, "plugins", "opencode-harness-kit.js")
485
+ if (existsSync(pluginLink)) ok.push("plugin linked")
486
+ else notes.push("no plugin link: the guard is not enforcing anything. Run: oc-harness link")
487
+
488
+ // 6. Schema check. `variant` at the top level is silently stripped by
489
+ // OpenCode, so emitting it looks like tiering and does nothing.
490
+ if (actual && "variant" in actual) {
491
+ problems.push(
492
+ "top-level `variant` is rejected by the 1.18.35 Config schema and stripped at load; tiering comes from agent.*.variant",
493
+ )
494
+ }
495
+
496
+ // 7. The skill loop must be installed and loadable. A skill whose SKILL.md
497
+ // is missing or whose frontmatter is broken is skipped silently by
498
+ // OpenCode, so half the plan -> build -> review -> finish loop can go
499
+ // missing without any error surfacing.
500
+ const skillsDir = join(CONFIG_DIR, "skills")
501
+ if (!existsSync(skillsDir)) {
502
+ notes.push("no skills directory: the skill loop is not installed. Run: oc-harness install")
503
+ } else {
504
+ let valid = 0
505
+ for (const name of readdirSync(skillsDir)) {
506
+ const skillFile = join(skillsDir, name, "SKILL.md")
507
+ if (!existsSync(skillFile)) {
508
+ problems.push(`skills/${name} has no SKILL.md - it will never load. Run: oc-harness install`)
509
+ continue
510
+ }
511
+ const front = stripFrontmatter(readFileSync(skillFile, "utf8"))
512
+ if (front.name !== name) {
513
+ problems.push(`skills/${name}/SKILL.md names "${front.name ?? "nothing"}", expected "${name}"`)
514
+ } else if (!front.description) {
515
+ problems.push(`skills/${name}/SKILL.md has no description - OpenCode needs one to decide when to load it`)
516
+ } else {
517
+ valid += 1
518
+ }
519
+ }
520
+ if (valid) ok.push(`${valid} skill(s) valid`)
521
+ }
522
+
523
+ console.log(`\n${bold("verify")} ${stamp(`profile ${p.id} -> ${CONFIG_FILE}`)}\n`)
524
+ for (const o of ok) console.log(` ${green("✓")} ${o}`)
525
+ for (const nt of notes) console.log(` ${yellow("!")} ${nt}`)
526
+ for (const pr of problems) console.log(` ${red("✗")} ${pr}`)
527
+
528
+ if (problems.length) {
529
+ console.log(`\n ${red(`${problems.length} problem(s)`)}\n`)
530
+ process.exit(1)
531
+ }
532
+ console.log(`\n ${green("install is consistent with the profile")}\n`)
533
+ }
534
+
535
+ /** Minimal `key: value` reader for agent frontmatter. */
536
+ function stripFrontmatter(text) {
537
+ const block = /^---\n([\s\S]*?)\n---/.exec(text)
538
+ if (!block) return {}
539
+ const out = {}
540
+ for (const line of block[1].split("\n")) {
541
+ const m = /^([A-Za-z_][\w]*):\s*(.+?)\s*$/.exec(line)
542
+ if (m) out[m[1]] = m[2].replace(/^["']|["']$/g, "")
543
+ }
544
+ return out
545
+ }
546
+
547
+ /**
548
+ * Fields of the installed config that must agree with the profile. Model and
549
+ * variant first because those are the ones with consequences that are invisible
550
+ * from inside a session.
551
+ */
552
+ function configDrift(p, actual) {
553
+ const out = []
554
+ const want = `${p.provider}/${p.model}`
555
+ const roles = resolveRoles(p)
556
+
557
+ if (actual.model !== want) out.push(`model ${actual.model} -> ${want}`)
558
+
559
+ for (const [role, level] of Object.entries(roles)) {
560
+ if (level === undefined) continue
561
+ const agent = actual.agent?.[role]
562
+ if (!agent) {
563
+ out.push(`agent.${role} missing`)
564
+ continue
565
+ }
566
+ if (agent.model !== want) out.push(`agent.${role}.model ${agent.model} -> ${want}`)
567
+ if (agent.variant !== (level ?? undefined)) {
568
+ out.push(`agent.${role}.variant ${agent.variant ?? "(none)"} -> ${level ?? "(none)"}`)
569
+ }
570
+ }
571
+
572
+ const wantReserved = reservedTokens(p)
573
+ if (actual.compaction?.reserved !== wantReserved) {
574
+ out.push(`compaction.reserved ${actual.compaction?.reserved} -> ${wantReserved}`)
575
+ }
576
+ const wantThreshold = pruneThreshold(p)
577
+ if (actual.harness?.threshold !== wantThreshold) {
578
+ out.push(`harness.threshold ${actual.harness?.threshold} -> ${wantThreshold}`)
579
+ }
580
+ if (actual.permission?.edit !== "allow") {
581
+ out.push(
582
+ `permission.edit ${actual.permission?.edit ?? "(unset)"} -> allow. While it is ask, opencode appends the global rule after each agent's and last-match-wins overrides every read-only role's edit deny.`,
583
+ )
584
+ }
585
+ return out
586
+ }
587
+
588
+ async function cmdUse(id) {
589
+ if (!id) fail("usage: oc-harness use <profile> [--print] [--yes]")
590
+ const p = loadProfile(id)
591
+ const retention = loadRetention()[`${p.provider}/${p.model}`]
592
+
593
+ // A profile may not become the default until a human has stated its
594
+ // retention terms. Capability metadata cannot answer this question.
595
+ if (retention?.retention === "unknown" || (!retention && p.retention === "unknown")) {
596
+ fail(
597
+ `retention for ${p.provider}/${p.model} is not recorded in retention.yml.\n` +
598
+ `Add an entry with retention: zero-retention | may-train before installing it.`,
599
+ )
600
+ }
601
+ if (p.retention === "may-train" && !has("--yes")) {
602
+ const msg =
603
+ `${yellow("!")} ${p.provider}/${p.model} may train on your prompts and completions.\n` +
604
+ ` ${retention?.note ?? ""}\n` +
605
+ ` Re-run with --yes to accept.`
606
+ if (process.stdin.isTTY && !has("--print")) {
607
+ fail(msg + "\n")
608
+ }
609
+ console.error(msg)
610
+ }
611
+
612
+ let base = {}
613
+ if (existsSync(CONFIG_FILE)) {
614
+ base = stripComments(readFileSync(CONFIG_FILE, "utf8"))
615
+ }
616
+
617
+ const out = generateConfig(p, { base, comments: true })
618
+ if (has("--print")) {
619
+ process.stdout.write(out)
620
+ return
621
+ }
622
+
623
+ mkdirSync(dirname(CONFIG_FILE), { recursive: true })
624
+ if (existsSync(CONFIG_FILE) && !has("--yes")) {
625
+ // The config is the user's file once written. A backup is made either
626
+ // way, but overwriting it without asking is how a hand-tuned setup dies.
627
+ if (interactive() && !(await confirm(`overwrite ${CONFIG_FILE}? (a .bak backup is kept)`))) {
628
+ fail("left the existing config alone")
629
+ }
630
+ writeFileSync(`${CONFIG_FILE}.bak`, readFileSync(CONFIG_FILE))
631
+ console.error(stamp(` existing config backed up to ${CONFIG_FILE}.bak`))
632
+ }
633
+ writeFileSync(CONFIG_FILE, out)
634
+ console.log(`\n ${green("✓")} wrote ${CONFIG_FILE}`)
635
+ console.log(` ${stamp(`model ${p.provider}/${p.model}`)}`)
636
+ console.log(` ${stamp(`effort ${resolveDefaultVariant(p) ?? "(not tunable)"}`)}`)
637
+ console.log(` ${stamp(`roles ${Object.entries(resolveRoles(p)).map(([k, v]) => `${k}=${v ?? "-"}`).join(" ")}`)}`)
638
+ console.log(` ${stamp(`reserved ${reservedTokens(p).toLocaleString("en-US")} tokens`)}`)
639
+ console.log(`\n ${stamp("verify with: opencode debug config")}\n`)
640
+ }
641
+
642
+ /** opencode.jsonc is JSONC, so strip comments before parsing. */
643
+ function stripComments(text) {
644
+ const without = text
645
+ .replace(/^\s*\/\/.*$/gm, "")
646
+ .replace(/\/\*[\s\S]*?\*\//g, "")
647
+ try {
648
+ return JSON.parse(without)
649
+ } catch {
650
+ return {}
651
+ }
652
+ }
653
+
654
+ /** Re-fetch models.dev and report which profiles have drifted from it. */
655
+ async function fetchCatalog() {
656
+ const url = "https://models.dev/api.json"
657
+ try {
658
+ return JSON.parse(
659
+ execFileSync("curl", ["-sL", "--max-time", "40", url], {
660
+ encoding: "utf8",
661
+ maxBuffer: 64 * 1024 * 1024,
662
+ }),
663
+ )
664
+ } catch (err) {
665
+ fail(`could not reach ${url}: ${err.message}`)
666
+ }
667
+ }
668
+
669
+ function diffProfile(p, live) {
670
+ const issues = []
671
+ const liveLimits = live.limit ?? {}
672
+ if ((liveLimits.context ?? 0) !== p.limits.context) {
673
+ issues.push(`context ${p.limits.context} -> ${liveLimits.context}`)
674
+ }
675
+ if ((liveLimits.output ?? 0) !== p.limits.output) {
676
+ issues.push(`output ${p.limits.output} -> ${liveLimits.output}`)
677
+ }
678
+ if ((liveLimits.input ?? liveLimits.context) !== (p.limits.input ?? p.limits.context)) {
679
+ issues.push(`input ${p.limits.input} -> ${liveLimits.input ?? liveLimits.context}`)
680
+ }
681
+ const liveLevels = live.reasoning_options?.find((r) => r.type === "effort")?.values ?? []
682
+ if (JSON.stringify(liveLevels) !== JSON.stringify(p.effort?.levels ?? [])) {
683
+ issues.push(`effort [${(p.effort?.levels ?? []).join(",")}] -> [${liveLevels.join(",")}]`)
684
+ }
685
+ if (Boolean(live.temperature) !== Boolean(p.supports?.temperature)) {
686
+ issues.push(`temperature ${p.supports?.temperature} -> ${Boolean(live.temperature)}`)
687
+ }
688
+ if (Boolean(live.attachment) !== Boolean(p.supports?.attachment)) {
689
+ issues.push(`attachment ${p.supports?.attachment} -> ${Boolean(live.attachment)}`)
690
+ }
691
+ return issues
692
+ }
693
+
694
+ /**
695
+ * Refresh capability fields from live metadata.
696
+ *
697
+ * Only machine-derived fields are rewritten. `retention`, `notes`, `roles` and
698
+ * the fallback queue are human judgements and are never touched by a script:
699
+ * losing a privacy verdict to an automated rewrite would be worse than drift.
700
+ */
701
+ async function cmdRefresh(id) {
702
+ const path = profilePath(id)
703
+ if (!path) fail(`no profile named "${id}"`)
704
+ const catalog = await fetchCatalog()
705
+ const p = JSON.parse(readFileSync(path, "utf8"))
706
+ const live = catalog?.[p.provider]?.models?.[p.model]
707
+
708
+ if (!live) {
709
+ fail(
710
+ `${p.provider}/${p.model} is no longer in models.dev.\n` +
711
+ ` It may have been withdrawn from the provider. Remove the profile, or\n` +
712
+ ` keep it and set retention to reflect the current terms.`,
713
+ )
714
+ }
715
+
716
+ const before = diffProfile(p, live)
717
+ if (before.length === 0) {
718
+ console.log(`\n ${green("✓")} ${id} already matches live metadata\n`)
719
+ return
720
+ }
721
+
722
+ p.limits = {
723
+ ...p.limits,
724
+ context: live.limit?.context ?? p.limits.context,
725
+ output: live.limit?.output ?? p.limits.output,
726
+ }
727
+ // Many providers publish no separate input ceiling; drop ours so the loader
728
+ // derives it from context rather than pinning a stale number.
729
+ if (live.limit?.input) p.limits.input = live.limit.input
730
+ else delete p.limits.input
731
+
732
+ const levels = live.reasoning_options?.find((r) => r.type === "effort")?.values ?? []
733
+ p.effort = {
734
+ ...p.effort,
735
+ levels,
736
+ default: levels.includes(p.effort?.default) ? p.effort.default : levels[levels.length - 1] ?? null,
737
+ }
738
+ p.supports = {
739
+ ...p.supports,
740
+ reasoning: Boolean(live.reasoning),
741
+ temperature: Boolean(live.temperature),
742
+ toolCall: Boolean(live.tool_call),
743
+ structuredOutput: Boolean(live.structured_output),
744
+ attachment: Boolean(live.attachment),
745
+ }
746
+ p.verified = new Date().toISOString().slice(0, 10)
747
+
748
+ writeFileSync(path, `${JSON.stringify(p, null, 2)}\n`)
749
+ console.log(`\n ${green("✓")} refreshed ${path}`)
750
+ for (const line of before) console.log(` ${line}`)
751
+ console.log(
752
+ `\n ${stamp("kept as-is: retention, roles, notes, fallback (human judgement)")}\n`,
753
+ )
754
+ }
755
+
756
+ /** Re-check every profile against the live provider catalog. */
757
+ async function cmdDoctor() {
758
+ const catalog = await fetchCatalog()
759
+ const retention = loadRetention()
760
+ const ids = listProfileIds()
761
+ let problems = 0
762
+
763
+ console.log(`\n${bold("doctor")} ${stamp(`catalog ${Object.keys(catalog).length} providers`)}\n`)
764
+
765
+ for (const id of ids) {
766
+ let p
767
+ try {
768
+ p = loadProfile(id)
769
+ } catch (err) {
770
+ console.log(` ${red("✗")} ${id.padEnd(32)} ${err.message}`)
771
+ problems++
772
+ continue
773
+ }
774
+ const live = catalog?.[p.provider]?.models?.[p.model]
775
+ if (!live) {
776
+ console.log(` ${red("✗")} ${id.padEnd(32)} ${red("not in models.dev - may be withdrawn")}`)
777
+ problems++
778
+ continue
779
+ }
780
+
781
+ const issues = diffProfile(p, live)
782
+ const ret = retention[`${p.provider}/${p.model}`]
783
+ if (p.retention === "zero-retention" && ret?.retention === "may-train") {
784
+ issues.push(red("retention.yml now says may-train"))
785
+ }
786
+
787
+ if (issues.length === 0) {
788
+ console.log(` ${green("✓")} ${id.padEnd(32)} ${stamp("matches catalog")}`)
789
+ } else {
790
+ problems++
791
+ console.log(` ${yellow("!")} ${id.padEnd(32)} ${yellow("drift")}`)
792
+ for (const i of issues) console.log(` ${i}`)
793
+ console.log(` ${stamp(`fix: oc-harness use ${id} --refresh`)}`)
794
+ }
795
+ }
796
+ console.log(
797
+ problems
798
+ ? `\n ${yellow(`${problems} profile(s) drifted`)}\n`
799
+ : `\n ${green("all profiles match live metadata")}\n`,
800
+ )
801
+
802
+ // Second half: is the *installed* config still what the active profile
803
+ // compiles to? `doctor` used to only compare profiles against models.dev,
804
+ // which is the right check for perishable metadata and the wrong check for a
805
+ // config that was hand-edited after generation. The install that prompted
806
+ // this had `build` - the default agent - moved onto a may-train model while
807
+ // every other field still matched, and doctor reported everything green.
808
+ const activeId = profileIdFromConfig()
809
+ if (!activeId) {
810
+ console.log(` ${yellow("!")} ${stamp("no profile recorded in the config. Run: oc-harness use <profile>")}\n`)
811
+ return
812
+ }
813
+ const installed = existsSync(CONFIG_FILE) ? stripComments(readFileSync(CONFIG_FILE, "utf8")) : null
814
+ if (!installed) {
815
+ console.log(` ${yellow("!")} ${stamp(`${CONFIG_FILE} missing. Run: oc-harness use ${activeId}`)}\n`)
816
+ return
817
+ }
818
+ const drift = configDrift(loadProfile(activeId), installed)
819
+ console.log(`${bold("installed config")} ${stamp(activeId)}\n`)
820
+ if (drift.length === 0) {
821
+ console.log(` ${green("✓")} ${stamp("opencode.jsonc matches the profile")}`)
822
+ } else {
823
+ for (const d of drift) console.log(` ${yellow("!")} ${d}`)
824
+ console.log(`\n ${yellow(`${drift.length} field(s) drifted from the profile`)}`)
825
+ console.log(` ${stamp(`fix: oc-harness use ${activeId}`)}\n`)
826
+ }
827
+ }
828
+
829
+ /**
830
+ * Report agents in a project that the kit's effort tiering does not cover.
831
+ *
832
+ * Tiering only reaches agents named in a profile. A project agent declared in
833
+ * .opencode/agents/ with no model of its own inherits the global model and runs
834
+ * at the global effort, which is usually the most expensive tier. That is
835
+ * exactly the waste this kit exists to remove, and it is invisible otherwise.
836
+ */
837
+ function cmdAgents() {
838
+ // The global dir is where `oc-harness install` puts the kit's own agents, so
839
+ // scanning only the project reported "no project agents found" forever and hid
840
+ // the fact that none of them were installed at all.
841
+ const dirs = [
842
+ join(CONFIG_DIR, "agents"),
843
+ join(process.cwd(), ".opencode", "agents"),
844
+ join(process.cwd(), ".opencode", "agent"),
845
+ ]
846
+ const files = dirs.flatMap((d) =>
847
+ existsSync(d) ? readdirSync(d).filter((f) => f.endsWith(".md")).map((f) => join(d, f)) : [],
848
+ )
849
+
850
+ // Never hardcode a profile here: the whole point is to check the one that is
851
+ // actually active.
852
+ let active = profileIdFromConfig()
853
+ let current = null
854
+ try {
855
+ current = loadProfile(active ?? index().default)
856
+ } catch {
857
+ current = null
858
+ }
859
+ if (!current) {
860
+ fail("no profile is installed. Run: oc-harness use <profile>")
861
+ }
862
+
863
+ const config = existsSync(CONFIG_FILE) ? stripComments(readFileSync(CONFIG_FILE, "utf8")) : {}
864
+ const configured = config.model ?? "(none)"
865
+ const wantModel = `${current.provider}/${current.model}`
866
+
867
+ console.log(`\n${bold("agents")} ${stamp(`tiering against ${current.id}`)}\n`)
868
+ if (!files.length) {
869
+ console.log(` ${red("✗")} ${yellow("no agent markdown found")}`)
870
+ console.log(` ${stamp(`looked in ${dirs.join("\n ")}`)}`)
871
+ console.log(` ${stamp("run: oc-harness install")}\n`)
872
+ return
873
+ }
874
+
875
+ const roles = resolveRoles(current)
876
+ let untiered = 0
877
+ let stale = 0
878
+ for (const file of files) {
879
+ const text = readFileSync(file, "utf8")
880
+ const name = file.split("/").pop().replace(/\.md$/, "")
881
+ const front = stripFrontmatter(text)
882
+ const hasModel = Boolean(front.model)
883
+ const hasVariant = Boolean(front.variant)
884
+ const isKitRole = name in roles
885
+
886
+ const notes = []
887
+ if (!hasModel) notes.push("no model of its own, inherits the session model")
888
+ if (front.model && front.model !== wantModel) {
889
+ notes.push(`model ${front.model} is not the active ${wantModel}`)
890
+ }
891
+ if (hasModel && !hasVariant) notes.push("no variant, runs at the model default")
892
+
893
+ if (!notes.length) {
894
+ console.log(` ${green("✓")} ${name.padEnd(14)} ${stamp(`tiered${isKitRole ? ` ${roles[name]}` : ""}`)}`)
895
+ continue
896
+ }
897
+ untiered += !hasVariant || !hasModel ? 1 : 0
898
+ stale += front.model && front.model !== wantModel ? 1 : 0
899
+ console.log(` ${yellow("!")} ${name.padEnd(14)} ${yellow("not tiered")}`)
900
+ for (const nn of notes) console.log(` ${nn}`)
901
+ }
902
+
903
+ console.log(
904
+ `\n ${untiered ? yellow(`${untiered} agent(s) run at the session effort`) : green("all agents tiered")}` +
905
+ `${stale ? `, ${stale} pinned to a model that is not active` : ""}`,
906
+ )
907
+ console.log(` ${stamp("add `model:` and `variant:` to the agent frontmatter, or map them in the profile")}\n`)
908
+ }
909
+
910
+ function fail(msg) {
911
+ console.error(`\n ${red("error")} ${msg}\n`)
912
+ process.exit(1)
913
+ }
914
+
915
+ try {
916
+ switch (cmd) {
917
+ case "list":
918
+ await cmdList()
919
+ break
920
+ case "use":
921
+ await cmdUse(args[1])
922
+ break
923
+ case "show":
924
+ cmdShow(args[1])
925
+ break
926
+ case "refresh":
927
+ await cmdRefresh(args[1])
928
+ break
929
+ case "link":
930
+ cmdLink()
931
+ break
932
+ case "install":
933
+ await cmdInstall()
934
+ break
935
+ case "init":
936
+ await cmdInit()
937
+ break
938
+ case "verify":
939
+ cmdVerify()
940
+ break
941
+ case "agents":
942
+ cmdAgents()
943
+ break
944
+ case "doctor":
945
+ await cmdDoctor()
946
+ break
947
+ case "baseline":
948
+ await import("./baseline.mjs")
949
+ break
950
+ case "help":
951
+ default:
952
+ console.log(`
953
+ oc-harness <command>
954
+
955
+ list show profiles with retention and caveats
956
+ init pick a free profile, then install + use + verify in one go
957
+ use <id> compile a profile into ${CONFIG_FILE}
958
+ show <id> print the resolved effort plan and derived numbers
959
+ refresh <id> update capability fields from models.dev
960
+ install symlink the plugin, agents, skills and GLOBAL.md into the config dir
961
+ verify check the install still matches the profile and its rules are loaded
962
+ link symlink the built plugin into ~/.config/opencode/plugins
963
+ agents report agents the effort tiering does not reach
964
+ doctor re-check profiles against models.dev and the installed config
965
+ baseline measure the current opencode install
966
+
967
+ options
968
+ --print write the config to stdout instead of disk
969
+ --yes accept a retention warning, back up the old config
970
+ --retention filter list by retention class
971
+ --free list free profiles only
972
+ `)
973
+ }
974
+ } catch (err) {
975
+ if (err instanceof ProfileError) fail(err.message)
976
+ fail(err.message)
977
+ }