@esneiderbravo/speclaw 2.0.18 → 2.0.20

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.
Files changed (32) hide show
  1. package/dist/cli/commands/ship-on-stop.js +6 -1
  2. package/dist/cli/commands/ship.js +2 -1
  3. package/dist/cli/commands/update.js +1 -1
  4. package/dist/cli/index.js +6 -4
  5. package/dist/cli/lib/ui.js +3 -0
  6. package/dist/modules/compass/explore-rich.js +72 -0
  7. package/dist/modules/compass/indexer.js +2 -0
  8. package/dist/modules/compass/register.js +10 -8
  9. package/dist/modules/cortex/register.js +1 -1
  10. package/dist/modules/foundation/assets/AGENTS.template.md +6 -3
  11. package/dist/modules/foundation/assets/CLAUDE.template.md +6 -3
  12. package/dist/modules/foundation/check.js +24 -1
  13. package/dist/modules/foundation/compass-nudge.js +69 -3
  14. package/dist/modules/foundation/doctor.js +1 -1
  15. package/dist/modules/foundation/hooks.js +20 -7
  16. package/dist/modules/foundation/register-core.js +12 -3
  17. package/dist/modules/lawbook/assets/skills/cortex/SKILL.md +11 -11
  18. package/dist/modules/lawbook/assets/skills/quick/steps/02-implement.md +2 -1
  19. package/dist/modules/lawbook/assets/skills/test/steps/01-quality-gates.md +3 -1
  20. package/dist/modules/lawbook/engine.js +2 -2
  21. package/dist/modules/lawbook/investigate.js +85 -4
  22. package/dist/modules/lawbook/levels.js +9 -3
  23. package/dist/modules/lawbook/quick.js +5 -2
  24. package/dist/modules/lawbook/register.js +7 -5
  25. package/dist/modules/lawbook/scaffold-change.js +17 -11
  26. package/dist/modules/lawbook/ship.js +369 -36
  27. package/dist/modules/lawbook/stack-parse.js +21 -5
  28. package/dist/server.js +15 -1
  29. package/dist/shared/exposure.js +4 -7
  30. package/dist/shared/index-stats.js +40 -0
  31. package/dist/shared/mcp.js +3 -2
  32. package/package.json +1 -1
@@ -4,9 +4,10 @@ import { spawnSync } from "node:child_process";
4
4
  import { createHash } from "node:crypto";
5
5
  import { changedFiles, isGitRepo, mergeBase, worktreeChangedFiles } from "../../shared/git.js";
6
6
  import { handleHarness, readHarness } from "../cortex/harness.js";
7
- import { specArchive, specArchivePreconditions } from "./engine.js";
8
- import { confirmedLevel } from "./levels.js";
7
+ import { deltaSpecFiles, specArchive, specArchivePreconditions } from "./engine.js";
8
+ import { artifactNeeds, confirmedLevel, gatherSignals, loadCeremonyConfig, promoteCeremonyLevel, proposeLevel, readCeremonyRecord, readChangeType, } from "./levels.js";
9
9
  import { scaffoldQuick } from "./quick.js";
10
+ import { CHANGE_NAME_RE, isPlaceholderDelta, scaffoldFeature } from "./scaffold-change.js";
10
11
  const GATE_TAIL_LINES = 12;
11
12
  /**
12
13
  * Read the `ship:` block of lawbook/config.yaml (line-oriented, like the other
@@ -79,10 +80,28 @@ function branchFiles(projectPath) {
79
80
  return [];
80
81
  const base = mergeBase(projectPath, "main") ?? mergeBase(projectPath, "master");
81
82
  const committed = base ? changedFiles(projectPath, base) : [];
82
- return [...new Set([...committed, ...worktreeChangedFiles(projectPath)])]
83
+ return [
84
+ ...new Set([
85
+ ...committed,
86
+ ...worktreeChangedFiles(projectPath),
87
+ ...untrackedFiles(projectPath),
88
+ ]),
89
+ ]
83
90
  .filter((f) => !f.startsWith("lawbook/changes/"))
84
91
  .sort();
85
92
  }
93
+ /** New files not yet added, outside ship's own output (`lawbook/changes/`, `.speclaw/`). */
94
+ function untrackedFiles(projectPath) {
95
+ return git(projectPath, [
96
+ "-c",
97
+ "core.quotePath=false",
98
+ "ls-files",
99
+ "--others",
100
+ "--exclude-standard",
101
+ ])
102
+ .split("\n")
103
+ .filter((f) => f && !f.startsWith("lawbook/changes/") && !f.startsWith(".speclaw/"));
104
+ }
86
105
  function secs(ms) {
87
106
  return `${(ms / 1000).toFixed(1)} s`;
88
107
  }
@@ -139,14 +158,201 @@ function findArchived(projectPath, name) {
139
158
  .pop();
140
159
  return hit ? path.join("lawbook", "changes", "archive", hit) : null;
141
160
  }
161
+ const RECORD_STUB = "<!-- 2–5 lines: what and why. -->";
142
162
  function fillRecord(recordPath, summary) {
143
163
  if (!fs.existsSync(recordPath))
144
164
  return;
145
- const body = fs
146
- .readFileSync(recordPath, "utf8")
147
- .replace("<!-- 2–5 lines: what and why. -->", summary)
148
- .replace(/^- \[ \] /gm, "- [x] ");
149
- fs.writeFileSync(recordPath, body);
165
+ let body = fs.readFileSync(recordPath, "utf8");
166
+ // A function replacer: `$&` or `$'` in a commit body must stay literal.
167
+ if (summary)
168
+ body = body.replace(RECORD_STUB, () => summary);
169
+ fs.writeFileSync(recordPath, body.replace(/^- \[ \] /gm, "- [x] "));
170
+ }
171
+ /**
172
+ * The branch's own commit messages (subjects and bodies, trailers dropped),
173
+ * skipping commits that only touched ship's output; empty when there are none.
174
+ * A Conventional Commit body is the change's why, so it stands in for one.
175
+ */
176
+ function commitSummary(projectPath) {
177
+ const base = mergeBase(projectPath, "main") ?? mergeBase(projectPath, "master");
178
+ if (!base)
179
+ return "";
180
+ return git(projectPath, [
181
+ "log",
182
+ "--format=%B",
183
+ `${base}..HEAD`,
184
+ "--",
185
+ ".",
186
+ ":(exclude)lawbook/changes",
187
+ ":(exclude).speclaw",
188
+ ])
189
+ .split("\n")
190
+ .filter((l) => !/^[\w-]+-by:/i.test(l))
191
+ .join("\n")
192
+ .replace(/\n{3,}/g, "\n\n")
193
+ .trim()
194
+ .split("\n")
195
+ .slice(0, COMMIT_SUMMARY_LINES)
196
+ .join("\n");
197
+ }
198
+ /** A record's why is a few lines; a long branch history keeps only its start. */
199
+ const COMMIT_SUMMARY_LINES = 15;
200
+ /** A manifest whose only diff against `base` is its `"version"` lines: a release bump. */
201
+ function isVersionBump(projectPath, base, file) {
202
+ if (!base || !/(^|\/)package(-lock)?\.json$/.test(file))
203
+ return false;
204
+ const lines = git(projectPath, ["diff", "-U0", base, "--", file])
205
+ .split("\n")
206
+ .filter((l) => /^[+-](?![+-]{2})/.test(l));
207
+ return lines.length > 0 && lines.every((l) => /^[+-]\s*"version"\s*:/.test(l));
208
+ }
209
+ /**
210
+ * Propose a ceremony level from the branch diff itself. A release bump of the
211
+ * manifests is not a global change, so it does not raise the level.
212
+ */
213
+ function measureDiff(projectPath, files) {
214
+ const base = mergeBase(projectPath, "main") ?? mergeBase(projectPath, "master");
215
+ const paths = files.filter((f) => !isVersionBump(projectPath, base, f));
216
+ const { thresholds } = loadCeremonyConfig(projectPath);
217
+ return proposeLevel(gatherSignals(projectPath, { paths, symbols: [] }, thresholds), thresholds);
218
+ }
219
+ /**
220
+ * Create the change at the level its diff measures: level 0 gets a record,
221
+ * level 1+ the feature stubs that level requires. The level is recorded as
222
+ * `measured`, so a later, larger diff raises it.
223
+ */
224
+ function scaffoldMeasured(projectPath, name, files, measured) {
225
+ const proposal = measured ?? measureDiff(projectPath, files);
226
+ const targets = { paths: files, symbols: [] };
227
+ const level = proposal.level ?? 0;
228
+ if (level > 0 && CHANGE_NAME_RE.test(name)) {
229
+ scaffoldFeature(projectPath, name, { level, targets, proposal, confirmedBy: "measured" });
230
+ }
231
+ else {
232
+ scaffoldQuick(projectPath, name, targets, { proposal, confirmedBy: "measured" });
233
+ }
234
+ }
235
+ /** Raise a measured level when the branch diff has grown past it; a human-set level stands. */
236
+ function remeasure(projectPath, name, files) {
237
+ const rec = readCeremonyRecord(projectPath, name);
238
+ if (rec?.confirmedBy !== "measured")
239
+ return;
240
+ const proposal = measureDiff(projectPath, files);
241
+ if (proposal.level !== null && proposal.level > rec.confirmedLevel) {
242
+ promoteCeremonyLevel(projectPath, name, proposal.level, "the branch diff grew", proposal);
243
+ }
244
+ }
245
+ /**
246
+ * Reopen a change archived on this branch at a measured level the diff has
247
+ * since outgrown (work shipped at a mid-task stop, then grew): move it back
248
+ * under `lawbook/changes/`, drop its finished harness, and raise its level. An
249
+ * archive that already exists at the merge base belongs to merged work and stays.
250
+ *
251
+ * @returns Whether the change was reopened.
252
+ */
253
+ function reopenIfGrown(projectPath, name, archivedRel, files) {
254
+ const archivedDir = path.join(projectPath, archivedRel);
255
+ let rec = {};
256
+ try {
257
+ rec = JSON.parse(fs.readFileSync(path.join(archivedDir, "change.json"), "utf8"));
258
+ }
259
+ catch {
260
+ return false;
261
+ }
262
+ if (rec.confirmedBy !== "measured" || rec.confirmedLevel === undefined)
263
+ return false;
264
+ const base = mergeBase(projectPath, "main") ?? mergeBase(projectPath, "master");
265
+ if (!base)
266
+ return false;
267
+ // git object paths are POSIX on every platform.
268
+ const objectPath = `${archivedRel.split(path.sep).join("/")}/change.json`;
269
+ const atBase = spawnSync("git", ["cat-file", "-e", `${base}:${objectPath}`], {
270
+ cwd: projectPath,
271
+ });
272
+ if (atBase.status === 0)
273
+ return false;
274
+ const proposal = measureDiff(projectPath, files);
275
+ if (proposal.level === null || proposal.level <= rec.confirmedLevel)
276
+ return false;
277
+ const changeDir = path.join(projectPath, "lawbook", "changes", name);
278
+ fs.renameSync(archivedDir, changeDir);
279
+ fs.rmSync(path.join(changeDir, "harness.json"), { force: true });
280
+ promoteCeremonyLevel(projectPath, name, proposal.level, "the branch diff grew", proposal);
281
+ return true;
282
+ }
283
+ /**
284
+ * Text a scaffold, a promotion, or ship itself leaves in an artifact until
285
+ * someone writes it. Task stubs match whole generated lines only, so a real
286
+ * task that starts the same way ("Make the hook idempotent") is never a stub.
287
+ */
288
+ const STUBS = {
289
+ // At level 1+ the level-0 file list ship filled in is not a why.
290
+ "record.md": [/<!-- 2–5 lines: what and why\. -->/, /^Changed \d+ file\(s\): /m],
291
+ "proposal.md": [
292
+ /<!-- The problem and who it affects\. -->/,
293
+ /\(promoted — fill in why\)/,
294
+ // A promotion seeds Why from the level rationale and What with this line.
295
+ /\(promoted from level \d\)/,
296
+ ],
297
+ "design.md": [/<!-- Module boundaries, data flow, and decisions\. -->/, /\(promoted — fill in\)/],
298
+ "tasks.md": [
299
+ /^\s*- \[[ xX]\] (1\.1 Implement the change|Implement|Make the fix|Make the change|Add or update a regression test|Add or update tests|Record evidence under reports\/|Write discipline report under reports\/)\s*$/m,
300
+ ],
301
+ };
302
+ /**
303
+ * Artifacts the change's confirmed level requires that are missing or still a
304
+ * scaffold stub, as instructions the agent can act on. Level 0 owes nothing:
305
+ * its record falls back to the commits or the file list, so it costs no turn. Bug-shaped changes keep
306
+ * their own checks (`bugfix.md`) and are not judged here.
307
+ *
308
+ * @param projectPath - Project root with `lawbook/`.
309
+ * @param name - Change name.
310
+ * @returns One instruction per missing artifact; empty when the change is documented.
311
+ */
312
+ export function pendingArtifacts(projectPath, name) {
313
+ const dir = path.join(projectPath, "lawbook", "changes", name);
314
+ if (!fs.existsSync(dir) || readChangeType(projectPath, name) === "bug")
315
+ return [];
316
+ const level = confirmedLevel(projectPath, name);
317
+ const needs = artifactNeeds(level, "feature");
318
+ const rel = (f) => path.join("lawbook", "changes", name, f);
319
+ const state = (f) => {
320
+ const abs = path.join(dir, f);
321
+ if (!fs.existsSync(abs))
322
+ return "missing";
323
+ const text = fs.readFileSync(abs, "utf8");
324
+ return (STUBS[f] ?? []).some((m) => m.test(text)) ? "stub" : "written";
325
+ };
326
+ const out = [];
327
+ // Level 0 never waits on prose: the record falls back to commits or the file list.
328
+ if (needs.record && level > 0 && state("record.md") !== "written") {
329
+ out.push(`${rel("record.md")}: write what changed and why (2–5 lines) under "What changes"`);
330
+ }
331
+ if (needs.proposal && state("proposal.md") !== "written") {
332
+ out.push(`${rel("proposal.md")}: write why, what changes, and the impact`);
333
+ }
334
+ const design = state("design.md");
335
+ if (needs.design && !needs.designOptionalWithJustification && design !== "written") {
336
+ out.push(`${rel("design.md")}: write the approach — boundaries, data flow, decisions`);
337
+ }
338
+ else if (design === "stub") {
339
+ out.push(`${rel("design.md")}: write the approach, or delete it (optional at level 2)`);
340
+ }
341
+ if (needs.tasksFile && state("tasks.md") !== "written") {
342
+ out.push(`${rel("tasks.md")}: list the tasks this change did, checked (- [x])`);
343
+ }
344
+ if (needs.deltaSpecs) {
345
+ const specs = deltaSpecFiles(dir);
346
+ if (specs.length === 0) {
347
+ out.push(`${rel("specs/<capability>/spec.md")}: add the full intended spec of the capability this changes`);
348
+ }
349
+ for (const f of specs) {
350
+ if (isPlaceholderDelta(fs.readFileSync(f, "utf8"))) {
351
+ out.push(`${path.relative(projectPath, f)}: replace the placeholder with the capability's full intended spec (or move it to the capability it changes)`);
352
+ }
353
+ }
354
+ }
355
+ return out;
150
356
  }
151
357
  /**
152
358
  * Record the test verdict derived from gate exit codes. Ship never records a
@@ -173,12 +379,13 @@ function recordGateVerdict(projectPath, change) {
173
379
  }
174
380
  }
175
381
  /**
176
- * Fast path for finished work, in one call: scaffold a level-0 record when the
177
- * change does not exist, run the project's gates once, and write a discipline
178
- * report from their real output. At level 0 passing gates are the whole
179
- * verification, so the change is archived. At level 1+ ship stops after the
180
- * evidence: review is a human (or reviewer-role) decision taken on the PR, and
181
- * the archive follows it.
382
+ * Fast path for finished work, in one call: create the change at the level its
383
+ * branch diff measures (raising a measured level when the diff grew), refuse
384
+ * to run the gates while an artifact that level requires is missing or still a
385
+ * stub, then run the project's gates once and write a discipline report from
386
+ * their real output. At level 0 passing gates are the whole verification, so
387
+ * the change is archived. At level 1+ ship stops after the evidence: review is
388
+ * a human (or reviewer-role) decision taken on the PR, and the archive follows it.
182
389
  *
183
390
  * @param projectPath - Project root with `lawbook/`.
184
391
  * @param name - Change name (kebab-case).
@@ -192,16 +399,46 @@ export function shipChange(projectPath, name, opts = {}) {
192
399
  const date = opts.date ?? new Date().toISOString().slice(0, 10);
193
400
  const changeDir = path.join(projectPath, "lawbook", "changes", name);
194
401
  const files = branchFiles(projectPath);
195
- const archived = findArchived(projectPath, name);
196
- if (!fs.existsSync(changeDir) && !archived) {
197
- scaffoldQuick(projectPath, name, { paths: [], symbols: [] });
402
+ let archived = findArchived(projectPath, name);
403
+ if (archived && !fs.existsSync(changeDir) && reopenIfGrown(projectPath, name, archived, files)) {
404
+ archived = null;
198
405
  }
199
- const summary = opts.summary ??
200
- (files.length
201
- ? `Changed ${files.length} file(s): ${files.slice(0, 8).join(", ")}${files.length > 8 ? ", …" : ""}`
202
- : "No file changes detected.");
203
- fillRecord(path.join(changeDir, "record.md"), summary);
406
+ if (!fs.existsSync(changeDir) && !archived)
407
+ scaffoldMeasured(projectPath, name, files);
408
+ else if (fs.existsSync(changeDir))
409
+ remeasure(projectPath, name, files);
410
+ const commits = isGitRepo(projectPath) ? commitSummary(projectPath) : "";
411
+ const fileList = files.length
412
+ ? `Changed ${files.length} file(s): ${files.slice(0, 8).join(", ")}${files.length > 8 ? ", …" : ""}`
413
+ : "No file changes detected.";
414
+ fillRecord(path.join(changeDir, "record.md"), opts.summary ?? (commits || (confirmedLevel(projectPath, name) === 0 ? fileList : "")));
415
+ const pending = pendingArtifacts(projectPath, name);
204
416
  const t1 = Date.now();
417
+ if (pending.length) {
418
+ const level = confirmedLevel(projectPath, name);
419
+ const why = readCeremonyRecord(projectPath, name)?.rationale ?? "";
420
+ return {
421
+ change: name,
422
+ gatesPassed: false,
423
+ gates: [],
424
+ files,
425
+ report: null,
426
+ pending,
427
+ archivedTo: null,
428
+ next: [
429
+ `level ${level}${why ? ` (${why})` : ""}: document the change, then stop again — the gates run once it is written`,
430
+ ...pending,
431
+ ],
432
+ timings: {
433
+ scaffold: t1 - t0,
434
+ gates: 0,
435
+ report: 0,
436
+ archive: 0,
437
+ overhead: t1 - t0,
438
+ total: t1 - t0,
439
+ },
440
+ };
441
+ }
205
442
  const commands = opts.gates ?? cfg.gates ?? detectGates(projectPath);
206
443
  const gates = [];
207
444
  for (const command of commands) {
@@ -247,6 +484,7 @@ export function shipChange(projectPath, name, opts = {}) {
247
484
  gates,
248
485
  files,
249
486
  report: reportRel,
487
+ pending,
250
488
  archivedTo,
251
489
  next,
252
490
  timings: {
@@ -259,6 +497,92 @@ export function shipChange(projectPath, name, opts = {}) {
259
497
  },
260
498
  };
261
499
  }
500
+ /** Where {@link docHint} remembers what it last measured and told. */
501
+ const DOC_HINT_STATE = path.join(".speclaw", "doc-hint.json");
502
+ /** The change the hook would ship on this branch: the one last shipped here, else the branch's. */
503
+ function branchChange(projectPath, branch) {
504
+ const marker = readMarker(projectPath);
505
+ return marker && marker.branch === branch && marker.change
506
+ ? marker.change
507
+ : changeNameForBranch(branch);
508
+ }
509
+ /**
510
+ * The documentation a change owes, told while the agent is still working so it
511
+ * writes it in the same turn as the code instead of after a blocked stop. Runs
512
+ * from the `PostToolUse` hook: it re-measures only when the set of changed
513
+ * files changed, creates the change once the diff measures level 1 or more,
514
+ * and speaks once per change and level. Never throws; null means say nothing.
515
+ *
516
+ * @param projectPath - Project root.
517
+ * @returns The hint for the agent, or null.
518
+ */
519
+ export function docHint(projectPath) {
520
+ try {
521
+ if (!fs.existsSync(path.join(projectPath, "lawbook")))
522
+ return null;
523
+ // One git call answers "anything new?" on most calls (this runs after every
524
+ // edit and Bash call): branch, HEAD, and the worktree outside ship's output.
525
+ const status = git(projectPath, [
526
+ "-c",
527
+ "core.quotePath=false",
528
+ "status",
529
+ "--porcelain=v2",
530
+ "--branch",
531
+ "--untracked-files=all",
532
+ ]);
533
+ const branch = /^# branch\.head (.+)$/m.exec(status)?.[1] ?? "";
534
+ if (!branch || ["main", "master", "(detached)"].includes(branch))
535
+ return null;
536
+ const quick = status
537
+ .split("\n")
538
+ .filter((l) => !l.includes(" lawbook/changes/") && !l.includes(" .speclaw/"))
539
+ .join("\n");
540
+ const statePath = path.join(projectPath, DOC_HINT_STATE);
541
+ let prev = {};
542
+ try {
543
+ prev = JSON.parse(fs.readFileSync(statePath, "utf8"));
544
+ }
545
+ catch {
546
+ // First hint on this checkout.
547
+ }
548
+ if (prev.quick === quick)
549
+ return null;
550
+ const files = branchFiles(projectPath);
551
+ 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({});
558
+ return null;
559
+ }
560
+ const change = branchChange(projectPath, branch);
561
+ const changeDir = path.join(projectPath, "lawbook", "changes", change);
562
+ const archived = findArchived(projectPath, change);
563
+ if (fs.existsSync(changeDir))
564
+ remeasure(projectPath, change, files);
565
+ else if (archived)
566
+ reopenIfGrown(projectPath, change, archived, files);
567
+ else {
568
+ const proposal = measureDiff(projectPath, files);
569
+ if ((proposal.level ?? 0) > 0)
570
+ scaffoldMeasured(projectPath, change, files, proposal);
571
+ }
572
+ const level = fs.existsSync(changeDir) ? confirmedLevel(projectPath, change) : 0;
573
+ remember({ change, level });
574
+ const pending = level > 0 ? pendingArtifacts(projectPath, change) : [];
575
+ if (pending.length === 0 || (prev.change === change && prev.level === level))
576
+ return null;
577
+ const why = readCeremonyRecord(projectPath, change)?.rationale ?? "";
578
+ return (`speclaw: this change measures level ${level}${why ? ` (${why})` : ""}. ` +
579
+ `In this same turn, before you stop, write briefly and truthfully:\n- ${pending.join("\n- ")}\n` +
580
+ `The Stop hook then runs the gates once.`);
581
+ }
582
+ catch {
583
+ return null;
584
+ }
585
+ }
262
586
  /**
263
587
  * Change name for a branch: the part after the last `/`, kebab-cased
264
588
  * (`fix/compass-query-output` → `compass-query-output`).
@@ -311,30 +635,41 @@ function readMarker(projectPath) {
311
635
  return { branch: "", change: "", fingerprint: raw };
312
636
  }
313
637
  /**
314
- * Fingerprint of the branch's work outside `lawbook/changes/`: the working tree
315
- * diffed against the merge base with `main`/`master`, plus untracked files
316
- * (names and contents). Committing that work leaves it unchanged — a commit is not new work —
317
- * and ship writes under `lawbook/changes/`, so its own output never changes it.
318
- * Without a merge base it falls back to the diff against HEAD plus HEAD itself.
638
+ * Fingerprint of the branch's work outside `lawbook/changes/` and `.speclaw/`: every path that
639
+ * differs from the merge base with `main`/`master` or is untracked, with its
640
+ * working-tree contents. A file hashes the same whether it is untracked, staged
641
+ * or committed, so committing work — new files included — is not new work; ship
642
+ * writes under `lawbook/changes/`, so its own output never changes it either.
643
+ * Without a merge base it falls back to the paths changed against HEAD plus HEAD itself.
319
644
  */
320
645
  function workFingerprint(projectPath) {
321
646
  const base = mergeBase(projectPath, "main") ?? mergeBase(projectPath, "master");
322
- const diff = git(projectPath, ["diff", base ?? "HEAD", "--", ".", ":(exclude)lawbook/changes"]);
647
+ const tracked = git(projectPath, [
648
+ "-c",
649
+ "core.quotePath=false",
650
+ "diff",
651
+ "--name-only",
652
+ base ?? "HEAD",
653
+ "--",
654
+ ".",
655
+ ":(exclude)lawbook/changes",
656
+ ":(exclude).speclaw",
657
+ ]).split("\n");
323
658
  const hash = createHash("sha256");
324
- for (const f of git(projectPath, ["ls-files", "--others", "--exclude-standard"]).split("\n")) {
325
- if (!f || f.startsWith("lawbook/changes/") || f.startsWith(".speclaw/"))
659
+ for (const f of [...new Set([...tracked, ...untrackedFiles(projectPath)])].sort()) {
660
+ if (!f)
326
661
  continue;
327
- // Contents, not just names: editing a new, not-yet-added file is new work.
328
662
  hash.update(`${f}\n`);
329
663
  try {
330
664
  hash.update(fs.readFileSync(path.join(projectPath, f)));
331
665
  }
332
666
  catch {
333
- // Vanished or unreadable since listing: its name stands in.
667
+ // Deleted, vanished or unreadable: its name stands in.
668
+ hash.update("\0");
334
669
  }
335
670
  }
336
671
  const anchor = base ?? git(projectPath, ["rev-parse", "HEAD"]).trim();
337
- return hash.update(`\n${anchor}\n${diff}`).digest("hex");
672
+ return hash.update(`\n${anchor}\n`).digest("hex");
338
673
  }
339
674
  /**
340
675
  * The `Stop` hook body: when the agent ends a turn on a feature branch with
@@ -359,8 +694,6 @@ export function shipOnStop(projectPath) {
359
694
  }
360
695
  // A change shipped by name on this branch keeps receiving the branch's later
361
696
  // work; the branch name would point at another (possibly archived) change.
362
- const change = marker && marker.branch === branch && marker.change
363
- ? marker.change
364
- : changeNameForBranch(branch);
697
+ const change = branchChange(projectPath, branch);
365
698
  return { skipped: null, change, result: shipChange(projectPath, change) };
366
699
  }
@@ -1,4 +1,13 @@
1
+ import fs from "node:fs";
1
2
  import path from "node:path";
3
+ function realRoot(projectPath) {
4
+ try {
5
+ return fs.realpathSync(projectPath);
6
+ }
7
+ catch {
8
+ return projectPath;
9
+ }
10
+ }
2
11
  const V8_NAMED = /^\s*at\s+(?:async\s+)?(?:(.+?)\s+\()?([^\s()]+):(\d+):(\d+)\)?\s*$/;
3
12
  const V8_ANON = /^\s*at\s+([^\s()]+):(\d+):(\d+)\s*$/;
4
13
  const PY_FRAME = /^\s*File\s+"([^"]+)",\s*line\s+(\d+)(?:,\s*in\s+(.+))?\s*$/;
@@ -29,14 +38,21 @@ export function mapDistToSrc(rel) {
29
38
  */
30
39
  export function normalizeTracePath(projectPath, rawPath) {
31
40
  let p = rawPath.replace(/\\/g, "/");
32
- const root = projectPath.replace(/\\/g, "/");
33
- if (p.startsWith(root + "/"))
34
- p = p.slice(root.length + 1);
41
+ // A runtime prints real paths: under a symlinked root (macOS /tmp → /private/tmp)
42
+ // the trace names the real root, not the one the project was opened by.
43
+ const roots = [
44
+ ...new Set([projectPath, realRoot(projectPath)].map((r) => r.replace(/\\/g, "/"))),
45
+ ];
46
+ const strip = () => {
47
+ const root = roots.find((r) => p.startsWith(r + "/"));
48
+ if (root)
49
+ p = p.slice(root.length + 1);
50
+ };
51
+ strip();
35
52
  if (p.startsWith("file://")) {
36
53
  try {
37
54
  p = decodeURIComponent(new URL(p).pathname);
38
- if (p.startsWith(root + "/"))
39
- p = p.slice(root.length + 1);
55
+ strip();
40
56
  }
41
57
  catch {
42
58
  /* keep raw */
package/dist/server.js CHANGED
@@ -7,6 +7,20 @@ import { registerFoundation } from "./modules/foundation/register.js";
7
7
  import { registerTools } from "./modules/tools/register.js";
8
8
  import { isMinimalMode } from "./shared/exposure.js";
9
9
  import { pkgVersion } from "./shared/version.js";
10
+ /**
11
+ * Sent in the MCP `initialize` response; Claude Code keeps it in the agent's
12
+ * context even while tool definitions are deferred, so it names the one tool
13
+ * per job that replaces a shell read. Kept short: it is paid on every session.
14
+ */
15
+ export const SERVER_INSTRUCTIONS = [
16
+ "speclaw has a tool for each of these jobs; use it instead of shell reads:",
17
+ '- Locate code: compass_find. Read it: compass_explore (include ["source"]; maxDepth 4 reads a whole call chain; blast_radius before changing a symbol). Not cat/sed/grep/rg.',
18
+ "- Failing test, bug, or stack trace: lawbook_investigate first, with the test output as stackTrace — it ranks the code that test reaches.",
19
+ "- Before running tests or finishing: compass_diff_context (what the diff touches, which tests cover it).",
20
+ "- Multi-module or public-API work: lawbook_change draft before coding; validate any spec you write.",
21
+ "- No or stale index: compass_index. 3+ large independent parts: cortex. Setup: speclaw_setup.",
22
+ "Markdown, JSON, and config files are fine to read directly.",
23
+ ].join("\n");
10
24
  /**
11
25
  * Build the speclaw MCP server with every module's tools registered.
12
26
  *
@@ -14,7 +28,7 @@ import { pkgVersion } from "./shared/version.js";
14
28
  */
15
29
  export function buildServer(opts = {}) {
16
30
  const minimal = opts.minimal ?? isMinimalMode();
17
- const server = new McpServer({ name: "speclaw", version: pkgVersion() });
31
+ const server = new McpServer({ name: "speclaw", version: pkgVersion() }, { instructions: SERVER_INSTRUCTIONS });
18
32
  const reg = { minimal };
19
33
  registerFoundation(server, reg);
20
34
  registerSpec(server, reg);
@@ -5,14 +5,11 @@ import { readManifest } from "./manifest.js";
5
5
  /**
6
6
  * Tools omitted when the exposure profile is `minimal`. Kept tools are the
7
7
  * discovery + law loop: compass_explore/find/diff_context, lawbook_change,
8
- * cortex. Deprecated aliases are never registered in minimal mode.
8
+ * lawbook_investigate, cortex, and speclaw_check — the tool every installed
9
+ * hook calls, so omitting it silently disables the laws and the Compass nudge.
10
+ * Deprecated aliases are never registered in minimal mode.
9
11
  */
10
- export const MINIMAL_OMIT = new Set([
11
- "compass_index",
12
- "lawbook_investigate",
13
- "speclaw_setup",
14
- "speclaw_check",
15
- ]);
12
+ export const MINIMAL_OMIT = new Set(["compass_index", "speclaw_setup"]);
16
13
  /**
17
14
  * Whether minimal exposure is active for this process / project.
18
15
  * `SPECLAW_MINIMAL=1` wins; otherwise the project manifest's `minimal` flag.
@@ -0,0 +1,40 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ /**
4
+ * Totals of the last index run, kept beside the index as plain JSON so hot
5
+ * paths (the hooks) can size the repo without opening the database.
6
+ */
7
+ export const INDEX_STATS_FILE = path.join(".speclaw", "index-stats.json");
8
+ /**
9
+ * Record the index totals after a run. Best-effort: a failed write never fails
10
+ * the index.
11
+ *
12
+ * @param projectPath - Project root.
13
+ * @param totals - Whole-repository counts after the run.
14
+ */
15
+ export function writeIndexStats(projectPath, totals) {
16
+ try {
17
+ const file = path.join(projectPath, INDEX_STATS_FILE);
18
+ fs.mkdirSync(path.dirname(file), { recursive: true });
19
+ fs.writeFileSync(file, JSON.stringify({ files: totals.files, nodes: totals.nodes }) + "\n");
20
+ }
21
+ catch {
22
+ // The stats are an optimization for hooks; the index itself is intact.
23
+ }
24
+ }
25
+ /**
26
+ * The last index run's totals, or null when no run recorded them.
27
+ *
28
+ * @param projectPath - Project root.
29
+ */
30
+ export function readIndexStats(projectPath) {
31
+ try {
32
+ const raw = JSON.parse(fs.readFileSync(path.join(projectPath, INDEX_STATS_FILE), "utf8"));
33
+ return typeof raw.files === "number"
34
+ ? { files: raw.files, nodes: typeof raw.nodes === "number" ? raw.nodes : 0 }
35
+ : null;
36
+ }
37
+ catch {
38
+ return null;
39
+ }
40
+ }
@@ -25,8 +25,8 @@ export function text(value, budget = "brief") {
25
25
  }
26
26
  /**
27
27
  * Register one MCP tool after enforcing the context-budget caps (description
28
- * word count and estimated definition tokens). Does **not** set
29
- * `defer_loading` — that is not author-settable for MCP servers.
28
+ * word count and estimated definition tokens). `alwaysLoad` marks the
29
+ * definition `_meta["anthropic/alwaysLoad"]` so Claude Code never defers it.
30
30
  *
31
31
  * @param server - MCP server to register on.
32
32
  * @param spec - Tool name, description, schema, and handler.
@@ -51,6 +51,7 @@ export function defineTool(server, spec) {
51
51
  description: spec.description,
52
52
  inputSchema,
53
53
  ...(spec.annotations ? { annotations: spec.annotations } : {}),
54
+ ...(spec.alwaysLoad ? { _meta: { "anthropic/alwaysLoad": true } } : {}),
54
55
  }, spec.handler);
55
56
  }
56
57
  const ALIAS_MAX_WORDS = 12;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@esneiderbravo/speclaw",
3
- "version": "2.0.18",
3
+ "version": "2.0.20",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },