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.
Files changed (57) hide show
  1. package/CHANGELOG.md +301 -0
  2. package/GLOBAL.md +50 -13
  3. package/MIGRATION.md +140 -0
  4. package/README.md +276 -122
  5. package/bin/oc-harness.mjs +571 -135
  6. package/dist/index.js +453 -159
  7. package/docs/KNOWN-LIMITS.md +189 -4
  8. package/docs/RESEARCH.md +80 -5
  9. package/docs/ROUTING.md +254 -0
  10. package/lanes.json +136 -0
  11. package/lanes.schema.json +55 -0
  12. package/package.json +13 -11
  13. package/profiles/beam-free.json +51 -0
  14. package/profiles/big-pickle.json +45 -0
  15. package/profiles/deepseek-v4.1-flash-free.json +49 -0
  16. package/profiles/fleet-all.json +139 -0
  17. package/profiles/fleet-safe.json +139 -0
  18. package/profiles/glm-5.3-flash-free.json +49 -0
  19. package/profiles/index.json +120 -37
  20. package/profiles/longcat-2.5-preview-free.json +14 -10
  21. package/profiles/mimo-v2.6-flash-free.json +44 -0
  22. package/profiles/minimax-m3.1-flash-free.json +51 -0
  23. package/profiles/nemotron-3-ultra-free.json +44 -0
  24. package/profiles/nemotron-3.5-lightning-free.json +44 -0
  25. package/profiles/nemotron-3.5-super-vl-free.json +52 -0
  26. package/profiles/schema.json +30 -11
  27. package/{agents → prompts}/build.md +2 -35
  28. package/prompts/doc.md +42 -0
  29. package/{agents → prompts}/locate.md +1 -16
  30. package/prompts/longform.md +34 -0
  31. package/{agents → prompts}/plan.md +2 -25
  32. package/{agents → prompts}/reviewer.md +2 -22
  33. package/prompts/schema.md +30 -0
  34. package/prompts/sweep.md +29 -0
  35. package/{agents → prompts}/verify.md +6 -29
  36. package/prompts/vision.md +33 -0
  37. package/retention.yml +75 -57
  38. package/skills/space-build/SKILL.md +16 -0
  39. package/skills/space-finish/SKILL.md +0 -10
  40. package/skills/space-review/SKILL.md +8 -0
  41. package/src/config.js +94 -69
  42. package/src/driver/run.mjs +237 -0
  43. package/src/effort.js +59 -22
  44. package/src/index.ts +15 -2
  45. package/src/lane-usage.js +142 -0
  46. package/src/profile.js +615 -221
  47. package/src/router.ts +148 -39
  48. package/src/tasks.js +248 -0
  49. package/src/telemetry.ts +98 -0
  50. package/docs/demo/demo.gif +0 -0
  51. package/docs/demo/demo.mp4 +0 -0
  52. package/docs/demo/render.py +0 -146
  53. package/examples/muse-spark-1.3-contributor-free.json +0 -41
  54. package/profiles/deepseek-v4-flash-free.json +0 -41
  55. package/profiles/muse-split-free.json +0 -64
  56. package/profiles/space-bunny-free.json +0 -42
  57. package/profiles/x-preview-f-free.json +0 -41
@@ -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
- "ctx".padStart(9),
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(72))
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
- const r = retention[`opencode/${entry.id}`]
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
- entry.retention === "zero-retention"
103
- ? green(entry.retention)
104
- : yellow(entry.retention)
137
+ effective === "zero-retention"
138
+ ? green(effective)
139
+ : yellow(effective)
105
140
  const mismatch =
106
- r && r.retention && r.retention !== entry.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
- (entry.context ?? 0).toLocaleString("en-US").padStart(9),
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()[`opencode/${p.provider}/${p.model}`]
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
- row("input cap", (p.limits.input ?? p.limits.context).toLocaleString("en-US"))
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("role models")}`)
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
- console.log(`\n ${bold("role effort")}`)
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, "agents"), dest: join(CONFIG_DIR, "agents"), kind: "dir" },
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
- let stale = false
508
+
509
+ let isLink = false
510
+ let ours = false
377
511
  try {
378
- stale = readlinkSync(dest) !== target
512
+ isLink = lstatSync(dest).isSymbolicLink()
513
+ ours = isLink && readlinkSync(dest) === target
379
514
  } catch {
380
- stale = true
515
+ // Nothing at `dest` yet.
381
516
  }
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.
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
- if (!existsSync(dest)) {
390
- try {
391
- symlinkSync(target, dest)
392
- } catch (err) {
393
- console.log(` ${yellow("!")} ${stamp(`could not link ${dest}: ${err.message}`)}`)
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 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")
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(agentsDir, `${role}.md`)
604
+ const file = join(promptsDir, `${role}.md`)
462
605
  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)
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
- 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`)
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 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")
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] !== p.model) {
493
- problems.push(`agents/${name} mentions opencode/${named[1]}, active profile is ${p.model}`)
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)) ok.push("plugin linked")
501
- else notes.push("no plugin link: the guard is not enforcing anything. Run: oc-harness link")
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 readdirSync(skillsDir)) {
709
+ for (const name of ourSkills) {
521
710
  const skillFile = join(skillsDir, name, "SKILL.md")
522
711
  if (!existsSync(skillFile)) {
523
- problems.push(`skills/${name} has no SKILL.md - it will never load. Run: oc-harness install`)
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
- if (valid) ok.push(`${valid} skill(s) valid`)
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 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}`
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
- const retention = loadRetention()[`${p.provider}/${p.model}`]
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 (retention?.retention === "unknown" || (!retention && p.retention === "unknown")) {
850
+ if (unrecorded.length) {
618
851
  fail(
619
- `retention for ${p.provider}/${p.model} is not recorded in retention.yml.\n` +
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 (p.retention === "may-train" && !has("--yes")) {
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("!")} ${p.provider}/${p.model} may train on your prompts and completions.\n` +
626
- ` ${retention?.note ?? ""}\n` +
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
- let base = {}
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) && !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)`))) {
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(`roles ${Object.entries(resolveRoles(p)).map(([k, v]) => `${k}=${v ?? "-"}`).join(" ")}`)}`)
912
+ console.log(` ${stamp(`lanes ${Object.entries(resolveRoles(p)).map(([k, v]) => `${k}=${v ?? "-"}`).join(" ")}`)}`)
677
913
  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(" ")}`)}`)
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
- /** 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 {}
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 use ${id} --refresh`)}`)
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) ? stripComments(readFileSync(CONFIG_FILE, "utf8")) : null
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
- 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)) : [],
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 = existsSync(CONFIG_FILE) ? stripComments(readFileSync(CONFIG_FILE, "utf8")) : {}
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
- 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
-
1292
+ for (const lane of lanes) {
1293
+ const a = config.agent[lane] ?? {}
934
1294
  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}`)
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 (hasModel && !hasVariant) notes.push("no variant, runs at the model default")
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(` ${green("✓")} ${name.padEnd(14)} ${stamp(`tiered${isKitRole ? ` ${roles[name]}` : ""}`)}`)
1311
+ console.log(
1312
+ ` ${green("✓")} ${lane.padEnd(14)} ${stamp(`${a.model ?? "-"}${a.variant ? ` ${a.variant}` : ""}`)}`,
1313
+ )
943
1314
  continue
944
1315
  }
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")}`)
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 effort plan and derived numbers
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, agents, skills and GLOBAL.md into the config dir
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)