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.
- package/.agents/README.md +18 -13
- package/.agents/audit-checklists/architecture.md +24 -0
- package/.agents/audit-checklists/clean-code.md +24 -0
- package/.agents/audit-checklists/dependencies.md +14 -0
- package/.agents/audit-checklists/devops.md +17 -0
- package/.agents/audit-checklists/documentation.md +22 -0
- package/.agents/audit-checklists/lighthouse.md +15 -0
- package/.agents/audit-checklists/navigability.md +14 -0
- package/.agents/audit-checklists/performance.md +22 -0
- package/.agents/audit-checklists/privacy.md +21 -0
- package/.agents/audit-checklists/quality.md +18 -0
- package/.agents/audit-checklists/security.md +22 -0
- package/.agents/audit-checklists/seo.md +16 -0
- package/.agents/audit-checklists/sre.md +24 -0
- package/.agents/audit-checklists/ux-ui.md +21 -0
- package/.agents/docs/SDLC.md +63 -16
- package/.agents/docs/configuration.md +5 -3
- package/.agents/instructions.md +51 -21
- package/.agents/personas/architect.md +10 -7
- package/.agents/personas/engineer.md +4 -3
- package/.agents/personas/project-manager.md +5 -2
- package/.agents/personas/refactorer.md +5 -3
- package/.agents/rules/git-conventions.md +77 -0
- package/.agents/schemas/agentrc.schema.json +16 -4
- package/.agents/schemas/audit-rules.json +16 -2
- package/.agents/schemas/audit-rules.schema.json +7 -6
- package/.agents/schemas/lifecycle/merge.unlanded.schema.json +38 -0
- package/.agents/schemas/signal-event.schema.json +28 -13
- package/.agents/scripts/acceptance-spec-reconciler.js +6 -4
- package/.agents/scripts/check-context-budget.js +320 -0
- package/.agents/scripts/diagnose-friction.js +4 -4
- package/.agents/scripts/epic-audit-prepare.js +30 -2
- package/.agents/scripts/epic-audit-recheck.js +46 -13
- package/.agents/scripts/epic-deliver-prepare.js +80 -8
- package/.agents/scripts/epic-plan-spec.js +4 -8
- package/.agents/scripts/generate-lens-checklists.js +180 -0
- package/.agents/scripts/lib/audit-suite/checklist-threading.js +300 -0
- package/.agents/scripts/lib/audit-suite/findings.js +27 -0
- package/.agents/scripts/lib/audit-suite/index.js +9 -0
- package/.agents/scripts/lib/audit-suite/lens-checklist.js +212 -0
- package/.agents/scripts/lib/audit-suite/selector.js +136 -5
- package/.agents/scripts/lib/checks/loop-health.js +340 -0
- package/.agents/scripts/lib/cli-args.js +8 -0
- package/.agents/scripts/lib/config/explain.js +4 -0
- package/.agents/scripts/lib/config/runners.js +21 -2
- package/.agents/scripts/lib/config/temp-paths.js +24 -0
- package/.agents/scripts/lib/config-settings-schema-delivery.js +23 -3
- package/.agents/scripts/lib/config-settings-schema-quality.js +7 -0
- package/.agents/scripts/lib/doc-tiers.js +291 -0
- package/.agents/scripts/lib/epic-body-sections.js +5 -2
- package/.agents/scripts/lib/epic-merge-lock.js +83 -0
- package/.agents/scripts/lib/epic-plan-clarity.js +3 -1
- package/.agents/scripts/lib/feedback-loop/audit-results-graduator.js +66 -20
- package/.agents/scripts/lib/feedback-loop/graduator-core.js +395 -86
- package/.agents/scripts/lib/feedback-loop/memory-freshness.js +299 -72
- package/.agents/scripts/lib/feedback-loop/retro-proposals-graduator.js +438 -0
- package/.agents/scripts/lib/gates/friction.js +15 -5
- package/.agents/scripts/lib/observability/perf-aggregator.js +30 -104
- package/.agents/scripts/lib/observability/perf-report-readers.js +1 -1
- package/.agents/scripts/lib/observability/signal-validator.js +204 -0
- package/.agents/scripts/lib/observability/signals-writer.js +157 -54
- package/.agents/scripts/lib/observability/tool-trace-hook.js +42 -4
- package/.agents/scripts/lib/orchestration/acceptance-eval-decision.js +1 -1
- package/.agents/scripts/lib/orchestration/code-review.js +74 -4
- package/.agents/scripts/lib/orchestration/consolidation-precondition.js +213 -0
- package/.agents/scripts/lib/orchestration/doc-reader.js +4 -96
- package/.agents/scripts/lib/orchestration/docs-digest.js +34 -0
- package/.agents/scripts/lib/orchestration/epic-plan-spec/phases/authoring-context.js +56 -19
- package/.agents/scripts/lib/orchestration/epic-plan-spec/phases/run-spec-phase.js +22 -0
- package/.agents/scripts/lib/orchestration/lifecycle/emit-merge-unlanded.js +188 -0
- package/.agents/scripts/lib/orchestration/lifecycle/listeners/README.md +6 -0
- package/.agents/scripts/lib/orchestration/lifecycle/listeners/automerge-armer.js +69 -8
- package/.agents/scripts/lib/orchestration/lifecycle/listeners/automerge-predicate.js +11 -2
- package/.agents/scripts/lib/orchestration/lifecycle/listeners/finalizer.js +47 -61
- package/.agents/scripts/lib/orchestration/lifecycle/listeners/index.js +39 -3
- package/.agents/scripts/lib/orchestration/lifecycle/listeners/label-transitioner.js +144 -0
- package/.agents/scripts/lib/orchestration/lifecycle/listeners/merge-watcher.js +258 -14
- package/.agents/scripts/lib/orchestration/lifecycle/listeners/notify-dispatcher.js +6 -0
- package/.agents/scripts/lib/orchestration/merge-block-class.js +218 -0
- package/.agents/scripts/lib/orchestration/plan-review-routing.js +1 -1
- package/.agents/scripts/lib/orchestration/post-merge/phases/worktree-reap.js +3 -3
- package/.agents/scripts/lib/orchestration/retro/phases/compose-body.js +63 -34
- package/.agents/scripts/lib/orchestration/retro/phases/gather-signals.js +167 -52
- package/.agents/scripts/lib/orchestration/retro/phases/post-and-mirror.js +49 -2
- package/.agents/scripts/lib/orchestration/retro-proposals.js +12 -55
- package/.agents/scripts/lib/orchestration/retro-runner.js +9 -0
- package/.agents/scripts/lib/orchestration/single-story-close/phases/code-review.js +8 -0
- package/.agents/scripts/lib/orchestration/single-story-close/phases/confirm-merge.js +419 -0
- package/.agents/scripts/lib/orchestration/single-story-close/phases/options.js +35 -2
- package/.agents/scripts/lib/orchestration/single-story-close/phases/wrong-tree-guard.js +353 -69
- package/.agents/scripts/lib/orchestration/single-story-close/runner.js +66 -4
- package/.agents/scripts/lib/orchestration/spec-section-validator.js +60 -9
- package/.agents/scripts/lib/orchestration/story-close/auto-refresh-runner.js +7 -5
- package/.agents/scripts/lib/orchestration/story-close/merge-runner.js +24 -2
- package/.agents/scripts/lib/orchestration/story-close/phases/code-review.js +167 -8
- package/.agents/scripts/lib/orchestration/story-close/shared-checkout-guard.js +163 -0
- package/.agents/scripts/lib/orchestration/ticketing/reads.js +20 -9
- package/.agents/scripts/lib/planning-corpus.js +306 -0
- package/.agents/scripts/lib/signals/detectors/common.js +10 -10
- package/.agents/scripts/lib/signals/detectors/index.js +4 -4
- package/.agents/scripts/lib/signals/detectors/retry.js +19 -18
- package/.agents/scripts/lib/signals/detectors/rework.js +1 -1
- package/.agents/scripts/lib/signals/schema.js +56 -81
- package/.agents/scripts/lib/signals/span-tree.js +6 -5
- package/.agents/scripts/lib/story-plan.js +3 -0
- package/.agents/scripts/lib/wave-runner/tick.js +10 -2
- package/.agents/scripts/lifecycle-emit.js +39 -8
- package/.agents/scripts/providers/github/issues.js +12 -1
- package/.agents/scripts/resolve-doc-tiers.js +83 -0
- package/.agents/scripts/retro-run.js +51 -0
- package/.agents/scripts/signals-view.js +1 -1
- package/.agents/scripts/single-story-close.js +20 -1
- package/.agents/scripts/standalone-feedback-rollup.js +188 -0
- package/.agents/scripts/story-close.js +48 -0
- package/.agents/scripts/story-plan.js +51 -12
- package/.agents/scripts/validate-docs-freshness.js +69 -15
- package/.agents/skills/core/documentation-and-adrs/SKILL.md +58 -0
- package/.agents/skills/core/epic-plan-decompose-author/SKILL.md +5 -3
- package/.agents/skills/core/epic-plan-spec-author/SKILL.md +20 -7
- package/.agents/skills/core/scope-triage/SKILL.md +61 -0
- package/.agents/skills/skills.index.json +3 -3
- package/.agents/workflows/audit-documentation.md +82 -2
- package/.agents/workflows/helpers/code-review.md +193 -44
- package/.agents/workflows/helpers/deliver-epic.md +128 -39
- package/.agents/workflows/helpers/deliver-stories.md +26 -0
- package/.agents/workflows/helpers/epic-audit.md +116 -283
- package/.agents/workflows/helpers/epic-deliver-story.md +14 -0
- package/.agents/workflows/helpers/epic-plan-decompose.md +18 -200
- package/.agents/workflows/helpers/epic-plan-spec.md +18 -180
- package/.agents/workflows/helpers/plan-epic.md +141 -105
- package/.agents/workflows/helpers/plan-story.md +32 -0
- package/.agents/workflows/helpers/single-story-deliver.md +43 -0
- package/.agents/workflows/loops/nightly-audit.md +9 -7
- package/docs/CHANGELOG.md +29 -0
- package/lib/cli/doctor.js +44 -0
- package/package.json +4 -3
- package/.agents/scripts/epic-plan-spec-validate.js +0 -111
- package/.agents/scripts/lib/feedback-loop/code-review-graduator.js +0 -207
- package/.agents/scripts/lib/orchestration/epic-plan-spec/phases/prompts.js +0 -58
- 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
|
|
4
|
-
* (`kind`/`
|
|
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
|
-
|
|
38
|
+
ts: new Date().toISOString(),
|
|
29
39
|
epicId,
|
|
30
40
|
storyId,
|
|
31
41
|
category,
|
|
32
|
-
|
|
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
|
-
*
|
|
12
|
-
* `lib/signals/read
|
|
13
|
-
*
|
|
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 `
|
|
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
|
-
|
|
83
|
-
? evt.
|
|
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 `
|
|
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 =
|
|
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.
|
|
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
|
|
165
|
-
* `details.
|
|
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
|
|
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.
|
|
178
|
-
|
|
186
|
+
if (typeof d.commandHash === 'string' && d.commandHash.length > 0) {
|
|
187
|
+
commandHashes.add(d.commandHash);
|
|
179
188
|
}
|
|
180
189
|
}
|
|
181
|
-
return { retries, uniqueCommands:
|
|
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 ??
|
|
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
|
|
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
|
-
}
|