@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 (
|
|
182
|
+
if (["session-start", "reindex-file", "ship-on-stop", "measure-diff"].includes(cmd ?? ""))
|
|
181
183
|
return;
|
|
182
184
|
await maybeNotifyUpdate(cmd);
|
|
183
185
|
}
|
package/dist/cli/lib/help.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
}
|
|
556
|
-
|
|
557
|
-
|
|
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
|
-
|
|
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`).
|