@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.
@@ -56,8 +56,12 @@ automatically (a debounced incremental re-index on file change).
56
56
  Under Claude Code, a `SessionStart` hook refreshes an existing index when each
57
57
  session starts (`speclaw session-start`: silent, never fails, skips
58
58
  when `.speclaw/index.db` is absent, and leaves this file alone when nothing
59
- changed). The first build is always an explicit `compass_index` /
60
- `speclaw index`.
59
+ changed). A `PostToolUse` hook re-indexes each file the agent writes or edits
60
+ right after the edit (`speclaw reindex-file`: detached, silent, never fails);
61
+ PageRank and this file's map catch up on the next full run. Files created or
62
+ renamed by shell commands, and edits by agents without hooks, are picked up at
63
+ the next session start, `compass_index`, or `speclaw watch`. The first build is
64
+ always an explicit `compass_index` / `speclaw index`.
61
65
 
62
66
  If the graph is missing (no `.speclaw/index.db`), run `compass_index` first —
63
67
  a missing graph is not license to skip Compass. The only legitimate fallbacks
@@ -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) {
@@ -27,11 +27,38 @@ const LEGACY_SESSION_START_MARKER = "speclaw index --session-start";
27
27
  * Output is discarded because `SessionStart` stdout enters the agent's context,
28
28
  * and `|| true` keeps the exit code 0 in every case.
29
29
  */
30
- export const SESSION_START_COMMAND = 'cd "${CLAUDE_PROJECT_DIR:-.}" 2>/dev/null && [ -f .speclaw/index.db ] && { ' +
31
- "if [ -x node_modules/.bin/speclaw ]; then node_modules/.bin/speclaw session-start; " +
32
- "elif command -v speclaw >/dev/null 2>&1; then speclaw session-start; " +
33
- "else npm_config_update_notifier=false npm_config_offline=true npx --no-install @esneiderbravo/speclaw session-start; fi; " +
34
- "} >/dev/null 2>&1 || true";
30
+ export const SESSION_START_COMMAND = speclawCommand("session-start");
31
+ /**
32
+ * Substring every edit-reindex command contains, in each of its resolution
33
+ * branches. It is the merge identity of the `PostToolUse` reindex hook.
34
+ */
35
+ export const REINDEX_FILE_MARKER = "speclaw reindex-file";
36
+ /**
37
+ * The POSIX `sh` command the edit `PostToolUse` hook runs: the session-start
38
+ * command with `reindex-file` as the subcommand, so it keeps the same index
39
+ * guard, resolution order, offline npx, discarded output, and exit 0. The hook
40
+ * payload on stdin passes through `sh` to speclaw unchanged. A speclaw older
41
+ * than the command rejects it as unknown (exit 1) before touching the index.
42
+ */
43
+ export const REINDEX_FILE_COMMAND = speclawCommand("reindex-file");
44
+ /** Seconds the edit hook may run; the command returns once it has spawned the work. */
45
+ const REINDEX_TIMEOUT_SECONDS = 10;
46
+ /**
47
+ * Build a guarded, fail-safe speclaw hook command for one top-level subcommand
48
+ * (see {@link SESSION_START_COMMAND} for the shape). The only difference
49
+ * between the hook commands is the subcommand.
50
+ *
51
+ * @param sub - The top-level speclaw command the hook runs.
52
+ * @returns The POSIX `sh` command string.
53
+ */
54
+ export function speclawCommand(sub) {
55
+ // 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}; ` +
58
+ `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");
61
+ }
35
62
  /** `SessionStart` matcher covering every session source Claude Code reports. */
36
63
  const SESSION_START_MATCHER = "startup|resume|clear|compact";
37
64
  /** Seconds the blocking session-start hook may run (index wait plus a real refresh). */
@@ -72,8 +99,8 @@ export const NUDGE_MATCHER = "Read|Grep|Glob";
72
99
  /**
73
100
  * True when a hook object is one speclaw owns (safe to replace on merge): an
74
101
  * `mcp_tool` hook on the `speclaw` server, or a `command` hook whose command
75
- * contains {@link SESSION_START_MARKER} (or the pre-release
76
- * `index --session-start` marker). A user `command` hook without the
102
+ * contains {@link SESSION_START_MARKER}, {@link REINDEX_FILE_MARKER}, or the
103
+ * pre-release `index --session-start` marker. A user `command` hook without a
77
104
  * marker is never speclaw's.
78
105
  *
79
106
  * @param h - A hook object read from an agent's settings (any shape).
@@ -85,7 +112,9 @@ export function isSpeclawHook(h) {
85
112
  return true;
86
113
  return (o?.type === "command" &&
87
114
  typeof o?.command === "string" &&
88
- (o.command.includes(SESSION_START_MARKER) || o.command.includes(LEGACY_SESSION_START_MARKER)));
115
+ (o.command.includes(SESSION_START_MARKER) ||
116
+ o.command.includes(REINDEX_FILE_MARKER) ||
117
+ o.command.includes(LEGACY_SESSION_START_MARKER)));
89
118
  }
90
119
  /**
91
120
  * Compile a law manifest into the hook groups speclaw contributes, one per event
@@ -93,8 +122,10 @@ export function isSpeclawHook(h) {
93
122
  * `feedback`, `Stop` for `gate`, and `InstructionsLoaded` whenever any law exists
94
123
  * (the context-coverage audit). Two groups are always emitted, even with no
95
124
  * laws: one `PostToolUse` group matching `Read|Grep|Glob` for the Compass-first
96
- * nudge, and one `SessionStart` group whose `command` hook refreshes the index
97
- * silently when a session starts. A law whose scope contains a malformed glob is
125
+ * nudge, a separate `PostToolUse` group matching the mutating tools whose
126
+ * `command` hook re-indexes the edited file in the background, and one
127
+ * `SessionStart` group whose `command` hook refreshes the index silently when a
128
+ * session starts. A law whose scope contains a malformed glob is
98
129
  * excluded and reported, so a bad pattern fails loudly at generation rather than
99
130
  * silently matching nothing at runtime.
100
131
  *
@@ -121,6 +152,13 @@ export function compileHooks(manifest) {
121
152
  byEvent.PostToolUse = [{ matcher: NUDGE_MATCHER, hooks: [{ ...SPECLAW_HOOK }] }];
122
153
  if (hasFeedback)
123
154
  byEvent.PostToolUse.unshift({ matcher: MUTATION_MATCHER, hooks: [{ ...SPECLAW_HOOK }] });
155
+ // Covers: req~edit-reindex-hook~1
156
+ // Its own group, never folded into the feedback `mcp_tool` group, and only
157
+ // the keys the agent's hook schema defines (no `async`).
158
+ byEvent.PostToolUse.push({
159
+ matcher: MUTATION_MATCHER,
160
+ hooks: [{ type: "command", command: REINDEX_FILE_COMMAND, timeout: REINDEX_TIMEOUT_SECONDS }],
161
+ });
124
162
  if (hasGate)
125
163
  byEvent.Stop = [{ hooks: [{ ...SPECLAW_HOOK }] }];
126
164
  if (valid.length > 0)
@@ -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) {
@@ -1,4 +1,5 @@
1
1
  import { fileURLToPath } from "node:url";
2
+ import fs from "node:fs";
2
3
  import path from "node:path";
3
4
  /**
4
5
  * The `assets/` directory that sits next to a compiled module file. Each module
@@ -8,3 +9,26 @@ import path from "node:path";
8
9
  export function assetsDir(importMetaUrl) {
9
10
  return path.join(path.dirname(fileURLToPath(importMetaUrl)), "assets");
10
11
  }
12
+ /**
13
+ * `p` with symlinks resolved in its longest existing prefix (a deleted file or
14
+ * directory keeps its missing tail), so `/var/…` and `/private/var/…` spellings
15
+ * of one path compare equal even after the file is gone.
16
+ *
17
+ * @param p - An absolute path.
18
+ */
19
+ export function realPathOf(p) {
20
+ const tail = [];
21
+ let cur = path.resolve(p);
22
+ for (;;) {
23
+ try {
24
+ return path.join(fs.realpathSync(cur), ...tail.reverse());
25
+ }
26
+ catch {
27
+ const parent = path.dirname(cur);
28
+ if (parent === cur)
29
+ return path.resolve(p);
30
+ tail.push(path.basename(cur));
31
+ cur = parent;
32
+ }
33
+ }
34
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@esneiderbravo/speclaw",
3
- "version": "2.0.10",
3
+ "version": "2.0.12",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },