@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
@@ -0,0 +1,222 @@
1
+ /**
2
+ * provider.js — LLM provider abstraction. The rest of Map'd depends on this
3
+ * interface, never on a vendor SDK type directly.
4
+ *
5
+ * `getProvider(config)` -> { name, model, available(), complete(system, user, maxTokens) }
6
+ *
7
+ * Implementations:
8
+ * - anthropicProvider — reuses @anthropic-ai/sdk (already a dependency)
9
+ * - openaiCompatProvider — plain global fetch (Node 20+), no new SDK dependency
10
+ * - kimiProvider — Moonshot AI's Kimi models, over the same
11
+ * OpenAI-compatible chat/completions shape, its own
12
+ * KIMI_API_KEY/KIMI_BASE_URL so it never collides
13
+ * with a real OpenAI key in the same environment
14
+ * - nullProvider — deterministic-only: available() is false, complete()
15
+ * resolves null. This is what keeps chat/fix/mcp fully
16
+ * functional with zero API key configured.
17
+ *
18
+ * Never persists credentials: API keys are read from process.env only, never
19
+ * written to .mapdrc, config-state.json, or any audit record.
20
+ */
21
+
22
+ import { createAnthropicClientLoader } from "./anthropicClient.js";
23
+ import { resolveAnthropicModel, pinnedModel, FALLBACK_SONNET } from "./modelResolver.js";
24
+ const DEFAULT_OPENAI_MODEL = "gpt-4o-mini";
25
+ // Moonshot renames/adds Kimi model identifiers over time — verify the current
26
+ // one at https://platform.moonshot.ai/docs before relying on this default;
27
+ // override via .mapdrc `providers.kimi.model` at any time. (Confirmed present
28
+ // in Moonshot's own "Models and Pricing" listing as of this writing.)
29
+ const DEFAULT_KIMI_MODEL = "kimi-k2.6";
30
+
31
+ function redactForError(message) {
32
+ return String(message ?? "")
33
+ .replace(/sk-[A-Za-z0-9-_]{10,}/g, "sk-***redacted***")
34
+ .replace(/\bBearer\s+[A-Za-z0-9-_.]{10,}/gi, "Bearer ***redacted***");
35
+ }
36
+
37
+ async function responseTextOrEmpty(res) {
38
+ try {
39
+ return await res.text();
40
+ } catch {
41
+ return "";
42
+ }
43
+ }
44
+
45
+ /**
46
+ * Shared client for any OpenAI-compatible chat/completions endpoint —
47
+ * openaiCompatProvider and kimiProvider both delegate here so the fetch/
48
+ * error-handling logic exists exactly once.
49
+ */
50
+ const TRUNCATION_RETRY_CAP_TOKENS = 8000;
51
+ const TRUNCATION_NOTICE = "\n\n[⚠ response was truncated by the token limit even after one retry — this answer may be incomplete]";
52
+
53
+ function createOpenAiCompatibleProvider({ name, model, apiKeyEnv, baseUrl }) {
54
+ async function callOnce({ apiKey, system, user, maxTokens, signal }) {
55
+ let res;
56
+ try {
57
+ res = await fetch(`${baseUrl}/chat/completions`, {
58
+ method: "POST",
59
+ headers: {
60
+ "content-type": "application/json",
61
+ authorization: `Bearer ${apiKey}`,
62
+ },
63
+ body: JSON.stringify({
64
+ model,
65
+ max_tokens: maxTokens,
66
+ messages: [{ role: "system", content: system }, { role: "user", content: user }],
67
+ }),
68
+ signal,
69
+ });
70
+ } catch (e) {
71
+ throw new Error(`${name} provider network error: ${redactForError(e.message)}`);
72
+ }
73
+ if (!res.ok) {
74
+ const body = await responseTextOrEmpty(res);
75
+ throw new Error(`${name} provider error: HTTP ${res.status} ${redactForError(body).slice(0, 500)}`);
76
+ }
77
+ const json = await res.json();
78
+ const choice = json.choices?.[0];
79
+ const message = choice?.message;
80
+ const content = message?.content?.trim();
81
+ // Reasoning models (Kimi K2, and others behind this same OpenAI-compatible
82
+ // shape) return chain-of-thought in `reasoning_content` separately from
83
+ // the final answer in `content`. On a long/complex prompt the model can
84
+ // spend its whole max_tokens budget reasoning and never reach `content`,
85
+ // leaving it empty — falling back to reasoning_content surfaces *something*
86
+ // real rather than nothing; it's the model's own scratchpad, not a
87
+ // polished answer, so callers should treat it as a lower-confidence result.
88
+ const reasoning = message?.reasoning_content?.trim();
89
+ return { text: content || reasoning || null, truncated: choice?.finish_reason === "length" };
90
+ }
91
+
92
+ return {
93
+ name,
94
+ model,
95
+ available: () => !!process.env[apiKeyEnv],
96
+ async complete(system, user, maxTokens = 1500, { signal } = {}) {
97
+ const apiKey = process.env[apiKeyEnv];
98
+ if (!apiKey) return null;
99
+ let result = await callOnce({ apiKey, system, user, maxTokens, signal });
100
+ // A response cut off mid-answer (finish_reason "length") is worse than
101
+ // no answer — it can leave an incomplete claim looking finished, or (on
102
+ // a reasoning model) leak unfinished chain-of-thought as if it were the
103
+ // polished answer. One bounded retry with a larger budget resolves this
104
+ // in most cases without unbounded cost/latency; if it's still truncated
105
+ // after that, disclose it explicitly rather than silently returning a
106
+ // cut-off answer as if it were complete.
107
+ if (result.truncated && maxTokens < TRUNCATION_RETRY_CAP_TOKENS) {
108
+ result = await callOnce({ apiKey, system, user, maxTokens: Math.min(maxTokens * 2, TRUNCATION_RETRY_CAP_TOKENS), signal });
109
+ }
110
+ if (!result.text) return null;
111
+ return result.truncated ? `${result.text}${TRUNCATION_NOTICE}` : result.text;
112
+ },
113
+ };
114
+ }
115
+
116
+ export function anthropicProvider(config = {}) {
117
+ const client = createAnthropicClientLoader();
118
+ let resolved = null; // { model, source } — resolved once per process, on first use
119
+ async function callOnce(c, { system, user, maxTokens, signal }) {
120
+ const msg = await c.messages.create(
121
+ { model: resolved.model, max_tokens: maxTokens, system, messages: [{ role: "user", content: user }] },
122
+ { signal },
123
+ );
124
+ if (msg.model) provider.lastModelUsed = msg.model; // what actually answered, as the API reports it
125
+ const text = msg.content.filter((b) => b.type === "text").map((b) => b.text).join("\n").trim();
126
+ return { text: text || null, truncated: msg.stop_reason === "max_tokens" };
127
+ }
128
+
129
+ const provider = {
130
+ name: "anthropic",
131
+ // Until resolved: the pin, or the fallback. Unpinned, the real value is the
132
+ // newest Sonnet the Models API lists (see modelResolver.js).
133
+ model: pinnedModel(config) ?? FALLBACK_SONNET,
134
+ modelSource: pinnedModel(config) ? "pinned" : "unresolved",
135
+ lastModelUsed: null,
136
+ available: () => !!process.env.ANTHROPIC_API_KEY,
137
+ async resolveModel() {
138
+ const c = await client();
139
+ if (!c) return { model: provider.model, source: provider.modelSource };
140
+ if (!resolved) {
141
+ resolved = await resolveAnthropicModel(c, { config });
142
+ provider.model = resolved.model;
143
+ provider.modelSource = resolved.source;
144
+ }
145
+ return resolved;
146
+ },
147
+ async complete(system, user, maxTokens = 1500, { signal } = {}) {
148
+ const c = await client();
149
+ if (!c) return null;
150
+ await provider.resolveModel();
151
+ try {
152
+ let result = await callOnce(c, { system, user, maxTokens, signal });
153
+ // Same bounded-retry-then-disclose policy as the OpenAI-compatible
154
+ // providers: a response cut off mid-answer must never be presented as
155
+ // if it were complete.
156
+ if (result.truncated && maxTokens < TRUNCATION_RETRY_CAP_TOKENS) {
157
+ result = await callOnce(c, { system, user, maxTokens: Math.min(maxTokens * 2, TRUNCATION_RETRY_CAP_TOKENS), signal });
158
+ }
159
+ if (!result.text) return null;
160
+ return result.truncated ? `${result.text}${TRUNCATION_NOTICE}` : result.text;
161
+ } catch (e) {
162
+ throw new Error(`anthropic provider error: ${redactForError(e.message)}`);
163
+ }
164
+ },
165
+ };
166
+ return provider;
167
+ }
168
+
169
+ export function openaiCompatProvider(config = {}) {
170
+ return createOpenAiCompatibleProvider({
171
+ name: "openai",
172
+ model: config.providers?.openai?.model || DEFAULT_OPENAI_MODEL,
173
+ apiKeyEnv: "OPENAI_API_KEY",
174
+ baseUrl: process.env.OPENAI_BASE_URL || "https://api.openai.com/v1",
175
+ });
176
+ }
177
+
178
+ /**
179
+ * Moonshot AI's Kimi models — an OpenAI-compatible chat/completions API, so
180
+ * this reuses the same shared client as openaiCompatProvider. Uses its own
181
+ * KIMI_API_KEY (not OPENAI_API_KEY) so a Kimi key and a real OpenAI key can
182
+ * both be configured in the same environment without colliding. KIMI_BASE_URL
183
+ * defaults to the global endpoint; override it for the China region
184
+ * (https://api.moonshot.cn/v1) if that's where your account is registered.
185
+ */
186
+ export function kimiProvider(config = {}) {
187
+ return createOpenAiCompatibleProvider({
188
+ name: "kimi",
189
+ model: config.providers?.kimi?.model || DEFAULT_KIMI_MODEL,
190
+ apiKeyEnv: "KIMI_API_KEY",
191
+ baseUrl: process.env.KIMI_BASE_URL || "https://api.moonshot.ai/v1",
192
+ });
193
+ }
194
+
195
+ export function nullProvider() {
196
+ return {
197
+ name: "none",
198
+ model: null,
199
+ available: () => false,
200
+ async complete() { return null; },
201
+ };
202
+ }
203
+
204
+ /**
205
+ * Select a provider from config: "auto" tries anthropic, then openai, then
206
+ * kimi, then null; an explicit name pins to that provider (falling back to
207
+ * null if unavailable).
208
+ */
209
+ export function getProvider(config = {}) {
210
+ const requested = config.chat?.provider ?? "auto";
211
+ const candidates = {
212
+ anthropic: anthropicProvider(config),
213
+ openai: openaiCompatProvider(config),
214
+ kimi: kimiProvider(config),
215
+ none: nullProvider(),
216
+ };
217
+ if (requested !== "auto") return candidates[requested] ?? nullProvider();
218
+ if (candidates.anthropic.available()) return candidates.anthropic;
219
+ if (candidates.openai.available()) return candidates.openai;
220
+ if (candidates.kimi.available()) return candidates.kimi;
221
+ return candidates.none;
222
+ }
@@ -0,0 +1,86 @@
1
+ /**
2
+ * commandRunner.js — safe dev-command execution for chat/mcp. Never executes
3
+ * arbitrary shell text: every call is (cmd, args[]) through execFile/spawn
4
+ * (argument arrays, no shell interpolation), classified through
5
+ * core/policy.js before it's allowed to run at all, with a controlled cwd,
6
+ * a timeout, an output-character cap, and secret redaction on the way out.
7
+ *
8
+ * Long-running ("networked") commands (e.g. a dev server) are tracked in
9
+ * `activeChildren` so `mapd chat end` can terminate them before the chat
10
+ * process exits — nothing spawned by a chat session is left running after
11
+ * the session ends.
12
+ */
13
+
14
+ import { execFile, execFileSync, spawn } from "node:child_process";
15
+ import { classifyCommand, isPermitted } from "../core/policy.js";
16
+ import { redactSecrets } from "../core/security.js";
17
+ import { platformCommand } from "../core/proc.js";
18
+
19
+ const activeChildren = new Set();
20
+
21
+ export function getActiveChildren() { return activeChildren; }
22
+
23
+ export function killActiveChildren() {
24
+ for (const child of activeChildren) {
25
+ try {
26
+ // on Windows the child is a shell wrapping npm/node: kill the whole tree, not just the shell
27
+ if (process.platform === "win32" && child.pid) execFileSync("taskkill", ["/pid", String(child.pid), "/T", "/F"], { stdio: "ignore" });
28
+ else child.kill("SIGTERM");
29
+ } catch { /* already exited */ }
30
+ }
31
+ activeChildren.clear();
32
+ }
33
+
34
+ /**
35
+ * Runs a classified, policy-checked command. Returns:
36
+ * { ok:false, denied:true, reason } — refused outright
37
+ * { ok, classification, exitCode, stdout, stderr } — completed
38
+ * { ok:true, classification:"networked", longRunning:true, pid } — dev server started
39
+ */
40
+ export async function runCommand(cmd, args, { cwd, config = {}, approved = false, timeoutMs = 120_000, maxOutputChars } = {}) {
41
+ const { classification, allowed, reason } = classifyCommand(cmd, args);
42
+ if (!allowed) return { ok: false, denied: true, reason };
43
+
44
+ const { permitted, requiresApproval } = isPermitted(classification, config, { approved });
45
+ if (!permitted) {
46
+ return {
47
+ ok: false, denied: true, classification, requiresApproval,
48
+ reason: `'${cmd} ${args.join(" ")}' is classified as '${classification}' and requires explicit approval`,
49
+ };
50
+ }
51
+
52
+ const outCap = maxOutputChars ?? config.chat?.maxCommandOutputCharacters ?? 30_000;
53
+ let pc;
54
+ try { pc = platformCommand(cmd, args, { cwd }); }
55
+ catch (e) { return { ok: false, denied: true, classification, reason: e.message }; }
56
+
57
+ if (classification === "networked") {
58
+ return new Promise((resolve) => {
59
+ const child = spawn(pc.file, pc.args, { ...pc.options, stdio: ["ignore", "pipe", "pipe"] });
60
+ activeChildren.add(child);
61
+ let out = "";
62
+ child.stdout?.on("data", (d) => { out += d; });
63
+ child.stderr?.on("data", (d) => { out += d; });
64
+ child.on("exit", () => activeChildren.delete(child));
65
+ child.on("error", () => activeChildren.delete(child));
66
+ setTimeout(() => resolve({
67
+ ok: true, classification, longRunning: true, pid: child.pid,
68
+ initialOutput: redactSecrets(out.slice(0, outCap)),
69
+ }), 300);
70
+ });
71
+ }
72
+
73
+ return new Promise((resolve) => {
74
+ const child = execFile(pc.file, pc.args, { ...pc.options, timeout: timeoutMs, maxBuffer: 10 * 1024 * 1024 }, (err, stdout, stderr) => {
75
+ activeChildren.delete(child);
76
+ resolve({
77
+ ok: !err, classification,
78
+ exitCode: err?.code ?? 0,
79
+ stdout: redactSecrets((stdout ?? "").toString().slice(0, outCap)),
80
+ stderr: redactSecrets((stderr ?? "").toString().slice(0, outCap)),
81
+ timedOut: !!(err?.killed && err?.signal === "SIGTERM"),
82
+ });
83
+ });
84
+ activeChildren.add(child);
85
+ });
86
+ }
@@ -0,0 +1,275 @@
1
+ /**
2
+ * commands.js — the slash-command table. Every handler calls the exact same
3
+ * core services cli.js uses — no duplicated business logic. Dependency
4
+ * injection (`deps`) keeps this testable without spawning a real CLI process.
5
+ */
6
+
7
+ import fs from "node:fs";
8
+ import path from "node:path";
9
+ import { buildScoredGraph, getWorkflowSummaries, getRepoStatusSummary, buildTaskContext } from "../core/intelligence.js";
10
+ import { resolveFile, traceFile, tracePath, renderTraceFile, renderTracePath } from "../core/trace.js";
11
+ import { analyzeResolution, renderResolution } from "../core/resolution.js";
12
+ import { saveBaseline, loadBaseline, diffGraphs, saveFindings } from "../core/regression.js";
13
+ import { renderDocs } from "../core/docs.js";
14
+ import { runModernizationScan, saveModernizationReport } from "../core/modernize.js";
15
+ import { loadPkg } from "../core/graph.js";
16
+ import { pending, loadQueueWithStates } from "../core/review.js";
17
+ import { buildFindingEvidence, renderFindingEvidence } from "../core/evidence.js";
18
+ import { buildHandoff, renderHandoffPrompt } from "../core/handoff.js";
19
+ import { buildSolutions, narrateSolutions, renderSolutions } from "../core/solutions.js";
20
+ import { buildDiagnosis, renderDiagnosis } from "../core/diagnose.js";
21
+ import { explainScore, ceilingScore, deltaScore, renderExplain, renderCeiling, renderDelta } from "../core/score.js";
22
+ import { analyzeTestCoverage, testGaps, testCredit, renderTestGaps, renderTestCredit } from "../core/testGuidance.js";
23
+ import { planImprovements, renderImprovePlan } from "../core/improve.js";
24
+ import { runVerify, renderVerify } from "../core/verify.js";
25
+ import { exportTranscriptMarkdown } from "../core/session.js";
26
+ import { bold, dim, red, green, yellow, cyan } from "../core/theme.js";
27
+
28
+ const themeHelpers = { bold, dim, red, green, yellow, cyan };
29
+
30
+ function formatMapSummary(g) {
31
+ const lines = [`files: ${g.stats.fileCount} loc: ${g.stats.totalLoc} workflows: ${g.workflows.length}`,
32
+ `call resolution: ${(g.stats.callResolutionRate * 100).toFixed(1)}% repo confidence: ${g.repoConfidence}`];
33
+ for (const wf of g.workflows) lines.push(` [${wf.confidence.score}] ${wf.id} (${wf.files.length} files)`);
34
+ if (g.orphans.length) lines.push(`orphans: ${g.orphans.join(", ")}`);
35
+ return lines.join("\n");
36
+ }
37
+
38
+ /**
39
+ * `deps`: { rootDir, config, session? }. Returns a table of
40
+ * `command -> async (args[]) -> { ok, text }`.
41
+ */
42
+ export function createCommandTable(deps) {
43
+ const abs = path.resolve(deps.rootDir);
44
+
45
+ return {
46
+ "/map": async () => {
47
+ const g = buildScoredGraph(abs);
48
+ return { ok: true, text: formatMapSummary(g) };
49
+ },
50
+
51
+ "/baseline": async () => {
52
+ const g = buildScoredGraph(abs);
53
+ const p = saveBaseline(abs, g);
54
+ return { ok: true, text: `Baseline saved → ${p} (repo confidence ${g.repoConfidence})` };
55
+ },
56
+
57
+ "/check": async () => {
58
+ const loaded = loadBaseline(abs);
59
+ if (!loaded) return { ok: false, text: "No baseline found. Run /baseline first." };
60
+ if (loaded.schemaMismatch) return { ok: false, text: "Baseline schema mismatch — run /baseline to re-snapshot." };
61
+ const current = buildScoredGraph(abs);
62
+ const findings = diffGraphs(loaded.graph, current);
63
+ const saved = saveFindings(abs, findings);
64
+ const resolvedNote = saved.resolvedNow ? `\n${saved.resolvedNow} previously-open finding(s) auto-resolved — not reproduced by this re-check.` : "";
65
+ if (!findings.length) return { ok: true, text: `No regressions. Repo confidence ${loaded.graph.repoConfidence} → ${current.repoConfidence}.${resolvedNote}` };
66
+ return { ok: true, text: findings.map((f) => `[${f.severity.toUpperCase()}] ${f.kind}: ${f.detail}`).join("\n") + resolvedNote };
67
+ },
68
+
69
+ "/docs": async () => {
70
+ const g = buildScoredGraph(abs);
71
+ const md = await renderDocs(g, { withNarration: false });
72
+ const out = path.join(abs, "MAP.md");
73
+ fs.writeFileSync(out, md);
74
+ return { ok: true, text: `Docs → ${out}` };
75
+ },
76
+
77
+ "/modernize": async (args = []) => {
78
+ const mode = ["light", "medium", "heavy"].includes(args[0]) ? args[0] : "medium";
79
+ const g = buildScoredGraph(abs);
80
+ const report = runModernizationScan(abs, g, loadPkg(abs), mode);
81
+ const freshCount = report.findings.length; // before the save merges in carried lifecycle entries
82
+ const saved = saveModernizationReport(abs, report);
83
+ const resolvedNote = saved.resolvedNow ? ` ${saved.resolvedNow} previously-open finding(s) auto-resolved.` : "";
84
+ return { ok: true, text: `${freshCount} modernization finding(s) (${mode} mode).${resolvedNote}` };
85
+ },
86
+
87
+ "/review": async () => {
88
+ const { items, freshness } = loadQueueWithStates(abs);
89
+ const open = pending(items);
90
+ if (!open.length) return { ok: true, text: "Queue is empty — nothing awaiting approval." };
91
+ const lines = open.map((i) => `${i.id} [${i.source}] (${i.state}) ${i.kind}: ${i.detail}`);
92
+ if (freshness.stale && open.some((i) => i.state === "stale")) {
93
+ lines.push(`\n⚠ items marked (stale) come from ${freshness.staleReports.join(", ")}, which predate a newer source change — refresh with /check or /modernize before acting.`);
94
+ }
95
+ return { ok: true, text: lines.join("\n") };
96
+ },
97
+
98
+ "/findings": async () => {
99
+ const { items, freshness } = loadQueueWithStates(abs);
100
+ const open = pending(items).filter((i) => i.source === "check" || i.source.startsWith("modernize-"));
101
+ if (!open.length) return { ok: true, text: "No open findings." };
102
+ const lines = open.map((i) => `${i.id} [${i.source}] (${i.state}) ${i.kind}: ${i.detail}`);
103
+ if (freshness.stale && open.some((i) => i.state === "stale")) {
104
+ lines.push(`\n⚠ items marked (stale) come from ${freshness.staleReports.join(", ")}, which predate a newer source change — refresh with /check or /modernize before acting.`);
105
+ }
106
+ return { ok: true, text: lines.join("\n") };
107
+ },
108
+
109
+ "/evidence": async (args = []) => {
110
+ const id = args[0];
111
+ if (!id) return { ok: false, text: "Usage: /evidence <finding-id> — list IDs with /findings or /review." };
112
+ const data = buildFindingEvidence(abs, id);
113
+ if (!data) return { ok: false, text: `No item with ID ${id}. List current IDs with /review.` };
114
+ return { ok: true, text: renderFindingEvidence(data) };
115
+ },
116
+
117
+ "/project": async () => {
118
+ const g = buildScoredGraph(abs);
119
+ const summary = getRepoStatusSummary(g);
120
+ return {
121
+ ok: true,
122
+ text: `root: ${abs}\nfiles: ${summary.fileCount} workflows: ${summary.workflowCount} confidence: ${summary.repoConfidence}\nworkflows:\n` +
123
+ getWorkflowSummaries(g).map((w) => ` [${w.confidence}] ${w.id} (${w.fileCount} files)`).join("\n"),
124
+ };
125
+ },
126
+
127
+ "/context": async () => {
128
+ const session = deps.session;
129
+ if (!session) return { ok: true, text: "No active session." };
130
+ const lines = [
131
+ `${session.turns.length} turn(s) in this session.`,
132
+ `files inspected: ${session.filesInspected.size ? [...session.filesInspected].join(", ") : "none yet"}`,
133
+ `findings discussed: ${session.findingsDiscussed.size ? [...session.findingsDiscussed].join(", ") : "none yet"}`,
134
+ `commands executed: ${session.commandsExecuted.length ? session.commandsExecuted.map((c) => c.label).join(", ") : "none yet"}`,
135
+ `patches proposed: ${session.patchesProposed.length ? session.patchesProposed.map((p) => `${p.id} (${p.status})`).join(", ") : "none yet"}`,
136
+ `patches applied: ${session.patchesApplied.length ? session.patchesApplied.map((p) => p.id).join(", ") : "none yet"}`,
137
+ ];
138
+ return { ok: true, text: lines.join("\n") };
139
+ },
140
+
141
+ "/status": async () => {
142
+ const g = buildScoredGraph(abs);
143
+ const loaded = loadBaseline(abs);
144
+ const { items } = loadQueueWithStates(abs);
145
+ const open = pending(items);
146
+ const staleCount = open.filter((i) => i.state === "stale").length;
147
+ // ceiling-awareness: chat should proactively disclose the honest maximum,
148
+ // never let a reader assume 1.0 is reachable when it structurally isn't.
149
+ const ceiling = ceilingScore(abs, g);
150
+ const ceilingNote = ceiling.ceilingSignalCoverage < 1
151
+ ? ` — 1.0 is not honestly reachable (signalCoverage caps at ${ceiling.ceilingSignalCoverage}: ${ceiling.caps.map((c) => c.cap).join(", ")})`
152
+ : "";
153
+ return {
154
+ ok: true,
155
+ text: `repo confidence: ${g.repoConfidence} (honest ceiling ${ceiling.ceiling}${ceilingNote})\nbaseline: ${loaded ? (loaded.schemaMismatch ? "schema mismatch — re-run /baseline" : "present") : "none"}\nopen findings: ${open.length}${staleCount ? ` (${staleCount} from stale report(s) — refresh with /check or /modernize)` : ""}\ntip: /improve for a ranked plan, /score explain for the breakdown`,
156
+ };
157
+ },
158
+
159
+ "/score": async (args = []) => {
160
+ const sub = (args[0] || "explain").toLowerCase();
161
+ const g = buildScoredGraph(abs);
162
+ if (sub === "ceiling") return { ok: true, text: renderCeiling(ceilingScore(abs, g)) };
163
+ if (sub === "delta") {
164
+ const loaded = loadBaseline(abs);
165
+ if (!loaded) return { ok: false, text: "No baseline found. Run /baseline first." };
166
+ if (loaded.schemaMismatch) return { ok: false, text: "Baseline schema mismatch — run /baseline to re-snapshot." };
167
+ return { ok: true, text: renderDelta(deltaScore(loaded.graph, g)) };
168
+ }
169
+ return { ok: true, text: renderExplain(explainScore(g), { workflow: sub !== "explain" ? args[0] : undefined }) };
170
+ },
171
+
172
+ "/ceiling": async () => {
173
+ const g = buildScoredGraph(abs);
174
+ return { ok: true, text: renderCeiling(ceilingScore(abs, g)) };
175
+ },
176
+
177
+ "/test-gaps": async (args = []) => {
178
+ const analysis = analyzeTestCoverage(abs, buildScoredGraph(abs));
179
+ const gaps = testGaps(analysis, { includeShallow: args.includes("--shallow") });
180
+ return { ok: true, text: renderTestGaps(analysis, gaps, themeHelpers) };
181
+ },
182
+
183
+ "/test-credit": async (args = []) => {
184
+ const paddingOnly = args.includes("--padding");
185
+ const analysis = analyzeTestCoverage(abs, buildScoredGraph(abs));
186
+ return { ok: true, text: renderTestCredit(testCredit(analysis, { paddingOnly }), themeHelpers, { paddingOnly }) };
187
+ },
188
+
189
+ "/improve": async (args = []) => {
190
+ let budget, risk = "low";
191
+ for (let i = 0; i < args.length; i++) {
192
+ if (args[i] === "--budget") budget = args[++i];
193
+ else if (args[i] === "--risk") risk = args[++i];
194
+ }
195
+ return { ok: true, text: renderImprovePlan(planImprovements(abs, { budget, risk })) };
196
+ },
197
+
198
+ "/verify": async (args = []) => {
199
+ return { ok: true, text: renderVerify(runVerify(abs, { strict: args.includes("--strict") })) };
200
+ },
201
+
202
+ "/transcript": async (args = []) => {
203
+ const session = deps.session;
204
+ if (!session) return { ok: false, text: "No active session to save." };
205
+ const md = exportTranscriptMarkdown(session);
206
+ const out = args[0] ? path.resolve(abs, args[0]) : path.join(abs, ".mapd", "sessions", `${session.id}.md`);
207
+ fs.mkdirSync(path.dirname(out), { recursive: true });
208
+ fs.writeFileSync(out, md);
209
+ return { ok: true, text: `Transcript (${session.turns.length} turn(s)) → ${out}` };
210
+ },
211
+
212
+ "/diagnose": async (args = []) => {
213
+ const top = Number.parseInt(args[0], 10) || 5;
214
+ return { ok: true, text: renderDiagnosis(buildDiagnosis(abs, { top })) };
215
+ },
216
+
217
+ "/handoff": async (args = []) => {
218
+ const top = Number.parseInt(args[0], 10) || 5;
219
+ const data = buildHandoff(abs, { top });
220
+ return { ok: true, text: renderHandoffPrompt(data) };
221
+ },
222
+
223
+ "/solutions": async (args = []) => {
224
+ const top = Number.parseInt(args[0], 10) || 5;
225
+ let data = buildSolutions(abs, { top });
226
+ // chat already has a provider in hand when one's configured — narrate
227
+ // automatically here, unlike the CLI's opt-in --narrate (no surprise
228
+ // network calls from a scripted `mapd solutions` invocation).
229
+ if (deps.provider) data = await narrateSolutions(data, deps.provider, { graph: buildScoredGraph(abs) });
230
+ return { ok: true, text: renderSolutions(data) };
231
+ },
232
+
233
+ "/trace": async (args = []) => {
234
+ const [fileArg, toArg] = args;
235
+ if (!fileArg) return { ok: false, text: "Usage: /trace <file> [to-file] — explain why a file is in/out of a workflow, or the chain connecting two files." };
236
+ const g = buildScoredGraph(abs);
237
+ const from = resolveFile(g, fileArg);
238
+ if (from.notFound) return { ok: false, text: `No file matching "${fileArg}". List files with /map.` };
239
+ if (from.ambiguous) return { ok: false, text: `"${fileArg}" is ambiguous — matches: ${from.ambiguous.join(", ")}` };
240
+ if (toArg) {
241
+ const target = resolveFile(g, toArg);
242
+ if (target.notFound) return { ok: false, text: `No file matching "${toArg}".` };
243
+ if (target.ambiguous) return { ok: false, text: `"${toArg}" is ambiguous — matches: ${target.ambiguous.join(", ")}` };
244
+ return { ok: true, text: renderTracePath(tracePath(g, from.file, target.file), themeHelpers) };
245
+ }
246
+ return { ok: true, text: renderTraceFile(traceFile(g, from.file), themeHelpers) };
247
+ },
248
+
249
+ "/resolution": async (args = []) => {
250
+ const top = Number.parseInt(args[0], 10) || 10;
251
+ const g = buildScoredGraph(abs);
252
+ return { ok: true, text: renderResolution(analyzeResolution(g, { top }), themeHelpers) };
253
+ },
254
+
255
+ "/find": async (args = []) => {
256
+ const query = args.join(" ").trim();
257
+ if (!query) return { ok: false, text: "Usage: /find <query> — ranked symbol/file hits for a task or question." };
258
+ const g = buildScoredGraph(abs);
259
+ const data = buildTaskContext(g, query, { maxHits: 8, maxFiles: 8 });
260
+ if (!data.hits.length && !data.files.length) return { ok: true, text: `No symbol or file matches for "${query}".` };
261
+ const lines = [];
262
+ if (data.hits.length) lines.push("Top hits:", ...data.hits.map((h) => ` ${h.file}#${h.function} (score ${h.score})`));
263
+ if (data.files.length) lines.push("Relevant files:", ...data.files.map((f) => ` ${f.file} (${f.loc} LOC)`));
264
+ if (data.workflows.length) lines.push("Matched workflows:", ...data.workflows.map((w) => ` ${w.id}`));
265
+ return { ok: true, text: lines.join("\n") };
266
+ },
267
+
268
+ "/help": async () => ({
269
+ ok: true,
270
+ text: "Slash commands: /map /baseline /check /docs /modernize /review /findings /evidence <id> /project /context /status /diagnose /handoff /solutions\n" +
271
+ " /score [explain|ceiling|delta] /ceiling /trace <file> [to] /resolution /find <query> /test-gaps /test-credit [--padding] /improve [--budget 2h --risk low] /verify [--strict] /transcript [file] /help /clear /end\n" +
272
+ "Natural language also works: \"what's the honest ceiling\", \"what should I work on\", \"show test gaps\", \"is the project passing\", \"trace <file>\", \"what's dragging down the resolution rate\", \"find code related to <topic>\", \"approve finding <id>\", \"fix the highest-severity finding\".",
273
+ }),
274
+ };
275
+ }