opencode-harness-kit 0.4.2 → 0.4.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.
Files changed (73) hide show
  1. package/CHANGELOG.md +544 -19
  2. package/CONTRIBUTING.md +242 -0
  3. package/GLOBAL.md +34 -5
  4. package/MIGRATION.md +151 -0
  5. package/README.md +294 -37
  6. package/SECURITY.md +30 -0
  7. package/bin/baseline.mjs +15 -7
  8. package/bin/git-hooks/pre-commit +41 -9
  9. package/bin/oc-harness.mjs +1265 -82
  10. package/dist/config.d.ts +127 -0
  11. package/dist/denylist.d.ts +33 -0
  12. package/dist/effort.d.ts +64 -0
  13. package/dist/guard.d.ts +23 -0
  14. package/dist/health.d.ts +43 -0
  15. package/dist/index.d.ts +9 -0
  16. package/dist/index.js +532 -149
  17. package/dist/plugin.d.ts +15 -0
  18. package/dist/profile.d.ts +399 -0
  19. package/dist/project.d.ts +8 -0
  20. package/dist/prune.d.ts +6 -0
  21. package/dist/router.d.ts +3 -0
  22. package/dist/rules.d.ts +109 -0
  23. package/dist/safe.d.ts +7 -0
  24. package/dist/telemetry-store.d.ts +12 -0
  25. package/dist/telemetry.d.ts +3 -0
  26. package/docs/EXIT-CODES.md +71 -0
  27. package/docs/KNOWN-LIMITS.md +45 -21
  28. package/docs/RESEARCH.md +9 -4
  29. package/docs/ROUTING.md +53 -8
  30. package/eval/RESULTS.md +443 -0
  31. package/eval/ab.mjs +743 -0
  32. package/eval/attribute.mjs +170 -0
  33. package/eval/flags.mjs +88 -0
  34. package/eval/measure-install.mjs +323 -0
  35. package/eval/measure-prune.mjs +339 -0
  36. package/eval/metrics.js +79 -0
  37. package/eval/premises.mjs +200 -0
  38. package/eval/prepare.mjs +233 -0
  39. package/eval/run.mjs +376 -0
  40. package/eval/tasks-self.json +81 -0
  41. package/eval/tasks-ts.json +50 -0
  42. package/eval/tasks.json +83 -0
  43. package/eval/thresholds.mjs +134 -0
  44. package/eval/verify.mjs +287 -0
  45. package/package.json +44 -17
  46. package/profiles/fleet-all.json +6 -1
  47. package/profiles/fleet-safe.json +7 -3
  48. package/profiles/schema.json +182 -51
  49. package/prompts/build.md +4 -2
  50. package/skills/space-build/SKILL.md +4 -2
  51. package/skills/space-finish/SKILL.md +6 -2
  52. package/skills/space-plan/reference/task-template.md +13 -5
  53. package/src/config.js +182 -32
  54. package/src/denylist.js +95 -0
  55. package/src/driver/run.mjs +81 -16
  56. package/src/effort.js +15 -4
  57. package/src/guard.ts +165 -13
  58. package/src/health.js +80 -0
  59. package/src/index.ts +15 -4
  60. package/src/lane-usage.js +72 -18
  61. package/src/plugin-type-check.ts +38 -0
  62. package/src/plugin.ts +39 -0
  63. package/src/profile.js +225 -26
  64. package/src/project.js +47 -0
  65. package/src/prune.ts +110 -31
  66. package/src/router.ts +32 -63
  67. package/src/rules.js +99 -7
  68. package/src/safe.ts +27 -0
  69. package/src/sqlite.js +144 -0
  70. package/src/tasks.js +112 -29
  71. package/src/telemetry-store.ts +62 -0
  72. package/src/telemetry.ts +60 -57
  73. package/src/trust.js +70 -9
package/dist/index.js CHANGED
@@ -1,10 +1,64 @@
1
1
  // src/index.ts
2
2
  import { mkdirSync as mkdirSync3, writeFileSync as writeFileSync2 } from "node:fs";
3
- import { join as join5 } from "node:path";
3
+ import { join as join8 } from "node:path";
4
+
5
+ // src/guard.ts
6
+ import { homedir as homedir2, tmpdir as tmpdir2 } from "node:os";
7
+ import { join as join3, resolve as resolve2 } from "node:path";
4
8
 
5
9
  // src/rules.js
6
10
  import { homedir, tmpdir } from "node:os";
7
11
  import { resolve as resolvePath, sep } from "node:path";
12
+
13
+ // src/denylist.js
14
+ var BASH_DENYLIST = [
15
+ // `git push` is intentionally absent: it falls through to the `*` ask rule
16
+ // so opencode prompts for human approval before publishing.
17
+ "rm -rf *",
18
+ "rm -fr *",
19
+ "curl * | sh",
20
+ "curl * | bash",
21
+ "chmod 777 *",
22
+ "chmod 777*",
23
+ "gh pr create*",
24
+ "gh pr merge*",
25
+ "gh pr close*",
26
+ "gh release*",
27
+ "gh repo delete*",
28
+ // Publishing to a registry. The allowlist had a blanket `npm*`, so
29
+ // `npm publish` was ALLOWED - no prompt, no question - while GLOBAL.md says
30
+ // to ask before publishing. The mechanical layer contradicted the judgement
31
+ // layer, which is the exact failure this kit is built to catch.
32
+ //
33
+ // The registry is the one destination where a mistake cannot be undone by
34
+ // deleting a file.
35
+ "npm publish*",
36
+ "npm unpublish*",
37
+ "npm deprecate*",
38
+ "npm login*",
39
+ "npm adduser*",
40
+ "npm token *",
41
+ "npm owner *",
42
+ "yarn publish*",
43
+ "pnpm publish*",
44
+ "bun publish*"
45
+ ];
46
+ function matchesPattern(command, pattern) {
47
+ const re = new RegExp(
48
+ "^" + pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, "[\\s\\S]*") + "$"
49
+ );
50
+ return re.test(command);
51
+ }
52
+ function deniedBy(command) {
53
+ const cmd = String(command ?? "").trim();
54
+ if (!cmd) return null;
55
+ for (const pattern of BASH_DENYLIST) {
56
+ if (matchesPattern(cmd, pattern)) return pattern;
57
+ }
58
+ return null;
59
+ }
60
+
61
+ // src/rules.js
8
62
  var CASE_INSENSITIVE_FS = process.platform === "darwin" || process.platform === "win32";
9
63
  function expandHome(p) {
10
64
  if (p === "~") return homedir();
@@ -84,9 +138,17 @@ function segments(command) {
84
138
  if (command[i + 1] === "|") i++;
85
139
  continue;
86
140
  }
87
- if (ch === "&" && command[i + 1] === "&") {
141
+ if (ch === "&") {
142
+ if (command[i - 1] === ">" || command[i + 1] === ">") {
143
+ cur += ch;
144
+ continue;
145
+ }
146
+ if (command[i + 1] === "&") {
147
+ flush();
148
+ i++;
149
+ continue;
150
+ }
88
151
  flush();
89
- i++;
90
152
  continue;
91
153
  }
92
154
  cur += ch;
@@ -138,8 +200,38 @@ function tokenize(segment) {
138
200
  return tokens;
139
201
  }
140
202
  var ENV_ASSIGN = /^[A-Za-z_][A-Za-z0-9_]*=/;
141
- var GIT_WRAPPERS = /* @__PURE__ */ new Set(["env", "sudo", "doas", "nohup", "time", "command", "exec", "xargs"]);
203
+ var GIT_WRAPPERS = /* @__PURE__ */ new Set([
204
+ "env",
205
+ "sudo",
206
+ "doas",
207
+ "nohup",
208
+ "time",
209
+ "command",
210
+ "exec",
211
+ "xargs",
212
+ "npx"
213
+ ]);
142
214
  var WRAPPER_VALUE_FLAGS = /* @__PURE__ */ new Set(["-u", "--user", "-g", "--group", "-I", "-P", "-n", "-a", "-d"]);
215
+ var GIT_VALUE_FLAGS = /* @__PURE__ */ new Set([
216
+ "-C",
217
+ "-c",
218
+ "--git-dir",
219
+ "--work-tree",
220
+ "--namespace",
221
+ "--super-prefix",
222
+ "--exec-path",
223
+ "--config-env"
224
+ ]);
225
+ var GIT_GLOBAL_FLAGS = /* @__PURE__ */ new Set([
226
+ "--bare",
227
+ "--no-pager",
228
+ "--paginate",
229
+ "--no-replace-objects",
230
+ "--no-optional-locks",
231
+ "--literal-pathspecs",
232
+ "--glob-pathspecs",
233
+ "--icase-pathspecs"
234
+ ]);
143
235
  function skipPrefixes(tokens) {
144
236
  let i = 0;
145
237
  for (; ; ) {
@@ -170,17 +262,40 @@ function stripPrefixes(segment) {
170
262
  if (slash !== -1) rest[0] = rest[0].slice(slash + 1);
171
263
  return rest.join(" ");
172
264
  }
265
+ function gitSubcommand(segment) {
266
+ const tokens = tokenize(segment);
267
+ let i = skipPrefixes(tokens);
268
+ if (i >= tokens.length) return null;
269
+ if (!/(^|[\\/])git(\.exe)?$/i.test(tokens[i])) return null;
270
+ i++;
271
+ while (i < tokens.length) {
272
+ const flag = tokens[i];
273
+ if (GIT_VALUE_FLAGS.has(flag)) {
274
+ i += 2;
275
+ continue;
276
+ }
277
+ if (/^--[A-Za-z][\w-]*=/.test(flag) || GIT_GLOBAL_FLAGS.has(flag)) {
278
+ i++;
279
+ continue;
280
+ }
281
+ break;
282
+ }
283
+ return tokens[i] ?? null;
284
+ }
173
285
  var SHELL_BIN = /(^|[\\/])(sh|bash|zsh|dash|ksh)(\.exe)?$/i;
174
286
  var MAX_SHELL_DEPTH = 2;
175
287
  function shellPayload(segment) {
176
288
  const tokens = tokenize(segment);
177
289
  if (tokens.length < 2) return null;
178
- if (tokens[0] === "eval") return tokens.slice(1).join(" ") || null;
179
- if (!SHELL_BIN.test(tokens[0])) return null;
180
- const c = tokens.findIndex((t) => /^-[a-zA-Z]*c$/.test(t));
290
+ const i = skipPrefixes(tokens);
291
+ if (i >= tokens.length) return null;
292
+ if (tokens[i] === "eval") return tokens.slice(i + 1).join(" ") || null;
293
+ if (!SHELL_BIN.test(tokens[i])) return null;
294
+ const c = tokens.findIndex((t, n) => n > i && /^-[a-zA-Z]*c$/.test(t));
181
295
  if (c === -1 || c === tokens.length - 1) return null;
182
296
  return tokens.slice(c + 1).join(" ") || null;
183
297
  }
298
+ var ASK_GATED = [/^\s*git\s+push\b/];
184
299
  var BASH_RULES = [
185
300
  {
186
301
  name: "secret exfiltration via shell",
@@ -218,7 +333,18 @@ var BASH_RULES = [
218
333
  {
219
334
  name: "history rewrite",
220
335
  scope: "segment",
221
- test: /^git\s+(reset\s+--hard|clean\s+-[a-zA-Z]*f|rebase\s+.*--force)/i,
336
+ // The subcommand is resolved rather than read off the front of the text,
337
+ // because git takes options before it: `git -C /repo reset --hard` is the
338
+ // same command as `git reset --hard`, and a rule anchored at `^git` is
339
+ // defeated by every one of them. `gitSubcommand` already knows which
340
+ // options take a value and which ones are global.
341
+ check: (text) => {
342
+ const sub = gitSubcommand(text);
343
+ if (sub === "reset") return /(^|\s)--hard(\s|$)/.test(text);
344
+ if (sub === "clean") return /(^|\s)-[a-zA-Z]*f/.test(text);
345
+ if (sub === "rebase") return /(^|\s)--force/.test(text);
346
+ return false;
347
+ },
222
348
  why: "destroys work that only exists locally"
223
349
  }
224
350
  ];
@@ -249,6 +375,24 @@ function matches(rule, text) {
249
375
  if (!rule.test?.test(text)) return false;
250
376
  return !rule.unless?.test(text);
251
377
  }
378
+ function checkDenied(command, depth = 0) {
379
+ if (!command || !command.trim()) return null;
380
+ const parts = segments(command);
381
+ const normalized = parts.map((seg) => stripPrefixes(seg));
382
+ for (let i = 0; i < parts.length; i++) {
383
+ const pattern = deniedBy(normalized[i]);
384
+ if (pattern) return { pattern, segment: parts[i] };
385
+ }
386
+ if (depth < MAX_SHELL_DEPTH) {
387
+ for (const seg of parts) {
388
+ const payload = shellPayload(seg);
389
+ if (!payload) continue;
390
+ const hit = checkDenied(payload, depth + 1);
391
+ if (hit) return hit;
392
+ }
393
+ }
394
+ return null;
395
+ }
252
396
  function checkBash(command, rules = BASH_RULES, depth = 0) {
253
397
  if (!command || !command.trim()) return null;
254
398
  const parts = segments(command);
@@ -288,8 +432,82 @@ function checkWrite(target, rules = WRITE_RULES, root = "") {
288
432
  return null;
289
433
  }
290
434
 
435
+ // src/telemetry-store.ts
436
+ import { appendFileSync, existsSync, mkdirSync, statSync, renameSync } from "node:fs";
437
+ import { join } from "node:path";
438
+ var MAX_BYTES = 4 * 1024 * 1024;
439
+ function logPath(dir) {
440
+ return join(dir, "telemetry.jsonl");
441
+ }
442
+ function rotate(file) {
443
+ try {
444
+ if (existsSync(file) && statSync(file).size > MAX_BYTES) {
445
+ renameSync(file, `${file}.1`);
446
+ }
447
+ } catch {
448
+ }
449
+ }
450
+ function write(dir, record) {
451
+ try {
452
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
453
+ const file = logPath(dir);
454
+ rotate(file);
455
+ appendFileSync(file, JSON.stringify({ t: (/* @__PURE__ */ new Date()).toISOString(), ...record }) + "\n");
456
+ } catch {
457
+ }
458
+ }
459
+
460
+ // src/project.js
461
+ import { existsSync as existsSync2 } from "node:fs";
462
+ import { dirname, join as join2, resolve } from "node:path";
463
+ function projectRoot(directory, worktree) {
464
+ const start = resolve(directory || worktree || ".");
465
+ let dir = start;
466
+ for (let i = 0; i < 64; i++) {
467
+ if (existsSync2(join2(dir, ".git"))) return dir;
468
+ const parent = dirname(dir);
469
+ if (parent === dir) break;
470
+ dir = parent;
471
+ }
472
+ return start;
473
+ }
474
+
291
475
  // src/guard.ts
292
476
  var MARKER = "[opencode-harness-kit] blocked";
477
+ var WRITE_TOOLS = /* @__PURE__ */ new Set(["write", "edit", "apply_patch", "patch"]);
478
+ function pathsInPatch(text) {
479
+ const out = [];
480
+ const push = (p) => {
481
+ const v = p.trim();
482
+ if (!v) return;
483
+ if (v === "/dev/null") return;
484
+ if (!out.includes(v)) out.push(v);
485
+ };
486
+ for (const m of text.matchAll(/^\*\*\*\s+(?:Add|Update|Delete)\s+File:\s*(.+)$/gm)) {
487
+ push(m[1]);
488
+ }
489
+ for (const m of text.matchAll(/^\+\+\+\s+(?:b\/)?(.+)$/gm)) {
490
+ push(m[1]);
491
+ }
492
+ return out;
493
+ }
494
+ function writeTargets(tool, args) {
495
+ if (!args || typeof args !== "object") return [];
496
+ if (tool === "apply_patch") {
497
+ return pathsInPatch(String(args.patchText ?? args.patch ?? ""));
498
+ }
499
+ const out = [];
500
+ const one = (v) => {
501
+ if (typeof v === "string" && v) out.push(v);
502
+ };
503
+ one(args.filePath);
504
+ one(args.path);
505
+ for (const c of Array.isArray(args.changes) ? args.changes : []) {
506
+ one(c?.filePath);
507
+ one(c?.path);
508
+ }
509
+ return out;
510
+ }
293
511
  function deny(why, detail) {
294
512
  throw new Error(
295
513
  `${MARKER}: ${why}
@@ -301,24 +519,72 @@ function truncate(s, n = 200) {
301
519
  const one = s.replace(/\s+/g, " ").trim();
302
520
  return one.length > n ? `${one.slice(0, n)}...` : one;
303
521
  }
304
- var GuardPlugin = async ({ worktree }) => {
522
+ var GuardPlugin = async ({ worktree, directory }) => {
523
+ const project = projectRoot(directory, worktree);
524
+ const telemetryDir = join3(project, ".space");
525
+ const refuse = (tool, rule, why, detail) => {
526
+ write(telemetryDir, { kind: "guard", tool, rule });
527
+ deny(why, detail);
528
+ };
305
529
  return {
306
530
  "tool.execute.before": async (input, output) => {
307
531
  try {
308
532
  if (input.tool === "bash") {
533
+ const raw = output.args?.workdir;
534
+ if (typeof raw === "string" && raw.trim()) {
535
+ const target = raw.startsWith("~") ? join3(homedir2(), raw.slice(1)) : resolve2(project, raw);
536
+ if (!insideRoot(target, project) && !insideRoot(target, tmpdir2())) {
537
+ refuse(
538
+ input.tool,
539
+ "command outside the project",
540
+ "command runs outside the project",
541
+ `the working directory is ${truncate(target)}, and this project's files are the ones you were asked to change`
542
+ );
543
+ }
544
+ }
309
545
  const hit = checkBash(String(output.args?.command ?? ""));
310
- if (hit) deny(hit.name, `${hit.why}
546
+ if (hit) {
547
+ refuse(input.tool, hit.name, hit.name, `${hit.why}
311
548
  command: ${truncate(hit.segment)}`);
549
+ }
550
+ const command = String(output.args?.command ?? "");
551
+ const denied = checkDenied(command);
552
+ if (denied) {
553
+ refuse(
554
+ input.tool,
555
+ `denied command (${denied.pattern})`,
556
+ "denied command",
557
+ `this command matches \`${denied.pattern}\`, which the kit never allows.
558
+ command: ${truncate(denied.segment)}`
559
+ );
560
+ }
312
561
  return;
313
562
  }
314
- if (input.tool === "write" || input.tool === "edit" || input.tool === "patch") {
315
- const target = String(output.args?.filePath ?? output.args?.path ?? "") || String(output.args?.changes?.[0]?.filePath ?? "");
316
- const hit = checkWrite(target, void 0, worktree);
317
- if (hit) deny(hit.name, `${hit.why}
563
+ if (WRITE_TOOLS.has(input.tool)) {
564
+ const targets = writeTargets(input.tool, output.args);
565
+ if (!targets.length) {
566
+ refuse(
567
+ input.tool,
568
+ "unknown write target",
569
+ "cannot tell what this write touches",
570
+ `the ${input.tool} tool's arguments name no file, so the scope of the write is unknown`
571
+ );
572
+ }
573
+ for (const target of targets) {
574
+ const hit = checkWrite(target, void 0, project);
575
+ if (hit) {
576
+ refuse(input.tool, hit.name, hit.name, `${hit.why}
318
577
  file: ${truncate(hit.segment)}`);
578
+ }
579
+ }
319
580
  }
320
581
  } catch (err) {
321
582
  if (err instanceof Error && err.message.startsWith(MARKER)) throw err;
583
+ write(telemetryDir, {
584
+ kind: "guard",
585
+ tool: input.tool,
586
+ rule: "guard failed closed"
587
+ });
322
588
  throw new Error(
323
589
  `${MARKER} (guard failed closed): ${err instanceof Error ? err.message : String(err)}`
324
590
  );
@@ -328,21 +594,22 @@ var GuardPlugin = async ({ worktree }) => {
328
594
  };
329
595
 
330
596
  // src/profile.js
331
- import { readFileSync, existsSync, readdirSync } from "node:fs";
332
- import { dirname, join, resolve } from "node:path";
597
+ import { readFileSync, existsSync as existsSync3, readdirSync } from "node:fs";
598
+ import { dirname as dirname2, join as join4, resolve as resolve3 } from "node:path";
599
+ import { homedir as homedir3 } from "node:os";
333
600
  import { fileURLToPath } from "node:url";
334
- var HERE = dirname(fileURLToPath(import.meta.url));
601
+ var HERE = dirname2(fileURLToPath(import.meta.url));
335
602
  function findRoot(start) {
336
603
  let dir = start;
337
- for (let i = 0; i < 6; i++) {
338
- if (existsSync(join(dir, "profiles"))) return dir;
339
- const parent = dirname(dir);
604
+ for (let i = 0; i < 12; i++) {
605
+ if (existsSync3(join4(dir, "profiles"))) return dir;
606
+ const parent = dirname2(dir);
340
607
  if (parent === dir) break;
341
608
  dir = parent;
342
609
  }
343
- return resolve(start, "..");
610
+ return resolve3(start, "..");
344
611
  }
345
- var ROOT = findRoot(resolve(HERE, ".."));
612
+ var ROOT = findRoot(resolve3(HERE, ".."));
346
613
  var ProfileError = class extends Error {
347
614
  };
348
615
  var LANE_NAMES = [
@@ -357,14 +624,20 @@ var LANE_NAMES = [
357
624
  "sweep",
358
625
  "longform"
359
626
  ];
360
- var RETENTION_SEVERITY = ["zero-retention", "retained", "unknown", "may-train"];
627
+ var RETENTION_SEVERITY = ["zero-retention", "retained", "may-train", "unknown"];
628
+ function retentionSeverity(level) {
629
+ return RETENTION_SEVERITY.indexOf(
630
+ /** @type {Retention} */
631
+ level
632
+ );
633
+ }
361
634
  function profileDirs() {
362
- return [join(ROOT, "profiles")];
635
+ return [process.env.OPENCODE_HARNESS_PROFILES ?? join4(ROOT, "profiles")];
363
636
  }
364
637
  function profilePath(id) {
365
638
  for (const dir of profileDirs()) {
366
- const full = join(dir, `${id}.json`);
367
- if (existsSync(full)) return full;
639
+ const full = join4(dir, `${id}.json`);
640
+ if (existsSync3(full)) return full;
368
641
  }
369
642
  return null;
370
643
  }
@@ -405,8 +678,8 @@ function normalize(p) {
405
678
  var laneCache = null;
406
679
  function loadLanes() {
407
680
  if (laneCache) return laneCache;
408
- const file = join(ROOT, "lanes.json");
409
- if (!existsSync(file)) {
681
+ const file = join4(ROOT, "lanes.json");
682
+ if (!existsSync3(file)) {
410
683
  throw new ProfileError(`lane table missing: ${file}`);
411
684
  }
412
685
  const parsed = JSON.parse(readFileSync(file, "utf8"));
@@ -425,15 +698,25 @@ function loadLanes() {
425
698
  function laneMeta(name) {
426
699
  return loadLanes()[name] ?? null;
427
700
  }
701
+ var retentionCache = null;
428
702
  function loadRetention() {
429
- const file = join(ROOT, "retention.yml");
430
- if (!existsSync(file)) return {};
431
- const out = (
703
+ if (retentionCache) return retentionCache;
704
+ const file = join4(ROOT, "retention.yml");
705
+ if (!existsSync3(file)) {
706
+ retentionCache = { models: {}, defaults: {} };
707
+ return retentionCache;
708
+ }
709
+ const models = (
432
710
  /** @type {Record<string, any>} */
433
711
  {}
434
712
  );
713
+ const defaults = (
714
+ /** @type {Record<string, string>} */
715
+ {}
716
+ );
435
717
  const lines = readFileSync(file, "utf8").split("\n");
436
718
  let current = null;
719
+ let inDefaults = false;
437
720
  let i = 0;
438
721
  while (i < lines.length) {
439
722
  const raw = lines[i];
@@ -443,10 +726,28 @@ function loadRetention() {
443
726
  i++;
444
727
  continue;
445
728
  }
729
+ if (indent === 0 && line === "defaults:") {
730
+ inDefaults = true;
731
+ current = null;
732
+ i++;
733
+ continue;
734
+ }
735
+ if (indent === 0 && line === "models:") {
736
+ inDefaults = false;
737
+ current = null;
738
+ i++;
739
+ continue;
740
+ }
741
+ if (inDefaults) {
742
+ const pair = line.match(/^([a-z-]+)\s*:\s*(\S+)\s*$/);
743
+ if (pair) defaults[pair[1]] = pair[2];
744
+ i++;
745
+ continue;
746
+ }
446
747
  if (indent === 2 && line.endsWith(":")) {
447
748
  const key = line.slice(0, -1).trim();
448
749
  current = key.includes("/") ? key : `opencode/${key}`;
449
- out[current] = { retention: "unknown" };
750
+ models[current] = { retention: "unknown" };
450
751
  i++;
451
752
  continue;
452
753
  }
@@ -470,34 +771,41 @@ function loadRetention() {
470
771
  if (l.length - l.trimStart().length <= indent) break;
471
772
  parts.push(l.trim());
472
773
  }
473
- out[current][name] = parts.join(" ").replace(/\s+/g, " ").trim();
774
+ models[current][name] = parts.join(" ").replace(/\s+/g, " ").trim();
474
775
  i = j;
475
776
  continue;
476
777
  }
477
- out[current][name] = rest.replace(/^["']|["']$/g, "");
778
+ models[current][name] = rest.replace(/^["']|["']$/g, "");
478
779
  i++;
479
780
  continue;
480
781
  }
481
782
  i++;
482
783
  }
483
- return out;
784
+ retentionCache = { models, defaults };
785
+ return retentionCache;
484
786
  }
485
787
  function retentionFor(id) {
486
- const table = loadRetention();
788
+ const table = loadRetention().models;
487
789
  return table[id] ?? table[id.includes("/") ? id : `opencode/${id}`];
488
790
  }
489
- function modelRetention(model) {
490
- const declared = retentionFor(model)?.retention ?? "unknown";
791
+ function retentionOf(table, model) {
792
+ const fallback = table?.defaults?.unknown ?? "unknown";
793
+ const models = table?.models ?? {};
794
+ const record = models[model] ?? models[model.includes("/") ? model : `opencode/${model}`];
795
+ const declared = record?.retention ?? fallback;
491
796
  return (
492
797
  /** @type {Retention} */
493
798
  ["zero-retention", "retained", "may-train", "unknown"].includes(declared) ? declared : "unknown"
494
799
  );
495
800
  }
801
+ function modelRetention(model) {
802
+ return retentionOf(loadRetention(), model);
803
+ }
496
804
  function retentionAllowed(model, ceiling) {
497
805
  const level = modelRetention(model);
498
806
  if (level === "unknown") return false;
499
- const max = RETENTION_SEVERITY.indexOf(ceiling);
500
- const have = RETENTION_SEVERITY.indexOf(level);
807
+ const max = retentionSeverity(ceiling);
808
+ const have = retentionSeverity(level);
501
809
  if (max < 0) return false;
502
810
  return have <= max;
503
811
  }
@@ -518,6 +826,9 @@ function resolveLaneModel(p, role, opts = {}) {
518
826
  if (!retentionAllowed(candidate, ceiling)) continue;
519
827
  return { model: candidate, levels: levelsOfModel(candidate) };
520
828
  }
829
+ if (p.model && !unhealthy.has(p.model) && retentionAllowed(p.model, ceiling)) {
830
+ return { model: p.model, levels: levelsOfModel(p.model) };
831
+ }
521
832
  const fallback = resolveLaneChain(p, role)[0] ?? p.model;
522
833
  return { model: fallback, levels: levelsOfModel(fallback) };
523
834
  }
@@ -551,6 +862,7 @@ function resolveEffort(levels, requested) {
551
862
  if (!requested) return null;
552
863
  if (!levels || levels.length === 0) return null;
553
864
  if (levels.includes(requested)) return requested;
865
+ if (requested === "none") return null;
554
866
  const want = CANON.indexOf(requested);
555
867
  if (want < 0) {
556
868
  return levels[levels.length - 1] ?? null;
@@ -611,6 +923,29 @@ function validate(p, path = "<memory>") {
611
923
  );
612
924
  }
613
925
  }
926
+ if (p.prune) {
927
+ const prune = p.prune;
928
+ if (prune.keepRecentToolCalls !== void 0) {
929
+ if (!Number.isInteger(prune.keepRecentToolCalls) || prune.keepRecentToolCalls < 1) {
930
+ throw new ProfileError(
931
+ `${where}: prune.keepRecentToolCalls must be an integer of at least 1`
932
+ );
933
+ }
934
+ }
935
+ if (prune.keepRecentTurns !== void 0) {
936
+ if (!Number.isInteger(prune.keepRecentTurns) || prune.keepRecentTurns < 1) {
937
+ throw new ProfileError(
938
+ `${where}: prune.keepRecentTurns must be an integer of at least 1`
939
+ );
940
+ }
941
+ }
942
+ if (prune.keepUserMessages !== void 0 && typeof prune.keepUserMessages !== "boolean") {
943
+ throw new ProfileError(`${where}: prune.keepUserMessages must be a boolean`);
944
+ }
945
+ if (prune.enabled !== void 0 && typeof prune.enabled !== "boolean") {
946
+ throw new ProfileError(`${where}: prune.enabled must be a boolean`);
947
+ }
948
+ }
614
949
  for (const [role, rm] of Object.entries(p.roleModels ?? {})) {
615
950
  if (!LANE_NAMES.includes(role)) {
616
951
  throw new ProfileError(
@@ -625,9 +960,9 @@ function validate(p, path = "<memory>") {
625
960
  if (!rm?.model || typeof rm.model !== "string") {
626
961
  throw new ProfileError(`${where}: roleModels.${role} needs a model`);
627
962
  }
628
- if (rm.levels !== void 0 && !Array.isArray(rm.levels)) {
963
+ if (rm.levels !== void 0) {
629
964
  throw new ProfileError(
630
- `${where}: roleModels.${role}.levels must be an array`
965
+ `${where}: roleModels.${role}.levels is not read by anything - the lane takes its levels from the model it runs on. Remove it.`
631
966
  );
632
967
  }
633
968
  if (rm.chain !== void 0) {
@@ -647,6 +982,11 @@ function validate(p, path = "<memory>") {
647
982
  `${where}: roleModels.${role}.chain names ${rung}, which has no retention.yml entry`
648
983
  );
649
984
  }
985
+ if (!profileExists(rung)) {
986
+ throw new ProfileError(
987
+ `${where}: roleModels.${role}.chain names ${rung}, which has no profile in this directory`
988
+ );
989
+ }
650
990
  }
651
991
  }
652
992
  if (!retentionFor(rm.model)) {
@@ -654,8 +994,38 @@ function validate(p, path = "<memory>") {
654
994
  `${where}: roleModels.${role} names ${rm.model}, which has no retention.yml entry`
655
995
  );
656
996
  }
997
+ if (!profileExists(rm.model)) {
998
+ throw new ProfileError(
999
+ `${where}: roleModels.${role} names ${rm.model}, which has no profile in this directory`
1000
+ );
1001
+ }
1002
+ }
1003
+ if (p.utility) {
1004
+ if (typeof p.utility.model !== "string" || !p.utility.model) {
1005
+ throw new ProfileError(`${where}: utility.model must be a model id`);
1006
+ }
1007
+ const reachable = /* @__PURE__ */ new Set([p.model]);
1008
+ for (const rm of Object.values(p.roleModels ?? {})) {
1009
+ if (rm?.model) reachable.add(rm.model);
1010
+ for (const rung of rm?.chain ?? []) reachable.add(rung);
1011
+ }
1012
+ if (!reachable.has(p.utility.model)) {
1013
+ throw new ProfileError(
1014
+ `${where}: utility.model is ${p.utility.model}, which this profile does not resolve to (it has ${[...reachable].join(", ")})`
1015
+ );
1016
+ }
1017
+ }
1018
+ for (const id of p.fallback ?? []) {
1019
+ if (!profileExists(id)) {
1020
+ throw new ProfileError(
1021
+ `${where}: fallback names ${id}, which has no profile in this directory`
1022
+ );
1023
+ }
657
1024
  }
658
1025
  }
1026
+ function profileExists(id) {
1027
+ return profilePath(id) !== null;
1028
+ }
659
1029
  function inputCap(p, opts = {}) {
660
1030
  let cap = p.limits.input ?? p.limits.context;
661
1031
  for (const s of roleSplitModels(p, opts)) {
@@ -684,10 +1054,10 @@ function roleSplitModels(p, opts = {}) {
684
1054
  });
685
1055
  }
686
1056
  function configDir() {
687
- return process.env.OPENCODE_CONFIG_DIR ?? join(process.env.HOME ?? "", ".config", "opencode");
1057
+ return process.env.OPENCODE_CONFIG_DIR ?? join4(homedir3(), ".config", "opencode");
688
1058
  }
689
1059
  function configFilePath() {
690
- return join(configDir(), "opencode.jsonc");
1060
+ return join4(configDir(), "opencode.jsonc");
691
1061
  }
692
1062
  function parseJsonc(text) {
693
1063
  let out = "";
@@ -762,7 +1132,7 @@ function profileIdFromConfig() {
762
1132
  const fromEnv = process.env.OPENCODE_HARNESS_PROFILE;
763
1133
  if (fromEnv) return fromEnv.trim() || null;
764
1134
  const file = configFilePath();
765
- if (!existsSync(file)) return null;
1135
+ if (!existsSync3(file)) return null;
766
1136
  try {
767
1137
  const parsed = parseJsonc(readFileSync(file, "utf8"));
768
1138
  const harness = parsed?.harness;
@@ -777,18 +1147,16 @@ function profileIdFromConfig() {
777
1147
  }
778
1148
 
779
1149
  // src/prune.ts
780
- import { readFileSync as readFileSync2, existsSync as existsSync2 } from "node:fs";
781
- import { join as join2 } from "node:path";
1150
+ import { readFileSync as readFileSync2, existsSync as existsSync4 } from "node:fs";
1151
+ import { join as join5 } from "node:path";
782
1152
  function activeProfileId(worktree) {
783
- const candidates = [
784
- process.env.OPENCODE_HARNESS_PROFILE,
785
- join2(worktree, ".space", "active"),
786
- join2(ROOT, "profiles", ".active")
787
- ];
1153
+ const fromEnv = process.env.OPENCODE_HARNESS_PROFILE;
1154
+ if (fromEnv && fromEnv.trim()) return fromEnv.trim();
1155
+ const candidates = [join5(worktree, ".space", "active")];
788
1156
  for (const f of candidates) {
789
1157
  if (!f) continue;
790
1158
  try {
791
- if (existsSync2(f)) {
1159
+ if (existsSync4(f)) {
792
1160
  const v = readFileSync2(f, "utf8").trim();
793
1161
  if (v) return v;
794
1162
  }
@@ -797,6 +1165,7 @@ function activeProfileId(worktree) {
797
1165
  }
798
1166
  return null;
799
1167
  }
1168
+ var REASONING_STUB = "[reasoning] dropped from context - it was from an earlier turn and is stale.";
800
1169
  function sizeOf(part) {
801
1170
  if (typeof part?.text === "string") return part.text.length;
802
1171
  if (typeof part?.state?.output === "string") return part.state.output.length;
@@ -811,7 +1180,7 @@ function stub(part) {
811
1180
  const status = state.status ?? "done";
812
1181
  let tail = "";
813
1182
  const out = String(state.output ?? "");
814
- const m = out.match(/(passed|failed|error|Error|ok|OK|\d+ (?:passed|failed))/g);
1183
+ const m = out.match(/\b(?:\d+\s+(?:passed|failed)|passed|failed|error|ok)\b/gi);
815
1184
  if (m && m.length) tail = `, ${m[m.length - 1]}`;
816
1185
  return `[${tool}] ${cmd} -> ${status}${tail} | full output was pruned from context; re-run the command if you need it`;
817
1186
  }
@@ -821,24 +1190,40 @@ function stub(part) {
821
1190
  }
822
1191
  return `[${tool}] result pruned from context (${state.status ?? "done"}); re-run if you need it`;
823
1192
  }
824
- var PrunePlugin = async ({ worktree, $ }) => {
825
- let profile = null;
826
- try {
827
- const id = activeProfileId(worktree);
828
- if (id) profile = loadProfile(id);
829
- } catch {
830
- profile = null;
831
- }
832
- if (!profile?.prune?.enabled) {
833
- return {};
834
- }
835
- const keepCalls = profile.prune.keepRecentToolCalls ?? 6;
836
- const keepTurns = profile.prune.keepRecentTurns ?? 3;
837
- const keepUsers = profile.prune.keepUserMessages ?? true;
838
- const threshold = pruneThreshold(profile);
1193
+ var PrunePlugin = async ({ worktree, directory }) => {
1194
+ const project = projectRoot(directory, worktree);
1195
+ const DISABLED = {
1196
+ profile: null,
1197
+ keepCalls: 0,
1198
+ keepTurns: 0,
1199
+ keepUsers: true,
1200
+ threshold: 0
1201
+ };
1202
+ let cached = null;
1203
+ const resolve4 = () => {
1204
+ if (cached) return cached;
1205
+ let profile = null;
1206
+ try {
1207
+ const id = activeProfileId(project);
1208
+ if (id) profile = loadProfile(id);
1209
+ } catch {
1210
+ profile = null;
1211
+ }
1212
+ if (!profile?.prune?.enabled) return DISABLED;
1213
+ cached = {
1214
+ profile,
1215
+ keepCalls: profile.prune.keepRecentToolCalls ?? 6,
1216
+ keepTurns: profile.prune.keepRecentTurns ?? 3,
1217
+ keepUsers: profile.prune.keepUserMessages ?? true,
1218
+ threshold: pruneThreshold(profile)
1219
+ };
1220
+ return cached;
1221
+ };
839
1222
  return {
840
1223
  "experimental.chat.messages.transform": async (_input, output) => {
841
1224
  try {
1225
+ const { profile, keepCalls, keepTurns, keepUsers, threshold } = resolve4();
1226
+ if (!profile) return;
842
1227
  const messages = output.messages;
843
1228
  if (!Array.isArray(messages) || messages.length === 0) return;
844
1229
  const floor = Math.max(0, messages.length - 1 - keepTurns);
@@ -883,10 +1268,12 @@ var PrunePlugin = async ({ worktree, $ }) => {
883
1268
  stats.bytesAfter += size;
884
1269
  stats.kept++;
885
1270
  break;
886
- case "reasoning":
887
- stats.bytesAfter += Math.min(size, 200);
1271
+ case "reasoning": {
1272
+ part.text = REASONING_STUB;
1273
+ stats.bytesAfter += sizeOf(part);
888
1274
  stats.stubbed++;
889
1275
  break;
1276
+ }
890
1277
  case "tool": {
891
1278
  const state = part.state;
892
1279
  if (state?.status !== "completed") {
@@ -918,7 +1305,7 @@ var PrunePlugin = async ({ worktree, $ }) => {
918
1305
  }
919
1306
  if (stats.stubbed > 0) {
920
1307
  const saved = Math.max(0, stats.bytesBefore - stats.bytesAfter);
921
- await logPrune(profile.id, {
1308
+ await logPrune(project, profile.id, {
922
1309
  ...stats,
923
1310
  saved,
924
1311
  threshold
@@ -932,19 +1319,19 @@ var PrunePlugin = async ({ worktree, $ }) => {
932
1319
  }
933
1320
  };
934
1321
  };
935
- async function logPrune(profileId, stats) {
936
- const dir = join2(process.cwd(), ".space");
1322
+ async function logPrune(worktree, profileId, stats) {
1323
+ const dir = join5(worktree, ".space");
937
1324
  try {
938
1325
  const { mkdir, appendFile } = await import("node:fs/promises");
939
1326
  await mkdir(dir, { recursive: true });
940
1327
  await appendFile(
941
- join2(dir, "prune.jsonl"),
1328
+ join5(dir, "prune.jsonl"),
942
1329
  JSON.stringify({
943
1330
  t: (/* @__PURE__ */ new Date()).toISOString(),
944
1331
  profile: profileId,
945
1332
  stubbed: stats.stubbed,
946
1333
  kept: stats.kept,
947
- savedBytes: stats.saved,
1334
+ savedChars: stats.saved,
948
1335
  threshold: stats.threshold
949
1336
  }) + "\n"
950
1337
  );
@@ -952,34 +1339,56 @@ async function logPrune(profileId, stats) {
952
1339
  }
953
1340
  }
954
1341
 
955
- // src/router.ts
956
- import { readFileSync as readFileSync3, writeFileSync, mkdirSync } from "node:fs";
957
- import { join as join3 } from "node:path";
958
- function healthPath(worktree) {
959
- return join3(worktree, ".space", "health.json");
1342
+ // src/safe.ts
1343
+ function safeStringify(value) {
1344
+ try {
1345
+ return JSON.stringify(value) ?? String(value);
1346
+ } catch {
1347
+ try {
1348
+ return String(value);
1349
+ } catch {
1350
+ return "[unstringifiable]";
1351
+ }
1352
+ }
960
1353
  }
961
- function readHealth(worktree) {
1354
+
1355
+ // src/config.js
1356
+ function utilityModel(p) {
1357
+ const u = p?.utility;
1358
+ if (!u || typeof u.model !== "string" || !u.model) return null;
1359
+ return `${p.provider ?? "opencode"}/${u.model}`;
1360
+ }
1361
+
1362
+ // src/health.js
1363
+ import { existsSync as existsSync5, mkdirSync as mkdirSync2, readFileSync as readFileSync3, writeFileSync } from "node:fs";
1364
+ import { join as join6 } from "node:path";
1365
+ function healthPath(dir) {
1366
+ return join6(dir, ".space", "health.json");
1367
+ }
1368
+ function readHealth(dir) {
962
1369
  try {
963
- return JSON.parse(readFileSync3(healthPath(worktree), "utf8"));
1370
+ return JSON.parse(readFileSync3(healthPath(dir), "utf8"));
964
1371
  } catch {
965
1372
  return { unhealthy: {} };
966
1373
  }
967
1374
  }
968
- function writeHealth(worktree, h) {
1375
+ function writeHealth(dir, health) {
969
1376
  try {
970
- mkdirSync(join3(worktree, ".space"), { recursive: true });
971
- writeFileSync(healthPath(worktree), JSON.stringify(h, null, 2));
1377
+ if (!existsSync5(join6(dir, ".space"))) mkdirSync2(join6(dir, ".space"), { recursive: true });
1378
+ writeFileSync(healthPath(dir), JSON.stringify(health, null, 2));
972
1379
  } catch {
973
1380
  }
974
1381
  }
975
- function unhealthyModels(worktree) {
976
- const health = readHealth(worktree);
1382
+ function unhealthyModels(dir) {
1383
+ const health = readHealth(dir);
977
1384
  const out = /* @__PURE__ */ new Set();
978
1385
  for (const key of Object.keys(health.unhealthy ?? {})) {
979
1386
  out.add(key.includes("/") ? key.slice(key.indexOf("/") + 1) : key);
980
1387
  }
981
1388
  return out;
982
1389
  }
1390
+
1391
+ // src/router.ts
983
1392
  var SPENT = [
984
1393
  /FreeUsageLimitError/i,
985
1394
  /rate[_ -]?limit/i,
@@ -994,7 +1403,8 @@ function classify(text) {
994
1403
  return null;
995
1404
  }
996
1405
  var WARN_TEXT = "[opencode-harness-kit] This session's model route is rate limited or withdrawn. No plugin can swap a live session's model, so stop retrying and ask the human to start a new session. Run `oc-harness list` to see the lanes and their chains, or `oc-harness doctor` to check whether the profile still matches live metadata.";
997
- var RouterPlugin = async ({ worktree }) => {
1406
+ var RouterPlugin = async ({ worktree, directory }) => {
1407
+ const project = projectRoot(directory, worktree);
998
1408
  let failedSessions = 0;
999
1409
  let warned = false;
1000
1410
  return {
@@ -1002,8 +1412,8 @@ var RouterPlugin = async ({ worktree }) => {
1002
1412
  config: async (config) => {
1003
1413
  try {
1004
1414
  const cfg = config;
1005
- const health = readHealth(worktree);
1006
- const unhealthy = unhealthyModels(worktree);
1415
+ const health = readHealth(project);
1416
+ const unhealthy = unhealthyModels(project);
1007
1417
  const activeId = cfg.harness?.profile ?? profileIdFromConfig();
1008
1418
  if (!activeId) return;
1009
1419
  const id = activeId;
@@ -1017,7 +1427,6 @@ var RouterPlugin = async ({ worktree }) => {
1017
1427
  if (health.unhealthy?.[baseKey]) {
1018
1428
  const queue = [id, ...profile.fallback ?? []];
1019
1429
  for (const candidate of queue) {
1020
- if (health.unhealthy?.[`opencode/${candidate}`]) continue;
1021
1430
  let fallback;
1022
1431
  try {
1023
1432
  fallback = loadProfile(candidate);
@@ -1036,7 +1445,7 @@ var RouterPlugin = async ({ worktree }) => {
1036
1445
  threshold: fleet ? pruneThreshold(profile) : void 0
1037
1446
  });
1038
1447
  health.active = candidate;
1039
- writeHealth(worktree, health);
1448
+ writeHealth(project, health);
1040
1449
  break;
1041
1450
  }
1042
1451
  }
@@ -1063,16 +1472,16 @@ var RouterPlugin = async ({ worktree }) => {
1063
1472
  const e = event;
1064
1473
  const type = e?.type;
1065
1474
  if (!type) return;
1066
- const text = JSON.stringify(e);
1475
+ const text = safeStringify(e);
1067
1476
  const reason = classify(text);
1068
1477
  if (!reason) return;
1069
1478
  const modelID = e.properties?.modelID ?? e.properties?.model ?? e.properties?.modelID;
1070
1479
  const key = modelID ? `opencode/${modelID}` : void 0;
1071
1480
  if (!key) return;
1072
- const health = readHealth(worktree);
1481
+ const health = readHealth(project);
1073
1482
  if (health.unhealthy[key]) return;
1074
1483
  health.unhealthy[key] = { at: (/* @__PURE__ */ new Date()).toISOString(), reason };
1075
- writeHealth(worktree, health);
1484
+ writeHealth(project, health);
1076
1485
  failedSessions++;
1077
1486
  },
1078
1487
  "chat.message": async (input, output) => {
@@ -1087,7 +1496,7 @@ var RouterPlugin = async ({ worktree }) => {
1087
1496
  ${WARN_TEXT}`;
1088
1497
  return;
1089
1498
  }
1090
- writeHealth(worktree, readHealth(worktree));
1499
+ writeHealth(project, readHealth(project));
1091
1500
  }
1092
1501
  };
1093
1502
  };
@@ -1098,7 +1507,7 @@ function applySessionModel(cfg, profile, opts = {}) {
1098
1507
  try {
1099
1508
  const model = `${profile.provider}/${profile.model}`;
1100
1509
  cfg.model = model;
1101
- cfg.small_model = model;
1510
+ cfg.small_model = utilityModel(profile) ?? model;
1102
1511
  const variant = profile.effort?.default;
1103
1512
  if (profile.effort?.levels?.length && variant) cfg.variant = variant;
1104
1513
  else delete cfg.variant;
@@ -1127,31 +1536,9 @@ function applySessionModel(cfg, profile, opts = {}) {
1127
1536
  }
1128
1537
 
1129
1538
  // src/telemetry.ts
1130
- import { appendFileSync, mkdirSync as mkdirSync2, existsSync as existsSync4, statSync, renameSync } from "node:fs";
1131
- import { join as join4 } from "node:path";
1132
- var MAX_BYTES = 4 * 1024 * 1024;
1133
- function logPath(dir) {
1134
- return join4(dir, "telemetry.jsonl");
1135
- }
1136
- function rotate(file) {
1137
- try {
1138
- if (existsSync4(file) && statSync(file).size > MAX_BYTES) {
1139
- renameSync(file, `${file}.1`);
1140
- }
1141
- } catch {
1142
- }
1143
- }
1144
- function write(dir, record) {
1145
- try {
1146
- if (!existsSync4(dir)) mkdirSync2(dir, { recursive: true });
1147
- const file = logPath(dir);
1148
- rotate(file);
1149
- appendFileSync(file, JSON.stringify({ t: (/* @__PURE__ */ new Date()).toISOString(), ...record }) + "\n");
1150
- } catch {
1151
- }
1152
- }
1153
- var TelemetryPlugin = async ({ worktree }) => {
1154
- const dir = join4(worktree, ".space");
1539
+ import { join as join7 } from "node:path";
1540
+ var TelemetryPlugin = async ({ worktree, directory }) => {
1541
+ const dir = join7(projectRoot(directory, worktree), ".space");
1155
1542
  const started = Date.now();
1156
1543
  const opened = /* @__PURE__ */ new Set();
1157
1544
  const closed = /* @__PURE__ */ new Set();
@@ -1159,7 +1546,7 @@ var TelemetryPlugin = async ({ worktree }) => {
1159
1546
  event: async ({ event }) => {
1160
1547
  const type = event?.type;
1161
1548
  if (typeof type !== "string") return;
1162
- if (/error|rate|limit|quota/i.test(type)) {
1549
+ if (/(^|[._-])(error|errors|rate|limit|limits|quota)([._-]|$)/i.test(type)) {
1163
1550
  write(dir, { kind: "provider-event", type, detail: truncate2(safeStringify(event)) });
1164
1551
  return;
1165
1552
  }
@@ -1178,14 +1565,9 @@ var TelemetryPlugin = async ({ worktree }) => {
1178
1565
  }
1179
1566
  },
1180
1567
  "tool.execute.after": async (input, output) => {
1181
- const text = typeof output?.output === "string" ? output.output : "";
1182
- if (text.includes("[opencode-harness-kit] blocked")) {
1183
- write(dir, { kind: "guard", tool: input.tool, rule: extractRule(text) });
1184
- return;
1185
- }
1186
1568
  const command = String(input.args?.command ?? "");
1187
- if (input.tool === "bash" && /git push|rm -rf|chmod 777/.test(command)) {
1188
- write(dir, { kind: "guard-attempt", tool: input.tool, command: truncate2(command) });
1569
+ if (input.tool === "bash" && ASK_GATED.some((p) => p.test(command))) {
1570
+ write(dir, { kind: "guard-allowed", tool: input.tool, command: truncate2(command) });
1189
1571
  }
1190
1572
  },
1191
1573
  dispose: async () => {
@@ -1207,28 +1589,22 @@ function laneOf(event) {
1207
1589
  const completed = typeof time.completed === "number" ? time.completed : void 0;
1208
1590
  const ms = created !== void 0 && completed !== void 0 ? completed - created : void 0;
1209
1591
  if (!id || !agent) return null;
1592
+ const tokens = info.tokens ?? {};
1593
+ const ctx = typeof tokens.input === "number" ? tokens.input + (typeof tokens.cache?.read === "number" ? tokens.cache.read : 0) : void 0;
1594
+ const reasoning = typeof tokens.reasoning === "number" ? tokens.reasoning : void 0;
1210
1595
  return {
1211
1596
  id,
1212
1597
  agent,
1213
1598
  model: typeof modelId === "string" ? modelId : void 0,
1214
1599
  provider: typeof providerId === "string" ? providerId : void 0,
1215
- ms: typeof ms === "number" && ms >= 0 ? ms : void 0
1600
+ ms: typeof ms === "number" && ms >= 0 ? ms : void 0,
1601
+ ctx: typeof ctx === "number" ? ctx : void 0,
1602
+ reasoning
1216
1603
  };
1217
1604
  }
1218
- function extractRule(output) {
1219
- const m = output.match(/blocked:\s*([^\n]+)/);
1220
- return m ? m[1].trim() : "unknown";
1221
- }
1222
1605
  function truncate2(s, n = 300) {
1223
1606
  return s.length > n ? `${s.slice(0, n)}...` : s;
1224
1607
  }
1225
- function safeStringify(value) {
1226
- try {
1227
- return JSON.stringify(value) ?? String(value);
1228
- } catch {
1229
- return String(value);
1230
- }
1231
- }
1232
1608
 
1233
1609
  // src/effort.js
1234
1610
  function planEffort(profileId, opts = {}) {
@@ -1242,8 +1618,15 @@ function planEffort(profileId, opts = {}) {
1242
1618
  );
1243
1619
  }
1244
1620
  for (const [role, want] of Object.entries(p.roles ?? {})) {
1621
+ if (!want) continue;
1245
1622
  const got = roles[role];
1246
- if (want && got && want !== got) {
1623
+ if (got === null) {
1624
+ notes.push(
1625
+ `lane ${role} asked for ${want}; its model does not publish that level, so no variant is set.`
1626
+ );
1627
+ continue;
1628
+ }
1629
+ if (want !== got) {
1247
1630
  notes.push(`lane ${role} asked for ${want}; its model tops out at ${got}.`);
1248
1631
  }
1249
1632
  }
@@ -1280,8 +1663,7 @@ function applyEffort(config, result, opts = {}) {
1280
1663
  }
1281
1664
  cfg.harness = {
1282
1665
  ...cfg.harness ?? {},
1283
- profile: result.profileId,
1284
- ...opts.fallbackProfileId ? { fallback: opts.fallbackProfileId } : {}
1666
+ profile: result.profileId
1285
1667
  };
1286
1668
  return config;
1287
1669
  }
@@ -1311,14 +1693,15 @@ var HarnessPlugin = async (ctx, options) => {
1311
1693
  if (routerConfig) await routerConfig(config);
1312
1694
  const id = opts.profile ?? config?.harness?.profile ?? profileIdFromConfig();
1313
1695
  if (!id) return;
1696
+ const project = projectRoot(ctx.directory, ctx.worktree);
1314
1697
  try {
1315
1698
  const sessionModel = typeof config?.harness?.sessionModel === "string" ? config.harness.sessionModel : void 0;
1316
- applyEffort(config, planEffort(id, { unhealthy: unhealthyModels(ctx.worktree) }), {
1699
+ applyEffort(config, planEffort(id, { unhealthy: unhealthyModels(project) }), {
1317
1700
  sessionModel
1318
1701
  });
1319
- const dir = join5(ctx.worktree, ".space");
1702
+ const dir = join8(project, ".space");
1320
1703
  mkdirSync3(dir, { recursive: true });
1321
- writeFileSync2(join5(dir, "active"), `${id}
1704
+ writeFileSync2(join8(dir, "active"), `${id}
1322
1705
  `);
1323
1706
  } catch {
1324
1707
  }