@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
@@ -36,6 +36,15 @@
36
36
  * unconfigured → exactly one `skip` line; configured-but-missing / unparseable /
37
37
  * unsupported-schema → `warn`, never a crash. Dynamic-import `@mmnto/totem` to
38
38
  * keep core off the CLI cold-start graph, matching the other doctor checks.
39
+ *
40
+ * The **trust-readout** (mmnto-ai/totem#2327, Prop 303 §5(a)) post-processes
41
+ * the flat per-line dump into the doctor's aggregate output contract: verdict
42
+ * rollup (per-seat + global, R1), the run-time coverage denominator (R2),
43
+ * why-not per non-pass row at the level probed (R3), the `--json` verdict
44
+ * artifact (R4), and the `--strict` declaredly-toothless honesty line (R5).
45
+ * Spec: mmnto-ai/totem-strategy:doctrine/parity-manifest.md § "The
46
+ * trust-readout — the doctor's output contract"; deltas raise there, never
47
+ * silently diverge. Pure post-processing — zero probes added.
39
48
  */
40
49
  import * as path from 'node:path';
41
50
  // init-templates (large canonical strings) + the node:url / node:module builtins
@@ -46,9 +55,10 @@ const CHECK_NAME = 'Parity';
46
55
  * Resolve the on-disk artifact(s) a mechanical contract checks, or `undefined`
47
56
  * when this slice doesn't handle the contract (hooks / presence / value-equality
48
57
  * → kept as `skip` stubs). `claude-skills` yields one artifact PER distributed
49
- * skill; `review-reply-skill-content` is the single review-reply skill (it
50
- * overlaps `claude-skills` by design — both are distinct manifest contracts on
51
- * the same file; flagged to strategy as a manifest observation).
58
+ * skill; `review-reply-skill-content` and `review-loop-skill-content` are the
59
+ * single per-skill contracts (each overlaps `claude-skills` by design — both are
60
+ * distinct manifest contracts on the same file; flagged to strategy as a manifest
61
+ * observation).
52
62
  *
53
63
  * The canonical block is extracted from the running CLI's OWN `init-templates`
54
64
  * export (passed-in `extract` is the core `extractManagedBlock`, dynamic-imported
@@ -73,6 +83,15 @@ function mechanicalArtifactsFor(contractId, gitRoot, extract, templates) {
73
83
  lineName: 'Parity: review-reply-skill-content',
74
84
  },
75
85
  ];
86
+ case 'review-loop-skill-content':
87
+ return [
88
+ {
89
+ consumerPath: path.join(gitRoot, '.claude', 'skills', 'review-loop', 'SKILL.md'),
90
+ markers,
91
+ canonicalBlock: extract(templates.reviewLoopContent, markers),
92
+ lineName: 'Parity: review-loop-skill-content',
93
+ },
94
+ ];
76
95
  default:
77
96
  return undefined;
78
97
  }
@@ -347,7 +366,7 @@ export async function checkParity(cwd) {
347
366
  name: CHECK_NAME,
348
367
  status: 'skip',
349
368
  message: 'no parity manifest configured',
350
- }, configured);
369
+ }, configured, 'not-configured');
351
370
  case 'not-found': {
352
371
  // mmnto-ai/totem#2094 — when the configured manifest path lives under
353
372
  // node_modules/ (the strategy-doctrine optional-pin shape), the normal
@@ -368,7 +387,7 @@ export async function checkParity(cwd) {
368
387
  status: 'warn',
369
388
  message: `parity manifest not found at ${rel(cwd, result.path)}`,
370
389
  remediation,
371
- }, configured);
390
+ }, configured, 'not-found');
372
391
  }
373
392
  case 'unparseable':
374
393
  return single({
@@ -376,14 +395,14 @@ export async function checkParity(cwd) {
376
395
  status: 'warn',
377
396
  message: `parity manifest unreadable at ${rel(cwd, result.path)}: ${result.reason}`,
378
397
  remediation: 'Fix the manifest YAML / schema, then re-run totem doctor --parity.',
379
- }, configured);
398
+ }, configured, 'unparseable');
380
399
  case 'unsupported-schema':
381
400
  return single({
382
401
  name: CHECK_NAME,
383
402
  status: 'warn',
384
403
  message: `parity manifest schema v${result.schemaVersion} unsupported (this doctor supports v${SUPPORTED_PARITY_SCHEMA_VERSION})`,
385
404
  remediation: 'Upgrade @mmnto/cli or align the manifest schema-version.',
386
- }, configured);
405
+ }, configured, 'unsupported-schema');
387
406
  case 'ok': {
388
407
  const { contracts } = result.manifest;
389
408
  const summary = {
@@ -396,10 +415,11 @@ export async function checkParity(cwd) {
396
415
  // (packages/cli lazy-load guideline).
397
416
  const { createRequire } = await import('node:module');
398
417
  const url = await import('node:url');
399
- const { DISTRIBUTED_CLAUDE_SKILLS, REVIEW_REPLY_SKILL_CONTENT, SKILL_MARKER_START, SKILL_MARKER_END, CLAUDE_SESSION_START, GEMINI_SESSION_START, SESSION_START_MARKER, } = await import('./init-templates.js');
418
+ const { DISTRIBUTED_CLAUDE_SKILLS, REVIEW_REPLY_SKILL_CONTENT, REVIEW_LOOP_SKILL_CONTENT, SKILL_MARKER_START, SKILL_MARKER_END, CLAUDE_SESSION_START, GEMINI_SESSION_START, SESSION_START_MARKER, } = await import('./init-templates.js');
400
419
  const skillTemplates = {
401
420
  distributedSkills: DISTRIBUTED_CLAUDE_SKILLS,
402
421
  reviewReplyContent: REVIEW_REPLY_SKILL_CONTENT,
422
+ reviewLoopContent: REVIEW_LOOP_SKILL_CONTENT,
403
423
  markers: { start: SKILL_MARKER_START, end: SKILL_MARKER_END },
404
424
  };
405
425
  // The running CLI's own whole-file SessionStart hook templates (static — no
@@ -436,6 +456,10 @@ export async function checkParity(cwd) {
436
456
  // regenerated git-hook canonical matches THIS repo's package manager — no false drift.
437
457
  const fallbackCmd = getFallbackCommand(gitRoot);
438
458
  const blockingDriftIds = [];
459
+ // Trust-readout per-contract metadata (mmnto-ai/totem#2327), tagged at the
460
+ // routing branch that owns each contract so the R2 coverage split derives
461
+ // from the run itself — never from a mirrored registry (Tenet 20).
462
+ const readoutMeta = {};
439
463
  // The consumers-scope guard shared by the routing branches that don't
440
464
  // self-guard in core (mechanical + capability-probe): a scoped contract
441
465
  // must not emit drift in a repo that is not an intended consumer.
@@ -471,10 +495,20 @@ export async function checkParity(cwd) {
471
495
  // NON-probe rungs (managed-block, version-pin, …) are informational —
472
496
  // they fall through to the existing tractability routing unchanged.
473
497
  if (c.manifestation === 'capability-probe') {
498
+ // Probe resolution BEFORE the scope guard is meta-only (pure switch, no
499
+ // probe executes): a scoped-out row still classifies by whether the
500
+ // registry implements it (#2327 R2).
501
+ const probes = capabilityProbesFor(c.id, gitRoot);
502
+ readoutMeta[c.id] =
503
+ probes === undefined
504
+ ? { coverage: 'honest-absent' }
505
+ : {
506
+ coverage: 'mechanical',
507
+ ...(probes[0] !== undefined ? { sensesProbed: probes[0].probedLevel } : {}),
508
+ };
474
509
  const scopeSkip = consumersSkip(c);
475
510
  if (scopeSkip !== undefined)
476
511
  return scopeSkip;
477
- const probes = capabilityProbesFor(c.id, gitRoot);
478
512
  if (probes === undefined) {
479
513
  return [
480
514
  stub(c, `${c.dimension} (capability-probe) — probe not yet implemented for this row`),
@@ -510,6 +544,11 @@ export async function checkParity(cwd) {
510
544
  // documented to avoid.
511
545
  if (c.manifestation === 'value-equality') {
512
546
  const fields = valueEqualityFieldsFor(c.id, gitRoot);
547
+ // The scalar read is a config-declaration probe (#2327 R3): 'declared'.
548
+ readoutMeta[c.id] =
549
+ fields === undefined
550
+ ? { coverage: 'honest-absent' }
551
+ : { coverage: 'mechanical', sensesProbed: 'declared' };
513
552
  if (fields === undefined) {
514
553
  return [
515
554
  stub(c, `${c.dimension} (value-equality) — drift detection not yet implemented for this row`),
@@ -538,6 +577,11 @@ export async function checkParity(cwd) {
538
577
  // contract id at most ONCE for --strict so the count reflects contracts.
539
578
  if (c.manifestation === 'content-hash') {
540
579
  const packageDir = lockContentPackageDirFor(c.id, gitRoot);
580
+ // Installed-artifact hash equality probes on-disk content (#2327 R3): 'present'.
581
+ readoutMeta[c.id] =
582
+ packageDir === undefined
583
+ ? { coverage: 'honest-absent' }
584
+ : { coverage: 'mechanical', sensesProbed: 'present' };
541
585
  if (packageDir === undefined) {
542
586
  return [
543
587
  stub(c, `${c.dimension} (content-hash) — drift detection not yet implemented for this row`),
@@ -558,6 +602,7 @@ export async function checkParity(cwd) {
558
602
  // Fail-loud PER ROW, never per manifest (the total-outage guard): an
559
603
  // unrecognized rung value surfaces verbatim instead of darking the
560
604
  // sensor or silently mis-routing.
605
+ readoutMeta[c.id] = { coverage: 'honest-absent' };
561
606
  return [
562
607
  stub(c, `manifestation '${c.manifestation}' unrecognized by this doctor — drift detection not yet implemented`),
563
608
  ];
@@ -570,10 +615,13 @@ export async function checkParity(cwd) {
570
615
  // self-routes those to the toolchain reader (mmnto-ai/totem#2115). Only
571
616
  // stub a version-pinned row that's neither a deps package nor a toolchain.
572
617
  if (packageName === undefined && c.dimension !== TOOLCHAIN_DIMENSION) {
618
+ readoutMeta[c.id] = { coverage: 'honest-absent' };
573
619
  return [
574
620
  stub(c, `${c.dimension} (version-pinned) — drift detection not yet implemented`),
575
621
  ];
576
622
  }
623
+ // Pin-currency reads the consumer's dependency declaration (#2327 R3): 'declared'.
624
+ readoutMeta[c.id] = { coverage: 'mechanical', sensesProbed: 'declared' };
577
625
  const verdict = detectVersionPinnedContract(c, { cwd, gitRoot, repoId, packageName });
578
626
  if (verdict.status === 'warn' && c.blocking === true)
579
627
  blockingDriftIds.push(c.id);
@@ -581,6 +629,17 @@ export async function checkParity(cwd) {
581
629
  }
582
630
  // ── mechanical content-equality (mmnto-ai/totem#2073) ──
583
631
  if (c.tractability === 'mechanical') {
632
+ // Skills resolution is pure (no probe executes) — resolved BEFORE the
633
+ // scope guard so a scoped-out row still classifies by whether the
634
+ // registry implements it (#2327 R2); reused as `artifacts` below.
635
+ const skillArtifacts = mechanicalArtifactsFor(c.id, gitRoot, extractManagedBlock, skillTemplates);
636
+ const mechanicalImplemented = c.id === 'git-hooks' ||
637
+ c.id === 'session-start-orientation' ||
638
+ skillArtifacts !== undefined;
639
+ // Content-equality probes on-disk artifacts (#2327 R3): 'present'.
640
+ readoutMeta[c.id] = mechanicalImplemented
641
+ ? { coverage: 'mechanical', sensesProbed: 'present' }
642
+ : { coverage: 'honest-absent' };
584
643
  // Honor the contract's `consumers` scope before sensing drift (mirrors
585
644
  // detectVersionPinnedContract, which self-guards). A scoped mechanical
586
645
  // contract must not emit drift — or, under --strict, fail — in a repo that
@@ -632,8 +691,9 @@ export async function checkParity(cwd) {
632
691
  blockingDriftIds.push(c.id);
633
692
  return lines;
634
693
  }
635
- // skills: managed-block content equality against the in-process template.
636
- const artifacts = mechanicalArtifactsFor(c.id, gitRoot, extractManagedBlock, skillTemplates);
694
+ // skills: managed-block content equality against the in-process template
695
+ // (resolved above, pre-scope-guard, for the readout classification).
696
+ const artifacts = skillArtifacts;
637
697
  if (artifacts === undefined) {
638
698
  return [
639
699
  stub(c, `${c.dimension} (mechanical) — drift detection not yet implemented for this sub-class`),
@@ -665,6 +725,9 @@ export async function checkParity(cwd) {
665
725
  // `info` can't enter blockingDriftIds, so these contracts cannot fail even
666
726
  // under --strict (the manifest's "never fails" contract).
667
727
  if (c.tractability === 'manual-attestation') {
728
+ // Declared-covered but mechanically uncovered (#2327 R2 — human-asserted
729
+ // judgment is not code-verified truth); nothing is probed.
730
+ readoutMeta[c.id] = { coverage: 'attestation-only' };
668
731
  // The detector reads the sub-class discriminant (`c.package`) + the
669
732
  // canonical source directly off the contract. `package:` set ⇒ vendor-SDK
670
733
  // pin read; unset ⇒ doctrine-row pure-info surface. `attested` carries the
@@ -678,17 +741,31 @@ export async function checkParity(cwd) {
678
741
  return [verdictToLine(c, verdict)];
679
742
  }
680
743
  // ── anything else (a future tractability) → skip stub; the slice boundary stays observable ──
744
+ readoutMeta[c.id] = { coverage: 'honest-absent' };
681
745
  return [
682
746
  stub(c, `${c.dimension} (${c.tractability}) — drift detection not yet implemented`),
683
747
  ];
684
748
  });
685
- return { results: [summary, ...perContract], blockingDriftIds, configured };
749
+ return {
750
+ results: [summary, ...perContract],
751
+ blockingDriftIds,
752
+ configured,
753
+ loadStatus: 'ok',
754
+ readout: {
755
+ manifest: {
756
+ schemaVersion: result.manifest.schemaVersion,
757
+ status: result.manifest.status,
758
+ },
759
+ contracts,
760
+ meta: readoutMeta,
761
+ },
762
+ };
686
763
  }
687
764
  }
688
765
  }
689
- /** Wrap a single summary line in the `ParityCheckResult` shape (no blocking ids). */
690
- function single(result, configured) {
691
- return { results: [result], blockingDriftIds: [], configured };
766
+ /** Wrap a single summary line in the `ParityCheckResult` shape (no blocking ids, no readout). */
767
+ function single(result, configured, loadStatus) {
768
+ return { results: [result], blockingDriftIds: [], configured, loadStatus };
692
769
  }
693
770
  /** Map a core `ParityContractVerdict` to a `ParityLine` keyed by the contract id. */
694
771
  function verdictToLine(contract, verdict) {
@@ -726,6 +803,231 @@ function rel(cwd, target) {
726
803
  const r = path.relative(cwd, target);
727
804
  return r.length > 0 ? r : target;
728
805
  }
806
+ // ─── Trust-readout assembly (mmnto-ai/totem#2327) ────────
807
+ /** The `--json` artifact's own version stamp (`readout-schema-version`). */
808
+ const READOUT_SCHEMA_VERSION = 1;
809
+ /**
810
+ * R2 claim-boundary sentence — the readout states its own scope in its own
811
+ * output: an active-but-unmanifested surface (an undeclared MCP server, a
812
+ * user-global plugin) is outside the claim, stated not implied.
813
+ */
814
+ const CLAIM_BOUNDARY = 'covers the manifest-declared contract set only; active-but-unmanifested surfaces are outside this claim';
815
+ /**
816
+ * Whether a verdict line belongs to a `--strict`-promotable contract. A
817
+ * contract's drift can render across MULTIPLE artifact lines (e.g.
818
+ * `Parity: claude-skills (signoff)`), so a line is promotable when its name is
819
+ * the `Parity: <id>` summary OR a `Parity: <id> (…)` artifact line — an
820
+ * exact-name Set would leave the artifact lines rendered as WARN while the
821
+ * command still exits non-zero (GCA review). The trailing space guards against
822
+ * a contract id that is a prefix of another. Shared by the CLI render and the
823
+ * readout builder so the two can't disagree on what promoted.
824
+ */
825
+ function isPromotableLineName(name, blockingDriftIds) {
826
+ return blockingDriftIds.some((id) => name === `Parity: ${id}` || name.startsWith(`Parity: ${id} `));
827
+ }
828
+ /** Resolve the contract a verdict line belongs to (same prefix rule as promotion). */
829
+ function contractForLineName(name, contracts) {
830
+ return contracts.find((c) => name === `Parity: ${c.id}` || name.startsWith(`Parity: ${c.id} `));
831
+ }
832
+ // The CLI-side stubs' "not yet implemented" message shape — the one code-owned
833
+ // string the classifier keys on. A future optional reasonClass on the core
834
+ // verdict type retires this coupling (flagged to strategy in the
835
+ // 2026-07-09T2256Z deltas dispatch as an observation).
836
+ const HONEST_ABSENT_RE = /not yet implemented/;
837
+ /**
838
+ * R3 reason class for one verdict line. `pass` omits its class. A `skip` is
839
+ * `honest-absent` when the stub message or the contract's coverage class says
840
+ * the detector is unbuilt; every other skip — scope-guard skips and
841
+ * scope-indeterminate skips (e.g. applicable-but-missing scaffolds) alike —
842
+ * classifies `scoping-skip`, never silently `honest-absent`, which would
843
+ * understate what the registry implements.
844
+ */
845
+ function reasonClassFor(verdict, message, coverage) {
846
+ switch (verdict) {
847
+ case 'pass':
848
+ return undefined;
849
+ case 'warn':
850
+ case 'fail':
851
+ return 'drift';
852
+ case 'info':
853
+ return 'attestation';
854
+ case 'unknown':
855
+ return 'detector-error';
856
+ case 'skip':
857
+ if (HONEST_ABSENT_RE.test(message) || coverage === 'honest-absent')
858
+ return 'honest-absent';
859
+ return 'scoping-skip';
860
+ }
861
+ }
862
+ /**
863
+ * Assemble the trust-readout from `checkParity`'s raw materials — pure
864
+ * post-processing over the existing detector output (Prop 303 non-goal 2:
865
+ * zero probes added here).
866
+ */
867
+ export function buildParityReadout(inputs, results, blockingDriftIds, strict) {
868
+ const { contracts, meta } = inputs;
869
+ const rows = [];
870
+ for (const line of results) {
871
+ const contract = contractForLineName(line.name, contracts);
872
+ // The section summary line (`Parity`) is not a contract row.
873
+ if (contract === undefined)
874
+ continue;
875
+ const promoted = strict && line.status === 'warn' && isPromotableLineName(line.name, blockingDriftIds);
876
+ const verdict = promoted ? 'fail' : line.status;
877
+ const m = meta[contract.id];
878
+ const reasonClass = reasonClassFor(verdict, line.message, m?.coverage);
879
+ rows.push({
880
+ id: contract.id,
881
+ verdict,
882
+ ...(m?.sensesProbed !== undefined ? { sensesProbed: m.sensesProbed } : {}),
883
+ ...(reasonClass !== undefined ? { reasonClass } : {}),
884
+ message: line.message,
885
+ ...(contract.lastAttested !== undefined ? { lastAttested: contract.lastAttested } : {}),
886
+ lineName: line.name,
887
+ skippedNotGated: verdict === 'skip' && contract.blocking === true,
888
+ });
889
+ }
890
+ const zero = () => ({ pass: 0, warn: 0, info: 0, unknown: 0, skip: 0, fail: 0 });
891
+ const global = zero();
892
+ for (const r of rows)
893
+ global[r.verdict] += 1;
894
+ // Seats = the sorted union of declared vendor-adapter values. A line counts
895
+ // toward seat S when its contract is vendor-neutral (no vendor-adapter — it
896
+ // manifests on every seat) or declares S. An EMPTY vendor-adapter list is
897
+ // treated as vendor-neutral too: the schema admits `[]`, and excluding such
898
+ // a row from every seat while counting it globally would reopen the exact
899
+ // per-seat honesty hole R1 exists to close (spec-owner review on #2328).
900
+ // Per-seat exists precisely so a seat-scoped skip cannot hide inside the
901
+ // global number (R1).
902
+ const seats = [...new Set(contracts.flatMap((c) => c.vendorAdapter ?? []))].sort();
903
+ const perSeat = {};
904
+ for (const seat of seats)
905
+ perSeat[seat] = zero();
906
+ const adapterById = new Map(contracts.map((c) => [c.id, c.vendorAdapter]));
907
+ for (const r of rows) {
908
+ const adapter = adapterById.get(r.id);
909
+ for (const seat of seats) {
910
+ if (adapter === undefined || adapter.length === 0 || adapter.includes(seat)) {
911
+ perSeat[seat][r.verdict] += 1;
912
+ }
913
+ }
914
+ }
915
+ // R2: the denominator counts CONTRACTS (registry ∩ manifest is contract-
916
+ // granular), while the rollup above counts rendered verdict LINES — two
917
+ // populations, named apart in the render (raised Delta 2).
918
+ const denominator = { mechanical: 0, attestationOnly: 0, honestAbsent: 0 };
919
+ for (const c of contracts) {
920
+ const cls = meta[c.id]?.coverage ?? 'honest-absent';
921
+ if (cls === 'mechanical')
922
+ denominator.mechanical += 1;
923
+ else if (cls === 'attestation-only')
924
+ denominator.attestationOnly += 1;
925
+ else
926
+ denominator.honestAbsent += 1;
927
+ }
928
+ // R5: blocking-ids = the manifest's DECLARED `blocking: true` set, derived
929
+ // from the yaml at run time (never from prose — Tenet 20); gates-anything =
930
+ // that set is non-empty. Which blocking contracts actually drifted is
931
+ // visible as `fail` rows (strict) / promotable warns (default).
932
+ const blockingIds = contracts.filter((c) => c.blocking === true).map((c) => c.id);
933
+ return {
934
+ manifest: inputs.manifest,
935
+ rollup: { global, perSeat },
936
+ denominator,
937
+ strict: { armed: strict, blockingIds, gatesAnything: blockingIds.length > 0 },
938
+ rows,
939
+ };
940
+ }
941
+ /** One-line count rendering shared by the global + per-seat rollup lines. */
942
+ function renderCounts(c) {
943
+ return `${c.pass} pass · ${c.warn} warn · ${c.info} info · ${c.unknown} unknown · ${c.skip} skip · ${c.fail} fail`;
944
+ }
945
+ /**
946
+ * R3 attestation-age fragment: days since `last-attested:` ("not recorded"
947
+ * when absent). Staleness refines the message, never the status (the existing
948
+ * manifest constraint, unchanged).
949
+ */
950
+ function attestationAge(lastAttested) {
951
+ if (lastAttested === undefined)
952
+ return 'last attested: not recorded';
953
+ const t = Date.parse(lastAttested);
954
+ if (Number.isNaN(t))
955
+ return `last attested: ${lastAttested} (unparseable date)`;
956
+ const days = Math.max(0, Math.floor((Date.now() - t) / 86_400_000));
957
+ return `last attested ${days} day(s) ago`;
958
+ }
959
+ /**
960
+ * The `--json` verdict artifact (R4) — field names fixed by the spec section
961
+ * (kebab-case, no local envelope: the spec's top-level shape overrides the
962
+ * `json-output.ts` success/error wrapper). Emitted bare on stdout so the
963
+ * artifact is diffable (Prop 302 verdict-artifact discipline). On degenerate
964
+ * manifest-load states the artifact carries the load status and empty
965
+ * rollup/rows — the honest "nothing to roll up" shape.
966
+ */
967
+ function readoutJsonArtifact(readout, loadStatus, strict) {
968
+ const countsJson = (c) => ({
969
+ pass: c.pass,
970
+ warn: c.warn,
971
+ info: c.info,
972
+ unknown: c.unknown,
973
+ skip: c.skip,
974
+ fail: c.fail,
975
+ });
976
+ if (readout === undefined) {
977
+ const zero = { pass: 0, warn: 0, info: 0, unknown: 0, skip: 0, fail: 0 };
978
+ return {
979
+ 'readout-schema-version': READOUT_SCHEMA_VERSION,
980
+ manifest: { status: loadStatus },
981
+ rollup: { global: countsJson(zero), 'per-seat': {} },
982
+ denominator: {
983
+ mechanical: 0,
984
+ 'attestation-only': 0,
985
+ 'honest-absent': 0,
986
+ 'claim-boundary': CLAIM_BOUNDARY,
987
+ },
988
+ strict: { armed: strict, 'blocking-ids': [], 'gates-anything': false },
989
+ rows: [],
990
+ };
991
+ }
992
+ return {
993
+ 'readout-schema-version': READOUT_SCHEMA_VERSION,
994
+ manifest: {
995
+ 'schema-version': readout.manifest.schemaVersion,
996
+ status: readout.manifest.status,
997
+ },
998
+ rollup: {
999
+ global: countsJson(readout.rollup.global),
1000
+ 'per-seat': Object.fromEntries(Object.entries(readout.rollup.perSeat).map(([seat, c]) => [seat, countsJson(c)])),
1001
+ },
1002
+ denominator: {
1003
+ mechanical: readout.denominator.mechanical,
1004
+ 'attestation-only': readout.denominator.attestationOnly,
1005
+ 'honest-absent': readout.denominator.honestAbsent,
1006
+ 'claim-boundary': CLAIM_BOUNDARY,
1007
+ },
1008
+ strict: {
1009
+ armed: readout.strict.armed,
1010
+ 'blocking-ids': readout.strict.blockingIds,
1011
+ 'gates-anything': readout.strict.gatesAnything,
1012
+ },
1013
+ rows: readout.rows.map((r) => ({
1014
+ id: r.id,
1015
+ verdict: r.verdict,
1016
+ ...(r.sensesProbed !== undefined ? { 'senses-probed': r.sensesProbed } : {}),
1017
+ ...(r.reasonClass !== undefined ? { 'reason-class': r.reasonClass } : {}),
1018
+ message: r.message,
1019
+ ...(r.lastAttested !== undefined ? { 'last-attested': r.lastAttested } : {}),
1020
+ // R5 (build delta 4, mmnto-ai/totem-strategy#851): a qualifying row —
1021
+ // `verdict: skip` on a `blocking: true` contract (id ∈ strict.blocking-ids)
1022
+ // — carries `skipped-not-gated: true`. Presence-only, mirroring the
1023
+ // `last-attested?` convention: emitted true or the key is ABSENT, never
1024
+ // `false`. The fact is derivable, but R5's never-a-silent-pass posture
1025
+ // states it outright in the spec-shaped artifact rather than making every
1026
+ // consumer join two sections. Additive — `readout-schema-version` stays 1.
1027
+ ...(r.skippedNotGated ? { 'skipped-not-gated': true } : {}),
1028
+ })),
1029
+ };
1030
+ }
729
1031
  // ─── CLI entry ──────────────────────────────────────────
730
1032
  // Same value as CHECK_NAME — aliased (not re-literal'd) so the two can't drift.
731
1033
  const TAG = CHECK_NAME;
@@ -747,23 +1049,50 @@ export async function doctorParityCliCommand(options = {}) {
747
1049
  .replace(/ {2,}/g, ' ')
748
1050
  .trim();
749
1051
  const cwd = options.cwdForTest ?? process.cwd();
750
- const { results, blockingDriftIds, configured } = await checkParity(cwd);
1052
+ const { results, blockingDriftIds, configured, loadStatus, readout: readoutInputs, } = await checkParity(cwd);
751
1053
  // Folded-into-`--strict` no-op: when this run is the strict fold (not an explicit
752
1054
  // `doctor --parity`) and no repo-local manifest is configured, render and gate
753
1055
  // nothing — a non-adopter's `doctor --strict` stays byte-identical to before the
754
1056
  // fold. Explicit `--parity` (onlyWhenConfigured omitted) still shows the SKIP.
755
1057
  if (options.onlyWhenConfigured && !configured)
756
1058
  return;
757
- // Under --strict, a blocking contract's drift `warn` is rendered + gated as a
758
- // FAIL. A contract's drift can render across MULTIPLE artifact lines
759
- // (e.g. `Parity: claude-skills (signoff)`), so a line is promotable when its
760
- // name is the `Parity: <id>` summary OR a `Parity: <id> (…)` artifact line —
761
- // an exact-name Set would leave the artifact lines rendered as WARN while the
762
- // command still exits non-zero (GCA review on the PR). The trailing space
763
- // guards against a contract id that is a prefix of another.
764
- const isPromotable = (name) => blockingDriftIds.some((id) => name === `Parity: ${id}` || name.startsWith(`Parity: ${id} `));
1059
+ const strict = options.strict === true;
1060
+ const readout = readoutInputs !== undefined
1061
+ ? buildParityReadout(readoutInputs, results, blockingDriftIds, strict)
1062
+ : undefined;
1063
+ // `--json` replaces the human render wholesale: the artifact owns stdout so
1064
+ // it stays diffable (R4). The strict throw below still applies — artifact
1065
+ // AND exit code, matching R5's "exits non-zero iff ≥1 fail".
1066
+ if (options.json) {
1067
+ process.stdout.write(JSON.stringify(readoutJsonArtifact(readout, loadStatus, strict), null, 2) + '\n');
1068
+ }
1069
+ else {
1070
+ renderParityHuman(results, blockingDriftIds, strict, { log, bold, errorColor, successColor, warnColor }, render);
1071
+ if (readout !== undefined) {
1072
+ renderTrustReadout(readout, { log, bold }, render);
1073
+ }
1074
+ }
1075
+ // Sensor-not-gate: drift is report-only by default (exit 0). Only `--strict`
1076
+ // promotes a `blocking: true` contract's drift to a non-zero exit; non-blocking
1077
+ // drift never gates, and `info`/`unknown` are never promoted. The detectors
1078
+ // emit no raw `fail` status, so the gate is purely the strict+blocking
1079
+ // promotion — the gating model for a future slice that DOES emit a `fail` is
1080
+ // settled when that slice lands (CR review mmnto-ai/totem#2071: keep the gate from
1081
+ // suggesting a non-strict path that would break sensor-not-gate).
1082
+ if (options.strict && blockingDriftIds.length > 0) {
1083
+ throw new TotemError('PARITY_DRIFT_DETECTED', `${blockingDriftIds.length} parity contract(s) reported blocking drift under --strict.`, 'Reconcile each blocking contract against its canonical source, then re-run totem doctor --parity --strict.');
1084
+ }
1085
+ }
1086
+ /** The pre-readout flat per-line render (unchanged output, extracted for the `--json` split). */
1087
+ function renderParityHuman(results, blockingDriftIds, strict, ui, render) {
1088
+ const { log, bold } = ui;
1089
+ const errorColor = ui.errorColor ?? ((s) => s);
1090
+ const successColor = ui.successColor ?? ((s) => s);
1091
+ const warnColor = ui.warnColor ?? ((s) => s);
765
1092
  for (const r of results) {
766
- const status = options.strict && r.status === 'warn' && isPromotable(r.name) ? 'fail' : r.status;
1093
+ const status = strict && r.status === 'warn' && isPromotableLineName(r.name, blockingDriftIds)
1094
+ ? 'fail'
1095
+ : r.status;
767
1096
  switch (status) {
768
1097
  case 'pass':
769
1098
  log.success(TAG, `${successColor(bold('PASS'))} — ${render(r.message)}`);
@@ -799,15 +1128,58 @@ export async function doctorParityCliCommand(options = {}) {
799
1128
  break;
800
1129
  }
801
1130
  }
802
- // Sensor-not-gate: drift is report-only by default (exit 0). Only `--strict`
803
- // promotes a `blocking: true` contract's drift to a non-zero exit; non-blocking
804
- // drift never gates, and `info`/`unknown` are never promoted. The detectors
805
- // emit no raw `fail` status, so the gate is purely the strict+blocking
806
- // promotion the gating model for a future slice that DOES emit a `fail` is
807
- // settled when that slice lands (CR review mmnto-ai/totem#2071: keep the gate from
808
- // suggesting a non-strict path that would break sensor-not-gate).
809
- if (options.strict && blockingDriftIds.length > 0) {
810
- throw new TotemError('PARITY_DRIFT_DETECTED', `${blockingDriftIds.length} parity contract(s) reported blocking drift under --strict.`, 'Reconcile each blocking contract against its canonical source, then re-run totem doctor --parity --strict.');
1131
+ }
1132
+ /**
1133
+ * The trust-readout tail render (mmnto-ai/totem#2327 R1–R3, R5) replaces the
1134
+ * bare "N loaded" ending with the rollup, coverage denominator, why-not lines,
1135
+ * and the `--strict` honesty statement. Verdict lines above stay untouched.
1136
+ */
1137
+ function renderTrustReadout(readout, ui, render) {
1138
+ const { log, bold } = ui;
1139
+ log.info(TAG, bold('── trust-readout ──'));
1140
+ // R1 — one vocabulary, two scopes. Units named (verdict lines ≠ contracts).
1141
+ log.info(TAG, `rollup (verdict lines) — global: ${renderCounts(readout.rollup.global)}`);
1142
+ const seats = Object.keys(readout.rollup.perSeat);
1143
+ if (seats.length === 0) {
1144
+ log.dim(TAG, 'rollup — no seat-scoped rows declared (per-seat = global)');
1145
+ }
1146
+ else {
1147
+ for (const seat of seats) {
1148
+ log.info(TAG, `rollup — seat ${render(seat)}: ${renderCounts(readout.rollup.perSeat[seat])}`);
1149
+ }
1150
+ }
1151
+ // R2 — three counts on their own line, never one "covered" number; the
1152
+ // claim boundary stated in the readout's own words.
1153
+ const d = readout.denominator;
1154
+ log.info(TAG, `coverage (contracts) — ${d.mechanical} mechanically sensed · ${d.attestationOnly} attestation-only · ${d.honestAbsent} honest-absent`);
1155
+ log.dim(TAG, `claim boundary: ${CLAIM_BOUNDARY}`);
1156
+ // R3 — one why-not line per non-pass row, at the level actually probed
1157
+ // (green-halo cap: nothing renders above what was probed).
1158
+ const whyNot = readout.rows.filter((r) => r.verdict !== 'pass');
1159
+ if (whyNot.length > 0) {
1160
+ log.info(TAG, 'why-not (per non-pass row):');
1161
+ for (const r of whyNot) {
1162
+ const level = r.sensesProbed !== undefined ? ` at ${r.sensesProbed}` : '';
1163
+ const reason = r.reasonClass !== undefined ? ` [${r.reasonClass}]` : '';
1164
+ // R3 age-in-days for attestation rows. The detector message already
1165
+ // carries the raw date / "not recorded" text — append the derived age
1166
+ // only when a date exists to derive it from (no duplicate "not recorded").
1167
+ const age = r.reasonClass === 'attestation' && r.lastAttested !== undefined
1168
+ ? ` · ${attestationAge(r.lastAttested)}`
1169
+ : '';
1170
+ const gate = r.skippedNotGated ? ' · blocking — skipped-not-gated, never a silent pass' : '';
1171
+ log.dim(TAG, ` ${render(r.lineName)}: ${r.verdict.toUpperCase()}${level}${reason} — ${render(r.message)}${age}${gate}`);
1172
+ }
1173
+ }
1174
+ // R5 — declaredly-toothless in the readout's own words; derived from the
1175
+ // manifest's blocking set at run time, never from prose.
1176
+ const s = readout.strict;
1177
+ const armed = s.armed ? 'armed' : 'not armed';
1178
+ if (!s.gatesAnything) {
1179
+ log.info(TAG, `--strict (${armed}): currently gates nothing — the manifest declares no blocking: true contracts`);
1180
+ }
1181
+ else {
1182
+ log.info(TAG, `--strict (${armed}): gates ${s.blockingIds.length} blocking contract(s): ${s.blockingIds.map(render).join(', ')}`);
811
1183
  }
812
1184
  }
813
1185
  //# sourceMappingURL=doctor-parity.js.map