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
package/js/cli-table.json CHANGED
@@ -1079,6 +1079,59 @@
1079
1079
  ],
1080
1080
  "mutex": []
1081
1081
  },
1082
+ {
1083
+ "name": "scorecard",
1084
+ "help": "grade a repo with the AI Harness Scorecard (31 checks, five pillars) — scores the repo itself (CI, tests, docs), not the harness files; a reading, never a gate",
1085
+ "description": "",
1086
+ "positionals": [
1087
+ {
1088
+ "names": [],
1089
+ "dest": "path",
1090
+ "metavar": null,
1091
+ "help": "the repo to score (default: the current directory)",
1092
+ "choices": null,
1093
+ "default": null,
1094
+ "required": false,
1095
+ "nargs": "?",
1096
+ "is_flag": false,
1097
+ "type": null,
1098
+ "hidden": false
1099
+ }
1100
+ ],
1101
+ "options": [
1102
+ {
1103
+ "names": [
1104
+ "--json"
1105
+ ],
1106
+ "dest": "json",
1107
+ "metavar": null,
1108
+ "help": "print the full assessment as JSON — every check with its score, evidence and fix",
1109
+ "choices": null,
1110
+ "default": false,
1111
+ "required": false,
1112
+ "nargs": "0",
1113
+ "is_flag": true,
1114
+ "type": null,
1115
+ "hidden": false
1116
+ },
1117
+ {
1118
+ "names": [
1119
+ "--svg"
1120
+ ],
1121
+ "dest": "svg",
1122
+ "metavar": "PATH",
1123
+ "help": "write the scorecard as an SVG card (grade + one bar per pillar) to PATH instead of printing — the README card is docs/assets/scorecard.svg",
1124
+ "choices": null,
1125
+ "default": null,
1126
+ "required": false,
1127
+ "nargs": null,
1128
+ "is_flag": false,
1129
+ "type": null,
1130
+ "hidden": false
1131
+ }
1132
+ ],
1133
+ "mutex": []
1134
+ },
1082
1135
  {
1083
1136
  "name": "setup",
1084
1137
  "help": "interactive install wizard (dependency-free, all OSes)",
@@ -81,8 +81,8 @@ let HOST = 'claude';
81
81
  // Kept in step with adapters/opencode/plugins/geneseed-context.js.
82
82
  const EAGER_ROOT = ['AGENTS.md', 'AGENT.md', 'CLAUDE.md', '.cursorrules',
83
83
  'README.md', 'README.adoc', 'CONTRIBUTING.md', 'CONTRIBUTING.adoc', 'user-rules.md', 'PROFILE.md'];
84
- // Project docs are Markdown or AsciiDoc: the document-project skill writes AsciiDoc by default
85
- // when a project has no docs yet, so a loader blind to `.adoc` would hide every doc it wrote.
84
+ // Project docs are Markdown or AsciiDoc: the document-project skill writes AsciiDoc when the
85
+ // user asks for it or the docs already are, so a loader blind to `.adoc` would hide those docs.
86
86
  // Agent-runtime files (AGENTS.md, CLAUDE.md, specs) stay Markdown whatever the project uses.
87
87
  const DOC_EXT = new Set(['.md', '.adoc']);
88
88
  const isDoc = (name) => DOC_EXT.has(path.extname(name).toLowerCase());
@@ -126,7 +126,10 @@ const cost = (s) => s.length + (s.match(/\n/g) || []).length + 2;
126
126
  // honoured by `resolveContextSets` (the harness dir is its last candidate). The OpenCode
127
127
  // context plugin carries the same list.
128
128
  const SESSION_FILES = ['user-rules.md', 'PROFILE.md', 'memory/MEMORY.md', 'anamnesis/MEMORY.md',
129
- 'notebook/NOTEBOOK.md', 'wiki.jsonc'];
129
+ 'notebook/NOTEBOOK.md', 'geneseed-wiki.jsonc'];
130
+ // The wiki manifest's earlier name, read when the new one is absent (an install not yet re-emitted,
131
+ // or a rename that failed). One release; `wiki.json`, older still, is the build's to rename.
132
+ const LEGACY_WIKI = 'wiki.jsonc';
130
133
  // SHA-256 of each seed body in `js/build/stubs.mjs` (`SESSION_SEEDS`), CRLF folded. A file still
131
134
  // byte-identical to its seed says nothing, so it is skipped rather than injected as noise.
132
135
  // Hashes, not an import: stubs.mjs pulls in the build's writers, and this module loads on every
@@ -134,7 +137,9 @@ const SESSION_FILES = ['user-rules.md', 'PROFILE.md', 'memory/MEMORY.md', 'anamn
134
137
  const SEED_SHA256 = new Set([
135
138
  '5c2e92fb1acde041e02d9ebf158460d8ff7a07cbc2b447859631604610130721', // user-rules.md
136
139
  '29e012c3c4349ea62a9082cbd04bb25599a0ec280d62c33f6e10742809471816', // PROFILE.md
137
- 'cccc917c34e6b990e821620bdb4739090151885ab71b6e85046f30a3b71fa5e8', // wiki.jsonc
140
+ '3a82c78ccc5d745033c16c354f5eda9d79350c704e9da8d605d380fb1e108d7a', // geneseed-wiki.jsonc
141
+ // The wiki seed before the rename: the build renames an untouched one byte for byte.
142
+ 'cccc917c34e6b990e821620bdb4739090151885ab71b6e85046f30a3b71fa5e8', // wiki.jsonc (legacy)
138
143
  '99c786049c260f6baf7aec68a5c0f59907861afe9b867da009440613bdddb907', // MEMORY.md
139
144
  '9acb5c9d9cb5dac572104480f05b48cc144e676869bd60e86d8a15b1f6308184', // NOTEBOOK.md
140
145
  ]);
@@ -150,8 +155,9 @@ export function sessionFiles(hookRoot) {
150
155
  const found = [];
151
156
  for (const rel of SESSION_FILES) {
152
157
  let abs = path.join(hookRoot, rel);
153
- if (rel === 'wiki.jsonc' && process.env.GENESEED_WIKI && isFile(process.env.GENESEED_WIKI)) {
154
- abs = process.env.GENESEED_WIKI;
158
+ if (rel === 'geneseed-wiki.jsonc') {
159
+ if (process.env.GENESEED_WIKI && isFile(process.env.GENESEED_WIKI)) abs = process.env.GENESEED_WIKI;
160
+ else if (!isFile(abs)) abs = path.join(hookRoot, LEGACY_WIKI);
155
161
  }
156
162
  if (!isFile(abs)) continue;
157
163
  let text;
@@ -256,6 +262,7 @@ export function discoverContext(root, host = HOST) {
256
262
  *
257
263
  * `recs` is a Map, not an object: JS reorders integer-like keys on a plain object, and
258
264
  * these keys are absolute paths whose iteration order becomes the printed order.
265
+ * @returns {[Array<{path: string, description: string}>, Array<{path: string, description: string}>, string]}
259
266
  */
260
267
  export function resolveContextSets(root, hookRoot = null) {
261
268
  let manifest = null;
@@ -128,9 +128,7 @@ export function expanduser(p) {
128
128
  + "ntpath/posixpath would guess a home directory for 'user' — this port does not); "
129
129
  + 'pass an absolute path, or "~" / "~/…" for your own home directory';
130
130
  printErr(`${msg}\n`);
131
- const e = new Error(msg);
132
- e.exitCode = 1;
133
- throw e;
131
+ throw Object.assign(new Error(msg), { exitCode: 1 });
134
132
  }
135
133
  return p;
136
134
  }
@@ -35,7 +35,7 @@
35
35
  import { existsSync, readdirSync } from 'node:fs';
36
36
  import path from 'node:path';
37
37
 
38
- import { CONFIG, PACK_ORDER, THEMES, discoverNames, knownSkillIds } from '../build/source.mjs';
38
+ import { CONFIG, PACK_ORDER, THEMES, discoverNames, resolveSkillNames } from '../build/source.mjs';
39
39
  import { CLAUDE_STYLE, GLOBAL_MANIFEST, HOSTS, resolvePath } from './hosts.mjs';
40
40
  import { registryRoots } from '../inspect/registry.mjs';
41
41
  import { printErr, readText, isFile, isDir } from '../lib/fs.mjs';
@@ -408,7 +408,7 @@ export function excludedRulesOfDir(d) {
408
408
  }, []);
409
409
  }
410
410
 
411
- /** `Excluded skills: bruno-test-writer, daydream` — written only when something is excluded. */
411
+ /** `Excluded skills: bruno, daydream` — written only when something is excluded. */
412
412
  const EXCLUDED_SKILLS_RE = /^Excluded skills:[ \t]*(.+?)[ \t]*$/m;
413
413
 
414
414
  /**
@@ -416,17 +416,23 @@ const EXCLUDED_SKILLS_RE = /^Excluded skills:[ \t]*(.+?)[ \t]*$/m;
416
416
  * re-render the same set. Same semantics as `excludedRulesOfDir`: a missing line is `[]`
417
417
  * (nothing excluded), and a name this checkout does not ship condemns the whole line to `[]`
418
418
  * — re-admitting a skill is the safe direction, and the flag would refuse the name anyway.
419
+ *
420
+ * A name that only got OLDER is not unknown: an alias reads back as its target and a retired
421
+ * name drops out (`resolveSkillNames`), so a skill merge does not quietly re-admit everything
422
+ * else the install left out. Silent unless the caller passes `notify` — status, diff and the
423
+ * console call this on every read; the replays that rebuild an install (`rebuild-all`,
424
+ * `upgrade`, `migrate`) pass it.
419
425
  */
420
- export function excludedSkillsOfDir(d) {
426
+ export function excludedSkillsOfDir(d, notify = null) {
421
427
  return firstCarrier(d, (carrierPath) => {
422
428
  const text = readMaybe(carrierPath);
423
429
  if (text === null) return undefined;
424
430
  const m = EXCLUDED_SKILLS_RE.exec(text);
425
431
  if (!m) return undefined;
426
- const names = m[1].split(',').map((s) => s.trim()).filter(Boolean);
427
- const known = knownSkillIds();
428
- if (!names.every((n) => known.includes(n))) return [];
429
- return [...new Set(names)].sort();
432
+ const { ids, notices, unknown } = resolveSkillNames(m[1].split(',').map((s) => s.trim()).filter(Boolean));
433
+ if (unknown.length) return [];
434
+ if (notify) for (const n of notices) notify(n);
435
+ return ids;
430
436
  }, []);
431
437
  }
432
438
 
package/js/hosts/mcp.mjs CHANGED
@@ -5,7 +5,7 @@
5
5
  * at "~90 new, already in js/: `_mcp_load`/`readJsonc` (P6c), `settings.mjs`'s atomic write".
6
6
  * Measured against `js/`: `readJsonc` had crossed and NOTHING ELSE HAD. `js/web/api.mjs`
7
7
  * carries a five-line `mcpLoad` that is the no-host, comment-tolerant reader P6c needed for
8
- * `wiki.jsonc`, which is one arm of one of these thirteen functions. `_MCP_PRESETS`,
8
+ * `geneseed-wiki.jsonc`, which is one arm of one of these thirteen functions. `_MCP_PRESETS`,
9
9
  * `_mcp_servers_key`, `_mcp_preset_block`, `_mcp_apply`, `_mcp_state`, `_mcp_set_enabled`,
10
10
  * `_mcp_load`'s host fork, `_mcp_commented`, `_mcp_save`, `_mcp_config_for`,
11
11
  * `_mcp_install_targets`, `_mcp_known_names` and `_mcp_meta` are 168 lines of Python, all
@@ -32,6 +32,7 @@ import path from 'node:path';
32
32
  import { writeText, copyFile, readText } from '../lib/fs.mjs';
33
33
  import { jsonDumps, parseJson, formatValue, formatRepr, isTruthy, isDict } from '../lib/json.mjs';
34
34
  import { relPosix } from '../lib/text.mjs';
35
+ import { aliasesOf } from '../build/source.mjs';
35
36
 
36
37
  /** What a folder skill writes where it needs its own directory — see `writeNativeLayer`. */
37
38
  const SKILL_DIR_PLACEHOLDER = '<this-skill-directory>';
@@ -253,6 +254,25 @@ export function stripSkillBodyLinks(body) {
253
254
  return body.replace(SKILL_BODY_LINK_RE, '$1');
254
255
  }
255
256
 
257
+ /**
258
+ * A first-party skill's SIDE FILES (`src/skills/<stem>/<file>`, beside the flat spec) are
259
+ * linked from the spec as `[…](<stem>/<file>)` — the path that resolves in `src/` and in the
260
+ * flat bundle, where doctor's dead-link scan proves it. A native skill sits one level deeper
261
+ * (`skills/<stem>/SKILL.md`), and an alias or a command copies the body somewhere else again,
262
+ * so there the link becomes a code-span path under `dir`: the placeholder a host that
263
+ * announces the skill's base directory resolves, or the answer for one that does not.
264
+ *
265
+ * A link with an `#anchor` (which `sideFileProblems` accepts) gets the same pointer with the
266
+ * anchor DROPPED: the pointer is a path the agent hands to a file-read tool, and
267
+ * `deep.md#two` names no file. Left unmatched, `stripSkillBodyLinks` would reduce the link to
268
+ * its label and the agent would never learn where the file is.
269
+ */
270
+ export function pointSideFiles(body, stem, dir) {
271
+ // A function, not a `$1` string: `dir` is a real path and may itself hold a `$`.
272
+ return body.replace(new RegExp(`\\[[^\\]]+\\]\\(${stem}/([^)\\s#]+)(?:#[^)\\s]*)?\\)`, 'g'),
273
+ (_m, f) => `\`${dir}/${f}\``);
274
+ }
275
+
256
276
  /** `_build_emit._agent_color_map` — validated, always a valid OpenCode slot. */
257
277
  function agentColorMap(theme) {
258
278
  let raw = null;
@@ -479,8 +499,8 @@ export function claimer(oldOwned, cfg, manifestExisted = true) {
479
499
  * `<this-skill-directory>/…`; Claude Code resolves that because it announces each skill's
480
500
  * base directory on load, while on Bob the model got the text with a hole in it and went
481
501
  * hunting through the filesystem. With the option, every rendered `.md` of a vendored folder
482
- * has the placeholder replaced by the answer. Only vendored folders carry the placeholder — a
483
- * flat skill has no siblings to point at.
502
+ * has the placeholder replaced by the answer. A flat skill with side files points at them
503
+ * through the same option (`pointSideFiles`), placeholder when it is absent.
484
504
  *
485
505
  * `opts.claim` — a `claimer` closure the caller shares with its other writers; built from
486
506
  * `oldOwned`/`cfg`/`manifestExisted` when absent.
@@ -498,13 +518,15 @@ export function claimer(oldOwned, cfg, manifestExisted = true) {
498
518
  */
499
519
  export function writeNativeLayer(items, agentsDir, skillsDir, overrides = null, {
500
520
  host = 'opencode', oldOwned = null, cfg = null, manifestExisted = true,
501
- theme = null, src, skillDirOf = null, claim = claimer(oldOwned, cfg, manifestExisted),
521
+ theme = null, src = undefined, skillDirOf = null, claim = claimer(oldOwned, cfg, manifestExisted),
502
522
  } = {}) {
503
523
  const ov = overrides || {};
504
524
  if (host === 'claude') warnClaudeIgnored(ov);
505
525
  let nAgents = 0;
506
526
  let nSkills = 0;
507
527
  const written = [];
528
+ const sideFiles = []; // [stem, dest, text], written after the loop
529
+ const claimedSkills = new Set(); // stems whose own SKILL.md claim held
508
530
 
509
531
  const write = (dest, text) => {
510
532
  mkdirSync(path.dirname(dest), { recursive: true });
@@ -534,6 +556,15 @@ export function writeNativeLayer(items, agentsDir, skillsDir, overrides = null,
534
556
  continue;
535
557
  }
536
558
  if (text === null) continue;
559
+ // A first-party side file rides next to its skill's SKILL.md, read only when the body
560
+ // points the agent at it; not a skill, so not counted. `sideFileProblems` holds the
561
+ // layout (a flat spec beside it, one level, `.md`, linked from the body). Held until the
562
+ // loop ends: written only once its skill's own SKILL.md claim held (the alias rule), so a
563
+ // user's file at the target never gets a geneseed side file dropped beside it.
564
+ if (sparts.length === 3 && sparts[0] === 'skills') {
565
+ sideFiles.push([sparts[1], path.join(skillsDir, sparts[1], sparts[2]), text]);
566
+ continue;
567
+ }
537
568
  if (sparts.length !== 2 || !sparts[1].endsWith('.md')) continue;
538
569
  const [folder, fname] = sparts;
539
570
  const targetDir = { agents: agentsDir, skills: skillsDir }[folder];
@@ -567,18 +598,42 @@ export function writeNativeLayer(items, agentsDir, skillsDir, overrides = null,
567
598
  } else {
568
599
  // Skills are BYTE-IDENTICAL across hosts: name + description, body link-stripped.
569
600
  // The user-only key is emitted for every host too — Claude Code and Bob honour it,
570
- // OpenCode ignores an unknown key — so the identity holds.
601
+ // OpenCode ignores an unknown key — so the identity holds. ALIASES are the one
602
+ // exception: OpenCode has no user-only skill, so an alias skill there would land in the
603
+ // model's catalogue as a duplicate; it gets a command instead (`writeAliasCommands`).
571
604
  fm = [`name: ${stem}`, `description: ${jsonDumps(skillDescription(text))}`];
572
605
  if (isUserInvokedOnly(text)) fm.push('disable-model-invocation: true');
573
- body = stripSkillBodyLinks(body);
606
+ body = stripSkillBodyLinks(pointSideFiles(body, stem, skillDirOf ? skillDirOf(stem)
607
+ : SKILL_DIR_PLACEHOLDER));
574
608
  dest = path.join(skillsDir, stem, 'SKILL.md');
575
609
  kind = 'skill';
576
610
  }
577
611
  if (!claim(dest)) continue;
578
612
  write(dest, `---\n${fm.join('\n')}\n---\n\n${body}`);
613
+ if (kind === 'skill') claimedSkills.add(stem);
614
+ // An old name keeps answering `/name`: user-only, so the model's catalogue lists the
615
+ // skill once. Read off the already-filtered items, so excluding the target drops its
616
+ // aliases; through `claim`, so manifest, prune and uninstall own them; not counted.
617
+ // Only once the target's own claim held: a user's file at the target gets no alias
618
+ // pointing at a skill this install never wrote.
619
+ if (kind === 'skill' && host === 'claude') {
620
+ // The host announces the ALIAS's folder as the base directory, so a side-file pointer
621
+ // climbs back to the target's.
622
+ const aliasBody = skillDirOf ? body : stripSkillBodyLinks(
623
+ pointSideFiles(lstripNewlines(text), stem, `${SKILL_DIR_PLACEHOLDER}/../${stem}`));
624
+ for (const alias of aliasesOf(text)) {
625
+ const aliasDest = path.join(skillsDir, alias, 'SKILL.md');
626
+ if (!claim(aliasDest)) continue;
627
+ write(aliasDest, `---\nname: ${alias}\ndescription: ${jsonDumps(`Alias of ${stem}.`)}\n`
628
+ + `disable-model-invocation: true\n---\n\n${aliasBody}`);
629
+ }
630
+ }
579
631
  if (kind === 'agent') nAgents += 1;
580
632
  else nSkills += 1;
581
633
  }
634
+ for (const [stem, dest, text] of sideFiles) {
635
+ if (claimedSkills.has(stem) && claim(dest)) write(dest, stripSkillBodyLinks(text));
636
+ }
582
637
  return { nAgents, nSkills, written };
583
638
  }
584
639
 
@@ -27,7 +27,8 @@ import { writeText, readText, copyFile } from '../lib/fs.mjs';
27
27
  import { jsonDumps, jsonDumpsIndent, parseJson } from '../lib/json.mjs';
28
28
  import { comparePaths } from '../lib/paths.mjs';
29
29
  import { sourceReleaseVersion } from '../build/version.mjs';
30
- import { firstBlockquote, stripSkillBodyLinks, pushOverrideLines } from './native.mjs';
30
+ import { aliasesOf } from '../build/source.mjs';
31
+ import { firstBlockquote, stripSkillBodyLinks, pointSideFiles, pushOverrideLines } from './native.mjs';
31
32
 
32
33
  // Re-exported for `js/web/api.mjs` and the tests, which import it from here.
33
34
  export { sourceReleaseVersion };
@@ -104,7 +105,7 @@ const AGENT_OVERRIDES_STUB = {
104
105
  * `/code-review`. As a list the renamed entry missed its skill and was dropped in silence, so a
105
106
  * missing skill is now a thrown error in `writeCommandLayer`.
106
107
  */
107
- const COMMAND_SET = {
108
+ export const COMMAND_SET = {
108
109
  commit: 'commit', plan: 'plan', 'code-review': 'geneseed-code-review',
109
110
  'review-response': 'review-response', ship: 'ship', debug: 'debug', research: 'research',
110
111
  };
@@ -276,7 +277,7 @@ export function ensureAgentOverridesStub(cfg, base) {
276
277
  * `command/commit.md` without the check made uninstall delete it. Default: claim everything,
277
278
  * for a caller with no manifest to consult.
278
279
  */
279
- export function writePrimaryAgent(cfg, agentsDir, overrides, claim = () => true) {
280
+ export function writePrimaryAgent(cfg, agentsDir, overrides, claim = (_dest) => true) {
280
281
  if (!truthyEnv('GENESEED_PRIMARY') || !existsSync(cfg.primaryAgentSrc)
281
282
  || !statSync(cfg.primaryAgentSrc).isFile()) {
282
283
  return null;
@@ -296,7 +297,8 @@ export function writePrimaryAgent(cfg, agentsDir, overrides, claim = () => true)
296
297
  }
297
298
 
298
299
  /** `_build_emit._write_command_layer` — the opt-in /slash commands. */
299
- export function writeCommandLayer(cfg, items, commandDir, claim = () => true) {
300
+ export function writeCommandLayer(cfg, items, commandDir, claim = (_dest) => true,
301
+ skillDirOf = (n) => path.join(path.dirname(commandDir), 'skills', n).split(path.sep).join('/')) {
300
302
  if (!truthyEnv('GENESEED_COMMANDS')) return [];
301
303
  const byName = new Map();
302
304
  for (const { text, src } of items) {
@@ -318,7 +320,8 @@ export function writeCommandLayer(cfg, items, commandDir, claim = () => true) {
318
320
  const dest = path.join(commandDir, `${name}.md`);
319
321
  if (!claim(dest)) continue;
320
322
  const desc = firstBlockquote(text);
321
- const body = stripSkillBodyLinks(text.replace(/^\n+/, ''));
323
+ // A command has no base directory, so a side-file pointer names the skill's own.
324
+ const body = stripSkillBodyLinks(pointSideFiles(text.replace(/^\n+/, ''), skill, skillDirOf(skill)));
322
325
  mkdirSync(path.dirname(dest), { recursive: true });
323
326
  writeText(dest, `---\ndescription: ${jsonDumps(desc)}\n---\n\n${body}`);
324
327
  written.push(dest);
@@ -326,8 +329,36 @@ export function writeCommandLayer(cfg, items, commandDir, claim = () => true) {
326
329
  return written;
327
330
  }
328
331
 
332
+ /**
333
+ * A merged skill's OLD names as `/alias` commands — OpenCode's form of the user-only alias
334
+ * skill `writeNativeLayer` writes for the Claude dialect. A command, not a skill, because
335
+ * OpenCode has no user-only skill: an alias skill would sit in the model's catalogue as a
336
+ * second copy of its target. UNCONDITIONAL, unlike the `GENESEED_COMMANDS` set — an old name
337
+ * that only works behind an opt-in is a name that broke. Read off the already-filtered
338
+ * `items`, so excluding the target drops its aliases.
339
+ */
340
+ export function writeAliasCommands(cfg, items, commandDir, claim = (_dest) => true,
341
+ skillDirOf = (n) => path.join(path.dirname(commandDir), 'skills', n).split(path.sep).join('/')) {
342
+ const written = [];
343
+ for (const { text, src } of items) {
344
+ if (text === null) continue;
345
+ const sp = path.relative(cfg.src, src).split(path.sep);
346
+ if (sp.length !== 2 || sp[0] !== 'skills' || !sp[1].endsWith('.md') || sp[1].startsWith('_')) continue;
347
+ const stem = sp[1].slice(0, -3);
348
+ const body = stripSkillBodyLinks(pointSideFiles(text.replace(/^\n+/, ''), stem, skillDirOf(stem)));
349
+ for (const alias of aliasesOf(text)) {
350
+ const dest = path.join(commandDir, `${alias}.md`);
351
+ if (!claim(dest)) continue;
352
+ mkdirSync(path.dirname(dest), { recursive: true });
353
+ writeText(dest, `---\ndescription: ${jsonDumps(`Alias of ${sp[1].slice(0, -3)}.`)}\n---\n\n${body}`);
354
+ written.push(dest);
355
+ }
356
+ }
357
+ return written;
358
+ }
359
+
329
360
  /** `_build_emit._write_ponytail_command` — registered UNCONDITIONALLY; null when the user's. */
330
- export function writePonytailCommand(commandDir, claim = () => true) {
361
+ export function writePonytailCommand(commandDir, claim = (_dest) => true) {
331
362
  const dest = path.join(commandDir, 'ponytail.md');
332
363
  if (!claim(dest)) return null;
333
364
  mkdirSync(path.dirname(dest), { recursive: true });
@@ -165,6 +165,7 @@ const OWNED_BASH = ['rm -rf *', 'git commit*', 'git push*', ...LAW_IV_BASH];
165
165
  * caller warns on it. This is the one branch where the boundary can end up with NO Geneseed
166
166
  * gate at all while the constitution states the rule — the unsafe direction — so it must not
167
167
  * also be the silent one.
168
+ * @returns {[boolean, string[], string | null]}
168
169
  */
169
170
  function reconcileOpencodePermission(perm, want) {
170
171
  // A `permission` that is not an object, or whose `bash` is not one — OpenCode also accepts a
package/js/hosts/shim.mjs CHANGED
@@ -215,6 +215,7 @@ function shimIsLive(p) {
215
215
  * The fallback is strictly no worse than the old behaviour and far better than emitting
216
216
  * commands naming a shim that does not exist — those fail on every hook (9009 under
217
217
  * cmd.exe, 127 under sh) and take both gates down with them.
218
+ * @param {{ runner?: string, entry?: string, platform?: NodeJS.Platform }} [opts]
218
219
  */
219
220
  export function hookPrefix({ runner, entry, platform = process.platform } = {}) {
220
221
  // NOT a default. `runner`/`entry` have no computable fallback on this side — this process's
@@ -19,7 +19,8 @@ import { readText } from '../lib/fs.mjs';
19
19
  import { which } from '../lib/paths.mjs';
20
20
  import { NO_WINDOW } from '../lib/proc.mjs';
21
21
  import {
22
- folderSkillProblems, registryProblems, secretProblems, vendorPinProblems,
22
+ aliasProblems, folderSkillProblems, registryProblems, secretProblems, sideFileProblems,
23
+ vendorPinProblems,
23
24
  } from './checks-repo.mjs';
24
25
  import { constitutionProblems, leanBlockProblems } from './checks-constitution.mjs';
25
26
  import { countTableProblems } from './checks-counts.mjs';
@@ -109,6 +110,8 @@ export function authoringProblems() {
109
110
  problems.push(...secretProblems());
110
111
  problems.push(...vendorPinProblems());
111
112
  problems.push(...folderSkillProblems());
113
+ problems.push(...aliasProblems());
114
+ problems.push(...sideFileProblems());
112
115
  problems.push(...constitutionProblems());
113
116
  problems.push(...leanBlockProblems());
114
117
  problems.push(...countTableProblems());
@@ -116,7 +116,7 @@ const TMPL_SPEC_RE = /\{\{DIR_(AGENTS|SKILLS)\}\}\/([A-Za-z0-9_-]+)\.md/g;
116
116
  * badges, against `src/`.
117
117
  *
118
118
  * The tables must list EXACTLY the spec files (no dead row, no orphaned spec) and each
119
- * `agents`/`skills`/`laws`/`themes` badge must equal the real count. This is the
119
+ * `agents`/`skills`/`laws`/`doctrines`/`themes` badge must equal the real count. This is the
120
120
  * authoring-time guarantee that lets tables, badges and prose stay hand-written without
121
121
  * silently drifting from the source tree.
122
122
  */
@@ -151,7 +151,8 @@ export function countTableProblems() {
151
151
 
152
152
  // The numerals the laws RENDER with — positional, off the canon — because LAW_CLASS and the
153
153
  // console's LAW_META are keyed the way the rendered catalogue numbers them.
154
- const lawRules = ruleCanon(SRC).rules.filter((r) => r.kind === 'LAW');
154
+ const canonRules = ruleCanon(SRC).rules;
155
+ const lawRules = canonRules.filter((r) => r.kind === 'LAW');
155
156
  const lawNums = lawRules.map((r) => r.label);
156
157
  for (const num of lawNums) {
157
158
  if (!has(LAW_CLASS, num)) {
@@ -179,6 +180,9 @@ export function countTableProblems() {
179
180
  agents: srcStems('agents').size,
180
181
  skills: shippedSkills.size,
181
182
  laws: lawNums.length,
183
+ // Every doctrine rule in every pack, opt-in ones included: the laws badge alone told a
184
+ // reader the harness has 9 rules. It rides the laws badge (`laws-9 + 28 doctrines`).
185
+ doctrines: canonRules.filter((r) => r.kind === 'DOCTRINE').length,
182
186
  themes: themeFiles().length,
183
187
  plugins: existsSync(PLUGIN_SRC)
184
188
  ? readdirSync(PLUGIN_SRC).filter((f) => f.startsWith('geneseed-') && f.endsWith('.js')).length
@@ -190,7 +194,8 @@ export function countTableProblems() {
190
194
  // same reason: `if (m && …)` is green when there is no badge, so deleting a badge silences its
191
195
  // own gate.
192
196
  for (const [key, n] of Object.entries(counts)) {
193
- const m = new RegExp(`badge/${key}-(\\d+)`).exec(readme);
197
+ const m = (key === 'doctrines' ? /badge\/laws-\d+%20%2B%20(\d+)%20doctrines/
198
+ : new RegExp(`badge/${key}-(\\d+)`)).exec(readme);
194
199
  if (!m) {
195
200
  problems.push(`[authoring] README has no ${key} badge — deleting one is how this gate `
196
201
  + 'goes green while the reader is told nothing');