@dev-loops/core
Advanced tools
+1
-1
| { | ||
| "name": "@dev-loops/core", | ||
| "version": "1.0.0-rc.4", | ||
| "version": "1.0.0-rc.5", | ||
| "type": "module", | ||
@@ -5,0 +5,0 @@ "engines": { |
@@ -147,4 +147,10 @@ const VALID_HEAD_SCOPED_CI_STATUSES = new Set(["success", "failure", "pending", "none"]); | ||
| * | ||
| * `allQueued` is the zero-allocation stall signal (#1631): true when at least one | ||
| * check-run is present AND every one is still in the `queued` status — i.e. no | ||
| * runner has been allocated to any job (no job picked up / in_progress / completed). | ||
| * The CI watcher uses it to bail early on a stuck GitHub Actions queue instead | ||
| * of burning the full watch budget. | ||
| * | ||
| * @param {object} payload | ||
| * @returns {{ status: "success"|"failure"|"pending"|"none", unsupportedCompleted: boolean, failureDetails?: Array<string> }} | ||
| * @returns {{ status: "success"|"failure"|"pending"|"none", unsupportedCompleted: boolean, allQueued: boolean, failureDetails?: Array<string> }} | ||
| */ | ||
@@ -154,3 +160,3 @@ export function summarizeHeadScopedCheckRunsSignal(payload) { | ||
| if (runs.length === 0) { | ||
| return { status: "none", unsupportedCompleted: false }; | ||
| return { status: "none", unsupportedCompleted: false, allQueued: false }; | ||
| } | ||
@@ -162,2 +168,3 @@ | ||
| let hasUnsupportedCompleted = false; | ||
| let allQueued = true; // every run is status "queued" (zero runner allocation) | ||
| const failureDetails = []; | ||
@@ -169,2 +176,6 @@ | ||
| if (status !== "QUEUED") { | ||
| allQueued = false; | ||
| } | ||
| if (status !== "COMPLETED") { | ||
@@ -190,7 +201,7 @@ hasPending = true; | ||
| if (hasFailure) return { status: "failure", unsupportedCompleted: hasUnsupportedCompleted, failureDetails }; | ||
| if (hasPending) return { status: "pending", unsupportedCompleted: hasUnsupportedCompleted, failureDetails: failureDetails.length > 0 ? failureDetails : undefined }; | ||
| if (hasUnsupportedCompleted) return { status: "none", unsupportedCompleted: true, failureDetails: failureDetails.length > 0 ? failureDetails : undefined }; | ||
| if (hasSuccess) return { status: "success", unsupportedCompleted: false, failureDetails: failureDetails.length > 0 ? failureDetails : undefined }; | ||
| return { status: "none", unsupportedCompleted: false, failureDetails: failureDetails.length > 0 ? failureDetails : undefined }; | ||
| if (hasFailure) return { status: "failure", unsupportedCompleted: hasUnsupportedCompleted, allQueued, failureDetails }; | ||
| if (hasPending) return { status: "pending", unsupportedCompleted: hasUnsupportedCompleted, allQueued, failureDetails: failureDetails.length > 0 ? failureDetails : undefined }; | ||
| if (hasUnsupportedCompleted) return { status: "none", unsupportedCompleted: true, allQueued, failureDetails: failureDetails.length > 0 ? failureDetails : undefined }; | ||
| if (hasSuccess) return { status: "success", unsupportedCompleted: false, allQueued, failureDetails: failureDetails.length > 0 ? failureDetails : undefined }; | ||
| return { status: "none", unsupportedCompleted: false, allQueued, failureDetails: failureDetails.length > 0 ? failureDetails : undefined }; | ||
| } | ||
@@ -197,0 +208,0 @@ |
+244
-0
@@ -80,2 +80,89 @@ /** | ||
| /** | ||
| * Reviewer-budget preflight for a gate fan-out (issue #1507). | ||
| * | ||
| * Before the conductor dispatches any reviewer, it derives how many reviewers | ||
| * the round needs (one per dispatch unit — fresh angles + re-verifications) and | ||
| * compares against the harness's remaining reviewer budget. When the budget | ||
| * cannot cover the dispatch, the preflight reports the shortfall BEFORE any | ||
| * reviewer spawns, naming the shortfall; the shortfall is a recorded, resumable | ||
| * state (completed per-angle artifacts stay valid for their head, so a later | ||
| * session resumes the fan-out instead of restarting it). A budget shortfall | ||
| * NEVER downgrades a required gate to `inline_single_agent` and NEVER produces a | ||
| * clean verdict — no new gate-exemption path (#1507 AC4). | ||
| * | ||
| * Pure: takes the dispatch plan + available budget, returns the decision. The | ||
| * conductor reads `artifact.fanout.preflight` (emitted by `write-gate-context`) | ||
| * and dispatches wave-by-wave only when `dispatch === true`; on `false` it | ||
| * records the shortfall (the artifact itself is the resumable record) and | ||
| * stops without spawning a single reviewer. `availableReviewers` is `null` when | ||
| * the harness does not expose a budget — no shortfall can be proven, so the | ||
| * preflight proceeds (today's behavior); it only blocks on a PROVEN shortfall. | ||
| * | ||
| * The returned `verdict` and `executionMode` are ALWAYS `null`: a shortfall is | ||
| * not a verdict. `buildPreMergeGateCheck` / `evaluateInlineFanoutMode` reject a | ||
| * gate with no clean current-head marker and a non-`fanout_fanin` execution | ||
| * mode, so a shortfall state fails closed at merge rather than yielding a clean | ||
| * or inline verdict (#1507 DoD). | ||
| * | ||
| * @param {{ name: string, angles: string[] }[]} dispatchGroups — `resolveFanoutGroups` output (fresh angles + re-verifications) | ||
| * @param {number|null} [availableReviewers] — harness remaining reviewer budget; null/non-finite = unknown/unexposed | ||
| * @param {{ completedAngles?: Iterable<string> }} [options] — `completedAngles`: angle names that | ||
| * already have a clean per-angle findings artifact stamped for THIS head. A dispatch unit (group) | ||
| * whose angles are ALL complete is excluded from the required count and from `pendingGroups`, so a | ||
| * later session resumes the fan-out instead of restarting it (issue #1507 AC3): it re-runs the | ||
| * preflight and dispatches only the groups not already complete at this head. | ||
| * @returns {{ ok: boolean, dispatch: boolean, requiredReviewers: number, availableReviewers: number|null, shortfall: number|null, reason: string, verdict: null, executionMode: null, pendingGroups: { name: string, angles: string[] }[], skippedGroups: { name: string, angles: string[] }[], completedAngles: string[] }} | ||
| */ | ||
| export function reviewerBudgetPreflight(dispatchGroups, availableReviewers, { completedAngles } = {}) { | ||
| const groups = Array.isArray(dispatchGroups) ? dispatchGroups : []; | ||
| const completedSet = new Set( | ||
| Array.isArray(completedAngles) | ||
| ? completedAngles | ||
| : completedAngles == null | ||
| ? [] | ||
| : [...completedAngles], | ||
| ); | ||
| // #1507 AC3: resume instead of restart. One reviewer per dispatch unit (a | ||
| // group of N angles is one reviewer's scoped dispatch — see resolveFanoutGroups / | ||
| // countFreshDispatchUnits), but a group already COMPLETE at this head — every | ||
| // one of its angles has a clean artifact stamped for this head — needs no | ||
| // reviewer and is excluded from the required count and the pending plan. The | ||
| // conductor dispatches only `pendingGroups`. | ||
| const groupIsComplete = (g) => | ||
| Array.isArray(g?.angles) && g.angles.length > 0 && g.angles.every((a) => completedSet.has(a)); | ||
| const pendingGroups = groups.filter((g) => !groupIsComplete(g)); | ||
| const skippedGroups = groups.filter((g) => groupIsComplete(g)); | ||
| // One reviewer per dispatch unit: a group of N angles is one reviewer's | ||
| // scoped dispatch, so the reviewer count is the pending dispatch-unit count, | ||
| // not the raw angle count. | ||
| const requiredReviewers = pendingGroups.length; | ||
| const verdict = null; | ||
| const executionMode = null; | ||
| const resume = { pendingGroups, skippedGroups, completedAngles: [...completedSet] }; | ||
| if (typeof availableReviewers !== "number" || !Number.isFinite(availableReviewers)) { | ||
| return { ok: true, dispatch: true, requiredReviewers, availableReviewers: null, shortfall: null, reason: "budget_unknown", verdict, executionMode, ...resume }; | ||
| } | ||
| // A negative/over-spent budget clamps to 0 (budget exhausted → shortfall for | ||
| // any non-empty round); a fractional budget truncates to the integer floor. | ||
| const available = Math.max(0, Math.trunc(availableReviewers)); | ||
| if (requiredReviewers === 0) { | ||
| return { ok: true, dispatch: true, requiredReviewers: 0, availableReviewers: available, shortfall: null, reason: "no_reviewers_needed", verdict, executionMode, ...resume }; | ||
| } | ||
| if (available >= requiredReviewers) { | ||
| return { ok: true, dispatch: true, requiredReviewers, availableReviewers: available, shortfall: null, reason: "budget_sufficient", verdict, executionMode, ...resume }; | ||
| } | ||
| return { | ||
| ok: false, | ||
| dispatch: false, | ||
| requiredReviewers, | ||
| availableReviewers: available, | ||
| shortfall: requiredReviewers - available, | ||
| reason: "budget_shortfall", | ||
| verdict, | ||
| executionMode, | ||
| ...resume, | ||
| }; | ||
| } | ||
| // Exported so other tools (e.g. scripts/loop/consolidate-fanin.mjs, | ||
@@ -741,2 +828,144 @@ // scripts/github/upsert-checkpoint-verdict.mjs) sort/rank/validate against | ||
| /** | ||
| * The judge's relevance-based disposition vocabulary — distinct from the | ||
| * severity-based `disposition` (accepted-for-fix/deferred/needs-answer) that | ||
| * `deriveDisposition` owns. The judge decides *where* a finding is acted on | ||
| * (this PR or a follow-up), never *whether* it is real: a `reject` is a | ||
| * relevance verdict (out-of-scope against a named non-goal or scope | ||
| * boundary), not a reproduction verdict. The fixer retains reproduction-based | ||
| * rejection; the judge owns relevance (#1525). | ||
| */ | ||
| export const JUDGE_DISPOSITIONS = Object.freeze(["act", "defer", "reject"]); | ||
| /** | ||
| * Validate a judge verdict artifact shape (the dedicated `judge` agent's only | ||
| * write). Pure; throws on a malformed verdict rather than silently enriching | ||
| * findings with garbage. The judge is the designated memory across rounds, so | ||
| * its artifact is the authoritative relevance record — a malformed one fails | ||
| * closed rather than degrading to severity-only disposition. | ||
| * | ||
| * Shape: | ||
| * ``` | ||
| * { | ||
| * headSha: "<sha>", | ||
| * scopeDrift: { verdict: "within_scope"|"drift_detected", rationale: "...", driftedAreas: ["..."] }, | ||
| * dispositions: [{ index, disposition: "act"|"defer"|"reject", rationale, criterion?, followUpDraft? }] | ||
| * } | ||
| * ``` | ||
| * | ||
| * @param {unknown} verdict | ||
| * @returns {{ headSha: string, scopeDrift: object, dispositions: Array<object> }} | ||
| */ | ||
| export function validateJudgeVerdict(verdict) { | ||
| if (!verdict || typeof verdict !== "object" || Array.isArray(verdict)) { | ||
| throw new Error("judge verdict must be a JSON object"); | ||
| } | ||
| const v = /** @type {Record<string, unknown>} */ (verdict); | ||
| if (typeof v.headSha !== "string" || v.headSha.trim().length === 0) { | ||
| throw new Error("judge verdict.headSha must be a non-empty string"); | ||
| } | ||
| if (!v.scopeDrift || typeof v.scopeDrift !== "object" || Array.isArray(v.scopeDrift)) { | ||
| throw new Error("judge verdict.scopeDrift must be an object"); | ||
| } | ||
| const sd = /** @type {Record<string, unknown>} */ (v.scopeDrift); | ||
| if (sd.verdict !== "within_scope" && sd.verdict !== "drift_detected") { | ||
| throw new Error("judge verdict.scopeDrift.verdict must be 'within_scope' or 'drift_detected'"); | ||
| } | ||
| if (typeof sd.rationale !== "string" || sd.rationale.trim().length === 0) { | ||
| throw new Error("judge verdict.scopeDrift.rationale must be a non-empty string"); | ||
| } | ||
| if (!Array.isArray(sd.driftedAreas)) { | ||
| throw new Error("judge verdict.scopeDrift.driftedAreas must be an array"); | ||
| } | ||
| for (const [di, area] of sd.driftedAreas.entries()) { | ||
| if (typeof area !== "string" || area.trim().length === 0) { | ||
| throw new Error(`judge verdict.scopeDrift.driftedAreas[${di}] must be a non-empty string`); | ||
| } | ||
| } | ||
| if (!Array.isArray(v.dispositions)) { | ||
| throw new Error("judge verdict.dispositions must be an array"); | ||
| } | ||
| const seenIndices = new Set(); | ||
| for (const [i, d] of v.dispositions.entries()) { | ||
| if (!d || typeof d !== "object" || Array.isArray(d)) { | ||
| throw new Error(`judge verdict.dispositions[${i}] must be an object`); | ||
| } | ||
| const entry = /** @type {Record<string, unknown>} */ (d); | ||
| if (!Number.isInteger(entry.index) || entry.index < 0) { | ||
| throw new Error(`judge verdict.dispositions[${i}].index must be a non-negative integer`); | ||
| } | ||
| if (seenIndices.has(entry.index)) { | ||
| throw new Error(`judge verdict.dispositions[${i}].index ${entry.index} is a duplicate — the contract is one disposition per finding`); | ||
| } | ||
| seenIndices.add(entry.index); | ||
| if (!JUDGE_DISPOSITIONS.includes(entry.disposition)) { | ||
| throw new Error(`judge verdict.dispositions[${i}].disposition must be one of: ${JUDGE_DISPOSITIONS.join(", ")}`); | ||
| } | ||
| if (typeof entry.rationale !== "string" || entry.rationale.trim().length === 0) { | ||
| throw new Error(`judge verdict.dispositions[${i}].rationale must be a non-empty string naming the criterion, non-goal, or scope boundary`); | ||
| } | ||
| // followUpDraft is REQUIRED on a defer disposition (soft-cap contract: a | ||
| // deferred finding carries a fileable follow-up draft). Optional otherwise. | ||
| if (entry.disposition === "defer") { | ||
| if (!entry.followUpDraft || typeof entry.followUpDraft !== "object" || Array.isArray(entry.followUpDraft)) { | ||
| throw new Error(`judge verdict.dispositions[${i}].followUpDraft is required on a defer disposition`); | ||
| } | ||
| const draft = /** @type {Record<string, unknown>} */ (entry.followUpDraft); | ||
| if (typeof draft.title !== "string" || draft.title.trim().length === 0 || typeof draft.body !== "string") { | ||
| throw new Error(`judge verdict.dispositions[${i}].followUpDraft must have a non-empty title and a body string`); | ||
| } | ||
| } | ||
| } | ||
| return { headSha: v.headSha, scopeDrift: v.scopeDrift, dispositions: v.dispositions }; | ||
| } | ||
| /** | ||
| * Merge the judge's relevance-based dispositions into the consolidated findings | ||
| * array (the flat per-finding shape `consolidateFanin` / `toFindingsLogShape` | ||
| * produce). The judge runs AFTER fan-in and BEFORE the fix pass (#1525): it | ||
| * receives the consolidated ledger, the issue's AC/DoD/non-goals, the PR's | ||
| * declared scope, and prior-round ledgers, and emits a per-finding disposition | ||
| * (`act` / `defer` / `reject`) plus a scope-drift verdict on the PR as a whole. | ||
| * | ||
| * This function enriches each finding with `judgeDisposition`, `judgeRationale`, | ||
| * and (for `defer`) `followUpDraft` so the disposition ledger and posted findings | ||
| * comment carry what was consciously not acted on and why. The severity-based | ||
| * `disposition` (accepted-for-fix/deferred/needs-answer) is LEFT INTACT — the | ||
| * judge's relevance axis is complementary, not a replacement (a real defect | ||
| * stays a real defect; the judge decides *where* it is fixed, not *whether* it | ||
| * is real). | ||
| * | ||
| * The fix pass consumes only the `act` list; the fixer retains reproduction- | ||
| * based rejection (a finding that does not reproduce is dead regardless of the | ||
| * judge's verdict) but stops deciding relevance. | ||
| * | ||
| * Pure. Fails closed (throws) when a disposition references an out-of-range | ||
| * index — a judge verdict that names a finding that is not in the ledger is a | ||
| * mismatch, never a silent enrichment. | ||
| * | ||
| * @param {Array<object>} findings — the flat consolidated findings array | ||
| * @param {object} judgeVerdict — the validated judge verdict artifact | ||
| * @returns {{ findings: Array<object>, scopeDrift: object }} | ||
| */ | ||
| export function applyJudgeDispositions(findings, judgeVerdict) { | ||
| const validated = validateJudgeVerdict(judgeVerdict); | ||
| const list = Array.isArray(findings) ? findings : []; | ||
| const enriched = list.map((f) => ({ ...f })); | ||
| for (const d of validated.dispositions) { | ||
| if (d.index >= enriched.length) { | ||
| throw new Error(`judge disposition index ${d.index} is out of range (findings has ${enriched.length} entries)`); | ||
| } | ||
| const target = enriched[d.index]; | ||
| target.judgeDisposition = d.disposition; | ||
| target.judgeRationale = d.rationale; | ||
| if (typeof d.criterion === "string" && d.criterion.trim().length > 0) { | ||
| target.judgeCriterion = d.criterion.trim(); | ||
| } | ||
| if (d.disposition === "defer" && d.followUpDraft) { | ||
| target.followUpDraft = d.followUpDraft; | ||
| } | ||
| } | ||
| return { findings: enriched, scopeDrift: validated.scopeDrift }; | ||
| } | ||
| /** | ||
| * Map consolidated findings into the `--findings` JSON shape consumed by | ||
@@ -772,2 +1001,17 @@ * scripts/github/write-gate-findings-log.mjs (severity, angle, summary, | ||
| } | ||
| // Carry the judge's relevance-based dispositions through (#1525) so the | ||
| // durable ledger and posted findings comment show what was consciously not | ||
| // acted on and why. | ||
| if (typeof f.judgeDisposition === "string" && f.judgeDisposition.trim().length > 0) { | ||
| entry.judgeDisposition = f.judgeDisposition.trim(); | ||
| } | ||
| if (typeof f.judgeRationale === "string" && f.judgeRationale.trim().length > 0) { | ||
| entry.judgeRationale = f.judgeRationale.trim(); | ||
| } | ||
| if (typeof f.judgeCriterion === "string" && f.judgeCriterion.trim().length > 0) { | ||
| entry.judgeCriterion = f.judgeCriterion.trim(); | ||
| } | ||
| if (f.followUpDraft && typeof f.followUpDraft === "object" && !Array.isArray(f.followUpDraft)) { | ||
| entry.followUpDraft = f.followUpDraft; | ||
| } | ||
| return entry; | ||
@@ -774,0 +1018,0 @@ }); |
@@ -151,16 +151,21 @@ /** | ||
| /** | ||
| * Extract bullet items from a section body. Counts both `- [ ]`/`- [x]` | ||
| * checklist items and top-level plain `- ` bullets (dash at column 0, so | ||
| * nested/indented sub-bullets are not counted). Empty checkbox placeholders | ||
| * (`- [ ]` / `- [x]` with no trailing text) are skipped, not counted, so a | ||
| * section of only unfilled placeholders reports as unrefined. Returns the | ||
| * trimmed item text for each matching line. The checkbox state (checked vs | ||
| * unchecked) is intentionally not preserved: callers only need the item | ||
| * text to satisfy the refinement-artifact contract. | ||
| * Parse bullet/checkbox items from a section body into item states. Each | ||
| * checkbox item (`- [ ]`/`- [x]`/`- [X]`) becomes `{ text, checked }` | ||
| * (`checked` true only for a ticked `[x]`/`[X]`); a top-level plain bullet | ||
| * (`- text`, dash at column 0 so nested/indented sub-bullets are not counted) | ||
| * becomes `{ text, checked: null }` — it has no checkbox to tick. Empty | ||
| * checkbox placeholders (`- [ ]` / `- [x]` with no trailing text) are skipped, | ||
| * not counted, so a section of only unfilled placeholders reports as unrefined. | ||
| * Code-fenced lines are skipped (same fence logic as parseMarkdownSections, | ||
| * issue #1025) so a body cannot spoof the AC/DoD gate with code-fenced | ||
| * checkboxes. | ||
| * | ||
| * This is only ever called on the body of an already-recognized AC/DoD | ||
| * section (see `detectIssueRefinementArtifact`), so counting plain bullets | ||
| * is scoped to those sections and never affects prose sections. | ||
| * Shared by `extractChecklistItems` (text-only) and the unticked-AC check | ||
| * (`extractUncheckedChecklistItems`) so the two never drift on what counts as | ||
| * a checklist item or on the checkbox-state read (#1621). Only ever called on | ||
| * the body of an already-recognized AC/DoD section (see | ||
| * `detectIssueRefinementArtifact`), so counting plain bullets is scoped to | ||
| * those sections and never affects prose sections. | ||
| */ | ||
| export function extractChecklistItems(sectionBody) { | ||
| function parseChecklistItems(sectionBody) { | ||
| if (typeof sectionBody !== "string" || sectionBody.length === 0) { | ||
@@ -175,5 +180,2 @@ return []; | ||
| for (const line of lines) { | ||
| // Checkboxes/bullets inside a fenced code span are non-interactive text, not | ||
| // real items — skip them so a body cannot spoof the AC/DoD gate with | ||
| // code-fenced checkboxes (issue #1025). Same fence logic as parseMarkdownSections. | ||
| const step = stepFence(fence, line); | ||
@@ -191,3 +193,6 @@ fence = step.fence; | ||
| if (text.length > 0) { | ||
| items.push(text); | ||
| // `checked` is true only for a ticked box; `[ ]` (space) is false. | ||
| // A plain bullet has no checkbox, so it stays `null` below — it is | ||
| // neither ticked nor unticked and does not count as an unticked AC. | ||
| items.push({ text, checked: /^\s*-\s+\[[xX]\]/u.test(line) }); | ||
| } | ||
@@ -202,3 +207,3 @@ continue; | ||
| if (text.length > 0) { | ||
| items.push(text); | ||
| items.push({ text, checked: null }); | ||
| } | ||
@@ -212,2 +217,28 @@ } | ||
| /** | ||
| * Extract bullet items from a section body. Counts both `- [ ]`/`- [x]` | ||
| * checklist items and top-level plain `- ` bullets. Empty checkbox placeholders | ||
| * are skipped. Returns the trimmed item text for each matching line; the | ||
| * checkbox state is not preserved (use `extractUncheckedChecklistItems` for | ||
| * that). Thin wrapper over `parseChecklistItems` so the text-only contract | ||
| * stays byte-identical to its pre-#1621 shape. | ||
| */ | ||
| export function extractChecklistItems(sectionBody) { | ||
| return parseChecklistItems(sectionBody).map((item) => item.text); | ||
| } | ||
| /** | ||
| * Extract the text of UNCHECKED checkbox items (`- [ ]`) from a section body. | ||
| * A ticked box (`- [x]`/`- [X]`) and a plain bullet (no checkbox) are both | ||
| * excluded — only an actual unticked checkbox is an "unticked AC item" | ||
| * (#1621, ACCEPT-CRITERIA-VERIFY-AND-REFLECT). Empty placeholders are skipped. | ||
| * Thin wrapper over `parseChecklistItems` so the unticked read never drifts | ||
| * from `extractChecklistItems` on what counts as a checklist item. | ||
| */ | ||
| export function extractUncheckedChecklistItems(sectionBody) { | ||
| return parseChecklistItems(sectionBody) | ||
| .filter((item) => item.checked === false) | ||
| .map((item) => item.text); | ||
| } | ||
| /** | ||
| * Detect a linked refinement doc path from the issue body. | ||
@@ -254,2 +285,3 @@ * Looks for explicit `tmp/refinement/<n>-plan.md` style paths and the | ||
| * acItems: string[], | ||
| * uncheckedAcItems: string[], | ||
| * dodItems: string[], | ||
@@ -268,2 +300,3 @@ * sections: string[], | ||
| acItems: [], | ||
| uncheckedAcItems: [], | ||
| dodItems: [], | ||
@@ -284,2 +317,7 @@ sections: [], | ||
| const acItems = acceptanceSection ? extractChecklistItems(acceptanceSection.bodyLines.join("\n")) : []; | ||
| // Unticked AC checkboxes (`- [ ]`) of the spec-of-record — the | ||
| // ACCEPT-CRITERIA-VERIFY-AND-REFLECT precondition a clean pre_approval_gate | ||
| // must refuse on (#1621). Only actual unticked checkboxes count; a ticked | ||
| // box and a plain bullet (no checkbox) are both excluded. | ||
| const uncheckedAcItems = acceptanceSection ? extractUncheckedChecklistItems(acceptanceSection.bodyLines.join("\n")) : []; | ||
| const dodItems = dodSection ? extractChecklistItems(dodSection.bodyLines.join("\n")) : []; | ||
@@ -294,2 +332,3 @@ | ||
| acItems, | ||
| uncheckedAcItems, | ||
| dodItems, | ||
@@ -308,2 +347,3 @@ sections: sectionNames, | ||
| acItems, | ||
| uncheckedAcItems, | ||
| dodItems, | ||
@@ -322,2 +362,3 @@ sections: sectionNames, | ||
| acItems: [], | ||
| uncheckedAcItems: [], | ||
| dodItems: [], | ||
@@ -335,2 +376,3 @@ sections: sectionNames, | ||
| acItems: [], | ||
| uncheckedAcItems: [], | ||
| dodItems: [], | ||
@@ -552,3 +594,3 @@ sections: sectionNames, | ||
| "Add at least ONE of them — an Acceptance criteria section, a Definition of done section, or a linked refinement doc " + | ||
| "(e.g. run `/loop-grill <issue> --auto`, or the refiner) — before it enters the pickup queue."; | ||
| "(e.g. run `/dev-loops:loop-grill <issue> --auto` (or `/loop-grill <issue> --auto` in the dev-loops repo itself), or the refiner) — before it enters the pickup queue."; | ||
| return { action: auto ? "divert" : "block", reason, missing }; | ||
@@ -555,0 +597,0 @@ } |
@@ -76,21 +76,85 @@ /** | ||
| /** | ||
| * Returns true if a routing result represents a qualifying GitHub-first async | ||
| * dev-loop completion that requires a post-run behavioral retrospective before | ||
| * the next start/resume. | ||
| * Normalizes a dev-loop cycle identity — the minimum facts that pin a | ||
| * checkpoint record to one specific qualifying completion: repo, PR number, | ||
| * and merge commit. Returns null when any field is missing or malformed, so a | ||
| * partial/garbled identity can never be mistaken for a valid one. | ||
| * | ||
| * A qualifying completion is one that: | ||
| * - has a `selectedGate` in RETROSPECTIVE_QUALIFYING_GATES | ||
| * - with `routeKind === "route"` (inspect/status-only results do not qualify) | ||
| * @param {unknown} identity | ||
| * @returns {{repo: string, prNumber: number, mergeCommit: string}|null} | ||
| */ | ||
| export function isQualifyingAsyncCompletion(routingResult) { | ||
| if (!routingResult || typeof routingResult !== "object") return false; | ||
| const { routeKind, selectedGate } = routingResult; | ||
| if (routeKind !== "route") { | ||
| return false; | ||
| export function normalizeCheckpointCycleIdentity(identity) { | ||
| if (!identity || typeof identity !== "object") { | ||
| return null; | ||
| } | ||
| if (typeof selectedGate !== "string") return false; | ||
| return RETROSPECTIVE_QUALIFYING_GATES.includes(selectedGate); | ||
| const repo = typeof identity.repo === "string" ? identity.repo.trim() : ""; | ||
| const prNumber = Number.isInteger(identity.prNumber) && identity.prNumber > 0 ? identity.prNumber : null; | ||
| const mergeCommit = typeof identity.mergeCommit === "string" ? identity.mergeCommit.trim() : ""; | ||
| if (repo.length === 0 || prNumber === null || mergeCommit.length === 0) { | ||
| return null; | ||
| } | ||
| return { repo, prNumber, mergeCommit }; | ||
| } | ||
| /** | ||
| * Resolves the RETROSPECTIVE_CHECKPOINT_STATE for a durable checkpoint | ||
| * artifact, scoped to the recorded cycle's recency (issue: a one-time | ||
| * `complete`/`skipped` checkpoint must not satisfy every later qualifying | ||
| * cycle forever). | ||
| * | ||
| * A `complete` or `skipped` artifact is scoped by `hasNewerMergeSinceCheckpoint`: | ||
| * when true, something has merged since the checkpoint's recorded discharge | ||
| * point (or that point could not be verified at all), so the checkpoint | ||
| * cannot cover the newer cycle — it fails closed to MISSING. The caller | ||
| * derives `hasNewerMergeSinceCheckpoint` itself (this module stays | ||
| * pure/I/O-free) by checking local git ancestry between the checkpoint's | ||
| * recorded merge commit and the base branch, so this runs fresh on every | ||
| * evaluation rather than depending on anything having written a fresh | ||
| * `required` record for the new cycle. | ||
| * | ||
| * `required`/`none` are not scoped by this comparison: `required` already | ||
| * maps to MISSING regardless of recency (an outstanding requirement blocks | ||
| * the gate no matter which cycle triggered it), and `none` means no | ||
| * completion has ever been observed. | ||
| * | ||
| * @param {object|null|undefined} artifact - Parsed checkpoint JSON, or | ||
| * `undefined` when the durable artifact is genuinely ABSENT (no file). Any | ||
| * other non-plain-object value — including the JSON literal `null` (a file | ||
| * that IS present but contains malformed content) and a corrupt-but-valid | ||
| * scalar/array — is treated as present-but-malformed and fails closed to | ||
| * MISSING; only a genuinely absent artifact resolves to NONE. | ||
| * @param {object} [options] | ||
| * @param {boolean} [options.hasNewerMergeSinceCheckpoint] - True when the | ||
| * caller has determined (or could not rule out) that something has merged | ||
| * to the base branch since the checkpoint's recorded discharge point. | ||
| * Ignored for states other than `complete`/`skipped`. Defaults to `false` | ||
| * (trust the recorded state) so callers that never verify recency (e.g. | ||
| * `workflow.requireRetrospective` disabled) see unchanged behavior. | ||
| * @returns {"none"|"complete"|"skipped"|"missing"} | ||
| */ | ||
| export function resolveCheckpointStateFromArtifact(artifact, { hasNewerMergeSinceCheckpoint = false } = {}) { | ||
| if (artifact === undefined) { | ||
| return RETROSPECTIVE_CHECKPOINT_STATE.NONE; | ||
| } | ||
| if (artifact === null || typeof artifact !== "object" || Array.isArray(artifact)) { | ||
| // Present but malformed — fail closed, do not treat as "nothing observed". | ||
| return RETROSPECTIVE_CHECKPOINT_STATE.MISSING; | ||
| } | ||
| const rawState = typeof artifact.state === "string" ? artifact.state.trim().toLowerCase() : null; | ||
| if (rawState === "required" || rawState === "missing") { | ||
| return RETROSPECTIVE_CHECKPOINT_STATE.MISSING; | ||
| } | ||
| if (rawState === "none") { | ||
| return RETROSPECTIVE_CHECKPOINT_STATE.NONE; | ||
| } | ||
| if (rawState === "skipped") { | ||
| return hasNewerMergeSinceCheckpoint ? RETROSPECTIVE_CHECKPOINT_STATE.MISSING : RETROSPECTIVE_CHECKPOINT_STATE.SKIPPED; | ||
| } | ||
| if (rawState === "complete") { | ||
| return hasNewerMergeSinceCheckpoint ? RETROSPECTIVE_CHECKPOINT_STATE.MISSING : RETROSPECTIVE_CHECKPOINT_STATE.COMPLETE; | ||
| } | ||
| // Malformed/unrecognized durable state — fail closed. | ||
| return RETROSPECTIVE_CHECKPOINT_STATE.MISSING; | ||
| } | ||
| /** | ||
| * Enforcement gate for the required post-run behavioral retrospective. | ||
@@ -97,0 +161,0 @@ * |
Sorry, the diff of this file is too big to display
Sorry, the diff of this file is too big to display
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
1144109
1.97%24269
1.65%