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.
- package/.agents/README.md +46 -5
- package/.agents/docs/SDLC.md +97 -82
- package/.agents/docs/agentrc-reference.json +10 -2
- package/.agents/docs/configuration.md +4 -1
- package/.agents/docs/execution-reference.md +52 -0
- package/.agents/docs/workflows.md +1 -1
- package/.agents/instructions.md +85 -45
- package/.agents/personas/architect.md +8 -5
- package/.agents/personas/engineer-mobile.md +3 -2
- package/.agents/personas/engineer-web.md +3 -2
- package/.agents/personas/engineer.md +6 -5
- package/.agents/personas/product.md +19 -13
- package/.agents/personas/project-manager.md +9 -8
- package/.agents/personas/qa-engineer.md +10 -6
- package/.agents/personas/refactorer.md +3 -2
- package/.agents/personas/technical-writer.md +2 -1
- package/.agents/personas/ux-designer.md +2 -2
- package/.agents/schemas/agentrc.schema.json +41 -3
- package/.agents/schemas/qa-ledger.schema.json +2 -2
- package/.agents/scripts/acceptance-spec-reconciler.js +143 -59
- package/.agents/scripts/epic-deliver-prepare.js +40 -31
- package/.agents/scripts/epic-plan-decompose.js +2 -5
- package/.agents/scripts/epic-plan-spec.js +16 -19
- package/.agents/scripts/hierarchy-gate.js +11 -11
- package/.agents/scripts/lib/ITicketingProvider.js +4 -3
- package/.agents/scripts/lib/bdd-runner-detect.js +1 -1
- package/.agents/scripts/lib/bdd-scenario-scanner.js +1 -1
- package/.agents/scripts/lib/cli-args.js +1 -5
- package/.agents/scripts/lib/codebase-snapshot.js +1 -1
- package/.agents/scripts/lib/config/explain.js +4 -1
- package/.agents/scripts/lib/config/temp-paths.js +1 -4
- package/.agents/scripts/lib/config-settings-schema.js +30 -1
- package/.agents/scripts/lib/epic-body-sections.js +310 -0
- package/.agents/scripts/lib/epic-plan-clarity.js +38 -1
- package/.agents/scripts/lib/epic-plan-ideation.js +15 -3
- package/.agents/scripts/lib/findings/promote-finding.js +3 -3
- package/.agents/scripts/lib/findings/severity.js +5 -6
- package/.agents/scripts/lib/label-constants.js +7 -17
- package/.agents/scripts/lib/label-taxonomy.js +4 -21
- package/.agents/scripts/lib/orchestration/check-baselines/phases/evaluate.js +65 -2
- package/.agents/scripts/lib/orchestration/context-hydration-engine.js +105 -9
- package/.agents/scripts/lib/orchestration/doc-reader.js +29 -0
- package/.agents/scripts/lib/orchestration/docs-digest.js +134 -0
- package/.agents/scripts/lib/orchestration/epic-plan-decompose/phases/context.js +23 -22
- package/.agents/scripts/lib/orchestration/epic-plan-decompose/phases/persist-helpers.js +7 -10
- package/.agents/scripts/lib/orchestration/epic-plan-decompose/phases/planning-artifacts.js +4 -38
- package/.agents/scripts/lib/orchestration/epic-plan-lease-guard.js +8 -9
- package/.agents/scripts/lib/orchestration/epic-plan-spec/phases/authoring-context.js +11 -5
- package/.agents/scripts/lib/orchestration/epic-plan-spec/phases/cli-args.js +26 -5
- package/.agents/scripts/lib/orchestration/epic-plan-spec/phases/plan-epic.js +102 -304
- package/.agents/scripts/lib/orchestration/epic-plan-spec/phases/prompts.js +32 -29
- package/.agents/scripts/lib/orchestration/epic-plan-spec/phases/run-spec-phase.js +19 -20
- package/.agents/scripts/lib/orchestration/epic-plan-spec/phases/spec-authoring-grounding.js +1 -1
- package/.agents/scripts/lib/orchestration/epic-plan-spec/phases/spec-freshness.js +6 -9
- package/.agents/scripts/lib/orchestration/epic-plan-state-store.js +3 -4
- package/.agents/scripts/lib/orchestration/epic-runner/phases/build-wave-dag.js +1 -1
- package/.agents/scripts/lib/orchestration/epic-runner/phases/snapshot.js +20 -27
- package/.agents/scripts/lib/orchestration/lifecycle/listeners/acceptance-reconciler.js +11 -5
- package/.agents/scripts/lib/orchestration/lifecycle/listeners/finalizer.js +22 -59
- package/.agents/scripts/lib/orchestration/lifecycle/listeners/index.js +1 -1
- package/.agents/scripts/lib/orchestration/planning-context-budget.js +1 -1
- package/.agents/scripts/lib/orchestration/preflight-cache.js +1 -1
- package/.agents/scripts/lib/orchestration/spec-freshness.js +3 -3
- package/.agents/scripts/lib/orchestration/spec-section-validator.js +1 -1
- package/.agents/scripts/lib/orchestration/story-close/baseline-attribution/phases/refresh-commit.js +15 -1
- package/.agents/scripts/lib/orchestration/ticket-validator-sizing.js +122 -1
- package/.agents/scripts/lib/orchestration/ticketing/bulk.js +5 -8
- package/.agents/scripts/lib/orchestration/ticketing/reads.js +2 -2
- package/.agents/scripts/lib/plan-phase-cleanup.js +1 -2
- package/.agents/scripts/lib/qa/console-allowlist.js +5 -4
- package/.agents/scripts/lib/qa/qa-context-hydrator.js +6 -85
- package/.agents/scripts/lib/qa/resolve-qa-contract.js +144 -8
- package/.agents/scripts/lib/templates/decomposer-prompts.js +14 -8
- package/.agents/scripts/lifecycle-emit.js +1 -1
- package/.agents/scripts/lint-label-vocabulary.js +2 -3
- package/.agents/scripts/providers/github/mappers.js +0 -3
- package/.agents/scripts/providers/github/tickets.js +7 -18
- package/.agents/scripts/single-story-init.js +0 -1
- package/.agents/scripts/story-init.js +1 -29
- package/.agents/skills/core/epic-plan-consolidate/SKILL.md +43 -22
- package/.agents/skills/core/epic-plan-consolidate/examples.md +51 -0
- package/.agents/skills/core/epic-plan-decompose-author/SKILL.md +27 -40
- package/.agents/skills/core/epic-plan-decompose-author/examples.md +47 -0
- package/.agents/skills/core/epic-plan-premortem/SKILL.md +15 -13
- package/.agents/skills/core/epic-plan-premortem/examples.md +53 -0
- package/.agents/skills/core/epic-plan-spec-author/SKILL.md +143 -151
- package/.agents/skills/core/epic-plan-spec-author/examples.md +91 -0
- package/.agents/skills/core/hydrate-context/SKILL.md +10 -5
- package/.agents/skills/core/knowledge-transfer/SKILL.md +3 -2
- package/.agents/skills/core/scope-triage/SKILL.md +2 -1
- package/.agents/skills/skills.index.json +8 -8
- package/.agents/skills/stack/qa/qa-explore-driving/SKILL.md +52 -38
- package/.agents/templates/epic-from-idea.md +4 -0
- package/.agents/workflows/audit-to-stories.md +2 -2
- package/.agents/workflows/helpers/code-review.md +11 -9
- package/.agents/workflows/helpers/deliver-epic-reference.md +514 -0
- package/.agents/workflows/helpers/deliver-epic.md +173 -490
- package/.agents/workflows/helpers/epic-audit.md +11 -8
- package/.agents/workflows/helpers/epic-deliver-story.md +45 -27
- package/.agents/workflows/helpers/epic-plan-decompose.md +17 -12
- package/.agents/workflows/helpers/epic-plan-spec.md +68 -68
- package/.agents/workflows/helpers/parallel-tooling.md +2 -1
- package/.agents/workflows/helpers/plan-epic-reference.md +136 -0
- package/.agents/workflows/helpers/plan-epic.md +141 -256
- package/.agents/workflows/helpers/plan-story.md +31 -61
- package/.agents/workflows/helpers/qa-run-scenario.md +194 -0
- package/.agents/workflows/helpers/scope-triage-gate.md +97 -0
- package/.agents/workflows/helpers/single-story-deliver-reference.md +423 -0
- package/.agents/workflows/helpers/single-story-deliver.md +129 -393
- package/.agents/workflows/helpers/worktree-lifecycle.md +1 -1
- package/.agents/workflows/plan.md +8 -8
- package/.agents/workflows/qa-assist.md +2 -1
- package/.agents/workflows/qa-explore.md +63 -32
- package/.agents/workflows/qa-run.md +293 -130
- package/docs/CHANGELOG.md +35 -0
- package/package.json +1 -1
- package/.agents/schemas/qa-finding.schema.json +0 -133
- package/.agents/scripts/lib/issue-link-parser.js +0 -74
- package/.agents/scripts/lib/orchestration/finalize/close-planning-tickets.js +0 -116
- package/.agents/scripts/lib/orchestration/planning-state-manager.js +0 -318
- 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-
|
|
9
|
-
*
|
|
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
|
|
18
|
-
*
|
|
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
|
|
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
|
|
107
|
-
* intentionally have no acceptance-
|
|
108
|
-
* `context
|
|
109
|
-
*
|
|
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
|
|
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 —
|
|
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
|
-
//
|
|
102
|
-
|
|
103
|
-
|
|
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 {
|
|
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
|
|
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
|
|
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
|
-
|
|
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${
|
|
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 (
|
|
5
|
-
*
|
|
6
|
-
*
|
|
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
|
|
89
|
+
async function fetchPlanningEpic(provider, epicId) {
|
|
87
90
|
const epic = await provider.getEpic(epicId);
|
|
88
|
-
if (!epic
|
|
91
|
+
if (!epic || !hasTechSpecContent(epic.body ?? '')) {
|
|
89
92
|
throw new Error(
|
|
90
|
-
`[Decomposer] Epic #${epicId}
|
|
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
|
-
|
|
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
|
-
*
|
|
105
|
-
*
|
|
106
|
-
*
|
|
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
|
|
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 [
|
|
137
|
+
const [epicItem] = budgeted.items;
|
|
140
138
|
return {
|
|
141
139
|
epic: { id: epic.id, title: epic.title },
|
|
142
|
-
|
|
143
|
-
|
|
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,
|