@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 +6 -2
- package/dist/cli/commands/query.js +12 -2
- package/dist/cli/commands/reindex-file.js +146 -0
- package/dist/cli/commands/update.js +32 -0
- package/dist/cli/index.js +7 -3
- package/dist/cli/lib/help.js +19 -0
- package/dist/modules/compass/affected-config.js +5 -0
- package/dist/modules/compass/affected.js +344 -27
- package/dist/modules/compass/db.js +149 -16
- package/dist/modules/compass/diff-context.js +4 -2
- package/dist/modules/compass/explore-rich.js +14 -7
- package/dist/modules/compass/extract.js +163 -4
- package/dist/modules/compass/hotspots.js +11 -6
- package/dist/modules/compass/hybrid.js +9 -4
- package/dist/modules/compass/indexer.js +898 -202
- package/dist/modules/compass/map.js +2 -2
- package/dist/modules/compass/query.js +70 -24
- package/dist/modules/compass/register.js +1 -1
- package/dist/modules/compass/visualize.js +5 -3
- package/dist/modules/foundation/assets/docs/compass.template.md +22 -2
- package/dist/modules/foundation/hooks.js +48 -10
- package/dist/modules/lawbook/drift.js +2 -2
- package/dist/modules/lawbook/investigate.js +2 -1
- package/dist/shared/output-budget.js +2 -0
- package/dist/shared/paths.js +24 -0
- 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 **
|
|
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)
|
|
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
|
-
|
|
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`
|
|
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
|
|
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
|
}
|
package/dist/cli/lib/help.js
CHANGED
|
@@ -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
|
],
|