dev-loops 0.7.1 → 0.7.2

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