@esneiderbravo/speclaw 2.0.10 → 2.0.12
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 +9 -1
- package/dist/cli/commands/cortex.js +3 -2
- package/dist/cli/commands/lawbook.js +3 -2
- package/dist/cli/commands/laws.js +10 -2
- package/dist/cli/commands/reindex-file.js +146 -0
- package/dist/cli/commands/update.js +15 -0
- package/dist/cli/index.js +9 -5
- package/dist/cli/lib/args.js +35 -5
- package/dist/cli/lib/help.js +20 -1
- package/dist/modules/compass/db.js +61 -0
- package/dist/modules/compass/indexer.js +549 -123
- package/dist/modules/foundation/assets/docs/compass.template.md +6 -2
- package/dist/modules/foundation/doctor.js +1 -1
- package/dist/modules/foundation/hooks.js +48 -10
- package/dist/modules/foundation/integrity.js +11 -3
- package/dist/shared/paths.js +24 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -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
|
|
@@ -306,6 +310,10 @@ speclaw laws accept AGENTS.md # interactive TTY only — never via MCP
|
|
|
306
310
|
speclaw laws lock --force # list drifted files, confirm, re-baseline — interactive TTY only
|
|
307
311
|
```
|
|
308
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
|
+
|
|
309
317
|
A lock refresh (`init`, `update`, `laws compile`, `laws lock`) never launders an
|
|
310
318
|
edit: a strict file (`CLAUDE.md`, `AGENTS.md`, compiled rules) that drifted from
|
|
311
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 {
|
|
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
|
-
|
|
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.
|
|
@@ -181,7 +181,8 @@ export async function runSpec(flags) {
|
|
|
181
181
|
change: changeName,
|
|
182
182
|
harnessOp: op,
|
|
183
183
|
verdict: verdict ?? null,
|
|
184
|
-
|
|
184
|
+
// Covers: req~harness-state~1
|
|
185
|
+
openQuestions: repeated(flags.question),
|
|
185
186
|
pauseForQuestions: Boolean(flags["pause-questions"]),
|
|
186
187
|
note: typeof flags.note === "string" ? flags.note : undefined,
|
|
187
188
|
});
|
|
@@ -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.
|
|
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 (
|
|
102
|
+
if (failed)
|
|
95
103
|
process.exit(1);
|
|
96
104
|
return;
|
|
97
105
|
}
|
|
@@ -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
|
+
}
|
|
@@ -254,6 +254,21 @@ const MIGRATIONS = [
|
|
|
254
254
|
"project's docs or scripts run `command` blindly, make them handle `null`.\n" +
|
|
255
255
|
"- Preserve all project-specific wording; only apply these speclaw-authored changes.",
|
|
256
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
|
+
},
|
|
257
272
|
];
|
|
258
273
|
/**
|
|
259
274
|
* 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";
|
|
@@ -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":
|
|
@@ -162,11 +166,11 @@ async function main() {
|
|
|
162
166
|
process.exitCode = 0;
|
|
163
167
|
return;
|
|
164
168
|
}
|
|
165
|
-
const flags = parseFlags(rest);
|
|
169
|
+
const flags = parseFlags(rest, REPEATABLE_FLAGS);
|
|
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/args.js
CHANGED
|
@@ -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
|
-
|
|
28
|
+
set(key, inlineVal);
|
|
15
29
|
}
|
|
16
30
|
else if (i + 1 < argv.length && !argv[i + 1].startsWith("-")) {
|
|
17
|
-
|
|
31
|
+
set(key, argv[++i]);
|
|
18
32
|
}
|
|
19
33
|
else {
|
|
20
|
-
|
|
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
|
*
|
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
|
{
|
|
@@ -312,7 +331,7 @@ Options
|
|
|
312
331
|
--change <name> The change (or pass it as the second argument)
|
|
313
332
|
--verdict <v> PASS or FAIL (advance from reviewing/testing)
|
|
314
333
|
--note <text> A history note
|
|
315
|
-
--question <text>
|
|
334
|
+
--question <text> One open question, commas included; repeat for more
|
|
316
335
|
--pause-questions Pause the loop for open questions
|
|
317
336
|
--json Print the result as JSON
|
|
318
337
|
`,
|
|
@@ -568,6 +568,67 @@ export function needsReindex(db) {
|
|
|
568
568
|
export function clearNeedsReindex(db) {
|
|
569
569
|
db.prepare("DELETE FROM meta WHERE key = 'needs_reindex'").run();
|
|
570
570
|
}
|
|
571
|
+
/** `meta` key set by a per-file reindex until the next full post-processing pass. */
|
|
572
|
+
const POST_PENDING_KEY = "post_pending";
|
|
573
|
+
/**
|
|
574
|
+
* Whether a per-file reindex left global post-processing (PageRank, the compact
|
|
575
|
+
* map, cache upkeep) for the next full run. While set, a full run may not take
|
|
576
|
+
* its no-op fast path.
|
|
577
|
+
*/
|
|
578
|
+
export function postPending(db) {
|
|
579
|
+
const row = db.prepare("SELECT value FROM meta WHERE key = ?").get(POST_PENDING_KEY);
|
|
580
|
+
return row?.value === "1";
|
|
581
|
+
}
|
|
582
|
+
/** Record that a per-file reindex wrote rows and deferred the global pass. */
|
|
583
|
+
export function setPostPending(db) {
|
|
584
|
+
db.prepare("INSERT INTO meta(key, value) VALUES (?, '1') ON CONFLICT(key) DO UPDATE SET value = excluded.value").run(POST_PENDING_KEY);
|
|
585
|
+
}
|
|
586
|
+
/** Clear the per-file marker once a full run has done the global pass. */
|
|
587
|
+
export function clearPostPending(db) {
|
|
588
|
+
db.prepare("DELETE FROM meta WHERE key = ?").run(POST_PENDING_KEY);
|
|
589
|
+
}
|
|
590
|
+
/**
|
|
591
|
+
* Whether an open index is current for a per-file write: this release's schema
|
|
592
|
+
* version and edge shape, not stale, and no full reindex pending. Callers that
|
|
593
|
+
* probe before taking the write lock re-check under it.
|
|
594
|
+
*/
|
|
595
|
+
export function isCurrentIndex(db) {
|
|
596
|
+
return (readSchemaVersion(db) === SCHEMA_VERSION && hasEdgeSpec(db) && !isStale(db) && !needsReindex(db));
|
|
597
|
+
}
|
|
598
|
+
/**
|
|
599
|
+
* Open an existing index for a per-file write only when it is current.
|
|
600
|
+
*
|
|
601
|
+
* Unlike {@link openDb}, this never creates the `.speclaw` directory or the
|
|
602
|
+
* database, and never migrates, wipes, or re-stamps a schema: a missing index,
|
|
603
|
+
* an old or stale schema, or a pending full reindex returns `null` after a
|
|
604
|
+
* read-only probe, so the caller writes nothing and leaves the repair to the
|
|
605
|
+
* next full run.
|
|
606
|
+
*
|
|
607
|
+
* @param projectPath - Absolute path to the project root.
|
|
608
|
+
* @returns A read-write connection (WAL, foreign keys, 5 s busy timeout), or `null`.
|
|
609
|
+
*/
|
|
610
|
+
export function openCurrentDb(projectPath) {
|
|
611
|
+
const file = indexPath(projectPath);
|
|
612
|
+
if (!fs.existsSync(file))
|
|
613
|
+
return null;
|
|
614
|
+
let current;
|
|
615
|
+
const probe = new DatabaseSync(file, { readOnly: true });
|
|
616
|
+
try {
|
|
617
|
+
probe.exec("PRAGMA busy_timeout = 5000;");
|
|
618
|
+
current = isCurrentIndex(probe);
|
|
619
|
+
}
|
|
620
|
+
catch {
|
|
621
|
+
current = false;
|
|
622
|
+
}
|
|
623
|
+
finally {
|
|
624
|
+
probe.close();
|
|
625
|
+
}
|
|
626
|
+
if (!current)
|
|
627
|
+
return null;
|
|
628
|
+
const db = new DatabaseSync(file);
|
|
629
|
+
db.exec("PRAGMA foreign_keys = ON; PRAGMA busy_timeout = 5000;");
|
|
630
|
+
return db;
|
|
631
|
+
}
|
|
571
632
|
/** Absolute path to the index database file for a project. */
|
|
572
633
|
export function indexPath(projectPath) {
|
|
573
634
|
return path.join(projectPath, ".speclaw", "index.db");
|