@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.
@@ -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
 
@@ -30,7 +30,10 @@ 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`.
@@ -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.8",
3
+ "version": "2.0.9",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },