@sabaiway/agent-workflow-kit 2.1.0 → 3.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 +45 -0
- package/README.md +6 -5
- package/SKILL.md +12 -8
- package/bin/install.mjs +1 -1
- package/bridges/antigravity-cli-bridge/SKILL.md +8 -1
- package/bridges/antigravity-cli-bridge/bin/agy-review-honesty.test.mjs +150 -0
- package/bridges/antigravity-cli-bridge/bin/agy-review-model-screen.test.mjs +76 -0
- package/bridges/antigravity-cli-bridge/bin/agy-review.sh +80 -15
- package/bridges/antigravity-cli-bridge/bin/agy-review.test.mjs +95 -4
- package/bridges/antigravity-cli-bridge/capability.json +3 -2
- package/bridges/codex-cli-bridge/SKILL.md +8 -1
- package/bridges/codex-cli-bridge/bin/codex-review-honesty.test.mjs +143 -0
- package/bridges/codex-cli-bridge/bin/codex-review.sh +71 -10
- package/bridges/codex-cli-bridge/bin/codex-review.test.mjs +93 -10
- package/bridges/codex-cli-bridge/capability.json +3 -2
- package/capability.json +1 -1
- package/migrations/3.0.0-hardened-core-loop.md +44 -0
- package/migrations/README.md +1 -1
- package/package.json +2 -2
- package/references/hooks/gate-approve.mjs +1 -1
- package/references/modes/bootstrap.md +3 -3
- package/references/modes/commit-guard.md +19 -0
- package/references/modes/core-evidence.md +15 -0
- package/references/modes/coverage-check.md +12 -0
- package/references/modes/doc-parity.md +7 -6
- package/references/modes/gates.md +7 -6
- package/references/modes/grounding.md +1 -2
- package/references/modes/hook.md +1 -1
- package/references/modes/review-state.md +3 -3
- package/references/modes/upgrade.md +6 -6
- package/references/modes/velocity.md +2 -2
- package/references/scripts/archive-decisions.mjs +1 -1
- package/references/scripts/install-git-hooks-repo-exec.test.mjs +82 -0
- package/references/scripts/install-git-hooks.mjs +90 -18
- package/references/scripts/install-git-hooks.test.mjs +102 -0
- package/references/scripts/migrate-gates-branches.test.mjs +157 -0
- package/references/scripts/migrate-gates.mjs +395 -0
- package/references/scripts/migrate-gates.test.mjs +284 -0
- package/references/templates/agent_rules.md +2 -2
- package/tools/ack-write.mjs +1 -1
- package/tools/atomic-write.mjs +2 -2
- package/tools/autonomy-config.mjs +1 -1
- package/tools/autonomy-doctor.mjs +1 -1
- package/tools/autonomy-write.mjs +1 -1
- package/tools/bridge-settings-read.mjs +1 -1
- package/tools/bridge-settings.mjs +1 -1
- package/tools/changed-surface.mjs +8 -69
- package/tools/cheap-agents.mjs +2 -2
- package/tools/commands.mjs +17 -10
- package/tools/commit-guard.mjs +167 -0
- package/tools/core-evidence.mjs +914 -0
- package/tools/coverage-check.mjs +260 -0
- package/tools/delegation.mjs +1 -1
- package/tools/detect-backends.mjs +3 -3
- package/tools/doc-parity.mjs +11 -27
- package/tools/engine-source.mjs +1 -1
- package/tools/family-members.mjs +1 -1
- package/tools/family-registry.mjs +1 -1
- package/tools/fs-safe.mjs +1 -1
- package/tools/gate-hook.mjs +1 -1
- package/tools/{seed-gates.mjs → gates-init.mjs} +58 -52
- package/tools/grounding.mjs +6 -83
- package/tools/hide-footprint.mjs +1 -1
- package/tools/inject-methodology.mjs +1 -1
- package/tools/known-footprint.mjs +1 -1
- package/tools/labels.mjs +1 -1
- package/tools/lcov.mjs +6 -10
- package/tools/lens-region.mjs +1 -1
- package/tools/manifest/validate.mjs +19 -0
- package/tools/migrate-adr-store.mjs +2 -2
- package/tools/orchestration-config.mjs +1 -1
- package/tools/orchestration-write.mjs +1 -1
- package/tools/presentation.mjs +1 -1
- package/tools/procedures.mjs +2 -2
- package/tools/recipes.mjs +60 -9
- package/tools/recommendations.mjs +63 -7
- package/tools/release-scan.mjs +1 -1
- package/tools/renderers.mjs +1 -1
- package/tools/review-state.mjs +288 -340
- package/tools/run-gates.mjs +216 -92
- package/tools/sandbox-masks.mjs +1 -1
- package/tools/semver-lite.mjs +1 -1
- package/tools/set-autonomy.mjs +1 -1
- package/tools/set-recipe.mjs +1 -1
- package/tools/setup-backends.mjs +1 -1
- package/tools/surface.mjs +1 -1
- package/tools/uninstall.mjs +1 -1
- package/tools/velocity-profile.mjs +2 -2
- package/tools/view-model.mjs +1 -1
- package/references/modes/fold-completeness.md +0 -30
- package/references/modes/review-ledger.md +0 -34
- package/references/templates/verification-profile.json +0 -10
- package/tools/fold-completeness-run.mjs +0 -1120
- package/tools/fold-completeness.mjs +0 -672
- package/tools/review-ledger-core.mjs +0 -428
- package/tools/review-ledger-write.mjs +0 -647
- package/tools/review-ledger.mjs +0 -630
- package/tools/sarif.mjs +0 -52
- package/tools/verification-profile.mjs +0 -219
|
@@ -1,428 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
// review-ledger-core.mjs — the NEUTRAL ledger read/schema core (AD-050). One home for the validated
|
|
3
|
-
// review-ledger read path — the path/base resolvers, the tolerant schema validator + reader, and the
|
|
4
|
-
// loop/segment filters — extracted VERBATIM from review-ledger.mjs so BOTH read-only checkers can
|
|
5
|
-
// share it. review-ledger.mjs already imports review-state.mjs, so review-state.mjs cannot import
|
|
6
|
-
// review-ledger.mjs back (the cycle) — the degraded-exemption reader in review-state.mjs imports THIS
|
|
7
|
-
// neutral core instead (added in AD-050 Segment 2). review-ledger.mjs consumes + re-exports every
|
|
8
|
-
// symbol here for external back-compat — the changed-surface.mjs precedent (AD-048).
|
|
9
|
-
//
|
|
10
|
-
// Import-graph invariant (pinned by import-split tests): this module imports NOTHING from the
|
|
11
|
-
// family — node built-ins only. Everyone may import it; it imports no one:
|
|
12
|
-
// review-ledger.mjs → review-ledger-core.mjs ← review-state.mjs (AD-050 Segment 2)
|
|
13
|
-
//
|
|
14
|
-
// Read-only: never writes, never commits. It DOES spawn read-only `git` queries (rev-parse).
|
|
15
|
-
// Dependency-free, Node >= 18. No side effects on import.
|
|
16
|
-
|
|
17
|
-
import { readFileSync } from 'node:fs';
|
|
18
|
-
import { join } from 'node:path';
|
|
19
|
-
import { spawnSync } from 'node:child_process';
|
|
20
|
-
|
|
21
|
-
export const LEDGER_BASENAME = 'agent-workflow-review-ledger.jsonl';
|
|
22
|
-
|
|
23
|
-
// SCHEMA_VERSION is what the WRITER emits (M2/AD-046: a fixable-bug triage requires a non-null,
|
|
24
|
-
// well-formed testId — the red→green test that pins the fold; BUGFREE-1/AD-047: v3 adds the
|
|
25
|
-
// `override` record kind — the loud, durable waiver the fold-completeness gate consumes;
|
|
26
|
-
// BUGFREE-2/AD-048: v4 adds the SEGMENT — every record carries `base` = the commit the dirty tree
|
|
27
|
-
// sits on, and round numbering / caps / teeth / decideCheck all operate per (activity, loop, base) —
|
|
28
|
-
// plus the kind `gate-run` (the D5 green-baseline receipt run-gates --record mints), the override
|
|
29
|
-
// scope `size-cap` (the D4 diff-cap waiver), and the triage class `refuted` (the D6 honest lane for
|
|
30
|
-
// a phantom finding). The READER tolerates every SUPPORTED_SCHEMAS version under its own
|
|
31
|
-
// per-version rules, so historical/live v1..v3 ledgers never retroactively become malformed
|
|
32
|
-
// (Decision 2 — a malformed line cascades fail-closed refusals in the writer teeth AND the --check
|
|
33
|
-
// gate). v1 records keep the AD-045 rule (testId optional, unenforced); v2+ enforces the
|
|
34
|
-
// test-per-fold binding; ONLY v3+ may carry kind `override`; ONLY v4 may carry `base` / kind
|
|
35
|
-
// `gate-run` / scope `size-cap` / class `refuted` (older records never grow new surface).
|
|
36
|
-
// decideStop never reads testId, overrides, gate-runs, or base (not decideStop inputs — the caller
|
|
37
|
-
// passes ONE segment's records, exactly as it passes one loop's; D10: the truth table is untouched).
|
|
38
|
-
export const SCHEMA_VERSION = 4;
|
|
39
|
-
const SUPPORTED_SCHEMAS = new Set([1, 2, 3, 4]);
|
|
40
|
-
|
|
41
|
-
// The record vocabulary — the single home of every enum the schema validates.
|
|
42
|
-
const ACTIVITIES_SET = new Set(['plan-authoring', 'plan-execution']);
|
|
43
|
-
const KINDS_SET = new Set(['round', 'triage']);
|
|
44
|
-
const SEVERITIES = new Set(['blocker', 'major', 'minor']);
|
|
45
|
-
export const ORIGINS = ['first-draft', 'fold-induced', 'mechanics'];
|
|
46
|
-
const CLASSES = new Set(['fixable-bug', 'inherent-layer-residual', 'escalate']);
|
|
47
|
-
// v4 (BUGFREE-2 / D6): `refuted` — the honest lane for a phantom finding, refuted against code with
|
|
48
|
-
// a MANDATORY non-empty note citing the grounds; never silently dropped, never folded. Exported as
|
|
49
|
-
// the code-side vocabulary source the doc-parity lint (BUGFREE-3 / AD-049) checks the contract docs
|
|
50
|
-
// against — so the mode files can never drift from the schema's own class/scope lexicon.
|
|
51
|
-
export const V4_CLASSES = new Set([...CLASSES, 'refuted']);
|
|
52
|
-
const OVERRIDE_SCOPES = new Set(['oracle-change', 'red-proof']);
|
|
53
|
-
// v4 (BUGFREE-2 / D4): `size-cap` — the recorded waiver for a changed surface beyond the diff cap;
|
|
54
|
-
// exact payload carries the sanctioned magnitude, and it is SEGMENT-scoped (loop + base), unlike
|
|
55
|
-
// the two loop-scoped v3 scopes.
|
|
56
|
-
export const V4_OVERRIDE_SCOPES = new Set([...OVERRIDE_SCOPES, 'size-cap']);
|
|
57
|
-
|
|
58
|
-
// ── git-dir resolution (read-only queries; the ledger lives in the git dir, uncommittable) ──────
|
|
59
|
-
|
|
60
|
-
const gitLine = (args, cwd) => {
|
|
61
|
-
const r = spawnSync('git', args, { cwd, windowsHide: true });
|
|
62
|
-
if (r.error || r.status !== 0) return null;
|
|
63
|
-
return r.stdout.toString('utf8').replace(/\r?\n$/, '');
|
|
64
|
-
};
|
|
65
|
-
|
|
66
|
-
// The ledger path: AW_REVIEW_LEDGER overrides (mirrors AW_REVIEW_RECEIPTS); else <git dir>/basename.
|
|
67
|
-
// null when the cwd is not a git work tree (no git dir to anchor to).
|
|
68
|
-
export const resolveLedgerPath = (cwd, env = process.env) => {
|
|
69
|
-
if (env.AW_REVIEW_LEDGER) return env.AW_REVIEW_LEDGER;
|
|
70
|
-
const gitDir = gitLine(['rev-parse', '--absolute-git-dir'], cwd);
|
|
71
|
-
return gitDir == null ? null : join(gitDir, LEDGER_BASENAME);
|
|
72
|
-
};
|
|
73
|
-
|
|
74
|
-
// ── the segment (BUGFREE-2 / AD-048, D1) ─────────────────────────────────────────────────────────
|
|
75
|
-
// A SEGMENT = (activity, loop, base) where base = the commit the dirty tree sits on. Derived, never
|
|
76
|
-
// declared: `git rev-parse HEAD` is computed identically at write time and check time, matches the
|
|
77
|
-
// review's actual domain (the working-tree diff vs HEAD), and its reset is commit-gated — so
|
|
78
|
-
// resetting the round counter REQUIRES shipping a green, converged unit. An amend/rebase mid-loop
|
|
79
|
-
// orphans the segment's rounds — correct: the reviewed tree no longer exists.
|
|
80
|
-
|
|
81
|
-
// resolveBase(cwd) → the current HEAD commit sha, or null on an unborn branch / outside a git work
|
|
82
|
-
// tree (a caught refusal from git, never a crash — agy R1).
|
|
83
|
-
export const resolveBase = (cwd) => gitLine(['rev-parse', '--verify', '--quiet', 'HEAD'], cwd);
|
|
84
|
-
|
|
85
|
-
// ── schema validation (tolerant reader counts + surfaces malformed lines, never drops silently) ──
|
|
86
|
-
|
|
87
|
-
const isPlainObject = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
|
|
88
|
-
const isNonEmptyString = (v) => typeof v === 'string' && v.length > 0;
|
|
89
|
-
const isNonNegInt = (v) => Number.isInteger(v) && v >= 0;
|
|
90
|
-
|
|
91
|
-
// testId FORMAT (Decision 3): "<repo-relative test file>#<test-name-pattern>" — a "#" separator with
|
|
92
|
-
// BOTH halves non-empty. NO file-suffix rule: a suffix check would itself be a special case and would
|
|
93
|
-
// block a consumer's own naming (e.g. `.spec.js`; agy R1). The reader validates FORMAT only (it stays
|
|
94
|
-
// hermetic); the fold-completeness gate validates RESOLVABILITY via a bound-test probe run. Exported
|
|
95
|
-
// (with the splitter) as the single home of the format — the fold-completeness pair validates and
|
|
96
|
-
// splits testIds through THESE, so the format can never fork (BUGFREE-1 / AD-047).
|
|
97
|
-
const TESTID_SEPARATOR = '#';
|
|
98
|
-
export const isWellFormedTestId = (v) => {
|
|
99
|
-
if (typeof v !== 'string') return false;
|
|
100
|
-
const at = v.indexOf(TESTID_SEPARATOR);
|
|
101
|
-
return at > 0 && at < v.length - 1; // separator present, both halves non-empty
|
|
102
|
-
};
|
|
103
|
-
export const splitTestId = (v) => {
|
|
104
|
-
const at = v.indexOf(TESTID_SEPARATOR);
|
|
105
|
-
return { file: v.slice(0, at), pattern: v.slice(at + 1) };
|
|
106
|
-
};
|
|
107
|
-
|
|
108
|
-
// validateRound(obj) → { ok, reason }. Structural checks + the two internal-consistency invariants:
|
|
109
|
-
// the per-backend findings-by-severity equal that backend's counts, and the origins tally equals the
|
|
110
|
-
// aggregation of findings[].origin.
|
|
111
|
-
const validateRound = (obj) => {
|
|
112
|
-
if (!isPlainObject(obj.origins)) return { ok: false, reason: 'round: missing origins object' };
|
|
113
|
-
for (const k of ORIGINS) if (!isNonNegInt(obj.origins[k])) return { ok: false, reason: `round: origins.${k} must be a non-negative integer` };
|
|
114
|
-
if (!Array.isArray(obj.backends) || obj.backends.length === 0) return { ok: false, reason: 'round: backends must be a non-empty array' };
|
|
115
|
-
for (const b of obj.backends) {
|
|
116
|
-
if (!isPlainObject(b) || !isNonEmptyString(b.backend)) return { ok: false, reason: 'round: each backend needs a backend name' };
|
|
117
|
-
if (typeof b.degraded !== 'boolean') return { ok: false, reason: `round: backend ${b.backend} missing boolean degraded` };
|
|
118
|
-
if (!isNonNegInt(b.blockers) || !isNonNegInt(b.majors) || !isNonNegInt(b.minors)) return { ok: false, reason: `round: backend ${b.backend} counts must be non-negative integers` };
|
|
119
|
-
if (!isNonEmptyString(b.verdict)) return { ok: false, reason: `round: backend ${b.backend} missing verdict` };
|
|
120
|
-
// A degraded backend ran no real review: it MUST carry a reason, record 0/0/0 counts, and its
|
|
121
|
-
// verdict is exactly "degraded". Without this a degraded row could carry a blocking finding while
|
|
122
|
-
// convergence (which excludes degraded backends) still passes — a hidden-blocker hole (codex R1).
|
|
123
|
-
if (b.degraded === true) {
|
|
124
|
-
if (!isNonEmptyString(b.reason)) return { ok: false, reason: `round: degraded backend ${b.backend} must carry a reason` };
|
|
125
|
-
if (b.blockers !== 0 || b.majors !== 0 || b.minors !== 0) return { ok: false, reason: `round: degraded backend ${b.backend} must record 0/0/0 counts (it ran no real review)` };
|
|
126
|
-
if (b.verdict !== 'degraded') return { ok: false, reason: `round: degraded backend ${b.backend} verdict must be "degraded"` };
|
|
127
|
-
}
|
|
128
|
-
}
|
|
129
|
-
// Duplicate backend names would make entryFor / the per-backend consistency ambiguous (agy R1).
|
|
130
|
-
if (new Set(obj.backends.map((b) => b.backend)).size !== obj.backends.length) return { ok: false, reason: 'round: duplicate backend name in backends[]' };
|
|
131
|
-
if (!Array.isArray(obj.findings)) return { ok: false, reason: 'round: findings must be an array' };
|
|
132
|
-
const backendSet = new Set(obj.backends.map((b) => b.backend));
|
|
133
|
-
const degradedSet = new Set(obj.backends.filter((b) => b.degraded === true).map((b) => b.backend));
|
|
134
|
-
for (const f of obj.findings) {
|
|
135
|
-
if (!isPlainObject(f) || !isNonEmptyString(f.findingKey)) return { ok: false, reason: 'round: each finding needs a findingKey' };
|
|
136
|
-
if (!SEVERITIES.has(f.severity)) return { ok: false, reason: `round: finding ${f.findingKey} bad severity "${f.severity}"` };
|
|
137
|
-
if (!ORIGINS.includes(f.origin)) return { ok: false, reason: `round: finding ${f.findingKey} bad origin "${f.origin}"` };
|
|
138
|
-
if (!isNonEmptyString(f.backend)) return { ok: false, reason: `round: finding ${f.findingKey} missing backend` };
|
|
139
|
-
if (!backendSet.has(f.backend)) return { ok: false, reason: `round: finding ${f.findingKey} backend "${f.backend}" is not in backends[]` };
|
|
140
|
-
if (degradedSet.has(f.backend)) return { ok: false, reason: `round: finding ${f.findingKey} references degraded backend ${f.backend} (a degraded backend ran no review, mints no finding)` };
|
|
141
|
-
}
|
|
142
|
-
// Internal consistency: per-backend findings-by-severity equal the recorded counts.
|
|
143
|
-
for (const b of obj.backends) {
|
|
144
|
-
const own = { blocker: 0, major: 0, minor: 0 };
|
|
145
|
-
for (const f of obj.findings) if (f.backend === b.backend) own[f.severity] += 1;
|
|
146
|
-
if (own.blocker !== b.blockers || own.major !== b.majors || own.minor !== b.minors) {
|
|
147
|
-
return { ok: false, reason: `round: findings-vs-counts mismatch for ${b.backend} (findings ${own.blocker}/${own.major}/${own.minor} ≠ counts ${b.blockers}/${b.majors}/${b.minors})` };
|
|
148
|
-
}
|
|
149
|
-
}
|
|
150
|
-
// Internal consistency: the origins tally equals the aggregation of findings[].origin.
|
|
151
|
-
const tally = { 'first-draft': 0, 'fold-induced': 0, mechanics: 0 };
|
|
152
|
-
for (const f of obj.findings) tally[f.origin] += 1;
|
|
153
|
-
for (const k of ORIGINS) if (tally[k] !== obj.origins[k]) return { ok: false, reason: `round: origins-vs-findings mismatch for "${k}" (findings ${tally[k]} ≠ origins ${obj.origins[k]})` };
|
|
154
|
-
return { ok: true };
|
|
155
|
-
};
|
|
156
|
-
|
|
157
|
-
// validateTriage(obj, schema) → { ok, reason }. `schema` selects the per-version rules (v1 tolerant
|
|
158
|
-
// testId / v2 the test-per-fold binding / v4 the `refuted` class) — the shared structural checks
|
|
159
|
-
// run in every version.
|
|
160
|
-
const validateTriage = (obj, schema = SCHEMA_VERSION) => {
|
|
161
|
-
if (!Array.isArray(obj.classifications) || obj.classifications.length === 0) return { ok: false, reason: 'triage: classifications must be a non-empty array' };
|
|
162
|
-
const classes = schema >= 4 ? V4_CLASSES : CLASSES;
|
|
163
|
-
for (const c of obj.classifications) {
|
|
164
|
-
if (!isPlainObject(c) || !isNonEmptyString(c.findingKey)) return { ok: false, reason: 'triage: each classification needs a findingKey' };
|
|
165
|
-
if (!classes.has(c.class)) return { ok: false, reason: `triage: classification ${c.findingKey} bad class "${c.class}"` };
|
|
166
|
-
// D6 — `refuted` is the honest phantom-finding lane: the grounds are MANDATORY (a non-empty
|
|
167
|
-
// note citing what refutes it against code), never a silent drop.
|
|
168
|
-
if (c.class === 'refuted' && !isNonEmptyString(c.note)) return { ok: false, reason: `triage: classification ${c.findingKey} is refuted but carries no note — cite the grounds that refute it against code (mandatory)` };
|
|
169
|
-
if (typeof c.accepted !== 'boolean') return { ok: false, reason: `triage: classification ${c.findingKey} missing boolean accepted` };
|
|
170
|
-
// Structural (BOTH versions): testId is null/absent or a non-empty string — an ABSENT key is
|
|
171
|
-
// treated as null, never rejected here (agy R3). The writer normalizes it to null when stored.
|
|
172
|
-
if (!(c.testId === undefined || c.testId === null || isNonEmptyString(c.testId))) return { ok: false, reason: `triage: classification ${c.findingKey} testId must be null/absent or a non-empty string` };
|
|
173
|
-
// Schema v2 (M2/AD-046) — the test-per-fold binding: a fixable-bug MUST carry a testId (the
|
|
174
|
-
// red→green test that pins the fold), and ANY present testId must be well-formed. v1 keeps the
|
|
175
|
-
// AD-045 rule (testId optional, unenforced) so historical/live v1 ledgers never become malformed.
|
|
176
|
-
if (schema >= 2) {
|
|
177
|
-
const present = isNonEmptyString(c.testId);
|
|
178
|
-
if (c.class === 'fixable-bug' && !present) return { ok: false, reason: `triage: classification ${c.findingKey} is a fixable-bug but carries no testId — record the red→green test that pins the fold (write it first)` };
|
|
179
|
-
if (present && !isWellFormedTestId(c.testId)) return { ok: false, reason: `triage: classification ${c.findingKey} testId "${c.testId}" is malformed — expected "<test-file>#<test-name-pattern>" (a "#" separator, both halves non-empty)` };
|
|
180
|
-
}
|
|
181
|
-
if (typeof c.note !== 'string') return { ok: false, reason: `triage: classification ${c.findingKey} note must be a string` };
|
|
182
|
-
}
|
|
183
|
-
return { ok: true };
|
|
184
|
-
};
|
|
185
|
-
|
|
186
|
-
// validateOverride(obj, schema) → { ok, reason }. v3+ (BUGFREE-1 / AD-047, D3): scope `oracle-change`
|
|
187
|
-
// carries non-empty repo-relative files[] + reason; scope `red-proof` carries a REQUIRED
|
|
188
|
-
// well-formed testId + reason, no files[]. v4 (BUGFREE-2 / D4) adds scope `size-cap`: a REQUIRED
|
|
189
|
-
// positive-integer sanctionedLines — the exact magnitude the waiver sanctions, segment-scoped.
|
|
190
|
-
// Payloads are EXACT — a stray cross-scope field is a forgery smell, rejected. The fingerprint is
|
|
191
|
-
// recorded for audit only.
|
|
192
|
-
const OVERRIDE_SHARED_KEYS = new Set(['schema', 'loop', 'activity', 'kind', 'round', 'base', 'fingerprint', 'timestamp', 'scope', 'reason']);
|
|
193
|
-
const OVERRIDE_PAYLOAD_KEY = { 'oracle-change': 'files', 'red-proof': 'testId', 'size-cap': 'sanctionedLines' };
|
|
194
|
-
const validateOverride = (obj, schema = SCHEMA_VERSION) => {
|
|
195
|
-
const scopes = schema >= 4 ? V4_OVERRIDE_SCOPES : OVERRIDE_SCOPES;
|
|
196
|
-
if (!scopes.has(obj.scope)) return { ok: false, reason: `override: bad scope "${obj.scope}" (expected ${[...scopes].join(' | ')})` };
|
|
197
|
-
// EXACT per-scope payloads via an allow-list (codex R5): a stray key — a cross-scope field or an
|
|
198
|
-
// arbitrary hand-added one — is a forgery smell, rejected by name (the mutation-shape precedent).
|
|
199
|
-
const payloadKey = OVERRIDE_PAYLOAD_KEY[obj.scope];
|
|
200
|
-
for (const k of Object.keys(obj)) {
|
|
201
|
-
if (!OVERRIDE_SHARED_KEYS.has(k) && k !== payloadKey) return { ok: false, reason: `override: unknown key "${k}" (exact per-scope payloads: shared frame + ${payloadKey})` };
|
|
202
|
-
}
|
|
203
|
-
if (!isNonEmptyString(obj.reason)) return { ok: false, reason: 'override: a non-empty reason is required (never a silent waiver)' };
|
|
204
|
-
if (obj.scope === 'oracle-change') {
|
|
205
|
-
if (!Array.isArray(obj.files) || obj.files.length === 0) return { ok: false, reason: 'override: oracle-change files[] must be a non-empty array' };
|
|
206
|
-
for (const f of obj.files) {
|
|
207
|
-
if (!isNonEmptyString(f) || f.startsWith('/') || /^[a-zA-Z]:[\\/]/.test(f)) return { ok: false, reason: `override: files[] entries must be non-empty repo-relative paths (got ${JSON.stringify(f)})` };
|
|
208
|
-
}
|
|
209
|
-
return { ok: true };
|
|
210
|
-
}
|
|
211
|
-
if (obj.scope === 'size-cap') {
|
|
212
|
-
if (!(Number.isInteger(obj.sanctionedLines) && obj.sanctionedLines >= 1)) return { ok: false, reason: 'override: a size-cap override requires sanctionedLines — the exact positive-integer magnitude it sanctions' };
|
|
213
|
-
return { ok: true };
|
|
214
|
-
}
|
|
215
|
-
if (!isWellFormedTestId(obj.testId)) return { ok: false, reason: 'override: a red-proof override requires a well-formed testId "<test-file>#<test-name-pattern>"' };
|
|
216
|
-
return { ok: true };
|
|
217
|
-
};
|
|
218
|
-
|
|
219
|
-
// ── the gate-run record (BUGFREE-2 / D5): the green-baseline receipt run-gates --record mints ────
|
|
220
|
-
|
|
221
|
-
// Per-kind frame rule: a gate-run carries the SEGMENT frame (loop, base, fingerprint before/after,
|
|
222
|
-
// timestamp) and NO round number. The body is machine-composed by recordGateRun, so the key set is
|
|
223
|
-
// EXACT (the override allow-list precedent): declared[] = the FULL declaration at run time (id +
|
|
224
|
-
// cmd — the cmd is what the process-gate classification reads); results[] = what actually ran (a
|
|
225
|
-
// --only subset records exactly that subset, honestly); summary mirrors the runner's machine
|
|
226
|
-
// summary line, consistency-checked against results[] so a forged verdict cannot ride beside
|
|
227
|
-
// honest-looking evidence.
|
|
228
|
-
const GATE_RUN_KEYS = new Set(['schema', 'loop', 'activity', 'kind', 'base', 'fingerprint', 'fingerprintAfter', 'declared', 'results', 'summary', 'timestamp']);
|
|
229
|
-
const SUMMARY_KEYS = ['failed', 'failedIds', 'gates', 'passed', 'status'];
|
|
230
|
-
// Gate ids are kebab-case — the same closed shape run-gates.mjs enforces on the declaration; it
|
|
231
|
-
// also kills the comma-aliasing class in the failedIds compare (internal sweep).
|
|
232
|
-
const GATE_RUN_ID_RE = /^[a-z0-9]+(-[a-z0-9]+)*$/;
|
|
233
|
-
const validateGateRun = (obj) => {
|
|
234
|
-
for (const k of Object.keys(obj)) {
|
|
235
|
-
if (!GATE_RUN_KEYS.has(k)) return { ok: false, reason: `gate-run: unknown key "${k}" (exact machine-composed payload)` };
|
|
236
|
-
}
|
|
237
|
-
if (!(obj.fingerprintAfter === null || isNonEmptyString(obj.fingerprintAfter))) return { ok: false, reason: 'gate-run: fingerprintAfter must be null or a non-empty string (the post-run tree)' };
|
|
238
|
-
if (!Array.isArray(obj.declared) || obj.declared.length === 0) return { ok: false, reason: 'gate-run: declared must be a non-empty array of { id, cmd }' };
|
|
239
|
-
const declaredIds = new Set();
|
|
240
|
-
for (const d of obj.declared) {
|
|
241
|
-
if (!isPlainObject(d) || !isNonEmptyString(d.id) || !isNonEmptyString(d.cmd) || Object.keys(d).length !== 2) return { ok: false, reason: 'gate-run: each declared entry must be exactly { id, cmd } (non-empty strings)' };
|
|
242
|
-
if (!GATE_RUN_ID_RE.test(d.id)) return { ok: false, reason: `gate-run: id "${d.id}" must be kebab-case (the run-gates declaration shape)` };
|
|
243
|
-
if (declaredIds.has(d.id)) return { ok: false, reason: `gate-run: duplicate declared id "${d.id}"` };
|
|
244
|
-
declaredIds.add(d.id);
|
|
245
|
-
}
|
|
246
|
-
if (!Array.isArray(obj.results) || obj.results.length === 0) return { ok: false, reason: 'gate-run: results must be a non-empty array of { id, ok, code }' };
|
|
247
|
-
const resultIds = new Set();
|
|
248
|
-
for (const r of obj.results) {
|
|
249
|
-
if (!isPlainObject(r) || !isNonEmptyString(r.id) || typeof r.ok !== 'boolean' || !Number.isInteger(r.code) || Object.keys(r).length !== 3) return { ok: false, reason: 'gate-run: each result must be exactly { id, ok, code } (string, boolean, integer)' };
|
|
250
|
-
if (!declaredIds.has(r.id)) return { ok: false, reason: `gate-run: result id "${r.id}" is not in declared[]` };
|
|
251
|
-
if (resultIds.has(r.id)) return { ok: false, reason: `gate-run: duplicate result id "${r.id}"` };
|
|
252
|
-
resultIds.add(r.id);
|
|
253
|
-
}
|
|
254
|
-
const s = obj.summary;
|
|
255
|
-
if (!isPlainObject(s)) return { ok: false, reason: 'gate-run: summary must be an object' };
|
|
256
|
-
if (Object.keys(s).sort().join(',') !== SUMMARY_KEYS.join(',')) return { ok: false, reason: `gate-run: summary must carry exactly { ${SUMMARY_KEYS.join(', ')} }` };
|
|
257
|
-
const failing = obj.results.filter((r) => !r.ok);
|
|
258
|
-
// The status IS the verdict word: a valid gate-run always carries results, so the runner's
|
|
259
|
-
// vocabulary collapses to ok|fail here — tie it to the failing count (a forged "ok" beside red
|
|
260
|
-
// results must not validate; internal sweep).
|
|
261
|
-
const expectedStatus = failing.length === 0 ? 'ok' : 'fail';
|
|
262
|
-
if (s.status !== expectedStatus) return { ok: false, reason: `gate-run: summary.status must be "${expectedStatus}" for ${failing.length} failing result(s) (got ${JSON.stringify(s.status)})` };
|
|
263
|
-
if (s.gates !== obj.results.length || s.passed !== obj.results.length - failing.length || s.failed !== failing.length) {
|
|
264
|
-
return { ok: false, reason: `gate-run: summary counts do not match results (${s.gates}/${s.passed}/${s.failed} vs ${obj.results.length} results, ${failing.length} failing)` };
|
|
265
|
-
}
|
|
266
|
-
if (!Array.isArray(s.failedIds) || s.failedIds.length !== failing.length || !s.failedIds.every((id, i) => id === failing[i].id)) return { ok: false, reason: 'gate-run: summary.failedIds must equal the failing result ids, in results order' };
|
|
267
|
-
return { ok: true };
|
|
268
|
-
};
|
|
269
|
-
|
|
270
|
-
// validateRecord(obj) → { ok, reason }. The shared frame (schema/loop/activity/kind/round/base/
|
|
271
|
-
// fingerprint/timestamp) then the per-kind body. `reason` names the exact failed check so the
|
|
272
|
-
// malformed-line surface and the per-check named tests can assert it. Kind vocabulary is
|
|
273
|
-
// per-version: `override` exists only under schema >= 3, `gate-run` only under schema >= 4, and
|
|
274
|
-
// `base` is a v4-only frame field (older records never grow new kinds OR new fields — D2).
|
|
275
|
-
export const validateRecord = (obj) => {
|
|
276
|
-
if (!isPlainObject(obj)) return { ok: false, reason: 'not an object' };
|
|
277
|
-
if (!SUPPORTED_SCHEMAS.has(obj.schema)) return { ok: false, reason: `schema must be one of ${[...SUPPORTED_SCHEMAS].join(', ')}` };
|
|
278
|
-
if (!isNonEmptyString(obj.loop)) return { ok: false, reason: 'missing loop' };
|
|
279
|
-
if (!ACTIVITIES_SET.has(obj.activity)) return { ok: false, reason: `bad activity "${obj.activity}"` };
|
|
280
|
-
if (!KINDS_SET.has(obj.kind) && !(obj.schema >= 3 && obj.kind === 'override') && !(obj.schema >= 4 && obj.kind === 'gate-run')) return { ok: false, reason: `bad kind "${obj.kind}"` };
|
|
281
|
-
// The v4 SEGMENT frame (D1/D2): a v4 record REQUIRES base (null on an unborn branch, else the
|
|
282
|
-
// HEAD sha); a v1..v3 record must NOT carry it — an old record never grows new surface.
|
|
283
|
-
if (obj.schema >= 4) {
|
|
284
|
-
if (!(obj.base === null || isNonEmptyString(obj.base))) return { ok: false, reason: 'a v4 record requires base — null (unborn branch) or the HEAD commit the dirty tree sits on' };
|
|
285
|
-
} else if (obj.base !== undefined) {
|
|
286
|
-
return { ok: false, reason: `base is a v4 frame field — a schema-${obj.schema} record never carries it` };
|
|
287
|
-
}
|
|
288
|
-
// Per-kind frame (D5): a gate-run carries NO round number; every other kind requires one.
|
|
289
|
-
if (obj.kind === 'gate-run') {
|
|
290
|
-
if (obj.round !== undefined) return { ok: false, reason: 'gate-run: a gate-run carries no round number (it is segment-framed, not round-framed)' };
|
|
291
|
-
} else if (!(Number.isInteger(obj.round) && obj.round >= 1)) {
|
|
292
|
-
return { ok: false, reason: 'round must be an integer >= 1' };
|
|
293
|
-
}
|
|
294
|
-
if (!(obj.fingerprint === null || isNonEmptyString(obj.fingerprint))) return { ok: false, reason: 'fingerprint must be null or a non-empty string' };
|
|
295
|
-
if (!isNonEmptyString(obj.timestamp)) return { ok: false, reason: 'missing timestamp' };
|
|
296
|
-
if (obj.kind === 'round') return validateRound(obj);
|
|
297
|
-
if (obj.kind === 'override') return validateOverride(obj, obj.schema);
|
|
298
|
-
if (obj.kind === 'gate-run') return validateGateRun(obj);
|
|
299
|
-
return validateTriage(obj, obj.schema);
|
|
300
|
-
};
|
|
301
|
-
|
|
302
|
-
// readLedger(path) → { records, malformed, malformedReasons }. Absent file → empty (no review ran).
|
|
303
|
-
// A malformed line is counted + its reason surfaced, never silently dropped (mirrors readReceipts).
|
|
304
|
-
export const readLedger = (path, readFile = readFileSync) => {
|
|
305
|
-
let raw;
|
|
306
|
-
try {
|
|
307
|
-
raw = readFile(path, 'utf8');
|
|
308
|
-
} catch (err) {
|
|
309
|
-
// An ABSENT file → empty (no review ran). A non-ENOENT read error (EACCES/EIO) is NOT "no records":
|
|
310
|
-
// treating it as empty would fail the teeth OPEN and could clobber the ledger on rewrite (codex R1).
|
|
311
|
-
// Surface it as a readError so every caller (the writer, the gate) fails CLOSED.
|
|
312
|
-
if (err && err.code === 'ENOENT') return { records: [], malformed: 0, malformedReasons: [] };
|
|
313
|
-
return { records: [], malformed: 0, malformedReasons: [], readError: (err && err.code) || (err && err.message) || 'read failed' };
|
|
314
|
-
}
|
|
315
|
-
const records = [];
|
|
316
|
-
const malformedReasons = [];
|
|
317
|
-
for (const line of raw.split('\n')) {
|
|
318
|
-
if (line.trim() === '') continue;
|
|
319
|
-
let parsed;
|
|
320
|
-
try {
|
|
321
|
-
parsed = JSON.parse(line);
|
|
322
|
-
} catch (err) {
|
|
323
|
-
malformedReasons.push(`unparseable JSON (${err.message})`);
|
|
324
|
-
continue;
|
|
325
|
-
}
|
|
326
|
-
const v = validateRecord(parsed);
|
|
327
|
-
if (v.ok) records.push(parsed);
|
|
328
|
-
else malformedReasons.push(v.reason);
|
|
329
|
-
}
|
|
330
|
-
return { records, malformed: malformedReasons.length, malformedReasons };
|
|
331
|
-
};
|
|
332
|
-
|
|
333
|
-
// filterLoopRecords(records, { activity, loop }) → the records of ONE loop (all kinds), order
|
|
334
|
-
// preserved. The gate filters to activity==="plan-execution" AND loop===the in-flight plan stem;
|
|
335
|
-
// authoring rounds (and other plans' rounds) never enter the code gate.
|
|
336
|
-
export const filterLoopRecords = (records, { activity, loop }) =>
|
|
337
|
-
records.filter((r) => r.activity === activity && r.loop === loop);
|
|
338
|
-
|
|
339
|
-
// filterSegmentRecords(records, { activity, loop, base }) → the records of ONE segment (D1), order
|
|
340
|
-
// preserved. STRICT: only a v4+ record can be a segment member (a v1..v3 record carries no base and
|
|
341
|
-
// never enters one — the D7 legacy rule; the schema guard also keeps a defensive undefined base
|
|
342
|
-
// from matching a pre-v4 record's absent field, codex R1); records at baseA are invisible at baseB;
|
|
343
|
-
// base === null matches only null (an unborn-branch segment).
|
|
344
|
-
export const filterSegmentRecords = (records, { activity, loop, base }) =>
|
|
345
|
-
filterLoopRecords(records, { activity, loop }).filter((r) => r.schema >= 4 && r.base === base);
|
|
346
|
-
|
|
347
|
-
// roundSequenceIntact(records) → true iff the round records, in file order, number exactly 1,2,…,n
|
|
348
|
-
// (no duplicate, gap, or out-of-order round). Checks the EXISTING sequence, not just the incoming
|
|
349
|
-
// round: a ledger like [2] / [1,1] / [2,1] (reachable only by hand-editing the git-dir file — the
|
|
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
|
-
|
|
425
|
-
export const roundSequenceIntact = (records) => {
|
|
426
|
-
const nums = records.filter((r) => r.kind === 'round').map((r) => r.round);
|
|
427
|
-
return nums.every((n, i) => n === i + 1);
|
|
428
|
-
};
|