@esneiderbravo/speclaw 2.0.8 → 2.0.10

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 (41) hide show
  1. package/README.md +38 -9
  2. package/dist/cli/commands/index-build.js +2 -15
  3. package/dist/cli/commands/init.js +11 -3
  4. package/dist/cli/commands/lawbook.js +2 -0
  5. package/dist/cli/commands/laws.js +149 -19
  6. package/dist/cli/commands/query.js +12 -2
  7. package/dist/cli/commands/update.js +110 -24
  8. package/dist/cli/index.js +24 -66
  9. package/dist/cli/lib/help.js +489 -0
  10. package/dist/cli/lib/self-update.js +107 -0
  11. package/dist/cli/lib/update-check.js +17 -10
  12. package/dist/modules/compass/affected-config.js +5 -0
  13. package/dist/modules/compass/affected.js +344 -27
  14. package/dist/modules/compass/db.js +88 -16
  15. package/dist/modules/compass/diff-context.js +4 -2
  16. package/dist/modules/compass/explore-rich.js +14 -7
  17. package/dist/modules/compass/extract.js +163 -4
  18. package/dist/modules/compass/hotspots.js +11 -6
  19. package/dist/modules/compass/hybrid.js +9 -4
  20. package/dist/modules/compass/indexer.js +365 -95
  21. package/dist/modules/compass/map.js +2 -2
  22. package/dist/modules/compass/query.js +70 -24
  23. package/dist/modules/compass/register.js +1 -1
  24. package/dist/modules/compass/visualize.js +5 -3
  25. package/dist/modules/cortex/brief.js +3 -1
  26. package/dist/modules/cortex/harness.js +51 -5
  27. package/dist/modules/cortex/paths.js +39 -0
  28. package/dist/modules/cortex/status.js +4 -1
  29. package/dist/modules/foundation/assets/docs/compass.template.md +16 -0
  30. package/dist/modules/foundation/compile-laws.js +23 -6
  31. package/dist/modules/foundation/doctor.js +11 -1
  32. package/dist/modules/foundation/lock.js +224 -20
  33. package/dist/modules/foundation/scaffold.js +24 -8
  34. package/dist/modules/lawbook/assets/agents/archiver.md +5 -2
  35. package/dist/modules/lawbook/assets/skills/cortex/steps/02-dispatch-loop.md +4 -1
  36. package/dist/modules/lawbook/drift.js +2 -2
  37. package/dist/modules/lawbook/engine.js +24 -2
  38. package/dist/modules/lawbook/investigate.js +2 -1
  39. package/dist/shared/agents.js +56 -7
  40. package/dist/shared/output-budget.js +2 -0
  41. package/package.json +1 -1
package/README.md CHANGED
@@ -116,7 +116,7 @@ gh attestation verify <tarball> --owner esneiderbravo
116
116
  | Module | What it does |
117
117
  | :-- | :-- |
118
118
  | **Foundation** | The project's constitution: `LAWS.md` binding granular standards under `docs/standards/`, plus `CLAUDE.md` / `AGENTS.md`. **Enforced** via agent hooks (`speclaw check`), deterministic graph laws (`speclaw verify` / `laws verify`), multidialect compile (`laws compile`), and committed **`speclaw.lock`** digests + injection scan (`laws lock` / `accept` / `scan`). |
119
- | **Compass** | Local code graph (tree-sitter → `node:sqlite`): hybrid find (FTS5 + vectors + RRF + PageRank), impact, affected-tests, hotspots, coupling, visualize. Schema **10**. Refreshed silently at every Claude Code session start once built. No LLM — lives in `.speclaw/` (gitignored). |
119
+ | **Compass** | Local code graph (tree-sitter → `node:sqlite`): hybrid find (FTS5 + vectors + RRF + PageRank), impact, affected-tests, hotspots, coupling, visualize. Schema **11**. Refreshed silently at every Claude Code session start once built. No LLM — lives in `.speclaw/` (gitignored). |
120
120
  | **Lawbook** | Spec-driven workflow with **adaptive ceremony** (levels 0–3), EARS linting, requirement **coverage**, sealed **drift** anchors, bugfix + `lawbook_investigate`. Artifacts under `lawbook/`. |
121
121
  | **Cortex** | **One brain. Many agents.** The multi-agent loop: durable `harness.json`, MCP tool `cortex` (`status`/`start`/`advance`/`rework`/`brief`), CLI `speclaw cortex`, skill `/lawbook/cortex`. Dispatches explorer → planner → implementer → reviewer → tester → archiver. |
122
122
  | **Team** | Declare `team.owners` in `lawbook/config.yaml`; `speclaw owners --write` compiles a managed trailing block in `.github/CODEOWNERS` (GitHub: last match wins). Doctor checks the posture. CLI-only — no MCP tool. |
@@ -303,8 +303,27 @@ Create or refresh the committed lockfile (repo root, never under `.speclaw/`):
303
303
  speclaw laws lock
304
304
  speclaw laws scan
305
305
  speclaw laws accept AGENTS.md # interactive TTY only — never via MCP
306
+ speclaw laws lock --force # list drifted files, confirm, re-baseline — interactive TTY only
306
307
  ```
307
308
 
309
+ A lock refresh (`init`, `update`, `laws compile`, `laws lock`) never launders an
310
+ edit: a strict file (`CLAUDE.md`, `AGENTS.md`, compiled rules) that drifted from
311
+ the lock outside speclaw **keeps its locked digest**, and the command warns
312
+ `run speclaw laws accept <path>`, so `verify` keeps failing until a human
313
+ accepts it. Clean strict files, new strict files, and advisory files
314
+ (`LAWS.md`, `docs/standards/*`) are refreshed freely, and stale `accepted[]`
315
+ entries are pruned — an `accepted[]` entry lasts until the next refresh rewrites
316
+ that file, after which the audit trail lives in git history. `laws lock --force`
317
+ lists each drifted file with its locked and on-disk digests, asks for
318
+ confirmation (default No), then re-baselines them and records an `accepted[]`
319
+ entry for each (`--note` adds the reason); without a TTY, when declined, or when
320
+ any file or digest changed while the prompt was open, it exits 1 and leaves the
321
+ lock untouched. A `speclaw.lock` that exists but cannot be read (merge-conflict
322
+ markers, a newer `lockfileVersion`, or valid JSON with the wrong structure) is
323
+ never rebuilt: every refresh leaves it byte-identical and reports the error, and
324
+ the CLI (including `laws accept`) exits non-zero until it is repaired (or deleted
325
+ and re-created with `laws lock`).
326
+
308
327
  | Exit | Meaning |
309
328
  | :-- | :-- |
310
329
  | **0** | No findings at or above `--fail-on` (default `error`) |
@@ -335,16 +354,20 @@ review from Code Owners* when you declare `team.owners`.
335
354
  ## <img src="https://raw.githubusercontent.com/esneiderbravo/speclaw/main/brand/diamond.png" height="20" alt="◆" align="absmiddle">&nbsp; Staying up to date
336
355
 
337
356
  speclaw checks for new releases in the background (at most once a day) and nudges
338
- you when one lands. To bring a project up to date (migrations only — never runs
339
- `npm install -g`):
357
+ you when one lands. To bring a project up to date (never runs `npm install -g`):
340
358
 
341
359
  ```bash
342
360
  speclaw update
343
361
  ```
344
362
 
345
- If a newer binary is on npm, `update` prints an advisory (`current → latest`) and
346
- how to upgrade the binary separately (`npm i -g @esneiderbravo/speclaw@latest`, or
347
- prefer `npx @esneiderbravo/speclaw@latest update`), then still migrates the project.
363
+ `update` upgrades itself: when npm reports a newer release during the run, it
364
+ re-executes as `npx -y @esneiderbravo/speclaw@<latest> update` with the same
365
+ flags (also in CI), so the latest release applies its own migrations, and exits
366
+ with that run's code. It migrates with the installed binary instead when you opt
367
+ out (`--no-self-update` or `SPECLAW_NO_SELF_UPDATE=1`), when the registry is
368
+ unreachable (a cached version only), or when `npx` cannot be started; it then
369
+ prints how to upgrade the binary yourself (`npm i -g @esneiderbravo/speclaw@latest`).
370
+ `init` only advises — prefer `npx @esneiderbravo/speclaw@latest init`.
348
371
 
349
372
  `update` brings the current project up to date without a re-init, splitting files
350
373
  by who owns them:
@@ -354,11 +377,17 @@ by who owns them:
354
377
  - **Personalized files** (`CLAUDE.md`, `AGENTS.md`, `LAWS.md`, `docs/standards/*`,
355
378
  `docs/compass.md`, `lawbook/config.yaml`) are **never auto-edited** — `update`
356
379
  prints a prompt for the agent you're using.
357
- - **`speclaw.lock`** and the CODEOWNERS owners block are refreshed when configured.
358
-
359
- - `speclaw update --check` — report version status only; do not migrate.
380
+ - **`speclaw.lock`** and the CODEOWNERS owners block are refreshed when configured
381
+ (a drifted strict file keeps its locked digest — see `laws accept` above).
382
+ - **Agent MCP entry** — `init`, `agent add`, and `update` pin it to the installed
383
+ version (`npx -y @esneiderbravo/speclaw@<version> mcp`); `update` re-pins the
384
+ stock entry and keeps a custom one (e.g. a local `node` path).
385
+
386
+ - `speclaw update --check` — report version status only; do not re-run or migrate.
387
+ - `speclaw update --no-self-update` — migrate with the installed binary.
360
388
  - `speclaw update --migrate-only` — silent no-op alias of the default (compat).
361
389
  - `NO_UPDATE_NOTIFIER=1` — silence the reminder.
390
+ - `speclaw <command> --help` (or `-h`) — that command's usage; runs nothing.
362
391
 
363
392
  <br/>
364
393
 
@@ -1,26 +1,13 @@
1
1
  import { buildIndex } from "../../modules/compass/indexer.js";
2
2
  import { startWatch } from "../../modules/compass/watcher.js";
3
3
  import { recordCompassCall } from "../../shared/compass-calls.js";
4
+ import { helpFor } from "../lib/help.js";
4
5
  import { ui, renderProgress, clearProgress } from "../lib/ui.js";
5
- /** Usage text for `speclaw index --help`. */
6
- export const INDEX_HELP = `Usage: speclaw index [options]
7
-
8
- (Re)build the local Compass code graph for the current directory. Runs are
9
- incremental: unchanged files are skipped, and an unchanged project skips the
10
- global post-processing.
11
-
12
- Options
13
- --force Re-extract every file
14
- --prune Also evict embedding-cache rows unused for --retention days
15
- --retention <days> Retention window for --prune (default 30)
16
- --max-cache-mb <mb> Embedding-cache size cap (default 256)
17
- --json Print the index statistics as JSON
18
- `;
19
6
  /** (Re)build the Compass code graph for the cwd, showing progress and final stats. */
20
7
  export async function runIndex(flags) {
21
8
  const cwd = process.cwd();
22
9
  if (flags.help || flags.h) {
23
- process.stdout.write(INDEX_HELP);
10
+ process.stdout.write(helpFor("index") ?? "");
24
11
  return;
25
12
  }
26
13
  recordCompassCall(cwd, "compass_index");
@@ -1,7 +1,7 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
3
  import * as clack from "@clack/prompts";
4
- import { scaffold } from "../../modules/foundation/scaffold.js";
4
+ import { lockPreservedWarning, scaffold } from "../../modules/foundation/scaffold.js";
5
5
  import { specInit } from "../../modules/lawbook/engine.js";
6
6
  import { buildIndex } from "../../modules/compass/indexer.js";
7
7
  import { AGENTS, agentById } from "../../shared/agents.js";
@@ -45,8 +45,9 @@ export async function runInit(flags) {
45
45
  const upd = await checkForUpdates();
46
46
  if (upd.updateAvailable && upd.latest) {
47
47
  ui.warn(`You're on ${c.muted(upd.current)} — latest is ${c.bold(c.cyan(upd.latest))}.`);
48
- ui.info(`Recommended: refresh the binary with ${ui.code("npx @esneiderbravo/speclaw@latest init")} ` +
49
- `(or ${ui.code("npm i -g @esneiderbravo/speclaw@latest")}), then run ${ui.code("speclaw init")} again.`);
48
+ ui.info(`Recommended: run ${ui.code("npx @esneiderbravo/speclaw@latest init")} instead ` +
49
+ `(or ${ui.code("npm i -g @esneiderbravo/speclaw@latest")}, then ${ui.code("speclaw init")} again). ` +
50
+ `Later, ${ui.code("speclaw update")} upgrades itself through npx.`);
50
51
  ui.plain();
51
52
  }
52
53
  if (interactive) {
@@ -103,6 +104,13 @@ export async function runInit(flags) {
103
104
  ui.ok(`${PACK_LABELS[p] ?? p + " pack"}`);
104
105
  specInit(cwd);
105
106
  ui.ok(`Lawbook workspace ${c.muted("— lawbook/")}`);
107
+ for (const rel of report.lockPreserved)
108
+ ui.warn(lockPreservedWarning(rel));
109
+ if (report.lockError) {
110
+ // An existing lock that cannot be read is left untouched, never rebuilt.
111
+ ui.err(`${report.lockError} — speclaw.lock was left unchanged; repair it and re-run.`);
112
+ process.exitCode = 1;
113
+ }
106
114
  if (reinit && report.skipped.length) {
107
115
  ui.info(`${report.written.length} added · ${c.cream(String(report.skipped.length))} preserved untouched`);
108
116
  }
@@ -156,6 +156,8 @@ export async function runSpec(flags) {
156
156
  else
157
157
  ui.info(msg);
158
158
  }
159
+ if (r.harnessCompleted)
160
+ ui.ok("Cortex harness completed (stage done)");
159
161
  return;
160
162
  }
161
163
  case "harness": {
@@ -5,7 +5,7 @@ import { verifyLaws } from "../../modules/foundation/verify.js";
5
5
  import { compileLaws } from "../../modules/foundation/compile-laws.js";
6
6
  import { importRulesFrom } from "../../modules/foundation/import-rules.js";
7
7
  import { acceptLockPath, isInteractiveTty, refreshLockfile, verifyIntegrity, } from "../../modules/foundation/integrity.js";
8
- import { digestText, prepareIntegrityText, readLockfile } from "../../modules/foundation/lock.js";
8
+ import { digestText, driftedStrictPaths, LockChangedError, lockPreservedWarning, onDiskDigest, prepareIntegrityText, readLockfile, } from "../../modules/foundation/lock.js";
9
9
  import fs from "node:fs";
10
10
  import path from "node:path";
11
11
  import os from "node:os";
@@ -27,6 +27,8 @@ export async function runLaws(flags) {
27
27
  });
28
28
  if (flags.json) {
29
29
  console.log(JSON.stringify(report, null, 2));
30
+ if (report.failed.length || report.lockError)
31
+ process.exit(1);
30
32
  return;
31
33
  }
32
34
  ui.heading("speclaw laws compile");
@@ -35,7 +37,11 @@ export async function runLaws(flags) {
35
37
  (report.failed.length ? ` · ${report.failed.length} failed` : ""));
36
38
  for (const f of report.failed)
37
39
  ui.warn(`${f.path}: ${f.error}`);
38
- if (report.failed.length)
40
+ for (const rel of report.lockPreserved)
41
+ ui.warn(lockPreservedWarning(rel));
42
+ if (report.lockError)
43
+ ui.err(lockUnreadableMessage(report.lockError));
44
+ if (report.failed.length || report.lockError)
39
45
  process.exit(1);
40
46
  return;
41
47
  }
@@ -63,14 +69,7 @@ export async function runLaws(flags) {
63
69
  return;
64
70
  }
65
71
  if (sub === "lock") {
66
- const lock = refreshLockfile(process.cwd());
67
- if (flags.json) {
68
- console.log(JSON.stringify(lock, null, 2));
69
- return;
70
- }
71
- ui.heading("speclaw laws lock");
72
- ui.ok(`Wrote speclaw.lock — ${Object.keys(lock.files).length} file(s), ` +
73
- `${Object.keys(lock.symlinks).length} symlink(s), root ${lock.root.slice(0, 19)}…`);
72
+ await runLock(flags);
74
73
  return;
75
74
  }
76
75
  if (sub === "scan") {
@@ -132,9 +131,124 @@ export async function runLaws(flags) {
132
131
  if (report.findings.length === 0 && summary.evaluated > 0)
133
132
  ui.ok("No violations.");
134
133
  }
134
+ /**
135
+ * Injectable TTY check shared by `laws accept` and `laws lock --force`.
136
+ * Test-only seam: product code must never reassign it.
137
+ */
138
+ export const lawsTty = { isInteractive: isInteractiveTty };
139
+ /**
140
+ * Injectable yes/no prompt shared by `laws accept` and `laws lock --force`.
141
+ * Defaults to No; a cancelled prompt counts as No. Test-only seam: product
142
+ * code must never reassign it.
143
+ */
144
+ export const lawsConfirm = {
145
+ confirm: async (message) => {
146
+ const answer = await clack.confirm({ message, initialValue: false });
147
+ return !clack.isCancel(answer) && answer === true;
148
+ },
149
+ };
150
+ /** The error shown when `speclaw.lock` exists but cannot be read. */
151
+ function lockUnreadableMessage(detail) {
152
+ return (`${detail} — speclaw.lock was left unchanged. Repair it (resolve merge markers, ` +
153
+ "or upgrade speclaw for a newer lockfileVersion); to start over, delete it and " +
154
+ "run `speclaw laws lock`.");
155
+ }
156
+ /** The `by` recorded in `accepted[]` entries for a human acceptance. */
157
+ function acceptedBy() {
158
+ return os.userInfo().username || process.env.USER || "unknown";
159
+ }
160
+ /**
161
+ * `speclaw laws lock [--force] [--note <text>]`: create or refresh
162
+ * `speclaw.lock`. A drifted strict file keeps its locked digest and is warned
163
+ * about. `--force` re-baselines drifted files and records an `accepted[]` entry
164
+ * for each; it requires an interactive TTY (without one it exits 1 before
165
+ * touching the lock) and an explicit confirmation after listing each drifted
166
+ * path with its locked and on-disk digests (No or cancel exits 1, lock
167
+ * unchanged). If the drifted set, a locked digest, or an on-disk digest
168
+ * changed while the prompt was open, nothing is written and it exits 1. An
169
+ * existing lock that cannot be read is never overwritten: the
170
+ * command exits 1.
171
+ *
172
+ * @param flags - Parsed CLI flags (`--force`, `--note`, `--json`).
173
+ */
174
+ // Covers: req~laws-accept-human~1, req~laws-integrity-cli~1
175
+ export async function runLock(flags) {
176
+ const cwd = process.cwd();
177
+ const force = Boolean(flags.force);
178
+ if (force && !lawsTty.isInteractive()) {
179
+ ui.err("`speclaw laws lock --force` requires an interactive TTY — re-baselining is human-only.");
180
+ process.exit(1);
181
+ }
182
+ let prev;
183
+ let drifted;
184
+ try {
185
+ prev = readLockfile(cwd);
186
+ drifted = driftedStrictPaths(cwd, prev);
187
+ }
188
+ catch (err) {
189
+ ui.err(lockUnreadableMessage(err.message));
190
+ process.exitCode = 1;
191
+ return;
192
+ }
193
+ let rebaseline;
194
+ if (force && drifted.length) {
195
+ ui.heading("speclaw laws lock --force");
196
+ const confirmed = [];
197
+ for (const rel of drifted) {
198
+ const locked = prev.files[rel].digest;
199
+ const actual = onDiskDigest(cwd, rel) ?? "(missing)";
200
+ confirmed.push({ path: rel, locked, actual });
201
+ ui.info(rel);
202
+ ui.plain(` expected ${locked}`);
203
+ ui.plain(` actual ${actual}`);
204
+ }
205
+ const n = drifted.length;
206
+ const approved = await lawsConfirm.confirm(`Re-baseline ${n} drifted strict file${n === 1 ? "" : "s"} in speclaw.lock?`);
207
+ if (!approved) {
208
+ ui.warn("Re-baseline cancelled — lockfile unchanged.");
209
+ process.exitCode = 1;
210
+ return;
211
+ }
212
+ const note = typeof flags.note === "string" && flags.note.trim() ? flags.note.trim() : undefined;
213
+ rebaseline = { by: acceptedBy(), note, confirmed };
214
+ }
215
+ let result;
216
+ try {
217
+ // With `confirmed`, the refresh re-checks the drift and digests the human
218
+ // approved against the snapshot it writes, and throws on any difference.
219
+ result = refreshLockfile(cwd, { drifted, rebaseline });
220
+ }
221
+ catch (err) {
222
+ if (err instanceof LockChangedError) {
223
+ ui.err(`${err.message} — speclaw.lock was not written. Re-run ` +
224
+ "`speclaw laws lock --force` to review the current digests.");
225
+ }
226
+ else {
227
+ ui.err(lockUnreadableMessage(err.message));
228
+ }
229
+ process.exitCode = 1;
230
+ return;
231
+ }
232
+ const { lock } = result;
233
+ if (flags.json) {
234
+ console.log(JSON.stringify(lock, null, 2));
235
+ for (const rel of result.preserved)
236
+ process.stderr.write(lockPreservedWarning(rel) + "\n");
237
+ return;
238
+ }
239
+ ui.heading("speclaw laws lock");
240
+ ui.ok(`Wrote speclaw.lock — ${Object.keys(lock.files).length} file(s), ` +
241
+ `${Object.keys(lock.symlinks).length} symlink(s), root ${lock.root.slice(0, 19)}…`);
242
+ for (const rel of result.preserved)
243
+ ui.warn(lockPreservedWarning(rel));
244
+ for (const rel of result.rebaselined)
245
+ ui.warn(`${rel} re-baselined by --force (recorded in accepted[]).`);
246
+ if (result.pruned)
247
+ ui.info(`Pruned ${result.pruned} stale accepted entr${result.pruned === 1 ? "y" : "ies"}.`);
248
+ }
135
249
  async function runAccept(flags) {
136
250
  const cwd = process.cwd();
137
- if (!isInteractiveTty()) {
251
+ if (!lawsTty.isInteractive()) {
138
252
  ui.err("`speclaw laws accept` requires an interactive TTY — digest acceptance is human-only.");
139
253
  process.exit(1);
140
254
  }
@@ -143,7 +257,15 @@ async function runAccept(flags) {
143
257
  ui.err(`Usage: ${ui.code("speclaw laws accept <path>")}`);
144
258
  process.exit(1);
145
259
  }
146
- const lock = readLockfile(cwd);
260
+ let lock;
261
+ try {
262
+ lock = readLockfile(cwd);
263
+ }
264
+ catch (err) {
265
+ ui.err(lockUnreadableMessage(err.message));
266
+ process.exitCode = 1;
267
+ return;
268
+ }
147
269
  if (!lock) {
148
270
  ui.err("No speclaw.lock — run `speclaw laws lock` first.");
149
271
  process.exit(1);
@@ -166,11 +288,8 @@ async function runAccept(flags) {
166
288
  return;
167
289
  }
168
290
  const noteFlag = typeof flags.note === "string" ? flags.note : undefined;
169
- const confirmed = await clack.confirm({
170
- message: `Update speclaw.lock digest for ${rel}?`,
171
- initialValue: false,
172
- });
173
- if (clack.isCancel(confirmed) || !confirmed) {
291
+ const confirmed = await lawsConfirm.confirm(`Update speclaw.lock digest for ${rel}?`);
292
+ if (!confirmed) {
174
293
  ui.warn("Accept cancelled — lockfile unchanged.");
175
294
  process.exit(1);
176
295
  }
@@ -183,7 +302,18 @@ async function runAccept(flags) {
183
302
  if (!clack.isCancel(n) && n.trim())
184
303
  note = n.trim();
185
304
  }
186
- const by = os.userInfo().username || process.env.USER || "unknown";
187
- acceptLockPath(cwd, rel, { by, note });
305
+ const by = acceptedBy();
306
+ try {
307
+ acceptLockPath(cwd, rel, { by, note });
308
+ }
309
+ catch (err) {
310
+ // The lock is re-read here; it may have become unreadable during the prompt.
311
+ // Only lock read errors get the repair advice; others (a scan-only path)
312
+ // are reported as-is.
313
+ const detail = err.message;
314
+ ui.err(detail.startsWith("speclaw.lock:") ? lockUnreadableMessage(detail) : detail);
315
+ process.exitCode = 1;
316
+ return;
317
+ }
188
318
  ui.ok(`Accepted ${rel} — lock updated (by ${by}).`);
189
319
  }
@@ -47,8 +47,11 @@ export async function runQuery(cmd, flags) {
47
47
  console.log(s.source);
48
48
  ui.heading("Callees");
49
49
  r.callees?.forEach((c) => ui.info(`${c.name}${c.file ? ` (${c.file}:${c.line})` : ""}`));
50
+ if (r.unresolvedCallees && r.unresolvedCallees.count > 0) {
51
+ ui.info(`+ ${r.unresolvedCallees.count} unresolved call(s): ${r.unresolvedCallees.sample.join(", ")}`);
52
+ }
50
53
  ui.heading("Callers");
51
- r.callers?.forEach((c) => ui.info(`${c.name} (${c.file}:${c.line})`));
54
+ r.callers?.forEach((c) => ui.info(`${c.kind === "file" ? "(file) " : ""}${c.name} (${c.file}:${c.line})`));
52
55
  return;
53
56
  }
54
57
  case "search":
@@ -147,7 +150,14 @@ export async function runQuery(cmd, flags) {
147
150
  ui.info(result.reason);
148
151
  result.tests.forEach((t) => ui.info(t.file));
149
152
  ui.heading("Command");
150
- console.log(result.command);
153
+ // Covers: req~affected-test-selection~1
154
+ if (result.command === null) {
155
+ console.log(`no command: ${result.commandReason}`);
156
+ }
157
+ else {
158
+ console.log(result.command);
159
+ ui.info(result.commandReason);
160
+ }
151
161
  result.warnings.forEach((w) => ui.warn(w));
152
162
  return;
153
163
  }
@@ -2,8 +2,9 @@ import fs from "node:fs";
2
2
  import path from "node:path";
3
3
  import { ui, c } from "../lib/ui.js";
4
4
  import { checkForUpdates, isNewer } from "../lib/update-check.js";
5
+ import { isSafeVersion, safeForwardArgs, selfUpdate } from "../lib/self-update.js";
5
6
  import { pkgName, pkgVersion } from "../../shared/version.js";
6
- import { scaffold } from "../../modules/foundation/scaffold.js";
7
+ import { lockPreservedWarning, scaffold } from "../../modules/foundation/scaffold.js";
7
8
  import { PERSONALIZED } from "../../modules/foundation/ownership.js";
8
9
  import { detectConfiguredAgents } from "../../shared/agents.js";
9
10
  import { readManifest } from "../../shared/manifest.js";
@@ -11,15 +12,16 @@ import { loadPacks } from "../../modules/tools/packs.js";
11
12
  import { detectProjectName } from "./init.js";
12
13
  import { reportTrackedLocalContent } from "../lib/untrack.js";
13
14
  /**
14
- * How to upgrade the installed binary without this command spawning `npm install -g`.
15
+ * How to upgrade the installed binary when `update` migrates with the running
16
+ * one (opted out, offline, or `npx` unavailable). Never spawns `npm install -g`.
15
17
  *
16
18
  * @param name - Package name on npm (usually `pkgName()`).
17
19
  * @returns One-line advisory for the terminal.
18
20
  */
19
21
  export function binaryUpgradeHint(name) {
20
- return (`Upgrade the binary separately with ${ui.code(`npm i -g ${name}@latest`)} ` +
21
- `(or prefer ${ui.code(`npx ${name}@latest update`)}). ` +
22
- `This command only migrates the project.`);
22
+ return (`This run migrates with the installed binary. Normally ${ui.code("speclaw update")} ` +
23
+ `upgrades itself through npx; to upgrade the binary yourself run ` +
24
+ `${ui.code(`npm i -g ${name}@latest`)} (or ${ui.code(`npx ${name}@latest update`)}).`);
23
25
  }
24
26
  // The first migration must be tagged at the version that introduces this
25
27
  // mechanism (0.1.12): `isNewer` is strict, so an entry tagged at an already-
@@ -216,17 +218,58 @@ const MIGRATIONS = [
216
218
  "installed binary is stale so migrations match the latest package.\n" +
217
219
  "- Preserve all project-specific wording; only apply these speclaw-authored changes.",
218
220
  },
221
+ {
222
+ version: "2.0.9",
223
+ describe: "speclaw update re-executes itself at the latest version; MCP entry pinned; " +
224
+ "lock refresh preserves drift",
225
+ agentPrompt: "- `speclaw update` now upgrades itself: when npm reports a newer release during the " +
226
+ "run, it re-executes as `npx -y @esneiderbravo/speclaw@<latest> update` (also in CI) and " +
227
+ "that release applies its own migrations. Opt out with `--no-self-update` or " +
228
+ "`SPECLAW_NO_SELF_UPDATE=1`. If your docs tell users to upgrade the binary separately " +
229
+ "before `speclaw update`, drop that step.\n" +
230
+ "- The agent MCP entry is pinned to the installed version " +
231
+ "(`npx -y @esneiderbravo/speclaw@<version> mcp`); `speclaw update` re-pins a stock entry " +
232
+ "and keeps a custom one.\n" +
233
+ "- A lock refresh (init/update/`laws compile`/`laws lock`) no longer re-baselines a " +
234
+ "strict rule file (CLAUDE.md, AGENTS.md, compiled rules) edited outside speclaw: it keeps " +
235
+ "the locked digest and warns `run speclaw laws accept <path>`. `speclaw laws lock --force` " +
236
+ "re-baselines on an interactive TTY only. Mention this where the project documents " +
237
+ "`speclaw.lock`.\n" +
238
+ "- Preserve all project-specific wording; only apply these speclaw-authored changes.",
239
+ },
240
+ {
241
+ version: "2.0.10",
242
+ describe: "Compass schema 11 — truthful callers, callees, and affected-test commands",
243
+ agentPrompt: "- Compass is now **schema 11**: the first `speclaw index` after upgrading migrates " +
244
+ "10→11 and re-extracts every file once (embeddings are reused). Where the project " +
245
+ 'mentions "Schema **10**" or "Compass (schema 10)", change it to schema 11 ' +
246
+ "(10→11 forces a reindex; embeddings reused).\n" +
247
+ "- speclaw 2.0.9 or older (including an MCP entry pinned at `@2.0.9` or a stale " +
248
+ "global CLI) treats a schema-11 index as incompatible and rebuilds it from scratch, " +
249
+ "dropping the embedding cache. Where the project documents the index or the MCP entry, " +
250
+ "add: run `speclaw update` so the pinned MCP entry moves to 2.0.10; an older speclaw " +
251
+ "opening this index rebuilds it from scratch.\n" +
252
+ "- Affected-test `command` may now be `null` (with `commandReason`) when no test is " +
253
+ "reachable, and `commands[]` lists one `{ cwd, command, files }` per package. If the " +
254
+ "project's docs or scripts run `command` blindly, make them handle `null`.\n" +
255
+ "- Preserve all project-specific wording; only apply these speclaw-authored changes.",
256
+ },
219
257
  ];
220
258
  /**
221
- * Bring the current project up to date without a full re-init: check whether a
222
- * newer binary exists on npm (advisory only — never runs `npm install -g`), then
223
- * additively apply any new standards, skills, commands, and feature steps this
224
- * project is missing (existing files untouched).
259
+ * Bring the current project up to date without a full re-init. When the npm
260
+ * registry, queried during this run, reports a newer release, re-execute as
261
+ * `npx -y <pkg>@<latest> update <flags>` and exit with the child's code — the
262
+ * latest release then applies its own migrations, never this stale binary.
263
+ * Otherwise (already latest, opted out, offline cache only, loop guard set, or
264
+ * `npx` unavailable) additively apply the project migrations in process.
225
265
  *
226
- * @param flags - `--check` reports version status only (no migrate);
227
- * `--migrate-only` is a silent no-op alias of the default (compat).
228
- * @param hooks - Optional test seams for the version check and migrate path.
266
+ * @param flags - `--check` reports version status only (no re-run, no migrate);
267
+ * `--no-self-update` migrates with this binary; `--migrate-only` is a silent
268
+ * no-op alias of the default (compat).
269
+ * @param hooks - Optional test seams for the version check, the spawn, the
270
+ * environment, and the migrate path.
229
271
  */
272
+ // Covers: req~update-self-update~1
230
273
  export async function runUpdate(flags, hooks = {}) {
231
274
  const cwd = process.cwd();
232
275
  // `--migrate-only` is accepted for compat but is a no-op alias of default
@@ -235,27 +278,61 @@ export async function runUpdate(flags, hooks = {}) {
235
278
  const backup = Boolean(flags.backup);
236
279
  const check = hooks.checkForUpdates ?? checkForUpdates;
237
280
  const migrate = hooks.applyProjectMigrations ?? applyProjectMigrations;
281
+ const spawnSelf = hooks.selfUpdate ?? selfUpdate;
282
+ const env = hooks.env ?? process.env;
283
+ const runMigrations = () => migrate(cwd, backup, flags.minimal ? true : undefined);
238
284
  ui.step("Checking for updates");
239
- const { current, latest, updateAvailable } = await check({ force: true });
285
+ const { current, latest, updateAvailable, fresh } = await check({ force: true });
240
286
  if (!latest) {
241
287
  ui.warn("Could not reach the npm registry — skipping the version check.");
288
+ if (!checkOnly)
289
+ runMigrations();
290
+ return;
291
+ }
292
+ if (!updateAvailable) {
293
+ ui.ok(`Already on the latest version (${current}).`);
294
+ if (!checkOnly)
295
+ runMigrations();
296
+ return;
297
+ }
298
+ ui.info(`${c.muted(current)} ${c.muted("→")} ${c.cyan(latest)}`);
299
+ if (checkOnly) {
300
+ ui.info(`Run ${ui.code("speclaw update")} — it upgrades itself to ${latest} through npx and ` +
301
+ `migrates this project.`);
302
+ return;
242
303
  }
243
- else if (updateAvailable) {
244
- ui.info(`${c.muted(current)} ${c.muted("→")} ${c.cyan(latest)}`);
245
- ui.info(binaryUpgradeHint(pkgName()));
246
- if (checkOnly) {
247
- ui.info(`Run ${ui.code("speclaw update")} to apply project migrations with the binary you have.`);
304
+ // parseFlags turns `--no-self-update` into `flags["no-self-update"]`; accept
305
+ // the `self-update: false` spelling too in case a caller builds flags by hand.
306
+ const optedOut = Boolean(flags["no-self-update"]) ||
307
+ flags["self-update"] === false ||
308
+ Boolean(env.SPECLAW_NO_SELF_UPDATE);
309
+ const why = optedOut
310
+ ? "self-update is turned off"
311
+ : env.SPECLAW_SELF_UPDATED
312
+ ? "already re-executed once"
313
+ : !fresh
314
+ ? "the registry was unreachable (cached version only)"
315
+ : !isSafeVersion(latest)
316
+ ? `the registry reported an invalid version ${JSON.stringify(latest)}`
317
+ : null;
318
+ if (why === null) {
319
+ const { forward, dropped } = safeForwardArgs(hooks.argv ?? process.argv.slice(3));
320
+ if (dropped.length)
321
+ ui.warn(`Not forwarding unsafe argument(s): ${dropped.join(" ")}`);
322
+ ui.step(`Re-running ${ui.code(`npx -y ${pkgName()}@${latest} update`)}`);
323
+ const outcome = await spawnSelf({ pkg: pkgName(), version: latest, args: forward, env });
324
+ if (outcome.kind === "ran") {
325
+ // The child applied the migrations; the parent never migrates as well.
326
+ process.exitCode = outcome.code;
248
327
  return;
249
328
  }
329
+ ui.warn(`Could not start npx (${outcome.reason}) — migrating with the installed binary.`);
250
330
  }
251
331
  else {
252
- ui.ok(`Already on the latest version (${current}).`);
253
- if (checkOnly)
254
- return;
332
+ ui.info(`Not re-running at ${latest}: ${why}.`);
255
333
  }
256
- if (checkOnly)
257
- return;
258
- migrate(cwd, backup, flags.minimal ? true : undefined);
334
+ ui.info(binaryUpgradeHint(pkgName()));
335
+ runMigrations();
259
336
  }
260
337
  /**
261
338
  * Additively apply the current version's content and feature steps to a project.
@@ -310,6 +387,15 @@ function applyProjectMigrations(cwd, backup, minimal) {
310
387
  ui.warn(`${c.cream(rel)} had local edits — overwritten with the current version. ` +
311
388
  `Recover from git, or re-run with ${ui.code("--backup")} to keep a .bak.`);
312
389
  }
390
+ // A strict rule file edited outside the pipeline keeps its locked digest;
391
+ // only a human `speclaw laws accept` moves it.
392
+ for (const rel of report.lockPreserved)
393
+ ui.warn(lockPreservedWarning(rel));
394
+ if (report.lockError) {
395
+ // An existing lock that cannot be read is left untouched, never rebuilt.
396
+ ui.err(`${report.lockError} — speclaw.lock was left unchanged; repair it and re-run.`);
397
+ process.exitCode = 1;
398
+ }
313
399
  // A project several releases behind jumps straight to @latest, so apply EVERY
314
400
  // migration newer than its recorded version — not just the next one — oldest
315
401
  // first (sorted, so array order can't matter). Entries are cumulative: never