geneseed 3.1.3 → 3.2.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.
Files changed (113) hide show
  1. package/CHANGELOG.md +70 -0
  2. package/README.md +2 -2
  3. package/SHIPPED.md +3 -3
  4. package/bin/build-driver.mjs +127 -79
  5. package/bin/geneseed-cli.mjs +42 -84
  6. package/bin/geneseed-hook.mjs +5 -2
  7. package/harness.config.json +1 -1
  8. package/js/README.md +1 -10
  9. package/js/build/emit-claude.mjs +4 -5
  10. package/js/build/emit-common.mjs +63 -114
  11. package/js/build/emit-opencode.mjs +74 -66
  12. package/js/build/generate.mjs +4 -6
  13. package/js/build/render.mjs +13 -13
  14. package/js/build/source.mjs +7 -6
  15. package/js/build/stubs.mjs +25 -33
  16. package/js/build/themes.mjs +1 -1
  17. package/js/build/version.mjs +1 -3
  18. package/js/hosts/hooks.mjs +3 -3
  19. package/js/hosts/hosts.mjs +2 -4
  20. package/js/hosts/installs.mjs +35 -28
  21. package/js/hosts/link.mjs +1 -1
  22. package/js/hosts/native.mjs +37 -19
  23. package/js/hosts/opencode.mjs +39 -28
  24. package/js/hosts/settings.mjs +33 -110
  25. package/js/inspect/checks-authoring.mjs +5 -4
  26. package/js/inspect/checks-build.mjs +1 -1
  27. package/js/inspect/diff.mjs +1 -3
  28. package/js/inspect/doctor.mjs +59 -44
  29. package/js/inspect/excludes.mjs +4 -10
  30. package/js/inspect/inventory.mjs +37 -55
  31. package/js/inspect/registry.mjs +4 -7
  32. package/js/inspect/scan.mjs +8 -15
  33. package/js/inspect/status.mjs +1 -1
  34. package/js/lib/fs.mjs +34 -0
  35. package/js/lib/json.mjs +28 -1
  36. package/js/lib/paths.mjs +0 -10
  37. package/js/lib/udiff.mjs +11 -16
  38. package/js/maintain/memory.mjs +2 -4
  39. package/js/maintain/migrate.mjs +3 -3
  40. package/js/maintain/uninstall.mjs +90 -78
  41. package/js/maintain/update.mjs +2 -2
  42. package/js/ui/anim.mjs +1 -1
  43. package/js/ui/menu.mjs +1 -1
  44. package/js/ui/tui.mjs +3 -20
  45. package/js/web/actions.mjs +230 -217
  46. package/js/web/activity.mjs +71 -89
  47. package/js/web/api.mjs +133 -224
  48. package/js/web/daemon.mjs +92 -110
  49. package/js/web/docs.mjs +66 -111
  50. package/js/web/graph.mjs +0 -0
  51. package/js/web/handler.mjs +78 -97
  52. package/js/web/jobs.mjs +124 -165
  53. package/js/web/routes.mjs +73 -152
  54. package/js/web/server.mjs +47 -82
  55. package/package.json +1 -1
  56. package/src/agents/explorer.md +2 -1
  57. package/src/doctrines/process.md +4 -2
  58. package/src/laws/universal.md +55 -43
  59. package/src/skills/clarify.md +1 -1
  60. package/src/skills/forge-mcp.md +2 -1
  61. package/src/skills/fresh-eyes.md +1 -1
  62. package/src/skills/ingest.md +2 -1
  63. package/src/skills/parallel-agents.md +1 -1
  64. package/src/skills/ponytail.md +2 -2
  65. package/src/skills/research.md +4 -2
  66. package/themes/_TEMPLATE.json +2 -0
  67. package/themes/biker.json +2 -0
  68. package/themes/commentator.json +2 -0
  69. package/themes/cyberpunk.json +2 -0
  70. package/themes/gamer.json +2 -0
  71. package/themes/imperial.json +2 -0
  72. package/themes/joker.json +2 -0
  73. package/themes/marvin.json +2 -0
  74. package/themes/mean.json +2 -0
  75. package/themes/military.json +2 -0
  76. package/themes/neutral.json +2 -0
  77. package/themes/pirate.json +2 -0
  78. package/themes/sports.json +2 -0
  79. package/themes/verstappen.json +2 -0
  80. package/themes/wizard.json +2 -0
  81. package/web/dist/assets/{Activity-B49fE_ug.js → Activity-BTCG2gMl.js} +1 -1
  82. package/web/dist/assets/{ActivityDetail-abt7hzD2.js → ActivityDetail-julsmPDB.js} +1 -1
  83. package/web/dist/assets/CatalogRow-DqS4e5XM.js +1 -0
  84. package/web/dist/assets/Diff-CIrGbiqm.js +5 -0
  85. package/web/dist/assets/Doctor-CpI53oHY.js +1 -0
  86. package/web/dist/assets/{Graph-DvYMtlvl.js → Graph-CS4tA7YS.js} +1 -1
  87. package/web/dist/assets/Harness-vRYs11kX.js +1 -0
  88. package/web/dist/assets/Laws-BjbHJrJt.js +3 -0
  89. package/web/dist/assets/Library-CjC7BAWN.js +1 -0
  90. package/web/dist/assets/{marked.esm-JKvyS0Pp.js → Markdown-kgpg9EQM.js} +15 -15
  91. package/web/dist/assets/Profile-CHr-PPf6.js +1 -0
  92. package/web/dist/assets/{Rules-CfsV1EVy.js → Rules-CsC6hfqk.js} +1 -1
  93. package/web/dist/assets/Skills-BvatJGfe.js +1 -0
  94. package/web/dist/assets/{StatusBadge-C9bZOEZk.js → StatusBadge-gRrak0nO.js} +1 -1
  95. package/web/dist/assets/{activity-Cc0n2jwx.js → activity-B0-FXZCj.js} +1 -1
  96. package/web/dist/assets/index-BnRWQbZb.js +12 -0
  97. package/web/dist/assets/index-Bx3I7cRW.js +2 -0
  98. package/web/dist/assets/index-D0f9tJlA.css +1 -0
  99. package/web/dist/assets/index-DJOhOwwR.js +1 -0
  100. package/web/dist/index.html +2 -2
  101. package/web/src/pages/Laws.jsx +42 -71
  102. package/web/dist/assets/Diff-D3_bVkih.js +0 -5
  103. package/web/dist/assets/Doctor-hXp7srsx.js +0 -1
  104. package/web/dist/assets/Harness-BIWei7N5.js +0 -1
  105. package/web/dist/assets/Laws-CxmnRdwt.js +0 -3
  106. package/web/dist/assets/Library-MjbOkHIW.js +0 -1
  107. package/web/dist/assets/Markdown-Dx_w0faR.js +0 -1
  108. package/web/dist/assets/Profile-BRFvv2gr.js +0 -1
  109. package/web/dist/assets/Skills-DArA6IHS.js +0 -1
  110. package/web/dist/assets/index-Bx3oxZ1o.css +0 -1
  111. package/web/dist/assets/index-DC3-GlCY.js +0 -1
  112. package/web/dist/assets/index-DP035wPa.js +0 -12
  113. package/web/dist/assets/index-dHi-7M-F.js +0 -2
@@ -198,59 +198,51 @@ agent-overrides.json
198
198
  // Seeded stubs — write-once, never overwrite
199
199
  // ---------------------------------------------------------------------------
200
200
 
201
- /** `_build_render.ensure_context_stub`. */
201
+ /**
202
+ * `_build_render`'s eight `ensure_*_stub` functions all wrote this same guard out by hand:
203
+ * write `t` to `p` only when nothing is there yet. The STUB BODIES above are what a recorded
204
+ * corpus compares byte for byte — this only touches the write-once CHECK every one of them
205
+ * repeated, never the text.
206
+ */
207
+ const seed = (p, t) => { if (!existsSync(p)) writeText(p, t); };
208
+
209
+ /** A stub whose destination is a fixed `out`-relative name and a fixed body. */
210
+ const stubWriter = (file, stub) => (out) => seed(path.join(out, file), stub);
211
+
212
+ /** `_build_render.ensure_context_stub` — the one stub whose body is computed, not literal. */
202
213
  export function ensureContextStub(out) {
203
- const dest = path.join(out, 'context.json');
204
- if (!existsSync(dest)) {
205
- writeText(dest, `${jsonDumpsIndent(CONTEXT_STUB, { ensureAscii: false })}\n`);
206
- }
214
+ seed(path.join(out, 'context.json'), `${jsonDumpsIndent(CONTEXT_STUB, { ensureAscii: false })}\n`);
207
215
  }
208
216
 
209
217
  /** `_build_render.ensure_wiki_stub` — a legacy `wiki.json` counts as present. */
210
218
  export function ensureWikiStub(out) {
211
- const dest = path.join(out, 'wiki.jsonc');
212
- if (!existsSync(dest) && !existsSync(path.join(out, 'wiki.json'))) writeText(dest, WIKI_STUB);
219
+ if (!existsSync(path.join(out, 'wiki.json'))) seed(path.join(out, 'wiki.jsonc'), WIKI_STUB);
213
220
  }
214
221
 
215
222
  /** `_build_render.ensure_rules_stub`. */
216
- export function ensureRulesStub(out) {
217
- const dest = path.join(out, RULES_FILE);
218
- if (!existsSync(dest)) writeText(dest, RULES_STUB);
219
- }
223
+ export const ensureRulesStub = stubWriter(RULES_FILE, RULES_STUB);
220
224
 
221
225
  /** `_build_render.ensure_profile_stub`. */
222
- export function ensureProfileStub(out) {
223
- const dest = path.join(out, PROFILE_FILE);
224
- if (!existsSync(dest)) writeText(dest, PROFILE_STUB);
225
- }
226
+ export const ensureProfileStub = stubWriter(PROFILE_FILE, PROFILE_STUB);
226
227
 
227
228
  /**
228
229
  * `_build_render.ensure_excludes_stub` — the sovereign-repo list, seeded once and NEVER
229
230
  * overwritten. Reachable only from the Claude-shaped emits, which is why it arrived with
230
231
  * `emitClaudeRender` rather than with the bundle stubs beside it.
231
232
  */
232
- export function ensureExcludesStub(out) {
233
- const dest = path.join(out, EXCLUDES_FILE);
234
- if (!existsSync(dest)) writeText(dest, EXCLUDES_STUB);
235
- }
233
+ export const ensureExcludesStub = stubWriter(EXCLUDES_FILE, EXCLUDES_STUB);
236
234
 
237
235
  /** `_build_render.ensure_bundle_gitignore`. */
238
- export function ensureBundleGitignore(out) {
239
- const dest = path.join(out, '.gitignore');
240
- if (!existsSync(dest)) writeText(dest, BUNDLE_GITIGNORE);
241
- }
236
+ export const ensureBundleGitignore = stubWriter('.gitignore', BUNDLE_GITIGNORE);
237
+
238
+ /** A stub INSIDE an already-existing store dir — a memory/notebook index, only ever seeded
239
+ * once the store itself exists (`ensureMemoryIndex`/`ensureNotebookIndex` are both called
240
+ * right after the store dir is created, but never invent the dir themselves). */
241
+ const storeIndexWriter = (file, header) => (dir) => { if (isDir(dir)) seed(path.join(dir, file), header); };
242
242
 
243
243
  /** `_build_render.ensure_memory_index` — only inside an EXISTING store dir. */
244
- export function ensureMemoryIndex(memDir) {
245
- if (!isDir(memDir)) return;
246
- const idx = path.join(memDir, 'MEMORY.md');
247
- if (!existsSync(idx)) writeText(idx, '# Memory Index\n');
248
- }
244
+ export const ensureMemoryIndex = storeIndexWriter('MEMORY.md', '# Memory Index\n');
249
245
 
250
246
  /** `_build_render.ensure_notebook_index`. */
251
- export function ensureNotebookIndex(nbDir) {
252
- if (!isDir(nbDir)) return;
253
- const idx = path.join(nbDir, 'NOTEBOOK.md');
254
- if (!existsSync(idx)) writeText(idx, '# Notebook Index\n');
255
- }
247
+ export const ensureNotebookIndex = storeIndexWriter('NOTEBOOK.md', '# Notebook Index\n');
256
248
 
@@ -66,7 +66,7 @@ import { stripWhitespaceStart, stripWhitespaceEnd, stripWhitespace } from '../li
66
66
  * for containers — a `===` here would return `null` every time and silently route every
67
67
  * theme through the re-dump fallback, i.e. rewrite all 14 files in full.
68
68
  */
69
- export function insertThemeKeys(raw, theme, tmpl, tmplKeys, missing) {
69
+ function insertThemeKeys(raw, theme, tmpl, tmplKeys, missing) {
70
70
  const lines = splitLines(raw);
71
71
  if (!lines.length || stripWhitespace(lines[0]) !== '{') return null;
72
72
 
@@ -145,9 +145,7 @@ export function writeVersion(cfg, out) {
145
145
  // Mutating it away stays green here for a reason no cell can remove — freezing the
146
146
  // clock across a process boundary is not something the harness can do — which is
147
147
  // exactly why the choice is spelled out instead of relying on the comparison.
148
- const d = new Date();
149
- const today = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-`
150
- + `${String(d.getDate()).padStart(2, '0')}`;
148
+ const today = new Date().toLocaleDateString('sv-SE');
151
149
  writeText(path.join(out, VERSION_MARKER),
152
150
  `${fp} (built ${today}) [release ${release}]\n`);
153
151
  return fp;
@@ -35,7 +35,6 @@ import { existsSync, statSync, readFileSync, readdirSync, mkdirSync, realpathSyn
35
35
  from 'node:fs';
36
36
  import path from 'node:path';
37
37
  import { spawnSync } from 'node:child_process';
38
- import { fileURLToPath } from 'node:url';
39
38
  import { readText, writeText, printOut, printErr, withDiscardableStderr } from '../lib/fs.mjs';
40
39
  import { jsonDumpsCompact } from '../lib/json.mjs';
41
40
  import { normcase, comparePaths, toPlatformPath } from '../lib/paths.mjs';
@@ -46,7 +45,7 @@ import { NO_WINDOW } from '../lib/proc.mjs';
46
45
  // with it went this file's private `opencodeConfigDir`, whose only caller it was.
47
46
  import { resolveMemoryDir, expanduser } from './hosts.mjs';
48
47
 
49
- const ROOT = path.dirname(path.dirname(path.dirname(fileURLToPath(import.meta.url))));
48
+ const ROOT = path.resolve(import.meta.dirname, '../..');
50
49
 
51
50
  // ---- Python path and text primitives -------------------------------------------------
52
51
  // Small, and each one is here because a JS-idiomatic equivalent differs observably.
@@ -101,6 +100,7 @@ function resolvePath(p) {
101
100
  }
102
101
  }
103
102
 
103
+ // NOT imported from `js/lib/fs.mjs` (the owner everywhere else) — hot path, no new import cost.
104
104
  const isFile = (p) => { try { return statSync(p).isFile(); } catch { return false; } };
105
105
  const isDir = (p) => { try { return statSync(p).isDirectory(); } catch { return false; } };
106
106
 
@@ -575,7 +575,7 @@ export function cmdGitGate(args) {
575
575
  * Geneseed's own coinage and match anywhere; any other markdown matches only inside THIS
576
576
  * install's `<root>/memory/`.
577
577
  */
578
- export function ruleGateTarget(p, root) {
578
+ function ruleGateTarget(p, root) {
579
579
  if (typeof p !== 'string') return null;
580
580
  // Split on BOTH separators rather than taking a basename: a payload carrying a Windows
581
581
  // path reaches a POSIX host with its backslashes intact, and `path.basename` there
@@ -14,14 +14,12 @@
14
14
  * changed resolver rather than catch it — but `tests/unit/node_driver.test.mjs`'s relocation
15
15
  * table, which asserts each host's global target absolutely.
16
16
  */
17
- import { realpathSync, statSync } from 'node:fs';
17
+ import { realpathSync } from 'node:fs';
18
18
  import path from 'node:path';
19
19
  import os from 'node:os';
20
- import { printErr, withDiscardableStderr } from '../lib/fs.mjs';
20
+ import { printErr, withDiscardableStderr, isDir } from '../lib/fs.mjs';
21
21
  import { toPlatformPath } from '../lib/paths.mjs';
22
22
 
23
- const isDir = (p) => { try { return statSync(p).isDirectory(); } catch { return false; } };
24
-
25
23
  /** `_build_global.GLOBAL_MANIFEST` — the file whose presence means "a global install". */
26
24
  export const GLOBAL_MANIFEST = '.geneseed-manifest.json';
27
25
 
@@ -32,19 +32,16 @@
32
32
  * web cells recorded it. Both candidates are gone (the argument is in the Python), and the
33
33
  * claim is now true rather than aspirational.
34
34
  */
35
- import { existsSync, readdirSync, statSync } from 'node:fs';
35
+ import { existsSync, readdirSync } from 'node:fs';
36
36
  import path from 'node:path';
37
37
 
38
38
  import { CONFIG, PACK_ORDER, THEMES, discoverNames } from '../build/source.mjs';
39
39
  import { GLOBAL_MANIFEST, HOSTS, resolvePath } from './hosts.mjs';
40
40
  import { registryRoots } from '../inspect/registry.mjs';
41
- import { printErr, readText } from '../lib/fs.mjs';
41
+ import { printErr, readText, isFile, isDir } from '../lib/fs.mjs';
42
42
  import { formatRepr } from '../lib/json.mjs';
43
43
  import { comparePaths } from '../lib/paths.mjs';
44
44
 
45
- const isDir = (p) => { try { return statSync(p).isDirectory(); } catch { return false; } };
46
- const isFile = (p) => { try { return statSync(p).isFile(); } catch { return false; } };
47
-
48
45
  /**
49
46
  * `Path.read_text(encoding="utf-8")`, or null where Python raises OSError.
50
47
  *
@@ -150,6 +147,22 @@ function themeFromAgent(agentMd) {
150
147
  const CARRIERS = ['AGENT.md', 'CLAUDE.md', path.join('rules', 'geneseed.md'),
151
148
  'copilot-instructions.md', 'AGENTS.md'];
152
149
 
150
+ /**
151
+ * The scan `themeOfDir`, `leadOfDir`, `doctrinesOfDir` and `excludedRulesOfDir` each wrote
152
+ * out: try each carrier in order, first answer wins. `probe(carrierPath)` returns
153
+ * `undefined` to mean "keep looking" and anything else — including `null` or `[]`, both
154
+ * legitimate stop values for the two register readers below — to mean "stop, this is the
155
+ * answer". `fallback` is what every caller's own trailing `return …;` supplied once the
156
+ * whole list was exhausted with no answer.
157
+ */
158
+ function firstCarrier(d, probe, fallback = null) {
159
+ for (const carrier of CARRIERS) {
160
+ const result = probe(path.join(d, carrier));
161
+ if (result !== undefined) return result;
162
+ }
163
+ return fallback;
164
+ }
165
+
153
166
  /** `_harness_setup._theme_of_dir` — the marker, else the sigil in one of five carriers. */
154
167
  export function themeOfDir(d) {
155
168
  const marker = path.join(d, '.geneseed-theme');
@@ -157,11 +170,7 @@ export function themeOfDir(d) {
157
170
  const name = (readMaybe(marker) ?? '').trim();
158
171
  if (name) return name;
159
172
  }
160
- for (const carrier of CARRIERS) {
161
- const found = themeFromAgent(path.join(d, carrier));
162
- if (found) return found;
163
- }
164
- return null;
173
+ return firstCarrier(d, (carrierPath) => themeFromAgent(carrierPath) || undefined);
165
174
  }
166
175
 
167
176
  /** `_harness_setup.FOOTPRINTS`. */
@@ -209,14 +218,14 @@ export function capitalize(s) {
209
218
  }
210
219
 
211
220
  function leadOfDir(d, names) {
212
- for (const carrier of CARRIERS) {
213
- const text = readMaybe(path.join(d, carrier));
214
- if (text === null) continue;
221
+ return firstCarrier(d, (carrierPath) => {
222
+ const text = readMaybe(carrierPath);
223
+ if (text === null) return undefined;
215
224
  for (const name of names) {
216
225
  if (text.includes(`**${capitalize(name)}**`)) return name;
217
226
  }
218
- }
219
- return null;
227
+ return undefined;
228
+ });
220
229
  }
221
230
 
222
231
  export const postureOfDir = (d) => leadOfDir(d, discoverNames('postures', 'peer'));
@@ -257,17 +266,16 @@ const ACTIVE_PACKS_RE = /^Active packs:[ \t]*(.+?)[ \t]*$/m;
257
266
  * exactly the empty field a fold leaves behind, which is what makes this catch it.
258
267
  */
259
268
  export function doctrinesOfDir(d) {
260
- for (const carrier of CARRIERS) {
261
- const text = readMaybe(path.join(d, carrier));
262
- if (text === null) continue;
269
+ return firstCarrier(d, (carrierPath) => {
270
+ const text = readMaybe(carrierPath);
271
+ if (text === null) return undefined;
263
272
  const m = ACTIVE_PACKS_RE.exec(text);
264
- if (!m) continue;
273
+ if (!m) return undefined;
265
274
  if (m[1] === 'none') return [];
266
275
  const named = m[1].split(',').map((s) => s.trim());
267
276
  if (!named.every((n) => PACK_ORDER.includes(n))) return null;
268
- return PACK_ORDER.filter((p) => named.includes(p));
269
- }
270
- return null;
277
+ return PACK_ORDER.filter((pk) => named.includes(pk));
278
+ });
271
279
  }
272
280
 
273
281
  /**
@@ -314,11 +322,11 @@ const EXCLUDED_RULES_RE = /^Excluded rules:[ \t]*(.+?)[ \t]*$/m;
314
322
  * take away rules nobody named.
315
323
  */
316
324
  export function excludedRulesOfDir(d) {
317
- for (const carrier of CARRIERS) {
318
- const text = readMaybe(path.join(d, carrier));
319
- if (text === null) continue;
325
+ return firstCarrier(d, (carrierPath) => {
326
+ const text = readMaybe(carrierPath);
327
+ if (text === null) return undefined;
320
328
  const m = EXCLUDED_RULES_RE.exec(text);
321
- if (!m) continue;
329
+ if (!m) return undefined;
322
330
  if (m[1] === 'none') return [];
323
331
  const named = m[1].split(',').map((s) => s.trim());
324
332
  // `<pack> <n>` on the page, `<pack>.<n>` in the code — the same asymmetry the rendered
@@ -329,8 +337,7 @@ export function excludedRulesOfDir(d) {
329
337
  ? `${parts[0]}.${Number(parts[1])}` : null;
330
338
  });
331
339
  return ids.every(Boolean) ? [...new Set(ids)].sort() : [];
332
- }
333
- return [];
340
+ }, []);
334
341
  }
335
342
 
336
343
  // ---- what host a deployed dir belongs to (`_harness_mcp`) ---------------------------------
package/js/hosts/link.mjs CHANGED
@@ -86,7 +86,7 @@ import { toPlatformPath } from '../lib/paths.mjs';
86
86
  const IS_WIN = process.platform === 'win32';
87
87
 
88
88
  /** `_harness_lifecycle._win_bin_dir`. */
89
- export function winBinDir() {
89
+ function winBinDir() {
90
90
  // `os.environ.get(...) or str(Path.home())` — an EMPTY LOCALAPPDATA falls back too,
91
91
  // which `??` would not do.
92
92
  const base = process.env.LOCALAPPDATA || os.homedir();
@@ -173,14 +173,14 @@ function agentColorMap(theme) {
173
173
  cleaned[k] = 'secondary';
174
174
  }
175
175
  }
176
- if (!Object.prototype.hasOwnProperty.call(cleaned, '_default')) cleaned._default = 'secondary';
176
+ if (!Object.hasOwn(cleaned, '_default')) cleaned._default = 'secondary';
177
177
  return cleaned;
178
178
  }
179
179
 
180
180
  /** `_build_emit._agent_color`. */
181
181
  function agentColor(stem, theme) {
182
182
  const colors = agentColorMap(theme);
183
- return Object.prototype.hasOwnProperty.call(colors, stem) ? colors[stem] : colors._default;
183
+ return Object.hasOwn(colors, stem) ? colors[stem] : colors._default;
184
184
  }
185
185
 
186
186
  function isPlainObject(v) {
@@ -195,7 +195,7 @@ function given(v) {
195
195
  /**
196
196
  * `overrides.get(stem) or {}`.
197
197
  *
198
- * The `hasOwnProperty` guard is not ceremony: a bare `overrides[stem]` reaches
198
+ * The `Object.hasOwn` guard is not ceremony: a bare `overrides[stem]` reaches
199
199
  * `Object.prototype` for an agent named `constructor`, `toString` or `valueOf`, and would
200
200
  * hand back a function where Python hands back `None`.
201
201
  *
@@ -206,7 +206,7 @@ function given(v) {
206
206
  * difference on any path the gate covers — a difference in which malformed input crashes.
207
207
  */
208
208
  function agentOverride(overrides, stem) {
209
- return (Object.prototype.hasOwnProperty.call(overrides, stem) && overrides[stem]) || {};
209
+ return (Object.hasOwn(overrides, stem) && overrides[stem]) || {};
210
210
  }
211
211
 
212
212
  /**
@@ -230,30 +230,48 @@ export function pushOverrideLines(fm, ov) {
230
230
  if (given(ov.steps)) fm.push(`steps: ${formatValue(ov.steps)}`);
231
231
  }
232
232
 
233
- /** `_build_emit._claude_agent_frontmatter`. */
234
- function claudeAgentFrontmatter(stem, text, overrides) {
233
+ /**
234
+ * The readonly-tool line `claudeAgentFrontmatter`/`copilotAgentFrontmatter` each emit — a
235
+ * DENYLIST for Claude, an ALLOWLIST for Copilot — differing only in the key, the base list,
236
+ * the one extra entry the `<!-- bash: allow -->` marker toggles, and whether the value is
237
+ * bracketed. `extraWhenBashAllowed` is `false` for Claude (Bash is denied UNLESS the marker
238
+ * allows it) and `true` for Copilot (`execute` is allowed ONLY IF the marker is present) —
239
+ * the same marker, read in the opposite sense a denylist and an allowlist require.
240
+ */
241
+ const READONLY_TOOLS = {
242
+ claude: {
243
+ key: 'disallowedTools', tools: ['Write', 'Edit', 'NotebookEdit', 'WebFetch'],
244
+ extra: 'Bash', extraWhenBashAllowed: false, brackets: false,
245
+ },
246
+ copilot: {
247
+ key: 'tools', tools: ['read', 'search', 'todo', 'agent'],
248
+ extra: 'execute', extraWhenBashAllowed: true, brackets: true,
249
+ },
250
+ };
251
+
252
+ /** `_build_emit._claude_agent_frontmatter` / `._copilot_agent_frontmatter`. */
253
+ function claudeCopilotAgentFrontmatter(host, stem, text, overrides) {
235
254
  const fm = [`name: ${stem}`, `description: ${jsonDumps(descOf(text))}`];
236
255
  const ov = agentOverride(overrides, stem);
237
256
  if (isTruthy(ov.model)) fm.push(`model: ${formatValue(ov.model)}`);
238
257
  if (isReadonly(text)) {
239
- const denied = ['Write', 'Edit', 'NotebookEdit', 'WebFetch'];
240
- if (!text.includes('<!-- bash: allow -->')) denied.push('Bash');
241
- fm.push(`disallowedTools: ${denied.join(', ')}`);
258
+ const {
259
+ key, tools, extra, extraWhenBashAllowed, brackets,
260
+ } = READONLY_TOOLS[host];
261
+ const list = [...tools];
262
+ if (text.includes('<!-- bash: allow -->') === extraWhenBashAllowed) list.push(extra);
263
+ fm.push(`${key}: ${brackets ? `[${list.join(', ')}]` : list.join(', ')}`);
242
264
  }
243
265
  return fm;
244
266
  }
245
267
 
246
- /** `_build_emit._copilot_agent_frontmatter` — `tools:` is an ALLOWLIST, not a denylist. */
268
+ function claudeAgentFrontmatter(stem, text, overrides) {
269
+ return claudeCopilotAgentFrontmatter('claude', stem, text, overrides);
270
+ }
271
+
272
+ /** `tools:` is an ALLOWLIST, not a denylist. */
247
273
  function copilotAgentFrontmatter(stem, text, overrides) {
248
- const fm = [`name: ${stem}`, `description: ${jsonDumps(descOf(text))}`];
249
- const ov = agentOverride(overrides, stem);
250
- if (isTruthy(ov.model)) fm.push(`model: ${formatValue(ov.model)}`);
251
- if (isReadonly(text)) {
252
- const allowed = ['read', 'search', 'todo', 'agent'];
253
- if (text.includes('<!-- bash: allow -->')) allowed.push('execute');
254
- fm.push(`tools: [${allowed.join(', ')}]`);
255
- }
256
- return fm;
274
+ return claudeCopilotAgentFrontmatter('copilot', stem, text, overrides);
257
275
  }
258
276
 
259
277
  /** `_build_emit._opencode_agent_frontmatter`. */
@@ -115,36 +115,47 @@ export function sourceReleaseVersion(cfg) {
115
115
  return '0.0.0';
116
116
  }
117
117
 
118
+ const GRAY = 8;
119
+ const GREEN = 2;
120
+ const RED = 1;
121
+ const YEL = 3;
122
+ const MAG = 5;
123
+ const NONE = 'none';
124
+
125
+ /**
126
+ * `_build_emit._theme_json`'s role -> value map, one entry per `PALETTE_ROLES` member (19 —
127
+ * not the 17 the plan that asked for this derivation guessed; logged as a wrong-plan-claim
128
+ * row). `acc` is the one theme-tinted value; `fn` and `type` happen to share it today but are
129
+ * kept as their own role entries rather than aliased to `accent`, since nothing requires a
130
+ * future theme to tint them together.
131
+ *
132
+ * ONE SLOT IS NOT A PURE FUNCTION OF ITS ROLE, and folding it in here would be wrong rather
133
+ * than tidy: `syntaxPunctuation` carries role `fgMuted`, the same role as `textMuted`,
134
+ * `diffContext`, `diffLineNumber` and `markdownBlockQuote` — all four of which render GRAY —
135
+ * but `syntaxPunctuation` alone has always rendered NONE. A real asymmetry this theme has
136
+ * shipped with, reproduced below as a one-slot override rather than smoothed away, the same
137
+ * pattern `colorThemeJson` already uses for its own `TRANSPARENT_NONE` slots.
138
+ */
139
+ const roleAnsi = (acc) => ({
140
+ accent: acc, secondary: MAG, err: RED, warn: YEL, ok: GREEN,
141
+ fg: NONE, fgMuted: GRAY, border: GRAY,
142
+ bg: NONE, bgPanel: NONE, bgElement: NONE, addBg: NONE, delBg: NONE,
143
+ comment: GRAY, kw: MAG, fn: acc, str: GREEN, num: MAG, type: acc,
144
+ });
145
+
146
+ /** The one slot `roleAnsi`'s role lookup gets wrong on its own — forced to NONE after. */
147
+ const THEME_JSON_NONE_OVERRIDE = new Set(['syntaxPunctuation']);
148
+
118
149
  /** `_build_emit._theme_json` — a COMPLETE terminal-native theme tinted by ACCENT. */
119
150
  export function themeJson(theme) {
120
151
  const raw = theme && theme.ACCENT !== undefined ? theme.ACCENT : 'cyan';
121
152
  const key = String(raw).toLowerCase();
122
- const acc = Object.prototype.hasOwnProperty.call(ANSI, key) ? ANSI[key] : 6;
123
- const GRAY = 8;
124
- const GREEN = 2;
125
- const RED = 1;
126
- const YEL = 3;
127
- const MAG = 5;
128
- const NONE = 'none';
129
- const t = {
130
- primary: acc, secondary: MAG, accent: acc,
131
- error: RED, warning: YEL, success: GREEN, info: acc,
132
- text: NONE, textMuted: GRAY,
133
- background: NONE, backgroundPanel: NONE, backgroundElement: NONE,
134
- border: GRAY, borderActive: acc, borderSubtle: GRAY,
135
- diffAdded: GREEN, diffRemoved: RED, diffContext: GRAY,
136
- diffHunkHeader: acc, diffHighlightAdded: GREEN, diffHighlightRemoved: RED,
137
- diffAddedBg: NONE, diffRemovedBg: NONE, diffContextBg: NONE,
138
- diffLineNumber: GRAY, diffAddedLineNumberBg: NONE, diffRemovedLineNumberBg: NONE,
139
- markdownText: NONE, markdownHeading: acc, markdownLink: MAG,
140
- markdownLinkText: acc, markdownCode: GREEN, markdownBlockQuote: GRAY,
141
- markdownEmph: YEL, markdownStrong: YEL, markdownHorizontalRule: GRAY,
142
- markdownListItem: acc, markdownListEnumeration: acc, markdownImage: MAG,
143
- markdownImageText: acc, markdownCodeBlock: NONE,
144
- syntaxComment: GRAY, syntaxKeyword: MAG, syntaxFunction: acc,
145
- syntaxVariable: NONE, syntaxString: GREEN, syntaxNumber: MAG,
146
- syntaxType: acc, syntaxOperator: MAG, syntaxPunctuation: NONE,
147
- };
153
+ const acc = Object.hasOwn(ANSI, key) ? ANSI[key] : 6;
154
+ const roles = roleAnsi(acc);
155
+ const t = {};
156
+ for (const [slot, role] of Object.entries(SLOT_ROLE)) {
157
+ t[slot] = THEME_JSON_NONE_OVERRIDE.has(slot) ? NONE : roles[role];
158
+ }
148
159
  return { $schema: 'https://opencode.ai/theme.json', theme: t };
149
160
  }
150
161
 
@@ -177,7 +188,7 @@ export function colorThemeJson(palette, transparent) {
177
188
  // `palette[role]` is a plain subscript in Python: a palette missing a role raises
178
189
  // KeyError and aborts the emit. A bare JS lookup would yield undefined, which
179
190
  // JSON.stringify DROPS — silently emitting a theme with missing slots.
180
- if (!Object.prototype.hasOwnProperty.call(palette, role)) {
191
+ if (!Object.hasOwn(palette, role)) {
181
192
  throw new Error(`palette has no role '${role}' (slot '${slot}')`);
182
193
  }
183
194
  t[slot] = palette[role];
@@ -257,7 +268,7 @@ export function writePrimaryAgent(cfg, agentsDir, overrides) {
257
268
  const desc = 'Primary orchestrator — works by the harness Rules and delegates to the '
258
269
  + 'capability subagents.';
259
270
  const fm = [`description: ${jsonDumps(desc)}`, 'mode: primary', 'color: primary'];
260
- const ov = (Object.prototype.hasOwnProperty.call(overrides, 'orchestrator')
271
+ const ov = (Object.hasOwn(overrides, 'orchestrator')
261
272
  && overrides.orchestrator) || {};
262
273
  pushOverrideLines(fm, ov);
263
274
  const dest = path.join(agentsDir, 'orchestrator.md');