@esneiderbravo/speclaw 0.3.5 → 0.3.7

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.
@@ -1,218 +1,674 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
3
  import { AGENTS, agentById, detectConfiguredAgents } from "../../shared/agents.js";
4
- import { isMinimalMode } from "../../shared/exposure.js";
4
+ import { isMinimalMode, packageRoot } from "../../shared/exposure.js";
5
+ import { isGitRepo } from "../../shared/git.js";
6
+ import { readManifest } from "../../shared/manifest.js";
7
+ import { pkgName, pkgVersion } from "../../shared/version.js";
8
+ import { indexExists, openDb } from "../compass/db.js";
9
+ import { specList } from "../lawbook/engine.js";
5
10
  import { globError, hasBackend, hasBatchBackend, readLawManifest } from "./laws.js";
6
- /**
7
- * Run the speclaw installation health checks against a project: ai-specs and
8
- * LAWS.md presence, agent contracts, the docs/standards set, per-agent IDE
9
- * symlink health, the lawbook/ workflow, the Compass index, and .mcp.json wiring.
10
- *
11
- * @param projectPath - Absolute path to the project root.
12
- * @returns One {@link Check} per verified item, each carrying a remediation hint.
13
- */
14
- export function doctor(projectPath) {
11
+ import { redactValue } from "../../shared/redact.js";
12
+ const STATUS_RANK = {
13
+ skip: 0,
14
+ ok: 1,
15
+ warn: 2,
16
+ error: 3,
17
+ };
18
+ /** Worst status among a list (`error` > `warn` > `ok` > `skip`). */
19
+ export function worstStatus(statuses) {
20
+ let worst = "skip";
21
+ for (const s of statuses) {
22
+ if (STATUS_RANK[s] > STATUS_RANK[worst])
23
+ worst = s;
24
+ }
25
+ return worst;
26
+ }
27
+ function sectionOf(id, checks) {
28
+ return { id, status: worstStatus(checks.map((c) => c.status)), checks };
29
+ }
30
+ function detectInstallKind() {
31
+ const argv1 = process.argv[1] ?? "";
32
+ if (argv1.includes("_npx") ||
33
+ argv1.includes(`${path.sep}npx${path.sep}`) ||
34
+ process.env.npm_command === "exec") {
35
+ return "npx";
36
+ }
37
+ if (argv1.includes(`${path.sep}node_modules${path.sep}`))
38
+ return "local";
39
+ if (argv1.includes(`${path.sep}bin${path.sep}`) || argv1.includes(`${path.sep}.npm-global`)) {
40
+ return "global";
41
+ }
42
+ return "unknown";
43
+ }
44
+ function enginesRequirement() {
45
+ try {
46
+ const pkg = JSON.parse(fs.readFileSync(path.join(packageRoot(), "package.json"), "utf8"));
47
+ return pkg.engines?.node ?? ">=22";
48
+ }
49
+ catch {
50
+ return ">=22";
51
+ }
52
+ }
53
+ function nodeSatisfies(required, version) {
54
+ const m = /^>=\s*(\d+)/.exec(required.trim());
55
+ if (!m)
56
+ return true;
57
+ const major = parseInt(version.replace(/^v/, "").split(".")[0], 10);
58
+ return major >= parseInt(m[1], 10);
59
+ }
60
+ function libcLabel() {
61
+ if (process.platform !== "linux")
62
+ return "";
63
+ try {
64
+ const report = process.report;
65
+ const r = report?.getReport?.();
66
+ if (r?.header?.glibcVersionRuntime)
67
+ return `glibc ${r.header.glibcVersionRuntime}`;
68
+ }
69
+ catch {
70
+ /* ignore */
71
+ }
72
+ return "libc unknown";
73
+ }
74
+ function addCheck(checks, partial) {
75
+ if ((partial.status === "warn" || partial.status === "error") && !partial.remedy) {
76
+ // Spec: no remedy → demote to notes-style ok detail rather than a false warn.
77
+ checks.push({ ...partial, status: "ok", remedy: undefined });
78
+ return;
79
+ }
80
+ checks.push(partial);
81
+ }
82
+ /** The law ids recorded as loaded into agent context, from the append-only log. */
83
+ function loadedLawIds(projectPath) {
84
+ const loaded = new Set();
85
+ try {
86
+ const log = fs.readFileSync(path.join(projectPath, ".speclaw", "context-log.jsonl"), "utf8");
87
+ for (const line of log.split(/\r?\n/)) {
88
+ if (!line.trim())
89
+ continue;
90
+ const ids = JSON.parse(line).lawIds ?? [];
91
+ for (const id of ids)
92
+ loaded.add(id);
93
+ }
94
+ }
95
+ catch {
96
+ /* no log yet */
97
+ }
98
+ return loaded;
99
+ }
100
+ function readIndexedAt(projectPath) {
101
+ if (!indexExists(projectPath))
102
+ return null;
103
+ try {
104
+ const db = openDb(projectPath);
105
+ try {
106
+ const row = db.prepare("SELECT value FROM meta WHERE key = 'indexed_at'").get();
107
+ return row?.value ?? null;
108
+ }
109
+ finally {
110
+ db.close();
111
+ }
112
+ }
113
+ catch {
114
+ return null;
115
+ }
116
+ }
117
+ function countFilesNewerThan(projectPath, sinceMs) {
118
+ if (!indexExists(projectPath))
119
+ return 0;
120
+ try {
121
+ const db = openDb(projectPath);
122
+ try {
123
+ const rows = db.prepare("SELECT path FROM files").all();
124
+ let n = 0;
125
+ for (const r of rows) {
126
+ try {
127
+ if (fs.statSync(path.join(projectPath, r.path)).mtimeMs > sinceMs)
128
+ n++;
129
+ }
130
+ catch {
131
+ /* deleted */
132
+ }
133
+ }
134
+ return n;
135
+ }
136
+ finally {
137
+ db.close();
138
+ }
139
+ }
140
+ catch {
141
+ return 0;
142
+ }
143
+ }
144
+ function environmentChecks() {
15
145
  const checks = [];
16
- const has = (rel) => fs.existsSync(path.join(projectPath, rel));
17
- checks.push({
18
- name: "ai-specs directory",
19
- ok: has("ai-specs"),
20
- detail: has("ai-specs") ? "present" : "missing — run the scaffold tool first",
146
+ const required = enginesRequirement();
147
+ const okNode = nodeSatisfies(required, process.version);
148
+ addCheck(checks, {
149
+ id: "env.node",
150
+ title: "node",
151
+ status: okNode ? "ok" : "error",
152
+ value: process.version,
153
+ detail: `${process.version} (requires ${required})`,
154
+ remedy: okNode ? undefined : `Install Node.js ${required} (https://nodejs.org)`,
21
155
  });
22
- checks.push({
23
- name: "LAWS.md constitution",
24
- ok: has("LAWS.md"),
25
- detail: has("LAWS.md") ? "present" : "missing — the project has no law",
156
+ const libc = libcLabel();
157
+ addCheck(checks, {
158
+ id: "env.platform",
159
+ title: "platform",
160
+ status: "ok",
161
+ value: `${process.platform} ${process.arch}`,
162
+ detail: [process.platform, process.arch, libc].filter(Boolean).join(" "),
26
163
  });
27
- for (const entry of ["CLAUDE.md", "AGENTS.md", "docs/compass.md"]) {
28
- checks.push({
29
- name: `${entry} agent contract`,
30
- ok: has(entry),
31
- detail: has(entry) ? "present" : "missing — scaffold writes it",
32
- });
33
- }
34
- const standards = [
35
- "base-standards",
36
- "architecture",
37
- "backend-standards",
38
- "frontend-standards",
39
- "testing-standards",
40
- "documentation",
41
- "conventions",
42
- "lawbook",
43
- ];
44
- const missingStandards = standards.filter((s) => !has(path.join("docs/standards", `${s}.md`)));
45
- checks.push({
46
- name: "docs/standards/*",
47
- ok: missingStandards.length === 0,
48
- detail: missingStandards.length === 0
49
- ? `all ${standards.length} standards present`
50
- : `missing: ${missingStandards.join(", ")}`,
164
+ // Filled by caller with projectPath — placeholder; see buildEnvironment.
165
+ return checks;
166
+ }
167
+ function buildEnvironment(projectPath) {
168
+ const checks = environmentChecks();
169
+ const git = isGitRepo(projectPath);
170
+ addCheck(checks, {
171
+ id: "env.git",
172
+ title: "git",
173
+ status: git ? "ok" : "warn",
174
+ value: git,
175
+ detail: git ? "repository" : "not a git repository",
176
+ remedy: git ? undefined : "git init",
51
177
  });
52
- // Only check the agents the user actually configured — selection is opt-in.
178
+ addCheck(checks, {
179
+ id: "env.ast-engine",
180
+ title: "ast engine",
181
+ status: "skip",
182
+ detail: "@ast-grep/napi not shipped yet — skip until executable-laws wires it",
183
+ remedy: "speclaw update",
184
+ });
185
+ return checks;
186
+ }
187
+ function symlinkChecks(projectPath) {
188
+ const has = (rel) => fs.existsSync(path.join(projectPath, rel));
53
189
  const configured = AGENTS.filter((a) => detectConfiguredAgents(projectPath).includes(a.id));
54
190
  if (configured.length === 0) {
55
- checks.push({
56
- name: "agents",
57
- ok: false,
58
- detail: "none configured — run `speclaw init` or `speclaw agent add <id>`",
59
- });
191
+ return {
192
+ id: "cfg.symlinks",
193
+ title: "agent surfaces",
194
+ status: "warn",
195
+ detail: "none configured",
196
+ remedy: "speclaw init",
197
+ };
60
198
  }
199
+ const broken = [];
200
+ let linkCount = 0;
61
201
  for (const agent of configured) {
62
202
  for (const target of agent.linkTargets) {
63
- // only demand links for content that actually exists in ai-specs/
64
203
  if (!has(path.join("ai-specs", target)))
65
204
  continue;
66
- const ideDir = agent.ideDir;
67
- const linkPath = path.join(projectPath, ideDir, target);
68
- let ok = false;
69
- let detail = "missing";
205
+ const linkPath = path.join(projectPath, agent.ideDir, target);
206
+ linkCount++;
70
207
  try {
71
208
  const stat = fs.lstatSync(linkPath);
72
209
  if (stat.isSymbolicLink()) {
73
- ok = fs.existsSync(linkPath); // broken symlink -> false
74
- detail = ok ? `-> ${fs.readlinkSync(linkPath)}` : "broken symlink";
75
- }
76
- else {
77
- ok = true;
78
- detail = "real directory (not a symlink — consider migrating to ai-specs)";
210
+ if (!fs.existsSync(linkPath))
211
+ broken.push(`${agent.ideDir}/${target}`);
79
212
  }
80
213
  }
81
214
  catch {
82
- // stays missing
215
+ broken.push(`${agent.ideDir}/${target}`);
83
216
  }
84
- checks.push({ name: `${ideDir}/${target}`, ok, detail });
85
217
  }
86
218
  }
87
- checks.push({
88
- name: "lawbook workflow",
89
- ok: has("lawbook"),
90
- detail: has("lawbook") ? "lawbook/ present" : "missing — run the `lawbook_init` tool",
91
- });
92
- checks.push({
93
- name: "Compass index",
94
- ok: has(".speclaw/index.db"),
95
- detail: has(".speclaw/index.db")
96
- ? ".speclaw/index.db present"
97
- : "missing — run the `compass_index` tool",
98
- });
99
- checks.push({
100
- name: ".mcp.json wiring",
101
- ok: has(".mcp.json"),
102
- detail: has(".mcp.json") ? "present" : "missing — scaffold writes it",
103
- });
104
- lawEnforcementChecks(projectPath, checks);
105
- const minimal = isMinimalMode(projectPath);
106
- checks.push({
107
- name: "exposure profile",
108
- ok: true,
109
- detail: minimal
110
- ? "minimal — setup/lifecycle MCP tools are omitted from registration"
111
- : "full — all MCP tools registered (no server-side defer_loading)",
112
- });
113
- return checks;
219
+ if (broken.length) {
220
+ return {
221
+ id: "cfg.symlinks",
222
+ title: "agent surfaces",
223
+ status: "error",
224
+ value: broken.join(", "),
225
+ detail: `${configured.length} configured · broken: ${broken.join(", ")}`,
226
+ remedy: "speclaw update",
227
+ };
228
+ }
229
+ return {
230
+ id: "cfg.symlinks",
231
+ title: "agent surfaces",
232
+ status: "ok",
233
+ detail: `${configured.length} configured, ${linkCount} link(s) intact`,
234
+ };
114
235
  }
115
- /** The law ids recorded as loaded into agent context, from the append-only log. */
116
- function loadedLawIds(projectPath) {
117
- const loaded = new Set();
236
+ async function mcpCheckAsync(projectPath, agentId) {
237
+ const agent = agentById(agentId);
238
+ const id = `cfg.mcp.${agentId}`;
239
+ const title = `mcp · ${agentId}`;
240
+ if (!agent.mcpFile) {
241
+ return {
242
+ id,
243
+ title,
244
+ status: "skip",
245
+ detail: `${agent.label} has no MCP config surface`,
246
+ };
247
+ }
248
+ const mcpPath = path.join(projectPath, agent.mcpFile);
249
+ if (!fs.existsSync(mcpPath)) {
250
+ return {
251
+ id,
252
+ title,
253
+ status: "warn",
254
+ detail: "not configured",
255
+ remedy: `speclaw agent add ${agentId}`,
256
+ };
257
+ }
258
+ let entry;
118
259
  try {
119
- const log = fs.readFileSync(path.join(projectPath, ".speclaw", "context-log.jsonl"), "utf8");
120
- for (const line of log.split(/\r?\n/)) {
121
- if (!line.trim())
122
- continue;
123
- const ids = JSON.parse(line).lawIds ?? [];
124
- for (const id of ids)
125
- loaded.add(id);
126
- }
260
+ const cfg = JSON.parse(fs.readFileSync(mcpPath, "utf8"));
261
+ entry = cfg.mcpServers?.speclaw;
127
262
  }
128
263
  catch {
129
- // No log yet — hooks have not recorded any context loads.
264
+ return {
265
+ id,
266
+ title,
267
+ status: "warn",
268
+ detail: "MCP config unreadable",
269
+ remedy: `speclaw agent add ${agentId}`,
270
+ };
271
+ }
272
+ if (!entry) {
273
+ return {
274
+ id,
275
+ title,
276
+ status: "warn",
277
+ detail: "not configured (no speclaw server entry)",
278
+ remedy: `speclaw agent add ${agentId}`,
279
+ };
280
+ }
281
+ try {
282
+ const { collectRegisteredTools } = await import("./context-budget.js");
283
+ const tools = collectRegisteredTools(isMinimalMode(projectPath));
284
+ return {
285
+ id,
286
+ title,
287
+ status: "ok",
288
+ value: tools.length,
289
+ detail: `reachable (self-probe), ${tools.length} tools`,
290
+ };
291
+ }
292
+ catch (err) {
293
+ return {
294
+ id,
295
+ title,
296
+ status: "warn",
297
+ detail: `configured but probe failed: ${err.message}`,
298
+ remedy: `speclaw agent add ${agentId}`,
299
+ };
130
300
  }
131
- return loaded;
132
301
  }
133
- /**
134
- * Append the law-enforcement health checks: manifest presence and backend
135
- * coverage, glob validity (caught here rather than at runtime), context-coverage
136
- * with the post-compact caveat, and the agents where blocking laws don't apply.
137
- */
138
- function lawEnforcementChecks(projectPath, checks) {
302
+ /** Semver compare — true when `latest` is strictly newer than `current`. */
303
+ function isNewerVersion(latest, current) {
304
+ const parse = (v) => v
305
+ .split("-")[0]
306
+ .split(".")
307
+ .map((n) => parseInt(n, 10) || 0);
308
+ const a = parse(latest);
309
+ const b = parse(current);
310
+ for (let i = 0; i < 3; i++) {
311
+ const x = a[i] ?? 0;
312
+ const y = b[i] ?? 0;
313
+ if (x > y)
314
+ return true;
315
+ if (x < y)
316
+ return false;
317
+ }
318
+ return false;
319
+ }
320
+ async function fetchLatestVersion(name) {
321
+ const ctrl = new AbortController();
322
+ const timer = setTimeout(() => ctrl.abort(), 2500);
323
+ try {
324
+ const url = `https://registry.npmjs.org/${name.replace("/", "%2F")}/latest`;
325
+ const res = await fetch(url, { signal: ctrl.signal });
326
+ if (!res.ok)
327
+ return null;
328
+ const body = (await res.json());
329
+ return typeof body.version === "string" ? body.version : null;
330
+ }
331
+ catch {
332
+ return null;
333
+ }
334
+ finally {
335
+ clearTimeout(timer);
336
+ }
337
+ }
338
+ function lawsCheck(projectPath) {
139
339
  const manifest = readLawManifest(projectPath);
140
340
  if (!manifest) {
141
- checks.push({
142
- name: "law manifest",
143
- ok: false,
144
- detail: "missing — run `speclaw init`/`update` to seed .speclaw/laws-manifest.json",
145
- });
146
- return;
341
+ return {
342
+ id: "cfg.laws",
343
+ title: "laws",
344
+ status: "warn",
345
+ detail: "manifest missing",
346
+ remedy: "speclaw update",
347
+ };
147
348
  }
148
- const withPath = manifest.laws.filter(hasBackend);
149
- const withBatch = manifest.laws.filter(hasBatchBackend);
150
- const noBackend = manifest.laws.filter((l) => !hasBackend(l) && !hasBatchBackend(l));
151
- checks.push({
152
- name: "law manifest",
153
- ok: true,
154
- detail: `${manifest.laws.length} law(s): ${withPath.length} enforced (path), ` +
155
- `${withBatch.length} verified (deps/graph)` +
156
- (noBackend.length
157
- ? `, ${noBackend.length} declared without a backend yet (${noBackend
158
- .map((l) => l.id)
159
- .join(", ")})`
160
- : ""),
161
- });
162
- // Graph-engine availability — the deps/graph backends need the Compass index.
163
- if (withBatch.length > 0) {
164
- const indexed = fs.existsSync(path.join(projectPath, ".speclaw", "index.db"));
165
- checks.push({
166
- name: "graph law engines",
167
- ok: indexed,
168
- detail: indexed
169
- ? `index present — ${withBatch.length} deps/graph law(s) evaluable via \`speclaw laws verify\``
170
- : `${withBatch.length} deps/graph law(s) will be skipped (no-index) — run the \`compass_index\` tool`,
171
- });
172
- }
173
- // Glob validation — a malformed scope glob must fail loudly here, never
174
- // silently match zero files at runtime. (A malformed deps/graph regex is
175
- // rejected earlier, when the manifest is validated, so a manifest that reaches
176
- // here has none.)
177
349
  const badGlobs = [];
178
350
  for (const law of manifest.laws) {
179
351
  for (const pattern of law.scope) {
180
352
  const err = globError(pattern);
181
353
  if (err)
182
- badGlobs.push(`${law.id}: ${pattern} (${err})`);
354
+ badGlobs.push(`${law.id}: ${pattern}`);
183
355
  }
184
356
  }
185
- checks.push({
186
- name: "law scope globs",
187
- ok: badGlobs.length === 0,
188
- detail: badGlobs.length === 0 ? "all valid" : `malformed: ${badGlobs.join("; ")}`,
357
+ if (badGlobs.length) {
358
+ return {
359
+ id: "cfg.laws",
360
+ title: "laws",
361
+ status: "error",
362
+ detail: `invalid globs: ${badGlobs.join("; ")}`,
363
+ remedy: "Fix scope globs in .speclaw/laws-manifest.json",
364
+ };
365
+ }
366
+ const withPath = manifest.laws.filter(hasBackend).length;
367
+ const withBatch = manifest.laws.filter(hasBatchBackend).length;
368
+ return {
369
+ id: "cfg.laws",
370
+ title: "laws",
371
+ status: "ok",
372
+ value: manifest.laws.length,
373
+ detail: `${manifest.laws.length} declared · ${withPath} path · ${withBatch} deps/graph · 0 invalid`,
374
+ };
375
+ }
376
+ async function budgetCheck(projectPath) {
377
+ try {
378
+ const { measureInstallBudget } = await import("./context-budget.js");
379
+ const m = measureInstallBudget(projectPath);
380
+ return {
381
+ id: "cfg.budget",
382
+ title: "context cost",
383
+ status: "ok",
384
+ value: m.total,
385
+ detail: `~${m.total} always-on tokens (${m.profile}, ${m.toolCount} tools)`,
386
+ };
387
+ }
388
+ catch (err) {
389
+ return {
390
+ id: "cfg.budget",
391
+ title: "context cost",
392
+ status: "skip",
393
+ detail: `could not measure: ${err.message}`,
394
+ remedy: "speclaw budget",
395
+ };
396
+ }
397
+ }
398
+ function freshnessCheck(projectPath) {
399
+ if (!indexExists(projectPath)) {
400
+ return {
401
+ id: "cfg.index.freshness",
402
+ title: "index freshness",
403
+ status: "warn",
404
+ detail: "no index",
405
+ remedy: "speclaw index",
406
+ };
407
+ }
408
+ const indexedAt = readIndexedAt(projectPath);
409
+ if (!indexedAt) {
410
+ return {
411
+ id: "cfg.index.freshness",
412
+ title: "index freshness",
413
+ status: "skip",
414
+ detail: "meta.indexed_at absent (index from an older speclaw)",
415
+ remedy: "speclaw index",
416
+ };
417
+ }
418
+ const sinceMs = Date.parse(indexedAt);
419
+ if (Number.isNaN(sinceMs)) {
420
+ return {
421
+ id: "cfg.index.freshness",
422
+ title: "index freshness",
423
+ status: "skip",
424
+ detail: "meta.indexed_at unparseable",
425
+ remedy: "speclaw index",
426
+ };
427
+ }
428
+ const ageDays = (Date.now() - sinceMs) / (24 * 60 * 60 * 1000);
429
+ const changed = countFilesNewerThan(projectPath, sinceMs);
430
+ const stale = ageDays > 7 && changed > 0;
431
+ return {
432
+ id: "cfg.index.freshness",
433
+ title: "index freshness",
434
+ status: stale ? "warn" : "ok",
435
+ value: Math.floor(ageDays),
436
+ detail: `${Math.floor(ageDays)} day(s) old · ${changed} file(s) newer than index`,
437
+ remedy: stale ? "speclaw index" : undefined,
438
+ };
439
+ }
440
+ function specsOrphansCheck(projectPath) {
441
+ const list = specList(projectPath);
442
+ if (!list.initialized) {
443
+ return {
444
+ id: "cfg.specs.orphans",
445
+ title: "specs",
446
+ status: "skip",
447
+ detail: "lawbook not initialised",
448
+ remedy: "speclaw lawbook init",
449
+ };
450
+ }
451
+ const active = list.activeChanges;
452
+ if (active.length === 0) {
453
+ return {
454
+ id: "cfg.specs.orphans",
455
+ title: "specs",
456
+ status: "ok",
457
+ detail: "no active changes",
458
+ };
459
+ }
460
+ return {
461
+ id: "cfg.specs.orphans",
462
+ title: "specs",
463
+ status: "warn",
464
+ value: active.join(", "),
465
+ detail: `${active.length} change(s) not archived: ${active.join(", ")}`,
466
+ remedy: `speclaw lawbook archive ${active[0]}`,
467
+ };
468
+ }
469
+ function configurationChecks(projectPath, initialised) {
470
+ if (!initialised) {
471
+ const ids = [
472
+ "cfg.manifest",
473
+ "cfg.ownership",
474
+ "cfg.symlinks",
475
+ "cfg.hooks",
476
+ "cfg.laws",
477
+ "cfg.budget",
478
+ "cfg.index.freshness",
479
+ "cfg.specs.orphans",
480
+ ];
481
+ return ids.map((id) => ({
482
+ id,
483
+ title: id.replace(/^cfg\./, ""),
484
+ status: "skip",
485
+ detail: "project not initialised",
486
+ remedy: "speclaw init",
487
+ }));
488
+ }
489
+ const checks = [];
490
+ const manifest = readManifest(projectPath);
491
+ addCheck(checks, {
492
+ id: "cfg.manifest",
493
+ title: "manifest",
494
+ status: manifest ? "ok" : "warn",
495
+ detail: manifest ? `.speclaw.json (written by ${manifest.version})` : "missing .speclaw.json",
496
+ remedy: manifest ? undefined : "speclaw init",
497
+ });
498
+ addCheck(checks, {
499
+ id: "cfg.ownership",
500
+ title: "managed files",
501
+ status: "skip",
502
+ detail: "content-hash inventory not available yet — skip",
503
+ remedy: "speclaw update",
189
504
  });
190
- // Context coverage — which laws actually entered the agent's context.
505
+ checks.push(symlinkChecks(projectPath));
506
+ addCheck(checks, {
507
+ id: "cfg.hooks",
508
+ title: "hooks",
509
+ status: "skip",
510
+ detail: "see notes.compact / law context coverage — no dedicated hooks probe yet",
511
+ remedy: "speclaw update",
512
+ });
513
+ checks.push(lawsCheck(projectPath));
514
+ // budget + mcp + freshness + specs filled async by caller
515
+ return checks;
516
+ }
517
+ function notesSection(projectPath) {
518
+ const checks = [];
191
519
  const loaded = loadedLawIds(projectPath);
192
- const declared = manifest.laws.map((l) => l.id);
520
+ const manifest = readLawManifest(projectPath);
521
+ const declared = manifest?.laws.map((l) => l.id) ?? [];
193
522
  const missing = declared.filter((id) => !loaded.has(id));
194
- checks.push({
195
- name: "law context coverage",
196
- ok: true,
197
- detail: `${declared.length - missing.length} of ${declared.length} laws loaded into context` +
198
- (missing.length ? ` not yet loaded: ${missing.join(", ")}` : "") +
199
- ". Note: after a compact, root CLAUDE.md is re-injected but `paths:`-scoped rules are not," +
200
- " until a matching file is next touched — so a path-scoped law can be out of context" +
201
- " exactly when it matters, which is why it is also a hook.",
523
+ addCheck(checks, {
524
+ id: "notes.compact",
525
+ title: "post-compact rules",
526
+ status: "ok",
527
+ detail: "Rules with `paths:` are NOT re-injected after a context compact. " +
528
+ (manifest
529
+ ? `${declared.length - missing.length}/${declared.length} laws seen in context-log.`
530
+ : "No law manifest."),
202
531
  });
203
- // Agent asymmetry — where blocking laws cannot be enforced at the keystroke.
204
532
  const configured = detectConfiguredAgents(projectPath);
205
- const unhooked = configured
206
- .map((id) => agentById(id))
207
- .filter((a) => a && !a.hooks)
208
- .map((a) => a.label);
209
- if (unhooked.length) {
210
- const blocking = manifest.laws.filter((l) => l.enforcement === "bloqueo").length;
211
- checks.push({
212
- name: "hook coverage across agents",
213
- ok: true,
214
- detail: `no hook support for ${unhooked.join(", ")} — your ${blocking} blocking law(s) apply ` +
215
- "there only via `speclaw verify`.",
533
+ const caps = AGENTS.map((a) => {
534
+ const on = configured.includes(a.id);
535
+ return `${a.id}: hooks=${a.hooks ? "yes" : "no"} mcp=${a.mcpFile ? "yes" : "no"}${on ? " (configured)" : ""}`;
536
+ });
537
+ addCheck(checks, {
538
+ id: "notes.capabilities",
539
+ title: "agent capabilities",
540
+ status: "ok",
541
+ detail: caps.join("; "),
542
+ });
543
+ return checks;
544
+ }
545
+ /**
546
+ * Build the versioned diagnostic report for a project.
547
+ *
548
+ * @param projectPath - Absolute project root.
549
+ * @param opts - Offline / redaction options.
550
+ */
551
+ export async function doctor(projectPath, opts = {}) {
552
+ const redact = opts.redact !== false;
553
+ const offline = Boolean(opts.offline);
554
+ const initialised = Boolean(readManifest(projectPath)) || fs.existsSync(path.join(projectPath, "LAWS.md"));
555
+ const environment = buildEnvironment(projectPath);
556
+ const configuration = configurationChecks(projectPath, initialised);
557
+ if (initialised) {
558
+ configuration.push(await budgetCheck(projectPath));
559
+ configuration.push(freshnessCheck(projectPath));
560
+ configuration.push(specsOrphansCheck(projectPath));
561
+ const configured = detectConfiguredAgents(projectPath);
562
+ const mcpAgents = AGENTS.filter((a) => a.mcpFile && configured.includes(a.id));
563
+ if (mcpAgents.length === 0) {
564
+ // Still report one representative unconfigured surface when agents exist without mcp.
565
+ const withMcp = AGENTS.filter((a) => a.mcpFile);
566
+ for (const a of withMcp.slice(0, 1)) {
567
+ configuration.push(await mcpCheckAsync(projectPath, a.id));
568
+ }
569
+ }
570
+ else {
571
+ for (const a of mcpAgents) {
572
+ configuration.push(await mcpCheckAsync(projectPath, a.id));
573
+ }
574
+ }
575
+ }
576
+ const authentication = [
577
+ {
578
+ id: "auth.none",
579
+ title: "credentials",
580
+ status: "ok",
581
+ detail: "none — speclaw stores no credentials and runs fully local",
582
+ },
583
+ ];
584
+ const connectivity = [];
585
+ if (offline) {
586
+ connectivity.push({
587
+ id: "conn.registry",
588
+ title: "npm registry",
589
+ status: "skip",
590
+ detail: "skipped (--offline)",
591
+ remedy: "speclaw doctor",
216
592
  });
217
593
  }
594
+ else {
595
+ const current = pkgVersion();
596
+ const latest = await fetchLatestVersion(pkgName());
597
+ if (!latest) {
598
+ connectivity.push({
599
+ id: "conn.registry",
600
+ title: "npm registry",
601
+ status: "skip",
602
+ detail: "registry unreachable",
603
+ remedy: "speclaw doctor --offline",
604
+ });
605
+ }
606
+ else if (isNewerVersion(latest, current)) {
607
+ connectivity.push({
608
+ id: "conn.registry",
609
+ title: "npm registry",
610
+ status: "warn",
611
+ value: latest,
612
+ detail: `${current} installed, ${latest} available`,
613
+ remedy: "npx @esneiderbravo/speclaw@latest update",
614
+ });
615
+ }
616
+ else {
617
+ connectivity.push({
618
+ id: "conn.registry",
619
+ title: "npm registry",
620
+ status: "ok",
621
+ detail: `${current} installed (latest)`,
622
+ });
623
+ }
624
+ }
625
+ connectivity.push({
626
+ id: "conn.egress",
627
+ title: "outbound requests",
628
+ status: "ok",
629
+ value: 1,
630
+ detail: "1 possible: npm version check (disable with --offline). No analytics, no authenticated calls.",
631
+ });
632
+ const notes = notesSection(projectPath);
633
+ const sections = [
634
+ sectionOf("environment", environment),
635
+ sectionOf("configuration", configuration),
636
+ sectionOf("authentication", authentication),
637
+ sectionOf("connectivity", connectivity),
638
+ sectionOf("notes", notes),
639
+ ];
640
+ let report = {
641
+ schemaVersion: 1,
642
+ generatedAt: new Date().toISOString(),
643
+ speclaw: {
644
+ version: pkgVersion(),
645
+ mode: isMinimalMode(projectPath) ? "minimal" : "full",
646
+ installKind: detectInstallKind(),
647
+ },
648
+ status: worstStatus(sections.map((s) => s.status)),
649
+ redacted: redact,
650
+ sections,
651
+ };
652
+ if (redact) {
653
+ report = redactValue(report, projectPath);
654
+ report.redacted = true;
655
+ }
656
+ return report;
657
+ }
658
+ /**
659
+ * Flatten checks for legacy callers that still expect a name/ok/detail list.
660
+ * Prefer {@link doctor} + {@link DoctorReport}.
661
+ */
662
+ export function flattenChecks(report) {
663
+ const out = [];
664
+ for (const section of report.sections) {
665
+ for (const c of section.checks) {
666
+ out.push({
667
+ name: c.id,
668
+ ok: c.status === "ok" || c.status === "skip",
669
+ detail: c.detail ?? c.status,
670
+ });
671
+ }
672
+ }
673
+ return out;
218
674
  }