mandrel 2.24.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 (189) hide show
  1. package/.agents/agents/acceptance-critic.md +19 -21
  2. package/.agents/agents/auditor.md +16 -19
  3. package/.agents/agents/story-worker.md +41 -54
  4. package/.agents/audit-checklists/architecture.md +5 -1
  5. package/.agents/audit-checklists/baselines.md +21 -0
  6. package/.agents/audit-checklists/quality.md +3 -0
  7. package/.agents/docs/agentrc-reference.json +2 -1
  8. package/.agents/docs/configuration.md +2 -1
  9. package/.agents/docs/quality-gates.md +80 -18
  10. package/.agents/docs/workflows.md +4 -2
  11. package/.agents/instructions.md +1 -1
  12. package/.agents/rules/ci-remediation.md +68 -3
  13. package/.agents/schemas/agentrc.schema.json +6 -1
  14. package/.agents/schemas/audit-rules.json +15 -0
  15. package/.agents/schemas/baselines/audit-baselines-envelope.schema.json +242 -0
  16. package/.agents/schemas/baselines/baseline-envelope.schema.json +8 -0
  17. package/.agents/schemas/baselines/crap.schema.json +13 -1
  18. package/.agents/schemas/crap-report.schema.json +37 -0
  19. package/.agents/schemas/model-attribution.schema.json +4 -0
  20. package/.agents/scripts/acceptance-eval.js +124 -15
  21. package/.agents/scripts/audit-baselines.js +136 -0
  22. package/.agents/scripts/audit-labels-bootstrap.js +16 -95
  23. package/.agents/scripts/audit-to-stories.js +33 -3
  24. package/.agents/scripts/check-arch-cycles.js +12 -93
  25. package/.agents/scripts/check-baseline-drift.js +16 -3
  26. package/.agents/scripts/check-baselines.js +19 -3
  27. package/.agents/scripts/check-context-budget.js +95 -14
  28. package/.agents/scripts/check-cyclomatic.js +214 -0
  29. package/.agents/scripts/check-schema-references.js +392 -0
  30. package/.agents/scripts/check-test-temp-hygiene.js +121 -1
  31. package/.agents/scripts/check-workflow-timeouts.js +291 -0
  32. package/.agents/scripts/cleanup-repo-test-temp.js +7 -5
  33. package/.agents/scripts/deliver-light.js +3 -4
  34. package/.agents/scripts/deliver-recover.js +13 -0
  35. package/.agents/scripts/diagnose-friction.js +85 -19
  36. package/.agents/scripts/lib/audit-baselines/engine.js +177 -0
  37. package/.agents/scripts/lib/audit-baselines/gate-surface.js +63 -0
  38. package/.agents/scripts/lib/audit-baselines/headroom.js +72 -0
  39. package/.agents/scripts/lib/audit-baselines/hotspots.js +69 -0
  40. package/.agents/scripts/lib/audit-baselines/kinds.js +313 -0
  41. package/.agents/scripts/lib/audit-baselines/outliers.js +100 -0
  42. package/.agents/scripts/lib/audit-baselines/read.js +87 -0
  43. package/.agents/scripts/lib/audit-baselines/staleness.js +123 -0
  44. package/.agents/scripts/lib/audit-baselines/surface-entry.js +106 -0
  45. package/.agents/scripts/lib/audit-baselines/trend.js +125 -0
  46. package/.agents/scripts/lib/audit-baselines/weights.js +193 -0
  47. package/.agents/scripts/lib/audit-suite/index.js +0 -5
  48. package/.agents/scripts/lib/audit-suite/selector.js +9 -62
  49. package/.agents/scripts/lib/audit-to-stories/audit-label-taxonomy.js +177 -0
  50. package/.agents/scripts/lib/audit-to-stories/audit-lenses.js +1 -0
  51. package/.agents/scripts/lib/audit-to-stories/build-story-body.js +35 -3
  52. package/.agents/scripts/lib/audit-to-stories/group-findings.js +20 -11
  53. package/.agents/scripts/lib/audit-to-stories/parse-audit-md.js +23 -9
  54. package/.agents/scripts/lib/audit-to-stories/seed-from-findings.js +9 -2
  55. package/.agents/scripts/lib/baseline-loader.js +0 -0
  56. package/.agents/scripts/lib/baseline-schema-registry.js +13 -1
  57. package/.agents/scripts/lib/baselines/diff-scope-cli.js +22 -160
  58. package/.agents/scripts/lib/baselines/duplication-scanner.js +27 -0
  59. package/.agents/scripts/lib/baselines/git-base.js +0 -0
  60. package/.agents/scripts/lib/baselines/kinds/crap.js +499 -57
  61. package/.agents/scripts/lib/baselines/preview-gates.js +58 -15
  62. package/.agents/scripts/lib/baselines/reader.js +10 -0
  63. package/.agents/scripts/lib/baselines/refresh-service.js +69 -11
  64. package/.agents/scripts/lib/baselines/scope.js +39 -90
  65. package/.agents/scripts/lib/baselines/writer.js +16 -7
  66. package/.agents/scripts/lib/changed-files.js +8 -1
  67. package/.agents/scripts/lib/cli-args.js +115 -1
  68. package/.agents/scripts/lib/close-validation/runner.js +70 -25
  69. package/.agents/scripts/lib/config-settings-schema-delivery.js +5 -0
  70. package/.agents/scripts/lib/crap-engine.js +124 -27
  71. package/.agents/scripts/lib/crap-method-identity.js +153 -0
  72. package/.agents/scripts/lib/crap-utils.js +86 -13
  73. package/.agents/scripts/lib/cyclomatic-ceiling.js +265 -0
  74. package/.agents/scripts/lib/env-loader.js +46 -16
  75. package/.agents/scripts/lib/feedback-loop/audit-results-graduator.js +0 -2
  76. package/.agents/scripts/lib/feedback-loop/prior-feedback-fetcher.js +0 -2
  77. package/.agents/scripts/lib/feedback-loop/retro-proposals-graduator.js +0 -2
  78. package/.agents/scripts/lib/findings/route-finding.js +123 -17
  79. package/.agents/scripts/lib/findings/severity.js +80 -2
  80. package/.agents/scripts/lib/git/cached-fetch.js +0 -0
  81. package/.agents/scripts/lib/git-utils.js +136 -80
  82. package/.agents/scripts/lib/import-graph.js +156 -0
  83. package/.agents/scripts/lib/label-constants.js +17 -0
  84. package/.agents/scripts/lib/observability/runtime-friction.js +17 -2
  85. package/.agents/scripts/lib/observability/source-classifier.js +175 -2
  86. package/.agents/scripts/lib/orchestration/acceptance-eval-decision.js +110 -17
  87. package/.agents/scripts/lib/orchestration/ceremony-routing.js +17 -12
  88. package/.agents/scripts/lib/orchestration/check-baselines/phases/compare.js +36 -6
  89. package/.agents/scripts/lib/orchestration/check-baselines/phases/evaluate.js +5 -0
  90. package/.agents/scripts/lib/orchestration/check-baselines/phases/floors.js +12 -1
  91. package/.agents/scripts/lib/orchestration/check-baselines/phases/report.js +8 -1
  92. package/.agents/scripts/lib/orchestration/ci-rerun-guard.js +548 -0
  93. package/.agents/scripts/lib/orchestration/deliver-recover.js +2 -2
  94. package/.agents/scripts/lib/orchestration/git-cleanup/phases/phase-drivers.js +10 -5
  95. package/.agents/scripts/lib/orchestration/git-cleanup/phases/render.js +39 -3
  96. package/.agents/scripts/lib/orchestration/lifecycle/listeners/watcher.js +71 -20
  97. package/.agents/scripts/lib/orchestration/light-backstop.js +20 -3
  98. package/.agents/scripts/lib/orchestration/light-escalation.js +82 -3
  99. package/.agents/scripts/lib/orchestration/light-suitability.js +103 -5
  100. package/.agents/scripts/lib/orchestration/plan-context.js +119 -66
  101. package/.agents/scripts/lib/orchestration/plan-persist/fan-out-gate.js +31 -5
  102. package/.agents/scripts/lib/orchestration/plan-persist/run-plan-persist.js +216 -109
  103. package/.agents/scripts/lib/orchestration/plan-persist/story-ops.js +61 -13
  104. package/.agents/scripts/lib/orchestration/plan-persist/supersede-ops.js +79 -22
  105. package/.agents/scripts/lib/orchestration/plan-text-hygiene.js +51 -20
  106. package/.agents/scripts/lib/orchestration/planning/authoring-context.js +70 -74
  107. package/.agents/scripts/lib/orchestration/planning/memory-pool-advisory.js +231 -0
  108. package/.agents/scripts/lib/orchestration/resolve-stories.js +18 -17
  109. package/.agents/scripts/lib/orchestration/retro-proposals.js +0 -0
  110. package/.agents/scripts/lib/orchestration/run-epilogue.js +12 -0
  111. package/.agents/scripts/lib/orchestration/run-scoped-config.js +276 -0
  112. package/.agents/scripts/lib/orchestration/single-story-close/phases/base-sync.js +41 -10
  113. package/.agents/scripts/lib/orchestration/single-story-close/phases/confirm-merge.js +125 -37
  114. package/.agents/scripts/lib/orchestration/single-story-close/phases/normalize-pr-title.js +6 -6
  115. package/.agents/scripts/lib/orchestration/single-story-close/phases/options.js +42 -38
  116. package/.agents/scripts/lib/orchestration/single-story-close/phases/pull-request.js +129 -21
  117. package/.agents/scripts/lib/orchestration/single-story-close/phases/push.js +6 -1
  118. package/.agents/scripts/lib/orchestration/single-story-close/runner.js +267 -125
  119. package/.agents/scripts/lib/orchestration/spec-budget.js +16 -5
  120. package/.agents/scripts/lib/orchestration/story-follow-ups.js +182 -95
  121. package/.agents/scripts/lib/orchestration/ticket-validator-conflicts.js +22 -0
  122. package/.agents/scripts/lib/orchestration/ticket-validator.js +5 -11
  123. package/.agents/scripts/lib/orchestration/ticketing/reads.js +4 -4
  124. package/.agents/scripts/lib/reserved-test-ids.js +77 -0
  125. package/.agents/scripts/lib/story-adjacency.js +3 -3
  126. package/.agents/scripts/lib/story-plan.js +137 -42
  127. package/.agents/scripts/lib/templates/decomposer-prompts.js +4 -4
  128. package/.agents/scripts/lib/test-runner-contract.js +134 -0
  129. package/.agents/scripts/lib/test-tiers.js +11 -2
  130. package/.agents/scripts/lib/ticket-body-sections.js +1 -1
  131. package/.agents/scripts/lib/util/concurrent-map.js +17 -0
  132. package/.agents/scripts/lib/util/parse-id-list.js +103 -0
  133. package/.agents/scripts/lib/util/poll-loop.js +18 -2
  134. package/.agents/scripts/lib/wave-runner/live-probe.js +29 -13
  135. package/.agents/scripts/lib/wave-runner/ready-set.js +254 -43
  136. package/.agents/scripts/lib/workers/combined-mi-crap-worker.js +10 -13
  137. package/.agents/scripts/lib/workers/crap-worker.js +8 -13
  138. package/.agents/scripts/lib/workers/maintainability-report-worker.js +4 -10
  139. package/.agents/scripts/lib/workers/maintainability-worker.js +4 -10
  140. package/.agents/scripts/lib/workers/serve-worker-messages.js +35 -0
  141. package/.agents/scripts/lib/worktree/git-hooks.js +206 -0
  142. package/.agents/scripts/lib/worktree/lifecycle/creation.js +6 -0
  143. package/.agents/scripts/lib/worktree-manager.js +14 -0
  144. package/.agents/scripts/plan-run-epilogue.js +17 -5
  145. package/.agents/scripts/pr-watch-with-update.js +568 -213
  146. package/.agents/scripts/providers/github/tickets.js +33 -10
  147. package/.agents/scripts/provision-git-hooks.js +85 -0
  148. package/.agents/scripts/quality-preview.js +137 -28
  149. package/.agents/scripts/resolve-stories.js +4 -1
  150. package/.agents/scripts/run-coverage.js +86 -35
  151. package/.agents/scripts/run-lint.js +20 -0
  152. package/.agents/scripts/run-tests.js +36 -36
  153. package/.agents/scripts/single-story-close.js +34 -2
  154. package/.agents/scripts/single-story-confirm-merge.js +22 -6
  155. package/.agents/scripts/single-story-init.js +7 -0
  156. package/.agents/scripts/stories-wave-tick.js +308 -47
  157. package/.agents/scripts/story-plan.js +65 -9
  158. package/.agents/scripts/update-coverage-baseline.js +34 -4
  159. package/.agents/scripts/update-crap-baseline.js +42 -4
  160. package/.agents/scripts/update-duplication-baseline.js +209 -83
  161. package/.agents/scripts/update-maintainability-baseline.js +31 -4
  162. package/.agents/scripts/validate-docs-freshness.js +1 -0
  163. package/.agents/skills/core/diagnose-friction/SKILL.md +4 -1
  164. package/.agents/skills/core/gates-and-baselines/SKILL.md +17 -11
  165. package/.agents/skills/skills.index.json +2 -2
  166. package/.agents/templates/single-story-body.md +16 -8
  167. package/.agents/workflows/audit-architecture.md +69 -13
  168. package/.agents/workflows/audit-baselines.md +289 -0
  169. package/.agents/workflows/audit-navigability.md +5 -4
  170. package/.agents/workflows/audit-quality.md +26 -0
  171. package/.agents/workflows/audit-to-stories.md +30 -4
  172. package/.agents/workflows/deliver.md +92 -97
  173. package/.agents/workflows/helpers/acceptance-self-eval.md +47 -10
  174. package/.agents/workflows/helpers/audit-lens-core.md +25 -4
  175. package/.agents/workflows/helpers/code-quality-guardrails.md +9 -2
  176. package/.agents/workflows/helpers/deliver-digest.md +41 -21
  177. package/.agents/workflows/helpers/deliver-reference.md +95 -4
  178. package/.agents/workflows/helpers/deliver-story-reference.md +174 -52
  179. package/.agents/workflows/helpers/deliver-story.md +104 -119
  180. package/.agents/workflows/helpers/plan-reference.md +49 -5
  181. package/.agents/workflows/memory-consolidate.md +116 -0
  182. package/.agents/workflows/plan.md +62 -75
  183. package/README.md +13 -6
  184. package/docs/CHANGELOG.md +93 -0
  185. package/lib/cli/update.js +14 -11
  186. package/lib/cli/version-check.js +9 -1
  187. package/package.json +9 -4
  188. package/.agents/schemas/friction-event.schema.json +0 -56
  189. 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.
@@ -0,0 +1,77 @@
1
+ /**
2
+ * lib/reserved-test-ids.js — the framework's one declaration of which
3
+ * Epic / Story ids are **synthetic** (Story #4892).
4
+ *
5
+ * The band `999000–999999` has been reserved for test fixtures since the
6
+ * post-test temp reaper shipped, but it lived as a private regexp inside
7
+ * `cleanup-repo-test-temp.js` (`/^epic-999\d{3}$/`) and nothing else could
8
+ * consult it. Two independent surfaces need the same rule, and spelling it
9
+ * twice is how they would drift:
10
+ *
11
+ * 1. **Write side.** A test that spawns a real CLI at the repository root
12
+ * inherits the real state directory, so its fixture telemetry can land
13
+ * in the operator's live `temp/` signals tree. The post-run guard in
14
+ * `check-test-temp-hygiene.js` fails the run when a stream file owned by
15
+ * a reserved id survives there.
16
+ * 2. **Read side.** The retro composer renders the distinct Stories a
17
+ * friction bucket spans as recurrence evidence in a body that is filed
18
+ * as a real GitHub issue. A reserved id is synthetic by construction, so
19
+ * it can never be resolved to a real issue and must never be published
20
+ * as evidence (issue #4870 named `#999999` as a contributing Story).
21
+ *
22
+ * Deliberately dependency-free: the write side is a CLI guard and the read
23
+ * side is a pure composer, so the shared rule must not drag config, fs, or
24
+ * git resolution into either.
25
+ */
26
+
27
+ /** First id in the reserved test-fixture band. */
28
+ const RESERVED_TEST_ID_MIN = 999000;
29
+
30
+ /** Last id in the reserved test-fixture band. */
31
+ const RESERVED_TEST_ID_MAX = 999999;
32
+
33
+ /**
34
+ * Human-readable band, for guard failure messages that have to tell an
35
+ * operator which ids they may not use for real work.
36
+ */
37
+ export const RESERVED_TEST_ID_BAND = `${RESERVED_TEST_ID_MIN}–${RESERVED_TEST_ID_MAX}`;
38
+
39
+ /**
40
+ * Is `id` inside the reserved test-fixture band?
41
+ *
42
+ * Exact-band membership, because this is the predicate the temp reaper and
43
+ * the pollution guard share: both classify an on-disk directory that a test
44
+ * created, and over-reaching (treating every large id as reserved) would let
45
+ * one of them delete or condemn a directory the band never claimed.
46
+ *
47
+ * @param {unknown} id
48
+ * @returns {boolean}
49
+ */
50
+ export function isReservedTestId(id) {
51
+ return (
52
+ Number.isInteger(id) &&
53
+ id >= RESERVED_TEST_ID_MIN &&
54
+ id <= RESERVED_TEST_ID_MAX
55
+ );
56
+ }
57
+
58
+ /**
59
+ * May `id` be published as ticket evidence in operator-visible output?
60
+ *
61
+ * The floor is the reserved band, not the band itself: an id at or above
62
+ * `RESERVED_TEST_ID_MIN` is synthetic by construction (the framework reserves
63
+ * that space for fixtures, and no repository has minted that many issues), so
64
+ * nothing at or beyond the floor can be resolved to a real issue. A
65
+ * non-integer or non-positive id is not a ticket reference at all.
66
+ *
67
+ * This is a plausibility bound, not an existence probe: it is the strongest
68
+ * statement a caller with no ticket-provider access can make, and it is what
69
+ * keeps the invariant true at every call site instead of only where somebody
70
+ * remembered to wire a probe.
71
+ *
72
+ * @param {unknown} id
73
+ * @returns {boolean}
74
+ */
75
+ export function isPublishableTicketId(id) {
76
+ return Number.isInteger(id) && id > 0 && id < RESERVED_TEST_ID_MIN;
77
+ }
@@ -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