@dev-loops/core 1.0.0-rc.4 → 1.0.0-rc.6

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.
@@ -0,0 +1,375 @@
1
+ /**
2
+ * primer-evidence.mjs — primer dispatch ordering evidence + fail-closed fan-in
3
+ * validation (issue #1468 slice 3).
4
+ *
5
+ * Slice 1-2 (review-dispatch-plan.mjs) produced the deterministic request-plan
6
+ * artifact, request-prefix fingerprints, stable/volatile separation, and
7
+ * per-model primer-group partitioning. This slice closes the gap between
8
+ * "the rules say prime before fan-out" and "we can assert it happened": it
9
+ * records evidence that each request group's primer actually landed before any
10
+ * of that group's reviewers were released, and makes fan-in fail closed when
11
+ * that ordering — or the model group / request fingerprint / shared-prefix hash
12
+ * binding — is missing or mismatched.
13
+ *
14
+ * This module is pure and offline (no GitHub, no harness, no clock). It owns:
15
+ *
16
+ * 1. primer-evidence artifact builder — one deterministic per-gate-run record
17
+ * ('<gate>-<headSha>.primer-evidence.json') pairing the request plan with
18
+ * the observed primer runs and reviewer releases and deriving the ordering
19
+ * verdict.
20
+ * 2. fail-closed validator — checks, named individually, that every request
21
+ * group got its own primer run, that each primer is scoped to its own
22
+ * model/prefix (never credited to another group), and that every reviewer
23
+ * release happened after its group's primer landed.
24
+ */
25
+ import { mkdir, writeFile } from "node:fs/promises";
26
+ import path from "node:path";
27
+
28
+ export const PRIMER_EVIDENCE_SCHEMA_VERSION = 1;
29
+
30
+ const sha = (s) => `sha256:${String(s).replace(/^sha256:/, "").trim().toLowerCase()}`;
31
+
32
+ /**
33
+ * Deterministic artifact path for a gate run's primer evidence.
34
+ *
35
+ * @param {object} input
36
+ * @param {string} input.dir - directory to write under (e.g. the gate-context dir).
37
+ * @param {string} input.gate - gate name (pre_approval_gate, draft_gate, ...).
38
+ * @param {string} input.headSha - reviewed head SHA (hex, 7-64).
39
+ * @returns {string} absolute-style path joined under `dir`.
40
+ */
41
+ export function primerEvidencePath({ dir, gate, headSha } = {}) {
42
+ if (typeof dir !== "string" || dir.length === 0) throw new Error("primerEvidencePath requires a dir");
43
+ if (typeof gate !== "string" || gate.length === 0) throw new Error("primerEvidencePath requires a gate");
44
+ if (typeof headSha !== "string" || !/^[0-9a-f]{7,64}$/i.test(headSha.trim())) {
45
+ throw new Error("primerEvidencePath requires a hex headSha");
46
+ }
47
+ return path.join(dir, `${gate}-${headSha.trim().toLowerCase()}.primer-evidence.json`);
48
+ }
49
+
50
+ /**
51
+ * Import a plan's request groups into a plain lookup keyed by canonical
52
+ * (model, requestPrefixFingerprint). Fingerprint-less groups are keyed by a
53
+ * per-model ordinal (`${model}::__unkeyed:<n>`), mirroring partitionPrimerGroups'
54
+ * per-partition separation.
55
+ *
56
+ * @param {object[]} requestGroups
57
+ * @returns {Map<string, object>}
58
+ */
59
+ function planGroupIndex(requestGroups) {
60
+ const idx = new Map();
61
+ // Fingerprint-less groups are keyed by a PER-MODEL ordinal so a primer run
62
+ // maps to the SAME plan group regardless of array positions. The prior
63
+ // position-based `${model}::__unkeyed:${i}` key used the array index (the
64
+ // primer run's position), which was NOT the same index planGroupIndex used
65
+ // (the group's position in the plan) — a fingerprint-less run whose array
66
+ // index differed from its group's plan index threw a false "prefix not
67
+ // present" error, and validatePrimerEvidence keyed them without any index
68
+ // at all (`__unkeyed`). One ordinal scheme everywhere closes that drift.
69
+ const unkeyedOrdinals = new Map();
70
+ for (let i = 0; i < requestGroups.length; i++) {
71
+ const g = requestGroups[i];
72
+ let key;
73
+ if (g.requestPrefixFingerprint) {
74
+ key = `${g.model}::${g.requestPrefixFingerprint}`;
75
+ } else {
76
+ const n = unkeyedOrdinals.get(g.model) ?? 0;
77
+ unkeyedOrdinals.set(g.model, n + 1);
78
+ key = `${g.model}::__unkeyed:${n}`;
79
+ }
80
+ if (!idx.has(key)) idx.set(key, []);
81
+ idx.get(key).push(g);
82
+ }
83
+ return idx;
84
+ }
85
+
86
+ /** Normalize a fingerprint to a canonical `sha256:<hex>` or null. */
87
+ function normFp(v) {
88
+ if (v == null) return null;
89
+ return sha(v);
90
+ }
91
+
92
+ /**
93
+ * Build the primer-evidence artifact for a gate run.
94
+ *
95
+ * @param {object} input
96
+ * @param {object} input.plan - the dispatch plan from buildReviewDispatchPlan().
97
+ * @param {Array<object>} input.primerRuns - [{ model, requestPrefixFingerprint, primerForm, landedAt }]
98
+ * @param {Array<object>} input.reviewerReleases - [{ model, requestPrefixFingerprint, releasedAt }]
99
+ * @returns {object} canonical evidence artifact.
100
+ */
101
+ export function buildPrimerEvidence({ plan, primerRuns = [], reviewerReleases = [] } = {}) {
102
+ if (!plan || typeof plan !== "object" || !Array.isArray(plan.requestGroups)) {
103
+ throw new Error("buildPrimerEvidence requires a plan with requestGroups");
104
+ }
105
+ if (!Array.isArray(primerRuns)) throw new Error("primerRuns must be an array");
106
+ if (!Array.isArray(reviewerReleases)) throw new Error("reviewerReleases must be an array");
107
+
108
+ const groups = plan.requestGroups;
109
+ const idx = planGroupIndex(groups);
110
+
111
+ const runUnkeyedOrdinals = new Map();
112
+ const normRuns = primerRuns.map((r, i) => {
113
+ if (typeof r.model !== "string" || r.model.length === 0) {
114
+ throw new Error(`primerRuns[${i}].model must be a non-empty concrete model`);
115
+ }
116
+ const fp = normFp(r.requestPrefixFingerprint);
117
+ let key;
118
+ if (fp) {
119
+ key = `${r.model}::${fp}`;
120
+ } else {
121
+ const n = runUnkeyedOrdinals.get(r.model) ?? 0;
122
+ runUnkeyedOrdinals.set(r.model, n + 1);
123
+ key = `${r.model}::__unkeyed:${n}`;
124
+ }
125
+ if (!idx.has(key)) {
126
+ throw new Error(
127
+ `primerRuns[${i}] references model ${JSON.stringify(r.model)} with a prefix not present in the plan's request groups`,
128
+ );
129
+ }
130
+ return Object.freeze({
131
+ model: r.model,
132
+ requestPrefixFingerprint: fp,
133
+ primerForm: r.primerForm ?? null,
134
+ landedAt: Number.isFinite(r.landedAt) ? r.landedAt : null,
135
+ });
136
+ });
137
+
138
+ const normReleases = reviewerReleases.map((r, i) => {
139
+ return Object.freeze({
140
+ model: r.model,
141
+ requestPrefixFingerprint: normFp(r.requestPrefixFingerprint),
142
+ releasedAt: Number.isFinite(r.releasedAt) ? r.releasedAt : null,
143
+ });
144
+ });
145
+
146
+ return Object.freeze({
147
+ schemaVersion: PRIMER_EVIDENCE_SCHEMA_VERSION,
148
+ gate: plan.gate,
149
+ headSha: plan.headSha,
150
+ planHash: plan.planHash,
151
+ sharedPrefixHash: plan.sharedPrefixHash ?? null,
152
+ primerRuns: Object.freeze(normRuns),
153
+ reviewerReleases: Object.freeze(normReleases),
154
+ });
155
+ }
156
+
157
+ /**
158
+ * Fail-closed validation of primer-evidence against the request plan.
159
+ *
160
+ * @param {object} input
161
+ * @param {object} input.plan - dispatch plan.
162
+ * @param {object} input.evidence - artifact from buildPrimerEvidence().
163
+ * @returns {{ ok: boolean, failures: Array<{check: string, reason: string}> }}
164
+ */
165
+ export function validatePrimerEvidence({ plan, evidence } = {}) {
166
+ const failures = [];
167
+
168
+ // shared-prefix hash binding. A plan that carries no shared-prefix hash HAS
169
+ // no cache-access binding to enforce (both null is a pass); when the plan
170
+ // carries one, the evidence must carry the SAME value. This is a real bug
171
+ // fix: the prior `evidence.sharedPrefixHash == null ||` clause failed even
172
+ // when plan and evidence were BOTH absent (a both-absent plan could never be
173
+ // validated).
174
+ if (evidence.sharedPrefixHash !== (plan.sharedPrefixHash ?? null)) {
175
+ failures.push({
176
+ check: "shared_prefix_hash",
177
+ reason: `evidence sharedPrefixHash ${JSON.stringify(evidence.sharedPrefixHash)} does not match the plan's ${JSON.stringify(plan.sharedPrefixHash ?? null)}`,
178
+ });
179
+ }
180
+
181
+ // plan hash binding: evidence must reference the same search. Fails closed
182
+ // on a MISSING evidence planHash too (the evidence was not derived from this
183
+ // search, or was tampered), not only on a mismatch — matching the contract
184
+ // text that "plan hash is missing or mismatched" refuses consolidation.
185
+ if (evidence.planHash == null || plan.planHash == null || evidence.planHash !== plan.planHash) {
186
+ failures.push({
187
+ check: "plan_hash",
188
+ reason: `evidence planHash ${JSON.stringify(evidence.planHash)} does not match the plan's ${JSON.stringify(plan.planHash)}`,
189
+ });
190
+ }
191
+
192
+ const groups = plan.requestGroups ?? [];
193
+ const idx = planGroupIndex(groups);
194
+
195
+ // group coverage: every request group must have a primer run bound to its
196
+ // model + request fingerprint.
197
+ const coveredKeys = new Set();
198
+ const coveredUnkeyedOrdinals = new Map();
199
+ for (const r of evidence.primerRuns) {
200
+ if (r.requestPrefixFingerprint) {
201
+ coveredKeys.add(`${r.model}::${r.requestPrefixFingerprint}`);
202
+ } else {
203
+ const n = coveredUnkeyedOrdinals.get(r.model) ?? 0;
204
+ coveredUnkeyedOrdinals.set(r.model, n + 1);
205
+ coveredKeys.add(`${r.model}::__unkeyed:${n}`);
206
+ }
207
+ }
208
+ const groupUnkeyedOrdinals = new Map();
209
+ for (let i = 0; i < groups.length; i++) {
210
+ const g = groups[i];
211
+ let key;
212
+ if (g.requestPrefixFingerprint) {
213
+ key = `${g.model}::${g.requestPrefixFingerprint}`;
214
+ } else {
215
+ const n = groupUnkeyedOrdinals.get(g.model) ?? 0;
216
+ groupUnkeyedOrdinals.set(g.model, n + 1);
217
+ key = `${g.model}::__unkeyed:${n}`;
218
+ }
219
+ if (!coveredKeys.has(key)) {
220
+ failures.push({
221
+ check: "group_coverage",
222
+ reason: `request group ${i} (model ${JSON.stringify(g.model)}) has no primer run in the evidence`,
223
+ });
224
+ }
225
+ }
226
+
227
+ // model-group / request-fingerprint binding per reviewer release.
228
+ for (let i = 0; i < evidence.reviewerReleases.length; i++) {
229
+ const rel = evidence.reviewerReleases[i];
230
+ const key = rel.requestPrefixFingerprint
231
+ ? `${rel.model}::${rel.requestPrefixFingerprint}`
232
+ : `${rel.model}::__unkeyed`;
233
+ if (rel.requestPrefixFingerprint && idx.has(key)) {
234
+ // bound to a known group -> ok
235
+ } else if (rel.requestPrefixFingerprint && !idx.has(key)) {
236
+ failures.push({
237
+ check: "model_group",
238
+ reason: `reviewer release ${i} bound to model ${JSON.stringify(rel.model)} + fingerprint that is not a request group (heterogeneous routing must not credit one model's primer to another)`,
239
+ });
240
+ }
241
+ // ordering: a released reviewer must have ITS group's primer landed before
242
+ // it. Candidate primers are scoped to the release's OWN primer group — a
243
+ // keyed release binds to a same-model + same-fingerprint run, a
244
+ // fingerprint-less release binds to a same-model fingerprint-less run —
245
+ // NEVER the first same-model primer (which could belong to a different
246
+ // group sharing the model, e.g. a keyed group). This closes the fail-open
247
+ // where an unkeyed release was credited to a keyed group's earlier primer
248
+ // even though its own group's primer landed after it.
249
+ const candidates = evidence.primerRuns.filter(
250
+ (r) =>
251
+ r.model === rel.model &&
252
+ (rel.requestPrefixFingerprint == null
253
+ ? r.requestPrefixFingerprint == null
254
+ : r.requestPrefixFingerprint === rel.requestPrefixFingerprint),
255
+ );
256
+ if (candidates.length === 0) {
257
+ failures.push({
258
+ check: "model_group",
259
+ reason: `reviewer release ${i} has no primer run for model ${JSON.stringify(rel.model)}`,
260
+ });
261
+ } else {
262
+ // Deterministic: the barrier must hold against the LAST-landed candidate
263
+ // (reduce/max is order-independent, unlike .find()'s first-match). With a
264
+ // single same-group primer this is exactly that group's primer; only an
265
+ // anomalous multiplicity of same-group primers here is stricter, and
266
+ // stricter is the fail-closed direction.
267
+ const primerForRel = candidates.reduce((a, b) => (b.landedAt > a.landedAt ? b : a));
268
+ if (!Number.isFinite(rel.releasedAt) || !Number.isFinite(primerForRel.landedAt) || rel.releasedAt < primerForRel.landedAt) {
269
+ // Fail CLOSED when the ordering barrier is unprovable (missing / non-finite
270
+ // timestamps), not only when it is provably reversed: a release without a
271
+ // landed primer timestamp cannot attest the primer ran before it, so the
272
+ // evidence must not proceed.
273
+ failures.push({
274
+ check: "primer_order",
275
+ reason: `reviewer release ${i} (${JSON.stringify(rel.model)}) cannot prove its primer landed before it: releasedAt=${String(rel.releasedAt)}, primer landedAt=${String(primerForRel.landedAt)} (ordering barrier missing or violated)`,
276
+ });
277
+ }
278
+ }
279
+ }
280
+
281
+ // request-fingerprint binding for primer runs that reference a real prefix.
282
+ // (planUnkeyedKeys + the per-model run ordinal counter support the inverse
283
+ // fingerprint-LESS binding below.)
284
+ const planUnkeyedKeys = new Set();
285
+ {
286
+ const planUnkeyedOrdinals = new Map();
287
+ for (const g of groups) {
288
+ if (g.requestPrefixFingerprint) continue;
289
+ const n = planUnkeyedOrdinals.get(g.model) ?? 0;
290
+ planUnkeyedOrdinals.set(g.model, n + 1);
291
+ planUnkeyedKeys.add(`${g.model}::__unkeyed:${n}`);
292
+ }
293
+ }
294
+ const unkeyedRunOrdinals = new Map();
295
+ for (let i = 0; i < evidence.primerRuns.length; i++) {
296
+ const r = evidence.primerRuns[i];
297
+ if (r.requestPrefixFingerprint) {
298
+ const key = `${r.model}::${r.requestPrefixFingerprint}`;
299
+ if (!idx.has(key)) {
300
+ failures.push({
301
+ check: "request_fingerprint",
302
+ reason: `primer run ${i} request-prefix fingerprint not present in the plan's request groups for model ${JSON.stringify(r.model)}`,
303
+ });
304
+ }
305
+ } else {
306
+ // Inverse group binding for fingerprint-LESS runs: mirror the keyed
307
+ // check above. group_coverage proves every plan group has a primer run;
308
+ // this proves the reverse — that every unkeyed primer run maps to a
309
+ // fingerprint-less plan group for its model. Without it, a hand-edited /
310
+ // legacy evidence file could carry extra fingerprint-less runs for models
311
+ // or groups the plan never requested and still validate, breaking the
312
+ // "derived from the plan" invariant for unkeyed groups.
313
+ const n = unkeyedRunOrdinals.get(r.model) ?? 0;
314
+ unkeyedRunOrdinals.set(r.model, n + 1);
315
+ if (!planUnkeyedKeys.has(`${r.model}::__unkeyed:${n}`)) {
316
+ failures.push({
317
+ check: "request_group_unkeyed",
318
+ reason: `primer run ${i} (${JSON.stringify(r.model)}) is fingerprint-less but no fingerprint-less request group exists in the plan for that ordinal`,
319
+ });
320
+ }
321
+ }
322
+ }
323
+
324
+ // Drop duplicate entries (same check on the same release).
325
+ const seen = new Set();
326
+ const unique = failures.filter((f) => {
327
+ const key = `${f.check}|${f.reason}`;
328
+ if (seen.has(key)) return false;
329
+ seen.add(key);
330
+ return true;
331
+ });
332
+
333
+ return { ok: unique.length === 0, failures: unique };
334
+ }
335
+
336
+ /**
337
+ * Strict fail-closed enforcement surface (GATE-EXEC-PRIMER-EVIDENCE): throws
338
+ * when fan-in evidence is missing or invalid, naming the failing check. This is
339
+ * the refusal path a gate conductor calls after validatePrimerEvidence returns
340
+ * ok:false — it turns a reported failure into a hard stop.
341
+ *
342
+ * @param {object} input
343
+ * @param {object} input.plan - dispatch plan.
344
+ * @param {object} input.evidence - artifact from buildPrimerEvidence().
345
+ * @returns {true}
346
+ * @throws {Error} when any primer-evidence check fails.
347
+ */
348
+ export function enforcePrimerEvidence({ plan, evidence } = {}) {
349
+ const r = validatePrimerEvidence({ plan, evidence });
350
+ if (!r.ok) {
351
+ throw new Error(
352
+ `GATE-EXEC-PRIMER-EVIDENCE: primer evidence failed validation; refusing to proceed (${r.failures.map((f) => `${f.check}: ${f.reason}`).join("; ")})`,
353
+ );
354
+ }
355
+ return true;
356
+ }
357
+
358
+ /**
359
+ * Persist the evidence artifact to its deterministic path.
360
+ *
361
+ * @param {object} input
362
+ * @param {string} input.dir
363
+ * @param {object} input.evidence
364
+ * @returns {Promise<{ path: string }>}
365
+ */
366
+ export async function writePrimerEvidence({ dir, evidence } = {}) {
367
+ const target = primerEvidencePath({
368
+ dir,
369
+ gate: evidence.gate,
370
+ headSha: evidence.headSha,
371
+ });
372
+ await mkdir(path.dirname(target), { recursive: true });
373
+ await writeFile(target, `${JSON.stringify(evidence, null, 2)}\n`, "utf8");
374
+ return { path: target };
375
+ }
@@ -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
  /**