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
@@ -66,9 +66,12 @@
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';
71
+ import { parsePrNumberFromUrl } from '../../../github-url.js';
70
72
  import * as epicRunStateStore from '../../epic-run-state-store.js';
71
73
  import { findStructuredComment } from '../../ticketing.js';
74
+ import { emitMergeUnlanded } from '../emit-merge-unlanded.js';
72
75
  import { normalizeCheckState, RECOGNIZED_CHECK_STATES } from './watcher.js';
73
76
 
74
77
  /**
@@ -223,19 +226,51 @@ export function probeRequiredChecks({ prUrl, cwd, spawnFn = spawnSync }) {
223
226
  * stdout. We parse stdout first (it is populated even on the non-zero
224
227
  * exit) and classify from the outcomes.
225
228
  *
229
+ * Story #4472 — checks-less repos. In a repo with zero required checks
230
+ * (no branch protection, or protection that requires no status checks),
231
+ * `gh pr checks --required` writes NOTHING to stdout and reports
232
+ * `no checks reported on the <branch> branch` to stderr with a non-zero
233
+ * exit. That is the SAME empty-parsed-set condition the outcomes loop
234
+ * below already treats as green — there is simply nothing to gate on — so
235
+ * we must not conflate it with a genuine probe failure (auth, network, no
236
+ * PR). We detect the `no checks reported` stderr signature and return
237
+ * green, UNLESS the consumer opted into `delivery.ci.requireChecks`, in
238
+ * which case the absent CI gate is a deliberate hard block.
239
+ *
226
240
  * @param {{ status: number, stdout: string, stderr: string }} probe
241
+ * @param {{ requireChecks?: boolean }} [opts] When `requireChecks` is
242
+ * true, a checks-less repo fails closed instead of arming.
227
243
  * @returns {{ ok: boolean, reason: string|null, outcomes: Record<string, string> }}
228
244
  */
229
- export function classifyRequiredChecksProbe(probe) {
245
+ export function classifyRequiredChecksProbe(
246
+ probe,
247
+ { requireChecks = false } = {},
248
+ ) {
230
249
  const stdout = String(probe?.stdout ?? '').trim();
231
- // Empty stdout with a non-zero status → probe genuinely failed (auth,
232
- // network, no PR). Fail closed.
250
+ const stderr = String(probe?.stderr ?? '').trim();
251
+ // Empty stdout: either a checks-less repo (green, nothing to gate on) or
252
+ // a genuine probe failure. The `no checks reported` stderr signature
253
+ // distinguishes them.
233
254
  if (stdout.length === 0) {
255
+ const noChecksReported = /no checks reported/i.test(stderr);
256
+ if (noChecksReported && !requireChecks) {
257
+ // Zero required checks configured — matches the empty-parsed-set
258
+ // "treated as green" branch below. Nothing to gate on.
259
+ return { ok: true, reason: null, outcomes: {} };
260
+ }
261
+ if (noChecksReported && requireChecks) {
262
+ return {
263
+ ok: false,
264
+ reason:
265
+ 'no required checks reported and delivery.ci.requireChecks is set — failing closed per policy',
266
+ outcomes: {},
267
+ };
268
+ }
234
269
  return {
235
270
  ok: false,
236
271
  reason:
237
272
  `live required-check probe failed (status=${probe?.status ?? 'unknown'})` +
238
- (probe?.stderr ? `: ${String(probe.stderr).trim().slice(0, 200)}` : ''),
273
+ (stderr ? `: ${stderr.slice(0, 200)}` : ''),
239
274
  outcomes: {},
240
275
  };
241
276
  }
@@ -441,7 +476,11 @@ function evaluateCodeReviewSignals(codeReview, reasons) {
441
476
  if (codeReviewUnparseable) {
442
477
  return { codeReviewFound, codeReviewUnparseable, severity };
443
478
  }
444
- if (severity.critical > 0) {
479
+ // Route the halt-on-critical decision through the single halting rule of
480
+ // the unified verification-results contract (Story #4411) rather than a
481
+ // re-derived `critical > 0` expression. The unparseable branch above has
482
+ // already returned, so `severity.critical` is a concrete number here.
483
+ if (hasSurvivingCritical(severity)) {
445
484
  pushReason(
446
485
  reasons,
447
486
  REASON_CATEGORY.CRITICAL_REVIEW,
@@ -648,7 +687,11 @@ export async function evaluateAutoMergePredicate({
648
687
  // only IO, but the rule is directory-scoped; sequencing here is a
649
688
  // cheap concession for living inside the listener tree.
650
689
  const state = await readRunStateFn({ provider, epicId });
651
- const codeReview = await findCommentFn(provider, epicId, 'code-review');
690
+ const codeReview = await findCommentFn(
691
+ provider,
692
+ epicId,
693
+ 'verification-results',
694
+ );
652
695
  let retro = await findCommentFn(provider, epicId, 'retro');
653
696
  if (!retro) {
654
697
  retro = await findCommentFn(provider, epicId, 'retro-partial');
@@ -669,8 +712,13 @@ export class AutomergePredicate {
669
712
  * evaluator). Required for the read of run-state + structured
670
713
  * comments.
671
714
  * @param {object} [opts.config] Resolved agent config. Read for the
672
- * `delivery.ci.autoMerge` policy via `getCiDelivery`. Defaults to the
673
- * framework default (`trust-ci`) when omitted.
715
+ * `delivery.ci.autoMerge` policy and the `delivery.ci.requireChecks`
716
+ * fail-closed-without-checks policy via `getCiDelivery`. Defaults to the
717
+ * framework defaults (`trust-ci` / `requireChecks: false`) when omitted.
718
+ * @param {boolean} [opts.headless] When true (a `/deliver --yes` run), a
719
+ * predicate refusal escalates to an explicit `merge.unlanded` +
720
+ * `epic.blocked` terminal instead of silently parking on the
721
+ * operator-merges path (Story #4472). Defaults to `false` (attended).
674
722
  * @param {string} [opts.cwd] Working directory for the live
675
723
  * `gh pr checks --required` probe. Defaults to `process.cwd()`.
676
724
  * @param {Function} [opts.evaluatePredicateFn] override of
@@ -699,13 +747,20 @@ export class AutomergePredicate {
699
747
  this.epicId = opts.epicId;
700
748
  this.provider = opts.provider;
701
749
  this.cwd = opts.cwd ?? process.cwd();
702
- // Resolve the merge posture once at construction. `getCiDelivery`
703
- // applies the framework default (`trust-ci`) for any omitted field.
704
- this.policy = getCiDelivery(opts.config ?? null).autoMerge;
750
+ // Resolve the merge posture + fail-closed policy once at construction.
751
+ // `getCiDelivery` applies the framework defaults (`trust-ci` /
752
+ // `requireChecks: false`) for any omitted field.
753
+ const ci = getCiDelivery(opts.config ?? null);
754
+ this.policy = ci.autoMerge;
755
+ this.requireChecks = ci.requireChecks;
756
+ this.headless = opts.headless === true;
705
757
  this.evaluatePredicateFn =
706
758
  opts.evaluatePredicateFn ?? evaluateAutoMergePredicate;
707
759
  this.probeRequiredChecksFn =
708
760
  opts.probeRequiredChecksFn ?? probeRequiredChecks;
761
+ // Injected for tests so the headless terminal escalation can be
762
+ // observed without touching disk.
763
+ this.emitMergeUnlandedFn = opts.emitMergeUnlandedFn ?? emitMergeUnlanded;
709
764
  this.logger = opts.logger ?? console;
710
765
  /** @type {Set<string>} `${event}:${seqId}` idempotency cache. */
711
766
  this._seen = new Set();
@@ -777,7 +832,9 @@ export class AutomergePredicate {
777
832
  let probeVerdict;
778
833
  try {
779
834
  const probe = this.probeRequiredChecksFn({ prUrl, cwd: this.cwd });
780
- probeVerdict = classifyRequiredChecksProbe(probe);
835
+ probeVerdict = classifyRequiredChecksProbe(probe, {
836
+ requireChecks: this.requireChecks,
837
+ });
781
838
  } catch (err) {
782
839
  probeVerdict = {
783
840
  ok: false,
@@ -869,6 +926,17 @@ export class AutomergePredicate {
869
926
  * Emit `epic.merge.blocked`. Helper carved out so the blocking paths
870
927
  * (CI failure / predicate dirty / evaluator throw) share the same emit
871
928
  * shape.
929
+ *
930
+ * Story #4472 — must-land coverage of predicate refusal. In a headless
931
+ * (`/deliver --yes`) run there is no operator to act on a bare
932
+ * `epic.merge.blocked` (nothing in the listener chain consumes it), so
933
+ * the run would silently park on the operator-merges path. When
934
+ * `this.headless`, we additionally attribute the refusal to the
935
+ * lifecycle ledger via `merge.unlanded` (blockClass `predicate-refused`)
936
+ * and drive the explicit `epic.blocked` terminal — the same
937
+ * escalation the MergeWatcher performs on post-arm budget exhaustion —
938
+ * so the Epic transitions to `agent::blocked` with an operator-visible
939
+ * reason instead of stalling.
872
940
  */
873
941
  async _emitBlocked(prUrl, reason) {
874
942
  try {
@@ -878,6 +946,35 @@ export class AutomergePredicate {
878
946
  `[AutomergePredicate] epic.merge.blocked emit failed (swallowed): ${err?.message ?? err}`,
879
947
  );
880
948
  }
949
+ if (!this.headless) return;
950
+ // Ledger attribution — best-effort; a failed append must NOT mask the
951
+ // epic.blocked transition below.
952
+ try {
953
+ const prNumber = parsePrNumberFromUrl(prUrl);
954
+ if (Number.isInteger(prNumber) && prNumber > 0) {
955
+ this.emitMergeUnlandedFn({
956
+ scope: 'epic',
957
+ ticketId: this.epicId,
958
+ prNumber,
959
+ blockClass: 'predicate-refused',
960
+ reason,
961
+ elapsedSeconds: 0,
962
+ });
963
+ }
964
+ } catch (err) {
965
+ this.logger.warn?.(
966
+ `[AutomergePredicate] emitMergeUnlanded failed (swallowed): ${err?.message ?? err}`,
967
+ );
968
+ }
969
+ try {
970
+ await this.bus.emit('epic.blocked', {
971
+ reason: `merge-predicate:refused`,
972
+ });
973
+ } catch (err) {
974
+ this.logger.warn?.(
975
+ `[AutomergePredicate] epic.blocked emit on predicate refusal failed (swallowed): ${err?.message ?? err}`,
976
+ );
977
+ }
881
978
  }
882
979
 
883
980
  reset() {
@@ -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.confirmed → epic.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 ||
@@ -217,10 +226,15 @@ export async function buildDefaultListenerChain(opts = {}) {
217
226
  order.push('Finalizer');
218
227
 
219
228
  // 4. AutomergeArmer — arms `gh pr merge --auto --squash --delete-branch`
220
- // on epic.merge.ready.
229
+ // on epic.merge.ready. Story #4472: `headless` gates the direct-merge
230
+ // fallback's terminal escalation (a genuine arm failure emits
231
+ // `merge.unlanded` + `epic.blocked` in a `--yes` run instead of
232
+ // returning silently); `epicId` scopes the `merge.unlanded` ledger row.
221
233
  const automergeArmer = new AutomergeArmer({
222
234
  bus,
235
+ epicId,
223
236
  cwd: repoRoot,
237
+ headless,
224
238
  logger,
225
239
  });
226
240
  automergeArmer.register();
@@ -241,6 +255,7 @@ export async function buildDefaultListenerChain(opts = {}) {
241
255
  provider,
242
256
  config,
243
257
  cwd: repoRoot,
258
+ headless,
244
259
  logger,
245
260
  });
246
261
  automergePredicate.register();
@@ -288,6 +303,7 @@ export async function buildDefaultListenerChain(opts = {}) {
288
303
  cwd: repoRoot,
289
304
  intervalSeconds: mergeWatchConfig.intervalSeconds,
290
305
  maxBudgetSeconds: mergeWatchConfig.maxBudgetSeconds,
306
+ headless,
291
307
  logger,
292
308
  });
293
309
  mergeWatcher.register();
@@ -306,7 +322,32 @@ export async function buildDefaultListenerChain(opts = {}) {
306
322
  cleaner.register();
307
323
  order.push('Cleaner');
308
324
 
309
- // 8. CheckpointPointerWriter — persists `{ lastCompletedSeqId, phase }`
325
+ // 9. LabelTransitioner — flips the Epic ticket to `agent::done` (and
326
+ // closes it as completed, idempotently) on the terminal
327
+ // `epic.complete` event. Requires a truthy `provider`; skip
328
+ // cleanly when the caller omitted one — the same guard pattern as
329
+ // AutomergePredicate. Restores the contract the Cleaner /
330
+ // BranchCleaner / MergeWatcher docstrings have referenced since
331
+ // the original listener was deleted with the epic-runner stratum
332
+ // (#3936): without this registration the flip had NO owner and
333
+ // cleanly-merged Epics stranded at `agent::executing`.
334
+ let labelTransitioner = null;
335
+ if (provider) {
336
+ labelTransitioner = new LabelTransitioner({
337
+ bus,
338
+ epicId,
339
+ provider,
340
+ logger,
341
+ });
342
+ labelTransitioner.register();
343
+ order.push('LabelTransitioner');
344
+ } else {
345
+ logger?.debug?.(
346
+ '[lifecycle] buildDefaultListenerChain: skipping LabelTransitioner (no provider)',
347
+ );
348
+ }
349
+
350
+ // 10. CheckpointPointerWriter — persists `{ lastCompletedSeqId, phase }`
310
351
  // on every `*.end` event.
311
352
  const checkpointPointerWriter = new CheckpointPointerWriter({
312
353
  bus,
@@ -330,6 +371,7 @@ export async function buildDefaultListenerChain(opts = {}) {
330
371
  branchCleaner,
331
372
  mergeWatcher,
332
373
  cleaner,
374
+ labelTransitioner,
333
375
  checkpointPointerWriter,
334
376
  order,
335
377
  };
@@ -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
+ }