Sign In

@dev-loops/core

Package Overview
Dependencies
Maintainers
1
Versions
26
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

@dev-loops/core - npm Package Compare versions

Comparing version
1.0.0-rc.4
to
1.0.0-rc.5
+1
-1
package.json
{
"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 @@

@@ -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