@dev-loops/core 0.3.0 → 0.5.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.
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Local-planning plan-file intake state machine.
3
+ *
4
+ * A `--plan-file` startup hands the dev-loop a phase-doc-format plan that lives
5
+ * outside the tracker. Before promotion there is no issue to key a worktree on,
6
+ * so this intake stage classifies how far the plan has progressed through
7
+ * refinement. The classification drives whether the next step is refinement or
8
+ * promotion.
9
+ *
10
+ * This evaluator mirrors the `DEV_LOOP_ISSUE_ASSIGNMENT_SEAM` precedent: a
11
+ * frozen enum plus a pure, deterministic function. It performs no GitHub or
12
+ * filesystem side effects — the caller supplies the section-presence facts it
13
+ * has already read.
14
+ */
15
+
16
+ export const PLAN_FILE_INTAKE_STATE = Object.freeze({
17
+ /** Plan carries only the base authoring sections; refinement has not run. */
18
+ NEW_PLAN_NEEDS_REFINEMENT: "new_plan_needs_refinement",
19
+ /** Plan also carries Acceptance criteria + Definition of done; refinement already ran. */
20
+ PLAN_REFINED_READY_FOR_PROMOTION: "plan_refined_ready_for_promotion",
21
+ /** Inputs are ambiguous, conflicting, or unusable; fail closed. */
22
+ AMBIGUOUS_FAIL_CLOSED: "ambiguous_fail_closed",
23
+ });
24
+
25
+ /**
26
+ * Refinement-marker sections a plan gains once refinement has run on top of the
27
+ * base authoring sections.
28
+ */
29
+ export const PLAN_FILE_REFINEMENT_SECTIONS = Object.freeze(["Acceptance criteria", "Definition of done"]);
30
+
31
+ /**
32
+ * Pure intake-state classifier.
33
+ *
34
+ * @param {object} facts
35
+ * @param {boolean} facts.baseSectionsValid whether the plan passes the base-section validator (Status/Objective/In scope/Explicit non-goals)
36
+ * @param {boolean} facts.hasAcceptanceCriteria whether a non-empty Acceptance criteria section is present
37
+ * @param {boolean} facts.hasDefinitionOfDone whether a non-empty Definition of done section is present
38
+ * @returns {{ state: string }} one of PLAN_FILE_INTAKE_STATE values
39
+ */
40
+ export function evaluatePlanFileIntakeState({ baseSectionsValid, hasAcceptanceCriteria, hasDefinitionOfDone } = {}) {
41
+ // A plan that fails the base contract should already have been rejected by the
42
+ // caller; treating it as intake input here is ambiguous, so fail closed.
43
+ if (baseSectionsValid !== true) {
44
+ return { state: PLAN_FILE_INTAKE_STATE.AMBIGUOUS_FAIL_CLOSED };
45
+ }
46
+ // Refinement markers must be present as a pair. A plan carrying exactly one of
47
+ // the two refinement sections is partially refined in an undefined way; fail
48
+ // closed instead of guessing whether to refine or promote.
49
+ if (hasAcceptanceCriteria === true && hasDefinitionOfDone === true) {
50
+ return { state: PLAN_FILE_INTAKE_STATE.PLAN_REFINED_READY_FOR_PROMOTION };
51
+ }
52
+ if (hasAcceptanceCriteria !== true && hasDefinitionOfDone !== true) {
53
+ return { state: PLAN_FILE_INTAKE_STATE.NEW_PLAN_NEEDS_REFINEMENT };
54
+ }
55
+ return { state: PLAN_FILE_INTAKE_STATE.AMBIGUOUS_FAIL_CLOSED };
56
+ }
@@ -0,0 +1,234 @@
1
+ /**
2
+ * Local-planning plan-file PR-FIRST promotion (P4).
3
+ *
4
+ * This is the P4 promotion step. A plan refined to the
5
+ * `plan_refined_ready_for_promotion` state (P3 refinement produces it; P2
6
+ * defines the intake-state machine) is the spec-of-record. Promotion
7
+ * commits that plan doc and opens EXACTLY ONE draft PR — it never mints a
8
+ * GitHub issue. The PR body links the committed plan doc path and carries the
9
+ * full Acceptance criteria + Definition of done so the PR is a self-contained
10
+ * spec-of-record that can enter the existing PR-followup loop unchanged
11
+ * (`loop startup --pr <n>`), with no new lifecycle state and no issue.
12
+ *
13
+ * This module is pure: it decides promote-eligibility, parses/serializes the
14
+ * plan↔PR link (a minimal YAML front-matter block), and builds the PR body
15
+ * text. It performs no GitHub mutation, no network calls, and no filesystem
16
+ * I/O. The CLI owns ALL I/O (read plan file, git add/commit/branch, call
17
+ * create-pr.mjs, write the PR number back). That keeps the
18
+ * no-issue-mint / zero-pre-promotion-mutation guarantee structural here: there
19
+ * is no gh/network surface to reach from this module.
20
+ *
21
+ * It composes the already-shipped P2 intake-state machine: promotion is only
22
+ * eligible from `plan_refined_ready_for_promotion` (the state P3 refinement
23
+ * produces).
24
+ */
25
+
26
+ import {
27
+ evaluatePlanFileIntakeState,
28
+ PLAN_FILE_INTAKE_STATE,
29
+ } from "./plan-file-intake-contract.mjs";
30
+
31
+ /** Promotion actions the eligibility decision can return. */
32
+ export const PLAN_FILE_PROMOTE_ACTION = Object.freeze({
33
+ /** Eligible: commit the plan doc and open exactly one draft PR. */
34
+ PROMOTE: "promote",
35
+ /** Already linked to an open PR; idempotent no-op (report the existing PR). */
36
+ ALREADY_PROMOTED: "already_promoted",
37
+ });
38
+
39
+ /**
40
+ * Front-matter key that records the opened PR number on the plan doc, forming
41
+ * the plan→PR half of the bidirectional link (the PR body carries the doc path,
42
+ * the PR→plan half).
43
+ */
44
+ export const PLAN_FILE_PR_FRONT_MATTER_KEY = "prNumber";
45
+
46
+ /**
47
+ * Minimal additive front-matter support for plan files (an escalated extension
48
+ * to P1's format): a leading `---\n...\n---\n` block of simple `key: value`
49
+ * lines. Plans without a leading `---` are returned with an empty front-matter
50
+ * object and the full text as the body, so existing front-matter-free plans are
51
+ * never broken.
52
+ *
53
+ * ponytail: a flat scalar `key: value` parser, not a YAML engine. The only
54
+ * front-matter this contract reads/writes is `prNumber:` (an integer); upgrade
55
+ * to the `yaml` dep already in @dev-loops/core if richer front-matter is needed.
56
+ *
57
+ * @param {string} markdownText
58
+ * @returns {{ frontMatter: Record<string, string>, body: string }}
59
+ */
60
+ export function parsePlanFrontMatter(markdownText) {
61
+ const text = typeof markdownText === "string" ? markdownText : "";
62
+ // The opening fence must be the very first line.
63
+ const fenceMatch = /^---[ \t]*\r?\n([\s\S]*?)\r?\n---[ \t]*(?:\r?\n|$)/u.exec(text);
64
+ if (!fenceMatch || fenceMatch.index !== 0) {
65
+ return { frontMatter: {}, body: text };
66
+ }
67
+ const block = fenceMatch[1];
68
+ const body = text.slice(fenceMatch[0].length);
69
+ const frontMatter = {};
70
+ for (const rawLine of block.split(/\r?\n/u)) {
71
+ const line = rawLine.trim();
72
+ if (line.length === 0) continue;
73
+ const sep = line.indexOf(":");
74
+ if (sep === -1) continue;
75
+ const key = line.slice(0, sep).trim();
76
+ if (key.length === 0) continue;
77
+ // Plan content is untrusted: never let prototype-pollution keys through.
78
+ if (key === "__proto__" || key === "constructor" || key === "prototype") continue;
79
+ frontMatter[key] = line.slice(sep + 1).trim();
80
+ }
81
+ return { frontMatter, body };
82
+ }
83
+
84
+ /**
85
+ * Read the linked PR number from a plan's front-matter. Returns a positive
86
+ * integer when present and valid, otherwise null.
87
+ *
88
+ * @param {string} markdownText
89
+ * @returns {number | null}
90
+ */
91
+ export function readLinkedPrNumber(markdownText) {
92
+ const { frontMatter } = parsePlanFrontMatter(markdownText);
93
+ const raw = frontMatter[PLAN_FILE_PR_FRONT_MATTER_KEY];
94
+ if (typeof raw !== "string" || raw.length === 0) return null;
95
+ if (!/^\d+$/u.test(raw)) return null;
96
+ const n = Number.parseInt(raw, 10);
97
+ return Number.isInteger(n) && n > 0 ? n : null;
98
+ }
99
+
100
+ /**
101
+ * Serialize a plan back to text with the given PR number recorded in
102
+ * front-matter (the plan→PR link). Preserves an existing leading front-matter
103
+ * block's other keys and sets/replaces `prNumber`; adds a fresh block to a plan
104
+ * that had none. Idempotent: re-serializing with the same number reproduces the
105
+ * same text.
106
+ *
107
+ * @param {string} markdownText current plan text (with or without front-matter)
108
+ * @param {number} prNumber positive integer PR number to record
109
+ * @returns {string}
110
+ */
111
+ export function writeLinkedPrNumber(markdownText, prNumber) {
112
+ if (!Number.isInteger(prNumber) || prNumber <= 0) {
113
+ throw new Error("writeLinkedPrNumber requires a positive integer prNumber");
114
+ }
115
+ const { frontMatter, body } = parsePlanFrontMatter(markdownText);
116
+ const merged = { ...frontMatter, [PLAN_FILE_PR_FRONT_MATTER_KEY]: String(prNumber) };
117
+ const lines = Object.entries(merged).map(([key, value]) => `${key}: ${value}`);
118
+ return `---\n${lines.join("\n")}\n---\n${body}`;
119
+ }
120
+
121
+ /**
122
+ * Pure promote-eligibility decision.
123
+ *
124
+ * Fail-closed: promotion is eligible ONLY from the
125
+ * `plan_refined_ready_for_promotion` state (produced by P3 refinement). Any other intake state returns
126
+ * `ok: false` with a reason and no action — the caller must make zero GitHub
127
+ * mutation on that path. When the plan already carries a linked PR number in
128
+ * front-matter, the decision is `already_promoted` (idempotent: open nothing,
129
+ * report the existing PR).
130
+ *
131
+ * @param {object} facts
132
+ * @param {boolean} facts.baseSectionsValid whether the plan passes the base-section validator
133
+ * @param {boolean} facts.hasAcceptanceCriteria whether a non-empty Acceptance criteria section is present
134
+ * @param {boolean} facts.hasDefinitionOfDone whether a non-empty Definition of done section is present
135
+ * @param {number|null} [facts.existingPrNumber] PR number already recorded in the plan's front-matter, if any
136
+ * @returns {{ ok: boolean, action?: string, reason?: string, planFileIntakeState?: string, existingPrNumber?: number }}
137
+ */
138
+ export function evaluatePromoteEligibility({
139
+ baseSectionsValid,
140
+ hasAcceptanceCriteria,
141
+ hasDefinitionOfDone,
142
+ existingPrNumber = null,
143
+ } = {}) {
144
+ const state = evaluatePlanFileIntakeState({
145
+ baseSectionsValid,
146
+ hasAcceptanceCriteria,
147
+ hasDefinitionOfDone,
148
+ }).state;
149
+
150
+ // The ready gate: promotion only acts on a fully-refined plan. A plan that
151
+ // still needs refinement, is ambiguous, or fails the base contract is not
152
+ // promotable here; fail closed so the CLI makes no GitHub mutation.
153
+ if (state !== PLAN_FILE_INTAKE_STATE.PLAN_REFINED_READY_FOR_PROMOTION) {
154
+ return { ok: false, reason: "not_ready_for_promotion", planFileIntakeState: state };
155
+ }
156
+
157
+ // Idempotency: a plan already linked to a PR has been promoted. Report the
158
+ // existing PR and open nothing.
159
+ if (Number.isInteger(existingPrNumber) && existingPrNumber > 0) {
160
+ return {
161
+ ok: true,
162
+ action: PLAN_FILE_PROMOTE_ACTION.ALREADY_PROMOTED,
163
+ planFileIntakeState: state,
164
+ existingPrNumber,
165
+ };
166
+ }
167
+
168
+ return { ok: true, action: PLAN_FILE_PROMOTE_ACTION.PROMOTE, planFileIntakeState: state };
169
+ }
170
+
171
+ /**
172
+ * Neutralize GitHub issue-closing keywords (`closes #12`, `fixes #3`,
173
+ * `resolved #7`, …) by wrapping the keyword+reference in inline code, which
174
+ * GitHub does not parse as a closing reference. The AC/DoD section bodies are
175
+ * untrusted plan content; an accidental `Closes #123` in a plan would otherwise
176
+ * flow into the PR body and auto-close an unrelated issue on merge — breaking
177
+ * the PR-FIRST guarantee that promotion never closes a tracker artifact.
178
+ *
179
+ * ponytail: handles the realistic `#<n>` form; cross-repo (`owner/repo#n`) and
180
+ * full-URL closing refs are not neutralized — out of scope for local plans.
181
+ */
182
+ function neutralizeIssueCloseKeywords(text) {
183
+ return String(text).replace(
184
+ /\b(close[sd]?|fix(?:e[sd])?|resolve[sd]?)(\s+)(#\d+)\b/giu,
185
+ "`$1$2$3`",
186
+ );
187
+ }
188
+
189
+ /**
190
+ * Build the draft-PR body for a promoted plan. The body is the self-contained
191
+ * spec-of-record: it references the committed plan doc path (the PR→plan link)
192
+ * and carries the FULL Acceptance criteria + Definition of done extracted from
193
+ * the refined plan, so the PR alone fully specifies the work.
194
+ *
195
+ * Deliberately no `Closes #N` / issue reference: PR-FIRST promotion never mints
196
+ * an issue, and the committed plan doc — not a tracker artifact — is the
197
+ * authority. Issue-closing keywords inside the embedded AC/DoD are neutralized
198
+ * so untrusted plan content cannot smuggle one in.
199
+ *
200
+ * @param {object} params
201
+ * @param {string} params.planDocPath repo-relative path of the committed plan doc
202
+ * @param {string} params.acceptanceCriteria full Acceptance criteria section body
203
+ * @param {string} params.definitionOfDone full Definition of done section body
204
+ * @returns {string}
205
+ */
206
+ export function buildPromotionPrBody({ planDocPath, acceptanceCriteria, definitionOfDone } = {}) {
207
+ const docPath = String(planDocPath ?? "").trim();
208
+ const ac = String(acceptanceCriteria ?? "").trim();
209
+ const dod = String(definitionOfDone ?? "").trim();
210
+ if (docPath.length === 0) {
211
+ throw new Error("buildPromotionPrBody requires a planDocPath");
212
+ }
213
+ if (ac.length === 0) {
214
+ throw new Error("buildPromotionPrBody requires acceptanceCriteria");
215
+ }
216
+ if (dod.length === 0) {
217
+ throw new Error("buildPromotionPrBody requires definitionOfDone");
218
+ }
219
+ const safeAc = neutralizeIssueCloseKeywords(ac);
220
+ const safeDod = neutralizeIssueCloseKeywords(dod);
221
+ return [
222
+ `Spec-of-record: the committed plan doc \`${docPath}\` is the authority for this work.`,
223
+ "This PR was opened by PR-FIRST promotion; no tracker issue exists.",
224
+ "",
225
+ "## Acceptance criteria",
226
+ "",
227
+ safeAc,
228
+ "",
229
+ "## Definition of done",
230
+ "",
231
+ safeDod,
232
+ "",
233
+ ].join("\n");
234
+ }
@@ -0,0 +1,229 @@
1
+ /**
2
+ * Local-planning plan-file refine + human-review checkpoint (off-tracker).
3
+ *
4
+ * This is the P3 core noise-reduction step. It generalizes the proposal-first
5
+ * intake pattern (emit a local artifact, human-gate it, make zero tracker
6
+ * mutation, stop and ask) to plan files: the refined plan is written in-place to
7
+ * the single canonical plan file, the autonomous docs-grill runs as a step
8
+ * within refinement, and the loop stops at a local human-review checkpoint with
9
+ * the intake state advanced to `plan_refined_ready_for_promotion`.
10
+ *
11
+ * This module is pure: it transforms markdown text and reports a disposition. It
12
+ * performs no GitHub mutation, no network calls, and no filesystem I/O. The
13
+ * caller supplies the section-presence facts it already read (mirroring the
14
+ * `evaluatePlanFileIntakeState` precedent) and the refiner-produced payload, and
15
+ * writes the returned markdown back. That keeps the zero-tracker-mutation
16
+ * guarantee structural: there is no gh/network surface to reach from here.
17
+ *
18
+ * It composes the already-shipped contracts: P2 `evaluatePlanFileIntakeState` +
19
+ * `PLAN_FILE_REFINEMENT_SECTIONS` (this step acts on `new_plan_needs_refinement`
20
+ * and drives the transition to `plan_refined_ready_for_promotion`) and #948's
21
+ * docs-grill: the caller classifies each finding with `classifyDocsGrillFinding`
22
+ * and passes the dispositions in, and this module validates and records them.
23
+ */
24
+
25
+ import {
26
+ evaluatePlanFileIntakeState,
27
+ PLAN_FILE_INTAKE_STATE,
28
+ PLAN_FILE_REFINEMENT_SECTIONS,
29
+ } from "./plan-file-intake-contract.mjs";
30
+
31
+ /** The local human-review checkpoint surface the loop stops at on success. */
32
+ export const PLAN_FILE_REFINE_STOP = Object.freeze({
33
+ /** Refinement wrote the plan in-place; stop for local human review before any promotion. */
34
+ LOCAL_HUMAN_REVIEW: "local_human_review",
35
+ });
36
+
37
+ /** The heading the recorded docs-grill findings live under in the plan file. */
38
+ export const DOCS_GRILL_FINDINGS_HEADING = "Docs-grill findings";
39
+
40
+ /** The heading the refiner coverage matrix lives under in the plan file. */
41
+ export const COVERAGE_MATRIX_HEADING = "Coverage matrix";
42
+
43
+ /**
44
+ * Remove an existing `## <heading>` section (heading + body up to the next H2)
45
+ * from markdown so a refine re-run replaces it rather than appending a duplicate.
46
+ * Returns the markdown unchanged when the heading is absent.
47
+ */
48
+ function stripSection(markdownText, headingText) {
49
+ const escapedHeading = headingText.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&");
50
+ const headingPattern = new RegExp(`^##\\s+${escapedHeading}\\s*$`, "imu");
51
+ const match = headingPattern.exec(markdownText);
52
+ if (!match || match.index === undefined) return markdownText;
53
+ const start = match.index;
54
+ const afterHeading = start + match[0].length;
55
+ const remaining = markdownText.slice(afterHeading);
56
+ const nextHeadingMatch = /^##\s+/imu.exec(remaining);
57
+ const end = nextHeadingMatch && nextHeadingMatch.index !== undefined
58
+ ? afterHeading + nextHeadingMatch.index
59
+ : markdownText.length;
60
+ // Drop the section and collapse the blank-line gap it leaves behind.
61
+ return `${markdownText.slice(0, start)}${markdownText.slice(end)}`.replace(/\n{3,}/gu, "\n\n");
62
+ }
63
+
64
+ /**
65
+ * Whether markdown carries a `## <heading>` section marker. Used to re-derive
66
+ * the section-presence facts from the freshly-written text so the end-state
67
+ * check verifies the append actually happened (rather than re-asserting the
68
+ * inputs). ponytail: local copy of the section-detect regex; the shared section
69
+ * helper belongs in core once extractSection is lifted out of scripts/.
70
+ */
71
+ function hasSection(markdownText, headingText) {
72
+ const escaped = headingText.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&");
73
+ return new RegExp(`^##\\s+${escaped}\\s*$`, "imu").test(markdownText);
74
+ }
75
+
76
+ /** Append a `## <heading>` section with the given body to markdown. */
77
+ function appendSection(markdownText, headingText, body) {
78
+ const trimmed = markdownText.replace(/\s+$/u, "");
79
+ return `${trimmed}\n\n## ${headingText}\n\n${String(body).trim()}\n`;
80
+ }
81
+
82
+ /**
83
+ * Render recorded docs-grill findings as a markdown body. Each finding lists the
84
+ * disposition `classifyDocsGrillFinding` assigned it. When the grill produced no
85
+ * findings, an explicit "none recorded" line keeps the section idempotent and
86
+ * non-empty so the plan file always carries the grill evidence.
87
+ */
88
+ function renderGrillFindings(classified) {
89
+ if (classified.length === 0) {
90
+ return "- None recorded; the docs-grill step ran and surfaced no findings.";
91
+ }
92
+ return classified
93
+ .map((entry) => {
94
+ const summary = String(entry.summary ?? "").trim() || "(no summary)";
95
+ return `- [${entry.disposition}] (${entry.kind}) ${summary}`;
96
+ })
97
+ .join("\n");
98
+ }
99
+
100
+ /**
101
+ * Refine a plan file in-place and advance its intake state, then stop at the
102
+ * local human-review checkpoint.
103
+ *
104
+ * The caller reads the plan file and supplies the section-presence facts plus
105
+ * the refiner payload. On success it writes `refinedMarkdown` back to the same
106
+ * path (the single canonical artifact) and stops; it never promotes here.
107
+ *
108
+ * @param {object} params
109
+ * @param {string} params.markdownText current plan-file markdown
110
+ * @param {boolean} params.baseSectionsValid whether the plan passes the base-section validator
111
+ * @param {boolean} params.hasAcceptanceCriteria whether the plan already carries a non-empty Acceptance criteria section marker (per the intake contract)
112
+ * @param {boolean} params.hasDefinitionOfDone whether the plan already carries a non-empty Definition of done section marker (per the intake contract)
113
+ * @param {object} params.payload refiner-produced refinement output
114
+ * @param {string} params.payload.acceptanceCriteria Acceptance criteria section body
115
+ * @param {string} params.payload.definitionOfDone Definition of done section body
116
+ * @param {string} params.payload.coverageMatrix AC/DoD/Non-goal coverage matrix (markdown table)
117
+ * @param {object[]} [params.payload.grillDispositions] docs-grill dispositions the caller pre-classified via #948's `classifyDocsGrillFinding`; each entry is `{ kind, summary, disposition }` and a null/invalid `disposition` fails the grill closed
118
+ * @returns {{
119
+ * ok: boolean,
120
+ * reason?: string,
121
+ * planFileIntakeState?: string,
122
+ * refinedMarkdown?: string,
123
+ * grillDispositions?: object[],
124
+ * stop?: { kind: string },
125
+ * }}
126
+ */
127
+ export function refinePlanFileInPlace({
128
+ markdownText,
129
+ baseSectionsValid,
130
+ hasAcceptanceCriteria,
131
+ hasDefinitionOfDone,
132
+ payload,
133
+ } = {}) {
134
+ if (typeof markdownText !== "string" || markdownText.length === 0) {
135
+ return { ok: false, reason: "missing_plan_markdown" };
136
+ }
137
+
138
+ // Gate on the starting intake state: this step only acts on a base-valid plan
139
+ // that has not yet been refined. A plan already carrying refinement markers, a
140
+ // partially-refined plan, or one failing the base contract is ambiguous here;
141
+ // fail closed without writing or advancing.
142
+ const startState = evaluatePlanFileIntakeState({
143
+ baseSectionsValid,
144
+ hasAcceptanceCriteria,
145
+ hasDefinitionOfDone,
146
+ }).state;
147
+ if (startState !== PLAN_FILE_INTAKE_STATE.NEW_PLAN_NEEDS_REFINEMENT) {
148
+ return { ok: false, reason: "not_in_new_plan_needs_refinement", planFileIntakeState: startState };
149
+ }
150
+
151
+ // The refiner payload must carry the refinement contract: AC, DoD, and the
152
+ // coverage matrix. A missing/empty piece is a failed refine; fail closed.
153
+ if (!payload || typeof payload !== "object") {
154
+ return { ok: false, reason: "missing_refinement_payload", planFileIntakeState: startState };
155
+ }
156
+ const acceptanceCriteria = String(payload.acceptanceCriteria ?? "").trim();
157
+ const definitionOfDone = String(payload.definitionOfDone ?? "").trim();
158
+ const coverageMatrix = String(payload.coverageMatrix ?? "").trim();
159
+ if (!acceptanceCriteria) {
160
+ return { ok: false, reason: "missing_acceptance_criteria", planFileIntakeState: startState };
161
+ }
162
+ if (!definitionOfDone) {
163
+ return { ok: false, reason: "missing_definition_of_done", planFileIntakeState: startState };
164
+ }
165
+ if (!coverageMatrix) {
166
+ return { ok: false, reason: "missing_coverage_matrix", planFileIntakeState: startState };
167
+ }
168
+
169
+ // The docs-grill runs as a step of refinement. The caller (the CLI, which owns
170
+ // I/O and the scripts/ boundary) classifies each finding with #948's
171
+ // `classifyDocsGrillFinding` and passes the dispositions in. This core module
172
+ // validates and renders them but does NOT import the classifier — keeping it
173
+ // free of any scripts/ import so the published @dev-loops/core package does not
174
+ // break for consumers. A missing or invalid disposition fails the grill closed
175
+ // so a malformed grill cannot advance the state.
176
+ const grillDispositions = Array.isArray(payload.grillDispositions) ? payload.grillDispositions : [];
177
+ for (const d of grillDispositions) {
178
+ if (!d || typeof d.disposition !== "string" || d.disposition.length === 0) {
179
+ return { ok: false, reason: "docs_grill_failed", planFileIntakeState: startState };
180
+ }
181
+ }
182
+
183
+ // A managed section body must not contain a top-level `## ` heading: stripSection
184
+ // finds a section's end by scanning to the next `## `, so an embedded H2 in a body
185
+ // would break the strip-then-append idempotency on a re-run (the inner heading and
186
+ // its text would orphan into the document body). Fail closed on such a payload.
187
+ const grillBody = renderGrillFindings(grillDispositions);
188
+ if ([acceptanceCriteria, definitionOfDone, coverageMatrix, grillBody].some((b) => /^##\s/mu.test(String(b)))) {
189
+ return { ok: false, reason: "section_body_contains_heading", planFileIntakeState: startState };
190
+ }
191
+
192
+ // Write the refinement sections in-place. Strip any prior copy first so a
193
+ // re-run replaces rather than duplicates (idempotency), then append the fresh
194
+ // sections in a stable order.
195
+ const [acHeading, dodHeading] = PLAN_FILE_REFINEMENT_SECTIONS;
196
+ let refinedMarkdown = markdownText;
197
+ for (const heading of [acHeading, dodHeading, COVERAGE_MATRIX_HEADING, DOCS_GRILL_FINDINGS_HEADING]) {
198
+ refinedMarkdown = stripSection(refinedMarkdown, heading);
199
+ }
200
+ refinedMarkdown = appendSection(refinedMarkdown, acHeading, acceptanceCriteria);
201
+ refinedMarkdown = appendSection(refinedMarkdown, dodHeading, definitionOfDone);
202
+ refinedMarkdown = appendSection(refinedMarkdown, COVERAGE_MATRIX_HEADING, coverageMatrix);
203
+ refinedMarkdown = appendSection(refinedMarkdown, DOCS_GRILL_FINDINGS_HEADING, grillBody);
204
+
205
+ // Re-derive the section-presence facts from the text the write just produced
206
+ // (not from the inputs) so this check actually verifies the append landed: a
207
+ // correct refine carries the base sections forward and adds both refinement
208
+ // markers, flipping the intake state to ready. A buggy rewrite that dropped a
209
+ // section is caught here and fails closed rather than advancing the state.
210
+ const endState = evaluatePlanFileIntakeState({
211
+ baseSectionsValid,
212
+ hasAcceptanceCriteria: hasSection(refinedMarkdown, acHeading),
213
+ hasDefinitionOfDone: hasSection(refinedMarkdown, dodHeading),
214
+ }).state;
215
+ if (endState !== PLAN_FILE_INTAKE_STATE.PLAN_REFINED_READY_FOR_PROMOTION) {
216
+ return { ok: false, reason: "refine_did_not_reach_ready", planFileIntakeState: endState };
217
+ }
218
+
219
+ return {
220
+ ok: true,
221
+ planFileIntakeState: endState,
222
+ refinedMarkdown,
223
+ grillDispositions,
224
+ // Generalized proposal-first stop: the refined plan is the local artifact,
225
+ // it is written in-place, and the loop stops here for human review before
226
+ // any promotion. No tracker artifact is created or mutated.
227
+ stop: { kind: PLAN_FILE_REFINE_STOP.LOCAL_HUMAN_REVIEW },
228
+ };
229
+ }