@sabaiway/agent-workflow-kit 1.49.0 → 2.0.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.
@@ -60,6 +60,31 @@ export const settingValueValid = (entry, value) => {
60
60
  }
61
61
  };
62
62
 
63
+ // ── `modeCatalog` vocabulary (BRIDGE-MODES-CATALOG, D2/D4/D6) ────────────────────────
64
+ // Every `modeCatalog` string the mode renderer prints is ONE capped line: the surface is a
65
+ // terminal-width discovery list, and a control character would break the line (or a pasted form).
66
+ export const CATALOG_LINE_MAX = 200;
67
+ // The closed entry taxonomy (D4): a `primary` is a mode you drive; a `continuation` resumes one; an
68
+ // `env-hook` MODIFIES named parents (an env var is never a fake role).
69
+ export const CATALOG_KINDS = new Set(['primary', 'continuation', 'env-hook']);
70
+ // `enforced` is claimable only for an OS-/code-enforced fact (a runtime bound rides in `condition`);
71
+ // everything a prompt merely asks for is `advisory` (D6).
72
+ export const CATALOG_ENFORCEMENT = new Set(['enforced', 'advisory']);
73
+ // The AD-033 `contract` fields that carry invocation descriptors — the only referenceable ones.
74
+ export const CATALOG_CONTRACT_FIELDS = new Set(['invocations', 'continue']);
75
+ // The operand-slot grammar the RENDERER speaks: `<angle>` and `[bracket]` placeholders, with an
76
+ // optional `@` riding WITH the slot (`@<facts-file>` is one operand a user types, not a bare
77
+ // `<facts-file>` behind a stray character). Production owns it here; the bridge test suites keep
78
+ // their own local copy ON PURPOSE — they ship as standalone bridge payload and must not import the
79
+ // kit, so their regex is an INDEPENDENT drift oracle across the package boundary, not a duplicate.
80
+ export const CATALOG_SLOT_RE = /@?<[^<>]+>|\[[^[\]]*\]/g;
81
+ export const extractCatalogOperandSlots = (form) => (typeof form === 'string' ? form.match(CATALOG_SLOT_RE) ?? [] : []);
82
+
83
+ const CATALOG_KEY_RE = /^[A-Za-z][A-Za-z0-9._-]*$/;
84
+ const ENV_HOOK_KEY_RE = /^[A-Z][A-Z0-9_]*$/;
85
+ // eslint-disable-next-line no-control-regex
86
+ const CONTROL_CHAR_RE = /[\x00-\x1f\x7f]/;
87
+
63
88
  const hasTraversal = (p) => p.split(/[\\/]/).includes('..');
64
89
  const isUnresolved = (s) => /\{\{|\}\}|\$\{/.test(s);
65
90
 
@@ -124,6 +149,292 @@ export const readAuthoritativeVersion = (skillDir) => {
124
149
  return { version: null, from: 'no package.json or SKILL.md' };
125
150
  };
126
151
 
152
+ // Validate a PRESENT `modeCatalog` block against `roles` (BRIDGE-MODES-CATALOG, D1/D2/D4/D6). It is
153
+ // the USER-FACING mode catalog the `bridge-modes` renderer prints verbatim and composes invocation
154
+ // forms from — RELATED TO, never shadowing, the AD-033 `contract` (the internal driving contract):
155
+ // a contract-backed entry composes BY REFERENCE and never restates a descriptor; a contract-FREE
156
+ // primary (a raw prompt mode) and an env-hook carry a literal one — the stated exception to
157
+ // no-duplication, because for them the catalog IS canonical. Errors are appended, never thrown.
158
+ const validateModeCatalog = (catalog, roles, errors) => {
159
+ const oneLine = (label, value) => {
160
+ if (typeof value !== 'string' || !value) {
161
+ errors.push(`${label} must be a non-empty string`);
162
+ return false;
163
+ }
164
+ if (CONTROL_CHAR_RE.test(value)) {
165
+ errors.push(`${label} must not carry control characters`);
166
+ return false;
167
+ }
168
+ if (value.length > CATALOG_LINE_MAX) {
169
+ errors.push(`${label} must be one line of at most ${CATALOG_LINE_MAX} characters`);
170
+ return false;
171
+ }
172
+ return true;
173
+ };
174
+ const stringList = (label, value) => {
175
+ if (!Array.isArray(value) || value.length === 0) {
176
+ errors.push(`${label} must be a non-empty array of one-line strings`);
177
+ return;
178
+ }
179
+ value.forEach((v, i) => oneLine(`${label}[${i}]`, v));
180
+ };
181
+
182
+ // The key index is built FIRST: `parents[]` and `customHooks[]` resolve ACROSS entries, so a
183
+ // forward reference resolves exactly like a backward one (declaration order is not a contract).
184
+ const byKey = new Map();
185
+ for (const entry of catalog) {
186
+ if (entry == null || typeof entry !== 'object' || Array.isArray(entry)) continue;
187
+ if (typeof entry.key === 'string' && entry.key && !byKey.has(entry.key)) byKey.set(entry.key, entry);
188
+ }
189
+
190
+ const seenKeys = new Set();
191
+ const claimedRefs = new Set(); // "<role>.<field>[<index>]" — one contract invocation, at most one entry
192
+ catalog.forEach((entry, i) => {
193
+ const at = `\`modeCatalog[${i}]\``;
194
+ if (entry == null || typeof entry !== 'object' || Array.isArray(entry)) {
195
+ errors.push(`${at} must be an object`);
196
+ return;
197
+ }
198
+ const isEnvHook = entry.kind === 'env-hook';
199
+
200
+ if (typeof entry.key !== 'string' || !CATALOG_KEY_RE.test(entry.key)) {
201
+ errors.push(`${at}.key must be a bare token (a letter, then letters/digits/._-)`);
202
+ } else if (!oneLine(`${at}.key`, entry.key)) {
203
+ // The key IS the mode's printed identity — it obeys the same one-line contract as any other
204
+ // rendered string (the token regex already bars control characters; this bars a giant key).
205
+ } else if (seenKeys.has(entry.key)) {
206
+ errors.push(`duplicate modeCatalog key "${entry.key}" (${at})`);
207
+ } else {
208
+ seenKeys.add(entry.key);
209
+ if (isEnvHook && !ENV_HOOK_KEY_RE.test(entry.key)) {
210
+ errors.push(`${at}.key must be an UPPER_SNAKE_CASE env-var name (an env-hook's key IS its env var)`);
211
+ }
212
+ }
213
+ if (!CATALOG_KINDS.has(entry.kind)) {
214
+ errors.push(`${at}.kind must be one of primary|continuation|env-hook`);
215
+ return; // every rule below is keyed on the kind
216
+ }
217
+
218
+ oneLine(`${at}.purpose`, entry.purpose);
219
+ stringList(`${at}.whenToUse`, entry.whenToUse);
220
+ if (Object.hasOwn(entry, 'whenNotTo')) stringList(`${at}.whenNotTo`, entry.whenNotTo);
221
+
222
+ let role = null;
223
+ if (isEnvHook) {
224
+ if (Object.hasOwn(entry, 'role')) errors.push(`${at}.role is not allowed on an env-hook (it names parents[], never a role)`);
225
+ if (!Array.isArray(entry.parents) || entry.parents.length === 0) {
226
+ errors.push(`${at}.parents is required for an env-hook (the catalog keys it modifies)`);
227
+ } else {
228
+ const seenParents = new Set();
229
+ for (const p of entry.parents) {
230
+ const target = typeof p === 'string' ? byKey.get(p) : undefined;
231
+ if (target === undefined) {
232
+ errors.push(`${at}.parents names ${JSON.stringify(p)} which is no modeCatalog key`);
233
+ continue;
234
+ }
235
+ if (p === entry.key) {
236
+ errors.push(`${at}.parents must not name the env-hook itself`);
237
+ continue;
238
+ }
239
+ if (seenParents.has(p)) {
240
+ errors.push(`duplicate parent ${JSON.stringify(p)} (${at})`);
241
+ continue;
242
+ }
243
+ seenParents.add(p);
244
+ if (target.kind === 'env-hook') {
245
+ errors.push(`${at}.parents names ${JSON.stringify(p)}, which is an env-hook — a hook modifies a mode, never another hook`);
246
+ continue;
247
+ }
248
+ // The linkage is SYMMETRIC. The forward rule below stops a customHook from lying about a
249
+ // hook that does not target it; this is the reverse: a hook that claims a mode must be
250
+ // declared BY that mode, or the mode's rendered detail silently omits a hook that really
251
+ // changes how it runs — an incomplete discovery surface is the failure this block exists
252
+ // to prevent.
253
+ if (!Array.isArray(target.customHooks) || !target.customHooks.includes(entry.key)) {
254
+ errors.push(`${at}.parents names ${JSON.stringify(p)}, which does not list ${JSON.stringify(entry.key)} in its customHooks[]`);
255
+ }
256
+ }
257
+ }
258
+ } else {
259
+ if (Object.hasOwn(entry, 'parents')) errors.push(`${at}.parents is only allowed on an env-hook`);
260
+ if (!Object.hasOwn(entry, 'role')) errors.push(`${at}.role is required for a ${entry.kind} entry`);
261
+ else if (typeof entry.role !== 'string' || !Object.hasOwn(roles, entry.role)) {
262
+ errors.push(`${at}.role ${JSON.stringify(entry.role)} is no role of this manifest`);
263
+ } else {
264
+ role = entry.role;
265
+ }
266
+ }
267
+
268
+ // `submode` is the EXPLICIT parser-arm binding (D4) — the drift test set-equals these against the
269
+ // wrapper's real mode arms, so the binding is never parsed back out of the key.
270
+ const roleModes = role != null && Array.isArray(roles[role]?.modes) ? roles[role].modes : null;
271
+ if (Object.hasOwn(entry, 'submode')) {
272
+ if (typeof entry.submode !== 'string' || !entry.submode) errors.push(`${at}.submode must be a non-empty string (it names a parser mode arm)`);
273
+ else if (entry.kind !== 'primary') errors.push(`${at}.submode is only allowed on a primary entry (only a primary binds a parser mode arm)`);
274
+ else if (roleModes == null || roleModes.length === 0) errors.push(`${at}.submode is only allowed when the entry's role declares modes[]`);
275
+ else if (!roleModes.includes(entry.submode)) errors.push(`${at}.submode ${JSON.stringify(entry.submode)} is no declared mode of role "${role}"`);
276
+ } else if (entry.kind === 'primary' && roleModes != null && roleModes.length > 0) {
277
+ errors.push(`${at}.submode is required (a primary whose role declares modes[] binds one)`);
278
+ }
279
+
280
+ const contract = role != null ? roles[role]?.contract : null;
281
+ const contractBacked = !isEnvHook && contract != null && typeof contract === 'object' && !Array.isArray(contract);
282
+ // The kind BINDS the contract field it may claim: a primary DRIVES a mode (`invocations`), a
283
+ // continuation RESUMES one (`continue`). Crossing them would render a resume form as a drive
284
+ // form (or the reverse) — a lie in exactly the surface the catalog exists to make honest.
285
+ const requiredField = entry.kind === 'primary' ? 'invocations' : 'continue';
286
+ // A continuation has nothing to be canonical ABOUT: D6's literal-descriptor exception covers a
287
+ // contract-free PRIMARY (a raw prompt mode) and an env-hook only. A role with no `continue`
288
+ // contract simply has no continuation to catalog.
289
+ if (entry.kind === 'continuation' && !contractBacked) {
290
+ errors.push(`${at}: a continuation must be contract-backed (the literal-descriptor exception covers contract-free primaries and env-hooks only)`);
291
+ }
292
+ // The invocation forms this entry really renders — the domain every operand slot must live in.
293
+ const forms = [];
294
+ if (contractBacked) {
295
+ if (Object.hasOwn(entry, 'descriptor')) errors.push(`${at}.descriptor is only allowed on a contract-free primary or an env-hook (a contract-backed entry composes BY REFERENCE)`);
296
+ if (!Array.isArray(entry.invocationRefs) || entry.invocationRefs.length === 0) {
297
+ errors.push(`${at}.invocationRefs is required (non-empty) for a contract-backed entry`);
298
+ } else {
299
+ entry.invocationRefs.forEach((ref, j) => {
300
+ const rat = `${at}.invocationRefs[${j}]`;
301
+ if (ref == null || typeof ref !== 'object' || Array.isArray(ref)) {
302
+ errors.push(`${rat} must be a {contractField, index} object`);
303
+ return;
304
+ }
305
+ if (!CATALOG_CONTRACT_FIELDS.has(ref.contractField)) {
306
+ errors.push(`${rat}.contractField must be one of invocations|continue`);
307
+ return;
308
+ }
309
+ if (ref.contractField !== requiredField) {
310
+ errors.push(`${rat}.contractField must be "${requiredField}" for a ${entry.kind} entry (a primary drives, a continuation resumes)`);
311
+ return;
312
+ }
313
+ if (!Number.isSafeInteger(ref.index) || ref.index < 0) {
314
+ errors.push(`${rat}.index must be a non-negative integer`);
315
+ return;
316
+ }
317
+ const declared = contract[ref.contractField];
318
+ const form = Array.isArray(declared) ? declared[ref.index] : undefined;
319
+ if (typeof form !== 'string' || !form) {
320
+ errors.push(`${rat} does not resolve (roles.${role}.contract.${ref.contractField}[${ref.index}])`);
321
+ return;
322
+ }
323
+ // A REFERENCED form is printed exactly like a literal descriptor, so it obeys the same
324
+ // one-line contract. The AD-033 `contract` block is otherwise validator-TOLERATED (its
325
+ // shape is drift-guarded by the bridge tests, not here) — this checks only the invocations
326
+ // a catalog entry causes to be PRINTED, and the label names the contract as their home.
327
+ if (!oneLine(`${rat} → roles.${role}.contract.${ref.contractField}[${ref.index}]`, form)) return;
328
+ const claim = `${role}.${ref.contractField}[${ref.index}]`;
329
+ if (claimedRefs.has(claim)) errors.push(`duplicate modeCatalog invocation reference ${claim} (${at})`);
330
+ else claimedRefs.add(claim);
331
+ forms.push(form);
332
+ });
333
+ }
334
+ } else {
335
+ if (Object.hasOwn(entry, 'invocationRefs')) errors.push(`${at}.invocationRefs is only allowed on a contract-backed entry`);
336
+ if (!Object.hasOwn(entry, 'descriptor')) errors.push(`${at}.descriptor is required (a contract-free entry is canonical for its own invocation form)`);
337
+ else if (oneLine(`${at}.descriptor`, entry.descriptor)) forms.push(entry.descriptor);
338
+ }
339
+
340
+ // Descriptor honesty (D2), BOTH ways. The render labels an unfilled form a TEMPLATE and names
341
+ // each required operand, so the declared slots and the placeholders the forms really carry must
342
+ // be the SAME set: an invented slot names an operand with nowhere to go, and an UNDECLARED
343
+ // placeholder is worse — the render shows a form as if it were ready to run while the reader has
344
+ // no idea what to put there or whether it is required. `required` is deliberately NOT inferred
345
+ // from the bracket shape (`<extra flags...>` is legitimately optional): the grammar fixes a
346
+ // slot's IDENTITY, the catalog fixes its semantics.
347
+ const renderedSlots = new Set(forms.flatMap((f) => extractCatalogOperandSlots(f)));
348
+ const hasOperands = Object.hasOwn(entry, 'operands');
349
+ if (forms.length > 0 && renderedSlots.size > 0 && !hasOperands) {
350
+ errors.push(`${at}.operands is required because its invocation forms contain rendered operand slots`);
351
+ }
352
+ if (hasOperands) {
353
+ if (!Array.isArray(entry.operands) || entry.operands.length === 0) {
354
+ errors.push(`${at}.operands must be an array of typed operand slots`);
355
+ } else {
356
+ const seenSlots = new Set();
357
+ entry.operands.forEach((op, j) => {
358
+ const oat = `${at}.operands[${j}]`;
359
+ if (op == null || typeof op !== 'object' || Array.isArray(op)) {
360
+ errors.push(`${oat} must be a {slot, required, description} object`);
361
+ return;
362
+ }
363
+ const slotOk = oneLine(`${oat}.slot`, op.slot);
364
+ if (typeof op.required !== 'boolean') errors.push(`${oat}.required must be a boolean`);
365
+ oneLine(`${oat}.description`, op.description);
366
+ if (!slotOk) return;
367
+ if (seenSlots.has(op.slot)) errors.push(`duplicate operand slot "${op.slot}" (${oat})`);
368
+ else seenSlots.add(op.slot);
369
+ // Checked only once the forms really resolved (else the error is noise).
370
+ if (forms.length > 0 && !renderedSlots.has(op.slot)) {
371
+ errors.push(`${oat}.slot "${op.slot}" is not a rendered placeholder in this entry's invocation forms`);
372
+ }
373
+ });
374
+ if (forms.length > 0) {
375
+ for (const slot of renderedSlots) {
376
+ if (!seenSlots.has(slot)) errors.push(`${at}.operands is missing rendered slot "${slot}"`);
377
+ }
378
+ }
379
+ }
380
+ }
381
+
382
+ if (Object.hasOwn(entry, 'guardrails')) {
383
+ if (!Array.isArray(entry.guardrails) || entry.guardrails.length === 0) {
384
+ errors.push(`${at}.guardrails must be an array of typed guardrail entries`);
385
+ } else {
386
+ entry.guardrails.forEach((g, j) => {
387
+ const gat = `${at}.guardrails[${j}]`;
388
+ if (g == null || typeof g !== 'object' || Array.isArray(g)) {
389
+ errors.push(`${gat} must be a {value, enforcement, condition?, source} object`);
390
+ return;
391
+ }
392
+ oneLine(`${gat}.value`, g.value);
393
+ if (!CATALOG_ENFORCEMENT.has(g.enforcement)) errors.push(`${gat}.enforcement must be one of enforced|advisory`);
394
+ if (Object.hasOwn(g, 'condition')) oneLine(`${gat}.condition`, g.condition);
395
+ oneLine(`${gat}.source`, g.source);
396
+ });
397
+ }
398
+ }
399
+
400
+ if (Object.hasOwn(entry, 'customHooks')) {
401
+ if (!Array.isArray(entry.customHooks) || entry.customHooks.length === 0) {
402
+ errors.push(`${at}.customHooks must be a non-empty array of modeCatalog keys`);
403
+ } else {
404
+ const seenHooks = new Set();
405
+ for (const hook of entry.customHooks) {
406
+ const target = typeof hook === 'string' ? byKey.get(hook) : undefined;
407
+ if (target === undefined) {
408
+ errors.push(`${at}.customHooks names ${JSON.stringify(hook)} which is no modeCatalog key`);
409
+ continue;
410
+ }
411
+ if (seenHooks.has(hook)) {
412
+ errors.push(`duplicate customHook "${hook}" (${at})`);
413
+ continue;
414
+ }
415
+ seenHooks.add(hook);
416
+ if (hook === entry.key) {
417
+ // The raw-mode carve-out (D4): a contract-free primary IS its own escape, so it names
418
+ // itself rather than repeating an escape it does not have.
419
+ if (contractBacked || entry.kind !== 'primary') {
420
+ errors.push(`${at}.customHooks may name this entry itself only on a contract-free primary (the raw mode)`);
421
+ }
422
+ continue;
423
+ }
424
+ if (target.kind !== 'env-hook') {
425
+ errors.push(`${at}.customHooks names ${JSON.stringify(hook)}, which is neither an env-hook nor this entry itself`);
426
+ continue;
427
+ }
428
+ // A customHook can never LIE about a hook that does not target this mode.
429
+ if (!Array.isArray(target.parents) || !target.parents.includes(entry.key)) {
430
+ errors.push(`${at}.customHooks names env-hook "${hook}", which does not list ${JSON.stringify(entry.key)} in its parents[]`);
431
+ }
432
+ }
433
+ }
434
+ }
435
+ });
436
+ };
437
+
127
438
  export const validateManifest = (skillDir) => {
128
439
  const manifestPath = join(skillDir, 'capability.json');
129
440
  let raw;
@@ -377,6 +688,22 @@ export const validateManifest = (skillDir) => {
377
688
  }
378
689
  }
379
690
 
691
+ // `modeCatalog` (BRIDGE-MODES-CATALOG, D1): the user-facing mode catalog. ADDITIVE-OPTIONAL —
692
+ // an absent block is VALID (a bridge predating the catalog stays valid; the mode renders a stated
693
+ // "no catalog" line, never invalid-manifest and never an empty silent list). A PRESENT block is
694
+ // typed-validated like `settings`: the renderer prints its strings verbatim and composes runnable
695
+ // forms from its refs, so a malformed entry would render a lying discovery surface.
696
+ // Presence is keyed on the KEY, never on non-null: an explicit `modeCatalog: null` is a PRESENT
697
+ // malformed block, not an absence (absence is "this bridge predates the catalog"). An EMPTY array
698
+ // is likewise invalid — it would render as "this bridge has no modes", which is never true and is
699
+ // exactly the silent empty list D1 forbids; a bridge with nothing to say omits the block.
700
+ if (Object.hasOwn(manifest, 'modeCatalog')) {
701
+ const modeCatalog = manifest.modeCatalog;
702
+ if (!Array.isArray(modeCatalog)) errors.push('`modeCatalog` must be an array of mode entries');
703
+ else if (modeCatalog.length === 0) errors.push('`modeCatalog` must not be empty — a declared catalog states at least one mode (omit the block instead)');
704
+ else validateModeCatalog(modeCatalog, roles, errors);
705
+ }
706
+
380
707
  if (!isStub) {
381
708
  const auth = readAuthoritativeVersion(skillDir);
382
709
  if (auth.version == null) errors.push(`could not resolve an authoritative version (${auth.from})`);
@@ -348,6 +348,80 @@ export const filterSegmentRecords = (records, { activity, loop, base }) =>
348
348
  // (no duplicate, gap, or out-of-order round). Checks the EXISTING sequence, not just the incoming
349
349
  // round: a ledger like [2] / [1,1] / [2,1] (reachable only by hand-editing the git-dir file — the
350
350
  // stated residual) must fail closed rather than be trusted to compute the "latest" round (codex R3).
351
+ // ── the attesting-receipt predicate (BRIDGE-MODES-CATALOG, D3) ─────────────────────────
352
+ // The ONE place that decides whether a review receipt may attest a tree. It lives in this neutral
353
+ // core because all three consumers — review-state.mjs (the receipt gate), review-ledger.mjs (the
354
+ // round cross-check) and review-ledger-write.mjs (the round writer) — need it, and the core imports
355
+ // none of them: the same DAG reason this module exists at all. Two gates disagreeing about what
356
+ // counts as an attestation is exactly the class AD-050 closed; a second copy would re-open it.
357
+ export const REVIEW_RECEIPT_CLASS = Object.freeze({
358
+ NOT_CURRENT: 'not-current',
359
+ ATTESTING: 'attesting',
360
+ UNGROUNDED: 'ungrounded',
361
+ PROBE: 'probe',
362
+ UNMARKED: 'unmarked',
363
+ MALFORMED_MARKER: 'malformed-marker',
364
+ });
365
+
366
+ // Classify ONE receipt against a tree fingerprint. Order is load-bearing: identity first (is this
367
+ // even about the current tree?), then the probe marker (may it attest at all?), then grounding.
368
+ export const classifyReviewReceiptForTree = (receipt, fingerprint) => {
369
+ if (!isPlainObject(receipt) || receipt.fresh !== true || receipt.artifact !== 'code' || receipt.fingerprint !== fingerprint) {
370
+ return REVIEW_RECEIPT_CLASS.NOT_CURRENT;
371
+ }
372
+ if (!Object.hasOwn(receipt, 'probe')) return REVIEW_RECEIPT_CLASS.UNMARKED;
373
+ if (typeof receipt.probe !== 'boolean') return REVIEW_RECEIPT_CLASS.MALFORMED_MARKER;
374
+ if (receipt.probe === true) return REVIEW_RECEIPT_CLASS.PROBE;
375
+ if (receipt.grounded !== true) return REVIEW_RECEIPT_CLASS.UNGROUNDED;
376
+ return REVIEW_RECEIPT_CLASS.ATTESTING;
377
+ };
378
+
379
+ // Summarize one backend's receipts for a tree → { state, receipt, counts… }. `receipt` is the LATEST
380
+ // ATTESTING one — never simply the last line: a probe (or a forged marker) written after a real
381
+ // review must never become the authoritative verdict, which is precisely how a late probe SHIP could
382
+ // override an earlier real REWORK and let both gates report convergence.
383
+ export const summarizeReviewReceiptsForTree = (receipts, fingerprint) => {
384
+ const classified = receipts
385
+ .map((receipt) => ({ receipt, classification: classifyReviewReceiptForTree(receipt, fingerprint) }))
386
+ .filter(({ classification }) => classification !== REVIEW_RECEIPT_CLASS.NOT_CURRENT);
387
+ const rowsFor = (classification) => classified.filter((row) => row.classification === classification);
388
+ const attesting = rowsFor(REVIEW_RECEIPT_CLASS.ATTESTING);
389
+ const ungrounded = rowsFor(REVIEW_RECEIPT_CLASS.UNGROUNDED);
390
+ const probe = rowsFor(REVIEW_RECEIPT_CLASS.PROBE);
391
+ const unmarked = rowsFor(REVIEW_RECEIPT_CLASS.UNMARKED);
392
+ const malformedMarker = rowsFor(REVIEW_RECEIPT_CLASS.MALFORMED_MARKER);
393
+ const counts = {
394
+ currentCount: classified.length,
395
+ ungroundedCount: ungrounded.length,
396
+ probeExcluded: probe.length,
397
+ markerRejected: malformedMarker.length,
398
+ unmarkedRejected: unmarked.length,
399
+ };
400
+ if (attesting.length > 0) return { state: 'current', receipt: attesting[attesting.length - 1].receipt, ...counts };
401
+ if (ungrounded.length > 0) return { state: 'ungrounded', receipt: ungrounded[ungrounded.length - 1].receipt, ...counts };
402
+ if (classified.length > 0) {
403
+ return { state: malformedMarker.length > 0 || unmarked.length > 0 ? 'rejected' : 'probe', receipt: null, ...counts };
404
+ }
405
+ return { state: 'none', receipt: null, ...counts };
406
+ };
407
+
408
+ // Why this backend has no attestation — one stated sentence, never a silent "no receipt". The four
409
+ // causes have DIFFERENT recoveries (run a real review / refresh the bridge / fix the receipt source /
410
+ // re-run grounded), so they are never collapsed into one message.
411
+ export const describeMissingReviewAttestation = (summary) => {
412
+ if (summary.state === 'current') return null;
413
+ const exclusions = [
414
+ summary.probeExcluded > 0 ? `${summary.probeExcluded} probe receipt(s)` : null,
415
+ summary.markerRejected > 0 ? `${summary.markerRejected} receipt(s) with a malformed probe marker` : null,
416
+ summary.unmarkedRejected > 0 ? `${summary.unmarkedRejected} receipt(s) with no probe marker` : null,
417
+ ].filter(Boolean);
418
+ const exclusionSuffix = exclusions.length > 0 ? `; excluded ${exclusions.join(', ')}` : '';
419
+ if (summary.state === 'ungrounded') return `only ungrounded normal receipts exist for the current tree${exclusionSuffix}`;
420
+ if (summary.state === 'probe') return 'only probe receipts exist for the current tree — a probe review never attests';
421
+ if (summary.state === 'rejected') return `current-tree receipts have untrustworthy probe markers${exclusionSuffix}`;
422
+ return 'no fresh code receipt exists for the current tree';
423
+ };
424
+
351
425
  export const roundSequenceIntact = (records) => {
352
426
  const nums = records.filter((r) => r.kind === 'round').map((r) => r.round);
353
427
  return nums.every((n, i) => n === i + 1);
@@ -18,8 +18,10 @@
18
18
  // round VANISHED unclassified (D6 — no-repro-no-fold; `refuted` is the honest
19
19
  // phantom lane); refuses while the changed source surface exceeds the diff cap
20
20
  // without a recorded segment size-cap override (D4). Integrity binding (Decision 7):
21
- // each NON-degraded backend needs a grounded code receipt for the current tree, so a
22
- // round cannot be recorded for a tree no bridge reviewed.
21
+ // each NON-degraded backend needs an ATTESTING code receipt for the current tree
22
+ // (the shared review-ledger-core predicate: grounded AND probe:false a probe,
23
+ // unmarked or malformed marker never attests), so a round cannot be recorded for a
24
+ // tree no bridge really reviewed.
23
25
  // recordTriage — the classification that BREAKS the deadlock: each surviving blocking finding of a
24
26
  // SEGMENT round classified fixable-bug / inherent-layer-residual / escalate /
25
27
  // refuted (v4). No teeth (a triage is exactly what lets the next round proceed),
@@ -51,6 +53,9 @@ import {
51
53
  decideStop,
52
54
  validateRecord,
53
55
  } from './review-ledger.mjs';
56
+ // The SHARED attesting-receipt predicate (D3) — read straight from the neutral core, the same one
57
+ // review-state and the round cross-check use: one definition of "this receipt may attest a tree".
58
+ import { summarizeReviewReceiptsForTree, describeMissingReviewAttestation } from './review-ledger-core.mjs';
54
59
  // The NEUTRAL shared changed-surface computation (BUGFREE-2 / D4): the D4 diff-cap and the
55
60
  // fold-completeness coverage gate consume ONE computation, so they can never drift. The writer
56
61
  // imports the NEUTRAL module, never the runner (the sole-tree-toucher boundary, codex R2 — an
@@ -253,17 +258,20 @@ export const recordRound = (params, deps = {}) => {
253
258
  const v = validateRecord(record);
254
259
  if (!v.ok) throw stop(`refusing to record a malformed round: ${v.reason}`);
255
260
 
256
- // Integrity binding: each NON-degraded backend needs a grounded code receipt for this tree — a
257
- // round cannot be recorded for a tree no bridge reviewed. A degraded backend minted no receipt.
261
+ // Integrity binding: each NON-degraded backend needs an ATTESTING receipt for this tree — a round
262
+ // cannot be recorded for a tree no bridge really reviewed. A degraded backend minted no receipt.
263
+ // The predicate is the SHARED one (review-ledger-core), the same review-state and the round
264
+ // cross-check read — a probe receipt never counted as a review, and the stated reason names WHICH
265
+ // exclusion applied, because the recoveries differ (run a real review / refresh the bridge / fix
266
+ // the receipt source / re-run grounded) and a silent "no receipt" would hide that.
258
267
  const receiptsPath = deps.receiptsPath ?? resolveReceiptsPath(cwd, env);
259
268
  const { receipts } = receiptsPath ? readReceipts(receiptsPath, deps.readFile) : { receipts: [] };
260
269
  for (const b of backends) {
261
270
  if (b.degraded) continue;
262
- const own = receipts.filter(
263
- (r) => r.backend === b.backend && r.fingerprint === fingerprint && r.artifact === 'code' && r.fresh === true && r.grounded === true,
264
- );
265
- if (own.length === 0) {
266
- throw stop(`refusing to record a round for ${b.backend}: no grounded code receipt for the current tree — run its review wrapper (codex-review code / agy-review code --facts @f) first, or mark the backend degraded with a reason`);
271
+ const own = receipts.filter((r) => r.backend === b.backend);
272
+ const failure = describeMissingReviewAttestation(summarizeReviewReceiptsForTree(own, fingerprint));
273
+ if (failure !== null) {
274
+ throw stop(`refusing to record a round for ${b.backend}: no grounded code receipt for the current tree can attest this round — ${failure}; run its real review (codex-review code / agy-review code --facts @f), or mark the backend degraded with a reason`);
267
275
  }
268
276
  }
269
277
 
@@ -518,7 +526,8 @@ record appends one review round. The JSON payload carries { loop, round, origi
518
526
  segment size-cap override (D4); while the segment lacks a quality-green gate-run at the
519
527
  current fingerprint (D5 — run the FULL matrix first: run-gates.mjs --record; a --only
520
528
  subset or a tree-changed run never satisfies; red PROCESS gates never block); or when a
521
- non-degraded backend lacks a grounded code receipt for the current tree.
529
+ non-degraded backend lacks an ATTESTING code receipt for the current tree (grounded AND
530
+ probe:false — a probe, unmarked or malformed marker never attests).
522
531
  classify appends one triage record. The JSON payload carries { loop, round, classifications } (each
523
532
  { findingKey, class, accepted, testId, note }). A fixable-bug REQUIRES a testId — the
524
533
  red→green test that pins the fold, formatted "<test-file>#<test-name-pattern>" (write it
@@ -19,8 +19,10 @@
19
19
  // no reviewer ready); when no plan is in flight (the review-state naming convention);
20
20
  // when the tree is clean (nothing to review); when the cwd is not a git work tree; and
21
21
  // when the in-flight plan-execution SEGMENT is `converged` or `resolved-residual` (its
22
- // latest round's non-degraded backends carry grounded code receipts for the recorded
23
- // fingerprint, and a recorded 0/0 is ship-class-consistent with those receipts).
22
+ // latest round's non-degraded backends carry ATTESTING code receipts for the recorded
23
+ // fingerprint the shared review-ledger-core predicate: grounded AND probe:false, a
24
+ // probe/unmarked/malformed marker never attests — and a recorded 0/0 is
25
+ // ship-class-consistent with those receipts).
24
26
  // exit 1 for any DIRTY in-flight plan-execution segment that is neither `converged` nor
25
27
  // `resolved-residual` — `triage-required`, `continue`, OR no round/receipt recorded in
26
28
  // the CURRENT segment (a dirty active plan with an empty/stale/other-segment ledger is a
@@ -83,6 +85,8 @@ import {
83
85
  filterLoopRecords,
84
86
  filterSegmentRecords,
85
87
  roundSequenceIntact,
88
+ summarizeReviewReceiptsForTree,
89
+ describeMissingReviewAttestation,
86
90
  } from './review-ledger-core.mjs';
87
91
  export {
88
92
  LEDGER_BASENAME,
@@ -270,22 +274,27 @@ export const decideStop = (records, { cap = REVIEW_CAP, currentFingerprint = nul
270
274
  // ── receipt cross-check (integrity binding, Decision 7) ──────────────────────────────────────────
271
275
 
272
276
  // receiptCrossCheck(round, receipts, fingerprint) → { ok, reason }. For each NON-degraded backend of
273
- // `round`: a grounded code receipt must exist for (backend, fingerprint) — a round cannot pass for a
274
- // tree no bridge reviewed — AND a recorded ship-class 0/0 must not coexist with a non-ship receipt
277
+ // `round`: an ATTESTING receipt must exist for (backend, fingerprint) — a round cannot pass for a
278
+ // tree no bridge really reviewed — AND a recorded ship-class 0/0 must not coexist with a non-ship
275
279
  // verdict. A degraded backend minted no receipt (it ran no real review) and is exempt.
280
+ // The attesting predicate is the SHARED one (review-ledger-core), the same review-state reads: this
281
+ // gate used to filter raw fields and take `own[own.length - 1]`, so a probe receipt landing AFTER a
282
+ // real one became the authoritative verdict — a late probe SHIP could bury a real REWORK and let
283
+ // BOTH gates report convergence (D3). The summary's `receipt` is the latest ATTESTING one, never
284
+ // simply the last line.
276
285
  export const receiptCrossCheck = (round, receipts, fingerprint) => {
277
286
  for (const b of round.backends) {
278
287
  if (b.degraded) continue;
279
- const own = receipts.filter(
280
- (r) => r.backend === b.backend && r.fingerprint === fingerprint && r.artifact === 'code' && r.fresh === true && r.grounded === true,
281
- );
282
- if (own.length === 0) {
283
- return { ok: false, reason: `no grounded code receipt for ${b.backend} at the recorded fingerprint (a recorded round must bind to a real review)` };
288
+ const own = receipts.filter((r) => r.backend === b.backend);
289
+ const summary = summarizeReviewReceiptsForTree(own, fingerprint);
290
+ const failure = describeMissingReviewAttestation(summary);
291
+ if (failure !== null) {
292
+ return { ok: false, reason: `no grounded code receipt for ${b.backend} at the recorded fingerprint can attest this review round ${failure}` };
284
293
  }
285
294
  if (b.blockers === 0 && b.majors === 0) {
286
- const latest = own[own.length - 1];
287
- if (!isShipVerdict(latest.verdict)) {
288
- return { ok: false, reason: `${b.backend} recorded 0 blockers/0 majors but its receipt verdict "${latest.verdict}" is not ship-class` };
295
+ const { verdict } = summary.receipt;
296
+ if (!isShipVerdict(verdict)) {
297
+ return { ok: false, reason: `${b.backend} recorded 0 blockers/0 majors but its attesting receipt verdict "${verdict}" is not ship-class` };
289
298
  }
290
299
  }
291
300
  }