@esneiderbravo/speclaw 2.0.8 → 2.0.9
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.
- package/README.md +37 -8
- package/dist/cli/commands/index-build.js +2 -15
- package/dist/cli/commands/init.js +11 -3
- package/dist/cli/commands/lawbook.js +2 -0
- package/dist/cli/commands/laws.js +149 -19
- package/dist/cli/commands/update.js +93 -24
- package/dist/cli/index.js +24 -66
- package/dist/cli/lib/help.js +489 -0
- package/dist/cli/lib/self-update.js +107 -0
- package/dist/cli/lib/update-check.js +17 -10
- package/dist/modules/cortex/brief.js +3 -1
- package/dist/modules/cortex/harness.js +51 -5
- package/dist/modules/cortex/paths.js +39 -0
- package/dist/modules/cortex/status.js +4 -1
- package/dist/modules/foundation/compile-laws.js +23 -6
- package/dist/modules/foundation/doctor.js +11 -1
- package/dist/modules/foundation/lock.js +224 -20
- package/dist/modules/foundation/scaffold.js +24 -8
- package/dist/modules/lawbook/assets/agents/archiver.md +5 -2
- package/dist/modules/lawbook/assets/skills/cortex/steps/02-dispatch-loop.md +4 -1
- package/dist/modules/lawbook/engine.js +24 -2
- package/dist/shared/agents.js +56 -7
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -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"> 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 (
|
|
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
|
-
|
|
346
|
-
|
|
347
|
-
|
|
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
|
-
-
|
|
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(
|
|
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:
|
|
49
|
-
`(or ${ui.code("npm i -g @esneiderbravo/speclaw@latest")}
|
|
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
|
}
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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 (!
|
|
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
|
-
|
|
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
|
|
170
|
-
|
|
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 =
|
|
187
|
-
|
|
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
|
}
|
|
@@ -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
|
|
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 (`
|
|
21
|
-
`
|
|
22
|
-
`
|
|
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,41 @@ 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
|
+
},
|
|
219
240
|
];
|
|
220
241
|
/**
|
|
221
|
-
* Bring the current project up to date without a full re-init
|
|
222
|
-
*
|
|
223
|
-
*
|
|
224
|
-
*
|
|
242
|
+
* Bring the current project up to date without a full re-init. When the npm
|
|
243
|
+
* registry, queried during this run, reports a newer release, re-execute as
|
|
244
|
+
* `npx -y <pkg>@<latest> update <flags>` and exit with the child's code — the
|
|
245
|
+
* latest release then applies its own migrations, never this stale binary.
|
|
246
|
+
* Otherwise (already latest, opted out, offline cache only, loop guard set, or
|
|
247
|
+
* `npx` unavailable) additively apply the project migrations in process.
|
|
225
248
|
*
|
|
226
|
-
* @param flags - `--check` reports version status only (no migrate);
|
|
227
|
-
* `--migrate-only` is a silent
|
|
228
|
-
*
|
|
249
|
+
* @param flags - `--check` reports version status only (no re-run, no migrate);
|
|
250
|
+
* `--no-self-update` migrates with this binary; `--migrate-only` is a silent
|
|
251
|
+
* no-op alias of the default (compat).
|
|
252
|
+
* @param hooks - Optional test seams for the version check, the spawn, the
|
|
253
|
+
* environment, and the migrate path.
|
|
229
254
|
*/
|
|
255
|
+
// Covers: req~update-self-update~1
|
|
230
256
|
export async function runUpdate(flags, hooks = {}) {
|
|
231
257
|
const cwd = process.cwd();
|
|
232
258
|
// `--migrate-only` is accepted for compat but is a no-op alias of default
|
|
@@ -235,27 +261,61 @@ export async function runUpdate(flags, hooks = {}) {
|
|
|
235
261
|
const backup = Boolean(flags.backup);
|
|
236
262
|
const check = hooks.checkForUpdates ?? checkForUpdates;
|
|
237
263
|
const migrate = hooks.applyProjectMigrations ?? applyProjectMigrations;
|
|
264
|
+
const spawnSelf = hooks.selfUpdate ?? selfUpdate;
|
|
265
|
+
const env = hooks.env ?? process.env;
|
|
266
|
+
const runMigrations = () => migrate(cwd, backup, flags.minimal ? true : undefined);
|
|
238
267
|
ui.step("Checking for updates");
|
|
239
|
-
const { current, latest, updateAvailable } = await check({ force: true });
|
|
268
|
+
const { current, latest, updateAvailable, fresh } = await check({ force: true });
|
|
240
269
|
if (!latest) {
|
|
241
270
|
ui.warn("Could not reach the npm registry — skipping the version check.");
|
|
271
|
+
if (!checkOnly)
|
|
272
|
+
runMigrations();
|
|
273
|
+
return;
|
|
274
|
+
}
|
|
275
|
+
if (!updateAvailable) {
|
|
276
|
+
ui.ok(`Already on the latest version (${current}).`);
|
|
277
|
+
if (!checkOnly)
|
|
278
|
+
runMigrations();
|
|
279
|
+
return;
|
|
242
280
|
}
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
ui.info(
|
|
246
|
-
|
|
247
|
-
|
|
281
|
+
ui.info(`${c.muted(current)} ${c.muted("→")} ${c.cyan(latest)}`);
|
|
282
|
+
if (checkOnly) {
|
|
283
|
+
ui.info(`Run ${ui.code("speclaw update")} — it upgrades itself to ${latest} through npx and ` +
|
|
284
|
+
`migrates this project.`);
|
|
285
|
+
return;
|
|
286
|
+
}
|
|
287
|
+
// parseFlags turns `--no-self-update` into `flags["no-self-update"]`; accept
|
|
288
|
+
// the `self-update: false` spelling too in case a caller builds flags by hand.
|
|
289
|
+
const optedOut = Boolean(flags["no-self-update"]) ||
|
|
290
|
+
flags["self-update"] === false ||
|
|
291
|
+
Boolean(env.SPECLAW_NO_SELF_UPDATE);
|
|
292
|
+
const why = optedOut
|
|
293
|
+
? "self-update is turned off"
|
|
294
|
+
: env.SPECLAW_SELF_UPDATED
|
|
295
|
+
? "already re-executed once"
|
|
296
|
+
: !fresh
|
|
297
|
+
? "the registry was unreachable (cached version only)"
|
|
298
|
+
: !isSafeVersion(latest)
|
|
299
|
+
? `the registry reported an invalid version ${JSON.stringify(latest)}`
|
|
300
|
+
: null;
|
|
301
|
+
if (why === null) {
|
|
302
|
+
const { forward, dropped } = safeForwardArgs(hooks.argv ?? process.argv.slice(3));
|
|
303
|
+
if (dropped.length)
|
|
304
|
+
ui.warn(`Not forwarding unsafe argument(s): ${dropped.join(" ")}`);
|
|
305
|
+
ui.step(`Re-running ${ui.code(`npx -y ${pkgName()}@${latest} update`)}`);
|
|
306
|
+
const outcome = await spawnSelf({ pkg: pkgName(), version: latest, args: forward, env });
|
|
307
|
+
if (outcome.kind === "ran") {
|
|
308
|
+
// The child applied the migrations; the parent never migrates as well.
|
|
309
|
+
process.exitCode = outcome.code;
|
|
248
310
|
return;
|
|
249
311
|
}
|
|
312
|
+
ui.warn(`Could not start npx (${outcome.reason}) — migrating with the installed binary.`);
|
|
250
313
|
}
|
|
251
314
|
else {
|
|
252
|
-
ui.
|
|
253
|
-
if (checkOnly)
|
|
254
|
-
return;
|
|
315
|
+
ui.info(`Not re-running at ${latest}: ${why}.`);
|
|
255
316
|
}
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
migrate(cwd, backup, flags.minimal ? true : undefined);
|
|
317
|
+
ui.info(binaryUpgradeHint(pkgName()));
|
|
318
|
+
runMigrations();
|
|
259
319
|
}
|
|
260
320
|
/**
|
|
261
321
|
* Additively apply the current version's content and feature steps to a project.
|
|
@@ -310,6 +370,15 @@ function applyProjectMigrations(cwd, backup, minimal) {
|
|
|
310
370
|
ui.warn(`${c.cream(rel)} had local edits — overwritten with the current version. ` +
|
|
311
371
|
`Recover from git, or re-run with ${ui.code("--backup")} to keep a .bak.`);
|
|
312
372
|
}
|
|
373
|
+
// A strict rule file edited outside the pipeline keeps its locked digest;
|
|
374
|
+
// only a human `speclaw laws accept` moves it.
|
|
375
|
+
for (const rel of report.lockPreserved)
|
|
376
|
+
ui.warn(lockPreservedWarning(rel));
|
|
377
|
+
if (report.lockError) {
|
|
378
|
+
// An existing lock that cannot be read is left untouched, never rebuilt.
|
|
379
|
+
ui.err(`${report.lockError} — speclaw.lock was left unchanged; repair it and re-run.`);
|
|
380
|
+
process.exitCode = 1;
|
|
381
|
+
}
|
|
313
382
|
// A project several releases behind jumps straight to @latest, so apply EVERY
|
|
314
383
|
// migration newer than its recorded version — not just the next one — oldest
|
|
315
384
|
// first (sorted, so array order can't matter). Entries are cumulative: never
|