@esneiderbravo/speclaw 0.3.13 → 0.4.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 (37) hide show
  1. package/README.md +2 -2
  2. package/dist/cli/commands/query.js +20 -0
  3. package/dist/cli/commands/update.js +7 -5
  4. package/dist/cli/index.js +2 -0
  5. package/dist/modules/compass/diff-context.js +134 -0
  6. package/dist/modules/compass/explore-rich.js +129 -0
  7. package/dist/modules/compass/impact-summary.js +33 -0
  8. package/dist/modules/compass/register.js +164 -74
  9. package/dist/modules/foundation/context-budget.js +1 -14
  10. package/dist/modules/foundation/doctor.js +46 -0
  11. package/dist/modules/foundation/register-core.js +57 -88
  12. package/dist/modules/foundation/register.js +1 -21
  13. package/dist/modules/foundation/setup-tool.js +96 -0
  14. package/dist/modules/lawbook/assets/commands/archive.md +1 -1
  15. package/dist/modules/lawbook/assets/commands/draft.md +1 -1
  16. package/dist/modules/lawbook/assets/commands/explore.md +1 -1
  17. package/dist/modules/lawbook/assets/commands/sync.md +2 -2
  18. package/dist/modules/lawbook/assets/skills/archive/SKILL.md +1 -1
  19. package/dist/modules/lawbook/assets/skills/archive/steps/03-validate-and-sync.md +3 -3
  20. package/dist/modules/lawbook/assets/skills/archive/steps/04-archive.md +1 -1
  21. package/dist/modules/lawbook/assets/skills/draft/steps/02-understand.md +1 -1
  22. package/dist/modules/lawbook/assets/skills/draft/steps/05-validate.md +1 -1
  23. package/dist/modules/lawbook/assets/skills/explore/steps/01-investigate.md +1 -1
  24. package/dist/modules/lawbook/assets/skills/quick/steps/02-implement.md +1 -1
  25. package/dist/modules/lawbook/assets/skills/sync/SKILL.md +1 -1
  26. package/dist/modules/lawbook/assets/skills/sync/steps/03-validate.md +1 -1
  27. package/dist/modules/lawbook/assets/skills/sync/steps/04-promote.md +1 -1
  28. package/dist/modules/lawbook/change-tool.js +90 -0
  29. package/dist/modules/lawbook/register.js +96 -54
  30. package/dist/modules/tools/register.js +4 -26
  31. package/dist/shared/deprecation.js +99 -0
  32. package/dist/shared/exposure.js +4 -19
  33. package/dist/shared/git.js +25 -0
  34. package/dist/shared/mcp.js +29 -3
  35. package/dist/shared/output-budget.js +68 -0
  36. package/dist/shared/tool-catalog.js +49 -0
  37. package/package.json +1 -1
package/README.md CHANGED
@@ -123,7 +123,7 @@ tokenizer on this corpus — not a BPE dependency):
123
123
 
124
124
  | | Tokens |
125
125
  | :-- | --: |
126
- | **speclaw budget (always-on)** | **~11.7k** (budget ceiling **13.0k**) |
126
+ | **speclaw budget (always-on)** | **~12.9k** (8 MCP tools · ceiling **13.0k**) |
127
127
  | Spec Kit commands alone | ~18.6k ([spec-kit#1401](https://github.com/github/spec-kit/issues/1401)) |
128
128
 
129
129
  ```bash
@@ -184,7 +184,7 @@ Plus a **`reports/`** folder — scaffolded at draft, filled at build with one r
184
184
  **Three ways to drive it — same engine, no external CLI:**
185
185
 
186
186
  - **In your agent** — the `/lawbook:explore`, `/lawbook:draft`, `/lawbook:build`, `/lawbook:sync`, `/lawbook:archive` commands (installed as skills).
187
- - **MCP tools** — `lawbook_init`, `lawbook_validate`, `lawbook_sync`, `lawbook_archive`, `lawbook_list`.
187
+ - **MCP tools** — eight canonical tools: `compass_explore`, `compass_find`, `compass_diff_context`, `compass_index`, `lawbook_change`, `lawbook_investigate`, `speclaw_setup`, `speclaw_check`.
188
188
  - **CLI** — `speclaw lawbook init | list | validate | sync | archive`.
189
189
 
190
190
  The workspace is committed under `lawbook/`: `specs/` (canonical), `changes/`
@@ -1,6 +1,7 @@
1
1
  import { explore, search, recall, impact, trace } from "../../modules/compass/query.js";
2
2
  import { affectedTests } from "../../modules/compass/affected.js";
3
3
  import { hotspots, coupling } from "../../modules/compass/hotspots.js";
4
+ import { diffContext, formatDiffContext } from "../../modules/compass/diff-context.js";
4
5
  import { list } from "../lib/args.js";
5
6
  import { ui } from "../lib/ui.js";
6
7
  /**
@@ -161,6 +162,25 @@ export async function runQuery(cmd, flags) {
161
162
  result.warnings.forEach((w) => ui.warn(w));
162
163
  return;
163
164
  }
165
+ case "diff-context": {
166
+ const files = list(flags.file);
167
+ const rev = typeof flags.rev === "string" ? flags.rev : undefined;
168
+ if (files.length === 0 && !rev && !flags.worktree) {
169
+ need(undefined, "diff-context [--file <path>...] [--rev <ref>] [--worktree]");
170
+ }
171
+ const result = diffContext({
172
+ projectPath: cwd,
173
+ rev: flags.worktree ? "WORKTREE" : rev,
174
+ paths: files.length ? files : undefined,
175
+ mode: flags.full ? "full" : "brief",
176
+ });
177
+ if (asJson) {
178
+ console.log(JSON.stringify(result, null, 2));
179
+ return;
180
+ }
181
+ console.log(formatDiffContext(result));
182
+ return;
183
+ }
164
184
  case "trace": {
165
185
  const r = trace(cwd, need(args[0], "trace <from> <to>"), need(args[1], "trace <from> <to>"));
166
186
  ui.heading(`Trace ${r.from} → ${r.to}`);
@@ -136,11 +136,13 @@ const MIGRATIONS = [
136
136
  "- Preserve all project-specific wording; only apply these speclaw-authored changes.",
137
137
  },
138
138
  {
139
- version: "0.3.13",
140
- describe: "Bugfix specsdraft --bug, bugfix.md, lawbook_investigate",
141
- agentPrompt: "- Mention bug changes: `speclaw lawbook draft --bug`, `bugfix.md` (repro + regression + prevention), " +
142
- "and `lawbook_investigate` / the investigate skill for graph-backed RCA. Feature ceremony unchanged; " +
143
- "`changeType: bug` in `change.json`. Security-withheld mode is not in this release.\n" +
139
+ version: "0.4.0",
140
+ describe: "Tool surfaceeight canonical MCP tools, aliases deprecated",
141
+ agentPrompt: "- MCP exposes eight canonical tools: `compass_explore`, `compass_find`, `compass_diff_context`, " +
142
+ "`compass_index`, `lawbook_change`, `lawbook_investigate`, `speclaw_setup`, `speclaw_check`. " +
143
+ "Retired names (e.g. `compass_search`, `lawbook_validate`, `init_project`) are aliases with " +
144
+ "`[deprecated]` responses — prefer the canonical names. `scaffold`, `doctor`, `compass_visualize`, " +
145
+ "and `law_verify` are CLI-only. Minimal profile omits setup/check/investigate/index.\n" +
144
146
  "- Preserve all project-specific wording; only apply these speclaw-authored changes.",
145
147
  },
146
148
  ];
package/dist/cli/index.js CHANGED
@@ -25,6 +25,7 @@ Compass (code intelligence — the same surface agents use via MCP)
25
25
  recall "<query>" Find code by meaning (semantic)
26
26
  impact <node> Blast radius (grouped by module; --flat / --json)
27
27
  affected-tests Tests affected by a change (--file / --from-diff / --json)
28
+ diff-context Change context for a diff (--file / --rev / --worktree / --json)
28
29
  hotspots Rank files by recent churn × AST complexity (--json / --sort)
29
30
  coupling <file> Temporal co-change partners for a file (--json)
30
31
  trace <from> <to> A call path between two nodes
@@ -144,6 +145,7 @@ async function dispatch(cmd, flags) {
144
145
  case "impact":
145
146
  case "trace":
146
147
  case "affected-tests":
148
+ case "diff-context":
147
149
  case "hotspots":
148
150
  case "coupling":
149
151
  return (await import("./commands/query.js")).runQuery(cmd, flags);
@@ -0,0 +1,134 @@
1
+ import { isGitRepo, changedFiles, worktreeChangedFiles } from "../../shared/git.js";
2
+ import { openDb, indexExists } from "./db.js";
3
+ import { impact } from "./query.js";
4
+ import { affectedTests } from "./affected.js";
5
+ import { hotspots } from "./hotspots.js";
6
+ import { summarizeImpact } from "./impact-summary.js";
7
+ import { applyTextBudget, } from "../../shared/output-budget.js";
8
+ function listWorktreeChanges(projectPath) {
9
+ const wt = worktreeChangedFiles(projectPath);
10
+ if (wt.length > 0)
11
+ return wt;
12
+ for (const base of ["main", "master"]) {
13
+ const files = changedFiles(projectPath, base);
14
+ if (files.length > 0)
15
+ return files;
16
+ }
17
+ return [];
18
+ }
19
+ function symbolsForFiles(projectPath, files, overestimate) {
20
+ if (!indexExists(projectPath))
21
+ return [];
22
+ const db = openDb(projectPath);
23
+ try {
24
+ const out = [];
25
+ for (const file of files) {
26
+ const nodes = db
27
+ .prepare(`SELECT n.name, n.kind, n.start_line AS line, f.path AS file
28
+ FROM nodes n JOIN files f ON f.id = n.file_id
29
+ WHERE f.path = ?`)
30
+ .all(file);
31
+ if (overestimate || nodes.length === 0) {
32
+ for (const n of nodes)
33
+ out.push(n);
34
+ }
35
+ else {
36
+ for (const n of nodes)
37
+ out.push(n);
38
+ }
39
+ }
40
+ return out;
41
+ }
42
+ finally {
43
+ db.close();
44
+ }
45
+ }
46
+ /**
47
+ * Graph context for a set of changed files (git rev, working tree, or explicit paths).
48
+ *
49
+ * @param query - Diff scope and output mode.
50
+ */
51
+ export function diffContext(query) {
52
+ const mode = query.mode ?? "brief";
53
+ const truncated = [];
54
+ let files = [...(query.paths ?? [])];
55
+ let message;
56
+ if (files.length === 0) {
57
+ if (!isGitRepo(query.projectPath)) {
58
+ throw new Error("not a git repository — pass `paths` explicitly to compass_diff_context");
59
+ }
60
+ files =
61
+ query.rev && query.rev !== "WORKTREE"
62
+ ? changedFiles(query.projectPath, query.rev)
63
+ : listWorktreeChanges(query.projectPath);
64
+ }
65
+ if (files.length === 0) {
66
+ return {
67
+ changedFiles: [],
68
+ changedSymbols: [],
69
+ message: "no changed files in scope",
70
+ truncated,
71
+ };
72
+ }
73
+ if (files.length > 50) {
74
+ message = `diff touches ${files.length} files — returning aggregated blast radius only; pass paths to narrow`;
75
+ files = files.slice(0, 50);
76
+ }
77
+ const overestimate = Boolean(query.paths?.length && !query.rev);
78
+ if (overestimate) {
79
+ message = "symbol set is an overestimate (paths without hunks — all nodes in those files)";
80
+ }
81
+ const changedSymbols = symbolsForFiles(query.projectPath, files, overestimate);
82
+ const result = { changedFiles: files, changedSymbols, truncated, message };
83
+ try {
84
+ const imp = impact(query.projectPath, {
85
+ files,
86
+ maxDepth: query.maxDepth ?? 4,
87
+ format: "grouped",
88
+ });
89
+ result.blastRadius = summarizeImpact(imp);
90
+ }
91
+ catch {
92
+ /* no index */
93
+ }
94
+ try {
95
+ const at = affectedTests(query.projectPath, {
96
+ files,
97
+ fromDiff: query.rev === "WORKTREE" || !query.rev ? "WORKTREE" : query.rev,
98
+ maxDepth: query.maxDepth ?? 6,
99
+ });
100
+ result.affectedTests = {
101
+ tests: at.tests,
102
+ command: at.command,
103
+ mode: at.mode,
104
+ reason: at.reason,
105
+ };
106
+ }
107
+ catch {
108
+ /* degrade */
109
+ }
110
+ try {
111
+ const hs = hotspots(query.projectPath, { sortBy: "combined", limit: 200 });
112
+ const touched = hs.hotspots.filter((h) => files.includes(h.file));
113
+ result.hotspotsTouched = touched.slice(0, 10).map((h) => ({
114
+ file: h.file,
115
+ combinedScore: h.combinedScore,
116
+ }));
117
+ }
118
+ catch {
119
+ /* degrade */
120
+ }
121
+ const json = applyTextBudget(JSON.stringify(result, null, 2), mode);
122
+ if (json.truncated) {
123
+ truncated.push({
124
+ field: "response",
125
+ omitted: json.omittedChars,
126
+ hint: 'use mode:"full" or pass explicit paths',
127
+ });
128
+ }
129
+ return result;
130
+ }
131
+ /** Format diff context with output budget. */
132
+ export function formatDiffContext(result, mode = "brief") {
133
+ return applyTextBudget(JSON.stringify(result, null, 2), mode).text;
134
+ }
@@ -0,0 +1,129 @@
1
+ import { explore, impact, trace, search, recall } from "./query.js";
2
+ import { affectedTests } from "./affected.js";
3
+ import { hotspots } from "./hotspots.js";
4
+ import { summarizeImpact } from "./impact-summary.js";
5
+ import { budgetExploreShape, applyTextBudget, } from "../../shared/output-budget.js";
6
+ const DEFAULT_INCLUDES = [
7
+ "source",
8
+ "callers",
9
+ "callees",
10
+ "blast_radius",
11
+ "tests",
12
+ ];
13
+ function withoutSource(symbol) {
14
+ const { source: _omit, ...rest } = symbol;
15
+ return { ...rest, source: "" };
16
+ }
17
+ /**
18
+ * Enriched symbol context: explore plus optional blast radius, tests, hotspot,
19
+ * and call path when `to` is set.
20
+ *
21
+ * @param query - Project path, symbol, includes, and output mode.
22
+ */
23
+ export async function exploreRich(query) {
24
+ const includes = query.include ?? DEFAULT_INCLUDES;
25
+ const mode = query.mode ?? "brief";
26
+ const truncated = [];
27
+ const degraded = [];
28
+ if (query.to) {
29
+ const pathResult = trace(query.projectPath, query.node, query.to, query.maxDepth ?? 8);
30
+ const base = explore(query.projectPath, query.node);
31
+ const out = {
32
+ ...base,
33
+ path: pathResult.path,
34
+ truncated,
35
+ degraded,
36
+ message: pathResult.path
37
+ ? `Call path ${query.node} → ${query.to} (${pathResult.hops} hop(s))`
38
+ : `No call path found within depth limit`,
39
+ };
40
+ if (!includes.includes("source") && out.symbol)
41
+ out.symbol = withoutSource(out.symbol);
42
+ if (!includes.includes("callers"))
43
+ out.callers = [];
44
+ if (!includes.includes("callees"))
45
+ out.callees = [];
46
+ budgetExploreShape(out, mode, truncated);
47
+ return out;
48
+ }
49
+ const base = explore(query.projectPath, query.node);
50
+ const out = { ...base, truncated, degraded };
51
+ if (!includes.includes("source") && out.symbol)
52
+ out.symbol = withoutSource(out.symbol);
53
+ if (!includes.includes("callers"))
54
+ out.callers = [];
55
+ if (!includes.includes("callees"))
56
+ out.callees = [];
57
+ if (base.found && base.symbol) {
58
+ const sym = base.symbol.name;
59
+ const file = base.symbol.file;
60
+ if (includes.includes("blast_radius")) {
61
+ try {
62
+ const imp = impact(query.projectPath, {
63
+ symbol: sym,
64
+ maxDepth: query.maxDepth ?? 4,
65
+ format: "grouped",
66
+ });
67
+ out.blastRadius = summarizeImpact(imp);
68
+ }
69
+ catch {
70
+ degraded.push("no-index");
71
+ }
72
+ }
73
+ if (includes.includes("tests")) {
74
+ try {
75
+ const at = affectedTests(query.projectPath, {
76
+ symbols: [sym],
77
+ maxDepth: query.maxDepth ?? 6,
78
+ });
79
+ out.affectedTests = {
80
+ count: at.tests.length,
81
+ files: at.tests.map((t) => t.file),
82
+ command: at.command,
83
+ };
84
+ }
85
+ catch {
86
+ degraded.push("no-tests-data");
87
+ }
88
+ }
89
+ if (includes.includes("hotspot")) {
90
+ try {
91
+ const hs = hotspots(query.projectPath, { sortBy: "combined", limit: 200 });
92
+ const idx = hs.hotspots.findIndex((h) => h.file === file);
93
+ if (idx >= 0) {
94
+ const h = hs.hotspots[idx];
95
+ out.hotspot = {
96
+ file: h.file,
97
+ combinedScore: h.combinedScore,
98
+ churn: h.activity.commits,
99
+ complexity: h.health?.worstLoc ?? 0,
100
+ rank: idx + 1,
101
+ };
102
+ }
103
+ else {
104
+ degraded.push("no-hotspots");
105
+ }
106
+ }
107
+ catch {
108
+ degraded.push("no-hotspots");
109
+ }
110
+ }
111
+ }
112
+ budgetExploreShape(out, mode, truncated);
113
+ if (truncated.length === 0)
114
+ delete out.truncated;
115
+ if (degraded.length === 0)
116
+ delete out.degraded;
117
+ return out;
118
+ }
119
+ /** Merge lexical and semantic search behind one surface. */
120
+ export async function findSymbols(projectPath, query, mode, limit) {
121
+ if (mode === "exact")
122
+ return search(projectPath, query, limit ?? 25);
123
+ return recall(projectPath, query, limit ?? 15);
124
+ }
125
+ /** Serialize explore-rich with output budget applied. */
126
+ export function formatExploreRich(result, mode = "brief") {
127
+ const json = JSON.stringify(result, null, 2);
128
+ return applyTextBudget(json, mode).text;
129
+ }
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Convert a grouped {@link ImpactResult} into a compact blast-radius summary.
3
+ *
4
+ * @param impact - Grouped impact output from Compass.
5
+ */
6
+ export function summarizeImpact(impact) {
7
+ const topNodes = [];
8
+ for (const mod of impact.modules) {
9
+ for (const n of mod.top) {
10
+ topNodes.push({ name: n.name, file: n.file, depth: n.depth });
11
+ if (topNodes.length >= 15)
12
+ break;
13
+ }
14
+ if (topNodes.length >= 15)
15
+ break;
16
+ }
17
+ const reachesPublicApi = impact.modules.some((m) => m.module.includes("public") || m.module === "api" || m.module.endsWith("/api"));
18
+ return {
19
+ totalNodes: impact.totals.nodes,
20
+ totalFiles: impact.totals.files,
21
+ totalModules: impact.totals.modules,
22
+ byModule: impact.modules.map((m) => ({
23
+ module: m.module,
24
+ nodes: m.nodes,
25
+ files: m.files,
26
+ minDepth: m.minDepth,
27
+ })),
28
+ reachesPublicApi,
29
+ maxDepthReached: impact.limits.maxDepthReached,
30
+ truncated: impact.limits.truncated,
31
+ topNodes,
32
+ };
33
+ }
@@ -1,19 +1,17 @@
1
1
  import { z } from "zod";
2
- import { defineTool, text } from "../../shared/mcp.js";
2
+ import { defineTool, defineAliasTool, text } from "../../shared/mcp.js";
3
3
  import { shouldExpose } from "../../shared/exposure.js";
4
+ import { aliasesEnabled } from "../../shared/tool-catalog.js";
5
+ import { logDeprecatedCall, prefixDeprecated } from "../../shared/deprecation.js";
4
6
  import { buildIndex } from "./indexer.js";
5
- import { explore, search, recall, impact, trace } from "./query.js";
7
+ import { impact } from "./query.js";
6
8
  import { affectedTests } from "./affected.js";
7
9
  import { hotspots, coupling } from "./hotspots.js";
8
10
  import { startWatch, stopWatch, watchStatus } from "./watcher.js";
9
- import { visualize } from "./visualize.js";
10
- // ─── Compass: speclaw's own code-intelligence engine (no external deps) ───
11
- /**
12
- * Register Compass MCP tools on the given server.
13
- *
14
- * @param server - The MCP server to register on.
15
- * @param opts - Exposure options (`minimal` omits setup/specialized tools).
16
- */
11
+ import { exploreRich, findSymbols, formatExploreRich, } from "./explore-rich.js";
12
+ import { diffContext, formatDiffContext } from "./diff-context.js";
13
+ const includeEnum = z.array(z.enum(["source", "callers", "callees", "blast_radius", "tests", "hotspot"]));
14
+ /** Register Compass MCP tools on the given server. */
17
15
  export function registerCompass(server, opts = {}) {
18
16
  const minimal = Boolean(opts.minimal);
19
17
  const add = (name, description, inputSchema, handler) => {
@@ -21,78 +19,170 @@ export function registerCompass(server, opts = {}) {
21
19
  return;
22
20
  defineTool(server, { name, description, inputSchema, handler });
23
21
  };
24
- add("compass_index", "Build or refresh the local code graph index. Run once per project, then on demand.", { projectPath: z.string() }, async ({ projectPath }) => text(await buildIndex(projectPath)));
25
- add("compass_explore", "Read a symbol's source plus callers and callees. Prefer this before grep or Read.", { projectPath: z.string(), node: z.string() }, async ({ projectPath, node }) => text(explore(projectPath, node)));
26
- add("compass_search", "Find symbols by name or keyword (substring). Cheaper structural search than grep.", { projectPath: z.string(), query: z.string(), limit: z.number().optional() }, async ({ projectPath, query, limit }) => text(search(projectPath, query, limit ?? 25)));
27
- add("compass_recall", "Find symbols by meaning via local embeddings. Use when names are unknown.", { projectPath: z.string(), query: z.string(), limit: z.number().optional() }, async ({ projectPath, query, limit }) => text(await recall(projectPath, query, limit ?? 15)));
28
- add("compass_impact", "Blast radius for a symbol or files, grouped by module (not a flat dump).", {
22
+ add("compass_explore", "Symbol context in one call: source, callers, callees, blast radius, tests, hotspot. Prefer before grep.", {
29
23
  projectPath: z.string(),
30
- /** @deprecated Prefer `symbol`. Kept for existing callers. */
31
- node: z.string().optional(),
32
- symbol: z.string().optional(),
33
- files: z.array(z.string()).optional(),
34
- nodeId: z.number().int().optional(),
35
- maxDepth: z.number().int().min(1).max(12).optional(),
36
- edgeKinds: z.array(z.enum(["call", "import"])).optional(),
37
- target: z.enum(["build", "test", "lint", "any"]).optional(),
38
- format: z.enum(["grouped", "flat"]).optional(),
39
- topModules: z.number().int().min(1).max(50).optional(),
40
- topPerModule: z.number().int().min(1).max(50).optional(),
41
- }, async (args) => text(impact(args.projectPath, {
42
- symbol: args.symbol ?? args.node,
43
- files: args.files,
44
- nodeId: args.nodeId,
45
- maxDepth: args.maxDepth ?? 4,
46
- edgeKinds: args.edgeKinds,
47
- target: args.target,
48
- format: args.format ?? "grouped",
49
- topModules: args.topModules,
50
- topPerModule: args.topPerModule,
51
- })));
52
- add("compass_affected_tests", "Select test files affected by a change; returns a ready-to-run command.", {
53
- projectPath: z.string(),
54
- files: z.array(z.string()).optional(),
55
- symbols: z.array(z.string()).optional(),
56
- fromDiff: z.string().optional(),
57
- maxDepth: z.number().int().min(1).max(12).optional(),
58
- }, async ({ projectPath, files, symbols, fromDiff, maxDepth }) => text(affectedTests(projectPath, { files, symbols, fromDiff, maxDepth })));
59
- add("compass_hotspots", "Rank files by recent churn and AST complexity; two axes, no magic score.", {
60
- projectPath: z.string(),
61
- days: z.number().int().min(1).max(3650).optional(),
62
- since: z.string().optional(),
63
- sortBy: z.enum(["churn", "complexity", "combined"]).optional(),
64
- limit: z.number().int().min(1).max(200).optional(),
65
- }, async ({ projectPath, days, since, sortBy, limit }) => text(hotspots(projectPath, { days, since, sortBy, limit })));
66
- add("compass_coupling", "Files that co-change with a target; strength, graph edge, and test-pair facts.", {
67
- projectPath: z.string(),
68
- file: z.string(),
69
- days: z.number().int().min(1).max(3650).optional(),
70
- since: z.string().optional(),
71
- minShared: z.number().int().min(1).optional(),
72
- maxFilesPerCommit: z.number().int().min(2).optional(),
73
- limit: z.number().int().min(1).max(200).optional(),
74
- }, async ({ projectPath, file, days, since, minShared, maxFilesPerCommit, limit }) => text(coupling(projectPath, file, { days, since, minShared, maxFilesPerCommit, limit })));
75
- add("compass_trace", "Find a call path between two symbols within a depth limit.", {
76
- projectPath: z.string(),
77
- from: z.string(),
78
- to: z.string(),
79
- maxDepth: z.number().optional(),
80
- }, async ({ projectPath, from, to, maxDepth }) => text(trace(projectPath, from, to, maxDepth ?? 8)));
81
- add("compass_visualize", "Write an offline HTML graph to .speclaw/graph.html for interactive exploration.", {
24
+ node: z.string(),
25
+ to: z.string().optional(),
26
+ include: includeEnum.optional(),
27
+ mode: z.enum(["brief", "full"]).optional(),
28
+ maxDepth: z.number().int().min(1).max(8).optional(),
29
+ }, async ({ projectPath, node, to, include, mode, maxDepth }) => {
30
+ const result = await exploreRich({
31
+ projectPath,
32
+ node,
33
+ to,
34
+ include: include,
35
+ mode: (mode ?? "brief"),
36
+ maxDepth,
37
+ });
38
+ return text(formatExploreRich(result, (mode ?? "brief")));
39
+ });
40
+ add("compass_find", "Find symbols by exact name or by concept. Use exact for identifiers, concept for meaning.", {
82
41
  projectPath: z.string(),
83
- node: z.string().optional(),
84
- depth: z.number().optional(),
42
+ query: z.string(),
43
+ mode: z.enum(["exact", "concept"]),
85
44
  limit: z.number().optional(),
86
- }, async ({ projectPath, node, depth, limit }) => text(visualize(projectPath, { focus: node, depth, limit })));
87
- add("compass_watch", "Start, stop, or status a debounced file watcher that re-indexes on change.", {
45
+ }, async ({ projectPath, query, mode, limit }) => text(await findSymbols(projectPath, query, mode, limit)));
46
+ add("compass_diff_context", "Graph context of changes in one call: symbols, blast radius, tests, hotspots. Default: working tree.", {
47
+ projectPath: z.string(),
48
+ rev: z.string().optional(),
49
+ paths: z.array(z.string()).optional(),
50
+ mode: z.enum(["brief", "full"]).optional(),
51
+ maxDepth: z.number().int().min(1).max(8).optional(),
52
+ }, async ({ projectPath, rev, paths, mode, maxDepth }) => {
53
+ const result = diffContext({
54
+ projectPath,
55
+ rev,
56
+ paths,
57
+ mode: (mode ?? "brief"),
58
+ maxDepth,
59
+ });
60
+ return text(formatDiffContext(result, (mode ?? "brief")));
61
+ });
62
+ add("compass_index", "Build or refresh the code graph index; optional watch action for live re-index.", {
88
63
  projectPath: z.string(),
89
- action: z.enum(["start", "stop", "status"]),
64
+ action: z.enum(["index", "start", "stop", "status"]).optional(),
90
65
  }, async ({ projectPath, action }) => {
91
- const result = action === "start"
66
+ const act = action ?? "index";
67
+ if (act === "index")
68
+ return text(await buildIndex(projectPath));
69
+ const result = act === "start"
92
70
  ? startWatch(projectPath)
93
- : action === "stop"
71
+ : act === "stop"
94
72
  ? stopWatch(projectPath)
95
73
  : watchStatus(projectPath);
96
74
  return text(result);
97
75
  });
76
+ if (minimal || !aliasesEnabled())
77
+ return;
78
+ defineAliasTool(server, {
79
+ name: "compass_search",
80
+ description: "Deprecated alias for compass_find mode exact.",
81
+ inputSchema: { projectPath: z.string(), query: z.string(), limit: z.number().optional() },
82
+ handler: async ({ projectPath, query, limit }) => {
83
+ logDeprecatedCall(projectPath, "compass_search");
84
+ const body = JSON.stringify(await findSymbols(projectPath, query, "exact", limit), null, 2);
85
+ return text(prefixDeprecated("compass_search", body));
86
+ },
87
+ });
88
+ defineAliasTool(server, {
89
+ name: "compass_recall",
90
+ description: "Deprecated alias for compass_find mode concept.",
91
+ inputSchema: { projectPath: z.string(), query: z.string(), limit: z.number().optional() },
92
+ handler: async ({ projectPath, query, limit }) => {
93
+ logDeprecatedCall(projectPath, "compass_recall");
94
+ const body = JSON.stringify(await findSymbols(projectPath, query, "concept", limit), null, 2);
95
+ return text(prefixDeprecated("compass_recall", body));
96
+ },
97
+ });
98
+ defineAliasTool(server, {
99
+ name: "compass_impact",
100
+ description: "Deprecated alias for compass_explore blast_radius include.",
101
+ inputSchema: {
102
+ projectPath: z.string(),
103
+ node: z.string().optional(),
104
+ symbol: z.string().optional(),
105
+ files: z.array(z.string()).optional(),
106
+ maxDepth: z.number().optional(),
107
+ },
108
+ handler: async (args) => {
109
+ logDeprecatedCall(args.projectPath, "compass_impact");
110
+ const sym = args.symbol ?? args.node;
111
+ const body = sym
112
+ ? JSON.stringify(await exploreRich({
113
+ projectPath: args.projectPath,
114
+ node: sym,
115
+ include: ["blast_radius"],
116
+ }), null, 2)
117
+ : JSON.stringify(impact(args.projectPath, { files: args.files, maxDepth: args.maxDepth ?? 4 }), null, 2);
118
+ return text(prefixDeprecated("compass_impact", body));
119
+ },
120
+ });
121
+ defineAliasTool(server, {
122
+ name: "compass_trace",
123
+ description: "Deprecated alias for compass_explore with to parameter.",
124
+ inputSchema: {
125
+ projectPath: z.string(),
126
+ from: z.string(),
127
+ to: z.string(),
128
+ maxDepth: z.number().optional(),
129
+ },
130
+ handler: async ({ projectPath, from, to, maxDepth }) => {
131
+ logDeprecatedCall(projectPath, "compass_trace");
132
+ const body = JSON.stringify(await exploreRich({ projectPath, node: from, to, maxDepth }), null, 2);
133
+ return text(prefixDeprecated("compass_trace", body));
134
+ },
135
+ });
136
+ defineAliasTool(server, {
137
+ name: "compass_affected_tests",
138
+ description: "Deprecated alias — use compass_diff_context or explore.",
139
+ inputSchema: {
140
+ projectPath: z.string(),
141
+ files: z.array(z.string()).optional(),
142
+ symbols: z.array(z.string()).optional(),
143
+ fromDiff: z.string().optional(),
144
+ },
145
+ handler: async (args) => {
146
+ logDeprecatedCall(args.projectPath, "compass_affected_tests");
147
+ const body = JSON.stringify(affectedTests(args.projectPath, args), null, 2);
148
+ return text(prefixDeprecated("compass_affected_tests", body));
149
+ },
150
+ });
151
+ defineAliasTool(server, {
152
+ name: "compass_hotspots",
153
+ description: "Deprecated alias — use compass_explore hotspot include.",
154
+ inputSchema: { projectPath: z.string(), limit: z.number().optional() },
155
+ handler: async ({ projectPath, limit }) => {
156
+ logDeprecatedCall(projectPath, "compass_hotspots");
157
+ const body = JSON.stringify(hotspots(projectPath, { limit }), null, 2);
158
+ return text(prefixDeprecated("compass_hotspots", body));
159
+ },
160
+ });
161
+ defineAliasTool(server, {
162
+ name: "compass_coupling",
163
+ description: "Deprecated alias — use compass_diff_context.",
164
+ inputSchema: { projectPath: z.string(), file: z.string() },
165
+ handler: async ({ projectPath, file }) => {
166
+ logDeprecatedCall(projectPath, "compass_coupling");
167
+ const body = JSON.stringify(coupling(projectPath, file, {}), null, 2);
168
+ return text(prefixDeprecated("compass_coupling", body));
169
+ },
170
+ });
171
+ defineAliasTool(server, {
172
+ name: "compass_watch",
173
+ description: "Deprecated alias for compass_index watch actions.",
174
+ inputSchema: {
175
+ projectPath: z.string(),
176
+ action: z.enum(["start", "stop", "status"]),
177
+ },
178
+ handler: async ({ projectPath, action }) => {
179
+ logDeprecatedCall(projectPath, "compass_watch");
180
+ const result = action === "start"
181
+ ? startWatch(projectPath)
182
+ : action === "stop"
183
+ ? stopWatch(projectPath)
184
+ : watchStatus(projectPath);
185
+ return text(prefixDeprecated("compass_watch", JSON.stringify(result, null, 2)));
186
+ },
187
+ });
98
188
  }