geneseed 3.4.0 → 3.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +283 -0
- package/DESIGN.md +11 -14
- package/QUICKSTART.md +3 -1
- package/README.md +51 -42
- package/SETUP.md +25 -27
- package/SHIPPED.md +26 -32
- package/adapters/bob/README.md +12 -9
- package/adapters/copilot/README.md +26 -25
- package/adapters/opencode/GLOBAL-HARNESS-SPEC.md +1 -1
- package/adapters/opencode/README.md +2 -2
- package/adapters/opencode/plugins/geneseed-context.js +5 -3
- package/adapters/opencode/plugins/geneseed-workflow.js +30 -3
- package/adapters/opencode/workflows/_runtime.js +97 -7
- package/bin/build-driver.mjs +5 -1248
- package/bin/geneseed-cli.mjs +11 -6
- package/bin/geneseed-hook.mjs +4 -2
- package/docs/candidate-hosts.md +1 -1
- package/docs/design-history.md +37 -37
- package/docs/extending.md +45 -24
- package/docs/token-footprint.md +52 -26
- package/docs/web/claude-hooks.md +3 -2
- package/docs/web/collaboration.md +2 -2
- package/docs/web/first-session.md +1 -1
- package/docs/web/footprint.md +2 -2
- package/docs/web/harnesses.md +1 -1
- package/docs/web/model.md +1 -1
- package/docs/web/plugin-workflow.md +1 -0
- package/docs/web/rules.md +3 -3
- package/docs/web/setup-choices.md +2 -2
- package/docs/web/themes-author.md +1 -1
- package/docs/web-ui.md +18 -10
- package/harness.config.json +1 -1
- package/js/README.md +6 -5
- package/js/build/driver.mjs +1191 -0
- package/js/build/emit-claude.mjs +17 -10
- package/js/build/emit-common.mjs +4 -4
- package/js/build/generate.mjs +1 -1
- package/js/build/render.mjs +48 -44
- package/js/build/stubs.mjs +1 -1
- package/js/build/themes.mjs +4 -4
- package/js/build/version.mjs +12 -0
- package/js/cli-table.json +21 -6
- package/js/hosts/hooks.mjs +89 -74
- package/js/hosts/hosts.mjs +19 -2
- package/js/hosts/mcp.mjs +14 -20
- package/js/hosts/native.mjs +23 -17
- package/js/hosts/settings.mjs +52 -104
- package/js/inspect/checks-authoring.mjs +117 -3
- package/js/inspect/diff.mjs +1 -1
- package/js/inspect/doctor.mjs +1 -1
- package/js/inspect/excludes.mjs +1 -1
- package/js/inspect/scan.mjs +3 -14
- package/js/inspect/status.mjs +2 -2
- package/js/inspect/validate.mjs +1 -1
- package/js/lib/fs.mjs +23 -8
- package/js/lib/json.mjs +7 -1
- package/js/lib/proc.mjs +6 -4
- package/js/maintain/migrate.mjs +1 -1
- package/js/maintain/setup.mjs +1 -1
- package/js/maintain/uninstall.mjs +17 -15
- package/js/ui/cli.mjs +19 -0
- package/js/ui/menu.mjs +1 -1
- package/js/web/actions.mjs +4 -4
- package/js/web/activity.mjs +4 -4
- package/js/web/api.mjs +14 -11
- package/js/web/daemon.mjs +1 -1
- package/js/web/docs.mjs +12 -12
- package/js/web/handler.mjs +12 -5
- package/js/web/jobs.mjs +1 -1
- package/package.json +1 -1
- package/src/AGENT.md.tmpl +69 -36
- package/src/agents/developer.md +2 -2
- package/src/agents/reviewer.md +5 -2
- package/src/doctrines/README.md +26 -3
- package/src/doctrines/craft.md +78 -5
- package/src/doctrines/ops.md +62 -0
- package/src/doctrines/process.md +91 -4
- package/src/doctrines/rigor.md +40 -0
- package/src/laws/universal.md +27 -21
- package/src/memory/README.md +4 -2
- package/src/modes/foreman.md +5 -5
- package/src/notebook/README.md +2 -1
- package/src/ontology/universal.md +71 -67
- package/src/postures/peer.md +3 -0
- package/src/skills/bruno-collection-generator.md +1 -1
- package/src/skills/bruno-test-writer.md +2 -2
- package/src/skills/daydream/LICENSE +21 -0
- package/src/skills/daydream/VENDOR.md +3 -1
- package/src/skills/geneseed-code-review.md +6 -2
- package/src/skills/parallel-agents.md +3 -3
- package/src/skills/pipeline.md +4 -4
- package/src/skills/react-view-transitions/VENDOR.md +7 -1
- package/src/skills/workflow.md +12 -4
- package/themes/_TEMPLATE.json +6 -4
- package/themes/biker.json +5 -3
- package/themes/commentator.json +5 -3
- package/themes/cyberpunk.json +5 -3
- package/themes/gamer.json +5 -3
- package/themes/imperial.json +5 -3
- package/themes/joker.json +5 -3
- package/themes/marvin.json +5 -3
- package/themes/mean.json +5 -3
- package/themes/military.json +5 -3
- package/themes/neutral.json +6 -4
- package/themes/opencode/README.md +2 -2
- package/themes/pirate.json +5 -3
- package/themes/sports.json +5 -3
- package/themes/verstappen.json +5 -3
- package/themes/wizard.json +5 -3
- package/web/dist/assets/Activity-Flgf4kHy.js +1 -0
- package/web/dist/assets/ActivityDetail-DIpr85nh.js +1 -0
- package/web/dist/assets/CatalogRow-CjyLjaLb.js +1 -0
- package/web/dist/assets/Diff-Deisdha8.js +2 -0
- package/web/dist/assets/Docs-DGUdY4l4.js +2 -0
- package/web/dist/assets/Doctor-F74lJ69P.js +1 -0
- package/web/dist/assets/FilterInput-BakJLvaV.js +1 -0
- package/web/dist/assets/GreenhouseView-D072lBiD.js +2 -0
- package/web/dist/assets/Harness-DNRP2Va7.js +1 -0
- package/web/dist/assets/JournalView-DWFqZ0Iw.js +1 -0
- package/web/dist/assets/Laws-BN6g3HU7.js +3 -0
- package/web/dist/assets/Library-CDOv4emM.js +1 -0
- package/web/dist/assets/Markdown-BQAvN8xx.js +38 -0
- package/web/dist/assets/OperatorHudView-CwNIUVX8.js +2 -0
- package/web/dist/assets/Profile-CpP9BTne.js +1 -0
- package/web/dist/assets/Rules-DFXsUCB7.js +1 -0
- package/web/dist/assets/Settings-BqZVUtJ5.js +1 -0
- package/web/dist/assets/Skills-Cu6kBgfF.js +1 -0
- package/web/dist/assets/StatusBadge-Cg6j-NJL.js +1 -0
- package/web/dist/assets/activity-iWaWVtsR.js +1 -0
- package/web/dist/assets/drawable-BgfrEBer.js +1 -0
- package/web/dist/assets/index-CAqrWhKl.js +2 -0
- package/web/dist/assets/index-Ruh93A3A.css +1 -0
- package/web/dist/assets/jobBuckets-Dbq8ZlWW.js +1 -0
- package/web/dist/assets/lawCats-BLgxl151.js +1 -0
- package/web/dist/assets/react-DbkvxWG5.js +8 -0
- package/web/dist/assets/rolldown-runtime-CbXtAM7H.js +1 -0
- package/web/dist/index.html +4 -3
- package/web/src/pages/Laws.jsx +22 -8
- package/src/skills/react-view-transitions/README.md +0 -42
- package/web/dist/assets/Activity-DMGhhjQc.js +0 -1
- package/web/dist/assets/ActivityDetail-CeQcDA7e.js +0 -1
- package/web/dist/assets/CatalogRow-CnSbDPyg.js +0 -1
- package/web/dist/assets/Diff-D_ksMBD1.js +0 -5
- package/web/dist/assets/Doctor-Brb3_oCT.js +0 -1
- package/web/dist/assets/FilterInput-DbRWPhtT.js +0 -1
- package/web/dist/assets/GreenhouseView-F3VNFr73.js +0 -2
- package/web/dist/assets/Harness-DJSwfYeF.js +0 -1
- package/web/dist/assets/JournalView-BLRYRnB3.js +0 -1
- package/web/dist/assets/Laws-CKYns9qe.js +0 -3
- package/web/dist/assets/Library-BK0YloSv.js +0 -1
- package/web/dist/assets/Markdown-BzTRzc7a.js +0 -46
- package/web/dist/assets/OperatorHudView-D3OQSGp5.js +0 -2
- package/web/dist/assets/Profile-CPA2u_0g.js +0 -1
- package/web/dist/assets/Rules-Lukel-Ru.js +0 -1
- package/web/dist/assets/Skills-BKRLHH7m.js +0 -1
- package/web/dist/assets/StatusBadge-DI3weRKd.js +0 -1
- package/web/dist/assets/activity-C_H2Bx1P.js +0 -1
- package/web/dist/assets/index-B7_Wga23.js +0 -2
- package/web/dist/assets/index-DzdQjvM1.css +0 -1
- package/web/dist/assets/index-N8dTrVro.js +0 -1
- package/web/dist/assets/index-Py_IQjgK.js +0 -10
- package/web/dist/assets/jobBuckets-DfTUJUxR.js +0 -1
- package/web/dist/assets/lawCats-DUS9UTH4.js +0 -1
- package/web/dist/assets/react-DOpiTn5v.js +0 -32
package/bin/build-driver.mjs
CHANGED
|
@@ -1,1252 +1,9 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
/**
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
* that 19 `rituals/` modules read 55 distinct names from, and ~11 sites spawn
|
|
7
|
-
* `[sys.executable, build.py, ...]`. None of those flip here: the Python CLI has to
|
|
8
|
-
* survive this phase intact, and every gate that drives it keeps driving it.
|
|
9
|
-
*
|
|
10
|
-
* WHAT THIS FILE ORIGINATES, AND WHY THAT IS THE PHASE'S WHOLE POINT.
|
|
11
|
-
* Every phase since P2d has asked one question at the process boundary: which values does
|
|
12
|
-
* the CHILD resolve, and which does the parent decide and send? P3c's answer for `cfgDir`
|
|
13
|
-
* was "the child must never resolve it — a child that did would render 135 files into the
|
|
14
|
-
* developer's real ~/.config/opencode". P4 inverts that rule rather than repeating it:
|
|
15
|
-
* this file IS the parent now, so the values a child must never resolve are exactly the
|
|
16
|
-
* values this file must originate. `ROOT` and the seven paths under it come from this
|
|
17
|
-
* script's own location, not from an inherited `cfg`.
|
|
18
|
-
*
|
|
19
|
-
* A KEY IS DELIBERATELY ABSENT FROM `cfg`, and its absence is load-bearing.
|
|
20
|
-
* `js_cfg()` (_build_core.py:199) always sent `structure` and `capabilityLinkRe`, because
|
|
21
|
-
* the Python originals were module-level names that TESTS MUTATE — `_OWNED` membership
|
|
22
|
-
* asked one level out. This driver has no Python module to mutate, so it sends neither;
|
|
23
|
-
* `js/build/render.mjs:215`'s `cfg.structure ?? STRUCTURE` takes its right-hand branch for
|
|
24
|
-
* that reason — a fallback that was dead code until P4, always overridden by the driver
|
|
25
|
-
* that always supplies it. `capabilityLinkRe`'s equivalent fallback in
|
|
26
|
-
* `js/build/emit-common.mjs`'s `stripCapabilityLinks` was P4's other one made live, and the
|
|
27
|
-
* over-engineering cleanup then deleted the override branch outright: nothing had ever
|
|
28
|
-
* supplied one, so there was no longer a `cfg` left to be absent from.
|
|
29
|
-
*
|
|
30
|
-
* THE FOOTPRINT DEFAULT IS THE FLAG'S, NOT THE FUNCTION'S. `--footprint` defaults to
|
|
31
|
-
* `lean` (build.py:354); every `emit_*`/`build` SIGNATURE defaults to `full`. A driver
|
|
32
|
-
* that reproduced the signature default would emit a different harness in every cell while
|
|
33
|
-
* every gate that calls the functions directly stayed green.
|
|
3
|
+
* `geneseed-build` — the generator's entry. Everything it does lives in `js/build/driver.mjs`,
|
|
4
|
+
* which the CLI verbs import too; this file only hands it argv. Keeping the entry this thin is
|
|
5
|
+
* what lets a `js/` module import the driver without running it on the importer's argv.
|
|
34
6
|
*/
|
|
35
|
-
import {
|
|
36
|
-
existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, renameSync, rmdirSync,
|
|
37
|
-
unlinkSync,
|
|
38
|
-
} from 'node:fs';
|
|
39
|
-
import path from 'node:path';
|
|
40
|
-
import { fileURLToPath } from 'node:url';
|
|
41
|
-
import { parseArgs as nodeParseArgs } from 'node:util';
|
|
42
|
-
import { build, phaseLog } from '../js/build/bundle.mjs';
|
|
43
|
-
import { emitClaudeRender } from '../js/build/emit-claude.mjs';
|
|
44
|
-
import { emitOpencodeRender, emitOpencodeGlobalRender } from '../js/build/emit-opencode.mjs';
|
|
45
|
-
import { copilotIntegrityCheck, settingsIntegrityCheck } from '../js/hosts/settings.mjs';
|
|
46
|
-
import { writeText, withPlatformNewlines, isFile } from '../js/lib/fs.mjs';
|
|
47
|
-
import { parseJson, jsonDumpsIndent } from '../js/lib/json.mjs';
|
|
48
|
-
// P5c moved these out of this file: `bin/geneseed-cli.mjs` needs the same four resolvers to
|
|
49
|
-
// find a global install, and a resolver that decides WHERE a driver writes is the last thing
|
|
50
|
-
// that should exist twice. golden.py's 259 cells are what made the move safe to attempt.
|
|
51
|
-
import {
|
|
52
|
-
GLOBAL_MANIFEST, resolvePath, opencodeConfigDir, claudeConfigDir, bobConfigDir,
|
|
53
|
-
copilotConfigDir,
|
|
54
|
-
} from '../js/hosts/hosts.mjs';
|
|
7
|
+
import { main } from '../js/build/driver.mjs';
|
|
55
8
|
|
|
56
|
-
|
|
57
|
-
// status` renders to count, so `bin/geneseed-cli.mjs` needs the same checkout paths and the
|
|
58
|
-
// same cfg. golden.py's 259 cells all build one, which is what made the move safe.
|
|
59
|
-
import {
|
|
60
|
-
ROOT, CONFIG, discoverNames, makeCfg, PACK_ORDER, knownRuleIds,
|
|
61
|
-
} from '../js/build/source.mjs';
|
|
62
|
-
|
|
63
|
-
// P5f moved these, for the same arithmetic a third time: `harness rebuild-all` reads the
|
|
64
|
-
// registry to find every install it must re-emit, and a CLI verb may not reach into a driver
|
|
65
|
-
// for a reader. See js/inspect/registry.mjs.
|
|
66
|
-
import { registryRecord, registryRoots } from '../js/inspect/registry.mjs';
|
|
67
|
-
|
|
68
|
-
// P2. `--sync-themes` crossed into its own module rather than into this file: it is 90 lines
|
|
69
|
-
// of textual surgery over committed files with a corpus of its own, and it is the half of the
|
|
70
|
-
// maintainer pair that needs nothing this driver is banned from having.
|
|
71
|
-
import { syncThemes } from '../js/build/themes.mjs';
|
|
72
|
-
|
|
73
|
-
/** The nine `--emit` choices, in build.py:337-338's order. */
|
|
74
|
-
const EMITS = ['files', 'opencode', 'opencode-global', 'claude', 'claude-global',
|
|
75
|
-
'bob', 'bob-global', 'copilot', 'copilot-global'];
|
|
76
|
-
|
|
77
|
-
/**
|
|
78
|
-
* The emits this driver can run end to end — all nine, as of P4e.
|
|
79
|
-
*
|
|
80
|
-
* The set is kept (rather than deleted along with the refusal branch) because it is the
|
|
81
|
-
* thing `test_the_node_driver_classifies_every_emit` reads to assert the PARTITION: every
|
|
82
|
-
* `--emit` choice is either here or refused with exit 3, so a tenth emit added to `EMITS`
|
|
83
|
-
* without crossing is a failure rather than an oversight. It is also what
|
|
84
|
-
* `test_every_relocation_var_moves_its_global_target` cross-checks its host table against.
|
|
85
|
-
*/
|
|
86
|
-
const PORTED = new Set(EMITS);
|
|
87
|
-
|
|
88
|
-
/** `_build_emit.PRIMARY_AGENT_SRC`. */
|
|
89
|
-
const PRIMARY_AGENT_SRC = path.join(ROOT, 'adapters', 'opencode', 'agents', 'orchestrator.md');
|
|
90
|
-
/**
|
|
91
|
-
* `_build_global.HOSTS[host]['native_catalog']` — does the host catalogue skills and agents
|
|
92
|
-
* to the model itself? A DECISION the driver takes and sends, never something the render
|
|
93
|
-
* half re-derives: `emit_opencode` passes `host_catalogs_natively('opencode')`, which is
|
|
94
|
-
* `true`, where the plain `files` bundle passes `false`. Getting this wrong collapses (or
|
|
95
|
-
* fails to collapse) AGENT.md's capability tables in every cell.
|
|
96
|
-
*/
|
|
97
|
-
const NATIVE_CATALOG = { opencode: true, claude: true, bob: false, copilot: false };
|
|
98
|
-
|
|
99
|
-
/** `_build_render.resolve_out` — absolute, or relative to the CURRENT WORKING DIRECTORY
|
|
100
|
-
* (not to ROOT), so the harness renders straight into any repository.
|
|
101
|
-
*
|
|
102
|
-
* EXPORTED IN P5i because `js/maintain/setup.mjs` became its second caller: `_setup_summary_lines`
|
|
103
|
-
* names the AGENT.md a bundle emit just wrote, and it names it by the same resolution the
|
|
104
|
-
* emit used. Copying the one line would be a second owner of the rule that a bare `Harness`
|
|
105
|
-
* follows the invocation and not the checkout — which is what
|
|
106
|
-
* `build/the-bundle-follows-cwd-not-the-checkout` exists to gate.
|
|
107
|
-
*
|
|
108
|
-
* `resolvePath` AND NOT `path.resolve`, and the difference is the whole reason `resolvePath`
|
|
109
|
-
* exists: the reference ends in `.resolve()`, which canonicalises — 8.3 short names expanded
|
|
110
|
-
* on Windows, symlinks followed on POSIX, the filesystem's own casing — where `path.resolve`
|
|
111
|
-
* only normalises `.`/`..`. Nine `resolvePath` call sites had the rule and this one did not.
|
|
112
|
-
* It is invisible on a machine whose paths are already canonical, which is every machine
|
|
113
|
-
* this ran on until GitHub's Windows runner handed it a `C:\\Users\\RUNNER~1\\...` and the
|
|
114
|
-
* two CLIs printed the same directory under two different names — a 70-byte difference in a
|
|
115
|
-
* line compared BYTE for byte by `test_the_two_entry_points_agree_on_stdout_BYTES`. The
|
|
116
|
-
* argument to `resolvePath` is made absolute first so its `expanduser` cannot fire: the
|
|
117
|
-
* reference does not expand `~` here, and a bare `~` is a legal directory name. */
|
|
118
|
-
export function resolveOut(raw) {
|
|
119
|
-
return resolvePath(path.resolve(process.cwd(), raw));
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
/**
|
|
123
|
-
* build.py:307-321 — the three defaults that come from `harness.config.json`.
|
|
124
|
-
*
|
|
125
|
-
* The corrupt-file branch is reproduced including its imprecision: the warning says only
|
|
126
|
-
* "using theme 'neutral'", but the branch also resets posture and mode. Matching the
|
|
127
|
-
* Python text matters more than fixing it here, because the two CLIs' stderr is compared
|
|
128
|
-
* byte-for-byte by `tests/golden.py` (:316).
|
|
129
|
-
*/
|
|
130
|
-
function configDefaults() {
|
|
131
|
-
const d = {
|
|
132
|
-
theme: 'neutral', posture: 'peer', mode: 'direct', doctrines: [...PACK_ORDER],
|
|
133
|
-
excludeRules: [],
|
|
134
|
-
};
|
|
135
|
-
if (!existsSync(CONFIG)) return d;
|
|
136
|
-
try {
|
|
137
|
-
const data = JSON.parse(readFileSync(CONFIG, 'utf8'));
|
|
138
|
-
if (data && typeof data === 'object' && !Array.isArray(data)) {
|
|
139
|
-
if (data.theme !== undefined) d.theme = data.theme;
|
|
140
|
-
if (data.posture !== undefined) d.posture = data.posture;
|
|
141
|
-
if (data.mode !== undefined) d.mode = data.mode;
|
|
142
|
-
// `doctrines` is the only config key that is a LIST, so it is the only one that can be
|
|
143
|
-
// the right type and still nonsense. A bad value warns and falls back to all packs
|
|
144
|
-
// rather than dying: this file is hand-editable, and a typo here must not brick every
|
|
145
|
-
// rebuild of an install whose AGENT.md is otherwise fine. `[]` is a legitimate value
|
|
146
|
-
// (no packs) and must survive the check — hence an explicit Array test, not truthiness.
|
|
147
|
-
if (data.doctrines !== undefined) {
|
|
148
|
-
const known = discoverNames('doctrines', PACK_ORDER[0]);
|
|
149
|
-
if (Array.isArray(data.doctrines) && data.doctrines.every((x) => known.includes(x))) {
|
|
150
|
-
d.doctrines = PACK_ORDER.filter((p) => data.doctrines.includes(p));
|
|
151
|
-
} else {
|
|
152
|
-
process.stderr.write(`[geneseed] WARN: ${path.basename(CONFIG)} "doctrines" is not a `
|
|
153
|
-
+ 'list of known pack names — using all packs.\n');
|
|
154
|
-
}
|
|
155
|
-
}
|
|
156
|
-
// The second axis, read the same forgiving way and defaulting the OTHER direction: a
|
|
157
|
-
// bad value excludes NOTHING, because the safe answer for a list of things to take
|
|
158
|
-
// away is to take nothing away. Same reasoning as `doctrines` defaulting to all.
|
|
159
|
-
if (data.excludeRules !== undefined) {
|
|
160
|
-
if (Array.isArray(data.excludeRules)
|
|
161
|
-
&& data.excludeRules.every((x) => typeof x === 'string')) {
|
|
162
|
-
d.excludeRules = [...new Set(data.excludeRules.map((x) => x.replace(/[ \t]+/, '.')))]
|
|
163
|
-
.sort();
|
|
164
|
-
} else {
|
|
165
|
-
process.stderr.write(`[geneseed] WARN: ${path.basename(CONFIG)} "excludeRules" is `
|
|
166
|
-
+ 'not a list of rule addresses — excluding nothing.\n');
|
|
167
|
-
}
|
|
168
|
-
}
|
|
169
|
-
}
|
|
170
|
-
} catch {
|
|
171
|
-
process.stderr.write(`[geneseed] WARN: ${path.basename(CONFIG)} is unreadable — `
|
|
172
|
-
+ "using theme 'neutral'.\n");
|
|
173
|
-
}
|
|
174
|
-
return d;
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
/**
|
|
178
|
-
* The parser's tables, at MODULE scope so `usage` reads the same two objects the loop below
|
|
179
|
-
* dispatches on. They were local to `parseArgs` until `-h` arrived; a help text listing its
|
|
180
|
-
* own private copy of the flags is a second source that drifts silently the first time a
|
|
181
|
-
* flag is added to one and not the other, and nothing downstream would say so.
|
|
182
|
-
*/
|
|
183
|
-
const VALUED = {
|
|
184
|
-
'--theme': 'theme', '--posture': 'posture', '--mode': 'mode',
|
|
185
|
-
'--doctrines': 'doctrines', '--exclude-rules': 'excludeRules',
|
|
186
|
-
'--out': 'out', '--target': 'out', '--emit': 'emit',
|
|
187
|
-
'--footprint': 'footprint', '--root': 'root',
|
|
188
|
-
};
|
|
189
|
-
const FLAGS = {
|
|
190
|
-
'--sync-themes': 'syncThemes', '--validate-only': 'validateOnly',
|
|
191
|
-
'-v': 'verbose', '--verbose': 'verbose',
|
|
192
|
-
};
|
|
193
|
-
|
|
194
|
-
/**
|
|
195
|
-
* `util.parseArgs`'s own option table, DERIVED from `VALUED`/`FLAGS` rather than typed out a
|
|
196
|
-
* second time — a hand-written twin is exactly the anti-drift failure this module's docblock
|
|
197
|
-
* already names for `usage()`. Every `VALUED` entry is `type: 'string'` (so a bare `--flag
|
|
198
|
-
* value` form is captured as one token instead of leaking `value` as an orphan positional);
|
|
199
|
-
* every long `FLAGS` entry is `type: 'boolean'`. `-v` gets `short: 'v'` for the same reason
|
|
200
|
-
* `-h` deliberately does NOT get one below `PARSE_OPTIONS` — see `parseArgs`'s docblock.
|
|
201
|
-
*/
|
|
202
|
-
const PARSE_OPTIONS = Object.fromEntries([
|
|
203
|
-
...Object.keys(VALUED).map((f) => [f.slice(2), { type: 'string' }]),
|
|
204
|
-
...Object.keys(FLAGS).filter((f) => f.startsWith('--')).map((f) => [f.slice(2), { type: 'boolean' }]),
|
|
205
|
-
]);
|
|
206
|
-
PARSE_OPTIONS.verbose = { type: 'boolean', short: 'v' };
|
|
207
|
-
|
|
208
|
-
/** `token.name` (dashes stripped, short aliases folded in) -> this file's `args` key. */
|
|
209
|
-
const VALUED_BY_NAME = Object.fromEntries(Object.keys(VALUED).map((f) => [f.slice(2), VALUED[f]]));
|
|
210
|
-
const FLAG_BY_NAME = Object.fromEntries(
|
|
211
|
-
Object.keys(FLAGS).filter((f) => f.startsWith('--')).map((f) => [f.slice(2), FLAGS[f]]),
|
|
212
|
-
);
|
|
213
|
-
|
|
214
|
-
/**
|
|
215
|
-
* The four flags `choice` validates, as ONE table for the same anti-drift reason.
|
|
216
|
-
*
|
|
217
|
-
* A function and not a constant because two of the four read the checkout: `discoverNames`
|
|
218
|
-
* scans `postures/` and `modes/`, so the answer depends on the source tree this driver is
|
|
219
|
-
* standing in and cannot be frozen at import.
|
|
220
|
-
*
|
|
221
|
-
* `--doctrines` is DELIBERATELY not a fifth row. Every consumer of this table treats an
|
|
222
|
-
* entry as one value drawn from a closed list — `choice` refuses anything else, and
|
|
223
|
-
* `usage`'s metavar prints the list as the whole legal surface. `--doctrines` takes a comma
|
|
224
|
-
* LIST plus the sentinel `none`, so a row here would have refused `craft,rigor` outright and
|
|
225
|
-
* advertised a metavar that lies. It is validated by `parseDoctrines` instead and prints the
|
|
226
|
-
* plain `DOCTRINES` metavar, exactly as `--theme` (also open-ended) already does.
|
|
227
|
-
*/
|
|
228
|
-
function choicesFor() {
|
|
229
|
-
return {
|
|
230
|
-
'--emit': EMITS,
|
|
231
|
-
'--footprint': ['lean', 'full'],
|
|
232
|
-
'--posture': discoverNames('postures', 'peer'),
|
|
233
|
-
'--mode': discoverNames('modes', 'direct'),
|
|
234
|
-
};
|
|
235
|
-
}
|
|
236
|
-
|
|
237
|
-
/**
|
|
238
|
-
* `--doctrines craft,rigor` / `--doctrines none` -> the cfg's pack array.
|
|
239
|
-
*
|
|
240
|
-
* Normalised into `PACK_ORDER` rather than kept in the order typed, which also dedupes: the
|
|
241
|
-
* rendered `Active packs:` line is a MARKER a later reader parses back out of a deployed
|
|
242
|
-
* carrier, and a marker whose contents depend on argument order is one that compares unequal
|
|
243
|
-
* to itself. Validation is against DISCOVERY, not `PACK_ORDER`, so the refusal names the
|
|
244
|
-
* packs the checkout actually has; a discovered pack missing from `PACK_ORDER` is a
|
|
245
|
-
* different fault and `js/build/render.mjs` refuses the whole build for it.
|
|
246
|
-
*/
|
|
247
|
-
/**
|
|
248
|
-
* `--exclude-rules "process 7,craft 3"` (or `process.7`) -> the cfg's exclusion array.
|
|
249
|
-
*
|
|
250
|
-
* BOTH SPELLINGS ACCEPTED ON THE WAY IN, ONE ON THE WAY OUT. The carrier's marker line and
|
|
251
|
-
* every citation in the corpus spell a rule `process 7`; the console's deep link and the
|
|
252
|
-
* catalogue spell it `process.7`. A flag that took only one of them would be wrong for
|
|
253
|
-
* whichever half of the documentation the reader had open, so it takes either and normalises
|
|
254
|
-
* to the dotted form the code compares.
|
|
255
|
-
*
|
|
256
|
-
* ⚠ VALIDATED AGAINST THE PACK FILES, NOT A RANGE. `process 9` and `craft 0` are both
|
|
257
|
-
* refusals, and so is a pack this checkout does not ship — an exclusion that names nothing is
|
|
258
|
-
* a typo that would otherwise take away nothing in silence, which is the failure this flag
|
|
259
|
-
* can least afford: the user asked for a rule to STOP binding and it would keep binding.
|
|
260
|
-
* Sorted, deduped, for the same marker-stability reason `--doctrines` is.
|
|
261
|
-
*/
|
|
262
|
-
function parseExcludeRules(value) {
|
|
263
|
-
const s = value.trim();
|
|
264
|
-
if (s === 'none' || s === '') return [];
|
|
265
|
-
const known = knownRuleIds();
|
|
266
|
-
const out = [];
|
|
267
|
-
for (const raw of s.split(',').map((x) => x.trim()).filter(Boolean)) {
|
|
268
|
-
const id = raw.replace(/[ \t]+/, '.');
|
|
269
|
-
if (!known.includes(id)) {
|
|
270
|
-
die(2, `argument --exclude-rules: invalid choice: '${raw}' (choose from `
|
|
271
|
-
+ `${known.map((c) => `'${c.replace('.', ' ')}'`).join(', ')}, or 'none')`);
|
|
272
|
-
}
|
|
273
|
-
out.push(id);
|
|
274
|
-
}
|
|
275
|
-
return [...new Set(out)].sort();
|
|
276
|
-
}
|
|
277
|
-
|
|
278
|
-
function parseDoctrines(value) {
|
|
279
|
-
const s = value.trim();
|
|
280
|
-
const known = discoverNames('doctrines', PACK_ORDER[0]);
|
|
281
|
-
if (s === 'none') return [];
|
|
282
|
-
const names = s.split(',').map((x) => x.trim()).filter(Boolean);
|
|
283
|
-
if (!names.length) {
|
|
284
|
-
die(2, "argument --doctrines: expected a comma-separated list of packs, or 'none'");
|
|
285
|
-
}
|
|
286
|
-
for (const n of names) {
|
|
287
|
-
if (!known.includes(n)) {
|
|
288
|
-
die(2, `argument --doctrines: invalid choice: '${n}' (choose from `
|
|
289
|
-
+ `${known.map((c) => `'${c}'`).join(', ')}, or 'none')`);
|
|
290
|
-
}
|
|
291
|
-
}
|
|
292
|
-
return PACK_ORDER.filter((p) => names.includes(p));
|
|
293
|
-
}
|
|
294
|
-
|
|
295
|
-
/**
|
|
296
|
-
* `-h/--help` — the flag argparse handed the reference for free and this hand-rolled parser
|
|
297
|
-
* did not, so the two entry points disagreed: the reference printed usage and exited 0 while
|
|
298
|
-
* `geneseed-build --help` died with `unrecognized arguments: --help` and exit 2.
|
|
299
|
-
*
|
|
300
|
-
* NO CELL COULD SEE IT. `golden.py`'s `_argv` builds every cell out of the render flags and
|
|
301
|
-
* never emits `--help`, so the byte comparison only ever ran inputs both implementations
|
|
302
|
-
* were built for — a flag missing from one side is invisible to a gate that never passes it.
|
|
303
|
-
* `test_the_help_text_names_every_flag_the_reference_takes` is the gate, and it reads
|
|
304
|
-
* `build.py`'s `add_argument` calls rather than this file, so it fails on drift from EITHER
|
|
305
|
-
* side rather than agreeing with whichever one it was copied from.
|
|
306
|
-
*
|
|
307
|
-
* The text is deliberately NOT argparse's byte-for-byte. Reproducing its wrapping would put
|
|
308
|
-
* a hand-written copy of the reference's output in a file no gate compares, which is the
|
|
309
|
-
* drift this whole docblock is about; the surface here is the npx one, in the same class as
|
|
310
|
-
* the `--sync-themes` and `--validate-only` refusals that already answer differently.
|
|
311
|
-
*/
|
|
312
|
-
function usage() {
|
|
313
|
-
const choices = choicesFor();
|
|
314
|
-
const metavar = (flag) => (choices[flag]
|
|
315
|
-
? `{${choices[flag].join(',')}}`
|
|
316
|
-
: VALUED[flag].toUpperCase());
|
|
317
|
-
const valued = Object.keys(VALUED);
|
|
318
|
-
const bare = Object.keys(FLAGS);
|
|
319
|
-
return [
|
|
320
|
-
`usage: geneseed-build [-h] ${[...valued.map((f) => `[${f} ${metavar(f)}]`),
|
|
321
|
-
...bare.map((f) => `[${f}]`)].join(' ')}`,
|
|
322
|
-
'',
|
|
323
|
-
'Render the Geneseed harness for a theme.',
|
|
324
|
-
'',
|
|
325
|
-
'options:',
|
|
326
|
-
' -h, --help',
|
|
327
|
-
...valued.map((f) => ` ${f} ${metavar(f)}`),
|
|
328
|
-
...bare.map((f) => ` ${f}`),
|
|
329
|
-
'',
|
|
330
|
-
].join('\n');
|
|
331
|
-
}
|
|
332
|
-
|
|
333
|
-
/**
|
|
334
|
-
* argparse's `--flag value` and `--flag=value`, plus the `--target` alias for `--out` — the
|
|
335
|
-
* SPLITTING now done by `util.parseArgs({tokens: true})`, with every choice/mutex/wording
|
|
336
|
-
* decision this function made before P6 (Task 4) still made HERE, unmoved.
|
|
337
|
-
*
|
|
338
|
-
* `strict: false` is load-bearing twice: it lets an unrecognized flag surface as a token this
|
|
339
|
-
* walk can refuse in its OWN words rather than node's, and it is what makes a value-taking
|
|
340
|
-
* flag (`type: 'string'` in `PARSE_OPTIONS`) swallow the very next raw argument even when that
|
|
341
|
-
* argument itself looks like a flag — `--theme --posture peer` sets `theme` to the literal
|
|
342
|
-
* string `"--posture"` and leaves `peer` a stray token, exactly as the hand-rolled version
|
|
343
|
-
* being replaced did with its own `argv[i += 1]`. That is not a bug this port inherited by
|
|
344
|
-
* accident: it is the one shape `parseArgs`'s tokenizer reproduces for free, and reproducing
|
|
345
|
-
* anything smarter (a lookahead that refuses to swallow a flag-shaped value) would be new
|
|
346
|
-
* behaviour, not a preserved one.
|
|
347
|
-
*
|
|
348
|
-
* `-h` DELIBERATELY IS NOT `PARSE_OPTIONS.help`'s short alias. Declaring one would make
|
|
349
|
-
* `parseArgs` fold a bundled `-vh` into two tokens — `verbose` then `help` — and print the
|
|
350
|
-
* help text for a cluster the original parser refused outright as one unrecognized argument.
|
|
351
|
-
* Detecting help by re-reading `argv[tok.index]` for the literal text `-h`/`--help` keeps the
|
|
352
|
-
* ORIGINAL equality check (`tok === '-h' || tok === '--help'`) intact no matter how `parseArgs`
|
|
353
|
-
* privately tokenizes the cluster, and `argv[tok.index]` is what recovers that literal text
|
|
354
|
-
* for every refusal below — `tok.name`/`tok.rawName` are `parseArgs`'s own canonicalisation and
|
|
355
|
-
* would print `-b`/`-o`/`-g`/`-u`/`-s` for a bundle like `-bogus` where the original parser (and
|
|
356
|
-
* this one) refuses the whole typed token, `-bogus`, once.
|
|
357
|
-
*/
|
|
358
|
-
function parseArgs(argv, defaults) {
|
|
359
|
-
const args = {
|
|
360
|
-
theme: defaults.theme, posture: defaults.posture, mode: defaults.mode,
|
|
361
|
-
doctrines: defaults.doctrines,
|
|
362
|
-
excludeRules: defaults.excludeRules,
|
|
363
|
-
out: 'Harness', emit: 'files', footprint: 'lean', root: null,
|
|
364
|
-
syncThemes: false, validateOnly: false, verbose: false,
|
|
365
|
-
};
|
|
366
|
-
const { tokens } = nodeParseArgs({
|
|
367
|
-
args: argv, options: PARSE_OPTIONS, allowPositionals: true, strict: false, tokens: true,
|
|
368
|
-
});
|
|
369
|
-
for (const tok of tokens) {
|
|
370
|
-
if (tok.kind === 'option' && tok.value === undefined
|
|
371
|
-
&& (argv[tok.index] === '-h' || argv[tok.index] === '--help')) {
|
|
372
|
-
process.stdout.write(usage());
|
|
373
|
-
// The same marker `die` throws, with a SUCCESS code: help is not a refusal, and
|
|
374
|
-
// `main`'s catch is what turns either into this process's exit status. A bare
|
|
375
|
-
// `process.exit(0)` here would be the P5f bug in reverse — it takes `rebuild-all`'s
|
|
376
|
-
// loop with it, and skips the stdout flush the text was just written to.
|
|
377
|
-
const e = new Error('help');
|
|
378
|
-
e.exitCode = 0;
|
|
379
|
-
throw e;
|
|
380
|
-
}
|
|
381
|
-
if (tok.kind === 'option' && VALUED_BY_NAME[tok.name] !== undefined) {
|
|
382
|
-
if (tok.value === undefined) die(2, `argument ${tok.rawName}: expected one argument`);
|
|
383
|
-
args[VALUED_BY_NAME[tok.name]] = tok.value;
|
|
384
|
-
continue;
|
|
385
|
-
}
|
|
386
|
-
// `tok.value === undefined` excludes `--sync-themes=x` from matching here — a store_true
|
|
387
|
-
// flag never took `=` in the hand-rolled version either, because its `FLAGS[tok]` lookup
|
|
388
|
-
// keyed on the WHOLE typed token (equals sign and all), never on the split-off flag name.
|
|
389
|
-
// `parseArgs` always sets a `value` KEY on every option token (`undefined` when none was
|
|
390
|
-
// given), so this must compare the VALUE, not `'value' in tok` — the key is always there.
|
|
391
|
-
if (tok.kind === 'option' && tok.value === undefined && FLAG_BY_NAME[tok.name] !== undefined) {
|
|
392
|
-
args[FLAG_BY_NAME[tok.name]] = true;
|
|
393
|
-
continue;
|
|
394
|
-
}
|
|
395
|
-
// Every other shape — an unrecognized option, a bare positional, or `--` (which `parseArgs`
|
|
396
|
-
// always treats as a terminator but this parser never did) — refuses with the literal text
|
|
397
|
-
// typed at that argv position, matching the original's `unrecognized arguments: ${tok}`.
|
|
398
|
-
die(2, `unrecognized arguments: ${argv[tok.index]}`);
|
|
399
|
-
}
|
|
400
|
-
const choices = choicesFor();
|
|
401
|
-
for (const flag of Object.keys(choices)) choice(flag, args[VALUED[flag]], choices[flag]);
|
|
402
|
-
// Only when the flag was actually PASSED is this a string; left at its default it is
|
|
403
|
-
// already the array `configDefaults` built, and re-parsing an array would stringify it.
|
|
404
|
-
if (typeof args.doctrines === 'string') args.doctrines = parseDoctrines(args.doctrines);
|
|
405
|
-
if (typeof args.excludeRules === 'string') args.excludeRules = parseExcludeRules(args.excludeRules);
|
|
406
|
-
return args;
|
|
407
|
-
}
|
|
408
|
-
|
|
409
|
-
/**
|
|
410
|
-
* The generator's OWN flag surface, for the one consumer that is not this driver.
|
|
411
|
-
*
|
|
412
|
-
* `geneseed validate` takes `--theme/--emit/--out/--root/--footprint/-v` — the reference's
|
|
413
|
-
* `--validate-only` reads exactly the flags `build.py`'s parser already produced, so the port
|
|
414
|
-
* hands the CLI this parser rather than a second one beside it. That is not tidiness: a
|
|
415
|
-
* hand-rolled copy would have its own `--target` alias, its own `choices` lists and its own
|
|
416
|
-
* `-h`, and `test_the_help_text_names_every_flag_the_reference_takes` gates only this one.
|
|
417
|
-
*
|
|
418
|
-
* `withPlatformNewlines` because the refusal and `-h` paths WRITE: called from `bin/build-driver.mjs`
|
|
419
|
-
* they sit inside `main`'s funnel, and called from the CLI binary they would not.
|
|
420
|
-
*/
|
|
421
|
-
export function parseDriverArgs(argv) {
|
|
422
|
-
return withPlatformNewlines(() => parseArgs(argv, configDefaults()));
|
|
423
|
-
}
|
|
424
|
-
|
|
425
|
-
function choice(flag, value, allowed) {
|
|
426
|
-
if (!allowed.includes(value)) {
|
|
427
|
-
die(2, `argument ${flag}: invalid choice: '${value}' `
|
|
428
|
-
+ `(choose from ${allowed.map((c) => `'${c}'`).join(', ')})`);
|
|
429
|
-
}
|
|
430
|
-
}
|
|
431
|
-
|
|
432
|
-
/**
|
|
433
|
-
* argparse's error exit — a THROW since P5f, where it used to be `process.exit(code)`.
|
|
434
|
-
*
|
|
435
|
-
* `harness rebuild-all` re-emits every active install and its whole contract is "continue
|
|
436
|
-
* past a failure so one broken install never blocks the rest". The Python gets that for free:
|
|
437
|
-
* each rebuild is a SUBPROCESS, and a child that exits 2 hands back a return code. Here the
|
|
438
|
-
* driver is a module in the same process, so `process.exit` would take the loop, the CLI and
|
|
439
|
-
* every remaining install with it — the one place where importing rather than spawning is not
|
|
440
|
-
* transparent, and it turns a per-install failure into a total one.
|
|
441
|
-
*
|
|
442
|
-
* The marker is `exitCode`, the SAME one `assertSourceComplete` and `effectiveTheme` already
|
|
443
|
-
* use, and the first draft of this used a second name so that only `die` would be converted
|
|
444
|
-
* — on the argument that an incomplete source should keep its stack. That draft was wrong,
|
|
445
|
-
* and `rebuild-all/one-broken-install-does-not-stop-the-rest` is what said so: a
|
|
446
|
-
* `.geneseed-theme` naming a theme that does not exist makes `effectiveTheme` refuse, and
|
|
447
|
-
* with only `die` converted the refusal propagated out of the loop and the remaining
|
|
448
|
-
* installs were never rebuilt. `main` is standing in for a PROCESS, and a process boundary
|
|
449
|
-
* turns every deliberate refusal into an exit code — the narrower rule was a distinction
|
|
450
|
-
* with no principle behind it.
|
|
451
|
-
*
|
|
452
|
-
* One behaviour improves as a side effect, and it is worth naming rather than discovering
|
|
453
|
-
* later: `process.exit` terminates without flushing a pending stdout write, so a refusal on
|
|
454
|
-
* a slow pipe could lose output it had already produced. Returning through `main` lets Node
|
|
455
|
-
* drain normally.
|
|
456
|
-
*/
|
|
457
|
-
function die(code, msg) {
|
|
458
|
-
process.stderr.write(`geneseed: error: ${msg}\n`);
|
|
459
|
-
const e = new Error(msg);
|
|
460
|
-
e.exitCode = code;
|
|
461
|
-
throw e;
|
|
462
|
-
}
|
|
463
|
-
|
|
464
|
-
/**
|
|
465
|
-
* `_build_settings._hook_runner_entry()`'s two values — this driver's answer, which since
|
|
466
|
-
* P5b is NOT Python's.
|
|
467
|
-
*
|
|
468
|
-
* The Python original returns `sys.executable` and `<checkout>/rituals/harness.py`: the
|
|
469
|
-
* interpreter running `build.py`, and the harness it will hand `%*` to. This driver returns
|
|
470
|
-
* `process.execPath` and `<checkout>/bin/geneseed-hook.mjs`, because the four verbs the
|
|
471
|
-
* emitted hooks invoke — context, git-gate, rule-gate, learn — are now Node, and an install
|
|
472
|
-
* this driver emits therefore needs no Python at all for its hooks.
|
|
473
|
-
*
|
|
474
|
-
* WHAT THIS RETIRED, AND WHY IT IS A DELETION. Until this phase the function was
|
|
475
|
-
* `hookOptsOrDie`: it scanned PATH for an interpreter and refused the four Claude-shaped
|
|
476
|
-
* emits with exit 4 when it found none, because writing a shim that names a nonexistent
|
|
477
|
-
* interpreter silently disables every hook in the install. Both halves are gone. `runner` is
|
|
478
|
-
* `process.execPath` — the node already running this file, which by construction exists —
|
|
479
|
-
* so there is nothing left to discover and no way for discovery to fail. P4e kept the
|
|
480
|
-
* unreachable exit-3 branch because `test_the_node_driver_classifies_every_emit` asserts a
|
|
481
|
-
* partition it belongs to; nothing asserts a partition over this one, so keeping it would
|
|
482
|
-
* be keeping code no test can reach for no stated reason.
|
|
483
|
-
*
|
|
484
|
-
* THE SHIM IS MACHINE-WIDE, AND THAT IS THE DECISION THIS PHASE ACTUALLY TOOK.
|
|
485
|
-
* `hookShimPath()` is `$GENESEED_HOME`-or-`~/.geneseed` + `bin/geneseed-hook[.cmd]`, with no
|
|
486
|
-
* per-install component: every emit of every install on the machine rewrites the same file,
|
|
487
|
-
* and every install's hooks execute it. While both drivers baked Python that was invisible,
|
|
488
|
-
* because last-writer-wins wrote the same thing. It is observable now — a machine whose last
|
|
489
|
-
* emit ran through this file has EVERY install's hooks running under Node, including
|
|
490
|
-
* installs the Python driver wrote, and the reverse.
|
|
491
|
-
*
|
|
492
|
-
* That is correct exactly while the two entry points answer the same verbs the same way, so
|
|
493
|
-
* the gate that used to be a formality is now load-bearing:
|
|
494
|
-
* `test_the_entry_carries_exactly_the_verbs_the_emitter_wires` reads the emitter's wiring
|
|
495
|
-
* and `bin/geneseed-hook.mjs`'s VERBS table and requires them EQUAL — a wired verb the entry
|
|
496
|
-
* lacks is a dead hook on every install on the machine, not just this one. The alternative
|
|
497
|
-
* considered and rejected was a per-driver shim path: the path is baked into every already
|
|
498
|
-
* emitted hook command, so changing it makes every existing install's hooks stale until
|
|
499
|
-
* re-emit, which is P10's migration arriving five phases early.
|
|
500
|
-
*
|
|
501
|
-
* EXPORTED SINCE P6i, because the emitter is no longer its only caller.
|
|
502
|
-
* `js/maintain/uninstall.mjs`'s `remergeClaudeHooks` re-merges the canonical hooks when a disabled
|
|
503
|
-
* Claude install is turned back on, and `mergeClaudeSettings` refuses to guess the pair —
|
|
504
|
-
* correctly. There is exactly one right answer on this side and it is this function, so the
|
|
505
|
-
* second caller imports it rather than restating two lines that must never drift: a
|
|
506
|
-
* reactivate whose shim path differed from the emitter's would wire hooks pointing at a file
|
|
507
|
-
* the emitter never writes.
|
|
508
|
-
*/
|
|
509
|
-
export function hookRunnerEntry() {
|
|
510
|
-
return { runner: process.execPath, entry: path.join(ROOT, 'bin', 'geneseed-hook.mjs') };
|
|
511
|
-
}
|
|
512
|
-
|
|
513
|
-
/**
|
|
514
|
-
* `_build_global._preamble_exclude` — the `claudeMdExcludes` entry a PROJECT install writes
|
|
515
|
-
* to suppress the GLOBAL preamble of the same host, or null for a host that gets none.
|
|
516
|
-
*
|
|
517
|
-
* `_PREAMBLE_CONFIG_DIR` has exactly one key, so only a `CLAUDE.md` carrier resolves to a
|
|
518
|
-
* value: Bob's and Copilot's `AGENTS.md`, and Copilot's `copilot-instructions.md`, get null.
|
|
519
|
-
*
|
|
520
|
-
* Computed HERE and not in the render child, and that is the inverted boundary rule doing
|
|
521
|
-
* real work rather than being restated. P3b's note on the Python original: it resolves
|
|
522
|
-
* through `_build_core._claude_config_dir`, an `_OWNED` name precisely because the suite
|
|
523
|
-
* redirects it at a sandbox, and a redirect that stops at a subprocess half-works in
|
|
524
|
-
* silence. The child must therefore never derive it — but this file is the PARENT, so
|
|
525
|
-
* deriving it is exactly this file's job, and `the host emit` receives the answer.
|
|
526
|
-
*
|
|
527
|
-
* POSIX spelling, per the original: `claudeMdExcludes` entries are glob patterns, where a
|
|
528
|
-
* backslash is an escape, so the Windows-native form risks never matching.
|
|
529
|
-
*/
|
|
530
|
-
function preambleExclude(claudeMd) {
|
|
531
|
-
if (path.basename(claudeMd) !== 'CLAUDE.md') return null;
|
|
532
|
-
return resolvePath(path.join(claudeConfigDir(), 'CLAUDE.md')).split(path.sep).join('/');
|
|
533
|
-
}
|
|
534
|
-
|
|
535
|
-
/**
|
|
536
|
-
* `_build_global._warn_bob_global_over_project` — informational, never auto-removes.
|
|
537
|
-
*
|
|
538
|
-
* Same shape as `warnCopilotGlobalOverProject` and a DIFFERENT message, because the two
|
|
539
|
-
* hosts differ in what they can promise: Copilot simply stacks both preambles, while Bob's
|
|
540
|
-
* workspace rules may or may not shadow the global copy depending on precedence nothing can
|
|
541
|
-
* verify at emit time — so this text hedges where Copilot's asserts.
|
|
542
|
-
*/
|
|
543
|
-
function warnBobGlobalOverProject() {
|
|
544
|
-
const survivors = projectSurvivors('bob');
|
|
545
|
-
if (!survivors.length) return;
|
|
546
|
-
process.stderr.write(
|
|
547
|
-
`[geneseed] WARN: ${survivors.length} project Bob install(s) already exist — `
|
|
548
|
-
+ 'emitting GLOBAL now means BOTH may auto-load together in those repos '
|
|
549
|
-
+ "(doubled context) unless Bob's workspace rules truly shadow the global "
|
|
550
|
-
+ "one there. Review and remove what you don't want:\n");
|
|
551
|
-
for (const root of survivors) {
|
|
552
|
-
process.stderr.write(` - ${root} -> geneseed uninstall --target "${root}"\n`);
|
|
553
|
-
}
|
|
554
|
-
}
|
|
555
|
-
|
|
556
|
-
/**
|
|
557
|
-
* `_build_render._rel_under` — POSIX path of `out` relative to `root`, or '' when they are
|
|
558
|
-
* the same directory OR when `out` is not under `root` at all.
|
|
559
|
-
*
|
|
560
|
-
* `Path.relative_to` RAISES for a non-descendant; `path.relative` happily walks up with
|
|
561
|
-
* `..`. Reproducing the raise is the whole content of this function: a `..` prefix here
|
|
562
|
-
* would be written into `opencode.json`'s instruction path.
|
|
563
|
-
*/
|
|
564
|
-
function relUnder(out, root) {
|
|
565
|
-
const rel = path.relative(root, out);
|
|
566
|
-
if (rel === '' || rel === '.') return '';
|
|
567
|
-
if (rel.startsWith('..') || path.isAbsolute(rel)) return '';
|
|
568
|
-
return rel.split(path.sep).join('/');
|
|
569
|
-
}
|
|
570
|
-
|
|
571
|
-
/**
|
|
572
|
-
* `_build_global._write_manifest_atomic` — temp + rename, so a torn manifest can never make
|
|
573
|
-
* the next emit treat every owned file as the user's own.
|
|
574
|
-
*
|
|
575
|
-
* `writeText`, not `writeFileSync`: Python writes this through `Path.write_text`, so the
|
|
576
|
-
* whole document is CRLF on Windows.
|
|
577
|
-
*
|
|
578
|
-
* `jsonDumpsIndent`, not `JSON.stringify`: `json.dumps` defaults to `ensure_ascii=True` and
|
|
579
|
-
* escapes every non-ASCII character, where `JSON.stringify` emits it raw. This was
|
|
580
|
-
* `JSON.stringify` for a whole phase and nothing caught it, because the only manifest being
|
|
581
|
-
* written was the per-repo OpenCode one and its `_comment` is pure ASCII. The
|
|
582
|
-
* `opencode-global` comment contains an em dash, and that is the character that finally
|
|
583
|
-
* made the difference observable — a helper that is wrong everywhere but tellable in only
|
|
584
|
-
* one place.
|
|
585
|
-
*/
|
|
586
|
-
function writeManifestAtomic(file, data) {
|
|
587
|
-
const tmp = `${file}.tmp`;
|
|
588
|
-
writeText(tmp, `${jsonDumpsIndent(data)}\n`);
|
|
589
|
-
renameSync(tmp, file);
|
|
590
|
-
}
|
|
591
|
-
|
|
592
|
-
/**
|
|
593
|
-
* Write-before-delete: remove only what this layer owned before and no longer produces.
|
|
594
|
-
*
|
|
595
|
-
* Runs AFTER the whole current set is on disk, so a live file is never momentarily absent
|
|
596
|
-
* part-way through an emit. A pre-existing file that was never in the manifest is the
|
|
597
|
-
* user's and is not reachable from here at all.
|
|
598
|
-
*/
|
|
599
|
-
function pruneOwned(oc, oldOwned, owned) {
|
|
600
|
-
const now = new Set(owned);
|
|
601
|
-
const failed = [];
|
|
602
|
-
for (const rel of [...new Set(oldOwned)].filter((r) => !now.has(r)).sort()) {
|
|
603
|
-
const victim = path.join(oc, rel);
|
|
604
|
-
try {
|
|
605
|
-
if (isFile(victim)) {
|
|
606
|
-
unlinkSync(victim);
|
|
607
|
-
const parent = path.dirname(victim);
|
|
608
|
-
if (parent !== oc && readdirSync(parent).length === 0) rmdirSync(parent);
|
|
609
|
-
}
|
|
610
|
-
} catch (e) {
|
|
611
|
-
// Deliberately divergent and deliberately unreachable in the gates: `str(OSError)`
|
|
612
|
-
// ("[Errno 13] Permission denied: ...") and a Node error message ("EACCES: ...") can
|
|
613
|
-
// never agree, and no cell drives a real filesystem failure through this text.
|
|
614
|
-
failed.push(`${rel} (${e.message})`);
|
|
615
|
-
}
|
|
616
|
-
}
|
|
617
|
-
if (failed.length) {
|
|
618
|
-
process.stderr.write('[geneseed] WARN: could not remove stale owned file(s): '
|
|
619
|
-
+ `${failed.join(', ')}\n`);
|
|
620
|
-
}
|
|
621
|
-
}
|
|
622
|
-
|
|
623
|
-
/**
|
|
624
|
-
* The manifest PRE-read all three emit bodies performed inline: does a manifest already
|
|
625
|
-
* exist at `manifestPath`, and if so, what does it parse to. `existed` is what
|
|
626
|
-
* `emitOpencode` alone needs (as `manifestExisted`, threaded into the render job); every
|
|
627
|
-
* caller reads `doc.owned`, and `emitClaudeCore` also reads `doc.managed` — `doc` is `null`
|
|
628
|
-
* on a missing OR a corrupt manifest, so `(doc && doc.owned) || []` covers both the same way
|
|
629
|
-
* the three inline try/catches did.
|
|
630
|
-
*/
|
|
631
|
-
function readManifest(manifestPath) {
|
|
632
|
-
const existed = existsSync(manifestPath);
|
|
633
|
-
let doc = null;
|
|
634
|
-
if (existed) {
|
|
635
|
-
try { doc = parseJson(readFileSync(manifestPath, 'utf8')); } catch { doc = null; }
|
|
636
|
-
}
|
|
637
|
-
return { existed, doc };
|
|
638
|
-
}
|
|
639
|
-
|
|
640
|
-
/** The "+ primary agent, N command(s)" tail both OpenCode summary lines share. */
|
|
641
|
-
function extrasTail(stats) {
|
|
642
|
-
const extras = [...(stats.primary ? ['primary agent'] : []),
|
|
643
|
-
...(stats.nCommands ? [`${stats.nCommands} command(s)`] : [])];
|
|
644
|
-
return extras.length ? ` + ${extras.join(', ')}` : '';
|
|
645
|
-
}
|
|
646
|
-
|
|
647
|
-
/**
|
|
648
|
-
* `_build_global._project_survivors` — registered per-repo PROJECT installs of `emitName`.
|
|
649
|
-
*
|
|
650
|
-
* Each candidate root's own `.geneseed-emit` marker is read and compared to the literal
|
|
651
|
-
* emit name; an unreadable marker is skipped, never raised.
|
|
652
|
-
*/
|
|
653
|
-
function projectSurvivors(emitName) {
|
|
654
|
-
const out = [];
|
|
655
|
-
for (const root of registryRoots()) {
|
|
656
|
-
let marker;
|
|
657
|
-
try {
|
|
658
|
-
marker = readFileSync(path.join(root, '.geneseed-emit'), 'utf8').trim();
|
|
659
|
-
} catch { continue; }
|
|
660
|
-
if (marker === emitName) out.push(root);
|
|
661
|
-
}
|
|
662
|
-
return out;
|
|
663
|
-
}
|
|
664
|
-
|
|
665
|
-
/**
|
|
666
|
-
* `_build_global._warn_copilot_global_over_project` — purely informational.
|
|
667
|
-
*
|
|
668
|
-
* Copilot documents no exclude or shadow mechanism, so a global preamble and a project
|
|
669
|
-
* one simply stack. UNREACHABLE from every golden cell: each emits into a fresh sandbox
|
|
670
|
-
* whose registry is empty, so `projectSurvivors` is always `[]` and this never prints.
|
|
671
|
-
* Reaching it needs a project emit registered BEFORE a global one in the same sandbox.
|
|
672
|
-
*/
|
|
673
|
-
function warnCopilotGlobalOverProject() {
|
|
674
|
-
const survivors = projectSurvivors('copilot');
|
|
675
|
-
if (!survivors.length) return;
|
|
676
|
-
process.stderr.write(
|
|
677
|
-
`[geneseed] WARN: ${survivors.length} project Copilot install(s) already exist — `
|
|
678
|
-
+ 'emitting GLOBAL now means BOTH preambles load together in those repos '
|
|
679
|
-
+ '(doubled context): Copilot stacks ~/.copilot/copilot-instructions.md on top '
|
|
680
|
-
+ "of a repo's own AGENTS.md. Review and remove what you don't want:\n");
|
|
681
|
-
for (const root of survivors) {
|
|
682
|
-
process.stderr.write(` - ${root} -> geneseed uninstall --target "${root}"\n`);
|
|
683
|
-
}
|
|
684
|
-
}
|
|
685
|
-
|
|
686
|
-
/**
|
|
687
|
-
* `_build_emit.emit_opencode` — the driver body, which is what this phase actually ports.
|
|
688
|
-
*
|
|
689
|
-
* RENDER and WIRE already ran in Node before P4; what lived only in Python was the stage
|
|
690
|
-
* either side of them: reading the previous manifest (PRE), pruning what this layer no
|
|
691
|
-
* longer owns, writing the manifest atomically, and the summary line. All five stages now
|
|
692
|
-
* run in one process, in the order `test_emit_phase_order.py` pins for the Python twin:
|
|
693
|
-
* RENDER -> WIRE -> PRUNE -> MANIFEST -> VERIFY (opencode has no VERIFY; it writes no
|
|
694
|
-
* settings file of its own).
|
|
695
|
-
*/
|
|
696
|
-
function emitOpencode(cfg, args, out) {
|
|
697
|
-
const root = args.root ? resolveOut(args.root) : out;
|
|
698
|
-
const oc = path.join(root, '.opencode');
|
|
699
|
-
const manifestPath = path.join(oc, GLOBAL_MANIFEST);
|
|
700
|
-
|
|
701
|
-
// PRE. A missing manifest reads as "owned nothing before", which is what makes the first
|
|
702
|
-
// re-emit after an upgrade treat already-existing files as the user's (claim-on-create)
|
|
703
|
-
// rather than deleting them. The prune set is then empty by construction.
|
|
704
|
-
const { existed: manifestExisted, doc } = readManifest(manifestPath);
|
|
705
|
-
const oldOwned = (doc && doc.owned) || [];
|
|
706
|
-
|
|
707
|
-
const agentPathRel = relUnder(out, root);
|
|
708
|
-
const agentPath = agentPathRel ? `${agentPathRel}/AGENT.md` : 'AGENT.md';
|
|
709
|
-
|
|
710
|
-
// RENDER + WIRE, one call. `primaryAgentSrc` lives in `_build_emit` and only this job
|
|
711
|
-
// needs it, so the caller adds it rather than `makeCfg` reaching across.
|
|
712
|
-
const rendered = emitOpencodeRender(
|
|
713
|
-
{ ...cfg, primaryAgentSrc: PRIMARY_AGENT_SRC },
|
|
714
|
-
{
|
|
715
|
-
theme: args.theme, out, root, footprint: args.footprint,
|
|
716
|
-
nativeCatalog: NATIVE_CATALOG.opencode, oldOwned, manifestExisted, agentPath,
|
|
717
|
-
});
|
|
718
|
-
const { owned, stats, cfgName } = rendered;
|
|
719
|
-
|
|
720
|
-
phaseLog('PRUNE');
|
|
721
|
-
pruneOwned(oc, oldOwned, owned);
|
|
722
|
-
|
|
723
|
-
phaseLog('MANIFEST');
|
|
724
|
-
writeManifestAtomic(manifestPath, {
|
|
725
|
-
_comment: 'Files owned by Geneseed\'s per-repo OpenCode emit (--emit opencode). '
|
|
726
|
-
+ 'Do not edit; removed on re-emit. A pre-existing file not in this '
|
|
727
|
-
+ 'list is yours and is never touched.',
|
|
728
|
-
owned: [...owned].sort(),
|
|
729
|
-
scope: 'project',
|
|
730
|
-
});
|
|
731
|
-
|
|
732
|
-
const extra = extrasTail(stats);
|
|
733
|
-
process.stdout.write(`[geneseed] opencode layer: ${stats.nAgents} subagents, `
|
|
734
|
-
+ `${stats.nSkills} skills, ${stats.nPlugins} plugin(s), `
|
|
735
|
-
+ `${stats.nWorkflows} workflow file(s), ${cfgName} (instructions: ${agentPath})`
|
|
736
|
-
+ `${extra}\n`);
|
|
737
|
-
}
|
|
738
|
-
|
|
739
|
-
/**
|
|
740
|
-
* `_build_global.emit_opencode_global` — the driver body for the "everything global, zero
|
|
741
|
-
* per-repo" deployment.
|
|
742
|
-
*
|
|
743
|
-
* Shaped like `emitOpencode` and differing in four measured ways, each of which is a value
|
|
744
|
-
* this driver decides rather than the child re-deriving it:
|
|
745
|
-
* - the target is the RESOLVED config dir, not `--out`;
|
|
746
|
-
* - `out` is passed through unchanged and is the LEGACY BUNDLE a memory store is migrated
|
|
747
|
-
* from — not the target, not the config dir's parent, not derivable from anything the
|
|
748
|
-
* child holds;
|
|
749
|
-
* - `agentPath` is ABSOLUTE here (`<cfg>/AGENT.md` as POSIX), where the per-repo emit
|
|
750
|
-
* sends a path relative to the project root;
|
|
751
|
-
* - the manifest carries no `scope` and no `managed` claim set, because the one file this
|
|
752
|
-
* emit wires is never unwired by any teardown — so there is nothing to record and no
|
|
753
|
-
* VERIFY stage to re-read it.
|
|
754
|
-
*/
|
|
755
|
-
function emitOpencodeGlobal(cfg, args, out) {
|
|
756
|
-
const cfgDir = args.cfgDir ?? opencodeConfigDir();
|
|
757
|
-
const manifestPath = path.join(cfgDir, GLOBAL_MANIFEST);
|
|
758
|
-
|
|
759
|
-
const { doc } = readManifest(manifestPath);
|
|
760
|
-
const oldOwned = (doc && doc.owned) || [];
|
|
761
|
-
|
|
762
|
-
const agentPath = path.join(cfgDir, 'AGENT.md').split(path.sep).join('/');
|
|
763
|
-
|
|
764
|
-
const rendered = emitOpencodeGlobalRender(
|
|
765
|
-
{ ...cfg, primaryAgentSrc: PRIMARY_AGENT_SRC },
|
|
766
|
-
{
|
|
767
|
-
theme: args.theme, cfgDir, out: out === null ? null : out,
|
|
768
|
-
footprint: args.footprint, nativeCatalog: NATIVE_CATALOG.opencode,
|
|
769
|
-
oldOwned, agentPath,
|
|
770
|
-
});
|
|
771
|
-
const { owned, stats, memStatus, nbStatus, cfgName } = rendered;
|
|
772
|
-
|
|
773
|
-
phaseLog('PRUNE');
|
|
774
|
-
pruneOwned(cfgDir, oldOwned, owned);
|
|
775
|
-
|
|
776
|
-
phaseLog('MANIFEST');
|
|
777
|
-
writeManifestAtomic(manifestPath, {
|
|
778
|
-
_comment: 'Files owned by Geneseed\'s --emit opencode-global. '
|
|
779
|
-
+ 'Do not edit; removed on re-emit. The memory and notebook '
|
|
780
|
-
+ 'stores are NOT listed — they are never deleted.',
|
|
781
|
-
owned: [...owned].sort(),
|
|
782
|
-
});
|
|
783
|
-
|
|
784
|
-
const extra = extrasTail(stats);
|
|
785
|
-
process.stdout.write(`[geneseed] opencode-global -> ${cfgDir}: ${stats.nAgents} subagents, `
|
|
786
|
-
+ `${stats.nSkills} skills, ${stats.nPlugins} plugin(s), `
|
|
787
|
-
+ `${stats.nWorkflows} workflow file(s), AGENT.md, ${memStatus}, ${nbStatus}, `
|
|
788
|
-
+ `${cfgName} (no context.json)${extra}. `
|
|
789
|
-
+ 'The learn plugin now finds <cfg>/memory automatically; set GENESEED_HARNESS only to '
|
|
790
|
-
+ 'override.\n');
|
|
791
|
-
return cfgDir;
|
|
792
|
-
}
|
|
793
|
-
|
|
794
|
-
/**
|
|
795
|
-
* `_build_global._emit_claude_core` — the shared engine behind six emits.
|
|
796
|
-
*
|
|
797
|
-
* Only the two Copilot ones run through it so far, and that is a deliberate split rather
|
|
798
|
-
* than an arbitrary stopping point: `js/build/emit-claude.mjs's wire` gates the entire settings/hook path
|
|
799
|
-
* behind `if (!isCopilot)`, so a Copilot emit never reaches `hookPrefix` and therefore never
|
|
800
|
-
* needs `hookOpts` — the pair of machine-absolute values (`sys.executable` and the harness
|
|
801
|
-
* entry point) that a Node driver has no way to produce. Copilot crossing first isolates
|
|
802
|
-
* this driver body from that question entirely.
|
|
803
|
-
*
|
|
804
|
-
* `hookOpts` is OMITTED for a host that does not wire hooks, rather than passed as `{}` or
|
|
805
|
-
* `null`. If a future change ever routed a Copilot emit into the hook path, `hookPrefix()`
|
|
806
|
-
* would receive `undefined`, hit its own default and throw the error it was built to throw
|
|
807
|
-
* — where `null` would raise an unrelated TypeError and `{}` would bake the literal string
|
|
808
|
-
* `"undefined"` into the shim and silently kill every hook in the install. Fail loud, on
|
|
809
|
-
* purpose. The four hook-writing hosts pass a real pair from `hookRunnerEntry()`, which
|
|
810
|
-
* cannot fail to produce one — `process.execPath` is the node already running.
|
|
811
|
-
*/
|
|
812
|
-
function emitClaudeCore(cfg, args, { cfgDir, claudeMd, scope, host, out, hookOpts }) {
|
|
813
|
-
const manifestPath = path.join(cfgDir, GLOBAL_MANIFEST);
|
|
814
|
-
const { doc } = readManifest(manifestPath);
|
|
815
|
-
const oldOwned = (doc && doc.owned) || [];
|
|
816
|
-
const oldManaged = (doc && doc.managed && typeof doc.managed === 'object'
|
|
817
|
-
&& !Array.isArray(doc.managed)) ? doc.managed : {};
|
|
818
|
-
const isCopilot = host === 'copilot';
|
|
819
|
-
|
|
820
|
-
// RENDER + WIRE in one child's worth of work. `preambleExclude` is null for every host
|
|
821
|
-
// but Claude — `_PREAMBLE_CONFIG_DIR` has exactly one key, `CLAUDE.md` — so neither
|
|
822
|
-
// Copilot carrier filename (copilot-instructions.md, AGENTS.md) resolves to one, and
|
|
823
|
-
// neither does Bob's. Spelling it as a call rather than a literal is what makes that a
|
|
824
|
-
// measured `null` instead of an assumed one.
|
|
825
|
-
const rendered = emitClaudeRender(cfg, {
|
|
826
|
-
theme: args.theme, cfgDir, claudeMd, scope, host,
|
|
827
|
-
out: out === null ? null : out,
|
|
828
|
-
footprint: args.footprint, nativeCatalog: NATIVE_CATALOG[host] ?? false,
|
|
829
|
-
oldOwned, oldManaged, preambleExclude: preambleExclude(claudeMd),
|
|
830
|
-
...(hookOpts ? { hookOpts } : {}),
|
|
831
|
-
});
|
|
832
|
-
const { owned, stats, memStatus, nbStatus, managed } = rendered;
|
|
833
|
-
|
|
834
|
-
phaseLog('PRUNE');
|
|
835
|
-
pruneOwned(cfgDir, oldOwned, owned);
|
|
836
|
-
|
|
837
|
-
phaseLog('MANIFEST');
|
|
838
|
-
writeManifestAtomic(manifestPath, {
|
|
839
|
-
_comment: "Files owned by Geneseed's Claude emit. Do not edit; removed on "
|
|
840
|
-
+ 're-emit. The memory and notebook stores are NOT listed — never '
|
|
841
|
-
+ 'deleted. `managed` records the CLAUDE.md block + settings.json '
|
|
842
|
-
+ 'hooks so uninstall removes exactly those.',
|
|
843
|
-
owned: [...owned].sort(),
|
|
844
|
-
managed,
|
|
845
|
-
scope,
|
|
846
|
-
});
|
|
847
|
-
|
|
848
|
-
// VERIFY — re-read the settings file just written and match it against the claims just
|
|
849
|
-
// recorded, so a merge that silently did not stick (a commented file, a mid-flight
|
|
850
|
-
// external edit, a bug in the merge) is loud now instead of surfacing as hooks that
|
|
851
|
-
// quietly never fire. Copilot's settings have their own shape and their own check; a
|
|
852
|
-
// Copilot PROJECT emit records no hooks and so checks nothing.
|
|
853
|
-
//
|
|
854
|
-
// On the Python driver this stage runs in Python AFTER a Node child did the wiring, which
|
|
855
|
-
// makes it a live cross-implementation check on every build. Here both halves are Node, so
|
|
856
|
-
// that particular property is gone and only the self-check remains — worth stating,
|
|
857
|
-
// because it is a real reduction in what a Claude emit proves about itself, and the thing
|
|
858
|
-
// that replaces it is the acceptance matrix rather than anything at runtime.
|
|
859
|
-
//
|
|
860
|
-
// SILENT ON SUCCESS, which is its own coverage hazard: a clean emit prints nothing, so
|
|
861
|
-
// deleting this call is byte-identical in all 259 cells. `test_verify_reports_an_orphaned
|
|
862
|
-
// _geneseed_hook` plants the fault that makes it speak.
|
|
863
|
-
phaseLog('VERIFY');
|
|
864
|
-
if (!isCopilot) {
|
|
865
|
-
settingsIntegrityCheck(
|
|
866
|
-
path.join(cfgDir, managed.settings_file || 'settings.json'), managed, 'present');
|
|
867
|
-
} else if (managed.copilot_hooks) {
|
|
868
|
-
copilotIntegrityCheck(path.join(cfgDir, 'settings.json'), managed.copilot_hooks, 'present');
|
|
869
|
-
}
|
|
870
|
-
return {
|
|
871
|
-
nAgents: stats.nAgents,
|
|
872
|
-
nSkills: stats.nSkills,
|
|
873
|
-
nHooks: (managed.settings_hooks || managed.copilot_hooks || []).length,
|
|
874
|
-
memStatus,
|
|
875
|
-
nbStatus,
|
|
876
|
-
};
|
|
877
|
-
}
|
|
878
|
-
|
|
879
|
-
/** `_build_global.emit_copilot` — per-repo: AGENTS.md at the repo root + a `.github/` layer. */
|
|
880
|
-
function emitCopilot(cfg, args, out) {
|
|
881
|
-
const root = args.root ? resolveOut(args.root) : out;
|
|
882
|
-
const cfgDir = path.join(root, '.github');
|
|
883
|
-
const r = emitClaudeCore(cfg, args, {
|
|
884
|
-
cfgDir, claudeMd: path.join(root, 'AGENTS.md'), scope: 'project', host: 'copilot', out,
|
|
885
|
-
});
|
|
886
|
-
process.stdout.write(`[geneseed] copilot (folder) -> ${root}: AGENTS.md + .github/ `
|
|
887
|
-
+ `(${r.nAgents} agents (.agent.md), ${r.nSkills} skills), ${r.memStatus}, `
|
|
888
|
-
+ `${r.nbStatus}. No hooks at project scope (Copilot reads hooks from `
|
|
889
|
-
+ '~/.copilot/settings.json only — the global emit wires them).\n');
|
|
890
|
-
return cfgDir;
|
|
891
|
-
}
|
|
892
|
-
|
|
893
|
-
/** `_build_global.emit_copilot_global` — into Copilot's personal config dir (~/.copilot). */
|
|
894
|
-
function emitCopilotGlobal(cfg, args, out) {
|
|
895
|
-
const cfgDir = args.cfgDir ?? copilotConfigDir();
|
|
896
|
-
warnCopilotGlobalOverProject();
|
|
897
|
-
// The hook runner pair, as for Claude and Bob: the global emit is the one Copilot scope
|
|
898
|
-
// that wires hooks (`~/.copilot/settings.json`), and the shim it bakes is machine-wide.
|
|
899
|
-
const hookOpts = hookRunnerEntry();
|
|
900
|
-
const r = emitClaudeCore(cfg, args, {
|
|
901
|
-
cfgDir, claudeMd: path.join(cfgDir, 'copilot-instructions.md'),
|
|
902
|
-
scope: 'global', host: 'copilot', out, hookOpts,
|
|
903
|
-
});
|
|
904
|
-
process.stdout.write(`[geneseed] copilot-global -> ${cfgDir}: ${r.nAgents} agents (.agent.md), `
|
|
905
|
-
+ `${r.nSkills} skills, copilot-instructions.md, ${r.memStatus}, ${r.nbStatus}, `
|
|
906
|
-
+ `${r.nHooks} hooks (settings.json: sessionStart context, toolCall gate). Memory `
|
|
907
|
-
+ "write-back rides the preamble's instructions; MCP servers go in mcp-config.json.\n");
|
|
908
|
-
return cfgDir;
|
|
909
|
-
}
|
|
910
|
-
|
|
911
|
-
/**
|
|
912
|
-
* `_build_global.emit_claude_global` — into Claude Code's global config dir (~/.claude).
|
|
913
|
-
*
|
|
914
|
-
* `hookRunnerEntry()` is still called BEFORE the engine, though it can no longer refuse:
|
|
915
|
-
* the shape stayed after P5b deleted the refusal so that a future value which CAN fail is
|
|
916
|
-
* decided while nothing has been written, rather than behind a half-rendered config dir.
|
|
917
|
-
*/
|
|
918
|
-
function emitClaudeGlobal(cfg, args, out) {
|
|
919
|
-
const cfgDir = args.cfgDir ?? claudeConfigDir();
|
|
920
|
-
const hookOpts = hookRunnerEntry();
|
|
921
|
-
const r = emitClaudeCore(cfg, args, {
|
|
922
|
-
cfgDir, claudeMd: path.join(cfgDir, 'CLAUDE.md'), scope: 'global', host: 'claude', out,
|
|
923
|
-
hookOpts,
|
|
924
|
-
});
|
|
925
|
-
// "Hooks call the harness by absolute path", not `harness.py` — and this one was already
|
|
926
|
-
// WRONG on this side rather than merely about to be: P5b made this driver bake `<node>
|
|
927
|
-
// bin/geneseed-hook.mjs` into the hooks it writes, so the sentence named a file its own
|
|
928
|
-
// emit does not use. The two drivers bake different entries and printed the same claim;
|
|
929
|
-
// the neutral wording is true of both, and stays true when only one is left. It is frozen
|
|
930
|
-
// in the `claude-global` cells' recorded stdout, which is why it moves here rather than in
|
|
931
|
-
// the deletion phase, which may not move a recorded byte.
|
|
932
|
-
process.stdout.write(`[geneseed] claude-global -> ${cfgDir}: ${r.nAgents} subagents, `
|
|
933
|
-
+ `${r.nSkills} skills, CLAUDE.md, ${r.nHooks} hook group(s), settings.json, `
|
|
934
|
-
+ `${r.memStatus}, ${r.nbStatus}. No plugins/workflows/themes (no Claude analogue); `
|
|
935
|
-
+ '~/.claude/plugins is never touched. Hooks call the harness by absolute path; set '
|
|
936
|
-
+ 'GENESEED_HARNESS only to relocate memory.\n');
|
|
937
|
-
return cfgDir;
|
|
938
|
-
}
|
|
939
|
-
|
|
940
|
-
/** `_build_global.emit_claude` — per-repo: CLAUDE.md at the root + a `.claude/` layer. */
|
|
941
|
-
function emitClaude(cfg, args, out) {
|
|
942
|
-
const root = args.root ? resolveOut(args.root) : out;
|
|
943
|
-
const hookOpts = hookRunnerEntry();
|
|
944
|
-
const r = emitClaudeCore(cfg, args, {
|
|
945
|
-
cfgDir: path.join(root, '.claude'), claudeMd: path.join(root, 'CLAUDE.md'),
|
|
946
|
-
scope: 'project', host: 'claude', out, hookOpts,
|
|
947
|
-
});
|
|
948
|
-
process.stdout.write(`[geneseed] claude (folder) -> ${root}: CLAUDE.md + .claude/ `
|
|
949
|
-
+ `(${r.nAgents} subagents, ${r.nSkills} skills, ${r.nHooks} hook group(s), `
|
|
950
|
-
+ `settings.json), ${r.memStatus}, ${r.nbStatus}.\n`);
|
|
951
|
-
}
|
|
952
|
-
|
|
953
|
-
/**
|
|
954
|
-
* `_build_global.emit_bob_global` — into Bob's global config dir (~/.bob).
|
|
955
|
-
*
|
|
956
|
-
* The warning fires BEFORE the emit, matching the Python order: it is about a state this
|
|
957
|
-
* emit is at the point of creating, so printing it afterwards would describe the situation
|
|
958
|
-
* as though the operator had already chosen it.
|
|
959
|
-
*/
|
|
960
|
-
function emitBobGlobal(cfg, args, out) {
|
|
961
|
-
const cfgDir = args.cfgDir ?? bobConfigDir();
|
|
962
|
-
const hookOpts = hookRunnerEntry();
|
|
963
|
-
warnBobGlobalOverProject();
|
|
964
|
-
const r = emitClaudeCore(cfg, args, {
|
|
965
|
-
cfgDir, claudeMd: path.join(cfgDir, 'AGENTS.md'), scope: 'global', host: 'bob', out,
|
|
966
|
-
hookOpts,
|
|
967
|
-
});
|
|
968
|
-
process.stdout.write(`[geneseed] bob-global -> ${cfgDir}: ${r.nAgents} subagents, `
|
|
969
|
-
+ `${r.nSkills} skills, rules/geneseed.md (Bob's always-injected channel; a global `
|
|
970
|
-
+ 'AGENTS.md is not auto-loaded, so none is written), '
|
|
971
|
-
+ `${r.nHooks} hook group(s), settings.json, ${r.memStatus}, ${r.nbStatus}.\n`);
|
|
972
|
-
return cfgDir;
|
|
973
|
-
}
|
|
974
|
-
|
|
975
|
-
/** `_build_global.emit_bob` — per-repo: AGENTS.md at the root + a `.bob/` layer. */
|
|
976
|
-
function emitBob(cfg, args, out) {
|
|
977
|
-
const root = args.root ? resolveOut(args.root) : out;
|
|
978
|
-
const hookOpts = hookRunnerEntry();
|
|
979
|
-
const r = emitClaudeCore(cfg, args, {
|
|
980
|
-
cfgDir: path.join(root, '.bob'), claudeMd: path.join(root, 'AGENTS.md'),
|
|
981
|
-
scope: 'project', host: 'bob', out, hookOpts,
|
|
982
|
-
});
|
|
983
|
-
process.stdout.write(`[geneseed] bob (folder) -> ${root}: AGENTS.md + .bob/ `
|
|
984
|
-
+ `(${r.nAgents} subagents, ${r.nSkills} skills, rules/geneseed.md shadow stub, `
|
|
985
|
-
+ `${r.nHooks} hook group(s), settings.json), ${r.memStatus}, ${r.nbStatus}.\n`);
|
|
986
|
-
}
|
|
987
|
-
|
|
988
|
-
/**
|
|
989
|
-
* build.py:437-466 — the POST stage, which writes markers and records the install and
|
|
990
|
-
* wires NOTHING. `test_the_post_emit_stage_wires_nothing` classifies that stage on the
|
|
991
|
-
* Python side; this is the same stage on this side, and it stays after the dispatch for
|
|
992
|
-
* the same reason.
|
|
993
|
-
*
|
|
994
|
-
* `writeText`, never `writeFileSync`: Python's `Path.write_text` opens in text mode with
|
|
995
|
-
* `newline=None` and translates the trailing `\n` to CRLF on Windows. A marker written
|
|
996
|
-
* with a bare LF differs from Python's in every cell on this platform.
|
|
997
|
-
*/
|
|
998
|
-
function writeMarkers(markerDir, emit, footprint) {
|
|
999
|
-
try {
|
|
1000
|
-
mkdirSync(markerDir, { recursive: true });
|
|
1001
|
-
writeText(path.join(markerDir, '.geneseed-emit'), `${emit}\n`);
|
|
1002
|
-
// Written for EVERY emit (build.py:443, unconditional), unlike the theme marker:
|
|
1003
|
-
// the claude/bob/copilot PROJECT installs never call build(), so this is their only
|
|
1004
|
-
// footprint record. Read by harness._footprint_of_dir, which defaults to 'full'.
|
|
1005
|
-
writeText(path.join(markerDir, '.geneseed-footprint'), `${footprint}\n`);
|
|
1006
|
-
} catch { /* best-effort, exactly as build.py:444-445 — a marker hiccup never fails a build */ }
|
|
1007
|
-
}
|
|
1008
|
-
|
|
1009
|
-
/**
|
|
1010
|
-
* `_build_global.HOSTS[host]['emit_global']` — the one column a CALLER outside the dispatch
|
|
1011
|
-
* needs, and the reason it is a named export rather than a fifth branch in `main`.
|
|
1012
|
-
*
|
|
1013
|
-
* `harness diff` renders an 'expected' copy of a deployed install into a temp dir and
|
|
1014
|
-
* compares it file by file. The Python spells that `HOSTS[host]["emit_global"](theme,
|
|
1015
|
-
* out=..., cfg=<tmp>, footprint=...)`, where `cfg` overrides the target the emit would
|
|
1016
|
-
* otherwise resolve. This is that call, and the override is threaded as `args.cfgDir` through
|
|
1017
|
-
* the four `emit*Global` bodies rather than added to `parseArgs`: `build.py` has no
|
|
1018
|
-
* `--cfg-dir` flag, and a flag on one driver and not the other is a divergence
|
|
1019
|
-
* `tests/golden.py` compares 259 times and could never see, because it drives both from the
|
|
1020
|
-
* same argv.
|
|
1021
|
-
*
|
|
1022
|
-
* WHICH SIDE OF THE BOUNDARY THIS SITS ON. P3c's rule was that a render CHILD must never
|
|
1023
|
-
* resolve a config dir, because a child that did would write 135 files into the developer's
|
|
1024
|
-
* real `~/.config/opencode`; P4a inverted it for the driver, which is the parent and must
|
|
1025
|
-
* ORIGINATE what the child may not resolve. `diff` is a third position: it is a parent that
|
|
1026
|
-
* needs the target to be somewhere other than where the resolver would put it, so it sends
|
|
1027
|
-
* the value and the resolver is skipped entirely. An emit whose `cfgDir` came from anywhere
|
|
1028
|
-
* but this argument would overwrite the live install `diff` is only reading.
|
|
1029
|
-
*/
|
|
1030
|
-
const GLOBAL_EMITS = {
|
|
1031
|
-
opencode: emitOpencodeGlobal,
|
|
1032
|
-
claude: emitClaudeGlobal,
|
|
1033
|
-
bob: emitBobGlobal,
|
|
1034
|
-
copilot: emitCopilotGlobal,
|
|
1035
|
-
};
|
|
1036
|
-
|
|
1037
|
-
/**
|
|
1038
|
-
* The PER-REPO half of the same idea, and `doctor` is its only caller.
|
|
1039
|
-
*
|
|
1040
|
-
* `_claude_bob_emit_problems` renders `emit_claude`, `emit_bob` and `emit_copilot` into
|
|
1041
|
-
* throwaway sandboxes and scans each — the three emits that write CLAUDE.md/AGENTS.md
|
|
1042
|
-
* straight into a repo, and the ones outside doctor's sweep when the skill-table dead links
|
|
1043
|
-
* shipped.
|
|
1044
|
-
*
|
|
1045
|
-
* IT CALLS THE EMIT, NOT `main`, AND THAT IS THE WHOLE POINT OF THE EXPORT. Routing this
|
|
1046
|
-
* through `driverMain(['--emit', 'claude', …])` would look like less code and would run two
|
|
1047
|
-
* stages the Python's direct call never reaches: `writeMarkers` drops `.geneseed-emit` and
|
|
1048
|
-
* `.geneseed-footprint` into the sandbox, and `registryRecord` writes a row into the USER'S
|
|
1049
|
-
* install registry for a temp directory that is deleted a millisecond later. A validation
|
|
1050
|
-
* pass must not register an install.
|
|
1051
|
-
*
|
|
1052
|
-
* `footprint: 'full'` is the Python signature default that `_claude_bob_emit_problems`'s
|
|
1053
|
-
* three-positional call leaves in place, inherited here rather than re-decided.
|
|
1054
|
-
*/
|
|
1055
|
-
const PROJECT_EMITS = {
|
|
1056
|
-
claude: emitClaude, bob: emitBob, copilot: emitCopilot,
|
|
1057
|
-
// P2. `opencode` joins the three for `cmdValidate`, which has to be able to render EVERY
|
|
1058
|
-
// `--emit` choice into its sandbox and not only the three doctor already scans. Its call
|
|
1059
|
-
// shape is `emitClaude`'s exactly — `(cfg, args, out)` — so the row is the whole change.
|
|
1060
|
-
opencode: emitOpencode,
|
|
1061
|
-
};
|
|
1062
|
-
|
|
1063
|
-
export function emitProjectInto(host, { theme, out, root, footprint = 'full', doctrines = null }) {
|
|
1064
|
-
const emit = PROJECT_EMITS[host];
|
|
1065
|
-
// `doctrines` rides the same rail posture and mode ride into `emitGlobalInto`, and for the
|
|
1066
|
-
// same reason: a drift reader that renders `expected` at the DEFAULT pack set reports the
|
|
1067
|
-
// whole doctrines section as edited on every narrowed install. `null` means "no opinion" and
|
|
1068
|
-
// `makeCfg` renders all four, which is what every validation caller wants.
|
|
1069
|
-
return withPlatformNewlines(() => emit(makeCfg(doctrines ? { doctrines } : {}),
|
|
1070
|
-
{ theme, footprint, root }, out));
|
|
1071
|
-
}
|
|
1072
|
-
|
|
1073
|
-
/**
|
|
1074
|
-
* The ninth `--emit` choice — a plain `files` bundle — for the same caller and the same
|
|
1075
|
-
* reason: `_validate_only`'s `else` branch is `build(args.theme, sandbox, args.footprint)`,
|
|
1076
|
-
* a DIRECT call that reaches neither `writeMarkers` nor `registryRecord`.
|
|
1077
|
-
*
|
|
1078
|
-
* `nativeCatalog: false` is that three-positional call's signature default, inherited here
|
|
1079
|
-
* exactly as `run` inherits it below.
|
|
1080
|
-
*/
|
|
1081
|
-
export function buildInto({ theme, out, footprint = 'lean' }) {
|
|
1082
|
-
return withPlatformNewlines(
|
|
1083
|
-
() => build(makeCfg(), theme, out, { footprint, nativeCatalog: false }),
|
|
1084
|
-
);
|
|
1085
|
-
}
|
|
1086
|
-
|
|
1087
|
-
export function emitGlobalInto(host, {
|
|
1088
|
-
theme, out, cfgDir, footprint, posture = null, mode = null, doctrines = null,
|
|
1089
|
-
}) {
|
|
1090
|
-
// `build.HOSTS.get(host, build.HOSTS["opencode"])` — an unknown host falls back rather than
|
|
1091
|
-
// raising, because the host comes from a marker file a user can edit.
|
|
1092
|
-
const emit = GLOBAL_EMITS[host] ?? GLOBAL_EMITS.opencode;
|
|
1093
|
-
// The same funnel `main` installs, for the same reason and not because `diff` asked: this
|
|
1094
|
-
// runs the whole render tree, whose ~25 print sites write raw `\n`. `diff` swallows the
|
|
1095
|
-
// emit's STDOUT and lets its stderr through (the Python's `redirect_stdout` does exactly
|
|
1096
|
-
// that), so an untranslated WARN from `warnBobGlobalOverProject` would reach the user's
|
|
1097
|
-
// terminal with the wrong bytes on the one stream the caller deliberately does not hide.
|
|
1098
|
-
// POSTURE AND MODE TRAVEL WITH THE RENDER, and the `|| default` is load-bearing:
|
|
1099
|
-
// `postureOfDir` returns null for "no install / undetectable", which `makeCfg`'s parameter
|
|
1100
|
-
// default would NOT catch. Callers that pass nothing keep the reference's behaviour of
|
|
1101
|
-
// rendering at `peer`/`direct`; the drift readers pass what the deployment actually says,
|
|
1102
|
-
// because rendering `expected` at the defaults reports AGENT.md as edited on every install
|
|
1103
|
-
// that chose a register — the same scar the footprint left in `diffCollect`, one axis over.
|
|
1104
|
-
return withPlatformNewlines(
|
|
1105
|
-
() => emit(
|
|
1106
|
-
// THE PACK SELECTION RIDES THE SAME RAIL, with one difference: `doctrinesOfDir` answers
|
|
1107
|
-
// `null` for "no marker / undetectable", and that must render at ALL packs rather than
|
|
1108
|
-
// at `[]`, so the key is OMITTED instead of passed through — `makeCfg`'s own default is
|
|
1109
|
-
// the fail-closed answer and an explicit `doctrines: null` would not reach it.
|
|
1110
|
-
makeCfg({
|
|
1111
|
-
posture: posture || 'peer', mode: mode || 'direct', ...(doctrines ? { doctrines } : {}),
|
|
1112
|
-
}),
|
|
1113
|
-
{ theme, footprint, root: null, cfgDir }, out,
|
|
1114
|
-
),
|
|
1115
|
-
);
|
|
1116
|
-
}
|
|
1117
|
-
|
|
1118
|
-
/**
|
|
1119
|
-
* EXPORTED because `harness build` is a passthrough to this program.
|
|
1120
|
-
*
|
|
1121
|
-
* `_harness_build.cmd_build` is `run([sys.executable, BUILD, *extra]).returncode` — Python
|
|
1122
|
-
* needs a second process because `build.py` is a different PROGRAM. Here it is a module in
|
|
1123
|
-
* the same one, and `bin/geneseed-cli.mjs` is under a transitive `child_process` ban, so
|
|
1124
|
-
* `js/build/generate.mjs` calls this directly. The export is what makes the auto-run below need a
|
|
1125
|
-
* guard: without one, importing this file to reach `main` would RUN the generator with the
|
|
1126
|
-
* CLI's argv.
|
|
1127
|
-
*/
|
|
1128
|
-
export function main(argv) {
|
|
1129
|
-
// `withPlatformNewlines` is what makes this driver's bytes Python's bytes on Windows, for every
|
|
1130
|
-
// print site in the whole render tree at once — see its docblock for why a funnel and not
|
|
1131
|
-
// 25 calls to `printOut`. It wraps the `catch` as well as `run`, because `die` writes its
|
|
1132
|
-
// line on the way out.
|
|
1133
|
-
return withPlatformNewlines(() => {
|
|
1134
|
-
try {
|
|
1135
|
-
return run(argv);
|
|
1136
|
-
} catch (e) {
|
|
1137
|
-
// Every DELIBERATE refusal becomes this process's exit code — `die`'s, and equally
|
|
1138
|
-
// `assertSourceComplete`'s and `effectiveTheme`'s, which have already explained
|
|
1139
|
-
// themselves on stderr. An unmarked throw is a crash and keeps its stack, because
|
|
1140
|
-
// Python prints a traceback for one and dressing it as a tidy refusal would hide a bug.
|
|
1141
|
-
if (e && e.exitCode !== undefined) return e.exitCode;
|
|
1142
|
-
throw e;
|
|
1143
|
-
}
|
|
1144
|
-
});
|
|
1145
|
-
}
|
|
1146
|
-
|
|
1147
|
-
function run(argv) {
|
|
1148
|
-
const args = parseArgs(argv, configDefaults());
|
|
1149
|
-
|
|
1150
|
-
// P2 crossed both of the flags this branch used to refuse, and they landed in two
|
|
1151
|
-
// different places because the transitive `child_process` ban splits them.
|
|
1152
|
-
//
|
|
1153
|
-
// `--sync-themes` is HERE, because it needs nothing this driver may not have: it reads
|
|
1154
|
-
// `themes/`, rewrites the files textually, and prints. `js/build/themes.mjs` carries it.
|
|
1155
|
-
// Non-zero when files were CHANGED (0 == already in sync), so CI can run it as a drift
|
|
1156
|
-
// check — build.py:393-397's mapping, and a refusal (exit 2) if the template is unreadable.
|
|
1157
|
-
if (args.syncThemes) return syncThemes() ? 1 : 0;
|
|
1158
|
-
// `--validate-only` is NOT, and cannot be: its source-tree half is the doctor, and
|
|
1159
|
-
// `js/inspect/checks-authoring.mjs` starts a process (`node --check` over the OpenCode plugins). This driver
|
|
1160
|
-
// is under a transitive ban on reaching any such module, gated twice — by a source grep in
|
|
1161
|
-
// `tests/test_node_cli_parity.py` and by an import walk in `tests/test_hook_cli_parity.py`.
|
|
1162
|
-
// So the tool crossed onto the CLI binary, which already carries the doctor, and this flag
|
|
1163
|
-
// points at it rather than silently building for real into the caller's `--out`.
|
|
1164
|
-
if (args.validateOnly) {
|
|
1165
|
-
die(2, '--validate-only lives on the CLI entry point, because it runs the doctor and '
|
|
1166
|
-
+ 'this generator may not start a process. Run: geneseed validate '
|
|
1167
|
-
+ '[--theme T] [--emit E] [--out O] [--root R] [--footprint F] [-v]');
|
|
1168
|
-
}
|
|
1169
|
-
|
|
1170
|
-
// Every `--emit` choice has crossed as of P4e, so this branch is now unreachable — and it
|
|
1171
|
-
// stays, because it is the partition `test_the_node_driver_classifies_every_emit` asserts:
|
|
1172
|
-
// a tenth emit added to `EMITS` and not to `PORTED` refuses loudly here instead of falling
|
|
1173
|
-
// through the dispatch's `else` and silently building a plain bundle.
|
|
1174
|
-
if (!PORTED.has(args.emit)) {
|
|
1175
|
-
// The `Run: …` tail naming the Python driver is gone. It named the fallback for an emit
|
|
1176
|
-
// this driver could not do, and there is no longer a second driver to fall back TO — the
|
|
1177
|
-
// partition is now "known" vs "not a thing", and only the refusal is left to say so.
|
|
1178
|
-
die(3, `--emit ${args.emit} is not one this driver builds (only `
|
|
1179
|
-
+ `${[...PORTED].join(', ')} are).`);
|
|
1180
|
-
}
|
|
1181
|
-
|
|
1182
|
-
const out = resolveOut(args.out);
|
|
1183
|
-
const cfg = makeCfg(args);
|
|
1184
|
-
|
|
1185
|
-
// The marker directory is the emit's TARGET, which for a global emit is the config dir it
|
|
1186
|
-
// just rendered into and not `--out` at all — so the emit returns it rather than the
|
|
1187
|
-
// caller guessing (build.py:427-436 re-resolves; returning it keeps one resolution).
|
|
1188
|
-
//
|
|
1189
|
-
// NINE ARMS, TWO TABLES ALREADY ON FILE: `GLOBAL_EMITS` and `PROJECT_EMITS` above are
|
|
1190
|
-
// exactly this dispatch's `-global` and per-repo halves, keyed by host rather than by
|
|
1191
|
-
// `--emit` name, so splitting the suffix reaches both without a third map.
|
|
1192
|
-
let markerDir = out;
|
|
1193
|
-
if (args.emit.endsWith('-global')) {
|
|
1194
|
-
markerDir = GLOBAL_EMITS[args.emit.slice(0, -'-global'.length)](cfg, args, out);
|
|
1195
|
-
} else if (args.emit !== 'files') {
|
|
1196
|
-
// The PROJECT emits keep their markers in `out`, not in the host config dir the emit
|
|
1197
|
-
// wrote to (build.py:435-436) — `.github/` is the layer, `out` is the install.
|
|
1198
|
-
PROJECT_EMITS[args.emit](cfg, args, out);
|
|
1199
|
-
} else {
|
|
1200
|
-
// The ninth choice, a plain bundle: `nativeCatalog: false` is build.py:421's
|
|
1201
|
-
// three-positional-argument call reproduced — `build(args.theme, out, args.footprint)`
|
|
1202
|
-
// leaves `native_catalog` at its signature default. It is the one signature default
|
|
1203
|
-
// this driver DOES inherit, and it is inherited because the Python CLI inherits it too.
|
|
1204
|
-
build(cfg, args.theme, out, { footprint: args.footprint, nativeCatalog: false });
|
|
1205
|
-
}
|
|
1206
|
-
|
|
1207
|
-
writeMarkers(markerDir, args.emit, args.footprint);
|
|
1208
|
-
// build() drops a .geneseed-theme in `out` for the emits that call it; the global emits
|
|
1209
|
-
// render into the config dir WITHOUT calling build(), so the theme is recorded here.
|
|
1210
|
-
// Deliberately not written for the claude/bob/copilot PROJECT emits — they carry none,
|
|
1211
|
-
// and `_harness_setup._installed_defaults` detects those by an AGENT.md sigil scan
|
|
1212
|
-
// instead. Writing one for them would change the emitted tree.
|
|
1213
|
-
if (args.emit.endsWith('-global')) {
|
|
1214
|
-
try {
|
|
1215
|
-
writeText(path.join(markerDir, '.geneseed-theme'), `${args.theme}\n`);
|
|
1216
|
-
} catch { /* best-effort, as build.py:449-452 */ }
|
|
1217
|
-
}
|
|
1218
|
-
// An ALLOW-LIST, not "everything that is not global": a plain `--emit files` dev build —
|
|
1219
|
-
// the default — must never pollute the registry, and only the four per-repo host emits
|
|
1220
|
-
// are ones `_EMIT_HOST_SCOPE` can map back to a row. Records `out`, where the marker is.
|
|
1221
|
-
if (['opencode', 'claude', 'bob', 'copilot'].includes(args.emit)) registryRecord(markerDir);
|
|
1222
|
-
return 0;
|
|
1223
|
-
}
|
|
1224
|
-
|
|
1225
|
-
/**
|
|
1226
|
-
* Run only when this file IS the entry point, not when `js/build/generate.mjs` imports it.
|
|
1227
|
-
*
|
|
1228
|
-
* `import.meta.main` would say this in one word and is Node 24; the spec's `engines` floor
|
|
1229
|
-
* is still open and this machine runs v22, which is why the comparison is spelled out rather than using it. So the
|
|
1230
|
-
* check is the manual one, through `realpathSync` because `process.argv[1]` is whatever the
|
|
1231
|
-
* caller typed and `import.meta.url` is always the resolved file — an `npx`-generated shim
|
|
1232
|
-
* or a `geneseed link` symlink makes those differ as strings while naming the same file.
|
|
1233
|
-
*
|
|
1234
|
-
* BOTH ways of getting this wrong are gated, which is why it is a guard and not a comment,
|
|
1235
|
-
* and both were measured rather than argued:
|
|
1236
|
-
*
|
|
1237
|
-
* * stuck FALSE — `node bin/build-driver.mjs` becomes a silent no-op: 259 golden cells.
|
|
1238
|
-
* * stuck TRUE — importing this file runs the generator on the IMPORTER's argv, so it is
|
|
1239
|
-
* not only `build` that breaks. `bin/geneseed-cli.mjs` reaches `js/build/generate.mjs` at
|
|
1240
|
-
* module load, so every CLI verb dies in the driver's flag parser: **62 of the 166
|
|
1241
|
-
* acceptance cells**, `exclude` and `status` and `version` among them.
|
|
1242
|
-
*/
|
|
1243
|
-
const entry = (() => {
|
|
1244
|
-
if (!process.argv[1]) return false;
|
|
1245
|
-
try {
|
|
1246
|
-
return realpathSync.native(process.argv[1]) === realpathSync.native(fileURLToPath(import.meta.url));
|
|
1247
|
-
} catch {
|
|
1248
|
-
return path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
|
|
1249
|
-
}
|
|
1250
|
-
})();
|
|
1251
|
-
|
|
1252
|
-
if (entry) process.exitCode = main(process.argv.slice(2));
|
|
9
|
+
process.exitCode = main(process.argv.slice(2));
|