opencode-harness-kit 0.6.2 → 0.6.4
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 +125 -9
- package/README.md +22 -25
- package/bin/oc-harness.mjs +150 -22
- package/package.json +1 -1
- package/profiles/index.json +1 -12
- package/retention.yml +0 -8
package/CHANGELOG.md
CHANGED
|
@@ -12,6 +12,118 @@ is not happening.
|
|
|
12
12
|
|
|
13
13
|
---
|
|
14
14
|
|
|
15
|
+
## [0.6.4] — 2026-10-23
|
|
16
|
+
|
|
17
|
+
Fixed, each one found by re-reading what 0.6.3 published, not by running
|
|
18
|
+
anything new:
|
|
19
|
+
|
|
20
|
+
- **The install command in the README named a package that does not exist.**
|
|
21
|
+
The Install section said `npx oc-harness init` "from the registry", and
|
|
22
|
+
`oc-harness` is this kit's binary name, not a package anyone can fetch: the
|
|
23
|
+
command every new reader was pointed at returns `npm error 404 'oc-harness'
|
|
24
|
+
is not in this registry`. Every install path in the README now reads
|
|
25
|
+
`npx -y opencode-harness-kit@latest <cmd>`, bare `oc-harness` is defined
|
|
26
|
+
once as shorthand for one of the two real forms, and a test pins every
|
|
27
|
+
`npx` reference in the README to this package. Released because the npm
|
|
28
|
+
page is a snapshot: the README on npmjs.com renders from the published
|
|
29
|
+
tarball, so fixing it in git changes nothing for the next visitor.
|
|
30
|
+
- **`profiles/index.json` listed `flint-40b-free` twice**, and the README's
|
|
31
|
+
shipped-profiles table repeated it with a second, differently-worded
|
|
32
|
+
note. `oc-harness list` printed twelve rows, `oc-harness init` offered
|
|
33
|
+
twelve choices, flint twice in both, and the README counted twelve
|
|
34
|
+
shipped profiles. Eleven profiles ship; one entry remains, carrying the
|
|
35
|
+
honest note of both. Tests now diff the index, the table and the loader,
|
|
36
|
+
with no Set to collapse the difference.
|
|
37
|
+
- **`retention.yml` repeated `opencode/flint-40b-free`, and the last key
|
|
38
|
+
won.** The hand-rolled parser overwrites `models[id]` on every repeat, so
|
|
39
|
+
the vague 2026-10-22 stub silently replaced the 2026-10-23 record that
|
|
40
|
+
quotes the provider's terms verbatim - same retention class, wrong date,
|
|
41
|
+
lost detail. A test now scans the file for repeated keys.
|
|
42
|
+
- **`install` from npx symlinked into the npx cache.** The installer always
|
|
43
|
+
symlinked the plugin, prompts, skills and GLOBAL.md back to the package
|
|
44
|
+
root - right from a git checkout ("edit here, live everywhere"), visible
|
|
45
|
+
breakage from an npx cache that `npm cache clean --force` deletes. From a
|
|
46
|
+
checkout nothing changes; from npx or any npm install, where `.git` is
|
|
47
|
+
absent, `install` now copies, so the harness on disk does not dangle the
|
|
48
|
+
day the cache is cleaned. `verify` and `uninstall` learn that a
|
|
49
|
+
byte-identical copy of the kit's file is the kit's, and a test installs
|
|
50
|
+
from a copy of the package with no `.git` into a temp HOME, asserts real
|
|
51
|
+
files where links used to go, and runs `verify` green.
|
|
52
|
+
|
|
53
|
+
No runtime change: `dist/`, the plugin and every model-facing artifact are
|
|
54
|
+
byte-for-byte what 0.6.3 shipped.
|
|
55
|
+
|
|
56
|
+
## [0.6.3] — 2026-10-23
|
|
57
|
+
|
|
58
|
+
Nothing here changes the runtime. The plugin, the CLI and `dist/` are
|
|
59
|
+
byte-for-byte what 0.6.2 shipped; this release is the front page and the
|
|
60
|
+
packaging metadata catching up with decisions already made.
|
|
61
|
+
|
|
62
|
+
### Removed
|
|
63
|
+
|
|
64
|
+
- **The demo media is gone from the repository.** `docs/demo/pitch.mp4`
|
|
65
|
+
(12.5 MB) and `docs/demo-loop.gif` are deleted, and the README no longer
|
|
66
|
+
embeds them. The recording lives on in the private
|
|
67
|
+
[`pham-canh/video`](https://github.com/pham-canh/video) repo. Two reasons
|
|
68
|
+
it had to leave this one: the `.mp4` was never shipped by npm (`files`
|
|
69
|
+
covers `docs/*.md` only), so the README rendered on npmjs.com carried a
|
|
70
|
+
`<video>` tag whose source could not exist — a 404 embed on the public
|
|
71
|
+
page; and a repo whose pitch is "the metadata is the truth" was weighting
|
|
72
|
+
its own checkout with twelve megabytes nobody could watch.
|
|
73
|
+
|
|
74
|
+
### Fixed
|
|
75
|
+
|
|
76
|
+
- **The lockfile answered for a version two releases behind.**
|
|
77
|
+
`package-lock.json` still said `0.5.8` while `package.json` said `0.6.2`,
|
|
78
|
+
and carried a `peerDependencies` block the manifest no longer has. Both are
|
|
79
|
+
synced now, by hand rather than a regenerating `npm install`, so the locked
|
|
80
|
+
dependency versions are untouched.
|
|
81
|
+
|
|
82
|
+
## [0.6.2] — 2026-10-23
|
|
83
|
+
|
|
84
|
+
Published. Tag `v0.6.2`.
|
|
85
|
+
|
|
86
|
+
### Fixed
|
|
87
|
+
|
|
88
|
+
- **Transient catalogue errors stopped benching live routes.** When
|
|
89
|
+
`oc-harness doctor` or the router asked models.dev and the request failed
|
|
90
|
+
for a reason other than "this model is withdrawn" — a timeout, a rate
|
|
91
|
+
limit, a network blip — the failure was percolating into the route health
|
|
92
|
+
as if the model itself were dead, and a live route got benched on evidence
|
|
93
|
+
that was about the network, not the model. The distinction now matters:
|
|
94
|
+
transient errors are retried and reported as transient; only a confirmed
|
|
95
|
+
`status: "deprecated"` out of the catalogue marks a route unhealthy.
|
|
96
|
+
|
|
97
|
+
## [0.6.1] — 2026-10-23
|
|
98
|
+
|
|
99
|
+
Published. Tag `v0.6.1`.
|
|
100
|
+
|
|
101
|
+
### Added
|
|
102
|
+
|
|
103
|
+
- **A `flint-40b-free` profile, published with its retention verdict.** A
|
|
104
|
+
same-size free model beside the fleet's usual rungs, and the tenth free
|
|
105
|
+
model in the default fleet needed a lane to run in, so the build assigns
|
|
106
|
+
it.
|
|
107
|
+
|
|
108
|
+
### Fixed
|
|
109
|
+
|
|
110
|
+
- **The fleet no longer routes through a withdrawn model.**
|
|
111
|
+
`minimax-m3.1-flash-free` was marked deprecated in the catalogue while the
|
|
112
|
+
default fleet still carried it in a lane; `flint-40b-free` takes its place
|
|
113
|
+
in the default chains.
|
|
114
|
+
|
|
115
|
+
- **`use` and `install` write their files atomically.** The config and the
|
|
116
|
+
health file are now written to a sibling temp file and renamed, so a
|
|
117
|
+
`install` interrupted mid-write can no longer leave a half-written config
|
|
118
|
+
behind. (A failure found its way through this when the code called
|
|
119
|
+
`renameSync` it had never imported; the import is there now.)
|
|
120
|
+
|
|
121
|
+
- **A publish job re-run against an already-published tag is green**, not a
|
|
122
|
+
red crash on E409.
|
|
123
|
+
|
|
124
|
+
- **eval's `padEnd` did not coerce its arguments to strings**, and died on a
|
|
125
|
+
numeric task id.
|
|
126
|
+
|
|
15
127
|
## [0.6.0] — 2026-10-22
|
|
16
128
|
|
|
17
129
|
Published. The tag is what cut it: `git tag v0.6.0 && git push origin
|
|
@@ -139,15 +251,19 @@ default fleet was carrying a dead fallback rung in **all ten lanes** while
|
|
|
139
251
|
release, and it is out of the repository now.
|
|
140
252
|
|
|
141
253
|
A take is about one machine and one moment, and so is the recording of it.
|
|
142
|
-
The `.gitignore`
|
|
143
|
-
that made it until somebody decides, deliberately, to publish
|
|
144
|
-
decision went the other way, so the file went back where
|
|
145
|
-
it — `video/build/`, which is ignored — and the README no
|
|
146
|
-
video the repository does not carry.
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
254
|
+
The `.gitignore` of that release said the same thing: "a recording stays on
|
|
255
|
+
the machine that made it until somebody decides, deliberately, to publish
|
|
256
|
+
it". The decision went the other way then, so the file went back where
|
|
257
|
+
that rule keeps it — `video/build/`, which is ignored — and the README no
|
|
258
|
+
longer links a video the repository does not carry. (Neither the rule nor
|
|
259
|
+
the recording survived 0.6.3, which removed the rule along with the
|
|
260
|
+
demonstration.)
|
|
261
|
+
|
|
262
|
+
At the time, what was published on GitHub was still the old
|
|
263
|
+
`docs/demo/pitch.mp4`: four minutes forty-seven seconds, recorded live
|
|
264
|
+
from a Mac terminal, with a voice. 0.6.3 removed it, and the history
|
|
265
|
+
rewrite of that release means no tag carries it today. The npm package
|
|
266
|
+
never carried a video at all.
|
|
151
267
|
|
|
152
268
|
## [0.5.8] — 2026-10-22
|
|
153
269
|
|
package/README.md
CHANGED
|
@@ -3,18 +3,6 @@
|
|
|
3
3
|
[](https://github.com/pham-canh/opencode-harness-kit/actions/workflows/ci.yml)
|
|
4
4
|
[](https://www.npmjs.com/package/opencode-harness-kit)
|
|
5
5
|
|
|
6
|
-
<p align="center">
|
|
7
|
-
<video src="docs/demo/pitch.mp4" width="720" controls></video>
|
|
8
|
-
</p>
|
|
9
|
-
|
|
10
|
-
*Four minutes forty-seven seconds, recorded live from a real terminal and
|
|
11
|
-
narrated — every command on screen is typed and executed while the camera
|
|
12
|
-
runs. The voiceover is in [`video/`](video/), along with what it took to
|
|
13
|
-
record it and the two mistakes that cost the most time.*
|
|
14
|
-
|
|
15
|
-
*It was recorded against an earlier version of the kit: the numbers it names
|
|
16
|
-
are the ones from then, and the ones below are the ones now.*
|
|
17
|
-
|
|
18
6
|
A harness kit for [OpenCode](https://opencode.ai) that is **model-agnostic by
|
|
19
7
|
construction**. Switching models is a data edit, not a code change.
|
|
20
8
|
|
|
@@ -22,10 +10,6 @@ construction**. Switching models is a data edit, not a code change.
|
|
|
22
10
|
npx -y opencode-harness-kit init # pick a free model, then install + verify
|
|
23
11
|
```
|
|
24
12
|
|
|
25
|
-
<p align="center">
|
|
26
|
-
<img src="docs/demo-loop.gif" width="760" alt="oc-harness list showing the model-agnostic profiles, then oc-harness verify reporting a consistent install">
|
|
27
|
-
</p>
|
|
28
|
-
|
|
29
13
|
then restart opencode. That is the whole quickstart. No npm account needed on
|
|
30
14
|
your side — `npx` only downloads the published kit. Prefer git? The same three
|
|
31
15
|
steps from a checkout (see [Install](#install)):
|
|
@@ -36,7 +20,8 @@ git clone https://github.com/pham-canh/opencode-harness-kit.git
|
|
|
36
20
|
```
|
|
37
21
|
|
|
38
22
|
The three steps `init` runs, spelled out for when you want to know what
|
|
39
|
-
happened (prefix with `npx
|
|
23
|
+
happened (prefix each with `npx -y opencode-harness-kit@latest` or
|
|
24
|
+
`./bin/oc-harness.mjs` to taste):
|
|
40
25
|
|
|
41
26
|
```bash
|
|
42
27
|
oc-harness use fleet-safe # compile the profile
|
|
@@ -64,7 +49,7 @@ lanes.json ← the jobs
|
|
|
64
49
|
src/ ← no model name anywhere in here
|
|
65
50
|
```
|
|
66
51
|
|
|
67
|
-
Same plugin source,
|
|
52
|
+
Same plugin source, eleven shipped profiles, eleven different behaviours. The
|
|
68
53
|
test suite asserts that rather than taking it on faith.
|
|
69
54
|
|
|
70
55
|
### How a request travels
|
|
@@ -253,7 +238,6 @@ it. One file covers ten jobs either way.
|
|
|
253
238
|
| `nemotron-3.5-lightning-free` | 5 | 262,144 | — | **may train** |
|
|
254
239
|
| `mimo-v2.6-flash-free` | 5 | 200,000 | — | **may train** |
|
|
255
240
|
| `big-pickle` | 5 | 160,000 | — | **may train** |
|
|
256
|
-
| `flint-40b-free` | 5 | 262,144 | `max` | **may train** |
|
|
257
241
|
|
|
258
242
|
The five-lane profiles run everything on one model, which is the right shape
|
|
259
243
|
when you want one route and want to know exactly which one.
|
|
@@ -322,7 +306,7 @@ A CI job that failed on the second kind would go red on a Tuesday because
|
|
|
322
306
|
somebody else changed a provider's limits, and a job that goes red for a
|
|
323
307
|
reason you cannot act on is a job people stop reading.
|
|
324
308
|
|
|
325
|
-
It is not a hypothetical. The day this was written, four of the
|
|
309
|
+
It is not a hypothetical. The day this was written, four of the shipped
|
|
326
310
|
shipped profiles had already drifted from the live catalogue — the
|
|
327
311
|
`temperature` flag had flipped on every one of them.
|
|
328
312
|
|
|
@@ -577,7 +561,8 @@ oc-harness auto --json the same reconcile, as a report a script can read
|
|
|
577
561
|
oc-harness use <id> compile a profile into ~/.config/opencode/opencode.jsonc
|
|
578
562
|
oc-harness use <id> --dryrun print a summary of what would be written, write nothing
|
|
579
563
|
oc-harness refresh <id> re-derive a profile against the live model list
|
|
580
|
-
oc-harness install symlink the plugin, prompts, skills and GLOBAL.md
|
|
564
|
+
oc-harness install symlink the plugin, prompts, skills and GLOBAL.md into the config dir
|
|
565
|
+
(from `npx`, they link into a package copy it makes first - see Install)
|
|
581
566
|
oc-harness link just the plugin, no prompts or skills
|
|
582
567
|
oc-harness verify check the install against the profile; non-zero on drift
|
|
583
568
|
oc-harness agents report agents the effort tiering does not reach
|
|
@@ -873,12 +858,15 @@ One command if you are new here (`init` asks which free profile to run on,
|
|
|
873
858
|
then runs the two commands below plus `verify`):
|
|
874
859
|
|
|
875
860
|
```bash
|
|
876
|
-
npx
|
|
861
|
+
npx -y opencode-harness-kit@latest init
|
|
877
862
|
```
|
|
878
863
|
|
|
879
|
-
Two commands, in this order, if you already know which profile you want
|
|
880
|
-
|
|
881
|
-
|
|
864
|
+
Two commands, in this order, if you already know which profile you want.
|
|
865
|
+
Two forms of the same program: `npx -y opencode-harness-kit@latest …` from
|
|
866
|
+
the registry, or `./bin/oc-harness.mjs …` from a checkout. Bare `oc-harness`
|
|
867
|
+
elsewhere in this README is shorthand for whichever form installed it —
|
|
868
|
+
`npx` leaves no global binary, so every command already naming just
|
|
869
|
+
`oc-harness` is one of the two above in disguise:
|
|
882
870
|
|
|
883
871
|
```bash
|
|
884
872
|
oc-harness use fleet-safe # writes opencode.jsonc
|
|
@@ -889,6 +877,15 @@ oc-harness install # writes everything that file points at
|
|
|
889
877
|
`GLOBAL.md` into `~/.config/opencode/`, so this repo stays the single source
|
|
890
878
|
of truth and an edit here takes effect on the next opencode start.
|
|
891
879
|
|
|
880
|
+
That is the checkout behaviour, and it relies on the repository staying put.
|
|
881
|
+
Run through `npx` — or any package install — there is no repository to hold
|
|
882
|
+
the truth, only a npm cache that `npm cache clean --force` can delete under
|
|
883
|
+
it: the installer then COPIES the package once to
|
|
884
|
+
`~/.config/opencode/kit/` and links into that instead, so `npm cache clean`
|
|
885
|
+
cannot dangle the harness out from under its own config. Same commands, same
|
|
886
|
+
config; `uninstall` takes the copy out with the links, and `verify` judges
|
|
887
|
+
both modes by the same ownership rules.
|
|
888
|
+
|
|
892
889
|
### The one command that asks nothing
|
|
893
890
|
|
|
894
891
|
`init` is the front door for a person: it asks which free profile to run on.
|
package/bin/oc-harness.mjs
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
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
18
|
import { execFileSync } from "node:child_process"
|
|
@@ -56,6 +56,74 @@ 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"]) {
|
|
98
|
+
cpSync(join(ROOT, name), join(COPY_ROOT, name))
|
|
99
|
+
}
|
|
100
|
+
writeFileSync(join(COPY_ROOT, ".source"), `${want}\n`)
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* The root the kit installs FROM: the repository in a checkout, `COPY_ROOT`
|
|
105
|
+
* otherwise. Calling it at all triggers `ensurePackageCopy()`, so a caller
|
|
106
|
+
* holding the returned paths can rely on the files existing.
|
|
107
|
+
*
|
|
108
|
+
* @returns {string}
|
|
109
|
+
*/
|
|
110
|
+
function kitSourceRoot() {
|
|
111
|
+
if (CHECKOUT) return ROOT
|
|
112
|
+
ensurePackageCopy()
|
|
113
|
+
return COPY_ROOT
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* The root installed files may POINT at: the repository in a checkout, the
|
|
118
|
+
* durable package copy otherwise. `isOurs(below)` judges ownership against
|
|
119
|
+
* it.
|
|
120
|
+
*
|
|
121
|
+
* @returns {string}
|
|
122
|
+
*/
|
|
123
|
+
function kitTargetRoot() {
|
|
124
|
+
return CHECKOUT ? ROOT : COPY_ROOT
|
|
125
|
+
}
|
|
126
|
+
|
|
59
127
|
const args = process.argv.slice(2)
|
|
60
128
|
const cmd = args[0] ?? "list"
|
|
61
129
|
const has = (f) => args.includes(f)
|
|
@@ -382,12 +450,13 @@ function cmdShow(id) {
|
|
|
382
450
|
}
|
|
383
451
|
|
|
384
452
|
/**
|
|
385
|
-
*
|
|
386
|
-
*
|
|
387
|
-
*
|
|
453
|
+
* Put the built plugin where opencode loads it from -
|
|
454
|
+
* ~/.config/opencode/plugins/opencode-harness-kit.js - symlinked at the kit
|
|
455
|
+
* itself from a checkout, at the durable package copy when the kit was
|
|
456
|
+
* reached through npm or npx.
|
|
388
457
|
*/
|
|
389
458
|
function cmdLink() {
|
|
390
|
-
const dist = join(
|
|
459
|
+
const dist = join(kitSourceRoot(), "dist", "index.js")
|
|
391
460
|
if (!existsSync(dist)) {
|
|
392
461
|
fail("dist/index.js missing. Run: npm run build")
|
|
393
462
|
}
|
|
@@ -426,10 +495,14 @@ function cmdLink() {
|
|
|
426
495
|
|
|
427
496
|
/** Where the behavioural layer has to land for the config to mean anything. */
|
|
428
497
|
function installTargets() {
|
|
498
|
+
// `kitSourceRoot()` is the repository in a checkout, and the durable copy
|
|
499
|
+
// of the package under the config directory otherwise - an npx cache is
|
|
500
|
+
// npm's to clean, and links into it would dangle the day it goes.
|
|
501
|
+
const root = kitSourceRoot()
|
|
429
502
|
return [
|
|
430
|
-
{ src: join(
|
|
431
|
-
{ src: join(
|
|
432
|
-
{ src: join(
|
|
503
|
+
{ src: join(root, "prompts"), dest: join(CONFIG_DIR, "prompts"), kind: "dir" },
|
|
504
|
+
{ src: join(root, "skills"), dest: join(CONFIG_DIR, "skills"), kind: "dir" },
|
|
505
|
+
{ src: join(root, "GLOBAL.md"), dest: join(CONFIG_DIR, "GLOBAL.md"), kind: "file" },
|
|
433
506
|
]
|
|
434
507
|
}
|
|
435
508
|
|
|
@@ -607,7 +680,15 @@ function isLegacyAgentLink(full) {
|
|
|
607
680
|
* prompts, the four skills, the global rules - was a `cp` line in the README
|
|
608
681
|
* that nobody ran. Nothing checked it afterwards, which is how a config ended
|
|
609
682
|
* up pointing at an instruction file that did not exist while still looking
|
|
610
|
-
* correct.
|
|
683
|
+
* correct.
|
|
684
|
+
*
|
|
685
|
+
* From a checkout everything symlinks at the repository: edit there, live
|
|
686
|
+
* everywhere. From npm or npx there is no repository to hold the truth - the
|
|
687
|
+
* cache that supplied this process can be cleaned at any moment - so the
|
|
688
|
+
* package is first COPIED to a private, durable place under the config
|
|
689
|
+
* directory (`~/.config/opencode/kit/`), and every link points in there.
|
|
690
|
+
* `linkOne` and the ownership checks are unchanged; only what sits at the far
|
|
691
|
+
* end of the link differs.
|
|
611
692
|
*/
|
|
612
693
|
async function cmdInstall() {
|
|
613
694
|
const dist = join(ROOT, "dist", "index.js")
|
|
@@ -1180,7 +1261,7 @@ function cmdVerify() {
|
|
|
1180
1261
|
problems.push(
|
|
1181
1262
|
`${kitInstruction} does not exist - the rules in it are not being loaded. Run: oc-harness install`,
|
|
1182
1263
|
)
|
|
1183
|
-
} else if (!
|
|
1264
|
+
} else if (!isOurs(kitTarget)) {
|
|
1184
1265
|
// The kit's own file, and not a file that happens to have its name. The
|
|
1185
1266
|
// installer refuses to replace a real file, so a user who already had a
|
|
1186
1267
|
// GLOBAL.md keeps it - and the check above was satisfied by its mere
|
|
@@ -1218,7 +1299,7 @@ function cmdVerify() {
|
|
|
1218
1299
|
// The config's own reference is still checked below: the lane's prompt
|
|
1219
1300
|
// path is a separate thing, and a broken one is worth saying whatever
|
|
1220
1301
|
// the file at that path happens to be.
|
|
1221
|
-
if (!
|
|
1302
|
+
if (!isOurs(file)) {
|
|
1222
1303
|
problems.push(
|
|
1223
1304
|
`prompts/${role}.md is not the kit's file - the ${role} lane runs on somebody else's instructions. Run: oc-harness install`,
|
|
1224
1305
|
)
|
|
@@ -1372,7 +1453,7 @@ function cmdVerify() {
|
|
|
1372
1453
|
// This does not stop the frontmatter check below. A directory that is
|
|
1373
1454
|
// not the kit's can still be a skill whose name has drifted away from
|
|
1374
1455
|
// its directory, and that is a second thing worth saying.
|
|
1375
|
-
if (!
|
|
1456
|
+
if (!isOurs(join(skillsDir, name))) {
|
|
1376
1457
|
problems.push(
|
|
1377
1458
|
`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
1459
|
)
|
|
@@ -2039,6 +2120,36 @@ function pointsIntoKit(p) {
|
|
|
2039
2120
|
}
|
|
2040
2121
|
}
|
|
2041
2122
|
|
|
2123
|
+
/**
|
|
2124
|
+
* Whether an installed path holds the kit's file.
|
|
2125
|
+
*
|
|
2126
|
+
* From a checkout that means a link into the repository itself. From npm or
|
|
2127
|
+
* npx the install copied the package to `COPY_ROOT` and linked everything at
|
|
2128
|
+
* THAT, so "ours" is a link into - or a file inside - the copy. Links are
|
|
2129
|
+
* resolved relative to the link and without requiring them to exist, so a
|
|
2130
|
+
* dangling link still counts as the kit's: the state `npm uninstall` leaves
|
|
2131
|
+
* behind, and `uninstall`'s job to fix.
|
|
2132
|
+
*
|
|
2133
|
+
* @param {string} p
|
|
2134
|
+
* @returns {boolean}
|
|
2135
|
+
*/
|
|
2136
|
+
function isOurs(p) {
|
|
2137
|
+
if (CHECKOUT) return pointsIntoKit(p)
|
|
2138
|
+
let st
|
|
2139
|
+
try {
|
|
2140
|
+
st = lstatSync(p)
|
|
2141
|
+
} catch {
|
|
2142
|
+
return false
|
|
2143
|
+
}
|
|
2144
|
+
const root = resolve(COPY_ROOT)
|
|
2145
|
+
const under = (q) => q === root || q.startsWith(root + sep) || q.startsWith(root + "/")
|
|
2146
|
+
if (st.isSymbolicLink()) {
|
|
2147
|
+
const target = readlinkSync(p)
|
|
2148
|
+
return under(isAbsolute(target) ? target : resolve(dirname(p), target))
|
|
2149
|
+
}
|
|
2150
|
+
return under(resolve(p))
|
|
2151
|
+
}
|
|
2152
|
+
|
|
2042
2153
|
/**
|
|
2043
2154
|
* Copy the machine-derived fields of a live catalogue entry into a profile.
|
|
2044
2155
|
*
|
|
@@ -2333,11 +2444,14 @@ function findProjectSpace(startDir) {
|
|
|
2333
2444
|
* Remove what the kit installed.
|
|
2334
2445
|
*
|
|
2335
2446
|
* The install SYMLINKS the plugin, the prompts, the skills and GLOBAL.md out
|
|
2336
|
-
* of the kit
|
|
2337
|
-
*
|
|
2338
|
-
*
|
|
2339
|
-
*
|
|
2340
|
-
*
|
|
2447
|
+
* of the kit - the repository in a checkout, the durable package copy
|
|
2448
|
+
* otherwise - and into the config directory. So `npm uninstall` the kit
|
|
2449
|
+
* leaves four dangling symlinks behind, and opencode then loads neither the
|
|
2450
|
+
* rules nor the guard - and says nothing about it.
|
|
2451
|
+
*
|
|
2452
|
+
* So the kit can take them back out, and it says what it removed. A copy of
|
|
2453
|
+
* the package under the config directory is the kit's ENTIRELY - it exists
|
|
2454
|
+
* only because `install` made it - so it goes with the links.
|
|
2341
2455
|
*/
|
|
2342
2456
|
function cmdUninstall() {
|
|
2343
2457
|
const removed = []
|
|
@@ -2358,14 +2472,16 @@ function cmdUninstall() {
|
|
|
2358
2472
|
}
|
|
2359
2473
|
// A symlink is not enough to make it ours. The user may have their own
|
|
2360
2474
|
// 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
|
-
//
|
|
2475
|
+
// and "a symlink in prompts/ is the kit's" would delete it. A link that
|
|
2476
|
+
// is the kit's to manage goes; anything else is kept. Whose a link is is
|
|
2477
|
+
// `isOurs`'s decision: into the repository from a checkout, into the
|
|
2478
|
+
// package copy otherwise.
|
|
2363
2479
|
//
|
|
2364
2480
|
// The target is resolved WITHOUT requiring it to exist. A DANGLING link
|
|
2365
2481
|
// is the case this command exists for - `npm uninstall` leaves four of
|
|
2366
2482
|
// them behind - and `realpathSync` throws on those, which classified the
|
|
2367
2483
|
// kit's own broken links as somebody else's and left them in place.
|
|
2368
|
-
const ours = entries.filter((f) =>
|
|
2484
|
+
const ours = entries.filter((f) => isOurs(join(full, f)))
|
|
2369
2485
|
if (!ours.length) {
|
|
2370
2486
|
kept.push(`${dir}/ holds real files`)
|
|
2371
2487
|
continue
|
|
@@ -2388,14 +2504,14 @@ function cmdUninstall() {
|
|
|
2388
2504
|
for (const t of targets) {
|
|
2389
2505
|
if (!existsSync(t) && !isSymlink(t)) continue
|
|
2390
2506
|
try {
|
|
2391
|
-
// Only a link
|
|
2507
|
+
// Only a link the kit installed is ours to remove. A real file the
|
|
2392
2508
|
// user put there is theirs, and so is a link to their own file -
|
|
2393
2509
|
// `GLOBAL.md` is a standard opencode name.
|
|
2394
2510
|
if (!isSymlink(t)) {
|
|
2395
2511
|
kept.push(`${t} is a real file`)
|
|
2396
2512
|
continue
|
|
2397
2513
|
}
|
|
2398
|
-
if (!
|
|
2514
|
+
if (!isOurs(t)) {
|
|
2399
2515
|
kept.push(`${t} points somewhere else`)
|
|
2400
2516
|
continue
|
|
2401
2517
|
}
|
|
@@ -2405,6 +2521,18 @@ function cmdUninstall() {
|
|
|
2405
2521
|
kept.push(`${t} (could not be removed)`)
|
|
2406
2522
|
}
|
|
2407
2523
|
}
|
|
2524
|
+
// The package copy - `~/.config/opencode/kit/` when the kit was reached
|
|
2525
|
+
// through npm or npx - exists only because `install` created it, so it
|
|
2526
|
+
// goes out with the links. Unreachable from a checkout, where no copy
|
|
2527
|
+
// exists.
|
|
2528
|
+
if (!CHECKOUT && existsSync(COPY_ROOT)) {
|
|
2529
|
+
try {
|
|
2530
|
+
rmSync(COPY_ROOT, { recursive: true, force: true })
|
|
2531
|
+
removed.push("kit/ (the package copy this install was built from)")
|
|
2532
|
+
} catch {
|
|
2533
|
+
kept.push(`${COPY_ROOT} (could not be removed)`)
|
|
2534
|
+
}
|
|
2535
|
+
}
|
|
2408
2536
|
console.log(`\n${bold("uninstall")}\n`)
|
|
2409
2537
|
for (const r of removed) console.log(` ${green("✓")} removed ${r}`)
|
|
2410
2538
|
for (const k of kept) console.log(` ${yellow("!")} left alone: ${k}`)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "opencode-harness-kit",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.4",
|
|
4
4
|
"description": "Harness kit for OpenCode: capability profiles, a fleet of job lanes with one free model each, role-based reasoning effort, context pruning, and measured baselines.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"homepage": "https://github.com/pham-canh/opencode-harness-kit#readme",
|
package/profiles/index.json
CHANGED
|
@@ -110,7 +110,7 @@
|
|
|
110
110
|
"topEffort": "max",
|
|
111
111
|
"context": 262144,
|
|
112
112
|
"default": false,
|
|
113
|
-
"note": "Open-weight MoE with about 2.5B active parameters for
|
|
113
|
+
"note": "Open-weight MoE, 40B total with about 2.5B active parameters, built for coding agents and small enough to serve locally. Full effort ladder, 262k context, 64k output, no image input. Knowledge cutoff 2026-06. May train on prompts during its free period."
|
|
114
114
|
},
|
|
115
115
|
{
|
|
116
116
|
"id": "big-pickle",
|
|
@@ -122,17 +122,6 @@
|
|
|
122
122
|
"context": 160000,
|
|
123
123
|
"default": false,
|
|
124
124
|
"note": "Stealth model, no public identity, no effort levels, no image input. The provider may train on it during its free period."
|
|
125
|
-
},
|
|
126
|
-
{
|
|
127
|
-
"id": "flint-40b-free",
|
|
128
|
-
"file": "profiles/flint-40b-free.json",
|
|
129
|
-
"title": "Flint 40B Free",
|
|
130
|
-
"retention": "may-train",
|
|
131
|
-
"free": true,
|
|
132
|
-
"topEffort": "max",
|
|
133
|
-
"context": 262144,
|
|
134
|
-
"default": false,
|
|
135
|
-
"note": "Open-weight MoE, 40B total with about 2.5B active, built for coding agents and small enough to serve locally. Full effort ladder, 262k context, 64k output, no image input."
|
|
136
125
|
}
|
|
137
126
|
],
|
|
138
127
|
"excluded": [],
|
package/retention.yml
CHANGED
|
@@ -110,14 +110,6 @@ models:
|
|
|
110
110
|
verified: 2026-10-19
|
|
111
111
|
source: https://opencode.ai/docs/zen
|
|
112
112
|
|
|
113
|
-
opencode/flint-40b-free:
|
|
114
|
-
retention: may-train
|
|
115
|
-
note: >-
|
|
116
|
-
Free trial endpoint on opencode zen; treated as may-train until terms
|
|
117
|
-
state otherwise.
|
|
118
|
-
verified: 2026-10-22
|
|
119
|
-
source: https://opencode.ai/docs/zen
|
|
120
|
-
|
|
121
113
|
# --- Paid routes, kept because the catalog still serves them and a profile
|
|
122
114
|
# may be pointed at one. ---
|
|
123
115
|
|