geneseed 3.1.1 → 3.1.3

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 (106) hide show
  1. package/CHANGELOG.md +123 -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/docs/web/exclusions.md +1 -1
  12. package/docs/web/footprint.md +1 -1
  13. package/docs/web/foreman-mode.md +2 -2
  14. package/docs/web/themes-author.md +1 -1
  15. package/docs/web-ui.md +41 -29
  16. package/harness.config.json +1 -1
  17. package/js/README.md +203 -0
  18. package/js/build/bundle.mjs +231 -0
  19. package/js/{catalog.mjs → build/catalog.mjs} +7 -7
  20. package/js/build/emit-claude.mjs +287 -0
  21. package/js/build/emit-common.mjs +255 -0
  22. package/js/build/emit-opencode.mjs +214 -0
  23. package/js/{generate.mjs → build/generate.mjs} +27 -28
  24. package/js/{render.mjs → build/render.mjs} +13 -9
  25. package/js/{checkout.mjs → build/source.mjs} +9 -9
  26. package/js/build/stubs.mjs +256 -0
  27. package/js/{themes.mjs → build/themes.mjs} +24 -24
  28. package/js/build/version.mjs +155 -0
  29. package/js/{hooks.mjs → hosts/hooks.mjs} +47 -44
  30. package/js/{hosts.mjs → hosts/hosts.mjs} +30 -28
  31. package/js/{installs.mjs → hosts/installs.mjs} +18 -16
  32. package/js/{link.mjs → hosts/link.mjs} +29 -28
  33. package/js/{mcp.mjs → hosts/mcp.mjs} +10 -9
  34. package/js/{native.mjs → hosts/native.mjs} +24 -22
  35. package/js/{opencode.mjs → hosts/opencode.mjs} +12 -7
  36. package/js/{settings.mjs → hosts/settings.mjs} +63 -55
  37. package/js/inspect/checks-authoring.mjs +761 -0
  38. package/js/inspect/checks-build.mjs +231 -0
  39. package/js/inspect/checks-repo.mjs +308 -0
  40. package/js/{diff.mjs → inspect/diff.mjs} +34 -31
  41. package/js/inspect/doctor.mjs +286 -0
  42. package/js/{excludes.mjs → inspect/excludes.mjs} +30 -33
  43. package/js/{inventory.mjs → inspect/inventory.mjs} +20 -17
  44. package/js/{registry.mjs → inspect/registry.mjs} +12 -10
  45. package/js/inspect/scan.mjs +125 -0
  46. package/js/{status.mjs → inspect/status.mjs} +27 -25
  47. package/js/inspect/validate.mjs +199 -0
  48. package/js/lib/fs.mjs +37 -759
  49. package/js/lib/json.mjs +344 -0
  50. package/js/lib/paths.mjs +242 -0
  51. package/js/lib/proc.mjs +3 -3
  52. package/js/lib/text.mjs +181 -0
  53. package/js/lib/udiff.mjs +1 -1
  54. package/js/{memory.mjs → maintain/memory.mjs} +10 -10
  55. package/js/{migrate.mjs → maintain/migrate.mjs} +28 -26
  56. package/js/{setup.mjs → maintain/setup.mjs} +34 -32
  57. package/js/{uninstall.mjs → maintain/uninstall.mjs} +55 -56
  58. package/js/{update.mjs → maintain/update.mjs} +51 -51
  59. package/js/{anim.mjs → ui/anim.mjs} +30 -28
  60. package/js/{cli.mjs → ui/cli.mjs} +10 -10
  61. package/js/{menu.mjs → ui/menu.mjs} +10 -9
  62. package/js/{tui.mjs → ui/tui.mjs} +36 -34
  63. package/js/web/actions.mjs +84 -85
  64. package/js/web/activity.mjs +22 -21
  65. package/js/web/api.mjs +154 -51
  66. package/js/web/daemon.mjs +319 -0
  67. package/js/web/docs.mjs +24 -21
  68. package/js/web/graph.mjs +0 -0
  69. package/js/web/handler.mjs +442 -0
  70. package/js/web/jobs.mjs +21 -19
  71. package/js/web/routes.mjs +245 -0
  72. package/js/web/server.mjs +56 -987
  73. package/package.json +11 -3
  74. package/src/doctrines/README.md +1 -1
  75. package/themes/opencode/README.md +1 -1
  76. package/web/dist/assets/{Activity-D_sYBlW-.js → Activity-B49fE_ug.js} +1 -1
  77. package/web/dist/assets/{ActivityDetail-BomKcMhy.js → ActivityDetail-abt7hzD2.js} +1 -1
  78. package/web/dist/assets/{Diff-BSvi1WUg.js → Diff-D3_bVkih.js} +1 -1
  79. package/web/dist/assets/{Doctor-Cz1s-y1n.js → Doctor-hXp7srsx.js} +1 -1
  80. package/web/dist/assets/{Graph-CuntzFJ0.js → Graph-DvYMtlvl.js} +1 -1
  81. package/web/dist/assets/Harness-BIWei7N5.js +1 -0
  82. package/web/dist/assets/Laws-CxmnRdwt.js +3 -0
  83. package/web/dist/assets/{Library-DXhTN9ju.js → Library-MjbOkHIW.js} +1 -1
  84. package/web/dist/assets/{Markdown-BoiZcdUl.js → Markdown-Dx_w0faR.js} +1 -1
  85. package/web/dist/assets/{Profile-DfCHlZ03.js → Profile-BRFvv2gr.js} +1 -1
  86. package/web/dist/assets/{Rules-eND7hJv9.js → Rules-CfsV1EVy.js} +1 -1
  87. package/web/dist/assets/{Skills-CDl9opZO.js → Skills-DArA6IHS.js} +1 -1
  88. package/web/dist/assets/{StatusBadge-DSDtcArX.js → StatusBadge-C9bZOEZk.js} +1 -1
  89. package/web/dist/assets/{activity-Dmb0FTAx.js → activity-Cc0n2jwx.js} +1 -1
  90. package/web/dist/assets/index-Bx3oxZ1o.css +1 -0
  91. package/web/dist/assets/index-DC3-GlCY.js +1 -0
  92. package/web/dist/assets/index-DP035wPa.js +12 -0
  93. package/web/dist/assets/{index-Dg58ScBe.js → index-dHi-7M-F.js} +1 -1
  94. package/web/dist/index.html +2 -2
  95. package/web/src/pages/Laws.jsx +5 -19
  96. package/docs/declined.md +0 -200
  97. package/docs/limits.md +0 -329
  98. package/js/doctor.mjs +0 -1750
  99. package/js/emit.mjs +0 -1411
  100. package/web/dist/assets/About-DyK7yRy7.js +0 -1
  101. package/web/dist/assets/Harnesses-DgRyOviS.js +0 -1
  102. package/web/dist/assets/Laws-CWk3Mpri.js +0 -3
  103. package/web/dist/assets/Themes-CoAWuIeM.js +0 -2
  104. package/web/dist/assets/index-B-mXquBZ.css +0 -1
  105. package/web/dist/assets/index-CH_Z725C.js +0 -12
  106. package/web/dist/assets/index-jrY4m9jP.js +0 -1
@@ -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
+