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
@@ -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,
@@ -8,12 +8,10 @@
8
8
  * - `computeStoryPerfSummary(events, opts)` → `<!-- structured:story-perf-summary -->`
9
9
  * - `computeEpicPerfReport(perStorySummaries, opts)` → `<!-- structured:epic-perf-report -->`
10
10
  *
11
- * Plus streaming counterparts that consume the canonical
12
- * `lib/signals/read` iterator directly, so the aggregator owns its own
13
- * NDJSON ingestion through the shared reader (Task #1460 AC):
14
- *
15
- * - `computeStoryPerfSummaryFromStore({ storyId, epicId, config? })`
16
- * - `computeEpicPerfReportFromStore({ epicId, perStorySummaries, config? })`
11
+ * Both take a materialised event iterable; the caller (`analyze-execution.js`)
12
+ * owns NDJSON ingestion via `lib/signals/read.js`. (The former
13
+ * streaming store-reading variants were retired in the Epic #4406
14
+ * signal-contract cutover — they had no callers.)
17
15
  *
18
16
  * Schemas:
19
17
  * - `.agents/schemas/story-perf-summary.schema.json`
@@ -39,7 +37,6 @@
39
37
  */
40
38
 
41
39
  import { isObject } from '../json-utils.js';
42
- import { read as readSignals } from '../signals/read.js';
43
40
  import { EVENT_KINDS } from '../signals/schema.js';
44
41
 
45
42
  const FRICTION_KIND = EVENT_KINDS.FRICTION;
@@ -69,7 +66,10 @@ function nonNegativeNumber(v) {
69
66
 
70
67
  /**
71
68
  * Pull friction-by-category counts off a list of NDJSON events. Keys are
72
- * the `details.category` strings; values 0 integers.
69
+ * the **top-level** `category` strings (Epic #4406 canonical shape); a
70
+ * record with no top-level category buckets under `Unknown`. Reading the
71
+ * top-level key (not `details.category`) is what un-zeroes the report —
72
+ * every writer emits `category` at the envelope top level.
73
73
  *
74
74
  * @param {Iterable<object>} events
75
75
  * @returns {Object<string, number>}
@@ -79,8 +79,8 @@ function frictionByCategory(events) {
79
79
  for (const evt of events) {
80
80
  if (!isObject(evt) || evt.kind !== FRICTION_KIND) continue;
81
81
  const category =
82
- isObject(evt.details) && typeof evt.details.category === 'string'
83
- ? evt.details.category
82
+ typeof evt.category === 'string' && evt.category.length > 0
83
+ ? evt.category
84
84
  : 'Unknown';
85
85
  out[category] = (out[category] ?? 0) + 1;
86
86
  }
@@ -125,7 +125,10 @@ function topSlowPhasesVsBaseline(events, opts = {}) {
125
125
  /**
126
126
  * Build the `reworkScore` object: `{ filesEditedBeyondThreshold, topPath?,
127
127
  * topPathEdits? }`. We aggregate `kind: 'rework'` signals whose details
128
- * carry a `path` and an `edits` count. When the input has no rework
128
+ * carry a `targetHash` and an `editCount` the exact keys
129
+ * `detectors/rework.js` emits (Epic #4406 canonical shape). `topPath` is
130
+ * the offending `targetHash` (a sha256; the raw path never reaches the
131
+ * aggregator by the privacy contract). When the input has no rework
129
132
  * signals we return the zero-shape: `{ filesEditedBeyondThreshold: 0 }`.
130
133
  *
131
134
  * @param {Iterable<object>} events
@@ -136,9 +139,12 @@ function reworkScore(events) {
136
139
  for (const evt of events) {
137
140
  if (!isObject(evt) || evt.kind !== REWORK_KIND) continue;
138
141
  const d = isObject(evt.details) ? evt.details : {};
139
- const p = typeof d.path === 'string' && d.path.length > 0 ? d.path : null;
142
+ const p =
143
+ typeof d.targetHash === 'string' && d.targetHash.length > 0
144
+ ? d.targetHash
145
+ : null;
140
146
  if (!p) continue;
141
- const edits = nonNegativeInt(d.edits);
147
+ const edits = nonNegativeInt(d.editCount);
142
148
  editsByPath.set(p, Math.max(editsByPath.get(p) ?? 0, edits));
143
149
  }
144
150
  if (editsByPath.size === 0) {
@@ -161,24 +167,27 @@ function reworkScore(events) {
161
167
 
162
168
  /**
163
169
  * Build the `retryDensity` object: `{ retries, uniqueCommands }`. Sums
164
- * `kind: 'retry'` signals; `uniqueCommands` is the number of distinct
165
- * `details.command` strings observed. Zero-shape on empty input.
170
+ * `kind: 'retry'` signals; `uniqueCommands` is the hash-cardinality of the
171
+ * distinct `details.commandHash` values observed the exact key
172
+ * `detectors/retry.js` emits (Epic #4406 canonical shape). The raw command
173
+ * never reaches the aggregator by the privacy contract, so we count
174
+ * distinct hashes. Zero-shape on empty input.
166
175
  *
167
176
  * @param {Iterable<object>} events
168
177
  * @returns {{ retries: number, uniqueCommands: number }}
169
178
  */
170
179
  function retryDensity(events) {
171
180
  let retries = 0;
172
- const commands = new Set();
181
+ const commandHashes = new Set();
173
182
  for (const evt of events) {
174
183
  if (!isObject(evt) || evt.kind !== RETRY_KIND) continue;
175
184
  const d = isObject(evt.details) ? evt.details : {};
176
185
  retries += 1;
177
- if (typeof d.command === 'string' && d.command.length > 0) {
178
- commands.add(d.command);
186
+ if (typeof d.commandHash === 'string' && d.commandHash.length > 0) {
187
+ commandHashes.add(d.commandHash);
179
188
  }
180
189
  }
181
- return { retries, uniqueCommands: commands.size };
190
+ return { retries, uniqueCommands: commandHashes.size };
182
191
  }
183
192
 
184
193
  /**
@@ -375,7 +384,7 @@ const DEFAULT_VERIFY_CONCURRENCY_CAP = 4;
375
384
  const DEFAULT_WAVE_CONCURRENCY_CAP = 2;
376
385
 
377
386
  function tsOf(evt) {
378
- return evt?.ts ?? evt?.timestamp ?? null;
387
+ return evt?.ts ?? null;
379
388
  }
380
389
 
381
390
  function tsToMs(ts) {
@@ -385,8 +394,7 @@ function tsToMs(ts) {
385
394
  }
386
395
 
387
396
  function storyIdOf(evt) {
388
- const raw = evt?.story ?? evt?.storyId;
389
- const n = Number(raw);
397
+ const n = Number(evt?.storyId);
390
398
  return Number.isInteger(n) && n > 0 ? n : null;
391
399
  }
392
400
 
@@ -803,85 +811,3 @@ export function computeEpicPerfReport(perStorySummaries, opts) {
803
811
  mostFrictionStories,
804
812
  };
805
813
  }
806
-
807
- // ---------------------------------------------------------------------------
808
- // Streaming entry-points (Epic #1181 / Story #1438 / Task #1460)
809
- // ---------------------------------------------------------------------------
810
-
811
- /**
812
- * Streaming variant of `computeStoryPerfSummary` that ingests events
813
- * directly from `lib/signals/read.js` rather than expecting the caller
814
- * to materialise the iterable upstream. The aggregation logic is
815
- * identical — we collect the events through the shared reader and then
816
- * delegate to `computeStoryPerfSummary`.
817
- *
818
- * Use this when the caller is the analyzer and already has the
819
- * `{ epicId, storyId, config }` triple; use the pure
820
- * `computeStoryPerfSummary(events, opts)` when the caller already
821
- * holds an in-memory event array (tests, mock injections).
822
- *
823
- * @param {{
824
- * storyId: number,
825
- * epicId: number,
826
- * closedAt?: string,
827
- * phaseTiming?: object|null,
828
- * config?: object,
829
- * }} opts
830
- * @returns {Promise<object>} StoryPerfSummary payload.
831
- */
832
- export async function computeStoryPerfSummaryFromStore(opts) {
833
- if (!opts || typeof opts !== 'object') {
834
- throw new TypeError('computeStoryPerfSummaryFromStore: opts is required');
835
- }
836
- const { storyId, epicId, config } = opts;
837
- const events = [];
838
- for await (const evt of readSignals({
839
- epic: Number(epicId),
840
- story: Number(storyId),
841
- config,
842
- })) {
843
- events.push(evt);
844
- }
845
- return computeStoryPerfSummary(events, {
846
- storyId,
847
- epicId,
848
- closedAt: opts.closedAt,
849
- phaseTiming: opts.phaseTiming,
850
- });
851
- }
852
-
853
- /**
854
- * Streaming variant of `computeEpicPerfReport` that ingests the
855
- * raw-event roll-up directly from `lib/signals/read.js` (across every
856
- * Story under the Epic). Per-Story summaries are still passed in by
857
- * the caller — those are the canonical per-Story payloads upserted
858
- * onto each Story ticket and not derivable from the raw stream alone
859
- * (they fold in phase-timer data).
860
- *
861
- * @param {{
862
- * epicId: number,
863
- * perStorySummaries?: Iterable<object>,
864
- * generatedAt?: string,
865
- * waveParallelism?: Array<object>,
866
- * topHotspots?: Array<object>,
867
- * config?: object,
868
- * }} opts
869
- * @returns {Promise<object>} EpicPerfReport payload.
870
- */
871
- export async function computeEpicPerfReportFromStore(opts) {
872
- if (!opts || typeof opts !== 'object') {
873
- throw new TypeError('computeEpicPerfReportFromStore: opts is required');
874
- }
875
- const { epicId, perStorySummaries, config } = opts;
876
- const events = [];
877
- for await (const evt of readSignals({ epic: Number(epicId), config })) {
878
- events.push(evt);
879
- }
880
- return computeEpicPerfReport(perStorySummaries ?? [], {
881
- epicId,
882
- generatedAt: opts.generatedAt,
883
- events,
884
- waveParallelism: opts.waveParallelism,
885
- topHotspots: opts.topHotspots,
886
- });
887
- }