@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
|
@@ -62,28 +62,97 @@ export function rootDigest(files) {
|
|
|
62
62
|
}
|
|
63
63
|
return digestOf(acc);
|
|
64
64
|
}
|
|
65
|
-
/**
|
|
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
|
-
|
|
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
|
|
83
|
-
symlinks: raw.symlinks
|
|
84
|
-
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
|
-
/**
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
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
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
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
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
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.
|
|
13
|
-
`
|
|
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 →
|
|
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
|
-
|
|
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
|
package/dist/shared/agents.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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"] =
|
|
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} (
|
|
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.
|