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/CHANGELOG.md +249 -1
- package/README.md +222 -5
- package/bin/oc-harness.mjs +85 -7
- 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/prompts/reviewer.md +24 -0
- 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
|
@@ -15,7 +15,7 @@
|
|
|
15
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
|
|
|
@@ -94,7 +94,17 @@ function ensurePackageCopy() {
|
|
|
94
94
|
for (const name of ["dist", "profiles", "prompts", "skills"]) {
|
|
95
95
|
cpSync(join(ROOT, name), join(COPY_ROOT, name), { recursive: true })
|
|
96
96
|
}
|
|
97
|
-
for (const name of ["lanes.json", "retention.yml", "GLOBAL.md"]) {
|
|
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.
|
|
98
108
|
cpSync(join(ROOT, name), join(COPY_ROOT, name))
|
|
99
109
|
}
|
|
100
110
|
writeFileSync(join(COPY_ROOT, ".source"), `${want}\n`)
|
|
@@ -1620,7 +1630,7 @@ async function cmdUse(id, catalog = null, sink = {}) {
|
|
|
1620
1630
|
}
|
|
1621
1631
|
fail(msg, wantJson)
|
|
1622
1632
|
}
|
|
1623
|
-
if (!id) fail("usage: oc-harness use <profile> [--print] [--yes]")
|
|
1633
|
+
if (!id) fail("usage: oc-harness use <profile> [--print] [--yes] [--no-skills]")
|
|
1624
1634
|
let p = null
|
|
1625
1635
|
try {
|
|
1626
1636
|
p = loadProfile(id)
|
|
@@ -1759,7 +1769,15 @@ async function cmdUse(id, catalog = null, sink = {}) {
|
|
|
1759
1769
|
)
|
|
1760
1770
|
}
|
|
1761
1771
|
|
|
1762
|
-
|
|
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
|
+
})
|
|
1763
1781
|
if (flag("--print")) {
|
|
1764
1782
|
process.stdout.write(out)
|
|
1765
1783
|
return
|
|
@@ -3072,8 +3090,8 @@ async function runDoctor(catalog) {
|
|
|
3072
3090
|
}
|
|
3073
3091
|
const live = catalog?.[p.provider]?.models?.[p.model]
|
|
3074
3092
|
if (!live) {
|
|
3075
|
-
console.log(` ${red("✗")} ${id.padEnd(32)} ${red("not in models.dev -
|
|
3076
|
-
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")
|
|
3077
3095
|
drift++
|
|
3078
3096
|
continue
|
|
3079
3097
|
}
|
|
@@ -3525,11 +3543,62 @@ async function cmdRun() {
|
|
|
3525
3543
|
fail(`--role ${role} is not one of ${lanes.join(", ")}`)
|
|
3526
3544
|
}
|
|
3527
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
|
+
|
|
3528
3593
|
const result = runLoop({
|
|
3529
3594
|
cwd: process.cwd(),
|
|
3530
3595
|
role,
|
|
3531
3596
|
maxRounds,
|
|
3532
3597
|
budgetMinutes,
|
|
3598
|
+
allowExtraFiles,
|
|
3599
|
+
forceUnlock: flag("--force-unlock"),
|
|
3600
|
+
review: flag("--review"),
|
|
3601
|
+
stepCap,
|
|
3533
3602
|
})
|
|
3534
3603
|
|
|
3535
3604
|
const s = result.summary
|
|
@@ -3625,6 +3694,7 @@ try {
|
|
|
3625
3694
|
auto refresh + install + use + verify, asking nothing - the
|
|
3626
3695
|
unattended reconcile, for an agent or a job
|
|
3627
3696
|
use <id> compile a profile into ${CONFIG_FILE}
|
|
3697
|
+
--no-skills omits the skill list from the lanes' prompt
|
|
3628
3698
|
show <id> print the resolved lane plan and derived numbers
|
|
3629
3699
|
refresh <id> update capability fields from models.dev
|
|
3630
3700
|
install symlink the plugin, prompts, skills and GLOBAL.md into the config dir
|
|
@@ -3642,7 +3712,7 @@ try {
|
|
|
3642
3712
|
cost what the kit is doing on this project, in numbers
|
|
3643
3713
|
restore <file> put one back
|
|
3644
3714
|
baseline measure the current opencode install
|
|
3645
|
-
run
|
|
3715
|
+
run build and verify .space/tasks.md one task at a time
|
|
3646
3716
|
|
|
3647
3717
|
options
|
|
3648
3718
|
--print write the config to stdout instead of disk
|
|
@@ -3665,6 +3735,14 @@ try {
|
|
|
3665
3735
|
--role <agent> which agent does the work (default: build)
|
|
3666
3736
|
--max-rounds N stop after N tasks (default: 20)
|
|
3667
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/.
|
|
3668
3746
|
`)
|
|
3669
3747
|
// An unknown command is a typo, not a request for help. Without this
|
|
3670
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
|
+
};
|
package/dist/config.d.ts
CHANGED
|
@@ -27,6 +27,35 @@ export declare const GLOBAL_INSTRUCTIONS = "~/.config/opencode/GLOBAL.md";
|
|
|
27
27
|
* @returns {string}
|
|
28
28
|
*/
|
|
29
29
|
export declare function kitVersion(): string;
|
|
30
|
+
/** Where opencode's user config lives when nothing overrides it. */
|
|
31
|
+
export declare const DEFAULT_CONFIG_DIR: string;
|
|
32
|
+
/** Where this kit itself lives - the parent of the bundle (or src) directory. */
|
|
33
|
+
export declare function kitRoot(): string;
|
|
34
|
+
/**
|
|
35
|
+
* The skills.paths entries of a base config, with the dead ones dropped.
|
|
36
|
+
*
|
|
37
|
+
* A `paths` entry that never resolves is not a configuration - it is noise
|
|
38
|
+
* the file carries for every reader to puzzle over, and a reader who DOES
|
|
39
|
+
* puzzle over it gets a wrong model of the world: this kit's config shipped
|
|
40
|
+
* `["../skills"]` which had dl resolved to `~/.config/skills` - a directory
|
|
41
|
+
* that has never existed on this machine - for release after release.
|
|
42
|
+
*
|
|
43
|
+
* A relative entry resolves against the opencode config directory; an
|
|
44
|
+
* absolute one stands as written; a relative one is also checked against the
|
|
45
|
+
* kit root, because an entry pointing INTO the kit was always a plausible
|
|
46
|
+
* intent and the kit knows where its own skills are. Nothing that resolves
|
|
47
|
+
* is kept; word everything else is dropped and named in the header, the same
|
|
48
|
+
* way dropped bash rules and removed lanes are.
|
|
49
|
+
*
|
|
50
|
+
* @param {any} skills
|
|
51
|
+
* @param {string} configDir
|
|
52
|
+
* @param {string} [kitRoot] where the kit itself lives, when overridable
|
|
53
|
+
* @returns {{ skills: any, dropped: string[] }}
|
|
54
|
+
*/
|
|
55
|
+
export declare function cleanSkillPaths(skills: any, configDir: string, kitRoot?: string): {
|
|
56
|
+
skills: any;
|
|
57
|
+
dropped: string[];
|
|
58
|
+
};
|
|
30
59
|
/**
|
|
31
60
|
* The memory plugin this kit can turn on.
|
|
32
61
|
*
|
|
@@ -122,11 +151,15 @@ export declare const BASH_ALLOWLIST: string[];
|
|
|
122
151
|
export { BASH_DENYLIST } from "./denylist.js";
|
|
123
152
|
/**
|
|
124
153
|
* @param {Profile} p
|
|
125
|
-
* @param {{ base?: Record<string, any>, comments?: boolean, unhealthy?: Set<string
|
|
154
|
+
* @param {{ base?: Record<string, any>, comments?: boolean, unhealthy?: Set<string>,
|
|
155
|
+
* configDir?: string, kitRoot?: string, skills?: boolean }} [opts]
|
|
126
156
|
* @returns {string}
|
|
127
157
|
*/
|
|
128
158
|
export declare function generateConfig(p: Profile, opts?: {
|
|
129
159
|
base?: Record<string, any>;
|
|
130
160
|
comments?: boolean;
|
|
131
161
|
unhealthy?: Set<string>;
|
|
162
|
+
configDir?: string;
|
|
163
|
+
kitRoot?: string;
|
|
164
|
+
skills?: boolean;
|
|
132
165
|
}): string;
|
package/dist/effort.d.ts
CHANGED
|
@@ -35,12 +35,23 @@ export type EffortResult = {
|
|
|
35
35
|
*/
|
|
36
36
|
export declare const MANAGED_ROLES: string[];
|
|
37
37
|
/**
|
|
38
|
+
* The spent routes the effort plan honours.
|
|
39
|
+
*
|
|
40
|
+
* `opts.unhealthy` is a bare-model set a caller computed already - the one
|
|
41
|
+
* contract the tests and the CLI use. `opts.dir` is the project root, and it
|
|
42
|
+
* asks the plan to read the health file itself under the profile's OWN
|
|
43
|
+
* provider: a health file keyed `openrouter/<x>` must not read as a spent
|
|
44
|
+
* `opencode/<x>`, and a bare set has already lost the provider it came from,
|
|
45
|
+
* so the caller cannot narrow it after the fact. Passing both is the
|
|
46
|
+
* caller's narrowed set, and it wins.
|
|
47
|
+
*
|
|
38
48
|
* @param {string} profileId
|
|
39
|
-
* @param {{ unhealthy?: Set<string
|
|
49
|
+
* @param {{ unhealthy?: Set<string>, dir?: string }} [opts]
|
|
40
50
|
* @returns {EffortResult}
|
|
41
51
|
*/
|
|
42
52
|
export declare function planEffort(profileId: string, opts?: {
|
|
43
53
|
unhealthy?: Set<string>;
|
|
54
|
+
dir?: string;
|
|
44
55
|
}): EffortResult;
|
|
45
56
|
/**
|
|
46
57
|
* Mutate the resolved OpenCode config so each managed agent declares its own
|
package/dist/guard.d.ts
CHANGED
|
@@ -8,6 +8,11 @@ export declare const MARKER = "[opencode-harness-kit] blocked";
|
|
|
8
8
|
* markers, and a plain unified diff with `--- a/path` and `+++ b/path`
|
|
9
9
|
* headers.
|
|
10
10
|
*
|
|
11
|
+
* An update may also carry `*** Move to: path`, which is the path the file
|
|
12
|
+
* is written TO. Reading only the `*** Update File:` line checked the file
|
|
13
|
+
* the patch read from and left the destination unchecked, so a patch could
|
|
14
|
+
* move a file out of the project while every path it named was inside it.
|
|
15
|
+
*
|
|
11
16
|
* A unified diff names the file TWICE: `--- a/path` is where the change
|
|
12
17
|
* comes FROM, `+++ b/path` is where it goes TO. A deletion is `--- a/path`
|
|
13
18
|
* and `+++ /dev/null`, so the path that has to be scope-checked is the one
|
package/dist/health.d.ts
CHANGED
|
@@ -110,11 +110,22 @@ export declare function unhealthyRoutes(dir: string): Record<string, SpentEntry>
|
|
|
110
110
|
* lane chains list bare model ids, so the prefix is dropped here. Comparing
|
|
111
111
|
* the two directly would never match, and a spent route would look healthy.
|
|
112
112
|
*
|
|
113
|
+
* The drop collides when two providers host the same model id: a recorded
|
|
114
|
+
* `openrouter/<x>` then reads as a spent `opencode/<x>`, and every lane whose
|
|
115
|
+
* chain names the opencode one is pushed off a route that is fine. `provider`
|
|
116
|
+
* narrows the answer to the routes a session of THAT provider can actually
|
|
117
|
+
* hit - a provider-qualified key for another provider is dropped, and a key
|
|
118
|
+
* with no provider at all is kept, because a bare key is a standing statement
|
|
119
|
+
* about the model id and it is not readable as any narrower one. Callers
|
|
120
|
+
* including the router, whose lane set may not share one provider, keep the
|
|
121
|
+
* unfiltered call and its old behaviour.
|
|
122
|
+
*
|
|
113
123
|
* A route whose window has passed is not spent any more, and is not returned
|
|
114
124
|
* here: the provider's quota comes back at 00:00 UTC, and a lane that stays
|
|
115
125
|
* on its fallback forever is a lane that never comes back either.
|
|
116
126
|
*
|
|
117
127
|
* @param {string} dir the project root
|
|
128
|
+
* @param {string} [provider] only count routes served by this provider
|
|
118
129
|
* @returns {Set<string>}
|
|
119
130
|
*/
|
|
120
|
-
export declare function unhealthyModels(dir: string): Set<string>;
|
|
131
|
+
export declare function unhealthyModels(dir: string, provider?: string): Set<string>;
|