@esneiderbravo/speclaw 2.0.9 → 2.0.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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 **10**. 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 **11**. 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. |
@@ -250,7 +250,11 @@ hooks into that agent's settings **by identity** — it never touches hooks you
250
250
  added yourself. The same merge adds one `SessionStart` command hook that
251
251
  silently refreshes an existing Compass index when a session starts
252
252
  (`speclaw session-start`; it skips when no index exists, never downloads or
253
- contacts the registry, and always exits 0). The compiled law manifest lives in `.speclaw/laws-manifest.json`
253
+ contacts the registry, and always exits 0), and one separate `PostToolUse`
254
+ command hook for `Write|Edit|MultiEdit|NotebookEdit` that hands each edited
255
+ file to `speclaw reindex-file`, which re-indexes it in a detached background
256
+ process (silent, always exit 0; PageRank and the compact map catch up on the
257
+ next full run). The compiled law manifest lives in `.speclaw/laws-manifest.json`
254
258
  (gitignored) and is **adapted to the target tree** on `init`/`update` — speclaw's
255
259
  own architecture laws are seeded only when those paths exist, and the cycle law
256
260
  follows `apps/*/src`, `packages/*/src`, or `src/` rather than copying
@@ -47,8 +47,11 @@ export async function runQuery(cmd, flags) {
47
47
  console.log(s.source);
48
48
  ui.heading("Callees");
49
49
  r.callees?.forEach((c) => ui.info(`${c.name}${c.file ? ` (${c.file}:${c.line})` : ""}`));
50
+ if (r.unresolvedCallees && r.unresolvedCallees.count > 0) {
51
+ ui.info(`+ ${r.unresolvedCallees.count} unresolved call(s): ${r.unresolvedCallees.sample.join(", ")}`);
52
+ }
50
53
  ui.heading("Callers");
51
- r.callers?.forEach((c) => ui.info(`${c.name} (${c.file}:${c.line})`));
54
+ r.callers?.forEach((c) => ui.info(`${c.kind === "file" ? "(file) " : ""}${c.name} (${c.file}:${c.line})`));
52
55
  return;
53
56
  }
54
57
  case "search":
@@ -147,7 +150,14 @@ export async function runQuery(cmd, flags) {
147
150
  ui.info(result.reason);
148
151
  result.tests.forEach((t) => ui.info(t.file));
149
152
  ui.heading("Command");
150
- console.log(result.command);
153
+ // Covers: req~affected-test-selection~1
154
+ if (result.command === null) {
155
+ console.log(`no command: ${result.commandReason}`);
156
+ }
157
+ else {
158
+ console.log(result.command);
159
+ ui.info(result.commandReason);
160
+ }
151
161
  result.warnings.forEach((w) => ui.warn(w));
152
162
  return;
153
163
  }
@@ -0,0 +1,146 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { spawn } from "node:child_process";
4
+ import { helpFor } from "../lib/help.js";
5
+ import { realPathOf } from "../../shared/paths.js";
6
+ /** Hook payloads are small JSON objects; anything larger is not one. */
7
+ const MAX_PAYLOAD_BYTES = 1024 * 1024;
8
+ /**
9
+ * Split raw arguments into file paths: every token after `--`, and every
10
+ * token before it that does not look like a flag.
11
+ *
12
+ * @param args - The arguments after `reindex-file`.
13
+ * @returns The path arguments, in order.
14
+ */
15
+ export function reindexPathArgs(args) {
16
+ const out = [];
17
+ let rest = false;
18
+ for (const a of args) {
19
+ if (rest)
20
+ out.push(a);
21
+ else if (a === "--")
22
+ rest = true;
23
+ else if (!a.startsWith("-"))
24
+ out.push(a);
25
+ }
26
+ return out;
27
+ }
28
+ /**
29
+ * The absolute file a `PostToolUse` hook payload edited: the first non-empty of
30
+ * `tool_input.file_path` and `tool_input.notebook_path`, a relative value
31
+ * resolved against the payload's absolute `cwd`, else against `fallbackCwd`.
32
+ *
33
+ * @param payload - The parsed hook JSON.
34
+ * @param fallbackCwd - Base for a relative path when the payload has no absolute `cwd`.
35
+ * @returns The absolute path, or `null` when the payload names no file.
36
+ */
37
+ export function hookTarget(payload, fallbackCwd) {
38
+ if (!payload || typeof payload !== "object")
39
+ return null;
40
+ const p = payload;
41
+ const input = p.tool_input;
42
+ if (!input || typeof input !== "object")
43
+ return null;
44
+ const pick = (v) => typeof v === "string" && v.trim() !== "" ? v.trim() : null;
45
+ const target = pick(input.file_path) ?? pick(input.notebook_path);
46
+ if (target === null)
47
+ return null;
48
+ const base = typeof p.cwd === "string" && path.isAbsolute(p.cwd) ? p.cwd : fallbackCwd;
49
+ return path.resolve(base, target);
50
+ }
51
+ /** Whether `abs` lies strictly inside `root` (symlinked prefixes such as macOS `/var` resolved). */
52
+ function insideRoot(root, abs) {
53
+ const rel = path.relative(realPathOf(root), realPathOf(abs));
54
+ return rel !== "" && !rel.startsWith("..") && !path.isAbsolute(rel);
55
+ }
56
+ /** Read stdin to its end; `null` when it exceeds the payload cap (still drained). */
57
+ async function readPayload(stdin) {
58
+ const chunks = [];
59
+ let size = 0;
60
+ for await (const chunk of stdin) {
61
+ const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk));
62
+ size += buf.length;
63
+ if (size <= MAX_PAYLOAD_BYTES)
64
+ chunks.push(buf);
65
+ }
66
+ return size > MAX_PAYLOAD_BYTES ? null : Buffer.concat(chunks).toString("utf8");
67
+ }
68
+ /**
69
+ * `speclaw reindex-file [--] <path>...` and its hook mode.
70
+ *
71
+ * - **Path mode** (paths given): re-index exactly those files in the foreground
72
+ * with Compass `indexFiles` — the detached child, tests, the bench, and manual
73
+ * use run this.
74
+ * - **Hook mode** (no path): read the Claude Code `PostToolUse` JSON from stdin,
75
+ * take `tool_input.file_path` / `notebook_path`, and hand it to a detached
76
+ * `speclaw reindex-file -- <abs>` child, then return without waiting, so the
77
+ * edit is not slowed. This branch never opens the database or loads Compass
78
+ * (or tree-sitter). With stdin on a terminal it prints usage instead of
79
+ * blocking.
80
+ *
81
+ * The project root is the working directory (where the hook guard found
82
+ * `.speclaw/index.db`); a payload `cwd` only resolves a relative file path, so
83
+ * a payload cannot redirect writes into another project's index. With no index
84
+ * it does nothing and creates nothing. It prints nothing, is never logged as a
85
+ * Compass call, and swallows every failure (including `SQLITE_BUSY`): the
86
+ * caller always sees exit 0.
87
+ *
88
+ * @param args - Raw arguments after `reindex-file` (`--` honored).
89
+ * @param cwd - Project root (defaults to the process working directory).
90
+ * @param opts - `entry`: the CLI script the detached child runs (defaults to
91
+ * this process's `argv[1]`); `stdin`: the payload stream (defaults to
92
+ * `process.stdin`). Both are test seams.
93
+ */
94
+ // Covers: req~reindex-on-edit~1
95
+ export async function runReindexFile(args, cwd = process.cwd(), opts = {}) {
96
+ // Node lines that still flag `node:sqlite` as experimental would print an
97
+ // ExperimentalWarning to stderr; this command must print nothing.
98
+ process.removeAllListeners("warning");
99
+ try {
100
+ const paths = reindexPathArgs(args);
101
+ const hasIndex = () => fs.existsSync(path.join(cwd, ".speclaw", "index.db"));
102
+ if (paths.length > 0) {
103
+ if (!hasIndex())
104
+ return;
105
+ const { indexFiles } = await import("../../modules/compass/indexer.js");
106
+ await indexFiles(cwd, paths);
107
+ return;
108
+ }
109
+ const stdin = opts.stdin ?? process.stdin;
110
+ if (stdin.isTTY) {
111
+ process.stdout.write(helpFor("reindex-file") ?? "");
112
+ return;
113
+ }
114
+ const raw = await readPayload(stdin);
115
+ if (raw === null)
116
+ return;
117
+ let payload;
118
+ try {
119
+ payload = JSON.parse(raw);
120
+ }
121
+ catch {
122
+ return;
123
+ }
124
+ const target = hookTarget(payload, cwd);
125
+ if (target === null || !hasIndex() || !insideRoot(cwd, target))
126
+ return;
127
+ const entry = opts.entry ?? process.argv[1];
128
+ if (!entry)
129
+ return;
130
+ // detached: its own process group, so neither the hook timeout nor the
131
+ // agent's cleanup of the hook's group kills it. `--` keeps a path that
132
+ // starts with "-" from reading as a flag.
133
+ const child = spawn(process.execPath, [entry, "reindex-file", "--", target], {
134
+ cwd,
135
+ detached: true,
136
+ stdio: "ignore",
137
+ windowsHide: true,
138
+ });
139
+ child.on("error", () => { });
140
+ child.unref();
141
+ }
142
+ catch {
143
+ // A concurrent writer (SQLITE_BUSY after busy_timeout) or any other failure
144
+ // must never fail or print into the agent's edit.
145
+ }
146
+ }
@@ -237,6 +237,38 @@ const MIGRATIONS = [
237
237
  "`speclaw.lock`.\n" +
238
238
  "- Preserve all project-specific wording; only apply these speclaw-authored changes.",
239
239
  },
240
+ {
241
+ version: "2.0.10",
242
+ describe: "Compass schema 11 — truthful callers, callees, and affected-test commands",
243
+ agentPrompt: "- Compass is now **schema 11**: the first `speclaw index` after upgrading migrates " +
244
+ "10→11 and re-extracts every file once (embeddings are reused). Where the project " +
245
+ 'mentions "Schema **10**" or "Compass (schema 10)", change it to schema 11 ' +
246
+ "(10→11 forces a reindex; embeddings reused).\n" +
247
+ "- speclaw 2.0.9 or older (including an MCP entry pinned at `@2.0.9` or a stale " +
248
+ "global CLI) treats a schema-11 index as incompatible and rebuilds it from scratch, " +
249
+ "dropping the embedding cache. Where the project documents the index or the MCP entry, " +
250
+ "add: run `speclaw update` so the pinned MCP entry moves to 2.0.10; an older speclaw " +
251
+ "opening this index rebuilds it from scratch.\n" +
252
+ "- Affected-test `command` may now be `null` (with `commandReason`) when no test is " +
253
+ "reachable, and `commands[]` lists one `{ cwd, command, files }` per package. If the " +
254
+ "project's docs or scripts run `command` blindly, make them handle `null`.\n" +
255
+ "- Preserve all project-specific wording; only apply these speclaw-authored changes.",
256
+ },
257
+ {
258
+ version: "2.0.11",
259
+ describe: "Compass re-indexes each file an agent edits (PostToolUse → speclaw reindex-file)",
260
+ agentPrompt: "- For hook-capable agents (Claude Code), `init`/`update` now add one more `PostToolUse` " +
261
+ "command hook (matcher `Write|Edit|MultiEdit|NotebookEdit`, timeout 10) that hands the " +
262
+ "edited file to `speclaw reindex-file` in a detached background process: silent, always " +
263
+ "exit 0, skipped when `.speclaw/index.db` is absent, never downloads. This update reports " +
264
+ "the agent settings file under `refreshedDiverged` once; that is expected.\n" +
265
+ "- Where the project documents how the Compass index stays fresh (session start, " +
266
+ "`compass_index`, `speclaw watch`), add: each Write/Edit is re-indexed right after the " +
267
+ "edit; PageRank and the compact map in `docs/compass.md` catch up on the next full run; " +
268
+ "files created or renamed by shell commands and edits by agents without hooks are picked " +
269
+ "up at the next session start or `compass_index`.\n" +
270
+ "- Preserve all project-specific wording; only apply these speclaw-authored changes.",
271
+ },
240
272
  ];
241
273
  /**
242
274
  * Bring the current project up to date without a full re-init. When the npm
package/dist/cli/index.js CHANGED
@@ -36,7 +36,8 @@ const HEADER_COMMANDS = new Set([
36
36
  * forced-color signal counts as interactive so the header is exercisable in a
37
37
  * child process. `budget --json`, `doctor --json`, and `coverage` when emitting
38
38
  * TAP/JSON (or when stdout is not a TTY) are machine-consumed and suppress the
39
- * header. `session-start` is not header-eligible: it must print nothing.
39
+ * header. `session-start` and `reindex-file` are not header-eligible: they must print
40
+ * nothing.
40
41
  */
41
42
  function maybeHeader(cmd, flags) {
42
43
  if (!process.stdout.isTTY && process.env.FORCE_COLOR !== "1")
@@ -106,6 +107,9 @@ async function dispatch(cmd, flags) {
106
107
  return (await import("./commands/index-build.js")).runWatch(flags);
107
108
  case "session-start":
108
109
  return (await import("./commands/session-start.js")).runSessionStart();
110
+ case "reindex-file":
111
+ // Raw argv: the shared flag parser would read `-- <path>` as a flag value.
112
+ return (await import("./commands/reindex-file.js")).runReindexFile(process.argv.slice(3));
109
113
  case "explore":
110
114
  case "search":
111
115
  case "recall":
@@ -165,8 +169,8 @@ async function main() {
165
169
  const flags = parseFlags(rest);
166
170
  maybeHeader(cmd, flags);
167
171
  await dispatch(cmd, flags);
168
- // The SessionStart hook's refresh must stay silent: no update notice either.
169
- if (cmd === "session-start")
172
+ // The SessionStart and edit hooks must stay silent: no update notice either.
173
+ if (cmd === "session-start" || cmd === "reindex-file")
170
174
  return;
171
175
  await maybeNotifyUpdate(cmd);
172
176
  }
@@ -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
+ reindex-file [paths...] Silent re-index of edited files (PostToolUse hook; stdin JSON)
27
28
  watch Keep the index fresh on file changes
28
29
  explore <node> A node's source + callers/callees
29
30
  search <query> Hybrid find (BM25+vector+name); --focus --max-tokens --explain
@@ -171,6 +172,24 @@ Keep the Compass index fresh: re-index changed files until interrupted (Ctrl-C).
171
172
 
172
173
  Silent, fail-safe refresh of an existing Compass index. The SessionStart hook
173
174
  runs it; it prints nothing and never fails the session.
175
+ `,
176
+ },
177
+ {
178
+ name: "reindex-file",
179
+ usage: `Usage: speclaw reindex-file [--] <path>...
180
+ speclaw reindex-file < hook.json
181
+
182
+ Re-index single files in an existing Compass index, silently and fail-safe.
183
+
184
+ With paths, re-indexes exactly those files now (a deleted file is removed;
185
+ paths outside the project, in skipped directories, of no indexed language, or
186
+ over the size cap are ignored). With no path, reads a PostToolUse hook payload
187
+ from stdin and re-indexes tool_input.file_path (or notebook_path) in a detached
188
+ background process, returning at once. The PostToolUse hook for
189
+ Write|Edit|MultiEdit|NotebookEdit runs it.
190
+
191
+ PageRank and docs/compass.md are left to the next full run (session start,
192
+ speclaw index, compass_index, watch). Prints nothing and always exits 0.
174
193
  `,
175
194
  },
176
195
  {
@@ -10,10 +10,15 @@ const DEFAULT_TARGETS = {
10
10
  exclude: ["**/*.{test,spec}.{ts,tsx,js,jsx,mts,cts}", "test/**", "tests/**"],
11
11
  },
12
12
  test: {
13
+ // The `**/` forms cover workspace packages (`apps/web/src/**`), whose
14
+ // changes would otherwise be filtered out before any test is selected.
13
15
  include: [
14
16
  "src/**",
15
17
  "test/**",
16
18
  "tests/**",
19
+ "**/src/**",
20
+ "**/test/**",
21
+ "**/tests/**",
17
22
  "**/*.{test,spec}.{ts,tsx,js,jsx}",
18
23
  "**/conftest.py",
19
24
  ],