@sabaiway/agent-workflow-kit 2.1.0 → 3.1.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.
Files changed (107) hide show
  1. package/CHANGELOG.md +94 -1
  2. package/README.md +7 -5
  3. package/SKILL.md +16 -8
  4. package/bin/install.mjs +11 -2
  5. package/bridges/antigravity-cli-bridge/SKILL.md +8 -1
  6. package/bridges/antigravity-cli-bridge/bin/agy-review-honesty.test.mjs +163 -0
  7. package/bridges/antigravity-cli-bridge/bin/agy-review-model-screen.test.mjs +76 -0
  8. package/bridges/antigravity-cli-bridge/bin/agy-review.sh +80 -15
  9. package/bridges/antigravity-cli-bridge/bin/agy-review.test.mjs +171 -22
  10. package/bridges/antigravity-cli-bridge/bin/agy.test.mjs +59 -41
  11. package/bridges/antigravity-cli-bridge/capability.json +3 -2
  12. package/bridges/codex-cli-bridge/SKILL.md +8 -1
  13. package/bridges/codex-cli-bridge/bin/codex-exec.test.mjs +67 -22
  14. package/bridges/codex-cli-bridge/bin/codex-review-honesty.test.mjs +156 -0
  15. package/bridges/codex-cli-bridge/bin/codex-review.sh +71 -10
  16. package/bridges/codex-cli-bridge/bin/codex-review.test.mjs +158 -27
  17. package/bridges/codex-cli-bridge/capability.json +3 -2
  18. package/capability.json +1 -1
  19. package/migrations/3.0.0-hardened-core-loop.md +44 -0
  20. package/migrations/README.md +1 -1
  21. package/package.json +2 -2
  22. package/references/hooks/gate-approve.mjs +1 -1
  23. package/references/modes/bootstrap.md +3 -3
  24. package/references/modes/commit-guard.md +19 -0
  25. package/references/modes/core-evidence.md +15 -0
  26. package/references/modes/coverage-check.md +12 -0
  27. package/references/modes/doc-parity.md +7 -6
  28. package/references/modes/gates.md +7 -6
  29. package/references/modes/grounding.md +1 -2
  30. package/references/modes/hook.md +1 -1
  31. package/references/modes/recommendations.md +1 -0
  32. package/references/modes/review-state.md +3 -3
  33. package/references/modes/upgrade.md +6 -6
  34. package/references/modes/velocity.md +2 -2
  35. package/references/modes/worktrees.md +120 -0
  36. package/references/scripts/archive-decisions.mjs +3 -3
  37. package/references/scripts/archive-decisions.test.mjs +5 -5
  38. package/references/scripts/check-docs-size-cli.test.mjs +41 -0
  39. package/references/scripts/check-docs-size.mjs +46 -29
  40. package/references/scripts/install-git-hooks-repo-exec.test.mjs +82 -0
  41. package/references/scripts/install-git-hooks.mjs +90 -18
  42. package/references/scripts/install-git-hooks.test.mjs +102 -0
  43. package/references/scripts/migrate-gates-branches.test.mjs +157 -0
  44. package/references/scripts/migrate-gates.mjs +395 -0
  45. package/references/scripts/migrate-gates.test.mjs +284 -0
  46. package/references/templates/agent_rules.md +2 -2
  47. package/tools/ack-write.mjs +1 -1
  48. package/tools/atomic-write.mjs +2 -2
  49. package/tools/autonomy-config.mjs +1 -1
  50. package/tools/autonomy-doctor.mjs +2 -2
  51. package/tools/autonomy-write.mjs +1 -1
  52. package/tools/bridge-settings-read.mjs +1 -1
  53. package/tools/bridge-settings.mjs +1 -1
  54. package/tools/changed-surface.mjs +16 -77
  55. package/tools/cheap-agents.mjs +2 -2
  56. package/tools/commands.mjs +26 -10
  57. package/tools/commit-guard.mjs +167 -0
  58. package/tools/core-evidence.mjs +914 -0
  59. package/tools/coverage-check.mjs +260 -0
  60. package/tools/delegation.mjs +1 -1
  61. package/tools/detect-backends.mjs +3 -3
  62. package/tools/doc-parity.mjs +11 -27
  63. package/tools/engine-source.mjs +1 -1
  64. package/tools/family-members.mjs +1 -1
  65. package/tools/family-registry.mjs +1 -1
  66. package/tools/fs-safe.mjs +1 -1
  67. package/tools/gate-hook.mjs +1 -1
  68. package/tools/{seed-gates.mjs → gates-init.mjs} +58 -52
  69. package/tools/grounding.mjs +19 -96
  70. package/tools/hide-footprint.mjs +1 -1
  71. package/tools/inject-methodology.mjs +72 -41
  72. package/tools/known-footprint.mjs +1 -1
  73. package/tools/labels.mjs +1 -1
  74. package/tools/lcov.mjs +6 -10
  75. package/tools/lens-region.mjs +1 -1
  76. package/tools/manifest/validate.mjs +19 -0
  77. package/tools/migrate-adr-store.mjs +5 -5
  78. package/tools/orchestration-config.mjs +1 -1
  79. package/tools/orchestration-write.mjs +1 -1
  80. package/tools/presentation.mjs +1 -1
  81. package/tools/procedures.mjs +3 -3
  82. package/tools/recipes.mjs +62 -11
  83. package/tools/recommendations.mjs +97 -14
  84. package/tools/release-scan.mjs +10 -3
  85. package/tools/renderers.mjs +1 -1
  86. package/tools/review-state.mjs +291 -343
  87. package/tools/run-gates.mjs +216 -92
  88. package/tools/sandbox-masks.mjs +14 -14
  89. package/tools/semver-lite.mjs +1 -1
  90. package/tools/set-autonomy.mjs +1 -1
  91. package/tools/set-recipe.mjs +2 -2
  92. package/tools/setup-backends.mjs +1 -1
  93. package/tools/surface.mjs +1 -1
  94. package/tools/uninstall.mjs +1 -1
  95. package/tools/velocity-profile.mjs +9 -9
  96. package/tools/view-model.mjs +1 -1
  97. package/tools/worktrees.mjs +2292 -0
  98. package/references/modes/fold-completeness.md +0 -30
  99. package/references/modes/review-ledger.md +0 -34
  100. package/references/templates/verification-profile.json +0 -10
  101. package/tools/fold-completeness-run.mjs +0 -1120
  102. package/tools/fold-completeness.mjs +0 -672
  103. package/tools/review-ledger-core.mjs +0 -428
  104. package/tools/review-ledger-write.mjs +0 -647
  105. package/tools/review-ledger.mjs +0 -630
  106. package/tools/sarif.mjs +0 -52
  107. 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
- };