@dev-loops/core 0.1.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.
Files changed (54) hide show
  1. package/bin/capture-deep-persona-signals.mjs +143 -0
  2. package/bin/ensure-phase-files.mjs +7 -0
  3. package/bin/log-bash-exit-1.mjs +7 -0
  4. package/bin/parse-review-threads.mjs +7 -0
  5. package/package.json +78 -0
  6. package/src/analysis/change-classifier.mjs +146 -0
  7. package/src/analysis/diff-analyzer.mjs +285 -0
  8. package/src/bash-exit-one.mjs +130 -0
  9. package/src/cli/helpers.mjs +22 -0
  10. package/src/cli/primitives.mjs +70 -0
  11. package/src/cli/retry-wrapper.mjs +169 -0
  12. package/src/cli/subcommand-runner.mjs +246 -0
  13. package/src/config/config.mjs +965 -0
  14. package/src/debt/cluster.mjs +240 -0
  15. package/src/debt/debt-finding.mjs +68 -0
  16. package/src/debt/debt-signal.mjs +46 -0
  17. package/src/debt/deep-persona-signals.mjs +266 -0
  18. package/src/debt/remediation-to-issue.mjs +121 -0
  19. package/src/debt/score.mjs +127 -0
  20. package/src/debt/shape.mjs +214 -0
  21. package/src/github/copilot-helpers.mjs +343 -0
  22. package/src/github/repo-slug.mjs +105 -0
  23. package/src/github/review-threads.mjs +343 -0
  24. package/src/harness/adapter.mjs +57 -0
  25. package/src/harness/index.mjs +3 -0
  26. package/src/harness/noop-adapter.mjs +22 -0
  27. package/src/harness/pi-adapter.mjs +47 -0
  28. package/src/loop/async-start-contract.mjs +170 -0
  29. package/src/loop/conductor-routing.mjs +817 -0
  30. package/src/loop/copilot-ci-status.mjs +255 -0
  31. package/src/loop/copilot-loop-iterations.mjs +161 -0
  32. package/src/loop/copilot-loop-state.mjs +510 -0
  33. package/src/loop/handoff-envelope.mjs +800 -0
  34. package/src/loop/issue-refinement-artifact.mjs +268 -0
  35. package/src/loop/lifecycle-state.mjs +342 -0
  36. package/src/loop/phase-files.mjs +187 -0
  37. package/src/loop/policy-constants.mjs +17 -0
  38. package/src/loop/pr-gate-coordination.mjs +1278 -0
  39. package/src/loop/public-dev-loop-routing-contract.mjs +277 -0
  40. package/src/loop/public-dev-loop-routing.mjs +1746 -0
  41. package/src/loop/queue-board-ordering.mjs +38 -0
  42. package/src/loop/queue-board-sync.mjs +223 -0
  43. package/src/loop/queue-driver.mjs +164 -0
  44. package/src/loop/queue-parallel.mjs +190 -0
  45. package/src/loop/queue-state.mjs +230 -0
  46. package/src/loop/retrospective-checkpoint.mjs +178 -0
  47. package/src/loop/reviewer-loop-state.mjs +456 -0
  48. package/src/loop/run-inspection.mjs +604 -0
  49. package/src/loop/steering.mjs +793 -0
  50. package/src/loop/timeout-policy.mjs +73 -0
  51. package/src/loop/tracker-first-loop-state.mjs +87 -0
  52. package/src/loop/tracker-pr-state.mjs +301 -0
  53. package/src/loop/worktree-guard.mjs +141 -0
  54. package/src/refinement/ac-dod-matrix.mjs +95 -0
@@ -0,0 +1,268 @@
1
+ /**
2
+ * Deterministic issue refinement-artifact detection.
3
+ *
4
+ * Implements the bounded refinement check required by the draft gate per
5
+ * issue #532: a draft PR cannot leave draft unless the linked issue has an
6
+ * explicit refinement artifact (Acceptance criteria section, DoD section,
7
+ * or a linked refinement doc) that the pre-approval gate can verify
8
+ * against. Prose-only issues (Problem / Root Cause / Fix) without an
9
+ * `Acceptance criteria` or `DoD` section cause the draft gate to post
10
+ * `verdict=blocked` with the `missing_refinement_artifact` finding.
11
+ *
12
+ * This module owns:
13
+ * - canonical section-name matching for AC / DoD blocks
14
+ * - bullet-item extraction (both `- [ ]` and `- [x]`)
15
+ * - linked-refinement-doc detection from issue body
16
+ *
17
+ * It deliberately does NOT:
18
+ * - auto-generate ACs from prose
19
+ * - mutate GitHub state
20
+ * - re-implement the issue<->PR linkage detection (callers own that)
21
+ */
22
+
23
+ export const REFINEMENT_SOURCE = Object.freeze({
24
+ ISSUE_BODY_AC: "issue-body-ac",
25
+ ISSUE_BODY_DOD: "issue-body-dod",
26
+ LINKED_DOC: "linked-doc",
27
+ MISSING: "missing",
28
+ });
29
+
30
+ const REFINEMENT_ARTIFACT_FINDING = "missing_refinement_artifact";
31
+
32
+ /**
33
+ * Canonical list of section headings that satisfy the refinement check.
34
+ * Matching is case-insensitive and tolerates trailing/leading whitespace.
35
+ * The two-element minimum keeps the contract explicit:
36
+ * - one AC section (Acceptance criteria)
37
+ * - one DoD-style section (DoD or Definition of Done)
38
+ */
39
+ const ACCEPTANCE_SECTION_PATTERNS = Object.freeze([
40
+ /^acceptance criteria\s*$/i,
41
+ /^ac\b.*$/i,
42
+ ]);
43
+
44
+ const DOD_SECTION_PATTERNS = Object.freeze([
45
+ /^definition of done\s*$/i,
46
+ /^done\s*$/i,
47
+ /^dod\s*$/i,
48
+ ]);
49
+
50
+ /**
51
+ * Extract `## ...` heading boundaries from a Markdown body.
52
+ * Returns a sorted array of { level, name, bodyLines } records.
53
+ */
54
+ export function parseMarkdownSections(body) {
55
+ if (typeof body !== "string" || body.length === 0) {
56
+ return [];
57
+ }
58
+
59
+ const lines = body.split(/\r?\n/u);
60
+ const sections = [];
61
+ let current = null;
62
+
63
+ for (const line of lines) {
64
+ const match = /^(#{1,6})\s+(.+?)\s*$/u.exec(line);
65
+ if (match) {
66
+ if (current) {
67
+ sections.push(current);
68
+ }
69
+ current = {
70
+ level: match[1].length,
71
+ name: match[2],
72
+ bodyLines: [],
73
+ };
74
+ continue;
75
+ }
76
+ if (current) {
77
+ current.bodyLines.push(line);
78
+ }
79
+ }
80
+
81
+ if (current) {
82
+ sections.push(current);
83
+ }
84
+
85
+ return sections;
86
+ }
87
+
88
+ function findSectionByPatterns(sections, patterns) {
89
+ for (const section of sections) {
90
+ for (const pattern of patterns) {
91
+ if (pattern.test(section.name)) {
92
+ return section;
93
+ }
94
+ }
95
+ }
96
+ return null;
97
+ }
98
+
99
+ /**
100
+ * Extract checklist bullet items (`- [ ]` and `- [x]`) from a section body.
101
+ * Returns the trimmed item text for each matching line. The checkbox state
102
+ * (checked vs unchecked) is intentionally not preserved: callers only need
103
+ * the item text to satisfy the refinement-artifact contract.
104
+ */
105
+ export function extractChecklistItems(sectionBody) {
106
+ if (typeof sectionBody !== "string" || sectionBody.length === 0) {
107
+ return [];
108
+ }
109
+
110
+ const items = [];
111
+ const lines = sectionBody.split(/\r?\n/u);
112
+
113
+ for (const line of lines) {
114
+ const match = /^\s*-\s+\[(?:[ xX])\]\s+(.+?)\s*$/u.exec(line);
115
+ if (match) {
116
+ const text = match[1].trim();
117
+ if (text.length > 0) {
118
+ items.push(text);
119
+ }
120
+ }
121
+ }
122
+
123
+ return items;
124
+ }
125
+
126
+ /**
127
+ * Detect a linked refinement doc path from the issue body.
128
+ * Looks for explicit `tmp/refinement/<n>-plan.md` style paths and the
129
+ * `## Refinement` / `## Plan` / `## Refinement doc` sections.
130
+ */
131
+ export function detectLinkedRefinementDoc(body) {
132
+ if (typeof body !== "string" || body.length === 0) {
133
+ return { found: false, path: null, reason: "empty-body" };
134
+ }
135
+
136
+ const pathMatch = /(?:^|\s|[`(\[<])(tmp\/refinement\/[A-Za-z0-9._/\-]+\.md)\b/u.exec(body);
137
+ if (pathMatch) {
138
+ return { found: true, path: pathMatch[1], reason: "explicit-path" };
139
+ }
140
+
141
+ const sections = parseMarkdownSections(body);
142
+ const refinementSection = findSectionByPatterns(sections, [
143
+ /^refinement doc\s*$/i,
144
+ /^refinement\s*$/i,
145
+ /^plan doc\s*$/i,
146
+ /^plan\s*$/i,
147
+ ]);
148
+ if (refinementSection) {
149
+ const inlinePath = /(?:^|\s)(tmp\/refinement\/[^\s)`'"]+\.md)\b/u.exec(refinementSection.bodyLines.join("\n"));
150
+ if (inlinePath) {
151
+ return { found: true, path: inlinePath[1], reason: "refinement-section-path" };
152
+ }
153
+ }
154
+
155
+ return { found: false, path: null, reason: "no-linked-doc" };
156
+ }
157
+
158
+ /**
159
+ * Detect the refinement artifact on a parsed issue body.
160
+ *
161
+ * @param {object} input
162
+ * @param {string} [input.body] Raw issue body Markdown.
163
+ * @param {number} [input.issueNumber] Issue number, used for linked-doc convention.
164
+ * @returns {{
165
+ * hasACs: boolean,
166
+ * source: string,
167
+ * acItems: string[],
168
+ * dodItems: string[],
169
+ * sections: string[],
170
+ * linkedDoc: { found: boolean, path: string|null, reason: string },
171
+ * reason: string,
172
+ * finding: string|null,
173
+ * }}
174
+ */
175
+ export function detectIssueRefinementArtifact({ body = "", issueNumber = null } = {}) {
176
+ if (typeof body !== "string" || body.length === 0) {
177
+ return {
178
+ hasACs: false,
179
+ source: REFINEMENT_SOURCE.MISSING,
180
+ acItems: [],
181
+ dodItems: [],
182
+ sections: [],
183
+ linkedDoc: { found: false, path: null, reason: "empty-body" },
184
+ reason: "Issue body is empty; no ACs/DoD/linked-doc can be detected.",
185
+ finding: REFINEMENT_ARTIFACT_FINDING,
186
+ };
187
+ }
188
+
189
+ const sections = parseMarkdownSections(body);
190
+ const sectionNames = sections.map((s) => s.name);
191
+
192
+ const acceptanceSection = findSectionByPatterns(sections, ACCEPTANCE_SECTION_PATTERNS);
193
+ const dodSection = findSectionByPatterns(sections, DOD_SECTION_PATTERNS);
194
+
195
+ const acItems = acceptanceSection ? extractChecklistItems(acceptanceSection.bodyLines.join("\n")) : [];
196
+ const dodItems = dodSection ? extractChecklistItems(dodSection.bodyLines.join("\n")) : [];
197
+
198
+ const linkedDoc = detectLinkedRefinementDoc(body);
199
+
200
+ if (acItems.length > 0) {
201
+ return {
202
+ hasACs: true,
203
+ source: REFINEMENT_SOURCE.ISSUE_BODY_AC,
204
+ acItems,
205
+ dodItems,
206
+ sections: sectionNames,
207
+ linkedDoc,
208
+ reason: `Found ${acItems.length} Acceptance criteria checklist item(s) in the issue body.`,
209
+ finding: null,
210
+ };
211
+ }
212
+
213
+ if (dodItems.length > 0) {
214
+ return {
215
+ hasACs: true,
216
+ source: REFINEMENT_SOURCE.ISSUE_BODY_DOD,
217
+ acItems,
218
+ dodItems,
219
+ sections: sectionNames,
220
+ linkedDoc,
221
+ reason: `Found ${dodItems.length} DoD checklist item(s) in the issue body.`,
222
+ finding: null,
223
+ };
224
+ }
225
+
226
+ if (linkedDoc.found) {
227
+ return {
228
+ hasACs: true,
229
+ source: REFINEMENT_SOURCE.LINKED_DOC,
230
+ acItems: [],
231
+ dodItems: [],
232
+ sections: sectionNames,
233
+ linkedDoc,
234
+ reason: `Issue body links a refinement doc at ${linkedDoc.path}; treating that as the refinement artifact source.`,
235
+ finding: null,
236
+ };
237
+ }
238
+
239
+ return {
240
+ hasACs: false,
241
+ source: REFINEMENT_SOURCE.MISSING,
242
+ acItems: [],
243
+ dodItems: [],
244
+ sections: sectionNames,
245
+ linkedDoc,
246
+ reason: "Issue body has no Acceptance criteria section, no DoD section, and no linked refinement doc.",
247
+ finding: REFINEMENT_ARTIFACT_FINDING,
248
+ };
249
+ }
250
+
251
+ /**
252
+ * Map a draft-gate refinement check to the result surface consumed by
253
+ * `evaluatePrGateCoordination`. The mapping keeps the contract
254
+ * deterministic: the draft gate must not produce a `clean` verdict
255
+ * for the current head when the refinement check is `missing`.
256
+ */
257
+ export function summarizeRefinementGateCheck({ body = "", issueNumber = null } = {}) {
258
+ const artifact = detectIssueRefinementArtifact({ body, issueNumber });
259
+ const verdict = artifact.hasACs ? "clean" : "blocked";
260
+ const finding = artifact.finding;
261
+ return {
262
+ artifact,
263
+ verdict,
264
+ finding,
265
+ blocking: !artifact.hasACs,
266
+ reason: artifact.reason,
267
+ };
268
+ }
@@ -0,0 +1,342 @@
1
+ /**
2
+ * Deterministic outer dev-loop lifecycle state model.
3
+ *
4
+ * This module defines the sequential lifecycle phases — issue_intake →
5
+ * refinement → implementation → draft_gate → feedback_resolution →
6
+ * pre_approval_gate → merge — as a consultable graph so skills use
7
+ * machine-resolved state instead of restating the flow in prose.
8
+ *
9
+ * This module provides:
10
+ * - LIFECYCLE_STATE: stable phase name constants
11
+ * - LIFECYCLE_TRANSITIONS: legal transition graph between phases
12
+ * - LIFECYCLE_GRAPH: metadata (start, end, entry, terminal, nonterminal)
13
+ * - LIFECYCLE_NEXT_ACTIONS: recommended next action for each phase
14
+ * - resolveLifecycleState: resolver that maps inputs to one lifecycle phase
15
+ * - getAllowedTransitions: helper to list allowed next phases
16
+ * - COPILOT_INNER_STATE_MAP: maps lifecycle phases to copilot-loop-state.mjs inner states
17
+ *
18
+ * Contract guarantees:
19
+ * - One deterministic lifecycle phase per normalized input set
20
+ * - Ambiguous or incomplete inputs fall back to issue_intake
21
+ * - Transition graph enforces legal phase progression
22
+ * - Purely functional; no I/O or side effects
23
+ *
24
+ * Integration boundary:
25
+ * - Skills call resolveLifecycleState to determine current phase
26
+ * - Copilot-loop-state.mjs remains the inner machine for the Copilot review portion
27
+ * - Lifecycle phases are the outer sequence; inner states are sub-phase detail
28
+ */
29
+
30
+ // ---------------------------------------------------------------------------
31
+ // Exported constants
32
+ // ---------------------------------------------------------------------------
33
+
34
+ /**
35
+ * Stable lifecycle phase name constants.
36
+ *
37
+ * These are the sequential outer dev-loop phases from issue intake to merge.
38
+ */
39
+ export const LIFECYCLE_STATE = Object.freeze({
40
+ /** Issue normalization, scope confirmation, PR linkage detection. */
41
+ ISSUE_INTAKE: "issue_intake",
42
+ /** Issue refinement: spec elaboration, audit, acceptance criteria hardening. */
43
+ REFINEMENT: "refinement",
44
+ /** Active code implementation (local or Copilot-assisted). */
45
+ IMPLEMENTATION: "implementation",
46
+ /** Draft gate review before marking PR ready for review. */
47
+ DRAFT_GATE: "draft_gate",
48
+ /** Review feedback fix/reply/resolve loop. */
49
+ FEEDBACK_RESOLUTION: "feedback_resolution",
50
+ /** Pre-approval gate before merge. */
51
+ PRE_APPROVAL_GATE: "pre_approval_gate",
52
+ /** Final merge step. */
53
+ MERGE: "merge",
54
+ });
55
+
56
+ const LIFECYCLE_STATE_VALUES = Object.freeze(Object.values(LIFECYCLE_STATE));
57
+ const LIFECYCLE_STATE_SET = new Set(LIFECYCLE_STATE_VALUES);
58
+
59
+ /**
60
+ * Legal transitions between lifecycle phases.
61
+ *
62
+ * Each entry lists the phases reachable from the given phase.
63
+ * Terminal states (merge) have no outgoing transitions.
64
+ */
65
+ export const LIFECYCLE_TRANSITIONS = Object.freeze({
66
+ [LIFECYCLE_STATE.ISSUE_INTAKE]: Object.freeze([
67
+ LIFECYCLE_STATE.REFINEMENT,
68
+ LIFECYCLE_STATE.IMPLEMENTATION,
69
+ ]),
70
+ [LIFECYCLE_STATE.REFINEMENT]: Object.freeze([
71
+ LIFECYCLE_STATE.ISSUE_INTAKE,
72
+ LIFECYCLE_STATE.IMPLEMENTATION,
73
+ ]),
74
+ [LIFECYCLE_STATE.IMPLEMENTATION]: Object.freeze([
75
+ LIFECYCLE_STATE.DRAFT_GATE,
76
+ LIFECYCLE_STATE.FEEDBACK_RESOLUTION,
77
+ ]),
78
+ [LIFECYCLE_STATE.DRAFT_GATE]: Object.freeze([
79
+ LIFECYCLE_STATE.IMPLEMENTATION,
80
+ LIFECYCLE_STATE.FEEDBACK_RESOLUTION,
81
+ ]),
82
+ [LIFECYCLE_STATE.FEEDBACK_RESOLUTION]: Object.freeze([
83
+ LIFECYCLE_STATE.IMPLEMENTATION,
84
+ LIFECYCLE_STATE.PRE_APPROVAL_GATE,
85
+ ]),
86
+ [LIFECYCLE_STATE.PRE_APPROVAL_GATE]: Object.freeze([
87
+ LIFECYCLE_STATE.IMPLEMENTATION,
88
+ LIFECYCLE_STATE.FEEDBACK_RESOLUTION,
89
+ LIFECYCLE_STATE.MERGE,
90
+ ]),
91
+ [LIFECYCLE_STATE.MERGE]: Object.freeze([]),
92
+ });
93
+
94
+ /** Terminal lifecycle phases — no further progression. */
95
+ export const LIFECYCLE_TERMINAL_STATES = Object.freeze([
96
+ LIFECYCLE_STATE.MERGE,
97
+ ]);
98
+
99
+ /** Nonterminal lifecycle phases — progression still possible. */
100
+ export const LIFECYCLE_NONTERMINAL_STATES = Object.freeze([
101
+ LIFECYCLE_STATE.ISSUE_INTAKE,
102
+ LIFECYCLE_STATE.REFINEMENT,
103
+ LIFECYCLE_STATE.IMPLEMENTATION,
104
+ LIFECYCLE_STATE.DRAFT_GATE,
105
+ LIFECYCLE_STATE.FEEDBACK_RESOLUTION,
106
+ LIFECYCLE_STATE.PRE_APPROVAL_GATE,
107
+ ]);
108
+
109
+ const LIFECYCLE_TERMINAL_SET = new Set(LIFECYCLE_TERMINAL_STATES);
110
+
111
+ /** High-level graph metadata for visualization and inspection. */
112
+ export const LIFECYCLE_GRAPH = Object.freeze({
113
+ start: Object.freeze({ id: "lifecycle_start", label: "Start", semantic: true }),
114
+ end: Object.freeze({ id: "lifecycle_end", label: "End", semantic: true }),
115
+ entryState: LIFECYCLE_STATE.ISSUE_INTAKE,
116
+ entryStates: Object.freeze([...LIFECYCLE_STATE_VALUES]),
117
+ terminalStates: LIFECYCLE_TERMINAL_STATES,
118
+ nonterminalStates: LIFECYCLE_NONTERMINAL_STATES,
119
+ });
120
+
121
+ /** Recommended next action for each lifecycle phase. */
122
+ export const LIFECYCLE_NEXT_ACTIONS = Object.freeze({
123
+ [LIFECYCLE_STATE.ISSUE_INTAKE]:
124
+ "Normalize the issue: confirm scope, detect linked PR, and determine readiness.",
125
+ [LIFECYCLE_STATE.REFINEMENT]:
126
+ "Refine the issue: elaborate spec, run bounded audit if needed, harden acceptance criteria.",
127
+ [LIFECYCLE_STATE.IMPLEMENTATION]:
128
+ "Implement the accepted scope on a feature branch or via Copilot handoff.",
129
+ [LIFECYCLE_STATE.DRAFT_GATE]:
130
+ "Run draft gate review before marking the PR ready for review.",
131
+ [LIFECYCLE_STATE.FEEDBACK_RESOLUTION]:
132
+ "Address review feedback: fix, reply to, and resolve threads on GitHub.",
133
+ [LIFECYCLE_STATE.PRE_APPROVAL_GATE]:
134
+ "Run pre-approval gate review; verify gate evidence, CI, and unresolved threads.",
135
+ [LIFECYCLE_STATE.MERGE]:
136
+ "Merge is authorized; run the final merge step and write the retrospective checkpoint.",
137
+ });
138
+
139
+ // ---------------------------------------------------------------------------
140
+ // Resolver
141
+ // ---------------------------------------------------------------------------
142
+
143
+ /**
144
+ * Map an explicit lifecycle phase string to its canonical state value.
145
+ * Returns the canonical state if recognized, otherwise null.
146
+ */
147
+ function normalizeLifecycleState(value) {
148
+ if (typeof value !== "string") return null;
149
+ const trimmed = value.trim().toLowerCase();
150
+ return LIFECYCLE_STATE_SET.has(trimmed) ? trimmed : null;
151
+ }
152
+
153
+ /**
154
+ * Resolve the current lifecycle phase from authoritative inputs.
155
+ *
156
+ * Input shape:
157
+ * ```js
158
+ * {
159
+ * phase, // explicit phase string (overrides infer)
160
+ * hasLinkedPr, // boolean: open linked PR exists
161
+ * prIsDraft, // boolean: PR is in draft state
162
+ * hasUnresolvedThreads, // boolean: unresolved review threads exist
163
+ * preApprovalGatePassed, // boolean: current-head pre_approval_gate clean
164
+ * mergeAuthorized, // boolean: explicit merge authorization granted
165
+ * isMerged, // boolean: PR has been merged
166
+ * }
167
+ * ```
168
+ *
169
+ * Returns:
170
+ * ```js
171
+ * {
172
+ * state: string, // canonical lifecycle phase
173
+ * allowedTransitions: string[], // legal next phases
174
+ * nextAction: string, // recommended next action
175
+ * isTerminal: boolean, // true if merge (no further progression)
176
+ * }
177
+ * ```
178
+ *
179
+ * Resolution order (first-match):
180
+ * 1. Explicit phase → return canonical if recognized, fall through if not
181
+ * 2. Merged → merge (terminal)
182
+ * 3. Merge authorized + pre-approval passed + linked PR → merge
183
+ * 4. Pre-approval passed + PR exists → pre_approval_gate
184
+ * 5. Unresolved threads + PR exists → feedback_resolution
185
+ * 6. Draft PR → implementation
186
+ * 7. Ready PR (not draft) → implementation
187
+ * 8. No linked PR → issue_intake
188
+ */
189
+ export function resolveLifecycleState(input = {}) {
190
+ const {
191
+ phase = null,
192
+ hasLinkedPr = false,
193
+ prIsDraft = false,
194
+ hasUnresolvedThreads = false,
195
+ preApprovalGatePassed = false,
196
+ mergeAuthorized = false,
197
+ isMerged = false,
198
+ } = input;
199
+
200
+ // 1. Explicit phase override — canonical or fail closed
201
+ if (phase !== null && phase !== undefined) {
202
+ const normalized = normalizeLifecycleState(phase);
203
+ if (normalized) {
204
+ return buildResult(normalized);
205
+ }
206
+ // unrecognized phase: fall through to inference
207
+ }
208
+
209
+ // 2. Merged → terminal
210
+ if (isMerged) {
211
+ return buildResult(LIFECYCLE_STATE.MERGE);
212
+ }
213
+
214
+ // 3. Merge authorized with pre-approval + linked PR → merge
215
+ if (mergeAuthorized && preApprovalGatePassed && hasLinkedPr) {
216
+ return buildResult(LIFECYCLE_STATE.MERGE);
217
+ }
218
+
219
+ // 4. Pre-approval gate passed (but merge not yet authorized)
220
+ if (preApprovalGatePassed && hasLinkedPr) {
221
+ return buildResult(LIFECYCLE_STATE.PRE_APPROVAL_GATE);
222
+ }
223
+
224
+ // 5. Unresolved threads exist → feedback resolution
225
+ if (hasUnresolvedThreads && hasLinkedPr) {
226
+ return buildResult(LIFECYCLE_STATE.FEEDBACK_RESOLUTION);
227
+ }
228
+
229
+ // 6. Draft PR → implementation
230
+ if (prIsDraft && hasLinkedPr) {
231
+ return buildResult(LIFECYCLE_STATE.IMPLEMENTATION);
232
+ }
233
+
234
+ // 7. PR exists (not draft) → implementation
235
+ if (hasLinkedPr && !prIsDraft) {
236
+ return buildResult(LIFECYCLE_STATE.IMPLEMENTATION);
237
+ }
238
+
239
+ // 8. No linked PR → issue intake
240
+ return buildResult(LIFECYCLE_STATE.ISSUE_INTAKE);
241
+ }
242
+
243
+ function buildResult(state) {
244
+ const transitions = LIFECYCLE_TRANSITIONS[state] ?? [];
245
+ return {
246
+ state,
247
+ allowedTransitions: [...transitions],
248
+ nextAction: LIFECYCLE_NEXT_ACTIONS[state] ?? "",
249
+ isTerminal: LIFECYCLE_TERMINAL_SET.has(state),
250
+ };
251
+ }
252
+
253
+ // ---------------------------------------------------------------------------
254
+ // Transition helpers
255
+ // ---------------------------------------------------------------------------
256
+
257
+ /**
258
+ * Return the allowed next phases for a given lifecycle state.
259
+ * Unknown states return an empty array.
260
+ */
261
+ export function getAllowedTransitions(state) {
262
+ if (!LIFECYCLE_STATE_SET.has(state)) return [];
263
+ return [...(LIFECYCLE_TRANSITIONS[state] ?? [])];
264
+ }
265
+
266
+ /**
267
+ * Check whether a transition from one phase to another is legal.
268
+ */
269
+ export function isTransitionAllowed(fromState, toState) {
270
+ if (!LIFECYCLE_STATE_SET.has(fromState) || !LIFECYCLE_STATE_SET.has(toState)) {
271
+ return false;
272
+ }
273
+ return LIFECYCLE_TRANSITIONS[fromState].includes(toState);
274
+ }
275
+
276
+ /**
277
+ * Check whether a value is a recognized lifecycle state.
278
+ */
279
+ export function isKnownLifecycleState(value) {
280
+ return LIFECYCLE_STATE_SET.has(value);
281
+ }
282
+
283
+ // ---------------------------------------------------------------------------
284
+ // Connection to copilot-loop-state.mjs inner machine
285
+ // ---------------------------------------------------------------------------
286
+
287
+ /**
288
+ * Map from outer lifecycle phases to copilot-loop-state.mjs inner machine states.
289
+ *
290
+ * Skills use this mapping to determine which inner-machine states are active
291
+ * during a given lifecycle phase. Not all lifecycle phases have a corresponding
292
+ * inner-machine state (issue_intake and refinement are outer-only).
293
+ *
294
+ * The inner machine is the authority for Copilot review/fix loop states;
295
+ * this mapping is advisory for routing and status reporting.
296
+ */
297
+ export const COPILOT_INNER_STATE_MAP = Object.freeze({
298
+ [LIFECYCLE_STATE.ISSUE_INTAKE]: Object.freeze([]),
299
+ [LIFECYCLE_STATE.REFINEMENT]: Object.freeze([]),
300
+ [LIFECYCLE_STATE.IMPLEMENTATION]: Object.freeze([
301
+ "no_pr",
302
+ "pr_draft",
303
+ ]),
304
+ [LIFECYCLE_STATE.DRAFT_GATE]: Object.freeze([
305
+ "pr_ready_no_feedback",
306
+ ]),
307
+ [LIFECYCLE_STATE.FEEDBACK_RESOLUTION]: Object.freeze([
308
+ "waiting_for_copilot_review",
309
+ "unresolved_feedback_present",
310
+ "already_fixed_needs_reply_resolve",
311
+ "ready_to_rerequest_review",
312
+ "waiting_for_ci",
313
+ "review_request_unavailable",
314
+ "blocked_needs_user_decision",
315
+ "round_cap_reached",
316
+ ]),
317
+ [LIFECYCLE_STATE.PRE_APPROVAL_GATE]: Object.freeze([
318
+ "low_signal_converged",
319
+ "round_cap_clean_fallback",
320
+ "internal_tooling_direct_gate",
321
+ ]),
322
+ [LIFECYCLE_STATE.MERGE]: Object.freeze([
323
+ "done",
324
+ ]),
325
+ });
326
+
327
+ /**
328
+ * Resolve the lifecycle phase implied by a given copilot-loop-state.mjs inner state.
329
+ *
330
+ * Returns the lifecycle phase that typically contains the given inner state,
331
+ * or null if the inner state maps to no lifecycle phase.
332
+ */
333
+ export function lifecyclePhaseForCopilotState(copilotState) {
334
+ if (typeof copilotState !== "string") return null;
335
+
336
+ for (const [phase, innerStates] of Object.entries(COPILOT_INNER_STATE_MAP)) {
337
+ if (innerStates.includes(copilotState)) {
338
+ return phase;
339
+ }
340
+ }
341
+ return null;
342
+ }