@esneiderbravo/speclaw 2.0.20 → 2.0.21

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
  }
@@ -1,6 +1,7 @@
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";
6
7
  import { handleHarness, readHarness } from "../cortex/harness.js";
@@ -211,10 +212,70 @@ function isVersionBump(projectPath, base, file) {
211
212
  * manifests is not a global change, so it does not raise the level.
212
213
  */
213
214
  function measureDiff(projectPath, files) {
215
+ const cached = cachedMeasure(projectPath, files);
216
+ if (cached)
217
+ return cached;
214
218
  const base = mergeBase(projectPath, "main") ?? mergeBase(projectPath, "master");
215
219
  const paths = files.filter((f) => !isVersionBump(projectPath, base, f));
216
220
  const { thresholds } = loadCeremonyConfig(projectPath);
217
- return proposeLevel(gatherSignals(projectPath, { paths, symbols: [] }, thresholds), thresholds);
221
+ const proposal = proposeLevel(gatherSignals(projectPath, { paths, symbols: [] }, thresholds), thresholds);
222
+ try {
223
+ const file = path.join(projectPath, LEVEL_CACHE);
224
+ fs.mkdirSync(path.dirname(file), { recursive: true });
225
+ fs.writeFileSync(file, JSON.stringify({ key: fileSetKey(files), proposal }) + "\n");
226
+ }
227
+ catch {
228
+ // The cache only saves time; the measurement stands without it.
229
+ }
230
+ return proposal;
231
+ }
232
+ /**
233
+ * The last measurement, kept per changed-file set: measuring a large diff
234
+ * (blast radius, affected tests) takes seconds, so the edit hook measures in
235
+ * the background and ship reuses the result at the stop.
236
+ */
237
+ const LEVEL_CACHE = path.join(".speclaw", "level-cache.json");
238
+ function fileSetKey(files) {
239
+ return createHash("sha256").update(files.join("\n")).digest("hex");
240
+ }
241
+ /** The cached proposal for exactly this file set, or null. */
242
+ function cachedMeasure(projectPath, files) {
243
+ try {
244
+ const c = JSON.parse(fs.readFileSync(path.join(projectPath, LEVEL_CACHE), "utf8"));
245
+ return c.key === fileSetKey(files) && c.proposal ? c.proposal : null;
246
+ }
247
+ catch {
248
+ return null;
249
+ }
250
+ }
251
+ /**
252
+ * Measure the branch's current diff into the level cache (`speclaw
253
+ * measure-diff`, the edit hook's detached background job).
254
+ *
255
+ * @param projectPath - Project root.
256
+ */
257
+ export function measureBranchDiff(projectPath) {
258
+ const files = branchFiles(projectPath);
259
+ if (files.length)
260
+ measureDiff(projectPath, files);
261
+ }
262
+ /** The CLI entry beside this module, run by the background measurement. */
263
+ const CLI_ENTRY = fileURLToPath(new URL("../../cli/index.js", import.meta.url));
264
+ /** Start `speclaw measure-diff` in its own process group; never waits, never throws. */
265
+ function measureInBackground(projectPath) {
266
+ try {
267
+ const child = spawn(process.execPath, [CLI_ENTRY, "measure-diff"], {
268
+ cwd: projectPath,
269
+ detached: true,
270
+ stdio: "ignore",
271
+ windowsHide: true,
272
+ });
273
+ child.on("error", () => { });
274
+ child.unref();
275
+ }
276
+ catch {
277
+ // The stop measures anyway; a lost background job only delays the hint.
278
+ }
218
279
  }
219
280
  /**
220
281
  * Create the change at the level its diff measures: level 0 gets a record,
@@ -545,16 +606,31 @@ export function docHint(projectPath) {
545
606
  catch {
546
607
  // First hint on this checkout.
547
608
  }
548
- if (prev.quick === quick)
609
+ const save = (next) => {
610
+ fs.mkdirSync(path.dirname(statePath), { recursive: true });
611
+ fs.writeFileSync(statePath, JSON.stringify(next) + "\n");
612
+ };
613
+ if (prev.quick === quick && !prev.pending)
549
614
  return null;
615
+ // A measurement still running for this same worktree: nothing new to say yet,
616
+ // and listing the branch's files would cost more git calls than the answer.
617
+ if (prev.quick === quick && prev.pending && !measuredSince(projectPath, prev.pending.at)) {
618
+ if (Date.now() - prev.pending.at < MEASURE_GRACE_MS)
619
+ return null;
620
+ }
550
621
  const files = branchFiles(projectPath);
551
622
  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({});
623
+ if (files.length === 0 || (prev.key === key && !prev.pending)) {
624
+ save({ ...prev, quick, key });
625
+ return null;
626
+ }
627
+ // Measuring a large diff takes seconds — longer than the hook may run —
628
+ // so it happens in the background and the hint follows on a later call.
629
+ if (!cachedMeasure(projectPath, files)) {
630
+ const fresh = prev.pending?.key === key && Date.now() - prev.pending.at < MEASURE_GRACE_MS;
631
+ if (!fresh)
632
+ measureInBackground(projectPath);
633
+ save({ ...prev, quick, pending: fresh ? prev.pending : { key, at: Date.now() } });
558
634
  return null;
559
635
  }
560
636
  const change = branchChange(projectPath, branch);
@@ -570,10 +646,15 @@ export function docHint(projectPath) {
570
646
  scaffoldMeasured(projectPath, change, files, proposal);
571
647
  }
572
648
  const level = fs.existsSync(changeDir) ? confirmedLevel(projectPath, change) : 0;
573
- remember({ change, level });
574
649
  const pending = level > 0 ? pendingArtifacts(projectPath, change) : [];
575
- if (pending.length === 0 || (prev.change === change && prev.level === level))
650
+ const told = prev.told?.change === change && prev.told.level === level;
651
+ if (pending.length === 0 || told) {
652
+ save({ quick, key, ...(prev.told ? { told: prev.told } : {}) });
576
653
  return null;
654
+ }
655
+ // "Told" is recorded only with the hint actually returned, so a level is
656
+ // never marked as told by a call that said nothing.
657
+ save({ quick, key, told: { change, level } });
577
658
  const why = readCeremonyRecord(projectPath, change)?.rationale ?? "";
578
659
  return (`speclaw: this change measures level ${level}${why ? ` (${why})` : ""}. ` +
579
660
  `In this same turn, before you stop, write briefly and truthfully:\n- ${pending.join("\n- ")}\n` +
@@ -583,6 +664,17 @@ export function docHint(projectPath) {
583
664
  return null;
584
665
  }
585
666
  }
667
+ /** Whether the level cache was written at or after `at` (epoch ms). */
668
+ function measuredSince(projectPath, at) {
669
+ try {
670
+ return fs.statSync(path.join(projectPath, LEVEL_CACHE)).mtimeMs >= at;
671
+ }
672
+ catch {
673
+ return false;
674
+ }
675
+ }
676
+ /** How long a started background measurement is awaited before it is started again. */
677
+ const MEASURE_GRACE_MS = 2 * 60 * 1000;
586
678
  /**
587
679
  * Change name for a branch: the part after the last `/`, kebab-cased
588
680
  * (`fix/compass-query-output` → `compass-query-output`).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@esneiderbravo/speclaw",
3
- "version": "2.0.20",
3
+ "version": "2.0.21",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },