@mmnto/cli 1.92.0 → 1.93.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/dist/commands/doctor-parity.d.ts.map +1 -1
- package/dist/commands/doctor-parity.js +23 -4
- package/dist/commands/doctor-parity.js.map +1 -1
- package/dist/commands/doctor-parity.test.js +135 -1
- package/dist/commands/doctor-parity.test.js.map +1 -1
- package/dist/commands/init-templates.d.ts +6 -2
- package/dist/commands/init-templates.d.ts.map +1 -1
- package/dist/commands/init-templates.js +63 -2
- package/dist/commands/init-templates.js.map +1 -1
- package/dist/commands/init.test.js +78 -3
- package/dist/commands/init.test.js.map +1 -1
- package/dist/commands/review-fan.d.ts +336 -0
- package/dist/commands/review-fan.d.ts.map +1 -0
- package/dist/commands/review-fan.js +1076 -0
- package/dist/commands/review-fan.js.map +1 -0
- package/dist/commands/review-fan.test.d.ts +2 -0
- package/dist/commands/review-fan.test.d.ts.map +1 -0
- package/dist/commands/review-fan.test.js +1184 -0
- package/dist/commands/review-fan.test.js.map +1 -0
- package/dist/commands/shield-covariate.test.d.ts +14 -0
- package/dist/commands/shield-covariate.test.d.ts.map +1 -0
- package/dist/commands/shield-covariate.test.js +84 -0
- package/dist/commands/shield-covariate.test.js.map +1 -0
- package/dist/commands/shield.d.ts +162 -3
- package/dist/commands/shield.d.ts.map +1 -1
- package/dist/commands/shield.js +342 -74
- package/dist/commands/shield.js.map +1 -1
- package/dist/commands/shield.test.js +169 -3
- package/dist/commands/shield.test.js.map +1 -1
- package/dist/git.d.ts +25 -0
- package/dist/git.d.ts.map +1 -1
- package/dist/git.js +50 -6
- package/dist/git.js.map +1 -1
- package/dist/git.test.js +119 -14
- package/dist/git.test.js.map +1 -1
- package/dist/index.js +11 -0
- package/dist/index.js.map +1 -1
- package/dist/orchestrators/orchestrator.d.ts +1 -0
- package/dist/orchestrators/orchestrator.d.ts.map +1 -1
- package/dist/orchestrators/orchestrator.js +1 -1
- package/dist/orchestrators/orchestrator.js.map +1 -1
- package/package.json +2 -2
|
@@ -0,0 +1,1076 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Multi-lane review fan, round chaining, predicates, and verdict emission
|
|
3
|
+
* (Prop 304 R2, mmnto-ai/totem#2106).
|
|
4
|
+
*
|
|
5
|
+
* This module owns everything that turns `review.lanes` into a Prop 302 verdict
|
|
6
|
+
* artifact: the config validator, the strict per-lane runner wrapper over
|
|
7
|
+
* `runOrchestrator`, the #2104 panel + #2103 post-check wiring, the round-chain
|
|
8
|
+
* lineage bookkeeping, the two derived predicates (`settled` and cache
|
|
9
|
+
* eligibility), and the verdict emission + report line. `shieldCommand` calls
|
|
10
|
+
* `runReviewFan` on the standard review path when lanes are configured; the
|
|
11
|
+
* legacy single-lane path is untouched (invariant 7).
|
|
12
|
+
*
|
|
13
|
+
* The whole loop state machine lives here in the CLI (Tenet 16): any agent
|
|
14
|
+
* driving `totem review` gets identical round-chaining/settle capability — the
|
|
15
|
+
* `review-loop` skill is a thin driver, never a state owner.
|
|
16
|
+
*
|
|
17
|
+
* ── TWO HASH DOMAINS (codex fold 1, load-bearing) ────────────────────────────
|
|
18
|
+
* `diffScope.diffHash` (the MASKED review-payload identity — what the lanes
|
|
19
|
+
* reviewed) and `.reviewed-content-hash` (the extension-scoped tracked-source
|
|
20
|
+
* hash that authorizes a push) bind DIFFERENT state and are never equal. The
|
|
21
|
+
* caller captures the content hash once PRE-fan; the fan re-hashes ONCE POST-fan,
|
|
22
|
+
* derives `reviewedState` from that single compare, and reuses it for BOTH the
|
|
23
|
+
* verdict field and the stamp decision (codex rev-2 fold 1). Drift ⇒
|
|
24
|
+
* `reviewedState='drifted'` ⇒ `settled=false` AND cache-ineligible; the fan
|
|
25
|
+
* stamps the pre-fan hash directly via `writeReviewedContentHashValue` (bypassing
|
|
26
|
+
* `stampReviewedContentHashIfTreeUnchanged`, whose recompute would be a second,
|
|
27
|
+
* divergent compare — the single-lane path still uses that helper).
|
|
28
|
+
*/
|
|
29
|
+
import { assertValidModelName, KNOWN_PROVIDERS, parseModelString, } from '../orchestrators/orchestrator.js';
|
|
30
|
+
import { computeReviewedContentHash, deriveLaneOutcome, extractStructuredVerdict, writeReviewedContentHashValue, } from './shield.js';
|
|
31
|
+
import { DISPLAY_TAG, MAX_DIFF_CHARS, TAG } from './shield-templates.js';
|
|
32
|
+
/**
|
|
33
|
+
* Round index at/above which the advisory max-rounds sensor line fires. A
|
|
34
|
+
* constant (never a config knob this slice) — advisory only, NEVER a block.
|
|
35
|
+
*/
|
|
36
|
+
export const MAX_ROUNDS_ADVISORY = 5;
|
|
37
|
+
// ─── Config validation (item 1) ─────────────────────────────────────────────
|
|
38
|
+
/**
|
|
39
|
+
* Validate a configured `review.lanes` array at review startup — a hard init
|
|
40
|
+
* error on any violation (Prop 304 R2 config boundary; codex fold 7). Reuses
|
|
41
|
+
* the CLI's `assertValidModelName` (shell-injection + leading-dash gate) and
|
|
42
|
+
* `parseModelString`, so a lane accepted here resolves identically at invoke.
|
|
43
|
+
*
|
|
44
|
+
* Rules (design item 1):
|
|
45
|
+
* - every entry must be a known `provider:model` (a `:`-prefixed known
|
|
46
|
+
* provider, or a bare model resolved against the base provider),
|
|
47
|
+
* - the `shell` provider is REJECTED (a review lane is an LLM lane, never a
|
|
48
|
+
* shell command),
|
|
49
|
+
* - empty / whitespace-only entries are rejected,
|
|
50
|
+
* - duplicate NORMALIZED (`provider:model`) entries are rejected.
|
|
51
|
+
*
|
|
52
|
+
* `baseProvider` is the configured orchestrator provider used to resolve a bare
|
|
53
|
+
* (prefix-less) lane; when absent, a bare lane is rejected (it has no provider
|
|
54
|
+
* to resolve against). ABSENT `lanes` returns `[]` (the legacy path runs).
|
|
55
|
+
*
|
|
56
|
+
* Returns the NORMALIZED (`provider:model`) lane list — the fan's laneIds and
|
|
57
|
+
* per-lane model routing use exactly these strings, so normalization has one
|
|
58
|
+
* home.
|
|
59
|
+
*/
|
|
60
|
+
export function validateReviewLanes(lanes, baseProvider,
|
|
61
|
+
// Rule 64: this module never statically imports core VALUES. The async caller
|
|
62
|
+
// (`shieldCommand`) imports the `TotemConfigError` class once and threads it in; we
|
|
63
|
+
// bind a plain factory closure and pass it down to the sync helpers (design item 1 —
|
|
64
|
+
// thread values rather than making every tiny helper async, so the throw-shaped tests
|
|
65
|
+
// stay synchronous).
|
|
66
|
+
TotemConfigError) {
|
|
67
|
+
if (lanes === undefined)
|
|
68
|
+
return [];
|
|
69
|
+
const mkErr = makeLaneConfigErrorFactory(TotemConfigError);
|
|
70
|
+
const seen = new Set();
|
|
71
|
+
const normalizedLanes = [];
|
|
72
|
+
for (const raw of lanes) {
|
|
73
|
+
const trimmed = typeof raw === 'string' ? raw.trim() : '';
|
|
74
|
+
if (trimmed.length === 0) {
|
|
75
|
+
throw mkErr(`review.lanes contains an empty or whitespace-only entry.`, 'Every lane must be a non-empty "provider:model" string, e.g. "anthropic:claude-sonnet-4".');
|
|
76
|
+
}
|
|
77
|
+
// Shell-safety gate (leading-dash + allow-list regex) — same gate the
|
|
78
|
+
// resolver applies, so a lane accepted here is accepted at invoke time.
|
|
79
|
+
assertValidModelName(trimmed);
|
|
80
|
+
const { provider, model } = resolveLaneProvider(trimmed, baseProvider, mkErr);
|
|
81
|
+
if (provider === 'shell') {
|
|
82
|
+
// Capability-admission framing (strategy-codex G2): a support-limit error naming
|
|
83
|
+
// the unsupported adapter — NOT an allowlist / "structurally ineligible" rejection.
|
|
84
|
+
throw mkErr(`review.lanes entry "${trimmed}" uses the 'shell' provider — an unsupported adapter for review fan lanes.`, 'Fan lanes are served by the LLM adapters (anthropic/gemini/openai/ollama); the shell adapter is not supported for review fan lanes. Use an LLM "provider:model".');
|
|
85
|
+
}
|
|
86
|
+
if (model.length === 0) {
|
|
87
|
+
throw mkErr(`review.lanes entry "${trimmed}" has an empty model portion.`, 'Provide a model name after the provider prefix, e.g. "gemini:gemini-2.5-flash-preview".');
|
|
88
|
+
}
|
|
89
|
+
const normalized = `${provider}:${model}`;
|
|
90
|
+
if (seen.has(normalized)) {
|
|
91
|
+
throw mkErr(`review.lanes has a duplicate lane "${normalized}" (normalized from "${trimmed}").`, 'Remove the duplicate — each fan lane must be a distinct provider:model.');
|
|
92
|
+
}
|
|
93
|
+
seen.add(normalized);
|
|
94
|
+
normalizedLanes.push(normalized);
|
|
95
|
+
}
|
|
96
|
+
return normalizedLanes;
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Reject fan-incompatible flags at review startup when the fan is active (finding 12).
|
|
100
|
+
* `--suppress`, `--learn`, and `--auto-capture` have NO defined fan semantics yet, so a
|
|
101
|
+
* fan-active run rejects them LOUDLY (naming the unsupported combination) rather than
|
|
102
|
+
* silently ignoring them. Note: `--raw` is diverted to the legacy zero-LLM path upstream
|
|
103
|
+
* (so the fan never activates with `--raw`; finding 1), and `--out` IS supported by the
|
|
104
|
+
* fan (it writes the human-readable fan report; finding 2).
|
|
105
|
+
*/
|
|
106
|
+
export function assertFanFlagsSupported(options,
|
|
107
|
+
// Threaded from `shieldCommand`'s single core import (rule 64) — this validator stays
|
|
108
|
+
// sync so its throw-shaped tests never become async.
|
|
109
|
+
TotemConfigError) {
|
|
110
|
+
const unsupported = [];
|
|
111
|
+
if (options.suppress !== undefined && options.suppress.length > 0)
|
|
112
|
+
unsupported.push('--suppress');
|
|
113
|
+
if (options.learn === true)
|
|
114
|
+
unsupported.push('--learn');
|
|
115
|
+
if (options.autoCapture === true)
|
|
116
|
+
unsupported.push('--auto-capture');
|
|
117
|
+
if (unsupported.length > 0) {
|
|
118
|
+
throw new TotemConfigError(`${unsupported.join(', ')} ${unsupported.length === 1 ? 'is' : 'are'} not supported with the multi-lane review fan (review.lanes configured).`, 'These flags have no defined fan semantics yet — drop them, or run a single-lane review (an explicit --model selects a one-lane invocation on the legacy path).', 'CONFIG_INVALID');
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* Resolve a lane string to its `{provider, model}`. A `:`-prefixed KNOWN
|
|
123
|
+
* provider splits out that provider (reuse of `parseModelString`'s semantics);
|
|
124
|
+
* a `:`-prefixed UNKNOWN provider is rejected here as an unknown provider (the
|
|
125
|
+
* design's "unknown provider rejected"), rather than silently folded into the
|
|
126
|
+
* model as `parseModelString` alone would. A prefix-less lane resolves against
|
|
127
|
+
* `baseProvider` (rejected when there is none).
|
|
128
|
+
*/
|
|
129
|
+
function resolveLaneProvider(trimmed, baseProvider, mkErr) {
|
|
130
|
+
const colonIdx = trimmed.indexOf(':');
|
|
131
|
+
if (colonIdx === 0) {
|
|
132
|
+
// A leading colon (`":claude-sonnet-4"` — a plausible typo for a
|
|
133
|
+
// provider:model entry) must fail at config parse, not fall through to the
|
|
134
|
+
// bare-model path and surface as a confusing provider API error later
|
|
135
|
+
// (PR #2337 greptile P2).
|
|
136
|
+
throw mkErr(`review.lanes entry "${trimmed}" starts with ":" — the provider portion is empty.`, `Name the provider explicitly, e.g. "anthropic${trimmed}".`);
|
|
137
|
+
}
|
|
138
|
+
if (colonIdx > 0) {
|
|
139
|
+
const prefix = trimmed.slice(0, colonIdx);
|
|
140
|
+
if (KNOWN_PROVIDERS.includes(prefix)) {
|
|
141
|
+
// Delegate to parseModelString so the split matches the resolver exactly
|
|
142
|
+
// (it splits on the FIRST colon — ollama quantized tags like `x:8b` stay
|
|
143
|
+
// in the model portion).
|
|
144
|
+
return parseModelString(trimmed, prefix);
|
|
145
|
+
}
|
|
146
|
+
// A `:`-prefixed entry whose prefix is not a known provider is an unknown
|
|
147
|
+
// provider, not a colon-bearing model name — reject it loud (a lane is an
|
|
148
|
+
// explicit provider:model; a typo'd provider must never route silently).
|
|
149
|
+
// Deliberately NOT folded into the base provider: a typo'd provider
|
|
150
|
+
// (`anthropc:x`) must never route silently to whatever base happens to be
|
|
151
|
+
// configured. A model name that itself contains ':' (ollama quantization
|
|
152
|
+
// tags) is supported via an explicit provider prefix — the split is on the
|
|
153
|
+
// FIRST colon, so `ollama:llama3:8b` parses as provider=ollama,
|
|
154
|
+
// model=llama3:8b. The hint teaches that spelling (PR #2337 GCA round 2).
|
|
155
|
+
throw mkErr(`review.lanes entry "${trimmed}" names an unknown provider "${prefix}".`, `Use one of the known providers (${KNOWN_PROVIDERS.filter((p) => p !== 'shell').join(', ')}), e.g. "anthropic:claude-sonnet-4". If the model name itself contains ":" (e.g. an ollama quantization tag), spell the provider explicitly: "ollama:llama3:8b".`);
|
|
156
|
+
}
|
|
157
|
+
// Bare (prefix-less) lane — resolve against the base provider.
|
|
158
|
+
if (baseProvider === undefined) {
|
|
159
|
+
throw mkErr(`review.lanes entry "${trimmed}" has no provider prefix and no orchestrator provider is configured to resolve it.`, 'Prefix the lane with a provider, e.g. "anthropic:claude-sonnet-4", or configure an orchestrator.');
|
|
160
|
+
}
|
|
161
|
+
return { provider: baseProvider, model: trimmed };
|
|
162
|
+
}
|
|
163
|
+
// ─── Predicates (item 6 — core-owned; consumed, never re-implemented) ────────
|
|
164
|
+
//
|
|
165
|
+
// `settled` and cache-eligibility are the SINGLE-SOURCE-OF-TRUTH pure predicates
|
|
166
|
+
// exported by core (`deriveSettled` / `deriveCacheEligible`, over
|
|
167
|
+
// `VerdictPredicateInput = { lanes, findings, postChecks, reviewedState }`). The
|
|
168
|
+
// fan builds the verdict and derives both from it — the assembled artifact IS a
|
|
169
|
+
// valid `VerdictPredicateInput`, so what the persisted boundary re-derives
|
|
170
|
+
// (finding 5) and what the CLI acts on can never diverge. Only the private
|
|
171
|
+
// `everyLaneCompleted` helper below stays local, for the honest exit-reason
|
|
172
|
+
// prose in `describeIneligibility`.
|
|
173
|
+
/** First conjunct (local mirror, for exit-reason prose only): every attempted lane completed. */
|
|
174
|
+
function everyLaneCompleted(lanes) {
|
|
175
|
+
return lanes.length > 0 && lanes.every((l) => l.status === 'completed');
|
|
176
|
+
}
|
|
177
|
+
// ─── Review-specific post-check rule set (item 4) ────────────────────────────
|
|
178
|
+
/**
|
|
179
|
+
* The CLI-side review structured-output rule (DECIDABLE). The shipped generic
|
|
180
|
+
* `structuredOutputRule` bare-`JSON.parse`s `output.content` and would
|
|
181
|
+
* MIS-VERDICT valid XML-wrapped / fenced Shield output as malformed — so it is
|
|
182
|
+
* deliberately NOT wired here. This rule runs the SINGLE shared
|
|
183
|
+
* `extractStructuredVerdict` cascade (the same parser the CLI path uses): an
|
|
184
|
+
* extractable verdict passes, unextractable output is a decidable fail.
|
|
185
|
+
* Caller-scoped to `review` runs.
|
|
186
|
+
*/
|
|
187
|
+
export const reviewStructuredOutputRule = {
|
|
188
|
+
name: 'review-structured-verdict',
|
|
189
|
+
tier: 'decidable',
|
|
190
|
+
appliesTo: (a) => a.admission?.runMetadata?.caller === 'review',
|
|
191
|
+
evaluate: (a) => {
|
|
192
|
+
const verdict = extractStructuredVerdict(a.output.content);
|
|
193
|
+
if (verdict === null) {
|
|
194
|
+
return {
|
|
195
|
+
verdict: 'fail',
|
|
196
|
+
message: 'review lane output is not extractable by the shared Shield verdict cascade',
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
return { verdict: 'pass', message: 'review lane output parses via the shared verdict cascade' };
|
|
200
|
+
},
|
|
201
|
+
};
|
|
202
|
+
/**
|
|
203
|
+
* The review rule set: the CLI review structured-verdict rule PLUS the shipped
|
|
204
|
+
* default rules EXCEPT the generic `structured-output` rule (see above). Built
|
|
205
|
+
* lazily from `DEFAULT_RULES` at call time so it always tracks the shipped set.
|
|
206
|
+
*/
|
|
207
|
+
async function buildReviewRules() {
|
|
208
|
+
const { DEFAULT_RULES } = await import('@mmnto/totem');
|
|
209
|
+
return [
|
|
210
|
+
reviewStructuredOutputRule,
|
|
211
|
+
...DEFAULT_RULES.filter((r) => r.name !== 'structured-output'),
|
|
212
|
+
];
|
|
213
|
+
}
|
|
214
|
+
/**
|
|
215
|
+
* The lane-blind laneId (Prop 302 G1): `lane-<index>:<resolvedBackendOrConfiguredLane>`.
|
|
216
|
+
* `<index>` is the lane's zero-based position in the configured fan;
|
|
217
|
+
* `<resolvedBackendOrConfiguredLane>` is the resolved backend (`provider:model`) for a
|
|
218
|
+
* lane that reached one, or the configured lane string for a lane that failed before
|
|
219
|
+
* a backend resolved. Backend-derived vocabulary only — the `LaneIdSchema` refinement
|
|
220
|
+
* rejects any warm/cold/headless runner class.
|
|
221
|
+
*/
|
|
222
|
+
function laneId(index, resolvedBackendOrConfiguredLane) {
|
|
223
|
+
return `lane-${index}:${resolvedBackendOrConfiguredLane}`;
|
|
224
|
+
}
|
|
225
|
+
/**
|
|
226
|
+
* Classify one lane's INVOCATION result (index-tagged for the laneId). The invoker is
|
|
227
|
+
* called once and NOT wrapped here: an invoker throw REJECTS this promise and the fan's
|
|
228
|
+
* `Promise.allSettled` maps the rejection to a `failed` lane via
|
|
229
|
+
* {@link classifyRejectedLane} — an explicit terminal classification, never a bare
|
|
230
|
+
* swallow (finding 13). A missing artifact emission is a `failed` lane, unextractable
|
|
231
|
+
* output is `abstained`, and an extractable verdict is `completed` with a severity tally
|
|
232
|
+
* from its exemption-filtered findings.
|
|
233
|
+
*
|
|
234
|
+
* NO retry lives here beyond `runOrchestrator`'s existing logged quota fallback
|
|
235
|
+
* (the design's "no runner retry"); `resolvedBackend` records what actually ran.
|
|
236
|
+
*/
|
|
237
|
+
export async function runLane(index, laneModel, invoker, shared, deliveredPrompt) {
|
|
238
|
+
// No try/catch: a throw here is classified by the fan's allSettled handler
|
|
239
|
+
// (classifyRejectedLane) so the classification is not a bare swallow (finding 13).
|
|
240
|
+
const invocation = await invoker(laneModel, deliveredPrompt);
|
|
241
|
+
// A response-cache hit emits no run artifact (fresh is forced, so it cannot
|
|
242
|
+
// occur — but defense in depth): missing emission is a terminal lane failure,
|
|
243
|
+
// never a completed lane without genuine provenance (invariant 2). The laneId
|
|
244
|
+
// uses the CONFIGURED lane (no backend resolved).
|
|
245
|
+
if (invocation.runArtifactHash === undefined || invocation.runArtifact === undefined) {
|
|
246
|
+
return {
|
|
247
|
+
lane: {
|
|
248
|
+
status: 'failed',
|
|
249
|
+
laneId: laneId(index, laneModel),
|
|
250
|
+
typedReason: 'missing-artifact-emission',
|
|
251
|
+
// rev-6 item 3: persist the configured lane the id suffix binds to (no backend
|
|
252
|
+
// resolved, so the suffix has nothing else to ground against).
|
|
253
|
+
configuredLane: laneModel,
|
|
254
|
+
},
|
|
255
|
+
filteredFindings: [],
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
const resolvedBackend = invocation.runArtifact.backend.qualifiedModel;
|
|
259
|
+
const id = laneId(index, resolvedBackend);
|
|
260
|
+
if (invocation.content === undefined) {
|
|
261
|
+
// The invoke produced no content (only the degenerate --raw path). Treat as
|
|
262
|
+
// an abstention — there is a run artifact but no usable structured verdict.
|
|
263
|
+
return {
|
|
264
|
+
lane: {
|
|
265
|
+
status: 'abstained',
|
|
266
|
+
laneId: id,
|
|
267
|
+
resolvedBackend,
|
|
268
|
+
runArtifactHash: invocation.runArtifactHash,
|
|
269
|
+
reason: 'lane produced no content to extract a verdict from',
|
|
270
|
+
},
|
|
271
|
+
runArtifact: invocation.runArtifact,
|
|
272
|
+
filteredFindings: [],
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
const outcome = await deriveLaneOutcome(invocation.content, shared);
|
|
276
|
+
if (outcome.structuredVerdict === null) {
|
|
277
|
+
return {
|
|
278
|
+
lane: {
|
|
279
|
+
status: 'abstained',
|
|
280
|
+
laneId: id,
|
|
281
|
+
resolvedBackend,
|
|
282
|
+
runArtifactHash: invocation.runArtifactHash,
|
|
283
|
+
reason: 'lane output not extractable by the shared Shield verdict cascade',
|
|
284
|
+
},
|
|
285
|
+
runArtifact: invocation.runArtifact,
|
|
286
|
+
filteredFindings: [],
|
|
287
|
+
};
|
|
288
|
+
}
|
|
289
|
+
const verdictSummary = tallyFindings(outcome.filteredFindings);
|
|
290
|
+
return {
|
|
291
|
+
lane: {
|
|
292
|
+
status: 'completed',
|
|
293
|
+
laneId: id,
|
|
294
|
+
resolvedBackend,
|
|
295
|
+
runArtifactHash: invocation.runArtifactHash,
|
|
296
|
+
verdictSummary,
|
|
297
|
+
},
|
|
298
|
+
runArtifact: invocation.runArtifact,
|
|
299
|
+
filteredFindings: outcome.filteredFindings,
|
|
300
|
+
};
|
|
301
|
+
}
|
|
302
|
+
/**
|
|
303
|
+
* Map a REJECTED lane promise to a `failed` lane record (finding 13): an invoker throw
|
|
304
|
+
* is classified (quota vs generic invoke error) and lands in the verdict as a terminal
|
|
305
|
+
* `failed` lane — a lane is never lost to a rejection. The laneId uses the CONFIGURED
|
|
306
|
+
* lane (a rejection means no backend resolved).
|
|
307
|
+
*/
|
|
308
|
+
export async function classifyRejectedLane(index, laneModel, reason) {
|
|
309
|
+
const typedReason = await classifyInvokeFailure(reason);
|
|
310
|
+
return {
|
|
311
|
+
// rev-6 item 3: a rejected lane resolved no backend — the laneId suffix binds to the
|
|
312
|
+
// configured lane, persisted here so the schema can validate that binding.
|
|
313
|
+
lane: {
|
|
314
|
+
status: 'failed',
|
|
315
|
+
laneId: laneId(index, laneModel),
|
|
316
|
+
typedReason,
|
|
317
|
+
configuredLane: laneModel,
|
|
318
|
+
},
|
|
319
|
+
filteredFindings: [],
|
|
320
|
+
};
|
|
321
|
+
}
|
|
322
|
+
/** Severity tally for a completed lane's `verdictSummary`. */
|
|
323
|
+
function tallyFindings(findings) {
|
|
324
|
+
let critical = 0;
|
|
325
|
+
let warn = 0;
|
|
326
|
+
let info = 0;
|
|
327
|
+
for (const f of findings) {
|
|
328
|
+
if (f.severity === 'CRITICAL')
|
|
329
|
+
critical += 1;
|
|
330
|
+
else if (f.severity === 'WARN')
|
|
331
|
+
warn += 1;
|
|
332
|
+
else
|
|
333
|
+
info += 1;
|
|
334
|
+
}
|
|
335
|
+
return { critical, warn, info };
|
|
336
|
+
}
|
|
337
|
+
/** Classify an invoke throw as a typed `failed` reason (quota vs generic invoke error). */
|
|
338
|
+
async function classifyInvokeFailure(err) {
|
|
339
|
+
const { isQuotaError } = await import('../orchestrators/orchestrator.js');
|
|
340
|
+
return isQuotaError(err) ? 'quota-exhausted' : 'invoke-error';
|
|
341
|
+
}
|
|
342
|
+
/**
|
|
343
|
+
* Build the source-discriminated `VerdictDiffScope` from the resolved scope
|
|
344
|
+
* metadata + the masked-payload `diffHash`. For `explicit-range` the schema
|
|
345
|
+
* requires both endpoints; a bare `--diff <ref>` (working-tree comparison) has
|
|
346
|
+
* no explicit head, so `HEAD` is recorded as the implicit head.
|
|
347
|
+
*/
|
|
348
|
+
export function buildDiffScope(meta, diffHash) {
|
|
349
|
+
switch (meta.source) {
|
|
350
|
+
case 'explicit-range':
|
|
351
|
+
return {
|
|
352
|
+
source: 'explicit-range',
|
|
353
|
+
diffHash,
|
|
354
|
+
base: meta.base ?? 'HEAD',
|
|
355
|
+
head: meta.head ?? 'HEAD',
|
|
356
|
+
};
|
|
357
|
+
case 'branch-vs-base':
|
|
358
|
+
return { source: 'branch-vs-base', diffHash, base: meta.base ?? 'HEAD' };
|
|
359
|
+
case 'staged':
|
|
360
|
+
return { source: 'staged', diffHash };
|
|
361
|
+
case 'uncommitted':
|
|
362
|
+
return { source: 'uncommitted', diffHash };
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
/**
|
|
366
|
+
* Resolve the composite lineage key over the RESOLVED scope selector (item 5;
|
|
367
|
+
* codex rev-2 fold 2). `repoIdentity` is the stable worktree identity (absolute
|
|
368
|
+
* `git rev-parse --show-toplevel`); `branch` is the current branch
|
|
369
|
+
* (`git symbolic-ref --short HEAD`), a detached HEAD becoming the literal
|
|
370
|
+
* `DETACHED:<sha>`. The per-source range selectors are contributed to the key so
|
|
371
|
+
* they describe the *lineage*, never the diff bytes:
|
|
372
|
+
* - `explicit-range` — the normalized `base` + `head` endpoints (two different
|
|
373
|
+
* ranges on one branch never cross-link — gate 2).
|
|
374
|
+
* - `branch-vs-base` — the resolved `base` + `mergeBase` sha (a moved merge-base
|
|
375
|
+
* forks the chain).
|
|
376
|
+
* - `staged` / `uncommitted` — no range fields; worktree identity + branch +
|
|
377
|
+
* source carry the lineage (the index/worktree has no second endpoint).
|
|
378
|
+
*/
|
|
379
|
+
export async function resolveLineage(meta, gitExec) {
|
|
380
|
+
const { computeLineageKey } = await import('@mmnto/totem');
|
|
381
|
+
const path = await import('node:path');
|
|
382
|
+
const repoIdentity = resolveRepoIdentity(gitExec, path);
|
|
383
|
+
const branch = resolveBranch(gitExec);
|
|
384
|
+
const mergeBase = resolveMergeBase(meta, gitExec);
|
|
385
|
+
const selectorForm = meta.selectorForm;
|
|
386
|
+
// `LineageKeyInput` is a SOURCE-DISCRIMINATED union: each variant carries only the
|
|
387
|
+
// range fields its source makes meaningful, so it is built as a full per-source
|
|
388
|
+
// literal (mutation would not typecheck). `selectorForm` (finding 10) rides every
|
|
389
|
+
// variant's common fields.
|
|
390
|
+
let keyInput;
|
|
391
|
+
switch (meta.source) {
|
|
392
|
+
case 'explicit-range':
|
|
393
|
+
keyInput = {
|
|
394
|
+
repoIdentity,
|
|
395
|
+
branch,
|
|
396
|
+
selectorForm,
|
|
397
|
+
source: 'explicit-range',
|
|
398
|
+
base: meta.base ?? 'HEAD',
|
|
399
|
+
head: meta.head ?? 'HEAD',
|
|
400
|
+
};
|
|
401
|
+
break;
|
|
402
|
+
case 'branch-vs-base':
|
|
403
|
+
keyInput = {
|
|
404
|
+
repoIdentity,
|
|
405
|
+
branch,
|
|
406
|
+
selectorForm,
|
|
407
|
+
source: 'branch-vs-base',
|
|
408
|
+
base: meta.base ?? '',
|
|
409
|
+
mergeBase,
|
|
410
|
+
};
|
|
411
|
+
break;
|
|
412
|
+
case 'staged':
|
|
413
|
+
keyInput = { repoIdentity, branch, selectorForm, source: 'staged' };
|
|
414
|
+
break;
|
|
415
|
+
case 'uncommitted':
|
|
416
|
+
keyInput = { repoIdentity, branch, selectorForm, source: 'uncommitted' };
|
|
417
|
+
break;
|
|
418
|
+
}
|
|
419
|
+
const lineageKey = computeLineageKey(keyInput);
|
|
420
|
+
return { branch, mergeBase, lineageKey };
|
|
421
|
+
}
|
|
422
|
+
/**
|
|
423
|
+
* The stable worktree identity: the absolute resolved `git rev-parse
|
|
424
|
+
* --show-toplevel` (codex rev-2 fold 2). Distinct worktrees of the same repo have
|
|
425
|
+
* distinct toplevels and so never cross-link. Falls back to a stable literal when
|
|
426
|
+
* git is unavailable, keeping the lineage key deterministic.
|
|
427
|
+
*/
|
|
428
|
+
/**
|
|
429
|
+
* Run a git probe through the injected seam and return its trimmed stdout, or
|
|
430
|
+
* `undefined` when git exits non-zero. For these lineage probes a non-zero exit is an
|
|
431
|
+
* EXPECTED state (a detached HEAD, a missing merge-base, a non-repo cwd), not an error.
|
|
432
|
+
* This is the ONE explicit degrade point (finding 12): the failure is surfaced as a
|
|
433
|
+
* Result (`undefined`), never rethrown, and every caller reads the `undefined` and
|
|
434
|
+
* applies its documented fallback — so no probe site carries a bare swallow.
|
|
435
|
+
*/
|
|
436
|
+
function tryGit(gitExec, args) {
|
|
437
|
+
// totem-context: intentional fail-open — a non-zero git exit on a lineage probe is an
|
|
438
|
+
// EXPECTED state (detached HEAD / missing merge-base / non-repo cwd), surfaced as a
|
|
439
|
+
// Result (`undefined`) for the caller's documented fallback, never a silent drop.
|
|
440
|
+
try {
|
|
441
|
+
return gitExec(args).trim();
|
|
442
|
+
// totem-context: intentional fail-open — expected git-probe miss → Result (undefined).
|
|
443
|
+
}
|
|
444
|
+
catch (_err) {
|
|
445
|
+
return undefined;
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
function resolveRepoIdentity(gitExec, path) {
|
|
449
|
+
const top = tryGit(gitExec, ['rev-parse', '--show-toplevel']);
|
|
450
|
+
if (top !== undefined && top.length > 0)
|
|
451
|
+
return path.resolve(top);
|
|
452
|
+
return 'WORKTREE:unknown';
|
|
453
|
+
}
|
|
454
|
+
function resolveBranch(gitExec) {
|
|
455
|
+
const branch = tryGit(gitExec, ['symbolic-ref', '--short', 'HEAD']);
|
|
456
|
+
if (branch !== undefined && branch.length > 0)
|
|
457
|
+
return branch;
|
|
458
|
+
// Detached HEAD: use a stable literal so distinct detached states do not
|
|
459
|
+
// collide with a real branch named the same as a sha prefix.
|
|
460
|
+
const sha = tryGit(gitExec, ['rev-parse', 'HEAD']);
|
|
461
|
+
return sha !== undefined && sha.length > 0 ? `DETACHED:${sha}` : 'DETACHED:unknown';
|
|
462
|
+
}
|
|
463
|
+
function resolveMergeBase(meta, gitExec) {
|
|
464
|
+
// ONLY `branch-vs-base` contributes a resolved merge-base to its lineage key, so it is
|
|
465
|
+
// the only source that spawns `git merge-base`. Every other source returns '' WITHOUT a
|
|
466
|
+
// subprocess (greptile item 2): staged / uncommitted have no second endpoint, and
|
|
467
|
+
// `explicit-range` keys on its base+head endpoints and DISCARDS the merge-base entirely
|
|
468
|
+
// — the branch + source (plus the range endpoints) carry the lineage, and '' keeps the
|
|
469
|
+
// key stable per-branch.
|
|
470
|
+
if (meta.source !== 'branch-vs-base')
|
|
471
|
+
return '';
|
|
472
|
+
const base = meta.base;
|
|
473
|
+
if (base === undefined)
|
|
474
|
+
return '';
|
|
475
|
+
const head = meta.head ?? 'HEAD';
|
|
476
|
+
// No shared history (or the ref moved) ⇒ `undefined` ⇒ '' — the branch + source
|
|
477
|
+
// still key the lineage; a moved merge-base forks the chain.
|
|
478
|
+
return tryGit(gitExec, ['merge-base', base, head]) ?? '';
|
|
479
|
+
}
|
|
480
|
+
/** The default `git` runner (production). */
|
|
481
|
+
export async function defaultGitExec(cwd) {
|
|
482
|
+
const { safeExec } = await import('@mmnto/totem');
|
|
483
|
+
return (args) => safeExec('git', [...args], { cwd });
|
|
484
|
+
}
|
|
485
|
+
/**
|
|
486
|
+
* Resolve the round record (item 5). Implicit path: the latest verdict sharing
|
|
487
|
+
* the computed lineage key links as prior (round = prior + 1); a corrupt/missing
|
|
488
|
+
* prior restarts the chain at round 0 with a warning. Explicit `--continues
|
|
489
|
+
* <hash>`: load that verdict and link to it (its round + 1); a lineage mismatch
|
|
490
|
+
* WARNS (honoring the explicit intent) and records the CURRENT lineage key.
|
|
491
|
+
*/
|
|
492
|
+
export async function resolveRound(totemDirAbs, lineageKey, continuesHash) {
|
|
493
|
+
const { findLatestVerdictForLineage, loadVerdictArtifact } = await import('@mmnto/totem');
|
|
494
|
+
const warnings = [];
|
|
495
|
+
if (continuesHash !== undefined) {
|
|
496
|
+
// Explicit override — an honest load failure is loud (the user named it). The
|
|
497
|
+
// loader returns the artifact WITH its verified address; the user-supplied
|
|
498
|
+
// `continuesHash` IS that verified stored address (load verified it), so the link
|
|
499
|
+
// uses it directly.
|
|
500
|
+
const prior = loadVerdictArtifact(totemDirAbs, continuesHash);
|
|
501
|
+
if (prior.artifact.round.lineageKey !== lineageKey) {
|
|
502
|
+
warnings.push(`--continues ${continuesHash.slice(0, 8)} links a verdict from a DIFFERENT lineage; honoring the explicit intent and recording the current lineage key (branch/base/source moved).`);
|
|
503
|
+
}
|
|
504
|
+
return {
|
|
505
|
+
round: {
|
|
506
|
+
index: prior.artifact.round.index + 1,
|
|
507
|
+
priorVerdictHash: continuesHash,
|
|
508
|
+
lineageKey,
|
|
509
|
+
},
|
|
510
|
+
warnings,
|
|
511
|
+
};
|
|
512
|
+
}
|
|
513
|
+
// Implicit linkage on the composite lineage key. A corrupt / mis-addressed prior in
|
|
514
|
+
// this lineage is content-address-verified, warned, and SKIPPED inside the scan
|
|
515
|
+
// (finding 4, core-owned) — it returns `undefined` ⇒ the chain honestly restarts at
|
|
516
|
+
// round 0 (the failure-table "prior verdict missing/corrupt" row). No bare catch is
|
|
517
|
+
// needed here, and an UNEXPECTED failure (e.g. a filesystem permission error) now
|
|
518
|
+
// propagates loud instead of masquerading as a chain restart (finding 12). The
|
|
519
|
+
// per-entry skip warnings route into `warnings` for the sensor line.
|
|
520
|
+
const prior = findLatestVerdictForLineage(totemDirAbs, lineageKey, (msg) => warnings.push(msg));
|
|
521
|
+
if (prior === undefined) {
|
|
522
|
+
return { round: { index: 0, lineageKey }, warnings };
|
|
523
|
+
}
|
|
524
|
+
// Link to the prior's STORED, verified content address (rev-6 item 1) — never a
|
|
525
|
+
// recompute over the Zod-stripped shape, which would diverge for a forward-minor prior
|
|
526
|
+
// and point `priorVerdictHash` at a nonexistent file.
|
|
527
|
+
return {
|
|
528
|
+
round: {
|
|
529
|
+
index: prior.artifact.round.index + 1,
|
|
530
|
+
priorVerdictHash: prior.contentHash,
|
|
531
|
+
lineageKey,
|
|
532
|
+
},
|
|
533
|
+
warnings,
|
|
534
|
+
};
|
|
535
|
+
}
|
|
536
|
+
/**
|
|
537
|
+
* Run the #2103 post-check engine over every lane that emitted a run artifact
|
|
538
|
+
* (completed AND abstained — finding 8) and, with ≥2 COMPLETED lanes, assemble + write
|
|
539
|
+
* the #2104 panel from the completed lanes' run artifacts ONLY (failed/abstained lanes
|
|
540
|
+
* never reach `assemblePanelArtifact`).
|
|
541
|
+
*
|
|
542
|
+
* Panel inputs stay completed-only, but post-checks ALSO cover abstained lanes: an
|
|
543
|
+
* abstained lane's unextractable output is exactly what the review-specific decidable
|
|
544
|
+
* structured-output rule must persist a 'fail' row for, so its failure lands honestly
|
|
545
|
+
* in `verdict.postChecks` instead of vanishing.
|
|
546
|
+
*/
|
|
547
|
+
export async function runPanelAndPostChecks(laneResults, totemDirAbs, configRoot, createdAt) {
|
|
548
|
+
const { evaluatePostChecks, assemblePanelArtifact, writePanelArtifact } = await import('@mmnto/totem');
|
|
549
|
+
const rules = await buildReviewRules();
|
|
550
|
+
const postChecks = [];
|
|
551
|
+
const laneReports = new Map();
|
|
552
|
+
// Completed AND abstained lanes both carry a run artifact; failed lanes do not.
|
|
553
|
+
const withArtifact = laneResults.filter((lr) => lr.runArtifact !== undefined);
|
|
554
|
+
for (const lr of withArtifact) {
|
|
555
|
+
const artifact = lr.runArtifact;
|
|
556
|
+
const report = await evaluatePostChecks(artifact, rules, { configRoot });
|
|
557
|
+
laneReports.set(lr.lane.laneId, report);
|
|
558
|
+
for (const f of report.findings) {
|
|
559
|
+
postChecks.push({
|
|
560
|
+
ruleName: f.ruleName,
|
|
561
|
+
tier: f.tier,
|
|
562
|
+
verdict: f.verdict,
|
|
563
|
+
message: f.message,
|
|
564
|
+
});
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
// A panel is assembled ONLY from ≥2 usable (completed) lanes.
|
|
568
|
+
const completed = laneResults.filter((lr) => lr.lane.status === 'completed');
|
|
569
|
+
if (completed.length < 2) {
|
|
570
|
+
return { postChecks };
|
|
571
|
+
}
|
|
572
|
+
const laneInputs = completed.map((lr) => ({
|
|
573
|
+
laneId: lr.lane.laneId,
|
|
574
|
+
artifact: lr.runArtifact,
|
|
575
|
+
report: laneReports.get(lr.lane.laneId),
|
|
576
|
+
}));
|
|
577
|
+
const panel = assemblePanelArtifact(laneInputs, createdAt);
|
|
578
|
+
const saved = writePanelArtifact(totemDirAbs, panel);
|
|
579
|
+
return { postChecks, panelArtifactHash: saved.hash, diversity: panel.diversity };
|
|
580
|
+
}
|
|
581
|
+
// ─── Verdict assembly (item 7) ───────────────────────────────────────────────
|
|
582
|
+
/** Normalize a completed lane's exemption-filtered findings into verdict findings. */
|
|
583
|
+
function toVerdictFindings(findings) {
|
|
584
|
+
return findings.map((f) => ({
|
|
585
|
+
severity: f.severity,
|
|
586
|
+
...(f.confidence !== undefined ? { confidence: f.confidence } : {}),
|
|
587
|
+
...(f.file !== undefined ? { file: f.file } : {}),
|
|
588
|
+
...(f.line !== undefined ? { line: f.line } : {}),
|
|
589
|
+
message: f.message,
|
|
590
|
+
}));
|
|
591
|
+
}
|
|
592
|
+
/**
|
|
593
|
+
* Assemble the full verdict artifact in memory (item 7). Counts are DERIVED
|
|
594
|
+
* from `lanes` (never mirrored on trust — the schema re-validates them), the
|
|
595
|
+
* findings union is the completed lanes' exemption-filtered findings, and
|
|
596
|
+
* `settled` is the derived predicate over this artifact's own content (including
|
|
597
|
+
* the `reviewedState` drift clause — codex rev-2 fold 1).
|
|
598
|
+
*/
|
|
599
|
+
export function assembleVerdict(inputs,
|
|
600
|
+
// Rule 64: the core predicate + schema-version VALUE are threaded in by the async
|
|
601
|
+
// caller (`runReviewFan`), never statically imported here — so this stays sync and its
|
|
602
|
+
// structural tests do not become async.
|
|
603
|
+
deriveSettled, VERDICT_ARTIFACT_SCHEMA_VERSION) {
|
|
604
|
+
const lanes = inputs.laneResults.map((lr) => lr.lane);
|
|
605
|
+
const completed = inputs.laneResults.filter((lr) => lr.lane.status === 'completed');
|
|
606
|
+
const findingsUnion = completed.flatMap((lr) => lr.filteredFindings);
|
|
607
|
+
const findings = toVerdictFindings(findingsUnion);
|
|
608
|
+
// `settled` is the core-owned dryness predicate over THIS artifact's own content
|
|
609
|
+
// (the persisted boundary re-derives + checks it — finding 5).
|
|
610
|
+
const settled = deriveSettled({
|
|
611
|
+
lanes,
|
|
612
|
+
findings,
|
|
613
|
+
postChecks: inputs.panelAndChecks.postChecks,
|
|
614
|
+
reviewedState: inputs.reviewedState,
|
|
615
|
+
});
|
|
616
|
+
return {
|
|
617
|
+
schemaVersion: VERDICT_ARTIFACT_SCHEMA_VERSION,
|
|
618
|
+
diffScope: inputs.diffScope,
|
|
619
|
+
lanes,
|
|
620
|
+
attemptedLaneCount: lanes.length,
|
|
621
|
+
completedLaneCount: completed.length,
|
|
622
|
+
...(inputs.panelAndChecks.panelArtifactHash !== undefined
|
|
623
|
+
? { panelArtifactHash: inputs.panelAndChecks.panelArtifactHash }
|
|
624
|
+
: {}),
|
|
625
|
+
postChecks: inputs.panelAndChecks.postChecks,
|
|
626
|
+
findings,
|
|
627
|
+
...(inputs.panelAndChecks.diversity !== undefined
|
|
628
|
+
? { diversity: inputs.panelAndChecks.diversity }
|
|
629
|
+
: {}),
|
|
630
|
+
round: inputs.round,
|
|
631
|
+
reviewedState: inputs.reviewedState,
|
|
632
|
+
settled,
|
|
633
|
+
createdAt: inputs.createdAt,
|
|
634
|
+
};
|
|
635
|
+
}
|
|
636
|
+
// ─── Delivered masked diff segment + diffHash (codex rev-2 fold 4) ────────────
|
|
637
|
+
/**
|
|
638
|
+
* The diff segment EXACTLY as delivered inside the shared per-lane prompt's
|
|
639
|
+
* `<git_diff>` block: post file-filtering, post `MAX_DIFF_CHARS` truncation
|
|
640
|
+
* INCLUDING the truncation marker. Mirrors `assemblePrompt`'s truncation VERBATIM
|
|
641
|
+
* (the single-lane path's assembly is untouched); used only as a fallback when the
|
|
642
|
+
* `<git_diff>` block cannot be located in the assembled prompt.
|
|
643
|
+
*/
|
|
644
|
+
function deliveredDiffSegment(diff) {
|
|
645
|
+
return diff.length > MAX_DIFF_CHARS
|
|
646
|
+
? diff.slice(0, MAX_DIFF_CHARS) + `\n... [diff truncated at ${MAX_DIFF_CHARS} chars] ...`
|
|
647
|
+
: diff;
|
|
648
|
+
}
|
|
649
|
+
/** The `<git_diff>` block wrapper (see `wrapXml`) delimits the delivered segment. */
|
|
650
|
+
const GIT_DIFF_SEGMENT_RE = /<git_diff>\n([\s\S]*?)\n<\/git_diff>/;
|
|
651
|
+
/**
|
|
652
|
+
* Extract the exact bytes inside the assembled prompt's `<git_diff>` block —
|
|
653
|
+
* what every lane actually reviewed (post-truncation). `wrapXml('git_diff', …)`
|
|
654
|
+
* emits `<git_diff>\n{content}\n</git_diff>`, so the captured group is the
|
|
655
|
+
* delivered segment. Returns `null` when no block is present (structural mode /
|
|
656
|
+
* malformed prompt), letting the caller fall back to `deliveredDiffSegment`.
|
|
657
|
+
*/
|
|
658
|
+
function extractGitDiffSegment(prompt) {
|
|
659
|
+
const m = GIT_DIFF_SEGMENT_RE.exec(prompt);
|
|
660
|
+
return m === null ? null : m[1];
|
|
661
|
+
}
|
|
662
|
+
/**
|
|
663
|
+
* Build the production per-lane invoker over `runOrchestrator`: forces a fresh
|
|
664
|
+
* invoke (so a response-cache hit can never masquerade as a completed lane),
|
|
665
|
+
* captures the run-artifact hash via `onEmitted`, and loads the artifact for the
|
|
666
|
+
* panel + post-checks. Every lane gets the IDENTICAL prompt at temperature 0.
|
|
667
|
+
*/
|
|
668
|
+
function makeLaneInvoker(ctx) {
|
|
669
|
+
return async (laneModel, deliveredPrompt) => {
|
|
670
|
+
const { runOrchestrator } = await import('../utils.js');
|
|
671
|
+
const { ADMISSION_COMPLETION_ONLY, loadRunArtifact } = await import('@mmnto/totem');
|
|
672
|
+
let runArtifactHash;
|
|
673
|
+
const content = await runOrchestrator({
|
|
674
|
+
// The PRE-MASKED, pre-assembled payload (codex rev-2 fold 4). Every lane
|
|
675
|
+
// gets the identical bytes the `diffHash` binds; `runOrchestrator`'s DLP
|
|
676
|
+
// pass re-masks idempotently (a no-op on already-masked text), so the
|
|
677
|
+
// persisted `maskedPrompt` equals these bytes for remote AND local providers.
|
|
678
|
+
prompt: deliveredPrompt,
|
|
679
|
+
tag: TAG,
|
|
680
|
+
// Force a fresh invoke: a response-cache hit emits no run artifact and so
|
|
681
|
+
// must never yield a completed lane. `raw` is forced off — the fan needs
|
|
682
|
+
// real content. `model` routes this lane's provider.
|
|
683
|
+
options: { ...ctx.options, model: laneModel, fresh: true, raw: false },
|
|
684
|
+
config: ctx.config,
|
|
685
|
+
cwd: ctx.cwd,
|
|
686
|
+
configRoot: ctx.configRoot,
|
|
687
|
+
totalResults: ctx.totalResults,
|
|
688
|
+
temperature: 0,
|
|
689
|
+
backendAdmissionClass: ADMISSION_COMPLETION_ONLY,
|
|
690
|
+
runMetadata: { caller: 'review', codeBlind: ctx.codeBlind },
|
|
691
|
+
artifact: {
|
|
692
|
+
groundingHash: ctx.groundingHash,
|
|
693
|
+
provenanceSummary: ctx.provenanceSummary,
|
|
694
|
+
bundle: ctx.groundingBundle,
|
|
695
|
+
onEmitted: (hash) => {
|
|
696
|
+
runArtifactHash = hash;
|
|
697
|
+
},
|
|
698
|
+
},
|
|
699
|
+
});
|
|
700
|
+
const runArtifact = runArtifactHash !== undefined ? loadRunArtifact(ctx.totemDirAbs, runArtifactHash) : undefined;
|
|
701
|
+
return { content, runArtifactHash, runArtifact };
|
|
702
|
+
};
|
|
703
|
+
}
|
|
704
|
+
/** Display order — CRITICAL first, INFO last (echoes the single-lane display). */
|
|
705
|
+
const FAN_SEVERITY_ORDER = ['CRITICAL', 'WARN', 'INFO'];
|
|
706
|
+
/**
|
|
707
|
+
* Dedupe the completed lanes' exemption-filtered findings on
|
|
708
|
+
* `(severity, file, line, message)` and count how many distinct lanes converged on
|
|
709
|
+
* each (finding 2). Failed/abstained lanes contribute nothing (no findings).
|
|
710
|
+
*/
|
|
711
|
+
function dedupeFanFindings(laneResults) {
|
|
712
|
+
const map = new Map();
|
|
713
|
+
for (const lr of laneResults) {
|
|
714
|
+
if (lr.lane.status !== 'completed')
|
|
715
|
+
continue;
|
|
716
|
+
const id = lr.lane.laneId;
|
|
717
|
+
for (const f of lr.filteredFindings) {
|
|
718
|
+
const key = JSON.stringify([f.severity, f.file ?? null, f.line ?? null, f.message]);
|
|
719
|
+
const existing = map.get(key);
|
|
720
|
+
if (existing !== undefined)
|
|
721
|
+
existing.lanes.add(id);
|
|
722
|
+
else
|
|
723
|
+
map.set(key, { finding: f, lanes: new Set([id]) });
|
|
724
|
+
}
|
|
725
|
+
}
|
|
726
|
+
const out = [...map.values()].map((e) => ({ finding: e.finding, laneCount: e.lanes.size }));
|
|
727
|
+
out.sort((a, b) => FAN_SEVERITY_ORDER.indexOf(a.finding.severity) -
|
|
728
|
+
FAN_SEVERITY_ORDER.indexOf(b.finding.severity));
|
|
729
|
+
return out;
|
|
730
|
+
}
|
|
731
|
+
/**
|
|
732
|
+
* One rendered finding line — echoes the single-lane display styling
|
|
733
|
+
* (` <SEVERITY> [<conf>] <file>:<line> — <message>`) plus a lane-convergence
|
|
734
|
+
* annotation (`(N lanes)`).
|
|
735
|
+
*/
|
|
736
|
+
function formatFanFinding(af) {
|
|
737
|
+
const f = af.finding;
|
|
738
|
+
const location = f.file !== undefined ? (f.line !== undefined ? `${f.file}:${f.line} ` : `${f.file} `) : '';
|
|
739
|
+
const conf = f.confidence !== undefined ? ` [${f.confidence}]` : '';
|
|
740
|
+
const conv = ` (${af.laneCount} lane${af.laneCount === 1 ? '' : 's'})`;
|
|
741
|
+
return ` ${f.severity}${conf} ${location}— ${f.message}${conv}`;
|
|
742
|
+
}
|
|
743
|
+
/**
|
|
744
|
+
* Render the normalized finding union to stderr BEFORE the summary (finding 2) — the
|
|
745
|
+
* actual finding MESSAGES, with lane attribution, in severity order.
|
|
746
|
+
*/
|
|
747
|
+
function renderFanFindingsToStderr(findings) {
|
|
748
|
+
if (findings.length === 0) {
|
|
749
|
+
console.error('Review fan — 0 finding(s) across lanes.');
|
|
750
|
+
return;
|
|
751
|
+
}
|
|
752
|
+
console.error(`Review fan — ${findings.length} finding(s) (deduped across lanes):`);
|
|
753
|
+
for (const af of findings)
|
|
754
|
+
console.error(formatFanFinding(af));
|
|
755
|
+
}
|
|
756
|
+
/**
|
|
757
|
+
* The human-readable fan report written by `--out` (finding 2): the findings, the
|
|
758
|
+
* per-lane outcomes, and the summary (covariate line + settled/cache/drift state).
|
|
759
|
+
*/
|
|
760
|
+
function renderFanReport(laneResults, verdict, verdictHash, findings, cacheEligible,
|
|
761
|
+
// Threaded from `runReviewFan`'s single core import (rule 64) — this sync helper never
|
|
762
|
+
// imports core itself.
|
|
763
|
+
renderCovariateLine) {
|
|
764
|
+
const lines = [];
|
|
765
|
+
lines.push(`Review fan — ${verdict.completedLaneCount}/${verdict.attemptedLaneCount} lane(s) completed`);
|
|
766
|
+
lines.push('');
|
|
767
|
+
lines.push('Lanes:');
|
|
768
|
+
for (const lr of laneResults) {
|
|
769
|
+
const l = lr.lane;
|
|
770
|
+
if (l.status === 'completed') {
|
|
771
|
+
const s = l.verdictSummary;
|
|
772
|
+
lines.push(` ${l.laneId} — completed (${s.critical} critical, ${s.warn} warn, ${s.info} info)`);
|
|
773
|
+
}
|
|
774
|
+
else if (l.status === 'abstained') {
|
|
775
|
+
lines.push(` ${l.laneId} — abstained (${l.reason})`);
|
|
776
|
+
}
|
|
777
|
+
else {
|
|
778
|
+
lines.push(` ${l.laneId} — failed (${l.typedReason})`);
|
|
779
|
+
}
|
|
780
|
+
}
|
|
781
|
+
lines.push('');
|
|
782
|
+
lines.push(`Findings (${findings.length}):`);
|
|
783
|
+
if (findings.length === 0)
|
|
784
|
+
lines.push(' (none)');
|
|
785
|
+
else
|
|
786
|
+
for (const af of findings)
|
|
787
|
+
lines.push(formatFanFinding(af));
|
|
788
|
+
lines.push('');
|
|
789
|
+
// Pair the artifact with its STORED address (rev-6 item 1) so the rendered hash8 is the
|
|
790
|
+
// on-disk file address, not a recompute over the normalized shape.
|
|
791
|
+
lines.push(renderCovariateLine({ artifact: verdict, contentHash: verdictHash }));
|
|
792
|
+
lines.push(`settled=${verdict.settled} cache-eligible=${cacheEligible} reviewedState=${verdict.reviewedState}`);
|
|
793
|
+
return lines.join('\n');
|
|
794
|
+
}
|
|
795
|
+
/**
|
|
796
|
+
* The standard-path fan entry. Runs every configured lane IN PARALLEL (finding 13),
|
|
797
|
+
* canonicalizes the results into configured-lane order, runs the panel + post-checks,
|
|
798
|
+
* resolves the round chain, takes the single post-fan tree compare in a short critical
|
|
799
|
+
* section (finding 6), assembles + saves the verdict, renders the findings + covariate
|
|
800
|
+
* line, and enforces the exit contract (finding 3 / Gate G5):
|
|
801
|
+
*
|
|
802
|
+
* - DEFAULT: sensor exit 0 — the verdict, the covariate line, and the findings render
|
|
803
|
+
* are always emitted; a findings-bearing or degraded-coverage round does NOT throw.
|
|
804
|
+
* - `--fail-on <severity>`: throw `SHIELD_FAILED` when the round has findings at/above
|
|
805
|
+
* that severity OR is not cache-eligible.
|
|
806
|
+
* - `--override <reason>`: converts a `--fail-on` failure to pass AND authorizes the
|
|
807
|
+
* trap-ledgered cache stamp on a non-cache-eligible round (matched trees only —
|
|
808
|
+
* drift is never stampable, even overridden).
|
|
809
|
+
*
|
|
810
|
+
* Cache-eligible ⇒ stamp the pre-fan hash. ALL lanes terminal-failed (Gate G3) ⇒ the
|
|
811
|
+
* honest verdict is WRITTEN FIRST, then the run hard-errors. Zero configured lanes ⇒ a
|
|
812
|
+
* pre-attempt hard error with no verdict.
|
|
813
|
+
*/
|
|
814
|
+
export async function runReviewFan(ctx) {
|
|
815
|
+
const { log } = await import('../ui.js');
|
|
816
|
+
const { deriveCacheEligible, deriveSettled, maskSecrets, renderCovariateLine, TotemError, VERDICT_ARTIFACT_SCHEMA_VERSION, } = await import('@mmnto/totem');
|
|
817
|
+
// ── Pre-attempt guard: zero configured lanes ⇒ no verdict (the schema requires a
|
|
818
|
+
// nonempty lanes array; there is nothing to converge). This is a pre-attempt failure,
|
|
819
|
+
// NOT the all-lanes-failed case (which writes an honest verdict first, Gate G3). ──
|
|
820
|
+
if (ctx.laneModels.length === 0) {
|
|
821
|
+
throw new TotemError('SHIELD_FAILED', 'All 0 review lane(s) configured — no verdict written (configure at least one review.lanes entry).', 'Add a `provider:model` entry to `review.lanes`, then re-run `totem review`.');
|
|
822
|
+
}
|
|
823
|
+
const now = ctx.now ?? (() => new Date().toISOString());
|
|
824
|
+
const invoker = ctx.invoker ?? makeLaneInvoker(ctx);
|
|
825
|
+
const gitExec = ctx.gitExec ?? (await defaultGitExec(ctx.cwd));
|
|
826
|
+
const contentHash = ctx.contentHash ??
|
|
827
|
+
(() => computeReviewedContentHash(ctx.cwd, ctx.configRoot, ctx.config.review.sourceExtensions));
|
|
828
|
+
log.info(DISPLAY_TAG, `Review fan: ${ctx.laneModels.length} lane(s) — ${ctx.laneModels.join(', ')}`);
|
|
829
|
+
// ── Pre-assemble the shared masked payload ONCE (codex rev-2 fold 4) ──
|
|
830
|
+
// `diffHash` binds the EXACT masked `<git_diff>` segment every lane sees —
|
|
831
|
+
// post file-filter, post-truncation (marker included), post-DLP. Masking the
|
|
832
|
+
// whole assembled prompt once here (idempotent — verified) makes the segment
|
|
833
|
+
// inside it the delivered bytes; each lane gets this identical payload and
|
|
834
|
+
// `runOrchestrator`'s DLP pass is a no-op, so the persisted `maskedPrompt`'s
|
|
835
|
+
// `<git_diff>` segment recomputes this hash (invariant 12/15, reproducible;
|
|
836
|
+
// never binds bytes a lane did not see, never binds secret-bearing bytes).
|
|
837
|
+
const deliveredPrompt = maskSecrets(ctx.prompt);
|
|
838
|
+
const diffSegment = extractGitDiffSegment(deliveredPrompt) ?? maskSecrets(deliveredDiffSegment(ctx.filteredDiff));
|
|
839
|
+
const diffHash = await sha256Hex(diffSegment);
|
|
840
|
+
// ── Run every lane IN PARALLEL over the immutable shared deliveredPrompt (finding 13) ──
|
|
841
|
+
// `Promise.allSettled` preserves input order, so the results are already in
|
|
842
|
+
// configured-lane order — the artifact is deterministic regardless of completion
|
|
843
|
+
// order. A rejected lane promise is mapped to a `failed` lane classification
|
|
844
|
+
// (classifyRejectedLane) so a lane is never lost.
|
|
845
|
+
const settledLanes = await Promise.allSettled(ctx.laneModels.map((laneModel, index) => runLane(index, laneModel, invoker, ctx.shared, deliveredPrompt)));
|
|
846
|
+
const laneResults = [];
|
|
847
|
+
for (let index = 0; index < settledLanes.length; index++) {
|
|
848
|
+
const s = settledLanes[index];
|
|
849
|
+
laneResults.push(s.status === 'fulfilled'
|
|
850
|
+
? s.value
|
|
851
|
+
: await classifyRejectedLane(index, ctx.laneModels[index], s.reason));
|
|
852
|
+
}
|
|
853
|
+
// ── Log per-lane outcomes in configured order (deterministic) ──
|
|
854
|
+
for (let index = 0; index < laneResults.length; index++) {
|
|
855
|
+
const laneModel = ctx.laneModels[index];
|
|
856
|
+
const lane = laneResults[index].lane;
|
|
857
|
+
if (lane.status === 'failed') {
|
|
858
|
+
log.warn(DISPLAY_TAG, `Lane ${laneModel} FAILED (${lane.typedReason}).`);
|
|
859
|
+
}
|
|
860
|
+
else if (lane.status === 'abstained') {
|
|
861
|
+
log.warn(DISPLAY_TAG, `Lane ${laneModel} ABSTAINED (${lane.reason}).`);
|
|
862
|
+
}
|
|
863
|
+
else {
|
|
864
|
+
const s = lane.verdictSummary;
|
|
865
|
+
log.info(DISPLAY_TAG, `Lane ${laneModel} completed — ${s.critical} critical, ${s.warn} warn, ${s.info} info.`);
|
|
866
|
+
}
|
|
867
|
+
}
|
|
868
|
+
const anyWithOutput = laneResults.some((lr) => lr.lane.status === 'completed' || lr.lane.status === 'abstained');
|
|
869
|
+
const createdAt = now();
|
|
870
|
+
// ── Panel + post-checks (over completed AND abstained lanes; finding 8) ──
|
|
871
|
+
const panelAndChecks = await runPanelAndPostChecks(laneResults, ctx.totemDirAbs, ctx.configRoot, createdAt);
|
|
872
|
+
// ── diffScope binds the delivered masked `<git_diff>` segment (fold 4, above) ──
|
|
873
|
+
const diffScope = buildDiffScope(ctx.diffMeta, diffHash);
|
|
874
|
+
// ── Round chain / lineage ──
|
|
875
|
+
const lineage = await resolveLineage(ctx.diffMeta, gitExec);
|
|
876
|
+
const roundRes = await resolveRound(ctx.totemDirAbs, lineage.lineageKey, ctx.continues);
|
|
877
|
+
for (const w of roundRes.warnings)
|
|
878
|
+
log.warn(DISPLAY_TAG, `Sensor: ${w}`);
|
|
879
|
+
// ── Post-fan tree compare — the REAL critical section (finding 6) ──
|
|
880
|
+
// Sampled here, AFTER the post-check / panel / lineage work, immediately before
|
|
881
|
+
// verdict assembly + report + stamp: a mutation DURING any of that work is caught.
|
|
882
|
+
// This single compare feeds BOTH predicates AND the stamp decision — no second
|
|
883
|
+
// compare, no TOCTOU divergence. A `null` pre-fan hash means there was no tracked
|
|
884
|
+
// source to authorize (legacy no-op).
|
|
885
|
+
const postFanContentHash = await contentHash();
|
|
886
|
+
const reviewedState = ctx.preFanContentHash === null || postFanContentHash === ctx.preFanContentHash
|
|
887
|
+
? 'matched'
|
|
888
|
+
: 'drifted';
|
|
889
|
+
if (reviewedState === 'drifted') {
|
|
890
|
+
log.warn(DISPLAY_TAG, 'WORKTREE DRIFT: tracked source files changed during the review fan. The verdict is bound to the pre-fan tree, so it is NOT settled and the reviewed-content-hash was NOT stamped — this review does not authorize a push. Re-run `totem review` against the current tree.');
|
|
891
|
+
}
|
|
892
|
+
// ── Assemble + save the verdict — kept ADJACENT to the compare above and the stamp
|
|
893
|
+
// below (rev-5 item 2). The compare→stamp interval is deliberately narrowed to
|
|
894
|
+
// exactly assemble (pure, in-memory) + save (one `wx` write): this window is
|
|
895
|
+
// INHERENT, because `reviewedState` is persisted verdict content — the compare must
|
|
896
|
+
// precede assembly, and the artifact must exist before a stamp can claim the round
|
|
897
|
+
// is recorded. Every render/report/covariate/--out side effect happens AFTER the
|
|
898
|
+
// stamp decision, so mutation during that I/O can never influence it. ──
|
|
899
|
+
const verdict = assembleVerdict({
|
|
900
|
+
diffScope,
|
|
901
|
+
laneResults,
|
|
902
|
+
panelAndChecks,
|
|
903
|
+
round: roundRes.round,
|
|
904
|
+
reviewedState,
|
|
905
|
+
createdAt,
|
|
906
|
+
}, deriveSettled, VERDICT_ARTIFACT_SCHEMA_VERSION);
|
|
907
|
+
const { saveVerdictArtifact } = await import('@mmnto/totem');
|
|
908
|
+
// The saved hash IS the content address (dedup-safe: an identical round returns
|
|
909
|
+
// the existing address).
|
|
910
|
+
const verdictHash = saveVerdictArtifact(ctx.totemDirAbs, verdict).hash;
|
|
911
|
+
// ── ALL lanes terminal-failed (Gate G3): the honest verdict is now WRITTEN — hard-error ──
|
|
912
|
+
// (A pure boolean check — it does not widen the compare→stamp window, and an
|
|
913
|
+
// all-failed round is never cache-eligible nor override-stampable: the run ends here.)
|
|
914
|
+
if (!anyWithOutput) {
|
|
915
|
+
throw new TotemError('SHIELD_FAILED', `All ${laneResults.length} review lane(s) failed to invoke — an honest verdict was written (all lanes failed, not settled), then the run hard-errors.`, 'Check backend API keys / quota, then re-run `totem review`.');
|
|
916
|
+
}
|
|
917
|
+
// ── Predicates (the verdict IS a valid VerdictPredicateInput) ──
|
|
918
|
+
const cacheEligible = deriveCacheEligible(verdict);
|
|
919
|
+
// ── Cache stamp — ADJACENT to the save (rev-5 item 2), BEFORE any render/report I/O;
|
|
920
|
+
// independent of the sensor/fail-on exit decision (finding 3) ──
|
|
921
|
+
const override = ctx.options.override;
|
|
922
|
+
if (cacheEligible) {
|
|
923
|
+
// cacheEligible carries `reviewedState === 'matched'`, so drift can never reach
|
|
924
|
+
// here. Stamp EXACTLY the PRE-fan hash via the primitive writer (reusing the single
|
|
925
|
+
// compare above), never a second recompute. A `null` pre-fan hash ⇒ nothing to stamp.
|
|
926
|
+
if (ctx.preFanContentHash !== null) {
|
|
927
|
+
await writeReviewedContentHashValue(ctx.preFanContentHash, ctx.cwd, ctx.config.totemDir, ctx.configRoot, ctx.config.review.sourceExtensions);
|
|
928
|
+
}
|
|
929
|
+
}
|
|
930
|
+
else if (override !== undefined) {
|
|
931
|
+
if (reviewedState === 'matched') {
|
|
932
|
+
// `--override` authorizes the trap-ledgered cache stamp on a non-cache-eligible
|
|
933
|
+
// round — through the ledger+explicit-hash primitive (rev-5 item 1, codex
|
|
934
|
+
// critical): it binds the PRE-FAN hash and recomputes the CURRENT tree hash once
|
|
935
|
+
// more immediately adjacent to the stamp write. An edit landing after the fan's
|
|
936
|
+
// one compare (which derived `reviewedState`) is caught there — loud refusal,
|
|
937
|
+
// ledgered override WITHOUT a stamp. The current tree hash is NEVER stamped.
|
|
938
|
+
log.warn(DISPLAY_TAG, `SHIELD OVERRIDE APPLIED: ${override}`);
|
|
939
|
+
const { recordShieldOverrideWithExpectedHash } = await import('./shield.js');
|
|
940
|
+
await recordShieldOverrideWithExpectedHash({
|
|
941
|
+
override,
|
|
942
|
+
cwd: ctx.cwd,
|
|
943
|
+
totemDir: ctx.config.totemDir,
|
|
944
|
+
configRoot: ctx.configRoot,
|
|
945
|
+
sourceExtensions: ctx.config.review.sourceExtensions,
|
|
946
|
+
expectedContentHash: ctx.preFanContentHash,
|
|
947
|
+
// The SAME seam the fan's own compare used (production: re-hash the tracked
|
|
948
|
+
// tree), so tests can inject a post-compare mutation and prove the refusal.
|
|
949
|
+
computeCurrentHash: contentHash,
|
|
950
|
+
});
|
|
951
|
+
}
|
|
952
|
+
else {
|
|
953
|
+
// Drift is NEVER stampable, even overridden (finding 3) — say it loudly.
|
|
954
|
+
log.warn(DISPLAY_TAG, 'OVERRIDE CANNOT STAMP A DRIFTED TREE: the worktree changed mid-review, so the reviewed-content-hash was NOT stamped even under --override. The verdict stands (bound to the pre-fan tree); no push is authorized. Re-run `totem review` against the current tree.');
|
|
955
|
+
}
|
|
956
|
+
}
|
|
957
|
+
// ── Findings render (finding 2) — the actual messages, AFTER the stamp decision ──
|
|
958
|
+
const fanFindings = dedupeFanFindings(laneResults);
|
|
959
|
+
renderFanFindingsToStderr(fanFindings);
|
|
960
|
+
// ── The core-owned, grep-able covariate line (contract v1; finding 14) ──
|
|
961
|
+
// Pair the freshly-saved verdict with the address `saveVerdictArtifact` returned
|
|
962
|
+
// (its verified on-disk address) so the rendered hash8 always names the stored file.
|
|
963
|
+
log.info(DISPLAY_TAG, renderCovariateLine({ artifact: verdict, contentHash: verdictHash }));
|
|
964
|
+
// ── --out: write the human-readable fan report (findings + lanes + summary) ──
|
|
965
|
+
if (ctx.options.out) {
|
|
966
|
+
const { writeOutput } = await import('../utils.js');
|
|
967
|
+
writeOutput(renderFanReport(laneResults, verdict, verdictHash, fanFindings, cacheEligible, renderCovariateLine), ctx.options.out);
|
|
968
|
+
log.success(DISPLAY_TAG, `Fan report written to ${ctx.options.out}`);
|
|
969
|
+
}
|
|
970
|
+
// ── Sensor lines (warnings-class, never blocks) ──
|
|
971
|
+
if (ctx.laneModels.length === 1) {
|
|
972
|
+
log.warn(DISPLAY_TAG, 'Sensor: single-lane fan — degenerate diversity (no cross-lane corroboration). Add lanes for panel diversity.');
|
|
973
|
+
}
|
|
974
|
+
if (verdict.round.index >= MAX_ROUNDS_ADVISORY) {
|
|
975
|
+
log.warn(DISPLAY_TAG, `Sensor: round ${verdict.round.index} reached the max-rounds advisory threshold (${MAX_ROUNDS_ADVISORY}) — advisory only; consider human judgment.`);
|
|
976
|
+
}
|
|
977
|
+
// ── Exit contract (finding 3 / Gate G5) ──
|
|
978
|
+
// Default: sensor exit 0. `--fail-on <severity>` opts into a non-zero exit when the
|
|
979
|
+
// round has findings at/above that severity OR is not cache-eligible; `--override`
|
|
980
|
+
// converts that failure to a pass.
|
|
981
|
+
const failOn = ctx.options.failOn;
|
|
982
|
+
if (failOn !== undefined) {
|
|
983
|
+
const hasAtOrAbove = failOn === 'critical'
|
|
984
|
+
? verdict.findings.some((f) => f.severity === 'CRITICAL')
|
|
985
|
+
: verdict.findings.some((f) => f.severity === 'WARN' || f.severity === 'CRITICAL');
|
|
986
|
+
if (hasAtOrAbove || !cacheEligible) {
|
|
987
|
+
if (override === undefined) {
|
|
988
|
+
throw new TotemError('SHIELD_FAILED', `Shield review failed (--fail-on ${failOn}): ${describeFailOnFailure(verdict, failOn, cacheEligible)}`, 'Fix the issues in the verdict above, then re-run `totem review` (or pass --override <reason> to convert this --fail-on failure to a pass).');
|
|
989
|
+
}
|
|
990
|
+
log.warn(DISPLAY_TAG, `SHIELD OVERRIDE: --fail-on ${failOn} failure converted to pass by override (${override}).`);
|
|
991
|
+
}
|
|
992
|
+
}
|
|
993
|
+
log.success(DISPLAY_TAG, `Review complete — verdict ${verdictHash.slice(0, 8)} (round ${verdict.round.index}, settled=${verdict.settled}).`);
|
|
994
|
+
}
|
|
995
|
+
/**
|
|
996
|
+
* `totem review --covariate` (rev-5 item 4): the EXECUTABLE covariate transport.
|
|
997
|
+
* Read-only and zero-LLM — resolves the CURRENT lineage through exactly the same
|
|
998
|
+
* {@link resolveLineage} path `runReviewFan` uses (never a re-implementation), loads
|
|
999
|
+
* the latest verdict for that lineage, and prints the core-owned
|
|
1000
|
+
* {@link renderCovariateLine} to STDOUT (the skills pipe it into the consolidated
|
|
1001
|
+
* round-disposition comment). No verdict for the lineage ⇒ a LOUD sensor message and
|
|
1002
|
+
* a clean return (exit 0) — the caller learns there is no line to carry, nothing gates.
|
|
1003
|
+
*/
|
|
1004
|
+
export async function printCovariateLine(query) {
|
|
1005
|
+
const { log } = await import('../ui.js');
|
|
1006
|
+
if (query.diffMeta === null) {
|
|
1007
|
+
log.warn(DISPLAY_TAG, 'Covariate: no diff detected — no review lineage resolves, so there is no covariate line to print (sensor; exit 0).');
|
|
1008
|
+
return;
|
|
1009
|
+
}
|
|
1010
|
+
const gitExec = query.gitExec ?? (await defaultGitExec(query.cwd));
|
|
1011
|
+
const lineage = await resolveLineage(query.diffMeta, gitExec);
|
|
1012
|
+
const { findLatestVerdictForLineage, renderCovariateLine } = await import('@mmnto/totem');
|
|
1013
|
+
const verdict = findLatestVerdictForLineage(query.totemDirAbs, lineage.lineageKey, (msg) => log.warn(DISPLAY_TAG, `Sensor: ${msg}`));
|
|
1014
|
+
if (verdict === undefined) {
|
|
1015
|
+
log.warn(DISPLAY_TAG, 'Covariate: no verdict artifact recorded for the current lineage — run `totem review` with review.lanes configured to emit one (sensor; exit 0).');
|
|
1016
|
+
return;
|
|
1017
|
+
}
|
|
1018
|
+
// STDOUT, not the stderr log: this line IS the transport payload (format v1). The
|
|
1019
|
+
// loader returns the artifact WITH its verified stored address (rev-6 item 1), so a
|
|
1020
|
+
// forward-minor verdict advertises its RAW file address — byte-equal to the filename.
|
|
1021
|
+
console.log(renderCovariateLine(verdict));
|
|
1022
|
+
}
|
|
1023
|
+
/** Name the failing cache-eligibility conjunct(s) for an honest exit reason. */
|
|
1024
|
+
function describeIneligibility(inputs) {
|
|
1025
|
+
const parts = [];
|
|
1026
|
+
const completed = inputs.lanes.filter((l) => l.status === 'completed').length;
|
|
1027
|
+
if (!everyLaneCompleted(inputs.lanes)) {
|
|
1028
|
+
parts.push(`lane coverage ${completed}/${inputs.lanes.length} (a lane failed or abstained)`);
|
|
1029
|
+
}
|
|
1030
|
+
const criticals = inputs.findings.filter((f) => f.severity === 'CRITICAL').length;
|
|
1031
|
+
if (criticals > 0) {
|
|
1032
|
+
parts.push(`${criticals} CRITICAL finding(s)`);
|
|
1033
|
+
}
|
|
1034
|
+
const failedRules = inputs.postChecks
|
|
1035
|
+
.filter((r) => r.tier === 'decidable' && r.verdict === 'fail')
|
|
1036
|
+
.map((r) => r.ruleName);
|
|
1037
|
+
if (failedRules.length > 0) {
|
|
1038
|
+
parts.push(`decidable post-check failure(s): ${[...new Set(failedRules)].join(', ')}`);
|
|
1039
|
+
}
|
|
1040
|
+
if (inputs.reviewedState === 'drifted') {
|
|
1041
|
+
parts.push('worktree drifted mid-review (verdict bound to the pre-fan tree; not authorized)');
|
|
1042
|
+
}
|
|
1043
|
+
return parts.length > 0 ? parts.join('; ') : 'not cache-eligible';
|
|
1044
|
+
}
|
|
1045
|
+
/**
|
|
1046
|
+
* The honest reason a `--fail-on <severity>` gate tripped: the findings at/above the
|
|
1047
|
+
* threshold (WARNs are surfaced only for `--fail-on warn`, since they don't affect
|
|
1048
|
+
* cache-eligibility) plus any cache-ineligibility conjunct.
|
|
1049
|
+
*/
|
|
1050
|
+
function describeFailOnFailure(verdict, failOn, cacheEligible) {
|
|
1051
|
+
const parts = [];
|
|
1052
|
+
if (failOn === 'warn') {
|
|
1053
|
+
const warns = verdict.findings.filter((f) => f.severity === 'WARN').length;
|
|
1054
|
+
if (warns > 0)
|
|
1055
|
+
parts.push(`${warns} WARN finding(s)`);
|
|
1056
|
+
}
|
|
1057
|
+
if (!cacheEligible)
|
|
1058
|
+
parts.push(describeIneligibility(verdict));
|
|
1059
|
+
return parts.length > 0 ? parts.join('; ') : `findings at or above ${failOn}`;
|
|
1060
|
+
}
|
|
1061
|
+
/** sha256 hex of a UTF-8 string (the masked-diff payload identity). */
|
|
1062
|
+
async function sha256Hex(text) {
|
|
1063
|
+
const crypto = await import('node:crypto');
|
|
1064
|
+
return crypto.createHash('sha256').update(text, 'utf-8').digest('hex');
|
|
1065
|
+
}
|
|
1066
|
+
/**
|
|
1067
|
+
* Build the lane-validator config-error factory bound to the dynamically-imported core
|
|
1068
|
+
* `TotemConfigError` class (rule 64: no static core VALUE import). Each async entry point
|
|
1069
|
+
* imports the class once and threads the returned `(message, hint) => TotemConfigError`
|
|
1070
|
+
* closure to the sync helpers, so a lane accepted/rejected here still raises a hard
|
|
1071
|
+
* `CONFIG_INVALID` init error.
|
|
1072
|
+
*/
|
|
1073
|
+
function makeLaneConfigErrorFactory(TotemConfigError) {
|
|
1074
|
+
return (message, hint) => new TotemConfigError(message, hint, 'CONFIG_INVALID');
|
|
1075
|
+
}
|
|
1076
|
+
//# sourceMappingURL=review-fan.js.map
|