@esneiderbravo/speclaw 2.0.19 → 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.
Files changed (32) hide show
  1. package/dist/cli/commands/ship-on-stop.js +22 -2
  2. package/dist/cli/commands/ship.js +2 -1
  3. package/dist/cli/commands/update.js +1 -1
  4. package/dist/cli/index.js +3 -1
  5. package/dist/cli/lib/help.js +11 -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 +71 -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 +422 -26
  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
@@ -1,12 +1,14 @@
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";
7
- import { specArchive, specArchivePreconditions } from "./engine.js";
8
- import { confirmedLevel } from "./levels.js";
8
+ import { deltaSpecFiles, specArchive, specArchivePreconditions } from "./engine.js";
9
+ import { artifactNeeds, confirmedLevel, gatherSignals, loadCeremonyConfig, promoteCeremonyLevel, proposeLevel, readCeremonyRecord, readChangeType, } from "./levels.js";
9
10
  import { scaffoldQuick } from "./quick.js";
11
+ import { CHANGE_NAME_RE, isPlaceholderDelta, scaffoldFeature } from "./scaffold-change.js";
10
12
  const GATE_TAIL_LINES = 12;
11
13
  /**
12
14
  * Read the `ship:` block of lawbook/config.yaml (line-oriented, like the other
@@ -157,14 +159,261 @@ function findArchived(projectPath, name) {
157
159
  .pop();
158
160
  return hit ? path.join("lawbook", "changes", "archive", hit) : null;
159
161
  }
162
+ const RECORD_STUB = "<!-- 2–5 lines: what and why. -->";
160
163
  function fillRecord(recordPath, summary) {
161
164
  if (!fs.existsSync(recordPath))
162
165
  return;
163
- const body = fs
164
- .readFileSync(recordPath, "utf8")
165
- .replace("<!-- 2–5 lines: what and why. -->", summary)
166
- .replace(/^- \[ \] /gm, "- [x] ");
167
- fs.writeFileSync(recordPath, body);
166
+ let body = fs.readFileSync(recordPath, "utf8");
167
+ // A function replacer: `$&` or `$'` in a commit body must stay literal.
168
+ if (summary)
169
+ body = body.replace(RECORD_STUB, () => summary);
170
+ fs.writeFileSync(recordPath, body.replace(/^- \[ \] /gm, "- [x] "));
171
+ }
172
+ /**
173
+ * The branch's own commit messages (subjects and bodies, trailers dropped),
174
+ * skipping commits that only touched ship's output; empty when there are none.
175
+ * A Conventional Commit body is the change's why, so it stands in for one.
176
+ */
177
+ function commitSummary(projectPath) {
178
+ const base = mergeBase(projectPath, "main") ?? mergeBase(projectPath, "master");
179
+ if (!base)
180
+ return "";
181
+ return git(projectPath, [
182
+ "log",
183
+ "--format=%B",
184
+ `${base}..HEAD`,
185
+ "--",
186
+ ".",
187
+ ":(exclude)lawbook/changes",
188
+ ":(exclude).speclaw",
189
+ ])
190
+ .split("\n")
191
+ .filter((l) => !/^[\w-]+-by:/i.test(l))
192
+ .join("\n")
193
+ .replace(/\n{3,}/g, "\n\n")
194
+ .trim()
195
+ .split("\n")
196
+ .slice(0, COMMIT_SUMMARY_LINES)
197
+ .join("\n");
198
+ }
199
+ /** A record's why is a few lines; a long branch history keeps only its start. */
200
+ const COMMIT_SUMMARY_LINES = 15;
201
+ /** A manifest whose only diff against `base` is its `"version"` lines: a release bump. */
202
+ function isVersionBump(projectPath, base, file) {
203
+ if (!base || !/(^|\/)package(-lock)?\.json$/.test(file))
204
+ return false;
205
+ const lines = git(projectPath, ["diff", "-U0", base, "--", file])
206
+ .split("\n")
207
+ .filter((l) => /^[+-](?![+-]{2})/.test(l));
208
+ return lines.length > 0 && lines.every((l) => /^[+-]\s*"version"\s*:/.test(l));
209
+ }
210
+ /**
211
+ * Propose a ceremony level from the branch diff itself. A release bump of the
212
+ * manifests is not a global change, so it does not raise the level.
213
+ */
214
+ function measureDiff(projectPath, files) {
215
+ const cached = cachedMeasure(projectPath, files);
216
+ if (cached)
217
+ return cached;
218
+ const base = mergeBase(projectPath, "main") ?? mergeBase(projectPath, "master");
219
+ const paths = files.filter((f) => !isVersionBump(projectPath, base, f));
220
+ const { thresholds } = loadCeremonyConfig(projectPath);
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
+ }
279
+ }
280
+ /**
281
+ * Create the change at the level its diff measures: level 0 gets a record,
282
+ * level 1+ the feature stubs that level requires. The level is recorded as
283
+ * `measured`, so a later, larger diff raises it.
284
+ */
285
+ function scaffoldMeasured(projectPath, name, files, measured) {
286
+ const proposal = measured ?? measureDiff(projectPath, files);
287
+ const targets = { paths: files, symbols: [] };
288
+ const level = proposal.level ?? 0;
289
+ if (level > 0 && CHANGE_NAME_RE.test(name)) {
290
+ scaffoldFeature(projectPath, name, { level, targets, proposal, confirmedBy: "measured" });
291
+ }
292
+ else {
293
+ scaffoldQuick(projectPath, name, targets, { proposal, confirmedBy: "measured" });
294
+ }
295
+ }
296
+ /** Raise a measured level when the branch diff has grown past it; a human-set level stands. */
297
+ function remeasure(projectPath, name, files) {
298
+ const rec = readCeremonyRecord(projectPath, name);
299
+ if (rec?.confirmedBy !== "measured")
300
+ return;
301
+ const proposal = measureDiff(projectPath, files);
302
+ if (proposal.level !== null && proposal.level > rec.confirmedLevel) {
303
+ promoteCeremonyLevel(projectPath, name, proposal.level, "the branch diff grew", proposal);
304
+ }
305
+ }
306
+ /**
307
+ * Reopen a change archived on this branch at a measured level the diff has
308
+ * since outgrown (work shipped at a mid-task stop, then grew): move it back
309
+ * under `lawbook/changes/`, drop its finished harness, and raise its level. An
310
+ * archive that already exists at the merge base belongs to merged work and stays.
311
+ *
312
+ * @returns Whether the change was reopened.
313
+ */
314
+ function reopenIfGrown(projectPath, name, archivedRel, files) {
315
+ const archivedDir = path.join(projectPath, archivedRel);
316
+ let rec = {};
317
+ try {
318
+ rec = JSON.parse(fs.readFileSync(path.join(archivedDir, "change.json"), "utf8"));
319
+ }
320
+ catch {
321
+ return false;
322
+ }
323
+ if (rec.confirmedBy !== "measured" || rec.confirmedLevel === undefined)
324
+ return false;
325
+ const base = mergeBase(projectPath, "main") ?? mergeBase(projectPath, "master");
326
+ if (!base)
327
+ return false;
328
+ // git object paths are POSIX on every platform.
329
+ const objectPath = `${archivedRel.split(path.sep).join("/")}/change.json`;
330
+ const atBase = spawnSync("git", ["cat-file", "-e", `${base}:${objectPath}`], {
331
+ cwd: projectPath,
332
+ });
333
+ if (atBase.status === 0)
334
+ return false;
335
+ const proposal = measureDiff(projectPath, files);
336
+ if (proposal.level === null || proposal.level <= rec.confirmedLevel)
337
+ return false;
338
+ const changeDir = path.join(projectPath, "lawbook", "changes", name);
339
+ fs.renameSync(archivedDir, changeDir);
340
+ fs.rmSync(path.join(changeDir, "harness.json"), { force: true });
341
+ promoteCeremonyLevel(projectPath, name, proposal.level, "the branch diff grew", proposal);
342
+ return true;
343
+ }
344
+ /**
345
+ * Text a scaffold, a promotion, or ship itself leaves in an artifact until
346
+ * someone writes it. Task stubs match whole generated lines only, so a real
347
+ * task that starts the same way ("Make the hook idempotent") is never a stub.
348
+ */
349
+ const STUBS = {
350
+ // At level 1+ the level-0 file list ship filled in is not a why.
351
+ "record.md": [/<!-- 2–5 lines: what and why\. -->/, /^Changed \d+ file\(s\): /m],
352
+ "proposal.md": [
353
+ /<!-- The problem and who it affects\. -->/,
354
+ /\(promoted — fill in why\)/,
355
+ // A promotion seeds Why from the level rationale and What with this line.
356
+ /\(promoted from level \d\)/,
357
+ ],
358
+ "design.md": [/<!-- Module boundaries, data flow, and decisions\. -->/, /\(promoted — fill in\)/],
359
+ "tasks.md": [
360
+ /^\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,
361
+ ],
362
+ };
363
+ /**
364
+ * Artifacts the change's confirmed level requires that are missing or still a
365
+ * scaffold stub, as instructions the agent can act on. Level 0 owes nothing:
366
+ * its record falls back to the commits or the file list, so it costs no turn. Bug-shaped changes keep
367
+ * their own checks (`bugfix.md`) and are not judged here.
368
+ *
369
+ * @param projectPath - Project root with `lawbook/`.
370
+ * @param name - Change name.
371
+ * @returns One instruction per missing artifact; empty when the change is documented.
372
+ */
373
+ export function pendingArtifacts(projectPath, name) {
374
+ const dir = path.join(projectPath, "lawbook", "changes", name);
375
+ if (!fs.existsSync(dir) || readChangeType(projectPath, name) === "bug")
376
+ return [];
377
+ const level = confirmedLevel(projectPath, name);
378
+ const needs = artifactNeeds(level, "feature");
379
+ const rel = (f) => path.join("lawbook", "changes", name, f);
380
+ const state = (f) => {
381
+ const abs = path.join(dir, f);
382
+ if (!fs.existsSync(abs))
383
+ return "missing";
384
+ const text = fs.readFileSync(abs, "utf8");
385
+ return (STUBS[f] ?? []).some((m) => m.test(text)) ? "stub" : "written";
386
+ };
387
+ const out = [];
388
+ // Level 0 never waits on prose: the record falls back to commits or the file list.
389
+ if (needs.record && level > 0 && state("record.md") !== "written") {
390
+ out.push(`${rel("record.md")}: write what changed and why (2–5 lines) under "What changes"`);
391
+ }
392
+ if (needs.proposal && state("proposal.md") !== "written") {
393
+ out.push(`${rel("proposal.md")}: write why, what changes, and the impact`);
394
+ }
395
+ const design = state("design.md");
396
+ if (needs.design && !needs.designOptionalWithJustification && design !== "written") {
397
+ out.push(`${rel("design.md")}: write the approach — boundaries, data flow, decisions`);
398
+ }
399
+ else if (design === "stub") {
400
+ out.push(`${rel("design.md")}: write the approach, or delete it (optional at level 2)`);
401
+ }
402
+ if (needs.tasksFile && state("tasks.md") !== "written") {
403
+ out.push(`${rel("tasks.md")}: list the tasks this change did, checked (- [x])`);
404
+ }
405
+ if (needs.deltaSpecs) {
406
+ const specs = deltaSpecFiles(dir);
407
+ if (specs.length === 0) {
408
+ out.push(`${rel("specs/<capability>/spec.md")}: add the full intended spec of the capability this changes`);
409
+ }
410
+ for (const f of specs) {
411
+ if (isPlaceholderDelta(fs.readFileSync(f, "utf8"))) {
412
+ out.push(`${path.relative(projectPath, f)}: replace the placeholder with the capability's full intended spec (or move it to the capability it changes)`);
413
+ }
414
+ }
415
+ }
416
+ return out;
168
417
  }
169
418
  /**
170
419
  * Record the test verdict derived from gate exit codes. Ship never records a
@@ -191,12 +440,13 @@ function recordGateVerdict(projectPath, change) {
191
440
  }
192
441
  }
193
442
  /**
194
- * Fast path for finished work, in one call: scaffold a level-0 record when the
195
- * change does not exist, run the project's gates once, and write a discipline
196
- * report from their real output. At level 0 passing gates are the whole
197
- * verification, so the change is archived. At level 1+ ship stops after the
198
- * evidence: review is a human (or reviewer-role) decision taken on the PR, and
199
- * the archive follows it.
443
+ * Fast path for finished work, in one call: create the change at the level its
444
+ * branch diff measures (raising a measured level when the diff grew), refuse
445
+ * to run the gates while an artifact that level requires is missing or still a
446
+ * stub, then run the project's gates once and write a discipline report from
447
+ * their real output. At level 0 passing gates are the whole verification, so
448
+ * the change is archived. At level 1+ ship stops after the evidence: review is
449
+ * a human (or reviewer-role) decision taken on the PR, and the archive follows it.
200
450
  *
201
451
  * @param projectPath - Project root with `lawbook/`.
202
452
  * @param name - Change name (kebab-case).
@@ -210,16 +460,46 @@ export function shipChange(projectPath, name, opts = {}) {
210
460
  const date = opts.date ?? new Date().toISOString().slice(0, 10);
211
461
  const changeDir = path.join(projectPath, "lawbook", "changes", name);
212
462
  const files = branchFiles(projectPath);
213
- const archived = findArchived(projectPath, name);
214
- if (!fs.existsSync(changeDir) && !archived) {
215
- scaffoldQuick(projectPath, name, { paths: [], symbols: [] });
216
- }
217
- const summary = opts.summary ??
218
- (files.length
219
- ? `Changed ${files.length} file(s): ${files.slice(0, 8).join(", ")}${files.length > 8 ? ", …" : ""}`
220
- : "No file changes detected.");
221
- fillRecord(path.join(changeDir, "record.md"), summary);
463
+ let archived = findArchived(projectPath, name);
464
+ if (archived && !fs.existsSync(changeDir) && reopenIfGrown(projectPath, name, archived, files)) {
465
+ archived = null;
466
+ }
467
+ if (!fs.existsSync(changeDir) && !archived)
468
+ scaffoldMeasured(projectPath, name, files);
469
+ else if (fs.existsSync(changeDir))
470
+ remeasure(projectPath, name, files);
471
+ const commits = isGitRepo(projectPath) ? commitSummary(projectPath) : "";
472
+ const fileList = files.length
473
+ ? `Changed ${files.length} file(s): ${files.slice(0, 8).join(", ")}${files.length > 8 ? ", …" : ""}`
474
+ : "No file changes detected.";
475
+ fillRecord(path.join(changeDir, "record.md"), opts.summary ?? (commits || (confirmedLevel(projectPath, name) === 0 ? fileList : "")));
476
+ const pending = pendingArtifacts(projectPath, name);
222
477
  const t1 = Date.now();
478
+ if (pending.length) {
479
+ const level = confirmedLevel(projectPath, name);
480
+ const why = readCeremonyRecord(projectPath, name)?.rationale ?? "";
481
+ return {
482
+ change: name,
483
+ gatesPassed: false,
484
+ gates: [],
485
+ files,
486
+ report: null,
487
+ pending,
488
+ archivedTo: null,
489
+ next: [
490
+ `level ${level}${why ? ` (${why})` : ""}: document the change, then stop again — the gates run once it is written`,
491
+ ...pending,
492
+ ],
493
+ timings: {
494
+ scaffold: t1 - t0,
495
+ gates: 0,
496
+ report: 0,
497
+ archive: 0,
498
+ overhead: t1 - t0,
499
+ total: t1 - t0,
500
+ },
501
+ };
502
+ }
223
503
  const commands = opts.gates ?? cfg.gates ?? detectGates(projectPath);
224
504
  const gates = [];
225
505
  for (const command of commands) {
@@ -265,6 +545,7 @@ export function shipChange(projectPath, name, opts = {}) {
265
545
  gates,
266
546
  files,
267
547
  report: reportRel,
548
+ pending,
268
549
  archivedTo,
269
550
  next,
270
551
  timings: {
@@ -277,6 +558,123 @@ export function shipChange(projectPath, name, opts = {}) {
277
558
  },
278
559
  };
279
560
  }
561
+ /** Where {@link docHint} remembers what it last measured and told. */
562
+ const DOC_HINT_STATE = path.join(".speclaw", "doc-hint.json");
563
+ /** The change the hook would ship on this branch: the one last shipped here, else the branch's. */
564
+ function branchChange(projectPath, branch) {
565
+ const marker = readMarker(projectPath);
566
+ return marker && marker.branch === branch && marker.change
567
+ ? marker.change
568
+ : changeNameForBranch(branch);
569
+ }
570
+ /**
571
+ * The documentation a change owes, told while the agent is still working so it
572
+ * writes it in the same turn as the code instead of after a blocked stop. Runs
573
+ * from the `PostToolUse` hook: it re-measures only when the set of changed
574
+ * files changed, creates the change once the diff measures level 1 or more,
575
+ * and speaks once per change and level. Never throws; null means say nothing.
576
+ *
577
+ * @param projectPath - Project root.
578
+ * @returns The hint for the agent, or null.
579
+ */
580
+ export function docHint(projectPath) {
581
+ try {
582
+ if (!fs.existsSync(path.join(projectPath, "lawbook")))
583
+ return null;
584
+ // One git call answers "anything new?" on most calls (this runs after every
585
+ // edit and Bash call): branch, HEAD, and the worktree outside ship's output.
586
+ const status = git(projectPath, [
587
+ "-c",
588
+ "core.quotePath=false",
589
+ "status",
590
+ "--porcelain=v2",
591
+ "--branch",
592
+ "--untracked-files=all",
593
+ ]);
594
+ const branch = /^# branch\.head (.+)$/m.exec(status)?.[1] ?? "";
595
+ if (!branch || ["main", "master", "(detached)"].includes(branch))
596
+ return null;
597
+ const quick = status
598
+ .split("\n")
599
+ .filter((l) => !l.includes(" lawbook/changes/") && !l.includes(" .speclaw/"))
600
+ .join("\n");
601
+ const statePath = path.join(projectPath, DOC_HINT_STATE);
602
+ let prev = {};
603
+ try {
604
+ prev = JSON.parse(fs.readFileSync(statePath, "utf8"));
605
+ }
606
+ catch {
607
+ // First hint on this checkout.
608
+ }
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)
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
+ }
621
+ const files = branchFiles(projectPath);
622
+ const key = `${branch}\n${files.join("\n")}`;
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() } });
634
+ return null;
635
+ }
636
+ const change = branchChange(projectPath, branch);
637
+ const changeDir = path.join(projectPath, "lawbook", "changes", change);
638
+ const archived = findArchived(projectPath, change);
639
+ if (fs.existsSync(changeDir))
640
+ remeasure(projectPath, change, files);
641
+ else if (archived)
642
+ reopenIfGrown(projectPath, change, archived, files);
643
+ else {
644
+ const proposal = measureDiff(projectPath, files);
645
+ if ((proposal.level ?? 0) > 0)
646
+ scaffoldMeasured(projectPath, change, files, proposal);
647
+ }
648
+ const level = fs.existsSync(changeDir) ? confirmedLevel(projectPath, change) : 0;
649
+ const pending = level > 0 ? pendingArtifacts(projectPath, change) : [];
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 } : {}) });
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 } });
658
+ const why = readCeremonyRecord(projectPath, change)?.rationale ?? "";
659
+ return (`speclaw: this change measures level ${level}${why ? ` (${why})` : ""}. ` +
660
+ `In this same turn, before you stop, write briefly and truthfully:\n- ${pending.join("\n- ")}\n` +
661
+ `The Stop hook then runs the gates once.`);
662
+ }
663
+ catch {
664
+ return null;
665
+ }
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;
280
678
  /**
281
679
  * Change name for a branch: the part after the last `/`, kebab-cased
282
680
  * (`fix/compass-query-output` → `compass-query-output`).
@@ -388,8 +786,6 @@ export function shipOnStop(projectPath) {
388
786
  }
389
787
  // A change shipped by name on this branch keeps receiving the branch's later
390
788
  // work; the branch name would point at another (possibly archived) change.
391
- const change = marker && marker.branch === branch && marker.change
392
- ? marker.change
393
- : changeNameForBranch(branch);
789
+ const change = branchChange(projectPath, branch);
394
790
  return { skipped: null, change, result: shipChange(projectPath, change) };
395
791
  }
@@ -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.19",
3
+ "version": "2.0.21",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },