geneseed 3.12.0 → 3.14.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 +160 -0
- package/README.md +2 -2
- package/SHIPPED.md +1 -1
- package/adapters/bob/README.md +1 -1
- package/adapters/claude-code/README.md +1 -1
- package/adapters/openclaude/README.md +1 -1
- package/adapters/opencode/README.md +3 -3
- package/adapters/opencode/plugins/geneseed-learn.js +10 -1
- package/bin/geneseed-cli.mjs +10 -14
- package/bin/geneseed-hook.mjs +22 -15
- package/docs/README.md +41 -22
- package/docs/concepts/agents.md +3 -1
- package/docs/concepts/collaboration.md +3 -1
- package/docs/concepts/exclusions.md +4 -2
- package/docs/concepts/footprint.md +3 -1
- package/docs/concepts/foreman-mode.md +3 -1
- package/docs/concepts/hook-gates.md +61 -0
- package/docs/concepts/hooks.md +8 -56
- package/docs/concepts/host-matrix.md +38 -0
- package/docs/concepts/hosts.md +6 -32
- package/docs/concepts/loop-branch.md +66 -0
- package/docs/concepts/loop-bricks.md +48 -0
- package/docs/concepts/loop-contracts.md +33 -0
- package/docs/concepts/loop-gates.md +30 -0
- package/docs/concepts/loop-notes.md +43 -0
- package/docs/concepts/loop-risk.md +36 -0
- package/docs/concepts/loop-templates.md +50 -0
- package/docs/concepts/loops.md +24 -116
- package/docs/concepts/lsp.md +2 -0
- package/docs/concepts/memory.md +3 -1
- package/docs/concepts/notebook.md +3 -1
- package/docs/concepts/rules.md +4 -2
- package/docs/concepts/skills.md +3 -1
- package/docs/concepts/themes.md +3 -1
- package/docs/extending.md +28 -13
- package/docs/guides/autostart.md +3 -1
- package/docs/guides/choose-your-setup.md +5 -80
- package/docs/guides/headless.md +5 -3
- package/docs/guides/install-agent-md.md +37 -0
- package/docs/guides/install-bob.md +20 -0
- package/docs/guides/install-claude-code.md +22 -0
- package/docs/guides/install-clone.md +34 -0
- package/docs/guides/install-openclaude.md +25 -0
- package/docs/guides/install-opencode.md +35 -0
- package/docs/guides/install-scripted.md +21 -0
- package/docs/guides/install.md +9 -118
- package/docs/guides/loop-finish.md +54 -0
- package/docs/guides/loop-running.md +66 -0
- package/docs/guides/loop-which-template.md +35 -0
- package/docs/guides/mcp-filesystem.md +41 -0
- package/docs/guides/mcp-gitlab.md +66 -0
- package/docs/guides/mcp-markitdown.md +36 -0
- package/docs/guides/mcp.md +8 -137
- package/docs/guides/migrate.md +3 -1
- package/docs/guides/project-context.md +4 -2
- package/docs/guides/read-non-markdown.md +17 -0
- package/docs/guides/run-a-loop.md +64 -0
- package/docs/guides/run-anywhere.md +2 -0
- package/docs/guides/setup-doctrines.md +55 -0
- package/docs/guides/setup-footprint.md +48 -0
- package/docs/guides/uninstall.md +3 -1
- package/docs/guides/upgrade.md +3 -1
- package/docs/guides/verify.md +3 -1
- package/docs/guides/wiki.md +3 -1
- package/docs/guides/worktree.md +3 -1
- package/docs/reference/about.md +3 -1
- package/docs/reference/cli.md +2 -0
- package/docs/reference/env-context.md +40 -0
- package/docs/reference/environment.md +10 -59
- package/docs/reference/glossary.md +3 -1
- package/docs/reference/loop-actions.md +20 -0
- package/docs/reference/opencode-plugins.md +14 -118
- package/docs/reference/plugin-activity.md +20 -0
- package/docs/reference/plugin-context.md +27 -0
- package/docs/reference/plugin-guard.md +27 -0
- package/docs/reference/plugin-learn.md +27 -0
- package/docs/reference/plugin-notify.md +20 -0
- package/docs/reference/plugin-ponytail.md +19 -0
- package/docs/reference/plugin-workflow.md +25 -0
- package/docs/reference/troubleshoot-doctor.md +50 -0
- package/docs/reference/troubleshoot-hooks.md +99 -0
- package/docs/reference/troubleshoot-install.md +108 -0
- package/docs/reference/troubleshoot-tools.md +56 -0
- package/docs/reference/troubleshoot-update.md +23 -0
- package/docs/reference/troubleshoot-web.md +66 -0
- package/docs/reference/troubleshooting.md +13 -337
- package/docs/understand/a-day.md +3 -1
- package/docs/understand/enforced-vs-asked.md +3 -1
- package/docs/understand/harness.md +2 -0
- package/docs/understand/machine-bob.md +17 -0
- package/docs/understand/machine-claude.md +35 -0
- package/docs/understand/machine-opencode.md +36 -0
- package/docs/understand/on-your-machine.md +12 -55
- package/docs/understand/take-it-out.md +13 -0
- package/docs/web-ui.md +35 -15
- package/harness.config.json +1 -1
- package/js/README.md +30 -13
- package/js/build/args.mjs +374 -0
- package/js/build/driver.mjs +137 -553
- package/js/build/emit-claude.mjs +2 -4
- package/js/build/generate.mjs +6 -3
- package/js/build/render.mjs +5 -9
- package/js/build/source.mjs +3 -11
- package/js/cli-table.json +27 -1
- package/js/hosts/hooks-context.mjs +431 -0
- package/js/hosts/hooks-learn.mjs +275 -0
- package/js/hosts/hooks-prims.mjs +108 -0
- package/js/hosts/hooks.mjs +50 -977
- package/js/hosts/hosts.mjs +12 -4
- package/js/hosts/installs.mjs +5 -6
- package/js/hosts/mcp.mjs +13 -49
- package/js/hosts/memory-files.mjs +181 -0
- package/js/hosts/native.mjs +43 -5
- package/js/hosts/settings.mjs +135 -414
- package/js/hosts/shim.mjs +373 -0
- package/js/inspect/checks-authoring.mjs +21 -828
- package/js/inspect/checks-build.mjs +32 -4
- package/js/inspect/checks-constitution.mjs +493 -0
- package/js/inspect/checks-counts.mjs +242 -0
- package/js/inspect/checks-repo.mjs +17 -8
- package/js/inspect/diff.mjs +4 -1
- package/js/inspect/doctor.mjs +2 -2
- package/js/inspect/inventory.mjs +4 -4
- package/js/inspect/scan.mjs +1 -1
- package/js/inspect/status.mjs +1 -1
- package/js/inspect/validate.mjs +48 -35
- package/js/loop/catalog.mjs +17 -4
- package/js/loop/cli.mjs +52 -16
- package/js/loop/graph.mjs +19 -1
- package/js/loop/registry.mjs +191 -0
- package/js/loop/score.mjs +46 -7
- package/js/loop/state.mjs +157 -19
- package/js/maintain/memory.mjs +1 -1
- package/js/maintain/migrate.mjs +18 -30
- package/js/maintain/setup.mjs +1 -1
- package/js/maintain/uninstall.mjs +47 -88
- package/js/ui/cli.mjs +12 -12
- package/js/web/actions.mjs +39 -4
- package/js/web/api.mjs +34 -21
- package/js/web/docs.mjs +87 -78
- package/js/web/routes.mjs +5 -1
- package/package.json +1 -1
- package/src/agents/explorer.md +2 -2
- package/src/agents/researcher.md +3 -3
- package/src/bricks/README.md +3 -1
- package/src/bricks/adr-challenge.md +20 -0
- package/src/bricks/adr-draft.md +15 -0
- package/src/bricks/characterize.md +17 -0
- package/src/bricks/ci-fix.md +2 -1
- package/src/bricks/ci-triage.md +20 -0
- package/src/bricks/compat-check.md +16 -0
- package/src/bricks/done-check.md +16 -0
- package/src/bricks/fitness-define.md +22 -0
- package/src/bricks/flake-check.md +17 -0
- package/src/bricks/flake-repro.md +18 -0
- package/src/bricks/identify.md +6 -3
- package/src/bricks/migration-plan.md +21 -0
- package/src/bricks/mutation-check.md +18 -0
- package/src/bricks/plan.md +6 -6
- package/src/bricks/red-test.md +16 -0
- package/src/bricks/review.md +5 -3
- package/src/bricks/spec.md +18 -0
- package/src/bricks/upgrade-scout.md +16 -0
- package/src/loops/api-endpoint.json +31 -0
- package/src/loops/architecture-decision.json +22 -0
- package/src/loops/bugfix.json +1 -0
- package/src/loops/ci-repair.json +19 -0
- package/src/loops/deps-upgrade.json +29 -0
- package/src/loops/enforce-architecture-rule.json +28 -0
- package/src/loops/feature.json +8 -5
- package/src/loops/fix-flaky-tests.json +30 -0
- package/src/loops/legacy-refactor.json +26 -0
- package/src/loops/legacy-tests.json +29 -0
- package/src/loops/refactor.json +5 -1
- package/src/loops/remove-dead-code.json +30 -0
- package/src/loops/spec-first-feature.json +29 -0
- package/src/loops/tdd.json +25 -0
- package/src/loops/update-contract.json +33 -0
- package/src/skills/brick-forge.md +3 -1
- package/src/skills/bruno-collection-generator.md +3 -3
- package/src/skills/bruno-test-writer.md +3 -3
- package/src/skills/ci-fix.md +3 -3
- package/src/skills/consolidate-memory.md +4 -4
- package/src/skills/council.md +1 -1
- package/src/skills/fresh-eyes.md +1 -1
- package/src/skills/git-archaeology.md +1 -1
- package/src/skills/git-rescue.md +4 -3
- package/src/skills/handoff.md +1 -1
- package/src/skills/herdr.md +1 -1
- package/src/skills/learn-mode.md +2 -2
- package/src/skills/loop.md +22 -10
- package/src/skills/opencode-theme.md +3 -3
- package/src/skills/ponytail.md +1 -1
- package/src/skills/profile.md +3 -4
- package/src/skills/prose.md +2 -3
- package/src/skills/quiz.md +1 -1
- package/src/skills/react-view-transitions/SKILL.md +1 -1
- package/src/skills/react-view-transitions/VENDOR.md +8 -3
- package/src/skills/research.md +3 -3
- package/src/skills/review-response.md +2 -2
- package/src/skills/rule.md +3 -3
- package/src/skills/security-audit.md +4 -4
- package/src/skills/teach.md +1 -1
- package/src/skills/wiki.md +3 -3
- package/src/skills/workflow.md +4 -4
- package/src/skills/worktree.md +2 -2
- package/web/dist/assets/Activity-CjRqZnr-.js +1 -0
- package/web/dist/assets/ActivityDetail-OQTxKBj2.js +1 -0
- package/web/dist/assets/Dashboard-J-xCe_Gk.js +2 -0
- package/web/dist/assets/Docs-CvOZ8akL.js +5 -0
- package/web/dist/assets/{GreenhouseView-C6ibc8IE.js → GreenhouseView-6KDjX8da.js} +1 -1
- package/web/dist/assets/{Installs-DRYjYnxs.js → Installs-MWuhNjSm.js} +2 -2
- package/web/dist/assets/JournalView-C8MrOz2c.js +1 -0
- package/web/dist/assets/Laws-Du0-Zyp2.js +3 -0
- package/web/dist/assets/Library-DOkO0TtB.js +1 -0
- package/web/dist/assets/Loops-Dv_aLByJ.js +2 -0
- package/web/dist/assets/{Markdown-qjM2_ofS.js → Markdown-X2a3qvOh.js} +14 -14
- package/web/dist/assets/{OperatorHudView-Bs7paWmh.js → OperatorHudView-BPP1dtLz.js} +1 -1
- package/web/dist/assets/{Personal-BY5n4gTw.js → Personal-26VPYkK0.js} +1 -1
- package/web/dist/assets/Tabs-BzKuFskD.js +1 -0
- package/web/dist/assets/{usePoll-O0sJEw1Y.js → activity-Dr7ZMBI3.js} +1 -1
- package/web/dist/assets/{api-CyMRbU2x.js → api-D_ixdyEY.js} +1 -1
- package/web/dist/assets/drawable-zVVDbsyw.js +1 -0
- package/web/dist/assets/index-Ctp-4poP.css +1 -0
- package/web/dist/assets/index-KHt3wE_K.js +2 -0
- package/web/dist/assets/usePoll-DD8VjtSg.js +1 -0
- package/web/dist/index.html +3 -3
- package/web/src/pages/Laws.jsx +0 -1
- package/web/dist/assets/Activity-GaaZWXTY.js +0 -1
- package/web/dist/assets/ActivityDetail-4t7d45Du.js +0 -1
- package/web/dist/assets/Dashboard-CYn7Tp16.js +0 -2
- package/web/dist/assets/Docs-BeFKLg2T.js +0 -5
- package/web/dist/assets/JournalView-1HbU1mfg.js +0 -1
- package/web/dist/assets/Laws-DFoG8JWB.js +0 -3
- package/web/dist/assets/Library-DDjgNwz_.js +0 -1
- package/web/dist/assets/Loops-D9VEoO0F.js +0 -1
- package/web/dist/assets/Tabs-ho-EYD8i.js +0 -1
- package/web/dist/assets/drawable-CLS9drMb.js +0 -1
- package/web/dist/assets/index-CX_hXMi5.css +0 -1
- package/web/dist/assets/index-Cjr7XCA3.js +0 -2
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The AUTHORING gates — whether the sources agree with themselves.
|
|
3
|
-
*
|
|
4
|
-
*
|
|
2
|
+
* The AUTHORING gates — whether the sources agree with themselves. `authoringProblems`
|
|
3
|
+
* aggregates them: the spec purpose lines and the plugins here, the constitution in
|
|
4
|
+
* `checks-constitution.mjs`, the counts in `checks-counts.mjs`, the repo records in
|
|
5
|
+
* `checks-repo.mjs`.
|
|
5
6
|
*
|
|
6
7
|
* Over half of the old `doctor.mjs` was this group, and it is the one with the most reasons
|
|
7
8
|
* to be read: adding a law, a doctrine or a skill moves numbers in files nobody edits by
|
|
@@ -12,830 +13,26 @@
|
|
|
12
13
|
* `node --check` over every OpenCode plugin. See `doctor.mjs` for what that allow-list costs.
|
|
13
14
|
*/
|
|
14
15
|
import path from 'node:path';
|
|
15
|
-
import {
|
|
16
|
-
CONFIG, DOCS, DOC_FOLDERS, PACK_ORDER, PLUGIN_SRC, RETIRED_RULE_IDS, ROOT, RULE_ID_RE, SRC, THEMES, canonFiles,
|
|
17
|
-
knownRuleIds, ruleCanon, titleKey,
|
|
18
|
-
} from '../build/source.mjs';
|
|
19
|
-
import { themeFiles } from '../hosts/installs.mjs';
|
|
16
|
+
import { PLUGIN_SRC, SRC } from '../build/source.mjs';
|
|
20
17
|
import { descBlockProblem, firstBlockquote } from '../hosts/native.mjs';
|
|
21
18
|
import { readText } from '../lib/fs.mjs';
|
|
22
|
-
import { parseJson, formatRepr } from '../lib/json.mjs';
|
|
23
19
|
import { which } from '../lib/paths.mjs';
|
|
24
20
|
import { NO_WINDOW } from '../lib/proc.mjs';
|
|
25
21
|
import {
|
|
26
22
|
folderSkillProblems, registryProblems, secretProblems, vendorPinProblems,
|
|
27
23
|
} from './checks-repo.mjs';
|
|
24
|
+
import { constitutionProblems, leanBlockProblems } from './checks-constitution.mjs';
|
|
25
|
+
import { countTableProblems } from './checks-counts.mjs';
|
|
28
26
|
import { catalogProblems } from '../loop/catalog.mjs';
|
|
29
|
-
import {
|
|
30
|
-
import { NOTE, globSorted, has, isDir, isFile, rglob, srcStems } from './scan.mjs';
|
|
27
|
+
import { globSorted, isDir } from './scan.mjs';
|
|
31
28
|
import { spawnSync } from 'node:child_process';
|
|
32
|
-
import { existsSync, readdirSync } from 'node:fs';
|
|
33
|
-
|
|
34
|
-
const ROMAN_VALUES = { I: 1, V: 5, X: 10, L: 50, C: 100, D: 500, M: 1000 };
|
|
35
|
-
|
|
36
|
-
/**
|
|
37
|
-
* `_harness_build._roman_to_int`. Law headings number in Roman and the web ledger keys its
|
|
38
|
-
* per-rule copy by the Arabic equivalent, so the gate has to bridge the two. 0 on an
|
|
39
|
-
* unparseable numeral, which surfaces as a gate problem rather than a crash.
|
|
40
|
-
*/
|
|
41
|
-
export function romanToInt(num) {
|
|
42
|
-
let total = 0;
|
|
43
|
-
let prev = 0;
|
|
44
|
-
for (const ch of [...num.toUpperCase()].reverse()) {
|
|
45
|
-
const v = ROMAN_VALUES[ch];
|
|
46
|
-
if (v === undefined) return 0;
|
|
47
|
-
total += v < prev ? -v : v;
|
|
48
|
-
prev = Math.max(prev, v);
|
|
49
|
-
}
|
|
50
|
-
return total;
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
/**
|
|
54
|
-
* `_harness_build._LAW_META_ROW` — `<arabic>: ['<class>', '<principle>', '<id>'],` in the web page.
|
|
55
|
-
*
|
|
56
|
-
* The third field PINS the row to a rule id. The key is the number the catalogue renders, and a
|
|
57
|
-
* number is only a position: remove a law and every later row describes its predecessor's
|
|
58
|
-
* neighbour, with every class still plausible. The pin is what makes that a doctor failure.
|
|
59
|
-
*
|
|
60
|
-
* Either quote style, because a principle carrying an apostrophe is double-quoted. Every
|
|
61
|
-
* `\s*` spans newlines and the trailing comma before `]` is optional, so a row a prettier has
|
|
62
|
-
* reflowed across several lines still matches — a gate that only understood the one-line form
|
|
63
|
-
* silently lost it and left the law's Principle unguarded.
|
|
64
|
-
*
|
|
65
|
-
* `(?m)` is the `m` flag; JS has no inline form. The `.` inside the captures must NOT match a
|
|
66
|
-
* newline (Python has no `re.S` here), which is JS's default.
|
|
67
|
-
*/
|
|
68
|
-
const LAW_META_ROW =
|
|
69
|
-
/^[^\S\n]*(\d+):\s*\[\s*(['"])(.*?)\2\s*,\s*(['"])(.*?)\4\s*(?:,\s*(['"])(.*?)\6\s*)?,?\s*\]\s*,?[^\S\n]*$/gm;
|
|
70
|
-
|
|
71
|
-
/**
|
|
72
|
-
* `_harness_build._law_meta_problems` — the web Laws ledger's per-rule copy stays complete.
|
|
73
|
-
*
|
|
74
|
-
* `LAW_META` holds each law's one-line Principle, copy that exists nowhere else in the tree,
|
|
75
|
-
* and a law with no row falls back to `['craft', '']`: the rule renders with a blank
|
|
76
|
-
* description and the wrong class chip, while everything upstream is fine. That is exactly
|
|
77
|
-
* how two laws shipped description-less.
|
|
78
|
-
*/
|
|
79
|
-
export function lawMetaProblems(lawNums, lawClass, lawClasses, lawIds = null) {
|
|
80
|
-
const page = path.join(ROOT, 'web', 'src', 'pages', 'Laws.jsx');
|
|
81
|
-
let text;
|
|
82
|
-
try { text = readText(page); } catch (e) {
|
|
83
|
-
return [`[authoring] web/src/pages/Laws.jsx unreadable: ${e.message}`];
|
|
84
|
-
}
|
|
85
|
-
const block = /^const LAW_META = \{$([\s\S]*?)^\}$/m.exec(text);
|
|
86
|
-
if (!block) {
|
|
87
|
-
return ['[authoring] LAW_META literal not found in web/src/pages/Laws.jsx — the web '
|
|
88
|
-
+ 'Laws ledger\'s Principle column would have no gate'];
|
|
89
|
-
}
|
|
90
|
-
const meta = new Map();
|
|
91
|
-
for (const m of block[1].matchAll(LAW_META_ROW)) {
|
|
92
|
-
meta.set(Number(m[1]), [m[3], m[5], m[7]]);
|
|
93
|
-
}
|
|
94
|
-
const problems = [];
|
|
95
|
-
const seen = new Set();
|
|
96
|
-
for (const [i, roman] of lawNums.entries()) {
|
|
97
|
-
const n = romanToInt(roman);
|
|
98
|
-
seen.add(n);
|
|
99
|
-
if (!meta.has(n)) {
|
|
100
|
-
problems.push(`[authoring] laws/universal.md rule ${roman} (${n}) has no row in `
|
|
101
|
-
+ 'LAW_META (web/src/pages/Laws.jsx) — the web Laws ledger would '
|
|
102
|
-
+ 'render it with a blank Principle');
|
|
103
|
-
continue;
|
|
104
|
-
}
|
|
105
|
-
const [klass, principle, pin] = meta.get(n);
|
|
106
|
-
if (lawIds && pin !== lawIds[i]) {
|
|
107
|
-
problems.push(`[authoring] LAW_META[${n}] is pinned to '${pin ?? '(no id)'}' but rule ${roman} is `
|
|
108
|
-
+ `'${lawIds[i]}' — the rows no longer follow the canon's order, so this principle `
|
|
109
|
-
+ 'describes a different law');
|
|
110
|
-
}
|
|
111
|
-
if (!principle.trim()) {
|
|
112
|
-
problems.push(`[authoring] LAW_META[${n}] has an empty principle line — rule `
|
|
113
|
-
+ `${roman} would render with a blank description`);
|
|
114
|
-
}
|
|
115
|
-
if (!lawClasses.includes(klass)) {
|
|
116
|
-
problems.push(`[authoring] LAW_META[${n}] class '${klass}' is not a known class `
|
|
117
|
-
+ `${formatRepr(lawClasses)}`);
|
|
118
|
-
} else if (lawClass[roman] && klass !== lawClass[roman]) {
|
|
119
|
-
problems.push(`[authoring] LAW_META[${n}] classes rule ${roman} as '${klass}' but `
|
|
120
|
-
+ `LAW_CLASS says '${lawClass[roman]}'`);
|
|
121
|
-
}
|
|
122
|
-
}
|
|
123
|
-
for (const n of [...meta.keys()].filter((k) => !seen.has(k)).sort((a, b) => a - b)) {
|
|
124
|
-
problems.push(`[authoring] LAW_META lists rule ${n} but laws/universal.md has no such rule`);
|
|
125
|
-
}
|
|
126
|
-
return problems;
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
/** `LAW_META_ROW`'s twin for a STRING key — `'<pack>.<n>': ['<pack>', '<principle>', '<id>'],`. */
|
|
130
|
-
const DOCTRINE_META_ROW =
|
|
131
|
-
/^[^\S\n]*(['"])([a-z]+\.\d+)\1:\s*\[\s*(['"])(.*?)\3\s*,\s*(['"])(.*?)\5\s*(?:,\s*(['"])(.*?)\7\s*)?,?\s*\]\s*,?[^\S\n]*$/gm;
|
|
132
|
-
|
|
133
|
-
/**
|
|
134
|
-
* `lawMetaProblems`' doctrine half — the console's per-rule Principle column stays complete.
|
|
135
|
-
*
|
|
136
|
-
* SAME DEFECT, ONE TIER OVER. `DOCTRINE_META` holds copy that exists nowhere else in the tree,
|
|
137
|
-
* and a rule with no row falls back to an empty principle: it renders with a blank description
|
|
138
|
-
* while every count upstream reads correct. Two invariants shipped exactly that way before
|
|
139
|
-
* `lawMetaProblems` existed, and 23 new rules is a much larger surface to lose one in.
|
|
140
|
-
*
|
|
141
|
-
* ⚠ THE CLASS ARM IS DIFFERENT FROM THE INVARIANTS'. An invariant's class is checked against
|
|
142
|
-
* `LAW_CLASSES`, a vocabulary of six; a doctrine rule has no second taxonomy — its class IS its
|
|
143
|
-
* pack — so the check is an EQUALITY against the pack in its own key. That catches the copy
|
|
144
|
-
* error a six-way vocabulary cannot: a row pasted from the pack above it, keeping the class of
|
|
145
|
-
* the pack it came from, which would colour the row and label its chip with the wrong pack.
|
|
146
|
-
*/
|
|
147
|
-
export function doctrineMetaProblems(addrs, ids = null) {
|
|
148
|
-
const page = path.join(ROOT, 'web', 'src', 'pages', 'Laws.jsx');
|
|
149
|
-
let text;
|
|
150
|
-
try { text = readText(page); } catch (e) {
|
|
151
|
-
return [`[authoring] web/src/pages/Laws.jsx unreadable: ${e.message}`];
|
|
152
|
-
}
|
|
153
|
-
const block = /^const DOCTRINE_META = \{$([\s\S]*?)^\}$/m.exec(text);
|
|
154
|
-
if (!block) {
|
|
155
|
-
return ['[authoring] DOCTRINE_META literal not found in web/src/pages/Laws.jsx — the '
|
|
156
|
-
+ "console's doctrine rows would have no gate on their Principle column"];
|
|
157
|
-
}
|
|
158
|
-
const meta = new Map();
|
|
159
|
-
for (const m of block[1].matchAll(DOCTRINE_META_ROW)) meta.set(m[2], [m[4], m[6], m[8]]);
|
|
160
|
-
const problems = [];
|
|
161
|
-
const seen = new Set();
|
|
162
|
-
for (const [i, addr] of addrs.entries()) {
|
|
163
|
-
seen.add(addr);
|
|
164
|
-
if (!meta.has(addr)) {
|
|
165
|
-
problems.push(`[authoring] doctrine rule ${addr} has no row in DOCTRINE_META `
|
|
166
|
-
+ '(web/src/pages/Laws.jsx) — the console would render it with a blank Principle');
|
|
167
|
-
continue;
|
|
168
|
-
}
|
|
169
|
-
const [klass, principle, pin] = meta.get(addr);
|
|
170
|
-
if (ids && pin !== ids[i]) {
|
|
171
|
-
problems.push(`[authoring] DOCTRINE_META['${addr}'] is pinned to '${pin ?? '(no id)'}' but `
|
|
172
|
-
+ `that address is '${ids[i]}' now — the row describes a different rule`);
|
|
173
|
-
}
|
|
174
|
-
if (!principle.trim()) {
|
|
175
|
-
problems.push(`[authoring] DOCTRINE_META['${addr}'] has an empty principle line — that `
|
|
176
|
-
+ 'rule would render with a blank description');
|
|
177
|
-
}
|
|
178
|
-
const pack = addr.split('.')[0];
|
|
179
|
-
if (klass !== pack) {
|
|
180
|
-
problems.push(`[authoring] DOCTRINE_META['${addr}'] carries pack '${klass}' — a doctrine `
|
|
181
|
-
+ `rule's class IS its pack, so this must read '${pack}'`);
|
|
182
|
-
}
|
|
183
|
-
}
|
|
184
|
-
for (const addr of [...meta.keys()].filter((k) => !seen.has(k)).sort()) {
|
|
185
|
-
problems.push(`[authoring] DOCTRINE_META lists ${addr} but no pack file defines it`);
|
|
186
|
-
}
|
|
187
|
-
return problems;
|
|
188
|
-
}
|
|
189
|
-
|
|
190
|
-
/**
|
|
191
|
-
* `_harness_build._prose_mirror_problems` — the human-readable count mirrors, the ones the
|
|
192
|
-
* badge regex never sees.
|
|
193
|
-
*
|
|
194
|
-
* The README "What you get" table and the web onboarding copy each restate the law / agent /
|
|
195
|
-
* skill counts in prose, and the README enumerates the skills by name. Nothing renders these,
|
|
196
|
-
* so they drift silently — they already had. Pure over its inputs, so the corpus can feed it
|
|
197
|
-
* crafted drift without touching the tree.
|
|
198
|
-
*/
|
|
199
|
-
export function proseMirrorProblems(readme, web, counts, skillStems, shipped = '') {
|
|
200
|
-
const problems = [];
|
|
201
|
-
const { laws, agents, skills, plugins } = counts;
|
|
202
|
-
|
|
203
|
-
for (const m of readme.matchAll(/(\d+) universal laws/g)) {
|
|
204
|
-
if (Number(m[1]) !== laws) {
|
|
205
|
-
problems.push(`[authoring] README prose says '${m[1]} universal laws' but src has ${laws}`);
|
|
206
|
-
}
|
|
207
|
-
}
|
|
208
|
-
// The README plugin count. It had drifted — the overview sentence said six while seven
|
|
209
|
-
// shipped — because the project's other plugin count ({N_PLUGINS} in the web docs) reads
|
|
210
|
-
// a DEPLOYED plugins dir that a markdown file in this repository never has. `plugins` is
|
|
211
|
-
// optional in `counts` (Python's `.get`, undefined here) because the corpus predates it.
|
|
212
|
-
if (plugins !== undefined) {
|
|
213
|
-
for (const m of readme.matchAll(/(\d+) \*{0,2}plugins/g)) {
|
|
214
|
-
if (Number(m[1]) !== plugins) {
|
|
215
|
-
problems.push(`[authoring] README prose says '${m[1]} plugins' but `
|
|
216
|
-
+ `adapters/opencode/plugins has ${plugins}`);
|
|
217
|
-
}
|
|
218
|
-
}
|
|
219
|
-
}
|
|
220
|
-
for (const [label, want] of [['Agents', agents], ['Skills', skills]]) {
|
|
221
|
-
const m = new RegExp(`${label}\\*\\*\\s*\\((\\d+)\\)`).exec(readme);
|
|
222
|
-
if (m && Number(m[1]) !== want) {
|
|
223
|
-
problems.push(`[authoring] README '${label} (${m[1]})' but src has ${want}`);
|
|
224
|
-
}
|
|
225
|
-
}
|
|
226
|
-
// The README Skills row must enumerate EXACTLY the source skills — a dropped name is
|
|
227
|
-
// invisible to the (N) count alone, which is the off-by-one this gate forbids.
|
|
228
|
-
const row = readme.split('\n').find((ln) => ln.includes('🛠') && ln.includes('Skills')) ?? '';
|
|
229
|
-
if (row.includes('workflows:')) {
|
|
230
|
-
const listed = new Set(row.split('workflows:').slice(1).join('workflows:').split('·')
|
|
231
|
-
.map((seg) => seg.replace(/[^a-z0-9-]/g, '')));
|
|
232
|
-
listed.delete('');
|
|
233
|
-
for (const missing of [...skillStems].filter((s) => !listed.has(s)).sort()) {
|
|
234
|
-
problems.push(`[authoring] README skills list omits '${missing}'`);
|
|
235
|
-
}
|
|
236
|
-
for (const orphan of [...listed].filter((s) => !skillStems.has(s)).sort()) {
|
|
237
|
-
problems.push(`[authoring] README skills list names '${orphan}' but no `
|
|
238
|
-
+ `skills/${orphan}.md exists`);
|
|
239
|
-
}
|
|
240
|
-
}
|
|
241
|
-
|
|
242
|
-
for (const m of web.matchAll(/(\d+) universal (?:laws|Rules)/g)) {
|
|
243
|
-
if (Number(m[1]) !== laws) {
|
|
244
|
-
problems.push(`[authoring] _web_core prose says '${m[1]} universal laws/Rules' but `
|
|
245
|
-
+ `src has ${laws}`);
|
|
246
|
-
}
|
|
247
|
-
}
|
|
248
|
-
for (const m of web.matchAll(/(\d+) capability specialists/g)) {
|
|
249
|
-
if (Number(m[1]) !== agents) {
|
|
250
|
-
problems.push(`[authoring] _web_core prose says '${m[1]} capability specialists' but `
|
|
251
|
-
+ `src has ${agents}`);
|
|
252
|
-
}
|
|
253
|
-
}
|
|
254
|
-
// N is a curated-subset size, not the total, so it is gated against the wikilinks it
|
|
255
|
-
// introduces rather than against the skill count. `[\s\S]` is Python's `re.S` dot.
|
|
256
|
-
const m = /(\d+) repeatable workflows the agent can invoke by name([\s\S]*?)playbook under/
|
|
257
|
-
.exec(web);
|
|
258
|
-
if (m) {
|
|
259
|
-
const listedN = (m[2].match(/\[\[[^\]]+\]\]/g) ?? []).length;
|
|
260
|
-
if (Number(m[1]) !== listedN) {
|
|
261
|
-
problems.push(`[authoring] _web_core says '${m[1]} repeatable workflows' `
|
|
262
|
-
+ `but its wikilink list has ${listedN}`);
|
|
263
|
-
}
|
|
264
|
-
}
|
|
265
|
-
|
|
266
|
-
// ⚠ THE ABSENCE OF THIS TRIPLE IS CHECKED BY THE CALLER, NOT HERE, AND THAT IS FORCED RATHER
|
|
267
|
-
// THAN CHOSEN. This arm is satisfiable by DELETING THE SENTENCE — no match, no problem, green
|
|
268
|
-
// — which is a gate failing open. The obvious fix is an `else` right here, and it cannot go
|
|
269
|
-
// here: `prose_mirror_problems` is one of the pure functions recorded in
|
|
270
|
-
// `tests/__snapshots__/primitives/`, the recording holds a case that passes an EMPTY `shipped`
|
|
271
|
-
// and expects `[]`, and there is no recorder left to re-bless it. So the presence check lives
|
|
272
|
-
// in `countTableProblems`, which is not recorded — beside the read that already knows whether
|
|
273
|
-
// the file could be opened at all, which is where it reads better anyway.
|
|
274
|
-
const s = /(\d+) laws, (\d+) agents, (\d+) skills/.exec(shipped);
|
|
275
|
-
if (s) {
|
|
276
|
-
for (const [got, want, label] of [[s[1], laws, 'laws'], [s[2], agents, 'agents'],
|
|
277
|
-
[s[3], skills, 'skills']]) {
|
|
278
|
-
if (Number(got) !== want) {
|
|
279
|
-
problems.push(`[authoring] SHIPPED.md says '${got} ${label}' but src has ${want}`);
|
|
280
|
-
}
|
|
281
|
-
}
|
|
282
|
-
}
|
|
283
|
-
return problems;
|
|
284
|
-
}
|
|
285
|
-
|
|
286
|
-
/** `_build_render._TMPL_SPEC_RE`, as the count gate spells it. */
|
|
287
|
-
const TMPL_SPEC_RE = /\{\{DIR_(AGENTS|SKILLS)\}\}\/([A-Za-z0-9_-]+)\.md/g;
|
|
288
|
-
// A rule is DECLARED by its heading and CITED by its id — see `ruleCanon` in
|
|
289
|
-
// `js/build/source.mjs`. The gates below read the RAW source, because the render resolves ids and
|
|
290
|
-
// a bad one would already be gone from what it hands back.
|
|
291
|
-
const DECL_LINE_RE = /^### \{\{(LAW|DOCTRINE):([^}]*)\}\}[ \t]+\S/;
|
|
292
|
-
const ID_CITE_RE = /\{\{(LAW|DOCTRINE):([^}]*)\}\}/g;
|
|
293
|
-
// The pre-id spellings. Either one in src/ is a citation that names a POSITION, which is exactly
|
|
294
|
-
// what stops being true the next time the canon moves.
|
|
295
|
-
const BARE_CITE_RE = /\{\{LAW\}\}\s+[IVXLCDM]+\b|\{\{DOCTRINE\}\}\s+[a-z]+\s+\d+\b/g;
|
|
296
|
-
// `{{DOCTRINE}}` or `{{DOCTRINE:` and not `DOCTRINE`: `{{DOCTRINES}}` is the SECTION noun and is
|
|
297
|
-
// legal everywhere, including in an invariant that points at the tier as a whole.
|
|
298
|
-
const DOCTRINE_TOKEN_RE = /\{\{DOCTRINE(?:\}\}|:)/;
|
|
299
|
-
|
|
300
|
-
/**
|
|
301
|
-
* The rules the hooks enforce at the tool boundary, and the address each is bound to there. The
|
|
302
|
-
* hook path cannot afford to read the canon on every tool call, so its gate-ledger keys
|
|
303
|
-
* (`law-1`, `law-4`, `process-1`, `process-5` in `js/hosts/hooks.mjs` and the OpenCode guard
|
|
304
|
-
* plugin), `CONSENT_RULE` in `js/hosts/settings.mjs` and the console's `HOOK_GATED` stay
|
|
305
|
-
* number-keyed — and this pin is what turns a renumber that moves one of these rules into a
|
|
306
|
-
* doctor failure instead of a gate silently guarding its neighbour.
|
|
307
|
-
*/
|
|
308
|
-
export const HOOK_PINNED = {
|
|
309
|
-
'sealed-secrets': 'I', 'deletion-is-deliberate': 'IV',
|
|
310
|
-
'persist-insight': 'process 1', 'consent-before-push': 'process 5',
|
|
311
|
-
};
|
|
312
|
-
|
|
313
|
-
/**
|
|
314
|
-
* The LEAN block grammar, and the heading split, both duplicated from `js/build/render.mjs`.
|
|
315
|
-
*
|
|
316
|
-
* Deliberately duplicated rather than imported: both are module-private
|
|
317
|
-
* there, and exporting them so one gate could borrow them would widen the renderer's surface
|
|
318
|
-
* for a reader that only needs to know the shape. Two literals, one paragraph apart from the
|
|
319
|
-
* rule they encode, is cheaper than a seam.
|
|
320
|
-
*/
|
|
321
|
-
const LEAN_BLOCK_RE =
|
|
322
|
-
/[ \t]*<!-- LEAN:begin -->\n([\s\S]*?)[ \t]*<!-- LEAN:else -->\n([\s\S]*?)[ \t]*<!-- LEAN:end -->\n/g;
|
|
323
|
-
|
|
324
|
-
/** `### ` at line start — the same cut `splitAtLawHeadings` makes, and for the same addresses. */
|
|
325
|
-
function leanRuleBlocks(text) {
|
|
326
|
-
const out = [];
|
|
327
|
-
let start = 0;
|
|
328
|
-
for (let i = 0; i < text.length; i++) {
|
|
329
|
-
if ((i === 0 || text[i - 1] === '\n') && text.startsWith('### ', i)) {
|
|
330
|
-
out.push(text.slice(start, i));
|
|
331
|
-
start = i;
|
|
332
|
-
}
|
|
333
|
-
}
|
|
334
|
-
out.push(text.slice(start));
|
|
335
|
-
return out.slice(1); // drop the lead chunk: the file's preamble
|
|
336
|
-
}
|
|
337
|
-
|
|
338
|
-
/**
|
|
339
|
-
* Every authored rule carries exactly one LEAN block, and both halves say something.
|
|
340
|
-
*
|
|
341
|
-
* ⚠ `resolveLean` IS A REPLACE OVER A MARKER PAIR, WHICH MAKES IT A NO-OP ON MARKERLESS TEXT.
|
|
342
|
-
* A rule whose block was never written, or lost to an edit, therefore ships its FULL body at
|
|
343
|
-
* the default footprint — silently, in every install, with every other gate in the tree green.
|
|
344
|
-
* That is the failure this arm exists for, and before it there was no LEAN gate anywhere in
|
|
345
|
-
* `js/inspect/`: not for the doctrine packs, and not for the invariants either.
|
|
346
|
-
*
|
|
347
|
-
* The other two shapes are cheaper to state than to debug. An empty lean half renders a
|
|
348
|
-
* heading over nothing at the footprint most installs use. Two blocks in one rule are
|
|
349
|
-
* ambiguous by construction — `LEAN_BLOCK_RE` is global, so both would resolve, and which
|
|
350
|
-
* text the reader gets would be a fact about the order they were written in.
|
|
351
|
-
*
|
|
352
|
-
* The ontology is exempt on purpose: it carries ONE file-wide block whose halves each repeat
|
|
353
|
-
* the four `####` section headings, which is a different shape with its own gate in
|
|
354
|
-
* `tests/unit/emit_smoke.test.mjs` (four distinct names, eight occurrences).
|
|
355
|
-
*/
|
|
356
|
-
export function leanBlockProblems() {
|
|
357
|
-
const problems = [];
|
|
358
|
-
const files = [['laws', path.join(SRC, 'laws', 'universal.md')]];
|
|
359
|
-
for (const pack of PACK_ORDER) files.push(['doctrines', path.join(SRC, 'doctrines', `${pack}.md`)]);
|
|
360
|
-
|
|
361
|
-
for (const [dir, file] of files) {
|
|
362
|
-
const rel = `${dir}/${path.basename(file)}`;
|
|
363
|
-
let text;
|
|
364
|
-
try { text = readText(file); } catch (e) {
|
|
365
|
-
problems.push(`[authoring] ${rel} unreadable: ${e.message} — every rule in it would ship `
|
|
366
|
-
+ 'its full body at the lean footprint, unannounced');
|
|
367
|
-
continue;
|
|
368
|
-
}
|
|
369
|
-
for (const block of leanRuleBlocks(text)) {
|
|
370
|
-
// The address as the SOURCE spells it — `{{LAW}} II`, `{{DOCTRINE}} craft 1` — because
|
|
371
|
-
// that is the string an author greps for, and the rendered spelling is fourteen strings.
|
|
372
|
-
const heading = block.slice(0, block.indexOf('\n') === -1 ? undefined : block.indexOf('\n'));
|
|
373
|
-
const addr = heading.replace(/^###\s+/, '').split(/\s+[—-]\s+/)[0].trim();
|
|
374
|
-
const found = [...block.matchAll(LEAN_BLOCK_RE)];
|
|
375
|
-
if (found.length === 0) {
|
|
376
|
-
problems.push(`[authoring] ${rel}: ${addr} has no LEAN block — resolveLean is a no-op on `
|
|
377
|
-
+ 'markerless text, so the rule would inline its FULL body at the default footprint');
|
|
378
|
-
continue;
|
|
379
|
-
}
|
|
380
|
-
if (found.length > 1) {
|
|
381
|
-
problems.push(`[authoring] ${rel}: ${addr} has ${found.length} LEAN blocks — both resolve, `
|
|
382
|
-
+ 'so which text a reader is handed is a fact about the order they were written in');
|
|
383
|
-
}
|
|
384
|
-
const [, full, lean] = found[0];
|
|
385
|
-
if (!full.trim()) {
|
|
386
|
-
problems.push(`[authoring] ${rel}: ${addr} has an empty full half — the rule would vanish `
|
|
387
|
-
+ 'from a full build and from its own on-disk file');
|
|
388
|
-
}
|
|
389
|
-
if (!lean.trim()) {
|
|
390
|
-
problems.push(`[authoring] ${rel}: ${addr} has an empty lean half — the rule would render `
|
|
391
|
-
+ 'as a heading over nothing at the footprint most installs use');
|
|
392
|
-
}
|
|
393
|
-
}
|
|
394
|
-
}
|
|
395
|
-
return problems;
|
|
396
|
-
}
|
|
397
|
-
|
|
398
|
-
/**
|
|
399
|
-
* The three-tier constitution's own authoring gates — the doctrine twin of the `LAW_CLASS` /
|
|
400
|
-
* `LAW_META` family, plus the two purity rules the tiering rests on.
|
|
401
|
-
*
|
|
402
|
-
* IT LIVES BESIDE `countTableProblems` RATHER THAN INSIDE IT, and is called from
|
|
403
|
-
* `authoringProblems` beside `registry`/`secret`/`vendorPin`. `countTableProblems` opens by
|
|
404
|
-
* reading `AGENT.md.tmpl` and HARD-RETURNS if that read fails, so an arm placed inside it
|
|
405
|
-
* would go quiet on exactly the tree that is most broken. Nothing here needs the template's
|
|
406
|
-
* tables, and `proseMirrorProblems` — the frozen recording — is untouched either way.
|
|
407
|
-
*
|
|
408
|
-
* ⚠ THE VOCABULARY GATES ARE HERE BECAUSE `themeParityProblems` CANNOT SEE THEM. That check is
|
|
409
|
-
* presence-only and SYMMETRIC over the union of every theme's keys: a key missing from all
|
|
410
|
-
* fourteen is in parity, and a key whose VALUE breaks a parser is not its business at all. So
|
|
411
|
-
* `DOC_*` coverage, the `LEX_*` equality and the single-word tier noun each need a gate
|
|
412
|
-
* that reads the SOURCE and asks what the source requires.
|
|
413
|
-
*/
|
|
414
|
-
export function constitutionProblems() {
|
|
415
|
-
const problems = [];
|
|
416
|
-
|
|
417
|
-
// ---- every rule declares an id, every id is well-formed and unique, none is retired.
|
|
418
|
-
//
|
|
419
|
-
// Numbers are positional now (`ruleCanon`), so contiguity and filing cannot go wrong; what can
|
|
420
|
-
// is the declaration itself. A `### ` line in a canon file that is not `{{KIND:id}} Name`
|
|
421
|
-
// renders nowhere as a rule — no number, no title — and a duplicated id makes every citation
|
|
422
|
-
// of it ambiguous.
|
|
423
|
-
for (const [kind, pack, file] of canonFiles(SRC)) {
|
|
424
|
-
const rel = path.relative(SRC, file).split(path.sep).join('/');
|
|
425
|
-
let text;
|
|
426
|
-
try { text = readText(file); } catch (e) {
|
|
427
|
-
problems.push(`[authoring] ${rel} is ${pack ? 'named in PACK_ORDER' : 'the canon'} but `
|
|
428
|
-
+ `unreadable: ${e.message} — every citation into it breaks`);
|
|
429
|
-
continue;
|
|
430
|
-
}
|
|
431
|
-
let count = 0;
|
|
432
|
-
for (const line of text.split('\n').filter((l) => l.startsWith('### '))) {
|
|
433
|
-
const m = DECL_LINE_RE.exec(line);
|
|
434
|
-
if (!m || m[1] !== kind) {
|
|
435
|
-
problems.push(`[authoring] ${rel}: '${line}' is not a rule declaration — a rule heading `
|
|
436
|
-
+ `here reads '### {{${kind}:<id>}} <Principle Name>'; without an id it has no number, `
|
|
437
|
-
+ 'no title and no way to be cited');
|
|
438
|
-
continue;
|
|
439
|
-
}
|
|
440
|
-
count += 1;
|
|
441
|
-
if (!RULE_ID_RE.test(m[2])) {
|
|
442
|
-
problems.push(`[authoring] ${rel}: rule id '${m[2]}' is not kebab-case (a-z, 0-9, '-')`);
|
|
443
|
-
}
|
|
444
|
-
}
|
|
445
|
-
if (!count) {
|
|
446
|
-
const why = pack ? 'the pack would render as an empty section' : 'there would be no invariants';
|
|
447
|
-
problems.push(`[authoring] ${rel} declares no rule — ${why}`);
|
|
448
|
-
}
|
|
449
|
-
}
|
|
450
|
-
const canon = ruleCanon(SRC);
|
|
451
|
-
const seenIds = new Map();
|
|
452
|
-
const seenNames = new Map();
|
|
453
|
-
for (const r of canon.rules) {
|
|
454
|
-
const where = r.kind === 'LAW' ? `law ${r.label}` : `doctrine ${r.label}`;
|
|
455
|
-
if (seenIds.has(r.id)) {
|
|
456
|
-
problems.push(`[authoring] rule id '${r.id}' is declared twice (${seenIds.get(r.id)} and `
|
|
457
|
-
+ `${where}) — ids are unique across laws and doctrines, or a citation names two rules`);
|
|
458
|
-
} else seenIds.set(r.id, where);
|
|
459
|
-
const lower = r.name.toLowerCase();
|
|
460
|
-
if (seenNames.has(lower)) {
|
|
461
|
-
problems.push(`[authoring] principle name '${r.name}' is declared twice (${seenNames.get(lower)} `
|
|
462
|
-
+ `and ${where}) — a citation renders as the name, so two rules would read as one`);
|
|
463
|
-
} else seenNames.set(lower, where);
|
|
464
|
-
const retiredAs = RETIRED_RULE_IDS[r.id];
|
|
465
|
-
if (retiredAs && retiredAs.liveAs !== r.kind) {
|
|
466
|
-
problems.push(`[authoring] ${where} reuses the retired id '${r.id}' (${retiredAs.was}, `
|
|
467
|
-
+ `${retiredAs.note}) — an id that meant one rule in someone's notes must never come back `
|
|
468
|
-
+ 'meaning another; pick a new one');
|
|
469
|
-
}
|
|
470
|
-
}
|
|
471
|
-
const rules = new Map(canon.rules.filter((r) => r.kind === 'DOCTRINE')
|
|
472
|
-
.map((r) => [`${r.pack}.${r.n}`, r]));
|
|
473
|
-
|
|
474
|
-
// ---- the hook-gated rules are still at the address the hooks key them by.
|
|
475
|
-
for (const [id, label] of Object.entries(HOOK_PINNED)) {
|
|
476
|
-
const r = canon.byId.get(id);
|
|
477
|
-
if (!r || r.label !== label) {
|
|
478
|
-
problems.push(`[authoring] the hooks gate '${id}' as ${label} (js/hosts/hooks.mjs ledger keys, `
|
|
479
|
-
+ `settings.mjs CONSENT_RULE, the console's HOOK_GATED), but the canon ${r ? `numbers it ${r.label}`
|
|
480
|
-
: 'no longer declares it'} — move those keys with it, or the tool boundary guards a `
|
|
481
|
-
+ 'different rule than the prompt names');
|
|
482
|
-
}
|
|
483
|
-
}
|
|
484
|
-
|
|
485
|
-
// The console's Principle column, keyed off the rules just parsed — so the gate is driven by
|
|
486
|
-
// the SOURCE and a rule added to a pack file is caught the same day, not when someone
|
|
487
|
-
// notices a blank description in the browser.
|
|
488
|
-
problems.push(...doctrineMetaProblems([...rules.keys()], [...rules.values()].map((r) => r.id)));
|
|
489
|
-
|
|
490
|
-
// ---- `--exclude-rules` closes against the SAME rules this walk just found.
|
|
491
|
-
//
|
|
492
|
-
// `knownRuleIds` is what the CLI flag, the console's trust boundary and every wizard prompt
|
|
493
|
-
// validate an address against; the walk above is what the constitution actually renders.
|
|
494
|
-
// Two readers of one directory is the shape that lets a rule be authored, rendered, cited —
|
|
495
|
-
// and rejected by the only flag that can switch it off, with `invalid choice` naming a set
|
|
496
|
-
// that visibly contains it. An equality, not containment: a stale id in the enumerator is a
|
|
497
|
-
// rule the console offers a switch for and the build cannot drop.
|
|
498
|
-
const enumerated = knownRuleIds();
|
|
499
|
-
const walked = [...rules.keys()];
|
|
500
|
-
for (const id of enumerated.filter((i) => !walked.includes(i))) {
|
|
501
|
-
problems.push(`[authoring] knownRuleIds() offers '${id.replace('.', ' ')}', which no pack `
|
|
502
|
-
+ 'file defines — --exclude-rules accepts an address the build cannot drop');
|
|
503
|
-
}
|
|
504
|
-
for (const id of walked.filter((i) => !enumerated.includes(i))) {
|
|
505
|
-
problems.push(`[authoring] doctrine ${id.replace('.', ' ')} is defined but knownRuleIds() `
|
|
506
|
-
+ 'does not offer it — --exclude-rules refuses a rule that exists, and the console has '
|
|
507
|
-
+ 'no switch for it');
|
|
508
|
-
}
|
|
509
|
-
|
|
510
|
-
// ---- every citation in src/ is by id, and every id resolves.
|
|
511
|
-
//
|
|
512
|
-
// The whole tree, not the template: a skill footer citing a rule that does not exist is as
|
|
513
|
-
// broken as a template one, and it renders into every bundle.
|
|
514
|
-
const cited = new Map(); // 'process.5' -> [rel, …]
|
|
515
|
-
for (const p of rglob(SRC)) {
|
|
516
|
-
if (!isFile(p) || !(p.endsWith('.md') || p.endsWith('.tmpl'))) continue;
|
|
517
|
-
let text;
|
|
518
|
-
try { text = readText(p); } catch { continue; }
|
|
519
|
-
const rel = path.relative(SRC, p).split(path.sep).join('/');
|
|
520
|
-
// ⚠ AN ALWAYS-ON TIER MAY NEVER CITE A TOGGLEABLE ONE (D2). A `--doctrines craft` build
|
|
521
|
-
// ships invariants that point at rules its AGENT.md does not contain; the ontology is
|
|
522
|
-
// worse still, being the tier that states the order the others sit in. Doctrine->doctrine
|
|
523
|
-
// citations are fine and stay: the full catalogue ships in every bundle.
|
|
524
|
-
if ((rel.startsWith('ontology/') || rel.startsWith('laws/')) && DOCTRINE_TOKEN_RE.test(text)) {
|
|
525
|
-
problems.push(`[authoring] ${rel} cites {{DOCTRINE}} — an always-on tier may never `
|
|
526
|
-
+ 'reference a toggleable one, or a pack-off build ships a rule pointing at text '
|
|
527
|
-
+ 'that is not there. State the point directly instead');
|
|
528
|
-
}
|
|
529
|
-
for (const [bare] of text.matchAll(BARE_CITE_RE)) {
|
|
530
|
-
problems.push(`[authoring] ${rel} cites '${bare.replace(/\s+/g, ' ')}' by number — numbers are `
|
|
531
|
-
+ 'positional and move when the canon does; cite {{LAW:<id>}} / {{DOCTRINE:<id>}}');
|
|
532
|
-
}
|
|
533
|
-
for (const [whole, kind, id] of text.matchAll(ID_CITE_RE)) {
|
|
534
|
-
const r = canon.byId.get(id);
|
|
535
|
-
if (r && r.kind === kind) {
|
|
536
|
-
if (kind === 'DOCTRINE') {
|
|
537
|
-
const key = `${r.pack}.${r.n}`;
|
|
538
|
-
if (!cited.has(key)) cited.set(key, []);
|
|
539
|
-
cited.get(key).push(rel);
|
|
540
|
-
}
|
|
541
|
-
continue;
|
|
542
|
-
}
|
|
543
|
-
const retiredAs = RETIRED_RULE_IDS[id];
|
|
544
|
-
let why = 'which no law or doctrine declares — it would render as the raw token';
|
|
545
|
-
if (r) why = `but '${id}' is a ${r.kind === 'LAW' ? 'law' : 'doctrine rule'} — use {{${r.kind}:${id}}}`;
|
|
546
|
-
else if (retiredAs) {
|
|
547
|
-
why = `a retired id (${retiredAs.was}, ${retiredAs.note}) — cite the rule its text now lives in`;
|
|
548
|
-
}
|
|
549
|
-
problems.push(`[authoring] ${rel} cites ${whole}, ${why}`);
|
|
550
|
-
}
|
|
551
|
-
}
|
|
552
|
-
|
|
553
|
-
// ---- the vocabulary every theme owes the source: one title per declared rule, keyed by id.
|
|
554
|
-
const wanted = new Set(canon.rules.filter((r) => r.kind === 'DOCTRINE').map((r) => titleKey(r.kind, r.id)));
|
|
555
|
-
const lexWant = canon.rules.filter((r) => r.kind === 'LAW').map((r) => titleKey(r.kind, r.id));
|
|
556
|
-
// `_TEMPLATE.json` is included — it is the file `--sync-themes` seeds a new theme from, so a
|
|
557
|
-
// stale key there propagates into every voice added afterwards. `themeFiles()` excludes it.
|
|
558
|
-
const files = [...themeFiles(), path.join(THEMES, '_TEMPLATE.json')];
|
|
559
|
-
for (const f of files) {
|
|
560
|
-
const name = path.basename(f);
|
|
561
|
-
let theme;
|
|
562
|
-
try { theme = parseJson(readText(f)); } catch (e) {
|
|
563
|
-
problems.push(`[authoring] ${name} unreadable: ${e.message}`);
|
|
564
|
-
continue;
|
|
565
|
-
}
|
|
566
|
-
if (!theme || typeof theme !== 'object' || Array.isArray(theme)) continue;
|
|
567
|
-
const keys = Object.keys(theme);
|
|
568
|
-
for (const key of [...wanted].sort()) {
|
|
569
|
-
if (!Object.hasOwn(theme, key)) {
|
|
570
|
-
problems.push(`[authoring] ${name} has no {${key}} — a doctrine rule declares it, and `
|
|
571
|
-
+ 'theme parity cannot see a key that is missing from every theme at once');
|
|
572
|
-
}
|
|
573
|
-
}
|
|
574
|
-
for (const key of keys.filter((k) => k.startsWith('DOC_')).sort()) {
|
|
575
|
-
if (!wanted.has(key)) {
|
|
576
|
-
problems.push(`[authoring] ${name} defines {${key}}, which no doctrine rule declares — a `
|
|
577
|
-
+ 'dead voice key ships a title for a rule that does not exist');
|
|
578
|
-
}
|
|
579
|
-
}
|
|
580
|
-
// I1 — an EQUALITY, not a presence check. The renumber left `LEX_XXII`, `LEX_XXIII`,
|
|
581
|
-
// `LEX_XXIV` and `LEX_XXXVI` behind in all fifteen files, and parity was silent because
|
|
582
|
-
// they were absent from nowhere.
|
|
583
|
-
const lex = keys.filter((k) => k.startsWith('LEX_')).sort();
|
|
584
|
-
const stale = lex.filter((k) => !lexWant.includes(k));
|
|
585
|
-
const missing = lexWant.filter((k) => !lex.includes(k));
|
|
586
|
-
for (const k of stale) {
|
|
587
|
-
problems.push(`[authoring] ${name} still defines {${k}} — no law declares this id and `
|
|
588
|
-
+ 'nothing renders this key, so it is a title for a rule that no longer exists');
|
|
589
|
-
}
|
|
590
|
-
for (const k of missing) {
|
|
591
|
-
problems.push(`[authoring] ${name} has no {${k}} — one of the invariants would `
|
|
592
|
-
+ 'render with an empty title');
|
|
593
|
-
}
|
|
594
|
-
// M10 — ⚠ BOTH HEADING PARSERS MATCH THE TIER NOUN WITH `\S+`. A two-word value does not
|
|
595
|
-
// error; the heading stops matching and the tier silently parses to nothing. `_TEMPLATE`'s
|
|
596
|
-
// values are descriptive placeholders, so only real voices are held to this.
|
|
597
|
-
if (name === '_TEMPLATE.json') continue;
|
|
598
|
-
for (const key of ['LAW', 'DOCTRINE']) {
|
|
599
|
-
const v = theme[key];
|
|
600
|
-
if (typeof v === 'string' && /\s/.test(v)) {
|
|
601
|
-
problems.push(`[authoring] ${name} spells {${key}} as ${formatRepr(v)} — the heading `
|
|
602
|
-
+ 'parsers match the tier noun with \\S+, so a value with a space makes every '
|
|
603
|
-
+ `${key === 'LAW' ? 'invariant' : 'doctrine rule'} parse to nothing, in silence`);
|
|
604
|
-
}
|
|
605
|
-
}
|
|
606
|
-
}
|
|
607
29
|
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
// have caught those: §7 still existed, it had just stopped being the Wiki.
|
|
615
|
-
//
|
|
616
|
-
// What it does catch is the other half of a renumber — a pointer past the end, which is what
|
|
617
|
-
// REMOVING a section leaves behind — and it is nearly free. The half it cannot catch is a
|
|
618
|
-
// judgement about intent, and `docs/extending.md` carries the manual sweep for it. Naming
|
|
619
|
-
// that limit here is the point: a reader who assumes this gate is total will skip the sweep.
|
|
620
|
-
const declared = new Set();
|
|
621
|
-
// Read here rather than borrowing `countTableProblems`' copy: that function hard-returns when
|
|
622
|
-
// the template is unreadable, and this gate must not inherit a silence it did not choose.
|
|
623
|
-
let tmpl = '';
|
|
624
|
-
try { tmpl = readText(path.join(SRC, 'AGENT.md.tmpl')); } catch { tmpl = ''; }
|
|
625
|
-
for (const [, n] of tmpl.matchAll(/^## (\d+)\.\s/gm)) declared.add(Number(n));
|
|
626
|
-
if (declared.size) {
|
|
627
|
-
for (const p of rglob(SRC)) {
|
|
628
|
-
if (!isFile(p) || !(p.endsWith('.md') || p.endsWith('.tmpl'))) continue;
|
|
629
|
-
let text;
|
|
630
|
-
try { text = readText(p); } catch { continue; }
|
|
631
|
-
const rel = path.relative(SRC, p).split(path.sep).join('/');
|
|
632
|
-
for (const [, n] of text.matchAll(/§(\d+)/g)) {
|
|
633
|
-
if (declared.has(Number(n))) continue;
|
|
634
|
-
problems.push(`[authoring] ${rel} cites AGENT.md §${n}, and the template declares no `
|
|
635
|
-
+ `such section ${formatRepr([...declared].sort((a, b) => a - b).map(String))} — a `
|
|
636
|
-
+ 'renumber moved it, and this pointer renders into every bundle');
|
|
637
|
-
}
|
|
638
|
-
}
|
|
639
|
-
}
|
|
640
|
-
|
|
641
|
-
// ---- the build default names packs that exist.
|
|
642
|
-
let cfgDoctrines = null;
|
|
643
|
-
try {
|
|
644
|
-
const cfg = parseJson(readText(CONFIG));
|
|
645
|
-
if (cfg && typeof cfg === 'object' && Array.isArray(cfg.doctrines)) cfgDoctrines = cfg.doctrines;
|
|
646
|
-
} catch { /* no config, or unreadable — `configDefaults()` is forgiving here too */ }
|
|
647
|
-
if (cfgDoctrines) {
|
|
648
|
-
for (const pack of cfgDoctrines) {
|
|
649
|
-
if (!PACK_ORDER.includes(pack)) {
|
|
650
|
-
problems.push(`[authoring] harness.config.json names doctrine pack ${formatRepr(pack)}, `
|
|
651
|
-
+ `which this checkout does not ship ${formatRepr([...PACK_ORDER])} — every build reading `
|
|
652
|
-
+ 'that default would fail');
|
|
653
|
-
}
|
|
654
|
-
}
|
|
655
|
-
}
|
|
656
|
-
|
|
657
|
-
// ---- D5, a NOTE and not a problem: what a narrowed default leaves pointing at the shelf.
|
|
658
|
-
const on = cfgDoctrines ?? [...PACK_ORDER];
|
|
659
|
-
const dangling = [...cited].filter(([k]) => rules.has(k) && !on.includes(k.split('.')[0]));
|
|
660
|
-
if (dangling.length) {
|
|
661
|
-
const packs = [...new Set(dangling.map(([k]) => k.split('.')[0]))].sort();
|
|
662
|
-
problems.push(`${NOTE}harness.config.json builds ${formatRepr(on)}, and ${dangling.length} `
|
|
663
|
-
+ `citation(s) in src/ point into ${packs.join(', ')}. Those rules still SHIP — every `
|
|
664
|
-
+ 'pack file is in the bundle — so the reference resolves on disk; it is the rendered '
|
|
665
|
-
+ 'AGENT.md that will not contain them. Legal, and recorded here so it is not silent');
|
|
666
|
-
}
|
|
667
|
-
return problems;
|
|
668
|
-
}
|
|
669
|
-
|
|
670
|
-
/**
|
|
671
|
-
* `_harness_build._count_table_problems` — the hand-authored AGENT.md tables and the README
|
|
672
|
-
* badges, against `src/`.
|
|
673
|
-
*
|
|
674
|
-
* The tables must list EXACTLY the spec files (no dead row, no orphaned spec) and each
|
|
675
|
-
* `agents`/`skills`/`laws`/`themes` badge must equal the real count. This is the
|
|
676
|
-
* authoring-time guarantee that lets tables, badges and prose stay hand-written without
|
|
677
|
-
* silently drifting from the source tree.
|
|
678
|
-
*/
|
|
679
|
-
export function countTableProblems() {
|
|
680
|
-
const problems = [];
|
|
681
|
-
let ttext;
|
|
682
|
-
try { ttext = readText(path.join(SRC, 'AGENT.md.tmpl')); } catch (e) {
|
|
683
|
-
return [`[authoring] AGENT.md.tmpl unreadable: ${e.message}`];
|
|
684
|
-
}
|
|
685
|
-
|
|
686
|
-
const linked = { agents: new Set(), skills: new Set() };
|
|
687
|
-
for (const m of ttext.matchAll(TMPL_SPEC_RE)) {
|
|
688
|
-
if (m[2] !== '_template') linked[m[1] === 'AGENTS' ? 'agents' : 'skills'].add(m[2]);
|
|
689
|
-
}
|
|
690
|
-
for (const folder of ['agents', 'skills']) {
|
|
691
|
-
const files = srcStems(folder);
|
|
692
|
-
for (const missing of [...linked[folder]].filter((x) => !files.has(x)).sort()) {
|
|
693
|
-
problems.push(`[authoring] AGENT.md links ${folder}/${missing}.md but no such spec exists`);
|
|
694
|
-
}
|
|
695
|
-
for (const orphan of [...files].filter((x) => !linked[folder].has(x)).sort()) {
|
|
696
|
-
problems.push(`[authoring] ${folder}/${orphan}.md exists but the AGENT.md table omits it`);
|
|
697
|
-
}
|
|
698
|
-
}
|
|
699
|
-
|
|
700
|
-
const skillFiles = srcStems('skills');
|
|
701
|
-
for (const missing of [...skillFiles].filter((s) => !has(SKILL_CLASS, s)).sort()) {
|
|
702
|
-
problems.push(`[authoring] skills/${missing}.md has no category in SKILL_CLASS`);
|
|
703
|
-
}
|
|
704
|
-
for (const stale of Object.keys(SKILL_CLASS).filter((s) => !skillFiles.has(s)).sort()) {
|
|
705
|
-
problems.push(`[authoring] SKILL_CLASS lists '${stale}' but no skills/${stale}.md exists`);
|
|
706
|
-
}
|
|
707
|
-
|
|
708
|
-
// The numerals the laws RENDER with — positional, off the canon — because LAW_CLASS and the
|
|
709
|
-
// console's LAW_META are keyed the way the rendered catalogue numbers them.
|
|
710
|
-
const lawRules = ruleCanon(SRC).rules.filter((r) => r.kind === 'LAW');
|
|
711
|
-
const lawNums = lawRules.map((r) => r.label);
|
|
712
|
-
for (const num of lawNums) {
|
|
713
|
-
if (!has(LAW_CLASS, num)) {
|
|
714
|
-
problems.push(`[authoring] laws/universal.md rule ${num} has no class in LAW_CLASS`);
|
|
715
|
-
}
|
|
716
|
-
}
|
|
717
|
-
// `sorted(LAW_CLASS.items())` — by the Roman numeral as a STRING, which is what Python
|
|
718
|
-
// sorts here; the messages are re-sorted by `doctorCollect` anyway, so only the set travels.
|
|
719
|
-
for (const num of Object.keys(LAW_CLASS).sort()) {
|
|
720
|
-
if (!LAW_CLASSES.includes(LAW_CLASS[num])) {
|
|
721
|
-
problems.push(`[authoring] LAW_CLASS['${num}'] = '${LAW_CLASS[num]}' is not a known `
|
|
722
|
-
+ `class ${formatRepr(LAW_CLASSES)}`);
|
|
723
|
-
}
|
|
724
|
-
}
|
|
725
|
-
problems.push(...lawMetaProblems(lawNums, LAW_CLASS, LAW_CLASSES, lawRules.map((r) => r.id)));
|
|
726
|
-
|
|
727
|
-
// WHAT SHIPS, NOT WHAT IS FLAT. A folder skill (`src/skills/<name>/SKILL.md`, vendored with
|
|
728
|
-
// its scripts or references) ships exactly like a flat one, so the badge, the README list and
|
|
729
|
-
// the SHIPPED.md triple count it. It is added HERE rather than in `srcStems`, whose other
|
|
730
|
-
// readers above — the AGENT.md table links and SKILL_CLASS — are about flat specs only.
|
|
731
|
-
const skillsDir = path.join(SRC, 'skills');
|
|
732
|
-
const shippedSkills = new Set([...skillFiles, ...(isDir(skillsDir) ? readdirSync(skillsDir) : [])
|
|
733
|
-
.filter((d) => !d.startsWith('_') && isFile(path.join(skillsDir, d, 'SKILL.md')))]);
|
|
734
|
-
|
|
735
|
-
const counts = {
|
|
736
|
-
agents: srcStems('agents').size,
|
|
737
|
-
skills: shippedSkills.size,
|
|
738
|
-
laws: lawNums.length,
|
|
739
|
-
themes: themeFiles().length,
|
|
740
|
-
// Last on purpose: the badge loop below walks this object in insertion order and the
|
|
741
|
-
// message sequence is compared byte for byte against the Python implementation.
|
|
742
|
-
plugins: existsSync(PLUGIN_SRC)
|
|
743
|
-
? readdirSync(PLUGIN_SRC).filter((f) => f.startsWith('geneseed-') && f.endsWith('.js')).length
|
|
744
|
-
: 0,
|
|
745
|
-
};
|
|
746
|
-
let readme;
|
|
747
|
-
try { readme = readText(path.join(ROOT, 'README.md')); } catch { return problems; }
|
|
748
|
-
// ⚠ PRESENCE FIRST, THEN THE VALUE — the same absence arm the SHIPPED triple needed, for the
|
|
749
|
-
// same reason: `if (m && …)` is green when there is no badge, so deleting a badge silences its
|
|
750
|
-
// own gate. The `continue` keeps the loop's MESSAGE ORDER intact, which is load-bearing (see
|
|
751
|
-
// the `plugins`-is-last comment above): a missing badge reports in its own slot rather than
|
|
752
|
-
// reordering the ones after it.
|
|
753
|
-
for (const [key, n] of Object.entries(counts)) {
|
|
754
|
-
const m = new RegExp(`badge/${key}-(\\d+)`).exec(readme);
|
|
755
|
-
if (!m) {
|
|
756
|
-
problems.push(`[authoring] README has no ${key} badge — deleting one is how this gate `
|
|
757
|
-
+ 'goes green while the reader is told nothing');
|
|
758
|
-
continue;
|
|
759
|
-
}
|
|
760
|
-
if (Number(m[1]) !== n) {
|
|
761
|
-
problems.push(`[authoring] README ${key} badge says ${m[1]} but src has ${n}`);
|
|
762
|
-
}
|
|
763
|
-
}
|
|
764
|
-
|
|
765
|
-
// WHERE THE ONBOARDING COPY LIVES, AND WHY THIS READ MOVED. It used to open the one module
|
|
766
|
-
// that held the web console's onboarding prose. That prose has since moved into
|
|
767
|
-
// `docs/<folder>/*.md`, and the counts in it render from `{N_LAWS}` / `{N_AGENTS}` / `{N_SKILLS}`
|
|
768
|
-
// — so the read was still succeeding against a file the sentences had left, all three arms
|
|
769
|
-
// below scored zero, and the check looked healthy while gating nothing. A templated count
|
|
770
|
-
// cannot drift; what these arms still catch is a maintainer typing the NUMBER into a page
|
|
771
|
-
// instead of the token, which is the drift that reaches a reader.
|
|
772
|
-
//
|
|
773
|
-
// MEASURED BEFORE AND AFTER: zero problems either way on the shipped tree, so no recorded
|
|
774
|
-
// byte moves. `web` stays fail-soft — a missing docs tree is not an authoring fault.
|
|
775
|
-
let web = '';
|
|
776
|
-
try {
|
|
777
|
-
web = DOC_FOLDERS
|
|
778
|
-
.flatMap((f) => (isDir(path.join(DOCS, f))
|
|
779
|
-
? globSorted(path.join(DOCS, f), (n) => n.endsWith('.md')) : []))
|
|
780
|
-
.map(readText).join('\n');
|
|
781
|
-
} catch { web = ''; }
|
|
782
|
-
// ⚠ NOT FAIL-SOFT, UNLIKE `web` ABOVE, AND THE ASYMMETRY IS THE POINT. A missing `docs/web`
|
|
783
|
-
// tree is not an authoring fault — a checkout can legitimately lack it. SHIPPED.md is a
|
|
784
|
-
// tracked, shipped file that this function asserts the CONTENT of, so an unreadable one is
|
|
785
|
-
// indistinguishable from a deleted claim: swallowing it into `''` used to hand the triple arm
|
|
786
|
-
// an empty string and turn a hard read failure into a silent pass.
|
|
787
|
-
// ⚠ NOT FAIL-SOFT, UNLIKE `web` ABOVE, AND THE ASYMMETRY IS THE POINT. A missing `docs/web`
|
|
788
|
-
// tree is not an authoring fault — a checkout can legitimately lack it. SHIPPED.md is a
|
|
789
|
-
// tracked, shipped file whose CONTENT this function asserts, so an unreadable one was
|
|
790
|
-
// indistinguishable from a deleted claim: swallowing it into `''` handed the triple arm an
|
|
791
|
-
// empty string and turned a hard read failure into a silent pass.
|
|
792
|
-
//
|
|
793
|
-
// AND THE PRESENCE OF THE TRIPLE IS CHECKED HERE rather than inside `proseMirrorProblems`,
|
|
794
|
-
// which is where it belongs by subject and cannot go by construction — that function is
|
|
795
|
-
// recorded in `tests/__snapshots__/primitives/` with a case that passes an empty `shipped` and
|
|
796
|
-
// expects `[]`, and nothing can re-bless a recording whose recorder is deleted. This is the
|
|
797
|
-
// caller, it is not recorded, and it already owns the question "could the file be read".
|
|
798
|
-
let shipped;
|
|
799
|
-
try {
|
|
800
|
-
shipped = readText(path.join(ROOT, 'SHIPPED.md'));
|
|
801
|
-
} catch (e) {
|
|
802
|
-
problems.push(`[authoring] SHIPPED.md is unreadable (${e.message}) — the counts it carries `
|
|
803
|
-
+ 'cannot be checked, and a gate that cannot read its subject must say so');
|
|
804
|
-
shipped = '';
|
|
805
|
-
}
|
|
806
|
-
if (shipped && !/(\d+) laws, (\d+) agents, (\d+) skills/.test(shipped)) {
|
|
807
|
-
problems.push('[authoring] SHIPPED.md carries no \'N laws, N agents, N skills\' line — '
|
|
808
|
-
+ 'deleting it is how that gate goes green while the count drifts');
|
|
809
|
-
}
|
|
810
|
-
problems.push(...proseMirrorProblems(readme, web, counts, shippedSkills, shipped));
|
|
811
|
-
return problems;
|
|
812
|
-
}
|
|
813
|
-
|
|
814
|
-
/** `_harness_core.LEARN_PROMPT_HEAD`'s extraction regex — one owner, two readers. */
|
|
815
|
-
const LEARN_PROMPT_RE = /const LEARN_PROMPT_HEAD = `([\s\S]*?)`/;
|
|
816
|
-
|
|
817
|
-
/**
|
|
818
|
-
* `_harness_core._load_learn_prompt_head` — the distil instructions, read out of the plugin.
|
|
819
|
-
*
|
|
820
|
-
* The single source of truth is the OpenCode plugin, the artifact that ships to the primary
|
|
821
|
-
* runtime, so the CLI extracts it at load time rather than carrying a copy. Reproduced here
|
|
822
|
-
* rather than imported from `js/hosts/hooks.mjs`, which this entry may not reach: `learn` spawns the
|
|
823
|
-
* model CLI, and the allow-list below names ONE call site.
|
|
824
|
-
*
|
|
825
|
-
* The fallback matters as much as the extraction. It is what makes the drift arm of
|
|
826
|
-
* `_authoring_problems` unreachable — both the loaded copy and the checked literal come from
|
|
827
|
-
* one file through one regex, so a reference can never disagree with itself. A port that
|
|
828
|
-
* hardcoded the prompt WOULD disagree, which is what
|
|
829
|
-
* `doctor/the-loaded-copy-follows-the-plugin-rather-than-a-constant` exists to catch.
|
|
830
|
-
*/
|
|
831
|
-
function loadLearnPromptHead() {
|
|
832
|
-
try {
|
|
833
|
-
const m = LEARN_PROMPT_RE.exec(readText(path.join(PLUGIN_SRC, 'geneseed-learn.js')));
|
|
834
|
-
if (m) return m[1];
|
|
835
|
-
} catch { /* OSError — fall through */ }
|
|
836
|
-
return 'Distil at most one durable, reusable memory from the notes below. '
|
|
837
|
-
+ 'When in doubt, output exactly: NOTHING.';
|
|
838
|
-
}
|
|
30
|
+
// Moved to the two sibling modules; re-exported so callers that import the authoring group as
|
|
31
|
+
// one module keep working.
|
|
32
|
+
export {
|
|
33
|
+
HOOK_PINNED, constitutionProblems, doctrineMetaProblems, lawMetaProblems, leanBlockProblems,
|
|
34
|
+
} from './checks-constitution.mjs';
|
|
35
|
+
export { countTableProblems, proseMirrorProblems } from './checks-counts.mjs';
|
|
839
36
|
|
|
840
37
|
/**
|
|
841
38
|
* The shipped loop catalogue parses and every shipped template passes the engine's own graph
|
|
@@ -879,25 +76,21 @@ export function authoringProblems() {
|
|
|
879
76
|
}
|
|
880
77
|
}
|
|
881
78
|
}
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
// these two strings are byte-compared by the live doctor comparison.
|
|
79
|
+
// The learn plugin is the single source of the distil prompt: `js/hosts/hooks.mjs` extracts
|
|
80
|
+
// it at load time rather than carrying a copy, and falls back to a stub when it cannot — so
|
|
81
|
+
// the literal must stay extractable.
|
|
82
|
+
let learn = '';
|
|
83
|
+
try { learn = readText(path.join(PLUGIN_SRC, 'geneseed-learn.js')); } catch { learn = ''; }
|
|
84
|
+
if (!/const LEARN_PROMPT_HEAD = `[\s\S]*?`/.test(learn)) {
|
|
889
85
|
problems.push('[authoring] LEARN_PROMPT_HEAD literal not found in '
|
|
890
86
|
+ 'geneseed-learn.js — the harness would fall back (single source broken)');
|
|
891
|
-
} else if (m[1] !== loadLearnPromptHead()) {
|
|
892
|
-
problems.push('[authoring] LEARN_PROMPT_HEAD drifted between geneseed-learn.js '
|
|
893
|
-
+ "and the harness's loaded copy");
|
|
894
87
|
}
|
|
895
88
|
const node = which('node');
|
|
896
89
|
if (node) {
|
|
897
90
|
for (const js of globSorted(PLUGIN_SRC, (n) => n.endsWith('.js'))) {
|
|
898
91
|
// THE ONE SPAWN. `node --check` and nothing else — see the module header, and
|
|
899
92
|
// the `inspect/checks-authoring.mjs` row of the spawn allow-list in
|
|
900
|
-
// `tests/unit/hook_cli.test.mjs`, which names this argv literally.
|
|
93
|
+
// `tests/unit/hook_cli.test.mjs`, which names this argv literally.
|
|
901
94
|
// `NO_WINDOW` because the reference's `run()` folds `CREATE_NO_WINDOW` into every
|
|
902
95
|
// CAPTURING spawn and this is one — and because this loop is the burst a user sees:
|
|
903
96
|
// one console window per plugin, every time the web daemon runs the doctor.
|