@esneiderbravo/speclaw 0.3.5 → 0.3.8
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 +44 -15
- package/dist/cli/commands/coverage.js +49 -0
- package/dist/cli/commands/doctor.js +53 -21
- package/dist/cli/commands/telemetry.js +16 -0
- package/dist/cli/commands/update.js +21 -0
- package/dist/cli/index.js +16 -2
- package/dist/cli/lib/untrack.js +1 -0
- package/dist/modules/compass/db.js +20 -1
- package/dist/modules/compass/extract.js +59 -5
- package/dist/modules/compass/indexer.js +30 -1
- package/dist/modules/foundation/context-budget.js +16 -3
- package/dist/modules/foundation/doctor.js +626 -170
- package/dist/modules/foundation/graph.js +7 -4
- package/dist/modules/foundation/hooks.js +12 -0
- package/dist/modules/foundation/laws.js +1 -0
- package/dist/modules/foundation/register-core.js +108 -0
- package/dist/modules/foundation/register.js +18 -106
- package/dist/modules/lawbook/coverage.js +479 -0
- package/dist/modules/lawbook/engine.js +3 -0
- package/dist/modules/lawbook/register.js +13 -0
- package/dist/modules/lawbook/spec-items.js +168 -0
- package/dist/shared/exposure.js +1 -1
- package/dist/shared/install.js +1 -0
- package/dist/shared/redact.js +90 -0
- package/package.json +1 -1
|
@@ -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
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
*/
|
|
14
|
-
export function
|
|
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
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
"
|
|
36
|
-
"
|
|
37
|
-
|
|
38
|
-
"
|
|
39
|
-
"
|
|
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
|
-
|
|
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
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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
|
|
67
|
-
|
|
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
|
-
|
|
74
|
-
|
|
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
|
-
|
|
215
|
+
broken.push(`${agent.ideDir}/${target}`);
|
|
83
216
|
}
|
|
84
|
-
checks.push({ name: `${ideDir}/${target}`, ok, detail });
|
|
85
217
|
}
|
|
86
218
|
}
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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
|
-
|
|
116
|
-
|
|
117
|
-
const
|
|
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
|
|
120
|
-
|
|
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
|
-
|
|
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
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
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
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
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}
|
|
354
|
+
badGlobs.push(`${law.id}: ${pattern}`);
|
|
183
355
|
}
|
|
184
356
|
}
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
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
|
|
206
|
-
|
|
207
|
-
.
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
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
|
}
|