@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.
- package/README.md +37 -8
- package/dist/cli/commands/cortex.js +4 -0
- 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 +56 -7
- package/dist/modules/cortex/paths.js +39 -0
- package/dist/modules/cortex/register.js +6 -3
- package/dist/modules/cortex/status.js +200 -0
- 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/commands/cortex.md +2 -0
- package/dist/modules/lawbook/assets/skills/cortex/steps/01-load-or-start.md +27 -0
- package/dist/modules/lawbook/assets/skills/cortex/steps/02-dispatch-loop.md +19 -1
- package/dist/modules/lawbook/assets/skills/cortex/steps/03-complete.md +3 -1
- package/dist/modules/lawbook/change-tool.js +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
|
|
|
@@ -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 →
|
|
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),
|
|
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
|
-
|
|
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
|
-
|
|
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.
|