geneseed 2.1.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 +108 -0
- package/DESIGN.md +112 -61
- package/QUICKSTART.md +1 -1
- package/README.md +24 -14
- package/SETUP.md +83 -17
- package/SHIPPED.md +5 -4
- 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/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 +72 -6
- package/docs/extending.md +164 -55
- package/docs/limits.md +4 -4
- package/docs/token-footprint.md +15 -4
- 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/harness.config.json +1 -1
- package/js/catalog.mjs +29 -4
- package/js/checkout.mjs +24 -1
- package/js/cli-table.json +8 -6
- package/js/diff.mjs +8 -3
- package/js/doctor.mjs +307 -4
- 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 -21
- package/js/migrate.mjs +10 -4
- package/js/render.mjs +110 -7
- package/js/settings.mjs +218 -27
- package/js/setup.mjs +95 -5
- package/js/status.mjs +45 -4
- package/js/tui.mjs +44 -1
- package/js/uninstall.mjs +25 -6
- package/js/web/actions.mjs +56 -5
- 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 +6 -0
- 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 -336
- 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 -36
- package/themes/biker.json +37 -36
- package/themes/commentator.json +37 -36
- package/themes/cyberpunk.json +37 -36
- package/themes/gamer.json +37 -36
- package/themes/imperial.json +37 -36
- package/themes/joker.json +37 -36
- package/themes/marvin.json +37 -36
- package/themes/mean.json +37 -36
- package/themes/military.json +37 -36
- package/themes/neutral.json +37 -36
- package/themes/pirate.json +37 -36
- package/themes/sports.json +37 -36
- package/themes/verstappen.json +37 -36
- package/themes/wizard.json +37 -36
- package/web/dist/assets/{About-BYXlmbyo.js → About-C0Mk0igT.js} +1 -1
- package/web/dist/assets/{Activity-us5MPf4u.js → Activity-C4aJJVXz.js} +1 -1
- package/web/dist/assets/{ActivityDetail-CiaQXUk2.js → ActivityDetail-DaQ85za0.js} +1 -1
- package/web/dist/assets/{Diff-Bh0qbZ2s.js → Diff-B3tlO2Yj.js} +1 -1
- package/web/dist/assets/{Doctor-BIlQME45.js → Doctor-BNC-5Dmo.js} +1 -1
- package/web/dist/assets/{Graph-4dYD1dPG.js → Graph-us0T6ZcV.js} +1 -1
- package/web/dist/assets/{Harnesses-PzYZ3LOC.js → Harnesses-DAMgyfM_.js} +1 -1
- package/web/dist/assets/Laws-rOxegJ75.js +1 -0
- package/web/dist/assets/{Library-CjMbksJW.js → Library-KmwrHA1J.js} +1 -1
- package/web/dist/assets/{Markdown-Bk81c3aZ.js → Markdown-0eQOVGSF.js} +1 -1
- package/web/dist/assets/{Profile-Curui_-j.js → Profile-ZU7WV6Xl.js} +1 -1
- package/web/dist/assets/{Rules-Bm283wdv.js → Rules-CtDoPhhf.js} +1 -1
- package/web/dist/assets/{Skills-gruqYuhl.js → Skills-BhoOxdkD.js} +1 -1
- package/web/dist/assets/{StatusBadge-EiM_FPqv.js → StatusBadge-CGvYVCwa.js} +1 -1
- package/web/dist/assets/{Themes-DU99j4Hr.js → Themes-Dr7OOkD7.js} +1 -1
- package/web/dist/assets/{activity-0rvQ6Vra.js → activity-B8aq3qtl.js} +1 -1
- package/web/dist/assets/index-2wqPDxuN.css +1 -0
- package/web/dist/assets/{index-0b2hobOo.js → index-AhMzkdbI.js} +1 -1
- package/web/dist/assets/index-CRftEtBX.js +12 -0
- package/web/dist/assets/{index-IiGFmCW_.js → index-D0lPN_qW.js} +1 -1
- package/web/dist/index.html +2 -2
- package/web/src/pages/Laws.jsx +282 -91
- package/web/dist/assets/Laws-DtZweXal.js +0 -1
- package/web/dist/assets/index-D5VY3TY_.js +0 -12
- package/web/dist/assets/index-DUCsDv7z.css +0 -1
package/js/installs.mjs
CHANGED
|
@@ -35,7 +35,7 @@
|
|
|
35
35
|
import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
|
|
36
36
|
import path from 'node:path';
|
|
37
37
|
|
|
38
|
-
import { CONFIG, THEMES, discoverNames } from './checkout.mjs';
|
|
38
|
+
import { CONFIG, PACK_ORDER, THEMES, discoverNames } from './checkout.mjs';
|
|
39
39
|
import { GLOBAL_MANIFEST, HOSTS, pyResolve } from './hosts.mjs';
|
|
40
40
|
import { registryRoots } from './registry.mjs';
|
|
41
41
|
import { comparePaths, pyPrintErr, pyRepr, readText } from './lib/fs.mjs';
|
|
@@ -91,6 +91,17 @@ export const defaultTheme = () => configuredDefault('theme', 'neutral');
|
|
|
91
91
|
export const defaultPosture = () => configuredDefault('posture', 'peer');
|
|
92
92
|
export const defaultMode = () => configuredDefault('mode', 'direct');
|
|
93
93
|
|
|
94
|
+
// There is deliberately no `defaultDoctrines()` beside the three above, and its absence is
|
|
95
|
+
// the invariant. `theme`, `posture` and `mode` may fall back to `harness.config.json` when an
|
|
96
|
+
// install does not say, because the worst a wrong answer does there is cosmetic. The pack
|
|
97
|
+
// selection is not like them: it decides whether the commit/push consent gate is wired, so
|
|
98
|
+
// resolving "this install does not say" out of a MACHINE-WIDE config file narrows every
|
|
99
|
+
// pre-2.3 install on its first upgrade — `{"doctrines":["craft"]}` and the gate is gone from
|
|
100
|
+
// carriers that never asked. Unknown resolves to ALL packs; see `doctrinesForBuild` below.
|
|
101
|
+
// The config value keeps exactly one legitimate reader: `configDefaults()` in
|
|
102
|
+
// `bin/geneseed.mjs`, which answers `geneseed build` — the case where there is no install to
|
|
103
|
+
// ask in the first place.
|
|
104
|
+
|
|
94
105
|
// ---- theme, footprint, posture, mode, read back off a deployed tree -----------------------
|
|
95
106
|
|
|
96
107
|
/**
|
|
@@ -159,7 +170,8 @@ const FOOTPRINTS = ['lean', 'full'];
|
|
|
159
170
|
*
|
|
160
171
|
* `status` reads no footprint row at all; the WARN is why this function is ported there, and
|
|
161
172
|
* `diff` and `rebuild-all` read the RETURN value as well. An unrecognised marker is reported
|
|
162
|
-
* rather than swallowed (Rule
|
|
173
|
+
* rather than swallowed (Rule V, *Surface Failures* — VII before the three-tier renumber, and
|
|
174
|
+
* VII now names *Least Privilege*), and a compared stderr is what makes that observable —
|
|
163
175
|
* `status/a-bogus-footprint-marker-warns-on-stderr` is the cell.
|
|
164
176
|
*/
|
|
165
177
|
export function footprintOfDir(d) {
|
|
@@ -208,6 +220,76 @@ function leadOfDir(d, names) {
|
|
|
208
220
|
export const postureOfDir = (d) => leadOfDir(d, discoverNames('postures', 'peer'));
|
|
209
221
|
export const modeOfDir = (d) => leadOfDir(d, discoverNames('modes', 'direct'));
|
|
210
222
|
|
|
223
|
+
/** The `Active packs:` marker line the template emits, in one of the five carriers. */
|
|
224
|
+
const ACTIVE_PACKS_RE = /^Active packs:[ \t]*(.+?)[ \t]*$/m;
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* The doctrine packs a DEPLOYED install carries — the third register read back off disk,
|
|
228
|
+
* beside `postureOfDir` and `modeOfDir`, and the first that is a SET.
|
|
229
|
+
*
|
|
230
|
+
* MARKERED IN PROSE, like posture and mode and unlike theme/footprint: `src/AGENT.md.tmpl`
|
|
231
|
+
* writes a literal `Active packs: <list>` line under the doctrines section, so no extra dotfile
|
|
232
|
+
* has to survive a copy. The label is NOT token-substituted, so a themed bundle still answers.
|
|
233
|
+
*
|
|
234
|
+
* ⚠ THREE RETURN STATES, AND THE NULL IS THE SAFETY ONE.
|
|
235
|
+
* - `null` — no marker in any carrier. That is a pre-migration install, or a carrier that
|
|
236
|
+
* never had one, and it means "unknown", NOT "no packs". Every consumer must read it as
|
|
237
|
+
* the process pack being ACTIVE, so the commit/push consent gate stays installed. Write
|
|
238
|
+
* `doctrinesOfDir(root)` into an explicit `=== null` / `??` test — never
|
|
239
|
+
* `doctrinesOfDir(root)?.includes('process')`, which is falsy on `null` and would strip
|
|
240
|
+
* the gate from every existing install on its next upgrade.
|
|
241
|
+
* - `[]` — the marker is present and reads `none`. A deliberate empty selection (invariants
|
|
242
|
+
* and ontology only) and a real configuration: the gate comes OFF.
|
|
243
|
+
* - a list — the packs named, re-ordered through `PACK_ORDER`, because this value is
|
|
244
|
+
* compared against a marker the build writes in that order.
|
|
245
|
+
*
|
|
246
|
+
* A marker line this checkout cannot read WHOLE falls back to `null` rather than to whatever
|
|
247
|
+
* part of it did parse, for the same reason: a corrupt line is unknown, and unknown fails
|
|
248
|
+
* closed. ⚠ "Whole" is the strict word and it is what the first cut of this got wrong. The
|
|
249
|
+
* regex is single-line, so a marker a wrapper had folded — `Active packs: craft, rigor,\nops,
|
|
250
|
+
* process` — matched its first half, every name in that half was known, and the function
|
|
251
|
+
* answered the NARROWED set `['craft','rigor']` with no complaint. That is fail-OPEN against
|
|
252
|
+
* this docblock's own promise: the process pack disappears from an install that has it, and
|
|
253
|
+
* the gate goes with it. So every comma-separated field must be non-empty AND a pack this
|
|
254
|
+
* checkout ships; one that is not condemns the whole line to `null`. A trailing comma is
|
|
255
|
+
* exactly the empty field a fold leaves behind, which is what makes this catch it.
|
|
256
|
+
*/
|
|
257
|
+
export function doctrinesOfDir(d) {
|
|
258
|
+
for (const carrier of CARRIERS) {
|
|
259
|
+
const text = readMaybe(path.join(d, carrier));
|
|
260
|
+
if (text === null) continue;
|
|
261
|
+
const m = ACTIVE_PACKS_RE.exec(text);
|
|
262
|
+
if (!m) continue;
|
|
263
|
+
if (m[1] === 'none') return [];
|
|
264
|
+
const named = m[1].split(',').map((s) => s.trim());
|
|
265
|
+
if (!named.every((n) => PACK_ORDER.includes(n))) return null;
|
|
266
|
+
return PACK_ORDER.filter((p) => named.includes(p));
|
|
267
|
+
}
|
|
268
|
+
return null;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* `doctrinesOfDir` with the `null` RESOLVED — the one spelling every build-side consumer uses.
|
|
273
|
+
*
|
|
274
|
+
* ⚠ UNKNOWN RESOLVES TO ALL PACKS, NEVER TO A CONFIG VALUE. `doctrinesOfDir`'s docblock puts
|
|
275
|
+
* the rule on the reader; this function is what makes it true of the readers, because the
|
|
276
|
+
* previous shape (`doctrinesOfDir(root) ?? defaultDoctrines()`) left every consumer free to
|
|
277
|
+
* pick its own fallback and all five of them picked `harness.config.json`. With
|
|
278
|
+
* `{"doctrines":["craft"]}` in that file, an `upgrade`/`rebuild-all`/`migrate` re-emitted every
|
|
279
|
+
* pre-2.3 install at ONE pack and took the commit/push consent gate off installs whose owners
|
|
280
|
+
* had never been asked — the exact outcome the fail-closed reading exists to prevent, and it
|
|
281
|
+
* got WIDER when the reader was hardened, because more inputs now answer `null`.
|
|
282
|
+
*
|
|
283
|
+
* A config value is a legitimate default only where there is NO INSTALL TO ASK: a fresh
|
|
284
|
+
* `geneseed build`, which `configDefaults()` in `bin/geneseed.mjs` answers. Anything holding a
|
|
285
|
+
* directory has an install to ask, so it calls this and never the config.
|
|
286
|
+
*
|
|
287
|
+
* `[]` still passes through untouched — a marker that reads `none` is an answer, not a silence.
|
|
288
|
+
*/
|
|
289
|
+
export function doctrinesForBuild(d) {
|
|
290
|
+
return doctrinesOfDir(d) ?? [...PACK_ORDER];
|
|
291
|
+
}
|
|
292
|
+
|
|
211
293
|
// ---- what host a deployed dir belongs to (`_harness_mcp`) ---------------------------------
|
|
212
294
|
|
|
213
295
|
/** `_harness_mcp._claude_read_manifest`. */
|
|
@@ -333,7 +415,7 @@ export function installTargets() {
|
|
|
333
415
|
// ---- what this machine installed (`_harness_setup._installed_defaults`) --------------------
|
|
334
416
|
|
|
335
417
|
/**
|
|
336
|
-
* `_harness_setup._installed_defaults` — all
|
|
418
|
+
* `_harness_setup._installed_defaults` — all six keys.
|
|
337
419
|
*
|
|
338
420
|
* It carried only `theme` and `emit` for four phases because those are the two `status`
|
|
339
421
|
* prints and the two `doctor` reads, and the port's keep-vs-delete rule is "part of an
|
|
@@ -342,9 +424,20 @@ export function installTargets() {
|
|
|
342
424
|
* `inst.posture` / `inst.mode` / `inst.footprint`, so a three-key answer would have silently
|
|
343
425
|
* pre-selected the CONFIGURED default over the DEPLOYED one on three of its five questions.
|
|
344
426
|
* Nothing in `status` or `doctor` moves: both read the keys they already read.
|
|
427
|
+
*
|
|
428
|
+
* `doctrines` joined last, for the same reason and one phase later: the wizard's pack question
|
|
429
|
+
* shipped before the marker reader existed, so it defaulted to `all` for everyone and re-asked
|
|
430
|
+
* an installer who had already narrowed their selection. ⚠ THIS KEY IS THE ONE WITH THREE
|
|
431
|
+
* STATES, and the `=== null` tests below preserve all three: `null` is "no carrier on this
|
|
432
|
+
* machine said", `[]` is a carrier that said `none`, a list is a carrier that named packs.
|
|
433
|
+
* Only a WIZARD may read it — it is a pre-selection shown to a human who then answers. No
|
|
434
|
+
* build-side consumer resolves a pack set from here; that is `doctrinesForBuild(dir)`, which
|
|
435
|
+
* asks one named install rather than whichever candidate this walk happens to reach first.
|
|
345
436
|
*/
|
|
346
437
|
export function installedDefaults() {
|
|
347
|
-
const found = {
|
|
438
|
+
const found = {
|
|
439
|
+
theme: null, posture: null, mode: null, emit: null, footprint: null, doctrines: null,
|
|
440
|
+
};
|
|
348
441
|
const candidates = [];
|
|
349
442
|
for (const { host, configDir } of HOSTS) {
|
|
350
443
|
try { candidates.push([configDir(), `${host}-global`]); } catch { /* as the Python */ }
|
|
@@ -373,6 +466,10 @@ export function installedDefaults() {
|
|
|
373
466
|
if (found.footprint === null && isFile(path.join(base, '.geneseed-footprint'))) {
|
|
374
467
|
found.footprint = footprintOfDir(base);
|
|
375
468
|
}
|
|
469
|
+
// `doctrinesOfDir` and not `doctrinesForBuild`: the resolved form answers `[...PACK_ORDER]`
|
|
470
|
+
// for a carrier with no marker, which would end this walk at the FIRST candidate and hide
|
|
471
|
+
// a later carrier that does say. The null has to survive to the caller here.
|
|
472
|
+
if (found.doctrines === null) found.doctrines = doctrinesOfDir(base);
|
|
376
473
|
}
|
|
377
474
|
return found;
|
|
378
475
|
}
|
package/js/inventory.mjs
CHANGED
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
*/
|
|
22
22
|
import path from 'node:path';
|
|
23
23
|
|
|
24
|
-
import { ROOT, SRC, makeCfg } from './checkout.mjs';
|
|
24
|
+
import { ROOT, SRC, makeCfg, PACK_ORDER } from './checkout.mjs';
|
|
25
25
|
import { firstBlockquote } from './native.mjs';
|
|
26
26
|
import { renderAll } from './render.mjs';
|
|
27
27
|
import { pyResolve } from './hosts.mjs';
|
|
@@ -29,30 +29,59 @@ import { parseJson, readText } from './lib/fs.mjs';
|
|
|
29
29
|
|
|
30
30
|
/**
|
|
31
31
|
* `_harness_tui.LAW_HEADING_RE` — a heading in the RENDERED laws file, e.g.
|
|
32
|
-
* `### Rule
|
|
32
|
+
* `### Rule IV — Deletion Is Deliberate`.
|
|
33
33
|
*
|
|
34
34
|
* Not `js/doctor.mjs`'s `LAW_HEADING_RE`, which matches the `{{LAW}}` token in the
|
|
35
35
|
* unrendered source. Two patterns, two inputs, two names in Python as well.
|
|
36
36
|
*/
|
|
37
37
|
export const LAW_HEADING_RE = /^###\s+\S+\s+([IVXLCDM]+)\s+[—-]\s+(.+?)\s*$/;
|
|
38
38
|
|
|
39
|
+
/**
|
|
40
|
+
* A heading in a RENDERED doctrine pack, e.g. `### Doctrine craft 1 — Automate Repetition`.
|
|
41
|
+
*
|
|
42
|
+
* THE TIER NOUN IS THEMED AND THE ADDRESS IS NOT, exactly as the invariants work: `{{LAW}}`
|
|
43
|
+
* plus a Roman numeral there, `{{DOCTRINE}}` plus `<pack> <n>` here. So `\S+` for the noun —
|
|
44
|
+
* ⚠ which is why a theme's `DOCTRINE` value must be ONE WORD, and why `doctor` asserts it in
|
|
45
|
+
* all fourteen. A two-word noun does not error; it silently stops matching, and a pack quietly
|
|
46
|
+
* parses to zero rules.
|
|
47
|
+
*
|
|
48
|
+
* It cannot collide with `LAW_HEADING_RE` in either direction and the reason is case:
|
|
49
|
+
* `([a-z]+)` refuses `Rule I`'s uppercase numeral, and `([IVXLCDM]+)` refuses a lowercase pack
|
|
50
|
+
* id. Measured across all fourteen themes — zero cross-matches either way.
|
|
51
|
+
*/
|
|
52
|
+
export const DOCTRINE_HEADING_RE = /^###\s+\S+\s+([a-z]+)\s+(\d+)\s+[—-]\s+(.+?)\s*$/;
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* A section heading in the RENDERED ontology, e.g. `#### Telos`.
|
|
56
|
+
*
|
|
57
|
+
* FOUR HASHES, and that is the whole reason the ontology can share `universal.md`'s basename
|
|
58
|
+
* with the laws file without either parser seeing the other's headings. The section names come
|
|
59
|
+
* from `STRUCTURE` rather than from a theme, so the id below is stable across all fourteen
|
|
60
|
+
* voices and is safe to put in a deep link.
|
|
61
|
+
*/
|
|
62
|
+
export const ONTOLOGY_HEADING_RE = /^####\s+(.+?)\s*$/;
|
|
63
|
+
|
|
64
|
+
/** The lead line of a pack file — `**Craft** — how code is written.` */
|
|
65
|
+
const PACK_LEAD_RE = /^\*\*(.+?)\*\*\s+[—-]\s+(.+?)\s*$/;
|
|
66
|
+
|
|
39
67
|
/** `_harness_tui.LAW_CLASS` — each rule's class, keyed by Roman numeral. */
|
|
40
68
|
export const LAW_CLASS = {
|
|
41
|
-
I: 'security', II: 'process', III: 'verify', IV: 'security', V: '
|
|
42
|
-
VI: '
|
|
43
|
-
XI: 'craft', XII: 'craft', XIII: 'craft', XIV: 'process', XV: 'process',
|
|
44
|
-
XVI: 'context', XVII: 'context', XVIII: 'context', XIX: 'context', XX: 'security',
|
|
45
|
-
XXI: 'process', XXII: 'security', XXIII: 'security', XXIV: 'craft', XXV: 'craft',
|
|
46
|
-
XXVI: 'craft', XXVII: 'verify', XXVIII: 'process', XXIX: 'comms', XXX: 'comms',
|
|
47
|
-
XXXI: 'comms', XXXII: 'craft', XXXIII: 'craft', XXXIV: 'verify', XXXV: 'verify',
|
|
48
|
-
XXXVI: 'security', XXXVII: 'process', XXXVIII: 'verify', XXXIX: 'comms',
|
|
49
|
-
XL: 'comms',
|
|
69
|
+
I: 'security', II: 'process', III: 'verify', IV: 'security', V: 'verify',
|
|
70
|
+
VI: 'security', VII: 'security', VIII: 'craft', IX: 'security',
|
|
50
71
|
};
|
|
51
72
|
|
|
52
73
|
/** `_harness_tui.ENTITY_STATUSES` — the lifecycle statuses `registry.json` may carry. */
|
|
53
74
|
export const ENTITY_STATUSES = ['experimental', 'approved', 'deprecated'];
|
|
54
75
|
|
|
55
|
-
/**
|
|
76
|
+
/**
|
|
77
|
+
* `_harness_tui.LAW_CLASSES` — the six governance classes a law may carry.
|
|
78
|
+
*
|
|
79
|
+
* ⚠ SIX, THOUGH ONLY FOUR ARE IN USE. When the corpus became nine invariants, `context` and
|
|
80
|
+
* `comms` stopped having a member — that material moved to the ontology and the doctrine packs,
|
|
81
|
+
* neither of which is classed here. The list stays six because it is the VOCABULARY, not a
|
|
82
|
+
* census: `doctor` quotes it verbatim in the message a bad class earns, and two recorded cells
|
|
83
|
+
* in `tests/helpers/matrix/cli.*.json` hold that message byte for byte.
|
|
84
|
+
*/
|
|
56
85
|
export const LAW_CLASSES = ['security', 'process', 'verify', 'craft', 'context', 'comms'];
|
|
57
86
|
|
|
58
87
|
/** `_harness_tui.SKILL_CLASS` — each skill's category, keyed by file stem. */
|
|
@@ -106,6 +135,68 @@ export function parseLaws(text) {
|
|
|
106
135
|
return laws;
|
|
107
136
|
}
|
|
108
137
|
|
|
138
|
+
/**
|
|
139
|
+
* One rendered doctrine pack file split into `{pack, n, title, klass, body}` rows.
|
|
140
|
+
*
|
|
141
|
+
* Same accumulator as `parseLaws`, and deliberately so: text before the first heading — the
|
|
142
|
+
* pack's own lead line — is dropped on the floor because `cur` is still null, and each body is
|
|
143
|
+
* trimmed at its ends only.
|
|
144
|
+
*
|
|
145
|
+
* `klass` IS THE PACK ID, not one of `LAW_CLASSES`. A doctrine rule is grouped by the pack it
|
|
146
|
+
* belongs to and there is no second taxonomy over it; the six-class chip is the invariants'.
|
|
147
|
+
* The collision with the class names `craft` and `process` is real and harmless — the two
|
|
148
|
+
* lists are separate end to end and nothing ever unions them.
|
|
149
|
+
*
|
|
150
|
+
* `pack` is read from the HEADING and not from the filename, so a rule filed under the wrong
|
|
151
|
+
* pack is visible rather than silently renamed by its container. `doctor` is what compares the
|
|
152
|
+
* two; this function just reports what the text says.
|
|
153
|
+
*/
|
|
154
|
+
export function parseDoctrines(text) {
|
|
155
|
+
const rules = [];
|
|
156
|
+
let cur = null;
|
|
157
|
+
for (const line of text.split('\n')) {
|
|
158
|
+
const m = DOCTRINE_HEADING_RE.exec(line);
|
|
159
|
+
if (m) {
|
|
160
|
+
if (cur) rules.push(cur);
|
|
161
|
+
cur = { pack: m[1], n: Number(m[2]), title: m[3], klass: m[1], body: '' };
|
|
162
|
+
} else if (cur !== null) {
|
|
163
|
+
cur.body += `${line}\n`;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
if (cur) rules.push(cur);
|
|
167
|
+
for (const rule of rules) rule.body = rule.body.trim();
|
|
168
|
+
return rules;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* The rendered ontology split into `{id, title, body}` sections.
|
|
173
|
+
*
|
|
174
|
+
* `id` is the slugified title, and it is an ADDRESS: `#/item/law/ont:telos` is how the console
|
|
175
|
+
* deep-links a section, so it has to survive a theme change. It does, because the four titles
|
|
176
|
+
* come from `STRUCTURE` in `js/render.mjs` and not from any theme file — the same reason a
|
|
177
|
+
* citation can spell itself `({{ONTOLOGY}}: {{ONT_TELOS}})` with a token on both sides.
|
|
178
|
+
*
|
|
179
|
+
* The preamble above the first `####` is dropped, as in the two parsers above. It is the
|
|
180
|
+
* paragraph that states the tier order, and it renders into AGENT.md whole — the catalogue
|
|
181
|
+
* lists sections, and the preamble is not one.
|
|
182
|
+
*/
|
|
183
|
+
export function parseOntology(text) {
|
|
184
|
+
const sections = [];
|
|
185
|
+
let cur = null;
|
|
186
|
+
for (const line of text.split('\n')) {
|
|
187
|
+
const m = ONTOLOGY_HEADING_RE.exec(line);
|
|
188
|
+
if (m) {
|
|
189
|
+
if (cur) sections.push(cur);
|
|
190
|
+
cur = { id: m[1].toLowerCase().replace(/\s+/g, '-'), title: m[1], body: '' };
|
|
191
|
+
} else if (cur !== null) {
|
|
192
|
+
cur.body += `${line}\n`;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
if (cur) sections.push(cur);
|
|
196
|
+
for (const s of sections) s.body = s.body.trim();
|
|
197
|
+
return sections;
|
|
198
|
+
}
|
|
199
|
+
|
|
109
200
|
/**
|
|
110
201
|
* `_harness_tui.load_registry` — the `entities` map from `registry.json`, or `{}`.
|
|
111
202
|
*
|
|
@@ -144,33 +235,71 @@ export function entityStatus(registry, key) {
|
|
|
144
235
|
}
|
|
145
236
|
|
|
146
237
|
/**
|
|
147
|
-
* `_harness_tui._tui_inventory` — render-accurate inventory
|
|
148
|
-
* its one-line purpose,
|
|
149
|
-
*
|
|
238
|
+
* `_harness_tui._tui_inventory` — render-accurate inventory of all three constitutional tiers,
|
|
239
|
+
* plus every agent and skill with its one-line purpose, rendered spec, source and badge.
|
|
240
|
+
*
|
|
241
|
+
* THE WHOLE CATALOGUE, NOT THE BUILT SUBSET. All four pack files ship in every bundle
|
|
242
|
+
* (`OWNED_SRC_DIRS`), which is what lets a cross-pack citation resolve when its pack is not
|
|
243
|
+
* built into AGENT.md — and it is also what lets the console show an inactive pack greyed out
|
|
244
|
+
* with the command that would enable it. So the packs are always all parsed; `doctrines` only
|
|
245
|
+
* decides which of them carry `active: true`. Passing `null` — the default, and what `catalog`,
|
|
246
|
+
* `status` and the graph do — means "no install to ask", and every pack reads active, matching
|
|
247
|
+
* `makeCfg()`'s own default and `doctrinesForBuild`'s resolution of unknown.
|
|
248
|
+
*
|
|
249
|
+
* `laws` KEEPS ITS NAME and means the invariants. Renaming it to `invariants` would churn
|
|
250
|
+
* `status`, `catalog`, `tui`, the web API, its `SECTIONS` id, three test fixtures and two
|
|
251
|
+
* recorded corpora to say the same thing in a different word.
|
|
150
252
|
*/
|
|
151
|
-
export function tuiInventory(themeName) {
|
|
253
|
+
export function tuiInventory(themeName, doctrines = null) {
|
|
152
254
|
const { items } = renderAll(makeCfg(), themeName);
|
|
153
255
|
const registry = loadRegistry();
|
|
154
256
|
const agents = [];
|
|
155
257
|
const skills = [];
|
|
156
258
|
let laws = [];
|
|
259
|
+
let ontology = [];
|
|
260
|
+
const packs = new Map();
|
|
157
261
|
for (const { text, src } of items) {
|
|
158
262
|
if (text === null) continue;
|
|
159
263
|
const parts = path.relative(SRC, src).split(path.sep);
|
|
160
|
-
|
|
161
|
-
|
|
264
|
+
const two = parts.length === 2 && parts[1].endsWith('.md') && !parts[1].startsWith('_');
|
|
265
|
+
const name = two ? parts[1].slice(0, -3) : null;
|
|
266
|
+
if (two && (parts[0] === 'agents' || parts[0] === 'skills')) {
|
|
162
267
|
const entry = { name, desc: firstBlockquote(text), body: text,
|
|
163
268
|
source: pyResolve(src), status: entityStatus(registry, `${parts[0]}/${name}`) };
|
|
164
269
|
if (parts[0] === 'agents') agents.push(entry);
|
|
165
|
-
else
|
|
270
|
+
else {
|
|
166
271
|
entry.klass = has(SKILL_CLASS, name) ? SKILL_CLASS[name] : 'build';
|
|
167
272
|
skills.push(entry);
|
|
168
273
|
}
|
|
169
274
|
}
|
|
170
|
-
|
|
275
|
+
// Every `.md` in the dir is read; `PACK_ORDER` below decides which of them is a pack. The
|
|
276
|
+
// dir also holds a `README.md`, which passes the `_`-scaffold filter, and a second
|
|
277
|
+
// membership test here would be a second owner of the same rule — one that has to agree
|
|
278
|
+
// with the one that actually publishes the list.
|
|
279
|
+
if (two && parts[0] === 'doctrines') {
|
|
280
|
+
const lead = text.split('\n').map((l) => PACK_LEAD_RE.exec(l)).find(Boolean);
|
|
281
|
+
packs.set(name, { pack: name, title: lead ? lead[1] : name, desc: lead ? lead[2] : '',
|
|
282
|
+
source: pyResolve(src), rules: parseDoctrines(text) });
|
|
283
|
+
}
|
|
284
|
+
// Scoped to the laws dir, not matched on the basename: `src/ontology/universal.md`
|
|
285
|
+
// shares the name, sorts after `laws/`, and would clobber the parse with `[]` —
|
|
286
|
+
// `LAW_HEADING_RE` wants `###` and the ontology uses `####`. Same spelling as the
|
|
287
|
+
// already-scoped sites at `js/render.mjs`'s `rel === 'laws/universal.md'`.
|
|
288
|
+
if (parts.join('/') === 'laws/universal.md') laws = parseLaws(text);
|
|
289
|
+
if (parts.join('/') === 'ontology/universal.md') ontology = parseOntology(text);
|
|
171
290
|
}
|
|
172
291
|
const byName = (a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0);
|
|
173
292
|
agents.sort(byName);
|
|
174
293
|
skills.sort(byName);
|
|
175
|
-
|
|
294
|
+
// ⚠ `PACK_ORDER` IS THE SINGLE OWNER OF THE SET, and of its order. Filtering the order by
|
|
295
|
+
// what was found — rather than publishing what was found — is what keeps `README.md` and any
|
|
296
|
+
// other stray `.md` in that directory out of the catalogue, and it is the same membership
|
|
297
|
+
// rule `js/render.mjs` refuses the build over, so a fifth pack cannot be silently dropped
|
|
298
|
+
// here either. Order because `sortedSourceFiles` walks alphabetically, which puts `ops`
|
|
299
|
+
// second: the narrative order is the reading order, and it is what the marker line, the
|
|
300
|
+
// wizard and the rendered section all use.
|
|
301
|
+
const on = Array.isArray(doctrines) ? doctrines : PACK_ORDER;
|
|
302
|
+
const built = PACK_ORDER.filter((p) => packs.has(p))
|
|
303
|
+
.map((p) => ({ ...packs.get(p), active: on.includes(p) }));
|
|
304
|
+
return { agents, skills, laws, ontology, doctrines: built, theme: themeName };
|
|
176
305
|
}
|
package/js/migrate.mjs
CHANGED
|
@@ -35,8 +35,8 @@ import path from 'node:path';
|
|
|
35
35
|
import { main as driverMain } from '../bin/geneseed.mjs';
|
|
36
36
|
import { ROOT } from './checkout.mjs';
|
|
37
37
|
import {
|
|
38
|
-
EMIT_HOST_SCOPE, defaultMode, defaultPosture, defaultTheme,
|
|
39
|
-
installTargets, modeOfDir, postureOfDir, readMaybe, themeOfDir,
|
|
38
|
+
EMIT_HOST_SCOPE, defaultMode, defaultPosture, defaultTheme, doctrinesForBuild,
|
|
39
|
+
footprintOfDir, installState, installTargets, modeOfDir, postureOfDir, readMaybe, themeOfDir,
|
|
40
40
|
} from './installs.mjs';
|
|
41
41
|
import { DEFAULT_EMIT, setupBuildArgs } from './generate.mjs';
|
|
42
42
|
import {
|
|
@@ -206,7 +206,12 @@ export function cmdMigrate(args = {}) {
|
|
|
206
206
|
try { rmSync(stash, { recursive: true, force: true }); } catch { /* nothing to undo */ }
|
|
207
207
|
};
|
|
208
208
|
|
|
209
|
-
// Step 3 — re-emit, in each install's OWN
|
|
209
|
+
// Step 3 — re-emit, in each install's OWN six values. Same reads as `cmdRebuildAll`, and
|
|
210
|
+
// the pack selection is the sixth: leaving it out re-emitted a narrowed install at whatever
|
|
211
|
+
// `harness.config.json` said, which WIDENS a constitution its owner had cut down (and, the
|
|
212
|
+
// other way, can strip the consent gate). `null` — a pre-marker carrier, which on a MIGRATION
|
|
213
|
+
// is the common case and not the rare one — resolves to ALL packs through
|
|
214
|
+
// `doctrinesForBuild`, never to `harness.config.json`, which is what makes it fail-closed.
|
|
210
215
|
for (const r of rows) {
|
|
211
216
|
const marker = r.marker && (EMIT_HOST_SCOPE.get(r.marker) ?? ['', ''])[0] === r.host
|
|
212
217
|
? r.marker : '';
|
|
@@ -214,7 +219,8 @@ export function cmdMigrate(args = {}) {
|
|
|
214
219
|
const theme = themeOfDir(r.root) || defaultTheme();
|
|
215
220
|
const out = r.scope === 'global' ? null : r.root;
|
|
216
221
|
const argv = setupBuildArgs(theme, emit, out, out, footprintOfDir(r.root),
|
|
217
|
-
postureOfDir(r.root) || defaultPosture(), modeOfDir(r.root) || defaultMode()
|
|
222
|
+
postureOfDir(r.root) || defaultPosture(), modeOfDir(r.root) || defaultMode(),
|
|
223
|
+
doctrinesForBuild(r.root));
|
|
218
224
|
const label = `${r.host}:${r.scope} (${r.root})`;
|
|
219
225
|
pyPrint(`[migrate] re-emitting ${label}: theme=${theme} emit=${emit}\n`);
|
|
220
226
|
let rc = 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;
|
|
@@ -152,8 +163,18 @@ function splitAtLawHeadings(text) {
|
|
|
152
163
|
return out;
|
|
153
164
|
}
|
|
154
165
|
|
|
155
|
-
/**
|
|
156
|
-
|
|
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) {
|
|
157
178
|
const blocks = splitAtLawHeadings(text);
|
|
158
179
|
const out = [blocks[0].trimEnd()];
|
|
159
180
|
for (const b of blocks.slice(1)) {
|
|
@@ -169,6 +190,11 @@ export function terseLaws(text, theme, lawsPrefix = '') {
|
|
|
169
190
|
const m = /^([\s\S]+?[.!?])(?:\s|$)/.exec(body);
|
|
170
191
|
out.push(`${heading}\n${m ? m[1].trim() : body}`);
|
|
171
192
|
}
|
|
193
|
+
return out.join('\n\n');
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** `_build_render._terse_laws`. */
|
|
197
|
+
export function terseLaws(text, theme, lawsPrefix = '') {
|
|
172
198
|
const law = theme.LAW ?? 'Law';
|
|
173
199
|
const lawsDir = theme.DIR_LAWS ?? 'laws';
|
|
174
200
|
const pointer =
|
|
@@ -176,7 +202,7 @@ export function terseLaws(text, theme, lawsPrefix = '') {
|
|
|
176
202
|
+ `complete, binding text of every ${law} is in \`${lawsPrefix}${lawsDir}/universal.md\`; `
|
|
177
203
|
+ `read it whenever a ${law}'s application is unclear, and before any act touching `
|
|
178
204
|
+ `secrets, deletion, git history, scope, or untrusted content.`;
|
|
179
|
-
return
|
|
205
|
+
return terseBlocks(text) + '\n\n' + pointer;
|
|
180
206
|
}
|
|
181
207
|
|
|
182
208
|
/** `_build_render.render_file`. */
|
|
@@ -213,6 +239,75 @@ function registerBody(cfg, theme, dir, selected, fallback) {
|
|
|
213
239
|
return '';
|
|
214
240
|
}
|
|
215
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
|
+
|
|
216
311
|
/**
|
|
217
312
|
* `_build_render.effective_theme`.
|
|
218
313
|
*
|
|
@@ -223,10 +318,18 @@ function registerBody(cfg, theme, dir, selected, fallback) {
|
|
|
223
318
|
* it reaches nothing at all unless it travels. The constant stays as the default for the
|
|
224
319
|
* parity harnesses, which drive this module directly.
|
|
225
320
|
*/
|
|
226
|
-
export function effectiveTheme(cfg, themeName) {
|
|
321
|
+
export function effectiveTheme(cfg, themeName, { footprint = 'full', lawsPrefix = '' } = {}) {
|
|
227
322
|
const theme = { ...loadTheme(cfg, themeName), ...(cfg.structure ?? STRUCTURE) };
|
|
228
323
|
theme.POSTURE_BODY = registerBody(cfg, theme, 'postures', cfg.posture ?? 'peer', 'peer');
|
|
229
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';
|
|
230
333
|
return theme;
|
|
231
334
|
}
|
|
232
335
|
|
|
@@ -271,7 +374,7 @@ export function destRel(rel) {
|
|
|
271
374
|
export function renderAll(cfg, themeName, {
|
|
272
375
|
footprint = 'full', lawsPrefix = '', nativeCatalog = false,
|
|
273
376
|
} = {}) {
|
|
274
|
-
const theme = effectiveTheme(cfg, themeName);
|
|
377
|
+
const theme = effectiveTheme(cfg, themeName, { footprint, lawsPrefix });
|
|
275
378
|
const items = [];
|
|
276
379
|
for (const file of sortedSourceFiles(cfg.src)) {
|
|
277
380
|
const rel = path.relative(cfg.src, file);
|