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.
- 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 +62 -27
- package/.agents/docs/configuration.md +5 -4
- 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 +10 -6
- package/.agents/schemas/audit-rules.json +16 -2
- package/.agents/schemas/audit-rules.schema.json +7 -6
- package/.agents/schemas/lifecycle/epic.blocked.schema.json +1 -1
- package/.agents/schemas/lifecycle/merge.unlanded.schema.json +39 -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/coverage-capture.js +17 -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/close-validation/gates.js +64 -24
- package/.agents/scripts/lib/config/ci.js +12 -1
- package/.agents/scripts/lib/config/runners.js +13 -5
- package/.agents/scripts/lib/config/temp-paths.js +24 -0
- package/.agents/scripts/lib/config-settings-schema-delivery.js +28 -8
- 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 +47 -15
- 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/npm-scripts.js +55 -0
- 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 +193 -0
- package/.agents/scripts/lib/orchestration/lifecycle/listeners/README.md +6 -0
- package/.agents/scripts/lib/orchestration/lifecycle/listeners/automerge-armer.js +248 -13
- package/.agents/scripts/lib/orchestration/lifecycle/listeners/automerge-predicate.js +109 -12
- package/.agents/scripts/lib/orchestration/lifecycle/listeners/finalizer.js +47 -61
- package/.agents/scripts/lib/orchestration/lifecycle/listeners/index.js +46 -4
- 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 +246 -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/close-validation.js +5 -1
- 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/pre-merge-validation.js +8 -1
- 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 +116 -43
- package/.agents/workflows/helpers/deliver-epic.md +123 -54
- package/.agents/workflows/helpers/deliver-stories.md +26 -0
- package/.agents/workflows/helpers/epic-audit.md +116 -366
- 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 -224
- 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,
|
|
@@ -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
|
+
}
|