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
@@ -2,11 +2,12 @@
2
2
  * run-plan-persist.js — flat Story persist for the v2 `/mandrel-plan` collapse
3
3
  * (Stage 3 — `docs/roadmap.md`).
4
4
  *
5
- * Ordered, fail-closed pipeline:
5
+ * Ordered pipeline — hard gates refuse, everything else is a **warning** the
6
+ * dry-run lists (Story #5312):
6
7
  *
7
- * 1. Ticket validator + file-assumption + DAG + capacity + budget
8
+ * 1. `changes[]` repair + ticket validator + file-assumption + DAG
8
9
  * 2. Draft reachability (named soft failure, exit 3)
9
- * 3. Split-policy partition (`assertAcceptancePartition`) + spec fold/spill
10
+ * 3. Split-policy partition (`assertAcceptancePartition`) + spec fold
10
11
  * 4. Create Story issues (`type::story` + sanitized authored labels —
11
12
  * deliberately NOT `agent::ready`), resumably via a plan fingerprint
12
13
  * 5. Upsert `story-plan-state` on every created Story; upsert `plan-summary`
@@ -38,20 +39,13 @@
38
39
  */
39
40
 
40
41
  import { rm } from 'node:fs/promises';
41
- import { getLimits, getPaths, PROJECT_ROOT } from '../../config-resolver.js';
42
- import { gitSpawn } from '../../git-utils.js';
42
+ import { getPaths, PROJECT_ROOT } from '../../config-resolver.js';
43
43
  import { Logger } from '../../Logger.js';
44
44
  import { sweepTempRetention } from '../../temp-retention.js';
45
45
  import {
46
46
  concurrentMap,
47
47
  FANOUT_CONCURRENCY,
48
48
  } from '../../util/concurrent-map.js';
49
- import {
50
- deriveStoryShape,
51
- LITE_ROUTE_LABEL,
52
- resolveComplexityGate,
53
- resolvePlannerRouteVerdict,
54
- } from '../complexity-gate.js';
55
49
  import {
56
50
  appendCriticSkip,
57
51
  readPlanMetrics,
@@ -62,21 +56,20 @@ import {
62
56
  evaluateDraftReachability,
63
57
  renderReachabilityOrphans,
64
58
  } from '../plan-reachability.js';
59
+ import { evaluateTextHygiene } from '../plan-text-hygiene.js';
65
60
  import {
66
61
  computeAssembledConflictFindings,
67
62
  conflictFindingKey,
68
- renderHardConflictError,
69
63
  } from '../ticket-validator-conflicts.js';
70
64
  import { upsertStructuredComment } from '../ticketing.js';
65
+ import { recordAuditFilings, withAuditLabels } from './audit-provenance.js';
66
+ import { renderChangeRepair } from './changes-repair.js';
71
67
  import {
72
68
  resolveContainerEpic,
73
69
  resolveCrossPlanLinks,
74
70
  } from './cross-plan-links.js';
75
- import {
76
- enforceFanOutGate,
77
- surfaceSoftConflictFindings,
78
- } from './fan-out-gate.js';
79
- import { resolveBaseBranchRef, validateTickets } from './persist-helpers.js';
71
+ import { validateTickets } from './persist-helpers.js';
72
+ import { surfaceSoftConflictFindings } from './soft-findings.js';
80
73
  import {
81
74
  assemblePlanStories,
82
75
  createStoryIssues,
@@ -127,66 +120,87 @@ export async function writeCheckpointV2(provider, storyId, state) {
127
120
  }
128
121
 
129
122
  /**
130
- * Enforce the ticket validator's findings and report what the file-assumption
131
- * gate actually concluded.
132
- *
133
- * The return value feeds the posted `plan-summary`'s freshness line, which
134
- * used to be hard-coded `{ stale: 0, ambiguous: 0 }` — so the comment read
135
- * "Spec freshness: clean" even on the one run where the gate had *given up*
136
- * (Story #4541's unresolvable-base-ref downgrade). The summary asserted a
137
- * clean result precisely when it had the least evidence for one.
138
- *
139
- * The mapping is deliberate. A confirmed mismatch is never reported here at
140
- * all — it throws, and the run posts no summary. The only findings that
141
- * survive to the summary are ones the gate could not *verify*, because the
142
- * base ref they were computed against does not resolve; unverifiable is
143
- * `ambiguous`, not `stale`.
144
- *
145
- * @returns {{ stale: number, ambiguous: number }} Freshness counts for the
146
- * posted summary.
123
+ * Enforce the ticket validator's hard errors and collect its warnings.
124
+ *
125
+ * Since Story #5312 the only hard refusal the validator batches into
126
+ * `errors[]` is a `deletes` naming a path absent at base; every other
127
+ * footprint probe — a `creates` / `refactors-existing` mismatch, a goal,
128
+ * acceptance or verify path absent at base — lands on `warnings[]`, which
129
+ * the dry-run prints and the persist proceeds past. The `changes[]` repairs
130
+ * the helper applied are reported alongside so the operator sees what was
131
+ * rewritten.
132
+ *
133
+ * The returned freshness counts feed the posted `plan-summary`'s freshness
134
+ * line: every warning is a reference the base branch disagreed with, so it
135
+ * counts as `stale` there rather than the comment reading "clean" on a run
136
+ * that had something to say.
137
+ *
138
+ * @returns {{ warnings: string[], freshness: { stale: number, ambiguous: number } }}
147
139
  */
148
- function enforceTicketValidation(validated, { config, settings, cwd }) {
149
- const validationErrors = validated.errors ?? [];
150
- const assumptionFailures = validationErrors.filter((error) =>
151
- error.startsWith('File assumption mismatch:'),
152
- );
153
- const blockingErrors = validationErrors.filter(
154
- (error) => !error.startsWith('File assumption mismatch:'),
155
- );
156
- if (blockingErrors.length > 0) {
140
+ function enforceTicketValidation(validated) {
141
+ const errors = validated.errors ?? [];
142
+ if (errors.length > 0) {
157
143
  throw new Error(
158
- `[plan-persist] ticket validation failed with ${blockingErrors.length} ` +
159
- `hard error(s):\n${blockingErrors.map((error) => ` - ${error}`).join('\n')}`,
160
- );
161
- }
162
- if (assumptionFailures.length === 0) return { stale: 0, ambiguous: 0 };
163
- // Story #4541: resolve through the canonical `project.baseBranch` (the
164
- // shape `config-resolver` actually emits) with the legacy settings bag as
165
- // a fallback — reading a bare `config.baseBranch` meant this probe always
166
- // targeted the literal `main`.
167
- const gateBaseRef =
168
- config?.project?.baseBranch ??
169
- settings?.baseBranch ??
170
- resolveBaseBranchRef(config);
171
- const refResolves =
172
- gitSpawn(
173
- cwd ?? process.cwd(),
174
- 'rev-parse',
175
- '--verify',
176
- '--quiet',
177
- `${gateBaseRef}^{commit}`,
178
- ).status === 0;
179
- if (refResolves) {
180
- throw new Error(
181
- `[plan-persist] file-assumption gate: ${assumptionFailures.length} ` +
182
- `mismatch(es):\n${assumptionFailures.map((error) => ` - ${error}`).join('\n')}`,
144
+ `[plan-persist] ticket validation failed with ${errors.length} ` +
145
+ `hard error(s):\n${errors.map((error) => ` - ${error}`).join('\n')}`,
183
146
  );
184
147
  }
148
+ const warnings = [
149
+ ...(validated.repairs ?? []).map((repair) => renderChangeRepair(repair)),
150
+ ...(validated.warnings ?? []),
151
+ ];
152
+ return {
153
+ warnings,
154
+ freshness: freshnessCounts(validated.probeRef, validated.warnings ?? []),
155
+ };
156
+ }
157
+
158
+ /**
159
+ * Freshness counts for the posted plan summary. Every validator warning is
160
+ * a reference the base branch disagreed with — `stale` when a base ref was
161
+ * actually read, `ambiguous` when none resolved in this checkout and the
162
+ * probes were skipped, since nothing was verified either way.
163
+ *
164
+ * @param {string|null} probeRef
165
+ * @param {string[]} warnings
166
+ * @returns {{ stale: number, ambiguous: number }}
167
+ */
168
+ function freshnessCounts(probeRef, warnings) {
169
+ if (probeRef === null) return { stale: 0, ambiguous: warnings.length };
170
+ return { stale: warnings.length, ambiguous: 0 };
171
+ }
172
+
173
+ /**
174
+ * The `open-question` lint over the draft bodies (Story #5312) — an
175
+ * operator-directed question persisted into a Story a non-interactive
176
+ * sub-agent executes. A warning the dry-run lists, never a refusal.
177
+ *
178
+ * @param {object[]} rawStories
179
+ * @returns {string[]}
180
+ */
181
+ function collectOpenQuestionWarnings(rawStories) {
182
+ return evaluateTextHygiene({ draftStories: rawStories }).findings.map(
183
+ (finding) =>
184
+ `Story "${finding.slug}": open question in body — "${finding.evidence}". ${finding.message}`,
185
+ );
186
+ }
187
+
188
+ /**
189
+ * Print every warning the run collected under one heading. The dry-run is
190
+ * where an operator reads these; the persist prints the same list so a
191
+ * `--chain-on-clean` run loses nothing.
192
+ *
193
+ * @param {string[]} warnings
194
+ * @returns {void}
195
+ */
196
+ function logWarnings(warnings) {
197
+ if (warnings.length === 0) return;
185
198
  Logger.warn(
186
- `[plan-persist] file-assumption gate skipped: base ref '${gateBaseRef}' ` +
187
- `does not resolve — ${assumptionFailures.length} finding(s) downgraded.`,
199
+ `[plan-persist] ${warnings.length} warning(s) — the persist proceeds; review before delivering:`,
188
200
  );
189
- return { stale: 0, ambiguous: assumptionFailures.length };
201
+ for (const warning of warnings) {
202
+ Logger.warn(`[plan-persist] warning: ${warning}`);
203
+ }
190
204
  }
191
205
 
192
206
  /**
@@ -281,144 +295,23 @@ async function renderRunScopedPlanMetricsLine({
281
295
  }
282
296
 
283
297
  /**
284
- * Resolve the plan's **effective** complexity route for persist
285
- * (Story #4722, superseding the envelope-verdict model of Story #4707).
286
- *
287
- * Two staged inputs, no word count anywhere:
288
- *
289
- * 1. **The planner's authored verdict** — `--route-downgrade-reason` is the
290
- * lite claim's recorded reason (`resolvePlannerRouteVerdict`). No
291
- * recorded reason means no claim: the plan persists as standard `full`
292
- * and nothing is ledgered (`null`).
293
- * 2. **The deterministic shape backstop** — a lite claim is validated
294
- * against every assembled Story's own shape (`deriveStoryShape` over its
295
- * `changes[]`, acceptance count, creates-vs-refactors mix, and
296
- * sensitive-path classes). Any Story exceeding the ceilings **fails the
297
- * claim closed to `full`** — the honest gate: after authoring, the work
298
- * has measurable shape, so complexity is read from the work, not guessed
299
- * from the seed.
300
- *
301
- * The resolved route decides whether the created Stories carry the
302
- * {@link LITE_ROUTE_LABEL} **hint** (never the control signal — `/mandrel-deliver`
303
- * re-derives the route from the Story body's shape) and the `route` block
304
- * ledgered on their `story-plan-state` checkpoint, including the authored
305
- * verdict, its recorded reason, and the per-Story shape evidence. A refused
306
- * claim is ledgered too (route `full` with the refusal reasons), so the
307
- * judgment stays auditable either way.
308
- *
309
- * Module-private: reachable end to end through {@link runPlanPersist}
310
- * (whose result reports the resolved route), so there is no test-only
311
- * export to leave production-dead.
312
- *
313
- * @param {{
314
- * stories: ReturnType<typeof assemblePlanStories>['stories'],
315
- * routeDowngradeReason?: string|null,
316
- * config?: object,
317
- * injectedRules?: object,
318
- * }} args
319
- * @returns {{
320
- * route: 'lite'|'full',
321
- * reasons: string[],
322
- * authored: { route: 'lite', reason: string },
323
- * shape: Array<{ slug: string, route: string, reasons: string[], shape: object|null }>,
324
- * }|null} `null` when the planner authored no verdict (nothing to persist).
325
- */
326
- function resolveEffectiveRoute({
327
- stories,
328
- routeDowngradeReason = null,
329
- config = {},
330
- injectedRules,
331
- }) {
332
- const verdict = resolvePlannerRouteVerdict({ reason: routeDowngradeReason });
333
- if (verdict.route !== 'lite') return null;
334
-
335
- // The schema's documented contract: with the gate disabled
336
- // (`planning.complexityGate.enabled=false`), persist refuses lite claims —
337
- // the same switch dispatch reads (`resolveStoryDispatchMode` falls back to
338
- // sub-agent), so neither read point can honor a lite claim the operator
339
- // has switched off. The refusal is ledgered like any other, keeping the
340
- // judgment auditable.
341
- if (!resolveComplexityGate(config).enabled) {
342
- return {
343
- route: 'full',
344
- reasons: [
345
- 'planner lite verdict refused: complexity routing is disabled ' +
346
- '(planning.complexityGate.enabled=false)',
347
- ],
348
- authored: verdict.authored,
349
- shape: [],
350
- };
351
- }
352
-
353
- const perStory = (Array.isArray(stories) ? stories : []).map((story) => {
354
- const derived = deriveStoryShape({
355
- changes: story.bodyObject?.changes,
356
- acceptance: story.acceptance,
357
- injectedRules,
358
- });
359
- return {
360
- slug: story.slug,
361
- route: derived.route,
362
- reasons: derived.reasons,
363
- shape: derived.shape,
364
- };
365
- });
366
- const offenders = perStory.filter((entry) => entry.route !== 'lite');
367
- if (offenders.length > 0) {
368
- return {
369
- route: 'full',
370
- reasons: [
371
- `planner lite verdict refused: ${offenders.length} of ${perStory.length} ` +
372
- 'Story(ies) exceed the lite shape ceilings — failing closed to full',
373
- ...offenders.map((entry) => `${entry.slug}: ${entry.reasons[0]}`),
374
- ],
375
- authored: verdict.authored,
376
- shape: perStory,
377
- };
378
- }
379
- return {
380
- route: 'lite',
381
- reasons: [
382
- ...verdict.reasons,
383
- 'shape backstop: every authored Story fits the lite shape ceilings',
384
- ],
385
- authored: verdict.authored,
386
- shape: perStory,
387
- };
388
- }
389
-
390
- /**
391
- * Re-run the cross-Story conflict passes over the assembled bodies and route
392
- * the result (Story #5045).
298
+ * Re-run the cross-Story conflict passes over the assembled bodies
299
+ * (Story #5045).
393
300
  *
394
301
  * `validateTickets` runs before assembly, over the raw payload, so until now
395
302
  * plan-time conflict analysis judged an artifact that is not the one persist
396
303
  * writes — and the passes that scan `body.acceptance` / `body.verify` were
397
- * inert on the canonical top-level authoring shape as a result.
398
- *
399
- * Three outcomes, in order:
304
+ * inert on the canonical top-level authoring shape as a result. Findings the
305
+ * raw pass already reported are dropped so the same collision is not
306
+ * announced twice per run; the rest are returned for the plan-summary
307
+ * comment, which is where these findings stop being a stderr line nobody
308
+ * keeps. Every finding is advisory (Story #5312).
400
309
  *
401
- * 1. **Hard findings throw.** Policy upgrades (`planning.failOnSharedEditors`,
402
- * `planning.requireExplicitCrossStoryDeps`) are off by default; when an
403
- * operator turns one on it must bite on the persisted artifact too, and it
404
- * must bite **before** the first `createIssue`.
405
- * 2. **Soft findings the raw pass already reported are dropped**, so the same
406
- * collision is not announced twice per run.
407
- * 3. **Everything else is returned** for the plan-summary comment, which is
408
- * where these findings stop being a stderr line nobody keeps.
409
- *
410
- * @param {{ stories: object[], config: object, rawFindings: object[] }} args
310
+ * @param {{ stories: object[], rawFindings: object[] }} args
411
311
  * @returns {object[]} The assembled-pass findings, for the summary comment.
412
312
  */
413
- function analyzeAssembledStories({ stories, config, rawFindings }) {
414
- const findings = computeAssembledConflictFindings({ stories, config });
415
- const hard = findings.filter((finding) => finding.severity === 'hard');
416
- if (hard.length > 0) {
417
- throw new Error(
418
- `[plan-persist] ${hard.length} cross-Story conflict(s) in the assembled ` +
419
- `Story bodies:\n${hard.map((f) => ` - ${renderHardConflictError(f)}`).join('\n')}`,
420
- );
421
- }
313
+ function analyzeAssembledStories({ stories, rawFindings }) {
314
+ const findings = computeAssembledConflictFindings({ stories });
422
315
  const alreadyReported = new Set(
423
316
  (rawFindings ?? []).map((finding) => conflictFindingKey(finding)),
424
317
  );
@@ -469,33 +362,22 @@ export async function reapStalePlanDirs({
469
362
  }
470
363
 
471
364
  /**
472
- * Fail closed on a payload that cannot be persisted, and warn on an
473
- * explicitly-authorized over-budget one. Extracted from `runPlanPersist`
474
- * (Story #4926) so the entry point carries the flow, not the guards.
365
+ * Fail closed on a payload that cannot be persisted. Extracted from
366
+ * `runPlanPersist` (Story #4926) so the entry point carries the flow, not the
367
+ * guards. The reviewability budget that used to sit beside this check went
368
+ * with Story #5312 — a plan is as many Stories as the split policy yields.
475
369
  *
476
370
  * @param {unknown} rawStories
477
- * @param {{ maxTickets: number, allowOverBudget: boolean }} limits
478
371
  * @returns {void}
479
- * @throws {Error} On an empty payload or an unauthorized over-budget one.
372
+ * @throws {Error} On an empty payload.
480
373
  */
481
- function assertPersistablePlan(rawStories, { maxTickets, allowOverBudget }) {
374
+ function assertPersistablePlan(rawStories) {
482
375
  if (!Array.isArray(rawStories) || rawStories.length === 0) {
483
376
  throw new Error(
484
377
  '[plan-persist] stories payload must be a non-empty array ' +
485
378
  '(--stories <file>). Default is one Story.',
486
379
  );
487
380
  }
488
- if (rawStories.length <= maxTickets) return;
489
- if (!allowOverBudget) {
490
- throw new Error(
491
- `[plan-persist] Stories (${rawStories.length}) exceed the reviewability ` +
492
- `budget (${maxTickets}). Re-scope, or rerun with --allow-over-budget.`,
493
- );
494
- }
495
- Logger.warn(
496
- `[plan-persist] Persisting an over-budget plan: ${rawStories.length} ` +
497
- `Stories vs. budget ${maxTickets} (--allow-over-budget).`,
498
- );
499
381
  }
500
382
 
501
383
  /**
@@ -548,7 +430,6 @@ async function persistStoryArtifacts({
548
430
  provider,
549
431
  created,
550
432
  primary,
551
- route,
552
433
  summaryBody,
553
434
  }) {
554
435
  const cohort = created.map((createdStory) => ({
@@ -565,11 +446,6 @@ async function persistStoryArtifacts({
565
446
  primaryStoryId: primary.id,
566
447
  stories: cohort,
567
448
  },
568
- // Ledger the authored route verdict — the recorded reason and the
569
- // per-Story shape evidence, including a shape-refused claim — on plan
570
- // state (Story #4722). No authored verdict writes no block: absence
571
- // is the standard full path.
572
- ...(route ? { route } : {}),
573
449
  }),
574
450
  // The per-Story checkpoint upserts (Story #4952): each targets a
575
451
  // different issue and reads nothing another writes, so this loop was
@@ -604,32 +480,6 @@ async function cleanupPlanDirs({ config, planDir, skipCleanup }) {
604
480
  await reapStalePlanDirs({ config, keepDir: skipCleanup ? planDir : null });
605
481
  }
606
482
 
607
- /**
608
- * Log the effective complexity route (Story #4722). Lite is upheld by the
609
- * shape backstop; anything else reports why it fell back to full.
610
- *
611
- * @param {object|null} route
612
- * @param {boolean} isLiteRoute
613
- * @returns {void}
614
- */
615
- function logEffectiveRoute(route, isLiteRoute) {
616
- if (isLiteRoute) {
617
- Logger.info(
618
- `[plan-persist] ceremony-lite route upheld by the shape backstop: ` +
619
- `created Stories carry the ${LITE_ROUTE_LABEL} hint ` +
620
- `(recorded reason: ${route.authored.reason}). /mandrel-deliver re-derives ` +
621
- 'the route from each Story body — the label is never the control signal.',
622
- );
623
- return;
624
- }
625
- if (route) {
626
- Logger.warn(
627
- `[plan-persist] ${route.reasons.join('; ')} — persisting as full ` +
628
- '(no route hint label).',
629
- );
630
- }
631
- }
632
-
633
483
  /**
634
484
  * Log the operator-facing persist epilogue: adoption, the ready primary, the
635
485
  * deliver command, and the cohort grouping label.
@@ -687,21 +537,16 @@ function logPersistEpilogue({
687
537
  * planContextEnvelope?: object|null,
688
538
  * },
689
539
  * config?: object,
690
- * settings?: object,
691
540
  * opts?: {
692
541
  * forceReview?: boolean,
693
- * allowOverBudget?: boolean,
694
- * allowLargeFanOut?: boolean,
695
542
  * skipCleanup?: boolean,
696
543
  * dryRun?: boolean,
697
544
  * planDir?: string,
698
- * fanOutCounter?: Function,
545
+ * gitRunner?: Function,
699
546
  * cwd?: string,
700
547
  * sourceTicketIds?: number[],
701
548
  * sourceTicketOrigin?: 'flag'|'envelope'|'none',
702
549
  * closeSuperseded?: boolean,
703
- * routeDowngradeReason?: string|null,
704
- * injectedRules?: object,
705
550
  * },
706
551
  * }} input
707
552
  */
@@ -709,7 +554,6 @@ export async function runPlanPersist({
709
554
  provider,
710
555
  artifacts,
711
556
  config = {},
712
- settings = {},
713
557
  opts = {},
714
558
  }) {
715
559
  const {
@@ -720,18 +564,14 @@ export async function runPlanPersist({
720
564
  } = artifacts ?? {};
721
565
  const {
722
566
  forceReview = false,
723
- allowOverBudget = false,
724
- allowLargeFanOut = false,
725
567
  skipCleanup = false,
726
568
  dryRun = false,
727
569
  planDir = null,
728
- fanOutCounter = undefined,
570
+ gitRunner = undefined,
729
571
  cwd = PROJECT_ROOT,
730
572
  sourceTicketIds = [],
731
573
  sourceTicketOrigin = 'none',
732
574
  closeSuperseded = true,
733
- routeDowngradeReason = null,
734
- injectedRules = undefined,
735
575
  // Story #5139 — the optional container Epic. `null` (the default) is the
736
576
  // ordinary shape: no Epic is created unless `/mandrel-plan` offered one
737
577
  // above the threshold and the operator confirmed it.
@@ -744,26 +584,20 @@ export async function runPlanPersist({
744
584
  // through the shared standalone ledger (Story #4541).
745
585
  const runStartedAt = opts.metricsSince ?? new Date().toISOString();
746
586
 
747
- assertPersistablePlan(rawStories, {
748
- maxTickets: getLimits(config).maxTickets,
749
- allowOverBudget,
750
- });
587
+ assertPersistablePlan(rawStories);
751
588
 
752
589
  Logger.info(
753
590
  `[plan-persist] Running cross-validation on ${rawStories.length} Story ticket(s)...`,
754
591
  );
755
- const validated = validateTickets(rawStories, config, {
756
- fanOutCounter,
757
- cwd,
758
- modelCapacity: opts.modelCapacity,
759
- });
760
- enforceFanOutGate(validated.findings, allowLargeFanOut, 'plan-persist');
592
+ const validated = validateTickets(rawStories, config, { cwd, gitRunner });
761
593
  surfaceSoftConflictFindings(validated.findings, 'plan-persist');
762
- const freshness = enforceTicketValidation(validated, {
763
- config,
764
- settings,
765
- cwd,
766
- });
594
+ const { warnings: validationWarnings, freshness } =
595
+ enforceTicketValidation(validated);
596
+ const warnings = [
597
+ ...validationWarnings,
598
+ ...collectOpenQuestionWarnings(rawStories),
599
+ ];
600
+ logWarnings(warnings);
767
601
 
768
602
  const reachability = evaluateDraftReachability({
769
603
  tickets: rawStories,
@@ -779,8 +613,9 @@ export async function runPlanPersist({
779
613
  epicId: opts.adoptEpicId ?? null,
780
614
  });
781
615
 
782
- // Split policy + inline Spec fold (over-budget Specs fail closed — no docs/).
783
- const { stories } = assemblePlanStories(rawStories, {
616
+ // Split policy + inline Spec fold (Specs stay inline, never under docs/).
617
+ const seedContent = planContextEnvelope?.seed?.content ?? '';
618
+ const { stories: assembled } = assemblePlanStories(rawStories, {
784
619
  sharedSpec: techSpecContent,
785
620
  planAcceptance: planAcceptance ?? undefined,
786
621
  sourceTicketIds,
@@ -791,39 +626,35 @@ export async function runPlanPersist({
791
626
  // is the **fallback** — it is carried onto every Story that did not
792
627
  // attribute its own `provenance`, which keeps an un-attributed plan exactly
793
628
  // as recall-safe as it was. Empty for a `--tickets` run, a no-op there.
794
- provenanceSource: planContextEnvelope?.seed?.content ?? '',
629
+ provenanceSource: seedContent,
795
630
  });
796
631
 
632
+ // Stamp the `audit::*` labels the dedup corpus is listed by. Without them a
633
+ // Story this path files is absent from the pool an indexed sweep matches
634
+ // against, and an indexed run answers exact lookups from that pool without
635
+ // ever reaching the provider — so the provenance footers alone leave it
636
+ // invisible (Story #5307). A non-audit seed carries none: a no-op there.
637
+ const stories = withAuditLabels(assembled, seedContent);
638
+
797
639
  // Story #5045: the cross-Story conflict passes re-run over the assembled,
798
640
  // footer-stamped bodies — the artifact persist actually writes — before any
799
641
  // GitHub call, so a policy upgrade still refuses the plan pre-creation.
800
642
  const assembledConflicts = analyzeAssembledStories({
801
643
  stories,
802
- config,
803
644
  rawFindings: validated.findings,
804
645
  });
805
646
 
806
- // Effective complexity route (Story #4722): the planner's authored lite
807
- // verdict (recorded reason), validated against every assembled Story's own
808
- // shape — a claim exceeding the shape ceilings fails closed to full. Lite
809
- // persists the `route::lite` HINT label + a checkpoint route block; a
810
- // refused claim ledgers the refusal (no label); no claim persists nothing.
811
- const route = resolveEffectiveRoute({
812
- stories,
813
- routeDowngradeReason,
814
- config,
815
- injectedRules,
816
- });
817
- const isLiteRoute = route?.route === 'lite';
818
- logEffectiveRoute(route, isLiteRoute);
819
-
820
647
  const { created, planRunLabel, planRunLabelApplied } =
821
648
  await createStoryIssues({
822
649
  provider,
823
650
  stories,
824
- opts: { dryRun, routeLabel: isLiteRoute ? LITE_ROUTE_LABEL : null },
651
+ opts: { dryRun },
825
652
  });
826
653
 
654
+ // What this run filed, recorded where the next audit sweep reads it
655
+ // (Story #5307). A dry run created nothing, and the call knows it.
656
+ recordAuditFilings({ stories, created, tickets: rawStories, dryRun });
657
+
827
658
  const primary = created[0];
828
659
  const waveTable = buildWaveTable(
829
660
  stories.map((s) => ({
@@ -881,7 +712,6 @@ export async function runPlanPersist({
881
712
  provider,
882
713
  created,
883
714
  primary,
884
- route,
885
715
  summaryBody,
886
716
  });
887
717
  }
@@ -928,10 +758,14 @@ export async function runPlanPersist({
928
758
  stories: created,
929
759
  primaryStoryId: primary.id,
930
760
  planRunLabel,
931
- route,
932
761
  forceReview,
933
762
  reachability,
934
763
  freshness,
764
+ // Story #5312: what the run rewrote and what it noticed. The dry-run is
765
+ // the review surface now that the footprint probes warn instead of
766
+ // refusing, so the list rides the result envelope, not just stderr.
767
+ warnings,
768
+ repairs: validated.repairs ?? [],
935
769
  waveTable,
936
770
  // Story #5265 AC-2/AC-4: both halves of what persist concluded but used
937
771
  // to keep to itself — the `refactors-existing` declarations it rewrote,
@@ -0,0 +1,55 @@
1
+ /**
2
+ * Soft-finding reporting for plan-persist.
3
+ *
4
+ * Story #5312 retired the fan-out gate that shared this file: the delete
5
+ * blast-radius probe never refused a real plan, and its `--allow-large-fan-out`
6
+ * override was a flag nobody typed. What remains is the one surface that
7
+ * announces every advisory finding the validator produced, each under its
8
+ * own kind, so the operator reads a conflict as a conflict and a nudge as a
9
+ * nudge (Story #4907).
10
+ *
11
+ * @module lib/orchestration/plan-persist/soft-findings
12
+ */
13
+
14
+ import { Logger } from '../../Logger.js';
15
+ import {
16
+ CONFLICT_KINDS,
17
+ renderHardConflictError,
18
+ } from '../ticket-validator-conflicts.js';
19
+
20
+ /**
21
+ * Report every soft finding the validator produced, each under its own kind.
22
+ *
23
+ * Only the {@link CONFLICT_KINDS} are cross-Story conflicts. Any other soft
24
+ * kind is a single-Story nudge, and announcing it as a conflict overstated it
25
+ * and taught readers to discount the whole channel.
26
+ *
27
+ * @param {object[]} findings
28
+ * @param {string} [tag]
29
+ */
30
+ export function surfaceSoftConflictFindings(findings, tag = 'plan-persist') {
31
+ const soft = (findings ?? []).filter((f) => f?.severity === 'soft');
32
+ if (soft.length === 0) return;
33
+ const conflicts = soft.filter((f) => CONFLICT_KINDS.has(f?.kind));
34
+ const advisories = soft.filter((f) => !CONFLICT_KINDS.has(f?.kind));
35
+ if (conflicts.length > 0) {
36
+ Logger.warn(
37
+ `[${tag}] ${conflicts.length} soft cross-Story conflict finding(s) — review before approving the plan:`,
38
+ );
39
+ for (const finding of conflicts) {
40
+ Logger.warn(
41
+ `[${tag}] soft conflict: ${renderHardConflictError(finding)}`,
42
+ );
43
+ }
44
+ }
45
+ if (advisories.length > 0) {
46
+ Logger.warn(
47
+ `[${tag}] ${advisories.length} advisory finding(s) — the persist proceeds:`,
48
+ );
49
+ for (const finding of advisories) {
50
+ Logger.warn(
51
+ `[${tag}] advisory (${finding.kind}): ${renderHardConflictError(finding)}`,
52
+ );
53
+ }
54
+ }
55
+ }