mandrel 2.24.0 → 2.26.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 (189) hide show
  1. package/.agents/agents/acceptance-critic.md +19 -21
  2. package/.agents/agents/auditor.md +16 -19
  3. package/.agents/agents/story-worker.md +41 -54
  4. package/.agents/audit-checklists/architecture.md +5 -1
  5. package/.agents/audit-checklists/baselines.md +21 -0
  6. package/.agents/audit-checklists/quality.md +3 -0
  7. package/.agents/docs/agentrc-reference.json +2 -1
  8. package/.agents/docs/configuration.md +2 -1
  9. package/.agents/docs/quality-gates.md +80 -18
  10. package/.agents/docs/workflows.md +4 -2
  11. package/.agents/instructions.md +1 -1
  12. package/.agents/rules/ci-remediation.md +68 -3
  13. package/.agents/schemas/agentrc.schema.json +6 -1
  14. package/.agents/schemas/audit-rules.json +15 -0
  15. package/.agents/schemas/baselines/audit-baselines-envelope.schema.json +242 -0
  16. package/.agents/schemas/baselines/baseline-envelope.schema.json +8 -0
  17. package/.agents/schemas/baselines/crap.schema.json +13 -1
  18. package/.agents/schemas/crap-report.schema.json +37 -0
  19. package/.agents/schemas/model-attribution.schema.json +4 -0
  20. package/.agents/scripts/acceptance-eval.js +124 -15
  21. package/.agents/scripts/audit-baselines.js +136 -0
  22. package/.agents/scripts/audit-labels-bootstrap.js +16 -95
  23. package/.agents/scripts/audit-to-stories.js +33 -3
  24. package/.agents/scripts/check-arch-cycles.js +12 -93
  25. package/.agents/scripts/check-baseline-drift.js +16 -3
  26. package/.agents/scripts/check-baselines.js +19 -3
  27. package/.agents/scripts/check-context-budget.js +95 -14
  28. package/.agents/scripts/check-cyclomatic.js +214 -0
  29. package/.agents/scripts/check-schema-references.js +392 -0
  30. package/.agents/scripts/check-test-temp-hygiene.js +121 -1
  31. package/.agents/scripts/check-workflow-timeouts.js +291 -0
  32. package/.agents/scripts/cleanup-repo-test-temp.js +7 -5
  33. package/.agents/scripts/deliver-light.js +3 -4
  34. package/.agents/scripts/deliver-recover.js +13 -0
  35. package/.agents/scripts/diagnose-friction.js +85 -19
  36. package/.agents/scripts/lib/audit-baselines/engine.js +177 -0
  37. package/.agents/scripts/lib/audit-baselines/gate-surface.js +63 -0
  38. package/.agents/scripts/lib/audit-baselines/headroom.js +72 -0
  39. package/.agents/scripts/lib/audit-baselines/hotspots.js +69 -0
  40. package/.agents/scripts/lib/audit-baselines/kinds.js +313 -0
  41. package/.agents/scripts/lib/audit-baselines/outliers.js +100 -0
  42. package/.agents/scripts/lib/audit-baselines/read.js +87 -0
  43. package/.agents/scripts/lib/audit-baselines/staleness.js +123 -0
  44. package/.agents/scripts/lib/audit-baselines/surface-entry.js +106 -0
  45. package/.agents/scripts/lib/audit-baselines/trend.js +125 -0
  46. package/.agents/scripts/lib/audit-baselines/weights.js +193 -0
  47. package/.agents/scripts/lib/audit-suite/index.js +0 -5
  48. package/.agents/scripts/lib/audit-suite/selector.js +9 -62
  49. package/.agents/scripts/lib/audit-to-stories/audit-label-taxonomy.js +177 -0
  50. package/.agents/scripts/lib/audit-to-stories/audit-lenses.js +1 -0
  51. package/.agents/scripts/lib/audit-to-stories/build-story-body.js +35 -3
  52. package/.agents/scripts/lib/audit-to-stories/group-findings.js +20 -11
  53. package/.agents/scripts/lib/audit-to-stories/parse-audit-md.js +23 -9
  54. package/.agents/scripts/lib/audit-to-stories/seed-from-findings.js +9 -2
  55. package/.agents/scripts/lib/baseline-loader.js +0 -0
  56. package/.agents/scripts/lib/baseline-schema-registry.js +13 -1
  57. package/.agents/scripts/lib/baselines/diff-scope-cli.js +22 -160
  58. package/.agents/scripts/lib/baselines/duplication-scanner.js +27 -0
  59. package/.agents/scripts/lib/baselines/git-base.js +0 -0
  60. package/.agents/scripts/lib/baselines/kinds/crap.js +499 -57
  61. package/.agents/scripts/lib/baselines/preview-gates.js +58 -15
  62. package/.agents/scripts/lib/baselines/reader.js +10 -0
  63. package/.agents/scripts/lib/baselines/refresh-service.js +69 -11
  64. package/.agents/scripts/lib/baselines/scope.js +39 -90
  65. package/.agents/scripts/lib/baselines/writer.js +16 -7
  66. package/.agents/scripts/lib/changed-files.js +8 -1
  67. package/.agents/scripts/lib/cli-args.js +115 -1
  68. package/.agents/scripts/lib/close-validation/runner.js +70 -25
  69. package/.agents/scripts/lib/config-settings-schema-delivery.js +5 -0
  70. package/.agents/scripts/lib/crap-engine.js +124 -27
  71. package/.agents/scripts/lib/crap-method-identity.js +153 -0
  72. package/.agents/scripts/lib/crap-utils.js +86 -13
  73. package/.agents/scripts/lib/cyclomatic-ceiling.js +265 -0
  74. package/.agents/scripts/lib/env-loader.js +46 -16
  75. package/.agents/scripts/lib/feedback-loop/audit-results-graduator.js +0 -2
  76. package/.agents/scripts/lib/feedback-loop/prior-feedback-fetcher.js +0 -2
  77. package/.agents/scripts/lib/feedback-loop/retro-proposals-graduator.js +0 -2
  78. package/.agents/scripts/lib/findings/route-finding.js +123 -17
  79. package/.agents/scripts/lib/findings/severity.js +80 -2
  80. package/.agents/scripts/lib/git/cached-fetch.js +0 -0
  81. package/.agents/scripts/lib/git-utils.js +136 -80
  82. package/.agents/scripts/lib/import-graph.js +156 -0
  83. package/.agents/scripts/lib/label-constants.js +17 -0
  84. package/.agents/scripts/lib/observability/runtime-friction.js +17 -2
  85. package/.agents/scripts/lib/observability/source-classifier.js +175 -2
  86. package/.agents/scripts/lib/orchestration/acceptance-eval-decision.js +110 -17
  87. package/.agents/scripts/lib/orchestration/ceremony-routing.js +17 -12
  88. package/.agents/scripts/lib/orchestration/check-baselines/phases/compare.js +36 -6
  89. package/.agents/scripts/lib/orchestration/check-baselines/phases/evaluate.js +5 -0
  90. package/.agents/scripts/lib/orchestration/check-baselines/phases/floors.js +12 -1
  91. package/.agents/scripts/lib/orchestration/check-baselines/phases/report.js +8 -1
  92. package/.agents/scripts/lib/orchestration/ci-rerun-guard.js +548 -0
  93. package/.agents/scripts/lib/orchestration/deliver-recover.js +2 -2
  94. package/.agents/scripts/lib/orchestration/git-cleanup/phases/phase-drivers.js +10 -5
  95. package/.agents/scripts/lib/orchestration/git-cleanup/phases/render.js +39 -3
  96. package/.agents/scripts/lib/orchestration/lifecycle/listeners/watcher.js +71 -20
  97. package/.agents/scripts/lib/orchestration/light-backstop.js +20 -3
  98. package/.agents/scripts/lib/orchestration/light-escalation.js +82 -3
  99. package/.agents/scripts/lib/orchestration/light-suitability.js +103 -5
  100. package/.agents/scripts/lib/orchestration/plan-context.js +119 -66
  101. package/.agents/scripts/lib/orchestration/plan-persist/fan-out-gate.js +31 -5
  102. package/.agents/scripts/lib/orchestration/plan-persist/run-plan-persist.js +216 -109
  103. package/.agents/scripts/lib/orchestration/plan-persist/story-ops.js +61 -13
  104. package/.agents/scripts/lib/orchestration/plan-persist/supersede-ops.js +79 -22
  105. package/.agents/scripts/lib/orchestration/plan-text-hygiene.js +51 -20
  106. package/.agents/scripts/lib/orchestration/planning/authoring-context.js +70 -74
  107. package/.agents/scripts/lib/orchestration/planning/memory-pool-advisory.js +231 -0
  108. package/.agents/scripts/lib/orchestration/resolve-stories.js +18 -17
  109. package/.agents/scripts/lib/orchestration/retro-proposals.js +0 -0
  110. package/.agents/scripts/lib/orchestration/run-epilogue.js +12 -0
  111. package/.agents/scripts/lib/orchestration/run-scoped-config.js +276 -0
  112. package/.agents/scripts/lib/orchestration/single-story-close/phases/base-sync.js +41 -10
  113. package/.agents/scripts/lib/orchestration/single-story-close/phases/confirm-merge.js +125 -37
  114. package/.agents/scripts/lib/orchestration/single-story-close/phases/normalize-pr-title.js +6 -6
  115. package/.agents/scripts/lib/orchestration/single-story-close/phases/options.js +42 -38
  116. package/.agents/scripts/lib/orchestration/single-story-close/phases/pull-request.js +129 -21
  117. package/.agents/scripts/lib/orchestration/single-story-close/phases/push.js +6 -1
  118. package/.agents/scripts/lib/orchestration/single-story-close/runner.js +267 -125
  119. package/.agents/scripts/lib/orchestration/spec-budget.js +16 -5
  120. package/.agents/scripts/lib/orchestration/story-follow-ups.js +182 -95
  121. package/.agents/scripts/lib/orchestration/ticket-validator-conflicts.js +22 -0
  122. package/.agents/scripts/lib/orchestration/ticket-validator.js +5 -11
  123. package/.agents/scripts/lib/orchestration/ticketing/reads.js +4 -4
  124. package/.agents/scripts/lib/reserved-test-ids.js +77 -0
  125. package/.agents/scripts/lib/story-adjacency.js +3 -3
  126. package/.agents/scripts/lib/story-plan.js +137 -42
  127. package/.agents/scripts/lib/templates/decomposer-prompts.js +4 -4
  128. package/.agents/scripts/lib/test-runner-contract.js +134 -0
  129. package/.agents/scripts/lib/test-tiers.js +11 -2
  130. package/.agents/scripts/lib/ticket-body-sections.js +1 -1
  131. package/.agents/scripts/lib/util/concurrent-map.js +17 -0
  132. package/.agents/scripts/lib/util/parse-id-list.js +103 -0
  133. package/.agents/scripts/lib/util/poll-loop.js +18 -2
  134. package/.agents/scripts/lib/wave-runner/live-probe.js +29 -13
  135. package/.agents/scripts/lib/wave-runner/ready-set.js +254 -43
  136. package/.agents/scripts/lib/workers/combined-mi-crap-worker.js +10 -13
  137. package/.agents/scripts/lib/workers/crap-worker.js +8 -13
  138. package/.agents/scripts/lib/workers/maintainability-report-worker.js +4 -10
  139. package/.agents/scripts/lib/workers/maintainability-worker.js +4 -10
  140. package/.agents/scripts/lib/workers/serve-worker-messages.js +35 -0
  141. package/.agents/scripts/lib/worktree/git-hooks.js +206 -0
  142. package/.agents/scripts/lib/worktree/lifecycle/creation.js +6 -0
  143. package/.agents/scripts/lib/worktree-manager.js +14 -0
  144. package/.agents/scripts/plan-run-epilogue.js +17 -5
  145. package/.agents/scripts/pr-watch-with-update.js +568 -213
  146. package/.agents/scripts/providers/github/tickets.js +33 -10
  147. package/.agents/scripts/provision-git-hooks.js +85 -0
  148. package/.agents/scripts/quality-preview.js +137 -28
  149. package/.agents/scripts/resolve-stories.js +4 -1
  150. package/.agents/scripts/run-coverage.js +86 -35
  151. package/.agents/scripts/run-lint.js +20 -0
  152. package/.agents/scripts/run-tests.js +36 -36
  153. package/.agents/scripts/single-story-close.js +34 -2
  154. package/.agents/scripts/single-story-confirm-merge.js +22 -6
  155. package/.agents/scripts/single-story-init.js +7 -0
  156. package/.agents/scripts/stories-wave-tick.js +308 -47
  157. package/.agents/scripts/story-plan.js +65 -9
  158. package/.agents/scripts/update-coverage-baseline.js +34 -4
  159. package/.agents/scripts/update-crap-baseline.js +42 -4
  160. package/.agents/scripts/update-duplication-baseline.js +209 -83
  161. package/.agents/scripts/update-maintainability-baseline.js +31 -4
  162. package/.agents/scripts/validate-docs-freshness.js +1 -0
  163. package/.agents/skills/core/diagnose-friction/SKILL.md +4 -1
  164. package/.agents/skills/core/gates-and-baselines/SKILL.md +17 -11
  165. package/.agents/skills/skills.index.json +2 -2
  166. package/.agents/templates/single-story-body.md +16 -8
  167. package/.agents/workflows/audit-architecture.md +69 -13
  168. package/.agents/workflows/audit-baselines.md +289 -0
  169. package/.agents/workflows/audit-navigability.md +5 -4
  170. package/.agents/workflows/audit-quality.md +26 -0
  171. package/.agents/workflows/audit-to-stories.md +30 -4
  172. package/.agents/workflows/deliver.md +92 -97
  173. package/.agents/workflows/helpers/acceptance-self-eval.md +47 -10
  174. package/.agents/workflows/helpers/audit-lens-core.md +25 -4
  175. package/.agents/workflows/helpers/code-quality-guardrails.md +9 -2
  176. package/.agents/workflows/helpers/deliver-digest.md +41 -21
  177. package/.agents/workflows/helpers/deliver-reference.md +95 -4
  178. package/.agents/workflows/helpers/deliver-story-reference.md +174 -52
  179. package/.agents/workflows/helpers/deliver-story.md +104 -119
  180. package/.agents/workflows/helpers/plan-reference.md +49 -5
  181. package/.agents/workflows/memory-consolidate.md +116 -0
  182. package/.agents/workflows/plan.md +62 -75
  183. package/README.md +13 -6
  184. package/docs/CHANGELOG.md +93 -0
  185. package/lib/cli/update.js +14 -11
  186. package/lib/cli/version-check.js +9 -1
  187. package/package.json +9 -4
  188. package/.agents/schemas/friction-event.schema.json +0 -56
  189. package/.agents/scripts/lib/feedback-loop/memory-freshness.js +0 -707
@@ -12,15 +12,16 @@
12
12
  * created; this is a direct, synchronous watch with a real exit code.
13
13
  *
14
14
  * Slow-vs-failed semantics (Story #4358):
15
- * - GREEN — every required check terminal + green → exit 0.
15
+ * - GREEN — every required check terminal + green → exit 0, unless the
16
+ * no-rerun guard says otherwise (below).
16
17
  * - RED — one or more required checks genuinely failed → exit 1
17
18
  * IMMEDIATELY, consuming no resume budget. On red the CLI
18
- * writes `temp/story-<id>-ci-digest.{json,md}` (failing check
19
- * name, run id, a `gh run view --log-failed` tail, and a
20
- * coarse classification) and prints the red-green
21
- * remediation handoff. The digest is scoped by filename, so
22
- * it requires `--story` (Story #4539: the digest was
23
- * Epic-scoped and therefore never written on the only
19
+ * disarms native auto-merge and writes
20
+ * `temp/story-<id>-ci-digest.{json,md}` (failing check name,
21
+ * head SHA, run id + run link, a `gh run view --log-failed`
22
+ * tail, and a coarse classification). The digest is scoped by
23
+ * filename, so it requires `--story` (Story #4539: the digest
24
+ * was Epic-scoped and therefore never written on the only
24
25
  * delivery path v2 has).
25
26
  * - STILL-RUNNING — the poll cap fired with checks still pending and
26
27
  * none failed; the watcher re-armed up to
@@ -28,27 +29,104 @@
28
29
  * `still-running` verdict → exit 2 (NEVER 1, NEVER
29
30
  * `timed_out`). The CLI prints the `gh pr checks --watch`
30
31
  * handoff so the host can keep polling on its own cadence.
32
+ * - UNRESOLVED — every observed required check is green but the observed
33
+ * set does not reconcile with the repository's own verdict
34
+ * (Story #4873) → exit 2, same "keep watching" semantics as
35
+ * still-running. Withholding is the point: this watcher has
36
+ * reported green on a PR GitHub was reporting as BLOCKED.
37
+ * - NOT-YET-STARTED — the attach window was spent and NO required context
38
+ * ever attached, while the pull request itself kept reading back
39
+ * fine (Story #4890) → exit 2, the same "keep watching"
40
+ * semantics. This used to map onto the red exit code, which
41
+ * misroutes the caller twice over: the module reserves exit 1 for
42
+ * a required check that GENUINELY FAILED, and the red path writes
43
+ * a CI digest naming the failing check — so a caller routed onto
44
+ * it by a set that was merely empty found nothing to read. A
45
+ * still-empty required set is a slow condition, and the module
46
+ * already models slow as exit 2. A `gh` fault the PR probe cannot
47
+ * see past is still exit 1.
48
+ *
49
+ * Two GitHub oracles are deliberately not trusted on a single reading
50
+ * (Story #4873). An EMPTY `gh pr checks --required` probe is re-resolved
51
+ * within {@link REQUIRED_CONTEXT_ATTACH_WINDOW_MS} before it is believed —
52
+ * a ruleset attaches its required contexts asynchronously, and a required
53
+ * context that is an aggregator job gated on every other tier is by
54
+ * construction the LAST to appear (#4890 measured 16m52s), so a watch launched
55
+ * right after `gh pr create` used to fail a delivery whose CI had not been
56
+ * asked to start yet. And a GREEN verdict is issued only after
57
+ * {@link reconcileGreenVerdict} confirms the repository agrees; an
58
+ * unreconcilable set reports unresolved instead of green.
59
+ *
60
+ * No-rerun enforcement (Story #4865). `rules/ci-remediation.md` § Verifier
61
+ * forbids re-running a failed job to reach green; this CLI is the point
62
+ * that enforces it. Enforcement acts on the **first red** — GitHub's native
63
+ * auto-merge fires server-side and races any post-green detection, so a
64
+ * green can already have merged by the time it is observed. On red the
65
+ * watcher disarms auto-merge and records the head SHA; on green it reads
66
+ * the digest and adjudicates: a green on the SAME head SHA is a forbidden
67
+ * re-run (exit 1, `agent::blocked`, `meta::framework-gap` required), while
68
+ * a green on a NEW head SHA is a fix at source — the digest is retired,
69
+ * auto-merge is re-armed, and the delivery proceeds. A delivery that never
70
+ * went red has no digest and is untouched. Mechanism:
71
+ * `lib/orchestration/ci-rerun-guard.js`.
31
72
  *
32
73
  * Config (Story #4356 namespace, read via `getCiDelivery`):
33
74
  * - `delivery.ci.watch.pollIntervalMs`
34
75
  * - `delivery.ci.watch.maxPolls`
35
76
  * - `delivery.ci.watch.maxResumes`
77
+ * - `delivery.ci.watch.attachWindowMs` (Story #4890)
36
78
  * CLI flags override config; config overrides the framework fallback.
37
79
  *
38
80
  * Usage:
39
81
  * node .agents/scripts/pr-watch-with-update.js --pr <n> --story <id>
40
82
  * [--repo owner/repo] [--max-updates N] [--poll-interval-ms MS]
41
- * [--max-polls N] [--max-resumes N]
83
+ * [--max-polls N] [--max-resumes N] [--attach-window-ms MS]
42
84
  */
43
- import { spawnSync } from 'node:child_process';
44
- import { mkdirSync, writeFileSync } from 'node:fs';
45
- import path from 'node:path';
46
85
  import { parseArgs } from 'node:util';
47
86
  import { runAsCli } from './lib/cli-utils.js';
48
87
  import { getCiDelivery } from './lib/config/ci.js';
49
88
  import { resolveConfig } from './lib/config-resolver.js';
89
+ import { gh as defaultGh } from './lib/gh-exec.js';
50
90
  import { Logger } from './lib/Logger.js';
91
+ import {
92
+ blockStoryDelivery,
93
+ classifyFailure,
94
+ classifyGreenVerdict,
95
+ disarmAutoMerge,
96
+ formatRerunViolation,
97
+ readCiDigest,
98
+ resolveDigestScope,
99
+ resolvePrHeadSha,
100
+ retireCiDigest,
101
+ writeCiDigest,
102
+ } from './lib/orchestration/ci-rerun-guard.js';
51
103
  import { watchPrToTerminal } from './lib/orchestration/lifecycle/listeners/watcher.js';
104
+ import { enableAutoMergeWith } from './lib/orchestration/single-story-close/phases/auto-merge.js';
105
+ import { sleep as defaultSleep } from './lib/util/poll-loop.js';
106
+
107
+ /** Exit code reserved for the slow-but-not-red `still-running` verdict. */
108
+ export const STILL_RUNNING_EXIT_CODE = 2;
109
+
110
+ /**
111
+ * How long a probe that resolved NO required contexts keeps being retried
112
+ * before the watch stops waiting for one (Stories #4873, #4890).
113
+ *
114
+ * A repository ruleset attaches its required contexts to a pull request
115
+ * asynchronously, and the arrival latency is set by the SLOWEST context in the
116
+ * set. Story #4873 measured tens of seconds on a cold repo and calibrated the
117
+ * window at 90s; #4890 measured **16m52s** on this repository, because its
118
+ * required context is an aggregator job gated on every other tier and is
119
+ * therefore, by construction, the last check to appear. A 90s window still
120
+ * exhausted, so the watch still aborted on a PR whose CI was working exactly
121
+ * as designed.
122
+ *
123
+ * The default therefore covers a late aggregator with margin rather than a
124
+ * fast ruleset, and it is operator-tunable on the `delivery.ci.watch.*` ladder
125
+ * (`attachWindowMs`) for a repository whose contexts arrive on a different
126
+ * cadence. Spending the window costs nothing but wall-clock on a PR nobody
127
+ * could merge yet; exhausting it too early costs the whole delivery.
128
+ */
129
+ export const REQUIRED_CONTEXT_ATTACH_WINDOW_MS = 1_200_000;
52
130
 
53
131
  /** Framework fallbacks when neither a CLI flag nor config supplies a value. */
54
132
  export const WATCH_DEFAULTS = Object.freeze({
@@ -56,10 +134,161 @@ export const WATCH_DEFAULTS = Object.freeze({
56
134
  maxPolls: 180,
57
135
  maxUpdates: 3,
58
136
  maxResumes: 3,
137
+ attachWindowMs: REQUIRED_CONTEXT_ATTACH_WINDOW_MS,
59
138
  });
60
139
 
61
- /** Exit code reserved for the slow-but-not-red `still-running` verdict. */
62
- export const STILL_RUNNING_EXIT_CODE = 2;
140
+ /**
141
+ * Merge-state values that reconcile an observed all-green required set with
142
+ * the repository's own view of the pull request (Story #4873).
143
+ *
144
+ * `BLOCKED` is the measured false-green: the watcher's `gh pr checks
145
+ * --required` set came back SMALLER than branch protection's — a context
146
+ * attached after the first probe, so it was never in `requiredChecks` — every
147
+ * check the watcher knew about was green, and it reported green while GitHub
148
+ * still refused the merge. `UNKNOWN` / an unreadable probe is not a
149
+ * reconciliation either: it is the absence of the second opinion, and a green
150
+ * verdict is exactly the verdict that must not be issued on absent evidence.
151
+ */
152
+ const RECONCILED_MERGE_STATES = Object.freeze(
153
+ new Set(['CLEAN', 'UNSTABLE', 'HAS_HOOKS', 'BEHIND', 'DRAFT']),
154
+ );
155
+
156
+ /**
157
+ * Does an observed-green required set reconcile with the repository's own
158
+ * verdict on the PR? Pure — exported so the rule is reviewable as code.
159
+ *
160
+ * @param {{ observedRequired?: string[], mergeStateStatus?: string|null }} args
161
+ * @returns {{ reconciled: boolean, mergeStateStatus: string|null, reason: string }}
162
+ */
163
+ export function reconcileGreenVerdict({
164
+ observedRequired = [],
165
+ mergeStateStatus,
166
+ } = {}) {
167
+ const state = String(mergeStateStatus ?? '')
168
+ .trim()
169
+ .toUpperCase();
170
+ const observed = observedRequired.length;
171
+ if (!state) {
172
+ return {
173
+ reconciled: false,
174
+ mergeStateStatus: null,
175
+ reason:
176
+ `observed ${observed} required check(s) green, but the repository's merge state ` +
177
+ 'could not be read — the observed set cannot be reconciled, so the green verdict is withheld',
178
+ };
179
+ }
180
+ if (RECONCILED_MERGE_STATES.has(state)) {
181
+ return {
182
+ reconciled: true,
183
+ mergeStateStatus: state,
184
+ reason: `observed ${observed} required check(s) green and the repository reports mergeStateStatus=${state}`,
185
+ };
186
+ }
187
+ return {
188
+ reconciled: false,
189
+ mergeStateStatus: state,
190
+ reason:
191
+ `observed ${observed} required check(s) green, but the repository reports ` +
192
+ `mergeStateStatus=${state} — branch protection is enforcing a context this watch did not observe`,
193
+ };
194
+ }
195
+
196
+ /** Default merge-state probe: one `gh pr view --json mergeStateStatus`. */
197
+ async function defaultMergeStateProbe({ prRef }) {
198
+ try {
199
+ const view = await defaultGh.pr.view(prRef, ['mergeStateStatus']);
200
+ return typeof view?.mergeStateStatus === 'string'
201
+ ? view.mergeStateStatus
202
+ : null;
203
+ } catch {
204
+ return null;
205
+ }
206
+ }
207
+
208
+ /**
209
+ * Adapt the watch loop's own `ghPrViewFn` port — which already spawns
210
+ * `gh pr view --json mergeStateStatus` for BEHIND recovery — into the
211
+ * reconciliation probe, so a caller that injected one port does not have to
212
+ * inject a second for the same `gh` call.
213
+ */
214
+ function mergeStateProbeFromView(ghPrViewFn) {
215
+ return async ({ prUrl, repo, cwd }) => {
216
+ try {
217
+ const view = await ghPrViewFn({ prUrl, repo, cwd });
218
+ if (view?.status !== 0) return null;
219
+ const parsed = JSON.parse(String(view.stdout ?? '').trim());
220
+ return typeof parsed?.mergeStateStatus === 'string'
221
+ ? parsed.mergeStateStatus
222
+ : null;
223
+ } catch {
224
+ return null;
225
+ }
226
+ };
227
+ }
228
+
229
+ /**
230
+ * Run the watch, re-resolving a required-check set that came back EMPTY until
231
+ * the attach window is spent (Stories #4873 AC-3, #4890 AC-1). Every other
232
+ * terminal — green, red, still-running — returns on the first arm exactly as
233
+ * before.
234
+ *
235
+ * Re-arming the whole call is what makes convergence possible: the required
236
+ * check NAMES are resolved once per `watchPrToTerminal` call, so a context that
237
+ * attaches minutes later is only ever seen by a fresh call.
238
+ *
239
+ * `probePrResolvable` is the **structural** classifier, re-read every round: a
240
+ * required set that is empty while the pull request itself reads back fine is
241
+ * CI that has not started, so the window is worth spending; a pull request that
242
+ * does not read back at all is a `gh` fault, so the window is not spent on it
243
+ * and the caller reports the failure immediately. `gh` overloads its exit code
244
+ * across both conditions, and this deliberately does not fall back to matching
245
+ * its stderr prose — a human-readable string is not a classification contract.
246
+ *
247
+ * @returns {Promise<object>} the watch result plus `attachRetries`, and
248
+ * `prResolvable` whenever the required set stayed empty.
249
+ */
250
+ async function watchWithAttachWindow({
251
+ watchArgs,
252
+ attachWindowMs,
253
+ retryIntervalMs,
254
+ sleepFn,
255
+ nowMsFn,
256
+ probePrResolvable,
257
+ logger,
258
+ }) {
259
+ const deadline = nowMsFn() + attachWindowMs;
260
+ // The window is wall-clock, but the retry count is also capped: a caller
261
+ // running with a zero poll interval (every unit test, and a config that
262
+ // sets one) would otherwise spin the window out as a tight loop. Flooring
263
+ // the assumed cadence at 5s bounds the attempts without changing the
264
+ // wall-clock bound that governs a real watch.
265
+ const maxRetries = Math.ceil(
266
+ attachWindowMs / Math.max(retryIntervalMs, 5000),
267
+ );
268
+ let result = await watchPrToTerminal(watchArgs);
269
+ let retries = 0;
270
+ let prResolvable;
271
+ while (result.requiredChecksEmpty) {
272
+ prResolvable = await probePrResolvable();
273
+ if (!prResolvable) break;
274
+ if (retries >= maxRetries || nowMsFn() >= deadline) break;
275
+ retries += 1;
276
+ logger?.warn?.(
277
+ `[pr-watch] no required context has attached yet (${result.error}) — the pull request reads ` +
278
+ 'back fine, so this is CI that has not started; re-resolving the required set within the ' +
279
+ `${Math.round(attachWindowMs / 1000)}s attach window (attempt ${retries}).`,
280
+ );
281
+ await sleepFn(retryIntervalMs);
282
+ result = await watchPrToTerminal(watchArgs);
283
+ }
284
+ return {
285
+ ...result,
286
+ attachRetries: retries,
287
+ ...(result.requiredChecksEmpty
288
+ ? { prResolvable: Boolean(prResolvable) }
289
+ : {}),
290
+ };
291
+ }
63
292
 
64
293
  function parsePositiveInt(raw, fallback) {
65
294
  if (raw == null) return fallback;
@@ -76,8 +305,8 @@ function parsePositiveInt(raw, fallback) {
76
305
  *
77
306
  * @param {object} opts
78
307
  * @param {object|null} [opts.config] resolved config (or a bare bag).
79
- * @param {object} [opts.flags] `{ pollIntervalMs, maxPolls, maxResumes, maxUpdates }`.
80
- * @returns {{ pollIntervalMs: number, maxPolls: number, maxResumes: number, maxUpdates: number }}
308
+ * @param {object} [opts.flags] `{ pollIntervalMs, maxPolls, maxResumes, maxUpdates, attachWindowMs }`.
309
+ * @returns {{ pollIntervalMs: number, maxPolls: number, maxResumes: number, maxUpdates: number, attachWindowMs: number }}
81
310
  */
82
311
  export function resolveWatchKnobs({ config, flags = {} } = {}) {
83
312
  const watch = getCiDelivery(config).watch ?? {};
@@ -96,165 +325,164 @@ export function resolveWatchKnobs({ config, flags = {} } = {}) {
96
325
  WATCH_DEFAULTS.maxResumes,
97
326
  ),
98
327
  maxUpdates: pick(flags.maxUpdates, undefined, WATCH_DEFAULTS.maxUpdates),
328
+ attachWindowMs: pick(
329
+ flags.attachWindowMs,
330
+ watch.attachWindowMs,
331
+ WATCH_DEFAULTS.attachWindowMs,
332
+ ),
99
333
  };
100
334
  }
101
335
 
102
- /**
103
- * Coarse failure classification from a failing-check name. Pure —
104
- * exported for tests. Deliberately shallow: it steers the operator's
105
- * next move (which `/loop` unit to reach for), not a root-cause verdict.
106
- *
107
- * @param {string} name failing required-check name.
108
- * @returns {'test'|'lint'|'baseline'|'build'|'unknown'}
109
- */
110
- export function classifyFailure(name) {
111
- const n = String(name ?? '').toLowerCase();
112
- if (/lint|format|biome|markdownlint/.test(n)) return 'lint';
113
- if (/baseline|coverage|crap|maintainab|duplicat/.test(n)) return 'baseline';
114
- if (/build|compile|typecheck|bundle/.test(n)) return 'build';
115
- if (/test|spec|validate|ci|check/.test(n)) return 'test';
116
- return 'unknown';
117
- }
118
-
119
- /**
120
- * Default `gh run view --log-failed` spawn — pulls the tail of the failed
121
- * job log so the digest carries an actionable excerpt. Best-effort:
122
- * returns an empty tail when the run id is unknown or `gh` errors.
123
- * Exported indirectly via `writeCiDigest` injection so tests can stub
124
- * without shelling out.
125
- */
126
- function ghRunLogTail({ runId, cwd, spawnFn = spawnSync, maxLines = 40 }) {
127
- if (!runId) return '';
128
- const result = spawnFn('gh', ['run', 'view', String(runId), '--log-failed'], {
129
- cwd,
130
- encoding: 'utf-8',
131
- shell: false,
132
- maxBuffer: 10 * 1024 * 1024,
133
- });
134
- const out = (result.stdout ?? '').trim();
135
- if (out.length === 0) return '';
136
- const lines = out.split('\n');
137
- return lines.slice(-maxLines).join('\n');
336
+ /** Default re-arm: the sanctioned auto-merge enablement path. */
337
+ function defaultReArm({ cwd, prNumber }) {
338
+ return enableAutoMergeWith({ cwd, prNumber });
138
339
  }
139
340
 
140
341
  /**
141
- * Resolve the GitHub Actions run id for a failing check. Best-effort via
142
- * `gh pr checks --json name,link` the `link` field carries the run URL
143
- * whose trailing path segment is the run id. Returns `null` when
144
- * unresolvable.
342
+ * Red path (Story #4865). Disarm native auto-merge FIRST that is the
343
+ * race-free moment, before any green can exist then record the digest so
344
+ * the green path can adjudicate against the head SHA the red happened on.
345
+ *
346
+ * A disarm failure is a **blocker**, not a warning: an armed PR whose
347
+ * required check went red can still be merged by GitHub the instant a
348
+ * re-run turns it green, which is precisely what this guard exists to stop.
349
+ *
350
+ * @returns {Promise<{ headSha: string|null, disarm: object, digestPaths: object|null, blocked: boolean }>}
145
351
  */
146
- function resolveRunId({ prRef, checkName, cwd, spawnFn = spawnSync }) {
147
- const result = spawnFn('gh', ['pr', 'checks', prRef, '--json', 'name,link'], {
148
- cwd,
149
- encoding: 'utf-8',
150
- shell: false,
151
- });
352
+ async function handleRedWatch({
353
+ storyId,
354
+ prNumber,
355
+ prRef,
356
+ failures,
357
+ tempRoot,
358
+ cwd,
359
+ writeDigestFn,
360
+ headShaFn,
361
+ disarmFn,
362
+ blockFn,
363
+ logger,
364
+ }) {
365
+ const disarm = disarmFn({ prRef, cwd });
366
+ const scope = resolveDigestScope({ storyId });
367
+ const headSha = scope ? headShaFn({ prRef, cwd }) : null;
368
+ let digestPaths = null;
152
369
  try {
153
- const parsed = JSON.parse((result.stdout ?? '').trim() || '[]');
154
- const entry = Array.isArray(parsed)
155
- ? parsed.find((e) => e?.name === checkName)
156
- : null;
157
- const link = entry?.link ?? '';
158
- const m = /\/runs\/(\d+)/.exec(String(link));
159
- return m ? m[1] : null;
160
- } catch {
161
- return null;
370
+ digestPaths = writeDigestFn({
371
+ storyId,
372
+ prNumber,
373
+ headSha,
374
+ failures,
375
+ tempRoot,
376
+ cwd,
377
+ prRef,
378
+ });
379
+ } catch (err) {
380
+ logger.warn?.(
381
+ `[pr-watch] failed to write CI digest (non-fatal): ${err?.message ?? err}`,
382
+ );
162
383
  }
384
+ let blocked = false;
385
+ if (!disarm.disarmed) {
386
+ logger.error?.(
387
+ `[pr-watch] BLOCKER: auto-merge could NOT be disarmed on PR #${prNumber} (${disarm.detail}). ` +
388
+ 'An armed PR can merge the moment a re-run turns it green — the no-rerun rule cannot be enforced.',
389
+ );
390
+ const outcome = await blockFn({
391
+ storyId,
392
+ body: [
393
+ '### Auto-merge could not be disarmed after a red check — delivery blocked',
394
+ '',
395
+ `A required check went red on PR #${prNumber}, but disarming native auto-merge failed:`,
396
+ '',
397
+ `> ${disarm.detail}`,
398
+ '',
399
+ 'While the PR stays armed, GitHub can merge it server-side the instant the',
400
+ 'checks read green — including a green reached by re-running the failed job,',
401
+ 'which `.agents/rules/ci-remediation.md` § Verifier forbids. Disarm the PR by',
402
+ 'hand (or fix the `gh` fault), then resume the delivery.',
403
+ ].join('\n'),
404
+ });
405
+ blocked = Boolean(outcome?.blocked);
406
+ } else {
407
+ logger.error?.(
408
+ `[pr-watch] native auto-merge ${disarm.alreadyUnarmed ? 'was already un-armed' : 'DISARMED'} on PR #${prNumber} — ` +
409
+ 'it is re-armed only by a green on a NEW head SHA.',
410
+ );
411
+ }
412
+ if (digestPaths) {
413
+ logger.error?.(`[pr-watch] CI failure digest → ${digestPaths.jsonPath}`);
414
+ }
415
+ return { headSha, disarm, digestPaths, blocked };
163
416
  }
164
417
 
165
418
  /**
166
- * Resolve which ticket the digest is keyed to. Story #4539: the digest used
167
- * to be Epic-scoped by filename and returned `null` without an epic id — so
168
- * on the v2 Story path (which has no Epic and invokes the watch with `--pr`
169
- * alone) a red check wrote no digest at all, despite the module header
170
- * advertising one. v2.0.0 removed the Epic tier; Story scope is the only
171
- * scope.
419
+ * Green path (Story #4865). Adjudicate the green against any digest the
420
+ * scope recorded, and return the exit code with the report the caller
421
+ * prints.
172
422
  *
173
- * @param {{ storyId?: number|string|null }} opts
174
- * @returns {{ kind: 'story', id: number } | null}
423
+ * @returns {Promise<{ verdict: string, reason: string, exitCode: number, headSha: string|null, reArmed?: boolean, blocked?: boolean }>}
175
424
  */
176
- export function resolveDigestScope({ storyId = null } = {}) {
177
- if (storyId == null || String(storyId).length === 0) return null;
178
- const parsed = Number.parseInt(String(storyId), 10);
179
- return Number.isInteger(parsed) && parsed > 0
180
- ? { kind: 'story', id: parsed }
181
- : null;
182
- }
183
-
184
- /**
185
- * Write the CI failure digest (`.json` + `.md`) for a red watch. Returns
186
- * the two paths written, or `null` when no story id was supplied (the
187
- * digest is scoped by filename and has nothing to key on). Exported for
188
- * tests.
189
- *
190
- * @param {object} opts
191
- * @param {number|string|null} [opts.storyId] The v2 delivery scope.
192
- * @param {number} opts.prNumber
193
- * @param {Array<{name:string, outcome:string}>} opts.failures
194
- * @param {string} opts.tempRoot
195
- * @param {string} opts.cwd
196
- * @param {string} opts.prRef
197
- * @param {Function} [opts.runIdFn]
198
- * @param {Function} [opts.logTailFn]
199
- * @returns {{ jsonPath: string, mdPath: string } | null}
200
- */
201
- export function writeCiDigest({
202
- storyId = null,
425
+ async function evaluateGreenWatch({
426
+ storyId,
203
427
  prNumber,
204
- failures,
428
+ prRef,
205
429
  tempRoot,
206
430
  cwd,
207
- prRef,
208
- runIdFn = resolveRunId,
209
- logTailFn = ghRunLogTail,
431
+ readDigestFn,
432
+ retireDigestFn,
433
+ headShaFn,
434
+ reArmFn,
435
+ blockFn,
436
+ logger,
210
437
  }) {
211
438
  const scope = resolveDigestScope({ storyId });
212
- if (!scope) return null;
213
- const primary = failures[0] ?? { name: 'unknown', outcome: 'failure' };
214
- const runId = runIdFn({ prRef, checkName: primary.name, cwd });
215
- const logTail = logTailFn({ runId, cwd });
216
- const classification = classifyFailure(primary.name);
217
- const digest = {
218
- storyId: scope.id,
219
- prNumber,
220
- failingCheck: primary.name,
221
- failingOutcome: primary.outcome,
222
- runId,
223
- classification,
224
- allFailures: failures,
225
- logTail,
226
- generatedAt: new Date().toISOString(),
439
+ if (!scope) {
440
+ return {
441
+ verdict: 'clean',
442
+ reason: 'no --story scope: no digest can be keyed, guard inert',
443
+ exitCode: 0,
444
+ headSha: null,
445
+ };
446
+ }
447
+ const digest = readDigestFn({ storyId, tempRoot, cwd });
448
+ if (!digest) {
449
+ return {
450
+ verdict: 'clean',
451
+ reason: 'no digest for this scope: this delivery never went red',
452
+ exitCode: 0,
453
+ headSha: null,
454
+ };
455
+ }
456
+ const headSha = headShaFn({ prRef, cwd });
457
+ const { verdict, reason } = classifyGreenVerdict({ digest, headSha });
458
+ if (verdict === 'fix-at-source') {
459
+ retireDigestFn({ storyId, tempRoot, cwd });
460
+ const reArm = await reArmFn({ cwd, prNumber });
461
+ const reArmed = Boolean(reArm?.enabled);
462
+ logger.info?.(
463
+ `[pr-watch] green on a NEW head SHA (${reason}) — fix at source; digest retired, ` +
464
+ `auto-merge ${reArmed ? 're-armed' : `NOT re-armed (${reArm?.reason ?? 'unknown'})`}.`,
465
+ );
466
+ return { verdict, reason, exitCode: 0, headSha, reArmed };
467
+ }
468
+ const body = formatRerunViolation({ digest, headSha, prNumber, reason });
469
+ logger.error?.(
470
+ `[pr-watch] FORBIDDEN CI RE-RUN: ${reason}. Required check \`${digest.failingCheck}\` was red on this exact commit.`,
471
+ );
472
+ logger.error?.(
473
+ `[pr-watch] run link: ${digest.runUrl ?? `run id ${digest.runId ?? 'unresolved'}`} — classification: ${digest.classification ?? 'unknown'}`,
474
+ );
475
+ logger.error?.(
476
+ '[pr-watch] fix the root cause and push a new commit, or file a `meta::framework-gap` issue carrying the run link and failure signature.',
477
+ );
478
+ const outcome = await blockFn({ storyId, body });
479
+ return {
480
+ verdict,
481
+ reason,
482
+ exitCode: 1,
483
+ headSha,
484
+ blocked: Boolean(outcome?.blocked),
227
485
  };
228
- const dir = path.isAbsolute(tempRoot) ? tempRoot : path.join(cwd, tempRoot);
229
- mkdirSync(dir, { recursive: true });
230
- const base = `${scope.kind}-${scope.id}-ci-digest`;
231
- const jsonPath = path.join(dir, `${base}.json`);
232
- const mdPath = path.join(dir, `${base}.md`);
233
- writeFileSync(jsonPath, `${JSON.stringify(digest, null, 2)}\n`);
234
- const md = [
235
- `# CI failure digest — Story #${scope.id} (PR #${prNumber})`,
236
- '',
237
- `- **Failing check:** \`${digest.failingCheck}\` (${digest.failingOutcome})`,
238
- `- **Run id:** ${runId ?? 'unresolved'}`,
239
- `- **Classification:** ${classification}`,
240
- `- **Generated:** ${digest.generatedAt}`,
241
- '',
242
- failures.length > 1
243
- ? `Other non-green checks: ${failures
244
- .slice(1)
245
- .map((f) => `\`${f.name}\`=${f.outcome}`)
246
- .join(', ')}`
247
- : '',
248
- '',
249
- '## `gh run view --log-failed` tail',
250
- '',
251
- '```text',
252
- logTail || '(no failed-log output available)',
253
- '```',
254
- '',
255
- ].join('\n');
256
- writeFileSync(mdPath, md);
257
- return { jsonPath, mdPath };
258
486
  }
259
487
 
260
488
  /**
@@ -262,17 +490,24 @@ export function writeCiDigest({
262
490
  * the green / red / still-running / BEHIND paths can be exercised with
263
491
  * injected `gh` spawns and no `process.exit`.
264
492
  *
265
- * 0 → all required checks green.
266
- * 1 → a required check genuinely failed (red).
267
- * 2 still-running (slow CI): cap + resume budget exhausted, none red.
493
+ * 0 → all required checks green (and the no-rerun guard cleared them).
494
+ * 1 → a required check genuinely failed (red), OR the green was reached
495
+ * by a forbidden re-run of the same commit, OR the pull request itself
496
+ * could not be read (a `gh` / access fault).
497
+ * 2 → slow-but-not-red: still-running (cap + resume budget exhausted, none
498
+ * red), an unreconcilable green, or no required context attached within
499
+ * the attach window while the PR kept reading back fine (#4890).
268
500
  *
269
501
  * @param {object} opts
270
502
  * @param {number} opts.prNumber
271
- * @param {string|null} [opts.repo]
503
+ * @param {string|null} [opts.repo] `owner/repo`; passed to `gh` as `--repo`.
272
504
  * @param {number|string} [opts.maxUpdates]
273
505
  * @param {number|string} [opts.pollIntervalMs]
274
506
  * @param {number|string} [opts.maxPolls]
275
507
  * @param {number|string} [opts.maxResumes]
508
+ * @param {number|string} [opts.attachWindowMs] override the required-context
509
+ * attach window for one run (flag → `delivery.ci.watch.attachWindowMs` →
510
+ * {@link REQUIRED_CONTEXT_ATTACH_WINDOW_MS}).
276
511
  * @param {object|null} [opts.config] resolved config (defaults to resolveConfig()).
277
512
  * @param {string} [opts.tempRoot] digest output dir (default `temp`).
278
513
  * @param {Function} [opts.ghPrChecksFn] inject for tests
@@ -280,6 +515,12 @@ export function writeCiDigest({
280
515
  * @param {Function} [opts.ghPrUpdateBranchFn] inject for tests
281
516
  * @param {Function} [opts.sleepFn] inject for tests
282
517
  * @param {Function} [opts.writeDigestFn] inject for tests (default writeCiDigest)
518
+ * @param {Function} [opts.readDigestFn] inject for tests (default readCiDigest)
519
+ * @param {Function} [opts.retireDigestFn] inject for tests (default retireCiDigest)
520
+ * @param {Function} [opts.headShaFn] inject for tests (default resolvePrHeadSha)
521
+ * @param {Function} [opts.disarmAutoMergeFn] inject for tests (default disarmAutoMerge)
522
+ * @param {Function} [opts.reArmAutoMergeFn] inject for tests (default enableAutoMergeWith)
523
+ * @param {Function} [opts.blockDeliveryFn] inject for tests (default blockStoryDelivery)
283
524
  * @param {object} [opts.logger]
284
525
  * @param {(line: string) => void} [opts.print] stdout sink (default process.stdout)
285
526
  * @returns {Promise<number>} process exit code.
@@ -299,6 +540,15 @@ export async function runPrWatch({
299
540
  ghPrUpdateBranchFn,
300
541
  sleepFn,
301
542
  writeDigestFn = writeCiDigest,
543
+ readDigestFn = readCiDigest,
544
+ retireDigestFn = retireCiDigest,
545
+ headShaFn = resolvePrHeadSha,
546
+ disarmAutoMergeFn = disarmAutoMerge,
547
+ reArmAutoMergeFn = defaultReArm,
548
+ blockDeliveryFn = blockStoryDelivery,
549
+ mergeStateProbeFn,
550
+ attachWindowMs,
551
+ nowMsFn = Date.now,
302
552
  logger = Logger,
303
553
  print = (line) => process.stdout.write(`${line}\n`),
304
554
  } = {}) {
@@ -309,63 +559,156 @@ export async function runPrWatch({
309
559
  config !== undefined ? config : safeResolveConfig(logger);
310
560
  const knobs = resolveWatchKnobs({
311
561
  config: resolvedConfig,
312
- flags: { pollIntervalMs, maxPolls, maxResumes, maxUpdates },
562
+ flags: {
563
+ pollIntervalMs,
564
+ maxPolls,
565
+ maxResumes,
566
+ maxUpdates,
567
+ attachWindowMs,
568
+ },
313
569
  });
314
570
  const effectiveTempRoot =
315
571
  tempRoot ?? resolvedConfig?.project?.paths?.tempRoot ?? 'temp';
572
+ const cwd = process.cwd();
573
+
574
+ // `gh` has NO `<owner/repo>#<number>` argument form — it parses that string
575
+ // as a BRANCH NAME, which is why every `--repo` invocation used to fail at
576
+ // the first probe with a misleading `gh-checks-failed:status=1` (#4890). The
577
+ // repository therefore travels two sanctioned ways, never as a composed ref:
578
+ // - a real `--repo` flag on the watch ports, which build their own argv;
579
+ // - a canonical PR URL for the no-rerun-guard helpers, which take a bare
580
+ // ref and no repository of their own.
581
+ // With `--repo` omitted, `gh` infers the repository from the cwd's remote.
582
+ const prRef = String(prNumber);
583
+ const guardPrRef = repo
584
+ ? `https://github.com/${repo}/pull/${prNumber}`
585
+ : prRef;
316
586
 
317
- // `gh` accepts a bare PR number or a URL; passing `<repo>#<n>` lets
318
- // `gh` resolve the right repository without a URL. When `--repo` is
319
- // omitted, `gh` infers the repo from the cwd's remote.
320
- const prRef = repo ? `${repo}#${prNumber}` : String(prNumber);
587
+ // One merge-state probe, resolved once and used for both readings that need
588
+ // the repository's own view of the PR: the empty-required-set classification
589
+ // below, and the green-verdict reconciliation further down.
590
+ const probeMergeState =
591
+ mergeStateProbeFn ??
592
+ (ghPrViewFn ? mergeStateProbeFromView(ghPrViewFn) : defaultMergeStateProbe);
593
+ const readMergeState = () =>
594
+ probeMergeState({ prRef: guardPrRef, prUrl: prRef, repo, cwd, prNumber });
321
595
 
322
- const result = await watchPrToTerminal({
323
- prUrl: prRef,
324
- cwd: process.cwd(),
325
- maxPolls: knobs.maxPolls,
326
- maxUpdates: knobs.maxUpdates,
327
- maxResumes: knobs.maxResumes,
328
- pollIntervalMs: knobs.pollIntervalMs,
329
- ...(ghPrChecksFn ? { ghPrChecksFn } : {}),
330
- ...(ghPrViewFn ? { ghPrViewFn } : {}),
331
- ...(ghPrUpdateBranchFn ? { ghPrUpdateBranchFn } : {}),
332
- ...(sleepFn ? { sleepFn } : {}),
596
+ const result = await watchWithAttachWindow({
597
+ watchArgs: {
598
+ prUrl: prRef,
599
+ repo,
600
+ cwd,
601
+ maxPolls: knobs.maxPolls,
602
+ maxUpdates: knobs.maxUpdates,
603
+ maxResumes: knobs.maxResumes,
604
+ pollIntervalMs: knobs.pollIntervalMs,
605
+ ...(ghPrChecksFn ? { ghPrChecksFn } : {}),
606
+ ...(ghPrViewFn ? { ghPrViewFn } : {}),
607
+ ...(ghPrUpdateBranchFn ? { ghPrUpdateBranchFn } : {}),
608
+ ...(sleepFn ? { sleepFn } : {}),
609
+ logger,
610
+ },
611
+ attachWindowMs: knobs.attachWindowMs,
612
+ retryIntervalMs: knobs.pollIntervalMs,
613
+ sleepFn: sleepFn ?? defaultSleep,
614
+ nowMsFn,
615
+ // Structural, not prose: the PR reads back ⇒ `gh` works and the empty
616
+ // required set is CI that has not started yet.
617
+ probePrResolvable: async () => (await readMergeState()) !== null,
333
618
  logger,
334
619
  });
335
620
 
336
621
  // Always print the final outcomes map so the operator (and the
337
622
  // workflow log) can see exactly which check blocked.
338
- print(
339
- JSON.stringify({
340
- prNumber,
341
- checkOutcomes: result.outcomes,
342
- requiredChecks: result.requiredChecks,
343
- polls: result.polls,
344
- updatesApplied: result.updatesApplied,
345
- resumesApplied: result.resumesApplied,
346
- terminal: result.terminal,
347
- green: result.green,
348
- stillRunning: result.stillRunning,
349
- ...(result.error ? { error: result.error } : {}),
350
- }),
351
- );
623
+ const envelope = {
624
+ prNumber,
625
+ checkOutcomes: result.outcomes,
626
+ requiredChecks: result.requiredChecks,
627
+ polls: result.polls,
628
+ updatesApplied: result.updatesApplied,
629
+ resumesApplied: result.resumesApplied,
630
+ terminal: result.terminal,
631
+ green: result.green,
632
+ stillRunning: result.stillRunning,
633
+ ...(result.attachRetries ? { attachRetries: result.attachRetries } : {}),
634
+ ...(result.error ? { error: result.error } : {}),
635
+ };
352
636
 
353
- if (result.error) {
637
+ if (result.requiredChecksEmpty || result.error) {
638
+ // The attach window is spent and the required set is still empty. Classify
639
+ // it structurally — never against `gh`'s stderr prose (Story #4890).
640
+ const notYetStarted = Boolean(
641
+ result.requiredChecksEmpty && result.prResolvable,
642
+ );
643
+ print(
644
+ JSON.stringify({
645
+ ...envelope,
646
+ requiredChecksEmpty: Boolean(result.requiredChecksEmpty),
647
+ notYetStarted,
648
+ }),
649
+ );
650
+ if (notYetStarted) {
651
+ logger.warn?.(
652
+ `[pr-watch] no required check has attached to PR #${prNumber} within the ` +
653
+ `${Math.round(knobs.attachWindowMs / 1000)}s attach window (${result.attachRetries} re-resolutions), ` +
654
+ 'and the pull request still reads back fine — this is CI that has not started, NOT a red check. ' +
655
+ 'Keep polling natively:',
656
+ );
657
+ logger.warn?.('[pr-watch] gh pr checks <pr> --watch');
658
+ return STILL_RUNNING_EXIT_CODE;
659
+ }
354
660
  logger.error?.(
355
- `[pr-watch] could not resolve required checks: ${result.error}`,
661
+ `[pr-watch] could not resolve required checks: ${result.error} — the pull request itself could ` +
662
+ 'not be read, so this is a `gh` / access fault rather than CI that has not started.',
356
663
  );
357
664
  return 1;
358
665
  }
359
666
 
360
667
  if (result.green) {
361
- logger.info?.('[pr-watch] all required checks green.');
362
- return 0;
668
+ // Never green on an undercount (Story #4873). The observed required set is
669
+ // whatever `gh pr checks --required` returned on the FIRST probe; when a
670
+ // ruleset attached a context after that, every check we knew about can be
671
+ // green while GitHub still refuses the merge. Reconcile against the
672
+ // repository's own verdict before any green is issued — an unreconcilable
673
+ // set reports unresolved (exit 2, keep watching) rather than a false green
674
+ // or a false red.
675
+ const reconciliation = reconcileGreenVerdict({
676
+ observedRequired: result.requiredChecks,
677
+ mergeStateStatus: await readMergeState(),
678
+ });
679
+ if (!reconciliation.reconciled) {
680
+ print(JSON.stringify({ ...envelope, reconciliation }));
681
+ logger.warn?.(
682
+ `[pr-watch] withholding the green verdict: ${reconciliation.reason}. ` +
683
+ 'Re-run the watch once the repository settles, or inspect branch protection for a context this watch never saw.',
684
+ );
685
+ return STILL_RUNNING_EXIT_CODE;
686
+ }
687
+ const guard = await evaluateGreenWatch({
688
+ storyId,
689
+ prNumber,
690
+ prRef: guardPrRef,
691
+ tempRoot: effectiveTempRoot,
692
+ cwd,
693
+ readDigestFn,
694
+ retireDigestFn,
695
+ headShaFn,
696
+ reArmFn: reArmAutoMergeFn,
697
+ blockFn: blockDeliveryFn,
698
+ logger,
699
+ });
700
+ print(JSON.stringify({ ...envelope, reconciliation, rerunGuard: guard }));
701
+ if (guard.exitCode === 0) {
702
+ logger.info?.('[pr-watch] all required checks green.');
703
+ }
704
+ return guard.exitCode;
363
705
  }
364
706
 
365
707
  // Slow-but-not-red: the cap AND resume budget are exhausted with checks
366
708
  // still pending and none failed. Never exit 1, never `timed_out` — hand
367
709
  // off to the host's interval loop and exit 2.
368
710
  if (result.stillRunning) {
711
+ print(JSON.stringify(envelope));
369
712
  const stillPending = Object.entries(result.outcomes)
370
713
  .filter(([, v]) => v === 'still-running')
371
714
  .map(([k]) => k)
@@ -377,8 +720,8 @@ export async function runPrWatch({
377
720
  return STILL_RUNNING_EXIT_CODE;
378
721
  }
379
722
 
380
- // Genuine red check — exit 1 immediately, write the digest, and surface
381
- // the fix-loop handoff.
723
+ // Genuine red check — exit 1 immediately, disarm auto-merge, write the
724
+ // digest, and surface the fix-loop handoff.
382
725
  // Exclude 'still-running' as well as the non-failing states: when the cap
383
726
  // fires with a mixed failed+pending map, promotePendingToStillRunning has
384
727
  // rewritten the pending entries, and a still-running check is slow, not
@@ -395,26 +738,36 @@ export async function runPrWatch({
395
738
  .map(([name, outcome]) => ({ name, outcome }));
396
739
  const red = failures.map((f) => `${f.name}=${f.outcome}`).join(', ');
397
740
  logger.error?.(`[pr-watch] required check(s) not green: ${red}`);
398
- let digestPaths = null;
399
- try {
400
- digestPaths = writeDigestFn({
401
- storyId,
402
- prNumber,
403
- failures,
404
- tempRoot: effectiveTempRoot,
405
- cwd: process.cwd(),
406
- prRef,
407
- });
408
- } catch (err) {
409
- logger.warn?.(
410
- `[pr-watch] failed to write CI digest (non-fatal): ${err?.message ?? err}`,
411
- );
412
- }
413
- if (digestPaths) {
414
- logger.error?.(`[pr-watch] CI failure digest → ${digestPaths.jsonPath}`);
415
- }
741
+ const redOutcome = await handleRedWatch({
742
+ storyId,
743
+ prNumber,
744
+ prRef: guardPrRef,
745
+ failures,
746
+ tempRoot: effectiveTempRoot,
747
+ cwd,
748
+ writeDigestFn,
749
+ headShaFn,
750
+ disarmFn: disarmAutoMergeFn,
751
+ blockFn: blockDeliveryFn,
752
+ logger,
753
+ });
754
+ print(
755
+ JSON.stringify({
756
+ ...envelope,
757
+ classification: classifyFailure(failures[0]?.name),
758
+ rerunGuard: {
759
+ verdict: 'red',
760
+ headSha: redOutcome.headSha,
761
+ autoMergeDisarmed: redOutcome.disarm.disarmed,
762
+ disarmDetail: redOutcome.disarm.detail,
763
+ digestPath: redOutcome.digestPaths?.jsonPath ?? null,
764
+ blocked: redOutcome.blocked,
765
+ },
766
+ }),
767
+ );
416
768
  logger.error?.(
417
- '[pr-watch] a required check failed. Read the digest, apply the smallest fix, and re-run the suite until green.',
769
+ '[pr-watch] a required check failed. Read the digest, reproduce the failure, and apply the smallest fix at source, ' +
770
+ 'then push a new commit — re-running the failed job is forbidden (`.agents/rules/ci-remediation.md` § Verifier).',
418
771
  );
419
772
  return 1;
420
773
  }
@@ -441,6 +794,7 @@ async function main() {
441
794
  'poll-interval-ms': { type: 'string' },
442
795
  'max-polls': { type: 'string' },
443
796
  'max-resumes': { type: 'string' },
797
+ 'attach-window-ms': { type: 'string' },
444
798
  },
445
799
  strict: false,
446
800
  });
@@ -452,6 +806,7 @@ async function main() {
452
806
  pollIntervalMs: values['poll-interval-ms'],
453
807
  maxPolls: values['max-polls'],
454
808
  maxResumes: values['max-resumes'],
809
+ attachWindowMs: values['attach-window-ms'],
455
810
  });
456
811
  }
457
812