@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.
- package/package.json +6 -1
- package/src/claude/hook-decisions.mjs +172 -5
- package/src/cli/primitives.mjs +21 -0
- package/src/config/config.mjs +83 -2
- package/src/config/extension-defaults.yaml +5 -0
- package/src/github/comment-id-guard.mjs +70 -0
- package/src/github/copilot-helpers.mjs +31 -0
- package/src/github/issue-ops.mjs +6 -0
- package/src/loop/agent-stall.mjs +194 -0
- package/src/loop/bash-command-classify.mjs +277 -0
- package/src/loop/cache-telemetry-evidence.mjs +437 -0
- package/src/loop/copilot-ci-status.mjs +18 -7
- package/src/loop/default-branch-guard.mjs +1 -1
- package/src/loop/gate-fanin.mjs +244 -0
- package/src/loop/handoff-envelope.mjs +28 -1
- package/src/loop/issue-refinement-artifact.mjs +154 -18
- package/src/loop/main-checkout-ff.mjs +39 -0
- package/src/loop/primer-evidence.mjs +375 -0
- package/src/loop/public-dev-loop-routing.mjs +12 -0
- package/src/loop/retrospective-checkpoint.mjs +77 -13
- package/src/loop/review-dispatch-plan.mjs +595 -0
- package/src/loop/review-lineage.mjs +588 -0
- package/src/loop/worktree-guard.mjs +80 -0
- package/src/projects/move-queue-item.mjs +37 -1
|
@@ -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
|
-
*
|
|
77
|
-
*
|
|
78
|
-
*
|
|
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
|
-
*
|
|
81
|
-
*
|
|
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
|
|
85
|
-
if (!
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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
|
-
|
|
91
|
-
return
|
|
154
|
+
// Malformed/unrecognized durable state — fail closed.
|
|
155
|
+
return RETROSPECTIVE_CHECKPOINT_STATE.MISSING;
|
|
92
156
|
}
|
|
93
157
|
|
|
94
158
|
/**
|