mandrel 2.55.0 → 2.57.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/plan-critic.md +13 -18
  2. package/.agents/agents/story-worker.md +25 -34
  3. package/.agents/docs/agentrc-reference.json +4 -30
  4. package/.agents/docs/configuration.md +11 -28
  5. package/.agents/docs/execution-reference.md +5 -5
  6. package/.agents/docs/quality-gates.md +8 -7
  7. package/.agents/instructions.md +9 -10
  8. package/.agents/rules/ci-remediation.md +39 -21
  9. package/.agents/schemas/agentrc.schema.json +28 -185
  10. package/.agents/schemas/story-deliver-terminal.schema.json +1 -1
  11. package/.agents/scripts/acceptance-eval.js +107 -17
  12. package/.agents/scripts/audit-to-stories.js +222 -75
  13. package/.agents/scripts/ceremony-derive.js +191 -0
  14. package/.agents/scripts/check-context-budget.js +28 -33
  15. package/.agents/scripts/check-cyclomatic.js +4 -3
  16. package/.agents/scripts/deliver-light.js +31 -94
  17. package/.agents/scripts/file-ci-gap.js +306 -0
  18. package/.agents/scripts/lib/audit-suite/checklist-threading.js +15 -2
  19. package/.agents/scripts/lib/audit-to-stories/audit-label-taxonomy.js +25 -1
  20. package/.agents/scripts/lib/audit-to-stories/dedupe-against-github.js +40 -52
  21. package/.agents/scripts/lib/audit-to-stories/finding-adapter.js +5 -1
  22. package/.agents/scripts/lib/audit-to-stories/issue-corpus.js +162 -0
  23. package/.agents/scripts/lib/audit-to-stories/issues-file.js +121 -0
  24. package/.agents/scripts/lib/audit-to-stories/ledger-commit.js +1 -1
  25. package/.agents/scripts/lib/audit-to-stories/ledger-record.js +126 -0
  26. package/.agents/scripts/lib/audit-to-stories/seed-from-findings.js +11 -0
  27. package/.agents/scripts/lib/baselines/coverage-updater-cli.js +110 -0
  28. package/.agents/scripts/lib/baselines/crap-preview-scan.js +25 -0
  29. package/.agents/scripts/lib/baselines/crap-updater-cli.js +223 -0
  30. package/.agents/scripts/lib/bdd-scenario-budget.js +21 -3
  31. package/.agents/scripts/lib/bootstrap/quality-bootstrap.js +0 -1
  32. package/.agents/scripts/lib/close-validation/gates.js +52 -1
  33. package/.agents/scripts/lib/config/acceptance-eval.js +25 -57
  34. package/.agents/scripts/lib/config/delivery-routing.js +7 -33
  35. package/.agents/scripts/lib/config/explain.js +0 -19
  36. package/.agents/scripts/lib/config/limits.js +18 -78
  37. package/.agents/scripts/lib/config/quality.js +6 -3
  38. package/.agents/scripts/lib/config/runners.js +3 -2
  39. package/.agents/scripts/lib/config-settings-schema-delivery.js +15 -68
  40. package/.agents/scripts/lib/config-settings-schema-quality.js +0 -14
  41. package/.agents/scripts/lib/config-settings-schema.js +49 -143
  42. package/.agents/scripts/lib/crap-engine.js +35 -4
  43. package/.agents/scripts/lib/crap-utils.js +17 -1
  44. package/.agents/scripts/lib/cyclomatic-ceiling.js +19 -7
  45. package/.agents/scripts/lib/feedback-loop/graduator-core.js +53 -13
  46. package/.agents/scripts/lib/feedback-loop/prior-feedback-fetcher.js +71 -25
  47. package/.agents/scripts/lib/feedback-loop/retro-proposals-graduator.js +18 -25
  48. package/.agents/scripts/lib/{audit-to-stories/ledger.js → findings/audit-ledger.js} +131 -24
  49. package/.agents/scripts/lib/findings/route-finding.js +38 -0
  50. package/.agents/scripts/lib/generated/agentrc-validator.js +1 -1
  51. package/.agents/scripts/lib/github/framework-repo.js +148 -2
  52. package/.agents/scripts/lib/label-constants.js +6 -1
  53. package/.agents/scripts/lib/observability/runtime-friction.js +1 -1
  54. package/.agents/scripts/lib/observability/source-classifier.js +2 -0
  55. package/.agents/scripts/lib/orchestration/acceptance-eval-decision.js +5 -4
  56. package/.agents/scripts/lib/orchestration/ceremony-routing.js +19 -73
  57. package/.agents/scripts/lib/orchestration/ci-gap-intake.js +605 -0
  58. package/.agents/scripts/lib/orchestration/ci-rerun-guard.js +13 -8
  59. package/.agents/scripts/lib/orchestration/complexity-gate.js +46 -212
  60. package/.agents/scripts/lib/orchestration/file-assumptions.js +32 -17
  61. package/.agents/scripts/lib/orchestration/light-escalation.js +3 -3
  62. package/.agents/scripts/lib/orchestration/light-suitability.js +66 -233
  63. package/.agents/scripts/lib/orchestration/plan-context.js +181 -387
  64. package/.agents/scripts/lib/orchestration/plan-critic-conditions.js +42 -153
  65. package/.agents/scripts/lib/orchestration/plan-critics-evaluate.js +14 -70
  66. package/.agents/scripts/lib/orchestration/plan-persist/audit-provenance.js +197 -0
  67. package/.agents/scripts/lib/orchestration/plan-persist/changes-repair.js +300 -0
  68. package/.agents/scripts/lib/orchestration/plan-persist/persist-helpers.js +131 -168
  69. package/.agents/scripts/lib/orchestration/plan-persist/run-plan-persist.js +133 -299
  70. package/.agents/scripts/lib/orchestration/plan-persist/soft-findings.js +55 -0
  71. package/.agents/scripts/lib/orchestration/plan-persist/story-ops.js +16 -65
  72. package/.agents/scripts/lib/orchestration/plan-persist/wave-serialisation.js +22 -35
  73. package/.agents/scripts/lib/orchestration/plan-text-hygiene.js +30 -139
  74. package/.agents/scripts/lib/orchestration/planning/memory-pool-advisory.js +61 -223
  75. package/.agents/scripts/lib/orchestration/run-epilogue.js +4 -4
  76. package/.agents/scripts/lib/orchestration/single-story-close/phases/close-validation.js +5 -0
  77. package/.agents/scripts/lib/orchestration/single-story-close/phases/pre-gate-steps.js +46 -16
  78. package/.agents/scripts/lib/orchestration/story-close/context-budget-writeback.js +213 -0
  79. package/.agents/scripts/lib/orchestration/story-follow-ups.js +32 -20
  80. package/.agents/scripts/lib/orchestration/task-body-validator.js +10 -63
  81. package/.agents/scripts/lib/orchestration/ticket-validator-conflicts.js +33 -539
  82. package/.agents/scripts/lib/orchestration/ticket-validator-sizing.js +21 -414
  83. package/.agents/scripts/lib/orchestration/ticket-validator.js +54 -118
  84. package/.agents/scripts/lib/orchestration/verify-credit.js +69 -24
  85. package/.agents/scripts/lib/story-body/body-format-lints.js +15 -85
  86. package/.agents/scripts/lib/story-body/story-body.js +17 -237
  87. package/.agents/scripts/lib/templates/decomposer-prompts.js +84 -121
  88. package/.agents/scripts/lib/test-isolate/cli-options.js +93 -0
  89. package/.agents/scripts/lib/test-isolate/progress-log.js +45 -0
  90. package/.agents/scripts/lib/test-isolate/render-report.js +97 -0
  91. package/.agents/scripts/lib/test-isolate/run-isolate.js +87 -0
  92. package/.agents/scripts/lib/test-run-credit.js +266 -0
  93. package/.agents/scripts/lib/wave-runner/footprint.js +48 -358
  94. package/.agents/scripts/lib/wave-runner/ready-set.js +6 -5
  95. package/.agents/scripts/lib/workers/crap-worker.js +32 -41
  96. package/.agents/scripts/plan-context.js +7 -9
  97. package/.agents/scripts/plan-critics.js +28 -54
  98. package/.agents/scripts/plan-persist.js +25 -68
  99. package/.agents/scripts/pr-watch-with-update.js +3 -2
  100. package/.agents/scripts/quality-preview.js +51 -0
  101. package/.agents/scripts/run-tests.js +12 -0
  102. package/.agents/scripts/stories-wave-tick.js +23 -45
  103. package/.agents/scripts/test-isolate.js +13 -180
  104. package/.agents/scripts/update-coverage-baseline.js +25 -70
  105. package/.agents/scripts/update-crap-baseline.js +19 -123
  106. package/.agents/skills/core/scope-triage/SKILL.md +3 -3
  107. package/.agents/workflows/audit-clean-code.md +4 -3
  108. package/.agents/workflows/audit-to-stories.md +63 -27
  109. package/.agents/workflows/helpers/acceptance-self-eval.md +41 -41
  110. package/.agents/workflows/helpers/code-quality-guardrails.md +4 -4
  111. package/.agents/workflows/helpers/code-review.md +2 -3
  112. package/.agents/workflows/helpers/deliver-digest.md +41 -57
  113. package/.agents/workflows/helpers/deliver-light.md +40 -105
  114. package/.agents/workflows/helpers/deliver-reference.md +1 -1
  115. package/.agents/workflows/helpers/deliver-story-reference.md +56 -62
  116. package/.agents/workflows/helpers/deliver-story.md +9 -13
  117. package/.agents/workflows/helpers/plan-reference.md +132 -196
  118. package/.agents/workflows/mandrel-plan.md +28 -41
  119. package/.agents/workflows/memory-consolidate.md +9 -13
  120. package/docs/CHANGELOG.md +33 -0
  121. package/lib/migrations/index.js +4 -0
  122. package/lib/migrations/steps/2.57.0-retire-delivery-limit-knobs.js +45 -0
  123. package/lib/migrations/steps/2.57.0-retire-planning-limit-knobs.js +59 -0
  124. package/package.json +1 -1
  125. package/.agents/scripts/lib/framework-version.js +0 -39
  126. package/.agents/scripts/lib/orchestration/consolidation-precondition.js +0 -223
  127. package/.agents/scripts/lib/orchestration/plan-persist/fan-out-gate.js +0 -97
  128. package/.agents/scripts/lib/orchestration/planning/decomposer-context.js +0 -26
  129. package/.agents/scripts/lib/orchestration/spec-budget.js +0 -89
  130. package/.agents/scripts/lib/orchestration/spec-spill.js +0 -74
  131. package/.agents/scripts/lib/orchestration/verify-tier-repair.js +0 -107
@@ -1,4 +1,3 @@
1
- import { resolveListValue } from '../config/shared.js';
2
1
  import { parse as parseStoryBody } from '../story-body/story-body.js';
3
2
  import { collectStoryAssumptionEntries } from './file-assumptions.js';
4
3
  import { computeStoryReachability } from './story-reachability.js';
@@ -10,12 +9,11 @@ import { computeStoryReachability } from './story-reachability.js';
10
9
  * `resolveStoryBody` (Story #4271).
11
10
  *
12
11
  * The decomposer emits `body` as the canonical serialized **string**, but
13
- * the conflict passes (`indexConsumers`, `indexAssumptionEntries`,
14
- * `computeMissingBddScaffoldFindings`, and the producer path scan in
15
- * `collectStoryProducerPaths`) historically read `story.body` only when it
16
- * was already an object — so on the production string shape the
17
- * `implicit-cross-story-dep`, `fan-out`, registry, and `missing-bdd-scaffold`
18
- * findings emitted nothing. Parsing the body once at the entry point and
12
+ * the conflict passes (`indexConsumers`, `computeMissingBddScaffoldFindings`,
13
+ * and the producer path scan in `collectStoryProducerPaths`) historically
14
+ * read `story.body` only when it was already an object — so on the
15
+ * production string shape the `implicit-cross-story-dep` and
16
+ * `missing-bdd-scaffold` findings emitted nothing. Parsing the body once at the entry point and
19
17
  * threading the normalized Story through every pass restores parity.
20
18
  *
21
19
  * `collectStoryAssumptionEntries` already parses string bodies itself, so a
@@ -88,54 +86,14 @@ function normalizeStoryBody(story) {
88
86
  * @typedef {SharedEditorFinding | ImplicitCrossStoryDepFinding} ConflictFinding
89
87
  */
90
88
 
91
- const DEFAULT_POLICY = Object.freeze({
92
- failOnSharedEditors: false,
93
- requireExplicitCrossStoryDeps: false,
94
- failOnRegistryConflicts: false,
95
- failOnMissingBddScaffold: false,
96
- largeFanOutThreshold: 10,
97
- registries: null, // null = use DEFAULT_REGISTRY_PATTERNS
98
- fanOutCounter: null, // null = no fan-out probe (skip)
99
- });
100
-
101
89
  /**
102
- * Default cross-cutting registry / barrel files. Story #2962 — these are
103
- * files whose primary purpose is to wire siblings together (registries,
104
- * handler maps, listener barrels). When two or more concurrent Stories
105
- * either edit the registry directly OR create sibling files that need
106
- * registration in it, the registry edits collide on every Story-to-Epic
107
- * close after the first.
108
- *
109
- * Patterns support two shapes:
110
- * - exact path — `lib/orchestration/lifecycle/listeners/index.js`
111
- * - `**` suffix — `**\/listeners/index.js` (matches any depth)
112
- *
113
- * Module-private since `resolveConflictPolicy` became the one production
114
- * reader; tests reach it through `_internal`.
90
+ * Every conflict class is advisory (`'soft'`) since Story #5312: the
91
+ * `planning.failOnSharedEditors` / `requireExplicitCrossStoryDeps` /
92
+ * `failOnRegistryConflicts` / `failOnLargeFanOut` upgrade knobs are gone,
93
+ * along with the registry and fan-out findings they gated. A finding is a
94
+ * line in the plan summary the operator reads, never a refusal.
115
95
  */
116
- const DEFAULT_REGISTRY_PATTERNS = Object.freeze([
117
- 'lib/orchestration/lifecycle/listeners/index.js',
118
- '**/listeners/index.js',
119
- '**/handlers/index.js',
120
- ]);
121
-
122
- function matchRegistryPattern(path, pattern) {
123
- if (pattern.startsWith('**/')) {
124
- const tail = pattern.slice(3);
125
- return path === tail || path.endsWith(`/${tail}`);
126
- }
127
- return path === pattern;
128
- }
129
-
130
- function isRegistryPath(path, patterns) {
131
- for (const p of patterns) if (matchRegistryPattern(path, p)) return true;
132
- return false;
133
- }
134
-
135
- function parentDirOf(path) {
136
- const idx = path.lastIndexOf('/');
137
- return idx <= 0 ? '' : path.slice(0, idx);
138
- }
96
+ const SOFT = 'soft';
139
97
 
140
98
  /**
141
99
  * Assumptions that imply a *write* to a path — and therefore make the Story
@@ -321,36 +279,6 @@ function computeImplicitDepFindings(consumers, producers, reach, severity) {
321
279
  return findings;
322
280
  }
323
281
 
324
- /**
325
- * Index every object-form `body.changes` entry by `{ path, assumption }`
326
- * along with its parent Task/Story so the registry-and-fan-out passes can
327
- * reason about creates/deletes without re-walking the ticket array.
328
- */
329
- function indexAssumptionEntries(stories) {
330
- const entries = [];
331
- for (const story of stories) {
332
- const body = story?.body;
333
- if (!body || typeof body !== 'object') continue;
334
- const changes = Array.isArray(body.changes) ? body.changes : [];
335
- for (const change of changes) {
336
- if (
337
- change === null ||
338
- typeof change !== 'object' ||
339
- typeof change.path !== 'string' ||
340
- change.path.length === 0
341
- )
342
- continue;
343
- entries.push({
344
- path: change.path,
345
- assumption: change.assumption ?? null,
346
- storySlug: storySlugOf(story),
347
- taskSlug: story.slug,
348
- });
349
- }
350
- }
351
- return entries;
352
- }
353
-
354
282
  /**
355
283
  * Compute `missing-bdd-scaffold` findings (Story #3857).
356
284
  *
@@ -439,287 +367,17 @@ function computeMissingBddScaffoldFindings(stories, reach, severity) {
439
367
  return findings;
440
368
  }
441
369
 
442
- /**
443
- * Compute `cross-cutting-registries` findings (Story #2962).
444
- *
445
- * A registry/barrel file (e.g. `lib/orchestration/lifecycle/listeners/index.js`)
446
- * collides whenever two or more concurrent Stories either
447
- *
448
- * (a) directly edit the registry file, OR
449
- * (b) create a new sibling file in the same directory that the registry
450
- * would have to wire up.
451
- *
452
- * For each known registry pattern (`patterns`), we collect every Story whose
453
- * Tasks satisfy (a) or (b). When ≥2 such Stories sit in the same wave (no
454
- * transitive `depends_on` between them), emit a single finding keyed by the
455
- * registry path.
456
- *
457
- * Reached from the module's `_internal` named export. The pass is pure — it
458
- * performs no filesystem I/O and spawns no process — so its optional final
459
- * `deps` parameter seams the three collaborating predicates rather than a
460
- * built-in; each entry defaults to the real implementation
461
- * (`.agents/rules/test-seams.md` rules 1-3: the defaults live on the function,
462
- * never on a module-level mutable variable).
463
- *
464
- * @param {object} input
465
- * @param {{
466
- * isRegistryPathImpl?: typeof isRegistryPath,
467
- * inSameWaveImpl?: typeof inSameWave,
468
- * registryRegistryImpl?: typeof registryRegistry,
469
- * }} [deps]
470
- */
471
- function computeRegistryFindings(
472
- { stories, reach, patterns, producers, assumptionEntries, severity },
473
- {
474
- isRegistryPathImpl = isRegistryPath,
475
- inSameWaveImpl = inSameWave,
476
- registryRegistryImpl = registryRegistry,
477
- } = {},
478
- ) {
479
- const findings = [];
480
- // Build the matching registry path set from producer & creator paths.
481
- const registryHits = new Map(); // registryPath -> Map<storySlug, producers[]>
482
- function bump(registryPath, entry) {
483
- let perStory = registryHits.get(registryPath);
484
- if (!perStory) {
485
- perStory = new Map();
486
- registryHits.set(registryPath, perStory);
487
- }
488
- const existing = perStory.get(entry.storySlug) ?? [];
489
- existing.push(entry);
490
- perStory.set(entry.storySlug, existing);
491
- }
492
- // (a) direct registry edits — object-form `{ path, assumption }` entries
493
- // from `indexAssumptionEntries` (and the producer index built from them).
494
- for (const [path, entries] of producers.entries()) {
495
- if (!isRegistryPathImpl(path, patterns)) continue;
496
- for (const e of entries) {
497
- bump(path, {
498
- storySlug: e.storySlug,
499
- taskSlug: e.taskSlug,
500
- path,
501
- reason: 'edits-registry',
502
- });
503
- }
504
- }
505
- for (const e of assumptionEntries) {
506
- if (!isRegistryPathImpl(e.path, patterns)) continue;
507
- bump(e.path, {
508
- storySlug: e.storySlug,
509
- taskSlug: e.taskSlug,
510
- path: e.path,
511
- reason: 'edits-registry',
512
- });
513
- }
514
- // (b) sibling creates that would require registration in a registry.
515
- // A registry path's parent dir defines its "registration scope" — any
516
- // new file in that scope is a wiring candidate.
517
- const scopeByRegistry = new Map();
518
- for (const story of stories) {
519
- const body = story?.body;
520
- if (!body || typeof body !== 'object') continue;
521
- for (const change of body.changes ?? []) {
522
- if (
523
- change === null ||
524
- typeof change !== 'object' ||
525
- change.assumption !== 'creates' ||
526
- typeof change.path !== 'string'
527
- )
528
- continue;
529
- const childParent = parentDirOf(change.path);
530
- if (!childParent) continue;
531
- for (const reg of registryRegistryImpl(
532
- producers,
533
- assumptionEntries,
534
- patterns,
535
- scopeByRegistry,
536
- )) {
537
- if (reg.parentDir !== childParent) continue;
538
- bump(reg.path, {
539
- storySlug: storySlugOf(story),
540
- taskSlug: story.slug,
541
- path: change.path,
542
- reason: 'creates-sibling',
543
- });
544
- }
545
- }
546
- }
547
- for (const [registryPath, perStory] of registryHits.entries()) {
548
- const stories = Array.from(perStory.keys());
549
- if (stories.length < 2) continue;
550
- const cluster = new Set();
551
- for (let i = 0; i < stories.length; i += 1) {
552
- for (let j = i + 1; j < stories.length; j += 1) {
553
- if (inSameWaveImpl(reach, stories[i], stories[j])) {
554
- cluster.add(stories[i]);
555
- cluster.add(stories[j]);
556
- }
557
- }
558
- }
559
- if (cluster.size === 0) continue;
560
- const clusterSlugs = Array.from(cluster).sort();
561
- const producerList = [];
562
- for (const slug of clusterSlugs) {
563
- for (const p of perStory.get(slug) ?? []) producerList.push(p);
564
- }
565
- findings.push({
566
- kind: 'cross-cutting-registries',
567
- severity,
568
- registryPath,
569
- storySlugs: clusterSlugs,
570
- producers: producerList,
571
- });
572
- }
573
- return findings;
574
- }
575
-
576
- /**
577
- * Resolve the set of registry paths that should be considered in scope for
578
- * the sibling-create check. We treat any path that already matches a
579
- * registry pattern (whether produced by a Task or not — the path exists in
580
- * the project) as in-scope. To stay path-knowledge-free at plan time, we
581
- * only consider patterns that are explicit paths (no `**`) or that match a
582
- * path produced by some Task in the spec.
583
- */
584
- function registryRegistry(producers, assumptionEntries, patterns, cache) {
585
- if (cache.size > 0) return cache.values();
586
- // Explicit (no-glob) patterns: always in scope as their own path.
587
- for (const pat of patterns) {
588
- if (pat.startsWith('**/')) continue;
589
- cache.set(pat, { path: pat, parentDir: parentDirOf(pat) });
590
- }
591
- // Glob patterns: in scope iff some Task in the spec references a matching
592
- // path via changes (edits or creates). Avoids false positives when a
593
- // glob pattern doesn't apply to this repo at all.
594
- for (const path of producers.keys()) {
595
- if (cache.has(path)) continue;
596
- if (isRegistryPath(path, patterns)) {
597
- cache.set(path, { path, parentDir: parentDirOf(path) });
598
- }
599
- }
600
- for (const e of assumptionEntries) {
601
- if (cache.has(e.path)) continue;
602
- if (isRegistryPath(e.path, patterns)) {
603
- cache.set(e.path, { path: e.path, parentDir: parentDirOf(e.path) });
604
- }
605
- }
606
- return cache.values();
607
- }
608
-
609
- /**
610
- * Normalize a fan-out probe result into `{ count, files, probe }`.
611
- *
612
- * The production probe reports its referencing files and the exact command
613
- * that found them so an operator can reproduce the figure (Story #4547).
614
- * A bare number stays valid — injected test counters and any consumer
615
- * counter written against the Story #2962 contract keep working, they just
616
- * carry no audit trail.
617
- */
618
- function normalizeFanOutProbe(result) {
619
- if (typeof result === 'number') {
620
- return { count: result, files: [], probe: null };
621
- }
622
- if (result === null || typeof result !== 'object') {
623
- return { count: 0, files: [], probe: null };
624
- }
625
- const files = Array.isArray(result.files) ? result.files : [];
626
- const count = Number.isFinite(result.count) ? result.count : files.length;
627
- return { count, files, probe: result.probe ?? null };
628
- }
629
-
630
- /**
631
- * Index the basenames this spec *creates*, so a deletion that is really one
632
- * half of a move can be told apart from a genuine wide-coupling removal.
633
- */
634
- function indexCreatedBasenames(assumptionEntries) {
635
- const byBasename = new Map();
636
- for (const entry of assumptionEntries) {
637
- if (entry.assumption !== 'creates') continue;
638
- const base = entry.path.slice(entry.path.lastIndexOf('/') + 1);
639
- if (!byBasename.has(base)) byBasename.set(base, entry.path);
640
- }
641
- return byBasename;
642
- }
643
-
644
- /**
645
- * Compute `fan-out-warning` findings (Story #2962, reworked in #4547).
646
- *
647
- * For each `body.changes` entry whose `assumption` is `"deletes"`, probe the
648
- * files at the base branch that genuinely import or require the deleted
649
- * module. When that count exceeds the configured `largeFanOutThreshold`,
650
- * emit a finding carrying the referencing files and the probe that produced
651
- * them.
652
- *
653
- * The finding also records whether the deletion is **rename-shaped** — the
654
- * same spec creates a file with the deleted module's basename elsewhere —
655
- * because the remedy diverges: a move wants its importers repointed in one
656
- * Story, not a subsystem-by-subsystem migration split across several.
657
- *
658
- * The default severity is always `'soft'` — the persist gate enforces a
659
- * hard refusal via the `--allow-large-fan-out` operator flag, since the
660
- * planner cannot reduce call sites by re-prompting. Severity may still be
661
- * upgraded to `'hard'` via `failOnLargeFanOut` for callers that want the
662
- * standard `errors[]` path (e.g. CI dry-runs).
663
- */
664
- function computeFanOutFindings({
665
- assumptionEntries,
666
- threshold,
667
- counter,
668
- severity,
669
- }) {
670
- if (typeof counter !== 'function') return [];
671
- if (!Number.isFinite(threshold) || threshold < 0) return [];
672
- const findings = [];
673
- const cache = new Map();
674
- const createdBasenames = indexCreatedBasenames(assumptionEntries);
675
- for (const entry of assumptionEntries) {
676
- if (entry.assumption !== 'deletes') continue;
677
- let probed = cache.get(entry.path);
678
- if (probed === undefined) {
679
- probed = normalizeFanOutProbe(counter({ path: entry.path }));
680
- cache.set(entry.path, probed);
681
- }
682
- if (probed.count <= threshold) continue;
683
- const base = entry.path.slice(entry.path.lastIndexOf('/') + 1);
684
- const renameTarget = createdBasenames.get(base);
685
- findings.push({
686
- kind: 'fan-out-warning',
687
- severity,
688
- taskSlug: entry.taskSlug,
689
- storySlug: entry.storySlug,
690
- path: entry.path,
691
- callSiteCount: probed.count,
692
- callSites: probed.files,
693
- probe: probed.probe,
694
- renameShaped: renameTarget !== undefined && renameTarget !== entry.path,
695
- renameTarget: renameTarget === entry.path ? null : (renameTarget ?? null),
696
- threshold,
697
- });
698
- }
699
- return findings;
700
- }
701
-
702
370
  /**
703
371
  * Public entry point. Walks the normalized ticket spec once and returns
704
- * the structured cross-Story findings array. The caller's `policy` flags
705
- * decide whether each finding class lands as `'soft'` (advisory, won't
706
- * trigger re-decompose) or `'hard'` (rendered into `errors[]`).
372
+ * the structured cross-Story findings array. Every finding is `'soft'`
373
+ * (Story #5312) — an advisory line for the plan summary, never an
374
+ * `errors[]` entry.
707
375
  *
708
376
  * @param {object} input
709
377
  * @param {object[]} input.stories
710
- * @param {object} [input.policy]
711
- * @param {boolean} [input.policy.failOnSharedEditors=false]
712
- * @param {boolean} [input.policy.requireExplicitCrossStoryDeps=false]
713
- * @param {boolean} [input.policy.failOnRegistryConflicts=false]
714
- * @param {boolean} [input.policy.failOnMissingBddScaffold=false]
715
- * @param {boolean} [input.policy.failOnLargeFanOut=false]
716
- * @param {number} [input.policy.largeFanOutThreshold=10]
717
- * @param {string[]} [input.policy.registries] Registry patterns (defaults to DEFAULT_REGISTRY_PATTERNS).
718
- * @param {(arg: { path: string }) => number} [input.policy.fanOutCounter] Optional probe; when omitted the fan-out pass is skipped.
719
378
  * @returns {ConflictFinding[]}
720
379
  */
721
- export function computeConflictFindings({ stories, policy } = {}) {
722
- const merged = { ...DEFAULT_POLICY, ...(policy ?? {}) };
380
+ export function computeConflictFindings({ stories } = {}) {
723
381
  // Story #4271: normalize every Story's body to its structured object form
724
382
  // once, up front, so the canonical serialized **string** shape the
725
383
  // decomposer emits is scanned at parity with the pre-serialize object
@@ -728,87 +386,13 @@ export function computeConflictFindings({ stories, policy } = {}) {
728
386
  const producers = indexProducers(storyList);
729
387
  const consumers = indexConsumers(storyList, producers);
730
388
  const reach = computeStoryReachability(storyList);
731
- const assumptionEntries = indexAssumptionEntries(storyList);
732
- const sharedSeverity = merged.failOnSharedEditors ? 'hard' : 'soft';
733
- const implicitSeverity = merged.requireExplicitCrossStoryDeps
734
- ? 'hard'
735
- : 'soft';
736
- const registrySeverity = merged.failOnRegistryConflicts ? 'hard' : 'soft';
737
- const fanOutSeverity = merged.failOnLargeFanOut ? 'hard' : 'soft';
738
- const bddScaffoldSeverity = merged.failOnMissingBddScaffold ? 'hard' : 'soft';
739
- const patterns =
740
- Array.isArray(merged.registries) && merged.registries.length > 0
741
- ? merged.registries
742
- : DEFAULT_REGISTRY_PATTERNS;
743
389
  return [
744
- ...computeSharedEditorFindings(producers, reach, sharedSeverity),
745
- ...computeImplicitDepFindings(
746
- consumers,
747
- producers,
748
- reach,
749
- implicitSeverity,
750
- ),
751
- ...computeRegistryFindings({
752
- stories: storyList,
753
- reach,
754
- patterns,
755
- producers,
756
- assumptionEntries,
757
- severity: registrySeverity,
758
- }),
759
- ...computeFanOutFindings({
760
- assumptionEntries,
761
- threshold: merged.largeFanOutThreshold,
762
- counter: merged.fanOutCounter,
763
- severity: fanOutSeverity,
764
- }),
765
- ...computeMissingBddScaffoldFindings(storyList, reach, bddScaffoldSeverity),
390
+ ...computeSharedEditorFindings(producers, reach, SOFT),
391
+ ...computeImplicitDepFindings(consumers, producers, reach, SOFT),
392
+ ...computeMissingBddScaffoldFindings(storyList, reach, SOFT),
766
393
  ];
767
394
  }
768
395
 
769
- /**
770
- * Resolve the config-derived half of the conflict policy from
771
- * `config.planning` — the severity flags, the fan-out threshold, and the
772
- * registry patterns, in one place.
773
- *
774
- * Two passes consume `planning.*` and must not disagree: the raw
775
- * pre-assembly pass (`persist-helpers.validateTickets`, which attaches its
776
- * production `fanOutCounter` on top of this) and the post-assembly pass
777
- * (`computeAssembledConflictFindings`, which forces `fanOutCounter: null`).
778
- * Under Story #5045 each resolved its own copy, and the copies had already
779
- * drifted — `failOnMissingBddScaffold` reached only the assembled pass,
780
- * `failOnLargeFanOut` / `largeFanOutThreshold` / `crossCuttingRegistries`
781
- * only the raw one — so a knob set in config silently applied on one of the
782
- * two passes. A knob read here reaches both; that is the contract.
783
- *
784
- * `fanOutCounter` is deliberately absent: it is probe machinery, not
785
- * config, and each caller owns its own.
786
- *
787
- * @param {object} [config] Resolved config carrying `planning.*`.
788
- * @returns {object} A `computeConflictFindings` policy (no `fanOutCounter`).
789
- */
790
- export function resolveConflictPolicy(config) {
791
- const planning = config?.planning;
792
- const policy = {
793
- failOnSharedEditors: planning?.failOnSharedEditors === true,
794
- requireExplicitCrossStoryDeps:
795
- planning?.requireExplicitCrossStoryDeps === true,
796
- failOnRegistryConflicts: planning?.failOnRegistryConflicts === true,
797
- failOnLargeFanOut: planning?.failOnLargeFanOut === true,
798
- failOnMissingBddScaffold: planning?.failOnMissingBddScaffold === true,
799
- };
800
- if (Number.isFinite(planning?.largeFanOutThreshold)) {
801
- policy.largeFanOutThreshold = planning.largeFanOutThreshold;
802
- }
803
- if (planning?.crossCuttingRegistries !== undefined) {
804
- policy.registries = resolveListValue(
805
- DEFAULT_REGISTRY_PATTERNS,
806
- planning.crossCuttingRegistries,
807
- );
808
- }
809
- return policy;
810
- }
811
-
812
396
  /**
813
397
  * Re-run the cross-Story conflict passes over the **assembled** Story bodies —
814
398
  * the artifact persist actually writes (Story #5045).
@@ -824,20 +408,10 @@ export function resolveConflictPolicy(config) {
824
408
  * unreachable. Running the passes again over the serialized bodies restores
825
409
  * them.
826
410
  *
827
- * **The fan-out pass is deliberately not re-run.** It is a `git grep` per
828
- * deleted path and its inputs (`changes[]` deletes) are identical on both
829
- * sides, so re-probing would double the git cost for a byte-identical answer;
830
- * `enforceFanOutGate` already owns that class over the raw payload.
831
- *
832
- * @param {object} args
833
- * @param {Array<{ slug: string, title?: string, body: string, depends_on?: string[] }>} args.stories
834
- * Assembled Stories — `body` is the serialized, footer-stamped markdown.
835
- * @param {object} [args.config] Resolved config; `planning.*` supplies the
836
- * policy via {@link resolveConflictPolicy} — the same resolver the raw
837
- * pass uses, so a knob cannot apply on only one of the two passes.
411
+ * @param {{ stories: Array<{ slug: string, title: string, body: string, depends_on?: string[] }> }} args
838
412
  * @returns {ConflictFinding[]}
839
413
  */
840
- export function computeAssembledConflictFindings({ stories, config } = {}) {
414
+ export function computeAssembledConflictFindings({ stories } = {}) {
841
415
  return computeConflictFindings({
842
416
  stories: (Array.isArray(stories) ? stories : []).map((story) => ({
843
417
  slug: story.slug,
@@ -845,10 +419,6 @@ export function computeAssembledConflictFindings({ stories, config } = {}) {
845
419
  body: story.body,
846
420
  depends_on: Array.isArray(story.depends_on) ? story.depends_on : [],
847
421
  })),
848
- policy: {
849
- ...resolveConflictPolicy(config),
850
- fanOutCounter: null,
851
- },
852
422
  });
853
423
  }
854
424
 
@@ -867,7 +437,7 @@ export function computeAssembledConflictFindings({ stories, config } = {}) {
867
437
  export function conflictFindingKey(finding) {
868
438
  return [
869
439
  finding?.kind ?? '',
870
- finding?.path ?? finding?.registryPath ?? '',
440
+ finding?.path ?? '',
871
441
  Array.isArray(finding?.storySlugs)
872
442
  ? [...finding.storySlugs].sort().join(',')
873
443
  : (finding?.storySlug ?? ''),
@@ -877,86 +447,29 @@ export function conflictFindingKey(finding) {
877
447
  ].join('\u0000');
878
448
  }
879
449
 
880
- /**
881
- * Render the audit trail behind a fan-out finding's number, so an operator
882
- * can check the figure rather than trust it (Story #4547).
883
- *
884
- * Every importer is named — the list is deliberately **not** truncated. A
885
- * gate that fires at 100 importers is precisely when the operator needs the
886
- * list, and a `…and 109 more` tail would leave the figure uncheckable in
887
- * exactly the case the gate exists for. This message is a fail-closed stop,
888
- * not a log line; its length is the point.
889
- *
890
- * The probe is reported as what it is — the *candidate* net, which each hit
891
- * is then re-resolved against. It will report at least as many lines as the
892
- * gate counts files, so labelling it as the thing that produced the number
893
- * would send an operator chasing a discrepancy that is by design.
894
- *
895
- * Returns `''` for a bare-number counter, which carries no audit trail.
896
- */
897
- export function renderFanOutEvidence(finding) {
898
- const files = Array.isArray(finding.callSites) ? finding.callSites : [];
899
- const parts = [];
900
- if (files.length > 0) {
901
- parts.push(` Importers (${files.length}):`);
902
- for (const file of files) parts.push(` ${file}`);
903
- }
904
- if (finding.probe) {
905
- parts.push(
906
- ` Candidate probe (each hit re-resolved against its importer's directory):`,
907
- ` ${finding.probe}`,
908
- );
909
- }
910
- return parts.length > 0 ? `\n${parts.join('\n')}` : '';
911
- }
912
-
913
- /**
914
- * Render the remedy that actually fits the finding. A rename-shaped
915
- * deletion has nowhere to split to — the importers just need repointing at
916
- * the path the same plan creates — so telling the operator to split it
917
- * across Stories leaves the override as the only exit, which is exactly the
918
- * habit that defeats the gate (Story #4547).
919
- */
920
- export function renderFanOutRemedy(finding) {
921
- if (finding.renameShaped && finding.renameTarget) {
922
- return (
923
- `This deletion is rename-shaped: the same plan creates "${finding.renameTarget}" under the same basename. ` +
924
- `Repoint the importer(s) at the new path inside this Story — a move has no subsystems to split across — ` +
925
- `then rerun --allow-large-fan-out.`
926
- );
927
- }
928
- return (
929
- `Split the deletion into a subsystem-by-subsystem migration across multiple Stories, ` +
930
- `or rerun --allow-large-fan-out after confirming the deletion is intentional.`
931
- );
932
- }
933
-
934
450
  /**
935
451
  * The finding kinds that are genuinely **cross-Story conflicts** — the SSOT
936
452
  * for that question (Story #4907).
937
453
  *
938
- * Two readers need it and must not disagree: the validator, which renders
939
- * these through {@link renderHardConflictError} when policy upgrades them to
940
- * `errors[]`, and the persist soft-finding surface, which announces a
941
- * conflict as a conflict and every other soft kind (`spec-word-budget`,
942
- * `merge-candidate`, `unanchored-constant`, `missing-reason-to-exist`) as the
943
- * advisory it is. A second copy of this list is how the two drift back apart,
944
- * so it is defined exactly once and imported.
454
+ * The persist soft-finding surface announces a conflict as a conflict and
455
+ * every other soft kind as the advisory it is, and the summary comment
456
+ * renders only the shared-editor class beside the wave table. A second copy
457
+ * of this list is how readers drift apart, so it is defined exactly once and
458
+ * imported.
945
459
  */
946
460
  export const CONFLICT_KINDS = Object.freeze(
947
461
  new Set([
948
462
  'shared-editor',
949
463
  'implicit-cross-story-dep',
950
- 'cross-cutting-registries',
951
- 'fan-out-warning',
952
464
  'missing-bdd-scaffold',
953
465
  ]),
954
466
  );
955
467
 
956
468
  /**
957
- * Render a `'hard'`-severity conflict finding as a human-readable error
958
- * message. Used by the validator when policy flags upgrade a finding to
959
- * the AC-visible `errors[]` channel.
469
+ * Render a conflict finding as a human-readable line. Every finding is soft
470
+ * since Story #5312, so this feeds the dry-run warning list and the plan
471
+ * summary rather than an `errors[]` channel; the name survives because every
472
+ * caller imports it.
960
473
  */
961
474
  export function renderHardConflictError(finding) {
962
475
  if (finding.kind === 'shared-editor') {
@@ -966,23 +479,12 @@ export function renderHardConflictError(finding) {
966
479
  if (finding.kind === 'implicit-cross-story-dep') {
967
480
  return `Implicit cross-Story dependency: Story "${finding.consumer.storySlug}" references "${finding.path}" (produced by Story "${finding.producer.storySlug}") via body.${finding.consumer.sourceField}, but Story "${finding.consumer.storySlug}" has no depends_on link to Story "${finding.producer.storySlug}". Add depends_on: ["${finding.producer.storySlug}"] to the consumer Story or remove the reference.`;
968
481
  }
969
- if (finding.kind === 'cross-cutting-registries') {
970
- const stories = finding.storySlugs.map((s) => `"${s}"`).join(', ');
971
- return `Cross-cutting registry conflict: ${finding.storySlugs.length} concurrent Stories (${stories}) edit or register into "${finding.registryPath}". Add depends_on chains between them so the registry updates serialize, or split the registration into a dedicated late-wave wiring Story.`;
972
- }
973
- if (finding.kind === 'fan-out-warning') {
974
- return (
975
- `Large fan-out: Story "${finding.storySlug}" deletes "${finding.path}" ` +
976
- `with ${finding.callSiteCount} importer(s) on the base branch (threshold ${finding.threshold}). ` +
977
- `${renderFanOutRemedy(finding)}${renderFanOutEvidence(finding)}`
978
- );
979
- }
980
482
  if (finding.kind === 'missing-bdd-scaffold') {
981
483
  return `Missing BDD scaffold: Story "${finding.consumer.storySlug}" verifies against "${finding.path}" (created by Story "${finding.producer.storySlug}") via body.${finding.consumer.sourceField}, but "${finding.consumer.storySlug}" has no depends_on path to "${finding.producer.storySlug}" — the .feature file is scaffolded in the same wave (or later), so verification runs before the file exists. Add depends_on: ["${finding.producer.storySlug}"] to the consumer Story so the scaffold lands in an earlier wave.`;
982
484
  }
983
- // Findings from other passes (sizing, spec-word-budget) carry their own
984
- // message — render it rather than a shape-blind generic line, so the soft
985
- // surface (`surfaceSoftConflictFindings`) stays legible for every kind.
485
+ // Findings from other passes carry their own message — render it rather
486
+ // than a shape-blind generic line, so the soft surface
487
+ // (`surfaceSoftConflictFindings`) stays legible for every kind.
986
488
  if (typeof finding.message === 'string' && finding.message.length > 0) {
987
489
  return finding.message;
988
490
  }
@@ -1000,12 +502,4 @@ export const _internal = {
1000
502
  computeSharedEditorFindings,
1001
503
  computeImplicitDepFindings,
1002
504
  computeMissingBddScaffoldFindings,
1003
- indexAssumptionEntries,
1004
- computeRegistryFindings,
1005
- computeFanOutFindings,
1006
- matchRegistryPattern,
1007
- isRegistryPath,
1008
- parentDirOf,
1009
- DEFAULT_POLICY,
1010
- DEFAULT_REGISTRY_PATTERNS,
1011
505
  };