mandrel 1.81.0 → 1.83.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (121) hide show
  1. package/.agents/README.md +46 -5
  2. package/.agents/docs/SDLC.md +97 -82
  3. package/.agents/docs/agentrc-reference.json +10 -2
  4. package/.agents/docs/configuration.md +4 -1
  5. package/.agents/docs/execution-reference.md +52 -0
  6. package/.agents/docs/workflows.md +1 -1
  7. package/.agents/instructions.md +85 -45
  8. package/.agents/personas/architect.md +8 -5
  9. package/.agents/personas/engineer-mobile.md +3 -2
  10. package/.agents/personas/engineer-web.md +3 -2
  11. package/.agents/personas/engineer.md +6 -5
  12. package/.agents/personas/product.md +19 -13
  13. package/.agents/personas/project-manager.md +9 -8
  14. package/.agents/personas/qa-engineer.md +10 -6
  15. package/.agents/personas/refactorer.md +3 -2
  16. package/.agents/personas/technical-writer.md +2 -1
  17. package/.agents/personas/ux-designer.md +2 -2
  18. package/.agents/schemas/agentrc.schema.json +41 -3
  19. package/.agents/schemas/qa-ledger.schema.json +2 -2
  20. package/.agents/scripts/acceptance-spec-reconciler.js +143 -59
  21. package/.agents/scripts/epic-deliver-prepare.js +40 -31
  22. package/.agents/scripts/epic-plan-decompose.js +2 -5
  23. package/.agents/scripts/epic-plan-spec.js +16 -19
  24. package/.agents/scripts/hierarchy-gate.js +11 -11
  25. package/.agents/scripts/lib/ITicketingProvider.js +4 -3
  26. package/.agents/scripts/lib/bdd-runner-detect.js +1 -1
  27. package/.agents/scripts/lib/bdd-scenario-scanner.js +1 -1
  28. package/.agents/scripts/lib/cli-args.js +1 -5
  29. package/.agents/scripts/lib/codebase-snapshot.js +1 -1
  30. package/.agents/scripts/lib/config/explain.js +4 -1
  31. package/.agents/scripts/lib/config/temp-paths.js +1 -4
  32. package/.agents/scripts/lib/config-settings-schema.js +30 -1
  33. package/.agents/scripts/lib/epic-body-sections.js +310 -0
  34. package/.agents/scripts/lib/epic-plan-clarity.js +38 -1
  35. package/.agents/scripts/lib/epic-plan-ideation.js +15 -3
  36. package/.agents/scripts/lib/findings/promote-finding.js +3 -3
  37. package/.agents/scripts/lib/findings/severity.js +5 -6
  38. package/.agents/scripts/lib/label-constants.js +7 -17
  39. package/.agents/scripts/lib/label-taxonomy.js +4 -21
  40. package/.agents/scripts/lib/orchestration/check-baselines/phases/evaluate.js +65 -2
  41. package/.agents/scripts/lib/orchestration/context-hydration-engine.js +105 -9
  42. package/.agents/scripts/lib/orchestration/doc-reader.js +29 -0
  43. package/.agents/scripts/lib/orchestration/docs-digest.js +134 -0
  44. package/.agents/scripts/lib/orchestration/epic-plan-decompose/phases/context.js +23 -22
  45. package/.agents/scripts/lib/orchestration/epic-plan-decompose/phases/persist-helpers.js +7 -10
  46. package/.agents/scripts/lib/orchestration/epic-plan-decompose/phases/planning-artifacts.js +4 -38
  47. package/.agents/scripts/lib/orchestration/epic-plan-lease-guard.js +8 -9
  48. package/.agents/scripts/lib/orchestration/epic-plan-spec/phases/authoring-context.js +11 -5
  49. package/.agents/scripts/lib/orchestration/epic-plan-spec/phases/cli-args.js +26 -5
  50. package/.agents/scripts/lib/orchestration/epic-plan-spec/phases/plan-epic.js +102 -304
  51. package/.agents/scripts/lib/orchestration/epic-plan-spec/phases/prompts.js +32 -29
  52. package/.agents/scripts/lib/orchestration/epic-plan-spec/phases/run-spec-phase.js +19 -20
  53. package/.agents/scripts/lib/orchestration/epic-plan-spec/phases/spec-authoring-grounding.js +1 -1
  54. package/.agents/scripts/lib/orchestration/epic-plan-spec/phases/spec-freshness.js +6 -9
  55. package/.agents/scripts/lib/orchestration/epic-plan-state-store.js +3 -4
  56. package/.agents/scripts/lib/orchestration/epic-runner/phases/build-wave-dag.js +1 -1
  57. package/.agents/scripts/lib/orchestration/epic-runner/phases/snapshot.js +20 -27
  58. package/.agents/scripts/lib/orchestration/lifecycle/listeners/acceptance-reconciler.js +11 -5
  59. package/.agents/scripts/lib/orchestration/lifecycle/listeners/finalizer.js +22 -59
  60. package/.agents/scripts/lib/orchestration/lifecycle/listeners/index.js +1 -1
  61. package/.agents/scripts/lib/orchestration/planning-context-budget.js +1 -1
  62. package/.agents/scripts/lib/orchestration/preflight-cache.js +1 -1
  63. package/.agents/scripts/lib/orchestration/spec-freshness.js +3 -3
  64. package/.agents/scripts/lib/orchestration/spec-section-validator.js +1 -1
  65. package/.agents/scripts/lib/orchestration/story-close/baseline-attribution/phases/refresh-commit.js +15 -1
  66. package/.agents/scripts/lib/orchestration/ticket-validator-sizing.js +122 -1
  67. package/.agents/scripts/lib/orchestration/ticketing/bulk.js +5 -8
  68. package/.agents/scripts/lib/orchestration/ticketing/reads.js +2 -2
  69. package/.agents/scripts/lib/plan-phase-cleanup.js +1 -2
  70. package/.agents/scripts/lib/qa/console-allowlist.js +5 -4
  71. package/.agents/scripts/lib/qa/qa-context-hydrator.js +6 -85
  72. package/.agents/scripts/lib/qa/resolve-qa-contract.js +144 -8
  73. package/.agents/scripts/lib/templates/decomposer-prompts.js +14 -8
  74. package/.agents/scripts/lifecycle-emit.js +1 -1
  75. package/.agents/scripts/lint-label-vocabulary.js +2 -3
  76. package/.agents/scripts/providers/github/mappers.js +0 -3
  77. package/.agents/scripts/providers/github/tickets.js +7 -18
  78. package/.agents/scripts/single-story-init.js +0 -1
  79. package/.agents/scripts/story-init.js +1 -29
  80. package/.agents/skills/core/epic-plan-consolidate/SKILL.md +43 -22
  81. package/.agents/skills/core/epic-plan-consolidate/examples.md +51 -0
  82. package/.agents/skills/core/epic-plan-decompose-author/SKILL.md +27 -40
  83. package/.agents/skills/core/epic-plan-decompose-author/examples.md +47 -0
  84. package/.agents/skills/core/epic-plan-premortem/SKILL.md +15 -13
  85. package/.agents/skills/core/epic-plan-premortem/examples.md +53 -0
  86. package/.agents/skills/core/epic-plan-spec-author/SKILL.md +143 -151
  87. package/.agents/skills/core/epic-plan-spec-author/examples.md +91 -0
  88. package/.agents/skills/core/hydrate-context/SKILL.md +10 -5
  89. package/.agents/skills/core/knowledge-transfer/SKILL.md +3 -2
  90. package/.agents/skills/core/scope-triage/SKILL.md +2 -1
  91. package/.agents/skills/skills.index.json +8 -8
  92. package/.agents/skills/stack/qa/qa-explore-driving/SKILL.md +52 -38
  93. package/.agents/templates/epic-from-idea.md +4 -0
  94. package/.agents/workflows/audit-to-stories.md +2 -2
  95. package/.agents/workflows/helpers/code-review.md +11 -9
  96. package/.agents/workflows/helpers/deliver-epic-reference.md +514 -0
  97. package/.agents/workflows/helpers/deliver-epic.md +173 -490
  98. package/.agents/workflows/helpers/epic-audit.md +11 -8
  99. package/.agents/workflows/helpers/epic-deliver-story.md +45 -27
  100. package/.agents/workflows/helpers/epic-plan-decompose.md +17 -12
  101. package/.agents/workflows/helpers/epic-plan-spec.md +68 -68
  102. package/.agents/workflows/helpers/parallel-tooling.md +2 -1
  103. package/.agents/workflows/helpers/plan-epic-reference.md +136 -0
  104. package/.agents/workflows/helpers/plan-epic.md +141 -256
  105. package/.agents/workflows/helpers/plan-story.md +31 -61
  106. package/.agents/workflows/helpers/qa-run-scenario.md +194 -0
  107. package/.agents/workflows/helpers/scope-triage-gate.md +97 -0
  108. package/.agents/workflows/helpers/single-story-deliver-reference.md +423 -0
  109. package/.agents/workflows/helpers/single-story-deliver.md +129 -393
  110. package/.agents/workflows/helpers/worktree-lifecycle.md +1 -1
  111. package/.agents/workflows/plan.md +8 -8
  112. package/.agents/workflows/qa-assist.md +2 -1
  113. package/.agents/workflows/qa-explore.md +63 -32
  114. package/.agents/workflows/qa-run.md +293 -130
  115. package/docs/CHANGELOG.md +35 -0
  116. package/package.json +1 -1
  117. package/.agents/schemas/qa-finding.schema.json +0 -133
  118. package/.agents/scripts/lib/issue-link-parser.js +0 -74
  119. package/.agents/scripts/lib/orchestration/finalize/close-planning-tickets.js +0 -116
  120. package/.agents/scripts/lib/orchestration/planning-state-manager.js +0 -318
  121. package/.agents/scripts/lib/story-init/hierarchy-tracer.js +0 -57
@@ -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) {
@@ -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
@@ -232,7 +232,10 @@ const PREFIX_MEANINGS = Object.freeze([
232
232
  'planning.taskSizing',
233
233
  'Story-sizing threshold for the decompose validator.',
234
234
  ],
235
- ['qa.signInSeam', 'QA harness sign-in seam configuration.'],
235
+ [
236
+ 'qa.environments',
237
+ 'QA harness deployment target (baseUrl, per-environment sign-in seam, allowWrites gate).',
238
+ ],
236
239
  ['qa.personas', 'QA harness persona / credential mapping.'],
237
240
  ]);
238
241
 
@@ -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
  };
@@ -401,12 +406,36 @@ const QA_PERSONAS_SCHEMA = {
401
406
  ],
402
407
  };
403
408
 
409
+ // `environments` is the environment-keyed contract (Epic #4326, Story #4327).
410
+ // It replaces the retired top-level single `signInSeam` shape: each named
411
+ // environment carries its own `baseUrl`, its own per-environment `signInSeam`
412
+ // (reusing the same url-template/skill union), and an optional `allowWrites`
413
+ // gate. Downstream, `resolveQaEnvironment` selects one environment per
414
+ // invocation by name or by raw-URL origin match against `baseUrl`. The map
415
+ // must carry at least one environment. This is a hard cutover — there is no
416
+ // top-level `signInSeam` acceptance branch (see
417
+ // `.agents/rules/git-conventions.md` § Contract Cutovers).
418
+ const QA_ENVIRONMENTS_SCHEMA = {
419
+ type: 'object',
420
+ minProperties: 1,
421
+ additionalProperties: {
422
+ type: 'object',
423
+ properties: {
424
+ baseUrl: { ...SAFE_STRING, minLength: 1 },
425
+ signInSeam: QA_SIGN_IN_SEAM_SCHEMA,
426
+ allowWrites: { type: 'boolean' },
427
+ },
428
+ required: ['baseUrl', 'signInSeam'],
429
+ additionalProperties: false,
430
+ },
431
+ };
432
+
404
433
  export const QA_SCHEMA = {
405
434
  type: 'object',
406
435
  properties: {
407
436
  featureRoot: { ...SAFE_STRING, minLength: 1 },
408
437
  fixturesManifest: { ...SAFE_STRING, minLength: 1 },
409
- signInSeam: QA_SIGN_IN_SEAM_SCHEMA,
438
+ environments: QA_ENVIRONMENTS_SCHEMA,
410
439
  personas: QA_PERSONAS_SCHEMA,
411
440
  consoleAllowlist: {
412
441
  type: 'array',
@@ -0,0 +1,310 @@
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
+ * `##` headings dropped from the delivery-hydrated Epic body. These are
203
+ * ideation / authoring / close-machinery sections a story agent never acts
204
+ * on: keeping them out of the per-Story prompt trims token cost without
205
+ * losing any binding context (the Story carries its own inline
206
+ * acceptance[] / verify[]).
207
+ *
208
+ * @type {ReadonlySet<string>}
209
+ */
210
+ const DELIVERY_DROP_HEADINGS = new Set([
211
+ 'context',
212
+ 'scope',
213
+ 'acceptance criteria',
214
+ ]);
215
+
216
+ /**
217
+ * Slice an Epic body down to the sections a delivery story agent acts on.
218
+ *
219
+ * KEEP: the Epic title / preamble before the first `##`, `## Goal`,
220
+ * `## Non-Goals`, `## User Stories`, the `techSpec` managed region, and —
221
+ * fail-open — any unknown / operator-authored `##` section not in the drop
222
+ * list. DROP: `## Context`, `## Scope`, `## Acceptance Criteria`, and the
223
+ * `acceptanceTable` managed region.
224
+ *
225
+ * The `techSpec` region is located by its markers (not by heading
226
+ * boundaries) so its inner `## Delivery Slicing` heading is preserved
227
+ * verbatim and never mistaken for a plain droppable section. Plain `##`
228
+ * sections outside the managed regions are sliced by heading boundaries.
229
+ *
230
+ * Fail-open is load-bearing: any heading not explicitly in the drop set is
231
+ * kept, so operator-authored content is never silently lost.
232
+ *
233
+ * @param {string} body
234
+ * @returns {string}
235
+ */
236
+ export function sliceEpicBodyForDelivery(body) {
237
+ if (typeof body !== 'string' || body.length === 0) return '';
238
+
239
+ // 1. Drop the acceptance-table managed region outright (authoring/close
240
+ // machinery, never delivery context).
241
+ let working = stripEpicSection(body, 'acceptanceTable');
242
+
243
+ // 2. Protect the techSpec managed region from heading-boundary slicing by
244
+ // lifting it out behind an opaque placeholder, then restoring it after
245
+ // the plain-section pass. Its inner `## Delivery Slicing` heading must
246
+ // survive verbatim.
247
+ const techLoc = locate(working, 'techSpec');
248
+ let techRegion = null;
249
+ const PLACEHOLDER = 'MANDREL_TECH_SPEC_PLACEHOLDER';
250
+ if (techLoc) {
251
+ const { end } = descriptor('techSpec');
252
+ techRegion = working.slice(techLoc.startIdx, techLoc.endIdx + end.length);
253
+ working =
254
+ working.slice(0, techLoc.startIdx) +
255
+ PLACEHOLDER +
256
+ working.slice(techLoc.endIdx + end.length);
257
+ }
258
+
259
+ // 3. Slice plain `##` sections by heading boundaries, dropping only the
260
+ // known ideation/authoring headings. The preamble before the first
261
+ // `##` (Epic title / lede) is always kept.
262
+ const kept = [];
263
+ let dropping = false;
264
+ for (const line of working.split('\n')) {
265
+ const headingMatch = line.match(/^##\s+(.*?)\s*$/);
266
+ if (headingMatch) {
267
+ dropping = DELIVERY_DROP_HEADINGS.has(
268
+ headingMatch[1].trim().toLowerCase(),
269
+ );
270
+ if (dropping) continue;
271
+ }
272
+ if (dropping) continue;
273
+ kept.push(line);
274
+ }
275
+ working = kept.join('\n');
276
+
277
+ // 4. Restore the techSpec region in place.
278
+ if (techRegion !== null) {
279
+ working = working.replace(PLACEHOLDER, () => techRegion);
280
+ }
281
+
282
+ // 5. Normalise the blank-line runs left by the drops.
283
+ return working
284
+ .replace(/\n{3,}/g, '\n\n')
285
+ .replace(/^\n+/, '')
286
+ .trimEnd();
287
+ }
288
+
289
+ /**
290
+ * Strip the retired machine-managed `## Planning Artifacts` checklist from
291
+ * an Epic body (the section that linked the now-retired context tickets).
292
+ * The slice ends at the next `## ` heading, a managed-region marker, or
293
+ * EOF. Historical bodies without the section pass through untouched.
294
+ *
295
+ * @param {string} body
296
+ * @returns {string}
297
+ */
298
+ export function stripPlanningArtifactsSection(body) {
299
+ if (typeof body !== 'string' || body.length === 0) return '';
300
+ const headingMatch = body.match(/^##\s+Planning Artifacts[^\n]*$/m);
301
+ if (!headingMatch || typeof headingMatch.index !== 'number') return body;
302
+ const start = headingMatch.index;
303
+ const afterHeading = start + headingMatch[0].length;
304
+ const rest = body.slice(afterHeading);
305
+ const boundary = rest.search(/^(?:##\s|<!-- mandrel:)/m);
306
+ const end = boundary === -1 ? body.length : afterHeading + boundary;
307
+ const before = body.slice(0, start).replace(/\n+$/, '\n');
308
+ const after = body.slice(end);
309
+ return (before + after).replace(/\n{3,}/g, '\n\n').replace(/^\n+/, '');
310
+ }
@@ -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
 
@@ -196,8 +196,8 @@ function clusterToFinding(cluster) {
196
196
  /**
197
197
  * Build the `routedTo` link the schema stamps onto a promoted ledger item.
198
198
  *
199
- * The `routedTo.url` field is `minLength: 1` in both `qa-finding.schema.json`
200
- * and `qa-ledger.schema.json`, and the search/create port contract requires a
199
+ * The `routedTo.url` field is `minLength: 1` in `qa-ledger.schema.json`,
200
+ * and the search/create port contract requires a
201
201
  * routed issue to carry its canonical URL. So rather than silently stamp an
202
202
  * empty string (which would persist a schema-invalid ledger item), this guards
203
203
  * the url and throws when it is absent or blank (Story #3816, AC #4).
@@ -213,7 +213,7 @@ function routedToLink(issue, kind) {
213
213
  throw new Error(
214
214
  `promoteFindings: routed issue #${issue?.number ?? '?'} is missing a url; ` +
215
215
  'the search/create port contract requires a non-empty url ' +
216
- '(routedTo.url is minLength:1 in the qa-finding/qa-ledger schemas)',
216
+ '(routedTo.url is minLength:1 in the qa-ledger schema)',
217
217
  );
218
218
  }
219
219
  return {