@esneiderbravo/speclaw 0.3.7 → 0.3.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.
@@ -0,0 +1,491 @@
1
+ /**
2
+ * Deterministic spec↔code drift classification and reporting.
3
+ */
4
+ import fs from "node:fs";
5
+ import path from "node:path";
6
+ import { openDb, indexExists, needsReindex } from "../compass/db.js";
7
+ import { NORMALIZER_VERSION } from "../compass/hash.js";
8
+ import { logForPath } from "../../shared/git-history.js";
9
+ import { listAnchoredCapabilities, readAnchorsFile, resealAll, sealCapability, } from "./anchors.js";
10
+ const FAIL_RANK = { none: 0, cosmetic: 1, semantic: 2, any: 3 };
11
+ function stateRank(state) {
12
+ switch (state) {
13
+ case "changed-cosmetic":
14
+ return 1;
15
+ case "changed-semantic":
16
+ case "deleted":
17
+ return 2;
18
+ case "orphan":
19
+ case "ambiguous":
20
+ return 3;
21
+ default:
22
+ return 0;
23
+ }
24
+ }
25
+ /** Parse `--fail-on`; defaults to semantic; null when invalid. */
26
+ export function parseFailOn(raw) {
27
+ if (raw === undefined || raw === true)
28
+ return "semantic";
29
+ if (typeof raw !== "string")
30
+ return null;
31
+ if (raw === "none" || raw === "cosmetic" || raw === "semantic" || raw === "any")
32
+ return raw;
33
+ return null;
34
+ }
35
+ /** Classify one sealed anchor against the live graph. */
36
+ export function classifyAnchor(db, projectPath, capability, a) {
37
+ if (a.normalizerVersion !== NORMALIZER_VERSION) {
38
+ return { capability, anchor: a, state: "stale-hash" };
39
+ }
40
+ if (a.resolution === "unresolved")
41
+ return { capability, anchor: a, state: "orphan" };
42
+ if (a.resolution === "ambiguous")
43
+ return { capability, anchor: a, state: "ambiguous" };
44
+ if (a.anchorKind === "file") {
45
+ const ok = a.filePath != null && fs.existsSync(path.join(projectPath, a.filePath));
46
+ return { capability, anchor: a, state: ok ? "unchanged" : "deleted" };
47
+ }
48
+ const byName = db
49
+ .prepare(`SELECT n.id AS id, n.name AS name, n.kind AS kind, f.path AS path,
50
+ n.norm_hash AS normHash, n.body_hash AS bodyHash
51
+ FROM nodes n JOIN files f ON f.id = n.file_id
52
+ WHERE n.name = ?`)
53
+ .all(a.symbolName);
54
+ if (byName.length === 0) {
55
+ if (a.contentHash) {
56
+ const byHash = db
57
+ .prepare(`SELECT n.id AS id, n.name AS name, n.kind AS kind, f.path AS path,
58
+ n.norm_hash AS normHash, n.body_hash AS bodyHash
59
+ FROM nodes n JOIN files f ON f.id = n.file_id
60
+ WHERE n.norm_hash = ?
61
+ LIMIT 1`)
62
+ .get(a.contentHash);
63
+ if (byHash)
64
+ return { capability, anchor: a, state: "moved", currentFile: byHash.path };
65
+ }
66
+ return { capability, anchor: a, state: "deleted" };
67
+ }
68
+ const n = byName.find((m) => m.path === a.filePath) ?? (byName.length === 1 ? byName[0] : null);
69
+ if (!n)
70
+ return { capability, anchor: a, state: "ambiguous" };
71
+ if (n.normHash === a.contentHash) {
72
+ if (n.path !== a.filePath) {
73
+ return { capability, anchor: a, state: "moved", currentFile: n.path };
74
+ }
75
+ if (n.bodyHash !== a.rawHash) {
76
+ return { capability, anchor: a, state: "changed-cosmetic", currentFile: n.path };
77
+ }
78
+ return { capability, anchor: a, state: "unchanged", currentFile: n.path };
79
+ }
80
+ return { capability, anchor: a, state: "changed-semantic", currentFile: n.path };
81
+ }
82
+ function attachAge(projectPath, v) {
83
+ if (v.state !== "changed-semantic" && v.state !== "deleted")
84
+ return v;
85
+ const file = v.currentFile ?? v.anchor.filePath;
86
+ if (!file)
87
+ return { ...v, driftDays: null };
88
+ const touches = logForPath(projectPath, file);
89
+ const last = touches[0];
90
+ if (!last)
91
+ return { ...v, commitsSince: 0, driftDays: null };
92
+ const archived = Date.parse(v.anchor.archivedAt);
93
+ const driftDays = Number.isFinite(archived)
94
+ ? Math.max(0, Math.floor((last.ts * 1000 - archived) / 86_400_000))
95
+ : null;
96
+ return { ...v, commitsSince: touches.length, driftDays };
97
+ }
98
+ function matchGlob(relPath, pattern) {
99
+ const norm = relPath.replace(/\\/g, "/");
100
+ const esc = pattern
101
+ .replace(/\\/g, "/")
102
+ .replace(/[.+^${}()|[\]\\]/g, "\\$&")
103
+ .replace(/\*\*/g, "{{DS}}")
104
+ .replace(/\*/g, "[^/]*")
105
+ .replace(/{{DS}}/g, ".*");
106
+ return new RegExp(`^${esc}$`).test(norm);
107
+ }
108
+ /**
109
+ * Load `capabilities[].paths` from lawbook/config.yaml (line-oriented subset).
110
+ * Returns an empty map when the file is missing or no paths are declared.
111
+ */
112
+ export function loadCapabilityPaths(projectPath) {
113
+ const cfgPath = path.join(projectPath, "lawbook", "config.yaml");
114
+ if (!fs.existsSync(cfgPath))
115
+ return {};
116
+ const out = {};
117
+ let inCaps = false;
118
+ let current = null;
119
+ let inPaths = false;
120
+ for (const raw of fs.readFileSync(cfgPath, "utf8").split("\n")) {
121
+ const line = raw.replace(/\s+#.*$/, "");
122
+ if (/^\s*capabilities\s*:/.test(line)) {
123
+ inCaps = true;
124
+ current = null;
125
+ inPaths = false;
126
+ continue;
127
+ }
128
+ if (inCaps && /^[A-Za-z_]/.test(line)) {
129
+ // Next top-level key ends the capabilities block.
130
+ inCaps = false;
131
+ current = null;
132
+ inPaths = false;
133
+ }
134
+ if (!inCaps)
135
+ continue;
136
+ const name = /^\s*-\s*name\s*:\s*["']?([^"'#]+?)["']?\s*$/.exec(line);
137
+ if (name) {
138
+ current = name[1].trim();
139
+ out[current] ??= [];
140
+ inPaths = false;
141
+ continue;
142
+ }
143
+ if (/^\s*paths\s*:/.test(line)) {
144
+ inPaths = true;
145
+ const inline = /^\s*paths\s*:\s*\[([^\]]*)\]\s*$/.exec(line);
146
+ if (inline && current) {
147
+ out[current] = inline[1]
148
+ .split(",")
149
+ .map((s) => s.trim().replace(/^["']|["']$/g, ""))
150
+ .filter(Boolean);
151
+ inPaths = false;
152
+ }
153
+ continue;
154
+ }
155
+ if (inPaths && current) {
156
+ const item = /^\s*-\s*["']?([^"'#]+?)["']?\s*$/.exec(line);
157
+ if (item) {
158
+ out[current].push(item[1].trim());
159
+ continue;
160
+ }
161
+ if (/^\s*-\s*name\s*:/.test(line) || /^[A-Za-z_]/.test(line)) {
162
+ inPaths = false;
163
+ }
164
+ }
165
+ }
166
+ // Drop capabilities that declared no globs.
167
+ for (const k of Object.keys(out)) {
168
+ if (out[k].length === 0)
169
+ delete out[k];
170
+ }
171
+ return out;
172
+ }
173
+ /** Reverse drift: top-level symbols under capability paths with no seal. */
174
+ export function reverseDrift(db, capabilityPaths) {
175
+ if (Object.keys(capabilityPaths).length === 0) {
176
+ return {
177
+ enabled: false,
178
+ reason: "No capabilities[].paths configured — reverse drift disabled.",
179
+ hits: [],
180
+ };
181
+ }
182
+ const anchored = new Set(db.prepare(`SELECT symbol_name AS name FROM spec_anchors`).all().map((r) => r.name));
183
+ const nodes = db
184
+ .prepare(`SELECT n.name AS name, n.kind AS kind, f.path AS path
185
+ FROM nodes n JOIN files f ON f.id = n.file_id
186
+ WHERE n.parent_id IS NULL
187
+ AND n.kind IN ('function','class','method','interface')`)
188
+ .all();
189
+ const hits = [];
190
+ for (const [capability, globs] of Object.entries(capabilityPaths)) {
191
+ for (const n of nodes) {
192
+ if (anchored.has(n.name))
193
+ continue;
194
+ if (n.path.includes(".test.") || n.path.includes("/test/"))
195
+ continue;
196
+ if (!globs.some((g) => matchGlob(n.path, g)))
197
+ continue;
198
+ hits.push({ capability, filePath: n.path, symbolName: n.name, kind: n.kind });
199
+ }
200
+ }
201
+ return { enabled: true, hits };
202
+ }
203
+ function countStates(verdicts) {
204
+ const c = {
205
+ unchanged: 0,
206
+ changedCosmetic: 0,
207
+ changedSemantic: 0,
208
+ moved: 0,
209
+ deleted: 0,
210
+ orphan: 0,
211
+ ambiguous: 0,
212
+ unanchored: 0,
213
+ staleHash: 0,
214
+ };
215
+ for (const v of verdicts) {
216
+ switch (v.state) {
217
+ case "unchanged":
218
+ c.unchanged++;
219
+ break;
220
+ case "changed-cosmetic":
221
+ c.changedCosmetic++;
222
+ break;
223
+ case "changed-semantic":
224
+ c.changedSemantic++;
225
+ break;
226
+ case "moved":
227
+ c.moved++;
228
+ break;
229
+ case "deleted":
230
+ c.deleted++;
231
+ break;
232
+ case "orphan":
233
+ c.orphan++;
234
+ break;
235
+ case "ambiguous":
236
+ c.ambiguous++;
237
+ break;
238
+ case "unanchored":
239
+ c.unanchored++;
240
+ break;
241
+ case "stale-hash":
242
+ c.staleHash++;
243
+ break;
244
+ }
245
+ }
246
+ return c;
247
+ }
248
+ function emptyReport(generatedAt, failOn, needs, exitCode) {
249
+ return {
250
+ schemaVersion: 1,
251
+ generatedAt,
252
+ normalizerVersion: NORMALIZER_VERSION,
253
+ needsReindex: needs,
254
+ summary: {
255
+ capabilities: 0,
256
+ anchors: 0,
257
+ unchanged: 0,
258
+ changedCosmetic: 0,
259
+ changedSemantic: 0,
260
+ moved: 0,
261
+ deleted: 0,
262
+ orphan: 0,
263
+ ambiguous: 0,
264
+ unanchored: 0,
265
+ staleHash: 0,
266
+ maxDriftDays: null,
267
+ failOn,
268
+ exitCode,
269
+ },
270
+ verdicts: [],
271
+ reverse: { enabled: false, hits: [] },
272
+ };
273
+ }
274
+ /** Exit code under a fail-on threshold. */
275
+ export function driftExitCode(verdicts, failOn) {
276
+ if (failOn === "none")
277
+ return 0;
278
+ const threshold = FAIL_RANK[failOn];
279
+ for (const v of verdicts) {
280
+ const rank = stateRank(v.state);
281
+ if (rank === 0)
282
+ continue;
283
+ // orphan/ambiguous are rank 3 — they fail only under `--fail-on any`.
284
+ // cosmetic (1) and semantic/deleted (2) fail when rank is in [threshold, 2].
285
+ if (failOn === "any")
286
+ return 1;
287
+ if (rank >= threshold && rank <= FAIL_RANK.semantic)
288
+ return 1;
289
+ }
290
+ return 0;
291
+ }
292
+ /** Build a full drift report. */
293
+ export function buildDriftReport(projectPath, opts = {}) {
294
+ const failOn = opts.failOn ?? "semantic";
295
+ const generatedAt = new Date().toISOString();
296
+ if (!indexExists(projectPath))
297
+ return emptyReport(generatedAt, failOn, true, 2);
298
+ let resealSummaries;
299
+ if (opts.reseal) {
300
+ if (opts.capability) {
301
+ const specPath = path.join(projectPath, "lawbook", "specs", opts.capability, "spec.md");
302
+ const md = fs.existsSync(specPath) ? fs.readFileSync(specPath, "utf8") : "";
303
+ resealSummaries = [sealCapability(projectPath, opts.capability, md)];
304
+ }
305
+ else {
306
+ resealSummaries = resealAll(projectPath);
307
+ }
308
+ }
309
+ const db = openDb(projectPath);
310
+ try {
311
+ if (needsReindex(db))
312
+ return emptyReport(generatedAt, failOn, true, 2);
313
+ const caps = opts.capability ? [opts.capability] : listAnchoredCapabilities(projectPath);
314
+ const verdicts = [];
315
+ for (const capability of caps) {
316
+ const file = readAnchorsFile(projectPath, capability);
317
+ if (!file || file.anchors.length === 0) {
318
+ verdicts.push({
319
+ capability,
320
+ anchor: {
321
+ specId: capability,
322
+ requirementId: "",
323
+ scenarioId: "",
324
+ anchorKind: "symbol",
325
+ symbolName: "",
326
+ filePath: null,
327
+ resolution: "unresolved",
328
+ contentHash: null,
329
+ rawHash: null,
330
+ archivedAt: generatedAt,
331
+ commitSha: null,
332
+ source: "backtick",
333
+ normalizerVersion: NORMALIZER_VERSION,
334
+ },
335
+ state: "unanchored",
336
+ });
337
+ continue;
338
+ }
339
+ for (const a of file.anchors) {
340
+ verdicts.push(attachAge(projectPath, classifyAnchor(db, projectPath, capability, a)));
341
+ }
342
+ }
343
+ const reverse = opts.reverse
344
+ ? reverseDrift(db, opts.capabilityPaths ?? loadCapabilityPaths(projectPath))
345
+ : { enabled: false, reason: "Pass --reverse to enable.", hits: [] };
346
+ const counts = countStates(verdicts);
347
+ const maxDriftDays = verdicts.reduce((acc, v) => {
348
+ if (v.driftDays == null)
349
+ return acc;
350
+ return acc == null ? v.driftDays : Math.max(acc, v.driftDays);
351
+ }, null);
352
+ return {
353
+ schemaVersion: 1,
354
+ generatedAt,
355
+ normalizerVersion: NORMALIZER_VERSION,
356
+ needsReindex: false,
357
+ summary: {
358
+ capabilities: caps.length,
359
+ anchors: verdicts.filter((v) => v.state !== "unanchored").length,
360
+ ...counts,
361
+ maxDriftDays,
362
+ failOn,
363
+ exitCode: driftExitCode(verdicts, failOn),
364
+ },
365
+ verdicts,
366
+ reverse,
367
+ reseal: resealSummaries,
368
+ };
369
+ }
370
+ finally {
371
+ db.close();
372
+ }
373
+ }
374
+ /** Human TTY table. */
375
+ export function renderDriftTable(report) {
376
+ const s = report.summary;
377
+ const lines = [
378
+ `speclaw drift · ${s.capabilities} capabilities · ${s.anchors} anchors`,
379
+ "",
380
+ `unchanged ${s.unchanged} cosmetic ${s.changedCosmetic} moved ${s.moved} semantic ${s.changedSemantic} deleted ${s.deleted} orphan ${s.orphan} ambiguous ${s.ambiguous}`,
381
+ ];
382
+ const defects = report.verdicts.filter((v) => stateRank(v.state) >= 2);
383
+ if (defects.length) {
384
+ lines.push("");
385
+ for (const d of defects.slice(0, 30)) {
386
+ lines.push(` ${d.state.padEnd(18)} ${d.capability} → ${d.anchor.symbolName || "(unanchored)"}` +
387
+ (d.currentFile ? ` ${d.currentFile}` : ""));
388
+ }
389
+ if (defects.length > 30)
390
+ lines.push(` … ${defects.length - 30} more`);
391
+ }
392
+ if (report.reverse.enabled && report.reverse.hits.length) {
393
+ lines.push("", `reverse · ${report.reverse.hits.length} uncovered symbol(s)`);
394
+ for (const h of report.reverse.hits.slice(0, 15)) {
395
+ lines.push(` ${h.capability} ${h.filePath} ${h.symbolName}`);
396
+ }
397
+ }
398
+ else if (report.reverse.reason) {
399
+ lines.push("", report.reverse.reason);
400
+ }
401
+ return lines.join("\n");
402
+ }
403
+ /** Bounded agent summary. */
404
+ export function renderDriftAgent(report, maxItems = 10) {
405
+ if (report.needsReindex) {
406
+ return "Drift: index needs rebuild (`speclaw index`) before comparison.";
407
+ }
408
+ const s = report.summary;
409
+ const defects = report.verdicts.filter((v) => stateRank(v.state) >= 2);
410
+ if (defects.length === 0 && s.anchors > 0) {
411
+ return `Drift clean — ${s.anchors} anchors across ${s.capabilities} capabilities (fail-on ${s.failOn}).`;
412
+ }
413
+ if (s.anchors === 0) {
414
+ return "Drift: no sealed anchors yet. Run `speclaw drift --reseal` after indexing.";
415
+ }
416
+ const lines = [
417
+ `Drift: ${defects.length} defect(s) · semantic ${s.changedSemantic} · deleted ${s.deleted} · orphan ${s.orphan} (fail-on ${s.failOn}).`,
418
+ ];
419
+ for (const d of defects.slice(0, maxItems)) {
420
+ lines.push(`- [${d.state}] ${d.capability} ${d.anchor.symbolName}`);
421
+ }
422
+ if (defects.length > maxItems)
423
+ lines.push(`- … ${defects.length - maxItems} more`);
424
+ lines.push("Use `speclaw drift --json` for detail.");
425
+ return lines.join("\n");
426
+ }
427
+ /** Semantic/deleted findings for verify --ci. */
428
+ export function driftFindingsForVerify(projectPath) {
429
+ const report = buildDriftReport(projectPath, { failOn: "semantic" });
430
+ const out = [];
431
+ for (const v of report.verdicts) {
432
+ if (v.state !== "changed-semantic" && v.state !== "deleted")
433
+ continue;
434
+ out.push({
435
+ ruleId: `drift~${v.state}`,
436
+ file: v.currentFile ?? v.anchor.filePath ?? "lawbook/anchors",
437
+ line: 1,
438
+ message: `Spec drift (${v.state}): ${v.capability} → ${v.anchor.symbolName}`,
439
+ severity: "error",
440
+ });
441
+ }
442
+ return out;
443
+ }
444
+ /** Doctor check line. */
445
+ export function doctorDriftCheck(projectPath) {
446
+ const caps = listAnchoredCapabilities(projectPath);
447
+ if (caps.length === 0) {
448
+ return {
449
+ id: "cfg.drift",
450
+ title: "spec drift",
451
+ status: "skip",
452
+ detail: "no sealed anchors",
453
+ remedy: "speclaw drift --reseal",
454
+ };
455
+ }
456
+ if (!indexExists(projectPath)) {
457
+ return {
458
+ id: "cfg.drift",
459
+ title: "spec drift",
460
+ status: "warn",
461
+ detail: "index missing",
462
+ remedy: "speclaw index",
463
+ };
464
+ }
465
+ const report = buildDriftReport(projectPath, { failOn: "semantic" });
466
+ if (report.needsReindex) {
467
+ return {
468
+ id: "cfg.drift",
469
+ title: "spec drift",
470
+ status: "warn",
471
+ detail: "index needs rebuild before drift can run",
472
+ remedy: "speclaw index",
473
+ };
474
+ }
475
+ const bad = report.summary.changedSemantic + report.summary.deleted;
476
+ if (bad > 0) {
477
+ return {
478
+ id: "cfg.drift",
479
+ title: "spec drift",
480
+ status: "warn",
481
+ detail: `${bad} semantic/deleted across ${report.summary.anchors} anchors`,
482
+ remedy: "speclaw drift",
483
+ };
484
+ }
485
+ return {
486
+ id: "cfg.drift",
487
+ title: "spec drift",
488
+ status: "ok",
489
+ detail: `${report.summary.anchors} anchors · clean`,
490
+ };
491
+ }
@@ -1,5 +1,7 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
+ import { coverageArchiveBlockers } from "./coverage.js";
4
+ import { sealCapability } from "./anchors.js";
3
5
  // speclaw's own spec-driven workflow engine. Inspired by OpenSpec's model
4
6
  // (proposals, delta specs, changes, archive) but implemented from scratch and
5
7
  // deliberately simpler: a change's specs/ holds the full intended spec for each
@@ -310,6 +312,8 @@ export function specArchivePreconditions(projectPath, change) {
310
312
  blockers.push(`spec not synced: lawbook/specs/${rel} differs from the delta (run sync first)`);
311
313
  }
312
314
  }
315
+ // 4. Opt-in coverage gate: only when the change's delta specs declare ids.
316
+ blockers.push(...coverageArchiveBlockers(projectPath, change));
313
317
  return blockers;
314
318
  }
315
319
  /**
@@ -333,12 +337,49 @@ export function specArchive(projectPath, change, date) {
333
337
  throw new Error(`cannot archive "${change}" — resolve first:\n${blockers.map((b) => ` - ${b}`).join("\n")}`);
334
338
  }
335
339
  const { promoted, created, updated } = specSync(projectPath, change);
340
+ const seals = sealPromotedCapabilities(projectPath, change, [
341
+ ...promoted,
342
+ ...created,
343
+ ...updated,
344
+ ]);
336
345
  const archiveDir = path.join(root, "changes", "archive", `${date}-${change}`);
337
346
  fs.mkdirSync(path.dirname(archiveDir), { recursive: true });
338
347
  if (fs.existsSync(archiveDir))
339
348
  throw new Error(`archive target already exists: ${archiveDir}`);
340
349
  fs.renameSync(changeDir, archiveDir);
341
- return { change, promoted, created, updated, archivedTo: path.relative(projectPath, archiveDir) };
350
+ return {
351
+ change,
352
+ promoted,
353
+ created,
354
+ updated,
355
+ archivedTo: path.relative(projectPath, archiveDir),
356
+ seals,
357
+ };
358
+ }
359
+ /**
360
+ * Seal structural anchors for every capability whose canonical spec was
361
+ * promoted during archive. Missing specs are skipped; zero anchors warn via
362
+ * {@link SealSummary.warned} but never block archive.
363
+ */
364
+ function sealPromotedCapabilities(projectPath, change, promotedPaths) {
365
+ const caps = new Set();
366
+ for (const p of promotedPaths) {
367
+ // lawbook/specs/<capability>/spec.md → capability
368
+ const parts = p.replace(/\\/g, "/").split("/");
369
+ const specsIdx = parts.indexOf("specs");
370
+ if (specsIdx >= 0 && parts[specsIdx + 1])
371
+ caps.add(parts[specsIdx + 1]);
372
+ }
373
+ const out = [];
374
+ for (const capability of [...caps].sort()) {
375
+ const specPath = path.join(specRoot(projectPath), "specs", capability, "spec.md");
376
+ if (!fs.existsSync(specPath))
377
+ continue;
378
+ out.push(sealCapability(projectPath, capability, fs.readFileSync(specPath, "utf8"), {
379
+ specId: `${capability}#${change}`,
380
+ }));
381
+ }
382
+ return out;
342
383
  }
343
384
  /**
344
385
  * List the spec workspace: active changes, archived changes, and canonical
@@ -5,6 +5,8 @@ import { shouldExpose } from "../../shared/exposure.js";
5
5
  import { assetsDir } from "../../shared/paths.js";
6
6
  import { copyRendered } from "../../shared/install.js";
7
7
  import { specInit, specValidate, specSync, specArchive, specList } from "./engine.js";
8
+ import { buildCoverageReport, loadCoverageConfig, renderCoverageAgent } from "./coverage.js";
9
+ import { buildDriftReport, renderDriftAgent } from "./drift.js";
8
10
  const ASSETS = assetsDir(import.meta.url);
9
11
  /**
10
12
  * Install the spec module's workflow interface into a project's ai-specs/:
@@ -34,4 +36,32 @@ export function registerSpec(server, opts = {}) {
34
36
  change: z.string(),
35
37
  date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
36
38
  }, async ({ projectPath, change, date }) => text(specArchive(projectPath, change, date)));
39
+ add("lawbook_coverage", "Report which requirements lack impl/test coverage before declaring work done.", {
40
+ projectPath: z.string(),
41
+ change: z.string().optional(),
42
+ onlyDefects: z.boolean().optional(),
43
+ json: z.boolean().optional(),
44
+ }, async ({ projectPath, change, onlyDefects, json }) => {
45
+ const cfg = loadCoverageConfig(projectPath);
46
+ const report = buildCoverageReport(projectPath, { change, cfg });
47
+ if (json)
48
+ return text(JSON.stringify(report));
49
+ return text(renderCoverageAgent(report, onlyDefects !== false));
50
+ });
51
+ add("lawbook_drift", "Report deterministic drift between sealed spec anchors and the code graph. Call before claiming a task is done.", {
52
+ projectPath: z.string(),
53
+ capability: z.string().optional(),
54
+ includeReverse: z.boolean().optional(),
55
+ maxItems: z.number().int().min(1).max(50).optional(),
56
+ json: z.boolean().optional(),
57
+ }, async ({ projectPath, capability, includeReverse, maxItems, json }) => {
58
+ const report = buildDriftReport(projectPath, {
59
+ capability,
60
+ reverse: includeReverse === true,
61
+ failOn: "semantic",
62
+ });
63
+ if (json)
64
+ return text(JSON.stringify(report));
65
+ return text(renderDriftAgent(report, maxItems ?? 10));
66
+ });
37
67
  }