@dev-loops/core 0.4.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dev-loops/core",
3
- "version": "0.4.0",
3
+ "version": "0.5.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,6 +51,8 @@
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",
@@ -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.
@@ -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
+ }
@@ -119,6 +119,19 @@ function normalizeMergeStateStatus(value) {
119
119
  return value.trim().toUpperCase();
120
120
  }
121
121
 
122
+ function normalizeMergeable(value) {
123
+ if (typeof value !== "string" || value.trim().length === 0) {
124
+ return null;
125
+ }
126
+
127
+ const upper = value.trim().toUpperCase();
128
+ if (upper === "MERGEABLE" || upper === "CONFLICTING" || upper === "UNKNOWN") {
129
+ return upper;
130
+ }
131
+
132
+ return null;
133
+ }
134
+
122
135
  function normalizeConflictFiles(value) {
123
136
  if (!Array.isArray(value)) {
124
137
  return [];
@@ -142,14 +155,18 @@ function hasBlockedMergeStatus(mergeStateStatus) {
142
155
  return mergeStateStatus !== null && BLOCKED_MERGE_STATE_STATUSES.has(mergeStateStatus);
143
156
  }
144
157
 
145
- function formatBlockedMergeReason(mergeStateStatus, conflictFiles) {
158
+ function formatBlockedMergeReason(mergeStateStatus, conflictFiles, mergeable = null) {
146
159
  if (mergeStateStatus === "BEHIND") {
147
160
  let reason = "Branch must be updated from base before entering any gate.";
148
161
  reason += ` GitHub mergeStateStatus: ${mergeStateStatus}.`;
149
162
  return reason;
150
163
  }
151
164
 
152
- let reason = "The current branch conflicts with the base branch, so resolve the conflict locally on the PR branch, rerun validation, rerun gate detection, and only then resume the normal gate path.";
165
+ let reason = "The current branch conflicts with the base branch, so resolve the conflict locally on the PR branch (run `node scripts/loop/resolve-pr-conflicts.mjs --push` for the safe additive-CHANGELOG case), rerun validation, rerun gate detection, and only then resume the normal gate path.";
166
+
167
+ if (mergeable === "CONFLICTING") {
168
+ reason += " GitHub mergeable: CONFLICTING.";
169
+ }
153
170
 
154
171
  if (mergeStateStatus !== null) {
155
172
  reason += ` GitHub mergeStateStatus: ${mergeStateStatus}.`;
@@ -218,7 +235,23 @@ function buildRoundExhaustionGateEvidenceNote({ copilotReviewRoundCount, maxCopi
218
235
  return `Copilot review rounds exhausted (${copilotReviewRoundCount}/${maxCopilotRounds}); current head has zero unresolved threads and green or credibly green CI, so pre_approval_gate fallback is allowed without another Copilot re-request.`;
219
236
  }
220
237
 
221
- function evaluateRetrospectiveMergeApproval(checkpoint) {
238
+ /**
239
+ * Render the (user-authored) rawCallViolations array into a bounded, single-line
240
+ * fragment for the gate failure reason. Collapses whitespace/newlines per entry,
241
+ * caps per-entry length, and caps the number of entries shown so a large or
242
+ * garbled checkpoint cannot bloat or break gate output. Still fails closed —
243
+ * this only formats the reason; the violation count above does the gating.
244
+ */
245
+ function summarizeRawCallViolations(violations, { maxEntries = 10, maxEntryLen = 200 } = {}) {
246
+ const shown = violations.slice(0, maxEntries).map((v) => {
247
+ const flat = String(v).replace(/\s+/g, " ").trim();
248
+ return flat.length > maxEntryLen ? `${flat.slice(0, maxEntryLen)}…` : flat;
249
+ });
250
+ const more = violations.length - shown.length;
251
+ return more > 0 ? `${shown.join("; ")}; …(+${more} more)` : shown.join("; ");
252
+ }
253
+
254
+ function evaluateRetrospectiveMergeApproval(checkpoint, { developerMode = false } = {}) {
222
255
  if (!checkpoint || typeof checkpoint !== "object") {
223
256
  return { approved: false, reason: "No retrospective checkpoint was found." };
224
257
  }
@@ -278,6 +311,37 @@ function evaluateRetrospectiveMergeApproval(checkpoint) {
278
311
  return { approved: false, reason: "Retrospective is missing explicit `mergeRecommendation`." };
279
312
  }
280
313
 
314
+ // internalToolingOnly: the loop's own execution must have used internal dev-loops
315
+ // tooling only — no agent-level raw `gh`/`python`/`python3`/`node -e` (issue #982).
316
+ // This is a DEVELOPER-MODE retro step: it enforces the dev-loops maintainers'
317
+ // own dogfooding discipline and is opt-in via `workflow.requireRetrospectiveInternalTooling`
318
+ // (default OFF). CONSUMERS of the extension are never blocked by it — they may
319
+ // legitimately use raw gh/python/node -e in their own workflow — so when the flag
320
+ // is OFF these fields are neither required nor enforced (a complete checkpoint
321
+ // without them passes exactly as it did before #982). When ON it fails closed:
322
+ // a complete checkpoint must explicitly attest a clean tooling record, and an OLD
323
+ // checkpoint missing `internalToolingOnly` fails (not a silent pass). Re-record the
324
+ // retrospective with the new fields to clear it.
325
+ if (developerMode) {
326
+ const internalToolingOnly = br !== null ? br.internalToolingOnly : checkpoint.internalToolingOnly;
327
+ if (internalToolingOnly !== true) {
328
+ return {
329
+ approved: false,
330
+ reason: "Retrospective does not attest internal-tooling-only execution (`internalToolingOnly: true` is required in developer mode; agent-level raw gh/python/node -e is a violation). — re-record the retrospective with internalToolingOnly + rawCallViolations.",
331
+ };
332
+ }
333
+ const rawCallViolations = br !== null ? br.rawCallViolations : checkpoint.rawCallViolations;
334
+ if (!Array.isArray(rawCallViolations)) {
335
+ return { approved: false, reason: "Retrospective is missing `rawCallViolations` (array; empty when clean). — re-record the retrospective with internalToolingOnly + rawCallViolations." };
336
+ }
337
+ if (rawCallViolations.length > 0) {
338
+ return {
339
+ approved: false,
340
+ reason: `Retrospective records ${rawCallViolations.length} raw-call violation(s) (agent-level gh/python/node -e): ${summarizeRawCallViolations(rawCallViolations)}.`,
341
+ };
342
+ }
343
+ }
344
+
281
345
  return { approved: true, reason: null };
282
346
  }
283
347
 
@@ -610,6 +674,7 @@ function evaluatePrGateCoordinationCore(input = {}) {
610
674
  ? "internal_only"
611
675
  : (typeof input.reviewMode === "string" ? input.reviewMode.trim().toLowerCase() : null);
612
676
  const mergeStateStatus = normalizeMergeStateStatus(input.mergeStateStatus);
677
+ const mergeable = normalizeMergeable(input.mergeable);
613
678
  const conflictFiles = normalizeConflictFiles(input.conflictFiles);
614
679
  const ciStatus = normalizeCiStatus(input.ciStatus);
615
680
  const draftGateRequireCi = input.draftGateRequireCi !== false;
@@ -617,6 +682,9 @@ function evaluatePrGateCoordinationCore(input = {}) {
617
682
  const maxCopilotRounds = normalizePositiveInteger(input.maxCopilotRounds);
618
683
  const roundCapReached = maxCopilotRounds !== null && copilotReviewRoundCount >= maxCopilotRounds;
619
684
  const requireRetrospectiveGate = input.requireRetrospectiveGate === true;
685
+ // Developer-mode flag (#982): only the dev-loops repo dogfooding itself enforces the
686
+ // internal-tooling-only retro discipline. Default OFF so consumer state changes pass.
687
+ const requireRetrospectiveInternalTooling = input.requireRetrospectiveInternalTooling === true;
620
688
  const retrospectiveCheckpoint = input.retrospectiveCheckpoint;
621
689
  const prTitle = typeof input.prTitle === "string" ? input.prTitle : "";
622
690
  const refinementArtifact = input.refinementArtifact && typeof input.refinementArtifact === "object"
@@ -694,7 +762,44 @@ function evaluatePrGateCoordinationCore(input = {}) {
694
762
  });
695
763
  }
696
764
 
697
- if (hasBlockedMergeStatus(mergeStateStatus) || conflictFiles.length > 0) {
765
+ // Mergeability is a required precondition at every gate (issue #980). GitHub
766
+ // computes `mergeable` asynchronously, so an unsettled UNKNOWN must fail closed
767
+ // to a recheck — never a pass. The detect layer already re-polls a bounded
768
+ // number of times; if it still reads UNKNOWN here, hold gate progression and
769
+ // recheck rather than guess.
770
+ if (mergeable === "UNKNOWN") {
771
+ pushUnique(allowedNextActions, [PR_CHECKPOINT_ACTION.WAIT_FOR_CI]);
772
+ pushUnique(forbiddenActions, [
773
+ PR_CHECKPOINT_ACTION.RUN_DRAFT_GATE,
774
+ PR_CHECKPOINT_ACTION.RECONCILE_DRAFT_GATE,
775
+ PR_CHECKPOINT_ACTION.MARK_READY_FOR_REVIEW,
776
+ PR_CHECKPOINT_ACTION.REQUEST_COPILOT_REVIEW,
777
+ PR_CHECKPOINT_ACTION.RUN_PRE_APPROVAL_GATE,
778
+ PR_CHECKPOINT_ACTION.AWAIT_FINAL_HUMAN_APPROVAL,
779
+ PR_CHECKPOINT_ACTION.DECLARE_MERGE_READY,
780
+ ]);
781
+ return buildResult({
782
+ repo: input.repo ?? null,
783
+ pr: Number.isInteger(input.pr) ? input.pr : null,
784
+ currentHeadSha,
785
+ lifecycleState: effectiveLifecycleState,
786
+ loopDisposition: DISPOSITION.PENDING,
787
+ gateBoundary: PR_CHECKPOINT.CONFLICT_RESOLUTION,
788
+ draftGateAlreadySatisfied,
789
+ draftGate,
790
+ preApprovalGate,
791
+ allowedNextActions,
792
+ forbiddenActions,
793
+ nextAction: PR_CHECKPOINT_ACTION.WAIT_FOR_CI,
794
+ reason: "GitHub has not yet computed mergeability (mergeable=UNKNOWN), so gate progression is held: recheck before proceeding rather than treating an unsettled merge state as clean.",
795
+ mergeStateStatus,
796
+ conflictFiles,
797
+ refinementArtifact,
798
+ copilotReviewRoundCount,
799
+ });
800
+ }
801
+
802
+ if (hasBlockedMergeStatus(mergeStateStatus) || mergeable === "CONFLICTING" || conflictFiles.length > 0) {
698
803
  pushUnique(allowedNextActions, [PR_CHECKPOINT_ACTION.RESOLVE_MERGE_CONFLICTS]);
699
804
  pushUnique(forbiddenActions, [
700
805
  PR_CHECKPOINT_ACTION.RUN_DRAFT_GATE,
@@ -723,7 +828,7 @@ function evaluatePrGateCoordinationCore(input = {}) {
723
828
  allowedNextActions,
724
829
  forbiddenActions,
725
830
  nextAction: PR_CHECKPOINT_ACTION.RESOLVE_MERGE_CONFLICTS,
726
- reason: formatBlockedMergeReason(mergeStateStatus, conflictFiles),
831
+ reason: formatBlockedMergeReason(mergeStateStatus, conflictFiles, mergeable),
727
832
  mergeStateStatus,
728
833
  conflictFiles,
729
834
  refinementArtifact,
@@ -911,7 +1016,7 @@ function evaluatePrGateCoordinationCore(input = {}) {
911
1016
  });
912
1017
  }
913
1018
  if (requireRetrospectiveGate) {
914
- const retrospectiveGate = evaluateRetrospectiveMergeApproval(retrospectiveCheckpoint);
1019
+ const retrospectiveGate = evaluateRetrospectiveMergeApproval(retrospectiveCheckpoint, { developerMode: requireRetrospectiveInternalTooling });
915
1020
  if (!retrospectiveGate.approved) {
916
1021
  return buildRetrospectiveGatePendingResult({
917
1022
  input,
@@ -1183,7 +1288,7 @@ function evaluatePrGateCoordinationCore(input = {}) {
1183
1288
  });
1184
1289
  }
1185
1290
  if (requireRetrospectiveGate) {
1186
- const retrospectiveGate = evaluateRetrospectiveMergeApproval(retrospectiveCheckpoint);
1291
+ const retrospectiveGate = evaluateRetrospectiveMergeApproval(retrospectiveCheckpoint, { developerMode: requireRetrospectiveInternalTooling });
1187
1292
  if (!retrospectiveGate.approved) {
1188
1293
  return buildRetrospectiveGatePendingResult({
1189
1294
  input,
@@ -1349,7 +1454,7 @@ function evaluatePrGateCoordinationCore(input = {}) {
1349
1454
  });
1350
1455
  }
1351
1456
  if (requireRetrospectiveGate) {
1352
- const retrospectiveGate = evaluateRetrospectiveMergeApproval(retrospectiveCheckpoint);
1457
+ const retrospectiveGate = evaluateRetrospectiveMergeApproval(retrospectiveCheckpoint, { developerMode: requireRetrospectiveInternalTooling });
1353
1458
  if (!retrospectiveGate.approved) {
1354
1459
  return buildRetrospectiveGatePendingResult({
1355
1460
  input,
@@ -1509,7 +1614,7 @@ function evaluatePrGateCoordinationCore(input = {}) {
1509
1614
  });
1510
1615
  }
1511
1616
  if (requireRetrospectiveGate) {
1512
- const retrospectiveGate = evaluateRetrospectiveMergeApproval(retrospectiveCheckpoint);
1617
+ const retrospectiveGate = evaluateRetrospectiveMergeApproval(retrospectiveCheckpoint, { developerMode: requireRetrospectiveInternalTooling });
1513
1618
  if (!retrospectiveGate.approved) {
1514
1619
  return buildRetrospectiveGatePendingResult({
1515
1620
  input,
@@ -0,0 +1,138 @@
1
+ /**
2
+ * Spike-mode exit (P2 of the spike-mode track #965).
3
+ *
4
+ * Phase 1 (#964) shipped the spike intake state machine
5
+ * (`evaluateSpikeIntakeState` → SPIKE_INTAKE_STATE). A spike becomes exitable
6
+ * only once it carries a Recommendation (`spike_ready_for_exit`). This module is
7
+ * the exit decision: from that ready state, the operator picks a disposition —
8
+ * - DISCARD — the recommendation is "don't pursue"; drop the spike with ZERO
9
+ * tracker artifacts (the findings doc is the whole record).
10
+ * - GRADUATE — promote the exploration into a #947-consumable plan file
11
+ * (Status/Objective/In scope/Explicit non-goals) built from the
12
+ * spike's Question/Approach/Findings/Recommendation, which then
13
+ * enters the existing local-first plan→PR promotion path (#952).
14
+ *
15
+ * Pure: no fs/network/gh, no `scripts/` import (mirrors spike-intake-contract
16
+ * and plan-file-promote-contract). The CLI (`scripts/refine/exit-spike.mjs`)
17
+ * owns all I/O. Fail-closed on an unknown disposition or a non-ready state so
18
+ * the CLI makes zero mutation on those paths.
19
+ */
20
+
21
+ import { SPIKE_INTAKE_STATE } from "./spike-intake-contract.mjs";
22
+
23
+ /** Dispositions the operator can choose at a ready spike's exit. */
24
+ export const SPIKE_EXIT_DISPOSITION = Object.freeze({
25
+ /** Drop the spike with no tracker artifact (recommendation: don't pursue). */
26
+ DISCARD: "discard",
27
+ /** Promote into a plan file consumable by the #947 local-first flow. */
28
+ GRADUATE: "graduate",
29
+ });
30
+
31
+ /** Actions the exit decision can return (1:1 with the eligible dispositions). */
32
+ export const SPIKE_EXIT_ACTION = Object.freeze({
33
+ DISCARD: "discard",
34
+ GRADUATE: "graduate",
35
+ });
36
+
37
+ const DISPOSITION_TO_ACTION = Object.freeze({
38
+ [SPIKE_EXIT_DISPOSITION.DISCARD]: SPIKE_EXIT_ACTION.DISCARD,
39
+ [SPIKE_EXIT_DISPOSITION.GRADUATE]: SPIKE_EXIT_ACTION.GRADUATE,
40
+ });
41
+
42
+ /**
43
+ * Pure exit-eligibility decision.
44
+ *
45
+ * Eligible ONLY from `spike_ready_for_exit` (a Recommendation has been reached).
46
+ * Any other state — in-progress or ambiguous — fails closed with
47
+ * `not_ready_for_exit` and no action; the CLI must make zero tracker mutation.
48
+ * An unrecognized disposition fails closed with `unknown_disposition`.
49
+ *
50
+ * @param {object} facts
51
+ * @param {string} facts.spikeIntakeState one of SPIKE_INTAKE_STATE values (from evaluateSpikeIntakeState)
52
+ * @param {string} facts.disposition one of SPIKE_EXIT_DISPOSITION values
53
+ * @returns {{ ok: boolean, action?: string, reason?: string, spikeIntakeState?: string | null }}
54
+ */
55
+ export function evaluateSpikeExit({ spikeIntakeState, disposition } = {}) {
56
+ // The ready gate: an exit decision is only meaningful once a recommendation
57
+ // exists. Fail closed otherwise — never guess an exit for an in-progress or
58
+ // malformed spike.
59
+ if (spikeIntakeState !== SPIKE_INTAKE_STATE.SPIKE_READY_FOR_EXIT) {
60
+ return { ok: false, reason: "not_ready_for_exit", spikeIntakeState: spikeIntakeState ?? null };
61
+ }
62
+
63
+ // Own-property check: a bare index lookup would also match inherited
64
+ // Object.prototype keys (`toString`, `__proto__`, `constructor`), letting an
65
+ // unknown disposition resolve to a truthy value and bypass the fail-closed
66
+ // contract. Require a string that is an own key of the map.
67
+ if (typeof disposition !== "string" || !Object.hasOwn(DISPOSITION_TO_ACTION, disposition)) {
68
+ return { ok: false, reason: "unknown_disposition", spikeIntakeState };
69
+ }
70
+ const action = DISPOSITION_TO_ACTION[disposition];
71
+
72
+ return { ok: true, action, spikeIntakeState };
73
+ }
74
+
75
+ /**
76
+ * Build a #947-consumable plan-file body from a ready spike's sections.
77
+ *
78
+ * The emitted body carries the four base authoring sections the plan-file
79
+ * format requires (Status / Objective / In scope / Explicit non-goals — see
80
+ * scripts/refine/validate-plan-file.mjs), so it passes `validatePlanFile` and
81
+ * enters the existing local-first plan→PR promotion path (#952) unchanged.
82
+ *
83
+ * The spike sections map onto the plan as: the Question + Approach become the
84
+ * Objective's context, the Recommendation becomes the In-scope work, the
85
+ * Findings record the evidence, and a fixed non-goal keeps the plan from
86
+ * re-opening the (now-concluded) exploration. Status starts as Draft.
87
+ *
88
+ * Idempotent and pure: same input → identical output, no side effects. Fails
89
+ * closed (throws) on an empty required section so a graduate exit cannot emit a
90
+ * plan that the validator would reject.
91
+ *
92
+ * @param {object} sections
93
+ * @param {string} sections.question
94
+ * @param {string} sections.approach
95
+ * @param {string} sections.findings
96
+ * @param {string} sections.recommendation
97
+ * @returns {string} markdown plan-file body
98
+ */
99
+ export function buildGraduatedPlanBody({ question, approach, findings, recommendation } = {}) {
100
+ const q = String(question ?? "").trim();
101
+ const a = String(approach ?? "").trim();
102
+ const f = String(findings ?? "").trim();
103
+ const r = String(recommendation ?? "").trim();
104
+ if (q.length === 0) throw new Error("buildGraduatedPlanBody requires a non-empty question");
105
+ if (a.length === 0) throw new Error("buildGraduatedPlanBody requires a non-empty approach");
106
+ if (f.length === 0) throw new Error("buildGraduatedPlanBody requires non-empty findings");
107
+ if (r.length === 0) throw new Error("buildGraduatedPlanBody requires a non-empty recommendation");
108
+
109
+ return [
110
+ "# Graduated spike plan",
111
+ "",
112
+ "## Status",
113
+ "",
114
+ "Draft (graduated from a spike). Needs refinement before promotion.",
115
+ "",
116
+ "## Objective",
117
+ "",
118
+ `Act on the spike's recommendation. The spike asked: ${q}`,
119
+ "",
120
+ "Approach explored:",
121
+ "",
122
+ a,
123
+ "",
124
+ "## In scope",
125
+ "",
126
+ r,
127
+ "",
128
+ "Supporting findings from the spike:",
129
+ "",
130
+ f,
131
+ "",
132
+ "## Explicit non-goals",
133
+ "",
134
+ "- Re-running the spike's exploration; that question is concluded.",
135
+ "- Work beyond the recommendation above.",
136
+ "",
137
+ ].join("\n");
138
+ }
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Spike-mode intake state machine.
3
+ *
4
+ * A `--spike` startup hands the dev-loop a time-boxed, exploratory artifact that
5
+ * lives outside the tracker — startable from a local question with no GitHub
6
+ * issue. A spike is NOT a plan-needing-refinement, so its states are distinct
7
+ * from PLAN_FILE_INTAKE_STATE: it classifies how far the exploration has
8
+ * progressed toward an exit decision, not how far a plan has progressed toward
9
+ * promotion.
10
+ *
11
+ * This evaluator mirrors `evaluatePlanFileIntakeState`: a frozen enum plus a
12
+ * pure, deterministic function with no GitHub or filesystem side effects — the
13
+ * caller supplies the section-presence facts it has already read.
14
+ *
15
+ * The two non-ambiguous states are the seam phase 2 (#965) consumes for its
16
+ * discard/graduate exits:
17
+ * - SPIKE_IN_PROGRESS — exploration ongoing (no recommendation yet); a
18
+ * discard exit can drop it with no artifact.
19
+ * - SPIKE_READY_FOR_EXIT — a recommendation has been reached; phase 2 routes
20
+ * this to graduate (promote into a plan/PR) or
21
+ * discard (recommendation is "don't pursue").
22
+ */
23
+
24
+ export const SPIKE_INTAKE_STATE = Object.freeze({
25
+ /** Valid spike artifact; the Recommendation is not yet reached. */
26
+ SPIKE_IN_PROGRESS: "spike_in_progress",
27
+ /** Valid spike artifact carrying a Recommendation; an exit decision can be made. */
28
+ SPIKE_READY_FOR_EXIT: "spike_ready_for_exit",
29
+ /** Inputs are malformed or unusable; fail closed. */
30
+ AMBIGUOUS_FAIL_CLOSED: "ambiguous_fail_closed",
31
+ });
32
+
33
+ /**
34
+ * Pure intake-state classifier.
35
+ *
36
+ * @param {object} facts
37
+ * @param {boolean} facts.baseSectionsValid whether the spike's exploration scaffold (Question/Approach/Findings) is present and non-empty. Recommendation is NOT part of this fact — it is the separate exit-marker carried by `hasRecommendation`, so that a scaffold-valid spike without a Recommendation classifies as in-progress rather than failing closed.
38
+ * @param {boolean} facts.hasRecommendation whether a non-empty Recommendation section is present (the exit-marker that flips in-progress → ready-for-exit)
39
+ * @returns {{ state: string }} one of SPIKE_INTAKE_STATE values
40
+ */
41
+ export function evaluateSpikeIntakeState({ baseSectionsValid, hasRecommendation } = {}) {
42
+ // A malformed spike artifact (missing/empty base sections) is unusable intake
43
+ // input; fail closed instead of guessing an exit.
44
+ if (baseSectionsValid !== true) {
45
+ return { state: SPIKE_INTAKE_STATE.AMBIGUOUS_FAIL_CLOSED };
46
+ }
47
+ return {
48
+ state: hasRecommendation === true
49
+ ? SPIKE_INTAKE_STATE.SPIKE_READY_FOR_EXIT
50
+ : SPIKE_INTAKE_STATE.SPIKE_IN_PROGRESS,
51
+ };
52
+ }