geneseed 4.9.3 → 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 (143) hide show
  1. package/CHANGELOG.md +31 -0
  2. package/README.md +14 -1
  3. package/SHIPPED.md +1 -1
  4. package/adapters/claude-code/README.md +1 -1
  5. package/adapters/opencode/plugins/geneseed-guard.js +20 -1
  6. package/docs/README.md +1 -1
  7. package/docs/concepts/collaboration.md +1 -1
  8. package/docs/concepts/enforcement-table.md +2 -2
  9. package/docs/concepts/foreman-mode.md +1 -1
  10. package/docs/concepts/hooks.md +3 -2
  11. package/docs/concepts/kits.md +79 -0
  12. package/docs/concepts/loop-branch.md +1 -1
  13. package/docs/concepts/loop-bricks.md +1 -1
  14. package/docs/concepts/loop-contracts.md +1 -1
  15. package/docs/concepts/loop-gates.md +1 -1
  16. package/docs/concepts/loop-notes.md +1 -1
  17. package/docs/concepts/loop-risk.md +1 -1
  18. package/docs/concepts/loop-templates.md +1 -1
  19. package/docs/concepts/loops.md +1 -1
  20. package/docs/concepts/lsp.md +1 -1
  21. package/docs/extending.md +108 -4
  22. package/docs/guides/install-scripted.md +1 -1
  23. package/docs/reference/glossary.md +1 -0
  24. package/docs/reference/plugin-guard.md +1 -1
  25. package/docs/understand/enforced-vs-asked.md +1 -1
  26. package/docs/understand/machine-claude.md +1 -1
  27. package/docs/web-ui.md +4 -0
  28. package/harness.config.json +1 -1
  29. package/js/build/args.mjs +27 -6
  30. package/js/build/driver.mjs +29 -8
  31. package/js/build/emit-claude.mjs +3 -1
  32. package/js/build/generate.mjs +10 -5
  33. package/js/build/render.mjs +94 -11
  34. package/js/build/source.mjs +117 -4
  35. package/js/cli-table.json +15 -0
  36. package/js/hosts/hooks.mjs +2 -1
  37. package/js/hosts/installs.mjs +35 -2
  38. package/js/hosts/loss.mjs +23 -2
  39. package/js/hosts/native.mjs +14 -4
  40. package/js/inspect/checks-authoring.mjs +6 -3
  41. package/js/inspect/checks-build.mjs +7 -1
  42. package/js/inspect/checks-counts.mjs +153 -6
  43. package/js/inspect/checks-repo.mjs +95 -20
  44. package/js/inspect/cost.mjs +1 -1
  45. package/js/inspect/diff.mjs +3 -1
  46. package/js/inspect/inventory.mjs +1 -0
  47. package/js/inspect/status.mjs +44 -3
  48. package/js/inspect/validate.mjs +3 -3
  49. package/js/maintain/setup.mjs +30 -6
  50. package/js/maintain/update.mjs +1 -1
  51. package/js/web/actions.mjs +30 -6
  52. package/js/web/api.mjs +40 -3
  53. package/js/web/routes.mjs +2 -1
  54. package/package.json +1 -1
  55. package/registry.json +63 -0
  56. package/src/AGENT.md.tmpl +2 -2
  57. package/src/agents/devops-engineer.md +45 -0
  58. package/src/agents/docker-expert.md +45 -0
  59. package/src/agents/kubernetes-specialist.md +45 -0
  60. package/src/agents/reviewer.md +2 -0
  61. package/src/agents/spring-boot-engineer.md +48 -0
  62. package/src/agents/spring-reviewer.md +49 -0
  63. package/src/agents/spring-security.md +50 -0
  64. package/src/kits/spring-boot/rules.md +10 -0
  65. package/src/kits/spring-boot.json +23 -0
  66. package/src/laws/universal.md +13 -13
  67. package/src/licenses/mattpocock-skills.txt +21 -0
  68. package/src/licenses/piomin-spring-boot.txt +190 -0
  69. package/src/licenses/superpowers.txt +21 -0
  70. package/src/licenses/vibe-wise.txt +21 -0
  71. package/src/skills/brainstorm/builder-check.md +2 -0
  72. package/src/skills/brainstorm.md +4 -0
  73. package/src/skills/codebase-design.md +4 -0
  74. package/src/skills/daydream/VENDOR.md +7 -7
  75. package/src/skills/debug.md +5 -1
  76. package/src/skills/develop.md +2 -0
  77. package/src/skills/geneseed-code-review.md +6 -2
  78. package/src/skills/git-history.md +2 -0
  79. package/src/skills/handoff.md +2 -0
  80. package/src/skills/learn-mode.md +3 -2
  81. package/src/skills/parallel-agents.md +2 -0
  82. package/src/skills/pipeline.md +2 -0
  83. package/src/skills/plan.md +4 -0
  84. package/src/skills/prototype.md +2 -0
  85. package/src/skills/react-view-transitions/AGENTS.md +646 -221
  86. package/src/skills/react-view-transitions/SKILL.md +28 -16
  87. package/src/skills/react-view-transitions/VENDOR.md +10 -8
  88. package/src/skills/react-view-transitions/references/css-recipes.md +72 -2
  89. package/src/skills/react-view-transitions/references/implementation.md +14 -26
  90. package/src/skills/react-view-transitions/references/nextjs.md +94 -37
  91. package/src/skills/react-view-transitions/references/patterns.md +60 -48
  92. package/src/skills/react-view-transitions/references/troubleshooting.md +47 -0
  93. package/src/skills/research.md +2 -0
  94. package/src/skills/review-response.md +2 -0
  95. package/src/skills/rule.md +2 -0
  96. package/src/skills/ship.md +2 -0
  97. package/src/skills/skill-forge.md +1 -0
  98. package/src/skills/spring-boot/cloud.md +103 -0
  99. package/src/skills/spring-boot/java-modern.md +71 -0
  100. package/src/skills/spring-boot/logging.md +89 -0
  101. package/src/skills/spring-boot/security.md +112 -0
  102. package/src/skills/spring-boot/web.md +112 -0
  103. package/src/skills/spring-boot.md +57 -0
  104. package/src/skills/spring-data-jpa/patterns.md +68 -0
  105. package/src/skills/spring-data-jpa.md +81 -0
  106. package/src/skills/spring-testing.md +93 -0
  107. package/src/skills/wayfinder.md +2 -0
  108. package/src/upstreams.json +59 -0
  109. package/themes/_TEMPLATE.json +9 -0
  110. package/themes/biker.json +9 -0
  111. package/themes/commentator.json +9 -0
  112. package/themes/cyberpunk.json +9 -0
  113. package/themes/gamer.json +9 -0
  114. package/themes/imperial.json +9 -0
  115. package/themes/joker.json +9 -0
  116. package/themes/marvin.json +9 -0
  117. package/themes/mean.json +9 -0
  118. package/themes/military.json +9 -0
  119. package/themes/neutral.json +9 -0
  120. package/themes/pirate.json +9 -0
  121. package/themes/sports.json +9 -0
  122. package/themes/verstappen.json +9 -0
  123. package/themes/wizard.json +9 -0
  124. package/web/dist/assets/{Activity-C8sZVIUU.js → Activity-BjyJaf8e.js} +1 -1
  125. package/web/dist/assets/{ActivityDetail-Cc6vWUS4.js → ActivityDetail-Bo9nRDNV.js} +1 -1
  126. package/web/dist/assets/{Dashboard-C5ATa3ow.js → Dashboard-BQJGU7vE.js} +2 -2
  127. package/web/dist/assets/{Docs-tDW7i7mo.js → Docs-DLVckkew.js} +1 -1
  128. package/web/dist/assets/{GreenhouseView-zk2Uzc3A.js → GreenhouseView--DML7CW3.js} +1 -1
  129. package/web/dist/assets/{Installs-CB_rvbBP.js → Installs-CxCiln1F.js} +2 -2
  130. package/web/dist/assets/{JournalView-Bfj27XHC.js → JournalView-ymgZleHi.js} +1 -1
  131. package/web/dist/assets/{Laws-BA3CzUf5.js → Laws-BkKnZOBI.js} +1 -1
  132. package/web/dist/assets/Library-C8ercIZv.js +1 -0
  133. package/web/dist/assets/{Loops-DaPk-ckL.js → Loops-C95zhbFl.js} +1 -1
  134. package/web/dist/assets/{OperatorHudView-CyiGhiHk.js → OperatorHudView-B9qwc3w3.js} +1 -1
  135. package/web/dist/assets/{Personal-Bwd27CmT.js → Personal-DyR72AF-.js} +1 -1
  136. package/web/dist/assets/{Tabs-BOD7Kaz1.js → Tabs-ChCr_HqV.js} +1 -1
  137. package/web/dist/assets/api-BfpZmUMI.js +1 -0
  138. package/web/dist/assets/{drawable-CyLe1gtW.js → drawable-CgYi0Dtg.js} +1 -1
  139. package/web/dist/assets/index-B32EvfDT.js +2 -0
  140. package/web/dist/index.html +2 -2
  141. package/web/dist/assets/Library-BWwiy9Vb.js +0 -1
  142. package/web/dist/assets/api-CiXlkbe0.js +0 -1
  143. package/web/dist/assets/index-DQXG_mqo.js +0 -2
@@ -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
  }
package/js/hosts/loss.mjs CHANGED
@@ -10,7 +10,9 @@
10
10
  * - a protected branch (`main`, `master`, a remote's default) deleted or force-pushed, local
11
11
  * or remote;
12
12
  * - `branch -D x` while x carries a commit no remote ref and no HEAD reaches;
13
- * - uncommitted work discarded (`reset --hard`, `checkout -f`/`-- <paths>`, `switch -f`/
13
+ * - `reset --hard <target>` abandoning commits HEAD reaches and neither <target> nor a remote
14
+ * does — the uncommitted work it discards passes (user decision, 2026-10-18);
15
+ * - uncommitted work discarded (`checkout -f`/`-- <paths>`, `switch -f`/
14
16
  * `--discard-changes`, `restore` beyond `--staged` alone, `clean -f`) — and only when the
15
17
  * tree actually holds such work (for `clean`, what `git clean -n` says it would remove);
16
18
  * - only where a protected branch is concerned (user ruling, 2026-10-17): `checkout <paths>`
@@ -332,6 +334,25 @@ function branchAct(rest, dir) {
332
334
  return null;
333
335
  }
334
336
 
337
+ /**
338
+ * `git reset --hard [<target>]` — asks only when it would abandon COMMITS: one HEAD reaches that
339
+ * neither `<target>` nor any remote ref does. The uncommitted work it discards passes: what is in
340
+ * progress may break, what exists must be protected (user decision, 2026-10-18). No target is
341
+ * HEAD itself, which abandons nothing.
342
+ */
343
+ function resetHardAct(rest, dir) {
344
+ const dd = rest.indexOf('--');
345
+ const target = (dd < 0 ? rest : rest.slice(0, dd)).find((a) => !a.startsWith('-'));
346
+ if (target === undefined) return null;
347
+ if (target.includes(OPAQUE)) unverified(`the target \`${target.replace(OPAQUE, '')}\` expands in the shell`);
348
+ repoRoot(dir);
349
+ const t = git(dir, ['rev-parse', '-q', '--verify', `${target}^{commit}`]);
350
+ if (t.status !== 0) unverified(`cannot resolve ${target} to a commit`);
351
+ const r = git(dir, ['rev-list', '-n1', 'HEAD', '--not', t.stdout.trim(), '--remotes']);
352
+ if (r.status !== 0) unverified(`cannot compare HEAD with ${target}: ${firstLine(r.stderr)}`);
353
+ return r.stdout.trim() ? `\`git reset --hard ${target}\` would abandon commits no remote and not ${target} holds` : null;
354
+ }
355
+
335
356
  /** `git clean` — forced, it asks when `git clean -n` with the same selection lists anything. */
336
357
  function cleanAct(rest, dir) {
337
358
  const forced = rest.some((a) => a === '--force' || hasShort(a, 'f'));
@@ -373,7 +394,7 @@ function gitAct(args, dir, always) {
373
394
  const act = () => {
374
395
  switch (verb) {
375
396
  case 'reset':
376
- return rest.includes('--hard') ? dirty(d, [], '`git reset --hard`') : null;
397
+ return rest.includes('--hard') ? resetHardAct(rest, d) : null;
377
398
  case 'checkout': {
378
399
  const forced = rest.some((a) => a === '--force' || hasShort(a, 'f')) && dirty(d, [], '`git checkout --force`');
379
400
  if (forced) return forced;
@@ -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
  }
@@ -7,7 +7,10 @@
7
7
  * is the reader that feeds it.
8
8
  */
9
9
  import path from 'node:path';
10
- import { DOCS, DOC_FOLDERS, PLUGIN_SRC, ROOT, SRC, ruleCanon } from '../build/source.mjs';
10
+ import {
11
+ DOCS, DOC_FOLDERS, PLUGIN_SRC, RETIRED_KIT_IDS, ROOT, SRC, kitMembers, loadKits, makeCfg, ruleCanon, specLinks, upstreams,
12
+ } from '../build/source.mjs';
13
+ import { effectiveTheme, renderFile } from '../build/render.mjs';
11
14
  import { themeFiles } from '../hosts/installs.mjs';
12
15
  import { readText } from '../lib/fs.mjs';
13
16
  import { formatRepr } from '../lib/json.mjs';
@@ -25,7 +28,7 @@ import { existsSync, readdirSync } from 'node:fs';
25
28
  * so they drift silently — they already had. Pure over its inputs, so the corpus can feed it
26
29
  * crafted drift without touching the tree.
27
30
  */
28
- export function proseMirrorProblems(readme, web, counts, skillStems, shipped = '') {
31
+ export function proseMirrorProblems(readme, web, counts, skillStems, shipped = '', upstreamKeys = undefined) {
29
32
  const problems = [];
30
33
  const { laws, agents, skills, plugins } = counts;
31
34
 
@@ -68,6 +71,32 @@ export function proseMirrorProblems(readme, web, counts, skillStems, shipped = '
68
71
  }
69
72
  }
70
73
 
74
+ // The README Credits section names EXACTLY the `src/upstreams.json` keys, both directions,
75
+ // as the skills row does. A missing section is a problem, or deleting it would turn the gate
76
+ // green. `upstreamKeys` is optional, like `plugins`, because the corpus predates it.
77
+ if (upstreamKeys !== undefined) {
78
+ const lines = readme.split('\n').map((ln) => ln.replace(/\r$/, ''));
79
+ const at = lines.findIndex((ln) => ln.trim() === '## 🙏 Credits');
80
+ if (at < 0) {
81
+ problems.push("[authoring] README has no '## 🙏 Credits' section — src/upstreams.json has "
82
+ + `${upstreamKeys.length} upstreams to credit`);
83
+ } else {
84
+ const end = lines.findIndex((ln, i) => i > at && ln.startsWith('## '));
85
+ const listed = new Set();
86
+ for (const ln of lines.slice(at + 1, end < 0 ? undefined : end).filter((l) => l.startsWith('- '))) {
87
+ const k = /^- `([^`]+)`/.exec(ln);
88
+ if (k) listed.add(k[1]);
89
+ else problems.push(`[authoring] README Credits bullet names no upstream key in backticks: '${ln}'`);
90
+ }
91
+ for (const key of [...upstreamKeys].filter((k) => !listed.has(k)).sort()) {
92
+ problems.push(`[authoring] README Credits omits upstream '${key}' (src/upstreams.json)`);
93
+ }
94
+ for (const key of [...listed].filter((k) => !upstreamKeys.includes(k)).sort()) {
95
+ problems.push(`[authoring] README Credits names '${key}', which src/upstreams.json does not define`);
96
+ }
97
+ }
98
+ }
99
+
71
100
  for (const m of web.matchAll(/(\d+) universal (?:laws|Rules)/g)) {
72
101
  if (Number(m[1]) !== laws) {
73
102
  problems.push(`[authoring] docs/ prose says '${m[1]} universal laws/Rules' but `
@@ -127,8 +156,13 @@ export function countTableProblems() {
127
156
  return [`[authoring] AGENT.md.tmpl unreadable: ${e.message}`];
128
157
  }
129
158
 
159
+ // A kit member's row lives in its kit's manifest, not the template (spec A6). A manifest that
160
+ // does not parse is `kitProblems`' to name; here it means no kit rows.
161
+ let kits;
162
+ try { kits = loadKits(); } catch { kits = new Map(); }
163
+ const kitRows = [...kits.values()].flatMap((m) => [...(m.catalogue?.skills ?? []), ...(m.catalogue?.agents ?? [])]);
130
164
  const linked = { agents: new Set(), skills: new Set() };
131
- for (const m of ttext.matchAll(TMPL_SPEC_RE)) {
165
+ for (const m of [ttext, ...kitRows].join('\n').matchAll(TMPL_SPEC_RE)) {
132
166
  if (m[2] !== '_template') linked[m[1] === 'AGENTS' ? 'agents' : 'skills'].add(m[2]);
133
167
  }
134
168
  for (const folder of ['agents', 'skills']) {
@@ -173,11 +207,14 @@ export function countTableProblems() {
173
207
  // the SHIPPED.md triple count it. It is added HERE rather than in `srcStems`, whose other
174
208
  // readers above — the AGENT.md table links and SKILL_CLASS — are about flat specs only.
175
209
  const skillsDir = path.join(SRC, 'skills');
210
+ // Core only: a kit is off by default, so its members are in no badge, README list or SHIPPED triple.
211
+ const members = kitMembers(kits);
176
212
  const shippedSkills = new Set([...skillFiles, ...(isDir(skillsDir) ? readdirSync(skillsDir) : [])
177
- .filter((d) => !d.startsWith('_') && isFile(path.join(skillsDir, d, 'SKILL.md')))]);
213
+ .filter((d) => !d.startsWith('_') && isFile(path.join(skillsDir, d, 'SKILL.md')))]
214
+ .filter((s) => !members.skills.has(s)));
178
215
 
179
216
  const counts = {
180
- agents: srcStems('agents').size,
217
+ agents: [...srcStems('agents')].filter((a) => !members.agents.has(a)).length,
181
218
  skills: shippedSkills.size,
182
219
  laws: lawNums.length,
183
220
  // Every doctrine rule in every pack, opt-in ones included: the laws badge alone told a
@@ -242,6 +279,116 @@ export function countTableProblems() {
242
279
  problems.push('[authoring] SHIPPED.md carries no \'N laws, N agents, N skills\' line — '
243
280
  + 'deleting it is how that gate goes green while the count drifts');
244
281
  }
245
- problems.push(...proseMirrorProblems(readme, web, counts, shippedSkills, shipped));
282
+ // A bad upstreams.json is creditProblems' to name; here it means no keys to hold the README to.
283
+ let upstreamKeys;
284
+ try { upstreamKeys = Object.keys(upstreams()); } catch { upstreamKeys = []; }
285
+ problems.push(...proseMirrorProblems(readme, web, counts, shippedSkills, shipped, upstreamKeys));
286
+ return problems;
287
+ }
288
+
289
+ /** The `.md` files directly in `d`, sorted; none for a missing folder. */
290
+ const mdIn = (d) => { try { return readdirSync(d).filter((f) => f.endsWith('.md')).sort(); } catch { return []; } };
291
+
292
+ /**
293
+ * The kit arm (spec A6, R3, R4, B7): each `src/kits/<kit>.json` against `src`. Refused: a
294
+ * manifest that does not parse or lacks `description`/`rules`/`skills`/`agents`; a reserved kit
295
+ * id (`RETIRED_KIT_IDS`); `rules` naming a missing file, or rendering (neutral theme) to more
296
+ * words than its `rulesWordBudget` (default 300); a member in two kits, or a core one (a row in
297
+ * AGENT.md.tmpl) — R3; a member with no spec (or a vendored folder skill) or no `registry.json` row; a catalogue row not of the shape `| [<m>]({{DIR_*}}/<m>.md) | <text> |`, or a member without exactly one; any `banned` string (manifest) in a member file or the rules, unless the line carries `<!-- banned: ok -->`; and a core file linking a
298
+ * kit member, which dangles with the kit off — R4. `registry.json` sits beside `src`, as at ROOT.
299
+ */
300
+ export function kitProblems(src = SRC) {
301
+ let kits;
302
+ try { kits = loadKits(src); } catch (e) { return [`[authoring] ${e.message}`]; }
303
+ if (!kits.size) return [];
304
+ const problems = [];
305
+ const core = { agents: new Set(), skills: new Set() };
306
+ let tmpl = '';
307
+ try { tmpl = readText(path.join(src, 'AGENT.md.tmpl')); } catch { /* countTableProblems names it */ }
308
+ for (const m of tmpl.matchAll(TMPL_SPEC_RE)) core[m[1] === 'AGENTS' ? 'agents' : 'skills'].add(m[2]);
309
+ // null = no usable registry: `registryProblems` already names that (and every member it lacks a
310
+ // row for), so a member's missing row is only reported here when there IS an entities object.
311
+ let registry = null;
312
+ try { registry = JSON.parse(readText(path.join(path.dirname(src), 'registry.json'))).entities ?? null; } catch {
313
+ // registryProblems names an unreadable registry
314
+ }
315
+ const owner = new Map();
316
+ const cfg = { ...makeCfg(), src };
317
+ let theme = null;
318
+ for (const [kit, m] of kits) {
319
+ const at = `src/kits/${kit}.json`;
320
+ if (Object.hasOwn(RETIRED_KIT_IDS, kit)) {
321
+ problems.push(`[authoring] ${at}: kit id '${kit}' is reserved (${RETIRED_KIT_IDS[kit]})`);
322
+ }
323
+ const lacking = ['description', 'rules', 'skills', 'agents'].filter((k) => (
324
+ k === 'skills' || k === 'agents' ? !Array.isArray(m[k]) : typeof m[k] !== 'string'));
325
+ for (const k of lacking) problems.push(`[authoring] ${at} has no '${k}'`);
326
+ if (lacking.length) continue;
327
+ for (const kind of ['skills', 'agents']) {
328
+ for (const name of m[kind]) {
329
+ const key = `${kind}/${name}`;
330
+ if (owner.has(key)) problems.push(`[authoring] ${key} is in two kits: '${owner.get(key)}' and '${kit}'`);
331
+ else owner.set(key, kit);
332
+ if (core[kind].has(name)) problems.push(`[authoring] ${key} is core (AGENT.md.tmpl lists it), but kit '${kit}' claims it`);
333
+ if (!isFile(path.join(src, kind, `${name}.md`))) {
334
+ problems.push(kind === 'skills' && isFile(path.join(src, kind, name, 'SKILL.md'))
335
+ ? `[authoring] kit '${kit}' lists ${name}, a vendored folder skill; kit members must be flat specs`
336
+ : `[authoring] kit '${kit}' lists ${key}, but src/${key}.md does not exist`);
337
+ }
338
+ if (registry !== null && !Object.hasOwn(registry, key)) problems.push(`[authoring] ${key} (kit '${kit}') has no row in registry.json`);
339
+ }
340
+ }
341
+ // A6: a kit's catalogue rows are its own members' — a core row here would show twice with the
342
+ // kit on, or vanish with it off. Each row is `| [<m>]({{DIR_*}}/<m>.md) | <text> |`, one per member.
343
+ for (const kind of ['skills', 'agents']) {
344
+ const dir = kind === 'skills' ? 'SKILLS' : 'AGENTS';
345
+ const seen = new Map();
346
+ for (const row of m.catalogue?.[kind] ?? []) {
347
+ const hit = new RegExp(`^\\| \\[([a-z0-9-]+)\\]\\(\\{\\{DIR_${dir}\\}\\}/\\1\\.md\\) \\| \\S.* \\|$`).exec(row);
348
+ const named = hit?.[1] ?? row.match(/\{\{DIR_(?:AGENTS|SKILLS)\}\}\/([A-Za-z0-9_-]+)\.md/)?.[1];
349
+ if (named && !m[kind].includes(named)) {
350
+ problems.push(`[authoring] ${at} catalogue lists ${kind}/${named}, which is not a member of kit '${kit}'`);
351
+ } else if (!hit) {
352
+ problems.push(`[authoring] ${at} catalogue.${kind} row is not \`| [<member>]({{DIR_${dir}}}/<member>.md) | <text> |\`: ${row}`);
353
+ } else seen.set(named, (seen.get(named) ?? 0) + 1);
354
+ }
355
+ for (const name of m[kind]) {
356
+ const n = seen.get(name) ?? 0;
357
+ if (n !== 1) problems.push(`[authoring] ${at} catalogue.${kind} has ${n} rows for ${name}, want exactly one`);
358
+ }
359
+ }
360
+ // T4: strings no member file may contain; a line carrying the marker is exempt (a rule that names the construct to forbid it).
361
+ const files = [m.rules, ...m.skills.flatMap((n) => [`skills/${n}.md`, ...mdIn(path.join(src, 'skills', n)).map((f) => `skills/${n}/${f}`)]),
362
+ ...m.agents.map((n) => `agents/${n}.md`)];
363
+ for (const rel of files) {
364
+ if (!isFile(path.join(src, rel))) continue;
365
+ readText(path.join(src, rel)).split('\n').forEach((line, i) => {
366
+ if (line.includes('<!-- banned: ok -->')) return;
367
+ for (const b of m.banned ?? []) if (line.includes(b)) problems.push(`[authoring] src/${rel}:${i + 1} contains '${b}', banned by kit '${kit}'`);
368
+ });
369
+ }
370
+ const rules = path.join(src, m.rules);
371
+ if (!isFile(rules)) {
372
+ problems.push(`[authoring] ${at}: rules '${m.rules}' does not exist`);
373
+ continue;
374
+ }
375
+ theme ??= effectiveTheme(cfg, 'neutral');
376
+ const words = renderFile(cfg, rules, theme).split(/\s+/).filter(Boolean).length;
377
+ const budget = m.rulesWordBudget ?? 300;
378
+ if (words > budget) {
379
+ problems.push(`[authoring] src/${m.rules} renders to ${words} words, over kit '${kit}' rulesWordBudget of ${budget}`);
380
+ }
381
+ }
382
+ // R4. A core member (R3, named above) is left out: every core file linking it would repeat that.
383
+ const r4 = new Set();
384
+ for (const l of specLinks(src)) {
385
+ // Links run kit -> core only: a core file, or ANOTHER kit's file, linking a member dangles
386
+ // whenever that member's kit is off.
387
+ if (!l.owner || l.kit === l.owner || core[l.kind].has(l.name)) continue;
388
+ r4.add(l.kit
389
+ ? `[authoring] src/${l.rel} (kit '${l.kit}') links ${l.kind}/${l.name}, a member of kit '${l.owner}' — links run kit → core only`
390
+ : `[authoring] src/${l.rel} links ${l.kind}/${l.name}, a kit member — the link dangles when the kit is off`);
391
+ }
392
+ problems.push(...r4);
246
393
  return problems;
247
394
  }
@@ -9,7 +9,8 @@
9
9
  */
10
10
  import path from 'node:path';
11
11
  import {
12
- ALIASES_RE, PLUGIN_SRC, RETIRED_SKILL_IDS, ROOT, SRC, THEMES, WORKFLOW_SRC, aliasesOf, knownSkillIds,
12
+ ALIASES_RE, CREDIT_RE, PLUGIN_SRC, RETIRED_SKILL_IDS, ROOT, SRC, THEMES, WORKFLOW_SRC, aliasesOf,
13
+ knownSkillIds, upstreams,
13
14
  } from '../build/source.mjs';
14
15
  import { HOSTS } from '../hosts/hosts.mjs';
15
16
  import { installTargets, installsReferencingShim } from '../hosts/installs.mjs';
@@ -22,7 +23,7 @@ import { splitLines } from '../lib/udiff.mjs';
22
23
  import { ENTITY_STATUSES } from './inventory.mjs';
23
24
  import { assessRepo } from './scorecard.mjs';
24
25
  import { badgeUrl, renderSvg } from './scorecard-svg.mjs';
25
- import { isDir, isFile, rglob, srcStems } from './scan.mjs';
26
+ import { isDir, isFile, rglob, srcStems, within } from './scan.mjs';
26
27
  import { existsSync, readFileSync, readdirSync } from 'node:fs';
27
28
 
28
29
  /**
@@ -225,14 +226,17 @@ const MOVING_REFS = new Set(['main', 'master', 'develop', 'dev', 'head', 'latest
225
226
 
226
227
  /**
227
228
  * `_harness_build._vendor_pin_problems` — every vendored skill folder records where it came
228
- * from, under which license, and at WHICH immutable commit.
229
+ * from: `**Upstream key:** <key>`, naming its `src/upstreams.json` entry, which holds the repo,
230
+ * the pinned commit and the licence (`creditProblems` checks those facts).
229
231
  *
230
232
  * First-party folders that merely ride the vendored mechanism for their multi-file layout
231
- * declare that instead (`**Upstream:** this …`) and are exempt from the pin: there is no
232
- * upstream to drift against.
233
+ * declare that instead (`**Upstream:** this …`, plus a `**License:**`) and have no key: there is
234
+ * no upstream to drift against.
233
235
  */
234
236
  export function vendorPinProblems() {
235
237
  const problems = [];
238
+ let entries;
239
+ try { entries = upstreams(); } catch { entries = {}; } // creditProblems names the bad JSON
236
240
  for (const name of VENDORED_SKILL_DIRS) {
237
241
  const folder = path.join(SRC, 'skills', name);
238
242
  if (!isDir(folder)) {
@@ -246,24 +250,95 @@ export function vendorPinProblems() {
246
250
  + 'pinned commit and license are unrecorded');
247
251
  continue;
248
252
  }
249
- if (!/\*\*License:\*\*/.test(text)) {
250
- problems.push(`[authoring] skills/${name}/VENDOR.md records no '**License:**'`);
251
- }
252
253
  const upstream = /\*\*Upstream:\*\*\s*(\S+)/.exec(text);
253
- if (!upstream) {
254
- problems.push(`[authoring] skills/${name}/VENDOR.md declares no '**Upstream:**'`);
254
+ if (upstream && upstream[1].toLowerCase().startsWith('this')) { // first-party
255
+ if (!/\*\*License:\*\*/.test(text)) {
256
+ problems.push(`[authoring] skills/${name}/VENDOR.md records no '**License:**'`);
257
+ }
258
+ continue;
259
+ }
260
+ const key = /\*\*Upstream key:\*\*\s*(\S+)/.exec(text);
261
+ if (!key) {
262
+ problems.push(`[authoring] skills/${name}/VENDOR.md declares no '**Upstream key:**' `
263
+ + "(nor '**Upstream:** this repository' for a first-party folder)");
264
+ } else if (!Object.hasOwn(entries, key[1])) {
265
+ problems.push(`[authoring] skills/${name}/VENDOR.md names upstream key '${key[1]}', `
266
+ + 'which src/upstreams.json does not define');
267
+ }
268
+ }
269
+ return problems;
270
+ }
271
+
272
+ /** The files a `{{CREDIT:` may sit in: those `renderAll` derives a licence copy for, plus agents. */
273
+ const CREDITABLE_RE = /^(?:skills\/[^/_][^/]*(?:\/[^/_][^/]*)?|agents\/[^/_][^/]*)\.md$/;
274
+
275
+ /**
276
+ * Every credit ships with what it promises. A `{{CREDIT:<key>:…}}` key resolves in
277
+ * `src/upstreams.json`; every entry there is credited by a token or a VENDOR.md
278
+ * `**Upstream key:**`; its `licenseFile` exists (the emit copies it, and throws mid-build if
279
+ * not); its commit is an immutable sha. A token in a `_` partial is refused: it is included
280
+ * into other files, whose raw source `renderAll` scans for the licence copy, so it would credit
281
+ * without shipping the licence. A `licenseFile: null` is only for a key credited from a vendored
282
+ * folder alone, which ships its upstream's own LICENSE: anywhere else the licence text must
283
+ * ship with the work, so a token outside those folders needs a licence file.
284
+ */
285
+ export function creditProblems(src = SRC) {
286
+ let entries;
287
+ try { entries = upstreams(src); } catch {
288
+ return ['[authoring] src/upstreams.json is not valid JSON'];
289
+ }
290
+ const problems = [];
291
+ const used = new Set();
292
+ /** key -> the first non-vendored file whose token credits it. */
293
+ const needsLicence = new Map();
294
+ for (const file of rglob(src)) {
295
+ if (!file.endsWith('.md') || !isFile(file)) continue;
296
+ const rel = path.relative(src, file).split(path.sep).join('/');
297
+ const text = readText(file);
298
+ const vendorKey = path.basename(file) === 'VENDOR.md' && /\*\*Upstream key:\*\*\s*(\S+)/.exec(text);
299
+ if (vendorKey) used.add(vendorKey[1]);
300
+ if (!text.includes('{{CREDIT:')) continue;
301
+ if (!CREDITABLE_RE.test(rel)) {
302
+ problems.push(`[authoring] ${rel} carries a {{CREDIT:}} token, but only skills/<stem>.md, `
303
+ + 'skills/<stem>/*.md and agents/*.md may — the licence copy is derived for those alone');
304
+ continue;
305
+ }
306
+ for (const key of new Set([...text.matchAll(CREDIT_RE)].map((m) => m[1]))) {
307
+ used.add(key);
308
+ const sp = rel.split('/');
309
+ if (!(sp.length > 2 && VENDORED_SKILL_DIRS.has(sp[1])) && !needsLicence.has(key)) needsLicence.set(key, rel);
310
+ if (!Object.hasOwn(entries, key)) {
311
+ problems.push(`[authoring] ${rel} credits '${key}', which src/upstreams.json does not define`);
312
+ }
313
+ }
314
+ }
315
+ for (const [key, u] of Object.entries(entries)) {
316
+ const where = `src/upstreams.json entry '${key}'`;
317
+ if (!used.has(key)) problems.push(`[authoring] ${where} is credited by no {{CREDIT:}} token and no VENDOR.md`);
318
+ const pin = String(u.commit ?? '');
319
+ if (MOVING_REFS.has(pin.toLowerCase())) {
320
+ problems.push(`[authoring] ${where} pins '${pin}', a moving branch — record the commit sha instead`);
321
+ } else if (!HEX40_RE.test(pin.toLowerCase())) {
322
+ problems.push(`[authoring] ${where} pin '${pin}' is not a 40-character commit sha`);
323
+ }
324
+ for (const field of ['repo', 'author', 'license']) {
325
+ if (!u[field]) problems.push(`[authoring] ${where} has no '${field}'`);
326
+ }
327
+ if (u.frozen !== undefined && typeof u.frozen !== 'boolean') {
328
+ problems.push(`[authoring] ${where} has frozen ${JSON.stringify(u.frozen)}, which is not a boolean`);
329
+ }
330
+ if (!u.licenseFile) {
331
+ if (needsLicence.has(key)) {
332
+ problems.push(`[authoring] ${where} has licenseFile null, but ${needsLicence.get(key)} credits it `
333
+ + 'outside a vendored folder — its licence text must ship with the work');
334
+ }
255
335
  continue;
256
336
  }
257
- if (upstream[1].toLowerCase().startsWith('this')) continue; // first-party
258
- const pin = /\*\*Commit:\*\*\s*(\S+)/.exec(text);
259
- if (!pin) {
260
- problems.push(`[authoring] skills/${name}/VENDOR.md has no '**Commit:**' pin`);
261
- } else if (MOVING_REFS.has(pin[1].toLowerCase())) {
262
- problems.push(`[authoring] skills/${name}/VENDOR.md pins '${pin[1]}', a `
263
- + 'moving branch — record the commit sha instead');
264
- } else if (!HEX40_RE.test(pin[1].toLowerCase())) {
265
- problems.push(`[authoring] skills/${name}/VENDOR.md pin '${pin[1]}' is not `
266
- + 'a 40-character commit sha');
337
+ // The emit copies it into every install, so it must be a file of this tree.
338
+ if (!within(path.resolve(src, u.licenseFile), path.resolve(src))) {
339
+ problems.push(`[authoring] ${where} names licenseFile '${u.licenseFile}', which resolves outside src/`);
340
+ } else if (!isFile(path.join(src, u.licenseFile))) {
341
+ problems.push(`[authoring] ${where} names licenseFile '${u.licenseFile}', which does not exist under src/`);
267
342
  }
268
343
  }
269
344
  return problems;
@@ -35,7 +35,7 @@ import { isFile, readText } from '../lib/fs.mjs';
35
35
  * console draws it on the cost chart, so it lives here, once, where both read it. Raise it by
36
36
  * exactly the measured overrun and say why in that test's docblock, which keeps its history.
37
37
  */
38
- export const CARRIER_CEILING = { full: 62_219, lean: 39_088 };
38
+ export const CARRIER_CEILING = { full: 62_219, lean: 39_111 };
39
39
 
40
40
  /**
41
41
  * A frontmatter value as the host shows it. The emit writes descriptions JSON-escaped
@@ -49,7 +49,7 @@ import {
49
49
  } from '../hosts/hosts.mjs';
50
50
  import {
51
51
  EMIT_HOST_SCOPE, defaultTheme, doctrinesForBuild, excludedRulesOfDir,
52
- excludedSkillsOfDir, gatesOfDir,
52
+ excludedSkillsOfDir, gatesOfDir, kitsOfDir,
53
53
  footprintOfDir, modeOfDir, postureOfDir,
54
54
  trustOfDir,
55
55
  readJsonMaybe, readMaybe, themeOfDir,
@@ -265,6 +265,8 @@ export function diffCollect({
265
265
  excludeRules: excludedRulesOfDir(dir),
266
266
  // The excluded skills, or every skill the install left out reads as deleted.
267
267
  excludeSkills: excludedSkillsOfDir(dir),
268
+ // The kits on, or every kit file reads as added and the Kits block as drift.
269
+ kits: kitsOfDir(dir),
268
270
  // The gate switches, or a disarmed install reads its `<gate>: off` line as drift.
269
271
  ...gatesOfDir(dir),
270
272
  };
@@ -96,6 +96,7 @@ export const SKILL_CLASS = {
96
96
  develop: 'build', refactor: 'build', debug: 'build', dependencies: 'build',
97
97
  'frontend-design': 'build', prototype: 'build', deck: 'build',
98
98
  'forge-mcp': 'build', bruno: 'build', worktree: 'build',
99
+ 'spring-boot': 'build', 'spring-data-jpa': 'build', 'spring-testing': 'build',
99
100
  'geneseed-code-review': 'review', 'fresh-eyes': 'review', 'security-audit': 'review',
100
101
  'roast-me': 'review', 'review-response': 'review', ponytail: 'review',
101
102
  commit: 'ship', ship: 'ship', release: 'ship', handoff: 'ship',