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
@@ -0,0 +1,276 @@
1
+ /**
2
+ * run-scoped-config.js — pin the config values a delivery run was seeded
3
+ * from, and fail closed when the file they came from changed underneath it.
4
+ *
5
+ * The problem this exists for: `single-story-init.js` resolves config and
6
+ * seeds `story-<id>` from `project.baseBranch`; `single-story-close` used to
7
+ * re-resolve the SAME key and run close-validation, format-autofix, the gate
8
+ * baseline and base-sync against its own second answer. Nothing pinned the
9
+ * first value and nothing checked the two agreed — and the window between
10
+ * them is however long implementation takes. A concurrent session editing
11
+ * `.agentrc.json` / `.agentrc.local.json` mid-run therefore base-synced a
12
+ * Story against a base it was never seeded from, which surfaced only as an
13
+ * ordinary content conflict with nothing pointing at config.
14
+ *
15
+ * The base a Story was seeded from is a property of **that run**, not of
16
+ * whatever the config file says later. So init pins it and close reads the
17
+ * pin back.
18
+ *
19
+ * ## Where the pin lives
20
+ *
21
+ * The `story-init` structured comment, not a temp log: the post-land tail
22
+ * purges the temp tree, while the ticket comment survives every close re-run
23
+ * and every recovery path. `single-story-init.js` already upserts that
24
+ * comment; `pinRunScopedConfig` supplies the block it records, and
25
+ * `resolveRunScopedConfig` reads it back through the existing
26
+ * `findStructuredComment` seam.
27
+ *
28
+ * ## Adding another run-scoped key
29
+ *
30
+ * Add one row to `RUN_SCOPED_CONFIG_KEYS`. Both halves — the pin init writes
31
+ * and the comparison close makes — enumerate that registry, so a new key
32
+ * needs no second mechanism, no reader change and no writer change. Only
33
+ * `baseBranch` is enforced today because only its corruption is destructive
34
+ * (a wrong base merged into the Story branch permanently contaminates the
35
+ * branch and its PR diff); ceremony profile, quality floors and the
36
+ * concurrency cap are deliberately NOT pinned here.
37
+ */
38
+
39
+ import { parseFencedJsonComment } from './structured-comment-parser.js';
40
+ import { findStructuredComment } from './ticketing.js';
41
+
42
+ /**
43
+ * The run-scoped config registry. One row per key whose value belongs to the
44
+ * run that seeded the Story rather than to the current contents of the config
45
+ * file. `read` resolves the key from a resolved config (including its
46
+ * default); `label` is the dotted config path the operator has to go fix.
47
+ *
48
+ * @type {Record<string, { read: (config: object) => unknown, label: string }>}
49
+ */
50
+ const RUN_SCOPED_CONFIG_KEYS = {
51
+ baseBranch: {
52
+ read: (config) => config?.project?.baseBranch ?? 'main',
53
+ label: 'project.baseBranch',
54
+ },
55
+ };
56
+
57
+ /**
58
+ * Snapshot the run-scoped config values from a resolved config. This is the
59
+ * write half of the pin: `single-story-init.js` records the returned object
60
+ * in the `story-init` receipt so close can compare against it later.
61
+ *
62
+ * @param {object} config Resolved config (`resolveConfig` output).
63
+ * @param {typeof RUN_SCOPED_CONFIG_KEYS} [keys] Registry override — the
64
+ * default is the module registry; supplying one is how a caller (or a test)
65
+ * exercises the registry-driven property without a second mechanism.
66
+ * @returns {Record<string, unknown>} the pinned values, keyed by config key.
67
+ */
68
+ export function pinRunScopedConfig(config, keys = RUN_SCOPED_CONFIG_KEYS) {
69
+ const pinned = {};
70
+ for (const [key, spec] of Object.entries(keys)) {
71
+ pinned[key] = spec.read(config);
72
+ }
73
+ return pinned;
74
+ }
75
+
76
+ /**
77
+ * Read the pinned block out of the run's `story-init` receipt.
78
+ *
79
+ * Three distinct non-success states, all reported rather than collapsed —
80
+ * the whole point of this module is that a fallback is never silent:
81
+ * - `absent`: no `story-init` comment on the ticket. Real and expected —
82
+ * the upsert is best-effort (init logs and continues on failure), and a
83
+ * recovery path may close a Story whose init predates this receipt.
84
+ * - `unreadable`: the comment exists but carries no parseable JSON payload.
85
+ * - `provider-error`: the comment read itself failed.
86
+ *
87
+ * @param {{
88
+ * provider: object,
89
+ * storyId: number,
90
+ * findCommentFn?: typeof findStructuredComment,
91
+ * }} args
92
+ * @returns {Promise<{ status: string, values: Record<string, unknown>|null, detail: string|null }>}
93
+ */
94
+ async function readRunScopedConfigReceipt({
95
+ provider,
96
+ storyId,
97
+ findCommentFn = findStructuredComment,
98
+ }) {
99
+ let comment;
100
+ try {
101
+ comment = await findCommentFn(provider, Number(storyId), 'story-init');
102
+ } catch (err) {
103
+ return {
104
+ status: 'provider-error',
105
+ values: null,
106
+ detail: `story-init comment could not be read (${err?.message ?? err})`,
107
+ };
108
+ }
109
+ if (!comment) {
110
+ return {
111
+ status: 'absent',
112
+ values: null,
113
+ detail: 'no story-init comment on the ticket',
114
+ };
115
+ }
116
+ const payload = parseFencedJsonComment(comment);
117
+ if (!payload || typeof payload !== 'object') {
118
+ return {
119
+ status: 'unreadable',
120
+ values: null,
121
+ detail: 'story-init comment carries no parseable JSON payload',
122
+ };
123
+ }
124
+ // Receipts written before the `runScopedConfig` block existed carry the
125
+ // pinned values as top-level payload fields (`baseBranch` has been recorded
126
+ // there since Story #831). Reading the payload itself as the fallback block
127
+ // is what lets a Story initialized by an older init still close against its
128
+ // own pinned base instead of degrading to the fallback warning.
129
+ const block =
130
+ payload.runScopedConfig && typeof payload.runScopedConfig === 'object'
131
+ ? payload.runScopedConfig
132
+ : payload;
133
+ return { status: 'found', values: block, detail: null };
134
+ }
135
+
136
+ /**
137
+ * Build the refusal message for a mid-run config change. Names BOTH values
138
+ * for every conflicting key — the pinned one and the one config resolves to
139
+ * now — because "which base was this branch actually seeded from" is the
140
+ * question the operator could not previously answer.
141
+ *
142
+ * @param {{ storyId: number, conflicts: Array<{ label: string, pinned: unknown, current: unknown }> }} args
143
+ * @returns {string}
144
+ */
145
+ function formatRunScopedConflict({ storyId, conflicts }) {
146
+ const rows = conflicts.map(
147
+ ({ label, pinned, current }) =>
148
+ `${label}: pinned at init = \`${String(pinned)}\`, currently resolves to \`${String(current)}\``,
149
+ );
150
+ return (
151
+ `[single-story-close] Refusing to close Story #${storyId}: run-scoped config changed mid-run. ` +
152
+ `${rows.join('; ')}. ` +
153
+ 'The Story branch was seeded from the pinned value, so closing against the current one would ' +
154
+ 'base-sync it onto a base it was never seeded from. No base-sync, format-autofix or gate run was ' +
155
+ 'performed. A concurrent session most likely edited `.agentrc.json` / `.agentrc.local.json` during ' +
156
+ 'the implementation window: restore the pinned value and re-run close, or re-init the Story against ' +
157
+ 'the new value deliberately.'
158
+ );
159
+ }
160
+
161
+ /**
162
+ * Resolve the run-scoped config for a close, preferring the run's pin over a
163
+ * fresh resolution of the config file.
164
+ *
165
+ * Throws on disagreement — fail closed, before any gate, format-autofix or
166
+ * base-sync has run, with both values named. Never silently prefers either
167
+ * side: preferring the pin would base-sync correctly but hide a config change
168
+ * the operator needs to know about, and preferring current config is the bug
169
+ * this module exists to close.
170
+ *
171
+ * @param {{
172
+ * provider: object,
173
+ * storyId: number,
174
+ * config: object,
175
+ * keys?: typeof RUN_SCOPED_CONFIG_KEYS,
176
+ * findCommentFn?: typeof findStructuredComment,
177
+ * progress?: (tag: string, msg: string) => void,
178
+ * }} args
179
+ * @returns {Promise<{
180
+ * values: Record<string, unknown>,
181
+ * confirmed: boolean,
182
+ * receiptStatus: string,
183
+ * warning: string|null,
184
+ * }>} `confirmed` is true only when every key came from the receipt and
185
+ * agreed with current config — i.e. when downstream remediation may safely
186
+ * assume the pinned value is the one in play.
187
+ */
188
+ export async function resolveRunScopedConfig({
189
+ provider,
190
+ storyId,
191
+ config,
192
+ keys = RUN_SCOPED_CONFIG_KEYS,
193
+ findCommentFn,
194
+ progress,
195
+ }) {
196
+ const current = pinRunScopedConfig(config, keys);
197
+ const receipt = await readRunScopedConfigReceipt({
198
+ provider,
199
+ storyId,
200
+ findCommentFn,
201
+ });
202
+
203
+ if (receipt.status !== 'found') {
204
+ // A missing receipt is a real state, not an error — but the fallback is
205
+ // announced, because a silent one reintroduces exactly the bug above.
206
+ const warning =
207
+ `Run-scoped config could not be read from the run's init receipt ` +
208
+ `(${receipt.detail}); falling back to the currently-resolved config ` +
209
+ `(${describeValues(current, keys)}). This close cannot confirm the Story ` +
210
+ 'was seeded from these values.';
211
+ progress?.('PIN', `⚠️ ${warning}`);
212
+ return {
213
+ values: current,
214
+ confirmed: false,
215
+ receiptStatus: receipt.status,
216
+ warning,
217
+ };
218
+ }
219
+
220
+ const values = {};
221
+ const conflicts = [];
222
+ const unpinned = [];
223
+ for (const [key, spec] of Object.entries(keys)) {
224
+ const pinned = receipt.values?.[key];
225
+ if (pinned === undefined || pinned === null) {
226
+ unpinned.push(spec.label);
227
+ values[key] = current[key];
228
+ continue;
229
+ }
230
+ values[key] = pinned;
231
+ if (pinned !== current[key]) {
232
+ conflicts.push({ label: spec.label, pinned, current: current[key] });
233
+ }
234
+ }
235
+
236
+ if (conflicts.length > 0) {
237
+ throw new Error(formatRunScopedConflict({ storyId, conflicts }));
238
+ }
239
+
240
+ if (unpinned.length > 0) {
241
+ const warning =
242
+ `The run's init receipt pins no value for ${unpinned.join(', ')}; ` +
243
+ `falling back to the currently-resolved config (${describeValues(values, keys)}).`;
244
+ progress?.('PIN', `⚠️ ${warning}`);
245
+ return {
246
+ values,
247
+ confirmed: false,
248
+ receiptStatus: receipt.status,
249
+ warning,
250
+ };
251
+ }
252
+
253
+ progress?.(
254
+ 'PIN',
255
+ `📌 Run-scoped config pinned by the story-init receipt (${describeValues(values, keys)}).`,
256
+ );
257
+ return {
258
+ values,
259
+ confirmed: true,
260
+ receiptStatus: receipt.status,
261
+ warning: null,
262
+ };
263
+ }
264
+
265
+ /**
266
+ * Render `label=value` pairs for the operator-facing lines above.
267
+ *
268
+ * @param {Record<string, unknown>} values
269
+ * @param {typeof RUN_SCOPED_CONFIG_KEYS} keys
270
+ * @returns {string}
271
+ */
272
+ function describeValues(values, keys) {
273
+ return Object.entries(keys)
274
+ .map(([key, spec]) => `${spec.label}=\`${String(values[key])}\``)
275
+ .join(', ');
276
+ }
@@ -35,6 +35,7 @@ import {
35
35
  * cwd: string,
36
36
  * worktreePath: string|null,
37
37
  * baseBranch: string,
38
+ * baseConfirmed?: boolean,
38
39
  * storyBranch: string,
39
40
  * storyId: number,
40
41
  * provider: object,
@@ -46,6 +47,7 @@ export async function runBaseSyncPhase({
46
47
  cwd,
47
48
  worktreePath,
48
49
  baseBranch,
50
+ baseConfirmed = false,
49
51
  storyBranch,
50
52
  storyId,
51
53
  provider,
@@ -69,6 +71,7 @@ export async function runBaseSyncPhase({
69
71
  storyId,
70
72
  syncCwd,
71
73
  baseBranch,
74
+ baseConfirmed,
72
75
  storyBranch,
73
76
  result: syncResult,
74
77
  progress,
@@ -95,6 +98,7 @@ export async function runBaseSyncPhase({
95
98
  * storyId: number,
96
99
  * syncCwd: string,
97
100
  * baseBranch: string,
101
+ * baseConfirmed?: boolean,
98
102
  * storyBranch: string,
99
103
  * result: { kind: string, conflictFiles?: string[], stderr?: string },
100
104
  * progress: (tag: string, msg: string) => void,
@@ -105,6 +109,7 @@ export async function handleSyncFailure({
105
109
  storyId,
106
110
  syncCwd,
107
111
  baseBranch,
112
+ baseConfirmed = false,
108
113
  storyBranch,
109
114
  result,
110
115
  progress,
@@ -113,6 +118,7 @@ export async function handleSyncFailure({
113
118
  storyId,
114
119
  storyBranch,
115
120
  baseBranch,
121
+ baseConfirmed,
116
122
  syncCwd,
117
123
  result,
118
124
  });
@@ -147,13 +153,23 @@ export async function handleSyncFailure({
147
153
  * Build the markdown body posted on a base-sync failure. Pure; exported
148
154
  * for tests so the operator-recoverable surface stays reviewable.
149
155
  *
150
- * @param {{ storyId: number, storyBranch: string, baseBranch: string, syncCwd: string, result: { kind: string, conflictFiles?: string[], stderr?: string } }} args
156
+ * Story #4891 `baseConfirmed` gates the merge-the-base recovery block, and
157
+ * defaults to `false` so it fails closed. Telling the operator to run
158
+ * `git merge origin/<baseBranch>` is actively harmful when `<baseBranch>` is
159
+ * not the base the Story was seeded from: it permanently contaminates the
160
+ * branch and its PR diff with an unrelated base. Close confirms the base
161
+ * against the run's `story-init` receipt before that advice is emitted; when
162
+ * it could not (receipt absent, unreadable, or unpinned), the operator is
163
+ * told to establish the real base first instead.
164
+ *
165
+ * @param {{ storyId: number, storyBranch: string, baseBranch: string, baseConfirmed?: boolean, syncCwd: string, result: { kind: string, conflictFiles?: string[], stderr?: string } }} args
151
166
  * @returns {string}
152
167
  */
153
168
  export function buildSyncFailureCommentBody({
154
169
  storyId,
155
170
  storyBranch,
156
171
  baseBranch,
172
+ baseConfirmed = false,
157
173
  syncCwd,
158
174
  result,
159
175
  }) {
@@ -170,15 +186,30 @@ export function buildSyncFailureCommentBody({
170
186
  `sync against \`origin/${baseBranch}\` could not complete. The Story has`,
171
187
  `been transitioned to \`agent::blocked\`. To resume:`,
172
188
  '',
173
- '```bash',
174
- `cd ${syncCwd}`,
175
- `git fetch origin ${baseBranch}`,
176
- `git merge --no-edit origin/${baseBranch}`,
177
- '# resolve any conflicts, then:',
178
- `git add -A ; git commit --no-edit`,
179
- '# re-run close:',
180
- `node .agents/scripts/single-story-close.js --story ${storyId}`,
181
- '```',
189
+ ...(baseConfirmed
190
+ ? [
191
+ '```bash',
192
+ `cd ${syncCwd}`,
193
+ `git fetch origin ${baseBranch}`,
194
+ `git merge --no-edit origin/${baseBranch}`,
195
+ '# resolve any conflicts, then:',
196
+ `git add -A ; git commit --no-edit`,
197
+ '# re-run close:',
198
+ `node .agents/scripts/single-story-close.js --story ${storyId}`,
199
+ '```',
200
+ ]
201
+ : [
202
+ `⚠️ **No merge advice: \`${baseBranch}\` is unconfirmed.** This close could not`,
203
+ `read the base branch \`${storyBranch}\` was seeded from off the run's`,
204
+ '`story-init` receipt, so merging that base in could contaminate the branch',
205
+ 'and its PR diff with an unrelated base. Establish the real base first —',
206
+ `check the \`story-init\` comment on this issue and \`project.baseBranch\` in`,
207
+ '`.agentrc.json` / `.agentrc.local.json` — then merge that base and re-run:',
208
+ '',
209
+ '```bash',
210
+ `node .agents/scripts/single-story-close.js --story ${storyId}`,
211
+ '```',
212
+ ]),
182
213
  ];
183
214
  if (kind === 'conflict' && fileList.length > 0) {
184
215
  lines.push('', '**Conflicting files:**', '', ...fileList);
@@ -95,6 +95,7 @@ import {
95
95
  confirmStoryMerged as defaultConfirmStoryMerged,
96
96
  readPrMergeState as defaultReadPrMergeState,
97
97
  } from '../../../single-story/confirm-merge.js';
98
+ import { pollUntil } from '../../../util/poll-loop.js';
98
99
  import {
99
100
  emitMergeFlipFailed as defaultEmitMergeFlipFailed,
100
101
  MERGED_FLIP_FAILED_BLOCK_CLASS,
@@ -281,15 +282,33 @@ export async function readPrWaitProbe({
281
282
  * explicit `maxWaitSecondsOverride` still wins over the async cap — a headless
282
283
  * caller with no host ceiling opts back into single-block waiting.
283
284
  *
285
+ * `modeOverride` is the per-invocation `--merge-watch-mode` flag (Story #4949)
286
+ * and wins over `delivery.mergeWatch.mode` on exactly the precedence
287
+ * `maxWaitSecondsOverride` already uses. It exists because run topology is
288
+ * knowable only to the orchestrator: close sees one Story and cannot tell a
289
+ * solo delivery (where a foreground wait is the cheapest ending) from the Nth
290
+ * close of a wave (where each foreground wait is serialized dead time). The
291
+ * config default therefore stays `sync`, and the caller that knows better says
292
+ * so per invocation. The two flags remain composable — `--merge-watch-mode
293
+ * async --max-wait-seconds 900` selects the async posture and then overrides
294
+ * its probe cap, because the cap check below keys on the override's presence,
295
+ * not on where the mode came from.
296
+ *
284
297
  * @param {object} [config]
285
298
  * @param {number} [maxWaitSecondsOverride]
299
+ * @param {'sync'|'async'} [modeOverride]
286
300
  * @returns {{ mode: 'sync'|'async', intervalSeconds: number, maxWaitSeconds: number, maxBudgetSeconds: number, updateAttempts: number }}
287
301
  */
288
- export function resolveMergeWaitConfig(config, maxWaitSecondsOverride) {
302
+ export function resolveMergeWaitConfig(
303
+ config,
304
+ maxWaitSecondsOverride,
305
+ modeOverride,
306
+ ) {
289
307
  const mergeWatch = config?.delivery?.mergeWatch ?? {};
290
308
  const int = (value, fallback, min = 1) =>
291
309
  Number.isInteger(value) && value >= min ? value : fallback;
292
- const mode = mergeWatch.mode === 'async' ? 'async' : 'sync';
310
+ const requestedMode = modeOverride ?? mergeWatch.mode;
311
+ const mode = requestedMode === 'async' ? 'async' : 'sync';
293
312
  const configuredMaxWait = int(
294
313
  maxWaitSecondsOverride,
295
314
  int(mergeWatch.maxWaitSeconds, DEFAULT_MAX_WAIT_SECONDS),
@@ -363,7 +382,8 @@ function formatUnlandedFriction({
363
382
  const remedy =
364
383
  blockClass === 'checks-failed'
365
384
  ? `A required check is **red**. Fix the failure and push a new commit on \`story-${storyId}\`; ` +
366
- `auto-merge stays armed across retries. Watch the checks with:\n\n` +
385
+ `the red disarms auto-merge, and only a green on a new head SHA re-arms it — ` +
386
+ `re-running the failed job is forbidden. Watch the checks with:\n\n` +
367
387
  `\`\`\`bash\n${NEXT_COMMANDS.watchCi(storyId, prNumber)}\n\`\`\``
368
388
  : `Resolve the underlying condition (branch protection, required checks, ` +
369
389
  `or a manual merge), then resume the land:\n\n` +
@@ -757,6 +777,9 @@ async function onMergeObserved({
757
777
  * @param {string|null} args.autoMergeReason
758
778
  * @param {object} args.provider
759
779
  * @param {object} [args.config]
780
+ * @param {'sync'|'async'} [args.mergeWatchMode] Per-invocation
781
+ * `--merge-watch-mode` override (Story #4949); wins over
782
+ * `delivery.mergeWatch.mode`.
760
783
  * @param {(tag: string, msg: string) => void} [args.progress]
761
784
  * @param {object} [args.injectedGh]
762
785
  * @param {Function} [args.injectedNotify]
@@ -789,6 +812,7 @@ export async function runConfirmMergePhase({
789
812
  provider,
790
813
  config,
791
814
  maxWaitSeconds: maxWaitSecondsOverride,
815
+ mergeWatchMode: mergeWatchModeOverride,
792
816
  progress,
793
817
  injectedGh,
794
818
  injectedNotify,
@@ -831,7 +855,11 @@ export async function runConfirmMergePhase({
831
855
  maxWaitSeconds,
832
856
  maxBudgetSeconds,
833
857
  updateAttempts,
834
- } = resolveMergeWaitConfig(config, maxWaitSecondsOverride);
858
+ } = resolveMergeWaitConfig(
859
+ config,
860
+ maxWaitSecondsOverride,
861
+ mergeWatchModeOverride,
862
+ );
835
863
  const intervalMs = intervalSeconds * 1000;
836
864
  const startedAtMs = nowMsFn();
837
865
  let anchorMs = startedAtMs;
@@ -851,7 +879,20 @@ export async function runConfirmMergePhase({
851
879
  `cumulative budget=${maxBudgetSeconds}s)...`,
852
880
  );
853
881
 
854
- while (true) {
882
+ /**
883
+ * One poll iteration. Returns `{ done: false }` to keep polling, or
884
+ * `{ done: true, outcome }` with the phase's terminal. Story #4873 lifted
885
+ * this body out of a bespoke unbounded loop so the cadence is owned by the
886
+ * shared {@link pollUntil} primitive — the loop below sleeps, aborts, and
887
+ * counts ticks in exactly one place for every wait in the codebase. Every
888
+ * budget, floor, and classification decision is unchanged; only who owns the
889
+ * `await sleep(...)` moved.
890
+ *
891
+ * A throw from any of the terminal handlers is captured rather than allowed
892
+ * to escape into `pollUntil` (which treats a throwing `fn` as a non-match
893
+ * and would spin forever on it); the caller re-throws it after the loop.
894
+ */
895
+ async function runMergePoll() {
855
896
  const probe = await readPrWaitProbeFn({
856
897
  prNumber,
857
898
  gh: injectedGh,
@@ -875,26 +916,44 @@ export async function runConfirmMergePhase({
875
916
  maxBudgetSeconds,
876
917
  };
877
918
 
919
+ // Heartbeat (Story #4873). A backgrounded close writes this phase's
920
+ // progress to its own output file, and between the opening banner and the
921
+ // terminal there used to be NOTHING for minutes at a time — so an
922
+ // orchestrator watching that file could not tell a healthy in-flight wait
923
+ // from a wedged process without going back to GitHub itself. One line per
924
+ // poll makes the file's own growth the liveness signal.
925
+ progress?.(
926
+ 'CONFIRM',
927
+ `⏱ poll ${polls}: PR #${prNumber} state=${probe.state ?? 'unknown'} ` +
928
+ `checks=${probe.checksStatus ?? 'unknown'} ` +
929
+ `mergeState=${probe.mergeStateStatus ?? 'unknown'} ` +
930
+ `(${waitBudget.waitedSeconds}s of ${maxWaitSeconds}s this invocation; ` +
931
+ `${waitBudget.cumulativeSeconds}s of ${maxBudgetSeconds}s cumulative)` +
932
+ (probe.error ? ` — probe error: ${probe.error}` : ''),
933
+ );
934
+
878
935
  if (probe.state === 'MERGED' || probe.mergedAt) {
879
- return onMergeObserved({
880
- storyId,
881
- storyBranch,
882
- baseBranch,
883
- prNumber,
884
- prUrl,
885
- cwd,
886
- config,
887
- provider,
888
- progress,
889
- injectedGh,
890
- injectedNotify,
891
- readPrMergeStateFn,
892
- confirmStoryMergedFn,
893
- runPostLandTailFn,
894
- emitMergeFlipFailedFn,
895
- prProbe: probe,
896
- elapsedSeconds: Math.round(waitedMs / 1000),
897
- });
936
+ return doneWith(
937
+ await onMergeObserved({
938
+ storyId,
939
+ storyBranch,
940
+ baseBranch,
941
+ prNumber,
942
+ prUrl,
943
+ cwd,
944
+ config,
945
+ provider,
946
+ progress,
947
+ injectedGh,
948
+ injectedNotify,
949
+ readPrMergeStateFn,
950
+ confirmStoryMergedFn,
951
+ runPostLandTailFn,
952
+ emitMergeFlipFailedFn,
953
+ prProbe: probe,
954
+ elapsedSeconds: Math.round(waitedMs / 1000),
955
+ }),
956
+ );
898
957
  }
899
958
 
900
959
  // Everything below funnels into ONE terminal exit (Story #4710): each
@@ -987,16 +1046,18 @@ export async function runConfirmMergePhase({
987
1046
  }
988
1047
 
989
1048
  if (unlanded) {
990
- return blockOnUnlanded({
991
- storyId,
992
- prNumber,
993
- prUrl,
994
- ...unlanded,
995
- provider,
996
- progress,
997
- classifyMergeBlockFn,
998
- emitMergeUnlandedFn,
999
- });
1049
+ return doneWith(
1050
+ await blockOnUnlanded({
1051
+ storyId,
1052
+ prNumber,
1053
+ prUrl,
1054
+ ...unlanded,
1055
+ provider,
1056
+ progress,
1057
+ classifyMergeBlockFn,
1058
+ emitMergeUnlandedFn,
1059
+ }),
1060
+ );
1000
1061
  }
1001
1062
 
1002
1063
  // This invocation's bound expired → PENDING. Deliberately NOT a block:
@@ -1011,16 +1072,43 @@ export async function runConfirmMergePhase({
1011
1072
  `${waitBudget.cumulativeSeconds}s of ${maxBudgetSeconds}s cumulative). PR #${prNumber} still in flight ` +
1012
1073
  `(checks=${probe.checksStatus ?? 'unknown'}). Story stays at agent::closing — resumable.`,
1013
1074
  );
1014
- return {
1075
+ return doneWith({
1015
1076
  confirmed: false,
1016
1077
  terminal: 'pending',
1017
1078
  reason: `merge wait bound reached with the PR still in flight (checks=${probe.checksStatus ?? 'unknown'})`,
1018
1079
  prProbe: probe,
1019
1080
  waitBudget,
1020
1081
  elapsedSeconds: waitBudget.waitedSeconds,
1021
- };
1082
+ });
1022
1083
  }
1023
1084
 
1024
- await sleepFn(intervalMs);
1085
+ return { done: false };
1025
1086
  }
1087
+
1088
+ const tick = await pollUntil({
1089
+ fn: async () => {
1090
+ try {
1091
+ return await runMergePoll();
1092
+ } catch (err) {
1093
+ // A terminal handler threw. `pollUntil` treats a throwing `fn` as a
1094
+ // non-match and would poll forever on it, so the throw is carried out
1095
+ // as a match and re-raised below.
1096
+ return { done: true, thrown: err };
1097
+ }
1098
+ },
1099
+ predicate: (result) => result?.done === true,
1100
+ intervalMs,
1101
+ // The wait owns its own bounds (`maxWaitSeconds` → `pending`,
1102
+ // `maxBudgetSeconds` → blocked), and both are decided from the probe
1103
+ // inside the tick. A second, cruder wall-clock timeout here would throw
1104
+ // past those classifications.
1105
+ sleepFn: (ms) => sleepFn(ms),
1106
+ });
1107
+ if (tick.thrown) throw tick.thrown;
1108
+ return tick.outcome;
1109
+ }
1110
+
1111
+ /** Wrap a phase terminal as the poll loop's match. */
1112
+ function doneWith(outcome) {
1113
+ return { done: true, outcome };
1026
1114
  }
@@ -38,7 +38,7 @@ import { gitSpawn as defaultGitSpawn } from '../../../git-utils.js';
38
38
  import { Logger as DefaultLogger } from '../../../Logger.js';
39
39
 
40
40
  /** Safe default Conventional-Commit type when none can be derived. */
41
- export const DEFAULT_CONVENTIONAL_TYPE = 'chore';
41
+ const DEFAULT_CONVENTIONAL_TYPE = 'chore';
42
42
 
43
43
  /**
44
44
  * The Conventional-Commit types Mandrel accepts. Mirrors
@@ -46,7 +46,7 @@ export const DEFAULT_CONVENTIONAL_TYPE = 'chore';
46
46
  * `changelog-sections`. Kept in sync by hand (single hard-cutover, no
47
47
  * shim) — adding a type means touching all three.
48
48
  */
49
- export const CONVENTIONAL_TYPES = Object.freeze([
49
+ const CONVENTIONAL_TYPES = Object.freeze([
50
50
  'feat',
51
51
  'fix',
52
52
  'perf',
@@ -99,7 +99,7 @@ const LEADING_TYPE_RE = new RegExp(
99
99
  * @param {string} subject
100
100
  * @returns {boolean}
101
101
  */
102
- export function isConventionalSubject(subject) {
102
+ function isConventionalSubject(subject) {
103
103
  if (typeof subject !== 'string') return false;
104
104
  return CONVENTIONAL_HEADER_RE.test(subject.trim());
105
105
  }
@@ -111,7 +111,7 @@ export function isConventionalSubject(subject) {
111
111
  * @param {string} subject
112
112
  * @returns {string|null}
113
113
  */
114
- export function parseConventionalType(subject) {
114
+ function parseConventionalType(subject) {
115
115
  if (typeof subject !== 'string') return null;
116
116
  const match = subject.trim().match(LEADING_TYPE_RE);
117
117
  return match ? match[1] : null;
@@ -125,7 +125,7 @@ export function parseConventionalType(subject) {
125
125
  * @param {string[]} types
126
126
  * @returns {string|null}
127
127
  */
128
- export function pickDominantType(types) {
128
+ function pickDominantType(types) {
129
129
  const present = new Set(types.filter(Boolean));
130
130
  for (const candidate of TYPE_PRECEDENCE) {
131
131
  if (present.has(candidate)) return candidate;
@@ -148,7 +148,7 @@ export function pickDominantType(types) {
148
148
  * }} args
149
149
  * @returns {string}
150
150
  */
151
- export function deriveTypeFromBranchCommits({
151
+ function deriveTypeFromBranchCommits({
152
152
  storyBranch,
153
153
  baseBranch,
154
154
  cwd = process.cwd(),