mandrel 2.25.0 → 2.26.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 (131) hide show
  1. package/.agents/agents/acceptance-critic.md +10 -6
  2. package/.agents/audit-checklists/baselines.md +21 -0
  3. package/.agents/docs/quality-gates.md +80 -18
  4. package/.agents/docs/workflows.md +3 -1
  5. package/.agents/instructions.md +1 -1
  6. package/.agents/schemas/audit-rules.json +15 -0
  7. package/.agents/schemas/baselines/audit-baselines-envelope.schema.json +242 -0
  8. package/.agents/schemas/baselines/baseline-envelope.schema.json +4 -0
  9. package/.agents/schemas/baselines/crap.schema.json +8 -0
  10. package/.agents/schemas/model-attribution.schema.json +4 -0
  11. package/.agents/scripts/acceptance-eval.js +89 -6
  12. package/.agents/scripts/audit-baselines.js +136 -0
  13. package/.agents/scripts/check-arch-cycles.js +12 -93
  14. package/.agents/scripts/check-baseline-drift.js +16 -3
  15. package/.agents/scripts/check-baselines.js +19 -3
  16. package/.agents/scripts/check-cyclomatic.js +214 -0
  17. package/.agents/scripts/check-schema-references.js +392 -0
  18. package/.agents/scripts/check-test-temp-hygiene.js +38 -1
  19. package/.agents/scripts/check-workflow-timeouts.js +291 -0
  20. package/.agents/scripts/diagnose-friction.js +85 -19
  21. package/.agents/scripts/lib/audit-baselines/engine.js +177 -0
  22. package/.agents/scripts/lib/audit-baselines/gate-surface.js +63 -0
  23. package/.agents/scripts/lib/audit-baselines/headroom.js +72 -0
  24. package/.agents/scripts/lib/audit-baselines/hotspots.js +69 -0
  25. package/.agents/scripts/lib/audit-baselines/kinds.js +313 -0
  26. package/.agents/scripts/lib/audit-baselines/outliers.js +100 -0
  27. package/.agents/scripts/lib/audit-baselines/read.js +87 -0
  28. package/.agents/scripts/lib/audit-baselines/staleness.js +123 -0
  29. package/.agents/scripts/lib/audit-baselines/surface-entry.js +106 -0
  30. package/.agents/scripts/lib/audit-baselines/trend.js +125 -0
  31. package/.agents/scripts/lib/audit-baselines/weights.js +193 -0
  32. package/.agents/scripts/lib/audit-suite/index.js +0 -5
  33. package/.agents/scripts/lib/audit-suite/selector.js +9 -62
  34. package/.agents/scripts/lib/audit-to-stories/audit-lenses.js +1 -0
  35. package/.agents/scripts/lib/baseline-schema-registry.js +13 -1
  36. package/.agents/scripts/lib/baselines/diff-scope-cli.js +22 -160
  37. package/.agents/scripts/lib/baselines/duplication-scanner.js +27 -0
  38. package/.agents/scripts/lib/baselines/git-base.js +26 -4
  39. package/.agents/scripts/lib/baselines/kinds/crap.js +112 -15
  40. package/.agents/scripts/lib/baselines/refresh-service.js +69 -11
  41. package/.agents/scripts/lib/baselines/scope.js +39 -90
  42. package/.agents/scripts/lib/baselines/writer.js +16 -11
  43. package/.agents/scripts/lib/changed-files.js +8 -1
  44. package/.agents/scripts/lib/cli-args.js +115 -1
  45. package/.agents/scripts/lib/close-validation/runner.js +70 -25
  46. package/.agents/scripts/lib/crap-engine.js +32 -13
  47. package/.agents/scripts/lib/crap-method-identity.js +153 -0
  48. package/.agents/scripts/lib/crap-utils.js +13 -0
  49. package/.agents/scripts/lib/cyclomatic-ceiling.js +265 -0
  50. package/.agents/scripts/lib/feedback-loop/audit-results-graduator.js +0 -2
  51. package/.agents/scripts/lib/feedback-loop/prior-feedback-fetcher.js +0 -2
  52. package/.agents/scripts/lib/feedback-loop/retro-proposals-graduator.js +0 -2
  53. package/.agents/scripts/lib/git-utils.js +136 -80
  54. package/.agents/scripts/lib/import-graph.js +156 -0
  55. package/.agents/scripts/lib/observability/runtime-friction.js +17 -2
  56. package/.agents/scripts/lib/observability/source-classifier.js +175 -2
  57. package/.agents/scripts/lib/orchestration/ceremony-routing.js +17 -12
  58. package/.agents/scripts/lib/orchestration/check-baselines/phases/compare.js +36 -6
  59. package/.agents/scripts/lib/orchestration/check-baselines/phases/evaluate.js +5 -0
  60. package/.agents/scripts/lib/orchestration/check-baselines/phases/floors.js +12 -1
  61. package/.agents/scripts/lib/orchestration/check-baselines/phases/report.js +8 -1
  62. package/.agents/scripts/lib/orchestration/git-cleanup/phases/phase-drivers.js +10 -5
  63. package/.agents/scripts/lib/orchestration/git-cleanup/phases/render.js +39 -3
  64. package/.agents/scripts/lib/orchestration/plan-context.js +119 -66
  65. package/.agents/scripts/lib/orchestration/plan-persist/fan-out-gate.js +31 -5
  66. package/.agents/scripts/lib/orchestration/plan-persist/run-plan-persist.js +209 -109
  67. package/.agents/scripts/lib/orchestration/plan-persist/story-ops.js +48 -12
  68. package/.agents/scripts/lib/orchestration/plan-persist/supersede-ops.js +79 -22
  69. package/.agents/scripts/lib/orchestration/plan-text-hygiene.js +51 -20
  70. package/.agents/scripts/lib/orchestration/planning/authoring-context.js +70 -74
  71. package/.agents/scripts/lib/orchestration/planning/memory-pool-advisory.js +231 -0
  72. package/.agents/scripts/lib/orchestration/resolve-stories.js +18 -17
  73. package/.agents/scripts/lib/orchestration/run-epilogue.js +12 -0
  74. package/.agents/scripts/lib/orchestration/single-story-close/phases/confirm-merge.js +29 -3
  75. package/.agents/scripts/lib/orchestration/single-story-close/phases/normalize-pr-title.js +6 -6
  76. package/.agents/scripts/lib/orchestration/single-story-close/phases/options.js +42 -38
  77. package/.agents/scripts/lib/orchestration/single-story-close/phases/push.js +6 -1
  78. package/.agents/scripts/lib/orchestration/single-story-close/runner.js +245 -140
  79. package/.agents/scripts/lib/orchestration/spec-budget.js +16 -5
  80. package/.agents/scripts/lib/orchestration/story-follow-ups.js +182 -95
  81. package/.agents/scripts/lib/orchestration/ticket-validator-conflicts.js +22 -0
  82. package/.agents/scripts/lib/orchestration/ticket-validator.js +5 -11
  83. package/.agents/scripts/lib/orchestration/ticketing/reads.js +4 -4
  84. package/.agents/scripts/lib/story-adjacency.js +3 -3
  85. package/.agents/scripts/lib/test-runner-contract.js +134 -0
  86. package/.agents/scripts/lib/test-tiers.js +11 -2
  87. package/.agents/scripts/lib/util/concurrent-map.js +17 -0
  88. package/.agents/scripts/lib/util/parse-id-list.js +103 -0
  89. package/.agents/scripts/lib/wave-runner/live-probe.js +24 -14
  90. package/.agents/scripts/lib/wave-runner/ready-set.js +189 -42
  91. package/.agents/scripts/lib/workers/combined-mi-crap-worker.js +4 -10
  92. package/.agents/scripts/lib/workers/crap-worker.js +2 -10
  93. package/.agents/scripts/lib/workers/maintainability-report-worker.js +4 -10
  94. package/.agents/scripts/lib/workers/maintainability-worker.js +4 -10
  95. package/.agents/scripts/lib/workers/serve-worker-messages.js +35 -0
  96. package/.agents/scripts/lib/worktree/git-hooks.js +206 -0
  97. package/.agents/scripts/lib/worktree/lifecycle/creation.js +6 -0
  98. package/.agents/scripts/lib/worktree-manager.js +14 -0
  99. package/.agents/scripts/plan-run-epilogue.js +17 -5
  100. package/.agents/scripts/providers/github/tickets.js +33 -10
  101. package/.agents/scripts/provision-git-hooks.js +85 -0
  102. package/.agents/scripts/quality-preview.js +112 -28
  103. package/.agents/scripts/resolve-stories.js +4 -1
  104. package/.agents/scripts/run-coverage.js +86 -35
  105. package/.agents/scripts/run-lint.js +20 -0
  106. package/.agents/scripts/run-tests.js +26 -36
  107. package/.agents/scripts/single-story-close.js +28 -2
  108. package/.agents/scripts/single-story-confirm-merge.js +22 -6
  109. package/.agents/scripts/stories-wave-tick.js +214 -38
  110. package/.agents/scripts/update-coverage-baseline.js +34 -4
  111. package/.agents/scripts/update-duplication-baseline.js +209 -83
  112. package/.agents/scripts/validate-docs-freshness.js +1 -0
  113. package/.agents/skills/core/diagnose-friction/SKILL.md +4 -1
  114. package/.agents/skills/core/gates-and-baselines/SKILL.md +17 -11
  115. package/.agents/skills/skills.index.json +2 -2
  116. package/.agents/workflows/audit-baselines.md +289 -0
  117. package/.agents/workflows/audit-navigability.md +5 -4
  118. package/.agents/workflows/deliver.md +13 -4
  119. package/.agents/workflows/helpers/acceptance-self-eval.md +47 -10
  120. package/.agents/workflows/helpers/code-quality-guardrails.md +9 -2
  121. package/.agents/workflows/helpers/deliver-digest.md +41 -21
  122. package/.agents/workflows/helpers/deliver-reference.md +77 -1
  123. package/.agents/workflows/helpers/deliver-story-reference.md +47 -6
  124. package/.agents/workflows/helpers/plan-reference.md +15 -5
  125. package/.agents/workflows/memory-consolidate.md +116 -0
  126. package/.agents/workflows/plan.md +3 -0
  127. package/README.md +13 -6
  128. package/docs/CHANGELOG.md +64 -0
  129. package/package.json +9 -4
  130. package/.agents/schemas/friction-event.schema.json +0 -56
  131. package/.agents/scripts/lib/feedback-loop/memory-freshness.js +0 -707
@@ -464,6 +464,156 @@ function renderDiscardedItem(item) {
464
464
  return parts.join(' — ');
465
465
  }
466
466
 
467
+ /**
468
+ * Normalize the two loosely-typed inputs into the four buckets every section
469
+ * renderer reads. Absorbs the optional-chain / nullish-coalesce cluster that
470
+ * otherwise all lands on `buildFollowUpsCommentBody` (Story #4926).
471
+ *
472
+ * @param {object|null|undefined} proposals
473
+ * @param {object|null|undefined} graduated
474
+ * @returns {{ filed: object[], framework: object[], consumer: object[],
475
+ * discarded: object[], filingErrors: string[] }}
476
+ */
477
+ function normalizeRollupBuckets(proposals, graduated) {
478
+ const asArray = (value) => (Array.isArray(value) ? value : []);
479
+ return {
480
+ filed: asArray(graduated?.filed),
481
+ framework: asArray(proposals?.framework),
482
+ consumer: asArray(proposals?.consumer),
483
+ discarded: asArray(proposals?.discarded),
484
+ filingErrors: asArray(graduated?.errors),
485
+ };
486
+ }
487
+
488
+ /**
489
+ * Render the "**Filed**" section, or nothing when no proposal was filed.
490
+ *
491
+ * @param {object[]} filed
492
+ * @returns {string[]}
493
+ */
494
+ function renderFiledSection(filed) {
495
+ if (filed.length === 0) return [];
496
+ return [
497
+ '**Filed**',
498
+ ...filed.map(
499
+ (item) =>
500
+ `- ${item.source}: ${item.title}${item.url ? ` — ${item.url}` : ''}`,
501
+ ),
502
+ '',
503
+ ];
504
+ }
505
+
506
+ /**
507
+ * Render the "**Actionable (not auto-filed)**" fallback — the command stanzas
508
+ * an operator runs by hand when auto-filing produced nothing.
509
+ *
510
+ * @param {object[]} actionable framework + consumer, in that order
511
+ * @param {object[]} filed
512
+ * @returns {string[]}
513
+ */
514
+ function renderActionableSection(actionable, filed) {
515
+ if (actionable.length === 0 || filed.length > 0) return [];
516
+ const lines = ['**Actionable (not auto-filed)**'];
517
+ for (const item of actionable) {
518
+ lines.push(`- ${item.source}: ${item.title}`, '', '```bash', item.command);
519
+ lines.push('```');
520
+ }
521
+ lines.push('');
522
+ return lines;
523
+ }
524
+
525
+ /**
526
+ * Render the "**Below threshold (not filed)**" section.
527
+ *
528
+ * @param {object[]} discarded
529
+ * @returns {string[]}
530
+ */
531
+ function renderDiscardedSection(discarded) {
532
+ if (discarded.length === 0) return [];
533
+ return [
534
+ '**Below threshold (not filed)**',
535
+ ...discarded.map(
536
+ (item) => `- ${item.source}: ${renderDiscardedItem(item)}`,
537
+ ),
538
+ '',
539
+ ];
540
+ }
541
+
542
+ /**
543
+ * Render the all-empty branch. Story #4828 — "no proposals" has two readings,
544
+ * and only one of them is a quiet run. Signals gathered but nothing routed is
545
+ * the third instance of the silence Stories #4578 and #4824 each fixed once.
546
+ *
547
+ * @param {{ empty: boolean, outcome: {zeroProposals: boolean},
548
+ * signalCount: number, categories: object[], storyCount: number }} args
549
+ * @returns {string[]}
550
+ */
551
+ function renderEmptySection({
552
+ empty,
553
+ outcome,
554
+ signalCount,
555
+ categories,
556
+ storyCount,
557
+ }) {
558
+ if (!empty) return [];
559
+ return [
560
+ ...(outcome.zeroProposals
561
+ ? renderZeroProposalLines(signalCount, categories)
562
+ : renderEmptyRollupLines(storyCount)),
563
+ '',
564
+ ];
565
+ }
566
+
567
+ /**
568
+ * Build the machine-readable twin of the rendered prose. Every suspect flag
569
+ * above has a field here so a caller never has to regex the body.
570
+ *
571
+ * @param {object} args
572
+ * @returns {object}
573
+ */
574
+ function buildRollupPayload({
575
+ storyId,
576
+ storyCount,
577
+ signalCount,
578
+ categories,
579
+ buckets,
580
+ empty,
581
+ outcome,
582
+ }) {
583
+ const { filed, framework, consumer, discarded, filingErrors } = buckets;
584
+ return {
585
+ storyId,
586
+ storyCount,
587
+ // Story #4828 — the corpus the roll-up actually read. Without it a
588
+ // reader cannot tell "0 proposals because nothing recurred" from
589
+ // "0 proposals because routing broke".
590
+ signalCount,
591
+ categories,
592
+ framework: framework.map((i) => i.category),
593
+ consumer: consumer.map((i) => i.category),
594
+ // Story #4824 — the machine-readable twin of the row above. A bare
595
+ // category list could not distinguish a genuine one-off from a
596
+ // recurrence the window was too narrow to see, so the count, the
597
+ // cross-Story span, and the shape fingerprint ride along.
598
+ discarded: discarded.map((i) => ({
599
+ category: i.category,
600
+ occurrences: i.occurrences,
601
+ storyCount: i.storyCount ?? null,
602
+ fingerprint: i.fingerprint ?? null,
603
+ })),
604
+ filed: filed.map((i) => ({ category: i.category, url: i.url ?? null })),
605
+ // Story #4578 — an empty roll-up over N>1 Stories is a claim worth
606
+ // flagging, not a success. Machine-readable twin of the warning
607
+ // prose so a caller need not regex the body.
608
+ emptyRollupSuspect: storyCount > 1 && empty && signalCount === 0,
609
+ // Story #4828 — the two remaining shapes that used to render as
610
+ // success. Machine-readable twins of the warning prose above.
611
+ zeroProposalSuspect: outcome.zeroProposals,
612
+ unfiledProposalSuspect: outcome.unfiledProposals,
613
+ filingErrors,
614
+ };
615
+ }
616
+
467
617
  /**
468
618
  * @param {{
469
619
  * storyId: number,
@@ -487,13 +637,14 @@ export function buildFollowUpsCommentBody({
487
637
  signalCount = 0,
488
638
  categories = [],
489
639
  }) {
490
- const filed = Array.isArray(graduated?.filed) ? graduated.filed : [];
491
- const framework = proposals?.framework ?? [];
492
- const consumer = proposals?.consumer ?? [];
493
- const discarded = proposals?.discarded ?? [];
640
+ const buckets = normalizeRollupBuckets(proposals, graduated);
641
+ const { filed, framework, consumer, discarded } = buckets;
642
+ const actionable = [...framework, ...consumer];
643
+ const empty =
644
+ filed.length === 0 && actionable.length === 0 && discarded.length === 0;
494
645
  const outcome = assessRollupOutcome({
495
646
  signalCount,
496
- proposalCount: framework.length + consumer.length,
647
+ proposalCount: actionable.length,
497
648
  discardedCount: discarded.length,
498
649
  filedCount: filed.length,
499
650
  filingErrors: graduated?.errors,
@@ -504,103 +655,39 @@ export function buildFollowUpsCommentBody({
504
655
  '',
505
656
  `Actionable follow-ups captured from Story #${storyId} after merge.`,
506
657
  '',
658
+ ...(outcome.unfiledProposals
659
+ ? [
660
+ ...renderUnfiledProposalLines(
661
+ actionable.length,
662
+ buckets.filingErrors,
663
+ outcome.blockingSkipReasons,
664
+ ),
665
+ '',
666
+ ]
667
+ : []),
668
+ ...renderFiledSection(filed),
669
+ ...renderActionableSection(actionable, filed),
670
+ ...renderDiscardedSection(discarded),
671
+ ...renderEmptySection({
672
+ empty,
673
+ outcome,
674
+ signalCount,
675
+ categories,
676
+ storyCount,
677
+ }),
678
+ '```json',
507
679
  ];
508
- if (outcome.unfiledProposals) {
509
- lines.push(
510
- ...renderUnfiledProposalLines(
511
- framework.length + consumer.length,
512
- Array.isArray(graduated?.errors) ? graduated.errors : [],
513
- outcome.blockingSkipReasons,
514
- ),
515
- '',
516
- );
517
- }
518
- if (filed.length > 0) {
519
- lines.push('**Filed**');
520
- for (const item of filed) {
521
- lines.push(
522
- `- ${item.source}: ${item.title}${item.url ? ` — ${item.url}` : ''}`,
523
- );
524
- }
525
- lines.push('');
526
- }
527
- if (framework.length + consumer.length > 0 && filed.length === 0) {
528
- lines.push('**Actionable (not auto-filed)**');
529
- for (const item of [...framework, ...consumer]) {
530
- lines.push(`- ${item.source}: ${item.title}`);
531
- lines.push('');
532
- lines.push('```bash');
533
- lines.push(item.command);
534
- lines.push('```');
535
- }
536
- lines.push('');
537
- }
538
- if (discarded.length > 0) {
539
- lines.push('**Below threshold (not filed)**');
540
- for (const item of discarded) {
541
- lines.push(`- ${item.source}: ${renderDiscardedItem(item)}`);
542
- }
543
- lines.push('');
544
- }
545
- if (
546
- filed.length === 0 &&
547
- framework.length === 0 &&
548
- consumer.length === 0 &&
549
- discarded.length === 0
550
- ) {
551
- // Story #4828 — "no proposals" has two readings, and only one of them is
552
- // a quiet run. Signals gathered but nothing routed is the third instance
553
- // of the silence Stories #4578 and #4824 each fixed once.
554
- lines.push(
555
- ...(outcome.zeroProposals
556
- ? renderZeroProposalLines(signalCount, categories)
557
- : renderEmptyRollupLines(storyCount)),
558
- );
559
- lines.push('');
560
- }
561
- lines.push('```json');
562
680
  lines.push(
563
681
  JSON.stringify(
564
- {
682
+ buildRollupPayload({
565
683
  storyId,
566
684
  storyCount,
567
- // Story #4828 — the corpus the roll-up actually read. Without it a
568
- // reader cannot tell "0 proposals because nothing recurred" from
569
- // "0 proposals because routing broke".
570
685
  signalCount,
571
686
  categories,
572
- framework: framework.map((i) => i.category),
573
- consumer: consumer.map((i) => i.category),
574
- // Story #4824 — the machine-readable twin of the row above. A bare
575
- // category list could not distinguish a genuine one-off from a
576
- // recurrence the window was too narrow to see, so the count, the
577
- // cross-Story span, and the shape fingerprint ride along.
578
- discarded: discarded.map((i) => ({
579
- category: i.category,
580
- occurrences: i.occurrences,
581
- storyCount: i.storyCount ?? null,
582
- fingerprint: i.fingerprint ?? null,
583
- })),
584
- filed: filed.map((i) => ({
585
- category: i.category,
586
- url: i.url ?? null,
587
- })),
588
- // Story #4578 — an empty roll-up over N>1 Stories is a claim worth
589
- // flagging, not a success. Machine-readable twin of the warning
590
- // prose so a caller need not regex the body.
591
- emptyRollupSuspect:
592
- storyCount > 1 &&
593
- filed.length === 0 &&
594
- framework.length === 0 &&
595
- consumer.length === 0 &&
596
- discarded.length === 0 &&
597
- signalCount === 0,
598
- // Story #4828 — the two remaining shapes that used to render as
599
- // success. Machine-readable twins of the warning prose above.
600
- zeroProposalSuspect: outcome.zeroProposals,
601
- unfiledProposalSuspect: outcome.unfiledProposals,
602
- filingErrors: Array.isArray(graduated?.errors) ? graduated.errors : [],
603
- },
687
+ buckets,
688
+ empty,
689
+ outcome,
690
+ }),
604
691
  null,
605
692
  2,
606
693
  ),
@@ -816,6 +816,28 @@ export function renderFanOutRemedy(finding) {
816
816
  );
817
817
  }
818
818
 
819
+ /**
820
+ * The finding kinds that are genuinely **cross-Story conflicts** — the SSOT
821
+ * for that question (Story #4907).
822
+ *
823
+ * Two readers need it and must not disagree: the validator, which renders
824
+ * these through {@link renderHardConflictError} when policy upgrades them to
825
+ * `errors[]`, and the persist soft-finding surface, which announces a
826
+ * conflict as a conflict and every other soft kind (`spec-word-budget`,
827
+ * `merge-candidate`, `unanchored-constant`, `missing-reason-to-exist`) as the
828
+ * advisory it is. A second copy of this list is how the two drift back apart,
829
+ * so it is defined exactly once and imported.
830
+ */
831
+ export const CONFLICT_KINDS = Object.freeze(
832
+ new Set([
833
+ 'shared-editor',
834
+ 'implicit-cross-story-dep',
835
+ 'cross-cutting-registries',
836
+ 'fan-out-warning',
837
+ 'missing-bdd-scaffold',
838
+ ]),
839
+ );
840
+
819
841
  /**
820
842
  * Render a `'hard'`-severity conflict finding as a human-readable error
821
843
  * message. Used by the validator when policy flags upgrade a finding to
@@ -10,6 +10,7 @@ import {
10
10
  parseStoryBodyOrThrow,
11
11
  } from './story-body-gate.js';
12
12
  import {
13
+ CONFLICT_KINDS,
13
14
  computeConflictFindings,
14
15
  renderHardConflictError,
15
16
  } from './ticket-validator-conflicts.js';
@@ -666,25 +667,18 @@ export function validateAndNormalizeTickets(tickets, opts = {}) {
666
667
  policy: opts.conflictPolicy,
667
668
  });
668
669
  // Advisory `## Spec` word-budget pass (Story #4723) — soft findings only,
669
- // surfaced as warnings here and via the persist soft-finding channel;
670
670
  // never promoted to `errors[]`, so an over-budget Spec cannot fail the
671
671
  // persist. Runs after `assertStoryBodiesParse`, so string bodies parse.
672
+ // This pass computes but does not report: the sole production caller always
673
+ // runs the persist soft-finding surface, which reports every soft kind
674
+ // uniformly. Warning here too made `spec-word-budget` the only kind logged
675
+ // twice per run (Story #4907).
672
676
  const specBudgetFindings = computeSpecBudgetFindings({ stories });
673
- for (const finding of specBudgetFindings) {
674
- Logger.warn(`[ticket-validator] spec-word-budget: ${finding.message}`);
675
- }
676
677
  const findings = [
677
678
  ...sizingFindings,
678
679
  ...conflictFindings,
679
680
  ...specBudgetFindings,
680
681
  ];
681
- const CONFLICT_KINDS = new Set([
682
- 'shared-editor',
683
- 'implicit-cross-story-dep',
684
- 'cross-cutting-registries',
685
- 'fan-out-warning',
686
- 'missing-bdd-scaffold',
687
- ]);
688
682
  const errors = findings
689
683
  .filter((f) => f.severity === 'hard')
690
684
  .map((f) =>
@@ -180,7 +180,7 @@ export const WAVE_TYPE_PATTERN = WAVE_MARKER_RE;
180
180
  * authoritative entry — the label set is the actual race-detection signal).
181
181
  * Bounded to 1-9 digits to mirror the wave-marker safety margin.
182
182
  */
183
- export const CLAIM_TYPE_PATTERN = /^claim-([0-9]{1,9})$/;
183
+ const CLAIM_TYPE_PATTERN = /^claim-([0-9]{1,9})$/;
184
184
 
185
185
  /**
186
186
  * Lifecycle-listener marker pattern (Story #2239 / #2241 / #2242). The
@@ -189,7 +189,7 @@ export const CLAIM_TYPE_PATTERN = /^claim-([0-9]{1,9})$/;
189
189
  * `lifecycle-epic-unblocked`). Treated as a generic prefix so future
190
190
  * listener-owned events can mint new markers without touching this enum.
191
191
  */
192
- export const LIFECYCLE_TYPE_PATTERN = /^lifecycle-[a-z0-9]+(?:-[a-z0-9]+)*$/;
192
+ const LIFECYCLE_TYPE_PATTERN = /^lifecycle-[a-z0-9]+(?:-[a-z0-9]+)*$/;
193
193
 
194
194
  /**
195
195
  * @param {string} type
@@ -273,7 +273,7 @@ export function structuredCommentMarker(type, attrs = null) {
273
273
  * cannot leak across boundaries. Tests reset via the exported
274
274
  * `_resetStructuredCommentCache()` seam.
275
275
  */
276
- export const _structuredCommentCache = new WeakMap();
276
+ const _structuredCommentCache = new WeakMap();
277
277
 
278
278
  /**
279
279
  * Build a well-formed ticket snapshot for a Story that has zero child
@@ -359,7 +359,7 @@ export function structuredCommentCacheKey(ticketId, type, attrs) {
359
359
  * - Scoped per-provider via WeakMap so test fakes never share state
360
360
  * with the real GitHubProvider.
361
361
  */
362
- export const _rawCommentsCache = new WeakMap();
362
+ const _rawCommentsCache = new WeakMap();
363
363
 
364
364
  /**
365
365
  * Lookup (or lazily create) the per-provider raw-comments cache.
@@ -7,10 +7,10 @@
7
7
  * records into the `Map<storyId, number[]>` adjacency the kernel consumes.
8
8
  * This module is now the one home for that step; the live consumer is:
9
9
  *
10
- * - `lib/wave-runner/ready-set.js` (`selectReadySet`, the path-agnostic
10
+ * - `lib/wave-runner/ready-set.js` (`planReadySet`, the path-agnostic
11
11
  * continuous scheduler the `stories-wave-tick.js` adapter dispatches through)
12
12
  * - `stories-wave-tick.js` (for cycle detection, before delegating
13
- * selection to `selectReadySet`)
13
+ * selection to `planReadySet`)
14
14
  *
15
15
  * (Pre-v2: `epic-runner/phases/build-wave-dag.js` and `dispatch-pipeline.js`
16
16
  * also called here; both entry seams were deleted.)
@@ -44,7 +44,7 @@ import { parseBlockedBy } from './dependency-parser.js';
44
44
  * @param {object} [opts]
45
45
  * @param {boolean} [opts.dropForeign=false] When `false` (the v2 default,
46
46
  * matching the `/deliver` path — `stories-wave-tick.js` and the
47
- * `selectReadySet` core), the operator-DAG contract is preserved: a
47
+ * `planReadySet` core), the operator-DAG contract is preserved: a
48
48
  * dependency on an id absent from the input is treated as not-yet-done
49
49
  * and withholds the dependent until it completes. When `true` (the
50
50
  * pre-v2 Epic-scoped-wrapper semantics), edges pointing at ids outside
@@ -0,0 +1,134 @@
1
+ /**
2
+ * The contract every test-running entrypoint shares: the `node --test` flag
3
+ * set, and the per-tier preflight.
4
+ *
5
+ * ## Why this is a module and not two literals (Story #4936)
6
+ *
7
+ * The repository has two full-tier runners — `run-tests.js` (`npm test`) and
8
+ * `run-coverage.js` (`npm run test:coverage`, the *required* CI job). When
9
+ * they disagree about a `node --test` flag, they disagree about whether a
10
+ * test can execute at all: `--experimental-test-module-mocks` decides whether
11
+ * `t.mock.module` works, so a divergence makes a suite pass under one runner
12
+ * and fail under the other with no source defect. `FULL_TIER_GLOBS` in
13
+ * [`test-tiers.js`](test-tiers.js) already solved the sibling problem for
14
+ * *which files* run; this module is the same fix for *how they are run*.
15
+ * Both runners import from here; neither restates a literal.
16
+ *
17
+ * ## The preflight is invoked, never hooked
18
+ *
19
+ * `.npmrc` sets `ignore-scripts=true` as deliberate defence against malicious
20
+ * postinstall hooks (CWE-1357). That setting is correct and stays — but it
21
+ * suppresses every `pre*` / `post*` lifecycle script for `npm run` as well as
22
+ * for installs, so a `pretest` entry in `package.json` never fires. The
23
+ * repository carried `pretest`, `pretest:quick` and `pretest:integration`
24
+ * entries that therefore ran for no tier at all, and Story #4922 worked
25
+ * around it for the coverage tier alone by having CI name `pretest:coverage`
26
+ * explicitly. `runTierPreflight` closes the gap for every tier by moving the
27
+ * invocation into the runners, where it executes under `npm test`,
28
+ * `npm run test:coverage`, and a bare `node .agents/scripts/run-tests.js`
29
+ * alike — no lifecycle hook involved.
30
+ */
31
+
32
+ import { spawnSync } from 'node:child_process';
33
+ import os from 'node:os';
34
+ import path from 'node:path';
35
+ import process from 'node:process';
36
+
37
+ /** Minimum and maximum bounds for `--test-concurrency`. */
38
+ export const TEST_CONCURRENCY_MIN = 1;
39
+ export const TEST_CONCURRENCY_MAX = 16;
40
+
41
+ /**
42
+ * Resolve the `--test-concurrency` value for the current host.
43
+ *
44
+ * Uses `os.availableParallelism()` (Node ≥18.14 / ≥20.0) clamped to the
45
+ * range `[TEST_CONCURRENCY_MIN, TEST_CONCURRENCY_MAX]`. The `parallelism`
46
+ * parameter is injected in tests so the clamping logic is verifiable
47
+ * without touching the OS.
48
+ *
49
+ * @param {number} [parallelism] - defaults to `os.availableParallelism()`
50
+ * @returns {number}
51
+ */
52
+ export function resolveTestConcurrency(
53
+ parallelism = os.availableParallelism(),
54
+ ) {
55
+ return Math.min(
56
+ TEST_CONCURRENCY_MAX,
57
+ Math.max(TEST_CONCURRENCY_MIN, parallelism),
58
+ );
59
+ }
60
+
61
+ /**
62
+ * Fixed `node --test` flags applied to every spawn of every runner.
63
+ *
64
+ * `--test-concurrency` is derived at startup from the host's available
65
+ * parallelism so the value suits the machine running the suite rather than
66
+ * being pinned to the historical constant of 8.
67
+ *
68
+ * This is the **single** declaration. A flag added here reaches both runners;
69
+ * a flag added to one runner's argv builder instead is caught by the
70
+ * flag-set equality assertion in `tests/scripts/run-coverage.test.js`.
71
+ */
72
+ export const TEST_RUNNER_FLAGS = Object.freeze([
73
+ '--experimental-test-module-mocks',
74
+ '--test',
75
+ `--test-concurrency=${resolveTestConcurrency()}`,
76
+ ]);
77
+
78
+ /**
79
+ * Preflight scripts per tier, repo-relative. These are the definitions that
80
+ * used to live in `package.json` as inert `pretest*` entries.
81
+ *
82
+ * `full` carries the skills validator on top of the state-probe wrapper
83
+ * because the full tier is the release-shaped run (and the surface the
84
+ * coverage tier measures); `quick` and `integration` run the state probe
85
+ * only, matching the tiers' historical `pretest:quick` / `pretest:integration`
86
+ * definitions. The coverage runner runs the full tier, so it shares `full`.
87
+ *
88
+ * Deliberately not exported: a second importer would be a second place to
89
+ * read the tier→preflight mapping from, and tests assert the mapping through
90
+ * the spawns `runTierPreflight` actually issues — which is the thing that
91
+ * has to be true.
92
+ */
93
+ const TIER_PREFLIGHT_SCRIPTS = Object.freeze({
94
+ full: Object.freeze([
95
+ '.agents/scripts/test-wrapper.js',
96
+ '.agents/scripts/validate-skills.js',
97
+ ]),
98
+ quick: Object.freeze(['.agents/scripts/test-wrapper.js']),
99
+ integration: Object.freeze(['.agents/scripts/test-wrapper.js']),
100
+ });
101
+
102
+ /**
103
+ * Run the preflight for a tier, in order, stopping at the first failure.
104
+ *
105
+ * Mirrors npm's own `pre<script>` semantics — a failed preflight aborts
106
+ * before the test runner is spawned and propagates its exit code (the
107
+ * wrapper reserves 2 for "preflight refused") — except that it actually
108
+ * executes, which the npm hook does not under `ignore-scripts=true`.
109
+ *
110
+ * @param {object} [opts]
111
+ * @param {'full' | 'quick' | 'integration'} [opts.tier]
112
+ * @param {string} [opts.repoRoot] Absolute repository root.
113
+ * @param {typeof spawnSync} [opts.spawn] Injected in tests.
114
+ * @param {string} [opts.execPath] Node binary to spawn; injected in tests.
115
+ * @returns {number} 0 when every preflight script passed, else the first
116
+ * non-zero exit code.
117
+ */
118
+ export function runTierPreflight({
119
+ tier = 'full',
120
+ repoRoot = process.cwd(),
121
+ spawn = spawnSync,
122
+ execPath = process.execPath,
123
+ } = {}) {
124
+ for (const script of TIER_PREFLIGHT_SCRIPTS[tier] ?? []) {
125
+ const run = spawn(execPath, [path.join(repoRoot, script)], {
126
+ cwd: repoRoot,
127
+ stdio: 'inherit',
128
+ });
129
+ if (run.error) throw run.error;
130
+ const status = run.status ?? 1;
131
+ if (status !== 0) return status;
132
+ }
133
+ return 0;
134
+ }
@@ -35,7 +35,9 @@ const matchesIntegration = picomatch(INTEGRATION_INCLUDE, { dot: true });
35
35
  * same way (Story #4195). Without each root here, both the quick /
36
36
  * integration walk and the full-tier glob set miss the colocated tests,
37
37
  * leaving that coverage dark in `npm test`. The matching full-tier globs
38
- * live in `FULL_TIER_GLOBS`.
38
+ * live in the exported `FULL_TIER_GLOBS` — every full-tier runner
39
+ * (`run-tests.js`, `run-coverage.js`) MUST consume that constant rather than
40
+ * restate a glob literal, or a runner silently walks a narrower surface.
39
41
  */
40
42
  const TEST_WALK_ROOTS = ['tests', 'lib', '.agents/scripts'];
41
43
 
@@ -44,8 +46,15 @@ const TEST_WALK_ROOTS = ['tests', 'lib', '.agents/scripts'];
44
46
  * The `tests` glob is a flat recursive sweep; the `lib` and `.agents/scripts`
45
47
  * globs are scoped to `__tests__` subtrees so they only match colocated
46
48
  * tests, never the shipped source modules themselves.
49
+ *
50
+ * Exported because the full tier has two runners, not one: `run-tests.js`
51
+ * (via `listTestFilesForTier`) and `run-coverage.js`. Story #4922 — the
52
+ * coverage runner used to restate `tests/**` on its own, so the 47 colocated
53
+ * `__tests__` files ran under `npm test` but were absent from the measured
54
+ * surface, leaving the coverage and CRAP numbers computed over code the
55
+ * measuring run never executed. Consume this constant; never restate a glob.
47
56
  */
48
- const FULL_TIER_GLOBS = [
57
+ export const FULL_TIER_GLOBS = [
49
58
  'tests/**/*.test.js',
50
59
  'lib/**/__tests__/**/*.test.js',
51
60
  '.agents/scripts/**/__tests__/**/*.test.js',
@@ -13,6 +13,23 @@
13
13
  * and would leak otherwise.
14
14
  */
15
15
 
16
+ /**
17
+ * The one bound every independent-write fan-out over the GitHub API uses
18
+ * (Story #4952 raised those loops off serial; Story #4961 made this the single
19
+ * owner of the number they share). Imported by the `/plan` context gathers,
20
+ * the persist checkpoint fan-out, the `agent::ready` flips and the supersede
21
+ * close loop, so re-tuning the policy is one edit rather than six.
22
+ *
23
+ * **Why bounded and not unbounded.** Every unit in those fan-outs is its own
24
+ * API round-trip, so an unbounded map over an N-Story plan dispatches N writes
25
+ * at once — and GitHub answers a burst with a secondary rate limit rather than
26
+ * with throughput. The goal is overlapping unrelated waits, not saturating the
27
+ * API, and four is enough to collapse the latency the serial loops paid while
28
+ * staying well under the burst threshold. Callers whose ordering is
29
+ * load-bearing (`createStoryIssues`) stay serial instead of importing this.
30
+ */
31
+ export const FANOUT_CONCURRENCY = 4;
32
+
16
33
  /**
17
34
  * @template T, R
18
35
  * @param {ReadonlyArray<T>} items