@event4u/agent-config 14.18.0 → 14.19.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 (146) hide show
  1. package/.augment-plugin/marketplace.json +3 -3
  2. package/.augment-plugin/plugin.json +1 -1
  3. package/.claude-plugin/marketplace.json +1 -1
  4. package/CHANGELOG.md +169 -130
  5. package/README.md +71 -149
  6. package/dist/agent-src/README.md +1 -1
  7. package/dist/agent-src/commands/agent-handoff.md +2 -2
  8. package/dist/agent-src/commands/review/changes.md +19 -19
  9. package/dist/agent-src/commands/review.md +2 -2
  10. package/dist/agent-src/contexts/execution/host-capability-manifest.md +30 -3
  11. package/dist/agent-src/rules/autonomous-execution.md +1 -1
  12. package/dist/agent-src/rules/git-history-discipline.md +2 -2
  13. package/dist/agent-src/rules/guidelines.md +1 -1
  14. package/dist/agent-src/rules/session-canary.md +2 -2
  15. package/dist/agent-src/rules/token-efficiency.md +1 -1
  16. package/dist/agent-src/skills/adversarial-review/SKILL.md +0 -8
  17. package/dist/agent-src/skills/canvas-design/SKILL.md +1 -1
  18. package/dist/agent-src/skills/design-intelligence/SKILL.md +1 -1
  19. package/dist/agent-src/skills/design-intelligence/data/app-interface.csv +1 -1
  20. package/dist/agent-src/skills/design-intelligence/data/motion.csv +17 -17
  21. package/dist/agent-src/skills/design-intelligence/data/ux-guidelines.csv +2 -2
  22. package/dist/agent-src/skills/design-intelligence/references/context-and-registers.md +17 -0
  23. package/dist/agent-src/skills/design-intelligence/references/design-rules-checklist.md +4 -4
  24. package/dist/agent-src/skills/design-review/SKILL.md +1 -1
  25. package/dist/agent-src/skills/design-variations/SKILL.md +1 -1
  26. package/dist/agent-src/skills/existing-ui-audit/SKILL.md +1 -1
  27. package/dist/agent-src/skills/fe-design/SKILL.md +32 -5
  28. package/dist/agent-src/skills/fe-design/references/craft-floor.md +7 -0
  29. package/dist/agent-src/skills/fe-design/references/design-patterns.md +47 -2
  30. package/dist/agent-src/skills/laravel-scheduling/SKILL.md +3 -1
  31. package/dist/agent-src/skills/project-analyzer/SKILL.md +0 -6
  32. package/dist/agent-src/skills/react-native-setup/SKILL.md +3 -3
  33. package/dist/agent-src/skills/react-shadcn-ui/SKILL.md +1 -1
  34. package/dist/agent-src/skills/sequential-thinking/SKILL.md +18 -8
  35. package/dist/agent-src/skills/tailwind-engineer/SKILL.md +1 -1
  36. package/dist/agent-src/skills/ui-apply-generic/SKILL.md +1 -1
  37. package/dist/agent-src/skills/ui-component-architect/SKILL.md +1 -1
  38. package/dist/agent-src/skills/using-git-worktrees/SKILL.md +17 -2
  39. package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
  40. package/dist/cli/commands/recordTriggerEval.js +2 -2
  41. package/dist/cli/main.js +1 -1
  42. package/dist/cli-delegate/{chunk-AKJGUQNO.js → chunk-GZTMRYCN.js} +405 -341
  43. package/dist/cli-delegate/{chunk-7RXIKF62.js → chunk-IDHUQIXP.js} +283 -132
  44. package/dist/cli-delegate/cmd_adr_effective.js +318 -81
  45. package/dist/cli-delegate/cmd_conformance.js +420 -16
  46. package/dist/cli-delegate/cmd_doctor.js +1 -1
  47. package/dist/cli-delegate/cmd_export.js +1 -1
  48. package/dist/cli-delegate/cmd_handoff.js +1 -1
  49. package/dist/cli-delegate/cmd_prune.js +1 -1
  50. package/dist/cli-delegate/cmd_route_audit.js +1 -1
  51. package/dist/cli-delegate/cmd_settings_migrate.js +1 -1
  52. package/dist/cli-delegate/cmd_sync.js +1 -1
  53. package/dist/cli-delegate/cmd_uninstall.js +1 -1
  54. package/dist/cli-delegate/cmd_validate.js +1 -1
  55. package/dist/discovery/deprecation-report.md +1 -1
  56. package/dist/discovery/discovery-manifest.json +28 -27
  57. package/dist/discovery/discovery-manifest.json.sha256 +1 -1
  58. package/dist/discovery/discovery-manifest.summary.md +1 -1
  59. package/dist/discovery/dormancy-report.md +1 -1
  60. package/dist/discovery/orphan-report.md +1 -1
  61. package/dist/discovery/packs.json +2 -2
  62. package/dist/discovery/trust-report.md +1 -1
  63. package/dist/discovery/workspaces.json +2 -2
  64. package/dist/hooks/dispatch.js +2132 -1462
  65. package/dist/install/install.mjs +588 -527
  66. package/dist/mcp/registry-manifest.json +1 -1
  67. package/dist/mcp/server.json +2 -2
  68. package/dist/mcp/server.mjs +1619 -1045
  69. package/dist/scripts/_lib/model_tier.js +1 -1
  70. package/docs/guidelines/agent-infra/evaluator-independence-mechanics.md +1 -1
  71. package/docs/guidelines/agent-infra/roadmap-progress-mechanics.md +1 -1
  72. package/docs/guidelines/design-antipatterns.md +87 -64
  73. package/package.json +1 -1
  74. package/src/agent-src/README.md +1 -1
  75. package/src/config/ci-local-parity.yml +6 -0
  76. package/src/config/gate-coverage.yml +170 -0
  77. package/src/config/gate-violation-baselines.json +3 -3
  78. package/src/config/release-gate-locality.yml +12 -0
  79. package/src/scripts/_cli/cmd_doctor.ts +2 -2
  80. package/src/scripts/_cli/cmd_handoff.ts +1 -1
  81. package/src/scripts/_cli/cmd_update.ts +1 -1
  82. package/src/scripts/_cli/cmd_versions.ts +1 -1
  83. package/src/scripts/_lib/adr_frontmatter.ts +44 -0
  84. package/src/scripts/_lib/consequence_objects.ts +152 -0
  85. package/src/scripts/_lib/exec_evidence.ts +7 -0
  86. package/src/scripts/_lib/global_deploy_inventory.ts +2 -2
  87. package/src/scripts/_lib/host_permission_checks.ts +251 -0
  88. package/src/scripts/_lib/model_tier.ts +1 -1
  89. package/src/scripts/_lib/obligation_frequency.ts +2 -2
  90. package/src/scripts/_lib/release_highlights.ts +93 -0
  91. package/src/scripts/_lib/release_material.ts +64 -1
  92. package/src/scripts/_lib/runtime_journal.ts +26 -3
  93. package/src/scripts/_lib/runtime_wiring_checks.ts +12 -1
  94. package/src/scripts/_lib/session_register.ts +11 -6
  95. package/src/scripts/_lib/sqlite_schema.ts +22 -0
  96. package/src/scripts/_lib/surface.ts +86 -0
  97. package/src/scripts/_lib/ui_authority.ts +116 -0
  98. package/src/scripts/adr/regenerate_index.ts +54 -1
  99. package/src/scripts/adr_cite_check.ts +71 -15
  100. package/src/scripts/ai_council/clients.ts +1 -1
  101. package/src/scripts/autonomy_friction_corpus.ts +229 -0
  102. package/src/scripts/build_proof.ts +15 -2
  103. package/src/scripts/check_beta_review_markers.ts +141 -17
  104. package/src/scripts/check_enforcement_coverage.ts +1 -1
  105. package/src/scripts/check_enforcement_denominator.ts +121 -11
  106. package/src/scripts/check_finding_dispositions.ts +66 -9
  107. package/src/scripts/check_gate_coverage.ts +96 -0
  108. package/src/scripts/check_release_highlights.ts +74 -1
  109. package/src/scripts/check_skill_activation_claim.ts +179 -0
  110. package/src/scripts/check_trigger_evals.ts +1 -1
  111. package/src/scripts/conformance_scan.ts +44 -3
  112. package/src/scripts/explain_run.ts +1 -1
  113. package/src/scripts/generate_capability_matrix.ts +1 -1
  114. package/src/scripts/git_authorization_hook.ts +75 -1
  115. package/src/scripts/hook_manifest.json +1 -1
  116. package/src/scripts/hook_manifest.yaml +14 -11
  117. package/src/scripts/hooks/augment-dispatcher.sh +1 -1
  118. package/src/scripts/hooks/category_a.ts +291 -0
  119. package/src/scripts/hooks/cline-dispatcher.sh +1 -1
  120. package/src/scripts/hooks/cursor-dispatcher.sh +11 -5
  121. package/src/scripts/hooks/dispatch_hook.ts +17 -22
  122. package/src/scripts/hooks/git_command_classifier.ts +156 -1
  123. package/src/scripts/hooks/host_lowering.ts +215 -0
  124. package/src/scripts/hooks/host_lowering.yaml +185 -0
  125. package/src/scripts/hooks/host_semantics.ts +105 -37
  126. package/src/scripts/hooks/journal_record_hook.ts +10 -0
  127. package/src/scripts/hooks/permission_gate.ts +117 -0
  128. package/src/scripts/hooks_status.ts +12 -10
  129. package/src/scripts/install.ts +53 -82
  130. package/src/scripts/install_anthropic_key.sh +2 -2
  131. package/src/scripts/lint_design_antipattern_parity.ts +80 -0
  132. package/src/scripts/lint_evidence_artifacts.ts +45 -1
  133. package/src/scripts/lint_hook_manifest.ts +87 -3
  134. package/src/scripts/lint_motion_authority_drift.ts +731 -0
  135. package/src/scripts/lint_readme_size.ts +158 -12
  136. package/src/scripts/lint_reasoning_fixtures.ts +216 -0
  137. package/src/scripts/lint_skill_trigger_contradiction.ts +161 -0
  138. package/src/scripts/measure_sibling_naming.ts +150 -0
  139. package/src/scripts/redact_hook_capture.ts +1 -1
  140. package/src/scripts/report_skill_activation.ts +319 -7
  141. package/src/scripts/schemas/review-findings.schema.json +5 -0
  142. package/src/scripts/schemas/skill.schema.json +2 -2
  143. package/src/scripts/session_canary_hook.ts +1 -1
  144. package/src/scripts/session_register_hook.ts +4 -3
  145. package/src/scripts/trigger_eval_grandfather.json +5 -1
  146. package/src/scripts/validate_agent_settings.ts +1 -1
@@ -63,28 +63,34 @@ interface LapsedBaseline {
63
63
  contracts: string[];
64
64
  }
65
65
 
66
- let _baselineCache: Set<string> | null = null;
66
+ // Keyed by root: an unkeyed cache returns the REPO baseline for a fixture root
67
+ // whenever the repo was read first, which silently judges a fixture against
68
+ // production data and is invisible in the result.
69
+ const _baselineCache = new Map<string, Set<string>>();
67
70
 
68
71
  export function loadLapsedBaseline(root: string = ROOT): Set<string> {
69
- if (_baselineCache !== null) {
70
- return _baselineCache;
72
+ const hit = _baselineCache.get(root);
73
+ if (hit !== undefined) {
74
+ return hit;
71
75
  }
72
76
  const p = path.join(root, BASELINE_REL);
73
77
  if (!fs.existsSync(p)) {
74
78
  // The baseline reaching zero is the SUCCESS state: the file is deleted
75
79
  // in the same change that flips the severity. An absent file therefore
76
80
  // means "no inherited debt", i.e. every lapse is fresh and errors.
77
- _baselineCache = new Set<string>();
78
- return _baselineCache;
81
+ const empty = new Set<string>();
82
+ _baselineCache.set(root, empty);
83
+ return empty;
79
84
  }
80
85
  const parsed = JSON.parse(fs.readFileSync(p, 'utf-8')) as LapsedBaseline;
81
- _baselineCache = new Set(parsed.contracts);
82
- return _baselineCache;
86
+ const loaded = new Set(parsed.contracts);
87
+ _baselineCache.set(root, loaded);
88
+ return loaded;
83
89
  }
84
90
 
85
91
  /** Test seam — the module-level cache would otherwise outlive a fixture. */
86
92
  export function _resetLapsedBaseline(): void {
87
- _baselineCache = null;
93
+ _baselineCache.clear();
88
94
  }
89
95
 
90
96
  /**
@@ -115,12 +121,43 @@ const SUPERSEDED_RE = /^superseded-by:\s*\S+\s*$/m;
115
121
 
116
122
  const MAX_REVIEW_WINDOW_DAYS = 90;
117
123
 
124
+ /**
125
+ * Forward horizon for the upcoming-lapse report.
126
+ *
127
+ * The gate reports a contract on the day it lapses, which is the day it is
128
+ * already too late to decide anything: a FRESH lapse errors, and every pull
129
+ * request in the repository reds until someone edits a contract under time
130
+ * pressure. The horizon names the set that is ABOUT to enter that branch.
131
+ *
132
+ * The number is bounded on both sides: too wide and the report names most of
133
+ * the forward-dated corpus on every run, which is the same as naming none; too
134
+ * narrow and it arrives after the decision needed to be made.
135
+ */
136
+ const HORIZON_DAYS = 14;
137
+
118
138
  interface Violation {
119
139
  file: string;
120
140
  reason: string;
121
141
  severity: 'error' | 'warning';
122
142
  }
123
143
 
144
+ /**
145
+ * A contract whose window closes within {@link HORIZON_DAYS} and which is NOT
146
+ * in the frozen baseline — i.e. one that will enter the ERROR branch, not the
147
+ * inherited-warning one.
148
+ *
149
+ * Deliberately NOT a {@link Violation}: it carries no severity and never moves
150
+ * the exit code. A third severity class printed into the same stream as the 84
151
+ * standing warnings is the way this work changes nothing (risk 1 on the
152
+ * roadmap's own register), so the horizon is a separate, clearly-labelled
153
+ * section and the exit code stays a function of real violations alone.
154
+ */
155
+ interface Upcoming {
156
+ file: string;
157
+ date: string;
158
+ daysOut: number;
159
+ }
160
+
124
161
  function _exists(p: string): boolean {
125
162
  try {
126
163
  fs.statSync(p);
@@ -184,7 +221,7 @@ function _ordinalToISO(ordinal: number): string {
184
221
  return `${y}-${m}-${d}`;
185
222
  }
186
223
 
187
- function check_one(p: string, todayOrdinal: number): Violation[] {
224
+ function check_one(p: string, todayOrdinal: number, root: string = ROOT): Violation[] {
188
225
  const fm = read_frontmatter(p);
189
226
  if (fm === null) {
190
227
  return [];
@@ -199,7 +236,7 @@ function check_one(p: string, todayOrdinal: number): Violation[] {
199
236
  ['superseded-by', SUPERSEDED_RE.test(fm)],
200
237
  ];
201
238
  const setMarkers = markers.filter(([, present]) => present).map(([name]) => name);
202
- const rel = _relPosix(p, ROOT);
239
+ const rel = _relPosix(p, root);
203
240
  if (setMarkers.length === 0) {
204
241
  return [
205
242
  {
@@ -231,7 +268,7 @@ function check_one(p: string, todayOrdinal: number): Violation[] {
231
268
  // bound because a lapsed date can never also exceed the forward window,
232
269
  // so the order is a readability choice rather than a precedence one.
233
270
  if (reviewOrdinal < todayOrdinal) {
234
- const inherited = loadLapsedBaseline().has(rel);
271
+ const inherited = loadLapsedBaseline(root).has(rel);
235
272
  const age = todayOrdinal - reviewOrdinal;
236
273
  return [
237
274
  {
@@ -263,6 +300,34 @@ function check_one(p: string, todayOrdinal: number): Violation[] {
263
300
  return [];
264
301
  }
265
302
 
303
+ /**
304
+ * The upcoming-lapse finding for one contract, or `null`.
305
+ *
306
+ * Baselined contracts are excluded EXPLICITLY rather than by relying on the
307
+ * fact that a baselined contract is already lapsed and so cannot also carry a
308
+ * future date. That implication holds today and is not a property anything
309
+ * enforces; a guard that is merely vacuous cannot be distinguished from a
310
+ * guard that is absent, and the difference shows up the first time a baseline
311
+ * entry is re-dated forward.
312
+ */
313
+ export function upcoming_one(
314
+ p: string,
315
+ todayOrdinal: number,
316
+ horizonDays: number = HORIZON_DAYS,
317
+ root: string = ROOT,
318
+ ): Upcoming | null {
319
+ const fm = read_frontmatter(p);
320
+ if (fm === null || !/^stability:\s*beta\s*$/m.test(fm)) return null;
321
+ const km = KEEP_RE.exec(fm);
322
+ if (!km) return null;
323
+ const rel = _relPosix(p, root);
324
+ if (loadLapsedBaseline(root).has(rel)) return null;
325
+ const [ry, rm, rd] = _parseISODate(km[1]!);
326
+ const reviewOrdinal = _dateOrdinal(ry, rm, rd);
327
+ const daysOut = reviewOrdinal - todayOrdinal;
328
+ if (daysOut < 0 || daysOut > horizonDays) return null;
329
+ return { file: rel, date: _ordinalToISO(reviewOrdinal), daysOut };
330
+ }
266
331
 
267
332
  /**
268
333
  * STABILITY.md's own re-audit condition, as a command instead of as prose.
@@ -302,10 +367,16 @@ interface ParsedArgs {
302
367
  trigger: boolean;
303
368
  /** Fixture root, for `--self-test`. A fixture is never judged against the repo baseline. */
304
369
  root: string | null;
370
+ horizon: number;
305
371
  }
306
372
 
307
373
  function parse_args(argv: readonly string[]): ParsedArgs {
308
- const args: ParsedArgs = { json: false, trigger: false, root: null };
374
+ const args: ParsedArgs = {
375
+ json: false,
376
+ trigger: false,
377
+ root: null,
378
+ horizon: HORIZON_DAYS,
379
+ };
309
380
  for (let i = 0; i < argv.length; i += 1) {
310
381
  const arg = argv[i] as string;
311
382
  if (arg === '--json') {
@@ -313,14 +384,27 @@ function parse_args(argv: readonly string[]): ParsedArgs {
313
384
  } else if (arg === '--root') {
314
385
  args.root = argv[i + 1] ?? null;
315
386
  i += 1;
387
+ } else if (arg === '--horizon') {
388
+ const raw = argv[i + 1] ?? '';
389
+ const n = Number.parseInt(raw, 10);
390
+ if (!/^\d+$/.test(raw) || Number.isNaN(n)) {
391
+ process.stderr.write(
392
+ `check_beta_review_markers: error: --horizon needs a non-negative integer, got: ${raw || '(nothing)'}\n`,
393
+ );
394
+ process.exit(2);
395
+ }
396
+ args.horizon = n;
397
+ i += 1;
316
398
  } else if (arg === '--trigger') {
317
399
  args.trigger = true;
318
400
  } else if (arg === '-h' || arg === '--help') {
319
401
  process.stdout.write(
320
- 'usage: check_beta_review_markers [-h] [--json] [--trigger]\n' +
402
+ 'usage: check_beta_review_markers [-h] [--json] [--trigger] [--horizon N]\n' +
321
403
  ' --trigger print the lapsed percentage and whether STABILITY.md\n' +
322
404
  ' re-audit condition (>= 25 %) has fired. Reports; never fails.\n' +
323
- ' --root DIR judge a fixture tree instead of the repo (self-test seam).\n',
405
+ ' --root DIR judge a fixture tree instead of the repo (self-test seam).\n' +
406
+ ` --horizon N days ahead to report upcoming fresh lapses (default ${String(HORIZON_DAYS)}).\n` +
407
+ ' Advisory: never changes the exit code.\n',
324
408
  );
325
409
  process.exit(0);
326
410
  } else {
@@ -400,11 +484,15 @@ function main(): number {
400
484
  const scanRoot = args.root ?? ROOT;
401
485
  const contracts = _globMdSorted(path.join(scanRoot, CONTRACTS_DIR));
402
486
  let betaContracts = 0;
487
+ const upcoming: Upcoming[] = [];
403
488
  for (const p of contracts) {
404
489
  const fm = read_frontmatter(p);
405
490
  if (fm !== null && /^stability:\s*beta\s*$/m.test(fm)) betaContracts += 1;
406
- violations.push(...check_one(p, todayOrdinal));
491
+ violations.push(...check_one(p, todayOrdinal, scanRoot));
492
+ const due = upcoming_one(p, todayOrdinal, args.horizon, scanRoot);
493
+ if (due !== null) upcoming.push(due);
407
494
  }
495
+ upcoming.sort((a, b) => a.daysOut - b.daysOut || a.file.localeCompare(b.file));
408
496
  // Count every contract read, not the `stability: beta` subset: over a moved
409
497
  // `docs/contracts/` "no beta contracts" and "no contracts at all" produce
410
498
  // the same clean line, and only the second is a dead gate. Exit 1 is the
@@ -446,7 +534,9 @@ function main(): number {
446
534
  return 0;
447
535
  }
448
536
  if (args.json) {
449
- process.stdout.write(JSON.stringify({ violations }, null, 2) + '\n');
537
+ process.stdout.write(
538
+ JSON.stringify({ violations, upcoming, horizonDays: args.horizon }, null, 2) + '\n',
539
+ );
450
540
  } else {
451
541
  if (violations.length === 0) {
452
542
  process.stdout.write('✅ All beta contracts carry a valid review marker.\n');
@@ -455,7 +545,39 @@ function main(): number {
455
545
  const icon = v.severity === 'error' ? '❌' : '⚠️ ';
456
546
  process.stdout.write(`${icon} ${v.file}: ${v.reason}\n`);
457
547
  }
458
- process.stdout.write(`\n${violations.length} violation(s).\n`);
548
+ // Both counts, and the relationship between them, because a
549
+ // violation count alone cannot answer "how many contracts is this".
550
+ // Today they are equal — check_one returns at most one finding per
551
+ // contract — and printing only one of them hides the fact that it
552
+ // is the size of the decision, not just the size of the output.
553
+ const distinct = new Set(violations.map((v) => v.file)).size;
554
+ const baselineSize = loadLapsedBaseline(scanRoot).size;
555
+ const reportedInherited = violations.filter((v) =>
556
+ loadLapsedBaseline(scanRoot).has(v.file),
557
+ ).length;
558
+ process.stdout.write(
559
+ `\n${String(violations.length)} violation(s) across ` +
560
+ `${String(distinct)} distinct contract(s) — one line per finding, so the ` +
561
+ 'two differ only where a contract carries more than one.\n',
562
+ );
563
+ process.stdout.write(
564
+ `Frozen baseline: ${String(baselineSize)} entries · ` +
565
+ `${String(reportedInherited)} still reported as lapsed · ` +
566
+ `${String(baselineSize - reportedInherited)} inert ` +
567
+ '(left the lapsed set by promotion, supersession, or re-dating).\n',
568
+ );
569
+ }
570
+ if (upcoming.length > 0) {
571
+ process.stdout.write(
572
+ `\nUpcoming FRESH lapses within ${String(args.horizon)} day(s) — advisory, ` +
573
+ 'exit code unchanged. These are absent from the frozen baseline, so each\n' +
574
+ 'becomes an ERROR on its date rather than an inherited warning:\n',
575
+ );
576
+ for (const u of upcoming) {
577
+ process.stdout.write(
578
+ ` ${u.file}: keep-beta-until=${u.date} in ${String(u.daysOut)} day(s) [fresh]\n`,
579
+ );
580
+ }
459
581
  }
460
582
  }
461
583
  return violations.some((v) => v.severity === 'error') ? 1 : 0;
@@ -495,9 +617,11 @@ if (_isCliEntry() || process.argv[1] === _HERE) {
495
617
 
496
618
  export {
497
619
  type Violation,
620
+ type Upcoming,
498
621
  ROOT,
499
622
  CONTRACTS_DIR,
500
623
  MAX_REVIEW_WINDOW_DAYS,
624
+ HORIZON_DAYS,
501
625
  read_frontmatter,
502
626
  check_one,
503
627
  main,
@@ -118,7 +118,7 @@ export interface RuleCoverage {
118
118
  /**
119
119
  * Carrier period per hook-capable platform — never a single scalar.
120
120
  *
121
- * A scalar is wrong twice over: copilot has no hook surface at all, so a
121
+ * A scalar is wrong twice over: this package binds nothing on copilot, so a
122
122
  * weakest-platform collapse turns one platform property into a finding on
123
123
  * every hook-carried rule; and cline maps `stop` from `TaskCancel`, so the
124
124
  * same slot means per-turn on six platforms and per-interruption there. Both
@@ -39,9 +39,26 @@
39
39
  * Escape hatch: `<!-- enforcement-count-ok: <reason> -->` on the line, reason
40
40
  * mandatory. A bare marker stays a finding.
41
41
  *
42
+ * THE SECOND HALF — the projection owes the split. Exempting the generated file
43
+ * settles WHERE the number may be published; it says nothing about whether the
44
+ * published number is readable. `undeclared` quoted bare reads as a backlog
45
+ * somebody forgot to wire, and for nine of them that is not what it is:
46
+ * `block_kernel_rule_writes` refuses an `enforced_by` write on a kernel rule
47
+ * with no agent-accessible override, so those cannot declare one at all. The
48
+ * resolver has always computed `kernel_denied`; the published line dropped it
49
+ * and appended the word `yet`, promising a future that does not exist for that
50
+ * nine. So a line in the sanctioned projection that states an `undeclared`
51
+ * figure without its kernel-denied split is the SAME class of failure as a
52
+ * hand-written count elsewhere: a figure the reader cannot resolve.
53
+ *
54
+ * This deliberately does NOT check the split's value, for the same reason the
55
+ * first half does not check the count's — the resolver is the only source, and
56
+ * a value comparison here would be a second place the number lives.
57
+ *
42
58
  * Exit codes:
43
- * 0 — no hand-written enforcement count in the published corpus
44
- * 1 — at least one (or a marker with no reason)
59
+ * 0 — no hand-written enforcement count in the published corpus, and every
60
+ * published `undeclared` figure carries its kernel-denied split
61
+ * 1 — at least one (or a marker with no reason, or a split-less figure)
45
62
  * 2 — the gate could not run (dead scan scope, bad args)
46
63
  */
47
64
  import * as fs from 'node:fs';
@@ -57,8 +74,8 @@ const _HERE = fileURLToPath(import.meta.url);
57
74
  const DEFAULT_ROOT = path.join(path.dirname(_HERE), '..', '..');
58
75
  const SELF = 'src/scripts/check_enforcement_denominator.ts';
59
76
 
60
- const SELF_TEST_MIN_CASES = 7;
61
- const SELF_TEST_MIN_REJECT = 3;
77
+ const SELF_TEST_MIN_CASES = 10;
78
+ const SELF_TEST_MIN_REJECT = 5;
62
79
 
63
80
  /** Roots walked, repo-relative. */
64
81
  const SCAN_ROOTS = ['docs', 'README.md'] as const;
@@ -82,6 +99,19 @@ const MARKER = 'enforcement-count-ok:';
82
99
  const ENFORCEMENT_KEYWORDS =
83
100
  /undeclared|enforced_by|enforcement coverage|carry a backstop|backstop that fails/;
84
101
 
102
+ /**
103
+ * Shapes that state the `undeclared` figure itself. Narrower than
104
+ * {@link COUNT_SHAPES} on purpose: this is the ONE figure whose split is
105
+ * load-bearing, and a wider net would demand the split beside `blocking`.
106
+ */
107
+ const UNDECLARED_FIGURE_SHAPES: readonly RegExp[] = [
108
+ /\b\d+\s+undeclared\b/i,
109
+ /\bundeclared\s+rules?\s*\(\s*\d+/i,
110
+ ];
111
+
112
+ /** The split that makes the figure readable. Presence is checked, never value. */
113
+ const SPLIT_TOKEN = /kernel[-_]denied/i;
114
+
85
115
  const COUNT_SHAPES: readonly RegExp[] = [
86
116
  /\b\d+\s+of\s+\d+\s+(?:governed\s+)?rules?\b/,
87
117
  /\b\d+\s+(?:governed\s+)?rules?\b/,
@@ -93,8 +123,12 @@ export interface Finding {
93
123
  file: string;
94
124
  line: number;
95
125
  excerpt: string;
96
- /** `raw` — an unmarked count. `bare-marker` — a marker with no reason. */
97
- kind: 'raw' | 'bare-marker';
126
+ /**
127
+ * `raw` — an unmarked count. `bare-marker` — a marker with no reason.
128
+ * `missing-split` — the projection states `undeclared` without its
129
+ * kernel-denied split.
130
+ */
131
+ kind: 'raw' | 'bare-marker' | 'missing-split';
98
132
  }
99
133
 
100
134
  function markerOn(line: string): 'none' | 'bare' | 'reasoned' {
@@ -123,6 +157,30 @@ export function scanText(relPath: string, text: string): Finding[] {
123
157
  return out;
124
158
  }
125
159
 
160
+ /**
161
+ * The projection's own obligation: every line stating an `undeclared` figure
162
+ * carries the kernel-denied split. Run INSTEAD of {@link scanText} on a
163
+ * generated file — that file is allowed to state the number, and is the only
164
+ * one that owes the split.
165
+ */
166
+ export function scanGenerated(relPath: string, text: string): Finding[] {
167
+ const out: Finding[] = [];
168
+ const lines = text.split('\n');
169
+ for (let i = 0; i < lines.length; i += 1) {
170
+ const line = lines[i] ?? '';
171
+ if (!UNDECLARED_FIGURE_SHAPES.some((re) => re.test(line))) continue;
172
+ if (SPLIT_TOKEN.test(line)) continue;
173
+ if (markerOn(line) === 'reasoned') continue;
174
+ out.push({
175
+ file: relPath,
176
+ line: i + 1,
177
+ excerpt: line.trim().slice(0, 120),
178
+ kind: 'missing-split',
179
+ });
180
+ }
181
+ return out;
182
+ }
183
+
126
184
  /** `true` when the file declares itself generated — i.e. it IS the projection. */
127
185
  export function isGenerated(text: string): boolean {
128
186
  return text.split('\n', GENERATED_HEAD_LINES).join('\n').includes(GENERATED_MARKER);
@@ -177,7 +235,13 @@ export function check(root: string): { code: number; findings: Finding[]; scanne
177
235
  for (const rel of files) {
178
236
  const text = fs.readFileSync(path.join(root, rel), 'utf-8');
179
237
  if (isGenerated(text)) {
180
- ledger.complete(rel);
238
+ const split = scanGenerated(rel, text);
239
+ if (split.length > 0) {
240
+ findings.push(...split);
241
+ ledger.fail(rel, `${String(split.length)} undeclared figure(s) without the split`);
242
+ } else {
243
+ ledger.complete(rel);
244
+ }
181
245
  continue;
182
246
  }
183
247
  const found = scanText(rel, text);
@@ -259,8 +323,21 @@ export function selfTest(): number {
259
323
  }),
260
324
  },
261
325
  {
262
- name: "accepts the resolver's own generated projection",
326
+ name: "accepts the resolver's own generated projection when it carries the split",
263
327
  expect: 'accept',
328
+ run: () =>
329
+ run({
330
+ 'docs/proof.md':
331
+ `<!-- ${GENERATED_MARKER} src/scripts/build_proof.ts -->\n\n` +
332
+ '120 rules · 15 blocking · 86 undeclared — of which 9 kernel-denied.\n',
333
+ }),
334
+ },
335
+ {
336
+ // DENIAL POLARITY for the split half. Same file, same exemption, the
337
+ // split removed: the projection is allowed to state the number and is
338
+ // still a finding when it states it bare.
339
+ name: 'rejects the generated projection when the kernel-denied split is removed',
340
+ expect: 'reject',
264
341
  run: () =>
265
342
  run({
266
343
  'docs/proof.md':
@@ -268,6 +345,26 @@ export function selfTest(): number {
268
345
  '120 rules · 15 blocking · 86 undeclared (no enforced_by yet).\n',
269
346
  }),
270
347
  },
348
+ {
349
+ name: 'rejects a bare "Undeclared rules (N)" restatement in the projection',
350
+ expect: 'reject',
351
+ run: () =>
352
+ run({
353
+ 'docs/proof.md':
354
+ `<!-- ${GENERATED_MARKER} src/scripts/build_proof.ts -->\n\n` +
355
+ 'Undeclared rules (86) carry no row.\n',
356
+ }),
357
+ },
358
+ {
359
+ name: 'accepts a generated line about enforcement that states no undeclared figure',
360
+ expect: 'accept',
361
+ run: () =>
362
+ run({
363
+ 'docs/proof.md':
364
+ `<!-- ${GENERATED_MARKER} src/scripts/build_proof.ts -->\n\n` +
365
+ '120 rules · 15 blocking · the resolver prints its own frame.\n',
366
+ }),
367
+ },
271
368
  {
272
369
  name: 'accepts a dated ADR record (docs/decisions/ is excluded)',
273
370
  expect: 'accept',
@@ -318,12 +415,25 @@ export function main(argv?: readonly string[]): number {
318
415
  if (code === 2) return 2;
319
416
 
320
417
  if (findings.length > 0) {
321
- process.stdout.write('❌ hand-written enforcement count(s) in a published doc:\n\n');
418
+ process.stdout.write('❌ unresolvable enforcement figure(s) in a published doc:\n\n');
322
419
  for (const f of findings) {
323
420
  const why =
324
- f.kind === 'bare-marker' ? `\`${MARKER}\` with no reason` : 'hand-written count';
421
+ f.kind === 'bare-marker'
422
+ ? `\`${MARKER}\` with no reason`
423
+ : f.kind === 'missing-split'
424
+ ? 'published `undeclared` figure with no kernel-denied split'
425
+ : 'hand-written count';
325
426
  process.stdout.write(` ${f.file}:${String(f.line)} — ${why}\n ${f.excerpt}\n`);
326
427
  }
428
+ if (findings.some((f) => f.kind === 'missing-split')) {
429
+ process.stdout.write(
430
+ '\nThe projection may state the `undeclared` figure — it is the only surface\n' +
431
+ 'that may — but not bare. Nine of those rules are kernel-denied:\n' +
432
+ '`block_kernel_rule_writes` refuses an `enforced_by` write with no\n' +
433
+ 'agent-accessible override, so for them there is no *yet*. Print\n' +
434
+ "`summary.kernel_denied` on the same line (`build_proof.ts` § 4b does).\n",
435
+ );
436
+ }
327
437
  process.stdout.write(
328
438
  '\nExactly one enforcement denominator is quotable, and it comes from\n' +
329
439
  '`check_enforcement_coverage` — which resolves rather than counts, and prints\n' +
@@ -362,4 +472,4 @@ if (_isCliEntry() || process.argv[1] === _HERE) {
362
472
  process.exit(main());
363
473
  }
364
474
 
365
- export { SCAN_ROOTS, EXCLUDED_PREFIXES, MARKER };
475
+ export { SCAN_ROOTS, EXCLUDED_PREFIXES, MARKER, SPLIT_TOKEN, UNDECLARED_FIGURE_SHAPES };
@@ -50,12 +50,22 @@
50
50
  * not override either, so in CI the local tag list is EMPTY. See releaseStatus
51
51
  * for the asymmetry and the remote fallback.
52
52
  *
53
+ * AN EMPTY LEDGER MUST SAY WHY IT IS EMPTY. The released/unreleased split above
54
+ * closed the missing-file half; it left the other half open, because
55
+ * `findings: []` still passed while asserting nothing. A writer could satisfy
56
+ * the gate by creating an empty file, which is the same silence wearing the
57
+ * shape of a record. `no_findings_reason` is now required whenever the file
58
+ * exists and the array is empty — see empty_ledger_problem for what the reason
59
+ * has to distinguish.
60
+ *
53
61
  * A future reader changing allowEmpty below is the person this is addressed to:
54
- * the EMPTY_VALID waiver still covers a ledger that exists and is empty, and an
55
- * unreleased version. It no longer covers a released version with no ledger.
62
+ * the EMPTY_VALID waiver now covers only an EXPLAINED empty ledger and an
63
+ * unreleased version. It covers neither a released version with no ledger nor an
64
+ * empty ledger with no reason.
56
65
  *
57
66
  * Exit codes: 0 complete · 1 missing/incomplete dispositions (an absent ledger
58
- * for a RELEASED version is one of them) · 2 usage error, schema error, or an
67
+ * for a RELEASED version and an unexplained empty ledger are both among them) ·
68
+ * 2 usage error, schema error, or an
59
69
  * undeterminable release status. Deliberately NOT fail-open: this script never calls a model;
60
70
  * a broken ledger file is a hard error, not a neutral skip.
61
71
  */
@@ -65,7 +75,7 @@ import * as path from 'node:path';
65
75
  import process from 'node:process';
66
76
  import { fileURLToPath } from 'node:url';
67
77
 
68
- import { assertScanned, DeadScopeError } from './_lib/scan_scope.js';
78
+ import { DeadScopeError, reportScanned } from './_lib/scan_scope.js';
69
79
 
70
80
  const _HERE = fileURLToPath(import.meta.url);
71
81
  const REPO_ROOT = path.resolve(path.dirname(_HERE), '..', '..');
@@ -91,6 +101,8 @@ export interface Ledger {
91
101
  schema_version: number;
92
102
  release: string;
93
103
  findings: LedgerFinding[];
104
+ /** Why the finding set is empty. Required when it is; see empty_ledger_problem. */
105
+ no_findings_reason?: string;
94
106
  }
95
107
 
96
108
  /** Mirrors self_review_gate.classifyBlocking — security/claim × critical/high. */
@@ -136,6 +148,36 @@ export function missing_dispositions(findings: readonly LedgerFinding[]): string
136
148
  return problems;
137
149
  }
138
150
 
151
+ /**
152
+ * An empty finding set must say why it is empty.
153
+ *
154
+ * `findings: []` and a missing file made the same claim to every reader — "nothing
155
+ * to disposition" — and only one of them is evidence. Worse, they collapse two
156
+ * states that differ entirely: a review that ran and reported nothing, and a review
157
+ * that never ran at all. The reason field is what forces the writer to pick one and
158
+ * makes the pick falsifiable, so a fabricated empty ledger has to assert something
159
+ * a reader can check rather than merely occupy the path the gate looks at.
160
+ *
161
+ * Applies ONLY to a ledger file that exists. An absent ledger on an unreleased
162
+ * version is the normal in-flight state and has nothing to carry a reason.
163
+ */
164
+ export function empty_ledger_problem(
165
+ ledger: Pick<Ledger, 'findings' | 'no_findings_reason'>,
166
+ ): string | null {
167
+ if (ledger.findings.length > 0) {
168
+ return null;
169
+ }
170
+ if ((ledger.no_findings_reason ?? '').trim() !== '') {
171
+ return null;
172
+ }
173
+ return (
174
+ 'the ledger exists with an empty `findings` array and no `no_findings_reason`. '
175
+ + 'An unexplained empty ledger is indistinguishable from an absent one — state '
176
+ + 'whether the review ran and reported nothing, or did not run, and how a reader '
177
+ + 'can check that.'
178
+ );
179
+ }
180
+
139
181
  export type ReleaseStatus = 'released' | 'unreleased' | 'undeterminable';
140
182
  export type RemoteReleaseAnswer = 'released' | 'unreleased' | 'unavailable';
141
183
 
@@ -410,6 +452,19 @@ function main(argv: readonly string[]): number {
410
452
  }
411
453
  }
412
454
 
455
+ // An existing ledger that records nothing must say why. Checked here rather
456
+ // than in the schema because the requirement is conditional on the finding
457
+ // count, which the repo's Draft-07 subset validator cannot express.
458
+ if (fs.existsSync(ledgerPath)) {
459
+ const emptyProblem = empty_ledger_problem(ledger);
460
+ if (emptyProblem !== null) {
461
+ process.stderr.write(
462
+ `❌ ${path.relative(REPO_ROOT, ledgerPath)}: ${emptyProblem}\n`,
463
+ );
464
+ return 1;
465
+ }
466
+ }
467
+
413
468
  // Scope declaration, not a scope guard. The ledger is written per release by
414
469
  // `--ingest`, so "no <release>.json" is the normal state of a release whose
415
470
  // self-review reported nothing — the gate's own success line says so. That
@@ -418,7 +473,7 @@ function main(argv: readonly string[]): number {
418
473
  // durable trigger for an un-ingested finding is `--pr` mode, which compares
419
474
  // the ledger against what the self-review actually reported.
420
475
  try {
421
- assertScanned({
476
+ reportScanned({
422
477
  gate: 'check_finding_dispositions',
423
478
  scanned: ledger.findings.length,
424
479
  units: 'recorded finding(s)',
@@ -426,11 +481,13 @@ function main(argv: readonly string[]): number {
426
481
  allowEmpty:
427
482
  'EMPTY_VALID: zero recorded findings IS the pass state for a release whose '
428
483
  + 'self-review reported none — the ledger file is created on first --ingest, so a '
429
- + 'clean release legitimately has no <version>.json. NARROWED: this no longer '
484
+ + 'clean release legitimately has no <version>.json. NARROWED TWICE: it no longer '
430
485
  + 'covers a RELEASED version with no ledger, which returns 1 above on the '
431
- + 'released/unreleased split; the waiver now covers an empty-but-present ledger '
432
- + 'and an unreleased version. Un-ingested findings on an in-flight release are '
433
- + 'caught by --pr (unrecorded_findings), never by this count.',
486
+ + 'released/unreleased split, and it no longer covers an empty ledger that does '
487
+ + 'not say why it is empty, which returns 1 above via empty_ledger_problem. What '
488
+ + 'remains is an EXPLAINED empty ledger and an unreleased version. Un-ingested '
489
+ + 'findings on an in-flight release are caught by --pr (unrecorded_findings), '
490
+ + 'never by this count.',
434
491
  });
435
492
  } catch (exc) {
436
493
  if (exc instanceof DeadScopeError) {