mandrel 2.23.0 → 2.25.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 (101) hide show
  1. package/.agents/agents/acceptance-critic.md +13 -19
  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/quality.md +3 -0
  6. package/.agents/docs/agentrc-reference.json +2 -1
  7. package/.agents/docs/configuration.md +2 -1
  8. package/.agents/docs/workflows.md +1 -1
  9. package/.agents/rules/ci-remediation.md +68 -3
  10. package/.agents/schemas/agentrc.schema.json +6 -1
  11. package/.agents/schemas/baselines/baseline-envelope.schema.json +4 -0
  12. package/.agents/schemas/baselines/crap.schema.json +5 -1
  13. package/.agents/schemas/crap-report.schema.json +37 -0
  14. package/.agents/schemas/story-deliver-terminal.schema.json +6 -1
  15. package/.agents/scripts/acceptance-eval.js +35 -9
  16. package/.agents/scripts/audit-labels-bootstrap.js +16 -95
  17. package/.agents/scripts/audit-to-stories.js +33 -3
  18. package/.agents/scripts/check-context-budget.js +95 -14
  19. package/.agents/scripts/check-test-temp-hygiene.js +83 -0
  20. package/.agents/scripts/cleanup-repo-test-temp.js +7 -5
  21. package/.agents/scripts/deliver-light.js +22 -45
  22. package/.agents/scripts/deliver-recover.js +13 -0
  23. package/.agents/scripts/lib/audit-suite/lens-diff-floor.js +10 -25
  24. package/.agents/scripts/lib/audit-to-stories/audit-label-taxonomy.js +177 -0
  25. package/.agents/scripts/lib/audit-to-stories/build-story-body.js +35 -3
  26. package/.agents/scripts/lib/audit-to-stories/group-findings.js +20 -11
  27. package/.agents/scripts/lib/audit-to-stories/parse-audit-md.js +23 -9
  28. package/.agents/scripts/lib/audit-to-stories/seed-from-findings.js +9 -2
  29. package/.agents/scripts/lib/baseline-loader.js +0 -0
  30. package/.agents/scripts/lib/baselines/git-base.js +0 -0
  31. package/.agents/scripts/lib/baselines/kinds/crap.js +396 -51
  32. package/.agents/scripts/lib/baselines/kinds/maintainability.js +20 -32
  33. package/.agents/scripts/lib/baselines/preview-gates.js +58 -15
  34. package/.agents/scripts/lib/baselines/reader.js +10 -0
  35. package/.agents/scripts/lib/baselines/writer.js +7 -3
  36. package/.agents/scripts/lib/config-settings-schema-delivery.js +5 -0
  37. package/.agents/scripts/lib/crap-engine.js +97 -19
  38. package/.agents/scripts/lib/crap-utils.js +73 -13
  39. package/.agents/scripts/lib/env-loader.js +46 -16
  40. package/.agents/scripts/lib/escomplex-ast-compat.js +360 -0
  41. package/.agents/scripts/lib/findings/route-finding.js +123 -17
  42. package/.agents/scripts/lib/findings/severity.js +80 -2
  43. package/.agents/scripts/lib/git/cached-fetch.js +0 -0
  44. package/.agents/scripts/lib/label-constants.js +17 -0
  45. package/.agents/scripts/lib/maintainability-engine.js +83 -11
  46. package/.agents/scripts/lib/maintainability-unscorable.js +60 -0
  47. package/.agents/scripts/lib/maintainability-utils.js +14 -5
  48. package/.agents/scripts/lib/observability/runtime-friction.js +8 -0
  49. package/.agents/scripts/lib/orchestration/acceptance-eval-decision.js +110 -17
  50. package/.agents/scripts/lib/orchestration/ci-rerun-guard.js +548 -0
  51. package/.agents/scripts/lib/orchestration/deliver-recover.js +2 -2
  52. package/.agents/scripts/lib/orchestration/diff-magnitude.js +283 -0
  53. package/.agents/scripts/lib/orchestration/lifecycle/listeners/watcher.js +71 -20
  54. package/.agents/scripts/lib/orchestration/light-backstop.js +124 -0
  55. package/.agents/scripts/lib/orchestration/light-escalation.js +248 -0
  56. package/.agents/scripts/lib/orchestration/light-suitability.js +254 -51
  57. package/.agents/scripts/lib/orchestration/plan-context.js +12 -13
  58. package/.agents/scripts/lib/orchestration/plan-persist/run-plan-persist.js +7 -0
  59. package/.agents/scripts/lib/orchestration/plan-persist/story-ops.js +13 -1
  60. package/.agents/scripts/lib/orchestration/retro-proposals.js +0 -0
  61. package/.agents/scripts/lib/orchestration/run-scoped-config.js +276 -0
  62. package/.agents/scripts/lib/orchestration/single-story-close/phases/base-sync.js +41 -10
  63. package/.agents/scripts/lib/orchestration/single-story-close/phases/confirm-merge.js +96 -34
  64. package/.agents/scripts/lib/orchestration/single-story-close/phases/post-land.js +70 -2
  65. package/.agents/scripts/lib/orchestration/single-story-close/phases/pull-request.js +129 -21
  66. package/.agents/scripts/lib/orchestration/single-story-close/runner.js +73 -18
  67. package/.agents/scripts/lib/reserved-test-ids.js +77 -0
  68. package/.agents/scripts/lib/story-plan.js +137 -42
  69. package/.agents/scripts/lib/templates/decomposer-prompts.js +4 -4
  70. package/.agents/scripts/lib/ticket-body-sections.js +1 -1
  71. package/.agents/scripts/lib/util/poll-loop.js +18 -2
  72. package/.agents/scripts/lib/wave-runner/live-probe.js +7 -1
  73. package/.agents/scripts/lib/wave-runner/ready-set.js +73 -9
  74. package/.agents/scripts/lib/workers/combined-mi-crap-worker.js +6 -3
  75. package/.agents/scripts/lib/workers/crap-worker.js +6 -3
  76. package/.agents/scripts/lib/workers/maintainability-worker.js +14 -9
  77. package/.agents/scripts/pr-watch-with-update.js +568 -213
  78. package/.agents/scripts/quality-preview.js +25 -0
  79. package/.agents/scripts/run-tests.js +10 -0
  80. package/.agents/scripts/single-story-close.js +6 -0
  81. package/.agents/scripts/single-story-init.js +7 -0
  82. package/.agents/scripts/stories-wave-tick.js +95 -10
  83. package/.agents/scripts/story-plan.js +65 -9
  84. package/.agents/scripts/update-crap-baseline.js +42 -4
  85. package/.agents/scripts/update-maintainability-baseline.js +31 -4
  86. package/.agents/templates/single-story-body.md +16 -8
  87. package/.agents/workflows/audit-architecture.md +69 -13
  88. package/.agents/workflows/audit-quality.md +26 -0
  89. package/.agents/workflows/audit-to-stories.md +30 -4
  90. package/.agents/workflows/deliver.md +81 -95
  91. package/.agents/workflows/helpers/audit-lens-core.md +25 -4
  92. package/.agents/workflows/helpers/deliver-light.md +21 -4
  93. package/.agents/workflows/helpers/deliver-reference.md +18 -3
  94. package/.agents/workflows/helpers/deliver-story-reference.md +127 -46
  95. package/.agents/workflows/helpers/deliver-story.md +104 -119
  96. package/.agents/workflows/helpers/plan-reference.md +34 -0
  97. package/.agents/workflows/plan.md +60 -76
  98. package/docs/CHANGELOG.md +38 -0
  99. package/lib/cli/update.js +14 -11
  100. package/lib/cli/version-check.js +9 -1
  101. package/package.json +1 -1
@@ -379,14 +379,21 @@ function resolveRiskHeuristics(config = {}) {
379
379
  * this one screens a seed, that one decides. Collapsing them would make a
380
380
  * confirm a bypass.
381
381
  *
382
- * - `maxArtifacts` — enumerated seed items (one artifact each).
382
+ * **Risk only, never cardinality (Story #4856).** This carried a
383
+ * `maxArtifacts: 2` ceiling — the second surviving artifact count after Story
384
+ * #4764 retired the axis from the routing gate, and the more misleading of the
385
+ * two, because the artifacts it counted were **paths scraped from seed prose**
386
+ * rather than a measured footprint. Observed on the seed that produced Story
387
+ * #4856: a change spanning four framework modules was suggested as light off
388
+ * two scraped paths, one of which did not exist at the scraped location. A count
389
+ * of guesses is not a size signal, so the screen now keys on risk alone.
390
+ *
383
391
  * - `maxRiskHeuristicHits` — any risk-heuristic hit disqualifies: risk
384
392
  * is exactly what a light path should not carry.
385
393
  * - `maxSensitivePathClasses`— any sensitive-path class disqualifies, the
386
394
  * same taxonomy close applies to a landed diff.
387
395
  */
388
396
  const DELIVER_LIGHT_SUGGESTION_CEILINGS = Object.freeze({
389
- maxArtifacts: 2,
390
397
  maxRiskHeuristicHits: 0,
391
398
  maxSensitivePathClasses: 0,
392
399
  });
@@ -413,9 +420,6 @@ export function buildDeliverLightSuggestion(complexitySignals) {
413
420
  const advisory = /** @type {const} */ (true);
414
421
  const automatic = /** @type {const} */ (false);
415
422
  const s = complexitySignals ?? {};
416
- const artifactCount = Number.isInteger(s.artifactCount)
417
- ? s.artifactCount
418
- : Number.POSITIVE_INFINITY;
419
423
  const riskHits = Array.isArray(s.riskHeuristicHits)
420
424
  ? s.riskHeuristicHits.length
421
425
  : Number.POSITIVE_INFINITY;
@@ -424,11 +428,6 @@ export function buildDeliverLightSuggestion(complexitySignals) {
424
428
  : Number.POSITIVE_INFINITY;
425
429
 
426
430
  const reasons = [];
427
- if (artifactCount > ceilings.maxArtifacts) {
428
- reasons.push(
429
- `seed enumerates ${artifactCount} artifacts (> ${ceilings.maxArtifacts})`,
430
- );
431
- }
432
431
  if (riskHits > ceilings.maxRiskHeuristicHits) {
433
432
  reasons.push(`seed hits ${riskHits} risk-heuristic phrase(s)`);
434
433
  }
@@ -446,9 +445,9 @@ export function buildDeliverLightSuggestion(complexitySignals) {
446
445
  ceilings,
447
446
  reasons: suggested
448
447
  ? [
449
- `seed fits the light-path ceilings (≤${ceilings.maxArtifacts} artifacts, ` +
450
- 'no risk-heuristic hits, no sensitive-path classes) — the operator ' +
451
- 'may prefer /deliver for this scope',
448
+ 'seed carries no risk signal (no risk-heuristic hits, no ' +
449
+ 'sensitive-path classes) — the operator may prefer /deliver for ' +
450
+ "this scope; the light path's own gate and diff backstop decide size",
452
451
  ]
453
452
  : reasons,
454
453
  };
@@ -451,6 +451,7 @@ export async function runPlanPersist({
451
451
  stories: rawStories = null,
452
452
  techSpecContent = null,
453
453
  planAcceptance = null,
454
+ planContextEnvelope = null,
454
455
  } = artifacts ?? {};
455
456
  const {
456
457
  forceReview = false,
@@ -538,6 +539,12 @@ export async function runPlanPersist({
538
539
  sharedSpec: techSpecContent,
539
540
  planAcceptance: planAcceptance ?? undefined,
540
541
  sourceTicketIds,
542
+ // The seed this plan was authored from is the provenance source: an audit
543
+ // sweep's Single-plan seed carries the `audit-fingerprints` /
544
+ // `audit-semantic-keys` footers, which assembly copies into every persisted
545
+ // Story body so the next sweep recognises what it already planned
546
+ // (Story #4877). Empty for a `--tickets` run, which is a no-op.
547
+ provenanceSource: planContextEnvelope?.seed?.content ?? '',
541
548
  });
542
549
 
543
550
  // Effective complexity route (Story #4722): the planner's authored lite
@@ -17,6 +17,7 @@
17
17
 
18
18
  import { createHash } from 'node:crypto';
19
19
  import { applyBlockedByDependencies } from '../../../providers/github/blocked-by-add.js';
20
+ import { carryProvenanceFooters } from '../../findings/route-finding.js';
20
21
  import { Logger } from '../../Logger.js';
21
22
  import { AGENT_LABELS, TYPE_LABELS } from '../../label-constants.js';
22
23
  import {
@@ -392,7 +393,18 @@ function assembleOnePlanStory(ticket, opts) {
392
393
  });
393
394
  // Body first: the fingerprint is an identity over the *assembled* content,
394
395
  // so it cannot be computed until that content exists.
395
- const body = serializeStoryBody({ ...folded, depends_on });
396
+ const serialized = serializeStoryBody({ ...folded, depends_on });
397
+ // Carry audit dedup provenance out of the seed this plan was authored from
398
+ // (Story #4877). The audit sweep's Single-plan path stamps the
399
+ // `audit-fingerprints` / `audit-semantic-keys` footers into the seed it hands
400
+ // `/plan`; without this the persisted Story carries no provenance and the
401
+ // next sweep re-files work it already planned. Mechanical on purpose — the
402
+ // authoring agent is not asked to notice HTML comments in a one-pager. A
403
+ // non-audit seed carries no footers, so this is a no-op there.
404
+ const { body } = carryProvenanceFooters({
405
+ from: opts.provenanceSource ?? '',
406
+ into: serialized,
407
+ });
396
408
  const fingerprint = planStoryFingerprint({ slug, title, body });
397
409
  return {
398
410
  story: {
@@ -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,
@@ -363,7 +364,8 @@ function formatUnlandedFriction({
363
364
  const remedy =
364
365
  blockClass === 'checks-failed'
365
366
  ? `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` +
367
+ `the red disarms auto-merge, and only a green on a new head SHA re-arms it — ` +
368
+ `re-running the failed job is forbidden. Watch the checks with:\n\n` +
367
369
  `\`\`\`bash\n${NEXT_COMMANDS.watchCi(storyId, prNumber)}\n\`\`\``
368
370
  : `Resolve the underlying condition (branch protection, required checks, ` +
369
371
  `or a manual merge), then resume the land:\n\n` +
@@ -851,7 +853,20 @@ export async function runConfirmMergePhase({
851
853
  `cumulative budget=${maxBudgetSeconds}s)...`,
852
854
  );
853
855
 
854
- while (true) {
856
+ /**
857
+ * One poll iteration. Returns `{ done: false }` to keep polling, or
858
+ * `{ done: true, outcome }` with the phase's terminal. Story #4873 lifted
859
+ * this body out of a bespoke unbounded loop so the cadence is owned by the
860
+ * shared {@link pollUntil} primitive — the loop below sleeps, aborts, and
861
+ * counts ticks in exactly one place for every wait in the codebase. Every
862
+ * budget, floor, and classification decision is unchanged; only who owns the
863
+ * `await sleep(...)` moved.
864
+ *
865
+ * A throw from any of the terminal handlers is captured rather than allowed
866
+ * to escape into `pollUntil` (which treats a throwing `fn` as a non-match
867
+ * and would spin forever on it); the caller re-throws it after the loop.
868
+ */
869
+ async function runMergePoll() {
855
870
  const probe = await readPrWaitProbeFn({
856
871
  prNumber,
857
872
  gh: injectedGh,
@@ -875,26 +890,44 @@ export async function runConfirmMergePhase({
875
890
  maxBudgetSeconds,
876
891
  };
877
892
 
893
+ // Heartbeat (Story #4873). A backgrounded close writes this phase's
894
+ // progress to its own output file, and between the opening banner and the
895
+ // terminal there used to be NOTHING for minutes at a time — so an
896
+ // orchestrator watching that file could not tell a healthy in-flight wait
897
+ // from a wedged process without going back to GitHub itself. One line per
898
+ // poll makes the file's own growth the liveness signal.
899
+ progress?.(
900
+ 'CONFIRM',
901
+ `⏱ poll ${polls}: PR #${prNumber} state=${probe.state ?? 'unknown'} ` +
902
+ `checks=${probe.checksStatus ?? 'unknown'} ` +
903
+ `mergeState=${probe.mergeStateStatus ?? 'unknown'} ` +
904
+ `(${waitBudget.waitedSeconds}s of ${maxWaitSeconds}s this invocation; ` +
905
+ `${waitBudget.cumulativeSeconds}s of ${maxBudgetSeconds}s cumulative)` +
906
+ (probe.error ? ` — probe error: ${probe.error}` : ''),
907
+ );
908
+
878
909
  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
- });
910
+ return doneWith(
911
+ await onMergeObserved({
912
+ storyId,
913
+ storyBranch,
914
+ baseBranch,
915
+ prNumber,
916
+ prUrl,
917
+ cwd,
918
+ config,
919
+ provider,
920
+ progress,
921
+ injectedGh,
922
+ injectedNotify,
923
+ readPrMergeStateFn,
924
+ confirmStoryMergedFn,
925
+ runPostLandTailFn,
926
+ emitMergeFlipFailedFn,
927
+ prProbe: probe,
928
+ elapsedSeconds: Math.round(waitedMs / 1000),
929
+ }),
930
+ );
898
931
  }
899
932
 
900
933
  // Everything below funnels into ONE terminal exit (Story #4710): each
@@ -987,16 +1020,18 @@ export async function runConfirmMergePhase({
987
1020
  }
988
1021
 
989
1022
  if (unlanded) {
990
- return blockOnUnlanded({
991
- storyId,
992
- prNumber,
993
- prUrl,
994
- ...unlanded,
995
- provider,
996
- progress,
997
- classifyMergeBlockFn,
998
- emitMergeUnlandedFn,
999
- });
1023
+ return doneWith(
1024
+ await blockOnUnlanded({
1025
+ storyId,
1026
+ prNumber,
1027
+ prUrl,
1028
+ ...unlanded,
1029
+ provider,
1030
+ progress,
1031
+ classifyMergeBlockFn,
1032
+ emitMergeUnlandedFn,
1033
+ }),
1034
+ );
1000
1035
  }
1001
1036
 
1002
1037
  // This invocation's bound expired → PENDING. Deliberately NOT a block:
@@ -1011,16 +1046,43 @@ export async function runConfirmMergePhase({
1011
1046
  `${waitBudget.cumulativeSeconds}s of ${maxBudgetSeconds}s cumulative). PR #${prNumber} still in flight ` +
1012
1047
  `(checks=${probe.checksStatus ?? 'unknown'}). Story stays at agent::closing — resumable.`,
1013
1048
  );
1014
- return {
1049
+ return doneWith({
1015
1050
  confirmed: false,
1016
1051
  terminal: 'pending',
1017
1052
  reason: `merge wait bound reached with the PR still in flight (checks=${probe.checksStatus ?? 'unknown'})`,
1018
1053
  prProbe: probe,
1019
1054
  waitBudget,
1020
1055
  elapsedSeconds: waitBudget.waitedSeconds,
1021
- };
1056
+ });
1022
1057
  }
1023
1058
 
1024
- await sleepFn(intervalMs);
1059
+ return { done: false };
1025
1060
  }
1061
+
1062
+ const tick = await pollUntil({
1063
+ fn: async () => {
1064
+ try {
1065
+ return await runMergePoll();
1066
+ } catch (err) {
1067
+ // A terminal handler threw. `pollUntil` treats a throwing `fn` as a
1068
+ // non-match and would poll forever on it, so the throw is carried out
1069
+ // as a match and re-raised below.
1070
+ return { done: true, thrown: err };
1071
+ }
1072
+ },
1073
+ predicate: (result) => result?.done === true,
1074
+ intervalMs,
1075
+ // The wait owns its own bounds (`maxWaitSeconds` → `pending`,
1076
+ // `maxBudgetSeconds` → blocked), and both are decided from the probe
1077
+ // inside the tick. A second, cruder wall-clock timeout here would throw
1078
+ // past those classifications.
1079
+ sleepFn: (ms) => sleepFn(ms),
1080
+ });
1081
+ if (tick.thrown) throw tick.thrown;
1082
+ return tick.outcome;
1083
+ }
1084
+
1085
+ /** Wrap a phase terminal as the poll loop's match. */
1086
+ function doneWith(outcome) {
1087
+ return { done: true, outcome };
1026
1088
  }