mandrel 2.31.0 → 2.32.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 (250) hide show
  1. package/.agents/README.md +13 -17
  2. package/.agents/agents/acceptance-critic.md +1 -2
  3. package/.agents/docs/SDLC.md +4 -4
  4. package/.agents/docs/agentrc-reference.json +61 -57
  5. package/.agents/docs/configuration.md +274 -227
  6. package/.agents/docs/execution-reference.md +13 -14
  7. package/.agents/docs/quality-gates.md +195 -23
  8. package/.agents/instructions.md +2 -5
  9. package/.agents/rules/git-conventions-reference.md +27 -27
  10. package/.agents/rules/git-conventions.md +4 -2
  11. package/.agents/rules/known-tooling-behavior.md +66 -30
  12. package/.agents/rules/testing-standards.md +35 -71
  13. package/.agents/runtime-deps.json +0 -1
  14. package/.agents/schemas/agentrc.schema.json +1939 -1400
  15. package/.agents/schemas/lifecycle/README.md +21 -14
  16. package/.agents/schemas/lifecycle/ledger-record.schema.json +76 -22
  17. package/.agents/schemas/story-deliver-terminal.schema.json +2 -2
  18. package/.agents/scripts/README.md +7 -29
  19. package/.agents/scripts/apply-quality-bootstrap.js +27 -34
  20. package/.agents/scripts/bootstrap.js +28 -26
  21. package/.agents/scripts/check-baseline-drift.js +73 -13
  22. package/.agents/scripts/check-baseline-scope.js +362 -0
  23. package/.agents/scripts/check-dead-exports.js +9 -1
  24. package/.agents/scripts/check-gherkin-corpus.js +508 -0
  25. package/.agents/scripts/check-knip-entries.js +136 -0
  26. package/.agents/scripts/check-lifecycle-lint.js +36 -112
  27. package/.agents/scripts/check-schema-references.js +1 -1
  28. package/.agents/scripts/diagnose-friction.js +7 -4
  29. package/.agents/scripts/generate-config-docs.js +263 -171
  30. package/.agents/scripts/install-matrix-assert.js +0 -1
  31. package/.agents/scripts/lib/ITicketingProvider.js +0 -58
  32. package/.agents/scripts/lib/audit-baselines/staleness.js +6 -6
  33. package/.agents/scripts/lib/audit-baselines/trend.js +7 -8
  34. package/.agents/scripts/lib/audit-baselines/weights.js +4 -5
  35. package/.agents/scripts/lib/audit-suite/checklist-threading.js +1 -1
  36. package/.agents/scripts/lib/audit-to-stories/build-story-body.js +0 -1
  37. package/.agents/scripts/lib/baselines/envelope.js +41 -60
  38. package/.agents/scripts/lib/baselines/git-base.js +30 -37
  39. package/.agents/scripts/lib/baselines/kinds/_crap-new-method-gate.js +103 -0
  40. package/.agents/scripts/lib/baselines/kinds/_crap-read.js +150 -0
  41. package/.agents/scripts/lib/baselines/kinds/crap.js +25 -65
  42. package/.agents/scripts/lib/baselines/orphan-pruner.js +233 -0
  43. package/.agents/scripts/lib/baselines/refresh-service.js +6 -8
  44. package/.agents/scripts/lib/baselines/scope-assert.js +223 -0
  45. package/.agents/scripts/lib/baselines/scope-inventory.js +314 -0
  46. package/.agents/scripts/lib/bdd-step-index.js +326 -0
  47. package/.agents/scripts/lib/bootstrap/install-ledger.js +5 -3
  48. package/.agents/scripts/lib/bootstrap/issue-forms-template.js +4 -6
  49. package/.agents/scripts/lib/bootstrap/manifest.js +17 -40
  50. package/.agents/scripts/lib/bootstrap/project-bootstrap.js +12 -59
  51. package/.agents/scripts/lib/bootstrap/quality-bootstrap.js +62 -2
  52. package/.agents/scripts/lib/checks/loop-health.js +9 -37
  53. package/.agents/scripts/lib/child-exec.js +193 -0
  54. package/.agents/scripts/lib/cli/standard-args.js +1 -1
  55. package/.agents/scripts/lib/cli-args.js +64 -0
  56. package/.agents/scripts/lib/close-validation/gates.js +2 -2
  57. package/.agents/scripts/lib/close-validation/runner.js +3 -3
  58. package/.agents/scripts/lib/config/acceptance-eval.js +5 -52
  59. package/.agents/scripts/lib/config/commands.js +3 -5
  60. package/.agents/scripts/lib/config/explain.js +5 -7
  61. package/.agents/scripts/lib/config/gates/bundle-size.schema.js +32 -6
  62. package/.agents/scripts/lib/config/gates/coverage.schema.js +25 -5
  63. package/.agents/scripts/lib/config/gates/crap-incremental-coverage.schema.js +12 -2
  64. package/.agents/scripts/lib/config/gates/crap.schema.js +68 -23
  65. package/.agents/scripts/lib/config/gates/duplication.schema.js +29 -17
  66. package/.agents/scripts/lib/config/gates/index.js +5 -2
  67. package/.agents/scripts/lib/config/gates/lighthouse.schema.js +34 -6
  68. package/.agents/scripts/lib/config/gates/lint.schema.js +11 -2
  69. package/.agents/scripts/lib/config/gates/maintainability.schema.js +37 -15
  70. package/.agents/scripts/lib/config/gates/mutation.schema.js +15 -3
  71. package/.agents/scripts/lib/config/gates/shared.js +58 -9
  72. package/.agents/scripts/lib/config/github.js +0 -1
  73. package/.agents/scripts/lib/config/limits.js +3 -48
  74. package/.agents/scripts/lib/config/qa.js +105 -0
  75. package/.agents/scripts/lib/config/temp-paths.js +6 -5
  76. package/.agents/scripts/lib/config-settings-schema-delivery.js +237 -56
  77. package/.agents/scripts/lib/config-settings-schema-quality.js +209 -29
  78. package/.agents/scripts/lib/config-settings-schema.js +386 -39
  79. package/.agents/scripts/lib/crap-baseline-join.js +126 -9
  80. package/.agents/scripts/lib/crap-utils.js +84 -520
  81. package/.agents/scripts/lib/dead-exports-knip.js +79 -10
  82. package/.agents/scripts/lib/degraded-mode.js +2 -2
  83. package/.agents/scripts/lib/doc-tiers.js +3 -3
  84. package/.agents/scripts/lib/feedback-loop/graduator-core.js +46 -104
  85. package/.agents/scripts/lib/feedback-loop/retro-proposals-graduator.js +10 -8
  86. package/.agents/scripts/lib/fs-walk.js +52 -0
  87. package/.agents/scripts/lib/git-branch-lifecycle.js +2 -2
  88. package/.agents/scripts/lib/git-utils.js +16 -36
  89. package/.agents/scripts/lib/knip-entry-sync.js +469 -0
  90. package/.agents/scripts/lib/observability/metrics-ledger.js +1 -1
  91. package/.agents/scripts/lib/observability/runtime-friction.js +10 -0
  92. package/.agents/scripts/lib/observability/signal-validator.js +5 -85
  93. package/.agents/scripts/lib/observability/signals-writer.js +19 -62
  94. package/.agents/scripts/lib/observability/source-classifier.js +5 -7
  95. package/.agents/scripts/lib/observability/terse-result.js +3 -3
  96. package/.agents/scripts/lib/orchestration/behind-recovery.js +114 -0
  97. package/.agents/scripts/lib/orchestration/ceremony-routing.js +7 -8
  98. package/.agents/scripts/lib/orchestration/ci-rerun-guard.js +34 -33
  99. package/.agents/scripts/lib/orchestration/code-review.js +2 -2
  100. package/.agents/scripts/lib/orchestration/complexity-gate.js +43 -161
  101. package/.agents/scripts/lib/orchestration/diff-magnitude.js +4 -4
  102. package/.agents/scripts/lib/orchestration/label-transitions.js +3 -2
  103. package/.agents/scripts/lib/orchestration/lease-guard-shared.js +12 -38
  104. package/.agents/scripts/lib/orchestration/lifecycle/emit-merge-unlanded.js +5 -6
  105. package/.agents/scripts/lib/orchestration/plan-metrics.js +2 -3
  106. package/.agents/scripts/lib/orchestration/plan-persist/persist-helpers.js +6 -0
  107. package/.agents/scripts/lib/orchestration/plan-persist/story-ops.js +0 -1
  108. package/.agents/scripts/lib/orchestration/{lifecycle/listeners/watcher.js → pr-watch.js} +58 -208
  109. package/.agents/scripts/lib/orchestration/resolve-stories.js +5 -15
  110. package/.agents/scripts/lib/orchestration/review-providers/codex.js +1 -1
  111. package/.agents/scripts/lib/orchestration/review-providers/mi-exemptions.js +130 -0
  112. package/.agents/scripts/lib/orchestration/review-providers/native.js +30 -16
  113. package/.agents/scripts/lib/orchestration/single-story-close/phases/code-review.js +1 -1
  114. package/.agents/scripts/lib/orchestration/single-story-close/phases/confirm-merge.js +37 -26
  115. package/.agents/scripts/lib/orchestration/single-story-close/phases/conventional-subject.js +376 -0
  116. package/.agents/scripts/lib/orchestration/single-story-close/phases/normalize-pr-title.js +161 -151
  117. package/.agents/scripts/lib/orchestration/single-story-close/phases/options.js +15 -3
  118. package/.agents/scripts/lib/orchestration/single-story-close/phases/pull-request.js +10 -15
  119. package/.agents/scripts/lib/orchestration/single-story-close/phases/review-block.js +5 -0
  120. package/.agents/scripts/lib/orchestration/single-story-close/phases/review-override.js +157 -0
  121. package/.agents/scripts/lib/orchestration/single-story-close/phases/worktree-reap.js +0 -14
  122. package/.agents/scripts/lib/orchestration/single-story-close/runner.js +59 -25
  123. package/.agents/scripts/lib/orchestration/single-story-lease-guard.js +20 -31
  124. package/.agents/scripts/lib/orchestration/spec-spill.js +17 -3
  125. package/.agents/scripts/lib/orchestration/story-close/phases/review-core.js +7 -6
  126. package/.agents/scripts/lib/orchestration/story-deliver-terminal.js +2 -1
  127. package/.agents/scripts/lib/orchestration/task-body-validator.js +4 -1
  128. package/.agents/scripts/lib/orchestration/ticket-lease.js +28 -127
  129. package/.agents/scripts/lib/orchestration/ticket-validator-sizing.js +1 -1
  130. package/.agents/scripts/lib/orchestration/ticketing/reads.js +5 -5
  131. package/.agents/scripts/lib/orchestration/ticketing/transition.js +5 -4
  132. package/.agents/scripts/lib/orchestration/verify-tier-repair.js +107 -0
  133. package/.agents/scripts/lib/qa/coverage-verdict.js +5 -87
  134. package/.agents/scripts/lib/signals/detectors/common.js +1 -1
  135. package/.agents/scripts/lib/signals/index.js +8 -6
  136. package/.agents/scripts/lib/signals/schema.js +20 -25
  137. package/.agents/scripts/lib/signals/write.js +8 -8
  138. package/.agents/scripts/lib/story-body/story-body.js +12 -59
  139. package/.agents/scripts/lib/temp-retention.js +1 -1
  140. package/.agents/scripts/lib/templates/decomposer-prompts.js +16 -14
  141. package/.agents/scripts/lib/ticket-body-sections.js +4 -5
  142. package/.agents/scripts/lib/worktree/lifecycle/merge-reachability.js +13 -45
  143. package/.agents/scripts/lib/worktree/lifecycle/reap.js +4 -5
  144. package/.agents/scripts/lib/worktree-manager.js +2 -3
  145. package/.agents/scripts/lint-label-vocabulary.js +2 -24
  146. package/.agents/scripts/pr-watch-with-update.js +7 -5
  147. package/.agents/scripts/providers/github/cache.js +2 -2
  148. package/.agents/scripts/providers/github/comments.js +6 -28
  149. package/.agents/scripts/providers/github/compose.js +0 -15
  150. package/.agents/scripts/providers/github/errors.js +10 -27
  151. package/.agents/scripts/providers/github/request-helpers.js +1 -2
  152. package/.agents/scripts/providers/github/sub-issues.js +10 -218
  153. package/.agents/scripts/providers/github.js +4 -7
  154. package/.agents/scripts/prune-baseline-orphans.js +181 -0
  155. package/.agents/scripts/resolve-stories.js +0 -2
  156. package/.agents/scripts/run-lint.js +61 -61
  157. package/.agents/scripts/run-test-profile.js +6 -6
  158. package/.agents/scripts/run-verify.js +48 -30
  159. package/.agents/scripts/single-story-close.js +20 -0
  160. package/.agents/scripts/single-story-init.js +12 -35
  161. package/.agents/scripts/update-dead-exports-baseline.js +321 -0
  162. package/.agents/skills/core/gates-and-baselines/SKILL.md +2 -2
  163. package/.agents/skills/skills.index.json +1 -11
  164. package/.agents/workflows/audit-documentation.md +5 -6
  165. package/.agents/workflows/audit-to-stories.md +2 -2
  166. package/.agents/workflows/helpers/audit-lens-core.md +11 -12
  167. package/.agents/workflows/helpers/code-quality-guardrails.md +15 -14
  168. package/.agents/workflows/helpers/code-review.md +3 -8
  169. package/.agents/workflows/helpers/deliver-reference.md +2 -1
  170. package/.agents/workflows/helpers/deliver-story-reference.md +27 -16
  171. package/.agents/workflows/helpers/worktree-lifecycle.md +1 -2
  172. package/.agents/workflows/mandrel-update.md +10 -10
  173. package/.agents/workflows/qa-assist.md +15 -20
  174. package/.agents/workflows/qa-explore.md +9 -8
  175. package/README.md +1 -1
  176. package/docs/CHANGELOG.md +42 -0
  177. package/lib/migrations/index.js +2 -0
  178. package/lib/migrations/steps/2.32.0-retire-lint-baseline-command.js +127 -0
  179. package/package.json +12 -3
  180. package/.agents/schemas/lifecycle/checkpoint.written.schema.json +0 -13
  181. package/.agents/schemas/lifecycle/close-validate.end.schema.json +0 -18
  182. package/.agents/schemas/lifecycle/close-validate.start.schema.json +0 -13
  183. package/.agents/schemas/lifecycle/code-review.end.schema.json +0 -30
  184. package/.agents/schemas/lifecycle/code-review.start.schema.json +0 -12
  185. package/.agents/schemas/lifecycle/intervention.recorded.schema.json +0 -15
  186. package/.agents/schemas/lifecycle/loop.tick.schema.json +0 -20
  187. package/.agents/schemas/lifecycle/notification.emitted.schema.json +0 -18
  188. package/.agents/schemas/lifecycle/pr.created.schema.json +0 -14
  189. package/.agents/schemas/lifecycle/retro.end.schema.json +0 -16
  190. package/.agents/schemas/lifecycle/retro.start.schema.json +0 -12
  191. package/.agents/schemas/lifecycle/story.blocked.schema.json +0 -13
  192. package/.agents/schemas/lifecycle/story.dispatch.end.schema.json +0 -17
  193. package/.agents/schemas/lifecycle/story.dispatch.start.schema.json +0 -15
  194. package/.agents/schemas/lifecycle/story.merged.schema.json +0 -13
  195. package/.agents/scripts/check-gherkin-placeholders.js +0 -663
  196. package/.agents/scripts/check-lifecycle-doc-drift.js +0 -411
  197. package/.agents/scripts/lib/audit-suite/cli.js +0 -64
  198. package/.agents/scripts/lib/bootstrap/baselines-layout-migration.js +0 -202
  199. package/.agents/scripts/lib/bootstrap/ci-workflow-template.js +0 -212
  200. package/.agents/scripts/lib/checks/baseline-drift-main-checkout.js +0 -104
  201. package/.agents/scripts/lib/checks/push-hook-parity.js +0 -106
  202. package/.agents/scripts/lib/checks/windows-coverage-noise-floor.js +0 -92
  203. package/.agents/scripts/lib/checks/worktree-bootstrap-env.js +0 -81
  204. package/.agents/scripts/lib/checks/worktree-residue-biome.js +0 -55
  205. package/.agents/scripts/lib/crap-baseline-index.js +0 -46
  206. package/.agents/scripts/lib/crap-utils-incremental.js +0 -113
  207. package/.agents/scripts/lib/dynamic-workflow/capability.js +0 -396
  208. package/.agents/scripts/lib/feedback-loop/audit-results-graduator.js +0 -335
  209. package/.agents/scripts/lib/mutation/baseline-snapshot.js +0 -239
  210. package/.agents/scripts/lib/mutation/config-detector.js +0 -119
  211. package/.agents/scripts/lib/mutation/stryker-runner.js +0 -306
  212. package/.agents/scripts/lib/mutation/survivor-report.js +0 -160
  213. package/.agents/scripts/lib/observability/active-story-env.js +0 -170
  214. package/.agents/scripts/lib/observability/tool-trace-hook.js +0 -456
  215. package/.agents/scripts/lib/orchestration/acceptance-clusters.js +0 -111
  216. package/.agents/scripts/lib/orchestration/context-envelope.js +0 -277
  217. package/.agents/scripts/lib/orchestration/detectors-phase.js +0 -194
  218. package/.agents/scripts/lib/orchestration/lifecycle/bus.js +0 -309
  219. package/.agents/scripts/lib/orchestration/lifecycle/emit-loop-tick.js +0 -181
  220. package/.agents/scripts/lib/orchestration/lifecycle/ledger-writer.js +0 -229
  221. package/.agents/scripts/lib/orchestration/lifecycle/listeners/README.md +0 -54
  222. package/.agents/scripts/lib/orchestration/lifecycle/trace-logger.js +0 -344
  223. package/.agents/scripts/lib/orchestration/lint-baseline-service.js +0 -114
  224. package/.agents/scripts/lib/orchestration/pr-base-guard.js +0 -37
  225. package/.agents/scripts/lib/orchestration/resolves-token.js +0 -127
  226. package/.agents/scripts/lib/orchestration/spec-section-validator.js +0 -130
  227. package/.agents/scripts/lib/orchestration/story-close/emit-blocked.js +0 -55
  228. package/.agents/scripts/lib/orchestration/story-close/phases/code-review.js +0 -211
  229. package/.agents/scripts/lib/planning-corpus.js +0 -37
  230. package/.agents/scripts/lib/qa/coverage-report.js +0 -181
  231. package/.agents/scripts/lib/qa/propose-missing-test.js +0 -95
  232. package/.agents/scripts/lib/qa/qa-context-hydrator.js +0 -217
  233. package/.agents/scripts/lib/signals/detectors/index.js +0 -14
  234. package/.agents/scripts/lib/signals/detectors/retry.js +0 -253
  235. package/.agents/scripts/lib/signals/detectors/rework.js +0 -167
  236. package/.agents/scripts/lib/signals/read.js +0 -268
  237. package/.agents/scripts/lib/signals/span-tree.js +0 -291
  238. package/.agents/scripts/lib/story-lifecycle.js +0 -194
  239. package/.agents/scripts/lib/story-plan.js +0 -379
  240. package/.agents/scripts/lib/util/phase-timer-state.js +0 -72
  241. package/.agents/scripts/lib/util/phase-timer.js +0 -163
  242. package/.agents/scripts/lib/workers/combined-mi-crap-worker.js +0 -169
  243. package/.agents/scripts/lint-baseline.js +0 -507
  244. package/.agents/scripts/providers/github/prs.js +0 -103
  245. package/.agents/scripts/signals-view.js +0 -309
  246. package/.agents/scripts/story-plan.js +0 -370
  247. package/.agents/scripts/sync-branch-from-base.js +0 -149
  248. package/.agents/scripts/validate-docs-freshness.js +0 -314
  249. package/.agents/skills/core/diagnose-friction/SKILL.md +0 -78
  250. package/.agents/workflows/helpers/signals.md +0 -112
@@ -19,7 +19,7 @@
19
19
  * module mechanizes it.
20
20
  *
21
21
  * Contract (pure where possible — the only side effect is an injectable
22
- * `git log` read used to derive the type):
22
+ * `git log` read):
23
23
  *
24
24
  * - If `storyTitle` is **already** a parseable Conventional Commit
25
25
  * subject, it is preserved verbatim and suffixed with `(#<storyId>)`.
@@ -28,116 +28,48 @@
28
28
  * `<type>: <descriptive text> (#<storyId>)`. The `type` is derived
29
29
  * from the branch's own (already-conventional) commit subjects when
30
30
  * available, falling back to a safe configured default (`chore`).
31
+ * - Either way, a branch (or Story) that declares a breaking change gets
32
+ * the `!` marker and a `BREAKING CHANGE:` footer on the PR body.
31
33
  *
32
- * Mirrors the already-normalized Epic-finalize default in
33
- * `lib/orchestration/finalize/open-or-locate-pr.js` (`feat: Epic #<id>`),
34
- * bringing the standalone path to the same guarantee.
34
+ * The rules that decide *what* the subject says now live in
35
+ * `conventional-subject.js` type precedence, acronym-safe casing, and
36
+ * breaking-change collection are pure and unit-tested there. What is left
37
+ * here is the git read those rules consume and the assembly of the two
38
+ * strings `gh pr create` needs.
35
39
  */
36
40
 
37
41
  import { gitSpawn as defaultGitSpawn } from '../../../git-utils.js';
38
42
  import { Logger as DefaultLogger } from '../../../Logger.js';
43
+ import {
44
+ collectBreakingNotes,
45
+ isConventionalSubject,
46
+ markBreaking,
47
+ pickDominantType,
48
+ shapeDescription,
49
+ } from './conventional-subject.js';
39
50
 
40
51
  /** Safe default Conventional-Commit type when none can be derived. */
41
52
  const DEFAULT_CONVENTIONAL_TYPE = 'chore';
42
53
 
43
54
  /**
44
- * The Conventional-Commit types Mandrel accepts. Mirrors
45
- * `commitlint.config.js` `type-enum` and `release-please-config.json`
46
- * `changelog-sections`. Kept in sync by hand (single hard-cutover, no
47
- * shim) — adding a type means touching all three.
55
+ * Record separator between whole commit messages in the `git log` read. A NUL
56
+ * cannot occur inside a commit message, so splitting on it is unambiguous —
57
+ * unlike a blank-line or subject-prefix heuristic, which a commit body can
58
+ * forge.
48
59
  */
49
- const CONVENTIONAL_TYPES = Object.freeze([
50
- 'feat',
51
- 'fix',
52
- 'perf',
53
- 'refactor',
54
- 'revert',
55
- 'docs',
56
- 'style',
57
- 'chore',
58
- 'test',
59
- 'build',
60
- 'ci',
61
- ]);
62
-
63
- // Precedence used when a branch carries a mix of conventional types: pick
64
- // the most release-significant one so the squash subject communicates the
65
- // branch's headline impact (and release-please bumps appropriately).
66
- const TYPE_PRECEDENCE = Object.freeze([
67
- 'feat',
68
- 'fix',
69
- 'perf',
70
- 'refactor',
71
- 'revert',
72
- 'docs',
73
- 'style',
74
- 'test',
75
- 'build',
76
- 'ci',
77
- 'chore',
78
- ]);
79
-
80
- const TYPE_GROUP = CONVENTIONAL_TYPES.join('|');
81
- // Anchored Conventional-Commit header matcher:
82
- // <type>(<optional scope>)<optional !>: <non-empty description>
83
- // Mirrors the shape `@commitlint/config-conventional` enforces (a known
84
- // type, an optional parenthesised scope, an optional breaking `!`, a
85
- // colon-space separator, and a non-empty subject). Used for the pure
86
- // "is this already conventional?" check and to pull the type off a branch
87
- // commit subject without spawning commitlint per call.
88
- const CONVENTIONAL_HEADER_RE = new RegExp(
89
- `^(?:${TYPE_GROUP})(?:\\([^()\\r\\n]+\\))?!?: \\S.*$`,
90
- );
91
- const LEADING_TYPE_RE = new RegExp(
92
- `^(${TYPE_GROUP})(?:\\([^()\\r\\n]+\\))?!?:`,
93
- );
94
-
95
- /**
96
- * True iff `subject` is a parseable Conventional Commit subject under the
97
- * repo's type vocabulary. Pure.
98
- *
99
- * @param {string} subject
100
- * @returns {boolean}
101
- */
102
- function isConventionalSubject(subject) {
103
- if (typeof subject !== 'string') return false;
104
- return CONVENTIONAL_HEADER_RE.test(subject.trim());
105
- }
60
+ const RECORD_SEP = '\u0000';
106
61
 
107
62
  /**
108
- * Extract the Conventional-Commit `type` from a single commit subject, or
109
- * `null` when the subject is not conventional. Pure.
63
+ * Read the branch's own commits (those unique to the Story branch relative to
64
+ * the base branch) as whole messages — subject AND body, because the body is
65
+ * where a `BREAKING CHANGE:` footer lives.
110
66
  *
111
- * @param {string} subject
112
- * @returns {string|null}
113
- */
114
- function parseConventionalType(subject) {
115
- if (typeof subject !== 'string') return null;
116
- const match = subject.trim().match(LEADING_TYPE_RE);
117
- return match ? match[1] : null;
118
- }
119
-
120
- /**
121
- * Pick the most release-significant type from a list of conventional
122
- * types, honouring `TYPE_PRECEDENCE`. Returns `null` for an empty list.
123
- * Pure.
67
+ * Returns `[]` when the read fails, which degrades every downstream rule to
68
+ * its safe default (type `chore`, no breaking marker) rather than throwing a
69
+ * close that is otherwise healthy.
124
70
  *
125
- * @param {string[]} types
126
- * @returns {string|null}
127
- */
128
- function pickDominantType(types) {
129
- const present = new Set(types.filter(Boolean));
130
- for (const candidate of TYPE_PRECEDENCE) {
131
- if (present.has(candidate)) return candidate;
132
- }
133
- return null;
134
- }
135
-
136
- /**
137
- * Read the branch's own commit subjects (commits unique to the Story
138
- * branch relative to the base branch) and derive the dominant
139
- * Conventional-Commit type. Returns `DEFAULT_CONVENTIONAL_TYPE` when no
140
- * conventional subject is found or the git read fails.
71
+ * Oldest-first (`--reverse`) is load-bearing: `pickDominantType` breaks a tie
72
+ * on the Story's primary commit, which is the first one authored.
141
73
  *
142
74
  * @param {{
143
75
  * storyBranch: string,
@@ -146,96 +78,174 @@ function pickDominantType(types) {
146
78
  * gitSpawn?: typeof defaultGitSpawn,
147
79
  * logger?: { warn?: Function },
148
80
  * }} args
149
- * @returns {string}
81
+ * @returns {string[]} Whole commit messages, oldest first.
150
82
  */
151
- function deriveTypeFromBranchCommits({
83
+ function readBranchCommits({
152
84
  storyBranch,
153
85
  baseBranch,
154
86
  cwd = process.cwd(),
155
87
  gitSpawn = defaultGitSpawn,
156
88
  logger = DefaultLogger,
157
89
  }) {
90
+ if (!storyBranch || !baseBranch) return [];
91
+ const range = `${baseBranch}..${storyBranch}`;
158
92
  try {
159
- const range = `${baseBranch}..${storyBranch}`;
160
- const result = gitSpawn(cwd, 'log', '--no-merges', '--format=%s', range);
161
- if (!result || result.status !== 0) {
93
+ const result = gitSpawn(
94
+ cwd,
95
+ 'log',
96
+ '--no-merges',
97
+ '--reverse',
98
+ '--format=%B%x00',
99
+ range,
100
+ );
101
+ if (result?.status !== 0) {
162
102
  logger?.warn?.(
163
103
  `[normalize-pr-title] git log ${range} failed (status=${result?.status ?? 'n/a'}); ` +
164
- `defaulting type to "${DEFAULT_CONVENTIONAL_TYPE}".`,
104
+ `defaulting type to "${DEFAULT_CONVENTIONAL_TYPE}" and assuming no breaking change.`,
165
105
  );
166
- return DEFAULT_CONVENTIONAL_TYPE;
106
+ return [];
167
107
  }
168
- const types = String(result.stdout ?? '')
169
- .split('\n')
170
- .map((line) => parseConventionalType(line))
171
- .filter(Boolean);
172
- return pickDominantType(types) ?? DEFAULT_CONVENTIONAL_TYPE;
108
+ return String(result.stdout ?? '')
109
+ .split(RECORD_SEP)
110
+ .map((message) => message.trim())
111
+ .filter((message) => message.length > 0);
173
112
  } catch (err) {
174
113
  logger?.warn?.(
175
- `[normalize-pr-title] could not derive type from branch commits ` +
176
- `(defaulting to "${DEFAULT_CONVENTIONAL_TYPE}"): ${err?.message ?? err}`,
114
+ `[normalize-pr-title] could not read branch commits ` +
115
+ `(defaulting to "${DEFAULT_CONVENTIONAL_TYPE}", no breaking change): ${err?.message ?? err}`,
177
116
  );
178
- return DEFAULT_CONVENTIONAL_TYPE;
117
+ return [];
179
118
  }
180
119
  }
181
120
 
182
121
  /**
183
- * Produce a PR title that parses as a Conventional Commit.
122
+ * Produce the PR title and the breaking-change notes that belong with it.
184
123
  *
185
124
  * - Already-conventional `storyTitle` → preserved verbatim + `(#<id>)`.
186
- * - Otherwise → `<derivedType>: <storyTitle> (#<id>)`.
187
- * - Empty / missing `storyTitle` → `<derivedType>: Story #<id>`.
125
+ * - Otherwise → `<derivedType>: <shaped storyTitle> (#<id>)`.
126
+ * - Empty / missing `storyTitle` → `<derivedType>: story #<id>`.
127
+ * - Breaking → `!` inserted before the colon in either shape.
188
128
  *
189
- * The type derivation (`deriveTypeFromBranchCommits`) is the only side
190
- * effect, and is skipped entirely when the title is already conventional.
129
+ * `commitMessages` is the branch read (`readBranchCommits`); passing `[]`
130
+ * yields the safe default type and no breaking marker. `storyBody` is the
131
+ * Story issue's body, scanned for a declared `BREAKING CHANGE:` footer.
191
132
  *
192
133
  * @param {{
193
134
  * storyTitle: string,
194
135
  * storyId: number|string,
195
- * storyBranch?: string,
196
- * baseBranch?: string,
197
- * cwd?: string,
198
- * gitSpawn?: typeof defaultGitSpawn,
199
- * logger?: { warn?: Function },
136
+ * commitMessages?: string[],
137
+ * storyBody?: string,
200
138
  * }} args
139
+ * @returns {{ title: string, breaking: boolean, breakingNotes: string[] }}
140
+ */
141
+ function normalizePrTitle({
142
+ storyTitle,
143
+ storyId,
144
+ commitMessages = [],
145
+ storyBody = '',
146
+ }) {
147
+ const idSuffix = `(#${storyId})`;
148
+ const trimmed = typeof storyTitle === 'string' ? storyTitle.trim() : '';
149
+ const { breaking, notes } = collectBreakingNotes({
150
+ commitMessages,
151
+ storyBody,
152
+ });
153
+
154
+ // Already conventional → preserve verbatim (the maker's own casing and
155
+ // scope survive), append the id reference. Only the breaking marker may be
156
+ // added, and only when it is not already there.
157
+ const subject = isConventionalSubject(trimmed)
158
+ ? trimmed
159
+ : synthesizeSubject({ description: trimmed, storyId, commitMessages });
160
+
161
+ const marked = breaking ? markBreaking(subject) : subject;
162
+ return { title: `${marked} ${idSuffix}`, breaking, breakingNotes: notes };
163
+ }
164
+
165
+ /**
166
+ * Build a conventional subject for a Story whose title is plain prose.
167
+ *
168
+ * @param {{ description: string, storyId: number|string, commitMessages: string[] }} args
169
+ * @returns {string}
170
+ */
171
+ function synthesizeSubject({ description, storyId, commitMessages }) {
172
+ const subjects = commitMessages.map((message) => message.split('\n')[0]);
173
+ const type = pickDominantType(subjects) ?? DEFAULT_CONVENTIONAL_TYPE;
174
+ const raw = description.length > 0 ? description : `Story #${storyId}`;
175
+ return `${type}: ${shapeDescription(raw)}`;
176
+ }
177
+
178
+ /**
179
+ * Build the PR body.
180
+ *
181
+ * The `Closes #<id>` footer is what auto-closes the Story on merge. A
182
+ * `BREAKING CHANGE:` footer goes LAST, as the spec requires, so that a repo
183
+ * configured to use the PR body as the squash-commit message hands
184
+ * release-please a parseable note rather than prose. When the squash body is
185
+ * built from the constituent commit messages instead (GitHub's default, and
186
+ * this repo's setting), the note still reaches `main` through whichever
187
+ * commit carried the footer — and the `!` in the subject carries the signal
188
+ * either way.
189
+ *
190
+ * @param {{ storyId: number|string, breakingNotes?: string[] }} args
201
191
  * @returns {string}
202
192
  */
203
- export function normalizePrTitle({
193
+ function buildPrBody({ storyId, breakingNotes }) {
194
+ const lines = [`Closes #${storyId}`, '', '_Auto-opened by `/deliver`._'];
195
+ if (breakingNotes.length > 0) {
196
+ lines.push('', `BREAKING CHANGE: ${breakingNotes.join(' ')}`);
197
+ }
198
+ return lines.join('\n');
199
+ }
200
+
201
+ /**
202
+ * Derive the two strings `gh pr create` needs. One `git log` read serves both
203
+ * halves: the commit SUBJECTS decide the type, and the commit BODIES — plus
204
+ * the Story body — decide whether this is a breaking change.
205
+ *
206
+ * A declared break is announced on the progress channel, because a `!` the
207
+ * operator did not expect in the squash subject should be visible while the
208
+ * close is running rather than discovered in the release notes.
209
+ *
210
+ * @param {{ storyTitle: string, storyId: number|string, storyBody?: string,
211
+ * storyBranch: string, baseBranch: string, cwd?: string,
212
+ * gitSpawn?: typeof defaultGitSpawn,
213
+ * progress?: (tag: string, msg: string) => void }} args
214
+ * @returns {{ title: string, body: string, breaking: boolean, breakingNotes: string[] }}
215
+ */
216
+ export function buildPullRequestFields({
204
217
  storyTitle,
205
218
  storyId,
219
+ storyBody = '',
206
220
  storyBranch,
207
221
  baseBranch,
208
222
  cwd = process.cwd(),
209
223
  gitSpawn = defaultGitSpawn,
210
- logger = DefaultLogger,
224
+ progress = () => {},
211
225
  }) {
212
- const idSuffix = `(#${storyId})`;
213
- const trimmed = typeof storyTitle === 'string' ? storyTitle.trim() : '';
214
-
215
- // Already conventional → preserve verbatim, append the id reference.
216
- if (isConventionalSubject(trimmed)) {
217
- return `${trimmed} ${idSuffix}`;
226
+ const commitMessages = readBranchCommits({
227
+ storyBranch,
228
+ baseBranch,
229
+ cwd,
230
+ gitSpawn,
231
+ });
232
+ const { title, breaking, breakingNotes } = normalizePrTitle({
233
+ storyTitle,
234
+ storyId,
235
+ commitMessages,
236
+ storyBody,
237
+ });
238
+ if (breaking) {
239
+ progress(
240
+ 'PR',
241
+ '⚠️ Breaking change declared — the PR title carries `!` and the body a ' +
242
+ `BREAKING CHANGE footer: ${breakingNotes.join(' ') || '(no note text)'}`,
243
+ );
218
244
  }
219
-
220
- // Not conventional → derive a type and synthesize.
221
- const type =
222
- storyBranch && baseBranch
223
- ? deriveTypeFromBranchCommits({
224
- storyBranch,
225
- baseBranch,
226
- cwd,
227
- gitSpawn,
228
- logger,
229
- })
230
- : DEFAULT_CONVENTIONAL_TYPE;
231
-
232
- // Lowercase the leading character of a synthesized description so the
233
- // subject satisfies commitlint's `subject-case` rule (matching the
234
- // `shapeMergeSubject` behaviour). An already-conventional title is left
235
- // untouched (it was preserved verbatim above). The empty-title fallback
236
- // uses a lowercased `story #<id>` for the same reason.
237
- const rawDescription = trimmed.length > 0 ? trimmed : `Story #${storyId}`;
238
- const description =
239
- rawDescription.charAt(0).toLowerCase() + rawDescription.slice(1);
240
- return `${type}: ${description} ${idSuffix}`;
245
+ return {
246
+ title,
247
+ body: buildPrBody({ storyId, breakingNotes }),
248
+ breaking,
249
+ breakingNotes,
250
+ };
241
251
  }
@@ -9,7 +9,11 @@
9
9
  */
10
10
 
11
11
  import path from 'node:path';
12
- import { parseMergeWatchMode, parseSprintArgs } from '../../../cli-args.js';
12
+ import {
13
+ parseMergeWatchMode,
14
+ parseOverrideReviewBlock,
15
+ parseSprintArgs,
16
+ } from '../../../cli-args.js';
13
17
  import { getDeliveryRouting } from '../../../config/delivery-routing.js';
14
18
  import { PROJECT_ROOT } from '../../../project-root.js';
15
19
  import { isOperatorMergeReason } from './auto-merge.js';
@@ -91,8 +95,8 @@ export function resolveWaitForMerge({
91
95
  * (`waitForMergeExplicit` / `noWaitForMerge`) for the runner to resolve once
92
96
  * the config and the arm outcome exist.
93
97
  *
94
- * @param {{ storyIdParam, cwdParam, skipValidationParam, skipSyncParam, noAutoMergeParam, waitForMergeParam, noWaitForMergeParam, maxWaitSecondsParam, mergeWatchModeParam }} raw
95
- * @returns {{ storyId, cwd, skipValidation, skipSync, noAutoMerge, waitForMergeExplicit, noWaitForMerge, maxWaitSeconds, mergeWatchMode }}
98
+ * @param {{ storyIdParam, cwdParam, skipValidationParam, skipSyncParam, noAutoMergeParam, waitForMergeParam, noWaitForMergeParam, maxWaitSecondsParam, mergeWatchModeParam, overrideReviewBlockParam }} raw
99
+ * @returns {{ storyId, cwd, skipValidation, skipSync, noAutoMerge, waitForMergeExplicit, noWaitForMerge, maxWaitSeconds, mergeWatchMode, overrideReviewBlock }}
96
100
  */
97
101
  export function parseCloseOptions({
98
102
  storyIdParam,
@@ -104,6 +108,7 @@ export function parseCloseOptions({
104
108
  noWaitForMergeParam,
105
109
  maxWaitSecondsParam,
106
110
  mergeWatchModeParam,
111
+ overrideReviewBlockParam,
107
112
  }) {
108
113
  // An injecting caller (`storyIdParam` supplied) is not reading argv at all,
109
114
  // so there is nothing to parse and `parsed` stays empty. This used to build a
@@ -148,5 +153,12 @@ export function parseCloseOptions({
148
153
  ? waitForMergeExplicit
149
154
  : undefined,
150
155
  noWaitForMerge: !!resolveFlag(noWaitForMergeParam, parsed.noWaitForMerge),
156
+ // `undefined` when unsupplied, which is what keeps a review
157
+ // critical blocker blocking. An injecting caller passes the reason string
158
+ // directly; both doors run the same validating parser, so neither can arm
159
+ // a reasonless override.
160
+ overrideReviewBlock: parseOverrideReviewBlock(
161
+ resolveFlag(overrideReviewBlockParam, parsed.overrideReviewBlock),
162
+ ),
151
163
  };
152
164
  }
@@ -44,7 +44,7 @@
44
44
  import { gh as defaultGh } from '../../../gh-exec.js';
45
45
  import { Logger } from '../../../Logger.js';
46
46
  import { computeChangeSet as defaultComputeChangeSet } from '../../change-set.js';
47
- import { normalizePrTitle } from './normalize-pr-title.js';
47
+ import { buildPullRequestFields } from './normalize-pr-title.js';
48
48
 
49
49
  /**
50
50
  * Pick the PR this head branch should resolve to from a `gh pr list
@@ -112,6 +112,7 @@ function probeEmptyDiff({ cwd, baseBranch, storyBranch, computeChangeSet }) {
112
112
  * cwd: string,
113
113
  * storyId: number,
114
114
  * storyTitle: string,
115
+ * storyBody?: string,
115
116
  * storyBranch: string,
116
117
  * baseBranch: string,
117
118
  * gh?: ReturnType<typeof import('../../../gh-exec.js').createGh>,
@@ -124,6 +125,7 @@ export async function ensurePullRequestWith({
124
125
  cwd: _cwd,
125
126
  storyId,
126
127
  storyTitle,
128
+ storyBody = '',
127
129
  storyBranch,
128
130
  baseBranch,
129
131
  gh = defaultGh,
@@ -185,26 +187,19 @@ export async function ensurePullRequestWith({
185
187
  }
186
188
 
187
189
  progress('PR', `Opening PR for ${storyBranch} → ${baseBranch}...`);
188
- // The repo squash-merges and GitHub uses the PR title as the squash
189
- // subject on `main`. A raw human issue title is not a Conventional
190
- // Commit, so release-please silently counts it as 0 releasable commits
191
- // (Story #3969). Normalize the title to conventional form: preserve an
192
- // already-conventional `storyTitle` verbatim, otherwise synthesize a
193
- // type derived from the branch's own commit subjects (default `chore`).
194
- // `gh-exec` spawns `gh` against the current process cwd (the worktree),
195
- // so the branch-commit read uses the same cwd.
196
- const title = normalizePrTitle({
190
+ // The repo squash-merges and GitHub uses the PR title as the squash subject
191
+ // on `main`, so both fields are derived rather than typed — see
192
+ // `normalize-pr-title.js`. `gh-exec` spawns `gh` against the current process
193
+ // cwd (the worktree), so the branch read uses the same cwd.
194
+ const { title, body } = buildPullRequestFields({
197
195
  storyTitle,
198
196
  storyId,
197
+ storyBody,
199
198
  storyBranch,
200
199
  baseBranch,
201
200
  cwd: _cwd ?? process.cwd(),
201
+ progress,
202
202
  });
203
- const body = [
204
- `Closes #${storyId}`,
205
- '',
206
- `_Auto-opened by \`/deliver\`._`,
207
- ].join('\n');
208
203
  try {
209
204
  const createResult = await gh.pr.create([
210
205
  '--base',
@@ -20,6 +20,11 @@ export async function handleCriticalReviewBlock({
20
20
  '',
21
21
  `The Story-scope review reported **${criticalCount} critical blocker(s)** on ${prUrl}.`,
22
22
  'Remediate the posted findings, then re-run `/deliver`.',
23
+ '',
24
+ 'If you have reviewed a finding and judged it wrong, re-run close with',
25
+ '`--override-review-block "<reason>"` rather than merging by hand — see',
26
+ '`phases/review-override.js`. The override is recorded on the Story, on the',
27
+ 'PR, and as friction telemetry.',
23
28
  ].join('\n');
24
29
  try {
25
30
  await upsertStructuredComment(provider, storyId, 'friction', body);
@@ -0,0 +1,157 @@
1
+ /**
2
+ * phases/review-override.js — the sanctioned, logged override of a Story-scope
3
+ * code-review critical blocker.
4
+ *
5
+ * Split out of `phases/review-block.js`, which owns the opposite outcome: the
6
+ * blocker that *holds*. Keeping the two in one module meant one file owning
7
+ * both "park this Story" and "ship it anyway", and the override's audit trail is
8
+ * substantial enough — three write surfaces, each independently best-effort —
9
+ * to be its own reason to change.
10
+ *
11
+ * **Why an override exists at all.** A critical review finding halts
12
+ * `single-story-close.js` before auto-merge, and no flag overrode a review
13
+ * verdict: `--skip-validation` bypasses the gate chain and `--no-auto-merge`
14
+ * declines to arm, but neither touches the review. So an operator who had read a
15
+ * finding and judged it wrong could only land by merging the PR by hand, which
16
+ * bypasses the gate with no record anywhere of what was overridden or why. This
17
+ * module does not weaken the gate; it relocates that escape hatch out of an
18
+ * untraceable hand-merge and into a mandatory-reason audit trail.
19
+ *
20
+ * Live provenance: Story #5007 / PR #5022, where a FALSE critical blocker — an
21
+ * MI finding on files `delivery.quality.gates.maintainability.ignoreGlobs`
22
+ * exempts, which the `check-baselines.js` ratchet correctly ignored in the same
23
+ * run — halted a legitimate delivery with a hand-merge as the only way out.
24
+ */
25
+
26
+ import { Logger } from '../../../Logger.js';
27
+ import {
28
+ emitRuntimeFriction,
29
+ RUNTIME_FRICTION_CATEGORIES,
30
+ } from '../../../observability/runtime-friction.js';
31
+ import {
32
+ postStructuredComment,
33
+ upsertStructuredComment,
34
+ } from '../../ticketing.js';
35
+
36
+ /** Cap on the reason text copied into the friction signal's `details`. */
37
+ const REASON_SIGNAL_LIMIT = 500;
38
+
39
+ /**
40
+ * Build the audit record posted when an operator overrides a review blocker.
41
+ * Pure.
42
+ *
43
+ * The body restates the count, the reason, and the fact that auto-merge was
44
+ * armed anyway — an override whose trail says only "overridden" is no better
45
+ * than the hand-merge it replaces.
46
+ *
47
+ * Module-local: an implementation detail of
48
+ * {@link handleOverriddenReviewBlock}, whose posted body is observable through
49
+ * that public entry point. Exporting it would add a public symbol no production
50
+ * path reaches — the exact dead-export shape this repo ratchets against.
51
+ *
52
+ * @param {{ prUrl: string, criticalCount: number, reason: string }} args
53
+ * @returns {string}
54
+ */
55
+ function buildReviewOverrideBody({ prUrl, criticalCount, reason }) {
56
+ return [
57
+ '### Code-review blocker overridden by operator',
58
+ '',
59
+ `The Story-scope review reported **${criticalCount} critical blocker(s)** on ${prUrl}.`,
60
+ 'The operator reviewed and rejected the finding(s) and authorized delivery',
61
+ 'with `--override-review-block`; auto-merge was armed.',
62
+ '',
63
+ '**Recorded reason:**',
64
+ '',
65
+ `> ${reason.split('\n').join('\n> ')}`,
66
+ '',
67
+ 'The findings comment on the PR is left in place unchanged — this record',
68
+ 'sits beside it rather than resolving it.',
69
+ ].join('\n');
70
+ }
71
+
72
+ /**
73
+ * Post one audit record, swallowing the failure into a warning.
74
+ *
75
+ * Every write here is best-effort by design: an override whose audit trail
76
+ * partly failed must still land the delivery the operator authorized, because
77
+ * the close would otherwise fail for a reason the operator cannot act on. The
78
+ * friction signal is the durable record — it is what makes a rising override
79
+ * count visible to the retro.
80
+ *
81
+ * Module-local: the two call sites below are its only callers.
82
+ *
83
+ * @param {{ post: () => Promise<unknown>, surface: string }} args
84
+ * @returns {Promise<boolean>} true when the record landed.
85
+ */
86
+ async function postAuditRecord({ post, surface }) {
87
+ try {
88
+ await post();
89
+ return true;
90
+ } catch (err) {
91
+ Logger.warn(
92
+ `[single-story-close] failed to post review-override record on ${surface}: ${err?.message ?? err}`,
93
+ );
94
+ return false;
95
+ }
96
+ }
97
+
98
+ /**
99
+ * Record an operator-authorized override of a critical code-review blocker,
100
+ * then let close continue to the auto-merge phase.
101
+ *
102
+ * Writes to three surfaces: the Story issue via `upsert` so a re-run does not
103
+ * stack duplicates; the PR via `post` so the trail is append-only on the surface
104
+ * a human reviews, and so a reviewer reading the PR need not open the Story to
105
+ * learn the blocker was overridden; and the friction stream.
106
+ *
107
+ * @param {{
108
+ * provider: object,
109
+ * storyId: number,
110
+ * prUrl: string,
111
+ * prNumber: number|null,
112
+ * criticalCount: number,
113
+ * reason: string,
114
+ * config?: object,
115
+ * emitFrictionFn?: typeof emitRuntimeFriction,
116
+ * }} args
117
+ * @returns {Promise<{ overridden: true, reason: string, criticalCount: number }>}
118
+ */
119
+ export async function handleOverriddenReviewBlock({
120
+ provider,
121
+ storyId,
122
+ prUrl,
123
+ prNumber,
124
+ criticalCount,
125
+ reason,
126
+ config,
127
+ emitFrictionFn = emitRuntimeFriction,
128
+ }) {
129
+ const body = buildReviewOverrideBody({ prUrl, criticalCount, reason });
130
+ Logger.warn(
131
+ `[single-story-close] ⚠️ Story-scope review reported ${criticalCount} critical blocker(s) on ` +
132
+ `PR ${prUrl} — OVERRIDDEN by operator: ${reason}`,
133
+ );
134
+ await postAuditRecord({
135
+ post: () => upsertStructuredComment(provider, storyId, 'friction', body),
136
+ surface: `Story #${storyId}`,
137
+ });
138
+ if (Number.isInteger(prNumber)) {
139
+ await postAuditRecord({
140
+ post: () =>
141
+ postStructuredComment(provider, prNumber, 'notification', body),
142
+ surface: `PR #${prNumber}`,
143
+ });
144
+ }
145
+ await emitFrictionFn({
146
+ storyId,
147
+ category: RUNTIME_FRICTION_CATEGORIES.REVIEW_BLOCK_OVERRIDDEN,
148
+ tool: 'single-story-close',
149
+ details: {
150
+ prUrl,
151
+ criticalCount,
152
+ reason: reason.slice(0, REASON_SIGNAL_LIMIT),
153
+ },
154
+ config,
155
+ });
156
+ return { overridden: true, reason, criticalCount };
157
+ }