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
@@ -0,0 +1,438 @@
1
+ /**
2
+ * retro-proposals-graduator.js — Auto-graduate the retro's actionable
3
+ * routed proposals into GitHub follow-up issues. Story #4418 / Epic #4406.
4
+ *
5
+ * This is the loop's terminal junction: the retro composer
6
+ * (`retro-proposals.js`) already split the Epic's source-tagged friction
7
+ * into `framework` / `consumer` actionable items, each carrying a
8
+ * pre-drafted `gh issue create` command stanza. Historically an operator
9
+ * had to copy-paste those stanzas by hand, so `/plan` Phase 0's
10
+ * `recurringDefectClasses` stayed empty unless someone remembered to run
11
+ * the commands. This module files them mechanically instead — the retro
12
+ * body then lists the real filed issue numbers rather than paste-ready
13
+ * commands.
14
+ *
15
+ * Built on the graduator-core **pre-parsed-findings seam** (Story #4415):
16
+ * the routed proposals are handed to `graduate()` as a pre-parsed
17
+ * `findings` array, so there is no structured-comment parsing. The
18
+ * proposals are not file-scoped, so each finding is **path-less** — the
19
+ * seam skips the `git cat-file` path-exists gate rather than misclassifying
20
+ * the finding `file-removed`.
21
+ *
22
+ * Routing correctness: a routed item already knows its source
23
+ * (`framework` / `consumer`), so we file each source bucket with its own
24
+ * constant classifier and thread the graduator's per-run filing cap across
25
+ * the two buckets. The `meta::<framework-gap|consumer-improvement>` +
26
+ * `friction::<category>` labels are lifted verbatim from the routed item.
27
+ *
28
+ * Behind the `delivery.feedbackLoop.retroProposals` toggle (default ON,
29
+ * per `graduator-core.js#makeIsAutoFileEnabled`). NEVER throws — every
30
+ * failure path is captured in `errors[]`.
31
+ */
32
+
33
+ import { META_LABELS } from '../label-constants.js';
34
+ import { DEFAULT_FRAMEWORK_REPO } from '../orchestration/retro/phases/gather-signals.js';
35
+ import {
36
+ contentFingerprint,
37
+ DEFAULT_MAX_FILINGS_PER_RUN,
38
+ graduate,
39
+ makeIsAutoFileEnabled,
40
+ } from './graduator-core.js';
41
+
42
+ /**
43
+ * Resolve the toggle from the resolved agentrc config. Defaults to `true`
44
+ * — the feature is opt-out (mirrors auditResultsAutoFile).
45
+ *
46
+ * @param {object|undefined|null} config
47
+ * @returns {boolean}
48
+ */
49
+ export const isAutoFileEnabled = makeIsAutoFileEnabled('retroProposals');
50
+
51
+ /**
52
+ * Build the content-hash idempotency marker embedded in freshly filed
53
+ * follow-up bodies. Derived from the proposal's CATEGORY (retro proposals
54
+ * are path-less and the rendered title embeds a mutable recurrence count)
55
+ * so the marker is stable across sibling insert/remove/reorder churn AND
56
+ * across re-runs that change the count. An HTML comment so it survives
57
+ * markdown rendering but stays indexable via `gh search`.
58
+ *
59
+ * @param {number} epicId
60
+ * @param {{ category?: string, title?: string }} finding
61
+ * @returns {string}
62
+ */
63
+ export function buildContentMarker(epicId, finding) {
64
+ // Fingerprint on the CATEGORY only — never the rendered title. The
65
+ // title embeds the mutable recurrence count ("… recurred <N> times in
66
+ // Epic #X"), so hashing it made a retro re-run after the count changed
67
+ // mint a fresh fingerprint and re-file a duplicate issue for the same
68
+ // category. The idempotency identity of a retro proposal is
69
+ // (epic, category); the epic id is already carried in the marker text.
70
+ const fp = contentFingerprint({
71
+ category: finding.category,
72
+ path: '',
73
+ title: '',
74
+ });
75
+ return `<!-- retro-proposal-followup: epic-${epicId}-${fp} -->`;
76
+ }
77
+
78
+ /**
79
+ * Map a routed source to its `meta::*` routing label.
80
+ *
81
+ * @param {string} source
82
+ * @returns {string}
83
+ */
84
+ function metaSourceLabel(source) {
85
+ return source === 'framework'
86
+ ? META_LABELS.FRAMEWORK_GAP
87
+ : META_LABELS.CONSUMER_IMPROVEMENT;
88
+ }
89
+
90
+ /**
91
+ * The per-graduator behaviour bundle for the retro-proposals walk. Bound to
92
+ * a single source so the constant classifier routes every finding in the
93
+ * bucket to the correct repo (and the label reflects that source).
94
+ *
95
+ * @param {'framework'|'consumer'} source
96
+ * @returns {object}
97
+ */
98
+ function makeSpec(source) {
99
+ return {
100
+ fnName: 'graduateRetroProposals',
101
+ isAutoFileEnabled,
102
+ // Pre-parsed seam: the comment marker / parser are never consulted
103
+ // (findings are supplied directly), but the fields are declared for the
104
+ // shared walk's shape.
105
+ commentMarker: '<!-- structured-comment: retro -->',
106
+ noCommentReason: 'no-retro-comment',
107
+ parseFindings: () => [],
108
+ buildContentMarker,
109
+ crossRepoCommentAttrs: { graduator: 'retro-proposals' },
110
+ decorateRecord: (record, finding) => {
111
+ record.category = finding.category;
112
+ record.title = finding.title;
113
+ return record;
114
+ },
115
+ buildCrossRepoLog: ({ finding, routedRepo }) =>
116
+ `[retro-proposals-graduator] cross-repo skip (would file in ${routedRepo.owner}/${routedRepo.repo}): ${
117
+ finding.command ??
118
+ `gh issue create --title "${finding.title}" --label "${metaSourceLabel(source)},friction::${finding.category}"`
119
+ }`,
120
+ buildFollowUp: ({ finding, source: routedSource, idMarker }) => {
121
+ const labels = [
122
+ metaSourceLabel(routedSource),
123
+ `friction::${finding.category}`,
124
+ ];
125
+ const title = finding.title;
126
+ const body = [idMarker, '', finding.body ?? ''].join('\n');
127
+ return { title, body, labels };
128
+ },
129
+ };
130
+ }
131
+
132
+ /**
133
+ * Convert a routed-proposal item into a pre-parsed, path-less finding for
134
+ * the shared `graduate()` walk.
135
+ *
136
+ * @param {object} item — a `RoutedItem` from `composeRoutedProposals`.
137
+ * @param {'framework'|'consumer'} source
138
+ * @param {number} index
139
+ * @returns {object}
140
+ */
141
+ function toFinding(item, source, index) {
142
+ return {
143
+ index,
144
+ // Path-less — the retro proposals are not file-scoped, so the seam
145
+ // skips the path-exists gate rather than probing an empty path.
146
+ path: '',
147
+ severity: 'friction',
148
+ category: typeof item?.category === 'string' ? item.category : '',
149
+ source,
150
+ occurrences:
151
+ typeof item?.occurrences === 'number' ? item.occurrences : undefined,
152
+ title: typeof item?.title === 'string' ? item.title : '',
153
+ body: typeof item?.body === 'string' ? item.body : '',
154
+ command: typeof item?.command === 'string' ? item.command : '',
155
+ };
156
+ }
157
+
158
+ /**
159
+ * File the retro's actionable routed proposals as GitHub follow-up issues
160
+ * via the graduator pre-parsed-findings seam. Files the `framework` and
161
+ * `consumer` buckets, threading the per-run filing cap across both so the
162
+ * overall cap is respected. Never throws.
163
+ *
164
+ * @param {object} opts
165
+ * @param {number} opts.epicId
166
+ * @param {object} opts.provider — ticketing provider (getTicketComments;
167
+ * postComment for the cross-repo-deferred persistence).
168
+ * @param {object} [opts.config] — resolved agentrc.
169
+ * @param {{owner: string, repo: string}} opts.currentRepo — the repo the
170
+ * retro is running inside (the consumer's own repo); the cross-repo guard's
171
+ * anchor.
172
+ * @param {{owner: string, repo: string}} [opts.frameworkRepo] — where
173
+ * framework-tagged proposals route.
174
+ * @param {{ framework?: object[], consumer?: object[] }} [opts.routedProposals]
175
+ * @param {string} [opts.ghPath='gh']
176
+ * @param {Function} [opts.spawnImpl]
177
+ * @param {string} [opts.cwd]
178
+ * @param {number} [opts.timeoutMs]
179
+ * @param {number} [opts.maxFilingsPerRun]
180
+ * @param {{info?: Function, warn?: Function, debug?: Function}} [opts.logger]
181
+ * @returns {Promise<{
182
+ * filed: Array<{ index: number, source: string, repo: string, url: string|null, category: string, title: string }>,
183
+ * skipped: Array<{ index?: number, reason: string, category?: string, title?: string }>,
184
+ * errors: string[],
185
+ * }>}
186
+ */
187
+ export async function graduateRetroProposals({
188
+ epicId,
189
+ provider,
190
+ config,
191
+ currentRepo,
192
+ frameworkRepo,
193
+ routedProposals,
194
+ ghPath,
195
+ spawnImpl,
196
+ cwd,
197
+ timeoutMs,
198
+ maxFilingsPerRun = DEFAULT_MAX_FILINGS_PER_RUN,
199
+ logger,
200
+ } = {}) {
201
+ const envelope = { filed: [], skipped: [], errors: [] };
202
+
203
+ if (!isAutoFileEnabled(config)) {
204
+ return { filed: [], skipped: [{ reason: 'toggle-disabled' }], errors: [] };
205
+ }
206
+
207
+ const framework = Array.isArray(routedProposals?.framework)
208
+ ? routedProposals.framework
209
+ : [];
210
+ const consumer = Array.isArray(routedProposals?.consumer)
211
+ ? routedProposals.consumer
212
+ : [];
213
+ if (framework.length === 0 && consumer.length === 0) {
214
+ return {
215
+ filed: [],
216
+ skipped: [{ reason: 'no-actionable-proposals' }],
217
+ errors: [],
218
+ };
219
+ }
220
+
221
+ const buckets = [
222
+ { source: 'framework', items: framework },
223
+ { source: 'consumer', items: consumer },
224
+ ];
225
+
226
+ let remaining = maxFilingsPerRun;
227
+ for (const { source, items } of buckets) {
228
+ if (items.length === 0) continue;
229
+ const findings = items.map((item, i) => toFinding(item, source, i));
230
+ const res = await graduate({
231
+ epicId,
232
+ provider,
233
+ config,
234
+ currentRepo,
235
+ frameworkRepo,
236
+ // Each bucket's source is known — a constant classifier routes the
237
+ // whole bucket to the correct repo and stamps the correct label.
238
+ classifier: () => source,
239
+ ghPath,
240
+ spawnImpl,
241
+ cwd,
242
+ timeoutMs,
243
+ maxFilingsPerRun: Math.max(0, remaining),
244
+ findings,
245
+ logger,
246
+ spec: makeSpec(source),
247
+ });
248
+ envelope.filed.push(...res.filed);
249
+ envelope.skipped.push(...res.skipped);
250
+ envelope.errors.push(...res.errors);
251
+ remaining -= res.filed.length;
252
+ }
253
+
254
+ return envelope;
255
+ }
256
+
257
+ /**
258
+ * Pure: extract the trailing issue number from a `gh issue create` URL
259
+ * (`https://github.com/o/r/issues/123` → 123). Returns `null` when no
260
+ * trailing number is present.
261
+ *
262
+ * @param {string|null|undefined} url
263
+ * @returns {number|null}
264
+ */
265
+ export function issueNumberFromUrl(url) {
266
+ if (typeof url !== 'string') return null;
267
+ const m = url.match(/(\d+)\s*$/);
268
+ return m ? Number(m[1]) : null;
269
+ }
270
+
271
+ /**
272
+ * Pure: return a NEW `routedProposals` whose `framework` / `consumer` items
273
+ * each carry a `filedIssue` field ({ url, number }) when the graduator filed
274
+ * an issue for that item's `source` + `category`. Items with no matching
275
+ * filing are copied unchanged (the body renderer then falls back to the
276
+ * command stanza). The `discarded` bucket is passed through untouched.
277
+ *
278
+ * @param {{ framework?: object[], consumer?: object[], discarded?: object[] } | null | undefined} routedProposals
279
+ * @param {Array<{ source?: string, category?: string, url?: string|null }>} filed
280
+ * @returns {{ framework: object[], consumer: object[], discarded: object[] }}
281
+ */
282
+ export function enrichRoutedProposalsWithFilings(routedProposals, filed) {
283
+ const framework = Array.isArray(routedProposals?.framework)
284
+ ? routedProposals.framework
285
+ : [];
286
+ const consumer = Array.isArray(routedProposals?.consumer)
287
+ ? routedProposals.consumer
288
+ : [];
289
+ const discarded = Array.isArray(routedProposals?.discarded)
290
+ ? routedProposals.discarded
291
+ : [];
292
+
293
+ const byKey = new Map();
294
+ for (const record of Array.isArray(filed) ? filed : []) {
295
+ if (!record || typeof record.url !== 'string' || record.url.length === 0) {
296
+ continue;
297
+ }
298
+ const key = `${record.source}:${record.category}`;
299
+ if (!byKey.has(key)) {
300
+ byKey.set(key, {
301
+ url: record.url,
302
+ number: issueNumberFromUrl(record.url),
303
+ });
304
+ }
305
+ }
306
+
307
+ const enrich = (items, source) =>
308
+ items.map((item) => {
309
+ const filedIssue = byKey.get(`${source}:${item?.category}`);
310
+ return filedIssue ? { ...item, filedIssue } : item;
311
+ });
312
+
313
+ return {
314
+ framework: enrich(framework, 'framework'),
315
+ consumer: enrich(consumer, 'consumer'),
316
+ discarded,
317
+ };
318
+ }
319
+
320
+ /**
321
+ * Parse an `"<owner>/<repo>"` slug into `{ owner, repo }`, or `null` when
322
+ * the slug is empty / malformed.
323
+ *
324
+ * @param {string|null|undefined} slug
325
+ * @returns {{ owner: string, repo: string } | null}
326
+ */
327
+ export function parseRepoSlug(slug) {
328
+ if (typeof slug !== 'string') return null;
329
+ const parts = slug.split('/');
330
+ if (parts.length !== 2) return null;
331
+ const [owner, repo] = parts;
332
+ if (!owner || !repo) return null;
333
+ return { owner, repo };
334
+ }
335
+
336
+ /**
337
+ * Orchestrating seam invoked by the retro post-and-mirror phase: gate the
338
+ * toggle, file the routed proposals, and return the routed proposals
339
+ * enriched with the filed issue references so the body composer renders real
340
+ * issue numbers instead of command stanzas. Never throws — a filing failure
341
+ * degrades to the unenriched proposals (the composer falls back to command
342
+ * stanzas) and the error is surfaced in `errors[]`.
343
+ *
344
+ * @param {object} opts
345
+ * @param {number} opts.epicId
346
+ * @param {object} opts.provider
347
+ * @param {object} [opts.config]
348
+ * @param {string} [opts.frameworkRepo] — `"<owner>/<repo>"` slug.
349
+ * @param {string} [opts.consumerRepo] — `"<owner>/<repo>"` slug (currentRepo).
350
+ * @param {{ framework?: object[], consumer?: object[], discarded?: object[] }} [opts.routedProposals]
351
+ * @param {string} [opts.ghPath]
352
+ * @param {Function} [opts.spawnImpl]
353
+ * @param {string} [opts.cwd]
354
+ * @param {number} [opts.maxFilingsPerRun]
355
+ * @param {{info?: Function, warn?: Function}} [opts.logger]
356
+ * @param {Function} [opts.graduateFn] — test seam; defaults to
357
+ * {@link graduateRetroProposals}.
358
+ * @returns {Promise<{ routedProposals: object|null, summary: { filed: object[], skipped: object[], errors: string[] } }>}
359
+ */
360
+ export async function fileRetroProposals({
361
+ epicId,
362
+ provider,
363
+ config,
364
+ frameworkRepo,
365
+ consumerRepo,
366
+ routedProposals,
367
+ ghPath,
368
+ spawnImpl,
369
+ cwd,
370
+ maxFilingsPerRun,
371
+ logger,
372
+ graduateFn = graduateRetroProposals,
373
+ } = {}) {
374
+ const passthrough = (reason) => ({
375
+ routedProposals,
376
+ summary: { filed: [], skipped: reason ? [{ reason }] : [], errors: [] },
377
+ });
378
+
379
+ // Toggle OFF → leave proposals unenriched; the composer renders the
380
+ // paste-ready command stanzas.
381
+ if (!isAutoFileEnabled(config)) return passthrough('toggle-disabled');
382
+
383
+ const currentRepo = parseRepoSlug(consumerRepo);
384
+ if (!currentRepo) {
385
+ // No resolvable consumer repo — the retro already disables the consumer
386
+ // pane loudly; skip filing and fall back to command stanzas.
387
+ logger?.warn?.(
388
+ '[retro-proposals-graduator] No resolvable consumer repo — skipping auto-file (falling back to command stanzas).',
389
+ );
390
+ return passthrough('no-current-repo');
391
+ }
392
+ // Framework-repo fallback parity with `gatherRetroSignals`
393
+ // (gather-signals.js): an unconfigured `github.frameworkRepo` falls
394
+ // back to the Mandrel mirror constant, NEVER to the consumer's own
395
+ // repo — the prior `?? currentRepo` fallback silently auto-filed
396
+ // framework-tagged proposals into the consumer's repo while the retro
397
+ // body rendered them under "framework repo" (masked in this repo only
398
+ // because consumer === framework here).
399
+ const frameworkRepoObj =
400
+ parseRepoSlug(frameworkRepo) ?? parseRepoSlug(DEFAULT_FRAMEWORK_REPO);
401
+
402
+ let summary;
403
+ try {
404
+ summary = await graduateFn({
405
+ epicId,
406
+ provider,
407
+ config,
408
+ currentRepo,
409
+ frameworkRepo: frameworkRepoObj,
410
+ routedProposals,
411
+ ghPath,
412
+ spawnImpl,
413
+ cwd,
414
+ maxFilingsPerRun,
415
+ logger,
416
+ });
417
+ } catch (err) {
418
+ logger?.warn?.(
419
+ `[retro-proposals-graduator] Auto-file failed (falling back to command stanzas): ${err?.message ?? err}`,
420
+ );
421
+ return {
422
+ routedProposals,
423
+ summary: {
424
+ filed: [],
425
+ skipped: [],
426
+ errors: [`fileRetroProposals: ${err?.message ?? err}`],
427
+ },
428
+ };
429
+ }
430
+
431
+ const enriched = enrichRoutedProposalsWithFilings(
432
+ routedProposals,
433
+ summary.filed,
434
+ );
435
+ return { routedProposals: enriched, summary };
436
+ }
437
+
438
+ export default graduateRetroProposals;
@@ -1,8 +1,12 @@
1
1
  /**
2
2
  * Friction-signal emit helper shared by the baseline gates. The gate
3
- * supplies the violation payload; this helper owns the envelope shape
4
- * (`kind`/`timestamp`/`source`) and swallows append errors after a warn
3
+ * supplies the violation payload; this helper owns the canonical envelope
4
+ * shape (`kind`/`ts`/`emitter`) and swallows append errors after a warn
5
5
  * so observability outages never block a gate run.
6
+ *
7
+ * `details` is normalised to an object (never a bare string) per the
8
+ * Epic #4406 canonical contract — a string caller value is wrapped as
9
+ * `{ message }`.
6
10
  */
7
11
 
8
12
  import { appendSignal } from '../signals/index.js';
@@ -19,18 +23,24 @@ export async function emitFrictionSignal({
19
23
  logLabel = 'gate',
20
24
  }) {
21
25
  if (!storyId || !epicId) return;
26
+ const detailsObj =
27
+ typeof details === 'string'
28
+ ? { message: details }
29
+ : details && typeof details === 'object'
30
+ ? details
31
+ : {};
22
32
  try {
23
33
  await appendSignal({
24
34
  epicId,
25
35
  storyId,
26
36
  signal: {
27
37
  kind: 'friction',
28
- timestamp: new Date().toISOString(),
38
+ ts: new Date().toISOString(),
29
39
  epicId,
30
40
  storyId,
31
41
  category,
32
- source: { tool },
33
- details,
42
+ emitter: { tool },
43
+ details: detailsObj,
34
44
  ...payload,
35
45
  },
36
46
  config,
@@ -0,0 +1,55 @@
1
+ /**
2
+ * npm-scripts.js — shared `package.json` scripts probe (Story #4473).
3
+ *
4
+ * A single, dependency-free reader used wherever the framework must decide
5
+ * whether a consumer actually ships a given npm script before spawning
6
+ * `npm run <name>`. Two call sites depend on it:
7
+ * - `close-validation/gates.js` — only registers the coverage-capture gate
8
+ * when a `test:coverage` script exists (otherwise a consumer without it
9
+ * turns the gate into a guaranteed first-try close failure).
10
+ * - `coverage-capture.js` — fails fast with a one-line, fix-naming
11
+ * diagnostic instead of surfacing npm's opaque "Missing script" exit when
12
+ * invoked without the script.
13
+ *
14
+ * The reader is deliberately forgiving: any failure (missing file,
15
+ * unreadable, unparseable, or no `scripts` object) resolves to an empty map
16
+ * so callers treat "cannot prove the script exists" as "absent" without
17
+ * throwing.
18
+ */
19
+
20
+ import { existsSync, readFileSync } from 'node:fs';
21
+ import path from 'node:path';
22
+
23
+ /**
24
+ * Read the `scripts` map from the `package.json` at `cwd`.
25
+ *
26
+ * @param {string|undefined|null} cwd - Directory containing `package.json`.
27
+ * Defaults to `process.cwd()`.
28
+ * @returns {Record<string, string>} The scripts map, or `{}` on any failure.
29
+ */
30
+ export function readPackageScripts(cwd) {
31
+ try {
32
+ const pkgPath = path.join(cwd || process.cwd(), 'package.json');
33
+ if (!existsSync(pkgPath)) return {};
34
+ const parsed = JSON.parse(readFileSync(pkgPath, 'utf8'));
35
+ return parsed && typeof parsed.scripts === 'object' && parsed.scripts
36
+ ? parsed.scripts
37
+ : {};
38
+ } catch {
39
+ return {};
40
+ }
41
+ }
42
+
43
+ /**
44
+ * Does the consumer define a runnable npm script by this name? A script is
45
+ * "runnable" when it is a present, non-empty string.
46
+ *
47
+ * @param {Record<string, string>} scripts - A scripts map (from
48
+ * `readPackageScripts`).
49
+ * @param {string} name - The script name to check (e.g. `test:coverage`).
50
+ * @returns {boolean}
51
+ */
52
+ export function hasNpmScript(scripts, name) {
53
+ const s = scripts?.[name];
54
+ return typeof s === 'string' && s.trim().length > 0;
55
+ }