@esneiderbravo/speclaw 0.3.4 → 0.3.5

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 (54) hide show
  1. package/README.md +24 -0
  2. package/dist/cli/commands/budget.js +36 -0
  3. package/dist/cli/commands/doctor.js +11 -1
  4. package/dist/cli/commands/init.js +3 -1
  5. package/dist/cli/commands/update.js +23 -3
  6. package/dist/cli/index.js +11 -3
  7. package/dist/modules/compass/indexer.js +9 -0
  8. package/dist/modules/compass/map.js +114 -0
  9. package/dist/modules/compass/register.js +30 -64
  10. package/dist/modules/foundation/assets/docs/compass.template.md +3 -0
  11. package/dist/modules/foundation/context-budget.js +45 -0
  12. package/dist/modules/foundation/doctor.js +9 -0
  13. package/dist/modules/foundation/register.js +71 -120
  14. package/dist/modules/foundation/scaffold.js +1 -1
  15. package/dist/modules/lawbook/assets/skills/archive/SKILL.md +1 -31
  16. package/dist/modules/lawbook/assets/skills/archive/steps/01-confirm-done.md +7 -0
  17. package/dist/modules/lawbook/assets/skills/archive/steps/02-reconcile.md +15 -0
  18. package/dist/modules/lawbook/assets/skills/archive/steps/03-validate-and-sync.md +7 -0
  19. package/dist/modules/lawbook/assets/skills/archive/steps/04-archive.md +9 -0
  20. package/dist/modules/lawbook/assets/skills/archive/steps/05-report.md +7 -0
  21. package/dist/modules/lawbook/assets/skills/build/SKILL.md +2 -100
  22. package/dist/modules/lawbook/assets/skills/build/steps/01-load-change.md +7 -0
  23. package/dist/modules/lawbook/assets/skills/build/steps/02-branch.md +6 -0
  24. package/dist/modules/lawbook/assets/skills/build/steps/03-implement.md +11 -0
  25. package/dist/modules/lawbook/assets/skills/build/steps/04-quality-gates.md +10 -0
  26. package/dist/modules/lawbook/assets/skills/build/steps/05-manual-verification.md +22 -0
  27. package/dist/modules/lawbook/assets/skills/build/steps/06-discipline-reports.md +44 -0
  28. package/dist/modules/lawbook/assets/skills/build/steps/07-hand-off.md +9 -0
  29. package/dist/modules/lawbook/assets/skills/draft/SKILL.md +3 -80
  30. package/dist/modules/lawbook/assets/skills/draft/steps/01-ensure-workspace.md +5 -0
  31. package/dist/modules/lawbook/assets/skills/draft/steps/02-understand.md +12 -0
  32. package/dist/modules/lawbook/assets/skills/draft/steps/03-name-capabilities.md +14 -0
  33. package/dist/modules/lawbook/assets/skills/draft/steps/04-write-artifacts.md +41 -0
  34. package/dist/modules/lawbook/assets/skills/draft/steps/05-validate.md +11 -0
  35. package/dist/modules/lawbook/assets/skills/draft/steps/06-hand-off.md +5 -0
  36. package/dist/modules/lawbook/assets/skills/explore/SKILL.md +6 -22
  37. package/dist/modules/lawbook/assets/skills/explore/steps/01-investigate.md +16 -0
  38. package/dist/modules/lawbook/assets/skills/explore/steps/02-summarize.md +7 -0
  39. package/dist/modules/lawbook/assets/skills/sync/SKILL.md +1 -30
  40. package/dist/modules/lawbook/assets/skills/sync/steps/01-confirm.md +5 -0
  41. package/dist/modules/lawbook/assets/skills/sync/steps/02-reconcile.md +16 -0
  42. package/dist/modules/lawbook/assets/skills/sync/steps/03-validate.md +6 -0
  43. package/dist/modules/lawbook/assets/skills/sync/steps/04-promote.md +10 -0
  44. package/dist/modules/lawbook/assets/skills/sync/steps/05-report.md +7 -0
  45. package/dist/modules/lawbook/register.js +17 -36
  46. package/dist/modules/tools/register.js +15 -15
  47. package/dist/server.js +13 -7
  48. package/dist/shared/budget.js +159 -0
  49. package/dist/shared/exposure.js +110 -0
  50. package/dist/shared/manifest.js +11 -2
  51. package/dist/shared/mcp.js +33 -0
  52. package/dist/shared/schema-tokens.js +86 -0
  53. package/dist/shared/tokens.js +41 -0
  54. package/package.json +2 -1
package/README.md CHANGED
@@ -91,6 +91,30 @@ Compass is inspired by [CodeGraph](https://github.com/colbymchenry/codegraph) an
91
91
 
92
92
  <br/>
93
93
 
94
+ ## <img src="https://raw.githubusercontent.com/esneiderbravo/speclaw/main/brand/diamond.png" height="20" alt="◆" align="absmiddle">&nbsp; Context cost
95
+
96
+ speclaw publishes and **gates** its own always-on context cost. Measured with a
97
+ deterministic offline estimator (`speclaw/estimate-v1`, about ±8% vs Anthropic's
98
+ tokenizer on this corpus — not a BPE dependency):
99
+
100
+ | | Tokens |
101
+ | :-- | --: |
102
+ | **speclaw budget (always-on)** | **~11.7k** (budget ceiling **13.0k**) |
103
+ | Spec Kit commands alone | ~18.6k ([spec-kit#1401](https://github.com/github/spec-kit/issues/1401)) |
104
+
105
+ ```bash
106
+ speclaw budget # human table
107
+ speclaw budget --json # machine-readable; used by the suite gate
108
+ speclaw init --minimal # omit setup/lifecycle MCP tools from registration
109
+ ```
110
+
111
+ Raising a number in committed `token-budget.json` is a reviewable PR. Optional
112
+ calibration (never CI): `npm run budget:calibrate` with `ANTHROPIC_API_KEY`.
113
+ MCP servers cannot mark tools `defer_loading` — savings come from shorter
114
+ definitions, omitted registration (`--minimal`), and JIT skill steps.
115
+
116
+ <br/>
117
+
94
118
  ## <img src="https://raw.githubusercontent.com/esneiderbravo/speclaw/main/brand/diamond.png" height="20" alt="◆" align="absmiddle">&nbsp; The spec-driven workflow (Lawbook)
95
119
 
96
120
  Lawbook is speclaw's answer to the biggest risk with AI agents: **code that
@@ -0,0 +1,36 @@
1
+ import { ui } from "../lib/ui.js";
2
+ import { formatBudgetTable } from "../../shared/budget.js";
3
+ import { loadDeclaredBudget } from "../../shared/exposure.js";
4
+ import { measureInstallBudget } from "../../modules/foundation/context-budget.js";
5
+ /** Print the context-budget table or JSON. */
6
+ export async function runBudget(flags) {
7
+ const cwd = process.cwd();
8
+ const minimal = flags.minimal === true ? true : flags.minimal === false ? false : undefined;
9
+ const measurement = measureInstallBudget(cwd, minimal);
10
+ const declared = loadDeclaredBudget();
11
+ if (flags.json) {
12
+ console.log(JSON.stringify({
13
+ schemaVersion: 1,
14
+ profile: measurement.profile,
15
+ tools: measurement.tools,
16
+ skillsAndCommands: measurement.skillsAndCommands,
17
+ alwaysOnInstructions: measurement.alwaysOnInstructions,
18
+ pathScoped: measurement.pathScoped,
19
+ total: measurement.total,
20
+ toolCount: measurement.toolCount,
21
+ declared: {
22
+ tools: declared.surfaces.tools,
23
+ skillsAndCommands: declared.surfaces.skillsAndCommands,
24
+ alwaysOnInstructions: declared.surfaces.alwaysOnInstructions,
25
+ total: measurement.profile === "minimal" ? declared.minimal.total : declared.total,
26
+ },
27
+ details: measurement.details,
28
+ }, null, 2));
29
+ return;
30
+ }
31
+ ui.heading("speclaw budget");
32
+ console.log(formatBudgetTable(measurement, declared));
33
+ ui.plain();
34
+ ui.info(`profile: ${measurement.profile} · tools registered: ${measurement.toolCount}`);
35
+ ui.info("Spec Kit (commands only), for comparison: ~18,600 — github/spec-kit#1401");
36
+ }
@@ -1,8 +1,10 @@
1
1
  import { doctor } from "../../modules/foundation/doctor.js";
2
+ import { measureInstallBudget } from "../../modules/foundation/context-budget.js";
2
3
  import { ui } from "../lib/ui.js";
3
4
  /** Verify the installation, printing each check and exiting non-zero on failure. */
4
5
  export async function runDoctor(_flags) {
5
- const checks = doctor(process.cwd());
6
+ const cwd = process.cwd();
7
+ const checks = doctor(cwd);
6
8
  ui.heading("speclaw doctor");
7
9
  for (const c of checks) {
8
10
  if (c.ok)
@@ -10,6 +12,14 @@ export async function runDoctor(_flags) {
10
12
  else
11
13
  ui.err(`${c.name} — ${c.detail}`);
12
14
  }
15
+ try {
16
+ const m = measureInstallBudget(cwd);
17
+ ui.ok(`context budget — profile ${m.profile} · ${m.toolCount} tools · ~${m.total} always-on tokens ` +
18
+ `(A ${m.tools} · B ${m.skillsAndCommands} · C ${m.alwaysOnInstructions}; D path-scoped ${m.pathScoped} not in total)`);
19
+ }
20
+ catch (err) {
21
+ ui.warn(`context budget — could not measure: ${err.message}`);
22
+ }
13
23
  const failed = checks.filter((c) => !c.ok).length;
14
24
  ui.plain();
15
25
  if (failed === 0)
@@ -87,7 +87,9 @@ export async function runInit(flags) {
87
87
  if (reinit) {
88
88
  ui.info("speclaw is already set up here — your existing files are kept; only missing pieces are added.");
89
89
  }
90
- const report = scaffold(cwd, profile, packs, agents);
90
+ const report = scaffold(cwd, profile, packs, agents, {
91
+ minimal: Boolean(flags.minimal),
92
+ });
91
93
  ui.ok(`Foundation ${c.muted("— LAWS.md + 8 standards + CLAUDE.md/AGENTS.md")}`);
92
94
  ui.ok(`Lawbook workflow ${c.muted("— draft · build · sync · archive · explore")}`);
93
95
  for (const p of packs)
@@ -67,6 +67,18 @@ const MIGRATIONS = [
67
67
  "required status check in branch protection. speclaw never enables that itself.\n" +
68
68
  "- Preserve all project-specific wording; only apply these speclaw-authored changes.",
69
69
  },
70
+ {
71
+ version: "0.3.5",
72
+ describe: "Context budget: compact map markers and budget-aware agent contracts",
73
+ agentPrompt: "- In docs/compass.md, ensure the markers `<!-- speclaw:map:start -->` and " +
74
+ "`<!-- speclaw:map:end -->` exist (usually just above 'Project-specific starting points') " +
75
+ "so `compass_index` can regenerate the compact project map between them. Do not hand-edit " +
76
+ "the content between the markers.\n" +
77
+ "- Optionally note in CLAUDE.md / AGENTS.md that `speclaw budget` reports always-on context " +
78
+ "cost and that `speclaw init --minimal` / `SPECLAW_MINIMAL=1` omit setup MCP tools " +
79
+ "(no server-side defer_loading).\n" +
80
+ "- Preserve all project-specific wording; only apply these speclaw-authored changes.",
81
+ },
70
82
  ];
71
83
  /**
72
84
  * Update speclaw and bring the current project up to date without a full re-init:
@@ -108,7 +120,12 @@ export async function runUpdate(flags) {
108
120
  // Re-exec the NEWLY installed binary so migrations run with the new assets
109
121
  // and any new feature steps — not this (now-stale) process. Carry --backup
110
122
  // through so the refresh honors it after the upgrade.
111
- const reArgs = ["update", "--migrate-only", ...(backup ? ["--backup"] : [])];
123
+ const reArgs = [
124
+ "update",
125
+ "--migrate-only",
126
+ ...(backup ? ["--backup"] : []),
127
+ ...(flags.minimal ? ["--minimal"] : []),
128
+ ];
112
129
  const re = spawnSync("speclaw", reArgs, {
113
130
  stdio: "inherit",
114
131
  shell: winShell,
@@ -125,7 +142,7 @@ export async function runUpdate(flags) {
125
142
  return;
126
143
  }
127
144
  }
128
- applyProjectMigrations(cwd, backup);
145
+ applyProjectMigrations(cwd, backup, flags.minimal ? true : undefined);
129
146
  }
130
147
  /**
131
148
  * Additively apply the current version's content and feature steps to a project.
@@ -135,8 +152,10 @@ export async function runUpdate(flags) {
135
152
  * @param backup - When true, a locally edited managed file is copied to
136
153
  * `<file>.bak` before it is refreshed; the default overwrites it in place
137
154
  * (recoverable from git) and only reports the overwrite.
155
+ * @param minimal - When true, persist minimal exposure; when undefined, keep
156
+ * the manifest's existing value.
138
157
  */
139
- function applyProjectMigrations(cwd, backup) {
158
+ function applyProjectMigrations(cwd, backup, minimal) {
140
159
  const initialized = fs.existsSync(path.join(cwd, "ai-specs")) || fs.existsSync(path.join(cwd, "LAWS.md"));
141
160
  if (!initialized) {
142
161
  ui.step("Project");
@@ -154,6 +173,7 @@ function applyProjectMigrations(cwd, backup) {
154
173
  const report = scaffold(cwd, { project_name: detectProjectName(cwd) }, packs, agents, {
155
174
  refreshManaged: true,
156
175
  backup,
176
+ ...(minimal !== undefined ? { minimal } : {}),
157
177
  });
158
178
  const changed = report.written.filter((w) => !w.includes(".gitignore"));
159
179
  if (changed.length) {
package/dist/cli/index.js CHANGED
@@ -11,7 +11,9 @@ Install globally so the command is always available:
11
11
 
12
12
  Setup
13
13
  init Interactive setup: pick agents, scaffold, index, get the prompt
14
+ (--minimal omits setup/lifecycle MCP tools)
14
15
  update Upgrade speclaw and apply only what's new (no re-init)
16
+ (--minimal persists minimal exposure; omit to keep prior)
15
17
  agent list Show which agents are configured
16
18
  agent add <id> Configure another agent later (symlinks + MCP)
17
19
 
@@ -34,6 +36,7 @@ Lawbook (spec-driven workflow)
34
36
 
35
37
  Other
36
38
  doctor Verify the installation
39
+ budget Measure always-on context cost (tools, skills, instructions)
37
40
  check Evaluate an action against the laws (hooks call this; --dry-run to preview)
38
41
  laws verify Verify the deterministic dependency/graph laws against the index
39
42
  verify Verify laws for CI: exit codes, --sarif, --json, --strict-engines
@@ -55,6 +58,7 @@ const HEADER_COMMANDS = new Set([
55
58
  "update",
56
59
  "agent",
57
60
  "doctor",
61
+ "budget",
58
62
  "index",
59
63
  "watch",
60
64
  "lawbook",
@@ -64,13 +68,15 @@ const HEADER_COMMANDS = new Set([
64
68
  * header-eligible command AND stdout is an interactive terminal (so pipes,
65
69
  * redirection, and CI stay clean — mirroring the color gate in `ui.ts`). A
66
70
  * forced-color signal counts as interactive so the header is exercisable in a
67
- * child process.
71
+ * child process. `budget --json` is machine-consumed and suppresses the header.
68
72
  */
69
- function maybeHeader(cmd) {
73
+ function maybeHeader(cmd, flags) {
70
74
  if (!process.stdout.isTTY && process.env.FORCE_COLOR !== "1")
71
75
  return;
72
76
  if (!HEADER_COMMANDS.has(cmd))
73
77
  return;
78
+ if (cmd === "budget" && flags.json)
79
+ return;
74
80
  header();
75
81
  }
76
82
  /** Run the handler for a single command. Returns when the command completes. */
@@ -113,6 +119,8 @@ async function dispatch(cmd, flags) {
113
119
  return (await import("./commands/lawbook.js")).runSpec(flags);
114
120
  case "doctor":
115
121
  return (await import("./commands/doctor.js")).runDoctor(flags);
122
+ case "budget":
123
+ return (await import("./commands/budget.js")).runBudget(flags);
116
124
  case "check":
117
125
  return (await import("./commands/check.js")).runCheck(flags);
118
126
  case "laws":
@@ -129,7 +137,7 @@ async function dispatch(cmd, flags) {
129
137
  async function main() {
130
138
  const [cmd, ...rest] = process.argv.slice(2);
131
139
  const flags = parseFlags(rest);
132
- maybeHeader(cmd);
140
+ maybeHeader(cmd, flags);
133
141
  await dispatch(cmd, flags);
134
142
  await maybeNotifyUpdate(cmd);
135
143
  }
@@ -9,6 +9,7 @@ const SKIP_DIRS = new Set([
9
9
  ".git",
10
10
  "node_modules",
11
11
  "dist",
12
+ "dist-test",
12
13
  "build",
13
14
  ".next",
14
15
  "out",
@@ -173,5 +174,13 @@ export async function buildIndex(projectPath, onProgress) {
173
174
  finally {
174
175
  db.close();
175
176
  }
177
+ // Compact map in committed docs/compass.md (between markers) — zero tool-call cost.
178
+ try {
179
+ const { writeCompactMap } = await import("./map.js");
180
+ writeCompactMap(projectPath);
181
+ }
182
+ catch {
183
+ // Map generation must never fail an index run.
184
+ }
176
185
  return stats;
177
186
  }
@@ -0,0 +1,114 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { openDb } from "./db.js";
4
+ import { estimateTokens } from "../../shared/tokens.js";
5
+ import { loadDeclaredBudget } from "../../shared/exposure.js";
6
+ export const MAP_START = "<!-- speclaw:map:start -->";
7
+ export const MAP_END = "<!-- speclaw:map:end -->";
8
+ /**
9
+ * Build a compact project map (hubs + top-level layout) from the Compass DB.
10
+ * Truncates to the declared map token budget.
11
+ *
12
+ * @param projectPath - Project root with `.speclaw/index.db`.
13
+ * @returns Map body text (no markers), or null if the index is missing/empty.
14
+ */
15
+ export function generateCompactMap(projectPath) {
16
+ const dbPath = path.join(projectPath, ".speclaw", "index.db");
17
+ if (!fs.existsSync(dbPath))
18
+ return null;
19
+ const db = openDb(projectPath);
20
+ try {
21
+ const fileCount = db.prepare("SELECT COUNT(*) AS c FROM files").get().c;
22
+ const nodeCount = db.prepare("SELECT COUNT(*) AS c FROM nodes").get().c;
23
+ if (fileCount === 0)
24
+ return null;
25
+ const hubs = db
26
+ .prepare(`SELECT n.name AS name, COUNT(*) AS fan_in
27
+ FROM edges e
28
+ JOIN nodes n ON n.id = e.dst_node_id
29
+ WHERE e.kind = 'call' AND e.dst_node_id IS NOT NULL
30
+ GROUP BY n.id
31
+ ORDER BY fan_in DESC
32
+ LIMIT 12`)
33
+ .all();
34
+ const topDirs = db
35
+ .prepare(`SELECT CASE
36
+ WHEN instr(path, '/') > 0 THEN substr(path, 1, instr(path, '/') - 1)
37
+ ELSE path
38
+ END AS top, COUNT(*) AS c
39
+ FROM files
40
+ GROUP BY top
41
+ ORDER BY c DESC
42
+ LIMIT 8`)
43
+ .all();
44
+ const hubLine = hubs.length === 0
45
+ ? "hubs: (none yet)"
46
+ : `hubs: ${hubs.map((h) => `${h.name} ${h.fan_in}`).join(" · ")}`;
47
+ const dirLine = topDirs.map((d) => `${d.top}/ (${d.c})`).join(" ");
48
+ let body = [
49
+ `speclaw · ${fileCount} files · ${nodeCount} nodes`,
50
+ dirLine,
51
+ hubLine,
52
+ "entry: src/server.ts (mcp) · src/cli/index.ts (bin)",
53
+ ].join("\n");
54
+ const cap = loadDeclaredBudget().map;
55
+ let omitted = false;
56
+ while (estimateTokens(body) > cap && hubs.length > 3) {
57
+ hubs.pop();
58
+ omitted = true;
59
+ const shorter = hubs.length === 0
60
+ ? "hubs: (truncated)"
61
+ : `hubs: ${hubs.map((h) => `${h.name} ${h.fan_in}`).join(" · ")}`;
62
+ body = [
63
+ `speclaw · ${fileCount} files · ${nodeCount} nodes`,
64
+ dirLine,
65
+ shorter,
66
+ "entry: src/server.ts (mcp) · src/cli/index.ts (bin)",
67
+ omitted ? "(entries omitted to fit map budget)" : "",
68
+ ]
69
+ .filter(Boolean)
70
+ .join("\n");
71
+ }
72
+ if (estimateTokens(body) > cap) {
73
+ body = [
74
+ `speclaw · ${fileCount} files · ${nodeCount} nodes`,
75
+ "entry: src/server.ts (mcp) · src/cli/index.ts (bin)",
76
+ "(entries omitted to fit map budget)",
77
+ ].join("\n");
78
+ }
79
+ return body;
80
+ }
81
+ finally {
82
+ db.close();
83
+ }
84
+ }
85
+ /**
86
+ * Replace content between map markers in `docs/compass.md`. Preserves content
87
+ * outside markers. Does nothing (with reason) when markers are missing or the
88
+ * map cannot be generated.
89
+ *
90
+ * @param projectPath - Project root.
91
+ */
92
+ export function writeCompactMap(projectPath) {
93
+ const compassPath = path.join(projectPath, "docs", "compass.md");
94
+ if (!fs.existsSync(compassPath)) {
95
+ return { written: false, reason: "docs/compass.md missing" };
96
+ }
97
+ const original = fs.readFileSync(compassPath, "utf8");
98
+ const start = original.indexOf(MAP_START);
99
+ const end = original.indexOf(MAP_END);
100
+ if (start < 0 || end < 0 || end < start) {
101
+ return { written: false, reason: "map markers missing — not regenerating" };
102
+ }
103
+ const map = generateCompactMap(projectPath);
104
+ if (!map) {
105
+ // Leave markers but clear body so we don't leave a stale map.
106
+ const next = original.slice(0, start + MAP_START.length) + "\n" + original.slice(end);
107
+ if (next !== original)
108
+ fs.writeFileSync(compassPath, next);
109
+ return { written: false, reason: "no index or empty graph — map omitted" };
110
+ }
111
+ const next = original.slice(0, start + MAP_START.length) + "\n" + map + "\n" + original.slice(end);
112
+ fs.writeFileSync(compassPath, next);
113
+ return { written: true };
114
+ }
@@ -1,78 +1,44 @@
1
1
  import { z } from "zod";
2
- import { text } from "../../shared/mcp.js";
2
+ import { defineTool, text } from "../../shared/mcp.js";
3
+ import { shouldExpose } from "../../shared/exposure.js";
3
4
  import { buildIndex } from "./indexer.js";
4
5
  import { explore, search, recall, impact, trace } from "./query.js";
5
6
  import { startWatch, stopWatch, watchStatus } from "./watcher.js";
6
7
  import { visualize } from "./visualize.js";
7
8
  // ─── Compass: speclaw's own code-intelligence engine (no external deps) ───
8
- // A local graph of the codebase (nodes = definitions, edges = calls/imports)
9
- // plus a local vector store for semantic recall. Lives in .speclaw/ (gitignored).
10
9
  /**
11
- * Register all Compass MCP tools (index, explore, search, recall, impact,
12
- * trace, watch) on the given server.
10
+ * Register Compass MCP tools on the given server.
13
11
  *
14
- * @param server - The MCP server to register the Compass tools on.
12
+ * @param server - The MCP server to register on.
13
+ * @param opts - Exposure options (`minimal` omits setup/specialized tools).
15
14
  */
16
- export function registerCompass(server) {
17
- server.registerTool("compass_index", {
18
- description: "Build or incrementally refresh the Compass — speclaw's local code graph (.speclaw/index.db). Parses TS/JS/Python with tree-sitter into nodes (definitions) and edges (calls/imports), and computes a local vector embedding per node for semantic recall. Files unchanged since the last run are skipped by content hash. Run once after init and whenever you want a fresh graph.",
19
- inputSchema: { projectPath: z.string().describe("Absolute path to the project") },
20
- }, async ({ projectPath }) => text(await buildIndex(projectPath)));
21
- server.registerTool("compass_explore", {
22
- description: "Explore a node in the Compass: returns its verbatim source, location, callees, and resolved callers (blast radius). Use this BEFORE grep/read when locating or understanding code. Requires compass_index to have run.",
23
- inputSchema: {
24
- projectPath: z.string().describe("Absolute path to the project"),
25
- node: z.string().describe("Exact node name to explore (function/class/method/type)"),
26
- },
27
- }, async ({ projectPath, node }) => text(explore(projectPath, node)));
28
- server.registerTool("compass_search", {
29
- description: "Structural search of the Compass: find nodes by name or keyword (substring match). Returns name, kind, and file:line per hit. Cheaper and more structural than grep. Requires compass_index to have run.",
30
- inputSchema: {
31
- projectPath: z.string().describe("Absolute path to the project"),
32
- query: z.string().describe("Name or keyword to search for"),
33
- limit: z.number().optional().describe("Max results (default 25)"),
34
- },
35
- }, async ({ projectPath, query, limit }) => text(search(projectPath, query, limit ?? 25)));
36
- server.registerTool("compass_recall", {
37
- description: "Semantic search of the Compass: describe what you're looking for in natural language ('where auth tokens are validated') and get the nodes ranked by meaning, using the local vector store — even when the identifier names don't contain your words. Requires compass_index to have run.",
38
- inputSchema: {
39
- projectPath: z.string().describe("Absolute path to the project"),
40
- query: z.string().describe("Natural-language description of the code you want"),
41
- limit: z.number().optional().describe("Max results (default 15)"),
42
- },
43
- }, async ({ projectPath, query, limit }) => text(await recall(projectPath, query, limit ?? 15)));
44
- server.registerTool("compass_impact", {
45
- description: "Blast radius: every node that transitively calls the target, up to a depth. Answers 'what could break if I change this?' before editing. Includes dynamic-dispatch callers (matched by name). Requires compass_index.",
46
- inputSchema: {
47
- projectPath: z.string().describe("Absolute path to the project"),
48
- node: z.string().describe("Node name whose dependents you want"),
49
- maxDepth: z.number().optional().describe("Max hops to traverse (default 4)"),
50
- },
51
- }, async ({ projectPath, node, maxDepth }) => text(impact(projectPath, node, maxDepth ?? 4)));
52
- server.registerTool("compass_trace", {
53
- description: "Trace a call path from one node to another: returns the chain of calls linking them (or null if none within depth). Useful to understand how an entrypoint reaches a sink. Requires compass_index.",
54
- inputSchema: {
55
- projectPath: z.string().describe("Absolute path to the project"),
56
- from: z.string().describe("Starting node name"),
57
- to: z.string().describe("Target node name"),
58
- maxDepth: z.number().optional().describe("Max hops to search (default 8)"),
59
- },
15
+ export function registerCompass(server, opts = {}) {
16
+ const minimal = Boolean(opts.minimal);
17
+ const add = (name, description, inputSchema, handler) => {
18
+ if (!shouldExpose(name, minimal))
19
+ return;
20
+ defineTool(server, { name, description, inputSchema, handler });
21
+ };
22
+ 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)));
23
+ 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)));
24
+ 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)));
25
+ 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)));
26
+ add("compass_impact", "List transitive callers of a symbol (blast radius) before editing.", { projectPath: z.string(), node: z.string(), maxDepth: z.number().optional() }, async ({ projectPath, node, maxDepth }) => text(impact(projectPath, node, maxDepth ?? 4)));
27
+ add("compass_trace", "Find a call path between two symbols within a depth limit.", {
28
+ projectPath: z.string(),
29
+ from: z.string(),
30
+ to: z.string(),
31
+ maxDepth: z.number().optional(),
60
32
  }, async ({ projectPath, from, to, maxDepth }) => text(trace(projectPath, from, to, maxDepth ?? 8)));
61
- server.registerTool("compass_visualize", {
62
- description: "Generate an interactive, offline HTML visualization of the code graph into .speclaw/graph.html (gitignored). Nodes are definitions, edges are calls; drag/zoom/hover to explore. Pass a node to focus on its neighborhood, else the most-connected nodes are shown. Requires compass_index.",
63
- inputSchema: {
64
- projectPath: z.string().describe("Absolute path to the project"),
65
- node: z.string().optional().describe("Focus on this node's neighborhood (optional)"),
66
- depth: z.number().optional().describe("BFS depth around the focus node (default 2)"),
67
- limit: z.number().optional().describe("Max nodes for the whole-graph view (default 300)"),
68
- },
33
+ add("compass_visualize", "Write an offline HTML graph to .speclaw/graph.html for interactive exploration.", {
34
+ projectPath: z.string(),
35
+ node: z.string().optional(),
36
+ depth: z.number().optional(),
37
+ limit: z.number().optional(),
69
38
  }, async ({ projectPath, node, depth, limit }) => text(visualize(projectPath, { focus: node, depth, limit })));
70
- server.registerTool("compass_watch", {
71
- description: "Keep the Compass index fresh automatically: start/stop a file watcher that incrementally re-indexes on change (debounced). action=start|stop|status. Optional — the index is also refreshed on demand by compass_index.",
72
- inputSchema: {
73
- projectPath: z.string().describe("Absolute path to the project"),
74
- action: z.enum(["start", "stop", "status"]).describe("start, stop, or status"),
75
- },
39
+ add("compass_watch", "Start, stop, or status a debounced file watcher that re-indexes on change.", {
40
+ projectPath: z.string(),
41
+ action: z.enum(["start", "stop", "status"]),
76
42
  }, async ({ projectPath, action }) => {
77
43
  const result = action === "start"
78
44
  ? startWatch(projectPath)
@@ -42,6 +42,9 @@ a missing graph is not license to skip Compass. The only legitimate fallbacks
42
42
  to Grep/Read: a Compass call returned nothing useful for your query, or the
43
43
  target isn't indexed code (stylesheets, JSON/config, markdown, logs).
44
44
 
45
+ <!-- speclaw:map:start -->
46
+ <!-- speclaw:map:end -->
47
+
45
48
  ## Project-specific starting points
46
49
 
47
50
  <!-- Filled in during speclaw init: the project's real entrypoints, core
@@ -0,0 +1,45 @@
1
+ import { registerCompass } from "../compass/register.js";
2
+ import { registerSpec } from "../lawbook/register.js";
3
+ import { registerTools } from "../tools/register.js";
4
+ import { registerFoundation } from "./register.js";
5
+ import { measureBudget } from "../../shared/budget.js";
6
+ import { isMinimalMode, packageRoot } from "../../shared/exposure.js";
7
+ /**
8
+ * Collect tool definitions as the MCP server would register them for a profile.
9
+ *
10
+ * @param minimal - Exposure profile.
11
+ */
12
+ export function collectRegisteredTools(minimal) {
13
+ const tools = [];
14
+ const server = {
15
+ registerTool(name, config) {
16
+ tools.push({
17
+ name,
18
+ description: config.description ?? "",
19
+ inputSchema: config.inputSchema,
20
+ });
21
+ },
22
+ };
23
+ const opts = { minimal };
24
+ const stub = server;
25
+ registerFoundation(stub, opts);
26
+ registerSpec(stub, opts);
27
+ registerCompass(stub, opts);
28
+ registerTools(stub, opts);
29
+ return tools;
30
+ }
31
+ /**
32
+ * Measure context budget for an install (registered tools + project files).
33
+ *
34
+ * @param projectPath - Project root.
35
+ * @param minimal - Optional forced profile.
36
+ */
37
+ export function measureInstallBudget(projectPath, minimal) {
38
+ const profile = minimal ?? isMinimalMode(projectPath);
39
+ return measureBudget({
40
+ projectPath,
41
+ packagePath: packageRoot(),
42
+ tools: collectRegisteredTools(profile),
43
+ minimal: profile,
44
+ });
45
+ }
@@ -1,6 +1,7 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
3
  import { AGENTS, agentById, detectConfiguredAgents } from "../../shared/agents.js";
4
+ import { isMinimalMode } from "../../shared/exposure.js";
4
5
  import { globError, hasBackend, hasBatchBackend, readLawManifest } from "./laws.js";
5
6
  /**
6
7
  * Run the speclaw installation health checks against a project: ai-specs and
@@ -101,6 +102,14 @@ export function doctor(projectPath) {
101
102
  detail: has(".mcp.json") ? "present" : "missing — scaffold writes it",
102
103
  });
103
104
  lawEnforcementChecks(projectPath, checks);
105
+ const minimal = isMinimalMode(projectPath);
106
+ checks.push({
107
+ name: "exposure profile",
108
+ ok: true,
109
+ detail: minimal
110
+ ? "minimal — setup/lifecycle MCP tools are omitted from registration"
111
+ : "full — all MCP tools registered (no server-side defer_loading)",
112
+ });
104
113
  return checks;
105
114
  }
106
115
  /** The law ids recorded as loaded into agent context, from the append-only log. */