geneseed 3.1.1 → 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 (98) hide show
  1. package/CHANGELOG.md +68 -1
  2. package/DESIGN.md +15 -15
  3. package/README.md +1 -1
  4. package/SETUP.md +1 -1
  5. package/SHIPPED.md +1 -1
  6. package/bin/{geneseed.mjs → build-driver.mjs} +39 -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 +111 -83
  11. package/harness.config.json +1 -1
  12. package/js/README.md +203 -0
  13. package/js/build/bundle.mjs +231 -0
  14. package/js/{catalog.mjs → build/catalog.mjs} +7 -7
  15. package/js/build/emit-claude.mjs +287 -0
  16. package/js/build/emit-common.mjs +255 -0
  17. package/js/build/emit-opencode.mjs +214 -0
  18. package/js/{generate.mjs → build/generate.mjs} +27 -28
  19. package/js/{render.mjs → build/render.mjs} +13 -9
  20. package/js/{checkout.mjs → build/source.mjs} +9 -9
  21. package/js/build/stubs.mjs +256 -0
  22. package/js/{themes.mjs → build/themes.mjs} +24 -24
  23. package/js/build/version.mjs +155 -0
  24. package/js/{hooks.mjs → hosts/hooks.mjs} +47 -44
  25. package/js/{hosts.mjs → hosts/hosts.mjs} +30 -28
  26. package/js/{installs.mjs → hosts/installs.mjs} +18 -16
  27. package/js/{link.mjs → hosts/link.mjs} +29 -28
  28. package/js/{mcp.mjs → hosts/mcp.mjs} +10 -9
  29. package/js/{native.mjs → hosts/native.mjs} +24 -22
  30. package/js/{opencode.mjs → hosts/opencode.mjs} +12 -7
  31. package/js/{settings.mjs → hosts/settings.mjs} +63 -55
  32. package/js/inspect/checks-authoring.mjs +761 -0
  33. package/js/inspect/checks-build.mjs +231 -0
  34. package/js/inspect/checks-repo.mjs +308 -0
  35. package/js/{diff.mjs → inspect/diff.mjs} +34 -31
  36. package/js/inspect/doctor.mjs +286 -0
  37. package/js/{excludes.mjs → inspect/excludes.mjs} +30 -33
  38. package/js/{inventory.mjs → inspect/inventory.mjs} +20 -17
  39. package/js/{registry.mjs → inspect/registry.mjs} +12 -10
  40. package/js/inspect/scan.mjs +125 -0
  41. package/js/{status.mjs → inspect/status.mjs} +27 -25
  42. package/js/inspect/validate.mjs +199 -0
  43. package/js/lib/fs.mjs +37 -759
  44. package/js/lib/json.mjs +344 -0
  45. package/js/lib/paths.mjs +242 -0
  46. package/js/lib/proc.mjs +3 -3
  47. package/js/lib/text.mjs +181 -0
  48. package/js/lib/udiff.mjs +1 -1
  49. package/js/{memory.mjs → maintain/memory.mjs} +10 -10
  50. package/js/{migrate.mjs → maintain/migrate.mjs} +28 -26
  51. package/js/{setup.mjs → maintain/setup.mjs} +34 -32
  52. package/js/{uninstall.mjs → maintain/uninstall.mjs} +55 -56
  53. package/js/{update.mjs → maintain/update.mjs} +51 -51
  54. package/js/{anim.mjs → ui/anim.mjs} +30 -28
  55. package/js/{cli.mjs → ui/cli.mjs} +10 -10
  56. package/js/{menu.mjs → ui/menu.mjs} +10 -9
  57. package/js/{tui.mjs → ui/tui.mjs} +36 -34
  58. package/js/web/actions.mjs +84 -85
  59. package/js/web/activity.mjs +22 -21
  60. package/js/web/api.mjs +72 -50
  61. package/js/web/daemon.mjs +319 -0
  62. package/js/web/docs.mjs +24 -21
  63. package/js/web/graph.mjs +0 -0
  64. package/js/web/handler.mjs +442 -0
  65. package/js/web/jobs.mjs +21 -19
  66. package/js/web/routes.mjs +223 -0
  67. package/js/web/server.mjs +56 -987
  68. package/package.json +11 -3
  69. package/src/doctrines/README.md +1 -1
  70. package/themes/opencode/README.md +1 -1
  71. package/web/dist/assets/{About-DyK7yRy7.js → About-CPvIFZGh.js} +1 -1
  72. package/web/dist/assets/{Activity-D_sYBlW-.js → Activity-z0uXdS7J.js} +1 -1
  73. package/web/dist/assets/{ActivityDetail-BomKcMhy.js → ActivityDetail-CEj5IJpF.js} +1 -1
  74. package/web/dist/assets/{Diff-BSvi1WUg.js → Diff-BMR8fPuG.js} +1 -1
  75. package/web/dist/assets/{Doctor-Cz1s-y1n.js → Doctor-Bz5qE4r9.js} +1 -1
  76. package/web/dist/assets/{Graph-CuntzFJ0.js → Graph-BV6a6t2l.js} +1 -1
  77. package/web/dist/assets/{Harnesses-DgRyOviS.js → Harnesses-C5Ag5PaQ.js} +1 -1
  78. package/web/dist/assets/{Laws-CWk3Mpri.js → Laws-BK07NKw9.js} +1 -1
  79. package/web/dist/assets/{Library-DXhTN9ju.js → Library-CiXN9sC3.js} +1 -1
  80. package/web/dist/assets/{Markdown-BoiZcdUl.js → Markdown-DG6FjUz-.js} +1 -1
  81. package/web/dist/assets/{Profile-DfCHlZ03.js → Profile-B2oSvdxH.js} +1 -1
  82. package/web/dist/assets/{Rules-eND7hJv9.js → Rules-DobVPd80.js} +1 -1
  83. package/web/dist/assets/{Skills-CDl9opZO.js → Skills-DQPsvsn9.js} +1 -1
  84. package/web/dist/assets/{StatusBadge-DSDtcArX.js → StatusBadge-BhJ3HhdI.js} +1 -1
  85. package/web/dist/assets/{Themes-CoAWuIeM.js → Themes-BP38FBU9.js} +1 -1
  86. package/web/dist/assets/{activity-Dmb0FTAx.js → activity-D9mfv7pY.js} +1 -1
  87. package/web/dist/assets/index-BE226Gv6.css +1 -0
  88. package/web/dist/assets/{index-jrY4m9jP.js → index-BZ7cPLMI.js} +1 -1
  89. package/web/dist/assets/index-BgIs5eIa.js +12 -0
  90. package/web/dist/assets/{index-Dg58ScBe.js → index-DGM5j2hB.js} +1 -1
  91. package/web/dist/index.html +2 -2
  92. package/web/src/pages/Laws.jsx +1 -1
  93. package/docs/declined.md +0 -200
  94. package/docs/limits.md +0 -329
  95. package/js/doctor.mjs +0 -1750
  96. package/js/emit.mjs +0 -1411
  97. package/web/dist/assets/index-B-mXquBZ.css +0 -1
  98. package/web/dist/assets/index-CH_Z725C.js +0 -12
@@ -10,11 +10,13 @@
10
10
  * RENDER* -> WIRE* -> PRUNE -> MANIFEST -> VERIFY (see `_build_global._emit_claude_core`
11
11
  * and tests/test_emit_phase_order.py). RENDER produces files Geneseed owns wholesale;
12
12
  * WIRE reconciles files the user co-owns (settings.json, opencode.json, the CLAUDE.md
13
- * managed block) and stays in Python for now, in `_build_settings.py`. Node takes RENDER
13
+ * managed block) and lives in `js/hosts/settings.mjs`, driven from the two emit modules.
14
+ * THIS module is only RENDER
14
15
  * because it is the only stage whose output is a pure function of the source tree.
15
16
  *
16
17
  * PARITY IS THE CONTRACT, NOT THE INTENT. Every function here has a named Python
17
- * counterpart and must produce byte-identical output for it; tests/test_render_parity.py
18
+ * counterpart and had to produce byte-identical output for it. The reference is gone, so
19
+ * what holds the line is `tests/golden.mjs --against`, replaying recorded emit cells;
18
20
  * drives both over every theme x footprint x posture x mode and byte-compares the
19
21
  * written trees. Where Python and JS semantics differ silently — path sort order,
20
22
  * `.suffix`, `splitlines`, universal newlines — the difference is spelled out at the
@@ -28,9 +30,11 @@
28
30
  */
29
31
  import { readdirSync, statSync, existsSync } from 'node:fs';
30
32
  import path from 'node:path';
31
- import { readText, parseJson, normcase, comparePaths } from './lib/fs.mjs';
32
- // `js/checkout.mjs` imports nothing but node builtins, so this direction cannot cycle.
33
- import { PACK_ORDER } from './checkout.mjs';
33
+ import { readText } from '../lib/fs.mjs';
34
+ import { parseJson } from '../lib/json.mjs';
35
+ import { normcase, comparePaths } from '../lib/paths.mjs';
36
+ // `js/build/source.mjs` imports nothing but node builtins, so this direction cannot cycle.
37
+ import { PACK_ORDER } from './source.mjs';
34
38
 
35
39
  /** Document STRUCTURE is theme-INDEPENDENT — mirrors `_build_render.STRUCTURE`. */
36
40
  export const STRUCTURE = {
@@ -47,7 +51,7 @@ export const STRUCTURE = {
47
51
 
48
52
  /**
49
53
  * Source top-level dirs whose OUTPUT name is themed. Mirrors `SRC_DIR_TOKENS`.
50
- * Exported because `js/emit.mjs`'s `build` resolves the same tokens to decide which
54
+ * Exported because `js/build/bundle.mjs`'s `build` resolves the same tokens to decide which
51
55
  * dirs it owns and wipes — one owner, so the two cannot disagree about a folder name.
52
56
  */
53
57
  export const SRC_DIR_TOKENS = {
@@ -101,7 +105,7 @@ export function loadTheme(cfg, name) {
101
105
  // `sys.exit(<str>)` — the message on stderr, exit 1 — spelled the way
102
106
  // `assertSourceComplete` spells it: written at the raise site, and the throw carries
103
107
  // only the marker. ⚠ It was once described as an unreachable nicety under
104
- // `bin/geneseed.mjs`, on the grounds that `--theme` is validated against `choices` before
108
+ // `bin/build-driver.mjs`, on the grounds that `--theme` is validated against `choices` before
105
109
  // anything renders — it is not: `--theme` takes a value and has no `choices` entry, so THIS
106
110
  // is the refusal, on every path. It is also reachable from `geneseed status`, which takes
107
111
  // its theme from a `.geneseed-theme`
@@ -253,7 +257,7 @@ function registerBody(cfg, theme, dir, selected, fallback) {
253
257
  * entry point and an emit that quietly dropped a pack the user asked for is exactly the
254
258
  * failure the `Active packs:` marker would then attest to.
255
259
  *
256
- * Both throw with `exitCode`, the marker `bin/geneseed.mjs`'s `main` turns into a status.
260
+ * Both throw with `exitCode`, the marker `bin/build-driver.mjs`'s `main` turns into a status.
257
261
  */
258
262
  function activeDoctrines(cfg) {
259
263
  const dir = path.join(cfg.src, 'doctrines');
@@ -265,7 +269,7 @@ function activeDoctrines(cfg) {
265
269
  const stray = onDisk.filter((n) => !PACK_ORDER.includes(n));
266
270
  if (stray.length) {
267
271
  const e = new Error(`doctrine pack file(s) ${stray.join(', ')} exist under src/doctrines/ `
268
- + 'but are missing from PACK_ORDER (js/checkout.mjs) — add them there, or they render '
272
+ + 'but are missing from PACK_ORDER (js/build/source.mjs) — add them there, or they render '
269
273
  + 'into no install at all');
270
274
  e.exitCode = 1;
271
275
  throw e;
@@ -2,8 +2,8 @@
2
2
  * The checkout this code is running FROM — `_build_core`'s ROOT and the paths under it,
3
3
  * plus the `cfg` every render needs.
4
4
  *
5
- * The inverse of `js/hosts.mjs`. That module answers "where does the output go"; this one
6
- * answers "where is the source". Both were `bin/geneseed.mjs`'s, and both leave it for the
5
+ * The inverse of `js/hosts/hosts.mjs`. That module answers "where does the output go"; this one
6
+ * answers "where is the source". Both were `bin/build-driver.mjs`'s, and both leave it for the
7
7
  * same arithmetic reason: `bin/geneseed-cli.mjs` needs them too, and the value that decides
8
8
  * which tree gets rendered is the last thing that should exist twice.
9
9
  *
@@ -12,13 +12,13 @@
12
12
  * `build.render_all(theme)`. So the CLI has to build the same `cfg` the driver builds, and
13
13
  * until this move only the driver could.
14
14
  *
15
- * THE TWO ABSENT KEYS ARE STILL ABSENT, and the reason is bin/geneseed.mjs's, unchanged:
15
+ * THE TWO ABSENT KEYS ARE STILL ABSENT, and the reason is bin/build-driver.mjs's, unchanged:
16
16
  * `_build_core.js_cfg()` always sends `structure` and `capabilityLinkRe` because the Python
17
17
  * originals are module-level names TESTS MUTATE (`_OWNED` membership asked one level out).
18
18
  * A Node process has no Python module to mutate, so it sends neither and
19
- * `js/render.mjs`'s `cfg.structure ?? STRUCTURE` takes its right-hand branch.
19
+ * `js/build/render.mjs`'s `cfg.structure ?? STRUCTURE` takes its right-hand branch.
20
20
  *
21
- * The move is safe for the reason P5c's was: `tests/golden.py` drives `bin/geneseed.mjs`
21
+ * The move is safe for the reason P5c's was: `tests/golden.py` drives `bin/build-driver.mjs`
22
22
  * over 259 cells and compares the tree byte-for-byte, and every one of them builds a cfg
23
23
  * from these paths. A depth error or a renamed key fails 259 cells, not zero.
24
24
  */
@@ -37,7 +37,7 @@ import { fileURLToPath } from 'node:url';
37
37
  * which is what makes them comparable and what makes them unfenceable. See the
38
38
  * status/version section of `tests/harness_golden.py`.
39
39
  */
40
- export const ROOT = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
40
+ export const ROOT = path.dirname(path.dirname(path.dirname(fileURLToPath(import.meta.url))));
41
41
  export const SRC = path.join(ROOT, 'src');
42
42
  export const CONFIG = path.join(ROOT, 'harness.config.json');
43
43
  export const THEMES = path.join(ROOT, 'themes');
@@ -48,8 +48,8 @@ export const WORKFLOW_SRC = path.join(ROOT, 'adapters', 'opencode', 'workflows')
48
48
  * `_build_render.posture_names()` / `mode_names()` — discovered, never hardcoded, so a new
49
49
  * posture file appears in both CLIs' choices with no code change.
50
50
  *
51
- * P5f moved this here from `bin/geneseed.mjs`, which had the only caller until
52
- * `js/installs.mjs` needed the same two lists: `_posture_of_dir` scans a deployed carrier for
51
+ * P5f moved this here from `bin/build-driver.mjs`, which had the only caller until
52
+ * `js/hosts/installs.mjs` needed the same two lists: `_posture_of_dir` scans a deployed carrier for
53
53
  * `**<Name>**` and has to be scanning for the SAME names the driver's `--posture` accepts.
54
54
  * Two discoveries of one directory is precisely the shape that lets a new posture file be
55
55
  * buildable and undetectable.
@@ -72,7 +72,7 @@ export function discoverNames(dir, first) {
72
72
  * DELIBERATELY NOT `discoverNames('doctrines', 'craft')`. That helper `.sort()`s, so it
73
73
  * answers `craft, ops, process, rigor`; the constitution's order is a reading order —
74
74
  * write it (craft), prove it (rigor), run it (ops), ship it (process) — and no sort
75
- * produces it. Discovery still runs, but as a GATE rather than as the order: `js/render.mjs`
75
+ * produces it. Discovery still runs, but as a GATE rather than as the order: `js/build/render.mjs`
76
76
  * refuses to build when a pack file exists under `src/doctrines/` that is missing from this
77
77
  * array, so a fifth pack cannot be silently dropped from every install.
78
78
  *
@@ -0,0 +1,256 @@
1
+ /**
2
+ * The files an emit CREATES WHEN THEY ARE ABSENT and never touches again — context, wiki,
3
+ * user rules, excludes, profile, the bundle's .gitignore, the memory and notebook indexes.
4
+ *
5
+ * Every stub is a literal here and an `ensure*` beside it, and the pairing is the contract:
6
+ * the writer checks first and returns without writing when the file exists, because these are
7
+ * the USER's files from the moment they exist. A stub body is bytes a recorded corpus compares
8
+ * — changing one is a product change, not a tidy-up.
9
+ */
10
+ import path from 'node:path';
11
+ import { writeText } from '../lib/fs.mjs';
12
+ import { jsonDumpsIndent } from '../lib/json.mjs';
13
+ import { isDir } from './emit-common.mjs';
14
+ import { existsSync } from 'node:fs';
15
+
16
+ // ---------------------------------------------------------------------------
17
+ // Seeded stubs. Written once into the bundle and NEVER overwritten — each holds
18
+ // something the user owns. Duplicated from `_build_render.py` rather than read from a
19
+ // shared file: the parity gate drives both sides, so drift surfaces as a failing cell
20
+ // instead of as two copies rotting apart, and a shared file would be a third format to
21
+ // keep in agreement. Backticks are escaped; a raw one would end the literal.
22
+ // ---------------------------------------------------------------------------
23
+
24
+ /** `_build_render.CONTEXT_STUB`. */
25
+ const CONTEXT_STUB = {
26
+ _comment: "Point the agent at this project's own documentation. Each entry: 'path' "
27
+ + "(absolute, or relative to the repo root), 'load' ('eager' = read every "
28
+ + "session for small always-relevant rules; 'lazy' = read only when the task "
29
+ + "needs it), and 'description'. This file is host-specific — git-ignore it. "
30
+ + 'The build creates it once, empty, and never overwrites it.',
31
+ context: [],
32
+ };
33
+
34
+ /** `_build_render.WIKI_STUB`. */
35
+ const WIKI_STUB = `\
36
+ // Geneseed wiki.jsonc — declare your machine-wide knowledge base(s) here, typically
37
+ // an Obsidian vault (AGENT.md: the Wiki section). Comments are allowed in this file
38
+ // (JSONC). It is host-specific — never commit it. The build created it once, empty,
39
+ // and will never overwrite it.
40
+ //
41
+ // Each wiki carries:
42
+ // name a short label
43
+ // path absolute root of the vault (use forward slashes, also on Windows)
44
+ // description one line shown to the agent
45
+ // entries notes OR folders to load: path relative to the vault root ("." =
46
+ // the whole vault); load "eager" = read every session, "lazy" =
47
+ // read on demand; a folder applies its mode to every note beneath
48
+ // it, a file entry overrides its folder, "exclude" prunes
49
+ // conventions the vault's authoring-rules note — read before the first write
50
+ // inbox drop folder for notes the agent cannot confidently file
51
+ // protected folders the agent must never write to (guard-enforced on OpenCode)
52
+ //
53
+ // Example — copy this object into the "wikis" array below and edit:
54
+ // {
55
+ // "name": "Brain",
56
+ // "path": "C:/Users/me/Documents/Brain",
57
+ // "description": "my machine-wide knowledge base",
58
+ // "entries": [
59
+ // { "path": "ARCHITECTURE.md", "load": "eager", "description": "the root map" },
60
+ // { "path": ".", "load": "lazy" }
61
+ // ],
62
+ // "conventions": "STYLE.md",
63
+ // "inbox": "Inbox/",
64
+ // "protected": ["Journal/"]
65
+ // }
66
+ {
67
+ "wikis": []
68
+ }
69
+ `;
70
+
71
+ /** `_build_render.RULES_FILE`. */
72
+ const RULES_FILE = 'user-rules.md';
73
+
74
+ /** `_build_render.RULES_STUB`. */
75
+ const RULES_STUB = `\
76
+ # User rules
77
+
78
+ Your own standing rules. The agent obeys every rule in this file exactly as it
79
+ obeys the laws in AGENT.md §1 — always in force, in every task. A user rule may
80
+ *tighten* a law, never repeal one: where they conflict, the law wins.
81
+
82
+ Geneseed seeded this file once and will never overwrite it. The laws file is
83
+ regenerated on every update — never edit that one; this file is where your own
84
+ governance lives, and it survives updates, reinstalls, and theme switches.
85
+ Unlike \`context.json\`, it is safe to commit: project rules are meant to travel
86
+ with the repo and bind the whole team.
87
+
88
+ Keep the set small — every rule here is loaded every session, and a bloated
89
+ rule set dilutes the rules that matter. A durable fact belongs in memory, a
90
+ pointer to documentation belongs in \`context.json\`; only a standing *behaviour*
91
+ belongs here.
92
+
93
+ Format — one rule per \`## R<n> — Title\` heading, an optional metadata line in
94
+ parentheses, then the rule stated plainly:
95
+
96
+ ## R1 — No emoji in commit subjects
97
+ (scope: project | source: written by hand)
98
+ Commit subjects are plain text; no emoji, no decorative unicode.
99
+
100
+ \`trial until: YYYY-MM-DD\` in the metadata line marks a rule on probation —
101
+ usually one promoted from a recurring memory. Review it by that date, then
102
+ graduate it (remove the marker) or demote it back to memory.
103
+ `;
104
+
105
+ /** `_build_render.EXCLUDES_FILE`. */
106
+ export const EXCLUDES_FILE = 'excludes.json';
107
+
108
+ /**
109
+ * `_build_render.EXCLUDES_STUB` — one long line, exactly as Python spells it.
110
+ *
111
+ * Exported since P5c because `js/inspect/excludes.mjs` reads it as the single source of the shape a
112
+ * missing or corrupt `excludes.json` degrades to, exactly as `_harness_exclude._read_excludes`
113
+ * reads `build.EXCLUDES_STUB` rather than spelling `{"excludes": []}` a second time.
114
+ */
115
+ export const EXCLUDES_STUB = `\
116
+ {
117
+ "_comment": "Folders where this global Geneseed install goes dormant (hooks silent, preamble suppressed). Managed by \`harness exclude add|remove|list\`; safe to edit by hand. Paths are absolute.",
118
+ "excludes": []
119
+ }
120
+ `;
121
+
122
+ /**
123
+ * `_build_global._BOB_RULES_STUB` — the workspace shadow stub a PROJECT Bob emit ships.
124
+ *
125
+ * Exported since P5c: `harness exclude add` writes the SAME stub into an excluded repo, and
126
+ * decides ownership on the next `remove` by comparing the file's content to it byte for byte
127
+ * — so a second spelling here would orphan every stub the other writer created.
128
+ */
129
+ export const BOB_RULES_STUB = `\
130
+ <!-- geneseed: workspace shadow stub -->
131
+ This project's Geneseed instructions are the repo-root \`AGENTS.md\`, which Bob
132
+ auto-loads. This file exists only to shadow the same-named global Geneseed rules
133
+ file (\`~/.bob/rules/geneseed.md\`) so the global preamble does not stack on top of
134
+ the project's own. Follow the root \`AGENTS.md\`.
135
+ `;
136
+
137
+ /** `_build_render.PROFILE_FILE`. */
138
+ const PROFILE_FILE = 'PROFILE.md';
139
+
140
+ /** `_build_render.PROFILE_STUB`. */
141
+ const PROFILE_STUB = `\
142
+ # Your profile
143
+
144
+ Who you are and how you like to work — so the agent can meet you where you are
145
+ instead of guessing. Every section is optional; delete what you don't want to
146
+ share, add what you do. Geneseed seeded this file once and will never overwrite
147
+ it, so it survives updates, reinstalls, and theme switches.
148
+
149
+ This is *identity, not rules*. A standing rule the agent must obey belongs in
150
+ \`user-rules.md\` (AGENT.md §1); this file only colours how the agent works — tone,
151
+ depth, defaults. Where the two ever seem to conflict, the rule wins: precedence
152
+ is laws, then user-rules, then this profile.
153
+
154
+ ## Who I am
155
+
156
+ Role, domains you know deeply, domains you're learning. What you're usually here
157
+ to do.
158
+
159
+ ## How I work
160
+
161
+ Habits, tools, and environment worth knowing — your stack, your shell, the
162
+ conventions you hold to, the things that reliably annoy you.
163
+
164
+ ## Register preferences
165
+
166
+ How you like answers pitched: terse or expansive, teach-me or just-do-it, how
167
+ much pushback you want, which language(s) you think in.
168
+ `;
169
+
170
+ /** `_build_render.BUNDLE_GITIGNORE`. */
171
+ const BUNDLE_GITIGNORE = `\
172
+ # Generated by Geneseed. The rendered harness — AGENT.md, the laws, agents, and
173
+ # skills — is safe to commit; track it if you want it versioned with your project.
174
+ # Only the host-specific / personal files below are kept out of git.
175
+
176
+ # Project-context manifest — may hold private paths; never commit.
177
+ context.json
178
+
179
+ # Knowledge-base manifest — holds private machine paths; never commit.
180
+ # (wiki.json is the legacy name from earlier seeds.)
181
+ wiki.jsonc
182
+ wiki.json
183
+
184
+ # Per-agent model/temperature overrides — host-specific; never commit.
185
+ agent-overrides.json
186
+
187
+ # Which theme + emit mode + footprint this host last built (local build state, must not travel).
188
+ .geneseed-theme
189
+ .geneseed-emit
190
+ .geneseed-footprint
191
+ .geneseed-srcdirs.json
192
+
193
+ # memory/ keeps its own .gitignore so learned facts stay on this machine.
194
+ # notebook/ keeps its own .gitignore so the agent's own files stay on this machine.
195
+ `;
196
+
197
+ // ---------------------------------------------------------------------------
198
+ // Seeded stubs — write-once, never overwrite
199
+ // ---------------------------------------------------------------------------
200
+
201
+ /** `_build_render.ensure_context_stub`. */
202
+ export function ensureContextStub(out) {
203
+ const dest = path.join(out, 'context.json');
204
+ if (!existsSync(dest)) {
205
+ writeText(dest, `${jsonDumpsIndent(CONTEXT_STUB, { ensureAscii: false })}\n`);
206
+ }
207
+ }
208
+
209
+ /** `_build_render.ensure_wiki_stub` — a legacy `wiki.json` counts as present. */
210
+ export function ensureWikiStub(out) {
211
+ const dest = path.join(out, 'wiki.jsonc');
212
+ if (!existsSync(dest) && !existsSync(path.join(out, 'wiki.json'))) writeText(dest, WIKI_STUB);
213
+ }
214
+
215
+ /** `_build_render.ensure_rules_stub`. */
216
+ export function ensureRulesStub(out) {
217
+ const dest = path.join(out, RULES_FILE);
218
+ if (!existsSync(dest)) writeText(dest, RULES_STUB);
219
+ }
220
+
221
+ /** `_build_render.ensure_profile_stub`. */
222
+ export function ensureProfileStub(out) {
223
+ const dest = path.join(out, PROFILE_FILE);
224
+ if (!existsSync(dest)) writeText(dest, PROFILE_STUB);
225
+ }
226
+
227
+ /**
228
+ * `_build_render.ensure_excludes_stub` — the sovereign-repo list, seeded once and NEVER
229
+ * overwritten. Reachable only from the Claude-shaped emits, which is why it arrived with
230
+ * `emitClaudeRender` rather than with the bundle stubs beside it.
231
+ */
232
+ export function ensureExcludesStub(out) {
233
+ const dest = path.join(out, EXCLUDES_FILE);
234
+ if (!existsSync(dest)) writeText(dest, EXCLUDES_STUB);
235
+ }
236
+
237
+ /** `_build_render.ensure_bundle_gitignore`. */
238
+ export function ensureBundleGitignore(out) {
239
+ const dest = path.join(out, '.gitignore');
240
+ if (!existsSync(dest)) writeText(dest, BUNDLE_GITIGNORE);
241
+ }
242
+
243
+ /** `_build_render.ensure_memory_index` — only inside an EXISTING store dir. */
244
+ export function ensureMemoryIndex(memDir) {
245
+ if (!isDir(memDir)) return;
246
+ const idx = path.join(memDir, 'MEMORY.md');
247
+ if (!existsSync(idx)) writeText(idx, '# Memory Index\n');
248
+ }
249
+
250
+ /** `_build_render.ensure_notebook_index`. */
251
+ export function ensureNotebookIndex(nbDir) {
252
+ if (!isDir(nbDir)) return;
253
+ const idx = path.join(nbDir, 'NOTEBOOK.md');
254
+ if (!existsSync(idx)) writeText(idx, '# Notebook Index\n');
255
+ }
256
+
@@ -11,33 +11,33 @@
11
11
  * gate — is where that would be discovered.
12
12
  *
13
13
  * NEITHER `--sync-themes` NOR `--validate-only` IS REACHABLE FROM ANY CELL. `golden.py`'s
14
- * `_argv` emits neither flag (argued in situ at `bin/geneseed.mjs`'s dispatch), so the
14
+ * `_argv` emits neither flag (argued in situ at `bin/build-driver.mjs`'s dispatch), so the
15
15
  * acceptance matrix that gates all nine emits is structurally blind to both, and the driver
16
16
  * refused them rather than crossing for exactly that reason. The gate is
17
- * `tests/test_maintainer_tools_parity.py`: a corpus of hand-made theme directories run
18
- * through BOTH implementations, comparing the printed output, the return value, and the
19
- * resulting FILE BYTES.
17
+ * `tests/unit/maintainer_tools.test.mjs`, replaying the frozen recording in
18
+ * `tests/__snapshots__/sync_themes.json`: a corpus of hand-made theme directories, comparing
19
+ * the printed output, the return value and the resulting FILE BYTES against what the reference
20
+ * answered. Nothing can re-record it, so a change here that moves a byte is a product change.
20
21
  *
21
- * WHY THIS MODULE IS NOT `js/doctor.mjs`. `--sync-themes` has no doctor dependency, so it
22
+ * WHY THIS MODULE IS NOT `js/inspect/doctor.mjs`. `--sync-themes` has no doctor dependency, so it
22
23
  * stays on the generator driver where its flag lives — and the driver is under a transitive
23
24
  * ban on starting processes, which this module's imports respect. Its sibling
24
25
  * `--validate-only` needs the doctor and therefore could not stay; see `cmdValidate`.
25
26
  *
26
- * THE PRIMITIVES ARE BORROWED, NOT REWRITTEN. `pySplitLines`, `pyStripSpace`/`pyLStripSpace`/
27
- * `pyRStripSpace`, `parseJson`, `pyEq`, `jsonDumpsCompact`, `jsonDumpsIndent`, `readText` and
27
+ * THE PRIMITIVES ARE BORROWED, NOT REWRITTEN. `splitLines`, `stripWhitespace`/`stripWhitespaceStart`/
28
+ * `stripWhitespaceEnd`, `parseJson`, `deepEquals`, `jsonDumpsCompact`, `jsonDumpsIndent`, `readText` and
28
29
  * `writeText` all already exist and are all already gated. This file's documented failure
29
30
  * mode is the same primitive existing twice under two names and the two disagreeing, and
30
31
  * `str.strip` and `json.dumps` are precisely the two that would have been re-derived here.
31
32
  */
32
33
  import path from 'node:path';
33
34
 
34
- import { THEMES } from './checkout.mjs';
35
- import { themeFiles } from './installs.mjs';
36
- import { pySplitLines } from './lib/udiff.mjs';
37
- import {
38
- jsonDumpsCompact, jsonDumpsIndent, parseJson, pyEq, pyLStripSpace, pyRStripSpace,
39
- pyStripSpace, readText, writeText,
40
- } from './lib/fs.mjs';
35
+ import { THEMES } from './source.mjs';
36
+ import { themeFiles } from '../hosts/installs.mjs';
37
+ import { splitLines } from '../lib/udiff.mjs';
38
+ import { readText, writeText } from '../lib/fs.mjs';
39
+ import { jsonDumpsCompact, jsonDumpsIndent, parseJson, deepEquals } from '../lib/json.mjs';
40
+ import { stripWhitespaceStart, stripWhitespaceEnd, stripWhitespace } from '../lib/text.mjs';
41
41
 
42
42
  /**
43
43
  * `_build_render._insert_theme_keys` — insert ONLY the missing keys into the theme's existing
@@ -53,7 +53,7 @@ import {
53
53
  * caller then falls back to a full re-dump.
54
54
  *
55
55
  * FOUR PRIMITIVES CARRY THE WHOLE THING, and each is a place the two languages differ:
56
- * - `pySplitLines` is `str.splitlines()`, which breaks on U+2028/U+2029 where `split('\\n')`
56
+ * - `splitLines` is `str.splitlines()`, which breaks on U+2028/U+2029 where `split('\\n')`
57
57
  * does not — a theme file that acquired one from a paste would be re-lined by Python and
58
58
  * not by a naive port, and the insertion index would then point at a different key;
59
59
  * - the three `strip` spellings are Python's whitespace set, not `String.prototype.trim`'s;
@@ -62,13 +62,13 @@ import {
62
62
  * `AGENT_COLORS`, the only non-string among `_TEMPLATE.json`'s 140 values. `ensureAscii`
63
63
  * is false because the reference passes `ensure_ascii=False`, and 33 template values
64
64
  * carry non-ASCII text that would otherwise be escaped into the committed file;
65
- * - `pyEq`, because the safety net compares two `json.loads` results and `===` is identity
65
+ * - `deepEquals`, because the safety net compares two `json.loads` results and `===` is identity
66
66
  * for containers — a `===` here would return `null` every time and silently route every
67
67
  * theme through the re-dump fallback, i.e. rewrite all 14 files in full.
68
68
  */
69
69
  export function insertThemeKeys(raw, theme, tmpl, tmplKeys, missing) {
70
- const lines = pySplitLines(raw);
71
- if (!lines.length || pyStripSpace(lines[0]) !== '{') return null;
70
+ const lines = splitLines(raw);
71
+ if (!lines.length || stripWhitespace(lines[0]) !== '{') return null;
72
72
 
73
73
  const lineOf = (key) => {
74
74
  // `f' "{key}":'` — raw interpolation, NOT a JSON-escaped key. A theme key carrying a
@@ -92,9 +92,9 @@ export function insertThemeKeys(raw, theme, tmpl, tmplKeys, missing) {
92
92
  }
93
93
  if (pred === null) {
94
94
  // No earlier template key exists in this theme: insert right after `{`.
95
- const follows = lines.slice(1).some((ln) => pyLStripSpace(ln).startsWith('"'));
95
+ const follows = lines.slice(1).some((ln) => stripWhitespaceStart(ln).startsWith('"'));
96
96
  lines.splice(1, 0, entry + (follows ? ',' : ''));
97
- } else if (pyRStripSpace(lines[pred]).endsWith(',')) {
97
+ } else if (stripWhitespaceEnd(lines[pred]).endsWith(',')) {
98
98
  lines.splice(pred + 1, 0, `${entry},`);
99
99
  } else {
100
100
  // Predecessor was the last entry: it gains the comma, the new line is last.
@@ -109,7 +109,7 @@ export function insertThemeKeys(raw, theme, tmpl, tmplKeys, missing) {
109
109
  try { reparsed = parseJson(newText); } catch { return null; }
110
110
  const want = { ...theme };
111
111
  for (const k of missing) want[k] = tmpl[k];
112
- return pyEq(reparsed, want) ? newText : null;
112
+ return deepEquals(reparsed, want) ? newText : null;
113
113
  }
114
114
 
115
115
  /**
@@ -122,8 +122,8 @@ export function insertThemeKeys(raw, theme, tmpl, tmplKeys, missing) {
122
122
  * default is the checkout's own `themes/`, which is what the driver passes.
123
123
  *
124
124
  * PRINTS THROUGH A BARE `process.stdout.write`, like the generator's other ~25 print sites and
125
- * unlike every CLI verb: `bin/geneseed.mjs`'s `main` wraps the whole run in `withPyNewlines`,
126
- * so a `pyPrint` here would translate a second time and put `\r\r\n` on every line.
125
+ * unlike every CLI verb: `bin/build-driver.mjs`'s `main` wraps the whole run in `withPlatformNewlines`,
126
+ * so a `printOut` here would translate a second time and put `\r\r\n` on every line.
127
127
  *
128
128
  * ONE DELIBERATE DIVERGENCE FROM THE REFERENCE, changed on BOTH sides in this commit: a
129
129
  * missing or unreadable `_TEMPLATE.json` used to print and `return 0`, which the CLI maps to
@@ -133,7 +133,7 @@ export function insertThemeKeys(raw, theme, tmpl, tmplKeys, missing) {
133
133
  * is what makes the branch gateable at all: `json.JSONDecodeError` and V8's parse error
134
134
  * disagree on both wording and offset, so a message carrying `{e}` can never be compared
135
135
  * across the two implementations. The PER-THEME unreadable message below keeps its `{e}` and
136
- * therefore keeps that divergence, matching the convention `js/doctor.mjs` already ships at
136
+ * therefore keeps that divergence, matching the convention `js/inspect/doctor.mjs` already ships at
137
137
  * six sites — a skipped theme is a warning, not the tool's verdict.
138
138
  */
139
139
  export function syncThemes(themesDir = THEMES) {
@@ -0,0 +1,155 @@
1
+ /**
2
+ * What version an install carries, and whether the one about to be written is newer.
3
+ *
4
+ * `sourceFingerprint` answers "did the sources change?" without reading a version at all;
5
+ * the rest read, compare and write the release marker, and warn on a downgrade. Split out of
6
+ * `emit.mjs` because none of it is about emitting — `update` and the doctor ask the same
7
+ * questions of an install nobody is emitting into.
8
+ */
9
+ import path from 'node:path';
10
+ import { VERSION_MARKER } from '../hosts/hosts.mjs';
11
+ import { sourceReleaseVersion } from '../hosts/opencode.mjs';
12
+ import { readText, writeText } from '../lib/fs.mjs';
13
+ import { isDir, relPosix, rglobFiles } from './emit-common.mjs';
14
+ import { createHash } from 'node:crypto';
15
+ import { readFileSync } from 'node:fs';
16
+
17
+ // ---------------------------------------------------------------------------
18
+ // Version identity
19
+ // ---------------------------------------------------------------------------
20
+
21
+ /**
22
+ * `_build_render.source_fingerprint`.
23
+ *
24
+ * Sorted by the ROOT-relative POSIX path as a plain string — NOT `comparePaths`. Python
25
+ * sorts by an explicit `key=` here rather than by `Path` objects, so this one sort is
26
+ * case-SENSITIVE on every platform while `sorted(SRC.rglob("*"))` in `render_all` is not.
27
+ * Two sorts of the same files, two different orders, and only one of them is observable
28
+ * in the output; reproducing the wrong one changes the fingerprint of every install.
29
+ */
30
+ export function sourceFingerprint(cfg) {
31
+ const h = createHash('sha256');
32
+ const files = [];
33
+ for (const r of [cfg.src, cfg.themes, cfg.pluginSrc, cfg.workflowSrc]) {
34
+ if (isDir(r)) files.push(...rglobFiles(r));
35
+ }
36
+ const keyed = files.map((p) => [relPosix(cfg.root, p), p]);
37
+ keyed.sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0));
38
+ for (const [rel, p] of keyed) {
39
+ h.update(Buffer.from(`${rel}\0`, 'utf8'));
40
+ h.update(readFileSync(p));
41
+ h.update(Buffer.from([0]));
42
+ }
43
+ return h.digest('hex').slice(0, 12);
44
+ }
45
+
46
+ /**
47
+ * `_build_render.read_version` — the FINGERPRINT token, which is a different name and a
48
+ * different value from `read_release_version` right below it. `harness version` and the
49
+ * status panel both compare this one against `sourceFingerprint`.
50
+ *
51
+ * `txt.split()[0] if txt else None`: a marker that is empty or whitespace-only reads as no
52
+ * version at all, so the caller's candidate walk carries on past it rather than stopping.
53
+ */
54
+ export function readVersion(dir) {
55
+ let txt;
56
+ try {
57
+ txt = readText(path.join(dir, VERSION_MARKER)).trim();
58
+ } catch {
59
+ return null;
60
+ }
61
+ // `str.split()` with no argument splits on runs of whitespace AND drops the leading
62
+ // empties; `String.split(/\s+/)` does not, so an indented marker would yield ''.
63
+ const first = txt.split(/\s+/).filter(Boolean)[0];
64
+ return first === undefined ? null : first;
65
+ }
66
+
67
+ /** `_build_render.read_release_version`. */
68
+ function readReleaseVersion(out) {
69
+ let txt;
70
+ try {
71
+ txt = readText(path.join(out, VERSION_MARKER)).trim();
72
+ } catch {
73
+ // Python catches OSError only. A marker that is not valid UTF-8 raises there and
74
+ // degrades to U+FFFD here (Node's decoder substitutes rather than reporting) — the
75
+ // same asymmetry already recorded for `agent-overrides.json`, and on no covered path.
76
+ return null;
77
+ }
78
+ const m = /\[release ([^\]]+)\]/.exec(txt);
79
+ return m ? m[1] : null;
80
+ }
81
+
82
+ /** `_build_core._parse_version_tuple`. */
83
+ function parseVersionTuple(v) {
84
+ const parts = v.trim().split('.');
85
+ if (!parts.length) return null;
86
+ const out = [];
87
+ for (const p of parts) {
88
+ // `str.isdigit()`, not `Number.isInteger(+p)`: '' and '1e2' and ' 1' are all
89
+ // non-digit to Python, and `+p` accepts every one of them.
90
+ if (!/^[0-9]+$/.test(p)) return null;
91
+ out.push(Number(p));
92
+ }
93
+ return out;
94
+ }
95
+
96
+ /**
97
+ * `_build_core.version_is_newer` — null when either side fails to parse.
98
+ *
99
+ * EXPORTED FOR ITS GATE, and the argument belongs here rather than in the test. Its only
100
+ * caller is `warnIfDowngrade`, one line down, whose whole observable behaviour is "print a
101
+ * warning or say nothing" — so through the emit face `false` and `null` are the SAME
102
+ * observation, and six of the nine claims this function makes are invisible. That numeric
103
+ * comparison beats lexical (`1.10.0` > `1.9.0`), that a short tuple is zero-padded on the
104
+ * right (`1.2` == `1.2.0`), and that an unparseable side answers `null` rather than `false`
105
+ * are each a decision with a wrong answer that no cell and no emit can distinguish.
106
+ *
107
+ * Nothing else changes: it is the same function, called the same way, from the same one
108
+ * place. See `tests/unit/lifecycle.test.mjs`.
109
+ */
110
+ export function versionIsNewer(a, b) {
111
+ const ta = parseVersionTuple(a);
112
+ const tb = parseVersionTuple(b);
113
+ if (ta === null || tb === null) return null;
114
+ const width = Math.max(ta.length, tb.length);
115
+ for (let i = 0; i < width; i++) {
116
+ const x = ta[i] ?? 0;
117
+ const y = tb[i] ?? 0;
118
+ if (x !== y) return x > y;
119
+ }
120
+ return false;
121
+ }
122
+
123
+ /** `_build_render._warn_if_downgrade` — STDOUT, matching the Python. */
124
+ function warnIfDowngrade(cfg, out) {
125
+ const deployed = readReleaseVersion(out);
126
+ if (deployed === null) return;
127
+ const current = sourceReleaseVersion(cfg);
128
+ if (versionIsNewer(deployed, current)) {
129
+ process.stdout.write(`[geneseed] WARN: installing older Geneseed ${current} over newer `
130
+ + `${deployed} at ${out} — did you forget git pull?\n`);
131
+ }
132
+ }
133
+
134
+ /** `_build_render.write_version`. */
135
+ export function writeVersion(cfg, out) {
136
+ warnIfDowngrade(cfg, out);
137
+ const fp = sourceFingerprint(cfg);
138
+ const release = sourceReleaseVersion(cfg);
139
+ // `datetime.date.today().isoformat()` — the LOCAL date. `toISOString()` is UTC and
140
+ // would stamp tomorrow's date for anyone east of Greenwich after their evening.
141
+ //
142
+ // No gate can catch this one, and it is recorded rather than left to be discovered:
143
+ // both runtimes read their own clock at almost the same instant, so a UTC-vs-local
144
+ // mutation produces identical bytes except during the hour the two dates disagree.
145
+ // Mutating it away stays green here for a reason no cell can remove — freezing the
146
+ // clock across a process boundary is not something the harness can do — which is
147
+ // exactly why the choice is spelled out instead of relying on the comparison.
148
+ const d = new Date();
149
+ const today = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-`
150
+ + `${String(d.getDate()).padStart(2, '0')}`;
151
+ writeText(path.join(out, VERSION_MARKER),
152
+ `${fp} (built ${today}) [release ${release}]\n`);
153
+ return fp;
154
+ }
155
+