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.
- package/CHANGELOG.md +110 -0
- package/DESIGN.md +31 -20
- package/README.md +2 -2
- package/SETUP.md +30 -3
- package/SHIPPED.md +1 -1
- package/bin/{geneseed.mjs → build-driver.mjs} +88 -42
- package/bin/geneseed-cli.mjs +33 -32
- package/bin/geneseed-hook.mjs +7 -7
- package/docs/design-history.md +40 -40
- package/docs/extending.md +113 -85
- package/docs/web/rules.md +2 -0
- package/docs/web/setup-choices.md +13 -1
- package/harness.config.json +1 -1
- package/js/README.md +203 -0
- package/js/build/bundle.mjs +231 -0
- package/js/{catalog.mjs → build/catalog.mjs} +7 -7
- package/js/build/emit-claude.mjs +287 -0
- package/js/build/emit-common.mjs +255 -0
- package/js/build/emit-opencode.mjs +214 -0
- package/js/{generate.mjs → build/generate.mjs} +45 -31
- package/js/{render.mjs → build/render.mjs} +72 -11
- package/js/{checkout.mjs → build/source.mjs} +52 -11
- package/js/build/stubs.mjs +256 -0
- package/js/{themes.mjs → build/themes.mjs} +24 -24
- package/js/build/version.mjs +155 -0
- package/js/{hooks.mjs → hosts/hooks.mjs} +47 -44
- package/js/{hosts.mjs → hosts/hosts.mjs} +30 -28
- package/js/{installs.mjs → hosts/installs.mjs} +68 -16
- package/js/{link.mjs → hosts/link.mjs} +29 -28
- package/js/{mcp.mjs → hosts/mcp.mjs} +10 -9
- package/js/{native.mjs → hosts/native.mjs} +24 -22
- package/js/{opencode.mjs → hosts/opencode.mjs} +12 -7
- package/js/{settings.mjs → hosts/settings.mjs} +94 -69
- package/js/inspect/checks-authoring.mjs +761 -0
- package/js/inspect/checks-build.mjs +231 -0
- package/js/inspect/checks-repo.mjs +308 -0
- package/js/{diff.mjs → inspect/diff.mjs} +34 -31
- package/js/inspect/doctor.mjs +286 -0
- package/js/{excludes.mjs → inspect/excludes.mjs} +30 -33
- package/js/{inventory.mjs → inspect/inventory.mjs} +34 -20
- package/js/{registry.mjs → inspect/registry.mjs} +12 -10
- package/js/inspect/scan.mjs +125 -0
- package/js/{status.mjs → inspect/status.mjs} +27 -25
- package/js/inspect/validate.mjs +199 -0
- package/js/lib/fs.mjs +37 -759
- package/js/lib/json.mjs +344 -0
- package/js/lib/paths.mjs +242 -0
- package/js/lib/proc.mjs +3 -3
- package/js/lib/text.mjs +181 -0
- package/js/lib/udiff.mjs +1 -1
- package/js/{memory.mjs → maintain/memory.mjs} +10 -10
- package/js/{migrate.mjs → maintain/migrate.mjs} +28 -26
- package/js/{setup.mjs → maintain/setup.mjs} +49 -37
- package/js/{uninstall.mjs → maintain/uninstall.mjs} +62 -58
- package/js/{update.mjs → maintain/update.mjs} +51 -51
- package/js/{anim.mjs → ui/anim.mjs} +30 -28
- package/js/{cli.mjs → ui/cli.mjs} +10 -10
- package/js/{menu.mjs → ui/menu.mjs} +10 -9
- package/js/{tui.mjs → ui/tui.mjs} +36 -34
- package/js/web/actions.mjs +124 -89
- package/js/web/activity.mjs +22 -21
- package/js/web/api.mjs +88 -58
- package/js/web/daemon.mjs +319 -0
- package/js/web/docs.mjs +24 -21
- package/js/web/graph.mjs +0 -0
- package/js/web/handler.mjs +442 -0
- package/js/web/jobs.mjs +29 -21
- package/js/web/routes.mjs +223 -0
- package/js/web/server.mjs +56 -987
- package/package.json +11 -3
- package/src/AGENT.md.tmpl +1 -1
- package/src/doctrines/README.md +1 -1
- package/themes/opencode/README.md +1 -1
- package/web/dist/assets/{About-_MnJcwb_.js → About-CPvIFZGh.js} +1 -1
- package/web/dist/assets/{Activity-DpjqO-FR.js → Activity-z0uXdS7J.js} +1 -1
- package/web/dist/assets/{ActivityDetail-Bvrjv8oW.js → ActivityDetail-CEj5IJpF.js} +1 -1
- package/web/dist/assets/{Diff-DZ0IehBV.js → Diff-BMR8fPuG.js} +1 -1
- package/web/dist/assets/{Doctor-cu4krooO.js → Doctor-Bz5qE4r9.js} +1 -1
- package/web/dist/assets/{Graph-DUOPY65j.js → Graph-BV6a6t2l.js} +1 -1
- package/web/dist/assets/{Harnesses-B5Osg-BF.js → Harnesses-C5Ag5PaQ.js} +1 -1
- package/web/dist/assets/Laws-BK07NKw9.js +3 -0
- package/web/dist/assets/{Library-8y69I3_z.js → Library-CiXN9sC3.js} +1 -1
- package/web/dist/assets/{Markdown-Ds5GS1OD.js → Markdown-DG6FjUz-.js} +1 -1
- package/web/dist/assets/{Profile-7Jxa7ZUX.js → Profile-B2oSvdxH.js} +1 -1
- package/web/dist/assets/{Rules-UZ0jWkjg.js → Rules-DobVPd80.js} +1 -1
- package/web/dist/assets/{Skills-Bgkl0xLd.js → Skills-DQPsvsn9.js} +1 -1
- package/web/dist/assets/{StatusBadge-Cx4caF_2.js → StatusBadge-BhJ3HhdI.js} +1 -1
- package/web/dist/assets/{Themes-De8az7Sv.js → Themes-BP38FBU9.js} +1 -1
- package/web/dist/assets/{activity-VDAdVQ3q.js → activity-D9mfv7pY.js} +1 -1
- package/web/dist/assets/index-BE226Gv6.css +1 -0
- package/web/dist/assets/{index-BKrjBRV4.js → index-BZ7cPLMI.js} +1 -1
- package/web/dist/assets/index-BgIs5eIa.js +12 -0
- package/web/dist/assets/{index-wNiNzFFQ.js → index-DGM5j2hB.js} +1 -1
- package/web/dist/index.html +2 -2
- package/web/src/pages/Laws.jsx +135 -69
- package/docs/declined.md +0 -200
- package/docs/limits.md +0 -329
- package/js/doctor.mjs +0 -1712
- package/js/emit.mjs +0 -1410
- package/web/dist/assets/Laws-D8DJgTBp.js +0 -3
- package/web/dist/assets/index-1pPSz7Kl.css +0 -1
- package/web/dist/assets/index-C-gb-q-r.js +0 -12
|
@@ -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/
|
|
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,17 +12,17 @@
|
|
|
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/
|
|
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/
|
|
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
|
*/
|
|
25
|
-
import { readdirSync } from 'node:fs';
|
|
25
|
+
import { readdirSync, readFileSync } from 'node:fs';
|
|
26
26
|
import path from 'node:path';
|
|
27
27
|
import { fileURLToPath } from 'node:url';
|
|
28
28
|
|
|
@@ -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/
|
|
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
|
*
|
|
@@ -83,6 +83,34 @@ export function discoverNames(dir, first) {
|
|
|
83
83
|
*/
|
|
84
84
|
export const PACK_ORDER = ['craft', 'rigor', 'ops', 'process'];
|
|
85
85
|
|
|
86
|
+
/**
|
|
87
|
+
* Every doctrine rule address this checkout ships — `pack.n`, in render order.
|
|
88
|
+
*
|
|
89
|
+
* The ONE enumerator, for the same arithmetic reason `PACK_ORDER` is one array: the CLI
|
|
90
|
+
* flag, the console's trust boundary and the doctor all have to close `--exclude-rules`
|
|
91
|
+
* against the SAME set, and a second copy is a set that drifts by one rule the day a pack
|
|
92
|
+
* grows. Read from the UNRENDERED source (`### {{DOCTRINE}} <pack> <n>`) rather than from a
|
|
93
|
+
* built tree, because an address is a property of the source and the rendered heading is
|
|
94
|
+
* themed — fourteen spellings of a thing that must compare equal to itself.
|
|
95
|
+
*
|
|
96
|
+
* Ordered by `PACK_ORDER`, then by the order the headings stand in the pack file — NOT sorted.
|
|
97
|
+
* The file is what renders, so mirroring it is what keeps this list and the rendered
|
|
98
|
+
* constitution in the same sequence; a sort would silently disagree with a file whose headings
|
|
99
|
+
* were ever out of order, and disagree in the direction that reads as correct.
|
|
100
|
+
*/
|
|
101
|
+
export function knownRuleIds() {
|
|
102
|
+
const out = [];
|
|
103
|
+
for (const pack of PACK_ORDER) {
|
|
104
|
+
const file = path.join(SRC, 'doctrines', `${pack}.md`);
|
|
105
|
+
let text = '';
|
|
106
|
+
try { text = readFileSync(file, 'utf8'); } catch { continue; }
|
|
107
|
+
for (const m of text.matchAll(/^### \{\{DOCTRINE\}\} ([a-z]+) (\d+)\b/gm)) {
|
|
108
|
+
if (m[1] === pack) out.push(`${pack}.${Number(m[2])}`);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
return out;
|
|
112
|
+
}
|
|
113
|
+
|
|
86
114
|
/**
|
|
87
115
|
* `_build_core.js_cfg()`, originated rather than received.
|
|
88
116
|
*
|
|
@@ -95,7 +123,9 @@ export const PACK_ORDER = ['craft', 'rigor', 'ops', 'process'];
|
|
|
95
123
|
* out to renderers and a caller that sorted or spliced it in place would rewrite
|
|
96
124
|
* `PACK_ORDER` for the whole process.
|
|
97
125
|
*/
|
|
98
|
-
export function makeCfg({
|
|
126
|
+
export function makeCfg({
|
|
127
|
+
posture = 'peer', mode = 'direct', doctrines = PACK_ORDER, excludeRules = [],
|
|
128
|
+
} = {}) {
|
|
99
129
|
return {
|
|
100
130
|
root: ROOT,
|
|
101
131
|
src: SRC,
|
|
@@ -107,5 +137,16 @@ export function makeCfg({ posture = 'peer', mode = 'direct', doctrines = PACK_OR
|
|
|
107
137
|
posture,
|
|
108
138
|
mode,
|
|
109
139
|
doctrines: Array.isArray(doctrines) ? [...doctrines] : [...PACK_ORDER],
|
|
140
|
+
// ⚠ THE SECOND DOCTRINE AXIS, AND ITS DEFAULT IS THE OPPOSITE WAY ROUND. `doctrines`
|
|
141
|
+
// defaults to EVERYTHING because an unknown selection must bind the most; `excludeRules`
|
|
142
|
+
// defaults to NOTHING for exactly the same reason — an exclusion is a rule taken AWAY, so
|
|
143
|
+
// "unspecified" has to mean "nothing taken away". Both defaults fail closed; they only
|
|
144
|
+
// look inconsistent until you ask which direction is the safe one for each.
|
|
145
|
+
//
|
|
146
|
+
// Addresses are `<pack>.<n>`, the same spelling the console deep-links and the catalogue
|
|
147
|
+
// publishes. An exclusion whose pack is not in `doctrines` is harmless and stays: the pack
|
|
148
|
+
// is already absent, and dropping the exclusion would silently re-admit the rule the day
|
|
149
|
+
// the pack came back.
|
|
150
|
+
excludeRules: Array.isArray(excludeRules) ? [...excludeRules] : [],
|
|
110
151
|
};
|
|
111
152
|
}
|
|
@@ -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/
|
|
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/
|
|
18
|
-
*
|
|
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. `
|
|
27
|
-
* `
|
|
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 './
|
|
35
|
-
import { themeFiles } from '
|
|
36
|
-
import {
|
|
37
|
-
import {
|
|
38
|
-
|
|
39
|
-
|
|
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
|
-
* - `
|
|
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
|
-
* - `
|
|
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 =
|
|
71
|
-
if (!lines.length ||
|
|
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) =>
|
|
95
|
+
const follows = lines.slice(1).some((ln) => stripWhitespaceStart(ln).startsWith('"'));
|
|
96
96
|
lines.splice(1, 0, entry + (follows ? ',' : ''));
|
|
97
|
-
} else if (
|
|
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
|
|
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/
|
|
126
|
-
* so a `
|
|
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
|
+
|