@esneiderbravo/speclaw 2.0.19 → 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 (30) 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/modules/compass/explore-rich.js +72 -0
  5. package/dist/modules/compass/indexer.js +2 -0
  6. package/dist/modules/compass/register.js +10 -8
  7. package/dist/modules/cortex/register.js +1 -1
  8. package/dist/modules/foundation/assets/AGENTS.template.md +6 -3
  9. package/dist/modules/foundation/assets/CLAUDE.template.md +6 -3
  10. package/dist/modules/foundation/check.js +24 -1
  11. package/dist/modules/foundation/compass-nudge.js +69 -3
  12. package/dist/modules/foundation/doctor.js +1 -1
  13. package/dist/modules/foundation/hooks.js +20 -7
  14. package/dist/modules/foundation/register-core.js +12 -3
  15. package/dist/modules/lawbook/assets/skills/cortex/SKILL.md +11 -11
  16. package/dist/modules/lawbook/assets/skills/quick/steps/02-implement.md +2 -1
  17. package/dist/modules/lawbook/assets/skills/test/steps/01-quality-gates.md +3 -1
  18. package/dist/modules/lawbook/engine.js +2 -2
  19. package/dist/modules/lawbook/investigate.js +85 -4
  20. package/dist/modules/lawbook/levels.js +9 -3
  21. package/dist/modules/lawbook/quick.js +5 -2
  22. package/dist/modules/lawbook/register.js +7 -5
  23. package/dist/modules/lawbook/scaffold-change.js +17 -11
  24. package/dist/modules/lawbook/ship.js +328 -24
  25. package/dist/modules/lawbook/stack-parse.js +21 -5
  26. package/dist/server.js +15 -1
  27. package/dist/shared/exposure.js +4 -7
  28. package/dist/shared/index-stats.js +40 -0
  29. package/dist/shared/mcp.js +3 -2
  30. 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
@@ -157,14 +158,201 @@ function findArchived(projectPath, name) {
157
158
  .pop();
158
159
  return hit ? path.join("lawbook", "changes", "archive", hit) : null;
159
160
  }
161
+ const RECORD_STUB = "<!-- 2–5 lines: what and why. -->";
160
162
  function fillRecord(recordPath, summary) {
161
163
  if (!fs.existsSync(recordPath))
162
164
  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);
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;
168
356
  }
169
357
  /**
170
358
  * Record the test verdict derived from gate exit codes. Ship never records a
@@ -191,12 +379,13 @@ function recordGateVerdict(projectPath, change) {
191
379
  }
192
380
  }
193
381
  /**
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.
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.
200
389
  *
201
390
  * @param projectPath - Project root with `lawbook/`.
202
391
  * @param name - Change name (kebab-case).
@@ -210,16 +399,46 @@ export function shipChange(projectPath, name, opts = {}) {
210
399
  const date = opts.date ?? new Date().toISOString().slice(0, 10);
211
400
  const changeDir = path.join(projectPath, "lawbook", "changes", name);
212
401
  const files = branchFiles(projectPath);
213
- const archived = findArchived(projectPath, name);
214
- if (!fs.existsSync(changeDir) && !archived) {
215
- 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;
216
405
  }
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);
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);
222
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
+ }
223
442
  const commands = opts.gates ?? cfg.gates ?? detectGates(projectPath);
224
443
  const gates = [];
225
444
  for (const command of commands) {
@@ -265,6 +484,7 @@ export function shipChange(projectPath, name, opts = {}) {
265
484
  gates,
266
485
  files,
267
486
  report: reportRel,
487
+ pending,
268
488
  archivedTo,
269
489
  next,
270
490
  timings: {
@@ -277,6 +497,92 @@ export function shipChange(projectPath, name, opts = {}) {
277
497
  },
278
498
  };
279
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
+ }
280
586
  /**
281
587
  * Change name for a branch: the part after the last `/`, kebab-cased
282
588
  * (`fix/compass-query-output` → `compass-query-output`).
@@ -388,8 +694,6 @@ export function shipOnStop(projectPath) {
388
694
  }
389
695
  // A change shipped by name on this branch keeps receiving the branch's later
390
696
  // 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);
697
+ const change = branchChange(projectPath, branch);
394
698
  return { skipped: null, change, result: shipChange(projectPath, change) };
395
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.19",
3
+ "version": "2.0.20",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },