@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,503 @@
1
+ #!/usr/bin/env tsx
2
+ /**
3
+ * Suppression hygiene across every human-authored allowlist and baseline.
4
+ *
5
+ * Three properties, all of which this estate previously carried by convention:
6
+ *
7
+ * 1. **Shrink-only, mechanically.** Every list is compared entry-SET against
8
+ * the base ref, not entry-COUNT against a checked-in number. A count
9
+ * comparison passes swap-one-out-add-one-in; the set comparison does not.
10
+ * (`_lib/ratchet_base_ref.ts` carries the mechanism and the rename handling.)
11
+ * 2. **Every human-authored entry carries a `reason`.** An unexplained
12
+ * suppression cannot be audited by the next reader, and "the diff explains
13
+ * it" stops being true the moment the diff is six months old.
14
+ * 3. **Every NEW entry additionally carries a `falsifier`** — a re-runnable
15
+ * command that decides the entry. An entry with a falsifier is a ratchet;
16
+ * one without is a hole. Existing entries are grandfathered by count rather
17
+ * than retro-filled, because a retro-fill of 38 entries would be 38 guesses
18
+ * at what a past author meant, and a plausible-looking guess is worse than
19
+ * an admitted gap.
20
+ *
21
+ * **Why estate-level rather than one call per gate.** The roadmap step that
22
+ * charters this names the framework-leakage allowlist as the first adopter,
23
+ * and it is first in the inventory below. The enforcement itself lives here,
24
+ * once, for two reasons the per-gate wiring cannot match: a NEW allowlist added
25
+ * next month is caught by the inventory check even if its author never wires
26
+ * anything, and a hot lint that runs on every save does not acquire a
27
+ * dependency on git plumbing (`git show`, rename detection, base-ref
28
+ * resolution) which is exactly where CI-vs-local divergence gets introduced.
29
+ *
30
+ * **Gaming risk.** The cheapest degenerate pass is a `falsifier` field holding
31
+ * a command that always exits 0 (`true`, `echo ok`). This gate rejects the
32
+ * literal degenerate forms and enforces a minimum length; it cannot execute the
33
+ * command to check that it discriminates, and does not pretend to. The residual
34
+ * is a human read at review time, and it is named here so that it is a known
35
+ * gap rather than an assumed guarantee.
36
+ *
37
+ * **Lifecycle.** This gate lands ENFORCED, not advisory, because it was run
38
+ * against the real corpus before wiring and the live estate already satisfies
39
+ * it: all 38 object-tier entries across 10 surfaces carry a `reason`, and the
40
+ * `falsifier` requirement applies to NEW entries only. Advisory-until-empty is
41
+ * the rule for a gate whose corpus has findings on day one; this one had none,
42
+ * and shipping it advisory would have been a hedge rather than a stage.
43
+ *
44
+ * CLI contract: exit 0 = clean, 1 = growth, missing reason, missing falsifier
45
+ * on a new entry, an un-inventoried suppression file, or a dead scope.
46
+ * `--quiet` suppresses the per-file green lines.
47
+ */
48
+ import * as fs from 'node:fs';
49
+ import * as path from 'node:path';
50
+ import process from 'node:process';
51
+ import { fileURLToPath, pathToFileURL } from 'node:url';
52
+
53
+ import { GateLedger } from './_lib/gate_ledger.js';
54
+ import {
55
+ BaseRefUnavailableError,
56
+ compareToBaseRef,
57
+ resolveBaseRef,
58
+ type RatchetComparison,
59
+ } from './_lib/ratchet_base_ref.js';
60
+ import { assertScanned, DeadScopeError } from './_lib/scan_scope.js';
61
+
62
+ const _HERE = path.resolve(fileURLToPath(import.meta.url));
63
+ const REPO_ROOT = path.resolve(path.dirname(_HERE), '..', '..');
64
+ const QUIET = process.argv.slice(2).includes('--quiet');
65
+
66
+ /** Minimum characters in a `reason` / `falsifier` before it can be audited. */
67
+ export const MIN_REASON_CHARS = 20;
68
+
69
+ /**
70
+ * Falsifier values that satisfy the schema while deciding nothing.
71
+ *
72
+ * Named explicitly rather than described, so the check is testable.
73
+ */
74
+ const DEGENERATE_FALSIFIERS = new Set(['true', ':', 'echo ok', 'exit 0', 'n/a', 'none', 'tbd']);
75
+
76
+ /**
77
+ * Entry tiers.
78
+ *
79
+ * - `object` — human-authored records; carry `reason`, and `falsifier` when new.
80
+ * - `string_list` — bare-string carve-outs whose entry IS the whole record, so
81
+ * there is nowhere to put a reason. Subject to no-growth only; a file-level
82
+ * rationale lives in the JSON's own `_comment` / `_doc` key.
83
+ */
84
+ export type SuppressionTier = 'object' | 'string_list';
85
+
86
+ export interface SuppressionSpec {
87
+ /** Repo-relative path. */
88
+ file: string;
89
+ /** Key holding the entry list (or `null` when the root IS the list). */
90
+ listKey: string | null;
91
+ tier: SuppressionTier;
92
+ /** Field names, in order, that identify an entry for set comparison. */
93
+ keyFields?: readonly string[];
94
+ /** Field carrying the human rationale (`reason` unless noted). */
95
+ reasonField?: string;
96
+ /**
97
+ * Whether the entry SET may gain members.
98
+ *
99
+ * `forbidden` (default) is right for a suppression list: a new entry is a
100
+ * new hole. `requires_falsifier` is right for a registry OF ratchets —
101
+ * `gate-violation-baselines.json` gains an entry every time a new gate
102
+ * records its debt, and refusing that would mean no gate may ever be
103
+ * baselined, which forbids the practice this whole roadmap is asking for.
104
+ * Its shrink-only property is the per-entry `count`, enforced by
105
+ * `_lib/gate_baseline.ts`, not set membership.
106
+ */
107
+ growth?: 'forbidden' | 'requires_falsifier';
108
+ /**
109
+ * Fields that decide whether an entry is position-keyed or content-keyed.
110
+ *
111
+ * The two-tier split this roadmap asks for is, in this tree, entirely about
112
+ * ONE surface: human entries keyed by line number. Nothing here mixes human
113
+ * and machine-generated records — that half of the split is already the
114
+ * shape. What does exist is the recorded drift failure, where inserting a
115
+ * paragraph above an allowlisted line re-fires the ratchet on an entry
116
+ * nobody touched. Reporting the position-keyed count per run makes the
117
+ * migration to content anchors visible instead of aspirational.
118
+ */
119
+ driftKey?: { positionField: string; anchorField: string };
120
+ /** Why this list exists at all — printed with the count, so absence is visible. */
121
+ what: string;
122
+ }
123
+
124
+ /**
125
+ * The declared inventory of human-authored suppression surfaces.
126
+ *
127
+ * `check_suppression_inventory` (below) fails when a `*allowlist*.json` or
128
+ * `*baseline*.json` exists under the scanned roots and is absent here — an
129
+ * allowlist nobody listed is an allowlist nobody ratchets.
130
+ */
131
+ export const SUPPRESSION_INVENTORY: readonly SuppressionSpec[] = [
132
+ {
133
+ file: 'src/scripts/lint_framework_leakage_allowlist.json',
134
+ listKey: 'entries',
135
+ tier: 'object',
136
+ keyFields: ['file'],
137
+ driftKey: { positionField: 'lines', anchorField: 'anchor' },
138
+ what: 'per-line framework-token exemptions in generic artefacts',
139
+ },
140
+ {
141
+ file: 'src/scripts/pack_dependency_allowlist.json',
142
+ listKey: 'entries',
143
+ tier: 'object',
144
+ keyFields: ['pack', 'slug'],
145
+ what: 'cross-pack dependency edges accepted as-is',
146
+ },
147
+ {
148
+ file: 'src/scripts/lint_skill_originality_allowlist.json',
149
+ listKey: 'pairs',
150
+ tier: 'object',
151
+ keyFields: ['skill_a', 'skill_b'],
152
+ what: 'skill pairs allowed to overlap',
153
+ },
154
+ {
155
+ file: 'src/scripts/lint_workflow_security_allowlist.json',
156
+ listKey: 'findings',
157
+ tier: 'object',
158
+ keyFields: ['workflow', 'rule'],
159
+ what: 'accepted workflow-security findings',
160
+ },
161
+ {
162
+ file: 'src/scripts/lint_skill_scripts_readonly_allowlist.json',
163
+ listKey: 'entries',
164
+ tier: 'object',
165
+ keyFields: ['path'],
166
+ what: 'skill scripts permitted to write',
167
+ },
168
+ {
169
+ file: 'src/scripts/audit_skill_overlap_allowlist.json',
170
+ listKey: 'entries',
171
+ tier: 'object',
172
+ keyFields: ['skill_a', 'skill_b', 'path'],
173
+ what: 'accepted skill-overlap findings',
174
+ },
175
+ {
176
+ file: 'src/scripts/lint_skill_descriptions_allowlist.json',
177
+ listKey: 'entries',
178
+ tier: 'object',
179
+ keyFields: ['skill', 'path'],
180
+ what: 'accepted skill-description findings',
181
+ },
182
+ {
183
+ file: 'src/scripts/check_no_conflict_markers_allowlist.json',
184
+ listKey: 'files',
185
+ tier: 'string_list',
186
+ what: 'files permitted to contain conflict-marker-shaped text',
187
+ },
188
+ {
189
+ file: 'src/scripts/external_sources_denylist.json',
190
+ listKey: 'skip_paths',
191
+ tier: 'string_list',
192
+ what: 'paths exempt from the external-source scan',
193
+ },
194
+ {
195
+ file: 'src/config/gate-violation-baselines.json',
196
+ listKey: 'gates',
197
+ tier: 'object',
198
+ reasonField: 'note',
199
+ growth: 'requires_falsifier',
200
+ what: 'per-gate known-debt counts',
201
+ },
202
+ ];
203
+
204
+ export interface SuppressionFinding {
205
+ file: string;
206
+ entry: string;
207
+ kind: 'growth' | 'missing_reason' | 'missing_falsifier' | 'degenerate_falsifier' | 'uninventoried';
208
+ detail: string;
209
+ }
210
+
211
+ interface LoadedEntry {
212
+ key: string;
213
+ reason: string;
214
+ falsifier: string;
215
+ /** True when the entry is keyed by position and carries no content anchor. */
216
+ positionKeyed: boolean;
217
+ }
218
+
219
+ function entryKey(spec: SuppressionSpec, value: unknown, index: number): string {
220
+ if (typeof value === 'string') {
221
+ return value;
222
+ }
223
+ if (value !== null && typeof value === 'object') {
224
+ const rec = value as Record<string, unknown>;
225
+ const parts = (spec.keyFields ?? []).map((f) => (typeof rec[f] === 'string' ? String(rec[f]) : ''));
226
+ const joined = parts.filter((p) => p !== '').join('|');
227
+ if (joined !== '') {
228
+ return joined;
229
+ }
230
+ }
231
+ return `#${String(index)}`;
232
+ }
233
+
234
+ /** Read one suppression file into comparable entries. */
235
+ export function loadEntries(spec: SuppressionSpec, repoRoot = REPO_ROOT): LoadedEntry[] {
236
+ const raw = fs.readFileSync(path.join(repoRoot, spec.file), 'utf-8');
237
+ const parsed: unknown = JSON.parse(raw);
238
+ const container = spec.listKey === null ? parsed : (parsed as Record<string, unknown>)[spec.listKey];
239
+ const reasonField = spec.reasonField ?? 'reason';
240
+
241
+ const rows: LoadedEntry[] = [];
242
+ const push = (value: unknown, key: string): void => {
243
+ const rec = value !== null && typeof value === 'object' ? (value as Record<string, unknown>) : {};
244
+ const drift = spec.driftKey;
245
+ const anchored =
246
+ drift !== undefined &&
247
+ typeof rec[drift.anchorField] === 'string' &&
248
+ String(rec[drift.anchorField]).trim() !== '';
249
+ const positional =
250
+ drift !== undefined && Array.isArray(rec[drift.positionField]) && !anchored;
251
+ rows.push({
252
+ key,
253
+ reason: typeof rec[reasonField] === 'string' ? String(rec[reasonField]).trim() : '',
254
+ falsifier: typeof rec['falsifier'] === 'string' ? String(rec['falsifier']).trim() : '',
255
+ positionKeyed: positional,
256
+ });
257
+ };
258
+
259
+ if (Array.isArray(container)) {
260
+ container.forEach((item, i) => push(item, entryKey(spec, item, i)));
261
+ } else if (container !== null && typeof container === 'object') {
262
+ for (const [k, v] of Object.entries(container as Record<string, unknown>)) {
263
+ push(v, k);
264
+ }
265
+ }
266
+ return rows;
267
+ }
268
+
269
+ /** Every `*allowlist*.json` / `*baseline*.json` under the scanned roots. */
270
+ export function discoverSuppressionFiles(repoRoot = REPO_ROOT): string[] {
271
+ const roots = ['src/scripts', 'src/config'];
272
+ const out: string[] = [];
273
+ for (const root of roots) {
274
+ const dir = path.join(repoRoot, root);
275
+ let names: string[];
276
+ try {
277
+ names = fs.readdirSync(dir);
278
+ } catch {
279
+ continue;
280
+ }
281
+ for (const name of names) {
282
+ if (!name.endsWith('.json')) {
283
+ continue;
284
+ }
285
+ if (/allowlist|denylist|baselines?/i.test(name)) {
286
+ out.push(`${root}/${name}`);
287
+ }
288
+ }
289
+ }
290
+ return out.sort();
291
+ }
292
+
293
+ export function main(): number {
294
+ const findings: SuppressionFinding[] = [];
295
+ const ledger = new GateLedger('check_suppression_hygiene');
296
+
297
+ // An un-inventoried suppression file is the growth path this gate would
298
+ // otherwise miss entirely: adding a brand-new allowlist is unbounded growth
299
+ // that no per-file ratchet can see.
300
+ const declared = new Set(SUPPRESSION_INVENTORY.map((s) => s.file));
301
+ const discovered = discoverSuppressionFiles();
302
+ for (const file of discovered) {
303
+ if (!declared.has(file)) {
304
+ findings.push({
305
+ file,
306
+ entry: '(whole file)',
307
+ kind: 'uninventoried',
308
+ detail:
309
+ 'a suppression-shaped file that SUPPRESSION_INVENTORY does not declare — add it ' +
310
+ 'there (with its entry shape) so it is ratcheted, or rename it if it is not a ' +
311
+ 'suppression surface',
312
+ });
313
+ }
314
+ }
315
+
316
+ try {
317
+ assertScanned({
318
+ gate: 'check_suppression_hygiene',
319
+ scanned: SUPPRESSION_INVENTORY.length,
320
+ units: 'suppression file(s)',
321
+ roots: ['src/scripts', 'src/config'],
322
+ });
323
+ } catch (exc) {
324
+ if (exc instanceof DeadScopeError) {
325
+ process.stderr.write(`❌ ${exc.message}\n`);
326
+ return 1;
327
+ }
328
+ throw exc;
329
+ }
330
+
331
+ const baseRef = resolveBaseRef(REPO_ROOT);
332
+ if (baseRef === null) {
333
+ process.stderr.write(
334
+ '❌ check_suppression_hygiene: no base ref resolves (tried RATCHET_BASE_REF, ' +
335
+ 'GITHUB_BASE_REF, the PR merge parent, origin/main, main). A shrink-only check ' +
336
+ 'that cannot see its "before" has verified nothing, so this is a failure rather ' +
337
+ 'than a skip.\n',
338
+ );
339
+ return 1;
340
+ }
341
+
342
+ ledger.plan(SUPPRESSION_INVENTORY.map((s) => s.file));
343
+ const lines: string[] = [];
344
+
345
+ for (const spec of SUPPRESSION_INVENTORY) {
346
+ const absolute = path.join(REPO_ROOT, spec.file);
347
+ if (!fs.existsSync(absolute)) {
348
+ // A declared file that vanished is a dead entry in the inventory,
349
+ // not an absence of findings.
350
+ ledger.fail(spec.file, 'declared in SUPPRESSION_INVENTORY but absent on disk');
351
+ findings.push({
352
+ file: spec.file,
353
+ entry: '(whole file)',
354
+ kind: 'uninventoried',
355
+ detail: 'declared in SUPPRESSION_INVENTORY but absent on disk',
356
+ });
357
+ continue;
358
+ }
359
+
360
+ const entries = loadEntries(spec);
361
+ const reasonField = spec.reasonField ?? 'reason';
362
+
363
+ let comparison: RatchetComparison;
364
+ try {
365
+ comparison = compareToBaseRef({
366
+ baselinePath: spec.file,
367
+ baseRef,
368
+ repoRoot: REPO_ROOT,
369
+ entriesOf: (parsed) => entriesOfSpec(spec, parsed),
370
+ });
371
+ } catch (exc) {
372
+ if (exc instanceof BaseRefUnavailableError) {
373
+ ledger.fail(spec.file, exc.message);
374
+ findings.push({ file: spec.file, entry: '(whole file)', kind: 'growth', detail: exc.message });
375
+ continue;
376
+ }
377
+ throw exc;
378
+ }
379
+
380
+ const added = new Set(comparison.added);
381
+ let fileFindings = 0;
382
+
383
+ for (const entry of entries) {
384
+ if (spec.tier === 'object' && entry.reason.length < MIN_REASON_CHARS) {
385
+ findings.push({
386
+ file: spec.file,
387
+ entry: entry.key,
388
+ kind: 'missing_reason',
389
+ detail: `\`${reasonField}\` is missing or shorter than ${String(MIN_REASON_CHARS)} characters`,
390
+ });
391
+ fileFindings += 1;
392
+ }
393
+ if (!added.has(entry.key)) {
394
+ continue; // grandfathered: falsifier is required of NEW entries only
395
+ }
396
+ if (comparison.verdict === 'growth' && (spec.growth ?? 'forbidden') === 'forbidden') {
397
+ findings.push({
398
+ file: spec.file,
399
+ entry: entry.key,
400
+ kind: 'growth',
401
+ detail: `absent at ${baseRef} — this list is shrink-only`,
402
+ });
403
+ fileFindings += 1;
404
+ }
405
+ if (spec.tier !== 'object') {
406
+ continue;
407
+ }
408
+ if (entry.falsifier.length < MIN_REASON_CHARS) {
409
+ findings.push({
410
+ file: spec.file,
411
+ entry: entry.key,
412
+ kind: 'missing_falsifier',
413
+ detail:
414
+ 'a NEW entry must carry `falsifier`: a re-runnable command that decides it. ' +
415
+ 'An entry with a falsifier is a ratchet; one without is a hole',
416
+ });
417
+ fileFindings += 1;
418
+ } else if (DEGENERATE_FALSIFIERS.has(entry.falsifier.toLowerCase())) {
419
+ findings.push({
420
+ file: spec.file,
421
+ entry: entry.key,
422
+ kind: 'degenerate_falsifier',
423
+ detail: `\`${entry.falsifier}\` always succeeds — it decides nothing`,
424
+ });
425
+ fileFindings += 1;
426
+ }
427
+ }
428
+
429
+ if (fileFindings > 0) {
430
+ ledger.fail(spec.file, `${String(fileFindings)} finding(s)`);
431
+ } else {
432
+ ledger.complete(spec.file);
433
+ }
434
+
435
+ const reset = comparison.verdict === 'reset' ? ` — RE-BASELINED: ${comparison.resetReason ?? ''}` : '';
436
+ const positional = entries.filter((e) => e.positionKeyed).length;
437
+ const drift =
438
+ positional > 0 ? `, ${String(positional)} position-keyed (drift-fragile, migrate to anchors)` : '';
439
+ lines.push(
440
+ ` ${spec.file}: ${String(entries.length)} entry(ies) (${String(comparison.baseCount)} at base, ` +
441
+ `${String(comparison.removed.length)} removed)${drift} — ${spec.what}${reset}`,
442
+ );
443
+ }
444
+
445
+ process.stdout.write(
446
+ `suppression hygiene: ${String(SUPPRESSION_INVENTORY.length)} declared surface(s), ` +
447
+ `${String(discovered.length)} discovered, base ${baseRef}\n`,
448
+ );
449
+ if (!QUIET) {
450
+ for (const line of lines) {
451
+ process.stdout.write(`${line}\n`);
452
+ }
453
+ }
454
+ ledger.report();
455
+ process.stdout.write(`scanned: ${String(SUPPRESSION_INVENTORY.length)}\n`);
456
+
457
+ if (findings.length > 0) {
458
+ process.stderr.write(`\n❌ ${String(findings.length)} suppression-hygiene finding(s):\n`);
459
+ for (const f of findings) {
460
+ process.stderr.write(` ${f.file} → ${f.entry} [${f.kind}]\n ${f.detail}\n`);
461
+ }
462
+ return 1;
463
+ }
464
+ return 0;
465
+ }
466
+
467
+ /** Entry keys for one spec, applied to a parsed baseline from either side. */
468
+ export function entriesOfSpec(spec: SuppressionSpec, parsed: unknown): string[] {
469
+ const container = spec.listKey === null ? parsed : (parsed as Record<string, unknown>)[spec.listKey];
470
+ if (Array.isArray(container)) {
471
+ return container.map((item, i) => entryKey(spec, item, i));
472
+ }
473
+ if (container !== null && typeof container === 'object') {
474
+ return Object.keys(container as Record<string, unknown>);
475
+ }
476
+ return [];
477
+ }
478
+
479
+ function _isCliEntry(): boolean {
480
+ if (process.argv[1] === undefined) {
481
+ return false;
482
+ }
483
+ const argvUrl = pathToFileURL(path.resolve(process.argv[1])).href;
484
+ if (import.meta.url === argvUrl) {
485
+ return true;
486
+ }
487
+ // A symlinked invocation (installed projection, or macOS /var → /private/var
488
+ // temp dirs) makes the raw URLs differ: compare realpaths so the entry guard
489
+ // still fires instead of silently no-opping.
490
+ try {
491
+ const here = fs.realpathSync(fileURLToPath(import.meta.url));
492
+ const argv = fs.realpathSync(path.resolve(process.argv[1]));
493
+ return here === argv;
494
+ } catch {
495
+ return false;
496
+ }
497
+ }
498
+
499
+ if (_isCliEntry() || process.argv[1] === _HERE) {
500
+ process.exitCode = main();
501
+ }
502
+
503
+ export { QUIET };
@@ -1053,12 +1053,51 @@ function _read_rule_packs(): string[] | null {
1053
1053
  * compiles and behaves unchanged.
1054
1054
  */
1055
1055
  /** List rule basenames under RULES_SOURCE, workspace/pack scope applied. */
1056
+ /**
1057
+ * Is this rule `type: manual` (ADR-004 reference-only)?
1058
+ *
1059
+ * The schema is explicit: `manual` = "no auto-injection (zero workspace-budget
1060
+ * cost); file remains as a reference document linkable from skills/contexts."
1061
+ * `compile_router` already honours the first half by omitting manual rules from
1062
+ * `dist/router.json`. The per-tool projection did NOT: it symlinked them into
1063
+ * `.claude/rules/` (and siblings), so under the default `eager-all` projection
1064
+ * their full bodies were injected every turn — the exact opposite of "zero
1065
+ * workspace-budget cost". `brand-consistency` is the clearest case: its own body
1066
+ * says "reference-only, no router emission", and it shipped in context anyway.
1067
+ *
1068
+ * Excluded from the per-tool trees ONLY. The file keeps its place in
1069
+ * `dist/agent-src/rules/`, which is what "remains as a reference document
1070
+ * linkable" requires — inbound cross-references must still resolve.
1071
+ */
1072
+ function _is_manual_rule(rule_path: string): boolean {
1073
+ let content: string;
1074
+ try {
1075
+ content = fs.readFileSync(rule_path, 'utf-8');
1076
+ } catch {
1077
+ return false; // unreadable → fail open, same posture as the scope filters
1078
+ }
1079
+ const [fm_lines] = _split_frontmatter(content);
1080
+ if (fm_lines === null) {
1081
+ return false;
1082
+ }
1083
+ for (const line of fm_lines) {
1084
+ const m = /^type:\s*["']?([a-z]+)["']?\s*$/.exec(line);
1085
+ if (m) {
1086
+ return m[1] === 'manual';
1087
+ }
1088
+ }
1089
+ return false;
1090
+ }
1091
+
1056
1092
  function _scoped_rule_basenames(): string[] {
1057
1093
  const scope = _read_rule_workspaces();
1058
1094
  const pack_scope = _read_rule_packs();
1059
1095
  return _iterdirSorted(MODULE_STATE.RULES_SOURCE)
1060
1096
  .filter((p) => p.endsWith('.md') && _isFile(p))
1061
1097
  .filter((p) => rule_in_scope(p, scope, pack_scope))
1098
+ // ADR-004: a manual rule costs zero workspace budget, so it never gets a
1099
+ // per-tool symlink regardless of projection mode. See _is_manual_rule.
1100
+ .filter((p) => !_is_manual_rule(p))
1062
1101
  // A compile-disabled rule has no dist/agent-src/ counterpart, so a per-tool
1063
1102
  // symlink to it would dangle. The source file still exists, which is why the
1064
1103
  // scope filters alone let it through — the toggle is a separate axis and this
@@ -1311,7 +1350,10 @@ export function generate_cursor_mdc_rules(): number {
1311
1350
  const pack_scope = _read_rule_packs();
1312
1351
  const rules = _rglobSorted(MODULE_STATE.RULES_SOURCE, '*.md')
1313
1352
  .filter((p) => _isFile(p))
1314
- .filter((p) => rule_in_scope(p, scope, pack_scope));
1353
+ .filter((p) => rule_in_scope(p, scope, pack_scope))
1354
+ // ADR-004 manual rules are reference-only — never auto-injected into a
1355
+ // per-tool tree. Same predicate as generate_rule_symlinks.
1356
+ .filter((p) => !_is_manual_rule(p));
1315
1357
  const stems = rules.map((r) => path.basename(r, '.md'));
1316
1358
  const valid = new Set([
1317
1359
  ...stems.map((s) => `${s}.mdc`),
@@ -1330,7 +1372,10 @@ export function generate_windsurf_modern_rules(): number {
1330
1372
  const pack_scope = _read_rule_packs();
1331
1373
  const rules = _rglobSorted(MODULE_STATE.RULES_SOURCE, '*.md')
1332
1374
  .filter((p) => _isFile(p))
1333
- .filter((p) => rule_in_scope(p, scope, pack_scope));
1375
+ .filter((p) => rule_in_scope(p, scope, pack_scope))
1376
+ // ADR-004 manual rules are reference-only — never auto-injected into a
1377
+ // per-tool tree. Same predicate as generate_rule_symlinks.
1378
+ .filter((p) => !_is_manual_rule(p));
1334
1379
  const valid = new Set(rules.map((r) => path.basename(r)));
1335
1380
  _clean_modern_dir(_WINDSURF_RULES_DIR(), valid);
1336
1381
  for (const rule of rules) {