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
@@ -23,22 +23,23 @@ import {
23
23
  hasWebSurface,
24
24
  matchesAnyFilePattern,
25
25
  } from '../audit-suite/selector.js';
26
- import { getLimits } from '../config-resolver.js';
27
26
  import { findSimilarOpenStories } from '../duplicate-search.js';
28
27
  import { Logger } from '../Logger.js';
29
28
  import { parse as parseStoryBody } from '../story-body/story-body.js';
29
+ import {
30
+ renderStoryAuthorCore,
31
+ renderStorySplitRules,
32
+ } from '../templates/decomposer-prompts.js';
30
33
  import {
31
34
  renderAcceptanceSpecSystemPrompt,
32
35
  renderTechSpecSystemPrompt,
33
36
  } from '../templates/spec-author-prompts.js';
34
37
  import { concurrentMap, FANOUT_CONCURRENCY } from '../util/concurrent-map.js';
35
38
  import { buildComplexitySignals } from './complexity-gate.js';
36
- import { parseDeliverySlicingTable } from './consolidation-precondition.js';
37
39
  import { findDependencyCandidates } from './dependency-candidates.js';
38
40
  import { buildDocsDigest } from './docs-digest.js';
39
41
  import { findOpenEpicCandidates } from './epic-candidates.js';
40
42
  import { buildAuthoringContext } from './planning/authoring-context.js';
41
- import { buildDecomposerSystemPrompt } from './planning/decomposer-context.js';
42
43
 
43
44
  /**
44
45
  * Envelope byte ceiling (regression guard for the design's named PR2 risk:
@@ -62,61 +63,108 @@ import { buildDecomposerSystemPrompt } from './planning/decomposer-context.js';
62
63
  * so the seed remains the only field this ceiling leaves genuinely
63
64
  * unbounded. 256 KB (~64K tokens at the ≈4-chars/token estimate) leaves
64
65
  * roughly 2× headroom over the fixed-floor measurement above while staying
65
- * well under the session budget. The test suite asserts serialized
66
- * envelopes stay under this value — raise it only with a measured
67
- * justification.
66
+ * well under the session budget. An envelope over it is truncated with a
67
+ * `truncated` note rather than refused (Story #5312) — raise the ceiling
68
+ * only with a measured justification.
68
69
  */
69
70
  export const PLAN_CONTEXT_ENVELOPE_BYTE_CEILING = 256_000;
70
71
 
71
- /** Fields named in the over-ceiling error, to point at what to trim. */
72
- const OVERSIZE_REPORT_FIELDS = 3;
72
+ /** Marker appended to a string field the cap had to cut. */
73
+ const TRUNCATION_MARKER =
74
+ '\n\n[… truncated by plan-context: PLAN_CONTEXT_ENVELOPE_BYTE_CEILING …]';
75
+
76
+ /** Bounded number of cap rounds — each round cuts the current largest field. */
77
+ const MAX_TRUNCATION_ROUNDS = 8;
73
78
 
74
79
  /**
75
- * Per-field remedy for the over-ceiling refusal, keyed by envelope field
76
- * name. Story #4977 — the refusal used to hardcode "trim the seed, or plan
77
- * fewer --tickets" regardless of which field actually blew the budget; on a
78
- * consumer with a mature Gherkin corpus the dominant field was
79
- * `bddScenarios` (repo-derived, not seed-derived), and "trim the seed" was a
80
- * dead lever the operator had no way to act on. The remedy now follows the
81
- * single largest field.
80
+ * Byte length of a JSON-serialised value.
81
+ *
82
+ * @param {unknown} value
83
+ * @returns {number}
82
84
  */
83
- const OVERSIZE_FIELD_REMEDIES = Object.freeze({
84
- seed: 'Trim the seed text — it is carried verbatim by design and is the one field with no elision path.',
85
- sourceTickets:
86
- 'Plan fewer --tickets source issues in one run — each source ticket body is carried verbatim.',
87
- epic: 'Plan fewer --tickets source issues in one run, or re-plan with a shorter Epic body.',
88
- bddScenarios:
89
- "The project's .feature corpus is already capped near BDD_SCENARIOS_BYTE_BUDGET (lib/bdd-scenario-budget.js) — if this still dominates, another field is unusually small; check the full field breakdown.",
90
- docsContext:
91
- 'Trim project.docsContextFiles — docsContext is a digest built from those files.',
92
- systemPrompts:
93
- 'This field is a fixed framework prompt, not operator content — if it dominates, file a framework-gap issue rather than trying to trim it.',
94
- });
85
+ function jsonBytes(value) {
86
+ return Buffer.byteLength(JSON.stringify(value) ?? '', 'utf-8');
87
+ }
95
88
 
96
- const DEFAULT_OVERSIZE_REMEDY =
97
- 'Trim the seed, or plan fewer --tickets source issues in one run.';
89
+ /**
90
+ * Cut a string to fit `excess` fewer bytes, keeping a leading prefix and
91
+ * appending the truncation marker.
92
+ *
93
+ * @param {string} text
94
+ * @param {number} excess
95
+ * @returns {string}
96
+ */
97
+ function truncateString(text, excess) {
98
+ // A second cut of the same field must not stack a second marker.
99
+ const bare = text.endsWith(TRUNCATION_MARKER)
100
+ ? text.slice(0, -TRUNCATION_MARKER.length)
101
+ : text;
102
+ const keep = Math.max(
103
+ 0,
104
+ Buffer.byteLength(bare, 'utf-8') - excess - TRUNCATION_MARKER.length,
105
+ );
106
+ return `${Buffer.from(bare, 'utf-8').subarray(0, keep).toString('utf-8')}${TRUNCATION_MARKER}`;
107
+ }
98
108
 
99
109
  /**
100
- * Fail closed when an assembled envelope exceeds
101
- * {@link PLAN_CONTEXT_ENVELOPE_BYTE_CEILING}.
110
+ * Cut one envelope field down by roughly `excess` bytes. Three shapes are
111
+ * cuttable: a string (cut to a prefix), an array (drop tail entries until it
112
+ * fits), and an object whose largest string property is cut in place — which
113
+ * covers `seed.content`, `docsContext.digest` and every list field. Returns
114
+ * `null` for a shape nothing here can shrink.
102
115
  *
103
- * Until now the ceiling was enforced *only* by a test assertion over this
104
- * repo's own fixtures, which bounds nothing at runtime: the value it actually
105
- * has to hold for is a consumer's seed or `--tickets` source bodies, and no
106
- * test sees those. That left the documented planner-context cap resting
107
- * entirely on `planning.context.maxBytes` — which resolved but was wired to
108
- * nothing (its `applyBudget` pass lost its last caller in the v2 cutover), so
109
- * in practice no bound existed at all on the path that needed one. That key
110
- * and its budget module were removed outright in Story #4541; this ceiling is
111
- * the replacement.
116
+ * @param {unknown} value
117
+ * @param {number} excess
118
+ * @returns {{ value: unknown, note: string }|null}
119
+ */
120
+ function truncateField(value, excess) {
121
+ if (typeof value === 'string') {
122
+ return {
123
+ value: truncateString(value, excess),
124
+ note: `text cut to a prefix (${excess} bytes over)`,
125
+ };
126
+ }
127
+ if (Array.isArray(value)) {
128
+ let kept = value.length;
129
+ let bytes = jsonBytes(value);
130
+ const target = bytes - excess;
131
+ while (kept > 0 && bytes > target) {
132
+ kept -= 1;
133
+ bytes = jsonBytes(value.slice(0, kept));
134
+ }
135
+ return {
136
+ value: value.slice(0, kept),
137
+ note: `kept ${kept} of ${value.length} entries`,
138
+ };
139
+ }
140
+ if (value && typeof value === 'object') {
141
+ const [key] = Object.entries(value)
142
+ .filter(([, v]) => typeof v === 'string')
143
+ .map(([k, v]) => [k, Buffer.byteLength(v, 'utf-8')])
144
+ .sort((a, b) => b[1] - a[1])[0] ?? [null];
145
+ if (key === null) return null;
146
+ return {
147
+ value: { ...value, [key]: truncateString(value[key], excess) },
148
+ note: `.${key} cut to a prefix (${excess} bytes over)`,
149
+ };
150
+ }
151
+ return null;
152
+ }
153
+
154
+ /**
155
+ * Fit an assembled envelope under {@link PLAN_CONTEXT_ENVELOPE_BYTE_CEILING}
156
+ * by truncating its largest fields, recording every cut on a `truncated`
157
+ * field so the planner can see what it did not get.
112
158
  *
113
- * Failing closed is the right direction here and matches how an over-budget
114
- * `## Spec` is handled (`spec-spill.js`): an envelope this size does not
115
- * degrade the planner gracefully, it silently produces garbage Stories from a
116
- * truncated-by-the-host context. Better to refuse and say what to trim. The
117
- * bound is deliberately a fixed framework constant rather than an operator
118
- * knob — a cap the operator can raise past what the model can read is a cap
119
- * that fails silently again.
159
+ * Until Story #5312 this refused the envelope outright and exited non-zero
160
+ * naming what to trim. That was the wrong direction for a bound whose only
161
+ * job is to keep the planner's context readable: an oversize seed or
162
+ * `--tickets` body is an operator's real input, and refusing to plan from it
163
+ * cost a re-run for a ceiling the operator had no way to act on (the seed is
164
+ * carried verbatim by design). A truncated envelope with a note is a plan
165
+ * that runs on the part that fits and says so; a refusal is no plan at all.
166
+ * The bound itself stays a fixed framework constant — a cap the operator can
167
+ * raise past what the model can read fails silently again.
120
168
  *
121
169
  * Deliberately **not** exported: its only external caller would be a test, and
122
170
  * a test-only export is a production-dead one. It is reachable end to end
@@ -124,35 +172,57 @@ const DEFAULT_OVERSIZE_REMEDY =
124
172
  *
125
173
  * @param {object} envelope
126
174
  * @param {{ ceiling?: number }} [opts]
127
- * @returns {object} `envelope`, unchanged, when it fits.
175
+ * @returns {object} `envelope` unchanged when it fits; otherwise a truncated
176
+ * copy carrying `truncated: Array<{ field, originalBytes, keptBytes, note }>`.
128
177
  */
129
- function assertPlanContextWithinCeiling(envelope, opts = {}) {
178
+ function capPlanContextEnvelope(envelope, opts = {}) {
130
179
  const ceiling = opts.ceiling ?? PLAN_CONTEXT_ENVELOPE_BYTE_CEILING;
131
- const bytes = Buffer.byteLength(JSON.stringify(envelope) ?? '', 'utf-8');
132
- if (bytes <= ceiling) return envelope;
133
-
134
- const sortedFields = Object.entries(envelope)
135
- .map(([field, value]) => [
136
- field,
137
- Buffer.byteLength(JSON.stringify(value) ?? '', 'utf-8'),
138
- ])
139
- .sort((a, b) => b[1] - a[1]);
180
+ if (jsonBytes(envelope) <= ceiling) return envelope;
140
181
 
141
- const largest = sortedFields
142
- .slice(0, OVERSIZE_REPORT_FIELDS)
143
- .map(([field, size]) => `${field} (${Math.round(size / 1024)} KB)`)
144
- .join(', ');
145
-
146
- const topField = sortedFields[0]?.[0];
147
- const remedy = OVERSIZE_FIELD_REMEDIES[topField] ?? DEFAULT_OVERSIZE_REMEDY;
148
-
149
- throw new Error(
150
- `[plan-context] the assembled "${envelope?.mode}" envelope is ` +
151
- `${Math.round(bytes / 1024)} KB, over the ` +
152
- `${Math.round(ceiling / 1024)} KB planner-context ceiling. Largest ` +
153
- `fields: ${largest}. ${remedy} Raising the ceiling needs a measured ` +
154
- 'justification — see PLAN_CONTEXT_ENVELOPE_BYTE_CEILING.',
182
+ const next = { ...envelope };
183
+ const truncated = [];
184
+ for (let round = 0; round < MAX_TRUNCATION_ROUNDS; round += 1) {
185
+ const total = jsonBytes({ ...next, truncated });
186
+ if (total <= ceiling) break;
187
+ // JSON escaping of the marker and the note itself cost a few bytes the
188
+ // raw cut cannot see; over-cut by a small margin so the dominant field
189
+ // absorbs the whole excess rather than a residual spilling onto the next
190
+ // largest one (which is the planner's own prompt).
191
+ const excess = total - ceiling + 128;
192
+ // The largest field that can be cut — re-cut on a later round rather than
193
+ // moving on to a smaller field it never had to touch.
194
+ const candidates = Object.entries(next)
195
+ .map(([field, value]) => [field, jsonBytes(value)])
196
+ .sort((a, b) => b[1] - a[1]);
197
+ let applied = false;
198
+ for (const [field, bytes] of candidates) {
199
+ const cut = truncateField(next[field], excess);
200
+ if (cut === null) continue;
201
+ next[field] = cut.value;
202
+ const record = truncated.find((t) => t.field === field);
203
+ if (record) {
204
+ record.keptBytes = jsonBytes(cut.value);
205
+ record.note = cut.note;
206
+ } else {
207
+ truncated.push({
208
+ field,
209
+ originalBytes: bytes,
210
+ keptBytes: jsonBytes(cut.value),
211
+ note: cut.note,
212
+ });
213
+ }
214
+ applied = true;
215
+ break;
216
+ }
217
+ if (!applied) break;
218
+ }
219
+ Logger.warn(
220
+ `[plan-context] the assembled "${envelope?.mode}" envelope was over the ` +
221
+ `${Math.round(ceiling / 1024)} KB planner-context ceiling — truncated ` +
222
+ `${truncated.map((t) => `${t.field} (${t.note})`).join(', ')}. ` +
223
+ "See the envelope's `truncated` field.",
155
224
  );
225
+ return { ...next, truncated };
156
226
  }
157
227
 
158
228
  /**
@@ -236,13 +306,11 @@ function buildTemplateChanges(complexitySignals) {
236
306
  * / `verify[]` live at the ticket's top level — the machine contract persist
237
307
  * syncs into the body.
238
308
  *
239
- * Correct-by-construction skeleton (Story #4723): the emitted `verify[]`
240
- * placeholder already ends with a valid `(tier)` tag (swap `(unit)` for
241
- * `(contract)` / `(e2e)` / `(validate)` where appropriate), and when the
242
- * envelope's `complexitySignals` predicted a footprint the `changes[]`
243
- * entries arrive pre-resolved to creates-vs-refactors against the repo
244
- * snapshot — a faithfully-filled skeleton passes the persist ticket
245
- * validators without a mechanical round-trip. The persist gates stay
309
+ * Correct-by-construction skeleton (Story #4723): when the envelope's
310
+ * `complexitySignals` predicted a footprint the `changes[]` entries arrive
311
+ * pre-resolved to creates-vs-refactors against the repo snapshot — a
312
+ * faithfully-filled skeleton passes the persist ticket validators without a
313
+ * mechanical round-trip. The persist gates stay
246
314
  * authoritative (they probe the base branch ref, not the working tree).
247
315
  *
248
316
  * Pure and deterministic; the output is valid JSON (parseable as-is), with
@@ -265,19 +333,16 @@ export function renderStoriesTemplate({ complexitySignals = null } = {}) {
265
333
  'codes, security invariants, and load-bearing constraints with ' +
266
334
  'their why. Implementation choices belong to the deliverer unless ' +
267
335
  'load-bearing. No per-file behavior paragraphs, no current-state ' +
268
- 'narration. Aim for ~250 words; an advisory warning fires past 350, ' +
269
- 'and it never fails the persist. ' +
336
+ 'narration. As long as the work needs. ' +
270
337
  'Delete this field when acceptance[] carries the whole contract.',
271
338
  changes: buildTemplateChanges(complexitySignals),
272
339
  non_goals: [],
273
- reason_to_exist:
274
- 'Fill: the single coherent reason this Story exists (one sentence).',
275
340
  },
276
341
  acceptance: [
277
- 'Fill: a testable, observable criterion (a command exits 0, a file exists, a test matches)',
342
+ 'Fill: an outcome a PR reviewer can confirm from the diff and the verify output (three to six items)',
278
343
  ],
279
344
  verify: [
280
- 'Fill: exact command or test path — keep the trailing tier tag valid: unit, contract, e2e, or validate (unit)',
345
+ 'Fill: exact command or test path — the mechanical check the acceptance item rests on',
281
346
  ],
282
347
  depends_on: [],
283
348
  },
@@ -314,14 +379,12 @@ const DELTA_VERB_RE =
314
379
  * two skill Reads (`core/scope-triage` + the gate fragment's rubric pass)
315
380
  * from the headless path; the attended path keeps the skill-based judgment.
316
381
  *
317
- * The heuristics anchor to the same sizing SSOT the skill anchors to —
318
- * `DELIVERABLE_GRANULARITY_GUIDANCE` / `DEFAULT_MODEL_CAPACITY` in
319
- * `ticket-validator-sizing.js` (one Story = one coherent capability slice;
320
- * multiple independent capabilities = an Epic) — and to the skill's
321
- * change-request delta rubric. Like the skill, the verdict is **advisory**:
322
- * being wrong in the `epic` direction is cheap (the consolidation critic and
323
- * the sizing validator catch an over-planned Story later), and `borderline`
324
- * is a first-class output, not a forced call.
382
+ * The heuristics anchor to the same granularity SSOT the skill anchors to —
383
+ * `DELIVERABLE_GRANULARITY_GUIDANCE` in `ticket-validator-sizing.js` (one
384
+ * Story = one coherent capability slice; multiple independent capabilities =
385
+ * an Epic) — and to the skill's change-request delta rubric. Like the skill,
386
+ * the verdict is **advisory**: being wrong in the `epic` direction is cheap,
387
+ * and `borderline` is a first-class output, not a forced call.
325
388
  *
326
389
  * @param {{ seedText?: string }} args
327
390
  * @returns {{ verdict: 'epic'|'story'|'borderline', reasons: string[], advisory: true, appliedBy: 'cli' }}
@@ -383,113 +446,6 @@ export function buildScopeTriageSignal({ seedText = '' } = {}) {
383
446
  };
384
447
  }
385
448
 
386
- /**
387
- * Resolve the planning risk heuristics list from the canonical config
388
- * block (same resolution the decompose context uses).
389
- *
390
- * @param {object} config
391
- * @returns {string[]}
392
- */
393
- function resolveRiskHeuristics(config = {}) {
394
- if (Array.isArray(config.planning?.riskHeuristics)) {
395
- return config.planning.riskHeuristics;
396
- }
397
- return [];
398
- }
399
-
400
- /**
401
- * Ceilings a seed's advisory complexity signals must fit for the plan
402
- * workflow to **suggest** the light path at Gate #1 (Story #4741 R3 plan-side
403
- * handshake). Framework constants, not operator knobs — mirroring the
404
- * conservative intent of `complexity-gate.js`'s `STORY_SHAPE_CEILINGS`
405
- * (small, mostly-additive, non-sensitive) but read against the *seed-time*
406
- * signals rather than an authored Story shape.
407
- *
408
- * The suggestion is **advisory only and never an automatic reroute**: it
409
- * surfaces at Gate #1 for the operator to decide, and under `--yes` it is
410
- * recorded on the envelope while planning proceeds unchanged.
411
- *
412
- * These ceilings are deliberately NOT the ones the light path itself applies
413
- * (Story #4760). A confirmed suggestion routes into
414
- * `workflows/helpers/deliver-light.md`, whose gate re-judges the *predicted
415
- * shape* against `STORY_SHAPE_CEILINGS`. Two checks at two different stages:
416
- * this one screens a seed, that one decides. Collapsing them would make a
417
- * confirm a bypass.
418
- *
419
- * **Risk only, never cardinality (Story #4856).** This carried a
420
- * `maxArtifacts: 2` ceiling — the second surviving artifact count after Story
421
- * #4764 retired the axis from the routing gate, and the more misleading of the
422
- * two, because the artifacts it counted were **paths scraped from seed prose**
423
- * rather than a measured footprint. Observed on the seed that produced Story
424
- * #4856: a change spanning four framework modules was suggested as light off
425
- * two scraped paths, one of which did not exist at the scraped location. A count
426
- * of guesses is not a size signal, so the screen now keys on risk alone.
427
- *
428
- * - `maxRiskHeuristicHits` — any risk-heuristic hit disqualifies: risk
429
- * is exactly what a light path should not carry.
430
- * - `maxSensitivePathClasses`— any sensitive-path class disqualifies, the
431
- * same taxonomy close applies to a landed diff.
432
- */
433
- const DELIVER_LIGHT_SUGGESTION_CEILINGS = Object.freeze({
434
- maxRiskHeuristicHits: 0,
435
- maxSensitivePathClasses: 0,
436
- });
437
-
438
- /**
439
- * Derive the advisory light-path suggestion from a seed's complexity signals
440
- * (Story #4741 AC-6). Pure and total: a malformed / missing signal bag fails
441
- * conservative (not suggested), never throws.
442
- *
443
- * `automatic: false` is part of the contract — the suggestion is surfaced for
444
- * the operator, never a silent reroute of a non-interactive run.
445
- *
446
- * @param {object|null|undefined} complexitySignals
447
- * @returns {{
448
- * suggested: boolean,
449
- * automatic: false,
450
- * advisory: true,
451
- * ceilings: typeof DELIVER_LIGHT_SUGGESTION_CEILINGS,
452
- * reasons: string[],
453
- * }}
454
- */
455
- export function buildDeliverLightSuggestion(complexitySignals) {
456
- const ceilings = DELIVER_LIGHT_SUGGESTION_CEILINGS;
457
- const advisory = /** @type {const} */ (true);
458
- const automatic = /** @type {const} */ (false);
459
- const s = complexitySignals ?? {};
460
- const riskHits = Array.isArray(s.riskHeuristicHits)
461
- ? s.riskHeuristicHits.length
462
- : Number.POSITIVE_INFINITY;
463
- const sensitive = Array.isArray(s.sensitivePathClasses)
464
- ? s.sensitivePathClasses.length
465
- : Number.POSITIVE_INFINITY;
466
-
467
- const reasons = [];
468
- if (riskHits > ceilings.maxRiskHeuristicHits) {
469
- reasons.push(`seed hits ${riskHits} risk-heuristic phrase(s)`);
470
- }
471
- if (sensitive > ceilings.maxSensitivePathClasses) {
472
- reasons.push(
473
- `predicted footprint touches ${sensitive} sensitive-path class(es)`,
474
- );
475
- }
476
-
477
- const suggested = reasons.length === 0;
478
- return {
479
- suggested,
480
- automatic,
481
- advisory,
482
- ceilings,
483
- reasons: suggested
484
- ? [
485
- 'seed carries no risk signal (no risk-heuristic hits, no ' +
486
- 'sensitive-path classes) — the operator may prefer /mandrel-deliver for ' +
487
- "this scope; the light path's own gate and diff backstop decide size",
488
- ]
489
- : reasons,
490
- };
491
- }
492
-
493
449
  /**
494
450
  * The `audit-rules.json` lens `target` value marking a lens applicable only to
495
451
  * a project with a rendered frontend.
@@ -666,153 +622,32 @@ function buildUiSurfaceSignal({ complexitySignals, config, cwd } = {}) {
666
622
  *
667
623
  * @param {object} complexitySignals
668
624
  * @param {{ config?: object, cwd?: string }} [context]
669
- * @returns {object} the same signals plus `deliverLightSuggestion` and
670
- * `uiSurface`.
625
+ * @returns {object} the same signals plus `uiSurface`.
671
626
  */
672
627
  function withAdvisorySignals(complexitySignals, { config, cwd } = {}) {
673
628
  return {
674
629
  ...complexitySignals,
675
- deliverLightSuggestion: buildDeliverLightSuggestion(complexitySignals),
676
630
  uiSurface: buildUiSurfaceSignal({ complexitySignals, config, cwd }),
677
631
  };
678
632
  }
679
633
 
680
634
  /**
681
- * Count top-level enumerated items (`- `, `* `, `1. `) under the first
682
- * scope-shaped `## ` heading (Scope / MVP Scope / Proposed Scope / Work
683
- * Breakdown / Capabilities), up to the next `## ` heading. Returns `null`
684
- * when no scope-shaped heading exists — the caller treats that as "no
685
- * sizing signal" and defaults to fan-out.
686
- *
687
- * @param {string} body
688
- * @returns {number|null}
689
- */
690
- function countScopeItems(body) {
691
- if (typeof body !== 'string' || body.length === 0) return null;
692
- const lines = body.split(/\r?\n/);
693
- const headingIdx = lines.findIndex((line) =>
694
- /^##\s+(?:(?:MVP\s+|Proposed\s+)?Scope(?:\s+\([^)]+\))?|Work\s+Breakdown|Capabilities)\s*$/i.test(
695
- line.trim(),
696
- ),
697
- );
698
- if (headingIdx === -1) return null;
699
- let count = 0;
700
- for (let i = headingIdx + 1; i < lines.length; i++) {
701
- const line = lines[i];
702
- if (/^##\s+/.test(line)) break;
703
- if (/^\s*(?:[-*]|\d+\.)\s+\S/.test(line)) count += 1;
704
- }
705
- return count;
706
- }
707
-
708
- /**
709
- * Advisory single-vs-fan-out delivery-shape signal (design § 1 step 1;
710
- * routing pilot #4475). Derived from the same size/shape heuristics the
711
- * scope-triage rubric anchors to — the Delivery Slicing table when the Epic
712
- * body already carries one (slice count + "Independent?" chain shape,
713
- * via the Phase 8.3 precondition parser), else a scope-enumeration count.
714
- *
715
- * **Advisory only, fan-out by default.** This signal changes no routing
716
- * behaviour in this PR: the deliver-side reader is #4475's scope, and until
717
- * it lands the recommendation defaults to `fan-out` for every ambiguous
718
- * case. `single` is recommended only on clear one-pass indicators: a
719
- * slicing table proposing ≤ 2 slices, a pure dependent chain (zero
720
- * realized parallelism from the Story tier — the N=2 bench finding), or a
721
- * scope enumeration of ≤ 2 capabilities.
722
- *
723
- * @param {{ body: string }} args
724
- * @returns {{ recommendation: 'single'|'fan-out', reasons: string[], advisory: true }}
725
- */
726
- export function buildDeliveryShapeSignal({ body } = {}) {
727
- const advisory = /** @type {const} */ (true);
728
- const rows = parseDeliverySlicingTable(body ?? '');
729
-
730
- if (Array.isArray(rows) && rows.length > 0) {
731
- if (rows.length <= 2) {
732
- return {
733
- recommendation: 'single',
734
- reasons: [
735
- `delivery-slicing table proposes ${rows.length} slice(s) — one-pass-sized`,
736
- ],
737
- advisory,
738
- };
739
- }
740
- const chain = rows.slice(1).every((r) => r.independent === false);
741
- if (chain) {
742
- return {
743
- recommendation: 'single',
744
- reasons: [
745
- `delivery-slicing table is a pure dependent chain (${rows.length} slices, every non-first slice "Independent? No") — zero parallelism value from Story fan-out`,
746
- ],
747
- advisory,
748
- };
749
- }
750
- return {
751
- recommendation: 'fan-out',
752
- reasons: [
753
- `delivery-slicing table proposes ${rows.length} slices with independent parallelism`,
754
- ],
755
- advisory,
756
- };
757
- }
758
-
759
- const scopeItems = countScopeItems(body ?? '');
760
- if (scopeItems !== null && scopeItems > 0 && scopeItems <= 2) {
761
- return {
762
- recommendation: 'single',
763
- reasons: [
764
- `scope enumerates ${scopeItems} capability item(s) — one-pass-sized`,
765
- ],
766
- advisory,
767
- };
768
- }
769
- if (scopeItems !== null && scopeItems > 2) {
770
- return {
771
- recommendation: 'fan-out',
772
- reasons: [`scope enumerates ${scopeItems} capability items`],
773
- advisory,
774
- };
775
- }
776
- return {
777
- recommendation: 'fan-out',
778
- reasons: [
779
- 'no delivery-slicing table or scope enumeration to size against — defaulting to fan-out',
780
- ],
781
- advisory,
782
- };
783
- }
784
-
785
- /**
786
- * Render the three authoring system prompts the collapsed pipeline's
787
- * single authoring pass consumes. The spec/acceptance prompts render from
635
+ * Render the authoring system prompts the collapsed pipeline's single
636
+ * authoring pass consumes. The spec/acceptance prompts render from
788
637
  * `lib/templates/spec-author-prompts.js` (the M3/M8 handshake — envelope
789
- * authoritative from day one); the decompose prompt reuses the existing
790
- * Story #4162 carrier including the risk-heuristics suffix.
638
+ * authoritative from day one); the story prompt is the N=1 core from
639
+ * `lib/templates/decomposer-prompts.js`, with the schedule and partition
640
+ * rules a planner reads only when the default-single split policy clears
641
+ * carried separately as `storySplitRules` (Story #5312).
791
642
  *
792
- * @param {{ heuristics?: string[], maxTickets?: number }} args
793
- * @returns {{ spec: string, acceptance: string, decompose: string }}
643
+ * @returns {{ spec: string, acceptance: string, story: string, storySplitRules: string }}
794
644
  */
795
- export function buildSystemPrompts({ heuristics = [], maxTickets } = {}) {
796
- const decompose = buildDecomposerSystemPrompt(heuristics, {
797
- maxTickets,
798
- });
645
+ export function buildSystemPrompts() {
799
646
  return {
800
647
  spec: renderTechSpecSystemPrompt(),
801
648
  acceptance: renderAcceptanceSpecSystemPrompt(),
802
- // v2 Stage 3: default-single author prompt (decompose text + split policy).
803
- story: `${decompose}
804
-
805
- #### v2 DEFAULT-SINGLE SPLIT POLICY:
806
-
807
- Emit **exactly one Story** in \`stories.json\` unless the pieces have
808
- near-zero overlap or sit across an architectural seam. Coupled work stays
809
- one Story — put intra-session checkpoints in \`## Slicing\` and fold the
810
- Tech Spec into \`## Spec\` (inline only; over-budget Specs mean split or
811
- tighten — never write under \`docs/\`). Do **not** emit \`deliveryShape\`.
812
- When N>1, every acceptance criterion must belong to exactly one Story,
813
- and each Story carries its own \`## Spec\` (no shared techspec.md fold).
814
- `,
815
- decompose,
649
+ story: renderStoryAuthorCore(),
650
+ storySplitRules: renderStorySplitRules(),
816
651
  };
817
652
  }
818
653
 
@@ -986,20 +821,12 @@ async function buildSeedFileModeEnvelope({
986
821
  );
987
822
  }
988
823
 
989
- const limits = getLimits(config);
990
- const heuristics = resolveRiskHeuristics(config);
991
-
992
824
  // Hoisted above the gather (Story #5155): the dependency-candidate lookup
993
825
  // intersects against `predictedPaths`, so the signals have to exist before
994
826
  // the fan-out starts. `buildComplexitySignals` is synchronous and reads
995
827
  // nothing the gather produces, so hoisting it changes cost, not output.
996
828
  const complexitySignals = withAdvisorySignals(
997
- buildComplexitySignals({
998
- seedText: content,
999
- config,
1000
- riskHeuristics: heuristics,
1001
- cwd,
1002
- }),
829
+ buildComplexitySignals({ seedText: content, cwd }),
1003
830
  { config, cwd },
1004
831
  );
1005
832
 
@@ -1025,12 +852,9 @@ async function buildSeedFileModeEnvelope({
1025
852
  return {
1026
853
  mode: modeLabel,
1027
854
  seed: { path: seedFilePath ?? null, content },
1028
- // Advisory complexity signals only (Story #4722): no route, no routing
1029
- // authority. The planner authors the trivial-vs-standard verdict; persist
1030
- // validates a lite claim against the authored Story's shape. The nested
1031
- // `deliverLightSuggestion` is the advisory plan-side routing handshake
1032
- // (Story #4741 AC-6) and `uiSurface` the advisory /prototype offer —
1033
- // neither is ever an automatic reroute.
855
+ // Advisory complexity signals only: no route, no routing authority. The
856
+ // nested `uiSurface` is the advisory /prototype offer — never an
857
+ // automatic reroute.
1034
858
  complexitySignals,
1035
859
  duplicates,
1036
860
  epicCandidates,
@@ -1041,12 +865,7 @@ async function buildSeedFileModeEnvelope({
1041
865
  memoryPoolAdvisory: authoring.memoryPoolAdvisory,
1042
866
  priorFeedback: authoring.priorFeedback,
1043
867
  ticketSchema: TICKET_SCHEMA_DESCRIPTOR,
1044
- maxTickets: limits.maxTickets,
1045
- riskHeuristics: heuristics,
1046
- systemPrompts: buildSystemPrompts({
1047
- heuristics,
1048
- maxTickets: limits.maxTickets,
1049
- }),
868
+ systemPrompts: buildSystemPrompts(),
1050
869
  planState: null,
1051
870
  // N=1 default: author one Story; skip Epic-scale decompose ceremony.
1052
871
  planProfile: 'story-default',
@@ -1148,17 +967,9 @@ async function buildTicketsModeEnvelope({
1148
967
  .map((t) => `# ${t.title}\n\n${t.body}`)
1149
968
  .join('\n\n---\n\n');
1150
969
 
1151
- const limits = getLimits(config);
1152
- const heuristics = resolveRiskHeuristics(config);
1153
-
1154
970
  // Hoisted for the same reason as seed-file mode (Story #5155).
1155
971
  const complexitySignals = withAdvisorySignals(
1156
- buildComplexitySignals({
1157
- seedText: seed,
1158
- config,
1159
- riskHeuristics: heuristics,
1160
- cwd,
1161
- }),
972
+ buildComplexitySignals({ seedText: seed, cwd }),
1162
973
  { config, cwd },
1163
974
  );
1164
975
 
@@ -1196,12 +1007,7 @@ async function buildTicketsModeEnvelope({
1196
1007
  memoryPoolAdvisory: authoring.memoryPoolAdvisory,
1197
1008
  priorFeedback: authoring.priorFeedback,
1198
1009
  ticketSchema: TICKET_SCHEMA_DESCRIPTOR,
1199
- maxTickets: limits.maxTickets,
1200
- riskHeuristics: heuristics,
1201
- systemPrompts: buildSystemPrompts({
1202
- heuristics,
1203
- maxTickets: limits.maxTickets,
1204
- }),
1010
+ systemPrompts: buildSystemPrompts(),
1205
1011
  planState: null,
1206
1012
  planProfile:
1207
1013
  ticketIds.length === 1 ? 'story-default' : 'story-from-tickets',
@@ -1250,8 +1056,8 @@ function extractPriorArtifacts(priorBody) {
1250
1056
  * shape already shipped.
1251
1057
  *
1252
1058
  * The semantic steps that reach the ticket are preserved: the open-Story
1253
- * duplicate search (excluding the amended Story itself), the risk heuristics,
1254
- * and the authoring system prompts all still ride the envelope. What is
1059
+ * duplicate search (excluding the amended Story itself) and the authoring
1060
+ * system prompts still ride the envelope. What is
1255
1061
  * dropped is only the from-scratch repo interrogation the prior artifacts
1256
1062
  * already stand in for — that is the round-trip diet, not an amputation.
1257
1063
  *
@@ -1285,8 +1091,6 @@ async function buildAmendmentModeEnvelope({
1285
1091
  const priorBody = typeof prior.body === 'string' ? prior.body : '';
1286
1092
  const { priorAcceptance, deliveredFiles } = extractPriorArtifacts(priorBody);
1287
1093
 
1288
- const heuristics = resolveRiskHeuristics(config);
1289
- const limits = getLimits(config);
1290
1094
  // Story #4952 — this builder's independent-gather set has exactly one
1291
1095
  // member. `provider.getTicket` above is a hard data dependency (the prior
1292
1096
  // body IS the seed), and the mode deliberately carries no authoring-context
@@ -1320,12 +1124,7 @@ async function buildAmendmentModeEnvelope({
1320
1124
  // The prior body is the seed the delta is authored against.
1321
1125
  seed: { text: priorBody, path: null },
1322
1126
  complexitySignals: withAdvisorySignals(
1323
- buildComplexitySignals({
1324
- seedText: priorBody,
1325
- config,
1326
- riskHeuristics: heuristics,
1327
- cwd,
1328
- }),
1127
+ buildComplexitySignals({ seedText: priorBody, cwd }),
1329
1128
  { config, cwd },
1330
1129
  ),
1331
1130
  duplicates,
@@ -1333,12 +1132,7 @@ async function buildAmendmentModeEnvelope({
1333
1132
  // artifacts are the grounding, so there is no docs digest to anchor.
1334
1133
  docsContext: null,
1335
1134
  ticketSchema: TICKET_SCHEMA_DESCRIPTOR,
1336
- maxTickets: limits.maxTickets,
1337
- riskHeuristics: heuristics,
1338
- systemPrompts: buildSystemPrompts({
1339
- heuristics,
1340
- maxTickets: limits.maxTickets,
1341
- }),
1135
+ systemPrompts: buildSystemPrompts(),
1342
1136
  planState: null,
1343
1137
  planProfile: 'story-amendment',
1344
1138
  };
@@ -1349,7 +1143,7 @@ async function buildAmendmentModeEnvelope({
1349
1143
  *
1350
1144
  * Every mode returns through here, which makes this the one place the
1351
1145
  * envelope's total size is decided — and therefore the only honest place to
1352
- * bound it (see {@link assertPlanContextWithinCeiling}).
1146
+ * bound it (see {@link capPlanContextEnvelope}).
1353
1147
  *
1354
1148
  * @param {{
1355
1149
  * mode: 'seed-file'|'seed'|'tickets'|'amends',
@@ -1377,7 +1171,7 @@ export async function buildPlanContext({
1377
1171
  settings = {},
1378
1172
  cwd,
1379
1173
  }) {
1380
- return assertPlanContextWithinCeiling(
1174
+ return capPlanContextEnvelope(
1381
1175
  await buildPlanContextEnvelope({
1382
1176
  mode,
1383
1177
  seedFilePath,
@@ -1394,7 +1188,7 @@ export async function buildPlanContext({
1394
1188
  }
1395
1189
 
1396
1190
  /**
1397
- * Mode dispatch for {@link buildPlanContext}. Split out so the ceiling check
1191
+ * Mode dispatch for {@link buildPlanContext}. Split out so the ceiling cap
1398
1192
  * wraps every mode exactly once.
1399
1193
  */
1400
1194
  async function buildPlanContextEnvelope({