geneseed 3.13.0 → 3.15.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 +93 -0
- package/SHIPPED.md +1 -1
- package/adapters/opencode/plugins/geneseed-context.js +23 -9
- package/adapters/opencode/plugins/geneseed-guard.js +16 -4
- package/adapters/opencode/plugins/geneseed-learn.js +45 -10
- package/adapters/opencode/plugins/geneseed-notify.js +37 -19
- package/adapters/opencode/plugins/geneseed-workflow.js +3 -1
- package/adapters/opencode/workflows/_runtime.js +16 -3
- package/bin/geneseed-cli.mjs +32 -34
- package/bin/geneseed-hook.mjs +28 -18
- package/docs/extending.md +5 -5
- package/harness.config.json +1 -1
- package/js/README.md +28 -15
- package/js/build/args.mjs +375 -0
- package/js/build/bundle.mjs +10 -10
- package/js/build/driver.mjs +145 -560
- package/js/build/emit-claude.mjs +5 -6
- package/js/build/emit-common.mjs +2 -14
- package/js/build/emit-opencode.mjs +12 -8
- package/js/build/generate.mjs +21 -14
- package/js/build/render.mjs +6 -10
- package/js/build/source.mjs +9 -17
- package/js/build/stubs.mjs +6 -5
- package/js/build/version.mjs +21 -2
- package/js/hosts/hooks-context.mjs +432 -0
- package/js/hosts/hooks-learn.mjs +275 -0
- package/js/hosts/hooks-prims.mjs +108 -0
- package/js/hosts/hooks.mjs +50 -977
- package/js/hosts/hosts.mjs +31 -25
- package/js/hosts/installs.mjs +38 -20
- package/js/hosts/link.mjs +75 -26
- package/js/hosts/mcp.mjs +12 -48
- package/js/hosts/memory-files.mjs +190 -0
- package/js/hosts/native.mjs +106 -48
- package/js/hosts/opencode.mjs +44 -36
- package/js/hosts/settings.mjs +135 -414
- package/js/hosts/shim.mjs +375 -0
- package/js/inspect/checks-authoring.mjs +21 -828
- package/js/inspect/checks-build.mjs +4 -4
- package/js/inspect/checks-constitution.mjs +493 -0
- package/js/inspect/checks-counts.mjs +242 -0
- package/js/inspect/checks-repo.mjs +17 -8
- package/js/inspect/diff.mjs +9 -7
- package/js/inspect/doctor.mjs +2 -2
- package/js/inspect/excludes.mjs +10 -5
- package/js/inspect/inventory.mjs +4 -4
- package/js/inspect/scan.mjs +1 -1
- package/js/inspect/status.mjs +19 -22
- package/js/inspect/validate.mjs +50 -37
- package/js/lib/fs.mjs +12 -11
- package/js/lib/json.mjs +4 -4
- package/js/lib/paths.mjs +27 -33
- package/js/lib/proc.mjs +3 -3
- package/js/lib/prompt.mjs +132 -0
- package/js/lib/text.mjs +18 -4
- package/js/lib/udiff.mjs +3 -2
- package/js/loop/catalog.mjs +5 -3
- package/js/loop/cli.mjs +26 -12
- package/js/loop/graph.mjs +1 -1
- package/js/loop/registry.mjs +10 -15
- package/js/loop/score.mjs +1 -1
- package/js/loop/state.mjs +35 -11
- package/js/maintain/memory.mjs +6 -7
- package/js/maintain/migrate.mjs +22 -33
- package/js/maintain/setup.mjs +9 -131
- package/js/maintain/uninstall.mjs +57 -97
- package/js/maintain/update.mjs +115 -34
- package/js/ui/anim.mjs +8 -12
- package/js/ui/cli.mjs +37 -43
- package/js/ui/menu.mjs +7 -13
- package/js/ui/tui.mjs +14 -123
- package/js/web/actions.mjs +109 -403
- package/js/web/activity.mjs +1 -1
- package/js/web/api.mjs +41 -725
- package/js/web/catalog.mjs +527 -0
- package/js/web/daemon.mjs +21 -3
- package/js/web/docs.mjs +29 -72
- package/js/web/handler.mjs +51 -27
- package/js/web/jobs.mjs +24 -2
- package/js/web/routes.mjs +61 -6
- package/js/web/server.mjs +4 -4
- package/js/web/state.mjs +138 -0
- package/js/web/user-files.mjs +339 -0
- package/package.json +1 -1
- package/web/dist/assets/Activity-kpWxd70F.js +1 -0
- package/web/dist/assets/ActivityDetail-Ccr942VL.js +1 -0
- package/web/dist/assets/Dashboard-D1lE7jcg.js +2 -0
- package/web/dist/assets/Docs-BHyaggFy.js +5 -0
- package/web/dist/assets/FilterInput-CVyqmYWM.js +1 -0
- package/web/dist/assets/GreenhouseView-Sc_UDMOE.js +2 -0
- package/web/dist/assets/Installs-Gw-pJ3Zi.js +2 -0
- package/web/dist/assets/JournalView-_gxpsxXi.js +1 -0
- package/web/dist/assets/Laws-S9fq1r9T.js +3 -0
- package/web/dist/assets/Library-MBxadRzn.js +1 -0
- package/web/dist/assets/Loops-Da_JhlzM.js +2 -0
- package/web/dist/assets/{Markdown-CdTX0kLe.js → Markdown-X2a3qvOh.js} +11 -11
- package/web/dist/assets/OperatorHudView--TkvDybH.js +2 -0
- package/web/dist/assets/Personal-CTXKHVbV.js +1 -0
- package/web/dist/assets/Tabs-B0cdf8k6.js +1 -0
- package/web/dist/assets/activity-Dr7ZMBI3.js +1 -0
- package/web/dist/assets/api-CxgfYu18.js +1 -0
- package/web/dist/assets/drawable-BbWQxe82.js +1 -0
- package/web/dist/assets/index-Ctp-4poP.css +1 -0
- package/web/dist/assets/index-ujKG1ZNf.js +2 -0
- package/web/dist/assets/{react-Bbzhmq4v.js → react-B9Z_tXRV.js} +1 -1
- package/web/dist/assets/sections-GalMS0Zv.js +1 -0
- package/web/dist/assets/usePoll-DD8VjtSg.js +1 -0
- package/web/dist/index.html +5 -6
- package/web/src/pages/Laws.jsx +0 -1
- package/web/dist/assets/Activity-Ca2Aruu7.js +0 -1
- package/web/dist/assets/ActivityDetail-Bo7v4Z_r.js +0 -1
- package/web/dist/assets/Dashboard-BuMq46aT.js +0 -2
- package/web/dist/assets/Docs-BzccCllG.js +0 -5
- package/web/dist/assets/FilterInput-CBcJBQPI.js +0 -1
- package/web/dist/assets/GreenhouseView-BCIKcnGG.js +0 -2
- package/web/dist/assets/Installs-DMFJvq7s.js +0 -2
- package/web/dist/assets/JournalView-o1rmd1_1.js +0 -1
- package/web/dist/assets/Laws-BANxq_I_.js +0 -3
- package/web/dist/assets/Library-DW4x0fbh.js +0 -1
- package/web/dist/assets/Loops-DEBibbBP.js +0 -2
- package/web/dist/assets/OperatorHudView-CLD0fYaA.js +0 -2
- package/web/dist/assets/Personal-6uMJvyBG.js +0 -1
- package/web/dist/assets/Tabs-BubnyZhF.js +0 -1
- package/web/dist/assets/activity-CObqyxkL.js +0 -1
- package/web/dist/assets/api-D_ixdyEY.js +0 -1
- package/web/dist/assets/drawable-C_x5xncq.js +0 -1
- package/web/dist/assets/index-CMPgezFm.js +0 -2
- package/web/dist/assets/index-DMsT4Aa6.css +0 -1
- package/web/dist/assets/rolldown-runtime-hePW80VL.js +0 -1
- package/web/dist/assets/sections-Dg23Hel5.js +0 -1
- package/web/dist/assets/usePoll-BOqeunzF.js +0 -1
package/js/build/driver.mjs
CHANGED
|
@@ -1,14 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The generator driver —
|
|
2
|
+
* The generator driver — the nine emit targets and the per-host emit orchestration (flag parsing
|
|
3
|
+
* is `args.mjs`).
|
|
3
4
|
* `bin/build-driver.mjs` (`geneseed-build`) is only its entry; this module is what the CLI verbs
|
|
4
5
|
* (`build`, `setup`, `migrate`, `doctor`, `diff`, `validate`, the web console) import. It lived in
|
|
5
6
|
* `bin/` until 2026-09, which made nine `js/` modules import upward from a binary.
|
|
6
7
|
*
|
|
7
|
-
* P4 adds a driver; it does not replace one. `build.py` is ALSO the `import build` facade
|
|
8
|
-
* that 19 `rituals/` modules read 55 distinct names from, and ~11 sites spawn
|
|
9
|
-
* `[sys.executable, build.py, ...]`. None of those flip here: the Python CLI has to
|
|
10
|
-
* survive this phase intact, and every gate that drives it keeps driving it.
|
|
11
|
-
*
|
|
12
8
|
* WHAT THIS FILE ORIGINATES, AND WHY THAT IS THE PHASE'S WHOLE POINT.
|
|
13
9
|
* Every phase since P2d has asked one question at the process boundary: which values does
|
|
14
10
|
* the CHILD resolve, and which does the parent decide and send? P3c's answer for `cfgDir`
|
|
@@ -39,13 +35,14 @@ import {
|
|
|
39
35
|
unlinkSync,
|
|
40
36
|
} from 'node:fs';
|
|
41
37
|
import path from 'node:path';
|
|
42
|
-
import { parseArgs as nodeParseArgs } from 'node:util';
|
|
43
38
|
import { build, phaseLog } from './bundle.mjs';
|
|
44
39
|
import { emitClaudeRender } from './emit-claude.mjs';
|
|
45
40
|
import { emitOpencodeRender, emitOpencodeGlobalRender } from './emit-opencode.mjs';
|
|
46
41
|
import { settingsIntegrityCheck } from '../hosts/settings.mjs';
|
|
42
|
+
import { hookRunnerEntry } from '../hosts/shim.mjs';
|
|
47
43
|
import { writeText, withPlatformNewlines, isFile } from '../lib/fs.mjs';
|
|
48
44
|
import { parseJson, jsonDumpsIndent } from '../lib/json.mjs';
|
|
45
|
+
import { relPosix } from '../lib/text.mjs';
|
|
49
46
|
// P5c moved these out of this file: `bin/geneseed-cli.mjs` needs the same four resolvers to
|
|
50
47
|
// find a global install, and a resolver that decides WHERE a driver writes is the last thing
|
|
51
48
|
// that should exist twice. golden.py's 259 cells are what made the move safe to attempt.
|
|
@@ -58,25 +55,22 @@ import {
|
|
|
58
55
|
// status` renders to count, so `bin/geneseed-cli.mjs` needs the same checkout paths and the
|
|
59
56
|
// same cfg. golden.py's 259 cells all build one, which is what made the move safe.
|
|
60
57
|
import {
|
|
61
|
-
ROOT,
|
|
58
|
+
ROOT, makeCfg,
|
|
62
59
|
} from './source.mjs';
|
|
63
60
|
|
|
64
61
|
// P5f moved these, for the same arithmetic a third time: `harness rebuild-all` reads the
|
|
65
62
|
// registry to find every install it must re-emit, and a CLI verb may not reach into a driver
|
|
66
63
|
// for a reader. See js/inspect/registry.mjs.
|
|
67
64
|
import { registryRecord, registryRoots } from '../inspect/registry.mjs';
|
|
68
|
-
// The loop engine's trust presets — the `--trust` choices. `score.mjs` imports nothing, which is
|
|
69
|
-
// what lets it sit inside this driver's total `child_process` ban.
|
|
70
|
-
import { DEFAULT_PRESET, PRESETS } from '../loop/score.mjs';
|
|
71
65
|
|
|
72
66
|
// P2. `--sync-themes` crossed into its own module rather than into this file: it is 90 lines
|
|
73
67
|
// of textual surgery over committed files with a corpus of its own, and it is the half of the
|
|
74
68
|
// maintainer pair that needs nothing this driver is banned from having.
|
|
75
69
|
import { syncThemes } from './themes.mjs';
|
|
70
|
+
import { configDefaults, die, parseArgs } from './args.mjs';
|
|
71
|
+
|
|
72
|
+
export { parseDriverArgs } from './args.mjs';
|
|
76
73
|
|
|
77
|
-
/** The nine `--emit` choices: a plain bundle, then a project and a global emit per host. */
|
|
78
|
-
const EMITS = ['files', 'opencode', 'opencode-global', 'claude', 'claude-global',
|
|
79
|
-
'bob', 'bob-global', 'openclaude', 'openclaude-global'];
|
|
80
74
|
|
|
81
75
|
/** `_build_emit.PRIMARY_AGENT_SRC`. */
|
|
82
76
|
const PRIMARY_AGENT_SRC = path.join(ROOT, 'adapters', 'opencode', 'agents', 'orchestrator.md');
|
|
@@ -97,406 +91,14 @@ const PRIMARY_AGENT_SRC = path.join(ROOT, 'adapters', 'opencode', 'agents', 'orc
|
|
|
97
91
|
* It is invisible on a machine whose paths are already canonical, which is every machine
|
|
98
92
|
* this ran on until GitHub's Windows runner handed it a `C:\\Users\\RUNNER~1\\...` and the
|
|
99
93
|
* two CLIs printed the same directory under two different names — a 70-byte difference in a
|
|
100
|
-
* line compared BYTE for byte
|
|
94
|
+
* line the former Python suite compared BYTE for byte; `tests/unit/resolve_out.test.mjs` pins
|
|
95
|
+
* it now. The
|
|
101
96
|
* argument to `resolvePath` is made absolute first so its `expanduser` cannot fire: the
|
|
102
97
|
* reference does not expand `~` here, and a bare `~` is a legal directory name. */
|
|
103
98
|
export function resolveOut(raw) {
|
|
104
99
|
return resolvePath(path.resolve(process.cwd(), raw));
|
|
105
100
|
}
|
|
106
101
|
|
|
107
|
-
/**
|
|
108
|
-
* build.py:307-321 — the three defaults that come from `harness.config.json`.
|
|
109
|
-
*
|
|
110
|
-
* The corrupt-file branch is reproduced including its imprecision: the warning says only
|
|
111
|
-
* "using theme 'neutral'", but the branch also resets posture and mode. Matching the
|
|
112
|
-
* Python text matters more than fixing it here, because the two CLIs' stderr is compared
|
|
113
|
-
* byte-for-byte by `tests/golden.py` (:316).
|
|
114
|
-
*/
|
|
115
|
-
function configDefaults() {
|
|
116
|
-
const d = {
|
|
117
|
-
theme: 'neutral', posture: 'peer', mode: 'direct', trust: DEFAULT_PRESET,
|
|
118
|
-
doctrines: [...PACK_ORDER], excludeRules: [],
|
|
119
|
-
};
|
|
120
|
-
if (!existsSync(CONFIG)) return d;
|
|
121
|
-
try {
|
|
122
|
-
const data = JSON.parse(readFileSync(CONFIG, 'utf8'));
|
|
123
|
-
if (data && typeof data === 'object' && !Array.isArray(data)) {
|
|
124
|
-
if (data.theme !== undefined) d.theme = data.theme;
|
|
125
|
-
if (data.posture !== undefined) d.posture = data.posture;
|
|
126
|
-
if (data.mode !== undefined) d.mode = data.mode;
|
|
127
|
-
// `doctrines` is the only config key that is a LIST, so it is the only one that can be
|
|
128
|
-
// the right type and still nonsense. A bad value warns and falls back to all packs
|
|
129
|
-
// rather than dying: this file is hand-editable, and a typo here must not brick every
|
|
130
|
-
// rebuild of an install whose AGENT.md is otherwise fine. `[]` is a legitimate value
|
|
131
|
-
// (no packs) and must survive the check — hence an explicit Array test, not truthiness.
|
|
132
|
-
if (data.doctrines !== undefined) {
|
|
133
|
-
const known = discoverNames('doctrines', PACK_ORDER[0]);
|
|
134
|
-
if (Array.isArray(data.doctrines) && data.doctrines.every((x) => known.includes(x))) {
|
|
135
|
-
d.doctrines = PACK_ORDER.filter((p) => data.doctrines.includes(p));
|
|
136
|
-
} else {
|
|
137
|
-
process.stderr.write(`[geneseed] WARN: ${path.basename(CONFIG)} "doctrines" is not a `
|
|
138
|
-
+ 'list of known pack names — using all packs.\n');
|
|
139
|
-
}
|
|
140
|
-
}
|
|
141
|
-
// The second axis, read the same forgiving way and defaulting the OTHER direction: a
|
|
142
|
-
// bad value excludes NOTHING, because the safe answer for a list of things to take
|
|
143
|
-
// away is to take nothing away. Same reasoning as `doctrines` defaulting to all.
|
|
144
|
-
if (data.excludeRules !== undefined) {
|
|
145
|
-
if (Array.isArray(data.excludeRules)
|
|
146
|
-
&& data.excludeRules.every((x) => typeof x === 'string')) {
|
|
147
|
-
d.excludeRules = [...new Set(data.excludeRules.map((x) => x.replace(/[ \t]+/, '.')))]
|
|
148
|
-
.sort();
|
|
149
|
-
} else {
|
|
150
|
-
process.stderr.write(`[geneseed] WARN: ${path.basename(CONFIG)} "excludeRules" is `
|
|
151
|
-
+ 'not a list of rule addresses — excluding nothing.\n');
|
|
152
|
-
}
|
|
153
|
-
}
|
|
154
|
-
}
|
|
155
|
-
} catch {
|
|
156
|
-
process.stderr.write(`[geneseed] WARN: ${path.basename(CONFIG)} is unreadable — `
|
|
157
|
-
+ "using theme 'neutral'.\n");
|
|
158
|
-
}
|
|
159
|
-
return d;
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
/**
|
|
163
|
-
* The parser's tables, at MODULE scope so `usage` reads the same two objects the loop below
|
|
164
|
-
* dispatches on. They were local to `parseArgs` until `-h` arrived; a help text listing its
|
|
165
|
-
* own private copy of the flags is a second source that drifts silently the first time a
|
|
166
|
-
* flag is added to one and not the other, and nothing downstream would say so.
|
|
167
|
-
*/
|
|
168
|
-
const VALUED = {
|
|
169
|
-
'--theme': 'theme', '--posture': 'posture', '--mode': 'mode', '--trust': 'trust',
|
|
170
|
-
'--doctrines': 'doctrines', '--exclude-rules': 'excludeRules',
|
|
171
|
-
'--out': 'out', '--target': 'out', '--emit': 'emit',
|
|
172
|
-
'--footprint': 'footprint', '--root': 'root', '--config-dir': 'cfgDir',
|
|
173
|
-
};
|
|
174
|
-
const FLAGS = {
|
|
175
|
-
'--sync-themes': 'syncThemes', '--validate-only': 'validateOnly',
|
|
176
|
-
'-v': 'verbose', '--verbose': 'verbose',
|
|
177
|
-
};
|
|
178
|
-
|
|
179
|
-
/**
|
|
180
|
-
* `util.parseArgs`'s own option table, DERIVED from `VALUED`/`FLAGS` rather than typed out a
|
|
181
|
-
* second time — a hand-written twin is exactly the anti-drift failure this module's docblock
|
|
182
|
-
* already names for `usage()`. Every `VALUED` entry is `type: 'string'` (so a bare `--flag
|
|
183
|
-
* value` form is captured as one token instead of leaking `value` as an orphan positional);
|
|
184
|
-
* every long `FLAGS` entry is `type: 'boolean'`. `-v` gets `short: 'v'` for the same reason
|
|
185
|
-
* `-h` deliberately does NOT get one below `PARSE_OPTIONS` — see `parseArgs`'s docblock.
|
|
186
|
-
*/
|
|
187
|
-
const PARSE_OPTIONS = Object.fromEntries([
|
|
188
|
-
...Object.keys(VALUED).map((f) => [f.slice(2), { type: 'string' }]),
|
|
189
|
-
...Object.keys(FLAGS).filter((f) => f.startsWith('--')).map((f) => [f.slice(2), { type: 'boolean' }]),
|
|
190
|
-
]);
|
|
191
|
-
PARSE_OPTIONS.verbose = { type: 'boolean', short: 'v' };
|
|
192
|
-
|
|
193
|
-
/** `token.name` (dashes stripped, short aliases folded in) -> this file's `args` key. */
|
|
194
|
-
const VALUED_BY_NAME = Object.fromEntries(Object.keys(VALUED).map((f) => [f.slice(2), VALUED[f]]));
|
|
195
|
-
const FLAG_BY_NAME = Object.fromEntries(
|
|
196
|
-
Object.keys(FLAGS).filter((f) => f.startsWith('--')).map((f) => [f.slice(2), FLAGS[f]]),
|
|
197
|
-
);
|
|
198
|
-
|
|
199
|
-
/**
|
|
200
|
-
* The five flags `choice` validates, as ONE table for the same anti-drift reason.
|
|
201
|
-
*
|
|
202
|
-
* A function and not a constant because two of the five read the checkout: `discoverNames`
|
|
203
|
-
* scans `postures/` and `modes/`, so the answer depends on the source tree this driver is
|
|
204
|
-
* standing in and cannot be frozen at import.
|
|
205
|
-
*
|
|
206
|
-
* `--doctrines` is DELIBERATELY not a sixth row. Every consumer of this table treats an
|
|
207
|
-
* entry as one value drawn from a closed list — `choice` refuses anything else, and
|
|
208
|
-
* `usage`'s metavar prints the list as the whole legal surface. `--doctrines` takes a comma
|
|
209
|
-
* LIST plus the sentinel `none`, so a row here would have refused `craft,rigor` outright and
|
|
210
|
-
* advertised a metavar that lies. It is validated by `parseDoctrines` instead and prints the
|
|
211
|
-
* plain `DOCTRINES` metavar, exactly as `--theme` (also open-ended) already does.
|
|
212
|
-
*/
|
|
213
|
-
function choicesFor() {
|
|
214
|
-
return {
|
|
215
|
-
'--emit': EMITS,
|
|
216
|
-
'--footprint': ['lean', 'full'],
|
|
217
|
-
'--posture': discoverNames('postures', 'peer'),
|
|
218
|
-
'--mode': discoverNames('modes', 'direct'),
|
|
219
|
-
'--trust': Object.keys(PRESETS),
|
|
220
|
-
};
|
|
221
|
-
}
|
|
222
|
-
|
|
223
|
-
/**
|
|
224
|
-
* `--doctrines craft,rigor` / `--doctrines none` -> the cfg's pack array.
|
|
225
|
-
*
|
|
226
|
-
* Normalised into `PACK_ORDER` rather than kept in the order typed, which also dedupes: the
|
|
227
|
-
* rendered `Active packs:` line is a MARKER a later reader parses back out of a deployed
|
|
228
|
-
* carrier, and a marker whose contents depend on argument order is one that compares unequal
|
|
229
|
-
* to itself. Validation is against DISCOVERY, not `PACK_ORDER`, so the refusal names the
|
|
230
|
-
* packs the checkout actually has; a discovered pack missing from `PACK_ORDER` is a
|
|
231
|
-
* different fault and `js/build/render.mjs` refuses the whole build for it.
|
|
232
|
-
*/
|
|
233
|
-
/**
|
|
234
|
-
* `--exclude-rules "process 7,craft 3"` (or `process.7`) -> the cfg's exclusion array.
|
|
235
|
-
*
|
|
236
|
-
* BOTH SPELLINGS ACCEPTED ON THE WAY IN, ONE ON THE WAY OUT. The carrier's marker line and
|
|
237
|
-
* every citation in the corpus spell a rule `process 7`; the console's deep link and the
|
|
238
|
-
* catalogue spell it `process.7`. A flag that took only one of them would be wrong for
|
|
239
|
-
* whichever half of the documentation the reader had open, so it takes either and normalises
|
|
240
|
-
* to the dotted form the code compares.
|
|
241
|
-
*
|
|
242
|
-
* ⚠ VALIDATED AGAINST THE PACK FILES, NOT A RANGE. `process 9` and `craft 0` are both
|
|
243
|
-
* refusals, and so is a pack this checkout does not ship — an exclusion that names nothing is
|
|
244
|
-
* a typo that would otherwise take away nothing in silence, which is the failure this flag
|
|
245
|
-
* can least afford: the user asked for a rule to STOP binding and it would keep binding.
|
|
246
|
-
* Sorted, deduped, for the same marker-stability reason `--doctrines` is.
|
|
247
|
-
*/
|
|
248
|
-
function parseExcludeRules(value) {
|
|
249
|
-
const s = value.trim();
|
|
250
|
-
if (s === 'none' || s === '') return [];
|
|
251
|
-
const known = knownRuleIds();
|
|
252
|
-
const out = [];
|
|
253
|
-
for (const raw of s.split(',').map((x) => x.trim()).filter(Boolean)) {
|
|
254
|
-
const id = raw.replace(/[ \t]+/, '.');
|
|
255
|
-
if (!known.includes(id)) {
|
|
256
|
-
die(2, `argument --exclude-rules: invalid choice: '${raw}' (choose from `
|
|
257
|
-
+ `${known.map((c) => `'${c.replace('.', ' ')}'`).join(', ')}, or 'none')`);
|
|
258
|
-
}
|
|
259
|
-
out.push(id);
|
|
260
|
-
}
|
|
261
|
-
return [...new Set(out)].sort();
|
|
262
|
-
}
|
|
263
|
-
|
|
264
|
-
function parseDoctrines(value) {
|
|
265
|
-
const s = value.trim();
|
|
266
|
-
const known = discoverNames('doctrines', PACK_ORDER[0]);
|
|
267
|
-
if (s === 'none') return [];
|
|
268
|
-
const names = s.split(',').map((x) => x.trim()).filter(Boolean);
|
|
269
|
-
if (!names.length) {
|
|
270
|
-
die(2, "argument --doctrines: expected a comma-separated list of packs, or 'none'");
|
|
271
|
-
}
|
|
272
|
-
for (const n of names) {
|
|
273
|
-
if (!known.includes(n)) {
|
|
274
|
-
die(2, `argument --doctrines: invalid choice: '${n}' (choose from `
|
|
275
|
-
+ `${known.map((c) => `'${c}'`).join(', ')}, or 'none')`);
|
|
276
|
-
}
|
|
277
|
-
}
|
|
278
|
-
return PACK_ORDER.filter((p) => names.includes(p));
|
|
279
|
-
}
|
|
280
|
-
|
|
281
|
-
/**
|
|
282
|
-
* `-h/--help` — the flag argparse handed the reference for free and this hand-rolled parser
|
|
283
|
-
* did not, so the two entry points disagreed: the reference printed usage and exited 0 while
|
|
284
|
-
* `geneseed-build --help` died with `unrecognized arguments: --help` and exit 2.
|
|
285
|
-
*
|
|
286
|
-
* NO CELL COULD SEE IT. `golden.py`'s `_argv` builds every cell out of the render flags and
|
|
287
|
-
* never emits `--help`, so the byte comparison only ever ran inputs both implementations
|
|
288
|
-
* were built for — a flag missing from one side is invisible to a gate that never passes it.
|
|
289
|
-
* `test_the_help_text_names_every_flag_the_reference_takes` is the gate, and it reads
|
|
290
|
-
* `build.py`'s `add_argument` calls rather than this file, so it fails on drift from EITHER
|
|
291
|
-
* side rather than agreeing with whichever one it was copied from.
|
|
292
|
-
*
|
|
293
|
-
* The text is deliberately NOT argparse's byte-for-byte. Reproducing its wrapping would put
|
|
294
|
-
* a hand-written copy of the reference's output in a file no gate compares, which is the
|
|
295
|
-
* drift this whole docblock is about; the surface here is the npx one, in the same class as
|
|
296
|
-
* the `--sync-themes` and `--validate-only` refusals that already answer differently.
|
|
297
|
-
*/
|
|
298
|
-
function usage() {
|
|
299
|
-
const choices = choicesFor();
|
|
300
|
-
const metavar = (flag) => (choices[flag]
|
|
301
|
-
? `{${choices[flag].join(',')}}`
|
|
302
|
-
: VALUED[flag].toUpperCase());
|
|
303
|
-
const valued = Object.keys(VALUED);
|
|
304
|
-
const bare = Object.keys(FLAGS);
|
|
305
|
-
return [
|
|
306
|
-
`usage: geneseed-build [-h] ${[...valued.map((f) => `[${f} ${metavar(f)}]`),
|
|
307
|
-
...bare.map((f) => `[${f}]`)].join(' ')}`,
|
|
308
|
-
'',
|
|
309
|
-
'Render the Geneseed harness for a theme.',
|
|
310
|
-
'',
|
|
311
|
-
'options:',
|
|
312
|
-
' -h, --help',
|
|
313
|
-
...valued.map((f) => ` ${f} ${metavar(f)}`),
|
|
314
|
-
...bare.map((f) => ` ${f}`),
|
|
315
|
-
'',
|
|
316
|
-
].join('\n');
|
|
317
|
-
}
|
|
318
|
-
|
|
319
|
-
/**
|
|
320
|
-
* argparse's `--flag value` and `--flag=value`, plus the `--target` alias for `--out` — the
|
|
321
|
-
* SPLITTING now done by `util.parseArgs({tokens: true})`, with every choice/mutex/wording
|
|
322
|
-
* decision this function made before P6 (Task 4) still made HERE, unmoved.
|
|
323
|
-
*
|
|
324
|
-
* `strict: false` is load-bearing twice: it lets an unrecognized flag surface as a token this
|
|
325
|
-
* walk can refuse in its OWN words rather than node's, and it is what makes a value-taking
|
|
326
|
-
* flag (`type: 'string'` in `PARSE_OPTIONS`) swallow the very next raw argument even when that
|
|
327
|
-
* argument itself looks like a flag — `--theme --posture peer` sets `theme` to the literal
|
|
328
|
-
* string `"--posture"` and leaves `peer` a stray token, exactly as the hand-rolled version
|
|
329
|
-
* being replaced did with its own `argv[i += 1]`. That is not a bug this port inherited by
|
|
330
|
-
* accident: it is the one shape `parseArgs`'s tokenizer reproduces for free, and reproducing
|
|
331
|
-
* anything smarter (a lookahead that refuses to swallow a flag-shaped value) would be new
|
|
332
|
-
* behaviour, not a preserved one.
|
|
333
|
-
*
|
|
334
|
-
* `-h` DELIBERATELY IS NOT `PARSE_OPTIONS.help`'s short alias. Declaring one would make
|
|
335
|
-
* `parseArgs` fold a bundled `-vh` into two tokens — `verbose` then `help` — and print the
|
|
336
|
-
* help text for a cluster the original parser refused outright as one unrecognized argument.
|
|
337
|
-
* Detecting help by re-reading `argv[tok.index]` for the literal text `-h`/`--help` keeps the
|
|
338
|
-
* ORIGINAL equality check (`tok === '-h' || tok === '--help'`) intact no matter how `parseArgs`
|
|
339
|
-
* privately tokenizes the cluster, and `argv[tok.index]` is what recovers that literal text
|
|
340
|
-
* for every refusal below — `tok.name`/`tok.rawName` are `parseArgs`'s own canonicalisation and
|
|
341
|
-
* would print `-b`/`-o`/`-g`/`-u`/`-s` for a bundle like `-bogus` where the original parser (and
|
|
342
|
-
* this one) refuses the whole typed token, `-bogus`, once.
|
|
343
|
-
*/
|
|
344
|
-
function parseArgs(argv, defaults) {
|
|
345
|
-
const args = {
|
|
346
|
-
theme: defaults.theme, posture: defaults.posture, mode: defaults.mode,
|
|
347
|
-
trust: defaults.trust,
|
|
348
|
-
doctrines: defaults.doctrines,
|
|
349
|
-
excludeRules: defaults.excludeRules,
|
|
350
|
-
out: 'Harness', emit: 'files', footprint: 'lean', root: null,
|
|
351
|
-
syncThemes: false, validateOnly: false, verbose: false,
|
|
352
|
-
};
|
|
353
|
-
const { tokens } = nodeParseArgs({
|
|
354
|
-
args: argv, options: PARSE_OPTIONS, allowPositionals: true, strict: false, tokens: true,
|
|
355
|
-
});
|
|
356
|
-
for (const tok of tokens) {
|
|
357
|
-
if (tok.kind === 'option' && tok.value === undefined
|
|
358
|
-
&& (argv[tok.index] === '-h' || argv[tok.index] === '--help')) {
|
|
359
|
-
process.stdout.write(usage());
|
|
360
|
-
// The same marker `die` throws, with a SUCCESS code: help is not a refusal, and
|
|
361
|
-
// `main`'s catch is what turns either into this process's exit status. A bare
|
|
362
|
-
// `process.exit(0)` here would be the P5f bug in reverse — it takes `rebuild-all`'s
|
|
363
|
-
// loop with it, and skips the stdout flush the text was just written to.
|
|
364
|
-
const e = new Error('help');
|
|
365
|
-
e.exitCode = 0;
|
|
366
|
-
throw e;
|
|
367
|
-
}
|
|
368
|
-
if (tok.kind === 'option' && VALUED_BY_NAME[tok.name] !== undefined) {
|
|
369
|
-
if (tok.value === undefined) die(2, `argument ${tok.rawName}: expected one argument`);
|
|
370
|
-
args[VALUED_BY_NAME[tok.name]] = tok.value;
|
|
371
|
-
continue;
|
|
372
|
-
}
|
|
373
|
-
// `tok.value === undefined` excludes `--sync-themes=x` from matching here — a store_true
|
|
374
|
-
// flag never took `=` in the hand-rolled version either, because its `FLAGS[tok]` lookup
|
|
375
|
-
// keyed on the WHOLE typed token (equals sign and all), never on the split-off flag name.
|
|
376
|
-
// `parseArgs` always sets a `value` KEY on every option token (`undefined` when none was
|
|
377
|
-
// given), so this must compare the VALUE, not `'value' in tok` — the key is always there.
|
|
378
|
-
if (tok.kind === 'option' && tok.value === undefined && FLAG_BY_NAME[tok.name] !== undefined) {
|
|
379
|
-
args[FLAG_BY_NAME[tok.name]] = true;
|
|
380
|
-
continue;
|
|
381
|
-
}
|
|
382
|
-
// Every other shape — an unrecognized option, a bare positional, or `--` (which `parseArgs`
|
|
383
|
-
// always treats as a terminator but this parser never did) — refuses with the literal text
|
|
384
|
-
// typed at that argv position, matching the original's `unrecognized arguments: ${tok}`.
|
|
385
|
-
die(2, `unrecognized arguments: ${argv[tok.index]}`);
|
|
386
|
-
}
|
|
387
|
-
const choices = choicesFor();
|
|
388
|
-
for (const flag of Object.keys(choices)) choice(flag, args[VALUED[flag]], choices[flag]);
|
|
389
|
-
// Only when the flag was actually PASSED is this a string; left at its default it is
|
|
390
|
-
// already the array `configDefaults` built, and re-parsing an array would stringify it.
|
|
391
|
-
if (typeof args.doctrines === 'string') args.doctrines = parseDoctrines(args.doctrines);
|
|
392
|
-
if (typeof args.excludeRules === 'string') args.excludeRules = parseExcludeRules(args.excludeRules);
|
|
393
|
-
return args;
|
|
394
|
-
}
|
|
395
|
-
|
|
396
|
-
/**
|
|
397
|
-
* The generator's OWN flag surface, for the one consumer that is not this driver.
|
|
398
|
-
*
|
|
399
|
-
* `geneseed validate` takes `--theme/--emit/--out/--root/--footprint/-v` — the reference's
|
|
400
|
-
* `--validate-only` reads exactly the flags `build.py`'s parser already produced, so the port
|
|
401
|
-
* hands the CLI this parser rather than a second one beside it. That is not tidiness: a
|
|
402
|
-
* hand-rolled copy would have its own `--target` alias, its own `choices` lists and its own
|
|
403
|
-
* `-h`, and `test_the_help_text_names_every_flag_the_reference_takes` gates only this one.
|
|
404
|
-
*
|
|
405
|
-
* `withPlatformNewlines` because the refusal and `-h` paths WRITE: called from `bin/build-driver.mjs`
|
|
406
|
-
* they sit inside `main`'s funnel, and called from the CLI binary they would not.
|
|
407
|
-
*/
|
|
408
|
-
export function parseDriverArgs(argv) {
|
|
409
|
-
return withPlatformNewlines(() => parseArgs(argv, configDefaults()));
|
|
410
|
-
}
|
|
411
|
-
|
|
412
|
-
function choice(flag, value, allowed) {
|
|
413
|
-
if (!allowed.includes(value)) {
|
|
414
|
-
die(2, `argument ${flag}: invalid choice: '${value}' `
|
|
415
|
-
+ `(choose from ${allowed.map((c) => `'${c}'`).join(', ')})`);
|
|
416
|
-
}
|
|
417
|
-
}
|
|
418
|
-
|
|
419
|
-
/**
|
|
420
|
-
* argparse's error exit — a THROW since P5f, where it used to be `process.exit(code)`.
|
|
421
|
-
*
|
|
422
|
-
* `harness rebuild-all` re-emits every active install and its whole contract is "continue
|
|
423
|
-
* past a failure so one broken install never blocks the rest". The Python gets that for free:
|
|
424
|
-
* each rebuild is a SUBPROCESS, and a child that exits 2 hands back a return code. Here the
|
|
425
|
-
* driver is a module in the same process, so `process.exit` would take the loop, the CLI and
|
|
426
|
-
* every remaining install with it — the one place where importing rather than spawning is not
|
|
427
|
-
* transparent, and it turns a per-install failure into a total one.
|
|
428
|
-
*
|
|
429
|
-
* The marker is `exitCode`, the SAME one `assertSourceComplete` and `effectiveTheme` already
|
|
430
|
-
* use, and the first draft of this used a second name so that only `die` would be converted
|
|
431
|
-
* — on the argument that an incomplete source should keep its stack. That draft was wrong,
|
|
432
|
-
* and `rebuild-all/one-broken-install-does-not-stop-the-rest` is what said so: a
|
|
433
|
-
* `.geneseed-theme` naming a theme that does not exist makes `effectiveTheme` refuse, and
|
|
434
|
-
* with only `die` converted the refusal propagated out of the loop and the remaining
|
|
435
|
-
* installs were never rebuilt. `main` is standing in for a PROCESS, and a process boundary
|
|
436
|
-
* turns every deliberate refusal into an exit code — the narrower rule was a distinction
|
|
437
|
-
* with no principle behind it.
|
|
438
|
-
*
|
|
439
|
-
* One behaviour improves as a side effect, and it is worth naming rather than discovering
|
|
440
|
-
* later: `process.exit` terminates without flushing a pending stdout write, so a refusal on
|
|
441
|
-
* a slow pipe could lose output it had already produced. Returning through `main` lets Node
|
|
442
|
-
* drain normally.
|
|
443
|
-
*/
|
|
444
|
-
function die(code, msg) {
|
|
445
|
-
process.stderr.write(`geneseed: error: ${msg}\n`);
|
|
446
|
-
const e = new Error(msg);
|
|
447
|
-
e.exitCode = code;
|
|
448
|
-
throw e;
|
|
449
|
-
}
|
|
450
|
-
|
|
451
|
-
/**
|
|
452
|
-
* `_build_settings._hook_runner_entry()`'s two values — this driver's answer, which since
|
|
453
|
-
* P5b is NOT Python's.
|
|
454
|
-
*
|
|
455
|
-
* The Python original returns `sys.executable` and `<checkout>/rituals/harness.py`: the
|
|
456
|
-
* interpreter running `build.py`, and the harness it will hand `%*` to. This driver returns
|
|
457
|
-
* `process.execPath` and `<checkout>/bin/geneseed-hook.mjs`, because the four verbs the
|
|
458
|
-
* emitted hooks invoke — context, git-gate, rule-gate, learn — are now Node, and an install
|
|
459
|
-
* this driver emits therefore needs no Python at all for its hooks.
|
|
460
|
-
*
|
|
461
|
-
* WHAT THIS RETIRED, AND WHY IT IS A DELETION. Until this phase the function was
|
|
462
|
-
* `hookOptsOrDie`: it scanned PATH for an interpreter and refused the four Claude-shaped
|
|
463
|
-
* emits with exit 4 when it found none, because writing a shim that names a nonexistent
|
|
464
|
-
* interpreter silently disables every hook in the install. Both halves are gone. `runner` is
|
|
465
|
-
* `process.execPath` — the node already running this file, which by construction exists —
|
|
466
|
-
* so there is nothing left to discover and no way for discovery to fail. P4e kept the
|
|
467
|
-
* unreachable exit-3 branch because `test_the_node_driver_classifies_every_emit` asserts a
|
|
468
|
-
* partition it belongs to; nothing asserts a partition over this one, so keeping it would
|
|
469
|
-
* be keeping code no test can reach for no stated reason.
|
|
470
|
-
*
|
|
471
|
-
* THE SHIM IS MACHINE-WIDE, AND THAT IS THE DECISION THIS PHASE ACTUALLY TOOK.
|
|
472
|
-
* `hookShimPath()` is `$GENESEED_HOME`-or-`~/.geneseed` + `bin/geneseed-hook[.cmd]`, with no
|
|
473
|
-
* per-install component: every emit of every install on the machine rewrites the same file,
|
|
474
|
-
* and every install's hooks execute it. While both drivers baked Python that was invisible,
|
|
475
|
-
* because last-writer-wins wrote the same thing. It is observable now — a machine whose last
|
|
476
|
-
* emit ran through this file has EVERY install's hooks running under Node, including
|
|
477
|
-
* installs the Python driver wrote, and the reverse.
|
|
478
|
-
*
|
|
479
|
-
* That is correct exactly while the two entry points answer the same verbs the same way, so
|
|
480
|
-
* the gate that used to be a formality is now load-bearing:
|
|
481
|
-
* `test_the_entry_carries_exactly_the_verbs_the_emitter_wires` reads the emitter's wiring
|
|
482
|
-
* and `bin/geneseed-hook.mjs`'s VERBS table and requires them EQUAL — a wired verb the entry
|
|
483
|
-
* lacks is a dead hook on every install on the machine, not just this one. The alternative
|
|
484
|
-
* considered and rejected was a per-driver shim path: the path is baked into every already
|
|
485
|
-
* emitted hook command, so changing it makes every existing install's hooks stale until
|
|
486
|
-
* re-emit, which is P10's migration arriving five phases early.
|
|
487
|
-
*
|
|
488
|
-
* EXPORTED SINCE P6i, because the emitter is no longer its only caller.
|
|
489
|
-
* `js/maintain/uninstall.mjs`'s `remergeClaudeHooks` re-merges the canonical hooks when a disabled
|
|
490
|
-
* Claude install is turned back on, and `mergeClaudeSettings` refuses to guess the pair —
|
|
491
|
-
* correctly. There is exactly one right answer on this side and it is this function, so the
|
|
492
|
-
* second caller imports it rather than restating two lines that must never drift: a
|
|
493
|
-
* reactivate whose shim path differed from the emitter's would wire hooks pointing at a file
|
|
494
|
-
* the emitter never writes.
|
|
495
|
-
*/
|
|
496
|
-
export function hookRunnerEntry() {
|
|
497
|
-
return { runner: process.execPath, entry: path.join(ROOT, 'bin', 'geneseed-hook.mjs') };
|
|
498
|
-
}
|
|
499
|
-
|
|
500
102
|
/**
|
|
501
103
|
* `_build_global._preamble_exclude` — the `claudeMdExcludes` entry a PROJECT install writes
|
|
502
104
|
* to suppress the GLOBAL preamble of the same host, or null for a host that gets none.
|
|
@@ -549,10 +151,10 @@ function warnBobGlobalOverProject() {
|
|
|
549
151
|
* would be written into `opencode.json`'s instruction path.
|
|
550
152
|
*/
|
|
551
153
|
function relUnder(out, root) {
|
|
552
|
-
const rel =
|
|
154
|
+
const rel = relPosix(root, out);
|
|
553
155
|
if (rel === '' || rel === '.') return '';
|
|
554
156
|
if (rel.startsWith('..') || path.isAbsolute(rel)) return '';
|
|
555
|
-
return rel
|
|
157
|
+
return rel;
|
|
556
158
|
}
|
|
557
159
|
|
|
558
160
|
/**
|
|
@@ -655,7 +257,7 @@ function projectSurvivors(emitName) {
|
|
|
655
257
|
* RENDER and WIRE already ran in Node before P4; what lived only in Python was the stage
|
|
656
258
|
* either side of them: reading the previous manifest (PRE), pruning what this layer no
|
|
657
259
|
* longer owns, writing the manifest atomically, and the summary line. All five stages now
|
|
658
|
-
* run in one process, in the order `
|
|
260
|
+
* run in one process, in the order `tests/unit/emit_phase_order.test.mjs` pins:
|
|
659
261
|
* RENDER -> WIRE -> PRUNE -> MANIFEST -> VERIFY (opencode has no VERIFY; it writes no
|
|
660
262
|
* settings file of its own).
|
|
661
263
|
*/
|
|
@@ -730,7 +332,7 @@ function emitOpencodeGlobal(cfg, args, out) {
|
|
|
730
332
|
const rendered = emitOpencodeGlobalRender(
|
|
731
333
|
{ ...cfg, primaryAgentSrc: PRIMARY_AGENT_SRC },
|
|
732
334
|
{
|
|
733
|
-
theme: args.theme, cfgDir, out
|
|
335
|
+
theme: args.theme, cfgDir, out,
|
|
734
336
|
footprint: args.footprint, nativeCatalog: hostCatalogsNatively('opencode'),
|
|
735
337
|
oldOwned, agentPath,
|
|
736
338
|
});
|
|
@@ -780,10 +382,10 @@ function emitClaudeCore(cfg, args, { cfgDir, claudeMd, scope, host, out, hookOpt
|
|
|
780
382
|
// measured `null` instead of an assumed one.
|
|
781
383
|
const rendered = emitClaudeRender(cfg, {
|
|
782
384
|
theme: args.theme, cfgDir, claudeMd, scope, host,
|
|
783
|
-
out
|
|
385
|
+
out,
|
|
784
386
|
footprint: args.footprint, nativeCatalog: hostCatalogsNatively(host),
|
|
785
387
|
oldOwned, oldManaged, preambleExclude: preambleExclude(claudeMd, host),
|
|
786
|
-
|
|
388
|
+
hookOpts,
|
|
787
389
|
});
|
|
788
390
|
const { owned, stats, memStatus, nbStatus, managed } = rendered;
|
|
789
391
|
|
|
@@ -828,123 +430,91 @@ function emitClaudeCore(cfg, args, { cfgDir, claudeMd, scope, host, out, hookOpt
|
|
|
828
430
|
}
|
|
829
431
|
|
|
830
432
|
/**
|
|
831
|
-
*
|
|
832
|
-
*
|
|
833
|
-
*
|
|
834
|
-
*
|
|
835
|
-
*
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
// bin/geneseed-hook.mjs` into the hooks it writes, so the sentence named a file its own
|
|
847
|
-
// emit does not use. The two drivers bake different entries and printed the same claim;
|
|
848
|
-
// the neutral wording is true of both, and stays true when only one is left. It is frozen
|
|
849
|
-
// in the `claude-global` cells' recorded stdout, which is why it moves here rather than in
|
|
850
|
-
// the deletion phase, which may not move a recorded byte.
|
|
851
|
-
process.stdout.write(`[geneseed] claude-global -> ${cfgDir}: ${r.nAgents} subagents, `
|
|
852
|
-
+ `${r.nSkills} skills, CLAUDE.md, ${r.nHooks} hook group(s), settings.json, `
|
|
853
|
-
+ `${r.memStatus}, ${r.nbStatus}. No plugins/workflows/themes (no Claude analogue); `
|
|
854
|
-
+ '~/.claude/plugins is never touched. Hooks call the harness by absolute path; set '
|
|
855
|
-
+ 'GENESEED_HARNESS only to relocate memory.\n');
|
|
856
|
-
return cfgDir;
|
|
857
|
-
}
|
|
858
|
-
|
|
859
|
-
/** `_build_global.emit_claude` — per-repo: CLAUDE.md at the root + a `.claude/` layer. */
|
|
860
|
-
function emitClaude(cfg, args, out) {
|
|
861
|
-
const root = args.root ? resolveOut(args.root) : out;
|
|
862
|
-
const hookOpts = hookRunnerEntry();
|
|
863
|
-
const r = emitClaudeCore(cfg, args, {
|
|
864
|
-
cfgDir: path.join(root, '.claude'), claudeMd: path.join(root, 'CLAUDE.md'),
|
|
865
|
-
scope: 'project', host: 'claude', out, hookOpts,
|
|
866
|
-
});
|
|
867
|
-
process.stdout.write(`[geneseed] claude (folder) -> ${root}: CLAUDE.md + .claude/ `
|
|
868
|
-
+ `(${r.nAgents} subagents, ${r.nSkills} skills, ${r.nHooks} hook group(s), `
|
|
869
|
-
+ `settings.json), ${r.memStatus}, ${r.nbStatus}.\n`);
|
|
870
|
-
}
|
|
871
|
-
|
|
872
|
-
/**
|
|
873
|
-
* OpenClaude global — `~/.openclaude` (or `$OPENCLAUDE_CONFIG_DIR`). The Claude emit with
|
|
874
|
-
* another config dir: OpenClaude is a Claude Code fork that reads neither `~/.claude` nor
|
|
875
|
-
* `CLAUDE_CONFIG_DIR`, so a Claude install is invisible to it and it needs its own.
|
|
433
|
+
* The six Claude-shaped emits — Claude, Bob and OpenClaude, each per-repo and global — as one
|
|
434
|
+
* body and a table. They differ only in where the config dir is, what the instruction carrier
|
|
435
|
+
* is called and where it sits, and the one summary line each prints; everything else is
|
|
436
|
+
* `emitClaudeCore`.
|
|
437
|
+
*
|
|
438
|
+
* `configDir` set = a GLOBAL emit: the target is `args.cfgDir` (the `--config-dir` / `diff`
|
|
439
|
+
* override) or the host's resolved dir, the carrier lives inside it, and the dir is returned
|
|
440
|
+
* for the caller to record. Unset = PER-REPO: the target is `<root>/<layer>`, and the carrier
|
|
441
|
+
* sits at the root unless `carrierInLayer` says otherwise.
|
|
442
|
+
*
|
|
443
|
+
* `hookRunnerEntry()` is still called BEFORE the engine, though it can no longer refuse: the
|
|
444
|
+
* shape stayed after P5b deleted the refusal so that a future value which CAN fail is decided
|
|
445
|
+
* while nothing has been written, rather than behind a half-rendered config dir. `before` runs
|
|
446
|
+
* after it and before the emit — Bob's global warning is about a state this emit is at the
|
|
447
|
+
* point of creating, so printing it afterwards would describe it as already chosen.
|
|
876
448
|
*/
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
+ `${r.nHooks} hook group(s), settings.json, ${r.memStatus}, ${r.nbStatus}.\n`);
|
|
927
|
-
return cfgDir;
|
|
928
|
-
}
|
|
449
|
+
const CLAUDE_SHAPED = {
|
|
450
|
+
'claude-global': {
|
|
451
|
+
host: 'claude', configDir: claudeConfigDir, carrier: 'CLAUDE.md',
|
|
452
|
+
// "Hooks call the harness by absolute path": the hooks bake `<node> bin/geneseed-hook.mjs`.
|
|
453
|
+
summary: (at, r) => `[geneseed] claude-global -> ${at}: ${r.nAgents} subagents, `
|
|
454
|
+
+ `${r.nSkills} skills, CLAUDE.md, ${r.nHooks} hook group(s), settings.json, `
|
|
455
|
+
+ `${r.memStatus}, ${r.nbStatus}. No plugins/workflows/themes (no Claude analogue); `
|
|
456
|
+
+ '~/.claude/plugins is never touched. Hooks call the harness by absolute path; set '
|
|
457
|
+
+ 'GENESEED_HARNESS only to relocate memory.\n',
|
|
458
|
+
},
|
|
459
|
+
// Per-repo: CLAUDE.md at the root + a `.claude/` layer.
|
|
460
|
+
claude: {
|
|
461
|
+
host: 'claude', layer: '.claude', carrier: 'CLAUDE.md',
|
|
462
|
+
summary: (at, r) => `[geneseed] claude (folder) -> ${at}: CLAUDE.md + .claude/ `
|
|
463
|
+
+ `(${r.nAgents} subagents, ${r.nSkills} skills, ${r.nHooks} hook group(s), `
|
|
464
|
+
+ `settings.json), ${r.memStatus}, ${r.nbStatus}.\n`,
|
|
465
|
+
},
|
|
466
|
+
// `~/.openclaude` (or `$OPENCLAUDE_CONFIG_DIR`). OpenClaude is a Claude Code fork that reads
|
|
467
|
+
// neither `~/.claude` nor `CLAUDE_CONFIG_DIR`, so a Claude install is invisible to it.
|
|
468
|
+
'openclaude-global': {
|
|
469
|
+
host: 'openclaude', configDir: openclaudeConfigDir, carrier: 'CLAUDE.md',
|
|
470
|
+
summary: (at, r) => `[geneseed] openclaude-global -> ${at}: ${r.nAgents} subagents, `
|
|
471
|
+
+ `${r.nSkills} skills, CLAUDE.md, ${r.nHooks} hook group(s), settings.json, `
|
|
472
|
+
+ `${r.memStatus}, ${r.nbStatus}. MCP servers go in .openclaude.json.\n`,
|
|
473
|
+
},
|
|
474
|
+
// Everything under `.openclaude/`, the preamble included. OpenClaude reads the root CLAUDE.md
|
|
475
|
+
// only when the repo has no AGENTS.md, but `.openclaude/CLAUDE.md` always; keeping the root
|
|
476
|
+
// untouched also lets a Claude Code install share the repo.
|
|
477
|
+
openclaude: {
|
|
478
|
+
host: 'openclaude', layer: '.openclaude', carrier: 'CLAUDE.md', carrierInLayer: true,
|
|
479
|
+
summary: (at, r) => `[geneseed] openclaude (folder) -> ${at}: .openclaude/ `
|
|
480
|
+
+ `(CLAUDE.md, ${r.nAgents} subagents, ${r.nSkills} skills, ${r.nHooks} hook group(s), `
|
|
481
|
+
+ `settings.local.json), ${r.memStatus}, ${r.nbStatus}.\n`,
|
|
482
|
+
},
|
|
483
|
+
'bob-global': {
|
|
484
|
+
host: 'bob', configDir: bobConfigDir, carrier: 'AGENTS.md', before: warnBobGlobalOverProject,
|
|
485
|
+
summary: (at, r) => `[geneseed] bob-global -> ${at}: ${r.nAgents} subagents, `
|
|
486
|
+
+ `${r.nSkills} skills, rules/geneseed.md (Bob's always-injected channel; a global `
|
|
487
|
+
+ 'AGENTS.md is not auto-loaded, so none is written), '
|
|
488
|
+
+ `${r.nHooks} hook group(s), settings.json, ${r.memStatus}, ${r.nbStatus}.\n`,
|
|
489
|
+
},
|
|
490
|
+
// Per-repo: AGENTS.md at the root + a `.bob/` layer.
|
|
491
|
+
bob: {
|
|
492
|
+
host: 'bob', layer: '.bob', carrier: 'AGENTS.md',
|
|
493
|
+
summary: (at, r) => `[geneseed] bob (folder) -> ${at}: AGENTS.md + .bob/ `
|
|
494
|
+
+ `(${r.nAgents} subagents, ${r.nSkills} skills, rules/geneseed.md shadow stub, `
|
|
495
|
+
+ `${r.nHooks} hook group(s), settings.json), ${r.memStatus}, ${r.nbStatus}.\n`,
|
|
496
|
+
},
|
|
497
|
+
};
|
|
929
498
|
|
|
930
|
-
/** `
|
|
931
|
-
|
|
932
|
-
const
|
|
499
|
+
/** One row of `CLAUDE_SHAPED` as an emit — the `(cfg, args, out)` shape every dispatch takes. */
|
|
500
|
+
const claudeShaped = (name) => (cfg, args, out) => {
|
|
501
|
+
const { host, configDir, layer, carrier, carrierInLayer, before, summary } = CLAUDE_SHAPED[name];
|
|
502
|
+
const root = configDir ? null : (args.root ? resolveOut(args.root) : out);
|
|
503
|
+
const cfgDir = configDir ? (args.cfgDir ?? configDir()) : path.join(root, layer);
|
|
504
|
+
const claudeMd = path.join(configDir || carrierInLayer ? cfgDir : root, carrier);
|
|
933
505
|
const hookOpts = hookRunnerEntry();
|
|
506
|
+
before?.();
|
|
934
507
|
const r = emitClaudeCore(cfg, args, {
|
|
935
|
-
cfgDir
|
|
936
|
-
scope: 'project', host: 'bob', out, hookOpts,
|
|
508
|
+
cfgDir, claudeMd, scope: configDir ? 'global' : 'project', host, out, hookOpts,
|
|
937
509
|
});
|
|
938
|
-
process.stdout.write(
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
}
|
|
510
|
+
process.stdout.write(summary(root ?? cfgDir, r));
|
|
511
|
+
return configDir ? cfgDir : undefined;
|
|
512
|
+
};
|
|
942
513
|
|
|
943
514
|
/**
|
|
944
515
|
* build.py:437-466 — the POST stage, which writes markers and records the install and
|
|
945
|
-
* wires NOTHING. `
|
|
946
|
-
*
|
|
947
|
-
* the same reason.
|
|
516
|
+
* wires NOTHING. `tests/unit/emit_phase_order.test.mjs`'s 'the post-emit marker stage wires
|
|
517
|
+
* nothing' classifies it, and that is why it stays after the dispatch.
|
|
948
518
|
*
|
|
949
519
|
* `writeText`, never `writeFileSync`: Python's `Path.write_text` opens in text mode with
|
|
950
520
|
* `newline=None` and translates the trailing `\n` to CRLF on Windows. A marker written
|
|
@@ -984,9 +554,9 @@ function writeMarkers(markerDir, emit, footprint) {
|
|
|
984
554
|
*/
|
|
985
555
|
const GLOBAL_EMITS = {
|
|
986
556
|
opencode: emitOpencodeGlobal,
|
|
987
|
-
claude:
|
|
988
|
-
bob:
|
|
989
|
-
openclaude:
|
|
557
|
+
claude: claudeShaped('claude-global'),
|
|
558
|
+
bob: claudeShaped('bob-global'),
|
|
559
|
+
openclaude: claudeShaped('openclaude-global'),
|
|
990
560
|
};
|
|
991
561
|
|
|
992
562
|
/**
|
|
@@ -1008,21 +578,43 @@ const GLOBAL_EMITS = {
|
|
|
1008
578
|
* three-positional call leaves in place, inherited here rather than re-decided.
|
|
1009
579
|
*/
|
|
1010
580
|
const PROJECT_EMITS = {
|
|
1011
|
-
claude:
|
|
581
|
+
claude: claudeShaped('claude'), bob: claudeShaped('bob'), openclaude: claudeShaped('openclaude'),
|
|
1012
582
|
// P2. `opencode` joins the three for `cmdValidate`, which has to be able to render EVERY
|
|
1013
583
|
// `--emit` choice into its sandbox and not only the three doctor already scans. Its call
|
|
1014
|
-
// shape is
|
|
584
|
+
// shape is the Claude-shaped emits' exactly — `(cfg, args, out)` — so the row is the whole change.
|
|
1015
585
|
opencode: emitOpencode,
|
|
1016
586
|
};
|
|
1017
587
|
|
|
1018
|
-
|
|
588
|
+
/**
|
|
589
|
+
* The five render axes every `*Into` takes, folded into one `cfg` — the drift readers pass what
|
|
590
|
+
* the deployment says, `validate` passes its flags.
|
|
591
|
+
*
|
|
592
|
+
* EACH IS OMITTED WHEN NULL, never passed through, because `null` is "no opinion" and only
|
|
593
|
+
* `makeCfg`'s own defaults are the fail-closed answers: `peer`/`direct`/the default preset, ALL
|
|
594
|
+
* packs (`doctrinesOfDir` answers `null` for "no marker", which must not render as `[]`), and
|
|
595
|
+
* NO exclusions (a list of rules taken away defaults to taking nothing away). The posture and
|
|
596
|
+
* mode readers return `null` for "undetectable" too, which an explicit `posture: null` would
|
|
597
|
+
* carry straight past the parameter default.
|
|
598
|
+
*/
|
|
599
|
+
function axesCfg({ posture, mode, trust, doctrines, excludeRules }) {
|
|
600
|
+
return makeCfg({
|
|
601
|
+
...(posture ? { posture } : {}), ...(mode ? { mode } : {}), ...(trust ? { trust } : {}),
|
|
602
|
+
...(doctrines ? { doctrines } : {}), ...(excludeRules ? { excludeRules } : {}),
|
|
603
|
+
});
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
export function emitProjectInto(host, {
|
|
607
|
+
theme, out, root, footprint = 'full', posture = null, mode = null, trust = null,
|
|
608
|
+
doctrines = null, excludeRules = null,
|
|
609
|
+
}) {
|
|
1019
610
|
const emit = PROJECT_EMITS[host];
|
|
1020
|
-
//
|
|
1021
|
-
//
|
|
1022
|
-
//
|
|
1023
|
-
//
|
|
1024
|
-
return withPlatformNewlines(() => emit(
|
|
1025
|
-
{ theme, footprint, root }, out
|
|
611
|
+
// The axes ride the same rail into every `*Into`, and for the same reason: a drift reader
|
|
612
|
+
// that renders `expected` at the DEFAULT pack set reports the whole doctrines section as
|
|
613
|
+
// edited on every narrowed install, and `validate --doctrines craft` that ignored its flag
|
|
614
|
+
// checked an all-packs render nobody asked for.
|
|
615
|
+
return withPlatformNewlines(() => emit(
|
|
616
|
+
axesCfg({ posture, mode, trust, doctrines, excludeRules }), { theme, footprint, root }, out,
|
|
617
|
+
));
|
|
1026
618
|
}
|
|
1027
619
|
|
|
1028
620
|
/**
|
|
@@ -1033,14 +625,17 @@ export function emitProjectInto(host, { theme, out, root, footprint = 'full', do
|
|
|
1033
625
|
* `nativeCatalog: false` is that three-positional call's signature default, inherited here
|
|
1034
626
|
* exactly as `run` inherits it below.
|
|
1035
627
|
*/
|
|
1036
|
-
export function buildInto({
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
628
|
+
export function buildInto({
|
|
629
|
+
theme, out, footprint = 'lean', posture = null, mode = null, trust = null, doctrines = null,
|
|
630
|
+
excludeRules = null,
|
|
631
|
+
}) {
|
|
632
|
+
return withPlatformNewlines(() => build(axesCfg({ posture, mode, trust, doctrines, excludeRules }),
|
|
633
|
+
theme, out, { footprint, nativeCatalog: false }));
|
|
1040
634
|
}
|
|
1041
635
|
|
|
1042
636
|
export function emitGlobalInto(host, {
|
|
1043
637
|
theme, out, cfgDir, footprint, posture = null, mode = null, doctrines = null, trust = null,
|
|
638
|
+
excludeRules = null,
|
|
1044
639
|
}) {
|
|
1045
640
|
// `build.HOSTS.get(host, build.HOSTS["opencode"])` — an unknown host falls back rather than
|
|
1046
641
|
// raising, because the host comes from a marker file a user can edit.
|
|
@@ -1050,25 +645,15 @@ export function emitGlobalInto(host, {
|
|
|
1050
645
|
// emit's STDOUT and lets its stderr through (the Python's `redirect_stdout` does exactly
|
|
1051
646
|
// that), so an untranslated WARN from `warnBobGlobalOverProject` would reach the user's
|
|
1052
647
|
// terminal with the wrong bytes on the one stream the caller deliberately does not hide.
|
|
1053
|
-
//
|
|
1054
|
-
//
|
|
1055
|
-
//
|
|
1056
|
-
//
|
|
1057
|
-
//
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
// `null` for "no marker / undetectable", and that must render at ALL packs rather than
|
|
1063
|
-
// at `[]`, so the key is OMITTED instead of passed through — `makeCfg`'s own default is
|
|
1064
|
-
// the fail-closed answer and an explicit `doctrines: null` would not reach it.
|
|
1065
|
-
makeCfg({
|
|
1066
|
-
posture: posture || 'peer', mode: mode || 'direct', trust: trust || DEFAULT_PRESET,
|
|
1067
|
-
...(doctrines ? { doctrines } : {}),
|
|
1068
|
-
}),
|
|
1069
|
-
{ theme, footprint, root: null, cfgDir }, out,
|
|
1070
|
-
),
|
|
1071
|
-
);
|
|
648
|
+
// ALL FIVE AXES TRAVEL WITH THE RENDER (see `axesCfg`). Callers that pass nothing keep the
|
|
649
|
+
// reference's behaviour of rendering at `peer`/`direct`; the drift readers pass what the
|
|
650
|
+
// deployment actually says, because rendering `expected` at the defaults reports AGENT.md as
|
|
651
|
+
// edited on every install that chose a register, narrowed its packs or excluded a rule —
|
|
652
|
+
// the same scar the footprint left in `diffCollect`, one axis over each time.
|
|
653
|
+
return withPlatformNewlines(() => emit(
|
|
654
|
+
axesCfg({ posture, mode, trust, doctrines, excludeRules }),
|
|
655
|
+
{ theme, footprint, root: null, cfgDir }, out,
|
|
656
|
+
));
|
|
1072
657
|
}
|
|
1073
658
|
|
|
1074
659
|
/**
|
|
@@ -1113,8 +698,8 @@ function run(argv) {
|
|
|
1113
698
|
if (args.syncThemes) return syncThemes() ? 1 : 0;
|
|
1114
699
|
// `--validate-only` is NOT, and cannot be: its source-tree half is the doctor, and
|
|
1115
700
|
// `js/inspect/checks-authoring.mjs` starts a process (`node --check` over the OpenCode plugins). This driver
|
|
1116
|
-
// is under a transitive ban on reaching any such module, gated
|
|
1117
|
-
// `tests/
|
|
701
|
+
// is under a transitive ban on reaching any such module, gated by an import walk in
|
|
702
|
+
// `tests/unit/hook_cli.test.mjs` ("the generator driver still reaches no child-process module").
|
|
1118
703
|
// So the tool crossed onto the CLI binary, which already carries the doctor, and this flag
|
|
1119
704
|
// points at it rather than silently building for real into the caller's `--out`.
|
|
1120
705
|
if (args.validateOnly) {
|