@esneiderbravo/speclaw 2.0.20 → 2.0.22

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.
@@ -1,4 +1,4 @@
1
- import { shipOnStop } from "../../modules/lawbook/ship.js";
1
+ import { measureBranchDiff, shipOnStop } from "../../modules/lawbook/ship.js";
2
2
  /**
3
3
  * `speclaw ship-on-stop`: the Claude Code `Stop` hook. Ships the branch's
4
4
  * change when the work changed since the last ship; prints nothing on stdout.
@@ -28,6 +28,21 @@ export async function runShipOnStop(cwd = process.cwd()) {
28
28
  // A hook must never break the session.
29
29
  }
30
30
  }
31
+ /**
32
+ * `speclaw measure-diff`: the edit hook's detached background job. Measures the
33
+ * branch diff's ceremony level into `.speclaw/level-cache.json` so the next
34
+ * hook call and the stop reuse it. Silent and fail-safe.
35
+ *
36
+ * @param cwd - Project root (defaults to the process working directory).
37
+ */
38
+ export async function runMeasureDiff(cwd = process.cwd()) {
39
+ try {
40
+ measureBranchDiff(cwd);
41
+ }
42
+ catch {
43
+ // A background job must never surface anywhere.
44
+ }
45
+ }
31
46
  /** Whether this stop was already blocked by the hook once (`stop_hook_active`). */
32
47
  async function stopHookActive() {
33
48
  try {
package/dist/cli/index.js CHANGED
@@ -131,6 +131,8 @@ async function dispatch(cmd, flags) {
131
131
  return (await import("./commands/ship.js")).runShip(flags);
132
132
  case "ship-on-stop":
133
133
  return (await import("./commands/ship-on-stop.js")).runShipOnStop();
134
+ case "measure-diff":
135
+ return (await import("./commands/ship-on-stop.js")).runMeasureDiff();
134
136
  case "cortex":
135
137
  return (await import("./commands/cortex.js")).runCortex(flags);
136
138
  case "lawbook":
@@ -177,7 +179,7 @@ async function main() {
177
179
  await maybeHeader(cmd, flags);
178
180
  await dispatch(cmd, flags);
179
181
  // The SessionStart and edit hooks must stay silent: no update notice either.
180
- if (cmd === "session-start" || cmd === "reindex-file" || cmd === "ship-on-stop")
182
+ if (["session-start", "reindex-file", "ship-on-stop", "measure-diff"].includes(cmd ?? ""))
181
183
  return;
182
184
  await maybeNotifyUpdate(cmd);
183
185
  }
@@ -25,6 +25,7 @@ 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
27
  ship-on-stop Ship the branch's change when work changed (Stop hook)
28
+ measure-diff Measure the branch diff's level into the cache (edit hook job)
28
29
  reindex-file [paths...] Silent re-index of edited files (PostToolUse hook; stdin JSON)
29
30
  watch Keep the index fresh on file changes
30
31
  explore <node> A node's source + callers/callees
@@ -176,6 +177,16 @@ Keep the Compass index fresh: re-index changed files until interrupted (Ctrl-C).
176
177
  The Claude Code Stop hook. On a feature branch whose work changed since the
177
178
  last ship, runs \`speclaw ship\` for the change named after the branch. Prints
178
179
  nothing on stdout; a failing gate goes to stderr with exit 2 once.
180
+ `,
181
+ },
182
+ {
183
+ name: "measure-diff",
184
+ usage: `Usage: speclaw measure-diff
185
+
186
+ Measure the branch diff's ceremony level into .speclaw/level-cache.json. The
187
+ edit hook starts it as a detached background job when the changed files change,
188
+ so the hook never waits on a slow measurement; ship reuses the cached result.
189
+ Silent and fail-safe.
179
190
  `,
180
191
  },
181
192
  {
@@ -55,9 +55,11 @@ export function bashCodeRead(projectPath, command) {
55
55
  continue;
56
56
  }
57
57
  const where = existing.find((a) => isDirectory(projectPath, a) === true || indexedExt(path.extname(a) || "."));
58
+ // Shell quoting and escapes are not part of the searched text.
59
+ const pattern = args[0]?.replace(/^[\\'"]+|[\\'"]+$/g, "") || undefined;
58
60
  // No path argument: a search of the working directory, i.e. the repo.
59
61
  if (where || existing.length === 0)
60
- return { tool: "Grep", raw: where ?? ".", pattern: args[0] };
62
+ return { tool: "Grep", raw: where ?? ".", pattern };
61
63
  }
62
64
  return null;
63
65
  }
@@ -8,6 +8,7 @@ import crypto from "node:crypto";
8
8
  import fs from "node:fs";
9
9
  import path from "node:path";
10
10
  import { pkgName, pkgVersion } from "../../shared/version.js";
11
+ import { COMPASS_MAP_END, COMPASS_MAP_START, stripCompassMapBlock, } from "../../shared/compass-map.js";
11
12
  export const LOCKFILE_NAME = "speclaw.lock";
12
13
  export const LOCKFILE_VERSION = 1;
13
14
  /** Delimited provenance block excluded from digests (self-reference). */
@@ -270,23 +271,9 @@ function walkFiles(dir, projectPath, out, pred) {
270
271
  }
271
272
  }
272
273
  }
273
- /** Markers for the regenerable map block inside `docs/compass.md` (see compass/map.ts). */
274
- export const COMPASS_MAP_START = "<!-- speclaw:map:start -->";
275
- export const COMPASS_MAP_END = "<!-- speclaw:map:end -->";
276
- /**
277
- * Strip the regenerable map body between markers so integrity digests stay stable
278
- * across `speclaw index` (which rewrites the map in CI before verify).
279
- *
280
- * @param text - Full docs/compass.md contents.
281
- * @returns The same text with an empty map body, or `text` if markers are missing.
282
- */
283
- export function stripCompassMapBlock(text) {
284
- const start = text.indexOf(COMPASS_MAP_START);
285
- const end = text.indexOf(COMPASS_MAP_END);
286
- if (start < 0 || end < 0 || end < start)
287
- return text;
288
- return text.slice(0, start + COMPASS_MAP_START.length) + "\n" + text.slice(end);
289
- }
274
+ // The map block helpers moved to shared/ so the lawbook can ignore the
275
+ // regenerated map too; re-exported here for existing importers.
276
+ export { COMPASS_MAP_END, COMPASS_MAP_START, stripCompassMapBlock };
290
277
  /**
291
278
  * Path-specific bytes that feed {@link digestText}: speclaw-owned coderabbit
292
279
  * region, regenerable Compass map body stripped, otherwise the file as-is.
@@ -1,8 +1,10 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
- import { spawnSync } from "node:child_process";
3
+ import { spawn, spawnSync } from "node:child_process";
4
+ import { fileURLToPath } from "node:url";
4
5
  import { createHash } from "node:crypto";
5
6
  import { changedFiles, isGitRepo, mergeBase, worktreeChangedFiles } from "../../shared/git.js";
7
+ import { COMPASS_DOC, stripCompassMapBlock } from "../../shared/compass-map.js";
6
8
  import { handleHarness, readHarness } from "../cortex/harness.js";
7
9
  import { deltaSpecFiles, specArchive, specArchivePreconditions } from "./engine.js";
8
10
  import { artifactNeeds, confirmedLevel, gatherSignals, loadCeremonyConfig, promoteCeremonyLevel, proposeLevel, readCeremonyRecord, readChangeType, } from "./levels.js";
@@ -88,8 +90,28 @@ function branchFiles(projectPath) {
88
90
  ]),
89
91
  ]
90
92
  .filter((f) => !f.startsWith("lawbook/changes/"))
93
+ .filter((f) => f !== COMPASS_DOC || !onlyMapChanged(projectPath, base))
91
94
  .sort();
92
95
  }
96
+ /**
97
+ * Whether `docs/compass.md` differs from `base` only inside its generated map
98
+ * block: every full index rewrites that block, so on a fresh branch it would
99
+ * otherwise read as the agent's work and ship an empty change.
100
+ */
101
+ function onlyMapChanged(projectPath, base) {
102
+ if (!base)
103
+ return false;
104
+ try {
105
+ const now = fs.readFileSync(path.join(projectPath, COMPASS_DOC), "utf8");
106
+ const then = git(projectPath, ["show", `${base}:${COMPASS_DOC}`]);
107
+ // Line endings are the platform's (core.autocrlf), not the agent's work.
108
+ const lf = (t) => stripCompassMapBlock(t.replace(/\r\n/g, "\n"));
109
+ return then !== "" && lf(now) === lf(then);
110
+ }
111
+ catch {
112
+ return false;
113
+ }
114
+ }
93
115
  /** New files not yet added, outside ship's own output (`lawbook/changes/`, `.speclaw/`). */
94
116
  function untrackedFiles(projectPath) {
95
117
  return git(projectPath, [
@@ -211,10 +233,70 @@ function isVersionBump(projectPath, base, file) {
211
233
  * manifests is not a global change, so it does not raise the level.
212
234
  */
213
235
  function measureDiff(projectPath, files) {
236
+ const cached = cachedMeasure(projectPath, files);
237
+ if (cached)
238
+ return cached;
214
239
  const base = mergeBase(projectPath, "main") ?? mergeBase(projectPath, "master");
215
240
  const paths = files.filter((f) => !isVersionBump(projectPath, base, f));
216
241
  const { thresholds } = loadCeremonyConfig(projectPath);
217
- return proposeLevel(gatherSignals(projectPath, { paths, symbols: [] }, thresholds), thresholds);
242
+ const proposal = proposeLevel(gatherSignals(projectPath, { paths, symbols: [] }, thresholds), thresholds);
243
+ try {
244
+ const file = path.join(projectPath, LEVEL_CACHE);
245
+ fs.mkdirSync(path.dirname(file), { recursive: true });
246
+ fs.writeFileSync(file, JSON.stringify({ key: fileSetKey(files), proposal }) + "\n");
247
+ }
248
+ catch {
249
+ // The cache only saves time; the measurement stands without it.
250
+ }
251
+ return proposal;
252
+ }
253
+ /**
254
+ * The last measurement, kept per changed-file set: measuring a large diff
255
+ * (blast radius, affected tests) takes seconds, so the edit hook measures in
256
+ * the background and ship reuses the result at the stop.
257
+ */
258
+ const LEVEL_CACHE = path.join(".speclaw", "level-cache.json");
259
+ function fileSetKey(files) {
260
+ return createHash("sha256").update(files.join("\n")).digest("hex");
261
+ }
262
+ /** The cached proposal for exactly this file set, or null. */
263
+ function cachedMeasure(projectPath, files) {
264
+ try {
265
+ const c = JSON.parse(fs.readFileSync(path.join(projectPath, LEVEL_CACHE), "utf8"));
266
+ return c.key === fileSetKey(files) && c.proposal ? c.proposal : null;
267
+ }
268
+ catch {
269
+ return null;
270
+ }
271
+ }
272
+ /**
273
+ * Measure the branch's current diff into the level cache (`speclaw
274
+ * measure-diff`, the edit hook's detached background job).
275
+ *
276
+ * @param projectPath - Project root.
277
+ */
278
+ export function measureBranchDiff(projectPath) {
279
+ const files = branchFiles(projectPath);
280
+ if (files.length)
281
+ measureDiff(projectPath, files);
282
+ }
283
+ /** The CLI entry beside this module, run by the background measurement. */
284
+ const CLI_ENTRY = fileURLToPath(new URL("../../cli/index.js", import.meta.url));
285
+ /** Start `speclaw measure-diff` in its own process group; never waits, never throws. */
286
+ function measureInBackground(projectPath) {
287
+ try {
288
+ const child = spawn(process.execPath, [CLI_ENTRY, "measure-diff"], {
289
+ cwd: projectPath,
290
+ detached: true,
291
+ stdio: "ignore",
292
+ windowsHide: true,
293
+ });
294
+ child.on("error", () => { });
295
+ child.unref();
296
+ }
297
+ catch {
298
+ // The stop measures anyway; a lost background job only delays the hint.
299
+ }
218
300
  }
219
301
  /**
220
302
  * Create the change at the level its diff measures: level 0 gets a record,
@@ -545,16 +627,31 @@ export function docHint(projectPath) {
545
627
  catch {
546
628
  // First hint on this checkout.
547
629
  }
548
- if (prev.quick === quick)
630
+ const save = (next) => {
631
+ fs.mkdirSync(path.dirname(statePath), { recursive: true });
632
+ fs.writeFileSync(statePath, JSON.stringify(next) + "\n");
633
+ };
634
+ if (prev.quick === quick && !prev.pending)
549
635
  return null;
636
+ // A measurement still running for this same worktree: nothing new to say yet,
637
+ // and listing the branch's files would cost more git calls than the answer.
638
+ if (prev.quick === quick && prev.pending && !measuredSince(projectPath, prev.pending.at)) {
639
+ if (Date.now() - prev.pending.at < MEASURE_GRACE_MS)
640
+ return null;
641
+ }
550
642
  const files = branchFiles(projectPath);
551
643
  const key = `${branch}\n${files.join("\n")}`;
552
- const remember = (extra) => {
553
- fs.mkdirSync(path.dirname(statePath), { recursive: true });
554
- fs.writeFileSync(statePath, JSON.stringify({ ...prev, quick, key, ...extra }) + "\n");
555
- };
556
- if (files.length === 0 || prev.key === key) {
557
- remember({});
644
+ if (files.length === 0 || (prev.key === key && !prev.pending)) {
645
+ save({ ...prev, quick, key });
646
+ return null;
647
+ }
648
+ // Measuring a large diff takes seconds — longer than the hook may run —
649
+ // so it happens in the background and the hint follows on a later call.
650
+ if (!cachedMeasure(projectPath, files)) {
651
+ const fresh = prev.pending?.key === key && Date.now() - prev.pending.at < MEASURE_GRACE_MS;
652
+ if (!fresh)
653
+ measureInBackground(projectPath);
654
+ save({ ...prev, quick, pending: fresh ? prev.pending : { key, at: Date.now() } });
558
655
  return null;
559
656
  }
560
657
  const change = branchChange(projectPath, branch);
@@ -570,10 +667,15 @@ export function docHint(projectPath) {
570
667
  scaffoldMeasured(projectPath, change, files, proposal);
571
668
  }
572
669
  const level = fs.existsSync(changeDir) ? confirmedLevel(projectPath, change) : 0;
573
- remember({ change, level });
574
670
  const pending = level > 0 ? pendingArtifacts(projectPath, change) : [];
575
- if (pending.length === 0 || (prev.change === change && prev.level === level))
671
+ const told = prev.told?.change === change && prev.told.level === level;
672
+ if (pending.length === 0 || told) {
673
+ save({ quick, key, ...(prev.told ? { told: prev.told } : {}) });
576
674
  return null;
675
+ }
676
+ // "Told" is recorded only with the hint actually returned, so a level is
677
+ // never marked as told by a call that said nothing.
678
+ save({ quick, key, told: { change, level } });
577
679
  const why = readCeremonyRecord(projectPath, change)?.rationale ?? "";
578
680
  return (`speclaw: this change measures level ${level}${why ? ` (${why})` : ""}. ` +
579
681
  `In this same turn, before you stop, write briefly and truthfully:\n- ${pending.join("\n- ")}\n` +
@@ -583,6 +685,17 @@ export function docHint(projectPath) {
583
685
  return null;
584
686
  }
585
687
  }
688
+ /** Whether the level cache was written at or after `at` (epoch ms). */
689
+ function measuredSince(projectPath, at) {
690
+ try {
691
+ return fs.statSync(path.join(projectPath, LEVEL_CACHE)).mtimeMs >= at;
692
+ }
693
+ catch {
694
+ return false;
695
+ }
696
+ }
697
+ /** How long a started background measurement is awaited before it is started again. */
698
+ const MEASURE_GRACE_MS = 2 * 60 * 1000;
586
699
  /**
587
700
  * Change name for a branch: the part after the last `/`, kebab-cased
588
701
  * (`fix/compass-query-output` → `compass-query-output`).
@@ -656,12 +769,18 @@ function workFingerprint(projectPath) {
656
769
  ":(exclude).speclaw",
657
770
  ]).split("\n");
658
771
  const hash = createHash("sha256");
659
- for (const f of [...new Set([...tracked, ...untrackedFiles(projectPath)])].sort()) {
772
+ const files = [...new Set([...tracked, ...untrackedFiles(projectPath)])]
773
+ // A map-only docs/compass.md is no work: neither its bytes nor its name count.
774
+ .filter((f) => f !== COMPASS_DOC || !onlyMapChanged(projectPath, base))
775
+ .sort();
776
+ for (const f of files) {
660
777
  if (!f)
661
778
  continue;
662
779
  hash.update(`${f}\n`);
663
780
  try {
664
- hash.update(fs.readFileSync(path.join(projectPath, f)));
781
+ const bytes = fs.readFileSync(path.join(projectPath, f));
782
+ // A re-index rewrites the map block; only the rest of the file is work.
783
+ hash.update(f === COMPASS_DOC ? stripCompassMapBlock(bytes.toString("utf8")) : bytes);
665
784
  }
666
785
  catch {
667
786
  // Deleted, vanished or unreadable: its name stands in.
@@ -0,0 +1,20 @@
1
+ /** Markers for the regenerable map block inside `docs/compass.md` (see compass/map.ts). */
2
+ export const COMPASS_MAP_START = "<!-- speclaw:map:start -->";
3
+ export const COMPASS_MAP_END = "<!-- speclaw:map:end -->";
4
+ /** The project-relative path of the Compass guide that carries the generated map. */
5
+ export const COMPASS_DOC = "docs/compass.md";
6
+ /**
7
+ * Strip the regenerable map body between markers. `speclaw index` rewrites it on
8
+ * every full run, so integrity digests and "did the agent change this file?"
9
+ * checks must both ignore it.
10
+ *
11
+ * @param text - Full docs/compass.md contents.
12
+ * @returns The same text with an empty map body, or `text` if markers are missing.
13
+ */
14
+ export function stripCompassMapBlock(text) {
15
+ const start = text.indexOf(COMPASS_MAP_START);
16
+ const end = text.indexOf(COMPASS_MAP_END);
17
+ if (start < 0 || end < 0 || end < start)
18
+ return text;
19
+ return text.slice(0, start + COMPASS_MAP_START.length) + "\n" + text.slice(end);
20
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@esneiderbravo/speclaw",
3
- "version": "2.0.20",
3
+ "version": "2.0.22",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },