@event4u/agent-config 14.17.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 (184) 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 +207 -77
  5. package/README.md +72 -150
  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/pr/merge.md +35 -44
  9. package/dist/agent-src/commands/review/changes.md +19 -19
  10. package/dist/agent-src/commands/review.md +2 -2
  11. package/dist/agent-src/contexts/execution/host-capability-manifest.md +30 -3
  12. package/dist/agent-src/rules/autonomous-execution.md +1 -1
  13. package/dist/agent-src/rules/git-history-discipline.md +2 -2
  14. package/dist/agent-src/rules/guidelines.md +1 -1
  15. package/dist/agent-src/rules/session-canary.md +2 -2
  16. package/dist/agent-src/rules/token-efficiency.md +1 -1
  17. package/dist/agent-src/skills/accessibility-auditor/SKILL.md +2 -0
  18. package/dist/agent-src/skills/adversarial-review/SKILL.md +0 -8
  19. package/dist/agent-src/skills/ai-code-blindspots/SKILL.md +27 -1
  20. package/dist/agent-src/skills/canvas-design/SKILL.md +1 -1
  21. package/dist/agent-src/skills/code-intelligence/SKILL.md +60 -12
  22. package/dist/agent-src/skills/complexity-first-planning/SKILL.md +27 -0
  23. package/dist/agent-src/skills/design-intelligence/SKILL.md +1 -1
  24. package/dist/agent-src/skills/design-intelligence/data/app-interface.csv +1 -1
  25. package/dist/agent-src/skills/design-intelligence/data/motion.csv +17 -17
  26. package/dist/agent-src/skills/design-intelligence/data/ux-guidelines.csv +2 -2
  27. package/dist/agent-src/skills/design-intelligence/references/context-and-registers.md +17 -0
  28. package/dist/agent-src/skills/design-intelligence/references/design-rules-checklist.md +4 -4
  29. package/dist/agent-src/skills/design-review/SKILL.md +3 -1
  30. package/dist/agent-src/skills/design-variations/SKILL.md +1 -1
  31. package/dist/agent-src/skills/existing-ui-audit/SKILL.md +1 -1
  32. package/dist/agent-src/skills/fe-design/SKILL.md +32 -5
  33. package/dist/agent-src/skills/fe-design/references/craft-floor.md +7 -0
  34. package/dist/agent-src/skills/fe-design/references/design-patterns.md +47 -2
  35. package/dist/agent-src/skills/feature-planning/SKILL.md +34 -0
  36. package/dist/agent-src/skills/git-workflow/SKILL.md +37 -12
  37. package/dist/agent-src/skills/git-workflow/references/push-closes-its-loop.md +176 -0
  38. package/dist/agent-src/skills/laravel-mail/SKILL.md +44 -0
  39. package/dist/agent-src/skills/laravel-scheduling/SKILL.md +3 -1
  40. package/dist/agent-src/skills/project-analyzer/SKILL.md +0 -6
  41. package/dist/agent-src/skills/react-native-setup/SKILL.md +3 -3
  42. package/dist/agent-src/skills/react-shadcn-ui/SKILL.md +1 -1
  43. package/dist/agent-src/skills/sequential-thinking/SKILL.md +18 -8
  44. package/dist/agent-src/skills/tailwind-engineer/SKILL.md +1 -1
  45. package/dist/agent-src/skills/testing-anti-patterns/SKILL.md +40 -0
  46. package/dist/agent-src/skills/ui-apply-generic/SKILL.md +1 -1
  47. package/dist/agent-src/skills/ui-component-architect/SKILL.md +1 -1
  48. package/dist/agent-src/skills/using-git-worktrees/SKILL.md +29 -2
  49. package/dist/agent-src/skills/verify-repair-loop/SKILL.md +53 -8
  50. package/dist/agent-src/skills/worktree-lifecycle/SKILL.md +3 -1
  51. package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
  52. package/dist/cli/commands/recordTriggerEval.js +2 -2
  53. package/dist/cli/main.js +1 -1
  54. package/dist/cli-delegate/{chunk-AKJGUQNO.js → chunk-GZTMRYCN.js} +405 -341
  55. package/dist/cli-delegate/{chunk-7RXIKF62.js → chunk-IDHUQIXP.js} +283 -132
  56. package/dist/cli-delegate/cmd_adr_effective.js +318 -81
  57. package/dist/cli-delegate/cmd_conformance.js +749 -669
  58. package/dist/cli-delegate/cmd_doctor.js +1 -1
  59. package/dist/cli-delegate/cmd_export.js +1 -1
  60. package/dist/cli-delegate/cmd_handoff.js +1 -1
  61. package/dist/cli-delegate/cmd_prune.js +1 -1
  62. package/dist/cli-delegate/cmd_route_audit.js +1 -1
  63. package/dist/cli-delegate/cmd_settings_migrate.js +1 -1
  64. package/dist/cli-delegate/cmd_sync.js +1 -1
  65. package/dist/cli-delegate/cmd_uninstall.js +1 -1
  66. package/dist/cli-delegate/cmd_validate.js +1 -1
  67. package/dist/discovery/deprecation-report.md +1 -1
  68. package/dist/discovery/discovery-manifest.json +39 -38
  69. package/dist/discovery/discovery-manifest.json.sha256 +1 -1
  70. package/dist/discovery/discovery-manifest.summary.md +1 -1
  71. package/dist/discovery/dormancy-report.md +1 -1
  72. package/dist/discovery/orphan-report.md +1 -1
  73. package/dist/discovery/packs.json +2 -2
  74. package/dist/discovery/trust-report.md +1 -1
  75. package/dist/discovery/workspaces.json +2 -2
  76. package/dist/hooks/dispatch.js +2764 -2439
  77. package/dist/install/install.mjs +588 -527
  78. package/dist/mcp/registry-manifest.json +2 -2
  79. package/dist/mcp/server.json +2 -2
  80. package/dist/mcp/server.mjs +1381 -1138
  81. package/dist/scripts/_lib/model_tier.js +1 -1
  82. package/dist/shared/settingsConsent.js +1 -1
  83. package/docs/guidelines/agent-infra/evaluator-independence-mechanics.md +1 -1
  84. package/docs/guidelines/agent-infra/roadmap-progress-mechanics.md +1 -1
  85. package/docs/guidelines/design-antipatterns.md +87 -64
  86. package/package.json +1 -1
  87. package/src/agent-src/README.md +1 -1
  88. package/src/config/ci-local-parity.yml +6 -0
  89. package/src/config/conformance-claim-baseline.json +9 -0
  90. package/src/config/gate-coverage.yml +170 -0
  91. package/src/config/gate-violation-baselines.json +4 -4
  92. package/src/config/release-gate-locality.yml +141 -0
  93. package/src/scripts/_cli/cmd_doctor.ts +2 -2
  94. package/src/scripts/_cli/cmd_handoff.ts +1 -1
  95. package/src/scripts/_cli/cmd_update.ts +1 -1
  96. package/src/scripts/_cli/cmd_versions.ts +1 -1
  97. package/src/scripts/_lib/adr_frontmatter.ts +44 -0
  98. package/src/scripts/_lib/consequence_objects.ts +152 -0
  99. package/src/scripts/_lib/exec_evidence.ts +7 -0
  100. package/src/scripts/_lib/global_deploy_inventory.ts +2 -2
  101. package/src/scripts/_lib/host_permission_checks.ts +251 -0
  102. package/src/scripts/_lib/model_tier.ts +1 -1
  103. package/src/scripts/_lib/obligation_frequency.ts +2 -2
  104. package/src/scripts/_lib/release_highlights.ts +265 -1
  105. package/src/scripts/_lib/release_material.ts +129 -0
  106. package/src/scripts/_lib/repeated_run.ts +77 -0
  107. package/src/scripts/_lib/runtime_journal.ts +26 -3
  108. package/src/scripts/_lib/runtime_wiring_checks.ts +12 -1
  109. package/src/scripts/_lib/session_register.ts +11 -6
  110. package/src/scripts/_lib/sqlite_schema.ts +22 -0
  111. package/src/scripts/_lib/surface.ts +86 -0
  112. package/src/scripts/_lib/tamper_vocabulary.ts +242 -0
  113. package/src/scripts/_lib/ui_authority.ts +116 -0
  114. package/src/scripts/_lib/unified_diff.ts +121 -0
  115. package/src/scripts/adr/regenerate_index.ts +54 -1
  116. package/src/scripts/adr_cite_check.ts +71 -15
  117. package/src/scripts/ai_council/clients.ts +1 -1
  118. package/src/scripts/autonomy_friction_corpus.ts +229 -0
  119. package/src/scripts/build_proof.ts +15 -2
  120. package/src/scripts/check_beta_review_markers.ts +141 -17
  121. package/src/scripts/check_enforcement_coverage.ts +1 -1
  122. package/src/scripts/check_enforcement_denominator.ts +121 -11
  123. package/src/scripts/check_finding_dispositions.ts +239 -6
  124. package/src/scripts/check_gate_coverage.ts +96 -0
  125. package/src/scripts/check_installed_hooks_fresh.ts +381 -0
  126. package/src/scripts/check_release_highlights.ts +99 -14
  127. package/src/scripts/check_skill_activation_claim.ts +179 -0
  128. package/src/scripts/check_suppression_hygiene.ts +11 -0
  129. package/src/scripts/check_trigger_evals.ts +1 -1
  130. package/src/scripts/code_graph/build.ts +230 -33
  131. package/src/scripts/code_graph/extract.ts +168 -33
  132. package/src/scripts/code_graph/types.ts +37 -2
  133. package/src/scripts/code_graph/validate.ts +12 -1
  134. package/src/scripts/conformance_scan.ts +45 -4
  135. package/src/scripts/detect_silent_catch.ts +403 -0
  136. package/src/scripts/detect_verification_tampering.ts +538 -0
  137. package/src/scripts/explain_run.ts +1 -1
  138. package/src/scripts/generate_capability_matrix.ts +1 -1
  139. package/src/scripts/git_authorization_hook.ts +228 -18
  140. package/src/scripts/hook_manifest.json +1 -1
  141. package/src/scripts/hook_manifest.yaml +47 -30
  142. package/src/scripts/hooks/augment-dispatcher.sh +1 -1
  143. package/src/scripts/hooks/block_no_verify.ts +3 -3
  144. package/src/scripts/hooks/category_a.ts +291 -0
  145. package/src/scripts/hooks/cline-dispatcher.sh +1 -1
  146. package/src/scripts/hooks/concern_registry.ts +2 -2
  147. package/src/scripts/hooks/cursor-dispatcher.sh +11 -5
  148. package/src/scripts/hooks/dispatch_hook.ts +17 -22
  149. package/src/scripts/hooks/evidence_independence.ts +21 -2
  150. package/src/scripts/hooks/{block_unauthorized_git.ts → git_command_classifier.ts} +175 -364
  151. package/src/scripts/hooks/host_lowering.ts +215 -0
  152. package/src/scripts/hooks/host_lowering.yaml +185 -0
  153. package/src/scripts/hooks/host_semantics.ts +105 -37
  154. package/src/scripts/hooks/journal_record_hook.ts +10 -0
  155. package/src/scripts/hooks/permission_gate.ts +117 -0
  156. package/src/scripts/hooks/push_settle_hook.ts +250 -0
  157. package/src/scripts/hooks/ship_diff_volume_hook.ts +1 -1
  158. package/src/scripts/hooks_status.ts +12 -10
  159. package/src/scripts/install-hooks.sh +134 -1
  160. package/src/scripts/install.ts +53 -82
  161. package/src/scripts/install_anthropic_key.sh +2 -2
  162. package/src/scripts/language_mirror_hook.ts +27 -5
  163. package/src/scripts/lint_design_antipattern_parity.ts +80 -0
  164. package/src/scripts/lint_evidence_artifacts.ts +45 -1
  165. package/src/scripts/lint_hook_manifest.ts +87 -3
  166. package/src/scripts/lint_motion_authority_drift.ts +731 -0
  167. package/src/scripts/lint_readme_size.ts +158 -12
  168. package/src/scripts/lint_reasoning_fixtures.ts +216 -0
  169. package/src/scripts/lint_skill_trigger_contradiction.ts +161 -0
  170. package/src/scripts/measure_sibling_naming.ts +150 -0
  171. package/src/scripts/redact_hook_capture.ts +1 -1
  172. package/src/scripts/release.ts +31 -143
  173. package/src/scripts/release_drill.ts +71 -1
  174. package/src/scripts/release_publication.ts +243 -3
  175. package/src/scripts/release_verify.ts +220 -0
  176. package/src/scripts/report_skill_activation.ts +319 -7
  177. package/src/scripts/schemas/review-findings.schema.json +6 -1
  178. package/src/scripts/schemas/skill.schema.json +12 -2
  179. package/src/scripts/self_review_gate.ts +10 -1
  180. package/src/scripts/session_canary_hook.ts +1 -1
  181. package/src/scripts/session_register_hook.ts +4 -3
  182. package/src/scripts/trigger_eval_grandfather.json +5 -1
  183. package/src/scripts/validate_agent_settings.ts +1 -1
  184. package/src/shared/settingsConsent.ts +1 -1
@@ -0,0 +1,538 @@
1
+ #!/usr/bin/env tsx
2
+ /**
3
+ * detect_verification_tampering — did the path to green weaken the verification?
4
+ *
5
+ * `verify-repair-loop` states the gap it cannot close in its own text: a loop
6
+ * optimising for `pass_count >= N` "can game the metric — delete tests, skip
7
+ * assertions, weaken checks — and still hit the threshold" (`SKILL.md:53-54`),
8
+ * and "the numeric gate cannot catch this" (`:185`). Its answer is a
9
+ * fresh-context judge, which is a good answer and a probabilistic one. This is
10
+ * the deterministic half: given the diff between a failing run and the next
11
+ * attempt, decide by shape whether the verification got weaker.
12
+ *
13
+ * No model call. No network. The input is a unified diff, a commit message, and
14
+ * optionally a recorded runner verdict.
15
+ *
16
+ * THIS IS NOT A CI GATE, AND THE NAME SAYS SO.
17
+ *
18
+ * It is `detect_*`, not `check_*` / `lint_*`, and that is a claim about scope
19
+ * rather than a way around a counter. `_lib/gate_population.ts` classifies a
20
+ * script as gate-shaped by the prefixes `lint|check|audit|skill|verify`, and
21
+ * this script is deliberately outside that set for the reason
22
+ * `road-to-deterministic-defect-detectors` step 1.3 states: the tree's own
23
+ * measured lesson is that reach, not content, is the scarce thing, so a new
24
+ * always-on gate is the opposite of the lesson. It is invoked BY THE LOOP, on
25
+ * the loop's own diff, and it is registered in no CI task closure — so it adds
26
+ * no row to `gate-coverage.yml` and no member to the `check_gate_completeness`
27
+ * population. Promoting it is a later decision that owes a measured
28
+ * false-positive rate first, exactly as step 3.2 requires of its sibling.
29
+ *
30
+ * SELF-HONESTY.
31
+ *
32
+ * This detector must not commit the defects it detects. It contains no
33
+ * `catch` that discards its error, no skipped test, and no assertion it
34
+ * weakened to pass: `--self-test` drives the real CLI over the committed
35
+ * corpus and refuses to pass below a floor of cases, so deleting a case is
36
+ * not a route to green.
37
+ *
38
+ * CLI:
39
+ * detect_verification_tampering --diff <file> [--message <s>|--message-file <f>]
40
+ * detect_verification_tampering --diff <file> --verdict <verdict.json>
41
+ * detect_verification_tampering --corpus # run the whole fixture manifest
42
+ * detect_verification_tampering --self-test # drive the real CLI over it
43
+ *
44
+ * Exit codes — the outcome, not just pass/fail:
45
+ * 0 clean · 1 violations (a `block` finding) · 2 usage error or crash
46
+ * 3 warn-only violations · 4 stale_verdict
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 { runGateCli, runSelfTest, type SelfTestCase } from './_lib/gate_self_test.js';
54
+ import { parseUnifiedDiff, type DiffFile, type DiffHunk, type DiffLine } from './_lib/unified_diff.js';
55
+ import {
56
+ assertWellFormed,
57
+ detectorSpec,
58
+ locate,
59
+ parseAllowDeclarations,
60
+ type LoopOutcome,
61
+ type TamperDetectorId,
62
+ type TamperFinding,
63
+ } from './_lib/tamper_vocabulary.js';
64
+
65
+ const _HERE = fileURLToPath(import.meta.url);
66
+ const REPO_ROOT = path.resolve(path.dirname(_HERE), '..', '..');
67
+ const CORPUS = path.join(REPO_ROOT, 'tests/fixtures/tamper-corpus');
68
+ const SELF_TEST_MIN_CASES = 14;
69
+ const SELF_TEST_MIN_REJECT = 7;
70
+
71
+ /* ------------------------------------------------------------------ shapes */
72
+
73
+ /** A line that asserts something, in any of the stacks this tree touches. */
74
+ const ASSERTION_RE =
75
+ /(\bexpect\s*\(|\bassert[A-Za-z]*\s*\(|\bassert\s+|->assert[A-Za-z]*\s*\(|\bself\.assert[A-Za-z]*\s*\(|\.should\b|\bt\.(?:Error|Fatal)f?\s*\(|\brequire\.[A-Za-z]+\s*\()/;
76
+
77
+ /**
78
+ * Assertion strength, high to low. The ladder is the whole of
79
+ * `assertion-weakened`: an exact-value check that becomes an existence check
80
+ * still passes, and still passes for the broken output.
81
+ */
82
+ const STRENGTH_TIERS: ReadonlyArray<readonly [number, RegExp]> = [
83
+ [
84
+ 3,
85
+ /\.(?:toBe|toEqual|toStrictEqual|toHaveBeenCalledWith)\s*\(|\bassert(?:Equals|Same|Is|Identical)\s*\(|\bassertEquals\b|\bassert\s+[^=<>!]+==/,
86
+ ],
87
+ [
88
+ 2,
89
+ /\.(?:toContain|toMatch|toHaveLength|toBeGreaterThan|toBeLessThan|toBeCloseTo)\s*\(|\bassert(?:Contains|Count|GreaterThan|LessThan|Matches)\s*\(/,
90
+ ],
91
+ [
92
+ 1,
93
+ /\.(?:toBeDefined|toBeTruthy|toBeFalsy|toBeNull|toBeUndefined|toBeInstanceOf)\s*\(|\bassert(?:NotNull|NotEmpty|True|False|IsNotNone|IsNone)\s*\(/,
94
+ ],
95
+ ];
96
+
97
+ const SKIP_RE =
98
+ /(\b(?:it|test|describe|context|suite)\s*\.\s*(?:skip|only)\s*\(|\bx(?:it|describe|test)\s*\(|@pytest\.mark\.(?:skip|skipif|xfail)|@unittest\.skip|->markTestSkipped\s*\(|\bt\.Skip(?:Now)?\s*\(|#\[ignore\]|\.only\s*\()/;
99
+
100
+ /** The WHOLE line is an unconditional exit. A guarded `if (x) return;` is not. */
101
+ const UNCONDITIONAL_EXIT_RE =
102
+ /^\s*(?:return(?:\s+(?:true|None|nil|null))?\s*;?|process\.exit\(\s*0\s*\)\s*;?|sys\.exit\(\s*0?\s*\)|os\.Exit\(\s*0\s*\)\s*;?|exit\(\s*0\s*\)\s*;?)\s*$/;
103
+
104
+ const MOCK_RE =
105
+ /(\b(?:vi|jest)\s*\.\s*mock\s*\(|\bsinon\s*\.\s*(?:stub|mock)\s*\(|\bmock\.patch\s*\(|@patch\s*\(|monkeypatch\.setattr\s*\(|Mockery::mock\s*\(|->shouldReceive\s*\(|->createMock\s*\(|\bcreateMock\s*\()/;
106
+
107
+ /** Identifiers that appear in every mock call and prove nothing. */
108
+ const MOCK_API_WORDS = new Set([
109
+ 'mock', 'jest', 'sinon', 'patch', 'stub', 'vitest', 'monkeypatch', 'setattr',
110
+ 'Mockery', 'createMock', 'shouldReceive', 'return', 'const', 'function',
111
+ ]);
112
+
113
+ const TEST_PATH_RE =
114
+ /(^|\/)(?:tests?|spec|specs|__tests__)\//i;
115
+ const TEST_FILE_RE =
116
+ /(?:[._-](?:test|spec)\.[A-Za-z]+$|(?:^|\/)test_[^/]+\.py$|Test\.php$|_test\.go$)/;
117
+
118
+ function isTestPath(p: string): boolean {
119
+ return TEST_PATH_RE.test(p) || TEST_FILE_RE.test(p);
120
+ }
121
+
122
+ function isAssertion(text: string): boolean {
123
+ return ASSERTION_RE.test(text);
124
+ }
125
+
126
+ /** 3 exact · 2 relational · 1 existence · 0 unranked assertion. */
127
+ export function assertionStrength(text: string): number {
128
+ for (const [tier, re] of STRENGTH_TIERS) {
129
+ if (re.test(text)) return tier;
130
+ }
131
+ return 0;
132
+ }
133
+
134
+ /** Strip literals so two spellings of the same assertion compare equal. */
135
+ export function normalizeAssertion(text: string): string {
136
+ return text
137
+ .replace(/'[^']*'|"[^"]*"|`[^`]*`/g, 'S')
138
+ .replace(/\b\d+(?:\.\d+)?\b/g, 'N')
139
+ .replace(/\s+/g, ' ')
140
+ .trim();
141
+ }
142
+
143
+ /* --------------------------------------------------------------- detectors */
144
+
145
+ function finding(
146
+ id: TamperDetectorId,
147
+ file: string,
148
+ l: DiffLine,
149
+ ): TamperFinding {
150
+ return assertWellFormed({
151
+ id,
152
+ severity: detectorSpec(id).severity,
153
+ file,
154
+ line: l.line,
155
+ side: l.side === 'removed' ? 'removed' : 'added',
156
+ evidence: l.text.trim(),
157
+ });
158
+ }
159
+
160
+ function scanDeletedFile(file: DiffFile): TamperFinding[] {
161
+ // A wholly deleted file yields exactly ONE finding. Reporting every
162
+ // assertion inside it as separately removed would bury the real claim
163
+ // under its own consequences.
164
+ if (!isTestPath(file.path)) return [];
165
+ const firstHunk = file.hunks[0];
166
+ const firstLine = firstHunk?.lines[0];
167
+ return [
168
+ assertWellFormed({
169
+ id: 'test-file-deleted',
170
+ severity: detectorSpec('test-file-deleted').severity,
171
+ file: file.path,
172
+ line: firstLine?.line ?? 1,
173
+ side: 'removed',
174
+ evidence: `whole test file deleted (${String(firstHunk?.lines.length ?? 0)} lines)`,
175
+ }),
176
+ ];
177
+ }
178
+
179
+ function scanHunkAssertions(filePath: string, hunk: DiffHunk): TamperFinding[] {
180
+ const removed = hunk.lines.filter((l) => l.side === 'removed' && isAssertion(l.text));
181
+ const added = hunk.lines.filter((l) => l.side === 'added' && isAssertion(l.text));
182
+ if (removed.length === 0) return [];
183
+
184
+ const out: TamperFinding[] = [];
185
+ const pairedAdded = new Set<DiffLine>();
186
+ const pairedRemoved = new Set<DiffLine>();
187
+
188
+ // 1. Same assertion, different literal → the expectation moved onto the output.
189
+ for (const r of removed) {
190
+ const rNorm = normalizeAssertion(r.text);
191
+ for (const a of added) {
192
+ if (pairedAdded.has(a)) continue;
193
+ if (normalizeAssertion(a.text) === rNorm && a.text.trim() !== r.text.trim()) {
194
+ out.push(finding('expectation-rewritten', filePath, a));
195
+ pairedAdded.add(a);
196
+ pairedRemoved.add(r);
197
+ break;
198
+ }
199
+ }
200
+ }
201
+
202
+ const restRemoved = removed.filter((r) => !pairedRemoved.has(r));
203
+ const restAdded = added.filter((a) => !pairedAdded.has(a));
204
+ if (restRemoved.length === 0) return out;
205
+
206
+ // 2. Something replaced it, lower on the ladder → weakened.
207
+ if (restAdded.length > 0) {
208
+ const strongestRemoved = Math.max(...restRemoved.map((r) => assertionStrength(r.text)));
209
+ for (const a of restAdded) {
210
+ if (assertionStrength(a.text) < strongestRemoved) {
211
+ out.push(finding('assertion-weakened', filePath, a));
212
+ }
213
+ }
214
+ return out;
215
+ }
216
+
217
+ // 3. Nothing replaced it → removed outright.
218
+ for (const r of restRemoved) {
219
+ out.push(finding('assertion-removed', filePath, r));
220
+ }
221
+ return out;
222
+ }
223
+
224
+ function scanHunkSkips(filePath: string, hunk: DiffHunk): TamperFinding[] {
225
+ return hunk.lines
226
+ .filter((l) => l.side === 'added' && SKIP_RE.test(l.text))
227
+ .map((l) => finding('verification-skipped', filePath, l));
228
+ }
229
+
230
+ function scanHunkEarlyExit(filePath: string, hunk: DiffHunk): TamperFinding[] {
231
+ const out: TamperFinding[] = [];
232
+ hunk.lines.forEach((l, i) => {
233
+ if (l.side !== 'added' || !UNCONDITIONAL_EXIT_RE.test(l.text)) return;
234
+ // Only a finding when verification survives BELOW it — otherwise the
235
+ // return is an ordinary end-of-function line.
236
+ const survives = hunk.lines
237
+ .slice(i + 1)
238
+ .some((below) => below.side !== 'removed' && isAssertion(below.text));
239
+ if (survives) out.push(finding('early-exit-injected', filePath, l));
240
+ });
241
+ return out;
242
+ }
243
+
244
+ function identifiers(text: string): string[] {
245
+ return (text.match(/[A-Za-z_][A-Za-z0-9_]{3,}/g) ?? []).filter((w) => !MOCK_API_WORDS.has(w));
246
+ }
247
+
248
+ function scanFileMocks(file: DiffFile): TamperFinding[] {
249
+ const removedText: string[] = [];
250
+ for (const h of file.hunks) {
251
+ for (const l of h.lines) {
252
+ if (l.side === 'removed') removedText.push(l.text);
253
+ }
254
+ }
255
+ if (removedText.length === 0) return [];
256
+ const out: TamperFinding[] = [];
257
+ for (const h of file.hunks) {
258
+ for (const l of h.lines) {
259
+ if (l.side !== 'added' || !MOCK_RE.test(l.text)) continue;
260
+ const hit = identifiers(l.text).some((word) =>
261
+ removedText.some((r) => r.includes(word)),
262
+ );
263
+ if (hit) out.push(finding('subject-mocked-away', file.path, l));
264
+ }
265
+ }
266
+ return out;
267
+ }
268
+
269
+ /** Every finding in one diff, before the allow declarations are applied. */
270
+ export function scanDiff(diffText: string): TamperFinding[] {
271
+ const files = parseUnifiedDiff(diffText);
272
+ const out: TamperFinding[] = [];
273
+ for (const file of files) {
274
+ if (file.deleted) {
275
+ out.push(...scanDeletedFile(file));
276
+ continue;
277
+ }
278
+ if (file.added) continue;
279
+ for (const hunk of file.hunks) {
280
+ out.push(...scanHunkAssertions(file.path, hunk));
281
+ out.push(...scanHunkSkips(file.path, hunk));
282
+ out.push(...scanHunkEarlyExit(file.path, hunk));
283
+ }
284
+ out.push(...scanFileMocks(file));
285
+ }
286
+ return out;
287
+ }
288
+
289
+ /* -------------------------------------------------- phantom verification */
290
+
291
+ export interface RecordedVerdict {
292
+ status?: string;
293
+ ran_at?: string;
294
+ last_edit_at?: string;
295
+ }
296
+
297
+ /**
298
+ * A green verdict whose runner finished before the last edit is not evidence.
299
+ *
300
+ * Reported for a red verdict too: a stale red is not evidence of failure
301
+ * either, and passing it through would make the outcome depend on which way a
302
+ * run over the wrong tree happened to land.
303
+ */
304
+ export function verdictIsStale(v: RecordedVerdict): boolean {
305
+ if (typeof v.ran_at !== 'string' || typeof v.last_edit_at !== 'string') return false;
306
+ const ran = Date.parse(v.ran_at);
307
+ const edited = Date.parse(v.last_edit_at);
308
+ if (Number.isNaN(ran) || Number.isNaN(edited)) return false;
309
+ return ran < edited;
310
+ }
311
+
312
+ /* --------------------------------------------------------------- analysis */
313
+
314
+ export interface Analysis {
315
+ outcome: LoopOutcome;
316
+ findings: TamperFinding[];
317
+ suppressed: TamperFinding[];
318
+ malformedAllows: string[];
319
+ staleVerdict: boolean;
320
+ }
321
+
322
+ export function analyze(
323
+ diffText: string,
324
+ message: string,
325
+ verdict: RecordedVerdict | null,
326
+ ): Analysis {
327
+ const { allowed, malformed } = parseAllowDeclarations(message);
328
+ const all = scanDiff(diffText);
329
+ const findings = all.filter((f) => !allowed.has(f.id));
330
+ const suppressed = all.filter((f) => allowed.has(f.id));
331
+ const stale = verdict !== null && verdictIsStale(verdict);
332
+
333
+ let outcome: LoopOutcome;
334
+ if (stale) {
335
+ // Stale beats everything: nothing downstream of a verdict that never
336
+ // saw the change is worth grading.
337
+ outcome = 'stale_verdict';
338
+ } else if (malformed.length > 0) {
339
+ // A declaration that does not parse suppressed nothing and must not
340
+ // read to its author as an accepted exception.
341
+ outcome = 'violations';
342
+ } else if (findings.some((f) => f.severity === 'block')) {
343
+ outcome = 'violations';
344
+ } else if (findings.length > 0) {
345
+ outcome = 'violations';
346
+ } else {
347
+ outcome = 'clean';
348
+ }
349
+ return { outcome, findings, suppressed, malformedAllows: malformed, staleVerdict: stale };
350
+ }
351
+
352
+ export function exitCodeFor(a: Analysis): number {
353
+ if (a.staleVerdict) return 4;
354
+ if (a.malformedAllows.length > 0) return 1;
355
+ if (a.findings.some((f) => f.severity === 'block')) return 1;
356
+ if (a.findings.length > 0) return 3;
357
+ return 0;
358
+ }
359
+
360
+ export function render(a: Analysis): string {
361
+ const lines: string[] = [`outcome: ${a.outcome}`];
362
+ if (a.staleVerdict) {
363
+ lines.push(
364
+ 'stale_verdict — the recorded runner finished BEFORE the last edit in the loop, ' +
365
+ 'so its exit code describes a tree that no longer exists. Re-run before claiming anything.',
366
+ );
367
+ }
368
+ for (const m of a.malformedAllows) lines.push(`malformed declaration: ${m} (nothing was suppressed)`);
369
+ for (const f of a.findings) {
370
+ lines.push(`${f.severity.toUpperCase()} ${f.id} ${locate(f)} ${f.evidence}`);
371
+ lines.push(` why: ${detectorSpec(f.id).why}`);
372
+ }
373
+ for (const f of a.suppressed) {
374
+ lines.push(`allowed ${f.id} ${locate(f)} (declared in the commit message)`);
375
+ }
376
+ if (a.findings.length === 0 && !a.staleVerdict && a.malformedAllows.length === 0) {
377
+ lines.push('the path to green did not weaken the verification');
378
+ }
379
+ return lines.join('\n');
380
+ }
381
+
382
+ /* ----------------------------------------------------------------- corpus */
383
+
384
+ interface Fixture {
385
+ id: string;
386
+ diff: string;
387
+ kind: 'positive' | 'negative';
388
+ expect: string[];
389
+ message: string;
390
+ why: string;
391
+ }
392
+
393
+ export function loadCorpus(root: string = CORPUS): Fixture[] {
394
+ const raw = fs.readFileSync(path.join(root, 'manifest.json'), 'utf-8');
395
+ const parsed = JSON.parse(raw) as { fixtures: Fixture[] };
396
+ return parsed.fixtures;
397
+ }
398
+
399
+ function runCorpus(write: (s: string) => void): number {
400
+ const fixtures = loadCorpus();
401
+ let failed = 0;
402
+ for (const f of fixtures) {
403
+ const diffText = fs.readFileSync(path.join(CORPUS, f.diff), 'utf-8');
404
+ const a = analyze(diffText, f.message, null);
405
+ const got = a.findings.map((x) => x.id).sort();
406
+ const want = [...f.expect].sort();
407
+ const ok = JSON.stringify(got) === JSON.stringify(want);
408
+ if (!ok) failed += 1;
409
+ write(
410
+ `${ok ? 'OK ' : 'FAIL'} ${f.kind.padEnd(8)} ${f.id.padEnd(34)} ` +
411
+ `expect [${want.join(', ')}] got [${got.join(', ')}]\n`,
412
+ );
413
+ if (!ok) {
414
+ for (const x of a.findings) write(` ${x.id} ${locate(x)} ${x.evidence}\n`);
415
+ }
416
+ }
417
+ write(`\nscanned: ${String(fixtures.length)}\n`);
418
+ write(`${String(fixtures.length - failed)}/${String(fixtures.length)} fixtures behaved\n`);
419
+ return failed > 0 ? 1 : 0;
420
+ }
421
+
422
+ /* -------------------------------------------------------------- self-test */
423
+
424
+ function selfTest(): number {
425
+ const rel = 'src/scripts/detect_verification_tampering.ts';
426
+ const fixtures = loadCorpus();
427
+ const cases: SelfTestCase[] = fixtures.map((f) => ({
428
+ name: `${f.kind}/${f.id}`,
429
+ expect: f.kind === 'positive' ? 'reject' : 'accept',
430
+ run: () =>
431
+ runGateCli(
432
+ REPO_ROOT,
433
+ rel,
434
+ ['--diff', path.join(CORPUS, f.diff), '--message', f.message],
435
+ REPO_ROOT,
436
+ ),
437
+ }));
438
+ for (const [name, file, expect] of [
439
+ ['verdict/stale-green', 'stale-green.json', 'reject'],
440
+ ['verdict/stale-red', 'stale-red.json', 'reject'],
441
+ ['verdict/fresh-green', 'fresh-green.json', 'accept'],
442
+ ] as const) {
443
+ cases.push({
444
+ name,
445
+ expect,
446
+ run: () =>
447
+ runGateCli(
448
+ REPO_ROOT,
449
+ rel,
450
+ [
451
+ '--diff',
452
+ path.join(CORPUS, 'diffs/neg-assertion-added.diff'),
453
+ '--verdict',
454
+ path.join(CORPUS, 'verdicts', file),
455
+ ],
456
+ REPO_ROOT,
457
+ ),
458
+ });
459
+ }
460
+ cases.push({
461
+ name: 'declaration/unknown-id-suppresses-nothing',
462
+ expect: 'reject',
463
+ run: () =>
464
+ runGateCli(
465
+ REPO_ROOT,
466
+ rel,
467
+ [
468
+ '--diff',
469
+ path.join(CORPUS, 'diffs/test-file-deleted.diff'),
470
+ '--message',
471
+ 'fix: green it\n\ntamper-allow: test-files-deleted — a plausible reason with a typo in the id',
472
+ ],
473
+ REPO_ROOT,
474
+ ),
475
+ });
476
+ return runSelfTest({
477
+ gate: 'detect_verification_tampering',
478
+ cases,
479
+ minCases: SELF_TEST_MIN_CASES,
480
+ minRejectCases: SELF_TEST_MIN_REJECT,
481
+ });
482
+ }
483
+
484
+ /* ------------------------------------------------------------------- main */
485
+
486
+ function usage(): never {
487
+ process.stderr.write(
488
+ 'usage: detect_verification_tampering --diff <file> [--message <s>|--message-file <f>]\n' +
489
+ ' [--verdict <verdict.json>] [--json]\n' +
490
+ ' detect_verification_tampering --corpus | --self-test\n',
491
+ );
492
+ process.exit(2);
493
+ }
494
+
495
+ export function main(argv: readonly string[]): number {
496
+ if (argv.includes('--self-test')) return selfTest();
497
+ if (argv.includes('--corpus')) return runCorpus((s) => process.stdout.write(s));
498
+
499
+ let diffPath: string | null = null;
500
+ let message = '';
501
+ let verdictPath: string | null = null;
502
+ let json = false;
503
+ for (let i = 0; i < argv.length; i += 1) {
504
+ const a = argv[i];
505
+ if (a === '--diff') { diffPath = argv[++i] ?? null; }
506
+ else if (a === '--message') { message = argv[++i] ?? ''; }
507
+ else if (a === '--message-file') { message = fs.readFileSync(argv[++i] ?? '', 'utf-8'); }
508
+ else if (a === '--verdict') { verdictPath = argv[++i] ?? null; }
509
+ else if (a === '--json') { json = true; }
510
+ else usage();
511
+ }
512
+ if (diffPath === null) usage();
513
+
514
+ const diffText = fs.readFileSync(diffPath, 'utf-8');
515
+ const verdict =
516
+ verdictPath === null
517
+ ? null
518
+ : (JSON.parse(fs.readFileSync(verdictPath, 'utf-8')) as RecordedVerdict);
519
+ const analysis = analyze(diffText, message, verdict);
520
+ process.stdout.write(
521
+ (json ? JSON.stringify(analysis, null, 2) : render(analysis)) + '\n',
522
+ );
523
+ return exitCodeFor(analysis);
524
+ }
525
+
526
+ const invokedDirectly =
527
+ process.argv[1] !== undefined && pathToFileURL(process.argv[1]).href === import.meta.url;
528
+ if (invokedDirectly) {
529
+ try {
530
+ process.exit(main(process.argv.slice(2)));
531
+ } catch (err) {
532
+ // Rethrown as an exit-2 crash with the error text preserved. Never
533
+ // swallowed: a detector that reported clean because it threw would be
534
+ // the phantom green this file exists to catch.
535
+ process.stderr.write(`detect_verification_tampering crashed: ${String(err)}\n`);
536
+ process.exit(2);
537
+ }
538
+ }
@@ -690,7 +690,7 @@ function renderHygieneSection(hygiene: HygieneReadResult): string[] {
690
690
  out.push(`_Source: tried ${hygiene.checkedPaths.map((p) => `\`${p}\``).join(', then ')}_`);
691
691
  out.push('');
692
692
  if (hygiene.state === null) {
693
- out.push('no data — none of the candidate paths exist yet (no PostToolUse hook has run this session, or this host has no hook surface for it)');
693
+ out.push('no data — none of the candidate paths exist yet (no PostToolUse hook has run this session, or no PostToolUse hook is bound on this host)');
694
694
  return out;
695
695
  }
696
696
  const s = hygiene.state;
@@ -136,7 +136,7 @@ const _FN_SPEC: Record<string, FnSpec> = {
136
136
  // Surfaces the INSTALLER provides outside generate_tools() (so the derivation
137
137
  // guard stays pure). Marked with a † footnote in the rendered matrix.
138
138
  const _INSTALL_TIME_CELLS: Record<string, Record<string, string>> = {
139
- rules: { copilot: 'adapter' }, // .github/copilot-instructions.md (aggregated, install.py)
139
+ rules: { copilot: 'adapter' }, // .github/copilot-instructions.md (aggregated, the installer)
140
140
  };
141
141
 
142
142
  // Python: re.compile(r"\b(generate_[A-Za-z0-9_]+)\s*\(").