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.
Files changed (41) hide show
  1. package/.agents/README.md +51 -11
  2. package/.agents/agents/auditor.md +5 -0
  3. package/.agents/docs/SDLC.md +21 -12
  4. package/.agents/docs/agentrc-reference.json +1 -4
  5. package/.agents/docs/configuration.md +2 -2
  6. package/.agents/instructions.md +17 -16
  7. package/.agents/schemas/agentrc.schema.json +6 -7
  8. package/.agents/scripts/audit-to-stories.js +510 -66
  9. package/.agents/scripts/generate-skills-index.js +158 -75
  10. package/.agents/scripts/lib/audit-to-stories/epic-grouping-directive.js +39 -0
  11. package/.agents/scripts/lib/audit-to-stories/ledger-commit.js +290 -0
  12. package/.agents/scripts/lib/audit-to-stories/parse-audit-md.js +94 -3
  13. package/.agents/scripts/lib/audit-to-stories/seed-from-findings.js +10 -0
  14. package/.agents/scripts/lib/changed-files.js +100 -9
  15. package/.agents/scripts/lib/config-settings-schema.js +25 -7
  16. package/.agents/scripts/lib/generated/agentrc-validator.js +1 -1
  17. package/.agents/scripts/lib/label-constants.js +18 -0
  18. package/.agents/scripts/lib/label-taxonomy.js +18 -5
  19. package/.agents/scripts/lib/orchestration/epic-container.js +186 -0
  20. package/.agents/scripts/lib/orchestration/epic-expansion.js +148 -0
  21. package/.agents/scripts/lib/orchestration/plan-persist/epic-ops.js +320 -0
  22. package/.agents/scripts/lib/orchestration/plan-persist/run-plan-persist.js +18 -0
  23. package/.agents/scripts/lib/orchestration/run-epilogue.js +130 -1
  24. package/.agents/scripts/lib/qa/resolve-qa-contract.js +58 -6
  25. package/.agents/scripts/lib/skills/skills-index.js +168 -0
  26. package/.agents/scripts/lib/skills/walk-skill-files.js +133 -9
  27. package/.agents/scripts/plan-persist.js +39 -1
  28. package/.agents/scripts/providers/github/sub-issue-add.js +218 -0
  29. package/.agents/scripts/quality-preview.js +50 -9
  30. package/.agents/scripts/resolve-stories.js +42 -2
  31. package/.agents/scripts/validate-skills.js +53 -66
  32. package/.agents/templates/docs/audit-sweep-runbook.md +169 -0
  33. package/.agents/workflows/audit-to-stories.md +85 -7
  34. package/.agents/workflows/helpers/audit-lens-core.md +24 -4
  35. package/.agents/workflows/helpers/deliver-reference.md +8 -0
  36. package/.agents/workflows/helpers/plan-reference.md +28 -0
  37. package/.agents/workflows/mandrel-deliver.md +47 -43
  38. package/.agents/workflows/mandrel-plan.md +44 -38
  39. package/.agents/workflows/qa-run.md +13 -5
  40. package/docs/CHANGELOG.md +28 -0
  41. 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 { parseAuditReports } from './lib/audit-to-stories/parse-audit-md.js';
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
- async function loadProvider({ createProviderImpl, resolveConfigImpl } = {}) {
137
- // The provider is optional when missing, the dedupe step emits a
138
- // create-only classification and the workflow operator is informed. The
139
- // `createProviderImpl` / `resolveConfigImpl` seams let a contract test drive
140
- // this exact adapter (fingerprint + semantic-candidate ports) with an
141
- // in-memory issue store instead of the live GitHub provider.
142
- const fixture = await loadFixtureProvider();
143
- if (fixture) return fixture;
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
- const config = resolveConfig();
152
- const provider = createProvider(config ?? {});
153
- // The existing provider exposes higher-level ticket I/O. The dedupe
154
- // module needs `findIssuesByFingerprint(sha)` for the exact-fingerprint
155
- // pass and since Story #4626 — a `searchCandidates(finding)` port for
156
- // the meaning-first Stage-1 pass. Adapt both here so we don't bake
157
- // provider-shape knowledge into the dedupe module.
158
- if (typeof provider.searchIssues === 'function') {
159
- const owner = config?.github?.owner;
160
- const repo = config?.github?.repo;
161
- const normalise = (h) => ({
162
- number: h.number,
163
- state: (h.state ?? h.state_reason ?? 'open')
164
- .toString()
165
- .toLowerCase()
166
- .includes('closed')
167
- ? 'closed'
168
- : 'open',
169
- title: h.title ?? '',
170
- body: h.body ?? '',
171
- });
172
- return {
173
- async findIssuesByFingerprint(sha) {
174
- const hits = await provider.searchIssues({ query: sha, owner, repo });
175
- return (hits ?? []).map(normalise);
176
- },
177
- async searchCandidates(finding) {
178
- // Wire the shared semantic search onto the provider's full-text
179
- // issue search (open + closed) so route-finding's Stage-1 pass runs.
180
- const search = async (query) => {
181
- if (!query || query.trim().length === 0) return [];
182
- const hits = await provider.searchIssues({ query, owner, repo });
183
- return (hits ?? []).map(normalise);
184
- };
185
- return searchSemanticCandidates(finding, { search });
186
- },
187
- };
188
- }
189
- return null;
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 `loadProvider()` threw). This is the
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 loadProvider,
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
- { glob: pattern, severity, useProvider, ledger },
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 = loadProvider,
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
- const stamped = withFingerprints(filtered);
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({ glob, severity, dryRun, useProvider, ledgerPath }) {
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: ledgerPath ?? DEFAULT_LEDGER_PATH, write: !dryRun },
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
- ledger: plan.summary?.ledger ?? null,
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
- const provider = await loadProviderImpl();
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 new Error(
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
- return built
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
  ],