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
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
import fs from "node:fs";
|
|
1
3
|
import path from "node:path";
|
|
2
4
|
import process from "node:process";
|
|
3
5
|
import { parseRepoSlugParts } from "@dev-loops/core/github/repo-slug";
|
|
@@ -9,6 +11,7 @@ import {
|
|
|
9
11
|
} from "./_steering-state-file.mjs";
|
|
10
12
|
export const RUNNER_COORDINATION_SCHEMA_VERSION = 2;
|
|
11
13
|
export const RUNNER_COORDINATION_SUPPORTED_SCHEMA_VERSIONS = Object.freeze([1, 2]);
|
|
14
|
+
export const RUNNER_COORDINATION_HISTORY_LIMIT = 50; // cap audit trail; heartbeats append per-round, keep the most recent 50 events
|
|
12
15
|
export const RUNNER_OWNERSHIP_ERROR = Object.freeze({
|
|
13
16
|
ACTIVE_RUN_EXISTS: "active_run_exists",
|
|
14
17
|
OWNERSHIP_LOST: "ownership_lost",
|
|
@@ -50,7 +53,10 @@ async function loadRunnerStateFile(filePath) {
|
|
|
50
53
|
}
|
|
51
54
|
async function saveRunnerStateFile(filePath, state) {
|
|
52
55
|
try {
|
|
53
|
-
|
|
56
|
+
const capped = Array.isArray(state.history) && state.history.length > RUNNER_COORDINATION_HISTORY_LIMIT
|
|
57
|
+
? { ...state, history: state.history.slice(-RUNNER_COORDINATION_HISTORY_LIMIT) }
|
|
58
|
+
: state;
|
|
59
|
+
return await saveSharedStateFile(filePath, capped);
|
|
54
60
|
} catch (error) {
|
|
55
61
|
const message = error instanceof Error ? error.message : String(error);
|
|
56
62
|
throw new Error(`Failed to write runner coordination state file '${filePath}': ${message}`);
|
|
@@ -64,13 +70,62 @@ async function withRunnerStateFileLock(filePath, callback) {
|
|
|
64
70
|
throw new Error(`Failed to acquire runner coordination state lock for '${filePath}': ${message}`);
|
|
65
71
|
}
|
|
66
72
|
}
|
|
73
|
+
const coordinationRootCache = new Map();
|
|
74
|
+
/**
|
|
75
|
+
* Resolve the single stable coordination root for a checkout, independent of CWD.
|
|
76
|
+
*
|
|
77
|
+
* `git rev-parse --git-common-dir` yields the shared git dir that is identical for
|
|
78
|
+
* a repo and all its linked worktrees, so its parent is the one main-checkout root
|
|
79
|
+
* that a worktree runner and a repo-root detector both anchor to — eliminating the
|
|
80
|
+
* split-copy false-stale stall where each read a different `.pi/runner-coordination`
|
|
81
|
+
* file. Falls back to the canonicalized (realpath'd) `cwd` when git is
|
|
82
|
+
* unavailable or the dir is not a checkout, so symlinked and realpath'd
|
|
83
|
+
* spellings of the same non-git dir still converge on one coordination path.
|
|
84
|
+
*
|
|
85
|
+
* `cwd` is realpath'd once at entry: git returns a relative `--git-common-dir` from
|
|
86
|
+
* a main checkout but an already-realpath'd absolute one from a linked worktree, so
|
|
87
|
+
* resolving against a symlinked cwd (e.g. macOS /tmp -> /private/tmp) would make the
|
|
88
|
+
* two sides compute different roots for the same physical repo. Canonicalizing first
|
|
89
|
+
* makes both sides converge, and doubles as the cache key so symlink-variant cwd
|
|
90
|
+
* spellings share one cache entry. Cached per canonical cwd for the process.
|
|
91
|
+
*/
|
|
92
|
+
function resolveRepoCoordinationRoot(cwd) {
|
|
93
|
+
let canonicalCwd = cwd;
|
|
94
|
+
try {
|
|
95
|
+
canonicalCwd = fs.realpathSync(cwd);
|
|
96
|
+
} catch (err) {
|
|
97
|
+
// realpathSync on an existing checkout dir virtually never fails;
|
|
98
|
+
// warn (don't throw) so the rare transient failure — which can desync the
|
|
99
|
+
// coordination path across worktrees — is diagnosable instead of silent.
|
|
100
|
+
console.warn(
|
|
101
|
+
`[runner-coordination] realpathSync(${cwd}) failed; using raw cwd, coordination path may diverge across worktrees: ${err instanceof Error ? err.message : String(err)}`,
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
if (coordinationRootCache.has(canonicalCwd)) return coordinationRootCache.get(canonicalCwd);
|
|
105
|
+
let root = canonicalCwd;
|
|
106
|
+
try {
|
|
107
|
+
const commonDir = execFileSync("git", ["rev-parse", "--git-common-dir"], {
|
|
108
|
+
cwd: canonicalCwd,
|
|
109
|
+
encoding: "utf8",
|
|
110
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
111
|
+
}).trim();
|
|
112
|
+
if (commonDir) {
|
|
113
|
+
root = path.dirname(path.resolve(canonicalCwd, commonDir));
|
|
114
|
+
}
|
|
115
|
+
} catch {
|
|
116
|
+
// not a git checkout / git unavailable — anchor at the canonical (realpath'd) cwd
|
|
117
|
+
}
|
|
118
|
+
coordinationRootCache.set(canonicalCwd, root);
|
|
119
|
+
return root;
|
|
120
|
+
}
|
|
67
121
|
export function defaultRunnerCoordinationFilePathForTarget({ repo, pr }, cwd = process.cwd()) {
|
|
68
122
|
const { owner, name } = parseRepoSlugParts(repo, {
|
|
69
123
|
errorMessage: `Invalid repo slug for coordination target path: ${JSON.stringify(repo)}`,
|
|
70
124
|
lowercase: true,
|
|
71
125
|
});
|
|
72
126
|
const normalizedPr = normalizePr(pr);
|
|
73
|
-
|
|
127
|
+
const root = resolveRepoCoordinationRoot(cwd);
|
|
128
|
+
return path.join(root, ".pi", "runner-coordination", owner, name, `pr-${normalizedPr}.json`);
|
|
74
129
|
}
|
|
75
130
|
export function createRunnerCoordinationState({ repo, pr, runId = null, now = new Date().toISOString() }) {
|
|
76
131
|
const normalizedRepo = normalizeRepoSlug(repo);
|
|
@@ -324,6 +379,18 @@ export async function claimRunnerOwnership({
|
|
|
324
379
|
};
|
|
325
380
|
});
|
|
326
381
|
}
|
|
382
|
+
/**
|
|
383
|
+
* Verify the caller still owns the PR's runner claim.
|
|
384
|
+
*
|
|
385
|
+
* A successful owner-confirmed assert (the active owner's runId matches the
|
|
386
|
+
* caller's) refreshes `activeRun.updatedAt` — it acts as a heartbeat. Every
|
|
387
|
+
* other outcome (no record, no active run, ownership lost, missing run id)
|
|
388
|
+
* is a pure lock-free read with no write. Long-running loops must assert (or
|
|
389
|
+
* claim) at least once within the stale-max-age window
|
|
390
|
+
* (`STALE_RUNNER_DEFAULT_MAX_AGE_MS`, 30 minutes by default, overridable via
|
|
391
|
+
* `DEVLOOPS_STALE_RUNNER_MAX_AGE_MS`; see `_stale-runner-detection.mjs`) to
|
|
392
|
+
* avoid being seen as stale during a long multi-round gate cycle.
|
|
393
|
+
*/
|
|
327
394
|
export async function assertRunnerOwnership({
|
|
328
395
|
repo,
|
|
329
396
|
pr,
|
|
@@ -331,6 +398,7 @@ export async function assertRunnerOwnership({
|
|
|
331
398
|
cwd = process.cwd(),
|
|
332
399
|
filePath = null,
|
|
333
400
|
requireExisting = false,
|
|
401
|
+
now = new Date().toISOString(),
|
|
334
402
|
} = {}) {
|
|
335
403
|
const normalizedRepo = normalizeRepoSlug(repo);
|
|
336
404
|
const normalizedPr = normalizePr(pr);
|
|
@@ -397,17 +465,48 @@ export async function assertRunnerOwnership({
|
|
|
397
465
|
});
|
|
398
466
|
}
|
|
399
467
|
if (state.activeRun.runId === normalizedRunId) {
|
|
400
|
-
return {
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
runId
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
468
|
+
return withRunnerStateFileLock(resolvedPath, async () => {
|
|
469
|
+
const lockedRaw = await loadRunnerStateFile(resolvedPath);
|
|
470
|
+
const lockedState = lockedRaw === null
|
|
471
|
+
? null
|
|
472
|
+
: normalizeRunnerCoordinationState(lockedRaw, { repo: normalizedRepo, pr: normalizedPr });
|
|
473
|
+
if (lockedState?.activeRun?.runId !== normalizedRunId) {
|
|
474
|
+
// Ownership changed hands between the lockless read above and acquiring
|
|
475
|
+
// the lock (a concurrent takeover) — don't write; report the new owner.
|
|
476
|
+
return buildConflict({
|
|
477
|
+
error: RUNNER_OWNERSHIP_ERROR.OWNERSHIP_LOST,
|
|
478
|
+
repo: normalizedRepo,
|
|
479
|
+
pr: normalizedPr,
|
|
480
|
+
runId: normalizedRunId,
|
|
481
|
+
activeRun: lockedState?.activeRun ?? null,
|
|
482
|
+
filePath: resolvedPath,
|
|
483
|
+
exitSignals: lockedState?.exitSignals ?? [],
|
|
484
|
+
message: lockedState?.activeRun?.runId
|
|
485
|
+
? `PR ${normalizedRepo}#${normalizedPr} is now owned by run ${lockedState.activeRun.runId}; run ${normalizedRunId} must stop.`
|
|
486
|
+
: `PR ${normalizedRepo}#${normalizedPr} no longer has an active runner ownership record; run ${normalizedRunId} must stop.`,
|
|
487
|
+
});
|
|
488
|
+
}
|
|
489
|
+
const nextState = {
|
|
490
|
+
...lockedState,
|
|
491
|
+
activeRun: {
|
|
492
|
+
...lockedState.activeRun,
|
|
493
|
+
updatedAt: now,
|
|
494
|
+
},
|
|
495
|
+
history: [...lockedState.history, { type: "heartbeat", runId: normalizedRunId, at: now }],
|
|
496
|
+
};
|
|
497
|
+
await saveRunnerStateFile(resolvedPath, nextState);
|
|
498
|
+
return {
|
|
499
|
+
ok: true,
|
|
500
|
+
status: "owner_confirmed",
|
|
501
|
+
repo: normalizedRepo,
|
|
502
|
+
pr: normalizedPr,
|
|
503
|
+
runId: normalizedRunId,
|
|
504
|
+
activeRun: nextState.activeRun,
|
|
505
|
+
previousRun: nextState.previousRun,
|
|
506
|
+
exitSignals: nextState.exitSignals,
|
|
507
|
+
filePath: resolvedPath,
|
|
508
|
+
};
|
|
509
|
+
});
|
|
411
510
|
}
|
|
412
511
|
return buildConflict({
|
|
413
512
|
error: RUNNER_OWNERSHIP_ERROR.OWNERSHIP_LOST,
|
|
@@ -24,10 +24,13 @@
|
|
|
24
24
|
* - dev-loops subcommands and `node scripts/....mjs` invocations. Those scripts
|
|
25
25
|
* legitimately call gh/GraphQL/etc. internally — that IS the tooling.
|
|
26
26
|
* - A small explicit allowlist of write-ops that have no internal wrapper today:
|
|
27
|
-
* `gh pr merge`, `gh pr ready`, `gh issue create
|
|
28
|
-
*
|
|
27
|
+
* `gh pr merge`, `gh pr ready`, `gh issue create`. Plus belt-and-suspenders
|
|
28
|
+
* entries that DO have wrappers — `gh issue edit` (scripts/github/edit-issue.mjs)
|
|
29
|
+
* and `gh label create` (scripts/github/create-label.mjs) — kept so a bare
|
|
30
|
+
* invocation surfaced from the wrapper's own subprocess is not a false violation.
|
|
31
|
+
* All are recorded as `allowedWriteOps` rather than violations so the gate is not
|
|
29
32
|
* blocked forever on an unavoidable gap. Document/close the gap with a wrapper
|
|
30
|
-
* to remove
|
|
33
|
+
* to remove the no-wrapper entries from the allowlist.
|
|
31
34
|
*
|
|
32
35
|
* VIOLATION (agent-level raw call):
|
|
33
36
|
* - `gh ...` at the start of a command segment (start of line, or after
|
|
@@ -75,19 +78,21 @@ Exit codes:
|
|
|
75
78
|
2 Argument/runtime error, or invalid --jq filter`;
|
|
76
79
|
|
|
77
80
|
/**
|
|
78
|
-
* Write-ops
|
|
79
|
-
*
|
|
80
|
-
*
|
|
81
|
+
* Write-ops recorded distinctly (as allowedWriteOps, not violations) so the gate
|
|
82
|
+
* is not blocked forever on an unavoidable gap. Most have no internal wrapper yet;
|
|
83
|
+
* a couple DO have wrappers and are kept belt-and-suspenders (see below).
|
|
84
|
+
* Keep this set SMALL and explicit; remove a no-wrapper entry once a wrapper exists.
|
|
81
85
|
* @type {ReadonlyArray<RegExp>}
|
|
82
86
|
*/
|
|
83
87
|
const ALLOWED_WRITE_OPS = Object.freeze([
|
|
84
88
|
/^gh\s+pr\s+merge\b/,
|
|
85
89
|
/^gh\s+pr\s+ready\b/,
|
|
86
90
|
/^gh\s+issue\s+create\b/,
|
|
91
|
+
// `gh issue edit` (scripts/github/edit-issue.mjs) and `gh label create`
|
|
92
|
+
// (scripts/github/create-label.mjs) both HAVE wrappers; these entries are
|
|
93
|
+
// belt-and-suspenders so a bare invocation (e.g. surfaced from the wrapper's
|
|
94
|
+
// own subprocess) classifies as an allowed write-op, not a violation.
|
|
87
95
|
/^gh\s+issue\s+edit\b/,
|
|
88
|
-
// `gh label create` HAS a wrapper (scripts/github/create-label.mjs); this
|
|
89
|
-
// entry is belt-and-suspenders so a bare invocation (e.g. surfaced from the
|
|
90
|
-
// wrapper's own subprocess) classifies as an allowed write-op, not a violation.
|
|
91
96
|
/^gh\s+label\s+create\b/,
|
|
92
97
|
]);
|
|
93
98
|
|
|
@@ -1,14 +1,15 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { buildParseError, formatCliError, isCopilotLogin, isDirectCliRun, normalizeTimestamp, parseJsonText } from "../_core-helpers.mjs";
|
|
3
|
-
import { parsePrNumber, requireTokenValue, runChild } from "../_cli-primitives.mjs";
|
|
3
|
+
import { parsePrNumber, requireTokenValue, runChild as defaultRunChild } from "../_cli-primitives.mjs";
|
|
4
4
|
import { detectPostConvergenceSignificantChange } from "./_post-convergence-change.mjs";
|
|
5
5
|
import { detectRepoSlug, parseRepoSlug } from "@dev-loops/core/github/repo-slug";
|
|
6
6
|
import { resolveRunId } from "@dev-loops/core/loop/run-context";
|
|
7
|
-
import { loadDevLoopConfig, resolveRefinement } from "@dev-loops/core/config";
|
|
7
|
+
import { loadDevLoopConfig, resolveEffectiveCopilotRoundCap, resolveRefinement } from "@dev-loops/core/config";
|
|
8
8
|
import { autoDetectSnapshot } from "./detect-copilot-loop-state.mjs";
|
|
9
9
|
import { performCopilotReviewRequest } from "../github/request-copilot-review.mjs";
|
|
10
10
|
import { detectInternalOnly as detectPrInternalOnly } from "./detect-internal-only-pr.mjs";
|
|
11
11
|
import { applyConfirmedReviewRequest, interpretLoopState, NEXT_ACTIONS, STATE, summarizeLoopInterpretation, TRANSITIONS } from "@dev-loops/core/loop/copilot-loop-state";
|
|
12
|
+
import { PR_LIFECYCLE_STATE } from "@dev-loops/core/loop/pr-lifecycle";
|
|
12
13
|
import { ensureAsyncRunnerOwnership, releaseAsyncRunnerOwnership } from "./_pr-runner-coordination.mjs";
|
|
13
14
|
import { resolveRepoRoot } from "./_repo-root-resolver.mjs";
|
|
14
15
|
|
|
@@ -38,6 +39,13 @@ Optional:
|
|
|
38
39
|
--watch-status <status> Refresh deterministic loop state after a prior
|
|
39
40
|
watcher result (changed|timeout|idle). This mode
|
|
40
41
|
never requests review; it only re-detects state.
|
|
42
|
+
--lightweight This PR is light-dispatched (#1210): compose the
|
|
43
|
+
Copilot round cap with localImplementation.lightMode.
|
|
44
|
+
maxCopilotRounds (default 1) via
|
|
45
|
+
min(lightMode.maxCopilotRounds, refinement.maxCopilotRounds)
|
|
46
|
+
instead of using refinement.maxCopilotRounds alone.
|
|
47
|
+
refinement.maxCopilotRounds: 0 still disables Copilot
|
|
48
|
+
rounds even with --lightweight.
|
|
41
49
|
Output (stdout, JSON):
|
|
42
50
|
{ "ok": true, "action": "watch"|"fix"|"stop", "state": "...",
|
|
43
51
|
"allowedTransitions": [...], "nextAction": "...", "snapshot": {...},
|
|
@@ -100,7 +108,7 @@ function summarizeRequestWatchContract({
|
|
|
100
108
|
|| interpretation.state === STATE.READY_TO_REREQUEST_REVIEW
|
|
101
109
|
&& interpretation.sameHeadCleanConverged !== true
|
|
102
110
|
) {
|
|
103
|
-
routingState =
|
|
111
|
+
routingState = PR_LIFECYCLE_STATE.READY_STATE_NEEDS_COPILOT_REQUEST;
|
|
104
112
|
} else if (interpretation.state === STATE.INTERNAL_TOOLING_DIRECT_GATE) {
|
|
105
113
|
routingState = "internal_tooling_skip_copilot";
|
|
106
114
|
}
|
|
@@ -138,6 +146,7 @@ export function parseHandoffCliArgs(argv, { cwd = process.cwd() } = {}) {
|
|
|
138
146
|
repo: undefined,
|
|
139
147
|
pr: undefined,
|
|
140
148
|
watchStatus: undefined,
|
|
149
|
+
lightweight: false,
|
|
141
150
|
jq: undefined,
|
|
142
151
|
silent: false,
|
|
143
152
|
};
|
|
@@ -148,6 +157,7 @@ export function parseHandoffCliArgs(argv, { cwd = process.cwd() } = {}) {
|
|
|
148
157
|
repo: { type: "string" },
|
|
149
158
|
pr: { type: "string" },
|
|
150
159
|
"watch-status": { type: "string" },
|
|
160
|
+
lightweight: { type: "boolean" },
|
|
151
161
|
...JQ_OUTPUT_PARSE_OPTIONS,
|
|
152
162
|
},
|
|
153
163
|
allowPositionals: true,
|
|
@@ -184,6 +194,10 @@ export function parseHandoffCliArgs(argv, { cwd = process.cwd() } = {}) {
|
|
|
184
194
|
options.watchStatus = watchStatus;
|
|
185
195
|
continue;
|
|
186
196
|
}
|
|
197
|
+
if (token.name === "lightweight") {
|
|
198
|
+
options.lightweight = true;
|
|
199
|
+
continue;
|
|
200
|
+
}
|
|
187
201
|
if (matchJqOutputToken(token, options, (t) => requireTokenValue(t, parseError))) continue;
|
|
188
202
|
throw parseError(`Unknown argument: ${token.rawName}`);
|
|
189
203
|
}
|
|
@@ -213,7 +227,7 @@ export function parseHandoffCliArgs(argv, { cwd = process.cwd() } = {}) {
|
|
|
213
227
|
* loop should pause for operator review.
|
|
214
228
|
* Returns { paused: true, humanComments: [...] } when human comments need attention.
|
|
215
229
|
*/
|
|
216
|
-
export async function detectRecentHumanComments({ repo, pr, claimedAtMs }, { env = process.env, ghCommand = "gh" } = {}) {
|
|
230
|
+
export async function detectRecentHumanComments({ repo, pr, claimedAtMs }, { env = process.env, ghCommand = "gh", runChild = defaultRunChild } = {}) {
|
|
217
231
|
try {
|
|
218
232
|
const result = await runChild(
|
|
219
233
|
ghCommand,
|
|
@@ -298,7 +312,7 @@ export async function detectRecentHumanComments({ repo, pr, claimedAtMs }, { env
|
|
|
298
312
|
// (#1103, #1126): the current head, the Copilot reviews (to find the last
|
|
299
313
|
// reviewed head), and the PR's changed files. Fetched only when the interpreter
|
|
300
314
|
// already resolved ROUND_CAP_CLEAN_FALLBACK, so this extra call is off the hot path.
|
|
301
|
-
async function fetchReopenCycleFacts({ repo, pr }, { env = process.env, ghCommand = "gh" } = {}) {
|
|
315
|
+
async function fetchReopenCycleFacts({ repo, pr }, { env = process.env, ghCommand = "gh", runChild = defaultRunChild } = {}) {
|
|
302
316
|
const result = await runChild(
|
|
303
317
|
ghCommand,
|
|
304
318
|
["pr", "view", String(pr), "--repo", repo, "--json", "headRefOid,reviews,files"],
|
|
@@ -314,12 +328,16 @@ async function fetchReopenCycleFacts({ repo, pr }, { env = process.env, ghComman
|
|
|
314
328
|
}
|
|
315
329
|
}
|
|
316
330
|
|
|
317
|
-
export async function runHandoff(options, { env = process.env, ghCommand = "gh" } = {}) {
|
|
331
|
+
export async function runHandoff(options, { env = process.env, ghCommand = "gh", runChild = defaultRunChild, repoRoot } = {}) {
|
|
332
|
+
// Single resolved repo root for config + runner-coordination reads/writes.
|
|
333
|
+
// Defaults to the checkout's git toplevel (production); an injected repoRoot
|
|
334
|
+
// keeps the whole cascade hermetic when driven in-process.
|
|
335
|
+
const resolvedRepoRoot = repoRoot ?? resolveRepoRoot(process.cwd());
|
|
318
336
|
const runnerOwnership = await ensureAsyncRunnerOwnership({
|
|
319
337
|
repo: options.repo,
|
|
320
338
|
pr: options.pr,
|
|
321
339
|
env,
|
|
322
|
-
cwd:
|
|
340
|
+
cwd: resolvedRepoRoot,
|
|
323
341
|
claimIfMissing: true,
|
|
324
342
|
});
|
|
325
343
|
if (!runnerOwnership.ok) {
|
|
@@ -349,15 +367,24 @@ export async function runHandoff(options, { env = process.env, ghCommand = "gh"
|
|
|
349
367
|
}
|
|
350
368
|
let snapshot = await autoDetectSnapshot(
|
|
351
369
|
{ repo: options.repo, pr: options.pr },
|
|
352
|
-
{ env, ghCommand },
|
|
370
|
+
{ env, ghCommand, runChild },
|
|
353
371
|
);
|
|
354
|
-
const config = await loadDevLoopConfig({ repoRoot:
|
|
372
|
+
const config = await loadDevLoopConfig({ repoRoot: resolvedRepoRoot });
|
|
355
373
|
if (config.errors?.length > 0) {
|
|
356
374
|
console.error("[copilot-pr-handoff] config warnings:", JSON.stringify(config.errors));
|
|
357
375
|
}
|
|
358
376
|
const refinementConfig = config.errors?.length > 0
|
|
359
377
|
? resolveRefinement({ version: 1 })
|
|
360
378
|
: resolveRefinement(config.config);
|
|
379
|
+
if (options.lightweight) {
|
|
380
|
+
// Compose (not replace) the round cap for light-dispatched PRs (#1210):
|
|
381
|
+
// min(lightMode.maxCopilotRounds ?? 1, refinement.maxCopilotRounds), so
|
|
382
|
+
// maxCopilotRounds: 0 still disables Copilot rounds everywhere.
|
|
383
|
+
refinementConfig.maxCopilotRounds = resolveEffectiveCopilotRoundCap(
|
|
384
|
+
config.errors?.length > 0 ? { version: 1 } : config.config,
|
|
385
|
+
{ lightweight: true },
|
|
386
|
+
);
|
|
387
|
+
}
|
|
361
388
|
let interpretation = interpretLoopState(snapshot, refinementConfig);
|
|
362
389
|
|
|
363
390
|
// Check for human comments since last subagent action
|
|
@@ -366,7 +393,7 @@ export async function runHandoff(options, { env = process.env, ghCommand = "gh"
|
|
|
366
393
|
if (resolveRunId(env)) {
|
|
367
394
|
humanCommentCheck = await detectRecentHumanComments(
|
|
368
395
|
{ repo: options.repo, pr: options.pr, claimedAtMs: runnerOwnership?.activeRun?.claimedAt ? new Date(runnerOwnership.activeRun.claimedAt).getTime() : undefined },
|
|
369
|
-
{ env, ghCommand },
|
|
396
|
+
{ env, ghCommand, runChild },
|
|
370
397
|
);
|
|
371
398
|
}
|
|
372
399
|
const TERMINAL_STATES = new Set([STATE.NO_PR, STATE.DONE, STATE.BLOCKED_NEEDS_USER_DECISION]);
|
|
@@ -376,7 +403,7 @@ export async function runHandoff(options, { env = process.env, ghCommand = "gh"
|
|
|
376
403
|
repo: options.repo,
|
|
377
404
|
pr: options.pr,
|
|
378
405
|
env,
|
|
379
|
-
cwd:
|
|
406
|
+
cwd: resolvedRepoRoot,
|
|
380
407
|
});
|
|
381
408
|
return {
|
|
382
409
|
ok: true,
|
|
@@ -444,7 +471,7 @@ export async function runHandoff(options, { env = process.env, ghCommand = "gh"
|
|
|
444
471
|
interpretation.state === STATE.READY_TO_REREQUEST_REVIEW)
|
|
445
472
|
) {
|
|
446
473
|
try {
|
|
447
|
-
const internalCheck = await detectPrInternalOnly(options, { env, ghCommand });
|
|
474
|
+
const internalCheck = await detectPrInternalOnly(options, { env, ghCommand, runChild });
|
|
448
475
|
if (internalCheck.ok && internalCheck.internalOnly) {
|
|
449
476
|
internalOnlySkipCopilot = true;
|
|
450
477
|
interpretation = {
|
|
@@ -503,7 +530,7 @@ export async function runHandoff(options, { env = process.env, ghCommand = "gh"
|
|
|
503
530
|
if (!internalOnlySkipCopilot
|
|
504
531
|
&& options.watchStatus === undefined
|
|
505
532
|
&& interpretation.state === STATE.ROUND_CAP_CLEAN_FALLBACK) {
|
|
506
|
-
const reopenFacts = await fetchReopenCycleFacts(options, { env, ghCommand });
|
|
533
|
+
const reopenFacts = await fetchReopenCycleFacts(options, { env, ghCommand, runChild });
|
|
507
534
|
const significant = await detectPostConvergenceSignificantChange(
|
|
508
535
|
{
|
|
509
536
|
repo: options.repo,
|
|
@@ -514,7 +541,7 @@ export async function runHandoff(options, { env = process.env, ghCommand = "gh"
|
|
|
514
541
|
roundCapReached: true,
|
|
515
542
|
regularCopilotRounds: (snapshot.copilotReviewRoundCount ?? 0) > 0,
|
|
516
543
|
},
|
|
517
|
-
{ env, ghCommand },
|
|
544
|
+
{ env, ghCommand, runChild },
|
|
518
545
|
);
|
|
519
546
|
if (significant) {
|
|
520
547
|
reopenedCapCycle = true;
|
|
@@ -545,8 +572,12 @@ export async function runHandoff(options, { env = process.env, ghCommand = "gh"
|
|
|
545
572
|
// still refuses unless the head actually advanced past the last review
|
|
546
573
|
// (its hasNewCommits guard), so this cannot force an over-cap same-head request.
|
|
547
574
|
forceRerequestReview: reopenedCapCycle,
|
|
575
|
+
// Thread the light-dispatch signal so the requester enforces the same
|
|
576
|
+
// composed round cap this handoff resolved (#1210) — the enforcement
|
|
577
|
+
// backstop must never permit rounds the interpreter already forbids.
|
|
578
|
+
lightweight: options.lightweight,
|
|
548
579
|
},
|
|
549
|
-
{ env, ghCommand },
|
|
580
|
+
{ env, ghCommand, runChild },
|
|
550
581
|
);
|
|
551
582
|
reviewRequestStatus = requestResult.status;
|
|
552
583
|
snapshot = applyConfirmedReviewRequest(snapshot, reviewRequestStatus);
|
|
@@ -632,7 +663,7 @@ export async function runHandoff(options, { env = process.env, ghCommand = "gh"
|
|
|
632
663
|
repo: options.repo,
|
|
633
664
|
pr: options.pr,
|
|
634
665
|
env,
|
|
635
|
-
cwd:
|
|
666
|
+
cwd: resolvedRepoRoot,
|
|
636
667
|
});
|
|
637
668
|
if (runnerRelease.status !== "skipped_no_async_run_id") {
|
|
638
669
|
result.runnerRelease = runnerRelease;
|
|
@@ -75,7 +75,7 @@ export function parseGitDiffStat(output) {
|
|
|
75
75
|
}
|
|
76
76
|
return { filesChanged: fileCount, linesChanged: insertions + deletions };
|
|
77
77
|
}
|
|
78
|
-
function detectScope({ base, head } = {}) {
|
|
78
|
+
function detectScope({ base, head, cwd } = {}) {
|
|
79
79
|
let diffArgs = ["diff", "--stat"];
|
|
80
80
|
if (base && head) {
|
|
81
81
|
diffArgs.push(`${base}..${head}`);
|
|
@@ -86,7 +86,7 @@ function detectScope({ base, head } = {}) {
|
|
|
86
86
|
}
|
|
87
87
|
let output;
|
|
88
88
|
try {
|
|
89
|
-
output = execFileSync("git", diffArgs, { encoding: "utf8", maxBuffer: 1_000_000 });
|
|
89
|
+
output = execFileSync("git", diffArgs, { encoding: "utf8", maxBuffer: 1_000_000, cwd: cwd || undefined });
|
|
90
90
|
} catch (err) {
|
|
91
91
|
return { ok: false, filesChanged: 0, linesChanged: 0, error: err instanceof Error ? err.message : String(err) };
|
|
92
92
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { readFile } from "node:fs/promises";
|
|
3
3
|
import { parseArgs } from "node:util";
|
|
4
|
-
import { parsePrNumber, requireTokenValue, runChild } from "../_cli-primitives.mjs";
|
|
4
|
+
import { parsePrNumber, requireTokenValue, runChild as defaultRunChild } from "../_cli-primitives.mjs";
|
|
5
5
|
import {
|
|
6
6
|
buildParseError,
|
|
7
7
|
formatCliError,
|
|
@@ -14,7 +14,7 @@ import {
|
|
|
14
14
|
} from "../_core-helpers.mjs";
|
|
15
15
|
import { fetchGithubReviewThreadsPayload } from "../github/capture-review-threads.mjs";
|
|
16
16
|
import { parseRepoSlug } from "@dev-loops/core/github/repo-slug";
|
|
17
|
-
import { loadDevLoopConfig, resolveRefinement } from "@dev-loops/core/config";
|
|
17
|
+
import { loadDevLoopConfig, resolveEffectiveCopilotRoundCap, resolveRefinement } from "@dev-loops/core/config";
|
|
18
18
|
import {
|
|
19
19
|
buildSnapshotFromPrFacts,
|
|
20
20
|
interpretLoopState,
|
|
@@ -43,8 +43,13 @@ Required (auto-detect mode):
|
|
|
43
43
|
--pr <number> Pull request number
|
|
44
44
|
Required (snapshot mode):
|
|
45
45
|
--input <path> Path to snapshot JSON file
|
|
46
|
-
Optional (
|
|
47
|
-
|
|
46
|
+
Optional (either mode):
|
|
47
|
+
--lightweight This PR is light-dispatched (#1210): compose the Copilot
|
|
48
|
+
round cap with localImplementation.lightMode.maxCopilotRounds
|
|
49
|
+
(default 1) via min(lightMode.maxCopilotRounds,
|
|
50
|
+
refinement.maxCopilotRounds) instead of using
|
|
51
|
+
refinement.maxCopilotRounds alone. refinement.maxCopilotRounds:
|
|
52
|
+
0 still disables Copilot rounds even with --lightweight.
|
|
48
53
|
Output (stdout, JSON):
|
|
49
54
|
{ "ok": true, "snapshot": {..., "copilotReviewRoundCount": N}, "state": "...", "allowedTransitions": [...], "nextAction": "...",
|
|
50
55
|
"autoRerequestEligible": true|false, "sameHeadCleanConverged": true|false,
|
|
@@ -67,6 +72,7 @@ export function parseDetectCliArgs(argv) {
|
|
|
67
72
|
inputPath: undefined,
|
|
68
73
|
repo: undefined,
|
|
69
74
|
pr: undefined,
|
|
75
|
+
lightweight: false,
|
|
70
76
|
};
|
|
71
77
|
const { tokens } = parseArgs({
|
|
72
78
|
args: [...argv],
|
|
@@ -75,6 +81,7 @@ export function parseDetectCliArgs(argv) {
|
|
|
75
81
|
input: { type: "string" },
|
|
76
82
|
repo: { type: "string" },
|
|
77
83
|
pr: { type: "string" },
|
|
84
|
+
lightweight: { type: "boolean" },
|
|
78
85
|
...JQ_OUTPUT_PARSE_OPTIONS,
|
|
79
86
|
},
|
|
80
87
|
allowPositionals: true,
|
|
@@ -104,6 +111,10 @@ export function parseDetectCliArgs(argv) {
|
|
|
104
111
|
options.pr = parsePrNumber(requireTokenValue(token, parseError), parseError);
|
|
105
112
|
continue;
|
|
106
113
|
}
|
|
114
|
+
if (token.name === "lightweight") {
|
|
115
|
+
options.lightweight = true;
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
107
118
|
if (matchJqOutputToken(token, options, (t) => requireTokenValue(t, parseError))) continue;
|
|
108
119
|
throw parseError(`Unknown argument: ${token.rawName}`);
|
|
109
120
|
}
|
|
@@ -129,7 +140,7 @@ export function parseDetectCliArgs(argv) {
|
|
|
129
140
|
}
|
|
130
141
|
return options;
|
|
131
142
|
}
|
|
132
|
-
async function fetchPrView({ repo, pr }, { env, ghCommand }) {
|
|
143
|
+
async function fetchPrView({ repo, pr }, { env, ghCommand, runChild = defaultRunChild }) {
|
|
133
144
|
const result = await runChild(
|
|
134
145
|
ghCommand,
|
|
135
146
|
["pr", "view", String(pr), "--repo", repo, "--json", "headRefOid,isDraft,state,number,reviews,statusCheckRollup"],
|
|
@@ -150,7 +161,7 @@ async function fetchPrView({ repo, pr }, { env, ghCommand }) {
|
|
|
150
161
|
}
|
|
151
162
|
return payload;
|
|
152
163
|
}
|
|
153
|
-
async function fetchCopilotRequested({ repo, pr }, { env, ghCommand }) {
|
|
164
|
+
async function fetchCopilotRequested({ repo, pr }, { env, ghCommand, runChild = defaultRunChild }) {
|
|
154
165
|
const result = await runChild(
|
|
155
166
|
ghCommand,
|
|
156
167
|
["api", `repos/${repo}/pulls/${pr}/requested_reviewers`],
|
|
@@ -169,7 +180,7 @@ async function fetchCopilotRequested({ repo, pr }, { env, ghCommand }) {
|
|
|
169
180
|
const users = Array.isArray(payload?.users) ? payload.users : [];
|
|
170
181
|
return users.some((user) => isCopilotLogin(user?.login));
|
|
171
182
|
}
|
|
172
|
-
async function fetchLatestCopilotReviewRequestAt({ repo, pr }, { env, ghCommand }) {
|
|
183
|
+
async function fetchLatestCopilotReviewRequestAt({ repo, pr }, { env, ghCommand, runChild = defaultRunChild }) {
|
|
173
184
|
const result = await runChild(
|
|
174
185
|
ghCommand,
|
|
175
186
|
["api", `repos/${repo}/issues/${pr}/timeline`, "--paginate", "--jq",
|
|
@@ -201,7 +212,7 @@ function extractPrVisibleCheckNames(statusCheckRollup) {
|
|
|
201
212
|
.filter((name) => typeof name === "string" && name.length > 0);
|
|
202
213
|
}
|
|
203
214
|
|
|
204
|
-
async function fetchCurrentHeadCiEvidence({ repo, headSha, prVisibleCheckNames }, { env, ghCommand }) {
|
|
215
|
+
async function fetchCurrentHeadCiEvidence({ repo, headSha, prVisibleCheckNames }, { env, ghCommand, runChild = defaultRunChild }) {
|
|
205
216
|
const [checkRunsResult, statusesResult] = await Promise.all([
|
|
206
217
|
runChild(
|
|
207
218
|
ghCommand,
|
|
@@ -317,8 +328,8 @@ function hasSubmittedCopilotReviewOffCurrentHead(reviewSummary, currentHeadSha)
|
|
|
317
328
|
}
|
|
318
329
|
|
|
319
330
|
|
|
320
|
-
export async function autoDetectSnapshot({ repo, pr, reviewRequestStatusOverride, localValidationHeadSha, draftGateResetAtMs }, { env = process.env, ghCommand = "gh" } = {}) {
|
|
321
|
-
const prData = await fetchPrView({ repo, pr }, { env, ghCommand });
|
|
331
|
+
export async function autoDetectSnapshot({ repo, pr, reviewRequestStatusOverride, localValidationHeadSha, draftGateResetAtMs }, { env = process.env, ghCommand = "gh", runChild = defaultRunChild } = {}) {
|
|
332
|
+
const prData = await fetchPrView({ repo, pr }, { env, ghCommand, runChild });
|
|
322
333
|
if (prData === null) {
|
|
323
334
|
return normalizeSnapshot({ prExists: false });
|
|
324
335
|
}
|
|
@@ -344,13 +355,13 @@ export async function autoDetectSnapshot({ repo, pr, reviewRequestStatusOverride
|
|
|
344
355
|
} else if (reviewSummary.hasPendingReviewOnCurrentHead) {
|
|
345
356
|
copilotReviewRequestStatus = "requested";
|
|
346
357
|
} else {
|
|
347
|
-
const copilotRequested = await fetchCopilotRequested({ repo, pr }, { env, ghCommand });
|
|
358
|
+
const copilotRequested = await fetchCopilotRequested({ repo, pr }, { env, ghCommand, runChild });
|
|
348
359
|
if (!copilotRequested) {
|
|
349
360
|
copilotReviewRequestStatus = "none";
|
|
350
361
|
} else if (!reviewSummary.hasSubmittedReviewOnCurrentHead) {
|
|
351
362
|
copilotReviewRequestStatus = "requested";
|
|
352
363
|
} else {
|
|
353
|
-
const latestRequestAt = await fetchLatestCopilotReviewRequestAt({ repo, pr }, { env, ghCommand });
|
|
364
|
+
const latestRequestAt = await fetchLatestCopilotReviewRequestAt({ repo, pr }, { env, ghCommand, runChild });
|
|
354
365
|
const latestReviewAt = reviewSummary.latestSubmittedReviewOnCurrentHeadAt;
|
|
355
366
|
if (latestRequestAt !== null && latestReviewAt !== null && latestRequestAt > latestReviewAt) {
|
|
356
367
|
copilotReviewRequestStatus = "requested";
|
|
@@ -365,7 +376,7 @@ export async function autoDetectSnapshot({ repo, pr, reviewRequestStatusOverride
|
|
|
365
376
|
let actionableThreadCount = 0;
|
|
366
377
|
let lastCopilotRoundMaxSignal = null;
|
|
367
378
|
try {
|
|
368
|
-
const threadsPayload = await fetchGithubReviewThreadsPayload({ repo, pr }, { env, ghCommand });
|
|
379
|
+
const threadsPayload = await fetchGithubReviewThreadsPayload({ repo, pr }, { env, ghCommand, runChild });
|
|
369
380
|
const parsed = parseReviewThreads(threadsPayload);
|
|
370
381
|
unresolvedThreadCount = parsed.summary.unresolvedThreads;
|
|
371
382
|
actionableThreadCount = parsed.summary.actionableThreads;
|
|
@@ -389,7 +400,7 @@ export async function autoDetectSnapshot({ repo, pr, reviewRequestStatusOverride
|
|
|
389
400
|
let failureDetails = [];
|
|
390
401
|
let excludedFailureDetails = [];
|
|
391
402
|
if (shouldRefreshCurrentHeadCi) {
|
|
392
|
-
const refreshed = await fetchCurrentHeadCiEvidence({ repo, headSha: prHeadSha, prVisibleCheckNames }, { env, ghCommand });
|
|
403
|
+
const refreshed = await fetchCurrentHeadCiEvidence({ repo, headSha: prHeadSha, prVisibleCheckNames }, { env, ghCommand, runChild });
|
|
393
404
|
currentHeadCiStatus = refreshed?.status ?? "none";
|
|
394
405
|
failureDetails = refreshed?.failureDetails ?? [];
|
|
395
406
|
excludedFailureDetails = refreshed?.excludedFailureDetails ?? [];
|
|
@@ -455,6 +466,15 @@ export async function runCli(
|
|
|
455
466
|
const refinementConfig = config.errors.length > 0
|
|
456
467
|
? resolveRefinement({ version: 1 })
|
|
457
468
|
: resolveRefinement(config.config);
|
|
469
|
+
if (options.lightweight) {
|
|
470
|
+
// Compose (not replace) the round cap for light-dispatched PRs (#1210):
|
|
471
|
+
// min(lightMode.maxCopilotRounds ?? 1, refinement.maxCopilotRounds), so
|
|
472
|
+
// maxCopilotRounds: 0 still disables Copilot rounds everywhere.
|
|
473
|
+
refinementConfig.maxCopilotRounds = resolveEffectiveCopilotRoundCap(
|
|
474
|
+
config.errors.length > 0 ? { version: 1 } : config.config,
|
|
475
|
+
{ lightweight: true },
|
|
476
|
+
);
|
|
477
|
+
}
|
|
458
478
|
interpretation = interpretLoopState(interpretationInput, refinementConfig);
|
|
459
479
|
const interpretationSummary = summarizeLoopInterpretation(interpretation);
|
|
460
480
|
process.exitCode = emitResult({
|
|
@@ -3,7 +3,7 @@ import { statSync, readFileSync } from "node:fs";
|
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { parse as parseYaml } from "yaml";
|
|
5
5
|
import { buildParseError, formatCliError, isDirectCliRun } from "../_core-helpers.mjs";
|
|
6
|
-
import { parsePrNumber, requireTokenValue, runChild } from "../_cli-primitives.mjs";
|
|
6
|
+
import { parsePrNumber, requireTokenValue, runChild as defaultRunChild } from "../_cli-primitives.mjs";
|
|
7
7
|
import { parseRepoSlug } from "@dev-loops/core/github/repo-slug";
|
|
8
8
|
import { parseArgs } from "node:util";
|
|
9
9
|
import { JQ_OUTPUT_PARSE_OPTIONS, JQ_OUTPUT_USAGE, emitResult, matchJqOutputToken } from "../lib/jq-output.mjs";
|
|
@@ -184,7 +184,7 @@ export function parseCliArgs(argv) {
|
|
|
184
184
|
return options;
|
|
185
185
|
}
|
|
186
186
|
|
|
187
|
-
async function fetchPrFiles({ repo, pr }, { env = process.env, ghCommand = "gh" } = {}) {
|
|
187
|
+
async function fetchPrFiles({ repo, pr }, { env = process.env, ghCommand = "gh", runChild = defaultRunChild } = {}) {
|
|
188
188
|
const result = await runChild(
|
|
189
189
|
ghCommand,
|
|
190
190
|
["pr", "view", String(pr), "--repo", repo, "--json", "files", "--jq", ".files[].path"],
|
|
@@ -198,7 +198,7 @@ async function fetchPrFiles({ repo, pr }, { env = process.env, ghCommand = "gh"
|
|
|
198
198
|
return paths;
|
|
199
199
|
}
|
|
200
200
|
|
|
201
|
-
async function fetchPrLabels({ repo, pr }, { env = process.env, ghCommand = "gh" } = {}) {
|
|
201
|
+
async function fetchPrLabels({ repo, pr }, { env = process.env, ghCommand = "gh", runChild = defaultRunChild } = {}) {
|
|
202
202
|
const result = await runChild(
|
|
203
203
|
ghCommand,
|
|
204
204
|
["pr", "view", String(pr), "--repo", repo, "--json", "labels", "--jq", ".labels[].name"],
|
|
@@ -219,10 +219,10 @@ async function fetchPrLabels({ repo, pr }, { env = process.env, ghCommand = "gh"
|
|
|
219
219
|
* - If ANY changed file doesn't match any pattern → internalOnly=false
|
|
220
220
|
* - No blacklist needed — a non-matching file is consumer-facing by definition.
|
|
221
221
|
*/
|
|
222
|
-
export async function detectInternalOnly(options, { env = process.env, ghCommand = "gh" } = {}) {
|
|
222
|
+
export async function detectInternalOnly(options, { env = process.env, ghCommand = "gh", runChild = defaultRunChild } = {}) {
|
|
223
223
|
const patterns = loadInternalPathPatterns(options.config);
|
|
224
224
|
const matchers = buildPatternMatchers(patterns);
|
|
225
|
-
const files = await fetchPrFiles(options, { env, ghCommand });
|
|
225
|
+
const files = await fetchPrFiles(options, { env, ghCommand, runChild });
|
|
226
226
|
|
|
227
227
|
if (files.length === 0) {
|
|
228
228
|
return {
|
|
@@ -250,7 +250,7 @@ export async function detectInternalOnly(options, { env = process.env, ghCommand
|
|
|
250
250
|
|
|
251
251
|
// Check for explicit internal_only label if requested (confirmation only)
|
|
252
252
|
if (options.labelCheck) {
|
|
253
|
-
const labels = await fetchPrLabels(options, { env, ghCommand });
|
|
253
|
+
const labels = await fetchPrLabels(options, { env, ghCommand, runChild });
|
|
254
254
|
if (labels.includes("internal_only")) {
|
|
255
255
|
// Label confirms — path check already passed
|
|
256
256
|
}
|