@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.
- package/CHANGELOG.md +71 -0
- package/SKILL.md +1 -1
- package/bridges/antigravity-cli-bridge/SKILL.md +9 -1
- package/bridges/antigravity-cli-bridge/bin/agy-review.sh +22 -9
- package/bridges/antigravity-cli-bridge/bin/agy-review.test.mjs +173 -2
- package/bridges/antigravity-cli-bridge/bin/agy.test.mjs +76 -0
- package/bridges/antigravity-cli-bridge/capability.json +160 -2
- package/bridges/codex-cli-bridge/SKILL.md +8 -1
- package/bridges/codex-cli-bridge/bin/codex-exec.test.mjs +133 -0
- package/bridges/codex-cli-bridge/bin/codex-review.sh +22 -9
- package/bridges/codex-cli-bridge/bin/codex-review.test.mjs +165 -2
- package/bridges/codex-cli-bridge/capability.json +118 -2
- package/capability.json +1 -1
- package/package.json +1 -1
- package/references/modes/review-ledger.md +1 -1
- package/references/modes/review-state.md +2 -2
- package/tools/detect-backends.mjs +2 -2
- package/tools/manifest/schema.md +62 -0
- package/tools/manifest/validate.mjs +327 -0
- package/tools/review-ledger-core.mjs +74 -0
- package/tools/review-ledger-write.mjs +19 -10
- package/tools/review-ledger.mjs +21 -12
- package/tools/review-state.mjs +89 -28
|
@@ -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
|
|
22
|
-
//
|
|
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
|
|
257
|
-
//
|
|
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
|
-
|
|
264
|
-
)
|
|
265
|
-
|
|
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
|
|
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
|
package/tools/review-ledger.mjs
CHANGED
|
@@ -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
|
|
23
|
-
// fingerprint
|
|
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`:
|
|
274
|
-
// tree no bridge reviewed — AND a recorded ship-class 0/0 must not coexist with a non-ship
|
|
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
|
-
|
|
281
|
-
);
|
|
282
|
-
if (
|
|
283
|
-
return { ok: false, reason: `no grounded code receipt for ${b.backend} at the recorded fingerprint
|
|
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
|
|
287
|
-
if (!isShipVerdict(
|
|
288
|
-
return { ok: false, reason: `${b.backend} recorded 0 blockers/0 majors but its receipt verdict "${
|
|
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
|
}
|