opencode-arbiter 1.0.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/README.md +53 -0
- package/bin/arbiter.js +310 -0
- package/package.json +38 -0
- package/src/client.ts +78 -0
- package/src/commands.ts +29 -0
- package/src/index.ts +174 -0
- package/src/menu.test.ts +95 -0
- package/src/menu.ts +87 -0
- package/tsconfig.json +15 -0
package/README.md
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# opencode-arbiter
|
|
2
|
+
|
|
3
|
+
Local-first model router for [OpenCode](https://opencode.ai). This package ships two things:
|
|
4
|
+
|
|
5
|
+
- the **`arbiter` CLI**, which installs and runs the router server, and
|
|
6
|
+
- the **Notary plugin**, the OpenCode-side UI (plan-mode menu, overrides, correction logging).
|
|
7
|
+
|
|
8
|
+
Routing lives entirely in the server; the plugin is UI and logging only — it never calls a
|
|
9
|
+
model directly.
|
|
10
|
+
|
|
11
|
+
## Install
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
npm install -g opencode-arbiter
|
|
15
|
+
arbiter setup # download + install the server, create a venv, patch OpenCode config
|
|
16
|
+
arbiter start # run the router on 127.0.0.1:4317
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
`setup` needs Python 3.11+ on your PATH (used for a CPU-only PyTorch venv). Point OpenCode's
|
|
20
|
+
provider `baseUrl` at the router and it decides local (Ollama) vs cloud per task.
|
|
21
|
+
|
|
22
|
+
## CLI
|
|
23
|
+
|
|
24
|
+
| Command | Does |
|
|
25
|
+
|---|---|
|
|
26
|
+
| `arbiter setup` | Downloads the server bundle + classifier checkpoint for this package version, verifies checksums, creates `~/.arbiter/.venv`, installs deps, patches OpenCode config. |
|
|
27
|
+
| `arbiter start` | Starts the server detached, writes `~/.arbiter/server.pid`. |
|
|
28
|
+
| `arbiter stop` | Stops the running server. |
|
|
29
|
+
| `arbiter status` | Reports running/stopped and the server's `/health`. |
|
|
30
|
+
|
|
31
|
+
Env: `ARBITER_HOST`, `ARBITER_PORT`, `ARBITER_RELEASE_REPO`, `OPENCODE_CONFIG`.
|
|
32
|
+
|
|
33
|
+
## Plugin
|
|
34
|
+
|
|
35
|
+
OpenCode loads the `.ts` plugin directly (no build step). Add the entrypoint to your OpenCode
|
|
36
|
+
config's `plugin` array:
|
|
37
|
+
|
|
38
|
+
```jsonc
|
|
39
|
+
// ~/.config/opencode/opencode.json
|
|
40
|
+
{ "plugin": ["opencode-arbiter/src/index.ts"] }
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
`/train-mode` toggles manual approval. **Manual** (default) shows each resolution as an
|
|
44
|
+
accept/override menu and feeds the correction loop; **auto** passes decisions straight through
|
|
45
|
+
and only logs them.
|
|
46
|
+
|
|
47
|
+
## Develop
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
npm install
|
|
51
|
+
npm run typecheck
|
|
52
|
+
npm test
|
|
53
|
+
```
|
package/bin/arbiter.js
ADDED
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// opencode-arbiter CLI: setup / start / stop / status.
|
|
3
|
+
// Pure Node, cross-platform (Linux + macOS + Windows). No shell-outs for file ops or downloads.
|
|
4
|
+
|
|
5
|
+
import { createHash } from "node:crypto";
|
|
6
|
+
import { spawn, execFile } from "node:child_process";
|
|
7
|
+
import { createWriteStream, createReadStream, existsSync } from "node:fs";
|
|
8
|
+
import { mkdir, readFile, writeFile, rm } from "node:fs/promises";
|
|
9
|
+
import { homedir, platform } from "node:os";
|
|
10
|
+
import { dirname, join } from "node:path";
|
|
11
|
+
import { fileURLToPath } from "node:url";
|
|
12
|
+
import { promisify } from "node:util";
|
|
13
|
+
import { pipeline } from "node:stream/promises";
|
|
14
|
+
import { Readable } from "node:stream";
|
|
15
|
+
import { x as tarExtract } from "tar";
|
|
16
|
+
|
|
17
|
+
const execFileAsync = promisify(execFile);
|
|
18
|
+
|
|
19
|
+
// --- constants ---------------------------------------------------------------
|
|
20
|
+
|
|
21
|
+
const HOME = join(homedir(), ".arbiter");
|
|
22
|
+
const SERVER_DIR = join(HOME, "server");
|
|
23
|
+
const VENV_DIR = join(HOME, ".venv");
|
|
24
|
+
const PID_FILE = join(HOME, "server.pid");
|
|
25
|
+
const CHECKPOINT_REL = join("triage", "weights", "checkpoint.pt");
|
|
26
|
+
|
|
27
|
+
const HOST = process.env.ARBITER_HOST || "127.0.0.1";
|
|
28
|
+
const PORT = process.env.ARBITER_PORT || "4317";
|
|
29
|
+
|
|
30
|
+
// Where release assets live. Override with ARBITER_RELEASE_REPO=owner/repo.
|
|
31
|
+
const RELEASE_REPO = process.env.ARBITER_RELEASE_REPO || "Penitant/arbiter-release";
|
|
32
|
+
|
|
33
|
+
const IS_WIN = platform() === "win32";
|
|
34
|
+
|
|
35
|
+
// --- tiny logger -------------------------------------------------------------
|
|
36
|
+
|
|
37
|
+
const c = {
|
|
38
|
+
dim: (s) => `\x1b[2m${s}\x1b[0m`,
|
|
39
|
+
green: (s) => `\x1b[32m${s}\x1b[0m`,
|
|
40
|
+
red: (s) => `\x1b[31m${s}\x1b[0m`,
|
|
41
|
+
cyan: (s) => `\x1b[36m${s}\x1b[0m`,
|
|
42
|
+
};
|
|
43
|
+
const log = (...a) => console.log(...a);
|
|
44
|
+
const step = (s) => log(c.cyan("›"), s);
|
|
45
|
+
const ok = (s) => log(c.green("✓"), s);
|
|
46
|
+
const fail = (s) => { console.error(c.red("✗"), s); process.exitCode = 1; };
|
|
47
|
+
|
|
48
|
+
// --- version -----------------------------------------------------------------
|
|
49
|
+
|
|
50
|
+
async function pkgVersion() {
|
|
51
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
52
|
+
const raw = await readFile(join(here, "..", "package.json"), "utf8");
|
|
53
|
+
return JSON.parse(raw).version;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// --- github release asset download -------------------------------------------
|
|
57
|
+
|
|
58
|
+
function assetUrl(tag, name) {
|
|
59
|
+
return `https://github.com/${RELEASE_REPO}/releases/download/${tag}/${name}`;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async function download(url, dest) {
|
|
63
|
+
const res = await fetch(url, { redirect: "follow" });
|
|
64
|
+
if (!res.ok || !res.body) {
|
|
65
|
+
throw new Error(`download failed (${res.status}) for ${url}`);
|
|
66
|
+
}
|
|
67
|
+
await mkdir(dirname(dest), { recursive: true });
|
|
68
|
+
await pipeline(Readable.fromWeb(res.body), createWriteStream(dest));
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async function fetchText(url) {
|
|
72
|
+
const res = await fetch(url, { redirect: "follow" });
|
|
73
|
+
if (!res.ok) throw new Error(`fetch failed (${res.status}) for ${url}`);
|
|
74
|
+
return res.text();
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async function sha256File(path) {
|
|
78
|
+
const hash = createHash("sha256");
|
|
79
|
+
await pipeline(createReadStream(path), hash);
|
|
80
|
+
return hash.digest("hex");
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// A .sha256 file may be "<hex>" or "<hex> filename"; take the first field.
|
|
84
|
+
function parseChecksum(text) {
|
|
85
|
+
return text.trim().split(/\s+/)[0].toLowerCase();
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// --- python discovery --------------------------------------------------------
|
|
89
|
+
|
|
90
|
+
async function findPython() {
|
|
91
|
+
const candidates = IS_WIN ? ["python", "python3"] : ["python3", "python"];
|
|
92
|
+
for (const bin of candidates) {
|
|
93
|
+
try {
|
|
94
|
+
const { stdout } = await execFileAsync(bin, ["--version"]);
|
|
95
|
+
if (/Python 3\.(1[1-9]|[2-9]\d)/.test(stdout)) return bin;
|
|
96
|
+
} catch {
|
|
97
|
+
// try next candidate
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
throw new Error(
|
|
101
|
+
"no suitable Python found. Install Python 3.11+ and put `python3` (or `python`) on PATH.",
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function venvBin(name) {
|
|
106
|
+
return IS_WIN
|
|
107
|
+
? join(VENV_DIR, "Scripts", `${name}.exe`)
|
|
108
|
+
: join(VENV_DIR, "bin", name);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// --- opencode config patch ---------------------------------------------------
|
|
112
|
+
|
|
113
|
+
function opencodeConfigPath() {
|
|
114
|
+
if (process.env.OPENCODE_CONFIG) return process.env.OPENCODE_CONFIG;
|
|
115
|
+
const xdg = process.env.XDG_CONFIG_HOME || join(homedir(), ".config");
|
|
116
|
+
return join(xdg, "opencode", "opencode.json");
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
async function patchOpencodeConfig() {
|
|
120
|
+
const path = opencodeConfigPath();
|
|
121
|
+
const baseURL = `http://${HOST}:${PORT}/v1`;
|
|
122
|
+
let cfg = {};
|
|
123
|
+
if (existsSync(path)) {
|
|
124
|
+
try {
|
|
125
|
+
cfg = JSON.parse(await readFile(path, "utf8"));
|
|
126
|
+
} catch {
|
|
127
|
+
log(c.red("!"), `could not parse ${path}. Add this provider manually:`);
|
|
128
|
+
log(c.dim(` provider.arbiter.options.baseURL = "${baseURL}"`));
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
cfg.provider ??= {};
|
|
133
|
+
cfg.provider.arbiter = {
|
|
134
|
+
npm: "@ai-sdk/openai-compatible",
|
|
135
|
+
name: "Arbiter (local router)",
|
|
136
|
+
...cfg.provider.arbiter,
|
|
137
|
+
options: { ...(cfg.provider.arbiter?.options || {}), baseURL },
|
|
138
|
+
};
|
|
139
|
+
await mkdir(dirname(path), { recursive: true });
|
|
140
|
+
await writeFile(path, JSON.stringify(cfg, null, 2) + "\n");
|
|
141
|
+
ok(`patched OpenCode config ${c.dim(path)} → baseURL ${baseURL}`);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// --- commands ----------------------------------------------------------------
|
|
145
|
+
|
|
146
|
+
async function cmdSetup() {
|
|
147
|
+
const version = await pkgVersion();
|
|
148
|
+
const tag = `v${version}`;
|
|
149
|
+
log(`Setting up Arbiter ${c.cyan(tag)} into ${c.dim(HOME)}\n`);
|
|
150
|
+
await mkdir(HOME, { recursive: true });
|
|
151
|
+
|
|
152
|
+
const tarName = `arbiter-server-${tag}.tar.gz`;
|
|
153
|
+
const tarPath = join(HOME, tarName);
|
|
154
|
+
|
|
155
|
+
step("downloading server bundle…");
|
|
156
|
+
await download(assetUrl(tag, tarName), tarPath);
|
|
157
|
+
const publishedTarSum = parseChecksum(await fetchText(assetUrl(tag, `${tarName}.sha256`)));
|
|
158
|
+
const actualTarSum = await sha256File(tarPath);
|
|
159
|
+
if (actualTarSum !== publishedTarSum) {
|
|
160
|
+
throw new Error(`server bundle checksum mismatch (got ${actualTarSum}, expected ${publishedTarSum})`);
|
|
161
|
+
}
|
|
162
|
+
ok("server bundle verified");
|
|
163
|
+
|
|
164
|
+
step("extracting server…");
|
|
165
|
+
await rm(SERVER_DIR, { recursive: true, force: true });
|
|
166
|
+
await mkdir(SERVER_DIR, { recursive: true });
|
|
167
|
+
// The tarball's top-level entry is `server/`; strip it so files land directly in SERVER_DIR.
|
|
168
|
+
await tarExtract({ file: tarPath, cwd: SERVER_DIR, strip: 1 });
|
|
169
|
+
ok("server extracted");
|
|
170
|
+
|
|
171
|
+
step("downloading classifier checkpoint…");
|
|
172
|
+
const ckptPath = join(SERVER_DIR, CHECKPOINT_REL);
|
|
173
|
+
await download(assetUrl(tag, "checkpoint.pt"), ckptPath);
|
|
174
|
+
const publishedCkptSum = parseChecksum(await fetchText(assetUrl(tag, "checkpoint.pt.sha256")));
|
|
175
|
+
const actualCkptSum = await sha256File(ckptPath);
|
|
176
|
+
if (actualCkptSum !== publishedCkptSum) {
|
|
177
|
+
await rm(ckptPath, { force: true });
|
|
178
|
+
throw new Error(`checkpoint checksum mismatch (got ${actualCkptSum}, expected ${publishedCkptSum})`);
|
|
179
|
+
}
|
|
180
|
+
ok("checkpoint verified");
|
|
181
|
+
|
|
182
|
+
step("creating Python virtualenv…");
|
|
183
|
+
const python = await findPython();
|
|
184
|
+
await execFileAsync(python, ["-m", "venv", VENV_DIR]);
|
|
185
|
+
ok(`virtualenv at ${c.dim(VENV_DIR)} (${python})`);
|
|
186
|
+
|
|
187
|
+
step("installing server dependencies (CPU torch — can take a few minutes)…");
|
|
188
|
+
const pip = venvBin("pip");
|
|
189
|
+
await execFileAsync(pip, ["install", "--upgrade", "pip"], { maxBuffer: 1 << 26 });
|
|
190
|
+
await execFileAsync(
|
|
191
|
+
pip,
|
|
192
|
+
[
|
|
193
|
+
"install",
|
|
194
|
+
"--extra-index-url", "https://download.pytorch.org/whl/cpu",
|
|
195
|
+
"fastapi>=0.115",
|
|
196
|
+
"uvicorn[standard]>=0.30",
|
|
197
|
+
"httpx>=0.27",
|
|
198
|
+
"torch>=2.4",
|
|
199
|
+
"transformers>=4.44,<5",
|
|
200
|
+
],
|
|
201
|
+
{ maxBuffer: 1 << 26 },
|
|
202
|
+
);
|
|
203
|
+
ok("dependencies installed");
|
|
204
|
+
|
|
205
|
+
step("patching OpenCode config…");
|
|
206
|
+
await patchOpencodeConfig();
|
|
207
|
+
|
|
208
|
+
log(`\n${c.green("Done.")} Start the server with ${c.cyan("arbiter start")}.`);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
async function readPid() {
|
|
212
|
+
try {
|
|
213
|
+
const pid = parseInt(await readFile(PID_FILE, "utf8"), 10);
|
|
214
|
+
return Number.isInteger(pid) ? pid : null;
|
|
215
|
+
} catch {
|
|
216
|
+
return null;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function isAlive(pid) {
|
|
221
|
+
try {
|
|
222
|
+
process.kill(pid, 0);
|
|
223
|
+
return true;
|
|
224
|
+
} catch (e) {
|
|
225
|
+
return e.code === "EPERM"; // exists but owned by someone else
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
async function cmdStart() {
|
|
230
|
+
const existing = await readPid();
|
|
231
|
+
if (existing && isAlive(existing)) {
|
|
232
|
+
ok(`already running (pid ${existing}) on ${HOST}:${PORT}`);
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
if (!existsSync(SERVER_DIR)) {
|
|
236
|
+
throw new Error(`server not installed. Run ${c.cyan("arbiter setup")} first.`);
|
|
237
|
+
}
|
|
238
|
+
const python = venvBin("python");
|
|
239
|
+
const child = spawn(
|
|
240
|
+
python,
|
|
241
|
+
["-m", "uvicorn", "main:app", "--host", HOST, "--port", PORT],
|
|
242
|
+
{ cwd: SERVER_DIR, detached: true, stdio: "ignore", windowsHide: true },
|
|
243
|
+
);
|
|
244
|
+
child.unref();
|
|
245
|
+
await writeFile(PID_FILE, String(child.pid) + "\n");
|
|
246
|
+
ok(`started (pid ${child.pid}) on http://${HOST}:${PORT}`);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
async function cmdStop() {
|
|
250
|
+
const pid = await readPid();
|
|
251
|
+
if (!pid) {
|
|
252
|
+
log("not running (no pid file).");
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
try {
|
|
256
|
+
process.kill(pid, "SIGTERM");
|
|
257
|
+
ok(`stopped (pid ${pid}).`);
|
|
258
|
+
} catch (e) {
|
|
259
|
+
if (e.code === "ESRCH") log(`process ${pid} was not running.`);
|
|
260
|
+
else throw e;
|
|
261
|
+
} finally {
|
|
262
|
+
await rm(PID_FILE, { force: true });
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
async function cmdStatus() {
|
|
267
|
+
const pid = await readPid();
|
|
268
|
+
if (!pid || !isAlive(pid)) {
|
|
269
|
+
log(c.red("● stopped"));
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
272
|
+
log(c.green("● running"), c.dim(`pid ${pid} — http://${HOST}:${PORT}`));
|
|
273
|
+
try {
|
|
274
|
+
const res = await fetch(`http://${HOST}:${PORT}/health`, {
|
|
275
|
+
signal: AbortSignal.timeout(3000),
|
|
276
|
+
});
|
|
277
|
+
log(" /health:", JSON.stringify(await res.json()));
|
|
278
|
+
} catch {
|
|
279
|
+
log(" /health: unreachable (server starting or bound elsewhere)");
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// --- dispatch ----------------------------------------------------------------
|
|
284
|
+
|
|
285
|
+
const HELP = `opencode-arbiter — local-first model router for OpenCode
|
|
286
|
+
|
|
287
|
+
Usage: arbiter <command>
|
|
288
|
+
|
|
289
|
+
setup download + install the server, create a venv, patch OpenCode config
|
|
290
|
+
start start the server (detached) on ${HOST}:${PORT}
|
|
291
|
+
stop stop the running server
|
|
292
|
+
status show whether the server is running + /health
|
|
293
|
+
|
|
294
|
+
Env: ARBITER_HOST, ARBITER_PORT, ARBITER_RELEASE_REPO, OPENCODE_CONFIG`;
|
|
295
|
+
|
|
296
|
+
const commands = { setup: cmdSetup, start: cmdStart, stop: cmdStop, status: cmdStatus };
|
|
297
|
+
|
|
298
|
+
async function main() {
|
|
299
|
+
const cmd = process.argv[2];
|
|
300
|
+
if (!cmd || cmd === "-h" || cmd === "--help") return void log(HELP);
|
|
301
|
+
const handler = commands[cmd];
|
|
302
|
+
if (!handler) return fail(`unknown command "${cmd}"\n\n${HELP}`);
|
|
303
|
+
try {
|
|
304
|
+
await handler();
|
|
305
|
+
} catch (e) {
|
|
306
|
+
fail(e.message || String(e));
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
main();
|
package/package.json
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "opencode-arbiter",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "Local-first model router for OpenCode: per-task local (Ollama) vs cloud routing, with tier-scaled context handoff on model switches.",
|
|
6
|
+
"keywords": ["opencode", "ollama", "llm", "router", "plugin", "local-first"],
|
|
7
|
+
"license": "MIT",
|
|
8
|
+
"homepage": "https://github.com/Penitant/arbiter-release",
|
|
9
|
+
"repository": {
|
|
10
|
+
"type": "git",
|
|
11
|
+
"url": "git+https://github.com/Penitant/arbiter-release.git"
|
|
12
|
+
},
|
|
13
|
+
"bin": {
|
|
14
|
+
"arbiter": "bin/arbiter.js"
|
|
15
|
+
},
|
|
16
|
+
"main": "src/index.ts",
|
|
17
|
+
"files": [
|
|
18
|
+
"bin/",
|
|
19
|
+
"src/",
|
|
20
|
+
"tsconfig.json",
|
|
21
|
+
"README.md"
|
|
22
|
+
],
|
|
23
|
+
"engines": {
|
|
24
|
+
"node": ">=18"
|
|
25
|
+
},
|
|
26
|
+
"scripts": {
|
|
27
|
+
"typecheck": "tsc --noEmit",
|
|
28
|
+
"test": "node --test --experimental-strip-types src/*.test.ts"
|
|
29
|
+
},
|
|
30
|
+
"dependencies": {
|
|
31
|
+
"tar": "^7.4.3"
|
|
32
|
+
},
|
|
33
|
+
"devDependencies": {
|
|
34
|
+
"@opencode-ai/plugin": "^1.17.10",
|
|
35
|
+
"@types/node": "^22.20.0",
|
|
36
|
+
"typescript": "^5.5.0"
|
|
37
|
+
}
|
|
38
|
+
}
|
package/src/client.ts
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
// HTTP client for the Server's Notary contract; fails soft so a down Server never blocks the session.
|
|
2
|
+
|
|
3
|
+
/** The resolved proposal the Server returns from POST /classify. */
|
|
4
|
+
export interface Decision {
|
|
5
|
+
difficulty: "easy" | "medium" | "hard";
|
|
6
|
+
confidence: number;
|
|
7
|
+
tier: "local-simple" | "cloud-mid" | "cloud-heavy";
|
|
8
|
+
model: string; // "provider/id", e.g. "google/gemini-2.5-pro"
|
|
9
|
+
reasoning_effort: string | null;
|
|
10
|
+
reason: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface ClassifyRequest {
|
|
14
|
+
task_text: string;
|
|
15
|
+
session_id?: string;
|
|
16
|
+
context_tokens_estimate?: number;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** One logged resolution — accepted or overridden. */
|
|
20
|
+
export interface Correction {
|
|
21
|
+
task_text: string;
|
|
22
|
+
predicted_tier: string;
|
|
23
|
+
predicted_model: string;
|
|
24
|
+
confidence: number;
|
|
25
|
+
session_id: string;
|
|
26
|
+
corrected_tier?: string | null;
|
|
27
|
+
corrected_model?: string | null;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const DEFAULT_BASE_URL = "http://127.0.0.1:4317";
|
|
31
|
+
|
|
32
|
+
export class ServerClient {
|
|
33
|
+
private readonly baseUrl: string;
|
|
34
|
+
private readonly timeoutMs: number;
|
|
35
|
+
|
|
36
|
+
constructor(baseUrl?: string, timeoutMs = 4000) {
|
|
37
|
+
// ARBITER_SERVER_URL overrides the default localhost host/port.
|
|
38
|
+
this.baseUrl = (
|
|
39
|
+
baseUrl ?? process.env.ARBITER_SERVER_URL ?? DEFAULT_BASE_URL
|
|
40
|
+
).replace(/\/+$/, "");
|
|
41
|
+
this.timeoutMs = timeoutMs;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Resolve a proposal for a task; null on any failure (caller falls back silently). */
|
|
45
|
+
async classify(req: ClassifyRequest): Promise<Decision | null> {
|
|
46
|
+
try {
|
|
47
|
+
const resp = await this.post("/classify", req);
|
|
48
|
+
if (!resp.ok) return null; // includes 503 -> documented soft fallback
|
|
49
|
+
return (await resp.json()) as Decision;
|
|
50
|
+
} catch {
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Log a resolution. Best-effort: errors are swallowed, never disrupting the session. */
|
|
56
|
+
async logCorrection(c: Correction): Promise<void> {
|
|
57
|
+
try {
|
|
58
|
+
await this.post("/corrections", c);
|
|
59
|
+
} catch {
|
|
60
|
+
/* swallow — logging is never allowed to disrupt the session */
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
private async post(path: string, body: unknown): Promise<Response> {
|
|
65
|
+
const controller = new AbortController();
|
|
66
|
+
const timer = setTimeout(() => controller.abort(), this.timeoutMs);
|
|
67
|
+
try {
|
|
68
|
+
return await fetch(`${this.baseUrl}${path}`, {
|
|
69
|
+
method: "POST",
|
|
70
|
+
headers: { "content-type": "application/json" },
|
|
71
|
+
body: JSON.stringify(body),
|
|
72
|
+
signal: controller.signal,
|
|
73
|
+
});
|
|
74
|
+
} finally {
|
|
75
|
+
clearTimeout(timer);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
package/src/commands.ts
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
// /train-mode toggle: manual (proposals shown) vs auto (logged silently). In-memory, per-process.
|
|
2
|
+
|
|
3
|
+
export const TRAIN_MODE_COMMAND = "train-mode";
|
|
4
|
+
|
|
5
|
+
function defaultManual(): boolean {
|
|
6
|
+
// Default manual ON; set ARBITER_TRAIN_MODE=auto (or off/0) to start in auto mode.
|
|
7
|
+
const v = (process.env.ARBITER_TRAIN_MODE ?? "manual").toLowerCase();
|
|
8
|
+
return !["auto", "off", "0", "false", "no"].includes(v);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
let manual = defaultManual();
|
|
12
|
+
|
|
13
|
+
/** True when proposals should be shown for accept/override (manual approval). */
|
|
14
|
+
export function isManualMode(): boolean {
|
|
15
|
+
return manual;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Flip the mode and return the new state. */
|
|
19
|
+
export function toggleManualMode(): boolean {
|
|
20
|
+
manual = !manual;
|
|
21
|
+
return manual;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Human-readable one-liner for a toast after toggling. */
|
|
25
|
+
export function modeMessage(on: boolean): string {
|
|
26
|
+
return on
|
|
27
|
+
? "Arbiter train-mode ON — proposals shown for accept/override."
|
|
28
|
+
: "Arbiter train-mode OFF — routing automatic, decisions logged silently.";
|
|
29
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
// Notary plugin: plan-mode menu, override handling, correction logging. UI only — no routing logic.
|
|
2
|
+
|
|
3
|
+
import type { Plugin } from "@opencode-ai/plugin";
|
|
4
|
+
|
|
5
|
+
import { ServerClient, type Correction, type Decision } from "./client.js";
|
|
6
|
+
import { type Proposal, parseOverride, renderProposal } from "./menu.js";
|
|
7
|
+
import {
|
|
8
|
+
TRAIN_MODE_COMMAND,
|
|
9
|
+
isManualMode,
|
|
10
|
+
modeMessage,
|
|
11
|
+
toggleManualMode,
|
|
12
|
+
} from "./commands.js";
|
|
13
|
+
|
|
14
|
+
// Injected-menu prefix; skipped in chat.message so our own output isn't read as an override.
|
|
15
|
+
const MENU_PREFIX = "Arbiter proposes:";
|
|
16
|
+
|
|
17
|
+
// Only pending/in-progress subtasks are still routable.
|
|
18
|
+
const ACTIONABLE = new Set(["pending", "in_progress"]);
|
|
19
|
+
|
|
20
|
+
/** ~4 chars/token is a coarse but adequate estimate for the context-fit field. */
|
|
21
|
+
function estimateTokens(text: string): number {
|
|
22
|
+
return Math.ceil(text.length / 4);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Pull the concatenated text out of a message's parts (text parts only). */
|
|
26
|
+
function extractText(parts: Array<{ type?: string; text?: string }>): string {
|
|
27
|
+
return parts
|
|
28
|
+
.filter((p) => p?.type === "text" && typeof p.text === "string")
|
|
29
|
+
.map((p) => p.text as string)
|
|
30
|
+
.join("\n");
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export const NotaryPlugin: Plugin = async ({ client }) => {
|
|
34
|
+
const server = new ServerClient();
|
|
35
|
+
|
|
36
|
+
// Proposals shown but not yet accepted/overridden, keyed by session.
|
|
37
|
+
const pending = new Map<string, Proposal[]>();
|
|
38
|
+
// Last todo-set signature per session, so status-only updates don't re-show the menu.
|
|
39
|
+
const lastSignature = new Map<string, string>();
|
|
40
|
+
|
|
41
|
+
const toast = (message: string, variant: "info" | "success" = "info") =>
|
|
42
|
+
client.tui
|
|
43
|
+
.showToast({ body: { message, variant } })
|
|
44
|
+
.catch(() => {}); // a failed toast must never break the flow
|
|
45
|
+
|
|
46
|
+
const logAccepted = (p: Proposal, sessionID: string) =>
|
|
47
|
+
server.logCorrection({
|
|
48
|
+
task_text: p.taskText,
|
|
49
|
+
predicted_tier: p.decision.tier,
|
|
50
|
+
predicted_model: p.decision.model,
|
|
51
|
+
confidence: p.decision.confidence,
|
|
52
|
+
session_id: sessionID,
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
// Classify subtask texts in parallel, dropping any the Server can't resolve (null).
|
|
56
|
+
const classifyAll = async (
|
|
57
|
+
texts: string[],
|
|
58
|
+
sessionID: string,
|
|
59
|
+
): Promise<Proposal[]> => {
|
|
60
|
+
const decisions = await Promise.all(
|
|
61
|
+
texts.map((t) =>
|
|
62
|
+
server.classify({
|
|
63
|
+
task_text: t,
|
|
64
|
+
session_id: sessionID,
|
|
65
|
+
context_tokens_estimate: estimateTokens(t),
|
|
66
|
+
}),
|
|
67
|
+
),
|
|
68
|
+
);
|
|
69
|
+
const out: Proposal[] = [];
|
|
70
|
+
decisions.forEach((d: Decision | null, i) => {
|
|
71
|
+
if (d) out.push({ taskText: texts[i], decision: d });
|
|
72
|
+
});
|
|
73
|
+
return out;
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
console.log("[notary] Arbiter plugin loaded — Server contract at", "127.0.0.1:4317");
|
|
77
|
+
|
|
78
|
+
return {
|
|
79
|
+
/** Register the /train-mode command so OpenCode routes it to us. */
|
|
80
|
+
config: async (cfg) => {
|
|
81
|
+
cfg.command ??= {};
|
|
82
|
+
cfg.command[TRAIN_MODE_COMMAND] ??= {
|
|
83
|
+
// No-op template: the toggle happens in command.execute.before.
|
|
84
|
+
template: "",
|
|
85
|
+
description: "Toggle Arbiter train-mode (verify/override per-subtask routing)",
|
|
86
|
+
};
|
|
87
|
+
},
|
|
88
|
+
|
|
89
|
+
/** Intercept /train-mode: flip the mode, suppress the prompt, toast the result. */
|
|
90
|
+
"command.execute.before": async (input, output) => {
|
|
91
|
+
if (input.command !== TRAIN_MODE_COMMAND) return;
|
|
92
|
+
const on = toggleManualMode();
|
|
93
|
+
output.parts = []; // suppress — this is a UI toggle, not a model prompt
|
|
94
|
+
await toast(modeMessage(on));
|
|
95
|
+
},
|
|
96
|
+
|
|
97
|
+
/** Plan interception: classify each actionable subtask; in train-mode show the menu. */
|
|
98
|
+
event: async ({ event }) => {
|
|
99
|
+
if (event.type !== "todo.updated") return;
|
|
100
|
+
const { sessionID, todos } = event.properties;
|
|
101
|
+
|
|
102
|
+
const actionable = todos.filter((t) => ACTIONABLE.has(t.status));
|
|
103
|
+
const signature = JSON.stringify(actionable.map((t) => [t.id, t.content]));
|
|
104
|
+
if (signature === lastSignature.get(sessionID)) return; // status-only churn
|
|
105
|
+
lastSignature.set(sessionID, signature);
|
|
106
|
+
|
|
107
|
+
if (actionable.length === 0) {
|
|
108
|
+
pending.delete(sessionID);
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const proposals = await classifyAll(
|
|
113
|
+
actionable.map((t) => t.content),
|
|
114
|
+
sessionID,
|
|
115
|
+
);
|
|
116
|
+
if (proposals.length === 0) return; // Server couldn't resolve anything -> stay silent
|
|
117
|
+
|
|
118
|
+
if (isManualMode()) {
|
|
119
|
+
await client.session
|
|
120
|
+
.prompt({
|
|
121
|
+
path: { id: sessionID },
|
|
122
|
+
body: { noReply: true, parts: [{ type: "text", text: renderProposal(proposals) }] },
|
|
123
|
+
})
|
|
124
|
+
.catch(() => {});
|
|
125
|
+
pending.set(sessionID, proposals);
|
|
126
|
+
} else {
|
|
127
|
+
// Auto mode: pass-through, every subtask's decision logged silently.
|
|
128
|
+
for (const p of proposals) await logAccepted(p, sessionID);
|
|
129
|
+
}
|
|
130
|
+
},
|
|
131
|
+
|
|
132
|
+
/** Override channel: interpret the reply to a pending menu and log the correction(s). */
|
|
133
|
+
"chat.message": async (input, output) => {
|
|
134
|
+
const sessionID = input.sessionID;
|
|
135
|
+
const awaiting = pending.get(sessionID);
|
|
136
|
+
if (!awaiting) return; // no proposal in flight -> not our concern
|
|
137
|
+
|
|
138
|
+
const text = extractText(output.parts);
|
|
139
|
+
if (!text.trim() || text.startsWith(MENU_PREFIX)) return; // empty / our own menu
|
|
140
|
+
|
|
141
|
+
const ov = parseOverride(text, awaiting.length);
|
|
142
|
+
if (!ov) {
|
|
143
|
+
// Not a control reply: the user moved on; log the proposals as accepted-as-is.
|
|
144
|
+
pending.delete(sessionID);
|
|
145
|
+
for (const p of awaiting) await logAccepted(p, sessionID);
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
pending.delete(sessionID);
|
|
150
|
+
if (ov.kind === "accept") {
|
|
151
|
+
for (const p of awaiting) await logAccepted(p, sessionID);
|
|
152
|
+
await toast("Arbiter: accepted.", "success");
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// Override of one subtask; the rest stand as proposed (accepted).
|
|
157
|
+
const target = awaiting[ov.index - 1];
|
|
158
|
+
const correction: Correction = {
|
|
159
|
+
task_text: target.taskText,
|
|
160
|
+
predicted_tier: target.decision.tier,
|
|
161
|
+
predicted_model: target.decision.model,
|
|
162
|
+
confidence: target.decision.confidence,
|
|
163
|
+
session_id: sessionID,
|
|
164
|
+
corrected_tier: ov.targetIsTier ? ov.target : null,
|
|
165
|
+
corrected_model: ov.targetIsTier ? null : ov.target,
|
|
166
|
+
};
|
|
167
|
+
await server.logCorrection(correction);
|
|
168
|
+
for (let i = 0; i < awaiting.length; i++) {
|
|
169
|
+
if (i !== ov.index - 1) await logAccepted(awaiting[i], sessionID);
|
|
170
|
+
}
|
|
171
|
+
await toast(`Arbiter: overrode #${ov.index} -> ${ov.target}.`, "success");
|
|
172
|
+
},
|
|
173
|
+
};
|
|
174
|
+
};
|
package/src/menu.test.ts
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
// Tests for menu rendering + override parsing; runs under Node's built-in test runner.
|
|
2
|
+
|
|
3
|
+
import assert from "node:assert/strict";
|
|
4
|
+
import { test } from "node:test";
|
|
5
|
+
|
|
6
|
+
import type { Decision } from "./client.ts";
|
|
7
|
+
import {
|
|
8
|
+
isTier,
|
|
9
|
+
parseOverride,
|
|
10
|
+
renderProposal,
|
|
11
|
+
shortModel,
|
|
12
|
+
} from "./menu.ts";
|
|
13
|
+
|
|
14
|
+
function decision(over: Partial<Decision> = {}): Decision {
|
|
15
|
+
return {
|
|
16
|
+
difficulty: "medium",
|
|
17
|
+
confidence: 0.82,
|
|
18
|
+
tier: "cloud-mid",
|
|
19
|
+
model: "google/gemini-2.5-flash",
|
|
20
|
+
reasoning_effort: "medium",
|
|
21
|
+
reason: "cheapest reasoning-capable cloud-mid model",
|
|
22
|
+
...over,
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
test("shortModel strips the provider prefix", () => {
|
|
27
|
+
assert.equal(shortModel("google/gemini-3.5-flash"), "gemini-3.5-flash");
|
|
28
|
+
assert.equal(shortModel("qwen3:8b"), "qwen3:8b"); // no slash -> unchanged
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test("isTier recognizes only the three real tiers", () => {
|
|
32
|
+
assert.ok(isTier("cloud-heavy"));
|
|
33
|
+
assert.ok(!isTier("ultra"));
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test("renderProposal shows the tier->model tag, task, and footer", () => {
|
|
37
|
+
const out = renderProposal([
|
|
38
|
+
{ taskText: "implement JWT refresh logic", decision: decision() },
|
|
39
|
+
]);
|
|
40
|
+
assert.match(out, /^Arbiter proposes:/);
|
|
41
|
+
assert.match(out, /\[cloud-mid -> gemini-2\.5-flash\]/);
|
|
42
|
+
assert.match(out, /implement JWT refresh logic/);
|
|
43
|
+
assert.match(out, /Accept: enter/);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test("renderProposal aligns multiple subtasks and shows multi-footer", () => {
|
|
47
|
+
const out = renderProposal([
|
|
48
|
+
{ taskText: "rename a var", decision: decision({ tier: "local-simple", model: "ollama/qwen3:8b" }) },
|
|
49
|
+
{ taskText: "redesign session arch", decision: decision({ tier: "cloud-heavy", model: "google/gemini-3.5-flash" }) },
|
|
50
|
+
]);
|
|
51
|
+
const lines = out.split("\n").filter((l) => /^\d+\. /.test(l));
|
|
52
|
+
assert.equal(lines.length, 2);
|
|
53
|
+
// Tags padded to equal width -> the task text starts at the same column.
|
|
54
|
+
assert.equal(lines[0].indexOf("rename"), lines[1].indexOf("redesign"));
|
|
55
|
+
assert.match(out, /Accept all: enter/);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test("parseOverride treats empty/accept tokens as accept", () => {
|
|
59
|
+
for (const t of ["", " ", "accept", "ACCEPT ALL", "y", "ok"]) {
|
|
60
|
+
assert.deepEqual(parseOverride(t, 1), { kind: "accept" });
|
|
61
|
+
}
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test("parseOverride parses '<n> <model>' as a model override", () => {
|
|
65
|
+
assert.deepEqual(parseOverride("2 gemini-3.5-flash", 3), {
|
|
66
|
+
kind: "override",
|
|
67
|
+
index: 2,
|
|
68
|
+
target: "gemini-3.5-flash",
|
|
69
|
+
targetIsTier: false,
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test("parseOverride recognizes a tier target", () => {
|
|
74
|
+
const ov = parseOverride("2 cloud-heavy", 3);
|
|
75
|
+
assert.deepEqual(ov, { kind: "override", index: 2, target: "cloud-heavy", targetIsTier: true });
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test("parseOverride allows a bare target only for a single subtask", () => {
|
|
79
|
+
assert.deepEqual(parseOverride("gemini-3.5-flash", 1), {
|
|
80
|
+
kind: "override",
|
|
81
|
+
index: 1,
|
|
82
|
+
target: "gemini-3.5-flash",
|
|
83
|
+
targetIsTier: false,
|
|
84
|
+
});
|
|
85
|
+
// With multiple subtasks a bare target is ambiguous -> not a control input.
|
|
86
|
+
assert.equal(parseOverride("gemini-3.5-flash", 3), null);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test("parseOverride rejects an out-of-range index", () => {
|
|
90
|
+
assert.equal(parseOverride("9 gemini-3.5-flash", 3), null);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test("parseOverride returns null for an ordinary new task message", () => {
|
|
94
|
+
assert.equal(parseOverride("now add tests for the auth module please", 1), null);
|
|
95
|
+
});
|
package/src/menu.ts
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
// Plan-mode menu rendering + override parsing. Pure string logic, no SDK/network deps.
|
|
2
|
+
|
|
3
|
+
import type { Decision } from "./client.js";
|
|
4
|
+
|
|
5
|
+
export const TIERS = ["local-simple", "cloud-mid", "cloud-heavy"] as const;
|
|
6
|
+
export type Tier = (typeof TIERS)[number];
|
|
7
|
+
|
|
8
|
+
/** One proposed subtask: the task text and the Server's resolved Decision. */
|
|
9
|
+
export interface Proposal {
|
|
10
|
+
taskText: string;
|
|
11
|
+
decision: Decision;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export type Override =
|
|
15
|
+
| { kind: "accept" }
|
|
16
|
+
| { kind: "override"; index: number; target: string; targetIsTier: boolean };
|
|
17
|
+
|
|
18
|
+
/** Display name for a "provider/id" model — the id half. */
|
|
19
|
+
export function shortModel(model: string): string {
|
|
20
|
+
const slash = model.lastIndexOf("/");
|
|
21
|
+
return slash >= 0 ? model.slice(slash + 1) : model;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function isTier(value: string): value is Tier {
|
|
25
|
+
return (TIERS as readonly string[]).includes(value);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Collapse a task to a single trimmed line for the menu. */
|
|
29
|
+
function oneLine(text: string, max = 60): string {
|
|
30
|
+
const flat = text.replace(/\s+/g, " ").trim();
|
|
31
|
+
return flat.length > max ? `${flat.slice(0, max - 1)}…` : flat;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Render the numbered plan-mode menu; the `[tier -> model]` column is width-padded. */
|
|
35
|
+
export function renderProposal(proposals: Proposal[]): string {
|
|
36
|
+
if (proposals.length === 0) return "";
|
|
37
|
+
|
|
38
|
+
const tags = proposals.map(
|
|
39
|
+
(p) => `[${p.decision.tier} -> ${shortModel(p.decision.model)}]`,
|
|
40
|
+
);
|
|
41
|
+
const width = Math.max(...tags.map((t) => t.length));
|
|
42
|
+
|
|
43
|
+
const lines = proposals.map((p, i) => {
|
|
44
|
+
const tag = tags[i].padEnd(width);
|
|
45
|
+
return `${i + 1}. ${tag} ${oneLine(p.taskText)}`;
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
const footer =
|
|
49
|
+
proposals.length === 1
|
|
50
|
+
? [
|
|
51
|
+
"",
|
|
52
|
+
"Accept: enter",
|
|
53
|
+
'Override: type a tier or a model (e.g. "gemini-3.5-flash" or "cloud-heavy")',
|
|
54
|
+
]
|
|
55
|
+
: [
|
|
56
|
+
"",
|
|
57
|
+
"Accept all: enter",
|
|
58
|
+
"Override: type subtask number, then a tier or a specific model",
|
|
59
|
+
' (e.g. "2 gemini-3.5-flash")',
|
|
60
|
+
];
|
|
61
|
+
|
|
62
|
+
return ["Arbiter proposes:", "", ...lines, ...footer].join("\n");
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const ACCEPT_TOKENS = new Set(["", "accept", "accept all", "y", "yes", "ok"]);
|
|
66
|
+
|
|
67
|
+
/** Parse a reply: accept token, "<n> <target>", bare "<target>" (single subtask), else null. */
|
|
68
|
+
export function parseOverride(input: string, count: number): Override | null {
|
|
69
|
+
const trimmed = input.trim();
|
|
70
|
+
if (ACCEPT_TOKENS.has(trimmed.toLowerCase())) return { kind: "accept" };
|
|
71
|
+
|
|
72
|
+
// "<n> <target...>" — leading 1-based subtask index, then a tier or model.
|
|
73
|
+
const numbered = trimmed.match(/^(\d+)\s+(.+)$/);
|
|
74
|
+
if (numbered) {
|
|
75
|
+
const index = Number(numbered[1]);
|
|
76
|
+
if (index < 1 || index > count) return null; // out of range -> not a control input
|
|
77
|
+
const target = numbered[2].trim();
|
|
78
|
+
return { kind: "override", index, target, targetIsTier: isTier(target) };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Bare "<target>" with a single subtask: unambiguous override of subtask 1.
|
|
82
|
+
if (count === 1 && /^[\w./:-]+$/.test(trimmed)) {
|
|
83
|
+
return { kind: "override", index: 1, target: trimmed, targetIsTier: isTier(trimmed) };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return null;
|
|
87
|
+
}
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2022",
|
|
4
|
+
"module": "ESNext",
|
|
5
|
+
"moduleResolution": "Bundler",
|
|
6
|
+
"allowImportingTsExtensions": true,
|
|
7
|
+
"lib": ["ES2022", "DOM"],
|
|
8
|
+
"strict": true,
|
|
9
|
+
"noEmit": true,
|
|
10
|
+
"skipLibCheck": true,
|
|
11
|
+
"esModuleInterop": true,
|
|
12
|
+
"types": ["node"]
|
|
13
|
+
},
|
|
14
|
+
"include": ["src/**/*.ts"]
|
|
15
|
+
}
|