geneseed 3.17.0 → 3.19.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 (164) hide show
  1. package/CHANGELOG.md +39 -0
  2. package/DESIGN.md +1 -1
  3. package/README.md +23 -5
  4. package/SHIPPED.md +3 -3
  5. package/adapters/claude-code/skills/geneseed/SKILL.md +1 -1
  6. package/adapters/opencode/README.md +2 -2
  7. package/adapters/opencode/plugins/geneseed-context.js +19 -17
  8. package/adapters/opencode/plugins/geneseed-guard.js +7 -7
  9. package/bin/geneseed-cli.mjs +5 -0
  10. package/docs/concepts/exclusions.md +1 -1
  11. package/docs/concepts/skills.md +6 -2
  12. package/docs/extending.md +33 -0
  13. package/docs/guides/project-context.md +20 -0
  14. package/docs/guides/read-non-markdown.md +1 -1
  15. package/docs/guides/uninstall.md +1 -1
  16. package/docs/guides/upgrade.md +1 -1
  17. package/docs/guides/wiki.md +4 -4
  18. package/docs/opencode-plugin-setup.md +1 -1
  19. package/docs/reference/env-context.md +2 -2
  20. package/docs/reference/plugin-context.md +2 -2
  21. package/docs/reference/plugin-guard.md +1 -1
  22. package/docs/token-footprint.md +18 -15
  23. package/docs/understand/take-it-out.md +1 -1
  24. package/harness.config.json +1 -1
  25. package/js/README.md +4 -1
  26. package/js/build/args.mjs +22 -20
  27. package/js/build/bundle.mjs +1 -3
  28. package/js/build/emit-claude.mjs +5 -4
  29. package/js/build/emit-opencode.mjs +9 -2
  30. package/js/build/generate.mjs +6 -6
  31. package/js/build/render.mjs +19 -12
  32. package/js/build/source.mjs +82 -7
  33. package/js/build/stubs.mjs +35 -6
  34. package/js/build/themes.mjs +1 -3
  35. package/js/cli-table.json +53 -0
  36. package/js/hosts/hooks-context.mjs +13 -6
  37. package/js/hosts/hosts.mjs +1 -3
  38. package/js/hosts/installs.mjs +13 -7
  39. package/js/hosts/mcp.mjs +1 -1
  40. package/js/hosts/native.mjs +60 -5
  41. package/js/hosts/opencode.mjs +37 -6
  42. package/js/hosts/settings.mjs +1 -0
  43. package/js/hosts/shim.mjs +1 -0
  44. package/js/inspect/checks-authoring.mjs +4 -1
  45. package/js/inspect/checks-counts.mjs +8 -3
  46. package/js/inspect/checks-repo.mjs +166 -2
  47. package/js/inspect/diff.mjs +5 -1
  48. package/js/inspect/doctor.mjs +2 -1
  49. package/js/inspect/inventory.mjs +7 -9
  50. package/js/inspect/scorecard-checks.mjs +571 -0
  51. package/js/inspect/scorecard-svg.mjs +85 -0
  52. package/js/inspect/scorecard.mjs +839 -0
  53. package/js/lib/json.mjs +1 -1
  54. package/js/maintain/migrate.mjs +2 -1
  55. package/js/maintain/uninstall.mjs +2 -3
  56. package/js/maintain/update.mjs +10 -3
  57. package/js/ui/menu.mjs +2 -2
  58. package/js/ui/tui.mjs +1 -0
  59. package/js/web/api.mjs +2 -0
  60. package/js/web/catalog.mjs +24 -7
  61. package/js/web/daemon.mjs +2 -1
  62. package/js/web/routes.mjs +2 -0
  63. package/js/web/server.mjs +1 -1
  64. package/js/web/user-files.mjs +1 -0
  65. package/package.json +11 -3
  66. package/registry.json +12 -75
  67. package/src/AGENT.md.tmpl +19 -24
  68. package/src/agents/docs.md +1 -1
  69. package/src/agents/researcher.md +1 -1
  70. package/src/agents/reviewer.md +3 -0
  71. package/src/bricks/bruno-test.md +2 -2
  72. package/src/bricks/ci-fix.md +3 -3
  73. package/src/bricks/deps-audit.md +3 -3
  74. package/src/bricks/migration-plan.md +2 -2
  75. package/src/doctrines/comms.md +19 -0
  76. package/src/doctrines/process.md +2 -2
  77. package/src/laws/universal.md +2 -2
  78. package/src/skills/brainstorm.md +14 -10
  79. package/src/skills/bruno.md +84 -0
  80. package/src/skills/codebase-design.md +41 -23
  81. package/src/skills/daydream/SKILL.md +4 -3
  82. package/src/skills/daydream/VENDOR.md +6 -2
  83. package/src/skills/daydream/instructions.md +1 -1
  84. package/src/skills/debug/ci.md +11 -0
  85. package/src/skills/debug.md +11 -8
  86. package/src/skills/dependencies.md +65 -0
  87. package/src/skills/develop.md +9 -4
  88. package/src/skills/document-project.md +27 -22
  89. package/src/skills/explain-changes/SKILL.md +1 -1
  90. package/src/skills/fresh-eyes.md +1 -1
  91. package/src/skills/geneseed-code-review.md +21 -9
  92. package/src/skills/geneseed.md +4 -1
  93. package/src/skills/git-history.md +78 -0
  94. package/src/skills/handoff.md +4 -3
  95. package/src/skills/parallel-agents.md +2 -1
  96. package/src/skills/pipeline.md +1 -1
  97. package/src/skills/plan.md +5 -1
  98. package/src/skills/ponytail.md +1 -1
  99. package/src/skills/prototype.md +10 -7
  100. package/src/skills/react-view-transitions/SKILL.md +1 -0
  101. package/src/skills/react-view-transitions/VENDOR.md +4 -0
  102. package/src/skills/research.md +6 -1
  103. package/src/skills/review-response.md +8 -2
  104. package/src/skills/roast-me.md +8 -7
  105. package/src/skills/rule/consolidate.md +32 -0
  106. package/src/skills/rule.md +16 -4
  107. package/src/skills/security-audit.md +1 -1
  108. package/src/skills/ship.md +16 -9
  109. package/src/skills/teach.md +5 -3
  110. package/src/skills/wayfinder.md +19 -16
  111. package/src/skills/wiki.md +2 -2
  112. package/themes/_TEMPLATE.json +9 -17
  113. package/themes/biker.json +9 -17
  114. package/themes/commentator.json +9 -17
  115. package/themes/cyberpunk.json +9 -17
  116. package/themes/gamer.json +9 -17
  117. package/themes/imperial.json +9 -17
  118. package/themes/joker.json +9 -17
  119. package/themes/marvin.json +9 -17
  120. package/themes/mean.json +9 -17
  121. package/themes/military.json +9 -17
  122. package/themes/neutral.json +9 -17
  123. package/themes/pirate.json +9 -17
  124. package/themes/sports.json +9 -17
  125. package/themes/verstappen.json +9 -17
  126. package/themes/wizard.json +9 -17
  127. package/web/dist/assets/{Activity-DonKN1pi.js → Activity-IbwdHHyx.js} +1 -1
  128. package/web/dist/assets/{ActivityDetail-e47a3cdE.js → ActivityDetail-B5uivdWI.js} +1 -1
  129. package/web/dist/assets/{Dashboard-Cr7i0PsO.js → Dashboard-v6WQ37bD.js} +2 -2
  130. package/web/dist/assets/{Docs-Lne3nuzD.js → Docs-CypIfDBX.js} +1 -1
  131. package/web/dist/assets/FilterInput-BSfpStOZ.js +1 -0
  132. package/web/dist/assets/{GreenhouseView-4CbIuTpl.js → GreenhouseView-ClxP0hQG.js} +1 -1
  133. package/web/dist/assets/{Installs-CIzbV1Ks.js → Installs-CPkQeFjO.js} +1 -1
  134. package/web/dist/assets/{JournalView-BlBfuvzj.js → JournalView-BeEInhUf.js} +1 -1
  135. package/web/dist/assets/{Laws-B-pjcjHr.js → Laws-Ds6U5xBQ.js} +1 -1
  136. package/web/dist/assets/Library-BraUKPyQ.js +1 -0
  137. package/web/dist/assets/{Loops-DIOxW1xH.js → Loops-DWcy3mwK.js} +1 -1
  138. package/web/dist/assets/Markdown-C6fRd4A4.js +67 -0
  139. package/web/dist/assets/{OperatorHudView-D7S7z7CJ.js → OperatorHudView-CQyrJQ9m.js} +1 -1
  140. package/web/dist/assets/{Personal-Ws3v35AB.js → Personal-YWqs6qFL.js} +1 -1
  141. package/web/dist/assets/{Tabs-6Z1eziAq.js → Tabs-EhzEC-Xh.js} +1 -1
  142. package/web/dist/assets/{activity-Dr7ZMBI3.js → activity-dcg5vF9j.js} +1 -1
  143. package/web/dist/assets/{drawable-ATNeYKbT.js → drawable-CEKPNV7X.js} +1 -1
  144. package/web/dist/assets/{index-CDg5XUhG.js → index-CPi22b7y.js} +2 -2
  145. package/web/dist/assets/react-DRhoX0kS.js +9 -0
  146. package/web/dist/assets/{usePoll-DD8VjtSg.js → usePoll-BmR631BB.js} +1 -1
  147. package/web/dist/index.html +2 -2
  148. package/web/src/pages/Laws.jsx +5 -0
  149. package/src/skills/bruno-collection-generator.md +0 -52
  150. package/src/skills/bruno-test-writer.md +0 -51
  151. package/src/skills/ci-fix.md +0 -49
  152. package/src/skills/consolidate-memory.md +0 -50
  153. package/src/skills/deps-audit.md +0 -46
  154. package/src/skills/domain-modeling.md +0 -27
  155. package/src/skills/git-archaeology.md +0 -36
  156. package/src/skills/git-rescue.md +0 -58
  157. package/src/skills/migrate.md +0 -34
  158. package/src/skills/opencode-theme.md +0 -42
  159. package/src/skills/repo-map.md +0 -25
  160. package/src/skills/tickets.md +0 -33
  161. package/web/dist/assets/FilterInput-CVyqmYWM.js +0 -1
  162. package/web/dist/assets/Library-x8Nzg6yq.js +0 -1
  163. package/web/dist/assets/Markdown-X2a3qvOh.js +0 -38
  164. package/web/dist/assets/react-B9Z_tXRV.js +0 -8
@@ -10,7 +10,7 @@ One of the [OpenCode plugins](opencode-plugins.md). [Environment variables](envi
10
10
 
11
11
  **What it does.** Enforces the safety rules at the tool boundary — before a tool call runs, not after. High-confidence patterns only, so legitimate work is not caught.
12
12
 
13
- - **Blocks** writes to private-key and credential files (*Sealed Secrets*), catastrophic shell commands such as deleting the filesystem root (*Deletion Is Deliberate*), and any change — write, move, rename, delete — under a wiki folder declared `protected` in `wiki.jsonc`, and writes to a check listed in the project's `.geneseed/protected-checks.txt` (*External Gate* — see [Hooks](../concepts/hooks.md)).
13
+ - **Blocks** writes to private-key and credential files (*Sealed Secrets*), catastrophic shell commands such as deleting the filesystem root (*Deletion Is Deliberate*), and any change — write, move, rename, delete — under a wiki folder declared `protected` in `geneseed-wiki.jsonc`, and writes to a check listed in the project's `.geneseed/protected-checks.txt` (*External Gate* — see [Hooks](../concepts/hooks.md)).
14
14
  - **Warns** (logged, allowed) on `.env` writes and force-push.
15
15
  - **Speed-bumps** the *first* write to `user-rules.md` or a memory file: it is refused once, naming *Persist Insight* — whether something is a standing rule or a fact to remember is your call, settled through the `rule` skill. The re-issued write goes through.
16
16
 
@@ -4,9 +4,9 @@ What a deployed harness costs in context-window tokens, per host. Token counts a
4
4
  chars/4 estimates and shift a little with theme and version.
5
5
 
6
6
  **Treat every number below as a floor, not a reading.** They were measured on
7
- 2026-09-12 on a `--footprint lean` build (the default) of the neutral theme with
8
- four doctrine packs, 54 skills and 19 agents, and nothing re-measures this page
9
- automatically. For a
7
+ 2026-10-08 on a `--footprint lean` build (the default) of the neutral theme with
8
+ the default doctrine packs, 47 skills (plus 12 alias stubs) and 18 agents, and nothing
9
+ re-measures this page automatically. For a
10
10
  live figure on your own install, ask the agent for the `token-report` skill —
11
11
  that is what it is for. A `--footprint full` build runs roughly 5k tokens
12
12
  heavier on the root file.
@@ -17,21 +17,21 @@ The context every host injects at session start, before you type anything:
17
17
 
18
18
  | Component | Claude Code | OpenCode | Bob |
19
19
  |---|---|---|---|
20
- | Root instruction file | ~7.2k (`CLAUDE.md`) | ~7.2k (`AGENT.md`) | ~7.5k (`AGENTS.md`, §3 table inline) |
21
- | Skill metadata (name + description) | ~4.1k | ~4.1k | ~4.1k (native) |
22
- | Agent metadata | ~0.5k | ~0.5k | — (no agents directory) |
20
+ | Root instruction file | ~7.7k (`CLAUDE.md`) | ~7.6k (`AGENT.md`) | ~8.0k (`AGENTS.md`, §3 table inline) |
21
+ | Skill metadata (name + description) | ~3.0k | ~3.0k | ~3.0k (native) |
22
+ | Agent metadata | ~1.0k | ~1.0k | — (in the root's §3 table) |
23
23
  | Session files (rules, profile, Memory/Notebook indexes, wiki) | 0 fresh, grows with use (SessionStart hook) | 0 fresh, grows with use (context plugin) | 0 fresh, grows with use (SessionStart hook) |
24
- | **Total** | **~12k** | **~12k** | **~12k** |
24
+ | **Total** | **~12k** | **~12k** | **~11k** |
25
25
 
26
26
  The emits are at parity by design: ~12k tokens, about 6% of a 200k
27
- window. Bob catalogues skills natively but has no agents directory, so its
27
+ window. Bob catalogues skills natively but not agents, so its
28
28
  root keeps the §3 Agents table and drops the §4 Skills table — the catalogue
29
29
  flag is per kind (`hostCatalogsNatively`), not one boolean. Before the 2026-09 footprint pass every hooked host paid roughly double
30
30
  this: the context hook re-injected the root file the host had already loaded
31
31
  natively, and skill descriptions ran to 900 characters each — see the
32
- "Where the tokens go" list below for what changed. Bob carries the §3/§4 catalogue tables inline because its host exposes
33
- no native skill/agent inventory; Claude Code and OpenCode ship a
34
- pointer to the host's own inventory instead, ~1.9k tokens lighter. A session file still
32
+ "Where the tokens go" list below for what changed. Bob carries the §3 Agents table inline because its host exposes
33
+ no native agent inventory; Claude Code and OpenCode ship a
34
+ pointer to the host's own inventory instead, ~0.3k tokens lighter. A session file still
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
@@ -85,15 +85,18 @@ until 2026-09 it did, and the whole harness was paid twice per session.
85
85
  "dispatched agents never commit or push"), and the folder-Skill list, which
86
86
  now sits in the §4 catalogue half and so drops wherever the host catalogues
87
87
  Skills natively.
88
- - **Skill bodies (~53k) and agent bodies (~10k) are lazy** on every host —
88
+ - **Skill bodies (~54k) and agent bodies (~14k) are lazy** on every host —
89
89
  loaded only when invoked. A typical skill costs ≤3k per invocation; the
90
- heaviest (`react-view-transitions`, ~17.5k with its reference files) loads
90
+ heaviest (`react-view-transitions`, ~20k with its reference files) loads
91
91
  its references progressively.
92
92
  - **User-only skills leave the catalogue.** A skill whose source carries
93
93
  `<!-- invocation: user -->` renders `disable-model-invocation: true`; Claude
94
94
  Code and Bob then drop its description from the always-on skill metadata and
95
- only `/name` opens it. Seven skills carry it today (the five teaching drills,
96
- `herdr`, `opencode-theme`), roughly a seventh of the ~1.7k skill-metadata line.
95
+ only `/name` opens it. The three teaching skills (`teach`, `quiz`, `learn-mode`)
96
+ and `herdr` carry it today.
97
+ - **Aliases cost 0 always-on.** A merged skill's old names (`<!-- aliases: -->`)
98
+ ship as user-only stubs on Claude Code, Bob and OpenClaude, and as `/name` commands
99
+ on OpenCode — never in the model's catalogue, so a rename or merge is free.
97
100
  - Plugin JavaScript (OpenCode, ~26k on disk) runs in the host runtime and
98
101
  never enters the context window.
99
102
 
@@ -6,7 +6,7 @@ kind: "concept"
6
6
  section: "On your machine"
7
7
  description: "What geneseed uninstall removes, and what it keeps for you."
8
8
  ---
9
- `geneseed uninstall` removes only what Geneseed recorded writing: the rules file or its managed block, `agents/`, `skills/`, plugins, and Geneseed's own hook and config entries. It keeps `memory/` and `notebook/` in place, along with the files it seeded for you to edit (`user-rules.md`, `PROFILE.md`, `wiki.jsonc`). Add `--archive-memory` to move memory and notebook aside instead. It still deletes nothing. See [Uninstall](../guides/uninstall.md).
9
+ `geneseed uninstall` removes only what Geneseed recorded writing: the rules file or its managed block, `agents/`, `skills/`, plugins, and Geneseed's own hook and config entries. It keeps `memory/` and `notebook/` in place, along with the files it seeded for you to edit (`user-rules.md`, `PROFILE.md`, `geneseed-wiki.jsonc`). Add `--archive-memory` to move memory and notebook aside instead. It still deletes nothing. See [Uninstall](../guides/uninstall.md).
10
10
 
11
11
  ---
12
12
 
@@ -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.17.0"
5
+ "version": "3.19.0"
6
6
  }
package/js/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # `js/` — the module map
2
2
 
3
- Sixty-six modules in eight folders. This page is the address book: **what each one owns**, and
3
+ Sixty-nine modules in eight folders. This page is the address book: **what each one owns**, and
4
4
  **which file to open first** for a given task. It is not documentation of behaviour — every module
5
5
  has a docblock for that, and the docblock is the thing to read before editing.
6
6
 
@@ -109,6 +109,9 @@ Everything that inspects an already-built or already-deployed harness and report
109
109
  | `diff.mjs` | `geneseed diff` — what a deployed install has that a fresh render of `src/` does not |
110
110
  | `excludes.mjs` | `geneseed exclude add|remove|list` — the WRITER for `excludes.json`; `hooks.mjs` only reads it |
111
111
  | `registry.mjs` | `installs.json` under XDG — the persistent deploy-root list `rebuild-all` re-emits into |
112
+ | `scorecard.mjs` | `geneseed scorecard` — a port of the AI Harness Scorecard (upstream pinned in its docblock): the repo walk, the CI-YAML subset parser, `assessRepo`, the text/JSON renderers. Pure: no spawn, no network |
113
+ | `scorecard-svg.mjs` | The scorecard as an SVG card for a README (`geneseed scorecard --svg`): grade plus one bar per pillar, deterministic so doctor can compare the committed card byte for byte |
114
+ | `scorecard-checks.mjs` | The scorecard's 31 checks in five pillars, ported line for line — ids, points, partial scores, evidence and fixes as upstream writes them |
112
115
 
113
116
  **Before editing:** every check returns an array of problem strings, empty when clean — and since
114
117
  deleting any check leaves a clean run **byte-identical**, each one is gated by a PLANTED FAULT, not
package/js/build/args.mjs CHANGED
@@ -11,7 +11,7 @@ import { parseArgs as nodeParseArgs } from 'node:util';
11
11
  import { withPlatformNewlines } from '../lib/fs.mjs';
12
12
  import path from 'node:path';
13
13
  import {
14
- CONFIG, discoverNames, PACK_ORDER, knownRuleIds, knownSkillIds, linkedSkillIds,
14
+ CONFIG, discoverNames, PACK_ORDER, knownRuleIds, knownSkillIds, linkedSkillIds, resolveSkillNames,
15
15
  } from './source.mjs';
16
16
  // The loop engine's trust presets — the `--trust` choices. `score.mjs` imports nothing, which is
17
17
  // what lets it sit inside this module's `child_process` ban.
@@ -180,30 +180,36 @@ function parseExcludeRules(value) {
180
180
  }
181
181
 
182
182
  /**
183
- * `--exclude-skills "bruno-test-writer,openscad"` -> the cfg's skill exclusion array.
183
+ * `--exclude-skills "bruno,openscad"` -> the cfg's skill exclusion array.
184
184
  *
185
185
  * Refused, by name: a skill this checkout does not ship (a typo would exclude nothing in
186
186
  * silence), and a skill the constitution or another skill links to by path — excluding it
187
187
  * would ship that link dead (`linkedSkillIds`). Sorted and deduped for marker stability.
188
+ *
189
+ * An OLD name is not a typo, and is never refused: `upgrade` and `rebuild-all` replay an
190
+ * install's `Excluded skills:` line through this flag, so a name that became an alias or was
191
+ * retired after the install was made would otherwise break every rebuild of it. Those go
192
+ * through `resolveSkillNames` and leave one stderr line each; the linked refusal stays for a
193
+ * name typed as itself.
188
194
  */
189
195
  function parseExcludeSkills(value) {
190
196
  const s = value.trim();
191
197
  if (s === 'none' || s === '') return [];
192
198
  const known = knownSkillIds();
193
199
  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);
200
+ const names = s.split(',').map((x) => x.trim()).filter(Boolean);
201
+ const { ids, notices, unknown } = resolveSkillNames(names, { known, linked });
202
+ if (unknown.length) {
203
+ die(2, `argument --exclude-skills: unknown skill '${unknown[0]}' (choose from `
204
+ + `${known.filter((k) => !linked.includes(k)).join(', ')}, or 'none')`);
205
205
  }
206
- return [...new Set(out)].sort();
206
+ const protectedName = names.find((n) => linked.includes(n));
207
+ if (protectedName) {
208
+ die(2, `argument --exclude-skills: '${protectedName}' cannot be excluded — the harness links `
209
+ + `to it by path (protected: ${linked.join(', ')})`);
210
+ }
211
+ for (const n of notices) process.stderr.write(`[geneseed] --exclude-skills: ${n}\n`);
212
+ return ids;
207
213
  }
208
214
 
209
215
  /**
@@ -315,9 +321,7 @@ export function parseArgs(argv, defaults) {
315
321
  // `main`'s catch is what turns either into this process's exit status. A bare
316
322
  // `process.exit(0)` here would be the P5f bug in reverse — it takes `rebuild-all`'s
317
323
  // loop with it, and skips the stdout flush the text was just written to.
318
- const e = new Error('help');
319
- e.exitCode = 0;
320
- throw e;
324
+ throw Object.assign(new Error('help'), { exitCode: 0 });
321
325
  }
322
326
  if (tok.kind === 'option' && VALUED_BY_NAME[tok.name] !== undefined) {
323
327
  if (tok.value === undefined) die(2, `argument ${tok.rawName}: expected one argument`);
@@ -400,7 +404,5 @@ function choice(flag, value, allowed) {
400
404
  */
401
405
  export function die(code, msg) {
402
406
  process.stderr.write(`geneseed: error: ${msg}\n`);
403
- const e = new Error(msg);
404
- e.exitCode = code;
405
- throw e;
407
+ throw Object.assign(new Error(msg), { exitCode: code });
406
408
  }
@@ -65,9 +65,7 @@ export function assertSourceComplete(cfg, context = '') {
65
65
  + 'and a global re-emit would delete the good copies in an existing install.\n'
66
66
  + '[geneseed] ✗ Re-sync the source (./geneseed update, or re-run the upgrade) '
67
67
  + 'and try again.\n');
68
- const e = new Error('source is incomplete');
69
- e.exitCode = 1;
70
- throw e;
68
+ throw Object.assign(new Error('source is incomplete'), { exitCode: 1 });
71
69
  }
72
70
 
73
71
  // ---------------------------------------------------------------------------
@@ -24,7 +24,7 @@ import { renderAll, renderFile } from './render.mjs';
24
24
  import {
25
25
  BOB_RULES_STUB, ensureContextStub, ensureExcludesStub, ensureMemoryIndex, ensureNotebookIndex,
26
26
  ensureProfileStub,
27
- ensureRulesStub, ensureWikiStub,
27
+ ensureRulesStub, ensureWikiStub, WIKI_FILE,
28
28
  } from './stubs.mjs';
29
29
  import { writeVersion } from './version.mjs';
30
30
  import { existsSync, mkdirSync } from 'node:fs';
@@ -108,10 +108,11 @@ export function emitClaudeRender(cfg, job) {
108
108
  // Bob gives the model a skill's text but not its directory, so a folder skill's
109
109
  // `<this-skill-directory>` is resolved here. Per repo it is relative to the workspace root,
110
110
  // where Bob runs commands, so a committed `.bob/` stays valid on a teammate's checkout;
111
- // globally there is no workspace to be relative to, so it is absolute.
111
+ // globally there is no workspace to be relative to, so it is absolute — and `/`-separated,
112
+ // like the `/<file>` a pointer appends, so one path never mixes both separators.
112
113
  const skillDirOf = !isBob ? null
113
114
  : scope === 'project' ? (name) => relPosix(path.dirname(cfgDir), path.join(cfgDir, 'skills', name))
114
- : (name) => path.join(cfgDir, 'skills', name);
115
+ : (name) => path.join(cfgDir, 'skills', name).split(path.sep).join('/');
115
116
  // Bob's and OpenClaude's agents/skills use the Claude dialect verbatim.
116
117
  // `manifestExisted` is deliberately not passed: the Python does not pass it either, so
117
118
  // the pre-manifest header line is unreachable from this emit on both sides.
@@ -142,7 +143,7 @@ export function emitClaudeRender(cfg, job) {
142
143
  const gi = path.join(cfgDir, '.gitignore');
143
144
  const giLines = (host === 'claude' || host === 'openclaude' ? ['settings.local.json']
144
145
  : host === 'bob' ? ['settings.json'] : [])
145
- .concat(['context.json', 'wiki.jsonc', 'agent-overrides.json']);
146
+ .concat(['context.json', WIKI_FILE, 'agent-overrides.json']);
146
147
  if (!existsSync(gi)) {
147
148
  writeText(gi, `${giLines.join('\n')}\n`);
148
149
  owned.push('.gitignore');
@@ -11,7 +11,7 @@ import { VERSION_MARKER } from '../hosts/hosts.mjs';
11
11
  import { claimer, loadAgentOverrides, writeNativeLayer } from '../hosts/native.mjs';
12
12
  import {
13
13
  copyPlugins, copyWorkflows, ensureAgentOverridesStub, writeColorThemes, writeCommandLayer,
14
- writePonytailCommand, writePrimaryAgent, writeTheme,
14
+ writeAliasCommands, writePonytailCommand, writePrimaryAgent, writeTheme,
15
15
  } from '../hosts/opencode.mjs';
16
16
  import { mergeOpencodeJson } from '../hosts/settings.mjs';
17
17
  import { isFile, readText, writeText } from '../lib/fs.mjs';
@@ -68,10 +68,17 @@ function opencodeLayer(cfg, items, themeName, theme, dir, owned, opts) {
68
68
  const primary = writePrimaryAgent(cfg, path.join(dir, 'agents'), overrides, claim);
69
69
  if (primary) owned.push(relPosix(dir, primary));
70
70
 
71
- const commands = writeCommandLayer(cfg, items, path.join(dir, 'command'), claim);
71
+ // Where a command's side-file pointer says the skill lives — Bob's rule (`emitClaudeRender`):
72
+ // per repo relative to the workspace root, so a committed `.opencode/` holds on a teammate's
73
+ // checkout; globally (`wireBase` IS `dir`) absolute.
74
+ const skillDirOf = wireBase === dir ? (n) => path.join(dir, 'skills', n).split(path.sep).join('/')
75
+ : (n) => relPosix(wireBase, path.join(dir, 'skills', n));
76
+ const commands = writeCommandLayer(cfg, items, path.join(dir, 'command'), claim, skillDirOf);
72
77
  const ponytail = writePonytailCommand(path.join(dir, 'command'), claim); // always-on /ponytail
73
78
  if (ponytail) commands.push(ponytail);
74
79
  for (const p of commands) owned.push(relPosix(dir, p));
80
+ // Owned but not counted in `nCommands`, as an alias skill is not counted in `nSkills`.
81
+ for (const p of writeAliasCommands(cfg, items, path.join(dir, 'command'), claim, skillDirOf)) owned.push(relPosix(dir, p));
75
82
 
76
83
  owned.push(relPosix(dir, writeTheme(path.join(dir, 'themes'), themeName, theme)));
77
84
  for (const p of writeColorThemes(cfg, path.join(dir, 'themes'))) owned.push(relPosix(dir, p));
@@ -66,9 +66,7 @@ function sysExit(msg) {
66
66
  // transport hole P5b measured. A second copy of a translation is exactly what P5d's
67
67
  // `pyStrPath`/`toPlatformPath` split was, and it is ungated in precisely the same way.
68
68
  printErr(`${msg}\n`);
69
- const e = new Error(msg);
70
- e.exitCode = 1;
71
- throw e;
69
+ throw Object.assign(new Error(msg), { exitCode: 1 });
72
70
  }
73
71
 
74
72
  // --------------------------------------------------------------------------------------
@@ -198,7 +196,7 @@ const DEFAULT_EMIT = new Map([
198
196
  * host's row would otherwise rebuild with the WRONG emit — turning a Claude install into an
199
197
  * OpenCode one. `rebuild-all/a-dual-host-repo-rebuilds-each-row-in-its-own-emit` is the cell.
200
198
  */
201
- export function installProfile(host, scope, root) {
199
+ export function installProfile(host, scope, root, notify = null) {
202
200
  const raw = readMaybe(path.join(root, '.geneseed-emit'));
203
201
  let marker = raw === null ? '' : raw.trim();
204
202
  if (marker && (EMIT_HOST_SCOPE.get(marker) ?? ['', ''])[0] !== host) marker = '';
@@ -222,7 +220,7 @@ export function installProfile(host, scope, root) {
222
220
  // one of them. `null` (no marker) stays `null` so the argv omits the flag, as it always did.
223
221
  const excludeRules = excludedRulesOfDir(root);
224
222
  // Skills left out stay left out: an upgrade that forgot them would put them all back.
225
- const excludeSkills = excludedSkillsOfDir(root);
223
+ const excludeSkills = excludedSkillsOfDir(root, notify);
226
224
  const out = scope === 'global' ? null : root;
227
225
  const argv = setupBuildArgs(theme, emit, out, out, footprint, posture, mode, doctrines,
228
226
  PACK_ORDER, excludeRules, trust, excludeSkills);
@@ -285,8 +283,10 @@ export function rebuildAll() {
285
283
  }
286
284
  const failures = [];
287
285
  for (const [host, scope, root] of targets) {
288
- const p = installProfile(host, scope, root);
289
286
  const label = `${host}:${scope} (${root})`;
287
+ // The one replay that speaks when an excluded skill's name got older: status and the console
288
+ // read the same profile on every refresh and stay silent.
289
+ const p = installProfile(host, scope, root, (n) => printErr(`[rebuild-all] ${label}: ${n}\n`));
290
290
  printOut(`[rebuild-all] ${label}: theme=${p.theme} emit=${p.emit} footprint=${p.footprint} `
291
291
  + `posture=${p.posture} mode=${p.mode}\n`);
292
292
  const rc = driverMain(p.argv);
@@ -131,9 +131,7 @@ export function loadTheme(cfg, name) {
131
131
  // marker — an unvalidated file in a user's install. A bare `throw` there printed a Node
132
132
  // stack trace where Python printed one line, and the acceptance matrix is what said so.
133
133
  process.stderr.write(`[geneseed] unknown theme '${name}'. available: ${available}\n`);
134
- const e = new Error(`unknown theme '${name}'`);
135
- e.exitCode = 1;
136
- throw e;
134
+ throw Object.assign(new Error(`unknown theme '${name}'`), { exitCode: 1 });
137
135
  }
138
136
  // parseJson, not JSON.parse: one rule for every JSON the generator reads, so a numeric
139
137
  // theme value keeps the int/float distinction Python's `str()` and `repr()` render
@@ -247,7 +245,17 @@ function readSource(cfg, p) {
247
245
  return text;
248
246
  }
249
247
 
250
- /** `_build_render.render_file`. */
248
+ /**
249
+ * `_build_render.render_file`.
250
+ *
251
+ * @param {any} cfg
252
+ * @param {string} filePath
253
+ * @param {any} theme
254
+ * @param {string} [footprint]
255
+ * @param {string} [lawsPrefix]
256
+ * @param {Set<string>} [visiting]
257
+ * @param {boolean | { skills?: boolean, agents?: boolean }} [nativeCatalog]
258
+ */
251
259
  export function renderFile(cfg, filePath, theme, footprint = 'full', lawsPrefix = '',
252
260
  visiting = new Set(), nativeCatalog = false) {
253
261
  const here = path.resolve(filePath);
@@ -309,19 +317,15 @@ function activeDoctrines(cfg) {
309
317
  : [];
310
318
  const stray = onDisk.filter((n) => !PACK_ORDER.includes(n));
311
319
  if (stray.length) {
312
- const e = new Error(`doctrine pack file(s) ${stray.join(', ')} exist under src/doctrines/ `
320
+ throw Object.assign(new Error(`doctrine pack file(s) ${stray.join(', ')} exist under src/doctrines/ `
313
321
  + 'but are missing from PACK_ORDER (js/build/source.mjs) — add them there, or they render '
314
- + 'into no install at all');
315
- e.exitCode = 1;
316
- throw e;
322
+ + 'into no install at all'), { exitCode: 1 });
317
323
  }
318
324
  const wanted = cfg.doctrines ?? PACK_ORDER;
319
325
  for (const name of wanted) {
320
326
  if (!onDisk.includes(name)) {
321
- const e = new Error(`doctrine pack '${name}' is selected but src/doctrines/${name}.md `
322
- + `does not exist (packs on disk: ${onDisk.join(', ') || 'none'})`);
323
- e.exitCode = 1;
324
- throw e;
327
+ throw Object.assign(new Error(`doctrine pack '${name}' is selected but src/doctrines/${name}.md `
328
+ + `does not exist (packs on disk: ${onDisk.join(', ') || 'none'})`), { exitCode: 1 });
325
329
  }
326
330
  }
327
331
  // ⚠ A PACK WHOSE EVERY RULE IS EXCLUDED IS NOT A PACK, it is a heading over nothing. The
@@ -480,6 +484,9 @@ export function destRel(rel) {
480
484
  * themed POSIX output path, `text` is the rendered text or `null` for a binary that the
481
485
  * caller copies from `src`. Python returns a tuple of tuples; the object form is the
482
486
  * same data with the field names the Python docstring already uses.
487
+ * @param {any} cfg
488
+ * @param {string} themeName
489
+ * @param {{ footprint?: string, lawsPrefix?: string, nativeCatalog?: boolean | { skills?: boolean, agents?: boolean } }} [opts]
483
490
  */
484
491
  export function renderAll(cfg, themeName, {
485
492
  footprint = 'full', lawsPrefix = '', nativeCatalog = false,
@@ -89,6 +89,7 @@ export const RETIRED_RULE_IDS = {
89
89
  'absence-is-a-claim': { was: 'Law XI', liveAs: null, note: 'folded into verify-before-asserting' },
90
90
  };
91
91
 
92
+ /** @type {Array<[number, string]>} */
92
93
  const ROMAN = [[1000, 'M'], [900, 'CM'], [500, 'D'], [400, 'CD'], [100, 'C'], [90, 'XC'],
93
94
  [50, 'L'], [40, 'XL'], [10, 'X'], [9, 'IX'], [5, 'V'], [4, 'IV'], [1, 'I']];
94
95
  export function toRoman(n) {
@@ -194,7 +195,7 @@ export function discoverNames(dir, first) {
194
195
  .map((f) => path.basename(f, '.md'))
195
196
  .sort();
196
197
  } catch { /* missing dir — fall through to the single default below */ }
197
- names.sort((a, b) => (a !== first) - (b !== first) || (a < b ? -1 : a > b ? 1 : 0));
198
+ names.sort((a, b) => Number(a !== first) - Number(b !== first) || (a < b ? -1 : a > b ? 1 : 0));
198
199
  return names.length ? names : [first];
199
200
  }
200
201
 
@@ -241,11 +242,12 @@ export function knownRuleIds() {
241
242
  * skills) or a vendored `skills/<name>/` folder. The one enumerator `--exclude-skills` closes
242
243
  * against, for the reason `knownRuleIds` is one.
243
244
  */
244
- export function knownSkillIds() {
245
- const dir = path.join(SRC, 'skills');
246
- return readdirSync(dir, { withFileTypes: true })
245
+ export function knownSkillIds(src = SRC) {
246
+ const dir = path.join(src, 'skills');
247
+ // A Set: a skill with side files is both `debug.md` and `debug/`.
248
+ return [...new Set(readdirSync(dir, { withFileTypes: true })
247
249
  .filter((e) => !e.name.startsWith('_') && (e.isDirectory() || e.name.endsWith('.md')))
248
- .map((e) => e.name.replace(/\.md$/, '')).sort();
250
+ .map((e) => e.name.replace(/\.md$/, '')))].sort();
249
251
  }
250
252
 
251
253
  /**
@@ -253,7 +255,7 @@ export function knownSkillIds() {
253
255
  * outside the §4 catalogue table. They cannot be excluded: the link would ship dead. Scanned,
254
256
  * not listed, so a new link protects its target the day it is written.
255
257
  */
256
- export function linkedSkillIds() {
258
+ export function linkedSkillIds(src = SRC) {
257
259
  const linked = new Set();
258
260
  const walk = (d) => {
259
261
  for (const e of readdirSync(d, { withFileTypes: true })) {
@@ -266,10 +268,83 @@ export function linkedSkillIds() {
266
268
  }
267
269
  }
268
270
  };
269
- walk(SRC);
271
+ walk(src);
270
272
  return [...linked].sort();
271
273
  }
272
274
 
275
+ /**
276
+ * OLD NAMES KEEP WORKING. When skills merge, the absorbed names live on as aliases, declared in
277
+ * the surviving spec on a line of their own — `<!-- aliases: deps-audit, migrate -->` — beside
278
+ * `<!-- invocation: user -->`, for the same reason: a phrase in the spec, not a second manifest
279
+ * to keep in step. Anchored to a whole line so a skill that merely QUOTES the syntax in its
280
+ * prose declares nothing. The emit turns each alias into a user-only `/alias` that runs the
281
+ * target's body (`writeNativeLayer`, `writeAliasCommands`); `--exclude-skills` maps an alias to
282
+ * its target (`resolveSkillNames`); the doctor keeps the namespace clean (`aliasProblems`).
283
+ */
284
+ export const ALIASES_RE = /^<!--[ \t]*aliases:[ \t]*(.+?)[ \t]*-->[ \t]*\r?$/m;
285
+ export function aliasesOf(text) {
286
+ const m = ALIASES_RE.exec(text);
287
+ return m ? m[1].split(',').map((s) => s.trim()).filter(Boolean) : [];
288
+ }
289
+
290
+ /** Every declared alias -> the skill that declares it. First declaration wins; the doctor names a duplicate. */
291
+ export function skillAliases(src = SRC) {
292
+ const map = new Map();
293
+ const dir = path.join(src, 'skills');
294
+ for (const f of readdirSync(dir).filter((n) => n.endsWith('.md') && !n.startsWith('_')).sort()) {
295
+ for (const a of aliasesOf(readFileSync(path.join(dir, f), 'utf8'))) {
296
+ if (!map.has(a)) map.set(a, f.slice(0, -3));
297
+ }
298
+ }
299
+ return map;
300
+ }
301
+
302
+ /**
303
+ * Skill names that were REMOVED outright — no successor to alias them to. Kept because an
304
+ * install's `Excluded skills:` line outlives the skill: `upgrade` and `rebuild-all` replay it
305
+ * through `--exclude-skills`, and a name the checkout no longer knows would otherwise refuse
306
+ * the whole rebuild. A retired name is dropped with a notice instead (excluding a skill that
307
+ * no longer ships is already true), and the doctor refuses it as a new skill or alias name, so
308
+ * it never comes back meaning something else — `RETIRED_RULE_IDS`' contract, for skills.
309
+ */
310
+ export const RETIRED_SKILL_IDS = {
311
+ tickets: 'removed in the 2026-10 sharpen; plan slices the work now',
312
+ };
313
+
314
+ /**
315
+ * `--exclude-skills` names -> what this checkout can exclude, without ever dying on a name that
316
+ * merely got OLDER. A skill id passes; an alias maps to its target (excluding the merged skill
317
+ * is what excluding its old half meant); a retired name is dropped. An alias whose target the
318
+ * harness links to by path is dropped too — the old exclusion cannot be honoured, and refusing
319
+ * it would break every replay of an install that was valid when it was made. Only `unknown`
320
+ * (never shipped under any name) is the caller's to refuse. Pure: the tables arrive as
321
+ * arguments and the notices go back to the caller, so a reader that runs on every status call
322
+ * can stay silent while the flag speaks.
323
+ */
324
+ export function resolveSkillNames(names, {
325
+ known = knownSkillIds(), aliases = skillAliases(), retired = RETIRED_SKILL_IDS, linked = null,
326
+ } = {}) {
327
+ const ids = [];
328
+ const notices = [];
329
+ const unknown = [];
330
+ for (const name of names) {
331
+ if (known.includes(name)) ids.push(name);
332
+ else if (aliases.has(name)) {
333
+ const target = aliases.get(name);
334
+ linked ??= linkedSkillIds();
335
+ if (linked.includes(target)) {
336
+ notices.push(`'${name}' is now part of '${target}', which cannot be excluded — exclusion dropped`);
337
+ } else {
338
+ notices.push(`'${name}' is now part of '${target}' — excluding '${target}'`);
339
+ ids.push(target);
340
+ }
341
+ } else if (Object.hasOwn(retired, name)) {
342
+ notices.push(`'${name}' no longer ships (${retired[name]}) — exclusion dropped`);
343
+ } else unknown.push(name);
344
+ }
345
+ return { ids: [...new Set(ids)].sort(), notices, unknown };
346
+ }
347
+
273
348
  /**
274
349
  * `_build_core.js_cfg()`, originated rather than received.
275
350
  *
@@ -12,7 +12,7 @@ import { writeText } from '../lib/fs.mjs';
12
12
  import { isDir } from '../lib/fs.mjs';
13
13
  import { jsonDumpsIndent } from '../lib/json.mjs';
14
14
  import { EXCLUDES_FILE } from '../hosts/hosts.mjs';
15
- import { existsSync } from 'node:fs';
15
+ import { existsSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
16
16
 
17
17
  // ---------------------------------------------------------------------------
18
18
  // Seeded stubs. Written once into the bundle and NEVER overwritten — each holds
@@ -34,7 +34,7 @@ const CONTEXT_STUB = {
34
34
 
35
35
  /** `_build_render.WIKI_STUB`. */
36
36
  const WIKI_STUB = `\
37
- // Geneseed wiki.jsonc — declare your machine-wide knowledge base(s) here, typically
37
+ // geneseed-wiki.jsonc — declare your machine-wide knowledge base(s) here, typically
38
38
  // an Obsidian vault (AGENT.md: the Wiki section). Comments are allowed in this file
39
39
  // (JSONC). It is host-specific — never commit it. The build created it once, empty,
40
40
  // and will never overwrite it.
@@ -183,7 +183,8 @@ const BUNDLE_GITIGNORE = `\
183
183
  context.json
184
184
 
185
185
  # Knowledge-base manifest — holds private machine paths; never commit.
186
- # (wiki.json is the legacy name from earlier seeds.)
186
+ # (wiki.jsonc and wiki.json are its legacy names from earlier seeds.)
187
+ geneseed-wiki.jsonc
187
188
  wiki.jsonc
188
189
  wiki.json
189
190
 
@@ -220,9 +221,37 @@ export function ensureContextStub(out) {
220
221
  seed(path.join(out, 'context.json'), `${jsonDumpsIndent(CONTEXT_STUB, { ensureAscii: false })}\n`);
221
222
  }
222
223
 
223
- /** `_build_render.ensure_wiki_stub` — a legacy `wiki.json` counts as present. */
224
+ /** The wiki manifest's name. `geneseed-` says who owns it in a shared `.claude/`/`.opencode/`. */
225
+ export const WIKI_FILE = 'geneseed-wiki.jsonc';
226
+ /** Its earlier names, newest first. Readers still fall back to them for one release. */
227
+ const LEGACY_WIKI_FILES = ['wiki.jsonc', 'wiki.json'];
228
+
229
+ /**
230
+ * `_build_render.ensure_wiki_stub`, plus the rename. A legacy manifest is the user's file: it is
231
+ * RENAMED to `WIKI_FILE` (bytes preserved by construction), never copied and never seeded beside.
232
+ * A rename that fails (the file held open on Windows) leaves it where it is and seeds nothing —
233
+ * a seed beside it would fork the user's declarations; the readers' fallback still finds it.
234
+ * The sibling `.gitignore` follows the rename, or the private paths become committable.
235
+ */
224
236
  export function ensureWikiStub(out) {
225
- if (!existsSync(path.join(out, 'wiki.json'))) seed(path.join(out, 'wiki.jsonc'), WIKI_STUB);
237
+ const dest = path.join(out, WIKI_FILE);
238
+ if (existsSync(dest)) return;
239
+ const legacy = LEGACY_WIKI_FILES.find((n) => existsSync(path.join(out, n)));
240
+ if (!legacy) return seed(dest, WIKI_STUB);
241
+ try { renameSync(path.join(out, legacy), dest); } catch { return; }
242
+ ignoreRenamedWiki(path.join(out, '.gitignore'), legacy);
243
+ }
244
+
245
+ /** Add `WIKI_FILE` to a `.gitignore` that ignores the legacy name and not the new one, in the
246
+ * file's own line ending. Anything else — no file, no legacy line — is left alone. */
247
+ function ignoreRenamedWiki(gi, legacy) {
248
+ let text;
249
+ try { text = readFileSync(gi, 'utf8'); } catch { return; }
250
+ const lines = text.split(/\r?\n/);
251
+ if (!lines.includes(legacy) || lines.includes(WIKI_FILE)) return;
252
+ const eol = text.includes('\r\n') ? '\r\n' : '\n';
253
+ const sep = text.endsWith('\n') ? '' : eol;
254
+ writeFileSync(gi, `${text}${sep}${WIKI_FILE}${eol}`);
226
255
  }
227
256
 
228
257
  /** `_build_render.ensure_rules_stub`. */
@@ -264,7 +293,7 @@ export const ensureNotebookIndex = storeIndexWriter('NOTEBOOK.md', NOTEBOOK_INDE
264
293
  * the two copies must move with it.
265
294
  */
266
295
  export const SESSION_SEEDS = {
267
- 'user-rules.md': RULES_STUB, 'PROFILE.md': PROFILE_STUB, 'wiki.jsonc': WIKI_STUB,
296
+ 'user-rules.md': RULES_STUB, 'PROFILE.md': PROFILE_STUB, [WIKI_FILE]: WIKI_STUB,
268
297
  'MEMORY.md': MEMORY_INDEX, 'NOTEBOOK.md': NOTEBOOK_INDEX,
269
298
  };
270
299
 
@@ -144,9 +144,7 @@ export function syncThemes(themesDir = THEMES) {
144
144
  } catch {
145
145
  process.stdout.write(`[sync-themes] ${path.basename(tmplPath)} is missing or unreadable `
146
146
  + '— there is nothing to sync against.\n');
147
- const e = new Error('template unreadable');
148
- e.exitCode = 2;
149
- throw e;
147
+ throw Object.assign(new Error('template unreadable'), { exitCode: 2 });
150
148
  }
151
149
  const tmplKeys = Object.keys(tmpl);
152
150
  let changed = 0;