mjolnir-qa 0.5.3 → 0.5.4

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,71 @@ 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.4] — 2026-09-06
13
+
14
+ ### Agent Handoff + Minimized Reporting (plan 1788599400000)
15
+
16
+ ### Added
17
+
18
+ - **`mjolnir why <file>:<line>`** — occurrence-level evidence query
19
+ (informational, NOT a gate): exact file+line match, severity icon,
20
+ message/why/fix, evidence level, trust level, measured FP rate
21
+ (or the honest "ships on assumption"), runtime corroboration when
22
+ present, and the suppression contract (reason required, 90-day
23
+ expiry). Saved-report mode (`--json <mjolnir.json>`) is
24
+ authoritative; live scan runs otherwise. Exit 0 match / 1 no match.
25
+ - **`mjolnir handoff [mjolnir.json]`** — the deterministic fix-handoff
26
+ artifact: per-rule remediation sections (what is wrong / why
27
+ Mjölnir believes it / evidence boundary by level / occurrences
28
+ capped at 25 / fix / constraints / occurrences list), a per-rule
29
+ fenced copy block and a one-shot handoff prompt, and the formal
30
+ verification contract (TARGET_RESOLVED / TARGET_REMAINS /
31
+ NEW_FINDINGS_INTRODUCED / VERIFICATION_NOT_RUN, correlated by the
32
+ fingerprint ruleId+file+message; the standing caveat that a clean
33
+ `--scope changed` run verifies the changed surface only). Generated
34
+ solely from Mjölnir's own rule metadata — offline, deterministic,
35
+ escapeMarkdown'd. Zero findings → exit 0, non-actionable clean
36
+ artifact with no prompt. `--category`/`--rules` are presentation
37
+ filters.
38
+ - **`mjolnir install`** — installs the agent instruction surfaces
39
+ (`.claude/commands/mjolnir.md`, `.kilo/command/mjolnir.md`,
40
+ `.cursor/rules/mjolnir.mdc`, marker-appended `AGENTS.md`): the
41
+ version-pinned trust loop brief (scan `--scope changed` before
42
+ finishing, never suppress to green, report files changed and checks
43
+ not run). `--staged-hook` adds a NON-BLOCKING pre-commit hook
44
+ (`mjolnir --staged --blocking warning`, reusing `.husky`/
45
+ `core.hooksPath` when present). Marker-based idempotency;
46
+ `--dry-run` writes nothing; refusal (exit 10) before overwriting
47
+ any non-Mjölnir file; `--force` overwrites only Mjölnir-marked
48
+ files; never @latest.
49
+ - **`--score`** — prints only the numeric score (`unknown` when no
50
+ tests exist — never a fake 0); pure rendering flag, exit code
51
+ unchanged; stderr note when --json was also requested.
52
+ - **`--category <cat>`** (repeatable) — presentation filter on the
53
+ terminal findings display (and handoff/why): NEVER filters the
54
+ scan, the JSON/SARIF output, or the score; the terminal prints
55
+ `filtered view: N of M findings shown; score reflects the full
56
+ scan`. Unknown categories are a usage error (exit 10).
57
+ - **`--staged`** — scan-surface restriction: intersects discovered
58
+ test files with the git staged list; score reflects the staged
59
+ surface and is labeled as such (`staged surface: N file(s)`); not a
60
+ git repo → honest degraded fallback; empty staged set → exit 0.
61
+ - **`--blocking error|warning|none`** — exit-status override only:
62
+ maps onto the existing gate model (none→advisory, error→errors
63
+ block, warning→errors+warnings block). Detection and rendering are
64
+ identical under all three values; E0 findings never block; partial
65
+ scans stay exit 2.
66
+ - **`fixGroupId`** (additive JSON field): the stable semantic identity
67
+ of a remediation group — intentionally distinct from `ruleId`
68
+ (which identifies the detector). Current strategy: one rule = one
69
+ group, so fixGroupId equals ruleId today; consumers must not rely
70
+ on that permanently.
71
+
72
+ ### Changed
73
+
74
+ - help registry gained `why`, `handoff`, `install` and the new flags;
75
+ site/reference/cli.md documents the handoff trust model.
76
+
12
77
  ## [0.5.3] — 2026-09-05
13
78
 
14
79
  ### Terminal + CI UX Overhaul (plan 1788579907109)
package/dist/cli.d.mts CHANGED
@@ -128,6 +128,21 @@ interface Finding {
128
128
  /** How to fix — concrete action. */
129
129
  fix: string;
130
130
  docsUrl?: string;
131
+ /**
132
+ * Stable semantic identity of a remediation group: findings that can
133
+ * be reasoned about and potentially remediated as one root-cause unit
134
+ * (agent-handoff plan §5.1). This is intentionally a DIFFERENT
135
+ * concept from `ruleId` (which identifies the detector/rule).
136
+ *
137
+ * Current implementation strategy: fixGroupId = ruleId, because each
138
+ * rule currently represents one remediation group. Future rules may
139
+ * emit multiple findings belonging to one fix group, or multiple
140
+ * remediation groups. Consumers MUST NOT rely on
141
+ * fixGroupId === ruleId permanently.
142
+ *
143
+ * Additive within schemaVersion 1.
144
+ */
145
+ fixGroupId?: string;
131
146
  }
132
147
  interface DimensionScore {
133
148
  category: RuleCategory;
@@ -147,6 +162,16 @@ interface ScanResult {
147
162
  /** Present when --scope changed was requested. */
148
163
  scope?: "all" | "changed";
149
164
  scopeDegraded?: string;
165
+ /**
166
+ * Present when --staged was requested (agent-handoff plan §5.7):
167
+ * the scan surface was restricted to git staged files, and `files`
168
+ * is how many survived the intersection. The score reflects THAT
169
+ * surface — never present it as a full-repo score. Additive within
170
+ * schemaVersion 1.
171
+ */
172
+ staged?: {
173
+ files: number;
174
+ };
150
175
  /** Detected test frameworks (0.2). Empty + unknown=true when undetectable. */
151
176
  frameworks: string[];
152
177
  frameworkDetectionUnknown: boolean;
@@ -491,7 +516,7 @@ interface UniversalRule {
491
516
  * `scripts/sync-sarif-version.cjs` on release and guarded by
492
517
  * `tests/version-consistency.spec.ts` locally.
493
518
  */
494
- declare const CLI_VERSION = "0.5.3";
519
+ declare const CLI_VERSION = "0.5.4";
495
520
  declare function buildUniversalRules(root: string, strict?: boolean): Promise<{
496
521
  rules: UniversalRule[];
497
522
  pluginErrors: string[];
@@ -536,6 +561,33 @@ interface CliArgs {
536
561
  * and auto-disabled in CI/machine formats; this flag is the manual off.
537
562
  */
538
563
  noProgress?: boolean;
564
+ /**
565
+ * --category <cat> (repeatable): presentation filter — narrows the
566
+ * TERMINAL findings display (and handoff/why output) to the given
567
+ * rule categories. NEVER filters the scan, the JSON/SARIF output,
568
+ * or the score (agent-handoff plan §5.5).
569
+ */
570
+ categories?: RuleCategory[];
571
+ /**
572
+ * --score: print only the numeric score (or `unknown` when the repo
573
+ * has no tests) instead of the full report. Pure rendering flag:
574
+ * scan semantics and exit codes are unchanged (plan §5.6).
575
+ */
576
+ scoreOnly?: boolean;
577
+ /**
578
+ * --staged: scan-surface restriction (plan §5.7) — intersect the
579
+ * discovered test files with the git staged file list. Does NOT
580
+ * change rule semantics; score implications come only from the
581
+ * narrowed surface (labeled as such in the report).
582
+ */
583
+ staged?: boolean;
584
+ /**
585
+ * --blocking error|warning|none (plan §5.8): invocation-level gate
586
+ * override. Controls PROCESS EXIT BEHAVIOR ONLY via the existing
587
+ * exitForFindings mechanism — detection, rendering, JSON and score
588
+ * are identical under all three values. Overrides config.gate.
589
+ */
590
+ blocking?: "error" | "warning" | "none";
539
591
  }
540
592
  /** A usage-error detail: the offending token, when one exists. */
541
593
  interface UsageErrorDetail {
package/dist/cli.mjs CHANGED
@@ -25,6 +25,15 @@ const QA_IMPACT_LABELS = {
25
25
  "FALSE-GREEN": "False-green risk",
26
26
  HYGIENE: "Test hygiene debt"
27
27
  };
28
+ /** The closed set of rule categories (plan §5.5). `--category` values
29
+ * are validated against this list — unknown categories are a usage
30
+ * error, not a silent no-op. */
31
+ const RULE_CATEGORIES = [
32
+ "QA-TEST",
33
+ "QA-TQUAL",
34
+ "QA-PW",
35
+ "QA-CI"
36
+ ];
28
37
  /**
29
38
  * Honest default evidence level for a finding (Honesty Core Phase 1).
30
39
  * Derivation is deterministic and conservative:
@@ -9501,10 +9510,24 @@ function renderTerminal(result, opts) {
9501
9510
  score: result.score
9502
9511
  }, p, width, ascii);
9503
9512
  appendFrameworks(lines, result, ui);
9513
+ if (result.staged !== void 0) {
9514
+ lines.push(ui.p.dim(` staged surface: ${result.staged.files} file(s) scanned; score reflects that surface`));
9515
+ lines.push("");
9516
+ }
9504
9517
  appendDimensions(lines, result, ui);
9505
9518
  appendDeductions(lines, result, counts, ui);
9506
- appendFixThisFirst(lines, result, ui);
9507
- appendFindings(lines, result, counts, opts.verbose === true, ui, opts.tone);
9519
+ const filtered = opts.visibleFindings;
9520
+ const filtering = filtered !== void 0 && filtered.length < result.findings.length;
9521
+ const display = filtering ? {
9522
+ ...result,
9523
+ findings: filtered
9524
+ } : result;
9525
+ appendFixThisFirst(lines, display, ui);
9526
+ if (filtering) {
9527
+ lines.push(ui.p.dim(` filtered view: ${filtered?.length} of ${result.findings.length} findings shown; score reflects the full scan`));
9528
+ lines.push("");
9529
+ }
9530
+ appendFindings(lines, display, counts, opts.verbose === true, ui, opts.tone);
9508
9531
  if (counts.total === 0 && result.score === 100) appendForgedBlock(lines, p, ascii);
9509
9532
  appendFooter(lines, result, ui);
9510
9533
  return lines.join("\n");
@@ -9710,6 +9733,11 @@ function pushCard(lines, card, ui) {
9710
9733
  function appendFindings(lines, result, counts, verbose, ui, tone) {
9711
9734
  const { p } = ui;
9712
9735
  if (counts.total === 0) return;
9736
+ if (result.findings.length === 0) {
9737
+ lines.push(ui.p.dim(" filtered view: no findings in the selected category"));
9738
+ lines.push("");
9739
+ return;
9740
+ }
9713
9741
  const byRule = /* @__PURE__ */ new Map();
9714
9742
  for (const f of result.findings) {
9715
9743
  const list = byRule.get(f.ruleId) ?? [];
@@ -9887,7 +9915,7 @@ function renderSarif(result, repoRootUri) {
9887
9915
  tool: { driver: {
9888
9916
  name: "Mjölnir",
9889
9917
  informationUri: "https://github.com/Sergey-Bar/Mjolnir",
9890
- version: "0.5.3",
9918
+ version: "0.5.4",
9891
9919
  rules: [...rules.values()].map((r) => {
9892
9920
  const meta = RULES.find((x) => x.id === r.id);
9893
9921
  return {
@@ -10308,6 +10336,52 @@ function renderPrComment(result, options = {}) {
10308
10336
  return lines.join("\n");
10309
10337
  }
10310
10338
  //#endregion
10339
+ //#region src/commands/report-io.ts
10340
+ /**
10341
+ * Shared saved-report loading (agent-handoff plan §9.0).
10342
+ *
10343
+ * `summary`, `handoff` and `why` all consume a saved `--json` report.
10344
+ * One loader, one validation, one error shape — previously
10345
+ * validateReportJson was private to summary.ts; extracting it keeps
10346
+ * the three commands byte-identical in their loading behavior without
10347
+ * duplication. The exit-code mapping stays in the callers (they own
10348
+ * their io), but the error MESSAGES are identical because they come
10349
+ * from here.
10350
+ */
10351
+ /** Human message for any thrown value — never "undefined"/"[object Object]". */
10352
+ function errorText(err) {
10353
+ if (err instanceof Error) return err.message;
10354
+ if (typeof err === "string") return err;
10355
+ if (typeof err === "object" && err !== null) return JSON.stringify(err);
10356
+ return String(err);
10357
+ }
10358
+ /**
10359
+ * Parse-and-validate a saved report. Throws Error with a
10360
+ * human-explanatory message on: invalid JSON, non-object document,
10361
+ * wrong schemaVersion, missing findings array.
10362
+ */
10363
+ function validateReportJson(text) {
10364
+ let parsed;
10365
+ try {
10366
+ parsed = JSON.parse(text);
10367
+ } catch (err) {
10368
+ throw new Error(`not valid JSON (${errorText(err)})`, { cause: err });
10369
+ }
10370
+ if (typeof parsed !== "object" || parsed === null) throw new Error("the file is a JSON value but not an object");
10371
+ const doc = parsed;
10372
+ if (doc.schemaVersion !== 1) throw new Error(`unsupported schemaVersion ${JSON.stringify(doc.schemaVersion)} — expected 1`);
10373
+ if (!Array.isArray(doc.findings)) throw new Error("missing a \"findings\" array — is this a Mjölnir --json report?");
10374
+ return parsed;
10375
+ }
10376
+ /** Load + validate a saved report from disk. Throws on any problem. */
10377
+ function loadSavedReport(reportPath) {
10378
+ return validateReportJson(readFileSync(reportPath, "utf8"));
10379
+ }
10380
+ /** True when the report file exists (callers own the not-found message). */
10381
+ function reportExists(reportPath) {
10382
+ return existsSync(reportPath);
10383
+ }
10384
+ //#endregion
10311
10385
  //#region src/commands/summary.ts
10312
10386
  /**
10313
10387
  * `mjolnir summary [report.json]` — CI annotations + step summary
@@ -10332,29 +10406,7 @@ function renderPrComment(result, options = {}) {
10332
10406
  * `--path-prefix <dir>` re-scopes for subdirectory scans.
10333
10407
  */
10334
10408
  const DETAILS_PER_SEVERITY_CAP = 25;
10335
- /** Human message for any thrown value — never "undefined"/"[object Object]". */
10336
- function errorText(err) {
10337
- if (err instanceof Error) return err.message;
10338
- if (typeof err === "string") return err;
10339
- if (typeof err === "object" && err !== null) return JSON.stringify(err);
10340
- return String(err);
10341
- }
10342
- /** Parse-and-validate a saved report. Module-private: the command is
10343
- * the only consumer; tests exercise it through runSummaryCommand. */
10344
- function validateReportJson(text) {
10345
- let parsed;
10346
- try {
10347
- parsed = JSON.parse(text);
10348
- } catch (err) {
10349
- throw new Error(`not valid JSON (${errorText(err)})`, { cause: err });
10350
- }
10351
- if (typeof parsed !== "object" || parsed === null) throw new Error("the file is a JSON value but not an object");
10352
- const doc = parsed;
10353
- if (doc.schemaVersion !== 1) throw new Error(`unsupported schemaVersion ${JSON.stringify(doc.schemaVersion)} — expected 1`);
10354
- if (!Array.isArray(doc.findings)) throw new Error("missing a \"findings\" array — is this a Mjölnir --json report?");
10355
- return parsed;
10356
- }
10357
- function scoreBar(score, width = 20) {
10409
+ function scoreBar$1(score, width = 20) {
10358
10410
  const filled = Math.round(score / 100 * width);
10359
10411
  return `${"█".repeat(filled)}${"░".repeat(Math.max(0, width - filled))}`;
10360
10412
  }
@@ -10373,7 +10425,7 @@ function renderStepSummary(result, options = {}) {
10373
10425
  lines.push(`Score: **${result.score}/100** · ${verdict} (${state.band}) · ${headlineFor(state, result.findings.length)}`);
10374
10426
  lines.push("");
10375
10427
  lines.push("```text");
10376
- lines.push(`${scoreBar(result.score)} ${result.score}/100`);
10428
+ lines.push(`${scoreBar$1(result.score)} ${result.score}/100`);
10377
10429
  lines.push("```");
10378
10430
  }
10379
10431
  lines.push("");
@@ -10488,7 +10540,7 @@ function runSummaryCommand(argv, io = {
10488
10540
  }
10489
10541
  let result;
10490
10542
  try {
10491
- result = validateReportJson(readFileSync(reportPath, "utf8"));
10543
+ result = loadSavedReport(reportPath);
10492
10544
  } catch (err) {
10493
10545
  io.err(`mjolnir summary: cannot read ${reportPath}: ${errorText(err)}`);
10494
10546
  return 2;
@@ -10516,6 +10568,766 @@ function runSummaryCommand(argv, io = {
10516
10568
  return 0;
10517
10569
  }
10518
10570
  //#endregion
10571
+ //#region src/commands/why.ts
10572
+ /**
10573
+ * `mjolnir why <file>:<line>` — occurrence-level evidence/explanation
10574
+ * query (agent-handoff plan M2).
10575
+ *
10576
+ * Role: an INFORMATIONAL query, not a gate. It works regardless of
10577
+ * verdict or tier, shows evidence tags, measured FP rates, runtime
10578
+ * corroboration, why/fix, and suppression guidance. Matching is exact
10579
+ * file + exact line — the occurrence location as reported, not a
10580
+ * durable identity (the fingerprint contract, baseline.ts, governs
10581
+ * before/after correlation instead).
10582
+ *
10583
+ * Two modes:
10584
+ * - `--json <mjolnir.json>`: the saved report is AUTHORITATIVE — the
10585
+ * query runs against exactly what the saved scan found (deterministic,
10586
+ * offline).
10587
+ * - live (default): runs a fresh scan of the target.
10588
+ *
10589
+ * Exit codes: 0 match · 1 no finding at that location · 10 usage ·
10590
+ * 2 invalid saved report · 20 crash.
10591
+ */
10592
+ /** Exact file + exact line match over a finding list. Pure. */
10593
+ function explainAt(findings, file, line) {
10594
+ const normalized = file.replace(/\\/g, "/");
10595
+ return {
10596
+ findings: findings.filter((f) => f.file === normalized && f.line === line),
10597
+ file: normalized,
10598
+ line
10599
+ };
10600
+ }
10601
+ /** Parse the `<file>:<line>` positional (split at the LAST colon). */
10602
+ function parseFileLine(token) {
10603
+ const idx = token.lastIndexOf(":");
10604
+ if (idx === -1) return null;
10605
+ const file = token.slice(0, idx);
10606
+ const line = Number(token.slice(idx + 1));
10607
+ if (!file || !Number.isInteger(line) || line < 1) return null;
10608
+ return {
10609
+ file,
10610
+ line
10611
+ };
10612
+ }
10613
+ function evidenceLines(f, ui) {
10614
+ const lines = [];
10615
+ if (f.evidenceLevel !== void 0 || f.trustLevel !== void 0) {
10616
+ const parts = [];
10617
+ if (f.evidenceLevel !== void 0) parts.push(`evidence ${f.evidenceLevel}`);
10618
+ if (f.trustLevel !== void 0) parts.push(`trust ${f.trustLevel}`);
10619
+ lines.push(` Evidence: ${parts.join(" · ")}`);
10620
+ }
10621
+ if (f.measuredFpRate !== void 0) {
10622
+ const pct = Math.round(f.measuredFpRate * 100);
10623
+ const n = f.measuredFpN !== void 0 ? ` over ${f.measuredFpN} classified verdicts` : "";
10624
+ lines.push(` Measured FP rate: ${pct}%${n}`);
10625
+ } else lines.push(" Measured FP rate: none — this rule ships on assumption.");
10626
+ if (f.runtimeCorroboration !== void 0) {
10627
+ const c = f.runtimeCorroboration;
10628
+ const label = c.level === "defect" ? "defect corroborated by the run report" : c.level === "test" ? "the containing test executed in the run report" : "the containing file executed in the run report";
10629
+ lines.push(` Runtime corroboration: ${label} (${c.source})`);
10630
+ }
10631
+ return lines;
10632
+ }
10633
+ const SUPPRESSION_HINT = "Suppression (only with cause): an `ignore` entry in mjolnir.config.json — reason REQUIRED, expires after 90 days. Prefer fixing the root cause.";
10634
+ /** Render the why answer. Pure over (match, ui). */
10635
+ function renderWhy(match, ui = plainContext()) {
10636
+ const { p } = ui;
10637
+ if (match.findings.length === 0) return [
10638
+ sectionHeader(`WHY — ${match.file}:${match.line}`, ui),
10639
+ "",
10640
+ ` No finding at ${match.file}:${match.line} in this report.`,
10641
+ "",
10642
+ p.dim(" Locations are exact (file + line as reported). If the code"),
10643
+ p.dim(" moved since the scan, re-run mjolnir to refresh locations."),
10644
+ ""
10645
+ ].join("\n");
10646
+ const lines = [
10647
+ sectionHeader(`WHY — ${match.file}:${match.line}`, ui),
10648
+ "",
10649
+ `${match.findings.length} finding${match.findings.length === 1 ? "" : "s"} at this location:`,
10650
+ ""
10651
+ ];
10652
+ for (const f of match.findings) {
10653
+ lines.push(` ${severityIcon(f.severity, ui)} ${p.bold(escapeMarkdown(f.ruleId))} — ${escapeMarkdown(f.message)}`);
10654
+ lines.push(` Why it matters: ${escapeMarkdown(f.why)}`);
10655
+ lines.push(` Fix: ${escapeMarkdown(f.fix)}`);
10656
+ for (const line of evidenceLines(f, ui)) lines.push(line);
10657
+ lines.push(SUPPRESSION_HINT);
10658
+ lines.push("");
10659
+ }
10660
+ lines.push(nextStep("mjolnir explain <RULE-ID>", ui) + " — full rule context.");
10661
+ return lines.join("\n");
10662
+ }
10663
+ /**
10664
+ * Testable why command core. Returns the process exit code.
10665
+ * Live mode awaits the real scan; saved-report mode is synchronous.
10666
+ */
10667
+ async function runWhyCommand(argv, io = {
10668
+ out: (line) => console.log(line),
10669
+ err: (line) => console.error(line)
10670
+ }) {
10671
+ const locationToken = argv.find((a) => !a.startsWith("-"));
10672
+ if (!locationToken) {
10673
+ io.err("Usage: mjolnir why <file>:<line> [--json <mjolnir.json>]");
10674
+ return 10;
10675
+ }
10676
+ const location = parseFileLine(locationToken);
10677
+ if (!location) {
10678
+ io.err(`mjolnir why: cannot parse location "${locationToken}" — expected <file>:<line>`);
10679
+ return 10;
10680
+ }
10681
+ const jsonIdx = argv.indexOf("--json");
10682
+ const reportPath = jsonIdx !== -1 ? argv[jsonIdx + 1] : void 0;
10683
+ const targetIdx = argv.findIndex((a, i) => !a.startsWith("-") && i !== 0 && (jsonIdx === -1 || i !== jsonIdx + 1));
10684
+ const target = targetIdx !== -1 ? argv[targetIdx] : ".";
10685
+ let result;
10686
+ if (reportPath !== void 0) {
10687
+ if (!reportExists(reportPath)) {
10688
+ io.err(`mjolnir why: report file not found: ${reportPath}`);
10689
+ io.err(" Run the scan with --json first: mjolnir --json > mjolnir.json");
10690
+ return 10;
10691
+ }
10692
+ try {
10693
+ result = loadSavedReport(reportPath);
10694
+ } catch (err) {
10695
+ io.err(`mjolnir why: cannot read ${reportPath}: ${errorText(err)}`);
10696
+ return 2;
10697
+ }
10698
+ } else {
10699
+ if (!existsSync(target)) {
10700
+ io.err(`mjolnir why: scan target does not exist: ${target}`);
10701
+ return 10;
10702
+ }
10703
+ try {
10704
+ result = await runScan({
10705
+ target,
10706
+ json: false,
10707
+ verbose: false,
10708
+ maxDurationMs: Number.POSITIVE_INFINITY,
10709
+ scopeChanged: false,
10710
+ format: "terminal",
10711
+ strict: argv.includes("--strict")
10712
+ });
10713
+ } catch (err) {
10714
+ io.err(`mjolnir why: scan failed: ${errorText(err)}`);
10715
+ return 20;
10716
+ }
10717
+ }
10718
+ const match = explainAt(result.findings, location.file, location.line);
10719
+ const catIdxs = [];
10720
+ argv.forEach((a, i) => {
10721
+ if (a === "--category") catIdxs.push(i + 1);
10722
+ });
10723
+ const categories = catIdxs.map((i) => argv[i]).filter((c) => c !== void 0);
10724
+ const filtered = categories.length > 0 ? match.findings.filter((f) => categories.includes(f.category)) : match.findings;
10725
+ io.out(renderWhy({
10726
+ ...match,
10727
+ findings: filtered
10728
+ }));
10729
+ return filtered.length > 0 ? 0 : 1;
10730
+ }
10731
+ //#endregion
10732
+ //#region src/commands/handoff.ts
10733
+ const OCCURRENCE_CAP = 25;
10734
+ const EVIDENCE_BOUNDARY = {
10735
+ E2: "The detector's evidence is deterministic for this pattern. Check the location, then apply the prescribed fix — the finding is a deterministic defect at its boundary.",
10736
+ E1: "REQUIRES CONFIRMATION before editing. This is a heuristic finding — the detector's observation alone does not prove the defect. Establish the runtime/product context first, and treat the fix as a hypothesis to validate.",
10737
+ E0: "Observation only — informational by definition. This finding can never deduct points or gate CI. Decide whether it matters in context; do NOT 'fix' it blindly."
10738
+ };
10739
+ function evidenceBoundary(f) {
10740
+ const level = f.evidenceLevel ?? "E1";
10741
+ const base = EVIDENCE_BOUNDARY[level];
10742
+ if (f.runtimeCorroboration !== void 0) return `${base}\n${f.runtimeCorroboration.level === "defect" ? "Runtime corroboration: the run report directly corroborates this defect." : f.runtimeCorroboration.level === "test" ? "Runtime corroboration: the containing test executed in the run report." : "Runtime corroboration: the containing file executed in the run report."}`;
10743
+ return base;
10744
+ }
10745
+ function fpLine(f) {
10746
+ if (f.measuredFpRate === void 0) return "Measured FP rate: none — this rule ships on assumption (no measured false-positive rate).";
10747
+ return `Measured FP rate: ${Math.round(f.measuredFpRate * 100)}%${f.measuredFpN !== void 0 ? ` over ${f.measuredFpN} classified verdicts` : ""}.`;
10748
+ }
10749
+ function scoreBar(score, width = 20) {
10750
+ const filled = Math.round(score / 100 * width);
10751
+ return `${"█".repeat(filled)}${"░".repeat(Math.max(0, width - filled))}`;
10752
+ }
10753
+ function scopeNote(options) {
10754
+ const parts = [];
10755
+ if (options.categories && options.categories.length > 0) parts.push(`categories: ${options.categories.join(", ")}`);
10756
+ if (options.rules && options.rules.length > 0) parts.push(`rules: ${options.rules.join(", ")}`);
10757
+ return parts.length > 0 ? ` (filtered — ${parts.join("; ")})` : "";
10758
+ }
10759
+ /**
10760
+ * The verification procedure block — the Trust contract rendered into
10761
+ * every artifact (plan §5.3). `version` is pinned at render time.
10762
+ */
10763
+ function verificationBlock(version) {
10764
+ return [
10765
+ "## Verification procedure",
10766
+ "",
10767
+ "1. Before editing (recommended): `mjolnir baseline` captures the pre-fix report.",
10768
+ "2. After the fixes: `npx mjolnir-qa@" + version + " . --scope changed` re-verifies the targeted surface.",
10769
+ "3. Correlate before/after by finding fingerprint (ruleId + file + message — line numbers are occurrence locations, not identity; edits may move lines).",
10770
+ "",
10771
+ "Outcomes:",
10772
+ "",
10773
+ "- **TARGET_RESOLVED** — every fingerprint in this document is absent from the post-fix scan.",
10774
+ "- **TARGET_REMAINS** — at least one target fingerprint is still present. Report it honestly; do not suppress to clear the report.",
10775
+ "- **NEW_FINDINGS_INTRODUCED** — the post-fix scan contains fingerprints absent from the pre-fix report. Report them; do not silently accept them.",
10776
+ "- **VERIFICATION_NOT_RUN** — no post-fix scan, or a partial scan. Never claim a fix is verified without it.",
10777
+ "",
10778
+ "> ⚠ A clean `--scope changed` run re-verifies the targeted changed-scope remediation. It is NOT a statement that the entire repository is clean — run a full scan for that.",
10779
+ "",
10780
+ "Report files changed. Report checks not run. Report unresolved findings honestly."
10781
+ ];
10782
+ }
10783
+ /** The per-rule remediation copy block (fenced, self-contained). */
10784
+ function ruleCopyBlock(group, version) {
10785
+ const f = group.findings[0];
10786
+ const occurrences = group.findings.slice(0, OCCURRENCE_CAP).map((x) => `- \`${escapeMarkdown(x.file)}:${x.line}\``).join("\n");
10787
+ const overflow = group.findings.length > OCCURRENCE_CAP ? `\n- … and ${group.findings.length - OCCURRENCE_CAP} more — see the JSON report.` : "";
10788
+ return [
10789
+ "```text",
10790
+ `Remediation task: ${escapeMarkdown(group.ruleId)} (fix group: ${escapeMarkdown(group.fixGroupId ?? group.ruleId)})`,
10791
+ "",
10792
+ `What was detected: ${escapeMarkdown(f.message)}`,
10793
+ "",
10794
+ `Evidence boundary: ${evidenceBoundary(f).replace(/\n/g, " ")}`,
10795
+ escapeMarkdown(fpLine(f)),
10796
+ "",
10797
+ `What should change: ${escapeMarkdown(f.fix)}`,
10798
+ "",
10799
+ "Constraints:",
10800
+ "- Make the smallest behavior-preserving change that fixes the root cause.",
10801
+ "- Preserve public interfaces, failure semantics, and repository conventions.",
10802
+ "- Adapt identifiers and framework details instead of copying blindly.",
10803
+ "- Do NOT disable the rule or suppress matching code merely to obtain a green scan.",
10804
+ "",
10805
+ "Occurrences (validate each):",
10806
+ occurrences,
10807
+ overflow,
10808
+ "",
10809
+ "Verification:",
10810
+ ` npx mjolnir-qa@${version} . --scope changed`,
10811
+ "",
10812
+ "Expected verification behavior: the fingerprint (ruleId + file + message) of every occurrence above disappears from the post-fix scan, and no NEW fingerprint appears.",
10813
+ "",
10814
+ "Report files changed. Report checks not run. Report unresolved findings honestly.",
10815
+ "```"
10816
+ ].join("\n");
10817
+ }
10818
+ /** The full deterministic handoff artifact. Pure over (result, options). */
10819
+ function renderHandoff(result, options = {}, version = CLI_VERSION) {
10820
+ const lines = [];
10821
+ lines.push("### 🔨 Mjölnir — Fix Handoff");
10822
+ lines.push("");
10823
+ if (result.score !== null) {
10824
+ const state = deriveScoreState(result.score);
10825
+ const verdict = verdictFor(result.score);
10826
+ lines.push(`Score: **${result.score}/100** · ${verdict} (${state.band}) · ${headlineFor(state, result.findings.length)}`);
10827
+ lines.push("");
10828
+ lines.push("```text");
10829
+ lines.push(`${scoreBar(result.score)} ${result.score}/100`);
10830
+ lines.push("```");
10831
+ } else lines.push("Score: **not measurable** — no test files found (`reason: no-tests-found`).");
10832
+ lines.push("");
10833
+ if (result.partial) {
10834
+ lines.push("> ⚠ Partial scan: the budget expired or files were skipped — the finding list may be incomplete. Treat VERIFICATION claims accordingly.");
10835
+ lines.push("");
10836
+ }
10837
+ if (result.findings.length === 0) {
10838
+ lines.push("Zero findings — nothing to fix.");
10839
+ lines.push("");
10840
+ lines.push("No remediation prompt is included. Do not modify the repository on the basis of this document.");
10841
+ lines.push("");
10842
+ if (result.partial) {
10843
+ lines.push("> The scan was partial — the absence of findings is only as trustworthy as the scan's coverage.");
10844
+ lines.push("");
10845
+ }
10846
+ return lines.join("\n");
10847
+ }
10848
+ lines.push(`This document turns ${result.findings.length} finding${result.findings.length === 1 ? "" : "s"} into a remediation plan. Work top-down (errors before warnings); validate each occurrence according to its evidence level before editing.`);
10849
+ lines.push("");
10850
+ const groups = /* @__PURE__ */ new Map();
10851
+ for (const f of result.findings) {
10852
+ const gid = f.fixGroupId ?? f.ruleId;
10853
+ const g = groups.get(gid);
10854
+ if (g) g.findings.push(f);
10855
+ else groups.set(gid, {
10856
+ ruleId: f.ruleId,
10857
+ fixGroupId: gid,
10858
+ findings: [f]
10859
+ });
10860
+ }
10861
+ const ruleFilter = options.rules && options.rules.length > 0 ? new Set(options.rules) : void 0;
10862
+ const catFilter = options.categories && options.categories.length > 0 ? new Set(options.categories) : void 0;
10863
+ const selected = [...groups.values()].filter((g) => !ruleFilter || ruleFilter.has(g.ruleId)).map((g) => ({
10864
+ ...g,
10865
+ findings: catFilter ? g.findings.filter((f) => catFilter.has(f.category)) : g.findings
10866
+ })).filter((g) => g.findings.length > 0);
10867
+ if (selected.length === 0) {
10868
+ lines.push("No findings match the requested filters — no remediation prompt is included.");
10869
+ lines.push("");
10870
+ return lines.join("\n");
10871
+ }
10872
+ const sevOrder = {
10873
+ error: 0,
10874
+ warning: 1,
10875
+ info: 2
10876
+ };
10877
+ const sortable = selected;
10878
+ sortable.sort((a, b) => {
10879
+ const sa = sevOrder[a.findings[0].severity];
10880
+ const sb = sevOrder[b.findings[0].severity];
10881
+ if (sa !== sb) return sa - sb;
10882
+ if (b.findings.length !== a.findings.length) return b.findings.length - a.findings.length;
10883
+ return a.ruleId < b.ruleId ? -1 : 1;
10884
+ });
10885
+ lines.push("## How to use this document");
10886
+ lines.push("");
10887
+ lines.push("- Validate each occurrence according to its evidence level before editing.", "- Apply the smallest behavior-preserving fix.", "- Re-run the verification procedure below; correlate by fingerprint.", "- Never suppress a finding merely to obtain a green scan (suppressions live in `mjolnir.config.json`, require a reason, and expire after 90 days).");
10888
+ lines.push("");
10889
+ lines.push(scopeNote(options) ? scopeNote(options).trim() : "");
10890
+ if (!scopeNote(options)) lines.pop();
10891
+ lines.push("");
10892
+ for (const g of sortable) {
10893
+ const first = g.findings[0];
10894
+ lines.push(`### ${escapeMarkdown(g.ruleId)} — ${first.severity} × ${g.findings.length} (fix group: ${escapeMarkdown(g.fixGroupId)})`);
10895
+ lines.push("");
10896
+ lines.push(`**What is wrong:** ${escapeMarkdown(first.message)}`);
10897
+ lines.push("");
10898
+ lines.push(`**Why Mjölnir believes it:** ${escapeMarkdown(first.why)}`);
10899
+ lines.push("");
10900
+ lines.push(`**How trustworthy (evidence boundary):**`);
10901
+ lines.push("");
10902
+ lines.push(evidenceBoundary(first));
10903
+ lines.push("");
10904
+ lines.push(escapeMarkdown(fpLine(first)));
10905
+ lines.push("");
10906
+ lines.push(`**What should change:** ${escapeMarkdown(first.fix)}`);
10907
+ lines.push("");
10908
+ lines.push("**What must NOT change:** behavior unrelated to this finding — public interfaces, failure semantics, accessibility, and repository conventions stay intact.");
10909
+ lines.push("");
10910
+ lines.push(`**Occurrences (${g.findings.length}):**`);
10911
+ lines.push("");
10912
+ for (const f of g.findings.slice(0, OCCURRENCE_CAP)) lines.push(`- \`${escapeMarkdown(f.file)}:${f.line}\` — ${escapeMarkdown(f.message)}`);
10913
+ if (g.findings.length > OCCURRENCE_CAP) lines.push(`- … and ${g.findings.length - OCCURRENCE_CAP} more — see the JSON report.`);
10914
+ lines.push("");
10915
+ lines.push(ruleCopyBlock(g, version));
10916
+ lines.push("");
10917
+ }
10918
+ lines.push("## One-shot handoff prompt");
10919
+ lines.push("");
10920
+ lines.push("```text");
10921
+ lines.push(`Work through the ${selected.length} remediation group(s) above IN ORDER. For each:`);
10922
+ lines.push("- Validate the occurrences according to that group's evidence boundary (E2 = deterministic, act after a location check; E1/E0 = confirm in context first, never assume the observation alone proves the defect).");
10923
+ lines.push("- Apply the smallest behavior-preserving fix from the group's instruction.");
10924
+ lines.push("- Do NOT suppress findings merely to make the scan green.");
10925
+ lines.push(`- Re-run: npx mjolnir-qa@${version} . --scope changed`);
10926
+ lines.push("- Correlate before/after by fingerprint (ruleId + file + message).");
10927
+ lines.push("- Report: files changed, checks not run, and any findings that remain (TARGET_REMAINS) or newly appeared (NEW_FINDINGS_INTRODUCED).");
10928
+ lines.push("- Stop and ask the user when an evidence boundary cannot be resolved.");
10929
+ lines.push("```");
10930
+ lines.push("");
10931
+ lines.push(...verificationBlock(version));
10932
+ lines.push("");
10933
+ lines.push(`_Generated by [Mjölnir](https://github.com/Sergey-Bar/Mjolnir) — evidence and verification; the agent remains responsible for every change._`);
10934
+ return lines.join("\n");
10935
+ }
10936
+ const KNOWN_FLAGS = /* @__PURE__ */ new Set([
10937
+ "--category",
10938
+ "--rules",
10939
+ "--help",
10940
+ "-h"
10941
+ ]);
10942
+ /**
10943
+ * Testable handoff command core. Returns the process exit code.
10944
+ * `mjolnir handoff [mjolnir.json] [--category <cat>]... [--rules <ids>]`
10945
+ */
10946
+ function runHandoffCommand(argv, io = {
10947
+ out: (line) => console.log(line),
10948
+ err: (line) => console.error(line)
10949
+ }) {
10950
+ for (let i = 0; i < argv.length; i++) {
10951
+ const a = argv[i];
10952
+ if (!a.startsWith("-")) continue;
10953
+ if (a === "--category" || a === "--rules") {
10954
+ const val = argv[i + 1];
10955
+ if (val === void 0 || val.startsWith("-")) {
10956
+ io.err(usageErrorMessage({
10957
+ flag: a,
10958
+ token: val
10959
+ }));
10960
+ return 10;
10961
+ }
10962
+ i++;
10963
+ continue;
10964
+ }
10965
+ if (KNOWN_FLAGS.has(a)) continue;
10966
+ io.err(usageErrorMessage({ token: a }));
10967
+ return 10;
10968
+ }
10969
+ const positional = [];
10970
+ const categories = [];
10971
+ let rules;
10972
+ for (let i = 0; i < argv.length; i++) {
10973
+ const a = argv[i];
10974
+ if (a === "--category") {
10975
+ categories.push(argv[i + 1]);
10976
+ i++;
10977
+ continue;
10978
+ }
10979
+ if (a === "--rules") {
10980
+ rules = argv[i + 1].split(",").map((r) => r.trim()).filter((r) => r.length > 0);
10981
+ i++;
10982
+ continue;
10983
+ }
10984
+ if (!a.startsWith("-")) positional.push(a);
10985
+ }
10986
+ const reportPath = positional[0] ?? "mjolnir.json";
10987
+ if (!reportExists(reportPath)) {
10988
+ io.err(`mjolnir handoff: report file not found: ${reportPath}`);
10989
+ io.err(" Run the scan with --json first: mjolnir --json > mjolnir.json");
10990
+ return 10;
10991
+ }
10992
+ let result;
10993
+ try {
10994
+ result = loadSavedReport(reportPath);
10995
+ } catch (err) {
10996
+ io.err(`mjolnir handoff: cannot read ${reportPath}: ${errorText(err)}`);
10997
+ return 2;
10998
+ }
10999
+ io.out(renderHandoff(result, {
11000
+ categories,
11001
+ ...rules ? { rules } : {}
11002
+ }));
11003
+ return 0;
11004
+ }
11005
+ //#endregion
11006
+ //#region src/commands/install-agents.ts
11007
+ /**
11008
+ * `mjolnir install` — instruction-surface installer (agent-handoff
11009
+ * plan M4).
11010
+ *
11011
+ * Directory probes detect INSTRUCTION SURFACES, not agents: the probe
11012
+ * is a deterministic, offline existence check. All detected surfaces
11013
+ * are written; none take precedence over another.
11014
+ *
11015
+ * Safety contract (plan §17):
11016
+ * - Writes are planned by a pure `planInstall()` and executed by
11017
+ * `executeInstall()`; `--dry-run` never executes.
11018
+ * - A target file that exists WITHOUT a Mjölnir marker is user-owned →
11019
+ * refusal (exit 10). `--force` overwrites ONLY marker-identified
11020
+ * Mjölnir-owned files.
11021
+ * - Re-running rewrites Mjölnir-owned regions idempotently; identical
11022
+ * content → no write at all.
11023
+ * - Content is deterministic (no timestamps) and version-pinned to the
11024
+ * running CLI_VERSION — never @latest.
11025
+ *
11026
+ * Exit codes: 0 installed/updated/nothing-detected (honest message) ·
11027
+ * 10 refusal/usage · 20 crash.
11028
+ */
11029
+ const MARKER_OPEN = "<!-- mjolnir:managed";
11030
+ const MARKER_CLOSE = "<!-- /mjolnir:managed -->";
11031
+ function agentBrief(surface, cwdNote) {
11032
+ return [
11033
+ `# Mjölnir — verification trust loop (${surface})`,
11034
+ "",
11035
+ `${MARKER_OPEN} v${CLI_VERSION} -->`,
11036
+ "",
11037
+ `Run \`${cwdNote}npx mjolnir-qa@${CLI_VERSION} . --scope changed\` before finishing any task that touched test code.`,
11038
+ "",
11039
+ "- Exit 0 = clean. Exit 1 = new findings on changed lines: fix them (smallest behavior-preserving change) or justify them to the user.",
11040
+ `- npx mjolnir-qa@${CLI_VERSION} why <file>:<line> explains any finding (evidence level, measured FP rate, fix).`,
11041
+ `- npx mjolnir-qa@${CLI_VERSION} handoff renders a full remediation plan from a saved --json report.`,
11042
+ "- NEVER suppress a finding merely to obtain a green scan (suppressions live in mjolnir.config.json, require a reason, and expire after 90 days).",
11043
+ "- After fixing: re-run the scan, report files changed, report checks not run, report unresolved findings honestly.",
11044
+ "",
11045
+ `<!-- /mjolnir:managed -->`,
11046
+ ""
11047
+ ].join("\n");
11048
+ }
11049
+ /** Enumerate every detected surface with its planned content. Pure. */
11050
+ function detectSurfaces(cwd) {
11051
+ const surfaces = [];
11052
+ if (existsSync(join(cwd, ".claude"))) surfaces.push({
11053
+ name: "Claude Code command surface",
11054
+ dir: join(cwd, ".claude", "commands"),
11055
+ file: join(cwd, ".claude", "commands", "mjolnir.md"),
11056
+ content: agentBrief("Claude Code command", "/"),
11057
+ mode: "whole-file"
11058
+ });
11059
+ if (existsSync(join(cwd, ".kilo"))) surfaces.push({
11060
+ name: "Kilo command surface",
11061
+ dir: join(cwd, ".kilo", "command"),
11062
+ file: join(cwd, ".kilo", "command", "mjolnir.md"),
11063
+ content: agentBrief("Kilo command", ""),
11064
+ mode: "whole-file"
11065
+ });
11066
+ if (existsSync(join(cwd, ".cursor"))) surfaces.push({
11067
+ name: "Cursor rule surface",
11068
+ dir: join(cwd, ".cursor", "rules"),
11069
+ file: join(cwd, ".cursor", "rules", "mjolnir.mdc"),
11070
+ content: agentBrief("Cursor rule", ""),
11071
+ mode: "whole-file"
11072
+ });
11073
+ if (existsSync(join(cwd, "AGENTS.md"))) surfaces.push({
11074
+ name: "AGENTS.md instruction surface",
11075
+ dir: cwd,
11076
+ file: join(cwd, "AGENTS.md"),
11077
+ content: agentBrief("AGENTS.md", ""),
11078
+ mode: "append-block"
11079
+ });
11080
+ return surfaces;
11081
+ }
11082
+ function hasMjolnirMarker(content) {
11083
+ return content.includes("<!-- mjolnir:managed") && content.includes("<!-- /mjolnir:managed -->");
11084
+ }
11085
+ function mergedBlock(existing, content) {
11086
+ const openIdx = existing.indexOf(MARKER_OPEN);
11087
+ const closeIdx = existing.indexOf(MARKER_CLOSE);
11088
+ if (openIdx !== -1 && closeIdx !== -1 && closeIdx > openIdx) {
11089
+ const body = content.slice(content.indexOf(MARKER_OPEN), content.indexOf(MARKER_CLOSE) + 25);
11090
+ return existing.slice(0, openIdx) + body + existing.slice(closeIdx + 25);
11091
+ }
11092
+ return `${existing}${existing.endsWith("\n") ? "" : "\n"}\n${content}`;
11093
+ }
11094
+ /** Pure plan: what install WOULD do. Zero I/O. */
11095
+ function planInstall(cwd, options = {}) {
11096
+ const surfaces = detectSurfaces(cwd);
11097
+ const entries = [];
11098
+ for (const s of surfaces) {
11099
+ if (s.mode === "append-block") {
11100
+ const existing = readFileSync(s.file, "utf8");
11101
+ if (hasMjolnirMarker(existing)) {
11102
+ const merged = mergedBlock(existing, s.content);
11103
+ entries.push(merged === existing ? {
11104
+ action: "no-op",
11105
+ surface: s.name,
11106
+ file: s.file,
11107
+ content: existing
11108
+ } : {
11109
+ action: "update-in-place",
11110
+ surface: s.name,
11111
+ file: s.file,
11112
+ content: merged
11113
+ });
11114
+ continue;
11115
+ }
11116
+ entries.push({
11117
+ action: "create",
11118
+ surface: s.name,
11119
+ file: s.file,
11120
+ content: mergedBlock(existing, s.content)
11121
+ });
11122
+ continue;
11123
+ }
11124
+ if (existsSync(s.file)) {
11125
+ const existing = readFileSync(s.file, "utf8");
11126
+ if (existing === s.content) {
11127
+ entries.push({
11128
+ action: "no-op",
11129
+ surface: s.name,
11130
+ file: s.file,
11131
+ content: existing
11132
+ });
11133
+ continue;
11134
+ }
11135
+ if (!hasMjolnirMarker(existing)) {
11136
+ entries.push({
11137
+ action: "refuse",
11138
+ surface: s.name,
11139
+ file: s.file,
11140
+ reason: "existing file is not Mjölnir-managed (no marker) — pass --force ONLY after reviewing it"
11141
+ });
11142
+ continue;
11143
+ }
11144
+ if (options.force !== true) {
11145
+ entries.push({
11146
+ action: "refuse",
11147
+ surface: s.name,
11148
+ file: s.file,
11149
+ reason: "Mjölnir-managed file has local edits — pass --force to overwrite"
11150
+ });
11151
+ continue;
11152
+ }
11153
+ entries.push({
11154
+ action: "update-in-place",
11155
+ surface: s.name,
11156
+ file: s.file,
11157
+ content: s.content
11158
+ });
11159
+ continue;
11160
+ }
11161
+ entries.push({
11162
+ action: "create",
11163
+ surface: s.name,
11164
+ file: s.file,
11165
+ content: s.content
11166
+ });
11167
+ }
11168
+ return {
11169
+ entries,
11170
+ detected: surfaces.length
11171
+ };
11172
+ }
11173
+ /** Execute a plan. Returns the number of files written. */
11174
+ function executeInstall(entries) {
11175
+ let written = 0;
11176
+ for (const e of entries) {
11177
+ if (e.action === "refuse" || e.action === "no-op") continue;
11178
+ const dir = join(e.file, "..");
11179
+ mkdirSync(dir, { recursive: true });
11180
+ writeFileSync(e.file, e.content);
11181
+ written++;
11182
+ }
11183
+ return written;
11184
+ }
11185
+ /**
11186
+ * Testable install command core. Returns the process exit code.
11187
+ * `mjolnir install [--dry-run] [--force]` — probes the given cwd
11188
+ * (production default: process.cwd()).
11189
+ */
11190
+ function runInstallCommand(argv, io = {
11191
+ out: (line) => console.log(line),
11192
+ err: (line) => console.error(line)
11193
+ }, cwd = process.cwd()) {
11194
+ const dryRun = argv.includes("--dry-run");
11195
+ const force = argv.includes("--force");
11196
+ const stagedHook = argv.includes("--staged-hook");
11197
+ for (const a of argv) {
11198
+ if (a === "--dry-run" || a === "--force" || a === "--staged-hook") continue;
11199
+ if (a === "--help" || a === "-h") continue;
11200
+ io.err(usageMessageFor(a));
11201
+ return 10;
11202
+ }
11203
+ const { entries, detected } = planInstall(cwd, { force });
11204
+ if (detected === 0 && !stagedHook) {
11205
+ io.out("No instruction surfaces detected — nothing to install. Surfaces probed: .claude/ (Claude Code), .kilo/ (Kilo), .cursor/ (Cursor), AGENTS.md.");
11206
+ return 0;
11207
+ }
11208
+ if (dryRun) {
11209
+ io.out("Install plan (dry run — nothing written):");
11210
+ for (const e of entries) if (e.action === "refuse") io.err(` REFUSE ${e.file}: ${e.reason}`);
11211
+ else io.out(` ${e.action} ${e.file}`);
11212
+ if (stagedHook) {
11213
+ const hook = planHookInstall(cwd);
11214
+ io.out(` ${hook.action} ${hook.file} (non-blocking pre-commit hook)`);
11215
+ }
11216
+ return 0;
11217
+ }
11218
+ let refused = false;
11219
+ for (const e of entries) {
11220
+ if (e.action === "refuse") {
11221
+ io.err(`mjolnir install: refusing ${e.file} — ${e.reason}`);
11222
+ refused = true;
11223
+ continue;
11224
+ }
11225
+ if (e.action === "no-op") continue;
11226
+ }
11227
+ const written = executeInstall(entries);
11228
+ for (const e of entries) if (e.action !== "refuse" && e.action !== "no-op") io.out(` ${e.action}: ${e.surface} → ${e.file} (mjolnir-qa@${CLI_VERSION})`);
11229
+ if (stagedHook) {
11230
+ const hook = planHookInstall(cwd);
11231
+ executeHookInstall(hook);
11232
+ io.out(` ${hook.action}: non-blocking pre-commit hook → ${hook.file} (mjolnir-qa@${CLI_VERSION} --staged --blocking warning)`);
11233
+ }
11234
+ for (const e of entries) if (e.action !== "refuse" && e.action !== "no-op") io.out(` ${e.action}: ${e.surface} → ${e.file} (mjolnir-qa@${CLI_VERSION})`);
11235
+ if (refused) {
11236
+ io.err("Some surfaces were skipped — see refusals above. Nothing was overwritten.");
11237
+ return 10;
11238
+ }
11239
+ io.out(`Installed on ${detected} instruction surface(s); ${written} file(s) written.`);
11240
+ return 0;
11241
+ }
11242
+ function usageMessageFor(token) {
11243
+ return `mjolnir install: unknown argument "${token}" — supported: --dry-run, --force`;
11244
+ }
11245
+ const HOOK_MARKER_OPEN = "# mjolnir:managed pre-commit (non-blocking)";
11246
+ const HOOK_MARKER_CLOSE = "# /mjolnir:managed pre-commit";
11247
+ function hookBlock(version) {
11248
+ return [
11249
+ `${HOOK_MARKER_OPEN} v${version}`,
11250
+ `# Advisory: surfaces staged-file findings without blocking the commit.`,
11251
+ `mjolnir --staged --blocking warning || true`,
11252
+ HOOK_MARKER_CLOSE
11253
+ ].join("\n");
11254
+ }
11255
+ /** The hook file an existing hook manager (husky / core.hooksPath) owns. */
11256
+ function resolveHookTarget(cwd) {
11257
+ const huskyDir = join(cwd, ".husky");
11258
+ if (existsSync(huskyDir)) return join(huskyDir, "pre-commit");
11259
+ try {
11260
+ const hooksPath = execFileSync("git", [
11261
+ "-C",
11262
+ cwd,
11263
+ "config",
11264
+ "core.hooksPath"
11265
+ ], { stdio: [
11266
+ "ignore",
11267
+ "pipe",
11268
+ "ignore"
11269
+ ] }).toString().trim();
11270
+ return join(cwd, hooksPath, "pre-commit");
11271
+ } catch {}
11272
+ return join(cwd, ".git", "hooks", "pre-commit");
11273
+ }
11274
+ /**
11275
+ * Pure plan for `--staged-hook` (plan M5). Reuses husky/core.hooksPath
11276
+ * when present; otherwise the default .git/hooks path. Marker-based:
11277
+ * an existing hook WITHOUT the marker is user-owned → refuse.
11278
+ */
11279
+ function planHookInstall(cwd) {
11280
+ const file = resolveHookTarget(cwd);
11281
+ if (!existsSync(file)) return {
11282
+ action: "create",
11283
+ file
11284
+ };
11285
+ let existing;
11286
+ try {
11287
+ existing = readFileSync(file, "utf8");
11288
+ } catch {
11289
+ return {
11290
+ action: "refuse",
11291
+ file,
11292
+ reason: "existing hook is unreadable"
11293
+ };
11294
+ }
11295
+ if (existing.includes(HOOK_MARKER_OPEN)) return {
11296
+ action: "update",
11297
+ file
11298
+ };
11299
+ return {
11300
+ action: "append",
11301
+ file
11302
+ };
11303
+ }
11304
+ function executeHookInstall(entry) {
11305
+ switch (entry.action) {
11306
+ case "create":
11307
+ mkdirSync(join(entry.file, ".."), { recursive: true });
11308
+ writeFileSync(entry.file, `#!/bin/sh\n${hookBlock(CLI_VERSION)}\n`);
11309
+ return true;
11310
+ case "append": {
11311
+ const existing = readFileSync(entry.file, "utf8");
11312
+ const sep = existing.endsWith("\n") ? "" : "\n";
11313
+ writeFileSync(entry.file, `${existing}${sep}\n${hookBlock(CLI_VERSION)}\n`);
11314
+ return true;
11315
+ }
11316
+ case "update": {
11317
+ const existing = readFileSync(entry.file, "utf8");
11318
+ const openIdx = existing.indexOf(HOOK_MARKER_OPEN);
11319
+ const closeIdx = existing.indexOf(HOOK_MARKER_CLOSE);
11320
+ if (openIdx !== -1 && closeIdx !== -1) writeFileSync(entry.file, existing.slice(0, openIdx) + hookBlock(CLI_VERSION) + existing.slice(closeIdx + 29));
11321
+ else {
11322
+ const sep = existing.endsWith("\n") ? "" : "\n";
11323
+ writeFileSync(entry.file, `${existing}${sep}\n${hookBlock(CLI_VERSION)}\n`);
11324
+ }
11325
+ return true;
11326
+ }
11327
+ default: return false;
11328
+ }
11329
+ }
11330
+ //#endregion
10519
11331
  //#region src/scope/changed.ts
10520
11332
  /**
10521
11333
  * Changed-scope engine (Sprint-Plan W6, Product-MVP §9 `--scope changed`).
@@ -10724,6 +11536,23 @@ function filterToChanged(findings, diff) {
10724
11536
  return false;
10725
11537
  });
10726
11538
  }
11539
+ /**
11540
+ * --staged (agent-handoff plan §5.7): the staged file names, as a
11541
+ * *scan-surface restriction*. Returns null when git data is
11542
+ * unavailable (degraded — callers fall back to the full surface with
11543
+ * an honest stderr note). Empty list = genuinely nothing staged.
11544
+ */
11545
+ function computeStagedFiles(root) {
11546
+ if (!existsSync(join(root, ".git"))) return null;
11547
+ const raw = git$1(root, [
11548
+ "diff",
11549
+ "--cached",
11550
+ "--name-only",
11551
+ "-z"
11552
+ ]);
11553
+ if (raw === null) return null;
11554
+ return raw.split("\0").map((s) => s.trim()).filter((s) => s.length > 0);
11555
+ }
10727
11556
  //#endregion
10728
11557
  //#region src/engine/rule-runner.ts
10729
11558
  /**
@@ -11915,6 +12744,18 @@ const HELP_ENTRIES = [
11915
12744
  usage: "mjolnir init [--interactive]",
11916
12745
  examples: ["mjolnir init"]
11917
12746
  },
12747
+ {
12748
+ verb: "why",
12749
+ summary: "why did Mjölnir flag <file>:<line>? evidence + fix (not a gate)",
12750
+ usage: "mjolnir why <file>:<line> [path] [--json <mjolnir.json>]",
12751
+ examples: ["mjolnir why e2e/a.spec.ts:3", "mjolnir why e2e/a.spec.ts:3 --json mjolnir.json"]
12752
+ },
12753
+ {
12754
+ verb: "handoff",
12755
+ summary: "deterministic fix-handoff artifact from a saved --json report",
12756
+ usage: "mjolnir handoff [mjolnir.json] [--category <cat>] [--rules <ids>]",
12757
+ examples: ["mjolnir --json > mjolnir.json && mjolnir handoff mjolnir.json", "mjolnir handoff mjolnir.json --category QA-PW"]
12758
+ },
11918
12759
  {
11919
12760
  verb: "explain",
11920
12761
  summary: "what/why/fix + measured FP rate for one rule",
@@ -11944,6 +12785,12 @@ const HELP_ENTRIES = [
11944
12785
  summary: "self-audit of the rule base (fixture firewall, tiers, caps)",
11945
12786
  usage: "mjolnir doctor [repo-root]",
11946
12787
  examples: ["mjolnir doctor"]
12788
+ },
12789
+ {
12790
+ verb: "install",
12791
+ summary: "install the agent instruction surfaces + optional staged hook",
12792
+ usage: "mjolnir install [--staged-hook] [--dry-run] [--force]",
12793
+ examples: ["mjolnir install --dry-run", "mjolnir install --staged-hook"]
11947
12794
  }
11948
12795
  ];
11949
12796
  /** Scan-flag entries documented per-flag via the overview. */
@@ -11999,6 +12846,22 @@ const HELP_FLAGS = [
11999
12846
  {
12000
12847
  flag: "--no-progress",
12001
12848
  summary: "no live scan-progress line on stderr"
12849
+ },
12850
+ {
12851
+ flag: "--score",
12852
+ summary: "print only the numeric score (or `unknown`)"
12853
+ },
12854
+ {
12855
+ flag: "--category <cat>",
12856
+ summary: "presentation filter (repeatable)"
12857
+ },
12858
+ {
12859
+ flag: "--staged",
12860
+ summary: "scan only git staged files"
12861
+ },
12862
+ {
12863
+ flag: "--blocking <level>",
12864
+ summary: "exit-status override: error|warning|none"
12002
12865
  }
12003
12866
  ];
12004
12867
  const EXIT_CODE_TABLE = [
@@ -12083,7 +12946,13 @@ const GROUPS = [
12083
12946
  },
12084
12947
  {
12085
12948
  title: "Meta",
12086
- verbs: ["rules", "explain"]
12949
+ verbs: [
12950
+ "rules",
12951
+ "explain",
12952
+ "why",
12953
+ "handoff",
12954
+ "install"
12955
+ ]
12087
12956
  }
12088
12957
  ];
12089
12958
  const SCAN_SUMMARY_LINES = ["mjolnir [path] full-repo scan + WORTHINESS score"];
@@ -12742,6 +13611,15 @@ function renderImpact(report) {
12742
13611
  */
12743
13612
  const ui$8 = plainContext();
12744
13613
  const DEFAULT_BASELINE_PATH = join(".mjolnir", "baseline.json");
13614
+ /**
13615
+ * Correlation identity for before/after comparison (agent-handoff plan
13616
+ * §5.2): ruleId + file + message, deliberately EXCLUDING `line` — a
13617
+ * source edit that shifts a finding still correlates. file:line is an
13618
+ * occurrence location, not a durable identity; message rewording,
13619
+ * file renames and rule-id changes correlate as resolved+new
13620
+ * (documented limitation). Exported for the handoff verification
13621
+ * contract — do not duplicate this algorithm.
13622
+ */
12745
13623
  function fingerprint(f) {
12746
13624
  return `${f.ruleId}\u0000${f.file}\u0000${f.message}`;
12747
13625
  }
@@ -14618,7 +15496,7 @@ function computeSelectorHealth(root, ignoreMatcher = DEFAULT_IGNORE_MATCHER) {
14618
15496
  * `scripts/sync-sarif-version.cjs` on release and guarded by
14619
15497
  * `tests/version-consistency.spec.ts` locally.
14620
15498
  */
14621
- const CLI_VERSION = "0.5.3";
15499
+ const CLI_VERSION = "0.5.4";
14622
15500
  const UNIVERSAL_RULES = RULES.map(asUniversal);
14623
15501
  /** Registered rule IDs — used to warn on unknown severityOverrides keys (M4). */
14624
15502
  const KNOWN_RULE_IDS = new Set(RULES.map((r) => r.id));
@@ -14736,7 +15614,23 @@ function parseArgs(argv, onError) {
14736
15614
  else if (a === "--record-milestones") args.recordMilestones = true;
14737
15615
  else if (a === "--cache") args.cache = true;
14738
15616
  else if (a === "--no-progress") args.noProgress = true;
14739
- else if (a === "--help" || a === "-h") return null;
15617
+ else if (a === "--category") {
15618
+ const cat = argv[++i];
15619
+ if (cat === void 0 || !RULE_CATEGORIES.includes(cat)) return reject({
15620
+ flag: "--category",
15621
+ token: cat
15622
+ });
15623
+ args.categories = [...args.categories ?? [], cat];
15624
+ } else if (a === "--score") args.scoreOnly = true;
15625
+ else if (a === "--staged") args.staged = true;
15626
+ else if (a === "--blocking") {
15627
+ const level = argv[++i];
15628
+ if (level === "error" || level === "warning" || level === "none") args.blocking = level;
15629
+ else return reject({
15630
+ flag: "--blocking",
15631
+ token: level
15632
+ });
15633
+ } else if (a === "--help" || a === "-h") return null;
14740
15634
  else if (!a.startsWith("-")) args.target = a;
14741
15635
  else return reject({ token: a });
14742
15636
  }
@@ -14916,6 +15810,17 @@ async function runScan(args, hooks = {}) {
14916
15810
  testFiles: wfBucket
14917
15811
  });
14918
15812
  ctx.testFiles.push(...wfBucket);
15813
+ let stagedSurface = false;
15814
+ if (args.staged) {
15815
+ const staged = computeStagedFiles(scanRoot.root);
15816
+ if (staged === null) hooks.onConfigWarning?.("mjolnir: --staged ignored — not a git repository (scanning the full surface).");
15817
+ else {
15818
+ const stagedSet = new Set(staged.map((s) => s.replace(/\\/g, "/")));
15819
+ ctx.testFiles = ctx.testFiles.filter((f) => stagedSet.has(relative(scanRoot.root, f).replace(/\\/g, "/")));
15820
+ stagedSurface = true;
15821
+ if (ctx.testFiles.length === 0) hooks.onConfigWarning?.("mjolnir: --staged — no staged files match the scan surface.");
15822
+ }
15823
+ }
14919
15824
  hooks.onProgress?.({
14920
15825
  phase: "discover",
14921
15826
  done: ctx.testFiles.length,
@@ -15067,8 +15972,10 @@ async function runScan(args, hooks = {}) {
15067
15972
  });
15068
15973
  const elapsed = Date.now() - started;
15069
15974
  const hasTests = testFileCount > 0;
15975
+ for (const f of findings) f.fixGroupId = f.ruleId;
15070
15976
  const result = {
15071
15977
  schemaVersion: 1,
15978
+ ...stagedSurface ? { staged: { files: testFileCount } } : {},
15072
15979
  partial: discoveryTruncated || rulesPartial || skippedFiles > 0,
15073
15980
  score: hasTests ? total : null,
15074
15981
  ...hasTests ? {} : { reason: "no-tests-found" },
@@ -15370,13 +16277,22 @@ async function runScanCommand(argv, io = {
15370
16277
  for (const line of crashLog.slice(0, 50)) io.err(` ${line}`);
15371
16278
  if (crashLog.length > 50) io.err(` … and ${crashLog.length - 50} more`);
15372
16279
  }
16280
+ if (args.scoreOnly) {
16281
+ if (args.json) io.err("--score overrides --json; stdout is the bare score.");
16282
+ const { config: scoreConfig } = loadConfig(target, { knownRuleIds: KNOWN_RULE_IDS });
16283
+ io.out(result.score === null ? "unknown" : String(result.score));
16284
+ return exitForFindings(result.findings, args.blocking === "none" ? "advisory" : args.blocking ?? scoreConfig.gate ?? "error");
16285
+ }
15373
16286
  if (args.format === "sarif") io.out(renderSarif(result));
15374
16287
  else if (args.format === "mermaid") io.out(renderMermaid(result));
15375
16288
  else if (args.json) io.out(JSON.stringify(result, null, 2));
15376
16289
  else {
16290
+ const categories = args.categories;
16291
+ const visible = categories && categories.length > 0 ? result.findings.filter((f) => categories.includes(f.category)) : result.findings;
15377
16292
  io.out(renderTerminal(result, {
15378
16293
  isTTY: process$1.stdout.isTTY ?? false,
15379
16294
  verbose: args.verbose,
16295
+ ...categories && categories.length > 0 ? { visibleFindings: visible } : {},
15380
16296
  ...args.width !== void 0 ? { width: args.width } : {},
15381
16297
  ...args.ascii !== void 0 ? { ascii: args.ascii } : {},
15382
16298
  ...args.tone !== void 0 ? { tone: args.tone } : {}
@@ -15394,7 +16310,7 @@ async function runScanCommand(argv, io = {
15394
16310
  if (result.partial) return 2;
15395
16311
  const { config, warnings } = loadConfig(target, { knownRuleIds: KNOWN_RULE_IDS });
15396
16312
  for (const w of warnings) io.err(w);
15397
- return exitForFindings(result.findings, config.gate ?? "error");
16313
+ return exitForFindings(result.findings, args.blocking === "none" ? "advisory" : args.blocking ?? config.gate ?? "error");
15398
16314
  } catch (err) {
15399
16315
  if (err instanceof ConfigValidationError) {
15400
16316
  io.err(err.message);
@@ -15789,8 +16705,11 @@ async function main(argv = process$1.argv.slice(2), io = {
15789
16705
  if (argv[0] === "rules") return runRulesCommand(argv.slice(1));
15790
16706
  if (argv[0] === "explain") return runExplainCommand(argv.slice(1));
15791
16707
  if (argv[0] === "doctor:playwright") return runDoctorPlaywright(argv);
16708
+ if (argv[0] === "why") return runWhyCommand(argv.slice(1), io);
16709
+ if (argv[0] === "handoff") return runHandoffCommand(argv.slice(1), io);
16710
+ if (argv[0] === "install") return runInstallCommand(argv.slice(1), io);
15792
16711
  if (argv[0] === "help") return runHelpCommand(argv.slice(1), io);
15793
- return runScanCommand(argv);
16712
+ return runScanCommand(argv, io);
15794
16713
  }
15795
16714
  /**
15796
16715
  * `mjolnir help` / `mjolnir help <verb>` (plan M2). `--help`/`-h` and
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mjolnir-qa",
3
- "version": "0.5.3",
3
+ "version": "0.5.4",
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": {