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.
- package/CHANGELOG.md +303 -10
- package/README.md +244 -14
- package/bin/oc-harness.mjs +234 -28
- package/dist/budget.d.ts +247 -0
- package/dist/config.d.ts +34 -1
- package/dist/effort.d.ts +12 -1
- package/dist/guard.d.ts +5 -0
- package/dist/health.d.ts +12 -1
- package/dist/index.js +556 -149
- package/dist/profile.d.ts +2 -1
- package/dist/prune.d.ts +22 -0
- package/dist/rules.d.ts +6 -2
- package/docs/EXIT-CODES.md +18 -4
- package/docs/REVIEW-VERDICT.md +73 -0
- package/eval/RESULTS.md +130 -7
- package/eval/ab.mjs +591 -68
- package/eval/compare.mjs +267 -0
- package/eval/verify.mjs +9 -0
- package/lanes.json +18 -9
- package/package.json +6 -2
- package/profiles/index.json +1 -12
- package/prompts/reviewer.md +24 -0
- package/retention.yml +0 -8
- package/src/budget.js +418 -0
- package/src/config.js +172 -6
- package/src/driver/run.mjs +780 -42
- package/src/effort.js +17 -3
- package/src/guard.ts +304 -1
- package/src/health.js +13 -1
- package/src/index.ts +23 -12
- package/src/profile.js +1 -1
- package/src/prune.ts +21 -1
- package/src/rules.js +145 -0
- package/src/tasks.js +31 -0
package/bin/oc-harness.mjs
CHANGED
|
@@ -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
|
-
*
|
|
386
|
-
*
|
|
387
|
-
*
|
|
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(
|
|
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(
|
|
431
|
-
{ src: join(
|
|
432
|
-
{ src: join(
|
|
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.
|
|
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 (!
|
|
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 (!
|
|
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 (!
|
|
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
|
-
|
|
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
|
|
2337
|
-
*
|
|
2338
|
-
*
|
|
2339
|
-
*
|
|
2340
|
-
*
|
|
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
|
|
2362
|
-
//
|
|
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) =>
|
|
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
|
|
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 (!
|
|
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 -
|
|
2948
|
-
note("drift", id, "not in models.dev -
|
|
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
|
|
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
|
package/dist/budget.d.ts
ADDED
|
@@ -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
|
+
};
|