@dev-loops/core 1.0.0-rc.3 → 1.0.0-rc.5
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 +4 -1
- package/src/analysis/diff-analyzer.mjs +31 -5
- package/src/cli/primitives.mjs +10 -2
- package/src/cli/retry-wrapper.mjs +14 -6
- package/src/config/config.mjs +549 -44
- package/src/config/extension-defaults.yaml +38 -5
- package/src/github/copilot-helpers.mjs +139 -18
- package/src/github/issue-ops.mjs +77 -5
- package/src/github/review-threads.mjs +44 -3
- package/src/loop/copilot-ci-status.mjs +46 -18
- package/src/loop/copilot-loop-iterations.mjs +1 -2
- package/src/loop/default-branch-guard.mjs +380 -0
- package/src/loop/gate-carry-forward.mjs +28 -1
- package/src/loop/gate-fanin.mjs +723 -29
- package/src/loop/handoff-envelope.mjs +37 -16
- package/src/loop/issue-refinement-artifact.mjs +60 -18
- package/src/loop/main-checkout-ff.mjs +58 -0
- package/src/loop/pr-gate-coordination.mjs +194 -13
- package/src/loop/pr-title-markers.mjs +76 -15
- package/src/loop/public-dev-loop-routing.mjs +12 -0
- package/src/loop/retrospective-checkpoint.mjs +77 -13
- package/src/loop/reviewer-loop-state.mjs +2 -2
- package/src/loop/ui-e2e-scoping.mjs +2 -0
- package/src/loop/ui-review-drive.mjs +23 -0
- package/src/loop/ui-review-provision.mjs +36 -0
- package/src/projects/resolve-project.mjs +1 -1
|
@@ -12,30 +12,91 @@
|
|
|
12
12
|
* It is intentionally pure and side-effect free.
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
|
+
/**
|
|
16
|
+
* Builds a status-marker tester for a bare word like "WIP" or "DRAFT".
|
|
17
|
+
*
|
|
18
|
+
* A status marker asserts, on its own, that the PR is unfinished: bracketed
|
|
19
|
+
* (`[WIP]`), parenthesized (`(draft)`), colon-suffixed (`WIP:`), or the
|
|
20
|
+
* entire title with nothing else attached (a bare standalone `DRAFT`). A
|
|
21
|
+
* plain `\bWORD\b` match also hits the same word inside a compound noun
|
|
22
|
+
* phrase that names a component instead — `draft-gate`, `draft gate`,
|
|
23
|
+
* `wip-branch` — because a hyphen or a space is itself a word boundary. An
|
|
24
|
+
* underscore is a word character, so `draft_gate` never matched `\bDRAFT\b`
|
|
25
|
+
* to begin with; it is listed among the exempt forms for consistency, not
|
|
26
|
+
* because it was ever flagged. None of those forms satisfy any construction
|
|
27
|
+
* below, so a component name is left unflagged while a real status claim
|
|
28
|
+
* still is. "swipe"/"wiped"/"drafting" already fail every construction
|
|
29
|
+
* because there is no boundary between the marker word and the letters that
|
|
30
|
+
* follow it; a hyphen-prefixed compound like "re-draft" DOES create such a
|
|
31
|
+
* boundary (`\b` sees the hyphen).
|
|
32
|
+
*
|
|
33
|
+
* A trailing tag set off by a dash (`Fix login flow — WIP`) is deliberately
|
|
34
|
+
* NOT its own construction, even though it reads as a real status claim.
|
|
35
|
+
* Any dash-based construction narrow enough to close a title's tag also
|
|
36
|
+
* reopens the compound-noun false positive whenever the joiner is a
|
|
37
|
+
* different dash character (`Handle en dash–draft–gate naming`), and any fix
|
|
38
|
+
* for that narrows the construction until it drops real status claims that
|
|
39
|
+
* were previously caught (`Fix login flow — WIP.`, `— WIP (rebasing)`). The
|
|
40
|
+
* bracket/paren/colon/standalone set stays free of both failure modes, so a
|
|
41
|
+
* dash-set-off marker is left unflagged; `WIP:`/`DRAFT:` remains one
|
|
42
|
+
* keystroke away and stays flagged, the same trade already accepted for
|
|
43
|
+
* `WIP foo bar`.
|
|
44
|
+
*
|
|
45
|
+
* The bracket/paren/colon constructions all require the opening delimiter
|
|
46
|
+
* (`[`, `(`, or the marker word itself for colon) to sit at the start of the
|
|
47
|
+
* title or after whitespace — never directly after a letter, `/`, or `-` —
|
|
48
|
+
* so a conventional-commit scope (`fix(draft): support x`), a path segment
|
|
49
|
+
* (`app/[draft]/page.tsx`), a scoped label (`feat/draft: x`, `docs/wip:
|
|
50
|
+
* notes`), and a hyphen-prefixed compound (`re-draft: cleanup`) are all read
|
|
51
|
+
* as a component name, not a status claim — the same anchoring rule as the
|
|
52
|
+
* hyphen/underscore/space compound-noun exemption above. This does introduce
|
|
53
|
+
* one accepted false-negative class: a marker preceded by punctuation other
|
|
54
|
+
* than whitespace with no space of its own, e.g. `Fix bug,(draft)` or `Fix
|
|
55
|
+
* login(wip)`. Widening the anchor to "start, whitespace, or punctuation"
|
|
56
|
+
* would also re-admit the very forms (`/`, `-`) the anchor exists to
|
|
57
|
+
* exclude, so the narrower, whitespace-only anchor is kept and this
|
|
58
|
+
* false-negative class is accepted as its cost.
|
|
59
|
+
*
|
|
60
|
+
* The colon construction additionally requires the colon itself to CLOSE the
|
|
61
|
+
* tag — followed by whitespace or the end of the title, never directly by
|
|
62
|
+
* another character — so a scheme/tag/ref that merely starts with the
|
|
63
|
+
* marker word (`draft://`, `draft:latest`, `wip:branch`) is read as an
|
|
64
|
+
* unrelated identifier, not a status claim.
|
|
65
|
+
*/
|
|
66
|
+
function statusMarkerTester(word) {
|
|
67
|
+
const bracket = new RegExp(`(?:^|\\s)\\[\\s*${word}\\s*\\]`, "i");
|
|
68
|
+
const paren = new RegExp(`(?:^|\\s)\\(\\s*${word}\\s*\\)`, "i");
|
|
69
|
+
const colon = new RegExp(`(?:^|\\s)${word}\\s*:(?:\\s|$)`, "i");
|
|
70
|
+
const standalone = new RegExp(`^\\s*${word}\\s*$`, "i");
|
|
71
|
+
return (title) => bracket.test(title) || paren.test(title) || colon.test(title)
|
|
72
|
+
|| standalone.test(title);
|
|
73
|
+
}
|
|
74
|
+
|
|
15
75
|
/**
|
|
16
76
|
* Canonical merge-blocking markers and how to detect them.
|
|
17
77
|
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
* match those variants. The construction emoji has no word boundary, so it is
|
|
23
|
-
* matched literally anywhere in the title.
|
|
78
|
+
* "DO NOT MERGE" is a three-word phrase with no plausible compound-noun
|
|
79
|
+
* reading, so it keeps simple word-boundary matching. The construction emoji
|
|
80
|
+
* has no word boundary at all, so it is matched literally anywhere in the
|
|
81
|
+
* title.
|
|
24
82
|
*/
|
|
25
83
|
const MARKER_MATCHERS = [
|
|
26
|
-
{ label: "WIP",
|
|
27
|
-
{ label: "DRAFT",
|
|
84
|
+
{ label: "WIP", test: statusMarkerTester("WIP") },
|
|
85
|
+
{ label: "DRAFT", test: statusMarkerTester("DRAFT") },
|
|
28
86
|
// Flexible (any) whitespace between the phrase words, case-insensitive.
|
|
29
|
-
{ label: "DO NOT MERGE",
|
|
30
|
-
{ label: "🚧",
|
|
87
|
+
{ label: "DO NOT MERGE", test: (title) => /\bDO\s+NOT\s+MERGE\b/i.test(title) },
|
|
88
|
+
{ label: "🚧", test: (title) => /🚧/u.test(title) },
|
|
31
89
|
];
|
|
32
90
|
|
|
33
91
|
/**
|
|
34
92
|
* Finds merge-blocking markers in a PR title.
|
|
35
93
|
*
|
|
36
|
-
* Returns the canonical labels of every matched marker,
|
|
37
|
-
*
|
|
38
|
-
*
|
|
94
|
+
* Returns the canonical labels of every matched marker, in a stable order
|
|
95
|
+
* (the declaration order of {@link MARKER_MATCHERS}). Each label can appear
|
|
96
|
+
* at most once: MARKER_MATCHERS visits each entry exactly once and every
|
|
97
|
+
* entry's label is distinct, so the result is de-duped by construction —
|
|
98
|
+
* there is no separate dedupe step to fail. Returns an empty array when the
|
|
99
|
+
* title is clean, empty, or not a string.
|
|
39
100
|
*
|
|
40
101
|
* @param {unknown} title - The PR title to inspect.
|
|
41
102
|
* @returns {string[]} Canonical labels of matched markers, e.g. ["WIP"] or
|
|
@@ -47,8 +108,8 @@ export function findBlockingTitleMarkers(title) {
|
|
|
47
108
|
}
|
|
48
109
|
|
|
49
110
|
const matched = [];
|
|
50
|
-
for (const { label,
|
|
51
|
-
if (
|
|
111
|
+
for (const { label, test } of MARKER_MATCHERS) {
|
|
112
|
+
if (test(title)) {
|
|
52
113
|
matched.push(label);
|
|
53
114
|
}
|
|
54
115
|
}
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import {
|
|
2
2
|
evaluateRetrospectiveGate,
|
|
3
3
|
normalizeRetrospectiveCheckpointState,
|
|
4
|
+
normalizeCheckpointCycleIdentity,
|
|
5
|
+
resolveCheckpointStateFromArtifact,
|
|
4
6
|
} from "./retrospective-checkpoint.mjs";
|
|
5
7
|
import {
|
|
6
8
|
EXTERNAL_HEALTHY_WAIT_TIMEOUT_POLICY,
|
|
@@ -32,6 +34,16 @@ import {
|
|
|
32
34
|
|
|
33
35
|
export * from "./public-dev-loop-routing-contract.mjs";
|
|
34
36
|
|
|
37
|
+
// Re-exported so script-layer callers (e.g. resolve-dev-loop-startup.mjs and
|
|
38
|
+
// checkpoint-contract.mjs) can normalize a checkpoint cycle identity and
|
|
39
|
+
// resolve a durable checkpoint artifact's state through the public routing
|
|
40
|
+
// surface, without retrospective-checkpoint.mjs itself becoming a public
|
|
41
|
+
// package export (see skills/docs/retrospective-checkpoint-contract.md).
|
|
42
|
+
export {
|
|
43
|
+
normalizeCheckpointCycleIdentity,
|
|
44
|
+
resolveCheckpointStateFromArtifact,
|
|
45
|
+
};
|
|
46
|
+
|
|
35
47
|
const COPILOT_ISSUE_ASSIGNEE = "copilot-swe-agent";
|
|
36
48
|
|
|
37
49
|
const TARGET_KIND_SET = new Set(Object.values(DEV_LOOP_TARGET_KIND));
|
|
@@ -73,22 +73,86 @@ export function normalizeRetrospectiveCheckpointState(value) {
|
|
|
73
73
|
}
|
|
74
74
|
|
|
75
75
|
/**
|
|
76
|
-
*
|
|
77
|
-
*
|
|
78
|
-
*
|
|
76
|
+
* Normalizes a dev-loop cycle identity — the minimum facts that pin a
|
|
77
|
+
* checkpoint record to one specific qualifying completion: repo, PR number,
|
|
78
|
+
* and merge commit. Returns null when any field is missing or malformed, so a
|
|
79
|
+
* partial/garbled identity can never be mistaken for a valid one.
|
|
79
80
|
*
|
|
80
|
-
*
|
|
81
|
-
*
|
|
82
|
-
* - with `routeKind === "route"` (inspect/status-only results do not qualify)
|
|
81
|
+
* @param {unknown} identity
|
|
82
|
+
* @returns {{repo: string, prNumber: number, mergeCommit: string}|null}
|
|
83
83
|
*/
|
|
84
|
-
export function
|
|
85
|
-
if (!
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
84
|
+
export function normalizeCheckpointCycleIdentity(identity) {
|
|
85
|
+
if (!identity || typeof identity !== "object") {
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
const repo = typeof identity.repo === "string" ? identity.repo.trim() : "";
|
|
89
|
+
const prNumber = Number.isInteger(identity.prNumber) && identity.prNumber > 0 ? identity.prNumber : null;
|
|
90
|
+
const mergeCommit = typeof identity.mergeCommit === "string" ? identity.mergeCommit.trim() : "";
|
|
91
|
+
if (repo.length === 0 || prNumber === null || mergeCommit.length === 0) {
|
|
92
|
+
return null;
|
|
93
|
+
}
|
|
94
|
+
return { repo, prNumber, mergeCommit };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Resolves the RETROSPECTIVE_CHECKPOINT_STATE for a durable checkpoint
|
|
99
|
+
* artifact, scoped to the recorded cycle's recency (issue: a one-time
|
|
100
|
+
* `complete`/`skipped` checkpoint must not satisfy every later qualifying
|
|
101
|
+
* cycle forever).
|
|
102
|
+
*
|
|
103
|
+
* A `complete` or `skipped` artifact is scoped by `hasNewerMergeSinceCheckpoint`:
|
|
104
|
+
* when true, something has merged since the checkpoint's recorded discharge
|
|
105
|
+
* point (or that point could not be verified at all), so the checkpoint
|
|
106
|
+
* cannot cover the newer cycle — it fails closed to MISSING. The caller
|
|
107
|
+
* derives `hasNewerMergeSinceCheckpoint` itself (this module stays
|
|
108
|
+
* pure/I/O-free) by checking local git ancestry between the checkpoint's
|
|
109
|
+
* recorded merge commit and the base branch, so this runs fresh on every
|
|
110
|
+
* evaluation rather than depending on anything having written a fresh
|
|
111
|
+
* `required` record for the new cycle.
|
|
112
|
+
*
|
|
113
|
+
* `required`/`none` are not scoped by this comparison: `required` already
|
|
114
|
+
* maps to MISSING regardless of recency (an outstanding requirement blocks
|
|
115
|
+
* the gate no matter which cycle triggered it), and `none` means no
|
|
116
|
+
* completion has ever been observed.
|
|
117
|
+
*
|
|
118
|
+
* @param {object|null|undefined} artifact - Parsed checkpoint JSON, or
|
|
119
|
+
* `undefined` when the durable artifact is genuinely ABSENT (no file). Any
|
|
120
|
+
* other non-plain-object value — including the JSON literal `null` (a file
|
|
121
|
+
* that IS present but contains malformed content) and a corrupt-but-valid
|
|
122
|
+
* scalar/array — is treated as present-but-malformed and fails closed to
|
|
123
|
+
* MISSING; only a genuinely absent artifact resolves to NONE.
|
|
124
|
+
* @param {object} [options]
|
|
125
|
+
* @param {boolean} [options.hasNewerMergeSinceCheckpoint] - True when the
|
|
126
|
+
* caller has determined (or could not rule out) that something has merged
|
|
127
|
+
* to the base branch since the checkpoint's recorded discharge point.
|
|
128
|
+
* Ignored for states other than `complete`/`skipped`. Defaults to `false`
|
|
129
|
+
* (trust the recorded state) so callers that never verify recency (e.g.
|
|
130
|
+
* `workflow.requireRetrospective` disabled) see unchanged behavior.
|
|
131
|
+
* @returns {"none"|"complete"|"skipped"|"missing"}
|
|
132
|
+
*/
|
|
133
|
+
export function resolveCheckpointStateFromArtifact(artifact, { hasNewerMergeSinceCheckpoint = false } = {}) {
|
|
134
|
+
if (artifact === undefined) {
|
|
135
|
+
return RETROSPECTIVE_CHECKPOINT_STATE.NONE;
|
|
136
|
+
}
|
|
137
|
+
if (artifact === null || typeof artifact !== "object" || Array.isArray(artifact)) {
|
|
138
|
+
// Present but malformed — fail closed, do not treat as "nothing observed".
|
|
139
|
+
return RETROSPECTIVE_CHECKPOINT_STATE.MISSING;
|
|
140
|
+
}
|
|
141
|
+
const rawState = typeof artifact.state === "string" ? artifact.state.trim().toLowerCase() : null;
|
|
142
|
+
if (rawState === "required" || rawState === "missing") {
|
|
143
|
+
return RETROSPECTIVE_CHECKPOINT_STATE.MISSING;
|
|
144
|
+
}
|
|
145
|
+
if (rawState === "none") {
|
|
146
|
+
return RETROSPECTIVE_CHECKPOINT_STATE.NONE;
|
|
147
|
+
}
|
|
148
|
+
if (rawState === "skipped") {
|
|
149
|
+
return hasNewerMergeSinceCheckpoint ? RETROSPECTIVE_CHECKPOINT_STATE.MISSING : RETROSPECTIVE_CHECKPOINT_STATE.SKIPPED;
|
|
150
|
+
}
|
|
151
|
+
if (rawState === "complete") {
|
|
152
|
+
return hasNewerMergeSinceCheckpoint ? RETROSPECTIVE_CHECKPOINT_STATE.MISSING : RETROSPECTIVE_CHECKPOINT_STATE.COMPLETE;
|
|
89
153
|
}
|
|
90
|
-
|
|
91
|
-
return
|
|
154
|
+
// Malformed/unrecognized durable state — fail closed.
|
|
155
|
+
return RETROSPECTIVE_CHECKPOINT_STATE.MISSING;
|
|
92
156
|
}
|
|
93
157
|
|
|
94
158
|
/**
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Deterministic state machine and bounded planning/merge contracts for reviewer-side PR loops.
|
|
3
3
|
*/
|
|
4
|
+
import { SUBMITTED_REVIEW_STATES } from "../github/copilot-helpers.mjs";
|
|
4
5
|
|
|
5
6
|
export const REVIEWER_STATE = Object.freeze({
|
|
6
7
|
WAITING_FOR_REVIEW_REQUEST: "waiting_for_review_request",
|
|
@@ -105,7 +106,6 @@ const VALID_LOCAL_RUN_STATUSES = new Set(["none", "running", "completed", "faile
|
|
|
105
106
|
const VALID_LOCAL_MERGE_STATUSES = new Set(["none", "ready", "failed"]);
|
|
106
107
|
const VALID_DRAFT_NOTIFICATION_STATUSES = new Set(["none", "notified"]);
|
|
107
108
|
const VALID_SUBMISSION_STATUSES = new Set(["none", "submitted", "failed"]);
|
|
108
|
-
const VALID_SUBMITTED_REVIEW_STATES = new Set(["APPROVED", "CHANGES_REQUESTED", "COMMENTED", "DISMISSED"]);
|
|
109
109
|
|
|
110
110
|
const SUPPORTED_REVIEW_ANGLES = Object.freeze([
|
|
111
111
|
"correctness",
|
|
@@ -143,7 +143,7 @@ function normalizeSubmittedReviewState(value) {
|
|
|
143
143
|
}
|
|
144
144
|
|
|
145
145
|
const normalized = value.trim().toUpperCase();
|
|
146
|
-
return
|
|
146
|
+
return SUBMITTED_REVIEW_STATES.has(normalized) ? normalized : null;
|
|
147
147
|
}
|
|
148
148
|
|
|
149
149
|
/**
|
|
@@ -38,8 +38,10 @@ export const VIEWER_SOURCE_PATHS = Object.freeze([
|
|
|
38
38
|
export const REGISTERED_ARTIFACT_PATHS = Object.freeze([
|
|
39
39
|
"docs/presentations/introducing-dev-loops.html",
|
|
40
40
|
"docs/presentations/dev-loops-deep-dive.html",
|
|
41
|
+
"docs/presentations/how-dev-loops-decided-itself.html",
|
|
41
42
|
"docs/articles/introducing-dev-loops.html",
|
|
42
43
|
"docs/articles/dev-loops-deep-dive.html",
|
|
44
|
+
"docs/articles/how-dev-loops-decided-itself.html",
|
|
43
45
|
]);
|
|
44
46
|
|
|
45
47
|
export const VIEWER_ARTIFACT_ID = "inspect-run-viewer";
|
|
@@ -44,6 +44,19 @@ export function isErrorResponseStatus(status) {
|
|
|
44
44
|
return typeof status === "number" && (status < 200 || status >= 400);
|
|
45
45
|
}
|
|
46
46
|
|
|
47
|
+
/** The one owner of the request-abort carve-out: a request the browser itself
|
|
48
|
+
* aborted carries no defect signal. Navigating away cancels in-flight asset
|
|
49
|
+
* requests, so these appear on every multi-step flow. Matched per engine:
|
|
50
|
+
* WebKit reports "cancelled", Chromium "net::ERR_ABORTED", Firefox
|
|
51
|
+
* "NS_BINDING_ABORTED". Matching is case-insensitive and substring-based because
|
|
52
|
+
* engines wrap the token in longer text. A genuine DNS/connection/TLS failure
|
|
53
|
+
* carries a different token and is still classified must-fix. */
|
|
54
|
+
export function isAbortedRequestFailure(failure) {
|
|
55
|
+
if (typeof failure !== "string") return false;
|
|
56
|
+
const f = failure.toLowerCase();
|
|
57
|
+
return f.includes("cancelled") || f.includes("canceled") || f.includes("err_aborted") || f.includes("ns_binding_aborted");
|
|
58
|
+
}
|
|
59
|
+
|
|
47
60
|
/** Bound the stack text carried onto a page-error failure so a runaway stack
|
|
48
61
|
* (or a synthetic error with a huge stack) can't bloat the feed envelope. Keeps
|
|
49
62
|
* the head — the top frames, where the throwing file:line sits. Exported so the
|
|
@@ -157,6 +170,16 @@ export function classifyFailures({
|
|
|
157
170
|
}
|
|
158
171
|
|
|
159
172
|
for (const f of requestFailures) {
|
|
173
|
+
// A request the BROWSER aborted is not evidence of a defect: navigating away
|
|
174
|
+
// cancels every asset request still in flight, so a flow with more than one
|
|
175
|
+
// `goto` manufactures one of these per unfinished image/font on the page it
|
|
176
|
+
// left. Measured on sofatutor 2026-08-05: a clean two-goto admin2 walk
|
|
177
|
+
// produced 13, all "cancelled", all classified must-fix — and since
|
|
178
|
+
// `ok: failures.length === 0`, they failed an otherwise passing drive and
|
|
179
|
+
// would have been posted as findings against the PR. This is the request-abort
|
|
180
|
+
// counterpart of the 3xx carve-out in isErrorResponseStatus: a real
|
|
181
|
+
// server/network fault still arrives with its own failure text and is kept.
|
|
182
|
+
if (isAbortedRequestFailure(f.failure)) continue;
|
|
160
183
|
failures.push({
|
|
161
184
|
kind: "request-failed",
|
|
162
185
|
severity: MUST_FIX,
|
|
@@ -37,6 +37,10 @@ const MUST_FIX = "must-fix";
|
|
|
37
37
|
* @param {object} seams - Injected IO (all required except clock/log defaults).
|
|
38
38
|
* @param {(a:{repoRoot:string,pr:number,branch?:string})=>Promise<{path:string,created:boolean,reused:boolean}>} seams.ensureWorktree
|
|
39
39
|
* @param {(a:{worktreePath:string,repoRoot:string})=>{ok:boolean,message?:string,mainWorktreePath?:string|null}} seams.assertNotPrimary
|
|
40
|
+
* @param {(a:{worktreePath:string,repoRoot:string,pr:number})=>Promise<{ok:boolean,sha?:string|null,detail?:string}>} [seams.pinPrHead] -
|
|
41
|
+
* Pin the worktree to the PR head and report the resolved SHA. Optional only
|
|
42
|
+
* for back-compat: omitting it leaves the ref UNVERIFIED and is logged as such.
|
|
43
|
+
* Runs after the primary-checkout guard, never before.
|
|
40
44
|
* @param {(a:{repoRoot:string,worktreePath:string})=>Promise<{changed:boolean,detail:string}>} seams.detectDepDelta
|
|
41
45
|
* @param {(a:{worktreePath:string})=>Promise<{ok:boolean,detail:string}>} seams.installDeps
|
|
42
46
|
* @param {(worktreePath:string)=>Promise<object|null>} seams.resolveRunRecipe
|
|
@@ -58,6 +62,7 @@ export async function provisionAndBoot(
|
|
|
58
62
|
{
|
|
59
63
|
ensureWorktree,
|
|
60
64
|
assertNotPrimary,
|
|
65
|
+
pinPrHead,
|
|
61
66
|
detectDepDelta,
|
|
62
67
|
installDeps,
|
|
63
68
|
resolveRunRecipe,
|
|
@@ -111,6 +116,36 @@ export async function provisionAndBoot(
|
|
|
111
116
|
);
|
|
112
117
|
}
|
|
113
118
|
|
|
119
|
+
// 2b. Pin the worktree to the PR head, AFTER the primary-checkout guard above
|
|
120
|
+
// (this checks out a ref, so it must never run in the primary checkout).
|
|
121
|
+
// ensureWorktree resolves `branch` against what already exists, so the
|
|
122
|
+
// default `pr-<n>` lands wherever that name happens to point — the base
|
|
123
|
+
// branch when no remote carries it, and a same-named remote branch that
|
|
124
|
+
// is not this PR's head when one does. A fork PR's head branch is on no
|
|
125
|
+
// candidate remote at all. Reviewing the wrong commit still reports
|
|
126
|
+
// ok:true, so pin the head explicitly and record the resolved SHA.
|
|
127
|
+
let headSha = null;
|
|
128
|
+
if (pinPrHead) {
|
|
129
|
+
const pin = await pinPrHead({ worktreePath, repoRoot, pr });
|
|
130
|
+
if (!pin?.ok) {
|
|
131
|
+
return stop(
|
|
132
|
+
`cannot pin PR head: ${pin?.detail ?? "unknown failure"}`,
|
|
133
|
+
{
|
|
134
|
+
kind: "pr-head-unpinned",
|
|
135
|
+
severity: MUST_FIX,
|
|
136
|
+
message: `the worktree could not be pinned to PR #${pr}'s head, so it cannot be reviewed as that PR: ${pin?.detail ?? "unknown failure"}`,
|
|
137
|
+
},
|
|
138
|
+
{ worktreePath },
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
headSha = pin.sha ?? null;
|
|
142
|
+
record(`worktree pinned to PR head ${headSha ?? "(sha unknown)"}${pin.detail ? ` (${pin.detail})` : ""}`);
|
|
143
|
+
} else {
|
|
144
|
+
// Never silent: a caller without the seam gets an unverified ref, which is
|
|
145
|
+
// exactly the failure mode this step exists to close.
|
|
146
|
+
record("WARNING: PR head not pinned (no pinPrHead seam) — worktree ref is UNVERIFIED");
|
|
147
|
+
}
|
|
148
|
+
|
|
114
149
|
// 3. Install only the dependency-lock delta vs. the primary checkout. No delta
|
|
115
150
|
// => deps are shared; installing anything would be a blind re-install.
|
|
116
151
|
const delta = await detectDepDelta({ repoRoot, worktreePath });
|
|
@@ -256,6 +291,7 @@ export async function provisionAndBoot(
|
|
|
256
291
|
worktreePath,
|
|
257
292
|
created: wt.created,
|
|
258
293
|
reused: wt.reused,
|
|
294
|
+
headSha,
|
|
259
295
|
depInstall,
|
|
260
296
|
migrations,
|
|
261
297
|
boot: bootResult,
|
|
@@ -139,7 +139,7 @@ function resolveProjectSelector(args) {
|
|
|
139
139
|
: null;
|
|
140
140
|
if (!projectRef && !projectTitle) {
|
|
141
141
|
throw Object.assign(
|
|
142
|
-
new Error("--project is required (or set queue.board
|
|
142
|
+
new Error("--project is required (or set tracker.board — or the deprecated queue.board — number / title in .devloops)"),
|
|
143
143
|
{ code: "INVALID_PROJECT" },
|
|
144
144
|
);
|
|
145
145
|
}
|