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,188 @@
1
+ /**
2
+ * emit-merge-unlanded.js — Story #4426 (Epic #4425, slice 1: foundation).
3
+ *
4
+ * Programmatic helper that appends a single `merge.unlanded` NDJSON
5
+ * record to the lifecycle ledger whenever a headless delivery run
6
+ * finishes its work without a confirmed merge. Pattern mirrors
7
+ * `emit-story-heartbeat.js`: direct schema validation via Ajv followed by
8
+ * a synchronous `appendFileSync` — this event is NOT routed through the
9
+ * bus (unlike `emit-loop-tick.js`) because it can fire from either the
10
+ * epic-path finalize flow (which already owns an Epic-scoped bus
11
+ * instance for its own run) or the standalone `single-story-close` flow,
12
+ * which has no bus at all. A bare append keeps both call sites simple
13
+ * and dependency-free.
14
+ *
15
+ * Ledger destination is scope-driven (Story #4426 AC4):
16
+ * - `scope: 'epic'` → `epicLedgerPath(ticketId)` — the same
17
+ * `temp/epic-<id>/lifecycle.ndjson` every other Epic-scoped event
18
+ * lands in. `ticketId` is the epicId.
19
+ * - `scope: 'story'` → `storyLedgerPath(null, ticketId)` — the
20
+ * standalone story-scope destination
21
+ * `temp/standalone/stories/story-<id>/lifecycle.ndjson`. `ticketId`
22
+ * is the storyId. The standalone `single-story-close` path has no
23
+ * parent Epic to anchor a `temp/epic-<id>/` directory to, mirroring
24
+ * the `eid === null` standalone convention `signalsFile` already
25
+ * uses for Story-level signals.
26
+ *
27
+ * A caller may always override the destination via `ledgerPath` (tests,
28
+ * or a future caller with a non-default temp layout).
29
+ *
30
+ * Distinct from:
31
+ * - `epic.merge.blocked` — AutomergePredicate's "not safe to arm yet"
32
+ * signal, evaluated BEFORE arming. `merge.unlanded` fires AFTER a
33
+ * delivery flow has already finished trying and gives up.
34
+ * - `epic.blocked` / `story.blocked` — the generic `agent::blocked`
35
+ * transition signal. `merge.unlanded` is the merge-specific
36
+ * diagnosis a `*.blocked` transition is typically paired with, not a
37
+ * replacement for it.
38
+ *
39
+ * The emit is best-effort in the sense that a failure to append MUST NOT
40
+ * mask the underlying blocked-state transition the caller is already
41
+ * driving — callers should treat this the same way
42
+ * `emitStoryHeartbeat` documents: catch, log, and proceed with the label
43
+ * flip / friction comment regardless.
44
+ *
45
+ * Schema contract (merge.unlanded.schema.json):
46
+ * { event, scope, ticketId, prNumber, blockClass, reason,
47
+ * elapsedSeconds, timestamp? }
48
+ *
49
+ * The schema declares `additionalProperties: false`, so this emitter's
50
+ * signature is deliberately narrow: only the schema-allowed fields are
51
+ * accepted. `blockClass` MUST be one of the four classes named in
52
+ * `merge-block-class.js` — pass the classifier's verdict straight
53
+ * through (`classifyMergeBlock(...)` returns `{ blockClass, reason }`).
54
+ */
55
+
56
+ import { appendFileSync, mkdirSync, readFileSync } from 'node:fs';
57
+ import path from 'node:path';
58
+ import { fileURLToPath } from 'node:url';
59
+
60
+ import Ajv2020 from 'ajv/dist/2020.js';
61
+ import addFormats from 'ajv-formats';
62
+
63
+ import { epicLedgerPath, storyLedgerPath } from '../../config/temp-paths.js';
64
+ import { isValidBlockClass } from '../merge-block-class.js';
65
+
66
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
67
+ const SCHEMA_PATH = path.resolve(
68
+ __dirname,
69
+ '..',
70
+ '..',
71
+ '..',
72
+ '..',
73
+ 'schemas',
74
+ 'lifecycle',
75
+ 'merge.unlanded.schema.json',
76
+ );
77
+
78
+ const VALID_SCOPES = new Set(['epic', 'story']);
79
+
80
+ let _validator;
81
+
82
+ function getValidator() {
83
+ if (_validator) return _validator;
84
+ const schema = JSON.parse(readFileSync(SCHEMA_PATH, 'utf8'));
85
+ const ajv = new Ajv2020({ allErrors: true, strict: false });
86
+ addFormats(ajv);
87
+ _validator = ajv.compile(schema);
88
+ return _validator;
89
+ }
90
+
91
+ /**
92
+ * Append exactly one `merge.unlanded` NDJSON record to the resolved
93
+ * lifecycle ledger.
94
+ *
95
+ * @param {object} opts
96
+ * @param {'epic'|'story'} opts.scope Which delivery path is reporting the
97
+ * unlanded merge.
98
+ * @param {number} opts.ticketId epicId when `scope === 'epic'`,
99
+ * storyId when `scope === 'story'`.
100
+ * @param {number} opts.prNumber The PR number that did not land.
101
+ * @param {string} opts.blockClass One of the four
102
+ * `merge-block-class.js` classes.
103
+ * @param {string} opts.reason Free-form diagnosis detail — pass
104
+ * the classifier's `reason`.
105
+ * @param {number} opts.elapsedSeconds Elapsed watch/poll time when the
106
+ * run gave up.
107
+ * @param {string} [opts.timestamp] ISO-8601 wall clock. Defaults to
108
+ * now().
109
+ * @param {object} [opts.config] Optional resolved config for
110
+ * tempRoot.
111
+ * @param {string} [opts.ledgerPath] Override for tests / non-default
112
+ * layouts.
113
+ * @returns {{ ledgerPath: string, record: object }}
114
+ */
115
+ export function emitMergeUnlanded(opts) {
116
+ const {
117
+ scope,
118
+ ticketId,
119
+ prNumber,
120
+ blockClass,
121
+ reason,
122
+ elapsedSeconds,
123
+ timestamp = new Date().toISOString(),
124
+ config,
125
+ ledgerPath: ledgerPathOverride,
126
+ } = opts ?? {};
127
+
128
+ if (!VALID_SCOPES.has(scope)) {
129
+ throw new Error(
130
+ `emitMergeUnlanded: scope "${scope}" must be one of: ${[...VALID_SCOPES].join(', ')}`,
131
+ );
132
+ }
133
+ if (!Number.isInteger(ticketId) || ticketId < 1) {
134
+ throw new Error('emitMergeUnlanded: ticketId must be a positive integer');
135
+ }
136
+ if (!Number.isInteger(prNumber) || prNumber < 1) {
137
+ throw new Error('emitMergeUnlanded: prNumber must be a positive integer');
138
+ }
139
+ if (!isValidBlockClass(blockClass)) {
140
+ throw new Error(
141
+ `emitMergeUnlanded: blockClass "${blockClass}" is not a recognised merge-block-class value`,
142
+ );
143
+ }
144
+ if (typeof reason !== 'string' || reason.length === 0) {
145
+ throw new Error('emitMergeUnlanded: reason must be a non-empty string');
146
+ }
147
+ if (typeof elapsedSeconds !== 'number' || elapsedSeconds < 0) {
148
+ throw new Error(
149
+ 'emitMergeUnlanded: elapsedSeconds must be a non-negative number',
150
+ );
151
+ }
152
+
153
+ const payload = {
154
+ event: 'merge.unlanded',
155
+ scope,
156
+ ticketId,
157
+ prNumber,
158
+ blockClass,
159
+ reason,
160
+ elapsedSeconds,
161
+ timestamp,
162
+ };
163
+
164
+ const validator = getValidator();
165
+ if (!validator(payload)) {
166
+ const detail = (validator.errors ?? [])
167
+ .map((e) => `${e.instancePath || '/'} ${e.message}`)
168
+ .join('; ');
169
+ throw new Error(
170
+ `emitMergeUnlanded: payload failed schema validation: ${detail}`,
171
+ );
172
+ }
173
+
174
+ const ledgerPath =
175
+ ledgerPathOverride ??
176
+ (scope === 'epic'
177
+ ? epicLedgerPath(ticketId, config)
178
+ : storyLedgerPath(null, ticketId, config));
179
+ mkdirSync(path.dirname(ledgerPath), { recursive: true });
180
+ const record = {
181
+ kind: 'emitted',
182
+ ts: timestamp,
183
+ event: 'merge.unlanded',
184
+ payload,
185
+ };
186
+ appendFileSync(ledgerPath, `${JSON.stringify(record)}\n`, 'utf8');
187
+ return { ledgerPath, record };
188
+ }
@@ -22,6 +22,12 @@ entrypoint the standalone `lifecycle-emit.js` CLI shells in for
22
22
  the PR's `mergeCommit` is observed, then emits `epic.merge.confirmed`.
23
23
  - `cleaner.js` — archives `temp/epic-<id>/` and emits the terminal
24
24
  `epic.cleanup.* → epic.complete` sequence on `epic.merge.confirmed`.
25
+ - `label-transitioner.js` — flips the Epic ticket to `agent::done` (and
26
+ closes it as completed, idempotently) on `epic.complete`. Skipped when
27
+ no provider is wired. Re-homed onto this chain after the 2026-07-11
28
+ incident (merged Epics stranding at `agent::executing`): the original
29
+ LabelTransitioner died with the in-process runner stratum (#3936) and
30
+ the flip had no owner.
25
31
  - `checkpoint-pointer-writer.js` — persists `{ lastCompletedSeqId, phase }`
26
32
  on every `*.end` event.
27
33
 
@@ -64,7 +64,7 @@ import { resolveAutoMergeArmCwd } from '../../auto-merge-cwd.js';
64
64
  export function ghPrViewAutoMerge({ prUrl, cwd, spawnFn = spawnSync }) {
65
65
  const result = spawnFn(
66
66
  'gh',
67
- ['pr', 'view', prUrl, '--json', 'autoMergeRequest'],
67
+ ['pr', 'view', prUrl, '--json', 'autoMergeRequest,mergeCommit'],
68
68
  { cwd, encoding: 'utf-8', shell: false },
69
69
  );
70
70
  return {
@@ -110,9 +110,10 @@ export function ghPrMergeAuto({
110
110
  }
111
111
 
112
112
  /**
113
- * Pure: parse `gh pr view --json autoMergeRequest` output. The field
114
- * is `null` when auto-merge is NOT armed; a non-null object means a
115
- * prior arm is in place. Returns `true` iff already armed.
113
+ * Pure: parse `gh pr view --json autoMergeRequest,mergeCommit` output.
114
+ * `autoMergeRequest` is `null` when auto-merge is NOT armed; a non-null
115
+ * object means a prior arm is in place. Returns `true` iff already
116
+ * armed.
116
117
  *
117
118
  * Exported for tests so the JSON shape pin is reviewable.
118
119
  */
@@ -134,6 +135,36 @@ export function parseAutoMergeArmed(stdout) {
134
135
  }
135
136
  }
136
137
 
138
+ /**
139
+ * Pure: parse the same probe output for a non-null `mergeCommit` — the
140
+ * PR has ALREADY merged. `gh pr merge --auto` on an already-green PR
141
+ * merges immediately (there is nothing to queue behind), and its exit
142
+ * code then reflects the post-merge local housekeeping: on 2026-07-11
143
+ * (Epic #4454, PR #4459) the merge itself succeeded but the trailing
144
+ * local `--delete-branch` failed because a harness worktree held
145
+ * `epic/4454`, so `gh` exited 1 and the armer misreported a successful
146
+ * merge as `arm-failed` — stranding the run at `agent::blocked` with
147
+ * the whole must-land chain (MergeWatcher → Cleaner → LabelTransitioner)
148
+ * never engaging. The arm-failure re-probe below uses this parser to
149
+ * distinguish "merge landed, housekeeping grumbled" from a genuine arm
150
+ * failure.
151
+ */
152
+ export function parsePrMerged(stdout) {
153
+ const trimmed = String(stdout ?? '').trim();
154
+ if (trimmed.length === 0) return false;
155
+ try {
156
+ const parsed = JSON.parse(trimmed);
157
+ if (!parsed || typeof parsed !== 'object') return false;
158
+ return (
159
+ parsed.mergeCommit !== null &&
160
+ parsed.mergeCommit !== undefined &&
161
+ typeof parsed.mergeCommit === 'object'
162
+ );
163
+ } catch {
164
+ return false;
165
+ }
166
+ }
167
+
137
168
  /**
138
169
  * AutomergeArmer listener.
139
170
  */
@@ -208,13 +239,17 @@ export class AutomergeArmer {
208
239
  }
209
240
 
210
241
  // Layer 2 idempotency — cross-process probe. If auto-merge is
211
- // already armed on the PR, emit `epic.merge.armed` and bail
212
- // without re-issuing the merge command.
242
+ // already armed on the PR (or the PR already merged — a prior arm
243
+ // completed the merge before this run re-entered), emit
244
+ // `epic.merge.armed` and bail without re-issuing the merge command.
213
245
  const probe = this.ghPrViewAutoMergeFn({ prUrl, cwd: this.cwd });
214
- if (probe.status === 0 && parseAutoMergeArmed(probe.stdout)) {
246
+ if (
247
+ probe.status === 0 &&
248
+ (parseAutoMergeArmed(probe.stdout) || parsePrMerged(probe.stdout))
249
+ ) {
215
250
  this.classifications.push({ event, seqId, outcome: 'existing', prUrl });
216
251
  this.logger.info?.(
217
- `[AutomergeArmer] auto-merge already armed on ${prUrl} — short-circuiting.`,
252
+ `[AutomergeArmer] auto-merge already armed (or PR already merged) on ${prUrl} — short-circuiting.`,
218
253
  );
219
254
  await this._emitArmed(prUrl);
220
255
  return;
@@ -232,6 +267,32 @@ export class AutomergeArmer {
232
267
  // entire codebase (see check-lifecycle-lint.js).
233
268
  const arm = this.ghPrMergeAutoFn({ prUrl, cwd: this.cwd });
234
269
  if (arm.status !== 0) {
270
+ // A non-zero arm exit does NOT necessarily mean the arm failed:
271
+ // `gh pr merge --auto --squash --delete-branch` on an
272
+ // already-green PR merges immediately and then runs local
273
+ // branch-delete housekeeping whose failure (e.g. the epic branch
274
+ // held by another worktree) surfaces as exit 1 AFTER the merge
275
+ // landed (2026-07-11 incident, Epic #4454 / PR #4459). Re-probe
276
+ // before classifying: merged or armed → proceed as success so the
277
+ // MergeWatcher → Cleaner → LabelTransitioner chain engages.
278
+ const recheck = this.ghPrViewAutoMergeFn({ prUrl, cwd: this.cwd });
279
+ if (
280
+ recheck.status === 0 &&
281
+ (parsePrMerged(recheck.stdout) || parseAutoMergeArmed(recheck.stdout))
282
+ ) {
283
+ this.classifications.push({
284
+ event,
285
+ seqId,
286
+ outcome: 'armed',
287
+ prUrl,
288
+ note: `arm exit ${arm.status} but re-probe shows merged/armed (post-merge housekeeping failure): ${arm.stderr}`,
289
+ });
290
+ this.logger.warn?.(
291
+ `[AutomergeArmer] gh pr merge exited ${arm.status} but the PR is merged/armed — treating as success (housekeeping stderr: ${arm.stderr})`,
292
+ );
293
+ await this._emitArmed(prUrl);
294
+ return;
295
+ }
235
296
  this.classifications.push({
236
297
  event,
237
298
  seqId,
@@ -66,6 +66,7 @@
66
66
 
67
67
  import { spawnSync } from 'node:child_process';
68
68
 
69
+ import { hasSurvivingCritical } from '../../../audit-suite/findings.js';
69
70
  import { getCiDelivery } from '../../../config/ci.js';
70
71
  import * as epicRunStateStore from '../../epic-run-state-store.js';
71
72
  import { findStructuredComment } from '../../ticketing.js';
@@ -441,7 +442,11 @@ function evaluateCodeReviewSignals(codeReview, reasons) {
441
442
  if (codeReviewUnparseable) {
442
443
  return { codeReviewFound, codeReviewUnparseable, severity };
443
444
  }
444
- if (severity.critical > 0) {
445
+ // Route the halt-on-critical decision through the single halting rule of
446
+ // the unified verification-results contract (Story #4411) rather than a
447
+ // re-derived `critical > 0` expression. The unparseable branch above has
448
+ // already returned, so `severity.critical` is a concrete number here.
449
+ if (hasSurvivingCritical(severity)) {
445
450
  pushReason(
446
451
  reasons,
447
452
  REASON_CATEGORY.CRITICAL_REVIEW,
@@ -648,7 +653,11 @@ export async function evaluateAutoMergePredicate({
648
653
  // only IO, but the rule is directory-scoped; sequencing here is a
649
654
  // cheap concession for living inside the listener tree.
650
655
  const state = await readRunStateFn({ provider, epicId });
651
- const codeReview = await findCommentFn(provider, epicId, 'code-review');
656
+ const codeReview = await findCommentFn(
657
+ provider,
658
+ epicId,
659
+ 'verification-results',
660
+ );
652
661
  let retro = await findCommentFn(provider, epicId, 'retro');
653
662
  if (!retro) {
654
663
  retro = await findCommentFn(provider, epicId, 'retro-partial');
@@ -16,8 +16,15 @@
16
16
  *
17
17
  * Side effects executed inside `handle()`:
18
18
  * 1. Emit `epic.finalize.start`.
19
- * 2. Auto-graduate non-blocking code-review / audit-results findings
20
- * (best-effort; never throws).
19
+ * 2. Auto-graduate non-blocking findings in a SINGLE pass over the
20
+ * unified `verification-results` comment (best-effort; never throws).
21
+ * Story #4411 folded the former code-review + audit-results
22
+ * structured-comment contracts into one comment; the lens-aware
23
+ * audit-results graduator is the single canonical reader (its
24
+ * 🟢→`suggestion` severity vocabulary matches `findings-renderer.js`).
25
+ * Running a second code-review pass over the same comment would file
26
+ * every non-blocking finding twice, so only the audit-results
27
+ * graduation runs here.
21
28
  * 3. Idempotency probe — `gh pr list --head epic/<id>` returns any
22
29
  * existing PR URL. If one exists, short-circuit to `pr.created`
23
30
  * + `epic.finalize.end` carrying the existing URL.
@@ -69,7 +76,6 @@ import {
69
76
  graduateAuditResults as defaultGraduateAuditResults,
70
77
  isAutoFileEnabled as isAuditResultsAutoFileEnabled,
71
78
  } from '../../../feedback-loop/audit-results-graduator.js';
72
- import { graduateFindings as defaultGraduateFindings } from '../../../feedback-loop/code-review-graduator.js';
73
79
  import { parsePrNumberFromUrl } from '../../../github-url.js';
74
80
  import {
75
81
  markPrReady as defaultMarkPrReady,
@@ -235,17 +241,39 @@ export function extractPrUrl(stdout) {
235
241
  return match ? match[0] : null;
236
242
  }
237
243
 
244
+ /**
245
+ * Default bounded timeout for the finalize idempotency probe. Story #4415
246
+ * (Epic #4406) — a hung `gh pr list` spawn previously blocked finalize
247
+ * forever; the probe now SIGKILLs the child at this bound so a stalled
248
+ * `gh` cannot park the merge gate.
249
+ */
250
+ export const GH_PR_LIST_TIMEOUT_MS = 30000;
251
+
238
252
  /**
239
253
  * Default `gh` spawn used by the listener's idempotency probe.
240
254
  * Mirrors the `shell: false` contract `openOrLocatePr` and the other
241
255
  * listener helpers use so a future Windows audit doesn't have to grep
242
- * across two modules. Exported so tests can stub.
256
+ * across two modules. Bounded by `timeoutMs` (default
257
+ * {@link GH_PR_LIST_TIMEOUT_MS}) + `killSignal: 'SIGKILL'` so a hung `gh`
258
+ * spawn cannot block finalize indefinitely (Story #4415). Exported so
259
+ * tests can stub.
243
260
  */
244
- export function ghPrListHead({ epicBranch, cwd, spawnFn = spawnSync }) {
261
+ export function ghPrListHead({
262
+ epicBranch,
263
+ cwd,
264
+ spawnFn = spawnSync,
265
+ timeoutMs = GH_PR_LIST_TIMEOUT_MS,
266
+ }) {
245
267
  const result = spawnFn(
246
268
  'gh',
247
269
  ['pr', 'list', '--head', epicBranch, '--json', 'url', '--jq', '.[0].url'],
248
- { cwd, encoding: 'utf-8', shell: false },
270
+ {
271
+ cwd,
272
+ encoding: 'utf-8',
273
+ shell: false,
274
+ timeout: timeoutMs,
275
+ killSignal: 'SIGKILL',
276
+ },
249
277
  );
250
278
  return {
251
279
  status: result.status ?? 1,
@@ -276,7 +304,6 @@ export class Finalizer {
276
304
  * the graduators.
277
305
  * @param {{owner:string,repo:string}} [opts.currentRepo]
278
306
  * @param {{owner:string,repo:string}} [opts.frameworkRepo]
279
- * @param {Function} [opts.graduateFindingsFn]
280
307
  * @param {Function} [opts.graduateAuditResultsFn]
281
308
  * @param {{ info?: Function, warn?: Function, debug?: Function }} [opts.logger]
282
309
  */
@@ -314,8 +341,6 @@ export class Finalizer {
314
341
  this.config = opts.config ?? null;
315
342
  this.currentRepo = opts.currentRepo ?? null;
316
343
  this.frameworkRepo = opts.frameworkRepo ?? null;
317
- this.graduateFindingsFn =
318
- opts.graduateFindingsFn ?? defaultGraduateFindings;
319
344
  this.graduateAuditResultsFn =
320
345
  opts.graduateAuditResultsFn ?? defaultGraduateAuditResults;
321
346
  this.logger = opts.logger ?? console;
@@ -373,10 +398,11 @@ export class Finalizer {
373
398
  return;
374
399
  }
375
400
 
376
- // 1b. Auto-graduate non-blocking code-review findings (Story #2555).
377
- await this._runCodeReviewGraduation();
378
-
379
- // 1c. Auto-graduate non-blocking audit-results findings (Story #2615).
401
+ // 1b. Auto-graduate non-blocking findings in a SINGLE pass over the
402
+ // unified `verification-results` comment (Story #2615; unified in
403
+ // Story #4411). The lens-aware audit-results graduator is the sole
404
+ // canonical reader a second code-review pass over the same comment
405
+ // would file every non-blocking finding twice.
380
406
  await this._runAuditResultsGraduation();
381
407
 
382
408
  // 2. Idempotency probe — does a PR already exist on the head
@@ -498,54 +524,14 @@ export class Finalizer {
498
524
  }
499
525
 
500
526
  /**
501
- * Invoke the code-review graduator best-effort. Wired into finalize so
502
- * that surviving non-blocking findings get auto-filed as routed
503
- * follow-up issues (Story #2555 / Epic #2547). All failures are
504
- * captured and logged at warn level; the finalize pipeline continues
505
- * regardless the toggle `delivery.feedbackLoop.codeReviewAutoFile`
506
- * is the only operator-facing kill switch.
507
- */
508
- async _runCodeReviewGraduation() {
509
- if (!this.provider || !this.currentRepo) {
510
- this.logger.debug?.(
511
- '[Finalizer] code-review graduation skipped: provider or currentRepo not wired',
512
- );
513
- return;
514
- }
515
- try {
516
- const summary = await this.graduateFindingsFn({
517
- epicId: this.epicId,
518
- provider: this.provider,
519
- config: this.config,
520
- currentRepo: this.currentRepo,
521
- frameworkRepo: this.frameworkRepo,
522
- cwd: this.cwd,
523
- logger: this.logger,
524
- });
525
- const filed = Array.isArray(summary?.filed) ? summary.filed.length : 0;
526
- const skipped = Array.isArray(summary?.skipped)
527
- ? summary.skipped.length
528
- : 0;
529
- const errors = Array.isArray(summary?.errors) ? summary.errors.length : 0;
530
- this.logger.info?.(
531
- `[Finalizer] code-review graduation: filed=${filed} skipped=${skipped} errors=${errors}`,
532
- );
533
- if (errors > 0) {
534
- this.logger.warn?.(
535
- `[Finalizer] code-review graduator errors: ${summary.errors.join('; ')}`,
536
- );
537
- }
538
- } catch (err) {
539
- this.logger.warn?.(
540
- `[Finalizer] code-review graduator threw (swallowed): ${err?.message ?? err}`,
541
- );
542
- }
543
- }
544
-
545
- /**
546
- * Invoke the audit-results graduator best-effort. Wired into finalize
547
- * so that non-blocking audit findings (high/medium/low/suggestion) get
548
- * auto-filed as routed follow-up issues — Story #2615 / Epic #2586.
527
+ * Invoke the audit-results graduator best-effort the SINGLE canonical
528
+ * graduation pass over the unified `verification-results` comment. Wired
529
+ * into finalize so that non-blocking findings (high/medium/low/suggestion)
530
+ * get auto-filed as routed follow-up issues Story #2615 / Epic #2586,
531
+ * unified into one pass in Story #4411 / Epic #4405. The lens-aware
532
+ * audit-results graduator is canonical because its 🟢→`suggestion`
533
+ * severity vocabulary matches `findings-renderer.js`; running a parallel
534
+ * code-review pass over the same comment would double-file every finding.
549
535
  */
550
536
  async _runAuditResultsGraduation() {
551
537
  if (!this.provider || !this.currentRepo) {
@@ -18,8 +18,10 @@
18
18
  * 4. AutomergeArmer (epic.merge.ready → epic.merge.armed)
19
19
  * 5. AutomergePredicate (epic.watch.end → epic.merge.{ready,blocked})
20
20
  * 6. BranchCleaner (epic.cleanup.start → branch reap)
21
- * 7. Cleaner (epic.merge.armed → epic.cleanup.* / epic.complete)
22
- * 8. CheckpointPointerWriter (every *.end checkpoint.json)
21
+ * 7. MergeWatcher (epic.merge.armed → epic.merge.confirmed)
22
+ * 8. Cleaner (epic.merge.confirmedepic.cleanup.* / epic.complete)
23
+ * 9. LabelTransitioner (epic.complete → Epic ticket flips to agent::done)
24
+ * 10. CheckpointPointerWriter (every *.end → checkpoint.json)
23
25
  *
24
26
  * The bus contract requires LedgerWriter first: its `onEmitted` hook
25
27
  * lands the `emitted` ledger record on disk BEFORE any listener body
@@ -60,6 +62,7 @@ import { BranchCleaner } from './branch-cleaner.js';
60
62
  import { CheckpointPointerWriter } from './checkpoint-pointer-writer.js';
61
63
  import { Cleaner } from './cleaner.js';
62
64
  import { Finalizer } from './finalizer.js';
65
+ import { LabelTransitioner } from './label-transitioner.js';
63
66
  import { MergeWatcher } from './merge-watcher.js';
64
67
 
65
68
  /**
@@ -118,6 +121,11 @@ export function parseLedgerPath(ledgerPath) {
118
121
  * @param {object} [opts.checkpointer] Epic-run-state checkpoint reader.
119
122
  * When omitted, BranchCleaner is skipped.
120
123
  * @param {object} [opts.logger] Logger surface (`debug`/`warn`/`error`).
124
+ * @param {boolean} [opts.headless] Explicit must-land signal (Story
125
+ * #4427), threaded straight through to `MergeWatcher`. Defaults to
126
+ * `false` (attended-mode behavior unchanged). `lifecycle-emit.js`
127
+ * resolves this from its own `--headless` runtime flag — an explicit
128
+ * input, never an ambient global.
121
129
  *
122
130
  * @returns {Promise<{
123
131
  * ledgerWriter: object,
@@ -140,6 +148,7 @@ export async function buildDefaultListenerChain(opts = {}) {
140
148
  config = null,
141
149
  checkpointer = null,
142
150
  logger = console,
151
+ headless = false,
143
152
  } = opts;
144
153
  if (
145
154
  !bus ||
@@ -288,6 +297,7 @@ export async function buildDefaultListenerChain(opts = {}) {
288
297
  cwd: repoRoot,
289
298
  intervalSeconds: mergeWatchConfig.intervalSeconds,
290
299
  maxBudgetSeconds: mergeWatchConfig.maxBudgetSeconds,
300
+ headless,
291
301
  logger,
292
302
  });
293
303
  mergeWatcher.register();
@@ -306,7 +316,32 @@ export async function buildDefaultListenerChain(opts = {}) {
306
316
  cleaner.register();
307
317
  order.push('Cleaner');
308
318
 
309
- // 8. CheckpointPointerWriterpersists `{ lastCompletedSeqId, phase }`
319
+ // 9. LabelTransitionerflips the Epic ticket to `agent::done` (and
320
+ // closes it as completed, idempotently) on the terminal
321
+ // `epic.complete` event. Requires a truthy `provider`; skip
322
+ // cleanly when the caller omitted one — the same guard pattern as
323
+ // AutomergePredicate. Restores the contract the Cleaner /
324
+ // BranchCleaner / MergeWatcher docstrings have referenced since
325
+ // the original listener was deleted with the epic-runner stratum
326
+ // (#3936): without this registration the flip had NO owner and
327
+ // cleanly-merged Epics stranded at `agent::executing`.
328
+ let labelTransitioner = null;
329
+ if (provider) {
330
+ labelTransitioner = new LabelTransitioner({
331
+ bus,
332
+ epicId,
333
+ provider,
334
+ logger,
335
+ });
336
+ labelTransitioner.register();
337
+ order.push('LabelTransitioner');
338
+ } else {
339
+ logger?.debug?.(
340
+ '[lifecycle] buildDefaultListenerChain: skipping LabelTransitioner (no provider)',
341
+ );
342
+ }
343
+
344
+ // 10. CheckpointPointerWriter — persists `{ lastCompletedSeqId, phase }`
310
345
  // on every `*.end` event.
311
346
  const checkpointPointerWriter = new CheckpointPointerWriter({
312
347
  bus,
@@ -330,6 +365,7 @@ export async function buildDefaultListenerChain(opts = {}) {
330
365
  branchCleaner,
331
366
  mergeWatcher,
332
367
  cleaner,
368
+ labelTransitioner,
333
369
  checkpointPointerWriter,
334
370
  order,
335
371
  };