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.
- package/CHANGELOG.md +527 -18
- package/CONTRIBUTING.md +242 -0
- package/GLOBAL.md +34 -5
- package/MIGRATION.md +151 -0
- package/README.md +307 -37
- package/SECURITY.md +30 -0
- package/bin/baseline.mjs +15 -7
- package/bin/git-hooks/pre-commit +41 -9
- package/bin/oc-harness.mjs +1265 -82
- package/dist/config.d.ts +127 -0
- package/dist/denylist.d.ts +33 -0
- package/dist/effort.d.ts +64 -0
- package/dist/guard.d.ts +23 -0
- package/dist/health.d.ts +43 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.js +532 -149
- package/dist/plugin.d.ts +15 -0
- package/dist/profile.d.ts +399 -0
- package/dist/project.d.ts +8 -0
- package/dist/prune.d.ts +6 -0
- package/dist/router.d.ts +3 -0
- package/dist/rules.d.ts +109 -0
- package/dist/safe.d.ts +7 -0
- package/dist/telemetry-store.d.ts +12 -0
- package/dist/telemetry.d.ts +3 -0
- package/docs/EXIT-CODES.md +71 -0
- package/docs/KNOWN-LIMITS.md +45 -21
- package/docs/RESEARCH.md +9 -4
- package/docs/ROUTING.md +53 -8
- package/eval/RESULTS.md +443 -0
- package/eval/ab.mjs +743 -0
- package/eval/attribute.mjs +170 -0
- package/eval/flags.mjs +88 -0
- package/eval/measure-install.mjs +323 -0
- package/eval/measure-prune.mjs +339 -0
- package/eval/metrics.js +79 -0
- package/eval/premises.mjs +200 -0
- package/eval/prepare.mjs +233 -0
- package/eval/run.mjs +376 -0
- package/eval/tasks-self.json +81 -0
- package/eval/tasks-ts.json +50 -0
- package/eval/tasks.json +83 -0
- package/eval/thresholds.mjs +134 -0
- package/eval/verify.mjs +287 -0
- package/package.json +44 -17
- package/profiles/fleet-all.json +6 -1
- package/profiles/fleet-safe.json +7 -3
- package/profiles/schema.json +182 -51
- package/prompts/build.md +4 -2
- package/skills/space-build/SKILL.md +4 -2
- package/skills/space-finish/SKILL.md +6 -2
- package/skills/space-plan/reference/task-template.md +13 -5
- package/src/config.js +182 -32
- package/src/denylist.js +95 -0
- package/src/driver/run.mjs +81 -16
- package/src/effort.js +15 -4
- package/src/guard.ts +165 -13
- package/src/health.js +80 -0
- package/src/index.ts +15 -4
- package/src/lane-usage.js +72 -18
- package/src/plugin-type-check.ts +38 -0
- package/src/plugin.ts +39 -0
- package/src/profile.js +225 -26
- package/src/project.js +47 -0
- package/src/prune.ts +110 -31
- package/src/router.ts +32 -63
- package/src/rules.js +99 -7
- package/src/safe.ts +27 -0
- package/src/sqlite.js +144 -0
- package/src/tasks.js +112 -29
- package/src/telemetry-store.ts +62 -0
- package/src/telemetry.ts +60 -57
- package/src/trust.js +70 -9
package/bin/oc-harness.mjs
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
183
|
-
|
|
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(
|
|
314
|
-
{ dir: join(
|
|
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
|
|
421
|
-
//
|
|
422
|
-
//
|
|
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
|
|
425
|
-
|
|
426
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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" && !
|
|
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
|
-
|
|
866
|
-
|
|
867
|
-
fail(msg + "
|
|
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
|
-
|
|
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 (
|
|
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 (
|
|
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 (!
|
|
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
|
-
|
|
899
|
-
|
|
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 =
|
|
1230
|
+
const url = CATALOG_URL
|
|
951
1231
|
try {
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
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(
|
|
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
|
|
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
|
-
/**
|
|
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
|
-
|
|
1067
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1122
|
-
? `\n ${yellow(`${
|
|
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
|
-
|
|
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 (
|
|
2186
|
+
if (fields.length === 0) {
|
|
1145
2187
|
console.log(` ${green("✓")} ${stamp("opencode.jsonc matches the profile")}`)
|
|
1146
2188
|
} else {
|
|
1147
|
-
for (const d of
|
|
1148
|
-
console.log(`\n ${yellow(`${
|
|
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
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
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
|
-
"
|
|
2295
|
+
"avg ctx".padStart(10),
|
|
2296
|
+
"model".padEnd(30),
|
|
1188
2297
|
].join(" ")
|
|
1189
2298
|
console.log(head)
|
|
1190
|
-
console.log(" " + "-".repeat(
|
|
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
|
-
|
|
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
|
-
(
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
1370
|
-
budgetMinutes
|
|
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
|
|
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
|
}
|