geneseed 3.15.0 → 3.17.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 (75) hide show
  1. package/CHANGELOG.md +35 -0
  2. package/SHIPPED.md +1 -1
  3. package/adapters/opencode/plugins/geneseed-context.js +36 -8
  4. package/adapters/opencode/plugins/geneseed-guard.js +28 -0
  5. package/bin/geneseed.mjs +17 -0
  6. package/docs/concepts/exclusions.md +21 -9
  7. package/docs/concepts/hooks.md +1 -0
  8. package/docs/concepts/loop-notes.md +3 -0
  9. package/docs/concepts/loops.md +11 -0
  10. package/docs/guides/headless.md +1 -1
  11. package/docs/guides/install-scripted.md +1 -1
  12. package/docs/guides/project-context.md +3 -3
  13. package/docs/guides/read-non-markdown.md +1 -1
  14. package/docs/reference/plugin-context.md +2 -2
  15. package/docs/reference/plugin-guard.md +1 -1
  16. package/docs/token-footprint.md +10 -7
  17. package/harness.config.json +1 -1
  18. package/js/build/args.mjs +34 -3
  19. package/js/build/driver.mjs +8 -7
  20. package/js/build/generate.mjs +11 -5
  21. package/js/build/render.mjs +33 -3
  22. package/js/build/source.mjs +37 -1
  23. package/js/cli-table.json +15 -0
  24. package/js/hosts/hooks-context.mjs +112 -56
  25. package/js/hosts/hooks.mjs +34 -1
  26. package/js/hosts/installs.mjs +25 -2
  27. package/js/hosts/opencode.mjs +2 -0
  28. package/js/hosts/settings.mjs +2 -1
  29. package/js/inspect/checks-constitution.mjs +2 -2
  30. package/js/inspect/diff.mjs +4 -1
  31. package/js/inspect/status.mjs +2 -1
  32. package/js/inspect/validate.mjs +3 -1
  33. package/js/loop/graph.mjs +2 -1
  34. package/js/loop/state.mjs +30 -1
  35. package/js/maintain/setup.mjs +11 -4
  36. package/js/maintain/update.mjs +1 -1
  37. package/js/web/actions.mjs +5 -3
  38. package/package.json +1 -1
  39. package/src/AGENT.md.tmpl +1 -1
  40. package/src/agents/docs.md +5 -0
  41. package/src/bricks/apply.md +3 -0
  42. package/src/bricks/docs-update.md +2 -0
  43. package/src/bricks/test.md +17 -7
  44. package/src/doctrines/process.md +9 -5
  45. package/src/loops/api-endpoint.json +1 -1
  46. package/src/loops/bugfix.json +1 -1
  47. package/src/loops/ci-repair.json +1 -1
  48. package/src/loops/deps-upgrade.json +1 -1
  49. package/src/loops/enforce-architecture-rule.json +1 -1
  50. package/src/loops/feature.json +1 -1
  51. package/src/loops/fix-flaky-tests.json +1 -1
  52. package/src/loops/legacy-refactor.json +1 -1
  53. package/src/loops/legacy-tests.json +1 -1
  54. package/src/loops/refactor.json +1 -1
  55. package/src/loops/remove-dead-code.json +1 -1
  56. package/src/loops/spec-first-feature.json +1 -1
  57. package/src/loops/tdd.json +1 -1
  58. package/src/loops/update-contract.json +1 -1
  59. package/src/skills/document-project.md +91 -22
  60. package/web/dist/assets/{Activity-kpWxd70F.js → Activity-DonKN1pi.js} +1 -1
  61. package/web/dist/assets/{ActivityDetail-Ccr942VL.js → ActivityDetail-e47a3cdE.js} +1 -1
  62. package/web/dist/assets/{Dashboard-D1lE7jcg.js → Dashboard-Cr7i0PsO.js} +2 -2
  63. package/web/dist/assets/{Docs-BHyaggFy.js → Docs-Lne3nuzD.js} +1 -1
  64. package/web/dist/assets/{GreenhouseView-Sc_UDMOE.js → GreenhouseView-4CbIuTpl.js} +1 -1
  65. package/web/dist/assets/{Installs-Gw-pJ3Zi.js → Installs-CIzbV1Ks.js} +1 -1
  66. package/web/dist/assets/{JournalView-_gxpsxXi.js → JournalView-BlBfuvzj.js} +1 -1
  67. package/web/dist/assets/{Laws-S9fq1r9T.js → Laws-B-pjcjHr.js} +1 -1
  68. package/web/dist/assets/{Library-MBxadRzn.js → Library-x8Nzg6yq.js} +1 -1
  69. package/web/dist/assets/{Loops-Da_JhlzM.js → Loops-DIOxW1xH.js} +1 -1
  70. package/web/dist/assets/{OperatorHudView--TkvDybH.js → OperatorHudView-D7S7z7CJ.js} +1 -1
  71. package/web/dist/assets/{Personal-CTXKHVbV.js → Personal-Ws3v35AB.js} +1 -1
  72. package/web/dist/assets/{Tabs-B0cdf8k6.js → Tabs-6Z1eziAq.js} +1 -1
  73. package/web/dist/assets/{drawable-BbWQxe82.js → drawable-ATNeYKbT.js} +1 -1
  74. package/web/dist/assets/{index-ujKG1ZNf.js → index-CDg5XUhG.js} +2 -2
  75. package/web/dist/index.html +1 -1
@@ -162,10 +162,31 @@ export function substitute(text, theme) {
162
162
  }
163
163
 
164
164
  /** `_build_render._resolve_catalogs`. */
165
- function resolveCatalogs(text, nativeCatalog) {
165
+ function resolveCatalogs(text, nativeCatalog, excludeSkills = []) {
166
166
  // `true`/`false` still mean "both kinds", for the plain-bundle callers that pass a boolean.
167
167
  const nat = nativeCatalog === true ? { skills: true, agents: true } : (nativeCatalog || {});
168
- return text.replace(CATALOG_BLOCK_RE, (_m, kind, table, pointer) => (nat[kind] ? pointer : table));
168
+ return text.replace(CATALOG_BLOCK_RE, (_m, kind, table, pointer) => (nat[kind] ? pointer
169
+ : kind === 'skills' ? dropExcludedSkillRows(table, excludeSkills) : table));
170
+ }
171
+
172
+ /**
173
+ * The skills catalogue table, minus the rows of `--exclude-skills` — a row naming a skill the
174
+ * install never wrote is a dead link. A folder skill's bullet runs onto indented continuation
175
+ * lines, which go with it. Only the plain bundle reaches this: every host emit replaces the
176
+ * table with the pointer to its own inventory.
177
+ */
178
+ function dropExcludedSkillRows(table, excludeSkills) {
179
+ if (!excludeSkills.length) return table;
180
+ const names = excludeSkills.join('|');
181
+ const gone = new RegExp(`\\{\\{DIR_SKILLS\\}\\}/(?:${names})(?:\\.md|/SKILL\\.md)\\)`);
182
+ const out = [];
183
+ let dropping = false;
184
+ for (const line of table.split('\n')) {
185
+ if (dropping && /^[ \t]+\S/.test(line)) continue;
186
+ dropping = /^(?:\||- )/.test(line) && gone.test(line);
187
+ if (!dropping) out.push(line);
188
+ }
189
+ return out.join('\n');
169
190
  }
170
191
 
171
192
  /**
@@ -247,7 +268,7 @@ export function renderFile(cfg, filePath, theme, footprint = 'full', lawsPrefix
247
268
  return inner;
248
269
  });
249
270
 
250
- text = resolveCatalogs(text, nativeCatalog);
271
+ text = resolveCatalogs(text, nativeCatalog, cfg.excludeSkills ?? []);
251
272
  text = resolveLean(text, footprint);
252
273
  return substitute(text, theme);
253
274
  }
@@ -414,6 +435,11 @@ export function effectiveTheme(cfg, themeName, { footprint = 'full', lawsPrefix
414
435
  theme.EXCLUDED_RULES_LINE = dropped.length
415
436
  ? `\nExcluded rules: ${dropped.map((id) => id.replace('.', ' ')).join(', ')}`
416
437
  : '';
438
+ // The skills twin, under the same contract: written only when something is excluded, so a
439
+ // default build stays byte-identical, and read back by `excludedSkillsOfDir`.
440
+ const skipped = [...(cfg.excludeSkills ?? [])].sort();
441
+ // It fills a template line of its own, so its value carries the blank line around it.
442
+ theme.EXCLUDED_SKILLS_LINE = skipped.length ? `\nExcluded skills: ${skipped.join(', ')}\n` : '';
417
443
  return theme;
418
444
  }
419
445
 
@@ -460,8 +486,12 @@ export function renderAll(cfg, themeName, {
460
486
  } = {}) {
461
487
  const theme = effectiveTheme(cfg, themeName, { footprint, lawsPrefix });
462
488
  const items = [];
489
+ const skipped = new Set(cfg.excludeSkills ?? []);
463
490
  for (const file of sortedSourceFiles(cfg.src)) {
464
491
  const rel = path.relative(cfg.src, file);
492
+ // `--exclude-skills`: the one filter every host's emit and the plain bundle read through.
493
+ const sp = rel.split(path.sep);
494
+ if (sp[0] === 'skills' && sp.length >= 2 && skipped.has(sp[1].replace(/\.md$/, ''))) continue;
465
495
  const outRel = destRel(themedRel(rel, theme)).split(path.sep).join('/'); // as_posix()
466
496
  // The on-disk `laws/`, `ontology/` and `doctrines/` are the "complete text" the two lean
467
497
  // pointers promise — §1's and the Doctrines section's. They render at full whatever the
@@ -236,6 +236,40 @@ export function knownRuleIds() {
236
236
  return ruleCanon(SRC).rules.filter((r) => r.kind === 'DOCTRINE').map((r) => `${r.pack}.${r.n}`);
237
237
  }
238
238
 
239
+ /**
240
+ * Every skill this checkout ships: a flat `skills/<name>.md` (authoring `_` files are not
241
+ * skills) or a vendored `skills/<name>/` folder. The one enumerator `--exclude-skills` closes
242
+ * against, for the reason `knownRuleIds` is one.
243
+ */
244
+ export function knownSkillIds() {
245
+ const dir = path.join(SRC, 'skills');
246
+ return readdirSync(dir, { withFileTypes: true })
247
+ .filter((e) => !e.name.startsWith('_') && (e.isDirectory() || e.name.endsWith('.md')))
248
+ .map((e) => e.name.replace(/\.md$/, '')).sort();
249
+ }
250
+
251
+ /**
252
+ * The skills the constitution or another skill LINKS to by path (`{{DIR_SKILLS}}/<name>`),
253
+ * outside the §4 catalogue table. They cannot be excluded: the link would ship dead. Scanned,
254
+ * not listed, so a new link protects its target the day it is written.
255
+ */
256
+ export function linkedSkillIds() {
257
+ const linked = new Set();
258
+ const walk = (d) => {
259
+ for (const e of readdirSync(d, { withFileTypes: true })) {
260
+ const f = path.join(d, e.name);
261
+ if (e.isDirectory()) walk(f);
262
+ else if (/\.(md|tmpl)$/.test(e.name)) {
263
+ const text = readFileSync(f, 'utf8')
264
+ .replace(/<!-- CATALOG:begin skills -->[\s\S]*?<!-- CATALOG:end -->/, '');
265
+ for (const m of text.matchAll(/\{\{DIR_SKILLS\}\}\/([a-z0-9-]+)/g)) linked.add(m[1]);
266
+ }
267
+ }
268
+ };
269
+ walk(SRC);
270
+ return [...linked].sort();
271
+ }
272
+
239
273
  /**
240
274
  * `_build_core.js_cfg()`, originated rather than received.
241
275
  *
@@ -250,7 +284,7 @@ export function knownRuleIds() {
250
284
  */
251
285
  export function makeCfg({
252
286
  posture = 'peer', mode = 'direct', trust = DEFAULT_PRESET, doctrines = PACK_ORDER,
253
- excludeRules = [],
287
+ excludeRules = [], excludeSkills = [],
254
288
  } = {}) {
255
289
  return {
256
290
  root: ROOT,
@@ -276,5 +310,7 @@ export function makeCfg({
276
310
  // is already absent, and dropping the exclusion would silently re-admit the rule the day
277
311
  // the pack came back.
278
312
  excludeRules: Array.isArray(excludeRules) ? [...excludeRules] : [],
313
+ // Skill names left out of this install — same "unspecified takes nothing away" default.
314
+ excludeSkills: Array.isArray(excludeSkills) ? [...excludeSkills] : [],
279
315
  };
280
316
  }
package/js/cli-table.json CHANGED
@@ -178,6 +178,21 @@
178
178
  "type": null,
179
179
  "hidden": false
180
180
  },
181
+ {
182
+ "names": [
183
+ "--exclude-skills"
184
+ ],
185
+ "dest": "exclude_skills",
186
+ "metavar": null,
187
+ "help": "",
188
+ "choices": null,
189
+ "default": null,
190
+ "required": false,
191
+ "nargs": null,
192
+ "is_flag": false,
193
+ "type": null,
194
+ "hidden": false
195
+ },
181
196
  {
182
197
  "names": [
183
198
  "--out"
@@ -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 by default
85
+ // when a project has no docs yet, so a loader blind to `.adoc` would hide every doc it wrote.
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']);
@@ -96,11 +102,22 @@ const NATIVE_ROOT = {
96
102
  openclaude: ['AGENTS.md', 'CLAUDE.md'],
97
103
  };
98
104
 
99
- // Eager injection budget: a root README of 40k chars was going out whole, every session and
100
- // every compaction, and cost more than the harness itself. Per-file cut at the last line
101
- // break under the cap; files that would push the total over the budget are listed lazy.
102
- const EAGER_FILE_BYTES = 16 * 1024;
103
- const EAGER_TOTAL_BYTES = 48 * 1024;
105
+ // The whole payload's budget, in characters. Claude Code does not hand a hook's stdout to the
106
+ // model past roughly 10k characters: it saves the output to a file and the model sees a 2 KB
107
+ // preview, so everything after the cut is lost without a word. Observed 2026-10 (a 25.6 KB and
108
+ // a 16.5 KB SessionStart output, both persisted); the exact threshold is not documented, hence
109
+ // the margin. The old 48 KB eager budget never bound — the host's cap always cut first.
110
+ // Priority inside the budget: session files (the user's own), then eager docs (whole, or
111
+ // listed lazy), then the lazy listing, folded per directory and cut with a count.
112
+ const OUTPUT_BUDGET = 9000;
113
+ // Kept back from the eager docs so the lazy listing always has room to say what exists.
114
+ const LAZY_RESERVE = 1500;
115
+ // A directory with this many undescribed lazy files is listed once, with its count.
116
+ const LAZY_FOLD_AT = 3;
117
+ // What a line costs against the budget: its characters plus a terminator counted as two,
118
+ // because stdout goes out as \r\n on Windows — a 144-line listing overran by 137 before this.
119
+ // Multi-line text pays one more per inner line break, for the same reason.
120
+ const cost = (s) => s.length + (s.match(/\n/g) || []).length + 2;
104
121
 
105
122
  // The files the root instruction file names for session start, relative to the HARNESS dir
106
123
  // (`--root`: `.claude/`, `.bob/`, a global config dir) — not to the repo root discovery walks,
@@ -171,9 +188,8 @@ export function globalHookStandingDown(hookRoot, cwd) {
171
188
 
172
189
  /** `_disp` — relative to the repo root when it sits under it, else verbatim. */
173
190
  function disp(pathStr, root) {
174
- // `os.path.relpath` raises ValueError across drives and the Python falls back to the
175
- // path as given; `path.relative` silently returns an absolute path instead, which would
176
- // print a DIFFERENT string rather than the same one.
191
+ // A path on another drive has no relative form, so it is printed as given. `path.relative`
192
+ // would silently return an absolute path instead — a different string, not the same one.
177
193
  if (path.parse(path.resolve(pathStr)).root.toLowerCase()
178
194
  !== path.parse(path.resolve(root)).root.toLowerCase()) {
179
195
  return pathStr;
@@ -182,18 +198,18 @@ function disp(pathStr, root) {
182
198
  }
183
199
 
184
200
 
185
- function rglobMd(dir, acc = []) {
201
+ function rglobDocs(dir, acc = []) {
186
202
  for (const name of listDir(dir)) {
187
203
  const full = path.join(dir, name);
188
- if (isDir(full)) rglobMd(full, acc);
189
- else if (isFile(full) && path.extname(name).toLowerCase() === '.md') acc.push(full);
204
+ if (isDir(full)) rglobDocs(full, acc);
205
+ else if (isFile(full) && isDoc(name)) acc.push(full);
190
206
  }
191
207
  return acc;
192
208
  }
193
209
 
194
210
  /**
195
211
  * `_discover_context` — the no-manifest path, mirroring the OpenCode context plugin.
196
- * Root entry docs are eager; other root markdown, the doc trees and monorepo package
212
+ * Root entry docs are eager; other root docs (.md or .adoc), the doc trees and monorepo package
197
213
  * READMEs are lazy.
198
214
  */
199
215
  export function discoverContext(root, host = HOST) {
@@ -206,12 +222,12 @@ export function discoverContext(root, host = HOST) {
206
222
  const name = path.basename(full);
207
223
  if (native.includes(name)) continue;
208
224
  if (EAGER_ROOT.includes(name)) eager.set(full, null);
209
- else if (path.extname(name).toLowerCase() === '.md') lazy.set(full, null);
225
+ else if (isDoc(name)) lazy.set(full, null);
210
226
  }
211
227
  for (const d of LAZY_DIRS) {
212
228
  const sub = path.join(root, d);
213
229
  if (!isDir(sub)) continue;
214
- for (const md of sortPaths(rglobMd(sub))) {
230
+ for (const md of sortPaths(rglobDocs(sub))) {
215
231
  const parts = path.relative(root, md).split(/[\\/]/);
216
232
  if (parts.some((part) => EXCLUDE_DIRS.has(part))) continue;
217
233
  if (!eager.has(md) && !lazy.has(md)) lazy.set(md, null);
@@ -222,8 +238,10 @@ export function discoverContext(root, host = HOST) {
222
238
  if (!isDir(base)) continue;
223
239
  for (const pkg of sortPaths(listDir(base).map((n) => path.join(base, n)))) {
224
240
  if (!isDir(pkg) || EXCLUDE_DIRS.has(path.basename(pkg))) continue;
225
- const readme = path.join(pkg, 'README.md');
226
- if (isFile(readme) && !eager.has(readme) && !lazy.has(readme)) lazy.set(readme, null);
241
+ for (const name of ['README.md', 'README.adoc']) {
242
+ const readme = path.join(pkg, name);
243
+ if (isFile(readme) && !eager.has(readme) && !lazy.has(readme)) lazy.set(readme, null);
244
+ }
227
245
  }
228
246
  }
229
247
  return [
@@ -270,19 +288,18 @@ export function resolveContextSets(root, hookRoot = null) {
270
288
  let extend;
271
289
  try {
272
290
  let data = JSON.parse(raw);
273
- // Valid JSON of the wrong SHAPE, guarded on both sides — see the Python original. JS
274
- // would not have thrown here (`[].context` is merely undefined), which is exactly why
275
- // the guard is written out rather than left to fall out of the language: without it,
276
- // the two CLIs disagree about a file a user really does hand-edit.
291
+ // Valid JSON of the wrong SHAPE (null, a number, an array) is an empty manifest, not a
292
+ // syntax error. The guard is written out rather than left to fall out of the language:
293
+ // without it, `null.context` throws into the catch below and a file a user really does
294
+ // hand-edit is reported as invalid JSON when it is not.
277
295
  data = data && typeof data === 'object' && !Array.isArray(data) ? data : {};
278
296
  entries = data.context || [];
279
297
  if (!Array.isArray(entries)) entries = [];
280
298
  extend = Boolean(data.extend);
281
299
  } catch {
282
- // See the Python original for why the decoder's own message is not quoted: the two
283
- // engines disagree on the wording and on the offset, and V8 supplies no offset at all
284
- // for the commonest shapes. Reporting the file is what the user needs; reporting
285
- // WHICH decoder found it is what makes two implementations impossible.
300
+ // The decoder's own message is not quoted: its wording and offset are engine details,
301
+ // and V8 supplies no offset at all for the commonest shapes. Reporting the file is what
302
+ // the user needs.
286
303
  err(`[context] ${manifest} is not valid JSON — no context was loaded `
287
304
  + '(fix the syntax, then re-run).\n');
288
305
  return [[], [], String(manifest)];
@@ -331,14 +348,58 @@ export function resolveContextSets(root, hookRoot = null) {
331
348
  }
332
349
 
333
350
  /**
334
- * Cut `text` at the last line break under EAGER_FILE_BYTES and say where the rest is — one rule
335
- * for a session file and an eager doc alike. `where` finishes the sentence.
351
+ * Cut a session file at the last line break that fits `room` and say where the rest is. Only
352
+ * session files are cut: they are the user's own rules, and half of them beats none. An eager
353
+ * doc that does not fit is listed lazy instead — a README cut mid-section misleads.
336
354
  */
337
- function capEager(text, where) {
338
- if (text.length <= EAGER_FILE_BYTES) return text;
339
- const nl = text.lastIndexOf('\n', EAGER_FILE_BYTES);
340
- return `${text.slice(0, nl > 0 ? nl : EAGER_FILE_BYTES)}\n`
341
- + `[context] truncated at ${EAGER_FILE_BYTES / 1024} KB — read ${where}`;
355
+ function capToRoom(text, room, where) {
356
+ if (cost(text) <= room) return text;
357
+ const marker = `\n[context] truncated (session budget) — read ${where}`;
358
+ let cut = text.length;
359
+ do cut = text.lastIndexOf('\n', cut - 1);
360
+ while (cut > 0 && cost(text.slice(0, cut) + marker) > room);
361
+ return `${cut > 0 ? text.slice(0, cut) : ''}${marker}`;
362
+ }
363
+
364
+ /**
365
+ * The lazy listing: a described entry gets its own line; undescribed entries sharing a parent
366
+ * directory are folded into one `dir/ — N docs` line once there are LAZY_FOLD_AT of them (a
367
+ * `docs/` tree of 186 files was 186 lines). Lines past `room` become one count line.
368
+ */
369
+ function lazyLines(entries, root, room) {
370
+ const byDir = new Map();
371
+ for (const e of entries) {
372
+ const dir = path.dirname(disp(e.path, root));
373
+ // Root files stay listed one by one: there are few, and each name says what it is.
374
+ if (e.description || e.over || dir === '.') continue;
375
+ byDir.set(dir, (byDir.get(dir) || 0) + 1);
376
+ }
377
+ const lines = [];
378
+ const folded = new Set();
379
+ for (const e of entries) {
380
+ const shown = disp(e.path, root);
381
+ const dir = path.dirname(shown);
382
+ if (!e.description && !e.over && byDir.get(dir) >= LAZY_FOLD_AT) {
383
+ if (folded.has(dir)) continue;
384
+ folded.add(dir);
385
+ lines.push(` - ${dir.replace(/\\/g, '/')}/ — ${byDir.get(dir)} docs`);
386
+ continue;
387
+ }
388
+ const why = e.over ? ' (eager, but over the session budget — read on demand)' : '';
389
+ lines.push(` - ${shown}${e.description ? ` — ${e.description}` : ''}${why}`);
390
+ }
391
+ const kept = [];
392
+ let used = 0;
393
+ for (const [i, l] of lines.entries()) {
394
+ const tail = ` - … ${lines.length - i} more not listed (session budget)`;
395
+ if (used + cost(l) + (i < lines.length - 1 ? cost(tail) : 0) > room) {
396
+ kept.push(tail);
397
+ break;
398
+ }
399
+ kept.push(l);
400
+ used += cost(l);
401
+ }
402
+ return kept;
342
403
  }
343
404
 
344
405
  export function cmdContext(args) {
@@ -379,51 +440,46 @@ export function cmdContext(args) {
379
440
  }
380
441
 
381
442
  const lines = [];
443
+ // `spent` counts every character pushed, headers included: the budget is the host's cap on
444
+ // the whole payload, not on the file bodies alone.
382
445
  let spent = 0;
446
+ const push = (...ls) => { for (const l of ls) { lines.push(l); spent += cost(l); } };
383
447
  if (session.length) {
384
- lines.push('=== SESSION FILES \u2014 your harness files, injected at session start '
448
+ push('=== SESSION FILES \u2014 your harness files, injected at session start '
385
449
  + '(untouched seeds skipped) ===', '');
386
450
  for (const s of session) {
387
- const text = capEager(s.text, `${s.abs} on demand`);
388
- spent += text.length;
389
- lines.push(`----- ${s.rel} -----`, text, '');
451
+ const head = `----- ${s.rel} -----`;
452
+ push(head, capToRoom(s.text, OUTPUT_BUDGET - LAZY_RESERVE - spent - cost(head) - cost(''),
453
+ `${s.abs} on demand`), '');
390
454
  }
391
455
  }
392
456
  if (eager.length || lazy.length) {
393
- lines.push(`=== PROJECT CONTEXT \u2014 binding for this repo (via ${source}) ===`, '');
457
+ push(`=== PROJECT CONTEXT \u2014 binding for this repo (via ${source}) ===`, '');
394
458
  }
395
459
  const demoted = [];
396
460
  for (const entry of eager) {
397
461
  const p = entry.path === undefined ? '' : entry.path;
398
462
  const desc = entry.description === undefined ? '' : entry.description;
399
463
  const target = path.isAbsolute(p) ? p : path.join(root, p);
464
+ const head = `----- ${disp(p, root)}${desc ? ` \u2014 ${desc}` : ''} -----`;
400
465
  let text;
401
466
  try {
402
467
  text = readText(target).replace(/\n+$/, '');
403
468
  } catch (e) {
404
- lines.push(`----- ${disp(p, root)}${desc ? ` \u2014 ${desc}` : ''} -----`,
405
- `[context] MISSING eager file: ${asOsError(e, target)}`, '');
469
+ push(head, `[context] MISSING eager file: ${asOsError(e, target)}`, '');
406
470
  continue;
407
471
  }
408
- text = capEager(text, `${disp(p, root)} on demand for the rest`);
409
- if (spent + text.length > EAGER_TOTAL_BYTES) {
410
- demoted.push(entry);
472
+ if (spent + cost(head) + cost(text) + cost('') > OUTPUT_BUDGET - LAZY_RESERVE) {
473
+ demoted.push({ ...entry, over: true });
411
474
  continue;
412
475
  }
413
- spent += text.length;
414
- lines.push(`----- ${disp(p, root)}${desc ? ` \u2014 ${desc}` : ''} -----`, text, '');
476
+ push(head, text, '');
415
477
  }
416
478
 
417
479
  if (lazy.length || demoted.length) {
418
- lines.push('--- Lazy entries (load only when the task needs them) ---');
419
- for (const entry of [...lazy, ...demoted]) {
420
- const p = entry.path === undefined ? '' : entry.path;
421
- const desc = entry.description === undefined ? '' : entry.description;
422
- const over = demoted.includes(entry)
423
- ? ` (eager, but over the ${EAGER_TOTAL_BYTES / 1024} KB session budget \u2014 read on demand)` : '';
424
- lines.push(` - ${disp(p, root)}${desc ? ` \u2014 ${desc}` : ''}${over}`);
425
- }
426
- lines.push('');
480
+ const title = '--- Lazy entries (load only when the task needs them) ---';
481
+ push(title);
482
+ push(...lazyLines([...lazy, ...demoted], root, OUTPUT_BUDGET - spent - cost('')), '');
427
483
  }
428
484
 
429
485
  // Claude Code takes a SessionStart hook's plain stdout as context; Bob reads it the same way.
@@ -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 `
@@ -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 } from '../build/source.mjs';
38
+ import { CONFIG, PACK_ORDER, THEMES, discoverNames, knownSkillIds } 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,6 +408,28 @@ export function excludedRulesOfDir(d) {
408
408
  }, []);
409
409
  }
410
410
 
411
+ /** `Excluded skills: bruno-test-writer, daydream` — written only when something is excluded. */
412
+ const EXCLUDED_SKILLS_RE = /^Excluded skills:[ \t]*(.+?)[ \t]*$/m;
413
+
414
+ /**
415
+ * The skills a DEPLOYED install left out, read back so `upgrade`, `rebuild-all` and `diff`
416
+ * re-render the same set. Same semantics as `excludedRulesOfDir`: a missing line is `[]`
417
+ * (nothing excluded), and a name this checkout does not ship condemns the whole line to `[]`
418
+ * — re-admitting a skill is the safe direction, and the flag would refuse the name anyway.
419
+ */
420
+ export function excludedSkillsOfDir(d) {
421
+ return firstCarrier(d, (carrierPath) => {
422
+ const text = readMaybe(carrierPath);
423
+ if (text === null) return undefined;
424
+ const m = EXCLUDED_SKILLS_RE.exec(text);
425
+ 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();
430
+ }, []);
431
+ }
432
+
411
433
  // ---- what host a deployed dir belongs to (`_harness_mcp`) ---------------------------------
412
434
 
413
435
  /** `_harness_mcp._claude_read_manifest`. */
@@ -554,7 +576,7 @@ export function installTargets() {
554
576
  export function installedDefaults() {
555
577
  const found = {
556
578
  theme: null, posture: null, mode: null, emit: null, footprint: null, doctrines: null,
557
- excludeRules: null,
579
+ excludeRules: null, excludeSkills: null,
558
580
  };
559
581
  const candidates = [];
560
582
  for (const { host, configDir } of HOSTS) {
@@ -596,6 +618,7 @@ export function installedDefaults() {
596
618
  // pre-selects. The cost of that choice is that "excludes nothing" and "no carrier" are
597
619
  // indistinguishable here, which is exactly what the marker itself already says.
598
620
  if (!found.excludeRules?.length) found.excludeRules = excludedRulesOfDir(base);
621
+ if (!found.excludeSkills?.length) found.excludeSkills = excludedSkillsOfDir(base);
599
622
  }
600
623
  return found;
601
624
  }
@@ -309,6 +309,8 @@ export function writeCommandLayer(cfg, items, commandDir, claim = () => true) {
309
309
  }
310
310
  const written = [];
311
311
  for (const [name, skill] of Object.entries(COMMAND_SET)) {
312
+ // A command runs a skill; `--exclude-skills` took that skill out, so the command goes too.
313
+ if ((cfg.excludeSkills ?? []).includes(skill)) continue;
312
314
  const text = byName.get(skill);
313
315
  if (text === undefined) {
314
316
  throw new Error(`COMMAND_SET: /${name} runs skill '${skill}', which src/skills/ does not have`);
@@ -513,7 +513,8 @@ export function claudeHookGroups(cfg, hookOpts, doctrines = null, excluded = [],
513
513
  SessionStart: [{ hooks: [{ type: 'command', command: `${run} context --root "${cfg}"${b} || exit 0` }] }],
514
514
  // One group, both gates: process 5's consent is a stderr line here (Bob has no ask
515
515
  // 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.
516
+ // `tool-gate` only ever EXITS 2 for Laws I and IV, which every build carries, and for a
517
+ // write under a project's own `.geneseed/protected-checks.txt` — the project opted in.
517
518
  PreToolUse: [{ hooks: [{ type: 'command', command: `${run} tool-gate --root "${cfg}"${b}` }] }],
518
519
  Stop: [{ hooks: [{ type: 'command', command: `${run} learn ${mem} || exit 0` }] }],
519
520
  };
@@ -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
  /**
@@ -44,7 +44,8 @@ import {
44
44
  GLOBAL_MANIFEST, VERSION_MARKER, expanduser, opencodeConfigDir, resolvePath,
45
45
  } from '../hosts/hosts.mjs';
46
46
  import {
47
- EMIT_HOST_SCOPE, defaultTheme, doctrinesForBuild, excludedRulesOfDir, footprintOfDir, modeOfDir, postureOfDir,
47
+ EMIT_HOST_SCOPE, defaultTheme, doctrinesForBuild, excludedRulesOfDir, excludedSkillsOfDir,
48
+ footprintOfDir, modeOfDir, postureOfDir,
48
49
  trustOfDir,
49
50
  readJsonMaybe, readMaybe, themeOfDir,
50
51
  } from '../hosts/installs.mjs';
@@ -180,6 +181,8 @@ export function diffCollect({ target = null, theme = null, emit = null, footprin
180
181
  // The excluded rules, or an install built `--exclude-rules "process 5"` reads its
181
182
  // carrier as drift: `expected` would re-state the rule the deployment took away.
182
183
  excludeRules: excludedRulesOfDir(dir),
184
+ // The excluded skills, or every skill the install left out reads as deleted.
185
+ excludeSkills: excludedSkillsOfDir(dir),
183
186
  }));
184
187
  // The one place a render depends on WHERE it is installed: a global Bob emit writes each
185
188
  // folder skill's absolute directory into its SKILL.md (see `writeNativeLayer`), so the
@@ -370,7 +370,8 @@ export function statusLines(d, color = false) {
370
370
  const packs = p.doctrines.length ? p.doctrines.join(',') : 'no packs';
371
371
  const excl = p.excludeRules?.length ? `excluding ${p.excludeRules.join(',')}` : 'no rules excluded';
372
372
  rows.push([`${p.host}:${p.scope}`, [p.state, p.theme, p.footprint, `${p.posture}/${p.mode}/${p.trust}`,
373
- packs, excl].join(` ${DOT} `) + where]);
373
+ packs, excl, ...(p.excludeSkills?.length ? [`skills off: ${p.excludeSkills.join(',')}`] : [])]
374
+ .join(` ${DOT} `) + where]);
374
375
  }
375
376
 
376
377
  const labelW = Math.max(...rows.map(([k]) => codePointLength(k)));
@@ -32,7 +32,8 @@ import { isDir, isFile, rglob, withTempDir, within } from './scan.mjs';
32
32
  export const VALIDATE_USAGE = [
33
33
  'usage: geneseed validate [-h] [--theme THEME] [--emit EMIT] [--out OUT] [--root ROOT]',
34
34
  ' [--footprint {lean,full}] [--posture POSTURE] [--mode MODE]',
35
- ' [--trust TRUST] [--doctrines DOCTRINES] [--exclude-rules RULES] [-v]',
35
+ ' [--trust TRUST] [--doctrines DOCTRINES] [--exclude-rules RULES]',
36
+ ' [--exclude-skills SKILLS] [-v]',
36
37
  '',
37
38
  "Render and emit into a throwaway sandbox, run the doctor's checks, and write nothing real.",
38
39
  "Takes the generator's render flags (see geneseed-build --help) except --sync-themes and",
@@ -127,6 +128,7 @@ export function cmdValidate(args) {
127
128
  const opts = {
128
129
  theme: args.theme, footprint: args.footprint, posture: args.posture, mode: args.mode,
129
130
  trust: args.trust, doctrines: args.doctrines, excludeRules: args.excludeRules,
131
+ excludeSkills: args.excludeSkills,
130
132
  };
131
133
  let scanDirs;
132
134
  try {