@mmnto/cli 1.91.0 → 1.92.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.
@@ -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
@@ -347,7 +356,7 @@ export async function checkParity(cwd) {
347
356
  name: CHECK_NAME,
348
357
  status: 'skip',
349
358
  message: 'no parity manifest configured',
350
- }, configured);
359
+ }, configured, 'not-configured');
351
360
  case 'not-found': {
352
361
  // mmnto-ai/totem#2094 — when the configured manifest path lives under
353
362
  // node_modules/ (the strategy-doctrine optional-pin shape), the normal
@@ -368,7 +377,7 @@ export async function checkParity(cwd) {
368
377
  status: 'warn',
369
378
  message: `parity manifest not found at ${rel(cwd, result.path)}`,
370
379
  remediation,
371
- }, configured);
380
+ }, configured, 'not-found');
372
381
  }
373
382
  case 'unparseable':
374
383
  return single({
@@ -376,14 +385,14 @@ export async function checkParity(cwd) {
376
385
  status: 'warn',
377
386
  message: `parity manifest unreadable at ${rel(cwd, result.path)}: ${result.reason}`,
378
387
  remediation: 'Fix the manifest YAML / schema, then re-run totem doctor --parity.',
379
- }, configured);
388
+ }, configured, 'unparseable');
380
389
  case 'unsupported-schema':
381
390
  return single({
382
391
  name: CHECK_NAME,
383
392
  status: 'warn',
384
393
  message: `parity manifest schema v${result.schemaVersion} unsupported (this doctor supports v${SUPPORTED_PARITY_SCHEMA_VERSION})`,
385
394
  remediation: 'Upgrade @mmnto/cli or align the manifest schema-version.',
386
- }, configured);
395
+ }, configured, 'unsupported-schema');
387
396
  case 'ok': {
388
397
  const { contracts } = result.manifest;
389
398
  const summary = {
@@ -436,6 +445,10 @@ export async function checkParity(cwd) {
436
445
  // regenerated git-hook canonical matches THIS repo's package manager — no false drift.
437
446
  const fallbackCmd = getFallbackCommand(gitRoot);
438
447
  const blockingDriftIds = [];
448
+ // Trust-readout per-contract metadata (mmnto-ai/totem#2327), tagged at the
449
+ // routing branch that owns each contract so the R2 coverage split derives
450
+ // from the run itself — never from a mirrored registry (Tenet 20).
451
+ const readoutMeta = {};
439
452
  // The consumers-scope guard shared by the routing branches that don't
440
453
  // self-guard in core (mechanical + capability-probe): a scoped contract
441
454
  // must not emit drift in a repo that is not an intended consumer.
@@ -471,10 +484,20 @@ export async function checkParity(cwd) {
471
484
  // NON-probe rungs (managed-block, version-pin, …) are informational —
472
485
  // they fall through to the existing tractability routing unchanged.
473
486
  if (c.manifestation === 'capability-probe') {
487
+ // Probe resolution BEFORE the scope guard is meta-only (pure switch, no
488
+ // probe executes): a scoped-out row still classifies by whether the
489
+ // registry implements it (#2327 R2).
490
+ const probes = capabilityProbesFor(c.id, gitRoot);
491
+ readoutMeta[c.id] =
492
+ probes === undefined
493
+ ? { coverage: 'honest-absent' }
494
+ : {
495
+ coverage: 'mechanical',
496
+ ...(probes[0] !== undefined ? { sensesProbed: probes[0].probedLevel } : {}),
497
+ };
474
498
  const scopeSkip = consumersSkip(c);
475
499
  if (scopeSkip !== undefined)
476
500
  return scopeSkip;
477
- const probes = capabilityProbesFor(c.id, gitRoot);
478
501
  if (probes === undefined) {
479
502
  return [
480
503
  stub(c, `${c.dimension} (capability-probe) — probe not yet implemented for this row`),
@@ -510,6 +533,11 @@ export async function checkParity(cwd) {
510
533
  // documented to avoid.
511
534
  if (c.manifestation === 'value-equality') {
512
535
  const fields = valueEqualityFieldsFor(c.id, gitRoot);
536
+ // The scalar read is a config-declaration probe (#2327 R3): 'declared'.
537
+ readoutMeta[c.id] =
538
+ fields === undefined
539
+ ? { coverage: 'honest-absent' }
540
+ : { coverage: 'mechanical', sensesProbed: 'declared' };
513
541
  if (fields === undefined) {
514
542
  return [
515
543
  stub(c, `${c.dimension} (value-equality) — drift detection not yet implemented for this row`),
@@ -538,6 +566,11 @@ export async function checkParity(cwd) {
538
566
  // contract id at most ONCE for --strict so the count reflects contracts.
539
567
  if (c.manifestation === 'content-hash') {
540
568
  const packageDir = lockContentPackageDirFor(c.id, gitRoot);
569
+ // Installed-artifact hash equality probes on-disk content (#2327 R3): 'present'.
570
+ readoutMeta[c.id] =
571
+ packageDir === undefined
572
+ ? { coverage: 'honest-absent' }
573
+ : { coverage: 'mechanical', sensesProbed: 'present' };
541
574
  if (packageDir === undefined) {
542
575
  return [
543
576
  stub(c, `${c.dimension} (content-hash) — drift detection not yet implemented for this row`),
@@ -558,6 +591,7 @@ export async function checkParity(cwd) {
558
591
  // Fail-loud PER ROW, never per manifest (the total-outage guard): an
559
592
  // unrecognized rung value surfaces verbatim instead of darking the
560
593
  // sensor or silently mis-routing.
594
+ readoutMeta[c.id] = { coverage: 'honest-absent' };
561
595
  return [
562
596
  stub(c, `manifestation '${c.manifestation}' unrecognized by this doctor — drift detection not yet implemented`),
563
597
  ];
@@ -570,10 +604,13 @@ export async function checkParity(cwd) {
570
604
  // self-routes those to the toolchain reader (mmnto-ai/totem#2115). Only
571
605
  // stub a version-pinned row that's neither a deps package nor a toolchain.
572
606
  if (packageName === undefined && c.dimension !== TOOLCHAIN_DIMENSION) {
607
+ readoutMeta[c.id] = { coverage: 'honest-absent' };
573
608
  return [
574
609
  stub(c, `${c.dimension} (version-pinned) — drift detection not yet implemented`),
575
610
  ];
576
611
  }
612
+ // Pin-currency reads the consumer's dependency declaration (#2327 R3): 'declared'.
613
+ readoutMeta[c.id] = { coverage: 'mechanical', sensesProbed: 'declared' };
577
614
  const verdict = detectVersionPinnedContract(c, { cwd, gitRoot, repoId, packageName });
578
615
  if (verdict.status === 'warn' && c.blocking === true)
579
616
  blockingDriftIds.push(c.id);
@@ -581,6 +618,17 @@ export async function checkParity(cwd) {
581
618
  }
582
619
  // ── mechanical content-equality (mmnto-ai/totem#2073) ──
583
620
  if (c.tractability === 'mechanical') {
621
+ // Skills resolution is pure (no probe executes) — resolved BEFORE the
622
+ // scope guard so a scoped-out row still classifies by whether the
623
+ // registry implements it (#2327 R2); reused as `artifacts` below.
624
+ const skillArtifacts = mechanicalArtifactsFor(c.id, gitRoot, extractManagedBlock, skillTemplates);
625
+ const mechanicalImplemented = c.id === 'git-hooks' ||
626
+ c.id === 'session-start-orientation' ||
627
+ skillArtifacts !== undefined;
628
+ // Content-equality probes on-disk artifacts (#2327 R3): 'present'.
629
+ readoutMeta[c.id] = mechanicalImplemented
630
+ ? { coverage: 'mechanical', sensesProbed: 'present' }
631
+ : { coverage: 'honest-absent' };
584
632
  // Honor the contract's `consumers` scope before sensing drift (mirrors
585
633
  // detectVersionPinnedContract, which self-guards). A scoped mechanical
586
634
  // contract must not emit drift — or, under --strict, fail — in a repo that
@@ -632,8 +680,9 @@ export async function checkParity(cwd) {
632
680
  blockingDriftIds.push(c.id);
633
681
  return lines;
634
682
  }
635
- // skills: managed-block content equality against the in-process template.
636
- const artifacts = mechanicalArtifactsFor(c.id, gitRoot, extractManagedBlock, skillTemplates);
683
+ // skills: managed-block content equality against the in-process template
684
+ // (resolved above, pre-scope-guard, for the readout classification).
685
+ const artifacts = skillArtifacts;
637
686
  if (artifacts === undefined) {
638
687
  return [
639
688
  stub(c, `${c.dimension} (mechanical) — drift detection not yet implemented for this sub-class`),
@@ -665,6 +714,9 @@ export async function checkParity(cwd) {
665
714
  // `info` can't enter blockingDriftIds, so these contracts cannot fail even
666
715
  // under --strict (the manifest's "never fails" contract).
667
716
  if (c.tractability === 'manual-attestation') {
717
+ // Declared-covered but mechanically uncovered (#2327 R2 — human-asserted
718
+ // judgment is not code-verified truth); nothing is probed.
719
+ readoutMeta[c.id] = { coverage: 'attestation-only' };
668
720
  // The detector reads the sub-class discriminant (`c.package`) + the
669
721
  // canonical source directly off the contract. `package:` set ⇒ vendor-SDK
670
722
  // pin read; unset ⇒ doctrine-row pure-info surface. `attested` carries the
@@ -678,17 +730,31 @@ export async function checkParity(cwd) {
678
730
  return [verdictToLine(c, verdict)];
679
731
  }
680
732
  // ── anything else (a future tractability) → skip stub; the slice boundary stays observable ──
733
+ readoutMeta[c.id] = { coverage: 'honest-absent' };
681
734
  return [
682
735
  stub(c, `${c.dimension} (${c.tractability}) — drift detection not yet implemented`),
683
736
  ];
684
737
  });
685
- return { results: [summary, ...perContract], blockingDriftIds, configured };
738
+ return {
739
+ results: [summary, ...perContract],
740
+ blockingDriftIds,
741
+ configured,
742
+ loadStatus: 'ok',
743
+ readout: {
744
+ manifest: {
745
+ schemaVersion: result.manifest.schemaVersion,
746
+ status: result.manifest.status,
747
+ },
748
+ contracts,
749
+ meta: readoutMeta,
750
+ },
751
+ };
686
752
  }
687
753
  }
688
754
  }
689
- /** Wrap a single summary line in the `ParityCheckResult` shape (no blocking ids). */
690
- function single(result, configured) {
691
- return { results: [result], blockingDriftIds: [], configured };
755
+ /** Wrap a single summary line in the `ParityCheckResult` shape (no blocking ids, no readout). */
756
+ function single(result, configured, loadStatus) {
757
+ return { results: [result], blockingDriftIds: [], configured, loadStatus };
692
758
  }
693
759
  /** Map a core `ParityContractVerdict` to a `ParityLine` keyed by the contract id. */
694
760
  function verdictToLine(contract, verdict) {
@@ -726,6 +792,223 @@ function rel(cwd, target) {
726
792
  const r = path.relative(cwd, target);
727
793
  return r.length > 0 ? r : target;
728
794
  }
795
+ // ─── Trust-readout assembly (mmnto-ai/totem#2327) ────────
796
+ /** The `--json` artifact's own version stamp (`readout-schema-version`). */
797
+ const READOUT_SCHEMA_VERSION = 1;
798
+ /**
799
+ * R2 claim-boundary sentence — the readout states its own scope in its own
800
+ * output: an active-but-unmanifested surface (an undeclared MCP server, a
801
+ * user-global plugin) is outside the claim, stated not implied.
802
+ */
803
+ const CLAIM_BOUNDARY = 'covers the manifest-declared contract set only; active-but-unmanifested surfaces are outside this claim';
804
+ /**
805
+ * Whether a verdict line belongs to a `--strict`-promotable contract. A
806
+ * contract's drift can render across MULTIPLE artifact lines (e.g.
807
+ * `Parity: claude-skills (signoff)`), so a line is promotable when its name is
808
+ * the `Parity: <id>` summary OR a `Parity: <id> (…)` artifact line — an
809
+ * exact-name Set would leave the artifact lines rendered as WARN while the
810
+ * command still exits non-zero (GCA review). The trailing space guards against
811
+ * a contract id that is a prefix of another. Shared by the CLI render and the
812
+ * readout builder so the two can't disagree on what promoted.
813
+ */
814
+ function isPromotableLineName(name, blockingDriftIds) {
815
+ return blockingDriftIds.some((id) => name === `Parity: ${id}` || name.startsWith(`Parity: ${id} `));
816
+ }
817
+ /** Resolve the contract a verdict line belongs to (same prefix rule as promotion). */
818
+ function contractForLineName(name, contracts) {
819
+ return contracts.find((c) => name === `Parity: ${c.id}` || name.startsWith(`Parity: ${c.id} `));
820
+ }
821
+ // The CLI-side stubs' "not yet implemented" message shape — the one code-owned
822
+ // string the classifier keys on. A future optional reasonClass on the core
823
+ // verdict type retires this coupling (flagged to strategy in the
824
+ // 2026-07-09T2256Z deltas dispatch as an observation).
825
+ const HONEST_ABSENT_RE = /not yet implemented/;
826
+ /**
827
+ * R3 reason class for one verdict line. `pass` omits its class. A `skip` is
828
+ * `honest-absent` when the stub message or the contract's coverage class says
829
+ * the detector is unbuilt; every other skip — scope-guard skips and
830
+ * scope-indeterminate skips (e.g. applicable-but-missing scaffolds) alike —
831
+ * classifies `scoping-skip`, never silently `honest-absent`, which would
832
+ * understate what the registry implements.
833
+ */
834
+ function reasonClassFor(verdict, message, coverage) {
835
+ switch (verdict) {
836
+ case 'pass':
837
+ return undefined;
838
+ case 'warn':
839
+ case 'fail':
840
+ return 'drift';
841
+ case 'info':
842
+ return 'attestation';
843
+ case 'unknown':
844
+ return 'detector-error';
845
+ case 'skip':
846
+ if (HONEST_ABSENT_RE.test(message) || coverage === 'honest-absent')
847
+ return 'honest-absent';
848
+ return 'scoping-skip';
849
+ }
850
+ }
851
+ /**
852
+ * Assemble the trust-readout from `checkParity`'s raw materials — pure
853
+ * post-processing over the existing detector output (Prop 303 non-goal 2:
854
+ * zero probes added here).
855
+ */
856
+ export function buildParityReadout(inputs, results, blockingDriftIds, strict) {
857
+ const { contracts, meta } = inputs;
858
+ const rows = [];
859
+ for (const line of results) {
860
+ const contract = contractForLineName(line.name, contracts);
861
+ // The section summary line (`Parity`) is not a contract row.
862
+ if (contract === undefined)
863
+ continue;
864
+ const promoted = strict && line.status === 'warn' && isPromotableLineName(line.name, blockingDriftIds);
865
+ const verdict = promoted ? 'fail' : line.status;
866
+ const m = meta[contract.id];
867
+ const reasonClass = reasonClassFor(verdict, line.message, m?.coverage);
868
+ rows.push({
869
+ id: contract.id,
870
+ verdict,
871
+ ...(m?.sensesProbed !== undefined ? { sensesProbed: m.sensesProbed } : {}),
872
+ ...(reasonClass !== undefined ? { reasonClass } : {}),
873
+ message: line.message,
874
+ ...(contract.lastAttested !== undefined ? { lastAttested: contract.lastAttested } : {}),
875
+ lineName: line.name,
876
+ skippedNotGated: verdict === 'skip' && contract.blocking === true,
877
+ });
878
+ }
879
+ const zero = () => ({ pass: 0, warn: 0, info: 0, unknown: 0, skip: 0, fail: 0 });
880
+ const global = zero();
881
+ for (const r of rows)
882
+ global[r.verdict] += 1;
883
+ // Seats = the sorted union of declared vendor-adapter values. A line counts
884
+ // toward seat S when its contract is vendor-neutral (no vendor-adapter — it
885
+ // manifests on every seat) or declares S. An EMPTY vendor-adapter list is
886
+ // treated as vendor-neutral too: the schema admits `[]`, and excluding such
887
+ // a row from every seat while counting it globally would reopen the exact
888
+ // per-seat honesty hole R1 exists to close (spec-owner review on #2328).
889
+ // Per-seat exists precisely so a seat-scoped skip cannot hide inside the
890
+ // global number (R1).
891
+ const seats = [...new Set(contracts.flatMap((c) => c.vendorAdapter ?? []))].sort();
892
+ const perSeat = {};
893
+ for (const seat of seats)
894
+ perSeat[seat] = zero();
895
+ const adapterById = new Map(contracts.map((c) => [c.id, c.vendorAdapter]));
896
+ for (const r of rows) {
897
+ const adapter = adapterById.get(r.id);
898
+ for (const seat of seats) {
899
+ if (adapter === undefined || adapter.length === 0 || adapter.includes(seat)) {
900
+ perSeat[seat][r.verdict] += 1;
901
+ }
902
+ }
903
+ }
904
+ // R2: the denominator counts CONTRACTS (registry ∩ manifest is contract-
905
+ // granular), while the rollup above counts rendered verdict LINES — two
906
+ // populations, named apart in the render (raised Delta 2).
907
+ const denominator = { mechanical: 0, attestationOnly: 0, honestAbsent: 0 };
908
+ for (const c of contracts) {
909
+ const cls = meta[c.id]?.coverage ?? 'honest-absent';
910
+ if (cls === 'mechanical')
911
+ denominator.mechanical += 1;
912
+ else if (cls === 'attestation-only')
913
+ denominator.attestationOnly += 1;
914
+ else
915
+ denominator.honestAbsent += 1;
916
+ }
917
+ // R5: blocking-ids = the manifest's DECLARED `blocking: true` set, derived
918
+ // from the yaml at run time (never from prose — Tenet 20); gates-anything =
919
+ // that set is non-empty. Which blocking contracts actually drifted is
920
+ // visible as `fail` rows (strict) / promotable warns (default).
921
+ const blockingIds = contracts.filter((c) => c.blocking === true).map((c) => c.id);
922
+ return {
923
+ manifest: inputs.manifest,
924
+ rollup: { global, perSeat },
925
+ denominator,
926
+ strict: { armed: strict, blockingIds, gatesAnything: blockingIds.length > 0 },
927
+ rows,
928
+ };
929
+ }
930
+ /** One-line count rendering shared by the global + per-seat rollup lines. */
931
+ function renderCounts(c) {
932
+ return `${c.pass} pass · ${c.warn} warn · ${c.info} info · ${c.unknown} unknown · ${c.skip} skip · ${c.fail} fail`;
933
+ }
934
+ /**
935
+ * R3 attestation-age fragment: days since `last-attested:` ("not recorded"
936
+ * when absent). Staleness refines the message, never the status (the existing
937
+ * manifest constraint, unchanged).
938
+ */
939
+ function attestationAge(lastAttested) {
940
+ if (lastAttested === undefined)
941
+ return 'last attested: not recorded';
942
+ const t = Date.parse(lastAttested);
943
+ if (Number.isNaN(t))
944
+ return `last attested: ${lastAttested} (unparseable date)`;
945
+ const days = Math.max(0, Math.floor((Date.now() - t) / 86_400_000));
946
+ return `last attested ${days} day(s) ago`;
947
+ }
948
+ /**
949
+ * The `--json` verdict artifact (R4) — field names fixed by the spec section
950
+ * (kebab-case, no local envelope: the spec's top-level shape overrides the
951
+ * `json-output.ts` success/error wrapper). Emitted bare on stdout so the
952
+ * artifact is diffable (Prop 302 verdict-artifact discipline). On degenerate
953
+ * manifest-load states the artifact carries the load status and empty
954
+ * rollup/rows — the honest "nothing to roll up" shape.
955
+ */
956
+ function readoutJsonArtifact(readout, loadStatus, strict) {
957
+ const countsJson = (c) => ({
958
+ pass: c.pass,
959
+ warn: c.warn,
960
+ info: c.info,
961
+ unknown: c.unknown,
962
+ skip: c.skip,
963
+ fail: c.fail,
964
+ });
965
+ if (readout === undefined) {
966
+ const zero = { pass: 0, warn: 0, info: 0, unknown: 0, skip: 0, fail: 0 };
967
+ return {
968
+ 'readout-schema-version': READOUT_SCHEMA_VERSION,
969
+ manifest: { status: loadStatus },
970
+ rollup: { global: countsJson(zero), 'per-seat': {} },
971
+ denominator: {
972
+ mechanical: 0,
973
+ 'attestation-only': 0,
974
+ 'honest-absent': 0,
975
+ 'claim-boundary': CLAIM_BOUNDARY,
976
+ },
977
+ strict: { armed: strict, 'blocking-ids': [], 'gates-anything': false },
978
+ rows: [],
979
+ };
980
+ }
981
+ return {
982
+ 'readout-schema-version': READOUT_SCHEMA_VERSION,
983
+ manifest: {
984
+ 'schema-version': readout.manifest.schemaVersion,
985
+ status: readout.manifest.status,
986
+ },
987
+ rollup: {
988
+ global: countsJson(readout.rollup.global),
989
+ 'per-seat': Object.fromEntries(Object.entries(readout.rollup.perSeat).map(([seat, c]) => [seat, countsJson(c)])),
990
+ },
991
+ denominator: {
992
+ mechanical: readout.denominator.mechanical,
993
+ 'attestation-only': readout.denominator.attestationOnly,
994
+ 'honest-absent': readout.denominator.honestAbsent,
995
+ 'claim-boundary': CLAIM_BOUNDARY,
996
+ },
997
+ strict: {
998
+ armed: readout.strict.armed,
999
+ 'blocking-ids': readout.strict.blockingIds,
1000
+ 'gates-anything': readout.strict.gatesAnything,
1001
+ },
1002
+ rows: readout.rows.map((r) => ({
1003
+ id: r.id,
1004
+ verdict: r.verdict,
1005
+ ...(r.sensesProbed !== undefined ? { 'senses-probed': r.sensesProbed } : {}),
1006
+ ...(r.reasonClass !== undefined ? { 'reason-class': r.reasonClass } : {}),
1007
+ message: r.message,
1008
+ ...(r.lastAttested !== undefined ? { 'last-attested': r.lastAttested } : {}),
1009
+ })),
1010
+ };
1011
+ }
729
1012
  // ─── CLI entry ──────────────────────────────────────────
730
1013
  // Same value as CHECK_NAME — aliased (not re-literal'd) so the two can't drift.
731
1014
  const TAG = CHECK_NAME;
@@ -747,23 +1030,50 @@ export async function doctorParityCliCommand(options = {}) {
747
1030
  .replace(/ {2,}/g, ' ')
748
1031
  .trim();
749
1032
  const cwd = options.cwdForTest ?? process.cwd();
750
- const { results, blockingDriftIds, configured } = await checkParity(cwd);
1033
+ const { results, blockingDriftIds, configured, loadStatus, readout: readoutInputs, } = await checkParity(cwd);
751
1034
  // Folded-into-`--strict` no-op: when this run is the strict fold (not an explicit
752
1035
  // `doctor --parity`) and no repo-local manifest is configured, render and gate
753
1036
  // nothing — a non-adopter's `doctor --strict` stays byte-identical to before the
754
1037
  // fold. Explicit `--parity` (onlyWhenConfigured omitted) still shows the SKIP.
755
1038
  if (options.onlyWhenConfigured && !configured)
756
1039
  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} `));
1040
+ const strict = options.strict === true;
1041
+ const readout = readoutInputs !== undefined
1042
+ ? buildParityReadout(readoutInputs, results, blockingDriftIds, strict)
1043
+ : undefined;
1044
+ // `--json` replaces the human render wholesale: the artifact owns stdout so
1045
+ // it stays diffable (R4). The strict throw below still applies — artifact
1046
+ // AND exit code, matching R5's "exits non-zero iff ≥1 fail".
1047
+ if (options.json) {
1048
+ process.stdout.write(JSON.stringify(readoutJsonArtifact(readout, loadStatus, strict), null, 2) + '\n');
1049
+ }
1050
+ else {
1051
+ renderParityHuman(results, blockingDriftIds, strict, { log, bold, errorColor, successColor, warnColor }, render);
1052
+ if (readout !== undefined) {
1053
+ renderTrustReadout(readout, { log, bold }, render);
1054
+ }
1055
+ }
1056
+ // Sensor-not-gate: drift is report-only by default (exit 0). Only `--strict`
1057
+ // promotes a `blocking: true` contract's drift to a non-zero exit; non-blocking
1058
+ // drift never gates, and `info`/`unknown` are never promoted. The detectors
1059
+ // emit no raw `fail` status, so the gate is purely the strict+blocking
1060
+ // promotion — the gating model for a future slice that DOES emit a `fail` is
1061
+ // settled when that slice lands (CR review mmnto-ai/totem#2071: keep the gate from
1062
+ // suggesting a non-strict path that would break sensor-not-gate).
1063
+ if (options.strict && blockingDriftIds.length > 0) {
1064
+ 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.');
1065
+ }
1066
+ }
1067
+ /** The pre-readout flat per-line render (unchanged output, extracted for the `--json` split). */
1068
+ function renderParityHuman(results, blockingDriftIds, strict, ui, render) {
1069
+ const { log, bold } = ui;
1070
+ const errorColor = ui.errorColor ?? ((s) => s);
1071
+ const successColor = ui.successColor ?? ((s) => s);
1072
+ const warnColor = ui.warnColor ?? ((s) => s);
765
1073
  for (const r of results) {
766
- const status = options.strict && r.status === 'warn' && isPromotable(r.name) ? 'fail' : r.status;
1074
+ const status = strict && r.status === 'warn' && isPromotableLineName(r.name, blockingDriftIds)
1075
+ ? 'fail'
1076
+ : r.status;
767
1077
  switch (status) {
768
1078
  case 'pass':
769
1079
  log.success(TAG, `${successColor(bold('PASS'))} — ${render(r.message)}`);
@@ -799,15 +1109,58 @@ export async function doctorParityCliCommand(options = {}) {
799
1109
  break;
800
1110
  }
801
1111
  }
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.');
1112
+ }
1113
+ /**
1114
+ * The trust-readout tail render (mmnto-ai/totem#2327 R1–R3, R5) replaces the
1115
+ * bare "N loaded" ending with the rollup, coverage denominator, why-not lines,
1116
+ * and the `--strict` honesty statement. Verdict lines above stay untouched.
1117
+ */
1118
+ function renderTrustReadout(readout, ui, render) {
1119
+ const { log, bold } = ui;
1120
+ log.info(TAG, bold('── trust-readout ──'));
1121
+ // R1 — one vocabulary, two scopes. Units named (verdict lines ≠ contracts).
1122
+ log.info(TAG, `rollup (verdict lines) — global: ${renderCounts(readout.rollup.global)}`);
1123
+ const seats = Object.keys(readout.rollup.perSeat);
1124
+ if (seats.length === 0) {
1125
+ log.dim(TAG, 'rollup — no seat-scoped rows declared (per-seat = global)');
1126
+ }
1127
+ else {
1128
+ for (const seat of seats) {
1129
+ log.info(TAG, `rollup — seat ${render(seat)}: ${renderCounts(readout.rollup.perSeat[seat])}`);
1130
+ }
1131
+ }
1132
+ // R2 — three counts on their own line, never one "covered" number; the
1133
+ // claim boundary stated in the readout's own words.
1134
+ const d = readout.denominator;
1135
+ log.info(TAG, `coverage (contracts) — ${d.mechanical} mechanically sensed · ${d.attestationOnly} attestation-only · ${d.honestAbsent} honest-absent`);
1136
+ log.dim(TAG, `claim boundary: ${CLAIM_BOUNDARY}`);
1137
+ // R3 — one why-not line per non-pass row, at the level actually probed
1138
+ // (green-halo cap: nothing renders above what was probed).
1139
+ const whyNot = readout.rows.filter((r) => r.verdict !== 'pass');
1140
+ if (whyNot.length > 0) {
1141
+ log.info(TAG, 'why-not (per non-pass row):');
1142
+ for (const r of whyNot) {
1143
+ const level = r.sensesProbed !== undefined ? ` at ${r.sensesProbed}` : '';
1144
+ const reason = r.reasonClass !== undefined ? ` [${r.reasonClass}]` : '';
1145
+ // R3 age-in-days for attestation rows. The detector message already
1146
+ // carries the raw date / "not recorded" text — append the derived age
1147
+ // only when a date exists to derive it from (no duplicate "not recorded").
1148
+ const age = r.reasonClass === 'attestation' && r.lastAttested !== undefined
1149
+ ? ` · ${attestationAge(r.lastAttested)}`
1150
+ : '';
1151
+ const gate = r.skippedNotGated ? ' · blocking — skipped-not-gated, never a silent pass' : '';
1152
+ log.dim(TAG, ` ${render(r.lineName)}: ${r.verdict.toUpperCase()}${level}${reason} — ${render(r.message)}${age}${gate}`);
1153
+ }
1154
+ }
1155
+ // R5 — declaredly-toothless in the readout's own words; derived from the
1156
+ // manifest's blocking set at run time, never from prose.
1157
+ const s = readout.strict;
1158
+ const armed = s.armed ? 'armed' : 'not armed';
1159
+ if (!s.gatesAnything) {
1160
+ log.info(TAG, `--strict (${armed}): currently gates nothing — the manifest declares no blocking: true contracts`);
1161
+ }
1162
+ else {
1163
+ log.info(TAG, `--strict (${armed}): gates ${s.blockingIds.length} blocking contract(s): ${s.blockingIds.map(render).join(', ')}`);
811
1164
  }
812
1165
  }
813
1166
  //# sourceMappingURL=doctor-parity.js.map