geneseed 2.1.0 → 3.1.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 +155 -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 +102 -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-_MnJcwb_.js} +1 -1
- package/web/dist/assets/{Activity-us5MPf4u.js → Activity-DpjqO-FR.js} +1 -1
- package/web/dist/assets/{ActivityDetail-CiaQXUk2.js → ActivityDetail-Bvrjv8oW.js} +1 -1
- package/web/dist/assets/{Diff-Bh0qbZ2s.js → Diff-DZ0IehBV.js} +1 -1
- package/web/dist/assets/{Doctor-BIlQME45.js → Doctor-cu4krooO.js} +1 -1
- package/web/dist/assets/{Graph-4dYD1dPG.js → Graph-DUOPY65j.js} +1 -1
- package/web/dist/assets/{Harnesses-PzYZ3LOC.js → Harnesses-B5Osg-BF.js} +1 -1
- package/web/dist/assets/Laws-D8DJgTBp.js +3 -0
- package/web/dist/assets/{Library-CjMbksJW.js → Library-8y69I3_z.js} +1 -1
- package/web/dist/assets/{Markdown-Bk81c3aZ.js → Markdown-Ds5GS1OD.js} +1 -1
- package/web/dist/assets/{Profile-Curui_-j.js → Profile-7Jxa7ZUX.js} +1 -1
- package/web/dist/assets/{Rules-Bm283wdv.js → Rules-UZ0jWkjg.js} +1 -1
- package/web/dist/assets/{Skills-gruqYuhl.js → Skills-Bgkl0xLd.js} +1 -1
- package/web/dist/assets/{StatusBadge-EiM_FPqv.js → StatusBadge-Cx4caF_2.js} +1 -1
- package/web/dist/assets/{Themes-DU99j4Hr.js → Themes-De8az7Sv.js} +1 -1
- package/web/dist/assets/{activity-0rvQ6Vra.js → activity-VDAdVQ3q.js} +1 -1
- package/web/dist/assets/index-1pPSz7Kl.css +1 -0
- package/web/dist/assets/{index-0b2hobOo.js → index-BKrjBRV4.js} +1 -1
- package/web/dist/assets/index-C-gb-q-r.js +12 -0
- package/web/dist/assets/{index-IiGFmCW_.js → index-wNiNzFFQ.js} +1 -1
- package/web/dist/index.html +2 -2
- package/web/src/pages/Laws.jsx +401 -93
- 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/settings.mjs
CHANGED
|
@@ -67,17 +67,120 @@ function isDict(v) {
|
|
|
67
67
|
return Boolean(v) && typeof v === 'object' && !Array.isArray(v);
|
|
68
68
|
}
|
|
69
69
|
|
|
70
|
-
/**
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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];
|
|
81
184
|
}
|
|
82
185
|
|
|
83
186
|
/**
|
|
@@ -170,16 +273,27 @@ export function readJsonc(text) {
|
|
|
170
273
|
}
|
|
171
274
|
}
|
|
172
275
|
|
|
173
|
-
/**
|
|
174
|
-
|
|
175
|
-
|
|
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) {
|
|
176
288
|
process.stdout.write(`[${prefix}] ${path.basename(target)} has comments — not rewriting `
|
|
177
289
|
+ 'it (your edits are kept). Add this to its "instructions" array by hand:\n');
|
|
178
290
|
process.stdout.write(`[${prefix}] ${jsonDumps(agentPath)}\n`);
|
|
179
|
-
if (
|
|
180
|
-
process.stdout.write(
|
|
181
|
-
|
|
182
|
-
|
|
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')) {
|
|
183
297
|
process.stdout.write(`[${prefix}] ${line}\n`);
|
|
184
298
|
}
|
|
185
299
|
}
|
|
@@ -208,7 +322,7 @@ export function atomicWriteJson(p, config) {
|
|
|
208
322
|
* re-implements the inverse itself rather than inherit this one's permission/lsp side
|
|
209
323
|
* effects, so the only crossings are the two emit-tree ones (`_build_emit.emit_opencode`,
|
|
210
324
|
* `_build_global.emit_opencode_global`). */
|
|
211
|
-
export function mergeOpencodeJson(p, agentPath) {
|
|
325
|
+
export function mergeOpencodeJson(p, agentPath, doctrines = null) {
|
|
212
326
|
const target = opencodeTarget(p);
|
|
213
327
|
let config = { $schema: OPENCODE_SCHEMA, instructions: [] };
|
|
214
328
|
let hadComments = false;
|
|
@@ -239,13 +353,58 @@ export function mergeOpencodeJson(p, agentPath) {
|
|
|
239
353
|
const addInstr = indexOfEq(instr, agentPath) < 0;
|
|
240
354
|
if (addInstr) instr.push(agentPath);
|
|
241
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.
|
|
242
360
|
const addPerm = !has(config, 'permission');
|
|
243
|
-
|
|
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
|
+
}
|
|
244
391
|
const addLsp = !has(config, 'lsp');
|
|
245
392
|
if (addLsp) config.lsp = true;
|
|
246
|
-
if (!addInstr && !
|
|
393
|
+
if (!addInstr && !permChanged && !addLsp) return target;
|
|
247
394
|
if (path.extname(target) === '.jsonc' && hadComments) {
|
|
248
|
-
|
|
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);
|
|
249
408
|
return target;
|
|
250
409
|
}
|
|
251
410
|
try {
|
|
@@ -508,8 +667,38 @@ export function hookPrefix({ runner, entry, platform = process.platform } = {})
|
|
|
508
667
|
return `"${runner}" "${entry}"`;
|
|
509
668
|
}
|
|
510
669
|
|
|
511
|
-
/**
|
|
512
|
-
|
|
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) {
|
|
513
702
|
const run = hookPrefix(hookOpts);
|
|
514
703
|
const mem = `--memory "${path.join(cfg, 'memory')}"`;
|
|
515
704
|
// --root carries the install's own dir so a GLOBAL hook can stand down when a project
|
|
@@ -520,7 +709,8 @@ export function claudeHookGroups(cfg, hookOpts) {
|
|
|
520
709
|
const learn = `${run} learn ${mem} || exit 0`;
|
|
521
710
|
return {
|
|
522
711
|
PreToolUse: [
|
|
523
|
-
|
|
712
|
+
...(processPackOn(doctrines)
|
|
713
|
+
? [{ matcher: 'Bash', hooks: [{ type: 'command', command: gate }] }] : []),
|
|
524
714
|
{
|
|
525
715
|
matcher: 'Write|Edit|MultiEdit|NotebookEdit',
|
|
526
716
|
hooks: [{ type: 'command', command: ruleGate }],
|
|
@@ -550,7 +740,8 @@ export function claudeHookGroups(cfg, hookOpts) {
|
|
|
550
740
|
*
|
|
551
741
|
* `scope` is accepted and unused, exactly as in the Python.
|
|
552
742
|
*/
|
|
553
|
-
export function mergeClaudeSettings(p, scope = 'global', priorHooks = null, hookOpts = {}
|
|
743
|
+
export function mergeClaudeSettings(p, scope = 'global', priorHooks = null, hookOpts = {},
|
|
744
|
+
doctrines = null) {
|
|
554
745
|
const prior = (priorHooks || []).filter(isDict);
|
|
555
746
|
let config = {};
|
|
556
747
|
let hadComments = false;
|
|
@@ -570,7 +761,7 @@ export function mergeClaudeSettings(p, scope = 'global', priorHooks = null, hook
|
|
|
570
761
|
}
|
|
571
762
|
let hooks = get(config, 'hooks');
|
|
572
763
|
if (!isDict(hooks)) hooks = {};
|
|
573
|
-
const canonical = claudeHookGroups(path.dirname(p), hookOpts);
|
|
764
|
+
const canonical = claudeHookGroups(path.dirname(p), hookOpts, doctrines);
|
|
574
765
|
const canonFlat = [];
|
|
575
766
|
for (const [event, groups] of Object.entries(canonical)) {
|
|
576
767
|
for (const g of groups) canonFlat.push({ event, group: g });
|
package/js/setup.mjs
CHANGED
|
@@ -46,7 +46,7 @@ import path from 'node:path';
|
|
|
46
46
|
|
|
47
47
|
import { main as driverMain, resolveOut } from '../bin/geneseed.mjs';
|
|
48
48
|
import { playLine } from './anim.mjs';
|
|
49
|
-
import { discoverNames } from './checkout.mjs';
|
|
49
|
+
import { discoverNames, PACK_ORDER } from './checkout.mjs';
|
|
50
50
|
import { exportImprovements } from './diff.mjs';
|
|
51
51
|
import { cmdDoctor } from './doctor.mjs';
|
|
52
52
|
import { setupBuildArgs } from './generate.mjs';
|
|
@@ -211,6 +211,23 @@ const MODE_BLURBS = {
|
|
|
211
211
|
foreman: 'triages tasks, spawns pipelines for substantial work',
|
|
212
212
|
};
|
|
213
213
|
|
|
214
|
+
/**
|
|
215
|
+
* One line per doctrine pack, written for someone who has never read `src/doctrines/README.md`
|
|
216
|
+
* — this is the only description of a pack an installer ever sees.
|
|
217
|
+
*
|
|
218
|
+
* `process` says what turning it OFF costs, and it is the only one that has to. The other three
|
|
219
|
+
* trade practices for context; process 5 is the commit/push consent gate, so dropping the pack
|
|
220
|
+
* drops the rule that every commit and every push is asked for. A blurb that read "planning and
|
|
221
|
+
* context economy" would have sold that away in a menu.
|
|
222
|
+
*/
|
|
223
|
+
const DOCTRINE_BLURBS = {
|
|
224
|
+
craft: 'how code is written — reuse first, house conventions, docs, the smallest diff',
|
|
225
|
+
rigor: 'how work is proven — idempotence, honest tests, coverage, gates that can fail',
|
|
226
|
+
ops: 'how the machine is driven — tool discovery, non-blocking commands, teardown',
|
|
227
|
+
process: 'how a session runs — planning, context economy, docs first, and the consent gate '
|
|
228
|
+
+ 'on every commit and push (drop this pack and that gate goes with it)',
|
|
229
|
+
};
|
|
230
|
+
|
|
214
231
|
/**
|
|
215
232
|
* `_harness_setup._theme_options`.
|
|
216
233
|
*
|
|
@@ -243,6 +260,20 @@ export function modeOptions() {
|
|
|
243
260
|
return discoverNames('modes', 'direct').map((n) => [n, MODE_BLURBS[n] ?? '']);
|
|
244
261
|
}
|
|
245
262
|
|
|
263
|
+
/**
|
|
264
|
+
* The doctrine packs, in `PACK_ORDER` — the third option table, and the one that is a SET.
|
|
265
|
+
*
|
|
266
|
+
* The order is NARRATIVE and comes from `PACK_ORDER`; `discoverNames` only says which packs
|
|
267
|
+
* this checkout actually ships. Ordering by discovery instead would read `craft, ops, process,
|
|
268
|
+
* rigor` — it `.sort()`s — and the menu a human answers would stop matching the `Active packs:`
|
|
269
|
+
* line the build writes, which is normalised into `PACK_ORDER` at the driver. Same intersection
|
|
270
|
+
* the driver's `parseDoctrines` and the config loader take, for the same reason.
|
|
271
|
+
*/
|
|
272
|
+
export function doctrineOptions() {
|
|
273
|
+
const known = discoverNames('doctrines', PACK_ORDER[0]);
|
|
274
|
+
return PACK_ORDER.filter((n) => known.includes(n)).map((n) => [n, DOCTRINE_BLURBS[n] ?? '']);
|
|
275
|
+
}
|
|
276
|
+
|
|
246
277
|
/**
|
|
247
278
|
* `_harness_setup.EMIT_OPTIONS`.
|
|
248
279
|
*
|
|
@@ -272,12 +303,60 @@ const FOOTPRINT_OPTIONS = [
|
|
|
272
303
|
// --------------------------------------------------------------------------------------
|
|
273
304
|
|
|
274
305
|
/**
|
|
275
|
-
*
|
|
306
|
+
* The Doctrine-packs question — one `all`/`choose` gate, then a y/n per pack.
|
|
307
|
+
*
|
|
308
|
+
* TWO LEVELS RATHER THAN FOUR QUESTIONS, because the overwhelmingly common answer is "all of
|
|
309
|
+
* them" and a wizard that asks four yes/nos to get there taxes every install for the rare one.
|
|
310
|
+
* The gate's default is `all`, so an installer who holds Enter through the whole wizard gets
|
|
311
|
+
* the full set — which is what every pre-packs install already had.
|
|
312
|
+
*
|
|
313
|
+
* `askChoice` and not `confirm` for the per-pack question: `confirm` reads the first character
|
|
314
|
+
* and prints no description, so the pack's blurb — the only place a reader is told what the
|
|
315
|
+
* pack costs — would have nowhere to go. Two named options give it a line.
|
|
316
|
+
*
|
|
317
|
+
* Answering `no` to all four is legal and yields `[]`, which `setupBuildArgs` spells
|
|
318
|
+
* `--doctrines none`. That is a real configuration (invariants and ontology only), not a
|
|
319
|
+
* mistake to guard against, so it is not second-guessed here.
|
|
320
|
+
*
|
|
321
|
+
* `deployed` is what the machine's carriers already say (`installedDefaults().doctrines`), and
|
|
322
|
+
* it drives BOTH levels: a narrowed install opens on `choose` with its own packs pre-ticked,
|
|
323
|
+
* so holding Enter through the wizard KEEPS the selection instead of silently widening it back
|
|
324
|
+
* to all four. `null` — no carrier said — still opens on `all`, which is the same resolution
|
|
325
|
+
* `doctrinesForBuild` gives unknown, so the wizard and the build agree about silence.
|
|
326
|
+
*
|
|
327
|
+
* ⚠ It never returns `null`, at either level, and `tests/unit/setup.test.mjs` pins that: this
|
|
328
|
+
* is the one call site allowed to reach `setupBuildArgs` without a `doctrinesForBuild`
|
|
329
|
+
* fallback, and that exemption is only sound while the function is total.
|
|
330
|
+
*/
|
|
331
|
+
function askDoctrines(deployed = null) {
|
|
332
|
+
const opts = doctrineOptions();
|
|
333
|
+
const all = opts.map(([n]) => n);
|
|
334
|
+
const known = Array.isArray(deployed) ? all.filter((n) => deployed.includes(n)) : null;
|
|
335
|
+
const narrowed = known !== null && known.length !== all.length;
|
|
336
|
+
const pick = askChoice('Doctrine packs', [
|
|
337
|
+
['all', `${all.join(' + ')}${narrowed ? '' : ' (default)'}`],
|
|
338
|
+
['choose', `pick packs${narrowed ? ` (installed: ${known.join(', ') || 'none'})` : ''}`],
|
|
339
|
+
], narrowed ? 'choose' : 'all');
|
|
340
|
+
if (pick !== 'choose') return all;
|
|
341
|
+
return all.filter((name) => askChoice(`Include ${name} — ${DOCTRINE_BLURBS[name] ?? name}`,
|
|
342
|
+
[['yes', `build ${name} into AGENT.md`], ['no', `leave ${name} out`]],
|
|
343
|
+
known === null || known.includes(name) ? 'yes' : 'no') === 'yes');
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/**
|
|
347
|
+
* `_harness_setup._collect_setup_lines` — the six questions, the plan, the confirm.
|
|
276
348
|
*
|
|
277
349
|
* Returns the selection, or null when the confirm is declined. Every default is the
|
|
278
350
|
* DEPLOYED value first (`installedDefaults`) and the CONFIGURED one second, which is why
|
|
279
351
|
* P5i is the phase that had to give `installedDefaults` its posture, mode and footprint keys
|
|
280
352
|
* back: three of the five questions read them.
|
|
353
|
+
*
|
|
354
|
+
* Doctrines reads the same walk, and joined it last because the `Active packs:` marker reader
|
|
355
|
+
* did not exist when the question shipped. Until it did, every re-run of the wizard offered
|
|
356
|
+
* `all` to an installer who had deliberately narrowed their packs — the one register where
|
|
357
|
+
* holding Enter SILENTLY WIDENED a selection instead of keeping it. `inst.doctrines` is passed
|
|
358
|
+
* whole, `null` included, because `askDoctrines` distinguishes "no carrier said" from "a
|
|
359
|
+
* carrier said none" and the two open the question differently.
|
|
281
360
|
*/
|
|
282
361
|
export function collectSetupLines() {
|
|
283
362
|
pyPrint('Geneseed setup — answer a few questions; nothing is written until you confirm.\n');
|
|
@@ -285,6 +364,7 @@ export function collectSetupLines() {
|
|
|
285
364
|
const theme = askChoice('Theme', themeOptions(), inst.theme || defaultTheme());
|
|
286
365
|
const posture = askChoice('Posture', postureOptions(), inst.posture || defaultPosture());
|
|
287
366
|
const mode = askChoice('Mode', modeOptions(), inst.mode || defaultMode());
|
|
367
|
+
const doctrines = askDoctrines(inst.doctrines);
|
|
288
368
|
const emit = askChoice('Install mode', EMIT_OPTIONS, inst.emit || 'opencode-global');
|
|
289
369
|
const footprint = askChoice('Footprint', FOOTPRINT_OPTIONS.map(([k, d]) => [k, d]),
|
|
290
370
|
inst.footprint || 'lean');
|
|
@@ -299,10 +379,15 @@ export function collectSetupLines() {
|
|
|
299
379
|
} else if (emit === 'files') {
|
|
300
380
|
out = ask('Output dir for the bundle', 'Harness');
|
|
301
381
|
}
|
|
382
|
+
// The full pack list is passed as the elision default rather than left to `setupBuildArgs`'s
|
|
383
|
+
// own: the menu the user just answered was built from `doctrineOptions()`, so "they picked
|
|
384
|
+
// everything" must be judged against that same list, not against a second one.
|
|
385
|
+
const allPacks = doctrineOptions().map(([n]) => n);
|
|
302
386
|
pyPrint(`\nAbout to run: geneseed build ${
|
|
303
|
-
setupBuildArgs(theme, emit, out, root, footprint, posture, mode
|
|
387
|
+
setupBuildArgs(theme, emit, out, root, footprint, posture, mode, doctrines, allPacks)
|
|
388
|
+
.join(' ')}\n`);
|
|
304
389
|
if (!confirm('Proceed?', true)) return null;
|
|
305
|
-
return { theme, posture, mode, emit, out, root, footprint };
|
|
390
|
+
return { theme, posture, mode, doctrines, emit, out, root, footprint };
|
|
306
391
|
}
|
|
307
392
|
|
|
308
393
|
// --------------------------------------------------------------------------------------
|
|
@@ -441,6 +526,7 @@ export function setupLines() {
|
|
|
441
526
|
}
|
|
442
527
|
const {
|
|
443
528
|
theme, emit, out, root, footprint = 'lean', posture = 'peer', mode = 'direct',
|
|
529
|
+
doctrines = null,
|
|
444
530
|
} = sel;
|
|
445
531
|
if (emit === 'opencode-global') {
|
|
446
532
|
// The build below overwrites the deployed global harness, and the self-improvement loops
|
|
@@ -455,7 +541,11 @@ export function setupLines() {
|
|
|
455
541
|
pyPrint(`! could not export local edits (${e && e.message}) — continuing.\n`);
|
|
456
542
|
}
|
|
457
543
|
}
|
|
458
|
-
|
|
544
|
+
// Same list the question was built from — see `collectSetupLines`. The build that actually
|
|
545
|
+
// runs must carry the pack selection the plan line just previewed, or the wizard would print
|
|
546
|
+
// one command and run another.
|
|
547
|
+
const argv = setupBuildArgs(theme, emit, out, root, footprint, posture, mode, doctrines,
|
|
548
|
+
doctrineOptions().map(([n]) => n));
|
|
459
549
|
pyPrint(`Running: geneseed build ${argv.join(' ')}\n`);
|
|
460
550
|
const rc = driverMain(argv);
|
|
461
551
|
if (rc !== 0) {
|
package/js/status.mjs
CHANGED
|
@@ -125,9 +125,24 @@ export function cmdVersion(args) {
|
|
|
125
125
|
* it, and the eleven `status` cells and the catalog cells now gate the same classifier
|
|
126
126
|
* from opposite ends.
|
|
127
127
|
*/
|
|
128
|
-
export function inventoryCounts(themeName) {
|
|
129
|
-
const inv = tuiInventory(themeName);
|
|
130
|
-
|
|
128
|
+
export function inventoryCounts(themeName, doctrines = null) {
|
|
129
|
+
const inv = tuiInventory(themeName, doctrines);
|
|
130
|
+
const on = inv.doctrines.filter((p) => p.active);
|
|
131
|
+
return {
|
|
132
|
+
agents: inv.agents.length,
|
|
133
|
+
skills: inv.skills.length,
|
|
134
|
+
laws: inv.laws.length,
|
|
135
|
+
ontology: inv.ontology.length,
|
|
136
|
+
// ⚠ THREE NUMBERS AND NOT ONE, because a pack-off install is a real configuration and
|
|
137
|
+
// "23 rules" would be a lie in it. `packs`/`packsTotal` is the fraction the console and
|
|
138
|
+
// the panel both print; `doctrineRules` counts only the ACTIVE packs' rules — what this
|
|
139
|
+
// install is actually bound by, not what its bundle happens to carry. The catalogue is
|
|
140
|
+
// always whole (every pack file ships), so the total is recoverable from `tuiInventory`
|
|
141
|
+
// by anything that needs it and is not duplicated here.
|
|
142
|
+
packs: on.length,
|
|
143
|
+
packsTotal: inv.doctrines.length,
|
|
144
|
+
doctrineRules: on.reduce((n, p) => n + p.rules.length, 0),
|
|
145
|
+
};
|
|
131
146
|
}
|
|
132
147
|
|
|
133
148
|
/**
|
|
@@ -163,7 +178,10 @@ export function statusData() {
|
|
|
163
178
|
const inst = installedDefaults();
|
|
164
179
|
const theme = inst.theme || defaultTheme();
|
|
165
180
|
const mdir = resolveMemoryDir(null);
|
|
166
|
-
|
|
181
|
+
// `inst.doctrines` is what the machine's carriers say — `null` when none of them does, which
|
|
182
|
+
// `tuiInventory` reads as every pack, the same resolution `doctrinesForBuild` gives unknown.
|
|
183
|
+
// This is a DISPLAY read: nothing here decides what a build emits.
|
|
184
|
+
const inv = inventoryCounts(theme, inst.doctrines);
|
|
167
185
|
let cfgDir = null;
|
|
168
186
|
try { cfgDir = opencodeConfigDir(); } catch { cfgDir = null; }
|
|
169
187
|
const sourceFp = sourceFingerprint(makeCfg());
|
|
@@ -189,6 +207,15 @@ export function statusData() {
|
|
|
189
207
|
agents: inv.agents,
|
|
190
208
|
skills: inv.skills,
|
|
191
209
|
laws: inv.laws,
|
|
210
|
+
// ⚠ NAMED ONE BY ONE, AND THAT IS THE TRAP THIS FUNCTION SETS TWICE. `inv` is already
|
|
211
|
+
// `inventoryCounts`' reduction, and this literal names its keys again — so a count added
|
|
212
|
+
// to the inventory and not to BOTH places is dropped here in silence, with nothing to
|
|
213
|
+
// fail. There is deliberately no spread: the panel's payload is a stated contract, and
|
|
214
|
+
// `js/web/api.mjs` spreads THIS, so anything reaching that endpoint passes through here.
|
|
215
|
+
ontology: inv.ontology,
|
|
216
|
+
packs: inv.packs,
|
|
217
|
+
packs_total: inv.packsTotal,
|
|
218
|
+
doctrine_rules: inv.doctrineRules,
|
|
192
219
|
memory_dir: mdir ? String(mdir) : null,
|
|
193
220
|
facts: mdir ? memoryFactCount(mdir) : 0,
|
|
194
221
|
source_fp: sourceFp,
|
|
@@ -248,6 +275,20 @@ export function statusLines(d, color = false) {
|
|
|
248
275
|
+ `(${d.facts} fact${d.facts === 1 ? '' : 's'})`],
|
|
249
276
|
['version', `${d.installed_fp || '(none)'} ${DOT} source ${d.source_fp}`],
|
|
250
277
|
];
|
|
278
|
+
// ⚠ A ROW, NOT A WIDER `components` LINE, AND THE REASON IS THAT THE CORPUS CANNOT BE
|
|
279
|
+
// RE-BLESSED. `statusLines` has 30 recorded cases per platform in
|
|
280
|
+
// `tests/__snapshots__/primitives/`, every one of them holding the box's exact bytes —
|
|
281
|
+
// including `17 agents · 47 skills · 37 laws` — and the recorder was Python and is gone.
|
|
282
|
+
// Touching `components` moves all 30; adding a row would too, since the box width is
|
|
283
|
+
// measured over `rows`. So the row is CONDITIONAL on a key the recorded arguments do not
|
|
284
|
+
// carry, exactly as `agent_md` below already is, and the recording renders unchanged.
|
|
285
|
+
//
|
|
286
|
+
// It carries the two tiers `components` cannot: `components` counts the invariants as
|
|
287
|
+
// `laws`, which is still true, and says nothing about the ontology or the packs.
|
|
288
|
+
if (d.packs_total !== undefined) {
|
|
289
|
+
rows.push(['constitution', `${d.ontology} ontology sections ${DOT} ${d.doctrine_rules} `
|
|
290
|
+
+ `doctrine rules in ${d.packs}/${d.packs_total} packs`]);
|
|
291
|
+
}
|
|
251
292
|
if (d.agent_md) {
|
|
252
293
|
rows.push(['AGENT.md',
|
|
253
294
|
`${d.agent_md} (${d.agent_md_present ? 'present' : 'MISSING'})`]);
|
package/js/tui.mjs
CHANGED
|
@@ -246,6 +246,11 @@ const ICONS = {
|
|
|
246
246
|
agent: ['🤖', '◆', '@'],
|
|
247
247
|
skill: ['✨', '✦', '*'],
|
|
248
248
|
law: ['📜', '§', '#'],
|
|
249
|
+
// The other two tiers. Safe to add: `icon` has 32 recorded cases and none of them names a
|
|
250
|
+
// key that did not exist, so a new entry changes no recorded answer — unlike `glyphs`,
|
|
251
|
+
// whose recorded results are the whole object and which is therefore left alone.
|
|
252
|
+
ontology: ['🧭', '◉', 'O'],
|
|
253
|
+
doctrine: ['📐', '⌘', 'D'],
|
|
249
254
|
library: ['📚', '❒', 'L'],
|
|
250
255
|
notebook: ['📓', '✎', 'N'],
|
|
251
256
|
wiki: ['📘', '▥', 'W'],
|
|
@@ -387,14 +392,47 @@ export function themeFlair(theme) {
|
|
|
387
392
|
/**
|
|
388
393
|
* `_tui_entries` — the ordered (kind, label, data) rows for the left list. `head` is a
|
|
389
394
|
* section divider and is not selectable.
|
|
395
|
+
*
|
|
396
|
+
* ⚠ THE TWO NEW TIERS ARE GUARDED, AND THE GUARD IS NOT DEFENSIVENESS. This function has two
|
|
397
|
+
* recorded cases per platform in `tests/__snapshots__/primitives/`, replayed under
|
|
398
|
+
* `deepStrictEqual`, and their argument is a synthetic inventory carrying exactly
|
|
399
|
+
* `{agents, skills, laws, theme}` — no recorder survives to re-bless them. Reading
|
|
400
|
+
* `inv.doctrines.length` there THROWS; emitting a head unconditionally changes the recorded
|
|
401
|
+
* result array. Keyed on presence, the recording renders byte-identical and a real inventory
|
|
402
|
+
* gets all three tiers.
|
|
403
|
+
*
|
|
404
|
+
* The order is CONSTITUTIONAL and not alphabetical: ontology, then invariants, then doctrines
|
|
405
|
+
* — the order they are read in, the order AGENT.md renders them, and the order the console's
|
|
406
|
+
* three bands appear in. `Rule` and `Doctrine` are hardcoded English here, as `Rule` already
|
|
407
|
+
* was: this function receives an inventory, not a theme's nouns.
|
|
390
408
|
*/
|
|
391
409
|
export function tuiEntries(inv) {
|
|
392
410
|
const rows = [['head', `AGENTS (${inv.agents.length})`, null]];
|
|
393
411
|
for (const e of inv.agents) rows.push(['agent', e.name, e]);
|
|
394
412
|
rows.push(['head', `SKILLS (${inv.skills.length})`, null]);
|
|
395
413
|
for (const e of inv.skills) rows.push(['skill', e.name, e]);
|
|
414
|
+
if (inv.ontology?.length) {
|
|
415
|
+
rows.push(['head', `ONTOLOGY (${inv.ontology.length})`, null]);
|
|
416
|
+
for (const e of inv.ontology) rows.push(['ontology', e.title, e]);
|
|
417
|
+
}
|
|
396
418
|
rows.push(['head', `LAWS (${inv.laws.length})`, null]);
|
|
397
419
|
for (const e of inv.laws) rows.push(['law', `Rule ${e.num} — ${e.title}`, e]);
|
|
420
|
+
if (inv.doctrines?.length) {
|
|
421
|
+
// The head counts the ACTIVE packs' rules and names the fraction, because a pack that is
|
|
422
|
+
// not built in is still listed — greyed rather than absent, so "off" never looks like
|
|
423
|
+
// "gone" — and a head reading `DOCTRINES (23)` over an install bound by six would be the
|
|
424
|
+
// one number in this panel that lies.
|
|
425
|
+
const on = inv.doctrines.filter((p) => p.active);
|
|
426
|
+
const n = on.reduce((a, p) => a + p.rules.length, 0);
|
|
427
|
+
rows.push(['head',
|
|
428
|
+
`DOCTRINES (${n} in ${on.length}/${inv.doctrines.length} packs)`, null]);
|
|
429
|
+
for (const p of inv.doctrines) {
|
|
430
|
+
for (const r of p.rules) {
|
|
431
|
+
rows.push(['doctrine', `Doctrine ${r.pack} ${r.n} — ${r.title}`,
|
|
432
|
+
{ ...r, active: p.active, source: p.source }]);
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
}
|
|
398
436
|
return rows;
|
|
399
437
|
}
|
|
400
438
|
|
|
@@ -420,7 +458,12 @@ export function tuiEntries(inv) {
|
|
|
420
458
|
* the fix is one `or []` at the call site, and it belongs in the same change as the port.
|
|
421
459
|
*/
|
|
422
460
|
export function detailLines(kind, label, data) {
|
|
423
|
-
|
|
461
|
+
// The three constitutional tiers share one treatment — the label, a blank, then the body —
|
|
462
|
+
// because all three are a titled rule rather than a rendered spec. New kinds only: the nine
|
|
463
|
+
// recorded cases name kinds that already existed, so no recorded answer moves.
|
|
464
|
+
if (kind === 'law' || kind === 'doctrine' || kind === 'ontology') {
|
|
465
|
+
return [label, ''].concat(data ? pySplitLines(data.body) : []);
|
|
466
|
+
}
|
|
424
467
|
if ((kind === 'agent' || kind === 'skill') && data) return pySplitLines(data.body);
|
|
425
468
|
return null;
|
|
426
469
|
}
|