@mmnto/cli 1.117.0 → 1.118.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 (48) hide show
  1. package/dist/commands/bootstrap-wiring.test.js +28 -0
  2. package/dist/commands/bootstrap-wiring.test.js.map +1 -1
  3. package/dist/commands/init-templates.d.ts +6 -6
  4. package/dist/commands/init-templates.d.ts.map +1 -1
  5. package/dist/commands/init-templates.js +11 -8
  6. package/dist/commands/init-templates.js.map +1 -1
  7. package/dist/commands/init.test.js +54 -6
  8. package/dist/commands/init.test.js.map +1 -1
  9. package/dist/commands/install-hooks.d.ts.map +1 -1
  10. package/dist/commands/install-hooks.js +12 -1
  11. package/dist/commands/install-hooks.js.map +1 -1
  12. package/dist/commands/lint.js +1 -1
  13. package/dist/commands/lint.js.map +1 -1
  14. package/dist/commands/pre-push-gate-matrix.test.d.ts +17 -0
  15. package/dist/commands/pre-push-gate-matrix.test.d.ts.map +1 -0
  16. package/dist/commands/pre-push-gate-matrix.test.js +135 -0
  17. package/dist/commands/pre-push-gate-matrix.test.js.map +1 -0
  18. package/dist/commands/shield-admission.test.d.ts +14 -0
  19. package/dist/commands/shield-admission.test.d.ts.map +1 -0
  20. package/dist/commands/shield-admission.test.js +301 -0
  21. package/dist/commands/shield-admission.test.js.map +1 -0
  22. package/dist/commands/shield-classify.d.ts +10 -0
  23. package/dist/commands/shield-classify.d.ts.map +1 -1
  24. package/dist/commands/shield-classify.js +19 -1
  25. package/dist/commands/shield-classify.js.map +1 -1
  26. package/dist/commands/shield-covariate.test.js +3 -0
  27. package/dist/commands/shield-covariate.test.js.map +1 -1
  28. package/dist/commands/shield-estimate.js +5 -5
  29. package/dist/commands/shield-estimate.js.map +1 -1
  30. package/dist/commands/shield-estimate.test.js +7 -3
  31. package/dist/commands/shield-estimate.test.js.map +1 -1
  32. package/dist/commands/shield-nonreview.test.d.ts +12 -14
  33. package/dist/commands/shield-nonreview.test.d.ts.map +1 -1
  34. package/dist/commands/shield-nonreview.test.js +228 -42
  35. package/dist/commands/shield-nonreview.test.js.map +1 -1
  36. package/dist/commands/shield.d.ts +116 -1
  37. package/dist/commands/shield.d.ts.map +1 -1
  38. package/dist/commands/shield.js +439 -116
  39. package/dist/commands/shield.js.map +1 -1
  40. package/dist/git.d.ts +20 -2
  41. package/dist/git.d.ts.map +1 -1
  42. package/dist/git.js +11 -4
  43. package/dist/git.js.map +1 -1
  44. package/dist/git.test.js +58 -18
  45. package/dist/git.test.js.map +1 -1
  46. package/dist/index.js +2 -1
  47. package/dist/index.js.map +1 -1
  48. package/package.json +2 -2
@@ -974,14 +974,314 @@ export async function evaluateIncrementalEligibility(cwd, totemDir, configRoot)
974
974
  linesChanged: totalLines,
975
975
  };
976
976
  }
977
+ // ─── Admission phase (mmnto-ai/totem#2473) ──────────────────────────────────
978
+ //
979
+ // Applicability is decided by ONE closed evaluator BEFORE the engine boots or
980
+ // any lane is configured: either an admitted payload (the exact fan inputs) or
981
+ // a `not-applicable` verdict with its deterministic reason (the exact record
982
+ // inputs). A deterministic skip is an ADMISSION outcome — never a lane status,
983
+ // never an `InvokeFailureKind` (#2452 describes failures after an invocation
984
+ // was attempted). The record binds the exact observation (scope + input hash +
985
+ // selection-policy fingerprint) so `--covariate` resolves the CURRENT identity
986
+ // deterministically — never wall-clock arbitration (codex review, 2026-08-14).
987
+ /**
988
+ * Base identifier for the non-code classifier whose partition the admission
989
+ * projection applies. `buildProjectionPolicy` appends a digest of the
990
+ * classifier's exported policy tables (`CLASSIFIER_POLICY_TABLES`), so a
991
+ * table edit re-keys every admission address MECHANICALLY — no hand-bumped
992
+ * version string to forget (falsification-leg MATERIAL 2). Bump the base
993
+ * only when the classification ALGORITHM changes shape (new lookup order,
994
+ * new rule class) rather than its tables.
995
+ */
996
+ const REVIEW_CLASSIFIER_ID_BASE = 'classifyChangedFiles@1';
997
+ /** The requested selector expression, recorded as the no-diff arm's scope identity. */
998
+ export function requestedSelectorForm(options) {
999
+ if (options.diff !== undefined)
1000
+ return `--diff ${options.diff}`;
1001
+ if (options.staged === true)
1002
+ return '--staged';
1003
+ if (options.base !== undefined)
1004
+ return `--branch --base ${options.base}`;
1005
+ if (options.branch === true)
1006
+ return '--branch';
1007
+ return '(default-chain)';
1008
+ }
1009
+ /**
1010
+ * Mechanical payload narrowing shared by admission (full scope) and the legacy
1011
+ * incremental fast-path (delta): strip generated-artifact bytes (summarized,
1012
+ * not dropped), then filter non-code files. Pure classification — writes
1013
+ * nothing — so the read-only `--covariate` re-derivation can run it.
1014
+ */
1015
+ async function prepareReviewPayload(diff, changedFiles, cwd, quiet) {
1016
+ const { filterDiffByPatterns } = await import('../git.js');
1017
+ const { classifyChangedFiles } = await import('./shield-classify.js');
1018
+ const { DEFAULT_GENERATED_ARTIFACT_GLOBS, buildGeneratedArtifactSection, classifyGeneratedArtifacts, formatGeneratedArtifactLine, readGitattributesGeneratedPatterns, } = await import('./shield-generated.js');
1019
+ // Stage 0.5: Exclude generated-artifact BYTES from the synthesis input
1020
+ // (mmnto-ai/totem#2398): lockfiles, compiled-rules.json, dist/**, *.wasm burn
1021
+ // review-context tokens on bytes no reviewer should read. Classified by the
1022
+ // seeded globs + `.gitattributes` linguist-generated, stripped with a
1023
+ // per-file SUMMARY instead of a silent drop.
1024
+ let keptDiff = diff;
1025
+ let keptFiles = changedFiles;
1026
+ let generatedArtifactSummary;
1027
+ const gitattr = readGitattributesGeneratedPatterns(cwd);
1028
+ const generated = classifyGeneratedArtifacts({
1029
+ diff,
1030
+ changedFiles,
1031
+ generatedGlobs: [...DEFAULT_GENERATED_ARTIFACT_GLOBS, ...gitattr.generated],
1032
+ excludeGlobs: gitattr.notGenerated,
1033
+ });
1034
+ if (generated.summaries.length > 0) {
1035
+ if (!quiet) {
1036
+ log.info(DISPLAY_TAG, `Excluded ${generated.summaries.length} generated-artifact file(s) from the review payload (summarized, not dropped):`);
1037
+ for (const summary of generated.summaries) {
1038
+ log.dim(DISPLAY_TAG, formatGeneratedArtifactLine(summary));
1039
+ }
1040
+ }
1041
+ keptDiff = generated.keptDiff;
1042
+ keptFiles = generated.keptFiles;
1043
+ generatedArtifactSummary = buildGeneratedArtifactSection(generated.summaries);
1044
+ // All changed files were generated artifacts — nothing left to review.
1045
+ // This is the one skip that can drop a TRACKED, HASHED source file
1046
+ // (`.gitattributes linguist-generated` can mark a `.ts` as generated), so
1047
+ // it must never read as a clean review (mmnto-ai/totem#2466).
1048
+ if (!keptDiff.trim()) {
1049
+ return { empty: 'all-generated', skippedFiles: changedFiles };
1050
+ }
1051
+ }
1052
+ // Stage 1: Classify files — fast-path for non-code-only diffs
1053
+ const classification = classifyChangedFiles(keptFiles);
1054
+ if (classification.allNonCode) {
1055
+ return { empty: 'all-non-code', skippedFiles: keptFiles };
1056
+ }
1057
+ // Stage 2: Filter diff to code-only files for mixed diffs
1058
+ let filteredDiff = keptDiff;
1059
+ let filteredFiles = keptFiles;
1060
+ if (!classification.allCode && classification.nonCodeFiles.length > 0) {
1061
+ filteredDiff = await filterDiffByPatterns(keptDiff, classification.nonCodeFiles);
1062
+ filteredFiles = classification.codeFiles;
1063
+ if (!filteredDiff.trim()) {
1064
+ // After filtering non-code files, no code diff remains — nothing was examined.
1065
+ return { empty: 'filtered-empty', skippedFiles: keptFiles };
1066
+ }
1067
+ if (!quiet) {
1068
+ log.dim(DISPLAY_TAG, `Filtered ${classification.nonCodeFiles.length} non-code file(s) from diff`);
1069
+ }
1070
+ }
1071
+ return {
1072
+ diff: keptDiff,
1073
+ changedFiles: keptFiles,
1074
+ filteredDiff,
1075
+ filteredFiles,
1076
+ generatedArtifactSummary,
1077
+ };
1078
+ }
1079
+ /**
1080
+ * Select the execution payload for an ADMITTED run: the incremental delta when
1081
+ * it is eligible AND re-projects to something reviewable, else the admitted
1082
+ * full-scope payload. A non-reviewable delta (docs-only since the last pass)
1083
+ * falls back — it can never demote the run to a skip, because admission
1084
+ * already admitted the full scope. Pure selection; the caller owns logging.
1085
+ */
1086
+ export async function selectExecutionPayload(admitted, incremental, cwd, quiet) {
1087
+ const fullScope = {
1088
+ diff: admitted.diff,
1089
+ changedFiles: admitted.changedFiles,
1090
+ filteredDiff: admitted.filteredDiff,
1091
+ filteredFiles: admitted.filteredFiles,
1092
+ generatedArtifactSummary: admitted.generatedArtifactSummary,
1093
+ };
1094
+ if (!incremental.eligible || !incremental.deltaDiff || !incremental.changedFiles) {
1095
+ return { payload: fullScope, narrowed: false };
1096
+ }
1097
+ const delta = await prepareReviewPayload(incremental.deltaDiff, incremental.changedFiles, cwd, quiet);
1098
+ if (delta.empty !== undefined) {
1099
+ return { payload: fullScope, narrowed: false, deltaFallbackReason: delta.empty };
1100
+ }
1101
+ return {
1102
+ payload: {
1103
+ diff: delta.diff,
1104
+ changedFiles: delta.changedFiles,
1105
+ filteredDiff: delta.filteredDiff,
1106
+ filteredFiles: delta.filteredFiles,
1107
+ generatedArtifactSummary: delta.generatedArtifactSummary,
1108
+ },
1109
+ narrowed: true,
1110
+ };
1111
+ }
1112
+ /** The effective selection policy whose projection produced the admission outcome. */
1113
+ export async function buildProjectionPolicy(config, cwd) {
1114
+ const { DEFAULT_GENERATED_ARTIFACT_GLOBS, readGitattributesGeneratedPatterns } = await import('./shield-generated.js');
1115
+ const { CLASSIFIER_POLICY_TABLES } = await import('./shield-classify.js');
1116
+ const crypto = await import('node:crypto');
1117
+ const gitattr = readGitattributesGeneratedPatterns(cwd);
1118
+ // Mechanical table binding (falsification-leg MATERIAL 2): the digest of the
1119
+ // classifier's exported tables rides the id, so an edited table changes the
1120
+ // policy hash with no version-bump discipline required.
1121
+ const tablesDigest = crypto
1122
+ .createHash('sha256')
1123
+ .update(JSON.stringify(CLASSIFIER_POLICY_TABLES), 'utf-8')
1124
+ .digest('hex')
1125
+ .slice(0, 12);
1126
+ return {
1127
+ sourceExtensions: config.review.sourceExtensions,
1128
+ generatedGlobs: [...DEFAULT_GENERATED_ARTIFACT_GLOBS, ...gitattr.generated],
1129
+ notGeneratedGlobs: gitattr.notGenerated,
1130
+ // Defensive `?? []`: the Zod-validated config always carries the field, but
1131
+ // this seam also receives cast fixtures (and the same guard shape git.ts
1132
+ // uses for `shieldIgnorePatterns`).
1133
+ ignorePatterns: [...(config.ignorePatterns ?? []), ...(config.shieldIgnorePatterns ?? [])],
1134
+ classifierId: `${REVIEW_CLASSIFIER_ID_BASE}:${tablesDigest}`,
1135
+ };
1136
+ }
1137
+ /**
1138
+ * The closed admission evaluator (mmnto-ai/totem#2473 ruling item 1). Either
1139
+ * an admitted payload (the exact fan inputs) or `not-applicable` with the
1140
+ * exact record inputs. Read-only — the caller owns record emission.
1141
+ */
1142
+ export async function evaluateAdmission(input) {
1143
+ const { computeProjectionPolicyHash } = await import('@mmnto/totem');
1144
+ const crypto = await import('node:crypto');
1145
+ const sha256Hex = (text) => crypto.createHash('sha256').update(text, 'utf-8').digest('hex');
1146
+ const projectionPolicyHash = computeProjectionPolicyHash(await buildProjectionPolicy(input.config, input.cwd));
1147
+ if (input.diffResult === null || 'empty' in input.diffResult) {
1148
+ // Bind the RESOLVED empty scope when the resolver supplied one; the
1149
+ // requested selector fills the selector slot where the resolver has none
1150
+ // (keeping an empty `--staged` run distinguishable from an empty
1151
+ // default-chain run even though both terminate at the same branch-vs-base
1152
+ // fallback). `source: 'none'` survives only for the scope-less legacy arm.
1153
+ const resolved = input.diffResult;
1154
+ return {
1155
+ status: 'not-applicable',
1156
+ reason: 'no-diff',
1157
+ scope: resolved === null
1158
+ ? { source: 'none', base: null, head: null, selectorForm: input.requestedSelector }
1159
+ : {
1160
+ source: resolved.source,
1161
+ base: resolved.base ?? null,
1162
+ head: resolved.head ?? null,
1163
+ selectorForm: resolved.selectorForm ?? input.requestedSelector,
1164
+ },
1165
+ inputHash: sha256Hex(''),
1166
+ projectionPolicyHash,
1167
+ skippedFileCount: 0,
1168
+ skippedFiles: [],
1169
+ };
1170
+ }
1171
+ const scope = {
1172
+ source: input.diffResult.source,
1173
+ base: input.diffResult.base ?? null,
1174
+ head: input.diffResult.head ?? null,
1175
+ selectorForm: input.diffResult.selectorForm ?? null,
1176
+ };
1177
+ const inputHash = sha256Hex(input.diffResult.diff);
1178
+ const payload = await prepareReviewPayload(input.diffResult.diff, input.diffResult.changedFiles, input.cwd, input.quiet);
1179
+ if (payload.empty !== undefined) {
1180
+ return {
1181
+ status: 'not-applicable',
1182
+ reason: payload.empty,
1183
+ scope,
1184
+ inputHash,
1185
+ projectionPolicyHash,
1186
+ skippedFileCount: payload.skippedFiles.length,
1187
+ skippedFiles: payload.skippedFiles,
1188
+ };
1189
+ }
1190
+ return {
1191
+ status: 'admitted',
1192
+ diff: payload.diff,
1193
+ changedFiles: payload.changedFiles,
1194
+ filteredDiff: payload.filteredDiff,
1195
+ filteredFiles: payload.filteredFiles,
1196
+ generatedArtifactSummary: payload.generatedArtifactSummary,
1197
+ scope,
1198
+ inputHash,
1199
+ projectionPolicyHash,
1200
+ };
1201
+ }
1202
+ /**
1203
+ * Disposition→exit mapping for the admission phase. Bare review and `--gate`
1204
+ * both map every KNOWN not-applicable reason to exit 0 (the ruled
1205
+ * no-nonzero-by-default shape). The difference is the unknown arm: `--gate` is
1206
+ * a DECLARED mapping, so an unknown disposition fails CLOSED (nonzero via the
1207
+ * supplied error ctor) while the bare sensor warns and stays 0.
1208
+ */
1209
+ export function resolveNotApplicableExit(reason, gate, errCtor) {
1210
+ switch (reason) {
1211
+ case 'no-diff':
1212
+ case 'all-non-code':
1213
+ case 'filtered-empty':
1214
+ case 'all-generated':
1215
+ return 0;
1216
+ default:
1217
+ if (gate) {
1218
+ throw new errCtor('SHIELD_FAILED', `--gate: unknown admission disposition "${reason}" — failing closed (the gate maps only declared dispositions to exits).`, 'Upgrade @mmnto/cli so the hook and the CLI agree on the disposition vocabulary, or drop --gate for the sensor default.');
1219
+ }
1220
+ log.warn(DISPLAY_TAG, `Unknown admission disposition "${reason}" — sensor default exit 0 (a --gate wiring would fail closed here).`);
1221
+ return 0;
1222
+ }
1223
+ }
1224
+ /** Human detail for the single calm disposition line, per reason. */
1225
+ function describeNotApplicable(outcome) {
1226
+ // Counts are EXACT for what each arm's `skippedFileCount` actually holds
1227
+ // (falsification-leg MINOR 4): all-generated counts the changed files;
1228
+ // all-non-code and filtered-empty count the files remaining AFTER
1229
+ // generated-artifact exclusion. The rendered strings say "in scope" for the
1230
+ // post-strip arms — shorthand for that post-exclusion set (the record
1231
+ // schema's field doc carries the full reason-dependent basis; re-arm NIT 8).
1232
+ switch (outcome.reason) {
1233
+ case 'no-diff':
1234
+ return 'no changes detected in the resolved scope';
1235
+ case 'all-non-code':
1236
+ return `every file in scope (${outcome.skippedFileCount}) is non-code`;
1237
+ case 'filtered-empty':
1238
+ return `no code diff remains after filtering non-code files from ${outcome.skippedFileCount} file(s) in scope`;
1239
+ case 'all-generated':
1240
+ return `every changed file (${outcome.skippedFileCount}) is a generated artifact`;
1241
+ }
1242
+ }
1243
+ /**
1244
+ * The SINGLE not-applicable emission path (one record, one calm line — ruling
1245
+ * item 2). Persisting the record is fail-soft: it is disclosure, not a gate
1246
+ * (Tenet 13) — the printed line is the loud surface and never silently drops,
1247
+ * which is what licenses the catch below against Tenet 4. Never stamps.
1248
+ */
1249
+ async function emitNotApplicableDisposition(params) {
1250
+ const { ADMISSION_RECORD_SCHEMA_VERSION, saveAdmissionRecord } = await import('@mmnto/totem');
1251
+ const { outcome } = params;
1252
+ resolveNotApplicableExit(outcome.reason, params.gate, params.errCtor);
1253
+ let recordedNote = '';
1254
+ try {
1255
+ const saved = saveAdmissionRecord(params.totemDirAbs, {
1256
+ schemaVersion: ADMISSION_RECORD_SCHEMA_VERSION,
1257
+ disposition: 'not-applicable',
1258
+ reason: outcome.reason,
1259
+ createdAt: new Date().toISOString(),
1260
+ scope: outcome.scope,
1261
+ inputHash: outcome.inputHash,
1262
+ projectionPolicyHash: outcome.projectionPolicyHash,
1263
+ skippedFileCount: outcome.skippedFileCount,
1264
+ });
1265
+ recordedNote = ` (recorded ${saved.hash.slice(0, 8)})`;
1266
+ // totem-context: intentional cleanup — the record is disclosure, not a gate (Tenet 13): the printed disposition line below is the loud surface and never silently drops, so a failed persist degrades to the WARN here instead of blocking a docs-only push (design §Failure modes, mmnto-ai/totem#2473; strategy-claude review upheld this boundary)
1267
+ }
1268
+ catch (err) {
1269
+ log.warn(DISPLAY_TAG, `Admission record write failed — disposition not persisted (the line below remains the loud surface): ${err instanceof Error ? err.message : String(err)}`);
1270
+ }
1271
+ // ONE calm line, info-level — this prints on every docs-only run in every
1272
+ // consumer repo, so it must never read as a warning wall (#2473 consumer
1273
+ // datum). The no-stamp guarantee text is single-sourced via NO_STAMP_NOTICE.
1274
+ log.info(DISPLAY_TAG, `not-applicable (${outcome.reason}): ${describeNotApplicable(outcome)} — no lane ran. ${NO_STAMP_NOTICE}${recordedNote}`);
1275
+ if (outcome.skippedFiles.length > 0) {
1276
+ log.dim(DISPLAY_TAG, `Skipped: ${outcome.skippedFiles.join(', ')}`);
1277
+ }
1278
+ }
977
1279
  // ─── Main command ───────────────────────────────────
978
1280
  export async function shieldCommand(options) {
979
1281
  const path = await import('node:path');
980
1282
  const { TotemConfigError, TotemError } = await import('@mmnto/totem');
981
- const { filterDiffByPatterns, getDiffForReview } = await import('../git.js');
982
- const { classifyChangedFiles } = await import('./shield-classify.js');
1283
+ const { getDiffForReview } = await import('../git.js');
983
1284
  const { extractShieldContextAnnotations, extractShieldHints } = await import('./shield-hints.js');
984
- const { DEFAULT_GENERATED_ARTIFACT_GLOBS, buildGeneratedArtifactSection, classifyGeneratedArtifacts, formatGeneratedArtifactLine, readGitattributesGeneratedPatterns, } = await import('./shield-generated.js');
985
1285
  // mmnto-ai/totem#1714: --estimate is the deterministic-rule pre-flight
986
1286
  // path. Reject incompatible flag combinations BEFORE any other
987
1287
  // validation (e.g. the --override length check below) so the user-
@@ -996,6 +1296,11 @@ export async function shieldCommand(options) {
996
1296
  ['fresh', '--fresh'],
997
1297
  ['mode', '--mode'],
998
1298
  ['raw', '--raw'],
1299
+ // --gate maps admission/round dispositions to exits; an estimate is a
1300
+ // zero-LLM forecast with neither, so the flag has no semantics here —
1301
+ // and rejecting it keeps the --gate/--fail-on conflict unreachable via
1302
+ // the estimate short-circuit (falsification-leg MINOR 8).
1303
+ ['gate', '--gate'],
999
1304
  ];
1000
1305
  for (const [key, flag] of incompatible) {
1001
1306
  const value = options[key];
@@ -1021,6 +1326,17 @@ export async function shieldCommand(options) {
1021
1326
  if (options.mode && options.mode !== 'standard' && options.mode !== 'structural') {
1022
1327
  throw new TotemConfigError(`Invalid --mode "${options.mode}". Use "standard" or "structural".`, 'Check `totem review --help` for valid options.', 'CONFIG_INVALID');
1023
1328
  }
1329
+ // Flag-contract validation runs BEFORE `upgradePrePushHookIfNeeded` or any
1330
+ // other side effect (codex on mmnto-ai/totem#2473): an invalid command must
1331
+ // not mutate the hook on its way to CONFIG_INVALID.
1332
+ if (options.gate === true && options.failOn !== undefined) {
1333
+ throw new TotemConfigError('--gate and --fail-on are contradictory (hook context is findings-report-only per mmnto-ai/totem#2551; a severity gate asserts the opposite).', 'Use --gate for hook wiring (declared disposition→exit mapping) OR --fail-on <severity> for a findings gate — not both.', 'CONFIG_INVALID');
1334
+ }
1335
+ // Gate G5: validate `--fail-on` (only the fan reads it, but a bad value is a hard
1336
+ // config error on any path so the user is never silently ignored).
1337
+ if (options.failOn !== undefined && options.failOn !== 'critical' && options.failOn !== 'warn') {
1338
+ 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');
1339
+ }
1024
1340
  if (options.override !== undefined && options.override.length < 10) {
1025
1341
  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');
1026
1342
  }
@@ -1046,9 +1362,47 @@ export async function shieldCommand(options) {
1046
1362
  // ordinary no-diff path performs — this verb writes nothing).
1047
1363
  if (options.covariate) {
1048
1364
  const diffResult = await getDiffForReview(options, config, cwd, DISPLAY_TAG);
1365
+ // Admission-aware resolution (mmnto-ai/totem#2473): re-derive the CURRENT
1366
+ // admission classification (read-only, zero-LLM) and resolve by exact
1367
+ // identity — deterministic, never wall-clock arbitration across record
1368
+ // families. A not-applicable current state resolves the admission store;
1369
+ // an admitted current state resolves the verdict store as before.
1370
+ const admission = await evaluateAdmission({
1371
+ diffResult,
1372
+ requestedSelector: requestedSelectorForm(options),
1373
+ cwd,
1374
+ config,
1375
+ quiet: true,
1376
+ });
1377
+ if (admission.status === 'not-applicable') {
1378
+ const { findAdmissionRecordByIdentity, renderAdmissionLine } = await import('@mmnto/totem');
1379
+ // Identity carries the OBSERVATION only — schemaVersion is writer
1380
+ // metadata, excluded from the address so a 1.x bump never orphans prior
1381
+ // records on this exact-identity path (falsification-leg MINOR 3).
1382
+ const found = findAdmissionRecordByIdentity(path.join(configRoot, config.totemDir), {
1383
+ disposition: 'not-applicable',
1384
+ reason: admission.reason,
1385
+ scope: admission.scope,
1386
+ inputHash: admission.inputHash,
1387
+ projectionPolicyHash: admission.projectionPolicyHash,
1388
+ skippedFileCount: admission.skippedFileCount,
1389
+ }, (msg) => log.warn(DISPLAY_TAG, `Sensor: ${msg}`));
1390
+ if (found !== undefined) {
1391
+ // STDOUT, not the stderr log: the line IS the transport payload.
1392
+ console.log(renderAdmissionLine(found));
1393
+ }
1394
+ else {
1395
+ // LOUD no-current-record sensor — never a silent fallback to an older
1396
+ // verdict on the lineage (codex on mmnto-ai/totem#2473).
1397
+ log.warn(DISPLAY_TAG, `Covariate: the current state is not-applicable (${admission.reason}) but no admission record exists for this exact observation — run \`totem review\` to record it (sensor; exit 0).`);
1398
+ }
1399
+ return;
1400
+ }
1049
1401
  const { printCovariateLine } = await import('./review-fan.js');
1050
1402
  await printCovariateLine({
1051
- diffMeta: diffResult === null
1403
+ // An empty/absent resolution never reaches here (it resolves through the
1404
+ // admission arm above); the null arm is type-narrowing, not a live path.
1405
+ diffMeta: diffResult === null || 'empty' in diffResult
1052
1406
  ? null
1053
1407
  : {
1054
1408
  source: diffResult.source,
@@ -1072,16 +1426,42 @@ export async function shieldCommand(options) {
1072
1426
  if (!options.raw) {
1073
1427
  log.info(DISPLAY_TAG, 'Supplementary AI review lanes (advisory — not a merge gate). Limits disclosed in output: LLM window truncation on large diffs; non-code files skipped.');
1074
1428
  }
1075
- // Engine boot (mmnto-ai/totem#1794) — see lint.ts wiring for context.
1076
- const { bootstrapEngine } = await import('../utils/bootstrap-engine.js');
1077
- await bootstrapEngine(config, configRoot);
1429
+ // ── Admission phase (mmnto-ai/totem#2473) ──
1430
+ // Always the FULL scope from `getDiffForReview` — the legacy incremental
1431
+ // fast-path narrows only the admitted execution payload below, never
1432
+ // admission identity. A not-applicable outcome takes the SINGLE emission
1433
+ // path (one record + one calm line, no stamp — the former no-diff "trivial
1434
+ // pass" stamp is removed: it minted authorization for a tree no reviewer
1435
+ // saw) and returns — the fan is unreachable past this gate by CONTROL FLOW
1436
+ // (boot-spy test-locked); the type barrier sits one seam later at
1437
+ // `selectExecutionPayload` (re-arm NIT 9 wording).
1438
+ const diffResult = await getDiffForReview(options, config, cwd, DISPLAY_TAG);
1439
+ const admission = await evaluateAdmission({
1440
+ diffResult,
1441
+ requestedSelector: requestedSelectorForm(options),
1442
+ cwd,
1443
+ config,
1444
+ quiet: false,
1445
+ });
1446
+ if (admission.status === 'not-applicable') {
1447
+ await emitNotApplicableDisposition({
1448
+ outcome: admission,
1449
+ gate: options.gate === true,
1450
+ totemDirAbs: path.join(configRoot, config.totemDir),
1451
+ errCtor: TotemError,
1452
+ });
1453
+ return;
1454
+ }
1078
1455
  // ── Multi-lane review fan activation (Prop 304 R2, mmnto-ai/totem#2106) ──
1079
- // Validate `review.lanes` at review startup (a hard init error on any
1080
- // violation) and normalize. An explicit `--model` selects a ONE-lane
1081
- // invocation and never joins the configured fan (precedence pinned); the fan
1082
- // also does not apply to structural mode (context-blind single-lane stays
1083
- // legacy). `review.lanes` absent [] the legacy single-lane path runs
1084
- // byte-for-byte as today (invariant 7).
1456
+ // ADMITTED branch: fan configuration is downstream of admission (codex
1457
+ // conformance note 2 on #2473) a malformed lane config must not preempt a
1458
+ // not-applicable disposition. Universal flag validation (--gate/--fail-on/
1459
+ // --mode/--override) stays at the top of the command.
1460
+ // Validate `review.lanes` (a hard init error on any violation) and
1461
+ // normalize. An explicit `--model` selects a ONE-lane invocation and never
1462
+ // joins the configured fan (precedence pinned); the fan also does not apply
1463
+ // to structural mode (context-blind single-lane stays legacy). `review.lanes`
1464
+ // absent ⇒ [] ⇒ the legacy single-lane path runs byte-for-byte (invariant 7).
1085
1465
  const { validateReviewLanes, assertFanFlagsSupported } = await import('./review-fan.js');
1086
1466
  const laneModels = validateReviewLanes(config.review.lanes, config.orchestrator?.provider, TotemConfigError);
1087
1467
  // Finding 1: a fan-configured `--raw` stays the legacy ZERO-LLM context dump (no
@@ -1095,110 +1475,56 @@ export async function shieldCommand(options) {
1095
1475
  // LOUDLY (naming the unsupported combination) rather than silently ignoring them.
1096
1476
  if (fanActive)
1097
1477
  assertFanFlagsSupported(options, TotemConfigError);
1098
- // Gate G5: validate `--fail-on` (only the fan reads it, but a bad value is a hard
1099
- // config error on any path so the user is never silently ignored).
1100
- if (options.failOn !== undefined && options.failOn !== 'critical' && options.failOn !== 'warn') {
1101
- 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');
1102
- }
1103
- // --- Incremental shield fast-path (#1010) ---
1104
- // If the change since the last passed shield is small enough (< 15 lines,
1105
- // no new files), only evaluate the delta instead of the full branch diff.
1106
- // The fan needs full diff-scope metadata (source/base/head) for lineage, so
1107
- // the incremental fast-path is bypassed when the fan is active.
1108
- let diff;
1109
- let changedFiles;
1110
- // Resolved diff-scope metadata (Prop 304 R2) captured for the fan's verdict
1111
- // `diffScope` + lineage. Only populated on the full-diff path (the fan bypasses
1112
- // the incremental fast-path), so it is defined whenever `fanActive`.
1113
- let diffScopeMeta;
1478
+ // Engine boot (mmnto-ai/totem#1794) admitted runs only; a deterministic
1479
+ // skip never boots the engine. See lint.ts wiring for context.
1480
+ const { bootstrapEngine } = await import('../utils/bootstrap-engine.js');
1481
+ await bootstrapEngine(config, configRoot);
1482
+ // Resolved diff-scope metadata (Prop 304 R2) — the fan's verdict `diffScope` +
1483
+ // lineage, read from the admission's bound scope (one identity, never a
1484
+ // re-derive). Finding 10: the raw CLI selector form so `--diff main` and
1485
+ // `--diff main..HEAD` (same resolved refs) do NOT share a lineage the
1486
+ // admitted arm carries the resolver's selectorForm only (never the
1487
+ // requested-selector fallback the empty arms use).
1488
+ const admittedScope = admission.scope;
1489
+ if (admittedScope.source === 'none') {
1490
+ // Unreachable: an admitted outcome requires a resolved diff. Fail loud,
1491
+ // never fabricate a lineage.
1492
+ throw new TotemError('SHIELD_FAILED', 'Admitted review carries no resolved diff scope — this is a bug in the admission evaluator.', 'Re-run `totem review`; if this persists, file it with the command line used.');
1493
+ }
1494
+ const diffScopeMeta = {
1495
+ source: admittedScope.source,
1496
+ base: admittedScope.base ?? undefined,
1497
+ head: admittedScope.head ?? undefined,
1498
+ selectorForm: admittedScope.selectorForm ?? undefined,
1499
+ };
1500
+ // --- Incremental fast-path (#1010) — a payload SELECTION step (codex note 3) ---
1501
+ // The fan needs full diff-scope metadata for lineage, so it bypasses the
1502
+ // incremental path. Selection can narrow the execution payload to the delta;
1503
+ // it can never change admission (a non-reviewable delta falls back to the
1504
+ // admitted full-scope payload).
1114
1505
  const incremental = fanActive
1115
1506
  ? { eligible: false, reason: 'multi-lane fan requires full diff scope' }
1116
1507
  : await evaluateIncrementalEligibility(cwd, config.totemDir, configRoot);
1117
- if (incremental.eligible && incremental.deltaDiff && incremental.changedFiles) {
1508
+ // quiet=true for the delta re-projection: the full-scope pass already printed
1509
+ // the generated/filtered disclosures, so the delta pass logs nothing (the
1510
+ // .gitattributes read itself still happens — quiet gates logging only).
1511
+ // Named trade-off: on a narrowed run the printed disclosures describe the
1512
+ // FULL scope, a superset of the payload actually reviewed.
1513
+ const selection = await selectExecutionPayload(admission, incremental, cwd, true);
1514
+ if (selection.narrowed) {
1118
1515
  log.info(DISPLAY_TAG, `Incremental review: ${incremental.linesChanged} line(s) since last pass`);
1119
- diff = incremental.deltaDiff;
1120
- changedFiles = incremental.changedFiles;
1121
1516
  }
1122
- else {
1123
- if (incremental.reason && incremental.reason !== 'No previous shield state') {
1124
- log.dim(DISPLAY_TAG, `Full review: ${incremental.reason}`);
1125
- }
1126
- // Get git diff — shared helper merges ignore patterns, tries staged/all
1127
- // then falls back to branch diff, and extracts changed file paths.
1128
- const diffResult = await getDiffForReview(options, config, cwd, DISPLAY_TAG);
1129
- if (!diffResult) {
1130
- // No changes = trivial pass — stamp content hash
1131
- await writeReviewedContentHash(cwd, config.totemDir, configRoot, config.review.sourceExtensions);
1132
- return;
1133
- }
1134
- diff = diffResult.diff;
1135
- changedFiles = diffResult.changedFiles;
1136
- diffScopeMeta = {
1137
- source: diffResult.source,
1138
- base: diffResult.base,
1139
- head: diffResult.head,
1140
- // Finding 10: the raw CLI selector form so `--diff main` and `--diff main..HEAD`
1141
- // (same resolved refs) do NOT share a lineage.
1142
- selectorForm: diffResult.selectorForm,
1143
- };
1517
+ else if (selection.deltaFallbackReason !== undefined) {
1518
+ log.dim(DISPLAY_TAG, `Incremental delta is non-reviewable after filtering (${selection.deltaFallbackReason}) using the full-scope payload.`);
1144
1519
  }
1145
- // Stage 0.5: Exclude generated-artifact BYTES from the synthesis input
1146
- // (mmnto-ai/totem#2398). Generated artifacts (lockfiles, compiled-rules.json,
1147
- // dist/**, *.wasm, regenerated dashboards) burn review-context tokens on bytes
1148
- // no reviewer should read. Classify them by default (seeded globs + honor
1149
- // `.gitattributes` `linguist-generated`), strip their diff sections, and inject
1150
- // a per-file SUMMARY instead of a silent drop — path, change shape, size delta,
1151
- // semantic hash — so the "this regenerated" signal survives without the bytes.
1152
- let generatedArtifactSummary;
1153
- {
1154
- const gitattr = readGitattributesGeneratedPatterns(cwd);
1155
- const generated = classifyGeneratedArtifacts({
1156
- diff,
1157
- changedFiles,
1158
- generatedGlobs: [...DEFAULT_GENERATED_ARTIFACT_GLOBS, ...gitattr.generated],
1159
- excludeGlobs: gitattr.notGenerated,
1160
- });
1161
- if (generated.summaries.length > 0) {
1162
- log.info(DISPLAY_TAG, `Excluded ${generated.summaries.length} generated-artifact file(s) from the review payload (summarized, not dropped):`);
1163
- for (const summary of generated.summaries) {
1164
- log.dim(DISPLAY_TAG, formatGeneratedArtifactLine(summary));
1165
- }
1166
- diff = generated.keptDiff;
1167
- changedFiles = generated.keptFiles;
1168
- generatedArtifactSummary = buildGeneratedArtifactSection(generated.summaries);
1169
- // All changed files were generated artifacts — nothing left to review.
1170
- // Not sending them to the LLM stays correct (their correctness is a gate
1171
- // concern, not the LLM's) but that does NOT extend to stamping the push
1172
- // gate on their behalf: this is the one skip path that can drop a TRACKED,
1173
- // HASHED source file, because `.gitattributes linguist-generated` can mark
1174
- // a `.ts` as generated. Stamping here would authorize code no reviewer saw
1175
- // (mmnto-ai/totem#2466).
1176
- if (!diff.trim()) {
1177
- log.warn(DISPLAY_TAG, `NON-REVIEW: every changed file is a generated artifact, so no lane ran. ${NO_STAMP_NOTICE}`);
1178
- return;
1179
- }
1180
- }
1181
- }
1182
- // Stage 1: Classify files — fast-path for non-code-only diffs
1183
- const classification = classifyChangedFiles(changedFiles);
1184
- if (classification.allNonCode) {
1185
- log.warn(DISPLAY_TAG, `NON-REVIEW: every changed file is non-code, so no lane ran. ${NO_STAMP_NOTICE}`);
1186
- log.dim(DISPLAY_TAG, `Skipped: ${changedFiles.join(', ')}`);
1187
- return;
1188
- }
1189
- // Stage 2: Filter diff to code-only files for mixed diffs
1190
- let filteredDiff = diff;
1191
- let filteredFiles = changedFiles;
1192
- if (!classification.allCode && classification.nonCodeFiles.length > 0) {
1193
- filteredDiff = await filterDiffByPatterns(diff, classification.nonCodeFiles);
1194
- filteredFiles = classification.codeFiles;
1195
- if (!filteredDiff.trim()) {
1196
- // After filtering non-code files, no code diff remains — nothing was examined.
1197
- log.warn(DISPLAY_TAG, `NON-REVIEW: no code changes remain after filtering non-code files, so no lane ran. ${NO_STAMP_NOTICE}`);
1198
- return;
1199
- }
1200
- log.dim(DISPLAY_TAG, `Filtered ${classification.nonCodeFiles.length} non-code file(s) from diff`);
1520
+ else if (incremental.reason && incremental.reason !== 'No previous shield state') {
1521
+ log.dim(DISPLAY_TAG, `Full review: ${incremental.reason}`);
1201
1522
  }
1523
+ const { diff, changedFiles, filteredDiff, filteredFiles, generatedArtifactSummary } = selection.payload;
1524
+ // Generated-artifact exclusion (#2398) and non-code classification/filtering
1525
+ // now run inside the admission evaluator's `prepareReviewPayload` (one
1526
+ // emission path, mmnto-ai/totem#2473) — the admitted payload above already
1527
+ // carries `filteredDiff` / `filteredFiles` / `generatedArtifactSummary`.
1202
1528
  // Extract annotations once (shared between hints and ledger)
1203
1529
  const annotations = extractShieldContextAnnotations(filteredFiles, cwd);
1204
1530
  // Auto-detect smart review hints from the filtered diff
@@ -1297,12 +1623,9 @@ export async function shieldCommand(options) {
1297
1623
  // a verdict artifact, and enforce the cache-eligibility exit contract. The
1298
1624
  // legacy single-lane path below is left byte-for-byte unchanged (invariant 7).
1299
1625
  if (fanActive) {
1300
- if (diffScopeMeta === undefined) {
1301
- // Unreachable: the fan bypasses the incremental fast-path, so the full
1302
- // getDiffForReview path always populated diffScopeMeta. Fail loud, never a
1303
- // silent scope guess (Tenet 4).
1304
- 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.');
1305
- }
1626
+ // `diffScopeMeta` is a const built from the admission's bound scope (the
1627
+ // `source: 'none'` fail-loud guard above is the live unreachability check)
1628
+ // the old `undefined` guard died with the incremental-path split.
1306
1629
  // Exemptions are read once here and passed in side-effect-free (the fan is
1307
1630
  // pure over them). --suppress mutation is not wired into the fan this slice;
1308
1631
  // committed shared exemptions still filter each lane.