mandrel 1.81.0 → 1.83.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 (121) hide show
  1. package/.agents/README.md +46 -5
  2. package/.agents/docs/SDLC.md +97 -82
  3. package/.agents/docs/agentrc-reference.json +10 -2
  4. package/.agents/docs/configuration.md +4 -1
  5. package/.agents/docs/execution-reference.md +52 -0
  6. package/.agents/docs/workflows.md +1 -1
  7. package/.agents/instructions.md +85 -45
  8. package/.agents/personas/architect.md +8 -5
  9. package/.agents/personas/engineer-mobile.md +3 -2
  10. package/.agents/personas/engineer-web.md +3 -2
  11. package/.agents/personas/engineer.md +6 -5
  12. package/.agents/personas/product.md +19 -13
  13. package/.agents/personas/project-manager.md +9 -8
  14. package/.agents/personas/qa-engineer.md +10 -6
  15. package/.agents/personas/refactorer.md +3 -2
  16. package/.agents/personas/technical-writer.md +2 -1
  17. package/.agents/personas/ux-designer.md +2 -2
  18. package/.agents/schemas/agentrc.schema.json +41 -3
  19. package/.agents/schemas/qa-ledger.schema.json +2 -2
  20. package/.agents/scripts/acceptance-spec-reconciler.js +143 -59
  21. package/.agents/scripts/epic-deliver-prepare.js +40 -31
  22. package/.agents/scripts/epic-plan-decompose.js +2 -5
  23. package/.agents/scripts/epic-plan-spec.js +16 -19
  24. package/.agents/scripts/hierarchy-gate.js +11 -11
  25. package/.agents/scripts/lib/ITicketingProvider.js +4 -3
  26. package/.agents/scripts/lib/bdd-runner-detect.js +1 -1
  27. package/.agents/scripts/lib/bdd-scenario-scanner.js +1 -1
  28. package/.agents/scripts/lib/cli-args.js +1 -5
  29. package/.agents/scripts/lib/codebase-snapshot.js +1 -1
  30. package/.agents/scripts/lib/config/explain.js +4 -1
  31. package/.agents/scripts/lib/config/temp-paths.js +1 -4
  32. package/.agents/scripts/lib/config-settings-schema.js +30 -1
  33. package/.agents/scripts/lib/epic-body-sections.js +310 -0
  34. package/.agents/scripts/lib/epic-plan-clarity.js +38 -1
  35. package/.agents/scripts/lib/epic-plan-ideation.js +15 -3
  36. package/.agents/scripts/lib/findings/promote-finding.js +3 -3
  37. package/.agents/scripts/lib/findings/severity.js +5 -6
  38. package/.agents/scripts/lib/label-constants.js +7 -17
  39. package/.agents/scripts/lib/label-taxonomy.js +4 -21
  40. package/.agents/scripts/lib/orchestration/check-baselines/phases/evaluate.js +65 -2
  41. package/.agents/scripts/lib/orchestration/context-hydration-engine.js +105 -9
  42. package/.agents/scripts/lib/orchestration/doc-reader.js +29 -0
  43. package/.agents/scripts/lib/orchestration/docs-digest.js +134 -0
  44. package/.agents/scripts/lib/orchestration/epic-plan-decompose/phases/context.js +23 -22
  45. package/.agents/scripts/lib/orchestration/epic-plan-decompose/phases/persist-helpers.js +7 -10
  46. package/.agents/scripts/lib/orchestration/epic-plan-decompose/phases/planning-artifacts.js +4 -38
  47. package/.agents/scripts/lib/orchestration/epic-plan-lease-guard.js +8 -9
  48. package/.agents/scripts/lib/orchestration/epic-plan-spec/phases/authoring-context.js +11 -5
  49. package/.agents/scripts/lib/orchestration/epic-plan-spec/phases/cli-args.js +26 -5
  50. package/.agents/scripts/lib/orchestration/epic-plan-spec/phases/plan-epic.js +102 -304
  51. package/.agents/scripts/lib/orchestration/epic-plan-spec/phases/prompts.js +32 -29
  52. package/.agents/scripts/lib/orchestration/epic-plan-spec/phases/run-spec-phase.js +19 -20
  53. package/.agents/scripts/lib/orchestration/epic-plan-spec/phases/spec-authoring-grounding.js +1 -1
  54. package/.agents/scripts/lib/orchestration/epic-plan-spec/phases/spec-freshness.js +6 -9
  55. package/.agents/scripts/lib/orchestration/epic-plan-state-store.js +3 -4
  56. package/.agents/scripts/lib/orchestration/epic-runner/phases/build-wave-dag.js +1 -1
  57. package/.agents/scripts/lib/orchestration/epic-runner/phases/snapshot.js +20 -27
  58. package/.agents/scripts/lib/orchestration/lifecycle/listeners/acceptance-reconciler.js +11 -5
  59. package/.agents/scripts/lib/orchestration/lifecycle/listeners/finalizer.js +22 -59
  60. package/.agents/scripts/lib/orchestration/lifecycle/listeners/index.js +1 -1
  61. package/.agents/scripts/lib/orchestration/planning-context-budget.js +1 -1
  62. package/.agents/scripts/lib/orchestration/preflight-cache.js +1 -1
  63. package/.agents/scripts/lib/orchestration/spec-freshness.js +3 -3
  64. package/.agents/scripts/lib/orchestration/spec-section-validator.js +1 -1
  65. package/.agents/scripts/lib/orchestration/story-close/baseline-attribution/phases/refresh-commit.js +15 -1
  66. package/.agents/scripts/lib/orchestration/ticket-validator-sizing.js +122 -1
  67. package/.agents/scripts/lib/orchestration/ticketing/bulk.js +5 -8
  68. package/.agents/scripts/lib/orchestration/ticketing/reads.js +2 -2
  69. package/.agents/scripts/lib/plan-phase-cleanup.js +1 -2
  70. package/.agents/scripts/lib/qa/console-allowlist.js +5 -4
  71. package/.agents/scripts/lib/qa/qa-context-hydrator.js +6 -85
  72. package/.agents/scripts/lib/qa/resolve-qa-contract.js +144 -8
  73. package/.agents/scripts/lib/templates/decomposer-prompts.js +14 -8
  74. package/.agents/scripts/lifecycle-emit.js +1 -1
  75. package/.agents/scripts/lint-label-vocabulary.js +2 -3
  76. package/.agents/scripts/providers/github/mappers.js +0 -3
  77. package/.agents/scripts/providers/github/tickets.js +7 -18
  78. package/.agents/scripts/single-story-init.js +0 -1
  79. package/.agents/scripts/story-init.js +1 -29
  80. package/.agents/skills/core/epic-plan-consolidate/SKILL.md +43 -22
  81. package/.agents/skills/core/epic-plan-consolidate/examples.md +51 -0
  82. package/.agents/skills/core/epic-plan-decompose-author/SKILL.md +27 -40
  83. package/.agents/skills/core/epic-plan-decompose-author/examples.md +47 -0
  84. package/.agents/skills/core/epic-plan-premortem/SKILL.md +15 -13
  85. package/.agents/skills/core/epic-plan-premortem/examples.md +53 -0
  86. package/.agents/skills/core/epic-plan-spec-author/SKILL.md +143 -151
  87. package/.agents/skills/core/epic-plan-spec-author/examples.md +91 -0
  88. package/.agents/skills/core/hydrate-context/SKILL.md +10 -5
  89. package/.agents/skills/core/knowledge-transfer/SKILL.md +3 -2
  90. package/.agents/skills/core/scope-triage/SKILL.md +2 -1
  91. package/.agents/skills/skills.index.json +8 -8
  92. package/.agents/skills/stack/qa/qa-explore-driving/SKILL.md +52 -38
  93. package/.agents/templates/epic-from-idea.md +4 -0
  94. package/.agents/workflows/audit-to-stories.md +2 -2
  95. package/.agents/workflows/helpers/code-review.md +11 -9
  96. package/.agents/workflows/helpers/deliver-epic-reference.md +514 -0
  97. package/.agents/workflows/helpers/deliver-epic.md +173 -490
  98. package/.agents/workflows/helpers/epic-audit.md +11 -8
  99. package/.agents/workflows/helpers/epic-deliver-story.md +45 -27
  100. package/.agents/workflows/helpers/epic-plan-decompose.md +17 -12
  101. package/.agents/workflows/helpers/epic-plan-spec.md +68 -68
  102. package/.agents/workflows/helpers/parallel-tooling.md +2 -1
  103. package/.agents/workflows/helpers/plan-epic-reference.md +136 -0
  104. package/.agents/workflows/helpers/plan-epic.md +141 -256
  105. package/.agents/workflows/helpers/plan-story.md +31 -61
  106. package/.agents/workflows/helpers/qa-run-scenario.md +194 -0
  107. package/.agents/workflows/helpers/scope-triage-gate.md +97 -0
  108. package/.agents/workflows/helpers/single-story-deliver-reference.md +423 -0
  109. package/.agents/workflows/helpers/single-story-deliver.md +129 -393
  110. package/.agents/workflows/helpers/worktree-lifecycle.md +1 -1
  111. package/.agents/workflows/plan.md +8 -8
  112. package/.agents/workflows/qa-assist.md +2 -1
  113. package/.agents/workflows/qa-explore.md +63 -32
  114. package/.agents/workflows/qa-run.md +293 -130
  115. package/docs/CHANGELOG.md +35 -0
  116. package/package.json +1 -1
  117. package/.agents/schemas/qa-finding.schema.json +0 -133
  118. package/.agents/scripts/lib/issue-link-parser.js +0 -74
  119. package/.agents/scripts/lib/orchestration/finalize/close-planning-tickets.js +0 -116
  120. package/.agents/scripts/lib/orchestration/planning-state-manager.js +0 -318
  121. package/.agents/scripts/lib/story-init/hierarchy-tracer.js +0 -57
@@ -5,8 +5,8 @@
5
5
  * the shared findings core previously each declared their own severity list —
6
6
  * `classify-finding.js` (`[unknown, low, medium, high, critical]`),
7
7
  * `promote-finding.js` (`SEVERITY_RANK` over `[critical … info]`), and the
8
- * `qa-finding` / `qa-ledger` JSON schemas (`[critical, high, medium, low,
9
- * info]`). Because `severity` is a `fingerprintFinding` identity field
8
+ * `qa-ledger` JSON schema (`[critical, high, medium, low, info]`). Because
9
+ * `severity` is a `fingerprintFinding` identity field
10
10
  * (`route-finding.js`), the same finding could hash to different SHAs depending
11
11
  * on which path normalised its severity, silently weakening dedup. This module
12
12
  * collapses all three onto one enum + one normaliser so the fingerprint is
@@ -14,9 +14,8 @@
14
14
  *
15
15
  * The canonical order is `critical | high | medium | low | info`, highest →
16
16
  * lowest, and it MUST match the `severity` enum in
17
- * `.agents/schemas/qa-ledger.schema.json` (and the mirrored `qa-finding`
18
- * schema). Pure module: no I/O, no module-level state beyond the frozen
19
- * constants.
17
+ * `.agents/schemas/qa-ledger.schema.json`. Pure module: no I/O, no
18
+ * module-level state beyond the frozen constants.
20
19
  */
21
20
 
22
21
  /**
@@ -24,7 +23,7 @@
24
23
  * This is the ONLY definition of the severity vocabulary in the findings core;
25
24
  * `classify-finding.js` and `promote-finding.js` re-export / import it rather
26
25
  * than re-declaring their own list. Mirrors the `severity` enum in
27
- * `qa-ledger.schema.json` / `qa-finding.schema.json`.
26
+ * `qa-ledger.schema.json`.
28
27
  */
29
28
  export const SEVERITIES = Object.freeze([
30
29
  'critical',
@@ -91,22 +91,13 @@ export const STATUS_LABELS = {
91
91
  */
92
92
  export const PERSONA_LABEL_PREFIX = 'persona::';
93
93
 
94
- export const CONTEXT_LABELS = {
95
- PRD: 'context::prd',
96
- TECH_SPEC: 'context::tech-spec',
97
- ACCEPTANCE_SPEC: 'context::acceptance-spec',
98
- };
99
-
100
- /** Convenience aliases so callers can reach the new constants by name without
101
- * indexing into CONTEXT_LABELS. Mirrors the export ergonomics used by other
102
- * consumers that import named constants (e.g. PERSONA_LABEL_PREFIX). */
103
- export const CONTEXT_ACCEPTANCE_SPEC = CONTEXT_LABELS.ACCEPTANCE_SPEC;
104
-
105
94
  /**
106
- * Acceptance-axis labels for opt-out signalling on Stories that
107
- * intentionally have no acceptance-spec coverage. Separate namespace from
108
- * `context::` because it expresses absence rather than a linked context
109
- * ticket.
95
+ * Acceptance-axis labels for opt-out signalling on Epics that
96
+ * intentionally have no acceptance-table coverage. (Story #4324 retired
97
+ * the `context::tech-spec` / `context::acceptance-spec` label classes
98
+ * planning content now lives as managed sections of the Epic body. This
99
+ * waiver survives with unchanged meaning: it waives the Epic body's
100
+ * `## Acceptance Table` section instead of a ticket.)
110
101
  */
111
102
  export const ACCEPTANCE_LABELS = {
112
103
  N_A: 'acceptance::n-a',
@@ -149,7 +140,7 @@ export const PLANNING_LABELS = {
149
140
  /**
150
141
  * Convenience named export so callers can reach the constant without
151
142
  * indexing into PLANNING_LABELS — mirrors the ergonomics used by
152
- * ACCEPTANCE_NA / CONTEXT_ACCEPTANCE_SPEC. The literal value is
143
+ * ACCEPTANCE_NA. The literal value is
153
144
  * duplicated here (rather than aliased through PLANNING_LABELS) so a
154
145
  * grep for `PLANNING_HEALTHCHECK_WAIVED.*planning::healthcheck-waived`
155
146
  * matches a single line — see Story #2921 Task #2933 AC #1.
@@ -162,7 +153,6 @@ export const LABEL_COLORS = {
162
153
  AGENT: '#0E8A16',
163
154
  STATUS_BLOCKED: '#D93F0B',
164
155
  PERSONA: '#C5DEF5',
165
- CONTEXT: '#D4C5F9',
166
156
  ACCEPTANCE: '#FBCA04',
167
157
  PLANNING: '#FEF2C0',
168
158
  };
@@ -12,7 +12,6 @@ import { fileURLToPath } from 'node:url';
12
12
  import {
13
13
  ACCEPTANCE_LABELS,
14
14
  AGENT_LABELS,
15
- CONTEXT_LABELS,
16
15
  LABEL_COLORS,
17
16
  PERSONA_LABEL_PREFIX,
18
17
  PLANNING_LABELS,
@@ -64,7 +63,7 @@ export const LABEL_TAXONOMY = [
64
63
  name: AGENT_LABELS.REVIEW_SPEC,
65
64
  color: LABEL_COLORS.AGENT,
66
65
  description:
67
- 'Parking state — PRD + Tech Spec exist; awaiting human review before decomposition',
66
+ 'Parking state — Tech Spec exists; awaiting human review before decomposition',
68
67
  },
69
68
  {
70
69
  name: AGENT_LABELS.READY,
@@ -98,25 +97,9 @@ export const LABEL_TAXONOMY = [
98
97
  // Persona — dynamically derived from .agents/personas/*.md
99
98
  ...buildPersonaLabels(),
100
99
 
101
- // Context
102
- {
103
- name: CONTEXT_LABELS.PRD,
104
- color: LABEL_COLORS.CONTEXT,
105
- description: 'Product Requirements Document',
106
- },
107
- {
108
- name: CONTEXT_LABELS.TECH_SPEC,
109
- color: LABEL_COLORS.CONTEXT,
110
- description: 'Technical Specification',
111
- },
112
- {
113
- name: CONTEXT_LABELS.ACCEPTANCE_SPEC,
114
- color: LABEL_COLORS.CONTEXT,
115
- description: 'Acceptance Specification (Gherkin scenarios)',
116
- },
117
-
118
- // Acceptance axis — explicit opt-out signal for Stories that
119
- // intentionally have no acceptance-spec coverage.
100
+ // Acceptance axis — explicit opt-out signal for Epics that
101
+ // intentionally have no acceptance-table coverage (waives the Epic
102
+ // body's ## Acceptance Table section — Story #4324).
120
103
  {
121
104
  name: ACCEPTANCE_LABELS.N_A,
122
105
  color: LABEL_COLORS.ACCEPTANCE,
@@ -8,12 +8,69 @@
8
8
  */
9
9
 
10
10
  import { resolveBundleSizeEnvOverrides } from '../../../baselines/env-overrides.js';
11
- import { checkKernelVersion } from '../../../baselines/kernel.js';
11
+ import {
12
+ checkKernelVersion,
13
+ getKindModule,
14
+ } from '../../../baselines/kernel.js';
12
15
  import * as reader from '../../../baselines/reader.js';
13
16
  import { Logger } from '../../../Logger.js';
17
+ import { isIgnoredByGlobs } from '../../../maintainability-utils.js';
14
18
  import { applyTolerance, evaluateCompare, runCompareStage } from './compare.js';
15
19
  import { applyFloors, flattenBreaches } from './floors.js';
16
20
 
21
+ /**
22
+ * Defense-in-depth against an `ignoreGlobs`-poisoned baseline (Epic #4326
23
+ * incident). The generation path already drops `ignoreGlobs`-matched files
24
+ * before they reach `rows` (both the canonical `buildDefaultMaintainabilityScorer`
25
+ * and the story-close `buildKindScorer`), so a freshly-generated baseline's
26
+ * `rollup["*"]` never includes an ignored file. But the floor check trusts the
27
+ * *stored* `rollup["*"]`: if a baseline is poisoned by some other route — a
28
+ * stale branch's older tooling, a hand-edit, a future generation bug — an
29
+ * ignored file's metric can still drag the global floor axis (e.g.
30
+ * maintainability `min`) below its floor and block every downstream close.
31
+ *
32
+ * This recomputes the global `*` aggregate over the baseline rows that are NOT
33
+ * matched by the gate's `ignoreGlobs`, using the kind's own canonical
34
+ * `rollup()` aggregator, so the floor axis reflects only the files the gate is
35
+ * meant to police. It is a **no-op for a correctly-generated baseline** (no
36
+ * ignored file is present in `rows`, so the filtered set is identical and the
37
+ * stored `rollup["*"]` is returned unchanged) and only affects the `*`
38
+ * component the incident poisons; named-component rollups are left as stored.
39
+ * The compare/regression stage is untouched — this only reshapes the floor
40
+ * input. All three `ignoreGlobs`-configured gates (maintainability, crap,
41
+ * duplication) are `path`-keyed, so the shared path matcher applies uniformly.
42
+ *
43
+ * Kept module-local (not exported): the poison-exclusion behaviour is covered
44
+ * end-to-end through `evaluateKind` by the `check-baselines.min-floor` suite,
45
+ * so there is no external consumer to justify widening the surface.
46
+ *
47
+ * @param {{ kind: string, baseline: { rollup?: object, rows?: object[] }, ignoreGlobs?: string[], cwd?: string }} args
48
+ * @returns {object} the effective rollup to feed the floor check
49
+ */
50
+ function rollupExcludingIgnored({ kind, baseline, ignoreGlobs, cwd }) {
51
+ const rollup = baseline?.rollup;
52
+ if (!Array.isArray(ignoreGlobs) || ignoreGlobs.length === 0) return rollup;
53
+ const rows = baseline?.rows;
54
+ if (!Array.isArray(rows) || rows.length === 0) return rollup;
55
+ let mod;
56
+ try {
57
+ mod = getKindModule(kind);
58
+ } catch {
59
+ return rollup;
60
+ }
61
+ if (mod?.keyField !== 'path' || typeof mod.rollup !== 'function')
62
+ return rollup;
63
+ const kept = rows.filter((row) => {
64
+ const p = row?.path;
65
+ return typeof p !== 'string' || !isIgnoredByGlobs(p, ignoreGlobs, cwd);
66
+ });
67
+ // Nothing ignored is present → the stored rollup already excludes ignored
68
+ // files (the correct-baseline fast path); return it untouched.
69
+ if (kept.length === rows.length) return rollup;
70
+ const recomputed = mod.rollup(kept);
71
+ return { ...rollup, '*': recomputed?.['*'] ?? rollup?.['*'] };
72
+ }
73
+
17
74
  function loadHeadBaseline(kind, cwd, configPath) {
18
75
  try {
19
76
  return { baseline: reader.load(kind, { cwd, configPath }) };
@@ -100,7 +157,13 @@ export async function evaluateKind({
100
157
  const headLoad = loadHeadBaseline(kind, cwd, configPath);
101
158
  if (headLoad.schemaError) return { kind, schemaError: headLoad.schemaError };
102
159
  const baseline = headLoad.baseline;
103
- const findings = applyFloors(kind, baseline.rollup, gateBlock.floors ?? {});
160
+ const floorRollup = rollupExcludingIgnored({
161
+ kind,
162
+ baseline,
163
+ ignoreGlobs: gateBlock.ignoreGlobs,
164
+ cwd,
165
+ });
166
+ const findings = applyFloors(kind, floorRollup, gateBlock.floors ?? {});
104
167
  const breaches = flattenBreaches(findings);
105
168
  const cmp = await evaluateCompare({ kind, gateBlock, scope, cwd });
106
169
  const rawCompare = runCompareStage(baseline, cmp);
@@ -27,7 +27,7 @@ import {
27
27
  PROJECT_ROOT,
28
28
  resolveConfig,
29
29
  } from '../config-resolver.js';
30
-
30
+ import { sliceEpicBodyForDelivery } from '../epic-body-sections.js';
31
31
  import { Logger } from '../Logger.js';
32
32
  import {
33
33
  buildEnvelope,
@@ -163,8 +163,7 @@ function getVersion() {
163
163
  /**
164
164
  * Parse the work-breakdown hierarchy from a Task ticket body.
165
165
  *
166
- * Looks for patterns like: `Epic: #1`, `Story: #3`,
167
- * `PRD: #4`, `Tech Spec: #5`.
166
+ * Looks for patterns like: `Epic: #1`, `Story: #3`.
168
167
  *
169
168
  * @param {string} body
170
169
  * @returns {Record<string, number>}
@@ -177,7 +176,7 @@ export function parseHierarchy(body) {
177
176
  for (const match of matches) {
178
177
  const key = match[1].trim().toLowerCase().replace(/\s+/g, '');
179
178
  const val = Number.parseInt(match[2], 10);
180
- result[key] = val; // e.g. { epic: 1, story: 3, prd: 4, techspec: 5 }
179
+ result[key] = val; // e.g. { epic: 1, story: 3 }
181
180
  }
182
181
  return result;
183
182
  }
@@ -234,6 +233,71 @@ export function extractStorySections(body) {
234
233
  return { acceptance, verify };
235
234
  }
236
235
 
236
+ /**
237
+ * Remove one `## <heading>` section (heading line through the last line
238
+ * before the next `## ` heading, or EOF) from a Story body. Byte-preserving
239
+ * outside the removed span. No-op when the heading is absent. Case- and
240
+ * whitespace-tolerant to match `extractSectionList`.
241
+ *
242
+ * @param {string} body
243
+ * @param {string} heading
244
+ * @returns {string}
245
+ */
246
+ function stripSection(body, heading) {
247
+ if (typeof body !== 'string' || body.length === 0) return body ?? '';
248
+ const pattern = new RegExp(
249
+ `^##\\s+${heading.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\s*$`,
250
+ 'mi',
251
+ );
252
+ const startMatch = body.match(pattern);
253
+ if (!startMatch || startMatch.index == null) return body;
254
+ const start = startMatch.index;
255
+ const afterHeading = start + startMatch[0].length;
256
+ const rest = body.slice(afterHeading);
257
+ const nextHeading = rest.search(/^##\s+/m);
258
+ const end = nextHeading === -1 ? body.length : afterHeading + nextHeading;
259
+ const before = body.slice(0, start).replace(/\n+$/, '\n');
260
+ const after = body.slice(end).replace(/^\n+/, '');
261
+ return (before + after).replace(/\n{3,}/g, '\n\n').trimEnd();
262
+ }
263
+
264
+ /**
265
+ * Strip the inline `## Acceptance Criteria` / `## Acceptance` and `## Verify`
266
+ * sections from a Story body. Used to build the `taskInstructions` section
267
+ * when the dedicated `acceptanceCriteria` / `verificationCommands` envelope
268
+ * sections already carry those lists — so the binding acceptance/verify
269
+ * content appears exactly once in the hydrated envelope rather than being
270
+ * duplicated between the dedicated sections and the full task body.
271
+ *
272
+ * Each heading group is stripped only when its own dedicated section was
273
+ * emitted (`acceptance` gates `## Acceptance Criteria` + `## Acceptance`;
274
+ * `verify` gates `## Verify`). This keeps the strip symmetric with what was
275
+ * reproduced elsewhere in the envelope: a `## Verify` section that carried no
276
+ * bullets (so no dedicated `verificationCommands` section fired) is left
277
+ * intact in `taskInstructions` rather than being silently dropped. Both flags
278
+ * default to `true` so a no-argument call preserves the original
279
+ * strip-everything behaviour.
280
+ *
281
+ * @param {string} body
282
+ * @param {{ acceptance?: boolean, verify?: boolean }} [opts]
283
+ * @returns {string}
284
+ */
285
+ export function stripStorySectionsForTaskInstructions(
286
+ body,
287
+ { acceptance = true, verify = true } = {},
288
+ ) {
289
+ if (typeof body !== 'string' || body.length === 0) return body ?? '';
290
+ let out = body;
291
+ if (acceptance) {
292
+ out = stripSection(out, 'Acceptance Criteria');
293
+ out = stripSection(out, 'Acceptance');
294
+ }
295
+ if (verify) {
296
+ out = stripSection(out, 'Verify');
297
+ }
298
+ return out;
299
+ }
300
+
237
301
  /**
238
302
  * Detect whether the dispatched unit is a 2-tier Story (Story is the
239
303
  * leaf, carries inline acceptance/verify) vs. a 4-tier Task (Task is
@@ -351,14 +415,16 @@ async function buildHierarchySections(task, provider, epicId, agentSettings) {
351
415
  const depth = agentSettings?.contextDepth ?? 'standard';
352
416
  const idsToFetch = [];
353
417
 
418
+ // Story #4324 — the context-ticket classes are retired: the Epic body IS
419
+ // the planning document (ideation sections + folded Tech Spec sections).
420
+ // `full` and `standard` fetch the identical Epic / Story pair; both
421
+ // branches are kept so the `contextDepth` setting stays a stable API
422
+ // surface.
354
423
  if (depth === 'full') {
355
424
  idsToFetch.push({ key: 'Epic', id: epicId || hierarchyKeys.epic });
356
- idsToFetch.push({ key: 'PRD', id: hierarchyKeys.prd });
357
- idsToFetch.push({ key: 'Tech Spec', id: hierarchyKeys.techspec });
358
425
  idsToFetch.push({ key: 'Story', id: hierarchyKeys.story });
359
426
  } else if (depth === 'standard') {
360
427
  idsToFetch.push({ key: 'Epic', id: epicId || hierarchyKeys.epic });
361
- idsToFetch.push({ key: 'Tech Spec', id: hierarchyKeys.techspec });
362
428
  idsToFetch.push({ key: 'Story', id: hierarchyKeys.story });
363
429
  } else if (depth === 'minimal') {
364
430
  idsToFetch.push({ key: 'Story', id: hierarchyKeys.story });
@@ -370,7 +436,16 @@ async function buildHierarchySections(task, provider, epicId, agentSettings) {
370
436
  try {
371
437
  const t = await provider.getTicket(item.id);
372
438
  provenance.push(ticketSnapshot(t, retrievedAt));
373
- return `### ${item.key}: ${t.title} (#${t.id})\n\n${t.body}\n`;
439
+ // Hydration section-slicing guardrail (Story #4340): the Epic body
440
+ // is sliced down to only the sections a delivery story agent acts
441
+ // on — Goal / Non-Goals / User Stories / Tech Spec (plus any
442
+ // operator-authored section, fail-open). Ideation / authoring /
443
+ // close machinery (## Context, ## Scope, ## Acceptance Criteria,
444
+ // and the ## Acceptance Table managed region) is dropped so
445
+ // per-story prompt size stays flat versus the pre-fold baseline.
446
+ const body =
447
+ item.key === 'Epic' ? sliceEpicBodyForDelivery(t.body ?? '') : t.body;
448
+ return `### ${item.key}: ${t.title} (#${t.id})\n\n${body}\n`;
374
449
  } catch (err) {
375
450
  const detail = err?.message ? `: ${err.message}` : '';
376
451
  Logger.warn(
@@ -474,9 +549,16 @@ function buildStaticSections(
474
549
  }
475
550
  }
476
551
 
552
+ // Track which dedicated section(s) were emitted so taskInstructions drops
553
+ // only the inline sections that were actually reproduced elsewhere in the
554
+ // envelope — keeping each binding acceptance/verify item present exactly
555
+ // once, without dropping a bulletless section that has no dedicated twin.
556
+ let acceptanceEmitted = false;
557
+ let verifyEmitted = false;
477
558
  if (isTwoTierStoryTask(task)) {
478
559
  const { acceptance, verify } = extractStorySections(task.body ?? '');
479
560
  if (acceptance.length > 0) {
561
+ acceptanceEmitted = true;
480
562
  sections.push({
481
563
  name: 'acceptanceCriteria',
482
564
  priority: DEFAULT_SECTION_PRIORITIES.acceptanceCriteria,
@@ -488,6 +570,7 @@ function buildStaticSections(
488
570
  });
489
571
  }
490
572
  if (verify.length > 0) {
573
+ verifyEmitted = true;
491
574
  sections.push({
492
575
  name: 'verificationCommands',
493
576
  priority: DEFAULT_SECTION_PRIORITIES.verificationCommands,
@@ -500,11 +583,24 @@ function buildStaticSections(
500
583
  }
501
584
  }
502
585
 
586
+ // When a dedicated acceptance/verify section carries that list, strip the
587
+ // matching inline heading(s) from the task body so they are not duplicated
588
+ // in taskInstructions. Each group is gated on its own section: an inline
589
+ // section with no dedicated twin (e.g. a bulletless `## Verify`) is left
590
+ // intact. When neither fired, taskInstructions is byte-identical to the
591
+ // full body.
592
+ const taskBody =
593
+ acceptanceEmitted || verifyEmitted
594
+ ? stripStorySectionsForTaskInstructions(task.body ?? '', {
595
+ acceptance: acceptanceEmitted,
596
+ verify: verifyEmitted,
597
+ })
598
+ : task.body;
503
599
  sections.push({
504
600
  name: 'taskInstructions',
505
601
  priority: DEFAULT_SECTION_PRIORITIES.taskInstructions,
506
602
  elideWhenOverBudget: DEFAULT_ELIDE_POLICIES.taskInstructions,
507
- content: `## Task Instructions (Issue #${task.id}: ${task.title})\n\n${task.body}`,
603
+ content: `## Task Instructions (Issue #${task.id}: ${task.title})\n\n${taskBody}`,
508
604
  source: { kind: 'ticket', ref: String(task.id) },
509
605
  });
510
606
 
@@ -4,6 +4,35 @@ import { getPaths } from '../config-resolver.js';
4
4
  import { Logger } from '../Logger.js';
5
5
  import { applyBudget } from './planning-context-budget.js';
6
6
 
7
+ /**
8
+ * Read an explicit list of doc files relative to `docsRoot`, returning one
9
+ * `{ name, path, content }` object per file that exists and reads cleanly.
10
+ * Missing or unreadable files are skipped silently (mirrors
11
+ * {@link readDocsFromRoot}'s per-file try/catch). Order is preserved from the
12
+ * input list. This is the shared read/normalize seam the per-Epic docs digest
13
+ * builds on (Story #4338) so there is a single home for the fs read path.
14
+ *
15
+ * @param {{ files: string[], docsRoot?: string }} args
16
+ * @returns {Promise<Array<{ name: string, path: string, content: string }>>}
17
+ */
18
+ export async function readDocFiles({ files, docsRoot } = {}) {
19
+ const list = Array.isArray(files) ? files : [];
20
+ const root =
21
+ typeof docsRoot === 'string' && docsRoot.length > 0 ? docsRoot : '.';
22
+ const reads = list.map(async (name) => {
23
+ const full = path.join(root, name);
24
+ try {
25
+ const stat = await fs.promises.stat(full);
26
+ if (!stat.isFile()) return null;
27
+ const content = await fs.promises.readFile(full, 'utf-8');
28
+ return { name, path: name, content };
29
+ } catch (_e) {
30
+ return null;
31
+ }
32
+ });
33
+ return (await Promise.all(reads)).filter(Boolean);
34
+ }
35
+
7
36
  async function readDocsFromRoot(docsRoot, settings) {
8
37
  const explicit =
9
38
  Array.isArray(settings.docsContextFiles) &&
@@ -0,0 +1,134 @@
1
+ /**
2
+ * docs-digest.js — per-Epic docs digest builder (Story #4338).
3
+ *
4
+ * `/deliver` story sub-agents previously re-read every file in
5
+ * `project.docsContextFiles` on every Story, re-paying the full docs payload
6
+ * per child. This module produces a single **digest** — one compact markdown
7
+ * outline per configured doc — that the parent threads into every child prompt
8
+ * once. The digest gives each child enough shape (path, size, heading outline
9
+ * with line numbers, and the first paragraph under each `##` section) to decide
10
+ * which full files to pull on demand, instead of ingesting the whole set up
11
+ * front.
12
+ *
13
+ * The heavy lifting of reading + normalizing doc bodies is delegated to
14
+ * `doc-reader.js` (`readDocFiles`), keeping a single home for the fs read path.
15
+ */
16
+
17
+ import { readDocFiles } from './doc-reader.js';
18
+
19
+ /**
20
+ * Level-2 / level-3 markdown heading matcher. Mirrors the outline granularity
21
+ * the planning-context budget already uses so the two surfaces agree on what a
22
+ * "section" is.
23
+ */
24
+ const HEADING_RE = /^(#{2,3})\s+(.+?)\s*$/;
25
+
26
+ function byteLen(s) {
27
+ if (s == null) return 0;
28
+ return Buffer.byteLength(String(s), 'utf-8');
29
+ }
30
+
31
+ /**
32
+ * Extract the heading outline (level + text + 1-based line number) from a
33
+ * markdown body. Line numbers let a child jump straight to the section it
34
+ * needs when it pulls the full file.
35
+ *
36
+ * @param {string} content
37
+ * @returns {Array<{ level: number, text: string, line: number }>}
38
+ */
39
+ function extractOutline(content) {
40
+ if (!content) return [];
41
+ const lines = String(content).split(/\r?\n/);
42
+ const out = [];
43
+ for (let i = 0; i < lines.length; i++) {
44
+ const m = HEADING_RE.exec(lines[i]);
45
+ if (m) out.push({ level: m[1].length, text: m[2], line: i + 1 });
46
+ }
47
+ return out;
48
+ }
49
+
50
+ /**
51
+ * The first non-empty paragraph that follows a given heading line, up to the
52
+ * next heading or a blank-line paragraph break. Returns '' when the section
53
+ * has no prose (e.g. a heading immediately followed by a sub-heading).
54
+ *
55
+ * @param {string[]} lines full doc split into lines
56
+ * @param {number} headingLine 1-based line of the heading
57
+ * @returns {string}
58
+ */
59
+ function firstParagraphAfter(lines, headingLine) {
60
+ const para = [];
61
+ for (let i = headingLine; i < lines.length; i++) {
62
+ const line = lines[i];
63
+ if (HEADING_RE.test(line)) break;
64
+ if (line.trim() === '') {
65
+ if (para.length > 0) break;
66
+ continue;
67
+ }
68
+ para.push(line.trim());
69
+ }
70
+ return para.join(' ').trim();
71
+ }
72
+
73
+ /**
74
+ * Render one doc's digest section: path + byte size, then a bulleted heading
75
+ * outline where each `##` bullet carries the first paragraph beneath it.
76
+ *
77
+ * @param {{ path: string, content: string }} doc
78
+ * @returns {string} markdown block
79
+ */
80
+ function renderDocSection(doc) {
81
+ const content = typeof doc.content === 'string' ? doc.content : '';
82
+ const lines = content.split(/\r?\n/);
83
+ const outline = extractOutline(content);
84
+ const size = byteLen(content);
85
+
86
+ const parts = [`### \`${doc.path}\` (${size} bytes)`, ''];
87
+ if (outline.length === 0) {
88
+ parts.push('_No `##`/`###` headings._', '');
89
+ return parts.join('\n');
90
+ }
91
+
92
+ for (const h of outline) {
93
+ const indent = h.level === 3 ? ' ' : '';
94
+ parts.push(`${indent}- L${h.line} \`${'#'.repeat(h.level)}\` ${h.text}`);
95
+ if (h.level === 2) {
96
+ const para = firstParagraphAfter(lines, h.line);
97
+ if (para) parts.push(`${indent} ${para}`);
98
+ }
99
+ }
100
+ parts.push('');
101
+ return parts.join('\n');
102
+ }
103
+
104
+ /**
105
+ * Build the per-Epic docs digest markdown from the configured docs context
106
+ * files. Missing files are skipped silently (the read seam returns only the
107
+ * files it could stat + read). Returns `null` when there is nothing to digest
108
+ * — i.e. `docsContextFiles` is empty/unset — so callers surface a null
109
+ * `docsDigestPath` rather than writing an empty file.
110
+ *
111
+ * @param {{ docsContextFiles?: string[], docsRoot?: string }} args
112
+ * @returns {Promise<string|null>} the digest markdown, or null when there are
113
+ * no files to digest.
114
+ */
115
+ export async function buildDocsDigest({ docsContextFiles, docsRoot } = {}) {
116
+ const files = Array.isArray(docsContextFiles) ? docsContextFiles : [];
117
+ if (files.length === 0) return null;
118
+
119
+ const docs = await readDocFiles({ files, docsRoot });
120
+ if (docs.length === 0) return null;
121
+
122
+ const header = [
123
+ '# Docs digest',
124
+ '',
125
+ 'Per-Epic outline of the project docs context set. Each entry lists the',
126
+ 'file path, byte size, and its heading outline (with line numbers) plus',
127
+ 'the first paragraph under each `##` section. Read the full file on demand',
128
+ 'when a section looks relevant — do **not** ingest the whole set per Story.',
129
+ '',
130
+ ].join('\n');
131
+
132
+ const sections = docs.map(renderDocSection).join('\n');
133
+ return `${header}\n${sections}`.replace(/\n+$/, '\n');
134
+ }
@@ -1,9 +1,11 @@
1
1
  /**
2
2
  * context.js — Phase 3 of the epic-plan-decompose pipeline (Story #2466).
3
3
  *
4
- * Builds the authoring context (PRD + Tech Spec bodies, heuristics, system
5
- * prompt, ticket cap) the host LLM / `epic-plan-decompose-author` Skill
6
- * consumes when producing the ticket JSON array.
4
+ * Builds the authoring context (the Epic body which carries the folded
5
+ * Tech Spec sections and Acceptance Table per Story #4324 — plus
6
+ * heuristics, system prompt, ticket cap) the host LLM /
7
+ * `epic-plan-decompose-author` Skill consumes when producing the ticket
8
+ * JSON array.
7
9
  *
8
10
  * Extracted verbatim from `epic-plan-decompose.js`; both
9
11
  * `buildDecomposerSystemPrompt` and `buildDecompositionContext` retain
@@ -16,6 +18,7 @@ import {
16
18
  getLimits,
17
19
  resolvePreflightCeilings,
18
20
  } from '../../../config-resolver.js';
21
+ import { hasTechSpecContent } from '../../../epic-body-sections.js';
19
22
  import { renderDecomposerSystemPrompt } from '../../../templates/decomposer-prompts.js';
20
23
  import { read as readPlanState } from '../../epic-plan-state-store.js';
21
24
  import { applyBudget } from '../../planning-context-budget.js';
@@ -83,27 +86,25 @@ async function readPlanningDecision(provider, epicId) {
83
86
  };
84
87
  }
85
88
 
86
- async function fetchPlanningTickets(provider, epicId) {
89
+ async function fetchPlanningEpic(provider, epicId) {
87
90
  const epic = await provider.getEpic(epicId);
88
- if (!epic?.linkedIssues?.prd || !epic.linkedIssues.techSpec) {
91
+ if (!epic || !hasTechSpecContent(epic.body ?? '')) {
89
92
  throw new Error(
90
- `[Decomposer] Epic #${epicId} is missing linked PRD or Tech Spec. Run the Epic Planner first.`,
93
+ `[Decomposer] Epic #${epicId} body carries no Tech Spec sections (no ## Delivery Slicing). Run the Epic Planner (Phase 7) first.`,
91
94
  );
92
95
  }
93
- const [prd, techSpec] = await Promise.all([
94
- provider.getTicket(epic.linkedIssues.prd),
95
- provider.getTicket(epic.linkedIssues.techSpec),
96
- ]);
97
- return { epic, prd, techSpec };
96
+ return { epic };
98
97
  }
99
98
 
100
99
  /**
101
100
  * Build the authoring context the host LLM (or the
102
101
  * `epic-plan-decompose-author` Skill) needs to produce the ticket JSON.
103
102
  *
104
- * PRD and Tech Spec bodies are bounded by the planning-context budget
105
- * (Epic #817 Story 9). Pass `{ fullContext: true }` (CLI: `--full-context`)
106
- * to restore the unbounded full bodies.
103
+ * The Epic body (ideation sections + folded Tech Spec sections +
104
+ * Acceptance Table the AC-ID source for wave-0 BDD scaffold tags) is
105
+ * bounded by the planning-context budget (Epic #817 Story 9). Pass
106
+ * `{ fullContext: true }` (CLI: `--full-context`) to restore the
107
+ * unbounded full body.
107
108
  */
108
109
  export async function buildDecompositionContext(
109
110
  epicId,
@@ -111,7 +112,7 @@ export async function buildDecompositionContext(
111
112
  config = {},
112
113
  opts = {},
113
114
  ) {
114
- const { epic, prd, techSpec } = await fetchPlanningTickets(provider, epicId);
115
+ const { epic } = await fetchPlanningEpic(provider, epicId);
115
116
  const { planningRisk, reviewRouting } = await readPlanningDecision(
116
117
  provider,
117
118
  epicId,
@@ -129,18 +130,18 @@ export async function buildDecompositionContext(
129
130
  });
130
131
 
131
132
  const budgeted = applyBudget(
132
- [
133
- { path: `prd-${prd.id}.md`, content: prd.body ?? '' },
134
- { path: `tech-spec-${techSpec.id}.md`, content: techSpec.body ?? '' },
135
- ],
133
+ [{ path: `epic-${epic.id}.md`, content: epic.body ?? '' }],
136
134
  planningLimits,
137
135
  { fullContext },
138
136
  );
139
- const [prdItem, techSpecItem] = budgeted.items;
137
+ const [epicItem] = budgeted.items;
140
138
  return {
141
139
  epic: { id: epic.id, title: epic.title },
142
- prd: projectBudgetedEntry(prdItem, prd, budgeted.mode),
143
- techSpec: projectBudgetedEntry(techSpecItem, techSpec, budgeted.mode),
140
+ // Story #4324 — the Epic body is the single planning document: it
141
+ // carries the ideation sections, the folded Tech Spec sections
142
+ // (## Delivery Slicing first), and the ## Acceptance Table the wave-0
143
+ // BDD scaffold reads its AC IDs from.
144
+ epicBody: projectBudgetedEntry(epicItem, epic, budgeted.mode),
144
145
  heuristics,
145
146
  systemPrompt,
146
147
  maxTickets,