opencode-harness-kit 0.4.3 → 0.4.4

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.
Files changed (73) hide show
  1. package/CHANGELOG.md +527 -18
  2. package/CONTRIBUTING.md +242 -0
  3. package/GLOBAL.md +34 -5
  4. package/MIGRATION.md +151 -0
  5. package/README.md +307 -37
  6. package/SECURITY.md +30 -0
  7. package/bin/baseline.mjs +15 -7
  8. package/bin/git-hooks/pre-commit +41 -9
  9. package/bin/oc-harness.mjs +1265 -82
  10. package/dist/config.d.ts +127 -0
  11. package/dist/denylist.d.ts +33 -0
  12. package/dist/effort.d.ts +64 -0
  13. package/dist/guard.d.ts +23 -0
  14. package/dist/health.d.ts +43 -0
  15. package/dist/index.d.ts +9 -0
  16. package/dist/index.js +532 -149
  17. package/dist/plugin.d.ts +15 -0
  18. package/dist/profile.d.ts +399 -0
  19. package/dist/project.d.ts +8 -0
  20. package/dist/prune.d.ts +6 -0
  21. package/dist/router.d.ts +3 -0
  22. package/dist/rules.d.ts +109 -0
  23. package/dist/safe.d.ts +7 -0
  24. package/dist/telemetry-store.d.ts +12 -0
  25. package/dist/telemetry.d.ts +3 -0
  26. package/docs/EXIT-CODES.md +71 -0
  27. package/docs/KNOWN-LIMITS.md +45 -21
  28. package/docs/RESEARCH.md +9 -4
  29. package/docs/ROUTING.md +53 -8
  30. package/eval/RESULTS.md +443 -0
  31. package/eval/ab.mjs +743 -0
  32. package/eval/attribute.mjs +170 -0
  33. package/eval/flags.mjs +88 -0
  34. package/eval/measure-install.mjs +323 -0
  35. package/eval/measure-prune.mjs +339 -0
  36. package/eval/metrics.js +79 -0
  37. package/eval/premises.mjs +200 -0
  38. package/eval/prepare.mjs +233 -0
  39. package/eval/run.mjs +376 -0
  40. package/eval/tasks-self.json +81 -0
  41. package/eval/tasks-ts.json +50 -0
  42. package/eval/tasks.json +83 -0
  43. package/eval/thresholds.mjs +134 -0
  44. package/eval/verify.mjs +287 -0
  45. package/package.json +44 -17
  46. package/profiles/fleet-all.json +6 -1
  47. package/profiles/fleet-safe.json +7 -3
  48. package/profiles/schema.json +182 -51
  49. package/prompts/build.md +4 -2
  50. package/skills/space-build/SKILL.md +4 -2
  51. package/skills/space-finish/SKILL.md +6 -2
  52. package/skills/space-plan/reference/task-template.md +13 -5
  53. package/src/config.js +182 -32
  54. package/src/denylist.js +95 -0
  55. package/src/driver/run.mjs +81 -16
  56. package/src/effort.js +15 -4
  57. package/src/guard.ts +165 -13
  58. package/src/health.js +80 -0
  59. package/src/index.ts +15 -4
  60. package/src/lane-usage.js +72 -18
  61. package/src/plugin-type-check.ts +38 -0
  62. package/src/plugin.ts +39 -0
  63. package/src/profile.js +225 -26
  64. package/src/project.js +47 -0
  65. package/src/prune.ts +110 -31
  66. package/src/router.ts +32 -63
  67. package/src/rules.js +99 -7
  68. package/src/safe.ts +27 -0
  69. package/src/sqlite.js +144 -0
  70. package/src/tasks.js +112 -29
  71. package/src/telemetry-store.ts +62 -0
  72. package/src/telemetry.ts +60 -57
  73. package/src/trust.js +70 -9
@@ -12,9 +12,9 @@
12
12
  // The point of the kit: switching models is a data edit, not a code change.
13
13
  // Everything model-specific lives in profiles/<id>.json.
14
14
 
15
- import { chmodSync, copyFileSync, mkdirSync, readFileSync, writeFileSync, existsSync, rmSync, symlinkSync, readdirSync, readlinkSync, realpathSync, lstatSync } from "node:fs"
15
+ import { chmodSync, copyFileSync, mkdirSync, readFileSync, writeFileSync, existsSync, rmSync, symlinkSync, readdirSync, readlinkSync, realpathSync, lstatSync, appendFileSync } from "node:fs"
16
16
  import { homedir } from "node:os"
17
- import { dirname, join, relative } from "node:path"
17
+ import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path"
18
18
  import { execFileSync } from "node:child_process"
19
19
  import { fileURLToPath } from "node:url"
20
20
  import { createInterface } from "node:readline/promises"
@@ -43,9 +43,10 @@ import {
43
43
  ProfileError,
44
44
  parseJsonc,
45
45
  } from "../src/profile.js"
46
- import { generateConfig, globalInstructionsPath } from "../src/config.js"
46
+ import { generateConfig, globalInstructionsPath, utilityModel, withMemoryPlugin, canAddMemoryPlugin, kitVersion, MEMORY_PLUGIN } from "../src/config.js"
47
+ import { ruleNames, BASH_RULES, WRITE_RULES } from "../src/rules.js"
47
48
  import { planEffort } from "../src/effort.js"
48
- import { findTelemetryFile, readLaneUsage, averageMs } from "../src/lane-usage.js"
49
+ import { findTelemetryFiles, readLaneUsage, averageMs } from "../src/lane-usage.js"
49
50
  import { main as trustMain } from "../src/trust.js"
50
51
 
51
52
  const CONFIG_DIR =
@@ -56,9 +57,48 @@ const ROOT = import.meta.dirname ? join(import.meta.dirname, "..") : process.cwd
56
57
  const args = process.argv.slice(2)
57
58
  const cmd = args[0] ?? "list"
58
59
  const has = (f) => args.includes(f)
60
+ /**
61
+ * The value of a flag, in either spelling.
62
+ *
63
+ * `--profile fleet-safe` and `--profile=fleet-safe` are the same flag, and
64
+ * the second one is the conventional one. Reading only the first form made
65
+ * `--profile=fleet-safe` look like a flag with a missing value, which is a
66
+ * confusing way to be told you got the syntax wrong.
67
+ */
59
68
  const opt = (f) => {
60
69
  const i = args.indexOf(f)
61
- return i >= 0 ? args[i + 1] : undefined
70
+ if (i >= 0) return args[i + 1]
71
+ const inline = args.find((a) => a.startsWith(`${f}=`))
72
+ return inline === undefined ? undefined : inline.slice(f.length + 1)
73
+ }
74
+ /** A flag given as `--flag` or `--flag=value`. */
75
+ const flag = (f) => has(f) || args.some((a) => a.startsWith(`${f}=`))
76
+
77
+ /**
78
+ * A number from a flag, or the default.
79
+ *
80
+ * `Number("abc")` is NaN, and every comparison against NaN is false - so a
81
+ * typo in `--max-rounds` silently disabled the round cap AND the time
82
+ * budget, which are the two limits that keep an unattended loop from
83
+ * running forever.
84
+ *
85
+ * A mistyped cap is refused, not replaced with a default. The cap is the
86
+ * limit the operator chose, and a substitute is a different limit wearing
87
+ * the same name - the one thing a cap cannot be. So the run stops here,
88
+ * before a loop is started, instead of starting one under a limit nobody
89
+ * asked for.
90
+ *
91
+ * @param {string | undefined} raw
92
+ * @param {number} fallback
93
+ * @returns {number}
94
+ */
95
+ const positiveNumber = (raw, fallback) => {
96
+ if (raw === undefined) return fallback
97
+ const n = Number(raw)
98
+ if (!Number.isFinite(n) || n <= 0) {
99
+ fail(`${raw} is not a positive number. --max-rounds and --budget have to be positive numbers.`)
100
+ }
101
+ return n
62
102
  }
63
103
 
64
104
  const stamp = (s) => `\x1b[2m${s}\x1b[0m`
@@ -66,7 +106,10 @@ const bold = (s) => `\x1b[1m${s}\x1b[0m`
66
106
  const yellow = (s) => `\x1b[33m${s}\x1b[0m`
67
107
  const green = (s) => `\x1b[32m${s}\x1b[0m`
68
108
  const red = (s) => `\x1b[31m${s}\x1b[0m`
69
- const interactive = () => process.stdin.isTTY && !has("--yes")
109
+ // The colour out. The human report wants it; a JSON field does not, and a
110
+ // script that greps the message gets escape bytes it has to know to strip.
111
+ const plain = (s) => String(s).replace(/\x1b\[[0-9;]*m/g, "")
112
+ const interactive = () => process.stdin.isTTY && !flag("--yes")
70
113
 
71
114
  /**
72
115
  * A yes question with a yes default. Only asked on a terminal; scripts and
@@ -89,9 +132,9 @@ function index() {
89
132
 
90
133
  async function cmdList() {
91
134
  const idx = index()
92
- const retention = loadRetention()
135
+ const retention = loadRetention().models
93
136
  const only = opt("--retention")
94
- const freeOnly = has("--free")
137
+ const freeOnly = flag("--free")
95
138
 
96
139
  console.log(`\n${bold("opencode-harness-kit")} ${stamp("profiles")}\n`)
97
140
  const head = [
@@ -179,10 +222,64 @@ async function cmdList() {
179
222
  }
180
223
 
181
224
  function cmdShow(id) {
182
- if (!id) fail("usage: oc-harness show <profile>")
183
- const p = loadProfile(id)
225
+ const wantJson = flag("--json")
226
+ if (!id) fail("usage: oc-harness show <profile>", wantJson)
227
+ let p
228
+ try {
229
+ p = loadProfile(id)
230
+ } catch (err) {
231
+ fail(`profile "${id}" cannot be loaded: ${err instanceof Error ? err.message : String(err)}`, wantJson)
232
+ }
184
233
  const plan = planEffort(id)
185
- const retention = loadRetention()[`${p.provider}/${p.model}`]
234
+ const retention = loadRetention().models[`${p.provider}/${p.model}`]
235
+
236
+ // The machine-readable form. "What does this profile resolve to" is a
237
+ // question a script asks - to check a config, to diff two profiles, to
238
+ // feed a dashboard.
239
+ if (wantJson) {
240
+ console.log(
241
+ JSON.stringify(
242
+ {
243
+ id: p.id,
244
+ title: p.title ?? null,
245
+ provider: p.provider,
246
+ model: p.model,
247
+ limits: p.limits ?? null,
248
+ input_cap: inputCap(p),
249
+ output_cap: p.limits?.output ?? null,
250
+ effort_levels: p.effort?.levels ?? [],
251
+ default_variant: resolveDefaultVariant(p) ?? null,
252
+ temperature: Boolean(p.supports?.temperature),
253
+ attachment: Boolean(p.supports?.attachment),
254
+ reserved: reservedTokens(p),
255
+ prune_at: pruneThreshold(p),
256
+ retention: effectiveRetention(p).retention,
257
+ max_retention: p.maxRetention ?? null,
258
+ lanes: Object.fromEntries(
259
+ (roleSplitModels(p).length
260
+ ? roleSplitModels(p).map((s) => [s.role, { model: s.model, chain: resolveLaneChain(p, s.role) }])
261
+ : Object.keys(p.roles ?? {}).map((role) => [role, { model: p.model, chain: [p.model] }])
262
+ ),
263
+ ),
264
+ effort: plan.roles,
265
+ fallback: p.fallback ?? [],
266
+ // The human report calls these two things different things, so the
267
+ // JSON does too: "adjustments" are what the compiler changed about
268
+ // the plan, "notes" are what the profile says about itself.
269
+ adjustments: plan.notes ?? [],
270
+ notes: p.notes ?? null,
271
+ retention_note: retention?.note ?? null,
272
+ guard_rules: ruleNames().map((name) => {
273
+ const rule = [...BASH_RULES, ...WRITE_RULES].find((r) => r.name === name)
274
+ return { name, why: rule?.why ?? "" }
275
+ }),
276
+ },
277
+ null,
278
+ 2,
279
+ ),
280
+ )
281
+ return
282
+ }
186
283
 
187
284
  console.log(`\n${bold(p.id)} ${stamp(p.title ?? "")}\n`)
188
285
  const row = (k, v) => console.log(` ${k.padEnd(20)} ${v}`)
@@ -255,6 +352,14 @@ function cmdShow(id) {
255
352
  console.log(`\n ${bold("notes")}`)
256
353
  console.log(` ${p.notes}`)
257
354
  }
355
+ // What the guard blocks, and why. It was the one part of the kit a user
356
+ // could not inspect at all, and the answer to "why was my command blocked"
357
+ // was a guess.
358
+ console.log(`\n ${bold("guard rules")}`)
359
+ for (const name of ruleNames()) {
360
+ const rule = [...BASH_RULES, ...WRITE_RULES].find((r) => r.name === name)
361
+ console.log(` ${name.padEnd(34)} ${stamp(rule?.why ?? "")}`)
362
+ }
258
363
  console.log()
259
364
  }
260
365
 
@@ -291,6 +396,48 @@ function installTargets() {
291
396
  ]
292
397
  }
293
398
 
399
+ /**
400
+ * The git repository that contains `startDir`, or null.
401
+ *
402
+ * Found by walking up, the same way the telemetry file is found: the project
403
+ * is not necessarily the directory the command was run in.
404
+ *
405
+ * @param {string} startDir
406
+ * @returns {string | null}
407
+ */
408
+ function gitRoot(startDir) {
409
+ let dir = resolve(startDir)
410
+ for (let i = 0; i < 12; i++) {
411
+ if (existsSync(join(dir, ".git"))) return dir
412
+ const parent = dirname(dir)
413
+ if (parent === dir) break
414
+ dir = parent
415
+ }
416
+ return null
417
+ }
418
+
419
+ /**
420
+ * The project root, found by walking up from the working directory.
421
+ *
422
+ * opencode reads a project's agent files from `<project>/.opencode/`, and the
423
+ * project is not necessarily the directory the command was run from. Anchored
424
+ * on `process.cwd()`, the shadow check below found nothing whenever the
425
+ * command was run from a subdirectory - which is the one place a shadow is
426
+ * most likely to hide, because that is where the agent is working.
427
+ *
428
+ * @returns {string}
429
+ */
430
+ function projectRoot() {
431
+ let dir = process.cwd()
432
+ for (let i = 0; i < 12; i++) {
433
+ if (existsSync(join(dir, ".opencode"))) return dir
434
+ const parent = dirname(dir)
435
+ if (parent === dir) break
436
+ dir = parent
437
+ }
438
+ return process.cwd()
439
+ }
440
+
294
441
  /**
295
442
  * The directories opencode loads agent markdown files from.
296
443
  *
@@ -307,11 +454,12 @@ function installTargets() {
307
454
  * half of them.
308
455
  */
309
456
  function agentDirs() {
457
+ const project = projectRoot()
310
458
  return [
311
459
  { dir: join(CONFIG_DIR, "agents"), scope: "config" },
312
460
  { dir: join(CONFIG_DIR, "agent"), scope: "config" },
313
- { dir: join(process.cwd(), ".opencode", "agents"), scope: "project" },
314
- { dir: join(process.cwd(), ".opencode", "agent"), scope: "project" },
461
+ { dir: join(project, ".opencode", "agents"), scope: "project" },
462
+ { dir: join(project, ".opencode", "agent"), scope: "project" },
315
463
  ]
316
464
  }
317
465
 
@@ -417,13 +565,40 @@ async function cmdInstall() {
417
565
  console.log(` ${stamp(` ${dest.replace(`${homedir()}/`, "~/")}`)}`)
418
566
  }
419
567
 
420
- // The commit gate lives in the repository, not in the home directory, so it
421
- // survives a reinstall and can be reviewed. An existing hook is never
422
- // overwritten without asking.
568
+ // The commit gate lives in the PROJECT's repository, not in the kit's.
569
+ //
570
+ // It used to be written to `<kit root>/.git/hooks/pre-commit`, which for a
571
+ // user running `npx opencode-harness-kit install` is the kit's own
572
+ // installation directory - a node_modules folder or an npx cache, possibly
573
+ // read-only, and never the repository the user works in. So the gate was
574
+ // installed somewhere useless, and running the test suite installed it
575
+ // into the kit's own repository.
576
+ //
577
+ // The repository is the one that contains the directory the command was
578
+ // run in, found by walking up - the same way the telemetry file is found.
423
579
  const hookSrc = fileURLToPath(new URL("./git-hooks/pre-commit", import.meta.url))
424
- const hookDest = join(ROOT, ".git/hooks/pre-commit")
425
- let hookState = "skipped, not a git repository"
426
- if (existsSync(join(ROOT, ".git"))) {
580
+ const projectGit = gitRoot(process.cwd())
581
+ const inTheKit = projectGit !== null && resolve(projectGit) === resolve(ROOT)
582
+ // The hook is installed into the KIT's own repository, or into a project
583
+ // that asks for it with `--hook`.
584
+ //
585
+ // Installing it into every project that installs the kit is a surprise:
586
+ // the hook is a gate, and a gate nobody asked for is an obstacle. (Where it
587
+ // IS installed it is useful - the hook runs the PROJECT's own test script,
588
+ // not the kit's - but it is still the project's call.)
589
+ const hookDest =
590
+ projectGit && (inTheKit || flag("--hook"))
591
+ ? join(projectGit, ".git", "hooks", "pre-commit")
592
+ : null
593
+ // The reason a hook was not installed, when it was not. The kit's own
594
+ // repository is the one place the hook belongs, so "not a git repository"
595
+ // was never the right answer for it.
596
+ let hookState = !projectGit
597
+ ? "skipped, not a git repository"
598
+ : inTheKit
599
+ ? "skipped (the kit's own repository installs it)"
600
+ : "skipped, a hook is the project's call (use --hook to install one)"
601
+ if (hookDest && !process.env.OC_HARNESS_NO_HOOK) {
427
602
  let existing = null
428
603
  try {
429
604
  existing = readFileSync(hookDest, "utf8")
@@ -434,14 +609,16 @@ async function cmdInstall() {
434
609
  hookState = "skipped at the prompt"
435
610
  } else {
436
611
  try {
437
- mkdirSync(join(ROOT, ".git/hooks"), { recursive: true })
612
+ mkdirSync(join(projectGit, ".git", "hooks"), { recursive: true })
438
613
  copyFileSync(hookSrc, hookDest)
439
614
  chmodSync(hookDest, 0o755)
440
615
  hookState = `installed ${hookDest}`
441
616
  } catch (err) {
442
- hookState = `could not install: ${err.message}`
617
+ hookState = `could not install: ${err instanceof Error ? err.message : String(err)}`
443
618
  }
444
619
  }
620
+ } else if (process.env.OC_HARNESS_NO_HOOK) {
621
+ hookState = "skipped (OC_HARNESS_NO_HOOK is set)"
445
622
  }
446
623
  console.log(` ${stamp(`pre-commit hook: ${hookState}`)}`)
447
624
 
@@ -482,6 +659,18 @@ async function cmdInit() {
482
659
  await cmdInstall()
483
660
  await cmdUse(id)
484
661
  cmdVerify()
662
+ if (memorySpec()) {
663
+ console.log(
664
+ `\n ${stamp(
665
+ `memory: ${memorySpec()} is in the plugin list. It keeps its own config in`,
666
+ )}`,
667
+ )
668
+ console.log(
669
+ ` ${stamp(
670
+ "~/.config/opencode/opencode-mem.jsonc - give it a provider for auto-capture, or it stores without extracting.",
671
+ )}`,
672
+ )
673
+ }
485
674
  console.log(`\n ${green("✓")} done. ${stamp("restart opencode, then ask it something")}\n`)
486
675
  }
487
676
 
@@ -535,7 +724,7 @@ function linkOne(src, dest) {
535
724
  try {
536
725
  symlinkSync(target, dest)
537
726
  } catch (err) {
538
- console.log(` ${yellow("!")} ${stamp(`could not link ${dest}: ${err.message}`)}`)
727
+ console.log(` ${yellow("!")} ${stamp(`could not link ${dest}: ${err instanceof Error ? err.message : String(err)}`)}`)
539
728
  }
540
729
  }
541
730
 
@@ -557,7 +746,7 @@ function cmdVerify() {
557
746
  try {
558
747
  p = loadProfile(activeId)
559
748
  } catch (err) {
560
- fail(`active profile "${activeId}" cannot be loaded: ${err.message}`)
749
+ fail(`active profile "${activeId}" cannot be loaded: ${err instanceof Error ? err.message : String(err)}`)
561
750
  }
562
751
 
563
752
  // 1. The generated config must still be what the profile compiles to. A
@@ -578,7 +767,13 @@ function cmdVerify() {
578
767
 
579
768
  // 2. Instruction paths must resolve. An unresolved path is a no-op, not an
580
769
  // error, which is why it can sit there for weeks unnoticed.
581
- const instructions = (actual?.instructions ?? [globalInstructionsPath()])
770
+ //
771
+ // The kit's own GLOBAL.md has to be among them. A config whose
772
+ // `instructions` array replaced the kit's file was loading none of the
773
+ // behavioural layer - the lane table, the rules, the read-pressure
774
+ // guidance - and every one of those paths resolved, so the check passed
775
+ // over a harness that was doing nothing.
776
+ const instructions = /** @type {any} */ (actual?.instructions) ?? [globalInstructionsPath()]
582
777
  for (const raw of instructions) {
583
778
  const target = raw.startsWith("~")
584
779
  ? join(homedir(), raw.slice(1))
@@ -593,6 +788,19 @@ function cmdVerify() {
593
788
  )
594
789
  }
595
790
  }
791
+ const kitInstruction = globalInstructionsPath()
792
+ const kitTarget = kitInstruction.startsWith("~")
793
+ ? join(homedir(), kitInstruction.slice(1))
794
+ : kitInstruction
795
+ if (!/** @type {any} */ (instructions).includes(kitInstruction)) {
796
+ problems.push(
797
+ `the config does not load ${kitInstruction} - the kit's rules, the lane table and the read-pressure guidance are not being loaded. Run: oc-harness use ${p.id}`,
798
+ )
799
+ } else if (!existsSync(kitTarget)) {
800
+ problems.push(
801
+ `${kitInstruction} does not exist - the rules in it are not being loaded. Run: oc-harness install`,
802
+ )
803
+ }
596
804
 
597
805
  // 3. Every lane the profile enables must have its prompt installed, and the
598
806
  // config's reference to it must resolve. A lane with a model and a
@@ -638,13 +846,21 @@ function cmdVerify() {
638
846
  for (const name of readdirSync(dir).filter((f) => f.endsWith(".md"))) {
639
847
  const where = `${scope}:${dir.replace(`${homedir()}/`, "~/")}`
640
848
  const lane = name.replace(/\.md$/, "")
641
- if (lane in (actual?.agent ?? {})) {
849
+ if (lane in (/** @type {any} */ (actual?.agent) ?? {})) {
642
850
  problems.push(
643
851
  `agent file ${where}/${name} shadows the ${lane} lane: an agent file replaces the config entry, so the lane would not run on its own model. Run: oc-harness install`,
644
852
  )
645
853
  continue
646
854
  }
647
- const text = readFileSync(join(dir, name), "utf8")
855
+ // A dangling symlink is a routine state after a checkout is moved or a
856
+ // package directory is deleted. It must not abort the whole report.
857
+ let text = ""
858
+ try {
859
+ text = readFileSync(join(dir, name), "utf8")
860
+ } catch {
861
+ problems.push(`agent file ${where}/${name} cannot be read (a broken symlink?)`)
862
+ continue
863
+ }
648
864
  const named = text.match(/opencode\/([a-z0-9.\-]+)/i)
649
865
  if (named && !modelsInPlay(p).includes(named[1])) {
650
866
  problems.push(
@@ -668,13 +884,22 @@ function cmdVerify() {
668
884
  resolved = realpathSync(pluginLink)
669
885
  } catch {}
670
886
  const dataRoot = join(dirname(resolved), "..")
671
- if (!existsSync(join(dataRoot, "profiles"))) {
887
+ // The bundle needs three things beside it: the profiles, the lane table
888
+ // and the retention table. It used to check only the profiles, so a
889
+ // bundle with a missing retention.yml passed verification and then
890
+ // resolved every lane to an unknown retention.
891
+ const missing = ["profiles", "lanes.json", "retention.yml"].filter(
892
+ (name) => !existsSync(join(dataRoot, name)),
893
+ )
894
+ if (missing.length) {
672
895
  problems.push(
673
- `the plugin at ${resolved} cannot find its profiles (looked in ${dataRoot}). The guard, the pruning and the lane failover would all be silently absent. Run: oc-harness install`,
896
+ `the plugin at ${resolved} cannot find ${missing.join(", ")} (looked in ${dataRoot}). The guard, the pruning and the lane failover would all be silently absent. Run: oc-harness install`,
674
897
  )
675
898
  }
676
899
  } else {
677
- notes.push("no plugin link: the guard is not enforcing anything. Run: oc-harness link")
900
+ problems.push(
901
+ "no plugin link: the guard is not enforcing anything, so nothing in this kit is enforced. Run: oc-harness link",
902
+ )
678
903
  }
679
904
 
680
905
  // 6. Schema check. `variant` at the top level is silently stripped by
@@ -703,7 +928,9 @@ function cmdVerify() {
703
928
  const skillsDir = join(CONFIG_DIR, "skills")
704
929
  const ourSkills = kitSkillNames()
705
930
  if (!existsSync(skillsDir)) {
706
- notes.push("no skills directory: the skill loop is not installed. Run: oc-harness install")
931
+ problems.push(
932
+ "no skills directory: the skill loop is not installed, so the plan -> build -> review -> finish loop cannot run. Run: oc-harness install",
933
+ )
707
934
  } else {
708
935
  const installed = new Set(readdirSync(skillsDir))
709
936
  let valid = 0
@@ -789,7 +1016,12 @@ function configDrift(p, actual) {
789
1016
  const roles = resolveRoles(p)
790
1017
 
791
1018
  if (actual.model !== want) out.push(`model ${actual.model} -> ${want}`)
792
- if (actual.small_model !== want) out.push(`small_model ${actual.small_model} -> ${want}`)
1019
+ // The utility route, which is the session model unless the profile names
1020
+ // a cheaper one for opencode's short auxiliary calls.
1021
+ const wantUtility = utilityModel(p) ?? want
1022
+ if (actual.small_model !== wantUtility) {
1023
+ out.push(`small_model ${actual.small_model} -> ${wantUtility}`)
1024
+ }
793
1025
 
794
1026
  for (const [role, level] of Object.entries(roles)) {
795
1027
  if (level === undefined) continue
@@ -832,6 +1064,23 @@ function configDrift(p, actual) {
832
1064
  return out
833
1065
  }
834
1066
 
1067
+ /**
1068
+ * The memory plugin to turn on, or null when the flag is absent.
1069
+ *
1070
+ * `--memory` turns on the default plugin, and `--memory=<id>` picks another -
1071
+ * a pinned version, or a mirror of it.
1072
+ *
1073
+ * @returns {string | null}
1074
+ */
1075
+ function memorySpec() {
1076
+ if (!flag("--memory")) return null
1077
+ const inline = args.find((a) => a.startsWith("--memory="))
1078
+ if (inline) return inline.slice("--memory=".length)
1079
+ const next = opt("--memory")
1080
+ if (next && !next.startsWith("-")) return next
1081
+ return MEMORY_PLUGIN
1082
+ }
1083
+
835
1084
  async function cmdUse(id) {
836
1085
  if (!id) fail("usage: oc-harness use <profile> [--print] [--yes]")
837
1086
  const p = loadProfile(id)
@@ -854,32 +1103,48 @@ async function cmdUse(id) {
854
1103
  `Add an entry with retention: zero-retention | may-train before installing it.`,
855
1104
  )
856
1105
  }
857
- if (eff.retention === "may-train" && !has("--yes")) {
1106
+ if (eff.retention === "may-train" && !flag("--yes")) {
858
1107
  const trainOn = roleSplitModels(p)
859
1108
  .filter((s) => retentionFor(s.model)?.retention === "may-train")
860
1109
  .map((s) => `${s.role} runs on ${s.model}`)
861
1110
  const msg =
862
1111
  `${yellow("!")} ${eff.worst} may train on your prompts and completions.\n` +
863
1112
  (trainOn.length ? ` ${trainOn.join(", ")}.\n` : "") +
864
- ` ${retentionFor(eff.worst)?.note ?? ""}\n` +
865
- ` Re-run with --yes to accept.`
866
- if (process.stdin.isTTY && !has("--print")) {
867
- fail(msg + "\n")
1113
+ ` ${retentionFor(eff.worst)?.note ?? ""}\n`
1114
+ if (process.stdin.isTTY && !flag("--print")) {
1115
+ // On a terminal the run stops here, so the way forward is to accept it.
1116
+ fail(msg + " Re-run with --yes to accept.\n")
868
1117
  }
869
- console.error(msg)
1118
+ // Without a terminal there is nobody to ask, so the run continues and the
1119
+ // config is written. Telling the user to re-run with --yes at that point
1120
+ // would be asking them to do something that has already happened.
1121
+ console.error(msg + " Continuing: there is no terminal to ask.\n")
870
1122
  }
871
1123
 
872
1124
  const base = readConfigJson()
1125
+ // `--memory` turns on the memory plugin. The kit does not implement memory
1126
+ // - a local vector store with embeddings, a web UI and auto-capture is a
1127
+ // large thing to build badly, and `opencode-mem` already is one. What the
1128
+ // kit can do is make it one flag away and keep the config honest about it.
1129
+ const memory = memorySpec()
1130
+ const effectiveBase = memory ? withMemoryPlugin(base, memory) : base
1131
+ if (memory && !canAddMemoryPlugin(base)) {
1132
+ console.error(
1133
+ yellow(
1134
+ ` the config's plugin value is not a list, so ${memory} was not added. Fix it by hand.`,
1135
+ ),
1136
+ )
1137
+ }
873
1138
 
874
- const out = generateConfig(p, { base, comments: true })
875
- if (has("--print")) {
1139
+ const out = generateConfig(p, { base: effectiveBase, comments: true })
1140
+ if (flag("--print")) {
876
1141
  process.stdout.write(out)
877
1142
  return
878
1143
  }
879
1144
 
880
1145
  // A flag that is accepted and then ignored is worse than one that errors:
881
1146
  // `--dryrun` used to write the real config anyway.
882
- if (has("--dryrun")) {
1147
+ if (flag("--dryrun")) {
883
1148
  console.log(`\n ${stamp("dry run, nothing written")}`)
884
1149
  console.log(` ${stamp(`would write ${CONFIG_FILE}`)}`)
885
1150
  summary(p)
@@ -892,11 +1157,15 @@ async function cmdUse(id) {
892
1157
  // `--yes` skips the question, it does not skip the safety net. Gating the
893
1158
  // backup on `--yes` meant the one path that overwrites without asking was
894
1159
  // also the one path with nothing to fall back to.
895
- if (!has("--yes") && interactive() && !(await confirm(`overwrite ${CONFIG_FILE}? (a .bak backup is kept)`))) {
1160
+ if (!flag("--yes") && interactive() && !(await confirm(`overwrite ${CONFIG_FILE}? (a .bak backup is kept)`))) {
896
1161
  fail("left the existing config alone")
897
1162
  }
898
- writeFileSync(`${CONFIG_FILE}.bak`, readFileSync(CONFIG_FILE))
899
- console.error(stamp(` existing config backed up to ${CONFIG_FILE}.bak`))
1163
+ const kept = keepBackup(readFileSync(CONFIG_FILE, "utf8"))
1164
+ if (kept) {
1165
+ console.error(
1166
+ stamp(` existing config backed up to ${join(BACKUP_DIR, kept)}`),
1167
+ )
1168
+ }
900
1169
  }
901
1170
  writeFileSync(CONFIG_FILE, out)
902
1171
  console.log(`\n ${green("✓")} wrote ${CONFIG_FILE}`)
@@ -945,18 +1214,68 @@ function readConfigJson() {
945
1214
  return parsed
946
1215
  }
947
1216
 
1217
+ /**
1218
+ * Where the live model catalogue comes from.
1219
+ *
1220
+ * models.dev is the default and the only source the kit trusts for capability
1221
+ * metadata. The override exists for a mirror, and for a test: a test that
1222
+ * reached the network would fail on a machine that has none, and the kit's
1223
+ * own rule is that the suite runs offline.
1224
+ */
1225
+ const CATALOG_URL =
1226
+ process.env.OPENCODE_HARNESS_CATALOG_URL ?? "https://models.dev/api.json"
1227
+
948
1228
  /** Re-fetch models.dev and report which profiles have drifted from it. */
949
1229
  async function fetchCatalog() {
950
- const url = "https://models.dev/api.json"
1230
+ const url = CATALOG_URL
951
1231
  try {
952
- return JSON.parse(
953
- execFileSync("curl", ["-sL", "--max-time", "40", url], {
954
- encoding: "utf8",
955
- maxBuffer: 64 * 1024 * 1024,
956
- }),
957
- )
1232
+ // A `file://` URL is read directly. The catalogue override exists for a
1233
+ // mirror and for a test, and a test that needs the `curl` binary is a
1234
+ // test that fails on a minimal CI image.
1235
+ if (url.startsWith("file://")) {
1236
+ const body = readFileSync(url.slice("file://".length), "utf8")
1237
+ const parsed = JSON.parse(body)
1238
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
1239
+ throw new Error(`${url} did not return a model catalogue`)
1240
+ }
1241
+ return parsed
1242
+ }
1243
+ // `--fail` matters: without it curl exits 0 on a 4xx or a 5xx and hands
1244
+ // back an error body, which parses as a catalogue with no models in it.
1245
+ // Every profile would then be reported as withdrawn.
1246
+ const body = execFileSync("curl", ["-sL", "--fail", "--max-time", "40", url], {
1247
+ encoding: "utf8",
1248
+ maxBuffer: 64 * 1024 * 1024,
1249
+ })
1250
+ const parsed = JSON.parse(body)
1251
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
1252
+ throw new Error(`${url} did not return a model catalogue`)
1253
+ }
1254
+ return parsed
1255
+ } catch (err) {
1256
+ // THROWN, not exited. The caller decides what a catalogue that cannot be
1257
+ // read means: `doctor --watch` has to carry on, because a catalogue that
1258
+ // cannot be read says nothing about the models in it, and a failed poll
1259
+ // that ends the watch turns a transient network error into a dead
1260
+ // monitor.
1261
+ throw new Error(`could not reach ${url}: ${err instanceof Error ? err.message : String(err)}`)
1262
+ }
1263
+ }
1264
+
1265
+ /**
1266
+ * The catalogue, or a clean failure.
1267
+ *
1268
+ * The commands that read it once and go on to do something else want the
1269
+ * error as a message; the ones that watch it want the error as an error, so
1270
+ * they can carry on.
1271
+ *
1272
+ * @returns {Promise<any>}
1273
+ */
1274
+ async function fetchCatalogOrFail() {
1275
+ try {
1276
+ return await fetchCatalog()
958
1277
  } catch (err) {
959
- fail(`could not reach ${url}: ${err.message}`)
1278
+ fail(err instanceof Error ? err.message : String(err))
960
1279
  }
961
1280
  }
962
1281
 
@@ -992,10 +1311,498 @@ function diffProfile(p, live) {
992
1311
  * the fallback queue are human judgements and are never touched by a script:
993
1312
  * losing a privacy verdict to an automated rewrite would be worse than drift.
994
1313
  */
1314
+ /**
1315
+ * Refresh every shipped profile against the live catalogue.
1316
+ *
1317
+ * A free model is withdrawn in days, so a profile file goes stale in days
1318
+ * too, and there is no way to notice except by looking. This does the
1319
+ * looking for every profile at once and reports what changed.
1320
+ */
1321
+ async function cmdRefreshAll() {
1322
+ const catalog = await fetchCatalogOrFail()
1323
+ const ids = listProfileIds()
1324
+ const changed = []
1325
+ const gone = []
1326
+ const same = []
1327
+ for (const id of ids) {
1328
+ const path = profilePath(id)
1329
+ if (!path) continue
1330
+ let p
1331
+ try {
1332
+ p = JSON.parse(readFileSync(path, "utf8"))
1333
+ } catch {
1334
+ continue
1335
+ }
1336
+ const live = catalog?.[p.provider]?.models?.[p.model]
1337
+ if (!live) {
1338
+ gone.push(`${id} (${p.provider}/${p.model})`)
1339
+ continue
1340
+ }
1341
+ const before = diffProfile(p, live)
1342
+ if (!before.length) {
1343
+ same.push(id)
1344
+ continue
1345
+ }
1346
+ applyLiveMetadata(p, live)
1347
+ // `verified` is the field that says when a profile was last checked
1348
+ // against live metadata. Leaving it stale after a refresh makes it a lie.
1349
+ p.verified = new Date().toISOString().slice(0, 10)
1350
+ if (flag("--dryrun")) {
1351
+ changed.push(`${id}: ${before.join(", ")} (dry run, nothing written)`)
1352
+ continue
1353
+ }
1354
+ // A refresh overwrites a file the user may have edited by hand, and for
1355
+ // an npm install that file is inside node_modules, where the next
1356
+ // `npm install` destroys it anyway. The backup is the only copy.
1357
+ keepProfileBackup(path, readFileSync(path, "utf8"))
1358
+ writeFileSync(path, `${JSON.stringify(p, null, 2)}\n`)
1359
+ changed.push(`${id}: ${before.join(", ")}`)
1360
+ }
1361
+ console.log(`\n${bold("refreshed")} ${stamp(`${ids.length} profile(s) against the live catalogue`)}\n`)
1362
+ for (const c of changed) console.log(` ${green("~")} ${c}`)
1363
+ for (const g of gone) console.log(` ${red("!")} ${g} is no longer in the catalogue`)
1364
+ for (const id of same) console.log(` ${green("✓")} ${id} already matches`)
1365
+ if (gone.length) {
1366
+ console.log("")
1367
+ console.log(
1368
+ stamp(
1369
+ "A withdrawn model leaves its lane with one fewer rung. Check the chain, and the retention.",
1370
+ ),
1371
+ )
1372
+ }
1373
+ console.log("")
1374
+ }
1375
+
1376
+ /**
1377
+ * Whether a path is a symlink, whether or not its target exists.
1378
+ *
1379
+ * @param {string} p
1380
+ * @returns {boolean}
1381
+ */
1382
+ function isSymlink(p) {
1383
+ try {
1384
+ return lstatSync(p).isSymbolicLink()
1385
+ } catch {
1386
+ return false
1387
+ }
1388
+ }
1389
+
1390
+ /**
1391
+ * Whether a symlink points into this package.
1392
+ *
1393
+ * The target is resolved RELATIVE TO THE LINK, and without requiring it to
1394
+ * exist, so a dangling link - the thing `npm uninstall` leaves behind -
1395
+ * still counts as the kit's.
1396
+ *
1397
+ * @param {string} p
1398
+ * @returns {boolean}
1399
+ */
1400
+ function pointsIntoKit(p) {
1401
+ try {
1402
+ if (!lstatSync(p).isSymbolicLink()) return false
1403
+ const target = readlinkSync(p)
1404
+ const resolved = isAbsolute(target) ? target : resolve(dirname(p), target)
1405
+ return resolved.startsWith(ROOT + sep) || resolved.startsWith(ROOT + "/")
1406
+ } catch {
1407
+ return false
1408
+ }
1409
+ }
1410
+
1411
+ /**
1412
+ * Copy the machine-derived fields of a live catalogue entry into a profile.
1413
+ *
1414
+ * Only the fields a machine can know: the ceilings, the effort levels and the
1415
+ * capability flags. `retention`, `notes`, `roles` and the fallback queue are
1416
+ * human judgements and are never touched.
1417
+ *
1418
+ * @param {any} p the profile, mutated in place
1419
+ * @param {any} live the catalogue entry
1420
+ */
1421
+ function applyLiveMetadata(p, live) {
1422
+ p.limits = {
1423
+ ...p.limits,
1424
+ context: live.limit?.context ?? p.limits.context,
1425
+ output: live.limit?.output ?? p.limits.output,
1426
+ }
1427
+ // Many providers publish no separate input ceiling; drop ours so the loader
1428
+ // derives it from the context rather than pinning a stale number.
1429
+ if (live.limit?.input) p.limits.input = live.limit.input
1430
+ else delete p.limits.input
1431
+ const levels = live.reasoning_options?.find((r) => r.type === "effort")?.values ?? []
1432
+ p.effort = {
1433
+ ...p.effort,
1434
+ levels,
1435
+ default: levels.includes(p.effort?.default) ? p.effort.default : levels[levels.length - 1] ?? null,
1436
+ }
1437
+ p.supports = {
1438
+ ...p.supports,
1439
+ reasoning: Boolean(live.reasoning),
1440
+ temperature: Boolean(live.temperature),
1441
+ toolCall: Boolean(live.tool_call),
1442
+ structuredOutput: Boolean(live.structured_output),
1443
+ attachment: Boolean(live.attachment),
1444
+ }
1445
+ }
1446
+
1447
+ /**
1448
+ * Bring an install up to date.
1449
+ *
1450
+ * Three things go stale on their own, and all three are invisible:
1451
+ *
1452
+ * - the KIT. A new release can change the config it writes, and the old
1453
+ * config stays in place until something rewrites it.
1454
+ * - the PROFILES. A free model is withdrawn in days, and a profile file
1455
+ * that names a withdrawn model is a lane with one fewer rung.
1456
+ * - the CONFIG. A profile that changed does not change the config until
1457
+ * the config is written again.
1458
+ *
1459
+ * So: check the registry for a newer kit, refresh every profile against the
1460
+ * live catalogue, and write the config again.
1461
+ */
1462
+ /**
1463
+ * Put back the config the last `use` replaced.
1464
+ *
1465
+ * `use` keeps one `.bak` next to the config, and it OVERWRITES it on the
1466
+ * next run. So there is no history, and the backup does not say which
1467
+ * profile it was - so "go back to what I had" means "remember the old
1468
+ * profile id and run use again", which is not something a person can do at
1469
+ * 2am.
1470
+ *
1471
+ * The backup is now a DIRECTORY of the last few configs, each named with the
1472
+ * profile it came from and the time it was written.
1473
+ */
1474
+ const BACKUP_DIR = "backups"
1475
+
1476
+ /**
1477
+ * Keep a copy of a profile before it is overwritten.
1478
+ *
1479
+ * @param {string} path the profile file
1480
+ * @param {string} text its current contents
1481
+ */
1482
+ function keepProfileBackup(path, text) {
1483
+ try {
1484
+ // NOT inside `profiles/`. That directory is published to npm, and a
1485
+ // backup of a profile is not part of the package - so every refresh
1486
+ // would add a file to the tarball, forever, with nothing pruning them.
1487
+ // The backup belongs next to the config, where the other backups are.
1488
+ const dir = join(CONFIG_DIR, "profile-backups")
1489
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true })
1490
+ const stamp = new Date().toISOString().replace(/[:.]/g, "-")
1491
+ writeFileSync(join(dir, `${basename(path, ".json")}-${stamp}.json`), text)
1492
+ } catch {
1493
+ // A backup that cannot be written is not worth failing over.
1494
+ }
1495
+ }
1496
+
1497
+ /**
1498
+ * Keep a copy of the config before it is overwritten.
1499
+ *
1500
+ * @param {string} text the config as it is now
1501
+ */
1502
+ function keepBackup(text) {
1503
+ try {
1504
+ const dir = join(CONFIG_DIR, BACKUP_DIR)
1505
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true })
1506
+ // The most recent first, and only a few: a backup nobody can read
1507
+ // through is not a backup.
1508
+ const files = readdirSync(dir).filter((f) => f.endsWith(".jsonc")).sort()
1509
+ for (const f of files.slice(0, 9)) rmSync(join(dir, f), { force: true })
1510
+ const stamp = new Date().toISOString().replace(/[:.]/g, "-")
1511
+ const name = `opencode-${stamp}.jsonc`
1512
+ writeFileSync(join(dir, name), text)
1513
+ return name
1514
+ } catch {
1515
+ return null
1516
+ }
1517
+ }
1518
+
1519
+ /**
1520
+ * List the configs that were replaced.
1521
+ */
1522
+ function cmdBackups() {
1523
+ const dir = join(CONFIG_DIR, BACKUP_DIR)
1524
+ if (!existsSync(dir)) {
1525
+ console.log(`\n${bold("backups")} ${stamp("none yet")}\n`)
1526
+ return
1527
+ }
1528
+ const files = readdirSync(dir).filter((f) => f.endsWith(".jsonc")).sort().reverse()
1529
+ console.log(`\n${bold("backups")} ${stamp(`${files.length} config(s)`)}\n`)
1530
+ for (const f of files) {
1531
+ const full = join(dir, f)
1532
+ let profile = "?"
1533
+ try {
1534
+ profile = /** @type {any} */ (parseJsonc(readFileSync(full, "utf8")))?.harness?.profile ?? "?"
1535
+ } catch {}
1536
+ console.log(` ${f.padEnd(40)} ${stamp(`profile ${profile}`)}`)
1537
+ }
1538
+ console.log("")
1539
+ console.log(stamp("restore one with: oc-harness restore <file>"))
1540
+ console.log("")
1541
+ }
1542
+
1543
+ /**
1544
+ * What the kit is doing on THIS project, in numbers.
1545
+ *
1546
+ * The kit's claim is a trade: it costs a fixed overhead on every request and
1547
+ * buys back the context it prunes. Both numbers are already being written -
1548
+ * the prune log records what each pass removed, and the telemetry records
1549
+ * what each lane cost - and nothing read them together.
1550
+ *
1551
+ * This reads them and puts them next to each other, so the question "is this
1552
+ * kit earning its place here" has an answer that is about THIS project.
1553
+ */
1554
+ function cmdCost() {
1555
+ const dir = join(CONFIG_DIR, "..", ".space")
1556
+ const projectSpace = findProjectSpace(process.cwd())
1557
+ const pruneLog = projectSpace ? join(projectSpace, "prune.jsonl") : null
1558
+ const telemetry = projectSpace ? join(projectSpace, "telemetry.jsonl") : null
1559
+
1560
+ console.log(`\n${bold("cost")} ${stamp(projectSpace ?? "no project found")}\n`)
1561
+
1562
+ // 1. What the pruner removed.
1563
+ let prunes = 0
1564
+ let saved = 0
1565
+ let stubbed = 0
1566
+ if (pruneLog && existsSync(pruneLog)) {
1567
+ try {
1568
+ for (const line of readFileSync(pruneLog, "utf8").split("\n")) {
1569
+ if (!line.trim()) continue
1570
+ const r = JSON.parse(line)
1571
+ prunes++
1572
+ // The published 0.4.3 wrote `savedBytes` into the same file, and the
1573
+ // file is project-local and persistent, so the old records are still
1574
+ // there. Reading only the new name would report every project that
1575
+ // upgraded as having saved nothing.
1576
+ saved += r.savedChars ?? r.savedBytes ?? 0
1577
+ stubbed += r.stubbed ?? 0
1578
+ }
1579
+ } catch {
1580
+ // A log that cannot be read is a log with nothing in it.
1581
+ }
1582
+ }
1583
+ console.log(` ${bold("pruned")}`)
1584
+ if (!prunes) {
1585
+ console.log(` ${stamp("nothing yet - the pruner has not run in this project")}`)
1586
+ } else {
1587
+ console.log(` ${bold(saved.toLocaleString("en-US"))} characters removed over ${prunes} pass(es)`)
1588
+ console.log(` ${bold(stubbed.toLocaleString("en-US"))} tool outputs stubbed`)
1589
+ console.log(` ${stamp("the same context, re-sent on every request of the session")}`)
1590
+ }
1591
+
1592
+ // 2. What the lanes cost.
1593
+ const files = findTelemetryFiles(process.cwd())
1594
+ const { lanes: stats, requests } = readLaneUsage(files)
1595
+ console.log("")
1596
+ console.log(` ${bold("lanes")} ${stamp(`${requests} request(s)`)}`)
1597
+ const rows = Object.entries(stats)
1598
+ .map(([lane, s]) => ({
1599
+ lane,
1600
+ requests: s.requests,
1601
+ avgCtx: averageMs(s.ctx),
1602
+ avgReasoning: averageMs(s.reasoning),
1603
+ }))
1604
+ .filter((r) => r.requests > 0)
1605
+ .sort((a, b) => (b.avgCtx ?? 0) - (a.avgCtx ?? 0))
1606
+ if (!rows.length) {
1607
+ console.log(` ${stamp("no lane has been used in this project yet")}`)
1608
+ } else {
1609
+ console.log(
1610
+ ` ${"lane".padEnd(12)}${"reqs".padStart(6)}${"avg ctx".padStart(10)}${"avg reasoning".padStart(15)}`,
1611
+ )
1612
+ for (const r of rows) {
1613
+ console.log(
1614
+ ` ${r.lane.padEnd(12)}${String(r.requests).padStart(6)}${String(r.avgCtx ?? "-").padStart(10)}${String(r.avgReasoning ?? "-").padStart(15)}`,
1615
+ )
1616
+ }
1617
+ }
1618
+
1619
+ // 3. The fixed overhead.
1620
+ console.log("")
1621
+ console.log(` ${bold("fixed")}`)
1622
+ console.log(` ${stamp(`kit ${kitVersion()}, ${Object.keys(loadLanes()).length} lanes defined`)}`)
1623
+ const activeId = profileIdFromConfig()
1624
+ if (activeId) {
1625
+ try {
1626
+ const p = loadProfile(activeId)
1627
+ const models = new Set(
1628
+ Object.values(p.roleModels ?? {}).map((r) => r?.model).filter(Boolean),
1629
+ )
1630
+ console.log(` ${stamp(`profile ${activeId}: ${models.size} model(s) in play`)}`)
1631
+ } catch {
1632
+ // The active profile not loading is `verify`'s job, not this one's.
1633
+ }
1634
+ }
1635
+ console.log("")
1636
+ console.log(stamp("the full measurement, on the whole install: npm run measure:prune"))
1637
+ console.log("")
1638
+ }
1639
+
1640
+ /**
1641
+ * The `.space` directory of the project, found by walking up.
1642
+ *
1643
+ * @param {string} startDir
1644
+ * @returns {string | null}
1645
+ */
1646
+ function findProjectSpace(startDir) {
1647
+ let dir = resolve(startDir)
1648
+ for (let i = 0; i < 12; i++) {
1649
+ const space = join(dir, ".space")
1650
+ if (existsSync(space)) return space
1651
+ const parent = dirname(dir)
1652
+ if (parent === dir) break
1653
+ dir = parent
1654
+ }
1655
+ return null
1656
+ }
1657
+
1658
+ /**
1659
+ * Remove what the kit installed.
1660
+ *
1661
+ * The install SYMLINKS the plugin, the prompts, the skills and GLOBAL.md out
1662
+ * of the kit's directory and into the config directory. So `npm uninstall`
1663
+ * the kit leaves four dangling symlinks behind, and opencode then loads
1664
+ * neither the rules nor the guard - and says nothing about it.
1665
+ *
1666
+ * So the kit can take them back out, and it says what it removed.
1667
+ */
1668
+ function cmdUninstall() {
1669
+ const removed = []
1670
+ const kept = []
1671
+ const targets = [
1672
+ join(CONFIG_DIR, "plugins", "opencode-harness-kit.js"),
1673
+ join(CONFIG_DIR, "GLOBAL.md"),
1674
+ ]
1675
+ for (const dir of ["prompts", "skills"]) {
1676
+ const full = join(CONFIG_DIR, dir)
1677
+ if (!existsSync(full)) continue
1678
+ // A directory of symlinks is ours; a real directory is not.
1679
+ let entries = []
1680
+ try {
1681
+ entries = readdirSync(full)
1682
+ } catch {
1683
+ continue
1684
+ }
1685
+ // A symlink is not enough to make it ours. The user may have their own
1686
+ // symlink in that directory - to their own prompts, to a shared folder -
1687
+ // and "a symlink in prompts/ is the kit's" would delete it. A link whose
1688
+ // target is inside this package is the kit's; anything else is kept.
1689
+ //
1690
+ // The target is resolved WITHOUT requiring it to exist. A DANGLING link
1691
+ // is the case this command exists for - `npm uninstall` leaves four of
1692
+ // them behind - and `realpathSync` throws on those, which classified the
1693
+ // kit's own broken links as somebody else's and left them in place.
1694
+ const ours = entries.filter((f) => pointsIntoKit(join(full, f)))
1695
+ if (!ours.length) {
1696
+ kept.push(`${dir}/ holds real files`)
1697
+ continue
1698
+ }
1699
+ for (const f of ours) {
1700
+ try {
1701
+ rmSync(join(full, f), { force: true })
1702
+ } catch {}
1703
+ }
1704
+ removed.push(`${dir}/ (${ours.length} link(s))`)
1705
+ }
1706
+ for (const t of targets) {
1707
+ if (!existsSync(t) && !isSymlink(t)) continue
1708
+ try {
1709
+ // Only a link into this package is ours to remove. A real file the
1710
+ // user put there is theirs, and so is a link to their own file -
1711
+ // `GLOBAL.md` is a standard opencode name.
1712
+ if (!isSymlink(t)) {
1713
+ kept.push(`${t} is a real file`)
1714
+ continue
1715
+ }
1716
+ if (!pointsIntoKit(t)) {
1717
+ kept.push(`${t} points somewhere else`)
1718
+ continue
1719
+ }
1720
+ rmSync(t, { force: true })
1721
+ removed.push(t)
1722
+ } catch {
1723
+ kept.push(t)
1724
+ }
1725
+ }
1726
+ console.log(`\n${bold("uninstall")}\n`)
1727
+ for (const r of removed) console.log(` ${green("✓")} removed ${r}`)
1728
+ for (const k of kept) console.log(` ${yellow("!")} left alone: ${k}`)
1729
+ if (!removed.length) console.log(` ${stamp("nothing of the kit's was installed here")}`)
1730
+ console.log("")
1731
+ console.log(stamp("the config itself is untouched - remove it by hand if you want it gone"))
1732
+ console.log("")
1733
+ }
1734
+
1735
+ /**
1736
+ * Put a backup back.
1737
+ */
1738
+ function cmdRestore(name) {
1739
+ const dir = join(CONFIG_DIR, BACKUP_DIR)
1740
+ if (!name) {
1741
+ console.log(`\n${bold("restore")} ${stamp("which one?")}\n`)
1742
+ cmdBackups()
1743
+ return
1744
+ }
1745
+ const file = name.includes("/") ? name : join(dir, name)
1746
+ if (!existsSync(file)) fail(`no such backup: ${file}`)
1747
+ if (existsSync(CONFIG_FILE)) {
1748
+ writeFileSync(`${CONFIG_FILE}.bak`, readFileSync(CONFIG_FILE))
1749
+ }
1750
+ copyFileSync(file, CONFIG_FILE)
1751
+ console.log(`\n ${green("✓")} restored ${file} into ${CONFIG_FILE}\n`)
1752
+ console.log(stamp("verify with: oc-harness verify"))
1753
+ console.log("")
1754
+ }
1755
+
1756
+ async function cmdUpgrade() {
1757
+ console.log(`\n${bold("upgrade")}\n`)
1758
+
1759
+ // 1. The kit.
1760
+ const pkg = JSON.parse(readFileSync(join(ROOT, "package.json"), "utf8"))
1761
+ let latest = null
1762
+ try {
1763
+ const out = execFileSync(
1764
+ "npm",
1765
+ ["view", `${pkg.name}@latest`, "version"],
1766
+ { encoding: "utf8", timeout: 30_000 },
1767
+ )
1768
+ latest = out.trim()
1769
+ } catch {
1770
+ console.log(` ${yellow("!")} could not reach the registry to check for a newer kit`)
1771
+ }
1772
+ if (latest && latest !== pkg.version) {
1773
+ console.log(
1774
+ ` ${yellow("!")} ${pkg.name} ${pkg.version} is installed; ${latest} is published.`,
1775
+ )
1776
+ console.log(stamp(` update with: npm install -g ${pkg.name}@latest`))
1777
+ } else if (latest) {
1778
+ console.log(` ${green("✓")} ${pkg.name} ${pkg.version} is the published version`)
1779
+ }
1780
+
1781
+ // 2. The profiles.
1782
+ await cmdRefreshAll()
1783
+
1784
+ // 3. The config.
1785
+ const activeId = profileIdFromConfig() ?? index().default
1786
+ try {
1787
+ loadProfile(activeId)
1788
+ } catch (err) {
1789
+ fail(`the active profile cannot be loaded: ${err instanceof Error ? err.message : String(err)}`)
1790
+ }
1791
+ await cmdUse(activeId)
1792
+ console.log(` ${green("✓")} the config was written from ${activeId}`)
1793
+ console.log("")
1794
+ console.log(stamp("verify with: oc-harness verify"))
1795
+ console.log("")
1796
+ }
1797
+
995
1798
  async function cmdRefresh(id) {
1799
+ if (!id || id === "--all") {
1800
+ await cmdRefreshAll()
1801
+ return
1802
+ }
996
1803
  const path = profilePath(id)
997
1804
  if (!path) fail(`no profile named "${id}"`)
998
- const catalog = await fetchCatalog()
1805
+ const catalog = await fetchCatalogOrFail()
999
1806
  const p = JSON.parse(readFileSync(path, "utf8"))
1000
1807
  const live = catalog?.[p.provider]?.models?.[p.model]
1001
1808
 
@@ -1039,6 +1846,15 @@ async function cmdRefresh(id) {
1039
1846
  }
1040
1847
  p.verified = new Date().toISOString().slice(0, 10)
1041
1848
 
1849
+ if (flag("--dryrun")) {
1850
+ console.log(`\n ${stamp("dry run, nothing written")}`)
1851
+ for (const line of before) console.log(` ${line}`)
1852
+ return
1853
+ }
1854
+ // A refresh overwrites a file the user may have edited by hand, and for
1855
+ // an npm install that file is inside node_modules, where the next
1856
+ // `npm install` destroys it anyway. The backup is the only copy.
1857
+ keepProfileBackup(path, readFileSync(path, "utf8"))
1042
1858
  writeFileSync(path, `${JSON.stringify(p, null, 2)}\n`)
1043
1859
  console.log(`\n ${green("✓")} refreshed ${path}`)
1044
1860
  for (const line of before) console.log(` ${line}`)
@@ -1061,12 +1877,209 @@ function chainOf(p) {
1061
1877
  return out
1062
1878
  }
1063
1879
 
1064
- /** Re-check every profile against the live provider catalog. */
1880
+ /**
1881
+ * The history of what the catalogue lost.
1882
+ *
1883
+ * A model is withdrawn without a rename and without a notice, so the only
1884
+ * way to know WHEN a lane started failing is to have been looking. The
1885
+ * history is a JSONL file in the config directory, one line per withdrawal.
1886
+ */
1887
+ const WITHDRAWN_LOG = "withdrawn.jsonl"
1888
+
1889
+ /**
1890
+ * Record that a model left the catalogue.
1891
+ *
1892
+ * @param {string} model
1893
+ * @param {string} profiles the profile ids, comma-separated
1894
+ */
1895
+ function recordWithdrawal(model, profiles) {
1896
+ try {
1897
+ if (!existsSync(CONFIG_DIR)) mkdirSync(CONFIG_DIR, { recursive: true })
1898
+ appendFileSync(
1899
+ join(CONFIG_DIR, WITHDRAWN_LOG),
1900
+ JSON.stringify({
1901
+ t: new Date().toISOString(),
1902
+ model,
1903
+ profiles: String(profiles ?? "").split(",").filter(Boolean),
1904
+ }) + "\n",
1905
+ )
1906
+ } catch {
1907
+ // A history that cannot be written is not worth failing over.
1908
+ }
1909
+ }
1910
+
1911
+ /**
1912
+ * Which shipped models are no longer in the live catalogue.
1913
+ *
1914
+ * This is the question a free-tier user actually has to ask, and the answer
1915
+ * is not "has a profile drifted" - it is "is one of my routes gone". A model
1916
+ * that has left the catalogue is a model whose lane now fails over, or whose
1917
+ * chain has one fewer rung, and nobody is told.
1918
+ *
1919
+ * @param {any} catalog
1920
+ * @returns {Map<string, string[]>} the model ids that are gone, and the
1921
+ * profiles that used each one
1922
+ */
1923
+ function withdrawnModels(catalog) {
1924
+ /** @type {Map<string, string[]>} the model, and the profiles that use it */
1925
+ const gone = new Map()
1926
+ for (const id of listProfileIds()) {
1927
+ let p
1928
+ try {
1929
+ p = loadProfile(id)
1930
+ } catch {
1931
+ continue
1932
+ }
1933
+ for (const model of new Set([
1934
+ p.model,
1935
+ // The utility route is a model the kit runs, so it can be withdrawn
1936
+ // like any other.
1937
+ p.utility?.model,
1938
+ ...Object.values(p.roleModels ?? {}).flatMap((r) => r?.chain ?? []),
1939
+ ])) {
1940
+ if (!model) continue
1941
+ if (catalog?.[p.provider]?.models?.[model]) continue
1942
+ const users = gone.get(model) ?? []
1943
+ if (!users.includes(id)) users.push(id)
1944
+ gone.set(model, users)
1945
+ }
1946
+ }
1947
+ return gone
1948
+ }
1949
+
1950
+ /**
1951
+ * Watch the catalogue and report what disappeared.
1952
+ *
1953
+ * Free models on this tier are withdrawn in days - one of this kit's own
1954
+ * profiles lasted a month, another a day. A weekly `doctor` is the right
1955
+ * rhythm for a human, and this is the shape a cron wants: one run, a non-zero
1956
+ * exit when something is gone, and a line of output that says what.
1957
+ *
1958
+ * `--watch` keeps running and reports only what CHANGED, so it can sit in a
1959
+ * terminal for a week without becoming noise.
1960
+ */
1961
+ async function cmdDoctorWatch() {
1962
+ const watch = flag("--watch")
1963
+ // A mistyped interval used to become NaN, and `setTimeout(fn, NaN)` fires
1964
+ // after 0ms - so `--watch --interval abc` hammered models.dev a thousand
1965
+ // times a second.
1966
+ const interval = Math.max(30, positiveNumber(opt("--interval"), 300))
1967
+ const seen = new Set()
1968
+ let first = true
1969
+ for (;;) {
1970
+ /** @type {Map<string, string[]>} */
1971
+ let gone = new Map()
1972
+ try {
1973
+ const catalog = await fetchCatalog()
1974
+ gone = withdrawnModels(catalog)
1975
+ } catch (err) {
1976
+ // A failed fetch is NOT an empty catalogue. Treating it as one
1977
+ // announced every missing model as "is back", emptied the seen-set,
1978
+ // and made the next successful poll announce them all as gone again.
1979
+ console.error(red(` could not read the catalogue: ${err instanceof Error ? err.message : String(err)}`))
1980
+ if (!watch) process.exit(1)
1981
+ await new Promise((r) => setTimeout(r, interval * 1000))
1982
+ continue
1983
+ }
1984
+ const current = new Set(gone.keys())
1985
+ const fresh = [...gone.keys()].filter((m) => !seen.has(m))
1986
+ for (const m of fresh) {
1987
+ const users = gone.get(m) ?? []
1988
+ console.log(red(` ${new Date().toISOString()} ${m} is no longer in the catalogue`))
1989
+ if (users.length) console.log(stamp(` used by: ${users.join(", ")}`))
1990
+ console.log(stamp(` fix: oc-harness doctor && oc-harness refresh <profile>`))
1991
+ recordWithdrawal(m, users.join(","))
1992
+ }
1993
+ if (!first) {
1994
+ for (const m of seen) {
1995
+ if (!current.has(m)) {
1996
+ console.log(green(` ${new Date().toISOString()} ${m} is back`))
1997
+ }
1998
+ }
1999
+ }
2000
+ seen.clear()
2001
+ for (const m of current) seen.add(m)
2002
+ if (!watch) {
2003
+ if (gone.size) {
2004
+ console.error(
2005
+ red(` ${gone.size} shipped model(s) are gone: ${[...gone.keys()].join(", ")}`),
2006
+ )
2007
+ console.error(stamp(` recorded in ${join(CONFIG_DIR, WITHDRAWN_LOG)}`))
2008
+ process.exit(1)
2009
+ }
2010
+ console.log(green(" every shipped model is still in the catalogue"))
2011
+ return
2012
+ }
2013
+ first = false
2014
+ await new Promise((r) => setTimeout(r, interval * 1000))
2015
+ }
2016
+ }
2017
+
2018
+ /**
2019
+ * Re-check every profile against the live provider catalog.
2020
+ *
2021
+ * Two kinds of finding, and they are not the same kind of finding.
2022
+ *
2023
+ * A profile that has drifted from models.dev is perishable metadata: a
2024
+ * provider changed a context window, or a model left the catalogue. That is
2025
+ * worth reporting, and `oc-harness refresh` is the fix. It is not a broken
2026
+ * install, and a CI job that fails on it fails on a Tuesday because somebody
2027
+ * else changed a provider's limits.
2028
+ *
2029
+ * A config that has drifted from its profile, or an active profile that no
2030
+ * longer loads, is a broken install. That is always a failure.
2031
+ *
2032
+ * `--strict` makes the perishable finding fail too, for a release.
2033
+ */
1065
2034
  async function cmdDoctor() {
1066
- const catalog = await fetchCatalog()
1067
- const retention = loadRetention()
2035
+ // In JSON mode the human report is not printed at all. A report that is
2036
+ // half prose and half JSON is a report a script has to strip lines off.
2037
+ const wantJson = flag("--json")
2038
+ // ONE fetch. Two fetches means two round-trips to models.dev, and a
2039
+ // window in which the pre-check and the report can disagree - a model
2040
+ // withdrawn between them.
2041
+ let catalog
2042
+ try {
2043
+ catalog = await fetchCatalog()
2044
+ } catch (err) {
2045
+ // A catalogue that cannot be read is an error, and in JSON mode an
2046
+ // error is a JSON object: a script that pipes the output into a parser
2047
+ // gets something it can read rather than a parse failure.
2048
+ fail(err instanceof Error ? err.message : String(err), wantJson)
2049
+ }
2050
+ // The human report is built while the JSON one is not, and in JSON mode
2051
+ // the human lines go nowhere. They are collected rather than printed, so
2052
+ // the two modes print exactly one thing each.
2053
+ const realLog = console.log
2054
+ if (wantJson) console.log = () => {}
2055
+ try {
2056
+ await runDoctor(catalog)
2057
+ } finally {
2058
+ console.log = realLog
2059
+ }
2060
+ doctorReport.json = wantJson
2061
+ if (wantJson) console.log(JSON.stringify(doctorReport, null, 2))
2062
+ doctorExit()
2063
+ }
2064
+
2065
+ // The report the doctor fills in. It is rebuilt from scratch every run, so a
2066
+ // second doctor in the same process does not inherit the first one's findings.
2067
+ const doctorReport = { ok: true, problems: 0, drift: 0, strict: false, findings: [], json: false }
2068
+
2069
+ async function runDoctor(catalog) {
2070
+ const retention = loadRetention().models
1068
2071
  const ids = listProfileIds()
1069
2072
  let problems = 0
2073
+ let drift = 0
2074
+ const strict = flag("--strict")
2075
+ // The machine-readable form. "Is the install healthy" is a question a CI
2076
+ // job or a script asks, and the human report answers it in prose that has
2077
+ // to be parsed.
2078
+ doctorReport.findings = []
2079
+ const findings = doctorReport.findings
2080
+ /** @param {string} level @param {string} profile @param {string} message */
2081
+ const note = (level, profile, message) =>
2082
+ findings.push({ level, profile, message: plain(message) })
1070
2083
 
1071
2084
  console.log(`\n${bold("doctor")} ${stamp(`catalog ${Object.keys(catalog).length} providers`)}\n`)
1072
2085
 
@@ -1075,14 +2088,17 @@ async function cmdDoctor() {
1075
2088
  try {
1076
2089
  p = loadProfile(id)
1077
2090
  } catch (err) {
1078
- console.log(` ${red("✗")} ${id.padEnd(32)} ${err.message}`)
2091
+ const message = err instanceof Error ? err.message : String(err)
2092
+ console.log(` ${red("✗")} ${id.padEnd(32)} ${message}`)
2093
+ note("problem", id, message)
1079
2094
  problems++
1080
2095
  continue
1081
2096
  }
1082
2097
  const live = catalog?.[p.provider]?.models?.[p.model]
1083
2098
  if (!live) {
1084
2099
  console.log(` ${red("✗")} ${id.padEnd(32)} ${red("not in models.dev - may be withdrawn")}`)
1085
- problems++
2100
+ note("drift", id, "not in models.dev - may be withdrawn")
2101
+ drift++
1086
2102
  continue
1087
2103
  }
1088
2104
 
@@ -1111,15 +2127,16 @@ async function cmdDoctor() {
1111
2127
  if (issues.length === 0) {
1112
2128
  console.log(` ${green("✓")} ${id.padEnd(32)} ${stamp("matches catalog")}`)
1113
2129
  } else {
1114
- problems++
2130
+ drift++
1115
2131
  console.log(` ${yellow("!")} ${id.padEnd(32)} ${yellow("drift")}`)
1116
2132
  for (const i of issues) console.log(` ${i}`)
1117
2133
  console.log(` ${stamp(`fix: oc-harness refresh ${id}`)}`)
2134
+ for (const i of issues) note("drift", id, i)
1118
2135
  }
1119
2136
  }
1120
2137
  console.log(
1121
- problems
1122
- ? `\n ${yellow(`${problems} profile(s) drifted`)}\n`
2138
+ drift
2139
+ ? `\n ${yellow(`${drift} profile(s) drifted`)}\n`
1123
2140
  : `\n ${green("all profiles match live metadata")}\n`,
1124
2141
  )
1125
2142
 
@@ -1132,21 +2149,79 @@ async function cmdDoctor() {
1132
2149
  const activeId = profileIdFromConfig()
1133
2150
  if (!activeId) {
1134
2151
  console.log(` ${yellow("!")} ${stamp("no profile recorded in the config. Run: oc-harness use <profile>")}\n`)
2152
+ note("problem", "-", "no profile recorded in the config")
2153
+ problems++
2154
+ doctorReport.problems = problems
2155
+ doctorReport.drift = drift
2156
+ doctorReport.strict = strict
2157
+ doctorReport.ok = problems === 0 && (drift === 0 || !strict)
1135
2158
  return
1136
2159
  }
1137
2160
  const installed = existsSync(CONFIG_FILE) ? readConfigJson() : null
1138
2161
  if (!installed) {
1139
2162
  console.log(` ${yellow("!")} ${stamp(`${CONFIG_FILE} missing. Run: oc-harness use ${activeId}`)}\n`)
2163
+ note("problem", activeId, `${CONFIG_FILE} missing`)
2164
+ problems++
2165
+ doctorReport.problems = problems
2166
+ doctorReport.drift = drift
2167
+ doctorReport.strict = strict
2168
+ doctorReport.ok = problems === 0 && (drift === 0 || !strict)
1140
2169
  return
1141
2170
  }
1142
- const drift = configDrift(loadProfile(activeId), installed)
2171
+ // A config pointing at a profile that no longer loads is a real problem, and
2172
+ // it used to throw here and lose the whole report.
2173
+ let active = null
2174
+ try {
2175
+ active = loadProfile(activeId)
2176
+ } catch (err) {
2177
+ const message = `the active profile "${activeId}" cannot be loaded: ${err instanceof Error ? err.message : String(err)}`
2178
+ console.log(` ${red("✗")} ${stamp(message)}`)
2179
+ console.log(` ${stamp("fix: oc-harness use " + (index().default ?? "fleet-safe"))}\n`)
2180
+ note("problem", activeId, message)
2181
+ problems++
2182
+ active = null
2183
+ }
2184
+ const fields = active ? configDrift(active, installed) : []
1143
2185
  console.log(`${bold("installed config")} ${stamp(activeId)}\n`)
1144
- if (drift.length === 0) {
2186
+ if (fields.length === 0) {
1145
2187
  console.log(` ${green("✓")} ${stamp("opencode.jsonc matches the profile")}`)
1146
2188
  } else {
1147
- for (const d of drift) console.log(` ${yellow("!")} ${d}`)
1148
- console.log(`\n ${yellow(`${drift.length} field(s) drifted from the profile`)}`)
2189
+ for (const d of fields) console.log(` ${yellow("!")} ${d}`)
2190
+ console.log(`\n ${yellow(`${fields.length} field(s) drifted from the profile`)}`)
1149
2191
  console.log(` ${stamp(`fix: oc-harness use ${activeId}`)}\n`)
2192
+ for (const d of fields) note("drift", activeId, d)
2193
+ problems++
2194
+ }
2195
+ doctorReport.problems = problems
2196
+ doctorReport.drift = drift
2197
+ doctorReport.strict = strict
2198
+ doctorReport.ok = problems === 0 && (drift === 0 || !strict)
2199
+ }
2200
+
2201
+ /**
2202
+ * The exit code of `doctor`.
2203
+ *
2204
+ * A broken install fails. A profile that has drifted from models.dev is
2205
+ * perishable metadata and only fails under `--strict`, so a CI job that runs
2206
+ * this does not go red because a provider changed a context window.
2207
+ *
2208
+ * Reads the report the doctor filled in, and exits with the code the
2209
+ * report implies.
2210
+ */
2211
+ function doctorExit() {
2212
+ const { problems, drift, strict, json } = doctorReport
2213
+ // The exit code is the point of the command, and it is the same either
2214
+ // way. In JSON mode the report is the output, so nothing else is printed.
2215
+ if (problems > 0) {
2216
+ if (!json) console.log(` ${red(`${problems} problem(s) in the install`)}\n`)
2217
+ process.exit(1)
2218
+ }
2219
+ if (drift > 0 && strict) {
2220
+ if (!json) console.log(` ${red(`${drift} profile(s) drifted from live metadata`)}\n`)
2221
+ process.exit(1)
2222
+ }
2223
+ if (drift > 0 && !json) {
2224
+ console.log(` ${stamp(`${drift} profile(s) drifted from live metadata; re-run with --strict to fail on it`)}\n`)
1150
2225
  }
1151
2226
  }
1152
2227
 
@@ -1165,17 +2240,50 @@ function cmdLanes() {
1165
2240
  try {
1166
2241
  profile = loadProfile(activeId)
1167
2242
  } catch (err) {
1168
- fail(`active profile "${activeId}" cannot be loaded: ${err.message}`)
2243
+ fail(`active profile "${activeId}" cannot be loaded: ${err instanceof Error ? err.message : String(err)}`)
1169
2244
  }
1170
2245
 
1171
2246
  // The plugin writes to the project root, so the file is found by walking up
1172
2247
  // from here. Reading `process.cwd()` alone reported "never called" for every
1173
2248
  // lane whenever the command was run from a subdirectory.
1174
- const file = findTelemetryFile(process.cwd())
1175
- const { lanes: stats, records, ignored } = readLaneUsage(file)
1176
-
1177
- console.log(`\n${bold("lanes")} ${stamp(`${activeId} - ${records} lane request(s)`)}\n`)
2249
+ // ALL the telemetry files, oldest first. The plugin rotates the file at
2250
+ // 4 MB, and reading only the newest one made every lane report "never
2251
+ // used" the moment a rotation happened.
2252
+ const files = findTelemetryFiles(process.cwd())
2253
+ const { lanes: stats, requests, ignored } = readLaneUsage(files)
1178
2254
  const lanes = Object.keys(profile.roles ?? {})
2255
+
2256
+ // The machine-readable form, because "which lanes earn their place" is a
2257
+ // question a script answers, not a person.
2258
+ if (flag("--json")) {
2259
+ const out = {}
2260
+ for (const lane of lanes) {
2261
+ const s = stats[lane]
2262
+ out[lane] = s
2263
+ ? {
2264
+ requests: s.requests,
2265
+ avg_ms: averageMs(s.ms),
2266
+ avg_ctx: averageMs(s.ctx),
2267
+ avg_reasoning: averageMs(s.reasoning),
2268
+ models: [...s.models],
2269
+ }
2270
+ : { requests: 0, avg_ms: null, avg_ctx: null, avg_reasoning: null, models: [] }
2271
+ }
2272
+ // The key is "usage", not "lanes": `show --json` uses "lanes" for the
2273
+ // lane TABLE - which model each lane runs on - and two commands in one
2274
+ // package using one name for two different shapes is how a script ends
2275
+ // up reading the wrong one.
2276
+ console.log(
2277
+ JSON.stringify(
2278
+ { profile: activeId, requests, ignored, usage: out },
2279
+ null,
2280
+ 2,
2281
+ ),
2282
+ )
2283
+ return
2284
+ }
2285
+
2286
+ console.log(`\n${bold("lanes")} ${stamp(`${activeId} - ${requests} lane request(s)`)}\n`)
1179
2287
  if (!lanes.length) {
1180
2288
  console.log(` ${stamp("this profile enables no lanes")}\n`)
1181
2289
  return
@@ -1184,10 +2292,11 @@ function cmdLanes() {
1184
2292
  " " + "lane".padEnd(12),
1185
2293
  "reqs".padStart(7),
1186
2294
  "avg ms".padStart(9),
1187
- "model".padEnd(32),
2295
+ "avg ctx".padStart(10),
2296
+ "model".padEnd(30),
1188
2297
  ].join(" ")
1189
2298
  console.log(head)
1190
- console.log(" " + "-".repeat(64))
2299
+ console.log(" " + "-".repeat(70))
1191
2300
 
1192
2301
  let unused = 0
1193
2302
  for (const lane of lanes) {
@@ -1200,18 +2309,21 @@ function cmdLanes() {
1200
2309
  " " + lane.padEnd(12),
1201
2310
  String(0).padStart(7),
1202
2311
  "-".padStart(9),
1203
- (model ?? "-").padEnd(32),
2312
+ "-".padStart(10),
2313
+ (model ?? "-").padEnd(30),
1204
2314
  ].join(" ") + yellow(" never used"),
1205
2315
  )
1206
2316
  continue
1207
2317
  }
1208
2318
  const avg = averageMs(s.ms)
2319
+ const ctx = averageMs(s.ctx)
1209
2320
  console.log(
1210
2321
  [
1211
2322
  " " + lane.padEnd(12),
1212
2323
  String(s.requests).padStart(7),
1213
2324
  String(avg ?? "-").padStart(9),
1214
- ([...s.models].join(", ") || model || "-").padEnd(32),
2325
+ String(ctx ?? "-").padStart(10),
2326
+ ([...s.models].join(", ") || model || "-").padEnd(30),
1215
2327
  ].join(" "),
1216
2328
  )
1217
2329
  }
@@ -1228,7 +2340,11 @@ function cmdLanes() {
1228
2340
  ` ${stamp("one request is one assistant message; avg ms covers the requests that finished")}`,
1229
2341
  )
1230
2342
  console.log(
1231
- ` ${stamp(`telemetry is read from ${file ?? "no .space/telemetry.jsonl found by walking up from here"}`)}\n`,
2343
+ ` ${stamp(
2344
+ files.length
2345
+ ? `telemetry is read from ${files.join(", ")}`
2346
+ : "no .space/telemetry.jsonl found by walking up from here",
2347
+ )}\n`,
1232
2348
  )
1233
2349
  if (ignored) {
1234
2350
  console.log(
@@ -1306,7 +2422,13 @@ function cmdAgents() {
1306
2422
  const resolved = target.startsWith("~") ? join(homedir(), target.slice(1)) : target
1307
2423
  if (!existsSync(resolved)) notes.push(`prompt ${target} is not installed`)
1308
2424
  }
1309
- if (lane in roles && roles[lane] === undefined) notes.push("not tiered by the profile")
2425
+ // A lane the profile does not tier at all gets no variant, and a lane
2426
+ // whose model publishes no such level gets none either. `resolveRoles`
2427
+ // returns null for the second case and never undefined, so the check
2428
+ // that used to be `=== undefined` was never true and reported nothing.
2429
+ if (lane in roles && roles[lane] === null) {
2430
+ notes.push("the profile asks for a level this model does not publish, so no variant is set")
2431
+ }
1310
2432
 
1311
2433
  if (!notes.length) {
1312
2434
  console.log(
@@ -1324,10 +2446,16 @@ function cmdAgents() {
1324
2446
  // the most expensive tier. That is exactly the waste this kit exists to
1325
2447
  // remove, and it is invisible otherwise.
1326
2448
  for (const file of files) {
1327
- const text = readFileSync(file, "utf8")
2449
+ let text = ""
2450
+ try {
2451
+ text = readFileSync(file, "utf8")
2452
+ } catch {
2453
+ console.log(` ${yellow("!")} ${file.split("/").pop().padEnd(14)} ${yellow("cannot be read")}`)
2454
+ continue
2455
+ }
1328
2456
  const name = file.split("/").pop().replace(/\.md$/, "")
1329
2457
  const front = stripFrontmatter(text)
1330
- if (name in config.agent) {
2458
+ if (name in /** @type {any} */ (config.agent)) {
1331
2459
  console.log(
1332
2460
  ` ${yellow("!")} ${name.padEnd(14)} ${yellow("shadows a lane in the config")}`,
1333
2461
  )
@@ -1347,7 +2475,22 @@ function cmdAgents() {
1347
2475
  console.log(` ${stamp("add `model:` and `variant:` to the agent frontmatter, or map them in the profile")}\n`)
1348
2476
  }
1349
2477
 
1350
- function fail(msg) {
2478
+ /**
2479
+ * Stop, with a message.
2480
+ *
2481
+ * In JSON mode the message is a JSON object on STDOUT, so a script that pipes
2482
+ * the output into a parser gets an error it can read instead of a parse
2483
+ * failure. The human form still goes to stderr, where an error belongs.
2484
+ *
2485
+ * @param {string} msg
2486
+ * @param {boolean} [wantJson]
2487
+ */
2488
+ function fail(msg, wantJson = false) {
2489
+ if (wantJson) {
2490
+ console.log(JSON.stringify({ ok: false, error: plain(msg) }, null, 2))
2491
+ console.error(`\n ${red("error")} ${msg}\n`)
2492
+ process.exit(1)
2493
+ }
1351
2494
  console.error(`\n ${red("error")} ${msg}\n`)
1352
2495
  process.exit(1)
1353
2496
  }
@@ -1361,13 +2504,19 @@ function fail(msg) {
1361
2504
  * reading the transcript.
1362
2505
  */
1363
2506
  async function cmdRun() {
2507
+ // Read the caps before anything is started. A mistyped cap has to stop the
2508
+ // run here, while the answer is still "that is not a number" - not after a
2509
+ // loop has been stood up under a limit nobody asked for.
2510
+ const maxRounds = positiveNumber(opt("--max-rounds"), 20)
2511
+ const budgetMinutes = positiveNumber(opt("--budget"), 120)
2512
+
1364
2513
  const { runLoop } = await import("../src/driver/run.mjs")
1365
2514
 
1366
2515
  const result = runLoop({
1367
2516
  cwd: process.cwd(),
1368
2517
  role: opt("--role") ?? "build",
1369
- maxRounds: Number(opt("--max-rounds") ?? 20),
1370
- budgetMinutes: Number(opt("--budget") ?? 120),
2518
+ maxRounds,
2519
+ budgetMinutes,
1371
2520
  })
1372
2521
 
1373
2522
  const s = result.summary
@@ -1392,6 +2541,21 @@ try {
1392
2541
  case "refresh":
1393
2542
  await cmdRefresh(args[1])
1394
2543
  break
2544
+ case "upgrade":
2545
+ await cmdUpgrade()
2546
+ break
2547
+ case "backups":
2548
+ cmdBackups()
2549
+ break
2550
+ case "uninstall":
2551
+ cmdUninstall()
2552
+ break
2553
+ case "cost":
2554
+ cmdCost()
2555
+ break
2556
+ case "restore":
2557
+ cmdRestore(args[1])
2558
+ break
1395
2559
  case "link":
1396
2560
  cmdLink()
1397
2561
  break
@@ -1414,7 +2578,8 @@ try {
1414
2578
  process.exitCode = await trustMain(args.slice(1))
1415
2579
  break
1416
2580
  case "doctor":
1417
- await cmdDoctor()
2581
+ if (flag("--watch") || flag("--check-models")) await cmdDoctorWatch()
2582
+ else await cmdDoctor()
1418
2583
  break
1419
2584
  case "baseline":
1420
2585
  await import("./baseline.mjs")
@@ -1422,6 +2587,13 @@ try {
1422
2587
  case "run":
1423
2588
  await cmdRun()
1424
2589
  break
2590
+ case "--version":
2591
+ case "-v":
2592
+ case "version":
2593
+ console.log(
2594
+ JSON.parse(readFileSync(join(ROOT, "package.json"), "utf8")).version,
2595
+ )
2596
+ break
1425
2597
  case "help":
1426
2598
  default:
1427
2599
  console.log(`
@@ -1439,6 +2611,11 @@ try {
1439
2611
  lanes report which lanes the fleet actually used
1440
2612
  trust set up npm trusted publishing for this package
1441
2613
  doctor re-check profiles against models.dev and the installed config
2614
+ upgrade check for a newer kit, refresh the profiles, rewrite the config
2615
+ backups the configs that were replaced, newest first
2616
+ uninstall remove the plugin, prompts, skills and GLOBAL.md again
2617
+ cost what the kit is doing on this project, in numbers
2618
+ restore <file> put one back
1442
2619
  baseline measure the current opencode install
1443
2620
  run drive .space/tasks.md to done with the build agent
1444
2621
 
@@ -1447,6 +2624,12 @@ try {
1447
2624
  --yes accept a retention warning, back up the old config
1448
2625
  --retention filter list by retention class
1449
2626
  --free list free profiles only
2627
+ --memory turn on the memory plugin (--memory=<id> to pick one)
2628
+ --strict doctor: fail on perishable catalogue drift too
2629
+ --watch doctor: keep watching the catalogue, report what disappeared
2630
+ --check-models doctor: one run, non-zero when a shipped model is gone
2631
+ --json doctor, show, lanes: print the machine-readable form, and
2632
+ nothing else
1450
2633
 
1451
2634
  run options
1452
2635
  --role <agent> which agent does the work (default: build)
@@ -1462,5 +2645,5 @@ try {
1462
2645
  }
1463
2646
  } catch (err) {
1464
2647
  if (err instanceof ProfileError) fail(err.message)
1465
- fail(err.message)
2648
+ fail(err instanceof Error ? err.message : String(err))
1466
2649
  }