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
@@ -41,11 +41,47 @@
41
41
  * labels, post comments, or call `notify`. Downstream listeners
42
42
  * (Cleaner / LabelTransitioner on `epic.merge.confirmed`, Task #2912)
43
43
  * own those side effects.
44
+ *
45
+ * Must-land terminal step (Story #4427, Epic #4425 slice 2). In
46
+ * headless (`--yes`) delivery runs — signalled via the explicit
47
+ * `headless` constructor option, threaded from `/deliver`'s `--yes`
48
+ * flag through `lifecycle-emit.js`'s `--headless` runtime flag and
49
+ * `buildDefaultListenerChain({ headless })` — budget exhaustion no
50
+ * longer falls straight through to `epic.blocked`. Instead the watcher
51
+ * classifies the block (`classifyMergeBlock`, the shared classifier
52
+ * from Story #4426) and, bounded by one attempt each per watch run:
53
+ *
54
+ * - `checks-pending-timeout` (required checks still progressing) →
55
+ * extend the watch budget once and keep polling in the SAME watch
56
+ * cycle.
57
+ * - `api-race-other` (no definitive block signal) → re-arm once by
58
+ * re-emitting `epic.merge.ready` on the bus. This does NOT call
59
+ * `gh pr merge` directly — AutomergeArmer remains the sole
60
+ * authorized call site (merge-lockout invariant, Story #4427 AC).
61
+ * AutomergeArmer's own idempotent `gh pr view` probe short-circuits
62
+ * to a single `epic.merge.armed` re-emit when auto-merge is
63
+ * already armed on the PR, which re-triggers this watcher's
64
+ * `handle()` for a fresh watch cycle that continues the resume
65
+ * ledger's attempt count.
66
+ * - `branch-protection-human-required`, or retries already
67
+ * exhausted (both bounded attempts spent) → terminal: emit
68
+ * `merge.unlanded` (scope `"epic"`, carrying the block class) via
69
+ * `emitMergeUnlanded`, THEN fall through to the existing single
70
+ * `epic.blocked` emit below — one blocked path, never a duplicate
71
+ * `agent::blocked` transition.
72
+ *
73
+ * Attended-mode (headless === false, the default) behavior is
74
+ * byte-for-byte unchanged: budget exhaustion emits exactly
75
+ * `epic.blocked` with `reason: 'merge-watch:budget-exceeded'`, no
76
+ * classification, no retry, no `merge.unlanded`.
44
77
  */
45
78
 
46
79
  import { spawnSync } from 'node:child_process';
47
80
  import { appendFileSync, existsSync, mkdirSync, readFileSync } from 'node:fs';
48
81
  import path from 'node:path';
82
+ import { parsePrNumberFromUrl } from '../../../github-url.js';
83
+ import { classifyMergeBlock } from '../../merge-block-class.js';
84
+ import { emitMergeUnlanded } from '../emit-merge-unlanded.js';
49
85
 
50
86
  /**
51
87
  * Default poll interval and budget. The schema in
@@ -59,15 +95,31 @@ export const DEFAULT_INTERVAL_SECONDS = 30;
59
95
  export const DEFAULT_MAX_BUDGET_SECONDS = 3600;
60
96
 
61
97
  /**
62
- * Default `gh pr view --json mergeCommit,mergedAt` probe. Pure-spawn
98
+ * Fields requested from `gh pr view` on every poll. The merge-confirm
99
+ * fields (`mergeCommit`, `mergedAt`, `number`) are the original Story
100
+ * #2896 contract; `mergeStateStatus`, `reviewDecision`, and
101
+ * `statusCheckRollup` were added in Story #4427 so a headless
102
+ * budget-exhaustion path can classify the block (`classifyMergeBlock`)
103
+ * from the SAME probe already being polled, instead of issuing a
104
+ * second `gh` call.
105
+ */
106
+ const PR_VIEW_JSON_FIELDS =
107
+ 'mergeCommit,mergedAt,number,mergeStateStatus,reviewDecision,statusCheckRollup';
108
+
109
+ /**
110
+ * Default `gh pr view --json <PR_VIEW_JSON_FIELDS>` probe. Pure-spawn
63
111
  * helper — exported so tests can stub the shell-out without touching
64
112
  * the spawn wrapper.
65
113
  */
66
114
  export function ghPrViewMerge({ prUrl, cwd, spawnFn = spawnSync }) {
67
115
  const result = spawnFn(
68
116
  'gh',
69
- ['pr', 'view', prUrl, '--json', 'mergeCommit,mergedAt,number'],
70
- { cwd, encoding: 'utf-8', shell: false },
117
+ ['pr', 'view', prUrl, '--json', PR_VIEW_JSON_FIELDS],
118
+ {
119
+ cwd,
120
+ encoding: 'utf-8',
121
+ shell: false,
122
+ },
71
123
  );
72
124
  return {
73
125
  status: result.status ?? 1,
@@ -77,24 +129,58 @@ export function ghPrViewMerge({ prUrl, cwd, spawnFn = spawnSync }) {
77
129
  }
78
130
 
79
131
  /**
80
- * Parse `gh pr view --json mergeCommit,mergedAt,number` output.
81
- * Returns `{ mergeCommitSha, mergedAt, prNumber }` where
82
- * `mergeCommitSha` is `null` until the PR has merged. Pure — exported
83
- * for tests so the JSON-shape pin is reviewable.
132
+ * Pure: derive an aggregate `checksStatus` (`success` | `pending` |
133
+ * `still-running` | `failure` | `unknown`) from a
134
+ * `statusCheckRollup` array (`gh pr view --json statusCheckRollup`
135
+ * shape: `{ status, conclusion }` per check). Mirrors the values
136
+ * `classifyMergeBlock` expects on `prProbe.checksStatus`.
137
+ */
138
+ export function deriveChecksStatus(statusCheckRollup) {
139
+ if (!Array.isArray(statusCheckRollup) || statusCheckRollup.length === 0) {
140
+ return 'unknown';
141
+ }
142
+ let anyPending = false;
143
+ for (const check of statusCheckRollup) {
144
+ const conclusion = String(check?.conclusion ?? '').toUpperCase();
145
+ const status = String(check?.status ?? '').toUpperCase();
146
+ if (['FAILURE', 'CANCELLED', 'TIMED_OUT', 'ERROR'].includes(conclusion)) {
147
+ return 'failure';
148
+ }
149
+ if (status !== 'COMPLETED') {
150
+ anyPending = true;
151
+ }
152
+ }
153
+ return anyPending ? 'still-running' : 'success';
154
+ }
155
+
156
+ /**
157
+ * Parse `gh pr view --json <PR_VIEW_JSON_FIELDS>` output. Returns
158
+ * `{ mergeCommitSha, mergedAt, prNumber, mergeStateStatus,
159
+ * reviewDecision, checksStatus }` where `mergeCommitSha` is `null`
160
+ * until the PR has merged. Pure — exported for tests so the
161
+ * JSON-shape pin is reviewable.
84
162
  */
85
163
  export function parseMergeView(stdout) {
164
+ const empty = {
165
+ mergeCommitSha: null,
166
+ mergedAt: null,
167
+ prNumber: null,
168
+ mergeStateStatus: null,
169
+ reviewDecision: null,
170
+ checksStatus: 'unknown',
171
+ };
86
172
  const trimmed = String(stdout ?? '').trim();
87
173
  if (trimmed.length === 0) {
88
- return { mergeCommitSha: null, mergedAt: null, prNumber: null };
174
+ return empty;
89
175
  }
90
176
  let parsed;
91
177
  try {
92
178
  parsed = JSON.parse(trimmed);
93
179
  } catch {
94
- return { mergeCommitSha: null, mergedAt: null, prNumber: null };
180
+ return empty;
95
181
  }
96
182
  if (!parsed || typeof parsed !== 'object') {
97
- return { mergeCommitSha: null, mergedAt: null, prNumber: null };
183
+ return empty;
98
184
  }
99
185
  const merge = parsed.mergeCommit;
100
186
  const sha =
@@ -103,9 +189,35 @@ export function parseMergeView(stdout) {
103
189
  : null;
104
190
  const mergedAt = typeof parsed.mergedAt === 'string' ? parsed.mergedAt : null;
105
191
  const prNumber = Number.isInteger(parsed.number) ? parsed.number : null;
106
- return { mergeCommitSha: sha, mergedAt, prNumber };
192
+ const mergeStateStatus =
193
+ typeof parsed.mergeStateStatus === 'string'
194
+ ? parsed.mergeStateStatus
195
+ : null;
196
+ const reviewDecision =
197
+ typeof parsed.reviewDecision === 'string' ? parsed.reviewDecision : null;
198
+ const checksStatus = deriveChecksStatus(parsed.statusCheckRollup);
199
+ return {
200
+ mergeCommitSha: sha,
201
+ mergedAt,
202
+ prNumber,
203
+ mergeStateStatus,
204
+ reviewDecision,
205
+ checksStatus,
206
+ };
107
207
  }
108
208
 
209
+ // `parsePrNumberFromUrl` (imported above from the Story #3649 canonical
210
+ // `lib/github-url.js` helper) is the last-resort fallback for a `gh pr
211
+ // view` probe that never successfully returned `number` (e.g. every poll
212
+ // on the final watch cycle probe-failed). `emitMergeUnlanded` requires a
213
+ // positive-integer `prNumber`, and `prUrl` is always present by the time
214
+ // a watch cycle starts (checked in `handle()`), so this is the
215
+ // last-resort source of truth. Re-exported here so existing imports of
216
+ // `parsePrNumberFromUrl` from this module (e.g.
217
+ // `tests/epic-must-land-terminal.test.js`) keep working without a
218
+ // duplicate implementation (code-review finding, Epic #4425).
219
+ export { parsePrNumberFromUrl };
220
+
109
221
  /**
110
222
  * Resolve the resume-ledger path for an Epic. Pure helper — exported
111
223
  * so tests can pin the layout.
@@ -194,6 +306,16 @@ export class MergeWatcher {
194
306
  * epoch ms.
195
307
  * @param {() => string} [opts.nowIsoFn] override for tests; returns
196
308
  * ISO-8601 wall-clock for the attempt record.
309
+ * @param {boolean} [opts.headless] Explicit must-land signal (Story
310
+ * #4427). Defaults to `false` — attended-mode behavior (immediate
311
+ * `epic.blocked` on budget exhaustion, no classification, no
312
+ * retry) is unchanged. `true` engages the bounded classify-and-
313
+ * retry terminal step. Threaded from `/deliver`'s `--yes` flag via
314
+ * `lifecycle-emit.js --headless true` → `buildDefaultListenerChain`
315
+ * — an explicit constructor input, never an ambient global.
316
+ * @param {Function} [opts.emitMergeUnlandedFn] override for tests
317
+ * (defaults to the real `emitMergeUnlanded`, which appends to the
318
+ * on-disk lifecycle ledger).
197
319
  * @param {{ info?: Function, warn?: Function, debug?: Function }} [opts.logger]
198
320
  */
199
321
  constructor(opts = {}) {
@@ -229,14 +351,26 @@ export class MergeWatcher {
229
351
  this.nowMsFn = opts.nowMsFn ?? Date.now;
230
352
  this.nowIsoFn =
231
353
  opts.nowIsoFn ?? (() => new Date(this.nowMsFn()).toISOString());
354
+ this.headless = opts.headless === true;
355
+ this.emitMergeUnlandedFn = opts.emitMergeUnlandedFn ?? emitMergeUnlanded;
232
356
  this.logger = opts.logger ?? console;
233
357
  /** @type {Set<string>} `${event}:${seqId}` idempotency cache. */
234
358
  this._seen = new Set();
359
+ /**
360
+ * Must-land bounded-retry state (Story #4427, headless only). Each
361
+ * flips to `true` on its single use across the instance's whole
362
+ * lifetime — NOT per watch cycle — so a re-armed or budget-extended
363
+ * cycle that times out again falls straight through to the
364
+ * `merge.unlanded` terminal rather than retrying indefinitely.
365
+ */
366
+ this._budgetExtended = false;
367
+ this._reArmed = false;
235
368
  /**
236
369
  * Classification log — every `epic.merge.armed` observed lands
237
370
  * here with the outcome (`confirmed`, `budget-exceeded`,
238
- * `skipped-duplicate`, `failed`). Mirrors the Armer / Cleaner
239
- * "no silent skip" surface.
371
+ * `skipped-duplicate`, `failed`, or headless only — `extended` /
372
+ * `re-armed`). Mirrors the Armer / Cleaner "no silent skip"
373
+ * surface.
240
374
  */
241
375
  this.classifications = [];
242
376
  // Frozen tuple — MergeWatcher subscribes to EXACTLY one event.
@@ -292,7 +426,9 @@ export class MergeWatcher {
292
426
  }
293
427
 
294
428
  const intervalMs = this.intervalSeconds * 1000;
295
- const budgetMs = this.maxBudgetSeconds * 1000;
429
+ // `let`, not `const`: the headless must-land path extends this
430
+ // once on a `checks-pending-timeout` classification (Story #4427).
431
+ let budgetMs = this.maxBudgetSeconds * 1000;
296
432
  const startedAtMs = this.nowMsFn();
297
433
  let attempt = priorAttempts;
298
434
 
@@ -377,6 +513,112 @@ export class MergeWatcher {
377
513
  // Not merged. Budget check before sleeping.
378
514
  const elapsedMs = this.nowMsFn() - startedAtMs;
379
515
  if (elapsedMs + intervalMs > budgetMs) {
516
+ // Headless must-land: classify the block and try the bounded
517
+ // per-instance retry (budget extension OR re-arm, each at most
518
+ // once across this watcher's whole lifetime) before giving up.
519
+ // Attended mode (this.headless === false) skips straight to the
520
+ // unchanged budget-exceeded → epic.blocked path below.
521
+ if (this.headless) {
522
+ const elapsedSeconds = Math.floor(elapsedMs / 1000);
523
+ const classification = classifyMergeBlock({
524
+ prProbe: {
525
+ reviewDecision: view.reviewDecision,
526
+ mergeStateStatus: view.mergeStateStatus,
527
+ checksStatus: view.checksStatus,
528
+ error:
529
+ probe.status !== 0
530
+ ? probe.stderr || 'gh pr view failed'
531
+ : undefined,
532
+ },
533
+ budget: { exhausted: true, elapsedSeconds },
534
+ });
535
+
536
+ if (
537
+ classification.blockClass === 'checks-pending-timeout' &&
538
+ !this._budgetExtended
539
+ ) {
540
+ this._budgetExtended = true;
541
+ budgetMs += this.maxBudgetSeconds * 1000;
542
+ this.classifications.push({
543
+ event,
544
+ seqId,
545
+ outcome: 'extended',
546
+ reason: classification.reason,
547
+ prUrl,
548
+ pollAttempts: attempt,
549
+ });
550
+ this.logger.info?.(
551
+ `[MergeWatcher] extending watch budget once (checks-pending-timeout): ${classification.reason}`,
552
+ );
553
+ await this.sleepFn(intervalMs);
554
+ continue;
555
+ }
556
+
557
+ if (
558
+ classification.blockClass === 'api-race-other' &&
559
+ !this._reArmed
560
+ ) {
561
+ this._reArmed = true;
562
+ this.classifications.push({
563
+ event,
564
+ seqId,
565
+ outcome: 're-armed',
566
+ reason: classification.reason,
567
+ prUrl,
568
+ pollAttempts: attempt,
569
+ });
570
+ this.logger.info?.(
571
+ `[MergeWatcher] re-arming once (api-race-other): ${classification.reason}`,
572
+ );
573
+ let reArmEmitSucceeded = false;
574
+ try {
575
+ await this.bus.emit('epic.merge.ready', {
576
+ prUrl,
577
+ reason: `must-land retry: ${classification.reason}`,
578
+ });
579
+ reArmEmitSucceeded = true;
580
+ } catch (err) {
581
+ this.logger.warn?.(
582
+ `[MergeWatcher] must-land re-arm epic.merge.ready emit failed: ${err?.message ?? err}`,
583
+ );
584
+ }
585
+ if (reArmEmitSucceeded) {
586
+ // A successful re-arm re-emits epic.merge.armed (via
587
+ // AutomergeArmer's idempotent-probe short-circuit or a
588
+ // fresh arm), which re-triggers this watcher's handle()
589
+ // for a new watch cycle continuing the resume ledger's
590
+ // attempt count. Do NOT also emit epic.blocked here.
591
+ return;
592
+ }
593
+ // The re-arm attempt itself failed to emit — the bounded
594
+ // retry is spent with nothing landed. Fall through to the
595
+ // terminal merge.unlanded + epic.blocked path below rather
596
+ // than returning silently (audit-quality Critical finding,
597
+ // Epic #4425): a swallowed re-arm failure must still
598
+ // surface as an explicit block, never a silent stall.
599
+ }
600
+
601
+ // Terminal: branch-protection-human-required, or both bounded
602
+ // retries already spent. Emit merge.unlanded before falling
603
+ // through to the existing single epic.blocked emit below —
604
+ // one blocked path, never a duplicate agent::blocked
605
+ // transition.
606
+ try {
607
+ this.emitMergeUnlandedFn({
608
+ scope: 'epic',
609
+ ticketId: this.epicId,
610
+ prNumber: view.prNumber ?? parsePrNumberFromUrl(prUrl),
611
+ blockClass: classification.blockClass,
612
+ reason: classification.reason,
613
+ elapsedSeconds,
614
+ });
615
+ } catch (err) {
616
+ this.logger.warn?.(
617
+ `[MergeWatcher] emitMergeUnlanded failed (swallowed): ${err?.message ?? err}`,
618
+ );
619
+ }
620
+ }
621
+
380
622
  this.classifications.push({
381
623
  event,
382
624
  seqId,
@@ -416,6 +658,8 @@ export class MergeWatcher {
416
658
 
417
659
  reset() {
418
660
  this._seen.clear();
661
+ this._budgetExtended = false;
662
+ this._reArmed = false;
419
663
  this.classifications = [];
420
664
  }
421
665
  }
@@ -147,6 +147,12 @@ export class NotifyDispatcher {
147
147
  kind: 'notification.emitted',
148
148
  seqId,
149
149
  ts: new Date(this._now()).toISOString(),
150
+ // The common signal envelope requires `epicId` on the record
151
+ // itself — `hasCommonEnvelope` (lib/signals/schema.js) rejects
152
+ // rows without it and the read side silently drops them, so
153
+ // without this field the record was write-only (invisible to
154
+ // signals-view.js and every other reader).
155
+ epicId: this.epicId,
150
156
  sourceEvent: event,
151
157
  webhookEvent,
152
158
  };
@@ -0,0 +1,246 @@
1
+ // .agents/scripts/lib/orchestration/merge-block-class.js
2
+ /**
3
+ * merge-block-class.js — Story #4426 (Epic #4425, slice 1: foundation).
4
+ *
5
+ * Shared block-class classifier consumed by BOTH delivery paths — the
6
+ * epic-path must-land terminal step and the standalone
7
+ * `single-story-close` must-land terminal step (the not-yet-landed
8
+ * follow-on Stories under Epic #4425) — so a headless delivery run that
9
+ * finishes its work without a confirmed merge is attributable to exactly
10
+ * one of four classes from the SAME decision logic, instead of each path
11
+ * inventing its own ad hoc diagnosis.
12
+ *
13
+ * Block classes (Epic #4425 Goal; `predicate-refused` added by #4472):
14
+ * - `checks-pending-timeout` The watch/poll budget was
15
+ * exhausted while required checks
16
+ * were still pending/running — not
17
+ * a hard block, the run simply ran
18
+ * out of time.
19
+ * - `branch-protection-human-required` GitHub reports the PR needs a
20
+ * human action: a required review
21
+ * that hasn't been granted, or a
22
+ * branch-protection rule the
23
+ * automation cannot satisfy on its
24
+ * own.
25
+ * - `arm-failure` The arm call itself (`gh pr merge
26
+ * --auto` or equivalent) failed for
27
+ * a reason that is NOT branch
28
+ * protection — auth, rate limit, an
29
+ * already-merged race, a network
30
+ * error.
31
+ * - `api-race-other` Fallback for anything that does
32
+ * not cleanly fit the above three —
33
+ * a transient GraphQL/API error, an
34
+ * ambiguous probe result, or a
35
+ * genuinely novel condition.
36
+ * - `predicate-refused` The AutomergePredicate refused to
37
+ * arm merge BEFORE any arm attempt —
38
+ * a red/pending required check, an
39
+ * unreadable check probe, a dirty
40
+ * structured-signal verdict, or a
41
+ * `delivery.ci.requireChecks` policy
42
+ * block on a checks-less repo (#4472).
43
+ * The must-land contract previously
44
+ * only covered post-arm poll
45
+ * exhaustion, so a predicate refusal
46
+ * in headless mode silently parked;
47
+ * this class makes it attributable.
48
+ *
49
+ * Pure function, no I/O: callers pass in the already-observed
50
+ * arm-result / PR-probe / budget signals (from `AutomergeArmer`,
51
+ * `MergeWatcher`, a raw `gh pr view` read, or the standalone
52
+ * `single-story-confirm-merge.js` poll) and get back a
53
+ * `{ blockClass, reason }` verdict ready to hand to `emitMergeUnlanded`
54
+ * (`emit-merge-unlanded.js`).
55
+ */
56
+
57
+ /**
58
+ * The four block classes named in the Epic #4425 Goal. Order is the
59
+ * evaluation priority documented on `classifyMergeBlock` below, NOT an
60
+ * arbitrary listing — earlier entries are checked first when a real input
61
+ * happens to satisfy more than one heuristic.
62
+ */
63
+ export const BLOCK_CLASSES = Object.freeze([
64
+ 'checks-pending-timeout',
65
+ 'branch-protection-human-required',
66
+ 'arm-failure',
67
+ 'api-race-other',
68
+ ]);
69
+
70
+ /**
71
+ * The full set of block-class values a `merge.unlanded` record may carry.
72
+ * This is the classifier's four outputs PLUS `predicate-refused` (#4472),
73
+ * which is emitted DIRECTLY by the AutomergePredicate / AutomergeArmer for a
74
+ * headless refusal that never reached the poll-exhaustion classifier — so it
75
+ * is a valid attribution value even though `classifyMergeBlock` never
76
+ * produces it. `isValidBlockClass` (and the `merge.unlanded` schema enum)
77
+ * validate against this broader set; the classifier's own reachability
78
+ * invariant stays scoped to `BLOCK_CLASSES`.
79
+ */
80
+ export const MERGE_UNLANDED_BLOCK_CLASSES = Object.freeze([
81
+ ...BLOCK_CLASSES,
82
+ 'predicate-refused',
83
+ ]);
84
+
85
+ const BLOCK_CLASS_SET = new Set(MERGE_UNLANDED_BLOCK_CLASSES);
86
+
87
+ /**
88
+ * @param {string} value
89
+ * @returns {boolean} `true` iff `value` is a valid `merge.unlanded`
90
+ * block-class attribution (the four classifier outputs plus the directly-
91
+ * emitted `predicate-refused`).
92
+ */
93
+ export function isValidBlockClass(value) {
94
+ return BLOCK_CLASS_SET.has(value);
95
+ }
96
+
97
+ /**
98
+ * Substrings that identify a branch-protection / human-review rejection
99
+ * surfaced through an arm call's stderr or reason text. Matched
100
+ * case-insensitively against the whole string.
101
+ */
102
+ const BRANCH_PROTECTION_MARKERS = Object.freeze([
103
+ 'review',
104
+ 'required_status_checks',
105
+ 'protected branch',
106
+ 'branch protection',
107
+ 'approval',
108
+ ]);
109
+
110
+ function textIncludesAny(text, markers) {
111
+ const lower = String(text ?? '').toLowerCase();
112
+ return markers.some((marker) => lower.includes(marker));
113
+ }
114
+
115
+ /**
116
+ * Build the `api-race-other` fallback reason from whatever signal is
117
+ * available, so the emitted event still carries a specific-as-possible
118
+ * explanation rather than a bare "unknown".
119
+ */
120
+ function describeApiRaceFallback(prProbe, budget) {
121
+ if (prProbe?.error) {
122
+ return `PR probe error: ${prProbe.error}`;
123
+ }
124
+ if (budget && budget.exhausted === true) {
125
+ return `watch budget exhausted with an unrecognised checks status (${prProbe?.checksStatus ?? 'unknown'})`;
126
+ }
127
+ return 'no definitive block signal observed; classified as a transient API race or other condition';
128
+ }
129
+
130
+ /**
131
+ * Classify why a delivery run finished without a confirmed merge.
132
+ *
133
+ * Evaluation order (first match wins):
134
+ * 1. Arm failure — the arm call itself did not succeed. A failed arm
135
+ * means there is no "armed but stuck" PR left to probe, so this is
136
+ * checked before any PR-probe or budget signal. A branch-protection
137
+ * rejection surfaced AT arm time still routes to
138
+ * `branch-protection-human-required` rather than the generic
139
+ * `arm-failure`.
140
+ * 2. Budget exhaustion while checks were still in flight —
141
+ * `checks-pending-timeout`. Evaluated BEFORE the human-required
142
+ * probe signals because on a protected branch GitHub reports
143
+ * `mergeStateStatus: 'BLOCKED'` for the entire time required checks
144
+ * are still running — a slow-CI timeout would otherwise always
145
+ * misclassify as `branch-protection-human-required` and the
146
+ * headless once-only budget extension could never engage.
147
+ * 3. PR-probe human-required signals — `reviewDecision` reporting a
148
+ * required review, or `mergeStateStatus: 'BLOCKED'` with checks NOT
149
+ * in flight (green/failed checks + BLOCKED = a genuinely human
150
+ * gate, e.g. a missing approval).
151
+ * 4. Fallback — `api-race-other`.
152
+ *
153
+ * @param {object} input
154
+ * @param {object} [input.armResult] Outcome of the arm call.
155
+ * @param {boolean} [input.armResult.armed] `false` when the arm call
156
+ * itself failed (a non-zero `gh pr merge` exit, or arming was refused
157
+ * up-front).
158
+ * @param {string} [input.armResult.reason] Free-form failure detail (e.g.
159
+ * `gh` stderr) — inspected for branch-protection markers.
160
+ * @param {string} [input.armResult.error] Alternate free-form failure
161
+ * detail field, checked when `reason` is absent.
162
+ * @param {object} [input.prProbe] Latest `gh pr view` read.
163
+ * @param {string} [input.prProbe.reviewDecision] GitHub review decision
164
+ * (`REVIEW_REQUIRED`, `APPROVED`, …).
165
+ * @param {string} [input.prProbe.mergeStateStatus] GitHub merge-state
166
+ * status (`BLOCKED`, `BEHIND`, `CLEAN`, …).
167
+ * @param {string} [input.prProbe.checksStatus] Aggregate required-check
168
+ * status observed on the last probe (`success` | `pending` |
169
+ * `still-running` | `failure` | `unknown`).
170
+ * @param {string} [input.prProbe.error] Set when the probe call itself
171
+ * errored (network / API failure reading the PR).
172
+ * @param {object} [input.budget] Poll-budget accounting.
173
+ * @param {boolean} [input.budget.exhausted] `true` once the watch loop hit
174
+ * its budget without observing a confirmed merge.
175
+ * @param {number} [input.budget.elapsedSeconds] Elapsed watch time in
176
+ * seconds, folded into the `reason` text.
177
+ * @returns {{ blockClass: string, reason: string }}
178
+ */
179
+ export function classifyMergeBlock(input) {
180
+ const { armResult, prProbe, budget } = input ?? {};
181
+
182
+ // 1. Arm call failure.
183
+ if (armResult && armResult.armed === false) {
184
+ const detail = armResult.reason ?? armResult.error ?? '';
185
+ if (textIncludesAny(detail, BRANCH_PROTECTION_MARKERS)) {
186
+ return {
187
+ blockClass: 'branch-protection-human-required',
188
+ reason:
189
+ detail ||
190
+ 'arm call rejected: branch protection requires a human action',
191
+ };
192
+ }
193
+ return {
194
+ blockClass: 'arm-failure',
195
+ reason: detail || 'arm call failed for an unspecified reason',
196
+ };
197
+ }
198
+
199
+ // Positive in-flight evidence from the latest probe. Only `pending` /
200
+ // `still-running` count — `unknown` (empty rollup: a checks-less repo
201
+ // or a probe race) routes to the api-race re-arm below, and
202
+ // `undefined` (no probe at all) keeps its budget-timeout mapping in
203
+ // step 2 without suppressing the step-3 human-required verdict.
204
+ const checksStatus = prProbe?.checksStatus;
205
+ const checksPendingEvidence =
206
+ checksStatus === 'pending' || checksStatus === 'still-running';
207
+
208
+ // 2. Budget exhausted while checks were still in flight. Ordered
209
+ // before the human-required probe signals: `mergeStateStatus:
210
+ // 'BLOCKED'` is the steady state on a protected branch while required
211
+ // checks run, so a slow-CI timeout must not read as human-required —
212
+ // it must consume the headless once-only budget extension instead.
213
+ if (
214
+ budget &&
215
+ budget.exhausted === true &&
216
+ (checksPendingEvidence || checksStatus === undefined)
217
+ ) {
218
+ return {
219
+ blockClass: 'checks-pending-timeout',
220
+ reason: `watch budget exhausted after ${budget.elapsedSeconds ?? 'an unknown number of'} seconds with required checks still pending`,
221
+ };
222
+ }
223
+
224
+ // 3. PR-probe human-required signals. A BLOCKED merge state counts
225
+ // only without positive checks-in-flight evidence —
226
+ // BLOCKED-with-settled-checks is a genuinely human gate (e.g. a
227
+ // missing required approval), whereas BLOCKED-while-checks-run is the
228
+ // protected-branch steady state.
229
+ if (prProbe) {
230
+ if (
231
+ prProbe.reviewDecision === 'REVIEW_REQUIRED' ||
232
+ (prProbe.mergeStateStatus === 'BLOCKED' && !checksPendingEvidence)
233
+ ) {
234
+ return {
235
+ blockClass: 'branch-protection-human-required',
236
+ reason: `PR requires human action (reviewDecision=${prProbe.reviewDecision ?? 'n/a'}, mergeStateStatus=${prProbe.mergeStateStatus ?? 'n/a'})`,
237
+ };
238
+ }
239
+ }
240
+
241
+ // 4. Fallback.
242
+ return {
243
+ blockClass: 'api-race-other',
244
+ reason: describeApiRaceFallback(prProbe, budget),
245
+ };
246
+ }
@@ -24,7 +24,7 @@ const AUTO_PROCEED_MESSAGE =
24
24
  'Planning risk is low — auto-proceeding to Phase 8 decomposition after spec validation. Context tickets remain open until Epic delivery finalizes.';
25
25
 
26
26
  const REVIEW_REQUIRED_MESSAGE =
27
- 'Planning risk requires operator review — STOP before Phase 8. Review the PRD, Tech Spec, and Acceptance Spec on GitHub and confirm in this session before decomposition.';
27
+ 'Planning risk requires operator review — STOP before Phase 8. Review the Tech Spec sections and the Acceptance Table — both managed sections of the Epic body — on GitHub and confirm in this session before decomposition.';
28
28
 
29
29
  const FORCE_REVIEW_MESSAGE =
30
30
  'Operator override — forcing review stop before Phase 8 despite low planning risk.';
@@ -119,12 +119,12 @@ async function emitReapFailureFriction({
119
119
  storyId: Number(storyId),
120
120
  signal: {
121
121
  kind: 'friction',
122
- timestamp: new Date().toISOString(),
122
+ ts: new Date().toISOString(),
123
123
  epicId: Number(epicId),
124
124
  storyId: Number(storyId),
125
125
  category: 'reap-failure',
126
- source: { tool: 'story-close.js' },
127
- details: `Worktree reap failed: ${reason}`,
126
+ emitter: { tool: 'story-close.js' },
127
+ details: { message: `Worktree reap failed: ${reason}` },
128
128
  epicBranch,
129
129
  worktreePath: wtPath,
130
130
  reason,