opencode-harness-kit 0.5.7 → 0.6.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.
@@ -47,6 +47,7 @@ import { generateConfig, globalInstructionsPath, utilityModel, withMemoryPlugin,
47
47
  import { ruleNames, BASH_RULES, WRITE_RULES } from "../src/rules.js"
48
48
  import { planEffort } from "../src/effort.js"
49
49
  import { findTelemetryFiles, readLaneUsage, averageMs } from "../src/lane-usage.js"
50
+ import { researchModels, isWithdrawn, pruneWithdrawnChains } from "../src/research.js"
50
51
  import { main as trustMain } from "../src/trust.js"
51
52
 
52
53
  const CONFIG_DIR =
@@ -64,10 +65,22 @@ const has = (f) => args.includes(f)
64
65
  * the second one is the conventional one. Reading only the first form made
65
66
  * `--profile=fleet-safe` look like a flag with a missing value, which is a
66
67
  * confusing way to be told you got the syntax wrong.
68
+ *
69
+ * A value that is itself a flag is NOT a value. `oc-harness run --role
70
+ * --max-rounds 5` used to set the role to the literal string "--max-rounds",
71
+ * and the loop then ran `opencode run --agent --max-rounds ...`, which failed
72
+ * on every round and marked every task blocked. The typo was silent because
73
+ * the value was plausible. There is no way to spell a value that starts with
74
+ * `--`, and there never needs to be: no flag of this CLI takes one.
67
75
  */
68
76
  const opt = (f) => {
69
77
  const i = args.indexOf(f)
70
- if (i >= 0) return args[i + 1]
78
+ if (i >= 0) {
79
+ const next = args[i + 1]
80
+ // A missing value, or the next flag. Both mean the flag was not given
81
+ // what it needs - which is a typo, not an empty value.
82
+ return next === undefined || next.startsWith("--") ? undefined : next
83
+ }
71
84
  const inline = args.find((a) => a.startsWith(`${f}=`))
72
85
  return inline === undefined ? undefined : inline.slice(f.length + 1)
73
86
  }
@@ -109,7 +122,11 @@ const red = (s) => `\x1b[31m${s}\x1b[0m`
109
122
  // The colour out. The human report wants it; a JSON field does not, and a
110
123
  // script that greps the message gets escape bytes it has to know to strip.
111
124
  const plain = (s) => String(s).replace(/\x1b\[[0-9;]*m/g, "")
112
- const interactive = () => process.stdin.isTTY && !flag("--yes")
125
+ // A command that must not ask. `auto` sets it: it reconciles, so a question
126
+ // has no good answer - the one thing it can do about the user's own file is
127
+ // leave it alone and say so.
128
+ let nonInteractive = false
129
+ const interactive = () => process.stdin.isTTY && !flag("--yes") && !nonInteractive
113
130
 
114
131
  /**
115
132
  * A yes question with a yes default. Only asked on a terminal; scripts and
@@ -499,13 +516,7 @@ function removeLegacyAgentFiles() {
499
516
  for (const name of readdirSync(dir)) {
500
517
  if (!name.endsWith(".md")) continue
501
518
  const full = join(dir, name)
502
- let ours = false
503
- try {
504
- ours = readlinkSync(full).includes(join("space-harness", "agents"))
505
- } catch {
506
- ours = false
507
- }
508
- if (!ours) continue
519
+ if (!isLegacyAgentLink(full)) continue
509
520
  try {
510
521
  rmSync(full, { force: true })
511
522
  removed.push(name)
@@ -515,6 +526,39 @@ function removeLegacyAgentFiles() {
515
526
  return removed
516
527
  }
517
528
 
529
+ /**
530
+ * The agent directory an earlier version of this kit installed from.
531
+ *
532
+ * The kit was named `space-harness` then, and it put its agent files in
533
+ * `<kit>/agents/`.
534
+ */
535
+ const LEGACY_AGENT_DIR = ["space-harness", "agents"]
536
+
537
+ /**
538
+ * Whether a path is a symlink into an earlier version's agent directory.
539
+ *
540
+ * The match is on the parent DIRECTORY, compared segment by segment. A plain
541
+ * substring test - `target.includes("space-harness/agents")` - also matched
542
+ * `space-harness/agents-backup/`, and this function DELETES what it matches.
543
+ * A user who symlinked their own agent file out of a directory with a similar
544
+ * name lost it, because the kit thought it had installed it.
545
+ *
546
+ * @param {string} full
547
+ * @returns {boolean}
548
+ */
549
+ function isLegacyAgentLink(full) {
550
+ let target
551
+ try {
552
+ target = readlinkSync(full)
553
+ } catch {
554
+ return false
555
+ }
556
+ const parts = dirname(target).split(/[\\/]/)
557
+ const n = LEGACY_AGENT_DIR.length
558
+ if (parts.length < n) return false
559
+ return LEGACY_AGENT_DIR.every((seg, i) => parts[parts.length - n + i] === seg)
560
+ }
561
+
518
562
  /**
519
563
  * Replace `link` with `link` plus the agents, skills and GLOBAL.md.
520
564
  *
@@ -726,6 +770,9 @@ async function cmdInit() {
726
770
  let id = opt("--profile")
727
771
  if (!id) {
728
772
  if (!process.stdin.isTTY) {
773
+ // No question can be asked without a terminal, and picking for the user
774
+ // is not this command's job: `init` is the front door for a person, and
775
+ // `auto` is the one that runs without asking. Say what to type.
729
776
  fail("usage: oc-harness init --profile <id> [--yes] (no terminal to ask)")
730
777
  }
731
778
  const def = Math.max(free.findIndex((e) => e.id === index().default), 0)
@@ -742,7 +789,7 @@ async function cmdInit() {
742
789
  console.log(`\n ${stamp(`profile: ${id}`)}`)
743
790
  await cmdInstall()
744
791
  await cmdUse(id)
745
- cmdVerify()
792
+ if (cmdVerify().length) process.exitCode = 1
746
793
  if (memorySpec()) {
747
794
  console.log(
748
795
  `\n ${stamp(
@@ -758,6 +805,143 @@ async function cmdInit() {
758
805
  console.log(`\n ${green("✓")} done. ${stamp("restart opencode, then ask it something")}\n`)
759
806
  }
760
807
 
808
+ /**
809
+ * Bring the install back to the profile, without asking anything.
810
+ *
811
+ * The four commands that keep an install alive - refresh, install, use,
812
+ * verify - are each a step somebody has to remember, in the right order, with
813
+ * the right flags. An agent that has to keep an install working has to know
814
+ * all of that, and it has to know which of them are allowed to destroy
815
+ * something. This is one command that does the whole chain.
816
+ *
817
+ * oc-harness auto [--profile <id>] [--offline] [--json]
818
+ *
819
+ * The profile is the one the installed config names, or the default. Nothing
820
+ * is asked: a file of the user's that the installer would have overwritten is
821
+ * left alone and reported, rather than being a question with no good answer.
822
+ *
823
+ * `--yes` still means consent. A profile that may train on prompts is
824
+ * refused without it, exactly as `use` refuses it - the automation is about
825
+ * the mechanics, not about the consent.
826
+ *
827
+ * `--offline` skips the refresh, for a machine that cannot reach models.dev.
828
+ * `--json` prints a report a script can read, and nothing else on stdout.
829
+ */
830
+ async function cmdAuto() {
831
+ const wantJson = flag("--json")
832
+ const idx = index()
833
+ const steps = []
834
+ const notes = []
835
+
836
+ // The profile. What was asked for, else what is installed, else the default.
837
+ // A profile that was ASKED FOR and cannot be loaded is an error - falling
838
+ // back to another one would run the install against a profile the caller
839
+ // did not name.
840
+ const requested = opt("--profile")
841
+ let id = null
842
+ if (requested) {
843
+ try {
844
+ loadProfile(requested)
845
+ id = requested
846
+ } catch (err) {
847
+ fail(`profile "${requested}" cannot be loaded: ${err instanceof Error ? err.message : String(err)}`, wantJson)
848
+ }
849
+ } else {
850
+ const active = profileIdFromConfig()
851
+ if (active) {
852
+ try {
853
+ loadProfile(active)
854
+ id = active
855
+ } catch (err) {
856
+ notes.push(
857
+ `the installed profile ${active} cannot be loaded (${err instanceof Error ? err.message : String(err)}); using ${idx.default}`,
858
+ )
859
+ }
860
+ }
861
+ if (!id) {
862
+ id = idx.default
863
+ try {
864
+ loadProfile(id)
865
+ } catch (err) {
866
+ fail(`no usable profile: ${err instanceof Error ? err.message : String(err)}`, wantJson)
867
+ }
868
+ }
869
+ }
870
+
871
+ // The steps. Their human output is silenced in JSON mode, so stdout carries
872
+ // the report and nothing else.
873
+ const realLog = console.log
874
+ if (wantJson) console.log = () => {}
875
+ const problems = []
876
+ try {
877
+ // 1. The catalogue. A model that was withdrawn has to be caught before
878
+ // it is used, and the profile files are the only place that lives.
879
+ if (flag("--offline")) {
880
+ steps.push({ step: "refresh", status: "skipped", detail: "--offline" })
881
+ } else {
882
+ // The refresh's findings are part of the report, not decoration. In JSON
883
+ // mode the refresh's own output is silenced, so a withdrawn model or a
884
+ // profile that could not be read used to vanish from the report - and
885
+ // the report said "ok": true while a profile was never checked against
886
+ // the live catalogue.
887
+ const refreshed = await cmdRefreshAll()
888
+ for (const g of refreshed.gone) {
889
+ problems.push(`profile ${g} is no longer in the live catalogue`)
890
+ }
891
+ for (const s of refreshed.skipped) {
892
+ problems.push(
893
+ `profile ${s} was skipped by the refresh, so it was not checked against the live catalogue`,
894
+ )
895
+ }
896
+ const findings = refreshed.gone.length + refreshed.skipped.length
897
+ steps.push({
898
+ step: "refresh",
899
+ status: findings ? "failed" : "ok",
900
+ ...(findings ? { detail: `${findings} finding(s)` } : {}),
901
+ })
902
+ }
903
+
904
+ // 2. The behavioural layer: the plugin link, the prompts, the skills, the
905
+ // global rules. Without them the config means nothing.
906
+ await cmdInstall()
907
+ steps.push({ step: "install", status: "ok" })
908
+
909
+ // 3. The config, recompiled from the profile.
910
+ await cmdUse(id)
911
+ steps.push({ step: "use", status: "ok", detail: id })
912
+
913
+ // 4. The check that says whether all of that worked.
914
+ const verifyProblems = cmdVerify()
915
+ problems.push(...verifyProblems)
916
+ steps.push({
917
+ step: "verify",
918
+ status: verifyProblems.length ? "failed" : "ok",
919
+ detail: verifyProblems.length ? `${verifyProblems.length} problem(s)` : "the install is consistent",
920
+ })
921
+ } finally {
922
+ console.log = realLog
923
+ }
924
+
925
+ const report = {
926
+ ok: problems.length === 0,
927
+ profile: id,
928
+ steps,
929
+ problems,
930
+ ...(notes.length ? { notes } : {}),
931
+ }
932
+
933
+ if (wantJson) {
934
+ process.stdout.write(`${JSON.stringify(report, null, 2)}\n`)
935
+ } else {
936
+ console.log(`\n${bold("auto")} ${stamp(report.ok ? "the install is in step with the profile" : `${problems.length} problem(s)`)}\n`)
937
+ for (const s of steps) console.log(` ${green("✓")} ${s.step}${s.detail ? ` - ${s.detail}` : ""}`)
938
+ for (const n of notes) console.log(` ${yellow("!")} ${n}`)
939
+ for (const pr of problems) console.log(` ${red("✗")} ${pr}`)
940
+ console.log("")
941
+ }
942
+ if (problems.length) process.exitCode = 1
943
+ }
944
+
761
945
  async function askChoice(question) {
762
946
  const rl = createInterface({ input: process.stdin, output: process.stdout })
763
947
  try {
@@ -852,13 +1036,16 @@ function linkOne(src, dest, opts = {}) {
852
1036
  * the profile, an instruction path that resolves to nothing, and a set of role
853
1037
  * agents that were never installed while the config still declared them.
854
1038
  *
855
- * Exits non-zero when anything fails, so it can gate a commit or a CI job.
1039
+ * Returns the problems it found rather than exiting on them, so a caller that
1040
+ * has more to do - `oc-harness auto` - can carry on and report the whole
1041
+ * picture. The exit code is the caller's to set.
1042
+ *
1043
+ * @returns {string[]} the problems, empty when the install is consistent
856
1044
  */
857
1045
  function cmdVerify() {
858
1046
  const problems = []
859
1047
  const notes = []
860
1048
  const ok = []
861
-
862
1049
  const activeId = profileIdFromConfig() ?? index().default
863
1050
  let p = null
864
1051
  try {
@@ -1001,7 +1188,11 @@ function cmdVerify() {
1001
1188
  for (const name of readdirSync(dir).filter((f) => f.endsWith(".md"))) {
1002
1189
  const where = `${scope}:${dir.replace(`${homedir()}/`, "~/")}`
1003
1190
  const lane = name.replace(/\.md$/, "")
1004
- if (lane in (/** @type {any} */ (actual?.agent) ?? {})) {
1191
+ // `Object.hasOwn`, not `in`. The object is the user's parsed config, and
1192
+ // `in` walks the prototype chain - so an agent file named `toString.md`
1193
+ // or `constructor.md` matched a property every object has, and `verify`
1194
+ // reported a clean install as shadowing a lane that does not exist.
1195
+ if (Object.hasOwn(/** @type {any} */ (actual?.agent) ?? {}, lane)) {
1005
1196
  problems.push(
1006
1197
  `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`,
1007
1198
  )
@@ -1152,9 +1343,10 @@ function cmdVerify() {
1152
1343
 
1153
1344
  if (problems.length) {
1154
1345
  console.log(`\n ${red(`${problems.length} problem(s)`)}\n`)
1155
- process.exit(1)
1346
+ return problems
1156
1347
  }
1157
1348
  console.log(`\n ${green("install is consistent with the profile")}\n`)
1349
+ return problems
1158
1350
  }
1159
1351
 
1160
1352
  /**
@@ -1511,6 +1703,11 @@ function diffProfile(p, live) {
1511
1703
  * Only machine-derived fields are rewritten. `retention`, `notes`, `roles` and
1512
1704
  * the fallback queue are human judgements and are never touched by a script:
1513
1705
  * losing a privacy verdict to an automated rewrite would be worse than drift.
1706
+ *
1707
+ * The ONE exception is a chain rung the provider has withdrawn. A chain names
1708
+ * the models that could serve a lane, and a withdrawn model is not one of them
1709
+ * any more - it cannot run. Removing it is a derivation from live metadata
1710
+ * and it only ever removes; nothing is ever put INTO a lane.
1514
1711
  */
1515
1712
  /**
1516
1713
  * Refresh every shipped profile against the live catalogue.
@@ -1525,22 +1722,44 @@ async function cmdRefreshAll() {
1525
1722
  const changed = []
1526
1723
  const gone = []
1527
1724
  const same = []
1725
+ // The profiles that could not be read, and the profiles whose backup could
1726
+ // not be written. Both used to disappear without a word: the first was a
1727
+ // `continue` with no output, the second was a swallowed error - and the
1728
+ // summary line counted every id it was given, so a broken profile was
1729
+ // reported as a refreshed one.
1730
+ const skipped = []
1731
+ let checked = 0
1528
1732
  for (const id of ids) {
1529
1733
  const path = profilePath(id)
1530
- if (!path) continue
1734
+ if (!path) {
1735
+ skipped.push(`${id} (no file)`)
1736
+ continue
1737
+ }
1531
1738
  let p
1532
1739
  try {
1533
1740
  p = JSON.parse(readFileSync(path, "utf8"))
1534
- } catch {
1741
+ } catch (err) {
1742
+ skipped.push(`${id} (${err instanceof Error ? err.message : String(err)})`)
1535
1743
  continue
1536
1744
  }
1745
+ checked++
1537
1746
  const live = catalog?.[p.provider]?.models?.[p.model]
1538
1747
  if (!live) {
1539
1748
  gone.push(`${id} (${p.provider}/${p.model})`)
1540
1749
  continue
1541
1750
  }
1751
+ // A model the provider has withdrawn is still IN the catalogue, so the
1752
+ // check above passes it. A profile whose own model was withdrawn has no
1753
+ // chain behind it to fall back to, so there is nothing a refresh can
1754
+ // repair - it has to be retired, and that is a person's call.
1755
+ const prune = pruneWithdrawnChains(p, catalog, p.provider)
1756
+ if (prune.dead) {
1757
+ gone.push(`${id} (${p.provider}/${p.model} was withdrawn from the provider)`)
1758
+ continue
1759
+ }
1542
1760
  const before = diffProfile(p, live)
1543
- if (!before.length) {
1761
+ const pruned = prune.pruned.length > 0
1762
+ if (!before.length && !pruned) {
1544
1763
  same.push(id)
1545
1764
  continue
1546
1765
  }
@@ -1548,21 +1767,47 @@ async function cmdRefreshAll() {
1548
1767
  // `verified` is the field that says when a profile was last checked
1549
1768
  // against live metadata. Leaving it stale after a refresh makes it a lie.
1550
1769
  p.verified = new Date().toISOString().slice(0, 10)
1770
+ const notes = [...before]
1771
+ if (pruned) {
1772
+ notes.push(`withdrawn rungs removed: ${prune.pruned.join(", ")}`)
1773
+ for (const lane of prune.lanes) {
1774
+ notes.push(`lane ${lane.lane}: ${lane.from.join(", ")} -> ${lane.to.join(", ")}`)
1775
+ }
1776
+ if (prune.utilityMoved) {
1777
+ notes.push(`utility route: ${prune.utilityMoved} -> ${p.utility.model}`)
1778
+ }
1779
+ }
1551
1780
  if (flag("--dryrun")) {
1552
- changed.push(`${id}: ${before.join(", ")} (dry run, nothing written)`)
1781
+ changed.push(`${id}: ${notes.join("; ")} (dry run, nothing written)`)
1553
1782
  continue
1554
1783
  }
1555
1784
  // A refresh overwrites a file the user may have edited by hand, and for
1556
1785
  // an npm install that file is inside node_modules, where the next
1557
- // `npm install` destroys it anyway. The backup is the only copy.
1558
- keepProfileBackup(path, readFileSync(path, "utf8"))
1786
+ // `npm install` destroys it anyway. The backup is the only copy - so a
1787
+ // backup that could not be written means the refresh must not happen.
1788
+ const kept = keepProfileBackup(path, readFileSync(path, "utf8"))
1789
+ if (!kept) {
1790
+ skipped.push(`${id} (the backup could not be written, so the profile was left alone)`)
1791
+ continue
1792
+ }
1559
1793
  writeFileSync(path, `${JSON.stringify(p, null, 2)}\n`)
1560
- changed.push(`${id}: ${before.join(", ")}`)
1794
+ changed.push(`${id}: ${notes.join("; ")}`)
1561
1795
  }
1562
- console.log(`\n${bold("refreshed")} ${stamp(`${ids.length} profile(s) against the live catalogue`)}\n`)
1796
+ console.log(
1797
+ `\n${bold("refreshed")} ${stamp(`${checked} of ${ids.length} profile(s) against the live catalogue`)}\n`,
1798
+ )
1563
1799
  for (const c of changed) console.log(` ${green("~")} ${c}`)
1564
1800
  for (const g of gone) console.log(` ${red("!")} ${g} is no longer in the catalogue`)
1565
1801
  for (const id of same) console.log(` ${green("✓")} ${id} already matches`)
1802
+ for (const s of skipped) console.log(` ${red("!")} ${s} - skipped`)
1803
+ if (skipped.length) {
1804
+ console.log("")
1805
+ console.log(
1806
+ stamp(
1807
+ "A profile that was skipped was not checked against the live catalogue. Its model may have been withdrawn without anyone noticing.",
1808
+ ),
1809
+ )
1810
+ }
1566
1811
  if (gone.length) {
1567
1812
  console.log("")
1568
1813
  console.log(
@@ -1572,6 +1817,15 @@ async function cmdRefreshAll() {
1572
1817
  )
1573
1818
  }
1574
1819
  console.log("")
1820
+ // A skipped profile is a failure, not a footnote. `refresh --all` is run by
1821
+ // a job that assumes every profile was checked, and a non-zero exit is the
1822
+ // only way that job finds out.
1823
+ if (skipped.length) process.exitCode = 1
1824
+ // The findings come back with the command. They used to be printed and
1825
+ // dropped, so a caller that silences the human output - `auto --json` -
1826
+ // never saw them: a withdrawn model or a profile that was never checked
1827
+ // against the catalogue went into a report that said everything was fine.
1828
+ return { changed, gone, same, skipped }
1575
1829
  }
1576
1830
 
1577
1831
  /**
@@ -1688,6 +1942,10 @@ const BACKUP_DIR = "backups"
1688
1942
  *
1689
1943
  * @param {string} path the profile file
1690
1944
  * @param {string} text its current contents
1945
+ * @returns {string | null} the backup's name, or null when it could not be
1946
+ * written. The caller has to treat null as a refusal to go on: a profile
1947
+ * the user edited by hand has no other copy, and for an npm install that
1948
+ * file is inside node_modules, where the next `npm install` destroys it.
1691
1949
  */
1692
1950
  function keepProfileBackup(path, text) {
1693
1951
  try {
@@ -1698,9 +1956,20 @@ function keepProfileBackup(path, text) {
1698
1956
  const dir = join(CONFIG_DIR, "profile-backups")
1699
1957
  if (!existsSync(dir)) mkdirSync(dir, { recursive: true })
1700
1958
  const stamp = new Date().toISOString().replace(/[:.]/g, "-")
1701
- writeFileSync(join(dir, `${basename(path, ".json")}-${stamp}.json`), text)
1959
+ const name = `${basename(path, ".json")}-${stamp}.json`
1960
+ writeFileSync(join(dir, name), text)
1961
+ return name
1702
1962
  } catch {
1703
- // A backup that cannot be written is not worth failing over.
1963
+ // A backup that cannot be written is not worth failing over - unless the
1964
+ // caller was about to destroy the only copy of the file it holds.
1965
+ //
1966
+ // This used to swallow the error and return nothing, and both callers
1967
+ // then overwrote the profile anyway. A user who had edited
1968
+ // `profiles/<id>.json` by hand - the retention, the roles, the fallback,
1969
+ // the parts the code itself calls "human judgement" - lost those edits
1970
+ // with no backup and no message. `keepBackup` below already had the right
1971
+ // shape: return null, and let the caller refuse to go on.
1972
+ return null
1704
1973
  }
1705
1974
  }
1706
1975
 
@@ -2060,8 +2329,30 @@ async function cmdRefresh(id) {
2060
2329
  )
2061
2330
  }
2062
2331
 
2332
+ // A withdrawn model is still in the catalogue, so the check above passes
2333
+ // it. A profile whose own model was withdrawn has no chain behind it, so
2334
+ // there is nothing to fall back to and nothing a refresh can repair.
2335
+ const prune = pruneWithdrawnChains(p, catalog, p.provider)
2336
+ if (prune.dead) {
2337
+ fail(
2338
+ `${p.provider}/${p.model} was withdrawn from the provider.\n` +
2339
+ ` This profile has no chain to fall back to, so it cannot be repaired.\n` +
2340
+ ` Remove it, or point it at a model that is still live.`,
2341
+ )
2342
+ }
2343
+
2063
2344
  const before = diffProfile(p, live)
2064
- if (before.length === 0) {
2345
+ const notes = [...before]
2346
+ if (prune.pruned.length > 0) {
2347
+ notes.push(`withdrawn rungs removed: ${prune.pruned.join(", ")}`)
2348
+ for (const lane of prune.lanes) {
2349
+ notes.push(`lane ${lane.lane}: ${lane.from.join(", ")} -> ${lane.to.join(", ")}`)
2350
+ }
2351
+ if (prune.utilityMoved) {
2352
+ notes.push(`utility route: ${prune.utilityMoved} -> ${p.utility.model}`)
2353
+ }
2354
+ }
2355
+ if (notes.length === 0) {
2065
2356
  console.log(`\n ${green("✓")} ${id} already matches live metadata\n`)
2066
2357
  return
2067
2358
  }
@@ -2094,18 +2385,24 @@ async function cmdRefresh(id) {
2094
2385
 
2095
2386
  if (flag("--dryrun")) {
2096
2387
  console.log(`\n ${stamp("dry run, nothing written")}`)
2097
- for (const line of before) console.log(` ${line}`)
2388
+ for (const line of notes) console.log(` ${line}`)
2098
2389
  return
2099
2390
  }
2100
2391
  // A refresh overwrites a file the user may have edited by hand, and for
2101
2392
  // an npm install that file is inside node_modules, where the next
2102
- // `npm install` destroys it anyway. The backup is the only copy.
2103
- keepProfileBackup(path, readFileSync(path, "utf8"))
2393
+ // `npm install` destroys it anyway. The backup is the only copy - so a
2394
+ // backup that could not be written means the refresh must not happen.
2395
+ const kept = keepProfileBackup(path, readFileSync(path, "utf8"))
2396
+ if (!kept) {
2397
+ fail(
2398
+ `could not write a backup of ${path}, so the profile was left alone. Fix the permissions on ${join(CONFIG_DIR, "profile-backups")} and run it again`,
2399
+ )
2400
+ }
2104
2401
  writeFileSync(path, `${JSON.stringify(p, null, 2)}\n`)
2105
2402
  console.log(`\n ${green("✓")} refreshed ${path}`)
2106
- for (const line of before) console.log(` ${line}`)
2403
+ for (const line of notes) console.log(` ${line}`)
2107
2404
  console.log(
2108
- `\n ${stamp("kept as-is: retention, roles, notes, fallback (human judgement)")}\n`,
2405
+ `\n ${stamp("kept as-is: retention, roles, notes (human judgement)")}\n`,
2109
2406
  )
2110
2407
  }
2111
2408
 
@@ -2163,17 +2460,26 @@ function recordWithdrawal(model, profiles) {
2163
2460
  * chain has one fewer rung, and nobody is told.
2164
2461
  *
2165
2462
  * @param {any} catalog
2166
- * @returns {Map<string, string[]>} the model ids that are gone, and the
2167
- * profiles that used each one
2463
+ * @returns {{gone: Map<string, string[]>, skipped: {id: string, why: string}[]}}
2464
+ * the model ids that are gone and the profiles that used each one, and the
2465
+ * profiles that could not be checked at all
2168
2466
  */
2169
2467
  function withdrawnModels(catalog) {
2170
2468
  /** @type {Map<string, string[]>} the model, and the profiles that use it */
2171
2469
  const gone = new Map()
2470
+ // The profiles that could not be read. They used to disappear without a
2471
+ // word - the check was a `continue` with no output - and the summary then
2472
+ // said every shipped model was still in the catalogue. So a profile that
2473
+ // was NEVER CHECKED was reported as a profile that was checked and found
2474
+ // fine. A skipped profile is a failure, not a footnote.
2475
+ /** @type {{id: string, why: string}[]} */
2476
+ const skipped = []
2172
2477
  for (const id of listProfileIds()) {
2173
2478
  let p
2174
2479
  try {
2175
2480
  p = loadProfile(id)
2176
- } catch {
2481
+ } catch (err) {
2482
+ skipped.push({ id, why: err instanceof Error ? err.message : String(err) })
2177
2483
  continue
2178
2484
  }
2179
2485
  for (const model of new Set([
@@ -2190,7 +2496,7 @@ function withdrawnModels(catalog) {
2190
2496
  gone.set(model, users)
2191
2497
  }
2192
2498
  }
2193
- return gone
2499
+ return { gone, skipped }
2194
2500
  }
2195
2501
 
2196
2502
  /**
@@ -2211,13 +2517,20 @@ async function cmdDoctorWatch() {
2211
2517
  // times a second.
2212
2518
  const interval = Math.max(30, positiveNumber(opt("--interval"), 300))
2213
2519
  const seen = new Set()
2520
+ // The profiles that could not be checked, so the watch says them once and
2521
+ // not every poll.
2522
+ const seenSkipped = new Set()
2214
2523
  let first = true
2215
2524
  for (;;) {
2216
2525
  /** @type {Map<string, string[]>} */
2217
2526
  let gone = new Map()
2527
+ /** @type {{id: string, why: string}[]} */
2528
+ let skipped = []
2218
2529
  try {
2219
2530
  const catalog = await fetchCatalog()
2220
- gone = withdrawnModels(catalog)
2531
+ const found = withdrawnModels(catalog)
2532
+ gone = found.gone
2533
+ skipped = found.skipped
2221
2534
  } catch (err) {
2222
2535
  // A failed fetch is NOT an empty catalogue. Treating it as one
2223
2536
  // announced every missing model as "is back", emptied the seen-set,
@@ -2236,6 +2549,14 @@ async function cmdDoctorWatch() {
2236
2549
  console.log(stamp(` fix: oc-harness doctor && oc-harness refresh <profile>`))
2237
2550
  recordWithdrawal(m, users.join(","))
2238
2551
  }
2552
+ for (const s of skipped) {
2553
+ if (seenSkipped.has(s.id)) continue
2554
+ seenSkipped.add(s.id)
2555
+ console.error(red(` ${s.id} could not be checked against the catalogue: ${s.why}`))
2556
+ console.error(
2557
+ stamp(" a profile that was skipped was not checked. Its model may have been withdrawn without anyone noticing."),
2558
+ )
2559
+ }
2239
2560
  if (!first) {
2240
2561
  for (const m of seen) {
2241
2562
  if (!current.has(m)) {
@@ -2253,6 +2574,15 @@ async function cmdDoctorWatch() {
2253
2574
  console.error(stamp(` recorded in ${join(CONFIG_DIR, WITHDRAWN_LOG)}`))
2254
2575
  process.exit(1)
2255
2576
  }
2577
+ // A profile that could not be read is a profile that was not checked.
2578
+ // The run above says "every shipped model is still in the catalogue",
2579
+ // and that sentence is only true of the profiles it actually looked at.
2580
+ if (skipped.length) {
2581
+ console.error(
2582
+ red(` ${skipped.length} profile(s) could not be checked: ${skipped.map((s) => s.id).join(", ")}`),
2583
+ )
2584
+ process.exit(1)
2585
+ }
2256
2586
  console.log(green(" every shipped model is still in the catalogue"))
2257
2587
  return
2258
2588
  }
@@ -2312,6 +2642,111 @@ async function cmdDoctor() {
2312
2642
  // second doctor in the same process does not inherit the first one's findings.
2313
2643
  const doctorReport = { ok: true, problems: 0, drift: 0, strict: false, findings: [], json: false }
2314
2644
 
2645
+ /**
2646
+ * Research the live tier for models the kit could use.
2647
+ *
2648
+ * `doctor` says when a model has gone. This says what has come to replace
2649
+ * it, and what is still a person's decision.
2650
+ *
2651
+ * It writes nothing. The shortlist it prints is a list to argue with, not a
2652
+ * list to accept: `retention` is a judgement about a provider's terms, and
2653
+ * that is the one field a script must not fill in.
2654
+ */
2655
+ async function cmdResearch() {
2656
+ const wantJson = flag("--json")
2657
+ let catalog
2658
+ try {
2659
+ catalog = await fetchCatalog()
2660
+ } catch (err) {
2661
+ fail(err instanceof Error ? err.message : String(err), wantJson)
2662
+ }
2663
+
2664
+ // A profile that cannot be read is `doctor`'s finding, not this command's.
2665
+ // Research is about the tier, so a broken profile is left out of the
2666
+ // comparison rather than failing the whole report.
2667
+ const profiles = []
2668
+ for (const id of listProfileIds()) {
2669
+ try {
2670
+ profiles.push(loadProfile(id))
2671
+ } catch {
2672
+ // doctor reports this, with the profile's own name on it.
2673
+ }
2674
+ }
2675
+
2676
+ const report = researchModels({
2677
+ catalog,
2678
+ profiles,
2679
+ retentionOf: (model) => modelRetention(model),
2680
+ defaultProfileId: index().default ?? null,
2681
+ })
2682
+
2683
+ if (wantJson) {
2684
+ console.log(JSON.stringify({ ok: true, ...report }, null, 2))
2685
+ return
2686
+ }
2687
+
2688
+ console.log(
2689
+ `\n${bold("research")} ${stamp(`tier ${report.tier.provider}, ${report.tier.live} live of ${report.tier.total} free`)}\n`,
2690
+ )
2691
+
2692
+ // The models the kit uses that the provider has pulled. This is the part
2693
+ // that has to be acted on, so it comes first.
2694
+ if (report.shipped.gone.length) {
2695
+ console.log(` ${red("gone")} ${report.shipped.gone.length} model(s) in use have been withdrawn\n`)
2696
+ for (const g of report.shipped.gone) {
2697
+ console.log(` ${red("✗")} ${g.id}`)
2698
+ console.log(` ${g.reason}`)
2699
+ if (g.asBase.length) {
2700
+ console.log(` ${stamp("base model of")} ${g.asBase.join(", ")}`)
2701
+ }
2702
+ for (const [profile, laneList] of Object.entries(g.inChains ?? {})) {
2703
+ console.log(` ${stamp("fallback rung in")} ${profile}: ${laneList.join(", ")}`)
2704
+ }
2705
+ }
2706
+ console.log("")
2707
+ } else {
2708
+ console.log(` ${green("✓")} every model in use is still live\n`)
2709
+ }
2710
+
2711
+ // Per lane: which rungs of its chain are alive. A lane whose whole chain
2712
+ // has gone is a lane with no fallback at all.
2713
+ if (report.lanes.length) {
2714
+ console.log(` ${bold("lanes")}`)
2715
+ for (const l of report.lanes) {
2716
+ const dead = l.dead.length
2717
+ ? yellow(` ${l.dead.length} dead (${l.dead.join(", ")})`)
2718
+ : green(" 0 dead")
2719
+ console.log(` ${l.lane.padEnd(12)} ${String(l.live.length).padStart(2)} live${dead}`)
2720
+ }
2721
+ console.log("")
2722
+ }
2723
+
2724
+ // The shortlist. What each candidate can do, which lanes it could serve,
2725
+ // and what is still undecided about it.
2726
+ if (!report.candidates.length) {
2727
+ console.log(` ${stamp("no live free model outside the ones the kit already uses")}\n`)
2728
+ return
2729
+ }
2730
+ console.log(` ${bold("candidates")} ${stamp("live, free, and not in use yet")}\n`)
2731
+ for (const c of report.candidates) {
2732
+ const effort = c.effort.length ? c.effort.join(",") : "none published"
2733
+ console.log(
2734
+ ` ${bold(c.id)} ${stamp(
2735
+ `ctx ${Number(c.context).toLocaleString("en-US")} out ${Number(c.output).toLocaleString("en-US")} effort ${effort}`,
2736
+ )}`,
2737
+ )
2738
+ console.log(` fits: ${c.fits.length ? c.fits.join(", ") : "no lane on its own terms"}`)
2739
+ console.log(
2740
+ ` ${c.retention === "unknown" ? yellow("retention: unknown") : stamp(`retention: ${c.retention}`)}`,
2741
+ )
2742
+ for (const b of c.blockers) console.log(` ${yellow(b)}`)
2743
+ console.log("")
2744
+ }
2745
+ console.log(
2746
+ ` ${stamp("nothing here is written anywhere. A retention verdict is a person reading a provider's terms.")}\n`,
2747
+ )
2748
+ }
2749
+
2315
2750
  async function runDoctor(catalog) {
2316
2751
  const retention = loadRetention().models
2317
2752
  const ids = listProfileIds()
@@ -2359,7 +2794,16 @@ async function runDoctor(catalog) {
2359
2794
  // with no model at all, which no amount of `refresh` on the base profile
2360
2795
  // would reveal.
2361
2796
  for (const [lane, chain] of Object.entries(chainOf(p))) {
2362
- const alive = chain.filter((rung) => catalog?.[p.provider]?.models?.[rung])
2797
+ // A withdrawn model is still IN the catalog: models.dev keeps the
2798
+ // record and marks it deprecated rather than deleting it. So "is it
2799
+ // still there" is not the same question as "can it still run", and
2800
+ // asking the first one reported a dead model as a healthy fallback.
2801
+ // The message below always said "withdrawn" - only the filter was
2802
+ // wrong, which is why the fleet's dead rung stayed invisible.
2803
+ const alive = chain.filter((rung) => {
2804
+ const record = catalog?.[p.provider]?.models?.[rung]
2805
+ return record && !isWithdrawn(record)
2806
+ })
2363
2807
  if (!alive.length) {
2364
2808
  issues.push(red(`lane ${lane}: every model in its chain has left the catalog`))
2365
2809
  } else if (alive.length < chain.length) {
@@ -2701,7 +3145,12 @@ function cmdAgents() {
2701
3145
  }
2702
3146
  const name = file.split("/").pop().replace(/\.md$/, "")
2703
3147
  const front = stripFrontmatter(text)
2704
- if (name in /** @type {any} */ (config.agent)) {
3148
+ // `Object.hasOwn`, not `in`. `in` walks the prototype chain, so an agent
3149
+ // file named `toString.md` or `constructor.md` matched a property every
3150
+ // object has - and the file was reported as shadowing a lane that does not
3151
+ // exist, and its missing model was never reported either. `verify` had the
3152
+ // same check and the same fix; this one was left behind.
3153
+ if (Object.hasOwn(/** @type {any} */ (config.agent) ?? {}, name)) {
2705
3154
  console.log(
2706
3155
  ` ${yellow("!")} ${name.padEnd(14)} ${yellow("shadows a lane in the config")}`,
2707
3156
  )
@@ -2733,7 +3182,10 @@ function cmdAgents() {
2733
3182
  */
2734
3183
  function fail(msg, wantJson = false) {
2735
3184
  if (wantJson) {
2736
- console.log(JSON.stringify({ ok: false, error: plain(msg) }, null, 2))
3185
+ // `process.stdout.write`, not `console.log`: a JSON command silences
3186
+ // `console.log` so the report is the only thing on stdout, and the error
3187
+ // has to survive that.
3188
+ process.stdout.write(`${JSON.stringify({ ok: false, error: plain(msg) }, null, 2)}\n`)
2737
3189
  console.error(`\n ${red("error")} ${msg}\n`)
2738
3190
  process.exit(1)
2739
3191
  }
@@ -2758,9 +3210,29 @@ async function cmdRun() {
2758
3210
 
2759
3211
  const { runLoop } = await import("../src/driver/run.mjs")
2760
3212
 
3213
+ // A role that is not a lane is a typo, and the loop would fail every round
3214
+ // on it. It is refused here, while the answer is still "that is not a lane".
3215
+ //
3216
+ // A `--role` with no value is refused too, rather than falling back to the
3217
+ // default. `oc-harness run --role --max-rounds 5` used to set the role to
3218
+ // the literal string "--max-rounds" - and the loop then ran
3219
+ // `opencode run --agent --max-rounds ...`, which failed on every round and
3220
+ // marked every task blocked. A typo that quietly becomes the default is
3221
+ // still a typo that quietly becomes the wrong agent.
3222
+ const lanes = laneNames()
3223
+ const roleGiven = args.includes("--role") || args.some((a) => a.startsWith("--role="))
3224
+ const roleValue = opt("--role")
3225
+ if (roleGiven && roleValue === undefined) {
3226
+ fail(`--role needs a lane: ${lanes.join(", ")}`)
3227
+ }
3228
+ const role = roleValue ?? "build"
3229
+ if (!lanes.includes(role)) {
3230
+ fail(`--role ${role} is not one of ${lanes.join(", ")}`)
3231
+ }
3232
+
2761
3233
  const result = runLoop({
2762
3234
  cwd: process.cwd(),
2763
- role: opt("--role") ?? "build",
3235
+ role,
2764
3236
  maxRounds,
2765
3237
  budgetMinutes,
2766
3238
  })
@@ -2811,8 +3283,13 @@ try {
2811
3283
  case "init":
2812
3284
  await cmdInit()
2813
3285
  break
3286
+ case "auto":
3287
+ await cmdAuto()
3288
+ break
2814
3289
  case "verify":
2815
- cmdVerify()
3290
+ // The exit code is the gate. `verify` runs as the last step of the
3291
+ // install job in CI, and a verify that cannot fail is not a gate.
3292
+ if (cmdVerify().length) process.exitCode = 1
2816
3293
  break
2817
3294
  case "agents":
2818
3295
  cmdAgents()
@@ -2827,6 +3304,9 @@ try {
2827
3304
  if (flag("--watch") || flag("--check-models")) await cmdDoctorWatch()
2828
3305
  else await cmdDoctor()
2829
3306
  break
3307
+ case "research":
3308
+ await cmdResearch()
3309
+ break
2830
3310
  case "baseline":
2831
3311
  await import("./baseline.mjs")
2832
3312
  break
@@ -2847,6 +3327,8 @@ try {
2847
3327
 
2848
3328
  list show profiles with retention and caveats
2849
3329
  init pick a free profile, then install + use + verify in one go
3330
+ auto refresh + install + use + verify, asking nothing - the
3331
+ unattended reconcile, for an agent or a job
2850
3332
  use <id> compile a profile into ${CONFIG_FILE}
2851
3333
  show <id> print the resolved lane plan and derived numbers
2852
3334
  refresh <id> update capability fields from models.dev
@@ -2857,6 +3339,8 @@ try {
2857
3339
  lanes report which lanes the fleet actually used
2858
3340
  trust set up npm trusted publishing for this package
2859
3341
  doctor re-check profiles against models.dev and the installed config
3342
+ research find the live free models the kit could use, and the ones in
3343
+ use that have been withdrawn
2860
3344
  upgrade check for a newer kit, refresh the profiles, rewrite the config
2861
3345
  backups the configs that were replaced, newest first
2862
3346
  uninstall remove the plugin, prompts, skills and GLOBAL.md again
@@ -2874,8 +3358,10 @@ try {
2874
3358
  --strict doctor: fail on perishable catalogue drift too
2875
3359
  --watch doctor: keep watching the catalogue, report what disappeared
2876
3360
  --check-models doctor: one run, non-zero when a shipped model is gone
2877
- --json doctor, show, lanes: print the machine-readable form, and
2878
- nothing else
3361
+ --json doctor, show, lanes, auto, research: print the
3362
+ machine-readable form, and nothing else
3363
+ --offline auto: skip the refresh, for a machine that cannot reach
3364
+ models.dev
2879
3365
  --hook install: also install the pre-commit hook into this
2880
3366
  project. It is the project's call, so it has to be asked
2881
3367
  for.