mandrel 1.78.0 → 1.79.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/scripts/lib/orchestration/epic-plan-decompose/phases/context.js +7 -2
- package/.agents/scripts/lib/orchestration/epic-spec-reconciler-diff.js +15 -36
- package/.agents/scripts/lib/story-lifecycle.js +12 -4
- package/.agents/scripts/lib/templates/decomposer-prompts.js +14 -5
- package/.agents/skills/core/epic-plan-decompose-author/SKILL.md +10 -7
- package/docs/CHANGELOG.md +8 -0
- package/package.json +1 -1
|
@@ -22,9 +22,13 @@ import { applyBudget } from '../../planning-context-budget.js';
|
|
|
22
22
|
|
|
23
23
|
export function buildDecomposerSystemPrompt(
|
|
24
24
|
heuristics = [],
|
|
25
|
-
{ maxTickets, maxTokenBudget } = {},
|
|
25
|
+
{ maxTickets, maxTokenBudget, epicId } = {},
|
|
26
26
|
) {
|
|
27
|
-
const base = renderDecomposerSystemPrompt({
|
|
27
|
+
const base = renderDecomposerSystemPrompt({
|
|
28
|
+
maxTickets,
|
|
29
|
+
maxTokenBudget,
|
|
30
|
+
epicId,
|
|
31
|
+
});
|
|
28
32
|
const heuristicsStr =
|
|
29
33
|
heuristics.length > 0
|
|
30
34
|
? `### RISK HEURISTICS (planning metadata if any apply):\n- ${heuristics.join('\n- ')}`
|
|
@@ -121,6 +125,7 @@ export async function buildDecompositionContext(
|
|
|
121
125
|
const systemPrompt = buildDecomposerSystemPrompt(heuristics, {
|
|
122
126
|
maxTickets,
|
|
123
127
|
maxTokenBudget,
|
|
128
|
+
epicId,
|
|
124
129
|
});
|
|
125
130
|
|
|
126
131
|
const budgeted = applyBudget(
|
|
@@ -80,6 +80,7 @@
|
|
|
80
80
|
* issue with an empty body.
|
|
81
81
|
*/
|
|
82
82
|
|
|
83
|
+
import { composeStoryBody } from '../../providers/github/tickets.js';
|
|
83
84
|
import { assertPlanLabelAllowList } from './epic-spec-reconciler-discriminator.js';
|
|
84
85
|
import {
|
|
85
86
|
closeOp,
|
|
@@ -208,34 +209,6 @@ function stripFooter(body) {
|
|
|
208
209
|
return value.replace(ORCHESTRATOR_FOOTER_RE, '').replace(/\s+$/, '');
|
|
209
210
|
}
|
|
210
211
|
|
|
211
|
-
/**
|
|
212
|
-
* Render the canonical orchestrator footer (no leading newline). Format
|
|
213
|
-
* matches the byte-stable shape that the cascade-reading consumers
|
|
214
|
-
* (story-init, dispatcher, manifest, close-gate) parse line-anchored:
|
|
215
|
-
*
|
|
216
|
-
* ---
|
|
217
|
-
* parent: #<parentId>
|
|
218
|
-
* [Epic: #<epicId>] // only when epicId !== parentId
|
|
219
|
-
*
|
|
220
|
-
* [blocked by #<dep>] // one per dependency
|
|
221
|
-
*
|
|
222
|
-
* @param {{parentId: number, epicId?: number, dependencies?: number[]}} opts
|
|
223
|
-
* @returns {string}
|
|
224
|
-
*/
|
|
225
|
-
function renderFooter({ parentId, epicId, dependencies = [] }) {
|
|
226
|
-
const lines = ['---', `parent: #${parentId}`];
|
|
227
|
-
if (epicId !== undefined && epicId !== null && epicId !== parentId) {
|
|
228
|
-
lines.push(`Epic: #${epicId}`);
|
|
229
|
-
}
|
|
230
|
-
if (dependencies.length > 0) {
|
|
231
|
-
lines.push('');
|
|
232
|
-
for (const dep of dependencies) {
|
|
233
|
-
lines.push(`blocked by #${dep}`);
|
|
234
|
-
}
|
|
235
|
-
}
|
|
236
|
-
return lines.join('\n');
|
|
237
|
-
}
|
|
238
|
-
|
|
239
212
|
/**
|
|
240
213
|
* Compose the canonical orchestrator footer onto a spec body for non-epic
|
|
241
214
|
* entities. Resolves `parentSlug`/`dependsOn` slugs against the running
|
|
@@ -246,12 +219,19 @@ function renderFooter({ parentId, epicId, dependencies = [] }) {
|
|
|
246
219
|
* the YAML spec writes just the description, silently stripping
|
|
247
220
|
* `parent: #N` / `Epic: #M` / `blocked by #X` and breaking the cascade.
|
|
248
221
|
*
|
|
249
|
-
* Story #
|
|
250
|
-
*
|
|
251
|
-
*
|
|
252
|
-
*
|
|
253
|
-
*
|
|
254
|
-
*
|
|
222
|
+
* Story #4300 — the footer rendering is single-sourced from
|
|
223
|
+
* `composeStoryBody` (`providers/github/tickets.js`), the same helper the
|
|
224
|
+
* CREATE path (`epic-spec-reconciler-apply.js` → `provider.createTicket`)
|
|
225
|
+
* uses. Story #3185 previously inlined a parallel `renderFooter` here to
|
|
226
|
+
* avoid depending on the (now-removed) legacy Task-body renderer; that
|
|
227
|
+
* inlined copy silently diverged from `composeStoryBody` by gating the
|
|
228
|
+
* `Epic: #<id>` line on `epicId !== parentId` — a 3-tier-era condition
|
|
229
|
+
* that is always false under the 2-tier hierarchy (a Story's parent IS
|
|
230
|
+
* the Epic), so force re-decompose (`/plan --force`, which routes through
|
|
231
|
+
* this UPDATE path) silently dropped `Epic: #<id>` from every refreshed
|
|
232
|
+
* Story body and broke `story-init.js`'s hierarchy resolution. Importing
|
|
233
|
+
* `composeStoryBody` directly makes that divergence structurally
|
|
234
|
+
* impossible going forward.
|
|
255
235
|
*
|
|
256
236
|
* @param {{entity: string, parentSlug?: string|null, dependsOn?: string[]}} specEntity
|
|
257
237
|
* @param {string} specBody
|
|
@@ -284,8 +264,7 @@ function composeBodyWithFooter(specEntity, specBody, ctx) {
|
|
|
284
264
|
// included) or emits a canonical-form body. With the strip, the
|
|
285
265
|
// function is idempotent against its own output.
|
|
286
266
|
const head = stripFooter(specBody);
|
|
287
|
-
|
|
288
|
-
return `${head}\n\n${footer}`;
|
|
267
|
+
return composeStoryBody({ body: head, parentId, epicId, dependencies });
|
|
289
268
|
}
|
|
290
269
|
|
|
291
270
|
/**
|
|
@@ -23,6 +23,15 @@ import {
|
|
|
23
23
|
/**
|
|
24
24
|
* Parse the `Epic: #N` and `parent: #N` references from a Story body.
|
|
25
25
|
*
|
|
26
|
+
* Story #4300 (defense-in-depth): under the 2-tier hierarchy
|
|
27
|
+
* (Epic → Story) a Story's `parent: #N` marker always IS the parent
|
|
28
|
+
* Epic, so when the `Epic: #N` line is missing — e.g. a Story body
|
|
29
|
+
* refreshed by the reconciler's UPDATE op before Story #4300's
|
|
30
|
+
* single-sourced footer rendering landed — `epicId` falls back to the
|
|
31
|
+
* resolved `parentId` rather than reporting `null` and aborting
|
|
32
|
+
* delivery. A body that carries neither marker still resolves
|
|
33
|
+
* `epicId: null` (no parent to fall back to).
|
|
34
|
+
*
|
|
26
35
|
* @param {string} body Raw Story body Markdown.
|
|
27
36
|
* @returns {{ epicId: number|null, parentId: number|null }}
|
|
28
37
|
*/
|
|
@@ -30,10 +39,9 @@ export function resolveStoryHierarchy(body) {
|
|
|
30
39
|
const source = body ?? '';
|
|
31
40
|
const epicMatch = source.match(/(?:^\s*epic:\s*#(\d+))/im);
|
|
32
41
|
const parentMatch = source.match(/(?:^\s*parent:\s*#(\d+))/im);
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
};
|
|
42
|
+
const parentId = parentMatch ? Number.parseInt(parentMatch[1], 10) : null;
|
|
43
|
+
const epicId = epicMatch ? Number.parseInt(epicMatch[1], 10) : parentId;
|
|
44
|
+
return { epicId, parentId };
|
|
37
45
|
}
|
|
38
46
|
|
|
39
47
|
/**
|
|
@@ -36,8 +36,9 @@ import {
|
|
|
36
36
|
export function renderDecomposerSystemPrompt({
|
|
37
37
|
maxTickets = LIMITS_DEFAULTS.maxTickets,
|
|
38
38
|
maxTokenBudget = LIMITS_DEFAULTS.maxTokenBudget,
|
|
39
|
+
epicId = null,
|
|
39
40
|
} = {}) {
|
|
40
|
-
return render2TierPrompt({ maxTickets, maxTokenBudget });
|
|
41
|
+
return render2TierPrompt({ maxTickets, maxTokenBudget, epicId });
|
|
41
42
|
}
|
|
42
43
|
|
|
43
44
|
/**
|
|
@@ -46,7 +47,7 @@ export function renderDecomposerSystemPrompt({
|
|
|
46
47
|
* on the Story body so the executing agent has everything it needs in one
|
|
47
48
|
* ticket. Thematic grouping lives as prose in the Epic body / Tech Spec.
|
|
48
49
|
*/
|
|
49
|
-
function render2TierPrompt({ maxTickets, maxTokenBudget }) {
|
|
50
|
+
function render2TierPrompt({ maxTickets, maxTokenBudget, epicId = null }) {
|
|
50
51
|
// Sizing thresholds are sourced from the single DEFAULT_TASK_SIZING constant
|
|
51
52
|
// (ticket-validator-sizing.js) so the prompt and the validator cannot drift.
|
|
52
53
|
const { softFiles, hardFiles, maxAcceptance, softAcceptanceCount } =
|
|
@@ -66,6 +67,13 @@ function render2TierPrompt({ maxTickets, maxTokenBudget }) {
|
|
|
66
67
|
advisoryCaveat,
|
|
67
68
|
newFileContract,
|
|
68
69
|
} = AUTHORING_ALTITUDE_GUIDANCE;
|
|
70
|
+
// The namespaced AC-tag token the wave-0 BDD scaffold section below must
|
|
71
|
+
// require on every scaffolded scenario (Story #4301). When the Epic ID is
|
|
72
|
+
// known at render time, interpolate the concrete tag so the author has no
|
|
73
|
+
// placeholder to get wrong; otherwise fall back to the documented pattern.
|
|
74
|
+
const acTagExample = Number.isInteger(epicId)
|
|
75
|
+
? `@epic-${epicId}-ac-1`
|
|
76
|
+
: '@epic-<id>-ac-N';
|
|
69
77
|
return `You are an expert Senior Project Manager and Orchestrator.
|
|
70
78
|
Your job is to take a Product Requirements Document (PRD) and a Technical Specification and decompose them into a flat list of Story tickets for an AI Agent to execute.
|
|
71
79
|
|
|
@@ -96,7 +104,7 @@ You MUST respond ONLY with a valid JSON array of objects. No prose, no markdown
|
|
|
96
104
|
}
|
|
97
105
|
]
|
|
98
106
|
|
|
99
|
-
**Slug format**: \`^[a-z0-9][a-z0-9-]
|
|
107
|
+
**Slug format**: \`^[a-z0-9][a-z0-9-]*$\` — hyphen-case only. Underscores are rejected by the validator.
|
|
100
108
|
|
|
101
109
|
### STORY BODY SCHEMA (REQUIRED FOR EVERY STORY):
|
|
102
110
|
\`body\` MUST be a **string** — the serialized markdown produced by \`serialize()\` from \`lib/story-body/story-body.js\`. Do NOT emit \`body\` as a JSON object: an object body throws \`StoryBodyParseError\` in the reconciler (Story #3302) and is discarded by the GitHub provider, producing an empty issue body. Stories are consumed by non-interactive sub-agents that must self-verify from the Story ticket alone — so the ticket must carry everything an agent needs to execute and self-verify.
|
|
@@ -196,8 +204,9 @@ When the Acceptance Spec contains **one or more \`Disposition: new\` rows**, you
|
|
|
196
204
|
- **goal**: contains the literal token \`bdd-scaffold\` (e.g. "bdd-scaffold: create the @skip-tagged feature files the implementation Stories verify against").
|
|
197
205
|
- **depends_on**: EMPTY (\`[]\`) — it runs first, in wave 0.
|
|
198
206
|
- **changes**: one entry per distinct \`.feature\` file named in a \`new\` row, each \`{ "path": "<feature file path>", "assumption": "creates" }\`.
|
|
199
|
-
- **acceptance**: MUST assert (a) every new \`.feature\` file exists,
|
|
200
|
-
- **
|
|
207
|
+
- **acceptance**: MUST assert (a) every new \`.feature\` file exists, (b) every new scenario within them carries an \`@skip\` tag, AND (c) every new scenario also carries its **namespaced per-Epic AC tag** \`${acTagExample}\` (one tag per AC ID the scenario satisfies — see below). Keep these observable (a grep/validate command exits 0, a file exists at a path).
|
|
208
|
+
- **Namespaced AC tag is REQUIRED at scaffold time, not only at de-skip time.** Phase 7 finalize's \`acceptance-spec-reconciler.js\` matches AC IDs only against \`@epic-<id>-ac-*\` / \`@pending\` tags in \`tests/features/**\` — a bare \`@ac-N\` tag is deliberately ignored to prevent cross-Epic collision. A scaffolded scenario that carries \`@skip\` but omits \`@epic-<id>-ac-N\` reads as \`missing[]\` at finalize and aborts the close even after the implementation Story de-skips it, because the tag was never added. Tag each scenario with both \`@skip\` AND \`${acTagExample}\` (substituting the AC's own number) in this SAME wave-0 pass — do not defer the AC tag to the later de-skip edit.
|
|
209
|
+
- **verify**: a grep/validate command (tier \`validate\`), NOT an e2e runner — verifying that a file exists with the required tags needs no browser/playwright run. Example: \`grep -rL '@skip' tests/features/<area>/*.feature (validate)\` paired with an existence check, AND a check that every new AC ID's namespaced tag (\`${acTagExample}\`) appears in the scaffolded files, e.g. \`grep -q '${acTagExample}' tests/features/<area>/<file>.feature (validate)\` for each new AC row.
|
|
201
210
|
- Each implementation Story whose \`verify[]\` references one of these scaffolded \`.feature\` paths MUST \`depends_on\` the scaffold Story (so the scaffold lands in an earlier wave). Omitting the link trips the soft \`missing-bdd-scaffold\` validator finding.
|
|
202
211
|
|
|
203
212
|
When the Acceptance Spec contains **zero \`new\`-disposition rows** (every row is \`updated\` or \`unchanged\`), do NOT emit a scaffold Story — there is nothing to create.
|
|
@@ -290,13 +290,14 @@ When the Acceptance Spec contains **one or more `Disposition: new` rows**, you M
|
|
|
290
290
|
- **goal** (in body string): contains the literal token `bdd-scaffold`.
|
|
291
291
|
- **depends_on**: EMPTY (`[]`) — the scaffold runs first, in wave 0.
|
|
292
292
|
- **changes** (in body string): one `{ path, assumption: "creates" }` entry per distinct `.feature` file named in a `new` row.
|
|
293
|
-
- **acceptance** (top-level array): MUST assert (a) every new `.feature` file exists,
|
|
294
|
-
- **
|
|
293
|
+
- **acceptance** (top-level array): MUST assert (a) every new `.feature` file exists, (b) every new scenario within them carries an `@skip` tag, AND (c) every new scenario also carries its **namespaced per-Epic AC tag** `@epic-<id>-ac-N` (one tag per AC ID the scenario satisfies). Keep items observable (a command exits 0; a file exists at a path).
|
|
294
|
+
- **The namespaced AC tag is REQUIRED at scaffold time, not only at de-skip time.** Phase 7 finalize's `acceptance-spec-reconciler.js` matches AC IDs only against `@epic-<id>-ac-*` / `@pending` tags under `tests/features/**` — a bare `@ac-N` tag is deliberately ignored to prevent cross-Epic collision (Story #3362). A scaffolded scenario carrying `@skip` but no `@epic-<id>-ac-N` tag reads as `missing[]` at finalize and throws, aborting close, even after the implementation Story de-skips it — the tag was never added in either pass. Tag each scenario with both `@skip` AND `@epic-<id>-ac-N` (substituting the Epic's real ID and the scenario's own AC number) in this SAME wave-0 commit; do not defer the AC tag to the later de-skip edit.
|
|
295
|
+
- **verify** (top-level array): a grep/validate command (tier `validate`), NOT an e2e runner — verifying that a file exists with the required tags needs no browser/playwright run. Include a check that each new AC ID's namespaced tag is present in the scaffolded files, alongside the `@skip` check.
|
|
295
296
|
- Each implementation Story whose `verify[]` references a scaffolded `.feature` path MUST add `depends_on: ["<scaffold-slug>"]` so the scaffold lands in an earlier wave. Omitting the link trips the soft `missing-bdd-scaffold` finding in `ticket-validator-conflicts.js` (advisory, not a hard block).
|
|
296
297
|
|
|
297
298
|
When the Acceptance Spec contains **zero `new`-disposition rows** (every row is `updated` or `unchanged`), do NOT emit a scaffold Story — there is nothing to create.
|
|
298
299
|
|
|
299
|
-
**Worked example.** Acceptance Spec with two `new` rows (`AC-1` -> `tests/features/billing/invoice.feature`, `AC-2` -> `tests/features/billing/refund.feature`). The scaffold Story below uses a serialized string `body`, top-level `acceptance`/`verify` arrays,
|
|
300
|
+
**Worked example.** Epic #42, Acceptance Spec with two `new` rows (`AC-1` -> `tests/features/billing/invoice.feature`, `AC-2` -> `tests/features/billing/refund.feature`). The scaffold Story below uses a serialized string `body`, top-level `acceptance`/`verify` arrays, an empty `depends_on`, and tags each scenario with both `@skip` and its namespaced `@epic-42-ac-N` tag:
|
|
300
301
|
|
|
301
302
|
{
|
|
302
303
|
"slug": "scaffold-billing-feature-files",
|
|
@@ -306,16 +307,18 @@ When the Acceptance Spec contains **zero `new`-disposition rows** (every row is
|
|
|
306
307
|
"labels": ["type::story", "persona::qa-engineer"],
|
|
307
308
|
"acceptance": [
|
|
308
309
|
"tests/features/billing/invoice.feature and tests/features/billing/refund.feature both exist on the branch",
|
|
309
|
-
"every Scenario in the two new feature files is preceded by an @skip tag (grep for un-skipped scenarios returns zero matches)"
|
|
310
|
+
"every Scenario in the two new feature files is preceded by an @skip tag (grep for un-skipped scenarios returns zero matches)",
|
|
311
|
+
"the invoice.feature scenario carries @epic-42-ac-1 and the refund.feature scenario carries @epic-42-ac-2"
|
|
310
312
|
],
|
|
311
313
|
"verify": [
|
|
312
314
|
"test -f tests/features/billing/invoice.feature && test -f tests/features/billing/refund.feature (validate)",
|
|
313
|
-
"test -z \"$(grep -rL '@skip' tests/features/billing/*.feature)\" (validate)"
|
|
315
|
+
"test -z \"$(grep -rL '@skip' tests/features/billing/*.feature)\" (validate)",
|
|
316
|
+
"grep -q '@epic-42-ac-1' tests/features/billing/invoice.feature && grep -q '@epic-42-ac-2' tests/features/billing/refund.feature (validate)"
|
|
314
317
|
],
|
|
315
|
-
"body": "## Goal\nbdd-scaffold: create the @skip-tagged feature files the billing-flows implementation Stories verify against, so wave-0 lands them before any implementation Story runs.\n\n## Changes\n- {\"path\": \"tests/features/billing/invoice.feature\", \"assumption\": \"creates\"}\n- {\"path\": \"tests/features/billing/refund.feature\", \"assumption\": \"creates\"}\n\n## Acceptance\n- [ ] tests/features/billing/invoice.feature and tests/features/billing/refund.feature both exist on the branch\n- [ ] every Scenario in the two new feature files is preceded by an @skip tag\n\n## Verify\n- test -f tests/features/billing/invoice.feature && test -f tests/features/billing/refund.feature (validate)\n- test -z \"$(grep -rL '@skip' tests/features/billing/*.feature)\" (validate)\n"
|
|
318
|
+
"body": "## Goal\nbdd-scaffold: create the @skip-tagged, @epic-42-ac-N-tagged feature files the billing-flows implementation Stories verify against, so wave-0 lands them before any implementation Story runs.\n\n## Changes\n- {\"path\": \"tests/features/billing/invoice.feature\", \"assumption\": \"creates\"}\n- {\"path\": \"tests/features/billing/refund.feature\", \"assumption\": \"creates\"}\n\n## Acceptance\n- [ ] tests/features/billing/invoice.feature and tests/features/billing/refund.feature both exist on the branch\n- [ ] every Scenario in the two new feature files is preceded by an @skip tag\n- [ ] the invoice.feature scenario carries @epic-42-ac-1 and the refund.feature scenario carries @epic-42-ac-2\n\n## Verify\n- test -f tests/features/billing/invoice.feature && test -f tests/features/billing/refund.feature (validate)\n- test -z \"$(grep -rL '@skip' tests/features/billing/*.feature)\" (validate)\n- grep -q '@epic-42-ac-1' tests/features/billing/invoice.feature && grep -q '@epic-42-ac-2' tests/features/billing/refund.feature (validate)\n"
|
|
316
319
|
}
|
|
317
320
|
|
|
318
|
-
The implementation Stories that later un-skip and flesh out these scenarios each carry `depends_on: ["scaffold-billing-feature-files"]`, placing them in a later wave than the scaffold.
|
|
321
|
+
The implementation Stories that later un-skip and flesh out these scenarios each carry `depends_on: ["scaffold-billing-feature-files"]`, placing them in a later wave than the scaffold. They MUST NOT add the `@epic-42-ac-N` tag themselves — it is already present from the scaffold pass; their job is to remove `@skip` once the scenario passes.
|
|
319
322
|
|
|
320
323
|
### SCOPE-OVERLAP FLAGGING (docs/runbook downstream of config work)
|
|
321
324
|
|
package/docs/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,14 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to this project will be documented in this file.
|
|
4
4
|
|
|
5
|
+
## [1.79.0](https://github.com/dsj1984/mandrel/compare/mandrel-v1.78.0...mandrel-v1.79.0) (2026-06-30)
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
### Fixed
|
|
9
|
+
|
|
10
|
+
* **decompose-author:** require namespaced [@epic](https://github.com/epic)-<id>-ac-N tag on wave-0 BDD scaffold scenarios (refs [#4301](https://github.com/dsj1984/mandrel/issues/4301)) ([#4304](https://github.com/dsj1984/mandrel/issues/4304)) ([c97d45e](https://github.com/dsj1984/mandrel/commit/c97d45e806a72a8511995d8f606b5be2ef486315))
|
|
11
|
+
* **reconciler:** single-source the parent/Epic body trailer so force re-decompose retains Epic: #<id> (refs [#4300](https://github.com/dsj1984/mandrel/issues/4300)) ([#4303](https://github.com/dsj1984/mandrel/issues/4303)) ([a8e78a9](https://github.com/dsj1984/mandrel/commit/a8e78a9fd946e6f532a59df9cd37237e0d046b2d))
|
|
12
|
+
|
|
5
13
|
## [1.78.0](https://github.com/dsj1984/mandrel/compare/mandrel-v1.77.0...mandrel-v1.78.0) (2026-06-24)
|
|
6
14
|
|
|
7
15
|
|
package/package.json
CHANGED