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
@@ -7,7 +7,8 @@
7
7
  * and each alone would be enough:
8
8
  *
9
9
  * * The generator driver is under a hard `child_process` ban
10
- * (`test_the_driver_imports_no_child_process_module`), which is half of the proof that
10
+ * (`tests/unit/hook_cli.test.mjs`'s 'the generator driver still reaches no child-process
11
+ * module'), which is half of the proof that
11
12
  * it is a real implementation rather than a passthrough to `build.py`. `learn` MUST
12
13
  * spawn — the whole verb is "hand these notes to whatever `$GENESEED_LLM` names".
13
14
  * Putting it in that file means either breaking the gate or weakening it.
@@ -15,7 +16,8 @@
15
16
  * the Python side; a Node twin needs its own, invoked as `<node> <this> context
16
17
  * --root "<cfg>"`.
17
18
  * * `bin/build-driver.mjs` parses generator FLAGS (`--emit`, `--theme`, `--footprint`), and
18
- * `test_the_node_driver_classifies_every_emit` asserts a partition over them. Verbs
19
+ * 'the driver classifies every emit' (`tests/unit/node_driver.test.mjs`) asserts a
20
+ * partition over them. Verbs
19
21
  * are a different shape and would sit awkwardly inside that.
20
22
  *
21
23
  * WHAT BAKES THIS FILE, SINCE P5b. `bin/build-driver.mjs` writes `<node> <checkout>/bin/
@@ -123,7 +125,8 @@ async function main(argv) {
123
125
  if (!spec) {
124
126
  // "elsewhere", not a file name: naming `bin/geneseed-cli.mjs` here would mean this entry
125
127
  // carrying a copy of its sibling's verb table — the one thing
126
- // `test_the_two_entry_points_carry_disjoint_verb_sets` exists to keep from happening.
128
+ // 'the two entry points carry disjoint verb sets' (`tests/unit/hook_cli.test.mjs`) exists to
129
+ // keep from happening.
127
130
  // The COMMAND is not a table: `geneseed` is this package's `bin` entry for the CLI
128
131
  // (package.json), it answers every non-hook verb, and it survives the deletion of the
129
132
  // interpreter-plus-script invocation this line used to print.
@@ -4,18 +4,18 @@ order: 12
4
4
  title: "Exclusions"
5
5
  kind: "concept"
6
6
  section: "Configuration"
7
- description: "Two ways to switch things off: one rule, or every hook in a folder."
7
+ description: "Three ways to switch things off: one rule, one skill, or every hook in a folder."
8
8
  link: {"hash": "#/harness", "label": "Manage in Harness →"}
9
9
  ---
10
- Geneseed has two different ways to switch something off, and they work at different scales:
10
+ Geneseed has three ways to switch something off, and they work at different scales:
11
11
 
12
- | | Sovereign repo | Per-rule exclusion |
13
- | --- | --- | --- |
14
- | **Switches off** | the whole global install, inside one folder | one doctrine rule, everywhere the install applies |
15
- | **Scope** | a folder and everything under it | the install |
16
- | **Applies to** | **global** installs only | any install |
17
- | **Set with** | `geneseed exclude add <path>` | `--exclude-rules` at build time |
18
- | **Takes effect** | on the next tool call, no rebuild | on rebuild |
12
+ | | Sovereign repo | Per-rule exclusion | Per-skill exclusion |
13
+ | --- | --- | --- | --- |
14
+ | **Switches off** | the whole global install, inside one folder | one doctrine rule, everywhere the install applies | one skill, everywhere the install applies |
15
+ | **Scope** | a folder and everything under it | the install | the install |
16
+ | **Applies to** | **global** installs only | any install | any install |
17
+ | **Set with** | `geneseed exclude add <path>` | `--exclude-rules` at build time | `--exclude-skills` at build time |
18
+ | **Takes effect** | on the next tool call, no rebuild | on rebuild | on rebuild |
19
19
 
20
20
  > **You already know this:** `.gitignore` versus disabling one lint rule. One makes a tool ignore a whole folder; the other keeps the tool running everywhere with one check turned off.
21
21
 
@@ -55,3 +55,15 @@ geneseed build --emit claude-global --exclude-rules "process 7"
55
55
  ```
56
56
 
57
57
  The install records it (`Excluded rules: process 7` beside its pack list) and every rebuild and upgrade keeps it. The always-on Rules and the Ethos cannot be excluded. Picking packs and rules is covered in [Doctrine packs and single rules](../guides/setup-doctrines.md); what the layers are is in [Rules](rules.md).
58
+
59
+ ### Per-skill exclusion — skills you never use
60
+
61
+ Every installed skill costs a line in the host's skill listing, every session. Skills you never use can be left out of an install:
62
+
63
+ ```bash
64
+ geneseed build --emit claude-global --exclude-skills "bruno-test-writer,bruno-collection-generator,react-view-transitions"
65
+ ```
66
+
67
+ The skill's files are not written, its catalogue row goes, and an OpenCode `/command` that runs it goes too. The install records the list (`Excluded skills: …` under its skill section), and every rebuild and upgrade keeps it. `--exclude-skills none` puts them all back.
68
+
69
+ A few skills cannot be excluded, because the harness itself links to them by path — `council`, `rule`, `skill-forge`, `workflow` and a handful more. The flag names them when it refuses one. Skills you installed yourself are not Geneseed's to exclude: remove those from the host's skills folder directly.
@@ -12,7 +12,7 @@ description: "The build command to run in a script, a CI job, or a shell with no
12
12
  geneseed build --emit opencode-global --theme neutral
13
13
  ```
14
14
 
15
- `geneseed build` takes the same flags as the generator `geneseed-build`. `--emit` picks the host and its scope: `files`, `opencode`, `opencode-global`, `claude`, `claude-global`, `bob`, `bob-global`, `openclaude` or `openclaude-global`. A `-global` emit writes into the tool's own config directory. A project emit writes into the repo you name with `--out <repo> --root <repo>`. All the other flags (`--theme`, `--footprint`, `--posture`, `--mode`, `--trust`, `--doctrines`, `--exclude-rules`) are covered in [Choose your setup](choose-your-setup.md) and the two pages after it.
15
+ `geneseed build` takes the same flags as the generator `geneseed-build`. `--emit` picks the host and its scope: `files`, `opencode`, `opencode-global`, `claude`, `claude-global`, `bob`, `bob-global`, `openclaude` or `openclaude-global`. A `-global` emit writes into the tool's own config directory. A project emit writes into the repo you name with `--out <repo> --root <repo>`. All the other flags (`--theme`, `--footprint`, `--posture`, `--mode`, `--trust`, `--doctrines`, `--exclude-rules`) are covered in [Choose your setup](choose-your-setup.md) and the two pages after it. `--exclude-skills` leaves skills you never use out of the install; see [Exclusions](../concepts/exclusions.md).
16
16
 
17
17
  To test a build before writing anything, use `geneseed validate` with the same flags. See [Verify](verify.md).
18
18
 
@@ -25,7 +25,7 @@ With no configuration, the context step finds these files:
25
25
  - **Lazy:** `docs/`, `doc/`, `documentation/`, `architecture/`, `adr/` and `ADR/`, monorepo `packages/*/README.md` and `apps/*/README.md`, and any other root `*.md`.
26
26
  - **Never scanned:** `node_modules`, `.git`, `dist`, `build`, `vendor`, `.next`, `target`, `.venv`, `__pycache__`, `.opencode` and `.harness`.
27
27
 
28
- Eager files share a 48 KB budget per session. An eager file that would go over the budget is listed instead, to be read on demand.
28
+ On Claude Code, Bob and OpenClaude the whole injection stays under 9,000 characters, because Claude Code hands the model only a short preview of a hook output past about 10,000 and keeps the rest in a file. Your session files come first, then eager files whole; an eager file that does not fit is listed instead, to be read on demand. On OpenCode eager files share a 48 KB budget. On every host, three or more docs in one folder are listed as that folder with a count (`docs/guides/ — 30 docs`), not file by file.
29
29
 
30
30
  On OpenCode the context plugin does this work. On Claude Code, Bob and OpenClaude, the session-start hook does it (`geneseed-hook context`). Both read the same override file, described next.
31
31
 
@@ -35,11 +35,13 @@ pointer to the host's own inventory instead, ~1.9k tokens lighter. A session fil
35
35
  byte-identical to its seed is skipped — it says nothing — so a fresh install injects none
36
36
  of them; until 2026-10 the hook read them from the repo root, where a Claude-shaped install
37
37
  never puts them, and none reached the session at all. The
38
- eager-injection path is budget-capped identically everywhere — 16 KB per file
39
- (cut at a line break, with a marker saying so) and 48 KB per session (files past
40
- the budget are listed lazy with the reason) — so a 40k-character README no
41
- longer leaves whole every session, and growing Memory degrades every host the
42
- same way instead of one silently falling behind. The hook also **never injects
38
+ eager-injection path is budget-capped on every host. The SessionStart hook (Claude Code,
39
+ Bob, OpenClaude) keeps its whole payload under 9,000 characters: Claude Code persists a
40
+ hook output past ~10k to a file and shows the model a 2 KB preview, so until 2026-10 a
41
+ doc-heavy repo's context was cut off unannounced. Session files come first, eager docs
42
+ are injected whole or listed lazy with the reason. The OpenCode plugin has no such cap
43
+ and keeps 16 KB per file / 48 KB per session. Both fold a lazy listing: three or more
44
+ docs in one folder become one `dir/ — N docs` line. The hook also **never injects
43
45
  the root file the host loads by itself** (`CLAUDE.md` on Claude Code,
44
46
  `AGENTS.md` on Bob, `AGENT.md`/`AGENTS.md`/`CLAUDE.md` on OpenCode):
45
47
  until 2026-09 it did, and the whole harness was paid twice per session.
@@ -106,11 +108,12 @@ token counter also includes, none of which Geneseed controls:
106
108
  2. **Your repo's docs, injected eagerly** — the context delivery
107
109
  (plugin on OpenCode, SessionStart hook on Claude Code / Bob / OpenClaude) discovers and
108
110
  injects `README.md`, `CONTRIBUTING.md`, and files under `docs/`, up to the
109
- 48 KB budget (≈12k tokens). A doc-heavy repo fills it.
111
+ budget: 9,000 characters for the whole hook payload (≈2.3k tokens), 48 KB on
112
+ OpenCode (≈12k tokens).
110
113
  3. **Wiki eager entries** plus the lazy listing of the rest, if a wiki is
111
114
  configured.
112
115
  4. **Grown Memory/Notebook** — the emitted starter set is ~1.2k, but both
113
- accumulate with use (same 48 KB shared budget).
116
+ accumulate with use (same shared budget).
114
117
  5. **Other instruction files the host also loads** — a global install plus a
115
118
  per-repo install, a personal `~/.claude/CLAUDE.md`, or MCP servers each add
116
119
  their own share.
@@ -2,5 +2,5 @@
2
2
  "_comment": "version is a human-readable release label only; the canonical identity of an installed harness is the source fingerprint stamped in .geneseed-version (see `harness version`), not this string.",
3
3
  "theme": "neutral",
4
4
  "product": "Geneseed",
5
- "version": "3.14.0"
5
+ "version": "3.16.0"
6
6
  }
package/js/README.md CHANGED
@@ -129,6 +129,7 @@ into files users have committed, so change the test that states it in the same c
129
129
  | `text.mjs` | String primitives: `codePointLength`, `WHITESPACE`, `parseIntStrict`, `percentDecode` |
130
130
  | `udiff.mjs` | `geneseed diff`'s own difflib clone — the alignment IS the printed hunks — plus `splitLines` |
131
131
  | `proc.mjs` | One constant: the `windowsHide` flag every spawn in `js/` must reuse |
132
+ | `prompt.mjs` | The line-mode stdin readers — `ask`, `promptLine`, `confirm`, `askChoice` — read a byte at a time so they stop where Enter stops. Shared by `setup`, `uninstall` and `web` without dragging in the wizard |
132
133
  | `node-floor.mjs` | The first import of all three binaries: below Node 22.3, one sentence on stderr and exit 1 (0 in the hook) instead of a stack trace |
133
134
 
134
135
  **Before editing:** parse with `parseJson`, never `JSON.parse` — the latter collapses int `1` and
@@ -202,10 +203,13 @@ the deployed harness as JSON.
202
203
  | module | owns |
203
204
  |---|---|
204
205
  | `server.mjs` | `serve()`/`cmdWeb`: UI-build precheck, socket bind, token mint, daemon record wiring |
205
- | `routes.mjs` | Route DECLARATION only: the `POST_ROUTES` table and the ported/unported/declined sets |
206
- | `handler.mjs` | One function per request: dispatch, gzip, Host/CSRF guards, static SPA fallback |
207
- | `api.mjs` | `WebState` plus every GET read endpoint; owns `STATE_ROUTES`, `PREFIX_ROUTES`, `NotFound` |
208
- | `actions.mjs` | The mutating endpoints — rules, profile, memory, MCP, excludes — plus install/deploy argv |
206
+ | `routes.mjs` | The route tables — `STATE_ROUTES`, `PREFIX_ROUTES`, `POST_ROUTES` (+ its 409 column) — and the inline lists |
207
+ | `handler.mjs` | One function per request: dispatch, gzip, Host/CSRF guards and body cap (before the body is read), static SPA fallback |
208
+ | `state.mjs` | `webState` — the resolved install view every endpoint reads, with its cached inventory and doctor |
209
+ | `catalog.mjs` | The Library read model: inventory, constitution tiers, memory/notebook/wiki/config items, `apiCatalog`/`apiItem`, `NotFound` |
210
+ | `api.mjs` | The dashboard and settings reads: overview, recent, themes, setup, doctor, diff, installs, excludes, loops |
211
+ | `user-files.mjs` | The fingerprint-guarded editors for `user-rules.md` (incl. memory promotion) and `PROFILE.md`, read and write |
212
+ | `actions.mjs` | The other mutating endpoints — memory delete, MCP, excludes, installs, view, restore — plus install/deploy argv |
209
213
  | `daemon.mjs` | Detached web start/stop/status/restart, the `.geneseed-web.json` record, and `openUrl` |
210
214
  | `jobs.mjs` | `JobManager` and the action→argv table. The only module here that spawns job children |
211
215
  | `docs.mjs` | The Docs tab: the `docs/<folder>/` page registry, `?harness=` filtering, link rewriting, the six page kinds |
@@ -232,4 +236,4 @@ and inline lists are held against a surface list written out in
232
236
  | a hook verb | `js/hosts/hooks.mjs` (a gate) or its own `js/hosts/hooks-<verb>.mjs` | `claudeHookGroups` in `settings.mjs`, the `VERBS` row (with its `load`) in `bin/geneseed-hook.mjs`. Keep the import graph tiny |
233
237
  | a console doc page | `docs/<understand|guides|concepts|reference>/<id>.md` — the stem is the id | `docs/extending.md` §4c. Only `kind: "concept"` gets `{N_*}` substitution |
234
238
  | an OpenCode plugin | `adapters/opencode/plugins/geneseed-<name>.js` | `docs/extending.md` §4a — the `geneseed-` prefix is mechanically load-bearing |
235
- | to change a `js/lib/` primitive | `tests/fixtures/pure_probe.mjs` **first** | the docblock says which Node default it deliberately departs from — change that sentence too, or the next reader restores the default |
239
+ | to change a `js/lib/` primitive | `tests/unit/lib_primitives.test.mjs` **first** | the docblock says which Node default it deliberately departs from — change that sentence too, or the next reader restores the default |
package/js/build/args.mjs CHANGED
@@ -10,7 +10,9 @@ import { existsSync, readFileSync } from 'node:fs';
10
10
  import { parseArgs as nodeParseArgs } from 'node:util';
11
11
  import { withPlatformNewlines } from '../lib/fs.mjs';
12
12
  import path from 'node:path';
13
- import { CONFIG, discoverNames, PACK_ORDER, knownRuleIds } from './source.mjs';
13
+ import {
14
+ CONFIG, discoverNames, PACK_ORDER, knownRuleIds, knownSkillIds, linkedSkillIds,
15
+ } from './source.mjs';
14
16
  // The loop engine's trust presets — the `--trust` choices. `score.mjs` imports nothing, which is
15
17
  // what lets it sit inside this module's `child_process` ban.
16
18
  import { DEFAULT_PRESET, PRESETS } from '../loop/score.mjs';
@@ -24,13 +26,13 @@ const EMITS = ['files', 'opencode', 'opencode-global', 'claude', 'claude-global'
24
26
  *
25
27
  * The corrupt-file branch is reproduced including its imprecision: the warning says only
26
28
  * "using theme 'neutral'", but the branch also resets posture and mode. Matching the
27
- * Python text matters more than fixing it here, because the two CLIs' stderr is compared
28
- * byte-for-byte by `tests/golden.py` (:316).
29
+ * Python text mattered more than fixing it here, because the two CLIs' stderr was compared
30
+ * byte-for-byte by the former Python golden harness; nothing pins the wording now.
29
31
  */
30
32
  export function configDefaults() {
31
33
  const d = {
32
34
  theme: 'neutral', posture: 'peer', mode: 'direct', trust: DEFAULT_PRESET,
33
- doctrines: [...PACK_ORDER], excludeRules: [],
35
+ doctrines: [...PACK_ORDER], excludeRules: [], excludeSkills: [],
34
36
  };
35
37
  if (!existsSync(CONFIG)) return d;
36
38
  try {
@@ -93,7 +95,7 @@ export function configDefaults() {
93
95
  */
94
96
  const VALUED = {
95
97
  '--theme': 'theme', '--posture': 'posture', '--mode': 'mode', '--trust': 'trust',
96
- '--doctrines': 'doctrines', '--exclude-rules': 'excludeRules',
98
+ '--doctrines': 'doctrines', '--exclude-rules': 'excludeRules', '--exclude-skills': 'excludeSkills',
97
99
  '--out': 'out', '--target': 'out', '--emit': 'emit',
98
100
  '--footprint': 'footprint', '--root': 'root', '--config-dir': 'cfgDir',
99
101
  };
@@ -177,6 +179,33 @@ function parseExcludeRules(value) {
177
179
  return [...new Set(out)].sort();
178
180
  }
179
181
 
182
+ /**
183
+ * `--exclude-skills "bruno-test-writer,openscad"` -> the cfg's skill exclusion array.
184
+ *
185
+ * Refused, by name: a skill this checkout does not ship (a typo would exclude nothing in
186
+ * silence), and a skill the constitution or another skill links to by path — excluding it
187
+ * would ship that link dead (`linkedSkillIds`). Sorted and deduped for marker stability.
188
+ */
189
+ function parseExcludeSkills(value) {
190
+ const s = value.trim();
191
+ if (s === 'none' || s === '') return [];
192
+ const known = knownSkillIds();
193
+ const linked = linkedSkillIds();
194
+ const out = [];
195
+ for (const name of s.split(',').map((x) => x.trim()).filter(Boolean)) {
196
+ if (!known.includes(name)) {
197
+ die(2, `argument --exclude-skills: unknown skill '${name}' (choose from `
198
+ + `${known.filter((k) => !linked.includes(k)).join(', ')}, or 'none')`);
199
+ }
200
+ if (linked.includes(name)) {
201
+ die(2, `argument --exclude-skills: '${name}' cannot be excluded — the harness links to it `
202
+ + `by path (protected: ${linked.join(', ')})`);
203
+ }
204
+ out.push(name);
205
+ }
206
+ return [...new Set(out)].sort();
207
+ }
208
+
180
209
  /**
181
210
  * `--doctrines craft,rigor` / `--doctrines none` -> the cfg's pack array.
182
211
  *
@@ -271,6 +300,7 @@ export function parseArgs(argv, defaults) {
271
300
  trust: defaults.trust,
272
301
  doctrines: defaults.doctrines,
273
302
  excludeRules: defaults.excludeRules,
303
+ excludeSkills: defaults.excludeSkills ?? [],
274
304
  out: 'Harness', emit: 'files', footprint: 'lean', root: null,
275
305
  syncThemes: false, validateOnly: false, verbose: false,
276
306
  };
@@ -314,6 +344,7 @@ export function parseArgs(argv, defaults) {
314
344
  // already the array `configDefaults` built, and re-parsing an array would stringify it.
315
345
  if (typeof args.doctrines === 'string') args.doctrines = parseDoctrines(args.doctrines);
316
346
  if (typeof args.excludeRules === 'string') args.excludeRules = parseExcludeRules(args.excludeRules);
347
+ if (typeof args.excludeSkills === 'string') args.excludeSkills = parseExcludeSkills(args.excludeSkills);
317
348
  return args;
318
349
  }
319
350
 
@@ -325,7 +356,8 @@ export function parseArgs(argv, defaults) {
325
356
  * `--validate-only` reads exactly the flags `build.py`'s parser already produced, so the port
326
357
  * hands the CLI this parser rather than a second one beside it. That is not tidiness: a
327
358
  * hand-rolled copy would have its own `--target` alias, its own `choices` lists and its own
328
- * `-h`, and `test_the_help_text_names_every_flag_the_reference_takes` gates only this one.
359
+ * `-h`, and `tests/unit/node_driver.test.mjs`'s '--help names every flag the parser takes' gates only
360
+ * this one.
329
361
  *
330
362
  * `withPlatformNewlines` because the refusal and `-h` paths WRITE: called from `bin/build-driver.mjs`
331
363
  * they sit inside `main`'s funnel, and called from the CLI binary they would not.
@@ -12,9 +12,9 @@
12
12
  */
13
13
  import path from 'node:path';
14
14
  import { VERSION_MARKER } from '../hosts/hosts.mjs';
15
- import { copyFile, readText, writeText } from '../lib/fs.mjs';
15
+ import { copyFile, isDir, isFile, readText, writeText } from '../lib/fs.mjs';
16
16
  import { jsonDumpsIndent, parseJson, formatReprAscii, isTruthy } from '../lib/json.mjs';
17
- import { OWNED_SRC_DIRS, SRC_DIRS_MARKER, TMPL_SPEC_RE, isDir, isFile } from './emit-common.mjs';
17
+ import { OWNED_SRC_DIRS, SRC_DIRS_MARKER, TMPL_SPEC_RE } from './emit-common.mjs';
18
18
  import { SRC_DIR_TOKENS, renderAll } from './render.mjs';
19
19
  import {
20
20
  ensureBundleGitignore, ensureContextStub, ensureMemoryIndex, ensureNotebookIndex,
@@ -114,16 +114,9 @@ function safePriorDirName(out, priorName) {
114
114
  // build
115
115
  // ---------------------------------------------------------------------------
116
116
 
117
- /**
118
- * `_build_render.build` — render the bundle into `out`.
119
- *
120
- * `out` arrives as the STRING Python already resolved, and is echoed into the progress
121
- * lines verbatim: Python interpolates `str(Path)`, which is backslash-separated on
122
- * Windows, and re-deriving it here would differ by separator in every message.
123
- */
124
117
  /**
125
118
  * THE PHASE BOUNDARY MARKER — T8, and the whole of what this port owes
126
- * `tests/test_emit_phase_order.py`.
119
+ * `tests/unit/emit_phase_order.test.mjs`.
127
120
  *
128
121
  * Every emit runs its stages in one order: RENDER* → WIRE* → PRUNE → MANIFEST → VERIFY. RENDER
129
122
  * writes files Geneseed owns wholesale; WIRE reconciles files the USER co-owns (the
@@ -156,6 +149,13 @@ export function phaseLog(phase) {
156
149
  process.stderr.write(`[geneseed:phase] ${phase}\n`);
157
150
  }
158
151
 
152
+ /**
153
+ * `_build_render.build` — render the bundle into `out`.
154
+ *
155
+ * `out` arrives as the STRING Python already resolved, and is echoed into the progress
156
+ * lines verbatim: Python interpolates `str(Path)`, which is backslash-separated on
157
+ * Windows, and re-deriving it here would differ by separator in every message.
158
+ */
159
159
  export function build(cfg, themeName, out, { footprint = 'full', nativeCatalog = false } = {}) {
160
160
  phaseLog('RENDER');
161
161
  const { theme, items } = renderAll(cfg, themeName, { footprint, nativeCatalog });
@@ -42,6 +42,7 @@ import { settingsIntegrityCheck } from '../hosts/settings.mjs';
42
42
  import { hookRunnerEntry } from '../hosts/shim.mjs';
43
43
  import { writeText, withPlatformNewlines, isFile } from '../lib/fs.mjs';
44
44
  import { parseJson, jsonDumpsIndent } from '../lib/json.mjs';
45
+ import { relPosix } from '../lib/text.mjs';
45
46
  // P5c moved these out of this file: `bin/geneseed-cli.mjs` needs the same four resolvers to
46
47
  // find a global install, and a resolver that decides WHERE a driver writes is the last thing
47
48
  // that should exist twice. golden.py's 259 cells are what made the move safe to attempt.
@@ -90,7 +91,8 @@ const PRIMARY_AGENT_SRC = path.join(ROOT, 'adapters', 'opencode', 'agents', 'orc
90
91
  * It is invisible on a machine whose paths are already canonical, which is every machine
91
92
  * this ran on until GitHub's Windows runner handed it a `C:\\Users\\RUNNER~1\\...` and the
92
93
  * two CLIs printed the same directory under two different names — a 70-byte difference in a
93
- * line compared BYTE for byte by `test_the_two_entry_points_agree_on_stdout_BYTES`. The
94
+ * line the former Python suite compared BYTE for byte; `tests/unit/resolve_out.test.mjs` pins
95
+ * it now. The
94
96
  * argument to `resolvePath` is made absolute first so its `expanduser` cannot fire: the
95
97
  * reference does not expand `~` here, and a bare `~` is a legal directory name. */
96
98
  export function resolveOut(raw) {
@@ -149,10 +151,10 @@ function warnBobGlobalOverProject() {
149
151
  * would be written into `opencode.json`'s instruction path.
150
152
  */
151
153
  function relUnder(out, root) {
152
- const rel = path.relative(root, out);
154
+ const rel = relPosix(root, out);
153
155
  if (rel === '' || rel === '.') return '';
154
156
  if (rel.startsWith('..') || path.isAbsolute(rel)) return '';
155
- return rel.split(path.sep).join('/');
157
+ return rel;
156
158
  }
157
159
 
158
160
  /**
@@ -255,7 +257,7 @@ function projectSurvivors(emitName) {
255
257
  * RENDER and WIRE already ran in Node before P4; what lived only in Python was the stage
256
258
  * either side of them: reading the previous manifest (PRE), pruning what this layer no
257
259
  * longer owns, writing the manifest atomically, and the summary line. All five stages now
258
- * run in one process, in the order `test_emit_phase_order.py` pins for the Python twin:
260
+ * run in one process, in the order `tests/unit/emit_phase_order.test.mjs` pins:
259
261
  * RENDER -> WIRE -> PRUNE -> MANIFEST -> VERIFY (opencode has no VERIFY; it writes no
260
262
  * settings file of its own).
261
263
  */
@@ -511,9 +513,8 @@ const claudeShaped = (name) => (cfg, args, out) => {
511
513
 
512
514
  /**
513
515
  * build.py:437-466 — the POST stage, which writes markers and records the install and
514
- * wires NOTHING. `test_the_post_emit_stage_wires_nothing` classifies that stage on the
515
- * Python side; this is the same stage on this side, and it stays after the dispatch for
516
- * the same reason.
516
+ * wires NOTHING. `tests/unit/emit_phase_order.test.mjs`'s 'the post-emit marker stage wires
517
+ * nothing' classifies it, and that is why it stays after the dispatch.
517
518
  *
518
519
  * `writeText`, never `writeFileSync`: Python's `Path.write_text` opens in text mode with
519
520
  * `newline=None` and translates the trailing `\n` to CRLF on Windows. A marker written
@@ -595,16 +596,17 @@ const PROJECT_EMITS = {
595
596
  * mode readers return `null` for "undetectable" too, which an explicit `posture: null` would
596
597
  * carry straight past the parameter default.
597
598
  */
598
- function axesCfg({ posture, mode, trust, doctrines, excludeRules }) {
599
+ function axesCfg({ posture, mode, trust, doctrines, excludeRules, excludeSkills }) {
599
600
  return makeCfg({
600
601
  ...(posture ? { posture } : {}), ...(mode ? { mode } : {}), ...(trust ? { trust } : {}),
601
602
  ...(doctrines ? { doctrines } : {}), ...(excludeRules ? { excludeRules } : {}),
603
+ ...(excludeSkills ? { excludeSkills } : {}),
602
604
  });
603
605
  }
604
606
 
605
607
  export function emitProjectInto(host, {
606
608
  theme, out, root, footprint = 'full', posture = null, mode = null, trust = null,
607
- doctrines = null, excludeRules = null,
609
+ doctrines = null, excludeRules = null, excludeSkills = null,
608
610
  }) {
609
611
  const emit = PROJECT_EMITS[host];
610
612
  // The axes ride the same rail into every `*Into`, and for the same reason: a drift reader
@@ -612,7 +614,7 @@ export function emitProjectInto(host, {
612
614
  // edited on every narrowed install, and `validate --doctrines craft` that ignored its flag
613
615
  // checked an all-packs render nobody asked for.
614
616
  return withPlatformNewlines(() => emit(
615
- axesCfg({ posture, mode, trust, doctrines, excludeRules }), { theme, footprint, root }, out,
617
+ axesCfg({ posture, mode, trust, doctrines, excludeRules, excludeSkills }), { theme, footprint, root }, out,
616
618
  ));
617
619
  }
618
620
 
@@ -626,15 +628,15 @@ export function emitProjectInto(host, {
626
628
  */
627
629
  export function buildInto({
628
630
  theme, out, footprint = 'lean', posture = null, mode = null, trust = null, doctrines = null,
629
- excludeRules = null,
631
+ excludeRules = null, excludeSkills = null,
630
632
  }) {
631
- return withPlatformNewlines(() => build(axesCfg({ posture, mode, trust, doctrines, excludeRules }),
633
+ return withPlatformNewlines(() => build(axesCfg({ posture, mode, trust, doctrines, excludeRules, excludeSkills }),
632
634
  theme, out, { footprint, nativeCatalog: false }));
633
635
  }
634
636
 
635
637
  export function emitGlobalInto(host, {
636
638
  theme, out, cfgDir, footprint, posture = null, mode = null, doctrines = null, trust = null,
637
- excludeRules = null,
639
+ excludeRules = null, excludeSkills = null,
638
640
  }) {
639
641
  // `build.HOSTS.get(host, build.HOSTS["opencode"])` — an unknown host falls back rather than
640
642
  // raising, because the host comes from a marker file a user can edit.
@@ -650,7 +652,7 @@ export function emitGlobalInto(host, {
650
652
  // edited on every install that chose a register, narrowed its packs or excluded a rule —
651
653
  // the same scar the footprint left in `diffCollect`, one axis over each time.
652
654
  return withPlatformNewlines(() => emit(
653
- axesCfg({ posture, mode, trust, doctrines, excludeRules }),
655
+ axesCfg({ posture, mode, trust, doctrines, excludeRules, excludeSkills }),
654
656
  { theme, footprint, root: null, cfgDir }, out,
655
657
  ));
656
658
  }
@@ -14,10 +14,11 @@ import {
14
14
  unwireClaudeExcludes, unwireClaudeSettings, wireClaudeExcludes,
15
15
  } from '../hosts/settings.mjs';
16
16
  import { writeText } from '../lib/fs.mjs';
17
- import { isTruthy } from '../lib/json.mjs';
17
+ import { get, isDict, isTruthy } from '../lib/json.mjs';
18
+ import { relPosix } from '../lib/text.mjs';
18
19
  import { assertSourceComplete, phaseLog } from './bundle.mjs';
19
20
  import {
20
- get, globalMemory, globalNotebook, isDict, relPosix, shipLeanLaws, stripCapabilityLinks,
21
+ globalMemory, globalNotebook, shipLeanLaws, stripCapabilityLinks,
21
22
  } from './emit-common.mjs';
22
23
  import { renderAll, renderFile } from './render.mjs';
23
24
  import {
@@ -10,18 +10,11 @@
10
10
  * `emit-claude.mjs` do that; `bundle.mjs` renders the bundle they read from.
11
11
  */
12
12
  import path from 'node:path';
13
- import { copyFile, writeText, isFile, isDir } from '../lib/fs.mjs';
14
- import { get, isDict } from '../lib/json.mjs';
13
+ import { copyFile, writeText, isDir } from '../lib/fs.mjs';
14
+ import { relPosix } from '../lib/text.mjs';
15
15
  import { SRC_DIR_TOKENS, destRel } from './render.mjs';
16
16
  import { mkdirSync, readdirSync, cpSync } from 'node:fs';
17
17
 
18
- // `isFile`/`isDir`/`get`/`isDict` are owned by `js/lib` now (single owner across `hosts/`,
19
- // `build/`, `inspect/` and `web/` — layering forbids `build/` reaching into `inspect/scan.mjs`
20
- // for them, so `js/lib` is the one home both sides can reach). Re-exported here so this
21
- // module's own existing callers (`emit-claude.mjs`, `emit-opencode.mjs`, `version.mjs`,
22
- // `bundle.mjs`) keep importing them from `./emit-common.mjs` unchanged.
23
- export { isFile, isDir, get, isDict };
24
-
25
18
  /** `_build_render.SRC_DIRS_MARKER`. */
26
19
  export const SRC_DIRS_MARKER = '.geneseed-srcdirs.json';
27
20
  /**
@@ -79,11 +72,6 @@ export function rglobFiles(root) {
79
72
  .map((d) => path.join(d.parentPath, d.name));
80
73
  }
81
74
 
82
- /** `p.relative_to(base).as_posix()`. */
83
- export function relPosix(base, p) {
84
- return path.relative(base, p).split(path.sep).join('/');
85
- }
86
-
87
75
  /**
88
76
  * `_build_emit._strip_capability_links` — `re.sub(r"\1")`, so the pattern needs `g`.
89
77
  *
@@ -8,16 +8,17 @@
8
8
  */
9
9
  import path from 'node:path';
10
10
  import { VERSION_MARKER } from '../hosts/hosts.mjs';
11
- import { loadAgentOverrides, writeNativeLayer } from '../hosts/native.mjs';
11
+ import { claimer, loadAgentOverrides, writeNativeLayer } from '../hosts/native.mjs';
12
12
  import {
13
13
  copyPlugins, copyWorkflows, ensureAgentOverridesStub, writeColorThemes, writeCommandLayer,
14
14
  writePonytailCommand, writePrimaryAgent, writeTheme,
15
15
  } from '../hosts/opencode.mjs';
16
16
  import { mergeOpencodeJson } from '../hosts/settings.mjs';
17
- import { readText, writeText } from '../lib/fs.mjs';
17
+ import { isFile, readText, writeText } from '../lib/fs.mjs';
18
+ import { relPosix } from '../lib/text.mjs';
18
19
  import { assertSourceComplete, build, phaseLog } from './bundle.mjs';
19
20
  import {
20
- globalMemory, globalNotebook, isFile, relPosix, shipLeanLaws, stripCapabilityLinks,
21
+ globalMemory, globalNotebook, shipLeanLaws, stripCapabilityLinks,
21
22
  } from './emit-common.mjs';
22
23
  import { renderAll } from './render.mjs';
23
24
  import {
@@ -56,17 +57,20 @@ function opencodeLayer(cfg, items, themeName, theme, dir, owned, opts) {
56
57
  } = opts;
57
58
  ensureAgentOverridesStub(cfg, overridesDir);
58
59
  const overrides = loadAgentOverrides(overridesDir);
60
+ // One claim for every writer below whose path lands in `owned` — see `claimer`.
61
+ const claim = claimer(oldOwned, dir, manifestExisted);
59
62
 
60
63
  const { nAgents, nSkills, written } = writeNativeLayer(
61
64
  items, path.join(dir, 'agents'), path.join(dir, 'skills'), overrides,
62
- { host: 'opencode', oldOwned, cfg: dir, manifestExisted, theme, src: cfg.src });
65
+ { host: 'opencode', theme, src: cfg.src, claim });
63
66
  for (const p of written) owned.push(relPosix(dir, p));
64
67
 
65
- const primary = writePrimaryAgent(cfg, path.join(dir, 'agents'), overrides);
68
+ const primary = writePrimaryAgent(cfg, path.join(dir, 'agents'), overrides, claim);
66
69
  if (primary) owned.push(relPosix(dir, primary));
67
70
 
68
- const commands = writeCommandLayer(cfg, items, path.join(dir, 'command'));
69
- commands.push(writePonytailCommand(path.join(dir, 'command'))); // always-on /ponytail
71
+ const commands = writeCommandLayer(cfg, items, path.join(dir, 'command'), claim);
72
+ const ponytail = writePonytailCommand(path.join(dir, 'command'), claim); // always-on /ponytail
73
+ if (ponytail) commands.push(ponytail);
70
74
  for (const p of commands) owned.push(relPosix(dir, p));
71
75
 
72
76
  owned.push(relPosix(dir, writeTheme(path.join(dir, 'themes'), themeName, theme)));
@@ -97,7 +101,7 @@ function opencodeLayer(cfg, items, themeName, theme, dir, owned, opts) {
97
101
  * manifest is the DRIVER's stage: it reads the file, hands the prior claim in, and prunes
98
102
  * and rewrites it after this returns. One owner of the file, and this function is not it.
99
103
  *
100
- * `nativeCatalog` likewise arrives decided. It is `HOSTS['opencode']['native_catalog']` from
104
+ * `nativeCatalog` likewise arrives decided. It is `hostCatalogsNatively('opencode')` from
101
105
  * `js/hosts/hosts.mjs`, resolved by the driver before the call; passing the decision rather
102
106
  * than re-reading the registry keeps one owner of it.
103
107
  */