@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.
- package/package.json +9 -1
- package/src/config/config.mjs +213 -6
- package/src/config/extension-defaults.yaml +32 -1
- package/src/loop/async-start-contract.mjs +8 -27
- package/src/loop/handoff-envelope.mjs +50 -8
- package/src/loop/lifecycle-state.mjs +13 -1
- package/src/loop/plan-file-intake-contract.mjs +56 -0
- package/src/loop/plan-file-promote-contract.mjs +234 -0
- package/src/loop/plan-file-refine-contract.mjs +229 -0
- package/src/loop/pr-gate-coordination.mjs +114 -9
- package/src/loop/queue-board-ordering.mjs +5 -1
- package/src/loop/queue-driver.mjs +25 -3
- package/src/loop/run-context.mjs +9 -16
- package/src/loop/spike-exit-contract.mjs +138 -0
- package/src/loop/spike-intake-contract.mjs +52 -0
- package/src/loop/worktree-guard.mjs +6 -16
|
@@ -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
|
+
}
|