opencode-harness-kit 0.2.1 → 0.3.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/CHANGELOG.md +303 -0
  2. package/GLOBAL.md +50 -13
  3. package/MIGRATION.md +140 -0
  4. package/README.md +233 -116
  5. package/bin/oc-harness.mjs +474 -96
  6. package/dist/index.js +378 -155
  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 +12 -8
  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-review/SKILL.md +8 -0
  40. package/src/config.js +94 -69
  41. package/src/effort.js +59 -22
  42. package/src/index.ts +15 -2
  43. package/src/lane-usage.js +142 -0
  44. package/src/profile.js +502 -212
  45. package/src/router.ts +148 -39
  46. package/src/telemetry.ts +98 -0
  47. package/examples/muse-spark-1.3-contributor-free.json +0 -41
  48. package/profiles/deepseek-v4-flash-free.json +0 -41
  49. package/profiles/muse-split-free.json +0 -64
  50. package/profiles/space-bunny-free.json +0 -42
  51. package/profiles/x-preview-f-free.json +0 -41
@@ -25,17 +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,
35
44
  parseJsonc,
36
45
  } from "../src/profile.js"
37
46
  import { generateConfig, globalInstructionsPath } from "../src/config.js"
38
47
  import { planEffort } from "../src/effort.js"
48
+ import { findTelemetryFile, readLaneUsage, averageMs } from "../src/lane-usage.js"
39
49
 
40
50
  const CONFIG_DIR =
41
51
  process.env.OPENCODE_CONFIG_DIR ?? join(homedir(), ".config/opencode")
@@ -85,33 +95,58 @@ async function cmdList() {
85
95
  console.log(`\n${bold("opencode-harness-kit")} ${stamp("profiles")}\n`)
86
96
  const head = [
87
97
  " " + "profile".padEnd(30),
88
- "ctx".padStart(9),
98
+ "input".padStart(9),
89
99
  "top".padStart(7),
100
+ "lanes".padStart(6),
90
101
  "retention".padEnd(15),
91
102
  ].join(" ")
92
103
  console.log(head)
93
- console.log(" " + "-".repeat(72))
104
+ console.log(" " + "-".repeat(79))
94
105
 
95
106
  for (const entry of idx.profiles ?? []) {
96
107
  if (only && entry.retention !== only) continue
97
108
  if (freeOnly && entry.free === false) continue
98
- 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}`]
99
133
  const isDefault = entry.default
100
134
  const name = (isDefault ? green("▸ ") : " ") + entry.id
101
135
  const top = entry.topEffort ?? stamp("—")
102
136
  const ret =
103
- entry.retention === "zero-retention"
104
- ? green(entry.retention)
105
- : yellow(entry.retention)
137
+ effective === "zero-retention"
138
+ ? green(effective)
139
+ : yellow(effective)
106
140
  const mismatch =
107
- r && r.retention && r.retention !== entry.retention
141
+ r && r.retention && r.retention !== effective && lanes === 0
108
142
  ? ` ${red(`(retention.yml says ${r.retention})`)}`
109
143
  : ""
110
144
  console.log(
111
145
  [
112
146
  " " + name.padEnd(32),
113
- (entry.context ?? 0).toLocaleString("en-US").padStart(9),
147
+ cap.toLocaleString("en-US").padStart(9),
114
148
  String(top).padStart(7),
149
+ String(lanes).padStart(6),
115
150
  ret.padEnd(15),
116
151
  ].join(" ") + mismatch,
117
152
  )
@@ -126,6 +161,17 @@ async function cmdList() {
126
161
  console.log(` ${yellow(entry.reason)}`)
127
162
  }
128
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
+ }
129
175
  console.log(
130
176
  `\n ${stamp("install one:")} oc-harness use <profile>\n ${stamp("inspect:")} oc-harness show <profile>\n`,
131
177
  )
@@ -141,7 +187,11 @@ function cmdShow(id) {
141
187
  const row = (k, v) => console.log(` ${k.padEnd(20)} ${v}`)
142
188
  row("provider/model", `${p.provider}/${p.model}`)
143
189
  row("context", p.limits.context.toLocaleString("en-US"))
144
- 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"))
145
195
  row("output cap", p.limits.output.toLocaleString("en-US"))
146
196
  row("effort levels", p.effort?.levels?.length ? p.effort.levels.join(" < ") : "(not tunable)")
147
197
  row("default variant", resolveDefaultVariant(p) ?? "(none)")
@@ -157,14 +207,31 @@ function cmdShow(id) {
157
207
  console.log(` ${"".padEnd(20)} ${stamp(`base ${p.model} is ${p.retention}, but ${eff.worst} makes the whole profile ${eff.retention}`)}`)
158
208
  }
159
209
  if (roleSplitModels(p).length) {
160
- console.log(`\n ${bold("role models")}`)
210
+ console.log(`\n ${bold("lanes")}`)
161
211
  for (const s of roleSplitModels(p)) {
162
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(" → ")}` : ""
163
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}`)
164
222
  }
165
223
  }
166
224
 
167
- 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")}`)
168
235
  for (const [role, level] of Object.entries(plan.roles)) {
169
236
  const want = p.roles?.[role]
170
237
  const arrow = want && want !== level ? ` ${stamp(`(requested ${want})`)}` : ""
@@ -217,12 +284,69 @@ function cmdLink() {
217
284
  /** Where the behavioural layer has to land for the config to mean anything. */
218
285
  function installTargets() {
219
286
  return [
220
- { src: join(ROOT, "agents"), dest: join(CONFIG_DIR, "agents"), kind: "dir" },
287
+ { src: join(ROOT, "prompts"), dest: join(CONFIG_DIR, "prompts"), kind: "dir" },
221
288
  { src: join(ROOT, "skills"), dest: join(CONFIG_DIR, "skills"), kind: "dir" },
222
289
  { src: join(ROOT, "GLOBAL.md"), dest: join(CONFIG_DIR, "GLOBAL.md"), kind: "file" },
223
290
  ]
224
291
  }
225
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
+
226
350
  /**
227
351
  * Replace `link` with `link` plus the agents, skills and GLOBAL.md.
228
352
  *
@@ -239,6 +363,13 @@ async function cmdInstall() {
239
363
  // The plugin link first: without it the guard is not enforcing anything.
240
364
  cmdLink()
241
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
+
242
373
  for (const { src, dest, kind } of installTargets()) {
243
374
  if (!existsSync(src)) {
244
375
  console.log(` ${yellow("!")} ${stamp(`${kind} missing in repo: ${src}`)}`)
@@ -462,53 +593,88 @@ function cmdVerify() {
462
593
  }
463
594
  }
464
595
 
465
- // 3. Every role the profile tiers must have an installed agent file, or the
466
- // config entry configures a model and a permission but no behaviour.
467
- 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")
468
601
  const roles = resolveRoles(p)
469
602
  for (const [role, level] of Object.entries(roles)) {
470
603
  if (level === undefined) continue
471
- const file = join(agentsDir, `${role}.md`)
604
+ const file = join(promptsDir, `${role}.md`)
472
605
  if (!existsSync(file)) {
473
- // build and plan are built-in agents: opencode reads their config entry
474
- // even with no markdown file, but they then carry no custom prompt.
475
- const builtin = role === "build" || role === "plan"
476
- const msg = builtin
477
- ? `no agents/${role}.md - ${role} is a built-in agent, so it runs with opencode's default prompt, not this kit's`
478
- : `no agents/${role}.md - ${role} is declared in the config but does not exist as an agent. Run: oc-harness install`
479
- 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
+ )
480
609
  continue
481
610
  }
482
- const front = stripFrontmatter(readFileSync(file, "utf8"))
483
- const wantModel = `${p.provider}/${p.model}`
484
- const gotModel = front.model
485
- if (gotModel && gotModel !== wantModel) {
486
- problems.push(`agents/${role}.md names model ${gotModel}, profile says ${wantModel}`)
487
- } else if (!gotModel) {
488
- notes.push(`agents/${role}.md has no model: inherits the session model, so effort tiering may not apply`)
489
- }
490
- if (!gotModel && front.variant) {
491
- 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
+ }
492
623
  }
493
624
  }
494
625
 
495
- // 4. No installed agent may name a model that is not the active one. A
496
- // stale name in a prompt is the documentation rot `space-finish` warns
497
- // about, and it also means the agent silently runs on another model.
498
- if (existsSync(agentsDir)) {
499
- for (const name of readdirSync(agentsDir).filter((f) => f.endsWith(".md"))) {
500
- 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")
501
647
  const named = text.match(/opencode\/([a-z0-9.\-]+)/i)
502
- if (named && named[1] !== p.model) {
503
- 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
+ )
504
652
  }
505
653
  }
506
654
  }
507
655
 
508
656
  // 5. The plugin link has to exist or none of the guard is running.
509
657
  const pluginLink = join(CONFIG_DIR, "plugins", "opencode-harness-kit.js")
510
- if (existsSync(pluginLink)) ok.push("plugin linked")
511
- 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
+ }
512
678
 
513
679
  // 6. Schema check. `variant` at the top level is silently stripped by
514
680
  // OpenCode, so emitting it looks like tiering and does nothing.
@@ -522,15 +688,30 @@ function cmdVerify() {
522
688
  // is missing or whose frontmatter is broken is skipped silently by
523
689
  // OpenCode, so half the plan -> build -> review -> finish loop can go
524
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.
525
702
  const skillsDir = join(CONFIG_DIR, "skills")
703
+ const ourSkills = kitSkillNames()
526
704
  if (!existsSync(skillsDir)) {
527
705
  notes.push("no skills directory: the skill loop is not installed. Run: oc-harness install")
528
706
  } else {
707
+ const installed = new Set(readdirSync(skillsDir))
529
708
  let valid = 0
530
- for (const name of readdirSync(skillsDir)) {
709
+ for (const name of ourSkills) {
531
710
  const skillFile = join(skillsDir, name, "SKILL.md")
532
711
  if (!existsSync(skillFile)) {
533
- 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
+ )
534
715
  continue
535
716
  }
536
717
  const front = stripFrontmatter(readFileSync(skillFile, "utf8"))
@@ -542,7 +723,19 @@ function cmdVerify() {
542
723
  valid += 1
543
724
  }
544
725
  }
545
- 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`)
546
739
  }
547
740
 
548
741
  console.log(`\n${bold("verify")} ${stamp(`profile ${p.id} -> ${CONFIG_FILE}`)}\n`)
@@ -557,6 +750,21 @@ function cmdVerify() {
557
750
  console.log(`\n ${green("install is consistent with the profile")}\n`)
558
751
  }
559
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
+
560
768
  /** Minimal `key: value` reader for agent frontmatter. */
561
769
  function stripFrontmatter(text) {
562
770
  const block = /^---\n([\s\S]*?)\n---/.exec(text)
@@ -580,6 +788,7 @@ function configDrift(p, actual) {
580
788
  const roles = resolveRoles(p)
581
789
 
582
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}`)
583
792
 
584
793
  for (const [role, level] of Object.entries(roles)) {
585
794
  if (level === undefined) continue
@@ -588,17 +797,22 @@ function configDrift(p, actual) {
588
797
  out.push(`agent.${role} missing`)
589
798
  continue
590
799
  }
591
- // A role's expected model is the one roleModels declares for it, falling
592
- // back to the base. Comparing every agent against `want` would report
593
- // drift on every split role forever, which trains the user to ignore the
594
- // one command that exists to catch a moved `build`.
595
- 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}`
596
807
  if (agent.model !== wantRole) {
597
808
  out.push(`agent.${role}.model ${agent.model} -> ${wantRole}`)
598
809
  }
599
810
  if (agent.variant !== (level ?? undefined)) {
600
811
  out.push(`agent.${role}.variant ${agent.variant ?? "(none)"} -> ${level ?? "(none)"}`)
601
812
  }
813
+ if (!agent.prompt) {
814
+ out.push(`agent.${role}.prompt missing - the lane would run without instructions`)
815
+ }
602
816
  }
603
817
 
604
818
  const wantReserved = reservedTokens(p)
@@ -620,20 +834,33 @@ function configDrift(p, actual) {
620
834
  async function cmdUse(id) {
621
835
  if (!id) fail("usage: oc-harness use <profile> [--print] [--yes]")
622
836
  const p = loadProfile(id)
623
- 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
+ })
624
847
 
625
848
  // A profile may not become the default until a human has stated its
626
849
  // retention terms. Capability metadata cannot answer this question.
627
- if (retention?.retention === "unknown" || (!retention && p.retention === "unknown")) {
850
+ if (unrecorded.length) {
628
851
  fail(
629
- `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` +
630
853
  `Add an entry with retention: zero-retention | may-train before installing it.`,
631
854
  )
632
855
  }
633
- 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}`)
634
860
  const msg =
635
- `${yellow("!")} ${p.provider}/${p.model} may train on your prompts and completions.\n` +
636
- ` ${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` +
637
864
  ` Re-run with --yes to accept.`
638
865
  if (process.stdin.isTTY && !has("--print")) {
639
866
  fail(msg + "\n")
@@ -682,11 +909,12 @@ function summary(p) {
682
909
  const splits = roleSplitModels(p)
683
910
  console.log(` ${stamp(`model ${p.provider}/${p.model}`)}`)
684
911
  console.log(` ${stamp(`effort ${resolveDefaultVariant(p) ?? "(not tunable)"}`)}`)
685
- 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(" ")}`)}`)
686
913
  if (splits.length) {
687
- // Without this the split is invisible until someone opens the file, which
688
- // is too late to be told which roles send prompts to a provider that trains.
689
- 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(" ")}`)}`)
690
918
  console.log(` ${stamp(` ${splits.map((s) => s.limits ? `${s.role} out ${s.limits.output.toLocaleString("en-US")}` : null).filter(Boolean).join(" ")}`)}`)
691
919
  }
692
920
  console.log(` ${stamp(`retention ${eff.retention}${eff.retention === "may-train" ? " <-- prompts may train a model" : ""}`)}`)
@@ -818,6 +1046,20 @@ async function cmdRefresh(id) {
818
1046
  )
819
1047
  }
820
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
+
821
1063
  /** Re-check every profile against the live provider catalog. */
822
1064
  async function cmdDoctor() {
823
1065
  const catalog = await fetchCatalog()
@@ -849,6 +1091,22 @@ async function cmdDoctor() {
849
1091
  issues.push(red("retention.yml now says may-train"))
850
1092
  }
851
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
+
852
1110
  if (issues.length === 0) {
853
1111
  console.log(` ${green("✓")} ${id.padEnd(32)} ${stamp("matches catalog")}`)
854
1112
  } else {
@@ -891,6 +1149,93 @@ async function cmdDoctor() {
891
1149
  }
892
1150
  }
893
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
+
894
1239
  /**
895
1240
  * Report agents in a project that the kit's effort tiering does not cover.
896
1241
  *
@@ -903,13 +1248,16 @@ function cmdAgents() {
903
1248
  // The global dir is where `oc-harness install` puts the kit's own agents, so
904
1249
  // scanning only the project reported "no project agents found" forever and hid
905
1250
  // the fact that none of them were installed at all.
906
- const dirs = [
907
- join(CONFIG_DIR, "agents"),
908
- join(process.cwd(), ".opencode", "agents"),
909
- join(process.cwd(), ".opencode", "agent"),
910
- ]
911
- const files = dirs.flatMap((d) =>
912
- 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
+ : [],
913
1261
  )
914
1262
 
915
1263
  // Never hardcode a profile here: the whole point is to check the one that is
@@ -926,48 +1274,74 @@ function cmdAgents() {
926
1274
  }
927
1275
 
928
1276
  const config = readConfigJson()
929
- const configured = config.model ?? "(none)"
930
1277
  const wantModel = `${current.provider}/${current.model}`
931
1278
 
932
1279
  console.log(`\n${bold("agents")} ${stamp(`tiering against ${current.id}`)}\n`)
933
- if (!files.length) {
934
- console.log(` ${red("✗")} ${yellow("no agent markdown found")}`)
935
- console.log(` ${stamp(`looked in ${dirs.join("\n ")}`)}`)
936
- console.log(` ${stamp("run: oc-harness install")}\n`)
937
- return
938
- }
939
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.
940
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
+ }
941
1291
  let untiered = 0
942
- let stale = 0
943
- for (const file of files) {
944
- const text = readFileSync(file, "utf8")
945
- const name = file.split("/").pop().replace(/\.md$/, "")
946
- const front = stripFrontmatter(text)
947
- const hasModel = Boolean(front.model)
948
- const hasVariant = Boolean(front.variant)
949
- const isKitRole = name in roles
950
-
1292
+ for (const lane of lanes) {
1293
+ const a = config.agent[lane] ?? {}
951
1294
  const notes = []
952
- if (!hasModel) notes.push("no model of its own, inherits the session model")
953
- if (front.model && front.model !== wantModel) {
954
- 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")
1301
+ }
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`)
955
1307
  }
956
- if (hasModel && !hasVariant) notes.push("no variant, runs at the model default")
1308
+ if (lane in roles && roles[lane] === undefined) notes.push("not tiered by the profile")
957
1309
 
958
1310
  if (!notes.length) {
959
- 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
+ )
960
1314
  continue
961
1315
  }
962
- untiered += !hasVariant || !hasModel ? 1 : 0
963
- stale += front.model && front.model !== wantModel ? 1 : 0
964
- console.log(` ${yellow("!")} ${name.padEnd(14)} ${yellow("not tiered")}`)
1316
+ untiered++
1317
+ console.log(` ${yellow("!")} ${lane.padEnd(14)} ${yellow("not tiered")}`)
965
1318
  for (const nn of notes) console.log(` ${nn}`)
966
1319
  }
967
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
+
968
1343
  console.log(
969
- `\n ${untiered ? yellow(`${untiered} agent(s) run at the session effort`) : green("all agents tiered")}` +
970
- `${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")}`,
971
1345
  )
972
1346
  console.log(` ${stamp("add `model:` and `variant:` to the agent frontmatter, or map them in the profile")}\n`)
973
1347
  }
@@ -1032,6 +1406,9 @@ try {
1032
1406
  case "agents":
1033
1407
  cmdAgents()
1034
1408
  break
1409
+ case "lanes":
1410
+ cmdLanes()
1411
+ break
1035
1412
  case "doctor":
1036
1413
  await cmdDoctor()
1037
1414
  break
@@ -1049,12 +1426,13 @@ try {
1049
1426
  list show profiles with retention and caveats
1050
1427
  init pick a free profile, then install + use + verify in one go
1051
1428
  use <id> compile a profile into ${CONFIG_FILE}
1052
- show <id> print the resolved effort plan and derived numbers
1429
+ show <id> print the resolved lane plan and derived numbers
1053
1430
  refresh <id> update capability fields from models.dev
1054
- 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
1055
1432
  verify check the install still matches the profile and its rules are loaded
1056
1433
  link symlink the built plugin into ~/.config/opencode/plugins
1057
1434
  agents report agents the effort tiering does not reach
1435
+ lanes report which lanes the fleet actually used
1058
1436
  doctor re-check profiles against models.dev and the installed config
1059
1437
  baseline measure the current opencode install
1060
1438
  run drive .space/tasks.md to done with the build agent