geneseed 2.0.0 → 3.0.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 +217 -5
- package/DESIGN.md +149 -1326
- package/QUICKSTART.md +1 -1
- package/README.md +30 -22
- package/SETUP.md +98 -24
- package/SHIPPED.md +6 -5
- package/adapters/bob/README.md +3 -3
- package/adapters/claude-code/README.md +6 -4
- package/adapters/claude-code/skills/geneseed/SKILL.md +10 -6
- package/adapters/copilot/README.md +5 -4
- package/adapters/opencode/GLOBAL-HARNESS-SPEC.md +11 -6
- package/adapters/opencode/HOW-OPENCODE-LOADS.md +1 -1
- package/adapters/opencode/README.md +15 -9
- package/adapters/opencode/agents/orchestrator.md +1 -1
- package/adapters/opencode/opencode.json +0 -9
- package/adapters/opencode/plugins/geneseed-context.js +7 -7
- package/adapters/opencode/plugins/geneseed-guard.js +6 -6
- package/adapters/opencode/workflows/dispatch.js +1 -1
- package/bin/geneseed.mjs +74 -8
- package/docs/declined.md +53 -2
- package/docs/design-history.md +1290 -0
- package/docs/extending.md +672 -0
- package/docs/limits.md +141 -82
- package/docs/token-footprint.md +15 -4
- package/docs/web/agents.md +1 -1
- package/docs/web/authoring.md +1 -1
- package/docs/web/claude-hooks.md +2 -2
- package/docs/web/collaboration.md +3 -3
- package/docs/web/first-session.md +2 -2
- package/docs/web/footprint.md +14 -7
- package/docs/web/foreman-mode.md +2 -2
- package/docs/web/harnesses.md +5 -3
- package/docs/web/install-quick.md +1 -1
- package/docs/web/model.md +3 -3
- package/docs/web/plugin-context.md +2 -2
- package/docs/web/plugin-guard.md +3 -3
- package/docs/web/plugin-ponytail.md +1 -1
- package/docs/web/rules.md +23 -3
- package/docs/web/setup-choices.md +20 -7
- package/docs/web/themes-author.md +13 -1
- package/docs/web-ui.md +36 -12
- package/geneseed +0 -0
- package/harness.config.json +1 -1
- package/js/catalog.mjs +29 -4
- package/js/checkout.mjs +24 -1
- package/js/cli-table.json +9 -7
- package/js/diff.mjs +8 -3
- package/js/doctor.mjs +374 -26
- package/js/emit.mjs +48 -11
- package/js/generate.mjs +39 -5
- package/js/hooks.mjs +8 -7
- package/js/installs.mjs +101 -4
- package/js/inventory.mjs +150 -20
- package/js/lib/fs.mjs +6 -2
- package/js/mcp.mjs +3 -15
- package/js/migrate.mjs +10 -4
- package/js/render.mjs +116 -11
- package/js/settings.mjs +297 -28
- package/js/setup.mjs +95 -5
- package/js/status.mjs +45 -4
- package/js/themes.mjs +1 -1
- package/js/tui.mjs +46 -3
- package/js/uninstall.mjs +30 -11
- package/js/web/actions.mjs +76 -9
- package/js/web/api.mjs +95 -16
- package/js/web/docs.mjs +33 -5
- package/js/web/jobs.mjs +12 -3
- package/js/web/server.mjs +59 -2
- package/package.json +1 -1
- package/src/AGENT.md.tmpl +75 -65
- package/src/agents/_template.md +4 -4
- package/src/agents/advocate.md +4 -4
- package/src/agents/architect.md +2 -2
- package/src/agents/developer.md +5 -5
- package/src/agents/docs.md +2 -2
- package/src/agents/empiricist.md +3 -3
- package/src/agents/explorer.md +3 -3
- package/src/agents/framer.md +3 -3
- package/src/agents/historian.md +3 -3
- package/src/agents/operator.md +3 -3
- package/src/agents/pragmatist.md +3 -3
- package/src/agents/reviewer.md +2 -2
- package/src/agents/security.md +2 -2
- package/src/agents/skeptic.md +4 -4
- package/src/agents/steward.md +3 -3
- package/src/agents/tester.md +2 -2
- package/src/agents/user-advocate.md +3 -3
- package/src/agents/visionary.md +3 -3
- package/src/doctrines/README.md +41 -0
- package/src/doctrines/craft.md +51 -0
- package/src/doctrines/ops.md +61 -0
- package/src/doctrines/process.md +87 -0
- package/src/doctrines/rigor.md +52 -0
- package/src/laws/universal.md +17 -301
- package/src/memory/README.md +2 -2
- package/src/modes/README.md +2 -2
- package/src/modes/foreman.md +1 -1
- package/src/notebook/README.md +2 -2
- package/src/ontology/universal.md +89 -0
- package/src/postures/README.md +3 -3
- package/src/postures/artisan.md +3 -3
- package/src/postures/assistant.md +1 -1
- package/src/postures/expert.md +1 -1
- package/src/postures/mentor.md +1 -1
- package/src/postures/peer.md +2 -2
- package/src/skills/_template.md +2 -2
- package/src/skills/brainstorm.md +2 -2
- package/src/skills/clarify.md +2 -2
- package/src/skills/codebase-design.md +1 -1
- package/src/skills/commit.md +5 -5
- package/src/skills/council.md +4 -4
- package/src/skills/crash-course.md +1 -1
- package/src/skills/daydream/SKILL.md +1 -1
- package/src/skills/debug.md +2 -2
- package/src/skills/decode.md +1 -1
- package/src/skills/develop.md +6 -6
- package/src/skills/document-project.md +4 -4
- package/src/skills/domain-modeling.md +1 -1
- package/src/skills/drill.md +1 -1
- package/src/skills/feynman.md +1 -1
- package/src/skills/forge-mcp.md +3 -3
- package/src/skills/fresh-eyes.md +4 -4
- package/src/skills/frontend-design.md +1 -1
- package/src/skills/gap-detector.md +1 -1
- package/src/skills/geneseed-code-review.md +1 -1
- package/src/skills/geneseed.md +4 -4
- package/src/skills/git-archaeology.md +1 -1
- package/src/skills/git-rescue.md +3 -3
- package/src/skills/handoff.md +4 -4
- package/src/skills/herdr.md +1 -1
- package/src/skills/ingest.md +3 -3
- package/src/skills/learning-path.md +1 -1
- package/src/skills/migrate.md +5 -5
- package/src/skills/opencode-theme.md +1 -1
- package/src/skills/parallel-agents.md +2 -2
- package/src/skills/pipeline.md +2 -2
- package/src/skills/plan.md +2 -2
- package/src/skills/ponytail.md +1 -1
- package/src/skills/profile.md +1 -1
- package/src/skills/prose.md +2 -2
- package/src/skills/prototype.md +2 -2
- package/src/skills/refactor.md +2 -2
- package/src/skills/release.md +4 -4
- package/src/skills/repo-map.md +5 -5
- package/src/skills/research.md +6 -6
- package/src/skills/review-response.md +3 -3
- package/src/skills/roast-me.md +1 -1
- package/src/skills/rule.md +5 -5
- package/src/skills/ship.md +3 -3
- package/src/skills/tdd.md +2 -2
- package/src/skills/tickets.md +1 -1
- package/src/skills/wayfinder.md +1 -1
- package/src/skills/wiki.md +4 -4
- package/src/skills/workflow.md +2 -2
- package/themes/README.md +17 -7
- package/themes/_TEMPLATE.json +37 -33
- package/themes/biker.json +37 -33
- package/themes/commentator.json +37 -33
- package/themes/cyberpunk.json +37 -33
- package/themes/gamer.json +37 -33
- package/themes/imperial.json +37 -33
- package/themes/joker.json +37 -33
- package/themes/marvin.json +37 -33
- package/themes/mean.json +37 -33
- package/themes/military.json +37 -33
- package/themes/neutral.json +37 -33
- package/themes/pirate.json +37 -33
- package/themes/sports.json +37 -33
- package/themes/verstappen.json +37 -33
- package/themes/wizard.json +37 -33
- package/web/dist/assets/{About-DX1-VTBP.js → About-C0Mk0igT.js} +1 -1
- package/web/dist/assets/{Activity-Dp9NaXOn.js → Activity-C4aJJVXz.js} +1 -1
- package/web/dist/assets/{ActivityDetail-HpOBtrXS.js → ActivityDetail-DaQ85za0.js} +1 -1
- package/web/dist/assets/{Diff-Ct4G3Se8.js → Diff-B3tlO2Yj.js} +1 -1
- package/web/dist/assets/{Doctor-DGjkZFuA.js → Doctor-BNC-5Dmo.js} +1 -1
- package/web/dist/assets/{Graph-b8o-S6kv.js → Graph-us0T6ZcV.js} +1 -1
- package/web/dist/assets/Harnesses-DAMgyfM_.js +1 -0
- package/web/dist/assets/Laws-rOxegJ75.js +1 -0
- package/web/dist/assets/{Library-BEZscyaQ.js → Library-KmwrHA1J.js} +1 -1
- package/web/dist/assets/{Markdown-k_LSLBGq.js → Markdown-0eQOVGSF.js} +1 -1
- package/web/dist/assets/{Profile-DydukTxP.js → Profile-ZU7WV6Xl.js} +1 -1
- package/web/dist/assets/{Rules-CIRG4Ldt.js → Rules-CtDoPhhf.js} +1 -1
- package/web/dist/assets/{Skills-Buoi1IBr.js → Skills-BhoOxdkD.js} +1 -1
- package/web/dist/assets/{StatusBadge-CE_AJttl.js → StatusBadge-CGvYVCwa.js} +1 -1
- package/web/dist/assets/{Themes-CI4o3gJC.js → Themes-Dr7OOkD7.js} +1 -1
- package/web/dist/assets/{activity-BCW7e6Cy.js → activity-B8aq3qtl.js} +1 -1
- package/web/dist/assets/index-2wqPDxuN.css +1 -0
- package/web/dist/assets/{index-B1PIx7RM.js → index-AhMzkdbI.js} +1 -1
- package/web/dist/assets/index-CRftEtBX.js +12 -0
- package/web/dist/assets/{index-Ck2iCdaC.js → index-D0lPN_qW.js} +1 -1
- package/web/dist/index.html +2 -2
- package/web/src/pages/Laws.jsx +282 -88
- package/web/dist/assets/Harnesses-gr3-aHBF.js +0 -1
- package/web/dist/assets/Laws-DTwF5tTC.js +0 -1
- package/web/dist/assets/index-BnTQUSUz.js +0 -12
- package/web/dist/assets/index-DUCsDv7z.css +0 -1
package/js/render.mjs
CHANGED
|
@@ -29,13 +29,20 @@
|
|
|
29
29
|
import { readdirSync, statSync, existsSync } from 'node:fs';
|
|
30
30
|
import path from 'node:path';
|
|
31
31
|
import { readText, parseJson, normcase, comparePaths } from './lib/fs.mjs';
|
|
32
|
+
// `js/checkout.mjs` imports nothing but node builtins, so this direction cannot cycle.
|
|
33
|
+
import { PACK_ORDER } from './checkout.mjs';
|
|
32
34
|
|
|
33
35
|
/** Document STRUCTURE is theme-INDEPENDENT — mirrors `_build_render.STRUCTURE`. */
|
|
34
36
|
export const STRUCTURE = {
|
|
35
37
|
HARNESS: 'Geneseed', CHARTER: 'Charter', CONTEXT: 'Context',
|
|
36
38
|
SCRIPT: 'Script', SCRIPTS: 'Scripts',
|
|
37
39
|
DIR_LAWS: 'laws', DIR_AGENTS: 'agents', DIR_SKILLS: 'skills', DIR_MEMORY: 'memory',
|
|
38
|
-
DIR_NOTEBOOK: 'notebook',
|
|
40
|
+
DIR_NOTEBOOK: 'notebook', DIR_ONTOLOGY: 'ontology', DIR_DOCTRINES: 'doctrines',
|
|
41
|
+
// Ontology section names. Theme-INDEPENDENT on purpose: a citation reads
|
|
42
|
+
// `({{ONTOLOGY}}: {{ONT_TELOS}})` — token on both sides, so the heading and every
|
|
43
|
+
// reference to it move together and a rename can never desync them.
|
|
44
|
+
ONT_TELOS: 'Telos', ONT_EVIDENCE: 'Evidence', ONT_DECISIONS: 'Decisions',
|
|
45
|
+
ONT_CONDUCT: 'Conduct',
|
|
39
46
|
};
|
|
40
47
|
|
|
41
48
|
/**
|
|
@@ -46,11 +53,15 @@ export const STRUCTURE = {
|
|
|
46
53
|
export const SRC_DIR_TOKENS = {
|
|
47
54
|
laws: 'DIR_LAWS', agents: 'DIR_AGENTS', skills: 'DIR_SKILLS',
|
|
48
55
|
memory: 'DIR_MEMORY', notebook: 'DIR_NOTEBOOK',
|
|
56
|
+
ontology: 'DIR_ONTOLOGY', doctrines: 'DIR_DOCTRINES',
|
|
49
57
|
};
|
|
50
58
|
|
|
51
59
|
const TEXT_SUFFIXES = new Set(['.md', '.tmpl', '.json', '.txt', '.yml', '.yaml']);
|
|
52
60
|
|
|
53
|
-
|
|
61
|
+
// Digits are legal after the first character: `DOC_<PACK>_<n>` (the doctrine rule titles) are
|
|
62
|
+
// the first token names in the project to carry one, and without `0-9` here `{{DOC_CRAFT_1}}`
|
|
63
|
+
// silently never substitutes. A leading digit stays illegal, so `{{1}}` is still not a token.
|
|
64
|
+
const TOKEN_RE = /\{\{([A-Z_][A-Z0-9_]*)\}\}/g;
|
|
54
65
|
const INCLUDE_RE = /^[ \t]*<!--[ \t]*INCLUDE:[ \t]*(?<path>[^ \t]+)[ \t]*-->[ \t]*$/gm;
|
|
55
66
|
const CATALOG_BLOCK_RE =
|
|
56
67
|
/[ \t]*<!-- CATALOG:begin -->\n(?<table>[\s\S]*?)[ \t]*<!-- CATALOG:else -->\n(?<pointer>[\s\S]*?)[ \t]*<!-- CATALOG:end -->\n/g;
|
|
@@ -89,9 +100,11 @@ export function loadTheme(cfg, name) {
|
|
|
89
100
|
.map((f) => f.slice(0, -5)).sort().join(', ');
|
|
90
101
|
// `sys.exit(<str>)` — the message on stderr, exit 1 — spelled the way
|
|
91
102
|
// `assertSourceComplete` spells it: written at the raise site, and the throw carries
|
|
92
|
-
// only the marker. It
|
|
93
|
-
// `--theme` is validated against `choices` before
|
|
94
|
-
//
|
|
103
|
+
// only the marker. ⚠ It was once described as an unreachable nicety under
|
|
104
|
+
// `bin/geneseed.mjs`, on the grounds that `--theme` is validated against `choices` before
|
|
105
|
+
// anything renders — it is not: `--theme` takes a value and has no `choices` entry, so THIS
|
|
106
|
+
// is the refusal, on every path. It is also reachable from `geneseed status`, which takes
|
|
107
|
+
// its theme from a `.geneseed-theme`
|
|
95
108
|
// marker — an unvalidated file in a user's install. A bare `throw` there printed a Node
|
|
96
109
|
// stack trace where Python printed one line, and the acceptance matrix is what said so.
|
|
97
110
|
process.stderr.write(`[geneseed] unknown theme '${name}'. available: ${available}\n`);
|
|
@@ -113,7 +126,7 @@ export function substitute(text, theme) {
|
|
|
113
126
|
const v = theme[key];
|
|
114
127
|
// Python renders `str(theme[key])`, which agrees with JS only for strings —
|
|
115
128
|
// `str(True)`/`String(true)` and `str({...})`/`String({...})` do not. Every one of
|
|
116
|
-
// the
|
|
129
|
+
// the 148 tokens the source tree actually uses resolves to a string today
|
|
117
130
|
// (AGENT_COLORS is a dict but is never a token), so a non-string here means the
|
|
118
131
|
// source or a theme grew a case nobody has decided the semantics for. Fail loudly
|
|
119
132
|
// rather than emit `[object Object]` into somebody's AGENT.md.
|
|
@@ -150,8 +163,18 @@ function splitAtLawHeadings(text) {
|
|
|
150
163
|
return out;
|
|
151
164
|
}
|
|
152
165
|
|
|
153
|
-
/**
|
|
154
|
-
|
|
166
|
+
/**
|
|
167
|
+
* `_terse_laws` minus its pointer paragraph — heading + first sentence, per `### ` block.
|
|
168
|
+
*
|
|
169
|
+
* Split out rather than parameterised. The pointer was baked into the function body, and
|
|
170
|
+
* the doctrines axis needs the same truncation under a DIFFERENT closing paragraph (one
|
|
171
|
+
* that names the pack catalogue, not `laws/universal.md`, and drops the
|
|
172
|
+
* secrets/deletion/git-history clause, which is the invariants' domain and not a pack's).
|
|
173
|
+
* An extraction leaves `terseLaws` byte-identical for the laws path — the recorded lean
|
|
174
|
+
* AGENT.md does not move — where a fourth parameter would have put a branch inside the one
|
|
175
|
+
* function every frozen lean render already goes through.
|
|
176
|
+
*/
|
|
177
|
+
function terseBlocks(text) {
|
|
155
178
|
const blocks = splitAtLawHeadings(text);
|
|
156
179
|
const out = [blocks[0].trimEnd()];
|
|
157
180
|
for (const b of blocks.slice(1)) {
|
|
@@ -167,6 +190,11 @@ export function terseLaws(text, theme, lawsPrefix = '') {
|
|
|
167
190
|
const m = /^([\s\S]+?[.!?])(?:\s|$)/.exec(body);
|
|
168
191
|
out.push(`${heading}\n${m ? m[1].trim() : body}`);
|
|
169
192
|
}
|
|
193
|
+
return out.join('\n\n');
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** `_build_render._terse_laws`. */
|
|
197
|
+
export function terseLaws(text, theme, lawsPrefix = '') {
|
|
170
198
|
const law = theme.LAW ?? 'Law';
|
|
171
199
|
const lawsDir = theme.DIR_LAWS ?? 'laws';
|
|
172
200
|
const pointer =
|
|
@@ -174,7 +202,7 @@ export function terseLaws(text, theme, lawsPrefix = '') {
|
|
|
174
202
|
+ `complete, binding text of every ${law} is in \`${lawsPrefix}${lawsDir}/universal.md\`; `
|
|
175
203
|
+ `read it whenever a ${law}'s application is unclear, and before any act touching `
|
|
176
204
|
+ `secrets, deletion, git history, scope, or untrusted content.`;
|
|
177
|
-
return
|
|
205
|
+
return terseBlocks(text) + '\n\n' + pointer;
|
|
178
206
|
}
|
|
179
207
|
|
|
180
208
|
/** `_build_render.render_file`. */
|
|
@@ -211,6 +239,75 @@ function registerBody(cfg, theme, dir, selected, fallback) {
|
|
|
211
239
|
return '';
|
|
212
240
|
}
|
|
213
241
|
|
|
242
|
+
/**
|
|
243
|
+
* The doctrine packs this build actually renders, in `PACK_ORDER` — and the two gates
|
|
244
|
+
* that stand between `cfg.doctrines` and a silently wrong AGENT.md.
|
|
245
|
+
*
|
|
246
|
+
* GATE 1 (stray pack): a `.md` under `src/doctrines/` that `PACK_ORDER` does not name is a
|
|
247
|
+
* refusal, not a skip. Discovery sorts alphabetically and `PACK_ORDER` does not, so the two
|
|
248
|
+
* cannot be the same list; without this gate a fifth pack file would render into no install
|
|
249
|
+
* and no test would say so.
|
|
250
|
+
*
|
|
251
|
+
* GATE 2 (missing file): a pack named in `cfg.doctrines` with no file is a refusal too. The
|
|
252
|
+
* CLI validates `--doctrines` against discovery, but `makeCfg({doctrines})` is a public
|
|
253
|
+
* entry point and an emit that quietly dropped a pack the user asked for is exactly the
|
|
254
|
+
* failure the `Active packs:` marker would then attest to.
|
|
255
|
+
*
|
|
256
|
+
* Both throw with `exitCode`, the marker `bin/geneseed.mjs`'s `main` turns into a status.
|
|
257
|
+
*/
|
|
258
|
+
function activeDoctrines(cfg) {
|
|
259
|
+
const dir = path.join(cfg.src, 'doctrines');
|
|
260
|
+
const onDisk = existsSync(dir)
|
|
261
|
+
? readdirSync(dir)
|
|
262
|
+
.filter((f) => f.endsWith('.md') && path.basename(f, '.md').toLowerCase() !== 'readme')
|
|
263
|
+
.map((f) => path.basename(f, '.md'))
|
|
264
|
+
: [];
|
|
265
|
+
const stray = onDisk.filter((n) => !PACK_ORDER.includes(n));
|
|
266
|
+
if (stray.length) {
|
|
267
|
+
const e = new Error(`doctrine pack file(s) ${stray.join(', ')} exist under src/doctrines/ `
|
|
268
|
+
+ 'but are missing from PACK_ORDER (js/checkout.mjs) — add them there, or they render '
|
|
269
|
+
+ 'into no install at all');
|
|
270
|
+
e.exitCode = 1;
|
|
271
|
+
throw e;
|
|
272
|
+
}
|
|
273
|
+
const wanted = cfg.doctrines ?? PACK_ORDER;
|
|
274
|
+
for (const name of wanted) {
|
|
275
|
+
if (!onDisk.includes(name)) {
|
|
276
|
+
const e = new Error(`doctrine pack '${name}' is selected but src/doctrines/${name}.md `
|
|
277
|
+
+ `does not exist (packs on disk: ${onDisk.join(', ') || 'none'})`);
|
|
278
|
+
e.exitCode = 1;
|
|
279
|
+
throw e;
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
return PACK_ORDER.filter((p) => wanted.includes(p));
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* `{{DOCTRINES_BODY}}` — the active packs concatenated, the multi-select answer to
|
|
287
|
+
* `registerBody`'s single-select one.
|
|
288
|
+
*
|
|
289
|
+
* `registerBody` reads ONE file because a posture and a mode are scalars; a doctrine set is
|
|
290
|
+
* 0-4 files, so this loops instead of wrapping it. Under `lean` each pack gets the same
|
|
291
|
+
* heading + first-sentence treatment the invariants get, and the whole section — not each
|
|
292
|
+
* pack — closes with one pointer at the full catalogue, which ships in every bundle whether
|
|
293
|
+
* or not a pack is active.
|
|
294
|
+
*/
|
|
295
|
+
function doctrinesBody(cfg, theme, active, footprint, lawsPrefix) {
|
|
296
|
+
if (!active.length) return '';
|
|
297
|
+
const parts = active.map((name) => {
|
|
298
|
+
const body = substitute(readText(path.join(cfg.src, 'doctrines', `${name}.md`)), theme).trim();
|
|
299
|
+
return footprint === 'lean' ? terseBlocks(body) : body;
|
|
300
|
+
});
|
|
301
|
+
if (footprint === 'lean') {
|
|
302
|
+
const doctrine = theme.DOCTRINE ?? 'Doctrine';
|
|
303
|
+
const dir = theme.DIR_DOCTRINES ?? 'doctrines';
|
|
304
|
+
parts.push(`> Each ${doctrine} above is given in brief — the rule, not its reasoning. `
|
|
305
|
+
+ `The complete text of every pack, active or not, is in \`${lawsPrefix}${dir}/\`; read `
|
|
306
|
+
+ `the pack file whenever a ${doctrine}'s application is unclear.`);
|
|
307
|
+
}
|
|
308
|
+
return parts.join('\n\n');
|
|
309
|
+
}
|
|
310
|
+
|
|
214
311
|
/**
|
|
215
312
|
* `_build_render.effective_theme`.
|
|
216
313
|
*
|
|
@@ -221,10 +318,18 @@ function registerBody(cfg, theme, dir, selected, fallback) {
|
|
|
221
318
|
* it reaches nothing at all unless it travels. The constant stays as the default for the
|
|
222
319
|
* parity harnesses, which drive this module directly.
|
|
223
320
|
*/
|
|
224
|
-
export function effectiveTheme(cfg, themeName) {
|
|
321
|
+
export function effectiveTheme(cfg, themeName, { footprint = 'full', lawsPrefix = '' } = {}) {
|
|
225
322
|
const theme = { ...loadTheme(cfg, themeName), ...(cfg.structure ?? STRUCTURE) };
|
|
226
323
|
theme.POSTURE_BODY = registerBody(cfg, theme, 'postures', cfg.posture ?? 'peer', 'peer');
|
|
227
324
|
theme.MODE_BODY = registerBody(cfg, theme, 'modes', cfg.mode ?? 'direct', 'direct');
|
|
325
|
+
// The doctrines pair is threaded, not read off `cfg`: `footprint` is a per-RENDER value
|
|
326
|
+
// (`renderAll`'s option, and the same cfg renders both ways in one process), so parking it
|
|
327
|
+
// on cfg would make the terse/full choice sticky across calls. An options object rather
|
|
328
|
+
// than two positional params keeps the existing two-arg call sites — every test, and
|
|
329
|
+
// `renderAll` — compiling unchanged, and defaults to the full text if a caller forgets.
|
|
330
|
+
const active = activeDoctrines(cfg);
|
|
331
|
+
theme.DOCTRINES_BODY = doctrinesBody(cfg, theme, active, footprint, lawsPrefix);
|
|
332
|
+
theme.DOCTRINES_LIST = active.length ? active.join(', ') : 'none';
|
|
228
333
|
return theme;
|
|
229
334
|
}
|
|
230
335
|
|
|
@@ -269,7 +374,7 @@ export function destRel(rel) {
|
|
|
269
374
|
export function renderAll(cfg, themeName, {
|
|
270
375
|
footprint = 'full', lawsPrefix = '', nativeCatalog = false,
|
|
271
376
|
} = {}) {
|
|
272
|
-
const theme = effectiveTheme(cfg, themeName);
|
|
377
|
+
const theme = effectiveTheme(cfg, themeName, { footprint, lawsPrefix });
|
|
273
378
|
const items = [];
|
|
274
379
|
for (const file of sortedSourceFiles(cfg.src)) {
|
|
275
380
|
const rel = path.relative(cfg.src, file);
|
package/js/settings.mjs
CHANGED
|
@@ -33,7 +33,8 @@
|
|
|
33
33
|
* much larger change than the defect.
|
|
34
34
|
*/
|
|
35
35
|
import {
|
|
36
|
-
chmodSync, existsSync, mkdirSync, renameSync, rmSync, statSync, unlinkSync,
|
|
36
|
+
chmodSync, existsSync, mkdirSync, realpathSync, renameSync, rmSync, statSync, unlinkSync,
|
|
37
|
+
writeFileSync,
|
|
37
38
|
} from 'node:fs';
|
|
38
39
|
import path from 'node:path';
|
|
39
40
|
import os from 'node:os';
|
|
@@ -66,17 +67,120 @@ function isDict(v) {
|
|
|
66
67
|
return Boolean(v) && typeof v === 'object' && !Array.isArray(v);
|
|
67
68
|
}
|
|
68
69
|
|
|
69
|
-
/**
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
70
|
+
/**
|
|
71
|
+
* Is the `process` doctrine pack active for the install being emitted?
|
|
72
|
+
*
|
|
73
|
+
* ⚠ FAIL CLOSED, and the shape of the test is the whole point. Anything that is not an
|
|
74
|
+
* explicit array — `null`, `undefined`, a caller that never learned about packs — means
|
|
75
|
+
* "unknown", and unknown keeps the consent gate wired. `doctrinesOfDir` returns exactly that
|
|
76
|
+
* `null` for a pre-migration install, and `d?.includes('process')` would read it as "off" and
|
|
77
|
+
* silently unwire the gate from every carrier on disk. An empty array is NOT unknown: it is a
|
|
78
|
+
* deliberate `--doctrines none`, and it turns the gate off.
|
|
79
|
+
*/
|
|
80
|
+
const processPackOn = (d) => !Array.isArray(d) || d.includes('process');
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* `_build_settings._default_permission`.
|
|
84
|
+
*
|
|
85
|
+
* WHAT THE PACK TOGGLE REACHES AND WHAT IT MUST NOT. `git commit*` and `git push*` are the
|
|
86
|
+
* BOUNDARY HALF of doctrine process 5 (consent before recording or sharing); with the process
|
|
87
|
+
* pack off, that rule is no longer binding — its text is not rendered into the carrier's
|
|
88
|
+
* doctrine section — and a boundary that keeps asking for a rule the install did not adopt is
|
|
89
|
+
* the one disagreement the tiering is not allowed to produce. (It does NOT follow that the
|
|
90
|
+
* string is gone from the bundle; see `claudeHookGroups` for what D5 ships anyway and why.)
|
|
91
|
+
* `rm -rf *`, `git push --force*` and `git push -f*` stay in EVERY build: they are
|
|
92
|
+
* Law IV's territory — an always-on invariant — not the process pack's, and the guard
|
|
93
|
+
* plugin's own force-push arm is unconditional for the same reason.
|
|
94
|
+
*/
|
|
95
|
+
export function defaultPermission(doctrines = null) {
|
|
96
|
+
const bash = { 'rm -rf *': 'ask' };
|
|
97
|
+
if (processPackOn(doctrines)) {
|
|
98
|
+
bash['git commit*'] = 'ask';
|
|
99
|
+
bash['git push*'] = 'ask';
|
|
100
|
+
}
|
|
101
|
+
bash['git push --force*'] = 'ask';
|
|
102
|
+
bash['git push -f*'] = 'ask';
|
|
103
|
+
return { bash };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Every `permission.bash` key Geneseed has ever written — the OWNED set, which is not the same
|
|
108
|
+
* as the set `defaultPermission` returns for a given selection. The two process-pack keys are
|
|
109
|
+
* in it whether or not this build wants them, because reconciling an EXISTING opencode.json
|
|
110
|
+
* has to be able to recognise a key that is Geneseed's business at all — to add it back when
|
|
111
|
+
* the pack returns, and to name it when this build no longer wants it but will not remove it.
|
|
112
|
+
*/
|
|
113
|
+
const OWNED_BASH = ['rm -rf *', 'git commit*', 'git push*', 'git push --force*', 'git push -f*'];
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Bring an ALREADY-WRITTEN `permission` block back into line with the pack selection, and
|
|
117
|
+
* touch nothing else. Returns `[changed, stranded]` — whether anything moved, and the owned
|
|
118
|
+
* keys this build does NOT want but refuses to take away.
|
|
119
|
+
*
|
|
120
|
+
* ⚠ THIS IS THE FIX FOR A BOUNDARY FROZEN AT FIRST WRITE. `mergeOpencodeJson` used to add the
|
|
121
|
+
* whole block only when the key was absent, so on a file that already had one the git gate was
|
|
122
|
+
* whatever the FIRST emit happened to write, forever — and the direction that matters is
|
|
123
|
+
* turning the process pack back ON, which left the gate ABSENT while AGENT.md stated the rule.
|
|
124
|
+
* That direction is pure ADDITION and it is fixed here. Claude's side never had the hole;
|
|
125
|
+
* `mergeClaudeSettings` prunes and re-adds its managed groups on every emit, because that file
|
|
126
|
+
* has a MANAGED BLOCK and this one does not.
|
|
127
|
+
*
|
|
128
|
+
* ⚠ REMOVAL IS GUARDED BY OWNERSHIP, NOT BY VALUE — and this is the second thing the first cut
|
|
129
|
+
* got wrong. `opencode.json` is USER CO-OWNED: the WIRE stage reconciles it, it does not own
|
|
130
|
+
* it. Geneseed may take back an entry it wrote; it may never take back one the user wrote.
|
|
131
|
+
* Dropping every unwanted key that still read `'ask'` treated the VALUE as the ownership
|
|
132
|
+
* record, so a user who had typed their own `"git commit*": "ask"` — a perfectly ordinary
|
|
133
|
+
* thing to want, and the value Geneseed writes precisely because it is the obvious one — had
|
|
134
|
+
* it DELETED by a pack-off build.
|
|
135
|
+
*
|
|
136
|
+
* NOTHING ON DISK RECORDS PROVENANCE, and none of the candidates survived contact: the value
|
|
137
|
+
* is what a user would write too; the carrier's `Active packs:` marker is rewritten by this
|
|
138
|
+
* same emit BEFORE `mergeOpencodeJson` runs (`emitOpencodeGlobalRender` writes AGENT.md at the
|
|
139
|
+
* top and wires the config at the bottom), so it answers with the NEW selection and would
|
|
140
|
+
* licence every removal; and a per-install ownership record would be a new marker file joining
|
|
141
|
+
* the owned set, the reversal markers and the deletion corpus — a much larger change than the
|
|
142
|
+
* defect. So removal is CONSERVATIVE: an unwanted owned key that is already on disk stays, and
|
|
143
|
+
* is reported to the caller as `stranded` so the user is told rather than left to discover it.
|
|
144
|
+
*
|
|
145
|
+
* That leaves a gate wired for a rule this build does not make binding, which is the
|
|
146
|
+
* FAIL-CLOSED direction — the boundary asks for a confirmation the constitution no longer
|
|
147
|
+
* compels, exactly as the commit skill's own consent step does with the pack off. The unsafe
|
|
148
|
+
* direction, a rule with no gate behind it, cannot occur here. A FRESH file is unaffected:
|
|
149
|
+
* `mergeOpencodeJson` writes `defaultPermission(doctrines)` whole, so a new pack-off install
|
|
150
|
+
* has no git gate at all.
|
|
151
|
+
*
|
|
152
|
+
* ⚠ KEY ORDER IS NOT RECONCILED, DELIBERATELY. `defaultPermission` emits its keys in a fixed
|
|
153
|
+
* order, so a FRESH write is tidy; an off→on reconcile appends the two process keys after the
|
|
154
|
+
* force-push pair and the block reads out of order from then on. Restoring the order means
|
|
155
|
+
* rewriting a map whose other entries belong to the user — sorting THEIR keys to tidy OURS is
|
|
156
|
+
* the value-over-ownership mistake in a second suit, and JSON object order carries no meaning
|
|
157
|
+
* to OpenCode. So the order is left as found and only membership is reconciled.
|
|
158
|
+
*
|
|
159
|
+
* Returns a third element, `opaque`: the shape reason wiring was impossible, or `null`. The
|
|
160
|
+
* caller warns on it. This is the one branch where the boundary can end up with NO Geneseed
|
|
161
|
+
* gate at all while the constitution states the rule — the unsafe direction — so it must not
|
|
162
|
+
* also be the silent one.
|
|
163
|
+
*/
|
|
164
|
+
function reconcileOpencodePermission(perm, want) {
|
|
165
|
+
// A `permission` that is not an object, or whose `bash` is not one — OpenCode also accepts a
|
|
166
|
+
// bare `"bash": "allow"` — is a shape this function has no owned keys inside of. Leaving it
|
|
167
|
+
// exactly as found is the whole point; rewriting it into a map would DELETE a blanket policy
|
|
168
|
+
// the user wrote, which is a bigger change than the one being reconciled.
|
|
169
|
+
if (!isDict(perm)) return [false, [], 'is not an object'];
|
|
170
|
+
if (has(perm, 'bash') && !isDict(get(perm, 'bash'))) {
|
|
171
|
+
return [false, [], 'has a "bash" that is not an object'];
|
|
172
|
+
}
|
|
173
|
+
const fresh = !has(perm, 'bash');
|
|
174
|
+
const bash = fresh ? {} : get(perm, 'bash');
|
|
175
|
+
let changed = false;
|
|
176
|
+
const stranded = [];
|
|
177
|
+
for (const k of OWNED_BASH) {
|
|
178
|
+
if (has(want.bash, k)) {
|
|
179
|
+
if (!has(bash, k)) { bash[k] = want.bash[k]; changed = true; }
|
|
180
|
+
} else if (has(bash, k)) stranded.push(k);
|
|
181
|
+
}
|
|
182
|
+
if (changed && fresh) perm.bash = bash;
|
|
183
|
+
return [changed, stranded, null];
|
|
80
184
|
}
|
|
81
185
|
|
|
82
186
|
/**
|
|
@@ -169,16 +273,27 @@ export function readJsonc(text) {
|
|
|
169
273
|
}
|
|
170
274
|
}
|
|
171
275
|
|
|
172
|
-
/**
|
|
173
|
-
|
|
174
|
-
|
|
276
|
+
/**
|
|
277
|
+
* `_build_settings._warn_commented_jsonc`. Prints to STDOUT — the Python's own choice.
|
|
278
|
+
*
|
|
279
|
+
* `permission` is THREE-STATE, not a boolean: `'add'` for a file with no `permission` key,
|
|
280
|
+
* `'reconcile'` for one that has a block this build would have wired gates into, and falsy for
|
|
281
|
+
* neither. A commented `.jsonc` is never rewritten — that is what "your edits are kept" buys —
|
|
282
|
+
* so the only thing this branch can do about a stale block is SAY SO, and telling a file that
|
|
283
|
+
* already has a `permission` key to add one is not saying so. Any other truthy value is read as
|
|
284
|
+
* `'add'`, which is what the boolean callers this replaced meant.
|
|
285
|
+
*/
|
|
286
|
+
export function warnCommentedJsonc(target, agentPath, permission,
|
|
287
|
+
includeLsp = false, prefix = 'geneseed', doctrines = null) {
|
|
175
288
|
process.stdout.write(`[${prefix}] ${path.basename(target)} has comments — not rewriting `
|
|
176
289
|
+ 'it (your edits are kept). Add this to its "instructions" array by hand:\n');
|
|
177
290
|
process.stdout.write(`[${prefix}] ${jsonDumps(agentPath)}\n`);
|
|
178
|
-
if (
|
|
179
|
-
process.stdout.write(
|
|
180
|
-
|
|
181
|
-
|
|
291
|
+
if (permission) {
|
|
292
|
+
process.stdout.write(permission === 'reconcile'
|
|
293
|
+
? `[${prefix}] and its "permission".bash is missing gates this build wires — `
|
|
294
|
+
+ 'reconcile it by hand against:\n'
|
|
295
|
+
: `[${prefix}] and, for Geneseed's default ask-gates, a "permission" key:\n`);
|
|
296
|
+
for (const line of jsonDumpsIndent(defaultPermission(doctrines)).split('\n')) {
|
|
182
297
|
process.stdout.write(`[${prefix}] ${line}\n`);
|
|
183
298
|
}
|
|
184
299
|
}
|
|
@@ -207,7 +322,7 @@ export function atomicWriteJson(p, config) {
|
|
|
207
322
|
* re-implements the inverse itself rather than inherit this one's permission/lsp side
|
|
208
323
|
* effects, so the only crossings are the two emit-tree ones (`_build_emit.emit_opencode`,
|
|
209
324
|
* `_build_global.emit_opencode_global`). */
|
|
210
|
-
export function mergeOpencodeJson(p, agentPath) {
|
|
325
|
+
export function mergeOpencodeJson(p, agentPath, doctrines = null) {
|
|
211
326
|
const target = opencodeTarget(p);
|
|
212
327
|
let config = { $schema: OPENCODE_SCHEMA, instructions: [] };
|
|
213
328
|
let hadComments = false;
|
|
@@ -238,13 +353,58 @@ export function mergeOpencodeJson(p, agentPath) {
|
|
|
238
353
|
const addInstr = indexOfEq(instr, agentPath) < 0;
|
|
239
354
|
if (addInstr) instr.push(agentPath);
|
|
240
355
|
config.instructions = instr;
|
|
356
|
+
// Absent ⇒ write the whole block; present ⇒ reconcile Geneseed's own git keys. Adding is
|
|
357
|
+
// unconditional; removal is not this stage's to make (see `reconcileOpencodePermission`), so
|
|
358
|
+
// the second return value is what it declined to take away. `addPerm` stays a separate flag
|
|
359
|
+
// from "something changed" because the two produce DIFFERENT advice on a commented file.
|
|
241
360
|
const addPerm = !has(config, 'permission');
|
|
242
|
-
|
|
361
|
+
let permChanged = addPerm;
|
|
362
|
+
let stranded = [];
|
|
363
|
+
let opaque = null;
|
|
364
|
+
if (addPerm) config.permission = defaultPermission(doctrines);
|
|
365
|
+
else {
|
|
366
|
+
[permChanged, stranded, opaque] = reconcileOpencodePermission(get(config, 'permission'),
|
|
367
|
+
defaultPermission(doctrines));
|
|
368
|
+
}
|
|
369
|
+
// The shape said no. This is the direction the residue warning above is NOT — a rule stated
|
|
370
|
+
// in AGENT.md with nothing behind it — and it used to pass in silence, because a blanket
|
|
371
|
+
// `"permission": {"bash": "allow"}` makes every reconcile a no-op and the whole merge then
|
|
372
|
+
// short-circuits before any of the three WARNs below. Reported, never rewritten: the blanket
|
|
373
|
+
// policy is the user's answer and it wins (`generate.test.mjs` pins that it survives).
|
|
374
|
+
if (opaque) {
|
|
375
|
+
process.stderr.write(`[geneseed] WARN: "permission" in ${path.basename(target)} `
|
|
376
|
+
+ `${opaque}, so Geneseed cannot wire its own gates into it — including `
|
|
377
|
+
+ `${OWNED_BASH.filter((k) => has(defaultPermission(doctrines).bash, k))
|
|
378
|
+
.map((k) => jsonDumps(k)).join(', ')}. Your policy is left exactly as written; the `
|
|
379
|
+
+ 'harness states those rules but nothing enforces them at this boundary.\n');
|
|
380
|
+
}
|
|
381
|
+
// Told, not silently left: these are gates for a rule this build does not make binding, kept
|
|
382
|
+
// because Geneseed cannot prove it was the one that wrote them. stderr, like the other two
|
|
383
|
+
// WARNs here — the emit's stdout is byte-compared by the golden corpus.
|
|
384
|
+
if (stranded.length) {
|
|
385
|
+
process.stderr.write(`[geneseed] WARN: ${path.basename(target)} keeps `
|
|
386
|
+
+ `${stranded.map((k) => jsonDumps(k)).join(', ')} under "permission".bash — the `
|
|
387
|
+
+ 'doctrine pack behind those gates is off in this build, but Geneseed cannot tell its '
|
|
388
|
+
+ 'own entry from one you wrote, so it removes neither. Delete them by hand if you want '
|
|
389
|
+
+ 'them gone.\n');
|
|
390
|
+
}
|
|
243
391
|
const addLsp = !has(config, 'lsp');
|
|
244
392
|
if (addLsp) config.lsp = true;
|
|
245
|
-
if (!addInstr && !
|
|
393
|
+
if (!addInstr && !permChanged && !addLsp) return target;
|
|
246
394
|
if (path.extname(target) === '.jsonc' && hadComments) {
|
|
247
|
-
|
|
395
|
+
// ⚠ THE TWO CASES GIVE OPPOSITE ADVICE, and telling a file that HAS a `permission` key to
|
|
396
|
+
// add one is how the first cut of this got it wrong. `add` = there is no block, here is the
|
|
397
|
+
// whole one; `reconcile` = there is a block and it is missing gates this build wires, and
|
|
398
|
+
// this branch cannot write them because rewriting would drop the user's comments.
|
|
399
|
+
//
|
|
400
|
+
// DECIDED, NOT DEFERRED: a commented `.jsonc` is never rewritten and never will be. A
|
|
401
|
+
// comment-preserving JSONC writer is a parser, an AST and a printer — a dependency-free
|
|
402
|
+
// one, since this package ships zero — to keep formatting nobody asked us to touch, in a
|
|
403
|
+
// file Geneseed co-owns rather than owns. The advice above is the whole remedy: the exact
|
|
404
|
+
// block to paste, split by which of the two situations the reader is actually in. It is
|
|
405
|
+
// stdout, not stderr, because on this path the paste-in IS the output.
|
|
406
|
+
warnCommentedJsonc(target, agentPath, addPerm ? 'add' : (permChanged && 'reconcile'),
|
|
407
|
+
addLsp, 'geneseed', doctrines);
|
|
248
408
|
return target;
|
|
249
409
|
}
|
|
250
410
|
try {
|
|
@@ -294,6 +454,19 @@ export const SHIM_MARK = 'geneseed-hook';
|
|
|
294
454
|
*/
|
|
295
455
|
export const SHIM_ARGV = new Set(['$@', '%*']);
|
|
296
456
|
|
|
457
|
+
/**
|
|
458
|
+
* Every quoted token in a shim body that names a path which is not there — empty is healthy.
|
|
459
|
+
*
|
|
460
|
+
* ONE OWNER, because the rule has two consumers now and a second copy is a second place for the
|
|
461
|
+
* `"$@"` defect above to come back: `js/doctor.mjs` turns this into the `[shim] … does not
|
|
462
|
+
* exist` report, and `hookPrefix` reads it to decide whether the shim already on disk is worth
|
|
463
|
+
* protecting from a checkout that will not outlive the emit.
|
|
464
|
+
*/
|
|
465
|
+
export function shimDeadPaths(body) {
|
|
466
|
+
return [...body.matchAll(/"([^"]+)"/g)].map((m) => m[1])
|
|
467
|
+
.filter((q) => !SHIM_ARGV.has(q) && !existsSync(q));
|
|
468
|
+
}
|
|
469
|
+
|
|
297
470
|
/** `_SHIM_REL`, with the platform as an argument.
|
|
298
471
|
*
|
|
299
472
|
* Python computes it at IMPORT time from `sys.platform`, which is what makes the other
|
|
@@ -401,6 +574,50 @@ export function writeHookShim(p, body, platform = process.platform) {
|
|
|
401
574
|
}
|
|
402
575
|
}
|
|
403
576
|
|
|
577
|
+
/** The OS temp root, resolved once. `.native` because a Windows `TEMP` can be an 8.3 alias —
|
|
578
|
+
* the same trap `tests/helpers/sandbox.mjs` documents, reached from the other side. */
|
|
579
|
+
const TMP_REAL = (() => {
|
|
580
|
+
try { return realpathSync.native(os.tmpdir()); } catch { return os.tmpdir(); }
|
|
581
|
+
})();
|
|
582
|
+
|
|
583
|
+
/** Is `child` inside `parent`? `path.relative` compares case-insensitively on win32. */
|
|
584
|
+
function isUnder(child, parent) {
|
|
585
|
+
const rel = path.relative(parent, child);
|
|
586
|
+
return rel !== '' && !rel.startsWith('..') && !path.isAbsolute(rel);
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
/**
|
|
590
|
+
* Will the checkout holding `entry` outlive the emit that is baking it into the shim?
|
|
591
|
+
*
|
|
592
|
+
* TWO SHAPES, and both are in `docs/extending.md` §5.3 because both have really happened on this
|
|
593
|
+
* repo: a copied checkout under the OS temp root — the `copyCheckout` fixture, which builds its
|
|
594
|
+
* copy from `git ls-files` and therefore carries no `.git` at all, so only the temp root
|
|
595
|
+
* identifies it — and a git WORKTREE, whose `.git` is a FILE pointing at the main checkout
|
|
596
|
+
* rather than a directory. A plain checkout and an npm install are neither, and keep the
|
|
597
|
+
* ownership they have always had.
|
|
598
|
+
*
|
|
599
|
+
* `tmpRoot` IS A PARAMETER for the reason `shimRel`'s `platform` is: with the real temp root
|
|
600
|
+
* baked in, the second rule is unreachable in a test — anything a test can build under `mkdtemp`
|
|
601
|
+
* has already answered true on the first — so the worktree arm could only ever be exercised by
|
|
602
|
+
* whichever kind of checkout the run happened to sit in.
|
|
603
|
+
*/
|
|
604
|
+
export function ephemeralCheckout(entry, tmpRoot = TMP_REAL) {
|
|
605
|
+
let real = entry;
|
|
606
|
+
try { real = realpathSync.native(entry); } catch { /* not there yet — test the literal */ }
|
|
607
|
+
if (isUnder(real, tmpRoot)) return true;
|
|
608
|
+
// `<checkout>/bin/geneseed-hook.mjs` — the one shape `hookRunnerEntry` produces.
|
|
609
|
+
try { return statSync(path.join(path.dirname(path.dirname(real)), '.git')).isFile(); } catch {
|
|
610
|
+
return false;
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
/** Does the shim already on disk still name paths that all exist? */
|
|
615
|
+
function shimIsLive(p) {
|
|
616
|
+
try { return statSync(p).isFile() && shimDeadPaths(readText(p)).length === 0; } catch {
|
|
617
|
+
return false;
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
|
|
404
621
|
/**
|
|
405
622
|
* `_build_settings._hook_prefix` — the `<runner> <entrypoint>` every emitted hook starts
|
|
406
623
|
* with, falling back to the pre-shim direct form when the shim cannot be written.
|
|
@@ -422,6 +639,26 @@ export function hookPrefix({ runner, entry, platform = process.platform } = {})
|
|
|
422
639
|
+ 'bakes them, and there is no correct value to guess from inside Node');
|
|
423
640
|
}
|
|
424
641
|
const p = hookShimPath(platform);
|
|
642
|
+
// ⚠ A DISPOSABLE CHECKOUT DOES NOT GET TO CLAIM THE MACHINE-WIDE SHIM. The shim has no
|
|
643
|
+
// per-install component, so the last checkout to emit owns EVERY install's hooks — and when
|
|
644
|
+
// that checkout is a test sandbox or a git worktree, deleting it kills hooks machine-wide with
|
|
645
|
+
// nothing to report it: hooks signal through stdout and return 0 on every path, and this file
|
|
646
|
+
// is excluded from the byte corpora by name. Measured twice in one session, from
|
|
647
|
+
// `tests/unit/harness.test.mjs`'s copied-checkout fixture; `docs/extending.md` §5.3 carries
|
|
648
|
+
// the worktree half.
|
|
649
|
+
//
|
|
650
|
+
// KEEP, NOT REFUSE, and that distinction is what makes this safe to add here rather than at
|
|
651
|
+
// each call site. Returning the existing shim path leaves the emitted hook command
|
|
652
|
+
// byte-identical to what a normal emit writes; taking the fallback below instead would move a
|
|
653
|
+
// hook command in every recorded bundle. The install being emitted then runs the DURABLE
|
|
654
|
+
// checkout's entry — which is exactly what last-writer-wins already handed every other install
|
|
655
|
+
// on the machine, so nothing is lost by it.
|
|
656
|
+
//
|
|
657
|
+
// ONLY A SHIM THAT STILL RESOLVES IS PROTECTED. An absent or already-dead one is no worse for
|
|
658
|
+
// being rewritten from here, and a sandboxed `GENESEED_HOME` (every emit test, every cell) has
|
|
659
|
+
// none — so the suite's own emits are unaffected and this cannot go green by silently
|
|
660
|
+
// skipping the write.
|
|
661
|
+
if (ephemeralCheckout(entry) && shimIsLive(p)) return `"${p}"`;
|
|
425
662
|
const shim = writeHookShim(p, hookShimBody(runner, entry, platform), platform);
|
|
426
663
|
if (shim !== null) return `"${shim}"`;
|
|
427
664
|
process.stderr.write(`[geneseed] WARN: could not write the hook shim at ${p} — emitting `
|
|
@@ -430,8 +667,38 @@ export function hookPrefix({ runner, entry, platform = process.platform } = {})
|
|
|
430
667
|
return `"${runner}" "${entry}"`;
|
|
431
668
|
}
|
|
432
669
|
|
|
433
|
-
/**
|
|
434
|
-
|
|
670
|
+
/**
|
|
671
|
+
* `_build_settings._claude_hook_groups` — Geneseed's Claude hooks, keyed by event.
|
|
672
|
+
*
|
|
673
|
+
* ⚠ `doctrines` IS WHERE PROMPT AND BOUNDARY ARE KEPT IN AGREEMENT. The `git-gate` group is
|
|
674
|
+
* the tool-boundary half of doctrine process 5. With the process pack off, that rule is no
|
|
675
|
+
* longer BINDING — `src/doctrines/process.md` is not rendered into the carrier's doctrine
|
|
676
|
+
* section and nothing in the constitution obliges the agent to it — so a gate that stopped
|
|
677
|
+
* every Bash call to ask for consent to a rule the install did not adopt is the disagreement
|
|
678
|
+
* the three-tier split forbids. Omitting the group here is also what UNWIRES it from an
|
|
679
|
+
* install that had it: `mergeClaudeSettings` prunes every previously-managed group that is not
|
|
680
|
+
* in this return value, so flipping the pack off and rebuilding takes the hook out of
|
|
681
|
+
* settings.json rather than leaving it orphaned.
|
|
682
|
+
*
|
|
683
|
+
* ⚠ WHAT THIS DOES **NOT** CLAIM, because it was written here once and it was false: that the
|
|
684
|
+
* string `process 5` is absent from a pack-off bundle. It is not — 18 emitted files still cite
|
|
685
|
+
* it (`grep -rl 'process 5' <off-bundle>`), and by owner decision D5 that is deliberate: the
|
|
686
|
+
* whole pack CATALOGUE ships in every build (`<bundle>/doctrines/process.md` is byte-identical
|
|
687
|
+
* on and off), so body-prose citations from craft, from the agents and from the skills stay
|
|
688
|
+
* RESOLVABLE rather than dangling. A citation is not an obligation.
|
|
689
|
+
*
|
|
690
|
+
* The one place that distinction is thin is `skills/commit.md`, which carries the operative
|
|
691
|
+
* procedure ("get explicit consent before committing") and not merely a pointer. It is left
|
|
692
|
+
* unconditional ON PURPOSE: a skill that asks before committing when nothing compelled it is
|
|
693
|
+
* never unsafe, whereas conditionalising it would put a second copy of the pack switch in the
|
|
694
|
+
* prose layer for no gain in safety. Asymmetric risk, asymmetric answer — the BOUNDARY follows
|
|
695
|
+
* the pack, the ASKING does not have to.
|
|
696
|
+
*
|
|
697
|
+
* The `rule-gate` group stays in every build. Its subject is which durable store a write
|
|
698
|
+
* belongs in — the user's call — not how work is run, so it is not the process pack's to
|
|
699
|
+
* remove. `default` here means unknown, and unknown keeps the gate (see `processPackOn`).
|
|
700
|
+
*/
|
|
701
|
+
export function claudeHookGroups(cfg, hookOpts, doctrines = null) {
|
|
435
702
|
const run = hookPrefix(hookOpts);
|
|
436
703
|
const mem = `--memory "${path.join(cfg, 'memory')}"`;
|
|
437
704
|
// --root carries the install's own dir so a GLOBAL hook can stand down when a project
|
|
@@ -442,7 +709,8 @@ export function claudeHookGroups(cfg, hookOpts) {
|
|
|
442
709
|
const learn = `${run} learn ${mem} || exit 0`;
|
|
443
710
|
return {
|
|
444
711
|
PreToolUse: [
|
|
445
|
-
|
|
712
|
+
...(processPackOn(doctrines)
|
|
713
|
+
? [{ matcher: 'Bash', hooks: [{ type: 'command', command: gate }] }] : []),
|
|
446
714
|
{
|
|
447
715
|
matcher: 'Write|Edit|MultiEdit|NotebookEdit',
|
|
448
716
|
hooks: [{ type: 'command', command: ruleGate }],
|
|
@@ -472,7 +740,8 @@ export function claudeHookGroups(cfg, hookOpts) {
|
|
|
472
740
|
*
|
|
473
741
|
* `scope` is accepted and unused, exactly as in the Python.
|
|
474
742
|
*/
|
|
475
|
-
export function mergeClaudeSettings(p, scope = 'global', priorHooks = null, hookOpts = {}
|
|
743
|
+
export function mergeClaudeSettings(p, scope = 'global', priorHooks = null, hookOpts = {},
|
|
744
|
+
doctrines = null) {
|
|
476
745
|
const prior = (priorHooks || []).filter(isDict);
|
|
477
746
|
let config = {};
|
|
478
747
|
let hadComments = false;
|
|
@@ -492,7 +761,7 @@ export function mergeClaudeSettings(p, scope = 'global', priorHooks = null, hook
|
|
|
492
761
|
}
|
|
493
762
|
let hooks = get(config, 'hooks');
|
|
494
763
|
if (!isDict(hooks)) hooks = {};
|
|
495
|
-
const canonical = claudeHookGroups(path.dirname(p), hookOpts);
|
|
764
|
+
const canonical = claudeHookGroups(path.dirname(p), hookOpts, doctrines);
|
|
496
765
|
const canonFlat = [];
|
|
497
766
|
for (const [event, groups] of Object.entries(canonical)) {
|
|
498
767
|
for (const g of groups) canonFlat.push({ event, group: g });
|