@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
@@ -24,8 +24,49 @@
24
24
  * self_review_gate --findings-out) into the ledger with an empty
25
25
  * disposition, so validation is red until a human fills it.
26
26
  *
27
- * Exit codes: 0 complete · 1 missing/incomplete dispositions · 2 usage or
28
- * schema error. Deliberately NOT fail-open: this script never calls a model;
27
+ * ABSENCE IS NOT EVIDENCE OF ZERO. An absent ledger used to exit 0 for any
28
+ * version, so a RELEASED version whose findings were never ingested read as
29
+ * "no findings" — the failure this paragraph exists to stop. When the ledger
30
+ * file is missing the gate now asks whether the version has SHIPPED:
31
+ *
32
+ * released a tag for the version exists locally, or the remote says
33
+ * the release exists -> an absent ledger is a FAILURE (1)
34
+ * unreleased the local tag list is COMPLETE and lacks it, or the remote
35
+ * says so -> normal in-flight state, absent ledger is fine
36
+ * undeterminable the tag list cannot settle it and no remote answered ->
37
+ * exit 2. Refusing beats passing: a silent green here is the
38
+ * very inversion this split exists to remove
39
+ *
40
+ * THE SOURCE IS THE TAG / RELEASE, AND DELIBERATELY NOT `CHANGELOG.md`. The
41
+ * changelog heading is written by the release PR itself — gh pr diff 1836 shows
42
+ * "+## [14.16.0](...)" inside the release commit — so on branch release/X the
43
+ * changelog claims X is released before X exists. Reading it would redden every
44
+ * in-flight release branch over a file nobody could yet have written, which is
45
+ * the exact behaviour this split is here to preserve. The tag is created at
46
+ * publish, after merge, so it cannot make that mistake.
47
+ *
48
+ * A local tag MISS is not authoritative on its own: actions/checkout fetches
49
+ * depth 1 and no tags by default, and the one workflow that runs this gate does
50
+ * not override either, so in CI the local tag list is EMPTY. See releaseStatus
51
+ * for the asymmetry and the remote fallback.
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
+ *
61
+ * A future reader changing allowEmpty below is the person this is addressed to:
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.
65
+ *
66
+ * Exit codes: 0 complete · 1 missing/incomplete dispositions (an absent ledger
67
+ * for a RELEASED version and an unexplained empty ledger are both among them) ·
68
+ * 2 usage error, schema error, or an
69
+ * undeterminable release status. Deliberately NOT fail-open: this script never calls a model;
29
70
  * a broken ledger file is a hard error, not a neutral skip.
30
71
  */
31
72
  import { spawnSync } from 'node:child_process';
@@ -34,7 +75,7 @@ import * as path from 'node:path';
34
75
  import process from 'node:process';
35
76
  import { fileURLToPath } from 'node:url';
36
77
 
37
- import { assertScanned, DeadScopeError } from './_lib/scan_scope.js';
78
+ import { DeadScopeError, reportScanned } from './_lib/scan_scope.js';
38
79
 
39
80
  const _HERE = fileURLToPath(import.meta.url);
40
81
  const REPO_ROOT = path.resolve(path.dirname(_HERE), '..', '..');
@@ -60,6 +101,8 @@ export interface Ledger {
60
101
  schema_version: number;
61
102
  release: string;
62
103
  findings: LedgerFinding[];
104
+ /** Why the finding set is empty. Required when it is; see empty_ledger_problem. */
105
+ no_findings_reason?: string;
63
106
  }
64
107
 
65
108
  /** Mirrors self_review_gate.classifyBlocking — security/claim × critical/high. */
@@ -105,6 +148,154 @@ export function missing_dispositions(findings: readonly LedgerFinding[]): string
105
148
  return problems;
106
149
  }
107
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
+
181
+ export type ReleaseStatus = 'released' | 'unreleased' | 'undeterminable';
182
+ export type RemoteReleaseAnswer = 'released' | 'unreleased' | 'unavailable';
183
+
184
+ /**
185
+ * Pure core of the released-vs-unreleased discriminator. Every signal is
186
+ * INJECTED, so both directions AND the degradation are testable offline. A
187
+ * red-only test cannot catch the day this starts answering 'unreleased' for
188
+ * everything, which silently restores the fail-open it exists to replace.
189
+ *
190
+ * The asymmetry is the design, and it is the council correction to a first
191
+ * implementation that read local tags only:
192
+ *
193
+ * a local tag HIT is authoritative — the tag exists, the version shipped
194
+ * a local tag MISS is NOT authoritative — unless the tag list is COMPLETE
195
+ *
196
+ * A miss means "not in THIS checkout", which is not "never shipped".
197
+ * tagsComplete is the condition under which a miss IS authoritative: the
198
+ * repository is not shallow and carries at least one tag. That keeps a normal
199
+ * full clone offline and deterministic; only an incomplete checkout pays for a
200
+ * remote lookup.
201
+ */
202
+ export function releaseStatus(signals: {
203
+ localTagHit: boolean;
204
+ tagsComplete: boolean;
205
+ remote: RemoteReleaseAnswer;
206
+ }): ReleaseStatus {
207
+ if (signals.localTagHit) {
208
+ return 'released';
209
+ }
210
+ if (signals.tagsComplete) {
211
+ return 'unreleased';
212
+ }
213
+ if (signals.remote === 'released') {
214
+ return 'released';
215
+ }
216
+ if (signals.remote === 'unreleased') {
217
+ return 'unreleased';
218
+ }
219
+ return 'undeterminable';
220
+ }
221
+
222
+ /** Tag equality, tolerating this repo bare tags and a v prefix. */
223
+ export function localTagHit(release: string, tags: readonly string[]): boolean {
224
+ const v = release.trim();
225
+ if (!v) {
226
+ return false;
227
+ }
228
+ return tags.some((t) => {
229
+ const tt = t.trim();
230
+ return tt === v || tt === `v${v}`;
231
+ });
232
+ }
233
+
234
+ function _git(args: readonly string[]): { ok: boolean; out: string } {
235
+ const r = spawnSync('git', args as string[], {
236
+ encoding: 'utf-8',
237
+ cwd: REPO_ROOT,
238
+ maxBuffer: 16 * 1024 * 1024,
239
+ });
240
+ return { ok: r.status === 0, out: (r.stdout ?? '').trim() };
241
+ }
242
+
243
+ /** This repository tags. A git failure is an empty list, i.e. not complete. */
244
+ export function git_tags(): string[] {
245
+ const r = _git(['tag', '--list']);
246
+ return r.ok ? r.out.split('\n').map((l) => l.trim()).filter(Boolean) : [];
247
+ }
248
+
249
+ /** True when a local tag MISS may be trusted: full clone, and tags present. */
250
+ export function tags_complete(tags: readonly string[]): boolean {
251
+ if (tags.length === 0) {
252
+ return false;
253
+ }
254
+ const r = _git(['rev-parse', '--is-shallow-repository']);
255
+ return r.ok && r.out === 'false';
256
+ }
257
+
258
+ /**
259
+ * Remote answer, used ONLY when the local tag list cannot settle it. gh first —
260
+ * this repo publishes tag and GitHub Release together, the Release object is
261
+ * publication-specific rather than a bare ref push, and --pr mode already shells
262
+ * out to gh. git ls-remote is the fallback when gh is absent.
263
+ */
264
+ export function remote_release_status(release: string): RemoteReleaseAnswer {
265
+ const gh = spawnSync('gh', ['release', 'view', release, '--json', 'tagName'], {
266
+ encoding: 'utf-8',
267
+ cwd: REPO_ROOT,
268
+ maxBuffer: 8 * 1024 * 1024,
269
+ });
270
+ if (gh.status === 0) {
271
+ return 'released';
272
+ }
273
+ if (gh.status !== null && /release not found|could not find|not found/i.test(gh.stderr ?? '')) {
274
+ return 'unreleased';
275
+ }
276
+ const ls = spawnSync(
277
+ 'git',
278
+ ['ls-remote', '--tags', 'origin', `refs/tags/${release}`, `refs/tags/v${release}`],
279
+ { encoding: 'utf-8', cwd: REPO_ROOT, maxBuffer: 8 * 1024 * 1024 },
280
+ );
281
+ if (ls.status === 0) {
282
+ return (ls.stdout ?? '').trim() ? 'released' : 'unreleased';
283
+ }
284
+ return 'unavailable';
285
+ }
286
+
287
+ /** Resolve every signal and decide. Called only when the ledger file is absent. */
288
+ export function resolve_release_status(release: string): ReleaseStatus {
289
+ const tags = git_tags();
290
+ const hit = localTagHit(release, tags);
291
+ const complete = tags_complete(tags);
292
+ return releaseStatus({
293
+ localTagHit: hit,
294
+ tagsComplete: complete,
295
+ remote: hit || complete ? 'unavailable' : remote_release_status(release),
296
+ });
297
+ }
298
+
108
299
  /** Findings the gate comment reported but the ledger never ingested. */
109
300
  export function unrecorded_findings(
110
301
  reported: ReadonlyArray<Pick<LedgerFinding, 'finding_id' | 'severity' | 'kind' | 'title'>>,
@@ -227,6 +418,30 @@ function main(argv: readonly string[]): number {
227
418
  return 0;
228
419
  }
229
420
 
421
+ if (!fs.existsSync(ledgerPath)) {
422
+ const status = resolve_release_status(release);
423
+ if (status === 'released') {
424
+ process.stderr.write(
425
+ `❌ ${release} has shipped and carries no findings ledger at `
426
+ + `${path.relative(REPO_ROOT, ledgerPath)}.\n`
427
+ + ' Absence is not evidence of zero — the release PR\'s self-review may have\n'
428
+ + ' reported findings that were never ingested. Recover the machine block and:\n'
429
+ + ` ./scripts-run src/scripts/check_finding_dispositions --ingest <findings.json> --release ${release}\n`,
430
+ );
431
+ return 1;
432
+ }
433
+ if (status === 'undeterminable') {
434
+ process.stderr.write(
435
+ `❌ cannot determine whether ${release} has shipped: this checkout tag list is`
436
+ + ' incomplete (shallow or tagless) and no remote answered.\n'
437
+ + ' Refusing to pass on an absent ledger without knowing — that silent green is\n'
438
+ + ' the defect this check replaces. Fetch tags (actions/checkout fetch-tags: true)\n'
439
+ + ' or make the gh CLI available.\n',
440
+ );
441
+ return 2;
442
+ }
443
+ }
444
+
230
445
  let ledger: Ledger = { schema_version: 1, release, findings: [] };
231
446
  if (fs.existsSync(ledgerPath)) {
232
447
  try {
@@ -237,6 +452,19 @@ function main(argv: readonly string[]): number {
237
452
  }
238
453
  }
239
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
+
240
468
  // Scope declaration, not a scope guard. The ledger is written per release by
241
469
  // `--ingest`, so "no <release>.json" is the normal state of a release whose
242
470
  // self-review reported nothing — the gate's own success line says so. That
@@ -245,7 +473,7 @@ function main(argv: readonly string[]): number {
245
473
  // durable trigger for an un-ingested finding is `--pr` mode, which compares
246
474
  // the ledger against what the self-review actually reported.
247
475
  try {
248
- assertScanned({
476
+ reportScanned({
249
477
  gate: 'check_finding_dispositions',
250
478
  scanned: ledger.findings.length,
251
479
  units: 'recorded finding(s)',
@@ -253,8 +481,13 @@ function main(argv: readonly string[]): number {
253
481
  allowEmpty:
254
482
  'EMPTY_VALID: zero recorded findings IS the pass state for a release whose '
255
483
  + 'self-review reported none — the ledger file is created on first --ingest, so a '
256
- + 'clean release legitimately has no <version>.json. Un-ingested findings are '
257
- + 'caught by --pr (unrecorded_findings), never by this count.',
484
+ + 'clean release legitimately has no <version>.json. NARROWED TWICE: it no longer '
485
+ + 'covers a RELEASED version with no ledger, which returns 1 above on the '
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.',
258
491
  });
259
492
  } catch (exc) {
260
493
  if (exc instanceof DeadScopeError) {
@@ -113,6 +113,21 @@ export interface GateSpec {
113
113
  * `pending` is reported instead of hidden.
114
114
  */
115
115
  unavailable_exit?: number;
116
+ /**
117
+ * Repo-relative workflow file this row's `argv` is pinned to.
118
+ *
119
+ * Rule 2 of this manifest — CI-IDENTICAL INVOCATION — was an instruction to
120
+ * the author and nothing else: the guard runs whatever `argv` says and never
121
+ * looked at how CI calls the gate, so a row could drift from its workflow, or
122
+ * the workflow step could be deleted outright, and this file would keep
123
+ * reporting healthy coverage for a gate CI no longer runs.
124
+ *
125
+ * Opt-in per row, because most gates here have several CI invocations with
126
+ * different arguments and one `argv` cannot be identical to all of them. Where
127
+ * a row DOES name a workflow, that workflow must contain an invocation of the
128
+ * gate whose argument list equals `argv` exactly.
129
+ */
130
+ ci_invocation?: string;
116
131
  note?: string;
117
132
  }
118
133
 
@@ -191,6 +206,9 @@ export function load_manifest(file = MANIFEST): GateSpec[] {
191
206
  ...(e['unavailable_exit'] === undefined
192
207
  ? {}
193
208
  : { unavailable_exit: _require_int(e['unavailable_exit'], id, i) }),
209
+ ...(e['ci_invocation'] === undefined
210
+ ? {}
211
+ : { ci_invocation: _require_str(e['ci_invocation'], 'ci_invocation', id, i) }),
194
212
  ...(e['note'] === undefined ? {} : { note: String(e['note']) }),
195
213
  ...(e['canary'] === undefined ? {} : { canary: _require_canary(e['canary'], id, i) }),
196
214
  ...(e['no_canary_reason'] === undefined
@@ -405,6 +423,73 @@ function report_negative_control_inventory(specs: readonly GateSpec[]): void {
405
423
  }
406
424
  }
407
425
 
426
+ /**
427
+ * Invocations of `src/scripts/<id>` in a workflow, each as its argument list.
428
+ *
429
+ * Deliberately a text scan rather than a YAML walk: an invocation lives inside a
430
+ * `run:` block scalar, so parsing the workflow buys nothing and costs the ability
431
+ * to see a call split across a line continuation, which is how the release job
432
+ * writes this very gate. Backslash-newline joins are folded before matching for
433
+ * that reason.
434
+ */
435
+ export function ci_invocations(workflowText: string, id: string): string[][] {
436
+ const folded = workflowText.replace(/\\\n\s*/gu, ' ');
437
+ const out: string[][] = [];
438
+ const re = new RegExp(String.raw`src/scripts/${id}(?![A-Za-z0-9_])([^\n]*)`, 'gu');
439
+ for (const m of folded.matchAll(re)) {
440
+ out.push(
441
+ (m[1] ?? '')
442
+ .trim()
443
+ .split(/\s+/u)
444
+ .filter((t) => t !== ''),
445
+ );
446
+ }
447
+ return out;
448
+ }
449
+
450
+ /**
451
+ * The row's `argv` must be reproducible from the workflow it pins.
452
+ *
453
+ * Both directions are failures and they are different defects: no matching
454
+ * invocation means the manifest probes the gate in a way CI does not (or CI
455
+ * stopped calling it at all), which is the drift rule 2 asks for and nothing
456
+ * enforced.
457
+ */
458
+ export function ci_invocation_problem(
459
+ workflowText: string,
460
+ id: string,
461
+ argv: readonly string[],
462
+ ): string | null {
463
+ const found = ci_invocations(workflowText, id);
464
+ if (found.length === 0) {
465
+ return `no invocation of src/scripts/${id} — the workflow does not call this gate`;
466
+ }
467
+ const want = argv.join(' ');
468
+ if (found.some((a) => a.join(' ') === want)) {
469
+ return null;
470
+ }
471
+ return (
472
+ `argv [${want}] matches no invocation there; the workflow calls it as ` +
473
+ found.map((a) => `[${a.join(' ')}]`).join(', ')
474
+ );
475
+ }
476
+
477
+ /** Rows whose `ci_invocation` no longer describes their workflow. */
478
+ export function ci_invocation_drift(specs: readonly GateSpec[], repoRoot = REPO_ROOT): string[] {
479
+ const problems: string[] = [];
480
+ for (const s of specs) {
481
+ if (s.ci_invocation === undefined) continue;
482
+ const file = path.join(repoRoot, s.ci_invocation);
483
+ if (!fs.existsSync(file)) {
484
+ problems.push(`${s.id}: ci_invocation ${s.ci_invocation} does not exist`);
485
+ continue;
486
+ }
487
+ const problem = ci_invocation_problem(fs.readFileSync(file, 'utf-8'), s.id, s.argv);
488
+ if (problem !== null) problems.push(`${s.id}: ${s.ci_invocation} — ${problem}`);
489
+ }
490
+ return problems;
491
+ }
492
+
408
493
  export function main(argv: readonly string[]): number {
409
494
  const wantJson = argv.includes('--format') && argv[argv.indexOf('--format') + 1] === 'json';
410
495
  const listOnly = argv.includes('--list');
@@ -523,12 +608,23 @@ export function main(argv: readonly string[]): number {
523
608
  );
524
609
  }
525
610
  report_negative_control_inventory(specs);
611
+ const drift = ci_invocation_drift(specs);
612
+ for (const d of drift) {
613
+ process.stdout.write(` ❌ ${d}\n`);
614
+ }
526
615
  const hardening = report_hardening_ratchet();
527
616
  const selfTest = report_self_test_ratchet();
528
617
  if (failed.length > 0) {
529
618
  process.stdout.write(`❌ ${String(failed.length)} gate(s) failed the coverage floor.\n`);
530
619
  return 1;
531
620
  }
621
+ if (drift.length > 0) {
622
+ process.stdout.write(
623
+ `❌ ${String(drift.length)} row(s) declare a ci_invocation their workflow no longer matches — ` +
624
+ `the manifest probes the gate differently from CI, or CI stopped calling it.\n`,
625
+ );
626
+ return 1;
627
+ }
532
628
  if (hardening !== 0) {
533
629
  return hardening;
534
630
  }