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