@event4u/agent-config 14.6.0 → 14.8.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 (258) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/CHANGELOG.md +255 -145
  3. package/README.md +1 -1
  4. package/dist/agent-src/commands/analyze/inbox.md +264 -10
  5. package/dist/agent-src/commands/analyze.md +1 -1
  6. package/dist/agent-src/commands/evals/git-pr-merge.json +13 -0
  7. package/dist/agent-src/commands/evals/roadmap.json +17 -1
  8. package/dist/agent-src/commands/pr/create.md +14 -2
  9. package/dist/agent-src/commands/pr/merge.md +379 -0
  10. package/dist/agent-src/commands/roadmap/process-full.md +272 -32
  11. package/dist/agent-src/commands/roadmap.md +1 -1
  12. package/dist/agent-src/contexts/execution/auto-dispatch-classification.md +46 -36
  13. package/dist/agent-src/contexts/execution/autonomy-mechanics.md +37 -2
  14. package/dist/agent-src/contexts/execution/roadmap-execution-contract.md +95 -2
  15. package/dist/agent-src/contexts/execution/roadmap-process-loop.md +289 -106
  16. package/dist/agent-src/rules/artifact-drafting-protocol.md +5 -1
  17. package/dist/agent-src/rules/context-hygiene.md +2 -0
  18. package/dist/agent-src/rules/decision-revisit-gate.md +67 -32
  19. package/dist/agent-src/rules/fix-what-you-see.md +108 -0
  20. package/dist/agent-src/rules/no-roadmap-references.md +0 -5
  21. package/dist/agent-src/rules/recurring-criticism.md +110 -0
  22. package/dist/agent-src/rules/roadmap-progress-sync.md +20 -1
  23. package/dist/agent-src/rules/rule-type-governance.md +0 -2
  24. package/dist/agent-src/rules/self-repair-loop.md +17 -0
  25. package/dist/agent-src/rules/skill-quality.md +0 -2
  26. package/dist/agent-src/rules/source-confidentiality.md +37 -7
  27. package/dist/agent-src/scripts/archival_sweep.ts +117 -0
  28. package/dist/agent-src/scripts/archive_completed_roadmaps.ts +26 -7
  29. package/dist/agent-src/scripts/dashboard_mode.ts +212 -0
  30. package/dist/agent-src/scripts/gate_budget.ts +282 -0
  31. package/dist/agent-src/scripts/gate_execute.ts +72 -31
  32. package/dist/agent-src/scripts/roadmap_gates.ts +74 -3
  33. package/dist/agent-src/scripts/update_roadmap_progress.ts +296 -53
  34. package/dist/agent-src/skills/adr-create/SKILL.md +124 -5
  35. package/dist/agent-src/skills/ai-council/SKILL.md +48 -807
  36. package/dist/agent-src/skills/ai-council/references/advanced-modes.md +320 -0
  37. package/dist/agent-src/skills/ai-council/references/cost-and-redaction.md +99 -0
  38. package/dist/agent-src/skills/ai-council/references/execution-modes.md +162 -0
  39. package/dist/agent-src/skills/ai-council/references/output-and-synthesis.md +125 -0
  40. package/dist/agent-src/skills/ai-council/references/procedure.md +143 -0
  41. package/dist/agent-src/skills/decision-record/SKILL.md +61 -3
  42. package/dist/agent-src/skills/decision-review/SKILL.md +65 -0
  43. package/dist/agent-src/skills/merge-conflicts/SKILL.md +26 -0
  44. package/dist/agent-src/skills/quality-tools/SKILL.md +34 -326
  45. package/dist/agent-src/skills/quality-tools/references/js-ts-tools.md +113 -0
  46. package/dist/agent-src/skills/quality-tools/references/php-tools.md +222 -0
  47. package/dist/agent-src/skills/roadmap-management/SKILL.md +15 -409
  48. package/dist/agent-src/skills/roadmap-management/references/archival.md +226 -0
  49. package/dist/agent-src/skills/roadmap-management/references/authoring.md +107 -0
  50. package/dist/agent-src/skills/roadmap-management/references/dashboard.md +47 -0
  51. package/dist/agent-src/skills/roadmap-management/references/lifecycle.md +58 -0
  52. package/dist/agent-src/skills/security/SKILL.md +8 -0
  53. package/dist/agent-src/skills/security-audit/SKILL.md +12 -1
  54. package/dist/agent-src/skills/skill-writing/SKILL.md +34 -570
  55. package/dist/agent-src/skills/skill-writing/references/procedure.md +275 -0
  56. package/dist/agent-src/skills/skill-writing/references/read-only-scripts.md +18 -0
  57. package/dist/agent-src/skills/skill-writing/references/section-patterns.md +298 -0
  58. package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
  59. package/dist/agent-src/templates/roadmaps.md +65 -4
  60. package/dist/agent-src/templates/scripts/telemetry/flush_sender.mjs +163 -0
  61. package/dist/agent-src/templates/scripts/telemetry/remote.ts +160 -7
  62. package/dist/agent-src/templates/scripts/telemetry/transport.ts +169 -0
  63. package/dist/cli/registry.js +2 -1
  64. package/dist/cli/registry.js.map +1 -1
  65. package/dist/cli-delegate/chunk-2GNWGMAL.js +172 -0
  66. package/dist/cli-delegate/{chunk-P6LQDIWK.js → chunk-7PRE6ZV2.js} +338 -0
  67. package/dist/cli-delegate/{chunk-BKO2AZ7Z.js → chunk-B2ERU2VA.js} +1 -1
  68. package/dist/cli-delegate/chunk-E4XIZDIB.js +402 -0
  69. package/dist/cli-delegate/{chunk-3UCJWMMP.js → chunk-EEJUMCDZ.js} +16 -16
  70. package/dist/cli-delegate/{chunk-NXFWFAYV.js → chunk-G23AT6GD.js} +1 -0
  71. package/dist/cli-delegate/{chunk-XURJ4AQC.js → chunk-H4VIVD4C.js} +385 -284
  72. package/dist/cli-delegate/chunk-I3EIN2YG.js +36 -0
  73. package/dist/cli-delegate/{chunk-RLN73PR5.js → chunk-LPPWO5CE.js} +12 -8
  74. package/dist/cli-delegate/chunk-MWRD5YOH.js +100 -0
  75. package/dist/cli-delegate/{chunk-UEZ7D35A.js → chunk-N7CDDGI3.js} +4 -23
  76. package/dist/cli-delegate/{chunk-63WSP6YZ.js → chunk-PMEKGDED.js} +74 -167
  77. package/dist/cli-delegate/{chunk-GRRPEPRN.js → chunk-T7L2QK3K.js} +24 -5
  78. package/dist/cli-delegate/{chunk-EVTY7ISR.js → chunk-YM4GZDJG.js} +9 -1
  79. package/dist/cli-delegate/cmd_adr_effective.js +1162 -0
  80. package/dist/cli-delegate/cmd_conformance.js +466 -716
  81. package/dist/cli-delegate/cmd_converge.js +3 -3
  82. package/dist/cli-delegate/cmd_doctor.js +10 -10
  83. package/dist/cli-delegate/cmd_explain.js +1 -1
  84. package/dist/cli-delegate/cmd_export.js +10 -9
  85. package/dist/cli-delegate/cmd_handoff.js +10 -9
  86. package/dist/cli-delegate/cmd_memory_get.js +2 -1
  87. package/dist/cli-delegate/cmd_migrate.js +2 -2
  88. package/dist/cli-delegate/cmd_packs_active.js +1 -1
  89. package/dist/cli-delegate/cmd_preflight.js +5 -5
  90. package/dist/cli-delegate/cmd_prune.js +11 -10
  91. package/dist/cli-delegate/cmd_route_audit.js +6 -5
  92. package/dist/cli-delegate/cmd_route_explain.js +3 -3
  93. package/dist/cli-delegate/cmd_session_recycle.js +840 -30
  94. package/dist/cli-delegate/cmd_settings_get.js +3 -3
  95. package/dist/cli-delegate/cmd_settings_migrate.js +10 -9
  96. package/dist/cli-delegate/cmd_settings_set.js +1 -1
  97. package/dist/cli-delegate/cmd_sync.js +11 -10
  98. package/dist/cli-delegate/cmd_uninstall.js +13 -11
  99. package/dist/cli-delegate/cmd_update.js +4 -4
  100. package/dist/cli-delegate/cmd_upgrade.js +4 -4
  101. package/dist/cli-delegate/cmd_validate.js +11 -10
  102. package/dist/cli-delegate/cmd_versions.js +3 -3
  103. package/dist/discovery/deprecation-report.md +1 -1
  104. package/dist/discovery/discovery-manifest.json +111 -37
  105. package/dist/discovery/discovery-manifest.json.sha256 +1 -1
  106. package/dist/discovery/discovery-manifest.summary.md +5 -5
  107. package/dist/discovery/dormancy-report.md +1 -1
  108. package/dist/discovery/orphan-report.md +1 -1
  109. package/dist/discovery/packs.json +15 -12
  110. package/dist/discovery/trust-report.md +3 -3
  111. package/dist/discovery/workspaces.json +11 -8
  112. package/dist/hooks/dispatch.js +3165 -2192
  113. package/dist/install/claudePathsPlan.js +187 -0
  114. package/dist/install/claudePathsPlan.js.map +1 -0
  115. package/dist/install/globalRuleLayers.js +169 -0
  116. package/dist/install/globalRuleLayers.js.map +1 -0
  117. package/dist/install/hostLayerFingerprint.js +136 -0
  118. package/dist/install/hostLayerFingerprint.js.map +1 -0
  119. package/dist/install/install.mjs +553 -425
  120. package/dist/install/partitionEligibility.js +432 -0
  121. package/dist/install/partitionEligibility.js.map +1 -0
  122. package/dist/install/ruleLayerPartition.js +157 -0
  123. package/dist/install/ruleLayerPartition.js.map +1 -0
  124. package/dist/mcp/registry-manifest.json +3 -3
  125. package/dist/mcp/server.json +2 -2
  126. package/dist/mcp/server.mjs +1863 -1234
  127. package/dist/router.json +1 -1
  128. package/dist/scripts/_lib/agent_src.js +41 -0
  129. package/dist/scripts/_lib/agent_src.js.map +1 -1
  130. package/dist/scripts/_lib/install_layout.js +83 -0
  131. package/dist/scripts/_lib/install_layout.js.map +1 -0
  132. package/dist/scripts/_lib/installed_lock.js +444 -0
  133. package/dist/scripts/_lib/installed_lock.js.map +1 -0
  134. package/dist/scripts/_lib/prune_empty_dirs.js +85 -0
  135. package/dist/scripts/_lib/prune_empty_dirs.js.map +1 -0
  136. package/dist/scripts/condense.js +63 -251
  137. package/dist/scripts/condense.js.map +1 -1
  138. package/dist/server/schemas/settings.js +4 -0
  139. package/dist/server/schemas/settings.js.map +1 -1
  140. package/docs/contracts/settings-classes.md +12 -6
  141. package/docs/guidelines/agent-infra/agent-interaction-and-decision-quality.md +8 -3
  142. package/docs/guidelines/agent-infra/artifact-drafting-protocol-mechanics.md +45 -1
  143. package/docs/guidelines/agent-infra/context-hygiene-mechanics.md +26 -0
  144. package/docs/guidelines/agent-infra/false-green.md +47 -1
  145. package/docs/guidelines/agent-infra/memory-access.md +17 -0
  146. package/docs/guidelines/agent-infra/recurring-criticism-mechanics.md +96 -0
  147. package/package.json +1 -1
  148. package/src/agent-src/scripts/archival_sweep.ts +117 -0
  149. package/src/agent-src/scripts/archive_completed_roadmaps.ts +26 -7
  150. package/src/agent-src/scripts/dashboard_mode.ts +212 -0
  151. package/src/agent-src/scripts/gate_budget.ts +282 -0
  152. package/src/agent-src/scripts/gate_execute.ts +72 -31
  153. package/src/agent-src/scripts/roadmap_gates.ts +74 -3
  154. package/src/agent-src/scripts/update_roadmap_progress.ts +296 -53
  155. package/src/agent-src/templates/scripts/telemetry/flush_sender.mjs +163 -0
  156. package/src/agent-src/templates/scripts/telemetry/remote.ts +160 -7
  157. package/src/agent-src/templates/scripts/telemetry/transport.ts +169 -0
  158. package/src/config/agent-settings.template.yml +27 -2
  159. package/src/config/ci-local-parity.yml +39 -0
  160. package/src/config/estate-count-budget.json +612 -3
  161. package/src/config/evaluator-budgets.json +3 -3
  162. package/src/config/gate-coverage.yml +220 -12
  163. package/src/config/gate-violation-baselines.json +73 -63
  164. package/src/config/gitignore-block.txt +15 -2
  165. package/src/config/hook-latency-budget.json +5 -0
  166. package/src/config/rule-activation-census.json +14 -6
  167. package/src/config/rule-stub-ceilings.json +34 -7
  168. package/src/install/claudePathsPlan.ts +196 -0
  169. package/src/install/globalRuleLayers.ts +197 -0
  170. package/src/install/hostLayerFingerprint.ts +145 -0
  171. package/src/install/partitionEligibility.ts +502 -0
  172. package/src/install/ruleLayerPartition.ts +212 -0
  173. package/src/scripts/_cli/cmd_adr_effective.ts +484 -0
  174. package/src/scripts/_cli/cmd_session_recycle.ts +9 -1
  175. package/src/scripts/_dispatch.bash +17 -1
  176. package/src/scripts/_lib/ac_heading.ts +30 -0
  177. package/src/scripts/_lib/adr_frontmatter.test.ts +365 -0
  178. package/src/scripts/_lib/adr_frontmatter.ts +459 -0
  179. package/src/scripts/_lib/agent_src.ts +42 -0
  180. package/src/scripts/_lib/bench_ab_pinned_repo.ts +162 -0
  181. package/src/scripts/_lib/bench_ab_workspace.ts +10 -1
  182. package/src/scripts/_lib/envelope_grounding.ts +49 -9
  183. package/src/scripts/_lib/installed_lock.ts +50 -5
  184. package/src/scripts/_lib/layer_overlap_notice.ts +11 -2
  185. package/src/scripts/_lib/md_section.ts +129 -0
  186. package/src/scripts/_lib/prune_empty_dirs.ts +80 -0
  187. package/src/scripts/_lib/scope_guard.sh +26 -3
  188. package/src/scripts/_lib/self_repair.ts +19 -0
  189. package/src/scripts/_lib/self_repair_class_b.ts +171 -0
  190. package/src/scripts/adr/evidence_census.ts +899 -0
  191. package/src/scripts/adr/regenerate_index.ts +12 -32
  192. package/src/scripts/adr_cite_check.ts +540 -39
  193. package/src/scripts/ai_council/cli_help.ts +55 -0
  194. package/src/scripts/audit_adr_coverage.ts +103 -17
  195. package/src/scripts/before_complete_hook.ts +153 -28
  196. package/src/scripts/bench_ab_v2_complexity.ts +27 -7
  197. package/src/scripts/bench_hook_latency.ts +204 -0
  198. package/src/scripts/build_archive_index.ts +34 -0
  199. package/src/scripts/check_adr_frontmatter.ts +574 -39
  200. package/src/scripts/check_hook_bundle_content.ts +219 -0
  201. package/src/scripts/check_new_adr_evidence.ts +529 -0
  202. package/src/scripts/check_references.ts +78 -0
  203. package/src/scripts/check_release_published.ts +32 -11
  204. package/src/scripts/check_rule_layer_partition.ts +531 -0
  205. package/src/scripts/check_single_delivery.ts +194 -17
  206. package/src/scripts/check_standing_rule_delivery.ts +8 -2
  207. package/src/scripts/ci_settle.ts +170 -0
  208. package/src/scripts/condense.ts +79 -259
  209. package/src/scripts/council_cli.ts +3 -3
  210. package/src/scripts/dispatch_r2_reviewer.ts +6 -14
  211. package/src/scripts/external_sources_denylist.json +4 -1
  212. package/src/scripts/hook_manifest.json +1 -1
  213. package/src/scripts/hook_manifest.yaml +77 -14
  214. package/src/scripts/hooks/block_no_verify.ts +61 -10
  215. package/src/scripts/hooks/block_unauthorized_git.ts +21 -1
  216. package/src/scripts/hooks/concern_registry.ts +4 -0
  217. package/src/scripts/hooks/dispatch_hook.ts +154 -187
  218. package/src/scripts/hooks/dispatch_issues.ts +38 -19
  219. package/src/scripts/hooks/fallback_yaml.ts +66 -0
  220. package/src/scripts/hooks/host_semantics.ts +21 -0
  221. package/src/scripts/hooks/injection_budget.ts +13 -3
  222. package/src/scripts/hooks/py_json_dumps.ts +103 -0
  223. package/src/scripts/hooks/rtk_wrap_hook.ts +8 -3
  224. package/src/scripts/hooks/run_continuation_hook.ts +8 -5
  225. package/src/scripts/hooks/ship_diff_volume_hook.ts +81 -15
  226. package/src/scripts/hooks/source_first_gate_hook.ts +464 -0
  227. package/src/scripts/hooks/state_io.ts +870 -21
  228. package/src/scripts/hooks/stdin_failure_policy.ts +96 -0
  229. package/src/scripts/hooks/subagent_ledger_hook.ts +44 -15
  230. package/src/scripts/hooks/telemetry_flush_hook.ts +139 -0
  231. package/src/scripts/hooks/telemetry_self_repair.ts +128 -0
  232. package/src/scripts/hooks/telemetry_usage_hook.ts +22 -4
  233. package/src/scripts/hooks/turn_end_gate_hook.ts +81 -15
  234. package/src/scripts/injection_scan_hook.ts +137 -5
  235. package/src/scripts/install.ts +16 -11
  236. package/src/scripts/language_mirror_hook.ts +561 -25
  237. package/src/scripts/lint_framework_leakage_allowlist.json +2 -2
  238. package/src/scripts/lint_hook_manifest.ts +49 -0
  239. package/src/scripts/lint_plan_risk_register.ts +4 -3
  240. package/src/scripts/lint_provenance_vocabulary.ts +472 -4
  241. package/src/scripts/lint_skill_router_head.ts +4 -4
  242. package/src/scripts/mcp_server/tools.ts +2 -2
  243. package/src/scripts/new_roadmap.ts +136 -0
  244. package/src/scripts/pack_mcp_content.ts +20 -5
  245. package/src/scripts/release.ts +37 -38
  246. package/src/scripts/report_layer_overlap.ts +6 -0
  247. package/src/scripts/routing_doctor.ts +5 -1
  248. package/src/scripts/self_repair_hook.ts +37 -5
  249. package/src/scripts/session_register_hook.ts +11 -0
  250. package/src/scripts/skill_usage_collect.ts +11 -1
  251. package/src/scripts/skill_usage_report.ts +278 -8
  252. package/src/scripts/sync_pr_branch.ts +170 -20
  253. package/src/server/schemas/settings.ts +8 -0
  254. package/dist/cli-delegate/chunk-EJITJBJX.js +0 -172
  255. package/dist/cli-delegate/chunk-PZWRPRWP.js +0 -38
  256. package/dist/cli-delegate/{chunk-PPQO4NGR.js → chunk-6RJA2F5A.js} +3 -3
  257. package/dist/cli-delegate/{chunk-RYNADUFG.js → chunk-LWTYTX6F.js} +0 -0
  258. package/dist/cli-delegate/{chunk-FBKL6JWT.js → chunk-MMSGVZKV.js} +3 -3
@@ -88,9 +88,9 @@
88
88
  "reason": "Quoted anti-example ('❌ Describing how Laravel middleware works') — the line teaches what NOT to write."
89
89
  },
90
90
  {
91
- "file": "src/skills/skill-writing/SKILL.md",
91
+ "file": "src/skills/skill-writing/references/procedure.md",
92
92
  "anchor": "Use when creating Laravel middleware for request filtering",
93
- "reason": "Quoted description examples + the 'too-broad skill (Laravel skill)' anti-pattern — meta content."
93
+ "reason": "Quoted description examples + the 'too-broad skill (Laravel skill)' anti-pattern — meta content. Repointed 2026-08-20: the K6 router-head retrofit moved the quoted description examples out of the head into references/procedure.md, so the anchor no longer matched its file and the entry exempted nothing. The content is unchanged and still meta."
94
94
  },
95
95
  {
96
96
  "file": "src/skills/supply-chain-intake/SKILL.md",
@@ -135,6 +135,7 @@ function _check_concerns(manifest: YamlObject, errors: string[]): Set<string> {
135
135
  return new Set();
136
136
  }
137
137
  const names = new Set<string>();
138
+ const nudgeRanks = new Map<number, string[]>();
138
139
  for (const [name, spec] of Object.entries(concernsRaw)) {
139
140
  if (!isObject(spec)) {
140
141
  errors.push(
@@ -213,8 +214,56 @@ function _check_concerns(manifest: YamlObject, errors: string[]): Set<string> {
213
214
  }
214
215
  }
215
216
  }
217
+ // `nudge_rank:` — the nudge-exclusivity ordering `injection_budget`
218
+ // arbitrates on. Validated here because `injection_budget._selectNudge`
219
+ // says so in as many words: "a tie is a manifest defect rather than a
220
+ // runtime one, and `lint_hook_manifest` is where it should eventually be
221
+ // caught — that check does not exist yet and this comment is the honest
222
+ // statement of the gap". This is that check.
223
+ //
224
+ // A tie is not a crash. `_selectNudge` keeps the lowest rank and breaks
225
+ // ties on concern name, so a collision resolves stably and arbitrarily:
226
+ // one nudge silently wins forever, and the author who added the second one
227
+ // gets no signal that their concern can never emit. That is the failure
228
+ // mode — silent, not loud.
229
+ if ("nudge_rank" in spec) {
230
+ const rank = spec["nudge_rank"];
231
+ if (typeof rank !== "number" || !Number.isInteger(rank) || rank < 1) {
232
+ errors.push(
233
+ `concerns.${name}: 'nudge_rank' must be a positive integer ` +
234
+ `(got ${pyTypeName(rank)}) — the dispatcher keeps the LOWEST rank, ` +
235
+ `so a malformed value reads as "never wins" rather than as an error`,
236
+ );
237
+ } else {
238
+ const seen = nudgeRanks.get(rank);
239
+ if (seen) {
240
+ seen.push(name);
241
+ } else {
242
+ nudgeRanks.set(rank, [name]);
243
+ }
244
+ }
245
+ }
216
246
  names.add(name);
217
247
  }
248
+ // Uniqueness is enforced GLOBALLY, not per event, and that is a deliberate
249
+ // choice rather than an accident of what is easy to compute. `_selectNudge`
250
+ // compares candidates within one dispatch, so two concerns bound to disjoint
251
+ // events could share a rank harmlessly and the accurate invariant is
252
+ // per-event. Global uniqueness is the strictly-safe superset: it costs
253
+ // nothing at today's two declared ranks, and an author reusing a rank across
254
+ // events is exactly the confusion worth refusing. If a legitimate design
255
+ // ever needs the same rank on two events, NARROW this to concerns sharing at
256
+ // least one bound event — do not delete it.
257
+ for (const [rank, holders] of nudgeRanks) {
258
+ if (holders.length > 1) {
259
+ errors.push(
260
+ `concerns: nudge_rank ${rank} is declared by ${holders.length} concerns ` +
261
+ `(${holders.slice().sort().join(", ")}) — ranks must be unique, because ` +
262
+ `the dispatcher breaks a tie on concern name and the loser can then ` +
263
+ `never emit`,
264
+ );
265
+ }
266
+ }
218
267
  return names;
219
268
  }
220
269
 
@@ -39,6 +39,7 @@ import { fileURLToPath, pathToFileURL } from 'node:url';
39
39
 
40
40
  import { parse as parseYaml } from 'yaml';
41
41
 
42
+ import { isAcceptanceCriteriaHeading } from './_lib/ac_heading.js';
42
43
  import { workspaceIdentity } from './_lib/git_common_dir.js';
43
44
  import { gitEnv } from './_lib/git_env.js';
44
45
  import { splitMarkdownRow } from './_lib/md_table.js';
@@ -52,7 +53,7 @@ import { assertScanned, DeadScopeError } from './_lib/scan_scope.js';
52
53
  */
53
54
  export const RISK_REGISTER_GATE_ACTIVATION = '2026-08-04';
54
55
 
55
- const MARKER_RE =
56
+ export const MARKER_RE =
56
57
  /^<!--\s*risk-review:\s*v1\s*\|\s*reviewed:\s*(\d{4}-\d{2}-\d{2})\s*\|\s*reviewer:\s*(.+?)\s*-->\s*$/;
57
58
  const HONEST_NULL_RE = /^\*\*Honest-null:\*\*(.*)$/;
58
59
  const CHECKBOX_RE = /^\s*- \[( |x|~|-)\]/;
@@ -72,7 +73,7 @@ export interface PlanFeatures {
72
73
  phaseHeadings: string[];
73
74
  /** Total count of deliverable checkbox lines (state-insensitive). */
74
75
  checkboxCount: number;
75
- /** sha256 of the `## Acceptance Criteria` section body (checkbox state normalized). */
76
+ /** sha256 of the acceptance-criteria section body (heading matched per `_lib/ac_heading`; checkbox state normalized). */
76
77
  acHash: string;
77
78
  }
78
79
 
@@ -115,7 +116,7 @@ export function extractFeatures(text: string): PlanFeatures {
115
116
  if (CHECKBOX_RE.test(entry.line)) {
116
117
  checkboxCount += 1;
117
118
  }
118
- if (/^##\s+Acceptance Criteria\s*$/.test(entry.line)) {
119
+ if (isAcceptanceCriteriaHeading(entry.line)) {
119
120
  acStart = i;
120
121
  }
121
122
  }
@@ -26,18 +26,42 @@
26
26
  * 3. Number cross-check — every N/D figure inside a scope box MUST also
27
27
  * appear, verbatim, somewhere in `docs/CLAIMS.md` — a box number that
28
28
  * drifts from the ledger is a build failure, not a doc nit.
29
+ * 4. Permanence language — an ADR record MUST NOT assert that its decision
30
+ * is permanent in a load-bearing position (its `# ` title, its
31
+ * `decision:` slug, its `## Decision` section, an `Addendum`). ADR-239
32
+ * § 6 is the doctrine and ADR-208 the standing demonstration: its title
33
+ * says the tree is kept forever, its Decision says KEEP permanently, and
34
+ * its own frontmatter carries the conditions under which it is reopened.
35
+ * A record cannot be both. Two escapes, both enforced here: permanence
36
+ * scoped to an EXTERNAL invariant with the condition under which that
37
+ * invariant stops applying stated in the same section, or an owner
38
+ * purpose statement recorded as `authority_basis: owner_intent`.
39
+ * Describing positions — `## Alternatives considered`, `## Consequences`,
40
+ * `## Context`, an `## Amendment`, a quoted open question, a table
41
+ * rationale cell — are NOT violations.
29
42
  *
30
43
  * Scan surfaces: README.md + every `docs/**\/*.md` file (recursive). No
31
44
  * marketing/install-output text file carries this vocabulary today — this is
32
- * the single place to widen SCAN_ROOTS when one does.
45
+ * the single place to widen SCAN_ROOTS when one does. Rule 4 adds no root: it
46
+ * filters the already-walked `docs/**` set down to ADR records
47
+ * ({@link isAdrRecord}) rather than widening the walk, which is why it lives
48
+ * here instead of in a new gate script (a new gate costs seven wiring
49
+ * surfaces for the same coverage).
33
50
  *
34
- * Exit codes: 0 clean · 1 violations found.
51
+ * Two verdicts. Rules 1-3 hard-fail. Rule 4 is a RATCHET against
52
+ * `lint_provenance_vocabulary:permanence-language` in
53
+ * `src/config/gate-violation-baselines.json`, because the corpus carries real
54
+ * violations today and a gate that lands as five instant blockers is a shape
55
+ * this repository has already refused. A new record still hard-fails.
56
+ *
57
+ * Exit codes: 0 clean · 1 violations found (either verdict).
35
58
  */
36
59
 
37
60
  import * as fs from 'node:fs';
38
61
  import * as path from 'node:path';
39
62
  import { fileURLToPath, pathToFileURL } from 'node:url';
40
63
 
64
+ import { checkRatchet } from './_lib/gate_baseline.js';
41
65
  import { assertScanned, DeadScopeError } from './_lib/scan_scope.js';
42
66
 
43
67
  const _HERE = fileURLToPath(import.meta.url);
@@ -362,6 +386,387 @@ export function lintFile(text: string, file: string, claimsText: string): Violat
362
386
  ];
363
387
  }
364
388
 
389
+ // ---------------------------------------------------------------------------
390
+ // 4. Permanence language in ADR records
391
+ // ---------------------------------------------------------------------------
392
+
393
+ /**
394
+ * ADR record roots.
395
+ *
396
+ * Both already sit under `docs`, which {@link SCAN_ROOTS} walks — these roots
397
+ * do NOT widen the walk, they IDENTIFY which of the already-read files is an
398
+ * ADR record. Every other `docs/**` file is untouched by this rule.
399
+ */
400
+ export const ADR_RECORD_ROOTS: readonly string[] = ['docs/decisions', 'docs/adrs'];
401
+
402
+ /** Ratchet key in `src/config/gate-violation-baselines.json`. */
403
+ export const PERMANENCE_GATE = 'lint_provenance_vocabulary:permanence-language';
404
+
405
+ /**
406
+ * True for a flat `docs/decisions/ADR-*.md` record or a per-area
407
+ * `docs/adrs/<area>/<n>-*.md` record. A per-area `README.md` is an index, not
408
+ * a record, and is excluded on both surfaces — so are the non-ADR documents
409
+ * that share `docs/decisions/` (sweep artifacts, reclassification logs).
410
+ */
411
+ export function isAdrRecord(rel: string): boolean {
412
+ const posix = rel.split(path.sep).join('/');
413
+ const base = posix.slice(posix.lastIndexOf('/') + 1);
414
+ if (!base.endsWith('.md') || base === 'README.md') return false;
415
+ if (posix.startsWith('docs/decisions/')) return /^ADR-/.test(base);
416
+ if (posix.startsWith('docs/adrs/')) return /^\d/.test(base);
417
+ return false;
418
+ }
419
+
420
+ /**
421
+ * The four positions where a permanence claim is LOAD-BEARING — the record is
422
+ * asserting permanence rather than describing, historicising, or rejecting it.
423
+ *
424
+ * - `title` — the `# ` heading; it is the record's name in every citation.
425
+ * - `slug` — the `decision:` frontmatter value; machine-visible, so a
426
+ * permanence claim there is carried by every tool that reads the field.
427
+ * - `decision` — the `## Decision` section's own prose.
428
+ * - `addendum` — an `Addendum` heading at any level. Both corpus instances
429
+ * (ADR-122, ADR-124) assert a permanently-off default, which is the same
430
+ * claim shape as ADR-208's Decision, so they are in scope. `Amendment` is
431
+ * deliberately NOT: an amendment revises or historicises an earlier claim.
432
+ *
433
+ * Everything else is out of scope by construction: `## Alternatives
434
+ * considered` rejects a permanence option, `## Consequences` reports one,
435
+ * `## Context` recounts one, and an `## Open question` quotes one. Scoping is
436
+ * the whole gate — the same word list applied to whole files matches 41 lines
437
+ * across 30 records, and the large majority of them are legitimate.
438
+ */
439
+ export type PermanenceRegion = 'title' | 'slug' | 'decision' | 'addendum';
440
+
441
+ interface Region {
442
+ kind: PermanenceRegion;
443
+ /** First line index of the region (0-based, inclusive). */
444
+ start: number;
445
+ /** Last line index of the region (0-based, inclusive). */
446
+ end: number;
447
+ }
448
+
449
+ const FM_DELIM_RE = /^---[ \t]*$/;
450
+ const ANY_HEADING_RE = /^#{1,6}[ \t]+/;
451
+ const H1_RE = /^#[ \t]+/;
452
+ const DECISION_KEY_RE = /^decision:/;
453
+ // `## Decision`, `## Decisions`, `## Decision — GO`, `## Decision (Reading B)`,
454
+ // `### Decision`. Deliberately NOT `### Decision matrix` / `### Decision owner`
455
+ // / `### The owner's decision`: the word has to BE the heading, not open it.
456
+ const DECISION_HEADING_RE = /^#{2,6}[ \t]+Decisions?[ \t]*(?:[—–(:\-]|$)/i;
457
+ const ADDENDUM_HEADING_RE = /^#{2,6}[ \t]+Addendum\b/i;
458
+
459
+ /**
460
+ * Escape 2 — an owner purpose statement, keyed on the SCHEMA field.
461
+ *
462
+ * This originally matched a bare `owner_intent: currently binding` key, which
463
+ * the roadmap's own draft prose named and which no schema declares anywhere in
464
+ * the tree (`grep -rn owner_intent docs/ src/` found it only in that prose). It
465
+ * was usable — nothing rejects an unknown frontmatter key — and that is exactly
466
+ * what made it wrong: a lint reading a field the validator does not know is a
467
+ * second vocabulary for one concept, and the tree would have taught two.
468
+ *
469
+ * `authority_basis: owner_intent` is the declared field
470
+ * (`adr-layout § Provenance and evidence`, validated by
471
+ * `check_adr_frontmatter.check_descriptive_axes`), and it already carries the
472
+ * temporal meaning the phrase was reaching for: the burden table reads it as
473
+ * "binding until the owner changes it". So one field, already validated, rather
474
+ * than a phrase that only this lint could see.
475
+ */
476
+ const OWNER_INTENT_ESCAPE_RE = /^[ \t]*authority_basis:[ \t]*owner_intent[ \t]*$/m;
477
+
478
+ /** The literal an owner purpose statement writes instead of "forever". */
479
+ export const OWNER_INTENT_ESCAPE = 'authority_basis: owner_intent';
480
+
481
+ /** The YAML frontmatter block, or '' when the record carries none. */
482
+ function _frontmatterText(lines: readonly string[]): string {
483
+ if (lines.length === 0 || !FM_DELIM_RE.test(lines[0] ?? '')) return '';
484
+ for (let i = 1; i < lines.length; i++) {
485
+ if (FM_DELIM_RE.test(lines[i] ?? '')) return lines.slice(1, i).join('\n');
486
+ }
487
+ return '';
488
+ }
489
+
490
+ /**
491
+ * Single permanence words. `permanent` is matched only in a **predicative**
492
+ * position (see {@link PROSE_WORD_RES}) because attributively it names a
493
+ * thing, not the decision: "two permanent regression fixtures" (ADR-126) and
494
+ * "permanent migration stubs" (ADR-057) are objects that happen to persist.
495
+ */
496
+ export const PERMANENCE_WORDS: readonly string[] = ['forever', 'permanently', 'permanent'];
497
+
498
+ /**
499
+ * Multi-word equivalents. The first three are named by ADR-239 § 6;
500
+ * `now or in the future` is the corpus instance that made the list necessary
501
+ * (ADR-108's Decision statement says it instead of "forever"). `in perpetuity`
502
+ * and `for all time` have zero corpus hits today and are listed because they
503
+ * are the standard idioms for the same claim.
504
+ */
505
+ export const PERMANENCE_PHRASES: readonly string[] = [
506
+ 'never revisit',
507
+ 'never reconsider',
508
+ 'settled forever',
509
+ 'now or in the future',
510
+ 'in perpetuity',
511
+ 'for all time',
512
+ ];
513
+
514
+ /** Prose matchers. `forever` / `permanently` reject a trailing hyphen so a
515
+ * compound modifier ("forever-incomplete", "permanently-parked") describing
516
+ * something else is not read as a permanence claim about the decision. */
517
+ const PROSE_WORD_RES: readonly RegExp[] = [
518
+ /(?<![A-Za-z0-9])forever(?![A-Za-z0-9-])/gi,
519
+ /(?<![A-Za-z0-9])permanently(?![A-Za-z0-9-])/gi,
520
+ /(?<![A-Za-z0-9])permanent(?=[ \t]*(?:[.,;:!?)*\]]|$))/gi,
521
+ ];
522
+
523
+ const PHRASE_RES: readonly RegExp[] = PERMANENCE_PHRASES.map(
524
+ (p) =>
525
+ new RegExp(
526
+ `(?<![A-Za-z0-9])${p.split(' ').map(_escapeRegExp).join('[ \\t]+')}(?![A-Za-z0-9])`,
527
+ 'gi',
528
+ ),
529
+ );
530
+
531
+ /** A hyphen-joined slug carries its words without spaces, so the slug region
532
+ * matches on hyphen boundaries too (`dist-agent-src-keep-forever`). */
533
+ const SLUG_RE = /(?<![A-Za-z0-9])(?:forever|permanent(?:ly)?)(?![A-Za-z0-9])/gi;
534
+
535
+ const TABLE_ROW_RE = /^[ \t]*\|/;
536
+ const LIST_ITEM_RE = /^[ \t]*(?:[-*+]|\d+[.)])[ \t]/;
537
+ // A sentence break, tolerating a bold/paren/backtick close after the stop.
538
+ const SENTENCE_SPLIT_RE = /(?<=[.!?][)*"'\]`]{0,3})[ \t]+(?=[A-Z*`[("'])/g;
539
+
540
+ /**
541
+ * Negation and conditional markers that make a permanence word DESCRIPTIVE.
542
+ *
543
+ * Deliberately short. A wider list was tried and rejected against the corpus:
544
+ * `would` kills ADR-107's live claim ("commercial ship **would** require …"
545
+ * sits two sentences from "the suite is open-source forever"), and a bare `no`
546
+ * kills ADR-108's ("There is **no** commercial tier … now or in the future").
547
+ * Both are real violations, so a marker that silences them is worse than the
548
+ * noise it removes.
549
+ */
550
+ const NEGATION_RE =
551
+ /\b(?:cannot|can't|could not|couldn't|does not|doesn't|do not|don't|did not|is not|isn't|are not|aren't|was not|were not|no longer|rather than|instead of|not a|not an|not the|neither)\b/i;
552
+ const CONDITIONAL_RE = /(?:^|[\s(*_"'`])(?:if|unless)\b/i;
553
+
554
+ /** Escape 1, half one: the permanence is scoped to an EXTERNAL invariant. */
555
+ const EXTERNAL_INVARIANT_RE =
556
+ /\b(?:protocol|api compatibility|wire format|statutory|regulatory|legal obligation|licen[sc]e|licensing|contractual|upstream|external constraint|standards? body|rfc[ \t]*\d+)\b/i;
557
+ /** Escape 1, half two: the condition under which that invariant stops. */
558
+ const STOP_CONDITION_RE =
559
+ /\b(?:stops? applying|no longer applies|ceases? to apply|until|reopen(?:s|ed)?[ \t]+when|revisit(?:ed)?[ \t]+(?:if|when)|is[ \t]+retired|is[ \t]+withdrawn|expires?)\b/i;
560
+
561
+ const PERMANENCE_CONSEQUENCE_BOUND =
562
+ 'a record cannot both assert permanence and carry the conditions under which it ' +
563
+ 'is reopened (ADR-239 § 6; ADR-208 is the standing demonstration). Two escapes: ' +
564
+ 'scope the permanence to an external invariant AND state in the same section when ' +
565
+ `that invariant stops applying, or record an owner purpose statement as \`${OWNER_INTENT_ESCAPE}\``;
566
+
567
+ /** Split a record into its load-bearing regions. */
568
+ export function permanenceRegions(lines: readonly string[]): Region[] {
569
+ const regions: Region[] = [];
570
+ let bodyStart = 0;
571
+
572
+ if (lines.length > 0 && FM_DELIM_RE.test(lines[0] ?? '')) {
573
+ let close = -1;
574
+ for (let i = 1; i < lines.length; i++) {
575
+ if (FM_DELIM_RE.test(lines[i] ?? '')) {
576
+ close = i;
577
+ break;
578
+ }
579
+ }
580
+ if (close > 0) {
581
+ bodyStart = close + 1;
582
+ for (let i = 1; i < close; i++) {
583
+ if (!DECISION_KEY_RE.test(lines[i] ?? '')) continue;
584
+ // A folded value (`decision: >-`) continues on indented lines.
585
+ let end = i;
586
+ for (let j = i + 1; j < close; j++) {
587
+ if (!/^[ \t]+\S/.test(lines[j] ?? '')) break;
588
+ end = j;
589
+ }
590
+ regions.push({ kind: 'slug', start: i, end });
591
+ break;
592
+ }
593
+ }
594
+ }
595
+
596
+ let open: Region | null = null;
597
+ for (let i = bodyStart; i < lines.length; i++) {
598
+ const line = lines[i] ?? '';
599
+ if (!ANY_HEADING_RE.test(line)) continue;
600
+ if (open !== null) {
601
+ open.end = i - 1;
602
+ regions.push(open);
603
+ open = null;
604
+ }
605
+ if (H1_RE.test(line)) {
606
+ regions.push({ kind: 'title', start: i, end: i });
607
+ continue;
608
+ }
609
+ if (DECISION_HEADING_RE.test(line)) {
610
+ open = { kind: 'decision', start: i, end: lines.length - 1 };
611
+ continue;
612
+ }
613
+ if (ADDENDUM_HEADING_RE.test(line)) {
614
+ open = { kind: 'addendum', start: i, end: lines.length - 1 };
615
+ }
616
+ }
617
+ if (open !== null) regions.push(open);
618
+ return regions;
619
+ }
620
+
621
+ /**
622
+ * The sentence containing the hit.
623
+ *
624
+ * Hard-wrapped prose puts a sentence's start on a previous line (ADR-055's
625
+ * "If Step 8 slips," is one line above its "permanent"), so the paragraph is
626
+ * rejoined first. The walk stops at a list-item start in both directions: the
627
+ * corpus writes numbered Decision clauses one item per line, and merging them
628
+ * imports a neighbour's markers into this item's verdict.
629
+ */
630
+ function _sentenceAround(
631
+ lines: readonly string[],
632
+ region: Region,
633
+ lineIdx: number,
634
+ col: number,
635
+ ): string {
636
+ let top = lineIdx;
637
+ while (
638
+ top > region.start &&
639
+ !LIST_ITEM_RE.test(lines[top] ?? '') &&
640
+ (lines[top - 1] ?? '').trim() !== '' &&
641
+ !ANY_HEADING_RE.test(lines[top - 1] ?? '') &&
642
+ !TABLE_ROW_RE.test(lines[top - 1] ?? '')
643
+ ) {
644
+ top -= 1;
645
+ }
646
+ let bottom = lineIdx;
647
+ while (
648
+ bottom < region.end &&
649
+ (lines[bottom + 1] ?? '').trim() !== '' &&
650
+ !ANY_HEADING_RE.test(lines[bottom + 1] ?? '') &&
651
+ !LIST_ITEM_RE.test(lines[bottom + 1] ?? '') &&
652
+ !TABLE_ROW_RE.test(lines[bottom + 1] ?? '')
653
+ ) {
654
+ bottom += 1;
655
+ }
656
+
657
+ let joined = '';
658
+ let hitOffset = 0;
659
+ for (let i = top; i <= bottom; i++) {
660
+ const raw = lines[i] ?? '';
661
+ const sep = joined === '' ? '' : ' ';
662
+ if (i === lineIdx) {
663
+ const lead = raw.length - raw.trimStart().length;
664
+ hitOffset = joined.length + sep.length + Math.max(0, col - lead);
665
+ }
666
+ joined += sep + raw.trim();
667
+ }
668
+
669
+ const parts: Array<{ text: string; start: number }> = [];
670
+ let cursor = 0;
671
+ let m: RegExpExecArray | null;
672
+ SENTENCE_SPLIT_RE.lastIndex = 0;
673
+ while ((m = SENTENCE_SPLIT_RE.exec(joined)) !== null) {
674
+ parts.push({ text: joined.slice(cursor, m.index), start: cursor });
675
+ cursor = m.index + m[0].length;
676
+ }
677
+ parts.push({ text: joined.slice(cursor), start: cursor });
678
+ for (const p of parts) {
679
+ if (hitOffset >= p.start && hitOffset < p.start + p.text.length) return p.text;
680
+ }
681
+ return joined;
682
+ }
683
+
684
+ interface RawHit {
685
+ term: string;
686
+ index: number;
687
+ }
688
+
689
+ function _hits(line: string, res: readonly RegExp[]): RawHit[] {
690
+ const out: RawHit[] = [];
691
+ for (const re of res) {
692
+ re.lastIndex = 0;
693
+ let m: RegExpExecArray | null;
694
+ while ((m = re.exec(line)) !== null) {
695
+ out.push({ term: m[0], index: m.index });
696
+ if (m.index === re.lastIndex) re.lastIndex += 1;
697
+ }
698
+ }
699
+ return out;
700
+ }
701
+
702
+ /**
703
+ * Rule 4: permanence language in an ADR record's load-bearing positions.
704
+ *
705
+ * Returns [] for a file {@link isAdrRecord} rejects, so a caller may hand it
706
+ * any prose file.
707
+ */
708
+ export function permanenceViolations(text: string, file: string): Violation[] {
709
+ if (!isAdrRecord(file)) return [];
710
+
711
+ const rawLines = text.split('\n');
712
+ // Fence-stripped, index-preserving: a `## Decision` inside a code fence is
713
+ // an example, not a section, and must not open a region.
714
+ const prose = _stripFences(rawLines);
715
+ const regions = permanenceRegions(prose);
716
+ const ownerIntentEscape = OWNER_INTENT_ESCAPE_RE.test(_frontmatterText(rawLines));
717
+
718
+ const violations: Violation[] = [];
719
+ for (const region of regions) {
720
+ // The slug is machine-visible in every citation and hyphen-joined, so
721
+ // it takes the unconditional match and neither escape: a field cannot
722
+ // qualify a name that tools print without reading the field.
723
+ if (region.kind === 'slug') {
724
+ for (let i = region.start; i <= region.end; i++) {
725
+ for (const hit of _hits(prose[i] ?? '', [SLUG_RE])) {
726
+ violations.push({
727
+ file,
728
+ rule: 'permanence-language',
729
+ msg:
730
+ `line ${i + 1}: permanence language "${hit.term}" in the \`decision:\` slug — ` +
731
+ `${PERMANENCE_CONSEQUENCE_BOUND} (the slug takes neither escape)`,
732
+ });
733
+ }
734
+ }
735
+ continue;
736
+ }
737
+
738
+ const regionText = prose.slice(region.start, region.end + 1).join('\n');
739
+ const hasStopCondition = STOP_CONDITION_RE.test(regionText);
740
+
741
+ for (let i = region.start; i <= region.end; i++) {
742
+ const line = prose[i] ?? '';
743
+ const isProseRegion = region.kind === 'decision' || region.kind === 'addendum';
744
+ // A table cell is a per-row detail with its own rationale column,
745
+ // not the decision statement (ADR-074's "probing forever" is a
746
+ // failure mode being bounded, in the rationale column).
747
+ if (isProseRegion && TABLE_ROW_RE.test(line)) continue;
748
+
749
+ for (const hit of _hits(line, [...PROSE_WORD_RES, ...PHRASE_RES])) {
750
+ if (_isQuotedSpan(line, hit.index)) continue;
751
+ if (isProseRegion) {
752
+ if (ownerIntentEscape) continue;
753
+ const sentence = _sentenceAround(prose, region, i, hit.index);
754
+ if (NEGATION_RE.test(sentence) || CONDITIONAL_RE.test(sentence)) continue;
755
+ if (hasStopCondition && EXTERNAL_INVARIANT_RE.test(sentence)) continue;
756
+ }
757
+ violations.push({
758
+ file,
759
+ rule: 'permanence-language',
760
+ msg:
761
+ `line ${i + 1}: permanence language "${hit.term}" in the record's ${region.kind} — ` +
762
+ `${PERMANENCE_CONSEQUENCE_BOUND} (context: ${line.trim().slice(0, 90)})`,
763
+ });
764
+ }
765
+ }
766
+ }
767
+ return violations;
768
+ }
769
+
365
770
  // ---------------------------------------------------------------------------
366
771
  // Filesystem walk + CLI
367
772
  // ---------------------------------------------------------------------------
@@ -404,6 +809,42 @@ export function lintProvenanceVocabulary(repoRoot: string = REPO_ROOT): Violatio
404
809
  return violations;
405
810
  }
406
811
 
812
+ /**
813
+ * Rule 4 over the ADR corpus.
814
+ *
815
+ * Separate from {@link lintProvenanceVocabulary} because the two carry
816
+ * different verdicts: rules 1-3 hard-fail, rule 4 is ratcheted against
817
+ * `${PERMANENCE_GATE}` in `src/config/gate-violation-baselines.json` because
818
+ * the corpus contains real violations today (ADR-107, ADR-108, ADR-122,
819
+ * ADR-124, ADR-208) and a gate that lands as five instant blockers is a shape
820
+ * this repository has refused before. A NEW record still hard-fails: the
821
+ * ratchet only turns one way.
822
+ */
823
+ export function lintAdrPermanence(repoRoot: string = REPO_ROOT): Violation[] {
824
+ const records: string[] = [];
825
+ for (const root of ADR_RECORD_ROOTS) {
826
+ for (const abs of _collectMd(path.join(repoRoot, root))) {
827
+ const rel = path.relative(repoRoot, abs);
828
+ if (isAdrRecord(rel)) records.push(abs);
829
+ }
830
+ }
831
+ // A `## Decision`-scoped rule that read zero ADR records would print a
832
+ // green line while enforcing nothing — the exact failure the shared
833
+ // scope-assert exists for.
834
+ assertScanned({
835
+ gate: PERMANENCE_GATE,
836
+ scanned: records.length,
837
+ units: 'ADR record(s)',
838
+ roots: ADR_RECORD_ROOTS,
839
+ });
840
+ const violations: Violation[] = [];
841
+ for (const abs of records.sort()) {
842
+ const rel = path.relative(repoRoot, abs);
843
+ violations.push(...permanenceViolations(fs.readFileSync(abs, 'utf-8'), rel));
844
+ }
845
+ return violations;
846
+ }
847
+
407
848
  function _print(quiet: boolean, msg: string): void {
408
849
  if (!quiet) process.stdout.write(`${msg}\n`);
409
850
  }
@@ -411,8 +852,10 @@ function _print(quiet: boolean, msg: string): void {
411
852
  export function main(): number {
412
853
  const quiet = process.argv.slice(2).includes('--quiet');
413
854
  let violations: Violation[];
855
+ let permanence: Violation[];
414
856
  try {
415
857
  violations = lintProvenanceVocabulary();
858
+ permanence = lintAdrPermanence();
416
859
  } catch (e) {
417
860
  // 1 is the only failure code this gate defines, so a dead scan root and
418
861
  // a real violation share it — the message distinguishes them.
@@ -422,14 +865,39 @@ export function main(): number {
422
865
  }
423
866
  throw e;
424
867
  }
868
+
869
+ let failed = false;
425
870
  if (violations.length > 0) {
426
871
  for (const v of violations) {
427
872
  process.stderr.write(`❌ [${v.rule}] ${v.file}: ${v.msg}\n`);
428
873
  }
429
874
  process.stderr.write(`\n❌ provenance-vocabulary — ${violations.length} violation(s)\n`);
430
- return 1;
875
+ failed = true;
876
+ }
877
+
878
+ // Rule 4 reports every finding either way: at baseline they are the known
879
+ // debt this ratchet is walking down, and printing them is what makes the
880
+ // next lowering possible.
881
+ const verdict = checkRatchet({
882
+ gate: PERMANENCE_GATE,
883
+ actual: permanence.length,
884
+ repoRoot: REPO_ROOT,
885
+ });
886
+ if (!verdict.ok) {
887
+ for (const v of permanence) {
888
+ process.stderr.write(`❌ [${v.rule}] ${v.file}: ${v.msg}\n`);
889
+ }
890
+ process.stderr.write(`\n❌ ${verdict.message}\n`);
891
+ failed = true;
892
+ } else if (!quiet) {
893
+ for (const v of permanence) {
894
+ process.stdout.write(` [${v.rule}] ${v.file}: ${v.msg}\n`);
895
+ }
896
+ _print(quiet, `✅ ${verdict.message}`);
431
897
  }
432
- _print(quiet, '✅ provenance-vocabulary — no banned phrases, every approved-term use is co-located with a valid scope box, every box figure matches docs/CLAIMS.md');
898
+
899
+ if (failed) return 1;
900
+ _print(quiet, '✅ provenance-vocabulary — no banned phrases, every approved-term use is co-located with a valid scope box, every box figure matches docs/CLAIMS.md, no new permanence language in an ADR record');
433
901
  return 0;
434
902
  }
435
903
 
@@ -59,10 +59,10 @@ export const MODE_BODY_DIRS: readonly string[] = ['tasks', 'references'];
59
59
  * Measured, not guessed. REMOVE entries as skills are restructured; never add.
60
60
  */
61
61
  export const GRANDFATHERED: readonly string[] = [
62
- 'ai-council', // 1055
63
- 'skill-writing', // 767
64
- 'roadmap-management', // 552
65
- 'quality-tools', // 445
62
+ // Emptied 2026-08-20: all four seeded entries were restructured into router
63
+ // heads (entry head + `references/` mode bodies) under the published K6 cap.
64
+ // Shrink-only means this list may never grow again — a new oversized
65
+ // monolith fails the gate instead of joining it.
66
66
  ];
67
67
 
68
68
  export interface Finding {
@@ -669,7 +669,7 @@ async function _roadmapProgressHandler(
669
669
  };
670
670
  }
671
671
  const roadmaps = urp.collect(roadmapRoot);
672
- const content = urp.render(roadmaps, urp.collect_bundles(root));
672
+ const content = urp.render(roadmaps, urp.collect_bundles(root), roadmapRoot);
673
673
  const stepsDone = roadmaps.reduce((s, r) => s + r.done, 0);
674
674
  const stepsTotal = roadmaps.reduce((s, r) => s + r.total_active, 0);
675
675
  if (!dryRun) {
@@ -719,7 +719,7 @@ async function _roadmapArchiveHandler(
719
719
  const roadmaps = urp.collect(roadmapRoot);
720
720
  fs.writeFileSync(
721
721
  path.join(root, 'agents', 'roadmaps-progress.md'),
722
- urp.render(roadmaps, urp.collect_bundles(root)),
722
+ urp.render(roadmaps, urp.collect_bundles(root), roadmapRoot),
723
723
  'utf-8',
724
724
  );
725
725
  dashboardRegenerated = true;