mandrel 1.87.0 → 1.89.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 (140) 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 +63 -16
  17. package/.agents/docs/configuration.md +5 -3
  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 +16 -4
  25. package/.agents/schemas/audit-rules.json +16 -2
  26. package/.agents/schemas/audit-rules.schema.json +7 -6
  27. package/.agents/schemas/lifecycle/merge.unlanded.schema.json +38 -0
  28. package/.agents/schemas/signal-event.schema.json +28 -13
  29. package/.agents/scripts/acceptance-spec-reconciler.js +6 -4
  30. package/.agents/scripts/check-context-budget.js +320 -0
  31. package/.agents/scripts/diagnose-friction.js +4 -4
  32. package/.agents/scripts/epic-audit-prepare.js +30 -2
  33. package/.agents/scripts/epic-audit-recheck.js +46 -13
  34. package/.agents/scripts/epic-deliver-prepare.js +80 -8
  35. package/.agents/scripts/epic-plan-spec.js +4 -8
  36. package/.agents/scripts/generate-lens-checklists.js +180 -0
  37. package/.agents/scripts/lib/audit-suite/checklist-threading.js +300 -0
  38. package/.agents/scripts/lib/audit-suite/findings.js +27 -0
  39. package/.agents/scripts/lib/audit-suite/index.js +9 -0
  40. package/.agents/scripts/lib/audit-suite/lens-checklist.js +212 -0
  41. package/.agents/scripts/lib/audit-suite/selector.js +136 -5
  42. package/.agents/scripts/lib/checks/loop-health.js +340 -0
  43. package/.agents/scripts/lib/cli-args.js +8 -0
  44. package/.agents/scripts/lib/config/explain.js +4 -0
  45. package/.agents/scripts/lib/config/runners.js +21 -2
  46. package/.agents/scripts/lib/config/temp-paths.js +24 -0
  47. package/.agents/scripts/lib/config-settings-schema-delivery.js +23 -3
  48. package/.agents/scripts/lib/config-settings-schema-quality.js +7 -0
  49. package/.agents/scripts/lib/doc-tiers.js +291 -0
  50. package/.agents/scripts/lib/epic-body-sections.js +5 -2
  51. package/.agents/scripts/lib/epic-merge-lock.js +83 -0
  52. package/.agents/scripts/lib/epic-plan-clarity.js +3 -1
  53. package/.agents/scripts/lib/feedback-loop/audit-results-graduator.js +66 -20
  54. package/.agents/scripts/lib/feedback-loop/graduator-core.js +395 -86
  55. package/.agents/scripts/lib/feedback-loop/memory-freshness.js +299 -72
  56. package/.agents/scripts/lib/feedback-loop/retro-proposals-graduator.js +438 -0
  57. package/.agents/scripts/lib/gates/friction.js +15 -5
  58. package/.agents/scripts/lib/observability/perf-aggregator.js +30 -104
  59. package/.agents/scripts/lib/observability/perf-report-readers.js +1 -1
  60. package/.agents/scripts/lib/observability/signal-validator.js +204 -0
  61. package/.agents/scripts/lib/observability/signals-writer.js +157 -54
  62. package/.agents/scripts/lib/observability/tool-trace-hook.js +42 -4
  63. package/.agents/scripts/lib/orchestration/acceptance-eval-decision.js +1 -1
  64. package/.agents/scripts/lib/orchestration/code-review.js +74 -4
  65. package/.agents/scripts/lib/orchestration/consolidation-precondition.js +213 -0
  66. package/.agents/scripts/lib/orchestration/doc-reader.js +4 -96
  67. package/.agents/scripts/lib/orchestration/docs-digest.js +34 -0
  68. package/.agents/scripts/lib/orchestration/epic-plan-spec/phases/authoring-context.js +56 -19
  69. package/.agents/scripts/lib/orchestration/epic-plan-spec/phases/run-spec-phase.js +22 -0
  70. package/.agents/scripts/lib/orchestration/lifecycle/emit-merge-unlanded.js +188 -0
  71. package/.agents/scripts/lib/orchestration/lifecycle/listeners/README.md +6 -0
  72. package/.agents/scripts/lib/orchestration/lifecycle/listeners/automerge-armer.js +69 -8
  73. package/.agents/scripts/lib/orchestration/lifecycle/listeners/automerge-predicate.js +11 -2
  74. package/.agents/scripts/lib/orchestration/lifecycle/listeners/finalizer.js +47 -61
  75. package/.agents/scripts/lib/orchestration/lifecycle/listeners/index.js +39 -3
  76. package/.agents/scripts/lib/orchestration/lifecycle/listeners/label-transitioner.js +144 -0
  77. package/.agents/scripts/lib/orchestration/lifecycle/listeners/merge-watcher.js +258 -14
  78. package/.agents/scripts/lib/orchestration/lifecycle/listeners/notify-dispatcher.js +6 -0
  79. package/.agents/scripts/lib/orchestration/merge-block-class.js +218 -0
  80. package/.agents/scripts/lib/orchestration/plan-review-routing.js +1 -1
  81. package/.agents/scripts/lib/orchestration/post-merge/phases/worktree-reap.js +3 -3
  82. package/.agents/scripts/lib/orchestration/retro/phases/compose-body.js +63 -34
  83. package/.agents/scripts/lib/orchestration/retro/phases/gather-signals.js +167 -52
  84. package/.agents/scripts/lib/orchestration/retro/phases/post-and-mirror.js +49 -2
  85. package/.agents/scripts/lib/orchestration/retro-proposals.js +12 -55
  86. package/.agents/scripts/lib/orchestration/retro-runner.js +9 -0
  87. package/.agents/scripts/lib/orchestration/single-story-close/phases/code-review.js +8 -0
  88. package/.agents/scripts/lib/orchestration/single-story-close/phases/confirm-merge.js +419 -0
  89. package/.agents/scripts/lib/orchestration/single-story-close/phases/options.js +35 -2
  90. package/.agents/scripts/lib/orchestration/single-story-close/phases/wrong-tree-guard.js +353 -69
  91. package/.agents/scripts/lib/orchestration/single-story-close/runner.js +66 -4
  92. package/.agents/scripts/lib/orchestration/spec-section-validator.js +60 -9
  93. package/.agents/scripts/lib/orchestration/story-close/auto-refresh-runner.js +7 -5
  94. package/.agents/scripts/lib/orchestration/story-close/merge-runner.js +24 -2
  95. package/.agents/scripts/lib/orchestration/story-close/phases/code-review.js +167 -8
  96. package/.agents/scripts/lib/orchestration/story-close/shared-checkout-guard.js +163 -0
  97. package/.agents/scripts/lib/orchestration/ticketing/reads.js +20 -9
  98. package/.agents/scripts/lib/planning-corpus.js +306 -0
  99. package/.agents/scripts/lib/signals/detectors/common.js +10 -10
  100. package/.agents/scripts/lib/signals/detectors/index.js +4 -4
  101. package/.agents/scripts/lib/signals/detectors/retry.js +19 -18
  102. package/.agents/scripts/lib/signals/detectors/rework.js +1 -1
  103. package/.agents/scripts/lib/signals/schema.js +56 -81
  104. package/.agents/scripts/lib/signals/span-tree.js +6 -5
  105. package/.agents/scripts/lib/story-plan.js +3 -0
  106. package/.agents/scripts/lib/wave-runner/tick.js +10 -2
  107. package/.agents/scripts/lifecycle-emit.js +39 -8
  108. package/.agents/scripts/providers/github/issues.js +12 -1
  109. package/.agents/scripts/resolve-doc-tiers.js +83 -0
  110. package/.agents/scripts/retro-run.js +51 -0
  111. package/.agents/scripts/signals-view.js +1 -1
  112. package/.agents/scripts/single-story-close.js +20 -1
  113. package/.agents/scripts/standalone-feedback-rollup.js +188 -0
  114. package/.agents/scripts/story-close.js +48 -0
  115. package/.agents/scripts/story-plan.js +51 -12
  116. package/.agents/scripts/validate-docs-freshness.js +69 -15
  117. package/.agents/skills/core/documentation-and-adrs/SKILL.md +58 -0
  118. package/.agents/skills/core/epic-plan-decompose-author/SKILL.md +5 -3
  119. package/.agents/skills/core/epic-plan-spec-author/SKILL.md +20 -7
  120. package/.agents/skills/core/scope-triage/SKILL.md +61 -0
  121. package/.agents/skills/skills.index.json +3 -3
  122. package/.agents/workflows/audit-documentation.md +82 -2
  123. package/.agents/workflows/helpers/code-review.md +193 -44
  124. package/.agents/workflows/helpers/deliver-epic.md +128 -39
  125. package/.agents/workflows/helpers/deliver-stories.md +26 -0
  126. package/.agents/workflows/helpers/epic-audit.md +116 -283
  127. package/.agents/workflows/helpers/epic-deliver-story.md +14 -0
  128. package/.agents/workflows/helpers/epic-plan-decompose.md +18 -200
  129. package/.agents/workflows/helpers/epic-plan-spec.md +18 -180
  130. package/.agents/workflows/helpers/plan-epic.md +141 -105
  131. package/.agents/workflows/helpers/plan-story.md +32 -0
  132. package/.agents/workflows/helpers/single-story-deliver.md +43 -0
  133. package/.agents/workflows/loops/nightly-audit.md +9 -7
  134. package/docs/CHANGELOG.md +29 -0
  135. package/lib/cli/doctor.js +44 -0
  136. package/package.json +4 -3
  137. package/.agents/scripts/epic-plan-spec-validate.js +0 -111
  138. package/.agents/scripts/lib/feedback-loop/code-review-graduator.js +0 -207
  139. package/.agents/scripts/lib/orchestration/epic-plan-spec/phases/prompts.js +0 -58
  140. package/.agents/scripts/lib/signals/detectors/hotspot.js +0 -292
@@ -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,
@@ -1,12 +1,14 @@
1
1
  /**
2
2
  * lib/orchestration/retro-proposals.js — pure composer that turns
3
- * aggregated source-tagged friction signals into four routed proposal
4
- * sections (framework, consumer, memory, discarded).
3
+ * aggregated source-tagged friction signals into three routed proposal
4
+ * sections (framework, consumer, discarded).
5
5
  *
6
6
  * Epic #2547 / Story #2558 / Tech Spec #2550. Consumes per-Story signals
7
- * already source-tagged by `signals-writer.appendSignal` (Story #2553) and
8
- * yields a four-way split that the retro composer renders above the
9
- * `<!-- retro-complete: ... -->` marker.
7
+ * already source-tagged by `signals-writer.appendSignal` and yields a
8
+ * three-way split that the retro composer renders above the
9
+ * `<!-- retro-complete: ... -->` marker. The former "memory updates"
10
+ * pane was deleted in the Epic #4406 signal-contract cutover (it had no
11
+ * producer — no writer ever emitted the record it rendered).
10
12
  *
11
13
  * Heuristic:
12
14
  * - **Actionable** (renders as a pre-drafted `gh issue create` shell
@@ -14,14 +16,8 @@
14
16
  * Epic, OR an `agent::blocked` event whose root cause was not
15
17
  * resolved by Epic close (the caller supplies these as
16
18
  * `unresolvedBlockedEvents`).
17
- * - **Memorable** (renders as a plain bulleted instruction line under
18
- * "update your memory with the following insights"): a pattern
19
- * observed in retro signals supplied via `memorablePatterns`. We do
20
- * **not** emit memory frontmatter — the section is a free-text
21
- * instruction block.
22
19
  * - **Discarded**: a friction category with exactly 1 occurrence and
23
- * no follow-on signal (no companion `agent::blocked`, not in
24
- * `memorablePatterns`).
20
+ * no follow-on signal (no companion `agent::blocked`).
25
21
  *
26
22
  * Routing:
27
23
  * - Each actionable item is routed to `framework` or `consumer` based
@@ -45,17 +41,12 @@
45
41
  * @property {string} [category]
46
42
  * @property {string} [summary]
47
43
  *
48
- * @typedef {Object} MemorablePattern
49
- * @property {string} category
50
- * @property {string} insight The instruction line text (rendered as a bullet).
51
- *
52
44
  * @typedef {Object} RoutedProposalsInput
53
45
  * @property {number} epicId
54
46
  * @property {string} frameworkRepo `"<owner>/<repo>"`.
55
47
  * @property {string} consumerRepo `"<owner>/<repo>"`.
56
48
  * @property {FrictionSignal[]} [signals]
57
49
  * @property {BlockedEvent[]} [unresolvedBlockedEvents]
58
- * @property {MemorablePattern[]} [memorablePatterns]
59
50
  *
60
51
  * @typedef {Object} RoutedItem
61
52
  * @property {string} category
@@ -65,10 +56,6 @@
65
56
  * @property {string} body
66
57
  * @property {string} command The pre-drafted `gh issue create` line.
67
58
  *
68
- * @typedef {Object} MemoryItem
69
- * @property {string} category
70
- * @property {string} insight
71
- *
72
59
  * @typedef {Object} DiscardedItem
73
60
  * @property {string} category
74
61
  * @property {number} occurrences
@@ -77,7 +64,6 @@
77
64
  * @typedef {Object} RoutedProposals
78
65
  * @property {RoutedItem[]} framework
79
66
  * @property {RoutedItem[]} consumer
80
- * @property {MemoryItem[]} memory
81
67
  * @property {DiscardedItem[]} discarded
82
68
  */
83
69
 
@@ -88,7 +74,7 @@
88
74
  * @returns {RoutedProposals}
89
75
  */
90
76
  function emptyResult() {
91
- return { framework: [], consumer: [], memory: [], discarded: [] };
77
+ return { framework: [], consumer: [], discarded: [] };
92
78
  }
93
79
 
94
80
  /**
@@ -250,7 +236,6 @@ function buildRoutedItem({
250
236
  * consumerRepo: string,
251
237
  * signals: FrictionSignal[],
252
238
  * unresolvedBlockedEvents: BlockedEvent[],
253
- * memorablePatterns: MemorablePattern[],
254
239
  * } | null}
255
240
  */
256
241
  function normaliseInput(input) {
@@ -269,9 +254,6 @@ function normaliseInput(input) {
269
254
  unresolvedBlockedEvents: Array.isArray(record.unresolvedBlockedEvents)
270
255
  ? record.unresolvedBlockedEvents
271
256
  : [],
272
- memorablePatterns: Array.isArray(record.memorablePatterns)
273
- ? record.memorablePatterns
274
- : [],
275
257
  };
276
258
  }
277
259
 
@@ -280,11 +262,9 @@ function normaliseInput(input) {
280
262
  * signals.
281
263
  *
282
264
  * Pure — no I/O, no time-dependent state, no provider calls. Returns an
283
- * object with four arrays:
265
+ * object with three arrays:
284
266
  * - `framework`: actionable items routed to the framework repo.
285
267
  * - `consumer`: actionable items routed to the consumer repo.
286
- * - `memory`: bulleted instruction lines for the operator's memory
287
- * surface (NOT memory frontmatter).
288
268
  * - `discarded`: single-occurrence friction with no follow-on signal.
289
269
  *
290
270
  * @param {RoutedProposalsInput} input
@@ -299,28 +279,10 @@ export function composeRoutedProposals(input) {
299
279
  consumerRepo,
300
280
  signals,
301
281
  unresolvedBlockedEvents,
302
- memorablePatterns,
303
282
  } = normalised;
304
283
 
305
284
  const byCategory = aggregateByCategory(signals);
306
285
 
307
- // Memory: every supplied pattern with a non-empty insight, sorted by
308
- // category. Memorable categories are *also* tracked so a 1-occurrence
309
- // friction that's memorable is NOT discarded — it's already covered by
310
- // the memory section.
311
- const memorableCategories = new Set();
312
- /** @type {MemoryItem[]} */
313
- const memory = [];
314
- for (const m of memorablePatterns) {
315
- if (m === null || typeof m !== 'object') continue;
316
- const category = asString(m.category);
317
- const insight = asString(m.insight);
318
- if (category.length === 0 || insight.length === 0) continue;
319
- memorableCategories.add(category);
320
- memory.push({ category, insight });
321
- }
322
- memory.sort((a, b) => a.category.localeCompare(b.category));
323
-
324
286
  // Unresolved agent::blocked events always promote their category to
325
287
  // actionable — even if the friction count is < 2. The event itself
326
288
  // doesn't count as a friction occurrence; we treat it as a force-flag.
@@ -362,12 +324,7 @@ export function composeRoutedProposals(input) {
362
324
  else consumer.push(item);
363
325
  continue;
364
326
  }
365
- // total === 1 AND no force flag.
366
- if (memorableCategories.has(category)) {
367
- // Memorable single-occurrence frictions are covered by the memory
368
- // section; do not also discard them.
369
- continue;
370
- }
327
+ // total === 1 AND no force flag → discarded.
371
328
  discarded.push({ category, occurrences: total, source });
372
329
  }
373
330
 
@@ -391,5 +348,5 @@ export function composeRoutedProposals(input) {
391
348
  consumer.sort((a, b) => a.category.localeCompare(b.category));
392
349
  discarded.sort((a, b) => a.category.localeCompare(b.category));
393
350
 
394
- return { framework, consumer, memory, discarded };
351
+ return { framework, consumer, discarded };
395
352
  }
@@ -95,6 +95,9 @@ export { gatherRetroSignals } from './retro/phases/gather-signals.js';
95
95
  * bus?: object|null,
96
96
  * now?: () => number,
97
97
  * manualInterventions?: number,
98
+ * frameworkRepo?: string,
99
+ * consumerRepo?: string,
100
+ * config?: object|null,
98
101
  * gatherFn?: typeof gatherRetroSignals,
99
102
  * composeFn?: typeof composeRetroBody,
100
103
  * upsertFn?: typeof upsertStructuredComment,
@@ -122,6 +125,9 @@ export async function runRetro(opts = {}) {
122
125
  bus,
123
126
  now = Date.now,
124
127
  manualInterventions = 0,
128
+ frameworkRepo,
129
+ consumerRepo,
130
+ config = null,
125
131
  gatherFn = gatherRetroSignals,
126
132
  composeFn = composeRetroBody,
127
133
  upsertFn = upsertStructuredComment,
@@ -159,6 +165,9 @@ export async function runRetro(opts = {}) {
159
165
  bus,
160
166
  now,
161
167
  manualInterventions,
168
+ frameworkRepo,
169
+ consumerRepo,
170
+ config,
162
171
  gatherFn,
163
172
  composeFn,
164
173
  upsertFn,
@@ -94,6 +94,7 @@ export function buildStoryReviewCrossRefBody({
94
94
  * prNumber: number|null,
95
95
  * provider: object,
96
96
  * runCodeReviewFn: Function,
97
+ * runLocalLensReviewFn?: Function,
97
98
  * progress: (tag: string, msg: string) => void,
98
99
  * }} args
99
100
  * @returns {Promise<{
@@ -103,6 +104,7 @@ export function buildStoryReviewCrossRefBody({
103
104
  * posted?: boolean,
104
105
  * postedCommentId?: number|null,
105
106
  * crossRefPosted?: boolean,
107
+ * localLensReview?: object,
106
108
  * }>}
107
109
  */
108
110
  export async function runStoryScopeReview({
@@ -114,6 +116,7 @@ export async function runStoryScopeReview({
114
116
  prNumber,
115
117
  provider,
116
118
  runCodeReviewFn,
119
+ runLocalLensReviewFn,
117
120
  progress,
118
121
  }) {
119
122
  if (prNumber == null) {
@@ -138,6 +141,10 @@ export async function runStoryScopeReview({
138
141
  progress,
139
142
  progressTag: 'REVIEW',
140
143
  runCodeReviewFn,
144
+ // Forward the seam only when the caller injects it; otherwise
145
+ // `runStoryReviewCore` uses its default local-lens pass. `undefined`
146
+ // deep-merges to the default via the destructuring default there.
147
+ ...(runLocalLensReviewFn ? { runLocalLensReviewFn } : {}),
141
148
  });
142
149
 
143
150
  const sev = result.severity ?? {
@@ -186,5 +193,6 @@ export async function runStoryScopeReview({
186
193
  posted: result.posted,
187
194
  postedCommentId: result.postedCommentId ?? null,
188
195
  crossRefPosted,
196
+ localLensReview: result.localLensReview,
189
197
  };
190
198
  }