geneseed 3.17.0 → 3.18.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 (150) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/README.md +17 -3
  3. package/SHIPPED.md +3 -3
  4. package/adapters/claude-code/skills/geneseed/SKILL.md +1 -1
  5. package/adapters/opencode/README.md +2 -2
  6. package/adapters/opencode/plugins/geneseed-context.js +19 -17
  7. package/adapters/opencode/plugins/geneseed-guard.js +7 -7
  8. package/bin/geneseed-cli.mjs +5 -0
  9. package/docs/concepts/exclusions.md +1 -1
  10. package/docs/concepts/skills.md +4 -0
  11. package/docs/extending.md +33 -0
  12. package/docs/guides/read-non-markdown.md +1 -1
  13. package/docs/guides/uninstall.md +1 -1
  14. package/docs/guides/upgrade.md +1 -1
  15. package/docs/guides/wiki.md +4 -4
  16. package/docs/opencode-plugin-setup.md +1 -1
  17. package/docs/reference/env-context.md +2 -2
  18. package/docs/reference/plugin-context.md +2 -2
  19. package/docs/reference/plugin-guard.md +1 -1
  20. package/docs/token-footprint.md +18 -15
  21. package/docs/understand/take-it-out.md +1 -1
  22. package/harness.config.json +1 -1
  23. package/js/README.md +4 -1
  24. package/js/build/args.mjs +22 -20
  25. package/js/build/bundle.mjs +1 -3
  26. package/js/build/emit-claude.mjs +5 -4
  27. package/js/build/emit-opencode.mjs +9 -2
  28. package/js/build/generate.mjs +6 -6
  29. package/js/build/render.mjs +19 -12
  30. package/js/build/source.mjs +82 -7
  31. package/js/build/stubs.mjs +35 -6
  32. package/js/build/themes.mjs +1 -3
  33. package/js/cli-table.json +53 -0
  34. package/js/hosts/hooks-context.mjs +13 -6
  35. package/js/hosts/hosts.mjs +1 -3
  36. package/js/hosts/installs.mjs +13 -7
  37. package/js/hosts/mcp.mjs +1 -1
  38. package/js/hosts/native.mjs +60 -5
  39. package/js/hosts/opencode.mjs +37 -6
  40. package/js/hosts/settings.mjs +1 -0
  41. package/js/hosts/shim.mjs +1 -0
  42. package/js/inspect/checks-authoring.mjs +4 -1
  43. package/js/inspect/checks-repo.mjs +164 -2
  44. package/js/inspect/diff.mjs +5 -1
  45. package/js/inspect/doctor.mjs +2 -1
  46. package/js/inspect/inventory.mjs +7 -9
  47. package/js/inspect/scorecard-checks.mjs +571 -0
  48. package/js/inspect/scorecard-svg.mjs +85 -0
  49. package/js/inspect/scorecard.mjs +839 -0
  50. package/js/lib/json.mjs +1 -1
  51. package/js/maintain/migrate.mjs +2 -1
  52. package/js/maintain/uninstall.mjs +2 -3
  53. package/js/maintain/update.mjs +10 -3
  54. package/js/ui/menu.mjs +2 -2
  55. package/js/ui/tui.mjs +1 -0
  56. package/js/web/api.mjs +2 -0
  57. package/js/web/catalog.mjs +24 -7
  58. package/js/web/daemon.mjs +2 -1
  59. package/js/web/routes.mjs +2 -0
  60. package/js/web/server.mjs +1 -1
  61. package/js/web/user-files.mjs +1 -0
  62. package/package.json +11 -3
  63. package/registry.json +12 -75
  64. package/src/AGENT.md.tmpl +13 -22
  65. package/src/agents/docs.md +1 -1
  66. package/src/agents/researcher.md +1 -1
  67. package/src/bricks/bruno-test.md +2 -2
  68. package/src/bricks/ci-fix.md +3 -3
  69. package/src/bricks/deps-audit.md +3 -3
  70. package/src/bricks/migration-plan.md +2 -2
  71. package/src/doctrines/process.md +2 -2
  72. package/src/laws/universal.md +2 -2
  73. package/src/skills/brainstorm.md +14 -10
  74. package/src/skills/bruno.md +84 -0
  75. package/src/skills/codebase-design.md +41 -23
  76. package/src/skills/daydream/SKILL.md +3 -3
  77. package/src/skills/daydream/VENDOR.md +2 -2
  78. package/src/skills/daydream/instructions.md +1 -1
  79. package/src/skills/debug/ci.md +11 -0
  80. package/src/skills/debug.md +11 -8
  81. package/src/skills/dependencies.md +65 -0
  82. package/src/skills/develop.md +9 -4
  83. package/src/skills/document-project.md +27 -22
  84. package/src/skills/geneseed-code-review.md +13 -8
  85. package/src/skills/geneseed.md +4 -1
  86. package/src/skills/git-history.md +78 -0
  87. package/src/skills/handoff.md +4 -3
  88. package/src/skills/parallel-agents.md +1 -0
  89. package/src/skills/plan.md +5 -1
  90. package/src/skills/prototype.md +10 -7
  91. package/src/skills/research.md +6 -1
  92. package/src/skills/rule/consolidate.md +32 -0
  93. package/src/skills/rule.md +14 -4
  94. package/src/skills/security-audit.md +1 -1
  95. package/src/skills/ship.md +8 -5
  96. package/src/skills/teach.md +5 -3
  97. package/src/skills/wayfinder.md +19 -16
  98. package/src/skills/wiki.md +2 -2
  99. package/themes/_TEMPLATE.json +7 -16
  100. package/themes/biker.json +7 -16
  101. package/themes/commentator.json +7 -16
  102. package/themes/cyberpunk.json +7 -16
  103. package/themes/gamer.json +7 -16
  104. package/themes/imperial.json +7 -16
  105. package/themes/joker.json +7 -16
  106. package/themes/marvin.json +7 -16
  107. package/themes/mean.json +7 -16
  108. package/themes/military.json +7 -16
  109. package/themes/neutral.json +7 -16
  110. package/themes/pirate.json +7 -16
  111. package/themes/sports.json +7 -16
  112. package/themes/verstappen.json +7 -16
  113. package/themes/wizard.json +7 -16
  114. package/web/dist/assets/{Activity-DonKN1pi.js → Activity-4M4VQJOh.js} +1 -1
  115. package/web/dist/assets/{ActivityDetail-e47a3cdE.js → ActivityDetail-CnmDs43F.js} +1 -1
  116. package/web/dist/assets/{Dashboard-Cr7i0PsO.js → Dashboard-lTaH58R9.js} +2 -2
  117. package/web/dist/assets/{Docs-Lne3nuzD.js → Docs-DU5znu5f.js} +1 -1
  118. package/web/dist/assets/FilterInput-BSfpStOZ.js +1 -0
  119. package/web/dist/assets/{GreenhouseView-4CbIuTpl.js → GreenhouseView-BxO84bKX.js} +1 -1
  120. package/web/dist/assets/{Installs-CIzbV1Ks.js → Installs-B1WcObxK.js} +1 -1
  121. package/web/dist/assets/{JournalView-BlBfuvzj.js → JournalView-0YNKcaaD.js} +1 -1
  122. package/web/dist/assets/{Laws-B-pjcjHr.js → Laws-DnDpRwpE.js} +1 -1
  123. package/web/dist/assets/Library-DBkKsBMe.js +1 -0
  124. package/web/dist/assets/{Loops-DIOxW1xH.js → Loops-P-6RCdzS.js} +1 -1
  125. package/web/dist/assets/Markdown-C6fRd4A4.js +67 -0
  126. package/web/dist/assets/{OperatorHudView-D7S7z7CJ.js → OperatorHudView-BZZ3uJPH.js} +1 -1
  127. package/web/dist/assets/{Personal-Ws3v35AB.js → Personal-BoVw6OCW.js} +1 -1
  128. package/web/dist/assets/{Tabs-6Z1eziAq.js → Tabs-VYLhUTiX.js} +1 -1
  129. package/web/dist/assets/{activity-Dr7ZMBI3.js → activity-dcg5vF9j.js} +1 -1
  130. package/web/dist/assets/{drawable-ATNeYKbT.js → drawable-CdNARIKC.js} +1 -1
  131. package/web/dist/assets/{index-CDg5XUhG.js → index-hsabpYzv.js} +2 -2
  132. package/web/dist/assets/react-DRhoX0kS.js +9 -0
  133. package/web/dist/assets/{usePoll-DD8VjtSg.js → usePoll-BmR631BB.js} +1 -1
  134. package/web/dist/index.html +2 -2
  135. package/src/skills/bruno-collection-generator.md +0 -52
  136. package/src/skills/bruno-test-writer.md +0 -51
  137. package/src/skills/ci-fix.md +0 -49
  138. package/src/skills/consolidate-memory.md +0 -50
  139. package/src/skills/deps-audit.md +0 -46
  140. package/src/skills/domain-modeling.md +0 -27
  141. package/src/skills/git-archaeology.md +0 -36
  142. package/src/skills/git-rescue.md +0 -58
  143. package/src/skills/migrate.md +0 -34
  144. package/src/skills/opencode-theme.md +0 -42
  145. package/src/skills/repo-map.md +0 -25
  146. package/src/skills/tickets.md +0 -33
  147. package/web/dist/assets/FilterInput-CVyqmYWM.js +0 -1
  148. package/web/dist/assets/Library-x8Nzg6yq.js +0 -1
  149. package/web/dist/assets/Markdown-X2a3qvOh.js +0 -38
  150. package/web/dist/assets/react-B9Z_tXRV.js +0 -8
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;
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
  }