@event4u/agent-config 9.20.0 → 9.22.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 (239) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/CHANGELOG.md +86 -0
  3. package/dist/agent-src/commands/worktree/cleanup.md +17 -0
  4. package/dist/agent-src/commands/worktree/status.md +6 -0
  5. package/dist/agent-src/skills/worktree-lifecycle/SKILL.md +22 -0
  6. package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
  7. package/dist/cli-delegate/{chunk-XGA2NTU4.js → chunk-3EWYC3YK.js} +40 -0
  8. package/dist/cli-delegate/cmd_handoff.js +1 -1
  9. package/dist/cli-delegate/cmd_memory_get.js +1 -1
  10. package/dist/cli-delegate/cmd_route_audit.js +1 -1
  11. package/dist/discovery/deprecation-report.md +1 -1
  12. package/dist/discovery/discovery-manifest.json +5 -5
  13. package/dist/discovery/discovery-manifest.json.sha256 +1 -1
  14. package/dist/discovery/discovery-manifest.summary.md +1 -1
  15. package/dist/discovery/orphan-report.md +1 -1
  16. package/dist/discovery/packs.json +2 -2
  17. package/dist/discovery/trust-report.md +1 -1
  18. package/dist/discovery/workspaces.json +2 -2
  19. package/dist/hooks/dispatch.js +58 -2
  20. package/dist/mcp/registry-manifest.json +2 -2
  21. package/dist/mcp/server.json +2 -2
  22. package/dist/mcp/server.mjs +47 -29
  23. package/package.json +1 -1
  24. package/src/config/gate-coverage.yml +22 -4
  25. package/src/config/gate-violation-baselines.json +3 -8
  26. package/src/scripts/_lib/bench_ab_activation.ts +368 -0
  27. package/src/scripts/_lib/gate_population.ts +70 -0
  28. package/src/scripts/_lib/roadmap_granularity.ts +157 -0
  29. package/src/scripts/_lib/scan_scope.ts +44 -0
  30. package/src/scripts/_lib/stdin.ts +82 -0
  31. package/src/scripts/audit_adr_coverage.ts +38 -0
  32. package/src/scripts/audit_auto_rules.ts +17 -3
  33. package/src/scripts/audit_command_surface.ts +51 -0
  34. package/src/scripts/audit_initial_context.ts +39 -3
  35. package/src/scripts/audit_mcp_tools.ts +25 -0
  36. package/src/scripts/audit_overlap.ts +24 -1
  37. package/src/scripts/audit_skill_descriptions.ts +19 -0
  38. package/src/scripts/{verify_before_complete_hook.ts → before_complete_hook.ts} +16 -3
  39. package/src/scripts/bench_ab_task_runner.ts +18 -0
  40. package/src/scripts/bench_ab_v2_run.ts +147 -6
  41. package/src/scripts/bench_ab_v2_stats.ts +74 -2
  42. package/src/scripts/check_adr_frontmatter.ts +23 -3
  43. package/src/scripts/check_always_budget.ts +24 -0
  44. package/src/scripts/check_artefact_checksums.ts +24 -1
  45. package/src/scripts/check_artefact_count_messaging.ts +20 -0
  46. package/src/scripts/check_augmentignore.ts +22 -15
  47. package/src/scripts/check_backstop_debt.ts +22 -0
  48. package/src/scripts/check_beta_review_markers.ts +22 -1
  49. package/src/scripts/check_bridge_derivation.ts +22 -1
  50. package/src/scripts/check_bundle_path_leakage.ts +26 -0
  51. package/src/scripts/check_ci_strict_superset.ts +21 -3
  52. package/src/scripts/check_claims.ts +22 -0
  53. package/src/scripts/check_cli_registry_budget_sync.ts +33 -4
  54. package/src/scripts/check_cluster_patterns.ts +26 -4
  55. package/src/scripts/check_command_count_messaging.ts +20 -4
  56. package/src/scripts/check_comparison.ts +18 -0
  57. package/src/scripts/check_condensed_paths.ts +23 -2
  58. package/src/scripts/check_consumer_scope_flip.ts +21 -2
  59. package/src/scripts/check_council_config_location.ts +20 -0
  60. package/src/scripts/check_council_layout.ts +22 -2
  61. package/src/scripts/check_council_references.ts +23 -1
  62. package/src/scripts/check_dependency_floors.ts +19 -3
  63. package/src/scripts/check_discovery_determinism.ts +26 -0
  64. package/src/scripts/check_enforcement_coverage.ts +20 -0
  65. package/src/scripts/check_evaluator_budgets.ts +23 -0
  66. package/src/scripts/check_finding_dispositions.ts +29 -0
  67. package/src/scripts/check_gate_coverage.ts +63 -22
  68. package/src/scripts/check_gate_paths.ts +30 -5
  69. package/src/scripts/check_generated_artefact_headers.ts +21 -0
  70. package/src/scripts/check_generator_output_coverage.ts +23 -0
  71. package/src/scripts/check_gitignore_freshness.ts +23 -0
  72. package/src/scripts/check_host_loadability.ts +34 -8
  73. package/src/scripts/check_installer_import_purity.ts +22 -8
  74. package/src/scripts/check_kernel_prefix_stability.ts +19 -1
  75. package/src/scripts/check_kernel_rule_bundle.ts +23 -0
  76. package/src/scripts/check_knowledge_cards.ts +13 -0
  77. package/src/scripts/check_knowledge_pages.ts +23 -0
  78. package/src/scripts/check_knowledge_sharing.ts +27 -0
  79. package/src/scripts/check_md_language.ts +24 -0
  80. package/src/scripts/check_media_deps.ts +23 -0
  81. package/src/scripts/check_memory.ts +26 -1
  82. package/src/scripts/check_memory_contradiction.ts +16 -1
  83. package/src/scripts/check_memory_proposal.ts +15 -0
  84. package/src/scripts/check_memory_similarity.ts +16 -0
  85. package/src/scripts/check_module_management_neutral.ts +19 -11
  86. package/src/scripts/check_no_conflict_markers.ts +22 -1
  87. package/src/scripts/check_no_external_sources.ts +26 -1
  88. package/src/scripts/check_no_local_settings_committed.ts +37 -10
  89. package/src/scripts/check_no_new_legacy_path.ts +30 -1
  90. package/src/scripts/check_one_off_location.ts +43 -0
  91. package/src/scripts/check_originality_freshness.ts +25 -1
  92. package/src/scripts/check_overlay_cascade_subdirs.ts +21 -0
  93. package/src/scripts/check_pack_size.ts +22 -0
  94. package/src/scripts/check_preamble_payload_budget.ts +35 -5
  95. package/src/scripts/check_proposal.ts +24 -1
  96. package/src/scripts/check_public_catalog_links.ts +27 -0
  97. package/src/scripts/check_public_links.ts +19 -0
  98. package/src/scripts/check_quality_regression.ts +19 -0
  99. package/src/scripts/check_reach_channels.ts +44 -0
  100. package/src/scripts/check_reach_staleness.ts +27 -0
  101. package/src/scripts/check_release_adjacent_health.ts +28 -0
  102. package/src/scripts/check_release_highlights.ts +34 -0
  103. package/src/scripts/check_release_includes_discovery.ts +25 -6
  104. package/src/scripts/check_release_pr_shape.ts +18 -1
  105. package/src/scripts/check_release_published.ts +21 -0
  106. package/src/scripts/check_release_surface_equality.ts +22 -0
  107. package/src/scripts/check_release_trunk_sync.ts +19 -0
  108. package/src/scripts/check_reply_consistency.ts +22 -0
  109. package/src/scripts/check_roadmap_trackable.ts +20 -0
  110. package/src/scripts/check_role_doc_links.ts +23 -0
  111. package/src/scripts/check_rule_invariants.ts +14 -0
  112. package/src/scripts/check_secret_leak.ts +47 -0
  113. package/src/scripts/check_skill_gaps.ts +32 -0
  114. package/src/scripts/check_skill_requires.ts +19 -0
  115. package/src/scripts/check_source_pointer_freshness.ts +20 -0
  116. package/src/scripts/check_structural_breaking.ts +29 -2
  117. package/src/scripts/check_surface_tiers.ts +34 -0
  118. package/src/scripts/check_template_pin_drift.ts +23 -1
  119. package/src/scripts/check_test_coverage_diff.ts +17 -1
  120. package/src/scripts/check_token_optimizer_freshness.ts +18 -3
  121. package/src/scripts/check_token_quality_golden.ts +21 -0
  122. package/src/scripts/check_token_regression.ts +25 -0
  123. package/src/scripts/check_tracked_but_ignored.ts +26 -0
  124. package/src/scripts/check_trigger_eval_presence.ts +23 -1
  125. package/src/scripts/check_trigger_evals.ts +36 -0
  126. package/src/scripts/check_trunk_drift.ts +23 -0
  127. package/src/scripts/check_update_banner.ts +24 -0
  128. package/src/scripts/gate_scope_classify.ts +513 -0
  129. package/src/scripts/hook_manifest.yaml +1 -1
  130. package/src/scripts/hooks/block_kernel_rule_writes.ts +1 -1
  131. package/src/scripts/hooks/concern_registry.ts +2 -2
  132. package/src/scripts/lint_agent_security.ts +19 -0
  133. package/src/scripts/lint_agent_skill_names.ts +30 -0
  134. package/src/scripts/lint_agents_layout.ts +20 -0
  135. package/src/scripts/lint_agents_md.ts +20 -0
  136. package/src/scripts/lint_archived_skills.ts +24 -3
  137. package/src/scripts/lint_behavioural_eval_freshness.ts +29 -3
  138. package/src/scripts/lint_bench_ab.ts +25 -0
  139. package/src/scripts/lint_bench_corpus.ts +20 -4
  140. package/src/scripts/lint_breaking_changes_index.ts +24 -0
  141. package/src/scripts/lint_budget_ownership.ts +20 -3
  142. package/src/scripts/lint_changelog_rollback.ts +24 -0
  143. package/src/scripts/lint_command_flow_coverage.ts +22 -0
  144. package/src/scripts/lint_command_tiers.ts +29 -5
  145. package/src/scripts/lint_commit_subjects.ts +15 -0
  146. package/src/scripts/lint_confusables.ts +24 -0
  147. package/src/scripts/lint_consumer_internal_refs.ts +24 -0
  148. package/src/scripts/lint_design_quality.ts +22 -0
  149. package/src/scripts/lint_design_slop.ts +23 -0
  150. package/src/scripts/lint_discovery_manifest.ts +23 -0
  151. package/src/scripts/lint_discovery_vocabulary.ts +19 -0
  152. package/src/scripts/lint_documented_commands.ts +23 -0
  153. package/src/scripts/lint_empty_roadmaps.ts +23 -0
  154. package/src/scripts/lint_eval_fixture_citations.ts +19 -0
  155. package/src/scripts/lint_eval_freshness.ts +35 -0
  156. package/src/scripts/lint_examples.ts +18 -3
  157. package/src/scripts/lint_explain_trace.ts +23 -0
  158. package/src/scripts/lint_featured_skills.ts +26 -1
  159. package/src/scripts/lint_flows.ts +19 -0
  160. package/src/scripts/lint_frontmatter_boilerplate.ts +20 -0
  161. package/src/scripts/lint_ghostwriter_source.ts +26 -6
  162. package/src/scripts/lint_glama_drift.ts +24 -0
  163. package/src/scripts/lint_global_paths.ts +24 -0
  164. package/src/scripts/lint_governed_writes.ts +20 -0
  165. package/src/scripts/lint_hidden_unicode.ts +25 -0
  166. package/src/scripts/lint_hook_concern_budget.ts +39 -6
  167. package/src/scripts/lint_hook_manifest.ts +22 -0
  168. package/src/scripts/lint_instruction_smuggling.ts +23 -0
  169. package/src/scripts/lint_knowledge_scale.ts +32 -2
  170. package/src/scripts/lint_legal_pack.ts +31 -0
  171. package/src/scripts/lint_marketplace.ts +26 -0
  172. package/src/scripts/lint_marketplace_install_completeness.ts +23 -0
  173. package/src/scripts/lint_mcp_config_security.ts +24 -0
  174. package/src/scripts/lint_mcp_registry_manifest.ts +22 -1
  175. package/src/scripts/lint_memory_tripwire.ts +35 -0
  176. package/src/scripts/lint_missions.ts +23 -7
  177. package/src/scripts/lint_model_tier_coverage.ts +18 -0
  178. package/src/scripts/lint_namespace_collisions.ts +19 -0
  179. package/src/scripts/lint_no_new_atomic_commands.ts +33 -0
  180. package/src/scripts/lint_no_python_twin_rationale.ts +18 -0
  181. package/src/scripts/lint_one_off_age.ts +38 -2
  182. package/src/scripts/lint_orchestration_dsl.ts +23 -3
  183. package/src/scripts/lint_orchestrator_auto_detect.ts +19 -12
  184. package/src/scripts/lint_originality.ts +22 -4
  185. package/src/scripts/lint_originality_shingles.ts +28 -3
  186. package/src/scripts/lint_output_slop.ts +19 -0
  187. package/src/scripts/lint_override_kernel_guard.ts +30 -0
  188. package/src/scripts/lint_pack_first_win.ts +23 -0
  189. package/src/scripts/lint_pack_risk_class.ts +19 -3
  190. package/src/scripts/lint_persistence.ts +19 -0
  191. package/src/scripts/lint_persona_governance.ts +18 -3
  192. package/src/scripts/lint_positioning.ts +23 -0
  193. package/src/scripts/lint_pre_migration_refs.ts +24 -0
  194. package/src/scripts/lint_profile_overlay_set_only.ts +24 -0
  195. package/src/scripts/lint_provenance.ts +21 -0
  196. package/src/scripts/lint_provenance_vocabulary.ts +24 -1
  197. package/src/scripts/lint_readme_jargon.ts +21 -3
  198. package/src/scripts/lint_readme_serial_comma.ts +21 -3
  199. package/src/scripts/lint_readme_size.ts +21 -3
  200. package/src/scripts/lint_regression.ts +28 -0
  201. package/src/scripts/lint_roadmap_blockers.ts +43 -0
  202. package/src/scripts/lint_roadmap_ci_steps.ts +45 -0
  203. package/src/scripts/lint_roadmap_complexity.ts +47 -0
  204. package/src/scripts/lint_roadmap_later_disposition.ts +23 -0
  205. package/src/scripts/lint_rule_interactions.ts +16 -2
  206. package/src/scripts/lint_rule_tiers.ts +19 -6
  207. package/src/scripts/lint_showcase_sessions.ts +19 -3
  208. package/src/scripts/lint_skill_descriptions.ts +19 -0
  209. package/src/scripts/lint_skill_frontmatter_safety.ts +24 -0
  210. package/src/scripts/lint_skill_originality.ts +18 -3
  211. package/src/scripts/lint_skill_scripts_readonly.ts +20 -0
  212. package/src/scripts/lint_skill_tools.ts +84 -145
  213. package/src/scripts/lint_spawn_payload.ts +27 -1
  214. package/src/scripts/lint_store_boundary.ts +19 -0
  215. package/src/scripts/lint_subagent_determinism.ts +23 -1
  216. package/src/scripts/lint_supported_tools_matrix.ts +18 -0
  217. package/src/scripts/lint_surface_matrix.ts +19 -3
  218. package/src/scripts/lint_ticket_buildable.ts +23 -0
  219. package/src/scripts/lint_topics_yaml.ts +19 -12
  220. package/src/scripts/lint_trigger_precision.ts +27 -0
  221. package/src/scripts/lint_trust_coherence.ts +21 -0
  222. package/src/scripts/lint_ui_stack_bundles.ts +17 -3
  223. package/src/scripts/lint_value_dashboard.ts +19 -3
  224. package/src/scripts/lint_versioned_cache.ts +19 -1
  225. package/src/scripts/lint_workflow_security.ts +22 -14
  226. package/src/scripts/pr_url_reminder_hook.ts +1 -1
  227. package/src/scripts/skill_collision_clusters.ts +37 -3
  228. package/src/scripts/skill_discovery.ts +20 -0
  229. package/src/scripts/skill_eval_coverage.ts +22 -0
  230. package/src/scripts/skill_overlap.ts +32 -6
  231. package/src/scripts/skill_preview.ts +31 -0
  232. package/src/scripts/skill_trigger_eval.ts +18 -0
  233. package/src/scripts/skill_usage_collect.ts +21 -0
  234. package/src/scripts/skill_usage_report.ts +21 -0
  235. package/src/scripts/sweep_dead_scan_roots.ts +11 -3
  236. package/src/scripts/verify_physical_move.ts +24 -0
  237. package/src/scripts/worktree_cleanup_check.ts +319 -4
  238. package/src/scripts/check_bite_sized_granularity.ts +0 -116
  239. package/src/scripts/lint_workspace_boundary.ts +0 -267
@@ -0,0 +1,513 @@
1
+ #!/usr/bin/env tsx
2
+ /**
3
+ * Classify every unhardened gate by WHERE its corpus count already lives.
4
+ *
5
+ * `road-to-gate-hardening-adoption` Phase 1 step 1 requires the conversion
6
+ * work-list to be **produced by a script, not by reading**, with a `file:line`
7
+ * per entry naming the place the count already exists. Reading 189 gates by eye
8
+ * and calling the result a list is precisely the manufactured-evidence shape the
9
+ * parent roadmap exists to kill, so the list is derived here.
10
+ *
11
+ * The population is not re-derived: it is imported from
12
+ * `check_gate_coverage.list_unhardened_gates()`, the same function the ratchet
13
+ * reads. A second regex would be a third opinion on "what is a gate" — the
14
+ * disagreement Phase 3 exists to remove, not to widen.
15
+ *
16
+ * ## The classes
17
+ *
18
+ * - `count_at_exit` — a corpus-sized value is in scope at the gate's exit path
19
+ * (the entry function) and already gates its verdict. Converting these adds
20
+ * no semantic judgement: the number is published, not invented. **Phase 1.**
21
+ * - `count_in_helper` — the corpus is counted, but only inside a helper; the
22
+ * entry function never sees the number. Hoisting it is a real (small) code
23
+ * decision. **Phase 2.**
24
+ * - `no_corpus_count` — nothing array-shaped is counted anywhere. Watch-list
25
+ * gate, single-file gate, or legitimately-empty. **Phase 2**, per-gate.
26
+ *
27
+ * A "corpus producer" is a call whose callee name reads as collection-building
28
+ * (`readdirSync`, `glob`, `walk`, `collect*`, `list*`, `iter_*`, …), or an array
29
+ * pipeline (`.filter`/`.map`/`.flatMap`) whose receiver is itself a corpus.
30
+ * Deliberately syntactic: the output is a work-list a human reviews per entry,
31
+ * never an auto-conversion input.
32
+ *
33
+ * ## Corpus is not findings — the one distinction this script exists to make
34
+ *
35
+ * The first draft classified `errors.length` in `main()` as a count-at-exit and
36
+ * would have nominated 146 gates for a mechanical `scanned:` line. That number
37
+ * is the count of things WRONG, not of things READ, and publishing it as
38
+ * coverage is risk #1 of this roadmap's register (manufactured green via
39
+ * invented counts) implemented by the very tool meant to prevent it. So:
40
+ *
41
+ * - an accumulator (`const violations: X[] = []` then `.push`) is NEVER a corpus,
42
+ * regardless of name — a bare array literal is not a producer;
43
+ * - a finding-shaped NAME (`errors`, `violations`, `hits`, `offenders`, …) is
44
+ * labelled `findings` and cannot promote a gate into Phase 1, even when it
45
+ * reached the set some other way.
46
+ *
47
+ * A gate lands in `count_at_exit` only when a **corpus**-kind site sits in its
48
+ * entry function.
49
+ *
50
+ * CLI:
51
+ * ./scripts-run src/scripts/gate_scope_classify # markdown table
52
+ * ./scripts-run src/scripts/gate_scope_classify --format json
53
+ * ./scripts-run src/scripts/gate_scope_classify --class count_at_exit
54
+ * ./scripts-run src/scripts/gate_scope_classify --out <file> # write, else stdout only
55
+ *
56
+ * Exit codes: 0 always when the population could be read; 2 when it could not
57
+ * (an empty population would make the classification vacuous, and a vacuous
58
+ * work-list is the failure this whole track is about).
59
+ */
60
+ import * as fs from 'node:fs';
61
+ import * as path from 'node:path';
62
+ import process from 'node:process';
63
+ import { fileURLToPath, pathToFileURL } from 'node:url';
64
+
65
+ import ts from 'typescript';
66
+
67
+ import { list_unhardened_gates } from './check_gate_coverage.js';
68
+
69
+ const _HERE = fileURLToPath(import.meta.url);
70
+ const REPO_ROOT = path.resolve(path.dirname(_HERE), '..', '..');
71
+ const GATE_DIR = path.join(REPO_ROOT, 'src/scripts');
72
+
73
+ export type ScopeClass = 'count_at_exit' | 'count_in_helper' | 'no_corpus_count';
74
+
75
+ /**
76
+ * What the gate READS, which decides which hardening primitive fits.
77
+ *
78
+ * Phase 2 of the conversion asks "what is a unit here?" for every gate with no
79
+ * countable corpus — and that question has a different answer per shape, not per
80
+ * gate. The shape narrows a 118-gate judgement call to four recipes:
81
+ *
82
+ * - `walks-a-tree` — it enumerates a directory but never keeps the count. The
83
+ * corpus exists; hoist it. → `assertScanned`.
84
+ * - `reads-named-files` — a fixed set of paths (a config, a contract, a
85
+ * manifest). No corpus to count, but the paths can vanish.
86
+ * → `assertWatchlistResolves`.
87
+ * - `reads-a-diff` — it compares changed paths against a rule. A clean diff is
88
+ * genuinely zero units. → `allowEmpty: 'EMPTY_VALID: …'`, and the watch list
89
+ * (if any) still resolves.
90
+ * - `unclassified` — none of the above matched; read it.
91
+ *
92
+ * Syntactic and advisory. The deletion test is still applied per gate by whoever
93
+ * converts it; a shape that suggests `allowEmpty` is not permission to write one.
94
+ */
95
+ export type GateShape = 'walks-a-tree' | 'reads-named-files' | 'reads-a-diff' | 'unclassified';
96
+
97
+ /** What the counted value MEANS — the distinction that keeps Phase 1 honest. */
98
+ export type CountKind = 'corpus' | 'findings';
99
+
100
+ export interface CountSite {
101
+ /** Repo-relative `file:line` where the count expression already exists. */
102
+ at: string;
103
+ /** The expression text, trimmed — evidence a reviewer can grep for. */
104
+ expr: string;
105
+ /** Name of the function the expression sits in (`<top-level>` when none). */
106
+ fn: string;
107
+ /** `corpus` = units read · `findings` = units wrong. Only `corpus` counts. */
108
+ kind: CountKind;
109
+ }
110
+
111
+ export interface GateClassification {
112
+ gate: string;
113
+ cls: ScopeClass;
114
+ /** What the gate reads — picks the hardening primitive. See {@link GateShape}. */
115
+ shape: GateShape;
116
+ /** Entry function the gate exits through, when one was identified. */
117
+ entry: string | null;
118
+ /** Count sites, exit-path ones first. */
119
+ sites: CountSite[];
120
+ }
121
+
122
+ /** Which hardening primitive the shape suggests. Advisory, never a decision. */
123
+ const SHAPE_RECIPE: Record<GateShape, string> = {
124
+ 'walks-a-tree': '`assertScanned` — the corpus exists, hoist the count',
125
+ 'reads-named-files': '`assertWatchlistResolves` — no corpus, but the paths can vanish',
126
+ 'reads-a-diff': '`allowEmpty: EMPTY_VALID:` — a clean diff is genuinely zero units',
127
+ unclassified: 'read it — no shape matched',
128
+ };
129
+
130
+ /**
131
+ * Classify by what the source actually calls.
132
+ *
133
+ * Order matters: a gate that both diffs and walks is a diff gate, because the
134
+ * diff is what bounds its input. Walking beats reading named files for the same
135
+ * reason — the enumeration is the corpus, the named file is usually its config.
136
+ */
137
+ export function shapeOf(src: string): GateShape {
138
+ if (/git diff|git ls-files|--name-only|--cached/.test(src)) return 'reads-a-diff';
139
+ if (/readdirSync|globSync|\bglob\(|iter_artefacts|opendirSync/.test(src)) return 'walks-a-tree';
140
+ if (/readFileSync\(/.test(src)) return 'reads-named-files';
141
+ return 'unclassified';
142
+ }
143
+
144
+ /** Callee names that build a collection. Matched on the called identifier. */
145
+ const PRODUCER_RE =
146
+ /^(readdir|glob|walk|collect|list|gather|discover|enumerate|iter|scan|find|load|read_all|all)/i;
147
+
148
+ /** Array-pipeline methods: `x.filter(...)` is a corpus when `x` is one. */
149
+ const PIPELINE_METHODS = new Set(['filter', 'map', 'flatMap', 'concat', 'slice', 'sort']);
150
+
151
+ /**
152
+ * Names that mean "things wrong", not "things read".
153
+ *
154
+ * A gate reaching its exit with only one of these in scope has NOT computed a
155
+ * corpus size — the number is its verdict. Publishing it as `scanned:` would
156
+ * make a gate that found 3 problems claim it inspected 3 units, which is the
157
+ * false green the coverage guard reads as proof.
158
+ */
159
+ const FINDINGS_RE =
160
+ /^(err|errs|errors?|violations?|viols?|hits?|issues?|problems?|probs?|failures?|failed|offenders?|breaches?|findings?|warnings?|warns?|bad|missing|misses|stale|dupes?|duplicates?|collisions?|regressions?|over|under|orphans?|unresolved|broken|drift|gaps?|leaks?|mismatches?|empties|empty|red|green|passed)$/i;
161
+
162
+ /**
163
+ * Loop bookkeeping, never a corpus size.
164
+ *
165
+ * `for (let i = 0; …) i += 1` makes `i` an incremented counter by the same test
166
+ * that finds a real `checked` tally. Publishing a loop index as `scanned:` is
167
+ * the invented-count failure in its purest form, so index-shaped names are
168
+ * excluded by name AND `for`-initializer declarations are excluded by position.
169
+ */
170
+ const LOOP_INDEX_RE = /^(i|j|k|n|x|y|idx|index|pos|cursor)$/;
171
+
172
+ /** Entry-function names, in preference order. */
173
+ const ENTRY_NAMES = ['main', 'run', 'cli', 'default'];
174
+
175
+ function _rel(abs: string): string {
176
+ return path.relative(REPO_ROOT, abs).split(path.sep).join('/');
177
+ }
178
+
179
+ function _fnName(node: ts.Node): string {
180
+ let cur: ts.Node | undefined = node;
181
+ while (cur !== undefined) {
182
+ if (ts.isFunctionDeclaration(cur) && cur.name !== undefined) return cur.name.text;
183
+ if (ts.isMethodDeclaration(cur) && ts.isIdentifier(cur.name)) return cur.name.text;
184
+ if (
185
+ (ts.isFunctionExpression(cur) || ts.isArrowFunction(cur)) &&
186
+ cur.parent !== undefined &&
187
+ ts.isVariableDeclaration(cur.parent) &&
188
+ ts.isIdentifier(cur.parent.name)
189
+ ) {
190
+ return cur.parent.name.text;
191
+ }
192
+ cur = cur.parent;
193
+ }
194
+ return '<top-level>';
195
+ }
196
+
197
+ /** The identifier a pipeline chain ultimately reads from (`a.filter().map()` → `a`). */
198
+ function _receiverRoot(expr: ts.Expression): string | null {
199
+ let cur: ts.Expression = expr;
200
+ for (;;) {
201
+ if (ts.isIdentifier(cur)) return cur.text;
202
+ if (ts.isCallExpression(cur)) {
203
+ cur = cur.expression;
204
+ continue;
205
+ }
206
+ if (ts.isPropertyAccessExpression(cur)) {
207
+ cur = cur.expression;
208
+ continue;
209
+ }
210
+ if (ts.isAwaitExpression(cur) || ts.isParenthesizedExpression(cur)) {
211
+ cur = cur.expression;
212
+ continue;
213
+ }
214
+ return null;
215
+ }
216
+ }
217
+
218
+ /**
219
+ * Is this initializer a corpus?
220
+ *
221
+ * `known` carries the corpus names found so far, so a pipeline resolves only
222
+ * when its receiver is already a corpus — `errors.filter(...)` stays findings.
223
+ * A bare `[]` is deliberately NOT a producer: that is the accumulator shape
224
+ * every findings list in this repo uses.
225
+ */
226
+ function _isProducer(init: ts.Expression | undefined, known: ReadonlySet<string>): boolean {
227
+ if (init === undefined) return false;
228
+ if (ts.isAwaitExpression(init) || ts.isParenthesizedExpression(init)) {
229
+ return _isProducer(init.expression, known);
230
+ }
231
+ if (ts.isCallExpression(init)) {
232
+ const callee = init.expression;
233
+ if (ts.isIdentifier(callee)) {
234
+ if (callee.text === 'Array' || callee.text === 'Object') return false;
235
+ return PRODUCER_RE.test(callee.text);
236
+ }
237
+ if (ts.isPropertyAccessExpression(callee)) {
238
+ const m = callee.name.text;
239
+ if (PIPELINE_METHODS.has(m)) {
240
+ const root = _receiverRoot(callee.expression);
241
+ return root !== null && known.has(root) && !FINDINGS_RE.test(root);
242
+ }
243
+ if (m === 'from' || m === 'keys' || m === 'values' || m === 'entries') {
244
+ const arg = init.arguments[0];
245
+ if (arg === undefined) return false;
246
+ const root = _receiverRoot(arg);
247
+ return root !== null && known.has(root) && !FINDINGS_RE.test(root);
248
+ }
249
+ return PRODUCER_RE.test(m);
250
+ }
251
+ }
252
+ return false;
253
+ }
254
+
255
+ /**
256
+ * Collect every identifier in `src` that holds a corpus.
257
+ *
258
+ * Name-scoped, not symbol-scoped: two different `files` in two functions collapse
259
+ * into one name. Acceptable — this is a work-list, and a false candidate costs a
260
+ * reviewer one glance while a missed one costs a gate. Iterated to a fixpoint so
261
+ * a corpus derived from a corpus (`const md = files.filter(...)`) is one too.
262
+ */
263
+ export function corpusIdentifiers(src: ts.SourceFile): Set<string> {
264
+ const decls: { name: string; init: ts.Expression | undefined }[] = [];
265
+ const visit = (node: ts.Node): void => {
266
+ if (ts.isVariableDeclaration(node) && ts.isIdentifier(node.name)) {
267
+ decls.push({ name: node.name.text, init: node.initializer });
268
+ }
269
+ ts.forEachChild(node, visit);
270
+ };
271
+ visit(src);
272
+
273
+ const names = new Set<string>();
274
+ for (let pass = 0; pass < 4; pass += 1) {
275
+ const before = names.size;
276
+ for (const d of decls) {
277
+ if (FINDINGS_RE.test(d.name)) continue;
278
+ if (_isProducer(d.init, names)) names.add(d.name);
279
+ }
280
+ if (names.size === before) break;
281
+ }
282
+ return names;
283
+ }
284
+
285
+ /** Identify the function a CLI entry exits through. */
286
+ export function entryFunction(src: ts.SourceFile): string | null {
287
+ const declared = new Set<string>();
288
+ const visit = (node: ts.Node): void => {
289
+ if (ts.isFunctionDeclaration(node) && node.name !== undefined) declared.add(node.name.text);
290
+ if (
291
+ ts.isVariableDeclaration(node) &&
292
+ ts.isIdentifier(node.name) &&
293
+ node.initializer !== undefined &&
294
+ (ts.isArrowFunction(node.initializer) || ts.isFunctionExpression(node.initializer))
295
+ ) {
296
+ declared.add(node.name.text);
297
+ }
298
+ ts.forEachChild(node, visit);
299
+ };
300
+ visit(src);
301
+ for (const n of ENTRY_NAMES) if (declared.has(n)) return n;
302
+ return null;
303
+ }
304
+
305
+ /**
306
+ * Find every `<corpus>.length` read, plus counter variables that a loop
307
+ * increments — the two shapes a corpus size actually takes in this repo.
308
+ */
309
+ export function countSites(src: ts.SourceFile, corpus: ReadonlySet<string>, file: string): CountSite[] {
310
+ const sites: CountSite[] = [];
311
+ const push = (node: ts.Node, expr: string, name: string): void => {
312
+ const { line } = src.getLineAndCharacterOfPosition(node.getStart(src));
313
+ sites.push({
314
+ at: `${file}:${String(line + 1)}`,
315
+ expr,
316
+ fn: _fnName(node),
317
+ kind:
318
+ !FINDINGS_RE.test(name) &&
319
+ !LOOP_INDEX_RE.test(name) &&
320
+ !loopIndices.has(name) &&
321
+ (corpus.has(name) || incremented.has(name))
322
+ ? 'corpus'
323
+ : 'findings',
324
+ });
325
+ };
326
+ const incremented = new Set<string>();
327
+ const loopIndices = new Set<string>();
328
+ const findLoopIndices = (node: ts.Node): void => {
329
+ if (ts.isForStatement(node) && node.initializer !== undefined && ts.isVariableDeclarationList(node.initializer)) {
330
+ for (const d of node.initializer.declarations) {
331
+ if (ts.isIdentifier(d.name)) loopIndices.add(d.name.text);
332
+ }
333
+ }
334
+ ts.forEachChild(node, findLoopIndices);
335
+ };
336
+ findLoopIndices(src);
337
+
338
+ const findIncrements = (node: ts.Node): void => {
339
+ if (
340
+ ts.isPostfixUnaryExpression(node) &&
341
+ node.operator === ts.SyntaxKind.PlusPlusToken &&
342
+ ts.isIdentifier(node.operand)
343
+ ) {
344
+ incremented.add(node.operand.text);
345
+ }
346
+ if (
347
+ ts.isBinaryExpression(node) &&
348
+ node.operatorToken.kind === ts.SyntaxKind.PlusEqualsToken &&
349
+ ts.isIdentifier(node.left)
350
+ ) {
351
+ incremented.add(node.left.text);
352
+ }
353
+ ts.forEachChild(node, findIncrements);
354
+ };
355
+ findIncrements(src);
356
+
357
+ const visit = (node: ts.Node): void => {
358
+ if (
359
+ ts.isPropertyAccessExpression(node) &&
360
+ node.name.text === 'length' &&
361
+ ts.isIdentifier(node.expression)
362
+ ) {
363
+ const name = node.expression.text;
364
+ // Findings-shaped names are recorded too, labelled — a reviewer needs
365
+ // to see that the only number at the exit is a verdict, not a corpus.
366
+ if (corpus.has(name) || FINDINGS_RE.test(name)) push(node, `${name}.length`, name);
367
+ }
368
+ if (ts.isIdentifier(node) && incremented.has(node.text) && !ts.isVariableDeclaration(node.parent)) {
369
+ // Only the reads matter; the increments themselves are not the count.
370
+ const parent = node.parent;
371
+ const isWrite =
372
+ ts.isPostfixUnaryExpression(parent) ||
373
+ ts.isPrefixUnaryExpression(parent) ||
374
+ (ts.isBinaryExpression(parent) && parent.left === node);
375
+ if (!isWrite) push(node, node.text, node.text);
376
+ }
377
+ ts.forEachChild(node, visit);
378
+ };
379
+ visit(src);
380
+ return sites;
381
+ }
382
+
383
+ export function classifyGate(gate: string, dir = GATE_DIR): GateClassification {
384
+ const abs = path.join(dir, `${gate}.ts`);
385
+ const text = fs.readFileSync(abs, 'utf8');
386
+ const src = ts.createSourceFile(abs, text, ts.ScriptTarget.ES2022, true);
387
+ const entry = entryFunction(src);
388
+ const sites = countSites(src, corpusIdentifiers(src), _rel(abs));
389
+ const corpusSites = sites.filter((s) => s.kind === 'corpus');
390
+ const atExit = entry === null ? [] : corpusSites.filter((s) => s.fn === entry);
391
+ const ordered = [...atExit, ...sites.filter((s) => !atExit.includes(s))];
392
+ const cls: ScopeClass =
393
+ atExit.length > 0 ? 'count_at_exit' : corpusSites.length > 0 ? 'count_in_helper' : 'no_corpus_count';
394
+ return { gate, cls, shape: shapeOf(text), entry, sites: ordered };
395
+ }
396
+
397
+ export function classifyAll(gates: readonly string[], dir = GATE_DIR): GateClassification[] {
398
+ const out: GateClassification[] = [];
399
+ for (const g of gates) {
400
+ try {
401
+ out.push(classifyGate(g, dir));
402
+ } catch {
403
+ out.push({ gate: g, cls: 'no_corpus_count', shape: 'unclassified', entry: null, sites: [] });
404
+ }
405
+ }
406
+ return out;
407
+ }
408
+
409
+ const CLASS_ORDER: ScopeClass[] = ['count_at_exit', 'count_in_helper', 'no_corpus_count'];
410
+
411
+ export function renderMarkdown(rows: readonly GateClassification[]): string {
412
+ const byClass = new Map<ScopeClass, GateClassification[]>();
413
+ for (const c of CLASS_ORDER) byClass.set(c, []);
414
+ for (const r of rows) (byClass.get(r.cls) as GateClassification[]).push(r);
415
+
416
+ const lines = [
417
+ '# Unhardened-gate scope classification',
418
+ '',
419
+ 'Generated by `./scripts-run src/scripts/gate_scope_classify`. Population comes',
420
+ 'from `check_gate_coverage.list_unhardened_gates()` — the same function the',
421
+ '`gate-hardening:unhardened-scan-scope` ratchet reads, so this table and the',
422
+ 'ratchet cannot disagree about what a gate is.',
423
+ '',
424
+ `Population: **${String(rows.length)}** unhardened gate(s).`,
425
+ '',
426
+ '| Class | Gates | Meaning |',
427
+ '|---|---:|---|',
428
+ `| \`count_at_exit\` | ${String((byClass.get('count_at_exit') as GateClassification[]).length)} | The corpus size already exists in the entry function — publishing it invents nothing (Phase 1) |`,
429
+ `| \`count_in_helper\` | ${String((byClass.get('count_in_helper') as GateClassification[]).length)} | Counted, but only inside a helper — hoisting is a real decision (Phase 2) |`,
430
+ `| \`no_corpus_count\` | ${String((byClass.get('no_corpus_count') as GateClassification[]).length)} | Nothing array-shaped counted — watch-list, single-file, or legitimately empty (Phase 2) |`,
431
+ '',
432
+ ];
433
+ for (const c of CLASS_ORDER) {
434
+ const group = byClass.get(c) as GateClassification[];
435
+ lines.push(`## \`${c}\` — ${String(group.length)} gate(s)`, '');
436
+ if (group.length === 0) {
437
+ lines.push('_none_', '');
438
+ continue;
439
+ }
440
+ lines.push('| Gate | Shape | Entry | Count already at | Expression | Kind |', '|---|---|---|---|---|---|');
441
+ for (const g of group) {
442
+ const first = g.sites.find((s) => s.kind === 'corpus') ?? g.sites[0];
443
+ lines.push(
444
+ `| \`${g.gate}\` | ${g.shape} | ${g.entry === null ? '—' : `\`${g.entry}()\``} | ` +
445
+ `${first === undefined ? '—' : `\`${first.at}\``} | ` +
446
+ `${first === undefined ? '—' : `\`${first.expr}\` in \`${first.fn}()\``} | ` +
447
+ `${first === undefined ? '—' : first.kind} |`,
448
+ );
449
+ }
450
+ lines.push('');
451
+ }
452
+ lines.push('## Shape → hardening primitive', '', '| Shape | Suggested primitive |', '|---|---|');
453
+ for (const [shape, recipe] of Object.entries(SHAPE_RECIPE)) {
454
+ lines.push(`| \`${shape}\` | ${recipe} |`);
455
+ }
456
+ lines.push(
457
+ '',
458
+ 'Advisory. The deletion test — *"if this gate\'s scan root were deleted, would',
459
+ 'the reason still make sense?"* — is applied per gate by whoever converts it. A',
460
+ 'shape suggesting `allowEmpty` is not permission to write one.',
461
+ '',
462
+ );
463
+ return lines.join('\n');
464
+ }
465
+
466
+ export function main(argv: readonly string[]): number {
467
+ const gates = list_unhardened_gates();
468
+ if (gates.length === 0) {
469
+ process.stderr.write(
470
+ 'gate_scope_classify: the unhardened population is empty — either every gate is ' +
471
+ 'hardened (delete this script with the ratchet) or the population read failed.\n',
472
+ );
473
+ return 2;
474
+ }
475
+ let rows = classifyAll(gates);
476
+
477
+ const ci = argv.indexOf('--class');
478
+ if (ci >= 0 && argv[ci + 1] !== undefined) {
479
+ const want = argv[ci + 1] as ScopeClass;
480
+ rows = rows.filter((r) => r.cls === want);
481
+ }
482
+
483
+ const wantJson = argv.includes('--format') && argv[argv.indexOf('--format') + 1] === 'json';
484
+ const body = wantJson
485
+ ? `${JSON.stringify({ generated_by: 'gate_scope_classify', population: gates.length, rows }, null, 2)}\n`
486
+ : renderMarkdown(rows);
487
+
488
+ const oi = argv.indexOf('--out');
489
+ if (oi >= 0 && argv[oi + 1] !== undefined) {
490
+ const out = path.resolve(REPO_ROOT, argv[oi + 1] as string);
491
+ fs.mkdirSync(path.dirname(out), { recursive: true });
492
+ fs.writeFileSync(out, body.endsWith('\n') ? body : `${body}\n`, 'utf8');
493
+ process.stdout.write(`written: ${_rel(out)}\n`);
494
+ } else {
495
+ process.stdout.write(body.endsWith('\n') ? body : `${body}\n`);
496
+ }
497
+ return 0;
498
+ }
499
+
500
+ function _isCliEntry(): boolean {
501
+ if (process.argv[1] === undefined) return false;
502
+ const argvUrl = pathToFileURL(path.resolve(process.argv[1])).href;
503
+ if (argvUrl === import.meta.url) return true;
504
+ try {
505
+ return pathToFileURL(fs.realpathSync(path.resolve(process.argv[1]))).href === import.meta.url;
506
+ } catch {
507
+ return false;
508
+ }
509
+ }
510
+
511
+ if (_isCliEntry()) {
512
+ process.exit(main(process.argv.slice(2)));
513
+ }
@@ -57,7 +57,7 @@ concerns:
57
57
  # builds) into agents/runtime/state/verify-before-complete.json. The rule body
58
58
  # cites that file as the source of truth for "verified this turn?".
59
59
  verify-before-complete:
60
- script: src/scripts/verify_before_complete_hook.ts
60
+ script: src/scripts/before_complete_hook.ts
61
61
  args: []
62
62
  fail_closed: false
63
63
  # Phase 5 — Tier-1 hook for the minimal-safe-diff rule. Counts unique
@@ -40,7 +40,7 @@ import * as path from 'node:path';
40
40
 
41
41
  import { is_kernel_rule } from '../_lib/kernel_rules.js';
42
42
  import { EDIT_TOOLS } from '../minimal_safe_diff_hook.js';
43
- import { COMMAND_TOOLS } from '../verify_before_complete_hook.js';
43
+ import { COMMAND_TOOLS } from '../before_complete_hook.js';
44
44
  import { readHookStdin } from './hook_stdin.js';
45
45
 
46
46
  const _HERE = fileURLToPath(import.meta.url);
@@ -30,7 +30,7 @@ import { main as handoffContextMain } from '../handoff_context_hook.js';
30
30
  import { main as roadmapProgressMain } from '../roadmap_progress_hook.js';
31
31
  import { main as onboardingGateMain } from '../onboarding_gate_hook.js';
32
32
  import { main as contextHygieneMain } from '../context_hygiene_hook.js';
33
- import { main as verifyBeforeCompleteMain } from '../verify_before_complete_hook.js';
33
+ import { main as verifyBeforeCompleteMain } from '../before_complete_hook.js';
34
34
  import { main as minimalSafeDiffMain } from '../minimal_safe_diff_hook.js';
35
35
  import { main as injectionScanMain } from '../injection_scan_hook.js';
36
36
  import { main as memoryLearnMain } from '../memory_learn_hook.js';
@@ -59,7 +59,7 @@ export const CONCERN_REGISTRY: Readonly<Record<string, ConcernMain>> = {
59
59
  'src/scripts/roadmap_progress_hook.ts': roadmapProgressMain as ConcernMain,
60
60
  'src/scripts/onboarding_gate_hook.ts': onboardingGateMain as ConcernMain,
61
61
  'src/scripts/context_hygiene_hook.ts': contextHygieneMain as ConcernMain,
62
- 'src/scripts/verify_before_complete_hook.ts': verifyBeforeCompleteMain as ConcernMain,
62
+ 'src/scripts/before_complete_hook.ts': verifyBeforeCompleteMain as ConcernMain,
63
63
  'src/scripts/minimal_safe_diff_hook.ts': minimalSafeDiffMain as ConcernMain,
64
64
  'src/scripts/injection_scan_hook.ts': injectionScanMain as ConcernMain,
65
65
  'src/scripts/memory_learn_hook.ts': memoryLearnMain as ConcernMain,
@@ -27,6 +27,7 @@ import fs from "node:fs";
27
27
  import path from "node:path";
28
28
  import { fileURLToPath, pathToFileURL } from "node:url";
29
29
  import { runCountedProbe } from "./_lib/counted_probe.js";
30
+ import { assertWatchlistResolves, DeadScopeError } from "./_lib/scan_scope.js";
30
31
 
31
32
  const _HERE = path.dirname(fileURLToPath(import.meta.url));
32
33
 
@@ -184,6 +185,24 @@ function parse_args(argv: string[]): ParsedArgs {
184
185
  export function main(argv: string[] | null = null): number {
185
186
  const args = parse_args(argv ?? process.argv.slice(2));
186
187
 
188
+ // This runner owns no corpus of its own — it guards five named child linters,
189
+ // so its scope is that watch list. `_run` swallows an unparseable child
190
+ // payload as zero findings, which means a renamed or deleted child scores the
191
+ // umbrella "✅ clean (0 blocking)" instead of reporting that it never ran.
192
+ try {
193
+ assertWatchlistResolves({
194
+ gate: "lint_agent_security",
195
+ candidates: LINTERS.map(([, script]) => path.posix.join("src", "scripts", script)),
196
+ repoRoot: REPO_ROOT,
197
+ });
198
+ } catch (e) {
199
+ if (e instanceof DeadScopeError) {
200
+ process.stderr.write(`❌ ${e.message}\n`);
201
+ return 1;
202
+ }
203
+ throw e;
204
+ }
205
+
187
206
  const all_findings: Finding[] = [];
188
207
  let blocking = 0;
189
208
  for (const [check, script] of LINTERS) {
@@ -15,6 +15,7 @@ import { fileURLToPath, pathToFileURL } from 'node:url';
15
15
 
16
16
  import { command_slug } from './_lib/agent_src.js';
17
17
  import { is_claude_builtin_name } from './_lib/claude_builtin_names.js';
18
+ import { assertScanned, DeadScopeError } from './_lib/scan_scope.js';
18
19
 
19
20
  const SCRIPTS_DIR = path.dirname(fileURLToPath(import.meta.url));
20
21
  const ROOT = path.dirname(path.dirname(SCRIPTS_DIR));
@@ -232,6 +233,35 @@ function parse_args(argv: readonly string[]): { quiet: boolean } {
232
233
  export function main(argv?: readonly string[]): number {
233
234
  const { quiet } = parse_args(argv ?? process.argv.slice(2));
234
235
 
236
+ // Both check_* helpers return [] on a missing root, so a moved src/domains
237
+ // or src/skills reads as "all names compliant". Count the name-bearing
238
+ // files themselves — the two roots are summed because either one alone
239
+ // going dead still leaves the other's names checked, and the ✅ line claims
240
+ // BOTH surfaces. Exit 3 is this gate's internal-error code: a dead scope is
241
+ // the linter failing to run, not a name violation (1).
242
+ const scanned =
243
+ (isDir(SRC_DOMAINS) ? rglobNamed(SRC_DOMAINS, 'command.md').length : 0) +
244
+ (isDir(SRC_SKILLS)
245
+ ? fs
246
+ .readdirSync(SRC_SKILLS, { withFileTypes: true })
247
+ .filter((e) => e.isDirectory() && isFile(path.join(SRC_SKILLS, e.name, 'SKILL.md')))
248
+ .length
249
+ : 0);
250
+ try {
251
+ assertScanned({
252
+ gate: 'lint_agent_skill_names',
253
+ scanned,
254
+ units: 'command.md + SKILL.md file(s)',
255
+ roots: ['src/domains', 'src/skills'],
256
+ });
257
+ } catch (exc) {
258
+ if (exc instanceof DeadScopeError) {
259
+ process.stderr.write(`❌ ${exc.message}\n`);
260
+ return 3;
261
+ }
262
+ throw exc;
263
+ }
264
+
235
265
  let violations: string[];
236
266
  try {
237
267
  violations = [...check_commands(), ...check_skills()];
@@ -25,6 +25,8 @@ import * as fs from 'node:fs';
25
25
  import * as path from 'node:path';
26
26
  import { fileURLToPath, pathToFileURL } from 'node:url';
27
27
 
28
+ import { assertScanned, DeadScopeError } from './_lib/scan_scope.js';
29
+
28
30
  const _HERE = fileURLToPath(import.meta.url);
29
31
 
30
32
  // Cwd-relative `agents/` root (mirrors Python `Path("agents")`).
@@ -214,6 +216,24 @@ function main(argv?: readonly string[]): number {
214
216
 
215
217
  const projectRoot = process.cwd();
216
218
  const consumerMode = !is_source_repo(projectRoot);
219
+ // `AGENTS_ROOT` is cwd-relative, so a run from the wrong directory — or a
220
+ // repo that has moved `agents/` — makes both finders return early on the
221
+ // `_isDir` guard and the gate prints "✅ agents/ layout clean" having
222
+ // enumerated nothing.
223
+ try {
224
+ assertScanned({
225
+ gate: 'lint_agents_layout',
226
+ scanned: _iterdirSorted(AGENTS_ROOT).length,
227
+ units: 'agents/ root entr(ies)',
228
+ roots: [AGENTS_ROOT],
229
+ });
230
+ } catch (exc) {
231
+ if (exc instanceof DeadScopeError) {
232
+ process.stdout.write(`❌ ${exc.message}\n`);
233
+ return 1;
234
+ }
235
+ throw exc;
236
+ }
217
237
  const unknown = find_violations(AGENTS_ROOT, !consumerMode);
218
238
  const warnings = consumerMode ? find_consumer_warnings(AGENTS_ROOT) : [];
219
239