mandrel 1.68.0 → 1.70.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 (75) hide show
  1. package/.agents/README.md +1 -1
  2. package/.agents/docs/agentrc-reference.json +1 -2
  3. package/.agents/docs/configuration.md +2 -4
  4. package/.agents/docs/workflows.md +1 -1
  5. package/.agents/schemas/agentrc.schema.json +1 -5
  6. package/.agents/schemas/lifecycle/epic.automerge.end.schema.json +2 -1
  7. package/.agents/scripts/agents-update-preflight.js +235 -0
  8. package/.agents/scripts/apply-quality-bootstrap.js +79 -0
  9. package/.agents/scripts/audit-labels-bootstrap.js +52 -30
  10. package/.agents/scripts/audit-to-stories.js +54 -0
  11. package/.agents/scripts/bootstrap.js +13 -3
  12. package/.agents/scripts/epic-deliver-preflight.js +30 -13
  13. package/.agents/scripts/epic-deliver-prepare.js +40 -53
  14. package/.agents/scripts/epic-execute-record-wave.js +119 -133
  15. package/.agents/scripts/generate-config-docs.js +189 -94
  16. package/.agents/scripts/lib/audit-suite/findings.js +0 -4
  17. package/.agents/scripts/lib/audit-to-stories/audit-lenses.js +99 -0
  18. package/.agents/scripts/lib/audit-to-stories/build-story-body.js +13 -5
  19. package/.agents/scripts/lib/baseline-snapshot.js +163 -4
  20. package/.agents/scripts/lib/baselines/refresh-service.js +13 -5
  21. package/.agents/scripts/lib/config/baselines.js +0 -20
  22. package/.agents/scripts/lib/config/explain.js +0 -2
  23. package/.agents/scripts/lib/config/limits.js +19 -8
  24. package/.agents/scripts/lib/config/temp-paths.js +0 -31
  25. package/.agents/scripts/lib/config-settings-schema.js +1 -2
  26. package/.agents/scripts/lib/crap-utils.js +281 -0
  27. package/.agents/scripts/lib/maintainability-utils.js +32 -9
  28. package/.agents/scripts/lib/orchestration/dispatch-engine.js +0 -2
  29. package/.agents/scripts/lib/orchestration/epic-cleanup.js +11 -7
  30. package/.agents/scripts/lib/orchestration/epic-plan-decompose/phases/cli.js +6 -6
  31. package/.agents/scripts/lib/orchestration/epic-plan-decompose/phases/context.js +11 -5
  32. package/.agents/scripts/lib/orchestration/epic-run-state-store.js +203 -110
  33. package/.agents/scripts/lib/orchestration/epic-runner/progress-reporter/composition.js +38 -162
  34. package/.agents/scripts/lib/orchestration/epic-runner/progress-reporter/signals.js +3 -4
  35. package/.agents/scripts/lib/orchestration/epic-runner/progress-reporter/transport.js +16 -13
  36. package/.agents/scripts/lib/orchestration/epic-runner/sub-agent-return.js +10 -7
  37. package/.agents/scripts/lib/orchestration/lifecycle/listeners/automerge-predicate.js +37 -24
  38. package/.agents/scripts/lib/orchestration/lifecycle/trace-logger.js +0 -4
  39. package/.agents/scripts/lib/orchestration/manifest-builder.js +6 -0
  40. package/.agents/scripts/lib/orchestration/retro/phases/compose-body.js +101 -70
  41. package/.agents/scripts/lib/orchestration/spec-renderer.js +42 -14
  42. package/.agents/scripts/lib/orchestration/ticket-lease.js +3 -0
  43. package/.agents/scripts/lib/orchestration/ticket-validator-sizing.js +6 -2
  44. package/.agents/scripts/lib/orchestration/wave-record-io.js +18 -77
  45. package/.agents/scripts/lib/orchestration/wave-record-notifications.js +78 -122
  46. package/.agents/scripts/lib/orchestration/wave-record-projection.js +21 -226
  47. package/.agents/scripts/lib/presentation/dispatch-manifest-render.js +18 -1
  48. package/.agents/scripts/lib/presentation/manifest-render-waves.js +77 -4
  49. package/.agents/scripts/lib/story-adjacency.js +14 -10
  50. package/.agents/scripts/lib/story-body/story-body.js +142 -65
  51. package/.agents/scripts/lib/templates/decomposer-prompts.js +23 -3
  52. package/.agents/scripts/lib/test-tiers.js +13 -7
  53. package/.agents/scripts/lib/wave-runner/ready-set.js +295 -0
  54. package/.agents/scripts/lib/wave-runner/tick.js +446 -216
  55. package/.agents/scripts/lib/wave-runner/wave-runner-error.js +2 -1
  56. package/.agents/scripts/lib/workers/combined-mi-crap-worker.js +226 -0
  57. package/.agents/scripts/lint-label-vocabulary.js +1 -1
  58. package/.agents/scripts/providers/github/issues.js +48 -0
  59. package/.agents/scripts/providers/github.js +1 -0
  60. package/.agents/scripts/stories-wave-tick.js +262 -161
  61. package/.agents/skills/core/epic-plan-consolidate/SKILL.md +6 -0
  62. package/.agents/skills/core/epic-plan-decompose-author/SKILL.md +108 -101
  63. package/.agents/skills/skills.index.json +2 -2
  64. package/.agents/workflows/agents-update.md +205 -28
  65. package/.agents/workflows/deliver.md +12 -9
  66. package/.agents/workflows/helpers/deliver-epic.md +126 -90
  67. package/.agents/workflows/helpers/deliver-stories.md +131 -85
  68. package/.agents/workflows/helpers/plan-epic.md +13 -10
  69. package/.agents/workflows/plan.md +1 -1
  70. package/README.md +20 -0
  71. package/docs/CHANGELOG.md +46 -0
  72. package/lib/cli/registry.js +49 -6
  73. package/lib/cli/update.js +335 -332
  74. package/package.json +16 -11
  75. package/.agents/scripts/lib/wave-runner/wave-checkpoint.js +0 -91
@@ -8,23 +8,40 @@
8
8
  *
9
9
  * No top-level side effects — safe to import from tests without
10
10
  * triggering GitHub I/O.
11
+ *
12
+ * Story #4157 — each projected story's `wave` is a **render-time
13
+ * dependency depth** derived from the manifest's `dependsOn` edges via the
14
+ * shared `deriveStoryDepths` lens (`manifest-render-waves.js`, built on
15
+ * `assignLayers`), not the persisted `earliestWave`. Scheduling no longer
16
+ * stamps a wave field onto the run checkpoint (Epic #4151 / Story #4155),
17
+ * so the rollup re-derives depth from the dependency graph at render time.
11
18
  */
12
19
 
20
+ import { deriveStoryDepths } from './manifest-render-waves.js';
21
+
13
22
  /**
14
23
  * Pure: project a full dispatch manifest into the `{ stories }` shape
15
24
  * `renderManifest` accepts. Returns the canonical, non-ungrouped story
16
25
  * rows used by the Epic-level dispatch-manifest comment.
17
26
  *
27
+ * The `wave` field is the render-time dependency depth (Story #4157):
28
+ * `deriveStoryDepths` runs `assignLayers` over the entries' `dependsOn`
29
+ * edges, so a Story with no in-set dependency is wave 0 and a dependent
30
+ * sits one layer deeper than its deepest dependency. Entries the lens
31
+ * cannot place (e.g. a non-integer storyId that survives the sentinel
32
+ * filter) fall back to `-1`.
33
+ *
18
34
  * @param {object} manifest
19
35
  * @returns {{ storyId: number|string, wave: number, title: string }[]}
20
36
  */
21
37
  export function projectStoriesFromManifest(manifest) {
22
38
  const storyManifest = manifest?.storyManifest ?? [];
39
+ const depths = deriveStoryDepths(storyManifest);
23
40
  return storyManifest
24
41
  .filter((s) => s && s.storyId !== '__ungrouped__')
25
42
  .map((s) => ({
26
43
  storyId: s.storyId,
27
- wave: s.earliestWave ?? -1,
44
+ wave: depths.get(s.storyId) ?? -1,
28
45
  title: s.storyTitle ?? s.storySlug ?? '',
29
46
  }));
30
47
  }
@@ -17,15 +17,81 @@
17
17
  * Imports the small pure helpers from `manifest-helpers.js` — the
18
18
  * formatter re-exports them so existing call-sites that read these
19
19
  * names off `manifest-formatter.js` keep working.
20
+ *
21
+ * Story #4157 — the per-wave grouping key is now a **render-time
22
+ * dependency depth** derived from each Story entry's `dependsOn` edges via
23
+ * `assignLayers` (`lib/Graph.js`), not the persisted `earliestWave` field
24
+ * the planner stamped on the manifest. Scheduling no longer persists waves
25
+ * onto the run checkpoint (Epic #4151 / Story #4155), so the rollup
26
+ * re-derives depth from the dependency graph at the moment it renders. The
27
+ * shared `deriveStoryDepths` lens below is the single home for that
28
+ * derivation; `dispatch-manifest-render.js` imports it so both
29
+ * operator-facing surfaces group by the same render-time depths.
20
30
  */
21
31
 
32
+ import { assignLayers } from '../Graph.js';
22
33
  import { AGENT_LABELS } from '../label-constants.js';
34
+ import { buildStoryAdjacency } from '../story-adjacency.js';
23
35
  import {
24
36
  deriveStorySymbol,
25
37
  deriveWaveStatus,
26
38
  waveHeadingText,
27
39
  } from './manifest-helpers.js';
28
40
 
41
+ // ---------------------------------------------------------------------------
42
+ // Render-time dependency-depth lens (Story #4157)
43
+ // ---------------------------------------------------------------------------
44
+
45
+ /**
46
+ * Derive a render-time dependency depth for every Story entry in a
47
+ * `storyManifest`, keyed by storyId.
48
+ *
49
+ * The depth is computed by feeding the entries' `dependsOn` edges through
50
+ * the canonical `buildStoryAdjacency` builder (the same one the dispatch
51
+ * pipeline and `stories-wave-tick.js` use) and then `assignLayers`
52
+ * (`lib/Graph.js`): a Story with no dependency edges is depth 0, and a
53
+ * Story sits one layer deeper than its deepest in-set dependency. This
54
+ * replaces the planner's persisted `earliestWave` as the grouping key so
55
+ * the rollup renders correctly from the per-Story checkpoint shape, which
56
+ * no longer carries a wave field (Epic #4151 / Story #4155).
57
+ *
58
+ * `buildStoryAdjacency` reads each entry's id from `id ?? number`, so the
59
+ * entries are adapted to expose `id: storyId` and `dependsOn`. Foreign
60
+ * edges (pointing outside the supplied entry set) are dropped — the
61
+ * default `dropForeign: true` — so the DAG stays closed over the rendered
62
+ * Stories and depth never deepens on a reference the rollup cannot show.
63
+ * The ungrouped sentinel (`storyId === '__ungrouped__'`) and any non-object
64
+ * entry are skipped; they never participate in the graph.
65
+ *
66
+ * Pure: no IO, no clock. Returns a `Map<storyId, depth>` covering exactly
67
+ * the graph-eligible entries.
68
+ *
69
+ * @param {object[]} storyManifest
70
+ * @returns {Map<number|string, number>}
71
+ */
72
+ export function deriveStoryDepths(storyManifest) {
73
+ if (!Array.isArray(storyManifest)) return new Map();
74
+ const eligible = storyManifest.filter(
75
+ (s) =>
76
+ s !== null &&
77
+ typeof s === 'object' &&
78
+ s.storyId !== '__ungrouped__' &&
79
+ Number.isInteger(Number(s.storyId)),
80
+ );
81
+ const records = eligible.map((s) => ({
82
+ id: Number(s.storyId),
83
+ dependsOn: Array.isArray(s.dependsOn) ? s.dependsOn : [],
84
+ }));
85
+ const adjacency = buildStoryAdjacency(records);
86
+ const layers = assignLayers(adjacency);
87
+
88
+ const depths = new Map();
89
+ for (const s of eligible) {
90
+ depths.set(s.storyId, layers.get(Number(s.storyId)) ?? 0);
91
+ }
92
+ return depths;
93
+ }
94
+
29
95
  // ---------------------------------------------------------------------------
30
96
  // ---------------------------------------------------------------------------
31
97
 
@@ -90,14 +156,20 @@ function pickWaveTail(status, waveIdx, sortedWaves, storyCount) {
90
156
  * (a Story is "done" when it carries `agent::done`) — the unit
91
157
  * `deriveWaveStatus` consumes.
92
158
  *
159
+ * Story #4157 — the bucket key is the render-time dependency depth from
160
+ * `depths` (keyed by storyId), not the persisted `earliestWave`. The
161
+ * ungrouped sentinel and any entry the lens could not place fall into the
162
+ * `-1` "Ungrouped" bucket so they still render.
163
+ *
93
164
  * @param {object[]} waveStories
165
+ * @param {Map<number|string, number>} depths
94
166
  * @returns {{ waveGroups: Map<number, object[]>, waveStats: Map<number, { total: number, done: number }> }}
95
167
  */
96
- function groupStoriesByWave(waveStories) {
168
+ function groupStoriesByWave(waveStories, depths) {
97
169
  const waveGroups = new Map();
98
170
  const waveStats = new Map();
99
171
  for (const story of waveStories) {
100
- const w = story.earliestWave ?? -1;
172
+ const w = depths.get(story.storyId) ?? -1;
101
173
  if (!waveGroups.has(w)) {
102
174
  waveGroups.set(w, []);
103
175
  waveStats.set(w, { total: 0, done: 0 });
@@ -126,7 +198,8 @@ export function renderNestedWaveSections(storyManifest) {
126
198
  validateWaveSection('story', s),
127
199
  );
128
200
 
129
- const { waveGroups, waveStats } = groupStoriesByWave(waveStories);
201
+ const depths = deriveStoryDepths(waveStories);
202
+ const { waveGroups, waveStats } = groupStoriesByWave(waveStories, depths);
130
203
  const sortedWaves = [...waveGroups.keys()].sort((a, b) => a - b);
131
204
  const lines = [];
132
205
 
@@ -230,4 +303,4 @@ function renderImplicitDepBullet(finding) {
230
303
 
231
304
  // Test-only: surface the private predicate so the sibling unit test can
232
305
  // exercise each branch without going through the full renderer.
233
- export const __testables = { validateWaveSection };
306
+ export const __testables = { validateWaveSection, groupStoriesByWave };
@@ -1,18 +1,21 @@
1
1
  /**
2
2
  * lib/story-adjacency.js — the single story-level adjacency builder.
3
3
  *
4
- * All three wave-computation wrappers bottom out in the shared
5
- * `lib/Graph.js` kernel (`detectCycle` / `assignLayers` /
6
- * `computeWaves`), but each historically re-implemented the step that
7
- * turns a list of Story records into the `Map<storyId, number[]>`
8
- * adjacency the kernel consumes. This module is now the one home for
9
- * that step; the consumers are:
4
+ * Both Epic-path wave-computation wrappers bottom out in the shared
5
+ * `lib/Graph.js` kernel (`detectCycle` / `assignLayers` / `computeWaves`),
6
+ * but each historically re-implemented the step that turns a list of Story
7
+ * records into the `Map<storyId, number[]>` adjacency the kernel consumes.
8
+ * This module is now the one home for that step; the consumers are:
10
9
  *
11
10
  * - `lib/orchestration/epic-runner/phases/build-wave-dag.js`
12
11
  * (`buildStoryDag` → `computeWaves`)
13
12
  * - `lib/orchestration/dispatch-pipeline.js`
14
13
  * (`buildStoryDispatchGraph` → `computeStoryWaves`)
15
- * - `stories-wave-tick.js` (`buildAdjacency` `assignLayers`)
14
+ * - `lib/wave-runner/ready-set.js` (`selectReadySet`, the path-agnostic
15
+ * continuous scheduler the standalone `stories-wave-tick.js` adapter
16
+ * and the Epic path both dispatch through)
17
+ * - `stories-wave-tick.js` (for cycle detection, before delegating
18
+ * selection to `selectReadySet`)
16
19
  *
17
20
  * Dependency source order (must stay aligned with manifest-builder.js so
18
21
  * the dispatch manifest and runtime wave scheduling never disagree):
@@ -45,9 +48,10 @@ import { parseBlockedBy } from './dependency-parser.js';
45
48
  * @param {boolean} [opts.dropForeign=true] When true (the default,
46
49
  * matching the Epic-scoped wrappers), edges pointing at ids outside
47
50
  * the supplied story set are dropped so the DAG stays closed over the
48
- * scheduled set. `stories-wave-tick.js` passes `false` to preserve its
49
- * historical operator-DAG contract, where a dependency on an id absent
50
- * from the input still deepens the dependent's layer.
51
+ * scheduled set. The standalone path (`stories-wave-tick.js` and the
52
+ * `selectReadySet` core) passes `false` to preserve the operator-DAG
53
+ * contract, where a dependency on an id absent from the input is treated
54
+ * as not-yet-done and withholds the dependent until it completes.
51
55
  * @returns {Map<number, number[]>}
52
56
  */
53
57
  export function buildStoryAdjacency(stories, { dropForeign = true } = {}) {
@@ -16,6 +16,7 @@
16
16
  * verify: string[], // exact commands / tier annotation
17
17
  * references: PathEntry[], // read-only paths (optional)
18
18
  * wide: { reason } | null,// declared-wide footprint (optional)
19
+ * reason_to_exist: string | null, // one-sentence cohesion reason (optional)
19
20
  * depends_on: string[], // blocker story slugs or #ids
20
21
  * estimated_test_files: number | null, // absent → null (informational)
21
22
  * }
@@ -69,6 +70,7 @@ import { FILE_ASSUMPTION_VALUES } from '../orchestration/file-assumption-enum.js
69
70
  * @property {string[]} verify - Exact commands with tier annotation.
70
71
  * @property {PathEntry[]} references - Read-only paths (may be empty).
71
72
  * @property {{ reason: string }|null} wide - Declared-wide footprint (reason), or null.
73
+ * @property {string|null} reason_to_exist - One-sentence cohesion reason ("why this Story exists"), or null.
72
74
  * @property {string[]} depends_on - Blocking story slugs / issue refs.
73
75
  * @property {number|null} estimated_test_files - Test surface count or null.
74
76
  */
@@ -246,10 +248,14 @@ const META_BLOCK_RE = /<!--\s*meta:\s*(\{[\s\S]*?\})\s*-->/;
246
248
  * defaults instead of throwing.
247
249
  *
248
250
  * @param {string} markdown
249
- * @returns {{ wide: { reason: string }|null, estimated_test_files: number|null }}
251
+ * @returns {{ wide: { reason: string }|null, reason_to_exist: string|null, estimated_test_files: number|null }}
250
252
  */
251
253
  function extractMeta(markdown) {
252
- const result = { wide: null, estimated_test_files: null };
254
+ const result = {
255
+ wide: null,
256
+ reason_to_exist: null,
257
+ estimated_test_files: null,
258
+ };
253
259
  const match = markdown.match(META_BLOCK_RE);
254
260
  if (!match) return result;
255
261
 
@@ -263,12 +269,29 @@ function extractMeta(markdown) {
263
269
  if (parsed === null || typeof parsed !== 'object') return result;
264
270
 
265
271
  result.wide = normalizeWide(parsed.wide);
272
+ result.reason_to_exist = normalizeReasonToExist(parsed.reason_to_exist);
266
273
  if (typeof parsed.estimated_test_files === 'number') {
267
274
  result.estimated_test_files = parsed.estimated_test_files;
268
275
  }
269
276
  return result;
270
277
  }
271
278
 
279
+ /**
280
+ * Normalize a raw `reason_to_exist` value to a non-empty trimmed string or
281
+ * `null`. The field is the machine-checkable form of the cohesion rule
282
+ * ("one Story = one coherent change with one reason to exist"): the
283
+ * `epic-plan-consolidate` critic flags any Story whose body carries no
284
+ * non-empty reason. An empty or non-string value is treated as absent.
285
+ *
286
+ * @param {unknown} raw
287
+ * @returns {string|null}
288
+ */
289
+ function normalizeReasonToExist(raw) {
290
+ if (typeof raw !== 'string') return null;
291
+ const reason = raw.trim();
292
+ return reason.length === 0 ? null : reason;
293
+ }
294
+
272
295
  /**
273
296
  * Normalize a raw `wide` declaration to the canonical `{ reason }` shape or
274
297
  * `null`. A `wide` declaration is only honoured when it carries a non-empty
@@ -382,6 +405,7 @@ function parseLegacyStringBody(input, preamble, footer) {
382
405
  verify: [],
383
406
  references: [],
384
407
  wide: null,
408
+ reason_to_exist: null,
385
409
  depends_on: extractBlockedBy(footer),
386
410
  estimated_test_files: null,
387
411
  };
@@ -526,6 +550,7 @@ export function parse(input) {
526
550
  const meta = extractMeta(input);
527
551
  const estimated_test_files = meta.estimated_test_files;
528
552
  const wide = meta.wide;
553
+ const reason_to_exist = meta.reason_to_exist;
529
554
  if (estimated_test_files === null) {
530
555
  warnings.push(
531
556
  'test-surface-unestimated: estimated_test_files not present.',
@@ -539,6 +564,7 @@ export function parse(input) {
539
564
  verify,
540
565
  references,
541
566
  wide,
567
+ reason_to_exist,
542
568
  depends_on: dependsOn,
543
569
  estimated_test_files,
544
570
  };
@@ -597,6 +623,7 @@ function parseStructuredObject(obj) {
597
623
  }
598
624
 
599
625
  const wide = normalizeWide(obj.wide);
626
+ const reason_to_exist = normalizeReasonToExist(obj.reason_to_exist);
600
627
 
601
628
  // depends_on: may be at top level or in body
602
629
  const rawDeps = Array.isArray(obj.depends_on) ? obj.depends_on : [];
@@ -621,6 +648,7 @@ function parseStructuredObject(obj) {
621
648
  verify,
622
649
  references,
623
650
  wide,
651
+ reason_to_exist,
624
652
  depends_on,
625
653
  estimated_test_files,
626
654
  };
@@ -655,6 +683,108 @@ function serializePathEntry(entry) {
655
683
  return JSON.stringify({ path: entry.path, assumption: entry.assumption });
656
684
  }
657
685
 
686
+ /**
687
+ * Descriptor table for the human-readable Story-body sections, in canonical
688
+ * emit order (`## Goal`, `## Changes`, `## Acceptance`, `## Verify`,
689
+ * `## References`). Each descriptor reads one body field and returns the
690
+ * section's markdown block when the field is present and non-empty, or `null`
691
+ * to omit the section.
692
+ *
693
+ * Standardising the section ladder as a single data table makes adding a new
694
+ * optional section a one-line edit here rather than a new control-flow branch
695
+ * in {@link serialize}.
696
+ *
697
+ * @type {Array<{ field: string, render: (value: unknown) => string | null }>}
698
+ */
699
+ const SERIALIZE_SECTIONS = [
700
+ {
701
+ field: 'goal',
702
+ render: (goal) =>
703
+ typeof goal === 'string' && goal.trim().length > 0
704
+ ? `## Goal\n${goal.trim()}`
705
+ : null,
706
+ },
707
+ {
708
+ field: 'changes',
709
+ render: (changes) =>
710
+ Array.isArray(changes) && changes.length > 0
711
+ ? `## Changes\n${changes.map((c) => `- ${serializePathEntry(c)}`).join('\n')}`
712
+ : null,
713
+ },
714
+ {
715
+ field: 'acceptance',
716
+ render: (acceptance) =>
717
+ Array.isArray(acceptance) && acceptance.length > 0
718
+ ? `## Acceptance\n${acceptance.map((a) => `- [ ] ${a}`).join('\n')}`
719
+ : null,
720
+ },
721
+ {
722
+ field: 'verify',
723
+ render: (verify) =>
724
+ Array.isArray(verify) && verify.length > 0
725
+ ? `## Verify\n${verify.map((v) => `- ${v}`).join('\n')}`
726
+ : null,
727
+ },
728
+ {
729
+ field: 'references',
730
+ render: (references) =>
731
+ Array.isArray(references) && references.length > 0
732
+ ? `## References\n${references.map((r) => `- ${serializePathEntry(r)}`).join('\n')}`
733
+ : null,
734
+ },
735
+ ];
736
+
737
+ /**
738
+ * Build the trailing `<!-- meta: {...} -->` block carrying the fields that
739
+ * have no human-readable section (`wide`, `reason_to_exist`,
740
+ * `estimated_test_files`). Returns the empty string when no meta field is
741
+ * present so {@link serialize} appends nothing.
742
+ *
743
+ * Key insertion order (`wide` → `reason_to_exist` → `estimated_test_files`)
744
+ * is load-bearing: it fixes the serialized JSON byte sequence the parser's
745
+ * meta round-trip and the unit suite assert against.
746
+ *
747
+ * @param {StoryBody} body
748
+ * @returns {string}
749
+ */
750
+ function serializeMetaBlock(body) {
751
+ const metaFields = {};
752
+ const wide = normalizeWide(body.wide);
753
+ if (wide !== null) {
754
+ metaFields.wide = wide;
755
+ }
756
+ const reasonToExist = normalizeReasonToExist(body.reason_to_exist);
757
+ if (reasonToExist !== null) {
758
+ metaFields.reason_to_exist = reasonToExist;
759
+ }
760
+ if (typeof body.estimated_test_files === 'number') {
761
+ metaFields.estimated_test_files = body.estimated_test_files;
762
+ }
763
+ if (Object.keys(metaFields).length === 0) return '';
764
+ return `\n\n<!-- meta: ${JSON.stringify(metaFields)} -->`;
765
+ }
766
+
767
+ /**
768
+ * Build the optional `---` footer block (`parent` / `Epic` / `blocked by`
769
+ * lines). Returns the empty string when `opts.includeFooter` is falsy.
770
+ *
771
+ * @param {StoryBody} body
772
+ * @param {SerializeOptions} opts
773
+ * @returns {string}
774
+ */
775
+ function serializeFooter(body, opts) {
776
+ if (!opts.includeFooter) return '';
777
+ const footerLines = ['---'];
778
+ if (opts.footer?.parent) footerLines.push(`parent: #${opts.footer.parent}`);
779
+ if (opts.footer?.epic) footerLines.push(`Epic: #${opts.footer.epic}`);
780
+ if (Array.isArray(body.depends_on)) {
781
+ for (const dep of body.depends_on) {
782
+ footerLines.push(`blocked by ${dep}`);
783
+ }
784
+ }
785
+ return `\n\n${footerLines.join('\n')}`;
786
+ }
787
+
658
788
  /**
659
789
  * Serialize a structured {@link StoryBody} back to the canonical markdown
660
790
  * format written to GitHub issue bodies.
@@ -663,8 +793,8 @@ function serializePathEntry(entry) {
663
793
  * `## Goal`, `## Changes`, `## Acceptance`, `## Verify`, `## References`
664
794
  * (omitted when empty).
665
795
  *
666
- * `wide` and `estimated_test_files` are emitted as a fenced
667
- * `<!-- meta -->` comment block so round-trips preserve them without
796
+ * `wide`, `reason_to_exist`, and `estimated_test_files` are emitted as a
797
+ * fenced `<!-- meta -->` comment block so round-trips preserve them without
668
798
  * polluting the human-readable body.
669
799
  *
670
800
  * @param {StoryBody} body
@@ -679,69 +809,16 @@ export function serialize(body, opts = {}) {
679
809
  }
680
810
 
681
811
  const sections = [];
682
-
683
- // ## Goal
684
- if (typeof body.goal === 'string' && body.goal.trim().length > 0) {
685
- sections.push(`## Goal\n${body.goal.trim()}`);
686
- }
687
-
688
- // ## Changes
689
- if (Array.isArray(body.changes) && body.changes.length > 0) {
690
- const items = body.changes
691
- .map((c) => `- ${serializePathEntry(c)}`)
692
- .join('\n');
693
- sections.push(`## Changes\n${items}`);
694
- }
695
-
696
- // ## Acceptance
697
- if (Array.isArray(body.acceptance) && body.acceptance.length > 0) {
698
- const items = body.acceptance.map((a) => `- [ ] ${a}`).join('\n');
699
- sections.push(`## Acceptance\n${items}`);
700
- }
701
-
702
- // ## Verify
703
- if (Array.isArray(body.verify) && body.verify.length > 0) {
704
- const items = body.verify.map((v) => `- ${v}`).join('\n');
705
- sections.push(`## Verify\n${items}`);
706
- }
707
-
708
- // ## References (only when non-empty)
709
- if (Array.isArray(body.references) && body.references.length > 0) {
710
- const items = body.references
711
- .map((r) => `- ${serializePathEntry(r)}`)
712
- .join('\n');
713
- sections.push(`## References\n${items}`);
812
+ for (const descriptor of SERIALIZE_SECTIONS) {
813
+ const block = descriptor.render(body[descriptor.field]);
814
+ if (block !== null) sections.push(block);
714
815
  }
715
816
 
716
- let out = sections.join('\n\n');
717
-
718
- // Meta block for fields not representable as human-readable sections.
719
- const metaFields = {};
720
- const wide = normalizeWide(body.wide);
721
- if (wide !== null) {
722
- metaFields.wide = wide;
723
- }
724
- if (typeof body.estimated_test_files === 'number') {
725
- metaFields.estimated_test_files = body.estimated_test_files;
726
- }
727
- if (Object.keys(metaFields).length > 0) {
728
- out += `\n\n<!-- meta: ${JSON.stringify(metaFields)} -->`;
729
- }
730
-
731
- // Footer
732
- if (opts.includeFooter) {
733
- const footerLines = ['---'];
734
- if (opts.footer?.parent) footerLines.push(`parent: #${opts.footer.parent}`);
735
- if (opts.footer?.epic) footerLines.push(`Epic: #${opts.footer.epic}`);
736
- if (Array.isArray(body.depends_on)) {
737
- for (const dep of body.depends_on) {
738
- footerLines.push(`blocked by ${dep}`);
739
- }
740
- }
741
- out += `\n\n${footerLines.join('\n')}`;
742
- }
743
-
744
- return out;
817
+ return (
818
+ sections.join('\n\n') +
819
+ serializeMetaBlock(body) +
820
+ serializeFooter(body, opts)
821
+ );
745
822
  }
746
823
 
747
824
  // ---------------------------------------------------------------------------
@@ -15,11 +15,28 @@ import {
15
15
  * 2-tier is the only published hierarchy after Story #4041 removed the
16
16
  * Feature tier: the prompt emits Stories only (direct Epic children) and
17
17
  * asks the planner to carry acceptance/verify as top-level ticket arrays.
18
+ *
19
+ * **Single source of the prompt body (Story #4162).** This module is the sole
20
+ * carrier of the full decomposer system-prompt body. The
21
+ * `epic-plan-decompose-author` SKILL no longer embeds a second verbatim copy —
22
+ * it references this rendered prompt (delivered to the host LLM in the
23
+ * `systemPrompt` field of the authoring context envelope built by
24
+ * `epic-plan-decompose/phases/context.js`) instead, so the two surfaces cannot
25
+ * drift. A guard test (`tests/ticket-decomposer.test.js`) fails if the SKILL
26
+ * re-grows a full copy of the prompt preamble.
27
+ *
28
+ * **Token-budget sizing input (Story #4162).** `maxTokenBudget` is the real
29
+ * one-pass delivery envelope (the task-prompt hydration cap surfaced into the
30
+ * authoring envelope by `context.js`, Story #3875). It is threaded into the
31
+ * rendered prompt as a sizing input so the planner sizes Stories against the
32
+ * envelope a single agent can actually deliver in one pass, rather than leading
33
+ * with the file-count proxy alone.
18
34
  */
19
35
  export function renderDecomposerSystemPrompt({
20
36
  maxTickets = LIMITS_DEFAULTS.maxTickets,
37
+ maxTokenBudget = LIMITS_DEFAULTS.maxTokenBudget,
21
38
  } = {}) {
22
- return render2TierPrompt({ maxTickets });
39
+ return render2TierPrompt({ maxTickets, maxTokenBudget });
23
40
  }
24
41
 
25
42
  /**
@@ -28,7 +45,7 @@ export function renderDecomposerSystemPrompt({
28
45
  * on the Story body so the executing agent has everything it needs in one
29
46
  * ticket. Thematic grouping lives as prose in the Epic body / Tech Spec.
30
47
  */
31
- function render2TierPrompt({ maxTickets }) {
48
+ function render2TierPrompt({ maxTickets, maxTokenBudget }) {
32
49
  // Sizing thresholds are sourced from the single DEFAULT_TASK_SIZING constant
33
50
  // (ticket-validator-sizing.js) so the prompt and the validator cannot drift.
34
51
  const { softFiles, hardFiles, maxAcceptance, softAcceptanceCount } =
@@ -103,6 +120,7 @@ The serialized \`body\` string renders these markdown sections (in order):
103
120
  - **changes** (in body string): Each entry is an object \`{ path, assumption }\` where \`assumption\` is one of \`creates | refactors-existing | deletes\`. Acceptable path shapes include explicit files (\`src/components/Foo.tsx\`), glob patterns (\`tests/e2e/*.spec.ts\`, \`**/*.astro\`), and module identifiers that resolve to files. Use \`refactors-existing\` for in-place edits to a file already on \`main\`; \`creates\` for net-new files; \`deletes\` for removals.
104
121
  - **acceptance** (top-level array on the ticket object): Items MUST be observable from outside the agent. Acceptable shapes: a specific command exits 0, a file exists at a given path, a snapshot test matches, a \`data-testid\` resolves under a given selector, a row count in a fixture matches. UNACCEPTABLE: "verify by reading the diff", "looks good", "matches the spec" — push these down into a \`verify\` command instead.
105
122
  - **verify** (top-level array on the ticket object): Each entry MUST name a testing tier in parentheses, drawn from \`unit\` / \`contract\` / \`e2e\` / \`validate\`. Example: \`npm run test -- src/x.test.ts (unit)\`, \`npm run validate (validate)\`. Stories with zero verify entries SHOULD fail validation; if a story is genuinely unverifiable in isolation (e.g., a copy edit auditor will eyeball), the literal entry \`manual:<reason>\` is allowed so the absence is intentional, not lazy. Manual entries without a reason are rejected.
123
+ - **reason to exist** (REQUIRED, encoded as the \`reason_to_exist\` field of the \`<!-- meta: {...} -->\` comment appended to the serialized body string — NOT a top-level ticket field): One sentence stating the single coherent reason this Story exists, distinct from its broader \`## Goal\` prose. Every Story MUST carry a non-empty \`reason_to_exist\`; it is the machine-checkable form of the cohesion rule (**one Story = one coherent change with one reason to exist**) and the \`epic-plan-consolidate\` critic flags any Story whose body carries no non-empty reason to exist. Encode it as \`<!-- meta: {"reason_to_exist": "..."} -->\`.
106
124
  - **estimated_test_files** (optional, encoded in the \`<!-- meta: {...} -->\` comment appended to the serialized body string — NOT a top-level ticket field): Integer estimate of how many test files this Story creates or modifies. Omit when the number is not estimable. Informational only — it does not gate the decompose.
107
125
 
108
126
  #### STORY SIZING — COHESION FIRST (the numeric ceiling is only a backstop):
@@ -111,7 +129,9 @@ The serialized \`body\` string renders these markdown sections (in order):
111
129
 
112
130
  The primary question is **cohesion, not count**: *is this one coherent change with one reason to exist?* File count cannot tell a trivial ${softFiles}-file rename from a hard 3-file parser+caller+config change — so lead with the change's reason, not its size.
113
131
 
114
- - **One Story = one coherent change with one reason to exist.** If you cannot state that reason in a sentence, the Story is probably two Stories or two Stories that should be one.
132
+ **Size against the real one-pass delivery envelope.** Each Story is delivered and self-verified by a single agent in one pass, whose context is capped by the delivery token budget \`maxTokenBudget = ${maxTokenBudget}\` tokens (the task-prompt hydration cap). Use that envelope not the file count alone — as the leading sizing input: a Story is correctly sized when one agent can hold its full change, acceptance, and verification in a single pass within \`maxTokenBudget\`. The numeric file thresholds below are a coarse backstop on top of this envelope, not the primary signal.
133
+
134
+ - **One Story = one coherent change with one reason to exist.** If you cannot state that reason in a sentence, the Story is probably two Stories — or two Stories that should be one. State that sentence explicitly in the Story's \`reason_to_exist\` meta field (see STORY BODY RULES) so the consolidate critic can check it.
115
135
  - ${singleConsumerRule}
116
136
  - **Split independent, parallelizable work** into sibling Stories — but only when the pieces genuinely have separate reasons to exist.
117
137
  - **Declare \`wide\` with a one-line reason when a change is legitimately broad** (a cohesive cutover that spans many files for one reason). Declaring \`wide\` lifts the hard file-width ceiling — see below.
@@ -33,20 +33,26 @@ const matchesIntegration = picomatch(INTEGRATION_INCLUDE, { dot: true });
33
33
  * `tests` holds the framework's suite tree; `lib` holds the published CLI
34
34
  * (under `lib/cli` and `lib/migrations`) whose tests are colocated in
35
35
  * `__tests__` directories per the unit-tier convention in
36
- * `rules/testing-standards.md`. Without `lib` here, both the quick /
37
- * integration walk and the full-tier glob set miss the colocated CLI tests,
36
+ * `rules/testing-standards.md`. `.agents/scripts` holds the orchestration
37
+ * engine; some of its modules colocate tests in `__tests__` directories the
38
+ * same way (Story #4195). Without each root here, both the quick /
39
+ * integration walk and the full-tier glob set miss the colocated tests,
38
40
  * leaving that coverage dark in `npm test`. The matching full-tier globs
39
41
  * live in `FULL_TIER_GLOBS`.
40
42
  */
41
- const TEST_WALK_ROOTS = ['tests', 'lib'];
43
+ const TEST_WALK_ROOTS = ['tests', 'lib', '.agents/scripts'];
42
44
 
43
45
  /**
44
46
  * Glob targets for the `full` tier — one per walk root in `TEST_WALK_ROOTS`.
45
- * The `tests` glob is a flat recursive sweep; the `lib` glob is scoped to
46
- * `__tests__` subtrees so it only matches colocated tests, never the shipped
47
- * source modules themselves.
47
+ * The `tests` glob is a flat recursive sweep; the `lib` and `.agents/scripts`
48
+ * globs are scoped to `__tests__` subtrees so they only match colocated
49
+ * tests, never the shipped source modules themselves.
48
50
  */
49
- const FULL_TIER_GLOBS = ['tests/**/*.test.js', 'lib/**/__tests__/**/*.test.js'];
51
+ const FULL_TIER_GLOBS = [
52
+ 'tests/**/*.test.js',
53
+ 'lib/**/__tests__/**/*.test.js',
54
+ '.agents/scripts/**/__tests__/**/*.test.js',
55
+ ];
50
56
 
51
57
  /**
52
58
  * @param {string} dir