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
@@ -12,7 +12,7 @@ import { writeText } from '../lib/fs.mjs';
12
12
  import { isDir } from '../lib/fs.mjs';
13
13
  import { jsonDumpsIndent } from '../lib/json.mjs';
14
14
  import { EXCLUDES_FILE } from '../hosts/hosts.mjs';
15
- import { existsSync } from 'node:fs';
15
+ import { existsSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
16
16
 
17
17
  // ---------------------------------------------------------------------------
18
18
  // Seeded stubs. Written once into the bundle and NEVER overwritten — each holds
@@ -34,7 +34,7 @@ const CONTEXT_STUB = {
34
34
 
35
35
  /** `_build_render.WIKI_STUB`. */
36
36
  const WIKI_STUB = `\
37
- // Geneseed wiki.jsonc — declare your machine-wide knowledge base(s) here, typically
37
+ // geneseed-wiki.jsonc — declare your machine-wide knowledge base(s) here, typically
38
38
  // an Obsidian vault (AGENT.md: the Wiki section). Comments are allowed in this file
39
39
  // (JSONC). It is host-specific — never commit it. The build created it once, empty,
40
40
  // and will never overwrite it.
@@ -183,7 +183,8 @@ const BUNDLE_GITIGNORE = `\
183
183
  context.json
184
184
 
185
185
  # Knowledge-base manifest — holds private machine paths; never commit.
186
- # (wiki.json is the legacy name from earlier seeds.)
186
+ # (wiki.jsonc and wiki.json are its legacy names from earlier seeds.)
187
+ geneseed-wiki.jsonc
187
188
  wiki.jsonc
188
189
  wiki.json
189
190
 
@@ -220,9 +221,37 @@ export function ensureContextStub(out) {
220
221
  seed(path.join(out, 'context.json'), `${jsonDumpsIndent(CONTEXT_STUB, { ensureAscii: false })}\n`);
221
222
  }
222
223
 
223
- /** `_build_render.ensure_wiki_stub` — a legacy `wiki.json` counts as present. */
224
+ /** The wiki manifest's name. `geneseed-` says who owns it in a shared `.claude/`/`.opencode/`. */
225
+ export const WIKI_FILE = 'geneseed-wiki.jsonc';
226
+ /** Its earlier names, newest first. Readers still fall back to them for one release. */
227
+ const LEGACY_WIKI_FILES = ['wiki.jsonc', 'wiki.json'];
228
+
229
+ /**
230
+ * `_build_render.ensure_wiki_stub`, plus the rename. A legacy manifest is the user's file: it is
231
+ * RENAMED to `WIKI_FILE` (bytes preserved by construction), never copied and never seeded beside.
232
+ * A rename that fails (the file held open on Windows) leaves it where it is and seeds nothing —
233
+ * a seed beside it would fork the user's declarations; the readers' fallback still finds it.
234
+ * The sibling `.gitignore` follows the rename, or the private paths become committable.
235
+ */
224
236
  export function ensureWikiStub(out) {
225
- if (!existsSync(path.join(out, 'wiki.json'))) seed(path.join(out, 'wiki.jsonc'), WIKI_STUB);
237
+ const dest = path.join(out, WIKI_FILE);
238
+ if (existsSync(dest)) return;
239
+ const legacy = LEGACY_WIKI_FILES.find((n) => existsSync(path.join(out, n)));
240
+ if (!legacy) return seed(dest, WIKI_STUB);
241
+ try { renameSync(path.join(out, legacy), dest); } catch { return; }
242
+ ignoreRenamedWiki(path.join(out, '.gitignore'), legacy);
243
+ }
244
+
245
+ /** Add `WIKI_FILE` to a `.gitignore` that ignores the legacy name and not the new one, in the
246
+ * file's own line ending. Anything else — no file, no legacy line — is left alone. */
247
+ function ignoreRenamedWiki(gi, legacy) {
248
+ let text;
249
+ try { text = readFileSync(gi, 'utf8'); } catch { return; }
250
+ const lines = text.split(/\r?\n/);
251
+ if (!lines.includes(legacy) || lines.includes(WIKI_FILE)) return;
252
+ const eol = text.includes('\r\n') ? '\r\n' : '\n';
253
+ const sep = text.endsWith('\n') ? '' : eol;
254
+ writeFileSync(gi, `${text}${sep}${WIKI_FILE}${eol}`);
226
255
  }
227
256
 
228
257
  /** `_build_render.ensure_rules_stub`. */
@@ -264,7 +293,7 @@ export const ensureNotebookIndex = storeIndexWriter('NOTEBOOK.md', NOTEBOOK_INDE
264
293
  * the two copies must move with it.
265
294
  */
266
295
  export const SESSION_SEEDS = {
267
- 'user-rules.md': RULES_STUB, 'PROFILE.md': PROFILE_STUB, 'wiki.jsonc': WIKI_STUB,
296
+ 'user-rules.md': RULES_STUB, 'PROFILE.md': PROFILE_STUB, [WIKI_FILE]: WIKI_STUB,
268
297
  'MEMORY.md': MEMORY_INDEX, 'NOTEBOOK.md': NOTEBOOK_INDEX,
269
298
  };
270
299
 
@@ -144,9 +144,7 @@ export function syncThemes(themesDir = THEMES) {
144
144
  } catch {
145
145
  process.stdout.write(`[sync-themes] ${path.basename(tmplPath)} is missing or unreadable `
146
146
  + '— there is nothing to sync against.\n');
147
- const e = new Error('template unreadable');
148
- e.exitCode = 2;
149
- throw e;
147
+ throw Object.assign(new Error('template unreadable'), { exitCode: 2 });
150
148
  }
151
149
  const tmplKeys = Object.keys(tmpl);
152
150
  let changed = 0;
package/js/cli-table.json CHANGED
@@ -1079,6 +1079,59 @@
1079
1079
  ],
1080
1080
  "mutex": []
1081
1081
  },
1082
+ {
1083
+ "name": "scorecard",
1084
+ "help": "grade a repo with the AI Harness Scorecard (31 checks, five pillars) — scores the repo itself (CI, tests, docs), not the harness files; a reading, never a gate",
1085
+ "description": "",
1086
+ "positionals": [
1087
+ {
1088
+ "names": [],
1089
+ "dest": "path",
1090
+ "metavar": null,
1091
+ "help": "the repo to score (default: the current directory)",
1092
+ "choices": null,
1093
+ "default": null,
1094
+ "required": false,
1095
+ "nargs": "?",
1096
+ "is_flag": false,
1097
+ "type": null,
1098
+ "hidden": false
1099
+ }
1100
+ ],
1101
+ "options": [
1102
+ {
1103
+ "names": [
1104
+ "--json"
1105
+ ],
1106
+ "dest": "json",
1107
+ "metavar": null,
1108
+ "help": "print the full assessment as JSON — every check with its score, evidence and fix",
1109
+ "choices": null,
1110
+ "default": false,
1111
+ "required": false,
1112
+ "nargs": "0",
1113
+ "is_flag": true,
1114
+ "type": null,
1115
+ "hidden": false
1116
+ },
1117
+ {
1118
+ "names": [
1119
+ "--svg"
1120
+ ],
1121
+ "dest": "svg",
1122
+ "metavar": "PATH",
1123
+ "help": "write the scorecard as an SVG card (grade + one bar per pillar) to PATH instead of printing — the README card is docs/assets/scorecard.svg",
1124
+ "choices": null,
1125
+ "default": null,
1126
+ "required": false,
1127
+ "nargs": null,
1128
+ "is_flag": false,
1129
+ "type": null,
1130
+ "hidden": false
1131
+ }
1132
+ ],
1133
+ "mutex": []
1134
+ },
1082
1135
  {
1083
1136
  "name": "setup",
1084
1137
  "help": "interactive install wizard (dependency-free, all OSes)",
@@ -32,7 +32,8 @@ const _ERRNO = {
32
32
  function asOsError(e, filename) {
33
33
  const hit = _ERRNO[e && e.code];
34
34
  if (!hit) return String((e && e.message) || e);
35
- // Python reprs the filename, so a Windows path comes out with its backslashes doubled.
35
+ // The `[Errno N] text: 'file'` shape this hook has always printed: the filename is quoted,
36
+ // so a Windows path comes out with its backslashes doubled.
36
37
  return `[Errno ${hit[0]}] ${hit[1]}: ${JSON.stringify(filename).replace(/^"|"$/g, "'")
37
38
  .replace(/\\"/g, '"')}`;
38
39
  }
@@ -57,7 +58,7 @@ function fnTranslate(pat) {
57
58
  if (pat[j] === '!') j += 1;
58
59
  if (pat[j] === ']') j += 1;
59
60
  while (j < pat.length && pat[j] !== ']') j += 1;
60
- // An unterminated `[` is a LITERAL bracket in Python's translate, not an error.
61
+ // An unterminated `[` is a LITERAL bracket (fnmatch semantics), not an error.
61
62
  if (j >= pat.length) { out += '\\['; continue; }
62
63
  let body = pat.slice(i + 1, j).replaceAll('\\', '\\\\');
63
64
  if (body.startsWith('!')) body = `^${body.slice(1)}`;
@@ -77,9 +78,14 @@ function fnTranslate(pat) {
77
78
  */
78
79
  let HOST = 'claude';
79
80
 
80
- // Kept in step with adapters/opencode/plugins/geneseed-context.js, same as the Python.
81
+ // Kept in step with adapters/opencode/plugins/geneseed-context.js.
81
82
  const EAGER_ROOT = ['AGENTS.md', 'AGENT.md', 'CLAUDE.md', '.cursorrules',
82
- 'README.md', 'CONTRIBUTING.md', 'user-rules.md', 'PROFILE.md'];
83
+ 'README.md', 'README.adoc', 'CONTRIBUTING.md', 'CONTRIBUTING.adoc', 'user-rules.md', 'PROFILE.md'];
84
+ // Project docs are Markdown or AsciiDoc: the document-project skill writes AsciiDoc when the
85
+ // user asks for it or the docs already are, so a loader blind to `.adoc` would hide those docs.
86
+ // Agent-runtime files (AGENTS.md, CLAUDE.md, specs) stay Markdown whatever the project uses.
87
+ const DOC_EXT = new Set(['.md', '.adoc']);
88
+ const isDoc = (name) => DOC_EXT.has(path.extname(name).toLowerCase());
83
89
  const LAZY_DIRS = ['docs', 'doc', 'documentation', 'architecture', 'adr', 'ADR'];
84
90
  const EXCLUDE_DIRS = new Set(['node_modules', '.git', 'dist', 'build', 'vendor', '.next',
85
91
  'target', '.venv', '__pycache__', '.opencode', '.harness']);
@@ -120,7 +126,10 @@ const cost = (s) => s.length + (s.match(/\n/g) || []).length + 2;
120
126
  // honoured by `resolveContextSets` (the harness dir is its last candidate). The OpenCode
121
127
  // context plugin carries the same list.
122
128
  const SESSION_FILES = ['user-rules.md', 'PROFILE.md', 'memory/MEMORY.md', 'anamnesis/MEMORY.md',
123
- 'notebook/NOTEBOOK.md', 'wiki.jsonc'];
129
+ 'notebook/NOTEBOOK.md', 'geneseed-wiki.jsonc'];
130
+ // The wiki manifest's earlier name, read when the new one is absent (an install not yet re-emitted,
131
+ // or a rename that failed). One release; `wiki.json`, older still, is the build's to rename.
132
+ const LEGACY_WIKI = 'wiki.jsonc';
124
133
  // SHA-256 of each seed body in `js/build/stubs.mjs` (`SESSION_SEEDS`), CRLF folded. A file still
125
134
  // byte-identical to its seed says nothing, so it is skipped rather than injected as noise.
126
135
  // Hashes, not an import: stubs.mjs pulls in the build's writers, and this module loads on every
@@ -128,7 +137,9 @@ const SESSION_FILES = ['user-rules.md', 'PROFILE.md', 'memory/MEMORY.md', 'anamn
128
137
  const SEED_SHA256 = new Set([
129
138
  '5c2e92fb1acde041e02d9ebf158460d8ff7a07cbc2b447859631604610130721', // user-rules.md
130
139
  '29e012c3c4349ea62a9082cbd04bb25599a0ec280d62c33f6e10742809471816', // PROFILE.md
131
- 'cccc917c34e6b990e821620bdb4739090151885ab71b6e85046f30a3b71fa5e8', // wiki.jsonc
140
+ '3a82c78ccc5d745033c16c354f5eda9d79350c704e9da8d605d380fb1e108d7a', // geneseed-wiki.jsonc
141
+ // The wiki seed before the rename: the build renames an untouched one byte for byte.
142
+ 'cccc917c34e6b990e821620bdb4739090151885ab71b6e85046f30a3b71fa5e8', // wiki.jsonc (legacy)
132
143
  '99c786049c260f6baf7aec68a5c0f59907861afe9b867da009440613bdddb907', // MEMORY.md
133
144
  '9acb5c9d9cb5dac572104480f05b48cc144e676869bd60e86d8a15b1f6308184', // NOTEBOOK.md
134
145
  ]);
@@ -144,8 +155,9 @@ export function sessionFiles(hookRoot) {
144
155
  const found = [];
145
156
  for (const rel of SESSION_FILES) {
146
157
  let abs = path.join(hookRoot, rel);
147
- if (rel === 'wiki.jsonc' && process.env.GENESEED_WIKI && isFile(process.env.GENESEED_WIKI)) {
148
- abs = process.env.GENESEED_WIKI;
158
+ if (rel === 'geneseed-wiki.jsonc') {
159
+ if (process.env.GENESEED_WIKI && isFile(process.env.GENESEED_WIKI)) abs = process.env.GENESEED_WIKI;
160
+ else if (!isFile(abs)) abs = path.join(hookRoot, LEGACY_WIKI);
149
161
  }
150
162
  if (!isFile(abs)) continue;
151
163
  let text;
@@ -182,9 +194,8 @@ export function globalHookStandingDown(hookRoot, cwd) {
182
194
 
183
195
  /** `_disp` — relative to the repo root when it sits under it, else verbatim. */
184
196
  function disp(pathStr, root) {
185
- // `os.path.relpath` raises ValueError across drives and the Python falls back to the
186
- // path as given; `path.relative` silently returns an absolute path instead, which would
187
- // print a DIFFERENT string rather than the same one.
197
+ // A path on another drive has no relative form, so it is printed as given. `path.relative`
198
+ // would silently return an absolute path instead — a different string, not the same one.
188
199
  if (path.parse(path.resolve(pathStr)).root.toLowerCase()
189
200
  !== path.parse(path.resolve(root)).root.toLowerCase()) {
190
201
  return pathStr;
@@ -193,18 +204,18 @@ function disp(pathStr, root) {
193
204
  }
194
205
 
195
206
 
196
- function rglobMd(dir, acc = []) {
207
+ function rglobDocs(dir, acc = []) {
197
208
  for (const name of listDir(dir)) {
198
209
  const full = path.join(dir, name);
199
- if (isDir(full)) rglobMd(full, acc);
200
- else if (isFile(full) && path.extname(name).toLowerCase() === '.md') acc.push(full);
210
+ if (isDir(full)) rglobDocs(full, acc);
211
+ else if (isFile(full) && isDoc(name)) acc.push(full);
201
212
  }
202
213
  return acc;
203
214
  }
204
215
 
205
216
  /**
206
217
  * `_discover_context` — the no-manifest path, mirroring the OpenCode context plugin.
207
- * Root entry docs are eager; other root markdown, the doc trees and monorepo package
218
+ * Root entry docs are eager; other root docs (.md or .adoc), the doc trees and monorepo package
208
219
  * READMEs are lazy.
209
220
  */
210
221
  export function discoverContext(root, host = HOST) {
@@ -217,12 +228,12 @@ export function discoverContext(root, host = HOST) {
217
228
  const name = path.basename(full);
218
229
  if (native.includes(name)) continue;
219
230
  if (EAGER_ROOT.includes(name)) eager.set(full, null);
220
- else if (path.extname(name).toLowerCase() === '.md') lazy.set(full, null);
231
+ else if (isDoc(name)) lazy.set(full, null);
221
232
  }
222
233
  for (const d of LAZY_DIRS) {
223
234
  const sub = path.join(root, d);
224
235
  if (!isDir(sub)) continue;
225
- for (const md of sortPaths(rglobMd(sub))) {
236
+ for (const md of sortPaths(rglobDocs(sub))) {
226
237
  const parts = path.relative(root, md).split(/[\\/]/);
227
238
  if (parts.some((part) => EXCLUDE_DIRS.has(part))) continue;
228
239
  if (!eager.has(md) && !lazy.has(md)) lazy.set(md, null);
@@ -233,8 +244,10 @@ export function discoverContext(root, host = HOST) {
233
244
  if (!isDir(base)) continue;
234
245
  for (const pkg of sortPaths(listDir(base).map((n) => path.join(base, n)))) {
235
246
  if (!isDir(pkg) || EXCLUDE_DIRS.has(path.basename(pkg))) continue;
236
- const readme = path.join(pkg, 'README.md');
237
- if (isFile(readme) && !eager.has(readme) && !lazy.has(readme)) lazy.set(readme, null);
247
+ for (const name of ['README.md', 'README.adoc']) {
248
+ const readme = path.join(pkg, name);
249
+ if (isFile(readme) && !eager.has(readme) && !lazy.has(readme)) lazy.set(readme, null);
250
+ }
238
251
  }
239
252
  }
240
253
  return [
@@ -249,6 +262,7 @@ export function discoverContext(root, host = HOST) {
249
262
  *
250
263
  * `recs` is a Map, not an object: JS reorders integer-like keys on a plain object, and
251
264
  * these keys are absolute paths whose iteration order becomes the printed order.
265
+ * @returns {[Array<{path: string, description: string}>, Array<{path: string, description: string}>, string]}
252
266
  */
253
267
  export function resolveContextSets(root, hookRoot = null) {
254
268
  let manifest = null;
@@ -281,19 +295,18 @@ export function resolveContextSets(root, hookRoot = null) {
281
295
  let extend;
282
296
  try {
283
297
  let data = JSON.parse(raw);
284
- // Valid JSON of the wrong SHAPE, guarded on both sides — see the Python original. JS
285
- // would not have thrown here (`[].context` is merely undefined), which is exactly why
286
- // the guard is written out rather than left to fall out of the language: without it,
287
- // the two CLIs disagree about a file a user really does hand-edit.
298
+ // Valid JSON of the wrong SHAPE (null, a number, an array) is an empty manifest, not a
299
+ // syntax error. The guard is written out rather than left to fall out of the language:
300
+ // without it, `null.context` throws into the catch below and a file a user really does
301
+ // hand-edit is reported as invalid JSON when it is not.
288
302
  data = data && typeof data === 'object' && !Array.isArray(data) ? data : {};
289
303
  entries = data.context || [];
290
304
  if (!Array.isArray(entries)) entries = [];
291
305
  extend = Boolean(data.extend);
292
306
  } catch {
293
- // See the Python original for why the decoder's own message is not quoted: the two
294
- // engines disagree on the wording and on the offset, and V8 supplies no offset at all
295
- // for the commonest shapes. Reporting the file is what the user needs; reporting
296
- // WHICH decoder found it is what makes two implementations impossible.
307
+ // The decoder's own message is not quoted: its wording and offset are engine details,
308
+ // and V8 supplies no offset at all for the commonest shapes. Reporting the file is what
309
+ // the user needs.
297
310
  err(`[context] ${manifest} is not valid JSON — no context was loaded `
298
311
  + '(fix the syntax, then re-run).\n');
299
312
  return [[], [], String(manifest)];
@@ -201,7 +201,7 @@ const BOB_DENY_EXIT = 2;
201
201
  * logs. `gate-error` is a warning too: a crashed gate that blocked every tool call would be
202
202
  * a lockout, not a safeguard, and there is no prompt through which the user could clear it.
203
203
  */
204
- const BLOCK_RULES = new Set(['law-1', 'law-4']);
204
+ const BLOCK_RULES = new Set(['law-1', 'law-4', 'rigor-5']);
205
205
 
206
206
  /**
207
207
  * THE GATE LEDGER — one JSON line per ask, nothing per defer.
@@ -358,6 +358,33 @@ const SECRET_RE =
358
358
  /\b(?:AKIA[0-9A-Z]{16}|ghp_[A-Za-z0-9]{30,}|github_pat_[A-Za-z0-9_]{30,}|sk-ant-[A-Za-z0-9_-]{20,}|xox[abprs]-[0-9A-Za-z-]{10,})\b|-----BEGIN [A-Z ]*PRIVATE KEY-----/;
359
359
  const DOTENV_RE = /(^|[\\/])\.env(\.[^\\/]*)?$/;
360
360
 
361
+ /**
362
+ * External Gate's boundary half: the checks an agent is judged by must sit outside what it can
363
+ * edit, or a failing check is one edit away from a passing one. A project opts in by listing
364
+ * them, one repo-relative path per line (`#` comments), in `.geneseed/protected-checks.txt` at
365
+ * its git root — found by walking up from the WRITE TARGET, not the cwd, so a session in one repo
366
+ * writing into another reads that other repo's list. The list protects itself. Returns the entry
367
+ * the path falls under, or null. ponytail: a write tool only — a shell `sed -i` or redirect is
368
+ * not seen here; CI running the checks is the boundary that holds against that.
369
+ */
370
+ export const SENSOR_LIST = '.geneseed/protected-checks.txt';
371
+ function protectedCheck(p) {
372
+ try {
373
+ const target = resolvePath(p);
374
+ const root = gitRootOf(path.dirname(target));
375
+ if (!root) return null;
376
+ const text = readFileSync(path.join(root, SENSOR_LIST), 'utf8');
377
+ // `normcase` folds case AND slashes on Windows, so both sides go through it, the trailing
378
+ // separator of the prefix included.
379
+ const rel = normcase(path.relative(root, target));
380
+ const entries = [SENSOR_LIST, ...text.split(/\r?\n/).map((l) => l.trim().replace(/[\\/]+$/, ''))
381
+ .filter((l) => l && !l.startsWith('#'))];
382
+ return entries.find((e) => rel === normcase(e) || rel.startsWith(normcase(`${e}/`))) ?? null;
383
+ } catch {
384
+ return null; // no list (the common case) or an unreadable one: nothing is protected
385
+ }
386
+ }
387
+
361
388
  function ruleGate(args) {
362
389
  if (sovereignBypass(args.root)) return 0;
363
390
  return ruleDecide(args, readPayload());
@@ -378,6 +405,12 @@ function ruleDecide(args, payload) {
378
405
  + 'credential-shaped string. Secrets live in .env or a secret manager, never in a '
379
406
  + 'tracked file.');
380
407
  }
408
+ const sensor = protectedCheck(p);
409
+ if (sensor) {
410
+ return ask(args, 'rule-gate', 'rigor-5', `Geneseed (External Gate) — ${p} is a protected `
411
+ + `check (${sensor}, listed in ${SENSOR_LIST}). A check the agent can edit is not a `
412
+ + 'check: fix the code, not the check, unless the user asks for this edit.');
413
+ }
381
414
  const target = ruleGateTarget(p, args.root);
382
415
  if (!target) return 0;
383
416
  return ask(args, 'rule-gate', 'process-1', `Geneseed (Persist Insight) \u2014 writing to `
@@ -128,9 +128,7 @@ export function expanduser(p) {
128
128
  + "ntpath/posixpath would guess a home directory for 'user' — this port does not); "
129
129
  + 'pass an absolute path, or "~" / "~/…" for your own home directory';
130
130
  printErr(`${msg}\n`);
131
- const e = new Error(msg);
132
- e.exitCode = 1;
133
- throw e;
131
+ throw Object.assign(new Error(msg), { exitCode: 1 });
134
132
  }
135
133
  return p;
136
134
  }
@@ -35,7 +35,7 @@
35
35
  import { existsSync, readdirSync } from 'node:fs';
36
36
  import path from 'node:path';
37
37
 
38
- import { CONFIG, PACK_ORDER, THEMES, discoverNames, knownSkillIds } from '../build/source.mjs';
38
+ import { CONFIG, PACK_ORDER, THEMES, discoverNames, resolveSkillNames } from '../build/source.mjs';
39
39
  import { CLAUDE_STYLE, GLOBAL_MANIFEST, HOSTS, resolvePath } from './hosts.mjs';
40
40
  import { registryRoots } from '../inspect/registry.mjs';
41
41
  import { printErr, readText, isFile, isDir } from '../lib/fs.mjs';
@@ -408,7 +408,7 @@ export function excludedRulesOfDir(d) {
408
408
  }, []);
409
409
  }
410
410
 
411
- /** `Excluded skills: bruno-test-writer, daydream` — written only when something is excluded. */
411
+ /** `Excluded skills: bruno, daydream` — written only when something is excluded. */
412
412
  const EXCLUDED_SKILLS_RE = /^Excluded skills:[ \t]*(.+?)[ \t]*$/m;
413
413
 
414
414
  /**
@@ -416,17 +416,23 @@ const EXCLUDED_SKILLS_RE = /^Excluded skills:[ \t]*(.+?)[ \t]*$/m;
416
416
  * re-render the same set. Same semantics as `excludedRulesOfDir`: a missing line is `[]`
417
417
  * (nothing excluded), and a name this checkout does not ship condemns the whole line to `[]`
418
418
  * — re-admitting a skill is the safe direction, and the flag would refuse the name anyway.
419
+ *
420
+ * A name that only got OLDER is not unknown: an alias reads back as its target and a retired
421
+ * name drops out (`resolveSkillNames`), so a skill merge does not quietly re-admit everything
422
+ * else the install left out. Silent unless the caller passes `notify` — status, diff and the
423
+ * console call this on every read; the replays that rebuild an install (`rebuild-all`,
424
+ * `upgrade`, `migrate`) pass it.
419
425
  */
420
- export function excludedSkillsOfDir(d) {
426
+ export function excludedSkillsOfDir(d, notify = null) {
421
427
  return firstCarrier(d, (carrierPath) => {
422
428
  const text = readMaybe(carrierPath);
423
429
  if (text === null) return undefined;
424
430
  const m = EXCLUDED_SKILLS_RE.exec(text);
425
431
  if (!m) return undefined;
426
- const names = m[1].split(',').map((s) => s.trim()).filter(Boolean);
427
- const known = knownSkillIds();
428
- if (!names.every((n) => known.includes(n))) return [];
429
- return [...new Set(names)].sort();
432
+ const { ids, notices, unknown } = resolveSkillNames(m[1].split(',').map((s) => s.trim()).filter(Boolean));
433
+ if (unknown.length) return [];
434
+ if (notify) for (const n of notices) notify(n);
435
+ return ids;
430
436
  }, []);
431
437
  }
432
438
 
package/js/hosts/mcp.mjs CHANGED
@@ -5,7 +5,7 @@
5
5
  * at "~90 new, already in js/: `_mcp_load`/`readJsonc` (P6c), `settings.mjs`'s atomic write".
6
6
  * Measured against `js/`: `readJsonc` had crossed and NOTHING ELSE HAD. `js/web/api.mjs`
7
7
  * carries a five-line `mcpLoad` that is the no-host, comment-tolerant reader P6c needed for
8
- * `wiki.jsonc`, which is one arm of one of these thirteen functions. `_MCP_PRESETS`,
8
+ * `geneseed-wiki.jsonc`, which is one arm of one of these thirteen functions. `_MCP_PRESETS`,
9
9
  * `_mcp_servers_key`, `_mcp_preset_block`, `_mcp_apply`, `_mcp_state`, `_mcp_set_enabled`,
10
10
  * `_mcp_load`'s host fork, `_mcp_commented`, `_mcp_save`, `_mcp_config_for`,
11
11
  * `_mcp_install_targets`, `_mcp_known_names` and `_mcp_meta` are 168 lines of Python, all
@@ -32,6 +32,7 @@ import path from 'node:path';
32
32
  import { writeText, copyFile, readText } from '../lib/fs.mjs';
33
33
  import { jsonDumps, parseJson, formatValue, formatRepr, isTruthy, isDict } from '../lib/json.mjs';
34
34
  import { relPosix } from '../lib/text.mjs';
35
+ import { aliasesOf } from '../build/source.mjs';
35
36
 
36
37
  /** What a folder skill writes where it needs its own directory — see `writeNativeLayer`. */
37
38
  const SKILL_DIR_PLACEHOLDER = '<this-skill-directory>';
@@ -253,6 +254,25 @@ export function stripSkillBodyLinks(body) {
253
254
  return body.replace(SKILL_BODY_LINK_RE, '$1');
254
255
  }
255
256
 
257
+ /**
258
+ * A first-party skill's SIDE FILES (`src/skills/<stem>/<file>`, beside the flat spec) are
259
+ * linked from the spec as `[…](<stem>/<file>)` — the path that resolves in `src/` and in the
260
+ * flat bundle, where doctor's dead-link scan proves it. A native skill sits one level deeper
261
+ * (`skills/<stem>/SKILL.md`), and an alias or a command copies the body somewhere else again,
262
+ * so there the link becomes a code-span path under `dir`: the placeholder a host that
263
+ * announces the skill's base directory resolves, or the answer for one that does not.
264
+ *
265
+ * A link with an `#anchor` (which `sideFileProblems` accepts) gets the same pointer with the
266
+ * anchor DROPPED: the pointer is a path the agent hands to a file-read tool, and
267
+ * `deep.md#two` names no file. Left unmatched, `stripSkillBodyLinks` would reduce the link to
268
+ * its label and the agent would never learn where the file is.
269
+ */
270
+ export function pointSideFiles(body, stem, dir) {
271
+ // A function, not a `$1` string: `dir` is a real path and may itself hold a `$`.
272
+ return body.replace(new RegExp(`\\[[^\\]]+\\]\\(${stem}/([^)\\s#]+)(?:#[^)\\s]*)?\\)`, 'g'),
273
+ (_m, f) => `\`${dir}/${f}\``);
274
+ }
275
+
256
276
  /** `_build_emit._agent_color_map` — validated, always a valid OpenCode slot. */
257
277
  function agentColorMap(theme) {
258
278
  let raw = null;
@@ -479,8 +499,8 @@ export function claimer(oldOwned, cfg, manifestExisted = true) {
479
499
  * `<this-skill-directory>/…`; Claude Code resolves that because it announces each skill's
480
500
  * base directory on load, while on Bob the model got the text with a hole in it and went
481
501
  * hunting through the filesystem. With the option, every rendered `.md` of a vendored folder
482
- * has the placeholder replaced by the answer. Only vendored folders carry the placeholder — a
483
- * flat skill has no siblings to point at.
502
+ * has the placeholder replaced by the answer. A flat skill with side files points at them
503
+ * through the same option (`pointSideFiles`), placeholder when it is absent.
484
504
  *
485
505
  * `opts.claim` — a `claimer` closure the caller shares with its other writers; built from
486
506
  * `oldOwned`/`cfg`/`manifestExisted` when absent.
@@ -498,13 +518,15 @@ export function claimer(oldOwned, cfg, manifestExisted = true) {
498
518
  */
499
519
  export function writeNativeLayer(items, agentsDir, skillsDir, overrides = null, {
500
520
  host = 'opencode', oldOwned = null, cfg = null, manifestExisted = true,
501
- theme = null, src, skillDirOf = null, claim = claimer(oldOwned, cfg, manifestExisted),
521
+ theme = null, src = undefined, skillDirOf = null, claim = claimer(oldOwned, cfg, manifestExisted),
502
522
  } = {}) {
503
523
  const ov = overrides || {};
504
524
  if (host === 'claude') warnClaudeIgnored(ov);
505
525
  let nAgents = 0;
506
526
  let nSkills = 0;
507
527
  const written = [];
528
+ const sideFiles = []; // [stem, dest, text], written after the loop
529
+ const claimedSkills = new Set(); // stems whose own SKILL.md claim held
508
530
 
509
531
  const write = (dest, text) => {
510
532
  mkdirSync(path.dirname(dest), { recursive: true });
@@ -534,6 +556,15 @@ export function writeNativeLayer(items, agentsDir, skillsDir, overrides = null,
534
556
  continue;
535
557
  }
536
558
  if (text === null) continue;
559
+ // A first-party side file rides next to its skill's SKILL.md, read only when the body
560
+ // points the agent at it; not a skill, so not counted. `sideFileProblems` holds the
561
+ // layout (a flat spec beside it, one level, `.md`, linked from the body). Held until the
562
+ // loop ends: written only once its skill's own SKILL.md claim held (the alias rule), so a
563
+ // user's file at the target never gets a geneseed side file dropped beside it.
564
+ if (sparts.length === 3 && sparts[0] === 'skills') {
565
+ sideFiles.push([sparts[1], path.join(skillsDir, sparts[1], sparts[2]), text]);
566
+ continue;
567
+ }
537
568
  if (sparts.length !== 2 || !sparts[1].endsWith('.md')) continue;
538
569
  const [folder, fname] = sparts;
539
570
  const targetDir = { agents: agentsDir, skills: skillsDir }[folder];
@@ -567,18 +598,42 @@ export function writeNativeLayer(items, agentsDir, skillsDir, overrides = null,
567
598
  } else {
568
599
  // Skills are BYTE-IDENTICAL across hosts: name + description, body link-stripped.
569
600
  // The user-only key is emitted for every host too — Claude Code and Bob honour it,
570
- // OpenCode ignores an unknown key — so the identity holds.
601
+ // OpenCode ignores an unknown key — so the identity holds. ALIASES are the one
602
+ // exception: OpenCode has no user-only skill, so an alias skill there would land in the
603
+ // model's catalogue as a duplicate; it gets a command instead (`writeAliasCommands`).
571
604
  fm = [`name: ${stem}`, `description: ${jsonDumps(skillDescription(text))}`];
572
605
  if (isUserInvokedOnly(text)) fm.push('disable-model-invocation: true');
573
- body = stripSkillBodyLinks(body);
606
+ body = stripSkillBodyLinks(pointSideFiles(body, stem, skillDirOf ? skillDirOf(stem)
607
+ : SKILL_DIR_PLACEHOLDER));
574
608
  dest = path.join(skillsDir, stem, 'SKILL.md');
575
609
  kind = 'skill';
576
610
  }
577
611
  if (!claim(dest)) continue;
578
612
  write(dest, `---\n${fm.join('\n')}\n---\n\n${body}`);
613
+ if (kind === 'skill') claimedSkills.add(stem);
614
+ // An old name keeps answering `/name`: user-only, so the model's catalogue lists the
615
+ // skill once. Read off the already-filtered items, so excluding the target drops its
616
+ // aliases; through `claim`, so manifest, prune and uninstall own them; not counted.
617
+ // Only once the target's own claim held: a user's file at the target gets no alias
618
+ // pointing at a skill this install never wrote.
619
+ if (kind === 'skill' && host === 'claude') {
620
+ // The host announces the ALIAS's folder as the base directory, so a side-file pointer
621
+ // climbs back to the target's.
622
+ const aliasBody = skillDirOf ? body : stripSkillBodyLinks(
623
+ pointSideFiles(lstripNewlines(text), stem, `${SKILL_DIR_PLACEHOLDER}/../${stem}`));
624
+ for (const alias of aliasesOf(text)) {
625
+ const aliasDest = path.join(skillsDir, alias, 'SKILL.md');
626
+ if (!claim(aliasDest)) continue;
627
+ write(aliasDest, `---\nname: ${alias}\ndescription: ${jsonDumps(`Alias of ${stem}.`)}\n`
628
+ + `disable-model-invocation: true\n---\n\n${aliasBody}`);
629
+ }
630
+ }
579
631
  if (kind === 'agent') nAgents += 1;
580
632
  else nSkills += 1;
581
633
  }
634
+ for (const [stem, dest, text] of sideFiles) {
635
+ if (claimedSkills.has(stem) && claim(dest)) write(dest, stripSkillBodyLinks(text));
636
+ }
582
637
  return { nAgents, nSkills, written };
583
638
  }
584
639