mandrel 1.88.0 → 1.90.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (145) hide show
  1. package/.agents/README.md +18 -13
  2. package/.agents/audit-checklists/architecture.md +24 -0
  3. package/.agents/audit-checklists/clean-code.md +24 -0
  4. package/.agents/audit-checklists/dependencies.md +14 -0
  5. package/.agents/audit-checklists/devops.md +17 -0
  6. package/.agents/audit-checklists/documentation.md +22 -0
  7. package/.agents/audit-checklists/lighthouse.md +15 -0
  8. package/.agents/audit-checklists/navigability.md +14 -0
  9. package/.agents/audit-checklists/performance.md +22 -0
  10. package/.agents/audit-checklists/privacy.md +21 -0
  11. package/.agents/audit-checklists/quality.md +18 -0
  12. package/.agents/audit-checklists/security.md +22 -0
  13. package/.agents/audit-checklists/seo.md +16 -0
  14. package/.agents/audit-checklists/sre.md +24 -0
  15. package/.agents/audit-checklists/ux-ui.md +21 -0
  16. package/.agents/docs/SDLC.md +62 -27
  17. package/.agents/docs/configuration.md +5 -4
  18. package/.agents/instructions.md +51 -21
  19. package/.agents/personas/architect.md +10 -7
  20. package/.agents/personas/engineer.md +4 -3
  21. package/.agents/personas/project-manager.md +5 -2
  22. package/.agents/personas/refactorer.md +5 -3
  23. package/.agents/rules/git-conventions.md +77 -0
  24. package/.agents/schemas/agentrc.schema.json +10 -6
  25. package/.agents/schemas/audit-rules.json +16 -2
  26. package/.agents/schemas/audit-rules.schema.json +7 -6
  27. package/.agents/schemas/lifecycle/epic.blocked.schema.json +1 -1
  28. package/.agents/schemas/lifecycle/merge.unlanded.schema.json +39 -0
  29. package/.agents/schemas/signal-event.schema.json +28 -13
  30. package/.agents/scripts/acceptance-spec-reconciler.js +6 -4
  31. package/.agents/scripts/check-context-budget.js +320 -0
  32. package/.agents/scripts/coverage-capture.js +17 -0
  33. package/.agents/scripts/diagnose-friction.js +4 -4
  34. package/.agents/scripts/epic-audit-prepare.js +30 -2
  35. package/.agents/scripts/epic-audit-recheck.js +46 -13
  36. package/.agents/scripts/epic-deliver-prepare.js +80 -8
  37. package/.agents/scripts/epic-plan-spec.js +4 -8
  38. package/.agents/scripts/generate-lens-checklists.js +180 -0
  39. package/.agents/scripts/lib/audit-suite/checklist-threading.js +300 -0
  40. package/.agents/scripts/lib/audit-suite/findings.js +27 -0
  41. package/.agents/scripts/lib/audit-suite/index.js +9 -0
  42. package/.agents/scripts/lib/audit-suite/lens-checklist.js +212 -0
  43. package/.agents/scripts/lib/audit-suite/selector.js +136 -5
  44. package/.agents/scripts/lib/checks/loop-health.js +340 -0
  45. package/.agents/scripts/lib/cli-args.js +8 -0
  46. package/.agents/scripts/lib/close-validation/gates.js +64 -24
  47. package/.agents/scripts/lib/config/ci.js +12 -1
  48. package/.agents/scripts/lib/config/runners.js +13 -5
  49. package/.agents/scripts/lib/config/temp-paths.js +24 -0
  50. package/.agents/scripts/lib/config-settings-schema-delivery.js +28 -8
  51. package/.agents/scripts/lib/doc-tiers.js +291 -0
  52. package/.agents/scripts/lib/epic-body-sections.js +5 -2
  53. package/.agents/scripts/lib/epic-merge-lock.js +83 -0
  54. package/.agents/scripts/lib/epic-plan-clarity.js +3 -1
  55. package/.agents/scripts/lib/feedback-loop/audit-results-graduator.js +47 -15
  56. package/.agents/scripts/lib/feedback-loop/graduator-core.js +395 -86
  57. package/.agents/scripts/lib/feedback-loop/memory-freshness.js +299 -72
  58. package/.agents/scripts/lib/feedback-loop/retro-proposals-graduator.js +438 -0
  59. package/.agents/scripts/lib/gates/friction.js +15 -5
  60. package/.agents/scripts/lib/npm-scripts.js +55 -0
  61. package/.agents/scripts/lib/observability/perf-aggregator.js +30 -104
  62. package/.agents/scripts/lib/observability/perf-report-readers.js +1 -1
  63. package/.agents/scripts/lib/observability/signal-validator.js +204 -0
  64. package/.agents/scripts/lib/observability/signals-writer.js +157 -54
  65. package/.agents/scripts/lib/observability/tool-trace-hook.js +42 -4
  66. package/.agents/scripts/lib/orchestration/acceptance-eval-decision.js +1 -1
  67. package/.agents/scripts/lib/orchestration/code-review.js +74 -4
  68. package/.agents/scripts/lib/orchestration/consolidation-precondition.js +213 -0
  69. package/.agents/scripts/lib/orchestration/doc-reader.js +4 -96
  70. package/.agents/scripts/lib/orchestration/docs-digest.js +34 -0
  71. package/.agents/scripts/lib/orchestration/epic-plan-spec/phases/authoring-context.js +56 -19
  72. package/.agents/scripts/lib/orchestration/epic-plan-spec/phases/run-spec-phase.js +22 -0
  73. package/.agents/scripts/lib/orchestration/lifecycle/emit-merge-unlanded.js +193 -0
  74. package/.agents/scripts/lib/orchestration/lifecycle/listeners/README.md +6 -0
  75. package/.agents/scripts/lib/orchestration/lifecycle/listeners/automerge-armer.js +248 -13
  76. package/.agents/scripts/lib/orchestration/lifecycle/listeners/automerge-predicate.js +109 -12
  77. package/.agents/scripts/lib/orchestration/lifecycle/listeners/finalizer.js +47 -61
  78. package/.agents/scripts/lib/orchestration/lifecycle/listeners/index.js +46 -4
  79. package/.agents/scripts/lib/orchestration/lifecycle/listeners/label-transitioner.js +144 -0
  80. package/.agents/scripts/lib/orchestration/lifecycle/listeners/merge-watcher.js +258 -14
  81. package/.agents/scripts/lib/orchestration/lifecycle/listeners/notify-dispatcher.js +6 -0
  82. package/.agents/scripts/lib/orchestration/merge-block-class.js +246 -0
  83. package/.agents/scripts/lib/orchestration/plan-review-routing.js +1 -1
  84. package/.agents/scripts/lib/orchestration/post-merge/phases/worktree-reap.js +3 -3
  85. package/.agents/scripts/lib/orchestration/retro/phases/compose-body.js +63 -34
  86. package/.agents/scripts/lib/orchestration/retro/phases/gather-signals.js +167 -52
  87. package/.agents/scripts/lib/orchestration/retro/phases/post-and-mirror.js +49 -2
  88. package/.agents/scripts/lib/orchestration/retro-proposals.js +12 -55
  89. package/.agents/scripts/lib/orchestration/retro-runner.js +9 -0
  90. package/.agents/scripts/lib/orchestration/single-story-close/phases/close-validation.js +5 -1
  91. package/.agents/scripts/lib/orchestration/single-story-close/phases/code-review.js +8 -0
  92. package/.agents/scripts/lib/orchestration/single-story-close/phases/confirm-merge.js +419 -0
  93. package/.agents/scripts/lib/orchestration/single-story-close/phases/options.js +35 -2
  94. package/.agents/scripts/lib/orchestration/single-story-close/phases/wrong-tree-guard.js +353 -69
  95. package/.agents/scripts/lib/orchestration/single-story-close/runner.js +66 -4
  96. package/.agents/scripts/lib/orchestration/spec-section-validator.js +60 -9
  97. package/.agents/scripts/lib/orchestration/story-close/auto-refresh-runner.js +7 -5
  98. package/.agents/scripts/lib/orchestration/story-close/merge-runner.js +24 -2
  99. package/.agents/scripts/lib/orchestration/story-close/phases/code-review.js +167 -8
  100. package/.agents/scripts/lib/orchestration/story-close/pre-merge-validation.js +8 -1
  101. package/.agents/scripts/lib/orchestration/story-close/shared-checkout-guard.js +163 -0
  102. package/.agents/scripts/lib/orchestration/ticketing/reads.js +20 -9
  103. package/.agents/scripts/lib/planning-corpus.js +306 -0
  104. package/.agents/scripts/lib/signals/detectors/common.js +10 -10
  105. package/.agents/scripts/lib/signals/detectors/index.js +4 -4
  106. package/.agents/scripts/lib/signals/detectors/retry.js +19 -18
  107. package/.agents/scripts/lib/signals/detectors/rework.js +1 -1
  108. package/.agents/scripts/lib/signals/schema.js +56 -81
  109. package/.agents/scripts/lib/signals/span-tree.js +6 -5
  110. package/.agents/scripts/lib/story-plan.js +3 -0
  111. package/.agents/scripts/lib/wave-runner/tick.js +10 -2
  112. package/.agents/scripts/lifecycle-emit.js +39 -8
  113. package/.agents/scripts/providers/github/issues.js +12 -1
  114. package/.agents/scripts/resolve-doc-tiers.js +83 -0
  115. package/.agents/scripts/retro-run.js +51 -0
  116. package/.agents/scripts/signals-view.js +1 -1
  117. package/.agents/scripts/single-story-close.js +20 -1
  118. package/.agents/scripts/standalone-feedback-rollup.js +188 -0
  119. package/.agents/scripts/story-close.js +48 -0
  120. package/.agents/scripts/story-plan.js +51 -12
  121. package/.agents/scripts/validate-docs-freshness.js +69 -15
  122. package/.agents/skills/core/documentation-and-adrs/SKILL.md +58 -0
  123. package/.agents/skills/core/epic-plan-decompose-author/SKILL.md +5 -3
  124. package/.agents/skills/core/epic-plan-spec-author/SKILL.md +20 -7
  125. package/.agents/skills/core/scope-triage/SKILL.md +61 -0
  126. package/.agents/skills/skills.index.json +3 -3
  127. package/.agents/workflows/audit-documentation.md +82 -2
  128. package/.agents/workflows/helpers/code-review.md +116 -43
  129. package/.agents/workflows/helpers/deliver-epic.md +123 -54
  130. package/.agents/workflows/helpers/deliver-stories.md +26 -0
  131. package/.agents/workflows/helpers/epic-audit.md +116 -366
  132. package/.agents/workflows/helpers/epic-deliver-story.md +14 -0
  133. package/.agents/workflows/helpers/epic-plan-decompose.md +18 -200
  134. package/.agents/workflows/helpers/epic-plan-spec.md +18 -180
  135. package/.agents/workflows/helpers/plan-epic.md +141 -105
  136. package/.agents/workflows/helpers/plan-story.md +32 -0
  137. package/.agents/workflows/helpers/single-story-deliver.md +43 -0
  138. package/.agents/workflows/loops/nightly-audit.md +9 -7
  139. package/docs/CHANGELOG.md +29 -0
  140. package/lib/cli/doctor.js +44 -0
  141. package/package.json +4 -3
  142. package/.agents/scripts/epic-plan-spec-validate.js +0 -111
  143. package/.agents/scripts/lib/feedback-loop/code-review-graduator.js +0 -224
  144. package/.agents/scripts/lib/orchestration/epic-plan-spec/phases/prompts.js +0 -58
  145. package/.agents/scripts/lib/signals/detectors/hotspot.js +0 -292
@@ -26,6 +26,37 @@ export function normalizeInterventionCount(value) {
26
26
  return Math.trunc(value);
27
27
  }
28
28
 
29
+ /**
30
+ * Pure: does `routedProposals` carry at least one actionable item (a
31
+ * framework- or consumer-routed `gh issue create` proposal)? Story #4417 —
32
+ * the compact retro shape (and therefore the `automerge-verdict`
33
+ * `cleanSprint: true` flag it drives) MUST be suppressed whenever an
34
+ * actionable proposal exists, regardless of `forceFull` or the
35
+ * comment-derived counts: the clean-sprint verdict is an auto-merge input
36
+ * and must never read "clean" while actionable friction sits in the routed
37
+ * proposals. Discarded (single-occurrence) items do NOT count — they are
38
+ * the retro's explicit "nothing to file" bucket.
39
+ *
40
+ * @param {{ framework?: object[], consumer?: object[] } | null | undefined} routedProposals
41
+ * @returns {boolean}
42
+ */
43
+ export function hasActionableProposals(routedProposals) {
44
+ if (
45
+ !routedProposals ||
46
+ typeof routedProposals !== 'object' ||
47
+ Array.isArray(routedProposals)
48
+ ) {
49
+ return false;
50
+ }
51
+ const framework = Array.isArray(routedProposals.framework)
52
+ ? routedProposals.framework
53
+ : [];
54
+ const consumer = Array.isArray(routedProposals.consumer)
55
+ ? routedProposals.consumer
56
+ : [];
57
+ return framework.length > 0 || consumer.length > 0;
58
+ }
59
+
29
60
  /**
30
61
  * Pure: derive the recurring-defect-class signal from the routed-proposal
31
62
  * sections (Story #4135 / Epic #4131, F11).
@@ -125,7 +156,15 @@ export function composeRetroBody(input) {
125
156
  } = input;
126
157
 
127
158
  const interventions = normalizeInterventionCount(counts?.interventions);
128
- const compact = !forceFull && isCleanManifest({ ...counts, interventions });
159
+ // Story #4417 — the compact shape requires BOTH a clean manifest AND zero
160
+ // actionable routed proposals. The routed proposals and the counts are
161
+ // derived from the same unified signals scan (gather-signals.js), so this
162
+ // guard closes the window where the comment-derived counts read "clean"
163
+ // while the ndjson-derived routed proposals hold an actionable item.
164
+ const compact =
165
+ !forceFull &&
166
+ isCleanManifest({ ...counts, interventions }) &&
167
+ !hasActionableProposals(routedProposals);
129
168
  const heading = `## 🪞 Sprint Retrospective — Epic #${epicId}: ${epicTitle}`;
130
169
  const generatedLine = `_Generated ${timestamp}_`;
131
170
  const scorecardRows = [
@@ -214,12 +253,11 @@ export function composeRetroBody(input) {
214
253
  legacyActionItems.length > 0 ? legacyActionItems.join('\n') : '_None._';
215
254
 
216
255
  // Story #2558 — routed-proposals mode. When routedProposals is supplied
217
- // AND any of the four buckets is non-empty, render the four explicit
256
+ // AND any of the three buckets is non-empty, render the three explicit
218
257
  // sections in deterministic order ABOVE the retro-complete marker:
219
258
  // 1. Proposed issues — consumer repo
220
259
  // 2. Proposed issues — framework repo
221
- // 3. Proposed memory updates
222
- // 4. One-off / discarded
260
+ // 3. One-off / discarded
223
261
  // Otherwise the legacy "Action Items for Next Epic" section renders.
224
262
  const routedSectionsBlock = renderRoutedSections(routedProposals);
225
263
 
@@ -419,7 +457,10 @@ function shellEscape(s) {
419
457
  * Render the body lines (everything below a section heading and its trailing
420
458
  * blank) for a "proposed issues" bucket — the consumer and framework sections
421
459
  * share this shape. Empty buckets collapse to a single `_None._`; populated
422
- * buckets emit one fenced `gh issue create` stanza per item.
460
+ * buckets emit, per item, either the **filed issue reference** (when the
461
+ * retro auto-filer already filed it — Story #4418, stamped as
462
+ * `item.filedIssue`) or the paste-ready fenced `gh issue create` stanza (the
463
+ * toggle-OFF / filing-skipped fallback).
423
464
  *
424
465
  * @param {object[]} items
425
466
  * @returns {string[]}
@@ -430,32 +471,21 @@ function renderIssueBucket(items) {
430
471
  for (const item of items) {
431
472
  lines.push(`- **${item.title ?? item.category}**`);
432
473
  lines.push('');
433
- lines.push('```sh');
434
- lines.push(String(item.command ?? ''));
435
- lines.push('```');
474
+ if (item.filedIssue && item.filedIssue.url) {
475
+ const ref = Number.isInteger(item.filedIssue.number)
476
+ ? `#${item.filedIssue.number}`
477
+ : 'issue';
478
+ lines.push(` Filed: [${ref}](${item.filedIssue.url})`);
479
+ } else {
480
+ lines.push('```sh');
481
+ lines.push(String(item.command ?? ''));
482
+ lines.push('```');
483
+ }
436
484
  lines.push('');
437
485
  }
438
486
  return lines;
439
487
  }
440
488
 
441
- /**
442
- * Render the body lines for the "proposed memory updates" bucket — a plain
443
- * instruction prelude followed by one bullet per insight, or `_None._` when
444
- * empty. Deliberately NOT YAML frontmatter (asserted by the routed-sections
445
- * contract test).
446
- *
447
- * @param {object[]} items
448
- * @returns {string[]}
449
- */
450
- function renderMemoryBucket(items) {
451
- if (items.length === 0) return ['_None._'];
452
- return [
453
- 'update your memory with the following insights:',
454
- '',
455
- ...items.map((m) => `- ${m.insight}`),
456
- ];
457
- }
458
-
459
489
  /**
460
490
  * Render the body lines for the "one-off / discarded" bucket — one bullet per
461
491
  * discarded class naming its occurrence count and source, or `_None._`.
@@ -472,12 +502,16 @@ function renderDiscardedBucket(items) {
472
502
  }
473
503
 
474
504
  /**
475
- * Descriptor table for the four routed-proposal sections, in deterministic
476
- * emit order (consumer → framework → memory → discarded). Each descriptor
477
- * pairs a heading, the `routedProposals` field it reads, and a body renderer.
505
+ * Descriptor table for the three routed-proposal sections, in deterministic
506
+ * emit order (consumer → framework → discarded). Each descriptor pairs a
507
+ * heading, the `routedProposals` field it reads, and a body renderer.
478
508
  * {@link renderRoutedSections} walks the table once, so reordering or adding a
479
509
  * section is a data edit here rather than another copy-pasted emit block.
480
510
  *
511
+ * The former "Proposed memory updates" section was deleted in the Epic
512
+ * #4406 signal-contract cutover — no writer ever produced the memory-pane
513
+ * records it rendered.
514
+ *
481
515
  * @type {Array<{ heading: string, field: string, renderBucket: (items: object[]) => string[] }>}
482
516
  */
483
517
  const ROUTED_SECTIONS = [
@@ -491,11 +525,6 @@ const ROUTED_SECTIONS = [
491
525
  field: 'framework',
492
526
  renderBucket: renderIssueBucket,
493
527
  },
494
- {
495
- heading: '### Proposed memory updates',
496
- field: 'memory',
497
- renderBucket: renderMemoryBucket,
498
- },
499
528
  {
500
529
  heading: '### One-off / discarded',
501
530
  field: 'discarded',
@@ -10,8 +10,12 @@
10
10
  */
11
11
 
12
12
  import { TYPE_LABELS } from '../../../label-constants.js';
13
- import { forEachLine } from '../../../observability/signals-writer.js';
13
+ import {
14
+ forEachEpicLine,
15
+ forEachLine,
16
+ } from '../../../observability/signals-writer.js';
14
17
  import { concurrentMap } from '../../../util/concurrent-map.js';
18
+ import { read as readEpicRunState } from '../../epic-run-state-store.js';
15
19
  import { composeRoutedProposals } from '../../retro-proposals.js';
16
20
  import { parseFencedJsonComment } from '../../structured-comment-parser.js';
17
21
  import { findStructuredComment } from '../../ticketing.js';
@@ -38,6 +42,15 @@ const SIGNALS_READ_CONCURRENCY = 8;
38
42
  // zero-descendant walk is suspicious.
39
43
  const EPIC_BODY_REFERENCE = /#\d+/;
40
44
 
45
+ /**
46
+ * Fallback category stamped on a snapshot-derived blocked event that
47
+ * carries no `category` of its own. A non-empty category is what promotes
48
+ * the event to an actionable routed proposal in `composeRoutedProposals`,
49
+ * so a category-less `agent::blocked` record must still resolve to a
50
+ * concrete bucket rather than being silently dropped.
51
+ */
52
+ const BLOCKED_EVENT_FALLBACK_CATEGORY = 'agent-blocked';
53
+
41
54
  /**
42
55
  * Pure: aggregate `frictionByCategory` payloads into a single integer.
43
56
  */
@@ -50,6 +63,83 @@ function sumFriction(byCategory) {
50
63
  return total;
51
64
  }
52
65
 
66
+ /**
67
+ * Fold one parsed `signals.ndjson` record into the routed-signal
68
+ * accumulator. Shared by the per-Story and per-Epic scans so both streams
69
+ * contribute to the SAME unified counts that feed the routed proposals AND
70
+ * the compact/full retro decision. Reads the canonical envelope: top-level
71
+ * `category` and the string `source` classifier tag (Epic #4406). Non-object
72
+ * records and records without a `category` contribute nothing.
73
+ *
74
+ * @param {unknown} parsed
75
+ * @param {Array<{ category: string, source: 'framework'|'consumer' }>} routedSignals
76
+ */
77
+ function accumulateSignalRecord(parsed, routedSignals) {
78
+ if (parsed === null || typeof parsed !== 'object') return;
79
+ const record = /** @type {Record<string, unknown>} */ (parsed);
80
+ const category = typeof record.category === 'string' ? record.category : null;
81
+ if (!category) return;
82
+ const source = record.source === 'framework' ? 'framework' : 'consumer';
83
+ routedSignals.push({ category, source });
84
+ }
85
+
86
+ /**
87
+ * Read the `epic-run-state` snapshot and project every Story still recorded
88
+ * as `blocked` into an `unresolvedBlockedEvents` entry for
89
+ * `composeRoutedProposals`. Each event carries a **non-empty** category —
90
+ * the snapshot record's own `category` when present, otherwise the
91
+ * `agent-blocked` fallback — so it force-promotes an actionable proposal
92
+ * even when no friction signal shares its category. Best-effort: a read
93
+ * failure or shapeless snapshot degrades to an empty array (observability
94
+ * MUST NOT take down the retro path).
95
+ *
96
+ * @param {{
97
+ * epicId: number,
98
+ * provider: object,
99
+ * logger?: { warn?: Function },
100
+ * readFn: typeof readEpicRunState,
101
+ * }} args
102
+ * @returns {Promise<Array<{ ticketId?: number, category: string, source: 'framework'|'consumer' }>>}
103
+ */
104
+ async function readUnresolvedBlockedEvents({
105
+ epicId,
106
+ provider,
107
+ logger,
108
+ readFn,
109
+ }) {
110
+ let snapshot;
111
+ try {
112
+ snapshot = await readFn({ provider, epicId });
113
+ } catch (err) {
114
+ logger?.warn?.(
115
+ `[retro-runner] Failed to read epic-run-state for blocked events (continuing): ${
116
+ err?.message ?? err
117
+ }`,
118
+ );
119
+ return [];
120
+ }
121
+ const storiesMap =
122
+ snapshot && typeof snapshot.stories === 'object' && snapshot.stories
123
+ ? snapshot.stories
124
+ : {};
125
+ const events = [];
126
+ for (const [key, record] of Object.entries(storiesMap)) {
127
+ if (!record || typeof record !== 'object' || record.status !== 'blocked') {
128
+ continue;
129
+ }
130
+ const rawCategory =
131
+ typeof record.category === 'string' ? record.category.trim() : '';
132
+ const category =
133
+ rawCategory.length > 0 ? rawCategory : BLOCKED_EVENT_FALLBACK_CATEGORY;
134
+ const source = record.source === 'framework' ? 'framework' : 'consumer';
135
+ const ticketId = Number(key);
136
+ const event = { category, source };
137
+ if (Number.isInteger(ticketId) && ticketId > 0) event.ticketId = ticketId;
138
+ events.push(event);
139
+ }
140
+ return events;
141
+ }
142
+
53
143
  /**
54
144
  * Walk every descendant ticket of `epicId` once. Returns the flat list with
55
145
  * each ticket's labels + body + state — the consumer derives its own
@@ -154,6 +244,8 @@ export async function gatherRetroSignals({
154
244
  frameworkRepo,
155
245
  consumerRepo,
156
246
  forEachLineFn = forEachLine,
247
+ forEachEpicLineFn = forEachEpicLine,
248
+ epicRunStateReadFn = readEpicRunState,
157
249
  composeRoutedProposalsFn = composeRoutedProposals,
158
250
  }) {
159
251
  const descendants = await collectDescendants(provider, epicId);
@@ -232,54 +324,35 @@ export async function gatherRetroSignals({
232
324
  };
233
325
  }
234
326
 
235
- const counts = {
236
- friction: frictionFromSummaries,
237
- parked: parkedFollowOns.parked.length,
238
- recuts: parkedFollowOns.recuts.length,
239
- hitl,
240
- };
241
-
242
- // Story #2558 — read per-Story `signals.ndjson` streams (already
243
- // source-tagged by Story #2553's writer) and compose the four routed
244
- // proposal sections (framework / consumer / memory / discarded). Read
245
- // failures degrade silently — observability MUST NOT take down the
246
- // retro path. Empty streams yield empty arrays so the composer
247
- // remains backward-compatible.
327
+ // Story #4417 — the compact/full retro decision, the automerge-verdict
328
+ // cleanSprint flag, and the routed proposals must all read from ONE
329
+ // signals scan. We fold the per-Story `signals.ndjson` streams AND the
330
+ // per-Epic `signals.ndjson` stream (the `appendEpicSignal` wave-lifecycle
331
+ // writers, e.g. lifecycle-emit) into a single `routedSignals`
332
+ // accumulation; `counts.friction` then derives from that same scan so a
333
+ // retro can never read "clean" from the comment substrate while
334
+ // actionable friction sits unrendered in the ndjson substrate.
335
+ //
336
+ // Story #2558 — the streams are source-tagged by the writer's
337
+ // `tagSignalSource`; `accumulateSignalRecord` reads the canonical
338
+ // envelope (top-level `category` + string `source`, Epic #4406). Read
339
+ // failures degrade silently — observability MUST NOT take down the retro.
248
340
  //
249
- // Story #3347 — the per-Story reads previously ran one-at-a-time in a
250
- // sequential `for` loop, serializing N disk reads. They now fan out via
251
- // `concurrentMap` with a bounded cap (`SIGNALS_READ_CONCURRENCY`). Each
252
- // Story accumulates into its own local arrays; `concurrentMap` preserves
253
- // input order so we concatenate the per-Story results in `stories` order.
254
- // That keeps `routedSignals` / `memorablePatterns` — and therefore the
255
- // composed `routedProposals` — byte-for-byte identical to the prior
256
- // serial behaviour, independent of which read settles first.
341
+ // Story #3347 — the per-Story reads fan out via `concurrentMap` with a
342
+ // bounded cap (`SIGNALS_READ_CONCURRENCY`); `concurrentMap` preserves
343
+ // input order so we concatenate the per-Story `routedSignals` in
344
+ // `stories` order, keeping the composed `routedProposals` deterministic.
257
345
  const perStorySignals = await concurrentMap(
258
346
  stories,
259
347
  async (story) => {
260
348
  const sid = Number(story.id ?? story.number);
261
349
  if (!Number.isInteger(sid) || sid <= 0) {
262
- return { routedSignals: [], memorablePatterns: [] };
350
+ return { routedSignals: [] };
263
351
  }
264
352
  const localRoutedSignals = [];
265
- const localMemorablePatterns = [];
266
353
  try {
267
354
  await forEachLineFn(epicId, sid, (parsed) => {
268
- if (parsed === null || typeof parsed !== 'object') return;
269
- const record = /** @type {Record<string, unknown>} */ (parsed);
270
- const category =
271
- typeof record.category === 'string' ? record.category : null;
272
- const source =
273
- record.source === 'framework' ? 'framework' : 'consumer';
274
- if (category) {
275
- localRoutedSignals.push({ category, source });
276
- }
277
- if (record.memorable === true && typeof record.insight === 'string') {
278
- localMemorablePatterns.push({
279
- category: category ?? 'general',
280
- insight: record.insight,
281
- });
282
- }
355
+ accumulateSignalRecord(parsed, localRoutedSignals);
283
356
  });
284
357
  } catch (err) {
285
358
  logger?.warn?.(
@@ -288,24 +361,60 @@ export async function gatherRetroSignals({
288
361
  }`,
289
362
  );
290
363
  }
291
- return {
292
- routedSignals: localRoutedSignals,
293
- memorablePatterns: localMemorablePatterns,
294
- };
364
+ return { routedSignals: localRoutedSignals };
295
365
  },
296
366
  { concurrency: SIGNALS_READ_CONCURRENCY },
297
367
  );
298
368
  const routedSignals = [];
299
- const memorablePatterns = [];
300
369
  for (const perStory of perStorySignals) {
301
370
  routedSignals.push(...perStory.routedSignals);
302
- memorablePatterns.push(...perStory.memorablePatterns);
303
371
  }
304
372
 
305
- // Resolve repos. Caller overrides win; otherwise default the consumer
306
- // repo to the project's own `github.owner/repo` (best-effort: the
307
- // provider may expose it, but we don't depend on it — empty string
308
- // disables that pane in the routed proposals).
373
+ // Story #4417 — fold in the per-Epic `signals.ndjson` stream
374
+ // (`appendEpicSignal` wave-lifecycle writers, e.g. lifecycle-emit) so the
375
+ // unified scan is not blind to Epic-scoped friction that never lands on
376
+ // an individual Story stream.
377
+ try {
378
+ await forEachEpicLineFn(epicId, (parsed) => {
379
+ accumulateSignalRecord(parsed, routedSignals);
380
+ });
381
+ } catch (err) {
382
+ logger?.warn?.(
383
+ `[retro-runner] forEachEpicLine failed for epic #${epicId} (continuing): ${
384
+ err?.message ?? err
385
+ }`,
386
+ );
387
+ }
388
+
389
+ const counts = {
390
+ // `friction` derives from the unified ndjson scan (every categorized
391
+ // signal across the per-Story and per-Epic streams) PLUS the legacy
392
+ // story-perf-summary totals, so a non-empty ndjson substrate forces the
393
+ // full retro even when no `story-perf-summary` comment was posted.
394
+ friction: frictionFromSummaries + routedSignals.length,
395
+ parked: parkedFollowOns.parked.length,
396
+ recuts: parkedFollowOns.recuts.length,
397
+ hitl,
398
+ };
399
+
400
+ // Story #4417 — project unresolved `agent::blocked` Stories from the
401
+ // epic-run-state snapshot into force-flag events for the routed-proposal
402
+ // composer. This replaces the formerly hardwired empty array: a blocked
403
+ // Story now force-promotes an actionable proposal even absent a matching
404
+ // friction category (its category falls back to `agent-blocked`).
405
+ const unresolvedBlockedEvents = await readUnresolvedBlockedEvents({
406
+ epicId,
407
+ provider,
408
+ logger,
409
+ readFn: epicRunStateReadFn,
410
+ });
411
+
412
+ // Resolve repos. The framework repo falls back to the Mandrel mirror
413
+ // constant (a known, stable default). The consumer repo does NOT fall
414
+ // back to the framework repo: when the caller resolves no consumer repo
415
+ // from `config.github`, the consumer proposal pane is DISABLED loudly
416
+ // (Story #4417) rather than silently routing consumer-tagged friction at
417
+ // the framework mirror.
309
418
  const resolvedFrameworkRepo =
310
419
  typeof frameworkRepo === 'string' && frameworkRepo.length > 0
311
420
  ? frameworkRepo
@@ -313,15 +422,21 @@ export async function gatherRetroSignals({
313
422
  const resolvedConsumerRepo =
314
423
  typeof consumerRepo === 'string' && consumerRepo.length > 0
315
424
  ? consumerRepo
316
- : resolvedFrameworkRepo; // when caller omits, fall back to the framework repo so the command renders without an empty `--repo` flag.
425
+ : '';
426
+ if (resolvedConsumerRepo.length === 0) {
427
+ logger?.warn?.(
428
+ '[retro-runner] No consumer repo resolved from config.github — the ' +
429
+ 'consumer proposal pane is DISABLED for this retro. Set ' +
430
+ 'github.owner / github.repo to route consumer-tagged friction.',
431
+ );
432
+ }
317
433
 
318
434
  const routedProposals = composeRoutedProposalsFn({
319
435
  epicId,
320
436
  frameworkRepo: resolvedFrameworkRepo,
321
437
  consumerRepo: resolvedConsumerRepo,
322
438
  signals: routedSignals,
323
- unresolvedBlockedEvents: [],
324
- memorablePatterns,
439
+ unresolvedBlockedEvents,
325
440
  });
326
441
 
327
442
  return {
@@ -14,6 +14,7 @@ import nodeFs from 'node:fs';
14
14
  import path from 'node:path';
15
15
 
16
16
  import { epicRetroMirrorPath } from '../../../config/temp-paths.js';
17
+ import { fileRetroProposals as defaultFileRetroProposals } from '../../../feedback-loop/retro-proposals-graduator.js';
17
18
  import { upsertStructuredComment } from '../../ticketing.js';
18
19
  import { appendChecksSection, collectRetroFindings } from './checks.js';
19
20
  import {
@@ -37,9 +38,15 @@ export async function composeAndPostRetro({
37
38
  bus,
38
39
  now,
39
40
  manualInterventions,
41
+ frameworkRepo,
42
+ consumerRepo,
43
+ config = null,
40
44
  gatherFn = defaultGatherRetroSignals,
41
45
  composeFn = defaultComposeRetroBody,
42
46
  upsertFn = upsertStructuredComment,
47
+ fileRetroProposalsFn = defaultFileRetroProposals,
48
+ ghPath,
49
+ spawnImpl,
43
50
  runChecksFn,
44
51
  assembleStateFn,
45
52
  cwd,
@@ -48,7 +55,47 @@ export async function composeAndPostRetro({
48
55
  onMirrorWritten,
49
56
  perfThresholds = null,
50
57
  }) {
51
- const signals = await gatherFn({ epicId, provider, logger });
58
+ const signals = await gatherFn({
59
+ epicId,
60
+ provider,
61
+ logger,
62
+ frameworkRepo,
63
+ consumerRepo,
64
+ });
65
+
66
+ // Story #4418 — auto-file the retro's actionable routed proposals BEFORE
67
+ // the body composes, so the rendered retro sections list the real filed
68
+ // issue numbers instead of paste-ready `gh` command stanzas. Runs behind
69
+ // the `delivery.feedbackLoop.retroProposals` toggle (default ON); when
70
+ // OFF (or when filing is skipped / fails) the proposals pass through
71
+ // unenriched and the composer falls back to the command stanzas. Never
72
+ // throws — a filing failure degrades gracefully.
73
+ const { routedProposals: filedRoutedProposals, summary: filingSummary } =
74
+ await fileRetroProposalsFn({
75
+ epicId,
76
+ provider,
77
+ config,
78
+ frameworkRepo,
79
+ consumerRepo,
80
+ routedProposals: signals.routedProposals,
81
+ ghPath,
82
+ spawnImpl,
83
+ cwd,
84
+ logger,
85
+ });
86
+ // Surface (never throw on) per-proposal filing failures. Dropping
87
+ // `summary.errors` silently made a failed `gh issue create` — e.g. a
88
+ // `friction::<category>` label that doesn't exist in the target repo —
89
+ // invisible except as an unenriched command stanza in the retro body.
90
+ if (Array.isArray(filingSummary?.errors) && filingSummary.errors.length) {
91
+ for (const err of filingSummary.errors) {
92
+ logger?.warn?.(
93
+ `[retro] proposal auto-file error (degrading to command stanza): ${
94
+ typeof err === 'string' ? err : JSON.stringify(err)
95
+ }`,
96
+ );
97
+ }
98
+ }
52
99
 
53
100
  // Best-effort fetch of the Epic title for the heading.
54
101
  let epicTitle;
@@ -69,7 +116,7 @@ export async function composeAndPostRetro({
69
116
  storyPerfSummaries: signals.storyPerfSummaries,
70
117
  epicPerfReport: signals.epicPerfReport,
71
118
  parkedFollowOns: signals.parkedFollowOns,
72
- routedProposals: signals.routedProposals,
119
+ routedProposals: filedRoutedProposals,
73
120
  timestamp,
74
121
  forceFull,
75
122
  perfThresholds,