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
@@ -23,22 +23,24 @@ 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
+ ticketsModePromptField,
33
+ } from '../templates/decomposer-prompts.js';
30
34
  import {
31
35
  renderAcceptanceSpecSystemPrompt,
32
36
  renderTechSpecSystemPrompt,
33
37
  } from '../templates/spec-author-prompts.js';
34
38
  import { concurrentMap, FANOUT_CONCURRENCY } from '../util/concurrent-map.js';
35
39
  import { buildComplexitySignals } from './complexity-gate.js';
36
- import { parseDeliverySlicingTable } from './consolidation-precondition.js';
37
40
  import { findDependencyCandidates } from './dependency-candidates.js';
38
41
  import { buildDocsDigest } from './docs-digest.js';
39
42
  import { findOpenEpicCandidates } from './epic-candidates.js';
40
43
  import { buildAuthoringContext } from './planning/authoring-context.js';
41
- import { buildDecomposerSystemPrompt } from './planning/decomposer-context.js';
42
44
 
43
45
  /**
44
46
  * Envelope byte ceiling (regression guard for the design's named PR2 risk:
@@ -62,61 +64,108 @@ import { buildDecomposerSystemPrompt } from './planning/decomposer-context.js';
62
64
  * so the seed remains the only field this ceiling leaves genuinely
63
65
  * unbounded. 256 KB (~64K tokens at the ≈4-chars/token estimate) leaves
64
66
  * 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.
67
+ * well under the session budget. An envelope over it is truncated with a
68
+ * `truncated` note rather than refused (Story #5312) — raise the ceiling
69
+ * only with a measured justification.
68
70
  */
69
71
  export const PLAN_CONTEXT_ENVELOPE_BYTE_CEILING = 256_000;
70
72
 
71
- /** Fields named in the over-ceiling error, to point at what to trim. */
72
- const OVERSIZE_REPORT_FIELDS = 3;
73
+ /** Marker appended to a string field the cap had to cut. */
74
+ const TRUNCATION_MARKER =
75
+ '\n\n[… truncated by plan-context: PLAN_CONTEXT_ENVELOPE_BYTE_CEILING …]';
76
+
77
+ /** Bounded number of cap rounds — each round cuts the current largest field. */
78
+ const MAX_TRUNCATION_ROUNDS = 8;
73
79
 
74
80
  /**
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.
81
+ * Byte length of a JSON-serialised value.
82
+ *
83
+ * @param {unknown} value
84
+ * @returns {number}
82
85
  */
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
- });
86
+ function jsonBytes(value) {
87
+ return Buffer.byteLength(JSON.stringify(value) ?? '', 'utf-8');
88
+ }
95
89
 
96
- const DEFAULT_OVERSIZE_REMEDY =
97
- 'Trim the seed, or plan fewer --tickets source issues in one run.';
90
+ /**
91
+ * Cut a string to fit `excess` fewer bytes, keeping a leading prefix and
92
+ * appending the truncation marker.
93
+ *
94
+ * @param {string} text
95
+ * @param {number} excess
96
+ * @returns {string}
97
+ */
98
+ function truncateString(text, excess) {
99
+ // A second cut of the same field must not stack a second marker.
100
+ const bare = text.endsWith(TRUNCATION_MARKER)
101
+ ? text.slice(0, -TRUNCATION_MARKER.length)
102
+ : text;
103
+ const keep = Math.max(
104
+ 0,
105
+ Buffer.byteLength(bare, 'utf-8') - excess - TRUNCATION_MARKER.length,
106
+ );
107
+ return `${Buffer.from(bare, 'utf-8').subarray(0, keep).toString('utf-8')}${TRUNCATION_MARKER}`;
108
+ }
98
109
 
99
110
  /**
100
- * Fail closed when an assembled envelope exceeds
101
- * {@link PLAN_CONTEXT_ENVELOPE_BYTE_CEILING}.
111
+ * Cut one envelope field down by roughly `excess` bytes. Three shapes are
112
+ * cuttable: a string (cut to a prefix), an array (drop tail entries until it
113
+ * fits), and an object whose largest string property is cut in place — which
114
+ * covers `seed.content`, `docsContext.digest` and every list field. Returns
115
+ * `null` for a shape nothing here can shrink.
102
116
  *
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.
117
+ * @param {unknown} value
118
+ * @param {number} excess
119
+ * @returns {{ value: unknown, note: string }|null}
120
+ */
121
+ function truncateField(value, excess) {
122
+ if (typeof value === 'string') {
123
+ return {
124
+ value: truncateString(value, excess),
125
+ note: `text cut to a prefix (${excess} bytes over)`,
126
+ };
127
+ }
128
+ if (Array.isArray(value)) {
129
+ let kept = value.length;
130
+ let bytes = jsonBytes(value);
131
+ const target = bytes - excess;
132
+ while (kept > 0 && bytes > target) {
133
+ kept -= 1;
134
+ bytes = jsonBytes(value.slice(0, kept));
135
+ }
136
+ return {
137
+ value: value.slice(0, kept),
138
+ note: `kept ${kept} of ${value.length} entries`,
139
+ };
140
+ }
141
+ if (value && typeof value === 'object') {
142
+ const [key] = Object.entries(value)
143
+ .filter(([, v]) => typeof v === 'string')
144
+ .map(([k, v]) => [k, Buffer.byteLength(v, 'utf-8')])
145
+ .sort((a, b) => b[1] - a[1])[0] ?? [null];
146
+ if (key === null) return null;
147
+ return {
148
+ value: { ...value, [key]: truncateString(value[key], excess) },
149
+ note: `.${key} cut to a prefix (${excess} bytes over)`,
150
+ };
151
+ }
152
+ return null;
153
+ }
154
+
155
+ /**
156
+ * Fit an assembled envelope under {@link PLAN_CONTEXT_ENVELOPE_BYTE_CEILING}
157
+ * by truncating its largest fields, recording every cut on a `truncated`
158
+ * field so the planner can see what it did not get.
112
159
  *
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.
160
+ * Until Story #5312 this refused the envelope outright and exited non-zero
161
+ * naming what to trim. That was the wrong direction for a bound whose only
162
+ * job is to keep the planner's context readable: an oversize seed or
163
+ * `--tickets` body is an operator's real input, and refusing to plan from it
164
+ * cost a re-run for a ceiling the operator had no way to act on (the seed is
165
+ * carried verbatim by design). A truncated envelope with a note is a plan
166
+ * that runs on the part that fits and says so; a refusal is no plan at all.
167
+ * The bound itself stays a fixed framework constant — a cap the operator can
168
+ * raise past what the model can read fails silently again.
120
169
  *
121
170
  * Deliberately **not** exported: its only external caller would be a test, and
122
171
  * a test-only export is a production-dead one. It is reachable end to end
@@ -124,35 +173,57 @@ const DEFAULT_OVERSIZE_REMEDY =
124
173
  *
125
174
  * @param {object} envelope
126
175
  * @param {{ ceiling?: number }} [opts]
127
- * @returns {object} `envelope`, unchanged, when it fits.
176
+ * @returns {object} `envelope` unchanged when it fits; otherwise a truncated
177
+ * copy carrying `truncated: Array<{ field, originalBytes, keptBytes, note }>`.
128
178
  */
129
- function assertPlanContextWithinCeiling(envelope, opts = {}) {
179
+ function capPlanContextEnvelope(envelope, opts = {}) {
130
180
  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]);
181
+ if (jsonBytes(envelope) <= ceiling) return envelope;
140
182
 
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.',
183
+ const next = { ...envelope };
184
+ const truncated = [];
185
+ for (let round = 0; round < MAX_TRUNCATION_ROUNDS; round += 1) {
186
+ const total = jsonBytes({ ...next, truncated });
187
+ if (total <= ceiling) break;
188
+ // JSON escaping of the marker and the note itself cost a few bytes the
189
+ // raw cut cannot see; over-cut by a small margin so the dominant field
190
+ // absorbs the whole excess rather than a residual spilling onto the next
191
+ // largest one (which is the planner's own prompt).
192
+ const excess = total - ceiling + 128;
193
+ // The largest field that can be cut — re-cut on a later round rather than
194
+ // moving on to a smaller field it never had to touch.
195
+ const candidates = Object.entries(next)
196
+ .map(([field, value]) => [field, jsonBytes(value)])
197
+ .sort((a, b) => b[1] - a[1]);
198
+ let applied = false;
199
+ for (const [field, bytes] of candidates) {
200
+ const cut = truncateField(next[field], excess);
201
+ if (cut === null) continue;
202
+ next[field] = cut.value;
203
+ const record = truncated.find((t) => t.field === field);
204
+ if (record) {
205
+ record.keptBytes = jsonBytes(cut.value);
206
+ record.note = cut.note;
207
+ } else {
208
+ truncated.push({
209
+ field,
210
+ originalBytes: bytes,
211
+ keptBytes: jsonBytes(cut.value),
212
+ note: cut.note,
213
+ });
214
+ }
215
+ applied = true;
216
+ break;
217
+ }
218
+ if (!applied) break;
219
+ }
220
+ Logger.warn(
221
+ `[plan-context] the assembled "${envelope?.mode}" envelope was over the ` +
222
+ `${Math.round(ceiling / 1024)} KB planner-context ceiling — truncated ` +
223
+ `${truncated.map((t) => `${t.field} (${t.note})`).join(', ')}. ` +
224
+ "See the envelope's `truncated` field.",
155
225
  );
226
+ return { ...next, truncated };
156
227
  }
157
228
 
158
229
  /**
@@ -236,13 +307,11 @@ function buildTemplateChanges(complexitySignals) {
236
307
  * / `verify[]` live at the ticket's top level — the machine contract persist
237
308
  * syncs into the body.
238
309
  *
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
310
+ * Correct-by-construction skeleton (Story #4723): when the envelope's
311
+ * `complexitySignals` predicted a footprint the `changes[]` entries arrive
312
+ * pre-resolved to creates-vs-refactors against the repo snapshot — a
313
+ * faithfully-filled skeleton passes the persist ticket validators without a
314
+ * mechanical round-trip. The persist gates stay
246
315
  * authoritative (they probe the base branch ref, not the working tree).
247
316
  *
248
317
  * Pure and deterministic; the output is valid JSON (parseable as-is), with
@@ -265,19 +334,16 @@ export function renderStoriesTemplate({ complexitySignals = null } = {}) {
265
334
  'codes, security invariants, and load-bearing constraints with ' +
266
335
  'their why. Implementation choices belong to the deliverer unless ' +
267
336
  '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. ' +
337
+ 'narration. As long as the work needs. ' +
270
338
  'Delete this field when acceptance[] carries the whole contract.',
271
339
  changes: buildTemplateChanges(complexitySignals),
272
340
  non_goals: [],
273
- reason_to_exist:
274
- 'Fill: the single coherent reason this Story exists (one sentence).',
275
341
  },
276
342
  acceptance: [
277
- 'Fill: a testable, observable criterion (a command exits 0, a file exists, a test matches)',
343
+ 'Fill: an outcome a PR reviewer can confirm from the diff and the verify output (three to six items)',
278
344
  ],
279
345
  verify: [
280
- 'Fill: exact command or test path — keep the trailing tier tag valid: unit, contract, e2e, or validate (unit)',
346
+ 'Fill: exact command or test path — the mechanical check the acceptance item rests on',
281
347
  ],
282
348
  depends_on: [],
283
349
  },
@@ -314,14 +380,12 @@ const DELTA_VERB_RE =
314
380
  * two skill Reads (`core/scope-triage` + the gate fragment's rubric pass)
315
381
  * from the headless path; the attended path keeps the skill-based judgment.
316
382
  *
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.
383
+ * The heuristics anchor to the same granularity SSOT the skill anchors to —
384
+ * `DELIVERABLE_GRANULARITY_GUIDANCE` in `ticket-validator-sizing.js` (one
385
+ * Story = one coherent capability slice; multiple independent capabilities =
386
+ * an Epic) — and to the skill's change-request delta rubric. Like the skill,
387
+ * the verdict is **advisory**: being wrong in the `epic` direction is cheap,
388
+ * and `borderline` is a first-class output, not a forced call.
325
389
  *
326
390
  * @param {{ seedText?: string }} args
327
391
  * @returns {{ verdict: 'epic'|'story'|'borderline', reasons: string[], advisory: true, appliedBy: 'cli' }}
@@ -383,113 +447,6 @@ export function buildScopeTriageSignal({ seedText = '' } = {}) {
383
447
  };
384
448
  }
385
449
 
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
450
  /**
494
451
  * The `audit-rules.json` lens `target` value marking a lens applicable only to
495
452
  * a project with a rendered frontend.
@@ -666,153 +623,39 @@ function buildUiSurfaceSignal({ complexitySignals, config, cwd } = {}) {
666
623
  *
667
624
  * @param {object} complexitySignals
668
625
  * @param {{ config?: object, cwd?: string }} [context]
669
- * @returns {object} the same signals plus `deliverLightSuggestion` and
670
- * `uiSurface`.
626
+ * @returns {object} the same signals plus `uiSurface`.
671
627
  */
672
628
  function withAdvisorySignals(complexitySignals, { config, cwd } = {}) {
673
629
  return {
674
630
  ...complexitySignals,
675
- deliverLightSuggestion: buildDeliverLightSuggestion(complexitySignals),
676
631
  uiSurface: buildUiSurfaceSignal({ complexitySignals, config, cwd }),
677
632
  };
678
633
  }
679
634
 
680
635
  /**
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
636
+ * Render the authoring system prompts the collapsed pipeline's single
637
+ * authoring pass consumes. The spec/acceptance prompts render from
788
638
  * `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.
639
+ * authoritative from day one); the story prompt is the N=1 core from
640
+ * `lib/templates/decomposer-prompts.js`, with the schedule and partition
641
+ * rules a planner reads only when the default-single split policy clears
642
+ * carried separately as `storySplitRules` (Story #5312).
643
+ *
644
+ * `storyTicketsRules` is the one mode-conditional field (Story #5323): it
645
+ * only means anything when the seed is an existing ticket, and an envelope
646
+ * that carries it in every mode teaches the author to look for a source
647
+ * ticket that a `--seed` run does not have.
791
648
  *
792
- * @param {{ heuristics?: string[], maxTickets?: number }} args
793
- * @returns {{ spec: string, acceptance: string, decompose: string }}
649
+ * @param {{ mode?: string }} [args]
650
+ * @returns {{ spec: string, acceptance: string, story: string, storySplitRules: string, storyTicketsRules?: string }}
794
651
  */
795
- export function buildSystemPrompts({ heuristics = [], maxTickets } = {}) {
796
- const decompose = buildDecomposerSystemPrompt(heuristics, {
797
- maxTickets,
798
- });
652
+ export function buildSystemPrompts({ mode } = {}) {
799
653
  return {
800
654
  spec: renderTechSpecSystemPrompt(),
801
655
  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,
656
+ story: renderStoryAuthorCore(),
657
+ storySplitRules: renderStorySplitRules(),
658
+ ...ticketsModePromptField(mode),
816
659
  };
817
660
  }
818
661
 
@@ -986,20 +829,12 @@ async function buildSeedFileModeEnvelope({
986
829
  );
987
830
  }
988
831
 
989
- const limits = getLimits(config);
990
- const heuristics = resolveRiskHeuristics(config);
991
-
992
832
  // Hoisted above the gather (Story #5155): the dependency-candidate lookup
993
833
  // intersects against `predictedPaths`, so the signals have to exist before
994
834
  // the fan-out starts. `buildComplexitySignals` is synchronous and reads
995
835
  // nothing the gather produces, so hoisting it changes cost, not output.
996
836
  const complexitySignals = withAdvisorySignals(
997
- buildComplexitySignals({
998
- seedText: content,
999
- config,
1000
- riskHeuristics: heuristics,
1001
- cwd,
1002
- }),
837
+ buildComplexitySignals({ seedText: content, cwd }),
1003
838
  { config, cwd },
1004
839
  );
1005
840
 
@@ -1025,12 +860,9 @@ async function buildSeedFileModeEnvelope({
1025
860
  return {
1026
861
  mode: modeLabel,
1027
862
  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.
863
+ // Advisory complexity signals only: no route, no routing authority. The
864
+ // nested `uiSurface` is the advisory /prototype offer — never an
865
+ // automatic reroute.
1034
866
  complexitySignals,
1035
867
  duplicates,
1036
868
  epicCandidates,
@@ -1041,12 +873,7 @@ async function buildSeedFileModeEnvelope({
1041
873
  memoryPoolAdvisory: authoring.memoryPoolAdvisory,
1042
874
  priorFeedback: authoring.priorFeedback,
1043
875
  ticketSchema: TICKET_SCHEMA_DESCRIPTOR,
1044
- maxTickets: limits.maxTickets,
1045
- riskHeuristics: heuristics,
1046
- systemPrompts: buildSystemPrompts({
1047
- heuristics,
1048
- maxTickets: limits.maxTickets,
1049
- }),
876
+ systemPrompts: buildSystemPrompts(),
1050
877
  planState: null,
1051
878
  // N=1 default: author one Story; skip Epic-scale decompose ceremony.
1052
879
  planProfile: 'story-default',
@@ -1148,17 +975,9 @@ async function buildTicketsModeEnvelope({
1148
975
  .map((t) => `# ${t.title}\n\n${t.body}`)
1149
976
  .join('\n\n---\n\n');
1150
977
 
1151
- const limits = getLimits(config);
1152
- const heuristics = resolveRiskHeuristics(config);
1153
-
1154
978
  // Hoisted for the same reason as seed-file mode (Story #5155).
1155
979
  const complexitySignals = withAdvisorySignals(
1156
- buildComplexitySignals({
1157
- seedText: seed,
1158
- config,
1159
- riskHeuristics: heuristics,
1160
- cwd,
1161
- }),
980
+ buildComplexitySignals({ seedText: seed, cwd }),
1162
981
  { config, cwd },
1163
982
  );
1164
983
 
@@ -1196,12 +1015,7 @@ async function buildTicketsModeEnvelope({
1196
1015
  memoryPoolAdvisory: authoring.memoryPoolAdvisory,
1197
1016
  priorFeedback: authoring.priorFeedback,
1198
1017
  ticketSchema: TICKET_SCHEMA_DESCRIPTOR,
1199
- maxTickets: limits.maxTickets,
1200
- riskHeuristics: heuristics,
1201
- systemPrompts: buildSystemPrompts({
1202
- heuristics,
1203
- maxTickets: limits.maxTickets,
1204
- }),
1018
+ systemPrompts: buildSystemPrompts({ mode: 'tickets' }),
1205
1019
  planState: null,
1206
1020
  planProfile:
1207
1021
  ticketIds.length === 1 ? 'story-default' : 'story-from-tickets',
@@ -1250,8 +1064,8 @@ function extractPriorArtifacts(priorBody) {
1250
1064
  * shape already shipped.
1251
1065
  *
1252
1066
  * 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
1067
+ * duplicate search (excluding the amended Story itself) and the authoring
1068
+ * system prompts still ride the envelope. What is
1255
1069
  * dropped is only the from-scratch repo interrogation the prior artifacts
1256
1070
  * already stand in for — that is the round-trip diet, not an amputation.
1257
1071
  *
@@ -1285,8 +1099,6 @@ async function buildAmendmentModeEnvelope({
1285
1099
  const priorBody = typeof prior.body === 'string' ? prior.body : '';
1286
1100
  const { priorAcceptance, deliveredFiles } = extractPriorArtifacts(priorBody);
1287
1101
 
1288
- const heuristics = resolveRiskHeuristics(config);
1289
- const limits = getLimits(config);
1290
1102
  // Story #4952 — this builder's independent-gather set has exactly one
1291
1103
  // member. `provider.getTicket` above is a hard data dependency (the prior
1292
1104
  // body IS the seed), and the mode deliberately carries no authoring-context
@@ -1320,12 +1132,7 @@ async function buildAmendmentModeEnvelope({
1320
1132
  // The prior body is the seed the delta is authored against.
1321
1133
  seed: { text: priorBody, path: null },
1322
1134
  complexitySignals: withAdvisorySignals(
1323
- buildComplexitySignals({
1324
- seedText: priorBody,
1325
- config,
1326
- riskHeuristics: heuristics,
1327
- cwd,
1328
- }),
1135
+ buildComplexitySignals({ seedText: priorBody, cwd }),
1329
1136
  { config, cwd },
1330
1137
  ),
1331
1138
  duplicates,
@@ -1333,12 +1140,7 @@ async function buildAmendmentModeEnvelope({
1333
1140
  // artifacts are the grounding, so there is no docs digest to anchor.
1334
1141
  docsContext: null,
1335
1142
  ticketSchema: TICKET_SCHEMA_DESCRIPTOR,
1336
- maxTickets: limits.maxTickets,
1337
- riskHeuristics: heuristics,
1338
- systemPrompts: buildSystemPrompts({
1339
- heuristics,
1340
- maxTickets: limits.maxTickets,
1341
- }),
1143
+ systemPrompts: buildSystemPrompts(),
1342
1144
  planState: null,
1343
1145
  planProfile: 'story-amendment',
1344
1146
  };
@@ -1349,7 +1151,7 @@ async function buildAmendmentModeEnvelope({
1349
1151
  *
1350
1152
  * Every mode returns through here, which makes this the one place the
1351
1153
  * envelope's total size is decided — and therefore the only honest place to
1352
- * bound it (see {@link assertPlanContextWithinCeiling}).
1154
+ * bound it (see {@link capPlanContextEnvelope}).
1353
1155
  *
1354
1156
  * @param {{
1355
1157
  * mode: 'seed-file'|'seed'|'tickets'|'amends',
@@ -1377,7 +1179,7 @@ export async function buildPlanContext({
1377
1179
  settings = {},
1378
1180
  cwd,
1379
1181
  }) {
1380
- return assertPlanContextWithinCeiling(
1182
+ return capPlanContextEnvelope(
1381
1183
  await buildPlanContextEnvelope({
1382
1184
  mode,
1383
1185
  seedFilePath,
@@ -1394,7 +1196,7 @@ export async function buildPlanContext({
1394
1196
  }
1395
1197
 
1396
1198
  /**
1397
- * Mode dispatch for {@link buildPlanContext}. Split out so the ceiling check
1199
+ * Mode dispatch for {@link buildPlanContext}. Split out so the ceiling cap
1398
1200
  * wraps every mode exactly once.
1399
1201
  */
1400
1202
  async function buildPlanContextEnvelope({