@esneiderbravo/speclaw 2.0.11 → 2.0.13

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 (43) hide show
  1. package/README.md +5 -1
  2. package/dist/cli/commands/cortex.js +3 -2
  3. package/dist/cli/commands/lawbook.js +7 -3
  4. package/dist/cli/commands/laws.js +10 -2
  5. package/dist/cli/commands/query.js +2 -2
  6. package/dist/cli/commands/ship-on-stop.js +46 -0
  7. package/dist/cli/commands/ship.js +44 -0
  8. package/dist/cli/commands/update.js +25 -0
  9. package/dist/cli/index.js +8 -3
  10. package/dist/cli/lib/args.js +35 -5
  11. package/dist/cli/lib/help.js +36 -4
  12. package/dist/modules/compass/db.js +47 -9
  13. package/dist/modules/compass/explore-rich.js +50 -9
  14. package/dist/modules/compass/extract.js +220 -0
  15. package/dist/modules/compass/find-output.js +172 -0
  16. package/dist/modules/compass/hybrid.js +149 -21
  17. package/dist/modules/compass/indexer.js +26 -2
  18. package/dist/modules/compass/query.js +48 -12
  19. package/dist/modules/compass/register.js +13 -7
  20. package/dist/modules/cortex/harness.js +17 -4
  21. package/dist/modules/cortex/status.js +1 -1
  22. package/dist/modules/foundation/assets/AGENTS.template.md +10 -9
  23. package/dist/modules/foundation/assets/CLAUDE.template.md +13 -10
  24. package/dist/modules/foundation/assets/docs/compass.template.md +11 -4
  25. package/dist/modules/foundation/deps.js +5 -3
  26. package/dist/modules/foundation/doctor.js +1 -1
  27. package/dist/modules/foundation/graph.js +5 -5
  28. package/dist/modules/foundation/hooks.js +30 -4
  29. package/dist/modules/foundation/integrity.js +11 -3
  30. package/dist/modules/foundation/laws.js +5 -0
  31. package/dist/modules/lawbook/assets/commands/cortex.md +1 -1
  32. package/dist/modules/lawbook/assets/rules/spec-reports-disciplines.md +6 -0
  33. package/dist/modules/lawbook/assets/rules/spec-tasks-mandatory-steps.md +6 -0
  34. package/dist/modules/lawbook/assets/skills/cortex/SKILL.md +17 -5
  35. package/dist/modules/lawbook/bugfix.js +12 -4
  36. package/dist/modules/lawbook/change-tool.js +7 -0
  37. package/dist/modules/lawbook/engine.js +3 -0
  38. package/dist/modules/lawbook/levels.js +35 -4
  39. package/dist/modules/lawbook/quick.js +17 -6
  40. package/dist/modules/lawbook/ship.js +325 -0
  41. package/dist/shared/mcp.js +6 -4
  42. package/dist/shared/output-budget.js +36 -12
  43. package/package.json +1 -1
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. |
@@ -310,6 +310,10 @@ speclaw laws accept AGENTS.md # interactive TTY only — never via MCP
310
310
  speclaw laws lock --force # list drifted files, confirm, re-baseline — interactive TTY only
311
311
  ```
312
312
 
313
+ `speclaw laws scan` (text or `--json`) exits 1 when `speclaw.lock` cannot be
314
+ read or a finding has severity `error`. On an unreadable lock it names the lock
315
+ error (`lockError` in the JSON) and still reports every injection finding.
316
+
313
317
  A lock refresh (`init`, `update`, `laws compile`, `laws lock`) never launders an
314
318
  edit: a strict file (`CLAUDE.md`, `AGENTS.md`, compiled rules) that drifted from
315
319
  the lock outside speclaw **keeps its locked digest**, and the command warns
@@ -1,6 +1,6 @@
1
1
  import { handleHarness, readHarness } from "../../modules/cortex/harness.js";
2
2
  import { briefForStage } from "../../modules/cortex/brief.js";
3
- import { list } from "../lib/args.js";
3
+ import { repeated } from "../lib/args.js";
4
4
  import { ui } from "../lib/ui.js";
5
5
  const OPS = ["status", "start", "advance", "rework", "brief"];
6
6
  /**
@@ -36,7 +36,8 @@ export async function runCortex(flags) {
36
36
  change: changeName,
37
37
  harnessOp: op,
38
38
  verdict: verdict ?? null,
39
- openQuestions: list(flags.question),
39
+ // Covers: req~harness-state~1
40
+ openQuestions: repeated(flags.question),
40
41
  pauseForQuestions: Boolean(flags["pause-questions"]),
41
42
  note: typeof flags.note === "string" ? flags.note : undefined,
42
43
  });
@@ -5,7 +5,7 @@ import { scaffoldFeature } from "../../modules/lawbook/scaffold-change.js";
5
5
  import { investigate, formatInvestigateResult } from "../../modules/lawbook/investigate.js";
6
6
  import { handleHarness } from "../../modules/cortex/harness.js";
7
7
  import { printHarnessWarnings } from "./cortex.js";
8
- import { list } from "../lib/args.js";
8
+ import { list, repeated } from "../lib/args.js";
9
9
  import { ui } from "../lib/ui.js";
10
10
  function today() {
11
11
  // The MCP path passes the date in; the CLL runs on a real machine, so read it here.
@@ -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>");
@@ -181,7 +184,8 @@ export async function runSpec(flags) {
181
184
  change: changeName,
182
185
  harnessOp: op,
183
186
  verdict: verdict ?? null,
184
- openQuestions: list(flags.question),
187
+ // Covers: req~harness-state~1
188
+ openQuestions: repeated(flags.question),
185
189
  pauseForQuestions: Boolean(flags["pause-questions"]),
186
190
  note: typeof flags.note === "string" ? flags.note : undefined,
187
191
  });
@@ -73,13 +73,21 @@ export async function runLaws(flags) {
73
73
  return;
74
74
  }
75
75
  if (sub === "scan") {
76
+ // Text and --json share one exit rule: 1 on an unreadable lock or any
77
+ // error-severity finding, else 0.
78
+ // Covers: req~injection-scan~1, req~laws-integrity-cli~1
76
79
  const report = verifyIntegrity({ projectPath: process.cwd(), checks: "scan" });
80
+ const failed = report.lockError !== undefined || report.findings.some((f) => f.severity === "error");
77
81
  if (flags.json) {
78
82
  console.log(JSON.stringify(report, null, 2));
83
+ if (failed)
84
+ process.exit(1);
79
85
  return;
80
86
  }
81
87
  ui.heading("speclaw laws scan");
82
- if (report.findings.length === 0) {
88
+ if (report.lockError !== undefined)
89
+ ui.err(report.lockError);
90
+ if (report.findings.length === 0 && report.lockError === undefined) {
83
91
  ui.ok("No injection findings.");
84
92
  return;
85
93
  }
@@ -91,7 +99,7 @@ export async function runLaws(flags) {
91
99
  else
92
100
  ui.warn(msg);
93
101
  }
94
- if (report.findings.some((f) => f.severity === "error"))
102
+ if (failed)
95
103
  process.exit(1);
96
104
  return;
97
105
  }
@@ -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
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { parseFlags } from "./lib/args.js";
2
+ import { parseFlags, REPEATABLE_FLAGS } from "./lib/args.js";
3
3
  import { ui, header } from "./lib/ui.js";
4
4
  import { maybeNotifyUpdate } from "./lib/update-check.js";
5
5
  import { GLOBAL_HELP as HELP, helpFor, knownCommands, wantsHelp } from "./lib/help.js";
@@ -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":
@@ -166,11 +171,11 @@ async function main() {
166
171
  process.exitCode = 0;
167
172
  return;
168
173
  }
169
- const flags = parseFlags(rest);
174
+ const flags = parseFlags(rest, REPEATABLE_FLAGS);
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
  }
@@ -1,23 +1,37 @@
1
1
  /**
2
- * Minimal flag parser: `--key value`, `--key=value`, `--bool`, `-x`.
2
+ * Minimal flag parser: `--key value`, `--key=value`, `--bool`, `-x`. A repeated
3
+ * key keeps its last value unless it is listed in `repeatable`.
3
4
  *
4
5
  * @param argv - Raw argument tokens (already stripped of the command name).
6
+ * @param repeatable - Keys whose every value is kept: such a key always yields a
7
+ * `string[]` in argv order, and its values are never split at commas.
5
8
  * @returns Flags with named options set and non-flag tokens collected in `_`.
6
9
  */
7
- export function parseFlags(argv) {
10
+ export function parseFlags(argv, repeatable = []) {
8
11
  const flags = { _: [] };
12
+ const set = (key, value) => {
13
+ const prev = flags[key];
14
+ if (repeatable.includes(key) && (typeof value === "string" || Array.isArray(prev))) {
15
+ // A bare repeatable flag after real values adds nothing and drops nothing.
16
+ if (typeof value === "string")
17
+ flags[key] = Array.isArray(prev) ? [...prev, value] : [value];
18
+ }
19
+ else {
20
+ flags[key] = value;
21
+ }
22
+ };
9
23
  for (let i = 0; i < argv.length; i++) {
10
24
  const arg = argv[i];
11
25
  if (arg.startsWith("--")) {
12
26
  const [key, inlineVal] = arg.slice(2).split("=", 2);
13
27
  if (inlineVal !== undefined) {
14
- flags[key] = inlineVal;
28
+ set(key, inlineVal);
15
29
  }
16
30
  else if (i + 1 < argv.length && !argv[i + 1].startsWith("-")) {
17
- flags[key] = argv[++i];
31
+ set(key, argv[++i]);
18
32
  }
19
33
  else {
20
- flags[key] = true;
34
+ set(key, true);
21
35
  }
22
36
  }
23
37
  else if (arg.startsWith("-")) {
@@ -29,6 +43,22 @@ export function parseFlags(argv) {
29
43
  }
30
44
  return flags;
31
45
  }
46
+ /** Flags that may be passed more than once; each occurrence is one value. */
47
+ export const REPEATABLE_FLAGS = ["question"];
48
+ /**
49
+ * The values of a repeatable flag parsed with {@link REPEATABLE_FLAGS}, as-is:
50
+ * in argv order and never split at commas.
51
+ *
52
+ * @param value - The parsed flag value.
53
+ * @returns The string values; empty when the flag is absent or a bare boolean.
54
+ */
55
+ export function repeated(value) {
56
+ if (Array.isArray(value))
57
+ return value;
58
+ if (typeof value === "string")
59
+ return [value];
60
+ return [];
61
+ }
32
62
  /**
33
63
  * Normalize a flag value into a list, splitting comma-separated strings.
34
64
  *
@@ -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
@@ -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
  {
@@ -331,8 +361,9 @@ Options
331
361
  --change <name> The change (or pass it as the second argument)
332
362
  --verdict <v> PASS or FAIL (advance from reviewing/testing)
333
363
  --note <text> A history note
334
- --question <text> An open question (repeatable)
335
- --pause-questions Pause the loop for open questions
364
+ --question <text> One open question, commas included; repeat for more
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") {