@dev-loops/core 1.0.2 → 1.0.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +10 -1
- package/src/claude/asset-generation.mjs +64 -3
- package/src/claude/hook-decisions.mjs +97 -48
- package/src/config/config.mjs +417 -37
- package/src/github/copilot-helpers.mjs +28 -1
- package/src/github/issue-ops.mjs +4 -0
- package/src/github/repo-slug.mjs +25 -4
- package/src/github/test-mode-write-guard.mjs +81 -0
- package/src/loop/bash-command-classify.mjs +145 -28
- package/src/loop/child-launch-bound.mjs +152 -0
- package/src/loop/copilot-loop-state.mjs +20 -4
- package/src/loop/execution-record.mjs +412 -0
- package/src/loop/finding-cluster.mjs +277 -0
- package/src/loop/fixer-disposition.mjs +200 -0
- package/src/loop/gate-fanin.mjs +45 -0
- package/src/loop/merge-approval.mjs +283 -0
- package/src/loop/pr-gate-coordination.mjs +49 -0
- package/src/loop/queue-board-sync.mjs +6 -3
- package/src/loop/reviewer-unit-bound.mjs +308 -0
- package/src/loop/role-budget-bound.mjs +242 -0
- package/src/loop/size-budget-merge-gate.mjs +48 -12
- package/src/loop/watcher-exclusivity.mjs +302 -0
- package/src/security/secret-scan.mjs +13 -0
package/src/github/issue-ops.mjs
CHANGED
|
@@ -4,6 +4,7 @@ import { runChild as defaultRunChild } from "../cli/primitives.mjs";
|
|
|
4
4
|
import { parseJsonText } from "./review-threads.mjs";
|
|
5
5
|
import { parseRepoSlug } from "./repo-slug.mjs";
|
|
6
6
|
import { guardCommentBodyNoIssuePrIds } from "./comment-id-guard.mjs";
|
|
7
|
+
import { assertGithubWriteStubbedInTestMode } from "./test-mode-write-guard.mjs";
|
|
7
8
|
|
|
8
9
|
/**
|
|
9
10
|
* Core `gh issue` operations, extracted from the thin CLI wrappers under
|
|
@@ -88,6 +89,7 @@ export async function resolveCreateBody(options) {
|
|
|
88
89
|
}
|
|
89
90
|
|
|
90
91
|
export async function createIssue(options, { env = process.env, ghCommand = "gh", run = defaultRunChild } = {}) {
|
|
92
|
+
assertGithubWriteStubbedInTestMode(run, "issue create", { env });
|
|
91
93
|
const body = await resolveCreateBody(options);
|
|
92
94
|
if (typeof body !== "string" || body.trim().length === 0) {
|
|
93
95
|
const source = options.bodyFile !== undefined ? `--body-file ${options.bodyFile}` : "--body";
|
|
@@ -182,6 +184,7 @@ export function buildStateChangeArgs(options) {
|
|
|
182
184
|
}
|
|
183
185
|
|
|
184
186
|
export async function editIssue(options, { env = process.env, ghCommand = "gh", run = defaultRunChild } = {}) {
|
|
187
|
+
assertGithubWriteStubbedInTestMode(run, "issue edit/close", { env });
|
|
185
188
|
const { args, edited } = await buildEditArgs(options);
|
|
186
189
|
// Skip the edit call entirely when --state is the only change requested —
|
|
187
190
|
// `gh issue edit` with no field flags errors ("no changed fields").
|
|
@@ -227,6 +230,7 @@ export async function resolveCommentBody(options) {
|
|
|
227
230
|
}
|
|
228
231
|
|
|
229
232
|
export async function commentIssue(options, { env = process.env, ghCommand = "gh", run = defaultRunChild } = {}) {
|
|
233
|
+
assertGithubWriteStubbedInTestMode(run, "issue comment", { env });
|
|
230
234
|
const body = await resolveCommentBody(options);
|
|
231
235
|
// ISSUE/PR-ID GUARD (#1731): a generated comment body must never emit a raw
|
|
232
236
|
// issue/PR id (fail-closed unless explicitly allowlisted). `allowedRefs` is
|
package/src/github/repo-slug.mjs
CHANGED
|
@@ -83,10 +83,33 @@ export function dedupeRepoSlugOptions(options) {
|
|
|
83
83
|
}
|
|
84
84
|
return uniqueOptions;
|
|
85
85
|
}
|
|
86
|
+
// Parses a git remote URL into an <owner/name> slug, but only for github.com
|
|
87
|
+
// remotes. The URI form (scheme://[user@]host[:port]/path) is checked before
|
|
88
|
+
// the scp form (host:path), because the scp regex also matches URIs like
|
|
89
|
+
// https://... — checking scp first would misparse the host out of the scheme.
|
|
90
|
+
function parseGitHubRemoteSlug(url) {
|
|
91
|
+
let host, path;
|
|
92
|
+
const uri = url.match(/^[a-zA-Z][a-zA-Z0-9+.-]*:\/\/(?:[^@/]+@)?([^/:]+)(?::\d+)?\/(.+)$/);
|
|
93
|
+
const scp = url.match(/^(?:[^@/]+@)?([^/:]+):(.+)$/);
|
|
94
|
+
if (uri) {
|
|
95
|
+
host = uri[1];
|
|
96
|
+
path = uri[2];
|
|
97
|
+
} else if (scp) {
|
|
98
|
+
host = scp[1];
|
|
99
|
+
path = scp[2];
|
|
100
|
+
} else {
|
|
101
|
+
return null;
|
|
102
|
+
}
|
|
103
|
+
if (host.toLowerCase() !== "github.com") return null;
|
|
104
|
+
const seg = path.match(/([^/]+)\/([^/]+?)(?:\.git)?\/?$/);
|
|
105
|
+
if (!seg) return null;
|
|
106
|
+
return `${seg[1]}/${seg[2]}`;
|
|
107
|
+
}
|
|
108
|
+
|
|
86
109
|
/**
|
|
87
110
|
* Auto-detect <owner/name> from `git remote get-url origin`.
|
|
88
111
|
* Returns the slug string on success, or null when detection fails
|
|
89
|
-
* (no origin remote, not a git repo, or unparseable URL).
|
|
112
|
+
* (no origin remote, not a git repo, non-github.com host, or unparseable URL).
|
|
90
113
|
* Does NOT throw — callers should add their own context-specific error messages.
|
|
91
114
|
*/
|
|
92
115
|
export function detectRepoSlug(cwd) {
|
|
@@ -96,9 +119,7 @@ export function detectRepoSlug(cwd) {
|
|
|
96
119
|
encoding: "utf8",
|
|
97
120
|
stdio: ["ignore", "pipe", "pipe"],
|
|
98
121
|
}).trim();
|
|
99
|
-
|
|
100
|
-
if (!match) return null;
|
|
101
|
-
return `${match[1]}/${match[2]}`;
|
|
122
|
+
return parseGitHubRemoteSlug(url);
|
|
102
123
|
} catch {
|
|
103
124
|
return null;
|
|
104
125
|
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fail-closed test-mode guard for GitHub WRITE helpers.
|
|
3
|
+
*
|
|
4
|
+
* A GitHub write helper (issue/PR create/edit/close/comment/merge) must never
|
|
5
|
+
* reach a live GitHub write path from a test. The real incident: an unstubbed
|
|
6
|
+
* `bun run verify` drove a dedup test's followUpDraft through
|
|
7
|
+
* applyFollowUpIssues -> ensureFollowUpIssue -> createIssue and FILED A REAL
|
|
8
|
+
* ISSUE against the repo. The DI stub seam existed; the test simply did not
|
|
9
|
+
* thread it, and nothing failed closed to stop the live write.
|
|
10
|
+
*
|
|
11
|
+
* This guard is TEST-MODE ONLY. In production (`NODE_ENV !== "test"`) it is a
|
|
12
|
+
* no-op — it never changes production write behavior (non-goal: no production
|
|
13
|
+
* behavior change; no general network sandbox). It only asserts, when a test is
|
|
14
|
+
* running, that the write is stubbed by one of the two sanctioned seams:
|
|
15
|
+
*
|
|
16
|
+
* Test mode is derived from the EXECUTING PROCESS's own env (`process.env`),
|
|
17
|
+
* never from the write-target `env` a caller passes in. A caller can build a
|
|
18
|
+
* sparse `env` (e.g. `{ GH_TOKEN }` with no `NODE_ENV`) to shape the `gh`
|
|
19
|
+
* child's environment; if that sparse env decided test mode, it could turn
|
|
20
|
+
* the guard off from under a test simply by omitting `NODE_ENV`. Reading the
|
|
21
|
+
* real process env instead makes that impossible: a caller-supplied env can
|
|
22
|
+
* never disable the guard.
|
|
23
|
+
*
|
|
24
|
+
* (a) In-process DI seam: the helper's `run`/`runChild` was replaced with a
|
|
25
|
+
* stub (e.g. `makeGhMock`), so it is no longer the live child-exec seam.
|
|
26
|
+
* (b) Process-boundary seam: a subprocess test installed a fake `gh` on PATH
|
|
27
|
+
* (`writeGhStub`) and attests it via `DEV_LOOPS_GH_STUB` in the child env.
|
|
28
|
+
*
|
|
29
|
+
* Anything else in test mode fails closed at the call site, before any network
|
|
30
|
+
* call, with an actionable error naming both seams.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
import { runChild as liveRunChild } from "../cli/primitives.mjs";
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Env var a process-boundary gh stub sets to attest that `gh` itself is a stub
|
|
37
|
+
* (set automatically by the `writeGhStub` test helper). Only consulted in test
|
|
38
|
+
* mode, so it is not a production escape hatch.
|
|
39
|
+
*/
|
|
40
|
+
export const GH_STUB_ATTESTATION_ENV = "DEV_LOOPS_GH_STUB";
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* True when `run` is the live child-exec seam — i.e. NO in-process DI stub was
|
|
44
|
+
* injected. `null`/`undefined` (a helper with no run seam, e.g. the spawn-based
|
|
45
|
+
* create-pr path) is treated as live too, so it fails closed rather than open.
|
|
46
|
+
* @param {unknown} run
|
|
47
|
+
*/
|
|
48
|
+
export function isLiveExecutor(run) {
|
|
49
|
+
return run == null || run === liveRunChild;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Assert a GitHub write is stubbed when running under a test. No-op unless the
|
|
54
|
+
* EXECUTING PROCESS is in test mode (`processEnv.NODE_ENV === "test"`) — never
|
|
55
|
+
* decided by the caller-supplied write-target `env`, so a sparse `env` (e.g.
|
|
56
|
+
* missing `NODE_ENV`) cannot bypass the guard. Throws (code
|
|
57
|
+
* `GH_WRITE_UNSTUBBED_IN_TEST`) when the write would reach the live path with
|
|
58
|
+
* neither sanctioned stub seam present.
|
|
59
|
+
*
|
|
60
|
+
* @param {unknown} run - the helper's `run`/`runChild` seam (omit for spawn-only
|
|
61
|
+
* helpers with no in-process seam).
|
|
62
|
+
* @param {string} op - human-readable op name, e.g. `"issue create"`.
|
|
63
|
+
* @param {{ env?: NodeJS.ProcessEnv, processEnv?: NodeJS.ProcessEnv }} [opts]
|
|
64
|
+
* `env` is the write-target env (consulted only for the stub attestation);
|
|
65
|
+
* `processEnv` (defaults to `process.env`, injectable for tests) is the
|
|
66
|
+
* real executing process's env and is the sole source of test-mode.
|
|
67
|
+
*/
|
|
68
|
+
export function assertGithubWriteStubbedInTestMode(run, op, { env = process.env, processEnv = process.env } = {}) {
|
|
69
|
+
if (processEnv?.NODE_ENV !== "test") return; // production / non-test: never guards
|
|
70
|
+
if (!isLiveExecutor(run)) return; // (a) in-process DI stub injected
|
|
71
|
+
if (processEnv?.[GH_STUB_ATTESTATION_ENV] || env?.[GH_STUB_ATTESTATION_ENV]) return; // (b) process-boundary gh stub
|
|
72
|
+
throw Object.assign(
|
|
73
|
+
new Error(
|
|
74
|
+
`GitHub write helper "${op}" was called in test mode without an injected stub — ` +
|
|
75
|
+
`refusing to reach a live GitHub write path (a test must never mutate the real repo). ` +
|
|
76
|
+
`Inject the in-process DI seam (pass { run } / { runChild }, e.g. makeGhMock) or stub gh ` +
|
|
77
|
+
`at the process boundary (writeGhStub, which sets ${GH_STUB_ATTESTATION_ENV}). See issue 2216.`,
|
|
78
|
+
),
|
|
79
|
+
{ code: "GH_WRITE_UNSTUBBED_IN_TEST" },
|
|
80
|
+
);
|
|
81
|
+
}
|
|
@@ -8,9 +8,65 @@
|
|
|
8
8
|
* Pure and side-effect free.
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
-
/**
|
|
11
|
+
/**
|
|
12
|
+
* The dev-loops repo itself. Retained ONLY for the Pi extension's
|
|
13
|
+
* (`extension/post-merge-update.ts`) dev-loops-repo SELF-UPDATE scoping (`markPendingUpdate` /
|
|
14
|
+
* `queueIfEligible`), which is legitimately anchored to this one repo, not to whatever repo the
|
|
15
|
+
* harness happens to run in. Both harness guard suites — the Claude PreToolUse Bash gate
|
|
16
|
+
* (`decideBashGate` in `hook-decisions.mjs`) and the Pi extension's `gh pr ready`/`gh pr merge`
|
|
17
|
+
* guards (`post-merge-update.ts`) — now resolve the managed repo dynamically via
|
|
18
|
+
* `deriveInManagedRepo` below instead of comparing against this hardcoded slug, so every guard
|
|
19
|
+
* also applies in a dev-loops-managed consumer repo.
|
|
20
|
+
*/
|
|
12
21
|
export const TARGET_REPO_SLUG = "mfittko/dev-loops";
|
|
13
22
|
|
|
23
|
+
/** `.devloops` config file extensions checked (in order) to decide whether a repo root is
|
|
24
|
+
* dev-loops-managed. Shared by every harness that resolves `inManagedContext` from the
|
|
25
|
+
* filesystem (`fs.existsSync(path.join(repoRoot, \`.devloops${ext}\`))`). */
|
|
26
|
+
export const DEVLOOPS_CONFIG_VARIANTS = ["", ".yaml", ".yml", ".json"];
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Whether the current repo counts as "managed" for guard-gating purposes: inside a
|
|
30
|
+
* dev-loops-managed context (a `.devloops` config exists at its root) AND, when the managed
|
|
31
|
+
* repo's identity resolves, the cwd repo IS that managed repo. FAIL CLOSED: inside a managed
|
|
32
|
+
* context whose identity can't be resolved (`managedRepoSlug` null), the guard suite still
|
|
33
|
+
* applies rather than silently allowing everything.
|
|
34
|
+
* @param {Object} [params]
|
|
35
|
+
* @param {boolean} [params.inManagedContext] - Whether a `.devloops` config exists at the repo root.
|
|
36
|
+
* @param {string|null} [params.managedRepoSlug] - Resolved owner/name of the managed repo, or null.
|
|
37
|
+
* @param {string|null} [params.repoSlug] - Resolved owner/name of the cwd repo, or null.
|
|
38
|
+
* @returns {boolean}
|
|
39
|
+
*/
|
|
40
|
+
export function deriveInManagedRepo({ inManagedContext = false, managedRepoSlug = null, repoSlug = null } = {}) {
|
|
41
|
+
const managedSlug = (managedRepoSlug ?? "").trim().toLowerCase() || null;
|
|
42
|
+
const cwdSlug = (repoSlug ?? "").trim().toLowerCase() || null;
|
|
43
|
+
return Boolean(inManagedContext) && (managedSlug === null || cwdSlug === managedSlug);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Whether an explicit `--repo`/`-R` (or `GH_REPO=`) target is PROVABLY a different repo than the
|
|
48
|
+
* managed one — both slugs must resolve and differ. An unresolvable managed slug never proves
|
|
49
|
+
* foreign-ness (fail closed: the guard stays active rather than waving an explicit flag through).
|
|
50
|
+
* @param {string|null} explicitRepo
|
|
51
|
+
* @param {string|null} managedRepoSlug
|
|
52
|
+
* @returns {boolean}
|
|
53
|
+
*/
|
|
54
|
+
export function explicitRepoProvenForeign(explicitRepo, managedRepoSlug) {
|
|
55
|
+
const managedSlug = (managedRepoSlug ?? "").trim().toLowerCase() || null;
|
|
56
|
+
const explicit = (explicitRepo ?? "").trim().toLowerCase() || null;
|
|
57
|
+
if (managedSlug === null || explicit === null) {
|
|
58
|
+
return false;
|
|
59
|
+
}
|
|
60
|
+
// Both sides must be clean owner/name identities before we can prove foreign: a
|
|
61
|
+
// managed slug that bypassed the normalizer (e.g. a hostile `acme/widgets;id` test
|
|
62
|
+
// double) can't be trusted to prove anything about the explicit `--repo` — fail
|
|
63
|
+
// closed (the managed-repo guard still applies) rather than wave it through.
|
|
64
|
+
if (!isCleanRepoSlug(managedSlug) || !isCleanRepoSlug(explicit)) {
|
|
65
|
+
return false;
|
|
66
|
+
}
|
|
67
|
+
return explicit !== managedSlug;
|
|
68
|
+
}
|
|
69
|
+
|
|
14
70
|
/** Flags known to take a value argument for `gh pr ready` (not boolean flags). */
|
|
15
71
|
export const FLAGS_THAT_TAKE_VALUE = new Set(["-r", "--repo"]);
|
|
16
72
|
|
|
@@ -21,7 +77,7 @@ export const FLAGS_THAT_TAKE_VALUE = new Set(["-r", "--repo"]);
|
|
|
21
77
|
* strings — so a segment must break on them too, else `echo hi\ngh pr create` evades
|
|
22
78
|
* the gate. Used by all segment-splitting sites (DRY).
|
|
23
79
|
*/
|
|
24
|
-
const SHELL_SEGMENT_SEPARATOR = /\s*(
|
|
80
|
+
const SHELL_SEGMENT_SEPARATOR = /\s*(?:&&|\|\||;|\||&|\n|\r)\s*/;
|
|
25
81
|
|
|
26
82
|
/**
|
|
27
83
|
* Strip a single balanced surrounding quote pair (`'…'` or `"…"`) from a shell arg value.
|
|
@@ -67,8 +123,32 @@ export function trimToNull(value) {
|
|
|
67
123
|
return trimmed ? trimmed : null;
|
|
68
124
|
}
|
|
69
125
|
|
|
126
|
+
/**
|
|
127
|
+
* Strict GitHub owner/name identity shape: each of the two path segments is
|
|
128
|
+
* `[A-Za-z0-9._-]+` — the character set GitHub itself allows in an owner or repo name. A slug
|
|
129
|
+
* outside this shape (a shell metacharacter, whitespace, path traversal, or an extra `/` segment)
|
|
130
|
+
* can never be a real GitHub identity.
|
|
131
|
+
*/
|
|
132
|
+
const CLEAN_REPO_SLUG_RE = /^[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+$/;
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Whether `slug` is a clean `owner/name` GitHub identity (see `CLEAN_REPO_SLUG_RE`). Exported so a
|
|
136
|
+
* sink that interpolates a repo slug into a shell command string (e.g. the Pi extension's
|
|
137
|
+
* `gateCommand` in `extension/post-merge-update.ts`) can defense-in-depth guard the interpolation,
|
|
138
|
+
* on top of `normalizeGitHubRepoSlug` already enforcing this shape at the source.
|
|
139
|
+
* @param {string|null|undefined} slug @returns {boolean}
|
|
140
|
+
*/
|
|
141
|
+
export function isCleanRepoSlug(slug) {
|
|
142
|
+
return typeof slug === "string" && CLEAN_REPO_SLUG_RE.test(slug);
|
|
143
|
+
}
|
|
144
|
+
|
|
70
145
|
/**
|
|
71
146
|
* Normalize a git remote URL into an `owner/name` slug (lowercased), or null.
|
|
147
|
+
* A hostile remote (e.g. `git@github.com:acme/widgets;id`) never yields a slug carrying the
|
|
148
|
+
* injected metacharacters — the extracted candidate must match `CLEAN_REPO_SLUG_RE` or this
|
|
149
|
+
* returns null instead, so every caller (both harnesses' managed-repo scope checks, and any sink
|
|
150
|
+
* that interpolates the slug into a shell command) sees either a real GitHub identity or null,
|
|
151
|
+
* never shell-metacharacter-bearing text.
|
|
72
152
|
* @param {string} remoteUrl
|
|
73
153
|
* @returns {string|null}
|
|
74
154
|
*/
|
|
@@ -91,7 +171,8 @@ export function normalizeGitHubRepoSlug(remoteUrl) {
|
|
|
91
171
|
if (!match) {
|
|
92
172
|
continue;
|
|
93
173
|
}
|
|
94
|
-
|
|
174
|
+
const slug = trimToNull(match[1])?.toLowerCase() ?? null;
|
|
175
|
+
return isCleanRepoSlug(slug) ? slug : null;
|
|
95
176
|
}
|
|
96
177
|
|
|
97
178
|
return null;
|
|
@@ -486,6 +567,25 @@ export function extractRepoFlagsFromGhPrCreateSegments(command) {
|
|
|
486
567
|
return extractRepoFlagsFromGhSubcmdVerbSegments(command, "pr", "create");
|
|
487
568
|
}
|
|
488
569
|
|
|
570
|
+
/**
|
|
571
|
+
* Return `{ segment, explicitRepo }` for every `gh pr merge` segment (ignoring --help/-h) —
|
|
572
|
+
* PreToolUse gate scope check use only, so a proven-foreign leading segment can't shield a later
|
|
573
|
+
* managed one. Mirrors `extractRepoFlagsFromGhPrCreateSegments`.
|
|
574
|
+
* @param {string} command @returns {{ segment: string, explicitRepo: string|null }[]}
|
|
575
|
+
*/
|
|
576
|
+
export function extractRepoFlagsFromGhPrMergeSegments(command) {
|
|
577
|
+
return extractRepoFlagsFromGhSubcmdVerbSegments(command, "pr", "merge");
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
/**
|
|
581
|
+
* Return `{ segment, explicitRepo }` for every `gh pr ready` segment (ignoring --help/-h) —
|
|
582
|
+
* PreToolUse gate scope check use only. Mirrors `extractRepoFlagsFromGhPrCreateSegments`.
|
|
583
|
+
* @param {string} command @returns {{ segment: string, explicitRepo: string|null }[]}
|
|
584
|
+
*/
|
|
585
|
+
export function extractRepoFlagsFromGhPrReadySegments(command) {
|
|
586
|
+
return extractRepoFlagsFromGhSubcmdVerbSegments(command, "pr", "ready");
|
|
587
|
+
}
|
|
588
|
+
|
|
489
589
|
/** @param {string} command @returns {number|null} */
|
|
490
590
|
export function extractPrNumberFromGhPrMerge(command) {
|
|
491
591
|
return extractPrNumberFromGhPrVerb(command, "merge");
|
|
@@ -574,13 +674,22 @@ export function extractGhApiEndpointSegments(command) {
|
|
|
574
674
|
return out;
|
|
575
675
|
}
|
|
576
676
|
|
|
577
|
-
/** The `gh api` segments whose endpoint is the
|
|
578
|
-
* slug-embedded form (`repos
|
|
579
|
-
* which gh api resolves against the cwd repo — the
|
|
580
|
-
* on `
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
677
|
+
/** The `gh api` segments whose endpoint is the dev-loops-managed repo's URL path. Matches the
|
|
678
|
+
* absolute slug-embedded form (`repos/<managedSlug>/...`) when `managedSlug` resolves, plus the
|
|
679
|
+
* bare relative form (`issues/...`), which gh api resolves against the cwd repo — the
|
|
680
|
+
* decideBashGate call site gates the relative form on `inManagedRepo`. When `managedSlug` is null
|
|
681
|
+
* (the managed repo's identity could not be resolved in a managed context — AC4's fail-closed
|
|
682
|
+
* case), the absolute arm matches ANY owner/repo rather than a specific slug: identity is unknown,
|
|
683
|
+
* so an absolute write must be denied regardless of which repo it targets, not waved through for
|
|
684
|
+
* lack of a slug to compare against. The absolute arm fully regex-escapes a resolved slug (a `.`
|
|
685
|
+
* in a legitimate repo name must match literally, not as a wildcard) and matches
|
|
686
|
+
* case-insensitively (GitHub repo identity is case-insensitive). */
|
|
687
|
+
function managedGhApiPathRegex(suffix, managedSlug) {
|
|
688
|
+
if (!managedSlug) {
|
|
689
|
+
return new RegExp(`(?:repos/[^/]+/[^/]+/|^)${suffix}`, "i");
|
|
690
|
+
}
|
|
691
|
+
const slug = managedSlug.replace(/[.*+?^${}()|[\]\\/]/g, "\\$&");
|
|
692
|
+
return new RegExp(`(?:repos/${slug}/|^)${suffix}`, "i");
|
|
584
693
|
}
|
|
585
694
|
|
|
586
695
|
/** Strip a `scheme://host` prefix from an absolute gh api URL endpoint (`https://api.github.com/...`),
|
|
@@ -617,15 +726,18 @@ function ghApiSegmentHasWriteMethod(segment) {
|
|
|
617
726
|
}
|
|
618
727
|
|
|
619
728
|
/**
|
|
620
|
-
* SUBISSUE-NO-ADHOC-BYPASS: raw `gh api` WRITE to `.../issues/<n>/sub_issues[/priority]` on the
|
|
621
|
-
* repo — the ad-hoc sub-issue mutation that must flow through the sanctioned
|
|
622
|
-
* wrapper instead. Actor-independent (the issue's decided policy): the main
|
|
623
|
-
* direct path to sub-issue writes. Anchored on the
|
|
624
|
-
* method, so a `gh api` read or another repo's `sub_issues` write
|
|
625
|
-
*
|
|
729
|
+
* SUBISSUE-NO-ADHOC-BYPASS: raw `gh api` WRITE to `.../issues/<n>/sub_issues[/priority]` on the
|
|
730
|
+
* dev-loops-managed repo — the ad-hoc sub-issue mutation that must flow through the sanctioned
|
|
731
|
+
* `manage-sub-issues` wrapper instead. Actor-independent (the issue's decided policy): the main
|
|
732
|
+
* agent gets no reserved direct path to sub-issue writes. Anchored on the managed repo's URL path
|
|
733
|
+
* segment AND an explicit write method, so a `gh api` read or another repo's `sub_issues` write
|
|
734
|
+
* passes through (no false deny).
|
|
735
|
+
* @param {string} command @param {string|null} [managedSlug] - Resolved managed-repo slug, or
|
|
736
|
+
* null when unresolvable (both the relative and any absolute repos/<owner>/<repo>/ form match then, fail closed).
|
|
737
|
+
* @returns {boolean}
|
|
626
738
|
*/
|
|
627
|
-
export function commandContainsSubIssueAdHocBypass(command) {
|
|
628
|
-
const re =
|
|
739
|
+
export function commandContainsSubIssueAdHocBypass(command, managedSlug = null) {
|
|
740
|
+
const re = managedGhApiPathRegex(`issues/\\d+/sub_issues(?:/priority)?(?:\\s|$)`, managedSlug);
|
|
629
741
|
return extractGhApiEndpointSegments(command).some(
|
|
630
742
|
({ segment, endpoint }) => Boolean(endpoint) && re.test(normalizeGhApiEndpoint(endpoint)) && ghApiSegmentHasWriteMethod(segment),
|
|
631
743
|
);
|
|
@@ -633,12 +745,15 @@ export function commandContainsSubIssueAdHocBypass(command) {
|
|
|
633
745
|
|
|
634
746
|
/**
|
|
635
747
|
* COPILOT-FOLLOWUP-REPLY-RESOLVE-HELPER (REST half): raw `gh api` POST to
|
|
636
|
-
* `.../pulls/<n>/comments/<m>/replies` on the
|
|
637
|
-
* through `reply-resolve-review-thread(s).mjs`. Actor-independent: no reserved
|
|
638
|
-
*
|
|
748
|
+
* `.../pulls/<n>/comments/<m>/replies` on the dev-loops-managed repo — the ad-hoc thread reply
|
|
749
|
+
* that must flow through `reply-resolve-review-thread(s).mjs`. Actor-independent: no reserved
|
|
750
|
+
* direct reply path.
|
|
751
|
+
* @param {string} command @param {string|null} [managedSlug] - Resolved managed-repo slug, or
|
|
752
|
+
* null when unresolvable (both the relative and any absolute repos/<owner>/<repo>/ form match then, fail closed).
|
|
753
|
+
* @returns {boolean}
|
|
639
754
|
*/
|
|
640
|
-
export function commandContainsReplyResolveBypass(command) {
|
|
641
|
-
const re =
|
|
755
|
+
export function commandContainsReplyResolveBypass(command, managedSlug = null) {
|
|
756
|
+
const re = managedGhApiPathRegex(`pulls/\\d+/comments/\\d+/replies(?:\\s|$)`, managedSlug);
|
|
642
757
|
return extractGhApiEndpointSegments(command).some(
|
|
643
758
|
({ segment, endpoint }) => Boolean(endpoint) && re.test(normalizeGhApiEndpoint(endpoint)) && ghApiSegmentHasWriteMethod(segment),
|
|
644
759
|
);
|
|
@@ -657,12 +772,14 @@ export function commandContainsGraphqlResolveReviewThread(command) {
|
|
|
657
772
|
|
|
658
773
|
/**
|
|
659
774
|
* COPILOT-FOLLOWUP-REQUEST-HELPER-ONLY (REST half): raw `gh api` write to
|
|
660
|
-
* `.../pulls/<n>/requested_reviewers` on the
|
|
661
|
-
* flow through `scripts/github/request-copilot-review.mjs`. Actor-independent.
|
|
662
|
-
* @param {string} command @
|
|
775
|
+
* `.../pulls/<n>/requested_reviewers` on the dev-loops-managed repo — the ad-hoc Copilot review
|
|
776
|
+
* request that must flow through `scripts/github/request-copilot-review.mjs`. Actor-independent.
|
|
777
|
+
* @param {string} command @param {string|null} [managedSlug] - Resolved managed-repo slug, or
|
|
778
|
+
* null when unresolvable (both the relative and any absolute repos/<owner>/<repo>/ form match then, fail closed).
|
|
779
|
+
* @returns {boolean}
|
|
663
780
|
*/
|
|
664
|
-
export function commandContainsCopilotRequestBypass(command) {
|
|
665
|
-
const re =
|
|
781
|
+
export function commandContainsCopilotRequestBypass(command, managedSlug = null) {
|
|
782
|
+
const re = managedGhApiPathRegex(`pulls/\\d+/requested_reviewers(?:\\s|$)`, managedSlug);
|
|
666
783
|
return extractGhApiEndpointSegments(command).some(
|
|
667
784
|
({ segment, endpoint }) => Boolean(endpoint) && re.test(normalizeGhApiEndpoint(endpoint)) && ghApiSegmentHasWriteMethod(segment),
|
|
668
785
|
);
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* child-launch-bound.mjs — dev-loop execution-cap fail-fast child-launch
|
|
3
|
+
* bound (epic decided approach): a bounded, deterministic primitive that
|
|
4
|
+
* attempts a child launch AT MOST ONCE, queries the harness-supported-model
|
|
5
|
+
* inventory AT MOST ONCE and ONLY after a failed launch, and always produces
|
|
6
|
+
* a durable blocker record on failure — never a retry, a silent model
|
|
7
|
+
* substitution, or a dispatch without the requested override.
|
|
8
|
+
*
|
|
9
|
+
* Pure and offline: launch/query behavior is fully caller-injected
|
|
10
|
+
* (attemptLaunch/querySupportedModels); this module never imports a runtime
|
|
11
|
+
* harness adapter, reads a file, or performs I/O of its own.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Dev-loop harnesses this bound recognizes; any other value fails closed.
|
|
16
|
+
* This primitive only carries the harness label through the request/blocker
|
|
17
|
+
* — it does not branch on it or depend on per-harness capability data (e.g.
|
|
18
|
+
* HARNESS_DEFAULT_CAPABILITIES), so it stays agnostic across pi, claude, and
|
|
19
|
+
* codex.
|
|
20
|
+
*/
|
|
21
|
+
const HARNESS_VALUES = Object.freeze(["pi", "claude", "codex"]);
|
|
22
|
+
|
|
23
|
+
/** @param {unknown} value @returns {boolean} */
|
|
24
|
+
function isNonEmptyString(value) {
|
|
25
|
+
return typeof value === "string" && value.trim().length > 0;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Validate + normalize the child-launch request. Fails closed (TypeError) on
|
|
30
|
+
* any malformed/empty field, at the trust boundary of this function.
|
|
31
|
+
* @param {object} request
|
|
32
|
+
* @returns {{run:string, roleOrAngle:string, model:string, harness:"pi"|"claude"|"codex"}}
|
|
33
|
+
*/
|
|
34
|
+
function validateRequest(request) {
|
|
35
|
+
if (!request || typeof request !== "object") {
|
|
36
|
+
throw new TypeError("enforceChildLaunchBound requires a request object");
|
|
37
|
+
}
|
|
38
|
+
const { run, roleOrAngle, model, harness } = request;
|
|
39
|
+
if (!isNonEmptyString(run)) throw new TypeError("request.run must be a non-empty string");
|
|
40
|
+
if (!isNonEmptyString(roleOrAngle)) throw new TypeError("request.roleOrAngle must be a non-empty string");
|
|
41
|
+
if (!isNonEmptyString(model)) throw new TypeError("request.model must be a non-empty string");
|
|
42
|
+
if (!HARNESS_VALUES.includes(harness)) {
|
|
43
|
+
throw new TypeError(`request.harness must be one of ${HARNESS_VALUES.join(", ")}, got ${JSON.stringify(harness)}`);
|
|
44
|
+
}
|
|
45
|
+
return { run: run.trim(), roleOrAngle: roleOrAngle.trim(), model: model.trim(), harness };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Note: querySupportedModels may return { supported: [...] }, a bare
|
|
49
|
+
// array, or a Set — normalizing all three into one Set here keeps the
|
|
50
|
+
// caller-facing contract flexible without adding a second exported shape.
|
|
51
|
+
/** @param {{supported?: unknown}|unknown[]|Set<string>} result @returns {Set<string>} */
|
|
52
|
+
function toSupportedSet(result) {
|
|
53
|
+
const list = result && typeof result === "object" && !Array.isArray(result) && !(result instanceof Set)
|
|
54
|
+
? result.supported
|
|
55
|
+
: result;
|
|
56
|
+
if (list instanceof Set) return list;
|
|
57
|
+
if (Array.isArray(list)) return new Set(list);
|
|
58
|
+
return new Set();
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Enforce the bounded, deterministic child-launch fail-fast protocol.
|
|
63
|
+
*
|
|
64
|
+
* Scope: this bounds ONE invocation — one launch attempt plus at most one
|
|
65
|
+
* inventory query for that same call. It holds no state across calls, so
|
|
66
|
+
* cross-call terminality (never re-launching the same `(run, roleOrAngle,
|
|
67
|
+
* model)` after a prior call already returned a blocked verdict) is the
|
|
68
|
+
* caller's/coordinator's contract, not something a persisted token here
|
|
69
|
+
* enforces.
|
|
70
|
+
*
|
|
71
|
+
* Deadline contract: `deadlineMs` is a MEASUREMENT bound, not a per-call
|
|
72
|
+
* timeout. The function times the elapsed wall-clock span of the one launch
|
|
73
|
+
* attempt plus (on failure) the one inventory query, and reports
|
|
74
|
+
* `withinDeadline` fail-closed (`elapsedMs <= deadlineMs`) — it never cancels
|
|
75
|
+
* or races `attemptLaunch`/`querySupportedModels` against the clock. The
|
|
76
|
+
* <=60s guarantee holds only because each injected operation is a single
|
|
77
|
+
* bounded call, never a retry loop, inside this function; enforcing a hard
|
|
78
|
+
* per-operation timeout/cancellation on a slow `attemptLaunch` or
|
|
79
|
+
* `querySupportedModels` implementation is the caller's operation-budget
|
|
80
|
+
* responsibility and is intentionally out of scope for this pure primitive.
|
|
81
|
+
*
|
|
82
|
+
* @param {object} options
|
|
83
|
+
* @param {{run:string, roleOrAngle:string, model:string, harness:"pi"|"claude"|"codex"}} options.request
|
|
84
|
+
* @param {(request:object)=>({ok:true,launch:*}|{ok:false,reason:string})} options.attemptLaunch
|
|
85
|
+
* Called AT MOST ONCE.
|
|
86
|
+
* @param {(request:object)=>({supported:string[]}|string[]|Set<string>)} options.querySupportedModels
|
|
87
|
+
* Called AT MOST ONCE, and only after a failed launch.
|
|
88
|
+
* @param {()=>number} [options.now] - injectable clock, default Date.now.
|
|
89
|
+
* @param {number} [options.deadlineMs] - measurement bound in ms, default 60000.
|
|
90
|
+
* @returns {object} `{ ok: true, launch, events, elapsedMs, withinDeadline }`
|
|
91
|
+
* on success, or the durable blocker `{ ok: false, verdict: "blocked",
|
|
92
|
+
* reason, launchFailureDetail, request, modelSupported, elapsedMs,
|
|
93
|
+
* withinDeadline, events }` on failure. `launchFailureDetail` is the failed
|
|
94
|
+
* `attemptLaunch` result verbatim (its own reason plus any error/message it
|
|
95
|
+
* carried), preserved alongside the normalized `reason`.
|
|
96
|
+
*/
|
|
97
|
+
export function enforceChildLaunchBound({ request, attemptLaunch, querySupportedModels, now = () => Date.now(), deadlineMs = 60000 } = {}) {
|
|
98
|
+
const normalizedRequest = validateRequest(request);
|
|
99
|
+
if (typeof attemptLaunch !== "function") {
|
|
100
|
+
throw new TypeError("enforceChildLaunchBound requires attemptLaunch to be a function");
|
|
101
|
+
}
|
|
102
|
+
if (typeof querySupportedModels !== "function") {
|
|
103
|
+
throw new TypeError("enforceChildLaunchBound requires querySupportedModels to be a function");
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const events = [];
|
|
107
|
+
const start = now();
|
|
108
|
+
|
|
109
|
+
// EXACTLY one launch attempt, ever — no retry, no model substitution.
|
|
110
|
+
const launchResult = attemptLaunch(normalizedRequest);
|
|
111
|
+
events.push({ type: "launch_attempt" });
|
|
112
|
+
|
|
113
|
+
if (launchResult && launchResult.ok === true) {
|
|
114
|
+
// Success: never query the inventory, never attempt a second launch.
|
|
115
|
+
const elapsedMs = now() - start;
|
|
116
|
+
return { ok: true, launch: launchResult.launch, events, elapsedMs, withinDeadline: elapsedMs <= deadlineMs };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Launch failed: query the harness's supported-model inventory EXACTLY
|
|
120
|
+
// once, only now (never before a launch attempt, never more than once).
|
|
121
|
+
const inventory = querySupportedModels(normalizedRequest);
|
|
122
|
+
events.push({ type: "inventory_query" });
|
|
123
|
+
const modelSupported = toSupportedSet(inventory).has(normalizedRequest.model);
|
|
124
|
+
|
|
125
|
+
const launchReason = launchResult && typeof launchResult.reason === "string" ? launchResult.reason : null;
|
|
126
|
+
const reason = launchReason === "unresolvable"
|
|
127
|
+
? "child_model_unresolvable"
|
|
128
|
+
: !modelSupported
|
|
129
|
+
? "child_model_unsupported"
|
|
130
|
+
: "child_launch_failed_model_supported";
|
|
131
|
+
// Verbatim passthrough of the failed launch result (its own reason plus any
|
|
132
|
+
// error/message/detail it carried) — never inspected or altered — so a
|
|
133
|
+
// caller can see the exact failure detail alongside the normalized reason.
|
|
134
|
+
const launchFailureDetail = launchResult && typeof launchResult === "object" ? launchResult : null;
|
|
135
|
+
|
|
136
|
+
const elapsedMs = now() - start;
|
|
137
|
+
// Measurement, not enforcement: a slow adapter still returns the blocker
|
|
138
|
+
// (fail-closed) rather than being cancelled or retried against the clock.
|
|
139
|
+
const withinDeadline = elapsedMs <= deadlineMs;
|
|
140
|
+
|
|
141
|
+
return {
|
|
142
|
+
ok: false,
|
|
143
|
+
verdict: "blocked",
|
|
144
|
+
reason,
|
|
145
|
+
launchFailureDetail,
|
|
146
|
+
request: normalizedRequest,
|
|
147
|
+
modelSupported,
|
|
148
|
+
elapsedMs,
|
|
149
|
+
withinDeadline,
|
|
150
|
+
events,
|
|
151
|
+
};
|
|
152
|
+
}
|
|
@@ -197,6 +197,7 @@ export function buildSnapshotFromPrFacts({
|
|
|
197
197
|
lastCopilotRoundMaxSignal = null,
|
|
198
198
|
failureDetails = [],
|
|
199
199
|
excludedFailureDetails,
|
|
200
|
+
copilotBodyFeedbackUnresolved = false,
|
|
200
201
|
}) {
|
|
201
202
|
const prState = typeof prData?.state === "string" ? prData.state.toUpperCase() : "OPEN";
|
|
202
203
|
const prMerged = prState === "MERGED";
|
|
@@ -205,6 +206,9 @@ export function buildSnapshotFromPrFacts({
|
|
|
205
206
|
// that never threads an explicit ciStatus (e.g. gate-coordination detection)
|
|
206
207
|
// still never treats it as a blocking CI failure.
|
|
207
208
|
const rollupDerivation = deriveLoopCiStatusFromRollup(prData?.statusCheckRollup);
|
|
209
|
+
const currentHeadSha = typeof prData?.headRefOid === "string" && prData.headRefOid.trim().length > 0
|
|
210
|
+
? prData.headRefOid.trim()
|
|
211
|
+
: null;
|
|
208
212
|
|
|
209
213
|
return normalizeSnapshot({
|
|
210
214
|
prExists: true,
|
|
@@ -212,6 +216,7 @@ export function buildSnapshotFromPrFacts({
|
|
|
212
216
|
prDraft: Boolean(prData?.isDraft),
|
|
213
217
|
prMerged,
|
|
214
218
|
prClosed,
|
|
219
|
+
currentHeadSha,
|
|
215
220
|
copilotReviewRequestStatus,
|
|
216
221
|
copilotReviewPresent,
|
|
217
222
|
copilotReviewOnCurrentHead,
|
|
@@ -222,6 +227,7 @@ export function buildSnapshotFromPrFacts({
|
|
|
222
227
|
ciStatus: ciStatus ?? rollupDerivation.status,
|
|
223
228
|
failureDetails,
|
|
224
229
|
excludedFailureDetails: excludedFailureDetails ?? rollupDerivation.excludedFailureDetails,
|
|
230
|
+
copilotBodyFeedbackUnresolved,
|
|
225
231
|
});
|
|
226
232
|
}
|
|
227
233
|
|
|
@@ -269,6 +275,9 @@ export function normalizeSnapshot(raw) {
|
|
|
269
275
|
prDraft: Boolean(raw.prDraft),
|
|
270
276
|
prMerged: Boolean(raw.prMerged),
|
|
271
277
|
prClosed: Boolean(raw.prClosed),
|
|
278
|
+
currentHeadSha: typeof raw.currentHeadSha === "string" && raw.currentHeadSha.trim().length > 0
|
|
279
|
+
? raw.currentHeadSha.trim()
|
|
280
|
+
: null,
|
|
272
281
|
copilotReviewRequestStatus: VALID_REVIEW_REQUEST_STATUSES.has(raw.copilotReviewRequestStatus)
|
|
273
282
|
? raw.copilotReviewRequestStatus
|
|
274
283
|
: "none",
|
|
@@ -288,6 +297,7 @@ export function normalizeSnapshot(raw) {
|
|
|
288
297
|
agentFixStatus: raw.agentFixStatus === "applied" ? "applied" : null,
|
|
289
298
|
failureDetails: Array.isArray(raw.failureDetails) ? raw.failureDetails : [],
|
|
290
299
|
excludedFailureDetails: Array.isArray(raw.excludedFailureDetails) ? raw.excludedFailureDetails : [],
|
|
300
|
+
copilotBodyFeedbackUnresolved: Boolean(raw.copilotBodyFeedbackUnresolved),
|
|
291
301
|
};
|
|
292
302
|
}
|
|
293
303
|
|
|
@@ -397,7 +407,7 @@ export function interpretLoopState(snapshot, refinementConfig) {
|
|
|
397
407
|
&& state !== STATE.PR_DRAFT && state !== STATE.REVIEW_REQUEST_UNAVAILABLE
|
|
398
408
|
&& state !== STATE.BLOCKED_NEEDS_USER_DECISION) {
|
|
399
409
|
const ciClean = s.ciStatus === "success" || s.ciStatus === "crediblyGreen" || !preApprovalRequireCi;
|
|
400
|
-
const cleanThreads = s.unresolvedThreadCount === 0;
|
|
410
|
+
const cleanThreads = s.unresolvedThreadCount === 0 && !s.copilotBodyFeedbackUnresolved;
|
|
401
411
|
if (cleanThreads && ciClean) {
|
|
402
412
|
state = STATE.ROUND_CAP_CLEAN_FALLBACK;
|
|
403
413
|
} else if (!reviewInFlight) {
|
|
@@ -406,11 +416,16 @@ export function interpretLoopState(snapshot, refinementConfig) {
|
|
|
406
416
|
// Not clean WITH an in-flight request: leave state undecided for the routing below.
|
|
407
417
|
}
|
|
408
418
|
|
|
419
|
+
// Unresolved feedback includes both inline review threads and a Copilot
|
|
420
|
+
// review-body finding on the current head (a body-only "Changes recommended"
|
|
421
|
+
// review with zero inline threads must not read as clean).
|
|
422
|
+
const unresolvedFeedback = s.unresolvedThreadCount > 0 || s.copilotBodyFeedbackUnresolved;
|
|
423
|
+
|
|
409
424
|
if (state === undefined) {
|
|
410
|
-
if (
|
|
425
|
+
if (unresolvedFeedback && s.agentFixStatus === "applied") {
|
|
411
426
|
// Agent has fixed the code; threads still need reply/resolve on GitHub
|
|
412
427
|
state = STATE.ALREADY_FIXED_NEEDS_REPLY_RESOLVE;
|
|
413
|
-
} else if (
|
|
428
|
+
} else if (unresolvedFeedback) {
|
|
414
429
|
// Unresolved feedback exists — do not wait; enter fix/reply-resolve handling
|
|
415
430
|
state = STATE.UNRESOLVED_FEEDBACK_PRESENT;
|
|
416
431
|
} else if (s.copilotReviewRequestStatus === "requested" || s.copilotReviewRequestStatus === "already-requested") {
|
|
@@ -459,7 +474,8 @@ export function interpretLoopState(snapshot, refinementConfig) {
|
|
|
459
474
|
const sameHeadCleanConverged = state === STATE.READY_TO_REREQUEST_REVIEW
|
|
460
475
|
&& s.copilotReviewOnCurrentHead
|
|
461
476
|
&& s.unresolvedThreadCount === 0
|
|
462
|
-
&& s.actionableThreadCount === 0
|
|
477
|
+
&& s.actionableThreadCount === 0
|
|
478
|
+
&& !s.copilotBodyFeedbackUnresolved;
|
|
463
479
|
|
|
464
480
|
let nextAction = NEXT_ACTIONS[state];
|
|
465
481
|
if (sameHeadCleanConverged) {
|