@dev-loops/core 1.0.0 → 1.0.2-pre.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.
@@ -1,22 +1,30 @@
1
1
  /**
2
- * Gate carry-forward: a pure, fail-closed seam that decides whether a clean gate
3
- * angle verdict recorded at head A may be CARRIED FORWARD to head B without
4
- * re-running that angle's reviewer.
2
+ * Gate carry-forward: a pure, fail-closed seam that decides whether an angle's
3
+ * verdict recorded at head A (clean OR findings-present) may be CARRIED FORWARD
4
+ * to head B without re-running that angle's reviewer.
5
5
  *
6
6
  * Motivation: fresh-context-per-head re-fans ALL gate angles on every head bump,
7
7
  * even when the delta between the two heads provably cannot affect most angles
8
8
  * (e.g. a doc-only follow-up commit cannot change what a code-correctness angle
9
- * would find). Carry-forward lets the gate reuse the prior clean verdict for such
10
- * angles — but ONLY when it is provably safe.
9
+ * would find). Carry-forward lets the gate reuse the prior verdict for such
10
+ * angles — but ONLY when it is provably safe. This holds for a findings-present
11
+ * prior verdict too (issue #2017): a fixer push that never touches an angle's
12
+ * surface must not force that angle's OPEN findings to be re-litigated from
13
+ * scratch — the caller carries the prior findings forward unchanged, still
14
+ * open, still blocking. Carry-forward NEVER converts a finding into an
15
+ * approval; it only ever skips re-running a reviewer whose surface the delta
16
+ * provably did not touch.
11
17
  *
12
- * FAIL-CLOSED is paramount. An angle carries forward ONLY when EVERY changed file
13
- * in the delta A..B is provably OUTSIDE that angle's declared review surface. The
14
- * default in every uncertain case (non-clean prior verdict, empty/unavailable
15
- * delta, an unclassifiable file, an angle with no declared surface, a mandatory /
16
- * always-run angle) is MUST-RE-RUN. Carry-forward never fabricates a verdict: the
17
- * caller records the carried verdict with provenance pointing at the PRIOR head's
18
- * reviewer (that reviewer genuinely reviewed this angle's surface, which the delta
19
- * did not touch), clearly marked as carried — see
18
+ * FAIL-CLOSED is paramount. An angle carries forward ONLY when its prior verdict
19
+ * is carry-forward-eligible (clean or findings_present) AND EVERY changed file in
20
+ * the delta A..B is provably OUTSIDE that angle's declared review surface. The
21
+ * default in every uncertain case (an ineligible prior verdict — e.g. "blocked"
22
+ * or missing, empty/unavailable delta, an unclassifiable file, an angle with no
23
+ * declared surface, a mandatory / always-run angle) is MUST-RE-RUN. Carry-forward
24
+ * never fabricates a verdict: the caller records the carried verdict (and, for a
25
+ * findings-present carry, the carried findings) with provenance pointing at the
26
+ * PRIOR head's reviewer (that reviewer genuinely reviewed this angle's surface,
27
+ * which the delta did not touch), clearly marked as carried — see
20
28
  * skills/docs/gate-review-sub-loop-contract.md and write-gate-findings-log.mjs's
21
29
  * `carriedFromHead` provenance field.
22
30
  *
@@ -153,10 +161,16 @@ export function angleReviewSurface(angle, { alwaysRerun } = {}) {
153
161
  /**
154
162
  * Pure, deterministic, FAIL-CLOSED carry-forward decision for a single angle.
155
163
  *
156
- * Given a prior CLEAN verdict recorded at head A, the changed files of the delta
157
- * A..B, and the angle's declared review surface, decide whether the clean verdict
158
- * may be carried forward to head B (carryForward: true) or the angle MUST re-run
159
- * (carryForward: false). Defaults to must-re-run in every uncertain case.
164
+ * Given a prior carry-forward-eligible verdict recorded at head A (clean OR
165
+ * findings_present), the changed files of the delta A..B, and the angle's
166
+ * declared review surface, decide whether that verdict (and, for a
167
+ * findings-present angle, its open findings) may be carried forward to head B
168
+ * (carryForward: true) or the angle MUST re-run (carryForward: false).
169
+ * Defaults to must-re-run in every uncertain case. This function never
170
+ * inspects or mutates findings content — it only decides whether the delta
171
+ * proves the angle's surface untouched; the caller is responsible for
172
+ * carrying the actual prior findings through unchanged (never converting an
173
+ * open finding into an approval) when it honors `carryForward: true`.
160
174
  *
161
175
  * @param {object} input
162
176
  * @param {string} input.angle
@@ -164,11 +178,18 @@ export function angleReviewSurface(angle, { alwaysRerun } = {}) {
164
178
  * derived from {@link angleReviewSurface} when omitted.
165
179
  * @param {string[]} input.changedFiles — repo-relative paths changed between head
166
180
  * A and head B (the delta, NOT the full PR diff against base).
167
- * @param {string} input.prevVerdict — the angle's verdict at head A. Only "clean"
168
- * is carry-forward-eligible.
181
+ * @param {string} input.prevVerdict — the angle's verdict at head A. "clean" and
182
+ * "findings_present" are carry-forward-eligible; anything else (e.g.
183
+ * "blocked", missing) is not.
169
184
  * @returns {{ carryForward: boolean, reason: string }}
170
185
  */
171
186
 
187
+ // The only per-angle prior verdicts eligible to carry forward — matches the
188
+ // two verdict values gate-fanin's VALID_VERDICTS actually produces per angle
189
+ // (packages/core/src/loop/gate-fanin.mjs). Any other value (e.g. "blocked",
190
+ // undefined, a typo) fails closed to must-re-run.
191
+ const CARRY_FORWARD_ELIGIBLE_VERDICTS = new Set(["clean", "findings_present"]);
192
+
172
193
  // A path whose change rewrites the dev-loop review system itself — the angle
173
194
  // pool, mandatory floor, and reviewer personas/prompts — rather than a
174
195
  // reviewed surface. A clean verdict produced under the OLD config cannot
@@ -187,8 +208,11 @@ export function isDevLoopConfigSourcePath(filePath) {
187
208
  }
188
209
 
189
210
  export function resolveAngleCarryForward({ angle, angleSurface, changedFiles, prevVerdict }) {
190
- if (prevVerdict !== "clean") {
191
- return { carryForward: false, reason: `prior verdict is ${JSON.stringify(prevVerdict ?? null)}, not "clean"` };
211
+ if (!CARRY_FORWARD_ELIGIBLE_VERDICTS.has(prevVerdict)) {
212
+ return {
213
+ carryForward: false,
214
+ reason: `prior verdict is ${JSON.stringify(prevVerdict ?? null)}, not carry-forward-eligible (clean or findings_present)`,
215
+ };
192
216
  }
193
217
  const surface = angleSurface ?? angleReviewSurface(angle);
194
218
  if (surface.kind === "always") {
@@ -0,0 +1,75 @@
1
+ /**
2
+ * gate-evidence-reconcile.mjs — deterministic self-heal for a stuck
3
+ * `gate-evidence` required status (issue #1935).
4
+ *
5
+ * The server-side `gate-evidence` check (`.github/workflows/gate-evidence.yml`)
6
+ * re-fires when a gate verdict is posted (ADR 0043). That native re-fire is
7
+ * racy: a verdict-post run can be CANCELLED by `cancel-in-progress` when a
8
+ * superseding event lands, or evaluate before the just-posted verdict is
9
+ * API-visible, leaving the required status stuck at `failure` even though a
10
+ * clean current-head `pre_approval_gate` verdict now exists. Nothing re-fires
11
+ * afterward, so the merge stays `UNSTABLE` until a manual `gh run rerun`
12
+ * (observed on PR #1934; ADR 0057).
13
+ *
14
+ * This pure decision separates the two cases the reconcile must never confuse:
15
+ * - evidence genuinely satisfied but the status is stuck non-green → re-fire
16
+ * the concrete run that posted the stale status (automating the manual
17
+ * rerun; the rerun re-evaluates LIVE evidence, which is now satisfied).
18
+ * - evidence genuinely NOT satisfied → do nothing. A head that truly lacks a
19
+ * clean current-head verdict MUST keep failing the check (fail-closed).
20
+ */
21
+
22
+ /** Required commit-status context posted by the gate-evidence workflow. */
23
+ export const GATE_EVIDENCE_STATUS_CONTEXT = "gate-evidence";
24
+
25
+ /**
26
+ * Extract the Actions run id from a gate-evidence commit-status `target_url`.
27
+ * The workflow points every posted status at its own run:
28
+ * https://github.com/<owner>/<repo>/actions/runs/<run_id>
29
+ * Returns the numeric run id as a string, or null when the URL is absent or
30
+ * not an Actions-run URL (an unexpected target_url must not be coerced).
31
+ *
32
+ * @param {string} [targetUrl]
33
+ * @returns {string|null}
34
+ */
35
+ export function parseRunIdFromTargetUrl(targetUrl) {
36
+ if (typeof targetUrl !== "string") return null;
37
+ const match = targetUrl.match(/\/actions\/runs\/(\d+)(?:[/?#]|$)/);
38
+ return match ? match[1] : null;
39
+ }
40
+
41
+ /**
42
+ * Decide whether a stuck `gate-evidence` status should be re-fired.
43
+ *
44
+ * @param {object} input
45
+ * @param {boolean} input.evidenceSatisfied detect-checkpoint-evidence reports
46
+ * `evidenceState === "satisfied"` for the current head (clean draft_gate +
47
+ * current-head pre_approval_gate verdicts present).
48
+ * @param {string} [input.statusState] the `gate-evidence` commit-status state
49
+ * on the current head: `success` | `failure` | `error` | `pending` | `none`
50
+ * (`none` = no gate-evidence status posted for this head yet).
51
+ * @param {string|null} [input.runId] the Actions run id that posted the stale
52
+ * status (from `parseRunIdFromTargetUrl`), or null when unknown.
53
+ * @returns {{ action: "refire"|"none", runId?: string, reason: string }}
54
+ */
55
+ export function resolveGateEvidenceStatusReconcile({ evidenceSatisfied, statusState, runId } = {}) {
56
+ // Fail-closed: never re-fire when the verdict evidence is genuinely not
57
+ // satisfied. This preserves the "verdict genuinely missing" case — the head
58
+ // keeps failing closed exactly as before (issue #1935 AC #3).
59
+ if (evidenceSatisfied !== true) {
60
+ return { action: "none", reason: "evidence-not-satisfied-fail-closed" };
61
+ }
62
+ // Already green — nothing to reconcile.
63
+ if (statusState === "success") {
64
+ return { action: "none", reason: "already-success" };
65
+ }
66
+ // Evidence IS satisfied for the current head, but the required status is not
67
+ // success (the push-before-verdict race: a cancelled/stale re-fire). Re-fire
68
+ // the concrete run that posted the stale status. Without a run id there is
69
+ // nothing to re-fire deterministically; leave it to the native path rather
70
+ // than forging a status.
71
+ if (!runId) {
72
+ return { action: "none", reason: "no-run-to-refire" };
73
+ }
74
+ return { action: "refire", runId, reason: "evidence-satisfied-status-stale" };
75
+ }
@@ -63,13 +63,19 @@ export function scheduleFanoutWaves(dispatchGroups, maxConcurrent = 4) {
63
63
  }
64
64
 
65
65
  /**
66
- * Adaptive 429-backoff concurrency (issue #1601): halve the active batch before
67
- * escalating to foreground one-at-a-time fallback. On a 429, the conductor
68
- * recomputes the wave plan with `backoffMaxConcurrent(maxConcurrent)` and
69
- * retries the failed wave; if a single-unit wave still 429s, it falls back to
70
- * foreground (one-at-a-time) dispatch. The backoff is recorded in the round's
71
- * provenance (see skills/docs/gate-review-sub-loop-contract.md). Pure; never
72
- * returns 0 (a backoff from 1 stays 1 → foreground fallback owns that path).
66
+ * Adaptive concurrency backoff (issue #1601; retry discipline refined by #1907):
67
+ * halve the active batch before escalating to foreground one-at-a-time fallback.
68
+ * A transient dispatch failure (429/5xx) is first retried on the SAME unit with
69
+ * exponential backoff — safe because a reviewer's findings artifact is an
70
+ * idempotent single-write at a deterministic path — and the conductor reduces
71
+ * concurrency ONLY after that unit's retries are exhausted (~3 failed attempts),
72
+ * recomputing the wave plan with `backoffMaxConcurrent(maxConcurrent)` and
73
+ * retrying the reduced wave; if a single-unit wave still fails, it falls back to
74
+ * foreground (one-at-a-time) dispatch. This "retry the unit before reducing
75
+ * concurrency" ordering is owned by GATE-EXEC-DISPATCH-RETRY-BACKOFF in
76
+ * skills/docs/gate-review-sub-loop-contract.md; the backoff is recorded in the
77
+ * round's provenance. Pure; never returns 0 (a backoff from 1 stays 1 →
78
+ * foreground fallback owns that path).
73
79
  * @param {number} maxConcurrent
74
80
  * @returns {number}
75
81
  */
@@ -281,8 +287,8 @@ export function severityRank(severity) {
281
287
  /**
282
288
  * A zero-initialized severity→count map, one key per SEVERITY_ORDER entry, in
283
289
  * SEVERITY_ORDER's order. The shared starting point every severity tally in
284
- * this codebase (consolidateFanin's own `bySeverity`, consolidate-fanin.mjs's
285
- * `buildAngleMarker`, reconcile-draft-gate.mjs's no-findings placeholder) used
290
+ * this codebase (consolidateFanin's own `bySeverity`,
291
+ * reconcile-draft-gate.mjs's no-findings placeholder) used
286
292
  * to hand-roll separately via `Object.fromEntries(SEVERITY_ORDER.map((s) =>
287
293
  * [s, 0]))` — one copy here means a severity added to SEVERITY_ORDER is
288
294
  * zero-initialized everywhere at once.
@@ -299,10 +305,9 @@ export function zeroSeverityCounts() {
299
305
  * finding whose normalized severity is not a recognized SEVERITY_ORDER member
300
306
  * is silently excluded from the tally rather than inflating an unknown key —
301
307
  * every routed call site here counts already-validated findings in practice
302
- * (consolidateFanin validates every result's severity before this runs;
303
- * buildAngleMarker tallies consolidateFanin's own output), so this guard is a
304
- * defensive floor against future drift, not an escape hatch for accepting
305
- * unvalidated severities. `findings` and its entries are NOT nullish-tolerant:
308
+ * (consolidateFanin validates every result's severity before this runs), so
309
+ * this guard is a defensive floor against future drift, not an escape hatch
310
+ * for accepting unvalidated severities. `findings` and its entries are NOT nullish-tolerant:
306
311
  * a nullish `findings` argument throws (not iterable), and a nullish
307
312
  * individual entry throws reading `.severity` — no routed caller passes
308
313
  * either shape, so a caller that does gets a loud failure instead of a
@@ -336,6 +341,30 @@ export function normalizeSeverityCounts(counts) {
336
341
  return normalized;
337
342
  }
338
343
 
344
+ /**
345
+ * Resolve a finding's effective file path from EITHER shape the shared floor
346
+ * accepts: `file` (singular string, hand-authored / future-producer ledgers) or
347
+ * `files[0]` (the array shape consolidate-fanin / write-gate-findings-log emit).
348
+ * The ONE resolver `hasLocatableShape` and every downstream consumer keys on,
349
+ * so a consumer reading `finding.files[0]` directly can never crash on a
350
+ * singular-`file` finding that already passed the floor. The value is TRIMMED
351
+ * and a whitespace-only `file` is treated as ABSENT (it falls back to
352
+ * `files[0]`, not shadows it), so the resolved path both matches the trimmed
353
+ * commentable-line-set keys and is a valid GitHub review-comment `path` even
354
+ * for an untrimmed hand-authored ledger entry (`readGateFindingsLedger` trims
355
+ * `files[]` but not a singular `file`). Returns `undefined` when neither shape
356
+ * names a non-blank string path.
357
+ * @param {{ file?: unknown, files?: unknown }} finding
358
+ * @returns {string|undefined}
359
+ */
360
+ export function resolveFindingFile(finding) {
361
+ if (typeof finding?.file === "string" && finding.file.trim().length > 0) return finding.file.trim();
362
+ if (Array.isArray(finding?.files) && typeof finding.files[0] === "string" && finding.files[0].trim().length > 0) {
363
+ return finding.files[0].trim();
364
+ }
365
+ return undefined;
366
+ }
367
+
339
368
  /**
340
369
  * A finding is LOCATABLE-SHAPED when it names a real file (via `file` or
341
370
  * `files[0]`) and a positive-integer `line` — the ONE shared shape check
@@ -352,9 +381,7 @@ export function normalizeSeverityCounts(counts) {
352
381
  * @returns {boolean}
353
382
  */
354
383
  export function hasLocatableShape(finding) {
355
- const file = typeof finding?.file === "string"
356
- ? finding.file
357
- : (Array.isArray(finding?.files) ? finding.files[0] : undefined);
384
+ const file = resolveFindingFile(finding);
358
385
  return typeof file === "string" && file.trim().length > 0
359
386
  && Number.isInteger(finding?.line) && /** @type {number} */ (finding.line) >= 1;
360
387
  }
@@ -690,7 +717,7 @@ export function fanoutReviewerPairingError(perAngle, resolvedGroups = null) {
690
717
 
691
718
  /**
692
719
  * Base angle name for a delta-suffixed re-review entry (`<angle>-delta-at-...`,
693
- * e.g. `pr-checklist-matrix-delta-at-current-head`): a re-review scoped to only
720
+ * e.g. `pr-checklist-delta-at-current-head`): a re-review scoped to only
694
721
  * the current head's delta still counts toward its base angle for both
695
722
  * mandatory-angle coverage and pool-membership checks.
696
723
  *
@@ -736,12 +763,12 @@ export function checkFanoutAngleCoverage(recordedAngles, { mandatoryAngles = [],
736
763
 
737
764
  /**
738
765
  * Angles the fan-in itself mandates and may synthesize (consolidate-fanin's
739
- * `--pr-checklist-matrix clean` upsert) without them appearing in any gate's
766
+ * `--pr-checklist clean` upsert) without them appearing in any gate's
740
767
  * configured `angles` pool. Always legal in the foreign-angle check above —
741
768
  * requiring every consumer repo to also list them per-gate would make the two
742
769
  * tools contradict the shared contract they implement.
743
770
  */
744
- export const FANIN_SYNTHETIC_ANGLES = Object.freeze(["pr-checklist-matrix"]);
771
+ export const FANIN_SYNTHETIC_ANGLES = Object.freeze(["pr-checklist"]);
745
772
 
746
773
  /**
747
774
  * Validate a round's RESOLVED angle set — the full angle list the round
@@ -807,7 +834,7 @@ export function checkResolvedAngleEvidence(resolvedAngles, { recordedAngles, car
807
834
  export const DEFAULT_MAX_FANOUT_REVIEWERS = 8;
808
835
 
809
836
  // Every sanctioned angle name is a short, hand-authored slug (e.g.
810
- // "contradiction-lens", "pr-checklist-matrix"); nothing legitimate ever
837
+ // "contradiction-lens", "pr-checklist"); nothing legitimate ever
811
838
  // approaches this length. Bounding it here, at the trust boundary this
812
839
  // function already owns, fails a pathological artifact closed as malformed —
813
840
  // the same place every other angle-result defect is caught — instead of
@@ -123,7 +123,7 @@ register(INTERNAL_DEV_LOOP_STRATEGY.FINAL_APPROVAL, "default", {
123
123
  register(INTERNAL_DEV_LOOP_STRATEGY.LOCAL_IMPLEMENTATION, "default", {
124
124
  criteria: [
125
125
  { id: "phase-ac", must: "All phase acceptance criteria from the active phase doc are satisfied.", severity: "required" },
126
- { id: "verify-green", must: "`npm run verify` passes with no failures.", severity: "required" },
126
+ { id: "verify-green", must: "`bun run verify` passes with no failures.", severity: "required" },
127
127
  ],
128
128
  evidence: ["commands-run", "validation-output", "changed-files"],
129
129
  maxFinalizationTurns: 6,
@@ -138,7 +138,7 @@ register(INTERNAL_DEV_LOOP_STRATEGY.LOCAL_IMPLEMENTATION, "default", {
138
138
  register(INTERNAL_DEV_LOOP_STRATEGY.LOCAL_IMPLEMENTATION, "spike", {
139
139
  criteria: [
140
140
  { id: "spike-recorded", must: "The spike exploration and its recommendation are recorded (spike file + summary).", severity: "required" },
141
- { id: "verify-green", must: "`npm run verify` passes with no failures.", severity: "required" },
141
+ { id: "verify-green", must: "`bun run verify` passes with no failures.", severity: "required" },
142
142
  ],
143
143
  evidence: ["commands-run", "validation-output", "changed-files"],
144
144
  maxFinalizationTurns: 6,