@mmnto/cli 1.92.0 → 1.94.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/dist/commands/doctor-parity.d.ts.map +1 -1
  2. package/dist/commands/doctor-parity.js +23 -4
  3. package/dist/commands/doctor-parity.js.map +1 -1
  4. package/dist/commands/doctor-parity.test.js +135 -1
  5. package/dist/commands/doctor-parity.test.js.map +1 -1
  6. package/dist/commands/init-templates.d.ts +6 -2
  7. package/dist/commands/init-templates.d.ts.map +1 -1
  8. package/dist/commands/init-templates.js +63 -2
  9. package/dist/commands/init-templates.js.map +1 -1
  10. package/dist/commands/init.test.js +78 -3
  11. package/dist/commands/init.test.js.map +1 -1
  12. package/dist/commands/mail.d.ts.map +1 -1
  13. package/dist/commands/mail.js +25 -0
  14. package/dist/commands/mail.js.map +1 -1
  15. package/dist/commands/mail.test.js +80 -0
  16. package/dist/commands/mail.test.js.map +1 -1
  17. package/dist/commands/review-fan.d.ts +336 -0
  18. package/dist/commands/review-fan.d.ts.map +1 -0
  19. package/dist/commands/review-fan.js +1076 -0
  20. package/dist/commands/review-fan.js.map +1 -0
  21. package/dist/commands/review-fan.test.d.ts +2 -0
  22. package/dist/commands/review-fan.test.d.ts.map +1 -0
  23. package/dist/commands/review-fan.test.js +1184 -0
  24. package/dist/commands/review-fan.test.js.map +1 -0
  25. package/dist/commands/shield-covariate.test.d.ts +14 -0
  26. package/dist/commands/shield-covariate.test.d.ts.map +1 -0
  27. package/dist/commands/shield-covariate.test.js +84 -0
  28. package/dist/commands/shield-covariate.test.js.map +1 -0
  29. package/dist/commands/shield.d.ts +162 -3
  30. package/dist/commands/shield.d.ts.map +1 -1
  31. package/dist/commands/shield.js +342 -74
  32. package/dist/commands/shield.js.map +1 -1
  33. package/dist/commands/shield.test.js +169 -3
  34. package/dist/commands/shield.test.js.map +1 -1
  35. package/dist/git.d.ts +25 -0
  36. package/dist/git.d.ts.map +1 -1
  37. package/dist/git.js +50 -6
  38. package/dist/git.js.map +1 -1
  39. package/dist/git.test.js +119 -14
  40. package/dist/git.test.js.map +1 -1
  41. package/dist/index.js +11 -0
  42. package/dist/index.js.map +1 -1
  43. package/dist/orchestrators/orchestrator.d.ts +1 -0
  44. package/dist/orchestrators/orchestrator.d.ts.map +1 -1
  45. package/dist/orchestrators/orchestrator.js +1 -1
  46. package/dist/orchestrators/orchestrator.js.map +1 -1
  47. 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