dev-loops 0.2.7 → 0.4.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 +1 -1
- package/.claude/agents/developer.md +1 -0
- package/.claude/agents/fixer.md +1 -0
- package/.claude/agents/review.md +30 -0
- package/.claude/hooks/_run-context.mjs +9 -16
- package/.claude/skills/copilot-pr-followup/SKILL.md +62 -6
- package/.claude/skills/dev-loop/SKILL.md +6 -6
- package/.claude/skills/docs/anti-patterns.md +2 -0
- package/.claude/skills/docs/copilot-loop-operations.md +3 -3
- package/.claude/skills/docs/issue-intake-procedure.md +4 -0
- package/.claude/skills/docs/merge-preconditions.md +65 -1
- package/.claude/skills/docs/stop-conditions.md +1 -0
- package/.claude/skills/docs/tracker-first-loop-state.md +6 -6
- package/.claude/skills/local-implementation/SKILL.md +25 -5
- package/AGENTS.md +1 -1
- package/CHANGELOG.md +69 -0
- package/README.md +8 -2
- package/agents/developer.agent.md +1 -0
- package/agents/fixer.agent.md +1 -0
- package/agents/review.agent.md +30 -0
- package/cli/index.mjs +60 -8
- package/extension/README.md +1 -1
- package/package.json +3 -3
- package/scripts/README.md +8 -7
- package/scripts/_core-helpers.mjs +1 -0
- package/scripts/claude/headless-dev-loop.mjs +53 -13
- package/scripts/claude/headless-info-smoke.mjs +45 -11
- package/scripts/github/build-adjacent-bundle.mjs +448 -0
- package/scripts/github/{create-draft-pr.mjs → create-pr.mjs} +28 -12
- package/scripts/github/detect-checkpoint-evidence.mjs +95 -4
- package/scripts/github/offer-human-handoff.mjs +147 -0
- package/scripts/github/post-gate-findings.mjs +392 -0
- package/scripts/github/probe-ci-status.mjs +468 -0
- package/scripts/github/reconcile-draft-gate.mjs +2 -2
- package/scripts/github/request-copilot-review.mjs +72 -11
- package/scripts/github/resolve-handoff-candidates.mjs +412 -0
- package/scripts/github/upsert-checkpoint-verdict.mjs +599 -17
- package/scripts/github/verify-fresh-review-context.mjs +12 -1
- package/scripts/github/write-gate-context.mjs +634 -0
- package/scripts/github/write-gate-findings-log.mjs +1 -1
- package/scripts/loop/_stale-runner-detection.mjs +1 -1
- package/scripts/loop/_worktree-path.mjs +27 -0
- package/scripts/loop/cleanup-worktree.mjs +175 -0
- package/scripts/loop/copilot-pr-handoff.mjs +1 -1
- package/scripts/loop/detect-change-scope.mjs +36 -11
- package/scripts/loop/detect-pr-gate-coordination-state.mjs +30 -18
- package/scripts/loop/detect-stale-runner.mjs +3 -4
- package/scripts/loop/detect-tracker-first-loop-state.mjs +38 -11
- package/scripts/loop/ensure-worktree.mjs +219 -0
- package/scripts/loop/outer-loop.mjs +1 -1
- package/scripts/loop/pr-runner-coordination.mjs +1 -1
- package/scripts/loop/pre-flight-gate.mjs +10 -7
- package/scripts/loop/pre-push-main-guard.mjs +4 -4
- package/scripts/loop/provision-worktree.mjs +243 -0
- package/scripts/loop/resolve-dev-loop-startup.mjs +5 -5
- package/scripts/loop/run-queue.mjs +112 -16
- package/scripts/loop/run-watch-cycle.mjs +75 -22
- package/scripts/projects/add-queue-item.mjs +80 -48
- package/scripts/projects/archive-done-items.mjs +136 -39
- package/scripts/projects/ensure-queue-board.mjs +67 -65
- package/scripts/projects/list-queue-items.mjs +59 -57
- package/scripts/projects/move-queue-item.mjs +125 -125
- package/scripts/projects/reorder-queue-item.mjs +67 -48
- package/scripts/projects/sync-item-status.mjs +199 -0
- package/skills/copilot-pr-followup/SKILL.md +62 -6
- package/skills/dev-loop/SKILL.md +2 -2
- package/skills/dev-loop/scripts/log-bash-exit-1.mjs +2 -2
- package/skills/dev-loop/scripts/phase-files.mjs +2 -2
- package/skills/docs/anti-patterns.md +2 -0
- package/skills/docs/copilot-loop-operations.md +3 -3
- package/skills/docs/issue-intake-procedure.md +4 -0
- package/skills/docs/merge-preconditions.md +65 -1
- package/skills/docs/stop-conditions.md +1 -0
- package/skills/docs/tracker-first-loop-state.md +6 -6
- package/skills/local-implementation/SKILL.md +25 -5
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared worktree-path helpers (issue #909). Extracted so create/cleanup agree
|
|
3
|
+
* on how a path is canonicalized for comparison/safety checks.
|
|
4
|
+
*/
|
|
5
|
+
import { realpathSync } from "node:fs";
|
|
6
|
+
import path from "node:path";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Canonicalize for comparison: resolve symlinks on the longest existing prefix
|
|
10
|
+
* (macOS /var → /private/var), keeping any not-yet-created leaf. Lets us match a
|
|
11
|
+
* git-reported worktree path against a target that may not exist yet, and lets a
|
|
12
|
+
* safety prefix-check see through a symlinked namespace dir.
|
|
13
|
+
*/
|
|
14
|
+
export function canonicalize(p) {
|
|
15
|
+
let cur = path.resolve(p);
|
|
16
|
+
const tail = [];
|
|
17
|
+
for (;;) {
|
|
18
|
+
try {
|
|
19
|
+
return path.join(realpathSync(cur), ...tail);
|
|
20
|
+
} catch {
|
|
21
|
+
const parent = path.dirname(cur);
|
|
22
|
+
if (parent === cur) return path.resolve(p); // hit root without resolving
|
|
23
|
+
tail.unshift(path.basename(cur));
|
|
24
|
+
cur = parent;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
}
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Namespace-scoped post-merge worktree cleanup (issue #909).
|
|
4
|
+
*
|
|
5
|
+
* Resolves the canonical worktree path via the shared resolver, then runs
|
|
6
|
+
* `git worktree remove --force <path>` + `git worktree prune` from the main
|
|
7
|
+
* checkout (so it never removes the cwd).
|
|
8
|
+
*
|
|
9
|
+
* CLEANUP-SAFETY INVARIANT: refuses to remove any path NOT under
|
|
10
|
+
* `tmp/worktrees/dev-loops/`. A hand-made `tmp/worktrees/my-experiment` can
|
|
11
|
+
* never be force-removed by the loop.
|
|
12
|
+
*
|
|
13
|
+
* FAIL-SOFT: a git error is logged and reported but does NOT fail the process
|
|
14
|
+
* (exit 0 with a reason) so it never breaks a merge-completion flow. Prints a
|
|
15
|
+
* JSON result to stdout.
|
|
16
|
+
*/
|
|
17
|
+
import { execFileSync } from "node:child_process";
|
|
18
|
+
import path from "node:path";
|
|
19
|
+
import { buildParseError, formatCliError, isDirectCliRun } from "../_core-helpers.mjs";
|
|
20
|
+
import { requireTokenValue } from "../_cli-primitives.mjs";
|
|
21
|
+
import { parseArgs } from "node:util";
|
|
22
|
+
import { resolveWorktreePath, WORKTREE_NAMESPACE } from "@dev-loops/core/loop/handoff-envelope";
|
|
23
|
+
import { canonicalize } from "./_worktree-path.mjs";
|
|
24
|
+
|
|
25
|
+
const USAGE = `Usage:
|
|
26
|
+
cleanup-worktree.mjs --repo-root <p> (--issue <n> | --pr <n> | --path <p>)
|
|
27
|
+
Remove a loop-owned worktree after merge: git worktree remove --force + prune.
|
|
28
|
+
Refuses any path not under ${WORKTREE_NAMESPACE}/.
|
|
29
|
+
Required:
|
|
30
|
+
--repo-root <p> Absolute path to the main checkout (git runs here).
|
|
31
|
+
one of:
|
|
32
|
+
--issue <n> Issue number (resolves the canonical path).
|
|
33
|
+
--pr <n> PR number (resolves the canonical path).
|
|
34
|
+
--path <p> Explicit worktree path (must be under the namespace).
|
|
35
|
+
Optional:
|
|
36
|
+
-h, --help Show this help.
|
|
37
|
+
Output (stdout, JSON):
|
|
38
|
+
{ "ok": bool, "removed": <path>|null, "reason": "<why>" }
|
|
39
|
+
ok is true on success/skip (incl. fail-soft git errors); false ONLY when the
|
|
40
|
+
path is refused for being outside ${WORKTREE_NAMESPACE}/ (removed: null).`.trim();
|
|
41
|
+
|
|
42
|
+
const parseError = buildParseError(USAGE);
|
|
43
|
+
|
|
44
|
+
function parsePositiveInt(value, flag) {
|
|
45
|
+
const n = Number(value);
|
|
46
|
+
if (!Number.isInteger(n) || n < 1) throw parseError(`${flag} must be a positive integer`);
|
|
47
|
+
return n;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function parseCleanupWorktreeCliArgs(argv) {
|
|
51
|
+
const options = { help: false, repoRoot: undefined, issue: undefined, pr: undefined, path: undefined };
|
|
52
|
+
const { tokens } = parseArgs({
|
|
53
|
+
args: [...argv],
|
|
54
|
+
options: {
|
|
55
|
+
help: { type: "boolean", short: "h" },
|
|
56
|
+
"repo-root": { type: "string" },
|
|
57
|
+
issue: { type: "string" },
|
|
58
|
+
pr: { type: "string" },
|
|
59
|
+
path: { type: "string" },
|
|
60
|
+
},
|
|
61
|
+
allowPositionals: true,
|
|
62
|
+
strict: false,
|
|
63
|
+
tokens: true,
|
|
64
|
+
});
|
|
65
|
+
for (const token of tokens) {
|
|
66
|
+
if (token.kind === "positional") throw parseError(`Unknown argument: ${token.value}`);
|
|
67
|
+
if (token.kind !== "option") continue;
|
|
68
|
+
if (token.name === "help") {
|
|
69
|
+
options.help = true;
|
|
70
|
+
return options;
|
|
71
|
+
}
|
|
72
|
+
if (token.name === "repo-root") {
|
|
73
|
+
options.repoRoot = requireTokenValue(token, parseError, { flagPattern: /^-/u });
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
if (token.name === "issue") {
|
|
77
|
+
options.issue = parsePositiveInt(requireTokenValue(token, parseError, { flagPattern: /^-/u }), "--issue");
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
if (token.name === "pr") {
|
|
81
|
+
options.pr = parsePositiveInt(requireTokenValue(token, parseError, { flagPattern: /^-/u }), "--pr");
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
if (token.name === "path") {
|
|
85
|
+
options.path = requireTokenValue(token, parseError, { flagPattern: /^-/u });
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
throw parseError(`Unknown argument: ${token.rawName}`);
|
|
89
|
+
}
|
|
90
|
+
if (options.help) return options;
|
|
91
|
+
if (!options.repoRoot) throw parseError("Missing required --repo-root");
|
|
92
|
+
const selectors = [options.issue, options.pr, options.path].filter((v) => v !== undefined);
|
|
93
|
+
if (selectors.length === 0) throw parseError("One of --issue, --pr, or --path is required");
|
|
94
|
+
if (selectors.length > 1) throw parseError("Provide exactly one of --issue, --pr, or --path");
|
|
95
|
+
return options;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* True when `target` is at or under `<repoRoot>/tmp/worktrees/dev-loops/`.
|
|
100
|
+
* Canonicalizes (realpath) every side first: a purely lexical prefix check is
|
|
101
|
+
* bypassable when the namespace dir (or a parent) is a symlink pointing outside
|
|
102
|
+
* the repo — `git worktree remove --force` would then delete outside the repo.
|
|
103
|
+
* Two checks close that hole: (1) the resolved namespace must still live inside
|
|
104
|
+
* the resolved repo-root, and (2) the resolved target must live inside the
|
|
105
|
+
* resolved namespace.
|
|
106
|
+
*/
|
|
107
|
+
function isUnderNamespace(target, repoRoot) {
|
|
108
|
+
const realRoot = canonicalize(repoRoot);
|
|
109
|
+
const nsRoot = canonicalize(path.join(repoRoot, WORKTREE_NAMESPACE));
|
|
110
|
+
const real = canonicalize(target);
|
|
111
|
+
const within = (child, parent) => child === parent || child.startsWith(parent + path.sep);
|
|
112
|
+
// Namespace must resolve inside the repo (refuses a symlinked-out namespace),
|
|
113
|
+
// and the target must resolve inside that namespace.
|
|
114
|
+
return within(nsRoot, realRoot) && within(real, nsRoot);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export function cleanupWorktree({ repoRoot, issue, pr, path: explicitPath }, { gitCommand = "git" } = {}) {
|
|
118
|
+
const root = path.resolve(repoRoot);
|
|
119
|
+
|
|
120
|
+
let target;
|
|
121
|
+
if (explicitPath !== undefined) {
|
|
122
|
+
target = path.resolve(root, explicitPath);
|
|
123
|
+
} else {
|
|
124
|
+
const kind = issue !== undefined ? "issue" : "pr";
|
|
125
|
+
const number = issue !== undefined ? issue : pr;
|
|
126
|
+
target = resolveWorktreePath({ repoRoot: root, kind, number });
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Cleanup-safety invariant: only ever remove loop-owned worktrees.
|
|
130
|
+
if (!isUnderNamespace(target, root)) {
|
|
131
|
+
return {
|
|
132
|
+
ok: false,
|
|
133
|
+
removed: null,
|
|
134
|
+
reason: `refused: ${target} is not under ${WORKTREE_NAMESPACE}/`,
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
try {
|
|
139
|
+
execFileSync(gitCommand, ["worktree", "remove", "--force", target], {
|
|
140
|
+
cwd: root,
|
|
141
|
+
encoding: "utf8",
|
|
142
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
143
|
+
});
|
|
144
|
+
execFileSync(gitCommand, ["worktree", "prune"], {
|
|
145
|
+
cwd: root,
|
|
146
|
+
encoding: "utf8",
|
|
147
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
148
|
+
});
|
|
149
|
+
} catch (err) {
|
|
150
|
+
// Fail-soft: never break a merge-completion flow on a git error.
|
|
151
|
+
const detail = (err.stderr ?? err.message ?? "").toString().trim();
|
|
152
|
+
return { ok: true, removed: null, reason: `git error (non-fatal): ${detail}` };
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
return { ok: true, removed: target, reason: "removed" };
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export function runCli(argv = process.argv.slice(2), { stdout = process.stdout } = {}) {
|
|
159
|
+
const options = parseCleanupWorktreeCliArgs(argv);
|
|
160
|
+
if (options.help) {
|
|
161
|
+
stdout.write(`${USAGE}\n`);
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
const result = cleanupWorktree(options);
|
|
165
|
+
stdout.write(`${JSON.stringify(result)}\n`);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
if (isDirectCliRun(import.meta.url)) {
|
|
169
|
+
try {
|
|
170
|
+
runCli();
|
|
171
|
+
} catch (error) {
|
|
172
|
+
process.stderr.write(`${formatCliError(error)}\n`);
|
|
173
|
+
process.exitCode = 1;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
@@ -333,7 +333,7 @@ export async function runHandoff(options, { env = process.env, ghCommand = "gh"
|
|
|
333
333
|
let interpretation = interpretLoopState(snapshot, refinementConfig);
|
|
334
334
|
|
|
335
335
|
// Check for human comments since last subagent action
|
|
336
|
-
// Only active in async subagent context (DEVLOOPS_RUN_ID
|
|
336
|
+
// Only active in async subagent context (DEVLOOPS_RUN_ID set)
|
|
337
337
|
let humanCommentCheck = { paused: false };
|
|
338
338
|
if (resolveRunId(env)) {
|
|
339
339
|
humanCommentCheck = await detectRecentHumanComments(
|
|
@@ -1,12 +1,9 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { execFileSync } from "node:child_process";
|
|
3
3
|
import process from "node:process";
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
for (let i = 0; i < args.length; i++) {
|
|
8
|
-
if (args[i] === "--help" || args[i] === "-h") {
|
|
9
|
-
process.stdout.write(`Usage: detect-change-scope.mjs [--base <ref>] [--head <ref>]
|
|
4
|
+
import { parseArgs } from "node:util";
|
|
5
|
+
|
|
6
|
+
const USAGE = `Usage: detect-change-scope.mjs [--base <ref>] [--head <ref>]
|
|
10
7
|
Detect change scope from git diff for light-mode eligibility.
|
|
11
8
|
Options:
|
|
12
9
|
--base <ref> Override base ref (default: HEAD~1)
|
|
@@ -15,11 +12,39 @@ Options:
|
|
|
15
12
|
Exit codes:
|
|
16
13
|
0 Success
|
|
17
14
|
1 Error
|
|
18
|
-
|
|
19
|
-
|
|
15
|
+
`;
|
|
16
|
+
|
|
17
|
+
function parseCliArgs(argv) {
|
|
18
|
+
const { tokens } = parseArgs({
|
|
19
|
+
args: [...argv],
|
|
20
|
+
options: {
|
|
21
|
+
base: { type: "string" },
|
|
22
|
+
head: { type: "string" },
|
|
23
|
+
help: { type: "boolean", short: "h" },
|
|
24
|
+
},
|
|
25
|
+
allowPositionals: true,
|
|
26
|
+
strict: false,
|
|
27
|
+
tokens: true,
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
const opts = { base: null, head: null };
|
|
31
|
+
for (const token of tokens) {
|
|
32
|
+
if (token.kind === "option") {
|
|
33
|
+
if (token.name === "help") {
|
|
34
|
+
if (token.value !== undefined) {
|
|
35
|
+
throw new Error(`unknown argument: ${token.rawName}=${token.value}`);
|
|
36
|
+
}
|
|
37
|
+
process.stdout.write(USAGE);
|
|
38
|
+
process.exit(0);
|
|
39
|
+
}
|
|
40
|
+
if (token.name === "base") {
|
|
41
|
+
opts.base = token.value ?? null;
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
if (token.name === "head") {
|
|
45
|
+
opts.head = token.value ?? null;
|
|
46
|
+
}
|
|
20
47
|
}
|
|
21
|
-
if (args[i] === "--base" && i + 1 < args.length) opts.base = args[++i];
|
|
22
|
-
else if (args[i] === "--head" && i + 1 < args.length) opts.head = args[++i];
|
|
23
48
|
}
|
|
24
49
|
return opts;
|
|
25
50
|
}
|
|
@@ -64,7 +89,7 @@ function isEligibleForLightMode(scope, threshold) {
|
|
|
64
89
|
return scope.filesChanged <= threshold.maxFiles && scope.linesChanged <= threshold.maxLines;
|
|
65
90
|
}
|
|
66
91
|
async function main() {
|
|
67
|
-
const opts =
|
|
92
|
+
const opts = parseCliArgs(process.argv.slice(2));
|
|
68
93
|
const scope = detectScope(opts);
|
|
69
94
|
let threshold = { maxFiles: 3, maxLines: 200 };
|
|
70
95
|
let eligible = false;
|
|
@@ -6,13 +6,13 @@ import {
|
|
|
6
6
|
formatCliError,
|
|
7
7
|
isCopilotLogin,
|
|
8
8
|
isDirectCliRun,
|
|
9
|
-
normalizeTimestamp,
|
|
10
9
|
parseJsonText,
|
|
11
10
|
parseReviewThreads,
|
|
11
|
+
resolveDraftGateRoundResetMs,
|
|
12
12
|
summarizeCopilotReviews,
|
|
13
13
|
} from "../_core-helpers.mjs";
|
|
14
14
|
import { parsePrNumber, requireTokenValue, runChild } from "../_cli-primitives.mjs";
|
|
15
|
-
import { loadDevLoopConfig, resolveGateConfig, resolveRefinementConfig, resolveWorkflowConfig } from "@dev-loops/core/config";
|
|
15
|
+
import { loadDevLoopConfig, resolveGateConfig, resolveRefinement, resolveRefinementConfig, resolveWorkflowConfig } from "@dev-loops/core/config";
|
|
16
16
|
import { parseRepoSlug } from "@dev-loops/core/github/repo-slug";
|
|
17
17
|
import { buildSnapshotFromPrFacts, interpretLoopState, summarizeLoopInterpretation } from "@dev-loops/core/loop/copilot-loop-state";
|
|
18
18
|
import { evaluatePrGateCoordination, PR_CHECKPOINT, PR_CHECKPOINT_ACTION } from "@dev-loops/core/loop/pr-gate-coordination";
|
|
@@ -303,18 +303,13 @@ export async function loadPrGateCoordinationContext(options, runtime = {}) {
|
|
|
303
303
|
const gateEvidence = await detectCheckpointEvidence(options, runtime);
|
|
304
304
|
// When draft gate was re-passed on a different head, use its timestamp
|
|
305
305
|
// to reset the Copilot round count — only reviews after the re-pass count.
|
|
306
|
-
//
|
|
307
|
-
//
|
|
308
|
-
|
|
309
|
-
const
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
&& typeof draftGateHeadSha === "string"
|
|
314
|
-
&& !draftGateOnCurrentHead
|
|
315
|
-
&& typeof gateEvidence.draftGate?.updatedAt === "string"
|
|
316
|
-
? normalizeTimestamp(gateEvidence.draftGate.updatedAt)
|
|
317
|
-
: null;
|
|
306
|
+
// Shared with request-copilot-review so both scripts compute the same
|
|
307
|
+
// completed round count / cap (#896). Prefix matching for the head SHA lets
|
|
308
|
+
// shortened SHAs (7+) from gate comments match the full headRefOid.
|
|
309
|
+
const draftGateResetAtMs = resolveDraftGateRoundResetMs({
|
|
310
|
+
draftGate: gateEvidence.draftGate,
|
|
311
|
+
currentHeadSha,
|
|
312
|
+
});
|
|
318
313
|
const reviewSummary = summarizeCopilotReviews(prData?.reviews, { headSha: currentHeadSha, draftGateResetAtMs });
|
|
319
314
|
const reviewRequestStatus = requestedReviewers.requested
|
|
320
315
|
? "requested"
|
|
@@ -338,8 +333,19 @@ export async function loadPrGateCoordinationContext(options, runtime = {}) {
|
|
|
338
333
|
if (gateEvidence.currentHeadSha !== currentHeadSha) {
|
|
339
334
|
throw new Error(`PR head changed while loading gate coordination facts for ${options.repo}#${options.pr}; refuse to evaluate mixed-head gate state.`);
|
|
340
335
|
}
|
|
341
|
-
|
|
342
|
-
|
|
336
|
+
// Resolve the refinement config (round cap, low-signal heuristic) and feed it to
|
|
337
|
+
// the interpreter. Without it, the interpreter cannot see maxCopilotRounds and so
|
|
338
|
+
// never resolves ROUND_CAP_CLEAN_FALLBACK — a post-cap clean head would fall to
|
|
339
|
+
// READY_TO_REREQUEST_REVIEW, dead-ending the loop at the round cap (#896). This
|
|
340
|
+
// keeps the gate-coordination interpretation consistent with the standalone
|
|
341
|
+
// detect-copilot-loop-state path and with request-copilot-review's cap logic.
|
|
342
|
+
const interpreterRepoRoot = runtime.repoRoot ?? process.cwd();
|
|
343
|
+
const interpreterConfigResult = await loadDevLoopConfig({ repoRoot: interpreterRepoRoot });
|
|
344
|
+
const interpreterRefinementConfig = (Array.isArray(interpreterConfigResult.errors) && interpreterConfigResult.errors.length > 0)
|
|
345
|
+
? resolveRefinement({ version: 1 })
|
|
346
|
+
: resolveRefinement(interpreterConfigResult.config ?? { version: 1 });
|
|
347
|
+
const interpretation = interpretLoopState(snapshot, interpreterRefinementConfig);
|
|
348
|
+
const disposition = summarizeLoopInterpretation(interpretation, interpreterRefinementConfig);
|
|
343
349
|
const mergeStateStatus = typeof prData?.mergeStateStatus === "string" && prData.mergeStateStatus.trim().length > 0
|
|
344
350
|
? prData.mergeStateStatus.trim().toUpperCase()
|
|
345
351
|
: null;
|
|
@@ -362,6 +368,7 @@ export async function loadPrGateCoordinationContext(options, runtime = {}) {
|
|
|
362
368
|
interpretation,
|
|
363
369
|
disposition,
|
|
364
370
|
refinementArtifact,
|
|
371
|
+
refinementConfig: interpreterRefinementConfig,
|
|
365
372
|
};
|
|
366
373
|
}
|
|
367
374
|
|
|
@@ -429,9 +436,13 @@ export async function detectPrGateCoordinationState(options, runtime = {}) {
|
|
|
429
436
|
&& typeof (context.snapshot?.copilotReviewRoundCount) === "number"
|
|
430
437
|
&& context.snapshot?.copilotReviewRoundCount >= maxCopilotRounds;
|
|
431
438
|
const sameHeadCleanConverged = context.interpretation?.sameHeadCleanConverged ?? false;
|
|
439
|
+
// Round-cap clean fallback (#896): the interpreter resolved a clean post-cap head
|
|
440
|
+
// (zero unresolved threads + green CI) that Copilot will not re-review. The formal
|
|
441
|
+
// request guard must not fire here — pre_approval_gate reviews the post-cap head.
|
|
442
|
+
const roundCapCleanFallback = context.interpretation?.roundCapCleanEligible ?? false;
|
|
432
443
|
const copilotReviewEverFormallyRequested = copilotReviewRequestStatus === "none"
|
|
433
444
|
&& guardBoundaries.has(result.gateBoundary)
|
|
434
|
-
&& !(roundCapReached && sameHeadCleanConverged)
|
|
445
|
+
&& !(roundCapReached && (sameHeadCleanConverged || roundCapCleanFallback))
|
|
435
446
|
? await fetchCopilotEverFormallyRequested(
|
|
436
447
|
{ repo: context.repo, pr: context.pr },
|
|
437
448
|
runtime,
|
|
@@ -442,7 +453,8 @@ export async function detectPrGateCoordinationState(options, runtime = {}) {
|
|
|
442
453
|
copilotReviewRoundCount: context.snapshot?.copilotReviewRoundCount ?? 0,
|
|
443
454
|
copilotReviewEverFormallyRequested,
|
|
444
455
|
maxCopilotRounds,
|
|
445
|
-
sameHeadCleanConverged
|
|
456
|
+
sameHeadCleanConverged,
|
|
457
|
+
roundCapCleanFallback,
|
|
446
458
|
gateBoundary: result.gateBoundary,
|
|
447
459
|
})) {
|
|
448
460
|
result.gateBoundary = PR_CHECKPOINT.POST_DRAFT_EXTERNAL_REVIEW;
|
|
@@ -19,11 +19,10 @@ Required:
|
|
|
19
19
|
Optional:
|
|
20
20
|
--stale-runner-max-age-ms <ms>
|
|
21
21
|
Override the staleness threshold (default 30 minutes,
|
|
22
|
-
or $
|
|
22
|
+
or $DEVLOOPS_STALE_RUNNER_MAX_AGE_MS).
|
|
23
23
|
--run-id <id> Override the active run id (default: read from
|
|
24
|
-
DEVLOOPS_RUN_ID
|
|
25
|
-
|
|
26
|
-
the current run id is still the active owner.
|
|
24
|
+
DEVLOOPS_RUN_ID). When supplied, the detector additionally
|
|
25
|
+
verifies the current run id is still the active owner.
|
|
27
26
|
Output (stdout, JSON; always includes staleRunnerCheck):
|
|
28
27
|
{
|
|
29
28
|
"ok": true,
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import process from "node:process";
|
|
3
3
|
import { execFileSync } from "node:child_process";
|
|
4
|
+
import { parseArgs } from "node:util";
|
|
4
5
|
import { interpretTrackerLoopState } from "@dev-loops/core/loop/tracker-first-loop-state";
|
|
6
|
+
|
|
5
7
|
function showHelp() {
|
|
6
8
|
process.stdout.write(`Usage: detect-tracker-first-loop-state.mjs --repo <owner/name> --issue <number>
|
|
7
9
|
Detect tracker-first loop state for a GitHub issue.
|
|
@@ -15,40 +17,65 @@ Exit codes:
|
|
|
15
17
|
`);
|
|
16
18
|
process.exit(0);
|
|
17
19
|
}
|
|
18
|
-
|
|
19
|
-
|
|
20
|
+
|
|
21
|
+
function parseCliArgs(argv) {
|
|
22
|
+
const { tokens } = parseArgs({
|
|
23
|
+
args: [...argv],
|
|
24
|
+
options: {
|
|
25
|
+
repo: { type: "string" },
|
|
26
|
+
issue: { type: "string" },
|
|
27
|
+
help: { type: "boolean", short: "h" },
|
|
28
|
+
},
|
|
29
|
+
allowPositionals: true,
|
|
30
|
+
strict: false,
|
|
31
|
+
tokens: true,
|
|
32
|
+
});
|
|
33
|
+
|
|
20
34
|
const opts = { repo: null, issue: null };
|
|
21
|
-
for (
|
|
22
|
-
if (
|
|
23
|
-
|
|
35
|
+
for (const token of tokens) {
|
|
36
|
+
if (token.kind === "option") {
|
|
37
|
+
if (token.name === "help") {
|
|
38
|
+
if (token.value !== undefined) {
|
|
39
|
+
throw new Error(`unknown argument: ${token.rawName}=${token.value}`);
|
|
40
|
+
}
|
|
41
|
+
showHelp();
|
|
42
|
+
}
|
|
43
|
+
if (token.name === "repo") {
|
|
44
|
+
opts.repo = token.value ?? null;
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
if (token.name === "issue") {
|
|
48
|
+
opts.issue = token.value ?? null;
|
|
49
|
+
}
|
|
24
50
|
}
|
|
25
|
-
if (args[i] === "--repo" && i + 1 < args.length) opts.repo = args[++i];
|
|
26
|
-
else if (args[i] === "--issue" && i + 1 < args.length) opts.issue = args[++i];
|
|
27
51
|
}
|
|
28
52
|
return opts;
|
|
29
53
|
}
|
|
54
|
+
|
|
30
55
|
async function main() {
|
|
31
|
-
const
|
|
32
|
-
if (!
|
|
56
|
+
const rawOpts = parseCliArgs(process.argv.slice(2));
|
|
57
|
+
if (!rawOpts.repo || !rawOpts.issue) {
|
|
33
58
|
process.stderr.write(
|
|
34
59
|
JSON.stringify({ ok: false, error: "--repo and --issue required" }) + "\n"
|
|
35
60
|
);
|
|
36
61
|
process.exitCode = 1;
|
|
37
62
|
return;
|
|
38
63
|
}
|
|
64
|
+
const repo = rawOpts.repo;
|
|
65
|
+
const issue = rawOpts.issue;
|
|
39
66
|
let rawState = "";
|
|
40
67
|
let prContext = null;
|
|
41
68
|
try {
|
|
42
69
|
const issueJson = execFileSync(
|
|
43
70
|
"gh",
|
|
44
|
-
["issue", "view", String(
|
|
71
|
+
["issue", "view", String(issue), "--repo", repo, "--json", "state,title", "--jq", ".state"],
|
|
45
72
|
{ encoding: "utf8" }
|
|
46
73
|
).trim();
|
|
47
74
|
rawState = issueJson;
|
|
48
75
|
try {
|
|
49
76
|
const prJson = execFileSync(
|
|
50
77
|
"gh",
|
|
51
|
-
["pr", "list", "--repo",
|
|
78
|
+
["pr", "list", "--repo", repo, "--search", `${issue} in:body`, "--state", "open", "--json", "number,state,headRefName", "--jq", ".[0]"],
|
|
52
79
|
{ encoding: "utf8", stdio: ["pipe", "pipe", "ignore"] }
|
|
53
80
|
).trim();
|
|
54
81
|
if (prJson) prContext = JSON.parse(prJson);
|