@event4u/agent-config 16.0.0 → 16.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 (246) hide show
  1. package/.augment-plugin/marketplace.json +3 -3
  2. package/.augment-plugin/plugin.json +1 -1
  3. package/.claude-plugin/marketplace.json +1 -1
  4. package/CHANGELOG.md +297 -137
  5. package/CONTRIBUTING.md +9 -0
  6. package/README.md +2 -2
  7. package/dist/agent-src/commands/analyze/inbox.md +26 -0
  8. package/dist/agent-src/commands/analyze/roadmap-repos.md +27 -0
  9. package/dist/agent-src/commands/challenge-me/closure.md +178 -0
  10. package/dist/agent-src/commands/challenge-me.md +9 -2
  11. package/dist/agent-src/commands/council/default.md +25 -5
  12. package/dist/agent-src/commands/feature/plan.md +28 -1
  13. package/dist/agent-src/commands/feature/roadmap.md +28 -1
  14. package/dist/agent-src/commands/implement-ticket.md +27 -0
  15. package/dist/agent-src/commands/jira-ticket.md +27 -0
  16. package/dist/agent-src/commands/pr/create.md +29 -0
  17. package/dist/agent-src/commands/pr/merge.md +60 -9
  18. package/dist/agent-src/commands/roadmap/ai-council.md +7 -1
  19. package/dist/agent-src/commands/roadmap/create.md +74 -27
  20. package/dist/agent-src/commands/roadmap/materialize.md +27 -0
  21. package/dist/agent-src/commands/roadmap/process-full.md +123 -18
  22. package/dist/agent-src/commands/team.md +22 -0
  23. package/dist/agent-src/contexts/authority/scope-growth-ownership.md +80 -0
  24. package/dist/agent-src/contexts/communication/rules-auto/user-interaction-mechanics.md +47 -0
  25. package/dist/agent-src/contexts/execution/auto-orchestration-activation.md +44 -3
  26. package/dist/agent-src/contexts/execution/autonomy-mechanics.md +15 -15
  27. package/dist/agent-src/contexts/execution/decision-closure-execution.md +93 -0
  28. package/dist/agent-src/contexts/execution/fix-loop-ladder.md +108 -0
  29. package/dist/agent-src/contexts/execution/plan-confidence-gate.md +4 -2
  30. package/dist/agent-src/contexts/execution/roadmap-process-loop.md +75 -6
  31. package/dist/agent-src/guidelines/agent-infra/active-remediation-mechanics.md +57 -0
  32. package/dist/agent-src/guidelines/agent-infra/evaluator-independence-mechanics.md +102 -0
  33. package/dist/agent-src/guidelines/design-fidelity-routing.md +29 -0
  34. package/dist/agent-src/rules/autonomous-execution.md +15 -8
  35. package/dist/agent-src/rules/delegation-policy.md +23 -45
  36. package/dist/agent-src/rules/design-fidelity.md +32 -37
  37. package/dist/agent-src/rules/evaluator-independence.md +43 -49
  38. package/dist/agent-src/rules/test-first.md +41 -0
  39. package/dist/agent-src/skills/accessibility-auditor/data/manifest.json +3 -3
  40. package/dist/agent-src/skills/ai-council/SKILL.md +11 -3
  41. package/dist/agent-src/skills/api-design/data/manifest.json +3 -3
  42. package/dist/agent-src/skills/database/data/manifest.json +4 -4
  43. package/dist/agent-src/skills/database/data/query-tuning.csv +3 -3
  44. package/dist/agent-src/skills/design-review/SKILL.md +50 -0
  45. package/dist/agent-src/skills/existing-ui-audit/SKILL.md +14 -4
  46. package/dist/agent-src/skills/experiment-loop/SKILL.md +31 -1
  47. package/dist/agent-src/skills/roadmap-writing/SKILL.md +1 -1
  48. package/dist/agent-src/skills/storybook-workshop/SKILL.md +42 -3
  49. package/dist/agent-src/skills/threat-modeling/SKILL.md +9 -4
  50. package/dist/agent-src/skills/threat-modeling/data/manifest.json +4 -4
  51. package/dist/agent-src/skills/verify-repair-loop/SKILL.md +37 -3
  52. package/dist/agent-src/templates/agent-settings.md +1 -1
  53. package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
  54. package/dist/agent-src/templates/roadmaps.md +188 -0
  55. package/dist/agent-src/templates/scripts/work_engine/directives/backend/_self_fix.ts +62 -2
  56. package/dist/agent-src/templates/scripts/work_engine/directives/backend/test.ts +1 -0
  57. package/dist/agent-src/templates/scripts/work_engine/directives/backend/verify.ts +1 -0
  58. package/dist/cli-delegate/check_update_banner.js +40 -2
  59. package/dist/cli-delegate/chunk-4E2W63DA.js +335 -0
  60. package/dist/cli-delegate/chunk-5R7HOLAF.js +99 -0
  61. package/dist/cli-delegate/{chunk-IVBPRCQO.js → chunk-6KFEC3G7.js} +1 -1
  62. package/dist/cli-delegate/{chunk-2TAQ2K4V.js → chunk-6KM6ORRY.js} +55 -386
  63. package/dist/cli-delegate/{chunk-2XY7ILBT.js → chunk-FC75PGJE.js} +42 -2
  64. package/dist/cli-delegate/{chunk-GYC3VRYN.js → chunk-FXYUYU4N.js} +57 -13
  65. package/dist/cli-delegate/{chunk-55ACLDUE.js → chunk-GJCBB2UG.js} +168 -10
  66. package/dist/cli-delegate/{chunk-AK6HODNQ.js → chunk-HDVVGPQW.js} +873 -539
  67. package/dist/cli-delegate/{chunk-H32DGUJH.js → chunk-P6ZHP5W2.js} +1 -1
  68. package/dist/cli-delegate/{chunk-57PN4GYJ.js → chunk-RGNIWTEA.js} +1 -1
  69. package/dist/cli-delegate/chunk-ROYYTEZU.js +39 -0
  70. package/dist/cli-delegate/{chunk-3FKR64R5.js → chunk-UNCRJBT3.js} +31 -7
  71. package/dist/cli-delegate/{chunk-2DK2VJWG.js → chunk-WGXTVKCQ.js} +20 -6
  72. package/dist/cli-delegate/chunk-YDPILCOL.js +152 -0
  73. package/dist/cli-delegate/cmd_analyze_session.js +1 -1
  74. package/dist/cli-delegate/cmd_brand_status.js +1 -1
  75. package/dist/cli-delegate/cmd_conformance.js +90 -56
  76. package/dist/cli-delegate/cmd_doctor.js +7 -5
  77. package/dist/cli-delegate/cmd_explain.js +2 -2
  78. package/dist/cli-delegate/cmd_export.js +9 -7
  79. package/dist/cli-delegate/cmd_fleet.js +4 -2
  80. package/dist/cli-delegate/cmd_handoff.js +4 -4
  81. package/dist/cli-delegate/cmd_mcp_available.js +1 -1
  82. package/dist/cli-delegate/cmd_migrate.js +2 -2
  83. package/dist/cli-delegate/cmd_packs_active.js +2 -2
  84. package/dist/cli-delegate/cmd_preflight.js +4 -2
  85. package/dist/cli-delegate/cmd_prune.js +10 -8
  86. package/dist/cli-delegate/cmd_route_audit.js +2 -2
  87. package/dist/cli-delegate/cmd_route_explain.js +1 -1
  88. package/dist/cli-delegate/cmd_session_recycle.js +2 -2
  89. package/dist/cli-delegate/cmd_settings_get.js +28 -2
  90. package/dist/cli-delegate/cmd_settings_migrate.js +9 -7
  91. package/dist/cli-delegate/cmd_settings_set.js +1 -1
  92. package/dist/cli-delegate/cmd_sync.js +10 -8
  93. package/dist/cli-delegate/cmd_uninstall.js +16 -13
  94. package/dist/cli-delegate/cmd_update.js +1 -1
  95. package/dist/cli-delegate/cmd_upgrade.js +1 -1
  96. package/dist/cli-delegate/cmd_validate.js +12 -10
  97. package/dist/cli-delegate/cmd_versions.js +1 -1
  98. package/dist/discovery/deprecation-report.md +1 -1
  99. package/dist/discovery/discovery-manifest.json +85 -38
  100. package/dist/discovery/discovery-manifest.json.sha256 +1 -1
  101. package/dist/discovery/discovery-manifest.summary.md +4 -4
  102. package/dist/discovery/dormancy-report.md +1 -1
  103. package/dist/discovery/orphan-report.md +1 -1
  104. package/dist/discovery/packs.json +10 -8
  105. package/dist/discovery/trust-report.md +3 -3
  106. package/dist/discovery/workspaces.json +8 -6
  107. package/dist/hooks/dispatch.js +1832 -1007
  108. package/dist/install/claudeRuleRewrite.js +14 -3
  109. package/dist/install/conflict.js +60 -10
  110. package/dist/install/conflictTracker.js +218 -0
  111. package/dist/install/fsPrimitives.js +54 -0
  112. package/dist/install/install.mjs +2007 -1481
  113. package/dist/install/manifestFiles.js +259 -0
  114. package/dist/install/preflight.js +14 -2
  115. package/dist/install/preserve.js +157 -0
  116. package/dist/install/recordedOwnership.js +132 -0
  117. package/dist/install/txlog.js +11 -2
  118. package/dist/mcp/registry-manifest.json +3 -3
  119. package/dist/mcp/server.json +2 -2
  120. package/dist/mcp/server.mjs +398 -39
  121. package/dist/router.json +1 -1
  122. package/dist/scripts/_lib/agent_settings.js +3 -2
  123. package/dist/scripts/_lib/fs_atomic.js +167 -0
  124. package/dist/scripts/_lib/installed_tools.js +460 -0
  125. package/dist/scripts/_lib/lean_projection_mode.js +3 -2
  126. package/dist/scripts/_lib/settings_renamed_keys.js +78 -0
  127. package/dist/server/routes/install.js +8 -0
  128. package/dist/server/routes/wizard.js +9 -3
  129. package/dist/server/schemas/settings.js +17 -5
  130. package/dist/shared/missionExecution.js +97 -0
  131. package/dist/ui/assets/{index-D0jaCRPM.js → index-DH1xc62v.js} +17 -17
  132. package/dist/ui/index.html +1 -1
  133. package/docs/MIGRATION.md +103 -0
  134. package/docs/contracts/settings-classes.md +26 -7
  135. package/docs/guidelines/agent-infra/active-remediation-mechanics.md +57 -0
  136. package/docs/guidelines/agent-infra/evaluator-independence-mechanics.md +102 -0
  137. package/docs/guidelines/design-fidelity-routing.md +29 -0
  138. package/package.json +1 -1
  139. package/src/agent-src/templates/scripts/work_engine/directives/backend/_self_fix.ts +62 -2
  140. package/src/agent-src/templates/scripts/work_engine/directives/backend/test.ts +1 -0
  141. package/src/agent-src/templates/scripts/work_engine/directives/backend/verify.ts +1 -0
  142. package/src/config/agent-settings.template.yml +139 -27
  143. package/src/config/auto-rule-budget.json +16 -0
  144. package/src/config/ci-local-parity.yml +18 -5
  145. package/src/config/continuity-surface.json +21 -0
  146. package/src/config/gate-coverage.yml +146 -13
  147. package/src/config/gate-violation-baselines.json +6 -6
  148. package/src/config/hook-token-budget.json +2 -0
  149. package/src/config/loop-surfaces.yaml +215 -0
  150. package/src/config/platform-anchor.json +3 -2
  151. package/src/config/release-gate-locality.yml +11 -0
  152. package/src/config/routing-coverage-seed.json +18 -4
  153. package/src/config/rule-enforcement-baseline.json +0 -1
  154. package/src/install/claudeRuleRewrite.ts +16 -2
  155. package/src/install/conflict.ts +65 -9
  156. package/src/install/conflictTracker.ts +299 -0
  157. package/src/install/fsPrimitives.ts +54 -0
  158. package/src/install/manifestFiles.ts +312 -0
  159. package/src/install/preflight.ts +16 -2
  160. package/src/install/preserve.ts +205 -0
  161. package/src/install/recordedOwnership.ts +149 -0
  162. package/src/install/txlog.ts +11 -2
  163. package/src/install/types.ts +19 -4
  164. package/src/scripts/_cli/cmd_conformance.ts +88 -15
  165. package/src/scripts/_cli/cmd_doctor.ts +4 -3
  166. package/src/scripts/_cli/cmd_route_explain.ts +9 -2
  167. package/src/scripts/_cli/cmd_settings_get.ts +44 -0
  168. package/src/scripts/_cli/doctor_execution.ts +90 -0
  169. package/src/scripts/_lib/activation_payload.ts +247 -0
  170. package/src/scripts/_lib/agent_settings.ts +4 -2
  171. package/src/scripts/_lib/authority_path.ts +102 -0
  172. package/src/scripts/_lib/cascade_base.ts +94 -0
  173. package/src/scripts/_lib/changelog_eras.ts +7 -2
  174. package/src/scripts/_lib/continuation_ladder.ts +149 -2
  175. package/src/scripts/_lib/council_transport.ts +129 -0
  176. package/src/scripts/_lib/delivery_ready.ts +112 -0
  177. package/src/scripts/_lib/forge_protection.ts +267 -0
  178. package/src/scripts/_lib/hook_platform_keys.ts +27 -0
  179. package/src/scripts/_lib/json_python_parity.ts +127 -0
  180. package/src/scripts/_lib/lean_projection_mode.ts +14 -3
  181. package/src/scripts/_lib/loop_surfaces.ts +382 -0
  182. package/src/scripts/_lib/mission_record.ts +190 -0
  183. package/src/scripts/_lib/obligation_frequency.ts +217 -0
  184. package/src/scripts/_lib/obligations.ts +402 -0
  185. package/src/scripts/_lib/orchestration_record.ts +11 -20
  186. package/src/scripts/_lib/release_highlights.ts +199 -10
  187. package/src/scripts/_lib/release_holds.ts +493 -0
  188. package/src/scripts/_lib/release_migration_gate.ts +184 -0
  189. package/src/scripts/_lib/rides_along.ts +102 -0
  190. package/src/scripts/_lib/settings_renamed_keys.ts +82 -0
  191. package/src/scripts/_lib/test_delta.ts +166 -0
  192. package/src/scripts/_lib/test_provenance.ts +171 -0
  193. package/src/scripts/_lib/typed_op_grant.ts +246 -0
  194. package/src/scripts/_lib/typed_op_watch.ts +185 -0
  195. package/src/scripts/ai_council/config.ts +8 -11
  196. package/src/scripts/ai_council/fallback_config.ts +5 -3
  197. package/src/scripts/ai_council/ownership_classes.ts +118 -0
  198. package/src/scripts/ask_block_census.ts +285 -15
  199. package/src/scripts/check_enforcement_matrix.ts +624 -0
  200. package/src/scripts/check_gate_reachability.ts +173 -10
  201. package/src/scripts/check_generator_sync.ts +954 -0
  202. package/src/scripts/check_preamble_payload_budget.ts +75 -2
  203. package/src/scripts/check_release_highlights.ts +33 -1
  204. package/src/scripts/check_release_holds.ts +567 -0
  205. package/src/scripts/check_rule_projection_integrity.ts +22 -12
  206. package/src/scripts/check_test_delta.ts +294 -0
  207. package/src/scripts/check_test_weakening.ts +299 -0
  208. package/src/scripts/closure_scan.ts +333 -0
  209. package/src/scripts/compile_hook_manifest.ts +43 -3
  210. package/src/scripts/council_record_shape.ts +209 -0
  211. package/src/scripts/hook_manifest.json +1 -1
  212. package/src/scripts/hook_manifest.yaml +88 -9
  213. package/src/scripts/hooks/concern_registry.ts +4 -0
  214. package/src/scripts/hooks/design_pass_hook.ts +128 -3
  215. package/src/scripts/hooks/evidence_independence.ts +96 -0
  216. package/src/scripts/hooks/memory_recall_hook.ts +540 -0
  217. package/src/scripts/hooks/obligation_settle_hook.ts +182 -0
  218. package/src/scripts/hooks/rule_inject_hook.ts +49 -1
  219. package/src/scripts/hooks/run_continuation_hook.ts +17 -20
  220. package/src/scripts/hooks_doctor.ts +85 -0
  221. package/src/scripts/hooks_status.ts +19 -0
  222. package/src/scripts/install.ts +106 -206
  223. package/src/scripts/janitor.ts +7 -0
  224. package/src/scripts/lint_decision_classes.ts +464 -0
  225. package/src/scripts/lint_hook_manifest.ts +11 -0
  226. package/src/scripts/lint_major_migration_sections.ts +450 -0
  227. package/src/scripts/lint_plan_risk_register.ts +47 -3
  228. package/src/scripts/lint_roadmap_blockers.ts +61 -0
  229. package/src/scripts/lint_roadmap_producers.ts +202 -0
  230. package/src/scripts/measure_rule_budget.ts +101 -5
  231. package/src/scripts/new_roadmap.ts +37 -0
  232. package/src/scripts/orchestration_record.ts +0 -5
  233. package/src/scripts/release.ts +14 -98
  234. package/src/scripts/release_drill.ts +267 -1
  235. package/src/scripts/release_publication.ts +51 -0
  236. package/src/scripts/report_enforcement_drift.ts +566 -0
  237. package/src/scripts/report_host_injection_effect.ts +3 -11
  238. package/src/scripts/report_standing_payload_by_host.ts +26 -152
  239. package/src/scripts/schemas/agent-settings.schema.json +23 -0
  240. package/src/scripts/schemas/command.schema.json +4 -0
  241. package/src/scripts/scope_growth.ts +219 -0
  242. package/src/scripts/trigger_eval_grandfather.json +0 -1
  243. package/src/scripts/ui_conformance_probe.ts +622 -0
  244. package/src/server/schemas/settings.ts +31 -7
  245. package/src/shared/missionExecution.ts +126 -0
  246. package/dist/cli-delegate/chunk-523HCFYO.js +0 -90
@@ -0,0 +1,493 @@
1
+ /**
2
+ * Release holds — parse a roadmap's `## Release holds` declarations and
3
+ * evaluate each one against the checkboxes that open and clear it.
4
+ *
5
+ * Template rule 28 is the contract. The one sentence that governs every
6
+ * decision in this file: a hold is a positive declaration about a named tree
7
+ * state, never a side effect of open checkboxes. Roadmap *incompleteness* is
8
+ * never a release condition, so nothing here counts open boxes.
9
+ *
10
+ * NO THIRD PARSER. The checkbox vocabulary comes from `check_roadmap_trackable`
11
+ * and the fence-blanking and inline-marker grammar from `lint_roadmap_blockers`
12
+ * — the two gates that already own those shapes. A copy would drift, and the
13
+ * drift would be silent: a marker this file accepted and `lint_roadmap_blockers`
14
+ * rejected would be a hold that reddens one gate and is invisible to the other.
15
+ *
16
+ * The load-bearing asymmetry, from rule 28's state table: `not-evaluable`
17
+ * REFUSES. There is no path on which "could not evaluate" reads as safe, which
18
+ * is why every parse failure in this file produces a hold in that state rather
19
+ * than an absent hold or a thrown error.
20
+ */
21
+
22
+ import { readdirSync, readFileSync } from 'node:fs';
23
+ import * as path from 'node:path';
24
+
25
+ import { CHECKBOX_RE } from '../check_roadmap_trackable.js';
26
+ import { _stripFencedCode } from '../lint_roadmap_blockers.js';
27
+ import { entryConditionParts, _frontmatter } from '../lint_roadmap_later_disposition.js';
28
+
29
+ /** Rule 28's state table. Only `unopened` and `cleared` permit a cut. */
30
+ export type HoldState = 'unopened' | 'open' | 'cleared' | 'not-evaluable';
31
+
32
+ /** Rule 28's channel vocabulary. An omitted `Channel:` parses to `all`. */
33
+ export type HoldChannel = 'all' | 'latest';
34
+
35
+ /**
36
+ * Which cut is being checked, in rule 28's own two tokens.
37
+ *
38
+ * `all` (the default) is a STABLE `X.Y.Z` cut: every open hold refuses it,
39
+ * because a `latest` hold refuses a stable cut and an `all` hold refuses
40
+ * everything. `latest` is a `-next.N` PRERELEASE: only `all` holds refuse it,
41
+ * because a `latest` hold permits the opt-in channel by its own definition.
42
+ *
43
+ * The two tokens are the hold field's tokens on purpose — one vocabulary, not
44
+ * two — and they name the set of hold channels that refuse the cut, which is
45
+ * the only reading under which the flag's two values give different answers.
46
+ */
47
+ export type CutChannel = 'all' | 'latest';
48
+
49
+ export interface Hold {
50
+ readonly id: string;
51
+ readonly channel: HoldChannel;
52
+ readonly openedBy: string;
53
+ readonly clearedBy: string;
54
+ /** The clearing step's `verify:` text — the command that proves the fix. */
55
+ readonly clearVerify: string;
56
+ readonly state: HoldState;
57
+ readonly file: string;
58
+ /** Non-empty iff `state === 'not-evaluable'`; each entry is a reason. */
59
+ readonly malformed: readonly string[];
60
+ }
61
+
62
+ const HOLDS_SECTION_RE = /^##[ \t]+Release[ \t]+holds[ \t]*$/im;
63
+ const NEXT_H2_RE = /^##[ \t]+\S/m;
64
+ const HOLD_HEADING_RE = /^###[ \t]+hold:[ \t]*(.+?)[ \t]*$/gim;
65
+
66
+ const CHANNEL_FIELD_RE = /^-[ \t]*\*\*Channel:\*\*[ \t]*`?([A-Za-z-]+)`?/im;
67
+ const OPENED_BY_FIELD_RE = /^-[ \t]*\*\*Opened by:\*\*[ \t]*`?([^`\n<]+?)`?[ \t]*$/im;
68
+ const CLEARED_BY_FIELD_RE = /^-[ \t]*\*\*Cleared by:\*\*[ \t]*`?([^`\n<]+?)`?[ \t]*$/im;
69
+ const STATE_FIELD_RE = /^-[ \t]*\*\*State:\*\*[ \t]*\S/im;
70
+ const WHY_NOT_GUARD_RE = /^-[ \t]*\*\*Why not a guard:\*\*[ \t]*\S/im;
71
+
72
+ /**
73
+ * An inline hold marker, and it must sit on a real checkbox line.
74
+ *
75
+ * Same shape as `lint_roadmap_blockers`' `BLOCKED_BY_LINE_RE`, and for the same
76
+ * reason that file states: a marker in prose is documentation, not a binding.
77
+ * `CHECKBOX_RE`'s own source is spliced in so the accepted mark set cannot
78
+ * drift from the dashboard's.
79
+ */
80
+ const CHECKBOX_PREFIX = CHECKBOX_RE.source.replace(/^\^/, '').replace(/\[ \\t\\f\\v\]$/, '');
81
+ const OPENS_MARKER_RE = new RegExp(
82
+ `^${CHECKBOX_PREFIX}.*<!--[ \\t]*opens-hold:[ \\t]*([a-z0-9-]+)[ \\t]*-->`,
83
+ 'i',
84
+ );
85
+ const CLEARS_MARKER_RE = new RegExp(
86
+ `^${CHECKBOX_PREFIX}.*<!--[ \\t]*clears-hold:[ \\t]*([a-z0-9-]+)[ \\t]*-->`,
87
+ 'i',
88
+ );
89
+ /** The same two markers ANYWHERE, so a marker off a checkbox is detectable. */
90
+ const LOOSE_MARKER_RE = /<!--[ \t]*(opens|clears)-hold:[ \t]*([a-z0-9-]+)[ \t]*-->/gi;
91
+
92
+ /** Rule 13 applies inside a hold entry too — no version, tag or date. */
93
+ const VERSION_IN_ENTRY_RE = /\bv?\d+\.\d+\.\d+\b|\b\d{4}-\d{2}-\d{2}\b/;
94
+
95
+ /** A `verify:` field on the clearing step (rule 23). */
96
+ const VERIFY_FIELD_RE = /^[ \t]*verify:[ \t]*\S/im;
97
+
98
+ function isChecked(mark: string): boolean {
99
+ return mark === 'x' || mark === 'X';
100
+ }
101
+
102
+ /** The mark of the checkbox carrying `marker` for `id`, or null if absent. */
103
+ function markerMark(lines: readonly string[], re: RegExp, id: string): string | null {
104
+ for (const line of lines) {
105
+ const m = re.exec(line);
106
+ if (m && m[2]?.toLowerCase() === id.toLowerCase()) {
107
+ return m[1] ?? null;
108
+ }
109
+ }
110
+ return null;
111
+ }
112
+
113
+ /**
114
+ * The lines of the step a marker sits on, plus its continuation lines.
115
+ *
116
+ * A `verify:` field belongs to the step, and a step's body runs on until the
117
+ * next checkbox or a blank-line-then-heading. Returning the span rather than
118
+ * the single line is what lets the clearer's `verify:` be found at all.
119
+ */
120
+ function stepSpan(lines: readonly string[], re: RegExp, id: string): string[] {
121
+ for (let i = 0; i < lines.length; i += 1) {
122
+ const m = re.exec(lines[i]!);
123
+ if (!m || m[2]?.toLowerCase() !== id.toLowerCase()) {
124
+ continue;
125
+ }
126
+ const span = [lines[i]!];
127
+ for (let j = i + 1; j < lines.length; j += 1) {
128
+ const next = lines[j]!;
129
+ if (CHECKBOX_RE.test(next) || /^#{1,6}[ \t]/.test(next)) {
130
+ break;
131
+ }
132
+ span.push(next);
133
+ }
134
+ return span;
135
+ }
136
+ return [];
137
+ }
138
+
139
+ /**
140
+ * Parse and evaluate every hold declared in `text`.
141
+ *
142
+ * A file with no `## Release holds` section yields `[]` — the normal shape, and
143
+ * deliberately not an error: rule 28's default is that there is no hold.
144
+ */
145
+ export function evaluateHolds(text: string, file: string): Hold[] {
146
+ const stripped = _stripFencedCode(text);
147
+ const sectionMatch = HOLDS_SECTION_RE.exec(stripped);
148
+ const lines = stripped.split('\n');
149
+
150
+ // A marker with no section is a binding pointing at nothing. It cannot be
151
+ // ignored (that would fail open) and it has no entry to report against, so
152
+ // it becomes a not-evaluable hold under the id the marker names.
153
+ if (!sectionMatch) {
154
+ const orphans = new Map<string, string>();
155
+ LOOSE_MARKER_RE.lastIndex = 0;
156
+ let lm: RegExpExecArray | null;
157
+ while ((lm = LOOSE_MARKER_RE.exec(stripped)) !== null) {
158
+ orphans.set(lm[2]!.toLowerCase(), lm[1]!.toLowerCase());
159
+ }
160
+ return [...orphans.keys()].sort().map((id) => ({
161
+ id,
162
+ channel: 'all' as const,
163
+ openedBy: '',
164
+ clearedBy: '',
165
+ clearVerify: '',
166
+ state: 'not-evaluable' as const,
167
+ file,
168
+ malformed: [`marker \`${id}\` has no \`## Release holds\` entry`],
169
+ }));
170
+ }
171
+
172
+ const rest = stripped.slice(sectionMatch.index + sectionMatch[0].length);
173
+ const h2 = NEXT_H2_RE.exec(rest);
174
+ const section = h2 ? rest.slice(0, h2.index) : rest;
175
+
176
+ const entries: { id: string; body: string }[] = [];
177
+ HOLD_HEADING_RE.lastIndex = 0;
178
+ let hm: RegExpExecArray | null;
179
+ const starts: { id: string; at: number; end: number }[] = [];
180
+ while ((hm = HOLD_HEADING_RE.exec(section)) !== null) {
181
+ starts.push({ id: hm[1]!.trim(), at: hm.index, end: HOLD_HEADING_RE.lastIndex });
182
+ }
183
+ for (let i = 0; i < starts.length; i += 1) {
184
+ const next = starts[i + 1]?.at ?? section.length;
185
+ entries.push({ id: starts[i]!.id, body: section.slice(starts[i]!.end, next) });
186
+ }
187
+
188
+ const seen = new Map<string, number>();
189
+ for (const e of entries) {
190
+ const k = e.id.toLowerCase();
191
+ seen.set(k, (seen.get(k) ?? 0) + 1);
192
+ }
193
+
194
+ return entries.map(({ id, body }) => {
195
+ const malformed: string[] = [];
196
+
197
+ if ((seen.get(id.toLowerCase()) ?? 0) > 1) {
198
+ malformed.push(`duplicate hold id \`${id}\``);
199
+ }
200
+ if (!/^[a-z0-9-]+$/.test(id)) {
201
+ malformed.push(`hold id \`${id}\` is not kebab-case`);
202
+ }
203
+
204
+ const chanRaw = CHANNEL_FIELD_RE.exec(body)?.[1]?.toLowerCase();
205
+ let channel: HoldChannel = 'all';
206
+ if (chanRaw === undefined) {
207
+ channel = 'all';
208
+ } else if (chanRaw === 'all' || chanRaw === 'latest') {
209
+ channel = chanRaw;
210
+ } else {
211
+ malformed.push(`\`Channel: ${chanRaw}\` is not \`all\` or \`latest\``);
212
+ }
213
+
214
+ const openedBy = OPENED_BY_FIELD_RE.exec(body)?.[1]?.trim() ?? '';
215
+ const clearedBy = CLEARED_BY_FIELD_RE.exec(body)?.[1]?.trim() ?? '';
216
+ if (!openedBy) {
217
+ malformed.push('missing `Opened by:`');
218
+ }
219
+ if (!clearedBy) {
220
+ malformed.push('missing `Cleared by:`');
221
+ }
222
+ if (!STATE_FIELD_RE.test(body)) {
223
+ malformed.push('missing `State:`');
224
+ }
225
+ // Rule 28 makes this one mandatory by name: it is the field that keeps
226
+ // a hold the last rung rather than the first tool.
227
+ if (!WHY_NOT_GUARD_RE.test(body)) {
228
+ malformed.push('missing `Why not a guard:` (rule 28 makes it mandatory)');
229
+ }
230
+ if (VERSION_IN_ENTRY_RE.test(body)) {
231
+ malformed.push('hold entry names a version, tag or date (rule 13)');
232
+ }
233
+
234
+ const openMark = markerMark(lines, OPENS_MARKER_RE, id);
235
+ const clearMark = markerMark(lines, CLEARS_MARKER_RE, id);
236
+ const clearSpan = stepSpan(lines, CLEARS_MARKER_RE, id).join('\n');
237
+ const clearVerify = (/^[ \t]*verify:[ \t]*(\S.*)$/im.exec(clearSpan)?.[1] ?? '').trim();
238
+ if (openMark === null) {
239
+ malformed.push(`no checkbox carries \`opens-hold: ${id}\``);
240
+ }
241
+ if (clearMark === null) {
242
+ malformed.push(`no checkbox carries \`clears-hold: ${id}\``);
243
+ }
244
+ if (clearMark !== null && !VERIFY_FIELD_RE.test(clearSpan)) {
245
+ malformed.push(`the \`clears-hold: ${id}\` step carries no \`verify:\` field`);
246
+ }
247
+
248
+ if (malformed.length > 0) {
249
+ return {
250
+ id,
251
+ channel,
252
+ openedBy,
253
+ clearedBy,
254
+ clearVerify,
255
+ state: 'not-evaluable' as const,
256
+ file,
257
+ malformed,
258
+ };
259
+ }
260
+
261
+ const state: HoldState = !isChecked(openMark!)
262
+ ? 'unopened'
263
+ : isChecked(clearMark!)
264
+ ? 'cleared'
265
+ : 'open';
266
+
267
+ return { id, channel, openedBy, clearedBy, clearVerify, state, file, malformed: [] };
268
+ });
269
+ }
270
+
271
+ /**
272
+ * Read and evaluate one file. An unreadable file is `not-evaluable`, never
273
+ * absent — rule 28's state table has no row on which a read failure permits.
274
+ */
275
+ export function evaluateFile(path: string): Hold[] {
276
+ let text: string;
277
+ try {
278
+ text = readFileSync(path, 'utf8');
279
+ } catch (exc) {
280
+ return [
281
+ {
282
+ id: '<unreadable>',
283
+ channel: 'all',
284
+ openedBy: '',
285
+ clearedBy: '',
286
+ clearVerify: '',
287
+ state: 'not-evaluable',
288
+ file: path,
289
+ malformed: [`file could not be read: ${(exc as Error).message}`],
290
+ },
291
+ ];
292
+ }
293
+ try {
294
+ return evaluateHolds(text, path);
295
+ } catch (exc) {
296
+ return [
297
+ {
298
+ id: '<evaluator-error>',
299
+ channel: 'all',
300
+ openedBy: '',
301
+ clearedBy: '',
302
+ clearVerify: '',
303
+ state: 'not-evaluable',
304
+ file: path,
305
+ malformed: [`evaluator errored: ${(exc as Error).message}`],
306
+ },
307
+ ];
308
+ }
309
+ }
310
+
311
+ /** Does `hold` refuse a cut on `cut`? See `CutChannel` for the two tokens. */
312
+ export function refuses(hold: Hold, cut: CutChannel): boolean {
313
+ if (hold.state === 'not-evaluable') {
314
+ return true;
315
+ }
316
+ if (hold.state !== 'open') {
317
+ return false;
318
+ }
319
+ return cut === 'all' ? true : hold.channel === 'all';
320
+ }
321
+
322
+ /* ------------------------------------------------------------------ *
323
+ * Lifecycle — rule 28's per-folder half.
324
+ *
325
+ * "A window does not disappear by moving the file." The three folders answer
326
+ * differently and the difference is the whole point:
327
+ *
328
+ * archive/, skipped/ the move is REFUSED while a window is open. Both mean
329
+ * "no further work", and an open window is unfinished
330
+ * work by definition, so the move would retire a refusal
331
+ * nobody would ever clear.
332
+ * later/ the move is PERMITTED — the work resumes — but the open
333
+ * window must be named in `entry_condition.what`, so the
334
+ * thing that has to come back is written where the wake
335
+ * contract can be read. It still lists and still refuses.
336
+ *
337
+ * Deletion is NOT handled here and cannot be: a gate sees files, and a deleted
338
+ * file is not one. Rule 12 forbids the delete; this is a recorded residual.
339
+ * ------------------------------------------------------------------ */
340
+
341
+ /** The folders a move is refused into while a window is open. */
342
+ export const REFUSING_DIRS = ['archive', 'skipped'] as const;
343
+
344
+ export interface LifecycleViolation {
345
+ readonly file: string;
346
+ readonly holdId: string;
347
+ readonly reason: string;
348
+ }
349
+
350
+ /** The roadmap subfolder of `file`, or `''` when it sits in the active root. */
351
+ export function holdFolder(file: string): string {
352
+ const m = /agents\/roadmaps\/([^/]+)\//.exec(file.split(path.sep).join('/'));
353
+ return m ? m[1]! : '';
354
+ }
355
+
356
+ /**
357
+ * Lifecycle violations for one already-evaluated file.
358
+ *
359
+ * Takes the holds rather than re-reading, so the caller's evaluation is the one
360
+ * reported on — a second read could disagree with the first and the disagreement
361
+ * would be invisible.
362
+ */
363
+ export function lifecycleViolations(
364
+ file: string,
365
+ holds: readonly Hold[],
366
+ text: string,
367
+ ): LifecycleViolation[] {
368
+ const out: LifecycleViolation[] = [];
369
+ const folder = holdFolder(file);
370
+ // `not-evaluable` is deliberately included: a declaration nobody can read
371
+ // is not a declaration anybody can clear, so retiring it is the same defect.
372
+ const live = holds.filter((h) => h.state === 'open' || h.state === 'not-evaluable');
373
+ if (live.length === 0) {
374
+ return out;
375
+ }
376
+
377
+ if ((REFUSING_DIRS as readonly string[]).includes(folder)) {
378
+ for (const h of live) {
379
+ out.push({
380
+ file,
381
+ holdId: h.id,
382
+ reason:
383
+ `refused: a roadmap with a ${h.state} window may not live in \`${folder}/\` ` +
384
+ `— finish the clearing step \`${h.clearedBy || '?'}\` first, or move it to \`later/\``,
385
+ });
386
+ }
387
+ return out;
388
+ }
389
+
390
+ if (folder === 'later') {
391
+ const what = entryConditionParts(_frontmatter(text)).parts['what'] ?? '';
392
+ for (const h of live) {
393
+ if (!what.toLowerCase().includes(h.id.toLowerCase())) {
394
+ out.push({
395
+ file,
396
+ holdId: h.id,
397
+ reason:
398
+ `a \`later/\` roadmap with an ${h.state} window must name the hold id in ` +
399
+ `\`entry_condition.what\` — it is absent there`,
400
+ });
401
+ }
402
+ }
403
+ }
404
+ return out;
405
+ }
406
+
407
+ /**
408
+ * The refusal message, or `null` when the cut is permitted.
409
+ *
410
+ * One formatter, used by BOTH `check_release_holds --require-safe` and
411
+ * `release.ts`'s pre-flight, because two copies would drift and the drift would
412
+ * land where it hurts most: an operator reading one message at the CLI and a
413
+ * different one at the cut.
414
+ *
415
+ * Five fields per hold — roadmap, hold, opener, closer, and the closer's
416
+ * `verify:` command — because a refusal that does not say what to run to clear
417
+ * it has told the operator they are stuck rather than what to do.
418
+ */
419
+ export function refusalReport(holds: readonly Hold[], cut: CutChannel): string | null {
420
+ const blocking = holds.filter((h) => refuses(h, cut));
421
+ if (blocking.length === 0) {
422
+ return null;
423
+ }
424
+ const lines: string[] = [
425
+ `release-holds: REFUSED — ${blocking.length} hold(s) block this cut (channel ${cut})`,
426
+ ];
427
+ for (const h of blocking) {
428
+ lines.push('');
429
+ lines.push(` hold: ${h.id} [${h.state}, channel ${h.channel}]`);
430
+ lines.push(` roadmap: ${h.file}`);
431
+ if (h.state === 'not-evaluable') {
432
+ for (const why of h.malformed) {
433
+ lines.push(` not evaluable: ${why}`);
434
+ }
435
+ continue;
436
+ }
437
+ lines.push(` opened by: ${h.openedBy}`);
438
+ lines.push(` cleared by: ${h.clearedBy}`);
439
+ lines.push(` its verify: ${h.clearVerify || '(none recorded)'}`);
440
+ }
441
+ lines.push(
442
+ '',
443
+ ' Three ways forward, and the choice is yours — there is no override flag:',
444
+ ' 1. finish the clearing step (run its `verify:` above, then flip it)',
445
+ ' 2. cut `-next.N` instead, if every blocking hold is `Channel: latest`',
446
+ ' 3. use a release line — docs/contracts/release-trunk-sync.md',
447
+ );
448
+ return lines.join('\n');
449
+ }
450
+
451
+ /**
452
+ * The release pre-flight — refuse the cut while a declared tree state is
453
+ * unpublishable (template rule 28).
454
+ *
455
+ * Lives here rather than in `release.ts` for the reason that file's sibling
456
+ * `_lib/release_position.ts` states in its own docblock: `release.ts` sits far
457
+ * over the 1,500-line source ratchet, so growing it is a gate failure by
458
+ * construction, and `_lib/` is where the predicates the pipeline and its gates
459
+ * share already live. Its caller is the LAST check in `preflight()`, which runs
460
+ * before step 1 and before `execute()` — so a refusal costs nothing: no branch,
461
+ * no tag, no push has happened yet.
462
+ *
463
+ * The channel is always `all` because `release.ts` accepts only a bare `X.Y.Z`,
464
+ * so the cut it performs is always the stable one. The `-next.N` escape is named
465
+ * in the refusal text and never taken automatically: silently converting a
466
+ * stable cut into a prerelease is exactly the channel redirect rule 28 forbids,
467
+ * and the choice stays the operator's.
468
+ *
469
+ * `die` is injected rather than imported to keep the dependency pointing one
470
+ * way — `release.ts` already owns the process-exit semantics, and a `_lib`
471
+ * module that exits on its own behalf is not unit-testable.
472
+ */
473
+ export function releaseHoldPreflight(root: string, die: (msg: string) => void): void {
474
+ const holds: Hold[] = [];
475
+ for (const sub of ['', 'later', 'archive', 'skipped', 'stubs']) {
476
+ const dir = path.join(root, 'agents', 'roadmaps', sub);
477
+ let names: string[];
478
+ try {
479
+ names = readdirSync(dir)
480
+ .filter((n) => n.endsWith('.md'))
481
+ .sort();
482
+ } catch {
483
+ continue;
484
+ }
485
+ for (const n of names) {
486
+ holds.push(...evaluateFile(path.join(dir, n)));
487
+ }
488
+ }
489
+ const report = refusalReport(holds, 'all');
490
+ if (report !== null) {
491
+ die(report);
492
+ }
493
+ }
@@ -0,0 +1,184 @@
1
+ /**
2
+ * The two MIGRATION.md refusals a major cut has to clear.
3
+ *
4
+ * FORWARD — {@link assert_scheduled_deprecations_clear}: a commitment in the
5
+ * scheduled-deprecations table due at or before the major being cut.
6
+ * BACKWARD — {@link assert_major_migration_section}: a major that SHIPS a
7
+ * breaking change and tells a consumer nothing about it.
8
+ *
9
+ * They live together because they are the two halves of one obligation and
10
+ * were written eleven months apart, the second only after the first half's
11
+ * absence let 15.0.0 and 16.0.0 ship with no section at all. Extracted out of
12
+ * `release.ts` in the same change that added the backward half: that file sits
13
+ * 375 lines over the 1,500-line source ceiling `check_source_size_budget`
14
+ * ratchets, and the gate's own contract names a split as the way a total comes
15
+ * down rather than a baseline going up.
16
+ *
17
+ * Both keep the preview semantics `release.ts` asserts: under `--dry-run` they
18
+ * PRINT what the real run will refuse and return, because `--dry-run exits 0`
19
+ * is a contract that file's tests pin.
20
+ */
21
+ import * as fs from 'node:fs';
22
+ import * as path from 'node:path';
23
+
24
+ import { REPO_ROOT } from '../release_env.js';
25
+ import { die, run, type RunResult } from '../release_publication.js';
26
+ import {
27
+ MIGRATION_PATH as MAJOR_MIGRATION_PATH,
28
+ pendingMajorFinding,
29
+ } from '../lint_major_migration_sections.js';
30
+
31
+ /**
32
+ * Minor/patch components of a bare `X.Y.Z`.
33
+ *
34
+ * A local two-line parse rather than an import: `release.ts`'s `parse_version`
35
+ * is module-private there, and exporting it to serve one caller would widen
36
+ * that file's surface in the change whose point is to narrow it.
37
+ */
38
+ function _minor_patch(v: string): [number, number] {
39
+ const m = /^(\d+)\.(\d+)\.(\d+)$/.exec(v.trim());
40
+ if (m === null) {
41
+ throw new Error(`not a bare version: ${v}`);
42
+ }
43
+ return [Number(m[2]), Number(m[3])];
44
+ }
45
+
46
+ /**
47
+ * Refuse a MAJOR cut that carries a scheduled deprecation due at or before it.
48
+ *
49
+ * **The trigger is the shape of the TARGET, not a comparison with the current
50
+ * version.** A major target is `X.0.0` — and that one predicate covers every
51
+ * path to a major: the `--as major` flag, an explicit `--version 13.0.0`,
52
+ * auto-detection from a `feat!:` commit, AND `--resume`, where
53
+ * `_detect_in_flight_target()` returns the already-bumped `package.json`
54
+ * version so `target === current` and any current-vs-target comparison
55
+ * silently returns. Resume was the fourth path an earlier version of this
56
+ * guard missed while its own comment claimed three paths converged.
57
+ *
58
+ * Refusing a resumed release can strand a partially-completed one, and that is
59
+ * the deliberate trade: a stranded release is recoverable by fixing the table
60
+ * and resuming again, whereas a major shipped over a missed commitment is the
61
+ * failure this whole surface exists to prevent.
62
+ *
63
+ * The target version is PASSED to the gate. Without it the gate falls back to
64
+ * `package.json`, which at the cut to N still reads N-1 — so a row committed
65
+ * to N reads as one major early and passes, and only rows already a major late
66
+ * could ever be refused. That is the lateness being prevented, so measuring
67
+ * against the shipped version would have made the refusal fire exactly one
68
+ * major too late, forever.
69
+ *
70
+ * Runs under `--dry-run` too, unlike the rest of preflight: this check is one
71
+ * subprocess reading two files, so the "keep a preview fast" rationale that
72
+ * excludes the ~15s test-trend collection does not apply — and a preview that
73
+ * reports green for the single condition that will refuse the real run is the
74
+ * case an operator runs a preview to discover. It REPORTS there rather than
75
+ * dying: `--dry-run` exiting 0 before `execute()` and before `preflight()` is a
76
+ * contract this file's own tests assert, and an earlier revision of this guard
77
+ * broke it by sitting above the dry-run branch with no preview mode.
78
+ *
79
+ * @param runner Seam for the gate invocation. Production passes nothing and
80
+ * gets the real `run`; tests inject a stub, because the alternative — reaching
81
+ * this branch only by mutating `docs/MIGRATION.md` — would make the refusal
82
+ * path testable exclusively through a tracked-file edit.
83
+ */
84
+ export function assert_scheduled_deprecations_clear(
85
+ target: string,
86
+ runner: (args: readonly string[]) => RunResult = (args) => run(args, { check: false, capture: true }),
87
+ opts: { previewOnly?: boolean } = {},
88
+ ): void {
89
+ const [minor, patch] = _minor_patch(target);
90
+ if (minor !== 0 || patch !== 0) {
91
+ return;
92
+ }
93
+ const res = runner([
94
+ './scripts-run',
95
+ 'src/scripts/lint_scheduled_deprecations',
96
+ '--cutting',
97
+ target,
98
+ ]);
99
+ if (res.returncode === 0) {
100
+ return;
101
+ }
102
+ process.stderr.write(res.stdout);
103
+ process.stderr.write(res.stderr);
104
+ if (res.returncode !== 1) {
105
+ // The gate reserves 1 for a finding and 2 for a usage/environment
106
+ // failure. Diagnosing the latter as "the table has an overdue row"
107
+ // sends the releaser to edit a file that is not the problem.
108
+ die(
109
+ `refusing the ${target} cut: the scheduled-deprecations check could not run ` +
110
+ `(exit ${String(res.returncode)}). That is an environment or usage failure, not a ` +
111
+ 'finding in docs/MIGRATION.md — fix the invocation or the checkout, then re-run.',
112
+ );
113
+ }
114
+ if (opts.previewOnly === true) {
115
+ // The preview's job is to SHOW what the real run will refuse. Dying
116
+ // here would break the `--dry-run exits 0` contract asserted elsewhere
117
+ // in this file's tests — trading a documented exit code for a message
118
+ // that has already been printed above.
119
+ process.stderr.write(
120
+ `\n(dry-run) the ${target} cut WOULD BE REFUSED for the reason above. ` +
121
+ 'Previewing only; exit code unchanged.\n',
122
+ );
123
+ return;
124
+ }
125
+ die(
126
+ `refusing the ${target} cut: the scheduled-deprecations table in docs/MIGRATION.md ` +
127
+ 'has a row due at or before this major, or one that cannot be resolved. Act on ' +
128
+ "it — perform the removal in its own change, or revise the row's commitment and " +
129
+ 'record why the surface stays — then re-run.',
130
+ );
131
+ }
132
+
133
+ /**
134
+ * Refuse a MAJOR cut whose BREAKING section has no `docs/MIGRATION.md` entry.
135
+ *
136
+ * The BACKWARD half of the pre-flight MIGRATION.md obligation.
137
+ * {@link assert_scheduled_deprecations_clear} above is the forward half —
138
+ * commitments due at a future major — and it is the half that already had a
139
+ * gate. Nothing checked that a major SHIPPING a breaking change told a consumer
140
+ * what to do about it, and 15.0.0 and 16.0.0 both shipped without a section.
141
+ *
142
+ * It runs at the call site rather than inside `preflight()` because the
143
+ * comparand does not exist yet at pre-flight time: the section is rendered
144
+ * later and is not prepended to `CHANGELOG.md` until `execute()`. Reading the
145
+ * file in pre-flight would compare against the PREVIOUS release.
146
+ *
147
+ * Preview semantics match the sibling exactly — `--dry-run` prints what the
148
+ * real run will refuse and returns, because `--dry-run exits 0` is a contract
149
+ * this file's own tests assert.
150
+ *
151
+ * @param migrationReader Seam for the file read, so a test can drive the
152
+ * refusal without mutating a tracked file.
153
+ */
154
+ export function assert_major_migration_section(
155
+ target: string,
156
+ changelog_entry: string,
157
+ opts: { previewOnly?: boolean } = {},
158
+ migrationReader: () => string = () =>
159
+ fs.readFileSync(path.join(REPO_ROOT, MAJOR_MIGRATION_PATH), 'utf-8'),
160
+ ): void {
161
+ let migration: string;
162
+ try {
163
+ migration = migrationReader();
164
+ } catch (exc) {
165
+ die(
166
+ `refusing the ${target} cut: cannot read ${MAJOR_MIGRATION_PATH} ` +
167
+ `(${exc instanceof Error ? exc.message : String(exc)}). That is an environment ` +
168
+ 'failure, not a finding — fix the checkout, then re-run.',
169
+ );
170
+ }
171
+ const finding = pendingMajorFinding(target, changelog_entry, migration);
172
+ if (finding === null) {
173
+ return;
174
+ }
175
+ process.stderr.write(`\n${finding}\n`);
176
+ if (opts.previewOnly === true) {
177
+ process.stderr.write(
178
+ `\n(dry-run) the ${target} cut WOULD BE REFUSED for the reason above. ` +
179
+ 'Previewing only; exit code unchanged.\n',
180
+ );
181
+ return;
182
+ }
183
+ die(`refusing the ${target} cut: ${MAJOR_MIGRATION_PATH} has no section for ${target}.`);
184
+ }