@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.
@@ -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
- * Word-boundary matching is used for the alphabetic markers so that real words
19
- * are not false-positives (e.g. "swipe"/"wiped" must not match WIP;
20
- * "drafting"/"redraft" must not match DRAFT). Bracket/paren/colon punctuation
21
- * (`[WIP]`, `(wip)`, `WIP:`) are non-word characters, so `\b` boundaries still
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", pattern: /\bWIP\b/i },
27
- { label: "DRAFT", pattern: /\bDRAFT\b/i },
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", pattern: /\bDO\s+NOT\s+MERGE\b/i },
30
- { label: "🚧", pattern: /🚧/u },
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, de-duped and in a
37
- * stable order (the declaration order of {@link MARKER_MATCHERS}). Returns an
38
- * empty array when the title is clean, empty, or not a string.
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, pattern } of MARKER_MATCHERS) {
51
- if (pattern.test(title) && !matched.includes(label)) {
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
- * Returns true if a routing result represents a qualifying GitHub-first async
77
- * dev-loop completion that requires a post-run behavioral retrospective before
78
- * the next start/resume.
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
- * A qualifying completion is one that:
81
- * - has a `selectedGate` in RETROSPECTIVE_QUALIFYING_GATES
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 isQualifyingAsyncCompletion(routingResult) {
85
- if (!routingResult || typeof routingResult !== "object") return false;
86
- const { routeKind, selectedGate } = routingResult;
87
- if (routeKind !== "route") {
88
- return false;
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
- if (typeof selectedGate !== "string") return false;
91
- return RETROSPECTIVE_QUALIFYING_GATES.includes(selectedGate);
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 VALID_SUBMITTED_REVIEW_STATES.has(normalized) ? normalized : null;
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.number / queue.board.title in .devloops)"),
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
  }