mandrel 2.16.0 → 2.18.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 (69) hide show
  1. package/.agents/docs/agentrc-reference.json +10 -0
  2. package/.agents/docs/configuration.md +9 -0
  3. package/.agents/docs/quality-gates.md +137 -0
  4. package/.agents/schemas/agentrc.schema.json +48 -0
  5. package/.agents/schemas/baselines/baseline-envelope.schema.json +4 -0
  6. package/.agents/schemas/baselines/crap.schema.json +4 -0
  7. package/.agents/schemas/story-deliver-terminal.schema.json +6 -1
  8. package/.agents/scripts/acceptance-eval.js +52 -12
  9. package/.agents/scripts/audit-to-stories.js +92 -25
  10. package/.agents/scripts/boot-sweep.js +67 -8
  11. package/.agents/scripts/check-baseline-drift.js +138 -0
  12. package/.agents/scripts/coverage-capture.js +74 -25
  13. package/.agents/scripts/deliver-recover.js +45 -18
  14. package/.agents/scripts/drain-pending-cleanup.js +67 -23
  15. package/.agents/scripts/generate-lens-checklists.js +81 -30
  16. package/.agents/scripts/lib/audit-to-stories/parse-audit-md.js +88 -17
  17. package/.agents/scripts/lib/baselines/drift-detector.js +351 -0
  18. package/.agents/scripts/lib/baselines/envelope.js +7 -0
  19. package/.agents/scripts/lib/baselines/kernel.js +31 -0
  20. package/.agents/scripts/lib/baselines/kinds/crap.js +76 -0
  21. package/.agents/scripts/lib/baselines/reader.js +12 -1
  22. package/.agents/scripts/lib/baselines/refresh-service.js +7 -1
  23. package/.agents/scripts/lib/baselines/writer.js +10 -0
  24. package/.agents/scripts/lib/checks/story-init-not-backgrounded.js +23 -8
  25. package/.agents/scripts/lib/cli-utils.js +48 -13
  26. package/.agents/scripts/lib/close-validation/projections/advisories.js +184 -0
  27. package/.agents/scripts/lib/close-validation/projections/crap.js +303 -0
  28. package/.agents/scripts/lib/close-validation/runner.js +68 -0
  29. package/.agents/scripts/lib/config/gates/crap.schema.js +7 -0
  30. package/.agents/scripts/lib/config/quality.js +40 -0
  31. package/.agents/scripts/lib/config/temp-paths.js +27 -0
  32. package/.agents/scripts/lib/config-settings-schema-delivery.js +69 -0
  33. package/.agents/scripts/lib/coverage-utils.js +92 -9
  34. package/.agents/scripts/lib/crap-engine.js +113 -23
  35. package/.agents/scripts/lib/crap-utils.js +159 -93
  36. package/.agents/scripts/lib/dynamic-workflow/audit-orchestrator.js +97 -10
  37. package/.agents/scripts/lib/dynamic-workflow/degraded-coverage.js +81 -0
  38. package/.agents/scripts/lib/git-branch-lifecycle.js +15 -8
  39. package/.agents/scripts/lib/observability/terse-result.js +7 -3
  40. package/.agents/scripts/lib/orchestration/check-baselines/phases/compare.js +35 -0
  41. package/.agents/scripts/lib/orchestration/check-baselines/phases/evaluate.js +13 -0
  42. package/.agents/scripts/lib/orchestration/git-cleanup/phases/git-probes-ff.js +16 -1
  43. package/.agents/scripts/lib/orchestration/plan-persist/run-plan-persist.js +19 -41
  44. package/.agents/scripts/lib/orchestration/single-story-close/failed-terminal.js +122 -0
  45. package/.agents/scripts/lib/orchestration/single-story-close/gate-log.js +9 -5
  46. package/.agents/scripts/lib/orchestration/single-story-close/phases/close-validation.js +15 -1
  47. package/.agents/scripts/lib/orchestration/single-story-close/phases/post-land.js +31 -1
  48. package/.agents/scripts/lib/orchestration/story-deliver-terminal-schema.js +166 -0
  49. package/.agents/scripts/lib/orchestration/story-deliver-terminal.js +21 -50
  50. package/.agents/scripts/lib/orchestration/ticket-validator-conflicts.js +26 -12
  51. package/.agents/scripts/lib/single-story-sweep.js +11 -0
  52. package/.agents/scripts/lib/stdio-flush.js +71 -0
  53. package/.agents/scripts/lib/temp-retention.js +559 -0
  54. package/.agents/scripts/lib/transpile.js +133 -6
  55. package/.agents/scripts/lib/workers/combined-mi-crap-worker.js +47 -101
  56. package/.agents/scripts/lib/workers/crap-worker.js +49 -76
  57. package/.agents/scripts/lib/worktree/lifecycle/reap.js +81 -8
  58. package/.agents/scripts/nav-registry-diff.js +30 -8
  59. package/.agents/scripts/plan-run-epilogue.js +27 -11
  60. package/.agents/scripts/resolve-doc-tiers.js +18 -8
  61. package/.agents/scripts/single-story-close.js +9 -92
  62. package/.agents/scripts/single-story-init.js +1 -1
  63. package/.agents/scripts/sync-branch-from-base.js +6 -1
  64. package/.agents/scripts/update-crap-baseline.js +13 -0
  65. package/README.md +14 -6
  66. package/docs/CHANGELOG.md +36 -0
  67. package/lib/cli/version-helpers.js +7 -0
  68. package/lib/migrations/steps/2.2.0-retire-epic-ac-tags.js +15 -8
  69. package/package.json +5 -1
@@ -2,15 +2,15 @@ import fs from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import escomplex from 'typhonjs-escomplex';
4
4
  import { canonicalise as canonicalisePath } from './baselines/path-canon.js';
5
- import {
6
- coverageForMethodInEntry,
7
- findCoverageEntry,
8
- } from './coverage-utils.js';
5
+ import { findCoverageEntry } from './coverage-utils.js';
9
6
  import { POOL_SERIAL_THRESHOLD, runOnPool } from './cpu-pool.js';
10
- import { crapFormula } from './crap-engine.js';
7
+ import { finalizeMethodRows, methodRowsFromReport } from './crap-engine.js';
11
8
  import { Logger } from './Logger.js';
12
9
  import { scanDirectory } from './maintainability-utils.js';
13
- import { resolveTsTranspilerVersion, transpileIfNeeded } from './transpile.js';
10
+ import {
11
+ prepareSourceForScoring,
12
+ resolveTsTranspilerVersion,
13
+ } from './transpile.js';
14
14
 
15
15
  const CRAP_WORKER_URL = new URL('./workers/crap-worker.js', import.meta.url);
16
16
  const COMBINED_MI_CRAP_WORKER_URL = new URL(
@@ -201,6 +201,103 @@ export function buildBaselineEnvelope({
201
201
  };
202
202
  }
203
203
 
204
+ /**
205
+ * How many files to name when reporting the worst unresolved offenders. Long
206
+ * enough to point at a pattern, short enough to stay a readable CLI message.
207
+ */
208
+ const WORST_OFFENDER_LIMIT = 5;
209
+
210
+ /**
211
+ * Method-resolution telemetry (Story #4775, fix part 4).
212
+ *
213
+ * The updater used to persist a 100-row baseline built from 5023 dropped
214
+ * methods and log it as success — the rot that let a broken coverage join
215
+ * sit undetected for five weeks across three repos. These three helpers
216
+ * carry the counters that make a thin result *visible* and therefore
217
+ * refusable.
218
+ *
219
+ * The rate is deliberately measured over files that **do** have a coverage
220
+ * entry: a file the test run never touched has no join to fail, so counting
221
+ * it would dilute the signal the floor is meant to catch.
222
+ */
223
+ function newResolutionAccumulator() {
224
+ return { resolved: 0, total: 0, byFile: [] };
225
+ }
226
+
227
+ function accumulateResolution(acc, relPath, result) {
228
+ if (result?.hasCoverageEntry !== true) return;
229
+ const total = result.totalMethods ?? 0;
230
+ if (total === 0) return;
231
+ const resolved = result.resolvedMethods ?? 0;
232
+ acc.resolved += resolved;
233
+ acc.total += total;
234
+ if (resolved < total) {
235
+ acc.byFile.push({ file: relPath, unresolved: total - resolved, total });
236
+ }
237
+ }
238
+
239
+ function summarizeResolution(acc) {
240
+ const worstFiles = [...acc.byFile]
241
+ .sort((a, b) => b.unresolved - a.unresolved || a.file.localeCompare(b.file))
242
+ .slice(0, WORST_OFFENDER_LIMIT);
243
+ return {
244
+ resolvedMethods: acc.resolved,
245
+ joinableMethods: acc.total,
246
+ rate: acc.total === 0 ? 1 : acc.resolved / acc.total,
247
+ worstFiles,
248
+ };
249
+ }
250
+
251
+ /**
252
+ * Minimum number of joinable methods before the resolution-rate floor is
253
+ * enforced. A diff-scoped run can legitimately touch a handful of methods,
254
+ * where one unresolved method is a 50% rate and says nothing about the health
255
+ * of the join. Below this sample the rate is reported, never enforced.
256
+ */
257
+ const MIN_RESOLUTION_SAMPLE = 25;
258
+
259
+ /**
260
+ * Fail-closed guard on the per-method coverage join (Story #4775, fix part 4).
261
+ *
262
+ * The updater used to persist a 100-row baseline distilled from 5023 dropped
263
+ * methods and log it as a success — which is exactly how a broken join stayed
264
+ * invisible for five weeks across three repositories. A thin result is now a
265
+ * refusal: the caller throws before anything is written, and the message names
266
+ * the rate, the counts, and the files carrying the most unresolved methods so
267
+ * the operator can tell "my tests do not cover that" apart from "the join is
268
+ * broken".
269
+ *
270
+ * Returns `null` when the run may proceed, or the operator-facing message when
271
+ * it must not.
272
+ *
273
+ * @param {{resolvedMethods: number, joinableMethods: number, rate: number,
274
+ * worstFiles: Array<{file: string, unresolved: number, total: number}>}
275
+ * | undefined} resolution
276
+ * @param {number} floor
277
+ * @returns {string|null}
278
+ */
279
+ export function checkResolutionFloor(resolution, floor) {
280
+ if (!resolution) return null;
281
+ const { joinableMethods = 0, resolvedMethods = 0, rate = 1 } = resolution;
282
+ if (joinableMethods < MIN_RESOLUTION_SAMPLE) return null;
283
+ if (rate >= floor) return null;
284
+ const worst = (resolution.worstFiles ?? [])
285
+ .map((w) => ` - ${w.file} (${w.unresolved}/${w.total} unresolved)`)
286
+ .join('\n');
287
+ return (
288
+ `[CRAP] Refusing to persist: only ${resolvedMethods}/${joinableMethods} ` +
289
+ `method(s) (${(rate * 100).toFixed(1)}%) resolved a coverage entry in files ` +
290
+ `that HAVE coverage — below the ${(floor * 100).toFixed(1)}% floor ` +
291
+ '(delivery.quality.gates.crap.minMethodResolutionRate).\n' +
292
+ ' A baseline built from a broken join is not sparse, it is wrong: ' +
293
+ 'unresolved methods are absent and coincidental line collisions are ' +
294
+ 'mis-attributed.\n' +
295
+ (worst ? ` Worst unresolved files:\n${worst}\n` : '') +
296
+ " Regenerate coverage ('npm run test:coverage') and re-run; if the " +
297
+ 'rate stays low the coverage artifact and the scanned tree disagree.'
298
+ );
299
+ }
300
+
204
301
  /**
205
302
  * Parse `source` exactly once with escomplex and derive both the
206
303
  * maintainability score and the raw CRAP method rows from that single report.
@@ -216,6 +313,10 @@ export function buildBaselineEnvelope({
216
313
  *
217
314
  * @param {string} source Prepared (possibly transpiled) JavaScript source text.
218
315
  * @param {object|null} coverageForFile Istanbul coverage entry for this file.
316
+ * @param {((line: number) => number|null)|null} [mapLine] Transpiled →
317
+ * original-source line resolver from `transpileIfNeeded(…, {withLineMap:
318
+ * true})`; `null` for JavaScript, whose coordinates already match the
319
+ * coverage entry's.
219
320
  * @returns {{
220
321
  * report: object,
221
322
  * miScore: number,
@@ -229,7 +330,7 @@ export function buildBaselineEnvelope({
229
330
  * parseError: boolean,
230
331
  * }}
231
332
  */
232
- export function analyzeOnce(source, coverageForFile) {
333
+ export function analyzeOnce(source, coverageForFile, mapLine = null) {
233
334
  let report;
234
335
  try {
235
336
  report = escomplex.analyzeModule(source);
@@ -238,18 +339,7 @@ export function analyzeOnce(source, coverageForFile) {
238
339
  }
239
340
  const miScore =
240
341
  typeof report.maintainability === 'number' ? report.maintainability : 0;
241
- const methods = report?.methods ?? [];
242
- const crapRows = [];
243
- for (const m of methods) {
244
- const startLine = m?.lineStart;
245
- if (typeof startLine !== 'number') continue;
246
- const cyclomatic = m?.cyclomatic ?? 0;
247
- const coverage = coverageForFile
248
- ? coverageForMethodInEntry(coverageForFile, startLine)
249
- : null;
250
- const crap = coverage === null ? null : crapFormula(cyclomatic, coverage);
251
- crapRows.push({ method: m.name, startLine, cyclomatic, coverage, crap });
252
- }
342
+ const crapRows = methodRowsFromReport(report, coverageForFile, mapLine);
253
343
  return { report, miScore, crapRows, parseError: false };
254
344
  }
255
345
 
@@ -348,6 +438,7 @@ export async function scanAndScore({
348
438
  const rows = [];
349
439
  let skippedFilesNoCoverage = 0;
350
440
  let skippedMethodsNoCoverage = 0;
441
+ const resolution = newResolutionAccumulator();
351
442
  for (const { item, result } of perFile) {
352
443
  if (!result) continue; // unrecoverable per-file failure: drop silently to match pre-pool semantics
353
444
  if (result.skippedFileNoCoverage) {
@@ -366,6 +457,7 @@ export async function scanAndScore({
366
457
  continue;
367
458
  }
368
459
  skippedMethodsNoCoverage += result.skippedMethodsNoCoverage ?? 0;
460
+ accumulateResolution(resolution, item.relPath, result);
369
461
  for (const mr of result.rows) {
370
462
  rows.push({
371
463
  file: item.relPath,
@@ -390,6 +482,7 @@ export async function scanAndScore({
390
482
  scannedFiles,
391
483
  skippedFilesNoCoverage,
392
484
  skippedMethodsNoCoverage,
485
+ resolution: summarizeResolution(resolution),
393
486
  };
394
487
  }
395
488
 
@@ -408,50 +501,33 @@ function scoreFileSerial({ abs, relPath, requireCoverage }, coverage) {
408
501
  skippedFileNoCoverage: true,
409
502
  rows: [],
410
503
  skippedMethodsNoCoverage: 0,
504
+ hasCoverageEntry: false,
505
+ resolvedMethods: 0,
506
+ totalMethods: 0,
411
507
  };
412
508
  }
413
- let source;
414
- try {
415
- source = fs.readFileSync(abs, 'utf-8');
416
- } catch {
417
- return {
418
- skippedFileNoCoverage: false,
419
- rows: null,
420
- skippedMethodsNoCoverage: 0,
421
- };
422
- }
423
- const prepared = transpileIfNeeded(abs, source);
424
- if (prepared === null) {
425
- return {
426
- skippedFileNoCoverage: false,
427
- rows: null,
428
- skippedMethodsNoCoverage: 0,
429
- };
430
- }
431
- const { crapRows, parseError } = analyzeOnce(prepared, entry);
432
- if (parseError) {
433
- return {
434
- skippedFileNoCoverage: false,
435
- rows: null,
436
- skippedMethodsNoCoverage: 0,
437
- };
438
- }
439
- const rows = [];
440
- let skippedMethodsNoCoverage = 0;
441
- for (const mr of crapRows) {
442
- if (mr.crap === null || mr.coverage === null) {
443
- skippedMethodsNoCoverage += 1;
444
- continue;
445
- }
446
- rows.push({
447
- method: mr.method,
448
- startLine: mr.startLine,
449
- cyclomatic: mr.cyclomatic,
450
- coverage: mr.coverage,
451
- crap: mr.crap,
452
- });
453
- }
454
- return { skippedFileNoCoverage: false, rows, skippedMethodsNoCoverage };
509
+ const dropped = {
510
+ skippedFileNoCoverage: false,
511
+ rows: null,
512
+ skippedMethodsNoCoverage: 0,
513
+ hasCoverageEntry: entry !== null,
514
+ resolvedMethods: 0,
515
+ totalMethods: 0,
516
+ };
517
+ const prepared = prepareSourceForScoring(abs);
518
+ if (prepared.error) return dropped;
519
+ const { crapRows, parseError } = analyzeOnce(
520
+ prepared.code,
521
+ entry,
522
+ prepared.mapLine,
523
+ );
524
+ if (parseError) return dropped;
525
+ const finalized = finalizeMethodRows(crapRows, { requireCoverage });
526
+ return {
527
+ skippedFileNoCoverage: false,
528
+ hasCoverageEntry: entry !== null,
529
+ ...finalized,
530
+ };
455
531
  }
456
532
 
457
533
  async function scoreFilesViaPool(queue, coverage) {
@@ -494,33 +570,24 @@ async function scoreFilesViaPool(queue, coverage) {
494
570
  */
495
571
  function scoreFileCombinedSerial({ abs, relPath, requireCoverage }, coverage) {
496
572
  const entry = findCoverageEntry(coverage, relPath);
497
- let source;
498
- try {
499
- source = fs.readFileSync(abs, 'utf-8');
500
- } catch {
501
- return {
502
- relPath,
503
- miScore: null,
504
- skippedFileNoCoverage: false,
505
- crapRows: null,
506
- skippedMethodsNoCoverage: 0,
507
- };
508
- }
509
- const prepared = transpileIfNeeded(abs, source);
510
- if (prepared === null) {
573
+ const prepared = prepareSourceForScoring(abs);
574
+ if (prepared.error) {
511
575
  return {
512
576
  relPath,
513
- miScore: 0,
577
+ miScore: prepared.error === 'read' ? null : 0,
514
578
  skippedFileNoCoverage: false,
515
579
  crapRows: null,
516
580
  skippedMethodsNoCoverage: 0,
581
+ hasCoverageEntry: entry !== null,
582
+ resolvedMethods: 0,
583
+ totalMethods: 0,
517
584
  };
518
585
  }
519
586
  const {
520
587
  miScore,
521
588
  crapRows: rawCrapRows,
522
589
  parseError,
523
- } = analyzeOnce(prepared, entry);
590
+ } = analyzeOnce(prepared.code, entry, prepared.mapLine);
524
591
  if (parseError) {
525
592
  return {
526
593
  relPath,
@@ -528,6 +595,9 @@ function scoreFileCombinedSerial({ abs, relPath, requireCoverage }, coverage) {
528
595
  skippedFileNoCoverage: false,
529
596
  crapRows: null,
530
597
  skippedMethodsNoCoverage: 0,
598
+ hasCoverageEntry: entry !== null,
599
+ resolvedMethods: 0,
600
+ totalMethods: 0,
531
601
  };
532
602
  }
533
603
  if (requireCoverage && entry === null) {
@@ -537,29 +607,22 @@ function scoreFileCombinedSerial({ abs, relPath, requireCoverage }, coverage) {
537
607
  skippedFileNoCoverage: true,
538
608
  crapRows: [],
539
609
  skippedMethodsNoCoverage: 0,
610
+ hasCoverageEntry: false,
611
+ resolvedMethods: 0,
612
+ totalMethods: 0,
540
613
  };
541
614
  }
542
- const crapRows = [];
543
- let skippedMethodsNoCoverage = 0;
544
- for (const mr of rawCrapRows) {
545
- if (mr.crap === null || mr.coverage === null) {
546
- skippedMethodsNoCoverage += 1;
547
- continue;
548
- }
549
- crapRows.push({
550
- method: mr.method,
551
- startLine: mr.startLine,
552
- cyclomatic: mr.cyclomatic,
553
- coverage: mr.coverage,
554
- crap: mr.crap,
555
- });
556
- }
615
+ const { rows, skippedMethodsNoCoverage, resolvedMethods, totalMethods } =
616
+ finalizeMethodRows(rawCrapRows, { requireCoverage });
557
617
  return {
558
618
  relPath,
559
619
  miScore,
560
620
  skippedFileNoCoverage: false,
561
- crapRows,
621
+ crapRows: rows,
562
622
  skippedMethodsNoCoverage,
623
+ hasCoverageEntry: entry !== null,
624
+ resolvedMethods,
625
+ totalMethods,
563
626
  };
564
627
  }
565
628
 
@@ -693,6 +756,7 @@ export async function scanAndScoreCombined({
693
756
  const crapRows = [];
694
757
  let skippedFilesNoCoverage = 0;
695
758
  let skippedMethodsNoCoverage = 0;
759
+ const resolution = newResolutionAccumulator();
696
760
 
697
761
  for (const { item, result } of perFile) {
698
762
  if (!result) continue; // unrecoverable per-file failure: drop silently
@@ -716,6 +780,7 @@ export async function scanAndScoreCombined({
716
780
  continue;
717
781
  }
718
782
  skippedMethodsNoCoverage += result.skippedMethodsNoCoverage ?? 0;
783
+ accumulateResolution(resolution, item.relPath, result);
719
784
  for (const mr of result.crapRows) {
720
785
  crapRows.push({
721
786
  file: item.relPath,
@@ -750,6 +815,7 @@ export async function scanAndScoreCombined({
750
815
  scannedFiles,
751
816
  skippedFilesNoCoverage,
752
817
  skippedMethodsNoCoverage,
818
+ resolution: summarizeResolution(resolution),
753
819
  },
754
820
  };
755
821
  }
@@ -26,6 +26,18 @@
26
26
  * single write in the run is the report artifact, performed by the synthesis
27
27
  * agent, which is granted the read-only allowlist plus `Write`.
28
28
  *
29
+ * ## Partial-failure posture
30
+ *
31
+ * The per-dimension fan-out is settled, not all-or-nothing (Story #4783). One
32
+ * rejected dimension used to discard every sibling's completed work; the
33
+ * engine now partitions the settled results, flows the fulfilled dimensions on
34
+ * to the next phase, and records the rejected ones as an explicit
35
+ * degraded-coverage note in the report's Executive Summary. This mirrors the
36
+ * posture `close-validation/runner.js` already takes with per-gate errors:
37
+ * capture the failure into the result rather than rejecting the whole run.
38
+ * Only a *total* loss — every dimension rejected — throws, because there is
39
+ * then nothing to synthesise.
40
+ *
29
41
  * ## Report-contract self-check
30
42
  *
31
43
  * After synthesis the engine calls the caller-supplied `assertReportContract`
@@ -43,6 +55,14 @@
43
55
  * @module dynamic-workflow/audit-orchestrator
44
56
  */
45
57
 
58
+ import { withDegradedCoverageNote } from './degraded-coverage.js';
59
+
60
+ /**
61
+ * A dimension that did not complete, as recorded by the settled fan-out.
62
+ *
63
+ * @typedef {import('./degraded-coverage.js').DimensionFailure} DimensionFailure
64
+ */
65
+
46
66
  /**
47
67
  * The live dynamic-workflow runtime context the host passes to a saved
48
68
  * `.claude/workflows/*.workflow.js` entry point. Re-exported as a typedef so
@@ -82,8 +102,11 @@
82
102
  * Compose the analysis prompt for one dimension (lens-specific).
83
103
  * @property {(dimension: string, findings: string) => string} buildCrossCheckPrompt
84
104
  * Compose the adversarial cross-check prompt for one dimension's findings.
85
- * @property {(crossCheckedBlocks: string[]) => string} buildSynthesisPrompt
86
- * Compose the synthesis prompt that assembles the report and writes it.
105
+ * @property {(crossCheckedBlocks: string[], degraded: DimensionFailure[]) => string} buildSynthesisPrompt
106
+ * Compose the synthesis prompt that assembles the report and writes it. The
107
+ * second argument lists the dimensions that did not complete (empty on a
108
+ * full-coverage run); lenses may ignore it — the engine annotates the report
109
+ * with the degraded-coverage note either way.
87
110
  * @property {(report: string) => { conformant: boolean, missingSections: string[], hasTitle: boolean }} assertReportContract
88
111
  * Self-check the synthesised report against the lens's report contract.
89
112
  * @property {(check: { conformant: boolean, missingSections: string[], hasTitle: boolean }) => string} [formatContractError]
@@ -122,6 +145,45 @@ export function defaultContractError(check) {
122
145
  return `report failed contract check: missing ${titlePart}sections=[${sections}]`;
123
146
  }
124
147
 
148
+ /**
149
+ * Reduce a rejection reason to a single-line message.
150
+ *
151
+ * @param {unknown} reason
152
+ * @returns {string}
153
+ */
154
+ function describeRejection(reason) {
155
+ if (reason instanceof Error) return reason.message;
156
+ return String(reason ?? 'unknown error');
157
+ }
158
+
159
+ /**
160
+ * Partition settled fan-out results into the values that completed and the
161
+ * dimensions that did not. `dimensions[i]` names the dimension behind
162
+ * `settled[i]`, so a rejection is always attributable.
163
+ *
164
+ * @template T
165
+ * @param {readonly string[]} dimensions
166
+ * @param {readonly PromiseSettledResult<T>[]} settled
167
+ * @param {string} phaseName Phase recorded on each failure.
168
+ * @returns {{ fulfilled: T[], failures: DimensionFailure[] }}
169
+ */
170
+ function partitionSettled(dimensions, settled, phaseName) {
171
+ const fulfilled = [];
172
+ const failures = [];
173
+ settled.forEach((result, index) => {
174
+ if (result.status === 'fulfilled') {
175
+ fulfilled.push(result.value);
176
+ return;
177
+ }
178
+ failures.push({
179
+ dimension: dimensions[index],
180
+ phase: phaseName,
181
+ reason: describeRejection(result.reason),
182
+ });
183
+ });
184
+ return { fulfilled, failures };
185
+ }
186
+
125
187
  /**
126
188
  * Run the shared three-phase audit-lens orchestration: parallel per-dimension
127
189
  * analysis → adversarial cross-check → synthesis + report-contract self-check.
@@ -149,9 +211,10 @@ export async function runAuditOrchestration(spec) {
149
211
 
150
212
  const { agent, phase } = ctx;
151
213
 
152
- // Phase 1 — parallel per-dimension analysis (read-only agents).
153
- const rawFindings = await phase(ORCHESTRATION_PHASES.ANALYZE, async () =>
154
- Promise.all(
214
+ // Phase 1 — parallel per-dimension analysis (read-only agents). Settled, not
215
+ // all-or-nothing: one dimension's rejection must not discard its siblings.
216
+ const analyzed = await phase(ORCHESTRATION_PHASES.ANALYZE, async () =>
217
+ Promise.allSettled(
155
218
  dimensions.map(async (dimension) => {
156
219
  const { output } = await agent({
157
220
  prompt: buildDimensionPrompt(dimension),
@@ -161,11 +224,15 @@ export async function runAuditOrchestration(spec) {
161
224
  }),
162
225
  ),
163
226
  );
227
+ const { fulfilled: rawFindings, failures: analyzeFailures } =
228
+ partitionSettled(dimensions, analyzed, 'analyze');
164
229
 
165
230
  // Phase 2 — adversarial cross-check: an independent agent re-verifies each
166
- // dimension's findings and filters false positives before inclusion.
167
- const crossChecked = await phase(ORCHESTRATION_PHASES.CROSS_CHECK, async () =>
168
- Promise.all(
231
+ // surviving dimension's findings and filters false positives before
232
+ // inclusion. Settled for the same reason as phase 1.
233
+ const checkedDimensions = rawFindings.map((entry) => entry.dimension);
234
+ const checked = await phase(ORCHESTRATION_PHASES.CROSS_CHECK, async () =>
235
+ Promise.allSettled(
169
236
  rawFindings.map(async ({ dimension, findings }) => {
170
237
  const { output } = await agent({
171
238
  prompt: buildCrossCheckPrompt(dimension, findings),
@@ -175,18 +242,38 @@ export async function runAuditOrchestration(spec) {
175
242
  }),
176
243
  ),
177
244
  );
245
+ const { fulfilled: crossChecked, failures: crossCheckFailures } =
246
+ partitionSettled(checkedDimensions, checked, 'cross-check');
247
+
248
+ const degraded = [...analyzeFailures, ...crossCheckFailures];
249
+ if (dimensions.length > 0 && crossChecked.length === 0) {
250
+ // Nothing survived — there is no partial report to salvage.
251
+ throw new Error(
252
+ `every audit dimension failed: ${degraded
253
+ .map((f) => `${f.dimension} (${f.phase}: ${f.reason})`)
254
+ .join('; ')}`,
255
+ );
256
+ }
178
257
 
179
258
  // Phase 3 — synthesis: assemble the report contract and write the artifact.
180
- const { output: report } = await phase(
259
+ const { output: synthesised } = await phase(
181
260
  ORCHESTRATION_PHASES.SYNTHESIZE,
182
261
  async () =>
183
262
  agent({
184
- prompt: buildSynthesisPrompt(crossChecked),
263
+ prompt: buildSynthesisPrompt(crossChecked, degraded),
185
264
  // Synthesis is the one stage permitted to write the report artifact.
186
265
  allowedTools: [...readOnlyTools, SYNTHESIS_WRITE_TOOL],
187
266
  }),
188
267
  );
189
268
 
269
+ // The coverage gap is annotated by the engine, never left to the synthesis
270
+ // agent's discretion.
271
+ const report = withDegradedCoverageNote(
272
+ synthesised,
273
+ degraded,
274
+ dimensions.length,
275
+ );
276
+
190
277
  // Self-verify report-contract conformance before returning.
191
278
  const check = assertReportContract(report);
192
279
  if (!check.conformant) {
@@ -0,0 +1,81 @@
1
+ // .agents/scripts/lib/dynamic-workflow/degraded-coverage.js
2
+ /**
3
+ * Degraded-coverage annotation for audit-lens reports (Story #4783).
4
+ *
5
+ * An audit lens fans out one sub-agent per analysis dimension. When one of
6
+ * those dimensions rejects — a sub-agent that ran out of context, a
7
+ * measurement command that failed, a transient runtime error — the run used to
8
+ * discard every sibling dimension's completed work along with it.
9
+ *
10
+ * The engine now partitions instead: the fulfilled dimensions flow on to
11
+ * synthesis, and the rejected ones are recorded here as an explicit note in
12
+ * the report's Executive Summary. A lens that covers four of five dimensions
13
+ * *and says which one is missing* is strictly more useful than one that yields
14
+ * nothing — but only if the gap is visible. An unannotated partial report is
15
+ * worse than no report, because it reads as complete coverage.
16
+ *
17
+ * The annotation is applied by the engine, not requested of the synthesis
18
+ * agent: a coverage disclaimer that depends on an LLM remembering to write it
19
+ * is not a disclaimer.
20
+ *
21
+ * @module dynamic-workflow/degraded-coverage
22
+ */
23
+
24
+ /**
25
+ * A dimension that did not complete.
26
+ *
27
+ * @typedef {object} DimensionFailure
28
+ * @property {string} dimension The analysis dimension that failed.
29
+ * @property {string} phase The phase it failed in (`analyze` / `cross-check`).
30
+ * @property {string} reason The rejection's message.
31
+ */
32
+
33
+ /** Matches the Executive Summary heading at any heading level. */
34
+ const EXECUTIVE_SUMMARY_HEADING = /^#{1,6}\s+Executive Summary\b/i;
35
+
36
+ /**
37
+ * Render the degraded-coverage note. Names every failed dimension, the phase
38
+ * it failed in, and the reason, so a reader can tell coverage loss apart from
39
+ * an absence of findings.
40
+ *
41
+ * @param {readonly DimensionFailure[]} failures
42
+ * @param {number} totalDimensions Dimensions the run set out to cover.
43
+ * @returns {string} A markdown blockquote.
44
+ */
45
+ function formatDegradedCoverageNote(failures, totalDimensions) {
46
+ const detail = failures
47
+ .map((f) => `**${f.dimension}** (${f.phase}: ${f.reason})`)
48
+ .join('; ');
49
+ const noun = failures.length === 1 ? 'dimension' : 'dimensions';
50
+ return [
51
+ `> ⚠️ **Degraded coverage** — ${failures.length} of ${totalDimensions} analysis ${noun} did not complete`,
52
+ `> and ${failures.length === 1 ? 'is' : 'are'} unrepresented in this report: ${detail}.`,
53
+ '> Findings for the remaining dimensions are complete; the gap above is not evidence of their absence.',
54
+ ].join('\n');
55
+ }
56
+
57
+ /**
58
+ * Annotate a synthesised report with the degraded-coverage note, inserted
59
+ * directly beneath the `## Executive Summary` heading (every lens report
60
+ * contract requires that section). A report without the heading is prefixed
61
+ * instead, so the note can never be silently dropped.
62
+ *
63
+ * Returns the report unchanged when nothing failed — a full-coverage run must
64
+ * not carry a coverage caveat.
65
+ *
66
+ * @param {string} report
67
+ * @param {readonly DimensionFailure[]} failures
68
+ * @param {number} totalDimensions
69
+ * @returns {string}
70
+ */
71
+ export function withDegradedCoverageNote(report, failures, totalDimensions) {
72
+ if (!Array.isArray(failures) || failures.length === 0) return report;
73
+ const note = formatDegradedCoverageNote(failures, totalDimensions);
74
+ const lines = String(report).split('\n');
75
+ const headingIndex = lines.findIndex((line) =>
76
+ EXECUTIVE_SUMMARY_HEADING.test(line.trim()),
77
+ );
78
+ if (headingIndex === -1) return `${note}\n\n${report}`;
79
+ lines.splice(headingIndex + 1, 0, '', note);
80
+ return lines.join('\n');
81
+ }
@@ -134,16 +134,22 @@ export function classifyBranchSeed({ localHas, remoteHas }) {
134
134
  *
135
135
  * Caller-specific log lines and error text are passed in as the `messages`
136
136
  * data bag so behaviour stays byte-identical to the pre-extraction switches.
137
- * The git seams (`spawn`, `existsLocally`, `existsRemotely`) are injected so
138
- * each caller can bind its own cwd (and tests can mock them).
137
+ * The git seams (`spawn`, `existsLocally`, `existsRemotely`) are injectable so
138
+ * each caller can bind its own cwd (and tests can substitute stubs through the
139
+ * parameter rather than by module mocking). Per
140
+ * `.agents/rules/test-seams.md` rule 1 each seam **defaults to the real
141
+ * implementation** bound to `cwd`, so a caller that only knows its checkout
142
+ * passes `cwd` and nothing else; `single-story-init.js` keeps passing its own
143
+ * pre-bound seams and is unaffected.
139
144
  *
140
145
  * @param {object} opts
141
146
  * @param {string} opts.storyBranch
142
147
  * @param {string} opts.baseRef Ref to branch from on `create`.
148
+ * @param {string} [opts.cwd] Checkout the default seams bind to.
143
149
  * @param {boolean} [opts.swallowCreateRace=false]
144
- * @param {(args: string[]) => { status: number, stdout?: string, stderr?: string }} opts.spawn
145
- * @param {(branch: string) => boolean} opts.existsLocally
146
- * @param {(branch: string) => boolean} opts.existsRemotely
150
+ * @param {(args: string[]) => { status: number, stdout?: string, stderr?: string }} [opts.spawn]
151
+ * @param {(branch: string) => boolean} [opts.existsLocally]
152
+ * @param {(branch: string) => boolean} [opts.existsRemotely]
147
153
  * @param {(level: string, message: string) => void} [opts.progress]
148
154
  * @param {object} opts.messages
149
155
  * @param {(b: string) => string} opts.messages.reuse
@@ -158,10 +164,11 @@ export function classifyBranchSeed({ localHas, remoteHas }) {
158
164
  export function seedStoryBranchRef({
159
165
  storyBranch,
160
166
  baseRef,
167
+ cwd,
161
168
  swallowCreateRace = false,
162
- spawn,
163
- existsLocally,
164
- existsRemotely,
169
+ spawn = (args) => gitSpawn(cwd, ...args),
170
+ existsLocally = (branch) => branchExistsLocally(branch, cwd),
171
+ existsRemotely = (branch) => branchExistsViaTrackingRef(branch, cwd),
165
172
  progress = () => {},
166
173
  messages,
167
174
  }) {