geneseed 3.1.0 → 3.1.2

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 (102) hide show
  1. package/CHANGELOG.md +110 -0
  2. package/DESIGN.md +31 -20
  3. package/README.md +2 -2
  4. package/SETUP.md +30 -3
  5. package/SHIPPED.md +1 -1
  6. package/bin/{geneseed.mjs → build-driver.mjs} +88 -42
  7. package/bin/geneseed-cli.mjs +33 -32
  8. package/bin/geneseed-hook.mjs +7 -7
  9. package/docs/design-history.md +40 -40
  10. package/docs/extending.md +113 -85
  11. package/docs/web/rules.md +2 -0
  12. package/docs/web/setup-choices.md +13 -1
  13. package/harness.config.json +1 -1
  14. package/js/README.md +203 -0
  15. package/js/build/bundle.mjs +231 -0
  16. package/js/{catalog.mjs → build/catalog.mjs} +7 -7
  17. package/js/build/emit-claude.mjs +287 -0
  18. package/js/build/emit-common.mjs +255 -0
  19. package/js/build/emit-opencode.mjs +214 -0
  20. package/js/{generate.mjs → build/generate.mjs} +45 -31
  21. package/js/{render.mjs → build/render.mjs} +72 -11
  22. package/js/{checkout.mjs → build/source.mjs} +52 -11
  23. package/js/build/stubs.mjs +256 -0
  24. package/js/{themes.mjs → build/themes.mjs} +24 -24
  25. package/js/build/version.mjs +155 -0
  26. package/js/{hooks.mjs → hosts/hooks.mjs} +47 -44
  27. package/js/{hosts.mjs → hosts/hosts.mjs} +30 -28
  28. package/js/{installs.mjs → hosts/installs.mjs} +68 -16
  29. package/js/{link.mjs → hosts/link.mjs} +29 -28
  30. package/js/{mcp.mjs → hosts/mcp.mjs} +10 -9
  31. package/js/{native.mjs → hosts/native.mjs} +24 -22
  32. package/js/{opencode.mjs → hosts/opencode.mjs} +12 -7
  33. package/js/{settings.mjs → hosts/settings.mjs} +94 -69
  34. package/js/inspect/checks-authoring.mjs +761 -0
  35. package/js/inspect/checks-build.mjs +231 -0
  36. package/js/inspect/checks-repo.mjs +308 -0
  37. package/js/{diff.mjs → inspect/diff.mjs} +34 -31
  38. package/js/inspect/doctor.mjs +286 -0
  39. package/js/{excludes.mjs → inspect/excludes.mjs} +30 -33
  40. package/js/{inventory.mjs → inspect/inventory.mjs} +34 -20
  41. package/js/{registry.mjs → inspect/registry.mjs} +12 -10
  42. package/js/inspect/scan.mjs +125 -0
  43. package/js/{status.mjs → inspect/status.mjs} +27 -25
  44. package/js/inspect/validate.mjs +199 -0
  45. package/js/lib/fs.mjs +37 -759
  46. package/js/lib/json.mjs +344 -0
  47. package/js/lib/paths.mjs +242 -0
  48. package/js/lib/proc.mjs +3 -3
  49. package/js/lib/text.mjs +181 -0
  50. package/js/lib/udiff.mjs +1 -1
  51. package/js/{memory.mjs → maintain/memory.mjs} +10 -10
  52. package/js/{migrate.mjs → maintain/migrate.mjs} +28 -26
  53. package/js/{setup.mjs → maintain/setup.mjs} +49 -37
  54. package/js/{uninstall.mjs → maintain/uninstall.mjs} +62 -58
  55. package/js/{update.mjs → maintain/update.mjs} +51 -51
  56. package/js/{anim.mjs → ui/anim.mjs} +30 -28
  57. package/js/{cli.mjs → ui/cli.mjs} +10 -10
  58. package/js/{menu.mjs → ui/menu.mjs} +10 -9
  59. package/js/{tui.mjs → ui/tui.mjs} +36 -34
  60. package/js/web/actions.mjs +124 -89
  61. package/js/web/activity.mjs +22 -21
  62. package/js/web/api.mjs +88 -58
  63. package/js/web/daemon.mjs +319 -0
  64. package/js/web/docs.mjs +24 -21
  65. package/js/web/graph.mjs +0 -0
  66. package/js/web/handler.mjs +442 -0
  67. package/js/web/jobs.mjs +29 -21
  68. package/js/web/routes.mjs +223 -0
  69. package/js/web/server.mjs +56 -987
  70. package/package.json +11 -3
  71. package/src/AGENT.md.tmpl +1 -1
  72. package/src/doctrines/README.md +1 -1
  73. package/themes/opencode/README.md +1 -1
  74. package/web/dist/assets/{About-_MnJcwb_.js → About-CPvIFZGh.js} +1 -1
  75. package/web/dist/assets/{Activity-DpjqO-FR.js → Activity-z0uXdS7J.js} +1 -1
  76. package/web/dist/assets/{ActivityDetail-Bvrjv8oW.js → ActivityDetail-CEj5IJpF.js} +1 -1
  77. package/web/dist/assets/{Diff-DZ0IehBV.js → Diff-BMR8fPuG.js} +1 -1
  78. package/web/dist/assets/{Doctor-cu4krooO.js → Doctor-Bz5qE4r9.js} +1 -1
  79. package/web/dist/assets/{Graph-DUOPY65j.js → Graph-BV6a6t2l.js} +1 -1
  80. package/web/dist/assets/{Harnesses-B5Osg-BF.js → Harnesses-C5Ag5PaQ.js} +1 -1
  81. package/web/dist/assets/Laws-BK07NKw9.js +3 -0
  82. package/web/dist/assets/{Library-8y69I3_z.js → Library-CiXN9sC3.js} +1 -1
  83. package/web/dist/assets/{Markdown-Ds5GS1OD.js → Markdown-DG6FjUz-.js} +1 -1
  84. package/web/dist/assets/{Profile-7Jxa7ZUX.js → Profile-B2oSvdxH.js} +1 -1
  85. package/web/dist/assets/{Rules-UZ0jWkjg.js → Rules-DobVPd80.js} +1 -1
  86. package/web/dist/assets/{Skills-Bgkl0xLd.js → Skills-DQPsvsn9.js} +1 -1
  87. package/web/dist/assets/{StatusBadge-Cx4caF_2.js → StatusBadge-BhJ3HhdI.js} +1 -1
  88. package/web/dist/assets/{Themes-De8az7Sv.js → Themes-BP38FBU9.js} +1 -1
  89. package/web/dist/assets/{activity-VDAdVQ3q.js → activity-D9mfv7pY.js} +1 -1
  90. package/web/dist/assets/index-BE226Gv6.css +1 -0
  91. package/web/dist/assets/{index-BKrjBRV4.js → index-BZ7cPLMI.js} +1 -1
  92. package/web/dist/assets/index-BgIs5eIa.js +12 -0
  93. package/web/dist/assets/{index-wNiNzFFQ.js → index-DGM5j2hB.js} +1 -1
  94. package/web/dist/index.html +2 -2
  95. package/web/src/pages/Laws.jsx +135 -69
  96. package/docs/declined.md +0 -200
  97. package/docs/limits.md +0 -329
  98. package/js/doctor.mjs +0 -1712
  99. package/js/emit.mjs +0 -1410
  100. package/web/dist/assets/Laws-D8DJgTBp.js +0 -3
  101. package/web/dist/assets/index-1pPSz7Kl.css +0 -1
  102. package/web/dist/assets/index-C-gb-q-r.js +0 -12
@@ -15,7 +15,7 @@ import ErrorState from '../components/ErrorState.jsx'
15
15
  //
16
16
  // ⚠ SIX, THOUGH ONLY FOUR HAVE A MEMBER. `context` and `comms` lost theirs when the corpus became
17
17
  // nine invariants — that material moved to the ontology and the doctrine packs, neither of which
18
- // is classed here. The list stays six because it is the VOCABULARY that js/inventory.mjs's
18
+ // is classed here. The list stays six because it is the VOCABULARY that js/inspect/inventory.mjs's
19
19
  // LAW_CLASSES publishes and doctor quotes verbatim; the PAGE renders only non-empty facets, which
20
20
  // is a display decision and not a change to the taxonomy.
21
21
  const LAW_CATS = {
@@ -126,13 +126,56 @@ function ruleName(rawTitle, romanNum, arabicNum) {
126
126
  // One expandable row, shared by the invariant and doctrine bands: lazy-loads its full body via
127
127
  // /api/item/law/<address> the first time it opens, cached on subsequent toggles. The address is
128
128
  // the catalog's `name` — a Roman numeral or `<pack>.<n>` — never the display number.
129
- function LawRow({ law, isOpen, onToggle }) {
129
+ function LawRow({ law, isOpen, onToggle, toggleCol = null }) {
130
130
  const { data: detail } = useAsync(
131
131
  () => (isOpen ? api.item('law', law.addr) : Promise.resolve(null)),
132
132
  [isOpen, law.addr],
133
133
  )
134
134
  const body = detail?.body || law.ess
135
- return (
135
+ const expandRow = (
136
+ <>
137
+ {isOpen && (
138
+ <div className="law-expand">
139
+ {detail ? (
140
+ <p>
141
+ <LawText text={body} />
142
+ </p>
143
+ ) : (
144
+ <p className="dim">Loading…</p>
145
+ )}
146
+ <div className="law-srcline">
147
+ $ geneseed law {law.addr} · {law.src}
148
+ </div>
149
+ </div>
150
+ )}
151
+ </>
152
+ )
153
+ // Doctrine rows expose two separate actions: the first four columns are one disclosure
154
+ // button, while the fifth is the selection switch. Keeping them as siblings means a switch
155
+ // click changes only the staged selection, never the open rule.
156
+ return toggleCol ? (
157
+ <>
158
+ <div
159
+ className={`law-row has-toggle ${isOpen ? 'on' : ''} ${law.off ? 'law-off' : ''}`}
160
+ style={{ '--cc': law.c }}
161
+ >
162
+ <button className="law-disclosure" onClick={onToggle} aria-expanded={isOpen}>
163
+ <span className="law-no">
164
+ <span className="x">›</span>
165
+ {law.pad}
166
+ </span>
167
+ <span className="law-name">{law.name}</span>
168
+ <span className="law-princ">{law.ess}</span>
169
+ <span className="law-class">
170
+ <span className="cdot" />
171
+ {law.catLabel}
172
+ </span>
173
+ </button>
174
+ <span className="toggle-col">{toggleCol}</span>
175
+ </div>
176
+ {expandRow}
177
+ </>
178
+ ) : (
136
179
  <>
137
180
  <button
138
181
  className={`law-row ${isOpen ? 'on' : ''} ${law.off ? 'law-off' : ''}`}
@@ -151,20 +194,7 @@ function LawRow({ law, isOpen, onToggle }) {
151
194
  {law.catLabel}
152
195
  </span>
153
196
  </button>
154
- {isOpen && (
155
- <div className="law-expand">
156
- {detail ? (
157
- <p>
158
- <LawText text={body} />
159
- </p>
160
- ) : (
161
- <p className="dim">Loading…</p>
162
- )}
163
- <div className="law-srcline">
164
- $ geneseed law {law.addr} · {law.src}
165
- </div>
166
- </div>
167
- )}
197
+ {expandRow}
168
198
  </>
169
199
  )
170
200
  }
@@ -222,13 +252,14 @@ export default function Laws({ selected, overview, onAction }) {
222
252
  // throws, taking the whole page down rather than just the control. Hence the defensive
223
253
  // `data?.items` here and the plain (non-hook) helpers further down.
224
254
  const allItems = data?.items || []
225
- // Insertion order is PACK_ORDER — the server sends the roster in it — and the Set keeps it.
226
- const deployedPacks = [
227
- ...new Set(
228
- allItems.filter((i) => i.tier === 'doctrine' && i.active !== false).map((i) => i.pack),
229
- ),
230
- ]
231
- const deployedKey = deployedPacks.join(',')
255
+ // ⚠ THE UNIT IS THE RULE, not the pack. The selection below is a list of rule ADDRESSES
256
+ // (`process.7`), and the pack axis is DERIVED from it when Apply builds the request — a
257
+ // pack with every rule off drops out of `--doctrines` entirely, so the rendered AGENT.md
258
+ // never carries a pack header with nothing under it.
259
+ const deployedRules = allItems
260
+ .filter((i) => i.tier === 'doctrine' && i.active !== false)
261
+ .map((i) => i.name)
262
+ const deployedKey = deployedRules.join(',')
232
263
  const [picked, setPicked] = useState([])
233
264
  // Re-sync on the VALUE, never on the payload's identity: `data` is a fresh object on every
234
265
  // refetch, so an identity dependency would discard a half-made selection.
@@ -282,7 +313,10 @@ export default function Laws({ selected, overview, onAction }) {
282
313
  pack: it.pack,
283
314
  title: it.packTitle || it.pack,
284
315
  desc: it.packDesc || '',
285
- active: it.active !== false,
316
+ // ⚠ `packActive`, NOT the first rule's `active`. A pack is on when the install built
317
+ // it in AT ALL; with the per-rule axis its first rule can be the excluded one, and
318
+ // reading the pack's state off it would report a live pack as dropped.
319
+ active: it.packActive !== false,
286
320
  rules: [],
287
321
  }
288
322
  packs.push(pack)
@@ -300,7 +334,9 @@ export default function Laws({ selected, overview, onAction }) {
300
334
  })
301
335
  }
302
336
  const activePacks = packs.filter((p) => p.active)
303
- const ruleCount = activePacks.reduce((n, p) => n + p.rules.length, 0)
337
+ // Rule by rule, not pack by pack: an active pack can carry an excluded rule, and this
338
+ // readout claims to say how many rules bind the install.
339
+ const ruleCount = packs.reduce((n, p) => n + p.rules.filter((r) => !r.off).length, 0)
304
340
 
305
341
  // ---- the pack selection: STAGED here, applied once -------------------------------------
306
342
  //
@@ -314,32 +350,62 @@ export default function Laws({ selected, overview, onAction }) {
314
350
  // them describing a state nobody asked for, each writing a different `Active packs:` marker
315
351
  // for the next reader to parse. So the switches edit local state and `Apply` sends the whole
316
352
  // selection. The state itself lives above the early returns — see the ⚠ there.
317
- const isOn = (name) => picked.includes(name)
318
- const togglePack = (name) =>
319
- setPicked((cur) => (cur.includes(name) ? cur.filter((p) => p !== name) : [...cur, name]))
320
- const pickedPacks = packs.filter((p) => isOn(p.pack)).map((p) => p.pack)
321
- const dirty = pickedPacks.join(',') !== deployedKey
322
- const losingConsent = deployedPacks.includes('process') && !picked.includes('process')
353
+ const isOn = (addr) => picked.includes(addr)
354
+ const toggleRule = (addr) =>
355
+ setPicked((cur) => (cur.includes(addr) ? cur.filter((a) => a !== addr) : [...cur, addr]))
356
+ // A pack is never picked directly — it is on when any of its rules is. One source, so the
357
+ // header and the switches under it cannot disagree.
358
+ const packOn = (p) => p.rules.some((r) => isOn(r.addr))
359
+ // ⚠ A STAGED PACK MUST NOT READ AS A DEPLOYED ONE. `p.active` is what the install built;
360
+ // `packOn(p)` is what the switches currently say. Where they differ the header says so —
361
+ // reporting the staged state as fact is the one lie a control like this can tell.
362
+ const packState = (p) => {
363
+ const on = p.rules.filter((r) => isOn(r.addr)).length
364
+ if (!on) return p.active ? 'staged off' : 'not built in'
365
+ if (!p.active) return `staged on · ${on} of ${p.rules.length}`
366
+ return `${on} of ${p.rules.length} active`
367
+ }
368
+ // What the install excludes today: a rule that is off inside a pack it still builds in.
369
+ // A rule whose whole pack is absent is not an exclusion — the pack's absence says it, and
370
+ // naming it beside a `--doctrines` that omits the pack would be noise the CLI rejects.
371
+ const deployedExcluded = packs
372
+ .filter((p) => p.active)
373
+ .flatMap((p) => p.rules.filter((r) => r.off).map((r) => r.addr))
374
+ const allRules = packs.flatMap((p) => p.rules.map((r) => r.addr))
375
+ const pickedRules = allRules.filter(isOn)
376
+ const dirty = pickedRules.join(',') !== deployedKey
377
+ const CONSENT = 'process.5'
378
+ const losingConsent = deployedRules.includes(CONSENT) && !picked.includes(CONSENT)
323
379
  // Nothing to rebuild means nothing to toggle — a source render with no deployed install
324
380
  // still READS, it just cannot be changed from here.
325
381
  const install = overview?.install
326
382
  const canApply = Boolean(install && onAction)
327
383
 
384
+ // ⚠ TWO AXES OUT OF ONE SELECTION. The user only ever touches rules; the request carries
385
+ // both `doctrines` (the packs with at least one rule left) and `excludeRules` (the rules
386
+ // dropped from the packs that survive). An exclusion naming a pack that is already absent
387
+ // would be noise, so it is not sent — the pack's absence already says it.
328
388
  const applyPacks = () => {
329
389
  if (!canApply || !dirty) return
390
+ const keptPacks = packs.filter((p) => p.rules.some((r) => isOn(r.addr))).map((p) => p.pack)
391
+ const excludeRules = packs
392
+ .filter((p) => keptPacks.includes(p.pack))
393
+ .flatMap((p) => p.rules.filter((r) => !isOn(r.addr)).map((r) => r.addr))
330
394
  const warn = losingConsent
331
- ? '\n\n⚠ The process pack carries commit/push consent. Dropping it also removes the ' +
332
- 'git-gate hook, so commits and pushes stop being confirmed at the tool boundary. ' +
395
+ ? '\n\n⚠ process 5 carries commit/push consent. Dropping it also removes the git-gate ' +
396
+ 'hook, so commits and pushes stop being confirmed at the tool boundary. ' +
333
397
  '(rm -rf and force-push stay gated — those are Rule IV’s, not the pack’s.)'
334
398
  : ''
399
+ const what = pickedRules.length
400
+ ? `${pickedRules.length} of ${allRules.length} doctrine rules`
401
+ : 'no doctrine rules'
335
402
  if (
336
403
  window.confirm(
337
- `Rebuild ${install.host} · ${install.scope} with ${
338
- pickedPacks.length ? pickedPacks.join(', ') : 'no doctrine packs'
339
- }? It rebuilds in place, non-destructive.${warn}`,
404
+ `Rebuild ${install.host} · ${install.scope} with ${what}? ` +
405
+ `It rebuilds in place, non-destructive.${warn}`,
340
406
  )
341
407
  )
342
- onAction('install', { ...install, doctrines: pickedPacks })
408
+ onAction('install', { ...install, doctrines: keptPacks, excludeRules })
343
409
  }
344
410
 
345
411
  const counts = {}
@@ -443,41 +509,18 @@ export default function Laws({ selected, overview, onAction }) {
443
509
  </div>
444
510
  {packs.map((p) => (
445
511
  <div
446
- className={`card law-wrap mb-16 pack-wrap ${isOn(p.pack) ? '' : 'pack-off'}`}
512
+ className={`card law-wrap mb-16 pack-wrap ${packOn(p) ? '' : 'pack-off'}`}
447
513
  key={p.pack}
448
514
  >
449
515
  <div className="pack-head">
450
- {canApply && (
451
- <span
452
- className={`sw-toggle${isOn(p.pack) ? ' on' : ''}`}
453
- role="switch"
454
- aria-checked={isOn(p.pack)}
455
- aria-label={`${p.title} pack`}
456
- tabIndex={0}
457
- onClick={() => togglePack(p.pack)}
458
- onKeyDown={(e) => {
459
- if (e.key === 'Enter' || e.key === ' ') {
460
- if (e.key === ' ') e.preventDefault()
461
- togglePack(p.pack)
462
- }
463
- }}
464
- />
465
- )}
466
516
  <span className="pack-name" style={{ '--cc': PACK_CATS[p.pack] }}>
467
517
  <span className="cdot" />
468
518
  {p.title}
469
519
  </span>
470
520
  <span className="pack-desc">{p.desc}</span>
471
- <span className="pack-state">
472
- {p.rules.length} rules ·{' '}
473
- {isOn(p.pack) === p.active
474
- ? p.active
475
- ? 'active'
476
- : 'not built in'
477
- : isOn(p.pack)
478
- ? 'staged on'
479
- : 'staged off'}
480
- </span>
521
+ {/* Derived from the rules, never held separately — a pack IS however many of its
522
+ rules are on, and a second source could disagree with the switches above it. */}
523
+ <span className="pack-state">{packState(p)}</span>
481
524
  </div>
482
525
  {!p.active && !canApply && (
483
526
  // The fallback for a console with no install to rebuild — a reader still needs the
@@ -485,7 +528,12 @@ export default function Laws({ selected, overview, onAction }) {
485
528
  // ⚠ `geneseed-build`, not `geneseed build`: the CLI's `build` verb forwards
486
529
  // `--theme` and nothing else, so the shorter spelling errors.
487
530
  <div className="pack-enable">
488
- $ geneseed-build --doctrines {[...deployedPacks, p.pack].join(',')}
531
+ $ geneseed-build --doctrines{' '}
532
+ {packs
533
+ .filter((q) => q.active || q.pack === p.pack)
534
+ .map((q) => q.pack)
535
+ .join(',')}
536
+ {deployedExcluded.length > 0 && ` --exclude-rules ${deployedExcluded.join(',')}`}
489
537
  </div>
490
538
  )}
491
539
  <div className="law-rowhead">
@@ -493,9 +541,27 @@ export default function Laws({ selected, overview, onAction }) {
493
541
  <span>Rule</span>
494
542
  <span>Principle</span>
495
543
  <span>Pack</span>
544
+ <span className="toggle-col">Toggle</span>
496
545
  </div>
497
546
  {p.rules.map((r) => (
498
- <LawRow key={r.addr} law={r} isOpen={open === r.addr} onToggle={() => toggle(r.addr)} />
547
+ <LawRow
548
+ key={r.addr}
549
+ law={{ ...r, off: !isOn(r.addr) }}
550
+ isOpen={open === r.addr}
551
+ onToggle={() => toggle(r.addr)}
552
+ toggleCol={
553
+ canApply ? (
554
+ <button
555
+ type="button"
556
+ className={`sw-toggle${isOn(r.addr) ? ' on' : ''}`}
557
+ role="switch"
558
+ aria-checked={isOn(r.addr)}
559
+ aria-label={`${r.name} rule`}
560
+ onClick={() => toggleRule(r.addr)}
561
+ />
562
+ ) : null
563
+ }
564
+ />
499
565
  ))}
500
566
  </div>
501
567
  ))}
@@ -503,14 +569,14 @@ export default function Laws({ selected, overview, onAction }) {
503
569
  <div className="card pad-lg mb-16">
504
570
  {losingConsent && (
505
571
  <p className="pack-warn" role="status" aria-live="polite">
506
- ⚠ Dropping <b>process</b> also removes the commit/push consent gate —{' '}
572
+ ⚠ Dropping <b>process 5</b> also removes the commit/push consent gate —{' '}
507
573
  <code>git commit</code> and <code>git push</code> stop being confirmed at the tool
508
574
  boundary. <code>rm -rf</code> and force-push stay gated.
509
575
  </p>
510
576
  )}
511
577
  <div className="pack-apply">
512
578
  <button className="btn soft" onClick={applyPacks} disabled={!dirty}>
513
- Apply{dirty ? ` (${pickedPacks.length}/${packs.length} packs)` : ''}
579
+ Apply{dirty ? ` (${pickedRules.length}/${allRules.length} rules)` : ''}
514
580
  </button>
515
581
  <button
516
582
  className="btn ghost"
package/docs/declined.md DELETED
@@ -1,200 +0,0 @@
1
- # Declined divergences
2
-
3
- Places where this CLI deliberately does **not** reproduce the Python reference it was ported
4
- from, with the reason written down. A divergence that is not on this page is a bug; one that is,
5
- is a decision.
6
-
7
- The reference has been deleted, so "the reference does X" is no longer an argument anyone can
8
- re-run. What is recorded in `tests/__snapshots__/` is the reference's answer, frozen; what is
9
- listed here is where this implementation answers differently on purpose.
10
-
11
- **The recording is smaller than it was.** On **2026-08-17** the emit, cli and web corpora were
12
- retired — 1381 cells, produced by an implementation that no longer exists and re-recordable by
13
- nothing, so they reddened on every deliberate content change while saying nothing about a defect
14
- that happened to be stable. What licensed it was a measurement: all five mutations whose only
15
- declared gate was a corpus replay are killed by `tests/unit/` alone. `docs/limits.md` carries the
16
- full argument and what it cost. **The consequence for this page is narrow and worth stating: an
17
- entry whose "how it stays honest" half named one of those three corpora now names a weaker gate, or
18
- none.** The help fixtures under `tests/__snapshots__/help/`, which most of this page rests on, are
19
- untouched.
20
-
21
- ---
22
-
23
- ## `--help`: the prog is `geneseed`, not `harness`
24
-
25
- **Status:** declined (the port's wording is kept), narrow and gated.
26
-
27
- **What differs.** The reference opened `usage: harness status` for `status --help`; this CLI
28
- opens `usage: geneseed status`. The same substitution applies to `bin/geneseed-hook.mjs`, which
29
- spells itself `geneseed-hook`.
30
-
31
- **Why.** `harness` named the Python file this migration deleted. It was not a command anyone
32
- could type, and now it names nothing at all. This CLI already spells itself `geneseed` in every
33
- error it prints (`geneseed: error: …`, `bin/geneseed-cli.mjs`'s `die`), so reproducing `harness`
34
- here would make the help text the only surface still pointing at the old name.
35
-
36
- **How it stays honest.** `prog` is an ARGUMENT of `js/cli.mjs`'s `formatHelp`, not a constant.
37
- `tests/snapshot/cli_help.test.mjs` replays all 26 recorded fixtures through it with the reference's own
38
- prog and requires them byte for byte. The rename is the only thing that moves, and a separate
39
- assertion pins the shipped spelling so a renderer that ignored `prog` could not pass both.
40
-
41
- That the formatter agrees at every WIDTH — argparse's whole layout: usage assembly, the wrapped
42
- continuation indent, the two-column help position, the wrap column — was established by a live
43
- sweep over all 26 verbs at every `COLUMNS` from 20 to 200 while the reference still ran. It
44
- retired with the reference, and it is the reason the fixtures can be trusted: the 26 are recorded
45
- at ONE width (`COLUMNS=80`), and at that width two real divergences were invisible — a mutually
46
- exclusive group emitted as one unsplittable usage part, and `_max_help_position` baked as a
47
- constant where argparse derives it from the width. Both were found by the sweep and fixed. A
48
- claim about "the entire layout" that rests on a corpus at one width is a claim about one width,
49
- so a change to the wrapping arithmetic now needs its own argument rather than a green corpus.
50
-
51
- Note that the divergence could not have been applied to a recorded text after the fact: the
52
- continuation indent of a wrapped usage line is `len('usage: ') + len(prog) + 1`, so four of the
53
- 26 usage blocks re-flow when the prog changes length.
54
-
55
- ---
56
-
57
- ## `update --help` names `update`, where the reference names `upgrade`
58
-
59
- **Status:** reference behaviour, deliberately not reproduced.
60
-
61
- **What differs.** The reference printed `usage: harness upgrade [-h] [theme]` for `update --help`
62
- — the canonical verb, for a command the user spelled `update`. This CLI prints `usage: geneseed
63
- update`.
64
-
65
- **Why.** argparse implements an alias by registering a second key in `_SubParsersAction.choices`
66
- pointing at the SAME parser object, and `parser.prog` was fixed when that object was built. The
67
- leaked name is an implementation detail of the reference's alias mechanism, not a statement about
68
- the CLI. Recorded verbatim in `tests/__snapshots__/help/update.txt` and replayed with the
69
- reference's prog, so the reference's answer is preserved; the shipped call passes the verb the
70
- user typed.
71
-
72
- ---
73
-
74
- ## `link` bakes the interpreter into a written shim, where the Unix arm used to symlink
75
-
76
- **Status:** ACCEPTED, both consequences below. This is a change to the PRODUCT, not a port
77
- divergence — since Task 2b both implementations write the same shim — and it is recorded here
78
- because this is the page that exists for "what did we decide to live with".
79
-
80
- **What changed.** `./geneseed link` used to symlink `ROOT/geneseed` into `~/.local/bin`; the
81
- launcher then resolved the symlink at RUN TIME and found its own interpreter. It now writes a
82
- `#!/bin/sh` shim carrying a `# GENESEED_LINK_SHIM` marker that `exec`s the runtime named by
83
- absolute path (`process.execPath`), mirroring what the Windows arm has always done. The reason it
84
- moved is in `.superpowers/sdd/progress.md` Task 2b: the two implementations disagreed on four
85
- cells, and this repo has no deliberate-divergence mechanism, so the reference moved to the port's
86
- shape rather than the corpus recording a split.
87
-
88
- **Consequence 1 — durability.** The baked path can go away. A shim written under `nvm` names
89
- `~/.nvm/versions/node/v22.x/bin/node`; switch or clean up, and `geneseed` on `PATH` points at a
90
- runtime that no longer exists. The old symlink survived that, because it deferred the choice to the
91
- launcher. Accepted: the fix is one `./geneseed link` re-run, the failure is loud and immediate
92
- (`no such file or directory`), and the Windows arm has carried the same property since it
93
- shipped. Prefer the old behaviour? A shell function in your rc does exactly that, and SETUP.md
94
- offers it beside `link`.
95
-
96
- **Consequence 2 — which CLI is on `PATH`.** The shim names the implementation that WROTE it, and
97
- while two implementations existed that decided whether `geneseed tui` and `geneseed menu` opened
98
- the full-screen panel or refused it. This is now settled rather than a consequence: there is one
99
- implementation, and the panel it does not carry no longer exists anywhere to be reached. Both
100
- verbs cross and both refuse the panel by name, stating what this entry does carry (the TUI's
101
- layout half, not its screens) and offering `setup`, `doctor` and `build` instead. The refusal
102
- names no file, deliberately — an earlier one pointed at the panel's source, and a pointer to
103
- something a migration is deleting is worse than a plain statement that the screen is not there.
104
-
105
- ---
106
-
107
- ## argparse's ERROR wording is not reproduced
108
-
109
- **Status:** declined, pre-existing (P5a, P10c), restated here because it is the neighbour of the
110
- entry above and readers keep finding it separately.
111
-
112
- argparse printed a usage block around every parse failure and prefixed it `harness: error:`; both
113
- entries state the fault on one line prefixed `geneseed: error:` / `geneseed-hook: error:`. See
114
- `bin/geneseed-cli.mjs`'s `parse` docblock.
115
-
116
- **What used to be gated beside it no longer is.** Every error a command's own body raises was in
117
- the recorded CLI corpus, replayed cell by cell; that corpus was retired on 2026-08-17 and the
118
- per-verb error texts are now covered only where `tests/unit/` happens to drive the verb. The
119
- divergence declared here is unchanged — the port has never reproduced argparse's wording and does
120
- not start now — but the *neighbouring* claim, that the rest of the error surface is pinned byte for
121
- byte, has expired.
122
-
123
- ---
124
-
125
- ## Top-level `--help` is still a refusal on the Node entries
126
-
127
- **Status:** open, out of scope for Task 10, stated so it is not mistaken for done.
128
-
129
- `geneseed --help` with no verb prints
130
- `geneseed: error: the following arguments are required: cmd (one of …)` on stderr and exits 2,
131
- where the reference printed the whole parser's help with a subcommand table. Task 10's surface
132
- was the 26 per-verb help texts and only those are recorded.
133
-
134
- Rendering the top-level help needs argparse's `_iter_indented_subactions` path — the subcommand
135
- choices as a metavar in the usage line, and a nested, differently-indented action list whose
136
- invocations feed the same `_action_max_length` — which is a distinct piece of the formatter with
137
- no fixture behind it. Whoever writes it should record it the same way first.
138
-
139
- ---
140
-
141
- ## `--help` anywhere on the line wins, where argparse would fail on an earlier option
142
-
143
- **Status:** declined (the port's behaviour is kept), and the gate that pins it has been corrected
144
- to stop calling it argparse's rule.
145
-
146
- **What differs.** Both Node entries scan the whole argument list for `-h`/`--help` BEFORE parsing
147
- (`bin/geneseed-cli.mjs`, `bin/geneseed-hook.mjs`), so `geneseed version --target --help` prints
148
- the help and exits 0. argparse consumes optionals left to right, so `--target` swallows `--help`
149
- as its value and the reference exits 2 with `argument --target: expected one argument`. Three
150
- error classes reach it — a missing value, an invalid `type=int` value (`web --port abc -h`), and a
151
- mutex conflict (`theme --solid-only --transparent-only --help`) — across 19 mandatory-value
152
- options on 13 verbs, plus `--memory` and `--root` on the hook entry.
153
-
154
- **Why declined.** On a malformed command line the two answers are "help, exit 0" and "the error,
155
- exit 2". Neither takes an action; nothing is written, nothing is destroyed. A scan that stopped at
156
- the first option expecting a value would have to reproduce argparse's consumption order to be
157
- right, and that order is the piece of the parser this port deliberately does not carry (see
158
- argparse's ERROR wording above). Printing help for a line that contains `--help` is the answer a
159
- user is more likely to have wanted, and it is the same answer for every shape of malformed line.
160
-
161
- **How it stays honest.** `tests/snapshot/cli_help.test.mjs`'s `a --help anywhere in the line wins` used to
162
- docblock this as "argparse's own rule". It is not — that generalised from `diff --nope --help`,
163
- which works only because an UNKNOWN option is deferred to the end of `parse_known_args`. Two of
164
- that test's four cases are cases where the reference errors instead, so the test pins a
165
- divergence; its docblock now says which cases those are and points here.
166
-
167
- ## A verb that postdates the recorder is exempted from the cell matrix by name
168
-
169
- **Status:** ACCEPTED, 2026-08-19. This widens an existing exemption rather than minting a second
170
- one, and it changes a TEST, not the product.
171
-
172
- **What differs.** `tests/unit/hook_cli.test.mjs`'s `the matrix covers every verb each entry
173
- claims` asserted a strict equality between the hook verbs `tests/helpers/matrix/cli.*.json`
174
- covers and the verbs `bin/geneseed-hook.mjs` carries. The CLI half of the same test already
175
- exempted a named `NATIVE` list; the hook half exempted nothing. Both halves now filter through
176
- the same list.
177
-
178
- **Why the recording could not be kept as the whole answer.** The matrix was exported by the
179
- reference implementation, which was deleted on 2026-08-17 along with every recorder that could
180
- write a cell. So the covered set is frozen at four hook verbs for good. A fifth hook verb —
181
- wired in `js/settings.mjs`, carried in `bin/geneseed-hook.mjs`, declared in `js/cli-table.json` —
182
- would fail this equality with **no green path**: the corpus cannot grow, and the failure message
183
- correctly forbids deleting cells to pass. The gate had become a wall, and a wall that stops a
184
- legitimate change is not protection, it is an accident of when the recorder died.
185
-
186
- The reason the three CLI verbs were exempted in the first place — *the reference had nothing to
187
- compare against, and authoring one would be recording a copy of the value under test* — is
188
- exactly the reason a verb that postdates the reference has no cell. Same reason, same exemption.
189
-
190
- **What was lost, measured.** Nothing that another gate does not already hold. The only property
191
- the frozen hook half carried beyond `NATIVE` was *"a hook verb cannot be removed by a coordinated
192
- edit of three files"* — and `tests/unit/hook_cli.test.mjs`'s own literal (`sorted(wired)` against
193
- the four names) plus its `verbsOf(HOOK) === wired` equality are two editable copies of that same
194
- set, twelve lines above. `tests/mutate.mjs`'s M8 plants the one-file removal and is killed there,
195
- not here.
196
-
197
- **How it stays honest.** The exemption is a NAMED list, not a containment: a new verb has to be
198
- written into `NATIVE` deliberately, in two files that read each other's source, and the reverse
199
- check refuses an exemption for a verb neither entry point carries. So the next new verb still
200
- fails loudly until someone decides about it — which is the whole point of the original design.