@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.
- package/LICENSE +21 -0
- package/MASTER_PROMPT.md +134 -0
- package/README.md +494 -0
- package/SETUP.md +108 -0
- package/UAT.md +77 -0
- package/package.json +56 -0
- package/src/adapters/github-app.js +79 -0
- package/src/agents/anthropicClient.js +18 -0
- package/src/agents/llm.js +196 -0
- package/src/agents/modelResolver.js +87 -0
- package/src/agents/provider.js +222 -0
- package/src/chat/commandRunner.js +86 -0
- package/src/chat/commands.js +275 -0
- package/src/chat/intent.js +87 -0
- package/src/chat/llmIntent.js +118 -0
- package/src/chat/repl.js +471 -0
- package/src/cli.js +1408 -0
- package/src/config/index.js +197 -0
- package/src/config/schema.js +119 -0
- package/src/core/assist.js +64 -0
- package/src/core/audit.js +63 -0
- package/src/core/changes.js +110 -0
- package/src/core/confidence.js +0 -0
- package/src/core/configLint.js +141 -0
- package/src/core/diagnose.js +262 -0
- package/src/core/docs.js +140 -0
- package/src/core/doctor.js +134 -0
- package/src/core/envFiles.js +43 -0
- package/src/core/events.js +53 -0
- package/src/core/evidence.js +212 -0
- package/src/core/findingScoring.js +20 -0
- package/src/core/fix.js +192 -0
- package/src/core/fixApply.js +172 -0
- package/src/core/frameworkEntries.js +247 -0
- package/src/core/gates.js +209 -0
- package/src/core/graph.js +467 -0
- package/src/core/grounding.js +235 -0
- package/src/core/handoff.js +157 -0
- package/src/core/importResolver.js +218 -0
- package/src/core/improve.js +226 -0
- package/src/core/integrate.js +169 -0
- package/src/core/intelligence.js +212 -0
- package/src/core/modernize.js +370 -0
- package/src/core/parseCache.js +64 -0
- package/src/core/parser.js +536 -0
- package/src/core/policy.js +65 -0
- package/src/core/polyglot.js +333 -0
- package/src/core/proc.js +25 -0
- package/src/core/reachability.js +543 -0
- package/src/core/regression.js +193 -0
- package/src/core/resolution.js +92 -0
- package/src/core/retry.js +61 -0
- package/src/core/review.js +219 -0
- package/src/core/score.js +338 -0
- package/src/core/security.js +0 -0
- package/src/core/session.js +143 -0
- package/src/core/solutions.js +254 -0
- package/src/core/staleness.js +45 -0
- package/src/core/testGuidance.js +226 -0
- package/src/core/theme.js +50 -0
- package/src/core/trace.js +151 -0
- package/src/core/verify.js +123 -0
- package/src/core/view.js +221 -0
- package/src/core/viewServer.js +88 -0
- package/src/core/watch.js +76 -0
- package/src/core/workspace.js +115 -0
- package/src/mcp/server.js +48 -0
- package/src/mcp/tools.js +423 -0
- 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
|
+
}
|