mandrel 1.87.0 → 1.89.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 (140) 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 +63 -16
  17. package/.agents/docs/configuration.md +5 -3
  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 +16 -4
  25. package/.agents/schemas/audit-rules.json +16 -2
  26. package/.agents/schemas/audit-rules.schema.json +7 -6
  27. package/.agents/schemas/lifecycle/merge.unlanded.schema.json +38 -0
  28. package/.agents/schemas/signal-event.schema.json +28 -13
  29. package/.agents/scripts/acceptance-spec-reconciler.js +6 -4
  30. package/.agents/scripts/check-context-budget.js +320 -0
  31. package/.agents/scripts/diagnose-friction.js +4 -4
  32. package/.agents/scripts/epic-audit-prepare.js +30 -2
  33. package/.agents/scripts/epic-audit-recheck.js +46 -13
  34. package/.agents/scripts/epic-deliver-prepare.js +80 -8
  35. package/.agents/scripts/epic-plan-spec.js +4 -8
  36. package/.agents/scripts/generate-lens-checklists.js +180 -0
  37. package/.agents/scripts/lib/audit-suite/checklist-threading.js +300 -0
  38. package/.agents/scripts/lib/audit-suite/findings.js +27 -0
  39. package/.agents/scripts/lib/audit-suite/index.js +9 -0
  40. package/.agents/scripts/lib/audit-suite/lens-checklist.js +212 -0
  41. package/.agents/scripts/lib/audit-suite/selector.js +136 -5
  42. package/.agents/scripts/lib/checks/loop-health.js +340 -0
  43. package/.agents/scripts/lib/cli-args.js +8 -0
  44. package/.agents/scripts/lib/config/explain.js +4 -0
  45. package/.agents/scripts/lib/config/runners.js +21 -2
  46. package/.agents/scripts/lib/config/temp-paths.js +24 -0
  47. package/.agents/scripts/lib/config-settings-schema-delivery.js +23 -3
  48. package/.agents/scripts/lib/config-settings-schema-quality.js +7 -0
  49. package/.agents/scripts/lib/doc-tiers.js +291 -0
  50. package/.agents/scripts/lib/epic-body-sections.js +5 -2
  51. package/.agents/scripts/lib/epic-merge-lock.js +83 -0
  52. package/.agents/scripts/lib/epic-plan-clarity.js +3 -1
  53. package/.agents/scripts/lib/feedback-loop/audit-results-graduator.js +66 -20
  54. package/.agents/scripts/lib/feedback-loop/graduator-core.js +395 -86
  55. package/.agents/scripts/lib/feedback-loop/memory-freshness.js +299 -72
  56. package/.agents/scripts/lib/feedback-loop/retro-proposals-graduator.js +438 -0
  57. package/.agents/scripts/lib/gates/friction.js +15 -5
  58. package/.agents/scripts/lib/observability/perf-aggregator.js +30 -104
  59. package/.agents/scripts/lib/observability/perf-report-readers.js +1 -1
  60. package/.agents/scripts/lib/observability/signal-validator.js +204 -0
  61. package/.agents/scripts/lib/observability/signals-writer.js +157 -54
  62. package/.agents/scripts/lib/observability/tool-trace-hook.js +42 -4
  63. package/.agents/scripts/lib/orchestration/acceptance-eval-decision.js +1 -1
  64. package/.agents/scripts/lib/orchestration/code-review.js +74 -4
  65. package/.agents/scripts/lib/orchestration/consolidation-precondition.js +213 -0
  66. package/.agents/scripts/lib/orchestration/doc-reader.js +4 -96
  67. package/.agents/scripts/lib/orchestration/docs-digest.js +34 -0
  68. package/.agents/scripts/lib/orchestration/epic-plan-spec/phases/authoring-context.js +56 -19
  69. package/.agents/scripts/lib/orchestration/epic-plan-spec/phases/run-spec-phase.js +22 -0
  70. package/.agents/scripts/lib/orchestration/lifecycle/emit-merge-unlanded.js +188 -0
  71. package/.agents/scripts/lib/orchestration/lifecycle/listeners/README.md +6 -0
  72. package/.agents/scripts/lib/orchestration/lifecycle/listeners/automerge-armer.js +69 -8
  73. package/.agents/scripts/lib/orchestration/lifecycle/listeners/automerge-predicate.js +11 -2
  74. package/.agents/scripts/lib/orchestration/lifecycle/listeners/finalizer.js +47 -61
  75. package/.agents/scripts/lib/orchestration/lifecycle/listeners/index.js +39 -3
  76. package/.agents/scripts/lib/orchestration/lifecycle/listeners/label-transitioner.js +144 -0
  77. package/.agents/scripts/lib/orchestration/lifecycle/listeners/merge-watcher.js +258 -14
  78. package/.agents/scripts/lib/orchestration/lifecycle/listeners/notify-dispatcher.js +6 -0
  79. package/.agents/scripts/lib/orchestration/merge-block-class.js +218 -0
  80. package/.agents/scripts/lib/orchestration/plan-review-routing.js +1 -1
  81. package/.agents/scripts/lib/orchestration/post-merge/phases/worktree-reap.js +3 -3
  82. package/.agents/scripts/lib/orchestration/retro/phases/compose-body.js +63 -34
  83. package/.agents/scripts/lib/orchestration/retro/phases/gather-signals.js +167 -52
  84. package/.agents/scripts/lib/orchestration/retro/phases/post-and-mirror.js +49 -2
  85. package/.agents/scripts/lib/orchestration/retro-proposals.js +12 -55
  86. package/.agents/scripts/lib/orchestration/retro-runner.js +9 -0
  87. package/.agents/scripts/lib/orchestration/single-story-close/phases/code-review.js +8 -0
  88. package/.agents/scripts/lib/orchestration/single-story-close/phases/confirm-merge.js +419 -0
  89. package/.agents/scripts/lib/orchestration/single-story-close/phases/options.js +35 -2
  90. package/.agents/scripts/lib/orchestration/single-story-close/phases/wrong-tree-guard.js +353 -69
  91. package/.agents/scripts/lib/orchestration/single-story-close/runner.js +66 -4
  92. package/.agents/scripts/lib/orchestration/spec-section-validator.js +60 -9
  93. package/.agents/scripts/lib/orchestration/story-close/auto-refresh-runner.js +7 -5
  94. package/.agents/scripts/lib/orchestration/story-close/merge-runner.js +24 -2
  95. package/.agents/scripts/lib/orchestration/story-close/phases/code-review.js +167 -8
  96. package/.agents/scripts/lib/orchestration/story-close/shared-checkout-guard.js +163 -0
  97. package/.agents/scripts/lib/orchestration/ticketing/reads.js +20 -9
  98. package/.agents/scripts/lib/planning-corpus.js +306 -0
  99. package/.agents/scripts/lib/signals/detectors/common.js +10 -10
  100. package/.agents/scripts/lib/signals/detectors/index.js +4 -4
  101. package/.agents/scripts/lib/signals/detectors/retry.js +19 -18
  102. package/.agents/scripts/lib/signals/detectors/rework.js +1 -1
  103. package/.agents/scripts/lib/signals/schema.js +56 -81
  104. package/.agents/scripts/lib/signals/span-tree.js +6 -5
  105. package/.agents/scripts/lib/story-plan.js +3 -0
  106. package/.agents/scripts/lib/wave-runner/tick.js +10 -2
  107. package/.agents/scripts/lifecycle-emit.js +39 -8
  108. package/.agents/scripts/providers/github/issues.js +12 -1
  109. package/.agents/scripts/resolve-doc-tiers.js +83 -0
  110. package/.agents/scripts/retro-run.js +51 -0
  111. package/.agents/scripts/signals-view.js +1 -1
  112. package/.agents/scripts/single-story-close.js +20 -1
  113. package/.agents/scripts/standalone-feedback-rollup.js +188 -0
  114. package/.agents/scripts/story-close.js +48 -0
  115. package/.agents/scripts/story-plan.js +51 -12
  116. package/.agents/scripts/validate-docs-freshness.js +69 -15
  117. package/.agents/skills/core/documentation-and-adrs/SKILL.md +58 -0
  118. package/.agents/skills/core/epic-plan-decompose-author/SKILL.md +5 -3
  119. package/.agents/skills/core/epic-plan-spec-author/SKILL.md +20 -7
  120. package/.agents/skills/core/scope-triage/SKILL.md +61 -0
  121. package/.agents/skills/skills.index.json +3 -3
  122. package/.agents/workflows/audit-documentation.md +82 -2
  123. package/.agents/workflows/helpers/code-review.md +193 -44
  124. package/.agents/workflows/helpers/deliver-epic.md +128 -39
  125. package/.agents/workflows/helpers/deliver-stories.md +26 -0
  126. package/.agents/workflows/helpers/epic-audit.md +116 -283
  127. package/.agents/workflows/helpers/epic-deliver-story.md +14 -0
  128. package/.agents/workflows/helpers/epic-plan-decompose.md +18 -200
  129. package/.agents/workflows/helpers/epic-plan-spec.md +18 -180
  130. package/.agents/workflows/helpers/plan-epic.md +141 -105
  131. package/.agents/workflows/helpers/plan-story.md +32 -0
  132. package/.agents/workflows/helpers/single-story-deliver.md +43 -0
  133. package/.agents/workflows/loops/nightly-audit.md +9 -7
  134. package/docs/CHANGELOG.md +29 -0
  135. package/lib/cli/doctor.js +44 -0
  136. package/package.json +4 -3
  137. package/.agents/scripts/epic-plan-spec-validate.js +0 -111
  138. package/.agents/scripts/lib/feedback-loop/code-review-graduator.js +0 -207
  139. package/.agents/scripts/lib/orchestration/epic-plan-spec/phases/prompts.js +0 -58
  140. package/.agents/scripts/lib/signals/detectors/hotspot.js +0 -292
@@ -0,0 +1,144 @@
1
+ // .agents/scripts/lib/orchestration/lifecycle/listeners/label-transitioner.js
2
+ /**
3
+ * LabelTransitioner — lifecycle listener that owns the terminal Epic
4
+ * ticket-state flip: on `epic.complete` it transitions the Epic to
5
+ * `agent::done` via the canonical `transitionTicketState` API (which
6
+ * also closes the issue with `state_reason: completed` and mirrors the
7
+ * Projects-v2 status column).
8
+ *
9
+ * Subscribes to:
10
+ * - `epic.complete` → and ONLY this event.
11
+ *
12
+ * Why this listener exists (regression history): the original
13
+ * LabelTransitioner lived in the in-process epic-runner stratum and was
14
+ * deleted with it (Story #3908 / #3936) — but the `lifecycle-emit`
15
+ * listener chain never re-registered a replacement, so the Epic
16
+ * `agent::done` flip silently had NO owner. Every docstring in
17
+ * `cleaner.js` / `branch-cleaner.js` / `merge-watcher.js` that says
18
+ * "LabelTransitioner flips the Epic ticket to `agent::done` on
19
+ * epic.complete" referenced a ghost. In practice the flip only happened
20
+ * when a driving session (or the operator) ran `update-ticket-state.js`
21
+ * by hand — observed live on 2026-07-11 when Epics #4405 / #4425 /
22
+ * #4429 merged cleanly (Cleaner archived, `epic.complete` on the
23
+ * ledger) yet stayed at `agent::executing`. This listener restores the
24
+ * documented contract on the SOLE production wiring path
25
+ * (`buildDefaultListenerChain`).
26
+ *
27
+ * Side effects executed inside `handle()`:
28
+ * 1. `transitionTicketState(provider, epicId, STATE_LABELS.DONE)` —
29
+ * adds `agent::done`, removes every other `agent::*` label, closes
30
+ * the issue as completed (idempotent when the GitHub Closes-#N
31
+ * linkage already closed it), syncs the board column, and runs the
32
+ * upward cascade (a no-op sweep here: story-close already flipped
33
+ * every child Story).
34
+ *
35
+ * Failure posture: a failed transition THROWS (per
36
+ * `rules/orchestration-error-handling.md` — throw, never fatal). The
37
+ * bus's `onFailed` hook records the failure on the ledger and
38
+ * `lifecycle-emit`'s `collectOutcomes` → `emitBlockedSignal` path
39
+ * surfaces it loudly, so a provider outage cannot silently strand the
40
+ * Epic at `agent::executing` again — the exact failure mode this
41
+ * listener exists to close.
42
+ *
43
+ * Idempotency contract: per-instance `Set<string>` of
44
+ * `${event}:${seqId}` keys (the standard bus-replay defence). The
45
+ * transition itself is also idempotent at the provider layer (label
46
+ * add/remove and a close on an already-closed issue are no-ops), so a
47
+ * cross-process replay after a crash re-runs the flip harmlessly.
48
+ *
49
+ * Side-effect firewall: exactly one provider call per handled event. No
50
+ * filesystem writes, no follow-up bus emits, no `gh` shell-outs.
51
+ */
52
+
53
+ import { STATE_LABELS } from '../../ticketing/reads.js';
54
+ import { transitionTicketState } from '../../ticketing/transition.js';
55
+
56
+ /**
57
+ * The single lifecycle event this listener subscribes to. `epic.complete`
58
+ * is the terminal event of a successful Epic run, emitted by Cleaner
59
+ * AFTER the MergeWatcher observed a non-null mergeCommit — so the flip
60
+ * can never fire for an Epic whose PR did not actually merge.
61
+ */
62
+ export const SUBSCRIBED_EVENT = 'epic.complete';
63
+
64
+ export class LabelTransitioner {
65
+ /**
66
+ * @param {object} opts
67
+ * @param {object} opts.bus Lifecycle bus exposing `on()`.
68
+ * @param {number} opts.epicId Epic ticket id.
69
+ * @param {import('../../../ITicketingProvider.js').ITicketingProvider} opts.provider
70
+ * Ticketing provider. Required — the chain builder skips this
71
+ * listener entirely when no provider is wired (parity with
72
+ * AutomergePredicate's guard), so construction can demand one.
73
+ * @param {{ info?: Function, warn?: Function, debug?: Function }} [opts.logger]
74
+ */
75
+ constructor(opts = {}) {
76
+ if (!opts.bus || typeof opts.bus.on !== 'function') {
77
+ throw new TypeError('LabelTransitioner requires a bus with on()');
78
+ }
79
+ if (!Number.isInteger(opts.epicId) || opts.epicId < 1) {
80
+ throw new TypeError('LabelTransitioner requires a numeric epicId');
81
+ }
82
+ if (!opts.provider) {
83
+ // Truthiness-only, parity with AutomergePredicate: the chain
84
+ // builder's best-effort registration must not explode on a
85
+ // shape-minimal provider — a malformed one fails loudly at
86
+ // handle time instead, where the ledger records the outcome.
87
+ throw new TypeError('LabelTransitioner requires a provider');
88
+ }
89
+ this.bus = opts.bus;
90
+ this.epicId = opts.epicId;
91
+ this.provider = opts.provider;
92
+ this.logger = opts.logger ?? console;
93
+ /** @type {Set<string>} `${event}:${seqId}` idempotency cache. */
94
+ this._seen = new Set();
95
+ // Canonical subscription-set shape: the lifecycle doc-drift gate
96
+ // (`check-lifecycle-doc-drift.js#extractCodeEvents`) and the
97
+ // event-connectivity contract test both resolve this frozen array
98
+ // (constant references included) to derive the subscriber table.
99
+ this.events = Object.freeze([SUBSCRIBED_EVENT]);
100
+ }
101
+
102
+ /**
103
+ * Register the listener on `epic.complete`. Returns the array of
104
+ * unsubscribe callbacks the bus produced (parity with the sibling
105
+ * listeners).
106
+ */
107
+ register() {
108
+ return this.events.map((event) =>
109
+ this.bus.on(event, async (ctx) => this.handle(ctx)),
110
+ );
111
+ }
112
+
113
+ /**
114
+ * Bus listener body. Idempotent on `(event, seqId)`; flips the Epic
115
+ * to `agent::done` exactly once per observed `epic.complete`.
116
+ */
117
+ async handle({ event, seqId }) {
118
+ const key = `${event}:${seqId}`;
119
+ if (this._seen.has(key)) {
120
+ this.logger.debug?.(
121
+ `[LabelTransitioner] skip duplicate ${key} (idempotent)`,
122
+ );
123
+ return;
124
+ }
125
+ this._seen.add(key);
126
+
127
+ this.logger.info?.(
128
+ `[LabelTransitioner] epic.complete observed — transitioning Epic #${this.epicId} to ${STATE_LABELS.DONE}.`,
129
+ );
130
+ // Throws on failure by design: the ledger records the failed
131
+ // listener outcome and lifecycle-emit surfaces it (see the failure
132
+ // posture note in the module docstring). Swallowing here would
133
+ // recreate the silent agent::executing strand this listener fixes.
134
+ await transitionTicketState(this.provider, this.epicId, STATE_LABELS.DONE);
135
+ }
136
+
137
+ /**
138
+ * Test-only — clear the idempotency cache so a single instance can
139
+ * exercise replay scenarios without re-constructing the listener.
140
+ */
141
+ resetSeen() {
142
+ this._seen.clear();
143
+ }
144
+ }
@@ -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
  };