@event4u/agent-config 9.22.0 → 9.24.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.
Files changed (90) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/CHANGELOG.md +84 -238
  3. package/README.md +2 -2
  4. package/dist/agent-src/commands/agent-handoff.md +10 -3
  5. package/dist/agent-src/commands/analyze/inbox.md +190 -0
  6. package/dist/agent-src/commands/analyze.md +4 -3
  7. package/dist/agent-src/commands/optimize/deep.md +13 -0
  8. package/dist/agent-src/commands/pr/create.md +6 -6
  9. package/dist/agent-src/contexts/execution/cheap-question-mechanics.md +12 -2
  10. package/dist/agent-src/contexts/execution/mandated-lines.md +180 -0
  11. package/dist/agent-src/rules/context-hygiene.md +20 -0
  12. package/dist/agent-src/rules/downstream-changes.md +26 -0
  13. package/dist/agent-src/rules/think-before-action.md +25 -0
  14. package/dist/agent-src/rules/token-budget-discipline.md +46 -3
  15. package/dist/agent-src/rules/token-efficiency.md +13 -0
  16. package/dist/agent-src/rules/ui-audit-gate.md +33 -2
  17. package/dist/agent-src/skills/adversarial-review/SKILL.md +18 -4
  18. package/dist/agent-src/skills/decision-review/SKILL.md +49 -0
  19. package/dist/agent-src/skills/judge-artifact-completeness/SKILL.md +5 -1
  20. package/dist/agent-src/skills/judge-bug-hunter/SKILL.md +16 -0
  21. package/dist/agent-src/skills/judge-synthesis/SKILL.md +14 -1
  22. package/dist/agent-src/skills/rule-writing/SKILL.md +33 -0
  23. package/dist/agent-src/skills/skill-improvement-pipeline/SKILL.md +31 -0
  24. package/dist/agent-src/skills/skill-reviewer/SKILL.md +1 -1
  25. package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
  26. package/dist/cli/registry.js +1 -0
  27. package/dist/cli/registry.js.map +1 -1
  28. package/dist/cli-delegate/chunk-DVRS2KVR.js +4602 -0
  29. package/dist/cli-delegate/{chunk-ELMFOQPW.js → chunk-SJQMJI7M.js} +8 -4597
  30. package/dist/cli-delegate/cmd_export.js +2 -1
  31. package/dist/cli-delegate/cmd_prune.js +2 -1
  32. package/dist/cli-delegate/cmd_settings_migrate.js +2 -1
  33. package/dist/cli-delegate/cmd_settings_set.js +3370 -0
  34. package/dist/cli-delegate/cmd_sync.js +2 -1
  35. package/dist/cli-delegate/cmd_uninstall.js +2 -1
  36. package/dist/cli-delegate/cmd_validate.js +2 -1
  37. package/dist/discovery/deprecation-report.md +1 -1
  38. package/dist/discovery/discovery-manifest.json +54 -27
  39. package/dist/discovery/discovery-manifest.json.sha256 +1 -1
  40. package/dist/discovery/discovery-manifest.summary.md +3 -3
  41. package/dist/discovery/orphan-report.md +1 -1
  42. package/dist/discovery/packs.json +6 -5
  43. package/dist/discovery/trust-report.md +2 -2
  44. package/dist/discovery/workspaces.json +5 -4
  45. package/dist/hooks/dispatch.js +79 -1
  46. package/dist/mcp/registry-manifest.json +3 -3
  47. package/dist/mcp/server.json +2 -2
  48. package/dist/scripts/condense.js +47 -2
  49. package/dist/scripts/condense.js.map +1 -1
  50. package/dist/server/routes/settings.js +82 -0
  51. package/dist/server/routes/settings.js.map +1 -1
  52. package/dist/shared/settingsClasses.js +191 -0
  53. package/dist/shared/settingsClasses.js.map +1 -0
  54. package/docs/contracts/settings-classes.md +342 -0
  55. package/docs/guidelines/agent-infra/artifact-drafting-protocol-mechanics.md +23 -2
  56. package/docs/guidelines/agent-infra/false-green.md +187 -0
  57. package/docs/guidelines/agent-infra/gate-authoring.md +177 -0
  58. package/package.json +3 -1
  59. package/src/config/ci-local-parity.yml +12 -0
  60. package/src/config/evaluator-budgets.json +4 -4
  61. package/src/config/gate-coverage.yml +54 -12
  62. package/src/config/gate-violation-baselines.json +6 -0
  63. package/src/config/pack-size-budget.json +4 -3
  64. package/src/scripts/_cli/cmd_settings_set.ts +509 -0
  65. package/src/scripts/_dispatch.bash +16 -0
  66. package/src/scripts/_lib/gate_ledger.ts +295 -0
  67. package/src/scripts/_lib/gate_result.ts +101 -0
  68. package/src/scripts/_lib/gate_self_test.ts +129 -0
  69. package/src/scripts/_lib/measured_render.ts +97 -0
  70. package/src/scripts/_lib/ratchet_base_ref.ts +373 -0
  71. package/src/scripts/check_gate_completeness.ts +244 -0
  72. package/src/scripts/check_gate_coverage.ts +35 -6
  73. package/src/scripts/check_references.ts +125 -15
  74. package/src/scripts/check_review_dispositions.ts +14 -1
  75. package/src/scripts/check_suppression_hygiene.ts +503 -0
  76. package/src/scripts/condense.ts +47 -2
  77. package/src/scripts/hook_manifest.yaml +23 -0
  78. package/src/scripts/hooks/dispatch_hook.ts +65 -1
  79. package/src/scripts/hooks/host_semantics.ts +140 -0
  80. package/src/scripts/lint_framework_leakage.ts +147 -15
  81. package/src/scripts/lint_mandated_lines.ts +339 -0
  82. package/src/scripts/lint_roadmap_complexity.ts +102 -11
  83. package/src/scripts/lint_roadmap_family_cap.ts +191 -0
  84. package/src/scripts/lint_settings_classes.ts +397 -0
  85. package/src/scripts/lint_token_budget_discipline.ts +109 -1
  86. package/src/scripts/measure_rule_absoluta.ts +231 -0
  87. package/src/scripts/report_imperative_density.ts +229 -0
  88. package/src/scripts/verify_physical_move.ts +10 -1
  89. package/src/server/io/yamlIO.ts +173 -0
  90. package/src/shared/settingsClasses.ts +235 -0
@@ -0,0 +1,191 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Hard-Gate linter: the skill-ecosystem roadmap family may not exceed its
4
+ * concurrency cap.
5
+ *
6
+ * ADR-215 § D2 states the cap is "mechanically enforced, not left to
7
+ * discipline", and gives the reason: a guideline a single maintainer intends to
8
+ * honour drifts under urgency. Until this file existed the record asserted an
9
+ * enforcement that did not exist and pointed at an implementation step that was
10
+ * never written — a claim-without-resolution in the one record governing the
11
+ * only surviving restraint mechanism. This gate makes the claim true.
12
+ *
13
+ * The cap counts roadmaps in the family that sit at the TOP LEVEL of
14
+ * `agents/roadmaps/` — i.e. active work. `archive/`, `later/`, `skipped/` and
15
+ * `stubs/` are by definition not concurrently open and are not counted.
16
+ *
17
+ * Widening the cap to cover more families (see ADR-216 § D3, which records the
18
+ * scope limitation honestly rather than pretending the cap manages total
19
+ * capacity) is a one-line change to FAMILY_PREFIX plus a new decision record —
20
+ * never a silent edit to CAP.
21
+ *
22
+ * CLI contract: exit 0 = at or under the cap, 1 = over the cap.
23
+ * `--quiet` suppresses the green line, matching the sibling roadmap gates.
24
+ */
25
+ import * as fs from 'node:fs';
26
+ import * as path from 'node:path';
27
+ import { fileURLToPath, pathToFileURL } from 'node:url';
28
+
29
+ import { assertScanned, DeadScopeError } from './_lib/scan_scope.js';
30
+
31
+ /** Mirror the sibling gates: bare argv membership, computed at import. */
32
+ const QUIET = process.argv.slice(2).includes('--quiet');
33
+
34
+ const ROADMAP_DIR = path.join('agents', 'roadmaps');
35
+
36
+ /**
37
+ * The capped family and its ceiling, per ADR-215 § D2 as amended by ADR-216 § D3.
38
+ * Both are named constants so a future widening is a visible one-line diff
39
+ * accompanied by a decision record, not an unnoticed threshold edit.
40
+ */
41
+ const FAMILY_PREFIX = 'road-to-skill-ecosystem-';
42
+ const CAP = 2;
43
+
44
+ const _HERE = path.resolve(fileURLToPath(import.meta.url));
45
+
46
+ /** `Path.is_dir()` following symlinks, never throwing. */
47
+ function _isDir(p: string): boolean {
48
+ try {
49
+ return fs.statSync(p).isDirectory();
50
+ } catch {
51
+ return false;
52
+ }
53
+ }
54
+
55
+ /** Walk up from CWD until a dir containing `agents/roadmaps` is found. */
56
+ function _repo_root(): string {
57
+ let cur = process.cwd();
58
+ const chain = [cur];
59
+ for (;;) {
60
+ const parent = path.dirname(cur);
61
+ if (parent === cur) break;
62
+ chain.push(parent);
63
+ cur = parent;
64
+ }
65
+ for (const candidate of chain) {
66
+ if (_isDir(path.join(candidate, ROADMAP_DIR))) {
67
+ return candidate;
68
+ }
69
+ }
70
+ return process.cwd();
71
+ }
72
+
73
+ /**
74
+ * Every `*.md` directly under `agents/roadmaps/` — top level only, sorted.
75
+ * Subdirectories are deliberately NOT walked: a roadmap in `later/` or
76
+ * `archive/` is not concurrently open, which is the whole point of the cap.
77
+ */
78
+ export function active_roadmaps(root: string): string[] {
79
+ const base = path.join(root, ROADMAP_DIR);
80
+ if (!_isDir(base)) {
81
+ return [];
82
+ }
83
+ let entries: fs.Dirent[];
84
+ try {
85
+ entries = fs.readdirSync(base, { withFileTypes: true });
86
+ } catch {
87
+ return [];
88
+ }
89
+ return entries
90
+ .filter((e) => !e.isDirectory() && e.name.endsWith('.md'))
91
+ .map((e) => e.name)
92
+ .sort();
93
+ }
94
+
95
+ /** The subset of active roadmaps belonging to the capped family. */
96
+ export function family_members(root: string): string[] {
97
+ return active_roadmaps(root).filter((n) => n.startsWith(FAMILY_PREFIX));
98
+ }
99
+
100
+ export function main(): number {
101
+ const root = _repo_root();
102
+
103
+ // Anti-vacuity: this gate reports an ABSENCE (no over-cap condition), so a
104
+ // dead scan root would print the same green line as a healthy tree. The
105
+ // scanned unit is every ACTIVE roadmap, not just the family — a repo whose
106
+ // top-level roadmap dir is empty or moved is a dead scope, not a pass.
107
+ // This is the invariant road-to-skill-ecosystem-gate-integrity generalises;
108
+ // the gate that enforces that roadmap's own cap had better honour it.
109
+ try {
110
+ assertScanned({
111
+ gate: 'lint_roadmap_family_cap',
112
+ scanned: active_roadmaps(root).length,
113
+ units: 'active roadmap file(s)',
114
+ roots: [ROADMAP_DIR],
115
+ });
116
+ } catch (exc) {
117
+ if (exc instanceof DeadScopeError) {
118
+ process.stderr.write(`❌ ${exc.message}\n`);
119
+ return 1;
120
+ }
121
+ throw exc;
122
+ }
123
+
124
+ const members = family_members(root);
125
+
126
+ if (members.length <= CAP) {
127
+ if (!QUIET) {
128
+ process.stdout.write(
129
+ `✅ lint-roadmap-family-cap: ${members.length}/${CAP} slot(s) used ` +
130
+ `(scanned ${active_roadmaps(root).length} active roadmap file(s)).\n`,
131
+ );
132
+ }
133
+ return 0;
134
+ }
135
+
136
+ process.stdout.write(
137
+ `❌ lint-roadmap-family-cap: ${members.length} concurrently-open ` +
138
+ `\`${FAMILY_PREFIX}*\` roadmap(s), cap is ${CAP}:\n`,
139
+ );
140
+ for (const name of members) {
141
+ process.stdout.write(` agents/roadmaps/${name}\n`);
142
+ }
143
+ process.stdout.write('\n');
144
+ process.stdout.write(
145
+ ' The cap is a capacity mechanism, not an adoption gate — one maintainer\n',
146
+ );
147
+ process.stdout.write(
148
+ ' cannot hold this many parallel workstreams (ADR-215 § D2, ADR-216 § D3).\n',
149
+ );
150
+ process.stdout.write(' To open another, free a slot first:\n');
151
+ process.stdout.write(
152
+ ' • archive a finished one — git mv it into agents/roadmaps/archive/, or\n',
153
+ );
154
+ process.stdout.write(
155
+ ' • park one — git mv it into agents/roadmaps/later/ with `status: later`\n',
156
+ );
157
+ process.stdout.write(' and a reachable resume condition, or\n');
158
+ process.stdout.write(
159
+ ' • widen the cap deliberately — edit CAP in this file AND record the\n',
160
+ );
161
+ process.stdout.write(
162
+ ' decision. A silent threshold edit is the failure this gate exists to stop.\n',
163
+ );
164
+ return 1;
165
+ }
166
+
167
+ function _isCliEntry(): boolean {
168
+ if (process.argv[1] === undefined) {
169
+ return false;
170
+ }
171
+ const argvUrl = pathToFileURL(path.resolve(process.argv[1])).href;
172
+ if (import.meta.url === argvUrl) {
173
+ return true;
174
+ }
175
+ // A symlinked invocation (installed projection, or macOS /var → /private/var
176
+ // temp dirs) makes the raw URLs differ: compare realpaths so the entry guard
177
+ // still fires instead of silently no-opping.
178
+ try {
179
+ const here = fs.realpathSync(fileURLToPath(import.meta.url));
180
+ const argv = fs.realpathSync(path.resolve(process.argv[1]));
181
+ return here === argv;
182
+ } catch {
183
+ return false;
184
+ }
185
+ }
186
+
187
+ if (_isCliEntry() || process.argv[1] === _HERE) {
188
+ process.exitCode = main();
189
+ }
190
+
191
+ export { CAP, FAMILY_PREFIX, QUIET };
@@ -0,0 +1,397 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Settings A/B/C class linter — every template leaf carries exactly one class.
4
+ *
5
+ * Phase 1 of `road-to-zero-ceremony-settings`. The package is growing an
6
+ * agent-writable settings path (`settings set`); the A/B/C taxonomy in
7
+ * `docs/contracts/settings-classes.md` is the fence that keeps a spend ceiling,
8
+ * an allowlist, or a gate switch off that path. A key with no class is a key
9
+ * with no fence, so this gate refuses the build until every leaf has a row.
10
+ *
11
+ * Checks, in order:
12
+ * 1. every template leaf has exactly one row in the contract table;
13
+ * 2. every contract row names a key the template actually has;
14
+ * 3. every row's class is one of A | B | C;
15
+ * 4. every B row's template default is a conservative value
16
+ * (`false` / `""` / `0` / `[]` / `null` / `{}`) — the invariant that makes
17
+ * "absent" and "declined" the same outcome in a sparse settings file;
18
+ * 5. the contract's own Counts table matches the tallies computed here.
19
+ *
20
+ * **Landed at error, not advisory.** The authoring guideline's advisory-first
21
+ * rule exists so a gate cannot be wired to error over a corpus whose hits are
22
+ * unfixable. That does not apply here: the gate was run against the real
23
+ * template in the same change, all 140 leaves were classified, and the finding
24
+ * count reached zero before it was wired. There is therefore no
25
+ * `gate-violation-baselines.json` entry — zero is the standing requirement, not
26
+ * a floor a ratchet walks down to.
27
+ *
28
+ * **Gaming risk.** The cheap degenerate pass is class inflation in the *other*
29
+ * direction from the one people expect: marking every key `C` makes the gate
30
+ * green while making the writer useless, and nothing here can tell a
31
+ * conscientious C from a lazy one. Mitigation: the contract states eight
32
+ * explicit tests for C (the eighth, audit-and-observability, is the one a lazy
33
+ * blanket-C most often hides behind) and names `commands.suggestion.enabled` as the worked
34
+ * A-not-C counter-example, so a blanket-C diff reads as wrong to a reviewer;
35
+ * check 4 additionally makes the B class mechanically falsifiable rather than
36
+ * decorative. Residual: **the A/B/C judgement itself is not machine-checkable.**
37
+ * This gate proves the table is *complete and consistent with the template*; it
38
+ * cannot prove a row's class is *correct*. The C list was council-reviewed once
39
+ * (Phase 1 step 4) precisely because completeness is checkable and correctness
40
+ * is not.
41
+ */
42
+
43
+ import * as fs from 'node:fs';
44
+ import * as os from 'node:os';
45
+ import * as path from 'node:path';
46
+ import { fileURLToPath, pathToFileURL } from 'node:url';
47
+
48
+ import { load as parseYaml } from 'js-yaml';
49
+
50
+ import {
51
+ getSettingsLeaf,
52
+ isConservativeDefault,
53
+ isSettingsClass,
54
+ parseDeclaredClassCounts,
55
+ parseSettingsClassRows,
56
+ SETTINGS_CLASSES,
57
+ type SettingsClass,
58
+ type SettingsClassRow,
59
+ settingsLeafPaths,
60
+ } from '../shared/settingsClasses.js';
61
+ import { GateLedger } from './_lib/gate_ledger.js';
62
+ import { runGateCli, runSelfTest } from './_lib/gate_self_test.js';
63
+ import { DeadScopeError, reportScanned } from './_lib/scan_scope.js';
64
+
65
+ const GATE = 'lint_settings_classes';
66
+
67
+ /** Mirrors the sibling gates: a bare argv membership check, computed at import. */
68
+ const QUIET = process.argv.slice(2).includes('--quiet');
69
+
70
+ const _HERE = path.resolve(fileURLToPath(import.meta.url));
71
+ const REPO_ROOT = path.resolve(path.dirname(_HERE), '..', '..');
72
+
73
+ const TEMPLATE_RELATIVE = 'src/config/agent-settings.template.yml';
74
+ const CONTRACT_RELATIVE = 'docs/contracts/settings-classes.md';
75
+
76
+ type Yaml = string | number | boolean | null | Yaml[] | { [k: string]: Yaml };
77
+
78
+ function _rootOverride(argv: readonly string[]): string | null {
79
+ const i = argv.indexOf('--root');
80
+ const value = i === -1 ? undefined : argv[i + 1];
81
+ return value === undefined ? null : path.resolve(value);
82
+ }
83
+
84
+ function _readFile(p: string): string | null {
85
+ try {
86
+ return fs.readFileSync(p, 'utf-8');
87
+ } catch {
88
+ return null;
89
+ }
90
+ }
91
+
92
+ /**
93
+ * Prove, against the real CLI, that this gate still rejects what it must.
94
+ *
95
+ * The accept case seeds a two-key template with a matching contract; each
96
+ * reject case removes exactly one property of that fixture, so a pass proves
97
+ * the specific check and not merely that the fixture is broken.
98
+ */
99
+ export function selfTest(): number {
100
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'lsc-selftest-'));
101
+ const seed = (name: string, template: string | null, contract: string | null): string => {
102
+ const root = path.join(tmp, name);
103
+ fs.mkdirSync(path.join(root, 'src', 'config'), { recursive: true });
104
+ fs.mkdirSync(path.join(root, 'docs', 'contracts'), { recursive: true });
105
+ if (template !== null) {
106
+ fs.writeFileSync(path.join(root, TEMPLATE_RELATIVE), template, 'utf-8');
107
+ }
108
+ if (contract !== null) {
109
+ fs.writeFileSync(path.join(root, CONTRACT_RELATIVE), contract, 'utf-8');
110
+ }
111
+ return root;
112
+ };
113
+ const run = (cwd: string): number => runGateCli(REPO_ROOT, `src/scripts/${GATE}.ts`, ['--quiet', '--root', cwd], REPO_ROOT);
114
+
115
+ const template = 'alpha:\n one: false\nbeta: "keep"\n';
116
+ const counts = (a: number, b: number, c: number, total: number): string =>
117
+ `\n## Counts\n\n| Class | Keys |\n|---|---|\n| A — preference | ${String(a)} |\n` +
118
+ `| B — consent | ${String(b)} |\n| C — guarded | ${String(c)} |\n` +
119
+ `| **Total** | **${String(total)}** |\n`;
120
+ const table = (rows: string): string => `# Fixture\n${counts(0, 1, 1, 2)}\n## The table\n\n| Key | Class | Default | Why |\n|---|---|---|---|\n${rows}`;
121
+
122
+ const clean = table('| `alpha.one` | B | `false` | fixture |\n| `beta` | C | `"keep"` | fixture |\n');
123
+
124
+ try {
125
+ return runSelfTest({
126
+ gate: GATE,
127
+ minCases: 6,
128
+ minRejectCases: 5,
129
+ cases: [
130
+ {
131
+ name: 'a template whose every leaf is classified passes',
132
+ expect: 'accept',
133
+ run: () => run(seed('clean', template, clean)),
134
+ },
135
+ {
136
+ name: 'an unclassified template leaf is rejected',
137
+ expect: 'reject',
138
+ run: () =>
139
+ run(
140
+ seed(
141
+ 'unclassified',
142
+ template,
143
+ table('| `beta` | C | `"keep"` | fixture |\n') + counts(0, 0, 1, 1),
144
+ ),
145
+ ),
146
+ },
147
+ {
148
+ name: 'a contract row naming a key the template lost is rejected',
149
+ expect: 'reject',
150
+ run: () =>
151
+ run(
152
+ seed(
153
+ 'stale-row',
154
+ template,
155
+ table(
156
+ '| `alpha.one` | B | `false` | fixture |\n| `beta` | C | `"keep"` | fixture |\n' +
157
+ '| `gamma.gone` | A | `1` | fixture |\n',
158
+ ),
159
+ ),
160
+ ),
161
+ },
162
+ {
163
+ name: 'a class outside A|B|C is rejected',
164
+ expect: 'reject',
165
+ run: () =>
166
+ run(
167
+ seed(
168
+ 'bad-class',
169
+ template,
170
+ table('| `alpha.one` | D | `false` | fixture |\n| `beta` | C | `"keep"` | fixture |\n'),
171
+ ),
172
+ ),
173
+ },
174
+ {
175
+ name: 'a B key whose default is permissive is rejected',
176
+ expect: 'reject',
177
+ run: () =>
178
+ run(
179
+ seed(
180
+ 'permissive-b',
181
+ 'alpha:\n one: true\nbeta: "keep"\n',
182
+ clean,
183
+ ),
184
+ ),
185
+ },
186
+ {
187
+ name: 'a Counts table that disagrees with the rows is rejected',
188
+ expect: 'reject',
189
+ run: () =>
190
+ run(
191
+ seed(
192
+ 'bad-counts',
193
+ template,
194
+ `# Fixture\n${counts(9, 9, 9, 27)}\n## The table\n\n| Key | Class | Default | Why |\n|---|---|---|---|\n` +
195
+ '| `alpha.one` | B | `false` | fixture |\n| `beta` | C | `"keep"` | fixture |\n',
196
+ ),
197
+ ),
198
+ },
199
+ {
200
+ name: 'a missing template — a dead scan root — is rejected, not passed as "nothing to check"',
201
+ expect: 'reject',
202
+ run: () => run(seed('dead-root', null, clean)),
203
+ },
204
+ ],
205
+ });
206
+ } finally {
207
+ fs.rmSync(tmp, { recursive: true, force: true });
208
+ }
209
+ }
210
+
211
+ function main(): number {
212
+ if (process.argv.slice(2).includes('--self-test')) {
213
+ return selfTest();
214
+ }
215
+ const root = _rootOverride(process.argv.slice(2)) ?? REPO_ROOT;
216
+ const templatePath = path.join(root, TEMPLATE_RELATIVE);
217
+ const contractPath = path.join(root, CONTRACT_RELATIVE);
218
+
219
+ const templateText = _readFile(templatePath);
220
+ if (templateText === null) {
221
+ process.stderr.write(`❌ ${GATE}: settings template not found at ${TEMPLATE_RELATIVE} — the scan root is dead.\n`);
222
+ return 1;
223
+ }
224
+ const contractText = _readFile(contractPath);
225
+ if (contractText === null) {
226
+ process.stderr.write(`❌ ${GATE}: class contract not found at ${CONTRACT_RELATIVE} — every key is unclassified.\n`);
227
+ return 1;
228
+ }
229
+
230
+ let parsed: Yaml;
231
+ try {
232
+ parsed = parseYaml(templateText) as Yaml;
233
+ } catch (e) {
234
+ process.stderr.write(`❌ ${GATE}: ${TEMPLATE_RELATIVE} did not parse: ${String(e)}\n`);
235
+ return 1;
236
+ }
237
+ if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
238
+ process.stderr.write(`❌ ${GATE}: ${TEMPLATE_RELATIVE} did not parse to a map.\n`);
239
+ return 1;
240
+ }
241
+
242
+ const leaves = settingsLeafPaths(parsed);
243
+ try {
244
+ // Publishes the number it just asserted, so the coverage guard reads the
245
+ // judged denominator rather than an enumerated one.
246
+ reportScanned({ gate: GATE, scanned: leaves.length, units: 'settings leaf key(s)', roots: [TEMPLATE_RELATIVE] });
247
+ } catch (e) {
248
+ if (e instanceof DeadScopeError) {
249
+ process.stderr.write(`❌ ${e.message}\n`);
250
+ return 1;
251
+ }
252
+ throw e;
253
+ }
254
+
255
+ const rows = parseSettingsClassRows(contractText);
256
+ const rowByKey = new Map<string, SettingsClassRow>();
257
+ const findings: string[] = [];
258
+
259
+ // The ledger plans one target per template leaf plus one per contract row,
260
+ // so a stale row is accounted work rather than an invisible extra.
261
+ const ledger = new GateLedger(GATE);
262
+ ledger.plan(leaves.map((k) => `key:${k}`));
263
+ ledger.plan(rows.map((r, i) => `row:${String(i)}:${r.key}`));
264
+
265
+ const leafSet = new Set(leaves);
266
+ for (const [i, row] of rows.entries()) {
267
+ const target = `row:${String(i)}:${row.key}`;
268
+ const duplicate = rowByKey.get(row.key);
269
+ if (duplicate !== undefined) {
270
+ const finding =
271
+ `${CONTRACT_RELATIVE}:${String(row.line)} duplicate row for \`${row.key}\` ` +
272
+ `(first at line ${String(duplicate.line)}) — one key, one class.`;
273
+ findings.push(finding);
274
+ ledger.fail(target, finding);
275
+ continue;
276
+ }
277
+ rowByKey.set(row.key, row);
278
+ if (!isSettingsClass(row.cls)) {
279
+ const finding =
280
+ `${CONTRACT_RELATIVE}:${String(row.line)} \`${row.key}\` has class '${row.cls}' — ` +
281
+ `allowed: ${SETTINGS_CLASSES.join(' | ')}.`;
282
+ findings.push(finding);
283
+ ledger.fail(target, finding);
284
+ continue;
285
+ }
286
+ if (!leafSet.has(row.key)) {
287
+ // Resolved HERE rather than in a later pass. A stale row that
288
+ // `complete`s and then produces a finding downstream is counted as
289
+ // satisfied by the ledger while the gate exits 1 — a completeness
290
+ // report that covers less than it appears to, which is the exact
291
+ // shape the ledger exists to make impossible.
292
+ const finding =
293
+ `${CONTRACT_RELATIVE}:${String(row.line)} \`${row.key}\` is not a leaf in ` +
294
+ `${TEMPLATE_RELATIVE} — the row is stale; delete it or restore the key.`;
295
+ findings.push(finding);
296
+ ledger.fail(target, finding);
297
+ continue;
298
+ }
299
+ ledger.complete(target);
300
+ }
301
+
302
+
303
+ const tally: Record<SettingsClass, number> = { A: 0, B: 0, C: 0 };
304
+ for (const leaf of leaves) {
305
+ const target = `key:${leaf}`;
306
+ const row = rowByKey.get(leaf);
307
+ if (row === undefined) {
308
+ const finding =
309
+ `${TEMPLATE_RELATIVE} \`${leaf}\` has no class — add a row to ${CONTRACT_RELATIVE}. ` +
310
+ 'An unclassified key reaches the settings writer with no fence.';
311
+ findings.push(finding);
312
+ ledger.fail(target, finding);
313
+ continue;
314
+ }
315
+ if (!isSettingsClass(row.cls)) {
316
+ // Already reported against the row; do not double-count the key.
317
+ ledger.outOfScope(target, 'declared_exemption');
318
+ continue;
319
+ }
320
+ const cls: SettingsClass = row.cls;
321
+ tally[cls] += 1;
322
+ if (cls === 'B' && !isConservativeDefault(getSettingsLeaf(parsed, leaf))) {
323
+ const finding =
324
+ `${CONTRACT_RELATIVE}:${String(row.line)} \`${leaf}\` is class B but its template default is ` +
325
+ 'permissive — a B key must default to the conservative value, or "never asked" and ' +
326
+ '"answered yes" become the same state in a sparse file.';
327
+ findings.push(finding);
328
+ ledger.fail(target, finding);
329
+ continue;
330
+ }
331
+ ledger.complete(target);
332
+ }
333
+
334
+ const declared = parseDeclaredClassCounts(contractText);
335
+ const expected: Array<[string, number | null, number]> = [
336
+ ['A', declared.A, tally.A],
337
+ ['B', declared.B, tally.B],
338
+ ['C', declared.C, tally.C],
339
+ ['Total', declared.total, leaves.length],
340
+ ];
341
+ for (const [label, stated, actual] of expected) {
342
+ if (stated === null) {
343
+ findings.push(
344
+ `${CONTRACT_RELATIVE} the Counts table has no '${label}' row — a published count with no ` +
345
+ 'row is a count nothing can check.',
346
+ );
347
+ continue;
348
+ }
349
+ if (stated !== actual) {
350
+ findings.push(
351
+ `${CONTRACT_RELATIVE} Counts says ${label} = ${String(stated)}, the table holds ` +
352
+ `${String(actual)} — a derived number beside a mechanism that can compute it.`,
353
+ );
354
+ }
355
+ }
356
+
357
+ ledger.report();
358
+
359
+ if (findings.length > 0) {
360
+ process.stderr.write('\n');
361
+ for (const f of findings) {
362
+ process.stderr.write(`❌ ${f}\n`);
363
+ }
364
+ process.stderr.write(`\n❌ ${GATE}: ${String(findings.length)} finding(s)\n`);
365
+ return 1;
366
+ }
367
+ if (!QUIET) {
368
+ process.stdout.write(
369
+ `✅ ${GATE}: ${String(leaves.length)} settings key(s) classified — ` +
370
+ `A=${String(tally.A)} B=${String(tally.B)} C=${String(tally.C)}\n`,
371
+ );
372
+ }
373
+ return 0;
374
+ }
375
+
376
+ function _isCliEntry(): boolean {
377
+ if (process.argv[1] === undefined) {
378
+ return false;
379
+ }
380
+ const argvUrl = pathToFileURL(path.resolve(process.argv[1])).href;
381
+ if (import.meta.url === argvUrl) {
382
+ return true;
383
+ }
384
+ try {
385
+ const here = fs.realpathSync(fileURLToPath(import.meta.url));
386
+ const argv = fs.realpathSync(path.resolve(process.argv[1]));
387
+ return here === argv;
388
+ } catch {
389
+ return false;
390
+ }
391
+ }
392
+
393
+ if (_isCliEntry() || process.argv[1] === _HERE) {
394
+ process.exit(main());
395
+ }
396
+
397
+ export { CONTRACT_RELATIVE, GATE, REPO_ROOT, TEMPLATE_RELATIVE, main };