@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.
- package/dist/commands/doctor-parity.d.ts.map +1 -1
- package/dist/commands/doctor-parity.js +23 -4
- package/dist/commands/doctor-parity.js.map +1 -1
- package/dist/commands/doctor-parity.test.js +135 -1
- package/dist/commands/doctor-parity.test.js.map +1 -1
- package/dist/commands/init-templates.d.ts +6 -2
- package/dist/commands/init-templates.d.ts.map +1 -1
- package/dist/commands/init-templates.js +63 -2
- package/dist/commands/init-templates.js.map +1 -1
- package/dist/commands/init.test.js +78 -3
- package/dist/commands/init.test.js.map +1 -1
- package/dist/commands/mail.d.ts.map +1 -1
- package/dist/commands/mail.js +25 -0
- package/dist/commands/mail.js.map +1 -1
- package/dist/commands/mail.test.js +80 -0
- package/dist/commands/mail.test.js.map +1 -1
- package/dist/commands/review-fan.d.ts +336 -0
- package/dist/commands/review-fan.d.ts.map +1 -0
- package/dist/commands/review-fan.js +1076 -0
- package/dist/commands/review-fan.js.map +1 -0
- package/dist/commands/review-fan.test.d.ts +2 -0
- package/dist/commands/review-fan.test.d.ts.map +1 -0
- package/dist/commands/review-fan.test.js +1184 -0
- package/dist/commands/review-fan.test.js.map +1 -0
- package/dist/commands/shield-covariate.test.d.ts +14 -0
- package/dist/commands/shield-covariate.test.d.ts.map +1 -0
- package/dist/commands/shield-covariate.test.js +84 -0
- package/dist/commands/shield-covariate.test.js.map +1 -0
- package/dist/commands/shield.d.ts +162 -3
- package/dist/commands/shield.d.ts.map +1 -1
- package/dist/commands/shield.js +342 -74
- package/dist/commands/shield.js.map +1 -1
- package/dist/commands/shield.test.js +169 -3
- package/dist/commands/shield.test.js.map +1 -1
- package/dist/git.d.ts +25 -0
- package/dist/git.d.ts.map +1 -1
- package/dist/git.js +50 -6
- package/dist/git.js.map +1 -1
- package/dist/git.test.js +119 -14
- package/dist/git.test.js.map +1 -1
- package/dist/index.js +11 -0
- package/dist/index.js.map +1 -1
- package/dist/orchestrators/orchestrator.d.ts +1 -0
- package/dist/orchestrators/orchestrator.d.ts.map +1 -1
- package/dist/orchestrators/orchestrator.js +1 -1
- package/dist/orchestrators/orchestrator.js.map +1 -1
- package/package.json +2 -2
package/dist/commands/shield.js
CHANGED
|
@@ -290,9 +290,20 @@ function refreshReviewExtensionsFileIfStale(totemDirAbs, extensions, fs, path) {
|
|
|
290
290
|
}
|
|
291
291
|
}
|
|
292
292
|
/**
|
|
293
|
-
*
|
|
294
|
-
*
|
|
295
|
-
*
|
|
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
|
|
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
|
-
|
|
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'),
|
|
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
|
-
*
|
|
353
|
-
*
|
|
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
|
-
*
|
|
356
|
-
*
|
|
357
|
-
*
|
|
358
|
-
*
|
|
359
|
-
*
|
|
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
|
|
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(
|
|
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:
|
|
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 —
|
|
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
|
-
//
|
|
561
|
-
const
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
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
|
-
|
|
590
|
-
|
|
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
|
|
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 (
|
|
604
|
-
await
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
793
|
-
if (
|
|
794
|
-
|
|
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
|
-
|
|
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
|