geneseed 3.1.3 → 3.3.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 +180 -0
- package/README.md +24 -14
- package/SETUP.md +38 -7
- package/SHIPPED.md +6 -5
- package/adapters/bob/README.md +40 -10
- package/adapters/claude-code/README.md +5 -0
- package/adapters/claude-code/settings.json +8 -1
- package/adapters/copilot/README.md +50 -24
- package/adapters/opencode/README.md +5 -1
- package/adapters/opencode/plugins/geneseed-guard.js +40 -6
- package/bin/build-driver.mjs +143 -89
- package/bin/geneseed-cli.mjs +63 -106
- package/bin/geneseed-hook.mjs +26 -16
- package/docs/candidate-hosts.md +74 -0
- package/docs/extending.md +6 -5
- package/docs/token-footprint.md +32 -21
- package/docs/web-ui.md +5 -13
- package/harness.config.json +1 -1
- package/js/README.md +5 -15
- package/js/build/emit-claude.mjs +26 -12
- package/js/build/emit-common.mjs +63 -114
- package/js/build/emit-opencode.mjs +74 -66
- package/js/build/generate.mjs +4 -6
- package/js/build/render.mjs +50 -32
- package/js/build/source.mjs +29 -8
- package/js/build/stubs.mjs +25 -33
- package/js/build/themes.mjs +1 -1
- package/js/build/version.mjs +1 -3
- package/js/cli-table.json +84 -0
- package/js/hosts/hooks.mjs +243 -81
- package/js/hosts/hosts.mjs +57 -5
- package/js/hosts/installs.mjs +35 -28
- package/js/hosts/link.mjs +2 -2
- package/js/hosts/mcp.mjs +3 -1
- package/js/hosts/native.mjs +54 -19
- package/js/hosts/opencode.mjs +39 -28
- package/js/hosts/settings.mjs +206 -115
- package/js/inspect/checks-authoring.mjs +5 -4
- package/js/inspect/checks-build.mjs +1 -1
- package/js/inspect/diff.mjs +1 -3
- package/js/inspect/doctor.mjs +59 -44
- package/js/inspect/excludes.mjs +5 -11
- package/js/inspect/inventory.mjs +38 -56
- package/js/inspect/registry.mjs +4 -7
- package/js/inspect/scan.mjs +8 -15
- package/js/inspect/status.mjs +51 -3
- package/js/lib/fs.mjs +34 -0
- package/js/lib/json.mjs +28 -1
- package/js/lib/paths.mjs +0 -10
- package/js/lib/udiff.mjs +11 -16
- package/js/maintain/memory.mjs +2 -4
- package/js/maintain/migrate.mjs +3 -3
- package/js/maintain/uninstall.mjs +127 -88
- package/js/maintain/update.mjs +4 -4
- package/js/ui/anim.mjs +1 -1
- package/js/ui/menu.mjs +1 -1
- package/js/ui/tui.mjs +3 -20
- package/js/web/actions.mjs +230 -217
- package/js/web/activity.mjs +71 -89
- package/js/web/api.mjs +130 -226
- package/js/web/daemon.mjs +92 -110
- package/js/web/docs.mjs +59 -126
- package/js/web/handler.mjs +94 -123
- package/js/web/jobs.mjs +116 -178
- package/js/web/routes.mjs +47 -192
- package/js/web/server.mjs +47 -82
- package/package.json +1 -1
- package/registry.json +14 -0
- package/src/AGENT.md.tmpl +55 -16
- package/src/agents/explorer.md +2 -1
- package/src/doctrines/README.md +3 -3
- package/src/doctrines/process.md +4 -2
- package/src/doctrines/rigor.md +13 -0
- package/src/laws/universal.md +133 -43
- package/src/ontology/universal.md +38 -13
- package/src/skills/_template.md +5 -1
- package/src/skills/bruno-collection-generator.md +63 -0
- package/src/skills/bruno-test-writer.md +62 -0
- package/src/skills/clarify.md +1 -1
- package/src/skills/crash-course.md +1 -0
- package/src/skills/decode.md +1 -0
- package/src/skills/drill.md +1 -0
- package/src/skills/feynman.md +1 -0
- package/src/skills/forge-mcp.md +2 -1
- package/src/skills/fresh-eyes.md +1 -1
- package/src/skills/herdr.md +1 -0
- package/src/skills/ingest.md +2 -1
- package/src/skills/learning-path.md +1 -0
- package/src/skills/opencode-theme.md +1 -0
- package/src/skills/parallel-agents.md +1 -1
- package/src/skills/ponytail.md +2 -2
- package/src/skills/research.md +4 -2
- package/themes/_TEMPLATE.json +5 -0
- package/themes/biker.json +5 -0
- package/themes/commentator.json +5 -0
- package/themes/cyberpunk.json +5 -0
- package/themes/gamer.json +5 -0
- package/themes/imperial.json +5 -0
- package/themes/joker.json +5 -0
- package/themes/marvin.json +5 -0
- package/themes/mean.json +5 -0
- package/themes/military.json +5 -0
- package/themes/neutral.json +5 -0
- package/themes/pirate.json +5 -0
- package/themes/sports.json +5 -0
- package/themes/verstappen.json +5 -0
- package/themes/wizard.json +5 -0
- package/web/dist/assets/Activity-BfVcLrda.js +1 -0
- package/web/dist/assets/ActivityDetail-CQXccGPl.js +1 -0
- package/web/dist/assets/CatalogRow-DALEQImZ.js +1 -0
- package/web/dist/assets/Diff-BIjxXx46.js +5 -0
- package/web/dist/assets/Doctor-CJLbgsrw.js +1 -0
- package/web/dist/assets/FilterInput-CkUCk_lY.js +1 -0
- package/web/dist/assets/GreenhouseView-DfC5IILJ.js +2 -0
- package/web/dist/assets/Harness-Dm5CCU-v.js +1 -0
- package/web/dist/assets/JournalView-Cpu2eI4s.js +1 -0
- package/web/dist/assets/Laws-DDDVFuQz.js +3 -0
- package/web/dist/assets/Library-Dao3T9na.js +1 -0
- package/web/dist/assets/{marked.esm-JKvyS0Pp.js → Markdown-C__nuWX5.js} +15 -15
- package/web/dist/assets/OperatorHudView-DEpCtAjP.js +2 -0
- package/web/dist/assets/Profile-BlXtUlh2.js +1 -0
- package/web/dist/assets/{Rules-CfsV1EVy.js → Rules-DIre6gxR.js} +1 -1
- package/web/dist/assets/Skills-rY13ZPhM.js +1 -0
- package/web/dist/assets/{StatusBadge-C9bZOEZk.js → StatusBadge-wW_RS_my.js} +1 -1
- package/web/dist/assets/{activity-Cc0n2jwx.js → activity-DpYPKNrA.js} +1 -1
- package/web/dist/assets/index-BEZvPvpF.js +1 -0
- package/web/dist/assets/index-DNQE5ydy.js +10 -0
- package/web/dist/assets/index-DzdQjvM1.css +1 -0
- package/web/dist/assets/index-PmQGRS1X.js +2 -0
- package/web/dist/assets/jobBuckets-DfTUJUxR.js +1 -0
- package/web/dist/assets/lawCats-DUS9UTH4.js +1 -0
- package/web/dist/index.html +2 -2
- package/web/src/pages/Laws.jsx +119 -130
- package/js/web/graph.mjs +0 -0
- package/web/dist/assets/Activity-B49fE_ug.js +0 -1
- package/web/dist/assets/ActivityDetail-abt7hzD2.js +0 -1
- package/web/dist/assets/Diff-D3_bVkih.js +0 -5
- package/web/dist/assets/Doctor-hXp7srsx.js +0 -1
- package/web/dist/assets/Graph-DvYMtlvl.js +0 -1
- package/web/dist/assets/Harness-BIWei7N5.js +0 -1
- package/web/dist/assets/Laws-CxmnRdwt.js +0 -3
- package/web/dist/assets/Library-MjbOkHIW.js +0 -1
- package/web/dist/assets/Markdown-Dx_w0faR.js +0 -1
- package/web/dist/assets/Profile-BRFvv2gr.js +0 -1
- package/web/dist/assets/Skills-DArA6IHS.js +0 -1
- package/web/dist/assets/index-Bx3oxZ1o.css +0 -1
- package/web/dist/assets/index-DC3-GlCY.js +0 -1
- package/web/dist/assets/index-DP035wPa.js +0 -12
- package/web/dist/assets/index-dHi-7M-F.js +0 -2
- package/web/dist/assets/roman-DkYN0LX9.js +0 -1
|
@@ -383,7 +383,11 @@ behaviour** — nothing changes the machine's current agent/model unless you opt
|
|
|
383
383
|
files (Law I), catastrophic shell like `rm -rf /` (Law IV), and any mutation under
|
|
384
384
|
a declared wiki's `protected` folders (AGENT.md §8, from `wiki.jsonc`); **warns** on
|
|
385
385
|
`.env` writes and force-push. `GENESEED_GUARD=off` disables it, `=warn` downgrades
|
|
386
|
-
blocks to warnings.
|
|
386
|
+
blocks to warnings. Every **block** appends one line to the install's
|
|
387
|
+
`notebook/gates.jsonl` (rule `law-1` / `law-4` / `process-1` / `wiki`, a timestamp,
|
|
388
|
+
never the path or command) — the same ledger the Claude-family hooks write, so
|
|
389
|
+
`geneseed status` counts what the boundary caught on every host. Warnings are not
|
|
390
|
+
recorded.
|
|
387
391
|
- **Invisible context injection** (the default). The context plugin delivers via
|
|
388
392
|
`experimental.chat.messages.transform`, so the PROJECT CONTEXT block never appears in
|
|
389
393
|
the conversation and survives compaction inherently; on a build without the hook it
|
|
@@ -60,6 +60,36 @@ const WARN_ONLY = MODE === "warn"
|
|
|
60
60
|
|
|
61
61
|
function log(msg) { console.error(`[geneseed-guard] ${msg}`) }
|
|
62
62
|
|
|
63
|
+
// ---- the gate ledger ------------------------------------------------------------
|
|
64
|
+
// The twin of `ledger()` in js/hosts/hooks.mjs: one JSON line per BLOCK into
|
|
65
|
+
// `<cfg>/notebook/gates.jsonl`, the rule and never the content, so `geneseed status` can
|
|
66
|
+
// count what the boundary actually caught on an OpenCode install too. Until this the
|
|
67
|
+
// recommended install was the one with no gate evidence at all. A warning is not recorded
|
|
68
|
+
// (nothing was refused) and neither is `GENESEED_GUARD=warn`. `notebook/` is never created
|
|
69
|
+
// here — an install without one (or a test importing this file) records nothing. Capped
|
|
70
|
+
// at the newest MAX_LEDGER_LINES like the hook's; never throws, never decides.
|
|
71
|
+
const MAX_LEDGER_LINES = 1000
|
|
72
|
+
async function ledger(rule) {
|
|
73
|
+
try {
|
|
74
|
+
const bases = []
|
|
75
|
+
if (process.env.GENESEED_HARNESS) bases.push(process.env.GENESEED_HARNESS)
|
|
76
|
+
bases.push(path.dirname(PLUGIN_DIR))
|
|
77
|
+
for (const base of bases) {
|
|
78
|
+
const dir = path.join(base, "notebook")
|
|
79
|
+
if (!(await isDir(dir))) continue
|
|
80
|
+
const file = path.join(dir, "gates.jsonl")
|
|
81
|
+
await fs.appendFile(file, `${JSON.stringify({
|
|
82
|
+
ts: new Date().toISOString(), verb: "guard", rule, cwd: process.cwd(),
|
|
83
|
+
})}\n`)
|
|
84
|
+
const lines = (await fs.readFile(file, "utf8")).split("\n").filter(Boolean)
|
|
85
|
+
if (lines.length > MAX_LEDGER_LINES) {
|
|
86
|
+
await fs.writeFile(file, `${lines.slice(-MAX_LEDGER_LINES).join("\n")}\n`)
|
|
87
|
+
}
|
|
88
|
+
return
|
|
89
|
+
}
|
|
90
|
+
} catch { /* the ledger never decides */ }
|
|
91
|
+
}
|
|
92
|
+
|
|
63
93
|
// Files the agent must not write (secrets / private keys) → BLOCK.
|
|
64
94
|
const SECRET_RE = [
|
|
65
95
|
/(^|\/)id_(rsa|ed25519|ecdsa|dsa)(\.pub)?$/i,
|
|
@@ -119,6 +149,7 @@ const hasAny = (name, parts) => parts.some((s) => name.includes(s))
|
|
|
119
149
|
// per wiki. Mutating anything under one is denied. Same resolution chain as the
|
|
120
150
|
// context plugin: $GENESEED_WIKI -> $GENESEED_HARNESS/wiki.jsonc -> beside the install.
|
|
121
151
|
async function isFile(p) { try { return (await fs.stat(p)).isFile() } catch { return false } }
|
|
152
|
+
async function isDir(p) { try { return (await fs.stat(p)).isDirectory() } catch { return false } }
|
|
122
153
|
|
|
123
154
|
// wiki.jsonc is JSONC (the seeded stub carries a commented example): strip // and
|
|
124
155
|
// /* */ comments plus trailing commas before parsing — string-aware, so quoted
|
|
@@ -243,22 +274,25 @@ export const GeneseedGuard = async () => {
|
|
|
243
274
|
if (await sovereignBypass(process.cwd())) return
|
|
244
275
|
const tool = (input?.tool || input?.name || "").toLowerCase()
|
|
245
276
|
const args = output?.args || input?.args || {}
|
|
246
|
-
|
|
277
|
+
// `rule` is the ledger key, spelled as js/hosts/hooks.mjs spells it (`law-1`,
|
|
278
|
+
// `law-4`, `process-1`) plus `wiki`, so status counts one vocabulary across hosts.
|
|
279
|
+
const deny = async (why, rule) => {
|
|
247
280
|
if (WARN_ONLY) { log(`WARN (would block): ${why}`); return false }
|
|
248
281
|
log(`BLOCKED: ${why}`)
|
|
282
|
+
await ledger(rule)
|
|
249
283
|
throw new Error(`[geneseed-guard] blocked: ${why} — set GENESEED_GUARD=off to allow`)
|
|
250
284
|
}
|
|
251
285
|
try {
|
|
252
286
|
if (hasAny(tool, WRITE_TOOLS)) {
|
|
253
287
|
const p = pickPath(args)
|
|
254
|
-
if (p && SECRET_RE.some((re) => re.test(p))) { deny(`write to secret/key file ${p} (Law I)
|
|
288
|
+
if (p && SECRET_RE.some((re) => re.test(p))) { await deny(`write to secret/key file ${p} (Law I)`, "law-1"); return }
|
|
255
289
|
if (p && SECRET_WARN_RE.some((re) => re.test(p))) log(`WARN: writing ${p} — keep secrets out of tracked files (Law I)`)
|
|
256
290
|
const store = p && ruleStoreTarget(p)
|
|
257
291
|
if (store && !RULE_STORE_BUMPED.has(p)) {
|
|
258
292
|
RULE_STORE_BUMPED.add(p)
|
|
259
|
-
deny(`writing to ${store} — a standing rule, or a fact to remember? That ` +
|
|
293
|
+
await deny(`writing to ${store} — a standing rule, or a fact to remember? That ` +
|
|
260
294
|
`choice is the user's (Doctrine process 1). Settle it through the rule skill, ` +
|
|
261
|
-
`then re-issue this write
|
|
295
|
+
`then re-issue this write`, "process-1")
|
|
262
296
|
return
|
|
263
297
|
}
|
|
264
298
|
}
|
|
@@ -268,14 +302,14 @@ export const GeneseedGuard = async () => {
|
|
|
268
302
|
const abs = (path.isAbsolute(p) ? p : path.resolve(p)).replace(/\\/g, "/").toLowerCase()
|
|
269
303
|
const hit = (await protectedPrefixes()).find(
|
|
270
304
|
(x) => abs.startsWith(x.prefix) || abs === x.prefix.slice(0, -1))
|
|
271
|
-
if (hit) { deny(`mutation in protected wiki folder — ${hit.label} (AGENT.md §8)
|
|
305
|
+
if (hit) { await deny(`mutation in protected wiki folder — ${hit.label} (AGENT.md §8)`, "wiki"); return }
|
|
272
306
|
}
|
|
273
307
|
}
|
|
274
308
|
// NOT else-if: a compound tool name (e.g. exec_and_save) can match both
|
|
275
309
|
// classes, and the shell check must still run after the write check.
|
|
276
310
|
if (hasAny(tool, SHELL_TOOLS)) {
|
|
277
311
|
const c = pickCommand(args)
|
|
278
|
-
if (c && SHELL_BLOCK_RE.some((re) => re.test(c))) { deny(`catastrophic command (Law IV): ${c.slice(0, 80)}
|
|
312
|
+
if (c && SHELL_BLOCK_RE.some((re) => re.test(c))) { await deny(`catastrophic command (Law IV): ${c.slice(0, 80)}`, "law-4"); return }
|
|
279
313
|
if (c && SHELL_WARN_RE.some((re) => re.test(c))) log(`WARN: irreversible op — confirm intent (Law IV): ${c.slice(0, 80)}`)
|
|
280
314
|
}
|
|
281
315
|
} catch (err) {
|
package/bin/build-driver.mjs
CHANGED
|
@@ -16,14 +16,16 @@
|
|
|
16
16
|
* values this file must originate. `ROOT` and the seven paths under it come from this
|
|
17
17
|
* script's own location, not from an inherited `cfg`.
|
|
18
18
|
*
|
|
19
|
-
*
|
|
20
|
-
* `js_cfg()` (_build_core.py:199) always
|
|
21
|
-
* the Python originals
|
|
22
|
-
* asked one level out. This driver has no Python module to mutate, so it sends neither
|
|
23
|
-
* `js/build/render.mjs:215`'s `cfg.structure ?? STRUCTURE`
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
19
|
+
* A KEY IS DELIBERATELY ABSENT FROM `cfg`, and its absence is load-bearing.
|
|
20
|
+
* `js_cfg()` (_build_core.py:199) always sent `structure` and `capabilityLinkRe`, because
|
|
21
|
+
* the Python originals were module-level names that TESTS MUTATE — `_OWNED` membership
|
|
22
|
+
* asked one level out. This driver has no Python module to mutate, so it sends neither;
|
|
23
|
+
* `js/build/render.mjs:215`'s `cfg.structure ?? STRUCTURE` takes its right-hand branch for
|
|
24
|
+
* that reason — a fallback that was dead code until P4, always overridden by the driver
|
|
25
|
+
* that always supplies it. `capabilityLinkRe`'s equivalent fallback in
|
|
26
|
+
* `js/build/emit-common.mjs`'s `stripCapabilityLinks` was P4's other one made live, and the
|
|
27
|
+
* over-engineering cleanup then deleted the override branch outright: nothing had ever
|
|
28
|
+
* supplied one, so there was no longer a `cfg` left to be absent from.
|
|
27
29
|
*
|
|
28
30
|
* THE FOOTPRINT DEFAULT IS THE FLAG'S, NOT THE FUNCTION'S. `--footprint` defaults to
|
|
29
31
|
* `lean` (build.py:354); every `emit_*`/`build` SIGNATURE defaults to `full`. A driver
|
|
@@ -32,15 +34,16 @@
|
|
|
32
34
|
*/
|
|
33
35
|
import {
|
|
34
36
|
existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, renameSync, rmdirSync,
|
|
35
|
-
|
|
37
|
+
unlinkSync,
|
|
36
38
|
} from 'node:fs';
|
|
37
39
|
import path from 'node:path';
|
|
38
40
|
import { fileURLToPath } from 'node:url';
|
|
41
|
+
import { parseArgs as nodeParseArgs } from 'node:util';
|
|
39
42
|
import { build, phaseLog } from '../js/build/bundle.mjs';
|
|
40
43
|
import { emitClaudeRender } from '../js/build/emit-claude.mjs';
|
|
41
44
|
import { emitOpencodeRender, emitOpencodeGlobalRender } from '../js/build/emit-opencode.mjs';
|
|
42
|
-
import { settingsIntegrityCheck } from '../js/hosts/settings.mjs';
|
|
43
|
-
import { writeText, withPlatformNewlines } from '../js/lib/fs.mjs';
|
|
45
|
+
import { copilotIntegrityCheck, settingsIntegrityCheck } from '../js/hosts/settings.mjs';
|
|
46
|
+
import { writeText, withPlatformNewlines, isFile } from '../js/lib/fs.mjs';
|
|
44
47
|
import { parseJson, jsonDumpsIndent } from '../js/lib/json.mjs';
|
|
45
48
|
// P5c moved these out of this file: `bin/geneseed-cli.mjs` needs the same four resolvers to
|
|
46
49
|
// find a global install, and a resolver that decides WHERE a driver writes is the last thing
|
|
@@ -188,6 +191,26 @@ const FLAGS = {
|
|
|
188
191
|
'-v': 'verbose', '--verbose': 'verbose',
|
|
189
192
|
};
|
|
190
193
|
|
|
194
|
+
/**
|
|
195
|
+
* `util.parseArgs`'s own option table, DERIVED from `VALUED`/`FLAGS` rather than typed out a
|
|
196
|
+
* second time — a hand-written twin is exactly the anti-drift failure this module's docblock
|
|
197
|
+
* already names for `usage()`. Every `VALUED` entry is `type: 'string'` (so a bare `--flag
|
|
198
|
+
* value` form is captured as one token instead of leaking `value` as an orphan positional);
|
|
199
|
+
* every long `FLAGS` entry is `type: 'boolean'`. `-v` gets `short: 'v'` for the same reason
|
|
200
|
+
* `-h` deliberately does NOT get one below `PARSE_OPTIONS` — see `parseArgs`'s docblock.
|
|
201
|
+
*/
|
|
202
|
+
const PARSE_OPTIONS = Object.fromEntries([
|
|
203
|
+
...Object.keys(VALUED).map((f) => [f.slice(2), { type: 'string' }]),
|
|
204
|
+
...Object.keys(FLAGS).filter((f) => f.startsWith('--')).map((f) => [f.slice(2), { type: 'boolean' }]),
|
|
205
|
+
]);
|
|
206
|
+
PARSE_OPTIONS.verbose = { type: 'boolean', short: 'v' };
|
|
207
|
+
|
|
208
|
+
/** `token.name` (dashes stripped, short aliases folded in) -> this file's `args` key. */
|
|
209
|
+
const VALUED_BY_NAME = Object.fromEntries(Object.keys(VALUED).map((f) => [f.slice(2), VALUED[f]]));
|
|
210
|
+
const FLAG_BY_NAME = Object.fromEntries(
|
|
211
|
+
Object.keys(FLAGS).filter((f) => f.startsWith('--')).map((f) => [f.slice(2), FLAGS[f]]),
|
|
212
|
+
);
|
|
213
|
+
|
|
191
214
|
/**
|
|
192
215
|
* The four flags `choice` validates, as ONE table for the same anti-drift reason.
|
|
193
216
|
*
|
|
@@ -307,7 +330,31 @@ function usage() {
|
|
|
307
330
|
].join('\n');
|
|
308
331
|
}
|
|
309
332
|
|
|
310
|
-
/**
|
|
333
|
+
/**
|
|
334
|
+
* argparse's `--flag value` and `--flag=value`, plus the `--target` alias for `--out` — the
|
|
335
|
+
* SPLITTING now done by `util.parseArgs({tokens: true})`, with every choice/mutex/wording
|
|
336
|
+
* decision this function made before P6 (Task 4) still made HERE, unmoved.
|
|
337
|
+
*
|
|
338
|
+
* `strict: false` is load-bearing twice: it lets an unrecognized flag surface as a token this
|
|
339
|
+
* walk can refuse in its OWN words rather than node's, and it is what makes a value-taking
|
|
340
|
+
* flag (`type: 'string'` in `PARSE_OPTIONS`) swallow the very next raw argument even when that
|
|
341
|
+
* argument itself looks like a flag — `--theme --posture peer` sets `theme` to the literal
|
|
342
|
+
* string `"--posture"` and leaves `peer` a stray token, exactly as the hand-rolled version
|
|
343
|
+
* being replaced did with its own `argv[i += 1]`. That is not a bug this port inherited by
|
|
344
|
+
* accident: it is the one shape `parseArgs`'s tokenizer reproduces for free, and reproducing
|
|
345
|
+
* anything smarter (a lookahead that refuses to swallow a flag-shaped value) would be new
|
|
346
|
+
* behaviour, not a preserved one.
|
|
347
|
+
*
|
|
348
|
+
* `-h` DELIBERATELY IS NOT `PARSE_OPTIONS.help`'s short alias. Declaring one would make
|
|
349
|
+
* `parseArgs` fold a bundled `-vh` into two tokens — `verbose` then `help` — and print the
|
|
350
|
+
* help text for a cluster the original parser refused outright as one unrecognized argument.
|
|
351
|
+
* Detecting help by re-reading `argv[tok.index]` for the literal text `-h`/`--help` keeps the
|
|
352
|
+
* ORIGINAL equality check (`tok === '-h' || tok === '--help'`) intact no matter how `parseArgs`
|
|
353
|
+
* privately tokenizes the cluster, and `argv[tok.index]` is what recovers that literal text
|
|
354
|
+
* for every refusal below — `tok.name`/`tok.rawName` are `parseArgs`'s own canonicalisation and
|
|
355
|
+
* would print `-b`/`-o`/`-g`/`-u`/`-s` for a bundle like `-bogus` where the original parser (and
|
|
356
|
+
* this one) refuses the whole typed token, `-bogus`, once.
|
|
357
|
+
*/
|
|
311
358
|
function parseArgs(argv, defaults) {
|
|
312
359
|
const args = {
|
|
313
360
|
theme: defaults.theme, posture: defaults.posture, mode: defaults.mode,
|
|
@@ -316,11 +363,12 @@ function parseArgs(argv, defaults) {
|
|
|
316
363
|
out: 'Harness', emit: 'files', footprint: 'lean', root: null,
|
|
317
364
|
syncThemes: false, validateOnly: false, verbose: false,
|
|
318
365
|
};
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
if (tok === '
|
|
366
|
+
const { tokens } = nodeParseArgs({
|
|
367
|
+
args: argv, options: PARSE_OPTIONS, allowPositionals: true, strict: false, tokens: true,
|
|
368
|
+
});
|
|
369
|
+
for (const tok of tokens) {
|
|
370
|
+
if (tok.kind === 'option' && tok.value === undefined
|
|
371
|
+
&& (argv[tok.index] === '-h' || argv[tok.index] === '--help')) {
|
|
324
372
|
process.stdout.write(usage());
|
|
325
373
|
// The same marker `die` throws, with a SUCCESS code: help is not a refusal, and
|
|
326
374
|
// `main`'s catch is what turns either into this process's exit status. A bare
|
|
@@ -330,15 +378,24 @@ function parseArgs(argv, defaults) {
|
|
|
330
378
|
e.exitCode = 0;
|
|
331
379
|
throw e;
|
|
332
380
|
}
|
|
333
|
-
if (
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
}
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
381
|
+
if (tok.kind === 'option' && VALUED_BY_NAME[tok.name] !== undefined) {
|
|
382
|
+
if (tok.value === undefined) die(2, `argument ${tok.rawName}: expected one argument`);
|
|
383
|
+
args[VALUED_BY_NAME[tok.name]] = tok.value;
|
|
384
|
+
continue;
|
|
385
|
+
}
|
|
386
|
+
// `tok.value === undefined` excludes `--sync-themes=x` from matching here — a store_true
|
|
387
|
+
// flag never took `=` in the hand-rolled version either, because its `FLAGS[tok]` lookup
|
|
388
|
+
// keyed on the WHOLE typed token (equals sign and all), never on the split-off flag name.
|
|
389
|
+
// `parseArgs` always sets a `value` KEY on every option token (`undefined` when none was
|
|
390
|
+
// given), so this must compare the VALUE, not `'value' in tok` — the key is always there.
|
|
391
|
+
if (tok.kind === 'option' && tok.value === undefined && FLAG_BY_NAME[tok.name] !== undefined) {
|
|
392
|
+
args[FLAG_BY_NAME[tok.name]] = true;
|
|
393
|
+
continue;
|
|
341
394
|
}
|
|
395
|
+
// Every other shape — an unrecognized option, a bare positional, or `--` (which `parseArgs`
|
|
396
|
+
// always treats as a terminator but this parser never did) — refuses with the literal text
|
|
397
|
+
// typed at that argv position, matching the original's `unrecognized arguments: ${tok}`.
|
|
398
|
+
die(2, `unrecognized arguments: ${argv[tok.index]}`);
|
|
342
399
|
}
|
|
343
400
|
const choices = choicesFor();
|
|
344
401
|
for (const flag of Object.keys(choices)) choice(flag, args[VALUED[flag]], choices[flag]);
|
|
@@ -532,10 +589,6 @@ function writeManifestAtomic(file, data) {
|
|
|
532
589
|
renameSync(tmp, file);
|
|
533
590
|
}
|
|
534
591
|
|
|
535
|
-
function isFile(p) {
|
|
536
|
-
try { return statSync(p).isFile(); } catch { return false; }
|
|
537
|
-
}
|
|
538
|
-
|
|
539
592
|
/**
|
|
540
593
|
* Write-before-delete: remove only what this layer owned before and no longer produces.
|
|
541
594
|
*
|
|
@@ -567,6 +620,30 @@ function pruneOwned(oc, oldOwned, owned) {
|
|
|
567
620
|
}
|
|
568
621
|
}
|
|
569
622
|
|
|
623
|
+
/**
|
|
624
|
+
* The manifest PRE-read all three emit bodies performed inline: does a manifest already
|
|
625
|
+
* exist at `manifestPath`, and if so, what does it parse to. `existed` is what
|
|
626
|
+
* `emitOpencode` alone needs (as `manifestExisted`, threaded into the render job); every
|
|
627
|
+
* caller reads `doc.owned`, and `emitClaudeCore` also reads `doc.managed` — `doc` is `null`
|
|
628
|
+
* on a missing OR a corrupt manifest, so `(doc && doc.owned) || []` covers both the same way
|
|
629
|
+
* the three inline try/catches did.
|
|
630
|
+
*/
|
|
631
|
+
function readManifest(manifestPath) {
|
|
632
|
+
const existed = existsSync(manifestPath);
|
|
633
|
+
let doc = null;
|
|
634
|
+
if (existed) {
|
|
635
|
+
try { doc = parseJson(readFileSync(manifestPath, 'utf8')); } catch { doc = null; }
|
|
636
|
+
}
|
|
637
|
+
return { existed, doc };
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
/** The "+ primary agent, N command(s)" tail both OpenCode summary lines share. */
|
|
641
|
+
function extrasTail(stats) {
|
|
642
|
+
const extras = [...(stats.primary ? ['primary agent'] : []),
|
|
643
|
+
...(stats.nCommands ? [`${stats.nCommands} command(s)`] : [])];
|
|
644
|
+
return extras.length ? ` + ${extras.join(', ')}` : '';
|
|
645
|
+
}
|
|
646
|
+
|
|
570
647
|
/**
|
|
571
648
|
* `_build_global._project_survivors` — registered per-repo PROJECT installs of `emitName`.
|
|
572
649
|
*
|
|
@@ -624,14 +701,8 @@ function emitOpencode(cfg, args, out) {
|
|
|
624
701
|
// PRE. A missing manifest reads as "owned nothing before", which is what makes the first
|
|
625
702
|
// re-emit after an upgrade treat already-existing files as the user's (claim-on-create)
|
|
626
703
|
// rather than deleting them. The prune set is then empty by construction.
|
|
627
|
-
const manifestExisted =
|
|
628
|
-
|
|
629
|
-
if (manifestExisted) {
|
|
630
|
-
try {
|
|
631
|
-
const m = parseJson(readFileSync(manifestPath, 'utf8'));
|
|
632
|
-
oldOwned = (m && m.owned) || [];
|
|
633
|
-
} catch { oldOwned = []; }
|
|
634
|
-
}
|
|
704
|
+
const { existed: manifestExisted, doc } = readManifest(manifestPath);
|
|
705
|
+
const oldOwned = (doc && doc.owned) || [];
|
|
635
706
|
|
|
636
707
|
const agentPathRel = relUnder(out, root);
|
|
637
708
|
const agentPath = agentPathRel ? `${agentPathRel}/AGENT.md` : 'AGENT.md';
|
|
@@ -658,9 +729,7 @@ function emitOpencode(cfg, args, out) {
|
|
|
658
729
|
scope: 'project',
|
|
659
730
|
});
|
|
660
731
|
|
|
661
|
-
const
|
|
662
|
-
...(stats.nCommands ? [`${stats.nCommands} command(s)`] : [])];
|
|
663
|
-
const extra = extras.length ? ` + ${extras.join(', ')}` : '';
|
|
732
|
+
const extra = extrasTail(stats);
|
|
664
733
|
process.stdout.write(`[geneseed] opencode layer: ${stats.nAgents} subagents, `
|
|
665
734
|
+ `${stats.nSkills} skills, ${stats.nPlugins} plugin(s), `
|
|
666
735
|
+ `${stats.nWorkflows} workflow file(s), ${cfgName} (instructions: ${agentPath})`
|
|
@@ -687,13 +756,8 @@ function emitOpencodeGlobal(cfg, args, out) {
|
|
|
687
756
|
const cfgDir = args.cfgDir ?? opencodeConfigDir();
|
|
688
757
|
const manifestPath = path.join(cfgDir, GLOBAL_MANIFEST);
|
|
689
758
|
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
try {
|
|
693
|
-
const m = parseJson(readFileSync(manifestPath, 'utf8'));
|
|
694
|
-
oldOwned = (m && m.owned) || [];
|
|
695
|
-
} catch { oldOwned = []; }
|
|
696
|
-
}
|
|
759
|
+
const { doc } = readManifest(manifestPath);
|
|
760
|
+
const oldOwned = (doc && doc.owned) || [];
|
|
697
761
|
|
|
698
762
|
const agentPath = path.join(cfgDir, 'AGENT.md').split(path.sep).join('/');
|
|
699
763
|
|
|
@@ -717,9 +781,7 @@ function emitOpencodeGlobal(cfg, args, out) {
|
|
|
717
781
|
owned: [...owned].sort(),
|
|
718
782
|
});
|
|
719
783
|
|
|
720
|
-
const
|
|
721
|
-
...(stats.nCommands ? [`${stats.nCommands} command(s)`] : [])];
|
|
722
|
-
const extra = extras.length ? ` + ${extras.join(', ')}` : '';
|
|
784
|
+
const extra = extrasTail(stats);
|
|
723
785
|
process.stdout.write(`[geneseed] opencode-global -> ${cfgDir}: ${stats.nAgents} subagents, `
|
|
724
786
|
+ `${stats.nSkills} skills, ${stats.nPlugins} plugin(s), `
|
|
725
787
|
+ `${stats.nWorkflows} workflow file(s), AGENT.md, ${memStatus}, ${nbStatus}, `
|
|
@@ -749,16 +811,10 @@ function emitOpencodeGlobal(cfg, args, out) {
|
|
|
749
811
|
*/
|
|
750
812
|
function emitClaudeCore(cfg, args, { cfgDir, claudeMd, scope, host, out, hookOpts }) {
|
|
751
813
|
const manifestPath = path.join(cfgDir, GLOBAL_MANIFEST);
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
const data = parseJson(readFileSync(manifestPath, 'utf8'));
|
|
757
|
-
oldOwned = data.owned || [];
|
|
758
|
-
oldManaged = (data.managed && typeof data.managed === 'object'
|
|
759
|
-
&& !Array.isArray(data.managed)) ? data.managed : {};
|
|
760
|
-
} catch { oldOwned = []; oldManaged = {}; }
|
|
761
|
-
}
|
|
814
|
+
const { doc } = readManifest(manifestPath);
|
|
815
|
+
const oldOwned = (doc && doc.owned) || [];
|
|
816
|
+
const oldManaged = (doc && doc.managed && typeof doc.managed === 'object'
|
|
817
|
+
&& !Array.isArray(doc.managed)) ? doc.managed : {};
|
|
762
818
|
const isCopilot = host === 'copilot';
|
|
763
819
|
|
|
764
820
|
// RENDER + WIRE in one child's worth of work. `preambleExclude` is null for every host
|
|
@@ -792,8 +848,8 @@ function emitClaudeCore(cfg, args, { cfgDir, claudeMd, scope, host, out, hookOpt
|
|
|
792
848
|
// VERIFY — re-read the settings file just written and match it against the claims just
|
|
793
849
|
// recorded, so a merge that silently did not stick (a commented file, a mid-flight
|
|
794
850
|
// external edit, a bug in the merge) is loud now instead of surfacing as hooks that
|
|
795
|
-
// quietly never fire.
|
|
796
|
-
//
|
|
851
|
+
// quietly never fire. Copilot's settings have their own shape and their own check; a
|
|
852
|
+
// Copilot PROJECT emit records no hooks and so checks nothing.
|
|
797
853
|
//
|
|
798
854
|
// On the Python driver this stage runs in Python AFTER a Node child did the wiring, which
|
|
799
855
|
// makes it a live cross-implementation check on every build. Here both halves are Node, so
|
|
@@ -804,15 +860,17 @@ function emitClaudeCore(cfg, args, { cfgDir, claudeMd, scope, host, out, hookOpt
|
|
|
804
860
|
// SILENT ON SUCCESS, which is its own coverage hazard: a clean emit prints nothing, so
|
|
805
861
|
// deleting this call is byte-identical in all 259 cells. `test_verify_reports_an_orphaned
|
|
806
862
|
// _geneseed_hook` plants the fault that makes it speak.
|
|
863
|
+
phaseLog('VERIFY');
|
|
807
864
|
if (!isCopilot) {
|
|
808
|
-
phaseLog('VERIFY');
|
|
809
865
|
settingsIntegrityCheck(
|
|
810
866
|
path.join(cfgDir, managed.settings_file || 'settings.json'), managed, 'present');
|
|
867
|
+
} else if (managed.copilot_hooks) {
|
|
868
|
+
copilotIntegrityCheck(path.join(cfgDir, 'settings.json'), managed.copilot_hooks, 'present');
|
|
811
869
|
}
|
|
812
870
|
return {
|
|
813
871
|
nAgents: stats.nAgents,
|
|
814
872
|
nSkills: stats.nSkills,
|
|
815
|
-
nHooks: (managed.settings_hooks || []).length,
|
|
873
|
+
nHooks: (managed.settings_hooks || managed.copilot_hooks || []).length,
|
|
816
874
|
memStatus,
|
|
817
875
|
nbStatus,
|
|
818
876
|
};
|
|
@@ -827,7 +885,8 @@ function emitCopilot(cfg, args, out) {
|
|
|
827
885
|
});
|
|
828
886
|
process.stdout.write(`[geneseed] copilot (folder) -> ${root}: AGENTS.md + .github/ `
|
|
829
887
|
+ `(${r.nAgents} agents (.agent.md), ${r.nSkills} skills), ${r.memStatus}, `
|
|
830
|
-
+ `${r.nbStatus}. No
|
|
888
|
+
+ `${r.nbStatus}. No hooks at project scope (Copilot reads hooks from `
|
|
889
|
+
+ '~/.copilot/settings.json only — the global emit wires them).\n');
|
|
831
890
|
return cfgDir;
|
|
832
891
|
}
|
|
833
892
|
|
|
@@ -835,14 +894,17 @@ function emitCopilot(cfg, args, out) {
|
|
|
835
894
|
function emitCopilotGlobal(cfg, args, out) {
|
|
836
895
|
const cfgDir = args.cfgDir ?? copilotConfigDir();
|
|
837
896
|
warnCopilotGlobalOverProject();
|
|
897
|
+
// The hook runner pair, as for Claude and Bob: the global emit is the one Copilot scope
|
|
898
|
+
// that wires hooks (`~/.copilot/settings.json`), and the shim it bakes is machine-wide.
|
|
899
|
+
const hookOpts = hookRunnerEntry();
|
|
838
900
|
const r = emitClaudeCore(cfg, args, {
|
|
839
901
|
cfgDir, claudeMd: path.join(cfgDir, 'copilot-instructions.md'),
|
|
840
|
-
scope: 'global', host: 'copilot', out,
|
|
902
|
+
scope: 'global', host: 'copilot', out, hookOpts,
|
|
841
903
|
});
|
|
842
904
|
process.stdout.write(`[geneseed] copilot-global -> ${cfgDir}: ${r.nAgents} agents (.agent.md), `
|
|
843
|
-
+ `${r.nSkills} skills, copilot-instructions.md, ${r.memStatus}, ${r.nbStatus}
|
|
844
|
-
+
|
|
845
|
-
+ "preamble's instructions
|
|
905
|
+
+ `${r.nSkills} skills, copilot-instructions.md, ${r.memStatus}, ${r.nbStatus}, `
|
|
906
|
+
+ `${r.nHooks} hooks (settings.json: sessionStart context, toolCall gate). Memory `
|
|
907
|
+
+ "write-back rides the preamble's instructions; MCP servers go in mcp-config.json.\n");
|
|
846
908
|
return cfgDir;
|
|
847
909
|
}
|
|
848
910
|
|
|
@@ -1123,30 +1185,22 @@ function run(argv) {
|
|
|
1123
1185
|
// The marker directory is the emit's TARGET, which for a global emit is the config dir it
|
|
1124
1186
|
// just rendered into and not `--out` at all — so the emit returns it rather than the
|
|
1125
1187
|
// caller guessing (build.py:427-436 re-resolves; returning it keeps one resolution).
|
|
1188
|
+
//
|
|
1189
|
+
// NINE ARMS, TWO TABLES ALREADY ON FILE: `GLOBAL_EMITS` and `PROJECT_EMITS` above are
|
|
1190
|
+
// exactly this dispatch's `-global` and per-repo halves, keyed by host rather than by
|
|
1191
|
+
// `--emit` name, so splitting the suffix reaches both without a third map.
|
|
1126
1192
|
let markerDir = out;
|
|
1127
|
-
if (args.emit
|
|
1128
|
-
|
|
1129
|
-
} else if (args.emit
|
|
1130
|
-
markerDir = emitOpencodeGlobal(cfg, args, out);
|
|
1131
|
-
} else if (args.emit === 'copilot') {
|
|
1193
|
+
if (args.emit.endsWith('-global')) {
|
|
1194
|
+
markerDir = GLOBAL_EMITS[args.emit.slice(0, -'-global'.length)](cfg, args, out);
|
|
1195
|
+
} else if (args.emit !== 'files') {
|
|
1132
1196
|
// The PROJECT emits keep their markers in `out`, not in the host config dir the emit
|
|
1133
1197
|
// wrote to (build.py:435-436) — `.github/` is the layer, `out` is the install.
|
|
1134
|
-
|
|
1135
|
-
} else if (args.emit === 'copilot-global') {
|
|
1136
|
-
markerDir = emitCopilotGlobal(cfg, args, out);
|
|
1137
|
-
} else if (args.emit === 'claude') {
|
|
1138
|
-
emitClaude(cfg, args, out);
|
|
1139
|
-
} else if (args.emit === 'claude-global') {
|
|
1140
|
-
markerDir = emitClaudeGlobal(cfg, args, out);
|
|
1141
|
-
} else if (args.emit === 'bob') {
|
|
1142
|
-
emitBob(cfg, args, out);
|
|
1143
|
-
} else if (args.emit === 'bob-global') {
|
|
1144
|
-
markerDir = emitBobGlobal(cfg, args, out);
|
|
1198
|
+
PROJECT_EMITS[args.emit](cfg, args, out);
|
|
1145
1199
|
} else {
|
|
1146
|
-
// `nativeCatalog: false` is build.py:421's
|
|
1147
|
-
// `build(args.theme, out, args.footprint)`
|
|
1148
|
-
// default. It is the one signature default
|
|
1149
|
-
// inherited because the Python CLI inherits it too.
|
|
1200
|
+
// The ninth choice, a plain bundle: `nativeCatalog: false` is build.py:421's
|
|
1201
|
+
// three-positional-argument call reproduced — `build(args.theme, out, args.footprint)`
|
|
1202
|
+
// leaves `native_catalog` at its signature default. It is the one signature default
|
|
1203
|
+
// this driver DOES inherit, and it is inherited because the Python CLI inherits it too.
|
|
1150
1204
|
build(cfg, args.theme, out, { footprint: args.footprint, nativeCatalog: false });
|
|
1151
1205
|
}
|
|
1152
1206
|
|