@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
@@ -1,19 +1,16 @@
1
1
  import path from "node:path";
2
2
  import { z } from "zod";
3
- import { defineTool, text } from "../../shared/mcp.js";
3
+ import { defineTool, defineAliasTool, text } from "../../shared/mcp.js";
4
4
  import { shouldExpose } from "../../shared/exposure.js";
5
+ import { aliasesEnabled } from "../../shared/tool-catalog.js";
6
+ import { logDeprecatedCall, prefixDeprecated } from "../../shared/deprecation.js";
5
7
  import { assetsDir } from "../../shared/paths.js";
6
8
  import { copyRendered } from "../../shared/install.js";
7
- import { specInit, specValidate, specSync, specArchive, specList } from "./engine.js";
8
- import { handleLevel } from "./quick.js";
9
9
  import { investigate, formatInvestigateResult } from "./investigate.js";
10
- import { buildCoverageReport, loadCoverageConfig, renderCoverageAgent } from "./coverage.js";
11
- import { buildDriftReport, renderDriftAgent } from "./drift.js";
10
+ import { handleLawbookChange, lawbookChangeSchema } from "./change-tool.js";
12
11
  const ASSETS = assetsDir(import.meta.url);
13
12
  /**
14
- * Install the spec module's workflow interface into a project's ai-specs/:
15
- * the draft/build/sync/archive/explore skills, the /spec commands, and the
16
- * mandatory-task-steps rule. Always installed — it's the core workflow.
13
+ * Install the spec module's workflow interface into a project's ai-specs/.
17
14
  */
18
15
  export function installWorkflow(projectPath, vars, report, opts) {
19
16
  const aiSpecs = path.join(projectPath, "ai-specs");
@@ -21,7 +18,7 @@ export function installWorkflow(projectPath, vars, report, opts) {
21
18
  copyRendered(path.join(ASSETS, "commands"), path.join(aiSpecs, "commands", "lawbook"), vars, report, opts);
22
19
  copyRendered(path.join(ASSETS, "rules"), path.join(aiSpecs, "rules"), vars, report, opts);
23
20
  }
24
- /** Register the spec workflow MCP tools (init, list, validate, sync, archive). */
21
+ /** Register the spec workflow MCP tools. */
25
22
  export function registerSpec(server, opts = {}) {
26
23
  const minimal = Boolean(opts.minimal);
27
24
  const add = (name, description, inputSchema, handler) => {
@@ -29,57 +26,102 @@ export function registerSpec(server, opts = {}) {
29
26
  return;
30
27
  defineTool(server, { name, description, inputSchema, handler });
31
28
  };
32
- add("lawbook_init", "Create the lawbook/ workspace (specs, changes, archive, config). Idempotent.", { projectPath: z.string() }, async ({ projectPath }) => text(specInit(projectPath)));
33
- add("lawbook_list", "List active changes, archives, and canonical capabilities under lawbook/.", { projectPath: z.string() }, async ({ projectPath }) => text(specList(projectPath)));
34
- add("lawbook_level", "Propose, set, promote, or explain a change's ceremony level (0–3).", {
35
- projectPath: z.string(),
36
- mode: z.enum(["propose", "set", "promote", "explain"]),
37
- change: z.string().optional(),
38
- paths: z.array(z.string()).optional(),
39
- symbols: z.array(z.string()).optional(),
40
- level: z.union([z.literal(0), z.literal(1), z.literal(2), z.literal(3)]).optional(),
41
- reason: z.string().optional(),
42
- }, async (args) => text(handleLevel(args)));
43
- add("lawbook_investigate", "Rank bug origins from the graph. Pass stackTrace or symptom. Returns suspects with reasons — evidence, not a verdict.", {
29
+ add("lawbook_change", "Lawbook lifecycle: init, list, validate, sync, archive, level, coverage, drift.", lawbookChangeSchema, async (args) => text(handleLawbookChange(args)));
30
+ add("lawbook_investigate", "Rank bug origins from the graph. Pass stackTrace or symptom — evidence, not verdict.", {
44
31
  projectPath: z.string(),
45
32
  stackTrace: z.string().optional(),
46
33
  symptom: z.string().optional(),
47
34
  hintPaths: z.array(z.string()).optional(),
48
35
  maxSuspects: z.number().int().min(1).max(25).optional(),
49
36
  }, async (args) => text(formatInvestigateResult(await investigate(args))));
50
- add("lawbook_validate", "Validate a change's proposal, tasks, and delta specs before build or sync.", { projectPath: z.string(), change: z.string() }, async ({ projectPath, change }) => text(specValidate(projectPath, change)));
51
- add("lawbook_sync", "Promote a change's delta specs into canonical lawbook/specs/ without archiving.", { projectPath: z.string(), change: z.string() }, async ({ projectPath, change }) => text(specSync(projectPath, change)));
52
- add("lawbook_archive", "Sync a change into canonical specs, then move it under changes/archive/.", {
53
- projectPath: z.string(),
54
- change: z.string(),
55
- date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
56
- }, async ({ projectPath, change, date }) => text(specArchive(projectPath, change, date)));
57
- add("lawbook_coverage", "Report which requirements lack impl/test coverage before declaring work done.", {
58
- projectPath: z.string(),
59
- change: z.string().optional(),
60
- onlyDefects: z.boolean().optional(),
61
- json: z.boolean().optional(),
62
- }, async ({ projectPath, change, onlyDefects, json }) => {
63
- const cfg = loadCoverageConfig(projectPath);
64
- const report = buildCoverageReport(projectPath, { change, cfg });
65
- if (json)
66
- return text(JSON.stringify(report));
67
- return text(renderCoverageAgent(report, onlyDefects !== false));
37
+ if (minimal || !aliasesEnabled())
38
+ return;
39
+ const aliasHandler = (alias, action) => async (args) => {
40
+ logDeprecatedCall(args.projectPath, alias);
41
+ const merged = { ...args, action };
42
+ const result = handleLawbookChange(merged);
43
+ const body = typeof result === "string" ? result : JSON.stringify(result, null, 2);
44
+ return text(prefixDeprecated(alias, body));
45
+ };
46
+ defineAliasTool(server, {
47
+ name: "lawbook_init",
48
+ description: "Deprecated alias for lawbook_change action init.",
49
+ inputSchema: { projectPath: z.string() },
50
+ handler: aliasHandler("lawbook_init", "init"),
68
51
  });
69
- add("lawbook_drift", "Report deterministic drift between sealed spec anchors and the code graph. Call before claiming a task is done.", {
70
- projectPath: z.string(),
71
- capability: z.string().optional(),
72
- includeReverse: z.boolean().optional(),
73
- maxItems: z.number().int().min(1).max(50).optional(),
74
- json: z.boolean().optional(),
75
- }, async ({ projectPath, capability, includeReverse, maxItems, json }) => {
76
- const report = buildDriftReport(projectPath, {
77
- capability,
78
- reverse: includeReverse === true,
79
- failOn: "semantic",
80
- });
81
- if (json)
82
- return text(JSON.stringify(report));
83
- return text(renderDriftAgent(report, maxItems ?? 10));
52
+ defineAliasTool(server, {
53
+ name: "lawbook_list",
54
+ description: "Deprecated alias for lawbook_change action list.",
55
+ inputSchema: { projectPath: z.string() },
56
+ handler: aliasHandler("lawbook_list", "list"),
57
+ });
58
+ defineAliasTool(server, {
59
+ name: "lawbook_validate",
60
+ description: "Deprecated alias for lawbook_change action validate.",
61
+ inputSchema: { projectPath: z.string(), change: z.string() },
62
+ handler: aliasHandler("lawbook_validate", "validate"),
63
+ });
64
+ defineAliasTool(server, {
65
+ name: "lawbook_sync",
66
+ description: "Deprecated alias for lawbook_change action sync.",
67
+ inputSchema: { projectPath: z.string(), change: z.string() },
68
+ handler: aliasHandler("lawbook_sync", "sync"),
69
+ });
70
+ defineAliasTool(server, {
71
+ name: "lawbook_archive",
72
+ description: "Deprecated alias for lawbook_change action archive.",
73
+ inputSchema: {
74
+ projectPath: z.string(),
75
+ change: z.string(),
76
+ date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
77
+ },
78
+ handler: aliasHandler("lawbook_archive", "archive"),
79
+ });
80
+ defineAliasTool(server, {
81
+ name: "lawbook_level",
82
+ description: "Deprecated alias for lawbook_change action level.",
83
+ inputSchema: {
84
+ projectPath: z.string(),
85
+ mode: z.string().optional(),
86
+ change: z.string().optional(),
87
+ level: z.number().optional(),
88
+ reason: z.string().optional(),
89
+ },
90
+ handler: async (args) => {
91
+ logDeprecatedCall(args.projectPath, "lawbook_level");
92
+ const body = JSON.stringify(handleLawbookChange({ ...args, action: "level" }), null, 2);
93
+ return text(prefixDeprecated("lawbook_level", body));
94
+ },
95
+ });
96
+ defineAliasTool(server, {
97
+ name: "lawbook_coverage",
98
+ description: "Deprecated alias for lawbook_change action coverage.",
99
+ inputSchema: {
100
+ projectPath: z.string(),
101
+ change: z.string().optional(),
102
+ onlyDefects: z.boolean().optional(),
103
+ json: z.boolean().optional(),
104
+ },
105
+ handler: async (args) => {
106
+ logDeprecatedCall(args.projectPath, "lawbook_coverage");
107
+ const body = JSON.stringify(handleLawbookChange({ ...args, action: "coverage" }), null, 2);
108
+ return text(prefixDeprecated("lawbook_coverage", body));
109
+ },
110
+ });
111
+ defineAliasTool(server, {
112
+ name: "lawbook_drift",
113
+ description: "Deprecated alias for lawbook_change action drift.",
114
+ inputSchema: {
115
+ projectPath: z.string(),
116
+ capability: z.string().optional(),
117
+ includeReverse: z.boolean().optional(),
118
+ maxItems: z.number().int().optional(),
119
+ json: z.boolean().optional(),
120
+ },
121
+ handler: async (args) => {
122
+ logDeprecatedCall(args.projectPath, "lawbook_drift");
123
+ const body = JSON.stringify(handleLawbookChange({ ...args, action: "drift" }), null, 2);
124
+ return text(prefixDeprecated("lawbook_drift", body));
125
+ },
84
126
  });
85
127
  }
@@ -1,27 +1,5 @@
1
- import { z } from "zod";
2
- import { defineTool, text } from "../../shared/mcp.js";
3
- import { shouldExpose } from "../../shared/exposure.js";
4
- import { emptyReport } from "../../shared/install.js";
5
- import { refreshAgents } from "../../shared/agents.js";
6
- import { loadPacks, installPack } from "./packs.js";
7
- export { loadPacks, installPack };
8
- /** Register the tools-module MCP tools (list_packs, add_pack). */
9
- export function registerTools(server, opts = {}) {
10
- const minimal = Boolean(opts.minimal);
11
- const add = (name, description, inputSchema, handler) => {
12
- if (!shouldExpose(name, minimal))
13
- return;
14
- defineTool(server, { name, description, inputSchema, handler });
15
- };
16
- add("list_packs", "List available speclaw skill packs and what each contains.", {}, async () => text(loadPacks()));
17
- add("add_pack", "Install one pack into an initialized project and refresh IDE symlinks.", {
18
- projectPath: z.string(),
19
- pack: z.string(),
20
- vars: z.record(z.string()).optional(),
21
- }, async ({ projectPath, pack, vars }) => {
22
- const report = emptyReport();
23
- installPack(projectPath, pack, vars ?? {}, report);
24
- refreshAgents(projectPath, report);
25
- return text(report);
26
- });
1
+ export { loadPacks, installPack } from "./packs.js";
2
+ /** Packs are registered via `speclaw_setup` — no standalone MCP tools. */
3
+ export function registerTools(_server, _opts = {}) {
4
+ /* consolidated into speclaw_setup */
27
5
  }
@@ -0,0 +1,99 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { ALIAS_TARGETS } from "./tool-catalog.js";
4
+ const LOG_NAME = "deprecated-calls.jsonl";
5
+ /** Append-only log of deprecated alias invocations under `.speclaw/`. */
6
+ export function logDeprecatedCall(projectPath, alias) {
7
+ const target = ALIAS_TARGETS[alias];
8
+ if (!target)
9
+ return;
10
+ const dir = path.join(projectPath, ".speclaw");
11
+ try {
12
+ fs.mkdirSync(dir, { recursive: true });
13
+ const line = JSON.stringify({
14
+ at: new Date().toISOString(),
15
+ alias,
16
+ replacement: target,
17
+ });
18
+ fs.appendFileSync(path.join(dir, LOG_NAME), line + "\n", "utf8");
19
+ }
20
+ catch {
21
+ /* best-effort */
22
+ }
23
+ }
24
+ /** Read recent deprecated alias counts for doctor. */
25
+ export function readDeprecatedCallCounts(projectPath) {
26
+ const counts = new Map();
27
+ const file = path.join(projectPath, ".speclaw", LOG_NAME);
28
+ try {
29
+ for (const line of fs.readFileSync(file, "utf8").split("\n")) {
30
+ if (!line.trim())
31
+ continue;
32
+ const row = JSON.parse(line);
33
+ if (row.alias)
34
+ counts.set(row.alias, (counts.get(row.alias) ?? 0) + 1);
35
+ }
36
+ }
37
+ catch {
38
+ /* no log */
39
+ }
40
+ return counts;
41
+ }
42
+ const SCAN_PATHS = ["CLAUDE.md", "AGENTS.md", "LAWS.md", "docs/compass.md"];
43
+ const SCAN_DIRS = [".cursor/rules", ".claude/rules", "ai-specs/rules"];
44
+ function listMarkdownFiles(dir) {
45
+ const out = [];
46
+ try {
47
+ for (const ent of fs.readdirSync(dir, { withFileTypes: true })) {
48
+ const full = path.join(dir, ent.name);
49
+ if (ent.isDirectory())
50
+ out.push(...listMarkdownFiles(full));
51
+ else if (ent.name.endsWith(".md"))
52
+ out.push(full);
53
+ }
54
+ }
55
+ catch {
56
+ /* missing dir */
57
+ }
58
+ return out;
59
+ }
60
+ /** Find personalized files that still cite retired MCP tool names. */
61
+ export function scanRetiredToolReferences(projectPath) {
62
+ const files = new Set();
63
+ for (const rel of SCAN_PATHS) {
64
+ const full = path.join(projectPath, rel);
65
+ if (fs.existsSync(full))
66
+ files.add(full);
67
+ }
68
+ for (const rel of SCAN_DIRS) {
69
+ for (const full of listMarkdownFiles(path.join(projectPath, rel)))
70
+ files.add(full);
71
+ }
72
+ const hits = [];
73
+ for (const file of files) {
74
+ let text;
75
+ try {
76
+ text = fs.readFileSync(file, "utf8");
77
+ }
78
+ catch {
79
+ continue;
80
+ }
81
+ for (const [alias, replacement] of Object.entries(ALIAS_TARGETS)) {
82
+ if (text.includes(alias)) {
83
+ hits.push({ file: path.relative(projectPath, file), alias, replacement });
84
+ }
85
+ }
86
+ }
87
+ return hits;
88
+ }
89
+ const REMOVAL_VERSION = "0.6.0";
90
+ /**
91
+ * Prefix a tool response when invoked through a deprecated alias.
92
+ *
93
+ * @param alias - Retired tool name.
94
+ * @param body - Serialized response body.
95
+ */
96
+ export function prefixDeprecated(alias, body) {
97
+ const target = ALIAS_TARGETS[alias] ?? "see canonical tools";
98
+ return `[deprecated] ${alias} → ${target}. Retiring in ${REMOVAL_VERSION}.\n\n${body}`;
99
+ }
@@ -4,29 +4,14 @@ import { fileURLToPath } from "node:url";
4
4
  import { readManifest } from "./manifest.js";
5
5
  /**
6
6
  * Tools omitted when the exposure profile is `minimal`. Kept tools are the
7
- * discovery + law loop: compass_explore/search/recall, lawbook_validate/sync,
8
- * lawbook_coverage, lawbook_drift, law_verify, speclaw_check.
7
+ * discovery + law loop: compass_explore/find/diff_context, lawbook_change.
8
+ * Deprecated aliases are never registered in minimal mode.
9
9
  */
10
10
  export const MINIMAL_OMIT = new Set([
11
11
  "compass_index",
12
- "compass_watch",
13
- "compass_impact",
14
- "compass_affected_tests",
15
- "compass_hotspots",
16
- "compass_coupling",
17
- "compass_trace",
18
- "compass_visualize",
19
- "lawbook_init",
20
- "lawbook_archive",
21
- "lawbook_list",
22
- "lawbook_level",
23
12
  "lawbook_investigate",
24
- "init_project",
25
- "scaffold",
26
- "configure_agent",
27
- "doctor",
28
- "add_pack",
29
- "list_packs",
13
+ "speclaw_setup",
14
+ "speclaw_check",
30
15
  ]);
31
16
  /**
32
17
  * Whether minimal exposure is active for this process / project.
@@ -86,3 +86,28 @@ export function changedFiles(projectPath, base) {
86
86
  .map((l) => l.trim())
87
87
  .filter(Boolean);
88
88
  }
89
+ /**
90
+ * Project-relative paths with uncommitted changes vs `HEAD` (staged and unstaged).
91
+ *
92
+ * @param projectPath - Directory inside the work tree.
93
+ */
94
+ export function worktreeChangedFiles(projectPath) {
95
+ if (!isGitRepo(projectPath))
96
+ return [];
97
+ const res = spawnSync("git", [
98
+ "-C",
99
+ projectPath,
100
+ "-c",
101
+ "core.quotePath=false",
102
+ "diff",
103
+ "--name-only",
104
+ "--diff-filter=ACMR",
105
+ "HEAD",
106
+ ], { encoding: "utf8" });
107
+ if (res.status !== 0 || typeof res.stdout !== "string")
108
+ return [];
109
+ return res.stdout
110
+ .split("\n")
111
+ .map((l) => l.trim())
112
+ .filter(Boolean);
113
+ }
@@ -1,18 +1,22 @@
1
1
  import { loadDeclaredBudget } from "./exposure.js";
2
2
  import { toolDefinitionTokens } from "./schema-tokens.js";
3
3
  import { countWords } from "./tokens.js";
4
+ import { applyTextBudget } from "./output-budget.js";
4
5
  /**
5
- * Wrap a value as an MCP text tool-result.
6
+ * Wrap a value as an MCP text tool-result, optionally applying an output budget.
6
7
  *
7
8
  * @param value - Payload to return; strings are emitted verbatim, other values are pretty-printed as JSON.
9
+ * @param mode - Output mode (`brief` default) for token budgeting.
8
10
  * @returns An MCP result object with a single text content block.
9
11
  */
10
- export function text(value) {
12
+ export function text(value, mode = "brief") {
13
+ const raw = typeof value === "string" ? value : JSON.stringify(value, null, 2);
14
+ const budgeted = applyTextBudget(raw, mode);
11
15
  return {
12
16
  content: [
13
17
  {
14
18
  type: "text",
15
- text: typeof value === "string" ? value : JSON.stringify(value, null, 2),
19
+ text: budgeted.text,
16
20
  },
17
21
  ],
18
22
  };
@@ -47,3 +51,25 @@ export function defineTool(server, spec) {
47
51
  ...(spec.annotations ? { annotations: spec.annotations } : {}),
48
52
  }, spec.handler);
49
53
  }
54
+ const ALIAS_MAX_WORDS = 12;
55
+ const ALIAS_MAX_TOKENS = 200;
56
+ /**
57
+ * Register a deprecated alias with a terse description (not counted in the
58
+ * canonical eight-tool limit).
59
+ */
60
+ export function defineAliasTool(server, spec) {
61
+ const words = countWords(spec.description);
62
+ if (words > ALIAS_MAX_WORDS) {
63
+ throw new Error(`alias ${spec.name}: description is ${words} words (cap ${ALIAS_MAX_WORDS})`);
64
+ }
65
+ const cost = toolDefinitionTokens({
66
+ name: spec.name,
67
+ description: spec.description,
68
+ inputSchema: spec.inputSchema,
69
+ });
70
+ if (cost > ALIAS_MAX_TOKENS) {
71
+ throw new Error(`alias ${spec.name}: ${cost} tokens exceeds alias cap ${ALIAS_MAX_TOKENS}`);
72
+ }
73
+ const inputSchema = (spec.inputSchema ?? {});
74
+ server.registerTool(spec.name, { description: spec.description, inputSchema }, spec.handler);
75
+ }
@@ -0,0 +1,68 @@
1
+ /** Declared output token ceilings (estimator v1: ~4 chars per token). */
2
+ export const OUTPUT_BUDGET = {
3
+ brief: 1500,
4
+ full: 4500,
5
+ };
6
+ const TRUNCATION_SUFFIX = `\n… [truncated — use mode:"full" or narrow includes]`;
7
+ /** Stable offline token estimate for budgeting (not a real tokenizer). */
8
+ export function estimateTokens(text) {
9
+ return Math.ceil(text.length / 4);
10
+ }
11
+ /**
12
+ * Trim `text` to fit `mode` budget; returns the possibly shortened text and
13
+ * whether truncation occurred.
14
+ */
15
+ export function applyTextBudget(text, mode = "brief") {
16
+ const budget = OUTPUT_BUDGET[mode];
17
+ const tokens = estimateTokens(text);
18
+ if (tokens <= budget)
19
+ return { text, truncated: false, omittedChars: 0 };
20
+ const suffixTokens = estimateTokens(TRUNCATION_SUFFIX);
21
+ const bodyBudget = Math.max(1, budget - suffixTokens);
22
+ const maxChars = bodyBudget * 4;
23
+ const trimmed = text.slice(0, maxChars);
24
+ return {
25
+ text: trimmed + TRUNCATION_SUFFIX,
26
+ truncated: true,
27
+ omittedChars: text.length - maxChars,
28
+ };
29
+ }
30
+ /**
31
+ * Truncate known list fields on an explore-style object before serialization.
32
+ *
33
+ * @param value - Plain object to mutate in place (arrays shortened, counts kept).
34
+ * @param mode - Output mode controlling list limits.
35
+ * @param truncated - Collector for explicit truncation records.
36
+ */
37
+ export function budgetExploreShape(value, mode, truncated) {
38
+ const maxCallers = mode === "full" ? 40 : 12;
39
+ const maxSourceLines = mode === "full" ? 120 : 40;
40
+ const symbol = value.symbol;
41
+ if (symbol && typeof symbol.source === "string") {
42
+ const lines = symbol.source.split("\n");
43
+ if (lines.length > maxSourceLines) {
44
+ const omitted = lines.length - maxSourceLines;
45
+ symbol.source = lines.slice(0, maxSourceLines).join("\n") + "\n…";
46
+ truncated.push({
47
+ field: "symbol.source",
48
+ omitted,
49
+ hint: 'use mode:"full" or omit source from include',
50
+ });
51
+ }
52
+ }
53
+ for (const field of ["callers", "callees"]) {
54
+ const list = value[field];
55
+ if (!Array.isArray(list))
56
+ continue;
57
+ if (list.length > maxCallers) {
58
+ const omitted = list.length - maxCallers;
59
+ value[`${field}Total`] = list.length;
60
+ value[field] = list.slice(0, maxCallers);
61
+ truncated.push({
62
+ field,
63
+ omitted,
64
+ hint: 'use mode:"full" or narrow with include',
65
+ });
66
+ }
67
+ }
68
+ }
@@ -0,0 +1,49 @@
1
+ /** Canonical MCP tools counted toward the surface limit (aliases excluded). */
2
+ export const CANONICAL_TOOLS = [
3
+ "compass_explore",
4
+ "compass_find",
5
+ "compass_diff_context",
6
+ "compass_index",
7
+ "lawbook_change",
8
+ "lawbook_investigate",
9
+ "speclaw_setup",
10
+ "speclaw_check",
11
+ ];
12
+ export const MAX_CANONICAL_TOOLS = 8;
13
+ /** Retired MCP names → canonical replacement hint for deprecation notices. */
14
+ export const ALIAS_TARGETS = {
15
+ compass_search: "compass_find (mode: exact)",
16
+ compass_recall: "compass_find (mode: concept)",
17
+ compass_impact: 'compass_explore (include: ["blast_radius"])',
18
+ compass_trace: "compass_explore (to: <symbol>)",
19
+ compass_affected_tests: "compass_explore or compass_diff_context",
20
+ compass_hotspots: "compass_explore or compass_diff_context",
21
+ compass_coupling: "compass_diff_context",
22
+ compass_watch: "compass_index (action: start|stop|status)",
23
+ lawbook_init: 'lawbook_change (action: "init")',
24
+ lawbook_list: 'lawbook_change (action: "list")',
25
+ lawbook_validate: 'lawbook_change (action: "validate")',
26
+ lawbook_sync: 'lawbook_change (action: "sync")',
27
+ lawbook_archive: 'lawbook_change (action: "archive")',
28
+ lawbook_level: 'lawbook_change (action: "level")',
29
+ lawbook_coverage: 'lawbook_change (action: "coverage")',
30
+ lawbook_drift: 'lawbook_change (action: "drift")',
31
+ init_project: 'speclaw_setup (action: "init")',
32
+ configure_agent: 'speclaw_setup (action: "configure-agent")',
33
+ add_pack: 'speclaw_setup (action: "add-pack")',
34
+ list_packs: 'speclaw_setup (action: "list-packs")',
35
+ };
36
+ export const ALIAS_NAMES = Object.keys(ALIAS_TARGETS);
37
+ const ALIAS_SET = new Set(ALIAS_NAMES);
38
+ /** True when `name` is one of the eight canonical tools. */
39
+ export function isCanonicalTool(name) {
40
+ return CANONICAL_TOOLS.includes(name);
41
+ }
42
+ /** True when `name` is a deprecated alias (not counted in the canonical limit). */
43
+ export function isAliasTool(name) {
44
+ return ALIAS_SET.has(name);
45
+ }
46
+ /** Whether deprecated aliases should register for this process. */
47
+ export function aliasesEnabled() {
48
+ return process.env.SPECLAW_NO_ALIASES !== "1";
49
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@esneiderbravo/speclaw",
3
- "version": "0.3.13",
3
+ "version": "0.4.0",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },