@dev-tren/mapd 0.21.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. package/LICENSE +21 -0
  2. package/MASTER_PROMPT.md +134 -0
  3. package/README.md +494 -0
  4. package/SETUP.md +108 -0
  5. package/UAT.md +77 -0
  6. package/package.json +56 -0
  7. package/src/adapters/github-app.js +79 -0
  8. package/src/agents/anthropicClient.js +18 -0
  9. package/src/agents/llm.js +196 -0
  10. package/src/agents/modelResolver.js +87 -0
  11. package/src/agents/provider.js +222 -0
  12. package/src/chat/commandRunner.js +86 -0
  13. package/src/chat/commands.js +275 -0
  14. package/src/chat/intent.js +87 -0
  15. package/src/chat/llmIntent.js +118 -0
  16. package/src/chat/repl.js +471 -0
  17. package/src/cli.js +1408 -0
  18. package/src/config/index.js +197 -0
  19. package/src/config/schema.js +119 -0
  20. package/src/core/assist.js +64 -0
  21. package/src/core/audit.js +63 -0
  22. package/src/core/changes.js +110 -0
  23. package/src/core/confidence.js +0 -0
  24. package/src/core/configLint.js +141 -0
  25. package/src/core/diagnose.js +262 -0
  26. package/src/core/docs.js +140 -0
  27. package/src/core/doctor.js +134 -0
  28. package/src/core/envFiles.js +43 -0
  29. package/src/core/events.js +53 -0
  30. package/src/core/evidence.js +212 -0
  31. package/src/core/findingScoring.js +20 -0
  32. package/src/core/fix.js +192 -0
  33. package/src/core/fixApply.js +172 -0
  34. package/src/core/frameworkEntries.js +247 -0
  35. package/src/core/gates.js +209 -0
  36. package/src/core/graph.js +467 -0
  37. package/src/core/grounding.js +235 -0
  38. package/src/core/handoff.js +157 -0
  39. package/src/core/importResolver.js +218 -0
  40. package/src/core/improve.js +226 -0
  41. package/src/core/integrate.js +169 -0
  42. package/src/core/intelligence.js +212 -0
  43. package/src/core/modernize.js +370 -0
  44. package/src/core/parseCache.js +64 -0
  45. package/src/core/parser.js +536 -0
  46. package/src/core/policy.js +65 -0
  47. package/src/core/polyglot.js +333 -0
  48. package/src/core/proc.js +25 -0
  49. package/src/core/reachability.js +543 -0
  50. package/src/core/regression.js +193 -0
  51. package/src/core/resolution.js +92 -0
  52. package/src/core/retry.js +61 -0
  53. package/src/core/review.js +219 -0
  54. package/src/core/score.js +338 -0
  55. package/src/core/security.js +0 -0
  56. package/src/core/session.js +143 -0
  57. package/src/core/solutions.js +254 -0
  58. package/src/core/staleness.js +45 -0
  59. package/src/core/testGuidance.js +226 -0
  60. package/src/core/theme.js +50 -0
  61. package/src/core/trace.js +151 -0
  62. package/src/core/verify.js +123 -0
  63. package/src/core/view.js +221 -0
  64. package/src/core/viewServer.js +88 -0
  65. package/src/core/watch.js +76 -0
  66. package/src/core/workspace.js +115 -0
  67. package/src/mcp/server.js +48 -0
  68. package/src/mcp/tools.js +423 -0
  69. package/src/server.js +84 -0
package/src/cli.js ADDED
@@ -0,0 +1,1408 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Map'd — topical layer over a project directory.
4
+ * Function 2 MVP: map → score → document → detect regressions → propose (never apply) fixes.
5
+ */
6
+
7
+ import { Command, Help } from "commander";
8
+ import fs from "node:fs";
9
+ import path from "node:path";
10
+ import { performance } from "node:perf_hooks";
11
+ import { loadEnvFiles } from "./core/envFiles.js";
12
+ import { loadPkg } from "./core/graph.js";
13
+ import { buildScoredGraph, buildTaskContext } from "./core/intelligence.js";
14
+ import { renderDocs } from "./core/docs.js";
15
+ import { saveBaseline, loadBaseline, diffGraphs, saveFindings } from "./core/regression.js";
16
+ import { proposeFix, llmAvailable, resolveConflict, migrationPlan } from "./agents/llm.js";
17
+ import { detectConflicts, verifyProposal, scoreResolution, saveIntegrationReport, applyProposals } from "./core/integrate.js";
18
+ import { runModernizationScan, saveModernizationReport } from "./core/modernize.js";
19
+ import { loadQueue, pending, transition, loadQueueWithStates } from "./core/review.js";
20
+ import { buildFindingEvidence, renderFindingEvidence } from "./core/evidence.js";
21
+ import { loadConfig, validateConfig, initConfig, setAnnotation, removeAnnotation, listAnnotations } from "./config/index.js";
22
+ import { ANNOTATION_CLASSIFICATIONS } from "./config/schema.js";
23
+ import { applyAnnotations } from "./core/reachability.js";
24
+ import { runFixLifecycle } from "./core/fix.js";
25
+ import { chooseFixTarget, approveFixWithPostApplyVerification } from "./core/fixApply.js";
26
+ import { loadChanges, rollbackChange } from "./core/changes.js";
27
+ import { loadAudits, getAudit, recordAudit } from "./core/audit.js";
28
+ import { startChat, createChatContext, handleInput } from "./chat/repl.js";
29
+ import { startMcpServer } from "./mcp/server.js";
30
+ import { startWatcher } from "./core/watch.js";
31
+ import { runDoctor } from "./core/doctor.js";
32
+ import { buildHandoff, renderHandoffPrompt } from "./core/handoff.js";
33
+ import { buildSolutions, narrateSolutions, renderSolutions } from "./core/solutions.js";
34
+ import { buildDiagnosis, renderDiagnosis } from "./core/diagnose.js";
35
+ import { explainScore, ceilingScore, simulateScore, deltaScore, renderExplain, renderCeiling, renderSimulate, renderDelta } from "./core/score.js";
36
+ import { analyzeTestCoverage, testGaps, testCredit, renderTestGaps, renderTestCredit, honestlyTestedFiles } from "./core/testGuidance.js";
37
+ import { planImprovements, renderImprovePlan, renderAgentPack } from "./core/improve.js";
38
+ import { runVerify, renderVerify } from "./core/verify.js";
39
+ import { lintConfig, renderConfigLint } from "./core/configLint.js";
40
+ import { resolveFile, traceFile, tracePath, renderTraceFile, renderTracePath } from "./core/trace.js";
41
+ import { analyzeResolution, renderResolution } from "./core/resolution.js";
42
+ import { buildViewModel, renderViewHtml } from "./core/view.js";
43
+ import { startViewServer, openBrowser } from "./core/viewServer.js";
44
+ import { getProvider } from "./agents/provider.js";
45
+ import { buildAssist, renderAssist } from "./core/assist.js";
46
+ import { bold, dim, red, green, yellow, cyan, confidenceColor, wrapList } from "./core/theme.js";
47
+
48
+ // Load .env (project cwd, then a user-level ~/.env fallback) before anything
49
+ // else reads process.env (ANTHROPIC_API_KEY / OPENAI_API_KEY / KIMI_API_KEY,
50
+ // etc.) — see core/envFiles.js for the precedence rule. Never overwrites a
51
+ // variable already exported in the shell.
52
+ loadEnvFiles();
53
+
54
+ // A CLI user's mistake (nonexistent dir, malformed .mapdrc, unreadable file)
55
+ // should read as one actionable line, not a stack trace. MAPD_DEBUG=1
56
+ // restores full stacks for actual debugging.
57
+ const friendlyFail = (e) => {
58
+ if (process.env.MAPD_DEBUG) console.error(e);
59
+ else console.error(`mapd: ${e?.message ?? e} (set MAPD_DEBUG=1 for the full stack)`);
60
+ process.exit(1);
61
+ };
62
+ process.on("uncaughtException", friendlyFail);
63
+ process.on("unhandledRejection", friendlyFail);
64
+
65
+ const pkgVersion = JSON.parse(fs.readFileSync(new URL("../package.json", import.meta.url), "utf8")).version;
66
+
67
+ const program = new Command();
68
+ program.name("mapd")
69
+ .description("Verification-first project-understanding layer: deterministic workflow mapping, derived confidence, chat, fix engine, and MCP")
70
+ .version(pkgVersion)
71
+ // Required (per Commander's own docs) for any nested group — `fix`,
72
+ // `tools`, etc. — whose subcommands reuse an option name already declared
73
+ // on the group itself (e.g. `fix`'s own --json vs `fix review`'s --json):
74
+ // without it, an ancestor's own option-parsing greedily claims a flag
75
+ // before the subcommand ever gets a chance to see it. Root itself has no
76
+ // options of its own to misparse, so this is a no-op everywhere else.
77
+ .enablePositionalOptions();
78
+
79
+ // A flat alphabetical list of 15 verbs tells you nothing about where to start.
80
+ // Group them by the question each one answers, and mark the four that cover
81
+ // almost every session. Commander still prints its own list for `help
82
+ // <command>`; this replaces the top-level dump only.
83
+ const GROUPED_HELP = `Usage: mapd [command] [dir] [options]
84
+
85
+ Map'd — understand a codebase, then keep it honest.
86
+
87
+ UNDERSTAND
88
+ map build the map: modules, imports, workflows, risk
89
+ coverage which files a test genuinely covers, and which do not
90
+ chat ask about this project in plain language
91
+
92
+ DECIDE
93
+ improve ranked work queue — what lifts confidence most, per effort
94
+ check diff against the baseline — CI-friendly exit code
95
+ verify one-shot gate: config + map + doctor + score delta
96
+
97
+ ACT
98
+ fix propose a gate-verified fix for a finding
99
+
100
+ SET UP
101
+ doctor is this environment healthy?
102
+ config .mapdrc — defaults, annotations, validation
103
+
104
+ EVERYTHING ELSE
105
+ tools MAP.md, watch, modernize, merge help, the change
106
+ ledger, the MCP server -> mapd tools --help
107
+
108
+ FREE by default — every command above is deterministic static analysis.
109
+ No API key, no network, no spend.
110
+
111
+ An API key adds only: open-ended chat Q&A, fix proposals, conflict
112
+ resolution, and MAP.md narration. Set ANTHROPIC_API_KEY, OPENAI_API_KEY
113
+ or KIMI_API_KEY (environment, .env, or ~/.env).
114
+ mapd doctor shows which provider, if any, is detected
115
+ mapd chat --free stays offline even when a key is configured
116
+
117
+ Every command takes [dir] (default ".").
118
+ mapd help <command> for that command's options.
119
+ `;
120
+ // Root only. configureHelp IS inherited, so every subcommand must fall through
121
+ // to Commander's real formatter — otherwise `mapd map --help` prints this
122
+ // screen instead of map's own options.
123
+ const defaultFormatHelp = Help.prototype.formatHelp;
124
+ program.configureHelp({
125
+ formatHelp: (cmd, helper) =>
126
+ cmd === program ? GROUPED_HELP : defaultFormatHelp.call(helper, cmd, helper),
127
+ });
128
+
129
+ function renderTaskContext(data) {
130
+ const lines = [];
131
+ lines.push(`${bold("Map'd context")} — ${cyan(data.query)}`);
132
+ lines.push(`files: ${data.summary.fileCount} workflows: ${data.summary.workflowCount} confidence: ${confidenceColor(data.summary.repoConfidence)(data.summary.repoConfidence)}`);
133
+ if (data.caveats.length) {
134
+ lines.push("");
135
+ lines.push(`${yellow("caveats:")}`);
136
+ for (const c of data.caveats) lines.push(` - ${c}`);
137
+ }
138
+ lines.push("");
139
+ lines.push(bold("Top hits"));
140
+ if (!data.hits.length) {
141
+ lines.push(" no symbol matches");
142
+ } else {
143
+ for (const h of data.hits) {
144
+ const tags = [h.exported ? "exported" : null, h.matches?.length ? `matched ${h.matches.join(", ")}` : null]
145
+ .filter(Boolean).join("; ");
146
+ lines.push(` ${h.file}#${h.function} ${dim(`score ${h.score}${tags ? `; ${tags}` : ""}`)}`);
147
+ }
148
+ }
149
+ lines.push("");
150
+ lines.push(bold("Relevant files"));
151
+ if (!data.files.length) {
152
+ lines.push(" no files selected");
153
+ } else {
154
+ for (const f of data.files) {
155
+ const exportsText = f.exports.length ? ` exports: ${f.exports.join(", ")}` : "";
156
+ lines.push(` ${f.file} ${dim(`${f.loc} LOC; ${f.functions.length} parsed function(s)${exportsText}`)}`);
157
+ }
158
+ }
159
+ if (data.workflows.length) {
160
+ lines.push("");
161
+ lines.push(bold("Matched workflows"));
162
+ for (const wf of data.workflows) {
163
+ lines.push(` ${wf.id} ${dim(`${wf.fileCount} files; matched ${wf.matchedFiles.join(", ")}`)}`);
164
+ }
165
+ }
166
+ return lines.join("\n");
167
+ }
168
+
169
+ function renderProfile(profile) {
170
+ if (!profile) return "";
171
+ const lines = [`${bold("Profile")}: total ${profile.totalMs}ms`];
172
+ for (const step of profile.steps ?? []) {
173
+ lines.push(` ${step.name}: ${step.durationMs}ms`);
174
+ }
175
+ return lines.join("\n");
176
+ }
177
+
178
+ function timedValue(fn) {
179
+ const startedAt = performance.now();
180
+ const value = fn();
181
+ return { value, durationMs: Number((performance.now() - startedAt).toFixed(2)) };
182
+ }
183
+
184
+ function registryOptsFromCli(opts) {
185
+ return {
186
+ checkRegistry: opts.registry !== false,
187
+ registryTimeoutMs: Number.parseInt(opts.registryTimeout, 10) || 2000,
188
+ };
189
+ }
190
+
191
+ function looksLikeDirectoryArg(value) {
192
+ if (!value) return false;
193
+ if (value === "." || value === ".." || value.includes("/") || value.includes("\\")) return true;
194
+ try { return fs.statSync(path.resolve(value)).isDirectory(); } catch { return false; }
195
+ }
196
+
197
+ /**
198
+ * Registers the same command (identical arguments/options/description/action)
199
+ * under multiple parents — used to keep a legacy top-level name working
200
+ * (hidden from help/listing, per backward-compat) while also exposing the
201
+ * same functionality at its new, consolidated location. `build` chains
202
+ * .argument/.option/.description onto the Command it's given and returns it;
203
+ * `action` is attached once per registration so there is exactly one action
204
+ * body regardless of how many entry points reach it.
205
+ */
206
+ function twin(parents, name, build, action) {
207
+ return parents.map(({ cmd, cmdOpts }) => build(cmd.command(name, cmdOpts)).action(action));
208
+ }
209
+
210
+ async function mapAction(dir, opts) {
211
+ const abs = path.resolve(dir);
212
+ if (opts.view) {
213
+ if (opts.json) fs.writeFileSync(opts.json, JSON.stringify(buildScoredGraph(abs), null, 2));
214
+ if (!opts.static && !opts.out) {
215
+ const { url, providerAvailable } = await startViewServer(abs, { port: opts.port ?? 0, open: opts.open });
216
+ console.log(`${green("Map'd view")} live at ${cyan(url)} ${dim("(chat embedded — Ctrl-C to stop)")}`);
217
+ console.log(dim(` chat: command-style questions ("what should I work on", "show test gaps", "is it passing") answer instantly; ${providerAvailable ? "free-form Q&A uses your LLM and can take a while" : "set ANTHROPIC_API_KEY/OPENAI_API_KEY/KIMI_API_KEY for free-form answers"}.`));
218
+ return;
219
+ }
220
+ const model = buildViewModel(abs);
221
+ const out = path.resolve(abs, opts.out || "mapd-view.html");
222
+ fs.writeFileSync(out, renderViewHtml(model));
223
+ console.log(`${green("Map'd view")} → ${out} ${dim(`(static, no chat — ${model.stats.files} files, ${model.stats.workflows} workflows)`)}`);
224
+ if (opts.open) openBrowser(`file://${out}`);
225
+ return;
226
+ }
227
+
228
+ const built = opts.profile ? timedValue(() => buildScoredGraph(dir)) : null;
229
+ const g = built ? built.value : buildScoredGraph(dir);
230
+ if (opts.json) {
231
+ fs.writeFileSync(opts.json, JSON.stringify(g, null, 2));
232
+ }
233
+ const loaded = loadBaseline(abs);
234
+ const { items } = loadQueueWithStates(abs);
235
+ const open = pending(items);
236
+ console.log(`\n${bold("Map'd")} — ${dim(abs)}`);
237
+ console.log(` files: ${g.stats.fileCount} loc: ${g.stats.totalLoc} workflows: ${g.workflows.length}`);
238
+ console.log(` call resolution: ${(g.stats.callResolutionRate * 100).toFixed(1)}% repo confidence: ${confidenceColor(g.repoConfidence)(g.repoConfidence)}`);
239
+ console.log(` baseline: ${loaded ? (loaded.schemaMismatch ? red("schema-mismatch") : green("present")) : dim("none")} open findings: ${open.length > 0 ? yellow(open.length) : green(open.length)}\n`);
240
+ for (const wf of g.workflows) {
241
+ console.log(` ${confidenceColor(wf.confidence.score)(`[${wf.confidence.score}]`)} ${wf.id} ${dim(`(${wf.files.length} files, signal coverage ${wf.confidence.signalCoverage})`)}`);
242
+ }
243
+ if (g.orphans.length) console.log(`\n ${yellow("orphans:")} ${wrapList(g.orphans, { width: 90, indent: " " })}`);
244
+ // `check` only flags NEW parse failures vs the baseline, so one that predates it would otherwise never be shown
245
+ const unparsed = g.files.filter((f) => !f.parsed && f.parserKind !== "heuristic").map((f) => f.file);
246
+ if (unparsed.length) console.log(`\n ${red("failed to parse:")} ${wrapList(unparsed, { width: 90, indent: " " })} ${dim("(contents invisible to the map)")}`);
247
+ if (g.stats.heuristicFileCount) {
248
+ console.log(`\n ${dim(`${g.stats.heuristicFileCount} non-JS/TS file(s) mapped by heuristic language adapters (half-weight in confidence; disable with .mapdrc mapping.polyglot=false)`)}`);
249
+ }
250
+ if (g.reachability?.heuristicUnverified?.length) {
251
+ console.log(` ${dim(`${g.reachability.heuristicUnverified.length} heuristic-parsed file(s) unreached by import tracing — unverifiable, NOT claimed orphaned`)}`);
252
+ }
253
+ if (opts.json) console.log(`\n raw graph → ${opts.json}`);
254
+ if (built) console.log(`\n ${dim(`map built in ${built.durationMs}ms`)}`);
255
+
256
+ const hint = !loaded ? "mapd check --save-baseline to start tracking regressions"
257
+ : open.length ? "mapd fix review to see what's awaiting approval"
258
+ : "mapd check for regressions, or mapd chat to explore";
259
+ console.log(`\n ${dim(`Next: ${hint}`)}`);
260
+ }
261
+
262
+ program
263
+ .command("map")
264
+ .argument("[dir]", "project root", ".")
265
+ .option("--json <file>", "also write the raw scored graph as JSON (or, with --view, print the view model as JSON)")
266
+ .option("--profile", "also print map-build timing (for modernization-scan timing use `tools modernize --profile`)")
267
+ .option("--view", "open a browser view instead (workflow graph, heatmap, embedded chat) — see the options below")
268
+ .option("--static", "with --view, write a self-contained HTML file instead of serving (no chat panel)")
269
+ .option("--out <file>", "with --view, output HTML path (implies --static)")
270
+ .option("--port <n>", "with --view, port for the local server (default: an open port)", (v) => Number.parseInt(v, 10))
271
+ .option("--no-open", "with --view, don't open a browser automatically")
272
+ .description("Build the scored workflow map and print a summary (baseline + queue status included). --view opens an interactive browser view instead.")
273
+ .action(mapAction);
274
+
275
+ program
276
+ .command("context", { hidden: true }) // reachable via `mapd chat`: "find code related to <topic>"
277
+ .argument("<query>", "task or question to build context for")
278
+ .argument("[dir]", "project root", ".")
279
+ .option("--json", "print the raw context pack as JSON")
280
+ .option("--hits <n>", "maximum ranked symbol hits", "8")
281
+ .option("--files <n>", "maximum relevant file cards", "8")
282
+ .description("Build a compact graph-backed context pack for a task/question")
283
+ .action((query, dir, opts) => {
284
+ const g = buildScoredGraph(dir);
285
+ const data = buildTaskContext(g, query, {
286
+ maxHits: Number.parseInt(opts.hits, 10) || 8,
287
+ maxFiles: Number.parseInt(opts.files, 10) || 8,
288
+ });
289
+ console.log(opts.json ? JSON.stringify(data, null, 2) : renderTaskContext(data));
290
+ });
291
+
292
+ program
293
+ .command("baseline", { hidden: true }) // folded into `mapd check --save-baseline`
294
+ .argument("[dir]", "project root", ".")
295
+ .description("Snapshot the current scored map as the regression baseline")
296
+ .action((dir) => {
297
+ const g = buildScoredGraph(dir);
298
+ const p = saveBaseline(path.resolve(dir), g);
299
+ console.log(`Baseline saved → ${p} (repo confidence ${g.repoConfidence})`);
300
+ });
301
+
302
+ program
303
+ .command("check")
304
+ .argument("[dir]", "project root", ".")
305
+ .option("--propose", "draft LLM fix proposals for high-severity findings (requires ANTHROPIC_API_KEY)")
306
+ .option("--json", "print the structured result (findings, confidence delta, resolved count) as JSON")
307
+ .option("--save-baseline", "snapshot the current scored map as the regression baseline, instead of diffing against one")
308
+ .description("Diff current map against baseline; write findings awaiting approval and auto-resolve ones that no longer reproduce (exits 2 on high-severity findings — CI-friendly). --save-baseline snapshots instead of diffing.")
309
+ .action(async (dir, opts) => {
310
+ const abs = path.resolve(dir);
311
+ if (opts.saveBaseline) {
312
+ const g = buildScoredGraph(dir);
313
+ const p = saveBaseline(abs, g);
314
+ if (opts.json) { console.log(JSON.stringify({ ok: true, path: p, repoConfidence: g.repoConfidence }, null, 2)); return; }
315
+ console.log(`Baseline saved → ${p} (repo confidence ${g.repoConfidence})`);
316
+ return;
317
+ }
318
+ const loaded = loadBaseline(abs);
319
+ if (!loaded) {
320
+ if (opts.json) { console.log(JSON.stringify({ ok: false, error: "no-baseline" }, null, 2)); }
321
+ else console.error("No baseline found. Run `mapd check --save-baseline` first.");
322
+ process.exitCode = 1;
323
+ return;
324
+ }
325
+ if (loaded.schemaMismatch) {
326
+ if (opts.json) { console.log(JSON.stringify({ ok: false, error: "schema-mismatch", schemaMismatch: loaded.schemaMismatch }, null, 2)); }
327
+ else console.error(`Baseline schema v${loaded.schemaMismatch.found} does not match this Map'd (v${loaded.schemaMismatch.expected}). ` +
328
+ "Diffing across schemas would misreport — run `mapd check --save-baseline` to re-snapshot.");
329
+ process.exitCode = 1;
330
+ return;
331
+ }
332
+ const baseline = loaded.graph;
333
+ const current = buildScoredGraph(dir);
334
+ const findings = diffGraphs(baseline, current);
335
+ // A re-check that reproduces nothing is what auto-RESOLVES previously-open
336
+ // findings (see regression.js saveFindings) — always save, even when clean.
337
+ const confidence = { from: baseline.repoConfidence, to: current.repoConfidence };
338
+ if (!findings.length) {
339
+ const saved = saveFindings(abs, findings);
340
+ if (opts.json) { console.log(JSON.stringify({ ok: true, findings: [], confidence, resolvedNow: saved.resolvedNow, path: saved.path }, null, 2)); return; }
341
+ console.log(green(`No regressions. Repo confidence ${confidence.from} → ${confidence.to}.`));
342
+ if (saved.resolvedNow) console.log(green(`${saved.resolvedNow} previously-open finding(s) auto-resolved — not reproduced by this re-check.`));
343
+ return;
344
+ }
345
+ if (!opts.json) {
346
+ for (const f of findings) {
347
+ const sevColor = f.severity === "high" ? red : f.severity === "medium" ? yellow : dim;
348
+ console.log(` ${sevColor(`[${f.severity.toUpperCase()}]`)} ${bold(f.kind)}: ${f.detail}`);
349
+ }
350
+ }
351
+
352
+ if (opts.propose) {
353
+ if (!llmAvailable()) {
354
+ if (!opts.json) console.log("\n--propose requires ANTHROPIC_API_KEY; findings saved without proposals.");
355
+ } else {
356
+ for (const f of findings.filter((x) => x.severity === "high")) {
357
+ const sources = (f.evidence?.files ?? [])
358
+ .slice(0, 3)
359
+ .map((file) => {
360
+ try { return { file, source: fs.readFileSync(path.join(abs, file), "utf8").slice(0, 8000) }; }
361
+ catch { return { file, source: null }; }
362
+ });
363
+ f.proposal = await proposeFix(f, sources);
364
+ if (!opts.json) console.log(` → proposal drafted for ${f.kind} (awaiting approval)`);
365
+ }
366
+ }
367
+ }
368
+ const saved = saveFindings(abs, findings);
369
+ process.exitCode = findings.some((f) => f.severity === "high") ? 2 : 0; // CI-friendly
370
+ if (opts.json) { console.log(JSON.stringify({ ok: true, findings, confidence, resolvedNow: saved.resolvedNow, path: saved.path }, null, 2)); return; }
371
+ console.log(`\n${findings.length} finding(s) → ${saved.path} (status: awaiting-approval — nothing was modified)`);
372
+ if (saved.resolvedNow) console.log(green(`${saved.resolvedNow} previously-open finding(s) auto-resolved — not reproduced by this re-check.`));
373
+ console.log(dim(`\nNext: mapd fix --propose auto-selects the strongest open finding and proposes a gate-verified fix.`));
374
+ });
375
+
376
+ // `tools` — advanced/scriptable functionality that isn't part of the daily
377
+ // map/check/fix/chat loop: still fully supported, just not top-level noise.
378
+ const tools = program.command("tools")
379
+ .description("Everything beyond the core loop: docs, coverage, modernization, watch, merge help, the change ledger, and the MCP server");
380
+ tools.addHelpText("after", `
381
+ Grouped:
382
+ docs render MAP.md from the map
383
+ modernize rule-based modernization scan
384
+ integrate classify merge conflicts on a branch
385
+ changes what was applied, and roll it back
386
+ watch re-map on every change, report deltas live
387
+ mcp serve project understanding to agents over MCP
388
+
389
+ Promoted to the top level. The old paths still work, but prefer:
390
+ mapd coverage (was: mapd tools test gaps)
391
+ mapd improve (was: mapd tools improve)
392
+ `);
393
+
394
+ twin(
395
+ [{ cmd: program, cmdOpts: { hidden: true } }, { cmd: tools, cmdOpts: undefined }],
396
+ "docs",
397
+ (c) => c
398
+ .argument("[dir]", "project root", ".")
399
+ .option("-o, --out <file>", "output file", "MAP.md")
400
+ .option("--no-narration", "skip LLM narration even if a key is present")
401
+ .description("Render MAP.md from the scored graph"),
402
+ async (dir, opts) => {
403
+ const g = buildScoredGraph(dir);
404
+ const md = await renderDocs(g, { withNarration: opts.narration });
405
+ const out = path.resolve(dir, opts.out);
406
+ fs.writeFileSync(out, md);
407
+ console.log(`Docs → ${out} (narration: ${opts.narration && llmAvailable() ? "on" : "off — deterministic only"})`);
408
+ },
409
+ );
410
+
411
+ twin(
412
+ [{ cmd: program, cmdOpts: { hidden: true } }, { cmd: tools, cmdOpts: undefined }],
413
+ "integrate",
414
+ (c) => c
415
+ .argument("<branch>", "branch to merge into the current branch")
416
+ .argument("[dir]", "project root", ".")
417
+ .option("--propose", "draft LLM resolutions for classified conflicts (requires ANTHROPIC_API_KEY)")
418
+ .option("--apply", "apply proposals from the saved report that meet the threshold")
419
+ .option("--threshold <n>", "minimum derived resolution score for --apply", "0.8")
420
+ .description("F1: detect + classify merge conflicts; optionally propose gated resolutions"),
421
+ async (branch, dir, opts) => {
422
+ const abs = path.resolve(dir);
423
+ const allFiles = fs.existsSync(abs) ? buildScoredGraph(dir).files.map((f) => f.file) : [];
424
+ const hasTest = (file) => {
425
+ const base = path.posix.basename(file).replace(/\.(js|ts|jsx|tsx|mjs|cjs)$/, "");
426
+ return allFiles.some((f) => /(\.test\.|\.spec\.|__tests__\/|tests?\/)/.test(f) && f.includes(base));
427
+ };
428
+
429
+ const { mergeable, conflicts, mergeError, requiresGit } = detectConflicts(abs, branch);
430
+ if (requiresGit) {
431
+ console.error("`mapd integrate` needs a git repository — this project isn't one. It merges the branch in an isolated worktree to classify conflicts, so there's nothing to integrate without git.");
432
+ process.exitCode = 1;
433
+ return;
434
+ }
435
+ if (mergeError) {
436
+ console.error(`Merge attempt failed before conflict detection:\n ${mergeError}`);
437
+ process.exitCode = 1;
438
+ return;
439
+ }
440
+ if (mergeable) {
441
+ console.log(`Merging '${branch}' is clean — no congruence issues. Run your normal merge.`);
442
+ return;
443
+ }
444
+ console.log(`\n${conflicts.length} conflicted file(s) merging '${branch}':`);
445
+ for (const c of conflicts) console.log(` [${c.classification}] ${c.file}`);
446
+
447
+ if (opts.propose) {
448
+ if (!llmAvailable()) {
449
+ console.log("\n--propose requires ANTHROPIC_API_KEY; report saved with classification only.");
450
+ } else {
451
+ for (const c of conflicts) {
452
+ if (!c.ours || !c.theirs) { c.proposal = { status: "manual-required", reason: c.classification }; continue; }
453
+ const merged = await resolveConflict(c);
454
+ if (!merged) continue;
455
+ const gates = verifyProposal(c, merged);
456
+ const failed = gates.filter((g) => !g.passed);
457
+ c.proposal = {
458
+ mergedSource: merged,
459
+ gates,
460
+ status: failed.length ? "rejected-by-gate" : "awaiting-approval",
461
+ resolutionScore: scoreResolution(c, gates, hasTest(c.file)),
462
+ };
463
+ console.log(` → ${c.file}: ${c.proposal.status}` +
464
+ (failed.length ? ` (${failed.map((g) => g.gate).join(", ")})` : ` [score ${c.proposal.resolutionScore.score}]`));
465
+ }
466
+ }
467
+ }
468
+
469
+ const report = { branch, generatedAt: new Date().toISOString(), conflicts };
470
+ const p = saveIntegrationReport(abs, branch, report);
471
+ console.log(`\nIntegration report → ${p} (nothing was modified)`);
472
+
473
+ if (opts.apply) {
474
+ const t = parseFloat(opts.threshold);
475
+ const { applied, skipped } = applyProposals(abs, report, t);
476
+ for (const a of applied) console.log(` APPLIED ${a.file} (score ${a.score} ≥ ${t})`);
477
+ for (const s of skipped) console.log(` skipped ${s.file} (${s.reason ?? `score ${s.score} < ${t}`})`);
478
+ if (applied.length) console.log(`\nApplied files are working-tree edits — review the diff, then commit yourself.`);
479
+ }
480
+ },
481
+ );
482
+
483
+ twin(
484
+ [{ cmd: program, cmdOpts: { hidden: true } }, { cmd: tools, cmdOpts: undefined }],
485
+ "modernize",
486
+ (c) => c
487
+ .argument("[modeOrDir]", "scan mode (light | medium | heavy; defaults to medium) or project root")
488
+ .argument("[dir]", "project root", ".")
489
+ .option("-m, --mode <mode>", "light | medium | heavy (same as the positional mode)")
490
+ .option("--profile", "include deterministic phase timing in the saved report and terminal output")
491
+ .option("--no-registry", "skip npm outdated registry check (faster/offline deterministic scan)")
492
+ .option("--registry-timeout <ms>", "npm outdated timeout in milliseconds", "2000")
493
+ .option("--propose", "heavy mode: draft LLM migration plans for top findings (requires ANTHROPIC_API_KEY)")
494
+ .option("--json", "print the structured report (findings with operational-impact scores) as JSON")
495
+ .description("F3: rule-based modernization scan with derived operational-impact scores — `mapd tools modernize` (medium), `heavy`, `light`"),
496
+ async (modeOrDir, dir, opts) => {
497
+ // `mapd modernize heavy` / `mapd modernize light [dir]` — the first
498
+ // positional is a mode when it names one, otherwise it is the project dir
499
+ const MODES = ["light", "medium", "heavy"];
500
+ let positionalMode = null;
501
+ if (modeOrDir !== undefined) {
502
+ if (MODES.includes(modeOrDir.toLowerCase())) positionalMode = modeOrDir.toLowerCase();
503
+ else if (dir === ".") dir = modeOrDir;
504
+ else {
505
+ console.error(`Unknown mode '${modeOrDir}'. Use light, medium, or heavy (or omit for medium).`);
506
+ process.exitCode = 1;
507
+ return;
508
+ }
509
+ }
510
+ const mode = positionalMode ?? opts.mode ?? "medium";
511
+ if (!MODES.includes(mode)) {
512
+ console.error(`Unknown mode '${mode}'. Use light, medium, or heavy.`);
513
+ process.exitCode = 1;
514
+ return;
515
+ }
516
+ if (positionalMode && opts.mode && opts.mode !== positionalMode) {
517
+ console.error(`Conflicting modes: positional '${positionalMode}' vs --mode '${opts.mode}'. Pass one or the other.`);
518
+ process.exitCode = 1;
519
+ return;
520
+ }
521
+ opts.mode = mode;
522
+ const abs = path.resolve(dir);
523
+ const g = buildScoredGraph(dir);
524
+ const report = runModernizationScan(abs, g, loadPkg(abs), opts.mode, { profile: opts.profile, ...registryOptsFromCli(opts) });
525
+
526
+ if (!opts.json) {
527
+ console.log(`\n${bold(`Modernization scan (${opts.mode})`)} — ${report.findings.length} finding(s):\n`);
528
+ for (const f of report.findings) {
529
+ const oi = f.operationalImpact;
530
+ if (!oi) { console.log(` ${dim("[info]")} ${f.detail}`); continue; }
531
+ const priorityColor = oi.priority >= 0.5 ? red : oi.priority >= 0.2 ? yellow : dim;
532
+ console.log(` ${priorityColor(`[priority ${oi.priority}]`)} ${dim(`(${f.tier})`)} ${bold(f.rule)}`);
533
+ console.log(` ${f.detail}`);
534
+ console.log(` ${dim(`impact ${oi.impact} = reach ${oi.reach} × certainty ${oi.certainty}; safety ${oi.safety}`)}`);
535
+ }
536
+ }
537
+
538
+ if (opts.propose && opts.mode === "heavy") {
539
+ if (!llmAvailable()) {
540
+ if (!opts.json) console.log("\n--propose requires ANTHROPIC_API_KEY; report saved without plans.");
541
+ } else {
542
+ for (const f of report.findings.filter((x) => x.operationalImpact).slice(0, 5)) {
543
+ f.migrationPlan = await migrationPlan(f, g.stats);
544
+ if (!opts.json) console.log(` → migration plan drafted for ${f.rule} (awaiting approval)`);
545
+ }
546
+ }
547
+ } else if (opts.propose && !opts.json) {
548
+ console.log("\n--propose only runs in heavy mode (light/medium are detection-only by design).");
549
+ }
550
+
551
+ if (opts.profile && !opts.json) {
552
+ console.log("");
553
+ console.log(renderProfile(report.profile));
554
+ }
555
+
556
+ const saved = saveModernizationReport(abs, report);
557
+ if (opts.json) { console.log(JSON.stringify({ mode: opts.mode, findings: report.findings, resolvedNow: saved.resolvedNow, path: saved.path }, null, 2)); return; }
558
+ console.log(`\nReport → ${saved.path} (status: awaiting-approval — nothing was modified)`);
559
+ if (saved.resolvedNow) console.log(green(`${saved.resolvedNow} previously-open finding(s) auto-resolved — not reproduced by this re-scan.`));
560
+ },
561
+ );
562
+
563
+ // `fixCmd` is declared here (ahead of its own full definition further below)
564
+ // so `review` and `evidence` can be registered as its subcommands via twin(),
565
+ // while also staying reachable at their original hidden top-level path.
566
+ // enablePositionalOptions: `fix` and its `review`/`evidence` subcommands both
567
+ // declare a `--json` option — without this, Commander's own parser (which
568
+ // resolves a command's OWN options before checking whether the first operand
569
+ // matches a subcommand name) swallows `--json` as fix's option even when it's
570
+ // written after `review`/`evidence`, so the subcommand never sees it. This
571
+ // makes Commander stop parsing fix's own options at the first token that
572
+ // matches a subcommand name, handing everything after it (in original order)
573
+ // to that subcommand instead.
574
+ const fixCmd = program.command("fix").enablePositionalOptions();
575
+
576
+ twin(
577
+ [{ cmd: program, cmdOpts: { hidden: true } }, { cmd: fixCmd, cmdOpts: undefined }],
578
+ "review",
579
+ (c) => c
580
+ .argument("[dir]", "project root", ".")
581
+ .option("--approve <id>", "approve one item by ID (integration proposals get applied)")
582
+ .option("--dismiss <id>", "dismiss one item by ID")
583
+ .option("--reason <text>", "reason recorded with a dismissal")
584
+ .option("--all", "include every item regardless of state (resolved, dismissed, historical)")
585
+ .option("--state <state>", "filter the listing by derived state (active, stale, approved, resolved, dismissed, historical)")
586
+ .option("--json", "print the queue (or the approve/dismiss result) as JSON")
587
+ .description("Unified approval queue across check / integrate / modernize / fix reports, with derived states"),
588
+ (dir, opts) => {
589
+ const abs = path.resolve(dir);
590
+ const { items, freshness } = loadQueueWithStates(abs);
591
+
592
+ const targetId = opts.approve ?? opts.dismiss;
593
+ if (targetId) {
594
+ const item = items.find((i) => i.id === targetId);
595
+ if (!item) {
596
+ if (opts.json) { console.log(JSON.stringify({ ok: false, error: "no-such-id", id: targetId }, null, 2)); }
597
+ else console.error(`No item with ID ${targetId}. Run \`mapd fix review\` to list current IDs.`);
598
+ process.exitCode = 1;
599
+ return;
600
+ }
601
+ if (opts.approve && item.state === "stale" && !opts.json) {
602
+ console.log(yellow(`Note: this finding comes from a stale report (${path.basename(item.file)} predates a newer source change) — it may already be fixed.`));
603
+ }
604
+ const r = transition(abs, item, opts.approve ? "approve" : "dismiss", opts.reason);
605
+ if (!r.ok) process.exitCode = 1;
606
+ if (opts.json) { console.log(JSON.stringify({ ok: r.ok, action: r.action, id: item.id, detail: r.detail }, null, 2)); return; }
607
+ console.log(r.ok ? green(`${r.action.toUpperCase()} ${item.id}: ${r.detail}`) : red(`FAILED: ${r.detail}`));
608
+ return;
609
+ }
610
+
611
+ const list = opts.state
612
+ ? items.filter((i) => i.state === opts.state)
613
+ : opts.all ? items : pending(items);
614
+ if (opts.json) {
615
+ console.log(JSON.stringify({ count: list.length, freshness, items: list }, null, 2));
616
+ return;
617
+ }
618
+ if (!list.length) {
619
+ console.log(dim(opts.state ? `No items in state "${opts.state}".` : opts.all ? "No reviewable items in any report." : "Queue is empty — nothing awaiting approval."));
620
+ return;
621
+ }
622
+ const staleCount = list.filter((i) => i.state === "stale").length;
623
+ console.log(`\n${bold(`${list.length} item(s)`)}${opts.state ? ` in state "${opts.state}"` : opts.all ? "" : " awaiting approval"}:\n`);
624
+ for (const i of list) {
625
+ const meta = [
626
+ i.severity && `sev:${i.severity}`,
627
+ i.priority != null && `priority:${i.priority}`,
628
+ i.score != null && `score:${i.score}`,
629
+ i.hasProposal && "proposal:yes",
630
+ ].filter(Boolean).join(" ");
631
+ const sevColor = i.severity === "high" ? red : i.severity === "medium" ? yellow : dim;
632
+ const stateColor = i.state === "stale" ? yellow : i.state === "active" ? green : dim;
633
+ console.log(` ${cyan(i.id)} ${dim(`[${i.source}]`)} ${bold(i.kind)} ${stateColor(`(${i.state})`)}`);
634
+ console.log(` ${i.detail}${meta ? `\n ${sevColor(meta)}` : ""}`);
635
+ }
636
+ if (staleCount) {
637
+ console.log(`\n${yellow(`${staleCount} item(s) marked (stale) come from report(s) that predate a newer source change (${freshness.staleReports.join(", ")}).`)}`);
638
+ console.log(yellow("They may already be fixed — re-run `mapd check` / `mapd tools modernize` before acting on them."));
639
+ }
640
+ console.log(`\n${dim(`Approve: mapd fix review --approve <id> Dismiss: mapd fix review --dismiss <id> [--reason "..."] Evidence: mapd fix evidence <id>`)}`);
641
+ },
642
+ );
643
+
644
+ twin(
645
+ [{ cmd: program, cmdOpts: { hidden: true } }, { cmd: fixCmd, cmdOpts: undefined }],
646
+ "evidence",
647
+ (c) => c
648
+ .argument("<id>", "review-queue item ID from `mapd fix review`")
649
+ .argument("[dir]", "project root", ".")
650
+ .option("--json", "print the structured evidence instead of the rendered view")
651
+ .description("Show the deterministic evidence behind one finding: files, workflows, reachability class, annotations, gate results, and report freshness"),
652
+ (id, dir, opts) => {
653
+ const data = buildFindingEvidence(path.resolve(dir), id);
654
+ if (!data) {
655
+ console.error(`No item with ID ${id}. Run \`mapd fix review\` to list current IDs.`);
656
+ process.exitCode = 1;
657
+ return;
658
+ }
659
+ if (opts.json) { console.log(JSON.stringify(data, null, 2)); return; }
660
+ console.log(renderFindingEvidence(data));
661
+ },
662
+ );
663
+
664
+ // `configCmd` is declared here (ahead of its own full definition further
665
+ // below) so `annotate` can be registered as one of its subcommands via twin,
666
+ // while also staying reachable at its original hidden top-level path.
667
+ const configCmd = program.command("config").description("Manage .mapdrc project configuration");
668
+
669
+ const ANNOTATE_DESC = "Manage user-asserted annotations (.mapdrc project.annotations) — the project-knowledge memory for what static analysis cannot know";
670
+ // Not using twin() here: these are pure command GROUPS (no action of their
671
+ // own, only subcommands), so each just needs .description(), registered once
672
+ // per parent — the subcommand loop below is what twin()'s de-duplication
673
+ // benefit actually applies to.
674
+ const annotateGroups = [
675
+ program.command("annotate", { hidden: true }).description(ANNOTATE_DESC),
676
+ configCmd.command("annotate").description(ANNOTATE_DESC),
677
+ ];
678
+ for (const annotate of annotateGroups) {
679
+ annotate
680
+ .command("add")
681
+ .argument("<pattern>", "glob pattern, e.g. \"eval/results/**\"")
682
+ .argument("<classification>", `one of: ${ANNOTATION_CLASSIFICATIONS.join(", ")}`)
683
+ .argument("[dir]", "project root", ".")
684
+ .description("Assert a classification for files matching a glob; recorded as a rollback-able .mapdrc change and always surfaced as user-asserted, never as detected")
685
+ .action((pattern, classification, dir) => {
686
+ const abs = path.resolve(dir);
687
+ const r = setAnnotation(abs, pattern, classification);
688
+ if (!r.ok) { console.error(`annotate add: ${r.reason}`); process.exitCode = 1; return; }
689
+ recordAudit(abs, { command: "annotate add", initiator: "cli", finalStatus: "applied", changeIds: [r.changeId], pattern, classification });
690
+ console.log(green(`${r.replaced ? "Updated" : "Added"} annotation "${pattern}": "${classification}" → ${r.path} (change ${r.changeId})`));
691
+ if (r.hadComments) console.log(yellow("Note: .mapdrc comments were not preserved by this rewrite (rollback with `mapd tools changes rollback` if needed)."));
692
+ const g = buildScoredGraph(abs);
693
+ const matched = applyAnnotations(new Set(g.files.map((f) => f.file)), { [pattern]: classification });
694
+ console.log(matched.length
695
+ ? `Matches ${matched.length} file(s) in the current map: ${matched.slice(0, 5).map((m) => m.file).join(", ")}${matched.length > 5 ? ", …" : ""}`
696
+ : yellow("Matches 0 files in the current map — check the pattern (globs: ** crosses directories, * stays within a segment)."));
697
+ });
698
+
699
+ annotate
700
+ .command("list")
701
+ .argument("[dir]", "project root", ".")
702
+ .option("--json", "print as JSON")
703
+ .description("List resolved annotations and which current files each pattern matches")
704
+ .action((dir, opts) => {
705
+ const abs = path.resolve(dir);
706
+ const annotations = listAnnotations(abs);
707
+ const entries = Object.entries(annotations);
708
+ if (!entries.length) {
709
+ if (opts.json) { console.log("{}"); return; }
710
+ console.log(dim("No annotations. Add one with `mapd config annotate add <pattern> <classification>`."));
711
+ return;
712
+ }
713
+ const g = buildScoredGraph(abs);
714
+ const fileSet = new Set(g.files.map((f) => f.file));
715
+ const matches = applyAnnotations(fileSet, annotations);
716
+ if (opts.json) {
717
+ console.log(JSON.stringify(entries.map(([pattern, classification]) => ({
718
+ pattern, classification,
719
+ matchedFiles: matches.filter((m) => m.pattern === pattern).map((m) => m.file),
720
+ })), null, 2));
721
+ return;
722
+ }
723
+ console.log(`\n${bold(`${entries.length} annotation(s)`)} ${dim("(user-asserted in .mapdrc, surfaced as assertions — never as detected facts)")}\n`);
724
+ for (const [pattern, classification] of entries) {
725
+ const matched = matches.filter((m) => m.pattern === pattern);
726
+ console.log(` ${cyan(`"${pattern}"`)}: ${bold(classification)} ${matched.length ? dim(`→ ${matched.length} file(s) in the current map`) : yellow("→ matches 0 files in the current map")}`);
727
+ }
728
+ });
729
+
730
+ annotate
731
+ .command("remove")
732
+ .argument("<pattern>", "the exact glob pattern to remove")
733
+ .argument("[dir]", "project root", ".")
734
+ .description("Remove one annotation from the project .mapdrc (recorded as a rollback-able change)")
735
+ .action((pattern, dir) => {
736
+ const abs = path.resolve(dir);
737
+ const r = removeAnnotation(abs, pattern);
738
+ if (!r.ok) { console.error(`annotate remove: ${r.reason}`); process.exitCode = 1; return; }
739
+ recordAudit(abs, { command: "annotate remove", initiator: "cli", finalStatus: "applied", changeIds: [r.changeId], pattern });
740
+ console.log(green(`Removed annotation "${pattern}" → ${r.path} (change ${r.changeId})`));
741
+ if (r.hadComments) console.log(yellow("Note: .mapdrc comments were not preserved by this rewrite (rollback with `mapd tools changes rollback` if needed)."));
742
+ });
743
+ }
744
+
745
+ twin(
746
+ [{ cmd: program, cmdOpts: { hidden: true } }, { cmd: tools, cmdOpts: undefined }],
747
+ "watch",
748
+ (c) => c
749
+ .argument("[dir]", "project root", ".")
750
+ .option("--interval <ms>", "debounce window after a change", "400")
751
+ .option("--json-events", "also print each structured watch event as one JSON line (for external consumers)")
752
+ .description("Continuous mode: re-map on file change (hash-cached), report deltas and regressions live"),
753
+ async (dir, opts) => {
754
+ const abs = path.resolve(dir);
755
+ const intervalMs = Math.max(50, parseInt(opts.interval, 10) || 400);
756
+ const { bus, initialGraph } = startWatcher(abs, { intervalMs });
757
+
758
+ console.log(`[watch] initial map: ${initialGraph.stats.fileCount} files, ${initialGraph.workflows.length} workflow(s), confidence ${initialGraph.repoConfidence}`);
759
+ console.log(`[watch] watching ${abs} — Ctrl+C to stop\n`);
760
+
761
+ bus.on("error", (e) => console.log(`[watch] rescan failed: ${e.message}`));
762
+ bus.on("remap", (evt) => {
763
+ if (opts.jsonEvents) console.log(JSON.stringify(evt));
764
+ const d = evt.repoConfidence.delta;
765
+ console.log(`[watch] remap ${evt.durationMs}ms (reparsed ${evt.reparsedCount}, cache hits ${evt.cacheHits}) — confidence ${evt.repoConfidence.from} → ${evt.repoConfidence.to} (${d >= 0 ? "+" : ""}${d})`);
766
+ for (const f of evt.newFindings) console.log(` [${f.severity.toUpperCase()}] ${f.kind}: ${f.detail}`);
767
+ for (const f of evt.resolvedFindings) console.log(` [INFO] ${f.detail}`);
768
+ });
769
+ },
770
+ );
771
+
772
+ fixCmd
773
+ .argument("[id]", "finding ID from `mapd fix review`; omit to auto-select the strongest open check/modernize finding")
774
+ .argument("[dir]", "project root", ".")
775
+ .option("--propose", "generate and gate-verify a fix proposal (requires a configured LLM provider)")
776
+ .option("--apply", "after a successful --propose, immediately approve and apply the verified proposal")
777
+ .option("--max-attempts <n>", "override fix.maxAttempts from .mapdrc for this run")
778
+ .option("--dry-run", "run the full lifecycle but do not persist a proposal file")
779
+ .option("--impact", "preview the finding's blast radius, risk, test coverage, and modeled score gain — without proposing anything")
780
+ .option("--json", "with --impact, print the impact preview as JSON")
781
+ .description("Load a finding, propose a gate-verified fix, retry on gate failure, and save it awaiting approval. Subcommands: `mapd fix review` (approval queue), `mapd fix evidence <id>`.")
782
+ .action(async (id, dir, opts) => {
783
+ let targetId = id;
784
+ let rootDir = dir;
785
+ if (targetId && dir === "." && looksLikeDirectoryArg(targetId)) {
786
+ rootDir = targetId;
787
+ targetId = null;
788
+ }
789
+ const abs = path.resolve(rootDir);
790
+ const config = loadConfig(abs, { cliOverrides: opts.maxAttempts ? { fix: { maxAttempts: parseInt(opts.maxAttempts, 10) } } : {} });
791
+
792
+ if (opts.impact) {
793
+ let impactId = targetId;
794
+ if (!impactId) {
795
+ const selected = chooseFixTarget(pending(loadQueue(abs)));
796
+ if (!selected) { console.error("fix --impact: no open findings to preview. Run `mapd check` or `mapd tools modernize` first."); process.exitCode = 1; return; }
797
+ impactId = selected.id;
798
+ }
799
+ const evidence = buildFindingEvidence(abs, impactId);
800
+ if (!evidence) { console.error(`No finding with ID ${impactId}. List IDs with \`mapd fix review\`.`); process.exitCode = 1; return; }
801
+ const g = buildScoredGraph(abs);
802
+ const wfFiles = new Set(g.workflows.flatMap((w) => w.files));
803
+ const tested = honestlyTestedFiles(g);
804
+ const files = (evidence.files ?? []).map((f) => f.file);
805
+ const inWorkflow = files.filter((f) => wfFiles.has(f));
806
+ const named = evidence.finding?.rawEvidence?.workflow; // regression findings name their workflow directly
807
+ const workflows = [...new Set([...(named ? [named] : []), ...(evidence.files ?? []).flatMap((f) => f.workflows ?? [])])];
808
+ const testedCount = inWorkflow.filter((f) => tested.has(f)).length;
809
+ const riskByKind = { "parse-failure": "medium", "export-removed": "high", "workflow-removed": "high", "confidence-regression": "medium", "resolution-degradation": "medium" };
810
+ const risk = riskByKind[evidence.kind] ?? (evidence.severity === "high" ? "high" : evidence.severity === "medium" ? "medium" : "low");
811
+ const modeledGain = evidence.kind === "parse-failure" && inWorkflow.length ? simulateScore(abs, g, { fixParse: inWorkflow }).delta : null;
812
+ const impact = {
813
+ id: impactId, kind: evidence.kind, severity: evidence.severity, risk,
814
+ files, filesInWorkflow: inWorkflow.length, workflows, blastRadius: workflows.length,
815
+ testedFiles: testedCount, untestedFiles: inWorkflow.length - testedCount,
816
+ modeledScoreGain: modeledGain,
817
+ note: modeledGain == null ? "score gain from resolving this finding isn't directly modeled — it removes the finding and its regression risk; use `mapd score simulate` to model specific signal changes." : null,
818
+ };
819
+ if (opts.json) { console.log(JSON.stringify(impact, null, 2)); return; }
820
+ console.log(`\n${bold("fix impact")} — ${cyan(impactId)} ${dim(`(${evidence.kind}${evidence.severity ? `, ${evidence.severity}` : ""})`)}`);
821
+ console.log(` risk: ${risk === "high" ? red(risk) : risk === "medium" ? yellow(risk) : green(risk)} blast radius: ${workflows.length} workflow(s)${workflows.length ? dim(` (${workflows.join(", ")})`) : ""}`);
822
+ console.log(` files: ${files.length} (${inWorkflow.length} in a workflow) honestly tested: ${testedCount}/${inWorkflow.length}`);
823
+ if (modeledGain != null) console.log(` modeled score gain if fixed: ${green(`+${modeledGain}`)}`);
824
+ else console.log(dim(` ${impact.note}`));
825
+ return;
826
+ }
827
+
828
+ if (!opts.propose && !opts.dryRun) {
829
+ console.log("Nothing to do — pass --propose to generate a gate-verified fix proposal (or --dry-run to preview without saving; --impact <id> to size one first).");
830
+ return;
831
+ }
832
+ if (!targetId) {
833
+ const selected = chooseFixTarget(pending(loadQueue(abs)));
834
+ if (!selected) {
835
+ console.error("fix: no open check or modernize findings to target. Run `mapd check` or `mapd tools modernize` first.");
836
+ process.exitCode = 1;
837
+ return;
838
+ }
839
+ targetId = selected.id;
840
+ console.log(`Auto-selected finding ${targetId}: ${selected.kind}${selected.severity ? ` (${selected.severity})` : ""} — ${selected.detail}`);
841
+ }
842
+ const startedAt = Date.now();
843
+ const r = await runFixLifecycle(abs, targetId, config, { dryRun: opts.dryRun });
844
+ if (!r.ok) {
845
+ console.error(`fix ${targetId}: ${r.reason}`);
846
+ process.exitCode = 1;
847
+ recordAudit(abs, { command: "fix", initiator: "cli", finalStatus: "failed", durationMs: Date.now() - startedAt, reason: r.reason });
848
+ return;
849
+ }
850
+ console.log(`\nfix ${targetId}: ${r.attempts.length} attempt(s), stopped because "${r.stopReason}"`);
851
+ for (const a of r.attempts) {
852
+ const failed = a.gates.filter((g) => !g.passed).map((g) => g.gate);
853
+ console.log(` attempt ${a.attempt}: ${a.passed ? "PASSED all gates" : `failed (${failed.join(", ")})`}`);
854
+ }
855
+ if (r.dryRun) {
856
+ console.log(`\n--dry-run: proposal not saved. Final status would be "${r.proposalRecord.status}".`);
857
+ return;
858
+ }
859
+ console.log(`\nProposal → ${r.proposalPath} (status: ${r.proposalRecord.status})`);
860
+ recordAudit(abs, {
861
+ command: "fix", initiator: "cli", provider: config.chat?.provider, attempt: r.attempts.length,
862
+ gates: r.attempts.map((a) => a.gates), finalStatus: r.proposalRecord.status,
863
+ durationMs: Date.now() - startedAt,
864
+ });
865
+ if (r.proposalRecord.status !== "awaiting-approval") {
866
+ console.log(`Gate verification did not pass within the attempt budget — nothing was modified. Review the proposal file for details.`);
867
+ return;
868
+ }
869
+ console.log(`Review with \`mapd fix review\`, then \`mapd fix review --approve <id>\` to apply.`);
870
+
871
+ if (opts.apply) {
872
+ const item = pending(loadQueue(abs)).find((i) => i.source === "fix" && path.resolve(i.file) === path.resolve(r.proposalPath));
873
+ if (!item) {
874
+ console.error("--apply: could not locate the freshly-saved proposal in the review queue.");
875
+ process.exitCode = 1;
876
+ return;
877
+ }
878
+ const applied = approveFixWithPostApplyVerification(abs, item, config);
879
+ if (applied.ok) {
880
+ const health = applied.postApplyVerification?.health;
881
+ const checks = applied.postApplyVerification?.correctness?.checks ?? [];
882
+ console.log(`APPLIED: ${applied.detail}`);
883
+ console.log(`POST-APPLY verified: confidence ${health.preConfidence} → ${health.postConfidence}, workflows ${health.preWorkflowCount} → ${health.postWorkflowCount}${checks.length ? `, checks ${checks.map((c) => c.script).join(", ")}` : ", no project checks configured"}.`);
884
+ } else {
885
+ console.error(`FAILED to apply safely: ${applied.detail}`);
886
+ for (const issue of applied.postApplyVerification?.issues ?? []) console.error(` - ${issue}`);
887
+ process.exitCode = 1;
888
+ }
889
+ recordAudit(abs, {
890
+ command: "fix --apply",
891
+ initiator: "cli",
892
+ approvalStatus: applied.ok ? "approved" : "failed",
893
+ finalStatus: applied.ok ? "applied" : "rolled-back",
894
+ changeIds: applied.changeIds ?? [],
895
+ postApplyVerification: applied.postApplyVerification ?? null,
896
+ });
897
+ }
898
+ });
899
+
900
+ // The change ledger — one group for the three verbs over recorded real-tree
901
+ // changes. `changes` (bare) defaults to `changes list` for back-compat.
902
+ const CHANGES_DESC = "The change ledger: list applied real-tree changes, roll one back, or inspect audit records";
903
+ const changesGroups = [
904
+ program.command("changes", { hidden: true }).description(CHANGES_DESC),
905
+ tools.command("changes").description(CHANGES_DESC),
906
+ ];
907
+ for (const changesCmd of changesGroups) {
908
+ changesCmd
909
+ .command("list", { isDefault: true })
910
+ .argument("[dir]", "project root", ".")
911
+ .option("--json", "print as JSON")
912
+ .description("List recorded real-tree changes (fix applies, integrate applies, review approvals)")
913
+ .action((dir, opts) => {
914
+ const abs = path.resolve(dir);
915
+ const recorded = loadChanges(abs);
916
+ if (opts.json) { console.log(JSON.stringify(recorded, null, 2)); return; }
917
+ if (!recorded.length) { console.log("No recorded changes."); return; }
918
+ for (const c of recorded) {
919
+ console.log(` ${c.id} [${c.source}] ${c.file}${c.rolledBack ? " (rolled back)" : ""} ${c.at}`);
920
+ }
921
+ });
922
+
923
+ changesCmd
924
+ .command("rollback")
925
+ .argument("<changeId>", "change ID from `mapd tools changes`")
926
+ .argument("[dir]", "project root", ".")
927
+ .description("Restore a file to its state before a recorded change")
928
+ .action((changeId, dir) => {
929
+ const r = rollbackChange(path.resolve(dir), changeId);
930
+ console.log(r.ok ? r.detail : `FAILED: ${r.detail}`);
931
+ if (!r.ok) process.exitCode = 1;
932
+ });
933
+
934
+ changesCmd
935
+ .command("audit")
936
+ .argument("[dir]", "project root", ".")
937
+ .option("--id <id>", "show one audit record by ID (omit to list all)")
938
+ .option("--json", "print as JSON")
939
+ .description("Inspect structured audit records")
940
+ .action((dir, opts) => {
941
+ const abs = path.resolve(dir);
942
+ if (!opts.id) {
943
+ const audits = loadAudits(abs);
944
+ if (opts.json) { console.log(JSON.stringify(audits, null, 2)); return; }
945
+ if (!audits.length) { console.log("No audit records."); return; }
946
+ for (const a of audits) console.log(` ${a.id} ${a.timestamp} ${a.command ?? "?"} ${a.finalStatus ?? "?"}`);
947
+ return;
948
+ }
949
+ const a = getAudit(abs, opts.id);
950
+ if (!a) { console.error(`No audit record matching ${opts.id}`); process.exitCode = 1; return; }
951
+ console.log(JSON.stringify(a, null, 2));
952
+ });
953
+ }
954
+
955
+ twin(
956
+ [{ cmd: program, cmdOpts: { hidden: true } }, { cmd: tools, cmdOpts: undefined }],
957
+ "mcp",
958
+ (c) => c.description("Start a local MCP server over stdio, exposing Map'd's project understanding and verification tools to compatible agents"),
959
+ async () => {
960
+ await startMcpServer();
961
+ },
962
+ );
963
+
964
+ program
965
+ .command("chat")
966
+ .option("--free", "deterministic only: answer from the map, never call a model (no spend), even if a key is configured")
967
+ .argument("[dirOrQuery...]", "project root, and/or a one-shot query. A leading real directory is used as the root; anything else is joined into the query. With a query, chat answers once and exits (the natural-language router) instead of starting the REPL.")
968
+ .description("Start an interactive, project-aware chat session (exit with `mapd chat end`, exit, quit, or /end). Pass a query to get one answer and exit instead — same engine, non-interactive.")
969
+ .action(async (args, opts) => {
970
+ // --free forces the null provider for this process: the map answers what
971
+ // it can and open-ended questions decline, rather than quietly spending.
972
+ if (opts?.free) {
973
+ for (const k of ["ANTHROPIC_API_KEY", "OPENAI_API_KEY", "KIMI_API_KEY"]) delete process.env[k];
974
+ process.env.MAPD_FREE = "1";
975
+ }
976
+ let dir = ".";
977
+ let queryTokens = args;
978
+ if (args.length) {
979
+ try {
980
+ if (fs.statSync(path.resolve(args[0])).isDirectory()) { dir = args[0]; queryTokens = args.slice(1); }
981
+ } catch { /* not a real path — treat all tokens as the query */ }
982
+ }
983
+ if (queryTokens.length) {
984
+ const ctx = createChatContext(dir);
985
+ console.log(await handleInput(queryTokens.join(" "), ctx));
986
+ return;
987
+ }
988
+ await startChat(dir);
989
+ });
990
+
991
+ program
992
+ .command("status", { hidden: true }) // folded into `mapd map`'s default output
993
+ .argument("[dir]", "project root", ".")
994
+ .option("--json", "print as JSON")
995
+ .description("Show repo confidence, baseline status, and open findings")
996
+ .action((dir, opts) => {
997
+ const abs = path.resolve(dir);
998
+ const g = buildScoredGraph(abs);
999
+ const loaded = loadBaseline(abs);
1000
+ const { items } = loadQueueWithStates(abs);
1001
+ const open = items.filter((i) => i.state === "active" || i.state === "stale");
1002
+ const staleCount = open.filter((i) => i.state === "stale").length;
1003
+ const summary = {
1004
+ root: abs,
1005
+ fileCount: g.stats.fileCount,
1006
+ workflowCount: g.workflows.length,
1007
+ repoConfidence: g.repoConfidence,
1008
+ baseline: loaded ? (loaded.schemaMismatch ? "schema-mismatch" : "present") : "none",
1009
+ openFindings: open.length,
1010
+ staleFindings: staleCount,
1011
+ heuristicFileCount: g.stats.heuristicFileCount ?? 0,
1012
+ };
1013
+ if (opts.json) { console.log(JSON.stringify(summary, null, 2)); return; }
1014
+ console.log(`\n${bold("Map'd status")} — ${dim(abs)}`);
1015
+ console.log(` files: ${summary.fileCount}${summary.heuristicFileCount ? dim(` (${summary.heuristicFileCount} heuristic-parsed)`) : ""} workflows: ${summary.workflowCount} repo confidence: ${confidenceColor(summary.repoConfidence)(summary.repoConfidence)}`);
1016
+ console.log(` baseline: ${summary.baseline === "present" ? green(summary.baseline) : summary.baseline === "none" ? dim(summary.baseline) : red(summary.baseline)}`);
1017
+ console.log(` open findings: ${summary.openFindings > 0 ? yellow(summary.openFindings) : green(summary.openFindings)}${staleCount ? yellow(` (${staleCount} from stale report(s) — re-run mapd check/modernize)`) : ""}`);
1018
+ });
1019
+
1020
+ program
1021
+ .command("doctor")
1022
+ .argument("[dir]", "project root", ".")
1023
+ .option("--json", "print as JSON")
1024
+ .description("Inspect runtime, config, provider, cache, baseline, and git/MCP readiness")
1025
+ .action((dir, opts) => {
1026
+ const abs = path.resolve(dir);
1027
+ const { ok, checks } = runDoctor(abs);
1028
+ if (opts.json) { console.log(JSON.stringify({ ok, checks }, null, 2)); return; }
1029
+ console.log(`\n${bold("Map'd doctor")} — ${dim(abs)}\n`);
1030
+ for (const c of checks) {
1031
+ const tag = c.ok ? green("[OK] ") : red("[FAIL]");
1032
+ const parts = c.detail.split(", ");
1033
+ const detail = c.detail.length > 90 && parts.length > 3 ? wrapList(parts, { width: 90, indent: " " }) : c.detail;
1034
+ console.log(` ${tag} ${bold(c.name)}: ${detail}`);
1035
+ }
1036
+ console.log(`\n${ok ? green("All checks passed.") : red("Some checks failed — see above.")}`);
1037
+ if (!ok) process.exitCode = 1;
1038
+ });
1039
+
1040
+ program
1041
+ .command("diagnose", { hidden: true }) // reachable via `mapd chat`: "diagnose the understanding limits"
1042
+ .argument("[dir]", "project root", ".")
1043
+ .option("--top <n>", "number of examples to show per diagnosis section", (v) => Number.parseInt(v, 10), 5)
1044
+ .option("--json", "print the structured diagnosis instead of the rendered report")
1045
+ .description("Explain Map'd's current understanding limits: weak confidence signals, runtime blind spots, env contract, and next actions")
1046
+ .action((dir, opts) => {
1047
+ const data = buildDiagnosis(path.resolve(dir), { top: opts.top });
1048
+ if (opts.json) { console.log(JSON.stringify(data, null, 2)); return; }
1049
+ console.log(renderDiagnosis(data));
1050
+ });
1051
+
1052
+ program
1053
+ .command("solutions", { hidden: true }) // reachable via `mapd chat`: "show me solutions"
1054
+ .argument("[dir]", "project root", ".")
1055
+ .option("--top <n>", "number of highest-priority items to include", (v) => Number.parseInt(v, 10), 5)
1056
+ .option("--narrate", "attach an optional LLM-written explanation per solution (requires a configured provider; verified against real data, never surfaced if it can't be)")
1057
+ .option("--handoff", "instead of the clustered report, emit the highest-priority findings as a ready-to-paste prompt for Claude Code / Codex")
1058
+ .option("--json", "print the structured data instead of the rendered report")
1059
+ .description("Cluster related findings into data-backed solutions ranked by real workflow blast radius (add --handoff for a paste-ready agent prompt)")
1060
+ .action(async (dir, opts) => {
1061
+ const abs = path.resolve(dir);
1062
+ if (opts.handoff) {
1063
+ const data = buildHandoff(abs, { top: opts.top });
1064
+ console.log(opts.json ? JSON.stringify(data, null, 2) : renderHandoffPrompt(data));
1065
+ return;
1066
+ }
1067
+ const config = loadConfig(abs);
1068
+ let data = buildSolutions(abs, { top: opts.top });
1069
+ if (opts.narrate) data = await narrateSolutions(data, getProvider(config), { graph: buildScoredGraph(abs) });
1070
+ if (opts.json) { console.log(JSON.stringify(data, null, 2)); return; }
1071
+ console.log(renderSolutions(data));
1072
+ });
1073
+
1074
+ const score = program.command("score", { hidden: true }) // reachable via `mapd chat`: "explain the score", "what's the ceiling", etc.
1075
+ .description("Score Intelligence: explain, simulate, ceiling, and delta over the derived confidence number");
1076
+
1077
+ score
1078
+ .command("explain")
1079
+ .argument("[dir]", "project root", ".")
1080
+ .option("--workflow <id>", "restrict the per-workflow breakdown to one workflow")
1081
+ .option("--json", "print the structured breakdown as JSON")
1082
+ .description("Show exactly what each signal contributes to — and what each weak signal costs — the repo and per-workflow score")
1083
+ .action((dir, opts) => {
1084
+ const g = buildScoredGraph(path.resolve(dir));
1085
+ const data = explainScore(g);
1086
+ if (opts.json) { console.log(JSON.stringify(data, null, 2)); return; }
1087
+ console.log(renderExplain(data, { workflow: opts.workflow }));
1088
+ });
1089
+
1090
+ score
1091
+ .command("ceiling")
1092
+ .argument("[dir]", "project root", ".")
1093
+ .option("--json", "print the structured ceiling as JSON")
1094
+ .description("Report the honest maximum confidence reachable by in-repo work, and the structural caps (no git, heuristic parsing) that hold it below 1.0")
1095
+ .action((dir, opts) => {
1096
+ const abs = path.resolve(dir);
1097
+ const g = buildScoredGraph(abs);
1098
+ const data = ceilingScore(abs, g);
1099
+ if (opts.json) { console.log(JSON.stringify(data, null, 2)); return; }
1100
+ console.log(renderCeiling(data));
1101
+ });
1102
+
1103
+ const csv = (v) => v.split(",").map((s) => s.trim()).filter(Boolean);
1104
+ score
1105
+ .command("simulate")
1106
+ .argument("[dir]", "project root", ".")
1107
+ .option("--add-tests <files>", "comma-separated workflow files to treat as newly tested", csv, [])
1108
+ .option("--fix-parse <files>", "comma-separated AST files to treat as parsed-clean", csv, [])
1109
+ .option("--resolve-rate <n>", "hypothetical call-resolution rate (0..1)", (v) => Number.parseFloat(v))
1110
+ .option("--cover <n>", "hypothetical repo-coverage fraction (0..1)", (v) => Number.parseFloat(v))
1111
+ .option("--json", "print the structured simulation as JSON")
1112
+ .description("Ask what confidence would become if you added tests, fixed parses, or resolved calls — re-runs the real scorer, never a fake number")
1113
+ .action((dir, opts) => {
1114
+ const abs = path.resolve(dir);
1115
+ const g = buildScoredGraph(abs);
1116
+ const data = simulateScore(abs, g, {
1117
+ addTests: opts.addTests, fixParse: opts.fixParse,
1118
+ resolutionRate: opts.resolveRate, coverageOfRepo: opts.cover,
1119
+ });
1120
+ if (opts.json) { console.log(JSON.stringify(data, null, 2)); return; }
1121
+ console.log(renderSimulate(data));
1122
+ });
1123
+
1124
+ score
1125
+ .command("delta")
1126
+ .argument("[dir]", "project root", ".")
1127
+ .option("--json", "print the structured delta as JSON")
1128
+ .description("Explain why confidence changed since the baseline snapshot — attributed to per-signal contribution moves (no git required)")
1129
+ .action((dir, opts) => {
1130
+ const abs = path.resolve(dir);
1131
+ const loaded = loadBaseline(abs);
1132
+ if (!loaded) {
1133
+ console.error("No baseline found. Run `mapd check --save-baseline` first.");
1134
+ process.exitCode = 1;
1135
+ return;
1136
+ }
1137
+ if (loaded.schemaMismatch) {
1138
+ console.error(`Baseline schema v${loaded.schemaMismatch.found} does not match this Map'd (v${loaded.schemaMismatch.expected}). Run \`mapd check --save-baseline\` to re-snapshot.`);
1139
+ process.exitCode = 1;
1140
+ return;
1141
+ }
1142
+ const current = buildScoredGraph(abs);
1143
+ const data = deltaScore(loaded.graph, current);
1144
+ if (opts.json) { console.log(JSON.stringify(data, null, 2)); return; }
1145
+ console.log(renderDelta(data));
1146
+ });
1147
+
1148
+ program
1149
+ .command("verify")
1150
+ .argument("[dir]", "project root", ".")
1151
+ .option("--strict", "treat warnings as failures (exit 1)")
1152
+ .option("--json", "print the structured verification result as JSON")
1153
+ .description("One-shot gate: config + map + doctor + baseline regression + score delta + freshness → a single verdict, CI exit code, and PR-ready summary")
1154
+ .action((dir, opts) => {
1155
+ const v = runVerify(path.resolve(dir), { strict: opts.strict });
1156
+ if (opts.json) { console.log(JSON.stringify(v, null, 2)); }
1157
+ else { console.log(renderVerify(v)); }
1158
+ process.exitCode = v.exitCode;
1159
+ });
1160
+
1161
+ twin(
1162
+ // Top level on purpose. "What should I work on?" is one of the handful of
1163
+ // questions people actually open this tool to answer — burying it behind a
1164
+ // noun as vague as `tools` hid the single most useful command in the CLI.
1165
+ [{ cmd: program, cmdOpts: undefined }, { cmd: tools, cmdOpts: { hidden: true } }],
1166
+ "improve",
1167
+ (c) => c
1168
+ .argument("[dir]", "project root", ".")
1169
+ .option("--budget <time>", "time budget, e.g. 2h, 90m (default: unbounded)")
1170
+ .option("--risk <level>", "max risk to include: low | medium | high", "low")
1171
+ .option("--agent-pack", "emit a paste-ready task pack for Codex / Claude Code (exact files, risk, expected lift, verify command)")
1172
+ .option("--json", "print the structured plan as JSON")
1173
+ .description("Ranked, honest work queue: the tasks that lift confidence most per unit effort, each with measured score lift, what NOT to fake, and how to verify (also answerable via `mapd chat`: \"what should I work on\")"),
1174
+ (dir, opts) => {
1175
+ const plan = planImprovements(path.resolve(dir), { budget: opts.budget, risk: opts.risk });
1176
+ if (opts.json) { console.log(JSON.stringify(plan, null, 2)); return; }
1177
+ if (opts.agentPack) { console.log(renderAgentPack(plan)); return; }
1178
+ console.log(renderImprovePlan(plan));
1179
+ },
1180
+ );
1181
+
1182
+ const themeHelpers = { bold, dim, red, green, yellow, cyan };
1183
+
1184
+ program
1185
+ .command("view", { hidden: true }) // folded into `mapd map --view`
1186
+ .argument("[dir]", "project root", ".")
1187
+ .option("--static", "write a self-contained HTML file instead of serving (no chat panel)")
1188
+ .option("--out <file>", "output HTML path (implies --static)")
1189
+ .option("--port <n>", "port for the local server (default: an open port)", (v) => Number.parseInt(v, 10))
1190
+ .option("--no-open", "don't open a browser automatically")
1191
+ .option("--json", "print the view model as JSON instead of writing/serving")
1192
+ .description("Open a browser view of the project (workflow graph, recolorable heatmap, baseline diff) with an embedded chat to help you understand it and decide what to work on. Use --static for a standalone HTML file (no chat).")
1193
+ .action(async (dir, opts) => {
1194
+ const abs = path.resolve(dir);
1195
+ if (opts.json) { console.log(JSON.stringify(buildViewModel(abs), null, 2)); return; }
1196
+ // Default: serve the live view WITH the chat panel and open the browser.
1197
+ if (!opts.static && !opts.out) {
1198
+ const { url, providerAvailable } = await startViewServer(abs, { port: opts.port ?? 0, open: opts.open });
1199
+ console.log(`${green("Map'd view")} live at ${cyan(url)} ${dim("(chat embedded — Ctrl-C to stop)")}`);
1200
+ console.log(dim(` chat: command-style questions ("what should I work on", "show test gaps", "is it passing") answer instantly; ${providerAvailable ? "free-form Q&A uses your LLM and can take a while" : "set ANTHROPIC_API_KEY/OPENAI_API_KEY/KIMI_API_KEY for free-form answers"}.`));
1201
+ return; // the server keeps the process alive
1202
+ }
1203
+ const model = buildViewModel(abs);
1204
+ const out = path.resolve(abs, opts.out || "mapd-view.html");
1205
+ fs.writeFileSync(out, renderViewHtml(model));
1206
+ console.log(`${green("Map'd view")} → ${out} ${dim(`(static, no chat — ${model.stats.files} files, ${model.stats.workflows} workflows)`)}`);
1207
+ if (opts.open) openBrowser(`file://${out}`);
1208
+ });
1209
+
1210
+ program
1211
+ .command("resolution", { hidden: true }) // reachable via `mapd chat`: "what's dragging down the resolution rate"
1212
+ .argument("[dir]", "project root", ".")
1213
+ .option("--top <n>", "number of hotspots / anonymous-function files to show", (v) => Number.parseInt(v, 10), 10)
1214
+ .option("--json", "print the structured resolution analysis as JSON")
1215
+ .description("Rank the call sites dragging down resolutionRate by workflow blast radius, list anonymous functions hiding edges, and show which unresolved calls are external (not your problem)")
1216
+ .action((dir, opts) => {
1217
+ const g = buildScoredGraph(path.resolve(dir));
1218
+ const data = analyzeResolution(g, { top: opts.top });
1219
+ console.log(opts.json ? JSON.stringify(data, null, 2) : renderResolution(data, themeHelpers));
1220
+ });
1221
+
1222
+ program
1223
+ .command("trace", { hidden: true }) // reachable via `mapd chat`: "trace <file>" / "show the import chain from X to Y"
1224
+ .argument("<file>", "file to explain (or the FROM file when a target is given)")
1225
+ .argument("[to]", "optional target file — show the import/call chain from <file> to <to>")
1226
+ .argument("[dir]", "project root", ".")
1227
+ .option("--json", "print the structured trace as JSON")
1228
+ .description("Explain why a file is in/out of a workflow, or show the exact import/call chain connecting two files")
1229
+ .action((file, to, dir, opts) => {
1230
+ // `trace <file> [dir]` vs `trace <from> <to> [dir]` — a target file also has
1231
+ // slashes, so only treat `to` as the dir when it's a REAL directory on disk.
1232
+ const isRealDir = (v) => { try { return fs.statSync(path.resolve(v)).isDirectory(); } catch { return false; } };
1233
+ if (to && dir === "." && isRealDir(to)) { dir = to; to = undefined; }
1234
+ const g = buildScoredGraph(path.resolve(dir));
1235
+ const from = resolveFile(g, file);
1236
+ if (from.notFound) { console.error(`No file matching "${file}". List files with \`mapd map\`.`); process.exitCode = 1; return; }
1237
+ if (from.ambiguous) { console.error(`"${file}" is ambiguous — matches: ${from.ambiguous.join(", ")}`); process.exitCode = 1; return; }
1238
+
1239
+ if (to) {
1240
+ const target = resolveFile(g, to);
1241
+ if (target.notFound) { console.error(`No file matching "${to}".`); process.exitCode = 1; return; }
1242
+ if (target.ambiguous) { console.error(`"${to}" is ambiguous — matches: ${target.ambiguous.join(", ")}`); process.exitCode = 1; return; }
1243
+ const data = tracePath(g, from.file, target.file);
1244
+ console.log(opts.json ? JSON.stringify(data, null, 2) : renderTracePath(data, themeHelpers));
1245
+ return;
1246
+ }
1247
+ const data = traceFile(g, from.file);
1248
+ console.log(opts.json ? JSON.stringify(data, null, 2) : renderTraceFile(data, themeHelpers));
1249
+ });
1250
+
1251
+ // Test Guidance — one group over the shared honest-test-credit analysis.
1252
+ const TEST_DESC = "Test Guidance: find files lowering testPresence (gaps) and see which test really credits which source (credit)";
1253
+ const testGroups = [
1254
+ // `coverage` is the visible name: `mapd test` reads like "run my tests",
1255
+ // which this does not do — it reports which files a test genuinely covers.
1256
+ program.command("coverage").description(TEST_DESC),
1257
+ program.command("test", { hidden: true }).description(TEST_DESC),
1258
+ tools.command("test").description(TEST_DESC),
1259
+ ];
1260
+ for (const testCmd of testGroups) {
1261
+ testCmd
1262
+ .command("gaps", { isDefault: true })
1263
+ .argument("[dir]", "project root", ".")
1264
+ .option("--shallow", "also list shallow tests (import module XOR use export)")
1265
+ .option("--json", "print the structured analysis as JSON")
1266
+ .description("List workflow files lowering testPresence — untested files and name-only padding — with the literal crediting rule and a suggested test filename")
1267
+ .action((dir, opts) => {
1268
+ const abs = path.resolve(dir);
1269
+ const analysis = analyzeTestCoverage(abs, buildScoredGraph(abs));
1270
+ const gaps = testGaps(analysis, { includeShallow: opts.shallow });
1271
+ if (opts.json) {
1272
+ // Emit EVERY file's status, not just the gaps. A consumer that sees
1273
+ // only gaps cannot tell "tested" from "never assessed", and the safe
1274
+ // default it picks will be wrong either way — User-Tests defaulted the
1275
+ // silence to "tested-real" and reported 42/44 covered on a repo with
1276
+ // 13 untested files.
1277
+ const files = (analysis.files ?? []).map((c) => ({ file: c.file, status: c.status, inWorkflow: c.inWorkflow }));
1278
+ console.log(JSON.stringify({ summary: analysis.summary, rule: analysis.rule, gaps, files }, null, 2));
1279
+ return;
1280
+ }
1281
+ console.log(renderTestGaps(analysis, gaps, themeHelpers));
1282
+ });
1283
+
1284
+ testCmd
1285
+ .command("credit")
1286
+ .argument("[dir]", "project root", ".")
1287
+ .option("--padding", "show only padding suspects (credited by the basename rule but with no real import/export link)")
1288
+ .option("--json", "print the structured credit map as JSON")
1289
+ .description("Show which test file credits which source file, and whether that credit is real (imports the module + uses its exports), shallow, or name-only padding")
1290
+ .action((dir, opts) => {
1291
+ const abs = path.resolve(dir);
1292
+ const analysis = analyzeTestCoverage(abs, buildScoredGraph(abs));
1293
+ const rows = testCredit(analysis, { paddingOnly: opts.padding });
1294
+ if (opts.json) { console.log(JSON.stringify({ summary: analysis.summary, files: rows }, null, 2)); return; }
1295
+ console.log(renderTestCredit(rows, themeHelpers, { paddingOnly: opts.padding }));
1296
+ });
1297
+ }
1298
+
1299
+ /**
1300
+ * Walks Commander's own registered command tree (including nested subcommands
1301
+ * like `config init/show/validate`) and returns `{ command, description }`
1302
+ * for each — introspected directly from the real registrations, so this can
1303
+ * never drift out of sync with what the CLI actually supports. Hidden
1304
+ * commands (legacy aliases kept for backward compat) and their subtrees are
1305
+ * skipped, matching Commander's own --help filtering.
1306
+ */
1307
+ function listAllCommands(cmd, prefix = "mapd") {
1308
+ return cmd.commands.filter((c) => !c._hidden).flatMap((c) => {
1309
+ const name = `${prefix} ${c.name()}`;
1310
+ const usage = c.usage();
1311
+ const entry = { command: usage ? `${name} ${usage}` : name, description: c.description() || "(no description)" };
1312
+ return [entry, ...listAllCommands(c, name)];
1313
+ });
1314
+ }
1315
+
1316
+ const commandsAction = (opts) => {
1317
+ const entries = listAllCommands(program);
1318
+ if (opts.json) { console.log(JSON.stringify(entries, null, 2)); return; }
1319
+ console.log(`\n${bold("Map'd commands")}\n`);
1320
+ for (const e of entries) {
1321
+ console.log(` ${cyan(bold(e.command))}`);
1322
+ console.log(` ${dim(e.description)}\n`);
1323
+ }
1324
+ };
1325
+
1326
+ program
1327
+ .command("command", { hidden: true })
1328
+ .alias("commands")
1329
+ .description("List every mapd command and what it does")
1330
+ .option("--json", "print as JSON")
1331
+ .action(commandsAction);
1332
+
1333
+ tools
1334
+ .command("commands")
1335
+ .description("List every mapd command and what it does")
1336
+ .option("--json", "print as JSON")
1337
+ .action(commandsAction);
1338
+
1339
+ configCmd
1340
+ .command("init")
1341
+ .argument("[dir]", "project root", ".")
1342
+ .option("--force", "overwrite an existing .mapdrc")
1343
+ .description("Write a starter .mapdrc with documented defaults")
1344
+ .action((dir, opts) => {
1345
+ const r = initConfig(dir, { force: opts.force });
1346
+ if (!r.ok) {
1347
+ console.error(`config init: ${r.reason} → ${r.path}`);
1348
+ process.exitCode = 1;
1349
+ return;
1350
+ }
1351
+ console.log(`.mapdrc written → ${r.path}`);
1352
+ });
1353
+
1354
+ configCmd
1355
+ .command("show")
1356
+ .argument("[dir]", "project root", ".")
1357
+ .option("--json", "print as JSON")
1358
+ .description("Print the fully-resolved configuration (defaults + user + project + env)")
1359
+ .action((dir, opts) => {
1360
+ const resolved = loadConfig(dir);
1361
+ if (opts.json) {
1362
+ console.log(JSON.stringify(resolved, null, 2));
1363
+ return;
1364
+ }
1365
+ console.log(`\nResolved Map'd configuration for ${path.resolve(dir)}\n`);
1366
+ for (const [section, values] of Object.entries(resolved)) {
1367
+ console.log(` [${section}]`);
1368
+ for (const [k, v] of Object.entries(values)) console.log(` ${k}: ${JSON.stringify(v)}`);
1369
+ }
1370
+ });
1371
+
1372
+ configCmd
1373
+ .command("validate")
1374
+ .argument("[dir]", "project root", ".")
1375
+ .description("Validate the resolved configuration against Map'd's schema")
1376
+ .action((dir) => {
1377
+ const resolved = loadConfig(dir);
1378
+ const { ok, errors } = validateConfig(resolved);
1379
+ if (ok) {
1380
+ console.log("Configuration is valid.");
1381
+ return;
1382
+ }
1383
+ console.error("Configuration errors:");
1384
+ for (const e of errors) console.error(` - ${e}`);
1385
+ process.exitCode = 1;
1386
+ });
1387
+
1388
+ configCmd
1389
+ .command("lint")
1390
+ .argument("[dir]", "project root", ".")
1391
+ .option("--json", "print the structured lint result as JSON")
1392
+ .description("Catch config that lies to you: excluded-but-annotated files, stale/over-broad annotation globs, dead excludes, and unattributed manual assertions — each with a suggested patch (exits 1 on errors)")
1393
+ .action((dir, opts) => {
1394
+ const result = lintConfig(path.resolve(dir));
1395
+ if (opts.json) { console.log(JSON.stringify(result, null, 2)); }
1396
+ else { console.log(renderConfigLint(result)); }
1397
+ if (!result.ok) process.exitCode = 1;
1398
+ });
1399
+
1400
+ // Bare `mapd` (no subcommand, no flags) — a guided next-step suggestion
1401
+ // grounded in real project state, instead of a generic help dump. Anything
1402
+ // else (including `mapd --help`/`-h`/`--version`) still goes through
1403
+ // Commander normally.
1404
+ if (process.argv.length === 2) {
1405
+ console.log(renderAssist(buildAssist(process.cwd()), { bold, dim, cyan, confidenceColor }));
1406
+ } else {
1407
+ program.parseAsync();
1408
+ }