geneseed 3.0.0 → 3.1.1

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 (50) hide show
  1. package/CHANGELOG.md +90 -0
  2. package/DESIGN.md +16 -5
  3. package/README.md +1 -1
  4. package/SETUP.md +29 -2
  5. package/SHIPPED.md +1 -1
  6. package/bin/geneseed.mjs +51 -2
  7. package/docs/extending.md +2 -2
  8. package/docs/web/rules.md +2 -0
  9. package/docs/web/setup-choices.md +13 -1
  10. package/harness.config.json +1 -1
  11. package/js/checkout.mjs +43 -2
  12. package/js/doctor.mjs +39 -1
  13. package/js/emit.mjs +6 -5
  14. package/js/generate.mjs +18 -3
  15. package/js/installs.mjs +50 -0
  16. package/js/inventory.mjs +14 -3
  17. package/js/render.mjs +59 -2
  18. package/js/settings.mjs +31 -14
  19. package/js/setup.mjs +15 -5
  20. package/js/uninstall.mjs +7 -2
  21. package/js/web/actions.mjs +41 -5
  22. package/js/web/api.mjs +23 -8
  23. package/js/web/jobs.mjs +8 -2
  24. package/package.json +1 -1
  25. package/src/AGENT.md.tmpl +1 -1
  26. package/web/dist/assets/{About-C0Mk0igT.js → About-DyK7yRy7.js} +1 -1
  27. package/web/dist/assets/{Activity-C4aJJVXz.js → Activity-D_sYBlW-.js} +1 -1
  28. package/web/dist/assets/{ActivityDetail-DaQ85za0.js → ActivityDetail-BomKcMhy.js} +1 -1
  29. package/web/dist/assets/{Diff-B3tlO2Yj.js → Diff-BSvi1WUg.js} +1 -1
  30. package/web/dist/assets/{Doctor-BNC-5Dmo.js → Doctor-Cz1s-y1n.js} +1 -1
  31. package/web/dist/assets/{Graph-us0T6ZcV.js → Graph-CuntzFJ0.js} +1 -1
  32. package/web/dist/assets/{Harnesses-DAMgyfM_.js → Harnesses-DgRyOviS.js} +1 -1
  33. package/web/dist/assets/Laws-CWk3Mpri.js +3 -0
  34. package/web/dist/assets/{Library-KmwrHA1J.js → Library-DXhTN9ju.js} +1 -1
  35. package/web/dist/assets/{Markdown-0eQOVGSF.js → Markdown-BoiZcdUl.js} +1 -1
  36. package/web/dist/assets/{Profile-ZU7WV6Xl.js → Profile-DfCHlZ03.js} +1 -1
  37. package/web/dist/assets/{Rules-CtDoPhhf.js → Rules-eND7hJv9.js} +1 -1
  38. package/web/dist/assets/{Skills-BhoOxdkD.js → Skills-CDl9opZO.js} +1 -1
  39. package/web/dist/assets/{StatusBadge-CGvYVCwa.js → StatusBadge-DSDtcArX.js} +1 -1
  40. package/web/dist/assets/{Themes-Dr7OOkD7.js → Themes-CoAWuIeM.js} +1 -1
  41. package/web/dist/assets/{activity-B8aq3qtl.js → activity-Dmb0FTAx.js} +1 -1
  42. package/web/dist/assets/index-B-mXquBZ.css +1 -0
  43. package/web/dist/assets/index-CH_Z725C.js +12 -0
  44. package/web/dist/assets/{index-D0lPN_qW.js → index-Dg58ScBe.js} +1 -1
  45. package/web/dist/assets/{index-AhMzkdbI.js → index-jrY4m9jP.js} +1 -1
  46. package/web/dist/index.html +2 -2
  47. package/web/src/pages/Laws.jsx +214 -31
  48. package/web/dist/assets/Laws-rOxegJ75.js +0 -1
  49. package/web/dist/assets/index-2wqPDxuN.css +0 -1
  50. package/web/dist/assets/index-CRftEtBX.js +0 -12
package/CHANGELOG.md CHANGED
@@ -6,6 +6,96 @@ labels in `harness.config.json`. The canonical identity of an *installed* harnes
6
6
  is the source fingerprint in `.geneseed-version` (see `geneseed version`), not this
7
7
  label. For the capability ↔ spec map, see [SHIPPED.md](SHIPPED.md).
8
8
 
9
+ ## [Unreleased]
10
+
11
+ ## [3.1.1] — 2026-08-21
12
+
13
+ ### Added
14
+
15
+ - **Doctrines toggle per RULE, not per pack.** A second build-time axis,
16
+ `geneseed-build --exclude-rules "process 7"`, drops individual rules from the packs that
17
+ survive `--doctrines`. Either spelling of an address is accepted — `process 7` as the
18
+ constitution writes it, `process.7` as the console addresses it — and `none` is the explicit
19
+ empty selection.
20
+
21
+ **This supersedes the pack-level switch 3.1.0 shipped one day earlier.** That control could
22
+ only say *all of Observance or none of it*; the case it could not express — *keep the pack,
23
+ drop one rule* — is the one people actually have. The console's switches are now one per rule,
24
+ still staged locally and still applied in a single rebuild, and there is no pack switch at all:
25
+ the pack axis is DERIVED at Apply time from whichever rules survive. A pack whose every rule is
26
+ off leaves `--doctrines` entirely, so `AGENT.md` never renders a pack header with nothing under
27
+ it.
28
+
29
+ An install records its exclusions in a second marker line, `Excluded rules: process 7`,
30
+ **written only when something is excluded**. That asymmetry is load-bearing rather than tidy: a
31
+ default build is byte-identical to one from before this axis existed — 261 golden cells, nine
32
+ emit modes, both footprints, all unmoved, nothing re-blessed — and the line's absence is a
33
+ complete answer, so `excludedRulesOfDir` reads a missing line as `[]` where `doctrinesOfDir`
34
+ must read one as `null`. Every rebuild path (`upgrade`, `rebuild-all`, the console's Build and
35
+ Deploy) reads the line back and preserves it, or an upgrade would silently hand back every rule
36
+ its owner switched off.
37
+
38
+ ⚠ **The consent gate is re-keyed from the pack to the rule.** `process 5` carries commit/push
39
+ consent, and the `git-gate` hook plus the `git commit*` / `git push*` permission are now wired
40
+ on that rule rather than on its pack — so excluding it alone unwires them, and excluding
41
+ `process 7` beside it does not. Prompt and boundary are never allowed to disagree; `doctor`
42
+ gained a gate that fails if `js/settings.mjs`'s hardcoded `process.5` ever stops naming the
43
+ consent rule. `rm -rf` and force-push refusals remain Rule IV's territory and ride no axis.
44
+
45
+ ### Fixed
46
+
47
+ - **Independent doctrine controls.** Each Constitution doctrine row now keeps its detail
48
+ disclosure separate from the right-aligned switch. Toggling a staged rule no longer opens or
49
+ closes its detail, and the existing bottom **Apply** action remains the sole point at which a
50
+ selection changes the deployed harness.
51
+
52
+ ## [3.1.0] — 2026-08-20
53
+
54
+ ### Added
55
+
56
+ - **Doctrine packs are toggleable from the console.** The Constitution page gains a switch on
57
+ each pack header — beside its name, blurb and rule count — staged locally and committed by a
58
+ single **Apply** below the last pack, with **Revert** beside it. Until now the console could
59
+ *show* pack state and not change it: dropping a pack meant `geneseed-build --doctrines`, the
60
+ setup wizard, or editing `harness.config.json` by hand.
61
+
62
+ **On the Constitution page and not in Settings**, because that is where a reader is already
63
+ looking at what each pack contains — dropping `ops` is a decision about the six rules listed
64
+ directly under its header, and a switch three screens away makes it a decision taken blind. A
65
+ staged pack reads `staged on` / `staged off` and greys immediately, so the page never claims a
66
+ change has been applied when it has not.
67
+
68
+ **Staged rather than per-click, and that is the design.** A pack selection is a SET: acting per
69
+ toggle would re-emit the install once per switch — four rebuilds to get from all four packs to
70
+ one, three of them describing a state nobody asked for, each writing a different
71
+ `Active packs:` marker for the next reader to parse.
72
+
73
+ It acts on whichever install the Topbar harness selector points at, so it is already
74
+ per-harness. Pushing one selection to several installs at once is not here.
75
+
76
+ ⚠ **`process` toggles like any other pack, and the consequence is named rather than refused.**
77
+ That pack carries commit/push consent, so dropping it also drops the `git-gate` hook and the
78
+ `git commit*` / `git push*` permission — prompt and boundary are never allowed to disagree. An
79
+ inline warning says so while the change is staged, and the confirm repeats it. `rm -rf` and
80
+ force-push refusals are Rule IV's territory, ride no pack, and stay in every build.
81
+
82
+ ### Fixed
83
+
84
+ - **The Constitution page printed a command that errors.** An inactive pack's enable hint read
85
+ `geneseed build --doctrines …`; the CLI's `build` verb forwards `--theme` and nothing else, so
86
+ that spelling answers `unrecognized arguments`. It is `geneseed-build` — the same trap
87
+ `DESIGN.md` already documents for `--sync-themes`. Shipped in 3.0.0. With an install to
88
+ rebuild the switches replace the hint entirely; it survives only where there is nothing to act
89
+ on.
90
+
91
+ - **A page reachable from three routes remounted on every row expand.** `Laws`, `Skills` and
92
+ `Library` are each reachable from the flat view, `#/section/<name>` and
93
+ `#/item/<type>/<name>`, and each was rendered from three different positions in `App`'s tree.
94
+ Crossing between them is not a prop change to React — it is an unmount and a remount, so the
95
+ page threw away its state, refetched its catalogue and re-entered Suspense. Expanding a row
96
+ does exactly that, because the row IS the route (`#/laws` → `#/item/law/craft.1`). Measured in
97
+ the browser: one refetch of `/api/catalog/laws` per expand *and* per collapse, now zero.
98
+
9
99
  ## [3.0.0] — 2026-08-20
10
100
 
11
101
  ### Changed
package/DESIGN.md CHANGED
@@ -106,11 +106,22 @@ vault or a specific tool's hooks.
106
106
  is narrative and deliberately not alphabetical.
107
107
 
108
108
  **The Ontology and the Invariants are never toggleable** — every build carries both,
109
- whole. The Doctrine packs are the only tier a repository may narrow, chosen once at
110
- build time (`--doctrines craft,rigor`, `--doctrines none`, or the setup wizard) and
111
- defaulting to all four on. There is no runtime toggle. Every pack file ships on disk at
112
- both footprints whether or not it was built in, which is what lets a citation into an
113
- inactive pack still resolve.
109
+ whole. The Doctrine tier is the only one a repository may narrow, and it narrows on
110
+ **two axes**, both chosen once at build time and both defaulting to everything on:
111
+ `--doctrines craft,rigor` (or `none`, or the wizard) selects PACKS, and
112
+ `--exclude-rules "process 7"` drops individual RULES from the packs that survive. They
113
+ compose in one direction only — a pack whose every rule is excluded leaves the active
114
+ set, so no pack header is ever rendered empty. There is no runtime toggle for either.
115
+ Every pack file ships on disk at both footprints whether or not it was built in, which
116
+ is what lets a citation into an inactive pack still resolve.
117
+
118
+ Each axis leaves its own marker line in the carrier for later readers (`upgrade`,
119
+ `rebuild-all`, the console) to parse back: `Active packs:` always, and
120
+ `Excluded rules:` **only when something is excluded**. That asymmetry is deliberate and
121
+ load-bearing — the second line's absence is a complete answer ("nothing"), so a default
122
+ build is byte-identical to one from before the axis existed and no recording had to be
123
+ re-blessed; `excludedRulesOfDir` accordingly reads a missing line as `[]` where
124
+ `doctrinesOfDir` must read one as `null`.
114
125
 
115
126
  **Conflict order, exactly:** Ontology + Invariants → the user's own `user-rules.md` →
116
127
  the active Doctrines → `PROFILE.md`. A doctrine rule may *tighten* an invariant, never
package/README.md CHANGED
@@ -154,7 +154,7 @@ The harness ships as a small set of layers, and the web console's rail is the sa
154
154
  | --- | --- |
155
155
  | **🧭 Ontology** (`ontology/`) | the mind the rules govern, in four prose sections — **Telos** (the Pact: protect the user, the truth, the agent), **Evidence** (every claim graded by how it was obtained), **Decisions** (classify and tier by reversibility, show the real forks), **Conduct** (answer what was asked, once). Always in force, never toggleable |
156
156
  | **🛡️ Rules** (`laws/`) | 9 universal laws the agent obeys — always in force, never toggleable: sealed-secrets, one-intent-one-act, verify-before-assert, deletion-is-deliberate, surface-failures, data-not-orders, least-privilege, root-cause, external-gate |
157
- | **📐 Doctrines** (`doctrines/`) | practice packs, chosen per install at build time: **craft** (how code is written), **rigor** (how work is proven), **ops** (how the machine is operated), **process** (how a task is run). A doctrine rule may tighten a Rule, never repeal one, and the user's own `user-rules.md` outranks it. Pick with `geneseed-build --doctrines craft,rigor` (or `none`), or in the setup wizard; all four pack files ship on disk either way, so a citation into a pack you left out still resolves |
157
+ | **📐 Doctrines** (`doctrines/`) | practice packs, chosen per install at build time: **craft** (how code is written), **rigor** (how work is proven), **ops** (how the machine is operated), **process** (how a task is run). A doctrine rule may tighten a Rule, never repeal one, and the user's own `user-rules.md` outranks it. Pick with `geneseed-build --doctrines craft,rigor` (or `none`), a single rule with `--exclude-rules "process 7"`, in the setup wizard, or through the switches on the console's Constitution page; all four pack files ship on disk either way, so a citation into a pack you left out still resolves |
158
158
  | **🤖 Agents** (17) | capability specialists: `reviewer`, `tester`, `architect`, `docs`, `security`, `explorer`, `developer` — plus a debate **council** the `council` skill convenes: `advocate`, `skeptic`, `pragmatist`, `steward`, `visionary`, `user-advocate`, `framer`, `empiricist`, `operator`, `historian` |
159
159
  | **🛠 Skills** (47) | repeatable workflows: brainstorm · **clarify** · plan · **codebase-design** · **domain-modeling** · **wayfinder** · **tickets** · tdd · **develop** · debug · **prototype** · refactor · **ponytail** · **forge-mcp** · geneseed-code-review · **fresh-eyes** · **review-response** · commit · **ship** · **release** · **migrate** · **git-archaeology** · **git-rescue** · repo-map · document-project · **frontend-design** · **prose** · **ingest** · **research** · **learning-path** · **gap-detector** · **feynman** · **crash-course** · **drill** · **decode** · handoff · roast-me · **council** · parallel-agents · **workflow** · **wiki** · **geneseed** · **rule** · **profile** · **opencode-theme** · **herdr** · **pipeline** |
160
160
  | **🔌 Plugins** (OpenCode) | `geneseed-context` injects project docs *and your machine wiki* every session (and across compaction); `geneseed-learn` distils memory at session end; `geneseed-guard` enforces the safety Laws and protected wiki folders at the tool boundary; `geneseed-workflow` registers the `workflow` tool that runs saved orchestration scripts; `geneseed-notify` sends a native OS notification when a long run finishes; `geneseed-ponytail` holds a minimal-code mode (`/ponytail lite\|full\|ultra\|off`), opt-in, injecting the laziest-that-works ruleset every turn so it doesn't drift; `geneseed-activity` streams what each session is doing to the web console's Activity view |
package/SETUP.md CHANGED
@@ -168,8 +168,10 @@ It wires:
168
168
  for Doctrine `process 5`. The hook inspects the command and forces an `ask` on every
169
169
  `git commit` or `git push` (even chained or `-C`-flagged), un-suppressible by a
170
170
  one-time "don't ask again". Every other Bash command is deferred to the normal
171
- permission flow. It is wired only when the **process** pack is active — build with
172
- `--doctrines` leaving `process` out and this hook is not installed at all.
171
+ permission flow. It is wired only when **Doctrine process 5 itself** is active — build
172
+ with `--doctrines` leaving `process` out, or with `--exclude-rules "process 5"`, and this
173
+ hook is not installed at all. The prompt and the boundary move together by construction:
174
+ the gate is keyed on the rule, not on its pack.
173
175
  - **SessionStart** (`startup`/`clear`) — prints `AGENT.md` and injects the project
174
176
  context (`harness context`, which auto-discovers the repo's docs);
175
177
  - **SessionStart** (`resume`) — refreshes the project context only, without re-printing
@@ -313,6 +315,31 @@ the default is all four:
313
315
  refuses one that names a pack the checkout does not ship. Order is fixed by the harness
314
316
  (craft → rigor → ops → process) regardless of the order you type.
315
317
 
318
+ ### One rule at a time
319
+
320
+ A pack is coarse: `--doctrines` can only keep all of **process** or none of it.
321
+ `--exclude-rules` is the finer axis, and it takes rule addresses:
322
+
323
+ geneseed-build --exclude-rules "process 7" # keep the pack, drop one rule
324
+ geneseed-build --exclude-rules "process 5,craft 2" # several
325
+ geneseed-build --exclude-rules none # exclude nothing (the default)
326
+
327
+ Both spellings of an address work — `process 7` as the constitution writes it, or `process.7`
328
+ as the console addresses it. The two axes compose: a pack whose every rule is excluded drops
329
+ out of `Active packs:` entirely, so you never get a pack header with nothing under it.
330
+
331
+ An install records what it excluded in a second marker line, `Excluded rules: process 7`,
332
+ written **only when something is excluded** — so a build with the default keeps every byte it
333
+ had before this axis existed. `upgrade`, `rebuild-all` and the web console all read that line
334
+ back and preserve it, exactly as they preserve the pack list.
335
+
336
+ ⚠ **`process 5` is special**: excluding it also unwires the commit/push consent hook, because
337
+ the tool boundary is keyed on that rule. That is by design — the prompt and the boundary must
338
+ never disagree — and both the console and the wizard say so before applying it.
339
+
340
+ The web console has the switches: **Constitution → Doctrines**, one per rule, staged locally
341
+ with a single **Apply** that rebuilds the install once.
342
+
316
343
  The setup wizard asks too — one *all* / *choose* gate, then a yes/no per pack with its
317
344
  one-line description — and it **pre-selects what the install already carries**, read off the
318
345
  `Active packs:` marker line in the deployed `AGENT.md`. So re-running the wizard and holding
package/SHIPPED.md CHANGED
@@ -20,7 +20,7 @@ historical pointers, not files distributed with the repo.
20
20
  > **On version numbers.** [`harness.config.json`](harness.config.json)'s `version` is a
21
21
  > human-readable label only. The canonical identity of an installed harness is the
22
22
  > **source fingerprint** stamped in `.geneseed-version` (see `geneseed version`), not the
23
- > string. Current label: **3.0.0**.
23
+ > string. Current label: **3.1.0**.
24
24
 
25
25
  > **Spec status audit (2026-06-15).** Every dated spec authored before today was
26
26
  > verified against the source tree and confirmed implemented (code + tests + docs);
package/bin/geneseed.mjs CHANGED
@@ -52,7 +52,9 @@ import {
52
52
  // P5d moved these out of this file for the reason P5c moved the host resolvers: `harness
53
53
  // status` renders to count, so `bin/geneseed-cli.mjs` needs the same checkout paths and the
54
54
  // same cfg. golden.py's 259 cells all build one, which is what made the move safe.
55
- import { ROOT, CONFIG, THEMES, discoverNames, makeCfg, PACK_ORDER } from '../js/checkout.mjs';
55
+ import {
56
+ ROOT, CONFIG, THEMES, discoverNames, makeCfg, PACK_ORDER, knownRuleIds,
57
+ } from '../js/checkout.mjs';
56
58
 
57
59
  // P5f moved these, for the same arithmetic a third time: `harness rebuild-all` reads the
58
60
  // registry to find every install it must re-emit, and a CLI verb may not reach into a driver
@@ -124,6 +126,7 @@ export function resolveOut(raw) {
124
126
  function configDefaults() {
125
127
  const d = {
126
128
  theme: 'neutral', posture: 'peer', mode: 'direct', doctrines: [...PACK_ORDER],
129
+ excludeRules: [],
127
130
  };
128
131
  if (!existsSync(CONFIG)) return d;
129
132
  try {
@@ -146,6 +149,19 @@ function configDefaults() {
146
149
  + 'list of known pack names — using all packs.\n');
147
150
  }
148
151
  }
152
+ // The second axis, read the same forgiving way and defaulting the OTHER direction: a
153
+ // bad value excludes NOTHING, because the safe answer for a list of things to take
154
+ // away is to take nothing away. Same reasoning as `doctrines` defaulting to all.
155
+ if (data.excludeRules !== undefined) {
156
+ if (Array.isArray(data.excludeRules)
157
+ && data.excludeRules.every((x) => typeof x === 'string')) {
158
+ d.excludeRules = [...new Set(data.excludeRules.map((x) => x.replace(/[ \t]+/, '.')))]
159
+ .sort();
160
+ } else {
161
+ process.stderr.write(`[geneseed] WARN: ${path.basename(CONFIG)} "excludeRules" is `
162
+ + 'not a list of rule addresses — excluding nothing.\n');
163
+ }
164
+ }
149
165
  }
150
166
  } catch {
151
167
  process.stderr.write(`[geneseed] WARN: ${path.basename(CONFIG)} is unreadable — `
@@ -162,7 +178,7 @@ function configDefaults() {
162
178
  */
163
179
  const VALUED = {
164
180
  '--theme': 'theme', '--posture': 'posture', '--mode': 'mode',
165
- '--doctrines': 'doctrines',
181
+ '--doctrines': 'doctrines', '--exclude-rules': 'excludeRules',
166
182
  '--out': 'out', '--target': 'out', '--emit': 'emit',
167
183
  '--footprint': 'footprint', '--root': 'root',
168
184
  };
@@ -204,6 +220,37 @@ function choicesFor() {
204
220
  * packs the checkout actually has; a discovered pack missing from `PACK_ORDER` is a
205
221
  * different fault and `js/render.mjs` refuses the whole build for it.
206
222
  */
223
+ /**
224
+ * `--exclude-rules "process 7,craft 3"` (or `process.7`) -> the cfg's exclusion array.
225
+ *
226
+ * BOTH SPELLINGS ACCEPTED ON THE WAY IN, ONE ON THE WAY OUT. The carrier's marker line and
227
+ * every citation in the corpus spell a rule `process 7`; the console's deep link and the
228
+ * catalogue spell it `process.7`. A flag that took only one of them would be wrong for
229
+ * whichever half of the documentation the reader had open, so it takes either and normalises
230
+ * to the dotted form the code compares.
231
+ *
232
+ * ⚠ VALIDATED AGAINST THE PACK FILES, NOT A RANGE. `process 9` and `craft 0` are both
233
+ * refusals, and so is a pack this checkout does not ship — an exclusion that names nothing is
234
+ * a typo that would otherwise take away nothing in silence, which is the failure this flag
235
+ * can least afford: the user asked for a rule to STOP binding and it would keep binding.
236
+ * Sorted, deduped, for the same marker-stability reason `--doctrines` is.
237
+ */
238
+ function parseExcludeRules(value) {
239
+ const s = value.trim();
240
+ if (s === 'none' || s === '') return [];
241
+ const known = knownRuleIds();
242
+ const out = [];
243
+ for (const raw of s.split(',').map((x) => x.trim()).filter(Boolean)) {
244
+ const id = raw.replace(/[ \t]+/, '.');
245
+ if (!known.includes(id)) {
246
+ die(2, `argument --exclude-rules: invalid choice: '${raw}' (choose from `
247
+ + `${known.map((c) => `'${c.replace('.', ' ')}'`).join(', ')}, or 'none')`);
248
+ }
249
+ out.push(id);
250
+ }
251
+ return [...new Set(out)].sort();
252
+ }
253
+
207
254
  function parseDoctrines(value) {
208
255
  const s = value.trim();
209
256
  const known = discoverNames('doctrines', PACK_ORDER[0]);
@@ -264,6 +311,7 @@ function parseArgs(argv, defaults) {
264
311
  const args = {
265
312
  theme: defaults.theme, posture: defaults.posture, mode: defaults.mode,
266
313
  doctrines: defaults.doctrines,
314
+ excludeRules: defaults.excludeRules,
267
315
  out: 'Harness', emit: 'files', footprint: 'lean', root: null,
268
316
  syncThemes: false, validateOnly: false, verbose: false,
269
317
  };
@@ -296,6 +344,7 @@ function parseArgs(argv, defaults) {
296
344
  // Only when the flag was actually PASSED is this a string; left at its default it is
297
345
  // already the array `configDefaults` built, and re-parsing an array would stringify it.
298
346
  if (typeof args.doctrines === 'string') args.doctrines = parseDoctrines(args.doctrines);
347
+ if (typeof args.excludeRules === 'string') args.excludeRules = parseExcludeRules(args.excludeRules);
299
348
  return args;
300
349
  }
301
350
 
package/docs/extending.md CHANGED
@@ -116,9 +116,9 @@ Notes that matter:
116
116
 
117
117
  ### The gates CI does not run
118
118
 
119
- - **The mutation matrix itself.** `tests/mutate.mjs` — 31 rows, each a one-string edit to a
119
+ - **The mutation matrix itself.** `tests/mutate.mjs` — 33 rows, each a one-string edit to a
120
120
  product file with a written argument for which gate should catch it. The full run is **in no
121
- workflow** and should not be: it mutates the product tree 31 times and runs a gate per edit.
121
+ workflow** and should not be: it mutates the product tree 33 times and runs a gate per edit.
122
122
  Run `node tests/mutate.mjs --all` (or `--only M7`) by hand when you change something a gate is
123
123
  supposed to be watching. ⚠ An interrupted run leaves the **product tree mutated** — check
124
124
  `git status` before doing anything else.
package/docs/web/rules.md CHANGED
@@ -21,6 +21,8 @@ Practices rather than principles: **craft** (how code is written), **rigor** (ho
21
21
 
22
22
  A pack you leave out **still ships in the bundle**: every pack file lands under `doctrines/` beside `AGENT.md` whether or not it was built in. So a rule that cites one in an inactive pack still resolves on disk, and you can read the alternatives before turning one on. What an inactive pack loses is bindingness, not availability.
23
23
 
24
+ Rules toggle **individually** too — `geneseed-build --exclude-rules "process 7"`, or the switches on this page — so *keep all of process except one rule* is a state you can build. A pack whose every rule is excluded drops out of the active set on its own. What an install excluded is recorded beside the pack list, as `Excluded rules: process 7`, and every rebuild preserves it.
25
+
24
26
  ### Which outranks which
25
27
 
26
28
  Ontology and Invariants first, then your own `user-rules.md`, then the active Doctrines, then `PROFILE.md`. A rule you write in `user-rules.md` **outranks a doctrine rule** — a practice pack chosen at build time never overrides an instruction you wrote for your own repo — and nothing outranks an invariant: a user rule or a doctrine rule may tighten one, never repeal it.
@@ -36,10 +36,22 @@ Which practice rules this install adopts, fixed at build time. Unlike the dials
36
36
  - **ops** — how the machine is driven: tool discovery, commands that return, complete teardowns.
37
37
  - **process** — how a session runs: planning, context economy, docs first, and the **consent gate on every commit and push**.
38
38
 
39
- Turning **process** off takes that consent gate with it, at the tool boundary as well as in the prose: the OpenCode permission block and the Claude/Bob `PreToolUse` hook that ask before a commit or a push are wired only when the pack is built in, because a boundary that keeps asking for a rule the install never adopted is a gate arguing with its own harness. The invariant-territory refusals — `rm -rf`, a force-push — stay in every build; they are the Rules' territory, not the pack's.
39
+ Turning **process 5** off takes that consent gate with it, at the tool boundary as well as in the prose: the OpenCode permission block and the Claude/Bob `PreToolUse` hook that ask before a commit or a push are wired only when that rule is built in, because a boundary that keeps asking for a rule the install never adopted is a gate arguing with its own harness. The invariant-territory refusals — `rm -rf`, a force-push — stay in every build; they are the Rules' territory, not the pack's.
40
40
 
41
41
  A pack you leave out still ships in the bundle under `doctrines/`, so you can read it before turning it on, and a rule that cites it still resolves. Change the set with `geneseed-build --doctrines craft,rigor` (or `--doctrines none`), or in `harness.config.json`.
42
42
 
43
+ ### One rule at a time
44
+
45
+ The pack is the coarse axis. `--exclude-rules` is the fine one, and it takes rule addresses — so *keep all of process, drop just `process 7`* is expressible:
46
+
47
+ geneseed-build --exclude-rules "process 7"
48
+ geneseed-build --exclude-rules "process 5,craft 2"
49
+ geneseed-build --exclude-rules none
50
+
51
+ Either spelling of an address works, `process 7` or `process.7`. The two axes compose: a pack whose every rule is excluded leaves `Active packs:` entirely, so no pack header is ever rendered with nothing under it. An install records its exclusions in a second marker line — `Excluded rules: process 7` — written only when something *is* excluded, and every rebuild path reads it back and preserves it.
52
+
53
+ The switches are on the **Constitution** page, one per rule, staged locally and applied in a single rebuild.
54
+
43
55
  ### Footprint — how much of the constitution loads inline
44
56
 
45
57
  How much of the constitution `AGENT.md` carries inline every turn. A token-cost dial, not a rules cut — whatever this install adopted always applies:
@@ -2,5 +2,5 @@
2
2
  "_comment": "version is a human-readable release label only; the canonical identity of an installed harness is the source fingerprint stamped in .geneseed-version (see `harness version`), not this string.",
3
3
  "theme": "neutral",
4
4
  "product": "Geneseed",
5
- "version": "3.0.0"
5
+ "version": "3.1.1"
6
6
  }
package/js/checkout.mjs CHANGED
@@ -22,7 +22,7 @@
22
22
  * over 259 cells and compares the tree byte-for-byte, and every one of them builds a cfg
23
23
  * from these paths. A depth error or a renamed key fails 259 cells, not zero.
24
24
  */
25
- import { readdirSync } from 'node:fs';
25
+ import { readdirSync, readFileSync } from 'node:fs';
26
26
  import path from 'node:path';
27
27
  import { fileURLToPath } from 'node:url';
28
28
 
@@ -83,6 +83,34 @@ export function discoverNames(dir, first) {
83
83
  */
84
84
  export const PACK_ORDER = ['craft', 'rigor', 'ops', 'process'];
85
85
 
86
+ /**
87
+ * Every doctrine rule address this checkout ships — `pack.n`, in render order.
88
+ *
89
+ * The ONE enumerator, for the same arithmetic reason `PACK_ORDER` is one array: the CLI
90
+ * flag, the console's trust boundary and the doctor all have to close `--exclude-rules`
91
+ * against the SAME set, and a second copy is a set that drifts by one rule the day a pack
92
+ * grows. Read from the UNRENDERED source (`### {{DOCTRINE}} <pack> <n>`) rather than from a
93
+ * built tree, because an address is a property of the source and the rendered heading is
94
+ * themed — fourteen spellings of a thing that must compare equal to itself.
95
+ *
96
+ * Ordered by `PACK_ORDER`, then by the order the headings stand in the pack file — NOT sorted.
97
+ * The file is what renders, so mirroring it is what keeps this list and the rendered
98
+ * constitution in the same sequence; a sort would silently disagree with a file whose headings
99
+ * were ever out of order, and disagree in the direction that reads as correct.
100
+ */
101
+ export function knownRuleIds() {
102
+ const out = [];
103
+ for (const pack of PACK_ORDER) {
104
+ const file = path.join(SRC, 'doctrines', `${pack}.md`);
105
+ let text = '';
106
+ try { text = readFileSync(file, 'utf8'); } catch { continue; }
107
+ for (const m of text.matchAll(/^### \{\{DOCTRINE\}\} ([a-z]+) (\d+)\b/gm)) {
108
+ if (m[1] === pack) out.push(`${pack}.${Number(m[2])}`);
109
+ }
110
+ }
111
+ return out;
112
+ }
113
+
86
114
  /**
87
115
  * `_build_core.js_cfg()`, originated rather than received.
88
116
  *
@@ -95,7 +123,9 @@ export const PACK_ORDER = ['craft', 'rigor', 'ops', 'process'];
95
123
  * out to renderers and a caller that sorted or spliced it in place would rewrite
96
124
  * `PACK_ORDER` for the whole process.
97
125
  */
98
- export function makeCfg({ posture = 'peer', mode = 'direct', doctrines = PACK_ORDER } = {}) {
126
+ export function makeCfg({
127
+ posture = 'peer', mode = 'direct', doctrines = PACK_ORDER, excludeRules = [],
128
+ } = {}) {
99
129
  return {
100
130
  root: ROOT,
101
131
  src: SRC,
@@ -107,5 +137,16 @@ export function makeCfg({ posture = 'peer', mode = 'direct', doctrines = PACK_OR
107
137
  posture,
108
138
  mode,
109
139
  doctrines: Array.isArray(doctrines) ? [...doctrines] : [...PACK_ORDER],
140
+ // ⚠ THE SECOND DOCTRINE AXIS, AND ITS DEFAULT IS THE OPPOSITE WAY ROUND. `doctrines`
141
+ // defaults to EVERYTHING because an unknown selection must bind the most; `excludeRules`
142
+ // defaults to NOTHING for exactly the same reason — an exclusion is a rule taken AWAY, so
143
+ // "unspecified" has to mean "nothing taken away". Both defaults fail closed; they only
144
+ // look inconsistent until you ask which direction is the safe one for each.
145
+ //
146
+ // Addresses are `<pack>.<n>`, the same spelling the console deep-links and the catalogue
147
+ // publishes. An exclusion whose pack is not in `doctrines` is harmless and stays: the pack
148
+ // is already absent, and dropping the exclusion would silently re-admit the rule the day
149
+ // the pack came back.
150
+ excludeRules: Array.isArray(excludeRules) ? [...excludeRules] : [],
110
151
  };
111
152
  }
package/js/doctor.mjs CHANGED
@@ -34,7 +34,7 @@ import os from 'node:os';
34
34
  import path from 'node:path';
35
35
 
36
36
  import {
37
- CONFIG, PACK_ORDER, PLUGIN_SRC, ROOT, SRC, THEMES, WORKFLOW_SRC, makeCfg,
37
+ CONFIG, PACK_ORDER, PLUGIN_SRC, ROOT, SRC, THEMES, WORKFLOW_SRC, knownRuleIds, makeCfg,
38
38
  } from './checkout.mjs';
39
39
  import {
40
40
  buildInto, main as driverMain, emitGlobalInto, emitProjectInto,
@@ -900,6 +900,44 @@ export function constitutionProblems() {
900
900
  // notices a blank description in the browser.
901
901
  problems.push(...doctrineMetaProblems([...rules.keys()]));
902
902
 
903
+ // ---- `--exclude-rules` closes against the SAME rules this walk just found.
904
+ //
905
+ // `knownRuleIds` is what the CLI flag, the console's trust boundary and every wizard prompt
906
+ // validate an address against; the walk above is what the constitution actually renders.
907
+ // Two readers of one directory is the shape that lets a rule be authored, rendered, cited —
908
+ // and rejected by the only flag that can switch it off, with `invalid choice` naming a set
909
+ // that visibly contains it. An equality, not containment: a stale id in the enumerator is a
910
+ // rule the console offers a switch for and the build cannot drop.
911
+ const enumerated = knownRuleIds();
912
+ const walked = [...rules.keys()];
913
+ for (const id of enumerated.filter((i) => !walked.includes(i))) {
914
+ problems.push(`[authoring] knownRuleIds() offers '${id.replace('.', ' ')}', which no pack `
915
+ + 'file defines — --exclude-rules accepts an address the build cannot drop');
916
+ }
917
+ for (const id of walked.filter((i) => !enumerated.includes(i))) {
918
+ problems.push(`[authoring] doctrine ${id.replace('.', ' ')} is defined but knownRuleIds() `
919
+ + 'does not offer it — --exclude-rules refuses a rule that exists, and the console has '
920
+ + 'no switch for it');
921
+ }
922
+
923
+ // ---- the consent gate's address still names the consent rule.
924
+ //
925
+ // ⚠ THE ONE HARDCODED ADDRESS IN THE CODEBASE, and the one whose drift is silent AND unsafe.
926
+ // `js/settings.mjs` keys the git-gate hooks on `process.5` by literal; a renumber of the
927
+ // process pack moves that rule's neighbours under it, and the boundary would then follow
928
+ // whatever rule inherited the number while the prompt still said `process 5`. Checked
929
+ // against the pack file's own title token rather than against prose, so a reworded rule is
930
+ // not a false alarm and a MOVED one is not a silent pass.
931
+ const consentTitle = 'DOC_PROCESS_5';
932
+ let processText = '';
933
+ try { processText = readText(path.join(dir, 'process.md')); } catch { /* reported above */ }
934
+ if (processText && !new RegExp(`^### \\{\\{DOCTRINE\\}\\} process 5 — \\{\\{${consentTitle}\\}\\}`,
935
+ 'm').test(processText)) {
936
+ problems.push('[authoring] js/settings.mjs gates git commit/push on doctrine \'process 5\', '
937
+ + `but doctrines/process.md does not number {${consentTitle}} 5 — the tool boundary and `
938
+ + 'the prompt now name different rules');
939
+ }
940
+
903
941
  // ---- every `{{DOCTRINE}} <pack> <n>` anywhere in src/ resolves to a rule that exists.
904
942
  //
905
943
  // The whole tree, not the template: a skill footer citing `process 9` is as broken as a
package/js/emit.mjs CHANGED
@@ -846,7 +846,7 @@ export function emitOpencodeRender(cfg, job) {
846
846
  // downstream: `opencode.json`, or the `.jsonc` sibling when that is what is on disk.
847
847
  phaseLog('WIRE');
848
848
  const cfgName = path.basename(mergeOpencodeJson(path.join(root, 'opencode.json'), agentPath,
849
- cfg.doctrines));
849
+ cfg.doctrines, cfg.excludeRules));
850
850
 
851
851
  return {
852
852
  owned,
@@ -1080,7 +1080,7 @@ export function emitOpencodeGlobalRender(cfg, job) {
1080
1080
  // that split is what `tests/test_emit_phase_order.py` walks, and it walks the Python.
1081
1081
  phaseLog('WIRE');
1082
1082
  const cfgName = path.basename(mergeOpencodeJson(path.join(cfgDir, 'opencode.json'),
1083
- agentPath, cfg.doctrines));
1083
+ agentPath, cfg.doctrines, cfg.excludeRules));
1084
1084
 
1085
1085
  return {
1086
1086
  owned,
@@ -1216,7 +1216,8 @@ export function emitClaudeRender(cfg, job) {
1216
1216
 
1217
1217
  // WIRE — see `claudeWire` below. One child per emit, so the two halves are two
1218
1218
  // functions rather than two spawns; the seam between them is a call, not a process.
1219
- const managed = claudeWire(job, claudeMdText, agentText !== null, cfg.doctrines);
1219
+ const managed = claudeWire(job, claudeMdText, agentText !== null, cfg.doctrines,
1220
+ cfg.excludeRules);
1220
1221
 
1221
1222
  return {
1222
1223
  owned,
@@ -1251,7 +1252,7 @@ export function emitClaudeRender(cfg, job) {
1251
1252
  * not needed for it: Python tests `os.environ.get(...)` for truthiness and an env var is
1252
1253
  * always a string, so `''` is the only falsy value either language sees.
1253
1254
  */
1254
- function claudeWire(job, claudeMdText, hasAgentText, doctrines = null) {
1255
+ function claudeWire(job, claudeMdText, hasAgentText, doctrines = null, excludeRules = []) {
1255
1256
  phaseLog('WIRE');
1256
1257
  const { cfgDir, claudeMd, scope, host, oldManaged, preambleExclude, hookOpts } = job;
1257
1258
  const old = oldManaged && typeof oldManaged === 'object' && !Array.isArray(oldManaged)
@@ -1312,7 +1313,7 @@ function claudeWire(job, claudeMdText, hasAgentText, doctrines = null) {
1312
1313
  // DECIDES the install's packs, so the marker on disk is still the previous build's.
1313
1314
  const [, managedHooks] = mergeClaudeSettings(
1314
1315
  settingsPath, scope, oldSf === settingsName ? get(old, 'settings_hooks') : null, hookOpts,
1315
- doctrines,
1316
+ doctrines, excludeRules,
1316
1317
  );
1317
1318
  managed.settings_hooks = managedHooks;
1318
1319
 
package/js/generate.mjs CHANGED
@@ -37,7 +37,8 @@ import { makeCfg, PACK_ORDER } from './checkout.mjs';
37
37
  import { opencodeConfigDir, pyResolve } from './hosts.mjs';
38
38
  import {
39
39
  EMIT_HOST_SCOPE, defaultMode, defaultPosture, defaultTheme, doctrinesForBuild,
40
- footprintOfDir, installState, installTargets, modeOfDir, postureOfDir, readMaybe, themeOfDir,
40
+ excludedRulesOfDir, footprintOfDir, installState, installTargets, modeOfDir, postureOfDir,
41
+ readMaybe, themeOfDir,
41
42
  } from './installs.mjs';
42
43
  import { colorThemeFiles, colorThemeJson, PALETTE_ROLES } from './opencode.mjs';
43
44
  import { renderAll } from './render.mjs';
@@ -122,7 +123,8 @@ export function cmdBuild(args) {
122
123
  * accepts (an empty `--doctrines ` is a usage error there, so it can never be produced here).
123
124
  */
124
125
  export function setupBuildArgs(theme, emit, out = null, root = null, footprint = 'lean',
125
- posture = 'peer', mode = 'direct', doctrines = null, allPacks = PACK_ORDER) {
126
+ posture = 'peer', mode = 'direct', doctrines = null, allPacks = PACK_ORDER,
127
+ excludeRules = null) {
126
128
  const argv = ['--theme', theme, '--emit', emit];
127
129
  if (!['opencode-global', 'claude-global', 'bob-global', 'copilot-global'].includes(emit)) {
128
130
  if (out) argv.push('--out', out);
@@ -138,6 +140,13 @@ export function setupBuildArgs(theme, emit, out = null, root = null, footprint =
138
140
  const picked = allPacks.filter((p) => doctrines.includes(p));
139
141
  argv.push('--doctrines', picked.length ? picked.join(',') : 'none');
140
142
  }
143
+ // The second axis, emitted the same way and for the same reason — but ALWAYS when the
144
+ // caller passed a list, empty included. An omitted flag reads as "no opinion" and the
145
+ // driver's config fallback fills it in; `none` is the caller saying "exclude nothing",
146
+ // which is exactly what a rebuild that just re-enabled the last rule has to say.
147
+ if (Array.isArray(excludeRules)) {
148
+ argv.push('--exclude-rules', excludeRules.length ? excludeRules.join(',') : 'none');
149
+ }
141
150
  return argv;
142
151
  }
143
152
 
@@ -201,7 +210,13 @@ export function cmdRebuildAll() {
201
210
  // resolving that silence out of a machine-wide config narrows every such install on its
202
211
  // first upgrade. `doctrinesForBuild` resolves unknown to ALL packs — see its docblock.
203
212
  const doctrines = doctrinesForBuild(root);
204
- const argv = setupBuildArgs(theme, emit, out, out, footprint, posture, mode, doctrines);
213
+ // SEVEN. The per-rule axis is preserved for the same reason and with sharper teeth: an
214
+ // upgrade that dropped it would quietly hand back every rule its owner switched off, and
215
+ // `process 5` is one of them — the consent gate would return to a harness that had been
216
+ // built without it. No `doctrinesForBuild` rescue is needed here; see `apiInstallCmd`.
217
+ const excludeRules = excludedRulesOfDir(root);
218
+ const argv = setupBuildArgs(theme, emit, out, out, footprint, posture, mode, doctrines,
219
+ PACK_ORDER, excludeRules);
205
220
  const label = `${host}:${scope} (${root})`;
206
221
  pyPrint(`[rebuild-all] ${label}: theme=${theme} emit=${emit} footprint=${footprint} `
207
222
  + `posture=${posture} mode=${mode}\n`);