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.
- package/.agents/README.md +18 -13
- package/.agents/audit-checklists/architecture.md +24 -0
- package/.agents/audit-checklists/clean-code.md +24 -0
- package/.agents/audit-checklists/dependencies.md +14 -0
- package/.agents/audit-checklists/devops.md +17 -0
- package/.agents/audit-checklists/documentation.md +22 -0
- package/.agents/audit-checklists/lighthouse.md +15 -0
- package/.agents/audit-checklists/navigability.md +14 -0
- package/.agents/audit-checklists/performance.md +22 -0
- package/.agents/audit-checklists/privacy.md +21 -0
- package/.agents/audit-checklists/quality.md +18 -0
- package/.agents/audit-checklists/security.md +22 -0
- package/.agents/audit-checklists/seo.md +16 -0
- package/.agents/audit-checklists/sre.md +24 -0
- package/.agents/audit-checklists/ux-ui.md +21 -0
- package/.agents/docs/SDLC.md +63 -16
- package/.agents/docs/configuration.md +5 -3
- package/.agents/instructions.md +51 -21
- package/.agents/personas/architect.md +10 -7
- package/.agents/personas/engineer.md +4 -3
- package/.agents/personas/project-manager.md +5 -2
- package/.agents/personas/refactorer.md +5 -3
- package/.agents/rules/git-conventions.md +77 -0
- package/.agents/schemas/agentrc.schema.json +16 -4
- package/.agents/schemas/audit-rules.json +16 -2
- package/.agents/schemas/audit-rules.schema.json +7 -6
- package/.agents/schemas/lifecycle/merge.unlanded.schema.json +38 -0
- package/.agents/schemas/signal-event.schema.json +28 -13
- package/.agents/scripts/acceptance-spec-reconciler.js +6 -4
- package/.agents/scripts/check-context-budget.js +320 -0
- package/.agents/scripts/diagnose-friction.js +4 -4
- package/.agents/scripts/epic-audit-prepare.js +30 -2
- package/.agents/scripts/epic-audit-recheck.js +46 -13
- package/.agents/scripts/epic-deliver-prepare.js +80 -8
- package/.agents/scripts/epic-plan-spec.js +4 -8
- package/.agents/scripts/generate-lens-checklists.js +180 -0
- package/.agents/scripts/lib/audit-suite/checklist-threading.js +300 -0
- package/.agents/scripts/lib/audit-suite/findings.js +27 -0
- package/.agents/scripts/lib/audit-suite/index.js +9 -0
- package/.agents/scripts/lib/audit-suite/lens-checklist.js +212 -0
- package/.agents/scripts/lib/audit-suite/selector.js +136 -5
- package/.agents/scripts/lib/checks/loop-health.js +340 -0
- package/.agents/scripts/lib/cli-args.js +8 -0
- package/.agents/scripts/lib/config/explain.js +4 -0
- package/.agents/scripts/lib/config/runners.js +21 -2
- package/.agents/scripts/lib/config/temp-paths.js +24 -0
- package/.agents/scripts/lib/config-settings-schema-delivery.js +23 -3
- package/.agents/scripts/lib/config-settings-schema-quality.js +7 -0
- package/.agents/scripts/lib/doc-tiers.js +291 -0
- package/.agents/scripts/lib/epic-body-sections.js +5 -2
- package/.agents/scripts/lib/epic-merge-lock.js +83 -0
- package/.agents/scripts/lib/epic-plan-clarity.js +3 -1
- package/.agents/scripts/lib/feedback-loop/audit-results-graduator.js +66 -20
- package/.agents/scripts/lib/feedback-loop/graduator-core.js +395 -86
- package/.agents/scripts/lib/feedback-loop/memory-freshness.js +299 -72
- package/.agents/scripts/lib/feedback-loop/retro-proposals-graduator.js +438 -0
- package/.agents/scripts/lib/gates/friction.js +15 -5
- package/.agents/scripts/lib/observability/perf-aggregator.js +30 -104
- package/.agents/scripts/lib/observability/perf-report-readers.js +1 -1
- package/.agents/scripts/lib/observability/signal-validator.js +204 -0
- package/.agents/scripts/lib/observability/signals-writer.js +157 -54
- package/.agents/scripts/lib/observability/tool-trace-hook.js +42 -4
- package/.agents/scripts/lib/orchestration/acceptance-eval-decision.js +1 -1
- package/.agents/scripts/lib/orchestration/code-review.js +74 -4
- package/.agents/scripts/lib/orchestration/consolidation-precondition.js +213 -0
- package/.agents/scripts/lib/orchestration/doc-reader.js +4 -96
- package/.agents/scripts/lib/orchestration/docs-digest.js +34 -0
- package/.agents/scripts/lib/orchestration/epic-plan-spec/phases/authoring-context.js +56 -19
- package/.agents/scripts/lib/orchestration/epic-plan-spec/phases/run-spec-phase.js +22 -0
- package/.agents/scripts/lib/orchestration/lifecycle/emit-merge-unlanded.js +188 -0
- package/.agents/scripts/lib/orchestration/lifecycle/listeners/README.md +6 -0
- package/.agents/scripts/lib/orchestration/lifecycle/listeners/automerge-armer.js +69 -8
- package/.agents/scripts/lib/orchestration/lifecycle/listeners/automerge-predicate.js +11 -2
- package/.agents/scripts/lib/orchestration/lifecycle/listeners/finalizer.js +47 -61
- package/.agents/scripts/lib/orchestration/lifecycle/listeners/index.js +39 -3
- package/.agents/scripts/lib/orchestration/lifecycle/listeners/label-transitioner.js +144 -0
- package/.agents/scripts/lib/orchestration/lifecycle/listeners/merge-watcher.js +258 -14
- package/.agents/scripts/lib/orchestration/lifecycle/listeners/notify-dispatcher.js +6 -0
- package/.agents/scripts/lib/orchestration/merge-block-class.js +218 -0
- package/.agents/scripts/lib/orchestration/plan-review-routing.js +1 -1
- package/.agents/scripts/lib/orchestration/post-merge/phases/worktree-reap.js +3 -3
- package/.agents/scripts/lib/orchestration/retro/phases/compose-body.js +63 -34
- package/.agents/scripts/lib/orchestration/retro/phases/gather-signals.js +167 -52
- package/.agents/scripts/lib/orchestration/retro/phases/post-and-mirror.js +49 -2
- package/.agents/scripts/lib/orchestration/retro-proposals.js +12 -55
- package/.agents/scripts/lib/orchestration/retro-runner.js +9 -0
- package/.agents/scripts/lib/orchestration/single-story-close/phases/code-review.js +8 -0
- package/.agents/scripts/lib/orchestration/single-story-close/phases/confirm-merge.js +419 -0
- package/.agents/scripts/lib/orchestration/single-story-close/phases/options.js +35 -2
- package/.agents/scripts/lib/orchestration/single-story-close/phases/wrong-tree-guard.js +353 -69
- package/.agents/scripts/lib/orchestration/single-story-close/runner.js +66 -4
- package/.agents/scripts/lib/orchestration/spec-section-validator.js +60 -9
- package/.agents/scripts/lib/orchestration/story-close/auto-refresh-runner.js +7 -5
- package/.agents/scripts/lib/orchestration/story-close/merge-runner.js +24 -2
- package/.agents/scripts/lib/orchestration/story-close/phases/code-review.js +167 -8
- package/.agents/scripts/lib/orchestration/story-close/shared-checkout-guard.js +163 -0
- package/.agents/scripts/lib/orchestration/ticketing/reads.js +20 -9
- package/.agents/scripts/lib/planning-corpus.js +306 -0
- package/.agents/scripts/lib/signals/detectors/common.js +10 -10
- package/.agents/scripts/lib/signals/detectors/index.js +4 -4
- package/.agents/scripts/lib/signals/detectors/retry.js +19 -18
- package/.agents/scripts/lib/signals/detectors/rework.js +1 -1
- package/.agents/scripts/lib/signals/schema.js +56 -81
- package/.agents/scripts/lib/signals/span-tree.js +6 -5
- package/.agents/scripts/lib/story-plan.js +3 -0
- package/.agents/scripts/lib/wave-runner/tick.js +10 -2
- package/.agents/scripts/lifecycle-emit.js +39 -8
- package/.agents/scripts/providers/github/issues.js +12 -1
- package/.agents/scripts/resolve-doc-tiers.js +83 -0
- package/.agents/scripts/retro-run.js +51 -0
- package/.agents/scripts/signals-view.js +1 -1
- package/.agents/scripts/single-story-close.js +20 -1
- package/.agents/scripts/standalone-feedback-rollup.js +188 -0
- package/.agents/scripts/story-close.js +48 -0
- package/.agents/scripts/story-plan.js +51 -12
- package/.agents/scripts/validate-docs-freshness.js +69 -15
- package/.agents/skills/core/documentation-and-adrs/SKILL.md +58 -0
- package/.agents/skills/core/epic-plan-decompose-author/SKILL.md +5 -3
- package/.agents/skills/core/epic-plan-spec-author/SKILL.md +20 -7
- package/.agents/skills/core/scope-triage/SKILL.md +61 -0
- package/.agents/skills/skills.index.json +3 -3
- package/.agents/workflows/audit-documentation.md +82 -2
- package/.agents/workflows/helpers/code-review.md +193 -44
- package/.agents/workflows/helpers/deliver-epic.md +128 -39
- package/.agents/workflows/helpers/deliver-stories.md +26 -0
- package/.agents/workflows/helpers/epic-audit.md +116 -283
- package/.agents/workflows/helpers/epic-deliver-story.md +14 -0
- package/.agents/workflows/helpers/epic-plan-decompose.md +18 -200
- package/.agents/workflows/helpers/epic-plan-spec.md +18 -180
- package/.agents/workflows/helpers/plan-epic.md +141 -105
- package/.agents/workflows/helpers/plan-story.md +32 -0
- package/.agents/workflows/helpers/single-story-deliver.md +43 -0
- package/.agents/workflows/loops/nightly-audit.md +9 -7
- package/docs/CHANGELOG.md +29 -0
- package/lib/cli/doctor.js +44 -0
- package/package.json +4 -3
- package/.agents/scripts/epic-plan-spec-validate.js +0 -111
- package/.agents/scripts/lib/feedback-loop/code-review-graduator.js +0 -207
- package/.agents/scripts/lib/orchestration/epic-plan-spec/phases/prompts.js +0 -58
- 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
|
|
26
|
-
* issue (the adapter never posts; the orchestrator owns
|
|
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
|
-
'
|
|
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
|
|
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
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
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 {
|
|
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
|
|
65
|
-
*
|
|
66
|
-
*
|
|
67
|
-
*
|
|
68
|
-
*
|
|
69
|
-
*
|
|
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
|
|
86
|
-
|
|
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
|