@mmnto/cli 1.91.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.
Files changed (52) hide show
  1. package/dist/commands/doctor-parity.d.ts +105 -1
  2. package/dist/commands/doctor-parity.d.ts.map +1 -1
  3. package/dist/commands/doctor-parity.js +406 -34
  4. package/dist/commands/doctor-parity.js.map +1 -1
  5. package/dist/commands/doctor-parity.test.js +423 -2
  6. package/dist/commands/doctor-parity.test.js.map +1 -1
  7. package/dist/commands/ecl-gc.test.js +20 -0
  8. package/dist/commands/ecl-gc.test.js.map +1 -1
  9. package/dist/commands/init-templates.d.ts +6 -2
  10. package/dist/commands/init-templates.d.ts.map +1 -1
  11. package/dist/commands/init-templates.js +63 -2
  12. package/dist/commands/init-templates.js.map +1 -1
  13. package/dist/commands/init.test.js +78 -3
  14. package/dist/commands/init.test.js.map +1 -1
  15. package/dist/commands/mail.d.ts.map +1 -1
  16. package/dist/commands/mail.js +36 -0
  17. package/dist/commands/mail.js.map +1 -1
  18. package/dist/commands/mail.test.js +77 -0
  19. package/dist/commands/mail.test.js.map +1 -1
  20. package/dist/commands/review-fan.d.ts +336 -0
  21. package/dist/commands/review-fan.d.ts.map +1 -0
  22. package/dist/commands/review-fan.js +1076 -0
  23. package/dist/commands/review-fan.js.map +1 -0
  24. package/dist/commands/review-fan.test.d.ts +2 -0
  25. package/dist/commands/review-fan.test.d.ts.map +1 -0
  26. package/dist/commands/review-fan.test.js +1184 -0
  27. package/dist/commands/review-fan.test.js.map +1 -0
  28. package/dist/commands/shield-covariate.test.d.ts +14 -0
  29. package/dist/commands/shield-covariate.test.d.ts.map +1 -0
  30. package/dist/commands/shield-covariate.test.js +84 -0
  31. package/dist/commands/shield-covariate.test.js.map +1 -0
  32. package/dist/commands/shield-eval.integration.test.js +57 -13
  33. package/dist/commands/shield-eval.integration.test.js.map +1 -1
  34. package/dist/commands/shield.d.ts +162 -3
  35. package/dist/commands/shield.d.ts.map +1 -1
  36. package/dist/commands/shield.js +342 -74
  37. package/dist/commands/shield.js.map +1 -1
  38. package/dist/commands/shield.test.js +169 -3
  39. package/dist/commands/shield.test.js.map +1 -1
  40. package/dist/git.d.ts +25 -0
  41. package/dist/git.d.ts.map +1 -1
  42. package/dist/git.js +50 -6
  43. package/dist/git.js.map +1 -1
  44. package/dist/git.test.js +119 -14
  45. package/dist/git.test.js.map +1 -1
  46. package/dist/index.js +24 -2
  47. package/dist/index.js.map +1 -1
  48. package/dist/orchestrators/orchestrator.d.ts +1 -0
  49. package/dist/orchestrators/orchestrator.d.ts.map +1 -1
  50. package/dist/orchestrators/orchestrator.js +1 -1
  51. package/dist/orchestrators/orchestrator.js.map +1 -1
  52. package/package.json +2 -2
@@ -290,9 +290,20 @@ function refreshReviewExtensionsFileIfStale(totemDirAbs, extensions, fs, path) {
290
290
  }
291
291
  }
292
292
  /**
293
- * Write the .reviewed-content-hash flag on PASS.
294
- * Uses a content hash of tracked source files (not Git SHA) so the flag
295
- * survives commits, amends, and rebases. Only breaks when source files change.
293
+ * Pure content-hash computation for the reviewed-source flag (Prop 304 R2,
294
+ * codex fold 1). Hashes all tracked source-file objects whose extension is in
295
+ * `extensions` the extension-scoped tracked-source content hash that
296
+ * authorizes an agent push. NO writes: neither the cache flag nor the
297
+ * canonical `review-extensions.txt` refresh happen here, so a caller can
298
+ * compute the hash BEFORE invoking the reviewer and stamp it only if the tree
299
+ * is unchanged afterward — closing the mid-run authorization race.
300
+ *
301
+ * This is a DIFFERENT hash domain from `diffScope.diffHash` (the masked
302
+ * review-payload identity); the two bind different state and are never equal.
303
+ *
304
+ * Returns the hex sha256, or `null` when there are no tracked source files (or
305
+ * the git plumbing is unavailable — the flag is a best-effort hook
306
+ * convenience, so failures are swallowed rather than thrown).
296
307
  *
297
308
  * The `extensions` parameter drives which file types are hashed. Defaults to
298
309
  * the historical hardcoded set for backward compatibility with callers that
@@ -301,33 +312,26 @@ function refreshReviewExtensionsFileIfStale(totemDirAbs, extensions, fs, path) {
301
312
  * glob arguments via safeExec and the regex refinement is the shell-injection
302
313
  * boundary.
303
314
  */
304
- export async function writeReviewedContentHash(cwd, totemDir, configRoot, extensions = LEGACY_REVIEW_SOURCE_EXTENSIONS) {
315
+ export async function computeReviewedContentHash(cwd, configRoot, extensions = LEGACY_REVIEW_SOURCE_EXTENSIONS) {
305
316
  try {
306
- const path = await import('node:path');
307
- const fs = await import('node:fs');
308
317
  const { safeExec } = await import('@mmnto/totem');
309
318
  // Compute content hash: hash of all tracked source file objects
310
319
  const root = configRoot ?? cwd;
311
- const totemDirAbs = path.join(root, totemDir);
312
- // Auto-refresh the canonical file if it drifted from the config's set.
313
- // Closes the stale-canonical-file window without requiring the user to
314
- // re-run `totem sync` after editing totem.config.ts. (#1527)
315
- refreshReviewExtensionsFileIfStale(totemDirAbs, extensions, fs, path);
316
320
  const globArgs = extensions.map((e) => '*' + e);
317
- const files = safeExec('git', ['ls-files', '-z', ...globArgs], {
321
+ const files = safeExec('git', ['ls-files', '-z', '--', ...globArgs], {
318
322
  cwd: root,
319
323
  });
320
324
  if (!files.trim())
321
- return; // No source files — nothing to stamp
325
+ return null; // No source files — nothing to stamp
322
326
  // Filter out deleted files (still in index but missing on disk)
323
- const deleted = new Set(safeExec('git', ['ls-files', '--deleted', '-z', ...globArgs], {
327
+ const deleted = new Set(safeExec('git', ['ls-files', '--deleted', '-z', '--', ...globArgs], {
324
328
  cwd: root,
325
329
  })
326
330
  .split('\0')
327
331
  .filter(Boolean));
328
332
  const existing = files.split('\0').filter((f) => f && !deleted.has(f));
329
333
  if (existing.length === 0)
330
- return;
334
+ return null;
331
335
  const objectHashes = safeExec('git', ['hash-object', '--stdin-paths'], {
332
336
  cwd: root,
333
337
  input: existing.join('\n'),
@@ -335,11 +339,39 @@ export async function writeReviewedContentHash(cwd, totemDir, configRoot, extens
335
339
  const crypto = await import('node:crypto');
336
340
  // Ensure trailing newline to match bash pipeline output (sha256sum sees it)
337
341
  const normalizedHashes = objectHashes.endsWith('\n') ? objectHashes : objectHashes + '\n';
338
- const contentHash = crypto.createHash('sha256').update(normalizedHashes).digest('hex');
342
+ return crypto.createHash('sha256').update(normalizedHashes).digest('hex'); // totem-context: intentional cleanup — best-effort hook-convenience hash; failure degrades to no-stamp (TOTEM_DEBUG-only log), pre-refactor behavior per #1527
343
+ }
344
+ catch (err) {
345
+ // Non-fatal — flag is a convenience for PreToolUse hooks
346
+ if (process.env['TOTEM_DEBUG'] === '1') {
347
+ console.error('[Review] Failed to compute .reviewed-content-hash:', err instanceof Error ? err.message : err);
348
+ }
349
+ return null;
350
+ }
351
+ }
352
+ /**
353
+ * Stamp `<totemDir>/cache/.reviewed-content-hash` with EXACTLY the supplied
354
+ * hash — never recomputes (Prop 304 R2, codex fold 1). Also refreshes the
355
+ * canonical `review-extensions.txt` so the bash pre-push hook keys off the
356
+ * same extension set (#1527). Best-effort; a write failure is non-fatal (the
357
+ * flag is a PreToolUse-hook convenience). The caller owns hash provenance:
358
+ * pass the pre-fan hash so the stamp authorizes the exact tree that was
359
+ * reviewed, not whatever the tree happens to be at stamp time.
360
+ */
361
+ export async function writeReviewedContentHashValue(precomputedHash, cwd, totemDir, configRoot, extensions = LEGACY_REVIEW_SOURCE_EXTENSIONS) {
362
+ try {
363
+ const path = await import('node:path');
364
+ const fs = await import('node:fs');
365
+ const root = configRoot ?? cwd;
366
+ const totemDirAbs = path.join(root, totemDir);
367
+ // Auto-refresh the canonical file if it drifted from the config's set.
368
+ // Closes the stale-canonical-file window without requiring the user to
369
+ // re-run `totem sync` after editing totem.config.ts. (#1527)
370
+ refreshReviewExtensionsFileIfStale(totemDirAbs, extensions, fs, path);
339
371
  const cacheDir = path.join(totemDirAbs, 'cache');
340
372
  if (!fs.existsSync(cacheDir))
341
373
  fs.mkdirSync(cacheDir, { recursive: true });
342
- fs.writeFileSync(path.join(cacheDir, '.reviewed-content-hash'), contentHash);
374
+ fs.writeFileSync(path.join(cacheDir, '.reviewed-content-hash'), precomputedHash);
343
375
  }
344
376
  catch (err) {
345
377
  // Non-fatal — flag is a convenience for PreToolUse hooks
@@ -349,29 +381,92 @@ export async function writeReviewedContentHash(cwd, totemDir, configRoot, extens
349
381
  }
350
382
  }
351
383
  /**
352
- * Record a shield override: append the override event to the Trap Ledger
353
- * AND stamp the reviewed-content-hash so the push-gate hook unblocks.
384
+ * Write the .reviewed-content-hash flag on PASS.
385
+ * Uses a content hash of tracked source files (not Git SHA) so the flag
386
+ * survives commits, amends, and rebases. Only breaks when source files change.
354
387
  *
355
- * mmnto-ai/totem#1716: prior to this helper the override branch only wrote the ledger
356
- * entry; the missing stamp left the contributor stuck behind the push-gate
357
- * with a tribal-knowledge `git reset --soft HEAD~1 && totem review --staged`
358
- * workaround. Override is a legitimate completion path (with logged
359
- * justification) and must produce the same cache state as a passing review.
388
+ * Now a thin compose of the pure computer + explicit writer (Prop 304 R2): it
389
+ * hashes the CURRENT tree and stamps it. Retained at its original signature
390
+ * for the trivial fast-path stamps (no-changes / all-non-code / filtered-empty
391
+ * none of which open a mid-run LLM window) and `recordShieldOverride`, where
392
+ * there is no drift race to guard. The LLM review path does NOT use this — it
393
+ * captures the hash pre-fan and compare-and-stamps in `shieldCommand` /
394
+ * `handleVerdictResult` so a mid-review edit can never be authorized.
360
395
  */
361
- export async function recordShieldOverride(params) {
396
+ export async function writeReviewedContentHash(cwd, totemDir, configRoot, extensions = LEGACY_REVIEW_SOURCE_EXTENSIONS) {
397
+ const hash = await computeReviewedContentHash(cwd, configRoot, extensions);
398
+ if (hash === null)
399
+ return;
400
+ await writeReviewedContentHashValue(hash, cwd, totemDir, configRoot, extensions);
401
+ }
402
+ /** Append the shield-override event to the Trap Ledger (shared by both override paths). */
403
+ async function appendShieldOverrideLedgerEvent(cwd, totemDir, configRoot, override) {
362
404
  const path = await import('node:path');
363
405
  const { appendLedgerEvent } = await import('@mmnto/totem');
364
- const resolvedTotemDir = path.join(params.configRoot ?? params.cwd, params.totemDir);
406
+ const resolvedTotemDir = path.join(configRoot ?? cwd, totemDir);
365
407
  appendLedgerEvent(resolvedTotemDir, {
366
408
  timestamp: new Date().toISOString(),
367
409
  type: 'override',
368
410
  ruleId: 'shield-override',
369
411
  file: '(shield)',
370
- justification: params.override,
412
+ justification: override,
371
413
  source: 'shield',
372
414
  }, (msg) => log.dim(DISPLAY_TAG, msg));
415
+ }
416
+ /**
417
+ * Record a shield override: append the override event to the Trap Ledger
418
+ * AND stamp the reviewed-content-hash so the push-gate hook unblocks.
419
+ *
420
+ * mmnto-ai/totem#1716: prior to this helper the override branch only wrote the ledger
421
+ * entry; the missing stamp left the contributor stuck behind the push-gate
422
+ * with a tribal-knowledge `git reset --soft HEAD~1 && totem review --staged`
423
+ * workaround. Override is a legitimate completion path (with logged
424
+ * justification) and must produce the same cache state as a passing review.
425
+ *
426
+ * LEGACY SINGLE-LANE ONLY: this stamps the CURRENT tree hash (a recompute). The
427
+ * multi-lane fan must never use it — its long LLM window makes a mid-run edit real,
428
+ * so the fan goes through {@link recordShieldOverrideWithExpectedHash}, which binds
429
+ * the PRE-FAN hash and refuses to stamp a tree that no longer matches it (Prop 304
430
+ * rev-5 item 1).
431
+ */
432
+ export async function recordShieldOverride(params) {
433
+ await appendShieldOverrideLedgerEvent(params.cwd, params.totemDir, params.configRoot, params.override);
373
434
  await writeReviewedContentHash(params.cwd, params.totemDir, params.configRoot, params.sourceExtensions);
374
435
  }
436
+ /**
437
+ * Ledger + EXPLICIT-HASH override primitive (Prop 304 rev-5 item 1 — codex critical).
438
+ *
439
+ * `--override` on the fan path must never stamp an UNREVIEWED tree: the fan's one
440
+ * post-fan compare happens before verdict assembly, so an edit landing after that
441
+ * compare but before the stamp would — under `recordShieldOverride`'s current-tree
442
+ * recompute — be stamped as reviewed. This primitive closes that window:
443
+ *
444
+ * 1. The override event is ALWAYS appended to the Trap Ledger (the operator's
445
+ * justification is auditable whether or not a stamp lands).
446
+ * 2. IMMEDIATELY ADJACENT to the stamp write, the current tree hash is recomputed
447
+ * once more and compared to the caller's PRE-FAN `expectedContentHash`.
448
+ * 3. Match ⇒ stamp EXACTLY `expectedContentHash` via the explicit writer (never a
449
+ * recompute value). Mismatch ⇒ LOUD refusal, no stamp — the ledger records the
450
+ * override WITHOUT a stamp, and the return value says so.
451
+ *
452
+ * Returns `{ stamped }` so the caller can report honestly.
453
+ */
454
+ export async function recordShieldOverrideWithExpectedHash(params) {
455
+ await appendShieldOverrideLedgerEvent(params.cwd, params.totemDir, params.configRoot, params.override);
456
+ if (params.expectedContentHash === null)
457
+ return { stamped: false };
458
+ const computeCurrentHash = params.computeCurrentHash ??
459
+ (() => computeReviewedContentHash(params.cwd, params.configRoot, params.sourceExtensions));
460
+ // The adjacent recompute — the LAST read before the stamp write. Any tree mutation
461
+ // after the fan's own compare (which fed reviewedState) is caught here.
462
+ const currentHash = await computeCurrentHash();
463
+ if (currentHash !== params.expectedContentHash) {
464
+ log.warn(DISPLAY_TAG, 'OVERRIDE STAMP REFUSED: the tracked-source tree changed after the review compared it (current hash no longer matches the pre-review hash). The override was recorded in the Trap Ledger WITHOUT a stamp — this override does not authorize a push. Re-run `totem review` against the current tree.');
465
+ return { stamped: false };
466
+ }
467
+ await writeReviewedContentHashValue(params.expectedContentHash, params.cwd, params.totemDir, params.configRoot, params.sourceExtensions);
468
+ return { stamped: true };
469
+ }
375
470
  // ─── Deterministic mode (delegates to shared engine) ─
376
471
  // ─── Learn: extract lessons from failed verdict ─────
377
472
  export async function learnFromVerdict(verdictContent, diff, options, config, cwd, configRoot) {
@@ -537,71 +632,126 @@ export async function captureObservationRules(findings, cwd, config, configRoot)
537
632
  const manifest = readCompileManifest(manifestPath);
538
633
  manifest.output_hash = generateOutputHash(rulesPath);
539
634
  writeCompileManifest(manifestPath, manifest);
635
+ // totem-context: intentional — the compile manifest may not exist yet (first run before compile); a missing manifest is not an error, verify-manifest resyncs later.
540
636
  }
541
- catch {
542
- // Non-fatal — manifest may not exist yet (e.g. first run before compile)
637
+ catch (err) {
638
+ // Non-fatal — but only ENOENT (no manifest yet) is fully silent; a
639
+ // malformed/permission failure surfaces under TOTEM_DEBUG (PR #2337 CR).
640
+ if (err.code !== 'ENOENT' && process.env['TOTEM_DEBUG'] === '1') {
641
+ log.dim(DISPLAY_TAG, `Manifest re-hash failed: ${err instanceof Error ? err.message : String(err)}`);
642
+ }
543
643
  }
644
+ // totem-context: intentional — Pipeline 5 auto-capture is best-effort; it must never crash the shield command, so any failure degrades to a TOTEM_DEBUG log (no rethrow).
544
645
  }
545
646
  catch (err) {
546
- // Non-fatal — auto-capture should never crash the shield command
547
647
  if (process.env['TOTEM_DEBUG'] === '1') {
548
648
  log.dim(DISPLAY_TAG, `Pipeline 5 save failed: ${err instanceof Error ? err.message : String(err)}`);
549
649
  }
550
650
  }
551
651
  }
652
+ /**
653
+ * Pure per-lane outcome derivation (Prop 304 R2 — codex fold 3). Runs the
654
+ * single shared `extractStructuredVerdict` cascade, applies the exemption
655
+ * filter, and computes conformance, with NO display / cache / throw side
656
+ * effects. Unextractable output surfaces as `structuredVerdict: null` (a
657
+ * distinguishable abstention) rather than a throw, so a fan lane can record it
658
+ * as `abstained`.
659
+ *
660
+ * `shared` exemptions are passed IN (not read from disk) to keep this
661
+ * side-effect-free; the caller owns exemption I/O and any `--suppress`
662
+ * mutation before invoking.
663
+ */
664
+ export async function deriveLaneOutcome(content, shared) {
665
+ const structuredVerdict = extractStructuredVerdict(content);
666
+ if (!structuredVerdict) {
667
+ return { structuredVerdict: null, filteredFindings: [], exemptedFindings: [], pass: false };
668
+ }
669
+ const { filterExemptedFindings } = await import('../exemptions/exemption-engine.js');
670
+ const { filtered, exempted } = filterExemptedFindings(structuredVerdict.findings, shared);
671
+ const { pass } = computeVerdict({ ...structuredVerdict, findings: filtered });
672
+ return {
673
+ structuredVerdict,
674
+ filteredFindings: filtered,
675
+ exemptedFindings: exempted,
676
+ pass,
677
+ };
678
+ }
679
+ /**
680
+ * Two-hash-domains authorization fix (Prop 304 R2, codex fold 1). On a PASS,
681
+ * re-hash the CURRENT tracked-source tree and compare to the `preFanContentHash`
682
+ * captured before the reviewer ran. A mismatch means a mid-review edit landed:
683
+ * the verdict is bound to a tree that no longer exists on disk, so refuse to
684
+ * stamp — and say so loudly. On an unchanged tree, stamp EXACTLY the pre-fan
685
+ * hash (never a recompute) via the explicit writer.
686
+ *
687
+ * A `null` pre-fan hash means there were no tracked source files (or git
688
+ * plumbing was unavailable) before the fan; the legacy path wrote nothing in
689
+ * that case either, so this is a no-op — preserving prior behavior.
690
+ */
691
+ export async function stampReviewedContentHashIfTreeUnchanged(preFanContentHash, cwd, config, configRoot) {
692
+ if (preFanContentHash === null)
693
+ return;
694
+ const currentHash = await computeReviewedContentHash(cwd, configRoot, config.review.sourceExtensions);
695
+ if (currentHash !== preFanContentHash) {
696
+ log.warn(DISPLAY_TAG, 'WORKTREE DRIFT: tracked source files changed during review. The verdict is bound to the pre-review tree, so the reviewed-content-hash was NOT stamped — this review does not authorize a push. Re-run `totem review` against the current tree.');
697
+ return;
698
+ }
699
+ await writeReviewedContentHashValue(preFanContentHash, cwd, config.totemDir, configRoot, config.review.sourceExtensions);
700
+ }
552
701
  // ─── Shared verdict handler ─────────────────────────
553
- async function handleVerdictResult(content, diff, options, config, cwd, configRoot, modeLabel) {
702
+ async function handleVerdictResult(content, diff, options, config, cwd, configRoot, modeLabel, preFanContentHash) {
554
703
  const { TotemError } = await import('@mmnto/totem');
555
704
  writeOutput(content, options.out);
556
705
  if (options.out)
557
706
  log.success(DISPLAY_TAG, `Written to ${options.out}`);
558
707
  if (options.raw)
559
708
  return;
560
- // Try structured parsing first (V2)
561
- const structured = extractStructuredVerdict(content);
562
- if (structured) {
563
- // ─── Exemption filtering ───────────────────────────
564
- const pathMod = await import('node:path');
565
- const resolvedTotemDir = pathMod.join(configRoot ?? cwd, config.totemDir);
566
- const cacheDir = pathMod.join(resolvedTotemDir, 'cache');
567
- const { readSharedExemptions, writeSharedExemptions } = await import('../exemptions/exemption-store.js');
568
- const { filterExemptedFindings, addManualSuppression } = await import('../exemptions/exemption-engine.js');
569
- let shared = readSharedExemptions(resolvedTotemDir, (msg) => log.dim(DISPLAY_TAG, msg));
570
- // Apply manual --suppress flags
571
- if (options.suppress?.length) {
572
- const { appendLedgerEvent: appendExemptionEvent } = await import('@mmnto/totem');
573
- for (const label of options.suppress) {
574
- if (!label.trim())
575
- continue;
576
- shared = addManualSuppression(shared, label, `Manual suppression via --suppress`);
577
- log.info(DISPLAY_TAG, `Suppression registered: ${label}`);
578
- appendExemptionEvent(resolvedTotemDir, {
579
- timestamp: new Date().toISOString(),
580
- type: 'exemption',
581
- ruleId: 'exemption-manual',
582
- file: '(shield)',
583
- justification: `--suppress ${label}`,
584
- source: 'shield',
585
- }, (msg) => log.dim(DISPLAY_TAG, msg));
586
- }
587
- writeSharedExemptions(resolvedTotemDir, shared, (msg) => log.dim(DISPLAY_TAG, msg));
709
+ // ─── Exemption I/O + --suppress (side effects live in the shell) ──
710
+ const pathMod = await import('node:path');
711
+ const resolvedTotemDir = pathMod.join(configRoot ?? cwd, config.totemDir);
712
+ const cacheDir = pathMod.join(resolvedTotemDir, 'cache');
713
+ const { readSharedExemptions, writeSharedExemptions } = await import('../exemptions/exemption-store.js');
714
+ const { addManualSuppression } = await import('../exemptions/exemption-engine.js');
715
+ let shared = readSharedExemptions(resolvedTotemDir, (msg) => log.dim(DISPLAY_TAG, msg));
716
+ // Apply manual --suppress flags
717
+ if (options.suppress?.length) {
718
+ const { appendLedgerEvent: appendExemptionEvent } = await import('@mmnto/totem');
719
+ for (const label of options.suppress) {
720
+ if (!label.trim())
721
+ continue;
722
+ shared = addManualSuppression(shared, label, `Manual suppression via --suppress`);
723
+ log.info(DISPLAY_TAG, `Suppression registered: ${label}`);
724
+ appendExemptionEvent(resolvedTotemDir, {
725
+ timestamp: new Date().toISOString(),
726
+ type: 'exemption',
727
+ ruleId: 'exemption-manual',
728
+ file: '(shield)',
729
+ justification: `--suppress ${label}`,
730
+ source: 'shield',
731
+ }, (msg) => log.dim(DISPLAY_TAG, msg));
588
732
  }
589
- // Filter exempted findings
590
- const { filtered, exempted } = filterExemptedFindings(structured.findings, shared);
733
+ writeSharedExemptions(resolvedTotemDir, shared, (msg) => log.dim(DISPLAY_TAG, msg));
734
+ }
735
+ // Pure lane derivation: extract → exemption filter → conformance.
736
+ const outcome = await deriveLaneOutcome(content, shared);
737
+ // Try structured parsing first (V2)
738
+ if (outcome.structuredVerdict) {
739
+ const structured = outcome.structuredVerdict;
740
+ const filtered = outcome.filteredFindings;
741
+ const exempted = outcome.exemptedFindings;
591
742
  if (exempted.length > 0) {
592
743
  log.dim(DISPLAY_TAG, `${exempted.length} finding(s) exempted by suppression rules`);
593
744
  }
594
745
  // Use filtered verdict for pass/fail, but show all findings in display
595
746
  const filteredVerdict = { ...structured, findings: [...filtered, ...exempted] };
596
- const verdict = computeVerdict({ ...structured, findings: filtered });
597
- const display = formatVerdictForDisplay(filteredVerdict, verdict.pass);
747
+ const display = formatVerdictForDisplay(filteredVerdict, outcome.pass);
598
748
  console.error(display);
599
749
  // ─── Pipeline 5: auto-capture observation rules ──
600
750
  if (options.autoCapture === true) {
601
751
  await captureObservationRules(filtered, cwd, config, configRoot);
602
752
  }
603
- if (verdict.pass) {
604
- await writeReviewedContentHash(cwd, config.totemDir, configRoot, config.review.sourceExtensions);
753
+ if (outcome.pass) {
754
+ await stampReviewedContentHashIfTreeUnchanged(preFanContentHash, cwd, config, configRoot);
605
755
  }
606
756
  else if (options.override) {
607
757
  const criticalFindings = filtered.filter((f) => f.severity === 'CRITICAL');
@@ -644,7 +794,10 @@ async function handleVerdictResult(content, diff, options, config, cwd, configRo
644
794
  if (options.learn || config.shieldAutoLearn) {
645
795
  await learnFromVerdict(JSON.stringify(structured, null, 2), diff, options, config, cwd, configRoot);
646
796
  }
647
- throw new TotemError('SHIELD_FAILED', `Shield ${modeLabel} review failed: ${verdict.reason}`, 'Fix the issues identified in the review above, then re-run `totem review`.');
797
+ // Recompute the reason string for the failure message (the pure lane
798
+ // outcome carries `pass` but not the human reason).
799
+ const { reason } = computeVerdict({ ...structured, findings: filtered });
800
+ throw new TotemError('SHIELD_FAILED', `Shield ${modeLabel} review failed: ${reason}`, 'Fix the issues identified in the review above, then re-run `totem review`.');
648
801
  }
649
802
  return;
650
803
  }
@@ -656,7 +809,7 @@ async function handleVerdictResult(content, diff, options, config, cwd, configRo
656
809
  // totem-context: reason is either empty string or pre-prefixed with ' — ', so direct concat is intentional
657
810
  log.info(DISPLAY_TAG, `Verdict: ${verdictLabel}${reason}`);
658
811
  if (verdict.pass) {
659
- await writeReviewedContentHash(cwd, config.totemDir, configRoot, config.review.sourceExtensions);
812
+ await stampReviewedContentHashIfTreeUnchanged(preFanContentHash, cwd, config, configRoot);
660
813
  }
661
814
  else if (options.override) {
662
815
  log.warn(DISPLAY_TAG, `SHIELD OVERRIDE APPLIED: ${options.override}`);
@@ -788,24 +941,84 @@ export async function shieldCommand(options) {
788
941
  throw new TotemConfigError(`--override reason must be at least 10 characters (got ${options.override.length}).`, 'Provide a meaningful justification, e.g., --override "False positive: onWarn param visible at line 273"', 'CONFIG_INVALID');
789
942
  }
790
943
  const cwd = process.cwd();
791
- // Silently upgrade the pre-push hook if it lacks review auto-refresh (#1045)
792
- const { upgradePrePushHookIfNeeded } = await import('./install-hooks.js');
793
- if (upgradePrePushHookIfNeeded(cwd)) {
794
- log.dim(DISPLAY_TAG, 'Upgraded pre-push hook with review auto-refresh');
944
+ // Silently upgrade the pre-push hook if it lacks review auto-refresh (#1045).
945
+ // Skipped under --covariate: that verb is read-only by contract (rev-5 item 4).
946
+ if (!options.covariate) {
947
+ const { upgradePrePushHookIfNeeded } = await import('./install-hooks.js');
948
+ if (upgradePrePushHookIfNeeded(cwd)) {
949
+ log.dim(DISPLAY_TAG, 'Upgraded pre-push hook with review auto-refresh');
950
+ }
795
951
  }
796
952
  const configPath = resolveConfigPath(cwd);
797
953
  const configRoot = path.dirname(configPath);
798
954
  loadEnv(cwd);
799
955
  const config = await loadConfig(configPath);
956
+ // ── Executable covariate transport (Prop 304 rev-5 item 4) — read-only, zero-LLM ──
957
+ // Short-circuits BEFORE engine boot, fan activation, and every stamp-bearing
958
+ // fast-path: `--covariate` resolves the current lineage via the SAME
959
+ // getDiffForReview → resolveLineage path the fan uses, prints the latest verdict's
960
+ // core-owned covariate line, and exits 0. A no-diff resolution is handled inside
961
+ // printCovariateLine as a loud sensor message (never the trivial-pass stamp the
962
+ // ordinary no-diff path performs — this verb writes nothing).
963
+ if (options.covariate) {
964
+ const diffResult = await getDiffForReview(options, config, cwd, DISPLAY_TAG);
965
+ const { printCovariateLine } = await import('./review-fan.js');
966
+ await printCovariateLine({
967
+ diffMeta: diffResult === null
968
+ ? null
969
+ : {
970
+ source: diffResult.source,
971
+ base: diffResult.base,
972
+ head: diffResult.head,
973
+ selectorForm: diffResult.selectorForm,
974
+ },
975
+ totemDirAbs: path.join(configRoot, config.totemDir),
976
+ cwd,
977
+ });
978
+ return;
979
+ }
800
980
  // Engine boot (mmnto-ai/totem#1794) — see lint.ts wiring for context.
801
981
  const { bootstrapEngine } = await import('../utils/bootstrap-engine.js');
802
982
  await bootstrapEngine(config, configRoot);
983
+ // ── Multi-lane review fan activation (Prop 304 R2, mmnto-ai/totem#2106) ──
984
+ // Validate `review.lanes` at review startup (a hard init error on any
985
+ // violation) and normalize. An explicit `--model` selects a ONE-lane
986
+ // invocation and never joins the configured fan (precedence pinned); the fan
987
+ // also does not apply to structural mode (context-blind single-lane stays
988
+ // legacy). `review.lanes` absent ⇒ [] ⇒ the legacy single-lane path runs
989
+ // byte-for-byte as today (invariant 7).
990
+ const { validateReviewLanes, assertFanFlagsSupported } = await import('./review-fan.js');
991
+ const laneModels = validateReviewLanes(config.review.lanes, config.orchestrator?.provider, TotemConfigError);
992
+ // Finding 1: a fan-configured `--raw` stays the legacy ZERO-LLM context dump (no
993
+ // invokers, no verdict/run artifacts, `--out` behaves as legacy) — `--raw`
994
+ // DEACTIVATES the fan so the run falls through to the legacy raw path below.
995
+ const fanActive = laneModels.length >= 1 &&
996
+ options.model === undefined &&
997
+ options.mode !== 'structural' &&
998
+ !options.raw;
999
+ // Finding 12: when the fan is active, reject flags with no defined fan semantics
1000
+ // LOUDLY (naming the unsupported combination) rather than silently ignoring them.
1001
+ if (fanActive)
1002
+ assertFanFlagsSupported(options, TotemConfigError);
1003
+ // Gate G5: validate `--fail-on` (only the fan reads it, but a bad value is a hard
1004
+ // config error on any path so the user is never silently ignored).
1005
+ if (options.failOn !== undefined && options.failOn !== 'critical' && options.failOn !== 'warn') {
1006
+ throw new TotemConfigError(`Invalid --fail-on "${options.failOn}". Use "critical" or "warn".`, 'Pass --fail-on critical (exit non-zero on CRITICAL findings) or --fail-on warn (WARN or CRITICAL). Omit it for the default sensor exit 0.', 'CONFIG_INVALID');
1007
+ }
803
1008
  // --- Incremental shield fast-path (#1010) ---
804
1009
  // If the change since the last passed shield is small enough (< 15 lines,
805
1010
  // no new files), only evaluate the delta instead of the full branch diff.
1011
+ // The fan needs full diff-scope metadata (source/base/head) for lineage, so
1012
+ // the incremental fast-path is bypassed when the fan is active.
806
1013
  let diff;
807
1014
  let changedFiles;
808
- const incremental = await evaluateIncrementalEligibility(cwd, config.totemDir, configRoot);
1015
+ // Resolved diff-scope metadata (Prop 304 R2) — captured for the fan's verdict
1016
+ // `diffScope` + lineage. Only populated on the full-diff path (the fan bypasses
1017
+ // the incremental fast-path), so it is defined whenever `fanActive`.
1018
+ let diffScopeMeta;
1019
+ const incremental = fanActive
1020
+ ? { eligible: false, reason: 'multi-lane fan requires full diff scope' }
1021
+ : await evaluateIncrementalEligibility(cwd, config.totemDir, configRoot);
809
1022
  if (incremental.eligible && incremental.deltaDiff && incremental.changedFiles) {
810
1023
  log.info(DISPLAY_TAG, `Incremental review: ${incremental.linesChanged} line(s) since last pass`);
811
1024
  diff = incremental.deltaDiff;
@@ -825,6 +1038,14 @@ export async function shieldCommand(options) {
825
1038
  }
826
1039
  diff = diffResult.diff;
827
1040
  changedFiles = diffResult.changedFiles;
1041
+ diffScopeMeta = {
1042
+ source: diffResult.source,
1043
+ base: diffResult.base,
1044
+ head: diffResult.head,
1045
+ // Finding 10: the raw CLI selector form so `--diff main` and `--diff main..HEAD`
1046
+ // (same resolved refs) do NOT share a lineage.
1047
+ selectorForm: diffResult.selectorForm,
1048
+ };
828
1049
  }
829
1050
  // Stage 1: Classify files — fast-path for non-code-only diffs
830
1051
  const classification = classifyChangedFiles(changedFiles);
@@ -877,6 +1098,13 @@ export async function shieldCommand(options) {
877
1098
  if (fileContext) {
878
1099
  log.dim(DISPLAY_TAG, `File context: ${(fileContext.length / 1024).toFixed(0)}KB`);
879
1100
  }
1101
+ // Two hash domains (Prop 304 R2, codex fold 1): capture the extension-scoped
1102
+ // tracked-source content hash ONCE, before the reviewer runs, so a PASS
1103
+ // stamp authorizes the EXACT tree that was reviewed. The shipped code
1104
+ // recomputed this hash after the LLM returned, racing any mid-review edit;
1105
+ // `handleVerdictResult` now compare-and-stamps against this pre-fan value and
1106
+ // refuses to stamp on drift. Distinct from the review payload's `diffHash`.
1107
+ const preFanContentHash = await computeReviewedContentHash(cwd, configRoot, config.review.sourceExtensions);
880
1108
  // Structural mode — context-blind LLM review, no embeddings, no Totem knowledge
881
1109
  if (options.mode === 'structural') {
882
1110
  log.info(DISPLAY_TAG, 'Running structural review (context-blind, no Totem knowledge)...');
@@ -896,7 +1124,7 @@ export async function shieldCommand(options) {
896
1124
  throw new TotemError('SHIELD_FAILED', 'Orchestrator returned no content (defaulting to FAIL).', 'Check your orchestrator API key and model configuration.');
897
1125
  }
898
1126
  if (content != null) {
899
- await handleVerdictResult(content, diff, options, config, cwd, configRoot, 'structural');
1127
+ await handleVerdictResult(content, diff, options, config, cwd, configRoot, 'structural', preFanContentHash);
900
1128
  }
901
1129
  return;
902
1130
  }
@@ -933,6 +1161,46 @@ export async function shieldCommand(options) {
933
1161
  const { ADMISSION_COMPLETION_ONLY, calculateDeterministicHash, summarizeProvenance } = await import('@mmnto/totem');
934
1162
  const { buildRetrievalGroundingBundle } = await import('../utils.js');
935
1163
  const groundingBundle = buildRetrievalGroundingBundle(context);
1164
+ // ── Multi-lane review fan (Prop 304 R2, mmnto-ai/totem#2106) ──
1165
+ // When `review.lanes` is configured (and neither --model nor structural mode
1166
+ // opts out), fan the IDENTICAL assembled prompt across every lane, converge on
1167
+ // a verdict artifact, and enforce the cache-eligibility exit contract. The
1168
+ // legacy single-lane path below is left byte-for-byte unchanged (invariant 7).
1169
+ if (fanActive) {
1170
+ if (diffScopeMeta === undefined) {
1171
+ // Unreachable: the fan bypasses the incremental fast-path, so the full
1172
+ // getDiffForReview path always populated diffScopeMeta. Fail loud, never a
1173
+ // silent scope guess (Tenet 4).
1174
+ throw new TotemError('SHIELD_FAILED', 'Internal: diff-scope metadata was not resolved for the review fan.', 'Re-run `totem review`; report this if it recurs.');
1175
+ }
1176
+ // Exemptions are read once here and passed in side-effect-free (the fan is
1177
+ // pure over them). --suppress mutation is not wired into the fan this slice;
1178
+ // committed shared exemptions still filter each lane.
1179
+ const { readSharedExemptions } = await import('../exemptions/exemption-store.js');
1180
+ const resolvedTotemDir = path.join(configRoot, config.totemDir);
1181
+ const shared = readSharedExemptions(resolvedTotemDir, (msg) => log.dim(DISPLAY_TAG, msg));
1182
+ const { runReviewFan } = await import('./review-fan.js');
1183
+ await runReviewFan({
1184
+ laneModels,
1185
+ prompt,
1186
+ filteredDiff,
1187
+ diffMeta: diffScopeMeta,
1188
+ config,
1189
+ cwd,
1190
+ configRoot,
1191
+ totemDirAbs: resolvedTotemDir,
1192
+ options,
1193
+ groundingHash: calculateDeterministicHash(groundingBundle),
1194
+ provenanceSummary: summarizeProvenance(groundingBundle),
1195
+ groundingBundle,
1196
+ totalResults,
1197
+ codeBlind: codeBlindGuard.codeBlind,
1198
+ shared,
1199
+ preFanContentHash,
1200
+ continues: options.continues,
1201
+ });
1202
+ return;
1203
+ }
936
1204
  const content = await runOrchestrator({
937
1205
  prompt,
938
1206
  tag: TAG,
@@ -955,7 +1223,7 @@ export async function shieldCommand(options) {
955
1223
  },
956
1224
  });
957
1225
  if (content != null) {
958
- await handleVerdictResult(content, diff, options, config, cwd, configRoot, 'standard');
1226
+ await handleVerdictResult(content, diff, options, config, cwd, configRoot, 'standard', preFanContentHash);
959
1227
  }
960
1228
  }
961
1229
  //# sourceMappingURL=shield.js.map