@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
@@ -197,8 +197,9 @@ export const BUILTIN_SQL_LIST = BUILTIN_GLOBALS.map((n) => `'${n}'`).join(", ");
197
197
  /** Most bind parameters a scoped {@link resolveEdges} call uses before running unscoped. */
198
198
  const MAX_SCOPE_PARAMS = 10_000;
199
199
  /**
200
- * Resolve call and import edges to node ids — the one place edge resolution
201
- * lives, shared by the full index run and incremental re-indexing.
200
+ * Resolve call, import, and type-reference (`ref`) edges to node ids — the one
201
+ * place edge resolution lives, shared by the full index run and incremental
202
+ * re-indexing.
202
203
  *
203
204
  * - Edges whose `dst_node_id` points at a node that no longer exists (its file
204
205
  * was re-extracted) are reset to unresolved first.
@@ -216,6 +217,10 @@ const MAX_SCOPE_PARAMS = 10_000;
216
217
  * never call targets. An import of a symbol-less or re-exporting file (a
217
218
  * barrel) resolves to that file's file-owner node, so member calls through
218
219
  * it bind by name like this.
220
+ * - A `ref` resolves only to the definition its same-file import binding's
221
+ * target file holds (barrel subtree as for calls), else to a same-file
222
+ * definition; never by a bare global name (`ref` edges are not counted in
223
+ * the returned stats).
219
224
  * - Imports resolve their specifier relative to the importing file, then
220
225
  * through the nearest `tsconfig.json`/`jsconfig.json` `paths`/`baseUrl`, and
221
226
  * point at the target file's file-owner node when it has one, otherwise at
@@ -287,6 +292,25 @@ export function resolveEdges(db, fileIds) {
287
292
  OR (is_member = 2 AND edges.spec IS NOT NULL AND ${importTarget} IS NOT NULL))
288
293
  ${callScope}`).run(...(narrow ? [...ids, ...importScope, ...ids] : []));
289
294
  const calls = resolvedCalls() - before;
295
+ // Covers: req~type-ref-edges~1
296
+ // A type reference resolves only through its own file: the definition its
297
+ // import binding's target file (or that barrel's subtree) holds, else a
298
+ // same-file definition. Never by a bare global name, so a generic `Props`
299
+ // in an unrelated file stays NULL. `is_member = 1` (`a.b.Type`, a
300
+ // non-import qualifier) never resolves.
301
+ db.prepare(`UPDATE edges SET dst_node_id = (
302
+ SELECT n.id FROM nodes n
303
+ WHERE n.name = edges.dst_name
304
+ AND n.kind <> '${FILE_NODE_KIND}'
305
+ AND ((edges.spec IS NOT NULL AND (n.file_id = ${importTarget} OR ${underTargetDir}))
306
+ OR (edges.is_member = 0 AND n.file_id = edges.src_file_id))
307
+ ORDER BY CASE WHEN n.file_id = ${importTarget} THEN 0
308
+ WHEN edges.spec IS NOT NULL AND ${underTargetDir} THEN 1
309
+ ELSE 2 END, n.id
310
+ LIMIT 1
311
+ )
312
+ WHERE kind = 'ref' AND dst_node_id IS NULL AND is_member <> 1
313
+ ${callScope}`).run(...(narrow ? [...ids, ...importScope, ...ids] : []));
290
314
  return { calls, imports };
291
315
  }
292
316
  /** Project root of an index database stored at `<root>/.speclaw/index.db`. */
@@ -170,11 +170,37 @@ function pickPrimary(symbols) {
170
170
  }
171
171
  return symbols.find((s) => s.kind === "function") ?? symbols[0];
172
172
  }
173
+ /** Rows de-duplicated on every field (the call-only caller list, as before refs). */
174
+ function uniqueRows(rows) {
175
+ const seen = new Set();
176
+ return rows.filter((r) => {
177
+ const key = `${r.name}\0${r.kind}\0${r.file}\0${r.line}`;
178
+ if (seen.has(key))
179
+ return false;
180
+ seen.add(key);
181
+ return true;
182
+ });
183
+ }
184
+ /**
185
+ * One caller entry per owner node with `via`: `call` (at its first call line)
186
+ * when the node calls the symbol, else `ref` (at its first reference line).
187
+ */
188
+ // Covers: req~type-ref-edges~1
189
+ function callersWithVia(rows) {
190
+ const byNode = new Map();
191
+ for (const r of rows) {
192
+ const prev = byNode.get(r.id);
193
+ if (!prev || (prev.via === "ref" && r.via === "call")) {
194
+ byNode.set(r.id, { name: r.name, kind: r.kind, file: r.file, line: r.line, via: r.via });
195
+ }
196
+ }
197
+ return [...byNode.values()].sort((a, b) => a.file === b.file ? a.line - b.line : a.file < b.file ? -1 : 1);
198
+ }
173
199
  /**
174
200
  * Source, callers, and callees for one definition. `others` are the
175
201
  * `otherMatches` (same-name siblings, or the rest of a file).
176
202
  */
177
- function assembleExplore(db, projectPath, best, others) {
203
+ function assembleExplore(db, projectPath, best, others, includeRefs = false) {
178
204
  // Covers: req~compass-mcp-surface~1
179
205
  // Resolved callees only, one row per target node at its first call line, so
180
206
  // unresolved names (builtins, member calls) never crowd out project callees.
@@ -199,19 +225,25 @@ function assembleExplore(db, projectPath, best, others) {
199
225
  // builtin global name (`test`, `fetch`) matches by name only within the
200
226
  // definition's own file. A caller outside any definition is its file-owner
201
227
  // node (`kind: "file"`).
202
- const callers = db
203
- .prepare(`SELECT DISTINCT owner.name AS name, owner.kind AS kind, f.path AS file, e.line
228
+ // A `ref` edge (type reference) counts only through its resolved id, never by name.
229
+ const kinds = includeRefs ? "e.kind IN ('call', 'ref')" : "e.kind = 'call'";
230
+ const rows = db
231
+ .prepare(`SELECT DISTINCT owner.id AS id, owner.name AS name, owner.kind AS kind, f.path AS file,
232
+ e.line, e.kind AS via
204
233
  FROM edges e
205
234
  JOIN nodes owner ON owner.id = e.src_node_id
206
235
  JOIN files f ON f.id = owner.file_id
207
- WHERE (e.dst_node_id = ? OR (
208
- e.dst_name = ? AND e.is_member = 0
236
+ WHERE ${kinds}
237
+ AND (e.dst_node_id = ? OR (
238
+ e.kind = 'call' AND e.dst_name = ? AND e.is_member = 0
209
239
  AND (e.dst_name NOT IN (${BUILTIN_SQL_LIST})
210
240
  OR e.src_file_id = (SELECT file_id FROM nodes WHERE id = ?))
211
241
  ))
212
- AND e.kind = 'call'
213
242
  ORDER BY f.path, e.line`)
214
243
  .all(best.id, best.name, best.id);
244
+ const callers = includeRefs
245
+ ? callersWithVia(rows)
246
+ : uniqueRows(rows.map(({ name, kind, file, line }) => ({ name, kind, file, line })));
215
247
  return {
216
248
  found: true,
217
249
  symbol: {
@@ -247,7 +279,7 @@ function assembleExplore(db, projectPath, best, others) {
247
279
  * file one); a file with no node at all reports that it has no symbols. A non-unique suffix falls through to {@link search}, which also
248
280
  * matches `files.path`.
249
281
  */
250
- function exploreWhenNameMisses(db, projectPath, query) {
282
+ function exploreWhenNameMisses(db, projectPath, query, includeRefs) {
251
283
  // Covers: req~explore-file-path~2
252
284
  const resolved = resolveIndexedFile(db, projectPath, query);
253
285
  if (resolved.status === "one") {
@@ -255,7 +287,7 @@ function exploreWhenNameMisses(db, projectPath, query) {
255
287
  const owner = symbols.length === 0 ? fileSymbols(db, resolved.file, true)[0] : undefined;
256
288
  if (owner) {
257
289
  return {
258
- ...assembleExplore(db, projectPath, owner, []),
290
+ ...assembleExplore(db, projectPath, owner, [], includeRefs),
259
291
  message: `Path "${resolved.file}" declares no symbols; resolved to its file-owner node.`,
260
292
  };
261
293
  }
@@ -268,7 +300,7 @@ function exploreWhenNameMisses(db, projectPath, query) {
268
300
  };
269
301
  }
270
302
  const primary = pickPrimary(symbols);
271
- const result = assembleExplore(db, projectPath, primary, symbols.filter((s) => s.id !== primary.id));
303
+ const result = assembleExplore(db, projectPath, primary, symbols.filter((s) => s.id !== primary.id), includeRefs);
272
304
  return {
273
305
  ...result,
274
306
  message: `Path "${resolved.file}" resolved to ${primary.name}.`,
@@ -301,10 +333,14 @@ function exploreWhenNameMisses(db, projectPath, query) {
301
333
  *
302
334
  * @param projectPath - Absolute path to the indexed project.
303
335
  * @param query - Exact node name, or a file path when no symbol has that name.
336
+ * @param opts - `includeRefs` (default false) also lists type-reference
337
+ * (`ref`) callers and tags every caller with `via`; the MCP and CLI explore
338
+ * opt in, investigation and ceremony signals keep the call-only list.
304
339
  * @returns The explore result; `found` is `false` when no single symbol is selected.
305
340
  * @throws If no index exists for the project.
306
341
  */
307
- export function explore(projectPath, query) {
342
+ export function explore(projectPath, query, opts = {}) {
343
+ const includeRefs = opts.includeRefs ?? false;
308
344
  requireIndex(projectPath);
309
345
  const db = openDb(projectPath);
310
346
  try {
@@ -317,8 +353,8 @@ export function explore(projectPath, query) {
317
353
  LIMIT 10`)
318
354
  .all(query);
319
355
  if (matches.length === 0)
320
- return exploreWhenNameMisses(db, projectPath, query);
321
- return assembleExplore(db, projectPath, matches[0], matches.slice(1));
356
+ return exploreWhenNameMisses(db, projectPath, query, includeRefs);
357
+ return assembleExplore(db, projectPath, matches[0], matches.slice(1), includeRefs);
322
358
  }
323
359
  finally {
324
360
  db.close();
@@ -11,6 +11,7 @@ import { hotspots, coupling } from "./hotspots.js";
11
11
  import { startWatch, stopWatch, watchStatus } from "./watcher.js";
12
12
  import { exploreRich, findSymbols, formatExploreRich, } from "./explore-rich.js";
13
13
  import { diffContext, formatDiffContext } from "./diff-context.js";
14
+ import { formatFindResponse } from "./find-output.js";
14
15
  const includeEnum = z.array(z.enum(["source", "callers", "callees", "blast_radius", "tests", "hotspot"]));
15
16
  /** Register Compass MCP tools on the given server. */
16
17
  export function registerCompass(server, opts = {}) {
@@ -43,7 +44,7 @@ export function registerCompass(server, opts = {}) {
43
44
  mode: (mode ?? "brief"),
44
45
  maxDepth,
45
46
  });
46
- return text(formatExploreRich(result, (mode ?? "brief")));
47
+ return text(formatExploreRich(result, (mode ?? "brief")), mode ?? "brief");
47
48
  });
48
49
  add("compass_find", "Hybrid search over the index: BM25, vectors, and name match. mode sets weights; pass focus for edited files.", {
49
50
  projectPath: z.string(),
@@ -52,7 +53,10 @@ export function registerCompass(server, opts = {}) {
52
53
  limit: z.number().optional(),
53
54
  focus: z.array(z.string()).optional(),
54
55
  maxTokens: z.number().int().min(256).max(32_000).optional(),
55
- }, async ({ projectPath, query, mode, limit, focus, maxTokens }) => text(JSON.stringify(await findSymbols(projectPath, query, mode, limit, { focus, maxTokens }), null, 2)));
56
+ }, async ({ projectPath, query, mode, limit, focus, maxTokens }) => {
57
+ const found = await findSymbols(projectPath, query, mode, limit, { focus, maxTokens });
58
+ return text(formatFindResponse(found), { maxTokens: found.cap });
59
+ });
56
60
  add("compass_diff_context", "Graph context of changes in one call: symbols, blast radius, tests, hotspots. Default: working tree.", {
57
61
  projectPath: z.string(),
58
62
  rev: z.string().optional(),
@@ -67,7 +71,7 @@ export function registerCompass(server, opts = {}) {
67
71
  mode: (mode ?? "brief"),
68
72
  maxDepth,
69
73
  });
70
- return text(formatDiffContext(result, (mode ?? "brief")));
74
+ return text(formatDiffContext(result, (mode ?? "brief")), mode ?? "brief");
71
75
  });
72
76
  add("compass_index", "Build or refresh the code graph index; optional watch action for live re-index.", {
73
77
  projectPath: z.string(),
@@ -94,8 +98,9 @@ export function registerCompass(server, opts = {}) {
94
98
  handler: async ({ projectPath, query, limit }) => {
95
99
  logDeprecatedCall(projectPath, "compass_search");
96
100
  recordCompassCall(projectPath, "compass_search");
97
- const body = JSON.stringify(await findSymbols(projectPath, query, "exact", limit), null, 2);
98
- return text(prefixDeprecated("compass_search", body));
101
+ const found = await findSymbols(projectPath, query, "exact", limit);
102
+ const prefix = prefixDeprecated("compass_search", "");
103
+ return text(formatFindResponse(found, { prefix }), { maxTokens: found.cap });
99
104
  },
100
105
  });
101
106
  defineAliasTool(server, {
@@ -105,8 +110,9 @@ export function registerCompass(server, opts = {}) {
105
110
  handler: async ({ projectPath, query, limit }) => {
106
111
  logDeprecatedCall(projectPath, "compass_recall");
107
112
  recordCompassCall(projectPath, "compass_recall");
108
- const body = JSON.stringify(await findSymbols(projectPath, query, "concept", limit), null, 2);
109
- return text(prefixDeprecated("compass_recall", body));
113
+ const found = await findSymbols(projectPath, query, "concept", limit);
114
+ const prefix = prefixDeprecated("compass_recall", "");
115
+ return text(formatFindResponse(found, { prefix }), { maxTokens: found.cap });
110
116
  },
111
117
  });
112
118
  defineAliasTool(server, {
@@ -6,7 +6,8 @@
6
6
  * ops via `speclaw cortex` or the `cortex` MCP tool.
7
7
  *
8
8
  * Cortex must not import from the lawbook module (no cycles). Ceremony level
9
- * is read locally from `change.json` `confirmedLevel` (default 3).
9
+ * is read locally from `change.json` `confirmedLevel` (default 3) at start and
10
+ * again on every advance and rework.
10
11
  */
11
12
  import fs from "node:fs";
12
13
  import path from "node:path";
@@ -166,9 +167,18 @@ export function handleHarness(args) {
166
167
  if (!current) {
167
168
  throw new Error(`no harness.json for "${change}" — run cortex start first`);
168
169
  }
170
+ // Covers: req~harness-level-current~1
171
+ // The level is re-read on every mutating op, so a `level set` after start
172
+ // governs the next route; unconfirmed or missing → 3 (never skips planning).
173
+ const level = readConfirmedLevel(projectPath, change);
169
174
  if (harnessOp === "advance") {
170
- // Planner may pause for questions instead of advancing to implement.
171
- if (current.stage === "planning" && args.pauseForQuestions) {
175
+ // Covers: req~harness-pause-questions~1
176
+ // Only the planner may pause for questions; anywhere else the pause would
177
+ // be dropped silently, so it is rejected before any write.
178
+ if (args.pauseForQuestions && current.stage !== "planning") {
179
+ throw new Error(`pauseForQuestions is only valid from stage planning (current: ${current.stage})`);
180
+ }
181
+ if (args.pauseForQuestions) {
172
182
  const questions = args.openQuestions ?? [];
173
183
  if (questions.length === 0) {
174
184
  throw new Error(`pauseForQuestions requires at least one openQuestions entry`);
@@ -176,6 +186,7 @@ export function handleHarness(args) {
176
186
  const next = {
177
187
  ...current,
178
188
  stage: "questions",
189
+ level,
179
190
  openQuestions: questions,
180
191
  history: [
181
192
  ...current.history,
@@ -191,7 +202,7 @@ export function handleHarness(args) {
191
202
  writeHarness(projectPath, change, next);
192
203
  return next;
193
204
  }
194
- const to = allowedAdvance(current.stage, current.level, args.verdict ?? undefined);
205
+ const to = allowedAdvance(current.stage, level, args.verdict ?? undefined);
195
206
  // Compass-first gate: runs before any write, so a strict rejection leaves
196
207
  // harness.json byte-identical.
197
208
  let compassEvidence;
@@ -213,6 +224,7 @@ export function handleHarness(args) {
213
224
  const next = {
214
225
  ...current,
215
226
  stage: to,
227
+ level,
216
228
  verdicts,
217
229
  openQuestions: current.stage === "questions" ? [] : current.openQuestions,
218
230
  history: [
@@ -249,6 +261,7 @@ export function handleHarness(args) {
249
261
  const next = {
250
262
  ...current,
251
263
  stage: "implementing",
264
+ level,
252
265
  iteration: current.iteration + 1,
253
266
  verdicts,
254
267
  history: [
@@ -14,7 +14,7 @@ import { stageStartedAt } from "./compass-gate.js";
14
14
  import { OUTPUT_BUDGET, estimateTokens } from "../../shared/output-budget.js";
15
15
  import { resolveChangeDir } from "./paths.js";
16
16
  /** Interval used when the key is missing or invalid. */
17
- export const DEFAULT_STATUS_INTERVAL_MINUTES = 5;
17
+ export const DEFAULT_STATUS_INTERVAL_MINUTES = 0;
18
18
  /**
19
19
  * Largest interval reported. A session timer is a cron expression, and
20
20
  * an every-N-minutes step only exists for N ≤ 59; 60 is the hourly ping.
@@ -23,17 +23,18 @@ Claude-specific notes: [`CLAUDE.md`](CLAUDE.md). The law: [`LAWS.md`](LAWS.md).
23
23
  first; Claude Code sessions refresh an existing index at start), or the
24
24
  target isn't indexed code (stylesheets, config, logs). Cheat
25
25
  sheet: [`docs/compass.md`](docs/compass.md).
26
- 3. **Follow Cortex** (*One brain. Many agents.*) for every non-trivial change;
27
- archive within the same PR. The primary agent is the **coordinator**
28
- (`cortex` skill / `/lawbook/cortex`) — it MUST NOT implement
29
- product code itself. It dispatches explorer → planner → implementer →
30
- reviewer → tester → archiver (or adopts each role while obeying that
31
- role's permission profile). Drive state with the `cortex` MCP tool or
32
- `speclaw cortex`. Rules:
26
+ 3. **Cortex, always — one brain on the critical path** (*One brain. Many
27
+ agents.*). Branch `<type>/<slug>`, locate with Compass, implement the
28
+ change and its test yourself, run the tests, finish. The `Stop` hook
29
+ (`speclaw ship-on-stop`) — or `speclaw ship <change> --summary "<what and
30
+ why>"` as your last step on agents without hooks — records the change, runs
31
+ the gates once, writes the report from their real output, and archives
32
+ level-0 work. Review happens on the PR and never blocks the agent. Add
33
+ agents only to run three or more large, independent parts in parallel.
34
+ Details: the `cortex` skill and
33
35
  [`docs/standards/lawbook.md`](docs/standards/lawbook.md).
34
36
  4. **Run the quality gates yourself** before declaring anything done — see
35
- [`docs/standards/testing-standards.md`](docs/standards/testing-standards.md)
36
- (owned by the **tester** role under Cortex):
37
+ [`docs/standards/testing-standards.md`](docs/standards/testing-standards.md):
37
38
  - Tests: `{{test_commands}}`
38
39
  - Lint / type-check: `{{lint_commands}}`
39
40
  5. **Respect the conventions** — branches `{{branch_pattern}}`, commits
@@ -50,16 +50,19 @@ target is not indexed code — stylesheets, JSON/config, markdown, logs,
50
50
  generated files, lockfiles. Never skip Compass because grep "feels faster."
51
51
  See [`docs/compass.md`](docs/compass.md).
52
52
 
53
- ## Rule 2 — Spec-driven Cortex loop, always
54
-
55
- No non-trivial change lands without a lawbook change (propose → implement →
56
- verify → archive) run through **Cortex** (*One brain. Many agents.*). You are the
57
- **coordinator** unless you were spawned as a role agent: use the `cortex`
58
- skill / `/lawbook/cortex` and the `cortex` MCP tool; do **not** implement product code yourself.
59
- Role agents live in `ai-specs/agents/`. The rules are in
60
- [`docs/standards/lawbook.md`](docs/standards/lawbook.md);
61
- workflow skills live in `ai-specs/skills/` and `/lawbook` commands wrap them.
62
- A change is not done until it is archived — archiving belongs in the PR.
53
+ ## Rule 2 — Cortex, always — one brain on the critical path
54
+
55
+ Every change is built with **Cortex** (*One brain. Many agents.*), and Cortex
56
+ costs no more time than working alone: branch `<type>/<slug>`, locate with
57
+ Compass, implement the change and its test yourself, run the tests, finish.
58
+ Then stop: the `Stop` hook (`speclaw ship-on-stop`) records the change, runs
59
+ the gates once, writes the report from their real output, and archives
60
+ level-0 work with no agent turns; a failing gate comes back to you. Do not run
61
+ `speclaw ship` yourself, check the hook, open the archive, edit the generated
62
+ report, read the process docs, or drive the harness by hand. Review happens on the PR, never blocking the
63
+ agent. Add agents only to run three or more large, independent parts in
64
+ parallel. Details: the `cortex` skill and
65
+ [`docs/standards/lawbook.md`](docs/standards/lawbook.md).
63
66
 
64
67
  ## Rule 3 — Quality gates are non-negotiable
65
68
 
@@ -30,7 +30,7 @@ a request, not whole files.
30
30
  | Tool | Use it to |
31
31
  |------|-----------|
32
32
  | `compass_index` | Build/refresh the graph (`.speclaw/index.db`). Incremental — unchanged files are skipped by hash. Run once after init and after significant edits. |
33
- | `compass_find` | Find code: `mode: exact` by name/keyword, `mode: concept` by meaning in natural language. |
33
+ | `compass_find` | Find code: `mode: exact` by exact name (one or more identifiers; a missing name returns `found: false` and up to five `nearest` names), `mode: concept` by meaning in natural language. Returns compact hits (`name`, `kind`, `file`, `line`) in one JSON document that fits `maxTokens` (default 1500); `truncated: true` only when it trimmed. |
34
34
  | `compass_explore` | Read a node's verbatim source plus its callers and callees. The default before editing. Add `include: ["blast_radius"]` for what could break, or `to: <symbol>` for the call path between two nodes. |
35
35
  | `compass_diff_context` | Symbols, blast radius, and tests for a diff (working tree or a rev). |
36
36
 
@@ -46,9 +46,16 @@ project method, so they do not appear as callers. Calls through an imported
46
46
  re-export barrel (`index.ts` holding `export * from "./lib/core"`) bind by
47
47
  name, preferring a definition under the barrel's directory.
48
48
 
49
- Compass uses index schema 11. speclaw 2.0.9 or older, including an MCP entry
50
- pinned at `@2.0.9`, rebuilds a schema-11 index from scratch (losing the
51
- embedding cache). Run `speclaw update` so the pinned MCP entry moves to 2.0.10.
49
+ Explore callers carry `via`: `call` for calls, `ref` for type references
50
+ (annotations and `extends`/`implements` clauses that resolve through the file's
51
+ own import or a same-file definition). Type references never change blast
52
+ radius, affected tests, or ranking. `compass_find` keeps only indexed files in
53
+ `focus` and lists the others in `focusIgnored`.
54
+
55
+ Compass uses index schema 12 (11→12 forces one reindex; embeddings reused).
56
+ speclaw 2.0.12 or older, including an MCP entry pinned at `@2.0.12`, rebuilds a
57
+ schema-12 index from scratch (losing the embedding cache). Pin every speclaw to
58
+ 2.0.13: run `speclaw update` so the pinned MCP entry moves to 2.0.13.
52
59
 
53
60
  `compass_index` also takes `action: start|stop|status` to keep the index fresh
54
61
  automatically (a debounced incremental re-index on file change).
@@ -1,3 +1,4 @@
1
+ import { DEFAULT_EDGE_KINDS } from "./laws.js";
1
2
  import { underPaths } from "./verify-model.js";
2
3
  /** Substitute `$1`, `$2`, … in a pattern with capture groups from a match. */
3
4
  function applyGroups(pattern, match) {
@@ -5,9 +6,10 @@ function applyGroups(pattern, match) {
5
6
  }
6
7
  /** The `IN (?, ?)` clause and params for an optional edge-kind filter. */
7
8
  function edgeKindClause(edgeKinds) {
8
- if (!edgeKinds || edgeKinds.length === 0)
9
- return { sql: "", params: [] };
10
- return { sql: ` AND e.kind IN (${edgeKinds.map(() => "?").join(", ")})`, params: edgeKinds };
9
+ // Type-reference (`ref`) edges feed explore callers only; a law without
10
+ // explicit kinds keeps reading calls and imports, as before refs existed.
11
+ const kinds = edgeKinds && edgeKinds.length > 0 ? edgeKinds : DEFAULT_EDGE_KINDS;
12
+ return { sql: ` AND e.kind IN (${kinds.map(() => "?").join(", ")})`, params: [...kinds] };
11
13
  }
12
14
  /** Load resolved file→file edges (earliest line per pair) from the index. */
13
15
  function resolvedEdges(db, edgeKinds) {
@@ -563,7 +563,7 @@ function specsOrphansCheck(projectPath) {
563
563
  * accepting any pending drift — so that is offered only as a last resort.
564
564
  */
565
565
  export const UNREADABLE_LOCK_REMEDY = "resolve the merge conflict in speclaw.lock or restore it from git " +
566
- "(git checkout -- speclaw.lock); upgrade speclaw if the lockfileVersion is newer. " +
566
+ "(git checkout HEAD -- speclaw.lock); upgrade speclaw if the lockfileVersion is newer. " +
567
567
  "Last resort: delete it and run speclaw laws lock — this re-baselines every " +
568
568
  "pinned file and accepts any pending drift";
569
569
  function integrityChecks(projectPath) {
@@ -1,10 +1,10 @@
1
- import { matchesScope } from "./laws.js";
1
+ import { DEFAULT_EDGE_KINDS, matchesScope } from "./laws.js";
2
2
  import { underPaths } from "./verify-model.js";
3
3
  /** Build the cross-file dependency graph, restricted to `paths` and `scope`. */
4
4
  function buildGraph(db, paths, edgeKinds, scope) {
5
- const kindFilter = edgeKinds && edgeKinds.length > 0
6
- ? ` AND e.kind IN (${edgeKinds.map(() => "?").join(", ")})`
7
- : "";
5
+ // No explicit kinds: calls and imports only (`ref` edges never form a law cycle).
6
+ const kinds = edgeKinds && edgeKinds.length > 0 ? edgeKinds : DEFAULT_EDGE_KINDS;
7
+ const kindFilter = ` AND e.kind IN (${kinds.map(() => "?").join(", ")})`;
8
8
  const rows = db
9
9
  .prepare(`SELECT DISTINCT sf.path AS src, df.path AS dst
10
10
  FROM edges e
@@ -12,7 +12,7 @@ function buildGraph(db, paths, edgeKinds, scope) {
12
12
  JOIN nodes dn ON dn.id = e.dst_node_id
13
13
  JOIN files df ON df.id = dn.file_id
14
14
  WHERE e.dst_node_id IS NOT NULL AND sf.path <> df.path${kindFilter}`)
15
- .all(...(edgeKinds && edgeKinds.length > 0 ? edgeKinds : []));
15
+ .all(...kinds);
16
16
  const adj = new Map();
17
17
  for (const { src, dst } of rows) {
18
18
  if (!underPaths(src, paths) || !underPaths(dst, paths))
@@ -41,6 +41,21 @@ export const REINDEX_FILE_MARKER = "speclaw reindex-file";
41
41
  * than the command rejects it as unknown (exit 1) before touching the index.
42
42
  */
43
43
  export const REINDEX_FILE_COMMAND = speclawCommand("reindex-file");
44
+ /**
45
+ * Substring every ship-on-stop command contains, in each of its resolution
46
+ * branches. It is the merge identity of the `Stop` ship hook.
47
+ */
48
+ export const SHIP_ON_STOP_MARKER = "speclaw ship-on-stop";
49
+ /**
50
+ * The POSIX `sh` command the `Stop` hook runs: when the agent finishes a turn
51
+ * with new changes on a feature branch, speclaw records the change, runs the
52
+ * gates once, writes the report from their real output, and archives level-0
53
+ * work — with no agent turn. Guarded on `lawbook/` instead of the index. Its
54
+ * stderr is kept so a failing gate reaches the agent (exit 2).
55
+ */
56
+ export const SHIP_ON_STOP_COMMAND = speclawCommand("ship-on-stop");
57
+ /** Seconds the Stop hook may run: the project's own gates run inside it. */
58
+ const SHIP_ON_STOP_TIMEOUT = 600;
44
59
  /** Seconds the edit hook may run; the command returns once it has spawned the work. */
45
60
  const REINDEX_TIMEOUT_SECONDS = 10;
46
61
  /**
@@ -53,11 +68,17 @@ const REINDEX_TIMEOUT_SECONDS = 10;
53
68
  */
54
69
  export function speclawCommand(sub) {
55
70
  // Covers: req~edit-reindex-hook~1
56
- return ('cd "${CLAUDE_PROJECT_DIR:-.}" 2>/dev/null && [ -f .speclaw/index.db ] && { ' +
57
- `if [ -x node_modules/.bin/speclaw ]; then node_modules/.bin/speclaw ${sub}; ` +
71
+ const ship = sub === "ship-on-stop";
72
+ const run = `if [ -x node_modules/.bin/speclaw ]; then node_modules/.bin/speclaw ${sub}; ` +
58
73
  `elif command -v speclaw >/dev/null 2>&1; then speclaw ${sub}; ` +
59
- `else npm_config_update_notifier=false npm_config_offline=true npx --no-install @esneiderbravo/speclaw ${sub}; fi; ` +
60
- "} >/dev/null 2>&1 || true");
74
+ `else npm_config_update_notifier=false npm_config_offline=true npx --no-install @esneiderbravo/speclaw ${sub}; fi; `;
75
+ // The ship hook keeps its exit code (2 feeds a failing gate back to the
76
+ // agent) and its stderr; stdout is discarded like the other hooks.
77
+ return ship
78
+ ? 'cd "${CLAUDE_PROJECT_DIR:-.}" 2>/dev/null && [ -d lawbook ] && { ' + run + "} >/dev/null"
79
+ : 'cd "${CLAUDE_PROJECT_DIR:-.}" 2>/dev/null && [ -f .speclaw/index.db ] && { ' +
80
+ run +
81
+ "} >/dev/null 2>&1 || true";
61
82
  }
62
83
  /** `SessionStart` matcher covering every session source Claude Code reports. */
63
84
  const SESSION_START_MATCHER = "startup|resume|clear|compact";
@@ -113,6 +134,7 @@ export function isSpeclawHook(h) {
113
134
  return (o?.type === "command" &&
114
135
  typeof o?.command === "string" &&
115
136
  (o.command.includes(SESSION_START_MARKER) ||
137
+ o.command.includes(SHIP_ON_STOP_MARKER) ||
116
138
  o.command.includes(REINDEX_FILE_MARKER) ||
117
139
  o.command.includes(LEGACY_SESSION_START_MARKER)));
118
140
  }
@@ -161,6 +183,10 @@ export function compileHooks(manifest) {
161
183
  });
162
184
  if (hasGate)
163
185
  byEvent.Stop = [{ hooks: [{ ...SPECLAW_HOOK }] }];
186
+ // Covers: req~ship-on-stop-hook~1
187
+ (byEvent.Stop ??= []).push({
188
+ hooks: [{ type: "command", command: SHIP_ON_STOP_COMMAND, timeout: SHIP_ON_STOP_TIMEOUT }],
189
+ });
164
190
  if (valid.length > 0)
165
191
  byEvent.InstructionsLoaded = [{ hooks: [{ ...SPECLAW_HOOK }] }];
166
192
  // Covers: req~session-start-hook~1
@@ -23,23 +23,31 @@ export function verifyIntegrity(opts) {
23
23
  lock = readLockfile(projectPath);
24
24
  }
25
25
  catch (err) {
26
+ // An unreadable lock only blocks the digest/symlink comparison; the scan
27
+ // never needs the lock, so its findings must still be reported.
28
+ // Covers: req~injection-scan~1
29
+ const lockError = err.message;
30
+ const scanned = doScan ? scanAll(projectPath) : [];
26
31
  return {
27
32
  ok: false,
28
33
  lockPresent: fs.existsSync(lockfilePath(projectPath)),
29
34
  rootMatches: false,
30
- guidance: err.message,
35
+ guidance: lockError,
31
36
  files: [],
32
37
  symlinks: [],
33
- findings: [],
38
+ findings: scanned,
34
39
  verifyFindings: [
35
40
  {
36
41
  lawId: "integrity~lockfile~1",
37
42
  severity: "error",
38
43
  engine: "integrity",
39
44
  file: "speclaw.lock",
40
- message: err.message,
45
+ message: lockError,
41
46
  },
47
+ ...scanned.filter((f) => f.severity === "error").map(scanToFinding),
48
+ ...scanned.filter((f) => f.severity === "warn").map(scanToFinding),
42
49
  ],
50
+ lockError,
43
51
  };
44
52
  }
45
53
  if (!lock && doIntegrity) {
@@ -2,6 +2,11 @@ import fs from "node:fs";
2
2
  import path from "node:path";
3
3
  import { z } from "zod";
4
4
  import { adaptedLaws, catalogLaws, mergeAdaptedSeed } from "./seed-laws.js";
5
+ /**
6
+ * Edge kinds a `deps`/`graph` law reads when it names none: calls and
7
+ * imports. Compass type-reference (`ref`) edges only feed explore callers.
8
+ */
9
+ export const DEFAULT_EDGE_KINDS = ["call", "import"];
5
10
  const depsRuleSchema = z.object({
6
11
  name: z.string().optional(),
7
12
  from: z.string(),
@@ -10,4 +10,4 @@ with the `cortex` MCP tool (`status`/`start`/`advance`/`rework`/`brief`) or
10
10
  `speclaw cortex …`. Surface planner questions to the human. Do not implement
11
11
  product code yourself.
12
12
  The run posts status updates every `cortex.statusIntervalMinutes` minutes
13
- (`lawbook/config.yaml`, default 5, `0` disables, at most 60).
13
+ (`lawbook/config.yaml`, default 0, `0` disables, at most 60).
@@ -5,6 +5,12 @@ alwaysApply: true
5
5
 
6
6
  # Spec Reports: Discipline Coverage
7
7
 
8
+ > **Scope.** This rule governs spec-lane changes — the ones that carry a
9
+ > `tasks.md` (level 2+, or the Cortex fan-out lane). In the default Cortex lane,
10
+ > `speclaw ship` (or the `Stop` hook) writes the record and the report from the
11
+ > gates' real output: do not hand-write, edit, or restructure them, and do not
12
+ > re-run ship or archive yourself.
13
+
8
14
  A change's evidence of testing lives under `lawbook/changes/<name>/reports/`, as
9
15
  **one file per discipline the change actually touched**, named for that
10
16
  discipline (`<discipline>.md`) — never a single lumped report.
@@ -5,6 +5,12 @@ alwaysApply: true
5
5
 
6
6
  # Spec Tasks: Mandatory Steps
7
7
 
8
+ > **Scope.** This rule governs spec-lane changes — the ones that carry a
9
+ > `tasks.md` (level 2+, or the Cortex fan-out lane). In the default Cortex lane,
10
+ > `speclaw ship` (or the `Stop` hook) writes the record and the report from the
11
+ > gates' real output: do not hand-write, edit, or restructure them, and do not
12
+ > re-run ship or archive yourself.
13
+
8
14
  When creating or updating a `tasks.md` inside a `lawbook/changes/<name>/`, you MUST:
9
15
 
10
16
  ## 1. Read lawbook/config.yaml first