@event4u/agent-config 9.33.0 → 9.35.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 (105) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/CHANGELOG.md +154 -145
  3. package/CONTRIBUTING.md +23 -14
  4. package/dist/agent-src/commands/agent-handoff.md +15 -3
  5. package/dist/agent-src/commands/roadmap/next.md +37 -5
  6. package/dist/agent-src/scripts/archive_completed_roadmaps.ts +35 -1
  7. package/dist/agent-src/scripts/roadmap_gates.ts +119 -4
  8. package/dist/agent-src/skills/corpus-grounding/SKILL.md +21 -12
  9. package/dist/agent-src/skills/design-tokens/SKILL.md +15 -0
  10. package/dist/agent-src/skills/react-shadcn-ui/SKILL.md +24 -0
  11. package/dist/agent-src/skills/rule-writing/SKILL.md +23 -0
  12. package/dist/agent-src/skills/skill-writing/SKILL.md +80 -2
  13. package/dist/agent-src/skills/systematic-debugging/SKILL.md +5 -3
  14. package/dist/agent-src/skills/tailwind-engineer/SKILL.md +24 -0
  15. package/dist/agent-src/skills/testing-anti-patterns/SKILL.md +25 -0
  16. package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
  17. package/dist/agent-src/templates/scripts/memory_lookup.ts +1 -2
  18. package/dist/agent-src/templates/scripts/memory_signal.ts +2 -4
  19. package/dist/agent-src/templates/scripts/work_engine/hooks/builtin/decision_gate.ts +35 -5
  20. package/dist/agent-src/templates/scripts/work_engine/hooks/builtin/staged_confirmation.ts +252 -0
  21. package/dist/agent-src/templates/scripts/work_engine/hooks/builtin/staged_confirmation_store.ts +307 -0
  22. package/dist/cli/registry.js +2 -2
  23. package/dist/cli/registry.js.map +1 -1
  24. package/dist/cli-delegate/{chunk-PMQ6Z3T3.js → chunk-2TGMGLBL.js} +11 -4
  25. package/dist/cli-delegate/{chunk-YPAWXXXR.js → chunk-55RI5UXK.js} +17 -0
  26. package/dist/cli-delegate/{chunk-MA4GFWDY.js → chunk-H6F2CQFI.js} +1 -1
  27. package/dist/cli-delegate/{chunk-2WELHO3P.js → chunk-I5EAFRII.js} +35 -0
  28. package/dist/cli-delegate/{chunk-3EWYC3YK.js → chunk-UEZ7D35A.js} +4 -3
  29. package/dist/cli-delegate/{chunk-UVKNIJH2.js → chunk-ZAXH3ZWU.js} +3 -0
  30. package/dist/cli-delegate/cmd_conformance.js +2 -2
  31. package/dist/cli-delegate/cmd_doctor.js +2 -2
  32. package/dist/cli-delegate/cmd_export.js +2 -2
  33. package/dist/cli-delegate/cmd_handoff.js +3 -3
  34. package/dist/cli-delegate/cmd_memory_get.js +1 -1
  35. package/dist/cli-delegate/cmd_prune.js +2 -2
  36. package/dist/cli-delegate/cmd_route_audit.js +1 -1
  37. package/dist/cli-delegate/cmd_session_recycle.js +7 -2
  38. package/dist/cli-delegate/cmd_settings_migrate.js +2 -2
  39. package/dist/cli-delegate/cmd_settings_set.js +1 -1
  40. package/dist/cli-delegate/cmd_sync.js +2 -2
  41. package/dist/cli-delegate/cmd_uninstall.js +2 -2
  42. package/dist/cli-delegate/cmd_validate.js +2 -2
  43. package/dist/discovery/deprecation-report.md +1 -1
  44. package/dist/discovery/discovery-manifest.json +13 -13
  45. package/dist/discovery/discovery-manifest.json.sha256 +1 -1
  46. package/dist/discovery/discovery-manifest.summary.md +2 -2
  47. package/dist/discovery/dormancy-report.md +17 -0
  48. package/dist/discovery/orphan-report.md +1 -1
  49. package/dist/discovery/packs.json +3 -3
  50. package/dist/discovery/trust-report.md +1 -1
  51. package/dist/discovery/workspaces.json +3 -3
  52. package/dist/hooks/dispatch.js +355 -85
  53. package/dist/install/install.mjs +3 -0
  54. package/dist/mcp/registry-manifest.json +3 -3
  55. package/dist/mcp/server.json +2 -2
  56. package/dist/mcp/server.mjs +192 -12
  57. package/dist/server/schemas/settings.js +1 -0
  58. package/dist/server/schemas/settings.js.map +1 -1
  59. package/docs/contracts/settings-classes.md +3 -2
  60. package/docs/guidelines/agent-infra/failure-signatures.md +47 -12
  61. package/package.json +2 -2
  62. package/src/agent-src/scripts/archive_completed_roadmaps.ts +35 -1
  63. package/src/agent-src/scripts/roadmap_gates.ts +119 -4
  64. package/src/agent-src/templates/scripts/memory_lookup.ts +1 -2
  65. package/src/agent-src/templates/scripts/memory_signal.ts +2 -4
  66. package/src/agent-src/templates/scripts/work_engine/hooks/builtin/decision_gate.ts +35 -5
  67. package/src/agent-src/templates/scripts/work_engine/hooks/builtin/staged_confirmation.ts +252 -0
  68. package/src/agent-src/templates/scripts/work_engine/hooks/builtin/staged_confirmation_store.ts +307 -0
  69. package/src/config/agent-settings.template.yml +11 -2
  70. package/src/config/quorum-attendance-budget.json +11 -3
  71. package/src/scripts/_cli/cmd_session_recycle.ts +6 -1
  72. package/src/scripts/_dispatch.bash +6 -2
  73. package/src/scripts/_lib/env_kill_switch.ts +37 -0
  74. package/src/scripts/_lib/security_lint.ts +18 -4
  75. package/src/scripts/_lib/self_repair.ts +60 -0
  76. package/src/scripts/_lib/self_repair_store.ts +65 -5
  77. package/src/scripts/_lib/session_register.ts +67 -1
  78. package/src/scripts/_lib/subagent_capsule.ts +78 -1
  79. package/src/scripts/ai_council/config.ts +60 -0
  80. package/src/scripts/ai_council/events_log.ts +105 -6
  81. package/src/scripts/ai_council/orchestrator.ts +38 -0
  82. package/src/scripts/ai_council/prompts.ts +165 -3
  83. package/src/scripts/ai_council/quorum.ts +87 -0
  84. package/src/scripts/ai_team/review_gate.ts +2 -2
  85. package/src/scripts/build_discovery_manifest.ts +165 -0
  86. package/src/scripts/check_claims.ts +51 -2
  87. package/src/scripts/ci_time_ratio.ts +3 -1
  88. package/src/scripts/council_cli.ts +36 -1
  89. package/src/scripts/discovery_graph.ts +201 -27
  90. package/src/scripts/dispatch_r2_reviewer.ts +102 -1
  91. package/src/scripts/hooks/turn_end_gate_hook.ts +172 -11
  92. package/src/scripts/lint_framework_leakage.ts +195 -28
  93. package/src/scripts/lint_framework_leakage_allowlist.json +94 -86
  94. package/src/scripts/lint_handoffs.ts +92 -2
  95. package/src/scripts/lint_hedge_words.ts +317 -0
  96. package/src/scripts/lint_mcp_config_security.ts +1 -1
  97. package/src/scripts/lint_skill_frontmatter_safety.ts +1 -1
  98. package/src/scripts/memory_lookup.ts +1 -2
  99. package/src/scripts/schemas/command.schema.json +4 -0
  100. package/src/scripts/schemas/skill.schema.json +4 -0
  101. package/src/scripts/self_repair_cli.ts +26 -1
  102. package/src/scripts/session_register_hook.ts +238 -18
  103. package/src/scripts/sessions_cli.ts +377 -12
  104. package/src/scripts/skill_linter.ts +58 -2
  105. package/src/server/schemas/settings.ts +3 -0
@@ -9,11 +9,17 @@
9
9
  * is deliberately not another reminder. It is a check at the point of
10
10
  * delivery that can say no.
11
11
  *
12
- * Two detectors ride on one guard, because building the unsafe part twice
12
+ * Three detectors ride on one guard, because building the unsafe part twice
13
13
  * is how a second detector becomes a second outage:
14
14
  *
15
15
  * A — promissory closing (FC-5, 20 measured occurrences)
16
16
  * B — language mismatch (19 measured occurrences, fresh pin present)
17
+ * C — unverified edit (the turn changed a file and ran nothing that could
18
+ * have checked it — verify-before-complete's gate,
19
+ * read off the turn's TOOL CALLS rather than its
20
+ * prose, which is why `readTranscriptTail` collects
21
+ * them: `_messageText` keeps `type === 'text'` blocks
22
+ * only, so tool activity is invisible to A and B.)
17
23
  *
18
24
  * ## Re-entrancy — the shape, stated before registration
19
25
  *
@@ -60,10 +66,11 @@
60
66
  * mechanism exists and soaks before it binds" — two prior hook-severity
61
67
  * mistakes plus a turn-end blast radius made default-on uninsurable in
62
68
  * their reading. Both are satisfied literally: the MASTER switch
63
- * (`hooks.turn_end_gate.enabled`) is default OFF, and WITHIN it both
64
- * detectors are default ON. A maintainer who opts in gets both without
69
+ * (`hooks.turn_end_gate.enabled`) is default OFF, and WITHIN it every
70
+ * detector is default ON. A maintainer who opts in gets all of them without
65
71
  * further configuration; until then this concern is a no-op that costs one
66
- * settings-cascade read per turn-end.
72
+ * settings-cascade read per turn-end. Detector C landed under the same rule —
73
+ * the master switch was NOT flipped when it shipped.
67
74
  *
68
75
  * CONTRACT: dispatcher-internal exit is 1 (EXIT_BLOCK) on a fire, 0
69
76
  * otherwise. `fail_closed: false` — deliberately. A crash in a turn-end
@@ -71,6 +78,16 @@
71
78
  * crash to a block is the outage this whole file is arranged to avoid.
72
79
  * Every unreadable input, missing transcript, absent pin, or malformed
73
80
  * state resolves to silence.
81
+ *
82
+ * The severity mapping was re-probed when C landed, because
83
+ * `dispatch_hook.ts` defines `EXIT_WARN = 2` beside `EXIT_BLOCK = 1` and an
84
+ * advisory finding delivered on the wrong code becomes a hard deny. Result:
85
+ * this file emits ONLY 0 and 1 internally, `concern_block_exit_parity` pins the
86
+ * 1, and the dispatcher translates a stop-slot block to host exit **2** —
87
+ * which a spawned test in `turn_end_gate_hook.test.ts` asserts, because on the
88
+ * stop slot exit 1 would let the turn end anyway. C is a refusal like A and B,
89
+ * not an advisory, so there is no advisory path here to put on the wrong code.
90
+ * Probed, not assumed.
74
91
  */
75
92
  import * as crypto from 'node:crypto';
76
93
  import * as fs from 'node:fs';
@@ -104,7 +121,7 @@ const EXIT_ALLOW = 0;
104
121
  export const TRANSCRIPT_READ_MAX_BYTES = 8 * 1024 * 1024; // 8 MB
105
122
 
106
123
  /** Detector identity, used in the state marker and the refusal text. */
107
- export type DetectorId = 'promissory' | 'language';
124
+ export type DetectorId = 'promissory' | 'language' | 'verification';
108
125
 
109
126
  export interface Finding {
110
127
  detector: DetectorId;
@@ -360,6 +377,71 @@ export function detectLanguage(reply: string, pinned: Verdict): Finding | null {
360
377
  };
361
378
  }
362
379
 
380
+ // ---------------------------------------------------------------------------
381
+ // Detector 3 — an edit the turn never verified
382
+ // ---------------------------------------------------------------------------
383
+
384
+ /** Tools that CHANGE the tree. The three write tools, nothing inferred. */
385
+ const _EDIT_TOOLS = new Set(['Edit', 'Write', 'MultiEdit', 'NotebookEdit']);
386
+
387
+ /**
388
+ * Shell commands that count as verification.
389
+ *
390
+ * A positive list, and deliberately narrow. The alternative — treating any
391
+ * `Bash` call as verification — was rejected because `ls` would then clear an
392
+ * unverified edit, which is the failure mode `verify-before-complete` names
393
+ * ("relying on partial verification"). The cost of narrowness is a MISSED
394
+ * detection when a project verifies by some command not listed here, and that is
395
+ * the safe direction for a gate that can refuse a turn: a false negative costs
396
+ * one unguarded turn, a false positive teaches the user to switch the gate off.
397
+ */
398
+ const _VERIFY_RE =
399
+ /\b(test|tests|vitest|jest|pytest|phpunit|pest|tsc|eslint|ruff|mypy|pyright|clippy|typecheck|lint|build|ci|preflight|smoke[-_:a-z]*|check[-_:a-z]*|validate[-_:a-z]*)\b|\b(task|npm|pnpm|yarn|composer|cargo|go|make|php artisan|bun)\s+(run\s+)?\S*(test|check|lint|build|ci|typecheck)/i;
400
+
401
+ export function isVerificationCommand(command: string): boolean {
402
+ return _VERIFY_RE.test(command);
403
+ }
404
+
405
+ /**
406
+ * Fire when the turn changed a file and then ran nothing that could have
407
+ * checked it.
408
+ *
409
+ * The window is "after the LAST edit", not "anywhere in the turn": a test run
410
+ * before the final edit demonstrably did not exercise it, which is the same
411
+ * freshness argument `verify-before-complete` makes about trusting an earlier
412
+ * run ("no verification command run in this message → you cannot claim it
413
+ * passes"). Ordering is the only thing that distinguishes the two, so the
414
+ * detector reads the sequence rather than a pair of counts.
415
+ *
416
+ * Silent when the turn edited nothing — a read-only or conversational turn has
417
+ * no claim to verify, and refusing one would make the gate fire on the majority
418
+ * of turns, which is how a guard gets disabled.
419
+ */
420
+ export function detectUnverifiedEdit(toolCalls: readonly ToolCall[]): Finding | null {
421
+ let lastEdit = -1;
422
+ for (let i = 0; i < toolCalls.length; i += 1) {
423
+ if (_EDIT_TOOLS.has(toolCalls[i]!.name)) lastEdit = i;
424
+ }
425
+ if (lastEdit === -1) return null;
426
+ for (let i = lastEdit + 1; i < toolCalls.length; i += 1) {
427
+ const c = toolCalls[i]!;
428
+ if (c.name === 'Bash' && c.command !== undefined && isVerificationCommand(c.command)) {
429
+ return null;
430
+ }
431
+ }
432
+ const edited = toolCalls[lastEdit]!.path;
433
+ return {
434
+ detector: 'verification',
435
+ // The path, never the diff: the evidence span is quoted into a refusal,
436
+ // and `ToolCall` is shaped so a file body cannot reach it.
437
+ evidence: edited ?? toolCalls[lastEdit]!.name,
438
+ reason:
439
+ 'this turn changed a file and then ran no verification command — ' +
440
+ 'no test, type-check, lint or build call follows the last edit ' +
441
+ '(verify-before-complete: a claim without a fresh run is unverified)',
442
+ };
443
+ }
444
+
363
445
  // ---------------------------------------------------------------------------
364
446
  // Re-entrancy — the guard, keyed on the turn, not on the reply
365
447
  // ---------------------------------------------------------------------------
@@ -450,6 +532,24 @@ function markRefusedTurn(
450
532
  // Transcript
451
533
  // ---------------------------------------------------------------------------
452
534
 
535
+ /**
536
+ * One tool call the assistant made, reduced to what a detector can reason
537
+ * about: the tool's name, the shell command when it is a shell call, and the
538
+ * target path when it is a file write.
539
+ *
540
+ * Nothing else is kept. A tool input can hold a whole file body, and this
541
+ * struct is what a refusal quotes back — so it is shaped to be INCAPABLE of
542
+ * carrying one, the same PII-exclusion-by-construction discipline
543
+ * `domain-safety-pii` § Surface 2 asks for in a log line.
544
+ */
545
+ export interface ToolCall {
546
+ name: string;
547
+ /** `Bash` only — the command line, so "did anything verify" is answerable. */
548
+ command?: string;
549
+ /** Edit/Write only — the file the turn changed, used as the evidence span. */
550
+ path?: string;
551
+ }
552
+
453
553
  export interface TranscriptTail {
454
554
  lastAssistant: string;
455
555
  /**
@@ -458,6 +558,14 @@ export interface TranscriptTail {
458
558
  * identity; see `alreadyRefusedTurn` for why the prompt's text is not.
459
559
  */
460
560
  turnOrdinal: number;
561
+ /**
562
+ * The tool calls of the CURRENT turn, in order — reset at every genuine user
563
+ * prompt, so a verification from three turns ago cannot vouch for an edit
564
+ * made now. `_messageText` keeps only `type === 'text'` blocks, which is why
565
+ * this needed its own extraction rather than a reading of `lastAssistant`:
566
+ * tool activity is not in the prose at all.
567
+ */
568
+ toolCalls: ToolCall[];
461
569
  }
462
570
 
463
571
  /**
@@ -475,7 +583,7 @@ export function readTranscriptTail(
475
583
  transcriptPath: string,
476
584
  opts: { homeDir?: string; maxBytes?: number } = {},
477
585
  ): TranscriptTail {
478
- const empty: TranscriptTail = { lastAssistant: '', turnOrdinal: 0 };
586
+ const empty: TranscriptTail = { lastAssistant: '', turnOrdinal: 0, toolCalls: [] };
479
587
  if (!transcriptPath || !isSafeTranscriptPath(transcriptPath, opts)) return empty;
480
588
  let lines: string[];
481
589
  try {
@@ -494,6 +602,7 @@ export function readTranscriptTail(
494
602
  }
495
603
  let lastAssistant = '';
496
604
  let turnOrdinal = 0;
605
+ let toolCalls: ToolCall[] = [];
497
606
  for (const rawLine of lines) {
498
607
  const line = rawLine.trim();
499
608
  if (!line) continue;
@@ -514,15 +623,55 @@ export function readTranscriptTail(
514
623
  if (role !== 'assistant' && role !== 'user') continue;
515
624
  const msg = entry['message'];
516
625
  if (typeof msg !== 'object' || msg === null || Array.isArray(msg)) continue;
517
- const text = _messageText((msg as Record<string, unknown>)['content']);
626
+ const content = (msg as Record<string, unknown>)['content'];
627
+ // Tool calls are read BEFORE the text guard: an assistant entry that is
628
+ // only a tool_use block has no text at all, so `continue`-ing on a null
629
+ // text would drop exactly the entries this detector exists to see.
630
+ if (role === 'assistant') {
631
+ toolCalls.push(..._toolCalls(content));
632
+ }
633
+ const text = _messageText(content);
518
634
  if (text === null) continue;
519
635
  if (role === 'assistant') {
520
636
  lastAssistant = text;
521
637
  } else if (!isSyntheticPrompt(text)) {
522
638
  turnOrdinal += 1;
639
+ // A genuine user prompt starts a new turn, so the previous turn's
640
+ // tool activity stops counting. Without this reset, a verification
641
+ // run three turns ago would vouch for an edit made now — the
642
+ // "fresh" in edit-without-FRESH-verification is this line.
643
+ toolCalls = [];
644
+ }
645
+ }
646
+ return { lastAssistant: lastAssistant.trim(), turnOrdinal, toolCalls };
647
+ }
648
+
649
+ /**
650
+ * Extract this entry's tool calls, keeping only name, shell command and target
651
+ * path. A tool input can hold a whole file body; nothing but those three fields
652
+ * is carried forward.
653
+ */
654
+ function _toolCalls(content: unknown): ToolCall[] {
655
+ if (!Array.isArray(content)) return [];
656
+ const out: ToolCall[] = [];
657
+ for (const blk of content) {
658
+ if (typeof blk !== 'object' || blk === null || Array.isArray(blk)) continue;
659
+ const b = blk as Record<string, unknown>;
660
+ if (b['type'] !== 'tool_use') continue;
661
+ const name = b['name'];
662
+ if (typeof name !== 'string') continue;
663
+ const input = b['input'];
664
+ const call: ToolCall = { name };
665
+ if (typeof input === 'object' && input !== null && !Array.isArray(input)) {
666
+ const inp = input as Record<string, unknown>;
667
+ const cmd = inp['command'];
668
+ if (typeof cmd === 'string') call.command = cmd;
669
+ const p = inp['file_path'] ?? inp['path'] ?? inp['notebook_path'];
670
+ if (typeof p === 'string') call.path = p;
523
671
  }
672
+ out.push(call);
524
673
  }
525
- return { lastAssistant: lastAssistant.trim(), turnOrdinal };
674
+ return out;
526
675
  }
527
676
 
528
677
  function _messageText(content: unknown): string | null {
@@ -547,6 +696,7 @@ export interface GateSettings {
547
696
  enabled: boolean;
548
697
  promissory: boolean;
549
698
  language: boolean;
699
+ verification: boolean;
550
700
  }
551
701
 
552
702
  /**
@@ -581,7 +731,12 @@ function _isOn(v: unknown): boolean | null {
581
731
  * a turn-end outranks one settings read per turn-end.
582
732
  */
583
733
  export function readGateSettings(workspaceRoot: string): GateSettings {
584
- const off: GateSettings = { enabled: false, promissory: true, language: true };
734
+ const off: GateSettings = {
735
+ enabled: false,
736
+ promissory: true,
737
+ language: true,
738
+ verification: true,
739
+ };
585
740
  let settings: Record<string, unknown>;
586
741
  try {
587
742
  settings = load_agent_settings({ cwd: workspaceRoot }) as Record<string, unknown>;
@@ -601,6 +756,7 @@ export function readGateSettings(workspaceRoot: string): GateSettings {
601
756
  enabled: flag('enabled', false),
602
757
  promissory: flag('promissory', true),
603
758
  language: flag('language', true),
759
+ verification: flag('verification', true),
604
760
  };
605
761
  }
606
762
 
@@ -644,7 +800,7 @@ export function main(): number {
644
800
  // in production while its comment claimed it was enforced — the same
645
801
  // fixed-the-definition-not-the-caller shape as two other findings in that
646
802
  // round, which is why this line exists rather than a default parameter.
647
- const { lastAssistant, turnOrdinal } = readTranscriptTail(transcriptPath, {
803
+ const { lastAssistant, turnOrdinal, toolCalls } = readTranscriptTail(transcriptPath, {
648
804
  maxBytes: TRANSCRIPT_READ_MAX_BYTES,
649
805
  });
650
806
  if (!lastAssistant) return EXIT_ALLOW;
@@ -671,6 +827,10 @@ export function main(): number {
671
827
  const f = detectLanguage(lastAssistant, readLanguagePin(workspaceRoot));
672
828
  if (f) findings.push(f);
673
829
  }
830
+ if (settings.verification) {
831
+ const f = detectUnverifiedEdit(toolCalls);
832
+ if (f) findings.push(f);
833
+ }
674
834
  if (findings.length === 0) return EXIT_ALLOW;
675
835
 
676
836
  markRefusedTurn(workspaceRoot, sessionKey, turnOrdinal, findings[0]!.detector);
@@ -680,7 +840,8 @@ export function main(): number {
680
840
  );
681
841
  process.stderr.write(
682
842
  `turn-end-gate: REFUSED — this turn is not finished.\n${lines.join('\n')}\n` +
683
- ' Do the promised work now, or correct the language, then end the turn.\n' +
843
+ ' Do the promised work now, correct the language, or run the ' +
844
+ 'verification the edit needs, then end the turn.\n' +
684
845
  ' This turn will not be refused a second time.\n' +
685
846
  ' Disable: hooks.turn_end_gate.enabled: false in .agent-settings.yml\n',
686
847
  );
@@ -299,14 +299,21 @@ interface AllowlistEntry {
299
299
  file?: string;
300
300
  lines?: number[] | '*';
301
301
  /**
302
- * Content anchor — a substring of the exempted line.
302
+ * Content anchor — a substring of the exempted line. The only per-line key.
303
303
  *
304
- * Position-keyed entries (`lines: [100]`) are drift-fragile by
305
- * construction: inserting a paragraph above line 100 silently re-fires the
306
- * ratchet on an entry nobody touched, which this repository has recorded as
307
- * a real cost. An anchor keys the exemption to what is actually being
308
- * exempted, so the entry survives the edit that moves it. Prefer it for new
309
- * entries; `lines` stays supported for the existing ones.
304
+ * Position keying (`lines: [100]`) is retired, not merely discouraged.
305
+ * Inserting a paragraph above line 100 re-fires the ratchet on an entry
306
+ * nobody touched — recorded twice in one pull request — and, worse, it rots
307
+ * in silence: the migration that introduced this comment found three shipped
308
+ * entries that had stopped exempting anything, one naming line 100 of a
309
+ * 68-line file and two naming blank lines. Nothing reported it, because a
310
+ * drifted position key still parses.
311
+ *
312
+ * An anchor keys the exemption to the content being exempted, so it survives
313
+ * the edit that moves it and fails loudly when that content is gone.
314
+ * `validate_allowlist` additionally requires it to match exactly one line:
315
+ * `includes` is file-scoped, so a too-short anchor would silently exempt a
316
+ * second line nobody reviewed.
310
317
  */
311
318
  anchor?: string;
312
319
  reason?: string;
@@ -328,35 +335,124 @@ function _load_allowlist(): Allowlist {
328
335
  }
329
336
  }
330
337
 
331
- function _allowlisted(
338
+ /**
339
+ * Every allowlist entry must still exempt something, and exactly one thing.
340
+ *
341
+ * A position-keyed entry rots in silence: the line it names drifts, the entry
342
+ * keeps parsing, and nothing reports that an exemption stopped exempting. This
343
+ * repository shipped three such entries — one naming line 100 of a 68-line file,
344
+ * two naming blank lines — and the rot was invisible until the whole set was read
345
+ * at once. An anchor cannot drift, but it can over-reach: `includes` is
346
+ * file-scoped, so an anchor occurring twice silently exempts a second line nobody
347
+ * reviewed.
348
+ *
349
+ * Both failures are decidable from the file, so they are checked rather than
350
+ * trusted. `lines: '*'` stays legal — a deliberate whole-file exemption is a
351
+ * different thing from a position key, and it cannot drift.
352
+ */
353
+ /**
354
+ * The exact text `_allowlisted` compares an anchor against.
355
+ *
356
+ * The matcher sees `Hit.snippet`, not the raw line — trimmed and cut at 160
357
+ * chars. A validator reading the raw line would clear an anchor copied with its
358
+ * indentation, or drawn from past column 160, and the gate would then fail to
359
+ * suppress a hit the maintainer was told is exempt. One function, both callers.
360
+ */
361
+ export function anchor_haystack(line: string): string {
362
+ return line.trim().slice(0, 160);
363
+ }
364
+
365
+ export function validate_allowlist(allowlist: Allowlist, repo_root?: string): string[] {
366
+ // Default to the MUTABLE root, not the real one: the scan resolves entry
367
+ // paths against `REPO_ROOT`, and a validator resolving them anywhere else
368
+ // would report every entry missing the moment a caller redirects the tree.
369
+ const root = repo_root ?? REPO_ROOT;
370
+ const problems: string[] = [];
371
+ for (const entry of allowlist.entries ?? []) {
372
+ const file = entry.file;
373
+ if (typeof file !== 'string' || file === '') {
374
+ problems.push('entry without a `file`');
375
+ continue;
376
+ }
377
+ if (Array.isArray(entry.lines)) {
378
+ problems.push(
379
+ `${file}: position-keyed (\`lines: [${entry.lines.join(', ')}]\`) — ` +
380
+ 'use `anchor` with a substring of the exempted line; a line number ' +
381
+ 'drifts out from under its own exemption on any edit above it',
382
+ );
383
+ continue;
384
+ }
385
+ if (entry.lines === '*') {
386
+ // A whole-file exemption never consults the anchor, so validating one
387
+ // here would stop every scan for a field the matcher does not read.
388
+ continue;
389
+ }
390
+ const anchor = entry.anchor;
391
+ if (typeof anchor !== 'string' || anchor === '') {
392
+ problems.push(`${file}: neither an \`anchor\` nor \`lines: "*"\``);
393
+ continue;
394
+ }
395
+ const abs = path.join(root, file);
396
+ if (!_isFile(abs)) {
397
+ problems.push(`${file}: anchored entry names a file that does not exist`);
398
+ continue;
399
+ }
400
+ const matches = fs
401
+ .readFileSync(abs, 'utf-8')
402
+ .split('\n')
403
+ .filter((l) => anchor_haystack(l).includes(anchor)).length;
404
+ if (matches === 0) {
405
+ problems.push(
406
+ `${file}: anchor matches no line — the exempted content is gone, so the ` +
407
+ `entry exempts nothing: ${JSON.stringify(anchor.slice(0, 50))}`,
408
+ );
409
+ } else if (matches > 1) {
410
+ problems.push(
411
+ `${file}: anchor matches ${matches} lines — it would exempt more than the ` +
412
+ `reviewed one; lengthen it: ${JSON.stringify(anchor.slice(0, 50))}`,
413
+ );
414
+ }
415
+ }
416
+ return problems;
417
+ }
418
+
419
+ /**
420
+ * Index of the entry that exempts this hit, or `-1`.
421
+ *
422
+ * Returns the index rather than a boolean so the run can report which
423
+ * exemptions were never used. An entry that suppresses nothing is the failure
424
+ * this gate shipped: three had rotted under position keys, and re-keying them to
425
+ * anchors preserved the rot, because an anchor resolving to a line says nothing
426
+ * about that line producing a hit.
427
+ */
428
+ function _allowlist_index(
332
429
  rel_path: string,
333
430
  line_no: number,
334
431
  allowlist: Allowlist,
335
432
  line_text = '',
336
- ): boolean {
433
+ ): number {
337
434
  const logical = strip_source_prefix(rel_path);
338
- for (const entry of allowlist.entries ?? []) {
435
+ const entries = allowlist.entries ?? [];
436
+ for (let i = 0; i < entries.length; i += 1) {
437
+ const entry = entries[i] as AllowlistEntry;
339
438
  const entry_file = entry.file;
340
439
  const entry_logical =
341
440
  typeof entry_file === 'string' ? strip_source_prefix(entry_file) : null;
342
441
  if (entry_file !== rel_path && (logical === null || entry_logical !== logical)) {
343
442
  continue;
344
443
  }
345
- const lines = entry.lines;
346
- if (lines === '*') {
347
- return true;
444
+ if (entry.lines === '*') {
445
+ return i;
348
446
  }
349
- // Content anchor before line number: an anchored entry keeps matching
350
- // after an insertion moves the line, which a position key cannot.
447
+ // Content anchor, and nothing else. A numeric `lines` entry never reaches
448
+ // this point — `validate_allowlist` stops the run on one — so matching it
449
+ // here would be a branch that only fires for input the gate rejects.
351
450
  const anchor = entry.anchor;
352
451
  if (typeof anchor === 'string' && anchor !== '' && line_text.includes(anchor)) {
353
- return true;
354
- }
355
- if (Array.isArray(lines) && lines.includes(line_no)) {
356
- return true;
452
+ return i;
357
453
  }
358
454
  }
359
- return false;
455
+ return -1;
360
456
  }
361
457
 
362
458
  function _families_in_window(lines: string[], idx: number, radius = 10): Set<string> {
@@ -502,7 +598,12 @@ function* iter_md_files(paths: Iterable<string>): Generator<string> {
502
598
  }
503
599
  }
504
600
 
505
- function parse_args(argv: readonly string[]): { json: boolean; quiet: boolean; paths: string[] } {
601
+ function parse_args(argv: readonly string[]): {
602
+ json: boolean;
603
+ quiet: boolean;
604
+ narrowed: boolean;
605
+ paths: string[];
606
+ } {
506
607
  let json = false;
507
608
  let quiet = false;
508
609
  const paths: string[] = [];
@@ -535,16 +636,26 @@ function parse_args(argv: readonly string[]): { json: boolean; quiet: boolean; p
535
636
  process.exit(2);
536
637
  }
537
638
  }
538
- return { json, quiet, paths: paths.length > 0 ? paths : _default_paths() };
639
+ // `narrowed` distinguishes an explicit `--paths` from the default full set.
640
+ // `paths.length === 0` cannot: the default is three subdirectories, so a full
641
+ // run also arrives here with a non-empty list — which silently disabled the
642
+ // unused-exemption check the first time it was written against that test.
643
+ return {
644
+ json,
645
+ quiet,
646
+ narrowed: paths.length > 0,
647
+ paths: paths.length > 0 ? paths : _default_paths(),
648
+ };
539
649
  }
540
650
 
541
651
  /**
542
652
  * The allowlist entry that would silence one finding, ready to paste.
543
653
  *
544
- * Anchored on the matched token rather than on the line number: a
545
- * position-keyed entry re-fires the moment an insertion above moves the line,
546
- * on an entry nobody touched, which `check_suppression_hygiene` counts every
547
- * run until the existing 18 migrate.
654
+ * Anchored on the matched token rather than on the line number: a position key
655
+ * re-fires the moment an insertion above moves the line, on an entry nobody
656
+ * touched. The form is no longer merely discouraged — `validate_allowlist`
657
+ * refuses it, and the last 18 position-keyed entries were migrated to anchors
658
+ * in the change that introduced the refusal.
548
659
  */
549
660
  export function suppressionKey(rel: string, hit: Hit): string {
550
661
  const anchor = hit.snippet.trim().slice(0, 60);
@@ -613,7 +724,19 @@ export function main(argv?: readonly string[]): number {
613
724
  const args = parse_args(raw);
614
725
 
615
726
  const allowlist = _load_allowlist();
727
+ // Judge the exemptions before judging any file. A rotted entry produces a
728
+ // green run that means nothing, which is the one failure mode a gate must
729
+ // not have. Structural problems are collected here and reported at the end,
730
+ // NOT returned on early: an early return skipped the `scanned:` line the
731
+ // gate-coverage collector consumes and left `--json` emitting nothing, so a
732
+ // consumer parsing the envelope threw on exactly the runs where the gate
733
+ // reports itself unusable.
734
+ const allowlist_problems = validate_allowlist(allowlist);
616
735
  const file_hits: Array<[string, Hit[]]> = [];
736
+ // Which exemptions actually suppressed something. Only meaningful over a full
737
+ // scan: with `--paths` narrowing the tree, an entry for a file outside the
738
+ // scope is unused for a reason that says nothing about the entry.
739
+ const used_entries = new Set<number>();
617
740
  let total_hits = 0;
618
741
  let allowlisted_total = 0;
619
742
  // Every enumerated file is planned up front, so the five exemption branches
@@ -661,7 +784,9 @@ export function main(argv?: readonly string[]): number {
661
784
  if (h.cross_stack) {
662
785
  continue;
663
786
  }
664
- if (_allowlisted(rel, h.line, allowlist, h.snippet)) {
787
+ const idx = _allowlist_index(rel, h.line, allowlist, h.snippet);
788
+ if (idx >= 0) {
789
+ used_entries.add(idx);
665
790
  h.allowlisted = true;
666
791
  allowlisted_total += 1;
667
792
  continue;
@@ -679,10 +804,31 @@ export function main(argv?: readonly string[]): number {
679
804
  }
680
805
  }
681
806
 
807
+ // An exemption that suppressed nothing over a FULL scan exempts nothing at
808
+ // all — the rot the anchor migration was supposed to end, and which anchoring
809
+ // alone does not end: an anchor resolving to a line says nothing about that
810
+ // line producing a hit. Only checked on a full scan, because `--paths` makes
811
+ // "unused" mean "out of scope" instead.
812
+ if (!args.narrowed) {
813
+ const entries = allowlist.entries ?? [];
814
+ for (let i = 0; i < entries.length; i += 1) {
815
+ if (used_entries.has(i)) {
816
+ continue;
817
+ }
818
+ const e = entries[i] as AllowlistEntry;
819
+ allowlist_problems.push(
820
+ `${String(e.file)}: exemption suppressed nothing this run — the line it names ` +
821
+ 'produces no leakage hit, so it exempts nothing: ' +
822
+ JSON.stringify(String(e.anchor ?? e.lines).slice(0, 50)),
823
+ );
824
+ }
825
+ }
826
+
682
827
  const summary = {
683
828
  total_hits,
684
829
  files: file_hits.length,
685
830
  allowlisted: allowlisted_total,
831
+ allowlist_problems: allowlist_problems.length,
686
832
  };
687
833
 
688
834
  // Finalize before either output branch: an unaccounted target must throw
@@ -696,10 +842,11 @@ export function main(argv?: readonly string[]): number {
696
842
  hits.map((h) => ({ file: relToRepo(p), ...h })),
697
843
  ),
698
844
  summary,
845
+ allowlist_problems,
699
846
  ledger: tally,
700
847
  };
701
848
  process.stdout.write(`${JSON.stringify(out, null, 2)}\n`);
702
- return total_hits ? 1 : 0;
849
+ return total_hits || allowlist_problems.length ? 1 : 0;
703
850
  }
704
851
 
705
852
  if (!args.quiet) {
@@ -731,6 +878,26 @@ export function main(argv?: readonly string[]): number {
731
878
  // judged) and is the same "reading M as coverage" trap the human summary
732
879
  // carries. The ledger makes the honest number the cheap one to publish.
733
880
  process.stderr.write(`scanned: ${String(tally.completed + tally.failed)}\n`);
881
+
882
+ // Reported AFTER the ledger and the `scanned:` line, so a broken allowlist
883
+ // still produces both output contracts. An exemption problem fails the run
884
+ // on its own: a gate that scans cleanly through unusable exemptions is
885
+ // reporting on a question nobody asked.
886
+ if (allowlist_problems.length > 0) {
887
+ process.stderr.write(
888
+ `\n❌ ${allowlist_problems.length} unusable allowlist entr` +
889
+ `${allowlist_problems.length === 1 ? 'y' : 'ies'}:\n`,
890
+ );
891
+ for (const p of allowlist_problems) {
892
+ process.stderr.write(` · ${p}\n`);
893
+ }
894
+ process.stderr.write(
895
+ ' An exemption that names nothing, suppresses nothing, or reaches further\n' +
896
+ ' than the line it was reviewed for is not an exemption. Remove it or\n' +
897
+ ' re-anchor it to the content it is meant to cover.\n',
898
+ );
899
+ return 1;
900
+ }
734
901
  return total_hits ? 1 : 0;
735
902
  }
736
903