@esneiderbravo/speclaw 2.0.7 → 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.
@@ -62,28 +62,97 @@ export function rootDigest(files) {
62
62
  }
63
63
  return digestOf(acc);
64
64
  }
65
- /** Read lockfile or null if missing. Throws on unknown version / parse error. */
65
+ /**
66
+ * Read `speclaw.lock`.
67
+ *
68
+ * @param projectPath - Project root.
69
+ * @returns The lock, or `null` only when the file does not exist.
70
+ * @throws When the file exists but cannot be read: a parse error, a missing or
71
+ * unsupported `lockfileVersion`, or a body that parses but has the wrong
72
+ * structure (see {@link lockShapeError}). A malformed lock is never coerced
73
+ * into an empty one, because refreshing from an empty lock would launder
74
+ * every drifted strict file.
75
+ */
66
76
  export function readLockfile(projectPath) {
67
77
  const abs = lockfilePath(projectPath);
68
78
  if (!fs.existsSync(abs))
69
79
  return null;
70
- const raw = JSON.parse(fs.readFileSync(abs, "utf8"));
80
+ let raw;
81
+ try {
82
+ raw = JSON.parse(fs.readFileSync(abs, "utf8"));
83
+ }
84
+ catch (err) {
85
+ throw new Error(`speclaw.lock: unreadable (${err.message})`, { cause: err });
86
+ }
87
+ if (!raw || typeof raw !== "object")
88
+ throw new Error("speclaw.lock: not a JSON object");
71
89
  if (typeof raw.lockfileVersion !== "number") {
72
90
  throw new Error("speclaw.lock: missing lockfileVersion");
73
91
  }
74
92
  if (raw.lockfileVersion > LOCKFILE_VERSION) {
75
93
  throw new Error(`speclaw.lock: unsupported lockfileVersion ${raw.lockfileVersion} (max ${LOCKFILE_VERSION})`);
76
94
  }
95
+ const shape = lockShapeError(raw);
96
+ if (shape)
97
+ throw new Error(`speclaw.lock: invalid structure (${shape})`);
77
98
  return {
78
99
  lockfileVersion: raw.lockfileVersion,
79
100
  generator: String(raw.generator ?? ""),
80
101
  algorithm: "sha256",
81
102
  root: String(raw.root ?? ""),
82
- files: raw.files && typeof raw.files === "object" ? raw.files : {},
83
- symlinks: raw.symlinks && typeof raw.symlinks === "object" ? raw.symlinks : {},
84
- accepted: Array.isArray(raw.accepted) ? raw.accepted : [],
103
+ files: raw.files ?? {},
104
+ symlinks: raw.symlinks ?? {},
105
+ accepted: raw.accepted ?? [],
85
106
  };
86
107
  }
108
+ const LOCK_OWNERSHIPS = ["strict", "advisory", "scan-only"];
109
+ function isPlainObject(v) {
110
+ return typeof v === "object" && v !== null && !Array.isArray(v);
111
+ }
112
+ /**
113
+ * The first structural defect of a parsed lock body. `files`, `symlinks`, and
114
+ * `accepted` may be absent (they default to empty), but when present they must
115
+ * have the lockfile's types: `files` maps paths to `{ digest: string,
116
+ * ownership }`, `symlinks` maps paths to `{ target: string }`, and `accepted`
117
+ * is an array of `{ path: string, digest: string }` records.
118
+ *
119
+ * @param raw - The parsed JSON object.
120
+ * @returns A short description of the defect, or `null` when the shape is valid.
121
+ */
122
+ function lockShapeError(raw) {
123
+ const { files, symlinks, accepted } = raw;
124
+ if (files !== undefined) {
125
+ if (!isPlainObject(files))
126
+ return "files is not an object";
127
+ for (const [rel, e] of Object.entries(files)) {
128
+ if (!isPlainObject(e) || typeof e.digest !== "string") {
129
+ return `files["${rel}"] has no string digest`;
130
+ }
131
+ if (typeof e.ownership !== "string" || !LOCK_OWNERSHIPS.includes(e.ownership)) {
132
+ return `files["${rel}"] has an unknown ownership`;
133
+ }
134
+ }
135
+ }
136
+ if (symlinks !== undefined) {
137
+ if (!isPlainObject(symlinks))
138
+ return "symlinks is not an object";
139
+ for (const [rel, e] of Object.entries(symlinks)) {
140
+ if (!isPlainObject(e) || typeof e.target !== "string") {
141
+ return `symlinks["${rel}"] has no string target`;
142
+ }
143
+ }
144
+ }
145
+ if (accepted !== undefined) {
146
+ if (!Array.isArray(accepted))
147
+ return "accepted is not an array";
148
+ for (const [i, a] of accepted.entries()) {
149
+ if (!isPlainObject(a) || typeof a.path !== "string" || typeof a.digest !== "string") {
150
+ return `accepted[${i}] needs a string path and digest`;
151
+ }
152
+ }
153
+ }
154
+ return null;
155
+ }
87
156
  /** Write lockfile with stable JSON formatting. */
88
157
  export function writeLockfile(projectPath, lock) {
89
158
  const abs = lockfilePath(projectPath);
@@ -269,24 +338,159 @@ export function extractSpeclawYamlBlock(raw) {
269
338
  /<!-- speclaw:begin-provenance[\s\S]*?speclaw:end-provenance -->/.exec(raw);
270
339
  return m2 ? m2[0] : null;
271
340
  }
272
- /** Create or refresh speclaw.lock from the current tree. */
273
- export function refreshLockfile(projectPath) {
274
- const prev = (() => {
275
- try {
276
- return readLockfile(projectPath);
277
- }
278
- catch {
279
- return null;
341
+ /**
342
+ * Strict lock paths whose on-disk digest matches neither the locked digest nor
343
+ * an `accepted[]` digest for that path — files edited outside the speclaw
344
+ * pipeline. A missing file is not drifted (verify reports it as missing).
345
+ * Callers that write rule files must compute this **before** those writes.
346
+ *
347
+ * @param projectPath - Project root holding `speclaw.lock`.
348
+ * @param prev - The current lock; read from disk when omitted.
349
+ * @returns The drifted strict paths, sorted (empty when no lockfile exists).
350
+ * @throws When `speclaw.lock` exists but cannot be read (parse error,
351
+ * unsupported `lockfileVersion`, invalid structure) — an unreadable lock is
352
+ * never "no drift".
353
+ */
354
+ // Covers: req~lock-preserves-drift~1
355
+ export function driftedStrictPaths(projectPath, prev = readLockfile(projectPath)) {
356
+ if (!prev)
357
+ return [];
358
+ const drifted = [];
359
+ for (const [rel, entry] of Object.entries(prev.files)) {
360
+ if (entry.ownership !== "strict")
361
+ continue;
362
+ const actual = onDiskDigest(projectPath, rel);
363
+ if (actual === null || actual === entry.digest)
364
+ continue;
365
+ if (prev.accepted.some((a) => a.path === rel && a.digest === actual))
366
+ continue;
367
+ drifted.push(rel);
368
+ }
369
+ return drifted.sort();
370
+ }
371
+ /**
372
+ * The warning shown for a strict file whose locked digest a refresh kept.
373
+ *
374
+ * @param rel - Project-relative path of the drifted file.
375
+ * @returns One line naming the file and the `laws accept` command.
376
+ */
377
+ export function lockPreservedWarning(rel) {
378
+ return (`${rel} drifted from speclaw.lock — kept the locked digest; ` + `run speclaw laws accept ${rel}`);
379
+ }
380
+ /**
381
+ * The integrity digest of a project file as it is on disk.
382
+ *
383
+ * @param projectPath - Project root.
384
+ * @param rel - Project-relative path.
385
+ * @returns The digest, or `null` when the path is not a regular file.
386
+ */
387
+ export function onDiskDigest(projectPath, rel) {
388
+ const abs = path.join(projectPath, rel);
389
+ if (!fs.existsSync(abs) || !fs.statSync(abs).isFile())
390
+ return null;
391
+ return digestText(prepareIntegrityText(rel, fs.readFileSync(abs, "utf8")));
392
+ }
393
+ /** Thrown when the tree or the lock changed between a confirmation and its write. */
394
+ export class LockChangedError extends Error {
395
+ constructor(detail) {
396
+ super(`${detail} changed while the confirmation was open`);
397
+ this.name = "LockChangedError";
398
+ }
399
+ }
400
+ /**
401
+ * Re-check a confirmed re-baseline against the lock and the snapshot about to
402
+ * be written: the drifted set must be exactly the confirmed paths, and each
403
+ * path's locked and on-disk digests must equal what was shown.
404
+ *
405
+ * @returns The drifted paths (equal to the confirmed ones).
406
+ * @throws {LockChangedError} On any difference.
407
+ */
408
+ function checkConfirmedDrift(projectPath, prev, files, confirmed) {
409
+ const now = driftedStrictPaths(projectPath, prev);
410
+ const shown = confirmed.map((c) => c.path).sort();
411
+ const extra = now.filter((p) => !shown.includes(p));
412
+ if (extra.length)
413
+ throw new LockChangedError(`newly drifted ${extra.join(", ")}`);
414
+ const gone = shown.filter((p) => !now.includes(p));
415
+ if (gone.length)
416
+ throw new LockChangedError(gone.join(", "));
417
+ for (const c of confirmed) {
418
+ if (prev?.files[c.path]?.digest !== c.locked) {
419
+ throw new LockChangedError(`the locked digest of ${c.path}`);
280
420
  }
281
- })();
421
+ // A locked path outside discovery is not in the snapshot; read it directly.
422
+ const actual = files[c.path]?.digest ?? onDiskDigest(projectPath, c.path);
423
+ if (actual !== c.actual)
424
+ throw new LockChangedError(c.path);
425
+ }
426
+ return now;
427
+ }
428
+ /**
429
+ * Create or refresh speclaw.lock from the current tree without laundering
430
+ * tampering: a strict path that drifted from the lock keeps its locked digest
431
+ * (and is reported in `preserved`) unless `rebaseline` is given, which only the
432
+ * interactive `laws lock --force` passes. Clean strict paths are refreshed, new
433
+ * strict paths are added, and advisory paths are refreshed freely. `accepted[]`
434
+ * entries survive only while they equal their path's locked digest. A fresh
435
+ * baseline is built only when no lockfile exists: a lockfile that exists but
436
+ * cannot be read is never overwritten, because rebuilding it from disk would
437
+ * launder every drifted strict file (and downgrade a newer-format lock).
438
+ *
439
+ * @param projectPath - Project root.
440
+ * @param opts - `drifted`: the drifted strict paths computed before any rule
441
+ * file was written (defaults to computing them now); `rebaseline`: re-baseline
442
+ * drifted paths to disk and record an `accepted[]` entry by `by` for each
443
+ * (with the optional human `note` appended to the `laws lock --force` note).
444
+ * `rebaseline.confirmed` is what the human was shown and approved; when
445
+ * given, the drifted set and every locked and on-disk digest are re-checked
446
+ * against it from the snapshot about to be written, closing the gap between
447
+ * the prompt and the write.
448
+ * @returns The written lock plus the preserved, re-baselined, and pruned counts.
449
+ * @throws When `speclaw.lock` exists but cannot be read (parse error,
450
+ * unsupported `lockfileVersion`, invalid structure); nothing is written.
451
+ * @throws {LockChangedError} When `rebaseline.confirmed` no longer matches the
452
+ * drifted set or the digests on disk; nothing is written.
453
+ */
454
+ // Covers: req~lock-preserves-drift~1
455
+ export function refreshLockfile(projectPath, opts = {}) {
456
+ const prev = readLockfile(projectPath);
282
457
  const { files, symlinks } = snapshotLockEntries(projectPath);
283
- const lock = buildLock({
284
- files,
285
- symlinks,
286
- accepted: prev?.accepted ?? [],
287
- });
458
+ const confirmed = opts.rebaseline?.confirmed;
459
+ const drifted = confirmed
460
+ ? checkConfirmedDrift(projectPath, prev, files, confirmed)
461
+ : (opts.drifted ?? driftedStrictPaths(projectPath, prev));
462
+ const accepted = [...(prev?.accepted ?? [])];
463
+ const preserved = [];
464
+ const rebaselined = [];
465
+ const at = new Date().toISOString();
466
+ for (const rel of drifted) {
467
+ const old = prev?.files[rel];
468
+ const next = files[rel];
469
+ if (!old || !next || old.ownership !== "strict")
470
+ continue;
471
+ if (opts.rebaseline) {
472
+ if (next.digest !== old.digest) {
473
+ rebaselined.push(rel);
474
+ accepted.push({
475
+ path: rel,
476
+ digest: next.digest,
477
+ at,
478
+ by: opts.rebaseline.by,
479
+ note: opts.rebaseline.note
480
+ ? `laws lock --force: ${opts.rebaseline.note}`
481
+ : "laws lock --force",
482
+ });
483
+ }
484
+ }
485
+ else if (next.digest !== old.digest) {
486
+ files[rel] = old;
487
+ preserved.push(rel);
488
+ }
489
+ }
490
+ const kept = accepted.filter((a) => files[a.path] !== undefined && files[a.path].digest === a.digest);
491
+ const lock = buildLock({ files, symlinks, accepted: kept });
288
492
  writeLockfile(projectPath, lock);
289
- return lock;
493
+ return { lock, preserved, rebaselined, pruned: accepted.length - kept.length };
290
494
  }
291
495
  /** Render a data-only provenance HTML comment (no imperatives). */
292
496
  export function provenanceBlock(opts) {
@@ -11,7 +11,8 @@ import { pkgVersion } from "../../shared/version.js";
11
11
  import { mergeSeedLaws, readLawManifest, seedManifestFor, writeLawManifest, } from "./laws.js";
12
12
  import { installHooks } from "./hooks.js";
13
13
  import { compileLaws } from "./compile-laws.js";
14
- import { refreshLockfile } from "./lock.js";
14
+ import { driftedStrictPaths, refreshLockfile } from "./lock.js";
15
+ export { lockPreservedWarning } from "./lock.js";
15
16
  import { refreshOwnersIfConfigured } from "../team/owners.js";
16
17
  const ASSETS = assetsDir(import.meta.url);
17
18
  // Every {{var}} the foundation templates may reference. Ones the agent didn't
@@ -128,7 +129,19 @@ export function scaffold(projectPath, profile, packNames, agents = [], opts = {}
128
129
  const unknown = packNames.filter((n) => !packs[n]);
129
130
  if (unknown.length)
130
131
  throw new Error(`Unknown packs: ${unknown.join(", ")}`);
131
- const report = { ...emptyReport(), nextSteps: [] };
132
+ const report = { ...emptyReport(), nextSteps: [], lockPreserved: [] };
133
+ // Snapshot strict-file drift before writing anything: the rule files written
134
+ // below (foundation, agents, compiled laws) must not re-baseline an edit made
135
+ // outside the pipeline.
136
+ // A lockfile that exists but cannot be read is reported in `lockError` and
137
+ // left byte-identical: rebuilding it from disk would launder drift.
138
+ let drifted = [];
139
+ try {
140
+ drifted = driftedStrictPaths(projectPath);
141
+ }
142
+ catch (err) {
143
+ report.lockError = err.message;
144
+ }
132
145
  const vars = { ...FOUNDATION_DEFAULTS, ...profile };
133
146
  // Managed trees (MANAGED_TREES) carry speclaw's workflow logic and may be
134
147
  // overwritten on update; the foundation (personalized) is always additive.
@@ -157,17 +170,20 @@ export function scaffold(projectPath, profile, packNames, agents = [], opts = {}
157
170
  // executable-laws will extend the same manifest with more backends.
158
171
  const lawManifest = ensureLawManifest(projectPath, report);
159
172
  try {
160
- compileLaws({ projectPath, agents, writeManifest: false });
173
+ compileLaws({ projectPath, agents, writeManifest: false, drifted, refreshLock: false });
161
174
  }
162
175
  catch {
163
176
  // Compilation must not fail scaffold; `speclaw laws compile` surfaces errors.
164
177
  }
165
178
  // Covers: req~lock-refresh-update~1
166
- try {
167
- refreshLockfile(projectPath);
168
- }
169
- catch {
170
- // Lock refresh must not fail scaffold; `speclaw laws lock` surfaces errors.
179
+ if (report.lockError === undefined) {
180
+ try {
181
+ report.lockPreserved = refreshLockfile(projectPath, { drifted }).preserved;
182
+ }
183
+ catch (err) {
184
+ // refreshLockfile throws before writing; init/update surface lockError.
185
+ report.lockError = err.message;
186
+ }
171
187
  }
172
188
  ensureVerifyWorkflow(projectPath, report);
173
189
  report.hooks = installHooks(projectPath, agents, lawManifest, report, {
@@ -9,8 +9,11 @@ You are the **archiver** role in speclaw's **Cortex** (One brain. Many agents.).
9
9
  ## Goal
10
10
 
11
11
  Follow the `sync` then `archive` skills: reconcile delta specs to built code,
12
- `lawbook_change` action sync when needed, then archive. Advance Cortex to
13
- `done` after a successful archive.
12
+ `lawbook_change` action sync when needed, then archive. A successful archive
13
+ completes the Cortex harness itself (`harnessCompleted` in the result); do not
14
+ call a mutating Cortex op (`advance`, `rework`, `start`) afterwards. Confirm
15
+ with the read-only `cortex` action `status` that the stage is `done`, and
16
+ report it to the coordinator.
14
17
 
15
18
  ## Hard constraints
16
19
 
@@ -9,3 +9,5 @@ explorer → planner → implementer → reviewer → tester → archiver. Drive
9
9
  with the `cortex` MCP tool (`status`/`start`/`advance`/`rework`/`brief`) or
10
10
  `speclaw cortex …`. Surface planner questions to the human. Do not implement
11
11
  product code yourself.
12
+ The run posts status updates every `cortex.statusIntervalMinutes` minutes
13
+ (`lawbook/config.yaml`, default 5, `0` disables, at most 60).
@@ -22,4 +22,31 @@ Stage → role:
22
22
  | archiving | archiver (`sync` + `archive`) |
23
23
  | done | stop — report success |
24
24
 
25
+ ## Status updates
26
+
27
+ - Read `summary.statusIntervalMinutes` from the `status` result (after
28
+ `start`, run `status` once). `0` disables unsolicited updates; never parse
29
+ `lawbook/config.yaml` yourself.
30
+ - Create the timer only when the summary is non-null and the stage is not
31
+ `done`, the interval is greater than 0, and the host has a session timer
32
+ (Claude Code `CronCreate`, session-only, at least 1 minute). Then create
33
+ **one** recurring timer every `statusIntervalMinutes` minutes:
34
+ - schedule: for 1–59 minutes use `*/N * * * *`; for 60 (the engine caps the
35
+ interval at 60) use an hourly expression such as `7 * * * *`;
36
+ - prompt: the timer firing is a fresh prompt, so it must carry its context.
37
+ Name the change and the rules: run `cortex` action `status` for that
38
+ change and post the update in the session's language; skip the ping while
39
+ the stage is `questions`; when the stage is `done` or the run was stopped,
40
+ delete this timer (`CronDelete`) and post nothing.
41
+ - Remember the id that `CronCreate` returns; it is the run's status timer.
42
+ Before creating one, check that no status timer exists for this change
43
+ (your remembered id, or `CronList`), so there is exactly one per run.
44
+ `CronDelete` always targets that id.
45
+ - Create the timer only inside a Cortex run, never a second one per run, and
46
+ none when the interval is 0.
47
+ - Prefer background role dispatch: the timer fires only while the session is
48
+ idle, not during a blocking dispatch.
49
+ - On a host without a session timer, the after-op and before-dispatch updates
50
+ of the dispatch loop stand in for it.
51
+
25
52
  Next: read `steps/02-dispatch-loop.md` and do only what it says.
@@ -30,9 +30,27 @@ For the current Cortex stage (`cortex` action `brief` or `status`):
30
30
  - implementing done → `advance` (reviewing, or testing at level 0)
31
31
  - reviewing PASS → `advance` + `verdict: PASS`; FAIL → `rework` + `verdict: FAIL`
32
32
  - testing PASS → `advance` + `verdict: PASS`; FAIL → `rework` + `verdict: FAIL`
33
- - archiving success → `advance` (to done)
33
+ - archiving success → no Cortex op: the archive itself moves the harness
34
+ to `done` (the result reports `harnessCompleted`). Confirm with `cortex`
35
+ action `status` that the stage is `done`; never `advance` an archived
36
+ change (the engine rejects it).
34
37
  4. Max rework is 3; if the engine rejects further rework, **ask the human**.
35
38
  5. Coordinator never edits `src/`, never archives, never pushes, never runs
36
39
  `laws accept`.
40
+ 6. **Status updates.** Unless `summary.statusIntervalMinutes` is 0, post a
41
+ compact update after every Cortex op and just before every blocking role
42
+ dispatch, built from the `status` summary fields:
43
+ - the change, the stage and active role, the elapsed time in the stage;
44
+ - tasks done/total, rework iteration/max, and the pending verdicts;
45
+ - in `questions`, that the human owes answers.
46
+
47
+ Write it in the session's language (the language of the human's most
48
+ recent messages); keep stage, role, and tool names, file paths, and change
49
+ names in English. `summary.line` is English-only; use it as is only in an
50
+ English session. Skip timer pings while the stage is `questions` (the
51
+ question round already speaks to the human). When the human asks to stop
52
+ the updates, delete the timer (`CronDelete` with the id `CronCreate`
53
+ returned) and post no further unsolicited update for this run. An explicit
54
+ request for status is always answered.
37
55
 
38
56
  Next: read `steps/03-complete.md` and do only what it says.
@@ -1,6 +1,8 @@
1
1
  # Complete
2
2
 
3
- When Cortex stage is `done` (or the human stops the run), summarize:
3
+ When Cortex stage is `done` (or the human stops the run), first delete the
4
+ status timer if one exists (`CronDelete` with the id `CronCreate` returned,
5
+ or the id `CronList` shows for this change), then summarize:
4
6
 
5
7
  - Change name and final stage
6
8
  - Review/test verdicts and iteration count
@@ -6,6 +6,7 @@ import { scaffoldFeature } from "./scaffold-change.js";
6
6
  import { buildCoverageReport, loadCoverageConfig, renderCoverageAgent } from "./coverage.js";
7
7
  import { buildDriftReport, renderDriftAgent } from "./drift.js";
8
8
  import { handleHarness, harnessOps } from "../cortex/harness.js";
9
+ import { fitStatusResult } from "../cortex/status.js";
9
10
  export const lawbookChangeActions = [
10
11
  "init",
11
12
  "list",
@@ -111,7 +112,7 @@ export function handleLawbookChange(args) {
111
112
  if (!args.harnessOp) {
112
113
  throw new Error(`lawbook_change: action 'harness' requires 'harnessOp'`);
113
114
  }
114
- return handleHarness({
115
+ const result = handleHarness({
115
116
  projectPath: args.projectPath,
116
117
  change: requireField(args, "change"),
117
118
  harnessOp: args.harnessOp,
@@ -120,6 +121,8 @@ export function handleLawbookChange(args) {
120
121
  pauseForQuestions: args.pauseForQuestions,
121
122
  note: args.note ?? args.reason,
122
123
  });
124
+ // MCP-only path: fit `status` to the output budget like the `cortex` tool.
125
+ return args.harnessOp === "status" && "summary" in result ? fitStatusResult(result) : result;
123
126
  }
124
127
  default:
125
128
  throw new Error(`lawbook_change: unknown action '${String(args.action)}'`);
@@ -6,7 +6,7 @@ import { classifyEars, diagnoseEars, extractNormativeBody, loadEarsConfig, split
6
6
  import { artifactNeeds, confirmedLevel, countUncheckedTasks, gatherSignals, hasDisciplineReport, loadCeremonyConfig, proposeLevel, readChangeType, readCeremonyRecord, } from "./levels.js";
7
7
  import { inferBugResolution, preventionRequiresDelta, validateBugfixContent } from "./bugfix.js";
8
8
  import { isPlaceholderDelta } from "./scaffold-change.js";
9
- import { harnessArchiveBlockers } from "../cortex/harness.js";
9
+ import { completeHarnessOnArchive, harnessArchiveBlockers } from "../cortex/harness.js";
10
10
  // speclaw's own spec-driven workflow engine. Inspired by OpenSpec's model
11
11
  // (proposals, delta specs, changes, archive) but implemented from scratch and
12
12
  // deliberately simpler: a change's specs/ holds the full intended spec for each
@@ -501,7 +501,17 @@ export function specArchive(projectPath, change, date) {
501
501
  fs.mkdirSync(path.dirname(archiveDir), { recursive: true });
502
502
  if (fs.existsSync(archiveDir))
503
503
  throw new Error(`archive target already exists: ${archiveDir}`);
504
- fs.renameSync(changeDir, archiveDir);
504
+ // Complete the harness before the move (the archived path is not known to
505
+ // Cortex until then); a failed move puts the old harness bytes back.
506
+ // Covers: req~harness-archive-completes~1
507
+ const harness = completeHarnessOnArchive(projectPath, change, "archived by lawbook archive");
508
+ try {
509
+ renameDir(changeDir, archiveDir);
510
+ }
511
+ catch (err) {
512
+ harness.restore();
513
+ throw err;
514
+ }
505
515
  return {
506
516
  change,
507
517
  promoted,
@@ -509,8 +519,20 @@ export function specArchive(projectPath, change, date) {
509
519
  updated,
510
520
  archivedTo: path.relative(projectPath, archiveDir),
511
521
  seals,
522
+ harnessCompleted: harness.completed,
512
523
  };
513
524
  }
525
+ /**
526
+ * The directory move used by {@link specArchive}; a seam so tests can provoke
527
+ * a failed move and check the harness is restored. Test-only: product code
528
+ * must never reassign it.
529
+ */
530
+ export const archiveFs = {
531
+ renameSync: (from, to) => fs.renameSync(from, to),
532
+ };
533
+ function renameDir(from, to) {
534
+ archiveFs.renameSync(from, to);
535
+ }
514
536
  /**
515
537
  * Seal structural anchors for every capability whose canonical spec was
516
538
  * promoted during archive. Missing specs are skipped; zero anchors warn via
@@ -1,6 +1,7 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
3
  import { ensureGitignore } from "./install.js";
4
+ import { pkgName, pkgVersion } from "./version.js";
4
5
  /** The agents speclaw can configure. Add one here and the CLI picks it up. */
5
6
  export const AGENTS = [
6
7
  {
@@ -48,7 +49,39 @@ export const AGENTS = [
48
49
  export function agentById(id) {
49
50
  return AGENTS.find((a) => a.id === id);
50
51
  }
51
- const MCP_ENTRY = { type: "stdio", command: "npx", args: ["-y", "@esneiderbravo/speclaw", "mcp"] };
52
+ /**
53
+ * The speclaw MCP server entry, pinned to the running package version so the
54
+ * agent's MCP server matches the CLI that wrote the config (an unpinned entry
55
+ * runs whatever npx happens to have cached).
56
+ *
57
+ * @returns `npx -y <pkg>@<version> mcp` over stdio.
58
+ */
59
+ // Covers: req~mcp-entry-pinned~1
60
+ export function mcpEntry() {
61
+ return { type: "stdio", command: "npx", args: ["-y", `${pkgName()}@${pkgVersion()}`, "mcp"] };
62
+ }
63
+ /**
64
+ * Whether an existing `speclaw` entry is the stock npx launcher speclaw wrote
65
+ * (pinned or not), as opposed to a custom one the user owns.
66
+ *
67
+ * @param entry - The existing `mcpServers.speclaw` value.
68
+ * @returns True for `{ type?, command: "npx", args: ["-y", <pkg>[@<v>], "mcp"] }`.
69
+ */
70
+ function isStockMcpEntry(entry) {
71
+ if (!entry || typeof entry !== "object" || Array.isArray(entry))
72
+ return false;
73
+ const e = entry;
74
+ if (Object.keys(e).some((k) => k !== "type" && k !== "command" && k !== "args"))
75
+ return false;
76
+ if (e.command !== "npx" || !Array.isArray(e.args) || e.args.length !== 3)
77
+ return false;
78
+ const [y, pkg, mcp] = e.args;
79
+ const name = pkgName();
80
+ return (y === "-y" &&
81
+ mcp === "mcp" &&
82
+ typeof pkg === "string" &&
83
+ (pkg === name || pkg.startsWith(`${name}@`)));
84
+ }
52
85
  /** True when `p` exists as a filesystem entry (including a symlink), without following it. */
53
86
  function isSymlink(p) {
54
87
  try {
@@ -59,7 +92,12 @@ function isSymlink(p) {
59
92
  return false;
60
93
  }
61
94
  }
62
- /** Register the speclaw stdio MCP server in an agent's config file, merging into any existing config and skipping if already present. */
95
+ /**
96
+ * Register the speclaw stdio MCP server in an agent's config file, merging into
97
+ * any existing config. A missing entry is written pinned; a stock npx entry
98
+ * (pinned to another version or unpinned) is re-pinned; any other shape is a
99
+ * custom entry and is kept untouched.
100
+ */
63
101
  function writeMcpConfig(projectPath, mcpFile, report) {
64
102
  // The agent MCP config is per-developer wiring — regenerated by `speclaw init`
65
103
  // / `agent add`, points at a local runner — not shared source. Keep it out of
@@ -70,14 +108,25 @@ function writeMcpConfig(projectPath, mcpFile, report) {
70
108
  if (fs.existsSync(mcpPath))
71
109
  config = JSON.parse(fs.readFileSync(mcpPath, "utf8"));
72
110
  config.mcpServers = config.mcpServers ?? {};
73
- if (config.mcpServers["speclaw"]) {
74
- report.skipped.push(`${mcpPath} (speclaw already registered)`);
75
- return;
111
+ const pinned = mcpEntry();
112
+ const existing = config.mcpServers["speclaw"];
113
+ let note = "speclaw MCP server";
114
+ if (existing) {
115
+ if (!isStockMcpEntry(existing)) {
116
+ report.skipped.push(`${mcpPath} (custom speclaw entry kept)`);
117
+ return;
118
+ }
119
+ const e = existing;
120
+ if (e.type === pinned.type && JSON.stringify(e.args) === JSON.stringify(pinned.args)) {
121
+ report.skipped.push(`${mcpPath} (speclaw already registered)`);
122
+ return;
123
+ }
124
+ note = `speclaw MCP entry pinned to ${pkgVersion()}`;
76
125
  }
77
- config.mcpServers["speclaw"] = { ...MCP_ENTRY };
126
+ config.mcpServers["speclaw"] = pinned;
78
127
  fs.mkdirSync(path.dirname(mcpPath), { recursive: true });
79
128
  fs.writeFileSync(mcpPath, JSON.stringify(config, null, 2) + "\n");
80
- report.written.push(`${mcpPath} (speclaw MCP server)`);
129
+ report.written.push(`${mcpPath} (${note})`);
81
130
  }
82
131
  /**
83
132
  * Configure one agent: create its symlinks into ai-specs and register the MCP server.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@esneiderbravo/speclaw",
3
- "version": "2.0.7",
3
+ "version": "2.0.9",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },