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
@@ -2,21 +2,22 @@
2
2
  /**
3
3
  * acceptance-spec-reconciler.js — Story #2106 / Task #2113 (Epic #2001).
4
4
  *
5
- * Diffs the AC IDs declared in an Epic's linked `context::acceptance-spec`
6
- * body against the **per-Epic-namespaced** `@epic-<id>-ac-*` / `@pending`
7
- * tags emitted by scenarios under `tests/features/**`. The namespace is
8
- * load-bearing (Story #3362): `tests/features` is a single global tree
9
- * shared by every Epic, so a bare `@ac-N` tag authored under an unrelated
10
- * Epic's scenarios must not count as coverage for this Epic. Surfaces three
11
- * categories:
5
+ * Diffs the AC IDs declared in the Epic body's `## Acceptance Table`
6
+ * managed section (Story #4324 retired the `context::acceptance-spec`
7
+ * ticket class the table now lives on the Epic body itself) against the
8
+ * **per-Epic-namespaced** `@epic-<id>-ac-*` / `@pending` tags emitted by
9
+ * scenarios under `tests/features/**`. The namespace is load-bearing
10
+ * (Story #3362): `tests/features` is a single global tree shared by every
11
+ * Epic, so a bare `@ac-N` tag authored under an unrelated Epic's scenarios
12
+ * must not count as coverage for this Epic. Surfaces three categories:
12
13
  *
13
14
  * - `satisfied[]` — AC IDs covered by at least one non-pending scenario.
14
15
  * - `pending[]` — AC IDs covered only by scenarios tagged `@pending`.
15
16
  * - `missing[]` — AC IDs declared in the spec with no matching scenario.
16
17
  *
17
18
  * Used by `epic-deliver-finalize.js` (Task #2111) as a close-time gate: a
18
- * non-OK result aborts finalize before `closePlanningArtifacts` fires, so
19
- * planning artifacts stay open until the AC coverage gap is fixed.
19
+ * non-OK result aborts finalize before the PR opens, so the Epic stays
20
+ * blocked until the AC coverage gap is fixed.
20
21
  *
21
22
  * Per `.agents/rules/orchestration-error-handling.md`, this module
22
23
  * **throws `Error`** for unrecoverable conditions (rather than calling
@@ -27,16 +28,25 @@
27
28
  * Usage:
28
29
  * node .agents/scripts/acceptance-spec-reconciler.js --epic <epicId>
29
30
  *
31
+ * When invoked with `writeDispositions: true` (the close-time lifecycle
32
+ * listener path), the reconciler records the verification outcome of each
33
+ * AC row — `satisfied` / `pending` / `missing` — into the Disposition
34
+ * column of the `## Acceptance Table` section. The write is
35
+ * **section-scoped**: only the managed acceptance-table region of the Epic
36
+ * body is rewritten; everything outside it is byte-preserved (Story #4324
37
+ * guardrail, extending the single-writer discipline of #4303).
38
+ *
30
39
  * Stdout: a single JSON envelope:
31
40
  * {
32
41
  * "epicId": <number>,
33
- * "acceptanceSpecId": <number|null>,
34
42
  * "ok": <boolean>,
35
- * "ackIds": ["AC-1", "AC-2", ...],
43
+ * "status": "ok"|"waived"|"empty-spec"|"gap",
44
+ * "acIds": ["AC-1", "AC-2", ...],
36
45
  * "satisfied": ["AC-1", ...],
37
46
  * "pending": ["AC-2", ...],
38
47
  * "missing": ["AC-3", ...],
39
- * "featureFilesScanned": <number>
48
+ * "featureFilesScanned": <number>,
49
+ * "dispositionsUpdated": <boolean>
40
50
  * }
41
51
  */
42
52
 
@@ -47,34 +57,40 @@ import { parseArgs } from 'node:util';
47
57
  import { PENDING_TAGS } from './lib/bdd-runner-detect.js';
48
58
  import { runAsCli } from './lib/cli-utils.js';
49
59
  import { PROJECT_ROOT, resolveConfig } from './lib/config-resolver.js';
50
- import { parseLinkedIssues } from './lib/issue-link-parser.js';
60
+ import {
61
+ extractEpicSection,
62
+ upsertEpicSection,
63
+ } from './lib/epic-body-sections.js';
51
64
  import { Logger } from './lib/Logger.js';
52
65
  import { ACCEPTANCE_NA } from './lib/label-constants.js';
53
66
  import { createProvider } from './lib/provider-factory.js';
54
67
 
55
68
  const HELP = `Usage: node .agents/scripts/acceptance-spec-reconciler.js --epic <epicId>
56
69
 
57
- Diffs the AC IDs in the Epic's linked acceptance-spec body against the
58
- @ac-*/@pending tags in tests/features/**. Emits a JSON envelope on stdout.
59
- Throws (exit 1) when missing or pending ACs are detected, or when the Epic
60
- has no linked acceptance-spec and the acceptance::n-a waiver label is
61
- absent.
70
+ Diffs the AC IDs in the Epic body's ## Acceptance Table section against the
71
+ @epic-<id>-ac-*/@pending tags in tests/features/**. Emits a JSON envelope on
72
+ stdout. Throws (exit 1) when missing or pending ACs are detected, or when
73
+ the Epic body has no acceptance-table section and the acceptance::n-a
74
+ waiver label is absent.
62
75
 
63
76
  Options:
64
77
  --epic <id> Epic ticket id (required)
65
78
  --features-dir <path> Override features directory (default: tests/features)
66
79
  --skip-when-waived Exit 0 with status='waived' when acceptance::n-a is
67
- set instead of throwing on the missing spec.
80
+ set instead of throwing on the missing section.
81
+ --write-dispositions Record each AC's verification outcome into the
82
+ Disposition column of the ## Acceptance Table
83
+ section (section-scoped write).
68
84
  -h, --help Show this message and exit.
69
85
  `;
70
86
 
71
87
  /**
72
- * Pure: parse stable AC IDs (AC-<n>) out of an acceptance-spec body. AC
73
- * authoring style is "Acceptance Criteria — Markdown table whose first
88
+ * Pure: parse stable AC IDs (AC-<n>) out of an acceptance-table section.
89
+ * AC authoring style is "Acceptance Table — Markdown table whose first
74
90
  * column is the AC ID" — see ACCEPTANCE_SPEC_SYSTEM_PROMPT in
75
- * epic-plan-spec.js. We scan the entire body with a permissive regex
76
- * because operators are free to format the body however they wish around
77
- * the canonical table.
91
+ * epic-plan-spec.js. We scan the entire section with a permissive regex
92
+ * because operators are free to format the content however they wish
93
+ * around the canonical table.
78
94
  *
79
95
  * Returns IDs **in document order**, deduplicated, normalised to
80
96
  * upper-case (`AC-7`, not `ac-7`).
@@ -282,15 +298,10 @@ export function classifyCoverage({ acIds, tagSets, epicId = null }) {
282
298
  * Pure: render the operator-visible blocker message for a non-OK
283
299
  * reconciliation result. Exported so finalize can surface the same text.
284
300
  */
285
- export function renderBlockerMessage({
286
- epicId,
287
- acceptanceSpecId,
288
- missing,
289
- pending,
290
- }) {
301
+ export function renderBlockerMessage({ epicId, missing, pending }) {
291
302
  const lines = [
292
303
  `[acceptance-spec-reconciler] Epic #${epicId} cannot finalize:`,
293
- `linked acceptance-spec #${acceptanceSpecId ?? '(none)'} has uncovered AC IDs.`,
304
+ `the Epic body's ## Acceptance Table section has uncovered AC IDs.`,
294
305
  ];
295
306
  if (missing.length > 0) {
296
307
  lines.push(
@@ -306,25 +317,69 @@ export function renderBlockerMessage({
306
317
  return lines.join('\n');
307
318
  }
308
319
 
320
+ /**
321
+ * Pure: rewrite the Disposition column of the acceptance-table section so
322
+ * each AC row records its close-time verification outcome. Only table rows
323
+ * whose first data cell is an `AC-<n>` id are touched; header/divider rows,
324
+ * prose, and rows for unclassified ACs pass through verbatim.
325
+ *
326
+ * @param {string} sectionContent The `## Acceptance Table` section content.
327
+ * @param {{ satisfied: string[], pending: string[], missing: string[] }} classification
328
+ * @returns {string}
329
+ */
330
+ export function renderDispositions(sectionContent, classification) {
331
+ const outcomeById = new Map();
332
+ for (const id of classification.satisfied ?? []) {
333
+ outcomeById.set(id.toUpperCase(), 'satisfied');
334
+ }
335
+ for (const id of classification.pending ?? []) {
336
+ outcomeById.set(id.toUpperCase(), 'pending');
337
+ }
338
+ for (const id of classification.missing ?? []) {
339
+ outcomeById.set(id.toUpperCase(), 'missing');
340
+ }
341
+ const lines = String(sectionContent ?? '').split('\n');
342
+ const out = lines.map((line) => {
343
+ const trimmed = line.trim();
344
+ if (!trimmed.startsWith('|')) return line;
345
+ const cells = trimmed.split('|');
346
+ // `| a | b |` splits into ['', ' a ', ' b ', ''] — data cells are 1..-2.
347
+ if (cells.length < 4) return line;
348
+ const idMatch = cells[1].trim().match(/^AC-(\d+)$/i);
349
+ if (!idMatch) return line;
350
+ const outcome = outcomeById.get(`AC-${idMatch[1]}`.toUpperCase());
351
+ if (!outcome) return line;
352
+ cells[cells.length - 2] = ` ${outcome} `;
353
+ return cells.join('|');
354
+ });
355
+ return out.join('\n');
356
+ }
357
+
309
358
  /**
310
359
  * End-to-end reconcile. DI-friendly for tests.
311
360
  *
312
361
  * Behaviour:
313
362
  * - If the Epic carries the `acceptance::n-a` waiver label, returns
314
363
  * `{ ok: true, status: 'waived', ... }` without scanning features.
315
- * - If no acceptance-spec is linked and the waiver is absent, **throws**
316
- * a clear `Error` — this should never happen in practice because
317
- * `epic-deliver-finalize` runs after `runSnapshotPhase`'s start gate,
318
- * but we defend against direct CLI invocation.
319
- * - If the linked acceptance-spec body declares zero AC IDs, returns
364
+ * - If the Epic body has no `## Acceptance Table` managed section and
365
+ * the waiver is absent, **throws** a clear `Error` — this should never
366
+ * happen in practice because `/deliver` runs after `runSnapshotPhase`'s
367
+ * start gate, but we defend against direct CLI invocation.
368
+ * - If the acceptance-table section declares zero AC IDs, returns
320
369
  * `{ ok: true, status: 'empty-spec', ... }`.
321
370
  * - Otherwise classifies coverage and returns `{ ok, status, ... }`.
371
+ * With `writeDispositions: true`, the classification is also recorded
372
+ * into the Disposition column of the acceptance-table section — a
373
+ * section-scoped write that preserves every byte outside the managed
374
+ * region (best-effort: a write failure downgrades to a warning and
375
+ * never changes the verdict).
322
376
  *
323
377
  * @param {{
324
378
  * epicId: number,
325
379
  * cwd?: string,
326
380
  * featuresDir?: string,
327
381
  * skipWhenWaived?: boolean,
382
+ * writeDispositions?: boolean,
328
383
  * injectedProvider?: object,
329
384
  * injectedConfig?: object,
330
385
  * loggerImpl?: { info?: Function, warn?: Function, error?: Function },
@@ -333,7 +388,6 @@ export function renderBlockerMessage({
333
388
  * }} args
334
389
  * @returns {Promise<{
335
390
  * epicId: number,
336
- * acceptanceSpecId: number|null,
337
391
  * status: 'ok'|'waived'|'empty-spec'|'gap',
338
392
  * ok: boolean,
339
393
  * acIds: string[],
@@ -341,6 +395,7 @@ export function renderBlockerMessage({
341
395
  * pending: string[],
342
396
  * missing: string[],
343
397
  * featureFilesScanned: number,
398
+ * dispositionsUpdated: boolean,
344
399
  * }>}
345
400
  */
346
401
  export async function reconcileAcceptanceSpec({
@@ -348,6 +403,7 @@ export async function reconcileAcceptanceSpec({
348
403
  cwd,
349
404
  featuresDir,
350
405
  skipWhenWaived = false,
406
+ writeDispositions = false,
351
407
  injectedProvider,
352
408
  injectedConfig,
353
409
  loggerImpl,
@@ -367,9 +423,9 @@ export async function reconcileAcceptanceSpec({
367
423
  ? path.resolve(repoCwd, featuresDir)
368
424
  : path.resolve(repoCwd, 'tests', 'features');
369
425
 
370
- // 1. Load the Epic; prefer getEpic for the linkedIssues hydration but
371
- // fall back to getTicket + body parsing for providers that don't
372
- // expose the Epic-shaped reader (test doubles, primarily).
426
+ // 1. Load the Epic. `getEpic` is preferred; fall back to `getTicket`
427
+ // for providers that don't expose the Epic-shaped reader (test
428
+ // doubles, primarily). Only `body` and `labels` are consumed.
373
429
  let epic;
374
430
  if (typeof provider.getEpic === 'function') {
375
431
  epic = await provider.getEpic(epicId);
@@ -387,7 +443,6 @@ export async function reconcileAcceptanceSpec({
387
443
  );
388
444
  return {
389
445
  epicId,
390
- acceptanceSpecId: null,
391
446
  status: 'waived',
392
447
  ok: true,
393
448
  acIds: [],
@@ -395,20 +450,22 @@ export async function reconcileAcceptanceSpec({
395
450
  pending: [],
396
451
  missing: [],
397
452
  featureFilesScanned: 0,
453
+ dispositionsUpdated: false,
398
454
  };
399
455
  }
400
456
 
401
- const linkedIssues = epic.linkedIssues ?? parseLinkedIssues(epic.body ?? '');
402
- const acceptanceSpecId = linkedIssues?.acceptanceSpec ?? null;
457
+ const acceptanceSection = extractEpicSection(
458
+ epic.body ?? '',
459
+ 'acceptanceTable',
460
+ );
403
461
 
404
- if (!acceptanceSpecId) {
462
+ if (acceptanceSection === null) {
405
463
  if (skipWhenWaived) {
406
464
  logger.info?.(
407
- `[acceptance-spec-reconciler] Epic #${epicId} has no linked context::acceptance-spec ticket; --skip-when-waived set, returning status='waived'.`,
465
+ `[acceptance-spec-reconciler] Epic #${epicId} body has no ## Acceptance Table section; --skip-when-waived set, returning status='waived'.`,
408
466
  );
409
467
  return {
410
468
  epicId,
411
- acceptanceSpecId: null,
412
469
  status: 'waived',
413
470
  ok: true,
414
471
  acIds: [],
@@ -416,22 +473,16 @@ export async function reconcileAcceptanceSpec({
416
473
  pending: [],
417
474
  missing: [],
418
475
  featureFilesScanned: 0,
476
+ dispositionsUpdated: false,
419
477
  };
420
478
  }
421
479
  // Defence in depth — the start gate would normally catch this.
422
480
  throw new Error(
423
- `[acceptance-spec-reconciler] Epic #${epicId} has no linked context::acceptance-spec ticket and no acceptance::n-a waiver label. Re-run /plan Phase 7 or apply the waiver.`,
481
+ `[acceptance-spec-reconciler] Epic #${epicId} body has no ## Acceptance Table section and no acceptance::n-a waiver label. Re-run /plan Phase 7 or apply the waiver.`,
424
482
  );
425
483
  }
426
484
 
427
- const spec = await provider.getTicket(acceptanceSpecId);
428
- if (!spec) {
429
- throw new Error(
430
- `[acceptance-spec-reconciler] Linked acceptance-spec #${acceptanceSpecId} not found.`,
431
- );
432
- }
433
-
434
- const acIds = parseAcIds(spec.body ?? '');
485
+ const acIds = parseAcIds(acceptanceSection);
435
486
 
436
487
  // 2. Scan feature files.
437
488
  const featureFiles = listFeatureFiles(dir);
@@ -453,11 +504,10 @@ export async function reconcileAcceptanceSpec({
453
504
 
454
505
  if (acIds.length === 0) {
455
506
  logger.warn?.(
456
- `[acceptance-spec-reconciler] Acceptance-spec #${acceptanceSpecId} declares zero AC IDs — treating as empty spec.`,
507
+ `[acceptance-spec-reconciler] Epic #${epicId} acceptance-table section declares zero AC IDs — treating as empty spec.`,
457
508
  );
458
509
  return {
459
510
  epicId,
460
- acceptanceSpecId,
461
511
  status: 'empty-spec',
462
512
  ok: true,
463
513
  acIds: [],
@@ -465,6 +515,7 @@ export async function reconcileAcceptanceSpec({
465
515
  pending: [],
466
516
  missing: [],
467
517
  featureFilesScanned: featureFiles.length,
518
+ dispositionsUpdated: false,
468
519
  };
469
520
  }
470
521
 
@@ -475,9 +526,39 @@ export async function reconcileAcceptanceSpec({
475
526
  });
476
527
  const ok = missing.length === 0 && pending.length === 0;
477
528
 
529
+ // 3. Optional close-time disposition write-back. Section-scoped: the
530
+ // upsert replaces only the managed acceptance-table region; every
531
+ // byte outside it is preserved. Best-effort — a failed write is a
532
+ // warning, never a verdict change.
533
+ let dispositionsUpdated = false;
534
+ if (writeDispositions && typeof provider.updateTicket === 'function') {
535
+ try {
536
+ const rewrittenSection = renderDispositions(acceptanceSection, {
537
+ satisfied,
538
+ pending,
539
+ missing,
540
+ });
541
+ if (rewrittenSection !== acceptanceSection) {
542
+ const newBody = upsertEpicSection(
543
+ epic.body ?? '',
544
+ 'acceptanceTable',
545
+ rewrittenSection,
546
+ );
547
+ await provider.updateTicket(epicId, { body: newBody });
548
+ dispositionsUpdated = true;
549
+ logger.info?.(
550
+ `[acceptance-spec-reconciler] Recorded verification dispositions for ${acIds.length} AC row(s) in Epic #${epicId}'s ## Acceptance Table section.`,
551
+ );
552
+ }
553
+ } catch (err) {
554
+ logger.warn?.(
555
+ `[acceptance-spec-reconciler] disposition write-back failed (verdict unaffected): ${err?.message ?? err}`,
556
+ );
557
+ }
558
+ }
559
+
478
560
  return {
479
561
  epicId,
480
- acceptanceSpecId,
481
562
  status: ok ? 'ok' : 'gap',
482
563
  ok,
483
564
  acIds,
@@ -485,6 +566,7 @@ export async function reconcileAcceptanceSpec({
485
566
  pending,
486
567
  missing,
487
568
  featureFilesScanned: featureFiles.length,
569
+ dispositionsUpdated,
488
570
  };
489
571
  }
490
572
 
@@ -510,6 +592,7 @@ export function classifyReconcilerInvocation(values) {
510
592
  epicId,
511
593
  featuresDir: values['features-dir'] ?? null,
512
594
  skipWhenWaived: values['skip-when-waived'] === true,
595
+ writeDispositions: values['write-dispositions'] === true,
513
596
  };
514
597
  }
515
598
 
@@ -519,6 +602,7 @@ async function main() {
519
602
  epic: { type: 'string' },
520
603
  'features-dir': { type: 'string' },
521
604
  'skip-when-waived': { type: 'boolean' },
605
+ 'write-dispositions': { type: 'boolean' },
522
606
  help: { type: 'boolean', short: 'h' },
523
607
  },
524
608
  strict: false,
@@ -536,6 +620,7 @@ async function main() {
536
620
  epicId: intent.epicId,
537
621
  featuresDir: intent.featuresDir ?? undefined,
538
622
  skipWhenWaived: intent.skipWhenWaived,
623
+ writeDispositions: intent.writeDispositions,
539
624
  });
540
625
  // Always emit the structured envelope to stdout, even on non-OK, so a
541
626
  // caller capturing stdout can read the diff payload before reacting to
@@ -545,7 +630,6 @@ async function main() {
545
630
  throw new Error(
546
631
  renderBlockerMessage({
547
632
  epicId: result.epicId,
548
- acceptanceSpecId: result.acceptanceSpecId,
549
633
  missing: result.missing,
550
634
  pending: result.pending,
551
635
  }),
@@ -32,14 +32,16 @@
32
32
  * node .agents/scripts/epic-deliver-prepare.js --epic <epicId>
33
33
  */
34
34
 
35
+ import fs from 'node:fs';
36
+ import path from 'node:path';
35
37
  import { parseArgs } from 'node:util';
36
38
 
37
39
  import { runAsCli } from './lib/cli-utils.js';
38
- import { getRunners, resolveConfig } from './lib/config-resolver.js';
40
+ import { getPaths, getRunners, resolveConfig } from './lib/config-resolver.js';
39
41
  import { currentBranch as gitCurrentBranch } from './lib/git-branch-lifecycle.js';
40
42
  import { getEpicBranch, gitSpawn } from './lib/git-utils.js';
41
- import { parseLinkedIssues } from './lib/issue-link-parser.js';
42
43
  import { Logger } from './lib/Logger.js';
44
+ import { buildDocsDigest } from './lib/orchestration/docs-digest.js';
43
45
  import {
44
46
  resolveOperator,
45
47
  runPrepareGuards,
@@ -144,8 +146,6 @@ function resolveGitUserEmail(cwd) {
144
146
  * storyCount: number,
145
147
  * concurrencyCap: number,
146
148
  * stories: Array<{ storyId: number, title: string, worktree?: string }>,
147
- * prdId: number|null,
148
- * techSpecId: number|null,
149
149
  * checkpointInitializedAt: string,
150
150
  * }>}
151
151
  */
@@ -289,6 +289,40 @@ function evaluatePrepareConcurrencyGate({
289
289
  return gate;
290
290
  }
291
291
 
292
+ /**
293
+ * Build the per-Epic docs digest and write it to
294
+ * `<tempRoot>/epic-<id>/docs-digest.md`, returning its repo-relative path.
295
+ * Story #4338 — the parent threads this path into every child prompt so
296
+ * delivery sub-agents read one compact outline instead of re-ingesting the
297
+ * full `project.docsContextFiles` set per Story.
298
+ *
299
+ * Keyed off the **un-defaulted** config (`config.raw`): when the operator has
300
+ * not configured `project.docsContextFiles`, this returns `null` (no file
301
+ * written) rather than digesting the resolver's built-in default set — the
302
+ * digest is an opt-in surface for projects that curate their docs context.
303
+ *
304
+ * @param {{ epicId: number, cwd?: string, config: object }} args
305
+ * @returns {Promise<string|null>} repo-relative digest path, or null when
306
+ * `project.docsContextFiles` is empty/unset (or every file is missing).
307
+ */
308
+ async function writeDocsDigest({ epicId, cwd, config }) {
309
+ const rawFiles = config?.raw?.project?.docsContextFiles;
310
+ const docsContextFiles = Array.isArray(rawFiles) ? rawFiles : [];
311
+ if (docsContextFiles.length === 0) return null;
312
+
313
+ const paths = getPaths(config);
314
+ const root = path.resolve(cwd ?? process.cwd());
315
+ const docsRoot = path.resolve(root, paths.docsRoot);
316
+ const digest = await buildDocsDigest({ docsContextFiles, docsRoot });
317
+ if (digest == null) return null;
318
+
319
+ const relPath = path.join(paths.tempRoot, `epic-${epicId}`, 'docs-digest.md');
320
+ const absPath = path.resolve(root, relPath);
321
+ await fs.promises.mkdir(path.dirname(absPath), { recursive: true });
322
+ await fs.promises.writeFile(absPath, digest, 'utf-8');
323
+ return relPath;
324
+ }
325
+
292
326
  export async function runEpicDeliverPrepare({
293
327
  epicId,
294
328
  cwd,
@@ -377,45 +411,20 @@ export async function runEpicDeliverPrepare({
377
411
  });
378
412
  }
379
413
 
380
- // Story #4253: resolve the Epic's PRD / Tech-Spec linkages ONCE here and
381
- // surface them in the prepare envelope. The /deliver fan-out threads these
382
- // into each per-Story `story-init.js --prd/--tech-spec`, collapsing the
383
- // N per-Story `getEpic` round-trips to this single parent-side resolution.
384
- // The Epic snapshot is already in hand (`state.epic`), so this adds no
385
- // extra fetch; the body-parse fallback mirrors hierarchy-tracer's source.
386
- const { prdId, techSpecId } = resolveEpicLinkages(state.epic);
414
+ const docsDigestPath = await writeDocsDigest({ epicId, cwd, config });
387
415
 
388
416
  return {
389
417
  epicId,
390
418
  storyCount: openStories.length,
391
419
  concurrencyCap,
392
420
  stories,
393
- prdId,
394
- techSpecId,
395
421
  checkpointInitializedAt:
396
422
  checkpointState.startedAt ??
397
423
  checkpointState.lastUpdatedAt ??
398
424
  new Date().toISOString(),
399
425
  concurrencyHazardsBypassed: gate.bypassed,
400
426
  preflightCache: cacheStatus,
401
- };
402
- }
403
-
404
- /**
405
- * Resolve an Epic's linked PRD / Tech-Spec issue ids from the snapshot ticket.
406
- * Prefers the provider-supplied `linkedIssues` map and falls back to parsing
407
- * the Epic body's `## Planning Artifacts` section — the same two sources
408
- * `hierarchy-tracer.js` reads — so the threaded ids match what an unthreaded
409
- * `story-init.js` run would have resolved itself. Story #4253.
410
- *
411
- * @param {{ linkedIssues?: { prd?: number|null, techSpec?: number|null }|null, body?: string }|null|undefined} epic
412
- * @returns {{ prdId: number|null, techSpecId: number|null }}
413
- */
414
- function resolveEpicLinkages(epic) {
415
- const linked = epic?.linkedIssues ?? parseLinkedIssues(epic?.body ?? '');
416
- return {
417
- prdId: linked?.prd ?? null,
418
- techSpecId: linked?.techSpec ?? null,
427
+ docsDigestPath,
419
428
  };
420
429
  }
421
430
 
@@ -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,