dev-loops 0.7.1 → 0.8.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 (107) hide show
  1. package/.claude/.claude-plugin/plugin.json +1 -1
  2. package/.claude/agents/dev-loop.md +7 -7
  3. package/.claude/agents/fixer.md +1 -0
  4. package/.claude/agents/refiner.md +4 -4
  5. package/.claude/agents/review.md +10 -13
  6. package/.claude/commands/loop-continue.md +2 -1
  7. package/.claude/commands/loop-enqueue.md +9 -2
  8. package/.claude/commands/loop-info.md +1 -1
  9. package/.claude/commands/loop-start-spike.md +1 -1
  10. package/.claude/hooks/_bash-command-classify.mjs +7 -6
  11. package/.claude/hooks/_hook-decisions.mjs +5 -4
  12. package/.claude/skills/copilot-pr-followup/SKILL.md +42 -28
  13. package/.claude/skills/dev-loop/SKILL.md +8 -8
  14. package/.claude/skills/docs/acceptance-criteria-verification.md +13 -4
  15. package/.claude/skills/docs/anti-patterns.md +6 -5
  16. package/.claude/skills/docs/artifact-authority-contract.md +17 -14
  17. package/.claude/skills/docs/confirmation-rules.md +2 -1
  18. package/.claude/skills/docs/contract-style-guide.md +37 -0
  19. package/.claude/skills/docs/copilot-loop-operations.md +21 -15
  20. package/.claude/skills/docs/cross-harness-regression-contract.md +2 -2
  21. package/.claude/skills/docs/debt-remediation-contract.md +1 -1
  22. package/.claude/skills/docs/epic-tree-refinement-procedure.md +25 -22
  23. package/.claude/skills/docs/issue-intake-procedure.md +37 -22
  24. package/.claude/skills/docs/merge-preconditions.md +17 -13
  25. package/.claude/skills/docs/pr-lifecycle-contract.md +45 -36
  26. package/.claude/skills/docs/public-dev-loop-contract.md +61 -44
  27. package/.claude/skills/docs/retrospective-checkpoint-contract.md +25 -9
  28. package/.claude/skills/docs/spike-mode-contract.md +14 -7
  29. package/.claude/skills/docs/stop-conditions.md +30 -15
  30. package/.claude/skills/docs/tracker-first-loop-state.md +13 -8
  31. package/.claude/skills/docs/validation-policy.md +4 -5
  32. package/.claude/skills/local-implementation/SKILL.md +49 -143
  33. package/.claude/skills/loop-grill/SKILL.md +34 -14
  34. package/AGENTS.md +1 -1
  35. package/CHANGELOG.md +36 -0
  36. package/README.md +95 -189
  37. package/agents/dev-loop.agent.md +8 -8
  38. package/agents/fixer.agent.md +1 -0
  39. package/agents/refiner.agent.md +4 -4
  40. package/agents/review.agent.md +10 -13
  41. package/extension/README.md +5 -4
  42. package/package.json +7 -5
  43. package/scripts/_core-helpers.mjs +2 -0
  44. package/scripts/claude/headless-dev-loop.mjs +1 -1
  45. package/scripts/docs/validate-rule-ownership.mjs +442 -0
  46. package/scripts/docs/validate-state-machine-conformance.mjs +1169 -0
  47. package/scripts/github/_gate-names.mjs +5 -0
  48. package/scripts/github/_review-thread-mutations.mjs +5 -2
  49. package/scripts/github/capture-review-threads.mjs +2 -2
  50. package/scripts/github/create-pr.mjs +119 -10
  51. package/scripts/github/detect-checkpoint-evidence.mjs +109 -24
  52. package/scripts/github/edit-issue.mjs +259 -0
  53. package/scripts/github/list-review-threads.mjs +277 -0
  54. package/scripts/github/post-gate-findings.mjs +6 -3
  55. package/scripts/github/probe-ci-status.mjs +18 -0
  56. package/scripts/github/probe-copilot-review.mjs +24 -3
  57. package/scripts/github/reconcile-draft-gate.mjs +13 -13
  58. package/scripts/github/request-copilot-review.mjs +109 -42
  59. package/scripts/github/upsert-checkpoint-verdict.mjs +98 -27
  60. package/scripts/github/verify-briefing-prefixes.mjs +382 -0
  61. package/scripts/github/verify-fresh-review-context.mjs +78 -3
  62. package/scripts/github/wait-pr-checks.mjs +171 -0
  63. package/scripts/github/write-gate-context.mjs +220 -10
  64. package/scripts/github/write-gate-findings-log.mjs +54 -2
  65. package/scripts/loop/_post-convergence-change.mjs +2 -2
  66. package/scripts/loop/_pr-runner-coordination.mjs +112 -13
  67. package/scripts/loop/check-retro-tooling.mjs +14 -9
  68. package/scripts/loop/copilot-pr-handoff.mjs +47 -16
  69. package/scripts/loop/detect-change-scope.mjs +2 -2
  70. package/scripts/loop/detect-copilot-loop-state.mjs +34 -14
  71. package/scripts/loop/detect-internal-only-pr.mjs +6 -6
  72. package/scripts/loop/detect-pr-gate-coordination-state.mjs +152 -18
  73. package/scripts/loop/detect-refinement-grill-state.mjs +136 -0
  74. package/scripts/loop/resolve-dev-loop-startup.mjs +145 -8
  75. package/scripts/loop/run-watch-cycle.mjs +42 -7
  76. package/scripts/loop/sanctioned-commands.mjs +3 -0
  77. package/scripts/loop/validate-pr-body-spec.mjs +21 -5
  78. package/scripts/pages/build-state-atlas.mjs +65 -48
  79. package/scripts/projects/_resolve-project.mjs +1 -148
  80. package/scripts/projects/add-queue-item.mjs +87 -4
  81. package/scripts/projects/list-queue-items.mjs +3 -377
  82. package/scripts/projects/move-queue-item.mjs +3 -410
  83. package/scripts/projects/reorder-queue-item.mjs +3 -22
  84. package/skills/copilot-pr-followup/SKILL.md +42 -28
  85. package/skills/dev-loop/SKILL.md +3 -3
  86. package/skills/docs/acceptance-criteria-verification.md +13 -4
  87. package/skills/docs/anti-patterns.md +6 -5
  88. package/skills/docs/artifact-authority-contract.md +17 -14
  89. package/skills/docs/confirmation-rules.md +2 -1
  90. package/skills/docs/contract-style-guide.md +37 -0
  91. package/skills/docs/copilot-loop-operations.md +21 -15
  92. package/skills/docs/cross-harness-regression-contract.md +2 -2
  93. package/skills/docs/debt-remediation-contract.md +1 -1
  94. package/skills/docs/epic-tree-refinement-procedure.md +25 -22
  95. package/skills/docs/issue-intake-procedure.md +37 -22
  96. package/skills/docs/merge-preconditions.md +17 -13
  97. package/skills/docs/pr-lifecycle-contract.md +45 -36
  98. package/skills/docs/public-dev-loop-contract.md +61 -44
  99. package/skills/docs/required-rules.json +165 -0
  100. package/skills/docs/retrospective-checkpoint-contract.md +25 -9
  101. package/skills/docs/spike-mode-contract.md +14 -7
  102. package/skills/docs/stop-conditions.md +30 -15
  103. package/skills/docs/tracker-first-loop-state.md +13 -8
  104. package/skills/docs/validation-policy.md +4 -5
  105. package/skills/local-implementation/SKILL.md +49 -143
  106. package/skills/loop-grill/SKILL.md +38 -17
  107. package/scripts/docs/validate-no-duplicate-rules.mjs +0 -250
@@ -0,0 +1,1169 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * validate-state-machine-conformance.mjs — L2/L3 state-machine conformance +
4
+ * invariant harness (issue #1148).
5
+ *
6
+ * L2 (doc <-> code conformance): for a registered machine, every transition the
7
+ * doc's structured transition table declares must have a matching, executable
8
+ * check against the code (`status: "verified"`) or be explicitly accounted for
9
+ * as `"known_gap"` (tracked divergence, own issue) or `"external"` /
10
+ * `"owned_elsewhere"` (the transition is real, but decided by another module —
11
+ * never a silent pass). A doc transition with no registered check at all is
12
+ * `"missing"` and fails the run: this is how doc drift (a renamed/added/removed
13
+ * transition nobody updated the registration for) gets caught.
14
+ *
15
+ * L3 (graph invariants): a small reachability walk over a machine's declared
16
+ * `{states, transitions, terminalStates}` checks:
17
+ * - completeness: every non-terminal state has >=1 outgoing transition;
18
+ * - liveness: every state can reach some terminal state;
19
+ * plus a per-machine `safetyRules` list of predicates run over the real
20
+ * observations (the executed code results) gathered while checking L2, e.g.
21
+ * "no final-approval-readiness observation without both gates clean" or
22
+ * "no fail-closed (blocked) observation that still permits a gate-progressing
23
+ * action" — the concrete analogs, for this machine, of the generic "no ->merged
24
+ * without both gates clean" / "fail-closed states never dispatch a Backlog
25
+ * pull" invariants named in issue #1148.
26
+ *
27
+ * ---------------------------------------------------------------------------
28
+ * Registration path (DoD3): adding a second machine requires ONLY calling
29
+ * `registerMachine(machine)` with:
30
+ * {
31
+ * name, // stable machine id, e.g. "pr-gate-coordination"
32
+ * states, // string[] — every state name in the graph
33
+ * terminalStates, // string[] — absorbing states (subset of states)
34
+ * transitions, // [from, to][] — the FULL graph, for L3 (may
35
+ * // include a synthetic '[*]' terminal marker
36
+ * // target; it is ignored by both L2 and L3)
37
+ * docTransitions, // [from, to][] — the transitions the OWNER DOC
38
+ * // declares (usually === transitions minus the
39
+ * // '[*]' marker rows); this is the L2 "doc
40
+ * // table" and MUST be parsed from the owner
41
+ * // doc's own structured transition section via
42
+ * // `parseRequiredTransitions` (see the
43
+ * // pr-gate-coordination registration below for
44
+ * // the pattern), so editing the doc is visible
45
+ * // to the harness — never a hand-copied array;
46
+ * transitionChecks, // Map<"from->to", Check> — the L2 "code table":
47
+ * // { status: "verified", verify: () => { ok, detail, result } }
48
+ * // { status: "known_gap", issue, note }
49
+ * // { status: "external"|"owned_elsewhere", note }
50
+ * safetyRules, // [{ name, check: (observation) => boolean }]
51
+ * }
52
+ * No engine function below needs to change for a new machine — see the
53
+ * "adding a second machine" extensibility test in
54
+ * test/docs/validate-state-machine-conformance.test.mjs.
55
+ */
56
+
57
+ import { readFileSync } from "node:fs";
58
+ import path from "node:path";
59
+ import { fileURLToPath } from "node:url";
60
+
61
+ import { isDirectCliRun } from "../_core-helpers.mjs";
62
+ import { evaluatePrGateCoordination, PR_CHECKPOINT, PR_CHECKPOINT_ACTION } from "@dev-loops/core/loop/pr-gate-coordination";
63
+ import { DISPOSITION, interpretLoopState, STATE, TRANSITIONS } from "@dev-loops/core/loop/copilot-loop-state";
64
+ import { evaluateConductorRouting, getAllowedOuterTransitions, OUTER_STATE, OUTER_TERMINAL_STATES } from "@dev-loops/core/loop/conductor-routing";
65
+ import { interpretReviewerLoopState, REVIEWER_STATE, REVIEWER_TRANSITIONS } from "@dev-loops/core/loop/reviewer-loop-state";
66
+ import { interpretRefinementGrillState, GRILL_STATE, GRILL_TRANSITIONS } from "@dev-loops/core/loop/refinement-grill-state";
67
+ import { PR_LIFECYCLE_STATES, PR_LIFECYCLE_TERMINAL_STATES, PR_LIFECYCLE_TRANSITIONS } from "@dev-loops/core/loop/pr-lifecycle";
68
+ import { DEV_LOOP_GATE, DEV_LOOP_ROUTE_KIND, PUBLIC_DEV_LOOP_GATE_CONTRACT, DEV_LOOP_PUBLIC_INTENT, DEV_LOOP_TARGET_KIND, DEV_LOOP_ACTOR, DEV_LOOP_STATUS, DEV_LOOP_AUTHORIZATION, DEV_LOOP_TARGET_PREFERENCE, INTERNAL_DEV_LOOP_STRATEGY, evaluatePublicDevLoopRouting } from "@dev-loops/core/loop/public-dev-loop-routing";
69
+
70
+ const USAGE = `Usage: validate-state-machine-conformance.mjs [--help]
71
+
72
+ Run the L2 (doc <-> code) conformance check and L3 (completeness/safety/liveness)
73
+ graph invariants for every registered state machine.
74
+ Exit 0 when every registered machine's checks pass or are explicitly accounted
75
+ for (known_gap/external/owned_elsewhere). Exit 1 on any missing coverage,
76
+ divergence, or invariant failure.
77
+
78
+ Options:
79
+ --help, -h Show this help`.trim();
80
+
81
+ const TERMINAL_MARKER = "[*]";
82
+
83
+ // ---------------------------------------------------------------------------
84
+ // Generic engine (no machine-specific knowledge below this line).
85
+ // ---------------------------------------------------------------------------
86
+
87
+ function realEdges(transitions) {
88
+ return transitions.filter(([, to]) => to !== TERMINAL_MARKER);
89
+ }
90
+
91
+ /**
92
+ * L2 doc-side parser: read a contract doc's structured transition section
93
+ * (top-level bullets of the shape "- `from` -> `to`" under `sectionHeading`)
94
+ * into a [from, to][] table. Rows whose from/to is prose rather than a
95
+ * backtick token (abstract rows like "any open non-terminal lifecycle slice")
96
+ * MUST be resolved via an explicit `abstractRows` mapping
97
+ * (Map<"from->to raw text", [from, to][]>) — an unmapped abstract row throws,
98
+ * never silently drops, so a new abstract bullet in the doc is loud.
99
+ *
100
+ * Caveat: a top-level line that does not match "- X -> Y" at all (a unicode
101
+ * arrow, or missing the "- " bullet prefix) is treated as prose and skipped —
102
+ * that drop is silent. A line that DOES match "- X -> Y" but whose from/to is
103
+ * neither a `backtick token` nor a mapped abstractRows entry (e.g. a bullet
104
+ * with a trailing annotation after the arrow) is NOT silent: it fails the
105
+ * backtick-token match and throws via the unmapped-abstract-row path above.
106
+ * For pr-gate-coordination the load-time 1:1 doc<->atlas binding below
107
+ * additionally catches every silent-drop case; a second machine MUST pair
108
+ * this parser with an equivalent independent binding (or extend the parser
109
+ * with a "looks like a transition bullet but did not parse" check).
110
+ */
111
+ export function parseRequiredTransitions(markdown, { sectionHeading = "## Required transitions", abstractRows = new Map() } = {}) {
112
+ // The heading may open the file (index 0, no leading "\n") or appear further down (leading
113
+ // "\n"); check both so a doc that starts with this section is not falsely reported as missing.
114
+ const atStart = markdown.startsWith(`${sectionHeading}\n`);
115
+ const sectionStart = atStart ? 0 : markdown.indexOf(`\n${sectionHeading}\n`);
116
+ if (sectionStart === -1) throw new Error(`doc has no "${sectionHeading}" section`);
117
+ const headingEnd = sectionStart + (atStart ? 0 : 1) + sectionHeading.length + 1;
118
+ const afterHeading = markdown.slice(headingEnd);
119
+ const body = afterHeading.split(/\n#{1,6} /)[0];
120
+
121
+ const transitions = [];
122
+ for (const line of body.split(/\r?\n/)) {
123
+ const bullet = line.match(/^- (.+?) -> (.+)$/);
124
+ if (!bullet) continue; // sub-bullets (guards) are indented and skipped
125
+ const parts = [bullet[1].trim(), bullet[2].trim()];
126
+ const tokens = parts.map((p) => p.match(/^`([A-Za-z0-9_]+)`$/)?.[1] ?? null);
127
+ if (tokens[0] !== null && tokens[1] !== null) {
128
+ transitions.push([tokens[0], tokens[1]]);
129
+ continue;
130
+ }
131
+ const rawKey = `${parts[0]}->${parts[1]}`;
132
+ const mapped = abstractRows.get(rawKey);
133
+ if (!mapped) throw new Error(`unmapped abstract transition row in doc: "${rawKey}" — add it to abstractRows`);
134
+ transitions.push(...mapped);
135
+ }
136
+ if (transitions.length === 0) throw new Error(`"${sectionHeading}" section contains no transition bullets`);
137
+ return transitions;
138
+ }
139
+
140
+ /** L3 completeness: every non-terminal state has >=1 outgoing transition. */
141
+ export function checkCompleteness({ states, transitions, terminalStates }) {
142
+ const outgoing = new Set(realEdges(transitions).map(([from]) => from));
143
+ const deadEnds = states.filter((s) => !terminalStates.includes(s) && !outgoing.has(s));
144
+ return { ok: deadEnds.length === 0, deadEnds };
145
+ }
146
+
147
+ /** L3 liveness: every state can reach some terminal state (reachability walk). */
148
+ export function checkLiveness({ states, transitions, terminalStates }) {
149
+ const adjacency = new Map(states.map((s) => [s, []]));
150
+ for (const [from, to] of realEdges(transitions)) {
151
+ if (!adjacency.has(from)) adjacency.set(from, []);
152
+ adjacency.get(from).push(to);
153
+ }
154
+
155
+ const resolved = new Map();
156
+ function canReachTerminal(state, path) {
157
+ if (resolved.has(state)) return resolved.get(state);
158
+ if (terminalStates.includes(state)) {
159
+ resolved.set(state, true);
160
+ return true;
161
+ }
162
+ if (path.has(state)) return false; // cycle without a resolved terminal (yet)
163
+ path.add(state);
164
+ let ok = false;
165
+ for (const next of adjacency.get(state) ?? []) {
166
+ if (canReachTerminal(next, path)) {
167
+ ok = true;
168
+ break;
169
+ }
170
+ }
171
+ path.delete(state);
172
+ if (ok) resolved.set(state, true);
173
+ return ok;
174
+ }
175
+
176
+ const stuck = states.filter((s) => !canReachTerminal(s, new Set()));
177
+ return { ok: stuck.length === 0, stuck };
178
+ }
179
+
180
+ /** L3 safety: run each named predicate over every gathered observation. */
181
+ export function checkSafetyRules(observations, safetyRules) {
182
+ const violations = [];
183
+ for (const rule of safetyRules) {
184
+ for (const observation of observations) {
185
+ if (!rule.check(observation)) {
186
+ violations.push({ rule: rule.name, observation });
187
+ }
188
+ }
189
+ }
190
+ return { ok: violations.length === 0, violations };
191
+ }
192
+
193
+ /**
194
+ * L2: compare a doc-declared transition table against the registered code
195
+ * checks. `transitionChecks` is a Map keyed by `"from->to"`. Bidirectional:
196
+ * a doc transition with no check is "missing"; a check key no doc transition
197
+ * references is "unreferenced" (a stale check, or a code-side transition the
198
+ * doc dropped) — both fail. A `verify()` that throws is caught per-transition
199
+ * and reported as "divergent" (fail-closed) rather than aborting the run, so
200
+ * every other transition still gets checked and surfaces in the same report.
201
+ */
202
+ export function compareDocCodeTransitions(docTransitions, transitionChecks) {
203
+ const results = [];
204
+ const observations = [];
205
+ const referencedKeys = new Set();
206
+ for (const [from, to] of realEdges(docTransitions)) {
207
+ const key = `${from}->${to}`;
208
+ referencedKeys.add(key);
209
+ const check = transitionChecks.get(key);
210
+ if (!check) {
211
+ results.push({ from, to, status: "missing" });
212
+ continue;
213
+ }
214
+ if (check.status === "verified") {
215
+ // A throwing verify() is itself a divergence (fail-closed), not a reason to abort the
216
+ // whole run: report this transition as divergent with the error and keep checking the
217
+ // rest so other missing/unreferenced edges still surface in the same report.
218
+ try {
219
+ const outcome = check.verify();
220
+ // A verify() may return one observation or an array (multi-variant fixtures).
221
+ if (outcome && outcome.result !== undefined) observations.push(...(Array.isArray(outcome.result) ? outcome.result : [outcome.result]));
222
+ results.push({ from, to, status: outcome.ok ? "verified" : "divergent", detail: outcome.detail });
223
+ } catch (err) {
224
+ const message = err instanceof Error ? err.message : String(err);
225
+ results.push({ from, to, status: "divergent", detail: `verify() threw: ${message}` });
226
+ }
227
+ } else {
228
+ results.push({ from, to, status: check.status, issue: check.issue ?? null, note: check.note ?? null });
229
+ }
230
+ }
231
+ for (const key of transitionChecks.keys()) {
232
+ if (!referencedKeys.has(key)) {
233
+ const [from, to] = key.split("->");
234
+ results.push({ from, to, status: "unreferenced" });
235
+ }
236
+ }
237
+ const ok = results.every((r) => r.status !== "missing" && r.status !== "divergent" && r.status !== "unreferenced");
238
+ return { ok, results, observations };
239
+ }
240
+
241
+ const REGISTRY = new Map();
242
+
243
+ /** Register a machine for conformance + invariant checking (see header). */
244
+ export function registerMachine(machine) {
245
+ if (!machine || typeof machine.name !== "string" || machine.name.trim().length === 0) {
246
+ throw new Error("registerMachine requires a non-empty `name`");
247
+ }
248
+ // Fail-closed shape check: L2 needs BOTH tables; exactly one present is a
249
+ // half-registered machine that would otherwise skip L2 silently as ok:true.
250
+ if (Boolean(machine.docTransitions) !== Boolean(machine.transitionChecks)) {
251
+ throw new Error(`machine "${machine.name}" must provide docTransitions and transitionChecks together (or neither)`);
252
+ }
253
+ if (REGISTRY.has(machine.name)) {
254
+ throw new Error(`machine "${machine.name}" is already registered — duplicate registration would silently overwrite it`);
255
+ }
256
+ REGISTRY.set(machine.name, machine);
257
+ return machine;
258
+ }
259
+
260
+ export function getRegisteredMachines() {
261
+ return [...REGISTRY.values()];
262
+ }
263
+
264
+ /** Run every applicable check for one registered machine and return a report. */
265
+ export function runMachineConformance(machine) {
266
+ const completeness = checkCompleteness(machine);
267
+ const liveness = checkLiveness(machine);
268
+ const conformance = machine.docTransitions && machine.transitionChecks
269
+ ? compareDocCodeTransitions(machine.docTransitions, machine.transitionChecks)
270
+ : { ok: true, results: [], observations: [] };
271
+ const safety = machine.safetyRules && machine.safetyRules.length > 0
272
+ ? checkSafetyRules(conformance.observations, machine.safetyRules)
273
+ : { ok: true, violations: [] };
274
+
275
+ return {
276
+ name: machine.name,
277
+ ok: completeness.ok && liveness.ok && conformance.ok && safety.ok,
278
+ completeness,
279
+ liveness,
280
+ conformance,
281
+ safety,
282
+ };
283
+ }
284
+
285
+ // ---------------------------------------------------------------------------
286
+ // Reference machine (issue #1148): pr-gate-coordination.
287
+ //
288
+ // Doc side: skills/docs/pr-lifecycle-contract.md's "## Required transitions"
289
+ // bullets, parsed at load time via parseRequiredTransitions so a doc edit is
290
+ // immediately visible to the harness. The two abstract (non-backtick) rows are
291
+ // mapped explicitly to their concrete representatives below. The parsed table
292
+ // is additionally asserted to bind 1:1 to @dev-loops/core/loop/pr-lifecycle's
293
+ // PR_LIFECYCLE_TRANSITIONS (the exported table also consumed by
294
+ // build-state-atlas.mjs's diagram), so that constant cannot drift from the doc
295
+ // either.
296
+ //
297
+ // Code side: packages/core/src/loop/pr-gate-coordination.mjs exports
298
+ // PR_CHECKPOINT / PR_CHECKPOINT_ACTION but no state-to-state table in the
299
+ // doc's vocabulary (a gate boundary is coarser than a lifecycle state, and
300
+ // several lifecycle transitions belong to the copilot inner-loop state graph,
301
+ // out of scope per #1148/#1156/#1157). Each doc transition below is checked by
302
+ // actually calling the exported `evaluatePrGateCoordination` with a fixture
303
+ // standing in for the "from" state and asserting the returned action/boundary
304
+ // is consistent with the "to" state — a real characterization of the code,
305
+ // not a hand-copied assumption.
306
+ // ---------------------------------------------------------------------------
307
+
308
+ const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..");
309
+
310
+ // Abstract doc rows -> concrete graph edges. "any open non-terminal lifecycle
311
+ // slice" is represented by waiting_for_copilot_review (the guard fires ahead of
312
+ // every lifecycle-state branch in the code, so one representative suffices);
313
+ // "normal lifecycle re-entry state" re-enters the same representative slice.
314
+ const PR_LIFECYCLE_ABSTRACT_ROWS = new Map([
315
+ ["any open non-terminal lifecycle slice->`merge_conflict_resolution`", [["waiting_for_copilot_review", "merge_conflict_resolution"]]],
316
+ ["`merge_conflict_resolution`->normal lifecycle re-entry state", [["merge_conflict_resolution", "waiting_for_copilot_review"]]],
317
+ ]);
318
+
319
+ // Issue #1193 added the doc's `final_gate_remediation` -> `final_local_preapproval_gate`
320
+ // bullet (mirroring the draft pair's `draft_local_remediation` -> `draft_local_review_gate`
321
+ // re-entry bullet), retiring the implied-edge allowlist that used to stand in for it here.
322
+ const PR_LIFECYCLE_DOC_TRANSITIONS = parseRequiredTransitions(
323
+ readFileSync(path.join(REPO_ROOT, "skills", "docs", "pr-lifecycle-contract.md"), "utf8"),
324
+ { abstractRows: PR_LIFECYCLE_ABSTRACT_ROWS },
325
+ );
326
+
327
+ // Bind the parsed doc table 1:1 to the exported constant (order-insensitive).
328
+ {
329
+ const docSet = new Set(PR_LIFECYCLE_DOC_TRANSITIONS.map(([a, b]) => `${a}->${b}`));
330
+ const codeSet = new Set(realEdges(PR_LIFECYCLE_TRANSITIONS).map(([a, b]) => `${a}->${b}`));
331
+ const onlyDoc = [...docSet].filter((k) => !codeSet.has(k));
332
+ const onlyCode = [...codeSet].filter((k) => !docSet.has(k));
333
+ if (onlyDoc.length > 0 || onlyCode.length > 0) {
334
+ throw new Error(
335
+ "pr-lifecycle-contract.md Required transitions and @dev-loops/core/loop/pr-lifecycle's PR_LIFECYCLE_TRANSITIONS have drifted apart. "
336
+ + `Only in doc: [${onlyDoc.join(", ")}]. Only in code: [${onlyCode.join(", ")}].`,
337
+ );
338
+ }
339
+ }
340
+
341
+ function gate({ visible = false, headSha = null, verdict = null, contractComplete = false } = {}) {
342
+ return { visible, headSha, verdict, contractComplete };
343
+ }
344
+
345
+ const HEAD = "abc1234567890";
346
+ const CLEAN_GATE = gate({ visible: true, headSha: "abc1234", verdict: "clean" });
347
+ const CLEAN_MARKER = gate({ visible: true, headSha: "abc1234", verdict: "clean", contractComplete: true });
348
+ const FINDINGS_GATE = gate({ visible: true, headSha: "abc1234", verdict: "findings_present" });
349
+
350
+ function run(input) {
351
+ return evaluatePrGateCoordination({ currentHeadSha: HEAD, ...input });
352
+ }
353
+
354
+ const PR_GATE_TRANSITION_CHECKS = new Map();
355
+
356
+ function verified(key, verify) {
357
+ PR_GATE_TRANSITION_CHECKS.set(key, { status: "verified", verify });
358
+ }
359
+
360
+ // draft_local_review_gate -> draft_local_remediation: blocking draft_gate findings.
361
+ verified("draft_local_review_gate->draft_local_remediation", () => {
362
+ const result = run({ prDraft: true, lifecycleState: STATE.PR_DRAFT, ciStatus: "success", draftGate: FINDINGS_GATE });
363
+ const ok = result.gateBoundary === PR_CHECKPOINT.DRAFT_REVIEW
364
+ && result.nextAction === PR_CHECKPOINT_ACTION.RUN_DRAFT_GATE
365
+ && result.forbiddenActions.includes(PR_CHECKPOINT_ACTION.MARK_READY_FOR_REVIEW);
366
+ return { ok, detail: result, result };
367
+ });
368
+
369
+ // draft_local_review_gate -> ready_state_needs_copilot_request: clean current-head draft_gate evidence.
370
+ verified("draft_local_review_gate->ready_state_needs_copilot_request", () => {
371
+ const result = run({
372
+ prDraft: true,
373
+ lifecycleState: STATE.PR_DRAFT,
374
+ ciStatus: "success",
375
+ draftGate: CLEAN_GATE,
376
+ draftGateMarker: CLEAN_MARKER,
377
+ });
378
+ const ok = result.gateBoundary === PR_CHECKPOINT.DRAFT_REVIEW
379
+ && result.nextAction === PR_CHECKPOINT_ACTION.MARK_READY_FOR_REVIEW;
380
+ return { ok, detail: result, result };
381
+ });
382
+
383
+ // draft_local_review_gate -> stopped_needs_user_decision: human decision required (failing CI while draft).
384
+ verified("draft_local_review_gate->stopped_needs_user_decision", () => {
385
+ const result = run({ prDraft: true, lifecycleState: STATE.PR_DRAFT, ciStatus: "failure", draftGate: gate({ visible: false }) });
386
+ const ok = result.gateBoundary === PR_CHECKPOINT.BLOCKED && result.nextAction === PR_CHECKPOINT_ACTION.REPORT_BLOCKED;
387
+ return { ok, detail: result, result };
388
+ });
389
+
390
+ // draft_local_remediation -> draft_local_review_gate: fixes pushed on the draft head (re-entry is the
391
+ // same draft-review call; the code does not model a distinct "remediation" boundary).
392
+ verified("draft_local_remediation->draft_local_review_gate", () => {
393
+ const result = run({ prDraft: true, lifecycleState: STATE.PR_DRAFT, ciStatus: "success", draftGate: gate({ visible: false }) });
394
+ const ok = result.gateBoundary === PR_CHECKPOINT.DRAFT_REVIEW && result.nextAction === PR_CHECKPOINT_ACTION.RUN_DRAFT_GATE;
395
+ return { ok, detail: result, result };
396
+ });
397
+
398
+ // ready_state_needs_copilot_request -> waiting_for_copilot_review: explicit request/confirm succeeded.
399
+ verified("ready_state_needs_copilot_request->waiting_for_copilot_review", () => {
400
+ const result = run({ prDraft: false, lifecycleState: STATE.PR_READY_NO_FEEDBACK, loopDisposition: DISPOSITION.ACTION_REQUIRED });
401
+ const ok = result.gateBoundary === PR_CHECKPOINT.POST_DRAFT_EXTERNAL_REVIEW
402
+ && result.nextAction === PR_CHECKPOINT_ACTION.REQUEST_COPILOT_REVIEW;
403
+ return { ok, detail: result, result };
404
+ });
405
+
406
+ // ready_state_needs_copilot_request -> stopped_needs_user_decision: request unavailable or blocked.
407
+ verified("ready_state_needs_copilot_request->stopped_needs_user_decision", () => {
408
+ const result = run({ prDraft: false, lifecycleState: STATE.REVIEW_REQUEST_UNAVAILABLE });
409
+ const ok = result.gateBoundary === PR_CHECKPOINT.BLOCKED && result.nextAction === PR_CHECKPOINT_ACTION.REPORT_BLOCKED;
410
+ return { ok, detail: result, result };
411
+ });
412
+
413
+ // waiting_for_copilot_review -> merge_conflict_resolution: current-head merge state is conflicted.
414
+ // (Doc: "any open non-terminal lifecycle slice -> merge_conflict_resolution"; checked from this
415
+ // representative non-terminal slice since the guard fires ahead of every lifecycle-state branch.)
416
+ verified("waiting_for_copilot_review->merge_conflict_resolution", () => {
417
+ const result = run({ prDraft: false, lifecycleState: STATE.WAITING_FOR_COPILOT_REVIEW, mergeStateStatus: "DIRTY" });
418
+ const ok = result.gateBoundary === PR_CHECKPOINT.CONFLICT_RESOLUTION
419
+ && result.nextAction === PR_CHECKPOINT_ACTION.RESOLVE_MERGE_CONFLICTS;
420
+ return { ok, detail: result, result };
421
+ });
422
+
423
+ // waiting_for_copilot_review -> final_local_preapproval_gate: the request/re-review cycle has settled
424
+ // cleanly with no unresolved feedback and no further Copilot pass needed.
425
+ //
426
+ // Fixed by #1190 (previously a tracked known_gap; issue #1148 / epic #1104 comment thread): gate
427
+ // ENTRY used to trust the caller-supplied `sameHeadCleanConverged` flag as-is, with no independent
428
+ // signal to catch an unsettled/unreviewed current head — the only fail-closed guard against that
429
+ // lived downstream, at *verdict-post* time (`upsert-checkpoint-verdict.mjs`'s unsettled-review
430
+ // refusal). `evaluatePrGateCoordination` now takes an independent `copilotReviewRequestStatus`
431
+ // signal (not derived from `sameHeadCleanConverged`) and refuses `RUN_PRE_APPROVAL_GATE` outright
432
+ // whenever a Copilot review request is still outstanding on the current head — asserted at gate
433
+ // *entry*, mirroring the verdict-post predicate instead of only duplicating it after the fact.
434
+ verified("waiting_for_copilot_review->final_local_preapproval_gate", () => {
435
+ // Even a caller that reports sameHeadCleanConverged: true (e.g. a stale/racy
436
+ // interpretation) must still be refused while a Copilot review request is
437
+ // outstanding on the current head — the entry guard is independent of that flag.
438
+ const unsettled = run({
439
+ prDraft: false,
440
+ lifecycleState: STATE.READY_TO_REREQUEST_REVIEW,
441
+ ciStatus: "success",
442
+ sameHeadCleanConverged: true,
443
+ copilotReviewRequestStatus: "requested",
444
+ preApprovalGate: gate({ visible: false }),
445
+ });
446
+ const unsettledOk = unsettled.gateBoundary === PR_CHECKPOINT.POST_DRAFT_EXTERNAL_REVIEW
447
+ && unsettled.nextAction === PR_CHECKPOINT_ACTION.WAIT_FOR_COPILOT_REVIEW
448
+ && unsettled.forbiddenActions.includes(PR_CHECKPOINT_ACTION.RUN_PRE_APPROVAL_GATE);
449
+
450
+ // Once the review request has genuinely settled (no outstanding request) and the
451
+ // current head converged cleanly, pre_approval_gate entry is legal — the fix does
452
+ // not regress the real transition the doc requires.
453
+ const settled = run({
454
+ prDraft: false,
455
+ lifecycleState: STATE.READY_TO_REREQUEST_REVIEW,
456
+ ciStatus: "success",
457
+ sameHeadCleanConverged: true,
458
+ copilotReviewRequestStatus: "none",
459
+ preApprovalGate: gate({ visible: false }),
460
+ });
461
+ const settledOk = settled.gateBoundary === PR_CHECKPOINT.PRE_APPROVAL_GATE_WINDOW
462
+ && settled.nextAction === PR_CHECKPOINT_ACTION.RUN_PRE_APPROVAL_GATE;
463
+
464
+ const ok = unsettledOk && settledOk;
465
+ return { ok, detail: { unsettled, settled }, result: settled };
466
+ });
467
+
468
+ // final_local_preapproval_gate -> final_gate_remediation: pre-approval gate findings require changes.
469
+ verified("final_local_preapproval_gate->final_gate_remediation", () => {
470
+ const result = run({
471
+ prDraft: false,
472
+ lifecycleState: STATE.READY_TO_REREQUEST_REVIEW,
473
+ ciStatus: "success",
474
+ sameHeadCleanConverged: true,
475
+ preApprovalGate: FINDINGS_GATE,
476
+ });
477
+ const ok = result.gateBoundary === PR_CHECKPOINT.PRE_APPROVAL_GATE_WINDOW
478
+ && result.nextAction === PR_CHECKPOINT_ACTION.RUN_PRE_APPROVAL_GATE;
479
+ return { ok, detail: result, result };
480
+ });
481
+
482
+ // final_local_preapproval_gate -> waiting_for_human_pr_approval: clean current-head pre_approval_gate
483
+ // evidence exists (the code additionally requires clean draft_gate evidence at this boundary — a
484
+ // stricter-than-the-bullet precondition consistent with this doc's own boundary notes and #579's
485
+ // "no gate exemptions"; the fixture below satisfies both so it characterizes real reachable behavior).
486
+ verified("final_local_preapproval_gate->waiting_for_human_pr_approval", () => {
487
+ const result = run({
488
+ prDraft: false,
489
+ lifecycleState: STATE.READY_TO_REREQUEST_REVIEW,
490
+ ciStatus: "success",
491
+ sameHeadCleanConverged: true,
492
+ draftGate: gate({ visible: true, headSha: "abc1234", verdict: "clean" }),
493
+ preApprovalGate: gate({ visible: true, headSha: HEAD.slice(0, 7), verdict: "clean" }),
494
+ preApprovalGateMarker: gate({ visible: true, headSha: HEAD.slice(0, 7), verdict: "clean", contractComplete: true }),
495
+ });
496
+ const ok = result.gateBoundary === PR_CHECKPOINT.FINAL_APPROVAL_READY
497
+ && result.nextAction === PR_CHECKPOINT_ACTION.AWAIT_FINAL_HUMAN_APPROVAL;
498
+ return { ok, detail: result, result };
499
+ });
500
+
501
+ // final_gate_remediation -> final_local_preapproval_gate: re-running the gate after fixes is the same
502
+ // RUN_PRE_APPROVAL_GATE call as the fresh-entry case above (the code has no distinct "remediation"
503
+ // boundary, same as the draft-gate pair).
504
+ verified("final_gate_remediation->final_local_preapproval_gate", () => {
505
+ const result = run({
506
+ prDraft: false,
507
+ lifecycleState: STATE.READY_TO_REREQUEST_REVIEW,
508
+ ciStatus: "success",
509
+ sameHeadCleanConverged: true,
510
+ preApprovalGate: FINDINGS_GATE,
511
+ });
512
+ const ok = result.gateBoundary === PR_CHECKPOINT.PRE_APPROVAL_GATE_WINDOW
513
+ && result.nextAction === PR_CHECKPOINT_ACTION.RUN_PRE_APPROVAL_GATE;
514
+ return { ok, detail: result, result };
515
+ });
516
+
517
+ // waiting_for_human_pr_approval -> waiting_for_merge: approval arrives. External GitHub event; this
518
+ // function has no "approved" input and does not decide this transition.
519
+ PR_GATE_TRANSITION_CHECKS.set("waiting_for_human_pr_approval->waiting_for_merge", {
520
+ status: "external",
521
+ note: "Human approval is an external GitHub event; evaluatePrGateCoordination has no approved-state "
522
+ + "input and does not decide this transition (see pr-lifecycle-contract.md: \"Human approval / "
523
+ + "merge are explicit external waits\").",
524
+ });
525
+
526
+ // waiting_for_human_pr_approval -> draft_local_review_gate: PR reset to draft.
527
+ verified("waiting_for_human_pr_approval->draft_local_review_gate", () => {
528
+ const result = run({ prDraft: true, lifecycleState: STATE.PR_DRAFT, ciStatus: "success", draftGate: gate({ visible: false }) });
529
+ const ok = result.gateBoundary === PR_CHECKPOINT.DRAFT_REVIEW;
530
+ return { ok, detail: result, result };
531
+ });
532
+
533
+ // waiting_for_merge -> terminal_slice_complete: merged/closed and the PR lifecycle is complete.
534
+ verified("waiting_for_merge->terminal_slice_complete", () => {
535
+ const result = run({ prMerged: true, lifecycleState: STATE.DONE });
536
+ const ok = result.gateBoundary === PR_CHECKPOINT.DONE && result.nextAction === PR_CHECKPOINT_ACTION.REPORT_DONE;
537
+ return { ok, detail: result, result };
538
+ });
539
+
540
+ // copilot_feedback_remediation / copilot_reply_resolve_pending / merge_conflict_resolution's
541
+ // re-entry: these three transitions are decided by the copilot inner-loop state graph
542
+ // (packages/core/src/loop/copilot-loop-state.mjs TRANSITIONS), which is out of scope for this
543
+ // reference machine per #1148/#1156/#1157 — pr-gate-coordination only reacts to whichever STATE
544
+ // it is handed, it does not compute the STATE-to-STATE progression itself.
545
+ for (const key of [
546
+ "waiting_for_copilot_review->copilot_feedback_remediation",
547
+ "copilot_feedback_remediation->copilot_reply_resolve_pending",
548
+ "copilot_reply_resolve_pending->ready_state_needs_copilot_request",
549
+ "merge_conflict_resolution->waiting_for_copilot_review",
550
+ ]) {
551
+ PR_GATE_TRANSITION_CHECKS.set(key, {
552
+ status: "owned_elsewhere",
553
+ note: "State-to-state progression for the copilot inner review/fix loop is owned by "
554
+ + "copilot-loop-state.mjs's own STATE/TRANSITIONS table (#1156/#1157), not by "
555
+ + "pr-gate-coordination.mjs; this function only reacts to whichever STATE it is handed.",
556
+ });
557
+ }
558
+
559
+ const PR_GATE_COORDINATION_MACHINE = {
560
+ name: "pr-gate-coordination",
561
+ states: PR_LIFECYCLE_STATES,
562
+ terminalStates: PR_LIFECYCLE_TERMINAL_STATES,
563
+ transitions: PR_LIFECYCLE_TRANSITIONS,
564
+ docTransitions: PR_LIFECYCLE_DOC_TRANSITIONS,
565
+ transitionChecks: PR_GATE_TRANSITION_CHECKS,
566
+ safetyRules: [
567
+ {
568
+ // Analog of "no ->merged without both gates clean at head": final-approval readiness
569
+ // (this machine's closest reachable state to "ready to merge") requires both draft_gate and
570
+ // pre_approval_gate to be clean-at-head. The other half of the analog — DECLARE_MERGE_READY
571
+ // never appearing in any allowedNextActions (merge is external/human-only) — is asserted over
572
+ // the gathered observations in test/docs/validate-state-machine-conformance.test.mjs.
573
+ name: "no-final-approval-without-both-gates-clean",
574
+ check: (result) => result.nextAction !== PR_CHECKPOINT_ACTION.AWAIT_FINAL_HUMAN_APPROVAL
575
+ || (result.draftGate.cleanEvidenceExists && result.preApprovalGate.currentHeadClean),
576
+ },
577
+ {
578
+ // Analog of "fail-closed states never dispatch a Backlog pull": a blocked (fail-closed)
579
+ // result must never permit a gate-progressing action.
580
+ name: "blocked-never-permits-gate-progress",
581
+ check: (result) => result.gateBoundary !== PR_CHECKPOINT.BLOCKED
582
+ || !result.allowedNextActions.some((action) => [
583
+ PR_CHECKPOINT_ACTION.RUN_DRAFT_GATE,
584
+ PR_CHECKPOINT_ACTION.MARK_READY_FOR_REVIEW,
585
+ PR_CHECKPOINT_ACTION.REQUEST_COPILOT_REVIEW,
586
+ PR_CHECKPOINT_ACTION.RUN_PRE_APPROVAL_GATE,
587
+ PR_CHECKPOINT_ACTION.DECLARE_MERGE_READY,
588
+ ].includes(action)),
589
+ },
590
+ ],
591
+ };
592
+
593
+ registerMachine(PR_GATE_COORDINATION_MACHINE);
594
+
595
+ // ---------------------------------------------------------------------------
596
+ // Second machine (issue #1156): conductor-routing.
597
+ //
598
+ // Doc side: docs/conductor-routing-contract.md's "## Required transitions" bullets,
599
+ // parsed at load time via parseRequiredTransitions. The doc bullets one abstract row
600
+ // per non-terminal outer state ("`state` -> any outer state") because the outer-loop
601
+ // graph is stateless per cycle (every evaluation is independent, so a non-terminal
602
+ // outcome can be followed, next cycle, by any of the 7 outcomes) — the abstractRows
603
+ // map below expands each row to its 7 concrete edges.
604
+ //
605
+ // Code side: packages/core/src/loop/conductor-routing.mjs exports OUTER_STATE,
606
+ // OUTER_TERMINAL_STATES, and getAllowedOuterTransitions. Each expanded edge is checked
607
+ // by calling the real evaluateConductorRouting with a fixture drawn verbatim from the
608
+ // doc's own "Scenario matrix" (one fixture per reachable `to` state, reused across every
609
+ // `from`) and asserting getAllowedOuterTransitions(from) structurally allows `to` while
610
+ // the fixture's real routingOutcome matches `to` — a real characterization of the code,
611
+ // not a hand-copied assumption.
612
+ // ---------------------------------------------------------------------------
613
+
614
+ const OUTER_STATE_VALUES = Object.values(OUTER_STATE);
615
+ const OUTER_NON_TERMINAL_STATES = OUTER_STATE_VALUES.filter((s) => !OUTER_TERMINAL_STATES.includes(s));
616
+
617
+ const CONDUCTOR_ROUTING_ABSTRACT_ROWS = new Map(
618
+ OUTER_NON_TERMINAL_STATES.map((from) => [
619
+ `\`${from}\`->any outer state`,
620
+ OUTER_STATE_VALUES.map((to) => [from, to]),
621
+ ]),
622
+ );
623
+
624
+ const CONDUCTOR_ROUTING_DOC_TRANSITIONS = parseRequiredTransitions(
625
+ readFileSync(path.join(REPO_ROOT, "docs", "conductor-routing-contract.md"), "utf8"),
626
+ { abstractRows: CONDUCTOR_ROUTING_ABSTRACT_ROWS },
627
+ );
628
+
629
+ const TARGET = { repo: "acme/widgets", pr: 42 };
630
+
631
+ function routeOuter(input) {
632
+ return evaluateConductorRouting({ target: TARGET, ...input });
633
+ }
634
+
635
+ // One fixture per reachable outer state. Inputs are doc-described routing-policy
636
+ // combinations (mostly the doc's Scenario matrix; CONTINUE_CURRENT_WAIT and
637
+ // HANDOFF_TO_COPILOT_LOOP use equivalent doc-described input pairs where the
638
+ // literal scenario rows were repurposed for other states) — no invented behavior.
639
+ const OUTER_TO_FIXTURE = new Map([
640
+ [OUTER_STATE.CONTINUE_CURRENT_WAIT, () => routeOuter({ copilotState: "waiting_for_copilot_review", reviewerState: "waiting_for_author_followup" })],
641
+ [OUTER_STATE.HANDOFF_TO_COPILOT_LOOP, () => routeOuter({ copilotState: "pr_draft", reviewerState: "waiting_for_review_request" })],
642
+ [OUTER_STATE.HANDOFF_TO_REVIEWER_LOOP, () => routeOuter({ copilotState: "pr_ready_no_feedback", reviewerState: "review_requested" })],
643
+ [OUTER_STATE.STAY_WITH_CURRENT_LIVE_OWNER, () => routeOuter({ copilotState: "unresolved_feedback_present", reviewerState: "waiting_for_author_followup", ownershipState: "live_owner" })],
644
+ [OUTER_STATE.STOP_NEEDS_HUMAN, () => routeOuter({ copilotState: "blocked_needs_user_decision", reviewerState: "waiting_for_review_request" })],
645
+ [OUTER_STATE.DONE_TERMINAL, () => routeOuter({ copilotState: "done", reviewerState: "waiting_for_review_request" })],
646
+ [OUTER_STATE.NEEDS_RECONCILE, () => routeOuter({ copilotState: "some_unmapped_copilot_state", reviewerState: "some_unmapped_reviewer_state" })],
647
+ ]);
648
+
649
+ const CONDUCTOR_ROUTING_TRANSITION_CHECKS = new Map();
650
+ for (const from of OUTER_NON_TERMINAL_STATES) {
651
+ for (const to of OUTER_STATE_VALUES) {
652
+ CONDUCTOR_ROUTING_TRANSITION_CHECKS.set(`${from}->${to}`, {
653
+ status: "verified",
654
+ verify: () => {
655
+ const result = OUTER_TO_FIXTURE.get(to)();
656
+ const ok = result.routingOutcome === to && getAllowedOuterTransitions(from).includes(to);
657
+ return { ok, detail: result, result };
658
+ },
659
+ });
660
+ }
661
+ }
662
+
663
+ const CONDUCTOR_ROUTING_MACHINE = {
664
+ name: "conductor-routing",
665
+ states: OUTER_STATE_VALUES,
666
+ terminalStates: OUTER_TERMINAL_STATES,
667
+ transitions: OUTER_NON_TERMINAL_STATES.flatMap((from) => getAllowedOuterTransitions(from).map((to) => [from, to])),
668
+ docTransitions: CONDUCTOR_ROUTING_DOC_TRANSITIONS,
669
+ transitionChecks: CONDUCTOR_ROUTING_TRANSITION_CHECKS,
670
+ safetyRules: [
671
+ {
672
+ // Analog of "fail-closed states never dispatch a Backlog pull" (epic #1104 / docs:
673
+ // ROUTING-FAIL-CLOSED-RECONCILE): a fail-closed observation never carries a live handoff.
674
+ name: "fail-closed-no-dispatch",
675
+ check: (result) => (result.routingOutcome !== OUTER_STATE.STOP_NEEDS_HUMAN && result.routingOutcome !== OUTER_STATE.NEEDS_RECONCILE)
676
+ || (result.handoffEnvelope.loopFamily === null && result.handoffEnvelope.entrypoint === null),
677
+ },
678
+ ],
679
+ };
680
+
681
+ registerMachine(CONDUCTOR_ROUTING_MACHINE);
682
+
683
+ // ---------------------------------------------------------------------------
684
+ // Third machine (issue #1157): copilot-loop-state.
685
+ //
686
+ // Doc side: docs/copilot-loop-state-graph.md's "## Required transitions" bullets,
687
+ // parsed at load time via parseRequiredTransitions.
688
+ //
689
+ // Code side: packages/core/src/loop/copilot-loop-state.mjs exports STATE, TRANSITIONS,
690
+ // and interpretLoopState. Both STATE/TRANSITIONS are a plain lookup table (there is no
691
+ // "evaluate a from->to edge" function), so each doc transition is checked two ways: (1)
692
+ // structurally, TRANSITIONS[from] must include `to` (the real code table, not a hand-copied
693
+ // assumption); (2) behaviorally, a real snapshot fixture reaching `to` is run through the
694
+ // real interpretLoopState and must actually land on `to` — one fixture per reachable `to`
695
+ // state, reused across every `from` that doc-declares an edge into it (same pattern as
696
+ // conductor-routing's OUTER_TO_FIXTURE).
697
+ // ---------------------------------------------------------------------------
698
+
699
+ // Independent doc<->code binding required by the parseRequiredTransitions header caveat:
700
+ // the transitionChecks below are DERIVED from the parsed doc, so on their own they are
701
+ // one-directional (a silently dropped/deleted doc bullet would just shrink both tables in
702
+ // lockstep and keep passing). This load-time set-equality against the code's own transition
703
+ // table (mirroring pr-gate-coordination's atlas binding) makes any dropped, mangled, or
704
+ // deleted bullet throw loudly.
705
+ function bindDocToCodeTable(machineName, docTransitions, codeEdges) {
706
+ const docSet = new Set(realEdges(docTransitions).map(([a, b]) => `${a}->${b}`));
707
+ const codeSet = new Set(codeEdges.map(([a, b]) => `${a}->${b}`));
708
+ const onlyDoc = [...docSet].filter((k) => !codeSet.has(k));
709
+ const onlyCode = [...codeSet].filter((k) => !docSet.has(k));
710
+ if (onlyDoc.length > 0 || onlyCode.length > 0) {
711
+ throw new Error(
712
+ `${machineName}: doc "Required transitions" and the code transition table have drifted apart. `
713
+ + `Only in doc: [${onlyDoc.join(", ")}]. Only in code: [${onlyCode.join(", ")}].`,
714
+ );
715
+ }
716
+ }
717
+
718
+ function tableEdges(table) {
719
+ return Object.entries(table).flatMap(([from, tos]) => tos.map((to) => [from, to]));
720
+ }
721
+
722
+ function tableTerminalStates(table) {
723
+ return Object.entries(table).filter(([, tos]) => tos.length === 0).map(([state]) => state);
724
+ }
725
+
726
+ const COPILOT_LOOP_STATE_DOC_TRANSITIONS = parseRequiredTransitions(
727
+ readFileSync(path.join(REPO_ROOT, "docs", "copilot-loop-state-graph.md"), "utf8"),
728
+ );
729
+
730
+ bindDocToCodeTable("copilot-loop-state", COPILOT_LOOP_STATE_DOC_TRANSITIONS, tableEdges(TRANSITIONS));
731
+
732
+ const COPILOT_LOOP_STATE_TO_FIXTURE = new Map([
733
+ [STATE.PR_READY_NO_FEEDBACK, () => ({ prExists: true, prDraft: false, copilotReviewRequestStatus: "none", copilotReviewPresent: false, unresolvedThreadCount: 0, ciStatus: "success" })],
734
+ [STATE.WAITING_FOR_COPILOT_REVIEW, () => ({ prExists: true, prDraft: false, copilotReviewRequestStatus: "requested", unresolvedThreadCount: 0 })],
735
+ [STATE.UNRESOLVED_FEEDBACK_PRESENT, () => ({ prExists: true, prDraft: false, unresolvedThreadCount: 2, agentFixStatus: null })],
736
+ [STATE.ALREADY_FIXED_NEEDS_REPLY_RESOLVE, () => ({ prExists: true, prDraft: false, unresolvedThreadCount: 2, agentFixStatus: "applied" })],
737
+ [STATE.READY_TO_REREQUEST_REVIEW, () => ({ prExists: true, prDraft: false, copilotReviewPresent: true, unresolvedThreadCount: 0, ciStatus: "success", copilotReviewRequestStatus: "none" })],
738
+ [STATE.WAITING_FOR_CI, () => ({ prExists: true, prDraft: false, copilotReviewPresent: true, unresolvedThreadCount: 0, ciStatus: "pending", copilotReviewRequestStatus: "none" })],
739
+ [STATE.BLOCKED_NEEDS_USER_DECISION, () => ({ prExists: true, prDraft: false, copilotReviewRequestStatus: "failed" })],
740
+ [STATE.REVIEW_REQUEST_UNAVAILABLE, () => ({ prExists: true, prDraft: false, copilotReviewRequestStatus: "unavailable" })],
741
+ [STATE.DONE, () => ({ prExists: true, prMerged: true })],
742
+ ]);
743
+
744
+ const COPILOT_LOOP_STATE_TRANSITION_CHECKS = new Map();
745
+ for (const [from, to] of realEdges(COPILOT_LOOP_STATE_DOC_TRANSITIONS)) {
746
+ const buildFixture = COPILOT_LOOP_STATE_TO_FIXTURE.get(to);
747
+ if (!buildFixture) throw new Error(`copilot-loop-state: no fixture registered for reachable state "${to}"`);
748
+ COPILOT_LOOP_STATE_TRANSITION_CHECKS.set(`${from}->${to}`, {
749
+ status: "verified",
750
+ verify: () => {
751
+ const fixture = buildFixture();
752
+ const interpretation = interpretLoopState(fixture);
753
+ const ok = interpretation.state === to && (TRANSITIONS[from] || []).includes(to);
754
+ return {
755
+ ok,
756
+ detail: interpretation,
757
+ result: {
758
+ state: interpretation.state,
759
+ unresolvedThreadCount: fixture.unresolvedThreadCount ?? 0,
760
+ copilotReviewRequestStatus: fixture.copilotReviewRequestStatus ?? "none",
761
+ },
762
+ };
763
+ },
764
+ });
765
+ }
766
+
767
+ const COPILOT_LOOP_STATE_MACHINE = {
768
+ name: "copilot-loop-state",
769
+ states: Object.values(STATE),
770
+ terminalStates: tableTerminalStates(TRANSITIONS),
771
+ transitions: tableEdges(TRANSITIONS),
772
+ docTransitions: COPILOT_LOOP_STATE_DOC_TRANSITIONS,
773
+ transitionChecks: COPILOT_LOOP_STATE_TRANSITION_CHECKS,
774
+ safetyRules: [
775
+ {
776
+ // Analog of "fail-closed states never dispatch a Backlog pull" for this machine
777
+ // (docs: COPILOT-STATE-UNRESOLVED-PRIORITY): unresolved feedback must land in a
778
+ // fix/reply-resolve state — stronger than merely "not a wait state", so a
779
+ // regression routing unresolved feedback anywhere else also trips this rule.
780
+ name: "unresolved-feedback-lands-in-fix-states",
781
+ check: (observation) => observation.unresolvedThreadCount === 0
782
+ || observation.state === STATE.UNRESOLVED_FEEDBACK_PRESENT
783
+ || observation.state === STATE.ALREADY_FIXED_NEEDS_REPLY_RESOLVE,
784
+ },
785
+ ],
786
+ };
787
+
788
+ registerMachine(COPILOT_LOOP_STATE_MACHINE);
789
+
790
+ // ---------------------------------------------------------------------------
791
+ // Fourth machine (issue #1157): reviewer-loop-state.
792
+ //
793
+ // Doc side: docs/reviewer-loop-state-graph.md's "## Required transitions" bullets, with
794
+ // three abstract rows expanded via REVIEWER_LOOP_STATE_ABSTRACT_ROWS below. The interpreter
795
+ // checks its {!prExists, prMerged, prClosed, prDraft} pre-gates first (those snapshots stay
796
+ // `waiting_for_review_request`); only past those pre-gates does the reviewSubmissionStatus
797
+ // guard fire, ahead of the state-specific branches — `"failed"` fail-closes into
798
+ // `blocked_needs_user_decision`, `"submitted"` outranks the pre-posted local-metadata
799
+ // branches into `submitted_review`. The two submission-guard abstract rows cover exactly the
800
+ // pre-gate-passing states not already carried by the active-pass row / concrete bullets.
801
+ //
802
+ // Code side: packages/core/src/loop/reviewer-loop-state.mjs exports REVIEWER_STATE,
803
+ // REVIEWER_TRANSITIONS, and interpretReviewerLoopState. Checked the same two ways as
804
+ // copilot-loop-state above. `waiting_for_author_followup` / `waiting_for_re_request` are
805
+ // legacy compatibility states `interpretReviewerLoopState` never assigns as its own output
806
+ // (see the doc's note); their re-entry transitions are owned_elsewhere (the outer-loop
807
+ // compatibility layer), not by this pure interpreter.
808
+ // ---------------------------------------------------------------------------
809
+
810
+ const REVIEWER_LOOP_STATE_ABSTRACT_ROWS = new Map([
811
+ ["any active reviewer-pass state->`blocked_needs_user_decision`", [
812
+ [REVIEWER_STATE.REVIEW_REQUESTED, REVIEWER_STATE.BLOCKED_NEEDS_USER_DECISION],
813
+ [REVIEWER_STATE.DETERMINE_REVIEW_PLAN, REVIEWER_STATE.BLOCKED_NEEDS_USER_DECISION],
814
+ [REVIEWER_STATE.REVIEWS_RUNNING, REVIEWER_STATE.BLOCKED_NEEDS_USER_DECISION],
815
+ [REVIEWER_STATE.MERGE_RESULTS, REVIEWER_STATE.BLOCKED_NEEDS_USER_DECISION],
816
+ [REVIEWER_STATE.DRAFT_REVIEW_READY, REVIEWER_STATE.BLOCKED_NEEDS_USER_DECISION],
817
+ ]],
818
+ // The reviewSubmissionStatus:"failed" guard fails closed from every pre-gate-passing
819
+ // state; the active-pass row above lists five, these are the remaining five.
820
+ ["any submission-failure-eligible state->`blocked_needs_user_decision`", [
821
+ [REVIEWER_STATE.WAITING_FOR_REVIEW_REQUEST, REVIEWER_STATE.BLOCKED_NEEDS_USER_DECISION],
822
+ [REVIEWER_STATE.DRAFT_REVIEW_POSTED, REVIEWER_STATE.BLOCKED_NEEDS_USER_DECISION],
823
+ [REVIEWER_STATE.WAITING_FOR_USER_SUBMIT, REVIEWER_STATE.BLOCKED_NEEDS_USER_DECISION],
824
+ [REVIEWER_STATE.REVIEW_INVALIDATED, REVIEWER_STATE.BLOCKED_NEEDS_USER_DECISION],
825
+ [REVIEWER_STATE.SUBMITTED_REVIEW, REVIEWER_STATE.BLOCKED_NEEDS_USER_DECISION],
826
+ ]],
827
+ // The reviewSubmissionStatus:"submitted" guard routes the pre-posted local-metadata
828
+ // branches into submitted_review (draft_review_posted/waiting_for_user_submit reach it
829
+ // via their own concrete bullets, so they are not repeated here).
830
+ ["any pre-posted submission-settled state->`submitted_review`", [
831
+ [REVIEWER_STATE.WAITING_FOR_REVIEW_REQUEST, REVIEWER_STATE.SUBMITTED_REVIEW],
832
+ [REVIEWER_STATE.REVIEW_REQUESTED, REVIEWER_STATE.SUBMITTED_REVIEW],
833
+ [REVIEWER_STATE.DETERMINE_REVIEW_PLAN, REVIEWER_STATE.SUBMITTED_REVIEW],
834
+ [REVIEWER_STATE.REVIEWS_RUNNING, REVIEWER_STATE.SUBMITTED_REVIEW],
835
+ [REVIEWER_STATE.MERGE_RESULTS, REVIEWER_STATE.SUBMITTED_REVIEW],
836
+ [REVIEWER_STATE.DRAFT_REVIEW_READY, REVIEWER_STATE.SUBMITTED_REVIEW],
837
+ ]],
838
+ ]);
839
+
840
+ const REVIEWER_LOOP_STATE_DOC_TRANSITIONS = parseRequiredTransitions(
841
+ readFileSync(path.join(REPO_ROOT, "docs", "reviewer-loop-state-graph.md"), "utf8"),
842
+ { abstractRows: REVIEWER_LOOP_STATE_ABSTRACT_ROWS },
843
+ );
844
+
845
+ bindDocToCodeTable("reviewer-loop-state", REVIEWER_LOOP_STATE_DOC_TRANSITIONS, tableEdges(REVIEWER_TRANSITIONS));
846
+
847
+ // Transitions owned by the outer-loop legacy-compatibility layer, not by this pure
848
+ // interpreter (see header comment): interpretReviewerLoopState never assigns
849
+ // WAITING_FOR_AUTHOR_FOLLOWUP or WAITING_FOR_RE_REQUEST as its own output state, so no
850
+ // fixture can characterize a real "from" call landing on either of them.
851
+ const REVIEWER_LOOP_STATE_OWNED_ELSEWHERE_EDGES = [
852
+ [REVIEWER_STATE.WAITING_FOR_AUTHOR_FOLLOWUP, REVIEWER_STATE.SUBMITTED_REVIEW],
853
+ [REVIEWER_STATE.WAITING_FOR_AUTHOR_FOLLOWUP, REVIEWER_STATE.REVIEW_REQUESTED],
854
+ [REVIEWER_STATE.WAITING_FOR_AUTHOR_FOLLOWUP, REVIEWER_STATE.WAITING_FOR_REVIEW_REQUEST],
855
+ [REVIEWER_STATE.WAITING_FOR_RE_REQUEST, REVIEWER_STATE.REVIEW_REQUESTED],
856
+ [REVIEWER_STATE.WAITING_FOR_RE_REQUEST, REVIEWER_STATE.SUBMITTED_REVIEW],
857
+ ];
858
+
859
+ const REVIEWER_LOOP_STATE_TO_FIXTURE = new Map([
860
+ [REVIEWER_STATE.REVIEW_REQUESTED, () => ({ prExists: true, prDraft: false, reviewRequested: true })],
861
+ [REVIEWER_STATE.DETERMINE_REVIEW_PLAN, () => ({ prExists: true, prDraft: false, localPlanningStatus: "determining" })],
862
+ [REVIEWER_STATE.REVIEWS_RUNNING, () => ({ prExists: true, prDraft: false, localReviewRunsStatus: "running" })],
863
+ [REVIEWER_STATE.MERGE_RESULTS, () => ({ prExists: true, prDraft: false, localReviewRunsStatus: "completed" })],
864
+ [REVIEWER_STATE.DRAFT_REVIEW_READY, () => ({ prExists: true, prDraft: false, draftReviewPrepared: true })],
865
+ [REVIEWER_STATE.DRAFT_REVIEW_POSTED, () => ({ prExists: true, prDraft: false, draftReviewPosted: true, draftReviewNotificationStatus: "none", prHeadSha: "abc1234", draftReviewCommitSha: "abc1234" })],
866
+ [REVIEWER_STATE.WAITING_FOR_USER_SUBMIT, () => ({ prExists: true, prDraft: false, draftReviewPosted: true, draftReviewNotificationStatus: "notified", prHeadSha: "abc1234", draftReviewCommitSha: "abc1234" })],
867
+ [REVIEWER_STATE.SUBMITTED_REVIEW, () => ({ prExists: true, prDraft: false, submittedReviewPresent: true, prHeadSha: "abc1234", submittedReviewCommitSha: "abc1234", reviewRequested: false })],
868
+ [REVIEWER_STATE.REVIEW_INVALIDATED, () => ({ prExists: true, prDraft: false, draftReviewPosted: true, prHeadSha: "def5678", draftReviewCommitSha: "abc1234" })],
869
+ [REVIEWER_STATE.WAITING_FOR_REVIEW_REQUEST, () => ({ prExists: false })],
870
+ // Four variants — one per failure field the interpreter fail-closes on
871
+ // (reviewer-loop-state.mjs routes any of them to BLOCKED identically), so the
872
+ // fail-closed safety rule exercises every failure signal, not just planning.
873
+ [REVIEWER_STATE.BLOCKED_NEEDS_USER_DECISION, () => [
874
+ { prExists: true, prDraft: false, localPlanningStatus: "failed" },
875
+ { prExists: true, prDraft: false, localReviewRunsStatus: "failed" },
876
+ { prExists: true, prDraft: false, localMergeStatus: "failed" },
877
+ { prExists: true, prDraft: false, reviewSubmissionStatus: "failed" },
878
+ ]],
879
+ ]);
880
+
881
+ const REVIEWER_FAILURE_FIELDS = ["localPlanningStatus", "localReviewRunsStatus", "localMergeStatus", "reviewSubmissionStatus"];
882
+
883
+ const REVIEWER_LOOP_STATE_TRANSITION_CHECKS = new Map();
884
+ for (const [from, to] of REVIEWER_LOOP_STATE_OWNED_ELSEWHERE_EDGES) {
885
+ // owned_elsewhere skips the behavioral verify(), but the cheap structural check still
886
+ // applies: the edge must exist in the real code table, so removing it from
887
+ // REVIEWER_TRANSITIONS surfaces here instead of passing silently.
888
+ if (!(REVIEWER_TRANSITIONS[from] || []).includes(to)) {
889
+ throw new Error(`reviewer-loop-state: owned_elsewhere edge ${from}->${to} is not in REVIEWER_TRANSITIONS`);
890
+ }
891
+ REVIEWER_LOOP_STATE_TRANSITION_CHECKS.set(`${from}->${to}`, {
892
+ status: "owned_elsewhere",
893
+ note: "Legacy external-wait compatibility state re-entry is owned by the outer-loop "
894
+ + "compatibility layer, not by interpretReviewerLoopState, which never assigns "
895
+ + `"${from}" as its own output state (see docs/reviewer-loop-state-graph.md).`,
896
+ });
897
+ }
898
+ for (const [from, to] of realEdges(REVIEWER_LOOP_STATE_DOC_TRANSITIONS)) {
899
+ const key = `${from}->${to}`;
900
+ if (REVIEWER_LOOP_STATE_TRANSITION_CHECKS.has(key)) continue;
901
+ const buildFixture = REVIEWER_LOOP_STATE_TO_FIXTURE.get(to);
902
+ if (!buildFixture) throw new Error(`reviewer-loop-state: no fixture registered for reachable state "${to}"`);
903
+ REVIEWER_LOOP_STATE_TRANSITION_CHECKS.set(key, {
904
+ status: "verified",
905
+ verify: () => {
906
+ const built = buildFixture();
907
+ const fixtures = Array.isArray(built) ? built : [built];
908
+ const interpretations = fixtures.map((f) => interpretReviewerLoopState(f));
909
+ const ok = interpretations.every((i) => i.state === to) && (REVIEWER_TRANSITIONS[from] || []).includes(to);
910
+ return {
911
+ ok,
912
+ detail: interpretations.length === 1 ? interpretations[0] : interpretations,
913
+ result: fixtures.map((fixture, i) => ({
914
+ state: interpretations[i].state,
915
+ failed: REVIEWER_FAILURE_FIELDS.some((field) => fixture[field] === "failed"),
916
+ })),
917
+ };
918
+ },
919
+ });
920
+ }
921
+
922
+ const REVIEWER_LOOP_STATE_MACHINE = {
923
+ name: "reviewer-loop-state",
924
+ states: Object.values(REVIEWER_STATE),
925
+ terminalStates: tableTerminalStates(REVIEWER_TRANSITIONS),
926
+ transitions: tableEdges(REVIEWER_TRANSITIONS),
927
+ docTransitions: REVIEWER_LOOP_STATE_DOC_TRANSITIONS,
928
+ transitionChecks: REVIEWER_LOOP_STATE_TRANSITION_CHECKS,
929
+ safetyRules: [
930
+ {
931
+ // Analog of "fail-closed states never dispatch a Backlog pull" for this machine:
932
+ // any local-status failure must fail closed into blocked_needs_user_decision, never
933
+ // silently proceed into a draft/submit/wait branch.
934
+ name: "local-failure-always-fails-closed",
935
+ check: (observation) => !observation.failed || observation.state === REVIEWER_STATE.BLOCKED_NEEDS_USER_DECISION,
936
+ },
937
+ ],
938
+ };
939
+
940
+ registerMachine(REVIEWER_LOOP_STATE_MACHINE);
941
+
942
+ // ---------------------------------------------------------------------------
943
+ // Fifth machine (issue #1267): refinement-grill-state.
944
+ //
945
+ // Doc side: docs/refinement-grill-state-graph.md's "## Required transitions" bullets, with
946
+ // one abstract row expanded via REFINEMENT_GRILL_ABSTRACT_ROWS below (the fail-closed
947
+ // load/parse failure edge fires from every non-terminal state into
948
+ // blocked_needs_user_decision). The interpreter checks loadFailed first (fail closed), then
949
+ // the unresolvedGapCount handoff (honest handoff outranks synthesis), then the
950
+ // iterate-to-clean branches.
951
+ //
952
+ // Code side: packages/core/src/loop/refinement-grill-state.mjs exports GRILL_STATE,
953
+ // GRILL_TRANSITIONS, and interpretRefinementGrillState. Checked the same two ways as
954
+ // copilot-loop-state / reviewer-loop-state above: structurally (the edge exists in the real
955
+ // GRILL_TRANSITIONS table) and behaviorally (a fixture reaching `to` lands on `to`).
956
+ // ---------------------------------------------------------------------------
957
+
958
+ const REFINEMENT_GRILL_ABSTRACT_ROWS = new Map([
959
+ ["any non-terminal grill state->`blocked_needs_user_decision`", [
960
+ [GRILL_STATE.LOAD_TARGET, GRILL_STATE.BLOCKED_NEEDS_USER_DECISION],
961
+ [GRILL_STATE.DETECT_GAPS, GRILL_STATE.BLOCKED_NEEDS_USER_DECISION],
962
+ [GRILL_STATE.AWAIT_ANSWERS, GRILL_STATE.BLOCKED_NEEDS_USER_DECISION],
963
+ [GRILL_STATE.SYNTHESIZE, GRILL_STATE.BLOCKED_NEEDS_USER_DECISION],
964
+ [GRILL_STATE.RE_GRILL, GRILL_STATE.BLOCKED_NEEDS_USER_DECISION],
965
+ ]],
966
+ ]);
967
+
968
+ const REFINEMENT_GRILL_DOC_TRANSITIONS = parseRequiredTransitions(
969
+ readFileSync(path.join(REPO_ROOT, "docs", "refinement-grill-state-graph.md"), "utf8"),
970
+ { abstractRows: REFINEMENT_GRILL_ABSTRACT_ROWS },
971
+ );
972
+
973
+ bindDocToCodeTable("refinement-grill-state", REFINEMENT_GRILL_DOC_TRANSITIONS, tableEdges(GRILL_TRANSITIONS));
974
+
975
+ const REFINEMENT_GRILL_TO_FIXTURE = new Map([
976
+ [GRILL_STATE.DETECT_GAPS, () => ({ loaded: true, detectRan: false })],
977
+ [GRILL_STATE.AWAIT_ANSWERS, () => ({ loaded: true, detectRan: true, openGapCount: 2 })],
978
+ [GRILL_STATE.SYNTHESIZE, () => ({ loaded: true, detectRan: true, answersReady: true })],
979
+ [GRILL_STATE.RE_GRILL, () => ({ loaded: true, synthesized: true, reGrillRan: false })],
980
+ [GRILL_STATE.GRILL_CLEAN, () => ({ loaded: true, detectRan: true, openGapCount: 0 })],
981
+ [GRILL_STATE.NEEDS_HUMAN_HANDOFF, () => ({ loaded: true, detectRan: true, unresolvedGapCount: 1 })],
982
+ [GRILL_STATE.BLOCKED_NEEDS_USER_DECISION, () => ({ loadFailed: true })],
983
+ ]);
984
+
985
+ const REFINEMENT_GRILL_TRANSITION_CHECKS = new Map();
986
+ for (const [from, to] of realEdges(REFINEMENT_GRILL_DOC_TRANSITIONS)) {
987
+ const buildFixture = REFINEMENT_GRILL_TO_FIXTURE.get(to);
988
+ if (!buildFixture) throw new Error(`refinement-grill-state: no fixture registered for reachable state "${to}"`);
989
+ REFINEMENT_GRILL_TRANSITION_CHECKS.set(`${from}->${to}`, {
990
+ status: "verified",
991
+ verify: () => {
992
+ const f = buildFixture();
993
+ const i = interpretRefinementGrillState(f);
994
+ return {
995
+ ok: i.state === to && (GRILL_TRANSITIONS[from] || []).includes(to),
996
+ detail: i,
997
+ result: { state: i.state, loadFailed: Boolean(f.loadFailed), hasUnresolved: (f.unresolvedGapCount || 0) > 0 },
998
+ };
999
+ },
1000
+ });
1001
+ }
1002
+
1003
+ const REFINEMENT_GRILL_STATE_MACHINE = {
1004
+ name: "refinement-grill-state",
1005
+ states: Object.values(GRILL_STATE),
1006
+ terminalStates: tableTerminalStates(GRILL_TRANSITIONS),
1007
+ transitions: tableEdges(GRILL_TRANSITIONS),
1008
+ docTransitions: REFINEMENT_GRILL_DOC_TRANSITIONS,
1009
+ transitionChecks: REFINEMENT_GRILL_TRANSITION_CHECKS,
1010
+ safetyRules: [
1011
+ { name: "grill-load-failure-fails-closed", check: (o) => !o.loadFailed || o.state === GRILL_STATE.BLOCKED_NEEDS_USER_DECISION },
1012
+ { name: "grill-unresolved-gap-hands-off", check: (o) => !o.hasUnresolved || o.loadFailed || o.state === GRILL_STATE.NEEDS_HUMAN_HANDOFF },
1013
+ ],
1014
+ };
1015
+
1016
+ registerMachine(REFINEMENT_GRILL_STATE_MACHINE);
1017
+
1018
+ // ---------------------------------------------------------------------------
1019
+ // Sixth machine (issue #1233): public-dev-loop-routing.
1020
+ //
1021
+ // Doc side: skills/docs/public-dev-loop-contract.md's "## Required transitions" bullets,
1022
+ // parsed at load time. Like conductor-routing, the gate graph is stateless per cycle, so each
1023
+ // non-terminal (route/wait) gate bullets one abstract row "`gate` -> any dev-loop gate",
1024
+ // expanded below to concrete edges into all gates.
1025
+ //
1026
+ // Code side: packages/core/src/loop/public-dev-loop-routing.mjs exports DEV_LOOP_GATE,
1027
+ // PUBLIC_DEV_LOOP_GATE_CONTRACT, and evaluatePublicDevLoopRouting. Non-terminal vs terminal is
1028
+ // DERIVED from the gate contract's routeKind (route/wait = non-terminal; stop/needs_reconcile =
1029
+ // terminal), so a gate reclassification surfaces here instead of drifting silently. Each edge is
1030
+ // checked by calling the real evaluator with a fixture (drawn from the routing unit tests) that
1031
+ // produces the `to` gate and asserting selectedGate === to.
1032
+ // ---------------------------------------------------------------------------
1033
+
1034
+ const DEV_LOOP_GATE_VALUES = Object.values(DEV_LOOP_GATE);
1035
+ const DEV_LOOP_GATE_ROUTE_KIND = new Map(PUBLIC_DEV_LOOP_GATE_CONTRACT.map((entry) => [entry.gate, entry.routeKind]));
1036
+ const DEV_LOOP_TERMINAL_ROUTE_KINDS = new Set([DEV_LOOP_ROUTE_KIND.STOP, DEV_LOOP_ROUTE_KIND.NEEDS_RECONCILE]);
1037
+ const PUBLIC_DEV_LOOP_TERMINAL_GATES = DEV_LOOP_GATE_VALUES.filter((g) => DEV_LOOP_TERMINAL_ROUTE_KINDS.has(DEV_LOOP_GATE_ROUTE_KIND.get(g)));
1038
+ const PUBLIC_DEV_LOOP_NON_TERMINAL_GATES = DEV_LOOP_GATE_VALUES.filter((g) => !DEV_LOOP_TERMINAL_ROUTE_KINDS.has(DEV_LOOP_GATE_ROUTE_KIND.get(g)));
1039
+
1040
+ const PUBLIC_DEV_LOOP_ROUTING_ABSTRACT_ROWS = new Map(
1041
+ PUBLIC_DEV_LOOP_NON_TERMINAL_GATES.map((from) => [
1042
+ `\`${from}\`->any dev-loop gate`,
1043
+ DEV_LOOP_GATE_VALUES.map((to) => [from, to]),
1044
+ ]),
1045
+ );
1046
+
1047
+ const PUBLIC_DEV_LOOP_ROUTING_DOC_TRANSITIONS = parseRequiredTransitions(
1048
+ readFileSync(path.join(REPO_ROOT, "skills", "docs", "public-dev-loop-contract.md"), "utf8"),
1049
+ { abstractRows: PUBLIC_DEV_LOOP_ROUTING_ABSTRACT_ROWS },
1050
+ );
1051
+
1052
+ const PUBLIC_DEV_LOOP_ROUTING_TRANSITIONS = PUBLIC_DEV_LOOP_NON_TERMINAL_GATES.flatMap((from) => DEV_LOOP_GATE_VALUES.map((to) => [from, to]));
1053
+
1054
+ // Independent doc<->code binding (parseRequiredTransitions header caveat): a dropped or
1055
+ // renamed doc bullet throws loudly here instead of silently shrinking both tables in lockstep.
1056
+ bindDocToCodeTable("public-dev-loop-routing", PUBLIC_DEV_LOOP_ROUTING_DOC_TRANSITIONS, PUBLIC_DEV_LOOP_ROUTING_TRANSITIONS);
1057
+
1058
+ // Clean current-head pre_approval_gate evidence (inlined from the routing unit tests' helper).
1059
+ const PUBLIC_DEV_LOOP_CLEAN_PREAPPROVAL_EVIDENCE = {
1060
+ currentHeadSha: "abc1234",
1061
+ preApprovalGate: { visible: true, headSha: "abc1234", verdict: "clean" },
1062
+ };
1063
+
1064
+ const PUBLIC_DEV_LOOP_PR_TARGET = { kind: DEV_LOOP_TARGET_KIND.PR, pr: 88 };
1065
+ function publicDevLoopPrState(overrides = {}) {
1066
+ return {
1067
+ target: PUBLIC_DEV_LOOP_PR_TARGET,
1068
+ ownership: DEV_LOOP_ACTOR.COPILOT,
1069
+ nextActor: DEV_LOOP_ACTOR.COPILOT,
1070
+ status: DEV_LOOP_STATUS.ACTIVE,
1071
+ authorization: DEV_LOOP_AUTHORIZATION.NEEDS_CONFIRMATION,
1072
+ ...overrides,
1073
+ };
1074
+ }
1075
+ const PUBLIC_DEV_LOOP_GITHUB_FIRST = DEV_LOOP_TARGET_PREFERENCE.PREFER_GITHUB_FIRST;
1076
+
1077
+ // One fixture per reachable gate. Inputs are drawn from the routing unit tests
1078
+ // (packages/core/test/public-dev-loop-routing-routing.test.mjs) — no invented behavior.
1079
+ const PUBLIC_DEV_LOOP_GATE_TO_FIXTURE = new Map([
1080
+ [DEV_LOOP_GATE.STOP_BLOCKED_OR_NOT_AUTHORIZED, () => evaluatePublicDevLoopRouting({ intent: DEV_LOOP_PUBLIC_INTENT.CONTINUE_CURRENT, currentState: publicDevLoopPrState({ status: DEV_LOOP_STATUS.BLOCKED }), targetPreference: PUBLIC_DEV_LOOP_GITHUB_FIRST })],
1081
+ [DEV_LOOP_GATE.STOP_DONE_TERMINAL, () => evaluatePublicDevLoopRouting({ intent: DEV_LOOP_PUBLIC_INTENT.CONTINUE_CURRENT, currentState: publicDevLoopPrState({ status: DEV_LOOP_STATUS.DONE, authorization: DEV_LOOP_AUTHORIZATION.AUTHORIZED }), targetPreference: PUBLIC_DEV_LOOP_GITHUB_FIRST })],
1082
+ [DEV_LOOP_GATE.FINAL_APPROVAL, () => evaluatePublicDevLoopRouting({ intent: DEV_LOOP_PUBLIC_INTENT.CONTINUE_CURRENT, currentState: publicDevLoopPrState({ ownership: DEV_LOOP_ACTOR.MAINTAINER, nextActor: DEV_LOOP_ACTOR.MAINTAINER, status: DEV_LOOP_STATUS.APPROVAL_READY, authorization: DEV_LOOP_AUTHORIZATION.AUTHORIZED }), gateReviewEvidence: PUBLIC_DEV_LOOP_CLEAN_PREAPPROVAL_EVIDENCE, targetPreference: PUBLIC_DEV_LOOP_GITHUB_FIRST })],
1083
+ [DEV_LOOP_GATE.WAITING_FOR_MERGE_AUTHORIZATION, () => evaluatePublicDevLoopRouting({ intent: DEV_LOOP_PUBLIC_INTENT.CONTINUE_CURRENT, currentState: publicDevLoopPrState({ ownership: DEV_LOOP_ACTOR.MAINTAINER, nextActor: DEV_LOOP_ACTOR.MAINTAINER, status: DEV_LOOP_STATUS.MERGE_READY, authorization: DEV_LOOP_AUTHORIZATION.NEEDS_CONFIRMATION }), gateReviewEvidence: PUBLIC_DEV_LOOP_CLEAN_PREAPPROVAL_EVIDENCE, targetPreference: PUBLIC_DEV_LOOP_GITHUB_FIRST })],
1084
+ [DEV_LOOP_GATE.WAIT_WATCH, () => evaluatePublicDevLoopRouting({ intent: DEV_LOOP_PUBLIC_INTENT.CONTINUE_CURRENT, currentState: publicDevLoopPrState({ status: DEV_LOOP_STATUS.WAITING }), targetPreference: PUBLIC_DEV_LOOP_GITHUB_FIRST })],
1085
+ [DEV_LOOP_GATE.LOCAL_IMPLEMENTATION, () => evaluatePublicDevLoopRouting({ intent: DEV_LOOP_PUBLIC_INTENT.CONTINUE_CURRENT, currentState: { target: { kind: DEV_LOOP_TARGET_KIND.LOCAL_BRANCH, branch: "feature/issue-86" }, ownership: DEV_LOOP_ACTOR.LOCAL, nextActor: DEV_LOOP_ACTOR.LOCAL, status: DEV_LOOP_STATUS.ACTIVE, authorization: DEV_LOOP_AUTHORIZATION.AUTHORIZED } })],
1086
+ [DEV_LOOP_GATE.ISSUE_INTAKE, () => evaluatePublicDevLoopRouting({ intent: DEV_LOOP_PUBLIC_INTENT.START_ON_ISSUE, target: { kind: DEV_LOOP_TARGET_KIND.ISSUE, issue: 86 }, targetPreference: PUBLIC_DEV_LOOP_GITHUB_FIRST })],
1087
+ [DEV_LOOP_GATE.EXTERNAL_PR_FOLLOWUP, () => evaluatePublicDevLoopRouting({ intent: DEV_LOOP_PUBLIC_INTENT.CONTINUE_CURRENT, currentState: publicDevLoopPrState({ target: { kind: DEV_LOOP_TARGET_KIND.PR, pr: 91 }, ownership: DEV_LOOP_ACTOR.EXTERNAL_HUMAN, nextActor: DEV_LOOP_ACTOR.REVIEWER }), targetPreference: PUBLIC_DEV_LOOP_GITHUB_FIRST })],
1088
+ [DEV_LOOP_GATE.REVIEWER_FIXER, () => evaluatePublicDevLoopRouting({ intent: DEV_LOOP_PUBLIC_INTENT.CONTINUE_CURRENT, currentState: publicDevLoopPrState({ ownership: DEV_LOOP_ACTOR.REVIEWER, nextActor: DEV_LOOP_ACTOR.REVIEWER }), targetPreference: PUBLIC_DEV_LOOP_GITHUB_FIRST })],
1089
+ [DEV_LOOP_GATE.COPILOT_PR_FOLLOWUP, () => evaluatePublicDevLoopRouting({ intent: DEV_LOOP_PUBLIC_INTENT.CONTINUE_CURRENT, currentState: publicDevLoopPrState({}), targetPreference: PUBLIC_DEV_LOOP_GITHUB_FIRST })],
1090
+ [DEV_LOOP_GATE.FAIL_CLOSED_RECONCILE, () => evaluatePublicDevLoopRouting({ intent: DEV_LOOP_PUBLIC_INTENT.CONTINUE_ON_PR, target: { kind: DEV_LOOP_TARGET_KIND.PR, pr: 88 } })],
1091
+ ]);
1092
+
1093
+ const PUBLIC_DEV_LOOP_ROUTING_TRANSITION_CHECKS = new Map();
1094
+ for (const from of PUBLIC_DEV_LOOP_NON_TERMINAL_GATES) {
1095
+ for (const to of DEV_LOOP_GATE_VALUES) {
1096
+ const buildFixture = PUBLIC_DEV_LOOP_GATE_TO_FIXTURE.get(to);
1097
+ if (!buildFixture) throw new Error(`public-dev-loop-routing: no fixture registered for reachable gate "${to}"`);
1098
+ PUBLIC_DEV_LOOP_ROUTING_TRANSITION_CHECKS.set(`${from}->${to}`, {
1099
+ status: "verified",
1100
+ verify: () => {
1101
+ const result = buildFixture();
1102
+ const ok = result.selectedGate === to;
1103
+ return {
1104
+ ok,
1105
+ detail: result,
1106
+ result: { selectedGate: result.selectedGate, routeKind: result.routeKind, selectedStrategy: result.selectedStrategy },
1107
+ };
1108
+ },
1109
+ });
1110
+ }
1111
+ }
1112
+
1113
+ const PUBLIC_DEV_LOOP_ROUTING_MACHINE = {
1114
+ name: "public-dev-loop-routing",
1115
+ states: DEV_LOOP_GATE_VALUES,
1116
+ terminalStates: PUBLIC_DEV_LOOP_TERMINAL_GATES,
1117
+ transitions: PUBLIC_DEV_LOOP_ROUTING_TRANSITIONS,
1118
+ docTransitions: PUBLIC_DEV_LOOP_ROUTING_DOC_TRANSITIONS,
1119
+ transitionChecks: PUBLIC_DEV_LOOP_ROUTING_TRANSITION_CHECKS,
1120
+ safetyRules: [
1121
+ {
1122
+ // Analog of "fail-closed states never dispatch a Backlog pull": a terminal
1123
+ // (stop / needs_reconcile) gate must never carry a routed internal strategy.
1124
+ // Terminality is keyed off the gate contract's routeKind for the observed
1125
+ // selectedGate, not the observation's own routeKind — non-terminal gates
1126
+ // (e.g. issue_intake) can legitimately emit routeKind "stop" for
1127
+ // clarification/assignment-confirmation seams while still routing a strategy.
1128
+ name: "terminal-gate-never-carries-strategy",
1129
+ check: (observation) => !DEV_LOOP_TERMINAL_ROUTE_KINDS.has(DEV_LOOP_GATE_ROUTE_KIND.get(observation.selectedGate))
1130
+ || observation.selectedStrategy === INTERNAL_DEV_LOOP_STRATEGY.NONE,
1131
+ },
1132
+ ],
1133
+ };
1134
+
1135
+ registerMachine(PUBLIC_DEV_LOOP_ROUTING_MACHINE);
1136
+
1137
+ async function main(argv = process.argv.slice(2)) {
1138
+ if (argv.includes("--help") || argv.includes("-h")) {
1139
+ process.stdout.write(`${USAGE}\n`);
1140
+ return 0;
1141
+ }
1142
+
1143
+ let ok = true;
1144
+ for (const machine of getRegisteredMachines()) {
1145
+ const report = runMachineConformance(machine);
1146
+ ok = ok && report.ok;
1147
+ process.stdout.write(`Machine ${report.name}: ${report.ok ? "PASS" : "FAIL"}\n`);
1148
+ if (!report.completeness.ok) process.stdout.write(` completeness: dead-end state(s): ${report.completeness.deadEnds.join(", ")}\n`);
1149
+ if (!report.liveness.ok) process.stdout.write(` liveness: state(s) stuck without a terminal path: ${report.liveness.stuck.join(", ")}\n`);
1150
+ if (!report.conformance.ok) {
1151
+ for (const r of report.conformance.results) {
1152
+ if (r.status === "missing" || r.status === "divergent" || r.status === "unreferenced") {
1153
+ process.stdout.write(` conformance ${r.status}: ${r.from} -> ${r.to}\n`);
1154
+ }
1155
+ }
1156
+ }
1157
+ for (const r of report.conformance.results) {
1158
+ if (r.status === "known_gap") process.stdout.write(` known gap (tracked): ${r.from} -> ${r.to} (${r.issue})\n`);
1159
+ }
1160
+ if (!report.safety.ok) {
1161
+ for (const v of report.safety.violations) process.stdout.write(` safety violation: ${v.rule}\n`);
1162
+ }
1163
+ }
1164
+ return ok ? 0 : 1;
1165
+ }
1166
+
1167
+ if (isDirectCliRun(import.meta.url)) {
1168
+ process.exitCode = await main();
1169
+ }