geneseed 3.14.0 → 3.16.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 (94) hide show
  1. package/CHANGELOG.md +66 -0
  2. package/SHIPPED.md +1 -1
  3. package/adapters/opencode/plugins/geneseed-context.js +45 -11
  4. package/adapters/opencode/plugins/geneseed-guard.js +16 -4
  5. package/adapters/opencode/plugins/geneseed-learn.js +37 -11
  6. package/adapters/opencode/plugins/geneseed-notify.js +37 -19
  7. package/adapters/opencode/plugins/geneseed-workflow.js +3 -1
  8. package/adapters/opencode/workflows/_runtime.js +16 -3
  9. package/bin/geneseed-cli.mjs +22 -20
  10. package/bin/geneseed-hook.mjs +6 -3
  11. package/docs/concepts/exclusions.md +21 -9
  12. package/docs/guides/install-scripted.md +1 -1
  13. package/docs/guides/project-context.md +1 -1
  14. package/docs/token-footprint.md +10 -7
  15. package/harness.config.json +1 -1
  16. package/js/README.md +9 -5
  17. package/js/build/args.mjs +38 -6
  18. package/js/build/bundle.mjs +10 -10
  19. package/js/build/driver.mjs +16 -14
  20. package/js/build/emit-claude.mjs +3 -2
  21. package/js/build/emit-common.mjs +2 -14
  22. package/js/build/emit-opencode.mjs +12 -8
  23. package/js/build/generate.mjs +26 -16
  24. package/js/build/render.mjs +34 -4
  25. package/js/build/source.mjs +43 -7
  26. package/js/build/stubs.mjs +6 -5
  27. package/js/build/version.mjs +21 -2
  28. package/js/cli-table.json +15 -0
  29. package/js/hosts/hooks-context.mjs +85 -34
  30. package/js/hosts/hooks-prims.mjs +2 -2
  31. package/js/hosts/hosts.mjs +24 -26
  32. package/js/hosts/installs.mjs +58 -16
  33. package/js/hosts/link.mjs +75 -26
  34. package/js/hosts/memory-files.mjs +13 -4
  35. package/js/hosts/native.mjs +106 -48
  36. package/js/hosts/opencode.mjs +46 -36
  37. package/js/hosts/shim.mjs +4 -2
  38. package/js/inspect/diff.mjs +9 -7
  39. package/js/inspect/excludes.mjs +10 -5
  40. package/js/inspect/status.mjs +20 -22
  41. package/js/inspect/validate.mjs +5 -3
  42. package/js/lib/fs.mjs +12 -11
  43. package/js/lib/json.mjs +4 -4
  44. package/js/lib/paths.mjs +27 -33
  45. package/js/lib/proc.mjs +3 -3
  46. package/js/lib/prompt.mjs +132 -0
  47. package/js/lib/text.mjs +18 -4
  48. package/js/lib/udiff.mjs +3 -2
  49. package/js/maintain/memory.mjs +5 -6
  50. package/js/maintain/migrate.mjs +4 -3
  51. package/js/maintain/setup.mjs +20 -135
  52. package/js/maintain/uninstall.mjs +10 -9
  53. package/js/maintain/update.mjs +115 -34
  54. package/js/ui/anim.mjs +8 -12
  55. package/js/ui/cli.mjs +29 -35
  56. package/js/ui/menu.mjs +7 -13
  57. package/js/ui/tui.mjs +14 -123
  58. package/js/web/actions.mjs +87 -389
  59. package/js/web/activity.mjs +1 -1
  60. package/js/web/api.mjs +33 -717
  61. package/js/web/catalog.mjs +527 -0
  62. package/js/web/daemon.mjs +21 -3
  63. package/js/web/handler.mjs +51 -27
  64. package/js/web/jobs.mjs +24 -2
  65. package/js/web/routes.mjs +61 -6
  66. package/js/web/server.mjs +4 -4
  67. package/js/web/state.mjs +138 -0
  68. package/js/web/user-files.mjs +339 -0
  69. package/package.json +1 -1
  70. package/src/AGENT.md.tmpl +1 -1
  71. package/web/dist/assets/{Activity-CjRqZnr-.js → Activity-kpWxd70F.js} +1 -1
  72. package/web/dist/assets/{ActivityDetail-OQTxKBj2.js → ActivityDetail-Ccr942VL.js} +1 -1
  73. package/web/dist/assets/Dashboard-D1lE7jcg.js +2 -0
  74. package/web/dist/assets/{Docs-CvOZ8akL.js → Docs-BHyaggFy.js} +3 -3
  75. package/web/dist/assets/{GreenhouseView-6KDjX8da.js → GreenhouseView-Sc_UDMOE.js} +1 -1
  76. package/web/dist/assets/Installs-Gw-pJ3Zi.js +2 -0
  77. package/web/dist/assets/JournalView-_gxpsxXi.js +1 -0
  78. package/web/dist/assets/{Laws-Du0-Zyp2.js → Laws-S9fq1r9T.js} +1 -1
  79. package/web/dist/assets/{Library-DOkO0TtB.js → Library-MBxadRzn.js} +1 -1
  80. package/web/dist/assets/{Loops-Dv_aLByJ.js → Loops-Da_JhlzM.js} +2 -2
  81. package/web/dist/assets/{OperatorHudView-BPP1dtLz.js → OperatorHudView--TkvDybH.js} +1 -1
  82. package/web/dist/assets/{Personal-26VPYkK0.js → Personal-CTXKHVbV.js} +1 -1
  83. package/web/dist/assets/Tabs-B0cdf8k6.js +1 -0
  84. package/web/dist/assets/api-CxgfYu18.js +1 -0
  85. package/web/dist/assets/drawable-BbWQxe82.js +1 -0
  86. package/web/dist/assets/index-ujKG1ZNf.js +2 -0
  87. package/web/dist/index.html +2 -2
  88. package/web/dist/assets/Dashboard-J-xCe_Gk.js +0 -2
  89. package/web/dist/assets/Installs-MWuhNjSm.js +0 -2
  90. package/web/dist/assets/JournalView-C8MrOz2c.js +0 -1
  91. package/web/dist/assets/Tabs-BzKuFskD.js +0 -1
  92. package/web/dist/assets/api-D_ixdyEY.js +0 -1
  93. package/web/dist/assets/drawable-zVVDbsyw.js +0 -1
  94. package/web/dist/assets/index-KHt3wE_K.js +0 -2
@@ -3,7 +3,7 @@
3
3
  *
4
4
  * A faithful translation of `_build_emit._write_native_layer` and the frontmatter
5
5
  * builders it dispatches to. Capability specs become host-native subagents and skills:
6
- * one dialect per host (OpenCode / Claude Code), vendored skill folders copied
6
+ * one agent dialect per host family (OpenCode / Claude Code), vendored skill folders copied
7
7
  * through verbatim, authoring templates shipped flat.
8
8
  *
9
9
  * WHY THIS IS THE HARD HALF. `js/build/render.mjs` is a pure function of `src/` and `themes/`;
@@ -15,8 +15,7 @@
15
15
  * 2. Claim-on-create decides which files get written AT ALL: a target that already
16
16
  * exists and was not in the previous manifest belongs to the user, and is skipped
17
17
  * with a warning. So the output is a function of what is already on disk.
18
- * 3. Three host dialects diverge inside one loop — including a FILENAME change
19
- * (`<name>.agent.md`) that drags a link rewrite along with it.
18
+ * 3. Two agent dialects diverge inside one loop, on the same filenames.
20
19
  *
21
20
  * None of the three is reachable from the golden harness's happy path: golden emits into
22
21
  * a fresh tree with an empty overrides stub. `tests/unit/user_files.test.mjs` drives
@@ -32,9 +31,10 @@ import { existsSync, mkdirSync } from 'node:fs';
32
31
  import path from 'node:path';
33
32
  import { writeText, copyFile, readText } from '../lib/fs.mjs';
34
33
  import { jsonDumps, parseJson, formatValue, formatRepr, isTruthy, isDict } from '../lib/json.mjs';
34
+ import { relPosix } from '../lib/text.mjs';
35
35
 
36
36
  /** What a folder skill writes where it needs its own directory — see `writeNativeLayer`. */
37
- export const SKILL_DIR_PLACEHOLDER = '<this-skill-directory>';
37
+ const SKILL_DIR_PLACEHOLDER = '<this-skill-directory>';
38
38
 
39
39
  /** Mirrors `_build_core.VENDORED_SKILL_DIRS`. */
40
40
  export const VENDORED_SKILL_DIRS = new Set(['react-view-transitions', 'daydream', 'token-report', 'explain-changes']);
@@ -108,11 +108,6 @@ export function firstBlockquote(text) {
108
108
  return '';
109
109
  }
110
110
 
111
- /** `_build_emit.desc_of`. */
112
- export function descOf(text) {
113
- return firstBlockquote(text);
114
- }
115
-
116
111
  /**
117
112
  * The `**Trigger:**` paragraph of a skill, flattened to one line — markdown links reduced
118
113
  * to their text, HTML comments dropped, whitespace collapsed. '' when the skill has none.
@@ -155,7 +150,7 @@ function triggerOf(text) {
155
150
  */
156
151
  const DESCRIPTION_CAP = 320;
157
152
  function skillDescription(text) {
158
- return withWhen(descOf(text), triggerOf(text));
153
+ return withWhen(firstBlockquote(text), triggerOf(text));
159
154
  }
160
155
 
161
156
  /**
@@ -188,7 +183,7 @@ function dispatchOf(text) {
188
183
 
189
184
  /** The emitted `description:` of an agent — purpose + its first dispatch condition. */
190
185
  export function agentDescription(text) {
191
- return withWhen(descOf(text), dispatchOf(text));
186
+ return withWhen(firstBlockquote(text), dispatchOf(text));
192
187
  }
193
188
 
194
189
  /** Purpose + ` Use when: ` + the first sentence of `trig`, capped — see `skillDescription`. */
@@ -299,11 +294,9 @@ function given(v) {
299
294
  * `Object.prototype` for an agent named `constructor`, `toString` or `valueOf`, and would
300
295
  * hand back a function where Python hands back `None`.
301
296
  *
302
- * Known divergence, recorded rather than papered over: where the value is present but is
303
- * NOT an object (a user writing `"reviewer": "haiku"` instead of
304
- * `"reviewer": {"model": "haiku"}`), Python raises AttributeError and aborts the emit
305
- * while this returns undefined for every key and emits no override lines. Not a byte
306
- * difference on any path the gate covers — a difference in which malformed input crashes.
297
+ * A value that is present but NOT an object (`"reviewer": "haiku"` instead of
298
+ * `"reviewer": {"model": "haiku"}`) never reaches here from a file: `loadAgentOverrides`
299
+ * warns and drops it. A caller handing one in directly gets no override lines.
307
300
  */
308
301
  function agentOverride(overrides, stem) {
309
302
  return (Object.hasOwn(overrides, stem) && overrides[stem]) || {};
@@ -366,13 +359,36 @@ function opencodeAgentFrontmatter(stem, text, overrides, theme = null) {
366
359
  return fm;
367
360
  }
368
361
 
369
- /** `_build_emit._load_agent_overrides`. */
362
+ /** The keys the OpenCode dialect renders. Claude's honours `model` alone — see `writeNativeLayer`. */
363
+ const OVERRIDE_KEYS = ['model', 'temperature', 'variant', 'steps'];
364
+
365
+ /**
366
+ * '' when `v` may be written into a frontmatter line, else why not.
367
+ *
368
+ * Each value lands VERBATIM after `key: `, so a string carrying a line break writes frontmatter
369
+ * keys of its own (`"haiku\npermission: allow"`), and anything `formatValue` cannot render — a
370
+ * boolean, a list, an object — threw a bare TypeError that aborted the emit with a stack trace
371
+ * naming nothing the user wrote.
372
+ */
373
+ function overrideProblem(v) {
374
+ if (typeof v === 'string') return /[\r\n\u2028\u2029]/.test(v) ? 'contains a line break' : '';
375
+ // `parseJson` yields exactly one object `isDict` refuses: its number wrapper.
376
+ const isNumber = typeof v === 'object' && v !== null && !Array.isArray(v) && !isDict(v);
377
+ if (isNumber && Number.isFinite(+v)) return '';
378
+ return 'is not a string or a finite number';
379
+ }
380
+
381
+ /**
382
+ * `_build_emit._load_agent_overrides`, with every value that would break the frontmatter
383
+ * dropped and named on stderr — the user's file, so a bad entry is a warning, never an abort.
384
+ */
370
385
  export function loadAgentOverrides(base) {
386
+ const file = path.join(base, 'agent-overrides.json');
371
387
  let data;
372
388
  try {
373
389
  // parseJson, not JSON.parse: the override values are rendered with `formatValue`, which
374
390
  // needs the int/float distinction the raw literal carries and `JSON.parse` discards.
375
- data = parseJson(readText(path.join(base, 'agent-overrides.json')));
391
+ data = parseJson(readText(file));
376
392
  } catch {
377
393
  // Missing or malformed => no overrides, so agents inherit the host model.
378
394
  // Wider than Python's `except (OSError, json.JSONDecodeError)` by one case: a file
@@ -382,12 +398,72 @@ export function loadAgentOverrides(base) {
382
398
  return {};
383
399
  }
384
400
  const agents = isDict(data) ? data.agents : null;
385
- return isDict(agents) ? agents : {};
401
+ if (!isDict(agents)) return {};
402
+ // In place: `data` is a fresh parse, and deleting keeps a `__proto__`-named entry an own
403
+ // property, where copying into a new object would assign a prototype instead.
404
+ for (const [stem, entry] of Object.entries(agents)) {
405
+ if (entry === null) continue;
406
+ if (!isDict(entry)) {
407
+ warn(`[geneseed] WARN: ${file}: agents.${stem} is not an object — skipped`);
408
+ delete agents[stem];
409
+ continue;
410
+ }
411
+ for (const key of OVERRIDE_KEYS) {
412
+ if (!given(entry[key])) continue;
413
+ const why = overrideProblem(entry[key]);
414
+ if (!why) continue;
415
+ warn(`[geneseed] WARN: ${file}: agents.${stem}.${key} ${why} — skipped`);
416
+ delete entry[key];
417
+ }
418
+ }
419
+ return agents;
386
420
  }
387
421
 
388
- /** `Path.relative_to(base).as_posix()`. */
389
- function relPosix(base, target) {
390
- return path.relative(base, target).split(path.sep).join('/');
422
+ /**
423
+ * Warn once per override key the Claude dialect drops. The seeded `agent-overrides.json`
424
+ * advertises all four keys on every host, and Claude Code's agent frontmatter takes `model`
425
+ * only — so a `temperature` set there was silently a no-op.
426
+ */
427
+ function warnClaudeIgnored(overrides) {
428
+ const ignored = new Set();
429
+ for (const entry of Object.values(overrides)) {
430
+ if (!isDict(entry)) continue;
431
+ for (const key of OVERRIDE_KEYS.slice(1)) if (given(entry[key])) ignored.add(key);
432
+ }
433
+ for (const key of ignored) {
434
+ warn(`[geneseed] WARN: agent-overrides.json sets '${key}', which Claude-family hosts do `
435
+ + "not support — only 'model' is applied");
436
+ }
437
+ }
438
+
439
+ /**
440
+ * Claim-on-create, as a closure over one emit's prior manifest: `claim(dest)` is true when
441
+ * `dest` may be (over)written, false for a pre-existing file no previous emit owned — the
442
+ * user's, left alone, named on stderr, and kept out of the manifest by the caller.
443
+ *
444
+ * Its own function because every writer whose path lands in `owned` needs it, and uninstall
445
+ * deletes what `owned` names: the OpenCode command and primary-agent writers pushed their
446
+ * paths without it, so uninstall removed a user's own `command/commit.md`. ONE closure per
447
+ * emit, shared by all of them, so the pre-manifest header still prints once.
448
+ *
449
+ * `oldOwned === null` (or no `cfg`) is a caller doing no ownership at all: claim everything.
450
+ */
451
+ export function claimer(oldOwned, cfg, manifestExisted = true) {
452
+ const oldSet = oldOwned !== null && oldOwned !== undefined ? new Set(oldOwned) : null;
453
+ let headerPrinted = false;
454
+ return (dest) => {
455
+ if (oldSet === null || cfg === null || !existsSync(dest)) return true;
456
+ const rel = relPosix(cfg, dest);
457
+ if (oldSet.has(rel)) return true;
458
+ if (!manifestExisted && !headerPrinted) {
459
+ warn('[geneseed] first emit over a pre-manifest install — existing files '
460
+ + 'are treated as yours');
461
+ headerPrinted = true;
462
+ }
463
+ warn(`[geneseed] kept your existing ${rel} — skipped Geneseed's copy to avoid `
464
+ + 'clobbering it');
465
+ return false;
466
+ };
391
467
  }
392
468
 
393
469
  /**
@@ -406,6 +482,9 @@ function relPosix(base, target) {
406
482
  * has the placeholder replaced by the answer. Only vendored folders carry the placeholder — a
407
483
  * flat skill has no siblings to point at.
408
484
  *
485
+ * `opts.claim` — a `claimer` closure the caller shares with its other writers; built from
486
+ * `oldOwned`/`cfg`/`manifestExisted` when absent.
487
+ *
409
488
  * Returns `{ nAgents, nSkills, written }` — Python's 3-tuple, with `written` as absolute
410
489
  * paths in write order.
411
490
  *
@@ -419,30 +498,13 @@ function relPosix(base, target) {
419
498
  */
420
499
  export function writeNativeLayer(items, agentsDir, skillsDir, overrides = null, {
421
500
  host = 'opencode', oldOwned = null, cfg = null, manifestExisted = true,
422
- theme = null, src, skillDirOf = null,
501
+ theme = null, src, skillDirOf = null, claim = claimer(oldOwned, cfg, manifestExisted),
423
502
  } = {}) {
424
503
  const ov = overrides || {};
425
- const oldSet = oldOwned !== null && oldOwned !== undefined ? new Set(oldOwned) : null;
504
+ if (host === 'claude') warnClaudeIgnored(ov);
426
505
  let nAgents = 0;
427
506
  let nSkills = 0;
428
507
  const written = [];
429
- let headerPrinted = false;
430
-
431
- // True -> ok to (over)write; false -> a pre-existing file we never owned, so it is the
432
- // user's: leave it, warn, and keep it out of the manifest.
433
- const claim = (dest) => {
434
- if (oldSet === null || cfg === null || !existsSync(dest)) return true;
435
- const rel = relPosix(cfg, dest);
436
- if (oldSet.has(rel)) return true;
437
- if (!manifestExisted && !headerPrinted) {
438
- warn('[geneseed] first emit over a pre-manifest install — existing files '
439
- + 'are treated as yours');
440
- headerPrinted = true;
441
- }
442
- warn(`[geneseed] kept your existing ${rel} — skipped Geneseed's copy to avoid `
443
- + 'clobbering it');
444
- return false;
445
- };
446
508
 
447
509
  const write = (dest, text) => {
448
510
  mkdirSync(path.dirname(dest), { recursive: true });
@@ -498,13 +560,9 @@ export function writeNativeLayer(items, agentsDir, skillsDir, overrides = null,
498
560
  let dest;
499
561
  let kind;
500
562
  if (folder === 'agents') {
501
- if (host === 'claude') {
502
- fm = claudeAgentFrontmatter(stem, text, ov);
503
- dest = path.join(agentsDir, `${stem}.md`);
504
- } else {
505
- fm = opencodeAgentFrontmatter(stem, text, ov, theme);
506
- dest = path.join(agentsDir, `${stem}.md`);
507
- }
563
+ fm = host === 'claude' ? claudeAgentFrontmatter(stem, text, ov)
564
+ : opencodeAgentFrontmatter(stem, text, ov, theme);
565
+ dest = path.join(agentsDir, `${stem}.md`);
508
566
  kind = 'agent';
509
567
  } else {
510
568
  // Skills are BYTE-IDENTICAL across hosts: name + description, body link-stripped.
@@ -16,18 +16,21 @@
16
16
  *
17
17
  * TWO STREAMS, AND THE SPLIT IS NOT TIDY. `_warn_if_overrides_stale` prints to STDOUT
18
18
  * while every warning in `js/hosts/native.mjs` goes to stderr. That is the Python's own
19
- * inconsistency and it is reproduced, not corrected: the parity gate compares both
20
- * specification, and `tests/unit/agent_overrides.test.mjs` pins the stale notice to stdout.
21
- * It is safe because this is the
22
- * generator's CLI, which prints progress to stdout by design — unlike a hook path, where
23
- * a stray stdout byte silently disarms a blocking gate.
19
+ * inconsistency and it is reproduced, not corrected: `tests/unit/agent_overrides.test.mjs`
20
+ * pins the stale notice to stdout. It is safe because this is the generator's CLI, which
21
+ * prints progress to stdout by design — unlike a hook path, where a stray stdout byte
22
+ * silently disarms a blocking gate.
24
23
  */
25
24
  import { existsSync, mkdirSync, readdirSync, statSync } from 'node:fs';
26
25
  import path from 'node:path';
27
26
  import { writeText, readText, copyFile } from '../lib/fs.mjs';
28
27
  import { jsonDumps, jsonDumpsIndent, parseJson } from '../lib/json.mjs';
29
28
  import { comparePaths } from '../lib/paths.mjs';
30
- import { descOf, stripSkillBodyLinks, pushOverrideLines } from './native.mjs';
29
+ import { sourceReleaseVersion } from '../build/version.mjs';
30
+ import { firstBlockquote, stripSkillBodyLinks, pushOverrideLines } from './native.mjs';
31
+
32
+ // Re-exported for `js/web/api.mjs` and the tests, which import it from here.
33
+ export { sourceReleaseVersion };
31
34
 
32
35
  /** ANSI colour-name -> integer (0-7). Mirrors `_build_emit._ANSI`. */
33
36
  const ANSI = {
@@ -94,9 +97,17 @@ const AGENT_OVERRIDES_STUB = {
94
97
  },
95
98
  };
96
99
 
97
- /** Mirrors `_build_emit.COMMAND_SET` — the hot skills worth a one-keystroke trigger. */
98
- const COMMAND_SET = ['commit', 'plan', 'code-review', 'review-response',
99
- 'ship', 'debug', 'research'];
100
+ /**
101
+ * `_build_emit.COMMAND_SET` — the hot skills worth a one-keystroke trigger, as command name ->
102
+ * skill name. A map, not a list, because the two can differ: `code-review` became
103
+ * `geneseed-code-review` (so it no longer shadows a host built-in) while the command stays
104
+ * `/code-review`. As a list the renamed entry missed its skill and was dropped in silence, so a
105
+ * missing skill is now a thrown error in `writeCommandLayer`.
106
+ */
107
+ const COMMAND_SET = {
108
+ commit: 'commit', plan: 'plan', 'code-review': 'geneseed-code-review',
109
+ 'review-response': 'review-response', ship: 'ship', debug: 'debug', research: 'research',
110
+ };
100
111
 
101
112
  /** Mirrors `_build_emit.PONYTAIL_COMMAND_BODY`. */
102
113
  const PONYTAIL_COMMAND_BODY =
@@ -113,22 +124,6 @@ function truthyEnv(name) {
113
124
  return ['1', 'on', 'true', 'yes'].includes((process.env[name] || '').toLowerCase());
114
125
  }
115
126
 
116
- /**
117
- * `_build_core.source_release_version`. It reads `cfg.config`, so it takes `cfg` rather than
118
- * importing core — and it stayed HERE when core landed as `js/build/source.mjs`, which is why
119
- * `js/build/version.mjs` reaches back into the OpenCode module for it.
120
- */
121
- export function sourceReleaseVersion(cfg) {
122
- try {
123
- const data = parseJson(readText(cfg.config));
124
- const v = data && typeof data === 'object' && !Array.isArray(data) ? data.version : null;
125
- if (typeof v === 'string' && v) return v;
126
- } catch {
127
- // OSError / JSONDecodeError — degrade to "not newer" rather than raising.
128
- }
129
- return '0.0.0';
130
- }
131
-
132
127
  const GRAY = 8;
133
128
  const GREEN = 2;
134
129
  const RED = 1;
@@ -181,7 +176,6 @@ export function writeTheme(themesDir, themeName, theme) {
181
176
  return dest;
182
177
  }
183
178
 
184
- /** `_build_emit._color_theme_json`. */
185
179
  /**
186
180
  * `_build_emit._PALETTE_ROLES` — every role some slot reads, as a set.
187
181
  *
@@ -192,6 +186,7 @@ export function writeTheme(themesDir, themeName, theme) {
192
186
  */
193
187
  export const PALETTE_ROLES = new Set(Object.values(SLOT_ROLE));
194
188
 
189
+ /** `_build_emit._color_theme_json`. */
195
190
  export function colorThemeJson(palette, transparent) {
196
191
  const t = {};
197
192
  for (const [slot, role] of Object.entries(SLOT_ROLE)) {
@@ -272,8 +267,16 @@ export function ensureAgentOverridesStub(cfg, base) {
272
267
  writeText(dest, `${jsonDumpsIndent(stub, { ensureAscii: false })}\n`);
273
268
  }
274
269
 
275
- /** `_build_emit._write_primary_agent` — the opt-in `mode: primary` orchestrator. */
276
- export function writePrimaryAgent(cfg, agentsDir, overrides) {
270
+ /**
271
+ * `_build_emit._write_primary_agent` — the opt-in `mode: primary` orchestrator.
272
+ *
273
+ * `claim` is `claimer`'s closure from `js/hosts/native.mjs`, and this and the two command
274
+ * writers below take it for the reason `writeNativeLayer` does: their paths are pushed into
275
+ * `owned`, and uninstall deletes what `owned` names — so writing over a user's own
276
+ * `command/commit.md` without the check made uninstall delete it. Default: claim everything,
277
+ * for a caller with no manifest to consult.
278
+ */
279
+ export function writePrimaryAgent(cfg, agentsDir, overrides, claim = () => true) {
277
280
  if (!truthyEnv('GENESEED_PRIMARY') || !existsSync(cfg.primaryAgentSrc)
278
281
  || !statSync(cfg.primaryAgentSrc).isFile()) {
279
282
  return null;
@@ -286,13 +289,14 @@ export function writePrimaryAgent(cfg, agentsDir, overrides) {
286
289
  && overrides.orchestrator) || {};
287
290
  pushOverrideLines(fm, ov);
288
291
  const dest = path.join(agentsDir, 'orchestrator.md');
292
+ if (!claim(dest)) return null;
289
293
  mkdirSync(path.dirname(dest), { recursive: true });
290
294
  writeText(dest, `---\n${fm.join('\n')}\n---\n\n${body}`);
291
295
  return dest;
292
296
  }
293
297
 
294
298
  /** `_build_emit._write_command_layer` — the opt-in /slash commands. */
295
- export function writeCommandLayer(cfg, items, commandDir) {
299
+ export function writeCommandLayer(cfg, items, commandDir, claim = () => true) {
296
300
  if (!truthyEnv('GENESEED_COMMANDS')) return [];
297
301
  const byName = new Map();
298
302
  for (const { text, src } of items) {
@@ -304,12 +308,17 @@ export function writeCommandLayer(cfg, items, commandDir) {
304
308
  }
305
309
  }
306
310
  const written = [];
307
- for (const name of COMMAND_SET) {
308
- const text = byName.get(name);
309
- if (text === undefined) continue;
310
- const desc = descOf(text);
311
- const body = stripSkillBodyLinks(text.replace(/^\n+/, ''));
311
+ for (const [name, skill] of Object.entries(COMMAND_SET)) {
312
+ // A command runs a skill; `--exclude-skills` took that skill out, so the command goes too.
313
+ if ((cfg.excludeSkills ?? []).includes(skill)) continue;
314
+ const text = byName.get(skill);
315
+ if (text === undefined) {
316
+ throw new Error(`COMMAND_SET: /${name} runs skill '${skill}', which src/skills/ does not have`);
317
+ }
312
318
  const dest = path.join(commandDir, `${name}.md`);
319
+ if (!claim(dest)) continue;
320
+ const desc = firstBlockquote(text);
321
+ const body = stripSkillBodyLinks(text.replace(/^\n+/, ''));
313
322
  mkdirSync(path.dirname(dest), { recursive: true });
314
323
  writeText(dest, `---\ndescription: ${jsonDumps(desc)}\n---\n\n${body}`);
315
324
  written.push(dest);
@@ -317,9 +326,10 @@ export function writeCommandLayer(cfg, items, commandDir) {
317
326
  return written;
318
327
  }
319
328
 
320
- /** `_build_emit._write_ponytail_command` — registered UNCONDITIONALLY. */
321
- export function writePonytailCommand(commandDir) {
329
+ /** `_build_emit._write_ponytail_command` — registered UNCONDITIONALLY; null when the user's. */
330
+ export function writePonytailCommand(commandDir, claim = () => true) {
322
331
  const dest = path.join(commandDir, 'ponytail.md');
332
+ if (!claim(dest)) return null;
323
333
  mkdirSync(path.dirname(dest), { recursive: true });
324
334
  const desc = 'Set the ponytail minimal-code level for the session: lite | full | ultra | off';
325
335
  writeText(dest, `---\ndescription: ${jsonDumps(desc)}\n---\n\n${PONYTAIL_COMMAND_BODY}`);
package/js/hosts/shim.mjs CHANGED
@@ -273,7 +273,8 @@ export function hookPrefix({ runner, entry, platform = process.platform } = {})
273
273
  * interpreter silently disables every hook in the install. Both halves are gone. `runner` is
274
274
  * `process.execPath` — the node already running this file, which by construction exists —
275
275
  * so there is nothing left to discover and no way for discovery to fail. P4e kept the
276
- * unreachable exit-3 branch because `test_the_node_driver_classifies_every_emit` asserts a
276
+ * unreachable exit-3 branch because 'the driver classifies every emit'
277
+ * (`tests/unit/node_driver.test.mjs`) asserts a
277
278
  * partition it belongs to; nothing asserts a partition over this one, so keeping it would
278
279
  * be keeping code no test can reach for no stated reason.
279
280
  *
@@ -287,7 +288,8 @@ export function hookPrefix({ runner, entry, platform = process.platform } = {})
287
288
  *
288
289
  * That is correct exactly while the two entry points answer the same verbs the same way, so
289
290
  * the gate that used to be a formality is now load-bearing:
290
- * `test_the_entry_carries_exactly_the_verbs_the_emitter_wires` reads the emitter's wiring
291
+ * 'the hook entry carries exactly the verbs the emitter wires' (`tests/unit/hook_cli.test.mjs`)
292
+ * reads the emitter's wiring
291
293
  * and `bin/geneseed-hook.mjs`'s VERBS table and requires them EQUAL — a wired verb the entry
292
294
  * lacks is a dead hook on every install on the machine, not just this one. The alternative
293
295
  * considered and rejected was a per-driver shim path: the path is baked into every already
@@ -22,11 +22,10 @@
22
22
  * * **The diff algorithm.** A cell can produce ONE edited file and see one hunk. It cannot
23
23
  * produce the shapes where `difflib`'s choices differ from any other diff's — ties, the
24
24
  * recursion's alignment, and above all `autojunk`, which only engages at 200 lines and
25
- * changes the hunks on every real harness file. Gated as a corpus —
26
- * `tests/fixtures/pure_probe.mjs`'s `unified_diff` entry, replayed by
27
- * `tests/snapshot/pure_snapshot.test.mjs` against `tests/__snapshots__/primitives/` —
28
- * measured in both directions (28 of the 176 cases there disagree when `autojunk` is
29
- * switched off, so the corpus is not describing a constant).
25
+ * changes the hunks on every real harness file. The former Python suite gated it as a
26
+ * corpus, measured in both directions (28 of its 176 cases disagreed when `autojunk` was
27
+ * switched off). That corpus is gone and no Node test drives `unifiedDiff` directly, so
28
+ * beyond the one hunk the `diff/--full-…` cell sees, nothing pins these shapes today.
30
29
  * * **The timestamp.** `_write_improvements` names the file `improvements-%Y%m%d-%H%M%S.md`
31
30
  * and stamps the report with `datetime.now()`, so the two implementations cannot write the
32
31
  * same bytes across a second boundary — and a cell runs them in sequence, so the boundary
@@ -45,7 +44,8 @@ import {
45
44
  GLOBAL_MANIFEST, VERSION_MARKER, expanduser, opencodeConfigDir, resolvePath,
46
45
  } from '../hosts/hosts.mjs';
47
46
  import {
48
- EMIT_HOST_SCOPE, defaultTheme, doctrinesForBuild, excludedRulesOfDir, footprintOfDir, modeOfDir, postureOfDir,
47
+ EMIT_HOST_SCOPE, defaultTheme, doctrinesForBuild, excludedRulesOfDir, excludedSkillsOfDir,
48
+ footprintOfDir, modeOfDir, postureOfDir,
49
49
  trustOfDir,
50
50
  readJsonMaybe, readMaybe, themeOfDir,
51
51
  } from '../hosts/installs.mjs';
@@ -177,10 +177,12 @@ export function diffCollect({ target = null, theme = null, emit = null, footprin
177
177
  // makes explicit rather than leaving to `makeCfg`'s parameter default two layers down.
178
178
  posture: postureOfDir(dir), mode: modeOfDir(dir), doctrines: doctrinesForBuild(dir),
179
179
  // The loop skill's preset, or its `Default trust preset:` line reads as edited.
180
- trust: trustOfDir(dir),
180
+ trust: trustOfDir(dir, host),
181
181
  // The excluded rules, or an install built `--exclude-rules "process 5"` reads its
182
182
  // carrier as drift: `expected` would re-state the rule the deployment took away.
183
183
  excludeRules: excludedRulesOfDir(dir),
184
+ // The excluded skills, or every skill the install left out reads as deleted.
185
+ excludeSkills: excludedSkillsOfDir(dir),
184
186
  }));
185
187
  // The one place a render depends on WHERE it is installed: a global Bob emit writes each
186
188
  // folder skill's absolute directory into its SKILL.md (see `writeNativeLayer`), so the
@@ -78,14 +78,19 @@ const canon = (p) => resolvePath(p);
78
78
  /**
79
79
  * `_same` — is this stored entry the same folder as `repo`?
80
80
  *
81
- * `toPlatformPath` and not `path.normalize`: the latter collapses `a/../b` to `b`, and Python's
82
- * `Path()` does not, so a hand-edited entry carrying `..` matches here and would not match
83
- * there. Getting that backwards makes `exclude remove` unwire a repo the reference left
84
- * alone.
81
+ * Resolved like `repo` itself (`canon`: `..` collapsed, symlinks followed), the one rule the
82
+ * hook's `sovereignBypass` and the OpenCode plugins apply too — so an entry that EXCLUDES a
83
+ * folder is exactly the entry `exclude remove` can take back. It used to compare lexically,
84
+ * mirroring the former Python `Path()`, which made a hand-edited `<repo>/../repo` an entry the
85
+ * plugins honoured, the hook ignored, and `remove` could not find.
85
86
  */
86
87
  function same(stored, repo) {
87
88
  if (typeof stored !== 'string' || !stored) return false;
88
- return normcase(toPlatformPath(stored)) === normcase(repo);
89
+ try {
90
+ return normcase(canon(stored)) === normcase(repo);
91
+ } catch {
92
+ return false; // a `~user` form `expanduser` refuses names no folder
93
+ }
89
94
  }
90
95
 
91
96
  /**
@@ -34,25 +34,26 @@
34
34
  * became the second and third. Nothing about them changed in the move — the 166-cell
35
35
  * acceptance matrix is what licensed attempting it, `status`'s own eleven cells among them.
36
36
  *
37
- * WHAT NO CELL CAN REACH, and where it is gated instead. `tests/harness_golden.py`'s
38
- * status/version section carries the long form; the short form is that ROOT is not
37
+ * WHAT NO CELL CAN REACH, and where it is gated instead. The former Python matrix's
38
+ * status/version section carried the long form; the short form is that ROOT is not
39
39
  * redirectable across a process boundary, so three things here are unreachable from any
40
40
  * cell: `versionVerdict`'s "up to date" (needs a marker holding a fingerprint no cell can
41
41
  * know), `accentFor`'s cyan fallback (an unknown theme is refused upstream by
42
42
  * `effectiveTheme`), and the whole ANSI half of `statusLines` (`_color_enabled` is
43
43
  * `sys.stdout.isatty()`, and every harness captures stdout through a pipe). All three are
44
- * PURE FUNCTIONS of their arguments, so all three are gated as a corpus instead:
45
- * `tests/fixtures/pure_probe.mjs` calls them and `tests/snapshot/pure_snapshot.test.mjs`
46
- * replays that against `tests/__snapshots__/primitives/`. That was a third answer to the
47
- * colour question, which had been posed as a choice between shipping it ungated and not
48
- * shipping it.
44
+ * PURE FUNCTIONS of their arguments, so the former Python suite gated all three as a corpus
45
+ * instead — a third answer to the colour question, which had been posed as a choice between
46
+ * shipping it ungated and not shipping it. That corpus is gone: `tests/unit/lifecycle.test.mjs`
47
+ * now calls `versionVerdict` and both halves of `statusLines` directly, and nothing pins
48
+ * `accentFor`'s fallback.
49
49
  *
50
50
  * `manifestIsClaude` was a FOURTH until wave 2 of the P0/P1 review: it is only reached for
51
51
  * a candidate with no known host, and `ROOT/"Harness"` was ordered ahead of the sandbox's
52
52
  * own. Both ROOT-relative bundle candidates are gone from this walk and from
53
53
  * `installedDefaults`, so the only no-known-host candidate left is `cwd/"Harness"`, which
54
54
  * is inside the sandbox. It is reachable now; no cell seeds a manifest without an emit
55
- * marker, so it is still gated only by the corpus, and that is a gap rather than a wall.
55
+ * marker, so it is gated only by the direct calls in `tests/unit/claude.test.mjs`, and that is
56
+ * a gap rather than a wall.
56
57
  */
57
58
  import { existsSync, readdirSync, readFileSync } from 'node:fs';
58
59
  import path from 'node:path';
@@ -66,19 +67,15 @@ import {
66
67
  } from '../hosts/hosts.mjs';
67
68
  // P5f moved the install DETECTORS out of this file — `diff` renders its expected copy in the
68
69
  // deployed theme and footprint, and `rebuild-all` re-emits in the deployed everything, so
69
- // three verbs now read them. `defaultTheme`, `manifestIsClaude` and `installedDefaults` are
70
- // re-exported below because `tests/fixtures/pure_probe.mjs` names this module for two of them
71
- // and a corpus that followed the code to its new file would stop testing the caller's view.
70
+ // three verbs now read them.
72
71
  import {
73
- defaultTheme, installedDefaults, installTargets, manifestIsClaude, readJsonMaybe, readMaybe,
72
+ defaultTheme, installedDefaults, installTargets, readJsonMaybe, readMaybe,
74
73
  } from '../hosts/installs.mjs';
75
74
  import { installProfile, rebuildCommand } from '../build/generate.mjs';
76
75
  import { shimDead } from '../hosts/shim.mjs';
77
76
  import { printOut } from '../lib/fs.mjs';
78
77
  import { codePointLength, padEndToWidth } from '../lib/text.mjs';
79
78
 
80
- export { defaultTheme, manifestIsClaude };
81
-
82
79
  // ---- version ---------------------------------------------------------------------------
83
80
 
84
81
  /** `_harness_status._version_verdict`. */
@@ -331,13 +328,13 @@ export function statusLines(d, color = false) {
331
328
  + `(${d.facts} fact${d.facts === 1 ? '' : 's'})`],
332
329
  ['version', `${d.installed_fp || '(none)'} ${DOT} source ${d.source_fp}`],
333
330
  ];
334
- // ⚠ A ROW, NOT A WIDER `components` LINE, AND THE REASON IS THAT THE CORPUS CANNOT BE
335
- // RE-BLESSED. `statusLines` has 30 recorded cases per platform in
336
- // `tests/__snapshots__/primitives/`, every one of them holding the box's exact bytes —
337
- // including `17 agents · 47 skills · 37 laws` — and the recorder was Python and is gone.
338
- // Touching `components` moves all 30; adding a row would too, since the box width is
339
- // measured over `rows`. So the row is CONDITIONAL on a key the recorded arguments do not
340
- // carry, exactly as `agent_md` below already is, and the recording renders unchanged.
331
+ // ⚠ A ROW, NOT A WIDER `components` LINE. When this landed, `statusLines` had 30 recorded
332
+ // cases per platform in the former Python suite's corpus, every one holding the box's exact
333
+ // bytes — including `17 agents · 47 skills · 37 laws` — and no recorder left to re-bless them.
334
+ // Touching `components` would have moved all 30; adding a row would too, since the box width
335
+ // is measured over `rows`. So the row is CONDITIONAL on a key those arguments did not carry,
336
+ // exactly as `agent_md` below already is. The corpus is gone; a caller without the key still
337
+ // gets the box it got before.
341
338
  //
342
339
  // It carries the two tiers `components` cannot: `components` counts the invariants as
343
340
  // `laws`, which is still true, and says nothing about the ontology or the packs.
@@ -373,7 +370,8 @@ export function statusLines(d, color = false) {
373
370
  const packs = p.doctrines.length ? p.doctrines.join(',') : 'no packs';
374
371
  const excl = p.excludeRules?.length ? `excluding ${p.excludeRules.join(',')}` : 'no rules excluded';
375
372
  rows.push([`${p.host}:${p.scope}`, [p.state, p.theme, p.footprint, `${p.posture}/${p.mode}/${p.trust}`,
376
- packs, excl].join(` ${DOT} `) + where]);
373
+ packs, excl, ...(p.excludeSkills?.length ? [`skills off: ${p.excludeSkills.join(',')}`] : [])]
374
+ .join(` ${DOT} `) + where]);
377
375
  }
378
376
 
379
377
  const labelW = Math.max(...rows.map(([k]) => codePointLength(k)));
@@ -32,7 +32,8 @@ import { isDir, isFile, rglob, withTempDir, within } from './scan.mjs';
32
32
  export const VALIDATE_USAGE = [
33
33
  'usage: geneseed validate [-h] [--theme THEME] [--emit EMIT] [--out OUT] [--root ROOT]',
34
34
  ' [--footprint {lean,full}] [--posture POSTURE] [--mode MODE]',
35
- ' [--trust TRUST] [--doctrines DOCTRINES] [--exclude-rules RULES] [-v]',
35
+ ' [--trust TRUST] [--doctrines DOCTRINES] [--exclude-rules RULES]',
36
+ ' [--exclude-skills SKILLS] [-v]',
36
37
  '',
37
38
  "Render and emit into a throwaway sandbox, run the doctor's checks, and write nothing real.",
38
39
  "Takes the generator's render flags (see geneseed-build --help) except --sync-themes and",
@@ -127,6 +128,7 @@ export function cmdValidate(args) {
127
128
  const opts = {
128
129
  theme: args.theme, footprint: args.footprint, posture: args.posture, mode: args.mode,
129
130
  trust: args.trust, doctrines: args.doctrines, excludeRules: args.excludeRules,
131
+ excludeSkills: args.excludeSkills,
130
132
  };
131
133
  let scanDirs;
132
134
  try {
@@ -149,8 +151,8 @@ export function cmdValidate(args) {
149
151
  // which ends `raise SystemExit(res.get("exit", 1))` — an INTEGER — so every refusal
150
152
  // that crosses the seam arrives here as `SystemExit(1)` and `str(e)` is `'1'`, not the
151
153
  // sentence `load_theme` wrote on stderr on the way out. A port that printed `e.message`
152
- // says `unknown theme 'x'` where the reference says `1`. The corpus in
153
- // `tests/test_maintainer_tools_parity.py` is what found it; nothing else could.
154
+ // says `unknown theme 'x'` where the reference says `1`. The former Python
155
+ // cross-implementation corpus is what found it; nothing else could.
154
156
  if (e && e.exitCode !== undefined) {
155
157
  printOut(`[validate-only] render/emit FAILED for theme '${args.theme}' `
156
158
  + `emit '${emit}': ${e.exitCode}\n`);