mandrel 1.87.0 → 1.89.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 (140) hide show
  1. package/.agents/README.md +18 -13
  2. package/.agents/audit-checklists/architecture.md +24 -0
  3. package/.agents/audit-checklists/clean-code.md +24 -0
  4. package/.agents/audit-checklists/dependencies.md +14 -0
  5. package/.agents/audit-checklists/devops.md +17 -0
  6. package/.agents/audit-checklists/documentation.md +22 -0
  7. package/.agents/audit-checklists/lighthouse.md +15 -0
  8. package/.agents/audit-checklists/navigability.md +14 -0
  9. package/.agents/audit-checklists/performance.md +22 -0
  10. package/.agents/audit-checklists/privacy.md +21 -0
  11. package/.agents/audit-checklists/quality.md +18 -0
  12. package/.agents/audit-checklists/security.md +22 -0
  13. package/.agents/audit-checklists/seo.md +16 -0
  14. package/.agents/audit-checklists/sre.md +24 -0
  15. package/.agents/audit-checklists/ux-ui.md +21 -0
  16. package/.agents/docs/SDLC.md +63 -16
  17. package/.agents/docs/configuration.md +5 -3
  18. package/.agents/instructions.md +51 -21
  19. package/.agents/personas/architect.md +10 -7
  20. package/.agents/personas/engineer.md +4 -3
  21. package/.agents/personas/project-manager.md +5 -2
  22. package/.agents/personas/refactorer.md +5 -3
  23. package/.agents/rules/git-conventions.md +77 -0
  24. package/.agents/schemas/agentrc.schema.json +16 -4
  25. package/.agents/schemas/audit-rules.json +16 -2
  26. package/.agents/schemas/audit-rules.schema.json +7 -6
  27. package/.agents/schemas/lifecycle/merge.unlanded.schema.json +38 -0
  28. package/.agents/schemas/signal-event.schema.json +28 -13
  29. package/.agents/scripts/acceptance-spec-reconciler.js +6 -4
  30. package/.agents/scripts/check-context-budget.js +320 -0
  31. package/.agents/scripts/diagnose-friction.js +4 -4
  32. package/.agents/scripts/epic-audit-prepare.js +30 -2
  33. package/.agents/scripts/epic-audit-recheck.js +46 -13
  34. package/.agents/scripts/epic-deliver-prepare.js +80 -8
  35. package/.agents/scripts/epic-plan-spec.js +4 -8
  36. package/.agents/scripts/generate-lens-checklists.js +180 -0
  37. package/.agents/scripts/lib/audit-suite/checklist-threading.js +300 -0
  38. package/.agents/scripts/lib/audit-suite/findings.js +27 -0
  39. package/.agents/scripts/lib/audit-suite/index.js +9 -0
  40. package/.agents/scripts/lib/audit-suite/lens-checklist.js +212 -0
  41. package/.agents/scripts/lib/audit-suite/selector.js +136 -5
  42. package/.agents/scripts/lib/checks/loop-health.js +340 -0
  43. package/.agents/scripts/lib/cli-args.js +8 -0
  44. package/.agents/scripts/lib/config/explain.js +4 -0
  45. package/.agents/scripts/lib/config/runners.js +21 -2
  46. package/.agents/scripts/lib/config/temp-paths.js +24 -0
  47. package/.agents/scripts/lib/config-settings-schema-delivery.js +23 -3
  48. package/.agents/scripts/lib/config-settings-schema-quality.js +7 -0
  49. package/.agents/scripts/lib/doc-tiers.js +291 -0
  50. package/.agents/scripts/lib/epic-body-sections.js +5 -2
  51. package/.agents/scripts/lib/epic-merge-lock.js +83 -0
  52. package/.agents/scripts/lib/epic-plan-clarity.js +3 -1
  53. package/.agents/scripts/lib/feedback-loop/audit-results-graduator.js +66 -20
  54. package/.agents/scripts/lib/feedback-loop/graduator-core.js +395 -86
  55. package/.agents/scripts/lib/feedback-loop/memory-freshness.js +299 -72
  56. package/.agents/scripts/lib/feedback-loop/retro-proposals-graduator.js +438 -0
  57. package/.agents/scripts/lib/gates/friction.js +15 -5
  58. package/.agents/scripts/lib/observability/perf-aggregator.js +30 -104
  59. package/.agents/scripts/lib/observability/perf-report-readers.js +1 -1
  60. package/.agents/scripts/lib/observability/signal-validator.js +204 -0
  61. package/.agents/scripts/lib/observability/signals-writer.js +157 -54
  62. package/.agents/scripts/lib/observability/tool-trace-hook.js +42 -4
  63. package/.agents/scripts/lib/orchestration/acceptance-eval-decision.js +1 -1
  64. package/.agents/scripts/lib/orchestration/code-review.js +74 -4
  65. package/.agents/scripts/lib/orchestration/consolidation-precondition.js +213 -0
  66. package/.agents/scripts/lib/orchestration/doc-reader.js +4 -96
  67. package/.agents/scripts/lib/orchestration/docs-digest.js +34 -0
  68. package/.agents/scripts/lib/orchestration/epic-plan-spec/phases/authoring-context.js +56 -19
  69. package/.agents/scripts/lib/orchestration/epic-plan-spec/phases/run-spec-phase.js +22 -0
  70. package/.agents/scripts/lib/orchestration/lifecycle/emit-merge-unlanded.js +188 -0
  71. package/.agents/scripts/lib/orchestration/lifecycle/listeners/README.md +6 -0
  72. package/.agents/scripts/lib/orchestration/lifecycle/listeners/automerge-armer.js +69 -8
  73. package/.agents/scripts/lib/orchestration/lifecycle/listeners/automerge-predicate.js +11 -2
  74. package/.agents/scripts/lib/orchestration/lifecycle/listeners/finalizer.js +47 -61
  75. package/.agents/scripts/lib/orchestration/lifecycle/listeners/index.js +39 -3
  76. package/.agents/scripts/lib/orchestration/lifecycle/listeners/label-transitioner.js +144 -0
  77. package/.agents/scripts/lib/orchestration/lifecycle/listeners/merge-watcher.js +258 -14
  78. package/.agents/scripts/lib/orchestration/lifecycle/listeners/notify-dispatcher.js +6 -0
  79. package/.agents/scripts/lib/orchestration/merge-block-class.js +218 -0
  80. package/.agents/scripts/lib/orchestration/plan-review-routing.js +1 -1
  81. package/.agents/scripts/lib/orchestration/post-merge/phases/worktree-reap.js +3 -3
  82. package/.agents/scripts/lib/orchestration/retro/phases/compose-body.js +63 -34
  83. package/.agents/scripts/lib/orchestration/retro/phases/gather-signals.js +167 -52
  84. package/.agents/scripts/lib/orchestration/retro/phases/post-and-mirror.js +49 -2
  85. package/.agents/scripts/lib/orchestration/retro-proposals.js +12 -55
  86. package/.agents/scripts/lib/orchestration/retro-runner.js +9 -0
  87. package/.agents/scripts/lib/orchestration/single-story-close/phases/code-review.js +8 -0
  88. package/.agents/scripts/lib/orchestration/single-story-close/phases/confirm-merge.js +419 -0
  89. package/.agents/scripts/lib/orchestration/single-story-close/phases/options.js +35 -2
  90. package/.agents/scripts/lib/orchestration/single-story-close/phases/wrong-tree-guard.js +353 -69
  91. package/.agents/scripts/lib/orchestration/single-story-close/runner.js +66 -4
  92. package/.agents/scripts/lib/orchestration/spec-section-validator.js +60 -9
  93. package/.agents/scripts/lib/orchestration/story-close/auto-refresh-runner.js +7 -5
  94. package/.agents/scripts/lib/orchestration/story-close/merge-runner.js +24 -2
  95. package/.agents/scripts/lib/orchestration/story-close/phases/code-review.js +167 -8
  96. package/.agents/scripts/lib/orchestration/story-close/shared-checkout-guard.js +163 -0
  97. package/.agents/scripts/lib/orchestration/ticketing/reads.js +20 -9
  98. package/.agents/scripts/lib/planning-corpus.js +306 -0
  99. package/.agents/scripts/lib/signals/detectors/common.js +10 -10
  100. package/.agents/scripts/lib/signals/detectors/index.js +4 -4
  101. package/.agents/scripts/lib/signals/detectors/retry.js +19 -18
  102. package/.agents/scripts/lib/signals/detectors/rework.js +1 -1
  103. package/.agents/scripts/lib/signals/schema.js +56 -81
  104. package/.agents/scripts/lib/signals/span-tree.js +6 -5
  105. package/.agents/scripts/lib/story-plan.js +3 -0
  106. package/.agents/scripts/lib/wave-runner/tick.js +10 -2
  107. package/.agents/scripts/lifecycle-emit.js +39 -8
  108. package/.agents/scripts/providers/github/issues.js +12 -1
  109. package/.agents/scripts/resolve-doc-tiers.js +83 -0
  110. package/.agents/scripts/retro-run.js +51 -0
  111. package/.agents/scripts/signals-view.js +1 -1
  112. package/.agents/scripts/single-story-close.js +20 -1
  113. package/.agents/scripts/standalone-feedback-rollup.js +188 -0
  114. package/.agents/scripts/story-close.js +48 -0
  115. package/.agents/scripts/story-plan.js +51 -12
  116. package/.agents/scripts/validate-docs-freshness.js +69 -15
  117. package/.agents/skills/core/documentation-and-adrs/SKILL.md +58 -0
  118. package/.agents/skills/core/epic-plan-decompose-author/SKILL.md +5 -3
  119. package/.agents/skills/core/epic-plan-spec-author/SKILL.md +20 -7
  120. package/.agents/skills/core/scope-triage/SKILL.md +61 -0
  121. package/.agents/skills/skills.index.json +3 -3
  122. package/.agents/workflows/audit-documentation.md +82 -2
  123. package/.agents/workflows/helpers/code-review.md +193 -44
  124. package/.agents/workflows/helpers/deliver-epic.md +128 -39
  125. package/.agents/workflows/helpers/deliver-stories.md +26 -0
  126. package/.agents/workflows/helpers/epic-audit.md +116 -283
  127. package/.agents/workflows/helpers/epic-deliver-story.md +14 -0
  128. package/.agents/workflows/helpers/epic-plan-decompose.md +18 -200
  129. package/.agents/workflows/helpers/epic-plan-spec.md +18 -180
  130. package/.agents/workflows/helpers/plan-epic.md +141 -105
  131. package/.agents/workflows/helpers/plan-story.md +32 -0
  132. package/.agents/workflows/helpers/single-story-deliver.md +43 -0
  133. package/.agents/workflows/loops/nightly-audit.md +9 -7
  134. package/docs/CHANGELOG.md +29 -0
  135. package/lib/cli/doctor.js +44 -0
  136. package/package.json +4 -3
  137. package/.agents/scripts/epic-plan-spec-validate.js +0 -111
  138. package/.agents/scripts/lib/feedback-loop/code-review-graduator.js +0 -207
  139. package/.agents/scripts/lib/orchestration/epic-plan-spec/phases/prompts.js +0 -58
  140. package/.agents/scripts/lib/signals/detectors/hotspot.js +0 -292
@@ -22,8 +22,11 @@
22
22
  * Behaviour:
23
23
  * - Loads the configured review adapter via the factory; defaults to
24
24
  * `native` when `delivery.codeReview.provider` is unset.
25
- * - Always posts the structured `code-review` comment on the Epic
26
- * issue (the adapter never posts; the orchestrator owns persistence).
25
+ * - Always posts the unified `verification-results` structured comment on
26
+ * the Epic issue (the adapter never posts; the orchestrator owns
27
+ * persistence). Story #4411 (Epic #4405) unified the former
28
+ * `code-review` and `audit-results` findings contracts into this one
29
+ * `verification-results` marker.
27
30
  * - Treats severity.critical > 0 as a halting blocker — the merged
28
31
  * `/deliver` runner consults `halted` and refuses to advance
29
32
  * to Phase E (retro) when set.
@@ -32,6 +35,8 @@
32
35
  * helper's "operator must remediate before /deliver" gate.
33
36
  */
34
37
 
38
+ import { hasSurvivingCritical } from '../audit-suite/findings.js';
39
+ import { resolveLensTier } from '../audit-suite/selector.js';
35
40
  import { resolveConfig } from '../config-resolver.js';
36
41
  import { selectAuditStrategy } from '../dynamic-workflow/capability.js';
37
42
  import { gitSpawn } from '../git-utils.js';
@@ -228,6 +233,71 @@ export function resolveAuditLenses(envelope = {}) {
228
233
  return LENS_ORDER.filter((lens) => matched.has(lens));
229
234
  }
230
235
 
236
+ /**
237
+ * Compose the Epic-close (gate3) lens roster from the change-set selection
238
+ * plus every risk-routed lens.
239
+ *
240
+ * STOPGAP (post-#4405 review finding): Story #4412 excluded every
241
+ * `local`-tier change-set lens here on the premise that the Story-scope
242
+ * local-lens pass (Story #4409) verifies those concerns shift-left. That
243
+ * premise does not hold yet — `runLocalLensReview`
244
+ * (story-close/phases/code-review.js) materializes lens bodies with no
245
+ * `artifactPrefix` (so no content persists) and no consumer acts on its
246
+ * result, which left change-set lenses (security, privacy, performance,
247
+ * quality, …) executed by NO reviewer at ANY tier. Local-tier lenses are
248
+ * therefore KEPT at Epic close until the story-scope pass has a real
249
+ * consumer; re-enable the exclusion then (tracked as a follow-up issue).
250
+ * The write-time maker checklists (#4410) are advisory self-checks, not
251
+ * verification. The `resolveLensTierFn` seam is retained so the
252
+ * re-exclusion is a one-line revert.
253
+ *
254
+ * The two inputs mirror the `epic-audit-prepare.js` envelope:
255
+ * - `changeSetAudits` — the raw change-set gate3 selection, kept in full
256
+ * (see the stopgap note above).
257
+ * - `riskRoutedAudits` — the verdict-routed high-risk lenses plus the
258
+ * route-glob navigability lens. Kept in **full**, regardless of tier: a
259
+ * high-risk axis (or a route-adding change set) that demands a local-tier
260
+ * lens still runs it at Epic close, because that demand is the whole point
261
+ * of risk routing.
262
+ *
263
+ * De-duplicated and order-preserving (kept change-set lenses first, then the
264
+ * risk-routed extras). Pure over the injected `resolveLensTierFn` seam; the
265
+ * default resolver reads `audit-rules.json` from disk.
266
+ *
267
+ * @param {{
268
+ * changeSetAudits?: string[],
269
+ * riskRoutedAudits?: string[],
270
+ * resolveLensTierFn?: typeof resolveLensTier,
271
+ * }} [params]
272
+ * @returns {string[]} The Epic-close roster (all change-set tiers +
273
+ * risk-routed; local-tier exclusion suspended per the stopgap above).
274
+ */
275
+ export function selectEpicCloseLenses({
276
+ changeSetAudits = [],
277
+ riskRoutedAudits = [],
278
+ resolveLensTierFn = resolveLensTier,
279
+ } = {}) {
280
+ const kept = [];
281
+ const seen = new Set();
282
+ const add = (lens) => {
283
+ if (typeof lens !== 'string' || lens.length === 0 || seen.has(lens)) return;
284
+ seen.add(lens);
285
+ kept.push(lens);
286
+ };
287
+ for (const lens of Array.isArray(changeSetAudits) ? changeSetAudits : []) {
288
+ if (typeof lens !== 'string' || lens.length === 0) continue;
289
+ // Stopgap: keep local-tier lenses (see docstring). The tier seam is
290
+ // deliberately untouched so restoring `=== 'local' → continue` is a
291
+ // one-line revert once the story-scope tier has a real consumer.
292
+ void resolveLensTierFn;
293
+ add(lens);
294
+ }
295
+ for (const lens of Array.isArray(riskRoutedAudits) ? riskRoutedAudits : []) {
296
+ add(lens);
297
+ }
298
+ return kept;
299
+ }
300
+
231
301
  /**
232
302
  * Build the post-delivery audit-lens execution plan for a judged risk
233
303
  * envelope. Each routed lens (see {@link resolveAuditLenses}) is paired with a
@@ -551,7 +621,7 @@ async function postReviewComment({
551
621
  const postResult = await upsertCommentFn(
552
622
  provider,
553
623
  commentTargetId,
554
- 'code-review',
624
+ 'verification-results',
555
625
  report,
556
626
  );
557
627
  const postedCommentId =
@@ -621,7 +691,7 @@ async function executeReviewPipeline({ opts, config, envelope }) {
621
691
  );
622
692
 
623
693
  const severity = countBySeverity(findings);
624
- const halted = severity.critical > 0;
694
+ const halted = hasSurvivingCritical(severity);
625
695
  const report = renderFindingsFn({
626
696
  scope,
627
697
  ticketId,
@@ -0,0 +1,213 @@
1
+ /**
2
+ * consolidation-precondition.js — deterministic dispatch gate for the Phase
3
+ * 8.3 Holistic Consolidation sub-agent (Story #4431, Epic #4429).
4
+ *
5
+ * The Phase 8.3 consolidation critic (`epic-plan-consolidate`) is a genuine
6
+ * fresh-context `Agent` dispatch — every call re-pays the full always-loaded
7
+ * context (`.agents/instructions.md` and its always-on rules, § 4). When the
8
+ * decomposer's draft `tickets.json` already matches the Tech Spec's `##
9
+ * Delivery Slicing` target 1:1 (same shippable-Story count, and the
10
+ * `depends_on` shape already agrees with each slice's declared
11
+ * "Independent?" answer), there is nothing left for the critic to
12
+ * reconcile — dispatching it is pure token spend for a no-op. This module
13
+ * computes that decision **deterministically**, off the same two inputs the
14
+ * critic itself reads (the draft array and the Epic body's Delivery Slicing
15
+ * table), so `helpers/plan-epic.md` Phase 8.3 can skip the sub-agent
16
+ * dispatch when it is provably safe to.
17
+ *
18
+ * **Fail-open by design.** Every ambiguous case — a missing or unparseable
19
+ * Delivery Slicing section, an unparseable "Independent?" cell — resolves to
20
+ * `dispatch: true`. This gate can only ever *save* a dispatch when it is
21
+ * confident the critic has nothing to do; it never disables the critic's
22
+ * ability to catch a real divergence. Phase 8.4 (reachability critic), 8.5
23
+ * (pre-mortem critic), and the deterministic ticket validator are
24
+ * unconditional — this precondition governs only the 8.3 dispatch.
25
+ *
26
+ * Pure, synchronous, no I/O — callers own reading `tickets.json` and the
27
+ * Epic body off disk / the GitHub API.
28
+ */
29
+
30
+ import { DELIVERY_SLICING_RE as DELIVERY_SLICING_HEADING_RE } from '../epic-body-sections.js';
31
+
32
+ /** A row is a markdown table line: starts with `|` once trimmed. */
33
+ const TABLE_ROW_RE = /^\|/;
34
+
35
+ /** A markdown table separator row: `|---|:---:|---:|` (dashes, colons, pipes only). */
36
+ const TABLE_SEPARATOR_RE = /^\|?\s*:?-+:?\s*(\|\s*:?-+:?\s*)*\|?$/;
37
+
38
+ /** The literal goal-section token that marks the wave-0 BDD scaffold Story. */
39
+ const BDD_SCAFFOLD_GOAL_TOKEN = 'bdd-scaffold';
40
+
41
+ /**
42
+ * Split one markdown table row into trimmed cell strings.
43
+ *
44
+ * @param {string} line
45
+ * @returns {string[]}
46
+ */
47
+ function splitTableRow(line) {
48
+ let trimmed = line.trim();
49
+ if (trimmed.startsWith('|')) trimmed = trimmed.slice(1);
50
+ if (trimmed.endsWith('|')) trimmed = trimmed.slice(0, -1);
51
+ return trimmed.split('|').map((cell) => cell.trim());
52
+ }
53
+
54
+ /**
55
+ * Parse an "Independent?" cell per the pinned rule: match the cell's
56
+ * leading word case-insensitively as `Yes` or `No`. Any other leading word
57
+ * (or an empty cell) is unparseable and returns `null` — the caller must
58
+ * fail open (`dispatch: true`) rather than guess.
59
+ *
60
+ * @param {string} cell
61
+ * @returns {boolean|null} `true` for Yes, `false` for No, `null` when unparseable.
62
+ */
63
+ function parseIndependentCell(cell) {
64
+ const match = String(cell ?? '')
65
+ .trim()
66
+ .match(/^[A-Za-z]+/);
67
+ if (!match) return null;
68
+ const word = match[0].toLowerCase();
69
+ if (word === 'yes') return true;
70
+ if (word === 'no') return false;
71
+ return null;
72
+ }
73
+
74
+ /**
75
+ * Locate and parse the `## Delivery Slicing` markdown table out of the Epic
76
+ * body (which carries the folded Tech Spec sections — Story #4324). Returns
77
+ * `null` when the heading is absent, no table follows it, the table has no
78
+ * "Independent?" column, or any data row's "Independent?" cell is
79
+ * unparseable — every one of those is a fail-open signal for the caller.
80
+ *
81
+ * @param {string} epicBody
82
+ * @returns {{ slice: string, independent: boolean }[] | null}
83
+ */
84
+ export function parseDeliverySlicingTable(epicBody) {
85
+ if (typeof epicBody !== 'string' || epicBody.length === 0) return null;
86
+
87
+ const lines = epicBody.split(/\r?\n/);
88
+ const headingIdx = lines.findIndex((line) =>
89
+ DELIVERY_SLICING_HEADING_RE.test(line.trim()),
90
+ );
91
+ if (headingIdx === -1) return null;
92
+
93
+ let i = headingIdx + 1;
94
+ while (i < lines.length && lines[i].trim() === '') i++;
95
+ if (i >= lines.length || !TABLE_ROW_RE.test(lines[i].trim())) return null;
96
+
97
+ const headerCells = splitTableRow(lines[i]);
98
+ i++;
99
+ if (i >= lines.length || !TABLE_SEPARATOR_RE.test(lines[i].trim())) {
100
+ return null;
101
+ }
102
+ i++;
103
+
104
+ const independentIdx = headerCells.findIndex((cell) =>
105
+ /independent/i.test(cell),
106
+ );
107
+ if (independentIdx === -1) return null;
108
+
109
+ const rows = [];
110
+ while (i < lines.length && TABLE_ROW_RE.test(lines[i].trim())) {
111
+ const cells = splitTableRow(lines[i]);
112
+ const independent = parseIndependentCell(cells[independentIdx]);
113
+ if (independent === null) return null; // unparseable cell → fail open
114
+ rows.push({ slice: (cells[0] ?? '').trim(), independent });
115
+ i++;
116
+ }
117
+
118
+ return rows.length > 0 ? rows : null;
119
+ }
120
+
121
+ /**
122
+ * True when `story` is the recognized wave-0 BDD scaffold Story — identified
123
+ * by the literal `bdd-scaffold` goal token the `epic-plan-decompose-author`
124
+ * skill's WAVE-0 BDD SCAFFOLD STORY section requires. Scaffold Stories are
125
+ * not a Delivery Slicing slice, so they are excluded from the count
126
+ * comparison — BDD-adopting consumer repos still benefit from the
127
+ * precondition gate rather than always paying the 8.3 dispatch.
128
+ *
129
+ * @param {{ body?: unknown }} story
130
+ * @returns {boolean}
131
+ */
132
+ function isBddScaffoldStory(story) {
133
+ const body = story?.body;
134
+ return (
135
+ typeof body === 'string' &&
136
+ body.toLowerCase().includes(BDD_SCAFFOLD_GOAL_TOKEN)
137
+ );
138
+ }
139
+
140
+ /**
141
+ * Evaluate whether the Phase 8.3 consolidation sub-agent needs to run.
142
+ *
143
+ * @param {object} input
144
+ * @param {object[]} input.draftStories - The draft `tickets.json` array
145
+ * (`epic-plan-decompose-author`'s output) — raw Story ticket objects with
146
+ * top-level `slug` / `depends_on` / `body` (serialized string).
147
+ * @param {string} input.epicBody - The Epic body carrying the folded Tech
148
+ * Spec sections (`## Delivery Slicing` onward).
149
+ * @returns {{ dispatch: boolean, reasons: string[] }} `dispatch: false` only
150
+ * when the draft matches the Delivery Slicing table 1:1 in count and
151
+ * dependency shape; `dispatch: true` (with `reasons`) otherwise, including
152
+ * every fail-open case.
153
+ */
154
+ export function evaluateConsolidationPrecondition({ draftStories, epicBody }) {
155
+ if (!Array.isArray(draftStories)) {
156
+ throw new TypeError(
157
+ 'evaluateConsolidationPrecondition: draftStories must be an array',
158
+ );
159
+ }
160
+
161
+ const slicing = parseDeliverySlicingTable(epicBody);
162
+ if (!slicing) {
163
+ return {
164
+ dispatch: true,
165
+ reasons: [
166
+ 'Delivery Slicing section is missing or unparseable — fail-open to the critic.',
167
+ ],
168
+ };
169
+ }
170
+
171
+ const slicedStories = draftStories.filter(
172
+ (story) => !isBddScaffoldStory(story),
173
+ );
174
+
175
+ if (slicedStories.length !== slicing.length) {
176
+ return {
177
+ dispatch: true,
178
+ reasons: [
179
+ `Story count diverges from Delivery Slicing: ${slicing.length} proposed slice(s) vs ${slicedStories.length} non-scaffold draft Story(ies).`,
180
+ ],
181
+ };
182
+ }
183
+
184
+ const reasons = [];
185
+ for (let idx = 0; idx < slicing.length; idx++) {
186
+ const slice = slicing[idx];
187
+ const story = slicedStories[idx];
188
+ const dependsOn = Array.isArray(story?.depends_on) ? story.depends_on : [];
189
+ const hasDeps = dependsOn.length > 0;
190
+ const storyLabel = story?.slug ?? story?.title ?? `<story ${idx + 1}>`;
191
+
192
+ if (slice.independent === false && !hasDeps) {
193
+ reasons.push(
194
+ `Slice "${slice.slice}" (position ${idx + 1}) is marked Independent: No but draft Story "${storyLabel}" declares no depends_on.`,
195
+ );
196
+ } else if (slice.independent === true && hasDeps) {
197
+ reasons.push(
198
+ `Slice "${slice.slice}" (position ${idx + 1}) is marked Independent: Yes but draft Story "${storyLabel}" declares depends_on [${dependsOn.join(', ')}].`,
199
+ );
200
+ }
201
+ }
202
+
203
+ if (reasons.length > 0) {
204
+ return { dispatch: true, reasons };
205
+ }
206
+
207
+ return {
208
+ dispatch: false,
209
+ reasons: [
210
+ `Draft matches Delivery Slicing 1:1 in count and dependency shape (${slicing.length} slice(s)) — skipping the 8.3 consolidation dispatch.`,
211
+ ],
212
+ };
213
+ }
@@ -1,16 +1,13 @@
1
1
  import fs from 'node:fs';
2
2
  import path from 'node:path';
3
- import { getPaths } from '../config-resolver.js';
4
- import { Logger } from '../Logger.js';
5
- import { applyBudget } from './planning-context-budget.js';
6
3
 
7
4
  /**
8
5
  * Read an explicit list of doc files relative to `docsRoot`, returning one
9
6
  * `{ name, path, content }` object per file that exists and reads cleanly.
10
- * Missing or unreadable files are skipped silently (mirrors
11
- * {@link readDocsFromRoot}'s per-file try/catch). Order is preserved from the
12
- * input list. This is the shared read/normalize seam the per-Epic docs digest
13
- * builds on (Story #4338) so there is a single home for the fs read path.
7
+ * Missing or unreadable files are skipped silently. Order is preserved from
8
+ * the input list. This is the shared read/normalize seam the per-Epic docs
9
+ * digest builds on (Story #4338) so there is a single home for the fs read
10
+ * path.
14
11
  *
15
12
  * @param {{ files: string[], docsRoot?: string }} args
16
13
  * @returns {Promise<Array<{ name: string, path: string, content: string }>>}
@@ -32,92 +29,3 @@ export async function readDocFiles({ files, docsRoot } = {}) {
32
29
  });
33
30
  return (await Promise.all(reads)).filter(Boolean);
34
31
  }
35
-
36
- async function readDocsFromRoot(docsRoot, settings) {
37
- const explicit =
38
- Array.isArray(settings.docsContextFiles) &&
39
- settings.docsContextFiles.length > 0;
40
- const usedFallback = !explicit;
41
- let targetFiles;
42
- if (explicit) {
43
- targetFiles = settings.docsContextFiles.map((f) => ({
44
- name: f,
45
- full: path.join(docsRoot, f),
46
- }));
47
- } else {
48
- const entries = fs.readdirSync(docsRoot, { withFileTypes: true });
49
- targetFiles = entries
50
- .filter((e) => e.isFile() && e.name.endsWith('.md'))
51
- .map((e) => ({ name: e.name, full: path.join(docsRoot, e.name) }));
52
- }
53
-
54
- const reads = targetFiles.map(async ({ name, full }) => {
55
- try {
56
- const stat = await fs.promises.stat(full);
57
- if (!stat.isFile()) return null;
58
- const content = await fs.promises.readFile(full, 'utf-8');
59
- return { name, path: name, content };
60
- } catch (_e) {
61
- return null;
62
- }
63
- });
64
-
65
- const docs = (await Promise.all(reads)).filter(Boolean);
66
- return { docs, usedFallback };
67
- }
68
-
69
- /**
70
- * Read project documentation, returning raw doc objects so callers can apply
71
- * the planning-context budget themselves. Each entry is `{ name, path,
72
- * content }`. The legacy single-string concatenation is no longer the public
73
- * surface — use {@link buildDocsContext} when an envelope-shaped value is
74
- * needed.
75
- *
76
- * @param {object} settings — Resolved config bag (`project.paths.docsRoot`,
77
- * `project.docsContextFiles`) or legacy-shim view (same shape).
78
- * @returns {Promise<{ docs: Array<{name: string, path: string, content: string}>, usedFallback: boolean }>}
79
- */
80
- export async function scrapeProjectDocs(settings) {
81
- // `settings` is the legacy-shim view (`{ paths, docsContextFiles, ... }`)
82
- // OR the post-reshape canonical (`{ project: { paths, ... } }`). Under the
83
- // resolver shim `agentSettings.paths === project.paths`, so re-wrap to the
84
- // canonical shape `getPaths` consumes.
85
- const docsRoot = getPaths({
86
- project: { paths: settings?.project?.paths ?? settings?.paths },
87
- }).docsRoot;
88
- if (!docsRoot || !fs.existsSync(docsRoot)) {
89
- return { docs: [], usedFallback: false };
90
- }
91
- Logger.info(`[Epic Planner] Scraping project docs from ${docsRoot}...`);
92
- try {
93
- const result = await readDocsFromRoot(docsRoot, settings);
94
- if (result.usedFallback) {
95
- Logger.warn(
96
- '[Epic Planner] ⚠️ project.docsContextFiles is unset — falling back to every top-level *.md under docsRoot. Configure docsContextFiles for production planning.',
97
- );
98
- }
99
- return result;
100
- } catch (err) {
101
- Logger.warn(
102
- `[Epic Planner] Warning: Failed to read docsRoot: ${err.message}`,
103
- );
104
- return { docs: [], usedFallback: false };
105
- }
106
- }
107
-
108
- /**
109
- * Build the `docsContext` value emitted in `--emit-context` envelopes.
110
- * Reads the docs and applies the planning-context budget so over-budget
111
- * payloads downgrade to summary mode automatically. Callers pass
112
- * `{ fullContext: true }` to honour the `--full-context` CLI opt-in.
113
- *
114
- * @param {object} settings — Resolved config bag (same shape as `scrapeProjectDocs`).
115
- * @param {{ maxBytes?: number, summaryMode?: 'auto'|'always'|'never' }} [planningLimits]
116
- * @param {{ fullContext?: boolean }} [opts]
117
- * @returns {Promise<{ mode: 'full'|'summary', items: Array<object>, totalBytes: number, usedFallback: boolean }>}
118
- */
119
- export async function buildDocsContext(settings, planningLimits, opts = {}) {
120
- const { docs, usedFallback } = await scrapeProjectDocs(settings);
121
- const budgeted = applyBudget(docs, planningLimits, opts);
122
- return { ...budgeted, usedFallback };
123
- }
@@ -12,8 +12,16 @@
12
12
  *
13
13
  * The heavy lifting of reading + normalizing doc bodies is delegated to
14
14
  * `doc-reader.js` (`readDocFiles`), keeping a single home for the fs read path.
15
+ *
16
+ * Story #4433 extends this module with {@link ensureDocsDigest}, a shared
17
+ * generate-and-write export so the planner-context surface
18
+ * (`epic-plan-spec.js` / `authoring-context.js`) can produce a session docs
19
+ * digest without duplicating the mkdir+writeFile plumbing
20
+ * `epic-deliver-prepare.js` already owns for the delivery-children digest.
15
21
  */
16
22
 
23
+ import fs from 'node:fs';
24
+ import path from 'node:path';
17
25
  import { readDocFiles } from './doc-reader.js';
18
26
 
19
27
  /**
@@ -132,3 +140,29 @@ export async function buildDocsDigest({ docsContextFiles, docsRoot } = {}) {
132
140
  const sections = docs.map(renderDocSection).join('\n');
133
141
  return `${header}\n${sections}`.replace(/\n+$/, '\n');
134
142
  }
143
+
144
+ /**
145
+ * Build the docs digest and write it to `outputPath`, returning `null` (no
146
+ * write) when there is nothing to digest. This is the single shared
147
+ * generate-and-persist export both digest producers call: the per-Epic
148
+ * delivery-children digest (`epic-deliver-prepare.js`) and the planner-
149
+ * context digest (`epic-plan-spec.js` → `authoring-context.js`, Story
150
+ * #4433). Callers own path construction (temp-root layout, epic id, etc.)
151
+ * so both surfaces can keep — or deliberately share — their own convention;
152
+ * this function only owns "build digest, ensure parent dir, write file".
153
+ *
154
+ * @param {{ docsContextFiles?: string[], docsRoot?: string, outputPath: string }} args
155
+ * @returns {Promise<{ digest: string, outputPath: string } | null>}
156
+ */
157
+ export async function ensureDocsDigest({
158
+ docsContextFiles,
159
+ docsRoot,
160
+ outputPath,
161
+ } = {}) {
162
+ const digest = await buildDocsDigest({ docsContextFiles, docsRoot });
163
+ if (digest == null) return null;
164
+
165
+ await fs.promises.mkdir(path.dirname(outputPath), { recursive: true });
166
+ await fs.promises.writeFile(outputPath, digest, 'utf-8');
167
+ return { digest, outputPath };
168
+ }
@@ -15,18 +15,14 @@ import {
15
15
  } from '../../../bdd-runner-detect.js';
16
16
  import { scanBddScenarios } from '../../../bdd-scenario-scanner.js';
17
17
  import { buildCodebaseSnapshot } from '../../../codebase-snapshot.js';
18
- import { getLimits, PROJECT_ROOT } from '../../../config-resolver.js';
18
+ import { getLimits, getPaths, PROJECT_ROOT } from '../../../config-resolver.js';
19
19
  import { hasEpicSection } from '../../../epic-body-sections.js';
20
20
  import { scanMemoryFreshness } from '../../../feedback-loop/memory-freshness.js';
21
21
  import { fetchPriorFeedback } from '../../../feedback-loop/prior-feedback-fetcher.js';
22
22
  import { Logger } from '../../../Logger.js';
23
- import { buildDocsContext } from '../../doc-reader.js';
23
+ import { ensureDocsDigest } from '../../docs-digest.js';
24
24
  import { applyBudget } from '../../planning-context-budget.js';
25
25
  import { collectReferences, hasNewFileCue } from '../../spec-freshness.js';
26
- import {
27
- ACCEPTANCE_SPEC_SYSTEM_PROMPT,
28
- TECH_SPEC_SYSTEM_PROMPT,
29
- } from './prompts.js';
30
26
  import { buildAuthoringGrounding } from './spec-authoring-grounding.js';
31
27
 
32
28
  /**
@@ -57,16 +53,59 @@ export function resolveMemoryDir({ github } = {}) {
57
53
  return path.join(os.homedir(), '.claude', 'projects', repo, 'memory');
58
54
  }
59
55
 
56
+ /**
57
+ * Build the digest-first `docsContext` envelope field (Story #4433 — hard
58
+ * cutover of the § 3.1 planning read contract to digest-first with
59
+ * pull-on-demand, mirroring the Story #4324 delivery-children cutover).
60
+ *
61
+ * Ensures a docs digest exists at the **same** per-Epic temp path the
62
+ * `/deliver` story sub-agents already consume
63
+ * (`<tempRoot>/epic-<epicId>/docs-digest.md`) via the shared
64
+ * `ensureDocsDigest` export in `docs-digest.js` — one generator, one file,
65
+ * reused across the planning and delivery surfaces for the same Epic. The
66
+ * envelope carries only the digest path, not embedded doc content; the
67
+ * planner (host LLM or the `epic-plan-spec-author` Skill) reads the digest
68
+ * and pulls a full file/section on demand when it bears on the decision.
69
+ *
70
+ * Returns `null` — a silent no-op — when `project.docsContextFiles` is not
71
+ * configured. There is no scrape-every-markdown-under-docsRoot fallback for
72
+ * planning; that fallback remains a `doc-reader.js` primitive used
73
+ * elsewhere, not part of this envelope.
74
+ *
75
+ * @param {{ epicId: number, settings: object, cwd: string }} args
76
+ * @returns {Promise<{ mode: 'digest', digestPath: string } | null>}
77
+ */
78
+ async function buildPlanningDocsContext({ epicId, settings, cwd }) {
79
+ const docsContextFiles = Array.isArray(settings?.docsContextFiles)
80
+ ? settings.docsContextFiles
81
+ : [];
82
+ if (docsContextFiles.length === 0) return null;
83
+
84
+ const paths = getPaths({ project: { paths: settings?.paths } });
85
+ const docsRoot = path.resolve(cwd, paths.docsRoot);
86
+ const relPath = path.join(paths.tempRoot, `epic-${epicId}`, 'docs-digest.md');
87
+ const absPath = path.resolve(cwd, relPath);
88
+
89
+ const result = await ensureDocsDigest({
90
+ docsContextFiles,
91
+ docsRoot,
92
+ outputPath: absPath,
93
+ });
94
+ if (!result) return null;
95
+ return { mode: 'digest', digestPath: relPath };
96
+ }
97
+
60
98
  /**
61
99
  * Build the authoring context the host LLM (or the
62
100
  * `epic-plan-spec-author` Skill) needs to write the Tech Spec.
63
101
  *
64
- * `docsContext` is bounded by the planning-context budget (Epic #817 Story 9):
65
- * over-budget payloads downgrade to a summary representation with headings +
66
- * bounded excerpts. Pass `{ fullContext: true }` (CLI: `--full-context`) to
67
- * restore the unbounded full-body envelope. The Epic body itself is always
68
- * subject to the same budget so a sprawling Epic narrative cannot bypass the
69
- * cap by riding on top of `docsContext`.
102
+ * `docsContext` is digest-first (Story #4433): a pointer at the per-Epic
103
+ * docs digest rather than embedded doc content, `null` when
104
+ * `project.docsContextFiles` is unset. The Epic body itself stays bounded by
105
+ * the planning-context budget (Epic #817 Story 9) — over-budget bodies
106
+ * downgrade to a summary representation with headings + bounded excerpts.
107
+ * Pass `{ fullContext: true }` (CLI: `--full-context`) to restore the
108
+ * unbounded full Epic body.
70
109
  */
71
110
  export async function buildAuthoringContext(
72
111
  epicId,
@@ -80,10 +119,12 @@ export async function buildAuthoringContext(
80
119
  }
81
120
 
82
121
  const planningLimits = getLimits(settings).planningContext;
83
- const { fullContext = false } = opts;
122
+ const { fullContext = false, cwd = PROJECT_ROOT } = opts;
84
123
 
85
- const docsContext = await buildDocsContext(settings, planningLimits, {
86
- fullContext,
124
+ const docsContext = await buildPlanningDocsContext({
125
+ epicId: epic.id,
126
+ settings,
127
+ cwd,
87
128
  });
88
129
 
89
130
  const epicBody = applyBudget(
@@ -216,10 +257,6 @@ export async function buildAuthoringContext(
216
257
  },
217
258
  docsContext,
218
259
  codebaseSnapshot,
219
- systemPrompts: {
220
- techSpec: TECH_SPEC_SYSTEM_PROMPT,
221
- acceptanceSpec: ACCEPTANCE_SPEC_SYSTEM_PROMPT,
222
- },
223
260
  bddRunner,
224
261
  bddScenarios,
225
262
  memoryFreshness,
@@ -20,6 +20,10 @@ import {
20
20
  } from '../../epic-plan-state-store.js';
21
21
  import { resolveReviewRouting } from '../../plan-review-routing.js';
22
22
  import { deriveRiskEnvelope } from '../../planning-risk.js';
23
+ import {
24
+ formatMissingSectionMessage,
25
+ validateSpecSections,
26
+ } from '../../spec-section-validator.js';
23
27
  import { upsertStructuredComment } from '../../ticketing.js';
24
28
  import { planEpic } from './plan-epic.js';
25
29
  import { runSpecFreshnessCheck } from './spec-freshness.js';
@@ -109,6 +113,24 @@ export async function runSpecPhase(
109
113
  );
110
114
  }
111
115
 
116
+ // Story #4403 (Finding 3): the "## Delivery Slicing" presence gate — the
117
+ // Phase 8-side anchor the Holistic Consolidation pass reconciles against —
118
+ // is validated here, against the in-memory authored content, before any
119
+ // GitHub mutation (lease acquisition, Epic body write, structured
120
+ // comments). This replaces the retired standalone `epic-plan-spec-validate.js`
121
+ // CLI / Phase 7.5 workflow step, whose documented ordering ran the check
122
+ // AFTER the persist path had already written to GitHub and deleted the
123
+ // temp file it read — so the "blocking gate" could never actually block.
124
+ const sectionCheck = validateSpecSections({ body: techSpecContent });
125
+ if (!sectionCheck.ok) {
126
+ throw new Error(
127
+ formatMissingSectionMessage({
128
+ techspecPath: `Epic #${epicId} authored Tech Spec`,
129
+ missing: sectionCheck.missing,
130
+ }),
131
+ );
132
+ }
133
+
112
134
  // Story #4145 — probe the project's BDD runner. When none is detected
113
135
  // (`fallback === true`, e.g. a node:test repo with no tests/features/**),
114
136
  // the acceptance disposition is forced to not-applicable inside