@event4u/agent-config 11.0.0 → 12.1.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 (312) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/CHANGELOG.md +173 -229
  3. package/agents/templates/.ai-council.yml.example +46 -3
  4. package/dist/agent-src/commands/agent-handoff.md +0 -1
  5. package/dist/agent-src/commands/agent-status.md +0 -1
  6. package/dist/agent-src/commands/agents/audit.md +0 -1
  7. package/dist/agent-src/commands/agents/init.md +0 -1
  8. package/dist/agent-src/commands/agents/optimize.md +0 -1
  9. package/dist/agent-src/commands/agents/user/accept.md +0 -1
  10. package/dist/agent-src/commands/agents/user/delete.md +0 -1
  11. package/dist/agent-src/commands/agents/user/init.md +0 -1
  12. package/dist/agent-src/commands/agents/user/review.md +0 -1
  13. package/dist/agent-src/commands/agents/user/show.md +0 -1
  14. package/dist/agent-src/commands/agents/user/update.md +0 -1
  15. package/dist/agent-src/commands/agents/user.md +0 -1
  16. package/dist/agent-src/commands/agents.md +0 -1
  17. package/dist/agent-src/commands/analytics/prune.md +0 -1
  18. package/dist/agent-src/commands/analytics/show.md +0 -1
  19. package/dist/agent-src/commands/analytics.md +0 -1
  20. package/dist/agent-src/commands/analyze/conformance.md +0 -1
  21. package/dist/agent-src/commands/analyze/decision.md +0 -1
  22. package/dist/agent-src/commands/analyze/inbox.md +0 -1
  23. package/dist/agent-src/commands/analyze/incident.md +0 -1
  24. package/dist/agent-src/commands/analyze/near-miss.md +0 -1
  25. package/dist/agent-src/commands/analyze/postmortem.md +0 -1
  26. package/dist/agent-src/commands/analyze/premortem.md +0 -1
  27. package/dist/agent-src/commands/analyze/reference-repo.md +0 -1
  28. package/dist/agent-src/commands/analyze.md +0 -1
  29. package/dist/agent-src/commands/brand/identity.md +0 -1
  30. package/dist/agent-src/commands/brand/review.md +0 -1
  31. package/dist/agent-src/commands/brand/strategy.md +0 -1
  32. package/dist/agent-src/commands/brand/tokens.md +0 -1
  33. package/dist/agent-src/commands/brand/voice.md +0 -1
  34. package/dist/agent-src/commands/brand.md +0 -1
  35. package/dist/agent-src/commands/bug/fix.md +0 -1
  36. package/dist/agent-src/commands/bug/investigate.md +0 -1
  37. package/dist/agent-src/commands/bug.md +0 -1
  38. package/dist/agent-src/commands/challenge-me/vision.md +0 -1
  39. package/dist/agent-src/commands/challenge-me/with-docs.md +0 -1
  40. package/dist/agent-src/commands/challenge-me.md +0 -1
  41. package/dist/agent-src/commands/chat-history/import.md +0 -1
  42. package/dist/agent-src/commands/chat-history.md +0 -1
  43. package/dist/agent-src/commands/check-current-md.md +0 -1
  44. package/dist/agent-src/commands/commit/in-chunks.md +0 -1
  45. package/dist/agent-src/commands/commit.md +0 -1
  46. package/dist/agent-src/commands/condense.md +0 -1
  47. package/dist/agent-src/commands/context/create.md +0 -1
  48. package/dist/agent-src/commands/context/refactor.md +0 -1
  49. package/dist/agent-src/commands/context.md +0 -1
  50. package/dist/agent-src/commands/contribution-precheck.md +0 -1
  51. package/dist/agent-src/commands/cost/profile.md +0 -1
  52. package/dist/agent-src/commands/cost/report.md +0 -1
  53. package/dist/agent-src/commands/cost.md +0 -1
  54. package/dist/agent-src/commands/council/analysis.md +0 -1
  55. package/dist/agent-src/commands/council/debate.md +0 -1
  56. package/dist/agent-src/commands/council/default.md +0 -1
  57. package/dist/agent-src/commands/council/design.md +0 -1
  58. package/dist/agent-src/commands/council/optimize.md +0 -1
  59. package/dist/agent-src/commands/council/pr.md +0 -1
  60. package/dist/agent-src/commands/council.md +0 -1
  61. package/dist/agent-src/commands/design-system/capture.md +0 -1
  62. package/dist/agent-src/commands/design-system/generate.md +0 -1
  63. package/dist/agent-src/commands/design-system/import.md +0 -1
  64. package/dist/agent-src/commands/design-system.md +0 -1
  65. package/dist/agent-src/commands/estimate-ticket.md +0 -1
  66. package/dist/agent-src/commands/explain-run.md +0 -1
  67. package/dist/agent-src/commands/feature/dev.md +0 -1
  68. package/dist/agent-src/commands/feature/explore.md +0 -1
  69. package/dist/agent-src/commands/feature/plan.md +0 -1
  70. package/dist/agent-src/commands/feature/refactor.md +0 -1
  71. package/dist/agent-src/commands/feature/roadmap.md +0 -1
  72. package/dist/agent-src/commands/feature.md +0 -1
  73. package/dist/agent-src/commands/fix/ci.md +0 -1
  74. package/dist/agent-src/commands/fix/comments.md +0 -1
  75. package/dist/agent-src/commands/fix/portability.md +0 -1
  76. package/dist/agent-src/commands/fix/pr-comments-loop.md +0 -1
  77. package/dist/agent-src/commands/fix/pr-comments.md +0 -1
  78. package/dist/agent-src/commands/fix/quality.md +0 -1
  79. package/dist/agent-src/commands/fix/refs.md +0 -1
  80. package/dist/agent-src/commands/fix/route.md +0 -1
  81. package/dist/agent-src/commands/fix/seeder.md +0 -1
  82. package/dist/agent-src/commands/fix.md +0 -1
  83. package/dist/agent-src/commands/ghostwriter/delete.md +0 -1
  84. package/dist/agent-src/commands/ghostwriter/fetch.md +0 -1
  85. package/dist/agent-src/commands/ghostwriter/list.md +0 -1
  86. package/dist/agent-src/commands/ghostwriter/show.md +0 -1
  87. package/dist/agent-src/commands/ghostwriter/write.md +0 -1
  88. package/dist/agent-src/commands/ghostwriter.md +0 -1
  89. package/dist/agent-src/commands/grill-me.md +0 -1
  90. package/dist/agent-src/commands/humanize.md +0 -1
  91. package/dist/agent-src/commands/image/analyse.md +0 -1
  92. package/dist/agent-src/commands/image/create.md +0 -1
  93. package/dist/agent-src/commands/image/verify.md +0 -1
  94. package/dist/agent-src/commands/image.md +0 -1
  95. package/dist/agent-src/commands/implement-ticket.md +0 -1
  96. package/dist/agent-src/commands/jira-ticket.md +0 -1
  97. package/dist/agent-src/commands/judge/on-diff.md +0 -1
  98. package/dist/agent-src/commands/judge/solo.md +0 -1
  99. package/dist/agent-src/commands/judge/steps.md +0 -1
  100. package/dist/agent-src/commands/judge.md +0 -1
  101. package/dist/agent-src/commands/knowledge/cross-repo.md +0 -1
  102. package/dist/agent-src/commands/knowledge/forget.md +0 -1
  103. package/dist/agent-src/commands/knowledge/ingest.md +0 -1
  104. package/dist/agent-src/commands/knowledge/list.md +0 -1
  105. package/dist/agent-src/commands/knowledge.md +0 -1
  106. package/dist/agent-src/commands/memory/add.md +0 -1
  107. package/dist/agent-src/commands/memory/learn-low-impact.md +0 -1
  108. package/dist/agent-src/commands/memory/load.md +0 -1
  109. package/dist/agent-src/commands/memory/mine-session.md +0 -1
  110. package/dist/agent-src/commands/memory/promote.md +0 -1
  111. package/dist/agent-src/commands/memory/propose.md +0 -1
  112. package/dist/agent-src/commands/memory.md +0 -1
  113. package/dist/agent-src/commands/mission/upgrade.md +0 -1
  114. package/dist/agent-src/commands/mode.md +0 -1
  115. package/dist/agent-src/commands/module/create.md +0 -1
  116. package/dist/agent-src/commands/module/explore.md +0 -1
  117. package/dist/agent-src/commands/module.md +0 -1
  118. package/dist/agent-src/commands/optimize/agents-dir.md +0 -1
  119. package/dist/agent-src/commands/optimize/augmentignore.md +0 -1
  120. package/dist/agent-src/commands/optimize/deep.md +0 -1
  121. package/dist/agent-src/commands/optimize/project.md +0 -1
  122. package/dist/agent-src/commands/optimize/prompt.md +0 -1
  123. package/dist/agent-src/commands/optimize/rtk.md +0 -1
  124. package/dist/agent-src/commands/optimize/skills.md +0 -1
  125. package/dist/agent-src/commands/optimize.md +0 -1
  126. package/dist/agent-src/commands/orchestrate.md +0 -1
  127. package/dist/agent-src/commands/override/create.md +0 -1
  128. package/dist/agent-src/commands/override/manage.md +0 -1
  129. package/dist/agent-src/commands/override.md +0 -1
  130. package/dist/agent-src/commands/package/reset.md +0 -1
  131. package/dist/agent-src/commands/package/test.md +0 -1
  132. package/dist/agent-src/commands/package.md +0 -1
  133. package/dist/agent-src/commands/post-as/ghostwriter.md +0 -1
  134. package/dist/agent-src/commands/post-as/me.md +0 -1
  135. package/dist/agent-src/commands/post-as.md +0 -1
  136. package/dist/agent-src/commands/pr/create/description-only.md +0 -1
  137. package/dist/agent-src/commands/pr/create.md +23 -9
  138. package/dist/agent-src/commands/prediction-pool.md +0 -1
  139. package/dist/agent-src/commands/prepare-for-review.md +0 -1
  140. package/dist/agent-src/commands/profile/activate.md +0 -1
  141. package/dist/agent-src/commands/profile/deactivate.md +0 -1
  142. package/dist/agent-src/commands/profile/show.md +0 -1
  143. package/dist/agent-src/commands/profile.md +0 -1
  144. package/dist/agent-src/commands/project/analyze.md +0 -1
  145. package/dist/agent-src/commands/project/health.md +0 -1
  146. package/dist/agent-src/commands/project.md +0 -1
  147. package/dist/agent-src/commands/refine-ticket.md +0 -1
  148. package/dist/agent-src/commands/research/deep.md +0 -1
  149. package/dist/agent-src/commands/research/report.md +0 -1
  150. package/dist/agent-src/commands/research.md +0 -1
  151. package/dist/agent-src/commands/review/changes.md +0 -1
  152. package/dist/agent-src/commands/review/routing.md +0 -1
  153. package/dist/agent-src/commands/review.md +0 -1
  154. package/dist/agent-src/commands/roadmap/ai-council.md +0 -1
  155. package/dist/agent-src/commands/roadmap/create.md +0 -1
  156. package/dist/agent-src/commands/roadmap/materialize.md +0 -1
  157. package/dist/agent-src/commands/roadmap/next.md +0 -1
  158. package/dist/agent-src/commands/roadmap/process-full.md +0 -1
  159. package/dist/agent-src/commands/roadmap/process-phase.md +0 -1
  160. package/dist/agent-src/commands/roadmap/process-step.md +0 -1
  161. package/dist/agent-src/commands/roadmap.md +0 -1
  162. package/dist/agent-src/commands/rule-compliance-audit.md +0 -1
  163. package/dist/agent-src/commands/security-audit-config.md +0 -1
  164. package/dist/agent-src/commands/skill/preview.md +0 -1
  165. package/dist/agent-src/commands/skill.md +0 -1
  166. package/dist/agent-src/commands/skills/discover.md +0 -1
  167. package/dist/agent-src/commands/skills.md +0 -1
  168. package/dist/agent-src/commands/sync/agent-settings.md +0 -1
  169. package/dist/agent-src/commands/sync/gitignore/fix.md +0 -1
  170. package/dist/agent-src/commands/sync/gitignore.md +0 -1
  171. package/dist/agent-src/commands/sync.md +0 -1
  172. package/dist/agent-src/commands/tdd/green.md +0 -1
  173. package/dist/agent-src/commands/tdd/red.md +0 -1
  174. package/dist/agent-src/commands/tdd/refactor.md +0 -1
  175. package/dist/agent-src/commands/tdd.md +0 -1
  176. package/dist/agent-src/commands/team/adversarial.md +0 -1
  177. package/dist/agent-src/commands/team/delegate.md +0 -1
  178. package/dist/agent-src/commands/team/review.md +0 -1
  179. package/dist/agent-src/commands/team/status.md +0 -1
  180. package/dist/agent-src/commands/team-knowledge/bootstrap.md +0 -1
  181. package/dist/agent-src/commands/team-knowledge/consolidate.md +0 -1
  182. package/dist/agent-src/commands/team-knowledge.md +0 -1
  183. package/dist/agent-src/commands/team.md +0 -1
  184. package/dist/agent-src/commands/tests/create.md +0 -1
  185. package/dist/agent-src/commands/tests/e2e-heal.md +0 -1
  186. package/dist/agent-src/commands/tests/e2e-plan.md +0 -1
  187. package/dist/agent-src/commands/tests/execute.md +0 -1
  188. package/dist/agent-src/commands/tests.md +0 -1
  189. package/dist/agent-src/commands/threat-model.md +0 -1
  190. package/dist/agent-src/commands/update-form-request-messages.md +0 -1
  191. package/dist/agent-src/commands/upstream-contribute.md +0 -1
  192. package/dist/agent-src/commands/video/from-script.md +0 -1
  193. package/dist/agent-src/commands/video/from-song.md +0 -1
  194. package/dist/agent-src/commands/video/scene.md +0 -1
  195. package/dist/agent-src/commands/video/stitch.md +0 -1
  196. package/dist/agent-src/commands/video/storyboard.md +0 -1
  197. package/dist/agent-src/commands/video.md +0 -1
  198. package/dist/agent-src/commands/work.md +0 -1
  199. package/dist/agent-src/commands/worktree/cleanup.md +0 -1
  200. package/dist/agent-src/commands/worktree/create.md +0 -1
  201. package/dist/agent-src/commands/worktree/status.md +0 -1
  202. package/dist/agent-src/commands/worktree/verify.md +0 -1
  203. package/dist/agent-src/commands/worktree.md +0 -1
  204. package/dist/agent-src/contexts/model-recommendations.md +6 -4
  205. package/dist/agent-src/rules/low-impact-corpus-privacy-floor.md +1 -0
  206. package/dist/agent-src/scripts/roadmap_gates.ts +30 -2
  207. package/dist/agent-src/scripts/update_roadmap_progress.ts +32 -1
  208. package/dist/agent-src/skills/docx-authoring/SKILL.md +1 -0
  209. package/dist/agent-src/skills/git-workflow/SKILL.md +9 -1
  210. package/dist/agent-src/skills/pdf-tools/SKILL.md +1 -0
  211. package/dist/agent-src/skills/roadmap-writing/SKILL.md +7 -7
  212. package/dist/agent-src/skills/skill-writing/SKILL.md +35 -0
  213. package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
  214. package/dist/agent-src/templates/roadmaps.md +41 -3
  215. package/dist/cli/commands/commands.candidates.test.js +1 -1
  216. package/dist/cli/commands/commands.candidates.test.js.map +1 -1
  217. package/dist/cli/commands/commands.js +9 -15
  218. package/dist/cli/commands/commands.js.map +1 -1
  219. package/dist/cli/commands/profiles.test.js +7 -7
  220. package/dist/cli/commands/profiles.test.js.map +1 -1
  221. package/dist/cli/discovery/loadManifest.js.map +1 -1
  222. package/dist/cli/main.js +2 -2
  223. package/dist/cli/main.js.map +1 -1
  224. package/dist/cli/registry.js +1 -0
  225. package/dist/cli/registry.js.map +1 -1
  226. package/dist/cli-delegate/chunk-K34QTSD5.js +3895 -0
  227. package/dist/cli-delegate/{chunk-L565YFXH.js → chunk-LKZ5DOYW.js} +557 -297
  228. package/dist/cli-delegate/{chunk-NN4KW4HZ.js → chunk-O52B4F5S.js} +123 -2338
  229. package/dist/cli-delegate/cmd_conformance.js +18 -6
  230. package/dist/cli-delegate/cmd_doctor.js +2 -2
  231. package/dist/cli-delegate/cmd_export.js +1 -1
  232. package/dist/cli-delegate/cmd_handoff.js +1 -1
  233. package/dist/cli-delegate/cmd_prune.js +1 -1
  234. package/dist/cli-delegate/cmd_settings_migrate.js +1 -1
  235. package/dist/cli-delegate/cmd_sync.js +1 -1
  236. package/dist/cli-delegate/cmd_uninstall.js +1 -1
  237. package/dist/cli-delegate/cmd_validate.js +1 -1
  238. package/dist/discovery/deprecation-report.md +1 -1
  239. package/dist/discovery/discovery-manifest.json +213 -412
  240. package/dist/discovery/discovery-manifest.json.sha256 +1 -1
  241. package/dist/discovery/discovery-manifest.summary.md +2 -2
  242. package/dist/discovery/dormancy-report.md +1 -1
  243. package/dist/discovery/orphan-report.md +1 -1
  244. package/dist/discovery/packs.json +3 -3
  245. package/dist/discovery/trust-report.md +1 -1
  246. package/dist/discovery/workspaces.json +3 -3
  247. package/dist/hooks/dispatch.js +2588 -1082
  248. package/dist/install/install.mjs +744 -482
  249. package/dist/mcp/registry-manifest.json +3 -3
  250. package/dist/mcp/server.json +2 -2
  251. package/dist/mcp/server.mjs +159 -15
  252. package/dist/router.json +1 -1
  253. package/dist/scripts/_lib/model_tier.js +9 -0
  254. package/dist/scripts/_lib/model_tier.js.map +1 -1
  255. package/dist/scripts/condense.js +50 -0
  256. package/dist/scripts/condense.js.map +1 -1
  257. package/docs/MIGRATION.md +21 -1
  258. package/docs/contracts/settings-classes.md +41 -0
  259. package/package.json +1 -1
  260. package/src/agent-src/scripts/roadmap_gates.ts +30 -2
  261. package/src/agent-src/scripts/update_roadmap_progress.ts +32 -1
  262. package/src/config/agent-settings.template.yml +34 -0
  263. package/src/config/evaluator-budgets.json +4 -4
  264. package/src/config/gate-coverage.yml +39 -9
  265. package/src/config/gate-violation-baselines.json +9 -3
  266. package/src/config/hook-token-budget.json +7 -1
  267. package/src/config/preamble-payload-budget.json +28 -0
  268. package/src/scripts/_dispatch.bash +52 -5
  269. package/src/scripts/_lib/git_common_dir.ts +173 -0
  270. package/src/scripts/_lib/model_tier.ts +9 -0
  271. package/src/scripts/_lib/release_highlights.ts +108 -7
  272. package/src/scripts/_lib/skill_catalogue.ts +862 -0
  273. package/src/scripts/ai_council/clients.ts +182 -3
  274. package/src/scripts/ai_council/config.ts +16 -1
  275. package/src/scripts/ai_council/low_impact.ts +14 -1
  276. package/src/scripts/ai_council/orchestrator.ts +14 -1
  277. package/src/scripts/ai_council/transport_resolver.ts +32 -2
  278. package/src/scripts/audit_command_surface.ts +21 -15
  279. package/src/scripts/build_catalog_index.ts +1 -1
  280. package/src/scripts/build_discovery_manifest.ts +30 -34
  281. package/src/scripts/capture_skill_catalogue.ts +185 -328
  282. package/src/scripts/check_branch_freshness.ts +217 -62
  283. package/src/scripts/check_release_published.ts +6 -2
  284. package/src/scripts/check_release_trunk_sync.ts +18 -2
  285. package/src/scripts/condense.ts +50 -1
  286. package/src/scripts/dispatch_r2_reviewer.ts +15 -5
  287. package/src/scripts/evidence_report.ts +15 -4
  288. package/src/scripts/gen_discovery_baseline.ts +3 -3
  289. package/src/scripts/hook_manifest.yaml +67 -13
  290. package/src/scripts/hooks/concern_registry.ts +2 -0
  291. package/src/scripts/hooks/orchestration_record_hook.ts +21 -0
  292. package/src/scripts/hooks/subagent_ledger_hook.ts +17 -4
  293. package/src/scripts/hooks/tool_result_bytes_hook.ts +219 -0
  294. package/src/scripts/hot_context_hook.ts +13 -1
  295. package/src/scripts/install.ts +129 -0
  296. package/src/scripts/janitor.ts +77 -2
  297. package/src/scripts/lint_command_tiers.ts +29 -75
  298. package/src/scripts/lint_originality.ts +101 -14
  299. package/src/scripts/lint_plan_risk_register.ts +22 -9
  300. package/src/scripts/lint_roadmap_blockers.ts +109 -6
  301. package/src/scripts/lint_scheduled_deprecations.ts +738 -0
  302. package/src/scripts/migration_status.ts +17 -1
  303. package/src/scripts/probe_review_binding_drift.ts +731 -0
  304. package/src/scripts/release.ts +95 -1
  305. package/src/scripts/schemas/command.schema.json +6 -11
  306. package/src/scripts/schemas/skill.schema.json +12 -2
  307. package/src/scripts/schemas/subagent.schema.json +2 -2
  308. package/src/scripts/session_eol_report.ts +81 -0
  309. package/src/scripts/skill_linter.ts +82 -2
  310. package/src/scripts/workspace_doctor.ts +464 -0
  311. package/src/scripts/worktree_cleanup_check.ts +113 -8
  312. package/dist/cli-delegate/chunk-AYKQGIBG.js +0 -1594
@@ -43,9 +43,16 @@
43
43
  *
44
44
  * So the base is resolved, in order: an explicit `--base`, then the base of the
45
45
  * OPEN PR for this branch as the forge reports it, then the repo's default
46
- * branch from `origin/HEAD`, then `main`. Every message names the base AND
47
- * where it came from, because "I checked main because that is the PR's base"
48
- * and "I checked main because I could not ask" must never print the same.
46
+ * branch AS THE SERVER REPORTS IT (`ls-remote --symref`), then the local
47
+ * `origin/HEAD`, then `main`. Every message names the base AND where it came
48
+ * from, because "I checked main because that is the PR's base" and "I checked
49
+ * main because I could not ask" must never print the same.
50
+ *
51
+ * The server symref matters for the same reason `remoteHead` exists: the local
52
+ * `refs/remotes/origin/HEAD` is written at clone time and never refreshed, so
53
+ * taking the base NAME from it would reintroduce the stale-local-state failure
54
+ * this module argues against everywhere else. It is kept only as the labelled
55
+ * fallback for when the server cannot be reached.
49
56
  *
50
57
  * When the forge cannot be asked (no `gh`, not authenticated, no GitHub remote)
51
58
  * the run says so out loud even under `--quiet`: it has verified the default
@@ -59,6 +66,7 @@ import path from "node:path";
59
66
  import { fileURLToPath } from "node:url";
60
67
 
61
68
  import { runGateCli, runSelfTest, type SelfTestCase } from "./_lib/gate_self_test.js";
69
+ import { workspaceIdentity } from "./_lib/git_common_dir.js";
62
70
  import { reportScanned } from "./_lib/scan_scope.js";
63
71
 
64
72
  // ledger-exempt: single remote-ref probe — the entire scope is ONE ls-remote answer (0 or 1 refs) resolved to one aggregate ancestor verdict, and every empty path already publishes its reason via reportScanned allowEmpty; there is no per-target collection to account.
@@ -66,12 +74,25 @@ const DEFAULT_BASE = "main";
66
74
  const SELF_TEST_MIN_CASES = 5;
67
75
  const SELF_TEST_MIN_REJECT = 2;
68
76
  const SCRIPT_REL = "src/scripts/check_branch_freshness.ts";
69
- /** One forge round trip on the push path — long enough for a cold `gh`, short enough not to stall a push. */
70
- const FORGE_TIMEOUT_MS = 10_000;
77
+ /**
78
+ * One network round trip on the push path — long enough for a cold `gh`, short
79
+ * enough that the worst case stays under budget.
80
+ *
81
+ * It bounds `git` as well as `gh`, and that is the point rather than tidiness:
82
+ * with a timeout on `gh` only, the two `ls-remote` calls could hang unbounded
83
+ * against an unresponsive SSH remote, so the stated worst case was not one. The
84
+ * slowest path makes three calls (forge, symref, base head), so the arithmetic
85
+ * that keeps this under the 25s `pre_push_budget_seconds` ceiling is 3 x 8s.
86
+ */
87
+ const NETWORK_TIMEOUT_MS = 8_000;
71
88
 
72
89
  function git(args: string[]): string | null {
73
90
  try {
74
- return execFileSync("git", args, { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
91
+ return execFileSync("git", args, {
92
+ encoding: "utf8",
93
+ stdio: ["ignore", "pipe", "ignore"],
94
+ timeout: NETWORK_TIMEOUT_MS,
95
+ }).trim();
75
96
  } catch {
76
97
  return null;
77
98
  }
@@ -89,12 +110,17 @@ export type GhRunner = (args: string[]) => GhResult;
89
110
  * green it did not earn.
90
111
  */
91
112
  export type ForgeAnswer =
92
- | { kind: "pr"; base: string; number: number }
113
+ | { kind: "pr"; base: string; number?: number }
93
114
  | { kind: "none" }
94
115
  | { kind: "unavailable"; reason: string };
95
116
 
96
117
  /** Where the base name came from — printed with every verdict. */
97
- export type BaseSource = "flag" | "pull-request" | "origin-head" | "fallback";
118
+ export type BaseSource =
119
+ | "flag"
120
+ | "pull-request"
121
+ | "origin-symref"
122
+ | "origin-head"
123
+ | "fallback";
98
124
 
99
125
  export interface BaseResolution {
100
126
  readonly base: string;
@@ -109,7 +135,7 @@ function gh(args: string[]): GhResult {
109
135
  const out = execFileSync("gh", args, {
110
136
  encoding: "utf8",
111
137
  stdio: ["ignore", "pipe", "ignore"],
112
- timeout: FORGE_TIMEOUT_MS,
138
+ timeout: NETWORK_TIMEOUT_MS,
113
139
  });
114
140
  return { ok: true, stdout: out.toString() };
115
141
  } catch (err) {
@@ -118,7 +144,7 @@ function gh(args: string[]): GhResult {
118
144
  return { ok: false, reason: "the gh CLI is not installed" };
119
145
  }
120
146
  if (code === "ETIMEDOUT") {
121
- return { ok: false, reason: `gh did not answer within ${FORGE_TIMEOUT_MS / 1000}s` };
147
+ return { ok: false, reason: `gh did not answer within ${NETWORK_TIMEOUT_MS / 1000}s` };
122
148
  }
123
149
  return { ok: false, reason: "gh could not answer — not authenticated, or no GitHub remote" };
124
150
  }
@@ -153,11 +179,29 @@ export function askForgeForBase(branch: string, run: GhRunner = gh): ForgeAnswer
153
179
  if (base === "") {
154
180
  return { kind: "unavailable", reason: "gh answered without a baseRefName" };
155
181
  }
156
- return { kind: "pr", base, number: typeof row.number === "number" ? row.number : 0 };
182
+ // The number is cosmetic — it names the PR in the verdict line — while the
183
+ // base is the answer the gate exists to get. An unusable number therefore
184
+ // does NOT discard the base: doing that made the gate check the repo default
185
+ // instead and warn "could not ask the forge" when it had asked and been
186
+ // answered, trading a correct verdict for a tidier message. `describeBase`
187
+ // omits the number rather than rendering the impossible `#0`.
188
+ const usable =
189
+ typeof row.number === "number" && Number.isInteger(row.number) && row.number > 0;
190
+ return usable ? { kind: "pr", base, number: row.number as number } : { kind: "pr", base };
157
191
  }
158
192
 
159
- /** The repo's own default branch, so a consumer on `master` is not told about `main`. */
160
- export function repoDefaultBase(): { base: string; source: "origin-head" | "fallback" } {
193
+ /**
194
+ * The default branch as the LOCAL clone believes it — no network.
195
+ *
196
+ * `refs/remotes/origin/HEAD` is written at clone time and never refreshed
197
+ * without `git remote set-head`, so this is exactly the class of stale local
198
+ * state this module argues against everywhere else. It has exactly one caller,
199
+ * `serverDefaultBase`, and is reached only when the server could not be asked —
200
+ * where `describeBase` labels it as possibly stale. It decides no verdict on
201
+ * its own; a short-circuit that let it do so is the regression documented in
202
+ * `resolveBase`.
203
+ */
204
+ export function localDefaultBase(): { base: string; source: "origin-head" | "fallback" } {
161
205
  const ref = git(["symbolic-ref", "--short", "refs/remotes/origin/HEAD"]);
162
206
  if (ref !== null && ref.startsWith("origin/")) {
163
207
  const name = ref.slice("origin/".length).trim();
@@ -168,24 +212,90 @@ export function repoDefaultBase(): { base: string; source: "origin-head" | "fall
168
212
  return { base: DEFAULT_BASE, source: "fallback" };
169
213
  }
170
214
 
215
+ /**
216
+ * The default branch as the SERVER reports it right now.
217
+ *
218
+ * Same discipline as `remoteHead`: a gate whose thesis is "ask the remote"
219
+ * must not take the branch NAME from a tracking ref either. Costs one
220
+ * `ls-remote`, the round trip this gate already pays, and degrades to the
221
+ * local ref (labelled as such) when the server cannot be reached.
222
+ */
223
+ export function serverDefaultBase(): {
224
+ base: string;
225
+ source: "origin-symref" | "origin-head" | "fallback";
226
+ } {
227
+ const out = git(["ls-remote", "--symref", "origin", "HEAD"]);
228
+ if (out !== null) {
229
+ for (const line of out.split("\n")) {
230
+ const trimmed = line.trim();
231
+ if (trimmed.startsWith("ref:")) {
232
+ const name = trimmed.slice("ref:".length).trim().split(/\s+/)[0] ?? "";
233
+ if (name.startsWith("refs/heads/")) {
234
+ const short = name.slice("refs/heads/".length).trim();
235
+ if (short !== "") {
236
+ return { base: short, source: "origin-symref" };
237
+ }
238
+ }
239
+ }
240
+ }
241
+ }
242
+ return localDefaultBase();
243
+ }
244
+
245
+ /**
246
+ * The base requested on the command line, or null when none was.
247
+ *
248
+ * Both spellings are accepted. `--base=develop` used to be dropped silently by
249
+ * an `indexOf("--base")` lookup, and a dropped request is worse than a rejected
250
+ * one here: the gate then auto-resolves some OTHER base and reports a green
251
+ * about a base the caller never asked about.
252
+ */
253
+ export function explicitBase(argv: readonly string[]): string | null {
254
+ const joined = argv.find((a) => a.startsWith("--base="));
255
+ if (joined !== undefined) {
256
+ const value = joined.slice("--base=".length);
257
+ return value === "" ? null : value;
258
+ }
259
+ const flag = argv.indexOf("--base");
260
+ if (flag < 0) {
261
+ return null;
262
+ }
263
+ const explicit = argv[flag + 1];
264
+ // A bare trailing `--base`, or one followed by another flag, is a typo rather
265
+ // than a base name. Accepting it produced `remoteHead("--quiet")`, which fails
266
+ // and lands on the exit-0 NOT-VERIFIED path — a typo reading as a pass.
267
+ if (explicit === undefined || explicit === "" || explicit.startsWith("-")) {
268
+ return null;
269
+ }
270
+ return explicit;
271
+ }
272
+
171
273
  /** `--base` wins, then the open PR's base, then the repo default. */
172
274
  export function resolveBase(
173
275
  argv: readonly string[],
174
276
  branch: string,
175
277
  forge: (b: string) => ForgeAnswer = (b) => askForgeForBase(b),
176
278
  ): BaseResolution {
177
- const flag = argv.indexOf("--base");
178
- if (flag >= 0) {
179
- const explicit = argv[flag + 1];
180
- if (explicit !== undefined && explicit !== "") {
181
- return { base: explicit, source: "flag" };
182
- }
279
+ const explicit = explicitBase(argv);
280
+ if (explicit !== null) {
281
+ return { base: explicit, source: "flag" };
183
282
  }
283
+ // There is deliberately no cheap short-circuit for "the branch looks like the
284
+ // default". It was tried and reverted: it decided a VERDICT from
285
+ // `localDefaultBase`, the stale clone-time ref this module argues against, so
286
+ // a branch whose name collided with a stale or fallback local default was
287
+ // skipped without any check at all. The premise it rested on — that
288
+ // `gh pr list --head main` can never usefully answer — is false: a PR from the
289
+ // default branch into a release line or a parent PR is exactly that answer,
290
+ // and it is the case this gate exists for. Standing on the real base is
291
+ // short-circuited one layer up, in `main`, AFTER the base is known.
184
292
  const answer = forge(branch);
185
293
  if (answer.kind === "pr") {
186
- return { base: answer.base, source: "pull-request", pr: answer.number };
294
+ return answer.number === undefined
295
+ ? { base: answer.base, source: "pull-request" }
296
+ : { base: answer.base, source: "pull-request", pr: answer.number };
187
297
  }
188
- const fallback = repoDefaultBase();
298
+ const fallback = serverDefaultBase();
189
299
  return answer.kind === "unavailable"
190
300
  ? { base: fallback.base, source: fallback.source, unverified: answer.reason }
191
301
  : { base: fallback.base, source: fallback.source };
@@ -197,9 +307,13 @@ export function describeBase(r: BaseResolution): string {
197
307
  case "flag":
198
308
  return "base given on the command line";
199
309
  case "pull-request":
200
- return `base of open PR #${r.pr ?? 0}`;
310
+ return r.pr === undefined
311
+ ? "base of the open PR for this branch"
312
+ : `base of open PR #${r.pr}`;
313
+ case "origin-symref":
314
+ return "repo default branch, as the server reports it";
201
315
  case "origin-head":
202
- return "repo default branch (origin/HEAD)";
316
+ return "repo default branch from the LOCAL origin/HEAD ref, which may be stale";
203
317
  case "fallback":
204
318
  return `repo default (assumed ${DEFAULT_BASE} — origin/HEAD is not set)`;
205
319
  }
@@ -243,7 +357,15 @@ export function containsCommit(sha: string): boolean | null {
243
357
  * gate reading the tracking ref would pass it — which is exactly the false green
244
358
  * this gate exists to remove.
245
359
  */
246
- function buildFixture(root: string, opts: { advanceRemote: boolean }): string {
360
+ interface Scaffold {
361
+ origin: string;
362
+ work: string;
363
+ g: (args: string[], at: string) => void;
364
+ write: (at: string, name: string, body: string) => void;
365
+ }
366
+
367
+ /** The bare origin + working clone every fixture below starts from. */
368
+ function scaffold(root: string): Scaffold {
247
369
  const origin = path.join(root, "origin.git");
248
370
  const work = path.join(root, "work");
249
371
  fs.mkdirSync(origin, { recursive: true });
@@ -260,6 +382,11 @@ function buildFixture(root: string, opts: { advanceRemote: boolean }): string {
260
382
  g(["remote", "add", "origin", origin], work);
261
383
  write(work, "a.txt", "one");
262
384
  g(["push", "-u", "origin", DEFAULT_BASE], work);
385
+ return { origin, work, g, write };
386
+ }
387
+
388
+ function buildFixture(root: string, opts: { advanceRemote: boolean }): string {
389
+ const { origin, work, g, write } = scaffold(root);
263
390
  g(["checkout", "-b", "feat/x"], work);
264
391
  write(work, "b.txt", "two");
265
392
  if (opts.advanceRemote) {
@@ -272,37 +399,31 @@ function buildFixture(root: string, opts: { advanceRemote: boolean }): string {
272
399
  }
273
400
 
274
401
  /**
275
- * A repo where the branch's real base is NOT the default branch: `feat/x` is
276
- * cut from `develop`, `develop` then advances on the remote, and `main` never
277
- * moves.
402
+ * A repo where the branch's real base is NOT `main`: `feat/x` is cut from
403
+ * `develop`, `develop` then advances on the remote, and `main` never moves.
404
+ * The SERVER's `HEAD` is pointed at `serverDefault`.
278
405
  *
279
- * This is the fixture that demonstrates the defect the base resolution exists
280
- * to remove. Checked against `main` the branch is perfectly current — a GREEN
281
- * that is true and irrelevant. Checked against `develop`, the base it will
282
- * actually merge into, it is behind. Both verdicts come from the same tree, so
283
- * a regression that reinstates a hardcoded base flips exactly one of them.
406
+ * The base is left for the gate to resolve — no `--base`. That is the whole
407
+ * point of the fixture, and the first version got it wrong: it passed
408
+ * `--base` on both cases, which exercises the flag branch that existed before
409
+ * this change. Deleting the entire forge/symref resolution would have left both
410
+ * cases green, so the pair proved nothing about the new behaviour and the raised
411
+ * self-test floors rested on coverage that did not exist. (R2 finding 2.)
412
+ *
413
+ * With `serverDefault = "develop"` the gate must refuse: that is the base this
414
+ * branch is actually behind. With `serverDefault = "main"` the same tree passes.
415
+ * A regression that reinstates a hardcoded `main` flips the first case, because
416
+ * nothing but the resolution decides which base is asked about.
284
417
  */
285
- function buildTwoBaseFixture(root: string): string {
286
- const origin = path.join(root, "origin.git");
287
- const work = path.join(root, "work");
288
- fs.mkdirSync(origin, { recursive: true });
289
- const g = (args: string[], at: string): void => {
290
- execFileSync("git", args, { cwd: at, stdio: "ignore" });
291
- };
292
- const write = (at: string, name: string, body: string): void => {
293
- fs.writeFileSync(path.join(at, name), body);
294
- g(["add", "-A"], at);
295
- g(["-c", "user.email=t@t", "-c", "user.name=t", "commit", "-m", name], at);
296
- };
297
- g(["init", "--bare", "-b", DEFAULT_BASE], origin);
298
- g(["init", "-b", DEFAULT_BASE, work], root);
299
- g(["remote", "add", "origin", origin], work);
300
- write(work, "a.txt", "one");
301
- g(["push", "-u", "origin", DEFAULT_BASE], work);
418
+ function buildTwoBaseFixture(root: string, serverDefault: string): string {
419
+ const { origin, work, g, write } = scaffold(root);
302
420
  g(["checkout", "-b", "develop"], work);
303
421
  g(["push", "-u", "origin", "develop"], work);
304
422
  g(["checkout", "-b", "feat/x"], work);
305
423
  write(work, "b.txt", "two");
424
+ // The bare repo's own HEAD is what `ls-remote --symref origin HEAD` reports,
425
+ // so this is the server-side default branch, not a local tracking ref.
426
+ g(["symbolic-ref", "HEAD", `refs/heads/${serverDefault}`], origin);
306
427
  // A second clone advances `develop` only — `main` stays exactly where it was.
307
428
  const other = path.join(root, "other");
308
429
  g(["clone", origin, other], root);
@@ -312,6 +433,23 @@ function buildTwoBaseFixture(root: string): string {
312
433
  return work;
313
434
  }
314
435
 
436
+ /**
437
+ * Strip everything that could point the child's `gh` at a real forge.
438
+ *
439
+ * `runGateCli` spawns with the full inherited environment, and since the
440
+ * two-base cases dropped `--base` the resolution itself is what they assert on.
441
+ * An ambient `GH_REPO` can make `gh pr list --head feat/x` answer about a real
442
+ * PR in someone else's repository and silently move the base under test — the
443
+ * same ambient-credential hazard the `forge` seam closed for the unit tests,
444
+ * which would otherwise have stayed live in the only coverage of the new
445
+ * symref resolution. `CI` would no-op every case green.
446
+ */
447
+ function isolateChildEnv(): void {
448
+ for (const key of ["CI", "GITHUB_ACTIONS", "GH_REPO", "GH_TOKEN", "GITHUB_TOKEN", "GH_HOST", "GH_CONFIG_DIR"]) {
449
+ delete process.env[key];
450
+ }
451
+ }
452
+
315
453
  export function selfTest(repoRoot: string): number {
316
454
  const root = fs.mkdtempSync(path.join(os.tmpdir(), "cbf-selftest-"));
317
455
  const run = (advanceRemote: boolean, onBase: boolean): number => {
@@ -320,17 +458,15 @@ export function selfTest(repoRoot: string): number {
320
458
  if (onBase) {
321
459
  execFileSync("git", ["checkout", DEFAULT_BASE], { cwd: work, stdio: "ignore" });
322
460
  }
323
- // The child must not inherit a CI flag, or every case would no-op green.
324
- delete process.env["CI"];
325
- delete process.env["GITHUB_ACTIONS"];
461
+ isolateChildEnv();
326
462
  return runGateCli(repoRoot, SCRIPT_REL, ["--quiet"], work);
327
463
  };
328
- const runTwoBase = (base: string): number => {
464
+ const runTwoBase = (serverDefault: string): number => {
329
465
  const dir = fs.mkdtempSync(path.join(root, "twobase-"));
330
- const work = buildTwoBaseFixture(dir);
331
- delete process.env["CI"];
332
- delete process.env["GITHUB_ACTIONS"];
333
- return runGateCli(repoRoot, SCRIPT_REL, ["--quiet", "--base", base], work);
466
+ const work = buildTwoBaseFixture(dir, serverDefault);
467
+ isolateChildEnv();
468
+ // No `--base`: the gate resolves it, which is the behaviour under test.
469
+ return runGateCli(repoRoot, SCRIPT_REL, ["--quiet"], work);
334
470
  };
335
471
  const cases: SelfTestCase[] = [
336
472
  {
@@ -339,12 +475,12 @@ export function selfTest(repoRoot: string): number {
339
475
  run: () => run(true, false),
340
476
  },
341
477
  {
342
- name: "a branch behind its REAL base (develop) is refused when that base is checked",
478
+ name: "the RESOLVED base decides: server default develop, branch behind it, REFUSED",
343
479
  expect: "reject",
344
480
  run: () => runTwoBase("develop"),
345
481
  },
346
482
  {
347
- name: "the same branch passes against main — the false green a hardcoded base produces",
483
+ name: "same tree, server default main — passes, so the resolution is what moved the verdict",
348
484
  expect: "accept",
349
485
  run: () => runTwoBase(DEFAULT_BASE),
350
486
  },
@@ -389,7 +525,19 @@ function scanReport(scanned: number, allowEmpty?: string): void {
389
525
  });
390
526
  }
391
527
 
392
- export function main(argv: string[] = process.argv.slice(2)): number {
528
+ /**
529
+ * @param forge Seam for the forge lookup. Production passes nothing and gets the
530
+ * real `gh`. Tests MUST pass a stub: without this parameter the only way to
531
+ * reach the forge-unavailable branch was to not have `gh` installed, so the test
532
+ * for it could only assert conditionally — i.e. it passed whether or not the
533
+ * warning was emitted. It also stopped every fixture test from spawning `gh`
534
+ * against ambient credentials, where a `GH_REPO` override could return a real
535
+ * PR base for a same-named branch and silently change the base under test.
536
+ */
537
+ export function main(
538
+ argv: string[] = process.argv.slice(2),
539
+ forge?: (b: string) => ForgeAnswer,
540
+ ): number {
393
541
  if (argv.includes("--self-test")) {
394
542
  return selfTest(path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", ".."));
395
543
  }
@@ -401,14 +549,21 @@ export function main(argv: string[] = process.argv.slice(2)): number {
401
549
  return 0;
402
550
  }
403
551
 
404
- const branch = git(["rev-parse", "--abbrev-ref", "HEAD"]);
552
+ // Census row B4. The file-based resolver is immune to an inherited `GIT_DIR`
553
+ // (hooks export one, and this gate runs at push time). Behaviour-identical
554
+ // here by construction: the guard below already collapses `null` and the
555
+ // detached-HEAD literal onto the same skip, and unresolved maps to `null`.
556
+ const branchField = workspaceIdentity().branch;
557
+ const branch = branchField.resolved ? branchField.value : null;
405
558
  if (branch === null || branch === "HEAD") {
406
559
  scanReport(0, "detached HEAD — there is no branch whose freshness could be asked");
407
560
  return 0;
408
561
  }
409
562
 
410
563
  // Resolved AFTER the branch is known: the PR's base is a fact about THIS branch.
411
- const resolved = resolveBase(argv, branch);
564
+ // `resolveBase`'s third parameter has a default, so passing `undefined`
565
+ // positionally already selects the real `gh` — no branch needed here.
566
+ const resolved = resolveBase(argv, branch, forge);
412
567
  const base = resolved.base;
413
568
 
414
569
  if (branch === base) {
@@ -26,6 +26,7 @@ import * as fs from 'node:fs';
26
26
  import * as path from 'node:path';
27
27
  import { fileURLToPath, pathToFileURL } from 'node:url';
28
28
 
29
+ import { workspaceIdentity } from './_lib/git_common_dir.js';
29
30
  import { assertWatchlistResolves, DeadScopeError } from './_lib/scan_scope.js';
30
31
 
31
32
  const SEMVER_RE = /^(\d+)\.(\d+)\.(\d+)(?:[-+].*)?$/;
@@ -65,8 +66,11 @@ function _on_main(): boolean {
65
66
  if (ref === 'refs/heads/main' || ref === 'refs/heads/master') {
66
67
  return true;
67
68
  }
68
- const [rc, head] = _git('rev-parse', '--abbrev-ref', 'HEAD');
69
- return rc === 0 && head === MAIN_BRANCH;
69
+ // Census row B5. Behaviour-identical: a detached HEAD used to compare the
70
+ // literal `HEAD` against MAIN_BRANCH and fail; unresolved fails the same
71
+ // way, without a subprocess an inherited `GIT_DIR` could redirect.
72
+ const branch = workspaceIdentity().branch;
73
+ return branch.resolved && branch.value === MAIN_BRANCH;
70
74
  }
71
75
 
72
76
  function _npm_latest(pkg: string): string | null {
@@ -21,6 +21,7 @@ import * as fs from 'node:fs';
21
21
  import * as path from 'node:path';
22
22
  import { fileURLToPath, pathToFileURL } from 'node:url';
23
23
 
24
+ import { workspaceIdentity } from './_lib/git_common_dir.js';
24
25
  import { assertScanned } from './_lib/scan_scope.js';
25
26
 
26
27
  const RELEASE_BRANCH_RE = /^release\/(\d+)\.(\d+)\.(\d+)$/;
@@ -37,8 +38,19 @@ function _git(...args: string[]): string {
37
38
  return (proc.stdout ?? '').trim();
38
39
  }
39
40
 
41
+ /**
42
+ * Census row B3 — one of four private `_current_branch` implementations that
43
+ * existed beside the shared, file-based one.
44
+ *
45
+ * This one spawned `rev-parse --abbrev-ref HEAD` with **no `cwd` and no env
46
+ * scrub**, so an inherited `GIT_DIR` answered about the hook's repository.
47
+ * The migration is behaviour-identical at the only call site: `main()` treats
48
+ * `'HEAD'` and `''` the same ("detached HEAD — gate skipped"), and the
49
+ * resolver's unresolved case maps onto `''`.
50
+ */
40
51
  function _current_branch(): string {
41
- return _git('rev-parse', '--abbrev-ref', 'HEAD');
52
+ const branch = workspaceIdentity().branch;
53
+ return branch.resolved ? branch.value : '';
42
54
  }
43
55
 
44
56
  function _parse_semver(text: string): SemVer | null {
@@ -148,7 +160,11 @@ function main(): number {
148
160
  gate: 'check_release_trunk_sync',
149
161
  scanned: 0,
150
162
  units: 'release-prep branch(es)',
151
- roots: ['git rev-parse --abbrev-ref HEAD'],
163
+ // Names what the gate actually reads. It stopped spawning
164
+ // `rev-parse --abbrev-ref HEAD` when B3 moved to the file-based
165
+ // resolver; a scan root naming a command the gate does not run is
166
+ // a false scope declaration.
167
+ roots: ['<git-dir>/HEAD via workspaceIdentity().branch'],
152
168
  allowEmpty:
153
169
  'OPTIONAL_INPUT: the checked-out branch is not release/X.Y.Z, so there is no ' +
154
170
  'target version to compare main against — an absent question, not an empty ' +
@@ -1450,7 +1450,31 @@ export interface ClaudePathsPlan {
1450
1450
  /** Patterns to emit under `paths:`, in declaration order. */
1451
1451
  globs: string[];
1452
1452
  /** Patterns deliberately not emitted, with the reason a reader can act on. */
1453
- dropped: { pattern: string; reason: 'unresolved-placeholder' | 'over-budget' }[];
1453
+ dropped: { pattern: string; reason: 'unresolved-placeholder' | 'over-budget' | 'mixed-triggers' }[];
1454
+ }
1455
+
1456
+ /**
1457
+ * Does this rule declare any trigger that is NOT path-shaped?
1458
+ *
1459
+ * Load-bearing for `_claude_paths_plan` below, because one `triggers:` list
1460
+ * means two different things on two hosts. Cursor and Windsurf treat globs as
1461
+ * ADDITIVE — a rule with empty globs still reaches the agent through its
1462
+ * description (see `_emit_cursor_mdc`'s own comment). Claude Code treats
1463
+ * `paths:` as EXCLUSIVE: with it the rule loads on a path match; without it the
1464
+ * rule loads unconditionally. The emitter fed both semantics from one list, so
1465
+ * a rule that declared keywords *and* one path glob silently became
1466
+ * path-gated-only on Claude.
1467
+ */
1468
+ export function _has_non_path_trigger(meta: Record<string, unknown>): boolean {
1469
+ const triggers = meta['triggers'];
1470
+ if (!Array.isArray(triggers)) return false;
1471
+ for (const t of triggers) {
1472
+ if (t === null || typeof t !== 'object' || Array.isArray(t)) continue;
1473
+ const obj = t as Record<string, unknown>;
1474
+ if (typeof obj['keyword'] === 'string' && obj['keyword']) return true;
1475
+ if (typeof obj['phrase'] === 'string' && obj['phrase']) return true;
1476
+ }
1477
+ return false;
1454
1478
  }
1455
1479
 
1456
1480
  /**
@@ -1470,6 +1494,31 @@ export function _claude_paths_plan(meta: Record<string, unknown>): ClaudePathsPl
1470
1494
  }
1471
1495
  const globs: string[] = [];
1472
1496
  const dropped: ClaudePathsPlan['dropped'] = [];
1497
+
1498
+ // A rule that ALSO declares keyword / phrase triggers gets no `paths:` at
1499
+ // all, and therefore keeps loading unconditionally on Claude Code.
1500
+ //
1501
+ // Emitting `paths:` here would narrow the rule to a path match and discard
1502
+ // every keyword the author wrote, because this host reads the list as the
1503
+ // whole gate rather than as one way in. Measured before this guard: 25 of
1504
+ // 110 projected rules carried `paths:` and 19 of them also carried keyword
1505
+ // triggers — `design-fidelity` lost 21 of them, in exactly the pasted-
1506
+ // screenshot and capability-URL handovers its own routing section names as
1507
+ // the classes it exists to catch. Nothing in the tree recorded a decision
1508
+ // to make that trade, which is why it is treated as a defect.
1509
+ //
1510
+ // The drop is REPORTED rather than silent, so a rule whose obligation
1511
+ // really is path-bound is visible as a candidate for removing its keyword
1512
+ // triggers — the narrowing then becomes an authoring decision instead of an
1513
+ // emitter side effect. Cursor and Windsurf are untouched: globs are
1514
+ // additive there, so the same rule keeps both routes.
1515
+ if (_has_non_path_trigger(meta)) {
1516
+ for (const raw of derive_trigger_globs(meta)) {
1517
+ dropped.push({ pattern: raw, reason: 'mixed-triggers' });
1518
+ }
1519
+ return { globs, dropped };
1520
+ }
1521
+
1473
1522
  let spent = 0;
1474
1523
  for (const raw of derive_trigger_globs(meta)) {
1475
1524
  if (_is_unresolved_placeholder(raw)) {
@@ -222,13 +222,23 @@ export const REVIEW_SCOPE_NAME_ONLY_FLAGS: readonly string[] = [
222
222
  '-O/dev/null',
223
223
  ];
224
224
 
225
- /** `git diff` argv for the review scope (patch body), config-pinned. */
226
- export function reviewScopeDiffArgs(base: string): string[] {
225
+ /**
226
+ * `git diff` argv for the review scope (patch body), config-pinned.
227
+ *
228
+ * `head` defaults to `HEAD` — the only value the dispatcher and the validator
229
+ * ever pass, since both bind the scope being dispatched *now*. It is a
230
+ * parameter so that an out-of-band measurement can re-derive the scope at a
231
+ * HISTORICAL revision through this same definition instead of assembling a
232
+ * second `git diff` argv beside it: a private copy would drift from the
233
+ * normative flag list above the first time a flag changes, and then report a
234
+ * hash no gate would ever produce.
235
+ */
236
+ export function reviewScopeDiffArgs(base: string, head = 'HEAD'): string[] {
227
237
  return [
228
238
  ...REVIEW_SCOPE_GIT_CONFIG,
229
239
  'diff',
230
240
  ...REVIEW_SCOPE_DIFF_FLAGS,
231
- `${base}...HEAD`,
241
+ `${base}...${head}`,
232
242
  '--',
233
243
  ':/',
234
244
  ...REVIEW_SCOPE_EXCLUDES,
@@ -236,12 +246,12 @@ export function reviewScopeDiffArgs(base: string): string[] {
236
246
  }
237
247
 
238
248
  /** `git diff --name-only` argv for the review scope (changed-file list), config-pinned. */
239
- export function reviewScopeNameOnlyArgs(base: string): string[] {
249
+ export function reviewScopeNameOnlyArgs(base: string, head = 'HEAD'): string[] {
240
250
  return [
241
251
  ...REVIEW_SCOPE_GIT_CONFIG,
242
252
  'diff',
243
253
  ...REVIEW_SCOPE_NAME_ONLY_FLAGS,
244
- `${base}...HEAD`,
254
+ `${base}...${head}`,
245
255
  '--',
246
256
  ':/',
247
257
  ...REVIEW_SCOPE_EXCLUDES,
@@ -29,6 +29,8 @@ import * as path from 'node:path';
29
29
  import process from 'node:process';
30
30
  import { fileURLToPath, pathToFileURL } from 'node:url';
31
31
 
32
+ import { workspaceIdentity } from './_lib/git_common_dir.js';
33
+
32
34
  // argparse `prog` is hardcoded to "evidence_report.py" in the retired Python implementation,
33
35
  // so usage strings stay byte-identical regardless of the `.py`/`.ts` filename.
34
36
  const PROG = 'evidence_report.py';
@@ -39,15 +41,24 @@ const DESCRIPTION = 'Evidence Report template automation.';
39
41
  // Repo-root resolution
40
42
  // ---------------------------------------------------------------------------
41
43
 
42
- /** Return repo root via git, falling back to relative-path heuristic. */
44
+ /**
45
+ * Return repo root, falling back to the relative-path heuristic.
46
+ *
47
+ * Census row R4. The previous implementation spawned
48
+ * `git rev-parse --show-toplevel` with **no `cwd` and no env scrub**, so an
49
+ * inherited `GIT_DIR` (every git hook exports one) silently answered about the
50
+ * hook's repository instead of this one. `workspaceIdentity()` reads files, so
51
+ * there is no child process to redirect. The fallback is unchanged, and in a
52
+ * plain invocation both forms return the same path.
53
+ */
43
54
  function _repo_root(): string {
44
- const result = spawnSync('git', ['rev-parse', '--show-toplevel'], { encoding: 'utf-8' });
45
- if (result.error || result.status !== 0 || typeof result.stdout !== 'string') {
55
+ const root = workspaceIdentity().repoRoot;
56
+ if (!root.resolved) {
46
57
  // Script lives at src/scripts/; repo root is two levels up.
47
58
  const here = fileURLToPath(import.meta.url);
48
59
  return path.resolve(path.dirname(here), '..', '..');
49
60
  }
50
- return result.stdout.trim();
61
+ return root.value;
51
62
  }
52
63
 
53
64
  function _git_head_short(): string {