@sabaiway/agent-workflow-kit 1.40.0 → 1.41.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 +33 -0
- package/SKILL.md +1 -1
- package/capability.json +1 -1
- package/package.json +1 -1
- package/references/modes/review-state.md +2 -2
- package/tools/review-ledger-core.mjs +354 -0
- package/tools/review-ledger.mjs +30 -329
- package/tools/review-state.mjs +103 -20
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,39 @@ Semantically versioned ([semver](https://semver.org)), newest first. The `versio
|
|
|
4
4
|
is the current release. `upgrade` mode reads a project's `docs/ai/.workflow-version` and applies
|
|
5
5
|
every `migrations/<version>-<slug>.md` newer than it, in semver order.
|
|
6
6
|
|
|
7
|
+
## 1.41.0 — review-state degraded lane: align the presence gate with the review-ledger (AD-050)
|
|
8
|
+
|
|
9
|
+
A **feature** release (kit-only; deployment-lineage head stays `1.3.0` — no migration;
|
|
10
|
+
memory/engine/bridges untouched). Closes the AD-049 residual: the family shipped two read-only review
|
|
11
|
+
gates that **disagreed** on the same tree when a ready backend genuinely can't review a diff.
|
|
12
|
+
`review-ledger --check` (convergence) already excused a recorded-degraded backend; `review-state
|
|
13
|
+
--check` (presence) had no degraded model, so a legitimately degraded agy (stalled → no receipt) read
|
|
14
|
+
missing/stale → exit 1. On BUGFREE-3 S2 the loop converged codex-only with agy recorded degraded, yet
|
|
15
|
+
review-ledger PASSED and review-state FAILED — a consumer wiring review-state into a pre-commit hook
|
|
16
|
+
was blocked on an honest degrade.
|
|
17
|
+
|
|
18
|
+
- **A neutral read-core (`review-ledger-core.mjs`)** — the validated review-ledger read/schema core
|
|
19
|
+
(path/base resolvers, `validateRecord` + its validators + schema constants, loop/segment filters)
|
|
20
|
+
moves VERBATIM into a new node-built-ins-only module both read-only checkers import; `review-ledger.mjs`
|
|
21
|
+
re-exports every symbol for back-compat. It breaks the `review-ledger ↔ review-state` import cycle so
|
|
22
|
+
review-state can read the ledger without a back-import (the `changed-surface.mjs` precedent). Pure
|
|
23
|
+
mechanical move — no behaviour change, pinned by the full pre-existing suite + import-split pins.
|
|
24
|
+
- **The `review-state` degraded exemption** — `review-state --check` now exempts a recipe-named backend
|
|
25
|
+
without a current grounded receipt when the in-flight segment's LATEST review-ledger round records it
|
|
26
|
+
`degraded:true` at the current tree fingerprint, with ≥1 non-degraded recipe-named backend present in
|
|
27
|
+
that round and grounded, the loop unambiguous, and the ledger clean. It **mirrors review-ledger's
|
|
28
|
+
`decideStop`** exactly (allPresent + presence) and stays **verdict-blind** — presence, not unanimity:
|
|
29
|
+
the two gates now AGREE on an honestly-converged-with-degrade tree, and still intentionally differ on
|
|
30
|
+
a non-converged one. `--await` inherits the exemption.
|
|
31
|
+
- **Fail-closed, exemption-scoped** — an unreadable/malformed ledger DENIES the exemption but never
|
|
32
|
+
fails a tree whose receipts independently satisfy the gate (all-current stays exit 0, the ledger issue
|
|
33
|
+
surfaced). More than one plan in flight suppresses the exemption without adding a fail-closed arm.
|
|
34
|
+
|
|
35
|
+
Contract surfaces moved in lockstep (the tool header, `--help`, the human render, the `--await`
|
|
36
|
+
comment, `references/modes/review-state.md`). Council-converged both segments (S1 codex + agy SHIP; S2
|
|
37
|
+
codex R1 revise → allPresent fold → R2 both SHIP), the two-gate agreement dogfooded live + pinned by a
|
|
38
|
+
detector-independent `two-gate-agreement.test.mjs` + a 17-case matrix; full suite 2969 green.
|
|
39
|
+
|
|
7
40
|
## 1.40.0 — Universal verification profile + session-loop economics (a)–(h) (BUGFREE-3, AD-049)
|
|
8
41
|
|
|
9
42
|
A **feature** release (deployment-lineage head stays `1.3.0` — no migration; co-released with
|
package/SKILL.md
CHANGED
|
@@ -3,7 +3,7 @@ name: agent-workflow-kit
|
|
|
3
3
|
description: Deploy or upgrade a portable AI-agent memory-and-workflow system in any project. Use when the user wants to bootstrap `docs/ai/` + an entry-point `AGENTS.md` (+ `CLAUDE.md` alias) + cap/archive/index enforcement in a new or existing repo, set up the Memory Map and session protocols, install the docs-rotation pre-commit hook, or run `/agent-workflow-kit` / `/agent-workflow-kit upgrade`. Triggers on phrases like "set up the memory system", "deploy the AI workflow here", "bootstrap docs/ai", "upgrade the workflow".
|
|
4
4
|
disable-model-invocation: true
|
|
5
5
|
metadata:
|
|
6
|
-
version: '1.
|
|
6
|
+
version: '1.41.0'
|
|
7
7
|
---
|
|
8
8
|
|
|
9
9
|
# agent-workflow-kit
|
package/capability.json
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sabaiway/agent-workflow-kit",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.41.0",
|
|
4
4
|
"description": "Portable, cross-agent memory & workflow for AI coding agents — Claude Code, Codex, Cursor, Devin Desktop. One command deploys an AGENTS.md entry point + docs/ai context with cap/archive/index enforcement into any repo.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"ai-agents",
|
|
@@ -5,9 +5,9 @@ Read-only **review-receipt checker** (AD-038) — it makes *"reviewed ≠ shippe
|
|
|
5
5
|
Run `node ${CLAUDE_SKILL_DIR}/tools/review-state.mjs [--check] [--json]`:
|
|
6
6
|
|
|
7
7
|
1. Plain run → the human report: resolved recipe + source, plan-in-flight, tree fingerprint, per-backend receipt state (current / stale / ungrounded / missing) with verdict + grounding + timestamp.
|
|
8
|
-
2. **`--check`** → the gate exit code. The **normative exit contract lives in the tool header** (the single home — do not re-enumerate it elsewhere): exit 0 for a solo-resolved recipe (configured, or degraded there — no ready reviewer), no plan in flight (the `docs/plans` naming convention: `queue.md` and `EXECUTE-`/`FEEDBACK-`-prefixed or `PROMPT`/`prompt`/`handoff`-carrying names are scratch), a clean tree, a non-git cwd, or every recipe-named backend receipted **current + grounded
|
|
8
|
+
2. **`--check`** → the gate exit code. The **normative exit contract lives in the tool header** (the single home — do not re-enumerate it elsewhere): exit 0 for a solo-resolved recipe (configured, or degraded there — no ready reviewer), no plan in flight (the `docs/plans` naming convention: `queue.md` and `EXECUTE-`/`FEEDBACK-`-prefixed or `PROMPT`/`prompt`/`handoff`-carrying names are scratch), a clean tree, a non-git cwd, or every recipe-named backend receipted **current + grounded** — **or degraded-exempt**: the current segment's latest **review-ledger** round records that backend `degraded:true` at the current tree fingerprint, with ≥1 non-degraded recipe-named backend present + grounded and the ledger reading clean (AD-050; a corrupt ledger DENIES the exemption fail-closed but never fails an otherwise-satisfied tree — the tool header is the single home). Exit 1 when a backend is missing, **stale** (ANY edit after its review moves the fingerprint), or grounded:false under reviewed/council, and is not degraded-exempt. **Presence, not unanimity:** verdict adjudication (ship/revise, the divergence crossover) stays orchestrator judgment — the gate only proves the configured backends really reviewed THIS tree (a recorded degrade counts as "reviewed", never as "converged" — that stays `review-ledger`'s job). Plan/diff receipts and continuations (`agy-review --continue`) are **informational-only**: after a fold, only a **fresh grounded re-run** (`codex-review code`; `agy-review code --facts @f`) restores green.
|
|
9
9
|
3. **Wire it as a gate by hand OR via the explicit-consent seeder — never without consent (AD-021/AD-042).** The candidate line for your own `docs/ai/gates.json`: `{ "id": "review-state", "title": "Review receipts current for the uncommitted tree", "cmd": "node <path-to-this-skill>/tools/review-state.mjs --check" }` — with the path your project actually reaches the kit by, QUOTED so a path with spaces survives, executable from the project root. The consent-gated seeder (`${CLAUDE_SKILL_DIR}/references/modes/gates.md`, consent-seed section) offers exactly this entry — path resolved and quoted — ONLY when your `docs/ai/orchestration.json` declares `reviewed`/`council` on `plan-execution.review` (the slot this checker enforces); it writes nothing without your explicit yes. Once declared, the opt-in `${CLAUDE_SKILL_DIR}/references/modes/hook.md` auto-approves it like any other declared gate.
|
|
10
|
-
4. **`--await [--timeout <s>]`** (BUGFREE-3 / AD-049) → BLOCK until every recipe-named backend
|
|
10
|
+
4. **`--await [--timeout <s>]`** (BUGFREE-3 / AD-049) → BLOCK until every recipe-named backend is SATISFIED for the current tree (i.e. until `--check` would PASS), or the bounded timeout elapses (a loud exit 1; default 900s). Run it after dispatching the review bridges to WAIT for their receipts to land instead of hand-polling a pid: the durable completion signal is the **receipt**, never a process event (a harness "completed" notification fires early; a bridge's output late-flushes). It waits until each backend has a fresh grounded receipt **OR is degraded-exempt**: once a current-tree degrade is recorded in the review-ledger, `--await` stops waiting for that backend and returns READY (AD-050 — it inherits the `--check` exemption via the shared decision), so you no longer hand-`--await` around a known degrade. Still read-only (it only re-reads state, now the ledger too); solo / no-plan / clean-tree resolve instantly.
|
|
11
11
|
|
|
12
12
|
**Human residual (stated, accepted):** `git commit --no-verify` and receipt-file deletion/forgery remain possible — this is a self-discipline mechanism against silent process drift, not a security boundary.
|
|
13
13
|
|
|
@@ -0,0 +1,354 @@
|
|
|
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
|
+
export const roundSequenceIntact = (records) => {
|
|
352
|
+
const nums = records.filter((r) => r.kind === 'round').map((r) => r.round);
|
|
353
|
+
return nums.every((n, i) => n === i + 1);
|
|
354
|
+
};
|
package/tools/review-ledger.mjs
CHANGED
|
@@ -50,8 +50,6 @@
|
|
|
50
50
|
// `git` queries (via the reused review-state fingerprint reader + a git-dir resolver). Dependency-
|
|
51
51
|
// free, Node >= 18. No side effects on import (the isDirectRun idiom).
|
|
52
52
|
|
|
53
|
-
import { readFileSync } from 'node:fs';
|
|
54
|
-
import { join } from 'node:path';
|
|
55
53
|
import { pathToFileURL } from 'node:url';
|
|
56
54
|
import { spawnSync } from 'node:child_process';
|
|
57
55
|
import { detectBackends } from './detect-backends.mjs';
|
|
@@ -71,50 +69,43 @@ import {
|
|
|
71
69
|
// module — never through fold-completeness.mjs, which imports THIS module (the import-cycle
|
|
72
70
|
// invariant, codex R2; an import-split test pins it). probeVerdict is the one D4 algebra home.
|
|
73
71
|
import { probeVerdict, resolveResultsPath, readJsonlRows } from './changed-surface.mjs';
|
|
72
|
+
// The NEUTRAL ledger read/schema core (AD-050): the validated read path — path/base resolvers,
|
|
73
|
+
// schema validator + reader, loop/segment filters — extracted so review-state.mjs can read the
|
|
74
|
+
// ledger for its degraded exemption WITHOUT importing this module back (the cycle). Imported here
|
|
75
|
+
// for internal use AND re-exported below so every existing importer (grounding, fold-completeness
|
|
76
|
+
// [-run], review-ledger-write, doc-parity, the tests) resolves unchanged.
|
|
77
|
+
import {
|
|
78
|
+
LEDGER_BASENAME,
|
|
79
|
+
ORIGINS,
|
|
80
|
+
resolveLedgerPath,
|
|
81
|
+
resolveBase,
|
|
82
|
+
readLedger,
|
|
83
|
+
filterLoopRecords,
|
|
84
|
+
filterSegmentRecords,
|
|
85
|
+
roundSequenceIntact,
|
|
86
|
+
} from './review-ledger-core.mjs';
|
|
87
|
+
export {
|
|
88
|
+
LEDGER_BASENAME,
|
|
89
|
+
ORIGINS,
|
|
90
|
+
resolveLedgerPath,
|
|
91
|
+
resolveBase,
|
|
92
|
+
readLedger,
|
|
93
|
+
filterLoopRecords,
|
|
94
|
+
filterSegmentRecords,
|
|
95
|
+
roundSequenceIntact,
|
|
96
|
+
};
|
|
97
|
+
// Re-export-only (validated in the core, unused internally here): the schema/vocabulary + testId
|
|
98
|
+
// format the writer, doc-parity, and fold-completeness import through this module.
|
|
99
|
+
export { SCHEMA_VERSION, V4_CLASSES, V4_OVERRIDE_SCOPES, isWellFormedTestId, splitTestId, validateRecord } from './review-ledger-core.mjs';
|
|
74
100
|
|
|
75
|
-
export const LEDGER_BASENAME = 'agent-workflow-review-ledger.jsonl';
|
|
76
101
|
const ACTIVITY = 'plan-execution';
|
|
77
102
|
const SLOT = 'review';
|
|
78
103
|
// The triage TRIGGER cap (Decision 5): reaching it with an unclassified surviving blocking finding
|
|
79
104
|
// forces triage. Shared with the writer (which imports it) — the writer-only hard-max ceiling lives
|
|
80
105
|
// there, never here (it is not a decideStop input).
|
|
81
106
|
export const REVIEW_CAP = 2;
|
|
82
|
-
|
|
83
|
-
//
|
|
84
|
-
// `override` record kind — the loud, durable waiver the fold-completeness gate consumes;
|
|
85
|
-
// BUGFREE-2/AD-048: v4 adds the SEGMENT — every record carries `base` = the commit the dirty tree
|
|
86
|
-
// sits on, and round numbering / caps / teeth / decideCheck all operate per (activity, loop, base) —
|
|
87
|
-
// plus the kind `gate-run` (the D5 green-baseline receipt run-gates --record mints), the override
|
|
88
|
-
// scope `size-cap` (the D4 diff-cap waiver), and the triage class `refuted` (the D6 honest lane for
|
|
89
|
-
// a phantom finding). The READER tolerates every SUPPORTED_SCHEMAS version under its own
|
|
90
|
-
// per-version rules, so historical/live v1..v3 ledgers never retroactively become malformed
|
|
91
|
-
// (Decision 2 — a malformed line cascades fail-closed refusals in the writer teeth AND the --check
|
|
92
|
-
// gate). v1 records keep the AD-045 rule (testId optional, unenforced); v2+ enforces the
|
|
93
|
-
// test-per-fold binding; ONLY v3+ may carry kind `override`; ONLY v4 may carry `base` / kind
|
|
94
|
-
// `gate-run` / scope `size-cap` / class `refuted` (older records never grow new surface).
|
|
95
|
-
// decideStop never reads testId, overrides, gate-runs, or base (not decideStop inputs — the caller
|
|
96
|
-
// passes ONE segment's records, exactly as it passes one loop's; D10: the truth table is untouched).
|
|
97
|
-
export const SCHEMA_VERSION = 4;
|
|
98
|
-
const SUPPORTED_SCHEMAS = new Set([1, 2, 3, 4]);
|
|
99
|
-
|
|
100
|
-
// The record vocabulary — the single home of every enum the schema validates.
|
|
101
|
-
const ACTIVITIES_SET = new Set(['plan-authoring', 'plan-execution']);
|
|
102
|
-
const KINDS_SET = new Set(['round', 'triage']);
|
|
103
|
-
const SEVERITIES = new Set(['blocker', 'major', 'minor']);
|
|
104
|
-
export const ORIGINS = ['first-draft', 'fold-induced', 'mechanics'];
|
|
105
|
-
const CLASSES = new Set(['fixable-bug', 'inherent-layer-residual', 'escalate']);
|
|
106
|
-
// v4 (BUGFREE-2 / D6): `refuted` — the honest lane for a phantom finding, refuted against code with
|
|
107
|
-
// a MANDATORY non-empty note citing the grounds; never silently dropped, never folded. Exported as
|
|
108
|
-
// the code-side vocabulary source the doc-parity lint (BUGFREE-3 / AD-049) checks the contract docs
|
|
109
|
-
// against — so the mode files can never drift from the schema's own class/scope lexicon.
|
|
110
|
-
export const V4_CLASSES = new Set([...CLASSES, 'refuted']);
|
|
111
|
-
const OVERRIDE_SCOPES = new Set(['oracle-change', 'red-proof']);
|
|
112
|
-
// v4 (BUGFREE-2 / D4): `size-cap` — the recorded waiver for a changed surface beyond the diff cap;
|
|
113
|
-
// exact payload carries the sanctioned magnitude, and it is SEGMENT-scoped (loop + base), unlike
|
|
114
|
-
// the two loop-scoped v3 scopes.
|
|
115
|
-
export const V4_OVERRIDE_SCOPES = new Set([...OVERRIDE_SCOPES, 'size-cap']);
|
|
116
|
-
|
|
117
|
-
// ── git-dir resolution (read-only queries; the ledger lives in the git dir, uncommittable) ──────
|
|
107
|
+
|
|
108
|
+
// ── git-dir resolution (read-only queries) ──────────────────────────────────────────────────────
|
|
118
109
|
|
|
119
110
|
const gitLine = (args, cwd) => {
|
|
120
111
|
const r = spawnSync('git', args, { cwd, windowsHide: true });
|
|
@@ -124,25 +115,6 @@ const gitLine = (args, cwd) => {
|
|
|
124
115
|
|
|
125
116
|
const gitRoot = (cwd) => gitLine(['rev-parse', '--show-toplevel'], cwd);
|
|
126
117
|
|
|
127
|
-
// The ledger path: AW_REVIEW_LEDGER overrides (mirrors AW_REVIEW_RECEIPTS); else <git dir>/basename.
|
|
128
|
-
// null when the cwd is not a git work tree (no git dir to anchor to).
|
|
129
|
-
export const resolveLedgerPath = (cwd, env = process.env) => {
|
|
130
|
-
if (env.AW_REVIEW_LEDGER) return env.AW_REVIEW_LEDGER;
|
|
131
|
-
const gitDir = gitLine(['rev-parse', '--absolute-git-dir'], cwd);
|
|
132
|
-
return gitDir == null ? null : join(gitDir, LEDGER_BASENAME);
|
|
133
|
-
};
|
|
134
|
-
|
|
135
|
-
// ── the segment (BUGFREE-2 / AD-048, D1) ─────────────────────────────────────────────────────────
|
|
136
|
-
// A SEGMENT = (activity, loop, base) where base = the commit the dirty tree sits on. Derived, never
|
|
137
|
-
// declared: `git rev-parse HEAD` is computed identically at write time and check time, matches the
|
|
138
|
-
// review's actual domain (the working-tree diff vs HEAD), and its reset is commit-gated — so
|
|
139
|
-
// resetting the round counter REQUIRES shipping a green, converged unit. An amend/rebase mid-loop
|
|
140
|
-
// orphans the segment's rounds — correct: the reviewed tree no longer exists.
|
|
141
|
-
|
|
142
|
-
// resolveBase(cwd) → the current HEAD commit sha, or null on an unborn branch / outside a git work
|
|
143
|
-
// tree (a caught refusal from git, never a crash — agy R1).
|
|
144
|
-
export const resolveBase = (cwd) => gitLine(['rev-parse', '--verify', '--quiet', 'HEAD'], cwd);
|
|
145
|
-
|
|
146
118
|
// ── ship-verdict mapping (the single home; a named test pins it) ────────────────────────────────
|
|
147
119
|
|
|
148
120
|
// isShipVerdict(verdict) — which free-text review verdicts are ship-class. SHIP / SHIP WITH NITS are
|
|
@@ -152,191 +124,6 @@ export const isShipVerdict = (verdict) => {
|
|
|
152
124
|
return v === 'ship' || v === 'ship with nits';
|
|
153
125
|
};
|
|
154
126
|
|
|
155
|
-
// ── schema validation (tolerant reader counts + surfaces malformed lines, never drops silently) ──
|
|
156
|
-
|
|
157
|
-
const isPlainObject = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
|
|
158
|
-
const isNonEmptyString = (v) => typeof v === 'string' && v.length > 0;
|
|
159
|
-
const isNonNegInt = (v) => Number.isInteger(v) && v >= 0;
|
|
160
|
-
|
|
161
|
-
// testId FORMAT (Decision 3): "<repo-relative test file>#<test-name-pattern>" — a "#" separator with
|
|
162
|
-
// BOTH halves non-empty. NO file-suffix rule: a suffix check would itself be a special case and would
|
|
163
|
-
// block a consumer's own naming (e.g. `.spec.js`; agy R1). The reader validates FORMAT only (it stays
|
|
164
|
-
// hermetic); the fold-completeness gate validates RESOLVABILITY via a bound-test probe run. Exported
|
|
165
|
-
// (with the splitter) as the single home of the format — the fold-completeness pair validates and
|
|
166
|
-
// splits testIds through THESE, so the format can never fork (BUGFREE-1 / AD-047).
|
|
167
|
-
const TESTID_SEPARATOR = '#';
|
|
168
|
-
export const isWellFormedTestId = (v) => {
|
|
169
|
-
if (typeof v !== 'string') return false;
|
|
170
|
-
const at = v.indexOf(TESTID_SEPARATOR);
|
|
171
|
-
return at > 0 && at < v.length - 1; // separator present, both halves non-empty
|
|
172
|
-
};
|
|
173
|
-
export const splitTestId = (v) => {
|
|
174
|
-
const at = v.indexOf(TESTID_SEPARATOR);
|
|
175
|
-
return { file: v.slice(0, at), pattern: v.slice(at + 1) };
|
|
176
|
-
};
|
|
177
|
-
|
|
178
|
-
// validateRound(obj) → { ok, reason }. Structural checks + the two internal-consistency invariants:
|
|
179
|
-
// the per-backend findings-by-severity equal that backend's counts, and the origins tally equals the
|
|
180
|
-
// aggregation of findings[].origin.
|
|
181
|
-
const validateRound = (obj) => {
|
|
182
|
-
if (!isPlainObject(obj.origins)) return { ok: false, reason: 'round: missing origins object' };
|
|
183
|
-
for (const k of ORIGINS) if (!isNonNegInt(obj.origins[k])) return { ok: false, reason: `round: origins.${k} must be a non-negative integer` };
|
|
184
|
-
if (!Array.isArray(obj.backends) || obj.backends.length === 0) return { ok: false, reason: 'round: backends must be a non-empty array' };
|
|
185
|
-
for (const b of obj.backends) {
|
|
186
|
-
if (!isPlainObject(b) || !isNonEmptyString(b.backend)) return { ok: false, reason: 'round: each backend needs a backend name' };
|
|
187
|
-
if (typeof b.degraded !== 'boolean') return { ok: false, reason: `round: backend ${b.backend} missing boolean degraded` };
|
|
188
|
-
if (!isNonNegInt(b.blockers) || !isNonNegInt(b.majors) || !isNonNegInt(b.minors)) return { ok: false, reason: `round: backend ${b.backend} counts must be non-negative integers` };
|
|
189
|
-
if (!isNonEmptyString(b.verdict)) return { ok: false, reason: `round: backend ${b.backend} missing verdict` };
|
|
190
|
-
// A degraded backend ran no real review: it MUST carry a reason, record 0/0/0 counts, and its
|
|
191
|
-
// verdict is exactly "degraded". Without this a degraded row could carry a blocking finding while
|
|
192
|
-
// convergence (which excludes degraded backends) still passes — a hidden-blocker hole (codex R1).
|
|
193
|
-
if (b.degraded === true) {
|
|
194
|
-
if (!isNonEmptyString(b.reason)) return { ok: false, reason: `round: degraded backend ${b.backend} must carry a reason` };
|
|
195
|
-
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)` };
|
|
196
|
-
if (b.verdict !== 'degraded') return { ok: false, reason: `round: degraded backend ${b.backend} verdict must be "degraded"` };
|
|
197
|
-
}
|
|
198
|
-
}
|
|
199
|
-
// Duplicate backend names would make entryFor / the per-backend consistency ambiguous (agy R1).
|
|
200
|
-
if (new Set(obj.backends.map((b) => b.backend)).size !== obj.backends.length) return { ok: false, reason: 'round: duplicate backend name in backends[]' };
|
|
201
|
-
if (!Array.isArray(obj.findings)) return { ok: false, reason: 'round: findings must be an array' };
|
|
202
|
-
const backendSet = new Set(obj.backends.map((b) => b.backend));
|
|
203
|
-
const degradedSet = new Set(obj.backends.filter((b) => b.degraded === true).map((b) => b.backend));
|
|
204
|
-
for (const f of obj.findings) {
|
|
205
|
-
if (!isPlainObject(f) || !isNonEmptyString(f.findingKey)) return { ok: false, reason: 'round: each finding needs a findingKey' };
|
|
206
|
-
if (!SEVERITIES.has(f.severity)) return { ok: false, reason: `round: finding ${f.findingKey} bad severity "${f.severity}"` };
|
|
207
|
-
if (!ORIGINS.includes(f.origin)) return { ok: false, reason: `round: finding ${f.findingKey} bad origin "${f.origin}"` };
|
|
208
|
-
if (!isNonEmptyString(f.backend)) return { ok: false, reason: `round: finding ${f.findingKey} missing backend` };
|
|
209
|
-
if (!backendSet.has(f.backend)) return { ok: false, reason: `round: finding ${f.findingKey} backend "${f.backend}" is not in backends[]` };
|
|
210
|
-
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)` };
|
|
211
|
-
}
|
|
212
|
-
// Internal consistency: per-backend findings-by-severity equal the recorded counts.
|
|
213
|
-
for (const b of obj.backends) {
|
|
214
|
-
const own = { blocker: 0, major: 0, minor: 0 };
|
|
215
|
-
for (const f of obj.findings) if (f.backend === b.backend) own[f.severity] += 1;
|
|
216
|
-
if (own.blocker !== b.blockers || own.major !== b.majors || own.minor !== b.minors) {
|
|
217
|
-
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})` };
|
|
218
|
-
}
|
|
219
|
-
}
|
|
220
|
-
// Internal consistency: the origins tally equals the aggregation of findings[].origin.
|
|
221
|
-
const tally = { 'first-draft': 0, 'fold-induced': 0, mechanics: 0 };
|
|
222
|
-
for (const f of obj.findings) tally[f.origin] += 1;
|
|
223
|
-
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]})` };
|
|
224
|
-
return { ok: true };
|
|
225
|
-
};
|
|
226
|
-
|
|
227
|
-
// validateTriage(obj, schema) → { ok, reason }. `schema` selects the per-version rules (v1 tolerant
|
|
228
|
-
// testId / v2 the test-per-fold binding / v4 the `refuted` class) — the shared structural checks
|
|
229
|
-
// run in every version.
|
|
230
|
-
const validateTriage = (obj, schema = SCHEMA_VERSION) => {
|
|
231
|
-
if (!Array.isArray(obj.classifications) || obj.classifications.length === 0) return { ok: false, reason: 'triage: classifications must be a non-empty array' };
|
|
232
|
-
const classes = schema >= 4 ? V4_CLASSES : CLASSES;
|
|
233
|
-
for (const c of obj.classifications) {
|
|
234
|
-
if (!isPlainObject(c) || !isNonEmptyString(c.findingKey)) return { ok: false, reason: 'triage: each classification needs a findingKey' };
|
|
235
|
-
if (!classes.has(c.class)) return { ok: false, reason: `triage: classification ${c.findingKey} bad class "${c.class}"` };
|
|
236
|
-
// D6 — `refuted` is the honest phantom-finding lane: the grounds are MANDATORY (a non-empty
|
|
237
|
-
// note citing what refutes it against code), never a silent drop.
|
|
238
|
-
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)` };
|
|
239
|
-
if (typeof c.accepted !== 'boolean') return { ok: false, reason: `triage: classification ${c.findingKey} missing boolean accepted` };
|
|
240
|
-
// Structural (BOTH versions): testId is null/absent or a non-empty string — an ABSENT key is
|
|
241
|
-
// treated as null, never rejected here (agy R3). The writer normalizes it to null when stored.
|
|
242
|
-
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` };
|
|
243
|
-
// Schema v2 (M2/AD-046) — the test-per-fold binding: a fixable-bug MUST carry a testId (the
|
|
244
|
-
// red→green test that pins the fold), and ANY present testId must be well-formed. v1 keeps the
|
|
245
|
-
// AD-045 rule (testId optional, unenforced) so historical/live v1 ledgers never become malformed.
|
|
246
|
-
if (schema >= 2) {
|
|
247
|
-
const present = isNonEmptyString(c.testId);
|
|
248
|
-
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)` };
|
|
249
|
-
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)` };
|
|
250
|
-
}
|
|
251
|
-
if (typeof c.note !== 'string') return { ok: false, reason: `triage: classification ${c.findingKey} note must be a string` };
|
|
252
|
-
}
|
|
253
|
-
return { ok: true };
|
|
254
|
-
};
|
|
255
|
-
|
|
256
|
-
// validateOverride(obj, schema) → { ok, reason }. v3+ (BUGFREE-1 / AD-047, D3): scope `oracle-change`
|
|
257
|
-
// carries non-empty repo-relative files[] + reason; scope `red-proof` carries a REQUIRED
|
|
258
|
-
// well-formed testId + reason, no files[]. v4 (BUGFREE-2 / D4) adds scope `size-cap`: a REQUIRED
|
|
259
|
-
// positive-integer sanctionedLines — the exact magnitude the waiver sanctions, segment-scoped.
|
|
260
|
-
// Payloads are EXACT — a stray cross-scope field is a forgery smell, rejected. The fingerprint is
|
|
261
|
-
// recorded for audit only.
|
|
262
|
-
const OVERRIDE_SHARED_KEYS = new Set(['schema', 'loop', 'activity', 'kind', 'round', 'base', 'fingerprint', 'timestamp', 'scope', 'reason']);
|
|
263
|
-
const OVERRIDE_PAYLOAD_KEY = { 'oracle-change': 'files', 'red-proof': 'testId', 'size-cap': 'sanctionedLines' };
|
|
264
|
-
const validateOverride = (obj, schema = SCHEMA_VERSION) => {
|
|
265
|
-
const scopes = schema >= 4 ? V4_OVERRIDE_SCOPES : OVERRIDE_SCOPES;
|
|
266
|
-
if (!scopes.has(obj.scope)) return { ok: false, reason: `override: bad scope "${obj.scope}" (expected ${[...scopes].join(' | ')})` };
|
|
267
|
-
// EXACT per-scope payloads via an allow-list (codex R5): a stray key — a cross-scope field or an
|
|
268
|
-
// arbitrary hand-added one — is a forgery smell, rejected by name (the mutation-shape precedent).
|
|
269
|
-
const payloadKey = OVERRIDE_PAYLOAD_KEY[obj.scope];
|
|
270
|
-
for (const k of Object.keys(obj)) {
|
|
271
|
-
if (!OVERRIDE_SHARED_KEYS.has(k) && k !== payloadKey) return { ok: false, reason: `override: unknown key "${k}" (exact per-scope payloads: shared frame + ${payloadKey})` };
|
|
272
|
-
}
|
|
273
|
-
if (!isNonEmptyString(obj.reason)) return { ok: false, reason: 'override: a non-empty reason is required (never a silent waiver)' };
|
|
274
|
-
if (obj.scope === 'oracle-change') {
|
|
275
|
-
if (!Array.isArray(obj.files) || obj.files.length === 0) return { ok: false, reason: 'override: oracle-change files[] must be a non-empty array' };
|
|
276
|
-
for (const f of obj.files) {
|
|
277
|
-
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)})` };
|
|
278
|
-
}
|
|
279
|
-
return { ok: true };
|
|
280
|
-
}
|
|
281
|
-
if (obj.scope === 'size-cap') {
|
|
282
|
-
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' };
|
|
283
|
-
return { ok: true };
|
|
284
|
-
}
|
|
285
|
-
if (!isWellFormedTestId(obj.testId)) return { ok: false, reason: 'override: a red-proof override requires a well-formed testId "<test-file>#<test-name-pattern>"' };
|
|
286
|
-
return { ok: true };
|
|
287
|
-
};
|
|
288
|
-
|
|
289
|
-
// ── the gate-run record (BUGFREE-2 / D5): the green-baseline receipt run-gates --record mints ────
|
|
290
|
-
|
|
291
|
-
// Per-kind frame rule: a gate-run carries the SEGMENT frame (loop, base, fingerprint before/after,
|
|
292
|
-
// timestamp) and NO round number. The body is machine-composed by recordGateRun, so the key set is
|
|
293
|
-
// EXACT (the override allow-list precedent): declared[] = the FULL declaration at run time (id +
|
|
294
|
-
// cmd — the cmd is what the process-gate classification reads); results[] = what actually ran (a
|
|
295
|
-
// --only subset records exactly that subset, honestly); summary mirrors the runner's machine
|
|
296
|
-
// summary line, consistency-checked against results[] so a forged verdict cannot ride beside
|
|
297
|
-
// honest-looking evidence.
|
|
298
|
-
const GATE_RUN_KEYS = new Set(['schema', 'loop', 'activity', 'kind', 'base', 'fingerprint', 'fingerprintAfter', 'declared', 'results', 'summary', 'timestamp']);
|
|
299
|
-
const SUMMARY_KEYS = ['failed', 'failedIds', 'gates', 'passed', 'status'];
|
|
300
|
-
// Gate ids are kebab-case — the same closed shape run-gates.mjs enforces on the declaration; it
|
|
301
|
-
// also kills the comma-aliasing class in the failedIds compare (internal sweep).
|
|
302
|
-
const GATE_RUN_ID_RE = /^[a-z0-9]+(-[a-z0-9]+)*$/;
|
|
303
|
-
const validateGateRun = (obj) => {
|
|
304
|
-
for (const k of Object.keys(obj)) {
|
|
305
|
-
if (!GATE_RUN_KEYS.has(k)) return { ok: false, reason: `gate-run: unknown key "${k}" (exact machine-composed payload)` };
|
|
306
|
-
}
|
|
307
|
-
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)' };
|
|
308
|
-
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 }' };
|
|
309
|
-
const declaredIds = new Set();
|
|
310
|
-
for (const d of obj.declared) {
|
|
311
|
-
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)' };
|
|
312
|
-
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)` };
|
|
313
|
-
if (declaredIds.has(d.id)) return { ok: false, reason: `gate-run: duplicate declared id "${d.id}"` };
|
|
314
|
-
declaredIds.add(d.id);
|
|
315
|
-
}
|
|
316
|
-
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 }' };
|
|
317
|
-
const resultIds = new Set();
|
|
318
|
-
for (const r of obj.results) {
|
|
319
|
-
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)' };
|
|
320
|
-
if (!declaredIds.has(r.id)) return { ok: false, reason: `gate-run: result id "${r.id}" is not in declared[]` };
|
|
321
|
-
if (resultIds.has(r.id)) return { ok: false, reason: `gate-run: duplicate result id "${r.id}"` };
|
|
322
|
-
resultIds.add(r.id);
|
|
323
|
-
}
|
|
324
|
-
const s = obj.summary;
|
|
325
|
-
if (!isPlainObject(s)) return { ok: false, reason: 'gate-run: summary must be an object' };
|
|
326
|
-
if (Object.keys(s).sort().join(',') !== SUMMARY_KEYS.join(',')) return { ok: false, reason: `gate-run: summary must carry exactly { ${SUMMARY_KEYS.join(', ')} }` };
|
|
327
|
-
const failing = obj.results.filter((r) => !r.ok);
|
|
328
|
-
// The status IS the verdict word: a valid gate-run always carries results, so the runner's
|
|
329
|
-
// vocabulary collapses to ok|fail here — tie it to the failing count (a forged "ok" beside red
|
|
330
|
-
// results must not validate; internal sweep).
|
|
331
|
-
const expectedStatus = failing.length === 0 ? 'ok' : 'fail';
|
|
332
|
-
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)})` };
|
|
333
|
-
if (s.gates !== obj.results.length || s.passed !== obj.results.length - failing.length || s.failed !== failing.length) {
|
|
334
|
-
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)` };
|
|
335
|
-
}
|
|
336
|
-
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' };
|
|
337
|
-
return { ok: true };
|
|
338
|
-
};
|
|
339
|
-
|
|
340
127
|
// isProcessGateCmd(cmd) — the CLOSED, kit-owned process-gate classification (D5): the kit's own
|
|
341
128
|
// process-loop `--check` commands (review-state / review-ledger / fold-completeness) legitimately
|
|
342
129
|
// fail MID-loop, so "quality-green" excludes them — without the carve-out the D5 tooth is
|
|
@@ -363,83 +150,6 @@ export const isQualityGreenGateRun = (record) => {
|
|
|
363
150
|
return record.declared.every((d) => isProcessGateCmd(d.cmd) || green.has(d.id));
|
|
364
151
|
};
|
|
365
152
|
|
|
366
|
-
// validateRecord(obj) → { ok, reason }. The shared frame (schema/loop/activity/kind/round/base/
|
|
367
|
-
// fingerprint/timestamp) then the per-kind body. `reason` names the exact failed check so the
|
|
368
|
-
// malformed-line surface and the per-check named tests can assert it. Kind vocabulary is
|
|
369
|
-
// per-version: `override` exists only under schema >= 3, `gate-run` only under schema >= 4, and
|
|
370
|
-
// `base` is a v4-only frame field (older records never grow new kinds OR new fields — D2).
|
|
371
|
-
export const validateRecord = (obj) => {
|
|
372
|
-
if (!isPlainObject(obj)) return { ok: false, reason: 'not an object' };
|
|
373
|
-
if (!SUPPORTED_SCHEMAS.has(obj.schema)) return { ok: false, reason: `schema must be one of ${[...SUPPORTED_SCHEMAS].join(', ')}` };
|
|
374
|
-
if (!isNonEmptyString(obj.loop)) return { ok: false, reason: 'missing loop' };
|
|
375
|
-
if (!ACTIVITIES_SET.has(obj.activity)) return { ok: false, reason: `bad activity "${obj.activity}"` };
|
|
376
|
-
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}"` };
|
|
377
|
-
// The v4 SEGMENT frame (D1/D2): a v4 record REQUIRES base (null on an unborn branch, else the
|
|
378
|
-
// HEAD sha); a v1..v3 record must NOT carry it — an old record never grows new surface.
|
|
379
|
-
if (obj.schema >= 4) {
|
|
380
|
-
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' };
|
|
381
|
-
} else if (obj.base !== undefined) {
|
|
382
|
-
return { ok: false, reason: `base is a v4 frame field — a schema-${obj.schema} record never carries it` };
|
|
383
|
-
}
|
|
384
|
-
// Per-kind frame (D5): a gate-run carries NO round number; every other kind requires one.
|
|
385
|
-
if (obj.kind === 'gate-run') {
|
|
386
|
-
if (obj.round !== undefined) return { ok: false, reason: 'gate-run: a gate-run carries no round number (it is segment-framed, not round-framed)' };
|
|
387
|
-
} else if (!(Number.isInteger(obj.round) && obj.round >= 1)) {
|
|
388
|
-
return { ok: false, reason: 'round must be an integer >= 1' };
|
|
389
|
-
}
|
|
390
|
-
if (!(obj.fingerprint === null || isNonEmptyString(obj.fingerprint))) return { ok: false, reason: 'fingerprint must be null or a non-empty string' };
|
|
391
|
-
if (!isNonEmptyString(obj.timestamp)) return { ok: false, reason: 'missing timestamp' };
|
|
392
|
-
if (obj.kind === 'round') return validateRound(obj);
|
|
393
|
-
if (obj.kind === 'override') return validateOverride(obj, obj.schema);
|
|
394
|
-
if (obj.kind === 'gate-run') return validateGateRun(obj);
|
|
395
|
-
return validateTriage(obj, obj.schema);
|
|
396
|
-
};
|
|
397
|
-
|
|
398
|
-
// readLedger(path) → { records, malformed, malformedReasons }. Absent file → empty (no review ran).
|
|
399
|
-
// A malformed line is counted + its reason surfaced, never silently dropped (mirrors readReceipts).
|
|
400
|
-
export const readLedger = (path, readFile = readFileSync) => {
|
|
401
|
-
let raw;
|
|
402
|
-
try {
|
|
403
|
-
raw = readFile(path, 'utf8');
|
|
404
|
-
} catch (err) {
|
|
405
|
-
// An ABSENT file → empty (no review ran). A non-ENOENT read error (EACCES/EIO) is NOT "no records":
|
|
406
|
-
// treating it as empty would fail the teeth OPEN and could clobber the ledger on rewrite (codex R1).
|
|
407
|
-
// Surface it as a readError so every caller (the writer, the gate) fails CLOSED.
|
|
408
|
-
if (err && err.code === 'ENOENT') return { records: [], malformed: 0, malformedReasons: [] };
|
|
409
|
-
return { records: [], malformed: 0, malformedReasons: [], readError: (err && err.code) || (err && err.message) || 'read failed' };
|
|
410
|
-
}
|
|
411
|
-
const records = [];
|
|
412
|
-
const malformedReasons = [];
|
|
413
|
-
for (const line of raw.split('\n')) {
|
|
414
|
-
if (line.trim() === '') continue;
|
|
415
|
-
let parsed;
|
|
416
|
-
try {
|
|
417
|
-
parsed = JSON.parse(line);
|
|
418
|
-
} catch (err) {
|
|
419
|
-
malformedReasons.push(`unparseable JSON (${err.message})`);
|
|
420
|
-
continue;
|
|
421
|
-
}
|
|
422
|
-
const v = validateRecord(parsed);
|
|
423
|
-
if (v.ok) records.push(parsed);
|
|
424
|
-
else malformedReasons.push(v.reason);
|
|
425
|
-
}
|
|
426
|
-
return { records, malformed: malformedReasons.length, malformedReasons };
|
|
427
|
-
};
|
|
428
|
-
|
|
429
|
-
// filterLoopRecords(records, { activity, loop }) → the records of ONE loop (all kinds), order
|
|
430
|
-
// preserved. The gate filters to activity==="plan-execution" AND loop===the in-flight plan stem;
|
|
431
|
-
// authoring rounds (and other plans' rounds) never enter the code gate.
|
|
432
|
-
export const filterLoopRecords = (records, { activity, loop }) =>
|
|
433
|
-
records.filter((r) => r.activity === activity && r.loop === loop);
|
|
434
|
-
|
|
435
|
-
// filterSegmentRecords(records, { activity, loop, base }) → the records of ONE segment (D1), order
|
|
436
|
-
// preserved. STRICT: only a v4+ record can be a segment member (a v1..v3 record carries no base and
|
|
437
|
-
// never enters one — the D7 legacy rule; the schema guard also keeps a defensive undefined base
|
|
438
|
-
// from matching a pre-v4 record's absent field, codex R1); records at baseA are invisible at baseB;
|
|
439
|
-
// base === null matches only null (an unborn-branch segment).
|
|
440
|
-
export const filterSegmentRecords = (records, { activity, loop, base }) =>
|
|
441
|
-
filterLoopRecords(records, { activity, loop }).filter((r) => r.schema >= 4 && r.base === base);
|
|
442
|
-
|
|
443
153
|
// collectOverrides(records, { activity, loop }) → { oracleChangeFiles: Set, redProofTestIds: Set }.
|
|
444
154
|
// The UNION of the loop's recorded v3-scope override payloads — loop + payload scoped, never
|
|
445
155
|
// fingerprint-bound (D3: re-affirmation churn on every later edit would train rubber-stamping). The
|
|
@@ -469,15 +179,6 @@ export const collectSizeCapLimit = (records, { activity = ACTIVITY, loop, base }
|
|
|
469
179
|
return limit;
|
|
470
180
|
};
|
|
471
181
|
|
|
472
|
-
// roundSequenceIntact(records) → true iff the round records, in file order, number exactly 1,2,…,n
|
|
473
|
-
// (no duplicate, gap, or out-of-order round). Checks the EXISTING sequence, not just the incoming
|
|
474
|
-
// round: a ledger like [2] / [1,1] / [2,1] (reachable only by hand-editing the git-dir file — the
|
|
475
|
-
// stated residual) must fail closed rather than be trusted to compute the "latest" round (codex R3).
|
|
476
|
-
export const roundSequenceIntact = (records) => {
|
|
477
|
-
const nums = records.filter((r) => r.kind === 'round').map((r) => r.round);
|
|
478
|
-
return nums.every((n, i) => n === i + 1);
|
|
479
|
-
};
|
|
480
|
-
|
|
481
182
|
// ── the computed crossover-stop (pure; machine fields only) ─────────────────────────────────────
|
|
482
183
|
|
|
483
184
|
const BLOCKING = new Set(['blocker', 'major']);
|
package/tools/review-state.mjs
CHANGED
|
@@ -15,11 +15,18 @@
|
|
|
15
15
|
// EXECUTE- / FEEDBACK-, or a name containing PROMPT / prompt / handoff); when the tree is
|
|
16
16
|
// clean (nothing to review); when the cwd is not a git work tree (nothing to fingerprint);
|
|
17
17
|
// and when EVERY recipe-named backend has a current-fingerprint receipt with acceptable
|
|
18
|
-
// grounding (fresh:true, artifact "code", grounded:true)
|
|
19
|
-
//
|
|
20
|
-
//
|
|
21
|
-
//
|
|
22
|
-
//
|
|
18
|
+
// grounding (fresh:true, artifact "code", grounded:true) OR is degraded-exempt: the current
|
|
19
|
+
// plan-execution SEGMENT's latest review-ledger round records that backend degraded:true at
|
|
20
|
+
// the current tree fingerprint, with >= 1 non-degraded recipe-named backend present with a
|
|
21
|
+
// current grounded receipt and the ledger reading clean (AD-050; MIRRORS review-ledger
|
|
22
|
+
// decideStop's degraded handling — presence, not unanimity, never a 0/0-counts gate).
|
|
23
|
+
// exit 1 when a recipe-named backend has no current-fingerprint receipt AND is not degraded-exempt —
|
|
24
|
+
// including the stale-after-edit case (any tracked/untracked change after the review moves the
|
|
25
|
+
// fingerprint) — or when its only current receipts carry grounded:false AND it is not
|
|
26
|
+
// degraded-exempt (an ungrounded agy review under reviewed/council never satisfies the gate on
|
|
27
|
+
// its own — but a recorded current-tree degrade still exempts it). An unreadable/malformed
|
|
28
|
+
// review-ledger DENIES the degraded exemption (fail-closed) but NEVER fails a tree whose
|
|
29
|
+
// receipts independently satisfy the gate (that stays exit 0, the ledger issue surfaced).
|
|
23
30
|
// Informational receipts NEVER satisfy (nor fail) the tree check: plan/diff-mode receipts
|
|
24
31
|
// (artifact ≠ "code") and continuations (fresh:false — agy --continue/--conversation cannot attest
|
|
25
32
|
// a folded tree; only a fresh grounded re-run mints a gate-satisfying receipt).
|
|
@@ -46,6 +53,9 @@ import { createHash } from 'node:crypto';
|
|
|
46
53
|
import { detectBackends } from './detect-backends.mjs';
|
|
47
54
|
import { resolveActivityRecipe, planRecipe, DISPLAY_ALIASES } from './recipes.mjs';
|
|
48
55
|
import { CONFIG_REL, fail, loadConfig } from './orchestration-config.mjs';
|
|
56
|
+
// The NEUTRAL ledger read-core (AD-050): review-state reads the review-ledger ONLY for the degraded
|
|
57
|
+
// exemption, through the neutral core — never review-ledger.mjs (which imports THIS module, the cycle).
|
|
58
|
+
import { resolveLedgerPath, resolveBase, readLedger, filterSegmentRecords, roundSequenceIntact } from './review-ledger-core.mjs';
|
|
49
59
|
|
|
50
60
|
export const RECEIPTS_BASENAME = 'agent-workflow-review-receipts.jsonl';
|
|
51
61
|
export const PLANS_REL = 'docs/plans';
|
|
@@ -236,6 +246,45 @@ export const backendReceiptStatus = (receipts, backend, fingerprint) => {
|
|
|
236
246
|
return { state: own.length > 0 ? 'stale' : 'missing', verdict: null, grounded: null, timestamp: null };
|
|
237
247
|
};
|
|
238
248
|
|
|
249
|
+
// ── the degraded exemption (AD-050): read the review-ledger for a recorded current-tree degrade ────
|
|
250
|
+
|
|
251
|
+
// degradedExemptSet(args) → the Set of recipe-named backends EXEMPT from --check because the current
|
|
252
|
+
// segment's LATEST round records them degraded at the current tree fingerprint. It MIRRORS review-ledger
|
|
253
|
+
// decideStop's degraded handling: a backend WITHOUT a current grounded code receipt is exempt IFF
|
|
254
|
+
// (i) exactly one plan is in flight (else the loop is ambiguous — the exempt set is empty, NO fail-closed
|
|
255
|
+
// exit-1 arm) AND the ledger reads clean (a readError / malformed line DENIES the exemption, fail-closed);
|
|
256
|
+
// (ii) the segment (activity=plan-execution, loop, base=resolveBase) has >=1 round with an intact
|
|
257
|
+
// sequence; (iii) its LATEST round records THAT backend degraded; (iv) that round's fingerprint equals
|
|
258
|
+
// the CURRENT tree (the degrade attests THIS tree); (v) >=1 NON-degraded recipe-named backend is present
|
|
259
|
+
// with a current grounded receipt (never everyone degraded). It is VERDICT-BLIND — it mirrors only the
|
|
260
|
+
// PRESENCE half of decideStop (nonDegradedReq >= 1), never its 0/0 counts (Decision 7).
|
|
261
|
+
export const degradedExemptSet = ({ records, readError, malformed, base, plans, currentFingerprint, requiredBackends, backends }) => {
|
|
262
|
+
const empty = new Set();
|
|
263
|
+
if (plans.length !== 1) return empty; // (i) ambiguous loop → exemption suppressed (no fail-closed exit-1 arm)
|
|
264
|
+
if (readError || malformed > 0) return empty; // fail-closed: a corrupt ledger denies the exemption
|
|
265
|
+
if (currentFingerprint == null) return empty;
|
|
266
|
+
const loop = plans[0].replace(/\.md$/, '');
|
|
267
|
+
const rounds = filterSegmentRecords(records, { activity: ACTIVITY, loop, base }).filter((r) => r.kind === 'round');
|
|
268
|
+
if (rounds.length === 0) return empty; // (ii) empty segment → nothing recorded yet
|
|
269
|
+
if (!roundSequenceIntact(rounds)) return empty; // (ii) corrupt sequence → fail closed
|
|
270
|
+
const latest = rounds[rounds.length - 1];
|
|
271
|
+
if (latest.fingerprint !== currentFingerprint) return empty; // (iv) the degrade must attest THIS tree
|
|
272
|
+
// Mirror decideStop's PRESENCE discipline (review-ledger.mjs): EVERY recipe-named backend must be IN
|
|
273
|
+
// the latest round (allPresent) — a backend absent from the round reviewed nothing there, so a stray
|
|
274
|
+
// current receipt for a NON-recorded backend can never justify the exemption (codex R1: else a
|
|
275
|
+
// degrade-only round `[{agy degraded}]` + any current codex receipt would exempt agy, disagreeing
|
|
276
|
+
// with review-ledger, whose decideStop fails allPresent on the absent codex).
|
|
277
|
+
const entryFor = (rb) => latest.backends.find((b) => b.backend === rb);
|
|
278
|
+
if (!requiredBackends.every((rb) => entryFor(rb) !== undefined)) return empty;
|
|
279
|
+
const receiptCurrent = new Set(backends.filter((b) => b.state === 'current').map((b) => b.backend));
|
|
280
|
+
// (v) >=1 non-degraded recipe-named backend PRESENT in the latest round with a current grounded
|
|
281
|
+
// receipt — never all degraded (mirrors decideStop's nonDegradedReq >= 1, plus review-state's own
|
|
282
|
+
// "it really reviewed" = a current receipt).
|
|
283
|
+
if (!requiredBackends.some((rb) => { const e = entryFor(rb); return e && !e.degraded && receiptCurrent.has(rb); })) return empty;
|
|
284
|
+
// (iii) exempt each recipe-named backend the latest round records degraded.
|
|
285
|
+
return new Set(requiredBackends.filter((rb) => { const e = entryFor(rb); return e && e.degraded === true; }));
|
|
286
|
+
};
|
|
287
|
+
|
|
239
288
|
// ── the check + report core ─────────────────────────────────────────────────────────
|
|
240
289
|
|
|
241
290
|
// buildState({ cwd, env, detect }) → everything both renders need. Pure I/O at the edges.
|
|
@@ -262,6 +311,13 @@ export const buildState = ({ cwd, env = process.env, detect = detectBackends } =
|
|
|
262
311
|
const receiptsPath = resolveReceiptsPath(cwd, env);
|
|
263
312
|
const { receipts, malformed } = receiptsPath ? readReceipts(receiptsPath) : { receipts: [], malformed: 0 };
|
|
264
313
|
const backends = requiredBackends.map((b) => ({ backend: b, ...backendReceiptStatus(receipts, b, fingerprint) }));
|
|
314
|
+
// The degraded exemption (AD-050): read the review-ledger ONLY here, ONLY for the exemption — the
|
|
315
|
+
// whole gate never depends on the ledger (a corrupt ledger fails the exemption CLOSED, never a tree
|
|
316
|
+
// whose receipts independently satisfy the gate; Decision 3). base/ledger locate the current segment.
|
|
317
|
+
const base = resolveBase(cwd);
|
|
318
|
+
const ledgerPath = resolveLedgerPath(cwd, env);
|
|
319
|
+
const { records, malformed: ledgerMalformed, readError: ledgerReadError } = ledgerPath ? readLedger(ledgerPath) : { records: [], malformed: 0 };
|
|
320
|
+
const degradedExempt = [...degradedExemptSet({ records, readError: ledgerReadError, malformed: ledgerMalformed, base, plans, currentFingerprint: fingerprint, requiredBackends, backends })];
|
|
265
321
|
return {
|
|
266
322
|
resolved,
|
|
267
323
|
configSource,
|
|
@@ -273,6 +329,11 @@ export const buildState = ({ cwd, env = process.env, detect = detectBackends } =
|
|
|
273
329
|
receiptsPath,
|
|
274
330
|
receiptCount: receipts.length,
|
|
275
331
|
malformed,
|
|
332
|
+
base,
|
|
333
|
+
ledgerPath,
|
|
334
|
+
ledgerMalformed: ledgerMalformed ?? 0,
|
|
335
|
+
ledgerReadError: ledgerReadError ?? null,
|
|
336
|
+
degradedExempt,
|
|
276
337
|
detectionWarning,
|
|
277
338
|
};
|
|
278
339
|
};
|
|
@@ -302,16 +363,31 @@ export const decideCheck = (state) => {
|
|
|
302
363
|
// cannot fail the gate by itself (a forged/corrupt line must not brick commits), but the check
|
|
303
364
|
// line always names it so a PASS over a partially-corrupt file is visible.
|
|
304
365
|
const malformedNote = state.malformed > 0 ? ` — ${state.malformed} malformed receipt line(s) ignored; inspect ${state.receiptsPath}` : '';
|
|
305
|
-
|
|
366
|
+
// The review-ledger is consulted ONLY for the degraded exemption; a corrupt ledger DENIES it
|
|
367
|
+
// (fail-closed) but never fails a tree the receipts independently satisfy — surfaced either way
|
|
368
|
+
// (No-silent-failures; Decision 3).
|
|
369
|
+
const ledgerNote = state.ledgerReadError
|
|
370
|
+
? ` — review ledger unreadable (${state.ledgerReadError}); degraded exemption unavailable (fail-closed) — inspect ${state.ledgerPath}`
|
|
371
|
+
: state.ledgerMalformed > 0
|
|
372
|
+
? ` — review ledger has ${state.ledgerMalformed} malformed line(s); degraded exemption unavailable (fail-closed) — inspect ${state.ledgerPath}`
|
|
373
|
+
: '';
|
|
374
|
+
// The degraded exemption (AD-050): a backend recorded degraded for the current tree is excluded from
|
|
375
|
+
// `failing` (it reviewed nothing to receipt — MIRRORS decideStop excluding a degraded backend). It
|
|
376
|
+
// stays verdict-blind: the exemption proves the degrade was RECORDED, never that the tree converged.
|
|
377
|
+
const exempt = new Set(state.degradedExempt);
|
|
378
|
+
const failing = state.backends.filter((b) => b.state !== 'current' && !exempt.has(b.backend));
|
|
306
379
|
if (failing.length === 0) {
|
|
307
|
-
|
|
380
|
+
if (exempt.size === 0) {
|
|
381
|
+
return { code: 0, reason: `every recipe-named backend has a fresh grounded receipt for the current tree (${state.requiredBackends.join(' + ')})${malformedNote}${ledgerNote}` };
|
|
382
|
+
}
|
|
383
|
+
return { code: 0, reason: `every recipe-named backend reviewed the current tree (${state.requiredBackends.join(' + ')}) — degraded-exempt (recorded degraded for the current tree in the review ledger): ${[...exempt].join(', ')}${malformedNote}${ledgerNote}` };
|
|
308
384
|
}
|
|
309
385
|
const parts = failing.map((b) => {
|
|
310
386
|
if (b.state === 'ungrounded') return `${b.backend}: only ungrounded receipts for the current tree — re-run grounded (--facts)`;
|
|
311
387
|
if (b.state === 'stale') return `${b.backend}: receipts exist but none matches the current tree (edited after review) — run a fresh review`;
|
|
312
388
|
return `${b.backend}: no receipt — run its review wrapper`;
|
|
313
389
|
});
|
|
314
|
-
return { code: 1, reason: `${parts.join('; ')}${malformedNote}` };
|
|
390
|
+
return { code: 1, reason: `${parts.join('; ')}${malformedNote}${ledgerNote}` };
|
|
315
391
|
};
|
|
316
392
|
|
|
317
393
|
// ── rendering ───────────────────────────────────────────────────────────────────────
|
|
@@ -329,7 +405,11 @@ const formatHuman = (state, check) => {
|
|
|
329
405
|
else if (state.clean === true) lines.push(' tree: clean (nothing to review)');
|
|
330
406
|
else lines.push(` tree fingerprint: ${state.fingerprint}`);
|
|
331
407
|
lines.push(` receipts: ${state.receiptsPath ?? '(unresolvable — no git dir)'} (${state.receiptCount} line(s)${state.malformed ? `, ${state.malformed} malformed — inspect the file` : ''})`);
|
|
408
|
+
if (state.ledgerReadError) lines.push(` ⚠ review ledger unreadable (${state.ledgerReadError}) — degraded exemption unavailable`);
|
|
409
|
+
else if (state.ledgerMalformed) lines.push(` ⚠ review ledger: ${state.ledgerMalformed} malformed line(s) — degraded exemption unavailable`);
|
|
410
|
+
const exempt = new Set(state.degradedExempt);
|
|
332
411
|
for (const b of state.backends) {
|
|
412
|
+
const exemptTag = exempt.has(b.backend) ? ' — degraded-exempt (recorded degraded in the review ledger for the current tree)' : '';
|
|
333
413
|
const detail =
|
|
334
414
|
b.state === 'current'
|
|
335
415
|
? `current (verdict: ${b.verdict}, grounded, ${b.timestamp ?? '?'})`
|
|
@@ -338,7 +418,7 @@ const formatHuman = (state, check) => {
|
|
|
338
418
|
: b.state === 'stale'
|
|
339
419
|
? 'stale — no receipt matches the current tree (edited after review)'
|
|
340
420
|
: 'missing — no receipt from this backend';
|
|
341
|
-
lines.push(` ${STATE_GLYPH[b.state]} ${b.backend}: ${detail}`);
|
|
421
|
+
lines.push(` ${exempt.has(b.backend) ? '⊘' : STATE_GLYPH[b.state]} ${b.backend}: ${detail}${exemptTag}`);
|
|
342
422
|
}
|
|
343
423
|
lines.push(` check: ${check.code === 0 ? 'PASS' : 'FAIL'} — ${check.reason}`);
|
|
344
424
|
return lines.join('\n');
|
|
@@ -357,9 +437,10 @@ presence + verdict + grounding for the CURRENT tree. Plan/diff-mode receipts and
|
|
|
357
437
|
(fresh:false) are informational-only — they never satisfy the tree check.
|
|
358
438
|
|
|
359
439
|
--check exits 0/1 per the normative contract in the tool header: 0 for solo / no plan in flight /
|
|
360
|
-
a clean tree / not-a-git-tree / all recipe-named backends receipted-current-and-grounded
|
|
361
|
-
|
|
362
|
-
|
|
440
|
+
a clean tree / not-a-git-tree / all recipe-named backends receipted-current-and-grounded OR
|
|
441
|
+
degraded-exempt (a recorded current-tree degrade in the review-ledger for that backend; AD-050); 1 when
|
|
442
|
+
a recipe-named backend is missing, stale (edited after review), or grounded:false under reviewed/council
|
|
443
|
+
AND is not degraded-exempt. Declare it as a project gate by hand (docs/ai/gates.json) or via the
|
|
363
444
|
explicit-consent seeder (tools/seed-gates.mjs) — never without consent.
|
|
364
445
|
|
|
365
446
|
Read-only: never writes, never commits, never runs a subscription CLI; spawns read-only git queries.
|
|
@@ -393,14 +474,16 @@ export const main = (argv, ctx = {}) => {
|
|
|
393
474
|
};
|
|
394
475
|
|
|
395
476
|
// ── --await: block until every recipe-named backend has receipted the current tree ─────
|
|
396
|
-
// (BUGFREE-3 / AD-049, item (d)). It waits for
|
|
397
|
-
//
|
|
398
|
-
//
|
|
399
|
-
//
|
|
400
|
-
//
|
|
401
|
-
//
|
|
402
|
-
//
|
|
403
|
-
//
|
|
477
|
+
// (BUGFREE-3 / AD-049, item (d)). It waits for every recipe-named backend to be SATISFIED — a fresh
|
|
478
|
+
// grounded current-tree receipt, OR (AD-050) the degraded exemption: once a current-tree degrade is
|
|
479
|
+
// RECORDED in the review-ledger, --await stops waiting for that backend and returns READY (before,
|
|
480
|
+
// it waited forever for a receipt that never comes). It inherits the exemption for FREE — it polls
|
|
481
|
+
// the SAME decideCheck(buildState()) `--check` computes. The completion signal is the RECEIPT (i.e.
|
|
482
|
+
// `--check` would PASS), NEVER a process event — a harness "completed" notification fires early and a
|
|
483
|
+
// bridge's output late-flushes, so polling a pid/receipt-file is the durable mechanization of
|
|
484
|
+
// receipts-not-pgrep. Stays read-only (it only re-reads state — now the ledger too, a few KB per
|
|
485
|
+
// tick); the clock is injectable (ctx.now / ctx.sleep / ctx.pollMs) so hermetic tests never spend
|
|
486
|
+
// wall-clock.
|
|
404
487
|
|
|
405
488
|
const AWAIT_ALLOWED_ARGS = new Set(['--await', '--timeout']);
|
|
406
489
|
|