@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 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). 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
@@ -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 { list } from "../lib/args.js";
3
+ import { repeated } from "../lib/args.js";
4
4
  import { ui } from "../lib/ui.js";
5
5
  const OPS = ["status", "start", "advance", "rework", "brief"];
6
6
  /**
@@ -36,7 +36,8 @@ export async function runCortex(flags) {
36
36
  change: changeName,
37
37
  harnessOp: op,
38
38
  verdict: verdict ?? null,
39
- openQuestions: list(flags.question),
39
+ // Covers: req~harness-state~1
40
+ openQuestions: repeated(flags.question),
40
41
  pauseForQuestions: Boolean(flags["pause-questions"]),
41
42
  note: typeof flags.note === "string" ? flags.note : undefined,
42
43
  });
@@ -5,7 +5,7 @@ import { scaffoldFeature } from "../../modules/lawbook/scaffold-change.js";
5
5
  import { investigate, formatInvestigateResult } from "../../modules/lawbook/investigate.js";
6
6
  import { handleHarness } from "../../modules/cortex/harness.js";
7
7
  import { printHarnessWarnings } from "./cortex.js";
8
- import { list } from "../lib/args.js";
8
+ import { list, repeated } from "../lib/args.js";
9
9
  import { ui } from "../lib/ui.js";
10
10
  function today() {
11
11
  // The MCP path passes the date in; the CLL runs on a real machine, so read it here.
@@ -181,7 +181,8 @@ export async function runSpec(flags) {
181
181
  change: changeName,
182
182
  harnessOp: op,
183
183
  verdict: verdict ?? null,
184
- openQuestions: list(flags.question),
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.findings.length === 0) {
88
+ if (report.lockError !== undefined)
89
+ ui.err(report.lockError);
90
+ if (report.findings.length === 0 && report.lockError === undefined) {
83
91
  ui.ok("No injection findings.");
84
92
  return;
85
93
  }
@@ -91,7 +99,7 @@ export async function runLaws(flags) {
91
99
  else
92
100
  ui.warn(msg);
93
101
  }
94
- if (report.findings.some((f) => f.severity === "error"))
102
+ if (failed)
95
103
  process.exit(1);
96
104
  return;
97
105
  }
@@ -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` 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":
@@ -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 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
  }
@@ -1,23 +1,37 @@
1
1
  /**
2
- * Minimal flag parser: `--key value`, `--key=value`, `--bool`, `-x`.
2
+ * Minimal flag parser: `--key value`, `--key=value`, `--bool`, `-x`. A repeated
3
+ * key keeps its last value unless it is listed in `repeatable`.
3
4
  *
4
5
  * @param argv - Raw argument tokens (already stripped of the command name).
6
+ * @param repeatable - Keys whose every value is kept: such a key always yields a
7
+ * `string[]` in argv order, and its values are never split at commas.
5
8
  * @returns Flags with named options set and non-flag tokens collected in `_`.
6
9
  */
7
- export function parseFlags(argv) {
10
+ export function parseFlags(argv, repeatable = []) {
8
11
  const flags = { _: [] };
12
+ const set = (key, value) => {
13
+ const prev = flags[key];
14
+ if (repeatable.includes(key) && (typeof value === "string" || Array.isArray(prev))) {
15
+ // A bare repeatable flag after real values adds nothing and drops nothing.
16
+ if (typeof value === "string")
17
+ flags[key] = Array.isArray(prev) ? [...prev, value] : [value];
18
+ }
19
+ else {
20
+ flags[key] = value;
21
+ }
22
+ };
9
23
  for (let i = 0; i < argv.length; i++) {
10
24
  const arg = argv[i];
11
25
  if (arg.startsWith("--")) {
12
26
  const [key, inlineVal] = arg.slice(2).split("=", 2);
13
27
  if (inlineVal !== undefined) {
14
- flags[key] = inlineVal;
28
+ set(key, inlineVal);
15
29
  }
16
30
  else if (i + 1 < argv.length && !argv[i + 1].startsWith("-")) {
17
- flags[key] = argv[++i];
31
+ set(key, argv[++i]);
18
32
  }
19
33
  else {
20
- flags[key] = true;
34
+ set(key, true);
21
35
  }
22
36
  }
23
37
  else if (arg.startsWith("-")) {
@@ -29,6 +43,22 @@ export function parseFlags(argv) {
29
43
  }
30
44
  return flags;
31
45
  }
46
+ /** Flags that may be passed more than once; each occurrence is one value. */
47
+ export const REPEATABLE_FLAGS = ["question"];
48
+ /**
49
+ * The values of a repeatable flag parsed with {@link REPEATABLE_FLAGS}, as-is:
50
+ * in argv order and never split at commas.
51
+ *
52
+ * @param value - The parsed flag value.
53
+ * @returns The string values; empty when the flag is absent or a bare boolean.
54
+ */
55
+ export function repeated(value) {
56
+ if (Array.isArray(value))
57
+ return value;
58
+ if (typeof value === "string")
59
+ return [value];
60
+ return [];
61
+ }
32
62
  /**
33
63
  * Normalize a flag value into a list, splitting comma-separated strings.
34
64
  *
@@ -24,6 +24,7 @@ Setup
24
24
  Compass (code intelligence — the same surface agents use via MCP)
25
25
  index (Re)build the local code graph (--force / --prune / --json)
26
26
  session-start Silent, fail-safe refresh of an existing index (SessionStart hook)
27
+ 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> An open question (repeatable)
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");