@esneiderbravo/speclaw 0.3.12 → 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 (49) hide show
  1. package/README.md +2 -2
  2. package/dist/cli/commands/lawbook.js +42 -1
  3. package/dist/cli/commands/query.js +20 -0
  4. package/dist/cli/commands/update.js +10 -0
  5. package/dist/cli/index.js +8 -0
  6. package/dist/modules/compass/diff-context.js +134 -0
  7. package/dist/modules/compass/explore-rich.js +129 -0
  8. package/dist/modules/compass/impact-summary.js +33 -0
  9. package/dist/modules/compass/register.js +164 -74
  10. package/dist/modules/foundation/context-budget.js +1 -14
  11. package/dist/modules/foundation/doctor.js +77 -0
  12. package/dist/modules/foundation/register-core.js +57 -88
  13. package/dist/modules/foundation/register.js +1 -21
  14. package/dist/modules/foundation/setup-tool.js +96 -0
  15. package/dist/modules/lawbook/assets/commands/archive.md +1 -1
  16. package/dist/modules/lawbook/assets/commands/draft.md +1 -1
  17. package/dist/modules/lawbook/assets/commands/explore.md +1 -1
  18. package/dist/modules/lawbook/assets/commands/investigate.md +7 -0
  19. package/dist/modules/lawbook/assets/commands/sync.md +2 -2
  20. package/dist/modules/lawbook/assets/rules/spec-reports-disciplines.md +8 -0
  21. package/dist/modules/lawbook/assets/skills/archive/SKILL.md +1 -1
  22. package/dist/modules/lawbook/assets/skills/archive/steps/03-validate-and-sync.md +3 -3
  23. package/dist/modules/lawbook/assets/skills/archive/steps/04-archive.md +1 -1
  24. package/dist/modules/lawbook/assets/skills/draft/steps/02-understand.md +1 -1
  25. package/dist/modules/lawbook/assets/skills/draft/steps/04-write-artifacts.md +1 -0
  26. package/dist/modules/lawbook/assets/skills/draft/steps/05-validate.md +1 -1
  27. package/dist/modules/lawbook/assets/skills/explore/steps/01-investigate.md +1 -1
  28. package/dist/modules/lawbook/assets/skills/investigate/SKILL.md +10 -0
  29. package/dist/modules/lawbook/assets/skills/investigate/steps/01-investigate.md +7 -0
  30. package/dist/modules/lawbook/assets/skills/investigate/steps/02-hand-off.md +6 -0
  31. package/dist/modules/lawbook/assets/skills/quick/steps/02-implement.md +1 -1
  32. package/dist/modules/lawbook/assets/skills/sync/SKILL.md +1 -1
  33. package/dist/modules/lawbook/assets/skills/sync/steps/03-validate.md +1 -1
  34. package/dist/modules/lawbook/assets/skills/sync/steps/04-promote.md +1 -1
  35. package/dist/modules/lawbook/bugfix.js +195 -0
  36. package/dist/modules/lawbook/change-tool.js +90 -0
  37. package/dist/modules/lawbook/engine.js +70 -7
  38. package/dist/modules/lawbook/investigate.js +358 -0
  39. package/dist/modules/lawbook/levels.js +49 -2
  40. package/dist/modules/lawbook/register.js +102 -52
  41. package/dist/modules/lawbook/stack-parse.js +135 -0
  42. package/dist/modules/tools/register.js +4 -26
  43. package/dist/shared/deprecation.js +99 -0
  44. package/dist/shared/exposure.js +5 -19
  45. package/dist/shared/git.js +25 -0
  46. package/dist/shared/mcp.js +29 -3
  47. package/dist/shared/output-budget.js +68 -0
  48. package/dist/shared/tool-catalog.js +49 -0
  49. 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,5 +1,7 @@
1
1
  import { specInit, specValidate, specSync, specArchive, specList, } from "../../modules/lawbook/engine.js";
2
2
  import { handleLevel } from "../../modules/lawbook/quick.js";
3
+ import { scaffoldBugfix } from "../../modules/lawbook/bugfix.js";
4
+ import { investigate, formatInvestigateResult } from "../../modules/lawbook/investigate.js";
3
5
  import { list } from "../lib/args.js";
4
6
  import { ui } from "../lib/ui.js";
5
7
  function today() {
@@ -89,6 +91,45 @@ export async function runSpec(flags) {
89
91
  r.promoted.forEach((p) => ui.info(`${r.created.includes(p) ? "created" : "updated"}: ${p}`));
90
92
  return;
91
93
  }
94
+ case "investigate": {
95
+ const stackTrace = typeof flags["stack-trace"] === "string"
96
+ ? flags["stack-trace"]
97
+ : typeof flags.stackTrace === "string"
98
+ ? flags.stackTrace
99
+ : undefined;
100
+ const symptom = typeof flags.symptom === "string" ? flags.symptom : undefined;
101
+ const result = await investigate({
102
+ projectPath: cwd,
103
+ stackTrace,
104
+ symptom,
105
+ hintPaths: list(flags.path),
106
+ maxSuspects: flags.max !== undefined && flags.max !== true ? Number(flags.max) : undefined,
107
+ });
108
+ if (flags.json) {
109
+ console.log(formatInvestigateResult(result));
110
+ return;
111
+ }
112
+ console.log(formatInvestigateResult(result));
113
+ return;
114
+ }
115
+ case "draft": {
116
+ if (!flags.bug) {
117
+ ui.err("Usage: speclaw lawbook draft --bug <name> [--level N] [--json]");
118
+ process.exit(1);
119
+ }
120
+ const name = typeof flags.bug === "string" ? flags.bug : req(change, "lawbook draft --bug <name>");
121
+ const levelFlag = flags.level;
122
+ const level = levelFlag === undefined || levelFlag === true
123
+ ? undefined
124
+ : Number(levelFlag);
125
+ const result = scaffoldBugfix(cwd, name, { level });
126
+ if (flags.json) {
127
+ console.log(JSON.stringify(result, null, 2));
128
+ return;
129
+ }
130
+ ui.ok(`bug change "${name}" scaffolded at ${result.dir}`);
131
+ return;
132
+ }
92
133
  case "archive": {
93
134
  const r = specArchive(cwd, req(change, "lawbook archive <change>"), today());
94
135
  ui.ok(`archived to ${r.archivedTo} (${r.promoted.length} spec(s) promoted)`);
@@ -103,7 +144,7 @@ export async function runSpec(flags) {
103
144
  return;
104
145
  }
105
146
  default:
106
- ui.err("Usage: speclaw lawbook <init|list|validate|sync|archive|level> [change]");
147
+ ui.err("Usage: speclaw lawbook <init|list|validate|sync|archive|level|draft|investigate> [change]");
107
148
  process.exit(1);
108
149
  }
109
150
  }
@@ -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}`);
@@ -135,6 +135,16 @@ const MIGRATIONS = [
135
135
  "docs/standards/lawbook.md wording if the project still says every change needs all four artifacts.\n" +
136
136
  "- Preserve all project-specific wording; only apply these speclaw-authored changes.",
137
137
  },
138
+ {
139
+ version: "0.4.0",
140
+ describe: "Tool surface — eight 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" +
146
+ "- Preserve all project-specific wording; only apply these speclaw-authored changes.",
147
+ },
138
148
  ];
139
149
  /**
140
150
  * Update speclaw and bring the current project up to date without a full re-init:
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
@@ -35,6 +36,8 @@ Lawbook (spec-driven workflow)
35
36
  lawbook init Create the lawbook/ workspace
36
37
  lawbook list Active/archived changes and capabilities
37
38
  lawbook level <mode> Propose/set/promote/explain ceremony level (--json)
39
+ lawbook draft --bug <c> Scaffold a bug change (bugfix.md + reports)
40
+ lawbook investigate Rank bug suspects from graph (--symptom / --stack-trace, --json)
38
41
  lawbook validate <c> Validate a change's artifacts
39
42
  lawbook sync <c> Promote delta specs to canonical
40
43
  lawbook archive <c> Finalize and archive a change
@@ -102,6 +105,10 @@ function maybeHeader(cmd, flags) {
102
105
  return;
103
106
  if (cmd === "lawbook" && flags.json && flags._[0] === "level")
104
107
  return;
108
+ if (cmd === "lawbook" && flags.json && flags._[0] === "investigate")
109
+ return;
110
+ if (cmd === "lawbook" && flags.json && flags._[0] === "draft")
111
+ return;
105
112
  header();
106
113
  }
107
114
  /** Run the handler for a single command. Returns when the command completes. */
@@ -138,6 +145,7 @@ async function dispatch(cmd, flags) {
138
145
  case "impact":
139
146
  case "trace":
140
147
  case "affected-tests":
148
+ case "diff-context":
141
149
  case "hotspots":
142
150
  case "coupling":
143
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
+ }