@esneiderbravo/speclaw 2.0.2 → 2.0.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli/commands/check.js +11 -1
- package/dist/cli/commands/cortex.js +17 -0
- package/dist/cli/commands/index-build.js +13 -2
- package/dist/cli/commands/lawbook.js +31 -10
- package/dist/cli/commands/query.js +13 -0
- package/dist/cli/index.js +4 -1
- package/dist/modules/compass/indexer.js +14 -0
- package/dist/modules/compass/query.js +209 -62
- package/dist/modules/compass/register.js +17 -1
- package/dist/modules/cortex/compass-gate.js +84 -0
- package/dist/modules/cortex/harness.js +20 -11
- package/dist/modules/cortex/types.js +14 -0
- package/dist/modules/foundation/assets/AGENTS.template.md +2 -2
- package/dist/modules/foundation/assets/CLAUDE.template.md +4 -3
- package/dist/modules/foundation/assets/docs/compass.template.md +7 -7
- package/dist/modules/foundation/assets/docs/standards/lawbook.template.md +2 -2
- package/dist/modules/foundation/check.js +23 -5
- package/dist/modules/foundation/compass-nudge.js +189 -0
- package/dist/modules/foundation/hooks.js +18 -3
- package/dist/modules/lawbook/assets/agents/archiver.md +1 -1
- package/dist/modules/lawbook/assets/agents/explorer.md +7 -4
- package/dist/modules/lawbook/assets/agents/implementer.md +1 -1
- package/dist/modules/lawbook/assets/agents/planner.md +7 -4
- package/dist/modules/lawbook/assets/agents/reviewer.md +1 -1
- package/dist/modules/lawbook/assets/agents/tester.md +1 -1
- package/dist/modules/lawbook/assets/commands/draft.md +2 -2
- package/dist/modules/lawbook/assets/commands/quick.md +2 -2
- package/dist/modules/lawbook/assets/rules/spec-reports-disciplines.md +1 -1
- package/dist/modules/lawbook/assets/rules/spec-tasks-mandatory-steps.md +4 -4
- package/dist/modules/lawbook/assets/skills/archive/steps/02-reconcile.md +1 -1
- package/dist/modules/lawbook/assets/skills/build/steps/01-load-change.md +1 -1
- package/dist/modules/lawbook/assets/skills/cortex/steps/02-dispatch-loop.md +22 -6
- package/dist/modules/lawbook/assets/skills/draft/steps/01-ensure-workspace.md +1 -1
- package/dist/modules/lawbook/assets/skills/draft/steps/02-understand.md +7 -4
- package/dist/modules/lawbook/assets/skills/draft/steps/03-name-capabilities.md +3 -1
- package/dist/modules/lawbook/assets/skills/draft/steps/04-write-artifacts.md +12 -2
- package/dist/modules/lawbook/assets/skills/explore/steps/01-investigate.md +15 -6
- package/dist/modules/lawbook/assets/skills/explore/steps/02-summarize.md +2 -0
- package/dist/modules/lawbook/assets/skills/investigate/steps/02-hand-off.md +1 -1
- package/dist/modules/lawbook/assets/skills/quick/steps/02-implement.md +1 -1
- package/dist/modules/lawbook/assets/skills/sync/steps/01-confirm.md +1 -1
- package/dist/modules/lawbook/assets/skills/sync/steps/02-reconcile.md +1 -1
- package/dist/modules/lawbook/bugfix.js +22 -26
- package/dist/modules/lawbook/change-tool.js +15 -0
- package/dist/modules/lawbook/engine.js +11 -0
- package/dist/modules/lawbook/levels.js +2 -0
- package/dist/modules/lawbook/quick.js +14 -23
- package/dist/modules/lawbook/register.js +1 -1
- package/dist/modules/lawbook/scaffold-change.js +238 -0
- package/dist/shared/compass-calls.js +197 -0
- package/package.json +1 -1
|
@@ -14,7 +14,9 @@ async function readStdin() {
|
|
|
14
14
|
*
|
|
15
15
|
* - `--hook-payload -` reads a hook event JSON from stdin, evaluates it, prints
|
|
16
16
|
* the `hookSpecificOutput` contract, and exits 2 on `deny` (the documented
|
|
17
|
-
* command-hook block signal).
|
|
17
|
+
* command-hook block signal). `PostToolUse` never carries a
|
|
18
|
+
* `permissionDecision`: it prints `additionalContext` when the result has a
|
|
19
|
+
* reason (nothing otherwise) and always exits 0.
|
|
18
20
|
* - `--dry-run [--path P] [--event E]` previews the verdict for a synthetic
|
|
19
21
|
* action against path `P`, without blocking anything (always exits 0).
|
|
20
22
|
* - with no flags, prints a summary of the project's declared laws.
|
|
@@ -37,6 +39,14 @@ export async function runCheck(flags) {
|
|
|
37
39
|
const event = (payload.hook_event_name ?? payload.event ?? "PreToolUse");
|
|
38
40
|
const toolName = (payload.tool_name ?? payload.toolName);
|
|
39
41
|
const result = checkAction({ projectPath: cwd, event, toolName, payload });
|
|
42
|
+
if (event === "PostToolUse") {
|
|
43
|
+
// PostToolUse (feedback laws, the Compass-first nudge) carries context
|
|
44
|
+
// only and never a permission decision. Other events keep their output.
|
|
45
|
+
if (result.hookSpecificOutput) {
|
|
46
|
+
console.log(JSON.stringify({ hookSpecificOutput: result.hookSpecificOutput }));
|
|
47
|
+
}
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
40
50
|
const decision = result.verdict === "deny" ? "deny" : "allow";
|
|
41
51
|
console.log(JSON.stringify({
|
|
42
52
|
hookSpecificOutput: {
|
|
@@ -40,6 +40,7 @@ export async function runCortex(flags) {
|
|
|
40
40
|
pauseForQuestions: Boolean(flags["pause-questions"]),
|
|
41
41
|
note: typeof flags.note === "string" ? flags.note : undefined,
|
|
42
42
|
});
|
|
43
|
+
printHarnessWarnings(result);
|
|
43
44
|
console.log(JSON.stringify(result, null, 2));
|
|
44
45
|
}
|
|
45
46
|
catch (err) {
|
|
@@ -47,3 +48,19 @@ export async function runCortex(flags) {
|
|
|
47
48
|
process.exit(1);
|
|
48
49
|
}
|
|
49
50
|
}
|
|
51
|
+
/**
|
|
52
|
+
* Print the Compass-first gate outcome of a harness result to stderr — the
|
|
53
|
+
* `compassEvidence` count, then any warning — keeping stdout pure JSON.
|
|
54
|
+
*
|
|
55
|
+
* @param result - Any `handleHarness` result.
|
|
56
|
+
*/
|
|
57
|
+
export function printHarnessWarnings(result) {
|
|
58
|
+
const { compassEvidence: ev, warnings } = result;
|
|
59
|
+
if (ev) {
|
|
60
|
+
process.stderr.write(` compass-first: ${ev.calls} Compass evidence call(s) in stage "${ev.stage}" (compassGate: ${ev.mode})\n`);
|
|
61
|
+
}
|
|
62
|
+
if (!Array.isArray(warnings))
|
|
63
|
+
return;
|
|
64
|
+
for (const w of warnings)
|
|
65
|
+
process.stderr.write(` ! ${String(w)}\n`);
|
|
66
|
+
}
|
|
@@ -1,18 +1,26 @@
|
|
|
1
1
|
import { buildIndex } from "../../modules/compass/indexer.js";
|
|
2
2
|
import { startWatch } from "../../modules/compass/watcher.js";
|
|
3
|
+
import { recordCompassCall } from "../../shared/compass-calls.js";
|
|
3
4
|
import { ui, renderProgress, clearProgress } from "../lib/ui.js";
|
|
4
5
|
/** (Re)build the Compass code graph for the cwd, showing progress and final stats. */
|
|
5
6
|
export async function runIndex(flags) {
|
|
6
7
|
const cwd = process.cwd();
|
|
7
|
-
|
|
8
|
+
recordCompassCall(cwd, "compass_index");
|
|
9
|
+
const json = Boolean(flags.json);
|
|
10
|
+
if (!json)
|
|
11
|
+
ui.step("Indexing with Compass");
|
|
8
12
|
const start = Date.now();
|
|
9
13
|
const stats = await buildIndex(cwd, {
|
|
10
14
|
force: Boolean(flags.force),
|
|
11
15
|
prune: Boolean(flags.prune),
|
|
12
16
|
maxCacheMB: flags["max-cache-mb"] ? Number(flags["max-cache-mb"]) : undefined,
|
|
13
17
|
retentionDays: flags.retention ? Number(flags.retention) : undefined,
|
|
14
|
-
onProgress: (e) => renderProgress(e.done, e.total, e.file),
|
|
18
|
+
onProgress: json ? undefined : (e) => renderProgress(e.done, e.total, e.file),
|
|
15
19
|
});
|
|
20
|
+
if (json) {
|
|
21
|
+
process.stdout.write(JSON.stringify(stats, null, 2) + "\n");
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
16
24
|
clearProgress();
|
|
17
25
|
const secs = ((Date.now() - start) / 1000).toFixed(1);
|
|
18
26
|
const root = stats.rootUnchanged ? " · root unchanged" : "";
|
|
@@ -20,6 +28,9 @@ export async function runIndex(flags) {
|
|
|
20
28
|
`${stats.computed} computed · ${stats.fromCache} fromCache · ` +
|
|
21
29
|
`${stats.unchanged} unchanged · ${stats.skippedByStat} skippedByStat` +
|
|
22
30
|
`${root} (${secs}s)`);
|
|
31
|
+
const t = stats.totals;
|
|
32
|
+
ui.info(`Totals: ${t.files} files · ${t.nodes} nodes · ${t.edges} edges`);
|
|
33
|
+
ui.info(stats.nextStep);
|
|
23
34
|
}
|
|
24
35
|
/** Build the initial index, then watch for file changes to keep it fresh. */
|
|
25
36
|
export async function runWatch(_flags) {
|
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import { specInit, specValidate, specSync, specArchive, specList, } from "../../modules/lawbook/engine.js";
|
|
2
2
|
import { handleLevel } from "../../modules/lawbook/quick.js";
|
|
3
3
|
import { scaffoldBugfix } from "../../modules/lawbook/bugfix.js";
|
|
4
|
+
import { scaffoldFeature } from "../../modules/lawbook/scaffold-change.js";
|
|
4
5
|
import { investigate, formatInvestigateResult } from "../../modules/lawbook/investigate.js";
|
|
5
6
|
import { handleHarness } from "../../modules/cortex/harness.js";
|
|
7
|
+
import { printHarnessWarnings } from "./cortex.js";
|
|
6
8
|
import { list } from "../lib/args.js";
|
|
7
9
|
import { ui } from "../lib/ui.js";
|
|
8
10
|
function today() {
|
|
@@ -115,21 +117,32 @@ export async function runSpec(flags) {
|
|
|
115
117
|
return;
|
|
116
118
|
}
|
|
117
119
|
case "draft": {
|
|
118
|
-
if (!flags.bug) {
|
|
119
|
-
ui.err("Usage: speclaw lawbook draft --bug <name> [--level N] [--json]");
|
|
120
|
-
process.exit(1);
|
|
121
|
-
}
|
|
122
|
-
const name = typeof flags.bug === "string" ? flags.bug : req(change, "lawbook draft --bug <name>");
|
|
123
120
|
const levelFlag = flags.level;
|
|
124
|
-
const level = levelFlag === undefined || levelFlag === true
|
|
125
|
-
|
|
126
|
-
:
|
|
127
|
-
|
|
121
|
+
const level = levelFlag === undefined || levelFlag === true ? undefined : parseLevel(levelFlag);
|
|
122
|
+
if (flags.bug) {
|
|
123
|
+
const name = typeof flags.bug === "string" ? flags.bug : req(change, "lawbook draft --bug <name>");
|
|
124
|
+
const result = scaffoldBugfix(cwd, name, { level });
|
|
125
|
+
if (flags.json) {
|
|
126
|
+
console.log(JSON.stringify(result, null, 2));
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
ui.ok(`bug change "${name}" scaffolded at ${result.dir}`);
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
const name = req(change, "lawbook draft <name> [--level N] [--capability C] [--json] | --bug <name>");
|
|
133
|
+
const result = scaffoldFeature(cwd, name, {
|
|
134
|
+
level,
|
|
135
|
+
reason: typeof flags.reason === "string" ? flags.reason : undefined,
|
|
136
|
+
capability: typeof flags.capability === "string" ? flags.capability : undefined,
|
|
137
|
+
});
|
|
128
138
|
if (flags.json) {
|
|
129
139
|
console.log(JSON.stringify(result, null, 2));
|
|
130
140
|
return;
|
|
131
141
|
}
|
|
132
|
-
ui.ok(`
|
|
142
|
+
ui.ok(`feature change "${name}" scaffolded at ${result.dir} ` +
|
|
143
|
+
(result.level === null
|
|
144
|
+
? "(level unconfirmed — run `speclaw lawbook level set`)"
|
|
145
|
+
: `(level ${result.level})`));
|
|
133
146
|
return;
|
|
134
147
|
}
|
|
135
148
|
case "archive": {
|
|
@@ -170,6 +183,7 @@ export async function runSpec(flags) {
|
|
|
170
183
|
pauseForQuestions: Boolean(flags["pause-questions"]),
|
|
171
184
|
note: typeof flags.note === "string" ? flags.note : undefined,
|
|
172
185
|
});
|
|
186
|
+
printHarnessWarnings(result);
|
|
173
187
|
if (flags.json) {
|
|
174
188
|
console.log(JSON.stringify(result, null, 2));
|
|
175
189
|
return;
|
|
@@ -187,6 +201,13 @@ export async function runSpec(flags) {
|
|
|
187
201
|
process.exit(1);
|
|
188
202
|
}
|
|
189
203
|
}
|
|
204
|
+
/** Parse a `--level` flag value into a ceremony level; throws on anything but 0–3. */
|
|
205
|
+
function parseLevel(raw) {
|
|
206
|
+
const n = Number(Array.isArray(raw) ? raw[raw.length - 1] : raw);
|
|
207
|
+
if (n === 0 || n === 1 || n === 2 || n === 3)
|
|
208
|
+
return n;
|
|
209
|
+
throw new Error(`--level must be 0, 1, 2, or 3 (got "${String(raw)}")`);
|
|
210
|
+
}
|
|
190
211
|
/** Return the value or print a usage error and exit if it is missing. */
|
|
191
212
|
function req(value, usage) {
|
|
192
213
|
if (!value) {
|
|
@@ -5,6 +5,16 @@ import { hotspots, coupling } from "../../modules/compass/hotspots.js";
|
|
|
5
5
|
import { diffContext, formatDiffContext } from "../../modules/compass/diff-context.js";
|
|
6
6
|
import { list } from "../lib/args.js";
|
|
7
7
|
import { ui } from "../lib/ui.js";
|
|
8
|
+
import { recordCompassCall } from "../../shared/compass-calls.js";
|
|
9
|
+
/** CLI query verbs that are Compass call-log twins of an MCP tool. */
|
|
10
|
+
const LOGGED_VERBS = {
|
|
11
|
+
explore: "compass_explore",
|
|
12
|
+
search: "compass_search",
|
|
13
|
+
recall: "compass_recall",
|
|
14
|
+
impact: "compass_impact",
|
|
15
|
+
trace: "compass_trace",
|
|
16
|
+
"diff-context": "compass_diff_context",
|
|
17
|
+
};
|
|
8
18
|
/**
|
|
9
19
|
* Run a Compass query from the shell — the same surface agents call via MCP.
|
|
10
20
|
*
|
|
@@ -20,6 +30,9 @@ export async function runQuery(cmd, flags) {
|
|
|
20
30
|
const focus = list(flags.focus);
|
|
21
31
|
const maxTokens = flags["max-tokens"] ? Number(flags["max-tokens"]) : undefined;
|
|
22
32
|
const explain = Boolean(flags.explain);
|
|
33
|
+
const logged = LOGGED_VERBS[cmd];
|
|
34
|
+
if (logged)
|
|
35
|
+
recordCompassCall(cwd, logged);
|
|
23
36
|
try {
|
|
24
37
|
switch (cmd) {
|
|
25
38
|
case "explore": {
|
package/dist/cli/index.js
CHANGED
|
@@ -18,7 +18,7 @@ Setup
|
|
|
18
18
|
agent add <id> Configure another agent later (symlinks + MCP)
|
|
19
19
|
|
|
20
20
|
Compass (code intelligence — the same surface agents use via MCP)
|
|
21
|
-
index (Re)build the local code graph (--force / --prune)
|
|
21
|
+
index (Re)build the local code graph (--force / --prune / --json)
|
|
22
22
|
watch Keep the index fresh on file changes
|
|
23
23
|
explore <node> A node's source + callers/callees
|
|
24
24
|
search <query> Hybrid find (BM25+vector+name); --focus --max-tokens --explain
|
|
@@ -39,6 +39,7 @@ Lawbook (spec-driven workflow)
|
|
|
39
39
|
lawbook init Create the lawbook/ workspace
|
|
40
40
|
lawbook list Active/archived changes and capabilities
|
|
41
41
|
lawbook level <mode> Propose/set/promote/explain ceremony level (--json)
|
|
42
|
+
lawbook draft <name> Scaffold a feature change (--level N, --capability C, --json)
|
|
42
43
|
lawbook draft --bug <c> Scaffold a bug change (bugfix.md + reports)
|
|
43
44
|
lawbook investigate Rank bug suspects from graph (--symptom / --stack-trace, --json)
|
|
44
45
|
lawbook validate <c> Validate a change's artifacts
|
|
@@ -115,6 +116,8 @@ function maybeHeader(cmd, flags) {
|
|
|
115
116
|
return;
|
|
116
117
|
if (cmd === "quick" && flags.json)
|
|
117
118
|
return;
|
|
119
|
+
if (cmd === "index" && flags.json)
|
|
120
|
+
return;
|
|
118
121
|
if (cmd === "lawbook" && flags.json && flags._[0] === "level")
|
|
119
122
|
return;
|
|
120
123
|
if (cmd === "lawbook" && flags.json && flags._[0] === "investigate")
|
|
@@ -212,6 +212,8 @@ export async function buildIndex(projectPath, onProgressOrOpts) {
|
|
|
212
212
|
removed: 0,
|
|
213
213
|
rootUnchanged: false,
|
|
214
214
|
embedder: embedder.id,
|
|
215
|
+
totals: { files: 0, nodes: 0, edges: 0 },
|
|
216
|
+
nextStep: "",
|
|
215
217
|
};
|
|
216
218
|
const cfg = loadAffectedConfig(projectPath);
|
|
217
219
|
const existing = new Map();
|
|
@@ -398,6 +400,8 @@ export async function buildIndex(projectPath, onProgressOrOpts) {
|
|
|
398
400
|
evictCacheBySize(db, maxCacheMB);
|
|
399
401
|
db.prepare("INSERT INTO meta(key, value) VALUES ('indexed_at', ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value").run(new Date().toISOString());
|
|
400
402
|
clearNeedsReindex(db);
|
|
403
|
+
stats.totals = countTotals(db);
|
|
404
|
+
stats.nextStep = indexNextStep(stats.totals);
|
|
401
405
|
db.exec("COMMIT");
|
|
402
406
|
}
|
|
403
407
|
catch (err) {
|
|
@@ -416,6 +420,16 @@ export async function buildIndex(projectPath, onProgressOrOpts) {
|
|
|
416
420
|
}
|
|
417
421
|
return stats;
|
|
418
422
|
}
|
|
423
|
+
function countTotals(db) {
|
|
424
|
+
const count = (table) => Number(db.prepare(`SELECT COUNT(*) AS n FROM ${table}`).get().n);
|
|
425
|
+
return { files: count("files"), nodes: count("nodes"), edges: count("edges") };
|
|
426
|
+
}
|
|
427
|
+
/** Next-step hint printed after an index run; names the query tools, not grep. */
|
|
428
|
+
export function indexNextStep(totals) {
|
|
429
|
+
const fmt = (n) => n.toLocaleString("en-US");
|
|
430
|
+
return (`Index ready: ${fmt(totals.files)} files, ${fmt(totals.nodes)} symbols. ` +
|
|
431
|
+
`Next: compass_find "<concept>" or compass_explore <symbol> — do not grep.`);
|
|
432
|
+
}
|
|
419
433
|
function evictCacheBySize(db, maxCacheMB) {
|
|
420
434
|
const limitBytes = maxCacheMB * 1024 * 1024;
|
|
421
435
|
const row = db
|
|
@@ -9,11 +9,16 @@ function requireIndex(projectPath) {
|
|
|
9
9
|
}
|
|
10
10
|
}
|
|
11
11
|
/**
|
|
12
|
-
* Structural search: find nodes whose name contains `query
|
|
13
|
-
*
|
|
12
|
+
* Structural search: find nodes whose name contains `query`, or whose indexed
|
|
13
|
+
* file path contains `query`. Exact name matches and shorter names rank first.
|
|
14
|
+
*
|
|
15
|
+
* LIKE metacharacters `%` and `_` in `query` are escaped so they match
|
|
16
|
+
* literally. Name matching stays a substring of the caller's original text;
|
|
17
|
+
* the path comparison uses a repo-relative normalization (trim, slashes, a
|
|
18
|
+
* leading `./`, absolute paths under the project root).
|
|
14
19
|
*
|
|
15
20
|
* @param projectPath - Absolute path to the indexed project.
|
|
16
|
-
* @param query - Name or
|
|
21
|
+
* @param query - Name substring or file path to match.
|
|
17
22
|
* @param limit - Maximum number of hits to return.
|
|
18
23
|
* @returns Matching nodes ordered by relevance.
|
|
19
24
|
* @throws If no index exists for the project.
|
|
@@ -22,13 +27,16 @@ export function search(projectPath, query, limit = 25) {
|
|
|
22
27
|
requireIndex(projectPath);
|
|
23
28
|
const db = openDb(projectPath);
|
|
24
29
|
try {
|
|
30
|
+
const normalized = normalizePathQuery(projectPath, query);
|
|
31
|
+
const pathPattern = normalized ? `%${escapeLike(normalized)}%` : "";
|
|
25
32
|
const rows = db
|
|
26
33
|
.prepare(`SELECT s.name, s.kind, f.path AS file, s.start_line AS line, s.signature
|
|
27
34
|
FROM nodes s JOIN files f ON f.id = s.file_id
|
|
28
|
-
WHERE s.name LIKE ?
|
|
29
|
-
|
|
35
|
+
WHERE s.name LIKE ? ESCAPE '\\'
|
|
36
|
+
OR (? != '' AND f.path LIKE ? ESCAPE '\\')
|
|
37
|
+
ORDER BY (s.name = ?) DESC, length(s.name) ASC, f.path ASC, s.start_line ASC
|
|
30
38
|
LIMIT ?`)
|
|
31
|
-
.all(`%${query}%`, query, limit);
|
|
39
|
+
.all(`%${escapeLike(query)}%`, normalized, pathPattern, query, limit);
|
|
32
40
|
return rows;
|
|
33
41
|
}
|
|
34
42
|
finally {
|
|
@@ -44,18 +52,205 @@ function readSource(projectPath, file, startByte, endByte) {
|
|
|
44
52
|
return "";
|
|
45
53
|
}
|
|
46
54
|
}
|
|
55
|
+
/**
|
|
56
|
+
* Escape SQL `LIKE` metacharacters so `%` and `_` match literally under
|
|
57
|
+
* `ESCAPE '\'`.
|
|
58
|
+
*/
|
|
59
|
+
function escapeLike(value) {
|
|
60
|
+
return value.replace(/[\\%_]/g, (ch) => `\\${ch}`);
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Normalize a path-shaped query to a repo-relative slash path.
|
|
64
|
+
*
|
|
65
|
+
* Trims, turns backslashes into slashes, and strips one leading `./`. An
|
|
66
|
+
* absolute path that lies under `projectPath` becomes repo-relative; a path
|
|
67
|
+
* outside the project is left absolute so it will not match `files.path`.
|
|
68
|
+
*
|
|
69
|
+
* @param projectPath - Absolute path to the indexed project.
|
|
70
|
+
* @param query - Caller-supplied name or path.
|
|
71
|
+
* @returns The normalized path, or `""` when the query is only the project root.
|
|
72
|
+
*/
|
|
73
|
+
function normalizePathQuery(projectPath, query) {
|
|
74
|
+
let q = query.trim().split("\\").join("/");
|
|
75
|
+
if (q.startsWith("./"))
|
|
76
|
+
q = q.slice(2);
|
|
77
|
+
if (path.isAbsolute(q)) {
|
|
78
|
+
const rel = path.relative(path.resolve(projectPath), q).split("\\").join("/");
|
|
79
|
+
if (rel === "" || (!rel.startsWith("..") && !path.isAbsolute(rel)))
|
|
80
|
+
return rel;
|
|
81
|
+
}
|
|
82
|
+
return q;
|
|
83
|
+
}
|
|
84
|
+
/** Indexed paths that equal `query` or end with `/${query}`. Exact row first. */
|
|
85
|
+
function matchingFiles(db, query) {
|
|
86
|
+
const exact = db.prepare("SELECT path FROM files WHERE path = ?").get(query);
|
|
87
|
+
const suffix = db
|
|
88
|
+
.prepare("SELECT path FROM files WHERE path LIKE ? ESCAPE '\\' ORDER BY path")
|
|
89
|
+
.all(`%/${escapeLike(query)}`);
|
|
90
|
+
return { exact: exact?.path ?? null, suffix: suffix.map((row) => row.path) };
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Resolve `query` to indexed files after a symbol-name miss.
|
|
94
|
+
*
|
|
95
|
+
* Exact `files.path` wins. Otherwise a query that contains `/` must match
|
|
96
|
+
* exactly one suffix (`path` ends with `/${query}`). A query with no slash
|
|
97
|
+
* uses the same suffix rule as a basename; more than one file is ambiguous.
|
|
98
|
+
*/
|
|
99
|
+
function resolveIndexedFile(db, projectPath, query) {
|
|
100
|
+
const normalized = normalizePathQuery(projectPath, query);
|
|
101
|
+
if (!normalized)
|
|
102
|
+
return { status: "none" };
|
|
103
|
+
const { exact, suffix } = matchingFiles(db, normalized);
|
|
104
|
+
if (exact)
|
|
105
|
+
return { status: "one", file: exact };
|
|
106
|
+
if (normalized.includes("/")) {
|
|
107
|
+
if (suffix.length === 1)
|
|
108
|
+
return { status: "one", file: suffix[0] };
|
|
109
|
+
return { status: "none" };
|
|
110
|
+
}
|
|
111
|
+
if (suffix.length === 1)
|
|
112
|
+
return { status: "one", file: suffix[0] };
|
|
113
|
+
if (suffix.length > 1)
|
|
114
|
+
return { status: "ambiguous", basename: normalized, files: suffix };
|
|
115
|
+
return { status: "none" };
|
|
116
|
+
}
|
|
117
|
+
/** Definitions in one indexed file, ordered by source position. */
|
|
118
|
+
function fileSymbols(db, file) {
|
|
119
|
+
return db
|
|
120
|
+
.prepare(`SELECT s.id, s.name, s.kind, s.start_line, s.end_line, s.start_byte, s.end_byte,
|
|
121
|
+
s.signature, f.path AS file
|
|
122
|
+
FROM nodes s JOIN files f ON f.id = s.file_id
|
|
123
|
+
WHERE f.path = ?
|
|
124
|
+
ORDER BY s.start_line ASC, s.id ASC`)
|
|
125
|
+
.all(file);
|
|
126
|
+
}
|
|
127
|
+
/** Symbols defined in `files`, stable across files. Empty when none exist. */
|
|
128
|
+
function symbolsInFiles(db, files) {
|
|
129
|
+
if (files.length === 0)
|
|
130
|
+
return [];
|
|
131
|
+
const placeholders = files.map(() => "?").join(", ");
|
|
132
|
+
return db
|
|
133
|
+
.prepare(`SELECT s.name, s.kind, f.path AS file, s.start_line AS line, s.signature
|
|
134
|
+
FROM nodes s JOIN files f ON f.id = s.file_id
|
|
135
|
+
WHERE f.path IN (${placeholders})
|
|
136
|
+
ORDER BY f.path ASC, s.start_line ASC, s.name ASC`)
|
|
137
|
+
.all(...files);
|
|
138
|
+
}
|
|
139
|
+
/**
|
|
140
|
+
* Choose the symbol a file-path explore returns.
|
|
141
|
+
*
|
|
142
|
+
* Prefer a symbol whose name equals the file stem, function before class,
|
|
143
|
+
* then any other kind. Otherwise the first function by `start_line`, then
|
|
144
|
+
* the first symbol. `symbols` must be non-empty and ordered by `start_line`.
|
|
145
|
+
*/
|
|
146
|
+
function pickPrimary(symbols) {
|
|
147
|
+
const stem = path.parse(symbols[0].file).name;
|
|
148
|
+
const named = symbols.filter((s) => s.name === stem);
|
|
149
|
+
if (named.length > 0) {
|
|
150
|
+
return (named.find((s) => s.kind === "function") ?? named.find((s) => s.kind === "class") ?? named[0]);
|
|
151
|
+
}
|
|
152
|
+
return symbols.find((s) => s.kind === "function") ?? symbols[0];
|
|
153
|
+
}
|
|
154
|
+
/**
|
|
155
|
+
* Source, callers, and callees for one definition. `others` are the
|
|
156
|
+
* `otherMatches` (same-name siblings, or the rest of a file).
|
|
157
|
+
*/
|
|
158
|
+
function assembleExplore(db, projectPath, best, others) {
|
|
159
|
+
const callees = db
|
|
160
|
+
.prepare(`SELECT e.dst_name AS name, e.line, f.path AS file
|
|
161
|
+
FROM edges e LEFT JOIN nodes s ON s.id = e.dst_node_id
|
|
162
|
+
LEFT JOIN files f ON f.id = s.file_id
|
|
163
|
+
WHERE e.src_node_id = ? AND e.kind = 'call'
|
|
164
|
+
ORDER BY e.line`)
|
|
165
|
+
.all(best.id);
|
|
166
|
+
// Callers: match both the resolved edge AND any call by this name, so
|
|
167
|
+
// dynamic dispatch (a method/function called by name across sites) is not
|
|
168
|
+
// missed. Over-approximates conservatively — the point of a blast radius.
|
|
169
|
+
const callers = db
|
|
170
|
+
.prepare(`SELECT DISTINCT owner.name AS name, owner.kind AS kind, f.path AS file, e.line
|
|
171
|
+
FROM edges e
|
|
172
|
+
JOIN nodes owner ON owner.id = e.src_node_id
|
|
173
|
+
JOIN files f ON f.id = owner.file_id
|
|
174
|
+
WHERE (e.dst_node_id = ? OR e.dst_name = ?) AND e.kind = 'call'
|
|
175
|
+
ORDER BY f.path, e.line`)
|
|
176
|
+
.all(best.id, best.name);
|
|
177
|
+
return {
|
|
178
|
+
found: true,
|
|
179
|
+
symbol: {
|
|
180
|
+
name: best.name,
|
|
181
|
+
kind: best.kind,
|
|
182
|
+
file: best.file,
|
|
183
|
+
startLine: best.start_line,
|
|
184
|
+
endLine: best.end_line,
|
|
185
|
+
signature: best.signature,
|
|
186
|
+
source: readSource(projectPath, best.file, best.start_byte, best.end_byte),
|
|
187
|
+
},
|
|
188
|
+
callees: callees.map((c) => ({ name: c.name, file: c.file ?? undefined, line: c.line })),
|
|
189
|
+
callers,
|
|
190
|
+
otherMatches: others.length > 0
|
|
191
|
+
? others.map((m) => ({
|
|
192
|
+
name: m.name,
|
|
193
|
+
kind: m.kind,
|
|
194
|
+
file: m.file,
|
|
195
|
+
line: m.start_line,
|
|
196
|
+
signature: m.signature,
|
|
197
|
+
}))
|
|
198
|
+
: undefined,
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
/**
|
|
202
|
+
* Name miss: resolve a file path to one symbol, or list symbols when the
|
|
203
|
+
* basename is ambiguous or the file defines nothing. A non-unique suffix
|
|
204
|
+
* falls through to {@link search}, which also matches `files.path`.
|
|
205
|
+
*/
|
|
206
|
+
function exploreWhenNameMisses(db, projectPath, query) {
|
|
207
|
+
// Covers: req~explore-file-path~1
|
|
208
|
+
const resolved = resolveIndexedFile(db, projectPath, query);
|
|
209
|
+
if (resolved.status === "one") {
|
|
210
|
+
const symbols = fileSymbols(db, resolved.file);
|
|
211
|
+
if (symbols.length === 0) {
|
|
212
|
+
const near = search(projectPath, query, 10);
|
|
213
|
+
return {
|
|
214
|
+
found: false,
|
|
215
|
+
message: `Indexed file "${resolved.file}" has no symbols.`,
|
|
216
|
+
otherMatches: near,
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
const primary = pickPrimary(symbols);
|
|
220
|
+
const result = assembleExplore(db, projectPath, primary, symbols.filter((s) => s.id !== primary.id));
|
|
221
|
+
return {
|
|
222
|
+
...result,
|
|
223
|
+
message: `Path "${resolved.file}" resolved to ${primary.name}.`,
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
if (resolved.status === "ambiguous") {
|
|
227
|
+
return {
|
|
228
|
+
found: false,
|
|
229
|
+
message: `Basename "${resolved.basename}" is ambiguous.`,
|
|
230
|
+
otherMatches: symbolsInFiles(db, resolved.files),
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
const near = search(projectPath, query, 10);
|
|
234
|
+
return {
|
|
235
|
+
found: false,
|
|
236
|
+
message: `No exact symbol named "${query}". ${near.length} similar symbol(s) below.`,
|
|
237
|
+
otherMatches: near,
|
|
238
|
+
};
|
|
239
|
+
}
|
|
47
240
|
/**
|
|
48
241
|
* Explore an exact node by name: return its verbatim source, callees, and
|
|
49
242
|
* callers (its blast radius).
|
|
50
243
|
*
|
|
51
244
|
* When several nodes share the name, functions and classes are preferred as the
|
|
52
|
-
* primary result and the rest are surfaced under `otherMatches`. When
|
|
53
|
-
*
|
|
54
|
-
*
|
|
245
|
+
* primary result and the rest are surfaced under `otherMatches`. When the name
|
|
246
|
+
* misses, a normalized file path (exact, unique suffix, or unique basename)
|
|
247
|
+
* resolves to one symbol in that file — stem name first (function, then
|
|
248
|
+
* class), otherwise the first function, otherwise the first symbol. Anything
|
|
249
|
+
* still unresolved falls back to a fuzzy {@link search}.
|
|
55
250
|
*
|
|
56
251
|
* @param projectPath - Absolute path to the indexed project.
|
|
57
|
-
* @param query - Exact node name
|
|
58
|
-
* @returns The explore result; `found` is `false` when no
|
|
252
|
+
* @param query - Exact node name, or a file path when no symbol has that name.
|
|
253
|
+
* @returns The explore result; `found` is `false` when no single symbol is selected.
|
|
59
254
|
* @throws If no index exists for the project.
|
|
60
255
|
*/
|
|
61
256
|
export function explore(projectPath, query) {
|
|
@@ -70,57 +265,9 @@ export function explore(projectPath, query) {
|
|
|
70
265
|
ORDER BY s.kind = 'function' DESC, s.kind = 'class' DESC
|
|
71
266
|
LIMIT 10`)
|
|
72
267
|
.all(query);
|
|
73
|
-
if (matches.length === 0)
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
return {
|
|
77
|
-
found: false,
|
|
78
|
-
message: `No exact symbol named "${query}". ${near.length} similar symbol(s) below.`,
|
|
79
|
-
otherMatches: near,
|
|
80
|
-
};
|
|
81
|
-
}
|
|
82
|
-
const best = matches[0];
|
|
83
|
-
const callees = db
|
|
84
|
-
.prepare(`SELECT e.dst_name AS name, e.line, f.path AS file
|
|
85
|
-
FROM edges e LEFT JOIN nodes s ON s.id = e.dst_node_id
|
|
86
|
-
LEFT JOIN files f ON f.id = s.file_id
|
|
87
|
-
WHERE e.src_node_id = ? AND e.kind = 'call'
|
|
88
|
-
ORDER BY e.line`)
|
|
89
|
-
.all(best.id);
|
|
90
|
-
// Callers: match both the resolved edge AND any call by this name, so
|
|
91
|
-
// dynamic dispatch (a method/function called by name across sites) is not
|
|
92
|
-
// missed. Over-approximates conservatively — the point of a blast radius.
|
|
93
|
-
const callers = db
|
|
94
|
-
.prepare(`SELECT DISTINCT owner.name AS name, owner.kind AS kind, f.path AS file, e.line
|
|
95
|
-
FROM edges e
|
|
96
|
-
JOIN nodes owner ON owner.id = e.src_node_id
|
|
97
|
-
JOIN files f ON f.id = owner.file_id
|
|
98
|
-
WHERE (e.dst_node_id = ? OR e.dst_name = ?) AND e.kind = 'call'
|
|
99
|
-
ORDER BY f.path, e.line`)
|
|
100
|
-
.all(best.id, best.name);
|
|
101
|
-
return {
|
|
102
|
-
found: true,
|
|
103
|
-
symbol: {
|
|
104
|
-
name: best.name,
|
|
105
|
-
kind: best.kind,
|
|
106
|
-
file: best.file,
|
|
107
|
-
startLine: best.start_line,
|
|
108
|
-
endLine: best.end_line,
|
|
109
|
-
signature: best.signature,
|
|
110
|
-
source: readSource(projectPath, best.file, best.start_byte, best.end_byte),
|
|
111
|
-
},
|
|
112
|
-
callees: callees.map((c) => ({ name: c.name, file: c.file ?? undefined, line: c.line })),
|
|
113
|
-
callers,
|
|
114
|
-
otherMatches: matches.length > 1
|
|
115
|
-
? matches.slice(1).map((m) => ({
|
|
116
|
-
name: m.name,
|
|
117
|
-
kind: m.kind,
|
|
118
|
-
file: m.file,
|
|
119
|
-
line: m.start_line,
|
|
120
|
-
signature: m.signature,
|
|
121
|
-
}))
|
|
122
|
-
: undefined,
|
|
123
|
-
};
|
|
268
|
+
if (matches.length === 0)
|
|
269
|
+
return exploreWhenNameMisses(db, projectPath, query);
|
|
270
|
+
return assembleExplore(db, projectPath, matches[0], matches.slice(1));
|
|
124
271
|
}
|
|
125
272
|
finally {
|
|
126
273
|
db.close();
|
|
@@ -3,6 +3,7 @@ import { defineTool, defineAliasTool, text } from "../../shared/mcp.js";
|
|
|
3
3
|
import { shouldExpose } from "../../shared/exposure.js";
|
|
4
4
|
import { aliasesEnabled } from "../../shared/tool-catalog.js";
|
|
5
5
|
import { logDeprecatedCall, prefixDeprecated } from "../../shared/deprecation.js";
|
|
6
|
+
import { recordCompassCall } from "../../shared/compass-calls.js";
|
|
6
7
|
import { buildIndex } from "./indexer.js";
|
|
7
8
|
import { impact } from "./query.js";
|
|
8
9
|
import { affectedTests } from "./affected.js";
|
|
@@ -17,7 +18,14 @@ export function registerCompass(server, opts = {}) {
|
|
|
17
18
|
const add = (name, description, inputSchema, handler) => {
|
|
18
19
|
if (!shouldExpose(name, minimal))
|
|
19
20
|
return;
|
|
20
|
-
|
|
21
|
+
// Every canonical call lands in the call log the Cortex gate and the
|
|
22
|
+
// Compass-first nudge read; the write is best-effort and never throws.
|
|
23
|
+
const call = handler;
|
|
24
|
+
const logged = ((args, extra) => {
|
|
25
|
+
recordCompassCall(args.projectPath, name);
|
|
26
|
+
return call(args, extra);
|
|
27
|
+
});
|
|
28
|
+
defineTool(server, { name, description, inputSchema, handler: logged });
|
|
21
29
|
};
|
|
22
30
|
add("compass_explore", "Symbol context in one call: source, callers, callees, blast radius, tests, hotspot. Prefer before grep.", {
|
|
23
31
|
projectPath: z.string(),
|
|
@@ -85,6 +93,7 @@ export function registerCompass(server, opts = {}) {
|
|
|
85
93
|
inputSchema: { projectPath: z.string(), query: z.string(), limit: z.number().optional() },
|
|
86
94
|
handler: async ({ projectPath, query, limit }) => {
|
|
87
95
|
logDeprecatedCall(projectPath, "compass_search");
|
|
96
|
+
recordCompassCall(projectPath, "compass_search");
|
|
88
97
|
const body = JSON.stringify(await findSymbols(projectPath, query, "exact", limit), null, 2);
|
|
89
98
|
return text(prefixDeprecated("compass_search", body));
|
|
90
99
|
},
|
|
@@ -95,6 +104,7 @@ export function registerCompass(server, opts = {}) {
|
|
|
95
104
|
inputSchema: { projectPath: z.string(), query: z.string(), limit: z.number().optional() },
|
|
96
105
|
handler: async ({ projectPath, query, limit }) => {
|
|
97
106
|
logDeprecatedCall(projectPath, "compass_recall");
|
|
107
|
+
recordCompassCall(projectPath, "compass_recall");
|
|
98
108
|
const body = JSON.stringify(await findSymbols(projectPath, query, "concept", limit), null, 2);
|
|
99
109
|
return text(prefixDeprecated("compass_recall", body));
|
|
100
110
|
},
|
|
@@ -111,6 +121,7 @@ export function registerCompass(server, opts = {}) {
|
|
|
111
121
|
},
|
|
112
122
|
handler: async (args) => {
|
|
113
123
|
logDeprecatedCall(args.projectPath, "compass_impact");
|
|
124
|
+
recordCompassCall(args.projectPath, "compass_impact");
|
|
114
125
|
const sym = args.symbol ?? args.node;
|
|
115
126
|
const body = sym
|
|
116
127
|
? JSON.stringify(await exploreRich({
|
|
@@ -133,6 +144,7 @@ export function registerCompass(server, opts = {}) {
|
|
|
133
144
|
},
|
|
134
145
|
handler: async ({ projectPath, from, to, maxDepth }) => {
|
|
135
146
|
logDeprecatedCall(projectPath, "compass_trace");
|
|
147
|
+
recordCompassCall(projectPath, "compass_trace");
|
|
136
148
|
const body = JSON.stringify(await exploreRich({ projectPath, node: from, to, maxDepth }), null, 2);
|
|
137
149
|
return text(prefixDeprecated("compass_trace", body));
|
|
138
150
|
},
|
|
@@ -148,6 +160,7 @@ export function registerCompass(server, opts = {}) {
|
|
|
148
160
|
},
|
|
149
161
|
handler: async (args) => {
|
|
150
162
|
logDeprecatedCall(args.projectPath, "compass_affected_tests");
|
|
163
|
+
recordCompassCall(args.projectPath, "compass_affected_tests");
|
|
151
164
|
const body = JSON.stringify(affectedTests(args.projectPath, args), null, 2);
|
|
152
165
|
return text(prefixDeprecated("compass_affected_tests", body));
|
|
153
166
|
},
|
|
@@ -158,6 +171,7 @@ export function registerCompass(server, opts = {}) {
|
|
|
158
171
|
inputSchema: { projectPath: z.string(), limit: z.number().optional() },
|
|
159
172
|
handler: async ({ projectPath, limit }) => {
|
|
160
173
|
logDeprecatedCall(projectPath, "compass_hotspots");
|
|
174
|
+
recordCompassCall(projectPath, "compass_hotspots");
|
|
161
175
|
const body = JSON.stringify(hotspots(projectPath, { limit }), null, 2);
|
|
162
176
|
return text(prefixDeprecated("compass_hotspots", body));
|
|
163
177
|
},
|
|
@@ -168,6 +182,7 @@ export function registerCompass(server, opts = {}) {
|
|
|
168
182
|
inputSchema: { projectPath: z.string(), file: z.string() },
|
|
169
183
|
handler: async ({ projectPath, file }) => {
|
|
170
184
|
logDeprecatedCall(projectPath, "compass_coupling");
|
|
185
|
+
recordCompassCall(projectPath, "compass_coupling");
|
|
171
186
|
const body = JSON.stringify(coupling(projectPath, file, {}), null, 2);
|
|
172
187
|
return text(prefixDeprecated("compass_coupling", body));
|
|
173
188
|
},
|
|
@@ -181,6 +196,7 @@ export function registerCompass(server, opts = {}) {
|
|
|
181
196
|
},
|
|
182
197
|
handler: async ({ projectPath, action }) => {
|
|
183
198
|
logDeprecatedCall(projectPath, "compass_watch");
|
|
199
|
+
recordCompassCall(projectPath, "compass_watch");
|
|
184
200
|
const result = action === "start"
|
|
185
201
|
? startWatch(projectPath)
|
|
186
202
|
: action === "stop"
|