geneseed 3.4.0 → 3.5.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 (164) hide show
  1. package/CHANGELOG.md +283 -0
  2. package/DESIGN.md +11 -14
  3. package/QUICKSTART.md +3 -1
  4. package/README.md +51 -42
  5. package/SETUP.md +25 -27
  6. package/SHIPPED.md +26 -32
  7. package/adapters/bob/README.md +12 -9
  8. package/adapters/copilot/README.md +26 -25
  9. package/adapters/opencode/GLOBAL-HARNESS-SPEC.md +1 -1
  10. package/adapters/opencode/README.md +2 -2
  11. package/adapters/opencode/plugins/geneseed-context.js +5 -3
  12. package/adapters/opencode/plugins/geneseed-workflow.js +30 -3
  13. package/adapters/opencode/workflows/_runtime.js +97 -7
  14. package/bin/build-driver.mjs +5 -1248
  15. package/bin/geneseed-cli.mjs +11 -6
  16. package/bin/geneseed-hook.mjs +4 -2
  17. package/docs/candidate-hosts.md +1 -1
  18. package/docs/design-history.md +37 -37
  19. package/docs/extending.md +45 -24
  20. package/docs/token-footprint.md +52 -26
  21. package/docs/web/claude-hooks.md +3 -2
  22. package/docs/web/collaboration.md +2 -2
  23. package/docs/web/first-session.md +1 -1
  24. package/docs/web/footprint.md +2 -2
  25. package/docs/web/harnesses.md +1 -1
  26. package/docs/web/model.md +1 -1
  27. package/docs/web/plugin-workflow.md +1 -0
  28. package/docs/web/rules.md +3 -3
  29. package/docs/web/setup-choices.md +2 -2
  30. package/docs/web/themes-author.md +1 -1
  31. package/docs/web-ui.md +18 -10
  32. package/harness.config.json +1 -1
  33. package/js/README.md +6 -5
  34. package/js/build/driver.mjs +1191 -0
  35. package/js/build/emit-claude.mjs +17 -10
  36. package/js/build/emit-common.mjs +4 -4
  37. package/js/build/generate.mjs +1 -1
  38. package/js/build/render.mjs +48 -44
  39. package/js/build/stubs.mjs +1 -1
  40. package/js/build/themes.mjs +4 -4
  41. package/js/build/version.mjs +12 -0
  42. package/js/cli-table.json +21 -6
  43. package/js/hosts/hooks.mjs +89 -74
  44. package/js/hosts/hosts.mjs +19 -2
  45. package/js/hosts/mcp.mjs +14 -20
  46. package/js/hosts/native.mjs +23 -17
  47. package/js/hosts/settings.mjs +52 -104
  48. package/js/inspect/checks-authoring.mjs +117 -3
  49. package/js/inspect/diff.mjs +1 -1
  50. package/js/inspect/doctor.mjs +1 -1
  51. package/js/inspect/excludes.mjs +1 -1
  52. package/js/inspect/scan.mjs +3 -14
  53. package/js/inspect/status.mjs +2 -2
  54. package/js/inspect/validate.mjs +1 -1
  55. package/js/lib/fs.mjs +23 -8
  56. package/js/lib/json.mjs +7 -1
  57. package/js/lib/proc.mjs +6 -4
  58. package/js/maintain/migrate.mjs +1 -1
  59. package/js/maintain/setup.mjs +1 -1
  60. package/js/maintain/uninstall.mjs +17 -15
  61. package/js/ui/cli.mjs +19 -0
  62. package/js/ui/menu.mjs +1 -1
  63. package/js/web/actions.mjs +4 -4
  64. package/js/web/activity.mjs +4 -4
  65. package/js/web/api.mjs +14 -11
  66. package/js/web/daemon.mjs +1 -1
  67. package/js/web/docs.mjs +12 -12
  68. package/js/web/handler.mjs +12 -5
  69. package/js/web/jobs.mjs +1 -1
  70. package/package.json +1 -1
  71. package/src/AGENT.md.tmpl +69 -36
  72. package/src/agents/developer.md +2 -2
  73. package/src/agents/reviewer.md +5 -2
  74. package/src/doctrines/README.md +26 -3
  75. package/src/doctrines/craft.md +78 -5
  76. package/src/doctrines/ops.md +62 -0
  77. package/src/doctrines/process.md +91 -4
  78. package/src/doctrines/rigor.md +40 -0
  79. package/src/laws/universal.md +27 -21
  80. package/src/memory/README.md +4 -2
  81. package/src/modes/foreman.md +5 -5
  82. package/src/notebook/README.md +2 -1
  83. package/src/ontology/universal.md +71 -67
  84. package/src/postures/peer.md +3 -0
  85. package/src/skills/bruno-collection-generator.md +1 -1
  86. package/src/skills/bruno-test-writer.md +2 -2
  87. package/src/skills/daydream/LICENSE +21 -0
  88. package/src/skills/daydream/VENDOR.md +3 -1
  89. package/src/skills/geneseed-code-review.md +6 -2
  90. package/src/skills/parallel-agents.md +3 -3
  91. package/src/skills/pipeline.md +4 -4
  92. package/src/skills/react-view-transitions/VENDOR.md +7 -1
  93. package/src/skills/workflow.md +12 -4
  94. package/themes/_TEMPLATE.json +6 -4
  95. package/themes/biker.json +5 -3
  96. package/themes/commentator.json +5 -3
  97. package/themes/cyberpunk.json +5 -3
  98. package/themes/gamer.json +5 -3
  99. package/themes/imperial.json +5 -3
  100. package/themes/joker.json +5 -3
  101. package/themes/marvin.json +5 -3
  102. package/themes/mean.json +5 -3
  103. package/themes/military.json +5 -3
  104. package/themes/neutral.json +6 -4
  105. package/themes/opencode/README.md +2 -2
  106. package/themes/pirate.json +5 -3
  107. package/themes/sports.json +5 -3
  108. package/themes/verstappen.json +5 -3
  109. package/themes/wizard.json +5 -3
  110. package/web/dist/assets/Activity-Flgf4kHy.js +1 -0
  111. package/web/dist/assets/ActivityDetail-DIpr85nh.js +1 -0
  112. package/web/dist/assets/CatalogRow-CjyLjaLb.js +1 -0
  113. package/web/dist/assets/Diff-Deisdha8.js +2 -0
  114. package/web/dist/assets/Docs-DGUdY4l4.js +2 -0
  115. package/web/dist/assets/Doctor-F74lJ69P.js +1 -0
  116. package/web/dist/assets/FilterInput-BakJLvaV.js +1 -0
  117. package/web/dist/assets/GreenhouseView-D072lBiD.js +2 -0
  118. package/web/dist/assets/Harness-DNRP2Va7.js +1 -0
  119. package/web/dist/assets/JournalView-DWFqZ0Iw.js +1 -0
  120. package/web/dist/assets/Laws-BN6g3HU7.js +3 -0
  121. package/web/dist/assets/Library-CDOv4emM.js +1 -0
  122. package/web/dist/assets/Markdown-BQAvN8xx.js +38 -0
  123. package/web/dist/assets/OperatorHudView-CwNIUVX8.js +2 -0
  124. package/web/dist/assets/Profile-CpP9BTne.js +1 -0
  125. package/web/dist/assets/Rules-DFXsUCB7.js +1 -0
  126. package/web/dist/assets/Settings-BqZVUtJ5.js +1 -0
  127. package/web/dist/assets/Skills-Cu6kBgfF.js +1 -0
  128. package/web/dist/assets/StatusBadge-Cg6j-NJL.js +1 -0
  129. package/web/dist/assets/activity-iWaWVtsR.js +1 -0
  130. package/web/dist/assets/drawable-BgfrEBer.js +1 -0
  131. package/web/dist/assets/index-CAqrWhKl.js +2 -0
  132. package/web/dist/assets/index-Ruh93A3A.css +1 -0
  133. package/web/dist/assets/jobBuckets-Dbq8ZlWW.js +1 -0
  134. package/web/dist/assets/lawCats-BLgxl151.js +1 -0
  135. package/web/dist/assets/react-DbkvxWG5.js +8 -0
  136. package/web/dist/assets/rolldown-runtime-CbXtAM7H.js +1 -0
  137. package/web/dist/index.html +4 -3
  138. package/web/src/pages/Laws.jsx +22 -8
  139. package/src/skills/react-view-transitions/README.md +0 -42
  140. package/web/dist/assets/Activity-DMGhhjQc.js +0 -1
  141. package/web/dist/assets/ActivityDetail-CeQcDA7e.js +0 -1
  142. package/web/dist/assets/CatalogRow-CnSbDPyg.js +0 -1
  143. package/web/dist/assets/Diff-D_ksMBD1.js +0 -5
  144. package/web/dist/assets/Doctor-Brb3_oCT.js +0 -1
  145. package/web/dist/assets/FilterInput-DbRWPhtT.js +0 -1
  146. package/web/dist/assets/GreenhouseView-F3VNFr73.js +0 -2
  147. package/web/dist/assets/Harness-DJSwfYeF.js +0 -1
  148. package/web/dist/assets/JournalView-BLRYRnB3.js +0 -1
  149. package/web/dist/assets/Laws-CKYns9qe.js +0 -3
  150. package/web/dist/assets/Library-BK0YloSv.js +0 -1
  151. package/web/dist/assets/Markdown-BzTRzc7a.js +0 -46
  152. package/web/dist/assets/OperatorHudView-D3OQSGp5.js +0 -2
  153. package/web/dist/assets/Profile-CPA2u_0g.js +0 -1
  154. package/web/dist/assets/Rules-Lukel-Ru.js +0 -1
  155. package/web/dist/assets/Skills-BKRLHH7m.js +0 -1
  156. package/web/dist/assets/StatusBadge-DI3weRKd.js +0 -1
  157. package/web/dist/assets/activity-C_H2Bx1P.js +0 -1
  158. package/web/dist/assets/index-B7_Wga23.js +0 -2
  159. package/web/dist/assets/index-DzdQjvM1.css +0 -1
  160. package/web/dist/assets/index-N8dTrVro.js +0 -1
  161. package/web/dist/assets/index-Py_IQjgK.js +0 -10
  162. package/web/dist/assets/jobBuckets-DfTUJUxR.js +0 -1
  163. package/web/dist/assets/lawCats-DUS9UTH4.js +0 -1
  164. package/web/dist/assets/react-DOpiTn5v.js +0 -32
@@ -90,18 +90,22 @@ const consentRuleOn = (d, excluded = []) =>
90
90
  * doctrine section — and a boundary that keeps asking for a rule the install did not adopt is
91
91
  * the one disagreement the tiering is not allowed to produce. (It does NOT follow that the
92
92
  * string is gone from the bundle; see `claudeHookGroups` for what D5 ships anyway and why.)
93
- * `rm -rf *`, `git push --force*` and `git push -f*` stay in EVERY build: they are
94
- * Law IV's territory — an always-on invariant — not the process pack's, and the guard
95
- * plugin's own force-push arm is unconditional for the same reason.
93
+ * `rm -rf *`, the force pushes and the history-discarding git verbs (`reset --hard`,
94
+ * `clean -f`, `branch -D`, `checkout --`) stay in EVERY build: they are Law IV's territory —
95
+ * an always-on invariant — not the process pack's. They mirror `DESTRUCTIVE_GIT_RE` in
96
+ * js/hosts/hooks.mjs, so Claude's git-gate and OpenCode's permission ask about the same acts;
97
+ * before, OpenCode only logged the last four.
96
98
  */
99
+ const LAW_IV_BASH = ['git push --force*', 'git push -f*', 'git reset --hard*', 'git clean -f*',
100
+ 'git branch -D*', 'git checkout -- *'];
101
+
97
102
  function defaultPermission(doctrines = null, excluded = []) {
98
103
  const bash = { 'rm -rf *': 'ask' };
99
104
  if (consentRuleOn(doctrines, excluded)) {
100
105
  bash['git commit*'] = 'ask';
101
106
  bash['git push*'] = 'ask';
102
107
  }
103
- bash['git push --force*'] = 'ask';
104
- bash['git push -f*'] = 'ask';
108
+ for (const k of LAW_IV_BASH) bash[k] = 'ask';
105
109
  return { bash };
106
110
  }
107
111
 
@@ -112,7 +116,7 @@ function defaultPermission(doctrines = null, excluded = []) {
112
116
  * has to be able to recognise a key that is Geneseed's business at all — to add it back when
113
117
  * the pack returns, and to name it when this build no longer wants it but will not remove it.
114
118
  */
115
- const OWNED_BASH = ['rm -rf *', 'git commit*', 'git push*', 'git push --force*', 'git push -f*'];
119
+ const OWNED_BASH = ['rm -rf *', 'git commit*', 'git push*', ...LAW_IV_BASH];
116
120
 
117
121
  /**
118
122
  * Bring an ALREADY-WRITTEN `permission` block back into line with the pack selection, and
@@ -639,15 +643,16 @@ export function hookPrefix({ runner, entry, platform = process.platform } = {})
639
643
  /**
640
644
  * `_build_settings._claude_hook_groups` — Geneseed's Claude hooks, keyed by event.
641
645
  *
642
- * ⚠ `doctrines` IS WHERE PROMPT AND BOUNDARY ARE KEPT IN AGREEMENT. The `git-gate` group is
643
- * the tool-boundary half of doctrine process 5. With the process pack off, that rule is no
644
- * longer BINDING — `src/doctrines/process.md` is not rendered into the carrier's doctrine
645
- * section and nothing in the constitution obliges the agent to it — so a gate that stopped
646
- * every Bash call to ask for consent to a rule the install did not adopt is the disagreement
647
- * the three-tier split forbids. Omitting the group here is also what UNWIRES it from an
648
- * install that had it: `mergeClaudeSettings` prunes every previously-managed group that is not
649
- * in this return value, so flipping the pack off and rebuilding takes the hook out of
650
- * settings.json rather than leaving it orphaned.
646
+ * ⚠ `doctrines` IS WHERE PROMPT AND BOUNDARY ARE KEPT IN AGREEMENT. The `git-gate` group
647
+ * carries TWO rules: Law IV (destructive git — universal, every build) and doctrine process 5
648
+ * (consent before commit/push). With the process pack off, or process 5 excluded, that second
649
+ * rule is no longer BINDING — so a gate that stopped every commit to ask for consent to a rule
650
+ * the install did not adopt is the disagreement the three-tier split forbids. The group stays
651
+ * and gains `--no-consent`, which skips only the process-5 branch. It once DROPPED the whole
652
+ * group instead, and Law IV went with it — on Claude only, since Bob and Copilot carry it
653
+ * through `tool-gate`. The command changing is also what re-wires an existing install:
654
+ * `mergeClaudeSettings` prunes every previously-managed group that is not in this return
655
+ * value.
651
656
  *
652
657
  * ⚠ WHAT THIS DOES **NOT** CLAIM, because it was written here once and it was false: that the
653
658
  * string `process 5` is absent from a pack-off bundle. It is not — 18 emitted files still cite
@@ -695,14 +700,29 @@ export function claudeHookGroups(cfg, hookOpts, doctrines = null, excluded = [],
695
700
  Stop: [{ hooks: [{ type: 'command', command: `${run} learn ${mem} || exit 0` }] }],
696
701
  };
697
702
  }
703
+ if (host === 'copilot') {
704
+ // COPILOT'S OWN SCHEMA (docs.github.com/en/copilot/reference/hooks-configuration): camelCase
705
+ // events, each an ARRAY of `{type: "command", command}` entries — no matcher groups, so the
706
+ // array elements ARE the groups this merge appends and prunes. `preToolUse` answers with
707
+ // a top-level `permissionDecision` (`--host copilot`), and `agentStop`/`preCompact` carry
708
+ // `transcriptPath`, so `learn` runs here as on Claude. No matcher, so `tool-gate` reads
709
+ // the payload's shape. Unverified live: no Copilot CLI on the authoring machine.
710
+ const c = ' --host copilot';
711
+ const entry = (command) => [{ type: 'command', command }];
712
+ return {
713
+ sessionStart: entry(`${run} context --root "${cfg}"${c} || exit 0`),
714
+ preToolUse: entry(`${run} tool-gate --root "${cfg}"${c}`),
715
+ agentStop: entry(`${run} learn ${mem} || exit 0`),
716
+ preCompact: entry(`${run} learn ${mem} || exit 0`),
717
+ };
718
+ }
698
719
  const context = `${run} context --root "${cfg}" || exit 0`;
699
- const gate = `${run} git-gate --root "${cfg}"`;
720
+ const gate = `${run} git-gate --root "${cfg}"${consentRuleOn(doctrines, excluded) ? '' : ' --no-consent'}`;
700
721
  const ruleGate = `${run} rule-gate --root "${cfg}"`;
701
722
  const learn = `${run} learn ${mem} || exit 0`;
702
723
  return {
703
724
  PreToolUse: [
704
- ...(consentRuleOn(doctrines, excluded)
705
- ? [{ matcher: 'Bash', hooks: [{ type: 'command', command: gate }] }] : []),
725
+ { matcher: 'Bash', hooks: [{ type: 'command', command: gate }] },
706
726
  {
707
727
  matcher: 'Write|Edit|MultiEdit|NotebookEdit',
708
728
  hooks: [{ type: 'command', command: ruleGate }],
@@ -731,90 +751,11 @@ export function claudeHookGroups(cfg, hookOpts, doctrines = null, excluded = [],
731
751
  }
732
752
 
733
753
  /**
734
- * Geneseed's Copilot hooks, keyed by Copilot's event names.
735
- *
736
- * COPILOT'S SETTINGS ARE NOT CLAUDE'S. `~/.copilot/settings.json` carries `hooks` as a map of
737
- * event → ONE `{command, shell?, timeout?}` object, not event → list of matcher groups, so
738
- * nothing in `claudeHookGroups`/`mergeClaudeSettings` can be reused for it: there is no
739
- * array to append to, and a second Geneseed group per event is not expressible. Two hooks:
740
- *
741
- * * `sessionStart` → `context --host copilot`: the same project-context injection Claude
742
- * gets, in Copilot's `{"additionalContext"}` envelope.
743
- * * `toolCall` → `tool-gate --host copilot`: both gates behind the one command the event
744
- * allows, block-only for Laws I/IV, warn for the rest (js/hosts/hooks.mjs's header).
745
- *
746
- * No `sessionEnd`/`agentStop` → `learn`: Copilot's payloads for those carry no
747
- * `transcript_path`, so `learn` would have nothing to distil. The memory write-back stays on
748
- * the preamble for this host, as before.
749
- *
750
- * `|| exit 0` on the non-gate, as in `claudeHookGroups` and for the same reason.
751
- */
752
- export function copilotHooks(cfg, hookOpts) {
753
- const run = hookPrefix(hookOpts);
754
- return {
755
- sessionStart: { command: `${run} context --root "${cfg}" --host copilot || exit 0` },
756
- toolCall: { command: `${run} tool-gate --root "${cfg}" --host copilot` },
757
- };
758
- }
759
-
760
- /**
761
- * Wire Copilot's hooks into `p` — returns the complete current claim set, `[{event, hook}]`.
762
- *
763
- * ONE SLOT PER EVENT MEANS A FOREIGN HOOK CANNOT BE JOINED, ONLY DISPLACED — and displacing
764
- * a hook the user wrote is not Geneseed's to do. A slot is taken when it holds a hook that
765
- * is neither canonical nor a claim recorded by a previous emit; that event is skipped with a
766
- * warning and never claimed. A recorded stale claim (a moved shim) is replaced, as the Claude
767
- * merge prunes stale groups. Comments in the file mean it is not rewritten, as everywhere.
754
+ * LEGACY: the single-slot Copilot shape (`hooks.<event>` = ONE object, events `sessionStart` /
755
+ * `toolCall`) an emit wrote before Copilot's hooks became event → array. Copilot has no
756
+ * `toolCall` event any more, so those gates never ran. Nothing writes this shape now; these
757
+ * two only take an older install's recorded `copilot_hooks` claims back out.
768
758
  */
769
- export function mergeCopilotSettings(p, priorHooks = null, hookOpts = {}) {
770
- const prior = (priorHooks || []).filter(isDict);
771
- let config = {};
772
- let hadComments = false;
773
- if (existsSync(p)) {
774
- try {
775
- const [loaded, hc] = readJsonc(readText(p));
776
- hadComments = hc;
777
- if (isDict(loaded)) config = loaded;
778
- } catch (e) {
779
- if (!isOsError(e)) throw e;
780
- }
781
- }
782
- let hooks = get(config, 'hooks');
783
- if (!isDict(hooks)) hooks = {};
784
- const canonical = copilotHooks(path.dirname(p), hookOpts);
785
- const claims = [];
786
- let changed = false;
787
- for (const [event, hook] of Object.entries(canonical)) {
788
- const cur = get(hooks, event);
789
- const recorded = prior.find((r) => get(r, 'event') === event);
790
- if (isDict(cur) && deepEquals(cur, hook)) { claims.push({ event, hook }); continue; }
791
- if (isDict(cur) && !(recorded && deepEquals(recorded.hook, cur))) {
792
- process.stderr.write(`[geneseed] ${path.basename(p)}: hooks.${event} is already set and `
793
- + 'is not Geneseed\'s — left alone, so this event is not wired.\n');
794
- continue;
795
- }
796
- hooks[event] = hook;
797
- claims.push({ event, hook });
798
- changed = true;
799
- }
800
- // A recorded claim whose event is no longer canonical: unwire it, or it fires forever.
801
- for (const rec of prior) {
802
- const event = get(rec, 'event');
803
- if (event in canonical) continue;
804
- if (deepEquals(get(hooks, event), get(rec, 'hook'))) { delete hooks[event]; changed = true; }
805
- }
806
- if (!changed) return [p, claims];
807
- if (hadComments) {
808
- process.stderr.write(`[geneseed] ${path.basename(p)} has comments — not rewriting it `
809
- + "(your edits are kept). Add Geneseed's hooks by hand from adapters/copilot/README.md.\n");
810
- return [p, prior];
811
- }
812
- if (Object.keys(hooks).length) config.hooks = hooks;
813
- else delete config.hooks;
814
- atomicWriteJson(p, config);
815
- return [p, claims];
816
- }
817
-
818
759
  /** Remove exactly the recorded Copilot hooks from `p`. True when the file was rewritten. */
819
760
  export function unwireCopilotSettings(p, recorded) {
820
761
  if (!existsSync(p) || !recorded || !recorded.length) return false;
@@ -876,9 +817,15 @@ export function copilotIntegrityCheck(p, recorded, expect = 'present') {
876
817
  * `managed` is the complete current claim set, so unwire removes exactly those.
877
818
  *
878
819
  * `scope` is accepted and unused, exactly as in the Python.
820
+ *
821
+ * `cfgDir` is the install's own dir — what the hooks carry as `--root` and under which
822
+ * `memory/` lives. It is NOT always `dirname(p)`: Bob's global settings file is nested
823
+ * (`~/.bob/settings/settings.json`), and deriving the root from it pointed every global Bob
824
+ * hook at `~/.bob/settings` — memory learned where nothing reads it, excludes missed, and a
825
+ * global that never stood down for a project install.
879
826
  */
880
827
  export function mergeClaudeSettings(p, _scope = 'global', priorHooks = null, hookOpts = {},
881
- doctrines = null, excluded = [], host = 'claude') {
828
+ doctrines = null, excluded = [], host = 'claude', cfgDir = path.dirname(p)) {
882
829
  const prior = (priorHooks || []).filter(isDict);
883
830
  let config = {};
884
831
  let hadComments = false;
@@ -898,7 +845,7 @@ export function mergeClaudeSettings(p, _scope = 'global', priorHooks = null, hoo
898
845
  }
899
846
  let hooks = get(config, 'hooks');
900
847
  if (!isDict(hooks)) hooks = {};
901
- const canonical = claudeHookGroups(path.dirname(p), hookOpts, doctrines, excluded, host);
848
+ const canonical = claudeHookGroups(cfgDir, hookOpts, doctrines, excluded, host);
902
849
  const canonFlat = [];
903
850
  for (const [event, groups] of Object.entries(canonical)) {
904
851
  for (const g of groups) canonFlat.push({ event, group: g });
@@ -1231,7 +1178,8 @@ export function settingsIntegrityCheck(p, managed, expect = 'present') {
1231
1178
  for (const [event, group] of presentGroups) {
1232
1179
  const key = `${formatRepr(event)}\u0000${jsonDumpsCompact(group, { sortKeys: true })}`;
1233
1180
  if (recordedSet.has(key)) continue;
1234
- const cmds = (get(group, 'hooks') || []).filter(isDict)
1181
+ // A Claude group nests its commands under `hooks`; a Copilot entry IS the command.
1182
+ const cmds = [...(get(group, 'hooks') || []).filter(isDict), group]
1235
1183
  .map((h) => (has(h, 'command') ? h.command : ''));
1236
1184
  if (cmds.some((c) => typeof c === 'string'
1237
1185
  && GENESEED_HOOK_SNIFF.some((mk) => c.includes(mk)))) {
@@ -279,6 +279,91 @@ const DOCTRINE_CITE_RE = /\{\{DOCTRINE\}\}\s+([a-z]+)\s+(\d+)/g;
279
279
  const DOCTRINE_TOKEN_RE = /\{\{DOCTRINE\}\}/;
280
280
  const DOC_TOKEN_RE = /\{\{(DOC_[A-Z0-9_]+)\}\}/g;
281
281
 
282
+ /**
283
+ * The LEAN block grammar, and the heading split, both duplicated from `js/build/render.mjs`.
284
+ *
285
+ * Deliberately duplicated, the way `DOCTRINE_HEADING_RE` above is: both are module-private
286
+ * there, and exporting them so one gate could borrow them would widen the renderer's surface
287
+ * for a reader that only needs to know the shape. Two literals, one paragraph apart from the
288
+ * rule they encode, is cheaper than a seam.
289
+ */
290
+ const LEAN_BLOCK_RE =
291
+ /[ \t]*<!-- LEAN:begin -->\n([\s\S]*?)[ \t]*<!-- LEAN:else -->\n([\s\S]*?)[ \t]*<!-- LEAN:end -->\n/g;
292
+
293
+ /** `### ` at line start — the same cut `splitAtLawHeadings` makes, and for the same addresses. */
294
+ function leanRuleBlocks(text) {
295
+ const out = [];
296
+ let start = 0;
297
+ for (let i = 0; i < text.length; i++) {
298
+ if ((i === 0 || text[i - 1] === '\n') && text.startsWith('### ', i)) {
299
+ out.push(text.slice(start, i));
300
+ start = i;
301
+ }
302
+ }
303
+ out.push(text.slice(start));
304
+ return out.slice(1); // drop the lead chunk: the file's preamble
305
+ }
306
+
307
+ /**
308
+ * Every authored rule carries exactly one LEAN block, and both halves say something.
309
+ *
310
+ * ⚠ `resolveLean` IS A REPLACE OVER A MARKER PAIR, WHICH MAKES IT A NO-OP ON MARKERLESS TEXT.
311
+ * A rule whose block was never written, or lost to an edit, therefore ships its FULL body at
312
+ * the default footprint — silently, in every install, with every other gate in the tree green.
313
+ * That is the failure this arm exists for, and before it there was no LEAN gate anywhere in
314
+ * `js/inspect/`: not for the doctrine packs, and not for the invariants either.
315
+ *
316
+ * The other two shapes are cheaper to state than to debug. An empty lean half renders a
317
+ * heading over nothing at the footprint most installs use. Two blocks in one rule are
318
+ * ambiguous by construction — `LEAN_BLOCK_RE` is global, so both would resolve, and which
319
+ * text the reader gets would be a fact about the order they were written in.
320
+ *
321
+ * The ontology is exempt on purpose: it carries ONE file-wide block whose halves each repeat
322
+ * the four `####` section headings, which is a different shape with its own gate in
323
+ * `tests/unit/emit_smoke.test.mjs` (four distinct names, eight occurrences).
324
+ */
325
+ export function leanBlockProblems() {
326
+ const problems = [];
327
+ const files = [['laws', path.join(SRC, 'laws', 'universal.md')]];
328
+ for (const pack of PACK_ORDER) files.push(['doctrines', path.join(SRC, 'doctrines', `${pack}.md`)]);
329
+
330
+ for (const [dir, file] of files) {
331
+ const rel = `${dir}/${path.basename(file)}`;
332
+ let text;
333
+ try { text = readText(file); } catch (e) {
334
+ problems.push(`[authoring] ${rel} unreadable: ${e.message} — every rule in it would ship `
335
+ + 'its full body at the lean footprint, unannounced');
336
+ continue;
337
+ }
338
+ for (const block of leanRuleBlocks(text)) {
339
+ // The address as the SOURCE spells it — `{{LAW}} II`, `{{DOCTRINE}} craft 1` — because
340
+ // that is the string an author greps for, and the rendered spelling is fourteen strings.
341
+ const heading = block.slice(0, block.indexOf('\n') === -1 ? undefined : block.indexOf('\n'));
342
+ const addr = heading.replace(/^###\s+/, '').split(/\s+[—-]\s+/)[0].trim();
343
+ const found = [...block.matchAll(LEAN_BLOCK_RE)];
344
+ if (found.length === 0) {
345
+ problems.push(`[authoring] ${rel}: ${addr} has no LEAN block — resolveLean is a no-op on `
346
+ + 'markerless text, so the rule would inline its FULL body at the default footprint');
347
+ continue;
348
+ }
349
+ if (found.length > 1) {
350
+ problems.push(`[authoring] ${rel}: ${addr} has ${found.length} LEAN blocks — both resolve, `
351
+ + 'so which text a reader is handed is a fact about the order they were written in');
352
+ }
353
+ const [, full, lean] = found[0];
354
+ if (!full.trim()) {
355
+ problems.push(`[authoring] ${rel}: ${addr} has an empty full half — the rule would vanish `
356
+ + 'from a full build and from its own on-disk file');
357
+ }
358
+ if (!lean.trim()) {
359
+ problems.push(`[authoring] ${rel}: ${addr} has an empty lean half — the rule would render `
360
+ + 'as a heading over nothing at the footprint most installs use');
361
+ }
362
+ }
363
+ }
364
+ return problems;
365
+ }
366
+
282
367
  /**
283
368
  * The three-tier constitution's own authoring gates — the doctrine twin of the `LAW_CLASS` /
284
369
  * `LAW_META` family, plus the two purity rules the tiering rests on.
@@ -292,7 +377,7 @@ const DOC_TOKEN_RE = /\{\{(DOC_[A-Z0-9_]+)\}\}/g;
292
377
  * ⚠ THE VOCABULARY GATES ARE HERE BECAUSE `themeParityProblems` CANNOT SEE THEM. That check is
293
378
  * presence-only and SYMMETRIC over the union of every theme's keys: a key missing from all
294
379
  * fourteen is in parity, and a key whose VALUE breaks a parser is not its business at all. So
295
- * `DOC_*` coverage, the `LEX_I..LEX_IX` equality and the single-word tier noun each need a gate
380
+ * `DOC_*` coverage, the `LEX_I..LEX_XI` equality and the single-word tier noun each need a gate
296
381
  * that reads the SOURCE and asks what the source requires.
297
382
  */
298
383
  export function constitutionProblems() {
@@ -378,6 +463,18 @@ export function constitutionProblems() {
378
463
  // The whole tree, not the template: a skill footer citing `process 9` is as broken as a
379
464
  // template one, and it is the shape 339 rewired citations could have left behind.
380
465
  const cited = new Map(); // 'process.5' -> [rel, …]
466
+ // ⚠ A RETIRED LAW KEEPS ITS NUMBER, SO A CITATION OF IT STILL RESOLVES — to "Retired". Laws
467
+ // are appended, never renumbered, which is what keeps ~150 cross-references stable; the price
468
+ // is that nothing else notices `{{LAW}} IX` in a skill that means "ask first" (it is rigor 5
469
+ // now) or `{{LAW}} XI` that means "verify" (folded into III). Read off the canon itself, so a
470
+ // law retired tomorrow is covered the same day.
471
+ const retired = new Set();
472
+ try {
473
+ for (const [, num] of readText(path.join(SRC, 'laws', 'universal.md'))
474
+ .matchAll(/^### \{\{LAW\}\} ([IVXLCDM]+) —[^\n]*\n<!-- LEAN:begin -->\nRetired\b/gm)) {
475
+ retired.add(num);
476
+ }
477
+ } catch { /* an unreadable canon is reported elsewhere */ }
381
478
  for (const p of rglob(SRC)) {
382
479
  if (!isFile(p) || !(p.endsWith('.md') || p.endsWith('.tmpl'))) continue;
383
480
  let text;
@@ -392,6 +489,14 @@ export function constitutionProblems() {
392
489
  + 'reference a toggleable one, or a pack-off build ships a rule pointing at text '
393
490
  + 'that is not there. State the point directly instead');
394
491
  }
492
+ if (rel !== 'laws/universal.md') {
493
+ for (const [, num] of text.matchAll(/\{\{LAW\}\}\s+([IVXLCDM]+)\b/g)) {
494
+ if (retired.has(num)) {
495
+ problems.push(`[authoring] ${rel} cites {{LAW}} ${num}, which is retired — cite the `
496
+ + 'law or rule its text now lives in');
497
+ }
498
+ }
499
+ }
395
500
  for (const [, pack, n] of text.matchAll(DOCTRINE_CITE_RE)) {
396
501
  const key = `${pack}.${Number(n)}`;
397
502
  if (!cited.has(key)) cited.set(key, []);
@@ -583,9 +688,17 @@ export function countTableProblems() {
583
688
  }
584
689
  problems.push(...lawMetaProblems(lawNums, LAW_CLASS, LAW_CLASSES));
585
690
 
691
+ // WHAT SHIPS, NOT WHAT IS FLAT. A folder skill (`src/skills/<name>/SKILL.md`, vendored with
692
+ // its scripts or references) ships exactly like a flat one, so the badge, the README list and
693
+ // the SHIPPED.md triple count it. It is added HERE rather than in `srcStems`, whose other
694
+ // readers above — the AGENT.md table links and SKILL_CLASS — are about flat specs only.
695
+ const skillsDir = path.join(SRC, 'skills');
696
+ const shippedSkills = new Set([...skillFiles, ...(isDir(skillsDir) ? readdirSync(skillsDir) : [])
697
+ .filter((d) => !d.startsWith('_') && isFile(path.join(skillsDir, d, 'SKILL.md')))]);
698
+
586
699
  const counts = {
587
700
  agents: srcStems('agents').size,
588
- skills: srcStems('skills').size,
701
+ skills: shippedSkills.size,
589
702
  laws: lawNums.length,
590
703
  themes: themeFiles().length,
591
704
  // Last on purpose: the badge loop below walks this object in insertion order and the
@@ -656,7 +769,7 @@ export function countTableProblems() {
656
769
  problems.push('[authoring] SHIPPED.md carries no \'N laws, N agents, N skills\' line — '
657
770
  + 'deleting it is how that gate goes green while the count drifts');
658
771
  }
659
- problems.push(...proseMirrorProblems(readme, web, counts, skillFiles, shipped));
772
+ problems.push(...proseMirrorProblems(readme, web, counts, shippedSkills, shipped));
660
773
  return problems;
661
774
  }
662
775
 
@@ -756,6 +869,7 @@ export function authoringProblems() {
756
869
  problems.push(...secretProblems());
757
870
  problems.push(...vendorPinProblems());
758
871
  problems.push(...constitutionProblems());
872
+ problems.push(...leanBlockProblems());
759
873
  problems.push(...countTableProblems());
760
874
  return problems;
761
875
  }
@@ -40,7 +40,7 @@ import { existsSync, mkdirSync, mkdtempSync, readdirSync, rmSync, statSync } fro
40
40
  import os from 'node:os';
41
41
  import path from 'node:path';
42
42
 
43
- import { emitGlobalInto } from '../../bin/build-driver.mjs';
43
+ import { emitGlobalInto } from '../build/driver.mjs';
44
44
  import { GLOBAL_MANIFEST, VERSION_MARKER, expanduser, opencodeConfigDir } from '../hosts/hosts.mjs';
45
45
  import {
46
46
  EMIT_HOST_SCOPE, defaultTheme, doctrinesForBuild, footprintOfDir, modeOfDir, postureOfDir,
@@ -30,7 +30,7 @@
30
30
  * keep the Doctor page's per-check cards a parameter rather than a rewrite.
31
31
  */
32
32
  import path from 'node:path';
33
- import { emitGlobalInto, emitProjectInto, main as driverMain } from '../../bin/build-driver.mjs';
33
+ import { emitGlobalInto, emitProjectInto, main as driverMain } from '../build/driver.mjs';
34
34
  import { ROOT } from '../build/source.mjs';
35
35
  import { resolvePath } from '../hosts/hosts.mjs';
36
36
  import { installedDefaults, themeFiles } from '../hosts/installs.mjs';
@@ -244,7 +244,7 @@ export function cmdExclude(args) {
244
244
  for (const rec of snap.excludes) {
245
245
  const missing = [...allHosts].filter((h) => !rec.hosts.includes(h)).sort();
246
246
  const flag = missing.length
247
- ? ` (MISSING from: ${missing.join(', ')} — re-run \`harness exclude add\`)` : '';
247
+ ? ` (MISSING from: ${missing.join(', ')} — re-run \`geneseed exclude add\`)` : '';
248
248
  printOut(` ${rec.path} [${rec.hosts.slice().sort().join(', ')}]${flag}\n`);
249
249
  }
250
250
  return 0;
@@ -11,7 +11,7 @@
11
11
  import os from 'node:os';
12
12
  import path from 'node:path';
13
13
  import { SRC } from '../build/source.mjs';
14
- import { comparePaths, normcase } from '../lib/paths.mjs';
14
+ import { comparePaths, within } from '../lib/paths.mjs';
15
15
  import { isDir, isFile } from '../lib/fs.mjs';
16
16
  import { has } from '../lib/json.mjs';
17
17
  import { mkdtempSync, readdirSync, rmSync } from 'node:fs';
@@ -41,19 +41,8 @@ export function stripCode(text) {
41
41
  return text.replace(FENCE_RE, '').replace(COMMENT_RE, '').replace(INLINE_CODE_RE, '');
42
42
  }
43
43
 
44
- /**
45
- * `_harness_core._within` — `child.relative_to(parent)` without the exception.
46
- *
47
- * Segment-wise and through `normcase`, because that is what `PurePath` comparison does: a
48
- * bundle at `C:\Temp\X` contains `c:\temp\x\a.md` on Windows and does not on Linux. A
49
- * `startsWith` on the raw strings would additionally call `/tmp/bundle2` a child of
50
- * `/tmp/bundle`, which is the classic version of this bug.
51
- */
52
- export function within(child, parent) {
53
- const c = normcase(child).split(/[\\/]/);
54
- const p = normcase(parent).split(/[\\/]/);
55
- return p.length <= c.length && p.every((seg, i) => c[i] === seg);
56
- }
44
+ // `within` — owned by js/lib/paths.mjs; re-exported for this module's importers.
45
+ export { within };
57
46
 
58
47
  /** `sorted(d.glob(pat))`, or `[]` for a directory that is not there. */
59
48
  export function globSorted(dir, filter) {
@@ -62,7 +62,7 @@ import { tuiInventory } from './inventory.mjs';
62
62
  import { readVersion, sourceFingerprint } from '../build/version.mjs';
63
63
  import {
64
64
  claudeConfigDir, bobConfigDir, copilotConfigDir, opencodeConfigDir, resolvePath,
65
- resolveMemoryDir, sovereignBypass,
65
+ resolveMemoryDir, sovereignBypass, GATE_LEDGER,
66
66
  } from '../hosts/hosts.mjs';
67
67
  // P5f moved the install DETECTORS out of this file — `diff` renders its expected copy in the
68
68
  // deployed theme and footprint, and `rebuild-all` re-emits in the deployed everything, so
@@ -194,7 +194,7 @@ export function gateSummary(cfgDirs) {
194
194
  if (sovereignBypass(cfg)) standingDown.push(String(cfg));
195
195
  let raw;
196
196
  try {
197
- raw = readFileSync(path.join(cfg, 'notebook', 'gates.jsonl'), 'utf8');
197
+ raw = readFileSync(path.join(cfg, GATE_LEDGER), 'utf8');
198
198
  } catch {
199
199
  continue;
200
200
  }
@@ -7,7 +7,7 @@
7
7
  * reached — see its row in `tests/ported.json`.
8
8
  */
9
9
  import path from 'node:path';
10
- import { buildInto, emitGlobalInto, emitProjectInto } from '../../bin/build-driver.mjs';
10
+ import { buildInto, emitGlobalInto, emitProjectInto } from '../build/driver.mjs';
11
11
  import { resolvePath } from '../hosts/hosts.mjs';
12
12
  import { validateIsVendored } from '../hosts/native.mjs';
13
13
  import { printOut, printErr, readText } from '../lib/fs.mjs';
package/js/lib/fs.mjs CHANGED
@@ -202,19 +202,34 @@ export function withPlatformNewlines(fn) {
202
202
  * it for its own existing callers rather than owning a copy itself. `js/hosts/hooks.mjs` is
203
203
  * the deliberate exception — the hot path keeps its own inline copy rather than pay an import.
204
204
  *
205
- * `statSync`'s Node default THROWS for a missing path; `throwIfNoEntry: false` (Node >= 15.3)
206
- * is the one option that turns ONLY that case into `undefined`, and still throws for anything
207
- * else — a permission failure, a path with a non-directory ancestor (`ENOTDIR`). Every copy
208
- * this replaces used a bare `try/catch` instead, which swallowed ALL of those alike into the
209
- * same `false`. That is a real, deliberate narrowing: a permission failure silently read as
210
- * "not there" was the wrong answer to preserve, not a byte worth keeping.
205
+ * WHICH ERRORS MEAN "NO", matching `pathlib`'s `_ignore_error`: `ENOENT` (nothing there),
206
+ * `ENOTDIR` (a path THROUGH a file — `skills/brainstorm.md/SKILL.md`), `ELOOP`, `EBADF`. Each is a
207
+ * definite answer that nothing of that kind is at the path. Anything else — a permission
208
+ * failure above all — still throws: read as "not there" it would be the wrong answer, silently.
209
+ * Every copy this replaced used a bare `try/catch` that swallowed everything alike.
210
+ *
211
+ * ⚠ `ENOTDIR` WAS ONCE LEFT TO THROW here, on the reading that it resembled an access error.
212
+ * It is not one, and Windows reports the same probe as `ENOENT` — so the difference only shows
213
+ * on macOS/Linux: the folder-skill count asked `isFile(<skills>/<flat>.md/SKILL.md)` and the
214
+ * web console's overview died with `ENOTDIR` there while every Windows run stayed green.
211
215
  */
216
+ const NOT_THERE = new Set(['ENOENT', 'ENOTDIR', 'ELOOP', 'EBADF']);
217
+
218
+ function statOrNull(p) {
219
+ try {
220
+ return statSync(p);
221
+ } catch (e) {
222
+ if (e && NOT_THERE.has(e.code)) return null;
223
+ throw e;
224
+ }
225
+ }
226
+
212
227
  export function isFile(p) {
213
- return statSync(p, { throwIfNoEntry: false })?.isFile() ?? false;
228
+ return statOrNull(p)?.isFile() ?? false;
214
229
  }
215
230
 
216
231
  export function isDir(p) {
217
- return statSync(p, { throwIfNoEntry: false })?.isDirectory() ?? false;
232
+ return statOrNull(p)?.isDirectory() ?? false;
218
233
  }
219
234
 
220
235
  /**
package/js/lib/json.mjs CHANGED
@@ -248,8 +248,14 @@ export function has(obj, key) {
248
248
  return Object.hasOwn(obj, key);
249
249
  }
250
250
 
251
+ /**
252
+ * `isinstance(x, dict)` for a value that came out of `parseJson`. Numbers come back WRAPPED
253
+ * (`JsonNumber`), so `typeof v === 'object'` alone says yes to every number — and a settings
254
+ * file with `"permission": 5` then read as a dict the merge could write into, silently
255
+ * skipping the git gate. The one exclusion is the wrapper; everything else is unchanged.
256
+ */
251
257
  export function isDict(v) {
252
- return Boolean(v) && typeof v === 'object' && !Array.isArray(v);
258
+ return Boolean(v) && typeof v === 'object' && !Array.isArray(v) && !(v instanceof JsonNumber);
253
259
  }
254
260
 
255
261
  /**
package/js/lib/proc.mjs CHANGED
@@ -20,9 +20,11 @@
20
20
  * CAPTURING SPAWNS ONLY, and the asymmetry is deliberate. The rule was measured on Windows:
21
21
  * a process that sets `STARTF_USESTDHANDLES` only when a stream is redirected gives an
22
22
  * INHERITING child spawned with `CREATE_NO_WINDOW` a fresh hidden console and discards
23
- * everything it prints — `geneseed build > log.txt` wrote an empty file. libuv always passes
24
- * the handles explicitly, so the flag on an inheriting spawn would be harmless here; it stays
25
- * off anyway, because an inheriting child of a console-owning parent shows no window in the
26
- * first place, so it would buy nothing and re-open a failure mode already paid for once.
23
+ * everything it prints — `geneseed build > log.txt` wrote an empty file. That was Python's
24
+ * `CREATE_NO_WINDOW` creation flag. Node's `windowsHide` is a different mechanism, and libuv
25
+ * always passes the handles explicitly, so on an INHERITING spawn it is harmless: the child
26
+ * writes to the parent's handles. A few inheriting sites set it (`js/hosts/link.mjs`,
27
+ * `js/maintain/update.mjs`) and are correct — `tests/unit/spawn_hygiene.test.mjs` explains why
28
+ * it asserts no inverse rule.
27
29
  */
28
30
  export const NO_WINDOW = { windowsHide: process.platform === 'win32' };
@@ -34,7 +34,7 @@
34
34
  import { copyFileSync, existsSync, mkdirSync, readFileSync, rmSync } from 'node:fs';
35
35
  import path from 'node:path';
36
36
 
37
- import { main as driverMain } from '../../bin/build-driver.mjs';
37
+ import { main as driverMain } from '../build/driver.mjs';
38
38
  import { ROOT } from '../build/source.mjs';
39
39
  import {
40
40
  EMIT_HOST_SCOPE, defaultMode, defaultPosture, defaultTheme, doctrinesForBuild,
@@ -44,7 +44,7 @@ import { spawnSync } from 'node:child_process';
44
44
  import { existsSync, readSync } from 'node:fs';
45
45
  import path from 'node:path';
46
46
 
47
- import { main as driverMain, resolveOut } from '../../bin/build-driver.mjs';
47
+ import { main as driverMain, resolveOut } from '../build/driver.mjs';
48
48
  import { playLine } from '../ui/anim.mjs';
49
49
  import { discoverNames, PACK_ORDER } from '../build/source.mjs';
50
50
  import { exportImprovements } from '../inspect/diff.mjs';