geneseed 3.16.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 (177) hide show
  1. package/CHANGELOG.md +45 -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 +31 -21
  7. package/adapters/opencode/plugins/geneseed-guard.js +35 -7
  8. package/bin/geneseed-cli.mjs +5 -0
  9. package/bin/geneseed.mjs +17 -0
  10. package/docs/concepts/exclusions.md +1 -1
  11. package/docs/concepts/hooks.md +1 -0
  12. package/docs/concepts/loop-notes.md +3 -0
  13. package/docs/concepts/loops.md +11 -0
  14. package/docs/concepts/skills.md +4 -0
  15. package/docs/extending.md +33 -0
  16. package/docs/guides/headless.md +1 -1
  17. package/docs/guides/project-context.md +2 -2
  18. package/docs/guides/read-non-markdown.md +2 -2
  19. package/docs/guides/uninstall.md +1 -1
  20. package/docs/guides/upgrade.md +1 -1
  21. package/docs/guides/wiki.md +4 -4
  22. package/docs/opencode-plugin-setup.md +1 -1
  23. package/docs/reference/env-context.md +2 -2
  24. package/docs/reference/plugin-context.md +4 -4
  25. package/docs/reference/plugin-guard.md +1 -1
  26. package/docs/token-footprint.md +18 -15
  27. package/docs/understand/take-it-out.md +1 -1
  28. package/harness.config.json +1 -1
  29. package/js/README.md +4 -1
  30. package/js/build/args.mjs +22 -20
  31. package/js/build/bundle.mjs +1 -3
  32. package/js/build/emit-claude.mjs +5 -4
  33. package/js/build/emit-opencode.mjs +9 -2
  34. package/js/build/generate.mjs +6 -6
  35. package/js/build/render.mjs +19 -12
  36. package/js/build/source.mjs +82 -7
  37. package/js/build/stubs.mjs +35 -6
  38. package/js/build/themes.mjs +1 -3
  39. package/js/cli-table.json +53 -0
  40. package/js/hosts/hooks-context.mjs +40 -27
  41. package/js/hosts/hooks.mjs +34 -1
  42. package/js/hosts/hosts.mjs +1 -3
  43. package/js/hosts/installs.mjs +13 -7
  44. package/js/hosts/mcp.mjs +1 -1
  45. package/js/hosts/native.mjs +60 -5
  46. package/js/hosts/opencode.mjs +37 -6
  47. package/js/hosts/settings.mjs +3 -1
  48. package/js/hosts/shim.mjs +1 -0
  49. package/js/inspect/checks-authoring.mjs +4 -1
  50. package/js/inspect/checks-constitution.mjs +2 -2
  51. package/js/inspect/checks-repo.mjs +164 -2
  52. package/js/inspect/diff.mjs +5 -1
  53. package/js/inspect/doctor.mjs +2 -1
  54. package/js/inspect/inventory.mjs +7 -9
  55. package/js/inspect/scorecard-checks.mjs +571 -0
  56. package/js/inspect/scorecard-svg.mjs +85 -0
  57. package/js/inspect/scorecard.mjs +839 -0
  58. package/js/lib/json.mjs +1 -1
  59. package/js/loop/graph.mjs +2 -1
  60. package/js/loop/state.mjs +30 -1
  61. package/js/maintain/migrate.mjs +2 -1
  62. package/js/maintain/uninstall.mjs +2 -3
  63. package/js/maintain/update.mjs +10 -3
  64. package/js/ui/menu.mjs +2 -2
  65. package/js/ui/tui.mjs +1 -0
  66. package/js/web/api.mjs +2 -0
  67. package/js/web/catalog.mjs +24 -7
  68. package/js/web/daemon.mjs +2 -1
  69. package/js/web/routes.mjs +2 -0
  70. package/js/web/server.mjs +1 -1
  71. package/js/web/user-files.mjs +1 -0
  72. package/package.json +11 -3
  73. package/registry.json +12 -75
  74. package/src/AGENT.md.tmpl +13 -22
  75. package/src/agents/docs.md +5 -0
  76. package/src/agents/researcher.md +1 -1
  77. package/src/bricks/apply.md +3 -0
  78. package/src/bricks/bruno-test.md +2 -2
  79. package/src/bricks/ci-fix.md +3 -3
  80. package/src/bricks/deps-audit.md +3 -3
  81. package/src/bricks/docs-update.md +2 -0
  82. package/src/bricks/migration-plan.md +2 -2
  83. package/src/bricks/test.md +17 -7
  84. package/src/doctrines/process.md +11 -7
  85. package/src/laws/universal.md +2 -2
  86. package/src/loops/api-endpoint.json +1 -1
  87. package/src/loops/bugfix.json +1 -1
  88. package/src/loops/ci-repair.json +1 -1
  89. package/src/loops/deps-upgrade.json +1 -1
  90. package/src/loops/enforce-architecture-rule.json +1 -1
  91. package/src/loops/feature.json +1 -1
  92. package/src/loops/fix-flaky-tests.json +1 -1
  93. package/src/loops/legacy-refactor.json +1 -1
  94. package/src/loops/legacy-tests.json +1 -1
  95. package/src/loops/refactor.json +1 -1
  96. package/src/loops/remove-dead-code.json +1 -1
  97. package/src/loops/spec-first-feature.json +1 -1
  98. package/src/loops/tdd.json +1 -1
  99. package/src/loops/update-contract.json +1 -1
  100. package/src/skills/brainstorm.md +14 -10
  101. package/src/skills/bruno.md +84 -0
  102. package/src/skills/codebase-design.md +41 -23
  103. package/src/skills/daydream/SKILL.md +3 -3
  104. package/src/skills/daydream/VENDOR.md +2 -2
  105. package/src/skills/daydream/instructions.md +1 -1
  106. package/src/skills/debug/ci.md +11 -0
  107. package/src/skills/debug.md +11 -8
  108. package/src/skills/dependencies.md +65 -0
  109. package/src/skills/develop.md +9 -4
  110. package/src/skills/document-project.md +106 -32
  111. package/src/skills/geneseed-code-review.md +13 -8
  112. package/src/skills/geneseed.md +4 -1
  113. package/src/skills/git-history.md +78 -0
  114. package/src/skills/handoff.md +4 -3
  115. package/src/skills/parallel-agents.md +1 -0
  116. package/src/skills/plan.md +5 -1
  117. package/src/skills/prototype.md +10 -7
  118. package/src/skills/research.md +6 -1
  119. package/src/skills/rule/consolidate.md +32 -0
  120. package/src/skills/rule.md +14 -4
  121. package/src/skills/security-audit.md +1 -1
  122. package/src/skills/ship.md +8 -5
  123. package/src/skills/teach.md +5 -3
  124. package/src/skills/wayfinder.md +19 -16
  125. package/src/skills/wiki.md +2 -2
  126. package/themes/_TEMPLATE.json +7 -16
  127. package/themes/biker.json +7 -16
  128. package/themes/commentator.json +7 -16
  129. package/themes/cyberpunk.json +7 -16
  130. package/themes/gamer.json +7 -16
  131. package/themes/imperial.json +7 -16
  132. package/themes/joker.json +7 -16
  133. package/themes/marvin.json +7 -16
  134. package/themes/mean.json +7 -16
  135. package/themes/military.json +7 -16
  136. package/themes/neutral.json +7 -16
  137. package/themes/pirate.json +7 -16
  138. package/themes/sports.json +7 -16
  139. package/themes/verstappen.json +7 -16
  140. package/themes/wizard.json +7 -16
  141. package/web/dist/assets/{Activity-kpWxd70F.js → Activity-4M4VQJOh.js} +1 -1
  142. package/web/dist/assets/{ActivityDetail-Ccr942VL.js → ActivityDetail-CnmDs43F.js} +1 -1
  143. package/web/dist/assets/{Dashboard-D1lE7jcg.js → Dashboard-lTaH58R9.js} +2 -2
  144. package/web/dist/assets/{Docs-BHyaggFy.js → Docs-DU5znu5f.js} +1 -1
  145. package/web/dist/assets/FilterInput-BSfpStOZ.js +1 -0
  146. package/web/dist/assets/{GreenhouseView-Sc_UDMOE.js → GreenhouseView-BxO84bKX.js} +1 -1
  147. package/web/dist/assets/{Installs-Gw-pJ3Zi.js → Installs-B1WcObxK.js} +1 -1
  148. package/web/dist/assets/{JournalView-_gxpsxXi.js → JournalView-0YNKcaaD.js} +1 -1
  149. package/web/dist/assets/{Laws-S9fq1r9T.js → Laws-DnDpRwpE.js} +1 -1
  150. package/web/dist/assets/Library-DBkKsBMe.js +1 -0
  151. package/web/dist/assets/{Loops-Da_JhlzM.js → Loops-P-6RCdzS.js} +1 -1
  152. package/web/dist/assets/Markdown-C6fRd4A4.js +67 -0
  153. package/web/dist/assets/{OperatorHudView--TkvDybH.js → OperatorHudView-BZZ3uJPH.js} +1 -1
  154. package/web/dist/assets/{Personal-CTXKHVbV.js → Personal-BoVw6OCW.js} +1 -1
  155. package/web/dist/assets/{Tabs-B0cdf8k6.js → Tabs-VYLhUTiX.js} +1 -1
  156. package/web/dist/assets/{activity-Dr7ZMBI3.js → activity-dcg5vF9j.js} +1 -1
  157. package/web/dist/assets/{drawable-BbWQxe82.js → drawable-CdNARIKC.js} +1 -1
  158. package/web/dist/assets/{index-ujKG1ZNf.js → index-hsabpYzv.js} +2 -2
  159. package/web/dist/assets/react-DRhoX0kS.js +9 -0
  160. package/web/dist/assets/{usePoll-DD8VjtSg.js → usePoll-BmR631BB.js} +1 -1
  161. package/web/dist/index.html +2 -2
  162. package/src/skills/bruno-collection-generator.md +0 -52
  163. package/src/skills/bruno-test-writer.md +0 -51
  164. package/src/skills/ci-fix.md +0 -49
  165. package/src/skills/consolidate-memory.md +0 -50
  166. package/src/skills/deps-audit.md +0 -46
  167. package/src/skills/domain-modeling.md +0 -27
  168. package/src/skills/git-archaeology.md +0 -36
  169. package/src/skills/git-rescue.md +0 -58
  170. package/src/skills/migrate.md +0 -34
  171. package/src/skills/opencode-theme.md +0 -42
  172. package/src/skills/repo-map.md +0 -25
  173. package/src/skills/tickets.md +0 -33
  174. package/web/dist/assets/FilterInput-CVyqmYWM.js +0 -1
  175. package/web/dist/assets/Library-MBxadRzn.js +0 -1
  176. package/web/dist/assets/Markdown-X2a3qvOh.js +0 -38
  177. package/web/dist/assets/react-B9Z_tXRV.js +0 -8
@@ -27,7 +27,8 @@ import { writeText, readText, copyFile } from '../lib/fs.mjs';
27
27
  import { jsonDumps, jsonDumpsIndent, parseJson } from '../lib/json.mjs';
28
28
  import { comparePaths } from '../lib/paths.mjs';
29
29
  import { sourceReleaseVersion } from '../build/version.mjs';
30
- import { firstBlockquote, stripSkillBodyLinks, pushOverrideLines } from './native.mjs';
30
+ import { aliasesOf } from '../build/source.mjs';
31
+ import { firstBlockquote, stripSkillBodyLinks, pointSideFiles, pushOverrideLines } from './native.mjs';
31
32
 
32
33
  // Re-exported for `js/web/api.mjs` and the tests, which import it from here.
33
34
  export { sourceReleaseVersion };
@@ -104,7 +105,7 @@ const AGENT_OVERRIDES_STUB = {
104
105
  * `/code-review`. As a list the renamed entry missed its skill and was dropped in silence, so a
105
106
  * missing skill is now a thrown error in `writeCommandLayer`.
106
107
  */
107
- const COMMAND_SET = {
108
+ export const COMMAND_SET = {
108
109
  commit: 'commit', plan: 'plan', 'code-review': 'geneseed-code-review',
109
110
  'review-response': 'review-response', ship: 'ship', debug: 'debug', research: 'research',
110
111
  };
@@ -276,7 +277,7 @@ export function ensureAgentOverridesStub(cfg, base) {
276
277
  * `command/commit.md` without the check made uninstall delete it. Default: claim everything,
277
278
  * for a caller with no manifest to consult.
278
279
  */
279
- export function writePrimaryAgent(cfg, agentsDir, overrides, claim = () => true) {
280
+ export function writePrimaryAgent(cfg, agentsDir, overrides, claim = (_dest) => true) {
280
281
  if (!truthyEnv('GENESEED_PRIMARY') || !existsSync(cfg.primaryAgentSrc)
281
282
  || !statSync(cfg.primaryAgentSrc).isFile()) {
282
283
  return null;
@@ -296,7 +297,8 @@ export function writePrimaryAgent(cfg, agentsDir, overrides, claim = () => true)
296
297
  }
297
298
 
298
299
  /** `_build_emit._write_command_layer` — the opt-in /slash commands. */
299
- export function writeCommandLayer(cfg, items, commandDir, claim = () => true) {
300
+ export function writeCommandLayer(cfg, items, commandDir, claim = (_dest) => true,
301
+ skillDirOf = (n) => path.join(path.dirname(commandDir), 'skills', n).split(path.sep).join('/')) {
300
302
  if (!truthyEnv('GENESEED_COMMANDS')) return [];
301
303
  const byName = new Map();
302
304
  for (const { text, src } of items) {
@@ -318,7 +320,8 @@ export function writeCommandLayer(cfg, items, commandDir, claim = () => true) {
318
320
  const dest = path.join(commandDir, `${name}.md`);
319
321
  if (!claim(dest)) continue;
320
322
  const desc = firstBlockquote(text);
321
- const body = stripSkillBodyLinks(text.replace(/^\n+/, ''));
323
+ // A command has no base directory, so a side-file pointer names the skill's own.
324
+ const body = stripSkillBodyLinks(pointSideFiles(text.replace(/^\n+/, ''), skill, skillDirOf(skill)));
322
325
  mkdirSync(path.dirname(dest), { recursive: true });
323
326
  writeText(dest, `---\ndescription: ${jsonDumps(desc)}\n---\n\n${body}`);
324
327
  written.push(dest);
@@ -326,8 +329,36 @@ export function writeCommandLayer(cfg, items, commandDir, claim = () => true) {
326
329
  return written;
327
330
  }
328
331
 
332
+ /**
333
+ * A merged skill's OLD names as `/alias` commands — OpenCode's form of the user-only alias
334
+ * skill `writeNativeLayer` writes for the Claude dialect. A command, not a skill, because
335
+ * OpenCode has no user-only skill: an alias skill would sit in the model's catalogue as a
336
+ * second copy of its target. UNCONDITIONAL, unlike the `GENESEED_COMMANDS` set — an old name
337
+ * that only works behind an opt-in is a name that broke. Read off the already-filtered
338
+ * `items`, so excluding the target drops its aliases.
339
+ */
340
+ export function writeAliasCommands(cfg, items, commandDir, claim = (_dest) => true,
341
+ skillDirOf = (n) => path.join(path.dirname(commandDir), 'skills', n).split(path.sep).join('/')) {
342
+ const written = [];
343
+ for (const { text, src } of items) {
344
+ if (text === null) continue;
345
+ const sp = path.relative(cfg.src, src).split(path.sep);
346
+ if (sp.length !== 2 || sp[0] !== 'skills' || !sp[1].endsWith('.md') || sp[1].startsWith('_')) continue;
347
+ const stem = sp[1].slice(0, -3);
348
+ const body = stripSkillBodyLinks(pointSideFiles(text.replace(/^\n+/, ''), stem, skillDirOf(stem)));
349
+ for (const alias of aliasesOf(text)) {
350
+ const dest = path.join(commandDir, `${alias}.md`);
351
+ if (!claim(dest)) continue;
352
+ mkdirSync(path.dirname(dest), { recursive: true });
353
+ writeText(dest, `---\ndescription: ${jsonDumps(`Alias of ${sp[1].slice(0, -3)}.`)}\n---\n\n${body}`);
354
+ written.push(dest);
355
+ }
356
+ }
357
+ return written;
358
+ }
359
+
329
360
  /** `_build_emit._write_ponytail_command` — registered UNCONDITIONALLY; null when the user's. */
330
- export function writePonytailCommand(commandDir, claim = () => true) {
361
+ export function writePonytailCommand(commandDir, claim = (_dest) => true) {
331
362
  const dest = path.join(commandDir, 'ponytail.md');
332
363
  if (!claim(dest)) return null;
333
364
  mkdirSync(path.dirname(dest), { recursive: true });
@@ -165,6 +165,7 @@ const OWNED_BASH = ['rm -rf *', 'git commit*', 'git push*', ...LAW_IV_BASH];
165
165
  * caller warns on it. This is the one branch where the boundary can end up with NO Geneseed
166
166
  * gate at all while the constitution states the rule — the unsafe direction — so it must not
167
167
  * also be the silent one.
168
+ * @returns {[boolean, string[], string | null]}
168
169
  */
169
170
  function reconcileOpencodePermission(perm, want) {
170
171
  // A `permission` that is not an object, or whose `bash` is not one — OpenCode also accepts a
@@ -513,7 +514,8 @@ export function claudeHookGroups(cfg, hookOpts, doctrines = null, excluded = [],
513
514
  SessionStart: [{ hooks: [{ type: 'command', command: `${run} context --root "${cfg}"${b} || exit 0` }] }],
514
515
  // One group, both gates: process 5's consent is a stderr line here (Bob has no ask
515
516
  // tier), so the pack toggle that drops Claude's git-gate group has nothing to drop —
516
- // `tool-gate` only ever EXITS 2 for Laws I and IV, which every build carries.
517
+ // `tool-gate` only ever EXITS 2 for Laws I and IV, which every build carries, and for a
518
+ // write under a project's own `.geneseed/protected-checks.txt` — the project opted in.
517
519
  PreToolUse: [{ hooks: [{ type: 'command', command: `${run} tool-gate --root "${cfg}"${b}` }] }],
518
520
  Stop: [{ hooks: [{ type: 'command', command: `${run} learn ${mem} || exit 0` }] }],
519
521
  };
package/js/hosts/shim.mjs CHANGED
@@ -215,6 +215,7 @@ function shimIsLive(p) {
215
215
  * The fallback is strictly no worse than the old behaviour and far better than emitting
216
216
  * commands naming a shim that does not exist — those fail on every hook (9009 under
217
217
  * cmd.exe, 127 under sh) and take both gates down with them.
218
+ * @param {{ runner?: string, entry?: string, platform?: NodeJS.Platform }} [opts]
218
219
  */
219
220
  export function hookPrefix({ runner, entry, platform = process.platform } = {}) {
220
221
  // NOT a default. `runner`/`entry` have no computable fallback on this side — this process's
@@ -19,7 +19,8 @@ import { readText } from '../lib/fs.mjs';
19
19
  import { which } from '../lib/paths.mjs';
20
20
  import { NO_WINDOW } from '../lib/proc.mjs';
21
21
  import {
22
- folderSkillProblems, registryProblems, secretProblems, vendorPinProblems,
22
+ aliasProblems, folderSkillProblems, registryProblems, secretProblems, sideFileProblems,
23
+ vendorPinProblems,
23
24
  } from './checks-repo.mjs';
24
25
  import { constitutionProblems, leanBlockProblems } from './checks-constitution.mjs';
25
26
  import { countTableProblems } from './checks-counts.mjs';
@@ -109,6 +110,8 @@ export function authoringProblems() {
109
110
  problems.push(...secretProblems());
110
111
  problems.push(...vendorPinProblems());
111
112
  problems.push(...folderSkillProblems());
113
+ problems.push(...aliasProblems());
114
+ problems.push(...sideFileProblems());
112
115
  problems.push(...constitutionProblems());
113
116
  problems.push(...leanBlockProblems());
114
117
  problems.push(...countTableProblems());
@@ -169,14 +169,14 @@ const DOCTRINE_TOKEN_RE = /\{\{DOCTRINE(?:\}\}|:)/;
169
169
  /**
170
170
  * The rules the hooks enforce at the tool boundary, and the address each is bound to there. The
171
171
  * hook path cannot afford to read the canon on every tool call, so its gate-ledger keys
172
- * (`law-1`, `law-4`, `process-1`, `process-5` in `js/hosts/hooks.mjs` and the OpenCode guard
172
+ * (`law-1`, `law-4`, `process-1`, `process-5`, `rigor-5` in `js/hosts/hooks.mjs` and the OpenCode guard
173
173
  * plugin), `CONSENT_RULE` in `js/hosts/settings.mjs` and the console's `HOOK_GATED` stay
174
174
  * number-keyed — and this pin is what turns a renumber that moves one of these rules into a
175
175
  * doctor failure instead of a gate silently guarding its neighbour.
176
176
  */
177
177
  export const HOOK_PINNED = {
178
178
  'sealed-secrets': 'I', 'deletion-is-deliberate': 'IV',
179
- 'persist-insight': 'process 1', 'consent-before-push': 'process 5',
179
+ 'persist-insight': 'process 1', 'consent-before-push': 'process 5', 'external-gate': 'rigor 5',
180
180
  };
181
181
 
182
182
  /**
@@ -7,15 +7,20 @@
7
7
  * itself and compares it against what is on disk.
8
8
  */
9
9
  import path from 'node:path';
10
- import { PLUGIN_SRC, ROOT, SRC, THEMES, WORKFLOW_SRC } from '../build/source.mjs';
10
+ import {
11
+ ALIASES_RE, PLUGIN_SRC, RETIRED_SKILL_IDS, ROOT, SRC, THEMES, WORKFLOW_SRC, aliasesOf, knownSkillIds,
12
+ } from '../build/source.mjs';
11
13
  import { VENDORED_SKILL_DIRS } from '../hosts/native.mjs';
14
+ import { COMMAND_SET } from '../hosts/opencode.mjs';
12
15
  import { hookShimPath, shimDeadPaths } from '../hosts/shim.mjs';
13
16
  import { readText } from '../lib/fs.mjs';
14
17
  import { formatRepr, formatValue } from '../lib/json.mjs';
15
18
  import { splitLines } from '../lib/udiff.mjs';
16
19
  import { ENTITY_STATUSES } from './inventory.mjs';
20
+ import { assessRepo } from './scorecard.mjs';
21
+ import { badgeUrl, renderSvg } from './scorecard-svg.mjs';
17
22
  import { isDir, isFile, rglob, srcStems } from './scan.mjs';
18
- import { readFileSync } from 'node:fs';
23
+ import { existsSync, readFileSync, readdirSync } from 'node:fs';
19
24
 
20
25
  /**
21
26
  * `_harness_build._registry_keys` — every entity the registry must describe.
@@ -106,6 +111,7 @@ export function registryProblems() {
106
111
  *
107
112
  * `(?i)` becomes the `i` flag on the last one only, which is where the Python puts it.
108
113
  */
114
+ /** @type {Array<[string, RegExp]>} */
109
115
  const SECRET_PATTERNS = [
110
116
  ['Anthropic API key', /sk-ant-[A-Za-z0-9_-]{20,}/],
111
117
  ['OpenAI-style API key', /\bsk-[A-Za-z0-9]{32,}/],
@@ -240,6 +246,83 @@ export function vendorPinProblems() {
240
246
  return problems;
241
247
  }
242
248
 
249
+ /**
250
+ * Every `<!-- aliases: … -->` name is a clean, unclaimed, single-owner path segment.
251
+ *
252
+ * An alias becomes a folder (`skills/<alias>/`) and a command file (`command/<alias>.md`) on
253
+ * every install, so a name that collides overwrites something: another skill, another
254
+ * skill's alias, or an OpenCode command (`COMMAND_SET`, `/ponytail`). A retired skill id is
255
+ * refused as an alias AND as a live skill — `RETIRED_SKILL_IDS` is the list of names that
256
+ * already meant something on someone's machine. The emit trusts all of this; this is where
257
+ * it is checked.
258
+ */
259
+ export function aliasProblems(src = SRC) {
260
+ const problems = [];
261
+ const known = knownSkillIds(src);
262
+ const reserved = new Set([...Object.keys(COMMAND_SET), 'ponytail']);
263
+ for (const id of known) {
264
+ if (Object.hasOwn(RETIRED_SKILL_IDS, id)) {
265
+ problems.push(`[authoring] skill '${id}' reuses a retired skill id (RETIRED_SKILL_IDS)`);
266
+ }
267
+ }
268
+ const owner = new Map();
269
+ for (const id of known) {
270
+ let text;
271
+ try { text = readText(path.join(src, 'skills', `${id}.md`)); } catch { continue; } // a folder skill
272
+ // `aliasesOf` reads the FIRST marker line only, so a second one would be dropped in silence.
273
+ if ((text.match(new RegExp(ALIASES_RE.source, 'gm')) || []).length > 1) {
274
+ problems.push(`[authoring] skills/${id}.md declares aliases on more than one line — merge them`);
275
+ }
276
+ for (const alias of aliasesOf(text)) {
277
+ const where = `skills/${id}.md alias '${alias}'`;
278
+ if (!/^[a-z0-9-]+$/.test(alias)) problems.push(`[authoring] ${where} is not [a-z0-9-]+`);
279
+ else if (known.includes(alias)) problems.push(`[authoring] ${where} is already a skill`);
280
+ else if (Object.hasOwn(RETIRED_SKILL_IDS, alias)) {
281
+ problems.push(`[authoring] ${where} is a retired skill id (RETIRED_SKILL_IDS)`);
282
+ } else if (reserved.has(alias)) problems.push(`[authoring] ${where} is an OpenCode command name`);
283
+ else if (owner.has(alias)) {
284
+ problems.push(`[authoring] ${where} is already declared by skills/${owner.get(alias)}.md`);
285
+ } else owner.set(alias, id);
286
+ }
287
+ }
288
+ return problems;
289
+ }
290
+
291
+ /**
292
+ * A first-party skill's side files (`src/skills/<name>/<file>.md`, emitted beside its SKILL.md
293
+ * and read only when the body points at them) keep the one layout every writer assumes: a flat
294
+ * `<name>.md` owns the folder, files sit one level down, each is `.md` (rendered and themed),
295
+ * none is `SKILL.md` (the emit writes that one), and the spec links every one as
296
+ * `[…](<name>/<file>)` — an unlinked side file ships to every install and is never read.
297
+ * Vendored folders are exempt: they carry their own SKILL.md and their own layout.
298
+ */
299
+ export function sideFileProblems(src = SRC) {
300
+ const problems = [];
301
+ const dir = path.join(src, 'skills');
302
+ for (const e of readdirSync(dir, { withFileTypes: true })) {
303
+ if (!e.isDirectory() || e.name.startsWith('_') || VENDORED_SKILL_DIRS.has(e.name)) continue;
304
+ const name = e.name;
305
+ let spec;
306
+ try { spec = readText(path.join(dir, `${name}.md`)); } catch {
307
+ problems.push(`[authoring] skills/${name}/ has no skills/${name}.md — a side-file folder `
308
+ + 'belongs to a flat spec (or list a folder skill in VENDORED_SKILL_DIRS)');
309
+ continue;
310
+ }
311
+ for (const f of readdirSync(path.join(dir, name), { withFileTypes: true })) {
312
+ const where = `skills/${name}/${f.name}`;
313
+ if (!f.isFile() || !f.name.endsWith('.md')) {
314
+ problems.push(`[authoring] ${where} — a side file is a .md one level down`);
315
+ } else if (f.name === 'SKILL.md') {
316
+ problems.push(`[authoring] ${where} collides with the SKILL.md the emit writes`);
317
+ } else if (!spec.includes(`](${name}/${f.name})`) && !spec.includes(`](${name}/${f.name}#`)) {
318
+ problems.push(`[authoring] ${where} is not linked from skills/${name}.md — `
319
+ + `link it as [${name}/${f.name}](${name}/${f.name}) or delete it`);
320
+ }
321
+ }
322
+ }
323
+ return problems.sort();
324
+ }
325
+
243
326
  const FRONTMATTER_RE = /^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/;
244
327
 
245
328
  /**
@@ -359,3 +442,82 @@ export function moduleMapProblems() {
359
442
  }
360
443
  return problems;
361
444
  }
445
+
446
+ /**
447
+ * The AI Harness Scorecard checks this repo passes, written out — the ratchet's floor.
448
+ *
449
+ * Taken from `geneseed scorecard` on main after #209-#213 (security policy, PR template, ARCHITECTURE
450
+ * and ADRs, the audit and commitlint jobs, Dependabot, coverage, property tests, fuzzing): 26 of 31,
451
+ * 81.2/100, matching upstream's Python tool check for check. Then the weekly Stryker run and the
452
+ * offline link check added mutation_testing and stale_doc_detection: 28 of 31, 88.7 (A); the checkJs
453
+ * pass added type_safety (partial: `strict` is off): 29 of 31, 90.4. Grouped by pillar, in upstream
454
+ * order.
455
+ * The ratchet is on `passed`, which upstream sets for any score above zero: `test_suite_exists`
456
+ * and `error_handling_policy` pass at 1.5 of 3, and a full check sliding to a partial one is
457
+ * invisible here. A check this repo starts to pass is added by hand; one it stops passing is a
458
+ * doctor problem until fixed or deliberately removed from this list, in the same commit.
459
+ */
460
+ export const SCORECARD_FLOOR = [
461
+ 'architecture_doc',
462
+ 'agent_instructions',
463
+ 'adr_presence',
464
+ 'module_boundary_docs',
465
+ 'ci_pipeline_exists',
466
+ 'linter_enforcement',
467
+ 'type_safety',
468
+ 'dependency_auditing',
469
+ 'conventional_commits',
470
+ 'unsafe_code_policy',
471
+ 'test_suite_exists',
472
+ 'feature_matrix_testing',
473
+ 'coverage_measurement',
474
+ 'mutation_testing',
475
+ 'property_based_testing',
476
+ 'fuzz_testing',
477
+ 'contract_tests',
478
+ 'tests_blocking_ci',
479
+ 'code_review_required',
480
+ 'scheduled_ci',
481
+ 'stale_doc_detection',
482
+ 'mr_template',
483
+ 'automated_review',
484
+ 'doc_sync_check',
485
+ 'ai_usage_norms',
486
+ 'small_batch_enforcement',
487
+ 'multiple_approach_culture',
488
+ 'error_handling_policy',
489
+ 'security_critical_marking',
490
+ ];
491
+
492
+ /**
493
+ * The scorecard ratchet — one problem per `SCORECARD_FLOOR` check that no longer passes.
494
+ *
495
+ * DEVELOPMENT CHECKOUT ONLY: it scores `.github/` (CI, CODEOWNERS, the PR template), and
496
+ * `.github/` is WITHHELD from the npm package (`tests/unit/package_manifest.test.mjs`), so an npm
497
+ * install would fail half the floor for files it was never meant to carry. A `.git` entry (a
498
+ * directory in a clone, a FILE in a worktree — hence `existsSync`, not `isDir`) plus
499
+ * `.github/workflows` is what only a checkout of this repository has. Anything else skips: [].
500
+ */
501
+ export function scorecardProblems(root = ROOT) {
502
+ if (!existsSync(path.join(root, '.git')) || !isDir(path.join(root, '.github', 'workflows'))) return [];
503
+ const a = assessRepo(root);
504
+ const checks = new Map(a.categories.flatMap((c) => c.checks).map((k) => [k.id, k]));
505
+ // THE README CARD IS A RENDER OF THIS SAME ASSESSMENT, compared byte for byte: a card that no
506
+ // longer matches the score is the stale-number problem the badges have, caught the same way.
507
+ const card = path.join(root, ...SCORECARD_CARD.split('/'));
508
+ const stale = isFile(card) && readText(card) === renderSvg(a) ? [] : [
509
+ `[scorecard] ${SCORECARD_CARD} does not match the current score — run: geneseed scorecard --svg ${SCORECARD_CARD}`,
510
+ ];
511
+ const readme = path.join(root, 'README.md');
512
+ if (isFile(readme) && !readText(readme).includes(`(${badgeUrl(a)})`)) {
513
+ stale.push(`[scorecard] README.md's scorecard badge does not show the current score — set it to ${badgeUrl(a)}`);
514
+ }
515
+ return stale.concat(SCORECARD_FLOOR.filter((id) => !checks.get(id)?.passed).map((id) => {
516
+ const k = checks.get(id);
517
+ return `[scorecard] ${id} no longer passes — it is in SCORECARD_FLOOR (js/inspect/checks-repo.mjs)`
518
+ + (k ? `: ${k.evidence}. Fix: ${k.remediation}` : ', and the scorecard has no such check');
519
+ }));
520
+ }
521
+
522
+ /** Where the README's scorecard card lives, and what doctor compares against a fresh render. */
523
+ export const SCORECARD_CARD = 'docs/assets/scorecard.svg';
@@ -189,7 +189,10 @@ export function diffCollect({ target = null, theme = null, emit = null, footprin
189
189
  // reference render names the temp tree. Mapped back to the deployed dir before comparing,
190
190
  // or every such skill reads as edited. A deployment that has since MOVED still differs,
191
191
  // and that is real drift — its skills point at a directory that is no longer theirs.
192
+ // A side-file pointer (and a global OpenCode command's) spells the same directory
193
+ // `/`-separated, so that spelling is mapped too.
192
194
  const deployedDir = resolvePath(dir);
195
+ const posix = (p) => p.split(path.sep).join('/');
193
196
  const rels = [...new Set([...ownedSet(dir), ...ownedSet(expected)])].sort();
194
197
  for (const rel of rels) {
195
198
  const a = path.join(dir, rel);
@@ -198,7 +201,8 @@ export function diffCollect({ target = null, theme = null, emit = null, footprin
198
201
  // `read_text(errors="replace")` — Node's utf8 decoder substitutes U+FFFD for the
199
202
  // same bytes, and `readText` folds the line endings the way `Path.read_text` does.
200
203
  const ta = readText(a);
201
- const tb = readText(b).split(expected).join(deployedDir);
204
+ const tb = readText(b).split(expected).join(deployedDir)
205
+ .split(posix(expected)).join(posix(deployedDir));
202
206
  if (cmpKey(rel, ta) !== cmpKey(rel, tb)) {
203
207
  const diff = unifiedDiff(splitLines(tb), splitLines(ta), {
204
208
  fromfile: `source/${rel}`, tofile: `deployed/${rel}`, lineterm: '',
@@ -40,7 +40,7 @@ import { authoringProblems, loopProblems } from './checks-authoring.mjs';
40
40
  import {
41
41
  checkBuild, colorThemeProblems, renderedProblems, themeParityProblems,
42
42
  } from './checks-build.mjs';
43
- import { moduleMapProblems, shimProblems } from './checks-repo.mjs';
43
+ import { moduleMapProblems, scorecardProblems, shimProblems } from './checks-repo.mjs';
44
44
  import { isDoctorNote, sortedProblems, sortedUnique, stemOf, withTempDir } from './scan.mjs';
45
45
 
46
46
  /**
@@ -220,6 +220,7 @@ export function doctorCollect({
220
220
  problems = problems.concat(ran('loops', 'Loop catalogue', loopProblems()));
221
221
  problems = problems.concat(ran('shim', 'Hook shim', shimProbs));
222
222
  problems = problems.concat(ran('map', 'Module map', moduleMapProblems()));
223
+ problems = problems.concat(ran('scorecard', 'Scorecard floor', scorecardProblems()));
223
224
  // P10c's `cli` check is GONE, and the reason is not that it stopped mattering. It hashed
224
225
  // `rituals/harness.py` and compared that against a digest baked into `cli.json`, to catch a
225
226
  // parser edited without regenerating the table. P2 made the table the OWNED document
@@ -92,23 +92,21 @@ export const SKILL_CLASS = {
92
92
  brainstorm: 'design', plan: 'design', council: 'design',
93
93
  workflow: 'design', 'parallel-agents': 'design', pipeline: 'design',
94
94
  loop: 'design', 'brick-forge': 'design',
95
- 'codebase-design': 'design', 'domain-modeling': 'design', wayfinder: 'design',
96
- tickets: 'design',
97
- develop: 'build', refactor: 'build', debug: 'build', migrate: 'build',
98
- 'frontend-design': 'build', 'opencode-theme': 'build', prototype: 'build',
99
- 'forge-mcp': 'build', 'bruno-collection-generator': 'build', 'bruno-test-writer': 'build',
100
- worktree: 'build', 'ci-fix': 'build', 'deps-audit': 'build',
95
+ 'codebase-design': 'design', wayfinder: 'design',
96
+ develop: 'build', refactor: 'build', debug: 'build', dependencies: 'build',
97
+ 'frontend-design': 'build', prototype: 'build',
98
+ 'forge-mcp': 'build', bruno: 'build', worktree: 'build',
101
99
  'geneseed-code-review': 'review', 'fresh-eyes': 'review', 'security-audit': 'review',
102
100
  'roast-me': 'review', 'review-response': 'review', ponytail: 'review',
103
- commit: 'ship', ship: 'ship', release: 'ship', handoff: 'ship', 'git-rescue': 'ship',
104
- 'repo-map': 'understand', 'git-archaeology': 'understand',
101
+ commit: 'ship', ship: 'ship', release: 'ship', handoff: 'ship',
102
+ 'git-history': 'understand',
105
103
  research: 'understand', ingest: 'understand', 'document-project': 'understand',
106
104
  prose: 'understand',
107
105
  // `harness` — skills about the agent's own apparatus (its harness, memory, rules,
108
106
  // knowledge base, terminal) rather than about the user's code. They sat under
109
107
  // `understand` before, which made that class a grab-bag.
110
108
  wiki: 'harness', geneseed: 'harness', rule: 'harness', profile: 'harness',
111
- herdr: 'harness', 'skill-forge': 'harness', 'consolidate-memory': 'harness',
109
+ herdr: 'harness', 'skill-forge': 'harness',
112
110
  teach: 'learn', quiz: 'learn', 'learn-mode': 'learn',
113
111
  };
114
112