@esneiderbravo/speclaw 2.0.12 → 2.0.14

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 (44) hide show
  1. package/README.md +1 -1
  2. package/dist/cli/commands/init.js +3 -2
  3. package/dist/cli/commands/lawbook.js +4 -1
  4. package/dist/cli/commands/query.js +2 -2
  5. package/dist/cli/commands/ship-on-stop.js +46 -0
  6. package/dist/cli/commands/ship.js +44 -0
  7. package/dist/cli/commands/update.js +25 -0
  8. package/dist/cli/index.js +6 -1
  9. package/dist/cli/lib/help.js +36 -4
  10. package/dist/modules/compass/db.js +47 -9
  11. package/dist/modules/compass/explore-rich.js +50 -9
  12. package/dist/modules/compass/extract.js +220 -0
  13. package/dist/modules/compass/find-output.js +172 -0
  14. package/dist/modules/compass/hybrid.js +149 -21
  15. package/dist/modules/compass/indexer.js +26 -2
  16. package/dist/modules/compass/query.js +48 -12
  17. package/dist/modules/compass/register.js +13 -7
  18. package/dist/modules/cortex/harness.js +17 -4
  19. package/dist/modules/cortex/status.js +1 -1
  20. package/dist/modules/foundation/assets/AGENTS.template.md +12 -11
  21. package/dist/modules/foundation/assets/CLAUDE.template.md +16 -12
  22. package/dist/modules/foundation/assets/docs/compass.template.md +11 -4
  23. package/dist/modules/foundation/deps.js +5 -3
  24. package/dist/modules/foundation/dialects/copilot.js +3 -5
  25. package/dist/modules/foundation/graph.js +5 -5
  26. package/dist/modules/foundation/hooks.js +30 -4
  27. package/dist/modules/foundation/laws.js +5 -0
  28. package/dist/modules/foundation/scaffold.js +3 -16
  29. package/dist/modules/lawbook/anchors.js +38 -1
  30. package/dist/modules/lawbook/assets/commands/cortex.md +1 -1
  31. package/dist/modules/lawbook/assets/rules/spec-reports-disciplines.md +6 -0
  32. package/dist/modules/lawbook/assets/rules/spec-tasks-mandatory-steps.md +6 -0
  33. package/dist/modules/lawbook/assets/skills/cortex/SKILL.md +17 -5
  34. package/dist/modules/lawbook/bugfix.js +12 -4
  35. package/dist/modules/lawbook/change-tool.js +7 -0
  36. package/dist/modules/lawbook/engine.js +3 -0
  37. package/dist/modules/lawbook/levels.js +35 -4
  38. package/dist/modules/lawbook/quick.js +17 -6
  39. package/dist/modules/lawbook/ship.js +325 -0
  40. package/dist/shared/agents.js +7 -1
  41. package/dist/shared/mcp.js +6 -4
  42. package/dist/shared/output-budget.js +36 -12
  43. package/package.json +1 -1
  44. package/dist/modules/foundation/assets/workflows/speclaw.yml +0 -26
package/README.md CHANGED
@@ -116,7 +116,7 @@ gh attestation verify <tarball> --owner esneiderbravo
116
116
  | Module | What it does |
117
117
  | :-- | :-- |
118
118
  | **Foundation** | The project's constitution: `LAWS.md` binding granular standards under `docs/standards/`, plus `CLAUDE.md` / `AGENTS.md`. **Enforced** via agent hooks (`speclaw check`), deterministic graph laws (`speclaw verify` / `laws verify`), multidialect compile (`laws compile`), and committed **`speclaw.lock`** digests + injection scan (`laws lock` / `accept` / `scan`). |
119
- | **Compass** | Local code graph (tree-sitter → `node:sqlite`): hybrid find (FTS5 + vectors + RRF + PageRank), impact, affected-tests, hotspots, coupling, visualize. Schema **11**. Refreshed silently at every Claude Code session start once built. No LLM — lives in `.speclaw/` (gitignored). |
119
+ | **Compass** | Local code graph (tree-sitter → `node:sqlite`): hybrid find (FTS5 + vectors + RRF + PageRank), impact, affected-tests, hotspots, coupling, visualize. Schema **12** (type-reference callers). Refreshed silently at every Claude Code session start once built. No LLM — lives in `.speclaw/` (gitignored). |
120
120
  | **Lawbook** | Spec-driven workflow with **adaptive ceremony** (levels 0–3), EARS linting, requirement **coverage**, sealed **drift** anchors, bugfix + `lawbook_investigate`. Artifacts under `lawbook/`. |
121
121
  | **Cortex** | **One brain. Many agents.** The multi-agent loop: durable `harness.json`, MCP tool `cortex` (`status`/`start`/`advance`/`rework`/`brief`), CLI `speclaw cortex`, skill `/lawbook/cortex`. Dispatches explorer → planner → implementer → reviewer → tester → archiver. |
122
122
  | **Team** | Declare `team.owners` in `lawbook/config.yaml`; `speclaw owners --write` compiles a managed trailing block in `.github/CODEOWNERS` (GitHub: last match wins). Doctor checks the posture. CLI-only — no MCP tool. |
@@ -56,7 +56,7 @@ export async function runInit(flags) {
56
56
  agents: () => clack.multiselect({
57
57
  message: "Which agents do you use? (space to select)",
58
58
  options: AGENTS.map((a) => ({ value: a.id, label: a.label })),
59
- initialValues: ["claude"],
59
+ initialValues: ["agents"],
60
60
  required: true,
61
61
  }),
62
62
  };
@@ -77,7 +77,8 @@ export async function runInit(flags) {
77
77
  packs = answers.packs ?? [];
78
78
  }
79
79
  else {
80
- agents = list(flags.agents).length ? list(flags.agents) : ["claude"];
80
+ // Default: one agent-facing folder, `.agents/`, linked into ai-specs/.
81
+ agents = list(flags.agents).length ? list(flags.agents) : ["agents"];
81
82
  packs = list(flags.packs);
82
83
  }
83
84
  const unknownAgents = agents.filter((a) => !agentById(a));
@@ -126,7 +126,10 @@ export async function runSpec(flags) {
126
126
  console.log(JSON.stringify(result, null, 2));
127
127
  return;
128
128
  }
129
- ui.ok(`bug change "${name}" scaffolded at ${result.dir}`);
129
+ ui.ok(`bug change "${name}" scaffolded at ${result.dir} ` +
130
+ (level === undefined
131
+ ? "(level unconfirmed — run `speclaw lawbook level set`)"
132
+ : `(level ${level})`));
130
133
  return;
131
134
  }
132
135
  const name = req(change, "lawbook draft <name> [--level N] [--capability C] [--json] | --bug <name>");
@@ -36,7 +36,7 @@ export async function runQuery(cmd, flags) {
36
36
  try {
37
37
  switch (cmd) {
38
38
  case "explore": {
39
- const r = explore(cwd, need(args[0], "explore <node>"));
39
+ const r = explore(cwd, need(args[0], "explore <node>"), { includeRefs: true });
40
40
  if (!r.found) {
41
41
  ui.warn(r.message ?? "not found");
42
42
  r.otherMatches?.forEach((m) => ui.info(`${m.name} (${m.kind}) ${m.file}:${m.line}`));
@@ -51,7 +51,7 @@ export async function runQuery(cmd, flags) {
51
51
  ui.info(`+ ${r.unresolvedCallees.count} unresolved call(s): ${r.unresolvedCallees.sample.join(", ")}`);
52
52
  }
53
53
  ui.heading("Callers");
54
- r.callers?.forEach((c) => ui.info(`${c.kind === "file" ? "(file) " : ""}${c.name} (${c.file}:${c.line})`));
54
+ r.callers?.forEach((c) => ui.info(`${c.kind === "file" ? "(file) " : ""}${c.name} (${c.file}:${c.line})${c.via ? ` via ${c.via}` : ""}`));
55
55
  return;
56
56
  }
57
57
  case "search":
@@ -0,0 +1,46 @@
1
+ import { shipOnStop } from "../../modules/lawbook/ship.js";
2
+ /**
3
+ * `speclaw ship-on-stop`: the Claude Code `Stop` hook. Ships the branch's
4
+ * change when the work changed since the last ship; prints nothing on stdout.
5
+ * A failing gate goes to stderr with exit 2 so the agent sees it and fixes it
6
+ * — once: when the hook already blocked this stop (`stop_hook_active`), it
7
+ * exits 0 so the agent can never loop. Any other failure exits 0.
8
+ *
9
+ * @param cwd - Project root (defaults to the process working directory).
10
+ */
11
+ // Covers: req~ship-on-stop-hook~1
12
+ export async function runShipOnStop(cwd = process.cwd()) {
13
+ const active = await stopHookActive();
14
+ try {
15
+ const out = shipOnStop(cwd);
16
+ if (out.skipped === null && !out.result.gatesPassed && !active) {
17
+ const failed = out.result.gates[out.result.gates.length - 1];
18
+ process.stderr.write(`speclaw: gate failed — ${failed.command} (exit ${failed.exitCode}). Fix it before finishing.\n${failed.tail}\n`);
19
+ process.exit(2);
20
+ }
21
+ }
22
+ catch {
23
+ // A hook must never break the session.
24
+ }
25
+ }
26
+ /** Whether this stop was already blocked by the hook once (`stop_hook_active`). */
27
+ async function stopHookActive() {
28
+ try {
29
+ const raw = process.stdin.isTTY ? "" : await readStdin();
30
+ return raw
31
+ ? Boolean(JSON.parse(raw).stop_hook_active)
32
+ : false;
33
+ }
34
+ catch {
35
+ return false;
36
+ }
37
+ }
38
+ function readStdin() {
39
+ return new Promise((resolve) => {
40
+ let data = "";
41
+ process.stdin.setEncoding("utf8");
42
+ process.stdin.on("data", (c) => (data += c));
43
+ process.stdin.on("end", () => resolve(data));
44
+ setTimeout(() => resolve(data), 1000).unref();
45
+ });
46
+ }
@@ -0,0 +1,44 @@
1
+ import { list } from "../lib/args.js";
2
+ import { ui } from "../lib/ui.js";
3
+ import { shipChange } from "../../modules/lawbook/ship.js";
4
+ /**
5
+ * Fast path for finished work (`speclaw ship <name>`): gates once, a report
6
+ * from their real output, and the archive at level 0.
7
+ *
8
+ * @param flags - `_[0]` is the change name; `--summary`, `--gate`, `--discipline`, `--no-archive`, `--json`.
9
+ */
10
+ export async function runShip(flags) {
11
+ const name = flags._[0];
12
+ if (!name || typeof name !== "string") {
13
+ ui.err("Usage: speclaw ship <name> [--summary <text>] [--gate <cmd>] [--discipline <name>]");
14
+ process.exit(1);
15
+ }
16
+ const gates = list(flags.gate);
17
+ const result = shipChange(process.cwd(), name, {
18
+ summary: typeof flags.summary === "string" ? flags.summary : undefined,
19
+ gates: gates.length ? gates : undefined,
20
+ discipline: typeof flags.discipline === "string" ? flags.discipline : undefined,
21
+ noArchive: Boolean(flags["no-archive"]),
22
+ });
23
+ if (flags.json) {
24
+ console.log(JSON.stringify(result, null, 2));
25
+ }
26
+ else {
27
+ for (const g of result.gates) {
28
+ const t = `${(g.durationMs / 1000).toFixed(1)} s`;
29
+ if (g.exitCode === 0)
30
+ ui.ok(`${g.command} (${t})`);
31
+ else
32
+ ui.err(`${g.command} failed, exit ${g.exitCode} (${t})`);
33
+ }
34
+ ui.ok(`report ${ui.code(result.report)}`);
35
+ if (result.archivedTo)
36
+ ui.ok(`archived to ${result.archivedTo}`);
37
+ for (const n of result.next)
38
+ ui.warn(n);
39
+ const s = (ms) => `${(ms / 1000).toFixed(2)} s`;
40
+ ui.info(`speclaw overhead ${s(result.timings.overhead)} · gates ${s(result.timings.gates)} · total ${s(result.timings.total)}`);
41
+ }
42
+ if (!result.gatesPassed)
43
+ process.exit(1);
44
+ }
@@ -269,6 +269,31 @@ const MIGRATIONS = [
269
269
  "up at the next session start or `compass_index`.\n" +
270
270
  "- Preserve all project-specific wording; only apply these speclaw-authored changes.",
271
271
  },
272
+ {
273
+ version: "2.0.13",
274
+ describe: "Compass schema 12 — type-reference callers, compact compass_find, unconfirmed bug drafts",
275
+ agentPrompt: "- Compass is now **schema 12**: the first `speclaw index` after upgrading migrates " +
276
+ "11→12 and re-extracts every file once (embeddings are reused). Where the project " +
277
+ 'mentions "Schema **11**" or "Compass (schema 11)", change it to schema 12 ' +
278
+ "(11→12 forces a reindex; embeddings reused).\n" +
279
+ "- speclaw 2.0.12 or older (including an MCP entry pinned at `@2.0.12` or a stale " +
280
+ "global CLI) treats a schema-12 index as incompatible and rebuilds it from scratch. " +
281
+ "Pin every speclaw the project uses (global CLI, `npx`, CI, and the MCP entry) to " +
282
+ "2.0.13: run `speclaw update` so the pinned MCP entry moves to 2.0.13.\n" +
283
+ "- `compass_find` now returns one compact JSON document capped as a whole at " +
284
+ "`maxTokens` (default 1500): hits carry only `name`, `kind`, `file`, `line`; exact mode " +
285
+ "adds `found`, `terms`, and `nearest` when no symbol has the name; `focusIgnored` lists " +
286
+ "focus paths that are not indexed; `truncated: true` appears only when it trimmed. " +
287
+ "`speclaw search --json` keeps its shape. If the project's docs describe the find " +
288
+ "response fields, update them.\n" +
289
+ "- `compass_explore` and `speclaw explore` list type references (`ref` edges: " +
290
+ "annotations, `extends`/`implements`) among callers; every caller carries " +
291
+ '`via: "call" | "ref"`. Blast radius, affected tests, and ranking are unchanged.\n' +
292
+ "- Cortex: `pauseForQuestions` is accepted only from stage `planning` (an error " +
293
+ "elsewhere); `advance`/`rework` re-read the confirmed level; `speclaw lawbook draft " +
294
+ "--bug` without `--level` leaves the level unconfirmed (validate uses 3).\n" +
295
+ "- Preserve all project-specific wording; only apply these speclaw-authored changes.",
296
+ },
272
297
  ];
273
298
  /**
274
299
  * Bring the current project up to date without a full re-init. When the npm
package/dist/cli/index.js CHANGED
@@ -28,6 +28,7 @@ const HEADER_COMMANDS = new Set([
28
28
  "lawbook",
29
29
  "cortex",
30
30
  "quick",
31
+ "ship",
31
32
  ]);
32
33
  /**
33
34
  * Print the branded header once, ahead of a command's output, when it is a
@@ -124,6 +125,10 @@ async function dispatch(cmd, flags) {
124
125
  return (await import("./commands/visualize.js")).runVisualize(flags);
125
126
  case "quick":
126
127
  return (await import("./commands/quick.js")).runQuick(flags);
128
+ case "ship":
129
+ return (await import("./commands/ship.js")).runShip(flags);
130
+ case "ship-on-stop":
131
+ return (await import("./commands/ship-on-stop.js")).runShipOnStop();
127
132
  case "cortex":
128
133
  return (await import("./commands/cortex.js")).runCortex(flags);
129
134
  case "lawbook":
@@ -170,7 +175,7 @@ async function main() {
170
175
  maybeHeader(cmd, flags);
171
176
  await dispatch(cmd, flags);
172
177
  // The SessionStart and edit hooks must stay silent: no update notice either.
173
- if (cmd === "session-start" || cmd === "reindex-file")
178
+ if (cmd === "session-start" || cmd === "reindex-file" || cmd === "ship-on-stop")
174
179
  return;
175
180
  await maybeNotifyUpdate(cmd);
176
181
  }
@@ -24,6 +24,7 @@ Setup
24
24
  Compass (code intelligence — the same surface agents use via MCP)
25
25
  index (Re)build the local code graph (--force / --prune / --json)
26
26
  session-start Silent, fail-safe refresh of an existing index (SessionStart hook)
27
+ ship-on-stop Ship the branch's change when work changed (Stop hook)
27
28
  reindex-file [paths...] Silent re-index of edited files (PostToolUse hook; stdin JSON)
28
29
  watch Keep the index fresh on file changes
29
30
  explore <node> A node's source + callers/callees
@@ -41,6 +42,7 @@ Cortex (One brain. Many agents. — multi-agent loop)
41
42
  cortex <op> status|start|advance|rework|brief — drive harness.json (--change)
42
43
 
43
44
  Lawbook (spec-driven workflow)
45
+ ship <name> Fast path: gates once → report → archive (level 0)
44
46
  quick <name> Scaffold a level-0 change (record.md + reports)
45
47
  lawbook init Create the lawbook/ workspace
46
48
  lawbook list Active/archived changes and capabilities
@@ -127,7 +129,7 @@ npx @esneiderbravo/speclaw@latest init so the scaffold matches the latest releas
127
129
 
128
130
  Options
129
131
  --yes, -y Accept the defaults without prompting
130
- --agents <ids> Comma-separated agents to configure (claude, cursor, …)
132
+ --agents <ids> Agents to configure (default: agents → only .agents/; also claude, cursor, codex, windsurf)
131
133
  --packs <ids> Comma-separated tool packs to install
132
134
  --project-name <name> Project name for the templates
133
135
  --minimal Omit setup/lifecycle MCP tools
@@ -164,6 +166,15 @@ Options
164
166
  usage: `Usage: speclaw watch
165
167
 
166
168
  Keep the Compass index fresh: re-index changed files until interrupted (Ctrl-C).
169
+ `,
170
+ },
171
+ {
172
+ name: "ship-on-stop",
173
+ usage: `Usage: speclaw ship-on-stop
174
+
175
+ The Claude Code Stop hook. On a feature branch whose work changed since the
176
+ last ship, runs \`speclaw ship\` for the change named after the branch. Prints
177
+ nothing on stdout; a failing gate goes to stderr with exit 2 once.
167
178
  `,
168
179
  },
169
180
  {
@@ -196,7 +207,9 @@ speclaw index, compass_index, watch). Prints nothing and always exits 0.
196
207
  name: "explore",
197
208
  usage: `Usage: speclaw explore <node> [options]
198
209
 
199
- A symbol's source plus its callers and callees.
210
+ A symbol's source plus its callers and callees. Each caller carries
211
+ via: call (it calls the symbol) or ref (it names the type in an annotation
212
+ or an extends/implements clause).
200
213
 
201
214
  Options
202
215
  --json Print the result as JSON
@@ -307,6 +320,23 @@ Options
307
320
  --depth <n> Neighborhood depth around <node>
308
321
  --limit <n> Maximum nodes
309
322
  --no-open Do not open the browser
323
+ `,
324
+ },
325
+ {
326
+ name: "ship",
327
+ usage: `Usage: speclaw ship <name> [options]
328
+
329
+ Fast path for finished work: scaffold a level-0 record if the change is new,
330
+ run the project's gates once, write reports/<discipline>.md from their real
331
+ output, and archive at level 0. Level 1+ stops after the evidence: review
332
+ happens on the PR. Ship never records a review verdict.
333
+
334
+ Options
335
+ --summary <text> What changed (default: the changed-file list)
336
+ --gate <cmd> Gate command (repeatable; default ship.gates or package.json)
337
+ --discipline <name> Report name (default ship.discipline or "change")
338
+ --no-archive Stop after the report
339
+ --json Print the result as JSON
310
340
  `,
311
341
  },
312
342
  {
@@ -332,7 +362,8 @@ Options
332
362
  --verdict <v> PASS or FAIL (advance from reviewing/testing)
333
363
  --note <text> A history note
334
364
  --question <text> One open question, commas included; repeat for more
335
- --pause-questions Pause the loop for open questions
365
+ --pause-questions From planning only: pause for the --question list
366
+ (any other stage is rejected, exit 1)
336
367
  --json Print the result as JSON
337
368
  `,
338
369
  },
@@ -344,7 +375,8 @@ Options
344
375
  list Active/archived changes and capabilities
345
376
  level <mode> [change] propose|set|promote|explain ceremony level (--level, --reason)
346
377
  draft <name> Scaffold a feature change (--level N, --capability C)
347
- draft --bug <name> Scaffold a bug change (bugfix.md + reports)
378
+ draft --bug <name> Scaffold a bug change (bugfix.md + reports;
379
+ level unconfirmed unless --level N)
348
380
  investigate Rank bug suspects (--symptom, --stack-trace, --symbol, --path)
349
381
  validate <change> Validate a change's artifacts
350
382
  sync <change> Promote delta specs to canonical
@@ -234,7 +234,7 @@ export function probeFts5Support() {
234
234
  }
235
235
  }
236
236
  /** Schema version stamped into the `meta` table on first creation. */
237
- export const SCHEMA_VERSION = "11";
237
+ export const SCHEMA_VERSION = "12";
238
238
  /**
239
239
  * `nodes.kind` of the synthetic per-file owner node. It owns the references that
240
240
  * sit outside any definition (top-level imports, calls in callbacks) and is
@@ -253,7 +253,7 @@ function readSchemaVersion(db) {
253
253
  }
254
254
  /**
255
255
  * Decide whether an existing database is from an incompatible schema and must be
256
- * rebuilt. Schema 8→9, 9→10, and 10→11 are handled by migrators instead of a wipe.
256
+ * rebuilt. Schema 8→9, 9→10, 10→11, and 11→12 are handled by migrators instead of a wipe.
257
257
  */
258
258
  function isStale(db) {
259
259
  const hasEdges = db
@@ -262,10 +262,8 @@ function isStale(db) {
262
262
  if (!hasEdges)
263
263
  return false;
264
264
  const ver = readSchemaVersion(db);
265
- if (ver === "8" || ver === "9" || ver === "10")
265
+ if (ver === "8" || ver === "9" || ver === "10" || ver === "11")
266
266
  return false; // migrate in openDb
267
- if (ver === "11" && !hasEdgeSpec(db))
268
- return false; // pre-release 11: migrate in openDb
269
267
  if (ver !== SCHEMA_VERSION)
270
268
  return true;
271
269
  const edgeCols = db.prepare("PRAGMA table_info(edges)").all().map((c) => c.name);
@@ -469,12 +467,49 @@ export function migrate10to11(db) {
469
467
  throw new Error(`schema 10→11 migration failed — delete .speclaw/index.db to rebuild: ${err.message}`, { cause: err });
470
468
  }
471
469
  }
470
+ /** Reason recorded with the needs-reindex marker by the 11→12 migration. */
471
+ export const SCHEMA_12_REINDEX_REASON = "schema 12 adds type-reference (ref) edges for explore callers; reindex required";
472
+ /**
473
+ * Migrate schema 11 → 12: set the needs-reindex marker (its reason names
474
+ * schema 12, after any reason an earlier step of the same chain recorded) and
475
+ * stamp `"12"`, in one `BEGIN IMMEDIATE` transaction. No column changes
476
+ * (`edges.kind` is text); the next index run re-extracts every file so `ref`
477
+ * edges exist, while `embedding_cache` is untouched so unchanged symbols reuse
478
+ * their vectors.
479
+ *
480
+ * @param db - Open connection already at schema 11 (with `edges.spec`).
481
+ * @throws If any step fails; the transaction is rolled back and the stamp stays `"11"`.
482
+ */
483
+ export function migrate11to12(db) {
484
+ // Covers: req~schema-ref-edges~1
485
+ db.exec("BEGIN IMMEDIATE");
486
+ try {
487
+ const pending = db.prepare("SELECT value FROM meta WHERE key = 'needs_reindex'").get();
488
+ const prior = db.prepare("SELECT value FROM meta WHERE key = 'reindex_reason'").get();
489
+ const reason = pending?.value === "1" && prior?.value && prior.value !== SCHEMA_12_REINDEX_REASON
490
+ ? `${prior.value}; ${SCHEMA_12_REINDEX_REASON}`
491
+ : SCHEMA_12_REINDEX_REASON;
492
+ db.prepare("INSERT INTO meta(key, value) VALUES ('needs_reindex', '1') ON CONFLICT(key) DO UPDATE SET value = excluded.value").run();
493
+ db.prepare("INSERT INTO meta(key, value) VALUES ('reindex_reason', ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value").run(reason);
494
+ db.prepare("INSERT INTO meta(key, value) VALUES ('schema_version', ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value").run("12");
495
+ db.exec("COMMIT");
496
+ }
497
+ catch (err) {
498
+ try {
499
+ db.exec("ROLLBACK");
500
+ }
501
+ catch {
502
+ /* already rolled back */
503
+ }
504
+ throw new Error(`schema 11→12 migration failed — delete .speclaw/index.db to rebuild: ${err.message}`, { cause: err });
505
+ }
506
+ }
472
507
  /**
473
508
  * Open (creating if needed) the index database at `<projectPath>/.speclaw/index.db`.
474
509
  *
475
510
  * Ensures the `.speclaw` directory exists, enables WAL journaling and foreign
476
- * keys, and applies the schema. Schema 8→9, 9→10, and 10→11 migrate in place
477
- * and chain forward (embeddings preserved). Other incompatible schemas are wiped
511
+ * keys, and applies the schema. Schema 8→9, 9→10, 10→11, and 11→12 migrate in
512
+ * place and chain forward (embeddings preserved). Other incompatible schemas are wiped
478
513
  * and rebuilt.
479
514
  *
480
515
  * @param projectPath - Absolute path to the project root.
@@ -494,13 +529,16 @@ export function openDb(projectPath) {
494
529
  return null;
495
530
  }
496
531
  })();
497
- if (ver === "8" || ver === "9" || ver === "10" || (ver === "11" && !hasEdgeSpec(db))) {
532
+ if (ver === "8" || ver === "9" || ver === "10" || ver === "11") {
498
533
  try {
499
534
  if (ver === "8")
500
535
  migrate8to9(db, projectPath);
501
536
  if (ver === "8" || ver === "9")
502
537
  migrate9to10(db);
503
- migrate10to11(db);
538
+ // A pre-release schema-11 index (no `edges.spec`) takes the 10→11 step too.
539
+ if (ver !== "11" || !hasEdgeSpec(db))
540
+ migrate10to11(db);
541
+ migrate11to12(db);
504
542
  }
505
543
  catch (err) {
506
544
  db.close();
@@ -1,9 +1,9 @@
1
1
  import { explore, impact, trace } from "./query.js";
2
2
  import { affectedTests } from "./affected.js";
3
- import { FILE_NODE_KIND } from "./db.js";
3
+ import { FILE_NODE_KIND, indexExists, openDb } from "./db.js";
4
4
  import { hotspots } from "./hotspots.js";
5
5
  import { summarizeImpact } from "./impact-summary.js";
6
- import { budgetExploreShape, applyTextBudget, } from "../../shared/output-budget.js";
6
+ import { budgetExploreShape, applyTextBudget, OUTPUT_BUDGET, } from "../../shared/output-budget.js";
7
7
  const DEFAULT_INCLUDES = [
8
8
  "source",
9
9
  "callers",
@@ -28,7 +28,7 @@ export async function exploreRich(query) {
28
28
  const degraded = [];
29
29
  if (query.to) {
30
30
  const pathResult = trace(query.projectPath, query.node, query.to, query.maxDepth ?? 8);
31
- const base = explore(query.projectPath, query.node);
31
+ const base = explore(query.projectPath, query.node, { includeRefs: true });
32
32
  const out = {
33
33
  ...base,
34
34
  path: pathResult.path,
@@ -49,7 +49,7 @@ export async function exploreRich(query) {
49
49
  budgetExploreShape(out, mode, truncated);
50
50
  return out;
51
51
  }
52
- const base = explore(query.projectPath, query.node);
52
+ const base = explore(query.projectPath, query.node, { includeRefs: true });
53
53
  const out = { ...base, truncated, degraded };
54
54
  if (!includes.includes("source") && out.symbol)
55
55
  out.symbol = withoutSource(out.symbol);
@@ -123,16 +123,57 @@ export async function exploreRich(query) {
123
123
  delete out.degraded;
124
124
  return out;
125
125
  }
126
- /** Merge lexical and semantic search behind one surface. */
126
+ /**
127
+ * Merge lexical and semantic search behind one surface (the MCP find path).
128
+ * The focus is resolved against the index first so dropped paths can be
129
+ * reported; exact mode keeps only symbols named exactly a query term and
130
+ * reports `found`, `terms`, and, when nothing matches, `nearest`.
131
+ *
132
+ * @param projectPath - Indexed project root.
133
+ * @param query - Identifier(s) or prose.
134
+ * @param mode - `exact` (name lookup) or `concept` (fuzzy, dense-heavy).
135
+ * @param limit - Seed limit (default 50).
136
+ * @param opts - Focus paths and the response token cap (default: brief ceiling).
137
+ */
138
+ // Covers: req~find-exact-not-found~1, req~task-relative-ranking~1
127
139
  export async function findSymbols(projectPath, query, mode, limit, opts) {
128
- const { hybridSearch } = await import("./hybrid.js");
140
+ const { hybridSearch, resolveSearchFocus, exactTerms, nearestSymbols } = await import("./hybrid.js");
141
+ if (!indexExists(projectPath)) {
142
+ throw new Error("No index found. Build it first with the index_build tool (creates .speclaw/index.db).");
143
+ }
144
+ const db = openDb(projectPath);
145
+ let resolvedFocus;
146
+ try {
147
+ resolvedFocus = resolveSearchFocus(db, projectPath, opts?.focus);
148
+ }
149
+ finally {
150
+ db.close();
151
+ }
152
+ const cap = opts?.maxTokens ?? OUTPUT_BUDGET.brief;
153
+ const terms = mode === "exact" ? exactTerms(query) : undefined;
154
+ const report = { ranked: 0, knnIds: [] };
129
155
  const result = await hybridSearch(projectPath, query, {
130
156
  mode,
131
- focus: opts?.focus,
132
- maxTokens: opts?.maxTokens,
157
+ maxTokens: cap,
133
158
  seedLimit: limit ?? 50,
159
+ resolvedFocus,
160
+ exactNames: terms,
161
+ report,
134
162
  });
135
- return result;
163
+ const out = {
164
+ ...result,
165
+ mode,
166
+ focusIgnored: resolvedFocus.ignored,
167
+ cap,
168
+ capped: report.ranked > result.hits.length,
169
+ };
170
+ if (terms) {
171
+ out.terms = terms;
172
+ out.found = result.hits.length > 0;
173
+ if (!out.found)
174
+ out.nearest = nearestSymbols(projectPath, terms, report.knnIds);
175
+ }
176
+ return out;
136
177
  }
137
178
  /** Serialize explore-rich with output budget applied. */
138
179
  export function formatExploreRich(result, mode = "brief") {