geneseed 4.9.4 → 4.10.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 (133) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/README.md +13 -0
  3. package/SHIPPED.md +1 -1
  4. package/docs/README.md +1 -1
  5. package/docs/concepts/collaboration.md +1 -1
  6. package/docs/concepts/enforcement-table.md +1 -1
  7. package/docs/concepts/foreman-mode.md +1 -1
  8. package/docs/concepts/kits.md +79 -0
  9. package/docs/concepts/loop-branch.md +1 -1
  10. package/docs/concepts/loop-bricks.md +1 -1
  11. package/docs/concepts/loop-contracts.md +1 -1
  12. package/docs/concepts/loop-gates.md +1 -1
  13. package/docs/concepts/loop-notes.md +1 -1
  14. package/docs/concepts/loop-risk.md +1 -1
  15. package/docs/concepts/loop-templates.md +1 -1
  16. package/docs/concepts/loops.md +1 -1
  17. package/docs/concepts/lsp.md +1 -1
  18. package/docs/extending.md +108 -4
  19. package/docs/guides/install-scripted.md +1 -1
  20. package/docs/reference/glossary.md +1 -0
  21. package/docs/web-ui.md +4 -0
  22. package/harness.config.json +1 -1
  23. package/js/build/args.mjs +27 -6
  24. package/js/build/driver.mjs +29 -8
  25. package/js/build/emit-claude.mjs +3 -1
  26. package/js/build/generate.mjs +10 -5
  27. package/js/build/render.mjs +94 -11
  28. package/js/build/source.mjs +117 -4
  29. package/js/cli-table.json +15 -0
  30. package/js/hosts/installs.mjs +35 -2
  31. package/js/hosts/native.mjs +14 -4
  32. package/js/inspect/checks-authoring.mjs +6 -3
  33. package/js/inspect/checks-build.mjs +7 -1
  34. package/js/inspect/checks-counts.mjs +153 -6
  35. package/js/inspect/checks-repo.mjs +95 -20
  36. package/js/inspect/diff.mjs +3 -1
  37. package/js/inspect/inventory.mjs +1 -0
  38. package/js/inspect/status.mjs +44 -3
  39. package/js/inspect/validate.mjs +3 -3
  40. package/js/maintain/setup.mjs +30 -6
  41. package/js/maintain/update.mjs +1 -1
  42. package/js/web/actions.mjs +30 -6
  43. package/js/web/api.mjs +40 -3
  44. package/js/web/routes.mjs +2 -1
  45. package/package.json +1 -1
  46. package/registry.json +63 -0
  47. package/src/AGENT.md.tmpl +2 -2
  48. package/src/agents/devops-engineer.md +45 -0
  49. package/src/agents/docker-expert.md +45 -0
  50. package/src/agents/kubernetes-specialist.md +45 -0
  51. package/src/agents/reviewer.md +2 -0
  52. package/src/agents/spring-boot-engineer.md +48 -0
  53. package/src/agents/spring-reviewer.md +49 -0
  54. package/src/agents/spring-security.md +50 -0
  55. package/src/kits/spring-boot/rules.md +10 -0
  56. package/src/kits/spring-boot.json +23 -0
  57. package/src/licenses/mattpocock-skills.txt +21 -0
  58. package/src/licenses/piomin-spring-boot.txt +190 -0
  59. package/src/licenses/superpowers.txt +21 -0
  60. package/src/licenses/vibe-wise.txt +21 -0
  61. package/src/skills/brainstorm/builder-check.md +2 -0
  62. package/src/skills/brainstorm.md +4 -0
  63. package/src/skills/codebase-design.md +4 -0
  64. package/src/skills/daydream/VENDOR.md +7 -7
  65. package/src/skills/debug.md +5 -1
  66. package/src/skills/develop.md +2 -0
  67. package/src/skills/geneseed-code-review.md +6 -2
  68. package/src/skills/git-history.md +2 -0
  69. package/src/skills/handoff.md +2 -0
  70. package/src/skills/learn-mode.md +3 -2
  71. package/src/skills/parallel-agents.md +2 -0
  72. package/src/skills/pipeline.md +2 -0
  73. package/src/skills/plan.md +4 -0
  74. package/src/skills/prototype.md +2 -0
  75. package/src/skills/react-view-transitions/AGENTS.md +646 -221
  76. package/src/skills/react-view-transitions/SKILL.md +28 -16
  77. package/src/skills/react-view-transitions/VENDOR.md +10 -8
  78. package/src/skills/react-view-transitions/references/css-recipes.md +72 -2
  79. package/src/skills/react-view-transitions/references/implementation.md +14 -26
  80. package/src/skills/react-view-transitions/references/nextjs.md +94 -37
  81. package/src/skills/react-view-transitions/references/patterns.md +60 -48
  82. package/src/skills/react-view-transitions/references/troubleshooting.md +47 -0
  83. package/src/skills/research.md +2 -0
  84. package/src/skills/review-response.md +2 -0
  85. package/src/skills/rule.md +2 -0
  86. package/src/skills/ship.md +2 -0
  87. package/src/skills/skill-forge.md +1 -0
  88. package/src/skills/spring-boot/cloud.md +103 -0
  89. package/src/skills/spring-boot/java-modern.md +71 -0
  90. package/src/skills/spring-boot/logging.md +89 -0
  91. package/src/skills/spring-boot/security.md +112 -0
  92. package/src/skills/spring-boot/web.md +112 -0
  93. package/src/skills/spring-boot.md +57 -0
  94. package/src/skills/spring-data-jpa/patterns.md +68 -0
  95. package/src/skills/spring-data-jpa.md +81 -0
  96. package/src/skills/spring-testing.md +93 -0
  97. package/src/skills/wayfinder.md +2 -0
  98. package/src/upstreams.json +59 -0
  99. package/themes/_TEMPLATE.json +9 -0
  100. package/themes/biker.json +9 -0
  101. package/themes/commentator.json +9 -0
  102. package/themes/cyberpunk.json +9 -0
  103. package/themes/gamer.json +9 -0
  104. package/themes/imperial.json +9 -0
  105. package/themes/joker.json +9 -0
  106. package/themes/marvin.json +9 -0
  107. package/themes/mean.json +9 -0
  108. package/themes/military.json +9 -0
  109. package/themes/neutral.json +9 -0
  110. package/themes/pirate.json +9 -0
  111. package/themes/sports.json +9 -0
  112. package/themes/verstappen.json +9 -0
  113. package/themes/wizard.json +9 -0
  114. package/web/dist/assets/{Activity-C8sZVIUU.js → Activity-BjyJaf8e.js} +1 -1
  115. package/web/dist/assets/{ActivityDetail-Cc6vWUS4.js → ActivityDetail-Bo9nRDNV.js} +1 -1
  116. package/web/dist/assets/{Dashboard-C5ATa3ow.js → Dashboard-BQJGU7vE.js} +2 -2
  117. package/web/dist/assets/{Docs-tDW7i7mo.js → Docs-DLVckkew.js} +1 -1
  118. package/web/dist/assets/{GreenhouseView-zk2Uzc3A.js → GreenhouseView--DML7CW3.js} +1 -1
  119. package/web/dist/assets/{Installs-CB_rvbBP.js → Installs-CxCiln1F.js} +2 -2
  120. package/web/dist/assets/{JournalView-Bfj27XHC.js → JournalView-ymgZleHi.js} +1 -1
  121. package/web/dist/assets/{Laws-BA3CzUf5.js → Laws-BkKnZOBI.js} +1 -1
  122. package/web/dist/assets/Library-C8ercIZv.js +1 -0
  123. package/web/dist/assets/{Loops-DaPk-ckL.js → Loops-C95zhbFl.js} +1 -1
  124. package/web/dist/assets/{OperatorHudView-CyiGhiHk.js → OperatorHudView-B9qwc3w3.js} +1 -1
  125. package/web/dist/assets/{Personal-Bwd27CmT.js → Personal-DyR72AF-.js} +1 -1
  126. package/web/dist/assets/{Tabs-BOD7Kaz1.js → Tabs-ChCr_HqV.js} +1 -1
  127. package/web/dist/assets/api-BfpZmUMI.js +1 -0
  128. package/web/dist/assets/{drawable-CyLe1gtW.js → drawable-CgYi0Dtg.js} +1 -1
  129. package/web/dist/assets/index-B32EvfDT.js +2 -0
  130. package/web/dist/index.html +2 -2
  131. package/web/dist/assets/Library-BWwiy9Vb.js +0 -1
  132. package/web/dist/assets/api-CiXlkbe0.js +0 -1
  133. package/web/dist/assets/index-DQXG_mqo.js +0 -2
@@ -19,7 +19,7 @@ import { assertSourceComplete, phaseLog, ensureMemoryStore } from './bundle.mjs'
19
19
  import {
20
20
  claimAndWrite, globalMemory, globalNotebook, shipLeanLaws, stripCapabilityLinks,
21
21
  } from './emit-common.mjs';
22
- import { renderAll, renderFile } from './render.mjs';
22
+ import { kitsBody, renderAll, renderFile } from './render.mjs';
23
23
  import {
24
24
  BOB_RULES_STUB, ensureContextStub, ensureExcludesStub, ensureNotebookIndex,
25
25
  ensureProfileStub,
@@ -82,6 +82,8 @@ export function emitClaudeRender(cfg, job) {
82
82
  ptheme[tok] = prefix + (ptheme[tok] ?? tok.split('_').slice(1).join('_').toLowerCase());
83
83
  }
84
84
  ptheme.AT_HARNESS = prefix;
85
+ // The Kits block was substituted with the unprefixed DIR_* tokens; rebuild it, or its links dangle.
86
+ ptheme.KITS_BODY = kitsBody(cfg, ptheme, '');
85
87
  // Same catalogue decision as the renderAll above — a re-render that forgot it would
86
88
  // quietly put the stripped tables back.
87
89
  return renderFile(cfg, tmplItem.src, ptheme, footprint, '', new Set(), nativeCatalog);
@@ -40,7 +40,7 @@ import { DEFAULT_PRESET } from '../loop/score.mjs';
40
40
  import { opencodeConfigDir, resolvePath } from '../hosts/hosts.mjs';
41
41
  import {
42
42
  EMIT_HOST_SCOPE, defaultMode, defaultPosture, defaultTheme, doctrinesForBuild,
43
- excludedRulesOfDir, excludedSkillsOfDir, footprintOfDir, gatesOfDir, installState, installTargets,
43
+ excludedRulesOfDir, excludedSkillsOfDir, footprintOfDir, kitsOfDir, gatesOfDir, installState, installTargets,
44
44
  legacyProcessCarrierOfDir, modeOfDir, postureOfDir, readMaybe, themeOfDir, trustOfDir,
45
45
  } from '../hosts/installs.mjs';
46
46
  import { colorThemeFiles, colorThemeJson, PALETTE_ROLES } from '../hosts/opencode.mjs';
@@ -95,7 +95,7 @@ function sysExit(msg) {
95
95
  const BUILD_FORWARD = [
96
96
  ['theme', '--theme'], ['emit', '--emit'], ['footprint', '--footprint'],
97
97
  ['posture', '--posture'], ['mode', '--mode'], ['trust', '--trust'], ['doctrines', '--doctrines'],
98
- ['excludeRules', '--exclude-rules'], ['excludeSkills', '--exclude-skills'], ['out', '--out'], ['root', '--root'],
98
+ ['excludeRules', '--exclude-rules'], ['excludeSkills', '--exclude-skills'], ['kits', '--kits'], ['out', '--out'], ['root', '--root'],
99
99
  ['configDir', '--config-dir'],
100
100
  ];
101
101
 
@@ -141,7 +141,7 @@ export function cmdBuild(args) {
141
141
  */
142
142
  export function setupBuildArgs(theme, emit, out = null, root = null, footprint = 'lean',
143
143
  posture = 'peer', mode = 'direct', doctrines = null, allPacks = PACK_ORDER,
144
- excludeRules = null, trust = DEFAULT_PRESET, excludeSkills = null, gates = {}) {
144
+ excludeRules = null, trust = DEFAULT_PRESET, excludeSkills = null, gates = {}, kits = null) {
145
145
  const argv = ['--theme', theme, '--emit', emit];
146
146
  if (!emit.endsWith('-global')) {
147
147
  if (out) argv.push('--out', out);
@@ -174,6 +174,9 @@ export function setupBuildArgs(theme, emit, out = null, root = null, footprint =
174
174
  // `gates` is one object keyed as `GATE_SWITCHES` keys it (`gatesOfDir`, a profile, a body).
175
175
  // Law order, so a Law IV-only install still ends on `--no-deletion-gate` as it did.
176
176
  for (const g of GATE_SWITCHES) if (gates?.[g.key] === false) argv.push(g.flag);
177
+ // Kits elide when none is on, as skills do: no config key feeds them, so absence and `none`
178
+ // are the same answer. Last, so an argv naming no kit is the bytes it always was.
179
+ if (kits?.length) argv.push('--kits', [...kits].sort().join(','));
177
180
  return argv;
178
181
  }
179
182
 
@@ -235,9 +238,11 @@ export function installProfile(host, scope, root, notify = null) {
235
238
  // The gate switches: a rebuild that forgot one would silently re-arm (or, read wrong, disarm)
236
239
  // the install. A missing marker reads as armed.
237
240
  const gates = gatesOfDir(root, host);
241
+ // Kits on stay on (spec A3); one this checkout no longer ships drops with a notice (R2).
242
+ const kits = kitsOfDir(root, host, notify);
238
243
  const out = scope === 'global' ? null : root;
239
244
  const argv = setupBuildArgs(theme, emit, out, out, footprint, posture, mode, doctrines,
240
- PACK_ORDER, excludeRules, trust, excludeSkills, gates);
245
+ PACK_ORDER, excludeRules, trust, excludeSkills, gates, kits);
241
246
  // A GLOBAL ROW NAMES ITS OWN DIR. `root` came from the host's config-dir resolver, which
242
247
  // honours env overrides (`OPENCODE_CONFIG_DIR`, `CLAUDE_CONFIG_DIR`…); without the flag the
243
248
  // same argv run from a shell lacking that variable re-emits into the DEFAULT dir — a second
@@ -246,7 +251,7 @@ export function installProfile(host, scope, root, notify = null) {
246
251
  return {
247
252
  host, scope, root, state: installState(root, host, scope),
248
253
  emit, theme, footprint, posture, mode, trust, doctrines, excludeRules, excludeSkills,
249
- ...gates, argv,
254
+ kits, ...gates, argv,
250
255
  };
251
256
  }
252
257
 
@@ -34,7 +34,10 @@ import { readText } from '../lib/fs.mjs';
34
34
  import { parseJson } from '../lib/json.mjs';
35
35
  import { normcase, comparePaths } from '../lib/paths.mjs';
36
36
  // `js/build/source.mjs` imports nothing but node builtins, so this direction cannot cycle.
37
- import { GATE_SWITCHES, PACK_ORDER, readPackText, resolveRuleIds, ruleCanon } from './source.mjs';
37
+ import {
38
+ CREDIT_RE, GATE_SWITCHES, PACK_ORDER, kitMembers, loadKits, readPackText, resolveCredits, resolveRuleIds, ruleCanon, upstreamOf,
39
+ } from './source.mjs';
40
+ import { VENDORED_SKILL_DIRS } from '../hosts/native.mjs';
38
41
  import { DEFAULT_PRESET } from '../loop/score.mjs';
39
42
 
40
43
  /** Document STRUCTURE is theme-INDEPENDENT — mirrors `_build_render.STRUCTURE`. */
@@ -66,6 +69,9 @@ export const SRC_DIR_TOKENS = {
66
69
  };
67
70
 
68
71
  const TEXT_SUFFIXES = new Set(['.md', '.tmpl', '.json', '.txt', '.yml', '.yaml']);
72
+ /** Top-level `src/` entries the build reads but never ships: upstream-credit provenance, and the
73
+ * kit manifests (a kit's members ship flat from `skills/` and `agents/`). */
74
+ const NOT_BUNDLED = new Set(['upstreams.json', 'licenses', 'kits']);
69
75
 
70
76
  // Digits are legal after the first character: `DOC_<PACK>_<n>` (the doctrine rule titles) are
71
77
  // the first token names in the project to carry one, and without `0-9` here `{{DOC_CRAFT_1}}`
@@ -160,11 +166,59 @@ export function substitute(text, theme) {
160
166
  }
161
167
 
162
168
  /** `_build_render._resolve_catalogs`. */
163
- function resolveCatalogs(text, nativeCatalog, excludeSkills = []) {
169
+ function resolveCatalogs(text, nativeCatalog, excludeSkills = [], cfg = null) {
164
170
  // `true`/`false` still mean "both kinds", for the plain-bundle callers that pass a boolean.
165
171
  const nat = nativeCatalog === true ? { skills: true, agents: true } : (nativeCatalog || {});
166
- return text.replace(CATALOG_BLOCK_RE, (_m, kind, table, pointer) => (nat[kind] ? pointer
167
- : kind === 'skills' ? dropExcludedSkillRows(table, excludeSkills) : table));
172
+ return text.replace(CATALOG_BLOCK_RE, (_m, kind, table, pointer) => {
173
+ if (nat[kind]) return pointer;
174
+ // Active kits' rows close the table, before `--exclude-skills` drops rows (R5: exclusion wins).
175
+ const full = withTableRows(table, activeKits(cfg).flatMap(([, m]) => m.catalogue?.[kind] ?? []));
176
+ return kind === 'skills' ? dropExcludedSkillRows(full, excludeSkills) : full;
177
+ });
178
+ }
179
+
180
+ /**
181
+ * `rows` inserted after the last row of the block's first table — inside it, not after the
182
+ * folder-skills list that follows §4's table (a table row there renders as broken markup). A
183
+ * block with no table gets them at its end; no rows returns the block as it was.
184
+ */
185
+ function withTableRows(block, rows) {
186
+ if (!rows.length) return block;
187
+ const lines = block.split('\n');
188
+ let i = lines.findIndex((l) => l.startsWith('|'));
189
+ if (i < 0) i = lines.length - 1;
190
+ else while (i < lines.length && lines[i].startsWith('|')) i++;
191
+ lines.splice(i, 0, ...rows);
192
+ return lines.join('\n');
193
+ }
194
+
195
+ /** `loadKits(cfg.src)`, read once per cfg: a render asks for it per CATALOG block. */
196
+ const KITS = new WeakMap();
197
+ function kitsOf(cfg) {
198
+ let kits = KITS.get(cfg);
199
+ if (!kits) KITS.set(cfg, (kits = loadKits(cfg.src)));
200
+ return kits;
201
+ }
202
+
203
+ /** `[kit, manifest]` for each kit `cfg.kits` names, in its (sorted) order. */
204
+ function activeKits(cfg) {
205
+ if (!cfg?.kits?.length) return [];
206
+ const kits = kitsOf(cfg);
207
+ return cfg.kits.map((k) => {
208
+ if (!kits.has(k)) throw new Error(`unknown kit '${k}' (no src/kits/${k}.json)`);
209
+ const m = kits.get(k);
210
+ if (typeof m.rules !== 'string') throw new Error(`src/kits/${k}.json has no 'rules' path`);
211
+ return [k, m];
212
+ });
213
+ }
214
+
215
+ /**
216
+ * The Kits block: each active kit's rules.md, rendered against `theme`. Exported because a
217
+ * carrier re-rendered with prefixed DIR_* tokens (`prefixedAgentText`) must rebuild it too.
218
+ */
219
+ export function kitsBody(cfg, theme, lawsPrefix = '') {
220
+ return activeKits(cfg).map(([, m]) => `\n${renderFile(cfg, path.join(cfg.src, m.rules), theme,
221
+ 'full', lawsPrefix).trim()}\n`).join('');
168
222
  }
169
223
 
170
224
  /**
@@ -240,8 +294,8 @@ function readSource(cfg, p) {
240
294
  let seen = SRC_TEXT.get(cfg);
241
295
  if (!seen) SRC_TEXT.set(cfg, (seen = new Map()));
242
296
  let text = seen.get(p);
243
- // Rule ids resolve here, once per file, so every stage after sees the addressed form.
244
- if (text === undefined) seen.set(p, (text = resolveRuleIds(readText(p), cfg.src)));
297
+ // Rule ids and credits resolve here, once per file, so every stage after sees the addressed form.
298
+ if (text === undefined) seen.set(p, (text = resolveCredits(resolveRuleIds(readText(p), cfg.src), cfg.src)));
245
299
  return text;
246
300
  }
247
301
 
@@ -276,7 +330,7 @@ export function renderFile(cfg, filePath, theme, footprint = 'full', lawsPrefix
276
330
  return inner;
277
331
  });
278
332
 
279
- text = resolveCatalogs(text, nativeCatalog, cfg.excludeSkills ?? []);
333
+ text = resolveCatalogs(text, nativeCatalog, cfg.excludeSkills ?? [], cfg);
280
334
  text = resolveLean(text, footprint);
281
335
  return substitute(text, theme);
282
336
  }
@@ -444,6 +498,12 @@ export function effectiveTheme(cfg, themeName, { footprint = 'full', lawsPrefix
444
498
  const skipped = [...(cfg.excludeSkills ?? [])].sort();
445
499
  // It fills a template line of its own, so its value carries the blank line around it.
446
500
  theme.EXCLUDED_SKILLS_LINE = skipped.length ? `\nExcluded skills: ${skipped.join(', ')}\n` : '';
501
+ // Kits, under the same contract: both empty with no kit, so a default build stays
502
+ // byte-identical. The marker has the skills line's shape so one parser reads both; the block is
503
+ // each active kit's rules.md, plain text (no rule ids, no LEAN halves), the same at either footprint.
504
+ const kits = activeKits(cfg);
505
+ theme.KITS_LINE = kits.length ? `\nActive kits: ${kits.map(([k]) => k).join(', ')}\n` : '';
506
+ theme.KITS_BODY = kitsBody(cfg, theme, lawsPrefix);
447
507
  // The gate switches (`GATE_SWITCHES`: Laws I, III, IV), under the same contract: a line is
448
508
  // written only when its gate is OFF, so a default build stays byte-identical and a missing
449
509
  // line reads back as armed (`gatesOfDir`). The OpenCode plugins read these lines at load, so
@@ -488,7 +548,8 @@ export function destRel(rel) {
488
548
  *
489
549
  * Returns `{ theme, items }` where each item is `{ rel, text, src }` — `rel` is the
490
550
  * themed POSIX output path, `text` is the rendered text or `null` for a binary that the
491
- * caller copies from `src`. Python returns a tuple of tuples; the object form is the
551
+ * caller copies from `src`. A credited skill's licence copy also carries `licenceOf` (the
552
+ * skill's stem), since its `src` is not under that skill. Python returns a tuple of tuples; the object form is the
492
553
  * same data with the field names the Python docstring already uses.
493
554
  * @param {any} cfg
494
555
  * @param {string} themeName
@@ -500,11 +561,16 @@ export function renderAll(cfg, themeName, {
500
561
  const theme = effectiveTheme(cfg, themeName, { footprint, lawsPrefix });
501
562
  const items = [];
502
563
  const skipped = new Set(cfg.excludeSkills ?? []);
564
+ // Members of every kit `--kits` did not name: kit logic, not `excludeSkills`, so no marker.
565
+ const off = kitMembers(new Map([...kitsOf(cfg)].filter(([k]) => !(cfg.kits ?? []).includes(k))));
566
+ const licences = new Map(); // `<stem>/LICENSE-<key>.txt` -> the upstream's licence file
503
567
  for (const file of sortedSourceFiles(cfg.src)) {
504
568
  const rel = path.relative(cfg.src, file);
505
569
  // `--exclude-skills`: the one filter every host's emit and the plain bundle read through.
506
570
  const sp = rel.split(path.sep);
507
571
  if (sp[0] === 'skills' && sp.length >= 2 && skipped.has(sp[1].replace(/\.md$/, ''))) continue;
572
+ if (NOT_BUNDLED.has(sp[0])) continue;
573
+ if (sp.length >= 2 && Object.hasOwn(off, sp[0]) && off[sp[0]].has(sp[1].replace(/\.md$/, ''))) continue;
508
574
  const outRel = destRel(themedRel(rel, theme)).split(path.sep).join('/'); // as_posix()
509
575
  // The on-disk `laws/`, `ontology/` and `doctrines/` are the "complete text" the two lean
510
576
  // pointers promise — §1's and the Doctrines section's. They render at full whatever the
@@ -513,9 +579,26 @@ export function renderAll(cfg, themeName, {
513
579
  // accident, which is a different thing from shipping whole by contract.
514
580
  // Keyed on the SOURCE rel, not the themed one, so a DIR_* rename cannot un-exempt them.
515
581
  const fp = /^(?:laws|ontology|doctrines)[\\/]/.test(rel) ? 'full' : footprint;
516
- items.push(TEXT_SUFFIXES.has(suffixOf(path.basename(file)))
517
- ? { rel: outRel, text: renderFile(cfg, file, theme, fp, lawsPrefix, new Set(), nativeCatalog), src: file }
518
- : { rel: outRel, text: null, src: file });
582
+ const text = TEXT_SUFFIXES.has(suffixOf(path.basename(file)))
583
+ ? renderFile(cfg, file, theme, fp, lawsPrefix, new Set(), nativeCatalog) : null;
584
+ items.push({ rel: outRel, text, src: file });
585
+ // A credited skill ships its upstream's licence beside it, one copy per (skill, key). The
586
+ // token is gone from `text`, so the raw source is scanned; an unknown key or a null
587
+ // licenseFile yields no copy. A vendored folder's VENDOR.md carries a credit too, but the
588
+ // folder already ships its upstream's LICENSE verbatim, so it gets no second copy.
589
+ const stem = sp[0] === 'skills' && sp.length >= 2 ? sp[1].replace(/\.md$/, '') : null;
590
+ if (stem && !VENDORED_SKILL_DIRS.has(stem) && text !== null) {
591
+ for (const [, key] of readText(file).matchAll(CREDIT_RE)) {
592
+ const lic = upstreamOf(key, cfg.src)?.licenseFile;
593
+ if (lic) licences.set(`${stem}/LICENSE-${key}.txt`, path.join(cfg.src, lic));
594
+ }
595
+ }
596
+ }
597
+ // Copied, not rendered (`text: null`): the licence ships byte for byte. `licenceOf` names the
598
+ // skill, because the copy's `src` is the upstream's file, not a path under that skill.
599
+ for (const [name, lic] of [...licences].sort(([a], [b]) => comparePaths(a, b))) {
600
+ items.push({ rel: destRel(themedRel(path.join('skills', name), theme)).split(path.sep).join('/'),
601
+ text: null, src: lic, licenceOf: name.split('/')[0] });
519
602
  }
520
603
  return { theme, items };
521
604
  }
@@ -90,6 +90,14 @@ export const RETIRED_RULE_IDS = {
90
90
  'absence-is-a-claim': { was: 'Law XI', liveAs: null, note: 'folded into verify-before-asserting' },
91
91
  };
92
92
 
93
+ /**
94
+ * Kit ids that must never name a kit (`src/kits/<id>.json`), the reason beside each — the
95
+ * contract `RETIRED_RULE_IDS` keeps for rules. `none` was never a kit: `--kits none` means no kits.
96
+ */
97
+ export const RETIRED_KIT_IDS = {
98
+ none: 'reserved: --kits none switches every kit off',
99
+ };
100
+
93
101
  /** @type {Array<[number, string]>} */
94
102
  const ROMAN = [[1000, 'M'], [900, 'CM'], [500, 'D'], [400, 'CD'], [100, 'C'], [90, 'XC'],
95
103
  [50, 'L'], [40, 'XL'], [10, 'X'], [9, 'IX'], [5, 'V'], [4, 'IV'], [1, 'I']];
@@ -192,6 +200,82 @@ export function resolveRuleIds(text, src = SRC) {
192
200
  .replace(CITE_RE, (whole, kind, id) => { const r = known(kind, id); return r ? `*${r.name}*` : whole; });
193
201
  }
194
202
 
203
+ /** `{{CREDIT:<key>:<path>}}` — `key` names a `src/upstreams.json` entry, `path` a file upstream. */
204
+ export const CREDIT_RE = /\{\{CREDIT:([a-z0-9]+(?:-[a-z0-9]+)*):([A-Za-z0-9._/-]+)\}\}/g;
205
+
206
+ const _upstreams = new Map();
207
+ /**
208
+ * `src/upstreams.json`, parsed — memoised on its raw text the way `ruleCanon` is, so the web
209
+ * daemon sees an edit. A tree without the file credits nothing.
210
+ */
211
+ export function upstreams(src = SRC) {
212
+ const file = path.join(src, 'upstreams.json');
213
+ let text;
214
+ // Not readRawText: its mtime key misses a rewrite inside one mtime tick (seen on Windows CI).
215
+ try { text = readFileSync(file, 'utf8'); } catch { return {}; }
216
+ const hit = _upstreams.get(file);
217
+ if (hit && hit.text === text) return hit.entries;
218
+ const entries = JSON.parse(text);
219
+ _upstreams.set(file, { text, entries });
220
+ return entries;
221
+ }
222
+
223
+ /** The entry a credit key names, or null — `hasOwn`, so `constructor` is not a key. */
224
+ export function upstreamOf(key, src = SRC) {
225
+ const u = upstreams(src);
226
+ return Object.hasOwn(u, key) ? u[key] : null;
227
+ }
228
+
229
+ /**
230
+ * Resolve every credit in `text` into its attribution line. An unknown key is left exactly as
231
+ * written, as `resolveRuleIds` leaves an unknown id, so the doctor can name it.
232
+ */
233
+ export function resolveCredits(text, src = SRC) {
234
+ if (!text.includes('{{CREDIT:')) return text;
235
+ return text.replace(CREDIT_RE, (whole, key, file) => {
236
+ const u = upstreamOf(key, src);
237
+ return u ? `*Adapted from [${u.repo}](https://github.com/${u.repo}) (\`${file}\`) `
238
+ + `by ${u.author} — ${u.license}, modified.*` : whole;
239
+ });
240
+ }
241
+
242
+ /**
243
+ * `src/kits/<kit>.json`, parsed, as `Map<kit, manifest>` in sorted kit order. A manifest is
244
+ * `{ description, skills, agents, rules, rulesWordBudget, catalogue, banned? }`, the sole writer of its
245
+ * kit's membership; the `<kit>/` folder beside it holds the kit's `rules.md` and is not read
246
+ * here. A tree without `src/kits/` has no kits.
247
+ */
248
+ export function loadKits(src = SRC) {
249
+ const dir = path.join(src, 'kits');
250
+ let names;
251
+ try { names = readdirSync(dir).filter((n) => n.endsWith('.json')).sort(); } catch (e) {
252
+ if (e.code === 'ENOENT') return new Map(); // only an absent src/kits means "no kits"
253
+ throw new Error(`src/kits: ${e.message}`, { cause: e });
254
+ }
255
+ return new Map(names.map((n) => {
256
+ let m;
257
+ try { m = JSON.parse(readFileSync(path.join(dir, n), 'utf8')); } catch (e) {
258
+ throw new Error(`src/kits/${n}: ${e.message}`, { cause: e });
259
+ }
260
+ if (m === null || typeof m !== 'object' || Array.isArray(m)) throw new Error(`src/kits/${n}: a manifest must be a JSON object`);
261
+ if (m.banned !== undefined && !(Array.isArray(m.banned) && m.banned.every((b) => typeof b === 'string' && b))) {
262
+ throw new Error(`src/kits/${n}: 'banned' must be an array of non-empty strings`);
263
+ }
264
+ return [n.slice(0, -'.json'.length), m];
265
+ }));
266
+ }
267
+
268
+ /** Every skill and agent the kits handed in claim (a `loadKits` Map, or a subset of one). */
269
+ export function kitMembers(kits) {
270
+ const skills = new Set();
271
+ const agents = new Set();
272
+ for (const m of kits.values()) {
273
+ for (const s of m.skills ?? []) skills.add(s);
274
+ for (const a of m.agents ?? []) agents.add(a);
275
+ }
276
+ return { skills, agents };
277
+ }
278
+
195
279
  /**
196
280
  * `_build_core.ROOT` — the checkout, from this file's own location (`js/..`).
197
281
  *
@@ -295,20 +379,47 @@ export function knownSkillIds(src = SRC) {
295
379
  * not listed, so a new link protects its target the day it is written.
296
380
  */
297
381
  export function linkedSkillIds(src = SRC) {
298
- const linked = new Set();
382
+ // A kit's links to its own members do not protect them (spec R5: excluding a kit skill is
383
+ // allowed); a kit file's link to a CORE skill still does, or excluding it would leave the link dead.
384
+ return [...new Set(specLinks(src).filter((l) => l.kind === 'skills' && !(l.kit && l.kit === l.owner))
385
+ .map((l) => l.name))].sort();
386
+ }
387
+
388
+ /**
389
+ * Every `{{DIR_SKILLS}}/<name>` / `{{DIR_AGENTS}}/<name>` link in `src`'s .md/.tmpl files, outside
390
+ * the §4 skills catalogue table: `{ rel, kind, name, kit, owner }`. `kit` is the kit that owns the
391
+ * file (`kits/<kit>.json`, `kits/<kit>/**`, a member's spec or side file), `owner` the kit the
392
+ * target is a member of; each null for core. `linkedSkillIds` and the doctor's R4 arm
393
+ * (`kitProblems`) read the same walk.
394
+ */
395
+ export function specLinks(src = SRC) {
396
+ // Member -> its kit, per kind; the first kit wins a member two kits claim (R3 names that).
397
+ const owners = { skills: new Map(), agents: new Map() };
398
+ for (const [kit, m] of loadKits(src)) {
399
+ for (const kind of ['skills', 'agents']) for (const n of m[kind] ?? []) if (!owners[kind].has(n)) owners[kind].set(n, kit);
400
+ }
401
+ // Under kits/, only `<kit>.json` and `<kit>/**` belong to a kit; any other file there (a README) is nobody's.
402
+ const ofKit = (sp) => (sp[0] === 'kits'
403
+ ? (sp.length > 2 ? sp[1] : /\.json$/.test(sp[1]) ? sp[1].slice(0, -'.json'.length) : null)
404
+ : (sp.length >= 2 && Object.hasOwn(owners, sp[0]) && owners[sp[0]].get(sp[1].replace(/\.md$/, ''))) || null);
405
+ const links = [];
299
406
  const walk = (d) => {
300
407
  for (const e of readdirSync(d, { withFileTypes: true })) {
301
408
  const f = path.join(d, e.name);
302
409
  if (e.isDirectory()) walk(f);
303
410
  else if (/\.(md|tmpl)$/.test(e.name)) {
411
+ const sp = path.relative(src, f).split(path.sep);
304
412
  const text = readFileSync(f, 'utf8')
305
413
  .replace(/<!-- CATALOG:begin skills -->[\s\S]*?<!-- CATALOG:end -->/, '');
306
- for (const m of text.matchAll(/\{\{DIR_SKILLS\}\}\/([a-z0-9-]+)/g)) linked.add(m[1]);
414
+ for (const m of text.matchAll(/\{\{DIR_(SKILLS|AGENTS)\}\}\/([a-z0-9-]+)/g)) {
415
+ const kind = m[1].toLowerCase();
416
+ links.push({ rel: sp.join('/'), kind, name: m[2], kit: ofKit(sp), owner: owners[kind].get(m[2]) ?? null });
417
+ }
307
418
  }
308
419
  }
309
420
  };
310
421
  walk(src);
311
- return [...linked].sort();
422
+ return links;
312
423
  }
313
424
 
314
425
  /**
@@ -412,7 +523,7 @@ export const GATE_SWITCHES = [
412
523
  */
413
524
  export function makeCfg({
414
525
  posture = 'peer', mode = 'direct', trust = DEFAULT_PRESET, doctrines = PACK_ORDER,
415
- excludeRules = [], excludeSkills = [], ...switches
526
+ excludeRules = [], excludeSkills = [], kits = [], ...switches
416
527
  } = {}) {
417
528
  return {
418
529
  root: ROOT,
@@ -440,6 +551,8 @@ export function makeCfg({
440
551
  excludeRules: Array.isArray(excludeRules) ? [...excludeRules] : [],
441
552
  // Skill names left out of this install — same "unspecified takes nothing away" default.
442
553
  excludeSkills: Array.isArray(excludeSkills) ? [...excludeSkills] : [],
554
+ // Kits switched on for this install — off unless named, so a default build is unchanged.
555
+ kits: Array.isArray(kits) ? [...kits] : [],
443
556
  // The gate switches (`GATE_SWITCHES`: `secretsGate`, `doneGate`, `deletionGate`), each armed
444
557
  // unless its `no*` arg says so in so many words: only a literal `true` disarms, so every
445
558
  // caller that never heard of a switch keeps the gate.
package/js/cli-table.json CHANGED
@@ -193,6 +193,21 @@
193
193
  "type": null,
194
194
  "hidden": false
195
195
  },
196
+ {
197
+ "names": [
198
+ "--kits"
199
+ ],
200
+ "dest": "kits",
201
+ "metavar": null,
202
+ "help": "switch on opt-in kits, comma-separated; 'none' turns every kit off (default: no kits)",
203
+ "choices": null,
204
+ "default": null,
205
+ "required": false,
206
+ "nargs": null,
207
+ "is_flag": false,
208
+ "type": null,
209
+ "hidden": false
210
+ },
196
211
  {
197
212
  "names": [
198
213
  "--out"
@@ -36,7 +36,7 @@ import { existsSync, readdirSync } from 'node:fs';
36
36
  import path from 'node:path';
37
37
 
38
38
  import {
39
- CONFIG, GATE_SWITCHES, PACK_ORDER, THEMES, discoverNames, resolveSkillNames,
39
+ CONFIG, GATE_SWITCHES, PACK_ORDER, RETIRED_KIT_IDS, THEMES, discoverNames, loadKits, resolveSkillNames,
40
40
  } from '../build/source.mjs';
41
41
  import {
42
42
  CLAUDE_STYLE, DISABLED_STASH, GLOBAL_MANIFEST, HOSTS, isHostGlobalDir, liveRecordedHooks,
@@ -585,6 +585,33 @@ export function excludedSkillsOfDir(d, host = null, notify = null) {
585
585
  }, [], host);
586
586
  }
587
587
 
588
+ /** `Active kits: spring-boot` — written only when a kit is on; the skills line's shape. */
589
+ const KITS_RE = /^Active kits:[ \t]*(.+?)[ \t]*$/m;
590
+
591
+ /**
592
+ * The kits a DEPLOYED install has on, read back so `rebuild-all`, `upgrade`, `migrate`, `diff`, the wizard
593
+ * and the console re-render them (spec A3). A missing line is `[]`: no kits, the old install's
594
+ * state. Unlike `excludedSkillsOfDir`, a name this checkout does not ship drops ALONE, with a
595
+ * notice (spec R2): a retired kit must never reach `--kits`, which refuses it and would fail the
596
+ * whole `rebuild-all`, and the kits beside it are still the owner's choice. Silent without `notify`.
597
+ */
598
+ export function kitsOfDir(d, host = null, notify = null) {
599
+ return firstCarrier(d, (carrierPath) => {
600
+ const text = readMaybe(carrierPath);
601
+ if (text === null) return undefined;
602
+ const m = KITS_RE.exec(text);
603
+ if (!m) return undefined;
604
+ const known = loadKits();
605
+ const names = [...new Set(m[1].split(',').map((s) => s.trim()).filter(Boolean))].sort();
606
+ for (const k of names.filter((n) => !known.has(n))) {
607
+ notify?.(Object.hasOwn(RETIRED_KIT_IDS, k)
608
+ ? `kit '${k}' was retired (${RETIRED_KIT_IDS[k]}) — dropped`
609
+ : `kit '${k}' is not shipped by this Geneseed — dropped`);
610
+ }
611
+ return names.filter((n) => known.has(n));
612
+ }, [], host);
613
+ }
614
+
588
615
  /**
589
616
  * Whether a DEPLOYED install keeps one law's hook gate armed — `true` (armed) or `false` (its
590
617
  * `<marker>: off` line, written by a build with that gate's `--no-*-gate` flag; `GATE_SWITCHES`).
@@ -861,7 +888,7 @@ export function installsReferencingShim(shimPath = hookShimPath(), targets = ins
861
888
  export function installedDefaults() {
862
889
  const found = {
863
890
  theme: null, posture: null, mode: null, emit: null, footprint: null, doctrines: null,
864
- excludeRules: null, excludeSkills: null,
891
+ excludeRules: null, excludeSkills: null, kits: null,
865
892
  };
866
893
  const candidates = [];
867
894
  for (const { host, configDir } of HOSTS) {
@@ -904,6 +931,12 @@ export function installedDefaults() {
904
931
  // indistinguishable here, which is exactly what the marker itself already says.
905
932
  if (!found.excludeRules?.length) found.excludeRules = excludedRulesOfDir(base);
906
933
  if (!found.excludeSkills?.length) found.excludeSkills = excludedSkillsOfDir(base);
934
+ // Kits walk the same two-state way: no line and `none` both read `[]`.
935
+ // A broken manifest must not crash the wizard here: the build that follows still fails loudly
936
+ // in `loadKits`. Not guarded inside `kitsOfDir`, which rebuild-all must keep failing on.
937
+ if (!found.kits?.length) {
938
+ try { found.kits = kitsOfDir(base); } catch { found.kits = []; }
939
+ }
907
940
  }
908
941
  return found;
909
942
  }
@@ -579,7 +579,7 @@ export function writeNativeLayer(items, agentsDir, skillsDir, overrides = null,
579
579
  let nAgents = 0;
580
580
  let nSkills = 0;
581
581
  const written = [];
582
- const sideFiles = []; // [stem, dest, text], written after the loop
582
+ const sideFiles = []; // [stem, dest, text, source], written after the loop
583
583
  const claimedSkills = new Set(); // stems whose own SKILL.md claim held
584
584
  const userOnlySkills = []; // claimed stems carrying the user-only marker
585
585
 
@@ -589,7 +589,13 @@ export function writeNativeLayer(items, agentsDir, skillsDir, overrides = null,
589
589
  written.push(dest);
590
590
  };
591
591
 
592
- for (const { text, src: source } of items) {
592
+ for (const { text, src: source, rel, licenceOf } of items) {
593
+ // A credited skill's upstream licence: copied verbatim beside its SKILL.md, held like a side
594
+ // file until that claim holds. Routed by `licenceOf`, since `source` is the upstream's file.
595
+ if (licenceOf) {
596
+ sideFiles.push([licenceOf, path.join(skillsDir, licenceOf, path.posix.basename(rel)), null, source]);
597
+ continue;
598
+ }
593
599
  const sparts = relPosix(src, source).split('/');
594
600
 
595
601
  // Vendored third-party skill folders ride along verbatim, preserving their own
@@ -706,8 +712,12 @@ export function writeNativeLayer(items, agentsDir, skillsDir, overrides = null,
706
712
  if (kind === 'agent') nAgents += 1;
707
713
  else nSkills += 1;
708
714
  }
709
- for (const [stem, dest, text] of sideFiles) {
710
- if (claimedSkills.has(stem) && claim(dest)) write(dest, stripSkillBodyLinks(text));
715
+ for (const [stem, dest, text, source] of sideFiles) {
716
+ if (!claimedSkills.has(stem) || !claim(dest)) continue;
717
+ if (text !== null) { write(dest, stripSkillBodyLinks(text)); continue; }
718
+ mkdirSync(path.dirname(dest), { recursive: true });
719
+ copyFile(source, dest);
720
+ written.push(dest);
711
721
  }
712
722
  return { nAgents, nSkills, written, userOnlySkills };
713
723
  }
@@ -21,11 +21,11 @@ import { blankCodeSpans } from '../lib/text.mjs';
21
21
  import { which } from '../lib/paths.mjs';
22
22
  import { NO_WINDOW } from '../lib/proc.mjs';
23
23
  import {
24
- aliasProblems, folderSkillProblems, registryProblems, secretProblems, sideFileProblems,
24
+ aliasProblems, creditProblems, folderSkillProblems, registryProblems, secretProblems, sideFileProblems,
25
25
  vendorPinProblems,
26
26
  } from './checks-repo.mjs';
27
27
  import { constitutionProblems, leanBlockProblems } from './checks-constitution.mjs';
28
- import { countTableProblems } from './checks-counts.mjs';
28
+ import { countTableProblems, kitProblems } from './checks-counts.mjs';
29
29
  import { catalogProblems } from '../loop/catalog.mjs';
30
30
  import { globSorted, isDir, isFile, rglob } from './scan.mjs';
31
31
  import { spawnSync } from 'node:child_process';
@@ -55,6 +55,7 @@ const ALLOWED_COMMENT_RES = [
55
55
  // js/web/docs.mjs HARNESS_OPEN_RE: `harness:` then one or more of its four ids, comma-separated.
56
56
  new RegExp(`^harness:\\s*(?:${HARNESS_IDS})(?:\\s*,\\s*(?:${HARNESS_IDS}))*$`),
57
57
  /^bash:\s*allow$/, // js/hosts/native.mjs BASH_MARKER
58
+ /^banned: ok$/, // checks-counts.mjs kitProblems: a kit line that names a banned string
58
59
  ];
59
60
 
60
61
  /**
@@ -114,7 +115,7 @@ export function authorNoteProblems(src = SRC) {
114
115
  export {
115
116
  HOOK_PINNED, constitutionProblems, doctrineMetaProblems, lawMetaProblems, leanBlockProblems,
116
117
  } from './checks-constitution.mjs';
117
- export { countTableProblems, proseMirrorProblems } from './checks-counts.mjs';
118
+ export { countTableProblems, kitProblems, proseMirrorProblems } from './checks-counts.mjs';
118
119
 
119
120
  /**
120
121
  * The shipped loop catalogue parses and every shipped template passes the engine's own graph
@@ -192,10 +193,12 @@ export function authoringProblems() {
192
193
  problems.push(...vendorPinProblems());
193
194
  problems.push(...folderSkillProblems());
194
195
  problems.push(...aliasProblems());
196
+ problems.push(...creditProblems());
195
197
  problems.push(...sideFileProblems());
196
198
  problems.push(...constitutionProblems());
197
199
  problems.push(...leanBlockProblems());
198
200
  problems.push(...countTableProblems());
201
+ problems.push(...kitProblems());
199
202
  problems.push(...authorNoteProblems());
200
203
  return problems;
201
204
  }
@@ -9,7 +9,7 @@
9
9
  import path from 'node:path';
10
10
  import { stripCapabilityLinks } from '../build/emit-common.mjs';
11
11
  import { STRUCTURE, renderAll } from '../build/render.mjs';
12
- import { CONFIG, makeCfg } from '../build/source.mjs';
12
+ import { CONFIG, CREDIT_RE, makeCfg } from '../build/source.mjs';
13
13
  import { hostCatalogsNatively, resolvePath } from '../hosts/hosts.mjs';
14
14
  import { EMIT_HOST_SCOPE, footprintOfDir, themeFiles } from '../hosts/installs.mjs';
15
15
  import { isVendoredPath } from '../hosts/native.mjs';
@@ -80,6 +80,12 @@ export function checkBuild(themeName, out, isVendored = isVendoredPath) {
80
80
  for (const tok of sortedUnique(text.match(TOKEN_RE) ?? [])) {
81
81
  problems.push(`[${themeName}] unresolved token ${tok} in ${rel}`);
82
82
  }
83
+ // `TOKEN_RE` cannot see a credit (colon, lowercase key): one left standing names no upstream.
84
+ if (text.includes('{{CREDIT:')) {
85
+ for (const tok of sortedUnique(text.match(CREDIT_RE) ?? ['{{CREDIT:'])) {
86
+ problems.push(`[${themeName}] unresolved token ${tok} in ${rel}`);
87
+ }
88
+ }
83
89
  for (const p of linkProblems(md, text, outAbs, rel)) problems.push(`[${themeName}] ${p}`);
84
90
  for (const p of descriptionProblems(text, rel)) problems.push(`[${themeName}] ${p}`);
85
91
  }