mandrel 1.80.0 → 1.82.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 (101) hide show
  1. package/.agents/README.md +5 -5
  2. package/.agents/docs/SDLC.md +93 -80
  3. package/.agents/docs/configuration.md +11 -1
  4. package/.agents/docs/quality-gates.md +61 -0
  5. package/.agents/docs/workflows.md +1 -1
  6. package/.agents/instructions.md +9 -7
  7. package/.agents/personas/architect.md +8 -5
  8. package/.agents/personas/engineer-mobile.md +3 -2
  9. package/.agents/personas/engineer-web.md +3 -2
  10. package/.agents/personas/engineer.md +6 -5
  11. package/.agents/personas/product.md +19 -13
  12. package/.agents/personas/project-manager.md +9 -8
  13. package/.agents/personas/qa-engineer.md +10 -6
  14. package/.agents/personas/refactorer.md +3 -2
  15. package/.agents/personas/technical-writer.md +2 -1
  16. package/.agents/personas/ux-designer.md +2 -2
  17. package/.agents/schemas/agentrc.schema.json +10 -0
  18. package/.agents/scripts/acceptance-spec-reconciler.js +143 -59
  19. package/.agents/scripts/epic-deliver-prepare.js +0 -31
  20. package/.agents/scripts/epic-plan-decompose.js +2 -5
  21. package/.agents/scripts/epic-plan-spec.js +16 -19
  22. package/.agents/scripts/hierarchy-gate.js +11 -11
  23. package/.agents/scripts/lib/ITicketingProvider.js +4 -3
  24. package/.agents/scripts/lib/baselines/env-overrides.js +35 -0
  25. package/.agents/scripts/lib/bdd-runner-detect.js +1 -1
  26. package/.agents/scripts/lib/bdd-scenario-scanner.js +1 -1
  27. package/.agents/scripts/lib/cli-args.js +1 -5
  28. package/.agents/scripts/lib/codebase-snapshot.js +1 -1
  29. package/.agents/scripts/lib/config/temp-paths.js +1 -4
  30. package/.agents/scripts/lib/config-settings-schema.js +5 -0
  31. package/.agents/scripts/lib/epic-body-sections.js +222 -0
  32. package/.agents/scripts/lib/epic-plan-clarity.js +38 -1
  33. package/.agents/scripts/lib/epic-plan-ideation.js +15 -3
  34. package/.agents/scripts/lib/label-constants.js +7 -17
  35. package/.agents/scripts/lib/label-taxonomy.js +4 -21
  36. package/.agents/scripts/lib/orchestration/check-baselines/phases/evaluate.js +47 -1
  37. package/.agents/scripts/lib/orchestration/check-baselines/phases/parse-args.js +7 -0
  38. package/.agents/scripts/lib/orchestration/check-baselines/phases/pipeline.js +1 -1
  39. package/.agents/scripts/lib/orchestration/check-baselines/phases/report.js +2 -1
  40. package/.agents/scripts/lib/orchestration/context-hydration-engine.js +19 -8
  41. package/.agents/scripts/lib/orchestration/epic-plan-decompose/phases/context.js +23 -22
  42. package/.agents/scripts/lib/orchestration/epic-plan-decompose/phases/persist-helpers.js +7 -10
  43. package/.agents/scripts/lib/orchestration/epic-plan-decompose/phases/planning-artifacts.js +4 -38
  44. package/.agents/scripts/lib/orchestration/epic-plan-lease-guard.js +8 -9
  45. package/.agents/scripts/lib/orchestration/epic-plan-spec/phases/authoring-context.js +11 -5
  46. package/.agents/scripts/lib/orchestration/epic-plan-spec/phases/cli-args.js +26 -5
  47. package/.agents/scripts/lib/orchestration/epic-plan-spec/phases/plan-epic.js +102 -304
  48. package/.agents/scripts/lib/orchestration/epic-plan-spec/phases/prompts.js +32 -29
  49. package/.agents/scripts/lib/orchestration/epic-plan-spec/phases/run-spec-phase.js +19 -20
  50. package/.agents/scripts/lib/orchestration/epic-plan-spec/phases/spec-authoring-grounding.js +1 -1
  51. package/.agents/scripts/lib/orchestration/epic-plan-spec/phases/spec-freshness.js +6 -9
  52. package/.agents/scripts/lib/orchestration/epic-plan-state-store.js +3 -4
  53. package/.agents/scripts/lib/orchestration/epic-runner/phases/build-wave-dag.js +1 -1
  54. package/.agents/scripts/lib/orchestration/epic-runner/phases/snapshot.js +20 -27
  55. package/.agents/scripts/lib/orchestration/lifecycle/listeners/acceptance-reconciler.js +11 -5
  56. package/.agents/scripts/lib/orchestration/lifecycle/listeners/finalizer.js +22 -59
  57. package/.agents/scripts/lib/orchestration/lifecycle/listeners/index.js +1 -1
  58. package/.agents/scripts/lib/orchestration/planning-context-budget.js +1 -1
  59. package/.agents/scripts/lib/orchestration/preflight-cache.js +1 -1
  60. package/.agents/scripts/lib/orchestration/spec-freshness.js +3 -3
  61. package/.agents/scripts/lib/orchestration/spec-section-validator.js +1 -1
  62. package/.agents/scripts/lib/orchestration/ticket-validator-sizing.js +122 -1
  63. package/.agents/scripts/lib/orchestration/ticketing/bulk.js +5 -8
  64. package/.agents/scripts/lib/orchestration/ticketing/reads.js +2 -2
  65. package/.agents/scripts/lib/plan-phase-cleanup.js +1 -2
  66. package/.agents/scripts/lib/qa/qa-context-hydrator.js +6 -85
  67. package/.agents/scripts/lib/templates/decomposer-prompts.js +14 -8
  68. package/.agents/scripts/lifecycle-emit.js +1 -1
  69. package/.agents/scripts/lint-label-vocabulary.js +2 -3
  70. package/.agents/scripts/providers/github/mappers.js +0 -3
  71. package/.agents/scripts/providers/github/tickets.js +7 -18
  72. package/.agents/scripts/single-story-init.js +0 -1
  73. package/.agents/scripts/story-init.js +1 -29
  74. package/.agents/skills/core/epic-plan-consolidate/SKILL.md +37 -18
  75. package/.agents/skills/core/epic-plan-decompose-author/SKILL.md +23 -18
  76. package/.agents/skills/core/epic-plan-premortem/SKILL.md +7 -6
  77. package/.agents/skills/core/epic-plan-spec-author/SKILL.md +161 -109
  78. package/.agents/skills/core/hydrate-context/SKILL.md +10 -5
  79. package/.agents/skills/core/knowledge-transfer/SKILL.md +3 -2
  80. package/.agents/skills/core/scope-triage/SKILL.md +2 -1
  81. package/.agents/skills/skills.index.json +6 -6
  82. package/.agents/templates/epic-from-idea.md +4 -0
  83. package/.agents/workflows/audit-to-stories.md +2 -2
  84. package/.agents/workflows/helpers/code-review.md +11 -9
  85. package/.agents/workflows/helpers/deliver-epic.md +32 -44
  86. package/.agents/workflows/helpers/epic-audit.md +11 -8
  87. package/.agents/workflows/helpers/epic-deliver-story.md +10 -16
  88. package/.agents/workflows/helpers/epic-plan-decompose.md +17 -12
  89. package/.agents/workflows/helpers/epic-plan-spec.md +68 -68
  90. package/.agents/workflows/helpers/parallel-tooling.md +2 -1
  91. package/.agents/workflows/helpers/plan-epic.md +114 -99
  92. package/.agents/workflows/helpers/single-story-deliver.md +1 -1
  93. package/.agents/workflows/helpers/worktree-lifecycle.md +1 -1
  94. package/.agents/workflows/plan.md +8 -8
  95. package/.agents/workflows/qa-assist.md +2 -1
  96. package/docs/CHANGELOG.md +28 -0
  97. package/package.json +1 -1
  98. package/.agents/scripts/lib/issue-link-parser.js +0 -74
  99. package/.agents/scripts/lib/orchestration/finalize/close-planning-tickets.js +0 -116
  100. package/.agents/scripts/lib/orchestration/planning-state-manager.js +0 -318
  101. package/.agents/scripts/lib/story-init/hierarchy-tracer.js +0 -57
@@ -9,8 +9,7 @@
9
9
  * have been split into phase modules under
10
10
  * `lib/orchestration/epic-plan-decompose/phases/`:
11
11
  *
12
- * 1. planning-artifacts — `ensurePlanningArtifacts`,
13
- * `resolveConflictPolicy`.
12
+ * 1. planning-artifacts — `resolveConflictPolicy`.
14
13
  * 2. dag — `resolveDependencies`,
15
14
  * `orderTicketsForCreation`.
16
15
  * 3. context — `buildDecomposerSystemPrompt`,
@@ -23,7 +22,7 @@
23
22
  * 6. cli — argument parsing + `main()` pipeline.
24
23
  *
25
24
  * Modes:
26
- * --emit-context Prints the decomposer authoring context (PRD body,
25
+ * --emit-context Prints the decomposer authoring context (Epic body,
27
26
  * Tech Spec body, risk heuristics, system prompt,
28
27
  * ticket cap) as JSON. The authoring middle is the
29
28
  * `epic-plan-decompose-author` Skill
@@ -60,7 +59,6 @@ import {
60
59
  resolveDependencies,
61
60
  } from './lib/orchestration/epic-plan-decompose/phases/dag.js';
62
61
  import { runDecomposePhase } from './lib/orchestration/epic-plan-decompose/phases/persist.js';
63
- import { ensurePlanningArtifacts } from './lib/orchestration/epic-plan-decompose/phases/planning-artifacts.js';
64
62
 
65
63
  // Named exports preserved for the existing test surface. The pre-refactor
66
64
  // module published these and the consumers (`tests/ticket-decomposer.test.js`,
@@ -70,7 +68,6 @@ import { ensurePlanningArtifacts } from './lib/orchestration/epic-plan-decompose
70
68
  export {
71
69
  buildDecomposerSystemPrompt,
72
70
  buildDecompositionContext,
73
- ensurePlanningArtifacts,
74
71
  orderTicketsForCreation,
75
72
  resolveDependencies,
76
73
  runDecomposePhase,
@@ -10,18 +10,20 @@
10
10
  * JSON. The authoring middle is the
11
11
  * `epic-plan-spec-author` Skill (see
12
12
  * `.agents/skills/core/epic-plan-spec-author/SKILL.md`),
13
- * which consumes this envelope and writes the PRD and
14
- * Tech Spec markdown files.
13
+ * which consumes this envelope and writes the Tech Spec
14
+ * markdown file.
15
15
  *
16
- * 2. (default) Given author-provided PRD, Tech Spec, and risk-verdict
16
+ * 2. (default) Given author-provided Tech Spec and risk-verdict
17
17
  * files, validates the risk verdict against
18
18
  * `risk-verdict.schema.json`, derives the planningRisk
19
- * envelope, persists the artifact issues, records the
20
- * verdict as a `risk-verdict` structured comment, flips
21
- * the Epic to `agent::review-spec`, and upserts the
19
+ * envelope, folds the authored content into managed
20
+ * sections of the Epic body (Story #4324 — no separate
21
+ * context tickets), records the verdict as a
22
+ * `risk-verdict` structured comment, flips the Epic to
23
+ * `agent::review-spec`, and upserts the
22
24
  * `epic-plan-state` structured comment.
23
25
  *
24
- * --force regenerates existing PRD/Tech Spec.
26
+ * --force regenerates the existing Tech Spec.
25
27
  * --steal forcibly transfers a foreign Epic-lease claim (the plan-lease guard
26
28
  * fails closed, so any foreign assignee blocks the run unless stolen).
27
29
  *
@@ -70,7 +72,6 @@ import {
70
72
  } from './lib/orchestration/epic-plan-spec/phases/plan-epic.js';
71
73
  import {
72
74
  ACCEPTANCE_SPEC_SYSTEM_PROMPT,
73
- PRD_SYSTEM_PROMPT,
74
75
  TECH_SPEC_SYSTEM_PROMPT,
75
76
  } from './lib/orchestration/epic-plan-spec/phases/prompts.js';
76
77
  import {
@@ -89,7 +90,6 @@ export {
89
90
  buildAuthoringContext,
90
91
  drainPendingCleanupAtBoot,
91
92
  loadRiskVerdict,
92
- PRD_SYSTEM_PROMPT,
93
93
  planEpic,
94
94
  resolveAcceptancePersistence,
95
95
  resolveMemoryDir,
@@ -158,9 +158,9 @@ async function main() {
158
158
  return;
159
159
  }
160
160
 
161
- if (!values.prd || !values.techspec || !values['risk-verdict']) {
161
+ if (!values['tech-spec'] || !values['risk-verdict']) {
162
162
  throw new Error(
163
- 'Missing --prd, --techspec, and/or --risk-verdict file paths. (Use --emit-context first to gather authoring context; the epic-plan-spec-author Skill writes all artifacts including risk-verdict.json.)',
163
+ 'Missing --tech-spec and/or --risk-verdict file paths. (Use --emit-context first to gather authoring context; the epic-plan-spec-author Skill writes all artifacts including risk-verdict.json.)',
164
164
  );
165
165
  }
166
166
 
@@ -168,20 +168,17 @@ async function main() {
168
168
  // GitHub mutation: a malformed verdict fails closed here (Epic #3865).
169
169
  const riskVerdict = loadRiskVerdict(values['risk-verdict']);
170
170
 
171
- const readPromises = [
172
- readFile(values.prd, 'utf8'),
173
- readFile(values.techspec, 'utf8'),
174
- ];
175
- if (values['acceptance-spec']) {
176
- readPromises.push(readFile(values['acceptance-spec'], 'utf8'));
171
+ const readPromises = [readFile(values['tech-spec'], 'utf8')];
172
+ if (values['acceptance-table']) {
173
+ readPromises.push(readFile(values['acceptance-table'], 'utf8'));
177
174
  }
178
- const [prdContent, techSpecContent, acceptanceSpecContent = null] =
175
+ const [techSpecContent, acceptanceSpecContent = null] =
179
176
  await Promise.all(readPromises);
180
177
 
181
178
  const result = await runSpecPhase(
182
179
  epicId,
183
180
  provider,
184
- { prdContent, techSpecContent, acceptanceSpecContent },
181
+ { techSpecContent, acceptanceSpecContent },
185
182
  settings,
186
183
  {
187
184
  force: values.force,
@@ -10,16 +10,16 @@
10
10
  *
11
11
  * The two gates catch different problems and are intentionally distinct:
12
12
  * - The wave gate misses descendants that exist on GitHub but were never
13
- * in the manifest — context::prd / context::tech-spec tickets, mid-sprint
14
- * additions, or recuts that bypassed the dispatcher.
13
+ * in the manifest — mid-sprint additions, recuts that bypassed the
14
+ * dispatcher, or legacy `context::*` artifacts on historical Epics.
15
15
  * - The hierarchy gate misses parked follow-ons that live as separate
16
16
  * top-level Stories outside the Epic's sub-issue graph.
17
17
  *
18
18
  * Per ticket type the rule is:
19
19
  * - Stories — must be closed.
20
- * - Auxiliary (context::prd, context::tech-spec) — ignored.
21
- * These are closed by the operator after the Epic PR merges, so
22
- * requiring them closed here would block every Epic.
20
+ * - Auxiliary (legacy `context::*` artifacts) — ignored. Story #4324
21
+ * folded planning content into the Epic body; historical Epics keep
22
+ * their old context tickets, which are reference-only here.
23
23
  *
24
24
  * **2-tier hierarchy (Story #4041).** Mandrel ships only Epic / Story
25
25
  * tickets. `getSubTickets(<storyId>)` returns `[]`; the walk
@@ -39,7 +39,7 @@ import { parseArgs } from 'node:util';
39
39
  import { runAsCli } from './lib/cli-utils.js';
40
40
  import { resolveConfig } from './lib/config-resolver.js';
41
41
  import { Logger } from './lib/Logger.js';
42
- import { CONTEXT_LABELS, TYPE_LABELS } from './lib/label-constants.js';
42
+ import { TYPE_LABELS } from './lib/label-constants.js';
43
43
  import { createProvider } from './lib/provider-factory.js';
44
44
  import { concurrentMap } from './lib/util/concurrent-map.js';
45
45
 
@@ -52,10 +52,10 @@ const SUB_TICKET_FETCH_CONCURRENCY = 4;
52
52
  function classify(ticket) {
53
53
  const labels = ticket.labels ?? [];
54
54
  if (labels.includes(TYPE_LABELS.STORY)) return 'story';
55
- if (
56
- labels.includes(CONTEXT_LABELS.PRD) ||
57
- labels.includes(CONTEXT_LABELS.TECH_SPEC)
58
- ) {
55
+ // Legacy planning artifacts (pre-#4324 `context::*` tickets on
56
+ // historical Epics) are ignored — they are reference artifacts, not
57
+ // deliverables. New Epics carry planning content on the body itself.
58
+ if (labels.some((l) => typeof l === 'string' && l.startsWith('context::'))) {
59
59
  return 'auxiliary';
60
60
  }
61
61
  return 'other';
@@ -165,7 +165,7 @@ export async function runHierarchyGate({ epicId, injectedProvider } = {}) {
165
165
 
166
166
  const auxNote =
167
167
  auxiliaryDeferred > 0
168
- ? ` (${auxiliaryDeferred} auxiliary ticket${auxiliaryDeferred === 1 ? '' : 's'} deferred to Phase 7)`
168
+ ? ` (${auxiliaryDeferred} legacy auxiliary ticket${auxiliaryDeferred === 1 ? '' : 's'} ignored)`
169
169
  : '';
170
170
  Logger.info(
171
171
  `[hierarchy-gate] ✅ All ${descendants.length - auxiliaryDeferred} planned descendant(s) under Epic #${epicId} are closed${auxNote}.`,
@@ -33,15 +33,16 @@ export class ITicketingProvider {
33
33
  }
34
34
 
35
35
  /**
36
- * Fetch the Epic issue with body and linked context issues (PRD, Tech Spec).
36
+ * Fetch the Epic issue with its body the single planning document
37
+ * (ideation sections plus the folded Tech Spec / Acceptance Table
38
+ * managed sections, Story #4324).
37
39
  *
38
40
  * @param {number} epicId - GitHub Issue number of the Epic.
39
41
  * @returns {Promise<{
40
42
  * id: number,
41
43
  * title: string,
42
44
  * body: string,
43
- * labels: string[],
44
- * linkedIssues: { prd: number|null, techSpec: number|null }
45
+ * labels: string[]
45
46
  * }>}
46
47
  */
47
48
  async getEpic(_epicId) {
@@ -82,6 +82,41 @@ export function resolveCrapEnvOverrides(crapConfig, env) {
82
82
  return { newMethodCeiling, tolerance, refreshTag, overrides };
83
83
  }
84
84
 
85
+ /**
86
+ * Pure helper: resolve the one-shot bundle-size refresh/acknowledge flag
87
+ * (Story #151). Unlike `coverage` / `crap` / `maintainability`, the
88
+ * bundle-size gate has no scorer of its own — the measured sizes come from
89
+ * a build step the operator already runs, not a source-tree rescan — so
90
+ * there is no `refreshBaseline({ kind: 'bundle-size', ... })` path to
91
+ * regenerate a "corrected" baseline. Instead, `BUNDLE_SIZE_REFRESH=1`
92
+ * (mirroring `CRAP_TOLERANCE`'s env-override precedent) tells
93
+ * `check-baselines --gate bundle-size` to treat this run's head
94
+ * measurements as the newly acknowledged baseline: head-vs-base
95
+ * regressions are demoted to `unchanged` for this invocation only. Floors
96
+ * still apply — an acknowledged PR can still fail on an absolute budget
97
+ * breach, only the ratchet-vs-`origin/main` comparison is suspended.
98
+ *
99
+ * The flag is **not persisted** anywhere (no config write, no committed
100
+ * tag): the very next `check-baselines` invocation without the env var —
101
+ * i.e. the next PR — reverts to full strict enforcement automatically, so
102
+ * there is no lingering loosened tolerance to remember to reset (AC-3).
103
+ *
104
+ * Accepted truthy values: `1`, `true` (case-insensitive). Anything else
105
+ * (including unset/empty) resolves to `acknowledged: false`.
106
+ *
107
+ * @param {NodeJS.ProcessEnv} env
108
+ * @returns {{ acknowledged: boolean, overrides: string[] }}
109
+ */
110
+ export function resolveBundleSizeEnvOverrides(env) {
111
+ const raw = env?.BUNDLE_SIZE_REFRESH;
112
+ const acknowledged =
113
+ typeof raw === 'string' && /^(1|true)$/i.test(raw.trim());
114
+ const overrides = acknowledged
115
+ ? [`acknowledged=true (BUNDLE_SIZE_REFRESH=${raw})`]
116
+ : [];
117
+ return { acknowledged, overrides };
118
+ }
119
+
85
120
  /**
86
121
  * Pure helper: resolve the effective MI tolerance by layering precedence:
87
122
  * 1. `CRAP_TOLERANCE` env-var (CI override — the baseline-refresh-
@@ -3,7 +3,7 @@
3
3
  * Task #2103; workspace-aware extension from Story #2956).
4
4
  *
5
5
  * Used by `epic-plan-spec.js#buildAuthoringContext` to decide whether the
6
- * acceptance-spec body should plan **features-first** Story ordering (a real
6
+ * acceptance-table section should plan **features-first** Story ordering (a real
7
7
  * pending-tag is available, so the features-first Story can ship `.feature`
8
8
  * files marked `@pending` / `@skip` ahead of the implementation Stories) or
9
9
  * fall back to **dependencies-first** ordering (no pending tag → cannot
@@ -3,7 +3,7 @@
3
3
  *
4
4
  * Story #2637 (sibling to #2634 codebase-snapshot, #2635 spec-freshness,
5
5
  * #2636 file-assumption gate). The Acceptance Engineer step of
6
- * `epic-plan-spec-author` currently writes ACs from PRD/Tech Spec narrative
6
+ * `epic-plan-spec-author` currently writes ACs from Epic/Tech Spec narrative
7
7
  * alone — it never inspects the consumer project's existing `.feature`
8
8
  * files. Planned ACs frequently duplicate scenarios that already exist or
9
9
  * re-specify behaviour the codebase already proves; the duplication is
@@ -60,8 +60,6 @@ export function parseSprintArgs(args = process.argv) {
60
60
  'no-full-scope-crap': { type: 'boolean', default: false },
61
61
  executor: { type: 'string' },
62
62
  cwd: { type: 'string' },
63
- prd: { type: 'string' },
64
- 'tech-spec': { type: 'string' },
65
63
  'recut-of': { type: 'string' },
66
64
  resume: { type: 'boolean', default: false },
67
65
  restart: { type: 'boolean', default: false },
@@ -88,10 +86,8 @@ export function parseSprintArgs(args = process.argv) {
88
86
  process.env.AGENT_WORKTREE_ROOT ||
89
87
  null,
90
88
  recutOf: parseTicketId(values['recut-of']),
91
- // Story #4253: pre-resolved Epic linkages threaded by the /deliver
89
+ // Story #4253: pre-resolved Epic linkage threaded by the /deliver
92
90
  // fan-out so `story-init.js` can skip the per-Story `getEpic` round-trip.
93
- prdId: parseTicketId(values.prd),
94
- techSpecId: parseTicketId(values['tech-spec']),
95
91
  resume: values.resume ?? false,
96
92
  restart: values.restart ?? false,
97
93
  noEvidence: values['no-evidence'] ?? false,
@@ -2,7 +2,7 @@
2
2
  * codebase-snapshot.js — Bounded structural view of the consumer repo.
3
3
  *
4
4
  * Story #2634 (sibling to #2635 spec-freshness). `/plan` Phase 7
5
- * authors PRD + Tech Spec from documentation alone — `architecture.md`,
5
+ * authors the Tech Spec from documentation alone — `architecture.md`,
6
6
  * `data-dictionary.md`, `decisions.md`, `patterns.md`. When those docs
7
7
  * drift from the real source tree, the Architect persona cites modules
8
8
  * and paths that no longer exist, and the mismatch only surfaces at
@@ -10,7 +10,6 @@
10
10
  *
11
11
  * Layout:
12
12
  * temp/epic-<eid>/
13
- * ├─ prd.md
14
13
  * ├─ techspec.md
15
14
  * ├─ manifest.md (dispatch manifest)
16
15
  * ├─ retro.md (mirror of GitHub retro at Epic close)
@@ -228,7 +227,7 @@ export function epicTempDir(eid, config) {
228
227
  * location instead of being dropped on the floor.
229
228
  *
230
229
  * Story #2940 introduced the intermediate `stories/` segment so that
231
- * per-Epic top-level artifacts (`prd.md`, `techspec.md`, `manifest.md`,
230
+ * per-Epic top-level artifacts (`techspec.md`, `manifest.md`,
232
231
  * `retro.md`, `lifecycle.ndjson`, `baselines/`, `checkpoints/`) are
233
232
  * visually and structurally separated from the per-Story siblings.
234
233
  *
@@ -291,8 +290,6 @@ export function storyArtifactPath(eid, sid, name, config) {
291
290
 
292
291
  // --- Canonical Epic-level filenames (Tech Spec #1032 §tempRoot) ---
293
292
 
294
- export const epicPrdPath = (eid, config) =>
295
- epicArtifactPath(eid, 'prd.md', config);
296
293
  export const epicTechSpecPath = (eid, config) =>
297
294
  epicArtifactPath(eid, 'techspec.md', config);
298
295
  export const epicManifestPath = (eid, config) =>
@@ -263,6 +263,11 @@ const TASK_SIZING_SCHEMA = {
263
263
  hardFiles: { type: 'integer', minimum: 1 },
264
264
  maxAcceptance: { type: 'integer', minimum: 1 },
265
265
  softAcceptanceCount: { type: 'integer', minimum: 1 },
266
+ // Under-size (merge-candidate) thresholds (Story #4312). A Story whose
267
+ // footprint is at or below BOTH ceilings and that carries a `depends_on`
268
+ // edge to a sibling trips the advisory `merge-candidate` soft finding.
269
+ mergeCandidateMaxFiles: { type: 'integer', minimum: 1 },
270
+ mergeCandidateMaxAcceptance: { type: 'integer', minimum: 1 },
266
271
  },
267
272
  additionalProperties: false,
268
273
  };
@@ -0,0 +1,222 @@
1
+ /**
2
+ * epic-body-sections.js — marker-delimited managed sections of the Epic body.
3
+ *
4
+ * Story #4324 retired the `context::tech-spec` / `context::acceptance-spec`
5
+ * ticket classes: the Epic body is now the single planning document. The
6
+ * Tech Spec (opening with `## Delivery Slicing`, per #4316) and the
7
+ * Acceptance Spec's AC-ID table (`## Acceptance Table`, Outcomes keyed to
8
+ * Epic AC bullets per #4315) land as **managed sections** of the Epic body,
9
+ * delimited by invisible HTML comment markers so each writer can update
10
+ * *only its own region* without rewriting the rest of the body.
11
+ *
12
+ * Section-scoped writes are the load-bearing contract (extending the
13
+ * single-writer discipline #4303 established for the body trailer):
14
+ *
15
+ * - The Phase 7 spec persist path (`epic-plan-spec/phases/plan-epic.js`)
16
+ * upserts the `techSpec` and `acceptanceTable` regions.
17
+ * - The close-time acceptance reconciler
18
+ * (`acceptance-spec-reconciler.js`) reads and rewrites the
19
+ * `acceptanceTable` region only (verification dispositions).
20
+ *
21
+ * Everything outside a managed region is byte-preserved by every helper in
22
+ * this module. Markers are chosen so GitHub renders nothing for them; the
23
+ * human-visible headings (`## Delivery Slicing`, `## Acceptance Table`)
24
+ * live INSIDE the regions as ordinary content.
25
+ *
26
+ * Pure ESM, no I/O.
27
+ */
28
+
29
+ /**
30
+ * Managed-region descriptors. `start`/`end` are the literal marker lines;
31
+ * `label` is the human name used in log lines and error messages.
32
+ *
33
+ * @type {Readonly<Record<'techSpec'|'acceptanceTable', { start: string, end: string, label: string }>>}
34
+ */
35
+ export const EPIC_BODY_SECTIONS = Object.freeze({
36
+ techSpec: Object.freeze({
37
+ start: '<!-- mandrel:tech-spec:start -->',
38
+ end: '<!-- mandrel:tech-spec:end -->',
39
+ label: 'Tech Spec',
40
+ }),
41
+ acceptanceTable: Object.freeze({
42
+ start: '<!-- mandrel:acceptance-table:start -->',
43
+ end: '<!-- mandrel:acceptance-table:end -->',
44
+ label: 'Acceptance Table',
45
+ }),
46
+ });
47
+
48
+ /**
49
+ * Canonical heading the acceptance-table region opens with. Distinct from
50
+ * the Epic's ideation `## Acceptance Criteria` bullets (which remain the
51
+ * SSOT for *what* the table verifies — the table anchors to those bullets).
52
+ */
53
+ export const ACCEPTANCE_TABLE_HEADING = '## Acceptance Table';
54
+
55
+ /**
56
+ * Regex matching the Tech Spec's required opening heading (same variants
57
+ * `spec-section-validator.js` accepts).
58
+ */
59
+ const DELIVERY_SLICING_RE = /^##\s+(?:Delivery\s+)?Slicing\s*$/im;
60
+
61
+ /**
62
+ * @param {'techSpec'|'acceptanceTable'} kind
63
+ * @returns {{ start: string, end: string, label: string }}
64
+ */
65
+ function descriptor(kind) {
66
+ const d = EPIC_BODY_SECTIONS[kind];
67
+ if (!d) {
68
+ throw new TypeError(
69
+ `epic-body-sections: unknown section kind "${kind}" (expected ${Object.keys(EPIC_BODY_SECTIONS).join(' | ')})`,
70
+ );
71
+ }
72
+ return d;
73
+ }
74
+
75
+ /**
76
+ * Locate a managed region. Returns `null` when either marker is absent or
77
+ * the end marker precedes the start marker (malformed body — treated as
78
+ * absent so a writer re-appends a well-formed region rather than
79
+ * corrupting the body further).
80
+ *
81
+ * @param {string} body
82
+ * @param {'techSpec'|'acceptanceTable'} kind
83
+ * @returns {{ startIdx: number, contentStart: number, contentEnd: number, endIdx: number }|null}
84
+ */
85
+ function locate(body, kind) {
86
+ const { start, end } = descriptor(kind);
87
+ if (typeof body !== 'string' || body.length === 0) return null;
88
+ const startIdx = body.indexOf(start);
89
+ if (startIdx === -1) return null;
90
+ const contentStart = startIdx + start.length;
91
+ const endIdx = body.indexOf(end, contentStart);
92
+ if (endIdx === -1) return null;
93
+ return { startIdx, contentStart, contentEnd: endIdx, endIdx };
94
+ }
95
+
96
+ /**
97
+ * True when the body carries a well-formed managed region of `kind`.
98
+ *
99
+ * @param {string} body
100
+ * @param {'techSpec'|'acceptanceTable'} kind
101
+ * @returns {boolean}
102
+ */
103
+ export function hasEpicSection(body, kind) {
104
+ return locate(body, kind) !== null;
105
+ }
106
+
107
+ /**
108
+ * Extract a managed region's content (between the markers, trimmed).
109
+ * Returns `null` when the region is absent.
110
+ *
111
+ * @param {string} body
112
+ * @param {'techSpec'|'acceptanceTable'} kind
113
+ * @returns {string|null}
114
+ */
115
+ export function extractEpicSection(body, kind) {
116
+ const loc = locate(body, kind);
117
+ if (!loc) return null;
118
+ return body.slice(loc.contentStart, loc.contentEnd).trim();
119
+ }
120
+
121
+ /**
122
+ * Insert or replace a managed region, preserving every byte outside it.
123
+ *
124
+ * When the region exists, only the content between the markers is
125
+ * replaced. When absent, the region is appended at the end of the body
126
+ * (trailing whitespace normalised to a single blank-line separator). The
127
+ * `techSpec` region is appended before an existing `acceptanceTable`
128
+ * region so the rendered document keeps its canonical order
129
+ * (ideation sections → Tech Spec → Acceptance Table).
130
+ *
131
+ * @param {string} body
132
+ * @param {'techSpec'|'acceptanceTable'} kind
133
+ * @param {string} content Section content (headings included).
134
+ * @returns {string}
135
+ */
136
+ export function upsertEpicSection(body, kind, content) {
137
+ const { start, end } = descriptor(kind);
138
+ const safeBody = typeof body === 'string' ? body : '';
139
+ const trimmedContent = typeof content === 'string' ? content.trim() : '';
140
+ const region = `${start}\n\n${trimmedContent}\n\n${end}`;
141
+
142
+ const loc = locate(safeBody, kind);
143
+ if (loc) {
144
+ return (
145
+ safeBody.slice(0, loc.startIdx) +
146
+ region +
147
+ safeBody.slice(loc.endIdx + end.length)
148
+ );
149
+ }
150
+
151
+ // Keep canonical order when appending: the Tech Spec region goes before
152
+ // an already-present acceptance-table region.
153
+ if (kind === 'techSpec') {
154
+ const acceptanceLoc = locate(safeBody, 'acceptanceTable');
155
+ if (acceptanceLoc) {
156
+ const head = safeBody
157
+ .slice(0, acceptanceLoc.startIdx)
158
+ .replace(/\s+$/, '');
159
+ const tail = safeBody.slice(acceptanceLoc.startIdx);
160
+ return `${head}\n\n${region}\n\n${tail}`;
161
+ }
162
+ }
163
+
164
+ const trimmedBody = safeBody.replace(/\s+$/, '');
165
+ return trimmedBody.length > 0
166
+ ? `${trimmedBody}\n\n${region}\n`
167
+ : `${region}\n`;
168
+ }
169
+
170
+ /**
171
+ * Remove a managed region (markers and content). Byte-preserving outside
172
+ * the region; collapses the surrounding blank lines the writer added.
173
+ * No-op when the region is absent.
174
+ *
175
+ * @param {string} body
176
+ * @param {'techSpec'|'acceptanceTable'} kind
177
+ * @returns {string}
178
+ */
179
+ export function stripEpicSection(body, kind) {
180
+ const { end } = descriptor(kind);
181
+ const loc = locate(body, kind);
182
+ if (!loc) return typeof body === 'string' ? body : '';
183
+ const before = body.slice(0, loc.startIdx).replace(/\n+$/, '\n');
184
+ const after = body.slice(loc.endIdx + end.length).replace(/^\n+/, '\n');
185
+ return (before + after).replace(/\n{3,}/g, '\n\n');
186
+ }
187
+
188
+ /**
189
+ * Re-plan / decompose detection: true when the Epic body carries folded
190
+ * Tech Spec content — the managed region, or (defence in depth for a
191
+ * hand-authored body) a bare `## Delivery Slicing` heading.
192
+ *
193
+ * @param {string} body
194
+ * @returns {boolean}
195
+ */
196
+ export function hasTechSpecContent(body) {
197
+ if (hasEpicSection(body, 'techSpec')) return true;
198
+ return typeof body === 'string' && DELIVERY_SLICING_RE.test(body);
199
+ }
200
+
201
+ /**
202
+ * Strip the retired machine-managed `## Planning Artifacts` checklist from
203
+ * an Epic body (the section that linked the now-retired context tickets).
204
+ * The slice ends at the next `## ` heading, a managed-region marker, or
205
+ * EOF. Historical bodies without the section pass through untouched.
206
+ *
207
+ * @param {string} body
208
+ * @returns {string}
209
+ */
210
+ export function stripPlanningArtifactsSection(body) {
211
+ if (typeof body !== 'string' || body.length === 0) return '';
212
+ const headingMatch = body.match(/^##\s+Planning Artifacts[^\n]*$/m);
213
+ if (!headingMatch || typeof headingMatch.index !== 'number') return body;
214
+ const start = headingMatch.index;
215
+ const afterHeading = start + headingMatch[0].length;
216
+ const rest = body.slice(afterHeading);
217
+ const boundary = rest.search(/^(?:##\s|<!-- mandrel:)/m);
218
+ const end = boundary === -1 ? body.length : afterHeading + boundary;
219
+ const before = body.slice(0, start).replace(/\n+$/, '\n');
220
+ const after = body.slice(end);
221
+ return (before + after).replace(/\n{3,}/g, '\n\n').replace(/^\n+/, '');
222
+ }
@@ -28,6 +28,15 @@
28
28
  * `## Work Breakdown`
29
29
  * - `## Acceptance Criteria`, `## Acceptance`, `## AC`
30
30
  *
31
+ * Story #4324 folded the Tech Spec and Acceptance Spec into the Epic body
32
+ * as managed sections (`## Delivery Slicing`-led spec sections and the
33
+ * `## Acceptance Table`). The gate *recognises* those planning sections —
34
+ * reporting their presence under `planningSections[]` so a post-fold Epic
35
+ * body scores exactly as its ideation content deserves — but they never
36
+ * count toward (or against) the five-ideation-section verdict: Phase 6
37
+ * runs before Phase 7 authors them, and a re-planned Epic that already
38
+ * carries them must not be penalised or auto-passed by their presence.
39
+ *
31
40
  * Pure ESM, no I/O.
32
41
  */
33
42
 
@@ -54,6 +63,26 @@ export const SECTION_NAMES = Object.freeze([
54
63
  'acceptanceCriteria',
55
64
  ]);
56
65
 
66
+ /**
67
+ * Post-fold planning sections (Story #4324) — recognised and reported,
68
+ * never scored. `deliverySlicing` accepts the same heading variants as
69
+ * `spec-section-validator.js`.
70
+ */
71
+ const PLANNING_SECTION_RE = {
72
+ deliverySlicing: /^##\s+(?:Delivery\s+)?Slicing\s*$/im,
73
+ acceptanceTable: /^##\s+Acceptance\s+Table\s*$/im,
74
+ };
75
+
76
+ /**
77
+ * Names of the recognised (unscored) planning sections, in document order.
78
+ * Module-private: consumers read the reported `planningSections[]` rows on
79
+ * the scoreEpicBody result rather than importing the name list.
80
+ */
81
+ const PLANNING_SECTION_NAMES = Object.freeze([
82
+ 'deliverySlicing',
83
+ 'acceptanceTable',
84
+ ]);
85
+
57
86
  const CLEAR_THRESHOLD = 4;
58
87
  const PLACEHOLDER_PATTERN = /^_\(not\s+specified\)_$/i;
59
88
 
@@ -80,6 +109,7 @@ function classify(content) {
80
109
  * verdict: 'clear' | 'needs-refinement',
81
110
  * sections: Array<{ name: string, status: 'present' | 'placeholder' | 'missing' }>,
82
111
  * missingOrPlaceholder: string[],
112
+ * planningSections: Array<{ name: string, status: 'present' | 'missing' }>,
83
113
  * }}
84
114
  */
85
115
  const REQUIRED_SECTION = 'acceptanceCriteria';
@@ -138,5 +168,12 @@ export function scoreEpicBody({ body } = {}) {
138
168
  ? 'clear'
139
169
  : 'needs-refinement';
140
170
 
141
- return { verdict, sections, missingOrPlaceholder };
171
+ // Informational only: presence of the post-fold planning sections
172
+ // (Story #4324). Never feeds the verdict.
173
+ const planningSections = PLANNING_SECTION_NAMES.map((name) => ({
174
+ name,
175
+ status: PLANNING_SECTION_RE[name].test(source) ? 'present' : 'missing',
176
+ }));
177
+
178
+ return { verdict, sections, missingOrPlaceholder, planningSections };
142
179
  }
@@ -5,7 +5,7 @@
5
5
  * using the canonical template at `.agents/templates/epic-from-idea.md`.
6
6
  * Phase 4: open the GitHub Issue via an injected provider with the
7
7
  * `type::epic` label only — no `state::draft` (the Epic carries only
8
- * `type::epic` until PRD authoring writes `agent::review-spec`).
8
+ * `type::epic` until spec authoring writes `agent::review-spec`).
9
9
  *
10
10
  * The template is parsed from a string the caller has already loaded
11
11
  * (typically via `fs.readFile`). The renderer is pure — no I/O — and
@@ -30,13 +30,23 @@ const SECTION_RE = {
30
30
  /^##\s+(?:Non[\s-]?Goals|Out\s+of\s+Scope|Not\s+Doing(?:\s+\(and\s+Why\))?)\s*$/im,
31
31
  scope:
32
32
  /^##\s+(?:MVP\s+|Proposed\s+)?Scope(?:\s+\([^)]+\))?\s*$|^##\s+Work\s+Breakdown\s*$/im,
33
+ // Story #4314 — the PRD's one novel section (User Stories) now folds into
34
+ // the Epic body as a canonical section.
35
+ userStories: /^##\s+(?:User\s+Stories|Stories|Personas?\s+&\s+Stories)\s*$/im,
33
36
  acceptanceCriteria: /^##\s+(?:Acceptance(?:\s+Criteria)?|AC)\s*$/im,
34
37
  };
35
38
 
36
- const ORDER = ['context', 'goal', 'nonGoals', 'scope', 'acceptanceCriteria'];
39
+ const ORDER = [
40
+ 'context',
41
+ 'goal',
42
+ 'nonGoals',
43
+ 'scope',
44
+ 'userStories',
45
+ 'acceptanceCriteria',
46
+ ];
37
47
 
38
48
  /**
39
- * Extract the five canonical sections from an idea-refinement one-pager.
49
+ * Extract the six canonical sections from an idea-refinement one-pager.
40
50
  *
41
51
  * @param {string} onePager - Markdown produced by Phase 3 of the
42
52
  * `idea-refinement` skill.
@@ -46,6 +56,7 @@ const ORDER = ['context', 'goal', 'nonGoals', 'scope', 'acceptanceCriteria'];
46
56
  * goal: string,
47
57
  * nonGoals: string,
48
58
  * scope: string,
59
+ * userStories: string,
49
60
  * acceptanceCriteria: string,
50
61
  * }}
51
62
  */
@@ -72,6 +83,7 @@ export function parseOnePager(onePager) {
72
83
  goal: '',
73
84
  nonGoals: '',
74
85
  scope: '',
86
+ userStories: '',
75
87
  acceptanceCriteria: '',
76
88
  };
77
89