@dev-loops/core 1.0.1 → 1.0.2-slim.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,729 @@
1
+ /**
2
+ * spec-authority.mjs — canonical shared contract for IMMUTABLE SPEC AUTHORITY
3
+ * across the review / judge / fixer / gate / re-entry pipeline. Pure and
4
+ * side-effect free; persistence is the caller's job.
5
+ *
6
+ * Owns, in one place so no harness prompt re-states rules that could drift:
7
+ * 1. Two INDEPENDENT revision identities every decision must pin: `specDigest`
8
+ * (what the work is REQUIRED/FORBIDDEN to do — never derived from a head
9
+ * SHA) and the reviewed revision (`headSha` + `contentDigest`, what was
10
+ * actually evaluated — never masquerades as a spec change).
11
+ * 2. The four named judge disposition outcomes; the judge evaluates every
12
+ * finding AND its proposed remediation against the COMPLETE spec (not one
13
+ * supportive criterion) and selects exactly one.
14
+ * 3. Autonomous vs last-resort escalation: only `spec_cannot_decide` routes to
15
+ * a human-spec-decision state; a finding/remediation conflict resolves
16
+ * autonomously.
17
+ * 4. Human-only spec change: a material spec change produces a NEW
18
+ * `specDigest`, staling every prior-derived approval/disposition/gate
19
+ * result.
20
+ * 5. Criterion-scoped invalidation: a fixer push stales only the approvals for
21
+ * criteria whose covered content it changed; an unaffected criterion
22
+ * carries forward only with positive proof both its spec text and covered
23
+ * surface are unchanged — unknown impact fails closed.
24
+ */
25
+
26
+ import { sha256Hex } from "./review-dispatch-plan.mjs";
27
+ import { extractSection } from "./markdown-sections.mjs";
28
+ import { extractChecklistItems, detectAcDodMatrix } from "./issue-refinement-artifact.mjs";
29
+
30
+ /**
31
+ * Canonical spec category keys, in the fixed order the digest serializes them.
32
+ * A criterion's identity is (category, source-index) under one `specDigest`; the
33
+ * order here is therefore part of the contract, not incidental.
34
+ */
35
+ export const SPEC_CATEGORIES = Object.freeze([
36
+ "acceptanceCriteria",
37
+ "definitionOfDone",
38
+ "nonGoals",
39
+ ]);
40
+
41
+ /**
42
+ * Short stable id prefix per category. A criterion id is `<prefix>:<index>`
43
+ * (0-based within its category, source order), e.g. `ac:0`, `dod:2`, `ng:1`.
44
+ * Index-based ids are stable UNDER ONE `specDigest`: any insertion/removal that
45
+ * would shift an index also changes the normalized spec text and therefore the
46
+ * digest, which stales every derived approval anyway.
47
+ */
48
+ export const CATEGORY_ID_PREFIX = Object.freeze({
49
+ acceptanceCriteria: "ac",
50
+ definitionOfDone: "dod",
51
+ nonGoals: "ng",
52
+ });
53
+
54
+ /** Digest domain separators so the same bytes can never collide across the two
55
+ * identity namespaces (a spec that happens to equal reviewed content still
56
+ * produces two distinct digests). Carried as a field of the hashed object. */
57
+ const SPEC_DIGEST_DOMAIN = "spec-authority:spec:v1";
58
+ const CONTENT_DIGEST_DOMAIN = "spec-authority:content:v1";
59
+
60
+ /** Self-describing digest prefix. A `specDigest`/`contentDigest` is always
61
+ * `sha256:<64 hex>`; a `headSha` is bare hex. The prefix makes it structurally
62
+ * impossible to pass a head SHA where a spec digest is required (and vice
63
+ * versa), which is the mechanical half of "specDigest is never derived from
64
+ * headSha". */
65
+ export const DIGEST_RE = /^sha256:[0-9a-f]{64}$/;
66
+
67
+ /** A git head SHA: bare hex, 7-64 chars (matches judge-pass's own --head-sha
68
+ * validation), compared trim+lowercase everywhere. */
69
+ export const HEAD_SHA_RE = /^[0-9a-f]{7,64}$/;
70
+
71
+ /**
72
+ * The four named judge disposition outcomes. Exactly one is selected per
73
+ * finding after evaluating the finding AND each proposed remediation against
74
+ * the COMPLETE spec.
75
+ */
76
+ export const SPEC_AUTHORITY_OUTCOMES = Object.freeze({
77
+ /** Finding valid and its remediation compliant with the whole spec: authorize
78
+ * the compliant remedy (the fixer may choose among compliant alternatives). */
79
+ VALID_COMPLIANT: "valid_compliant",
80
+ /** The finding itself conflicts with an AC/DoD/non-goal: reject it
81
+ * autonomously. It cannot trigger a fix or block a clean verdict by existing. */
82
+ FINDING_CONFLICTS: "finding_conflicts",
83
+ /** Finding valid but its PROPOSED remediation conflicts with the spec: keep
84
+ * the finding, reject that remedy autonomously, route to a compliant
85
+ * alternative. */
86
+ REMEDIATION_CONFLICTS: "remediation_conflicts",
87
+ /** The spec cannot decide the work (materially ambiguous, internally
88
+ * contradictory, or progress requires changing/reinterpreting AC/DoD/non-goals):
89
+ * escalate to an explicit human-spec-decision state. LAST RESORT only. */
90
+ SPEC_CANNOT_DECIDE: "spec_cannot_decide",
91
+ });
92
+
93
+ export const SPEC_AUTHORITY_OUTCOME_VALUES = Object.freeze(
94
+ Object.values(SPEC_AUTHORITY_OUTCOMES),
95
+ );
96
+
97
+ /** The ONLY outcome that escalates to a human-spec-decision state. The other
98
+ * three resolve autonomously. Exported so no consumer re-hardcodes the set. */
99
+ export const HUMAN_SPEC_DECISION_OUTCOME = SPEC_AUTHORITY_OUTCOMES.SPEC_CANNOT_DECIDE;
100
+
101
+ /**
102
+ * Does an outcome require the loop to stop at the human-spec-decision state?
103
+ * Only `spec_cannot_decide` does — a finding/remediation conflict alone never
104
+ * justifies escalation (the judge rejects it or routes to a compliant remedy).
105
+ * @param {string} outcome
106
+ * @returns {boolean}
107
+ */
108
+ export function outcomeRequiresHumanDecision(outcome) {
109
+ return outcome === HUMAN_SPEC_DECISION_OUTCOME;
110
+ }
111
+
112
+ /**
113
+ * Normalize one criterion line into stable digest/compare text: strip a leading
114
+ * bullet/checkbox marker (defensive — extractChecklistItems already does for
115
+ * body-parsed specs, but a structured caller may pass raw lines), collapse
116
+ * internal whitespace, and trim. Case is PRESERVED (it is semantic).
117
+ * @param {unknown} text
118
+ * @returns {string}
119
+ */
120
+ export function normalizeCriterionText(text) {
121
+ return String(text ?? "")
122
+ .replace(/^\s*(?:>|\s)*(?:[-*+]|\d+[.)])\s+/u, "")
123
+ .replace(/^\[[ xX]\]\s+/u, "")
124
+ .replace(/\s+/gu, " ")
125
+ .trim();
126
+ }
127
+
128
+ /**
129
+ * Normalize a spec into the canonical shape the digest serializes. Accepts a
130
+ * `{ acceptanceCriteria, definitionOfDone, nonGoals }` object of string arrays.
131
+ * Each item is normalized via {@link normalizeCriterionText}; empties are
132
+ * dropped; SOURCE ORDER is preserved (order is criterion identity).
133
+ *
134
+ * Fail-closed: a spec with no acceptance criteria is not an authoritative spec
135
+ * (there is nothing the work is required to do), so it throws rather than
136
+ * digesting an empty authority.
137
+ *
138
+ * @param {{ acceptanceCriteria?: unknown, definitionOfDone?: unknown, nonGoals?: unknown }} spec
139
+ * @returns {{ acceptanceCriteria: string[], definitionOfDone: string[], nonGoals: string[] }}
140
+ */
141
+ export function normalizeSpec(spec) {
142
+ if (!spec || typeof spec !== "object" || Array.isArray(spec)) {
143
+ throw new Error("spec must be an object with acceptanceCriteria/definitionOfDone/nonGoals arrays");
144
+ }
145
+ const normalizeList = (value, label) => {
146
+ if (value === undefined || value === null) return [];
147
+ if (!Array.isArray(value)) {
148
+ throw new Error(`spec.${label} must be an array of strings`);
149
+ }
150
+ return value
151
+ .map((item) => normalizeCriterionText(item))
152
+ .filter((item) => item.length > 0);
153
+ };
154
+ const normalized = {
155
+ acceptanceCriteria: normalizeList(spec.acceptanceCriteria, "acceptanceCriteria"),
156
+ definitionOfDone: normalizeList(spec.definitionOfDone, "definitionOfDone"),
157
+ nonGoals: normalizeList(spec.nonGoals, "nonGoals"),
158
+ };
159
+ if (normalized.acceptanceCriteria.length === 0) {
160
+ throw new Error("spec has no acceptance criteria — not an authoritative spec (fail closed)");
161
+ }
162
+ return normalized;
163
+ }
164
+
165
+ /**
166
+ * The ordered criterion id list for a spec — the COMPLETE authoritative set the
167
+ * judge must evaluate every finding against. `<prefix>:<index>` per item, in
168
+ * SPEC_CATEGORIES order. Accepts either a raw or already-normalized spec.
169
+ * @param {object} spec
170
+ * @returns {string[]}
171
+ */
172
+ export function specCriterionIds(spec) {
173
+ // ALWAYS normalize — never trust the input to be pre-normalized. normalizeSpec
174
+ // is idempotent on already-normalized text, and normalizing here is what keeps
175
+ // the criterion id set byte-aligned with computeSpecDigest's own normalized
176
+ // view: a raw spec carrying an empty/whitespace-only criterion would otherwise
177
+ // yield a phantom id (and shift every later index) that the digested set does
178
+ // not, so the two revision-identity views would disagree on the same input.
179
+ const normalized = normalizeSpec(spec);
180
+ const ids = [];
181
+ for (const category of SPEC_CATEGORIES) {
182
+ normalized[category].forEach((_item, index) => {
183
+ ids.push(`${CATEGORY_ID_PREFIX[category]}:${index}`);
184
+ });
185
+ }
186
+ return ids;
187
+ }
188
+
189
+ /**
190
+ * Deterministic digest of the normalized AC/DoD/Non-goals. Identifies the spec
191
+ * authority. Never takes or derives from a head SHA. Same normalized spec always
192
+ * yields the same digest; any AC/DoD/non-goal text change yields a new one.
193
+ * @param {object} spec
194
+ * @returns {string} `sha256:<hex>`
195
+ */
196
+ export function computeSpecDigest(spec) {
197
+ const normalized = normalizeSpec(spec);
198
+ return sha256Hex({
199
+ domain: SPEC_DIGEST_DOMAIN,
200
+ v: 1,
201
+ acceptanceCriteria: normalized.acceptanceCriteria,
202
+ definitionOfDone: normalized.definitionOfDone,
203
+ nonGoals: normalized.nonGoals,
204
+ });
205
+ }
206
+
207
+ /**
208
+ * Deterministic digest of the reviewed implementation/prose content. A DISTINCT
209
+ * identity from `specDigest` (separate domain tag), so identical bytes reviewed
210
+ * as content never collide with the same bytes read as spec.
211
+ * @param {unknown} content
212
+ * @returns {string} `sha256:<hex>`
213
+ */
214
+ export function computeContentDigest(content) {
215
+ return sha256Hex({ domain: CONTENT_DIGEST_DOMAIN, v: 1, content: String(content ?? "") });
216
+ }
217
+
218
+ function assertDigestShape(value, label) {
219
+ if (typeof value !== "string" || !DIGEST_RE.test(value)) {
220
+ throw new Error(`${label} must be a "sha256:<64 hex>" digest`);
221
+ }
222
+ return value;
223
+ }
224
+
225
+ function normalizeHeadSha(value, label = "headSha") {
226
+ const sha = String(value ?? "").trim().toLowerCase();
227
+ if (!HEAD_SHA_RE.test(sha)) {
228
+ throw new Error(`${label} must be a 7-64 char hex git SHA`);
229
+ }
230
+ return sha;
231
+ }
232
+
233
+ /**
234
+ * Build (or validate) the two independent revision identities for a decision.
235
+ * Enforces the core invariant fail-closed: `specDigest` and `contentDigest` are
236
+ * both `sha256:`-prefixed and MUST differ from each other and from the bare
237
+ * `headSha` — a head SHA can never stand in for a spec digest.
238
+ *
239
+ * @param {object} input
240
+ * @param {object} [input.spec] — used to compute specDigest when not supplied
241
+ * @param {string} [input.specDigest]
242
+ * @param {string} input.headSha
243
+ * @param {unknown} [input.content] — used to compute contentDigest when not supplied
244
+ * @param {string} [input.contentDigest]
245
+ * @returns {{ specDigest: string, headSha: string, contentDigest: string }}
246
+ */
247
+ export function buildRevisionIdentity({ spec, specDigest, headSha, content, contentDigest } = {}) {
248
+ const resolvedSpecDigest = assertDigestShape(
249
+ specDigest ?? computeSpecDigest(spec),
250
+ "specDigest",
251
+ );
252
+ const sha = normalizeHeadSha(headSha);
253
+ const resolvedContentDigest = assertDigestShape(
254
+ contentDigest ?? computeContentDigest(content),
255
+ "contentDigest",
256
+ );
257
+ if (resolvedSpecDigest === resolvedContentDigest) {
258
+ throw new Error("SPEC-AUTHORITY-REVISION-IDENTITIES: specDigest and contentDigest must be distinct identities (fail closed)");
259
+ }
260
+ // Defensive tripwire for an explicitly-SUPPLIED digest: the domain separator
261
+ // in computeSpecDigest already guarantees specDigest is never DERIVED from a
262
+ // headSha, but reject here too when the digest's hex body equals or embeds
263
+ // the head SHA (any length) — fail-closed, since a real spec digest
264
+ // incidentally embedding a head SHA is negligible.
265
+ if (resolvedSpecDigest.slice("sha256:".length).includes(sha)) {
266
+ throw new Error("SPEC-AUTHORITY-REVISION-IDENTITIES: specDigest must not be derived from or embed headSha (fail closed)");
267
+ }
268
+ return { specDigest: resolvedSpecDigest, headSha: sha, contentDigest: resolvedContentDigest };
269
+ }
270
+
271
+ function normalizeIdSet(value, label) {
272
+ if (!Array.isArray(value)) {
273
+ throw new Error(`${label} must be an array of criterion ids`);
274
+ }
275
+ const set = new Set();
276
+ for (const raw of value) {
277
+ if (typeof raw !== "string" || raw.trim().length === 0) {
278
+ throw new Error(`${label} must contain only non-empty criterion id strings`);
279
+ }
280
+ set.add(raw.trim());
281
+ }
282
+ return set;
283
+ }
284
+
285
+ /**
286
+ * Validate ONE judge decision for a single finding against the complete spec.
287
+ * Pure and fail-closed: throws (never returns a partial decision) unless every
288
+ * authority invariant holds. Returns the normalized decision on success.
289
+ *
290
+ * Decision shape:
291
+ * ```
292
+ * {
293
+ * index: <0-based finding index>,
294
+ * outcome: <one of SPEC_AUTHORITY_OUTCOME_VALUES>,
295
+ * specDigest, headSha, contentDigest, // must match the run's current identities
296
+ * checkedCriteria: [<every criterion id>], // WHOLE-spec coverage; a partial /
297
+ * // supportive-only subset fails closed
298
+ * conflictingCriteria: [<ids>], // required + non-empty for the two conflict outcomes
299
+ * rationale: "<non-empty>",
300
+ * authorizedRemediation: "<...>", // required for valid_compliant
301
+ * rejectedRemediations: ["<...>"] // optional audit of rejected options
302
+ * }
303
+ * ```
304
+ *
305
+ * @param {unknown} decision
306
+ * @param {object} context
307
+ * @param {string} context.specDigest — the run's current spec digest
308
+ * @param {string} context.headSha — the run's current reviewed head
309
+ * @param {string} context.contentDigest — the run's current reviewed content digest
310
+ * @param {string[]} context.criterionIds — the COMPLETE ordered criterion id set
311
+ * @returns {object} the normalized decision
312
+ */
313
+ export function validateSpecAuthorityDecision(decision, { specDigest, headSha, contentDigest, criterionIds } = {}) {
314
+ const currentSpecDigest = assertDigestShape(specDigest, "context.specDigest");
315
+ const currentHead = normalizeHeadSha(headSha, "context.headSha");
316
+ const currentContentDigest = assertDigestShape(contentDigest, "context.contentDigest");
317
+ const fullCriteria = normalizeIdSet(criterionIds, "context.criterionIds");
318
+ if (fullCriteria.size === 0) {
319
+ throw new Error("context.criterionIds must be the non-empty complete criterion set (fail closed)");
320
+ }
321
+
322
+ if (!decision || typeof decision !== "object" || Array.isArray(decision)) {
323
+ throw new Error("spec-authority decision must be an object");
324
+ }
325
+ const d = /** @type {Record<string, unknown>} */ (decision);
326
+ if (!Number.isInteger(d.index) || /** @type {number} */ (d.index) < 0) {
327
+ throw new Error("spec-authority decision.index must be a non-negative integer");
328
+ }
329
+ if (!SPEC_AUTHORITY_OUTCOME_VALUES.includes(d.outcome)) {
330
+ throw new Error(
331
+ `spec-authority decision.outcome must be one of: ${SPEC_AUTHORITY_OUTCOME_VALUES.join(", ")}`,
332
+ );
333
+ }
334
+ // Revision identities must be pinned AND current. A stale specDigest/headSha/
335
+ // contentDigest fails closed — a decision made against an old revision must
336
+ // never authorize a fix or a gate transition at the current one.
337
+ if (assertDigestShape(d.specDigest, "decision.specDigest") !== currentSpecDigest) {
338
+ throw new Error("SPEC-AUTHORITY-STALE-REVISION-FAIL-CLOSED: decision.specDigest is stale/mismatched — re-decide against the current spec (fail closed)");
339
+ }
340
+ if (normalizeHeadSha(d.headSha, "decision.headSha") !== currentHead) {
341
+ throw new Error("SPEC-AUTHORITY-STALE-REVISION-FAIL-CLOSED: decision.headSha is stale/mismatched — re-decide against the current head (fail closed)");
342
+ }
343
+ if (assertDigestShape(d.contentDigest, "decision.contentDigest") !== currentContentDigest) {
344
+ throw new Error("SPEC-AUTHORITY-STALE-REVISION-FAIL-CLOSED: decision.contentDigest is stale/mismatched — re-decide against the current content (fail closed)");
345
+ }
346
+ if (typeof d.rationale !== "string" || d.rationale.trim().length === 0) {
347
+ throw new Error("spec-authority decision.rationale must be a non-empty string");
348
+ }
349
+
350
+ // WHOLE-SPEC evaluation: checkedCriteria must cover EVERY criterion id. A
351
+ // partial / supportive-only subset cannot produce a valid disposition — this
352
+ // is the deterministic enforcement of "a citation to one supportive criterion
353
+ // is insufficient".
354
+ const checked = normalizeIdSet(d.checkedCriteria, "decision.checkedCriteria");
355
+ const foreign = [...checked].filter((id) => !fullCriteria.has(id));
356
+ if (foreign.length > 0) {
357
+ throw new Error(`spec-authority decision.checkedCriteria names unknown criterion id(s): ${foreign.join(", ")}`);
358
+ }
359
+ const uncovered = [...fullCriteria].filter((id) => !checked.has(id));
360
+ if (uncovered.length > 0) {
361
+ throw new Error(`SPEC-AUTHORITY-WHOLE-SPEC-EVAL: decision did not evaluate the whole spec, so it cannot produce a valid disposition — uncovered criteria: ${uncovered.join(", ")} (supportive-only/partial citation is insufficient; fail closed)`);
362
+ }
363
+
364
+ // The two conflict outcomes require explicit conflict evidence: a non-empty
365
+ // conflictingCriteria drawn from the spec. Autonomous rejection is only
366
+ // legitimate when it names what the finding/remedy conflicts with.
367
+ const isConflict =
368
+ d.outcome === SPEC_AUTHORITY_OUTCOMES.FINDING_CONFLICTS ||
369
+ d.outcome === SPEC_AUTHORITY_OUTCOMES.REMEDIATION_CONFLICTS;
370
+ let conflictingCriteria = [];
371
+ if (isConflict) {
372
+ const conflicts = normalizeIdSet(d.conflictingCriteria, "decision.conflictingCriteria");
373
+ if (conflicts.size === 0) {
374
+ throw new Error(`SPEC-AUTHORITY-CONFLICT-EVIDENCE: ${d.outcome} decision requires non-empty conflictingCriteria (explicit conflict evidence; fail closed)`);
375
+ }
376
+ const unknownConflicts = [...conflicts].filter((id) => !fullCriteria.has(id));
377
+ if (unknownConflicts.length > 0) {
378
+ throw new Error(`spec-authority decision.conflictingCriteria names unknown criterion id(s): ${unknownConflicts.join(", ")}`);
379
+ }
380
+ conflictingCriteria = [...conflicts];
381
+ } else if (d.conflictingCriteria !== undefined && d.conflictingCriteria !== null) {
382
+ // A non-conflict outcome must not smuggle a conflict list.
383
+ throw new Error(`spec-authority ${d.outcome} decision must not carry conflictingCriteria`);
384
+ }
385
+
386
+ // valid_compliant must name the authorized (compliant) remediation. The fixer
387
+ // may choose among compliant alternatives, but the judge authorizes a concrete
388
+ // compliant direction, not a blank check.
389
+ let authorizedRemediation;
390
+ if (d.outcome === SPEC_AUTHORITY_OUTCOMES.VALID_COMPLIANT) {
391
+ if (typeof d.authorizedRemediation !== "string" || d.authorizedRemediation.trim().length === 0) {
392
+ throw new Error("spec-authority valid_compliant decision requires a non-empty authorizedRemediation");
393
+ }
394
+ authorizedRemediation = d.authorizedRemediation.trim();
395
+ }
396
+
397
+ const rejectedRemediations = Array.isArray(d.rejectedRemediations)
398
+ ? d.rejectedRemediations.filter((r) => typeof r === "string" && r.trim().length > 0).map((r) => r.trim())
399
+ : [];
400
+
401
+ return {
402
+ index: d.index,
403
+ outcome: d.outcome,
404
+ specDigest: currentSpecDigest,
405
+ headSha: currentHead,
406
+ contentDigest: currentContentDigest,
407
+ checkedCriteria: [...checked],
408
+ conflictingCriteria,
409
+ rationale: d.rationale.trim(),
410
+ ...(authorizedRemediation ? { authorizedRemediation } : {}),
411
+ ...(rejectedRemediations.length > 0 ? { rejectedRemediations } : {}),
412
+ requiresHumanDecision: outcomeRequiresHumanDecision(d.outcome),
413
+ };
414
+ }
415
+
416
+ /**
417
+ * Validate a full spec-authority verdict block (the per-run judge record that
418
+ * accompanies the relevance verdict): the pinned run identities plus one
419
+ * validated decision per finding. Fail-closed: every finding index must be
420
+ * disposed exactly once, and any `spec_cannot_decide` decision surfaces
421
+ * `humanDecisionRequired` so the caller stops at the human-spec-decision state.
422
+ *
423
+ * @param {unknown} verdict — { specDigest, headSha, contentDigest, decisions: [...] }
424
+ * @param {object} context
425
+ * @param {number} context.findingsCount — number of findings the verdict must cover
426
+ * @param {string[]} context.criterionIds — the complete criterion id set
427
+ * @returns {{ specDigest: string, headSha: string, contentDigest: string,
428
+ * decisions: object[], humanDecisionRequired: boolean,
429
+ * humanDecisionIndices: number[], outcomeCounts: Record<string, number> }}
430
+ */
431
+ export function validateSpecAuthorityVerdict(verdict, { findingsCount, criterionIds } = {}) {
432
+ if (!verdict || typeof verdict !== "object" || Array.isArray(verdict)) {
433
+ throw new Error("spec-authority verdict must be an object");
434
+ }
435
+ const v = /** @type {Record<string, unknown>} */ (verdict);
436
+ const specDigest = assertDigestShape(v.specDigest, "verdict.specDigest");
437
+ const headSha = normalizeHeadSha(v.headSha, "verdict.headSha");
438
+ const contentDigest = assertDigestShape(v.contentDigest, "verdict.contentDigest");
439
+ if (!Number.isInteger(findingsCount) || findingsCount < 0) {
440
+ throw new Error("context.findingsCount must be a non-negative integer");
441
+ }
442
+ if (!Array.isArray(v.decisions)) {
443
+ throw new Error("spec-authority verdict.decisions must be an array");
444
+ }
445
+ const decisions = [];
446
+ const seen = new Set();
447
+ const outcomeCounts = Object.fromEntries(SPEC_AUTHORITY_OUTCOME_VALUES.map((o) => [o, 0]));
448
+ const humanDecisionIndices = [];
449
+ for (const raw of v.decisions) {
450
+ const decision = validateSpecAuthorityDecision(raw, { specDigest, headSha, contentDigest, criterionIds });
451
+ if (decision.index >= findingsCount) {
452
+ throw new Error(`spec-authority decision index ${decision.index} is out of range (findings has ${findingsCount} entries)`);
453
+ }
454
+ if (seen.has(decision.index)) {
455
+ throw new Error(`spec-authority verdict has a duplicate decision for finding index ${decision.index}`);
456
+ }
457
+ seen.add(decision.index);
458
+ outcomeCounts[decision.outcome] += 1;
459
+ if (decision.requiresHumanDecision) humanDecisionIndices.push(decision.index);
460
+ decisions.push(decision);
461
+ }
462
+ const uncovered = [];
463
+ for (let i = 0; i < findingsCount; i += 1) {
464
+ if (!seen.has(i)) uncovered.push(i);
465
+ }
466
+ if (uncovered.length > 0) {
467
+ throw new Error(
468
+ `spec-authority verdict does not dispose ${uncovered.length} finding(s) (indexes: ${uncovered.join(", ")}) — every finding needs a whole-spec outcome (fail closed)`,
469
+ );
470
+ }
471
+ // Canonical ordering: sort by finding index so a verdict's decisions,
472
+ // humanDecisionIndices, and any downstream join (e.g. the human-decision
473
+ // reason string) are byte-stable regardless of the input's submission order —
474
+ // required for cross-harness determinism.
475
+ decisions.sort((a, b) => a.index - b.index);
476
+ humanDecisionIndices.sort((a, b) => a - b);
477
+ return {
478
+ specDigest,
479
+ headSha,
480
+ contentDigest,
481
+ decisions,
482
+ humanDecisionRequired: humanDecisionIndices.length > 0,
483
+ humanDecisionIndices,
484
+ outcomeCounts,
485
+ };
486
+ }
487
+
488
+ /**
489
+ * Resolve which prior criterion approvals survive a revision change. This is the
490
+ * one authority for both invalidation rules:
491
+ *
492
+ * - SPEC CHANGE (currentSpecDigest !== priorSpecDigest): a human-approved spec
493
+ * change stales EVERY prior-derived approval. Nothing carries; all must be
494
+ * re-established against the new spec.
495
+ * - CONTENT CHANGE (same specDigest, a fixer push): only the approvals for the
496
+ * criteria whose covered content the push changed (`affectedCriteria`) are
497
+ * stale. An unaffected criterion carries forward ONLY when `carryForwardProof`
498
+ * positively proves BOTH its governing spec text is unchanged AND its covered
499
+ * surface is unchanged. Missing/incomplete/false proof for an unaffected
500
+ * criterion fails closed to fresh review (stale).
501
+ *
502
+ * Fully deterministic and fail-closed. Unknown impact always stales.
503
+ *
504
+ * @param {object} input
505
+ * @param {string} input.priorSpecDigest
506
+ * @param {string} input.currentSpecDigest
507
+ * @param {string[]} input.priorApprovedCriteria — criterion ids approved under the prior revision
508
+ * @param {string[]} [input.affectedCriteria] — ids whose covered content the push changed
509
+ * @param {Record<string, { specTextUnchanged?: boolean, coveredSurfaceUnchanged?: boolean }>} [input.carryForwardProof]
510
+ * @returns {{ specChanged: boolean, stale: string[], carried: string[], reasons: Record<string, string> }}
511
+ */
512
+ export function resolveCriterionInvalidation({
513
+ priorSpecDigest,
514
+ currentSpecDigest,
515
+ priorApprovedCriteria,
516
+ affectedCriteria = [],
517
+ carryForwardProof = {},
518
+ } = {}) {
519
+ const prior = assertDigestShape(priorSpecDigest, "priorSpecDigest");
520
+ const current = assertDigestShape(currentSpecDigest, "currentSpecDigest");
521
+ const approved = [...normalizeIdSet(priorApprovedCriteria, "priorApprovedCriteria")];
522
+ const reasons = {};
523
+
524
+ if (prior !== current) {
525
+ // Human-approved spec change: a new specDigest invalidates every derived
526
+ // approval. Nothing carries.
527
+ for (const id of approved) reasons[id] = "spec_digest_changed — prior approval derived from a superseded spec revision";
528
+ return { specChanged: true, stale: approved, carried: [], reasons };
529
+ }
530
+
531
+ const affected = normalizeIdSet(affectedCriteria, "affectedCriteria");
532
+ const stale = [];
533
+ const carried = [];
534
+ for (const id of approved) {
535
+ if (affected.has(id)) {
536
+ stale.push(id);
537
+ reasons[id] = "fixer push changed content covered by this criterion — approval stale, fresh review required";
538
+ continue;
539
+ }
540
+ const proof = carryForwardProof[id];
541
+ const proven =
542
+ proof && typeof proof === "object" &&
543
+ proof.specTextUnchanged === true &&
544
+ proof.coveredSurfaceUnchanged === true;
545
+ if (proven) {
546
+ carried.push(id);
547
+ reasons[id] = "carried forward — proven spec text unchanged and covered surface unchanged";
548
+ } else {
549
+ stale.push(id);
550
+ reasons[id] = "unaffected but carry-forward not positively proven (spec text and/or covered surface unproven) — fail closed to fresh review";
551
+ }
552
+ }
553
+ return { specChanged: false, stale, carried, reasons };
554
+ }
555
+
556
+ /**
557
+ * AC1 (ADR 0061): the ONE shared identity-stamp helper every
558
+ * gate/fixer record writer threads its revision identity + checked criteria
559
+ * through, so the writers cannot drift from independently-recomputed fields.
560
+ * Validates the pinned trio (reusing {@link assertDigestShape} /
561
+ * {@link normalizeHeadSha}) plus the checked criteria (reusing
562
+ * {@link normalizeIdSet}, sorted for byte-stable determinism), and returns a
563
+ * NEW object — never mutates `record` — with the stamp nested under a
564
+ * `specAuthority` key so it can never collide with a writer's own fields.
565
+ * Fail-closed: throws on a missing/invalid identity rather than silently
566
+ * stamping a partial/malformed trio.
567
+ *
568
+ * @param {object} record — the record to stamp (spread into the returned copy)
569
+ * @param {object} identity
570
+ * @param {string} identity.specDigest
571
+ * @param {string} identity.headSha
572
+ * @param {string} identity.contentDigest
573
+ * @param {string[]} identity.checkedCriteria
574
+ * @returns {object} `{ ...record, specAuthority: { specDigest, headSha, contentDigest, checkedCriteria } }`
575
+ */
576
+ export function stampSpecAuthorityIdentity(record, { specDigest, headSha, contentDigest, checkedCriteria } = {}) {
577
+ if (!record || typeof record !== "object" || Array.isArray(record)) {
578
+ throw new Error("stampSpecAuthorityIdentity: record must be an object");
579
+ }
580
+ const stampedSpecDigest = assertDigestShape(specDigest, "specDigest");
581
+ const stampedHeadSha = normalizeHeadSha(headSha);
582
+ const stampedContentDigest = assertDigestShape(contentDigest, "contentDigest");
583
+ const checked = [...normalizeIdSet(checkedCriteria, "checkedCriteria")].sort();
584
+ return {
585
+ ...record,
586
+ specAuthority: {
587
+ specDigest: stampedSpecDigest,
588
+ headSha: stampedHeadSha,
589
+ contentDigest: stampedContentDigest,
590
+ checkedCriteria: checked,
591
+ },
592
+ };
593
+ }
594
+
595
+ // ---------------------------------------------------------------------------
596
+ // AC7 (ADR 0061): pure affected-criteria producer
597
+ // ---------------------------------------------------------------------------
598
+
599
+ // ponytail: minimal glob subset — exact path, a `dir/**` prefix, or a single
600
+ // `*` (matches any run of non-`/` characters) within one path segment. Every
601
+ // OTHER character, including every other regex metacharacter (`?`, `.`, `+`,
602
+ // `(`, `)`, `[`, `]`, `{`, `}`, `^`, `$`, `|`), is matched LITERALLY — a glob
603
+ // is never handed to RegExp unescaped. No glob/minimatch/picomatch util
604
+ // exists in this repo (checked packages/core/src and
605
+ // analysis/change-classifier.mjs); upgrade to picomatch/minimatch if a
606
+ // coverage map ever needs a richer subset (brace expansion, mid-pattern `**`).
607
+ function matchesCoverageGlob(pattern, filePath) {
608
+ if (pattern === filePath) return true;
609
+ if (pattern.endsWith("/**")) {
610
+ const prefix = pattern.slice(0, -3);
611
+ return filePath === prefix || filePath.startsWith(`${prefix}/`);
612
+ }
613
+ if (pattern.includes("*")) {
614
+ const escaped = pattern.replace(/[.+^${}()|[\]\\?]/gu, "\\$&").replace(/\*/gu, "[^/]*");
615
+ return new RegExp(`^${escaped}$`, "u").test(filePath);
616
+ }
617
+ return false;
618
+ }
619
+
620
+ /**
621
+ * Pure, fail-closed producer: map a fixer push's changed paths to the
622
+ * criteria whose declared coverage they touch. A changed path matching >=1
623
+ * criterion's glob(s) marks those criteria affected. A changed path matching
624
+ * ZERO criteria fails closed: it is recorded in `unmatchedPaths` and sets
625
+ * `uncertain: true` so the caller (the judge-pass bridge) treats every
626
+ * prior-approved criterion as affected (the pre-existing all-stale fallback)
627
+ * instead of silently under-staling an unmapped change.
628
+ *
629
+ * @param {object} input
630
+ * @param {string[]} input.changedPaths — repo-relative paths a fixer push changed
631
+ * @param {Record<string, string[]>} input.criterionCoverage — criterionId -> glob pattern array
632
+ * @returns {{ affectedCriteria: string[], uncertain: boolean, unmatchedPaths: string[] }}
633
+ */
634
+ export function resolveAffectedCriteria({ changedPaths, criterionCoverage } = {}) {
635
+ if (!Array.isArray(changedPaths)) {
636
+ throw new Error("changedPaths must be an array of repo-relative path strings");
637
+ }
638
+ const paths = changedPaths.map((p, i) => {
639
+ if (typeof p !== "string" || p.trim().length === 0) {
640
+ throw new Error(`changedPaths[${i}] must be a non-empty string`);
641
+ }
642
+ return p.trim();
643
+ });
644
+ if (!criterionCoverage || typeof criterionCoverage !== "object" || Array.isArray(criterionCoverage)) {
645
+ throw new Error("criterionCoverage must be an object mapping criterionId -> glob pattern array");
646
+ }
647
+ const coverageEntries = Object.entries(criterionCoverage).map(([rawCriterionId, globs]) => {
648
+ const criterionId = rawCriterionId.trim();
649
+ if (criterionId.length === 0) {
650
+ throw new Error("criterionCoverage keys must be non-empty criterion id strings");
651
+ }
652
+ if (!Array.isArray(globs)) {
653
+ throw new Error(`criterionCoverage[${JSON.stringify(rawCriterionId)}] must be an array of glob strings`);
654
+ }
655
+ const patterns = globs.map((g, i) => {
656
+ if (typeof g !== "string" || g.trim().length === 0) {
657
+ throw new Error(`criterionCoverage[${JSON.stringify(rawCriterionId)}][${i}] must be a non-empty glob string`);
658
+ }
659
+ return g.trim();
660
+ });
661
+ return [criterionId, patterns];
662
+ });
663
+
664
+ const affected = new Set();
665
+ const unmatchedPaths = [];
666
+ for (const filePath of paths) {
667
+ let matchedAny = false;
668
+ for (const [criterionId, patterns] of coverageEntries) {
669
+ if (patterns.some((pattern) => matchesCoverageGlob(pattern, filePath))) {
670
+ affected.add(criterionId);
671
+ matchedAny = true;
672
+ }
673
+ }
674
+ if (!matchedAny) unmatchedPaths.push(filePath);
675
+ }
676
+ return {
677
+ affectedCriteria: [...affected].sort(),
678
+ uncertain: unmatchedPaths.length > 0,
679
+ unmatchedPaths,
680
+ };
681
+ }
682
+
683
+ /**
684
+ * Convenience: extract a `{ acceptanceCriteria, definitionOfDone, nonGoals }`
685
+ * spec from a canonical tracker issue body, reusing the shared section +
686
+ * checklist/matrix parsers so the spec digest is computed from exactly the
687
+ * content the refinement gate already recognizes as AUTHORITATIVE. Returns the
688
+ * raw (un-normalized) lists; pass the result to {@link computeSpecDigest} /
689
+ * {@link normalizeSpec}.
690
+ *
691
+ * The AC/DoD source is the authoritative AC→DoD mapping MATRIX
692
+ * (`detectAcDodMatrix`, reused byte-identical from
693
+ * `issue-refinement-artifact.mjs`) when the body carries one that parses as
694
+ * valid; the list-form checklists (`extractChecklistItems`) are a redundant
695
+ * presentation projection of that same matrix and are read only as the
696
+ * fail-closed fallback for older issue bodies with no matrix at all.
697
+ * A checklist-only edit that projects an unchanged matrix therefore never
698
+ * touches `specDigest`; any edit that changes the matrix itself still does.
699
+ * Non-goals always come from the `## Non-goals` section — the matrix does not
700
+ * carry them.
701
+ *
702
+ * @param {string} body — the tracker issue markdown body
703
+ * @returns {{ acceptanceCriteria: string[], definitionOfDone: string[], nonGoals: string[] }}
704
+ */
705
+ export function extractSpecFromBody(body) {
706
+ const nonGoalsSection =
707
+ extractSection(body, "Non-goals") ?? extractSection(body, "Non goals");
708
+ const nonGoals = nonGoalsSection ? extractChecklistItems(nonGoalsSection) : [];
709
+
710
+ const matrix = detectAcDodMatrix(body);
711
+ if (matrix.found && matrix.valid && matrix.rows.length > 0) {
712
+ return {
713
+ acceptanceCriteria: matrix.rows.map((row) => row.criterion),
714
+ definitionOfDone: matrix.rows.map((row) => row.evidence),
715
+ nonGoals,
716
+ };
717
+ }
718
+
719
+ // Fallback: no positively-parseable matrix — hash the checklist projection
720
+ // (prior behavior) rather than an empty/weaker AC/DoD surface.
721
+ const acSection = extractSection(body, "Acceptance criteria");
722
+ const dodSection =
723
+ extractSection(body, "Definition of done") ?? extractSection(body, "DoD");
724
+ return {
725
+ acceptanceCriteria: acSection ? extractChecklistItems(acSection) : [],
726
+ definitionOfDone: dodSection ? extractChecklistItems(dodSection) : [],
727
+ nonGoals,
728
+ };
729
+ }