opencode-harness-kit 0.5.4 → 0.5.6

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/src/config.js CHANGED
@@ -158,6 +158,11 @@ export function utilityModel(p) {
158
158
  * `instructions` array used to replace it wholesale, which unloaded the
159
159
  * whole behavioural layer while everything still looked correct.
160
160
  *
161
+ * A SINGLE string is a list of one. The parameter has always been typed
162
+ * `string[] | string | undefined`, but a string was dropped on the floor:
163
+ * the file that came back named only the kit's GLOBAL.md, so the user's own
164
+ * instructions were unloaded without a word.
165
+ *
161
166
  * The user's own files keep their order and come first; the kit's is
162
167
  * appended. Duplicates are dropped, so re-running `use` does not grow the
163
168
  * list.
@@ -167,7 +172,12 @@ export function utilityModel(p) {
167
172
  * @returns {string[]}
168
173
  */
169
174
  export function mergeInstructions(base, kitPath) {
170
- const list = Array.isArray(base) ? base.filter((x) => typeof x === "string") : []
175
+ const given = Array.isArray(base)
176
+ ? base
177
+ : typeof base === "string"
178
+ ? [base]
179
+ : []
180
+ const list = given.filter((x) => typeof x === "string")
171
181
  /** @type {string[]} */
172
182
  const out = []
173
183
  for (const entry of [...list, kitPath]) {
@@ -235,6 +245,75 @@ export const BASH_ALLOWLIST = [
235
245
  */
236
246
  export { BASH_DENYLIST } from "./denylist.js"
237
247
 
248
+ /**
249
+ * The user's own bash rules, as a map the kit can merge into its own.
250
+ *
251
+ * The value has to be one of the three verbs opencode's schema defines. A
252
+ * rule the kit cannot express cannot work anyway, and writing it out
253
+ * unchanged would make the whole config invalid - which is a worse outcome
254
+ * than a rule the kit drops.
255
+ *
256
+ * A rule the kit drops is NOT dropped silently: the pattern and the verb it
257
+ * carried are returned in `dropped`, and the header of the generated file
258
+ * names them. The rule was the user's, and a file that comes back looking
259
+ * exactly as it did before is how a lost rule stays lost.
260
+ *
261
+ * `permission.bash` may also be a SINGLE verb, and a single verb is the
262
+ * blanket rule - every command that no other rule names. It used to be
263
+ * dropped on the floor with the rest of the non-object values, so a user
264
+ * who had `bash: "allow"` lost the blanket allow and got the kit's map
265
+ * instead.
266
+ *
267
+ * @param {any} base the config as it is on disk
268
+ * @returns {{ rules: Record<string, string>, dropped: string[] }}
269
+ */
270
+ function userBashRules(base) {
271
+ const own = base?.permission?.bash
272
+ /** @type {Record<string, string>} */
273
+ const rules = {}
274
+ /** @type {string[]} */
275
+ const dropped = []
276
+
277
+ /**
278
+ * Keep a rule the kit can write, or name the one it cannot.
279
+ *
280
+ * @param {string} pattern
281
+ * @param {any} verb
282
+ */
283
+ const keep = (pattern, verb) => {
284
+ if (verb === "allow" || verb === "ask" || verb === "deny") {
285
+ rules[pattern] = verb
286
+ return
287
+ }
288
+ const said = typeof verb === "string" ? JSON.stringify(verb) : String(verb)
289
+ dropped.push(`permission.bash "${pattern}" -> ${said}`)
290
+ }
291
+
292
+ if (own === undefined || own === null) return { rules, dropped }
293
+
294
+ if (typeof own === "string") {
295
+ keep("*", own)
296
+ return { rules, dropped }
297
+ }
298
+
299
+ // Anything else that is not a map of rules cannot be merged, and it is
300
+ // named so the loss is not silent either.
301
+ if (typeof own !== "object" || Array.isArray(own)) {
302
+ dropped.push(
303
+ `permission.bash is ${Array.isArray(own) ? "a list" : String(own)}, which is not a rule the kit can write`,
304
+ )
305
+ return { rules, dropped }
306
+ }
307
+ for (const [pattern, verb] of Object.entries(own)) {
308
+ if (typeof pattern !== "string" || !pattern) {
309
+ dropped.push(`permission.bash has a rule with no pattern, which was dropped`)
310
+ continue
311
+ }
312
+ keep(pattern, verb)
313
+ }
314
+ return { rules, dropped }
315
+ }
316
+
238
317
  /**
239
318
  * @param {Profile} p
240
319
  * @param {{ base?: Record<string, any>, comments?: boolean, unhealthy?: Set<string> }} [opts]
@@ -294,9 +373,34 @@ export function generateConfig(p, opts = {}) {
294
373
  agent[role] = existing
295
374
  }
296
375
 
376
+ // The user's own bash rules are merged in, not replaced.
377
+ //
378
+ // The map used to be built from the kit's two lists alone, so every rule
379
+ // the user had written was deleted on the next `oc-harness use` - and the
380
+ // file that came back looked exactly as it had before, so nothing said
381
+ // it. The rules that let somebody run their own tools without a prompt
382
+ // were the ones that vanished.
383
+ //
384
+ // The order is the precedence ladder, and it is the kit's denylist that
385
+ // closes it:
386
+ //
387
+ // kit allowlist -> the user's rules -> kit denylist
388
+ //
389
+ // The denylist is last because a deny a user rule could re-allow would
390
+ // not be a deny, and the denylist is the mechanical half of the safety
391
+ // policy. The user's rules sit after the allowlist so a rule of their own
392
+ // is not shadowed by one of the kit's.
393
+ //
394
+ // The kit's own catch-all stays the FIRST key, so a user rule for `*`
395
+ // replaces its value and not its position: the catch-all remains the
396
+ // fallback for the commands no other rule mentions, and the allowlist
397
+ // still decides the ones it names. opencode takes the last matching rule,
398
+ // so this order is the whole rule.
399
+ const own = userBashRules(base)
297
400
  const bashRules = {
298
401
  "*": "ask",
299
402
  ...Object.fromEntries(BASH_ALLOWLIST.map((rule) => [rule, "allow"])),
403
+ ...own.rules,
300
404
  ...Object.fromEntries(BASH_DENYLIST.map((rule) => [rule, "deny"])),
301
405
  }
302
406
 
@@ -423,13 +527,19 @@ export function generateConfig(p, opts = {}) {
423
527
  },
424
528
  }
425
529
 
426
- return render(out, p, { comments: opts.comments ?? true, instructionsPath, unhealthy })
530
+ return render(out, p, {
531
+ comments: opts.comments ?? true,
532
+ instructionsPath,
533
+ unhealthy,
534
+ dropped: own.dropped,
535
+ })
427
536
  }
428
537
 
429
538
  /**
430
539
  * @param {Record<string, any>} cfg
431
540
  * @param {Profile} p
432
- * @param {{ comments: boolean, instructionsPath?: string, unhealthy?: Set<string> }} opts
541
+ * @param {{ comments: boolean, instructionsPath?: string, unhealthy?: Set<string>,
542
+ * dropped?: string[] }} opts
433
543
  * @returns {string}
434
544
  */
435
545
  function render(cfg, p, opts) {
@@ -481,6 +591,19 @@ function render(cfg, p, opts) {
481
591
  `${c}ceiling ${p.maxRetention} - a lane may only resolve to a model at or below this`,
482
592
  ]
483
593
  : []),
594
+ // The rules the kit could not carry over. They are named HERE, in the
595
+ // file the user reads, because a rule that is dropped without a word is
596
+ // a rule that is lost for good: the file comes back looking exactly as
597
+ // it did before, and the prompt it used to allow is back.
598
+ ...(opts.dropped?.length
599
+ ? [
600
+ `${c}`,
601
+ `${c}Dropped from your own permission.bash rules, and NOT in the file below.`,
602
+ `${c}The verb has to be one of allow, ask or deny - a rule the kit cannot`,
603
+ `${c}write would fail the schema for the whole file:`,
604
+ ...opts.dropped.map((d) => `${c} ${d}`),
605
+ ]
606
+ : []),
484
607
  `${c}`,
485
608
  `${c}Derived numbers, do not hand-edit:`,
486
609
  `${c} compaction.reserved ${reservedTokens(p, { unhealthy }).toLocaleString("en-US")} (${Math.round((p.effort?.reserveShare ?? 0.12) * 100)}% of the narrowest input cap in play, held back for reasoning growth)`,
package/src/denylist.js CHANGED
@@ -19,10 +19,49 @@ export const BASH_DENYLIST = [
19
19
  // so opencode prompts for human approval before publishing.
20
20
  "rm -rf *",
21
21
  "rm -fr *",
22
+ // The short forms of the same flags, one at a time.
23
+ //
24
+ // The guard's rule is a TOKEN scanner: it blocks an rm whose short flag
25
+ // cluster contains r or f in any case, and an rm whose long flag is
26
+ // --recursive or --force. So `rm -f file` and `rm -r dir` are blocked
27
+ // there - and the permission map has to deny what the guard blocks, or
28
+ // the two layers contradict each other: the map would ask about a
29
+ // command the guard refuses.
30
+ //
31
+ // `-R` is named too because the guard's rule is case-insensitive and
32
+ // GNU rm reads `-R` as `--recursive`.
33
+ //
34
+ // The two layers are not the same matcher. The guard reads the tokens
35
+ // and can see a destructive flag in any position; this list is a flat
36
+ // glob over the command, so it can only name the spellings.
37
+ //
38
+ // A cluster a glob cannot express (`-rfv`) is caught by the guard and
39
+ // asked about by the config - the config asks about everything it does
40
+ // not name, and asking is the safe direction.
41
+ //
42
+ // A PERMUTED delete (`rm /tmp/x -rf`) is caught by the GUARD, which reads
43
+ // the tokens and does not care which order they come in. The glob cannot
44
+ // name it - a glob here could only name one spelling of it, and the
45
+ // spelling would still be the one it missed. So the config asks about it,
46
+ // and a human decides, and the code-level block is the guard's.
47
+ "rm -f *",
48
+ "rm -r *",
49
+ "rm -R *",
50
+ // The long GNU names of the same two flags. A delete that is spelled
51
+ // `--recursive --force` is the delete the short forms name.
52
+ "rm --recursive *",
53
+ "rm --force *",
22
54
  "curl * | sh",
23
55
  "curl * | bash",
56
+ // The same pipe into a shell that is named by its path. The shell is the
57
+ // same shell either way, and the config can only match what it can see.
58
+ "curl * | */sh",
59
+ "curl * | */bash",
24
60
  "chmod 777 *",
25
61
  "chmod 777*",
62
+ // The same mode written with a leading zero.
63
+ "chmod 0777 *",
64
+ "chmod 0777*",
26
65
  "gh pr create*",
27
66
  "gh pr merge*",
28
67
  "gh pr close*",
@@ -52,6 +91,10 @@ export const BASH_DENYLIST = [
52
91
  *
53
92
  * `*` matches any run of characters, including none.
54
93
  *
94
+ * Every other metacharacter is literal. `?` is a regex quantifier, so an
95
+ * unescaped one made the character before it optional and the pattern
96
+ * matched a command it never named.
97
+ *
55
98
  * @param {string} command
56
99
  * @param {string} pattern
57
100
  * @returns {boolean}
@@ -59,7 +102,7 @@ export const BASH_DENYLIST = [
59
102
  export function matchesPattern(command, pattern) {
60
103
  const re = new RegExp(
61
104
  "^" +
62
- pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, "[\\s\\S]*") +
105
+ pattern.replace(/[.+^${}()|[\]\\?]/g, "\\$&").replace(/\*/g, "[\\s\\S]*") +
63
106
  "$",
64
107
  )
65
108
  return re.test(command)
@@ -78,7 +121,7 @@ export function matchesPattern(command, pattern) {
78
121
  * same list and one of them reads it more carefully; that is the price of
79
122
  * having the list in two places.
80
123
  *
81
- * The two PIPE patterns are for the config's side only. `segments()` splits
124
+ * The PIPE patterns are for the config's side only. `segments()` splits
82
125
  * on `|`, so a segment never contains " | sh" and the guard catches a piped
83
126
  * script through its own rule instead.
84
127
  *
package/src/guard.ts CHANGED
@@ -31,12 +31,50 @@ export const MARKER = "[opencode-harness-kit] blocked"
31
31
  */
32
32
  const WRITE_TOOLS = new Set(["write", "edit", "apply_patch", "patch"])
33
33
 
34
+ /**
35
+ * Whether a hunk header follows, before another file's headers do.
36
+ *
37
+ * A `---`/`+++` pair is the header pair of a file only when a hunk header
38
+ * (`@@`) comes after it. That is the difference between the headers of a
39
+ * diff and two body lines that happen to look like one, and it is the same
40
+ * thing a reader of the diff does.
41
+ *
42
+ * @param {string[]} lines
43
+ * @param {number} from the line to start looking at
44
+ * @returns {boolean}
45
+ */
46
+ function hunkFollows(lines: string[], from: number): boolean {
47
+ for (let i = from; i < lines.length; i++) {
48
+ if (/^@@/.test(lines[i])) return true
49
+ // Another pair of headers starts here, so this pair was not followed by
50
+ // a hunk of its own.
51
+ if (/^(?:---|\+\+\+)\s/.test(lines[i])) return false
52
+ }
53
+ return false
54
+ }
55
+
34
56
  /**
35
57
  * The files a patch touches.
36
58
  *
37
59
  * `apply_patch` sends a patch, not a path, so the paths have to be read out
38
60
  * of it. Two formats are in use: opencode's own `*** Update File: path`
39
- * markers, and a plain unified diff with `+++ b/path` headers.
61
+ * markers, and a plain unified diff with `--- a/path` and `+++ b/path`
62
+ * headers.
63
+ *
64
+ * A unified diff names the file TWICE: `--- a/path` is where the change
65
+ * comes FROM, `+++ b/path` is where it goes TO. A deletion is `--- a/path`
66
+ * and `+++ /dev/null`, so the path that has to be scope-checked is the one
67
+ * in the `---` header and nowhere else. Reading only the `+++` header meant
68
+ * a patch that deleted a file outside the project was checked against
69
+ * nothing at all - and a patch that deletes a file is exactly the shape
70
+ * that reaches outside the project.
71
+ *
72
+ * The headers of a diff come BEFORE its first hunk, and a body line can
73
+ * look like a header: a removed line whose content is "-- foo" renders as
74
+ * "--- foo", and an added line "++ bar" renders as "+++ bar". Reading
75
+ * those as the headers of a file collected two words as paths, and a path
76
+ * that is not a path is a write the guard refuses for a reason nobody can
77
+ * find.
40
78
  *
41
79
  * @param {string} text
42
80
  * @returns {string[]}
@@ -44,16 +82,49 @@ const WRITE_TOOLS = new Set(["write", "edit", "apply_patch", "patch"])
44
82
  export function pathsInPatch(text: string): string[] {
45
83
  const out: string[] = []
46
84
  const push = (p: string) => {
47
- const v = p.trim()
85
+ // A unified diff header can end in a timestamp: `--- a/file\t2026-01-01`.
86
+ const v = p.split("\t")[0].trim()
48
87
  if (!v) return
49
88
  if (v === "/dev/null") return
50
89
  if (!out.includes(v)) out.push(v)
51
90
  }
52
- for (const m of text.matchAll(/^\*\*\*\s+(?:Add|Update|Delete)\s+File:\s*(.+)$/gm)) {
53
- push(m[1])
54
- }
55
- for (const m of text.matchAll(/^\+\+\+\s+(?:b\/)?(.+)$/gm)) {
56
- push(m[1])
91
+ const lines = text.split("\n")
92
+ // Whether a hunk header has been seen. After one, a `+++` line is added
93
+ // content and not the header of another file.
94
+ let seenHunk = false
95
+ for (let i = 0; i < lines.length; i++) {
96
+ const line = lines[i]
97
+ const marker = /^\*\*\*\s+(?:Add|Update|Delete)\s+File:\s*(.+)$/.exec(line)
98
+ if (marker) {
99
+ push(marker[1])
100
+ continue
101
+ }
102
+ if (/^@@/.test(line)) {
103
+ seenHunk = true
104
+ continue
105
+ }
106
+ // The `---` and `+++` headers of a unified diff are one pair: the
107
+ // first names where the change comes FROM, the second where it goes
108
+ // TO. They are read together so a body line that looks like a header
109
+ // is not mistaken for one.
110
+ const minus = /^---\s+(?:a\/)?(.+)$/.exec(line)
111
+ const next = i + 1 < lines.length ? /^\+\+\+\s+(?:b\/)?(.+)$/.exec(lines[i + 1]) : null
112
+ if (minus && next) {
113
+ // A pair is a header pair only when a hunk header follows it.
114
+ if (hunkFollows(lines, i + 2)) {
115
+ push(minus[1])
116
+ push(next[1])
117
+ }
118
+ // The `+++` line is consumed either way: it is the other half of the
119
+ // pair, and not a header of its own.
120
+ i++
121
+ continue
122
+ }
123
+ // A diff that carries only the `+++` header still names its file. It
124
+ // only names one while no hunk has been seen - after a hunk, a `+++`
125
+ // line is added content.
126
+ const plus = /^\+\+\+\s+(?:b\/)?(.+)$/.exec(line)
127
+ if (plus && !seenHunk) push(plus[1])
57
128
  }
58
129
  return out
59
130
  }
package/src/health.js CHANGED
@@ -15,12 +15,96 @@
15
15
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"
16
16
  import { join } from "node:path"
17
17
 
18
+ /**
19
+ * @typedef {object} SpentEntry
20
+ * @property {string} at when the route was recorded as spent
21
+ * @property {string} reason the pattern that matched
22
+ */
23
+
18
24
  /**
19
25
  * @typedef {object} Health
20
- * @property {Record<string, { at: string, reason: string }>} unhealthy
26
+ * @property {Record<string, SpentEntry>} unhealthy
21
27
  * @property {string} [active]
22
28
  */
23
29
 
30
+ /**
31
+ * How long a recorded spent route is honoured for, by default.
32
+ *
33
+ * The provider's free window resets at 00:00 UTC, so a route recorded as
34
+ * spent is expected to come back within a day. Without an expiry the file is
35
+ * a one-way ratchet: a fleet that hit one rate limit never uses that model
36
+ * again, its lanes drain onto the last rung of their chains, and the only
37
+ * way back is a human editing a JSON file by hand.
38
+ *
39
+ * 24h is the default because the provider's window is a day. The window
40
+ * belongs to the provider rather than to the kit, so it is configurable:
41
+ * HARNESS_SPENT_WINDOW_HOURS overrides it.
42
+ */
43
+ const DEFAULT_WINDOW_MS = 24 * 60 * 60 * 1000
44
+
45
+ /**
46
+ * The window, in milliseconds, a recorded spent route is honoured for.
47
+ *
48
+ * Read at call time rather than at import time, so a caller that changes it
49
+ * does not have to reload the module to see the effect.
50
+ *
51
+ * @returns {number}
52
+ */
53
+ export function spentWindowMs() {
54
+ const raw = process.env.HARNESS_SPENT_WINDOW_HOURS
55
+ if (!raw) return DEFAULT_WINDOW_MS
56
+ const hours = Number(raw)
57
+ // A window of zero would expire every route the moment it was written, and
58
+ // a negative one would expire routes that were never spent. Neither is a
59
+ // setting anybody meant, so the default stands.
60
+ if (!Number.isFinite(hours) || hours <= 0) return DEFAULT_WINDOW_MS
61
+ return hours * 60 * 60 * 1000
62
+ }
63
+
64
+ /**
65
+ * Whether a recorded route is still spent.
66
+ *
67
+ * An entry with no usable timestamp is KEPT. The router always stamps one,
68
+ * so an entry without one was written by a hand, and honouring a standing
69
+ * statement is safer than putting a route that may be dead back into a
70
+ * fleet. An entry that cannot be shown to be stale is not dropped.
71
+ *
72
+ * @param {unknown} entry
73
+ * @param {number} [now]
74
+ * @returns {boolean}
75
+ */
76
+ export function isSpent(entry, now = Date.now()) {
77
+ if (!entry || typeof entry !== "object" || Array.isArray(entry)) return false
78
+ const at = /** @type {any} */ (entry).at
79
+ if (typeof at !== "string") return true
80
+ const when = Date.parse(at)
81
+ if (Number.isNaN(when)) return true
82
+ return now - when <= spentWindowMs()
83
+ }
84
+
85
+ /**
86
+ * The spent routes that are still spent.
87
+ *
88
+ * Every reader of the file goes through here, so the router and the effort
89
+ * hook cannot disagree about which routes are down. A reader that looked at
90
+ * the raw file would see a route that came back an hour ago as spent, and
91
+ * would fail the fleet over onto a model that works.
92
+ *
93
+ * @param {Health} health
94
+ * @param {number} [now]
95
+ * @returns {Record<string, SpentEntry>}
96
+ */
97
+ export function liveUnhealthy(health, now = Date.now()) {
98
+ /** @type {Record<string, SpentEntry>} */
99
+ const out = {}
100
+ const source = health?.unhealthy
101
+ if (!source || typeof source !== "object" || Array.isArray(source)) return out
102
+ for (const [key, entry] of Object.entries(source)) {
103
+ if (isSpent(entry, now)) out[key] = entry
104
+ }
105
+ return out
106
+ }
107
+
24
108
  /**
25
109
  * @param {string} dir the project root
26
110
  * @returns {string}
@@ -30,31 +114,69 @@ export function healthPath(dir) {
30
114
  }
31
115
 
32
116
  /**
117
+ * The health file, or an empty one.
118
+ *
119
+ * A file that is not an object - null, an array, a number, a string - is not
120
+ * a health file, and reading it as one made `unhealthyModels` throw on a
121
+ * null. That call sits inside a bare catch in src/index.ts, so the throw
122
+ * silently skipped the effort plan AND the write of `.space/active` that the
123
+ * pruner depends on: a corrupt file switched the pruner off without a word.
124
+ *
33
125
  * @param {string} dir the project root
34
126
  * @returns {Health}
35
127
  */
36
128
  export function readHealth(dir) {
129
+ /** @type {any} */
130
+ let parsed
37
131
  try {
38
- return JSON.parse(readFileSync(healthPath(dir), "utf8"))
132
+ parsed = JSON.parse(readFileSync(healthPath(dir), "utf8"))
39
133
  } catch {
40
134
  return { unhealthy: {} }
41
135
  }
136
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
137
+ return { unhealthy: {} }
138
+ }
139
+ const unhealthy = parsed.unhealthy
140
+ if (!unhealthy || typeof unhealthy !== "object" || Array.isArray(unhealthy)) {
141
+ // The other keys are kept: `active` names the profile the fleet is
142
+ // running, and dropping it because the routes are malformed would lose
143
+ // the pruner's target as well.
144
+ return { ...parsed, unhealthy: {} }
145
+ }
146
+ return parsed
42
147
  }
43
148
 
44
149
  /**
150
+ * Write the health file, without the routes whose window has passed.
151
+ *
152
+ * The expiry is applied on the way out as well as on the way in, so a file
153
+ * that is read and written again - which the router does on every event -
154
+ * does not carry its dead routes forever.
155
+ *
45
156
  * @param {string} dir the project root
46
157
  * @param {Health} health
47
158
  */
48
159
  export function writeHealth(dir, health) {
49
160
  try {
50
161
  if (!existsSync(join(dir, ".space"))) mkdirSync(join(dir, ".space"), { recursive: true })
51
- writeFileSync(healthPath(dir), JSON.stringify(health, null, 2))
162
+ const pruned = { ...(health ?? {}), unhealthy: liveUnhealthy(health) }
163
+ writeFileSync(healthPath(dir), JSON.stringify(pruned, null, 2))
52
164
  } catch {
53
165
  // Best effort. A health file that cannot be written means no failover,
54
166
  // and failing to write it must not break the session.
55
167
  }
56
168
  }
57
169
 
170
+ /**
171
+ * The routes on disk that are still spent.
172
+ *
173
+ * @param {string} dir the project root
174
+ * @returns {Record<string, SpentEntry>}
175
+ */
176
+ export function unhealthyRoutes(dir) {
177
+ return liveUnhealthy(readHealth(dir))
178
+ }
179
+
58
180
  /**
59
181
  * The models a previous session found spent.
60
182
  *
@@ -67,13 +189,16 @@ export function writeHealth(dir, health) {
67
189
  * lane chains list bare model ids, so the prefix is dropped here. Comparing
68
190
  * the two directly would never match, and a spent route would look healthy.
69
191
  *
192
+ * A route whose window has passed is not spent any more, and is not returned
193
+ * here: the provider's quota comes back at 00:00 UTC, and a lane that stays
194
+ * on its fallback forever is a lane that never comes back either.
195
+ *
70
196
  * @param {string} dir the project root
71
197
  * @returns {Set<string>}
72
198
  */
73
199
  export function unhealthyModels(dir) {
74
- const health = readHealth(dir)
75
200
  const out = new Set()
76
- for (const key of Object.keys(health.unhealthy ?? {})) {
201
+ for (const key of Object.keys(unhealthyRoutes(dir))) {
77
202
  out.add(key.includes("/") ? key.slice(key.indexOf("/") + 1) : key)
78
203
  }
79
204
  return out
package/src/lane-usage.js CHANGED
@@ -34,10 +34,11 @@ import { dirname, join, resolve } from "node:path"
34
34
  * The telemetry files for the project containing `startDir`.
35
35
  *
36
36
  * ALL of them, oldest first. The plugin rotates the file when it passes 4 MB
37
- * and renames the old one to `telemetry.jsonl.1` - and the reader used to
38
- * look at `telemetry.jsonl` alone, so a rotation silently threw away the
39
- * history and every lane reported "never used". The number that decides
40
- * whether the fleet stays at ten lanes was disappearing on a schedule.
37
+ * and keeps THREE generations - `telemetry.jsonl`, `telemetry.jsonl.1` and
38
+ * `telemetry.jsonl.2` - and the reader used to look at `telemetry.jsonl`
39
+ * alone, so a rotation silently threw away the history and every lane
40
+ * reported "never used". The number that decides whether the fleet stays at
41
+ * ten lanes was disappearing on a schedule.
41
42
  *
42
43
  * @param {string} startDir
43
44
  * @returns {string[]} the paths, or an empty array when the project has none
@@ -62,13 +63,19 @@ export function findTelemetryFiles(startDir) {
62
63
  }
63
64
 
64
65
  /**
65
- * The telemetry file for the project containing `startDir`.
66
+ * The CURRENT telemetry file for the project containing `startDir`.
67
+ *
68
+ * The current one, not the oldest one. The files are enumerated oldest
69
+ * first, so the current file is the LAST of them - and taking the first
70
+ * handed back the file that had been rotated away twice, which is the
71
+ * history rather than the present.
66
72
  *
67
73
  * @param {string} startDir
68
74
  * @returns {string | null} the path, or null when the project has no telemetry
69
75
  */
70
76
  export function findTelemetryFile(startDir) {
71
- return findTelemetryFiles(startDir)[0] ?? null
77
+ const files = findTelemetryFiles(startDir)
78
+ return files.length ? files[files.length - 1] : null
72
79
  }
73
80
 
74
81
  /**