mandrel 2.38.0 → 2.40.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 +51 -11
- package/.agents/agents/auditor.md +5 -0
- package/.agents/docs/SDLC.md +21 -12
- package/.agents/docs/agentrc-reference.json +1 -4
- package/.agents/docs/configuration.md +2 -2
- package/.agents/instructions.md +17 -16
- package/.agents/schemas/agentrc.schema.json +6 -7
- package/.agents/scripts/audit-to-stories.js +510 -66
- package/.agents/scripts/generate-skills-index.js +158 -75
- package/.agents/scripts/lib/audit-to-stories/epic-grouping-directive.js +39 -0
- package/.agents/scripts/lib/audit-to-stories/ledger-commit.js +290 -0
- package/.agents/scripts/lib/audit-to-stories/parse-audit-md.js +94 -3
- package/.agents/scripts/lib/audit-to-stories/seed-from-findings.js +10 -0
- package/.agents/scripts/lib/changed-files.js +100 -9
- package/.agents/scripts/lib/config-settings-schema.js +25 -7
- package/.agents/scripts/lib/generated/agentrc-validator.js +1 -1
- package/.agents/scripts/lib/label-constants.js +18 -0
- package/.agents/scripts/lib/label-taxonomy.js +18 -5
- package/.agents/scripts/lib/orchestration/epic-container.js +186 -0
- package/.agents/scripts/lib/orchestration/epic-expansion.js +148 -0
- package/.agents/scripts/lib/orchestration/plan-persist/epic-ops.js +320 -0
- package/.agents/scripts/lib/orchestration/plan-persist/run-plan-persist.js +18 -0
- package/.agents/scripts/lib/orchestration/run-epilogue.js +130 -1
- package/.agents/scripts/lib/qa/resolve-qa-contract.js +58 -6
- package/.agents/scripts/lib/skills/skills-index.js +168 -0
- package/.agents/scripts/lib/skills/walk-skill-files.js +133 -9
- package/.agents/scripts/plan-persist.js +39 -1
- package/.agents/scripts/providers/github/sub-issue-add.js +218 -0
- package/.agents/scripts/quality-preview.js +50 -9
- package/.agents/scripts/resolve-stories.js +42 -2
- package/.agents/scripts/validate-skills.js +53 -66
- package/.agents/templates/docs/audit-sweep-runbook.md +169 -0
- package/.agents/workflows/audit-to-stories.md +85 -7
- package/.agents/workflows/helpers/audit-lens-core.md +24 -4
- package/.agents/workflows/helpers/deliver-reference.md +8 -0
- package/.agents/workflows/helpers/plan-reference.md +28 -0
- package/.agents/workflows/mandrel-deliver.md +47 -43
- package/.agents/workflows/mandrel-plan.md +44 -38
- package/.agents/workflows/qa-run.md +13 -5
- package/docs/CHANGELOG.md +28 -0
- package/package.json +1 -1
|
@@ -35,6 +35,7 @@ import { pathToFileURL } from 'node:url';
|
|
|
35
35
|
import { parseArgs } from 'node:util';
|
|
36
36
|
import { buildStoryBody } from './lib/audit-to-stories/build-story-body.js';
|
|
37
37
|
import { classifyGroupsAgainstGitHub } from './lib/audit-to-stories/dedupe-against-github.js';
|
|
38
|
+
import { formatEpicGrouping } from './lib/audit-to-stories/epic-grouping-directive.js';
|
|
38
39
|
import { withFingerprints } from './lib/audit-to-stories/finding-adapter.js';
|
|
39
40
|
import { groupFindings } from './lib/audit-to-stories/group-findings.js';
|
|
40
41
|
import {
|
|
@@ -43,7 +44,14 @@ import {
|
|
|
43
44
|
reconcileLedger,
|
|
44
45
|
writeLedger,
|
|
45
46
|
} from './lib/audit-to-stories/ledger.js';
|
|
46
|
-
import {
|
|
47
|
+
import {
|
|
48
|
+
resolveLedgerSummary,
|
|
49
|
+
runLedgerCommit,
|
|
50
|
+
} from './lib/audit-to-stories/ledger-commit.js';
|
|
51
|
+
import {
|
|
52
|
+
parseAuditReports,
|
|
53
|
+
parseSeverityTally,
|
|
54
|
+
} from './lib/audit-to-stories/parse-audit-md.js';
|
|
47
55
|
import { buildPlanSeedMarkdown } from './lib/audit-to-stories/seed-from-findings.js';
|
|
48
56
|
import { wireAuditStoryEdges } from './lib/audit-to-stories/wire-dependencies.js';
|
|
49
57
|
import { runAsCli } from './lib/cli-utils.js';
|
|
@@ -117,6 +125,180 @@ function tallyBySeverity(findings) {
|
|
|
117
125
|
return t;
|
|
118
126
|
}
|
|
119
127
|
|
|
128
|
+
/**
|
|
129
|
+
* The four levels a report's `Severity tally:` line declares. `Info` is
|
|
130
|
+
* deliberately absent — the severity scale already excludes it from scheduled
|
|
131
|
+
* work — and `unknown` is not a level a lens can declare, so neither is
|
|
132
|
+
* comparable against the line.
|
|
133
|
+
*/
|
|
134
|
+
const TALLY_LEVELS = Object.freeze(['critical', 'high', 'medium', 'low']);
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Project a findings list onto the four comparable levels, so a report's
|
|
138
|
+
* declared tally and the parsed one are compared over the same axes.
|
|
139
|
+
*
|
|
140
|
+
* @param {Array<{ severity?: string }>} findings
|
|
141
|
+
* @returns {{ critical: number, high: number, medium: number, low: number }}
|
|
142
|
+
*/
|
|
143
|
+
function comparableTally(findings) {
|
|
144
|
+
const full = tallyBySeverity(findings);
|
|
145
|
+
return Object.fromEntries(TALLY_LEVELS.map((level) => [level, full[level]]));
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function sameTally(a, b) {
|
|
149
|
+
return TALLY_LEVELS.every((level) => a[level] === b[level]);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function formatTally(tally) {
|
|
153
|
+
if (!tally) return '(no Severity tally line)';
|
|
154
|
+
return `Critical ${tally.critical} / High ${tally.high} / Medium ${tally.medium} / Low ${tally.low}`;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Run the cross-check, route its messages to stderr, and enforce the
|
|
159
|
+
* fail-closed arm — the whole report-verification step as one call, so
|
|
160
|
+
* `buildPlan` reads as a pipeline rather than absorbing the branching.
|
|
161
|
+
*
|
|
162
|
+
* `--auto` files unattended, so a report it cannot trust must stop the run
|
|
163
|
+
* BEFORE the ledger reconcile and before any Story payload is built: no GitHub
|
|
164
|
+
* write, no ledger write, non-zero exit. `--scan` reports and carries on — the
|
|
165
|
+
* failures ride the plan envelope for the operator to act on.
|
|
166
|
+
*
|
|
167
|
+
* @param {object} params
|
|
168
|
+
* @param {Array<{ sourceReport: string, markdown: string }>} params.reports
|
|
169
|
+
* @param {Array<object>} params.findings — every parsed finding, unfiltered.
|
|
170
|
+
* @param {boolean} [params.allowMissingTally]
|
|
171
|
+
* @param {boolean} [params.failOnReportFailures]
|
|
172
|
+
* @param {{ warn: Function }} params.logger
|
|
173
|
+
* @returns {Array<object>} the failures, for `summary.reportFailures[]`.
|
|
174
|
+
*/
|
|
175
|
+
function auditReportFailures({
|
|
176
|
+
reports,
|
|
177
|
+
findings,
|
|
178
|
+
allowMissingTally,
|
|
179
|
+
failOnReportFailures,
|
|
180
|
+
logger,
|
|
181
|
+
}) {
|
|
182
|
+
const { failures, warnings } = crossCheckReports({
|
|
183
|
+
reports,
|
|
184
|
+
findings,
|
|
185
|
+
allowMissingTally,
|
|
186
|
+
});
|
|
187
|
+
for (const warning of warnings) logger.warn(warning);
|
|
188
|
+
if (failures.length === 0) return failures;
|
|
189
|
+
const message = reportFailureWarning(failures);
|
|
190
|
+
logger.warn(message);
|
|
191
|
+
if (failOnReportFailures) {
|
|
192
|
+
throw new Error(
|
|
193
|
+
`refusing to file from an unverified report set. ${message}`,
|
|
194
|
+
);
|
|
195
|
+
}
|
|
196
|
+
return failures;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Cross-check every report's declared `Severity tally:` line against the
|
|
201
|
+
* findings the parser actually extracted from it (Story #5144).
|
|
202
|
+
*
|
|
203
|
+
* A parser that silently drops findings is indistinguishable from a clean
|
|
204
|
+
* report: the empty plan looks exactly like "nothing to file". The lens
|
|
205
|
+
* contract therefore mandates one machine-readable tally line per report, and
|
|
206
|
+
* this is where the two numbers meet. Three failure kinds are named:
|
|
207
|
+
*
|
|
208
|
+
* - `missing-tally` — the report declares no tally at all (an older or
|
|
209
|
+
* hand-written report). `allowMissingTally` downgrades ONLY this kind to a
|
|
210
|
+
* warning, for an interactive `--scan` over legacy reports.
|
|
211
|
+
* - `tally-mismatch` — the report says one thing and the parse says another.
|
|
212
|
+
* - `unresolved-severity` — a finding parsed with no resolvable severity. It
|
|
213
|
+
* is a report defect, never an `unknown` group.
|
|
214
|
+
*
|
|
215
|
+
* Pure: returns the messages and lets the caller own the single `Logger.warn`
|
|
216
|
+
* (stderr) sink, so `--scan` JSON on stdout stays clean.
|
|
217
|
+
*
|
|
218
|
+
* @param {object} params
|
|
219
|
+
* @param {Array<{ sourceReport: string, markdown: string }>} params.reports
|
|
220
|
+
* @param {Array<{ sourceReport: string, severity?: string, title?: string }>} params.findings
|
|
221
|
+
* @param {boolean} [params.allowMissingTally]
|
|
222
|
+
* @returns {{ failures: Array<object>, warnings: string[] }}
|
|
223
|
+
*/
|
|
224
|
+
function crossCheckReports({ reports, findings, allowMissingTally }) {
|
|
225
|
+
const failures = [];
|
|
226
|
+
const warnings = [];
|
|
227
|
+
const byReport = new Map(reports.map((r) => [r.sourceReport, []]));
|
|
228
|
+
for (const finding of findings) {
|
|
229
|
+
byReport.get(finding.sourceReport)?.push(finding);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
for (const report of reports) {
|
|
233
|
+
const own = byReport.get(report.sourceReport) ?? [];
|
|
234
|
+
const parsed = comparableTally(own);
|
|
235
|
+
const reported = parseSeverityTally(report.markdown);
|
|
236
|
+
const sourceReport = report.sourceReport;
|
|
237
|
+
const unresolved = own.filter((f) => !f.severity);
|
|
238
|
+
if (unresolved.length > 0) {
|
|
239
|
+
failures.push({
|
|
240
|
+
sourceReport,
|
|
241
|
+
kind: 'unresolved-severity',
|
|
242
|
+
reported,
|
|
243
|
+
parsed,
|
|
244
|
+
titles: unresolved.map((f) => f.title),
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
if (!reported) {
|
|
248
|
+
const failure = {
|
|
249
|
+
sourceReport,
|
|
250
|
+
kind: 'missing-tally',
|
|
251
|
+
reported: null,
|
|
252
|
+
parsed,
|
|
253
|
+
};
|
|
254
|
+
if (allowMissingTally) warnings.push(missingTallyWarning(failure));
|
|
255
|
+
else failures.push(failure);
|
|
256
|
+
continue;
|
|
257
|
+
}
|
|
258
|
+
if (!sameTally(reported, parsed)) {
|
|
259
|
+
failures.push({ sourceReport, kind: 'tally-mismatch', reported, parsed });
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
return { failures, warnings };
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* The `--allow-missing-tally` downgrade message. It still names the report and
|
|
268
|
+
* says plainly that `--auto` ignores the flag, so an operator never reads the
|
|
269
|
+
* warning as "this report is fine".
|
|
270
|
+
*
|
|
271
|
+
* @param {{ sourceReport: string, parsed: object }} failure
|
|
272
|
+
* @returns {string}
|
|
273
|
+
*/
|
|
274
|
+
function missingTallyWarning(failure) {
|
|
275
|
+
return `audit report cross-check: ${failure.sourceReport} declares no "Severity tally:" line — downgraded to a warning by --allow-missing-tally (parsed ${formatTally(failure.parsed)}). --auto ignores that flag and refuses the report.`;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* Render the report-failure block: one line per failure naming the report path
|
|
280
|
+
* and BOTH tallies, so the operator can see which side is wrong without
|
|
281
|
+
* re-reading the report.
|
|
282
|
+
*
|
|
283
|
+
* Pure: returns the message string so the caller owns the single `Logger.warn`.
|
|
284
|
+
*
|
|
285
|
+
* @param {Array<{ sourceReport: string, kind: string, reported: object|null, parsed: object, titles?: string[] }>} failures
|
|
286
|
+
* @returns {string}
|
|
287
|
+
*/
|
|
288
|
+
function reportFailureWarning(failures) {
|
|
289
|
+
const lines = failures.map((f) => {
|
|
290
|
+
const titles = f.titles?.length
|
|
291
|
+
? ` findings=${f.titles.map((t) => `"${t}"`).join(', ')}`
|
|
292
|
+
: '';
|
|
293
|
+
return ` - ${f.sourceReport} [${f.kind}] reported=${formatTally(f.reported)} parsed=${formatTally(f.parsed)}${titles}`;
|
|
294
|
+
});
|
|
295
|
+
return [
|
|
296
|
+
`audit report cross-check FAILED for ${failures.length} report(s) — the declared severity tally does not match the parsed findings:`,
|
|
297
|
+
...lines,
|
|
298
|
+
'Every report must carry "Severity tally: Critical <n> / High <n> / Medium <n> / Low <n>" in its Executive Summary, matching its own findings. Fix the report (or re-run the lens) before filing.',
|
|
299
|
+
].join('\n');
|
|
300
|
+
}
|
|
301
|
+
|
|
120
302
|
/**
|
|
121
303
|
* Test-only seam: when `AUDIT_TO_STORIES_PROVIDER_FIXTURE` names a module, load
|
|
122
304
|
* its default export as the dedup provider (ports) instead of the live GitHub
|
|
@@ -133,60 +315,198 @@ async function loadFixtureProvider() {
|
|
|
133
315
|
return mod.default ?? null;
|
|
134
316
|
}
|
|
135
317
|
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
318
|
+
/**
|
|
319
|
+
* Why the live provider could not be adapted, as a typed refusal.
|
|
320
|
+
*
|
|
321
|
+
* `loadProvider` used to collapse every one of these onto a bare `null`, which
|
|
322
|
+
* was survivable for the dedup path (it degrades to a create-only plan and
|
|
323
|
+
* warns) but not for `--wire-edges`, which cannot degrade and could therefore
|
|
324
|
+
* only blame "configuration" for what was just as likely an auth failure.
|
|
325
|
+
* Throwing a reason keeps the soft-fail (`loadProviderOrNull`) and lets the
|
|
326
|
+
* write path name the missing precondition (Story #5143).
|
|
327
|
+
*/
|
|
328
|
+
class ProviderUnavailableError extends Error {
|
|
329
|
+
/**
|
|
330
|
+
* @param {'no-config'|'provider-construction-failed'|'no-search-port'} reason
|
|
331
|
+
* @param {string} detail
|
|
332
|
+
*/
|
|
333
|
+
constructor(reason, detail) {
|
|
334
|
+
super(detail);
|
|
335
|
+
this.name = 'ProviderUnavailableError';
|
|
336
|
+
this.reason = reason;
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* Flatten one raw `searchIssues` hit onto the `{ number, state, title, body }`
|
|
342
|
+
* shape the dedupe module reads, collapsing every closed-ish state spelling
|
|
343
|
+
* (`CLOSED`, `state_reason: not_planned`, …) onto `'closed'`.
|
|
344
|
+
*
|
|
345
|
+
* @param {object} hit
|
|
346
|
+
* @returns {{ number: number, state: 'open'|'closed', title: string, body: string }}
|
|
347
|
+
*/
|
|
348
|
+
function normaliseIssueHit(hit) {
|
|
349
|
+
return {
|
|
350
|
+
number: hit.number,
|
|
351
|
+
state: (hit.state ?? hit.state_reason ?? 'open')
|
|
352
|
+
.toString()
|
|
353
|
+
.toLowerCase()
|
|
354
|
+
.includes('closed')
|
|
355
|
+
? 'closed'
|
|
356
|
+
: 'open',
|
|
357
|
+
title: hit.title ?? '',
|
|
358
|
+
body: hit.body ?? '',
|
|
359
|
+
};
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/**
|
|
363
|
+
* The two read ports the dedupe module consumes, adapted off the provider's
|
|
364
|
+
* one full-text `searchIssues` call: `findIssuesByFingerprint(sha)` for the
|
|
365
|
+
* exact-fingerprint pass and — since Story #4626 — `searchCandidates(finding)`
|
|
366
|
+
* for the meaning-first Stage-1 pass. Adapted here so no provider-shape
|
|
367
|
+
* knowledge is baked into the dedupe module.
|
|
368
|
+
*
|
|
369
|
+
* @param {object} provider
|
|
370
|
+
* @param {{ owner: string, repo: string }} coords
|
|
371
|
+
* @returns {{ findIssuesByFingerprint: Function, searchCandidates: Function }}
|
|
372
|
+
*/
|
|
373
|
+
function buildDedupPorts(provider, { owner, repo }) {
|
|
374
|
+
return {
|
|
375
|
+
async findIssuesByFingerprint(sha) {
|
|
376
|
+
const hits = await provider.searchIssues({ query: sha, owner, repo });
|
|
377
|
+
return (hits ?? []).map(normaliseIssueHit);
|
|
378
|
+
},
|
|
379
|
+
async searchCandidates(finding) {
|
|
380
|
+
// Wire the shared semantic search onto the provider's full-text
|
|
381
|
+
// issue search (open + closed) so route-finding's Stage-1 pass runs.
|
|
382
|
+
const search = async (query) => {
|
|
383
|
+
if (!query || query.trim().length === 0) return [];
|
|
384
|
+
const hits = await provider.searchIssues({ query, owner, repo });
|
|
385
|
+
return (hits ?? []).map(normaliseIssueHit);
|
|
386
|
+
};
|
|
387
|
+
return searchSemanticCandidates(finding, { search });
|
|
388
|
+
},
|
|
389
|
+
};
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/**
|
|
393
|
+
* The provider's write ports, carried through the adapter **bound** to their
|
|
394
|
+
* provider so `this` survives the hand-off — `GitHubProvider` delegates each
|
|
395
|
+
* of these to a composed gateway off `this`, so an unbound reference throws on
|
|
396
|
+
* first call. Narrowing the adapter to the dedup read ports is what made
|
|
397
|
+
* `--wire-edges` fail closed against a correctly configured repo (Story #5143).
|
|
398
|
+
*
|
|
399
|
+
* A port the provider does not implement is omitted rather than stubbed: the
|
|
400
|
+
* wire step already degrades per port (footer-only when the native dependency
|
|
401
|
+
* ports are absent), and a stub would defeat that check.
|
|
402
|
+
*
|
|
403
|
+
* @param {object} provider
|
|
404
|
+
* @returns {Record<string, Function>}
|
|
405
|
+
*/
|
|
406
|
+
function bindWritePorts(provider) {
|
|
407
|
+
const ports = {};
|
|
408
|
+
for (const name of PROVIDER_WRITE_PORTS) {
|
|
409
|
+
if (typeof provider[name] === 'function') {
|
|
410
|
+
ports[name] = provider[name].bind(provider);
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
return ports;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
/** The provider ports `--wire-edges` needs on the far side of the adapter. */
|
|
417
|
+
const PROVIDER_WRITE_PORTS = [
|
|
418
|
+
'updateTicket',
|
|
419
|
+
'getTicket',
|
|
420
|
+
'getDependencyWriteContext',
|
|
421
|
+
];
|
|
422
|
+
|
|
423
|
+
/**
|
|
424
|
+
* Resolve the config and construct the live provider, converting each way that
|
|
425
|
+
* can fail into a typed refusal.
|
|
426
|
+
*
|
|
427
|
+
* @param {{ createProviderImpl?: Function, resolveConfigImpl?: Function }} seams
|
|
428
|
+
* @returns {Promise<{ config: object, provider: object }>}
|
|
429
|
+
* @throws {ProviderUnavailableError}
|
|
430
|
+
*/
|
|
431
|
+
async function constructProvider({ createProviderImpl, resolveConfigImpl }) {
|
|
432
|
+
let config;
|
|
144
433
|
try {
|
|
145
434
|
const resolveConfig =
|
|
146
435
|
resolveConfigImpl ??
|
|
147
436
|
(await import('./lib/config-resolver.js')).resolveConfig;
|
|
437
|
+
config = resolveConfig();
|
|
438
|
+
} catch (err) {
|
|
439
|
+
throw new ProviderUnavailableError(
|
|
440
|
+
'no-config',
|
|
441
|
+
`resolving the project config failed: ${err.message}`,
|
|
442
|
+
);
|
|
443
|
+
}
|
|
444
|
+
if (!config?.github?.owner || !config?.github?.repo) {
|
|
445
|
+
throw new ProviderUnavailableError(
|
|
446
|
+
'no-config',
|
|
447
|
+
'github.owner and github.repo must both be set in .agentrc.json',
|
|
448
|
+
);
|
|
449
|
+
}
|
|
450
|
+
try {
|
|
148
451
|
const createProvider =
|
|
149
452
|
createProviderImpl ??
|
|
150
453
|
(await import('./lib/provider-factory.js')).createProvider;
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
454
|
+
return { config, provider: createProvider(config) };
|
|
455
|
+
} catch (err) {
|
|
456
|
+
throw new ProviderUnavailableError(
|
|
457
|
+
'provider-construction-failed',
|
|
458
|
+
`constructing the configured provider failed: ${err.message}`,
|
|
459
|
+
);
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
/**
|
|
464
|
+
* Adapt the configured provider for this CLI: the dedup read ports plus the
|
|
465
|
+
* provider's own write ports, bound.
|
|
466
|
+
*
|
|
467
|
+
* The `createProviderImpl` / `resolveConfigImpl` seams let a contract test
|
|
468
|
+
* drive this exact adapter with an in-memory issue store instead of the live
|
|
469
|
+
* GitHub provider. The `AUDIT_TO_STORIES_PROVIDER_FIXTURE` fixture is returned
|
|
470
|
+
* verbatim — it stands in for the whole adapter, not for the provider behind
|
|
471
|
+
* it.
|
|
472
|
+
*
|
|
473
|
+
* @param {{ createProviderImpl?: Function, resolveConfigImpl?: Function }} [seams]
|
|
474
|
+
* @returns {Promise<object>} the adapter (never null).
|
|
475
|
+
* @throws {ProviderUnavailableError} when no live provider could be adapted.
|
|
476
|
+
*/
|
|
477
|
+
async function loadProvider({ createProviderImpl, resolveConfigImpl } = {}) {
|
|
478
|
+
const fixture = await loadFixtureProvider();
|
|
479
|
+
if (fixture) return fixture;
|
|
480
|
+
const { config, provider } = await constructProvider({
|
|
481
|
+
createProviderImpl,
|
|
482
|
+
resolveConfigImpl,
|
|
483
|
+
});
|
|
484
|
+
if (typeof provider.searchIssues !== 'function') {
|
|
485
|
+
throw new ProviderUnavailableError(
|
|
486
|
+
'no-search-port',
|
|
487
|
+
'the configured provider exposes no searchIssues port',
|
|
488
|
+
);
|
|
489
|
+
}
|
|
490
|
+
return {
|
|
491
|
+
...buildDedupPorts(provider, {
|
|
492
|
+
owner: config.github.owner,
|
|
493
|
+
repo: config.github.repo,
|
|
494
|
+
}),
|
|
495
|
+
...bindWritePorts(provider),
|
|
496
|
+
};
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
/**
|
|
500
|
+
* The dedup path's soft-fail view of `loadProvider`: the provider is optional
|
|
501
|
+
* there — when it cannot be adapted the dedupe step emits a create-only
|
|
502
|
+
* classification and warns the operator loudly rather than aborting the scan.
|
|
503
|
+
*
|
|
504
|
+
* @param {{ createProviderImpl?: Function, resolveConfigImpl?: Function }} [seams]
|
|
505
|
+
* @returns {Promise<object|null>}
|
|
506
|
+
*/
|
|
507
|
+
async function loadProviderOrNull(seams = {}) {
|
|
508
|
+
try {
|
|
509
|
+
return await loadProvider(seams);
|
|
190
510
|
} catch (_) {
|
|
191
511
|
return null;
|
|
192
512
|
}
|
|
@@ -197,7 +517,8 @@ async function loadProvider({ createProviderImpl, resolveConfigImpl } = {}) {
|
|
|
197
517
|
* does NOT run against real GitHub issues. Two distinct reasons:
|
|
198
518
|
*
|
|
199
519
|
* - `'no-provider-port'` — the configured provider resolved but exposes no
|
|
200
|
-
* `searchIssues` port (or `
|
|
520
|
+
* `searchIssues` port (or `loadProviderOrNull()` swallowed a typed
|
|
521
|
+
* refusal — bad config, failed construction). This is the
|
|
201
522
|
* silent-no-op the workflow's "Never open a duplicate Issue" contract
|
|
202
523
|
* was failing on: every group classifies `create` and the operator gets
|
|
203
524
|
* zero automated dedup signal. Surfacing it loudly is the whole point.
|
|
@@ -267,7 +588,7 @@ function dedupDegradedWarning(entries) {
|
|
|
267
588
|
* @param {{
|
|
268
589
|
* collectReportPathsImpl?: typeof collectReportPaths,
|
|
269
590
|
* readReportsImpl?: typeof readReports,
|
|
270
|
-
* loadProviderImpl?: typeof
|
|
591
|
+
* loadProviderImpl?: typeof loadProviderOrNull,
|
|
271
592
|
* classifyGroupsImpl?: typeof classifyGroupsAgainstGitHub,
|
|
272
593
|
* reconcileScanLedgerImpl?: typeof reconcileScanLedger,
|
|
273
594
|
* logger?: { warn: Function },
|
|
@@ -275,13 +596,20 @@ function dedupDegradedWarning(entries) {
|
|
|
275
596
|
* @returns {Promise<object>} the plan envelope.
|
|
276
597
|
*/
|
|
277
598
|
async function buildPlan(
|
|
278
|
-
{
|
|
599
|
+
{
|
|
600
|
+
glob: pattern,
|
|
601
|
+
severity,
|
|
602
|
+
useProvider,
|
|
603
|
+
ledger,
|
|
604
|
+
allowMissingTally,
|
|
605
|
+
failOnReportFailures,
|
|
606
|
+
},
|
|
279
607
|
deps = {},
|
|
280
608
|
) {
|
|
281
609
|
const {
|
|
282
610
|
collectReportPathsImpl = collectReportPaths,
|
|
283
611
|
readReportsImpl = readReports,
|
|
284
|
-
loadProviderImpl =
|
|
612
|
+
loadProviderImpl = loadProviderOrNull,
|
|
285
613
|
classifyGroupsImpl = classifyGroupsAgainstGitHub,
|
|
286
614
|
reconcileScanLedgerImpl = reconcileScanLedger,
|
|
287
615
|
logger = Logger,
|
|
@@ -302,14 +630,25 @@ async function buildPlan(
|
|
|
302
630
|
create: 0,
|
|
303
631
|
skipOpen: 0,
|
|
304
632
|
skipReoccurring: 0,
|
|
633
|
+
reportFailures: [],
|
|
305
634
|
},
|
|
306
635
|
};
|
|
307
636
|
}
|
|
308
637
|
|
|
309
638
|
const reports = readReportsImpl(reportPaths);
|
|
310
639
|
const allFindings = parseAuditReports(reports, { repoRoot: process.cwd() });
|
|
640
|
+
const reportFailures = auditReportFailures({
|
|
641
|
+
reports,
|
|
642
|
+
findings: allFindings,
|
|
643
|
+
allowMissingTally,
|
|
644
|
+
failOnReportFailures,
|
|
645
|
+
logger,
|
|
646
|
+
});
|
|
311
647
|
const filtered = allFindings.filter((f) => meetsSeverity(f, severity));
|
|
312
|
-
|
|
648
|
+
// A finding whose severity did not resolve is a report defect, not a Story.
|
|
649
|
+
// It stays visible in `summary.tally.unknown` and in `reportFailures`, but
|
|
650
|
+
// it never reaches grouping — an `unknown` group is not a thing to file.
|
|
651
|
+
const stamped = withFingerprints(filtered.filter((f) => Boolean(f.severity)));
|
|
313
652
|
const { groups, edges } = groupFindings(stamped);
|
|
314
653
|
|
|
315
654
|
let classifications = groups.map((g) => ({
|
|
@@ -392,6 +731,7 @@ async function buildPlan(
|
|
|
392
731
|
totalFindings: allFindings.length,
|
|
393
732
|
filtered: filtered.length,
|
|
394
733
|
tally: tallyBySeverity(filtered),
|
|
734
|
+
reportFailures,
|
|
395
735
|
dedupApplied,
|
|
396
736
|
...(ledgerSummary ? { ledger: ledgerSummary } : {}),
|
|
397
737
|
...summary,
|
|
@@ -492,15 +832,35 @@ async function resolveSeverityFloor(explicit) {
|
|
|
492
832
|
* @param {boolean} [params.dryRun]
|
|
493
833
|
* @param {boolean} [params.useProvider]
|
|
494
834
|
* @param {string} [params.ledgerPath]
|
|
835
|
+
* @param {boolean} [params.ledgerCommit] — the operator asked for a ledger PR,
|
|
836
|
+
* so an unpersistable checkout is not a warning: it is about to be fixed.
|
|
837
|
+
* @param {(cwd: string, ...args: string[]) => string} [params.git] — probe seam.
|
|
838
|
+
* @param {string} [params.cwd]
|
|
839
|
+
* @param {{ warn: Function }} [params.logger]
|
|
495
840
|
* @returns {Promise<{ summary: object, stories: Array<object> }>}
|
|
496
841
|
*/
|
|
497
|
-
async function runAuto({
|
|
842
|
+
async function runAuto({
|
|
843
|
+
glob,
|
|
844
|
+
severity,
|
|
845
|
+
dryRun,
|
|
846
|
+
useProvider,
|
|
847
|
+
ledgerPath,
|
|
848
|
+
ledgerCommit,
|
|
849
|
+
git,
|
|
850
|
+
cwd,
|
|
851
|
+
logger = Logger,
|
|
852
|
+
}) {
|
|
498
853
|
const floor = await resolveSeverityFloor(severity);
|
|
854
|
+
const resolvedLedgerPath = ledgerPath ?? DEFAULT_LEDGER_PATH;
|
|
499
855
|
const plan = await buildPlan({
|
|
500
856
|
glob,
|
|
501
857
|
severity: floor,
|
|
502
858
|
useProvider,
|
|
503
|
-
ledger: { path:
|
|
859
|
+
ledger: { path: resolvedLedgerPath, write: !dryRun },
|
|
860
|
+
// `--auto` never accepts `--allow-missing-tally`: an unattended sweep has
|
|
861
|
+
// no operator to read a warning, so every report failure is fatal here.
|
|
862
|
+
allowMissingTally: false,
|
|
863
|
+
failOnReportFailures: true,
|
|
504
864
|
});
|
|
505
865
|
|
|
506
866
|
const byAction = {
|
|
@@ -538,7 +898,19 @@ async function runAuto({ glob, severity, dryRun, useProvider, ledgerPath }) {
|
|
|
538
898
|
.flatMap((c) => c.matchedIssues ?? [])
|
|
539
899
|
.map((i) => i.number)
|
|
540
900
|
.filter((n) => typeof n === 'number'),
|
|
541
|
-
|
|
901
|
+
// The sweep may have just written memory this checkout cannot keep — an
|
|
902
|
+
// ephemeral scheduled clone is exactly where that bites (Story #5145).
|
|
903
|
+
// The whole decision lives in `resolveLedgerSummary` so this assembly
|
|
904
|
+
// stays branch-free.
|
|
905
|
+
ledger: await resolveLedgerSummary({
|
|
906
|
+
ledger: plan.summary?.ledger ?? null,
|
|
907
|
+
ledgerPath: resolvedLedgerPath,
|
|
908
|
+
dryRun,
|
|
909
|
+
ledgerCommit,
|
|
910
|
+
cwd,
|
|
911
|
+
git,
|
|
912
|
+
logger,
|
|
913
|
+
}),
|
|
542
914
|
};
|
|
543
915
|
|
|
544
916
|
return { summary, stories };
|
|
@@ -584,6 +956,42 @@ function buildAndGateStories(eligible, edges) {
|
|
|
584
956
|
return built;
|
|
585
957
|
}
|
|
586
958
|
|
|
959
|
+
/**
|
|
960
|
+
* Which precondition is missing, keyed by the reason `loadProvider` refused.
|
|
961
|
+
*
|
|
962
|
+
* `--wire-edges` cannot degrade — the `blocked by #N` footers are the only
|
|
963
|
+
* thing `/mandrel-deliver`'s resolver reads — so the one thing its failure owes the
|
|
964
|
+
* operator is which of the preconditions is actually unmet. The message it
|
|
965
|
+
* replaced ("Configure github.owner/repo (and auth), or wire the edges by
|
|
966
|
+
* hand") blamed configuration for an auth failure and pointed at a manual
|
|
967
|
+
* fallback for what is a wiring bug (Story #5143).
|
|
968
|
+
*/
|
|
969
|
+
const WIRE_EDGES_PRECONDITIONS = {
|
|
970
|
+
'no-config': 'github.owner and github.repo are not both set in .agentrc.json',
|
|
971
|
+
'provider-construction-failed':
|
|
972
|
+
'the configured provider could not be constructed — check GH_TOKEN / gh auth',
|
|
973
|
+
'no-search-port': 'the configured provider exposes no issue ports',
|
|
974
|
+
'fixture-no-write-port':
|
|
975
|
+
'AUDIT_TO_STORIES_PROVIDER_FIXTURE names a fixture provider with no updateTicket port',
|
|
976
|
+
};
|
|
977
|
+
|
|
978
|
+
/**
|
|
979
|
+
* @param {string} reason A `ProviderUnavailableError.reason`, `'fixture-no-write-port'`,
|
|
980
|
+
* or `'unknown'` for a refusal that carried no reason at all.
|
|
981
|
+
* @param {string} [detail]
|
|
982
|
+
* @returns {Error}
|
|
983
|
+
*/
|
|
984
|
+
function wireEdgesPreconditionError(reason, detail) {
|
|
985
|
+
const named =
|
|
986
|
+
WIRE_EDGES_PRECONDITIONS[reason] ??
|
|
987
|
+
`the provider could not be loaded (${reason})`;
|
|
988
|
+
return new Error(
|
|
989
|
+
'--wire-edges needs a provider exposing updateTicket to rewrite the ' +
|
|
990
|
+
`Story bodies with their \`blocked by #N\` footers, but ${named}.` +
|
|
991
|
+
(detail ? ` [${detail}]` : ''),
|
|
992
|
+
);
|
|
993
|
+
}
|
|
994
|
+
|
|
587
995
|
/**
|
|
588
996
|
* The `--wire-edges` pass: hand the opened issue numbers back so the cohort's
|
|
589
997
|
* detected group edges become declared ordering (Story #5044).
|
|
@@ -608,13 +1016,14 @@ async function wireEdges({ plan, issueByGroupKey }, deps = {}) {
|
|
|
608
1016
|
const groups = (plan.classifications ?? [])
|
|
609
1017
|
.filter((c) => c.action === 'create')
|
|
610
1018
|
.map((c) => c.group);
|
|
611
|
-
|
|
1019
|
+
let provider;
|
|
1020
|
+
try {
|
|
1021
|
+
provider = await loadProviderImpl();
|
|
1022
|
+
} catch (err) {
|
|
1023
|
+
throw wireEdgesPreconditionError(err.reason ?? 'unknown', err.message);
|
|
1024
|
+
}
|
|
612
1025
|
if (typeof provider?.updateTicket !== 'function') {
|
|
613
|
-
throw
|
|
614
|
-
'--wire-edges needs a provider exposing updateTicket to rewrite the ' +
|
|
615
|
-
'Story bodies with their `blocked by #N` footers. Configure ' +
|
|
616
|
-
'github.owner/repo (and auth), or wire the edges by hand.',
|
|
617
|
-
);
|
|
1026
|
+
throw wireEdgesPreconditionError('fixture-no-write-port');
|
|
618
1027
|
}
|
|
619
1028
|
return wireImpl({
|
|
620
1029
|
groups,
|
|
@@ -672,7 +1081,10 @@ export const __testing = {
|
|
|
672
1081
|
meetsSeverity,
|
|
673
1082
|
collectReportPaths,
|
|
674
1083
|
buildPlan,
|
|
1084
|
+
crossCheckReports,
|
|
1085
|
+
reportFailureWarning,
|
|
675
1086
|
loadProvider,
|
|
1087
|
+
loadProviderOrNull,
|
|
676
1088
|
dedupSkippedWarning,
|
|
677
1089
|
dedupDegradedWarning,
|
|
678
1090
|
buildAndGateStories,
|
|
@@ -718,6 +1130,7 @@ export async function runAuditToStories(
|
|
|
718
1130
|
wireEdgesImpl = wireEdges,
|
|
719
1131
|
parseIssueMapImpl = parseIssueMap,
|
|
720
1132
|
persistImpl = persist,
|
|
1133
|
+
runLedgerCommitImpl = runLedgerCommit,
|
|
721
1134
|
stdout = process.stdout,
|
|
722
1135
|
} = deps;
|
|
723
1136
|
const { values } = parseArgs({
|
|
@@ -733,9 +1146,11 @@ export async function runAuditToStories(
|
|
|
733
1146
|
glob: { type: 'string' },
|
|
734
1147
|
severity: { type: 'string' },
|
|
735
1148
|
ledger: { type: 'string' },
|
|
1149
|
+
'ledger-commit': { type: 'boolean' },
|
|
736
1150
|
plan: { type: 'string' },
|
|
737
1151
|
out: { type: 'string' },
|
|
738
1152
|
'no-provider': { type: 'boolean' },
|
|
1153
|
+
'allow-missing-tally': { type: 'boolean' },
|
|
739
1154
|
json: { type: 'boolean' },
|
|
740
1155
|
},
|
|
741
1156
|
strict: false,
|
|
@@ -751,14 +1166,24 @@ export async function runAuditToStories(
|
|
|
751
1166
|
dryRun: values['dry-run'],
|
|
752
1167
|
useProvider: !values['no-provider'],
|
|
753
1168
|
ledgerPath: values.ledger,
|
|
1169
|
+
ledgerCommit: values['ledger-commit'],
|
|
754
1170
|
})
|
|
755
1171
|
).summary;
|
|
756
1172
|
|
|
1173
|
+
// Deliberately AFTER the summary is persisted (see the subcommand table's
|
|
1174
|
+
// `after` slot): a broken remote must not cost the operator the sweep's
|
|
1175
|
+
// findings, so the PR attempt is the last thing the run does (Story #5145).
|
|
1176
|
+
const commitLedger = async () => {
|
|
1177
|
+
if (!values['ledger-commit'] || values['dry-run']) return;
|
|
1178
|
+
await runLedgerCommitImpl({ ledgerPath: values.ledger });
|
|
1179
|
+
};
|
|
1180
|
+
|
|
757
1181
|
const scanPlan = () =>
|
|
758
1182
|
buildPlanImpl({
|
|
759
1183
|
glob: values.glob,
|
|
760
1184
|
severity: values.severity,
|
|
761
1185
|
useProvider: !values['no-provider'],
|
|
1186
|
+
allowMissingTally: values['allow-missing-tally'],
|
|
762
1187
|
});
|
|
763
1188
|
|
|
764
1189
|
const seedMarkdown = () => {
|
|
@@ -789,9 +1214,10 @@ export async function runAuditToStories(
|
|
|
789
1214
|
// renders its sub-command's output; persisting it — and the stdout newline a
|
|
790
1215
|
// piped run needs — happens once, below. The chain restated that tail in
|
|
791
1216
|
// every arm, so each new sub-command paid for it twice: once in the branch
|
|
792
|
-
// and once in the complexity budget.
|
|
1217
|
+
// and once in the complexity budget. The optional fourth slot is an
|
|
1218
|
+
// after-persist tail for work that must not pre-empt the report.
|
|
793
1219
|
const subcommands = [
|
|
794
|
-
['auto', async () => json(await runAutoSummary()), true],
|
|
1220
|
+
['auto', async () => json(await runAutoSummary()), true, commitLedger],
|
|
795
1221
|
['scan', async () => json(await scanPlan()), true],
|
|
796
1222
|
['emit-plan-seed', () => seedMarkdown(), false],
|
|
797
1223
|
['emit-stories', () => emittedStories(), true],
|
|
@@ -804,9 +1230,10 @@ export async function runAuditToStories(
|
|
|
804
1230
|
'Usage: node audit-to-stories.js (--scan | --emit-plan-seed | --emit-stories | --wire-edges) [options]',
|
|
805
1231
|
);
|
|
806
1232
|
}
|
|
807
|
-
const [, render, newlineOnStdout] = entry;
|
|
1233
|
+
const [, render, newlineOnStdout, after] = entry;
|
|
808
1234
|
persistImpl(await render(), values.out);
|
|
809
1235
|
if (newlineOnStdout && !values.out) stdout.write('\n');
|
|
1236
|
+
if (after) await after();
|
|
810
1237
|
}
|
|
811
1238
|
|
|
812
1239
|
/**
|
|
@@ -816,11 +1243,17 @@ export async function runAuditToStories(
|
|
|
816
1243
|
* numbers yet — so this is where a human driving the create pass by hand sees
|
|
817
1244
|
* the ordering they will replay through `--wire-edges` (Story #5044).
|
|
818
1245
|
*
|
|
1246
|
+
* The trailing `--- grouping ---` block carries the container-Epic default
|
|
1247
|
+
* (Story #5139), so the standalone path states it where it is actually read.
|
|
1248
|
+
* The `--json` form stays a bare array on purpose: it is a documented output
|
|
1249
|
+
* shape with a test asserting it, and the Epic is an operator decision the
|
|
1250
|
+
* workflow's Phase 4 stop owns, not a field a machine consumer acts on.
|
|
1251
|
+
*
|
|
819
1252
|
* @param {Array<{ title: string, labels: string[], body: string, groupKey?: string, dependsOn?: string[] }>} built
|
|
820
1253
|
* @returns {string}
|
|
821
1254
|
*/
|
|
822
1255
|
function renderStoryDrafts(built) {
|
|
823
|
-
|
|
1256
|
+
const drafts = built
|
|
824
1257
|
.map((s, i) => {
|
|
825
1258
|
const deps = (s.dependsOn ?? []).length
|
|
826
1259
|
? `\nDepends on group(s): ${s.dependsOn.join(', ')}`
|
|
@@ -828,6 +1261,9 @@ function renderStoryDrafts(built) {
|
|
|
828
1261
|
return `--- story ${i + 1} ---\nTitle: ${s.title}\nLabels: ${s.labels.join(', ')}\nGroup key: ${s.groupKey}${deps}\n\n${s.body}\n`;
|
|
829
1262
|
})
|
|
830
1263
|
.join('\n');
|
|
1264
|
+
|
|
1265
|
+
const grouping = formatEpicGrouping(built);
|
|
1266
|
+
return `${drafts}\n--- grouping ---\n${grouping}\n`;
|
|
831
1267
|
}
|
|
832
1268
|
|
|
833
1269
|
async function main() {
|
|
@@ -857,12 +1293,20 @@ runAsCli(import.meta.url, main, {
|
|
|
857
1293
|
['--glob <pattern>', 'Override the audit-results glob.'],
|
|
858
1294
|
['--severity <level>', 'Lowest severity to include (high|medium|low).'],
|
|
859
1295
|
['--ledger <path>', 'Path to the dedup ledger.'],
|
|
1296
|
+
[
|
|
1297
|
+
'--ledger-commit',
|
|
1298
|
+
'After the --auto summary prints, commit a changed ledger onto chore/audit-ledger-<date>, push it, and open a PR against the base branch (never auto-merged). Ignored under --dry-run.',
|
|
1299
|
+
],
|
|
860
1300
|
[
|
|
861
1301
|
'--plan <path>',
|
|
862
1302
|
'Read a previously emitted plan instead of re-scanning.',
|
|
863
1303
|
],
|
|
864
1304
|
['--out <path>', 'Write output to a file instead of stdout.'],
|
|
865
1305
|
['--no-provider', 'Skip live GitHub dedup lookups (offline).'],
|
|
1306
|
+
[
|
|
1307
|
+
'--allow-missing-tally',
|
|
1308
|
+
'Downgrade a missing "Severity tally:" line to a warning (--scan only; --auto ignores it).',
|
|
1309
|
+
],
|
|
866
1310
|
['--json', 'Force JSON output.'],
|
|
867
1311
|
['--dry-run', 'Report what would be filed; create nothing.'],
|
|
868
1312
|
],
|