@dev-loops/core 0.4.0 → 0.6.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dev-loops/core",
3
- "version": "0.4.0",
3
+ "version": "0.6.0",
4
4
  "type": "module",
5
5
  "engines": {
6
6
  "node": ">=24"
@@ -37,6 +37,9 @@
37
37
  "./loop/issue-refinement-artifact": "./src/loop/issue-refinement-artifact.mjs",
38
38
  "./loop/phase-files": "./src/loop/phase-files.mjs",
39
39
  "./loop/policy-constants": "./src/loop/policy-constants.mjs",
40
+ "./loop/plan-file-intake-contract": "./src/loop/plan-file-intake-contract.mjs",
41
+ "./loop/plan-file-promote-contract": "./src/loop/plan-file-promote-contract.mjs",
42
+ "./loop/plan-file-refine-contract": "./src/loop/plan-file-refine-contract.mjs",
40
43
  "./loop/pr-gate-coordination": "./src/loop/pr-gate-coordination.mjs",
41
44
  "./loop/pr-title-markers": "./src/loop/pr-title-markers.mjs",
42
45
  "./loop/public-dev-loop-routing": "./src/loop/public-dev-loop-routing.mjs",
@@ -48,9 +51,12 @@
48
51
  "./loop/reviewer-loop-state": "./src/loop/reviewer-loop-state.mjs",
49
52
  "./loop/run-context": "./src/loop/run-context.mjs",
50
53
  "./loop/run-inspection": "./src/loop/run-inspection.mjs",
54
+ "./loop/spike-exit-contract": "./src/loop/spike-exit-contract.mjs",
55
+ "./loop/spike-intake-contract": "./src/loop/spike-intake-contract.mjs",
51
56
  "./loop/steering": "./src/loop/steering.mjs",
52
57
  "./loop/timeout-policy": "./src/loop/timeout-policy.mjs",
53
58
  "./loop/tracker-pr-state": "./src/loop/tracker-pr-state.mjs",
59
+ "./loop/ui-e2e-scoping": "./src/loop/ui-e2e-scoping.mjs",
54
60
  "./refinement/ac-dod-matrix": "./src/refinement/ac-dod-matrix.mjs",
55
61
  "./harness": "./src/harness/index.mjs",
56
62
  "./loop/worktree-guard": "./src/loop/worktree-guard.mjs",
@@ -169,6 +169,32 @@ export function transformAgent({ source, raw, version = "latest" }) {
169
169
  return `${lines.join("\n")}\n${body}`;
170
170
  }
171
171
 
172
+ /**
173
+ * Transform a canonical `commands/<name>.command.md` into a Claude `.claude/commands/<name>.md`
174
+ * slash command (#972). Commands are thin wrappers over the public dev-loop contract: the body
175
+ * is a prompt (with `$ARGUMENTS`) that invokes the existing entrypoint, so there is NO routing
176
+ * logic here. Frontmatter keeps Claude's command fields (`description`, `argument-hint`); the body
177
+ * is passed through `stripPiOnlyBlocks` + `rewriteCliInvocation` like agents/skills.
178
+ * @param {{ source: string, raw: string, version?: string }} input
179
+ * @returns {string} Full generated file content.
180
+ */
181
+ export function transformCommand({ source, raw, version = "latest" }) {
182
+ const { frontmatter, body: rawBody } = splitFrontmatter(raw, source);
183
+ const body = rewriteCliInvocation(stripPiOnlyBlocks(rawBody), version);
184
+
185
+ const lines = ["---"];
186
+ if (frontmatter.description != null) {
187
+ lines.push(`description: ${JSON.stringify(String(frontmatter.description))}`);
188
+ }
189
+ if (frontmatter["argument-hint"] != null) {
190
+ lines.push(`argument-hint: ${JSON.stringify(String(frontmatter["argument-hint"]))}`);
191
+ }
192
+ lines.push("---");
193
+ lines.push(GENERATED_NOTE(source));
194
+ lines.push("");
195
+ return `${lines.join("\n")}\n${body}`;
196
+ }
197
+
172
198
  /**
173
199
  * Transform a canonical `skills/<name>/SKILL.md` into a Claude `.claude/skills/<name>/SKILL.md`.
174
200
  * @param {{ source: string, raw: string, version?: string }} input
@@ -53,6 +53,12 @@ const GatesConfig = z.strictObject({
53
53
  // `requireCi` is only behaviorally configurable for the draft gate.
54
54
  // preApproval always requires CI even if config repeats `requireCi`.
55
55
  preApproval: GateConfig.optional(),
56
+ // Relaxed spike gate profile (#965). A spike's deliverable is a findings doc,
57
+ // not production code, so it should not carry the full draft → pre-approval →
58
+ // Copilot production set. Resolved through the same config-merge layering and
59
+ // the same resolveGateConfig path as draft/preApproval — no new strategy→knob
60
+ // resolver. Absent for non-spike work, so production gates are unaffected.
61
+ spike: GateConfig.optional(),
56
62
  // Fail-closed enforcement that a gate verdict was produced by the
57
63
  // fan-out/fan-in review sub-loop (executionMode === "fanout_fanin" plus a
58
64
  // durable findings-log ledger), not an inline single-agent run. Default
@@ -105,6 +111,11 @@ const WorkflowConfig = z.strictObject({
105
111
  asyncStartMode: z.enum(["required", "allowed"]).default("required"),
106
112
  requireRetrospective: z.boolean(),
107
113
  requireRetrospectiveGate: z.boolean().default(false),
114
+ // Developer-mode retro step (#982): enforce internal-tooling-only execution
115
+ // (no agent-level raw gh/python/node -e) in the retrospective gate. This is the
116
+ // dev-loops maintainers' own dogfooding discipline — opt-in, default OFF so
117
+ // consumers of the extension are never blocked by it.
118
+ requireRetrospectiveInternalTooling: z.boolean().default(false),
108
119
  requireDraftFirst: z.boolean(),
109
120
  devModeDefault: z.boolean(),
110
121
  });
@@ -140,6 +151,16 @@ const WorktreeConfig = z.strictObject({
140
151
  linkOnInit: z.array(z.string().trim().min(1)).optional(),
141
152
  });
142
153
 
154
+ /**
155
+ * Local-planning config (#949): where persisted markdown plan files (phase-doc
156
+ * format) live when work originates from a plan file rather than a tracker
157
+ * issue. `plansDir` is a repo-relative directory; defaults to the existing
158
+ * phase-docs directory. See skills/docs/plan-file-contract.md.
159
+ */
160
+ const LocalPlanningConfig = z.strictObject({
161
+ plansDir: z.string().trim().min(1).optional(),
162
+ });
163
+
143
164
  /** Internal path whitelist for internal-only PR detection — flat array of regex strings */
144
165
  const InternalPatternsConfig = z.array(z.string().trim().min(1)).min(1);
145
166
 
@@ -159,6 +180,7 @@ const FileGateConfig = GateConfig.partial();
159
180
  const FileGatesConfig = z.strictObject({
160
181
  draft: FileGateConfig.optional(),
161
182
  preApproval: FileGateConfig.optional(),
183
+ spike: FileGateConfig.optional(),
162
184
  requireFanoutEvidence: z.boolean().optional(),
163
185
  maxFanoutReviewers: z.number().int().min(1).max(64).optional(),
164
186
  postFindingsComments: z.boolean().optional(),
@@ -190,6 +212,7 @@ export const DevLoopConfigSchema = z.strictObject({
190
212
  personas: PersonasConfig.optional(),
191
213
  internalPathPatterns: InternalPatternsConfig.optional(),
192
214
  worktree: WorktreeConfig.optional(),
215
+ localPlanning: LocalPlanningConfig.optional(),
193
216
  });
194
217
 
195
218
  // ============================================================================
@@ -215,6 +238,7 @@ export const BUILT_IN_DEFAULTS = Object.freeze({
215
238
  asyncStartMode: "required",
216
239
  requireRetrospective: false,
217
240
  requireRetrospectiveGate: false,
241
+ requireRetrospectiveInternalTooling: false,
218
242
  requireDraftFirst: false,
219
243
  devModeDefault: false,
220
244
  }),
@@ -239,6 +263,7 @@ export const BUILT_IN_DEFAULTS = Object.freeze({
239
263
  "^test/",
240
264
  ]),
241
265
  worktree: Object.freeze({ copyOnInit: Object.freeze([]), linkOnInit: Object.freeze([]) }),
266
+ localPlanning: Object.freeze({ plansDir: "docs/phases/" }),
242
267
  });
243
268
 
244
269
  // ============================================================================
@@ -260,6 +285,7 @@ export const FileConfigSchema = z.strictObject({
260
285
  personas: FilePersonasConfig.optional(),
261
286
  internalPathPatterns: InternalPatternsConfig.optional(),
262
287
  worktree: WorktreeConfig.partial().optional(),
288
+ localPlanning: LocalPlanningConfig.partial().optional(),
263
289
  });
264
290
 
265
291
  // ============================================================================
@@ -916,7 +942,7 @@ export function resolveRefinement(config) {
916
942
  * flags always resolve to stable defaults.
917
943
  *
918
944
  * @param {DevLoopConfig} config
919
- * @param {"draft"|"preApproval"} gate
945
+ * @param {"draft"|"preApproval"|"spike"} gate
920
946
  * @returns {{ angles: string[]|null, excludeAngles: string[], mandatoryAngles: string[], required: boolean, requireCi: boolean, blockCleanOnFindingSeverities: string[], dynamicAngles: boolean }}
921
947
  */
922
948
  export function resolveGateConfig(config, gate) {
@@ -1116,7 +1142,7 @@ export async function resolveGateAnglesDynamic(config, gate, { diff } = {}) {
1116
1142
  * for the requested key.
1117
1143
  *
1118
1144
  * @param {DevLoopConfig} config
1119
- * @param {"asyncStartMode"|"requireRetrospective"|"requireRetrospectiveGate"|"requireDraftFirst"|"devModeDefault"} key
1145
+ * @param {"asyncStartMode"|"requireRetrospective"|"requireRetrospectiveGate"|"requireRetrospectiveInternalTooling"|"requireDraftFirst"|"devModeDefault"} key
1120
1146
  * @returns {string|boolean}
1121
1147
  */
1122
1148
  export function resolveWorkflowConfig(config, key) {
@@ -1132,6 +1158,10 @@ export function resolveWorkflowConfig(config, key) {
1132
1158
  return config?.workflow?.requireRetrospectiveGate ?? DEFAULT_WORKFLOW_CONFIG.requireRetrospectiveGate;
1133
1159
  }
1134
1160
 
1161
+ if (key === "requireRetrospectiveInternalTooling") {
1162
+ return config?.workflow?.requireRetrospectiveInternalTooling ?? DEFAULT_WORKFLOW_CONFIG.requireRetrospectiveInternalTooling;
1163
+ }
1164
+
1135
1165
  if (key === "requireDraftFirst") {
1136
1166
  return config?.workflow?.requireDraftFirst ?? DEFAULT_WORKFLOW_CONFIG.requireDraftFirst;
1137
1167
  }
@@ -1177,6 +1207,24 @@ export function resolveWorktreeConfig(config) {
1177
1207
  return { copyOnInit: list(wt?.copyOnInit), linkOnInit: list(wt?.linkOnInit) };
1178
1208
  }
1179
1209
 
1210
+ /**
1211
+ * Resolve the local-planning plans directory from the merged dev-loop config.
1212
+ *
1213
+ * Returns the configured `localPlanning.plansDir` (trimmed) when present and
1214
+ * non-empty, otherwise the built-in default (`docs/phases/`) — the existing
1215
+ * phase-docs directory. See skills/docs/plan-file-contract.md.
1216
+ *
1217
+ * @param {DevLoopConfig} config
1218
+ * @returns {string}
1219
+ */
1220
+ export function resolvePlansDir(config) {
1221
+ const raw = config?.localPlanning?.plansDir;
1222
+ if (typeof raw === "string" && raw.trim().length > 0) {
1223
+ return raw.trim();
1224
+ }
1225
+ return BUILT_IN_DEFAULTS.localPlanning.plansDir;
1226
+ }
1227
+
1180
1228
  /**
1181
1229
  * Resolve the human-handoff config from the merged dev-loop config (#920).
1182
1230
  *
@@ -49,6 +49,9 @@ gates:
49
49
  requireCi: true
50
50
  mandatoryAngles:
51
51
  - pr-description
52
+ # Gate findings comments live ON the PR (the local-first spec-of-record /
53
+ # human-review surface), so they are evidence, not tracker noise — keep them on.
54
+ postFindingsComments: true
52
55
  preApproval:
53
56
  angles:
54
57
  - dry
@@ -67,11 +70,27 @@ gates:
67
70
  required: true
68
71
  mandatoryAngles:
69
72
  - pr-checklist-matrix
73
+ # Relaxed spike gate profile (#965). A spike's deliverable is a findings doc,
74
+ # not production code, so it is intentionally lighter than the production
75
+ # draft -> pre-approval -> Copilot set: a small docs-first angle set, not a
76
+ # required gate, and no CI prerequisite. Resolved through the same
77
+ # config-merge layering and resolveGateConfig path as draft/preApproval; only
78
+ # applies to spike-mode work, so production gates are unaffected.
79
+ spike:
80
+ angles:
81
+ - scope
82
+ - docs
83
+ excludeAngles: []
84
+ required: false
85
+ requireCi: false
86
+ mandatoryAngles: []
70
87
 
71
88
  # Autonomy: only merge requires operator confirmation by default.
72
89
  autonomy:
73
90
  stopAt:
74
91
  - merge
92
+ # Local-first never auto-merges; a human always merges.
93
+ humanMergeOnly: true
75
94
 
76
95
  # Workflow enforcement defaults.
77
96
  workflow:
@@ -82,6 +101,11 @@ workflow:
82
101
  # repo-root .devloops, which takes precedence over these extension defaults.
83
102
  requireRetrospective: false
84
103
  requireRetrospectiveGate: false
104
+ # Internal-tooling-only retro check (#982) is a DEVELOPER-MODE step — the dev-loops
105
+ # maintainers' own dogfooding discipline. It must never block a consumer's state
106
+ # changes (consumers may legitimately use raw gh/python/node -e), so it ships OFF.
107
+ # The dev-loops repo opts in via its own repo-root .devloops (takes precedence here).
108
+ requireRetrospectiveInternalTooling: false
85
109
  requireDraftFirst: true
86
110
  # Dev mode is the dev-loop self-improvement mode — it edits the loop's own skill/agent prompts
87
111
  # after a phase, which is only meaningful in the dev-loops repo. Shipped defaults must not force
@@ -89,6 +113,11 @@ workflow:
89
113
  # via its own repo-root .devloops (which takes precedence over these extension defaults).
90
114
  devModeDefault: false
91
115
 
116
+ # Local-planning: where persisted markdown plan files (phase-doc format) live
117
+ # when work originates from a plan file rather than a tracker issue (#949).
118
+ localPlanning:
119
+ plansDir: docs/phases/
120
+
92
121
  # Light-mode threshold for small local changes.
93
122
  localImplementation:
94
123
  lightMode:
@@ -99,7 +128,9 @@ localImplementation:
99
128
  # Queue defaults (repo-specific projectNumber/boardTitle omitted by design).
100
129
  queue:
101
130
  maxParallel: 3
102
- maxAutoFiledIssues: 10
131
+ # Local-first is PR-first (issues are skipped, #952), so auto-filing issues is
132
+ # near-zero; a low cap keeps tracker noise minimal, especially early.
133
+ maxAutoFiledIssues: 1
103
134
  reDispatchMaxRetries: 1
104
135
 
105
136
  # Persona registry used by gate review angle resolution.
@@ -13,6 +13,7 @@
13
13
  * Async context marker (required when workflow.asyncStartMode is `required`)
14
14
  * — see `@dev-loops/core/loop/run-context`:
15
15
  * - DEVLOOPS_RUN_ID env var (neutral, harness-agnostic)
16
+ * - PI_SUBAGENT_RUN_ID env var (the alias the Pi runtime injects)
16
17
  *
17
18
  * Allowed modes:
18
19
  * - workflow.asyncStartMode: required | allowed
@@ -159,14 +160,20 @@ export function validateAsyncStartContext({
159
160
  };
160
161
  }
161
162
 
162
- // No marker found — fail closed
163
+ // No marker found — fail closed.
164
+ // Derive the marker hint from ASYNC_CONTEXT_MARKERS (primary first, aliases after)
165
+ // so the message never drifts from the recognized-marker list.
166
+ const [primaryMarker, ...aliasMarkers] = ASYNC_CONTEXT_MARKERS;
167
+ const markerHint = aliasMarkers.length
168
+ ? `Set ${primaryMarker} (or the ${aliasMarkers.join("/")} alias) to proceed. `
169
+ : `Set ${primaryMarker} to proceed. `;
163
170
  return {
164
171
  status: ASYNC_START_STATUS.REJECTED,
165
172
  reason:
166
173
  "No async context detected. " +
167
174
  "The dev-loop must run within a visible async subagent session, " +
168
175
  "not as a detached local process. " +
169
- `Set ${ASYNC_CONTEXT_MARKERS[0]} to proceed. ` +
176
+ markerHint +
170
177
  "Repository-maintained workflow policy controls any exceptions.",
171
178
  detectedMarker: null,
172
179
  };
@@ -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
+ }