geneseed 3.1.1 → 3.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +68 -1
- package/DESIGN.md +15 -15
- package/README.md +1 -1
- package/SETUP.md +1 -1
- package/SHIPPED.md +1 -1
- package/bin/{geneseed.mjs → build-driver.mjs} +39 -42
- package/bin/geneseed-cli.mjs +33 -32
- package/bin/geneseed-hook.mjs +7 -7
- package/docs/design-history.md +40 -40
- package/docs/extending.md +111 -83
- package/harness.config.json +1 -1
- package/js/README.md +203 -0
- package/js/build/bundle.mjs +231 -0
- package/js/{catalog.mjs → build/catalog.mjs} +7 -7
- package/js/build/emit-claude.mjs +287 -0
- package/js/build/emit-common.mjs +255 -0
- package/js/build/emit-opencode.mjs +214 -0
- package/js/{generate.mjs → build/generate.mjs} +27 -28
- package/js/{render.mjs → build/render.mjs} +13 -9
- package/js/{checkout.mjs → build/source.mjs} +9 -9
- package/js/build/stubs.mjs +256 -0
- package/js/{themes.mjs → build/themes.mjs} +24 -24
- package/js/build/version.mjs +155 -0
- package/js/{hooks.mjs → hosts/hooks.mjs} +47 -44
- package/js/{hosts.mjs → hosts/hosts.mjs} +30 -28
- package/js/{installs.mjs → hosts/installs.mjs} +18 -16
- package/js/{link.mjs → hosts/link.mjs} +29 -28
- package/js/{mcp.mjs → hosts/mcp.mjs} +10 -9
- package/js/{native.mjs → hosts/native.mjs} +24 -22
- package/js/{opencode.mjs → hosts/opencode.mjs} +12 -7
- package/js/{settings.mjs → hosts/settings.mjs} +63 -55
- package/js/inspect/checks-authoring.mjs +761 -0
- package/js/inspect/checks-build.mjs +231 -0
- package/js/inspect/checks-repo.mjs +308 -0
- package/js/{diff.mjs → inspect/diff.mjs} +34 -31
- package/js/inspect/doctor.mjs +286 -0
- package/js/{excludes.mjs → inspect/excludes.mjs} +30 -33
- package/js/{inventory.mjs → inspect/inventory.mjs} +20 -17
- package/js/{registry.mjs → inspect/registry.mjs} +12 -10
- package/js/inspect/scan.mjs +125 -0
- package/js/{status.mjs → inspect/status.mjs} +27 -25
- package/js/inspect/validate.mjs +199 -0
- package/js/lib/fs.mjs +37 -759
- package/js/lib/json.mjs +344 -0
- package/js/lib/paths.mjs +242 -0
- package/js/lib/proc.mjs +3 -3
- package/js/lib/text.mjs +181 -0
- package/js/lib/udiff.mjs +1 -1
- package/js/{memory.mjs → maintain/memory.mjs} +10 -10
- package/js/{migrate.mjs → maintain/migrate.mjs} +28 -26
- package/js/{setup.mjs → maintain/setup.mjs} +34 -32
- package/js/{uninstall.mjs → maintain/uninstall.mjs} +55 -56
- package/js/{update.mjs → maintain/update.mjs} +51 -51
- package/js/{anim.mjs → ui/anim.mjs} +30 -28
- package/js/{cli.mjs → ui/cli.mjs} +10 -10
- package/js/{menu.mjs → ui/menu.mjs} +10 -9
- package/js/{tui.mjs → ui/tui.mjs} +36 -34
- package/js/web/actions.mjs +84 -85
- package/js/web/activity.mjs +22 -21
- package/js/web/api.mjs +72 -50
- package/js/web/daemon.mjs +319 -0
- package/js/web/docs.mjs +24 -21
- package/js/web/graph.mjs +0 -0
- package/js/web/handler.mjs +442 -0
- package/js/web/jobs.mjs +21 -19
- package/js/web/routes.mjs +223 -0
- package/js/web/server.mjs +56 -987
- package/package.json +11 -3
- package/src/doctrines/README.md +1 -1
- package/themes/opencode/README.md +1 -1
- package/web/dist/assets/{About-DyK7yRy7.js → About-CPvIFZGh.js} +1 -1
- package/web/dist/assets/{Activity-D_sYBlW-.js → Activity-z0uXdS7J.js} +1 -1
- package/web/dist/assets/{ActivityDetail-BomKcMhy.js → ActivityDetail-CEj5IJpF.js} +1 -1
- package/web/dist/assets/{Diff-BSvi1WUg.js → Diff-BMR8fPuG.js} +1 -1
- package/web/dist/assets/{Doctor-Cz1s-y1n.js → Doctor-Bz5qE4r9.js} +1 -1
- package/web/dist/assets/{Graph-CuntzFJ0.js → Graph-BV6a6t2l.js} +1 -1
- package/web/dist/assets/{Harnesses-DgRyOviS.js → Harnesses-C5Ag5PaQ.js} +1 -1
- package/web/dist/assets/{Laws-CWk3Mpri.js → Laws-BK07NKw9.js} +1 -1
- package/web/dist/assets/{Library-DXhTN9ju.js → Library-CiXN9sC3.js} +1 -1
- package/web/dist/assets/{Markdown-BoiZcdUl.js → Markdown-DG6FjUz-.js} +1 -1
- package/web/dist/assets/{Profile-DfCHlZ03.js → Profile-B2oSvdxH.js} +1 -1
- package/web/dist/assets/{Rules-eND7hJv9.js → Rules-DobVPd80.js} +1 -1
- package/web/dist/assets/{Skills-CDl9opZO.js → Skills-DQPsvsn9.js} +1 -1
- package/web/dist/assets/{StatusBadge-DSDtcArX.js → StatusBadge-BhJ3HhdI.js} +1 -1
- package/web/dist/assets/{Themes-CoAWuIeM.js → Themes-BP38FBU9.js} +1 -1
- package/web/dist/assets/{activity-Dmb0FTAx.js → activity-D9mfv7pY.js} +1 -1
- package/web/dist/assets/index-BE226Gv6.css +1 -0
- package/web/dist/assets/{index-jrY4m9jP.js → index-BZ7cPLMI.js} +1 -1
- package/web/dist/assets/index-BgIs5eIa.js +12 -0
- package/web/dist/assets/{index-Dg58ScBe.js → index-DGM5j2hB.js} +1 -1
- package/web/dist/index.html +2 -2
- package/web/src/pages/Laws.jsx +1 -1
- package/docs/declined.md +0 -200
- package/docs/limits.md +0 -329
- package/js/doctor.mjs +0 -1750
- package/js/emit.mjs +0 -1411
- package/web/dist/assets/index-B-mXquBZ.css +0 -1
- package/web/dist/assets/index-CH_Z725C.js +0 -12
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `build` — render one theme's sources into a bundle directory, and the two checks that
|
|
3
|
+
* decide whether it may.
|
|
4
|
+
*
|
|
5
|
+
* The LAYER BOTH HOST EMITS SIT ON, and that is why it is not in `emit.mjs` with them: the
|
|
6
|
+
* first map left it there, and the derived imports came back with `emit-opencode.mjs` and
|
|
7
|
+
* `emit-claude.mjs` importing `emit.mjs` while `emit.mjs` imported them back. A bundle is
|
|
8
|
+
* produced before any host is chosen, so it belongs UNDER the emitters, not beside them.
|
|
9
|
+
*
|
|
10
|
+
* `assertSourceComplete` scans the TEMPLATE rather than the render, and the src-dirs marker
|
|
11
|
+
* is how a re-emit knows which directory names the last one used.
|
|
12
|
+
*/
|
|
13
|
+
import path from 'node:path';
|
|
14
|
+
import { VERSION_MARKER } from '../hosts/hosts.mjs';
|
|
15
|
+
import { copyFile, readText, writeText } from '../lib/fs.mjs';
|
|
16
|
+
import { jsonDumpsIndent, parseJson, formatReprAscii, isTruthy } from '../lib/json.mjs';
|
|
17
|
+
import { OWNED_SRC_DIRS, SRC_DIRS_MARKER, TMPL_SPEC_RE, isDir, isFile } from './emit-common.mjs';
|
|
18
|
+
import { SRC_DIR_TOKENS, renderAll } from './render.mjs';
|
|
19
|
+
import {
|
|
20
|
+
ensureBundleGitignore, ensureContextStub, ensureMemoryIndex, ensureNotebookIndex,
|
|
21
|
+
ensureProfileStub, ensureRulesStub, ensureWikiStub,
|
|
22
|
+
} from './stubs.mjs';
|
|
23
|
+
import { writeVersion } from './version.mjs';
|
|
24
|
+
import { existsSync, mkdirSync, renameSync, rmSync } from 'node:fs';
|
|
25
|
+
|
|
26
|
+
// ---------------------------------------------------------------------------
|
|
27
|
+
// Source completeness
|
|
28
|
+
// ---------------------------------------------------------------------------
|
|
29
|
+
|
|
30
|
+
/** `_build_render._missing_referenced_specs` — scans the TEMPLATE, not the render. */
|
|
31
|
+
function missingReferencedSpecs(cfg) {
|
|
32
|
+
let tmpl;
|
|
33
|
+
try {
|
|
34
|
+
tmpl = readText(path.join(cfg.src, 'AGENT.md.tmpl'));
|
|
35
|
+
} catch {
|
|
36
|
+
return [];
|
|
37
|
+
}
|
|
38
|
+
const missing = new Set();
|
|
39
|
+
for (const [, kind, name] of tmpl.matchAll(TMPL_SPEC_RE)) {
|
|
40
|
+
const dir = kind.toLowerCase();
|
|
41
|
+
if (name !== '_template' && !isFile(path.join(cfg.src, dir, `${name}.md`))) {
|
|
42
|
+
missing.add(`${dir}/${name}.md`);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return [...missing].sort();
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* `_build_render.assert_source_complete`.
|
|
50
|
+
*
|
|
51
|
+
* Python raises `SystemExit(1)` after writing to stderr. Here the message goes to the
|
|
52
|
+
* captured stderr buffer and the throw carries the exit code, which the driver turns
|
|
53
|
+
* into a process status by `bin/build-driver.mjs`'s `main` — so the caller sees the same stream
|
|
54
|
+
* bytes and the same exit status whichever runtime rendered.
|
|
55
|
+
*/
|
|
56
|
+
export function assertSourceComplete(cfg, context = '') {
|
|
57
|
+
const missing = missingReferencedSpecs(cfg);
|
|
58
|
+
if (!missing.length) return;
|
|
59
|
+
const where = context ? ` (${context})` : '';
|
|
60
|
+
process.stderr.write(
|
|
61
|
+
`[geneseed][E-INCOMPLETE] ✗ source is incomplete${where}: AGENT.md references `
|
|
62
|
+
+ `${missing.length} spec(s) with no file under src/:\n`
|
|
63
|
+
+ missing.map((m) => ` - ${m}\n`).join('')
|
|
64
|
+
+ '[geneseed] ✗ Refusing to emit — a partial source would write dead links '
|
|
65
|
+
+ 'and a global re-emit would delete the good copies in an existing install.\n'
|
|
66
|
+
+ '[geneseed] ✗ Re-sync the source (./geneseed update, or re-run the upgrade) '
|
|
67
|
+
+ 'and try again.\n');
|
|
68
|
+
const e = new Error('source is incomplete');
|
|
69
|
+
e.exitCode = 1;
|
|
70
|
+
throw e;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// ---------------------------------------------------------------------------
|
|
74
|
+
// The owned-dir marker
|
|
75
|
+
// ---------------------------------------------------------------------------
|
|
76
|
+
|
|
77
|
+
/** `_build_render._read_prior_src_dirs` — `{}` on anything unreadable or not an object. */
|
|
78
|
+
function readPriorSrcDirs(out) {
|
|
79
|
+
let data;
|
|
80
|
+
try {
|
|
81
|
+
data = parseJson(readText(path.join(out, SRC_DIRS_MARKER)));
|
|
82
|
+
} catch {
|
|
83
|
+
return {};
|
|
84
|
+
}
|
|
85
|
+
return data && typeof data === 'object' && !Array.isArray(data) ? data : {};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** `_build_render._write_src_dirs_marker` — temp + replace. */
|
|
89
|
+
function writeSrcDirsMarker(out, resolved) {
|
|
90
|
+
const tmp = path.join(out, `${SRC_DIRS_MARKER}.tmp`);
|
|
91
|
+
writeText(tmp, `${jsonDumpsIndent(resolved)}\n`);
|
|
92
|
+
renameSync(tmp, path.join(out, SRC_DIRS_MARKER));
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* The guard on the prune of a RENAMED owned dir — `build`'s one destructive path driven
|
|
97
|
+
* by file content rather than by code.
|
|
98
|
+
*
|
|
99
|
+
* `.geneseed-srcdirs.json` is a plain file a user (or another tool) can edit, and the
|
|
100
|
+
* name in it is joined onto `out` and handed to a recursive delete. `".."` would take out
|
|
101
|
+
* the bundle's PARENT, an absolute path replaces `out` entirely under Python's
|
|
102
|
+
* `Path.__truediv__`, and `"a/b"` reaches into nested content. Only a plain
|
|
103
|
+
* single-segment name resolving directly under `out` is ever deleted.
|
|
104
|
+
*/
|
|
105
|
+
function safePriorDirName(out, priorName) {
|
|
106
|
+
return typeof priorName === 'string'
|
|
107
|
+
&& priorName !== '.' && priorName !== '..'
|
|
108
|
+
&& !path.isAbsolute(priorName)
|
|
109
|
+
&& path.basename(priorName) === priorName
|
|
110
|
+
&& path.dirname(path.resolve(out, priorName)) === path.resolve(out);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// ---------------------------------------------------------------------------
|
|
114
|
+
// build
|
|
115
|
+
// ---------------------------------------------------------------------------
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* `_build_render.build` — render the bundle into `out`.
|
|
119
|
+
*
|
|
120
|
+
* `out` arrives as the STRING Python already resolved, and is echoed into the progress
|
|
121
|
+
* lines verbatim: Python interpolates `str(Path)`, which is backslash-separated on
|
|
122
|
+
* Windows, and re-deriving it here would differ by separator in every message.
|
|
123
|
+
*/
|
|
124
|
+
/**
|
|
125
|
+
* THE PHASE BOUNDARY MARKER — T8, and the whole of what this port owes
|
|
126
|
+
* `tests/test_emit_phase_order.py`.
|
|
127
|
+
*
|
|
128
|
+
* Every emit runs its stages in one order: RENDER* → WIRE* → PRUNE → MANIFEST → VERIFY. RENDER
|
|
129
|
+
* writes files Geneseed owns wholesale; WIRE reconciles files the USER co-owns (the
|
|
130
|
+
* CLAUDE.md/AGENTS.md managed block, settings(.local).json, opencode.json) by reading what is
|
|
131
|
+
* already there and merging into it. WIRE must precede MANIFEST because wiring is what fills the
|
|
132
|
+
* `managed` record the manifest writes, and every teardown path unwires exactly what was wired.
|
|
133
|
+
*
|
|
134
|
+
* WHY A RUNTIME LOG AND NOT A SOURCE WALK. The reference gated this with five `ast` walks over
|
|
135
|
+
* its own generator, and could, because it keeps `_claude_render` / `_claude_wire` as two
|
|
136
|
+
* dispatchers and therefore two statements at every call site — a shape its own docstring calls
|
|
137
|
+
* "not incidental, it is what leaves this walker something to check". THIS PORT DOES NOT HAVE
|
|
138
|
+
* THAT SHAPE: `claudeWire` is a real function but the two OpenCode emits inline their merge
|
|
139
|
+
* (`emitOpencodeRender`, `emitOpencodeGlobalRender`), and PRUNE/MANIFEST/VERIFY are not in this
|
|
140
|
+
* module at all — they are in `bin/build-driver.mjs`'s driver bodies. A walker would have to span two
|
|
141
|
+
* files and would still be reading source rather than watching a run.
|
|
142
|
+
*
|
|
143
|
+
* So the order is made OBSERVABLE instead, which is strictly the better gate: it reports what
|
|
144
|
+
* actually executed, in the order it executed, including the branch a given host took.
|
|
145
|
+
*
|
|
146
|
+
* ⚠ BEHIND AN ENV VAR THE THREE REPLAYERS DO NOT SET, and that is the entire safety argument.
|
|
147
|
+
* `cellEnv` clears every `GENESEED_*` knob by prefix, so no recorded cell can turn this on and
|
|
148
|
+
* the emit corpus cannot move by one byte. It writes to STDERR, never stdout: stdout is the
|
|
149
|
+
* decision channel every Geneseed hook signals through, and a stray byte there turns a blocking
|
|
150
|
+
* gate into a silently permissive one.
|
|
151
|
+
*
|
|
152
|
+
* Read at CALL time, not at import, so one process can drive several emits under it.
|
|
153
|
+
*/
|
|
154
|
+
export function phaseLog(phase) {
|
|
155
|
+
if (!process.env.GENESEED_PHASE_LOG) return;
|
|
156
|
+
process.stderr.write(`[geneseed:phase] ${phase}\n`);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export function build(cfg, themeName, out, { footprint = 'full', nativeCatalog = false } = {}) {
|
|
160
|
+
phaseLog('RENDER');
|
|
161
|
+
const { theme, items } = renderAll(cfg, themeName, { footprint, nativeCatalog });
|
|
162
|
+
assertSourceComplete(cfg, `theme '${themeName}'`);
|
|
163
|
+
mkdirSync(out, { recursive: true });
|
|
164
|
+
|
|
165
|
+
// Wipe the owned dirs ONLY inside an established bundle: a first render into an
|
|
166
|
+
// arbitrary repo (`--out .`) must never delete an agents/ or skills/ the USER owns.
|
|
167
|
+
const isBundle = isFile(path.join(out, '.geneseed-theme')) || isFile(path.join(out, VERSION_MARKER));
|
|
168
|
+
const priorSrcDirs = isBundle ? readPriorSrcDirs(out) : {};
|
|
169
|
+
const resolvedSrcDirs = {};
|
|
170
|
+
for (const srcDir of OWNED_SRC_DIRS) {
|
|
171
|
+
// `theme.get(token, srcDir)` — `??` agrees with it except for an explicit JSON null,
|
|
172
|
+
// where Python would go on to join `None` onto a path and raise. No theme has one.
|
|
173
|
+
const dirname = theme[SRC_DIR_TOKENS[srcDir]] ?? srcDir;
|
|
174
|
+
resolvedSrcDirs[srcDir] = dirname;
|
|
175
|
+
const managed = path.join(out, dirname);
|
|
176
|
+
|
|
177
|
+
// `isTruthy`, not `priorName &&`: the marker is user-editable JSON, and Python's
|
|
178
|
+
// `if prior_name` is false for `[]`, `{}` and `0` where JS's is true for the first
|
|
179
|
+
// two. Getting that wrong warns about a value Python skips in silence — a stderr
|
|
180
|
+
// divergence reachable only from a hand-edited file, which is the one place nobody
|
|
181
|
+
// would look for it.
|
|
182
|
+
const priorName = priorSrcDirs[srcDir];
|
|
183
|
+
if (isBundle && isTruthy(priorName) && priorName !== dirname) {
|
|
184
|
+
if (safePriorDirName(out, priorName)) {
|
|
185
|
+
const stale = path.join(out, priorName);
|
|
186
|
+
if (isDir(stale)) rmSync(stale, { recursive: true, force: true });
|
|
187
|
+
} else {
|
|
188
|
+
process.stderr.write('[geneseed] WARN: ignoring suspicious prior dir name '
|
|
189
|
+
+ `${formatReprAscii(priorName)} recorded in ${SRC_DIRS_MARKER} - not pruned.\n`);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
if (!isDir(managed)) continue;
|
|
194
|
+
if (isBundle) {
|
|
195
|
+
rmSync(managed, { recursive: true, force: true });
|
|
196
|
+
} else {
|
|
197
|
+
// STDOUT, and ASCII-only: this print crashed with UnicodeEncodeError on a cp1252
|
|
198
|
+
// Windows console when it carried a warning-sign emoji.
|
|
199
|
+
process.stdout.write(`[geneseed] WARN: ${managed} already exists and ${out} is not a `
|
|
200
|
+
+ 'Geneseed bundle — keeping it; rendered files merge into it.\n');
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const nbDirname = theme[SRC_DIR_TOKENS.notebook] ?? 'notebook';
|
|
205
|
+
for (const { rel, text, src } of items) {
|
|
206
|
+
const dest = path.join(out, rel);
|
|
207
|
+
mkdirSync(path.dirname(dest), { recursive: true });
|
|
208
|
+
// The notebook is the agent's sovereign space: seeded files are written once and
|
|
209
|
+
// never re-emitted, so the agent may rewrite its own rules. Only `.gitignore` is
|
|
210
|
+
// re-asserted — the one law it cannot lift.
|
|
211
|
+
const parts = rel.split('/');
|
|
212
|
+
if (parts[0] === nbDirname && path.basename(rel) !== '.gitignore' && existsSync(dest)) {
|
|
213
|
+
continue;
|
|
214
|
+
}
|
|
215
|
+
if (text !== null) writeText(dest, text);
|
|
216
|
+
else copyFile(src, dest);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
writeText(path.join(out, '.geneseed-theme'), `${themeName}\n`);
|
|
220
|
+
writeVersion(cfg, out);
|
|
221
|
+
writeSrcDirsMarker(out, resolvedSrcDirs);
|
|
222
|
+
ensureContextStub(out);
|
|
223
|
+
ensureWikiStub(out);
|
|
224
|
+
ensureRulesStub(out);
|
|
225
|
+
ensureProfileStub(out);
|
|
226
|
+
ensureBundleGitignore(out);
|
|
227
|
+
ensureMemoryIndex(path.join(out, theme[SRC_DIR_TOKENS.memory] ?? 'memory'));
|
|
228
|
+
ensureNotebookIndex(path.join(out, theme[SRC_DIR_TOKENS.notebook] ?? 'notebook'));
|
|
229
|
+
process.stdout.write(`[geneseed] built theme '${themeName}' -> ${out} (${items.length} files)\n`);
|
|
230
|
+
}
|
|
231
|
+
|
|
@@ -23,11 +23,11 @@
|
|
|
23
23
|
* NOTHING HERE PAINTS. No cursor moves, no clears, no alternate screen: this writes lines
|
|
24
24
|
* and stops, exactly as `status` does. That is what keeps the dropped panel dropped.
|
|
25
25
|
*/
|
|
26
|
-
import { helpWidth } from '
|
|
27
|
-
import { tuiInventory } from '
|
|
28
|
-
import { defaultTheme, installedDefaults } from '
|
|
29
|
-
import {
|
|
30
|
-
import { dwidth, fit, icon, truncd, tuiEntries } from '
|
|
26
|
+
import { helpWidth } from '../ui/cli.mjs';
|
|
27
|
+
import { tuiInventory } from '../inspect/inventory.mjs';
|
|
28
|
+
import { defaultTheme, installedDefaults } from '../hosts/installs.mjs';
|
|
29
|
+
import { printOut } from '../lib/fs.mjs';
|
|
30
|
+
import { dwidth, fit, icon, truncd, tuiEntries } from '../ui/tui.mjs';
|
|
31
31
|
|
|
32
32
|
/**
|
|
33
33
|
* The listing's section names, and the row kind each one selects.
|
|
@@ -129,9 +129,9 @@ export function cmdCatalog(args) {
|
|
|
129
129
|
const lines = catalogLines(tuiInventory(theme, inst.doctrines), args.section,
|
|
130
130
|
helpWidth() + 2, args.source);
|
|
131
131
|
if (!lines.length) {
|
|
132
|
-
|
|
132
|
+
printOut(`[catalog] nothing to list${args.section ? ` under ${args.section}` : ''}.\n`);
|
|
133
133
|
return 1;
|
|
134
134
|
}
|
|
135
|
-
for (const line of lines)
|
|
135
|
+
for (const line of lines) printOut(`${line}\n`);
|
|
136
136
|
return 0;
|
|
137
137
|
}
|
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Claude and Bob emits — one renderer, and the settings merge that wires it in.
|
|
3
|
+
*
|
|
4
|
+
* Split out of `emit.mjs` beside `emit-opencode.mjs`, over the shared writers in
|
|
5
|
+
* `emit-common.mjs`. `claudeWire` is the half that touches a file the USER also edits, and
|
|
6
|
+
* its prior-claim pruning is the reason `deepEquals` exists — read it before changing anything here.
|
|
7
|
+
*/
|
|
8
|
+
import path from 'node:path';
|
|
9
|
+
import { VERSION_MARKER } from '../hosts/hosts.mjs';
|
|
10
|
+
import { loadAgentOverrides, writeNativeLayer } from '../hosts/native.mjs';
|
|
11
|
+
import { ensureAgentOverridesStub } from '../hosts/opencode.mjs';
|
|
12
|
+
import {
|
|
13
|
+
managedBlockRemove, managedBlockWrite, mergeClaudeSettings, unwireClaudeExcludes,
|
|
14
|
+
unwireClaudeSettings, wireClaudeExcludes,
|
|
15
|
+
} from '../hosts/settings.mjs';
|
|
16
|
+
import { writeText } from '../lib/fs.mjs';
|
|
17
|
+
import { isTruthy } from '../lib/json.mjs';
|
|
18
|
+
import { assertSourceComplete, phaseLog } from './bundle.mjs';
|
|
19
|
+
import {
|
|
20
|
+
get, globalMemory, globalNotebook, isDict, relPosix, shipLeanLaws, stripCapabilityLinks,
|
|
21
|
+
} from './emit-common.mjs';
|
|
22
|
+
import { renderAll, renderFile } from './render.mjs';
|
|
23
|
+
import {
|
|
24
|
+
BOB_RULES_STUB, ensureExcludesStub, ensureMemoryIndex, ensureNotebookIndex, ensureProfileStub,
|
|
25
|
+
ensureRulesStub, ensureWikiStub,
|
|
26
|
+
} from './stubs.mjs';
|
|
27
|
+
import { writeVersion } from './version.mjs';
|
|
28
|
+
import { existsSync, mkdirSync } from 'node:fs';
|
|
29
|
+
|
|
30
|
+
// ---------------------------------------------------------------------------
|
|
31
|
+
// _emit_claude_core — both halves
|
|
32
|
+
// ---------------------------------------------------------------------------
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* RENDER and WIRE for `_build_global._emit_claude_core`: this renders wholesale, then calls
|
|
36
|
+
* `claudeWire` below for the CLAUDE.md managed-block merge and the settings file. PRUNE,
|
|
37
|
+
* MANIFEST and VERIFY are the driver's, in `bin/build-driver.mjs`.
|
|
38
|
+
*
|
|
39
|
+
* Six emits route through here — claude, bob and copilot at both scopes — so this one
|
|
40
|
+
* job is what puts two thirds of the matrix on the seam.
|
|
41
|
+
*
|
|
42
|
+
* `claudeMdText` is the payload's one item that is not a file this process wrote. WIRE
|
|
43
|
+
* needs the managed block's text, and that text is a RENDER (`prefixedAgentText`
|
|
44
|
+
* re-renders AGENT.md with every store dir prefixed), so it is computed here and merged
|
|
45
|
+
* there. `emitOpencodeRender` needed nothing of the kind — its wired file is derived from
|
|
46
|
+
* a path, not from a render.
|
|
47
|
+
*/
|
|
48
|
+
export function emitClaudeRender(cfg, job) {
|
|
49
|
+
phaseLog('RENDER');
|
|
50
|
+
const {
|
|
51
|
+
theme: themeName, cfgDir, claudeMd, scope, out, footprint, host, nativeCatalog,
|
|
52
|
+
oldOwned,
|
|
53
|
+
} = job;
|
|
54
|
+
|
|
55
|
+
// `os.path.relpath(cfg, claude_md.parent)` — note the reversed argument order, and
|
|
56
|
+
// that Python answers '.' for an identical pair where `path.relative` answers ''.
|
|
57
|
+
const relCfg = path.relative(path.dirname(claudeMd), cfgDir).split(path.sep).join('/')
|
|
58
|
+
|| '.';
|
|
59
|
+
const lawsPrefix = relCfg === '.' ? '' : `${relCfg}/`;
|
|
60
|
+
const { theme, items } = renderAll(cfg, themeName, { footprint, lawsPrefix, nativeCatalog });
|
|
61
|
+
assertSourceComplete(cfg, `claude-${scope}`);
|
|
62
|
+
mkdirSync(cfgDir, { recursive: true });
|
|
63
|
+
|
|
64
|
+
/** `_emit_claude_core._prefixed_agent_text`. */
|
|
65
|
+
const prefixedAgentText = (prefix) => {
|
|
66
|
+
const tmplItem = items.find((i) => i.rel === 'AGENT.md');
|
|
67
|
+
if (tmplItem === undefined) return null;
|
|
68
|
+
// Deliberately NOT the same lookup as `agentText` below: that one skips an
|
|
69
|
+
// AGENT.md whose text is null, this one does not. Unreachable today (`.md` is a
|
|
70
|
+
// text suffix, so the render is never null) and reproduced anyway, because the two
|
|
71
|
+
// spellings sit four lines apart in the Python and only one of them filters.
|
|
72
|
+
//
|
|
73
|
+
// The fast path itself is byte-inert, measured: removing it re-renders with a theme
|
|
74
|
+
// whose DIR_* tokens gained an EMPTY prefix and with the same lawsPrefix, so it
|
|
75
|
+
// produces exactly the item's own text. The mutation stays green, and that is
|
|
76
|
+
// "nothing to detect" rather than "the gate cannot see it" — recorded the way
|
|
77
|
+
// `themed_rel` and `lstripNewlines` are, so the two verdicts stay distinguishable.
|
|
78
|
+
if (!prefix) return tmplItem.text;
|
|
79
|
+
const ptheme = { ...theme };
|
|
80
|
+
for (const tok of ['DIR_LAWS', 'DIR_AGENTS', 'DIR_SKILLS', 'DIR_MEMORY', 'DIR_NOTEBOOK']) {
|
|
81
|
+
ptheme[tok] = prefix + (ptheme[tok] ?? tok.split('_').slice(1).join('_').toLowerCase());
|
|
82
|
+
}
|
|
83
|
+
// Same catalogue decision as the renderAll above — a re-render that forgot it would
|
|
84
|
+
// quietly put the stripped tables back.
|
|
85
|
+
return renderFile(cfg, tmplItem.src, ptheme, footprint, '', new Set(), nativeCatalog);
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
const owned = [];
|
|
89
|
+
const agentText = items.find((i) => i.rel === 'AGENT.md' && i.text !== null)?.text ?? null;
|
|
90
|
+
const isBob = host === 'bob';
|
|
91
|
+
const isCopilot = host === 'copilot';
|
|
92
|
+
|
|
93
|
+
// Bob's always-injected channel is the rules folder. At GLOBAL scope rules/geneseed.md
|
|
94
|
+
// IS the preamble (a global ~/.bob/AGENTS.md is not auto-loaded), and its pointers need
|
|
95
|
+
// a `../` prefix because it sits one level below the stores; at PROJECT scope it is the
|
|
96
|
+
// slim shadow stub whose only job is to have the same filename.
|
|
97
|
+
if (isBob && agentText !== null) {
|
|
98
|
+
const rulesMd = path.join(cfgDir, 'rules', 'geneseed.md');
|
|
99
|
+
mkdirSync(path.dirname(rulesMd), { recursive: true });
|
|
100
|
+
writeText(rulesMd, scope === 'project' ? BOB_RULES_STUB
|
|
101
|
+
: stripCapabilityLinks(cfg, prefixedAgentText('../') || agentText));
|
|
102
|
+
owned.push('rules/geneseed.md');
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
ensureAgentOverridesStub(cfg, cfgDir);
|
|
106
|
+
// Bob's agents/skills use the Claude dialect verbatim; Copilot has its own frontmatter.
|
|
107
|
+
// `manifestExisted` is deliberately not passed: the Python does not pass it either, so
|
|
108
|
+
// the pre-manifest header line is unreachable from this emit on both sides.
|
|
109
|
+
const { nAgents, nSkills, written } = writeNativeLayer(
|
|
110
|
+
items, path.join(cfgDir, 'agents'), path.join(cfgDir, 'skills'),
|
|
111
|
+
loadAgentOverrides(cfgDir),
|
|
112
|
+
{ host: isCopilot ? 'copilot' : 'claude', oldOwned, cfg: cfgDir, src: cfg.src });
|
|
113
|
+
for (const p of written) owned.push(relPosix(cfgDir, p));
|
|
114
|
+
|
|
115
|
+
const memStatus = globalMemory(cfgDir, items, out, cfg.src);
|
|
116
|
+
ensureMemoryIndex(path.join(cfgDir, 'memory'));
|
|
117
|
+
const nbStatus = globalNotebook(cfgDir, items, out, cfg.src);
|
|
118
|
+
ensureNotebookIndex(path.join(cfgDir, 'notebook'));
|
|
119
|
+
ensureWikiStub(cfgDir);
|
|
120
|
+
ensureRulesStub(cfgDir);
|
|
121
|
+
ensureProfileStub(cfgDir);
|
|
122
|
+
ensureExcludesStub(cfgDir);
|
|
123
|
+
|
|
124
|
+
// Project hygiene, claim-on-create: an existing (possibly user-authored) .gitignore is
|
|
125
|
+
// never rewritten, but one we created stays owned across re-emits.
|
|
126
|
+
if (scope === 'project') {
|
|
127
|
+
const gi = path.join(cfgDir, '.gitignore');
|
|
128
|
+
const giLines = (host === 'claude' ? ['settings.local.json'] : [])
|
|
129
|
+
.concat(['wiki.jsonc', 'agent-overrides.json']);
|
|
130
|
+
if (!existsSync(gi)) {
|
|
131
|
+
writeText(gi, `${giLines.join('\n')}\n`);
|
|
132
|
+
owned.push('.gitignore');
|
|
133
|
+
} else if (oldOwned.includes('.gitignore')) {
|
|
134
|
+
owned.push('.gitignore');
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
writeVersion(cfg, cfgDir);
|
|
139
|
+
owned.push(VERSION_MARKER);
|
|
140
|
+
|
|
141
|
+
shipLeanLaws(items, theme, cfgDir, owned, footprint);
|
|
142
|
+
|
|
143
|
+
// The last render of the emit, and the only one whose product is not written here.
|
|
144
|
+
// Bob at GLOBAL scope gets no managed block at all, so nothing is rendered for it —
|
|
145
|
+
// the condition is the merge's, kept here so RENDER produces exactly what WIRE
|
|
146
|
+
// consumes and no more.
|
|
147
|
+
let claudeMdText = null;
|
|
148
|
+
if (agentText !== null && !(isBob && scope === 'global')) {
|
|
149
|
+
claudeMdText = stripCapabilityLinks(cfg, prefixedAgentText(lawsPrefix) || agentText);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// WIRE — see `claudeWire` below. One child per emit, so the two halves are two
|
|
153
|
+
// functions rather than two spawns; the seam between them is a call, not a process.
|
|
154
|
+
const managed = claudeWire(job, claudeMdText, agentText !== null, cfg.doctrines,
|
|
155
|
+
cfg.excludeRules);
|
|
156
|
+
|
|
157
|
+
return {
|
|
158
|
+
owned,
|
|
159
|
+
stats: { nAgents, nSkills },
|
|
160
|
+
memStatus,
|
|
161
|
+
nbStatus,
|
|
162
|
+
hasAgentText: agentText !== null,
|
|
163
|
+
claudeMdText,
|
|
164
|
+
managed,
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* `_build_global._claude_wire_py` — the files the Claude-shaped emits do NOT own: the
|
|
170
|
+
* CLAUDE.md/AGENTS.md managed block and settings(.local).json.
|
|
171
|
+
*
|
|
172
|
+
* Everything the render half wrote, it wrote wholesale. Everything here reconciles
|
|
173
|
+
* Geneseed's claim with content it did not write. The return value is `managed`, the claim
|
|
174
|
+
* set `bin/build-driver.mjs` records in the manifest so every teardown path unwires what was wired
|
|
175
|
+
* — which is why WIRE must precede MANIFEST, and why `managed` travels back rather than
|
|
176
|
+
* being recomputed.
|
|
177
|
+
*
|
|
178
|
+
* `preambleExclude` arrives decided and is NOT derived here. `bin/build-driver.mjs` computes
|
|
179
|
+
* it from the Claude config dir it resolved, and the suite redirects that at a sandbox.
|
|
180
|
+
* Resolving `~/.claude` on this side would write the developer's REAL CLAUDE.md path into a
|
|
181
|
+
* test's exclude list and suppress it. Third phase running that a name had to travel rather than be re-derived
|
|
182
|
+
* (`STRUCTURE` at P2d, `capabilityLinkRe` at P2e); this one edits a file the user co-owns.
|
|
183
|
+
*
|
|
184
|
+
* `GENESEED_STACK_GLOBAL` is read from the environment on BOTH sides — the child inherits
|
|
185
|
+
* `process.env`, so the opt-out reaches here without travelling in the job. `isTruthy` is
|
|
186
|
+
* not needed for it: Python tests `os.environ.get(...)` for truthiness and an env var is
|
|
187
|
+
* always a string, so `''` is the only falsy value either language sees.
|
|
188
|
+
*/
|
|
189
|
+
function claudeWire(job, claudeMdText, hasAgentText, doctrines = null, excludeRules = []) {
|
|
190
|
+
phaseLog('WIRE');
|
|
191
|
+
const { cfgDir, claudeMd, scope, host, oldManaged, preambleExclude, hookOpts } = job;
|
|
192
|
+
const old = oldManaged && typeof oldManaged === 'object' && !Array.isArray(oldManaged)
|
|
193
|
+
? oldManaged : {};
|
|
194
|
+
const isBob = host === 'bob';
|
|
195
|
+
const isCopilot = host === 'copilot';
|
|
196
|
+
const managed = {};
|
|
197
|
+
|
|
198
|
+
// CLAUDE.md — Claude auto-loads it by location; merge as a delimited block so any user
|
|
199
|
+
// prose around it survives. `claudeMdText` is the render half's product; it is null
|
|
200
|
+
// exactly when no block is to be written.
|
|
201
|
+
// Exception — Bob GLOBAL: Bob never auto-loads a global ~/.bob/AGENTS.md, so a global
|
|
202
|
+
// copy is pure disk weight; none is written, and a re-emit self-heals the one an older
|
|
203
|
+
// install carries.
|
|
204
|
+
if (claudeMdText !== null) {
|
|
205
|
+
managedBlockWrite(claudeMd, claudeMdText);
|
|
206
|
+
// No sticky "whole" flag: teardown always excises the block and deletes the file only
|
|
207
|
+
// when nothing else remains — a whole-file delete would eat prose the user added AFTER
|
|
208
|
+
// Geneseed created the file.
|
|
209
|
+
//
|
|
210
|
+
// `os.path.relpath(claude_md, cfg).replace(os.sep, '/')`. Python answers '.' where
|
|
211
|
+
// `path.relative` answers '' for an identical pair, the same guard `lawsPrefix` needs
|
|
212
|
+
// above — unreachable here (CLAUDE.md is a file, cfgDir a directory, so the pair is
|
|
213
|
+
// never identical) and spelled anyway, because the two call sites are 400 lines apart
|
|
214
|
+
// and only one of them is obviously safe.
|
|
215
|
+
const rel = path.relative(cfgDir, claudeMd).split(path.sep).join('/') || '.';
|
|
216
|
+
managed.claude_md = { rel };
|
|
217
|
+
} else if (isBob && scope === 'global' && isTruthy(get(old, 'claude_md'))) {
|
|
218
|
+
const oldCm = isDict(get(old, 'claude_md')) ? get(old, 'claude_md') : {};
|
|
219
|
+
// `.resolve()` on the Python side; `path.resolve` is its counterpart and both are
|
|
220
|
+
// applied to a path already built from an absolute cfgDir.
|
|
221
|
+
const victim = path.resolve(cfgDir, get(oldCm, 'rel') || path.basename(claudeMd));
|
|
222
|
+
managedBlockRemove(victim);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// Hooks embed machine-absolute paths. At PROJECT scope for Claude they go into
|
|
226
|
+
// settings.local.json — the personal, untracked file — never the team-shared
|
|
227
|
+
// settings.json, which would hand every teammate failing hooks pointing at this machine's
|
|
228
|
+
// node and this machine's checkout. (Bob documents no local variant, so it keeps
|
|
229
|
+
// settings.json.) Copilot has NO
|
|
230
|
+
// settings.json and no hook mechanism at all, so the whole stage is skipped: nothing
|
|
231
|
+
// written, no settings_* keys recorded for the lifecycle to unwire.
|
|
232
|
+
if (!isCopilot) {
|
|
233
|
+
const settingsName = scope === 'project' && !isBob ? 'settings.local.json' : 'settings.json';
|
|
234
|
+
const settingsPath = path.join(cfgDir, settingsName);
|
|
235
|
+
managed.settings_file = settingsName;
|
|
236
|
+
// Migration: an older install wired hooks/excludes into a different file — unwire the
|
|
237
|
+
// recorded claims there, or they linger (and run) forever.
|
|
238
|
+
const oldSf = get(old, 'settings_file') || 'settings.json';
|
|
239
|
+
if (oldSf !== settingsName) {
|
|
240
|
+
unwireClaudeSettings(path.join(cfgDir, oldSf), get(old, 'settings_hooks') || []);
|
|
241
|
+
unwireClaudeExcludes(path.join(cfgDir, oldSf), get(old, 'settings_excludes') || []);
|
|
242
|
+
}
|
|
243
|
+
// The merge prunes recorded groups that are no longer canonical and returns the
|
|
244
|
+
// COMPLETE current claim set — store it as-is; unioning with prior would resurrect the
|
|
245
|
+
// stale claims.
|
|
246
|
+
// `doctrines` decides whether the git-gate group is part of the canonical claim set. It
|
|
247
|
+
// travels from `cfg` rather than being re-read off the deployment: this is the emit that
|
|
248
|
+
// DECIDES the install's packs, so the marker on disk is still the previous build's.
|
|
249
|
+
const [, managedHooks] = mergeClaudeSettings(
|
|
250
|
+
settingsPath, scope, oldSf === settingsName ? get(old, 'settings_hooks') : null, hookOpts,
|
|
251
|
+
doctrines, excludeRules,
|
|
252
|
+
);
|
|
253
|
+
managed.settings_hooks = managedHooks;
|
|
254
|
+
|
|
255
|
+
// Project-bypasses-global (Claude only): a PROJECT install suppresses the GLOBAL
|
|
256
|
+
// ~/.claude/CLAUDE.md while cwd is this repo, via Claude's native claudeMdExcludes.
|
|
257
|
+
// Written only when this run actually emitted the project's own preamble (never
|
|
258
|
+
// suppress with no replacement); GENESEED_STACK_GLOBAL=1 opts out, and a re-emit with
|
|
259
|
+
// it set strips a prior exclude. Bob never gets one: its bypass is the same-named
|
|
260
|
+
// workspace rules file.
|
|
261
|
+
const priorRaw = get(old, 'settings_excludes');
|
|
262
|
+
const priorExcl = Array.isArray(priorRaw) ? priorRaw : [];
|
|
263
|
+
if (scope === 'project' && hasAgentText && preambleExclude) {
|
|
264
|
+
const wantExcl = [preambleExclude];
|
|
265
|
+
if (process.env.GENESEED_STACK_GLOBAL) {
|
|
266
|
+
unwireClaudeExcludes(settingsPath, wantExcl);
|
|
267
|
+
managed.settings_excludes = [];
|
|
268
|
+
} else {
|
|
269
|
+
const addedExcl = wireClaudeExcludes(settingsPath, wantExcl);
|
|
270
|
+
// Claim only what Geneseed itself wired (prior + newly added) — folding `wantExcl`
|
|
271
|
+
// in unconditionally would claim a user's own pre-existing exclude, and uninstall
|
|
272
|
+
// would then strip it. `sorted(set(a) | set(b))` — Python sorts strings by code
|
|
273
|
+
// point and so does the default `Array.sort`, which is why no comparator is passed.
|
|
274
|
+
managed.settings_excludes = [...new Set([...priorExcl, ...addedExcl])].sort();
|
|
275
|
+
}
|
|
276
|
+
} else if (priorExcl.length && isBob) {
|
|
277
|
+
// Self-heal older Bob installs: earlier versions wrote the global AGENTS.md into
|
|
278
|
+
// claudeMdExcludes here. The key is Claude-only and its Bob semantics are unknown, so
|
|
279
|
+
// a re-emit removes it instead of carrying it forward.
|
|
280
|
+
unwireClaudeExcludes(settingsPath, priorExcl);
|
|
281
|
+
} else if (priorExcl.length) {
|
|
282
|
+
managed.settings_excludes = priorExcl;
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
return managed;
|
|
286
|
+
}
|
|
287
|
+
|