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.
- package/.claude/.claude-plugin/plugin.json +1 -1
- package/.claude/agents/dev-loop.md +7 -7
- package/.claude/agents/fixer.md +1 -0
- package/.claude/agents/refiner.md +4 -4
- package/.claude/agents/review.md +10 -13
- package/.claude/commands/loop-continue.md +2 -1
- package/.claude/commands/loop-enqueue.md +9 -2
- package/.claude/commands/loop-info.md +1 -1
- package/.claude/commands/loop-start-spike.md +1 -1
- package/.claude/hooks/_bash-command-classify.mjs +7 -6
- package/.claude/hooks/_hook-decisions.mjs +5 -4
- package/.claude/skills/copilot-pr-followup/SKILL.md +42 -28
- package/.claude/skills/dev-loop/SKILL.md +8 -8
- package/.claude/skills/docs/acceptance-criteria-verification.md +13 -4
- package/.claude/skills/docs/anti-patterns.md +6 -5
- package/.claude/skills/docs/artifact-authority-contract.md +17 -14
- package/.claude/skills/docs/confirmation-rules.md +2 -1
- package/.claude/skills/docs/contract-style-guide.md +37 -0
- package/.claude/skills/docs/copilot-loop-operations.md +21 -15
- package/.claude/skills/docs/cross-harness-regression-contract.md +2 -2
- package/.claude/skills/docs/debt-remediation-contract.md +1 -1
- package/.claude/skills/docs/epic-tree-refinement-procedure.md +25 -22
- package/.claude/skills/docs/issue-intake-procedure.md +37 -22
- package/.claude/skills/docs/merge-preconditions.md +17 -13
- package/.claude/skills/docs/pr-lifecycle-contract.md +45 -36
- package/.claude/skills/docs/public-dev-loop-contract.md +61 -44
- package/.claude/skills/docs/retrospective-checkpoint-contract.md +25 -9
- package/.claude/skills/docs/spike-mode-contract.md +14 -7
- package/.claude/skills/docs/stop-conditions.md +30 -15
- package/.claude/skills/docs/tracker-first-loop-state.md +13 -8
- package/.claude/skills/docs/validation-policy.md +4 -5
- package/.claude/skills/local-implementation/SKILL.md +49 -143
- package/.claude/skills/loop-grill/SKILL.md +34 -14
- package/AGENTS.md +1 -1
- package/CHANGELOG.md +36 -0
- package/README.md +95 -189
- package/agents/dev-loop.agent.md +8 -8
- package/agents/fixer.agent.md +1 -0
- package/agents/refiner.agent.md +4 -4
- package/agents/review.agent.md +10 -13
- package/extension/README.md +5 -4
- package/package.json +7 -5
- package/scripts/_core-helpers.mjs +2 -0
- package/scripts/claude/headless-dev-loop.mjs +1 -1
- package/scripts/docs/validate-rule-ownership.mjs +442 -0
- package/scripts/docs/validate-state-machine-conformance.mjs +1169 -0
- package/scripts/github/_gate-names.mjs +5 -0
- package/scripts/github/_review-thread-mutations.mjs +5 -2
- package/scripts/github/capture-review-threads.mjs +2 -2
- package/scripts/github/create-pr.mjs +119 -10
- package/scripts/github/detect-checkpoint-evidence.mjs +109 -24
- package/scripts/github/edit-issue.mjs +259 -0
- package/scripts/github/list-review-threads.mjs +277 -0
- package/scripts/github/post-gate-findings.mjs +6 -3
- package/scripts/github/probe-ci-status.mjs +18 -0
- package/scripts/github/probe-copilot-review.mjs +24 -3
- package/scripts/github/reconcile-draft-gate.mjs +13 -13
- package/scripts/github/request-copilot-review.mjs +109 -42
- package/scripts/github/upsert-checkpoint-verdict.mjs +98 -27
- package/scripts/github/verify-briefing-prefixes.mjs +382 -0
- package/scripts/github/verify-fresh-review-context.mjs +78 -3
- package/scripts/github/wait-pr-checks.mjs +171 -0
- package/scripts/github/write-gate-context.mjs +220 -10
- package/scripts/github/write-gate-findings-log.mjs +54 -2
- package/scripts/loop/_post-convergence-change.mjs +2 -2
- package/scripts/loop/_pr-runner-coordination.mjs +112 -13
- package/scripts/loop/check-retro-tooling.mjs +14 -9
- package/scripts/loop/copilot-pr-handoff.mjs +47 -16
- package/scripts/loop/detect-change-scope.mjs +2 -2
- package/scripts/loop/detect-copilot-loop-state.mjs +34 -14
- package/scripts/loop/detect-internal-only-pr.mjs +6 -6
- package/scripts/loop/detect-pr-gate-coordination-state.mjs +152 -18
- package/scripts/loop/detect-refinement-grill-state.mjs +136 -0
- package/scripts/loop/resolve-dev-loop-startup.mjs +145 -8
- package/scripts/loop/run-watch-cycle.mjs +42 -7
- package/scripts/loop/sanctioned-commands.mjs +3 -0
- package/scripts/loop/validate-pr-body-spec.mjs +21 -5
- package/scripts/pages/build-state-atlas.mjs +65 -48
- package/scripts/projects/_resolve-project.mjs +1 -148
- package/scripts/projects/add-queue-item.mjs +87 -4
- package/scripts/projects/list-queue-items.mjs +3 -377
- package/scripts/projects/move-queue-item.mjs +3 -410
- package/scripts/projects/reorder-queue-item.mjs +3 -22
- package/skills/copilot-pr-followup/SKILL.md +42 -28
- package/skills/dev-loop/SKILL.md +3 -3
- package/skills/docs/acceptance-criteria-verification.md +13 -4
- package/skills/docs/anti-patterns.md +6 -5
- package/skills/docs/artifact-authority-contract.md +17 -14
- package/skills/docs/confirmation-rules.md +2 -1
- package/skills/docs/contract-style-guide.md +37 -0
- package/skills/docs/copilot-loop-operations.md +21 -15
- package/skills/docs/cross-harness-regression-contract.md +2 -2
- package/skills/docs/debt-remediation-contract.md +1 -1
- package/skills/docs/epic-tree-refinement-procedure.md +25 -22
- package/skills/docs/issue-intake-procedure.md +37 -22
- package/skills/docs/merge-preconditions.md +17 -13
- package/skills/docs/pr-lifecycle-contract.md +45 -36
- package/skills/docs/public-dev-loop-contract.md +61 -44
- package/skills/docs/required-rules.json +165 -0
- package/skills/docs/retrospective-checkpoint-contract.md +25 -9
- package/skills/docs/spike-mode-contract.md +14 -7
- package/skills/docs/stop-conditions.md +30 -15
- package/skills/docs/tracker-first-loop-state.md +13 -8
- package/skills/docs/validation-policy.md +4 -5
- package/skills/local-implementation/SKILL.md +49 -143
- package/skills/loop-grill/SKILL.md +38 -17
- 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
|
+
}
|