opencode-harness-kit 0.6.4 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/effort.js CHANGED
@@ -12,6 +12,7 @@
12
12
  // `max` gets the nearest level that actually exists.
13
13
 
14
14
  import { loadProfile, resolveRoles, resolveDefaultVariant, resolveLaneModel, LANE_NAMES } from "./profile.js"
15
+ import { unhealthyModels } from "./health.js"
15
16
 
16
17
  /**
17
18
  * @typedef {import("./profile.js").Profile} Profile
@@ -39,14 +40,27 @@ import { loadProfile, resolveRoles, resolveDefaultVariant, resolveLaneModel, LAN
39
40
  export const MANAGED_ROLES = LANE_NAMES
40
41
 
41
42
  /**
43
+ * The spent routes the effort plan honours.
44
+ *
45
+ * `opts.unhealthy` is a bare-model set a caller computed already - the one
46
+ * contract the tests and the CLI use. `opts.dir` is the project root, and it
47
+ * asks the plan to read the health file itself under the profile's OWN
48
+ * provider: a health file keyed `openrouter/<x>` must not read as a spent
49
+ * `opencode/<x>`, and a bare set has already lost the provider it came from,
50
+ * so the caller cannot narrow it after the fact. Passing both is the
51
+ * caller's narrowed set, and it wins.
52
+ *
42
53
  * @param {string} profileId
43
- * @param {{ unhealthy?: Set<string> }} [opts]
54
+ * @param {{ unhealthy?: Set<string>, dir?: string }} [opts]
44
55
  * @returns {EffortResult}
45
56
  */
46
57
  export function planEffort(profileId, opts = {}) {
47
58
  const p = loadProfile(profileId)
59
+ const unhealthy =
60
+ opts.unhealthy ?? (opts.dir ? unhealthyModels(opts.dir, p.provider) : undefined)
61
+ const scoped = unhealthy === undefined ? opts : { ...opts, unhealthy }
48
62
  const levels = p.effort?.levels ?? []
49
- const roles = resolveRoles(p, opts)
63
+ const roles = resolveRoles(p, scoped)
50
64
  const notes = []
51
65
 
52
66
  if (levels.length === 0) {
@@ -75,7 +89,7 @@ export function planEffort(profileId, opts = {}) {
75
89
  /** @type {Record<string, string>} */
76
90
  const laneModels = {}
77
91
  for (const role of Object.keys(p.roles ?? {})) {
78
- laneModels[role] = resolveLaneModel(p, role, opts).model
92
+ laneModels[role] = resolveLaneModel(p, role, scoped).model
79
93
  }
80
94
 
81
95
  return {
package/src/guard.ts CHANGED
@@ -10,6 +10,20 @@ import type { Plugin } from "./plugin.js"
10
10
  import { homedir, tmpdir } from "node:os"
11
11
  import { join, resolve } from "node:path"
12
12
  import { checkBash, checkDenied, checkWrite, insideRoot } from "./rules.js"
13
+ import {
14
+ budgetLimits,
15
+ createBudget,
16
+ ledgerPath,
17
+ readSpend,
18
+ recordSpend,
19
+ recordStop,
20
+ repairSpend,
21
+ requestTokens,
22
+ stopRecorded,
23
+ tokens,
24
+ windowLimits,
25
+ windowSpend,
26
+ } from "./budget.js"
13
27
 
14
28
  import { write } from "./telemetry-store.js"
15
29
  import { projectRoot } from "./project.js"
@@ -53,6 +67,25 @@ function hunkFollows(lines: string[], from: number): boolean {
53
67
  return false
54
68
  }
55
69
 
70
+ /**
71
+ * How many lines a standard unified-diff hunk header announces.
72
+ *
73
+ * The body of a hunk is where a `---`/`+++` pair can appear as content, so
74
+ * the body has to be stepped over rather than read. `@@ -1 +1 @@` has one
75
+ * old and one new line; `@@ -1,0 +1,2 @@` has two; `@@ -1,2 +1,3 @@` has
76
+ * five; `@@` with no counts (opencode's own marker) announces none.
77
+ *
78
+ * @param {string} header
79
+ * @returns {number}
80
+ */
81
+ function hunkLineCount(header: string): number {
82
+ const m = /^@@\s+-(\d+)(?:,(\d+))?\s+\+(\d+)(?:,(\d+))?\s+@@/.exec(header)
83
+ if (!m) return 0
84
+ const oldCount = m[2] === undefined ? 1 : Number(m[2])
85
+ const newCount = m[4] === undefined ? 1 : Number(m[4])
86
+ return oldCount + newCount
87
+ }
88
+
56
89
  /**
57
90
  * The files a patch touches.
58
91
  *
@@ -61,6 +94,11 @@ function hunkFollows(lines: string[], from: number): boolean {
61
94
  * markers, and a plain unified diff with `--- a/path` and `+++ b/path`
62
95
  * headers.
63
96
  *
97
+ * An update may also carry `*** Move to: path`, which is the path the file
98
+ * is written TO. Reading only the `*** Update File:` line checked the file
99
+ * the patch read from and left the destination unchecked, so a patch could
100
+ * move a file out of the project while every path it named was inside it.
101
+ *
64
102
  * A unified diff names the file TWICE: `--- a/path` is where the change
65
103
  * comes FROM, `+++ b/path` is where it goes TO. A deletion is `--- a/path`
66
104
  * and `+++ /dev/null`, so the path that has to be scope-checked is the one
@@ -92,15 +130,48 @@ export function pathsInPatch(text: string): string[] {
92
130
  // Whether a hunk header has been seen. After one, a `+++` line is added
93
131
  // content and not the header of another file.
94
132
  let seenHunk = false
133
+ // How many body lines of the current hunk are still to be stepped over.
134
+ // Reading a hunk body as headers is what turned a removed line whose
135
+ // content is "-- foo" into a path.
136
+ let hunkRemaining = 0
137
+ // opencode's own patch format names files with `*** ... File:` markers,
138
+ // and everything between them is content. A body line in that format is
139
+ // not a diff header, and the parser must not read one as a path.
140
+ let applyPatch = false
95
141
  for (let i = 0; i < lines.length; i++) {
96
142
  const line = lines[i]
97
143
  const marker = /^\*\*\*\s+(?:Add|Update|Delete)\s+File:\s*(.+)$/.exec(line)
98
144
  if (marker) {
145
+ applyPatch = true
99
146
  push(marker[1])
100
147
  continue
101
148
  }
149
+ // The destination of an update that moves the file. It is a write just
150
+ // like the `*** Update File:` path, and the path it moves to is the one
151
+ // that can leave the project.
152
+ const move = /^\*\*\*\s+Move to:\s*(.+)$/.exec(line)
153
+ if (move) {
154
+ push(move[1])
155
+ continue
156
+ }
157
+ if (applyPatch) continue
158
+ // Step over the body of a hunk before looking for headers in it. A
159
+ // hunk body line starts with a space, `-` or `+`; `\ No newline...` is
160
+ // a note and counts for nothing.
161
+ if (hunkRemaining > 0) {
162
+ const first = line[0]
163
+ if (first === " " || first === "-" || first === "+") {
164
+ hunkRemaining--
165
+ continue
166
+ }
167
+ if (first === "\\") continue
168
+ // A line that cannot be a hunk body means the counts were wrong. Fall
169
+ // through and read this line normally rather than swallow a header.
170
+ hunkRemaining = 0
171
+ }
102
172
  if (/^@@/.test(line)) {
103
173
  seenHunk = true
174
+ hunkRemaining = hunkLineCount(line)
104
175
  continue
105
176
  }
106
177
  // The `---` and `+++` headers of a unified diff are one pair: the
@@ -167,7 +238,7 @@ function truncate(s: string, n = 200): string {
167
238
  return one.length > n ? `${one.slice(0, n)}...` : one
168
239
  }
169
240
 
170
- export const GuardPlugin: Plugin = async ({ worktree, directory }) => {
241
+ export const GuardPlugin: Plugin = async ({ worktree, directory, client }) => {
171
242
  // The project root, not the worktree: the worktree is the filesystem root
172
243
  // in a project that is not a git repository, so the scope check was against
173
244
  // "/" - which contains every path - and the denial log went nowhere.
@@ -188,9 +259,241 @@ export const GuardPlugin: Plugin = async ({ worktree, directory }) => {
188
259
  deny(why, detail)
189
260
  }
190
261
 
262
+ // The free tier is a shared window and opencode never shows a number for
263
+ // it. One session made 599 requests and 12,025,980 uncached input tokens;
264
+ // the first sign of trouble was the provider refusing the next one, which
265
+ // reads as a broken model rather than a spent budget. This counts the window
266
+ // and says so while there is still room to act.
267
+ //
268
+ // Per session, because a subagent gets its own session and its own prompt:
269
+ // charging its tokens to the parent would warn the wrong conversation.
270
+ const limits = budgetLimits()
271
+ const budgets = new Map<string, ReturnType<typeof createBudget>>()
272
+ /** `step-finish` parts already counted, so an update is not a second step. */
273
+ const counted = new Set<string>()
274
+ // A single request that re-sent more than `OC_HARNESS_TURN_STOP`. This is
275
+ // the count that sees a deny/retry loop: each retry is a new request whose
276
+ // per-session total still looks small, but whose context re-send is the
277
+ // whole prompt. Keyed by session, valued by the size that tripped it.
278
+ const turnStops = new Map<string, number>()
279
+
280
+ // The window is shared, so the count cannot live in this process: two
281
+ // sessions open at once are two processes, and the one that has to warn is
282
+ // often not the one that spent. Every step is appended to a ledger file and
283
+ // the last hour is summed from it.
284
+ const window = windowLimits()
285
+ const ledger = ledgerPath()
286
+ let windowWarned = false
287
+ let windowSpent = false
288
+ /** Why the window is spent, so the refusal states the real reason. */
289
+ let windowWhy = ""
290
+
291
+ /** The sum crossing its stop. Also the reason a recorded stop keeps holding. */
292
+ const sumWhy = () =>
293
+ `all sessions have used ${tokens(window.stopAt)} context tokens in the last ` +
294
+ `${Math.round(window.windowMs / 60_000)} min. Wait for it to roll, or raise OC_HARNESS_BUDGET_WINDOW_STOP.`
295
+
296
+ const budgetFor = (sessionID: string) => {
297
+ let b = budgets.get(sessionID)
298
+ if (!b) {
299
+ b = createBudget(limits)
300
+ budgets.set(sessionID, b)
301
+ }
302
+ return b
303
+ }
304
+
305
+ /**
306
+ * Why the shared window is spent, or null if it is not.
307
+ *
308
+ * Checked against the ledger, not only the cached flag: a session that opens
309
+ * after the window ran out has seen no step of its own, so the flag would
310
+ * still be false and its first tool call would go through.
311
+ *
312
+ * Three ways to be spent, and all three fail closed:
313
+ *
314
+ * - a stop was recorded and its marker has not aged out. The sum is over
315
+ * the last hour; the provider's window is not, so a lock that ends when
316
+ * the sum rolls over was only partly on.
317
+ * - the sum crossed the stop.
318
+ * - the ledger exists and cannot be read. That is not a window with room
319
+ * in it - it is an unknown count, and the old code read it as zero.
320
+ */
321
+ const sharedWindowSpent = (): string | null => {
322
+ if (windowSpent) return windowWhy
323
+ if (window.stopAt <= 0) return null
324
+ const entries = readSpend(ledger)
325
+ if (entries === null) {
326
+ // A corrupt ledger used to fail closed, and the only way out was a
327
+ // hand-delete the user had never heard of. Repair it instead and say so
328
+ // in telemetry; only a repair that cannot be written still fails closed.
329
+ if (repairSpend(ledger)) {
330
+ write(telemetryDir, { kind: "budget repair", tool: "ledger", rule: "corrupt ledger moved to .corrupt" })
331
+ return null
332
+ }
333
+ windowSpent = true
334
+ windowWhy =
335
+ `the ledger at ${ledger} exists but cannot be read or repaired, so the spend cannot be counted. ` +
336
+ `It is treated as spent rather than as zero: delete that file to start the window over, ` +
337
+ `or raise OC_HARNESS_BUDGET_WINDOW_STOP.`
338
+ return windowWhy
339
+ }
340
+ const now = Date.now()
341
+ if (window.sticky && stopRecorded(entries, now)) {
342
+ windowSpent = true
343
+ windowWhy =
344
+ `a stop was recorded in this ledger and holds until its entries age out (six hours). ` +
345
+ `The provider's window does not reopen on the ${Math.round(window.windowMs / 60_000)}-min timer, ` +
346
+ `so this one does not either. Raise OC_HARNESS_BUDGET_WINDOW_STOP, or set ` +
347
+ `OC_HARNESS_BUDGET_WINDOW_STICKY=0 to let the sum alone decide.`
348
+ return windowWhy
349
+ }
350
+ if (windowSpend(entries, now, window.windowMs) >= window.stopAt) {
351
+ windowSpent = true
352
+ windowWhy = sumWhy()
353
+ // Persist the lock. The sum rolls out of the one-hour window while the
354
+ // provider's window stays shut, and a session that opens after the roll
355
+ // must not get a fresh turn out of the same spent budget.
356
+ try {
357
+ recordStop(ledger)
358
+ } catch {
359
+ // An unwritable ledger still blocks this process through windowSpent.
360
+ }
361
+ return windowWhy
362
+ }
363
+ return null
364
+ }
365
+
366
+ /** A toast is a courtesy, never a reason to fail a turn. */
367
+ const toast = async (message: string, variant: "info" | "warning" | "error") => {
368
+ try {
369
+ await client?.tui?.showToast({ body: { message, variant, duration: 12_000 } })
370
+ } catch {
371
+ // A client that cannot show a toast still gets the block below.
372
+ }
373
+ }
374
+
191
375
  return {
376
+ // The tokens are reported on the `step-finish` part, once per request.
377
+ // `message.updated` was the other candidate, but its token count grows
378
+ // while the request streams and would have to be diffed per message; the
379
+ // step part is already the final cost of that request.
380
+ //
381
+ // The cost is the whole context, not `tokens.input`: with prompt caching
382
+ // the uncached part is a few hundred tokens against tens of thousands of
383
+ // `cache.read`, so counting `input` alone made every threshold here about
384
+ // five to forty times too high. See `requestTokens` in ./budget.js.
385
+ event: async ({ event }: any) => {
386
+ if (event?.type !== "message.part.updated") return
387
+ const part = event.properties?.part
388
+ if (!part || part.type !== "step-finish" || !part.sessionID) return
389
+ if (part.id) {
390
+ if (counted.has(part.id)) return
391
+ counted.add(part.id)
392
+ }
393
+ const b = budgetFor(part.sessionID)
394
+ const spent = requestTokens(part.tokens)
395
+ const crossed = b.add(spent)
396
+
397
+ // The shared window, from the ledger every session writes to.
398
+ let recent = 0
399
+ if (spent > 0) {
400
+ try {
401
+ recent = windowSpend(recordSpend(ledger, spent), Date.now(), window.windowMs)
402
+ } catch {
403
+ // An unwritable state directory must not break the session. The
404
+ // per-session budget above still applies.
405
+ }
406
+ if (!windowWarned && window.warnAt > 0 && recent >= window.warnAt) {
407
+ windowWarned = true
408
+ await toast(
409
+ `All sessions have used ${tokens(recent)} context tokens in the last ${Math.round(window.windowMs / 60_000)} min ` +
410
+ `(warn at ${tokens(window.warnAt)}). The provider's window is shared, so a new session does not reset it.`,
411
+ "warning",
412
+ )
413
+ }
414
+ if (!windowSpent && window.stopAt > 0 && recent >= window.stopAt) {
415
+ windowSpent = true
416
+ // Without this, `sharedWindowSpent()` returned the empty windowWhy
417
+ // and every tool call was allowed: the toast said "blocked" while
418
+ // the guard passed. The reason and the marker have to be set here,
419
+ // where the sum crossed.
420
+ windowWhy = sumWhy()
421
+ try {
422
+ recordStop(ledger)
423
+ } catch {
424
+ // The in-process flag still blocks this session.
425
+ }
426
+ await toast(
427
+ `The shared window hit ${tokens(window.stopAt)} context tokens in ${Math.round(window.windowMs / 60_000)} min: ` +
428
+ `tool calls are now blocked in every session. Wait for it to roll, or raise OC_HARNESS_BUDGET_WINDOW_STOP.`,
429
+ "error",
430
+ )
431
+ }
432
+ }
433
+
434
+ // The turn stop, before the total: a loop is one request re-sending the
435
+ // whole prompt, and the total may still be far below its stop. Block the
436
+ // next tool call in this session and say not to retry - a retry is what
437
+ // spent the window.
438
+ if (
439
+ limits.turnStopAt > 0 &&
440
+ spent >= limits.turnStopAt &&
441
+ !turnStops.has(part.sessionID)
442
+ ) {
443
+ turnStops.set(part.sessionID, spent)
444
+ await toast(
445
+ `One request in this session re-sent ${tokens(spent)} context tokens ` +
446
+ `(turn stop ${tokens(limits.turnStopAt)}). Tool calls in this session are now blocked: ` +
447
+ `stop, do not retry the same step. Start a new session with a smaller task.`,
448
+ "error",
449
+ )
450
+ }
451
+
452
+ if (crossed.warn) {
453
+ await toast(
454
+ `This session has used ${tokens(b.total)} context tokens (warn at ${tokens(limits.warnAt)}). ` +
455
+ `Start a new session before the provider's window runs out.`,
456
+ "warning",
457
+ )
458
+ }
459
+ if (crossed.stop) {
460
+ await toast(
461
+ `This session hit its ${tokens(limits.stopAt)}-token budget: tool calls are now blocked. ` +
462
+ `Start a new session, or raise OC_HARNESS_BUDGET_STOP.`,
463
+ "error",
464
+ )
465
+ }
466
+ },
192
467
  "tool.execute.before": async (input: any, output: any) => {
193
468
  try {
469
+ const budget = input.sessionID ? budgets.get(input.sessionID) : undefined
470
+ if (budget?.stopped) {
471
+ refuse(
472
+ input.tool,
473
+ "session token budget",
474
+ "the session hit its token budget",
475
+ `it has used ${tokens(budget.total)} context tokens, and the stop threshold is ${tokens(limits.stopAt)}. ` +
476
+ `Start a new session, or raise OC_HARNESS_BUDGET_STOP.`,
477
+ )
478
+ }
479
+ // A new session does not reset the provider's window, so the shared
480
+ // stop has to hold across sessions - otherwise the fix for a spent
481
+ // session is "open another one", which spends the same window again.
482
+ const spentReason = sharedWindowSpent()
483
+ if (spentReason) {
484
+ refuse(input.tool, "shared token window", "the shared token window is spent", spentReason)
485
+ }
486
+ const turnSpend = input.sessionID ? turnStops.get(input.sessionID) : undefined
487
+ if (turnSpend !== undefined) {
488
+ refuse(
489
+ input.tool,
490
+ "single request budget",
491
+ "a single request in this session re-sent too much context",
492
+ `it re-sent ${tokens(turnSpend)} tokens against a turn stop of ${tokens(limits.turnStopAt)}. ` +
493
+ `Stop: do not retry the same step, because every retry re-sends the same prompt. ` +
494
+ `Start a new session with a smaller task, or raise OC_HARNESS_TURN_STOP.`,
495
+ )
496
+ }
194
497
  if (input.tool === "bash") {
195
498
  // The bash tool takes a `workdir`, and it becomes the command's
196
499
  // working directory. A command run from outside the project is
package/src/health.js CHANGED
@@ -236,16 +236,28 @@ export function unhealthyRoutes(dir) {
236
236
  * lane chains list bare model ids, so the prefix is dropped here. Comparing
237
237
  * the two directly would never match, and a spent route would look healthy.
238
238
  *
239
+ * The drop collides when two providers host the same model id: a recorded
240
+ * `openrouter/<x>` then reads as a spent `opencode/<x>`, and every lane whose
241
+ * chain names the opencode one is pushed off a route that is fine. `provider`
242
+ * narrows the answer to the routes a session of THAT provider can actually
243
+ * hit - a provider-qualified key for another provider is dropped, and a key
244
+ * with no provider at all is kept, because a bare key is a standing statement
245
+ * about the model id and it is not readable as any narrower one. Callers
246
+ * including the router, whose lane set may not share one provider, keep the
247
+ * unfiltered call and its old behaviour.
248
+ *
239
249
  * A route whose window has passed is not spent any more, and is not returned
240
250
  * here: the provider's quota comes back at 00:00 UTC, and a lane that stays
241
251
  * on its fallback forever is a lane that never comes back either.
242
252
  *
243
253
  * @param {string} dir the project root
254
+ * @param {string} [provider] only count routes served by this provider
244
255
  * @returns {Set<string>}
245
256
  */
246
- export function unhealthyModels(dir) {
257
+ export function unhealthyModels(dir, provider) {
247
258
  const out = new Set()
248
259
  for (const key of Object.keys(unhealthyRoutes(dir))) {
260
+ if (provider && key.includes("/") && !key.startsWith(`${provider}/`)) continue
249
261
  out.add(key.includes("/") ? key.slice(key.indexOf("/") + 1) : key)
250
262
  }
251
263
  return out
package/src/index.ts CHANGED
@@ -20,7 +20,6 @@ import { TelemetryPlugin } from "./telemetry.js"
20
20
  import { applyEffort, planEffort } from "./effort.js"
21
21
  import { profileIdFromConfig } from "./profile.js"
22
22
  import { projectRoot } from "./project.js"
23
- import { unhealthyModels } from "./health.js"
24
23
 
25
24
  export interface HarnessOptions {
26
25
  /** Profile id. Defaults to the one recorded in the config by `oc-harness use`. */
@@ -69,9 +68,9 @@ export const HarnessPlugin: Plugin = async (ctx, options?: HarnessOptions) => {
69
68
  // file the router writes is somewhere else entirely.
70
69
  const project = projectRoot(ctx.directory, ctx.worktree)
71
70
  try {
72
- // The same set of spent routes the router used. A lane that failed over
73
- // to a model with fewer effort levels must not keep the variant the
74
- // previous model had.
71
+ // The same set of spent routes the router used, read HERE under the
72
+ // profile's own provider: a health file keyed `openrouter/<x>` must not
73
+ // read as a spent `opencode/<x>` for a profile served by opencode.
75
74
  //
76
75
  // The session model is read before the effort plan is applied, because
77
76
  // the router may have moved it onto a fallback while the base route was
@@ -80,16 +79,28 @@ export const HarnessPlugin: Plugin = async (ctx, options?: HarnessOptions) => {
80
79
  typeof config?.harness?.sessionModel === "string"
81
80
  ? config.harness.sessionModel
82
81
  : undefined
83
- applyEffort(config, planEffort(id, { unhealthy: unhealthyModels(project) }), {
82
+ applyEffort(config, planEffort(id, { dir: project }), {
84
83
  sessionModel,
85
84
  })
86
- // Record it where the prune hook looks, so pruning picks up this
87
- // profile's thresholds without access to the resolved config.
88
- //
89
- // Under the PROJECT root, not the worktree: the worktree is the
90
- // filesystem root in a project that is not a git repository, and the
91
- // write fails there with a permission error that this catch swallows.
92
- // The pruner then finds no active profile and does nothing at all.
85
+ } catch {
86
+ // Never block startup on a bad profile.
87
+ }
88
+ // Record it where the prune hook looks, so pruning picks up this
89
+ // profile's thresholds without access to the resolved config.
90
+ //
91
+ // Under the PROJECT root, not the worktree: the worktree is the
92
+ // filesystem root in a project that is not a git repository, and the
93
+ // write fails there with a permission error that this catch swallows.
94
+ // The pruner then finds no active profile and does nothing at all.
95
+ //
96
+ // This used to sit inside the same try as applyEffort, and an applyEffort
97
+ // throw skipped it: a config that could not accept the effort plan (an
98
+ // agent dictionary OpenCode froze, a model line it rejects) left the
99
+ // marker stale, and the pruner ran on the PREVIOUS profile's thresholds
100
+ // until the next clean startup. The marker is therefore written
101
+ // independently - it is a statement about which profile the config names,
102
+ // not a consequence of whether the effort plan applied.
103
+ try {
93
104
  const dir = join(project, ".space")
94
105
  mkdirSync(dir, { recursive: true })
95
106
  writeFileSync(join(dir, "active"), `${id}\n`)
package/src/profile.js CHANGED
@@ -57,7 +57,7 @@ export const ROOT = findRoot(resolve(HERE, ".."))
57
57
  * @typedef {"zero-retention" | "retained" | "may-train" | "unknown"} Retention
58
58
  * @typedef {string | null} EffortRequest
59
59
  * @typedef {{ model: string, levels?: string[], chain?: string[] }} RoleModel
60
- * @typedef {{ description: string, mode?: string, steps?: number, permission?: Record<string, any>, prompt: string }} Lane
60
+ * @typedef {{ description: string, mode?: string, steps?: number, permission?: Record<string, any>, prompt: string, skills?: boolean }} Lane
61
61
  * @typedef {object} Profile
62
62
  * @property {string} id
63
63
  * @property {string} [title]
package/src/prune.ts CHANGED
@@ -21,6 +21,7 @@ import { loadProfile, pruneThreshold, ROOT } from "./profile.js"
21
21
  import { projectRoot } from "./project.js"
22
22
  import { readFileSync, existsSync } from "node:fs"
23
23
  import { join } from "node:path"
24
+ import { rotate as rotateFile, MAX_BYTES } from "./telemetry-store.js"
24
25
 
25
26
  /**
26
27
  * Which profile is active. In order: an explicit environment variable, then the
@@ -486,13 +487,26 @@ export const PrunePlugin: Plugin = async ({ worktree, directory }) => {
486
487
  }
487
488
  }
488
489
 
489
- async function logPrune(
490
+ /**
491
+ * Append one line to the project's prune log, and rotate it when the file
492
+ * outgrew `maxBytes`.
493
+ *
494
+ * Exported so the rotation can be driven directly in the tests: reaching it
495
+ * through the hook needs a 4MB fixture, which is not a fixture.
496
+ *
497
+ * @param {string} worktree the project the log belongs to
498
+ * @param {string} profileId the profile the prune ran under
499
+ * @param {StubStats & { saved: number, threshold: number }} stats
500
+ * @param {number} [maxBytes] rotate above this; the telemetry default
501
+ */
502
+ export async function logPrune(
490
503
  worktree: string,
491
504
  profileId: string,
492
505
  stats: StubStats & {
493
506
  saved: number
494
507
  threshold: number
495
508
  },
509
+ maxBytes: number = MAX_BYTES,
496
510
  ) {
497
511
  // The WORKTREE, not `process.cwd()`. Every other writer in this kit uses
498
512
  // the worktree, and the plugin's worktree is the project. The process cwd
@@ -504,6 +518,12 @@ async function logPrune(
504
518
  try {
505
519
  const { mkdir, appendFile } = await import("node:fs/promises")
506
520
  await mkdir(dir, { recursive: true })
521
+ // Rotated on the same scheme as the telemetry log: the file records one
522
+ // line per prune run, nothing samples its SIZE before appending, and an
523
+ // active project filled it to 1.5 megabytes in a day. The telemetry store
524
+ // already owns the three-generation rotation; the prune log borrows it
525
+ // rather than naming its own file schedule.
526
+ rotateFile(join(dir, "prune.jsonl"), maxBytes)
507
527
  await appendFile(
508
528
  join(dir, "prune.jsonl"),
509
529
  JSON.stringify({