opencode-harness-kit 0.2.0 → 0.3.3
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 +301 -0
- package/GLOBAL.md +50 -13
- package/MIGRATION.md +140 -0
- package/README.md +276 -122
- package/bin/oc-harness.mjs +571 -135
- package/dist/index.js +453 -159
- package/docs/KNOWN-LIMITS.md +189 -4
- package/docs/RESEARCH.md +80 -5
- package/docs/ROUTING.md +254 -0
- package/lanes.json +136 -0
- package/lanes.schema.json +55 -0
- package/package.json +13 -11
- package/profiles/beam-free.json +51 -0
- package/profiles/big-pickle.json +45 -0
- package/profiles/deepseek-v4.1-flash-free.json +49 -0
- package/profiles/fleet-all.json +139 -0
- package/profiles/fleet-safe.json +139 -0
- package/profiles/glm-5.3-flash-free.json +49 -0
- package/profiles/index.json +120 -37
- package/profiles/longcat-2.5-preview-free.json +14 -10
- package/profiles/mimo-v2.6-flash-free.json +44 -0
- package/profiles/minimax-m3.1-flash-free.json +51 -0
- package/profiles/nemotron-3-ultra-free.json +44 -0
- package/profiles/nemotron-3.5-lightning-free.json +44 -0
- package/profiles/nemotron-3.5-super-vl-free.json +52 -0
- package/profiles/schema.json +30 -11
- package/{agents → prompts}/build.md +2 -35
- package/prompts/doc.md +42 -0
- package/{agents → prompts}/locate.md +1 -16
- package/prompts/longform.md +34 -0
- package/{agents → prompts}/plan.md +2 -25
- package/{agents → prompts}/reviewer.md +2 -22
- package/prompts/schema.md +30 -0
- package/prompts/sweep.md +29 -0
- package/{agents → prompts}/verify.md +6 -29
- package/prompts/vision.md +33 -0
- package/retention.yml +75 -57
- package/skills/space-build/SKILL.md +16 -0
- package/skills/space-finish/SKILL.md +0 -10
- package/skills/space-review/SKILL.md +8 -0
- package/src/config.js +94 -69
- package/src/driver/run.mjs +237 -0
- package/src/effort.js +59 -22
- package/src/index.ts +15 -2
- package/src/lane-usage.js +142 -0
- package/src/profile.js +615 -221
- package/src/router.ts +148 -39
- package/src/tasks.js +248 -0
- package/src/telemetry.ts +98 -0
- package/docs/demo/demo.gif +0 -0
- package/docs/demo/demo.mp4 +0 -0
- package/docs/demo/render.py +0 -146
- package/examples/muse-spark-1.3-contributor-free.json +0 -41
- package/profiles/deepseek-v4-flash-free.json +0 -41
- package/profiles/muse-split-free.json +0 -64
- package/profiles/space-bunny-free.json +0 -42
- package/profiles/x-preview-f-free.json +0 -41
package/bin/oc-harness.mjs
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
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 } from "node:fs"
|
|
15
|
+
import { chmodSync, copyFileSync, mkdirSync, readFileSync, writeFileSync, existsSync, rmSync, symlinkSync, readdirSync, readlinkSync, realpathSync, lstatSync } from "node:fs"
|
|
16
16
|
import { homedir } from "node:os"
|
|
17
17
|
import { dirname, join, relative } from "node:path"
|
|
18
18
|
import { execFileSync } from "node:child_process"
|
|
@@ -25,16 +25,27 @@ import {
|
|
|
25
25
|
listProfileIds,
|
|
26
26
|
profileIdFromConfig,
|
|
27
27
|
profilePath,
|
|
28
|
+
inputCap,
|
|
28
29
|
pruneThreshold,
|
|
29
30
|
reservedTokens,
|
|
30
31
|
effectiveRetention,
|
|
32
|
+
modelsInPlay,
|
|
33
|
+
retentionFor,
|
|
31
34
|
roleSplitModels,
|
|
32
35
|
resolveDefaultVariant,
|
|
33
36
|
resolveRoles,
|
|
37
|
+
resolveRoleModel,
|
|
38
|
+
resolveLaneChain,
|
|
39
|
+
resolveLaneModel,
|
|
40
|
+
modelRetention,
|
|
41
|
+
loadLanes,
|
|
42
|
+
laneNames,
|
|
34
43
|
ProfileError,
|
|
44
|
+
parseJsonc,
|
|
35
45
|
} from "../src/profile.js"
|
|
36
46
|
import { generateConfig, globalInstructionsPath } from "../src/config.js"
|
|
37
47
|
import { planEffort } from "../src/effort.js"
|
|
48
|
+
import { findTelemetryFile, readLaneUsage, averageMs } from "../src/lane-usage.js"
|
|
38
49
|
|
|
39
50
|
const CONFIG_DIR =
|
|
40
51
|
process.env.OPENCODE_CONFIG_DIR ?? join(homedir(), ".config/opencode")
|
|
@@ -84,33 +95,58 @@ async function cmdList() {
|
|
|
84
95
|
console.log(`\n${bold("opencode-harness-kit")} ${stamp("profiles")}\n`)
|
|
85
96
|
const head = [
|
|
86
97
|
" " + "profile".padEnd(30),
|
|
87
|
-
"
|
|
98
|
+
"input".padStart(9),
|
|
88
99
|
"top".padStart(7),
|
|
100
|
+
"lanes".padStart(6),
|
|
89
101
|
"retention".padEnd(15),
|
|
90
102
|
].join(" ")
|
|
91
103
|
console.log(head)
|
|
92
|
-
console.log(" " + "-".repeat(
|
|
104
|
+
console.log(" " + "-".repeat(79))
|
|
93
105
|
|
|
94
106
|
for (const entry of idx.profiles ?? []) {
|
|
95
107
|
if (only && entry.retention !== only) continue
|
|
96
108
|
if (freeOnly && entry.free === false) continue
|
|
97
|
-
|
|
109
|
+
// The retention lookup is keyed on the model, not the profile id: a fleet
|
|
110
|
+
// profile's id is not a model, and looking it up by id silently found
|
|
111
|
+
// nothing, which is why the disagreement below never fired for one.
|
|
112
|
+
let model = entry.id
|
|
113
|
+
let lanes = 0
|
|
114
|
+
let effective = entry.retention
|
|
115
|
+
// The number that matters is the input cap the config can actually use:
|
|
116
|
+
// the narrowest lane model's on a fleet, the model's own on a
|
|
117
|
+
// single-model profile. The base model's context is a different number
|
|
118
|
+
// and it is printed by `show` and by the generated header.
|
|
119
|
+
let cap = entry.context ?? 0
|
|
120
|
+
try {
|
|
121
|
+
const loaded = loadProfile(entry.id)
|
|
122
|
+
model = loaded.model
|
|
123
|
+
lanes = Object.keys(loaded.roles ?? {}).length
|
|
124
|
+
// A fleet's retention is the worst of the models it resolves to, not the
|
|
125
|
+
// base model's. Comparing the base would report every fleet as
|
|
126
|
+
// disagreeing with retention.yml, which records the base model's terms.
|
|
127
|
+
effective = effectiveRetention(loaded).retention
|
|
128
|
+
cap = inputCap(loaded)
|
|
129
|
+
} catch {
|
|
130
|
+
model = entry.id
|
|
131
|
+
}
|
|
132
|
+
const r = retention[`opencode/${model}`]
|
|
98
133
|
const isDefault = entry.default
|
|
99
134
|
const name = (isDefault ? green("▸ ") : " ") + entry.id
|
|
100
135
|
const top = entry.topEffort ?? stamp("—")
|
|
101
136
|
const ret =
|
|
102
|
-
|
|
103
|
-
? green(
|
|
104
|
-
: yellow(
|
|
137
|
+
effective === "zero-retention"
|
|
138
|
+
? green(effective)
|
|
139
|
+
: yellow(effective)
|
|
105
140
|
const mismatch =
|
|
106
|
-
r && r.retention && r.retention !==
|
|
141
|
+
r && r.retention && r.retention !== effective && lanes === 0
|
|
107
142
|
? ` ${red(`(retention.yml says ${r.retention})`)}`
|
|
108
143
|
: ""
|
|
109
144
|
console.log(
|
|
110
145
|
[
|
|
111
146
|
" " + name.padEnd(32),
|
|
112
|
-
|
|
147
|
+
cap.toLocaleString("en-US").padStart(9),
|
|
113
148
|
String(top).padStart(7),
|
|
149
|
+
String(lanes).padStart(6),
|
|
114
150
|
ret.padEnd(15),
|
|
115
151
|
].join(" ") + mismatch,
|
|
116
152
|
)
|
|
@@ -125,6 +161,17 @@ async function cmdList() {
|
|
|
125
161
|
console.log(` ${yellow(entry.reason)}`)
|
|
126
162
|
}
|
|
127
163
|
}
|
|
164
|
+
// The routes that died. A profile is perishable, and a tier that turns over
|
|
165
|
+
// in days is the reason the fleet has chains instead of pins - so the ones
|
|
166
|
+
// that went are named, with what replaced them.
|
|
167
|
+
const gone = idx.withdrawn ?? []
|
|
168
|
+
if (gone.length) {
|
|
169
|
+
console.log("\n " + stamp("no longer on the tier"))
|
|
170
|
+
for (const entry of gone) {
|
|
171
|
+
console.log(` ${red("✗")} ${entry.id}`)
|
|
172
|
+
console.log(` ${stamp(entry.reason)}`)
|
|
173
|
+
}
|
|
174
|
+
}
|
|
128
175
|
console.log(
|
|
129
176
|
`\n ${stamp("install one:")} oc-harness use <profile>\n ${stamp("inspect:")} oc-harness show <profile>\n`,
|
|
130
177
|
)
|
|
@@ -134,13 +181,17 @@ function cmdShow(id) {
|
|
|
134
181
|
if (!id) fail("usage: oc-harness show <profile>")
|
|
135
182
|
const p = loadProfile(id)
|
|
136
183
|
const plan = planEffort(id)
|
|
137
|
-
const retention = loadRetention()[
|
|
184
|
+
const retention = loadRetention()[`${p.provider}/${p.model}`]
|
|
138
185
|
|
|
139
186
|
console.log(`\n${bold(p.id)} ${stamp(p.title ?? "")}\n`)
|
|
140
187
|
const row = (k, v) => console.log(` ${k.padEnd(20)} ${v}`)
|
|
141
188
|
row("provider/model", `${p.provider}/${p.model}`)
|
|
142
189
|
row("context", p.limits.context.toLocaleString("en-US"))
|
|
143
|
-
|
|
190
|
+
// The cap that actually governs: the narrowest lane model's on a fleet, the
|
|
191
|
+
// model's own on a single-model profile. Printing the base model's number
|
|
192
|
+
// next to a prune threshold derived from another one made the two
|
|
193
|
+
// irreconcilable on the screen.
|
|
194
|
+
row("input cap", inputCap(p).toLocaleString("en-US"))
|
|
144
195
|
row("output cap", p.limits.output.toLocaleString("en-US"))
|
|
145
196
|
row("effort levels", p.effort?.levels?.length ? p.effort.levels.join(" < ") : "(not tunable)")
|
|
146
197
|
row("default variant", resolveDefaultVariant(p) ?? "(none)")
|
|
@@ -156,14 +207,31 @@ function cmdShow(id) {
|
|
|
156
207
|
console.log(` ${"".padEnd(20)} ${stamp(`base ${p.model} is ${p.retention}, but ${eff.worst} makes the whole profile ${eff.retention}`)}`)
|
|
157
208
|
}
|
|
158
209
|
if (roleSplitModels(p).length) {
|
|
159
|
-
console.log(`\n ${bold("
|
|
210
|
+
console.log(`\n ${bold("lanes")}`)
|
|
160
211
|
for (const s of roleSplitModels(p)) {
|
|
161
212
|
const cap = s.limits ? ` out ${s.limits.output.toLocaleString("en-US")}` : ""
|
|
213
|
+
const chain = resolveLaneChain(p, s.role)
|
|
214
|
+
const via = chain.length > 1 ? ` via ${chain.join(" → ")}` : ""
|
|
162
215
|
console.log(` ${s.role.padEnd(20)} ${s.model}${cap}`)
|
|
216
|
+
if (via) console.log(` ${"".padEnd(20)} ${stamp(via.trim())}`)
|
|
217
|
+
}
|
|
218
|
+
} else {
|
|
219
|
+
console.log(`\n ${bold("lanes")}`)
|
|
220
|
+
for (const role of Object.keys(p.roles ?? {})) {
|
|
221
|
+
console.log(` ${role.padEnd(20)} ${p.model}`)
|
|
163
222
|
}
|
|
164
223
|
}
|
|
165
224
|
|
|
166
|
-
|
|
225
|
+
if (p.maxRetention && p.maxRetention !== p.retention) {
|
|
226
|
+
console.log(
|
|
227
|
+
`\n ${bold("retention ceiling")} ${p.maxRetention}`,
|
|
228
|
+
)
|
|
229
|
+
console.log(
|
|
230
|
+
` ${stamp("a lane may only resolve to a model at or below this")}`,
|
|
231
|
+
)
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
console.log(`\n ${bold("lane effort")}`)
|
|
167
235
|
for (const [role, level] of Object.entries(plan.roles)) {
|
|
168
236
|
const want = p.roles?.[role]
|
|
169
237
|
const arrow = want && want !== level ? ` ${stamp(`(requested ${want})`)}` : ""
|
|
@@ -216,12 +284,69 @@ function cmdLink() {
|
|
|
216
284
|
/** Where the behavioural layer has to land for the config to mean anything. */
|
|
217
285
|
function installTargets() {
|
|
218
286
|
return [
|
|
219
|
-
{ src: join(ROOT, "
|
|
287
|
+
{ src: join(ROOT, "prompts"), dest: join(CONFIG_DIR, "prompts"), kind: "dir" },
|
|
220
288
|
{ src: join(ROOT, "skills"), dest: join(CONFIG_DIR, "skills"), kind: "dir" },
|
|
221
289
|
{ src: join(ROOT, "GLOBAL.md"), dest: join(CONFIG_DIR, "GLOBAL.md"), kind: "file" },
|
|
222
290
|
]
|
|
223
291
|
}
|
|
224
292
|
|
|
293
|
+
/**
|
|
294
|
+
* The directories opencode loads agent markdown files from.
|
|
295
|
+
*
|
|
296
|
+
* Measured on OpenCode 1.19, not read off the docs:
|
|
297
|
+
*
|
|
298
|
+
* - `$CONFIG_DIR/agents/` and `$CONFIG_DIR/agent/` are both read, and when
|
|
299
|
+
* the same name is in both the plural directory wins.
|
|
300
|
+
* - `<project>/.opencode/agents/` and `<project>/.opencode/agent/` are both
|
|
301
|
+
* read. A `./agent/` in the project root is not.
|
|
302
|
+
*
|
|
303
|
+
* A file in any of them replaces the config entry of the same name
|
|
304
|
+
* wholesale, so a shadow in the singular directory is exactly as dangerous as
|
|
305
|
+
* one in the plural one, and a checker that only looks at `agents/` misses
|
|
306
|
+
* half of them.
|
|
307
|
+
*/
|
|
308
|
+
function agentDirs() {
|
|
309
|
+
return [
|
|
310
|
+
{ dir: join(CONFIG_DIR, "agents"), scope: "config" },
|
|
311
|
+
{ dir: join(CONFIG_DIR, "agent"), scope: "config" },
|
|
312
|
+
{ dir: join(process.cwd(), ".opencode", "agents"), scope: "project" },
|
|
313
|
+
{ dir: join(process.cwd(), ".opencode", "agent"), scope: "project" },
|
|
314
|
+
]
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* Remove the agent files an earlier version of this kit installed.
|
|
319
|
+
*
|
|
320
|
+
* They used to carry the model in their frontmatter, and an agent file of the
|
|
321
|
+
* same name replaces the config entry wholesale. A leftover `plan.md` from
|
|
322
|
+
* the old layout would therefore pin `plan` to whatever model it named -
|
|
323
|
+
* a model that may since have been withdrawn - and the config would look
|
|
324
|
+
* correct while the lane ran somewhere else. The prompts moved to prompts/,
|
|
325
|
+
* where a file cannot shadow a config entry.
|
|
326
|
+
*/
|
|
327
|
+
function removeLegacyAgentFiles() {
|
|
328
|
+
const removed = []
|
|
329
|
+
for (const { dir } of agentDirs()) {
|
|
330
|
+
if (!existsSync(dir)) continue
|
|
331
|
+
for (const name of readdirSync(dir)) {
|
|
332
|
+
if (!name.endsWith(".md")) continue
|
|
333
|
+
const full = join(dir, name)
|
|
334
|
+
let ours = false
|
|
335
|
+
try {
|
|
336
|
+
ours = readlinkSync(full).includes(join("space-harness", "agents"))
|
|
337
|
+
} catch {
|
|
338
|
+
ours = false
|
|
339
|
+
}
|
|
340
|
+
if (!ours) continue
|
|
341
|
+
try {
|
|
342
|
+
rmSync(full, { force: true })
|
|
343
|
+
removed.push(name)
|
|
344
|
+
} catch {}
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
return removed
|
|
348
|
+
}
|
|
349
|
+
|
|
225
350
|
/**
|
|
226
351
|
* Replace `link` with `link` plus the agents, skills and GLOBAL.md.
|
|
227
352
|
*
|
|
@@ -238,6 +363,13 @@ async function cmdInstall() {
|
|
|
238
363
|
// The plugin link first: without it the guard is not enforcing anything.
|
|
239
364
|
cmdLink()
|
|
240
365
|
|
|
366
|
+
const legacy = removeLegacyAgentFiles()
|
|
367
|
+
if (legacy.length) {
|
|
368
|
+
console.log(
|
|
369
|
+
` ${green("✓")} ${stamp(`removed ${legacy.length} legacy agent file(s) that would have shadowed the config`)}`,
|
|
370
|
+
)
|
|
371
|
+
}
|
|
372
|
+
|
|
241
373
|
for (const { src, dest, kind } of installTargets()) {
|
|
242
374
|
if (!existsSync(src)) {
|
|
243
375
|
console.log(` ${yellow("!")} ${stamp(`${kind} missing in repo: ${src}`)}`)
|
|
@@ -373,25 +505,36 @@ async function askChoice(question) {
|
|
|
373
505
|
function linkOne(src, dest) {
|
|
374
506
|
const parent = realpathSync(dirname(dest))
|
|
375
507
|
const target = relative(parent, realpathSync(src))
|
|
376
|
-
|
|
508
|
+
|
|
509
|
+
let isLink = false
|
|
510
|
+
let ours = false
|
|
377
511
|
try {
|
|
378
|
-
|
|
512
|
+
isLink = lstatSync(dest).isSymbolicLink()
|
|
513
|
+
ours = isLink && readlinkSync(dest) === target
|
|
379
514
|
} catch {
|
|
380
|
-
|
|
515
|
+
// Nothing at `dest` yet.
|
|
381
516
|
}
|
|
382
|
-
if (
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
517
|
+
if (ours) return
|
|
518
|
+
|
|
519
|
+
if (existsSync(dest)) {
|
|
520
|
+
// Replace only what this installer put there. The old code treated "not
|
|
521
|
+
// already our link" as "safe to delete" and called `rmSync` with
|
|
522
|
+
// `recursive: false`, which succeeds on a plain file: a hand-written
|
|
523
|
+
// agent was deleted and replaced by a symlink without a word. A real
|
|
524
|
+
// directory is left alone too -- unlinking it is not this installer's
|
|
525
|
+
// call to make.
|
|
526
|
+
if (!isLink) {
|
|
527
|
+
const kind = lstatSync(dest).isDirectory() ? "directory" : "file"
|
|
528
|
+
console.log(` ${yellow("!")} ${stamp(`left alone: ${dest} is a real ${kind} and was not ours to replace`)}`)
|
|
529
|
+
return
|
|
387
530
|
}
|
|
531
|
+
rmSync(dest, { force: true })
|
|
388
532
|
}
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
}
|
|
533
|
+
|
|
534
|
+
try {
|
|
535
|
+
symlinkSync(target, dest)
|
|
536
|
+
} catch (err) {
|
|
537
|
+
console.log(` ${yellow("!")} ${stamp(`could not link ${dest}: ${err.message}`)}`)
|
|
395
538
|
}
|
|
396
539
|
}
|
|
397
540
|
|
|
@@ -420,9 +563,7 @@ function cmdVerify() {
|
|
|
420
563
|
// hand-edit anywhere in the model/variant/permission surface shows up
|
|
421
564
|
// here, which is the check that would have caught `build` being moved onto
|
|
422
565
|
// a may-train model.
|
|
423
|
-
const actual = existsSync(CONFIG_FILE)
|
|
424
|
-
? stripComments(readFileSync(CONFIG_FILE, "utf8"))
|
|
425
|
-
: null
|
|
566
|
+
const actual = existsSync(CONFIG_FILE) ? readConfigJson() : null
|
|
426
567
|
if (!actual) {
|
|
427
568
|
problems.push(`${CONFIG_FILE} does not exist. Run: oc-harness use ${p.id}`)
|
|
428
569
|
} else {
|
|
@@ -452,53 +593,88 @@ function cmdVerify() {
|
|
|
452
593
|
}
|
|
453
594
|
}
|
|
454
595
|
|
|
455
|
-
// 3. Every
|
|
456
|
-
// config
|
|
457
|
-
|
|
596
|
+
// 3. Every lane the profile enables must have its prompt installed, and the
|
|
597
|
+
// config's reference to it must resolve. A lane with a model and a
|
|
598
|
+
// permission but no prompt is a lane that runs on opencode's default
|
|
599
|
+
// instructions and nobody can tell.
|
|
600
|
+
const promptsDir = join(CONFIG_DIR, "prompts")
|
|
458
601
|
const roles = resolveRoles(p)
|
|
459
602
|
for (const [role, level] of Object.entries(roles)) {
|
|
460
603
|
if (level === undefined) continue
|
|
461
|
-
const file = join(
|
|
604
|
+
const file = join(promptsDir, `${role}.md`)
|
|
462
605
|
if (!existsSync(file)) {
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
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)
|
|
606
|
+
problems.push(
|
|
607
|
+
`no prompts/${role}.md - the ${role} lane is declared in the config but has no instructions. Run: oc-harness install`,
|
|
608
|
+
)
|
|
470
609
|
continue
|
|
471
610
|
}
|
|
472
|
-
|
|
473
|
-
const
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
611
|
+
ok.push(`prompt ${role}.md installed`)
|
|
612
|
+
const ref = actual?.agent?.[role]?.prompt
|
|
613
|
+
if (ref) {
|
|
614
|
+
const target = ref.replace(/^\{file:/, "").replace(/\}$/, "")
|
|
615
|
+
const resolved = target.startsWith("~")
|
|
616
|
+
? join(homedir(), target.slice(1))
|
|
617
|
+
: target
|
|
618
|
+
if (!existsSync(resolved)) {
|
|
619
|
+
problems.push(
|
|
620
|
+
`agent.${role}.prompt points at ${target}, which does not exist. Run: oc-harness install`,
|
|
621
|
+
)
|
|
622
|
+
}
|
|
482
623
|
}
|
|
483
624
|
}
|
|
484
625
|
|
|
485
|
-
// 4. No installed agent may
|
|
486
|
-
//
|
|
487
|
-
//
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
626
|
+
// 4. No installed agent file may shadow a lane. An agent file of the same
|
|
627
|
+
// name replaces the config entry wholesale, and a file without a `model:`
|
|
628
|
+
// line makes the agent inherit the caller's model. Either way the lane
|
|
629
|
+
// stops running on the model the profile resolved for it, and the config
|
|
630
|
+
// keeps looking correct. Verified against OpenCode 1.19.
|
|
631
|
+
//
|
|
632
|
+
// The file's `model:` wins when the plugin is not there to re-pin it, and
|
|
633
|
+
// its `prompt` and `description` win even when it is. So a shadow is
|
|
634
|
+
// worth reporting wherever opencode would read one from.
|
|
635
|
+
for (const { dir, scope } of agentDirs()) {
|
|
636
|
+
if (!existsSync(dir)) continue
|
|
637
|
+
for (const name of readdirSync(dir).filter((f) => f.endsWith(".md"))) {
|
|
638
|
+
const where = `${scope}:${dir.replace(`${homedir()}/`, "~/")}`
|
|
639
|
+
const lane = name.replace(/\.md$/, "")
|
|
640
|
+
if (lane in (actual?.agent ?? {})) {
|
|
641
|
+
problems.push(
|
|
642
|
+
`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`,
|
|
643
|
+
)
|
|
644
|
+
continue
|
|
645
|
+
}
|
|
646
|
+
const text = readFileSync(join(dir, name), "utf8")
|
|
491
647
|
const named = text.match(/opencode\/([a-z0-9.\-]+)/i)
|
|
492
|
-
if (named && named[1]
|
|
493
|
-
problems.push(
|
|
648
|
+
if (named && !modelsInPlay(p).includes(named[1])) {
|
|
649
|
+
problems.push(
|
|
650
|
+
`agent file ${where}/${name} mentions opencode/${named[1]}, which is not in the active profile`,
|
|
651
|
+
)
|
|
494
652
|
}
|
|
495
653
|
}
|
|
496
654
|
}
|
|
497
655
|
|
|
498
656
|
// 5. The plugin link has to exist or none of the guard is running.
|
|
499
657
|
const pluginLink = join(CONFIG_DIR, "plugins", "opencode-harness-kit.js")
|
|
500
|
-
if (existsSync(pluginLink))
|
|
501
|
-
|
|
658
|
+
if (existsSync(pluginLink)) {
|
|
659
|
+
ok.push("plugin linked")
|
|
660
|
+
// The bundle finds its profiles by walking up from itself, so a bundle
|
|
661
|
+
// that was copied out of the kit's tree loads and does nothing: no guard,
|
|
662
|
+
// no pruning, no lane failover, no telemetry, and no error anywhere. The
|
|
663
|
+
// symlink is what keeps this honest, and this check is what notices when
|
|
664
|
+
// somebody replaced it with a copy.
|
|
665
|
+
let resolved = pluginLink
|
|
666
|
+
try {
|
|
667
|
+
resolved = realpathSync(pluginLink)
|
|
668
|
+
} catch {}
|
|
669
|
+
const dataRoot = join(dirname(resolved), "..")
|
|
670
|
+
if (!existsSync(join(dataRoot, "profiles"))) {
|
|
671
|
+
problems.push(
|
|
672
|
+
`the plugin at ${resolved} cannot find its profiles (looked in ${dataRoot}). The guard, the pruning and the lane failover would all be silently absent. Run: oc-harness install`,
|
|
673
|
+
)
|
|
674
|
+
}
|
|
675
|
+
} else {
|
|
676
|
+
notes.push("no plugin link: the guard is not enforcing anything. Run: oc-harness link")
|
|
677
|
+
}
|
|
502
678
|
|
|
503
679
|
// 6. Schema check. `variant` at the top level is silently stripped by
|
|
504
680
|
// OpenCode, so emitting it looks like tiering and does nothing.
|
|
@@ -512,15 +688,30 @@ function cmdVerify() {
|
|
|
512
688
|
// is missing or whose frontmatter is broken is skipped silently by
|
|
513
689
|
// OpenCode, so half the plan -> build -> review -> finish loop can go
|
|
514
690
|
// missing without any error surfacing.
|
|
691
|
+
//
|
|
692
|
+
// The check covers the kit's OWN skills, and only the kit's own skills.
|
|
693
|
+
// OpenCode identifies a skill by its DIRECTORY name, not by the `name:`
|
|
694
|
+
// in its frontmatter, so a skill whose two differ loads perfectly well
|
|
695
|
+
// under its directory name. A checker that insists they match fails a
|
|
696
|
+
// perfectly good install over somebody else's naming choice - which is
|
|
697
|
+
// what happened on an install that carried 220-odd community skills.
|
|
698
|
+
//
|
|
699
|
+
// For the kit's own skills the two DO have to agree: the loop refers to
|
|
700
|
+
// them by name, and a skill whose frontmatter drifted away from its
|
|
701
|
+
// directory is one the loop will not find.
|
|
515
702
|
const skillsDir = join(CONFIG_DIR, "skills")
|
|
703
|
+
const ourSkills = kitSkillNames()
|
|
516
704
|
if (!existsSync(skillsDir)) {
|
|
517
705
|
notes.push("no skills directory: the skill loop is not installed. Run: oc-harness install")
|
|
518
706
|
} else {
|
|
707
|
+
const installed = new Set(readdirSync(skillsDir))
|
|
519
708
|
let valid = 0
|
|
520
|
-
for (const name of
|
|
709
|
+
for (const name of ourSkills) {
|
|
521
710
|
const skillFile = join(skillsDir, name, "SKILL.md")
|
|
522
711
|
if (!existsSync(skillFile)) {
|
|
523
|
-
problems.push(
|
|
712
|
+
problems.push(
|
|
713
|
+
`skills/${name} has no SKILL.md - the ${name} step of the loop will never load. Run: oc-harness install`,
|
|
714
|
+
)
|
|
524
715
|
continue
|
|
525
716
|
}
|
|
526
717
|
const front = stripFrontmatter(readFileSync(skillFile, "utf8"))
|
|
@@ -532,7 +723,19 @@ function cmdVerify() {
|
|
|
532
723
|
valid += 1
|
|
533
724
|
}
|
|
534
725
|
}
|
|
535
|
-
|
|
726
|
+
// A directory of the same name as one of the kit's would shadow it, and
|
|
727
|
+
// the installer refuses to replace a real directory - so the kit's skill
|
|
728
|
+
// would be missing and the loop would break at that step.
|
|
729
|
+
for (const name of installed) {
|
|
730
|
+
if (ourSkills.has(name)) continue
|
|
731
|
+
const foreign = join(skillsDir, name, "SKILL.md")
|
|
732
|
+
if (!existsSync(foreign)) {
|
|
733
|
+
problems.push(`skills/${name} has no SKILL.md - it will never load`)
|
|
734
|
+
}
|
|
735
|
+
}
|
|
736
|
+
const others = [...installed].filter((n) => !ourSkills.has(n)).length
|
|
737
|
+
if (valid) ok.push(`${valid} kit skill(s) valid`)
|
|
738
|
+
if (others) ok.push(`${others} other skill(s) present`)
|
|
536
739
|
}
|
|
537
740
|
|
|
538
741
|
console.log(`\n${bold("verify")} ${stamp(`profile ${p.id} -> ${CONFIG_FILE}`)}\n`)
|
|
@@ -547,6 +750,21 @@ function cmdVerify() {
|
|
|
547
750
|
console.log(`\n ${green("install is consistent with the profile")}\n`)
|
|
548
751
|
}
|
|
549
752
|
|
|
753
|
+
/**
|
|
754
|
+
* The names of the skills this kit installs, read from the repo.
|
|
755
|
+
*
|
|
756
|
+
* These are the ones the loop refers to by name, so their frontmatter has to
|
|
757
|
+
* agree with their directory. Everything else in the skills directory belongs
|
|
758
|
+
* to whoever put it there.
|
|
759
|
+
*
|
|
760
|
+
* @returns {Set<string>}
|
|
761
|
+
*/
|
|
762
|
+
function kitSkillNames() {
|
|
763
|
+
const dir = join(ROOT, "skills")
|
|
764
|
+
if (!existsSync(dir)) return new Set()
|
|
765
|
+
return new Set(readdirSync(dir).filter((n) => existsSync(join(dir, n, "SKILL.md"))))
|
|
766
|
+
}
|
|
767
|
+
|
|
550
768
|
/** Minimal `key: value` reader for agent frontmatter. */
|
|
551
769
|
function stripFrontmatter(text) {
|
|
552
770
|
const block = /^---\n([\s\S]*?)\n---/.exec(text)
|
|
@@ -570,6 +788,7 @@ function configDrift(p, actual) {
|
|
|
570
788
|
const roles = resolveRoles(p)
|
|
571
789
|
|
|
572
790
|
if (actual.model !== want) out.push(`model ${actual.model} -> ${want}`)
|
|
791
|
+
if (actual.small_model !== want) out.push(`small_model ${actual.small_model} -> ${want}`)
|
|
573
792
|
|
|
574
793
|
for (const [role, level] of Object.entries(roles)) {
|
|
575
794
|
if (level === undefined) continue
|
|
@@ -578,17 +797,22 @@ function configDrift(p, actual) {
|
|
|
578
797
|
out.push(`agent.${role} missing`)
|
|
579
798
|
continue
|
|
580
799
|
}
|
|
581
|
-
// A
|
|
582
|
-
//
|
|
583
|
-
//
|
|
584
|
-
// one command that exists to catch a moved
|
|
585
|
-
|
|
800
|
+
// A lane's expected model is the one its chain resolved to, not the
|
|
801
|
+
// profile's base. A fleet puts ten different models behind ten agents, so
|
|
802
|
+
// comparing every lane against the base would report drift forever and
|
|
803
|
+
// train the user to ignore the one command that exists to catch a moved
|
|
804
|
+
// lane.
|
|
805
|
+
const resolved = resolveRoleModel(p, role)
|
|
806
|
+
const wantRole = `${p.provider}/${resolved ? resolved.model : p.model}`
|
|
586
807
|
if (agent.model !== wantRole) {
|
|
587
808
|
out.push(`agent.${role}.model ${agent.model} -> ${wantRole}`)
|
|
588
809
|
}
|
|
589
810
|
if (agent.variant !== (level ?? undefined)) {
|
|
590
811
|
out.push(`agent.${role}.variant ${agent.variant ?? "(none)"} -> ${level ?? "(none)"}`)
|
|
591
812
|
}
|
|
813
|
+
if (!agent.prompt) {
|
|
814
|
+
out.push(`agent.${role}.prompt missing - the lane would run without instructions`)
|
|
815
|
+
}
|
|
592
816
|
}
|
|
593
817
|
|
|
594
818
|
const wantReserved = reservedTokens(p)
|
|
@@ -610,20 +834,33 @@ function configDrift(p, actual) {
|
|
|
610
834
|
async function cmdUse(id) {
|
|
611
835
|
if (!id) fail("usage: oc-harness use <profile> [--print] [--yes]")
|
|
612
836
|
const p = loadProfile(id)
|
|
613
|
-
|
|
837
|
+
|
|
838
|
+
// A split profile puts the user behind every model in play, not just the base
|
|
839
|
+
// one. `p.retention` describes the base only, so gating on it let a profile
|
|
840
|
+
// whose plan/build/reviewer run on a may-train endpoint install silently.
|
|
841
|
+
// Every check below is over the whole set.
|
|
842
|
+
const eff = effectiveRetention(p)
|
|
843
|
+
const unrecorded = modelsInPlay(p).filter((m) => {
|
|
844
|
+
const entry = retentionFor(m)
|
|
845
|
+
return !entry || entry.retention === "unknown"
|
|
846
|
+
})
|
|
614
847
|
|
|
615
848
|
// A profile may not become the default until a human has stated its
|
|
616
849
|
// retention terms. Capability metadata cannot answer this question.
|
|
617
|
-
if (
|
|
850
|
+
if (unrecorded.length) {
|
|
618
851
|
fail(
|
|
619
|
-
`retention for ${
|
|
852
|
+
`retention for ${unrecorded.join(", ")} is not recorded in retention.yml.\n` +
|
|
620
853
|
`Add an entry with retention: zero-retention | may-train before installing it.`,
|
|
621
854
|
)
|
|
622
855
|
}
|
|
623
|
-
if (
|
|
856
|
+
if (eff.retention === "may-train" && !has("--yes")) {
|
|
857
|
+
const trainOn = roleSplitModels(p)
|
|
858
|
+
.filter((s) => retentionFor(s.model)?.retention === "may-train")
|
|
859
|
+
.map((s) => `${s.role} runs on ${s.model}`)
|
|
624
860
|
const msg =
|
|
625
|
-
`${yellow("!")} ${
|
|
626
|
-
` ${
|
|
861
|
+
`${yellow("!")} ${eff.worst} may train on your prompts and completions.\n` +
|
|
862
|
+
(trainOn.length ? ` ${trainOn.join(", ")}.\n` : "") +
|
|
863
|
+
` ${retentionFor(eff.worst)?.note ?? ""}\n` +
|
|
627
864
|
` Re-run with --yes to accept.`
|
|
628
865
|
if (process.stdin.isTTY && !has("--print")) {
|
|
629
866
|
fail(msg + "\n")
|
|
@@ -631,10 +868,7 @@ async function cmdUse(id) {
|
|
|
631
868
|
console.error(msg)
|
|
632
869
|
}
|
|
633
870
|
|
|
634
|
-
|
|
635
|
-
if (existsSync(CONFIG_FILE)) {
|
|
636
|
-
base = stripComments(readFileSync(CONFIG_FILE, "utf8"))
|
|
637
|
-
}
|
|
871
|
+
const base = readConfigJson()
|
|
638
872
|
|
|
639
873
|
const out = generateConfig(p, { base, comments: true })
|
|
640
874
|
if (has("--print")) {
|
|
@@ -652,10 +886,12 @@ async function cmdUse(id) {
|
|
|
652
886
|
}
|
|
653
887
|
|
|
654
888
|
mkdirSync(dirname(CONFIG_FILE), { recursive: true })
|
|
655
|
-
if (existsSync(CONFIG_FILE)
|
|
656
|
-
|
|
657
|
-
//
|
|
658
|
-
|
|
889
|
+
if (existsSync(CONFIG_FILE)) {
|
|
890
|
+
// The config is the user's file once written. The backup is unconditional:
|
|
891
|
+
// `--yes` skips the question, it does not skip the safety net. Gating the
|
|
892
|
+
// backup on `--yes` meant the one path that overwrites without asking was
|
|
893
|
+
// also the one path with nothing to fall back to.
|
|
894
|
+
if (!has("--yes") && interactive() && !(await confirm(`overwrite ${CONFIG_FILE}? (a .bak backup is kept)`))) {
|
|
659
895
|
fail("left the existing config alone")
|
|
660
896
|
}
|
|
661
897
|
writeFileSync(`${CONFIG_FILE}.bak`, readFileSync(CONFIG_FILE))
|
|
@@ -673,11 +909,12 @@ function summary(p) {
|
|
|
673
909
|
const splits = roleSplitModels(p)
|
|
674
910
|
console.log(` ${stamp(`model ${p.provider}/${p.model}`)}`)
|
|
675
911
|
console.log(` ${stamp(`effort ${resolveDefaultVariant(p) ?? "(not tunable)"}`)}`)
|
|
676
|
-
console.log(` ${stamp(`
|
|
912
|
+
console.log(` ${stamp(`lanes ${Object.entries(resolveRoles(p)).map(([k, v]) => `${k}=${v ?? "-"}`).join(" ")}`)}`)
|
|
677
913
|
if (splits.length) {
|
|
678
|
-
// Without this the
|
|
679
|
-
// is too late to be told which
|
|
680
|
-
|
|
914
|
+
// Without this the fleet is invisible until someone opens the file, which
|
|
915
|
+
// is too late to be told which lane sends prompts to a provider that
|
|
916
|
+
// trains on them.
|
|
917
|
+
console.log(` ${stamp(`lane model ${splits.map((s) => `${s.role}=${s.model}`).join(" ")}`)}`)
|
|
681
918
|
console.log(` ${stamp(` ${splits.map((s) => s.limits ? `${s.role} out ${s.limits.output.toLocaleString("en-US")}` : null).filter(Boolean).join(" ")}`)}`)
|
|
682
919
|
}
|
|
683
920
|
console.log(` ${stamp(`retention ${eff.retention}${eff.retention === "may-train" ? " <-- prompts may train a model" : ""}`)}`)
|
|
@@ -687,16 +924,24 @@ function summary(p) {
|
|
|
687
924
|
console.log(` ${stamp(`reserved ${reservedTokens(p).toLocaleString("en-US")} tokens`)}`)
|
|
688
925
|
}
|
|
689
926
|
|
|
690
|
-
/**
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
927
|
+
/**
|
|
928
|
+
* Read the installed config as JSONC.
|
|
929
|
+
*
|
|
930
|
+
* Strict on purpose. Returning `{}` for a config we could not parse made
|
|
931
|
+
* `oc-harness use` merge onto an empty base and write the user's own settings
|
|
932
|
+
* back out deleted, with no message. Refusing is the only safe answer here.
|
|
933
|
+
*/
|
|
934
|
+
function readConfigJson() {
|
|
935
|
+
if (!existsSync(CONFIG_FILE)) return {}
|
|
936
|
+
const parsed = parseJsonc(readFileSync(CONFIG_FILE, "utf8"))
|
|
937
|
+
if (!parsed) {
|
|
938
|
+
fail(
|
|
939
|
+
`${CONFIG_FILE} is not valid JSONC and was left untouched.\n` +
|
|
940
|
+
` Refusing to regenerate: the existing settings cannot be read, so merging\n` +
|
|
941
|
+
` onto them would silently drop them. Fix the file by hand, then re-run.`,
|
|
942
|
+
)
|
|
699
943
|
}
|
|
944
|
+
return parsed
|
|
700
945
|
}
|
|
701
946
|
|
|
702
947
|
/** Re-fetch models.dev and report which profiles have drifted from it. */
|
|
@@ -801,6 +1046,20 @@ async function cmdRefresh(id) {
|
|
|
801
1046
|
)
|
|
802
1047
|
}
|
|
803
1048
|
|
|
1049
|
+
/**
|
|
1050
|
+
* The model chain of every lane a profile enables, keyed by lane.
|
|
1051
|
+
* @param {ReturnType<typeof loadProfile>} p
|
|
1052
|
+
* @returns {Record<string, string[]>}
|
|
1053
|
+
*/
|
|
1054
|
+
function chainOf(p) {
|
|
1055
|
+
/** @type {Record<string, string[]>} */
|
|
1056
|
+
const out = {}
|
|
1057
|
+
for (const lane of Object.keys(p.roles ?? {})) {
|
|
1058
|
+
out[lane] = resolveLaneChain(p, lane)
|
|
1059
|
+
}
|
|
1060
|
+
return out
|
|
1061
|
+
}
|
|
1062
|
+
|
|
804
1063
|
/** Re-check every profile against the live provider catalog. */
|
|
805
1064
|
async function cmdDoctor() {
|
|
806
1065
|
const catalog = await fetchCatalog()
|
|
@@ -832,13 +1091,29 @@ async function cmdDoctor() {
|
|
|
832
1091
|
issues.push(red("retention.yml now says may-train"))
|
|
833
1092
|
}
|
|
834
1093
|
|
|
1094
|
+
// A fleet's lanes point at other models, and those are the routes that
|
|
1095
|
+
// get withdrawn. A lane whose whole chain has left the catalog is a lane
|
|
1096
|
+
// with no model at all, which no amount of `refresh` on the base profile
|
|
1097
|
+
// would reveal.
|
|
1098
|
+
for (const [lane, chain] of Object.entries(chainOf(p))) {
|
|
1099
|
+
const alive = chain.filter((rung) => catalog?.[p.provider]?.models?.[rung])
|
|
1100
|
+
if (!alive.length) {
|
|
1101
|
+
issues.push(red(`lane ${lane}: every model in its chain has left the catalog`))
|
|
1102
|
+
} else if (alive.length < chain.length) {
|
|
1103
|
+
const gone = chain.filter((rung) => !alive.includes(rung))
|
|
1104
|
+
issues.push(
|
|
1105
|
+
yellow(`lane ${lane}: ${gone.join(", ")} withdrawn, falls back to ${alive[0]}`),
|
|
1106
|
+
)
|
|
1107
|
+
}
|
|
1108
|
+
}
|
|
1109
|
+
|
|
835
1110
|
if (issues.length === 0) {
|
|
836
1111
|
console.log(` ${green("✓")} ${id.padEnd(32)} ${stamp("matches catalog")}`)
|
|
837
1112
|
} else {
|
|
838
1113
|
problems++
|
|
839
1114
|
console.log(` ${yellow("!")} ${id.padEnd(32)} ${yellow("drift")}`)
|
|
840
1115
|
for (const i of issues) console.log(` ${i}`)
|
|
841
|
-
console.log(` ${stamp(`fix: oc-harness
|
|
1116
|
+
console.log(` ${stamp(`fix: oc-harness refresh ${id}`)}`)
|
|
842
1117
|
}
|
|
843
1118
|
}
|
|
844
1119
|
console.log(
|
|
@@ -858,7 +1133,7 @@ async function cmdDoctor() {
|
|
|
858
1133
|
console.log(` ${yellow("!")} ${stamp("no profile recorded in the config. Run: oc-harness use <profile>")}\n`)
|
|
859
1134
|
return
|
|
860
1135
|
}
|
|
861
|
-
const installed = existsSync(CONFIG_FILE) ?
|
|
1136
|
+
const installed = existsSync(CONFIG_FILE) ? readConfigJson() : null
|
|
862
1137
|
if (!installed) {
|
|
863
1138
|
console.log(` ${yellow("!")} ${stamp(`${CONFIG_FILE} missing. Run: oc-harness use ${activeId}`)}\n`)
|
|
864
1139
|
return
|
|
@@ -874,6 +1149,93 @@ async function cmdDoctor() {
|
|
|
874
1149
|
}
|
|
875
1150
|
}
|
|
876
1151
|
|
|
1152
|
+
/**
|
|
1153
|
+
* Which lanes the fleet actually used.
|
|
1154
|
+
*
|
|
1155
|
+
* A fleet is a hypothesis, not a fact: ten lanes is a guess about which jobs
|
|
1156
|
+
* need a different model. The only way to know which of them earn their place
|
|
1157
|
+
* is to count the calls. A lane that was never called is a description the
|
|
1158
|
+
* model reads on every turn and a free model doing nothing, so it is the first
|
|
1159
|
+
* thing to cut.
|
|
1160
|
+
*/
|
|
1161
|
+
function cmdLanes() {
|
|
1162
|
+
const activeId = profileIdFromConfig() ?? index().default
|
|
1163
|
+
let profile = null
|
|
1164
|
+
try {
|
|
1165
|
+
profile = loadProfile(activeId)
|
|
1166
|
+
} catch (err) {
|
|
1167
|
+
fail(`active profile "${activeId}" cannot be loaded: ${err.message}`)
|
|
1168
|
+
}
|
|
1169
|
+
|
|
1170
|
+
// The plugin writes to the project root, so the file is found by walking up
|
|
1171
|
+
// from here. Reading `process.cwd()` alone reported "never called" for every
|
|
1172
|
+
// lane whenever the command was run from a subdirectory.
|
|
1173
|
+
const file = findTelemetryFile(process.cwd())
|
|
1174
|
+
const { lanes: stats, records, ignored } = readLaneUsage(file)
|
|
1175
|
+
|
|
1176
|
+
console.log(`\n${bold("lanes")} ${stamp(`${activeId} - ${records} lane request(s)`)}\n`)
|
|
1177
|
+
const lanes = Object.keys(profile.roles ?? {})
|
|
1178
|
+
if (!lanes.length) {
|
|
1179
|
+
console.log(` ${stamp("this profile enables no lanes")}\n`)
|
|
1180
|
+
return
|
|
1181
|
+
}
|
|
1182
|
+
const head = [
|
|
1183
|
+
" " + "lane".padEnd(12),
|
|
1184
|
+
"reqs".padStart(7),
|
|
1185
|
+
"avg ms".padStart(9),
|
|
1186
|
+
"model".padEnd(32),
|
|
1187
|
+
].join(" ")
|
|
1188
|
+
console.log(head)
|
|
1189
|
+
console.log(" " + "-".repeat(64))
|
|
1190
|
+
|
|
1191
|
+
let unused = 0
|
|
1192
|
+
for (const lane of lanes) {
|
|
1193
|
+
const s = stats[lane]
|
|
1194
|
+
const model = resolveLaneModel(profile, lane).model
|
|
1195
|
+
if (!s) {
|
|
1196
|
+
unused++
|
|
1197
|
+
console.log(
|
|
1198
|
+
[
|
|
1199
|
+
" " + lane.padEnd(12),
|
|
1200
|
+
String(0).padStart(7),
|
|
1201
|
+
"-".padStart(9),
|
|
1202
|
+
(model ?? "-").padEnd(32),
|
|
1203
|
+
].join(" ") + yellow(" never used"),
|
|
1204
|
+
)
|
|
1205
|
+
continue
|
|
1206
|
+
}
|
|
1207
|
+
const avg = averageMs(s.ms)
|
|
1208
|
+
console.log(
|
|
1209
|
+
[
|
|
1210
|
+
" " + lane.padEnd(12),
|
|
1211
|
+
String(s.requests).padStart(7),
|
|
1212
|
+
String(avg ?? "-").padStart(9),
|
|
1213
|
+
([...s.models].join(", ") || model || "-").padEnd(32),
|
|
1214
|
+
].join(" "),
|
|
1215
|
+
)
|
|
1216
|
+
}
|
|
1217
|
+
|
|
1218
|
+
console.log("")
|
|
1219
|
+
if (unused) {
|
|
1220
|
+
console.log(
|
|
1221
|
+
` ${yellow(`${unused} lane(s) were never used`)} ${stamp("cut candidates, not defects")}`,
|
|
1222
|
+
)
|
|
1223
|
+
} else {
|
|
1224
|
+
console.log(` ${green("every lane was used at least once")}`)
|
|
1225
|
+
}
|
|
1226
|
+
console.log(
|
|
1227
|
+
` ${stamp("one request is one assistant message; avg ms covers the requests that finished")}`,
|
|
1228
|
+
)
|
|
1229
|
+
console.log(
|
|
1230
|
+
` ${stamp(`telemetry is read from ${file ?? "no .space/telemetry.jsonl found by walking up from here"}`)}\n`,
|
|
1231
|
+
)
|
|
1232
|
+
if (ignored) {
|
|
1233
|
+
console.log(
|
|
1234
|
+
` ${yellow(`${ignored} record(s) had no message id and were not counted`)} ${stamp("written by a kit version before the counter was fixed")}\n`,
|
|
1235
|
+
)
|
|
1236
|
+
}
|
|
1237
|
+
}
|
|
1238
|
+
|
|
877
1239
|
/**
|
|
878
1240
|
* Report agents in a project that the kit's effort tiering does not cover.
|
|
879
1241
|
*
|
|
@@ -886,13 +1248,16 @@ function cmdAgents() {
|
|
|
886
1248
|
// The global dir is where `oc-harness install` puts the kit's own agents, so
|
|
887
1249
|
// scanning only the project reported "no project agents found" forever and hid
|
|
888
1250
|
// the fact that none of them were installed at all.
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
1251
|
+
//
|
|
1252
|
+
// opencode reads `agent/` and `agents/` alike, in the config dir and in
|
|
1253
|
+
// `.opencode/`, so all four are scanned. A file in the singular directory
|
|
1254
|
+
// shadows a lane exactly as one in the plural one does.
|
|
1255
|
+
const files = agentDirs().flatMap(({ dir }) =>
|
|
1256
|
+
existsSync(dir)
|
|
1257
|
+
? readdirSync(dir)
|
|
1258
|
+
.filter((f) => f.endsWith(".md"))
|
|
1259
|
+
.map((f) => join(dir, f))
|
|
1260
|
+
: [],
|
|
896
1261
|
)
|
|
897
1262
|
|
|
898
1263
|
// Never hardcode a profile here: the whole point is to check the one that is
|
|
@@ -908,49 +1273,75 @@ function cmdAgents() {
|
|
|
908
1273
|
fail("no profile is installed. Run: oc-harness use <profile>")
|
|
909
1274
|
}
|
|
910
1275
|
|
|
911
|
-
const config =
|
|
912
|
-
const configured = config.model ?? "(none)"
|
|
1276
|
+
const config = readConfigJson()
|
|
913
1277
|
const wantModel = `${current.provider}/${current.model}`
|
|
914
1278
|
|
|
915
1279
|
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
1280
|
|
|
1281
|
+
// The kit's own lanes live in the config, not in markdown: an agent file of
|
|
1282
|
+
// the same name replaces the config entry, and a file without a `model:`
|
|
1283
|
+
// line makes the agent inherit the caller's model.
|
|
923
1284
|
const roles = resolveRoles(current)
|
|
1285
|
+
const lanes = Object.keys(config.agent ?? {})
|
|
1286
|
+
if (!lanes.length) {
|
|
1287
|
+
console.log(` ${red("✗")} ${yellow("the config declares no agents")}`)
|
|
1288
|
+
console.log(` ${stamp("run: oc-harness use <profile>")}\n`)
|
|
1289
|
+
return
|
|
1290
|
+
}
|
|
924
1291
|
let untiered = 0
|
|
925
|
-
|
|
926
|
-
|
|
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
|
-
|
|
1292
|
+
for (const lane of lanes) {
|
|
1293
|
+
const a = config.agent[lane] ?? {}
|
|
934
1294
|
const notes = []
|
|
935
|
-
if (!
|
|
936
|
-
if (
|
|
937
|
-
notes.push(`model ${
|
|
1295
|
+
if (!a.model) notes.push("no model of its own, inherits the session model")
|
|
1296
|
+
else if (a.model !== wantModel && !modelsInPlay(current).some((m) => `${current.provider}/${m}` === a.model)) {
|
|
1297
|
+
notes.push(`model ${a.model} is not in the active profile`)
|
|
1298
|
+
}
|
|
1299
|
+
if (a.model && !a.variant) {
|
|
1300
|
+
notes.push("no variant, runs at the model default")
|
|
938
1301
|
}
|
|
939
|
-
if (
|
|
1302
|
+
if (!a.prompt) notes.push("no prompt, runs on opencode's default instructions")
|
|
1303
|
+
else {
|
|
1304
|
+
const target = String(a.prompt).replace(/^\{file:/, "").replace(/\}$/, "")
|
|
1305
|
+
const resolved = target.startsWith("~") ? join(homedir(), target.slice(1)) : target
|
|
1306
|
+
if (!existsSync(resolved)) notes.push(`prompt ${target} is not installed`)
|
|
1307
|
+
}
|
|
1308
|
+
if (lane in roles && roles[lane] === undefined) notes.push("not tiered by the profile")
|
|
940
1309
|
|
|
941
1310
|
if (!notes.length) {
|
|
942
|
-
console.log(
|
|
1311
|
+
console.log(
|
|
1312
|
+
` ${green("✓")} ${lane.padEnd(14)} ${stamp(`${a.model ?? "-"}${a.variant ? ` ${a.variant}` : ""}`)}`,
|
|
1313
|
+
)
|
|
943
1314
|
continue
|
|
944
1315
|
}
|
|
945
|
-
untiered
|
|
946
|
-
|
|
947
|
-
console.log(` ${yellow("!")} ${name.padEnd(14)} ${yellow("not tiered")}`)
|
|
1316
|
+
untiered++
|
|
1317
|
+
console.log(` ${yellow("!")} ${lane.padEnd(14)} ${yellow("not tiered")}`)
|
|
948
1318
|
for (const nn of notes) console.log(` ${nn}`)
|
|
949
1319
|
}
|
|
950
1320
|
|
|
1321
|
+
// A project agent declared in .opencode/agents/ with no model of its own
|
|
1322
|
+
// inherits the global model and runs at the global effort, which is usually
|
|
1323
|
+
// the most expensive tier. That is exactly the waste this kit exists to
|
|
1324
|
+
// remove, and it is invisible otherwise.
|
|
1325
|
+
for (const file of files) {
|
|
1326
|
+
const text = readFileSync(file, "utf8")
|
|
1327
|
+
const name = file.split("/").pop().replace(/\.md$/, "")
|
|
1328
|
+
const front = stripFrontmatter(text)
|
|
1329
|
+
if (name in config.agent) {
|
|
1330
|
+
console.log(
|
|
1331
|
+
` ${yellow("!")} ${name.padEnd(14)} ${yellow("shadows a lane in the config")}`,
|
|
1332
|
+
)
|
|
1333
|
+
console.log(` ${stamp("an agent file replaces the config entry, so the lane loses its model")}`)
|
|
1334
|
+
continue
|
|
1335
|
+
}
|
|
1336
|
+
if (!front.model) {
|
|
1337
|
+
untiered++
|
|
1338
|
+
console.log(` ${yellow("!")} ${name.padEnd(14)} ${yellow("not tiered")}`)
|
|
1339
|
+
console.log(` ${stamp("no model of its own, inherits the session model")}`)
|
|
1340
|
+
}
|
|
1341
|
+
}
|
|
1342
|
+
|
|
951
1343
|
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` : ""}`,
|
|
1344
|
+
`\n ${untiered ? yellow(`${untiered} agent(s) run at the session effort`) : green("all agents tiered")}`,
|
|
954
1345
|
)
|
|
955
1346
|
console.log(` ${stamp("add `model:` and `variant:` to the agent frontmatter, or map them in the profile")}\n`)
|
|
956
1347
|
}
|
|
@@ -960,6 +1351,32 @@ function fail(msg) {
|
|
|
960
1351
|
process.exit(1)
|
|
961
1352
|
}
|
|
962
1353
|
|
|
1354
|
+
/**
|
|
1355
|
+
* Drive the task file to done without a human at the keyboard.
|
|
1356
|
+
*
|
|
1357
|
+
* Every stop condition is printed, and the exit code says which kind of stop it
|
|
1358
|
+
* was: 0 finished, 1 nothing to plan, 2 needs a person, 3 the agent died,
|
|
1359
|
+
* 4 verify failed. A caller can therefore tell "done" from "gave up" without
|
|
1360
|
+
* reading the transcript.
|
|
1361
|
+
*/
|
|
1362
|
+
async function cmdRun() {
|
|
1363
|
+
const { runLoop } = await import("../src/driver/run.mjs")
|
|
1364
|
+
|
|
1365
|
+
const result = runLoop({
|
|
1366
|
+
cwd: process.cwd(),
|
|
1367
|
+
role: opt("--role") ?? "build",
|
|
1368
|
+
maxRounds: Number(opt("--max-rounds") ?? 20),
|
|
1369
|
+
budgetMinutes: Number(opt("--budget") ?? 120),
|
|
1370
|
+
})
|
|
1371
|
+
|
|
1372
|
+
const s = result.summary
|
|
1373
|
+
console.log(`\n ${bold(result.code === 0 ? "done" : "stopped")} ${result.reason}`)
|
|
1374
|
+
if (s) {
|
|
1375
|
+
console.log(` ${stamp(`${s.done} done, ${s.blocked} blocked, ${s.pending} pending`)}`)
|
|
1376
|
+
}
|
|
1377
|
+
if (result.code !== 0) process.exitCode = result.code
|
|
1378
|
+
}
|
|
1379
|
+
|
|
963
1380
|
try {
|
|
964
1381
|
switch (cmd) {
|
|
965
1382
|
case "list":
|
|
@@ -989,12 +1406,18 @@ try {
|
|
|
989
1406
|
case "agents":
|
|
990
1407
|
cmdAgents()
|
|
991
1408
|
break
|
|
1409
|
+
case "lanes":
|
|
1410
|
+
cmdLanes()
|
|
1411
|
+
break
|
|
992
1412
|
case "doctor":
|
|
993
1413
|
await cmdDoctor()
|
|
994
1414
|
break
|
|
995
1415
|
case "baseline":
|
|
996
1416
|
await import("./baseline.mjs")
|
|
997
1417
|
break
|
|
1418
|
+
case "run":
|
|
1419
|
+
await cmdRun()
|
|
1420
|
+
break
|
|
998
1421
|
case "help":
|
|
999
1422
|
default:
|
|
1000
1423
|
console.log(`
|
|
@@ -1003,21 +1426,34 @@ try {
|
|
|
1003
1426
|
list show profiles with retention and caveats
|
|
1004
1427
|
init pick a free profile, then install + use + verify in one go
|
|
1005
1428
|
use <id> compile a profile into ${CONFIG_FILE}
|
|
1006
|
-
show <id> print the resolved
|
|
1429
|
+
show <id> print the resolved lane plan and derived numbers
|
|
1007
1430
|
refresh <id> update capability fields from models.dev
|
|
1008
|
-
install symlink the plugin,
|
|
1431
|
+
install symlink the plugin, prompts, skills and GLOBAL.md into the config dir
|
|
1009
1432
|
verify check the install still matches the profile and its rules are loaded
|
|
1010
1433
|
link symlink the built plugin into ~/.config/opencode/plugins
|
|
1011
1434
|
agents report agents the effort tiering does not reach
|
|
1435
|
+
lanes report which lanes the fleet actually used
|
|
1012
1436
|
doctor re-check profiles against models.dev and the installed config
|
|
1013
1437
|
baseline measure the current opencode install
|
|
1438
|
+
run drive .space/tasks.md to done with the build agent
|
|
1014
1439
|
|
|
1015
1440
|
options
|
|
1016
1441
|
--print write the config to stdout instead of disk
|
|
1017
1442
|
--yes accept a retention warning, back up the old config
|
|
1018
1443
|
--retention filter list by retention class
|
|
1019
1444
|
--free list free profiles only
|
|
1445
|
+
|
|
1446
|
+
run options
|
|
1447
|
+
--role <agent> which agent does the work (default: build)
|
|
1448
|
+
--max-rounds N stop after N tasks (default: 20)
|
|
1449
|
+
--budget MIN stop after MIN minutes (default: 120)
|
|
1020
1450
|
`)
|
|
1451
|
+
// An unknown command is a typo, not a request for help. Without this
|
|
1452
|
+
// `oc-harness tsl` prints help and exits 0, so a script calling it never
|
|
1453
|
+
// finds out that nothing ran.
|
|
1454
|
+
if (cmd !== "help" && cmd !== "--help" && cmd !== "-h") {
|
|
1455
|
+
fail(`unknown command: ${cmd}\n run 'oc-harness help' for the list`)
|
|
1456
|
+
}
|
|
1021
1457
|
}
|
|
1022
1458
|
} catch (err) {
|
|
1023
1459
|
if (err instanceof ProfileError) fail(err.message)
|