opencode-harness-kit 0.6.3 → 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.
@@ -12,10 +12,10 @@
12
12
  // The point of the kit: switching models is a data edit, not a code change.
13
13
  // Everything model-specific lives in profiles/<id>.json.
14
14
 
15
- import { chmodSync, copyFileSync, mkdirSync, readFileSync, writeFileSync, existsSync, rmSync, symlinkSync, readdirSync, readlinkSync, realpathSync, lstatSync, appendFileSync, renameSync, unlinkSync } from "node:fs"
15
+ import { chmodSync, copyFileSync, cpSync, mkdirSync, readFileSync, writeFileSync, existsSync, rmSync, symlinkSync, readdirSync, readlinkSync, realpathSync, lstatSync, appendFileSync, renameSync, unlinkSync } from "node:fs"
16
16
  import { homedir } from "node:os"
17
17
  import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path"
18
- import { execFileSync } from "node:child_process"
18
+ import { execFileSync, spawnSync } from "node:child_process"
19
19
  import { fileURLToPath } from "node:url"
20
20
  import { createInterface } from "node:readline/promises"
21
21
 
@@ -56,6 +56,84 @@ const CONFIG_DIR =
56
56
  const CONFIG_FILE = join(CONFIG_DIR, "opencode.jsonc")
57
57
  const ROOT = import.meta.dirname ? join(import.meta.dirname, "..") : process.cwd()
58
58
 
59
+ // A git CHECKOUT is the kit's own source of truth and is expected to stay
60
+ // where it is: everything the kit installs symlinks back at the repository,
61
+ // so an edit there takes effect on the next opencode start.
62
+ //
63
+ // Anything else - `npx`, a plain `npm install` - reached this file through a
64
+ // package cache npm is free to clean at any command, and a symlink INTO that
65
+ // cache is a dangling path the day `npm cache clean --force` runs. For those
66
+ // installs the package is COPIED once to a private, durable place under the
67
+ // config directory, and every install target links into the copy instead.
68
+ const CHECKOUT = existsSync(join(ROOT, ".git"))
69
+ const COPY_ROOT = join(CONFIG_DIR, "kit")
70
+
71
+ /**
72
+ * Mirror the package's runtime assets into `COPY_ROOT`.
73
+ *
74
+ * Runs at most once per version: a `.source` marker records which package
75
+ * version the copy was made from, and an install of the same version keeps
76
+ * the existing copy rather than re-wiping files freshly linked into.
77
+ *
78
+ * The copy is whole on purpose. The plugin bundle looks for its profiles and
79
+ * data by walking up from itself, the prompts and skills are linked per file
80
+ * out of it, and every asset the behavioral layer needs lives inside it -
81
+ * which is also what makes `uninstall` clean: one tree, created by one
82
+ * command, owned by nobody else.
83
+ */
84
+ function ensurePackageCopy() {
85
+ if (CHECKOUT) return
86
+ const want = JSON.parse(readFileSync(join(ROOT, "package.json"), "utf8")).version
87
+ let have = null
88
+ try {
89
+ have = readFileSync(join(COPY_ROOT, ".source"), "utf8").trim()
90
+ } catch {}
91
+ if (have === want) return
92
+ rmSync(COPY_ROOT, { recursive: true, force: true })
93
+ mkdirSync(COPY_ROOT, { recursive: true })
94
+ for (const name of ["dist", "profiles", "prompts", "skills"]) {
95
+ cpSync(join(ROOT, name), join(COPY_ROOT, name), { recursive: true })
96
+ }
97
+ for (const name of ["lanes.json", "retention.yml", "GLOBAL.md", "package.json"]) {
98
+ // `package.json` travels with the copy because `kitVersion()` in
99
+ // src/config.js reads it UP from the plugin bundle's own location
100
+ // (`../package.json` from `dist/`). Without it, every kit field the
101
+ // telemetry plugin and the CLI print from a copy install read
102
+ // "unknown" - an install that could not say which version it was.
103
+ //
104
+ // `bin/` is deliberately NOT copied: nothing inside the copy points at
105
+ // the CLI, and `npx` re-fetches the entry point when the cache has been
106
+ // cleaned. A second copy of a 176KB CLI for a path nobody in the copy
107
+ // walks is dead weight, not durability.
108
+ cpSync(join(ROOT, name), join(COPY_ROOT, name))
109
+ }
110
+ writeFileSync(join(COPY_ROOT, ".source"), `${want}\n`)
111
+ }
112
+
113
+ /**
114
+ * The root the kit installs FROM: the repository in a checkout, `COPY_ROOT`
115
+ * otherwise. Calling it at all triggers `ensurePackageCopy()`, so a caller
116
+ * holding the returned paths can rely on the files existing.
117
+ *
118
+ * @returns {string}
119
+ */
120
+ function kitSourceRoot() {
121
+ if (CHECKOUT) return ROOT
122
+ ensurePackageCopy()
123
+ return COPY_ROOT
124
+ }
125
+
126
+ /**
127
+ * The root installed files may POINT at: the repository in a checkout, the
128
+ * durable package copy otherwise. `isOurs(below)` judges ownership against
129
+ * it.
130
+ *
131
+ * @returns {string}
132
+ */
133
+ function kitTargetRoot() {
134
+ return CHECKOUT ? ROOT : COPY_ROOT
135
+ }
136
+
59
137
  const args = process.argv.slice(2)
60
138
  const cmd = args[0] ?? "list"
61
139
  const has = (f) => args.includes(f)
@@ -382,12 +460,13 @@ function cmdShow(id) {
382
460
  }
383
461
 
384
462
  /**
385
- * Link the built plugin into ~/.config/opencode/plugins so opencode loads it
386
- * without an npm install. This is the path to use while developing, and it
387
- * means the kit is usable before anything is published.
463
+ * Put the built plugin where opencode loads it from -
464
+ * ~/.config/opencode/plugins/opencode-harness-kit.js - symlinked at the kit
465
+ * itself from a checkout, at the durable package copy when the kit was
466
+ * reached through npm or npx.
388
467
  */
389
468
  function cmdLink() {
390
- const dist = join(ROOT, "dist", "index.js")
469
+ const dist = join(kitSourceRoot(), "dist", "index.js")
391
470
  if (!existsSync(dist)) {
392
471
  fail("dist/index.js missing. Run: npm run build")
393
472
  }
@@ -426,10 +505,14 @@ function cmdLink() {
426
505
 
427
506
  /** Where the behavioural layer has to land for the config to mean anything. */
428
507
  function installTargets() {
508
+ // `kitSourceRoot()` is the repository in a checkout, and the durable copy
509
+ // of the package under the config directory otherwise - an npx cache is
510
+ // npm's to clean, and links into it would dangle the day it goes.
511
+ const root = kitSourceRoot()
429
512
  return [
430
- { src: join(ROOT, "prompts"), dest: join(CONFIG_DIR, "prompts"), kind: "dir" },
431
- { src: join(ROOT, "skills"), dest: join(CONFIG_DIR, "skills"), kind: "dir" },
432
- { src: join(ROOT, "GLOBAL.md"), dest: join(CONFIG_DIR, "GLOBAL.md"), kind: "file" },
513
+ { src: join(root, "prompts"), dest: join(CONFIG_DIR, "prompts"), kind: "dir" },
514
+ { src: join(root, "skills"), dest: join(CONFIG_DIR, "skills"), kind: "dir" },
515
+ { src: join(root, "GLOBAL.md"), dest: join(CONFIG_DIR, "GLOBAL.md"), kind: "file" },
433
516
  ]
434
517
  }
435
518
 
@@ -607,7 +690,15 @@ function isLegacyAgentLink(full) {
607
690
  * prompts, the four skills, the global rules - was a `cp` line in the README
608
691
  * that nobody ran. Nothing checked it afterwards, which is how a config ended
609
692
  * up pointing at an instruction file that did not exist while still looking
610
- * correct. Symlinks rather than copies, so the repo stays the source of truth.
693
+ * correct.
694
+ *
695
+ * From a checkout everything symlinks at the repository: edit there, live
696
+ * everywhere. From npm or npx there is no repository to hold the truth - the
697
+ * cache that supplied this process can be cleaned at any moment - so the
698
+ * package is first COPIED to a private, durable place under the config
699
+ * directory (`~/.config/opencode/kit/`), and every link points in there.
700
+ * `linkOne` and the ownership checks are unchanged; only what sits at the far
701
+ * end of the link differs.
611
702
  */
612
703
  async function cmdInstall() {
613
704
  const dist = join(ROOT, "dist", "index.js")
@@ -1180,7 +1271,7 @@ function cmdVerify() {
1180
1271
  problems.push(
1181
1272
  `${kitInstruction} does not exist - the rules in it are not being loaded. Run: oc-harness install`,
1182
1273
  )
1183
- } else if (!pointsIntoKit(kitTarget)) {
1274
+ } else if (!isOurs(kitTarget)) {
1184
1275
  // The kit's own file, and not a file that happens to have its name. The
1185
1276
  // installer refuses to replace a real file, so a user who already had a
1186
1277
  // GLOBAL.md keeps it - and the check above was satisfied by its mere
@@ -1218,7 +1309,7 @@ function cmdVerify() {
1218
1309
  // The config's own reference is still checked below: the lane's prompt
1219
1310
  // path is a separate thing, and a broken one is worth saying whatever
1220
1311
  // the file at that path happens to be.
1221
- if (!pointsIntoKit(file)) {
1312
+ if (!isOurs(file)) {
1222
1313
  problems.push(
1223
1314
  `prompts/${role}.md is not the kit's file - the ${role} lane runs on somebody else's instructions. Run: oc-harness install`,
1224
1315
  )
@@ -1372,7 +1463,7 @@ function cmdVerify() {
1372
1463
  // This does not stop the frontmatter check below. A directory that is
1373
1464
  // not the kit's can still be a skill whose name has drifted away from
1374
1465
  // its directory, and that is a second thing worth saying.
1375
- if (!pointsIntoKit(join(skillsDir, name))) {
1466
+ if (!isOurs(join(skillsDir, name))) {
1376
1467
  problems.push(
1377
1468
  `skills/${name} is not the kit's skill - the ${name} step of the loop runs on somebody else's instructions. Run: oc-harness install`,
1378
1469
  )
@@ -1539,7 +1630,7 @@ async function cmdUse(id, catalog = null, sink = {}) {
1539
1630
  }
1540
1631
  fail(msg, wantJson)
1541
1632
  }
1542
- if (!id) fail("usage: oc-harness use <profile> [--print] [--yes]")
1633
+ if (!id) fail("usage: oc-harness use <profile> [--print] [--yes] [--no-skills]")
1543
1634
  let p = null
1544
1635
  try {
1545
1636
  p = loadProfile(id)
@@ -1678,7 +1769,15 @@ async function cmdUse(id, catalog = null, sink = {}) {
1678
1769
  )
1679
1770
  }
1680
1771
 
1681
- const out = generateConfig(p, { base: effectiveBase, comments: true })
1772
+ // Relative skills paths resolve against the same directory the file lives
1773
+ // in - the one OpenCode reads them against. The toolkit default assumes
1774
+ // the standard config dir; the CLI knows where it actually wrote this one.
1775
+ const out = generateConfig(p, {
1776
+ base: effectiveBase,
1777
+ comments: true,
1778
+ configDir: CONFIG_DIR,
1779
+ skills: !flag("--no-skills"),
1780
+ })
1682
1781
  if (flag("--print")) {
1683
1782
  process.stdout.write(out)
1684
1783
  return
@@ -2039,6 +2138,36 @@ function pointsIntoKit(p) {
2039
2138
  }
2040
2139
  }
2041
2140
 
2141
+ /**
2142
+ * Whether an installed path holds the kit's file.
2143
+ *
2144
+ * From a checkout that means a link into the repository itself. From npm or
2145
+ * npx the install copied the package to `COPY_ROOT` and linked everything at
2146
+ * THAT, so "ours" is a link into - or a file inside - the copy. Links are
2147
+ * resolved relative to the link and without requiring them to exist, so a
2148
+ * dangling link still counts as the kit's: the state `npm uninstall` leaves
2149
+ * behind, and `uninstall`'s job to fix.
2150
+ *
2151
+ * @param {string} p
2152
+ * @returns {boolean}
2153
+ */
2154
+ function isOurs(p) {
2155
+ if (CHECKOUT) return pointsIntoKit(p)
2156
+ let st
2157
+ try {
2158
+ st = lstatSync(p)
2159
+ } catch {
2160
+ return false
2161
+ }
2162
+ const root = resolve(COPY_ROOT)
2163
+ const under = (q) => q === root || q.startsWith(root + sep) || q.startsWith(root + "/")
2164
+ if (st.isSymbolicLink()) {
2165
+ const target = readlinkSync(p)
2166
+ return under(isAbsolute(target) ? target : resolve(dirname(p), target))
2167
+ }
2168
+ return under(resolve(p))
2169
+ }
2170
+
2042
2171
  /**
2043
2172
  * Copy the machine-derived fields of a live catalogue entry into a profile.
2044
2173
  *
@@ -2333,11 +2462,14 @@ function findProjectSpace(startDir) {
2333
2462
  * Remove what the kit installed.
2334
2463
  *
2335
2464
  * The install SYMLINKS the plugin, the prompts, the skills and GLOBAL.md out
2336
- * of the kit's directory and into the config directory. So `npm uninstall`
2337
- * the kit leaves four dangling symlinks behind, and opencode then loads
2338
- * neither the rules nor the guard - and says nothing about it.
2339
- *
2340
- * So the kit can take them back out, and it says what it removed.
2465
+ * of the kit - the repository in a checkout, the durable package copy
2466
+ * otherwise - and into the config directory. So `npm uninstall` the kit
2467
+ * leaves four dangling symlinks behind, and opencode then loads neither the
2468
+ * rules nor the guard - and says nothing about it.
2469
+ *
2470
+ * So the kit can take them back out, and it says what it removed. A copy of
2471
+ * the package under the config directory is the kit's ENTIRELY - it exists
2472
+ * only because `install` made it - so it goes with the links.
2341
2473
  */
2342
2474
  function cmdUninstall() {
2343
2475
  const removed = []
@@ -2358,14 +2490,16 @@ function cmdUninstall() {
2358
2490
  }
2359
2491
  // A symlink is not enough to make it ours. The user may have their own
2360
2492
  // symlink in that directory - to their own prompts, to a shared folder -
2361
- // and "a symlink in prompts/ is the kit's" would delete it. A link whose
2362
- // target is inside this package is the kit's; anything else is kept.
2493
+ // and "a symlink in prompts/ is the kit's" would delete it. A link that
2494
+ // is the kit's to manage goes; anything else is kept. Whose a link is is
2495
+ // `isOurs`'s decision: into the repository from a checkout, into the
2496
+ // package copy otherwise.
2363
2497
  //
2364
2498
  // The target is resolved WITHOUT requiring it to exist. A DANGLING link
2365
2499
  // is the case this command exists for - `npm uninstall` leaves four of
2366
2500
  // them behind - and `realpathSync` throws on those, which classified the
2367
2501
  // kit's own broken links as somebody else's and left them in place.
2368
- const ours = entries.filter((f) => pointsIntoKit(join(full, f)))
2502
+ const ours = entries.filter((f) => isOurs(join(full, f)))
2369
2503
  if (!ours.length) {
2370
2504
  kept.push(`${dir}/ holds real files`)
2371
2505
  continue
@@ -2388,14 +2522,14 @@ function cmdUninstall() {
2388
2522
  for (const t of targets) {
2389
2523
  if (!existsSync(t) && !isSymlink(t)) continue
2390
2524
  try {
2391
- // Only a link into this package is ours to remove. A real file the
2525
+ // Only a link the kit installed is ours to remove. A real file the
2392
2526
  // user put there is theirs, and so is a link to their own file -
2393
2527
  // `GLOBAL.md` is a standard opencode name.
2394
2528
  if (!isSymlink(t)) {
2395
2529
  kept.push(`${t} is a real file`)
2396
2530
  continue
2397
2531
  }
2398
- if (!pointsIntoKit(t)) {
2532
+ if (!isOurs(t)) {
2399
2533
  kept.push(`${t} points somewhere else`)
2400
2534
  continue
2401
2535
  }
@@ -2405,6 +2539,18 @@ function cmdUninstall() {
2405
2539
  kept.push(`${t} (could not be removed)`)
2406
2540
  }
2407
2541
  }
2542
+ // The package copy - `~/.config/opencode/kit/` when the kit was reached
2543
+ // through npm or npx - exists only because `install` created it, so it
2544
+ // goes out with the links. Unreachable from a checkout, where no copy
2545
+ // exists.
2546
+ if (!CHECKOUT && existsSync(COPY_ROOT)) {
2547
+ try {
2548
+ rmSync(COPY_ROOT, { recursive: true, force: true })
2549
+ removed.push("kit/ (the package copy this install was built from)")
2550
+ } catch {
2551
+ kept.push(`${COPY_ROOT} (could not be removed)`)
2552
+ }
2553
+ }
2408
2554
  console.log(`\n${bold("uninstall")}\n`)
2409
2555
  for (const r of removed) console.log(` ${green("✓")} removed ${r}`)
2410
2556
  for (const k of kept) console.log(` ${yellow("!")} left alone: ${k}`)
@@ -2944,8 +3090,8 @@ async function runDoctor(catalog) {
2944
3090
  }
2945
3091
  const live = catalog?.[p.provider]?.models?.[p.model]
2946
3092
  if (!live) {
2947
- console.log(` ${red("✗")} ${id.padEnd(32)} ${red("not in models.dev - may be withdrawn")}`)
2948
- note("drift", id, "not in models.dev - may be withdrawn")
3093
+ console.log(` ${red("✗")} ${id.padEnd(32)} ${red("not in models.dev - drifted from live metadata")}`)
3094
+ note("drift", id, "not in models.dev - drifted from live metadata")
2949
3095
  drift++
2950
3096
  continue
2951
3097
  }
@@ -3397,11 +3543,62 @@ async function cmdRun() {
3397
3543
  fail(`--role ${role} is not one of ${lanes.join(", ")}`)
3398
3544
  }
3399
3545
 
3546
+ // `spawnSync("opencode", ...)` in the driver reports a missing binary as a
3547
+ // failed agent, which marks the FIRST task blocked and blames the task. The
3548
+ // install is the thing that is broken, so say that here, before any task is
3549
+ // touched.
3550
+ try {
3551
+ execFileSync("opencode", ["--version"], { stdio: "ignore", timeout: 15_000 })
3552
+ } catch (err) {
3553
+ const code = err && typeof err === "object" && "code" in err ? err.code : undefined
3554
+ fail(
3555
+ code === "ENOENT"
3556
+ ? "opencode is not on PATH. Install it first: https://opencode.ai/docs"
3557
+ : "opencode --version failed. Fix the install before running the loop.",
3558
+ )
3559
+ }
3560
+
3561
+ // The files: scope check reads `git status`. Outside a work tree it cannot
3562
+ // see anything, so it used to switch itself off in silence -- an unattended
3563
+ // run believing it was fenced while it was not. The one honest place to say
3564
+ // that is here, before the first task, and the explicit override is the same
3565
+ // flag that already means "no scope check".
3566
+ const allowExtraFiles = flag("--allow-extra-files")
3567
+ if (!allowExtraFiles) {
3568
+ const git = spawnSync("git", ["rev-parse", "--is-inside-work-tree"], {
3569
+ encoding: "utf8",
3570
+ timeout: 10_000,
3571
+ })
3572
+ if (git.status !== 0 || String(git.stdout ?? "").trim() !== "true") {
3573
+ fail(
3574
+ "oc-harness run needs a git work tree, because the files: scope check reads git status. Run it inside a repository, or pass --allow-extra-files to run without the check.",
3575
+ )
3576
+ }
3577
+ }
3578
+
3579
+ // A lane's step cap is the other way an agent stops mid-task. When it is
3580
+ // reached opencode exits 0 with a text-only answer, so the loop sees a
3581
+ // failed verify and has no idea why. Reading the cap here lets the stop say
3582
+ // "it ran out of steps" instead of leaving that to the log.
3583
+ let stepCap = 0
3584
+ try {
3585
+ const cfg = existsSync(CONFIG_FILE) ? parseJsonc(readFileSync(CONFIG_FILE, "utf8")) : null
3586
+ const configured = cfg?.agent?.[role]?.steps
3587
+ if (Number.isInteger(configured) && configured > 0) stepCap = configured
3588
+ } catch {
3589
+ // An unreadable config is opencode's failure to report, and it will. A
3590
+ // label on a stop is not worth turning into a second reason to fail.
3591
+ }
3592
+
3400
3593
  const result = runLoop({
3401
3594
  cwd: process.cwd(),
3402
3595
  role,
3403
3596
  maxRounds,
3404
3597
  budgetMinutes,
3598
+ allowExtraFiles,
3599
+ forceUnlock: flag("--force-unlock"),
3600
+ review: flag("--review"),
3601
+ stepCap,
3405
3602
  })
3406
3603
 
3407
3604
  const s = result.summary
@@ -3497,6 +3694,7 @@ try {
3497
3694
  auto refresh + install + use + verify, asking nothing - the
3498
3695
  unattended reconcile, for an agent or a job
3499
3696
  use <id> compile a profile into ${CONFIG_FILE}
3697
+ --no-skills omits the skill list from the lanes' prompt
3500
3698
  show <id> print the resolved lane plan and derived numbers
3501
3699
  refresh <id> update capability fields from models.dev
3502
3700
  install symlink the plugin, prompts, skills and GLOBAL.md into the config dir
@@ -3514,7 +3712,7 @@ try {
3514
3712
  cost what the kit is doing on this project, in numbers
3515
3713
  restore <file> put one back
3516
3714
  baseline measure the current opencode install
3517
- run drive .space/tasks.md to done with the build agent
3715
+ run build and verify .space/tasks.md one task at a time
3518
3716
 
3519
3717
  options
3520
3718
  --print write the config to stdout instead of disk
@@ -3537,6 +3735,14 @@ try {
3537
3735
  --role <agent> which agent does the work (default: build)
3538
3736
  --max-rounds N stop after N tasks (default: 20)
3539
3737
  --budget MIN stop after MIN minutes (default: 120)
3738
+ --allow-extra-files let a task change files its files: line does not
3739
+ name. Without this, an out-of-scope edit stops the loop,
3740
+ and the run needs a git work tree to see the change.
3741
+ --force-unlock clear a lock held by a live pid. Only for a lock left by
3742
+ a pid that was reused; two real runs must not overlap.
3743
+ --review after verify, ask the reviewer lane for a schema'd verdict.
3744
+ The run stops on fail or on any malformed verdict. The
3745
+ verdict is written to .space/review/.
3540
3746
  `)
3541
3747
  // An unknown command is a typo, not a request for help. Without this
3542
3748
  // `oc-harness tsl` prints help and exits 0, so a script calling it never
@@ -0,0 +1,247 @@
1
+ export declare const DEFAULT_WARN_TOKENS = 2000000;
2
+ export declare const DEFAULT_STOP_TOKENS = 5000000;
3
+ export declare const DEFAULT_TURN_STOP_TOKENS = 300000;
4
+ export declare const DEFAULT_WINDOW_MINUTES = 60;
5
+ export declare const DEFAULT_WINDOW_WARN_TOKENS = 4000000;
6
+ export declare const DEFAULT_WINDOW_STOP_TOKENS = 8000000;
7
+ /**
8
+ * What one request cost, in context tokens.
9
+ *
10
+ * A `step-finish` part reports four separate numbers: `input` is the part of
11
+ * the prompt that was not cached, `cache.read` is the cached prefix the model
12
+ * still had to read, `cache.write` is the prefix stored for the next request,
13
+ * and `output`/`reasoning` are what the model wrote. The first three are the
14
+ * context; only the first was counted before, which is why a step that cost
15
+ * 37,324 tokens was recorded as 844.
16
+ *
17
+ * `cache.write` is charged once, on the request that stored it, so summing it
18
+ * here is not double counting the reads that follow. Nor does it overlap
19
+ * `input`: across 605 measured steps the reported `total` was exactly
20
+ * `input + cache.read + cache.write + output + reasoning` every time, so the
21
+ * parts are disjoint and the three that are counted are the whole context.
22
+ *
23
+ * A value that is not a number contributes zero: a missing field must not turn
24
+ * the whole count into NaN, because a NaN comparison never fires a threshold.
25
+ *
26
+ * @param {any} tokens a `step-finish` part's `tokens`
27
+ * @returns {number}
28
+ */
29
+ export declare function requestTokens(tokens: any): number;
30
+ /**
31
+ * The limits this process should use.
32
+ *
33
+ * @param {Record<string, string | undefined>} [env]
34
+ * @returns {{ warnAt: number, stopAt: number, turnStopAt: number }}
35
+ */
36
+ export declare function budgetLimits(env?: Record<string, string | undefined>): {
37
+ warnAt: number;
38
+ stopAt: number;
39
+ turnStopAt: number;
40
+ };
41
+ /**
42
+ * A running total for one session.
43
+ *
44
+ * `add` returns the thresholds this call CROSSED, so the caller can warn once
45
+ * rather than once per request. A request that lands exactly on a threshold
46
+ * crosses it, and a request of zero tokens - a retry that never reached the
47
+ * provider - moves nothing.
48
+ *
49
+ * @param {{ warnAt?: number, stopAt?: number }} [limits]
50
+ */
51
+ export declare function createBudget({ warnAt, stopAt }?: {
52
+ warnAt?: number;
53
+ stopAt?: number;
54
+ }): {
55
+ /**
56
+ * @param {any} input tokens this request cost
57
+ * @returns {{ warn: boolean, stop: boolean }}
58
+ */
59
+ add(input: any): {
60
+ warn: boolean;
61
+ stop: boolean;
62
+ };
63
+ readonly total: number;
64
+ readonly warned: boolean;
65
+ readonly stopped: boolean;
66
+ };
67
+ /**
68
+ * A count with separators, for a message a human reads.
69
+ *
70
+ * @param {number} n
71
+ * @returns {string}
72
+ */
73
+ export declare function tokens(n: number): string;
74
+ /**
75
+ * Where the shared ledger lives.
76
+ *
77
+ * `XDG_STATE_HOME` is the right place for state that survives a reboot and is
78
+ * not configuration; falling back to `~/.local/state` is what the spec says to
79
+ * do. `OC_HARNESS_BUDGET_FILE` overrides both, which is what the tests use and
80
+ * what a user on a shared account would want.
81
+ *
82
+ * @param {Record<string, string | undefined>} [env]
83
+ * @returns {string}
84
+ */
85
+ export declare function ledgerPath(env?: Record<string, string | undefined>): string;
86
+ /**
87
+ * The entries in the ledger, or none.
88
+ *
89
+ * Three answers, not two, because "I could not read the count" and "the count
90
+ * is zero" are different facts and only one of them means there is room left:
91
+ *
92
+ * - no file -> `[]`: nothing has been spent yet.
93
+ * - a file that parses -> its entries.
94
+ * - a file that does not parse -> `null`.
95
+ *
96
+ * It used to return `[]` for the third case, which made a corrupt or
97
+ * half-written ledger indistinguishable from an empty window: the shared stop
98
+ * silently disappeared. `sharedWindowSpent()` in `guard.ts` repairs the file
99
+ * instead (see `repairSpend`): a ledger that cannot be read must not lock the
100
+ * window forever, because the only way out is a hand-delete the user may not
101
+ * know about. Nothing here throws.
102
+ *
103
+ * A `stop` entry is a marker, not a spend: `{ t, n: 0, stop: true }` records
104
+ * that the window was locked at `t`, so the lock can outlive the sliding sum
105
+ * that tripped it.
106
+ *
107
+ * @param {string} path
108
+ * @returns {{ t: number, n: number, stop?: boolean }[] | null}
109
+ */
110
+ export declare function readSpend(path: string): {
111
+ t: number;
112
+ n: number;
113
+ stop?: boolean;
114
+ }[] | null;
115
+ /**
116
+ * Keep the entries that are still inside the keep window, oldest first.
117
+ *
118
+ * @param {{ t: number, n: number, stop?: boolean }[]} entries
119
+ * @param {number} now
120
+ * @returns {{ t: number, n: number, stop?: boolean }[]}
121
+ */
122
+ export declare function pruneSpend(entries: {
123
+ t: number;
124
+ n: number;
125
+ stop?: boolean;
126
+ }[], now: number): {
127
+ t: number;
128
+ n: number;
129
+ stop?: boolean;
130
+ }[];
131
+ /**
132
+ * Move an unreadable ledger aside and start a fresh one.
133
+ *
134
+ * The old code read a corrupt file as "spent" and failed closed, which is the
135
+ * right answer for a single tool call and the wrong answer for the machine:
136
+ * the only exit was deleting a file the user had never heard of, so the next
137
+ * session was blocked for six hours by a truncated JSON write. The evidence
138
+ * still matters - a corrupt ledger is a bug worth reading - so the broken file
139
+ * is renamed to `<path>.corrupt` rather than deleted, and an empty ledger
140
+ * replaces it. The count cannot be recovered, so the new file starts at zero;
141
+ * if the old file had a stop marker, `.corrupt` still holds it.
142
+ *
143
+ * Returns false when the repair could not be written; the caller then falls
144
+ * back to the old fail-closed answer rather than pretending the window is
145
+ * empty.
146
+ *
147
+ * @param {string} path
148
+ * @returns {boolean}
149
+ */
150
+ export declare function repairSpend(path: string): boolean;
151
+ /**
152
+ * Write the ledger, atomically.
153
+ *
154
+ * Read-modify-write without a lock: two sessions finishing a step in the same
155
+ * millisecond can lose one entry. That is a few thousand tokens on a budget of
156
+ * millions, and a lock file in this path is a worse failure mode - a crashed
157
+ * session would leave the window blocked for everyone.
158
+ *
159
+ * @param {string} path
160
+ * @param {{ t: number, n: number, stop?: boolean }[]} entries
161
+ */
162
+ export declare function writeSpend(path: string, entries: {
163
+ t: number;
164
+ n: number;
165
+ stop?: boolean;
166
+ }[]): void;
167
+ /**
168
+ * Add one request's cost to the shared ledger.
169
+ *
170
+ * A ledger that cannot be read is repaired first, so the first write after a
171
+ * corruption appends to a fresh file instead of overwriting the broken one.
172
+ * The repair does not invent a stop: the count is unknown, and a marker that
173
+ * holds for six hours is the failure this repair exists to end.
174
+ *
175
+ * @param {string} path
176
+ * @param {number} n
177
+ * @param {number} [now]
178
+ * @returns {{ t: number, n: number, stop?: boolean }[]} the pruned ledger
179
+ */
180
+ export declare function recordSpend(path: string, n: number, now?: number): {
181
+ t: number;
182
+ n: number;
183
+ stop?: boolean;
184
+ }[];
185
+ /**
186
+ * Record that the window was locked, so the lock survives the window it was
187
+ * measured in.
188
+ *
189
+ * The sum is over the last hour, and the provider's window is not: it stayed
190
+ * shut for hours after the stop fired. A lock that releases itself on a timer
191
+ * is a lock that was only partly on, so the marker holds until `KEEP_MS`.
192
+ *
193
+ * @param {string} path
194
+ * @param {number} [now]
195
+ * @returns {{ t: number, n: number, stop?: boolean }[]} the pruned ledger
196
+ */
197
+ export declare function recordStop(path: string, now?: number): {
198
+ t: number;
199
+ n: number;
200
+ stop?: boolean;
201
+ }[];
202
+ /**
203
+ * Whether a stop marker is still inside the keep window.
204
+ *
205
+ * @param {{ t: number, n: number, stop?: boolean }[]} entries
206
+ * @param {number} now
207
+ * @returns {boolean}
208
+ */
209
+ export declare function stopRecorded(entries: {
210
+ t: number;
211
+ n: number;
212
+ stop?: boolean;
213
+ }[], now: number): boolean;
214
+ /**
215
+ * Tokens spent in the last `windowMs`, across every session on this machine.
216
+ *
217
+ * @param {{ t: number, n: number, stop?: boolean }[]} entries
218
+ * @param {number} now
219
+ * @param {number} windowMs
220
+ * @returns {number}
221
+ */
222
+ export declare function windowSpend(entries: {
223
+ t: number;
224
+ n: number;
225
+ stop?: boolean;
226
+ }[], now: number, windowMs: number): number;
227
+ /**
228
+ * The shared-window thresholds.
229
+ *
230
+ * `OC_HARNESS_BUDGET_WINDOW_MINUTES` sets the window (default 60). Zero or a
231
+ * value that is not a positive number falls back to the default: a window of
232
+ * zero would sum nothing and silently disable the tripwire.
233
+ *
234
+ * `OC_HARNESS_BUDGET_WINDOW_STICKY` (default on) makes a stop hold until its
235
+ * marker ages out, instead of ending when the entries that tripped it roll out
236
+ * of the window. `0` restores the sum-only behaviour, which unlocks the
237
+ * window on a timer the provider does not keep.
238
+ *
239
+ * @param {Record<string, string | undefined>} [env]
240
+ * @returns {{ windowMs: number, warnAt: number, stopAt: number, sticky: boolean }}
241
+ */
242
+ export declare function windowLimits(env?: Record<string, string | undefined>): {
243
+ windowMs: number;
244
+ warnAt: number;
245
+ stopAt: number;
246
+ sticky: boolean;
247
+ };