mjolnir-qa 0.5.37 → 0.5.39

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -9,6 +9,18 @@ Rule behavior changes (new rules, FP-rate changes against the corpus,
9
9
  severity changes) are first-class entries here — rule IDs are immutable
10
10
  once shipped, so this file is the record of what changed between versions.
11
11
 
12
+ ## [0.5.39] — 2026-09-09
13
+
14
+ ### Changes since 0
15
+
16
+ - P9: bus-factor program — MAINTAINERS ladder, OWNER-RUNBOOK, ADJUDICATION-KIT, CODEOWNERS (#69)
17
+
18
+ ## [0.5.38] — 2026-09-09
19
+
20
+ ### Changes since 0
21
+
22
+ - P7: agent loop — mjolnir verify + MCP verify tool + install surfaces (#68)
23
+
12
24
  ## [0.5.37] — 2026-09-08
13
25
 
14
26
  ### Changes since 0
package/dist/cli.d.mts CHANGED
@@ -783,7 +783,7 @@ declare const runScan: typeof runScan$1, buildUniversalRules: typeof buildUniver
783
783
  * `scripts/sync-sarif-version.cjs` on release and guarded by
784
784
  * `tests/version-consistency.spec.ts` locally.
785
785
  */
786
- declare const CLI_VERSION = "0.5.37";
786
+ declare const CLI_VERSION = "0.5.39";
787
787
  /** A usage-error detail: the offending token, when one exists. */
788
788
  interface UsageErrorDetail {
789
789
  /** The unknown flag or rejected value (e.g. `--nope`, `loud`). */
@@ -898,6 +898,16 @@ declare function runBaselineCommand(argv: string[], io?: {
898
898
  err: Output;
899
899
  }): Promise<number>;
900
900
  /** Testable `diff` handler (Sprint 6 Task 24) — new/worsened debt only. */
901
+ /** Testable `verify` handler — the agent-loop verb (master plan P7,
902
+ * plan 1788853205786). Scans the target and diffs against the committed
903
+ * baseline, rendering the before/after digest the agent loop consumes
904
+ * (resolved per §15 · new · unchanged by ruleId+location · score delta).
905
+ * Exit semantics are the frozen contract: 0 clean · 1 new error
906
+ * findings · 2 partial scan or no baseline · 20 internal. */
907
+ declare function runVerifyCommand(argv: string[], io?: {
908
+ out: Output;
909
+ err: Output;
910
+ }): Promise<number>;
901
911
  declare function runDiffCommand(argv: string[], io?: {
902
912
  out: Output;
903
913
  err: Output;
@@ -943,4 +953,4 @@ declare function runHelpCommand(argv: string[], io?: {
943
953
  }): number;
944
954
  declare function isEntryPoint(): boolean;
945
955
  //#endregion
946
- export { CLI_VERSION, type CliArgs, EVIDENCE_OVERRIDES, KNOWN_RULE_IDS, OVERLAP_META_BY_RULE_ID, type Output, SUITE_INVALIDATING_RULE_IDS, type ScanHooks, UsageErrorDetail, buildUniversalRules, discoverRuntimeReport, err, exitForFindings, fallbackWorkspace, internalErrorMessage, isEntryPoint, isValidFindingRecord, levenshtein, main, nearestFlags, out, parseArgs, pathMatchesGlob, runBadgeCommand, runBaselineCommand, runCiInstall, runCreateRuleCommand, runDebtCommand, runDiffCommand, runDoctorCommand, runDoctorPlaywright, runExplainCommand, runFixCommand, runForensicsCommand, runHandoverCommand, runHelpCommand, runImpactCommand, runInitCommand, runMutationCommand, runPrCommentCommand, runPwReportCommand, runRulesCommand, runScan, runScanCommand, runStatsCommand, runSuppressions, runTriageCommand, usageErrorMessage };
956
+ export { CLI_VERSION, type CliArgs, EVIDENCE_OVERRIDES, KNOWN_RULE_IDS, OVERLAP_META_BY_RULE_ID, type Output, SUITE_INVALIDATING_RULE_IDS, type ScanHooks, UsageErrorDetail, buildUniversalRules, discoverRuntimeReport, err, exitForFindings, fallbackWorkspace, internalErrorMessage, isEntryPoint, isValidFindingRecord, levenshtein, main, nearestFlags, out, parseArgs, pathMatchesGlob, runBadgeCommand, runBaselineCommand, runCiInstall, runCreateRuleCommand, runDebtCommand, runDiffCommand, runDoctorCommand, runDoctorPlaywright, runExplainCommand, runFixCommand, runForensicsCommand, runHandoverCommand, runHelpCommand, runImpactCommand, runInitCommand, runMutationCommand, runPrCommentCommand, runPwReportCommand, runRulesCommand, runScan, runScanCommand, runStatsCommand, runSuppressions, runTriageCommand, runVerifyCommand, usageErrorMessage };
package/dist/cli.mjs CHANGED
@@ -13464,7 +13464,7 @@ function renderSarif(result, repoRootUri) {
13464
13464
  tool: { driver: {
13465
13465
  name: "Mjölnir",
13466
13466
  informationUri: "https://github.com/Sergey-Bar/Mjolnir",
13467
- version: "0.5.37",
13467
+ version: "0.5.39",
13468
13468
  rules: [...rules.values()].map((r) => {
13469
13469
  const meta = RULES.find((x) => x.id === r.id);
13470
13470
  return {
@@ -14697,6 +14697,7 @@ function agentBrief(surface, cwdNote) {
14697
14697
  "- Exit 0 = clean. Exit 1 = new findings on changed lines: fix them (smallest behavior-preserving change) or justify them to the user.",
14698
14698
  `- npx mjolnir-qa@${CLI_VERSION} why <file>:<line> explains any finding (evidence level, measured FP rate, fix).`,
14699
14699
  `- npx mjolnir-qa@${CLI_VERSION} handoff renders a full remediation plan from a saved --json report.`,
14700
+ `- Agent loop: establish the before-state once with npx mjolnir-qa@${CLI_VERSION} baseline (commits .mjolnir/baseline.json); after fixing, npx mjolnir-qa@${CLI_VERSION} verify prints the before/after digest — resolved (per §15 lifecycle) / new / unchanged by ruleId+location, and the score delta. Exit 0 clean, 1 new errors, 2 partial or no baseline.`,
14700
14701
  "- NEVER suppress a finding merely to obtain a green scan (suppressions live in mjolnir.config.json, require a reason, and expire after 90 days).",
14701
14702
  "- After fixing: re-run the scan, report files changed, report checks not run, report unresolved findings honestly.",
14702
14703
  "",
@@ -15525,6 +15526,113 @@ function renderBaselineDiff(diff) {
15525
15526
  return lines.join("\n");
15526
15527
  }
15527
15528
  //#endregion
15529
+ //#region src/commands/verify.ts
15530
+ /**
15531
+ * `mjolnir verify` — the agent-loop digest (product-gap-remediation
15532
+ * master plan P7, plan 1788853205786 — flag agent, decision 6).
15533
+ *
15534
+ * An agent fixes findings, then runs `mjolnir verify` against the
15535
+ * committed baseline (established once with `mjolnir baseline`). The
15536
+ * digest answers exactly the loop's questions, derived from the SAME
15537
+ * §15 comparison machinery as `mjolnir diff` (no second truth):
15538
+ *
15539
+ * - RESOLVED — baseline findings no longer present, each with its
15540
+ * lifecycle resolution (only VERIFIED-RESOLVED is a fix claim);
15541
+ * - NEW — findings the fix introduced;
15542
+ * - UNCHANGED — pre-existing debt, grouped by ruleId + location
15543
+ * (the agent-loop key: an agent works by location, not message);
15544
+ * - SCORE DELTA — before (baseline) → after (this scan).
15545
+ *
15546
+ * Exit semantics are the frozen contract: 0 clean · 1 new error
15547
+ * findings · 2 partial scan or no baseline (nothing to verify against
15548
+ * — the agent must first establish the before-state).
15549
+ */
15550
+ function buildVerifyDigest(result, baseline) {
15551
+ const diff = diffAgainstBaseline(result, baseline);
15552
+ const digest = {
15553
+ hasBaseline: diff.hasBaseline,
15554
+ ...diff.baselineCapturedAt !== void 0 ? { baselineCapturedAt: diff.baselineCapturedAt } : {},
15555
+ ...diff.baselineCommit !== void 0 ? { baselineCommit: diff.baselineCommit } : {},
15556
+ resolved: diff.resolvedFindings.map((f) => ({
15557
+ ruleId: f.ruleId,
15558
+ file: f.file,
15559
+ resolution: f.resolution.status
15560
+ })),
15561
+ new: diff.newFindings.map((f) => ({
15562
+ ruleId: f.ruleId,
15563
+ file: f.file,
15564
+ line: f.line,
15565
+ severity: f.severity
15566
+ })),
15567
+ unchanged: [],
15568
+ unchangedCount: diff.unchangedCount,
15569
+ scoreBefore: diff.baselineScore ?? null,
15570
+ scoreAfter: result.score ?? null,
15571
+ scoreDelta: diff.baselineScore !== void 0 && result.score !== null ? result.score - diff.baselineScore : null
15572
+ };
15573
+ if (diff.hasBaseline && baseline) {
15574
+ const byRule = /* @__PURE__ */ new Map();
15575
+ const headByFingerprint = /* @__PURE__ */ new Map();
15576
+ for (const f of result.findings) headByFingerprint.set(`${f.ruleId}\u0000${f.file}\u0000${f.message}`, f);
15577
+ for (const b of baseline.findings) {
15578
+ const head = headByFingerprint.get(`${b.ruleId}\u0000${b.file}\u0000${b.message}`);
15579
+ if (!head) continue;
15580
+ let group = byRule.get(b.ruleId);
15581
+ if (!group) {
15582
+ group = {
15583
+ ruleId: b.ruleId,
15584
+ locations: []
15585
+ };
15586
+ byRule.set(b.ruleId, group);
15587
+ }
15588
+ group.locations.push(`${head.file}:${head.line}`);
15589
+ }
15590
+ digest.unchanged = [...byRule.values()].map((g) => ({
15591
+ ruleId: g.ruleId,
15592
+ locations: g.locations.sort()
15593
+ }));
15594
+ }
15595
+ return digest;
15596
+ }
15597
+ function renderVerifyDigest(digest) {
15598
+ if (!digest.hasBaseline) return [
15599
+ "VERIFY — no baseline",
15600
+ "",
15601
+ "No committed baseline at .mjolnir/baseline.json — nothing to verify",
15602
+ "against. Establish the before-state once with `mjolnir baseline`,",
15603
+ "then re-run `mjolnir verify` after the fix."
15604
+ ].join("\n");
15605
+ const lines = [];
15606
+ lines.push("VERIFY — before/after digest");
15607
+ lines.push("");
15608
+ if (digest.scoreDelta !== null) {
15609
+ const dir = digest.scoreDelta > 0 ? "improved" : digest.scoreDelta < 0 ? "worse" : "unchanged";
15610
+ lines.push(`score: ${digest.scoreBefore} → ${digest.scoreAfter} (${dir}, Δ${digest.scoreDelta >= 0 ? "+" : ""}${digest.scoreDelta})`);
15611
+ }
15612
+ lines.push(`${digest.resolved.length} resolved · ${digest.new.length} new · ${digest.unchangedCount} unchanged`);
15613
+ lines.push("");
15614
+ if (digest.resolved.length > 0) {
15615
+ lines.push("RESOLVED (per §15 lifecycle — only VERIFIED-RESOLVED is a fix claim):");
15616
+ for (const r of digest.resolved) lines.push(` ${r.ruleId} ${r.file} — ${r.resolution}`);
15617
+ lines.push("");
15618
+ }
15619
+ if (digest.new.length > 0) {
15620
+ lines.push("NEW (introduced by the change under verification):");
15621
+ for (const n of digest.new) lines.push(` ${n.ruleId} ${n.file}:${n.line} (${n.severity})`);
15622
+ lines.push("");
15623
+ }
15624
+ if (digest.unchanged.length > 0) {
15625
+ lines.push("UNCHANGED (pre-existing debt — out of this fix's scope):");
15626
+ for (const g of digest.unchanged) {
15627
+ lines.push(` ${g.ruleId} × ${g.locations.length}`);
15628
+ for (const loc of g.locations.slice(0, 5)) lines.push(` ${loc}`);
15629
+ if (g.locations.length > 5) lines.push(` … and ${g.locations.length - 5} more`);
15630
+ }
15631
+ lines.push("");
15632
+ }
15633
+ return lines.join("\n").trimEnd();
15634
+ }
15635
+ //#endregion
15528
15636
  //#region src/mcp/server.ts
15529
15637
  /** Hard caps (§21 threat model: parameter size + resource bounds). */
15530
15638
  const MAX_PARAM_BYTES = 65536;
@@ -15586,6 +15694,18 @@ const MCP_TOOLS = [
15586
15694
  },
15587
15695
  required: ["path", "scanResult"]
15588
15696
  }
15697
+ },
15698
+ {
15699
+ name: "verify",
15700
+ description: "Agent-loop digest (1:1 with `mjolnir verify`): scan the target and diff against the committed baseline. Returns resolved (per §15 lifecycle), new, unchanged (grouped by ruleId + location), and the score delta. Read-only; the same transport guardrails as every other tool.",
15701
+ inputSchema: {
15702
+ type: "object",
15703
+ properties: { path: {
15704
+ type: "string",
15705
+ description: "Directory to scan and verify."
15706
+ } },
15707
+ required: ["path"]
15708
+ }
15589
15709
  }
15590
15710
  ];
15591
15711
  /** Strict param validation — unknown/missing/mistyped ⇒ INVALID_PARAMS. */
@@ -15602,6 +15722,9 @@ function validateParams(name, args) {
15602
15722
  if (typeof args["path"] !== "string" || args["path"].length === 0) return "diff requires a non-empty string `path`";
15603
15723
  if (typeof args["scanResult"] !== "object" || args["scanResult"] === null) return "diff requires the `scanResult` object from a previous scan tool call";
15604
15724
  }
15725
+ if (name === "verify") {
15726
+ if (typeof args["path"] !== "string" || args["path"].length === 0) return "verify requires a non-empty string `path`";
15727
+ }
15605
15728
  return null;
15606
15729
  }
15607
15730
  /**
@@ -15718,6 +15841,52 @@ async function handleToolCall(call) {
15718
15841
  }
15719
15842
  };
15720
15843
  }
15844
+ if (call.name === "verify") {
15845
+ const target = resolve(call.args["path"]);
15846
+ if (!existsSync(target)) return {
15847
+ jsonrpc: "2.0",
15848
+ id: call.id,
15849
+ error: {
15850
+ code: MCP_ERRORS.INVALID_PARAMS,
15851
+ message: `verify target does not exist: ${target}`
15852
+ }
15853
+ };
15854
+ const baselinePath = join(target, DEFAULT_BASELINE_PATH);
15855
+ if (!existsSync(baselinePath)) return {
15856
+ jsonrpc: "2.0",
15857
+ id: call.id,
15858
+ result: {
15859
+ hasBaseline: false,
15860
+ note: "no committed baseline at .mjolnir/baseline.json — establish the before-state with `mjolnir baseline` first"
15861
+ }
15862
+ };
15863
+ const result = await serializeScan(() => runScan({
15864
+ target,
15865
+ json: true,
15866
+ verbose: false,
15867
+ maxDurationMs: Number.POSITIVE_INFINITY,
15868
+ scopeChanged: false,
15869
+ format: "json"
15870
+ }));
15871
+ const digest = buildVerifyDigest(result, loadBaseline(baselinePath));
15872
+ return {
15873
+ jsonrpc: "2.0",
15874
+ id: call.id,
15875
+ result: {
15876
+ hasBaseline: digest.hasBaseline,
15877
+ baselineCapturedAt: digest.baselineCapturedAt,
15878
+ baselineCommit: digest.baselineCommit,
15879
+ resolved: digest.resolved,
15880
+ new: digest.new,
15881
+ unchanged: digest.unchanged,
15882
+ unchangedCount: digest.unchangedCount,
15883
+ scoreBefore: digest.scoreBefore,
15884
+ scoreAfter: digest.scoreAfter,
15885
+ scoreDelta: digest.scoreDelta,
15886
+ partial: result.partial
15887
+ }
15888
+ };
15889
+ }
15721
15890
  return {
15722
15891
  jsonrpc: "2.0",
15723
15892
  id: call.id,
@@ -16669,10 +16838,16 @@ const HELP_ENTRIES = [
16669
16838
  },
16670
16839
  {
16671
16840
  verb: "diff",
16672
- summary: "compare a fresh scan against the baseline — new/worsened only",
16673
- usage: "mjolnir diff [path]",
16841
+ summary: "lifecycle diff vs the committed baseline",
16842
+ usage: "mjolnir diff [path] [--json] [scan flags]",
16674
16843
  examples: ["mjolnir diff"]
16675
16844
  },
16845
+ {
16846
+ verb: "verify",
16847
+ summary: "agent-loop digest: resolved/new/unchanged vs baseline + score delta",
16848
+ usage: "mjolnir verify [path] [--json] [scan flags]",
16849
+ examples: ["mjolnir verify"]
16850
+ },
16676
16851
  {
16677
16852
  verb: "impact",
16678
16853
  summary: "what a commit introduced vs resolved, since a prior commit",
@@ -16759,7 +16934,7 @@ const HELP_ENTRIES = [
16759
16934
  },
16760
16935
  {
16761
16936
  verb: "mcp",
16762
- summary: "run as an MCP server over stdio (scan / explain / diff tools)",
16937
+ summary: "run as an MCP server over stdio (scan / explain / diff / verify tools)",
16763
16938
  usage: "mjolnir mcp",
16764
16939
  examples: ["mjolnir mcp"]
16765
16940
  }
@@ -16912,7 +17087,8 @@ const GROUPS = [
16912
17087
  "badge",
16913
17088
  "impact",
16914
17089
  "baseline",
16915
- "diff"
17090
+ "diff",
17091
+ "verify"
16916
17092
  ]
16917
17093
  },
16918
17094
  {
@@ -19246,7 +19422,7 @@ const { runScan, buildUniversalRules, fallbackWorkspace, pathMatchesGlob, isVali
19246
19422
  * `scripts/sync-sarif-version.cjs` on release and guarded by
19247
19423
  * `tests/version-consistency.spec.ts` locally.
19248
19424
  */
19249
- const CLI_VERSION = "0.5.37";
19425
+ const CLI_VERSION = "0.5.39";
19250
19426
  function parseArgs(argv, onError) {
19251
19427
  const args = {
19252
19428
  target: ".",
@@ -20004,6 +20180,36 @@ async function runBaselineCommand(argv, io = {
20004
20180
  }
20005
20181
  }
20006
20182
  /** Testable `diff` handler (Sprint 6 Task 24) — new/worsened debt only. */
20183
+ /** Testable `verify` handler — the agent-loop verb (master plan P7,
20184
+ * plan 1788853205786). Scans the target and diffs against the committed
20185
+ * baseline, rendering the before/after digest the agent loop consumes
20186
+ * (resolved per §15 · new · unchanged by ruleId+location · score delta).
20187
+ * Exit semantics are the frozen contract: 0 clean · 1 new error
20188
+ * findings · 2 partial scan or no baseline · 20 internal. */
20189
+ async function runVerifyCommand(argv, io = {
20190
+ out,
20191
+ err
20192
+ }) {
20193
+ const args = parseArgsOrUsage(argv, io);
20194
+ if (!args) return 10;
20195
+ try {
20196
+ const target = resolve(args.target);
20197
+ const invalid = validateScanTarget(target, io.err);
20198
+ if (invalid !== null) return invalid;
20199
+ const result = await runScan({
20200
+ ...args,
20201
+ target
20202
+ });
20203
+ const digest = buildVerifyDigest(result, loadBaseline(join(target, DEFAULT_BASELINE_PATH), (w) => io.err(w)));
20204
+ io.out(renderVerifyDigest(digest));
20205
+ if (result.partial) return 2;
20206
+ if (!digest.hasBaseline) return 2;
20207
+ return digest.new.some((f) => f.severity === "error") ? 1 : 0;
20208
+ } catch (err) {
20209
+ internalErrorMessage(err, io.err, args?.debug === true);
20210
+ return 20;
20211
+ }
20212
+ }
20007
20213
  async function runDiffCommand(argv, io = {
20008
20214
  out,
20009
20215
  err
@@ -20207,6 +20413,7 @@ async function main(argv = process$1.argv.slice(2), io = {
20207
20413
  if (argv[0] === "impact") return runImpactCommand(argv.slice(1));
20208
20414
  if (argv[0] === "baseline") return runBaselineCommand(argv.slice(1));
20209
20415
  if (argv[0] === "diff") return runDiffCommand(argv.slice(1));
20416
+ if (argv[0] === "verify") return runVerifyCommand(argv.slice(1), io);
20210
20417
  if (argv[0] === "pr-comment") return runPrCommentCommand(argv.slice(1));
20211
20418
  if (argv[0] === "summary") return runSummaryCommand(argv.slice(1), io);
20212
20419
  if (argv[0] === "stats") return runStatsCommand(argv.slice(1));
@@ -20278,4 +20485,4 @@ function isEntryPoint() {
20278
20485
  }
20279
20486
  if (isEntryPoint()) process$1.exitCode = await main();
20280
20487
  //#endregion
20281
- export { CLI_VERSION, EVIDENCE_OVERRIDES, KNOWN_RULE_IDS, OVERLAP_META_BY_RULE_ID, SUITE_INVALIDATING_RULE_IDS, buildUniversalRules, discoverRuntimeReport, err, exitForFindings, fallbackWorkspace, internalErrorMessage, isEntryPoint, isValidFindingRecord, levenshtein, main, nearestFlags, out, parseArgs, pathMatchesGlob, runBadgeCommand, runBaselineCommand, runCiInstall, runCreateRuleCommand, runDebtCommand, runDiffCommand, runDoctorCommand, runDoctorPlaywright, runExplainCommand, runFixCommand, runForensicsCommand, runHandoverCommand, runHelpCommand, runImpactCommand, runInitCommand, runMutationCommand, runPrCommentCommand, runPwReportCommand, runRulesCommand, runScan, runScanCommand, runStatsCommand, runSuppressions, runTriageCommand, usageErrorMessage };
20488
+ export { CLI_VERSION, EVIDENCE_OVERRIDES, KNOWN_RULE_IDS, OVERLAP_META_BY_RULE_ID, SUITE_INVALIDATING_RULE_IDS, buildUniversalRules, discoverRuntimeReport, err, exitForFindings, fallbackWorkspace, internalErrorMessage, isEntryPoint, isValidFindingRecord, levenshtein, main, nearestFlags, out, parseArgs, pathMatchesGlob, runBadgeCommand, runBaselineCommand, runCiInstall, runCreateRuleCommand, runDebtCommand, runDiffCommand, runDoctorCommand, runDoctorPlaywright, runExplainCommand, runFixCommand, runForensicsCommand, runHandoverCommand, runHelpCommand, runImpactCommand, runInitCommand, runMutationCommand, runPrCommentCommand, runPwReportCommand, runRulesCommand, runScan, runScanCommand, runStatsCommand, runSuppressions, runTriageCommand, runVerifyCommand, usageErrorMessage };
@@ -13463,7 +13463,7 @@ function renderSarif(result, repoRootUri) {
13463
13463
  tool: { driver: {
13464
13464
  name: "Mjölnir",
13465
13465
  informationUri: "https://github.com/Sergey-Bar/Mjolnir",
13466
- version: "0.5.37",
13466
+ version: "0.5.39",
13467
13467
  rules: [...rules.values()].map((r) => {
13468
13468
  const meta = RULES.find((x) => x.id === r.id);
13469
13469
  return {
@@ -14696,6 +14696,7 @@ function agentBrief(surface, cwdNote) {
14696
14696
  "- Exit 0 = clean. Exit 1 = new findings on changed lines: fix them (smallest behavior-preserving change) or justify them to the user.",
14697
14697
  `- npx mjolnir-qa@${CLI_VERSION} why <file>:<line> explains any finding (evidence level, measured FP rate, fix).`,
14698
14698
  `- npx mjolnir-qa@${CLI_VERSION} handoff renders a full remediation plan from a saved --json report.`,
14699
+ `- Agent loop: establish the before-state once with npx mjolnir-qa@${CLI_VERSION} baseline (commits .mjolnir/baseline.json); after fixing, npx mjolnir-qa@${CLI_VERSION} verify prints the before/after digest — resolved (per §15 lifecycle) / new / unchanged by ruleId+location, and the score delta. Exit 0 clean, 1 new errors, 2 partial or no baseline.`,
14699
14700
  "- NEVER suppress a finding merely to obtain a green scan (suppressions live in mjolnir.config.json, require a reason, and expire after 90 days).",
14700
14701
  "- After fixing: re-run the scan, report files changed, report checks not run, report unresolved findings honestly.",
14701
14702
  "",
@@ -15860,10 +15861,16 @@ const HELP_ENTRIES = [
15860
15861
  },
15861
15862
  {
15862
15863
  verb: "diff",
15863
- summary: "compare a fresh scan against the baseline — new/worsened only",
15864
- usage: "mjolnir diff [path]",
15864
+ summary: "lifecycle diff vs the committed baseline",
15865
+ usage: "mjolnir diff [path] [--json] [scan flags]",
15865
15866
  examples: ["mjolnir diff"]
15866
15867
  },
15868
+ {
15869
+ verb: "verify",
15870
+ summary: "agent-loop digest: resolved/new/unchanged vs baseline + score delta",
15871
+ usage: "mjolnir verify [path] [--json] [scan flags]",
15872
+ examples: ["mjolnir verify"]
15873
+ },
15867
15874
  {
15868
15875
  verb: "impact",
15869
15876
  summary: "what a commit introduced vs resolved, since a prior commit",
@@ -15950,7 +15957,7 @@ const HELP_ENTRIES = [
15950
15957
  },
15951
15958
  {
15952
15959
  verb: "mcp",
15953
- summary: "run as an MCP server over stdio (scan / explain / diff tools)",
15960
+ summary: "run as an MCP server over stdio (scan / explain / diff / verify tools)",
15954
15961
  usage: "mjolnir mcp",
15955
15962
  examples: ["mjolnir mcp"]
15956
15963
  }
@@ -16103,7 +16110,8 @@ const GROUPS = [
16103
16110
  "badge",
16104
16111
  "impact",
16105
16112
  "baseline",
16106
- "diff"
16113
+ "diff",
16114
+ "verify"
16107
16115
  ]
16108
16116
  },
16109
16117
  {
@@ -17089,6 +17097,113 @@ function renderBaselineDiff(diff) {
17089
17097
  return lines.join("\n");
17090
17098
  }
17091
17099
  //#endregion
17100
+ //#region src/commands/verify.ts
17101
+ /**
17102
+ * `mjolnir verify` — the agent-loop digest (product-gap-remediation
17103
+ * master plan P7, plan 1788853205786 — flag agent, decision 6).
17104
+ *
17105
+ * An agent fixes findings, then runs `mjolnir verify` against the
17106
+ * committed baseline (established once with `mjolnir baseline`). The
17107
+ * digest answers exactly the loop's questions, derived from the SAME
17108
+ * §15 comparison machinery as `mjolnir diff` (no second truth):
17109
+ *
17110
+ * - RESOLVED — baseline findings no longer present, each with its
17111
+ * lifecycle resolution (only VERIFIED-RESOLVED is a fix claim);
17112
+ * - NEW — findings the fix introduced;
17113
+ * - UNCHANGED — pre-existing debt, grouped by ruleId + location
17114
+ * (the agent-loop key: an agent works by location, not message);
17115
+ * - SCORE DELTA — before (baseline) → after (this scan).
17116
+ *
17117
+ * Exit semantics are the frozen contract: 0 clean · 1 new error
17118
+ * findings · 2 partial scan or no baseline (nothing to verify against
17119
+ * — the agent must first establish the before-state).
17120
+ */
17121
+ function buildVerifyDigest(result, baseline) {
17122
+ const diff = diffAgainstBaseline(result, baseline);
17123
+ const digest = {
17124
+ hasBaseline: diff.hasBaseline,
17125
+ ...diff.baselineCapturedAt !== void 0 ? { baselineCapturedAt: diff.baselineCapturedAt } : {},
17126
+ ...diff.baselineCommit !== void 0 ? { baselineCommit: diff.baselineCommit } : {},
17127
+ resolved: diff.resolvedFindings.map((f) => ({
17128
+ ruleId: f.ruleId,
17129
+ file: f.file,
17130
+ resolution: f.resolution.status
17131
+ })),
17132
+ new: diff.newFindings.map((f) => ({
17133
+ ruleId: f.ruleId,
17134
+ file: f.file,
17135
+ line: f.line,
17136
+ severity: f.severity
17137
+ })),
17138
+ unchanged: [],
17139
+ unchangedCount: diff.unchangedCount,
17140
+ scoreBefore: diff.baselineScore ?? null,
17141
+ scoreAfter: result.score ?? null,
17142
+ scoreDelta: diff.baselineScore !== void 0 && result.score !== null ? result.score - diff.baselineScore : null
17143
+ };
17144
+ if (diff.hasBaseline && baseline) {
17145
+ const byRule = /* @__PURE__ */ new Map();
17146
+ const headByFingerprint = /* @__PURE__ */ new Map();
17147
+ for (const f of result.findings) headByFingerprint.set(`${f.ruleId}\u0000${f.file}\u0000${f.message}`, f);
17148
+ for (const b of baseline.findings) {
17149
+ const head = headByFingerprint.get(`${b.ruleId}\u0000${b.file}\u0000${b.message}`);
17150
+ if (!head) continue;
17151
+ let group = byRule.get(b.ruleId);
17152
+ if (!group) {
17153
+ group = {
17154
+ ruleId: b.ruleId,
17155
+ locations: []
17156
+ };
17157
+ byRule.set(b.ruleId, group);
17158
+ }
17159
+ group.locations.push(`${head.file}:${head.line}`);
17160
+ }
17161
+ digest.unchanged = [...byRule.values()].map((g) => ({
17162
+ ruleId: g.ruleId,
17163
+ locations: g.locations.sort()
17164
+ }));
17165
+ }
17166
+ return digest;
17167
+ }
17168
+ function renderVerifyDigest(digest) {
17169
+ if (!digest.hasBaseline) return [
17170
+ "VERIFY — no baseline",
17171
+ "",
17172
+ "No committed baseline at .mjolnir/baseline.json — nothing to verify",
17173
+ "against. Establish the before-state once with `mjolnir baseline`,",
17174
+ "then re-run `mjolnir verify` after the fix."
17175
+ ].join("\n");
17176
+ const lines = [];
17177
+ lines.push("VERIFY — before/after digest");
17178
+ lines.push("");
17179
+ if (digest.scoreDelta !== null) {
17180
+ const dir = digest.scoreDelta > 0 ? "improved" : digest.scoreDelta < 0 ? "worse" : "unchanged";
17181
+ lines.push(`score: ${digest.scoreBefore} → ${digest.scoreAfter} (${dir}, Δ${digest.scoreDelta >= 0 ? "+" : ""}${digest.scoreDelta})`);
17182
+ }
17183
+ lines.push(`${digest.resolved.length} resolved · ${digest.new.length} new · ${digest.unchangedCount} unchanged`);
17184
+ lines.push("");
17185
+ if (digest.resolved.length > 0) {
17186
+ lines.push("RESOLVED (per §15 lifecycle — only VERIFIED-RESOLVED is a fix claim):");
17187
+ for (const r of digest.resolved) lines.push(` ${r.ruleId} ${r.file} — ${r.resolution}`);
17188
+ lines.push("");
17189
+ }
17190
+ if (digest.new.length > 0) {
17191
+ lines.push("NEW (introduced by the change under verification):");
17192
+ for (const n of digest.new) lines.push(` ${n.ruleId} ${n.file}:${n.line} (${n.severity})`);
17193
+ lines.push("");
17194
+ }
17195
+ if (digest.unchanged.length > 0) {
17196
+ lines.push("UNCHANGED (pre-existing debt — out of this fix's scope):");
17197
+ for (const g of digest.unchanged) {
17198
+ lines.push(` ${g.ruleId} × ${g.locations.length}`);
17199
+ for (const loc of g.locations.slice(0, 5)) lines.push(` ${loc}`);
17200
+ if (g.locations.length > 5) lines.push(` … and ${g.locations.length - 5} more`);
17201
+ }
17202
+ lines.push("");
17203
+ }
17204
+ return lines.join("\n").trimEnd();
17205
+ }
17206
+ //#endregion
17092
17207
  //#region src/commands/stats.ts
17093
17208
  /**
17094
17209
  * `mjolnir stats` — Sprint 6 Task 26 (Master-Stabilization-Plan.md).
@@ -18882,7 +18997,7 @@ const { runScan, buildUniversalRules, fallbackWorkspace, pathMatchesGlob, isVali
18882
18997
  * `scripts/sync-sarif-version.cjs` on release and guarded by
18883
18998
  * `tests/version-consistency.spec.ts` locally.
18884
18999
  */
18885
- const CLI_VERSION = "0.5.37";
19000
+ const CLI_VERSION = "0.5.39";
18886
19001
  function parseArgs(argv, onError) {
18887
19002
  const args = {
18888
19003
  target: ".",
@@ -19640,6 +19755,36 @@ async function runBaselineCommand(argv, io = {
19640
19755
  }
19641
19756
  }
19642
19757
  /** Testable `diff` handler (Sprint 6 Task 24) — new/worsened debt only. */
19758
+ /** Testable `verify` handler — the agent-loop verb (master plan P7,
19759
+ * plan 1788853205786). Scans the target and diffs against the committed
19760
+ * baseline, rendering the before/after digest the agent loop consumes
19761
+ * (resolved per §15 · new · unchanged by ruleId+location · score delta).
19762
+ * Exit semantics are the frozen contract: 0 clean · 1 new error
19763
+ * findings · 2 partial scan or no baseline · 20 internal. */
19764
+ async function runVerifyCommand(argv, io = {
19765
+ out,
19766
+ err
19767
+ }) {
19768
+ const args = parseArgsOrUsage(argv, io);
19769
+ if (!args) return 10;
19770
+ try {
19771
+ const target = resolve(args.target);
19772
+ const invalid = validateScanTarget(target, io.err);
19773
+ if (invalid !== null) return invalid;
19774
+ const result = await runScan({
19775
+ ...args,
19776
+ target
19777
+ });
19778
+ const digest = buildVerifyDigest(result, loadBaseline(join(target, DEFAULT_BASELINE_PATH), (w) => io.err(w)));
19779
+ io.out(renderVerifyDigest(digest));
19780
+ if (result.partial) return 2;
19781
+ if (!digest.hasBaseline) return 2;
19782
+ return digest.new.some((f) => f.severity === "error") ? 1 : 0;
19783
+ } catch (err) {
19784
+ internalErrorMessage(err, io.err, args?.debug === true);
19785
+ return 20;
19786
+ }
19787
+ }
19643
19788
  async function runDiffCommand(argv, io = {
19644
19789
  out,
19645
19790
  err
@@ -19843,6 +19988,7 @@ async function main(argv = process$1.argv.slice(2), io = {
19843
19988
  if (argv[0] === "impact") return runImpactCommand(argv.slice(1));
19844
19989
  if (argv[0] === "baseline") return runBaselineCommand(argv.slice(1));
19845
19990
  if (argv[0] === "diff") return runDiffCommand(argv.slice(1));
19991
+ if (argv[0] === "verify") return runVerifyCommand(argv.slice(1), io);
19846
19992
  if (argv[0] === "pr-comment") return runPrCommentCommand(argv.slice(1));
19847
19993
  if (argv[0] === "summary") return runSummaryCommand(argv.slice(1), io);
19848
19994
  if (argv[0] === "stats") return runStatsCommand(argv.slice(1));
@@ -19975,6 +20121,18 @@ const MCP_TOOLS = [
19975
20121
  },
19976
20122
  required: ["path", "scanResult"]
19977
20123
  }
20124
+ },
20125
+ {
20126
+ name: "verify",
20127
+ description: "Agent-loop digest (1:1 with `mjolnir verify`): scan the target and diff against the committed baseline. Returns resolved (per §15 lifecycle), new, unchanged (grouped by ruleId + location), and the score delta. Read-only; the same transport guardrails as every other tool.",
20128
+ inputSchema: {
20129
+ type: "object",
20130
+ properties: { path: {
20131
+ type: "string",
20132
+ description: "Directory to scan and verify."
20133
+ } },
20134
+ required: ["path"]
20135
+ }
19978
20136
  }
19979
20137
  ];
19980
20138
  /** Strict param validation — unknown/missing/mistyped ⇒ INVALID_PARAMS. */
@@ -19991,6 +20149,9 @@ function validateParams(name, args) {
19991
20149
  if (typeof args["path"] !== "string" || args["path"].length === 0) return "diff requires a non-empty string `path`";
19992
20150
  if (typeof args["scanResult"] !== "object" || args["scanResult"] === null) return "diff requires the `scanResult` object from a previous scan tool call";
19993
20151
  }
20152
+ if (name === "verify") {
20153
+ if (typeof args["path"] !== "string" || args["path"].length === 0) return "verify requires a non-empty string `path`";
20154
+ }
19994
20155
  return null;
19995
20156
  }
19996
20157
  /**
@@ -20107,6 +20268,52 @@ async function handleToolCall(call) {
20107
20268
  }
20108
20269
  };
20109
20270
  }
20271
+ if (call.name === "verify") {
20272
+ const target = resolve(call.args["path"]);
20273
+ if (!existsSync(target)) return {
20274
+ jsonrpc: "2.0",
20275
+ id: call.id,
20276
+ error: {
20277
+ code: MCP_ERRORS.INVALID_PARAMS,
20278
+ message: `verify target does not exist: ${target}`
20279
+ }
20280
+ };
20281
+ const baselinePath = join(target, DEFAULT_BASELINE_PATH);
20282
+ if (!existsSync(baselinePath)) return {
20283
+ jsonrpc: "2.0",
20284
+ id: call.id,
20285
+ result: {
20286
+ hasBaseline: false,
20287
+ note: "no committed baseline at .mjolnir/baseline.json — establish the before-state with `mjolnir baseline` first"
20288
+ }
20289
+ };
20290
+ const result = await serializeScan(() => runScan({
20291
+ target,
20292
+ json: true,
20293
+ verbose: false,
20294
+ maxDurationMs: Number.POSITIVE_INFINITY,
20295
+ scopeChanged: false,
20296
+ format: "json"
20297
+ }));
20298
+ const digest = buildVerifyDigest(result, loadBaseline(baselinePath));
20299
+ return {
20300
+ jsonrpc: "2.0",
20301
+ id: call.id,
20302
+ result: {
20303
+ hasBaseline: digest.hasBaseline,
20304
+ baselineCapturedAt: digest.baselineCapturedAt,
20305
+ baselineCommit: digest.baselineCommit,
20306
+ resolved: digest.resolved,
20307
+ new: digest.new,
20308
+ unchanged: digest.unchanged,
20309
+ unchangedCount: digest.unchangedCount,
20310
+ scoreBefore: digest.scoreBefore,
20311
+ scoreAfter: digest.scoreAfter,
20312
+ scoreDelta: digest.scoreDelta,
20313
+ partial: result.partial
20314
+ }
20315
+ };
20316
+ }
20110
20317
  return {
20111
20318
  jsonrpc: "2.0",
20112
20319
  id: call.id,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mjolnir-qa",
3
- "version": "0.5.37",
3
+ "version": "0.5.39",
4
4
  "description": "Mjölnir — the Verification Trust Engine for QA. Audits test suites and CI pipelines, reports a worthiness score and prioritized findings.",
5
5
  "type": "module",
6
6
  "engines": {