mandrel 1.88.0 → 1.90.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 (145) hide show
  1. package/.agents/README.md +18 -13
  2. package/.agents/audit-checklists/architecture.md +24 -0
  3. package/.agents/audit-checklists/clean-code.md +24 -0
  4. package/.agents/audit-checklists/dependencies.md +14 -0
  5. package/.agents/audit-checklists/devops.md +17 -0
  6. package/.agents/audit-checklists/documentation.md +22 -0
  7. package/.agents/audit-checklists/lighthouse.md +15 -0
  8. package/.agents/audit-checklists/navigability.md +14 -0
  9. package/.agents/audit-checklists/performance.md +22 -0
  10. package/.agents/audit-checklists/privacy.md +21 -0
  11. package/.agents/audit-checklists/quality.md +18 -0
  12. package/.agents/audit-checklists/security.md +22 -0
  13. package/.agents/audit-checklists/seo.md +16 -0
  14. package/.agents/audit-checklists/sre.md +24 -0
  15. package/.agents/audit-checklists/ux-ui.md +21 -0
  16. package/.agents/docs/SDLC.md +62 -27
  17. package/.agents/docs/configuration.md +5 -4
  18. package/.agents/instructions.md +51 -21
  19. package/.agents/personas/architect.md +10 -7
  20. package/.agents/personas/engineer.md +4 -3
  21. package/.agents/personas/project-manager.md +5 -2
  22. package/.agents/personas/refactorer.md +5 -3
  23. package/.agents/rules/git-conventions.md +77 -0
  24. package/.agents/schemas/agentrc.schema.json +10 -6
  25. package/.agents/schemas/audit-rules.json +16 -2
  26. package/.agents/schemas/audit-rules.schema.json +7 -6
  27. package/.agents/schemas/lifecycle/epic.blocked.schema.json +1 -1
  28. package/.agents/schemas/lifecycle/merge.unlanded.schema.json +39 -0
  29. package/.agents/schemas/signal-event.schema.json +28 -13
  30. package/.agents/scripts/acceptance-spec-reconciler.js +6 -4
  31. package/.agents/scripts/check-context-budget.js +320 -0
  32. package/.agents/scripts/coverage-capture.js +17 -0
  33. package/.agents/scripts/diagnose-friction.js +4 -4
  34. package/.agents/scripts/epic-audit-prepare.js +30 -2
  35. package/.agents/scripts/epic-audit-recheck.js +46 -13
  36. package/.agents/scripts/epic-deliver-prepare.js +80 -8
  37. package/.agents/scripts/epic-plan-spec.js +4 -8
  38. package/.agents/scripts/generate-lens-checklists.js +180 -0
  39. package/.agents/scripts/lib/audit-suite/checklist-threading.js +300 -0
  40. package/.agents/scripts/lib/audit-suite/findings.js +27 -0
  41. package/.agents/scripts/lib/audit-suite/index.js +9 -0
  42. package/.agents/scripts/lib/audit-suite/lens-checklist.js +212 -0
  43. package/.agents/scripts/lib/audit-suite/selector.js +136 -5
  44. package/.agents/scripts/lib/checks/loop-health.js +340 -0
  45. package/.agents/scripts/lib/cli-args.js +8 -0
  46. package/.agents/scripts/lib/close-validation/gates.js +64 -24
  47. package/.agents/scripts/lib/config/ci.js +12 -1
  48. package/.agents/scripts/lib/config/runners.js +13 -5
  49. package/.agents/scripts/lib/config/temp-paths.js +24 -0
  50. package/.agents/scripts/lib/config-settings-schema-delivery.js +28 -8
  51. package/.agents/scripts/lib/doc-tiers.js +291 -0
  52. package/.agents/scripts/lib/epic-body-sections.js +5 -2
  53. package/.agents/scripts/lib/epic-merge-lock.js +83 -0
  54. package/.agents/scripts/lib/epic-plan-clarity.js +3 -1
  55. package/.agents/scripts/lib/feedback-loop/audit-results-graduator.js +47 -15
  56. package/.agents/scripts/lib/feedback-loop/graduator-core.js +395 -86
  57. package/.agents/scripts/lib/feedback-loop/memory-freshness.js +299 -72
  58. package/.agents/scripts/lib/feedback-loop/retro-proposals-graduator.js +438 -0
  59. package/.agents/scripts/lib/gates/friction.js +15 -5
  60. package/.agents/scripts/lib/npm-scripts.js +55 -0
  61. package/.agents/scripts/lib/observability/perf-aggregator.js +30 -104
  62. package/.agents/scripts/lib/observability/perf-report-readers.js +1 -1
  63. package/.agents/scripts/lib/observability/signal-validator.js +204 -0
  64. package/.agents/scripts/lib/observability/signals-writer.js +157 -54
  65. package/.agents/scripts/lib/observability/tool-trace-hook.js +42 -4
  66. package/.agents/scripts/lib/orchestration/acceptance-eval-decision.js +1 -1
  67. package/.agents/scripts/lib/orchestration/code-review.js +74 -4
  68. package/.agents/scripts/lib/orchestration/consolidation-precondition.js +213 -0
  69. package/.agents/scripts/lib/orchestration/doc-reader.js +4 -96
  70. package/.agents/scripts/lib/orchestration/docs-digest.js +34 -0
  71. package/.agents/scripts/lib/orchestration/epic-plan-spec/phases/authoring-context.js +56 -19
  72. package/.agents/scripts/lib/orchestration/epic-plan-spec/phases/run-spec-phase.js +22 -0
  73. package/.agents/scripts/lib/orchestration/lifecycle/emit-merge-unlanded.js +193 -0
  74. package/.agents/scripts/lib/orchestration/lifecycle/listeners/README.md +6 -0
  75. package/.agents/scripts/lib/orchestration/lifecycle/listeners/automerge-armer.js +248 -13
  76. package/.agents/scripts/lib/orchestration/lifecycle/listeners/automerge-predicate.js +109 -12
  77. package/.agents/scripts/lib/orchestration/lifecycle/listeners/finalizer.js +47 -61
  78. package/.agents/scripts/lib/orchestration/lifecycle/listeners/index.js +46 -4
  79. package/.agents/scripts/lib/orchestration/lifecycle/listeners/label-transitioner.js +144 -0
  80. package/.agents/scripts/lib/orchestration/lifecycle/listeners/merge-watcher.js +258 -14
  81. package/.agents/scripts/lib/orchestration/lifecycle/listeners/notify-dispatcher.js +6 -0
  82. package/.agents/scripts/lib/orchestration/merge-block-class.js +246 -0
  83. package/.agents/scripts/lib/orchestration/plan-review-routing.js +1 -1
  84. package/.agents/scripts/lib/orchestration/post-merge/phases/worktree-reap.js +3 -3
  85. package/.agents/scripts/lib/orchestration/retro/phases/compose-body.js +63 -34
  86. package/.agents/scripts/lib/orchestration/retro/phases/gather-signals.js +167 -52
  87. package/.agents/scripts/lib/orchestration/retro/phases/post-and-mirror.js +49 -2
  88. package/.agents/scripts/lib/orchestration/retro-proposals.js +12 -55
  89. package/.agents/scripts/lib/orchestration/retro-runner.js +9 -0
  90. package/.agents/scripts/lib/orchestration/single-story-close/phases/close-validation.js +5 -1
  91. package/.agents/scripts/lib/orchestration/single-story-close/phases/code-review.js +8 -0
  92. package/.agents/scripts/lib/orchestration/single-story-close/phases/confirm-merge.js +419 -0
  93. package/.agents/scripts/lib/orchestration/single-story-close/phases/options.js +35 -2
  94. package/.agents/scripts/lib/orchestration/single-story-close/phases/wrong-tree-guard.js +353 -69
  95. package/.agents/scripts/lib/orchestration/single-story-close/runner.js +66 -4
  96. package/.agents/scripts/lib/orchestration/spec-section-validator.js +60 -9
  97. package/.agents/scripts/lib/orchestration/story-close/auto-refresh-runner.js +7 -5
  98. package/.agents/scripts/lib/orchestration/story-close/merge-runner.js +24 -2
  99. package/.agents/scripts/lib/orchestration/story-close/phases/code-review.js +167 -8
  100. package/.agents/scripts/lib/orchestration/story-close/pre-merge-validation.js +8 -1
  101. package/.agents/scripts/lib/orchestration/story-close/shared-checkout-guard.js +163 -0
  102. package/.agents/scripts/lib/orchestration/ticketing/reads.js +20 -9
  103. package/.agents/scripts/lib/planning-corpus.js +306 -0
  104. package/.agents/scripts/lib/signals/detectors/common.js +10 -10
  105. package/.agents/scripts/lib/signals/detectors/index.js +4 -4
  106. package/.agents/scripts/lib/signals/detectors/retry.js +19 -18
  107. package/.agents/scripts/lib/signals/detectors/rework.js +1 -1
  108. package/.agents/scripts/lib/signals/schema.js +56 -81
  109. package/.agents/scripts/lib/signals/span-tree.js +6 -5
  110. package/.agents/scripts/lib/story-plan.js +3 -0
  111. package/.agents/scripts/lib/wave-runner/tick.js +10 -2
  112. package/.agents/scripts/lifecycle-emit.js +39 -8
  113. package/.agents/scripts/providers/github/issues.js +12 -1
  114. package/.agents/scripts/resolve-doc-tiers.js +83 -0
  115. package/.agents/scripts/retro-run.js +51 -0
  116. package/.agents/scripts/signals-view.js +1 -1
  117. package/.agents/scripts/single-story-close.js +20 -1
  118. package/.agents/scripts/standalone-feedback-rollup.js +188 -0
  119. package/.agents/scripts/story-close.js +48 -0
  120. package/.agents/scripts/story-plan.js +51 -12
  121. package/.agents/scripts/validate-docs-freshness.js +69 -15
  122. package/.agents/skills/core/documentation-and-adrs/SKILL.md +58 -0
  123. package/.agents/skills/core/epic-plan-decompose-author/SKILL.md +5 -3
  124. package/.agents/skills/core/epic-plan-spec-author/SKILL.md +20 -7
  125. package/.agents/skills/core/scope-triage/SKILL.md +61 -0
  126. package/.agents/skills/skills.index.json +3 -3
  127. package/.agents/workflows/audit-documentation.md +82 -2
  128. package/.agents/workflows/helpers/code-review.md +116 -43
  129. package/.agents/workflows/helpers/deliver-epic.md +123 -54
  130. package/.agents/workflows/helpers/deliver-stories.md +26 -0
  131. package/.agents/workflows/helpers/epic-audit.md +116 -366
  132. package/.agents/workflows/helpers/epic-deliver-story.md +14 -0
  133. package/.agents/workflows/helpers/epic-plan-decompose.md +18 -200
  134. package/.agents/workflows/helpers/epic-plan-spec.md +18 -180
  135. package/.agents/workflows/helpers/plan-epic.md +141 -105
  136. package/.agents/workflows/helpers/plan-story.md +32 -0
  137. package/.agents/workflows/helpers/single-story-deliver.md +43 -0
  138. package/.agents/workflows/loops/nightly-audit.md +9 -7
  139. package/docs/CHANGELOG.md +29 -0
  140. package/lib/cli/doctor.js +44 -0
  141. package/package.json +4 -3
  142. package/.agents/scripts/epic-plan-spec-validate.js +0 -111
  143. package/.agents/scripts/lib/feedback-loop/code-review-graduator.js +0 -224
  144. package/.agents/scripts/lib/orchestration/epic-plan-spec/phases/prompts.js +0 -58
  145. package/.agents/scripts/lib/signals/detectors/hotspot.js +0 -292
@@ -1,12 +1,14 @@
1
1
  /**
2
2
  * lib/orchestration/retro-proposals.js — pure composer that turns
3
- * aggregated source-tagged friction signals into four routed proposal
4
- * sections (framework, consumer, memory, discarded).
3
+ * aggregated source-tagged friction signals into three routed proposal
4
+ * sections (framework, consumer, discarded).
5
5
  *
6
6
  * Epic #2547 / Story #2558 / Tech Spec #2550. Consumes per-Story signals
7
- * already source-tagged by `signals-writer.appendSignal` (Story #2553) and
8
- * yields a four-way split that the retro composer renders above the
9
- * `<!-- retro-complete: ... -->` marker.
7
+ * already source-tagged by `signals-writer.appendSignal` and yields a
8
+ * three-way split that the retro composer renders above the
9
+ * `<!-- retro-complete: ... -->` marker. The former "memory updates"
10
+ * pane was deleted in the Epic #4406 signal-contract cutover (it had no
11
+ * producer — no writer ever emitted the record it rendered).
10
12
  *
11
13
  * Heuristic:
12
14
  * - **Actionable** (renders as a pre-drafted `gh issue create` shell
@@ -14,14 +16,8 @@
14
16
  * Epic, OR an `agent::blocked` event whose root cause was not
15
17
  * resolved by Epic close (the caller supplies these as
16
18
  * `unresolvedBlockedEvents`).
17
- * - **Memorable** (renders as a plain bulleted instruction line under
18
- * "update your memory with the following insights"): a pattern
19
- * observed in retro signals supplied via `memorablePatterns`. We do
20
- * **not** emit memory frontmatter — the section is a free-text
21
- * instruction block.
22
19
  * - **Discarded**: a friction category with exactly 1 occurrence and
23
- * no follow-on signal (no companion `agent::blocked`, not in
24
- * `memorablePatterns`).
20
+ * no follow-on signal (no companion `agent::blocked`).
25
21
  *
26
22
  * Routing:
27
23
  * - Each actionable item is routed to `framework` or `consumer` based
@@ -45,17 +41,12 @@
45
41
  * @property {string} [category]
46
42
  * @property {string} [summary]
47
43
  *
48
- * @typedef {Object} MemorablePattern
49
- * @property {string} category
50
- * @property {string} insight The instruction line text (rendered as a bullet).
51
- *
52
44
  * @typedef {Object} RoutedProposalsInput
53
45
  * @property {number} epicId
54
46
  * @property {string} frameworkRepo `"<owner>/<repo>"`.
55
47
  * @property {string} consumerRepo `"<owner>/<repo>"`.
56
48
  * @property {FrictionSignal[]} [signals]
57
49
  * @property {BlockedEvent[]} [unresolvedBlockedEvents]
58
- * @property {MemorablePattern[]} [memorablePatterns]
59
50
  *
60
51
  * @typedef {Object} RoutedItem
61
52
  * @property {string} category
@@ -65,10 +56,6 @@
65
56
  * @property {string} body
66
57
  * @property {string} command The pre-drafted `gh issue create` line.
67
58
  *
68
- * @typedef {Object} MemoryItem
69
- * @property {string} category
70
- * @property {string} insight
71
- *
72
59
  * @typedef {Object} DiscardedItem
73
60
  * @property {string} category
74
61
  * @property {number} occurrences
@@ -77,7 +64,6 @@
77
64
  * @typedef {Object} RoutedProposals
78
65
  * @property {RoutedItem[]} framework
79
66
  * @property {RoutedItem[]} consumer
80
- * @property {MemoryItem[]} memory
81
67
  * @property {DiscardedItem[]} discarded
82
68
  */
83
69
 
@@ -88,7 +74,7 @@
88
74
  * @returns {RoutedProposals}
89
75
  */
90
76
  function emptyResult() {
91
- return { framework: [], consumer: [], memory: [], discarded: [] };
77
+ return { framework: [], consumer: [], discarded: [] };
92
78
  }
93
79
 
94
80
  /**
@@ -250,7 +236,6 @@ function buildRoutedItem({
250
236
  * consumerRepo: string,
251
237
  * signals: FrictionSignal[],
252
238
  * unresolvedBlockedEvents: BlockedEvent[],
253
- * memorablePatterns: MemorablePattern[],
254
239
  * } | null}
255
240
  */
256
241
  function normaliseInput(input) {
@@ -269,9 +254,6 @@ function normaliseInput(input) {
269
254
  unresolvedBlockedEvents: Array.isArray(record.unresolvedBlockedEvents)
270
255
  ? record.unresolvedBlockedEvents
271
256
  : [],
272
- memorablePatterns: Array.isArray(record.memorablePatterns)
273
- ? record.memorablePatterns
274
- : [],
275
257
  };
276
258
  }
277
259
 
@@ -280,11 +262,9 @@ function normaliseInput(input) {
280
262
  * signals.
281
263
  *
282
264
  * Pure — no I/O, no time-dependent state, no provider calls. Returns an
283
- * object with four arrays:
265
+ * object with three arrays:
284
266
  * - `framework`: actionable items routed to the framework repo.
285
267
  * - `consumer`: actionable items routed to the consumer repo.
286
- * - `memory`: bulleted instruction lines for the operator's memory
287
- * surface (NOT memory frontmatter).
288
268
  * - `discarded`: single-occurrence friction with no follow-on signal.
289
269
  *
290
270
  * @param {RoutedProposalsInput} input
@@ -299,28 +279,10 @@ export function composeRoutedProposals(input) {
299
279
  consumerRepo,
300
280
  signals,
301
281
  unresolvedBlockedEvents,
302
- memorablePatterns,
303
282
  } = normalised;
304
283
 
305
284
  const byCategory = aggregateByCategory(signals);
306
285
 
307
- // Memory: every supplied pattern with a non-empty insight, sorted by
308
- // category. Memorable categories are *also* tracked so a 1-occurrence
309
- // friction that's memorable is NOT discarded — it's already covered by
310
- // the memory section.
311
- const memorableCategories = new Set();
312
- /** @type {MemoryItem[]} */
313
- const memory = [];
314
- for (const m of memorablePatterns) {
315
- if (m === null || typeof m !== 'object') continue;
316
- const category = asString(m.category);
317
- const insight = asString(m.insight);
318
- if (category.length === 0 || insight.length === 0) continue;
319
- memorableCategories.add(category);
320
- memory.push({ category, insight });
321
- }
322
- memory.sort((a, b) => a.category.localeCompare(b.category));
323
-
324
286
  // Unresolved agent::blocked events always promote their category to
325
287
  // actionable — even if the friction count is < 2. The event itself
326
288
  // doesn't count as a friction occurrence; we treat it as a force-flag.
@@ -362,12 +324,7 @@ export function composeRoutedProposals(input) {
362
324
  else consumer.push(item);
363
325
  continue;
364
326
  }
365
- // total === 1 AND no force flag.
366
- if (memorableCategories.has(category)) {
367
- // Memorable single-occurrence frictions are covered by the memory
368
- // section; do not also discard them.
369
- continue;
370
- }
327
+ // total === 1 AND no force flag → discarded.
371
328
  discarded.push({ category, occurrences: total, source });
372
329
  }
373
330
 
@@ -391,5 +348,5 @@ export function composeRoutedProposals(input) {
391
348
  consumer.sort((a, b) => a.category.localeCompare(b.category));
392
349
  discarded.sort((a, b) => a.category.localeCompare(b.category));
393
350
 
394
- return { framework, consumer, memory, discarded };
351
+ return { framework, consumer, discarded };
395
352
  }
@@ -95,6 +95,9 @@ export { gatherRetroSignals } from './retro/phases/gather-signals.js';
95
95
  * bus?: object|null,
96
96
  * now?: () => number,
97
97
  * manualInterventions?: number,
98
+ * frameworkRepo?: string,
99
+ * consumerRepo?: string,
100
+ * config?: object|null,
98
101
  * gatherFn?: typeof gatherRetroSignals,
99
102
  * composeFn?: typeof composeRetroBody,
100
103
  * upsertFn?: typeof upsertStructuredComment,
@@ -122,6 +125,9 @@ export async function runRetro(opts = {}) {
122
125
  bus,
123
126
  now = Date.now,
124
127
  manualInterventions = 0,
128
+ frameworkRepo,
129
+ consumerRepo,
130
+ config = null,
125
131
  gatherFn = gatherRetroSignals,
126
132
  composeFn = composeRetroBody,
127
133
  upsertFn = upsertStructuredComment,
@@ -159,6 +165,9 @@ export async function runRetro(opts = {}) {
159
165
  bus,
160
166
  now,
161
167
  manualInterventions,
168
+ frameworkRepo,
169
+ consumerRepo,
170
+ config,
162
171
  gatherFn,
163
172
  composeFn,
164
173
  upsertFn,
@@ -125,7 +125,11 @@ export async function runCloseValidationPhase({
125
125
  const validation = await runCloseValidation({
126
126
  cwd,
127
127
  worktreePath,
128
- gates: buildDefaultGates({ config, epicBranch: baseBranch }),
128
+ gates: buildDefaultGates({
129
+ config,
130
+ epicBranch: baseBranch,
131
+ cwd: worktreePath || cwd,
132
+ }),
129
133
  log: (m) => Logger.info(m),
130
134
  storyId,
131
135
  // Story #4250 — standalone storyId-anchored evidence keyspace. No
@@ -94,6 +94,7 @@ export function buildStoryReviewCrossRefBody({
94
94
  * prNumber: number|null,
95
95
  * provider: object,
96
96
  * runCodeReviewFn: Function,
97
+ * runLocalLensReviewFn?: Function,
97
98
  * progress: (tag: string, msg: string) => void,
98
99
  * }} args
99
100
  * @returns {Promise<{
@@ -103,6 +104,7 @@ export function buildStoryReviewCrossRefBody({
103
104
  * posted?: boolean,
104
105
  * postedCommentId?: number|null,
105
106
  * crossRefPosted?: boolean,
107
+ * localLensReview?: object,
106
108
  * }>}
107
109
  */
108
110
  export async function runStoryScopeReview({
@@ -114,6 +116,7 @@ export async function runStoryScopeReview({
114
116
  prNumber,
115
117
  provider,
116
118
  runCodeReviewFn,
119
+ runLocalLensReviewFn,
117
120
  progress,
118
121
  }) {
119
122
  if (prNumber == null) {
@@ -138,6 +141,10 @@ export async function runStoryScopeReview({
138
141
  progress,
139
142
  progressTag: 'REVIEW',
140
143
  runCodeReviewFn,
144
+ // Forward the seam only when the caller injects it; otherwise
145
+ // `runStoryReviewCore` uses its default local-lens pass. `undefined`
146
+ // deep-merges to the default via the destructuring default there.
147
+ ...(runLocalLensReviewFn ? { runLocalLensReviewFn } : {}),
141
148
  });
142
149
 
143
150
  const sev = result.severity ?? {
@@ -186,5 +193,6 @@ export async function runStoryScopeReview({
186
193
  posted: result.posted,
187
194
  postedCommentId: result.postedCommentId ?? null,
188
195
  crossRefPosted,
196
+ localLensReview: result.localLensReview,
189
197
  };
190
198
  }
@@ -0,0 +1,419 @@
1
+ /**
2
+ * phases/confirm-merge.js — headless must-land terminal step (Story #4428,
3
+ * Epic #4425 slice 3: standalone-path must-land terminal step).
4
+ *
5
+ * `runSingleStoryClose` (`../runner.js`) arms GitHub native auto-merge and
6
+ * historically rests the Story at `agent::closing` with the issue OPEN —
7
+ * merge confirmation is a separate manual step
8
+ * (`single-story-confirm-merge.js`) a headless (unattended) run never
9
+ * executes. A hung check or a failed arm then leaves the PR silently open
10
+ * forever with no operator watching.
11
+ *
12
+ * This phase closes that gap for **headless** runs (`--wait-merge`, threaded
13
+ * from `phases/options.js`): instead of returning at `agent::closing`, it
14
+ * polls the armed PR to merge confirmation — reusing the SAME
15
+ * `confirmStoryMerged` flip logic `single-story-confirm-merge.js` calls
16
+ * (Story #4428 AC4: exactly one merged/`agent::done` implementation) — on
17
+ * the `delivery.mergeWatch.intervalSeconds` / `maxBudgetSeconds` cadence
18
+ * (mirroring `MergeWatcher`'s poll/budget shape rather than forking it).
19
+ *
20
+ * Terminal outcomes:
21
+ * - `{ confirmed: true }` — the PR merged; `confirmStoryMerged` already
22
+ * flipped `agent::closing → agent::done` and closed the issue.
23
+ * - `{ confirmed: false, blockClass, reason }` — the arm failed outright,
24
+ * the PR closed without merging, or the poll budget was exhausted
25
+ * first. The block is classified via the shared
26
+ * `classifyMergeBlock` (`../../merge-block-class.js`), a
27
+ * `merge.unlanded` lifecycle event is emitted (`scope: 'story'`), a
28
+ * `friction` comment is posted, and the Story is transitioned to
29
+ * `agent::blocked`. The caller (`runSingleStoryClose`) throws so the
30
+ * CLI process exits non-zero — never a silent `agent::closing` rest.
31
+ *
32
+ * Attended (non-headless) runs never call this phase — `runner.js` only
33
+ * invokes it when `options.waitForMerge` is `true`.
34
+ */
35
+
36
+ import { gh as defaultGh } from '../../../gh-exec.js';
37
+ import {
38
+ confirmStoryMerged as defaultConfirmStoryMerged,
39
+ readPrMergeState as defaultReadPrMergeState,
40
+ } from '../../../single-story/confirm-merge.js';
41
+ import { emitMergeUnlanded as defaultEmitMergeUnlanded } from '../../lifecycle/emit-merge-unlanded.js';
42
+ import {
43
+ DEFAULT_INTERVAL_SECONDS,
44
+ DEFAULT_MAX_BUDGET_SECONDS,
45
+ deriveChecksStatus,
46
+ } from '../../lifecycle/listeners/merge-watcher.js';
47
+ import { classifyMergeBlock as defaultClassifyMergeBlock } from '../../merge-block-class.js';
48
+ import {
49
+ postStructuredComment,
50
+ STATE_LABELS,
51
+ transitionTicketState,
52
+ } from '../../ticketing.js';
53
+
54
+ function defaultSleep(ms) {
55
+ return new Promise((resolve) => setTimeout(resolve, ms));
56
+ }
57
+
58
+ /**
59
+ * Fresh PR probe for terminal classification. Fetches the fields
60
+ * `classifyMergeBlock` keys on (`mergeStateStatus`, `reviewDecision`,
61
+ * `statusCheckRollup` → derived `checksStatus`) so a budget exhaustion
62
+ * is classified from the REAL PR state instead of a hardcoded
63
+ * `checksStatus: 'pending'` stamp (which mislabeled every timeout as
64
+ * `checks-pending-timeout` — a review-required block was never
65
+ * diagnosable). Returns a degraded `{ checksStatus: 'pending', error }`
66
+ * probe when the read itself fails, preserving the prior conservative
67
+ * classification on probe errors.
68
+ */
69
+ async function readPrClassificationProbe({ prNumber, gh = defaultGh }) {
70
+ try {
71
+ const view = await gh.pr.view(prNumber, [
72
+ 'mergeStateStatus',
73
+ 'reviewDecision',
74
+ 'statusCheckRollup',
75
+ ]);
76
+ return {
77
+ mergeStateStatus:
78
+ typeof view?.mergeStateStatus === 'string'
79
+ ? view.mergeStateStatus
80
+ : undefined,
81
+ reviewDecision:
82
+ typeof view?.reviewDecision === 'string'
83
+ ? view.reviewDecision
84
+ : undefined,
85
+ checksStatus: deriveChecksStatus(view?.statusCheckRollup),
86
+ };
87
+ } catch (err) {
88
+ return {
89
+ checksStatus: 'pending',
90
+ error: `classification probe failed: ${err?.message ?? err}`,
91
+ };
92
+ }
93
+ }
94
+
95
+ /**
96
+ * Resolve the poll cadence from `delivery.mergeWatch.*`, falling back to
97
+ * the same defaults `MergeWatcher` uses when the config key is absent.
98
+ *
99
+ * @param {object} [config]
100
+ * @returns {{ intervalSeconds: number, maxBudgetSeconds: number }}
101
+ */
102
+ function resolveMergeWatchCadence(config) {
103
+ const mergeWatch = config?.delivery?.mergeWatch ?? {};
104
+ const intervalSeconds =
105
+ Number.isInteger(mergeWatch.intervalSeconds) &&
106
+ mergeWatch.intervalSeconds >= 1
107
+ ? mergeWatch.intervalSeconds
108
+ : DEFAULT_INTERVAL_SECONDS;
109
+ const maxBudgetSeconds =
110
+ Number.isInteger(mergeWatch.maxBudgetSeconds) &&
111
+ mergeWatch.maxBudgetSeconds >= 1
112
+ ? mergeWatch.maxBudgetSeconds
113
+ : DEFAULT_MAX_BUDGET_SECONDS;
114
+ return { intervalSeconds, maxBudgetSeconds };
115
+ }
116
+
117
+ /**
118
+ * Format the `friction` comment body posted alongside the `agent::blocked`
119
+ * transition when a headless close gives up without a confirmed merge.
120
+ */
121
+ function formatUnlandedFriction({
122
+ storyId,
123
+ prNumber,
124
+ prUrl,
125
+ blockClass,
126
+ reason,
127
+ elapsedSeconds,
128
+ }) {
129
+ const prLabel =
130
+ Number.isInteger(prNumber) && prNumber > 0
131
+ ? `PR #${prNumber}${prUrl ? ` (${prUrl})` : ''}`
132
+ : (prUrl ?? 'the PR');
133
+ return (
134
+ `### headless must-land: merge did not land\n\n` +
135
+ `Story #${storyId}: the headless close polled ${prLabel} for merge ` +
136
+ `confirmation and gave up after ${elapsedSeconds}s without observing a ` +
137
+ `confirmed merge.\n\n` +
138
+ `**Block class:** \`${blockClass}\`\n\n` +
139
+ `**Reason:** ${reason}\n\n` +
140
+ `Story transitioned to \`agent::blocked\`. Resolve the underlying ` +
141
+ `condition (branch protection, required checks, or a manual merge), ` +
142
+ `then re-run \`single-story-confirm-merge.js\` or resume delivery.`
143
+ );
144
+ }
145
+
146
+ /**
147
+ * Classify the unlanded merge, emit `merge.unlanded`, post a `friction`
148
+ * comment, and transition the Story to `agent::blocked`. Every side effect
149
+ * is best-effort logged rather than thrown — the caller (`runSingleStoryClose`)
150
+ * owns surfacing the non-zero exit via its own throw once this returns.
151
+ *
152
+ * @returns {Promise<{ confirmed: false, blockClass: string, reason: string, elapsedSeconds: number }>}
153
+ */
154
+ async function blockOnUnlanded({
155
+ storyId,
156
+ prNumber,
157
+ prUrl,
158
+ armResult,
159
+ prProbe,
160
+ budget,
161
+ provider,
162
+ progress,
163
+ classifyMergeBlockFn,
164
+ emitMergeUnlandedFn,
165
+ }) {
166
+ const { blockClass, reason } = classifyMergeBlockFn({
167
+ armResult,
168
+ prProbe,
169
+ budget,
170
+ });
171
+ const elapsedSeconds = budget?.elapsedSeconds ?? 0;
172
+
173
+ if (Number.isInteger(prNumber) && prNumber > 0) {
174
+ try {
175
+ emitMergeUnlandedFn({
176
+ scope: 'story',
177
+ ticketId: storyId,
178
+ prNumber,
179
+ blockClass,
180
+ reason,
181
+ elapsedSeconds,
182
+ });
183
+ } catch (err) {
184
+ progress?.(
185
+ 'CONFIRM',
186
+ `⚠️ merge.unlanded emit failed (continuing): ${err?.message ?? err}`,
187
+ );
188
+ }
189
+ } else {
190
+ progress?.(
191
+ 'CONFIRM',
192
+ '⚠️ No parseable PR number — skipping merge.unlanded emit (schema requires prNumber).',
193
+ );
194
+ }
195
+
196
+ const body = formatUnlandedFriction({
197
+ storyId,
198
+ prNumber,
199
+ prUrl,
200
+ blockClass,
201
+ reason,
202
+ elapsedSeconds,
203
+ });
204
+ try {
205
+ await postStructuredComment(provider, storyId, 'friction', body);
206
+ } catch (err) {
207
+ progress?.(
208
+ 'CONFIRM',
209
+ `⚠️ Failed to post merge.unlanded friction comment: ${err?.message ?? err}`,
210
+ );
211
+ }
212
+
213
+ try {
214
+ await transitionTicketState(provider, storyId, STATE_LABELS.BLOCKED, {});
215
+ progress?.(
216
+ 'CONFIRM',
217
+ `🛑 Story #${storyId} → agent::blocked (${blockClass}).`,
218
+ );
219
+ } catch (err) {
220
+ progress?.(
221
+ 'CONFIRM',
222
+ `⚠️ Failed to flip Story #${storyId} to agent::blocked: ${err?.message ?? err}`,
223
+ );
224
+ }
225
+
226
+ return { confirmed: false, blockClass, reason, elapsedSeconds };
227
+ }
228
+
229
+ /**
230
+ * Poll an armed standalone-Story PR to merge confirmation, or terminate
231
+ * `agent::blocked` with a classified `merge.unlanded` event.
232
+ *
233
+ * @param {object} args
234
+ * @param {string} args.cwd
235
+ * @param {number} args.storyId
236
+ * @param {number|null} args.prNumber
237
+ * @param {string} args.prUrl
238
+ * @param {boolean} args.autoMergeEnabled
239
+ * @param {string|null} args.autoMergeReason
240
+ * @param {object} args.provider
241
+ * @param {object} [args.config]
242
+ * @param {(tag: string, msg: string) => void} [args.progress]
243
+ * @param {object} [args.injectedGh]
244
+ * @param {Function} [args.injectedNotify]
245
+ * @param {Function} [args.confirmStoryMergedFn] Test seam — defaults to the
246
+ * SAME `confirmStoryMerged` export `single-story-confirm-merge.js` calls
247
+ * (Story #4428 AC4: one merged/`agent::done` implementation).
248
+ * @param {Function} [args.readPrMergeStateFn] Test seam for the PR-state reader.
249
+ * @param {Function} [args.classifyMergeBlockFn] Test seam for the classifier.
250
+ * @param {Function} [args.emitMergeUnlandedFn] Test seam for the lifecycle emitter.
251
+ * @param {(ms: number) => Promise<void>} [args.sleepFn] Test seam so the
252
+ * suite does not actually wait.
253
+ * @param {() => number} [args.nowMsFn] Test seam; returns epoch ms.
254
+ * @returns {Promise<{ confirmed: boolean, action?: string, blockClass?: string, reason?: string, elapsedSeconds?: number }>}
255
+ */
256
+ export async function runConfirmMergePhase({
257
+ cwd,
258
+ storyId,
259
+ prNumber,
260
+ prUrl,
261
+ autoMergeEnabled,
262
+ autoMergeReason,
263
+ provider,
264
+ config,
265
+ progress,
266
+ injectedGh,
267
+ injectedNotify,
268
+ confirmStoryMergedFn = defaultConfirmStoryMerged,
269
+ readPrMergeStateFn = defaultReadPrMergeState,
270
+ readPrClassificationProbeFn = readPrClassificationProbe,
271
+ classifyMergeBlockFn = defaultClassifyMergeBlock,
272
+ emitMergeUnlandedFn = defaultEmitMergeUnlanded,
273
+ sleepFn = defaultSleep,
274
+ nowMsFn = Date.now,
275
+ }) {
276
+ // The arm itself never succeeded (gh failure, unparseable PR number, or a
277
+ // deliberate disablement) — there is no "armed but unconfirmed" PR to
278
+ // poll. Headless mode still requires an explicit terminal state, so
279
+ // classify and block immediately rather than resting silently.
280
+ if (!autoMergeEnabled) {
281
+ progress?.(
282
+ 'CONFIRM',
283
+ `⚠️ Auto-merge not enabled (${autoMergeReason ?? 'unknown'}) — headless close cannot wait for a merge that was never armed.`,
284
+ );
285
+ return blockOnUnlanded({
286
+ storyId,
287
+ prNumber,
288
+ prUrl,
289
+ armResult: { armed: false, reason: autoMergeReason },
290
+ budget: { elapsedSeconds: 0 },
291
+ provider,
292
+ progress,
293
+ classifyMergeBlockFn,
294
+ emitMergeUnlandedFn,
295
+ });
296
+ }
297
+
298
+ const { intervalSeconds, maxBudgetSeconds } =
299
+ resolveMergeWatchCadence(config);
300
+ const intervalMs = intervalSeconds * 1000;
301
+ const budgetMs = maxBudgetSeconds * 1000;
302
+ const startedAtMs = nowMsFn();
303
+
304
+ progress?.(
305
+ 'CONFIRM',
306
+ `⏳ Headless must-land: polling PR #${prNumber} for merge confirmation (budget=${maxBudgetSeconds}s)...`,
307
+ );
308
+
309
+ while (true) {
310
+ const confirmation = await confirmStoryMergedFn({
311
+ provider,
312
+ storyId,
313
+ prNumber,
314
+ prUrl,
315
+ cwd,
316
+ config,
317
+ progress,
318
+ injectedGh,
319
+ injectedNotify,
320
+ readPrMergeStateFn,
321
+ });
322
+
323
+ if (confirmation.merged && confirmation.action !== 'flip-failed') {
324
+ progress?.(
325
+ 'CONFIRM',
326
+ `✅ Story #${storyId} merge confirmed — agent::done.`,
327
+ );
328
+ return { confirmed: true, action: confirmation.action };
329
+ }
330
+
331
+ if (confirmation.merged && confirmation.action === 'flip-failed') {
332
+ // The PR merged but the agent::closing → agent::done label flip
333
+ // itself threw — reporting confirmed:true here would strand the
334
+ // Story at agent::closing with no notification and no block, the
335
+ // silent-terminal-state gap the Epic exists to close (audit-quality
336
+ // Critical finding, Epic #4425). Route through the same
337
+ // blockOnUnlanded path as an unlanded merge so the run still
338
+ // terminates in an explicit agent::blocked state with a diagnosis.
339
+ progress?.(
340
+ 'CONFIRM',
341
+ `⚠️ Story #${storyId} merge confirmed but the agent::done flip failed — blocking explicitly.`,
342
+ );
343
+ return blockOnUnlanded({
344
+ storyId,
345
+ prNumber,
346
+ prUrl,
347
+ prProbe: {
348
+ error: 'merge confirmed but agent::done label flip failed',
349
+ },
350
+ budget: {
351
+ exhausted: true,
352
+ elapsedSeconds: Math.round((nowMsFn() - startedAtMs) / 1000),
353
+ },
354
+ provider,
355
+ progress,
356
+ classifyMergeBlockFn,
357
+ emitMergeUnlandedFn,
358
+ });
359
+ }
360
+
361
+ if (confirmation.reason === 'pr-not-merged') {
362
+ // The PR was closed without merging — a definitive terminal state,
363
+ // not a "still pending" condition the budget should keep waiting
364
+ // on. checksStatus MUST be a non-pending, non-undefined value here
365
+ // (audit-clean-code finding, Epic #4425): classifyMergeBlock's
366
+ // budget-exhausted branch treats an undefined checksStatus as
367
+ // "still pending", which would misclassify this definitive
368
+ // closed-without-merging case as checks-pending-timeout instead
369
+ // of falling through to the api-race-other reason built from
370
+ // prProbe.error below.
371
+ return blockOnUnlanded({
372
+ storyId,
373
+ prNumber,
374
+ prUrl,
375
+ prProbe: {
376
+ checksStatus: 'closed',
377
+ error: 'PR closed without merging (state=CLOSED)',
378
+ },
379
+ budget: {
380
+ exhausted: true,
381
+ elapsedSeconds: Math.round((nowMsFn() - startedAtMs) / 1000),
382
+ },
383
+ provider,
384
+ progress,
385
+ classifyMergeBlockFn,
386
+ emitMergeUnlandedFn,
387
+ });
388
+ }
389
+
390
+ const elapsedMs = nowMsFn() - startedAtMs;
391
+ if (elapsedMs + intervalMs > budgetMs) {
392
+ // Terminal classification from the REAL PR state — one fresh probe
393
+ // of the fields classifyMergeBlock keys on, instead of stamping
394
+ // every timeout `checksStatus: 'pending'` (which made a
395
+ // review-required block undiagnosable). The probe degrades to the
396
+ // prior conservative pending stamp when the read itself fails.
397
+ const prProbe = await readPrClassificationProbeFn({
398
+ prNumber,
399
+ gh: injectedGh,
400
+ });
401
+ return blockOnUnlanded({
402
+ storyId,
403
+ prNumber,
404
+ prUrl,
405
+ prProbe,
406
+ budget: {
407
+ exhausted: true,
408
+ elapsedSeconds: Math.round(elapsedMs / 1000),
409
+ },
410
+ provider,
411
+ progress,
412
+ classifyMergeBlockFn,
413
+ emitMergeUnlandedFn,
414
+ });
415
+ }
416
+
417
+ await sleepFn(intervalMs);
418
+ }
419
+ }