mandrel 2.56.0 → 2.58.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 (114) hide show
  1. package/.agents/agents/plan-critic.md +13 -18
  2. package/.agents/agents/story-worker.md +25 -33
  3. package/.agents/docs/agentrc-reference.json +0 -30
  4. package/.agents/docs/configuration.md +8 -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/schemas/agentrc.schema.json +9 -185
  9. package/.agents/schemas/story-deliver-terminal.schema.json +1 -1
  10. package/.agents/scripts/acceptance-eval.js +107 -17
  11. package/.agents/scripts/ceremony-derive.js +191 -0
  12. package/.agents/scripts/check-context-budget.js +28 -33
  13. package/.agents/scripts/check-cyclomatic.js +4 -3
  14. package/.agents/scripts/deliver-light.js +31 -94
  15. package/.agents/scripts/evidence-gate.js +17 -1
  16. package/.agents/scripts/lib/audit-suite/checklist-threading.js +15 -2
  17. package/.agents/scripts/lib/baselines/coverage-updater-cli.js +110 -0
  18. package/.agents/scripts/lib/baselines/crap-preview-scan.js +25 -0
  19. package/.agents/scripts/lib/baselines/crap-updater-cli.js +223 -0
  20. package/.agents/scripts/lib/bdd-scenario-budget.js +21 -3
  21. package/.agents/scripts/lib/bootstrap/quality-bootstrap.js +0 -1
  22. package/.agents/scripts/lib/close-validation/gates.js +52 -1
  23. package/.agents/scripts/lib/config/acceptance-eval.js +25 -57
  24. package/.agents/scripts/lib/config/delivery-routing.js +7 -33
  25. package/.agents/scripts/lib/config/explain.js +0 -19
  26. package/.agents/scripts/lib/config/limits.js +18 -78
  27. package/.agents/scripts/lib/config/quality.js +6 -3
  28. package/.agents/scripts/lib/config/runners.js +3 -2
  29. package/.agents/scripts/lib/config-settings-schema-delivery.js +15 -68
  30. package/.agents/scripts/lib/config-settings-schema-quality.js +0 -14
  31. package/.agents/scripts/lib/config-settings-schema.js +16 -143
  32. package/.agents/scripts/lib/crap-engine.js +35 -4
  33. package/.agents/scripts/lib/crap-utils.js +17 -1
  34. package/.agents/scripts/lib/cyclomatic-ceiling.js +19 -7
  35. package/.agents/scripts/lib/generated/agentrc-validator.js +1 -1
  36. package/.agents/scripts/lib/observability/runtime-friction.js +1 -1
  37. package/.agents/scripts/lib/observability/source-classifier.js +1 -0
  38. package/.agents/scripts/lib/orchestration/acceptance-eval-decision.js +5 -4
  39. package/.agents/scripts/lib/orchestration/ceremony-routing.js +19 -73
  40. package/.agents/scripts/lib/orchestration/code-review.js +7 -3
  41. package/.agents/scripts/lib/orchestration/complexity-gate.js +46 -212
  42. package/.agents/scripts/lib/orchestration/file-assumptions.js +32 -17
  43. package/.agents/scripts/lib/orchestration/light-escalation.js +3 -3
  44. package/.agents/scripts/lib/orchestration/light-suitability.js +66 -233
  45. package/.agents/scripts/lib/orchestration/pinned-identifier-lint.js +137 -0
  46. package/.agents/scripts/lib/orchestration/plan-context.js +189 -387
  47. package/.agents/scripts/lib/orchestration/plan-critic-conditions.js +42 -153
  48. package/.agents/scripts/lib/orchestration/plan-critics-evaluate.js +14 -70
  49. package/.agents/scripts/lib/orchestration/plan-persist/acceptance-handle-repair.js +107 -0
  50. package/.agents/scripts/lib/orchestration/plan-persist/changes-repair.js +305 -0
  51. package/.agents/scripts/lib/orchestration/plan-persist/persist-helpers.js +138 -170
  52. package/.agents/scripts/lib/orchestration/plan-persist/run-plan-persist.js +128 -297
  53. package/.agents/scripts/lib/orchestration/plan-persist/soft-findings.js +55 -0
  54. package/.agents/scripts/lib/orchestration/plan-persist/story-ops.js +16 -65
  55. package/.agents/scripts/lib/orchestration/plan-persist/wave-serialisation.js +22 -35
  56. package/.agents/scripts/lib/orchestration/plan-text-hygiene.js +36 -135
  57. package/.agents/scripts/lib/orchestration/planning/memory-pool-advisory.js +61 -223
  58. package/.agents/scripts/lib/orchestration/review-base-ref.js +138 -0
  59. package/.agents/scripts/lib/orchestration/single-story-close/phases/close-validation.js +5 -0
  60. package/.agents/scripts/lib/orchestration/single-story-close/phases/code-review.js +37 -5
  61. package/.agents/scripts/lib/orchestration/single-story-close/phases/pre-gate-steps.js +46 -16
  62. package/.agents/scripts/lib/orchestration/single-story-close/runner.js +6 -1
  63. package/.agents/scripts/lib/orchestration/story-close/context-budget-writeback.js +213 -0
  64. package/.agents/scripts/lib/orchestration/task-body-validator.js +10 -63
  65. package/.agents/scripts/lib/orchestration/ticket-validator-conflicts.js +33 -539
  66. package/.agents/scripts/lib/orchestration/ticket-validator-sizing.js +21 -414
  67. package/.agents/scripts/lib/orchestration/ticket-validator.js +54 -118
  68. package/.agents/scripts/lib/orchestration/verify-credit.js +69 -24
  69. package/.agents/scripts/lib/story-body/body-format-lints.js +15 -85
  70. package/.agents/scripts/lib/story-body/story-body.js +54 -240
  71. package/.agents/scripts/lib/templates/decomposer-prompts.js +133 -121
  72. package/.agents/scripts/lib/test-isolate/cli-options.js +93 -0
  73. package/.agents/scripts/lib/test-isolate/progress-log.js +45 -0
  74. package/.agents/scripts/lib/test-isolate/render-report.js +97 -0
  75. package/.agents/scripts/lib/test-isolate/run-isolate.js +87 -0
  76. package/.agents/scripts/lib/test-run-credit.js +277 -0
  77. package/.agents/scripts/lib/wave-runner/footprint.js +48 -358
  78. package/.agents/scripts/lib/wave-runner/ready-set.js +6 -5
  79. package/.agents/scripts/lib/workers/crap-worker.js +32 -41
  80. package/.agents/scripts/plan-context.js +7 -9
  81. package/.agents/scripts/plan-critics.js +28 -54
  82. package/.agents/scripts/plan-persist.js +25 -68
  83. package/.agents/scripts/quality-preview.js +51 -0
  84. package/.agents/scripts/run-tests.js +12 -0
  85. package/.agents/scripts/stories-wave-tick.js +23 -45
  86. package/.agents/scripts/test-isolate.js +13 -180
  87. package/.agents/scripts/update-coverage-baseline.js +25 -70
  88. package/.agents/scripts/update-crap-baseline.js +19 -123
  89. package/.agents/skills/core/scope-triage/SKILL.md +3 -3
  90. package/.agents/workflows/audit-clean-code.md +4 -3
  91. package/.agents/workflows/helpers/acceptance-self-eval.md +41 -41
  92. package/.agents/workflows/helpers/code-quality-guardrails.md +4 -4
  93. package/.agents/workflows/helpers/code-review.md +2 -3
  94. package/.agents/workflows/helpers/deliver-digest.md +46 -55
  95. package/.agents/workflows/helpers/deliver-light.md +40 -105
  96. package/.agents/workflows/helpers/deliver-reference.md +1 -1
  97. package/.agents/workflows/helpers/deliver-story-reference.md +54 -55
  98. package/.agents/workflows/helpers/deliver-story.md +10 -13
  99. package/.agents/workflows/helpers/plan-reference.md +163 -221
  100. package/.agents/workflows/mandrel-plan.md +31 -40
  101. package/.agents/workflows/memory-consolidate.md +9 -13
  102. package/docs/CHANGELOG.md +36 -0
  103. package/lib/cli/registry.js +98 -2
  104. package/lib/migrations/index.js +4 -0
  105. package/lib/migrations/steps/2.57.0-retire-delivery-limit-knobs.js +45 -0
  106. package/lib/migrations/steps/2.57.0-retire-planning-limit-knobs.js +59 -0
  107. package/package.json +1 -1
  108. package/.agents/scripts/lib/framework-version.js +0 -39
  109. package/.agents/scripts/lib/orchestration/consolidation-precondition.js +0 -223
  110. package/.agents/scripts/lib/orchestration/plan-persist/fan-out-gate.js +0 -97
  111. package/.agents/scripts/lib/orchestration/planning/decomposer-context.js +0 -26
  112. package/.agents/scripts/lib/orchestration/spec-budget.js +0 -89
  113. package/.agents/scripts/lib/orchestration/spec-spill.js +0 -74
  114. 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,22 +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 { renderRepair } from './acceptance-handle-repair.js';
71
66
  import { recordAuditFilings, withAuditLabels } from './audit-provenance.js';
72
67
  import {
73
68
  resolveContainerEpic,
74
69
  resolveCrossPlanLinks,
75
70
  } from './cross-plan-links.js';
76
- import {
77
- enforceFanOutGate,
78
- surfaceSoftConflictFindings,
79
- } from './fan-out-gate.js';
80
- import { resolveBaseBranchRef, validateTickets } from './persist-helpers.js';
71
+ import { validateTickets } from './persist-helpers.js';
72
+ import { surfaceSoftConflictFindings } from './soft-findings.js';
81
73
  import {
82
74
  assemblePlanStories,
83
75
  createStoryIssues,
@@ -128,66 +120,97 @@ export async function writeCheckpointV2(provider, storyId, state) {
128
120
  }
129
121
 
130
122
  /**
131
- * Enforce the ticket validator's findings and report what the file-assumption
132
- * gate actually concluded.
133
- *
134
- * The return value feeds the posted `plan-summary`'s freshness line, which
135
- * used to be hard-coded `{ stale: 0, ambiguous: 0 }` — so the comment read
136
- * "Spec freshness: clean" even on the one run where the gate had *given up*
137
- * (Story #4541's unresolvable-base-ref downgrade). The summary asserted a
138
- * clean result precisely when it had the least evidence for one.
139
- *
140
- * The mapping is deliberate. A confirmed mismatch is never reported here at
141
- * all — it throws, and the run posts no summary. The only findings that
142
- * survive to the summary are ones the gate could not *verify*, because the
143
- * base ref they were computed against does not resolve; unverifiable is
144
- * `ambiguous`, not `stale`.
145
- *
146
- * @returns {{ stale: number, ambiguous: number }} Freshness counts for the
147
- * 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 } }}
148
139
  */
149
- function enforceTicketValidation(validated, { config, settings, cwd }) {
150
- const validationErrors = validated.errors ?? [];
151
- const assumptionFailures = validationErrors.filter((error) =>
152
- error.startsWith('File assumption mismatch:'),
153
- );
154
- const blockingErrors = validationErrors.filter(
155
- (error) => !error.startsWith('File assumption mismatch:'),
156
- );
157
- if (blockingErrors.length > 0) {
158
- throw new Error(
159
- `[plan-persist] ticket validation failed with ${blockingErrors.length} ` +
160
- `hard error(s):\n${blockingErrors.map((error) => ` - ${error}`).join('\n')}`,
161
- );
162
- }
163
- if (assumptionFailures.length === 0) return { stale: 0, ambiguous: 0 };
164
- // Story #4541: resolve through the canonical `project.baseBranch` (the
165
- // shape `config-resolver` actually emits) with the legacy settings bag as
166
- // a fallback — reading a bare `config.baseBranch` meant this probe always
167
- // targeted the literal `main`.
168
- const gateBaseRef =
169
- config?.project?.baseBranch ??
170
- settings?.baseBranch ??
171
- resolveBaseBranchRef(config);
172
- const refResolves =
173
- gitSpawn(
174
- cwd ?? process.cwd(),
175
- 'rev-parse',
176
- '--verify',
177
- '--quiet',
178
- `${gateBaseRef}^{commit}`,
179
- ).status === 0;
180
- if (refResolves) {
140
+ function enforceTicketValidation(validated) {
141
+ const errors = validated.errors ?? [];
142
+ if (errors.length > 0) {
181
143
  throw new Error(
182
- `[plan-persist] file-assumption gate: ${assumptionFailures.length} ` +
183
- `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')}`,
184
146
  );
185
147
  }
148
+ const warnings = [
149
+ ...(validated.repairs ?? []).map(renderRepair),
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
+ /** How each text-hygiene finding kind names itself on the warning list. */
174
+ const TEXT_HYGIENE_LABELS = {
175
+ 'open-question': 'open question in body',
176
+ 'pinned-identifier': 'pinned identifier in acceptance[]',
177
+ };
178
+
179
+ /**
180
+ * The advisory text-hygiene lints over the draft (Story #5312, #5323) — an
181
+ * operator-directed question persisted into a Story a non-interactive
182
+ * sub-agent executes, and an acceptance item pinning an internal symbol the
183
+ * advisory `changes[]` may reshape. Warnings the dry-run lists, never
184
+ * refusals.
185
+ *
186
+ * @param {object[]} rawStories
187
+ * @returns {string[]}
188
+ */
189
+ function collectTextHygieneWarnings(rawStories) {
190
+ return evaluateTextHygiene({ draftStories: rawStories }).findings.map(
191
+ (finding) =>
192
+ `Story "${finding.slug}": ${
193
+ TEXT_HYGIENE_LABELS[finding.kind] ?? finding.kind
194
+ } — "${finding.evidence}". ${finding.message}`,
195
+ );
196
+ }
197
+
198
+ /**
199
+ * Print every warning the run collected under one heading. The dry-run is
200
+ * where an operator reads these; the persist prints the same list so a
201
+ * `--chain-on-clean` run loses nothing.
202
+ *
203
+ * @param {string[]} warnings
204
+ * @returns {void}
205
+ */
206
+ function logWarnings(warnings) {
207
+ if (warnings.length === 0) return;
186
208
  Logger.warn(
187
- `[plan-persist] file-assumption gate skipped: base ref '${gateBaseRef}' ` +
188
- `does not resolve — ${assumptionFailures.length} finding(s) downgraded.`,
209
+ `[plan-persist] ${warnings.length} warning(s) — the persist proceeds; review before delivering:`,
189
210
  );
190
- return { stale: 0, ambiguous: assumptionFailures.length };
211
+ for (const warning of warnings) {
212
+ Logger.warn(`[plan-persist] warning: ${warning}`);
213
+ }
191
214
  }
192
215
 
193
216
  /**
@@ -282,144 +305,23 @@ async function renderRunScopedPlanMetricsLine({
282
305
  }
283
306
 
284
307
  /**
285
- * Resolve the plan's **effective** complexity route for persist
286
- * (Story #4722, superseding the envelope-verdict model of Story #4707).
287
- *
288
- * Two staged inputs, no word count anywhere:
289
- *
290
- * 1. **The planner's authored verdict** — `--route-downgrade-reason` is the
291
- * lite claim's recorded reason (`resolvePlannerRouteVerdict`). No
292
- * recorded reason means no claim: the plan persists as standard `full`
293
- * and nothing is ledgered (`null`).
294
- * 2. **The deterministic shape backstop** — a lite claim is validated
295
- * against every assembled Story's own shape (`deriveStoryShape` over its
296
- * `changes[]`, acceptance count, creates-vs-refactors mix, and
297
- * sensitive-path classes). Any Story exceeding the ceilings **fails the
298
- * claim closed to `full`** — the honest gate: after authoring, the work
299
- * has measurable shape, so complexity is read from the work, not guessed
300
- * from the seed.
301
- *
302
- * The resolved route decides whether the created Stories carry the
303
- * {@link LITE_ROUTE_LABEL} **hint** (never the control signal — `/mandrel-deliver`
304
- * re-derives the route from the Story body's shape) and the `route` block
305
- * ledgered on their `story-plan-state` checkpoint, including the authored
306
- * verdict, its recorded reason, and the per-Story shape evidence. A refused
307
- * claim is ledgered too (route `full` with the refusal reasons), so the
308
- * judgment stays auditable either way.
309
- *
310
- * Module-private: reachable end to end through {@link runPlanPersist}
311
- * (whose result reports the resolved route), so there is no test-only
312
- * export to leave production-dead.
313
- *
314
- * @param {{
315
- * stories: ReturnType<typeof assemblePlanStories>['stories'],
316
- * routeDowngradeReason?: string|null,
317
- * config?: object,
318
- * injectedRules?: object,
319
- * }} args
320
- * @returns {{
321
- * route: 'lite'|'full',
322
- * reasons: string[],
323
- * authored: { route: 'lite', reason: string },
324
- * shape: Array<{ slug: string, route: string, reasons: string[], shape: object|null }>,
325
- * }|null} `null` when the planner authored no verdict (nothing to persist).
326
- */
327
- function resolveEffectiveRoute({
328
- stories,
329
- routeDowngradeReason = null,
330
- config = {},
331
- injectedRules,
332
- }) {
333
- const verdict = resolvePlannerRouteVerdict({ reason: routeDowngradeReason });
334
- if (verdict.route !== 'lite') return null;
335
-
336
- // The schema's documented contract: with the gate disabled
337
- // (`planning.complexityGate.enabled=false`), persist refuses lite claims —
338
- // the same switch dispatch reads (`resolveStoryDispatchMode` falls back to
339
- // sub-agent), so neither read point can honor a lite claim the operator
340
- // has switched off. The refusal is ledgered like any other, keeping the
341
- // judgment auditable.
342
- if (!resolveComplexityGate(config).enabled) {
343
- return {
344
- route: 'full',
345
- reasons: [
346
- 'planner lite verdict refused: complexity routing is disabled ' +
347
- '(planning.complexityGate.enabled=false)',
348
- ],
349
- authored: verdict.authored,
350
- shape: [],
351
- };
352
- }
353
-
354
- const perStory = (Array.isArray(stories) ? stories : []).map((story) => {
355
- const derived = deriveStoryShape({
356
- changes: story.bodyObject?.changes,
357
- acceptance: story.acceptance,
358
- injectedRules,
359
- });
360
- return {
361
- slug: story.slug,
362
- route: derived.route,
363
- reasons: derived.reasons,
364
- shape: derived.shape,
365
- };
366
- });
367
- const offenders = perStory.filter((entry) => entry.route !== 'lite');
368
- if (offenders.length > 0) {
369
- return {
370
- route: 'full',
371
- reasons: [
372
- `planner lite verdict refused: ${offenders.length} of ${perStory.length} ` +
373
- 'Story(ies) exceed the lite shape ceilings — failing closed to full',
374
- ...offenders.map((entry) => `${entry.slug}: ${entry.reasons[0]}`),
375
- ],
376
- authored: verdict.authored,
377
- shape: perStory,
378
- };
379
- }
380
- return {
381
- route: 'lite',
382
- reasons: [
383
- ...verdict.reasons,
384
- 'shape backstop: every authored Story fits the lite shape ceilings',
385
- ],
386
- authored: verdict.authored,
387
- shape: perStory,
388
- };
389
- }
390
-
391
- /**
392
- * Re-run the cross-Story conflict passes over the assembled bodies and route
393
- * the result (Story #5045).
308
+ * Re-run the cross-Story conflict passes over the assembled bodies
309
+ * (Story #5045).
394
310
  *
395
311
  * `validateTickets` runs before assembly, over the raw payload, so until now
396
312
  * plan-time conflict analysis judged an artifact that is not the one persist
397
313
  * writes — and the passes that scan `body.acceptance` / `body.verify` were
398
- * inert on the canonical top-level authoring shape as a result.
399
- *
400
- * Three outcomes, in order:
314
+ * inert on the canonical top-level authoring shape as a result. Findings the
315
+ * raw pass already reported are dropped so the same collision is not
316
+ * announced twice per run; the rest are returned for the plan-summary
317
+ * comment, which is where these findings stop being a stderr line nobody
318
+ * keeps. Every finding is advisory (Story #5312).
401
319
  *
402
- * 1. **Hard findings throw.** Policy upgrades (`planning.failOnSharedEditors`,
403
- * `planning.requireExplicitCrossStoryDeps`) are off by default; when an
404
- * operator turns one on it must bite on the persisted artifact too, and it
405
- * must bite **before** the first `createIssue`.
406
- * 2. **Soft findings the raw pass already reported are dropped**, so the same
407
- * collision is not announced twice per run.
408
- * 3. **Everything else is returned** for the plan-summary comment, which is
409
- * where these findings stop being a stderr line nobody keeps.
410
- *
411
- * @param {{ stories: object[], config: object, rawFindings: object[] }} args
320
+ * @param {{ stories: object[], rawFindings: object[] }} args
412
321
  * @returns {object[]} The assembled-pass findings, for the summary comment.
413
322
  */
414
- function analyzeAssembledStories({ stories, config, rawFindings }) {
415
- const findings = computeAssembledConflictFindings({ stories, config });
416
- const hard = findings.filter((finding) => finding.severity === 'hard');
417
- if (hard.length > 0) {
418
- throw new Error(
419
- `[plan-persist] ${hard.length} cross-Story conflict(s) in the assembled ` +
420
- `Story bodies:\n${hard.map((f) => ` - ${renderHardConflictError(f)}`).join('\n')}`,
421
- );
422
- }
323
+ function analyzeAssembledStories({ stories, rawFindings }) {
324
+ const findings = computeAssembledConflictFindings({ stories });
423
325
  const alreadyReported = new Set(
424
326
  (rawFindings ?? []).map((finding) => conflictFindingKey(finding)),
425
327
  );
@@ -470,33 +372,22 @@ export async function reapStalePlanDirs({
470
372
  }
471
373
 
472
374
  /**
473
- * Fail closed on a payload that cannot be persisted, and warn on an
474
- * explicitly-authorized over-budget one. Extracted from `runPlanPersist`
475
- * (Story #4926) so the entry point carries the flow, not the guards.
375
+ * Fail closed on a payload that cannot be persisted. Extracted from
376
+ * `runPlanPersist` (Story #4926) so the entry point carries the flow, not the
377
+ * guards. The reviewability budget that used to sit beside this check went
378
+ * with Story #5312 — a plan is as many Stories as the split policy yields.
476
379
  *
477
380
  * @param {unknown} rawStories
478
- * @param {{ maxTickets: number, allowOverBudget: boolean }} limits
479
381
  * @returns {void}
480
- * @throws {Error} On an empty payload or an unauthorized over-budget one.
382
+ * @throws {Error} On an empty payload.
481
383
  */
482
- function assertPersistablePlan(rawStories, { maxTickets, allowOverBudget }) {
384
+ function assertPersistablePlan(rawStories) {
483
385
  if (!Array.isArray(rawStories) || rawStories.length === 0) {
484
386
  throw new Error(
485
387
  '[plan-persist] stories payload must be a non-empty array ' +
486
388
  '(--stories <file>). Default is one Story.',
487
389
  );
488
390
  }
489
- if (rawStories.length <= maxTickets) return;
490
- if (!allowOverBudget) {
491
- throw new Error(
492
- `[plan-persist] Stories (${rawStories.length}) exceed the reviewability ` +
493
- `budget (${maxTickets}). Re-scope, or rerun with --allow-over-budget.`,
494
- );
495
- }
496
- Logger.warn(
497
- `[plan-persist] Persisting an over-budget plan: ${rawStories.length} ` +
498
- `Stories vs. budget ${maxTickets} (--allow-over-budget).`,
499
- );
500
391
  }
501
392
 
502
393
  /**
@@ -549,7 +440,6 @@ async function persistStoryArtifacts({
549
440
  provider,
550
441
  created,
551
442
  primary,
552
- route,
553
443
  summaryBody,
554
444
  }) {
555
445
  const cohort = created.map((createdStory) => ({
@@ -566,11 +456,6 @@ async function persistStoryArtifacts({
566
456
  primaryStoryId: primary.id,
567
457
  stories: cohort,
568
458
  },
569
- // Ledger the authored route verdict — the recorded reason and the
570
- // per-Story shape evidence, including a shape-refused claim — on plan
571
- // state (Story #4722). No authored verdict writes no block: absence
572
- // is the standard full path.
573
- ...(route ? { route } : {}),
574
459
  }),
575
460
  // The per-Story checkpoint upserts (Story #4952): each targets a
576
461
  // different issue and reads nothing another writes, so this loop was
@@ -605,32 +490,6 @@ async function cleanupPlanDirs({ config, planDir, skipCleanup }) {
605
490
  await reapStalePlanDirs({ config, keepDir: skipCleanup ? planDir : null });
606
491
  }
607
492
 
608
- /**
609
- * Log the effective complexity route (Story #4722). Lite is upheld by the
610
- * shape backstop; anything else reports why it fell back to full.
611
- *
612
- * @param {object|null} route
613
- * @param {boolean} isLiteRoute
614
- * @returns {void}
615
- */
616
- function logEffectiveRoute(route, isLiteRoute) {
617
- if (isLiteRoute) {
618
- Logger.info(
619
- `[plan-persist] ceremony-lite route upheld by the shape backstop: ` +
620
- `created Stories carry the ${LITE_ROUTE_LABEL} hint ` +
621
- `(recorded reason: ${route.authored.reason}). /mandrel-deliver re-derives ` +
622
- 'the route from each Story body — the label is never the control signal.',
623
- );
624
- return;
625
- }
626
- if (route) {
627
- Logger.warn(
628
- `[plan-persist] ${route.reasons.join('; ')} — persisting as full ` +
629
- '(no route hint label).',
630
- );
631
- }
632
- }
633
-
634
493
  /**
635
494
  * Log the operator-facing persist epilogue: adoption, the ready primary, the
636
495
  * deliver command, and the cohort grouping label.
@@ -688,21 +547,16 @@ function logPersistEpilogue({
688
547
  * planContextEnvelope?: object|null,
689
548
  * },
690
549
  * config?: object,
691
- * settings?: object,
692
550
  * opts?: {
693
551
  * forceReview?: boolean,
694
- * allowOverBudget?: boolean,
695
- * allowLargeFanOut?: boolean,
696
552
  * skipCleanup?: boolean,
697
553
  * dryRun?: boolean,
698
554
  * planDir?: string,
699
- * fanOutCounter?: Function,
555
+ * gitRunner?: Function,
700
556
  * cwd?: string,
701
557
  * sourceTicketIds?: number[],
702
558
  * sourceTicketOrigin?: 'flag'|'envelope'|'none',
703
559
  * closeSuperseded?: boolean,
704
- * routeDowngradeReason?: string|null,
705
- * injectedRules?: object,
706
560
  * },
707
561
  * }} input
708
562
  */
@@ -710,7 +564,6 @@ export async function runPlanPersist({
710
564
  provider,
711
565
  artifacts,
712
566
  config = {},
713
- settings = {},
714
567
  opts = {},
715
568
  }) {
716
569
  const {
@@ -721,18 +574,14 @@ export async function runPlanPersist({
721
574
  } = artifacts ?? {};
722
575
  const {
723
576
  forceReview = false,
724
- allowOverBudget = false,
725
- allowLargeFanOut = false,
726
577
  skipCleanup = false,
727
578
  dryRun = false,
728
579
  planDir = null,
729
- fanOutCounter = undefined,
580
+ gitRunner = undefined,
730
581
  cwd = PROJECT_ROOT,
731
582
  sourceTicketIds = [],
732
583
  sourceTicketOrigin = 'none',
733
584
  closeSuperseded = true,
734
- routeDowngradeReason = null,
735
- injectedRules = undefined,
736
585
  // Story #5139 — the optional container Epic. `null` (the default) is the
737
586
  // ordinary shape: no Epic is created unless `/mandrel-plan` offered one
738
587
  // above the threshold and the operator confirmed it.
@@ -745,26 +594,20 @@ export async function runPlanPersist({
745
594
  // through the shared standalone ledger (Story #4541).
746
595
  const runStartedAt = opts.metricsSince ?? new Date().toISOString();
747
596
 
748
- assertPersistablePlan(rawStories, {
749
- maxTickets: getLimits(config).maxTickets,
750
- allowOverBudget,
751
- });
597
+ assertPersistablePlan(rawStories);
752
598
 
753
599
  Logger.info(
754
600
  `[plan-persist] Running cross-validation on ${rawStories.length} Story ticket(s)...`,
755
601
  );
756
- const validated = validateTickets(rawStories, config, {
757
- fanOutCounter,
758
- cwd,
759
- modelCapacity: opts.modelCapacity,
760
- });
761
- enforceFanOutGate(validated.findings, allowLargeFanOut, 'plan-persist');
602
+ const validated = validateTickets(rawStories, config, { cwd, gitRunner });
762
603
  surfaceSoftConflictFindings(validated.findings, 'plan-persist');
763
- const freshness = enforceTicketValidation(validated, {
764
- config,
765
- settings,
766
- cwd,
767
- });
604
+ const { warnings: validationWarnings, freshness } =
605
+ enforceTicketValidation(validated);
606
+ const warnings = [
607
+ ...validationWarnings,
608
+ ...collectTextHygieneWarnings(rawStories),
609
+ ];
610
+ logWarnings(warnings);
768
611
 
769
612
  const reachability = evaluateDraftReachability({
770
613
  tickets: rawStories,
@@ -780,7 +623,7 @@ export async function runPlanPersist({
780
623
  epicId: opts.adoptEpicId ?? null,
781
624
  });
782
625
 
783
- // Split policy + inline Spec fold (over-budget Specs fail closed — no docs/).
626
+ // Split policy + inline Spec fold (Specs stay inline, never under docs/).
784
627
  const seedContent = planContextEnvelope?.seed?.content ?? '';
785
628
  const { stories: assembled } = assemblePlanStories(rawStories, {
786
629
  sharedSpec: techSpecContent,
@@ -808,29 +651,14 @@ export async function runPlanPersist({
808
651
  // GitHub call, so a policy upgrade still refuses the plan pre-creation.
809
652
  const assembledConflicts = analyzeAssembledStories({
810
653
  stories,
811
- config,
812
654
  rawFindings: validated.findings,
813
655
  });
814
656
 
815
- // Effective complexity route (Story #4722): the planner's authored lite
816
- // verdict (recorded reason), validated against every assembled Story's own
817
- // shape — a claim exceeding the shape ceilings fails closed to full. Lite
818
- // persists the `route::lite` HINT label + a checkpoint route block; a
819
- // refused claim ledgers the refusal (no label); no claim persists nothing.
820
- const route = resolveEffectiveRoute({
821
- stories,
822
- routeDowngradeReason,
823
- config,
824
- injectedRules,
825
- });
826
- const isLiteRoute = route?.route === 'lite';
827
- logEffectiveRoute(route, isLiteRoute);
828
-
829
657
  const { created, planRunLabel, planRunLabelApplied } =
830
658
  await createStoryIssues({
831
659
  provider,
832
660
  stories,
833
- opts: { dryRun, routeLabel: isLiteRoute ? LITE_ROUTE_LABEL : null },
661
+ opts: { dryRun },
834
662
  });
835
663
 
836
664
  // What this run filed, recorded where the next audit sweep reads it
@@ -894,7 +722,6 @@ export async function runPlanPersist({
894
722
  provider,
895
723
  created,
896
724
  primary,
897
- route,
898
725
  summaryBody,
899
726
  });
900
727
  }
@@ -941,10 +768,14 @@ export async function runPlanPersist({
941
768
  stories: created,
942
769
  primaryStoryId: primary.id,
943
770
  planRunLabel,
944
- route,
945
771
  forceReview,
946
772
  reachability,
947
773
  freshness,
774
+ // Story #5312: what the run rewrote and what it noticed. The dry-run is
775
+ // the review surface now that the footprint probes warn instead of
776
+ // refusing, so the list rides the result envelope, not just stderr.
777
+ warnings,
778
+ repairs: validated.repairs ?? [],
948
779
  waveTable,
949
780
  // Story #5265 AC-2/AC-4: both halves of what persist concluded but used
950
781
  // 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
+ }