mandrel 2.4.0 → 2.6.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 (70) hide show
  1. package/.agents/audit-checklists/accessibility.md +29 -0
  2. package/.agents/audit-checklists/architecture.md +4 -5
  3. package/.agents/audit-checklists/clean-code.md +10 -0
  4. package/.agents/audit-checklists/data-model.md +22 -0
  5. package/.agents/audit-checklists/dependencies.md +11 -2
  6. package/.agents/audit-checklists/devops.md +4 -0
  7. package/.agents/audit-checklists/navigability.md +3 -0
  8. package/.agents/audit-checklists/performance.md +8 -11
  9. package/.agents/audit-checklists/privacy.md +3 -4
  10. package/.agents/audit-checklists/quality.md +2 -0
  11. package/.agents/audit-checklists/security.md +4 -5
  12. package/.agents/audit-checklists/seo.md +7 -1
  13. package/.agents/audit-checklists/sre.md +14 -12
  14. package/.agents/audit-checklists/ux-ui.md +4 -0
  15. package/.agents/docs/configuration.md +3 -0
  16. package/.agents/docs/workflows.md +4 -3
  17. package/.agents/schemas/agentrc.schema.json +17 -0
  18. package/.agents/schemas/audit-rules.json +134 -19
  19. package/.agents/schemas/audit-rules.schema.json +6 -2
  20. package/.agents/scripts/audit-labels-bootstrap.js +4 -4
  21. package/.agents/scripts/audit-to-stories.js +244 -19
  22. package/.agents/scripts/lib/audit-suite/checklist-threading.js +26 -3
  23. package/.agents/scripts/lib/audit-suite/dispatch-checklist.js +132 -0
  24. package/.agents/scripts/lib/audit-suite/index.js +1 -0
  25. package/.agents/scripts/lib/audit-suite/selector.js +290 -14
  26. package/.agents/scripts/lib/audit-to-stories/audit-lenses.js +2 -1
  27. package/.agents/scripts/lib/audit-to-stories/build-story-body.js +5 -1
  28. package/.agents/scripts/lib/audit-to-stories/dedupe-against-github.js +23 -3
  29. package/.agents/scripts/lib/audit-to-stories/finding-adapter.js +38 -0
  30. package/.agents/scripts/lib/audit-to-stories/ledger.js +256 -0
  31. package/.agents/scripts/lib/audit-to-stories/parse-audit-md.js +41 -7
  32. package/.agents/scripts/lib/audit-to-stories/seed-from-findings.js +20 -2
  33. package/.agents/scripts/lib/command-header.js +1 -1
  34. package/.agents/scripts/lib/config-settings-schema-delivery.js +21 -0
  35. package/.agents/scripts/lib/dynamic-workflow/performance-report-contract.js +5 -3
  36. package/.agents/scripts/lib/feedback-loop/audit-results-graduator.js +56 -0
  37. package/.agents/scripts/lib/findings/route-finding.js +108 -10
  38. package/.agents/scripts/lib/observability/runtime-friction.js +137 -6
  39. package/.agents/scripts/lib/orchestration/retro-proposals.js +0 -0
  40. package/.agents/scripts/lib/orchestration/run-epilogue.js +5 -19
  41. package/.agents/scripts/lib/orchestration/single-story-close/phases/post-land.js +11 -0
  42. package/.agents/scripts/lib/orchestration/story-close/phases/local-lens-review.js +81 -1
  43. package/.agents/scripts/lib/orchestration/story-close/phases/review-core.js +1 -0
  44. package/.agents/scripts/lib/orchestration/story-follow-ups.js +53 -13
  45. package/.agents/scripts/nav-registry-diff.js +449 -0
  46. package/.agents/workflows/audit-accessibility.md +243 -0
  47. package/.agents/workflows/audit-architecture.md +89 -71
  48. package/.agents/workflows/audit-clean-code.md +87 -53
  49. package/.agents/workflows/audit-data-model.md +198 -0
  50. package/.agents/workflows/audit-dependencies.md +143 -28
  51. package/.agents/workflows/audit-devops.md +109 -18
  52. package/.agents/workflows/audit-documentation.md +25 -53
  53. package/.agents/workflows/audit-navigability.md +78 -22
  54. package/.agents/workflows/audit-performance.md +207 -103
  55. package/.agents/workflows/audit-privacy.md +51 -13
  56. package/.agents/workflows/audit-quality.md +71 -61
  57. package/.agents/workflows/audit-security.md +94 -71
  58. package/.agents/workflows/audit-seo.md +80 -25
  59. package/.agents/workflows/audit-sre.md +99 -66
  60. package/.agents/workflows/audit-to-stories.md +44 -5
  61. package/.agents/workflows/audit-ux-ui.md +71 -17
  62. package/.agents/workflows/helpers/audit-dual-path.md +59 -0
  63. package/.agents/workflows/helpers/audit-self-check.md +70 -0
  64. package/.agents/workflows/helpers/audit-severity-scale.md +19 -0
  65. package/.agents/workflows/helpers/deliver-story.md +25 -0
  66. package/docs/CHANGELOG.md +23 -0
  67. package/package.json +1 -1
  68. package/.agents/audit-checklists/lighthouse.md +0 -15
  69. package/.agents/schemas/audit-results.schema.json +0 -69
  70. package/.agents/workflows/audit-lighthouse.md +0 -269
@@ -32,7 +32,12 @@ import crypto from 'node:crypto';
32
32
 
33
33
  const SEP = '␟'; // unit separator — keeps fingerprint fields unambiguous
34
34
  const MARKER = 'audit-fingerprints:';
35
+ const SEMANTIC_MARKER = 'audit-semantic-keys:';
35
36
  const SHA1_RE = /^[0-9a-f]{40}$/;
37
+ // A semantic key round-trips through a comma-joined footer, so it must not
38
+ // carry a comma or a `>` (which would truncate the HTML comment). Both are
39
+ // stripped when the key is built, so this guard is defence-in-depth.
40
+ const SEMANTIC_KEY_RE = /^[^,>]+$/;
36
41
 
37
42
  /**
38
43
  * Normalise a single scalar identity field to a stable string.
@@ -92,6 +97,67 @@ export function fingerprintFinding(finding) {
92
97
  return { short: full.slice(0, 12), full, components };
93
98
  }
94
99
 
100
+ /**
101
+ * Compute the **location-based semantic key** for a finding. Unlike the
102
+ * fingerprint (which folds in the title, so any prose rewording mints a fresh
103
+ * sha), the semantic key is stable across a reworded title and a re-severitied
104
+ * finding: it is derived solely from the finding's identity *location* —
105
+ * `area` (the audit dimension) plus `primaryFile`. Two scans that describe the
106
+ * same problem at the same location produce the same semantic key even when
107
+ * their titles diverge, so a reworded finding still confirms against the Issue
108
+ * that already tracks that location.
109
+ *
110
+ * Returns the empty string when the location is unknown (no `area` and no
111
+ * `primaryFile`) — an empty key never confirms a match, exactly as an absent
112
+ * fingerprint footer never does.
113
+ *
114
+ * @param {object} finding — canonical finding ({ area, primaryFile, ... }).
115
+ * @returns {string}
116
+ */
117
+ export function semanticKeyFor(finding) {
118
+ const area = normaliseField(finding?.area);
119
+ const primaryFile = normaliseField(finding?.primaryFile);
120
+ if (!area && !primaryFile) return '';
121
+ const key = `${area}${SEP}${primaryFile}`;
122
+ return SEMANTIC_KEY_RE.test(key) ? key : key.replace(/[,>]/g, ' ').trim();
123
+ }
124
+
125
+ /**
126
+ * Render the machine-readable semantic-key footer for one or more keys
127
+ * (`<!-- audit-semantic-keys: key,key,... -->`). Stamped alongside the
128
+ * fingerprint footer by the audit filers so a later reworded finding can
129
+ * confirm identity by location when its fingerprint has drifted. Round-trips
130
+ * through {@link parseSemanticKeyFooter}. Empty keys are dropped.
131
+ *
132
+ * @param {string | string[]} keys — one semantic key or an array of them.
133
+ * @returns {string}
134
+ */
135
+ export function semanticKeyFooter(keys) {
136
+ const list = (Array.isArray(keys) ? keys : [keys])
137
+ .filter((k) => typeof k === 'string' && k.length > 0)
138
+ .map((k) => k.replace(/[,>]/g, ' ').trim())
139
+ .filter((k) => k.length > 0);
140
+ return `<!-- ${SEMANTIC_MARKER} ${list.join(',')} -->`;
141
+ }
142
+
143
+ /**
144
+ * Extract semantic keys from an Issue body carrying the semantic-key footer.
145
+ * Internal — the audit filers stamp the footer via {@link semanticKeyFooter};
146
+ * only the confirmation path here reads it back.
147
+ *
148
+ * @param {string} body
149
+ * @returns {string[]}
150
+ */
151
+ function parseSemanticKeyFooter(body) {
152
+ if (typeof body !== 'string') return [];
153
+ const match = body.match(/<!--\s*audit-semantic-keys:\s*([^>]*?)\s*-->/);
154
+ if (!match) return [];
155
+ return match[1]
156
+ .split(',')
157
+ .map((s) => s.trim())
158
+ .filter((s) => s.length > 0);
159
+ }
160
+
95
161
  /**
96
162
  * Render the machine-readable fingerprint footer for one or more shas.
97
163
  *
@@ -147,6 +213,21 @@ function issueCarriesFingerprint(issue, sha) {
147
213
  return parseFingerprintFooter(issue.body).includes(sha);
148
214
  }
149
215
 
216
+ /**
217
+ * Confirm an issue body's footer carries the target semantic key. Unlike
218
+ * {@link issueCarriesFingerprint}, this is strict on a missing body — a
219
+ * location match is only meaningful when the issue actually carries a
220
+ * semantic-key footer to compare against.
221
+ *
222
+ * @param {{ body?: string }} issue
223
+ * @param {string} key
224
+ * @returns {boolean}
225
+ */
226
+ function issueCarriesSemanticKey(issue, key) {
227
+ if (!key || typeof issue?.body !== 'string') return false;
228
+ return parseSemanticKeyFooter(issue.body).includes(key);
229
+ }
230
+
150
231
  /**
151
232
  * Decide the route decision from a confirmed matched issue's state.
152
233
  * @param {{ state?: string }} issue
@@ -194,23 +275,29 @@ function decideFromConfirmed(confirmed, sha) {
194
275
  }
195
276
 
196
277
  /**
197
- * Keep only the issue records that have the right wire shape AND carry the
198
- * target fingerprint in their footer. A semantic candidate that merely *looks*
199
- * similar but does not carry the sha is dropped here — semantic similarity
200
- * widens the net; the fingerprint footer is what confirms identity.
278
+ * Keep only the issue records that have the right wire shape AND carry a
279
+ * confirming footer. Confirmation is by the exact **fingerprint** footer and,
280
+ * when a `semanticKey` is supplied (audit dedup opts in via
281
+ * `options.semanticKeyConfirm`), ALSO by the location-based **semantic-key**
282
+ * footer. A semantic candidate that merely *looks* similar but carries neither
283
+ * footer is dropped here — semantic similarity widens the net; a deterministic
284
+ * footer (fingerprint or semantic key) is what confirms identity. The semantic
285
+ * key catches a reworded finding whose fingerprint has drifted but whose
286
+ * location is unchanged.
201
287
  *
202
288
  * @param {Array<unknown>} hits
203
- * @param {string} sha
289
+ * @param {{ sha: string, semanticKey?: string }} identity
204
290
  * @returns {Array<{ number: number, state: string }>}
205
291
  */
206
- function confirmFingerprint(hits, sha) {
292
+ function confirmCandidates(hits, { sha, semanticKey = '' }) {
207
293
  if (!Array.isArray(hits)) return [];
208
294
  return hits.filter(
209
295
  (h) =>
210
296
  h &&
211
297
  typeof h.number === 'number' &&
212
298
  typeof h.state === 'string' &&
213
- issueCarriesFingerprint(h, sha),
299
+ (issueCarriesFingerprint(h, sha) ||
300
+ issueCarriesSemanticKey(h, semanticKey)),
214
301
  );
215
302
  }
216
303
 
@@ -245,11 +332,18 @@ function confirmFingerprint(hits, sha) {
245
332
  * Meaning-first candidate search over open+closed issues (and Epic
246
333
  * sub-issues). When supplied, runs FIRST; its candidates are then
247
334
  * fingerprint-confirmed.
335
+ * @param {object} [options]
336
+ * @param {boolean} [options.semanticKeyConfirm=false] — also confirm a
337
+ * candidate by the location-based semantic-key footer, not the fingerprint
338
+ * alone. Opt-in so the audit dedup path catches a reworded finding at an
339
+ * unchanged location while the qa-explore path (which does not stamp
340
+ * semantic-key footers) stays fingerprint-exact and byte-identical.
248
341
  * @returns {Promise<{ decision: 'new'|'update-existing'|'duplicate'|'regression-of-closed', matchedIssue: object|null, fingerprint: string }>}
249
342
  */
250
343
  export async function routeFinding(
251
344
  finding,
252
345
  { searchIssues, searchCandidates } = {},
346
+ options = {},
253
347
  ) {
254
348
  if (
255
349
  typeof searchCandidates !== 'function' &&
@@ -261,6 +355,7 @@ export async function routeFinding(
261
355
  }
262
356
 
263
357
  const { full: sha } = fingerprintFinding(finding);
358
+ const semanticKey = options.semanticKeyConfirm ? semanticKeyFor(finding) : '';
264
359
 
265
360
  // Stage 1: semantic candidate pass first (when wired); else fingerprint
266
361
  // lookup. Both yield a candidate pool drawn from open AND closed issues.
@@ -269,15 +364,18 @@ export async function routeFinding(
269
364
  ? await searchCandidates(finding)
270
365
  : await searchIssues(sha);
271
366
 
272
- // Stage 2: confirm identity by fingerprint footer over the candidate pool.
273
- const confirmed = confirmFingerprint(hits, sha);
367
+ // Stage 2: confirm identity by fingerprint footer (and, when opted in, the
368
+ // location-based semantic-key footer) over the candidate pool.
369
+ const confirmed = confirmCandidates(hits, { sha, semanticKey });
274
370
 
275
371
  return decideFromConfirmed(confirmed, sha);
276
372
  }
277
373
 
278
374
  export const __testing = {
279
375
  MARKER,
376
+ SEMANTIC_MARKER,
280
377
  SEP,
281
- confirmFingerprint,
378
+ confirmCandidates,
282
379
  decideFromConfirmed,
380
+ issueCarriesSemanticKey,
283
381
  };
@@ -45,7 +45,7 @@
45
45
  import crypto from 'node:crypto';
46
46
 
47
47
  import { Logger } from '../Logger.js';
48
- import { appendSignal } from './signals-writer.js';
48
+ import { appendSignal, forEachLine } from './signals-writer.js';
49
49
 
50
50
  /**
51
51
  * The friction categories this module emits.
@@ -202,19 +202,150 @@ export async function emitBlockRecoveredFriction({
202
202
  }
203
203
 
204
204
  /**
205
- * Pure predicate: is this signal a recovery-marked `story-blocked` record?
205
+ * Emit the recovery counterpart of a `close-failed` record when a Story's
206
+ * close ultimately lands (Story #4649).
207
+ *
208
+ * A close that fails once — CI/lease contention, a transient GitHub fault —
209
+ * and succeeds on a later attempt still fired a `close-failed` record at the
210
+ * failed terminal, which the composer counts exactly like a close that never
211
+ * recovered. This is the `close-failed` analogue of
212
+ * {@link emitBlockRecoveredFriction}: same `recovered: true` discriminator,
213
+ * same category (a distinct bucket would itself aggregate into a routable
214
+ * proposal, re-introducing the noise).
215
+ *
216
+ * **Why this is not emitted from `frictionForTerminal`.** A `landed` terminal
217
+ * envelope is emitted at the very END of close — *after* the post-land tail
218
+ * has already gathered the signal stream and filed its follow-ups. A marker
219
+ * written there would arrive too late to net anything out of the run that
220
+ * produced it. So the emit hangs off `runPostLandTail`, which is the single
221
+ * shared land point (reached from both the in-close land and the standalone
222
+ * `single-story-confirm-merge.js` resume) and runs BEFORE follow-up capture.
223
+ *
224
+ * **Conditional on an actual failure.** The marker is appended only when the
225
+ * Story's stream already carries an un-recovered `close-failed`. Emitting
226
+ * unconditionally on every land would write a `close-failed` row for Stories
227
+ * whose close never failed — a category-mislabelled record — and, because the
228
+ * netting is per `(category, storyId)` over the cumulative stream, that
229
+ * spurious marker would suppress the Story's `close-failed` bucket outright,
230
+ * making the category un-routable at story scope for a Story that never had
231
+ * a close failure at all.
232
+ *
233
+ * What this guard does NOT do is bound the netting once a *legitimate* marker
234
+ * exists. The netting inherits the Story #4622 coarsening — per
235
+ * `(category, storyId)` across the whole stream, not 1:1 pairing — so a
236
+ * later, genuinely un-landed `close-failed` for a Story that already
237
+ * recovered once is still netted away, and does not even reach `discarded`.
238
+ * Reaching that needs a re-close after a land (a confirm-merge resume, or a
239
+ * close after a revert). Deliberate, inherited, and called out here rather
240
+ * than papered over: an aggregate is a routing heuristic, not an incident
241
+ * ledger.
242
+ *
243
+ * Best-effort; never throws. A read failure yields no marker (the failure
244
+ * stays counted) rather than a speculative write.
245
+ *
246
+ * @param {object} args
247
+ * @param {number} args.storyId
248
+ * @param {object} [args.config]
249
+ * @returns {Promise<boolean>} true when a record was appended.
250
+ */
251
+ export async function emitCloseRecoveredFriction({ storyId, config } = {}) {
252
+ const sid = positiveIntOrNull(storyId);
253
+ if (sid === null) return false;
254
+
255
+ let failed = false;
256
+ let recovered = false;
257
+ try {
258
+ await forEachLine(
259
+ null,
260
+ sid,
261
+ (parsed) => {
262
+ if (!parsed || typeof parsed !== 'object') return;
263
+ if (parsed.category !== RUNTIME_FRICTION_CATEGORIES.CLOSE_FAILED) {
264
+ return;
265
+ }
266
+ if (isRecoveredSignal(parsed)) recovered = true;
267
+ else failed = true;
268
+ },
269
+ config,
270
+ );
271
+ } catch (err) {
272
+ Logger.warn(
273
+ `[runtime-friction] close-recovery probe failed for Story #${sid}: ${
274
+ err instanceof Error ? err.message : String(err)
275
+ }`,
276
+ );
277
+ return false;
278
+ }
279
+ // Nothing to cancel, or already cancelled — a second marker would be noise.
280
+ if (!failed || recovered) return false;
281
+
282
+ return emitRuntimeFriction({
283
+ storyId: sid,
284
+ category: RUNTIME_FRICTION_CATEGORIES.CLOSE_FAILED,
285
+ tool: 'runPostLandTail',
286
+ details: { recovered: true },
287
+ config,
288
+ });
289
+ }
290
+
291
+ /**
292
+ * Normalize one raw signals-stream row into the shape the retro composer
293
+ * consumes, or `null` when the row carries no usable category.
294
+ *
295
+ * Single-homed because BOTH production gathers need it identically —
296
+ * `gatherStoryFrictionSignals` (story scope) and `executeFollowUpRollup`
297
+ * (run scope) — and the bug this exists to prevent is precisely the two of
298
+ * them drifting: they each independently flattened rows to
299
+ * `{ category, source }`, dropping the `storyId` / `details` the composer's
300
+ * recovery-netting keys on, which left that netting unreachable on real data
301
+ * while its unit tests stayed green (Story #4649).
302
+ *
303
+ * The row's own `storyId` wins over `fallbackStoryId` so a stream carrying
304
+ * foreign rows attributes each one correctly; the fallback covers records
305
+ * written before the field existed.
306
+ *
307
+ * @param {unknown} parsed One parsed NDJSON row.
308
+ * @param {number} fallbackStoryId Stream owner, used when the row has none.
309
+ * @returns {{ category: string, source: 'framework'|'consumer', storyId: number, details: object }|null}
310
+ */
311
+ export function normalizeGatheredSignal(parsed, fallbackStoryId) {
312
+ if (!parsed || typeof parsed !== 'object') return null;
313
+ const category =
314
+ typeof parsed.category === 'string' ? parsed.category.trim() : '';
315
+ if (!category) return null;
316
+ const recordStoryId = Number(parsed.storyId);
317
+ return {
318
+ category,
319
+ source: parsed.source === 'framework' ? 'framework' : 'consumer',
320
+ storyId: Number.isInteger(recordStoryId) ? recordStoryId : fallbackStoryId,
321
+ details:
322
+ parsed.details && typeof parsed.details === 'object'
323
+ ? parsed.details
324
+ : {},
325
+ };
326
+ }
327
+
328
+ /**
329
+ * Pure predicate: is this signal a recovery marker for its own category?
206
330
  * Shared with the retro composer so the "recovered" discriminator is read
207
- * from one place. A record is a recovery marker when its category is
208
- * `story-blocked` and `details.recovered === true`.
331
+ * from one place.
332
+ *
333
+ * **Category-agnostic by design (Story #4649).** The predicate used to hard-
334
+ * code `story-blocked`, which meant every new category needing recovery
335
+ * semantics had to re-implement the netting. A record is a recovery marker
336
+ * when it carries a usable `category` and `details.recovered === true`; the
337
+ * composer nets per `(category, storyId)`, so a marker can only ever cancel
338
+ * records in its OWN bucket.
209
339
  *
210
340
  * @param {object} signal
211
341
  * @returns {boolean}
212
342
  */
213
- export function isRecoveredBlockSignal(signal) {
343
+ export function isRecoveredSignal(signal) {
214
344
  return (
215
345
  signal !== null &&
216
346
  typeof signal === 'object' &&
217
- signal.category === RUNTIME_FRICTION_CATEGORIES.STORY_BLOCKED &&
347
+ typeof signal.category === 'string' &&
348
+ signal.category.trim() !== '' &&
218
349
  signal.details !== null &&
219
350
  typeof signal.details === 'object' &&
220
351
  signal.details.recovered === true
@@ -19,10 +19,10 @@ import { selectAudits } from '../audit-suite/index.js';
19
19
  import { graduateRetroProposals } from '../feedback-loop/retro-proposals-graduator.js';
20
20
  import { gitSpawn } from '../git-utils.js';
21
21
  import { Logger } from '../Logger.js';
22
- import { forEachLine } from '../observability/signals-writer.js';
23
22
  import { composeRoutedProposals } from './retro-proposals.js';
24
23
  import {
25
24
  buildFollowUpsCommentBody,
25
+ gatherRunFrictionSignals,
26
26
  resolveFollowUpRepos,
27
27
  } from './story-follow-ups.js';
28
28
  import { upsertStructuredComment } from './ticketing.js';
@@ -551,24 +551,10 @@ async function executeFollowUpRollup({
551
551
  config,
552
552
  cwd,
553
553
  }) {
554
- const signals = [];
555
- for (const raw of stories) {
556
- const sid = Number(raw);
557
- if (!Number.isInteger(sid) || sid <= 0) continue;
558
- await forEachLine(
559
- null,
560
- sid,
561
- (parsed) => {
562
- if (!parsed || typeof parsed !== 'object') return;
563
- const category =
564
- typeof parsed.category === 'string' ? parsed.category.trim() : '';
565
- if (!category) return;
566
- const source = parsed.source === 'framework' ? 'framework' : 'consumer';
567
- signals.push({ category, source });
568
- },
569
- config,
570
- );
571
- }
554
+ // Shared with the story-scoped gather (Story #4649): `storyId` + `details`
555
+ // are what the composer's recovery-netting keys on, and two hand-rolled
556
+ // copies of this loop are how they got dropped in the first place.
557
+ const signals = await gatherRunFrictionSignals(stories, config);
572
558
  const repos = resolveFollowUpRepos(config);
573
559
  const primaryId = Number(stories[0]);
574
560
  const proposals = composeRoutedProposals({
@@ -33,6 +33,7 @@ import path from 'node:path';
33
33
 
34
34
  import { gitSpawn as defaultGitSpawn } from '../../../git-utils.js';
35
35
  import { Logger } from '../../../Logger.js';
36
+ import { emitCloseRecoveredFriction as defaultEmitCloseRecoveredFriction } from '../../../observability/runtime-friction.js';
36
37
  import { acquireLockWithWait as defaultAcquireLockWithWait } from '../../../single-story-sweep/sweep-lock.js';
37
38
  import {
38
39
  executeFastForward as defaultExecuteFastForward,
@@ -250,6 +251,7 @@ async function stepBaseFastForward({
250
251
  * @param {object} [args.config]
251
252
  * @param {(tag: string, msg: string) => void} [args.progress]
252
253
  * @param {Function} [args.captureStoryFollowUpsFn] Test seam.
254
+ * @param {Function} [args.emitCloseRecoveredFrictionFn] Test seam.
253
255
  * @param {Function} [args.reassertStatusColumnFn] Test seam.
254
256
  * @param {Function} [args.gitSpawnFn] Test seam.
255
257
  * @param {Function} [args.planFastForwardFn] Test seam.
@@ -266,6 +268,7 @@ export async function runPostLandTail({
266
268
  config,
267
269
  progress,
268
270
  captureStoryFollowUpsFn = defaultCaptureStoryFollowUps,
271
+ emitCloseRecoveredFrictionFn = defaultEmitCloseRecoveredFriction,
269
272
  reassertStatusColumnFn = defaultReassertStatusColumn,
270
273
  gitSpawnFn = defaultGitSpawn,
271
274
  planFastForwardFn = defaultPlanFastForward,
@@ -274,6 +277,14 @@ export async function runPostLandTail({
274
277
  }) {
275
278
  progress?.('POST-LAND', `🧾 Running land tail for Story #${storyId}...`);
276
279
 
280
+ // Story #4649 — the close landed, so any earlier `close-failed` for this
281
+ // Story was transient. Emit the recovery marker BEFORE follow-up capture
282
+ // reads the stream: the `landed` terminal envelope is emitted after this
283
+ // whole tail, so a marker written there would arrive too late to net
284
+ // anything out of the very run that produced the failure. Best-effort and
285
+ // never throws, exactly like every other tail step.
286
+ await emitCloseRecoveredFrictionFn({ storyId, config });
287
+
277
288
  const followUps = await step(
278
289
  () =>
279
290
  stepFollowUps({
@@ -30,6 +30,58 @@ import { computeChangeSet } from '../../change-set.js';
30
30
  */
31
31
  const STORY_SCOPE_LENS_DEPTH = 'light';
32
32
 
33
+ /**
34
+ * Render the host-MUST-walk roster of materialized lens-prompt artifacts
35
+ * (Story #4627). The Story-scope lens pass materializes each matched lens's
36
+ * substituted prompt body to a scoped artifact file; the default review
37
+ * provider is a mechanical sweep that never reads them, so the artifacts are
38
+ * inert unless the close's stdout tells the host to walk them. This mirrors
39
+ * the plan-run audit-roster comment's "host MUST walk each" contract
40
+ * (`run-epilogue.js`): the close names each artifact path and the host reads
41
+ * each one against the diff.
42
+ *
43
+ * Pure: derives the block from the `runAuditSuite` envelope's `workflows[]`,
44
+ * keeping only entries that actually wrote an artifact. Returns `null` when no
45
+ * artifact was written (nothing to walk), so the caller emits nothing.
46
+ *
47
+ * Module-local: an implementation detail of {@link runLocalLensReview}, whose
48
+ * host-MUST-walk output rides out on the `progress` stream. Exercised through
49
+ * that public entry point (assert the progress lines name each artifact path)
50
+ * rather than imported directly, so it adds no production-dead public export.
51
+ *
52
+ * @param {object|null} materialized the `runAuditSuite` result envelope.
53
+ * @returns {string|null} the roster block, or `null` when there is nothing to walk.
54
+ */
55
+ function renderLensArtifactRoster(materialized) {
56
+ const paths = (materialized?.workflows ?? [])
57
+ .map((w) => w?.artifactPath)
58
+ .filter((p) => typeof p === 'string' && p.length > 0);
59
+ if (paths.length === 0) return null;
60
+ return [
61
+ `Lens prompts materialized (host MUST read/walk each against the Story diff):`,
62
+ ...paths.map((p) => ` - ${p}`),
63
+ ].join('\n');
64
+ }
65
+
66
+ /**
67
+ * Build the `runAuditSuite` substitutions for the Story-scope lens pass
68
+ * (Story #4627). Resolves the `{{changedFiles}}` token from the actual Story
69
+ * diff (newline-joined, the shape the lens templates' `## Scope` block reads)
70
+ * and the `{{ticketId}}` token from the Story id when known. Both are built-in
71
+ * substitution keys (`substitutions.js#BUILT_IN_SUBSTITUTION_KEYS`), so the
72
+ * runner accepts them without a per-lens `substitutionKeys` declaration.
73
+ *
74
+ * @param {{ changedFiles: string[], storyId?: number|string|null }} args
75
+ * @returns {Record<string, string>}
76
+ */
77
+ function buildLensSubstitutions({ changedFiles, storyId }) {
78
+ const substitutions = { changedFiles: changedFiles.join('\n') };
79
+ if (storyId != null && `${storyId}`.length > 0) {
80
+ substitutions.ticketId = String(storyId);
81
+ }
82
+ return substitutions;
83
+ }
84
+
33
85
  /**
34
86
  * Enumerate the files changed in the `baseRef...headRef` diff. Thin adapter over
35
87
  * the shared {@link computeChangeSet} enumerator (Story #4593) that flattens its
@@ -120,10 +172,20 @@ function resolveLensChangeSet({
120
172
  * for standalone callers that supply no list; see {@link resolveLensChangeSet}
121
173
  * for the three-state contract.
122
174
  *
175
+ * Story #4627 — the pass now delivers lens **content** to a reader. It threads
176
+ * `{{changedFiles}}` / `{{ticketId}}` substitutions and an `artifactPrefix`
177
+ * into `runAuditSuite` so each matched lens's substituted prompt body is
178
+ * written to a scoped artifact under the run's audit output dir, then emits a
179
+ * host-MUST-walk roster of those artifact paths to the close's stdout. Before
180
+ * this the default review provider dropped the materialized envelope, so the
181
+ * pass was a progress log line with no reader.
182
+ *
123
183
  * @param {{
124
184
  * baseRef: string,
125
185
  * headRef: string,
126
186
  * changedFiles?: string[]|null,
187
+ * storyId?: number|string|null,
188
+ * artifactPrefix?: string,
127
189
  * progress: (tag: string, msg: string) => void,
128
190
  * progressTag?: string,
129
191
  * gitSpawnFn?: import('../../change-set.js').GitSpawnFn,
@@ -135,12 +197,15 @@ function resolveLensChangeSet({
135
197
  * lenses: string[],
136
198
  * skipped: boolean,
137
199
  * materialized: object|null,
200
+ * artifactPaths: string[],
138
201
  * }>}
139
202
  */
140
203
  export async function runLocalLensReview({
141
204
  baseRef,
142
205
  headRef,
143
206
  changedFiles: injectedChangedFiles,
207
+ storyId,
208
+ artifactPrefix,
144
209
  progress,
145
210
  progressTag = 'CODE-REVIEW',
146
211
  gitSpawnFn = gitSpawn,
@@ -152,6 +217,7 @@ export async function runLocalLensReview({
152
217
  lenses: [],
153
218
  skipped: true,
154
219
  materialized: null,
220
+ artifactPaths: [],
155
221
  };
156
222
  try {
157
223
  const changedFiles = resolveLensChangeSet({
@@ -168,16 +234,30 @@ export async function runLocalLensReview({
168
234
  );
169
235
  return empty;
170
236
  }
171
- const materialized = await runAuditSuiteFn({ auditWorkflows: lenses });
237
+ // Scope the artifact filenames to this Story so concurrent closes on a
238
+ // shared audit output dir cannot clobber each other's prompts.
239
+ const effectivePrefix =
240
+ artifactPrefix ?? (storyId != null ? `story-${storyId}` : 'story-scope');
241
+ const materialized = await runAuditSuiteFn({
242
+ auditWorkflows: lenses,
243
+ substitutions: buildLensSubstitutions({ changedFiles, storyId }),
244
+ artifactPrefix: effectivePrefix,
245
+ });
172
246
  progress(
173
247
  progressTag,
174
248
  `Ran ${lenses.length} local lens(es) at ${STORY_SCOPE_LENS_DEPTH} depth: ${lenses.join(', ')}.`,
175
249
  );
250
+ const roster = renderLensArtifactRoster(materialized);
251
+ if (roster) progress(progressTag, roster);
252
+ const artifactPaths = (materialized?.workflows ?? [])
253
+ .map((w) => w?.artifactPath)
254
+ .filter((p) => typeof p === 'string' && p.length > 0);
176
255
  return {
177
256
  depth: STORY_SCOPE_LENS_DEPTH,
178
257
  lenses,
179
258
  skipped: false,
180
259
  materialized,
260
+ artifactPaths,
181
261
  };
182
262
  } catch (err) {
183
263
  // The lens pass is advisory: a git or materialization failure must not
@@ -110,6 +110,7 @@ export async function runStoryReviewCore({
110
110
  baseRef,
111
111
  headRef,
112
112
  changedFiles: changeSet.files,
113
+ storyId: storyIdNum,
113
114
  progress,
114
115
  progressTag,
115
116
  gitSpawnFn,
@@ -12,8 +12,12 @@
12
12
  import { graduateRetroProposals } from '../feedback-loop/retro-proposals-graduator.js';
13
13
  import { DEFAULT_FRAMEWORK_REPO } from '../github/framework-repo.js';
14
14
  import { Logger } from '../Logger.js';
15
+ import { normalizeGatheredSignal } from '../observability/runtime-friction.js';
15
16
  import { forEachLine } from '../observability/signals-writer.js';
16
- import { composeRoutedProposals } from './retro-proposals.js';
17
+ import {
18
+ composeRoutedProposals,
19
+ deriveUnresolvedBlockedEvents,
20
+ } from './retro-proposals.js';
17
21
  import { upsertStructuredComment } from './ticketing.js';
18
22
 
19
23
  export const FOLLOW_UPS_COMMENT_TYPE = 'follow-ups';
@@ -46,9 +50,23 @@ export function resolveFollowUpRepos(config) {
46
50
  }
47
51
 
48
52
  /**
53
+ * Gather the Story's friction signals for the composer.
54
+ *
55
+ * **`storyId` and `details` are load-bearing (Story #4649).** This function
56
+ * used to flatten every record to `{ category, source }`, which silently
57
+ * dropped exactly the two fields `netOutRecoveredIncidents` keys on — so the
58
+ * Story #4622 recovery-netting could never fire on real data, and every
59
+ * transient friction event survived to be auto-filed. The composer's unit
60
+ * tests passed throughout, because they fed it synthetic signals carrying
61
+ * both fields that no production path ever produced. Preserve them.
62
+ *
63
+ * The record's own `storyId` is preferred over the argument so a stream that
64
+ * carries foreign rows attributes each one correctly; the argument is the
65
+ * fallback for records written before the field existed.
66
+ *
49
67
  * @param {number} storyId
50
68
  * @param {object} [config]
51
- * @returns {Promise<Array<{ category: string, source: 'framework'|'consumer' }>>}
69
+ * @returns {Promise<Array<{ category: string, source: 'framework'|'consumer', storyId: number, details: object }>>}
52
70
  */
53
71
  export async function gatherStoryFrictionSignals(storyId, config) {
54
72
  const signals = [];
@@ -56,22 +74,40 @@ export async function gatherStoryFrictionSignals(storyId, config) {
56
74
  null,
57
75
  storyId,
58
76
  (parsed) => {
59
- if (!parsed || typeof parsed !== 'object') return;
60
- const kind = parsed.kind;
61
- if (kind !== 'friction' && kind !== undefined) {
62
- // Prefer friction records; also accept category-bearing rows.
63
- }
64
- const category =
65
- typeof parsed.category === 'string' ? parsed.category.trim() : '';
66
- if (!category) return;
67
- const source = parsed.source === 'framework' ? 'framework' : 'consumer';
68
- signals.push({ category, source });
77
+ const signal = normalizeGatheredSignal(parsed, storyId);
78
+ if (signal) signals.push(signal);
69
79
  },
70
80
  config,
71
81
  );
72
82
  return signals;
73
83
  }
74
84
 
85
+ /**
86
+ * Gather friction signals across every Story in a run, for the run-scoped
87
+ * roll-up.
88
+ *
89
+ * Homed beside {@link gatherStoryFrictionSignals} on purpose: the two used to
90
+ * be independent copies of the same loop in two modules, and they drifted in
91
+ * exactly the way that made the recovery-netting unreachable (Story #4649).
92
+ * One reader, one normalizer, no second place to forget a field.
93
+ *
94
+ * Unusable ids are skipped rather than throwing — a roll-up must not fail the
95
+ * epilogue over one malformed entry.
96
+ *
97
+ * @param {Array<number|string>} storyIds
98
+ * @param {object} [config]
99
+ * @returns {Promise<Array<{ category: string, source: 'framework'|'consumer', storyId: number, details: object }>>}
100
+ */
101
+ export async function gatherRunFrictionSignals(storyIds, config) {
102
+ const signals = [];
103
+ for (const raw of Array.isArray(storyIds) ? storyIds : []) {
104
+ const sid = Number(raw);
105
+ if (!Number.isInteger(sid) || sid <= 0) continue;
106
+ signals.push(...(await gatherStoryFrictionSignals(sid, config)));
107
+ }
108
+ return signals;
109
+ }
110
+
75
111
  /**
76
112
  * Render the empty-roll-up line.
77
113
  *
@@ -250,7 +286,11 @@ export async function captureStoryFollowUps({
250
286
  frameworkRepo: repos.frameworkRepo,
251
287
  consumerRepo: repos.consumerRepo,
252
288
  signals,
253
- unresolvedBlockedEvents: [],
289
+ // Derived, not hardcoded `[]` (Story #4649). This is the escape hatch
290
+ // the retired story-scope threshold carve-out was standing in for: a
291
+ // Story still parked at `agent::blocked` files at a single occurrence,
292
+ // while one that blocked and self-resolved nets out entirely.
293
+ unresolvedBlockedEvents: deriveUnresolvedBlockedEvents(signals),
254
294
  });
255
295
  const graduated = await graduateRetroProposals({
256
296
  epicId: sid,