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
package/js/README.md ADDED
@@ -0,0 +1,203 @@
1
+ # `js/` — the module map
2
+
3
+ Fifty-six modules in seven folders. This page is the address book: **what each one owns**, and
4
+ **which file to open first** for a given task. It is not documentation of behaviour — every module
5
+ has a docblock for that, and the docblock is the thing to read before editing.
6
+
7
+ `tests/unit/module_map.test.mjs` keeps this page honest: a module with no row here, or a row naming
8
+ a module that does not exist, is a test failure. `geneseed doctor` reports the same thing.
9
+
10
+ ---
11
+
12
+ ## The shape
13
+
14
+ ```
15
+ bin/geneseed-cli.mjs the `geneseed` CLI — 20+ verbs, may NOT spawn
16
+ bin/geneseed-hook.mjs the hook entry — 4 verbs, loads on EVERY tool call (~14 ms)
17
+ bin/build-driver.mjs `geneseed-build` — the generator's flags and emit targets
18
+
19
+ js/build/ src/ + themes/ → rendered text → a bundle → a per-host install
20
+ js/hosts/ where it stops being a renderer and touches the machine
21
+ js/inspect/ the read-only half: doctor, status, diff, validate, catalog
22
+ js/lib/ primitives, every one a deliberate divergence from the Node default
23
+ js/maintain/ lifecycle verbs that change an install that already exists
24
+ js/ui/ what it prints at a terminal
25
+ js/web/ the `geneseed web` console
26
+ ```
27
+
28
+ Dependency direction is one-way: `lib/` ← everything; `build/` ← `hosts/`; `inspect/` and `web/`
29
+ read what `build/` produced. Nothing in `lib/` imports anything above it.
30
+
31
+ ---
32
+
33
+ ## `js/build/` — the generator
34
+
35
+ Turns the checkout's `src/` + `themes/` into rendered text, writes that into a bundle, and emits it
36
+ as a per-host install at project or global scope.
37
+
38
+ | module | owns |
39
+ |---|---|
40
+ | `source.mjs` | Where the SOURCE is: `ROOT`/`SRC`/`THEMES`/`CONFIG`, `PACK_ORDER`, rule ids, the cfg object |
41
+ | `render.mjs` | The pure text pipeline: theme load, `INCLUDE` inlining, CATALOG blocks, `{{TOKENS}}`, dest paths |
42
+ | `bundle.mjs` | `build` — render a theme into a bundle dir, plus the source-completeness refusal. Sits UNDER both emits |
43
+ | `emit-claude.mjs` | The Claude/Bob/Copilot emit — the render half plus the settings + CLAUDE.md managed-block wire |
44
+ | `emit-opencode.mjs` | The OpenCode emits — a project's `.opencode/` and the global config dir. Deliberate twin of the above |
45
+ | `emit-common.mjs` | Constants, tree walkers, and the three writers BOTH global emits share. Decides no host |
46
+ | `stubs.mjs` | Write-once seed files: context, wiki, rules, excludes, profile, `.gitignore`, memory/notebook indexes |
47
+ | `version.mjs` | The release marker: fingerprint the sources, read/compare/write an install's version, warn on downgrade |
48
+ | `generate.mjs` | CLI verbs `build`, `prompt`, `theme`, `rebuild-all` — the thin face over `bin/build-driver.mjs` |
49
+ | `catalog.mjs` | `geneseed catalog` — prints the shipped roster. Classifies nothing itself |
50
+ | `themes.mjs` | Maintainer-only `--sync-themes`: inserts `_TEMPLATE.json`'s missing keys into the committed themes |
51
+
52
+ **Before editing:** `render.mjs` writes nothing, and its output is what every emit target
53
+ serialises — a change there is visible in all 14 themes at once. `emit-claude.mjs`'s
54
+ wire half edits files the **user** co-owns — get its prior-claim pruning wrong and you delete
55
+ someone's own settings keys. `emit-opencode.mjs` is shaped as a twin on purpose: a change that
56
+ lands on one host and not the other is the historical defect here, so edit both or neither.
57
+ `stubs.mjs` bodies are compared byte-for-byte by a recording — editing prose in one is a product
58
+ change. `themes.mjs` rewrites hand-written files in the repo, and `git diff` is where you would
59
+ find out about a slip.
60
+
61
+ ## `js/hosts/` — touching the machine
62
+
63
+ Resolving each host tool's config directories, writing host-native agents and skills into files the
64
+ user co-owns, and the hook verbs those emitted configs then run.
65
+
66
+ | module | owns |
67
+ |---|---|
68
+ | `hosts.mjs` | The four host config dirs (opencode/claude/bob/copilot), plus `resolvePath`/`expanduser` |
69
+ | `hooks.mjs` | The verbs the emitted hooks run every session: `context`, `git-gate`, `rule-gate`, `learn` |
70
+ | `settings.mjs` | Merges into user-owned `settings.json`/`opencode.json`; owns the hook shim and the managed blocks |
71
+ | `native.mjs` | Capability specs → host-native subagents and skills. The impure half of the emit |
72
+ | `opencode.mjs` | OpenCode-only extras: colour themes, primary agent, `/commands`, plugins, overrides stub |
73
+ | `installs.mjs` | Detects deployed installs and reads back their theme, footprint, posture and mode |
74
+ | `mcp.mjs` | MCP server presets, the per-host `mcpServers` config read/write, and toggle state |
75
+ | `link.mjs` | `geneseed link`/`unlink` — the PATH shim, and the only Windows USER-Path registry edit |
76
+
77
+ **Before editing:** `hooks.mjs` **is the hook path** — the verdict travels as JSON on stdout and
78
+ every arm exits 0, so one stray printed byte turns a blocking gate silently permissive, and anything
79
+ imported here is paid on every tool call. `hosts.mjs` is shared by the generator, the CLI and the
80
+ hook path, so it must stay free of `child_process`. `installs.mjs` reads through `readMaybe`, which
81
+ folds CRLF — a bare `readFileSync` here is a bug.
82
+
83
+ ## `js/inspect/` — the read-only half
84
+
85
+ Everything that inspects an already-built or already-deployed harness and reports on it.
86
+
87
+ | module | owns |
88
+ |---|---|
89
+ | `doctor.mjs` | The `geneseed doctor` **driver** only: picks themes, runs the checks, prints the verdict |
90
+ | `checks-build.mjs` | Gates over what the build PRODUCED: links, tokens, theme parity, OpenCode colours, bundle drift |
91
+ | `checks-repo.mjs` | Gates over the repo's own records: `registry.json`, committed secrets, the hook shim, vendored pins |
92
+ | `checks-authoring.mjs` | Self-consistency: law/doctrine metadata, constitution numbering, the counts README and the web quote |
93
+ | `scan.mjs` | The shared walk/sort/strip/test primitives all three `checks-*` use. Owns no judgement itself |
94
+ | `validate.mjs` | The `--validate-only` verb: renders a sandbox, scans it for tokens and dead links, own exit contract |
95
+ | `inventory.mjs` | The one catalog walk and its taxonomy tables, read by status, the TUI and the web console |
96
+ | `status.mjs` | `geneseed status` and `version` — the panel data, counts, accent colour, version verdict |
97
+ | `diff.mjs` | `geneseed diff` — what a deployed install has that a fresh render of `src/` does not |
98
+ | `excludes.mjs` | `geneseed exclude add|remove|list` — the WRITER for `excludes.json`; `hooks.mjs` only reads it |
99
+ | `registry.mjs` | `installs.json` under XDG — the persistent deploy-root list `rebuild-all` re-emits into |
100
+
101
+ **Before editing:** every check returns an array of problem strings, empty when clean — and since
102
+ deleting any check leaves a clean run **byte-identical**, each one is gated by a PLANTED FAULT, not
103
+ by a comparison. `scan.mjs`'s `TOKEN_RE` must stay in step with `render.mjs`'s. `registry.mjs`
104
+ swallows its own errors on purpose: a registry hiccup must never fail a build.
105
+
106
+ ## `js/lib/` — the primitives
107
+
108
+ Zero dependencies. **Every function here exists because the Node default is wrong for this tool** —
109
+ each one's docblock says which default, and why. Change a rule and you change what the tool writes
110
+ into files users have committed, so change the test that states it in the same commit.
111
+
112
+ | module | owns |
113
+ |---|---|
114
+ | `fs.mjs` | All disk and stdout/stderr writes. The single owner of the `\n` → `os.linesep` translation |
115
+ | `json.mjs` | The int/float-preserving parser (`JsonNumber`) plus every dumps/repr renderer |
116
+ | `paths.mjs` | Case folding, ordering, containment, spelling, PATH lookup — the platform-shaped rules |
117
+ | `text.mjs` | String primitives: `codePointLength`, `WHITESPACE`, `parseIntStrict`, `percentDecode` |
118
+ | `udiff.mjs` | `geneseed diff`'s own difflib clone — the alignment IS the printed hunks — plus `splitLines` |
119
+ | `proc.mjs` | One constant: the `windowsHide` flag every spawn in `js/` must reuse |
120
+
121
+ **Before editing:** parse with `parseJson`, never `JSON.parse` — the latter collapses int `1` and
122
+ float `1.0` into one double, and that value gets written into a user's file. Do not swap in a diff
123
+ library for `udiff.mjs`: it is not Myers, ties resolve to the earliest match. Every function in
124
+ `paths.mjs` branches on `process.platform`, so green on one OS proves nothing about the other.
125
+
126
+ ## `js/maintain/` — lifecycle
127
+
128
+ Verbs that change an install that already exists, as opposed to rendering one.
129
+
130
+ | module | owns |
131
+ |---|---|
132
+ | `update.mjs` | `upgrade`/`sync-self`/`bootstrap` — git pull, doctor gate, rebuild, each via a respawn |
133
+ | `setup.mjs` | The install wizard, and the CLI's only synchronous stdin readers (`ask`/`confirm`/`askChoice`) |
134
+ | `uninstall.mjs` | Removes or disables an install: manifest-driven reversal, plus stash deactivate/reactivate |
135
+ | `migrate.mjs` | `geneseed migrate` — all-or-nothing re-emit moving installs onto the npm install shape |
136
+ | `memory.mjs` | `geneseed memory list|rm` — CLI-side fact deletion, twin of the web DELETE endpoint |
137
+
138
+ **Before editing:** `update.mjs` is the one module that MUST spawn rather than import — the code on
139
+ disk is replaced mid-run, so an in-process call would validate the *old* source and then render it.
140
+ `setup.mjs` is the whole program's stdin layer, not just setup's. `memory.mjs` hard-unlinks user
141
+ facts with no archive path.
142
+
143
+ ## `js/ui/` — the terminal
144
+
145
+ | module | owns |
146
+ |---|---|
147
+ | `cli.mjs` | The whole CLI surface as data (`js/cli-table.json`) plus `--help` rendering and text wrapping |
148
+ | `menu.mjs` | Entry points for a bare `geneseed` and for `menu` — the web-vs-terminal fork |
149
+ | `tui.mjs` | The `tui` verb plus the panel's pure layout half: display width, glyphs, fit/truncate, rows |
150
+ | `anim.mjs` | Per-theme install ASCII art and its line animation — the only ANSI-emitting file here |
151
+
152
+ **Before editing:** there is no argument-parser *definition* in the code — every verb, flag, default
153
+ and help string lives in `js/cli-table.json`, and the table IS the parser. Most of `tui.mjs`'s
154
+ exports have no caller inside it: they are the curses-free half of a panel that does not exist.
155
+
156
+ ## `js/web/` — the console
157
+
158
+ A dependency-free localhost HTTP daemon that serves the built React UI from `web/dist` and exposes
159
+ the deployed harness as JSON.
160
+
161
+ | module | owns |
162
+ |---|---|
163
+ | `server.mjs` | `serve()`/`cmdWeb`: UI-build precheck, socket bind, token mint, daemon record wiring |
164
+ | `routes.mjs` | Route DECLARATION only: the `POST_ROUTES` table and the ported/unported/declined sets |
165
+ | `handler.mjs` | One function per request: dispatch, gzip, Host/CSRF guards, static SPA fallback |
166
+ | `api.mjs` | `WebState` plus every GET read endpoint; owns `STATE_ROUTES`, `PREFIX_ROUTES`, `NotFound` |
167
+ | `actions.mjs` | The mutating endpoints — rules, profile, memory, MCP, excludes — plus install/deploy argv |
168
+ | `daemon.mjs` | Detached web start/stop/status/restart, the `.geneseed-web.json` record, and `openUrl` |
169
+ | `jobs.mjs` | `JobManager` and the action→argv table. The only module here that spawns job children |
170
+ | `docs.mjs` | The Docs tab: the `docs/web/` page registry, `?harness=` filtering, the five page kinds |
171
+ | `graph.mjs` | `/api/graph` alone — the agent/skill/law cross-link matrix built from rendered bodies |
172
+ | `activity.mjs` | The `activity/` session files: pid-liveness prune, list, detail, and the on/off flag |
173
+
174
+ **Before editing:** `makeHandler(state, jm, token, dist, holder)`'s arity is load-bearing — three
175
+ test call sites construct one directly and drive it without a socket. Four of `routes.mjs`'s
176
+ exported sets are deliberately EMPTY and must stay declared: each is half of a partition test.
177
+ `graph.mjs` contains a literal NUL byte, so `grep`/`ripgrep` treat it as binary and skip it.
178
+
179
+ ---
180
+
181
+ ## Where do I add…
182
+
183
+ | I want to | open first | then |
184
+ |---|---|---|
185
+ | a doctrine rule | `src/doctrines/<pack>.md` — APPEND, ids run contiguously | `docs/extending.md` §2a — five files, `--sync-themes`, restyle 14 voices by hand |
186
+ | a law (invariant) | `src/laws/universal.md` — **APPEND, never insert** | `docs/extending.md` §2c. Amend the FIRST sentence: lean footprint keeps nothing else |
187
+ | a skill or an agent | `src/skills/<name>.md` or `src/agents/<name>.md` | `docs/extending.md` §3 — eight steps. Write the FILE before the table row or the build aborts |
188
+ | a doctor check | `js/inspect/checks-{build,repo,authoring}.mjs` | register with `ran(...)` in `doctor.mjs`, then a PLANTED FAULT in `tests/unit/harness.test.mjs` |
189
+ | a web endpoint | GET → `js/web/api.mjs`; POST → `js/web/actions.mjs` | declare it in `js/web/routes.mjs`; the partition test probes the real handler |
190
+ | a CLI verb | `js/cli-table.json` — the table IS the parser | the `VERBS` row in `bin/geneseed-cli.mjs`, the row count in `cli_table.test.mjs`, `NATIVE` in **two** test files |
191
+ | a hook verb | `js/hosts/hooks.mjs` | `claudeHookGroups` in `settings.mjs`, `VERBS` in `bin/geneseed-hook.mjs`. Keep the import graph tiny |
192
+ | a console doc page | `docs/web/<id>.md` — the stem is the id | `docs/extending.md` §4c. Only `kind: "concept"` gets `{N_*}` substitution |
193
+ | an OpenCode plugin | `adapters/opencode/plugins/geneseed-<name>.js` | `docs/extending.md` §4a — the `geneseed-` prefix is mechanically load-bearing |
194
+ | to change a `js/lib/` primitive | `tests/fixtures/pure_probe.mjs` **first** | the docblock says which Node default it deliberately departs from — change that sentence too, or the next reader restores the default |
195
+
196
+ ---
197
+
198
+ ## Known debris
199
+
200
+ * **`js/hosts/settings.mjs` carries a private second copy of Python's whitespace class**, duplicating
201
+ `WHITESPACE` in `js/lib/text.mjs`. `text.mjs` is the owner.
202
+ * **`web/src/pages/Skills.jsx`'s `SKILL_CATS` is a fourth, ungated copy of the skill taxonomy** —
203
+ nothing compares it to `SKILL_CLASS`, and an unknown category renders as Build with no warning.
@@ -0,0 +1,231 @@
1
+ /**
2
+ * `build` — render one theme's sources into a bundle directory, and the two checks that
3
+ * decide whether it may.
4
+ *
5
+ * The LAYER BOTH HOST EMITS SIT ON, and that is why it is not in `emit.mjs` with them: the
6
+ * first map left it there, and the derived imports came back with `emit-opencode.mjs` and
7
+ * `emit-claude.mjs` importing `emit.mjs` while `emit.mjs` imported them back. A bundle is
8
+ * produced before any host is chosen, so it belongs UNDER the emitters, not beside them.
9
+ *
10
+ * `assertSourceComplete` scans the TEMPLATE rather than the render, and the src-dirs marker
11
+ * is how a re-emit knows which directory names the last one used.
12
+ */
13
+ import path from 'node:path';
14
+ import { VERSION_MARKER } from '../hosts/hosts.mjs';
15
+ import { copyFile, readText, writeText } from '../lib/fs.mjs';
16
+ import { jsonDumpsIndent, parseJson, formatReprAscii, isTruthy } from '../lib/json.mjs';
17
+ import { OWNED_SRC_DIRS, SRC_DIRS_MARKER, TMPL_SPEC_RE, isDir, isFile } from './emit-common.mjs';
18
+ import { SRC_DIR_TOKENS, renderAll } from './render.mjs';
19
+ import {
20
+ ensureBundleGitignore, ensureContextStub, ensureMemoryIndex, ensureNotebookIndex,
21
+ ensureProfileStub, ensureRulesStub, ensureWikiStub,
22
+ } from './stubs.mjs';
23
+ import { writeVersion } from './version.mjs';
24
+ import { existsSync, mkdirSync, renameSync, rmSync } from 'node:fs';
25
+
26
+ // ---------------------------------------------------------------------------
27
+ // Source completeness
28
+ // ---------------------------------------------------------------------------
29
+
30
+ /** `_build_render._missing_referenced_specs` — scans the TEMPLATE, not the render. */
31
+ function missingReferencedSpecs(cfg) {
32
+ let tmpl;
33
+ try {
34
+ tmpl = readText(path.join(cfg.src, 'AGENT.md.tmpl'));
35
+ } catch {
36
+ return [];
37
+ }
38
+ const missing = new Set();
39
+ for (const [, kind, name] of tmpl.matchAll(TMPL_SPEC_RE)) {
40
+ const dir = kind.toLowerCase();
41
+ if (name !== '_template' && !isFile(path.join(cfg.src, dir, `${name}.md`))) {
42
+ missing.add(`${dir}/${name}.md`);
43
+ }
44
+ }
45
+ return [...missing].sort();
46
+ }
47
+
48
+ /**
49
+ * `_build_render.assert_source_complete`.
50
+ *
51
+ * Python raises `SystemExit(1)` after writing to stderr. Here the message goes to the
52
+ * captured stderr buffer and the throw carries the exit code, which the driver turns
53
+ * into a process status by `bin/build-driver.mjs`'s `main` — so the caller sees the same stream
54
+ * bytes and the same exit status whichever runtime rendered.
55
+ */
56
+ export function assertSourceComplete(cfg, context = '') {
57
+ const missing = missingReferencedSpecs(cfg);
58
+ if (!missing.length) return;
59
+ const where = context ? ` (${context})` : '';
60
+ process.stderr.write(
61
+ `[geneseed][E-INCOMPLETE] ✗ source is incomplete${where}: AGENT.md references `
62
+ + `${missing.length} spec(s) with no file under src/:\n`
63
+ + missing.map((m) => ` - ${m}\n`).join('')
64
+ + '[geneseed] ✗ Refusing to emit — a partial source would write dead links '
65
+ + 'and a global re-emit would delete the good copies in an existing install.\n'
66
+ + '[geneseed] ✗ Re-sync the source (./geneseed update, or re-run the upgrade) '
67
+ + 'and try again.\n');
68
+ const e = new Error('source is incomplete');
69
+ e.exitCode = 1;
70
+ throw e;
71
+ }
72
+
73
+ // ---------------------------------------------------------------------------
74
+ // The owned-dir marker
75
+ // ---------------------------------------------------------------------------
76
+
77
+ /** `_build_render._read_prior_src_dirs` — `{}` on anything unreadable or not an object. */
78
+ function readPriorSrcDirs(out) {
79
+ let data;
80
+ try {
81
+ data = parseJson(readText(path.join(out, SRC_DIRS_MARKER)));
82
+ } catch {
83
+ return {};
84
+ }
85
+ return data && typeof data === 'object' && !Array.isArray(data) ? data : {};
86
+ }
87
+
88
+ /** `_build_render._write_src_dirs_marker` — temp + replace. */
89
+ function writeSrcDirsMarker(out, resolved) {
90
+ const tmp = path.join(out, `${SRC_DIRS_MARKER}.tmp`);
91
+ writeText(tmp, `${jsonDumpsIndent(resolved)}\n`);
92
+ renameSync(tmp, path.join(out, SRC_DIRS_MARKER));
93
+ }
94
+
95
+ /**
96
+ * The guard on the prune of a RENAMED owned dir — `build`'s one destructive path driven
97
+ * by file content rather than by code.
98
+ *
99
+ * `.geneseed-srcdirs.json` is a plain file a user (or another tool) can edit, and the
100
+ * name in it is joined onto `out` and handed to a recursive delete. `".."` would take out
101
+ * the bundle's PARENT, an absolute path replaces `out` entirely under Python's
102
+ * `Path.__truediv__`, and `"a/b"` reaches into nested content. Only a plain
103
+ * single-segment name resolving directly under `out` is ever deleted.
104
+ */
105
+ function safePriorDirName(out, priorName) {
106
+ return typeof priorName === 'string'
107
+ && priorName !== '.' && priorName !== '..'
108
+ && !path.isAbsolute(priorName)
109
+ && path.basename(priorName) === priorName
110
+ && path.dirname(path.resolve(out, priorName)) === path.resolve(out);
111
+ }
112
+
113
+ // ---------------------------------------------------------------------------
114
+ // build
115
+ // ---------------------------------------------------------------------------
116
+
117
+ /**
118
+ * `_build_render.build` — render the bundle into `out`.
119
+ *
120
+ * `out` arrives as the STRING Python already resolved, and is echoed into the progress
121
+ * lines verbatim: Python interpolates `str(Path)`, which is backslash-separated on
122
+ * Windows, and re-deriving it here would differ by separator in every message.
123
+ */
124
+ /**
125
+ * THE PHASE BOUNDARY MARKER — T8, and the whole of what this port owes
126
+ * `tests/test_emit_phase_order.py`.
127
+ *
128
+ * Every emit runs its stages in one order: RENDER* → WIRE* → PRUNE → MANIFEST → VERIFY. RENDER
129
+ * writes files Geneseed owns wholesale; WIRE reconciles files the USER co-owns (the
130
+ * CLAUDE.md/AGENTS.md managed block, settings(.local).json, opencode.json) by reading what is
131
+ * already there and merging into it. WIRE must precede MANIFEST because wiring is what fills the
132
+ * `managed` record the manifest writes, and every teardown path unwires exactly what was wired.
133
+ *
134
+ * WHY A RUNTIME LOG AND NOT A SOURCE WALK. The reference gated this with five `ast` walks over
135
+ * its own generator, and could, because it keeps `_claude_render` / `_claude_wire` as two
136
+ * dispatchers and therefore two statements at every call site — a shape its own docstring calls
137
+ * "not incidental, it is what leaves this walker something to check". THIS PORT DOES NOT HAVE
138
+ * THAT SHAPE: `claudeWire` is a real function but the two OpenCode emits inline their merge
139
+ * (`emitOpencodeRender`, `emitOpencodeGlobalRender`), and PRUNE/MANIFEST/VERIFY are not in this
140
+ * module at all — they are in `bin/build-driver.mjs`'s driver bodies. A walker would have to span two
141
+ * files and would still be reading source rather than watching a run.
142
+ *
143
+ * So the order is made OBSERVABLE instead, which is strictly the better gate: it reports what
144
+ * actually executed, in the order it executed, including the branch a given host took.
145
+ *
146
+ * ⚠ BEHIND AN ENV VAR THE THREE REPLAYERS DO NOT SET, and that is the entire safety argument.
147
+ * `cellEnv` clears every `GENESEED_*` knob by prefix, so no recorded cell can turn this on and
148
+ * the emit corpus cannot move by one byte. It writes to STDERR, never stdout: stdout is the
149
+ * decision channel every Geneseed hook signals through, and a stray byte there turns a blocking
150
+ * gate into a silently permissive one.
151
+ *
152
+ * Read at CALL time, not at import, so one process can drive several emits under it.
153
+ */
154
+ export function phaseLog(phase) {
155
+ if (!process.env.GENESEED_PHASE_LOG) return;
156
+ process.stderr.write(`[geneseed:phase] ${phase}\n`);
157
+ }
158
+
159
+ export function build(cfg, themeName, out, { footprint = 'full', nativeCatalog = false } = {}) {
160
+ phaseLog('RENDER');
161
+ const { theme, items } = renderAll(cfg, themeName, { footprint, nativeCatalog });
162
+ assertSourceComplete(cfg, `theme '${themeName}'`);
163
+ mkdirSync(out, { recursive: true });
164
+
165
+ // Wipe the owned dirs ONLY inside an established bundle: a first render into an
166
+ // arbitrary repo (`--out .`) must never delete an agents/ or skills/ the USER owns.
167
+ const isBundle = isFile(path.join(out, '.geneseed-theme')) || isFile(path.join(out, VERSION_MARKER));
168
+ const priorSrcDirs = isBundle ? readPriorSrcDirs(out) : {};
169
+ const resolvedSrcDirs = {};
170
+ for (const srcDir of OWNED_SRC_DIRS) {
171
+ // `theme.get(token, srcDir)` — `??` agrees with it except for an explicit JSON null,
172
+ // where Python would go on to join `None` onto a path and raise. No theme has one.
173
+ const dirname = theme[SRC_DIR_TOKENS[srcDir]] ?? srcDir;
174
+ resolvedSrcDirs[srcDir] = dirname;
175
+ const managed = path.join(out, dirname);
176
+
177
+ // `isTruthy`, not `priorName &&`: the marker is user-editable JSON, and Python's
178
+ // `if prior_name` is false for `[]`, `{}` and `0` where JS's is true for the first
179
+ // two. Getting that wrong warns about a value Python skips in silence — a stderr
180
+ // divergence reachable only from a hand-edited file, which is the one place nobody
181
+ // would look for it.
182
+ const priorName = priorSrcDirs[srcDir];
183
+ if (isBundle && isTruthy(priorName) && priorName !== dirname) {
184
+ if (safePriorDirName(out, priorName)) {
185
+ const stale = path.join(out, priorName);
186
+ if (isDir(stale)) rmSync(stale, { recursive: true, force: true });
187
+ } else {
188
+ process.stderr.write('[geneseed] WARN: ignoring suspicious prior dir name '
189
+ + `${formatReprAscii(priorName)} recorded in ${SRC_DIRS_MARKER} - not pruned.\n`);
190
+ }
191
+ }
192
+
193
+ if (!isDir(managed)) continue;
194
+ if (isBundle) {
195
+ rmSync(managed, { recursive: true, force: true });
196
+ } else {
197
+ // STDOUT, and ASCII-only: this print crashed with UnicodeEncodeError on a cp1252
198
+ // Windows console when it carried a warning-sign emoji.
199
+ process.stdout.write(`[geneseed] WARN: ${managed} already exists and ${out} is not a `
200
+ + 'Geneseed bundle — keeping it; rendered files merge into it.\n');
201
+ }
202
+ }
203
+
204
+ const nbDirname = theme[SRC_DIR_TOKENS.notebook] ?? 'notebook';
205
+ for (const { rel, text, src } of items) {
206
+ const dest = path.join(out, rel);
207
+ mkdirSync(path.dirname(dest), { recursive: true });
208
+ // The notebook is the agent's sovereign space: seeded files are written once and
209
+ // never re-emitted, so the agent may rewrite its own rules. Only `.gitignore` is
210
+ // re-asserted — the one law it cannot lift.
211
+ const parts = rel.split('/');
212
+ if (parts[0] === nbDirname && path.basename(rel) !== '.gitignore' && existsSync(dest)) {
213
+ continue;
214
+ }
215
+ if (text !== null) writeText(dest, text);
216
+ else copyFile(src, dest);
217
+ }
218
+
219
+ writeText(path.join(out, '.geneseed-theme'), `${themeName}\n`);
220
+ writeVersion(cfg, out);
221
+ writeSrcDirsMarker(out, resolvedSrcDirs);
222
+ ensureContextStub(out);
223
+ ensureWikiStub(out);
224
+ ensureRulesStub(out);
225
+ ensureProfileStub(out);
226
+ ensureBundleGitignore(out);
227
+ ensureMemoryIndex(path.join(out, theme[SRC_DIR_TOKENS.memory] ?? 'memory'));
228
+ ensureNotebookIndex(path.join(out, theme[SRC_DIR_TOKENS.notebook] ?? 'notebook'));
229
+ process.stdout.write(`[geneseed] built theme '${themeName}' -> ${out} (${items.length} files)\n`);
230
+ }
231
+
@@ -23,11 +23,11 @@
23
23
  * NOTHING HERE PAINTS. No cursor moves, no clears, no alternate screen: this writes lines
24
24
  * and stops, exactly as `status` does. That is what keeps the dropped panel dropped.
25
25
  */
26
- import { helpWidth } from './cli.mjs';
27
- import { tuiInventory } from './inventory.mjs';
28
- import { defaultTheme, installedDefaults } from './installs.mjs';
29
- import { pyPrint } from './lib/fs.mjs';
30
- import { dwidth, fit, icon, truncd, tuiEntries } from './tui.mjs';
26
+ import { helpWidth } from '../ui/cli.mjs';
27
+ import { tuiInventory } from '../inspect/inventory.mjs';
28
+ import { defaultTheme, installedDefaults } from '../hosts/installs.mjs';
29
+ import { printOut } from '../lib/fs.mjs';
30
+ import { dwidth, fit, icon, truncd, tuiEntries } from '../ui/tui.mjs';
31
31
 
32
32
  /**
33
33
  * The listing's section names, and the row kind each one selects.
@@ -129,9 +129,9 @@ export function cmdCatalog(args) {
129
129
  const lines = catalogLines(tuiInventory(theme, inst.doctrines), args.section,
130
130
  helpWidth() + 2, args.source);
131
131
  if (!lines.length) {
132
- pyPrint(`[catalog] nothing to list${args.section ? ` under ${args.section}` : ''}.\n`);
132
+ printOut(`[catalog] nothing to list${args.section ? ` under ${args.section}` : ''}.\n`);
133
133
  return 1;
134
134
  }
135
- for (const line of lines) pyPrint(`${line}\n`);
135
+ for (const line of lines) printOut(`${line}\n`);
136
136
  return 0;
137
137
  }