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.
@@ -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({ maxTickets, maxTokenBudget });
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 #3185 — the footer compose/strip logic is inlined here rather
250
- * than reused from the legacy Task-body renderer module. That renderer
251
- * was removed, so the diff engine carries its own footer shape. The shape
252
- * is byte-identical to the legacy renderer's `parent: #<n>` /
253
- * `Epic: #<m>` / `blocked by #<x>` output so cascade-readers continue
254
- * to parse it unchanged.
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
- const footer = renderFooter({ parentId, epicId, dependencies });
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
- return {
34
- epicId: epicMatch ? Number.parseInt(epicMatch[1], 10) : null,
35
- parentId: parentMatch ? Number.parseInt(parentMatch[1], 10) : null,
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-]*\$\` — hyphen-case only. Underscores are rejected by the validator.
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, and (b) every new scenario within them carries an \`@skip\` tag. Keep these observable (a grep/validate command exits 0, a file exists at a path).
200
- - **verify**: a grep/validate command (tier \`validate\`), NOT an e2e runner verifying that a file exists with a tag needs no browser/playwright run. Example: \`grep -rL '@skip' tests/features/<area>/*.feature (validate)\` paired with an existence check.
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, and (b) every new scenario within them carries an `@skip` tag. Keep items observable (a command exits 0; a file exists at a path).
294
- - **verify** (top-level array): a grep/validate command (tier `validate`), NOT an e2e runnerverifying that a file exists with a tag needs no browser/playwright run.
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, and an empty `depends_on`:
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)-&lt;id&gt;-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: #&lt;id&gt; (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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mandrel",
3
- "version": "1.78.0",
3
+ "version": "1.79.0",
4
4
  "description": "Claude Code-first opinionated workflow framework: instructions, personas, skills, and SDLC workflows that govern AI coding assistants.",
5
5
  "files": [
6
6
  ".agents/",