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.
- package/CHANGELOG.md +330 -0
- package/README.md +241 -11
- package/bin/baseline.mjs +16 -1
- package/bin/oc-harness.mjs +528 -42
- package/dist/index.js +294 -57
- package/dist/profile.d.ts +2 -1
- package/dist/rules.d.ts +6 -0
- package/docs/KNOWN-LIMITS.md +22 -2
- package/lanes.json +2 -2
- package/package.json +1 -1
- package/profiles/beam-free.json +2 -3
- package/profiles/deepseek-v4.1-flash-free.json +1 -2
- package/profiles/fleet-all.json +6 -14
- package/profiles/fleet-safe.json +8 -19
- package/profiles/glm-5.3-flash-free.json +1 -2
- package/profiles/index.json +4 -11
- package/profiles/mimo-v2.6-flash-free.json +1 -2
- package/profiles/minimax-m3.1-flash-free.json +1 -2
- package/profiles/nemotron-3.5-lightning-free.json +2 -3
- package/profiles/nemotron-3.5-super-vl-free.json +1 -2
- package/profiles/schema.json +8 -0
- package/retention.yml +0 -9
- package/src/config.js +9 -1
- package/src/profile.js +14 -1
- package/src/prune.ts +90 -5
- package/src/research.js +462 -0
- package/src/rules.js +303 -21
- package/profiles/longcat-2.5-preview-free.json +0 -45
package/bin/oc-harness.mjs
CHANGED
|
@@ -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)
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
|
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
|
-
|
|
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}: ${
|
|
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
|
-
|
|
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}: ${
|
|
1794
|
+
changed.push(`${id}: ${notes.join("; ")}`)
|
|
1561
1795
|
}
|
|
1562
|
-
console.log(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
2403
|
+
for (const line of notes) console.log(` ${line}`)
|
|
2107
2404
|
console.log(
|
|
2108
|
-
`\n ${stamp("kept as-is: retention, roles, notes
|
|
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[]
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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.
|