@esneiderbravo/speclaw 0.3.13 → 1.0.0
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 +88 -72
- package/dist/cli/commands/index-build.js +12 -3
- package/dist/cli/commands/lawbook.js +1 -0
- package/dist/cli/commands/laws.js +149 -8
- package/dist/cli/commands/owners.js +44 -0
- package/dist/cli/commands/query.js +52 -10
- package/dist/cli/commands/update.js +35 -5
- package/dist/cli/commands/verify.js +8 -0
- package/dist/cli/index.js +15 -4
- package/dist/modules/compass/budget.js +128 -0
- package/dist/modules/compass/db.js +290 -30
- package/dist/modules/compass/diff-context.js +134 -0
- package/dist/modules/compass/embed-input.js +28 -0
- package/dist/modules/compass/embedder.js +3 -1
- package/dist/modules/compass/explore-rich.js +134 -0
- package/dist/modules/compass/extract.js +86 -0
- package/dist/modules/compass/hybrid.js +318 -0
- package/dist/modules/compass/impact-summary.js +33 -0
- package/dist/modules/compass/indexer.js +204 -33
- package/dist/modules/compass/merkle.js +76 -0
- package/dist/modules/compass/pagerank.js +122 -0
- package/dist/modules/compass/rank.js +95 -0
- package/dist/modules/compass/register.js +169 -75
- package/dist/modules/foundation/check.js +4 -2
- package/dist/modules/foundation/compile-laws.js +212 -0
- package/dist/modules/foundation/context-budget.js +1 -14
- package/dist/modules/foundation/dialects/agentsmd.js +95 -0
- package/dist/modules/foundation/dialects/claude-cursor.js +45 -0
- package/dist/modules/foundation/dialects/coderabbit.js +27 -0
- package/dist/modules/foundation/dialects/copilot.js +35 -0
- package/dist/modules/foundation/dialects/index.js +5 -0
- package/dist/modules/foundation/dialects/types.js +58 -0
- package/dist/modules/foundation/doctor.js +266 -14
- package/dist/modules/foundation/import-rules.js +67 -0
- package/dist/modules/foundation/integrity.js +307 -0
- package/dist/modules/foundation/laws-parse.js +131 -0
- package/dist/modules/foundation/laws.js +5 -0
- package/dist/modules/foundation/lock.js +283 -0
- package/dist/modules/foundation/ownership.js +4 -0
- package/dist/modules/foundation/register-core.js +57 -88
- package/dist/modules/foundation/register.js +1 -21
- package/dist/modules/foundation/scaffold.js +25 -0
- package/dist/modules/foundation/scan.js +227 -0
- package/dist/modules/foundation/setup-tool.js +96 -0
- package/dist/modules/foundation/verify.js +9 -1
- package/dist/modules/lawbook/assets/commands/archive.md +1 -1
- package/dist/modules/lawbook/assets/commands/draft.md +1 -1
- package/dist/modules/lawbook/assets/commands/explore.md +1 -1
- package/dist/modules/lawbook/assets/commands/sync.md +2 -2
- package/dist/modules/lawbook/assets/skills/archive/SKILL.md +1 -1
- package/dist/modules/lawbook/assets/skills/archive/steps/03-validate-and-sync.md +3 -3
- package/dist/modules/lawbook/assets/skills/archive/steps/04-archive.md +1 -1
- package/dist/modules/lawbook/assets/skills/draft/steps/02-understand.md +1 -1
- package/dist/modules/lawbook/assets/skills/draft/steps/05-validate.md +1 -1
- package/dist/modules/lawbook/assets/skills/explore/steps/01-investigate.md +1 -1
- package/dist/modules/lawbook/assets/skills/quick/steps/02-implement.md +1 -1
- package/dist/modules/lawbook/assets/skills/sync/SKILL.md +1 -1
- package/dist/modules/lawbook/assets/skills/sync/steps/03-validate.md +1 -1
- package/dist/modules/lawbook/assets/skills/sync/steps/04-promote.md +1 -1
- package/dist/modules/lawbook/change-tool.js +90 -0
- package/dist/modules/lawbook/coverage.js +45 -6
- package/dist/modules/lawbook/ears.js +417 -0
- package/dist/modules/lawbook/engine.js +29 -0
- package/dist/modules/lawbook/register.js +96 -54
- package/dist/modules/lawbook/spec-items.js +4 -1
- package/dist/modules/team/owners.js +464 -0
- package/dist/modules/tools/register.js +4 -26
- package/dist/shared/deprecation.js +99 -0
- package/dist/shared/exposure.js +4 -19
- package/dist/shared/git.js +25 -0
- package/dist/shared/mcp.js +29 -3
- package/dist/shared/output-budget.js +68 -0
- package/dist/shared/tool-catalog.js +49 -0
- package/package.json +4 -3
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { frontmatter, lawSlug, } from "./types.js";
|
|
2
|
+
function body(law) {
|
|
3
|
+
return [`# ${law.title}`, "", `<!-- speclaw:law-id ${law.id} -->`, "", law.prose, ""].join("\n");
|
|
4
|
+
}
|
|
5
|
+
/** Claude Code `.claude/rules` via managed `ai-specs/rules/<slug>.md` + `paths:`. */
|
|
6
|
+
export const claudeRulesDialect = {
|
|
7
|
+
id: "claude-rules",
|
|
8
|
+
compile(laws, ctx) {
|
|
9
|
+
if (!ctx.agents.includes("claude"))
|
|
10
|
+
return [];
|
|
11
|
+
const active = laws.filter((l) => (l.status ?? "active") !== "draft");
|
|
12
|
+
return active.map((law) => {
|
|
13
|
+
const fm = law.scope.length === 0 ? frontmatter({}) : frontmatter({ paths: law.scope });
|
|
14
|
+
return {
|
|
15
|
+
path: `ai-specs/rules/${lawSlug(law.id)}.md`,
|
|
16
|
+
contents: fm + body(law),
|
|
17
|
+
lawIds: [law.id],
|
|
18
|
+
mode: "write",
|
|
19
|
+
};
|
|
20
|
+
});
|
|
21
|
+
},
|
|
22
|
+
};
|
|
23
|
+
/** Cursor rules as `.mdc` under `ai-specs/rules` (symlinked via `.cursor/rules`). */
|
|
24
|
+
export const cursorMdcDialect = {
|
|
25
|
+
id: "cursor-mdc",
|
|
26
|
+
compile(laws, ctx) {
|
|
27
|
+
if (!ctx.agents.includes("cursor"))
|
|
28
|
+
return [];
|
|
29
|
+
const active = laws.filter((l) => (l.status ?? "active") !== "draft");
|
|
30
|
+
return active.map((law) => {
|
|
31
|
+
const empty = law.scope.length === 0;
|
|
32
|
+
const fm = frontmatter({
|
|
33
|
+
description: law.title,
|
|
34
|
+
alwaysApply: empty,
|
|
35
|
+
...(empty ? {} : { globs: law.scope }),
|
|
36
|
+
});
|
|
37
|
+
return {
|
|
38
|
+
path: `ai-specs/rules/${lawSlug(law.id)}.mdc`,
|
|
39
|
+
contents: fm + body(law),
|
|
40
|
+
lawIds: [law.id],
|
|
41
|
+
mode: "write",
|
|
42
|
+
};
|
|
43
|
+
});
|
|
44
|
+
},
|
|
45
|
+
};
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CodeRabbit: emit a merge artifact describing path_instructions entries.
|
|
3
|
+
* The orchestrator merges into existing YAML without a full YAML parser —
|
|
4
|
+
* marker-based line surgery on `path_instructions:` list items.
|
|
5
|
+
*/
|
|
6
|
+
export const coderabbitDialect = {
|
|
7
|
+
id: "coderabbit",
|
|
8
|
+
compile(laws, ctx) {
|
|
9
|
+
if (!ctx.agents.includes("coderabbit"))
|
|
10
|
+
return [];
|
|
11
|
+
const active = laws.filter((l) => (l.status ?? "active") !== "draft" && l.scope.length > 0);
|
|
12
|
+
if (active.length === 0)
|
|
13
|
+
return [];
|
|
14
|
+
const entries = active.map((law) => ({
|
|
15
|
+
path: law.scope[0] ?? "**",
|
|
16
|
+
instructions: `[speclaw:${law.id}] ${law.prose}`,
|
|
17
|
+
}));
|
|
18
|
+
return [
|
|
19
|
+
{
|
|
20
|
+
path: ".coderabbit.yaml",
|
|
21
|
+
contents: JSON.stringify({ path_instructions: entries }, null, 2) + "\n",
|
|
22
|
+
lawIds: active.map((l) => l.id),
|
|
23
|
+
mode: "merge-yaml-path-instructions",
|
|
24
|
+
},
|
|
25
|
+
];
|
|
26
|
+
},
|
|
27
|
+
};
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { frontmatter, lawSlug, } from "./types.js";
|
|
2
|
+
/**
|
|
3
|
+
* Copilot path-scoped instructions. Scoped laws only — never also dump the
|
|
4
|
+
* same body into AGENTS (dual-read nondeterminism).
|
|
5
|
+
*/
|
|
6
|
+
export const copilotDialect = {
|
|
7
|
+
id: "copilot-instructions",
|
|
8
|
+
compile(laws, ctx) {
|
|
9
|
+
// Emit when explicitly wanted: agent id "copilot" or always if .github exists —
|
|
10
|
+
// orchestrator passes agents; treat missing copilot as skip unless "agents" generic.
|
|
11
|
+
const want = ctx.agents.includes("copilot") ||
|
|
12
|
+
ctx.agents.includes("github-copilot") ||
|
|
13
|
+
ctx.agents.includes("agents");
|
|
14
|
+
if (!want)
|
|
15
|
+
return [];
|
|
16
|
+
const scoped = laws.filter((l) => (l.status ?? "active") !== "draft" && l.scope.length > 0);
|
|
17
|
+
return scoped.map((law) => {
|
|
18
|
+
const fm = frontmatter({ applyTo: law.scope.join(",") });
|
|
19
|
+
const body = [
|
|
20
|
+
`# ${law.title}`,
|
|
21
|
+
"",
|
|
22
|
+
`<!-- speclaw:law-id ${law.id} -->`,
|
|
23
|
+
"",
|
|
24
|
+
law.prose,
|
|
25
|
+
"",
|
|
26
|
+
].join("\n");
|
|
27
|
+
return {
|
|
28
|
+
path: `.github/instructions/${lawSlug(law.id)}.instructions.md`,
|
|
29
|
+
contents: fm + body,
|
|
30
|
+
lawIds: [law.id],
|
|
31
|
+
mode: "write",
|
|
32
|
+
};
|
|
33
|
+
});
|
|
34
|
+
},
|
|
35
|
+
};
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export { frontmatter, delimit, patchDelimited, lawSlug, commonPrefix } from "./types.js";
|
|
2
|
+
export { agentsmdDialect } from "./agentsmd.js";
|
|
3
|
+
export { claudeRulesDialect, cursorMdcDialect } from "./claude-cursor.js";
|
|
4
|
+
export { copilotDialect } from "./copilot.js";
|
|
5
|
+
export { coderabbitDialect } from "./coderabbit.js";
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/** YAML-ish frontmatter for markdown rule files. */
|
|
2
|
+
export function frontmatter(fields) {
|
|
3
|
+
const lines = ["---"];
|
|
4
|
+
for (const [k, v] of Object.entries(fields)) {
|
|
5
|
+
if (Array.isArray(v)) {
|
|
6
|
+
lines.push(`${k}:`);
|
|
7
|
+
for (const item of v)
|
|
8
|
+
lines.push(` - ${JSON.stringify(item)}`);
|
|
9
|
+
}
|
|
10
|
+
else if (typeof v === "boolean") {
|
|
11
|
+
lines.push(`${k}: ${v}`);
|
|
12
|
+
}
|
|
13
|
+
else {
|
|
14
|
+
lines.push(`${k}: ${JSON.stringify(v)}`);
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
lines.push("---", "");
|
|
18
|
+
return lines.join("\n");
|
|
19
|
+
}
|
|
20
|
+
/** HTML comment markers for delimited patches in personalized files. */
|
|
21
|
+
export function delimit(marker, body) {
|
|
22
|
+
return `<!-- speclaw:${marker}:start -->\n${body.trim()}\n<!-- speclaw:${marker}:end -->\n`;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Replace or append a delimited speclaw block inside `existing` text.
|
|
26
|
+
* Preserves all content outside the markers.
|
|
27
|
+
*/
|
|
28
|
+
export function patchDelimited(existing, marker, body) {
|
|
29
|
+
const block = delimit(marker, body);
|
|
30
|
+
const re = new RegExp(`<!--\\s*speclaw:${marker}:start\\s-->[\\s\\S]*?<!--\\s*speclaw:${marker}:end\\s-->\\n?`, "m");
|
|
31
|
+
if (re.test(existing))
|
|
32
|
+
return existing.replace(re, block);
|
|
33
|
+
const sep = existing.endsWith("\n") || existing.length === 0 ? "" : "\n";
|
|
34
|
+
return `${existing}${sep}\n${block}`;
|
|
35
|
+
}
|
|
36
|
+
/** Stable filename slug from a law id (`law~foo~1` → `law-foo-1`). */
|
|
37
|
+
export function lawSlug(id) {
|
|
38
|
+
return id.replace(/~/g, "-").replace(/[^a-zA-Z0-9._-]+/g, "-");
|
|
39
|
+
}
|
|
40
|
+
/** Longest common directory prefix of scope globs (best-effort). */
|
|
41
|
+
export function commonPrefix(scopes) {
|
|
42
|
+
const dirs = scopes
|
|
43
|
+
.map((s) => s
|
|
44
|
+
.replace(/\\/g, "/")
|
|
45
|
+
.replace(/\*\*.*$/, "")
|
|
46
|
+
.replace(/\/$/, ""))
|
|
47
|
+
.filter((s) => s.length > 0 && !s.includes("*"));
|
|
48
|
+
if (dirs.length === 0)
|
|
49
|
+
return "";
|
|
50
|
+
const parts = dirs[0].split("/");
|
|
51
|
+
let i = 0;
|
|
52
|
+
for (; i < parts.length; i++) {
|
|
53
|
+
const p = parts[i];
|
|
54
|
+
if (!dirs.every((d) => d.split("/")[i] === p))
|
|
55
|
+
break;
|
|
56
|
+
}
|
|
57
|
+
return parts.slice(0, i).join("/");
|
|
58
|
+
}
|
|
@@ -5,12 +5,18 @@ import { isMinimalMode, packageRoot } from "../../shared/exposure.js";
|
|
|
5
5
|
import { isGitRepo } from "../../shared/git.js";
|
|
6
6
|
import { readManifest } from "../../shared/manifest.js";
|
|
7
7
|
import { pkgName, pkgVersion } from "../../shared/version.js";
|
|
8
|
-
import { indexExists, openDb } from "../compass/db.js";
|
|
8
|
+
import { indexExists, openDb, probeFts5Support } from "../compass/db.js";
|
|
9
|
+
import { getEmbedder } from "../compass/embedder.js";
|
|
9
10
|
import { specList } from "../lawbook/engine.js";
|
|
10
11
|
import { doctorDriftCheck } from "../lawbook/drift.js";
|
|
11
12
|
import { loadCeremonyConfig } from "../lawbook/levels.js";
|
|
13
|
+
import { doctorOwnersChecks } from "../team/owners.js";
|
|
12
14
|
import { globError, hasBackend, hasBatchBackend, readLawManifest } from "./laws.js";
|
|
15
|
+
import { estimateAlwaysOnTokens } from "./compile-laws.js";
|
|
13
16
|
import { redactValue } from "../../shared/redact.js";
|
|
17
|
+
import { readDeprecatedCallCounts, scanRetiredToolReferences } from "../../shared/deprecation.js";
|
|
18
|
+
import { CANONICAL_TOOLS, ALIAS_TARGETS, isCanonicalTool } from "../../shared/tool-catalog.js";
|
|
19
|
+
import { discoverIntegrityPaths, lockfilePath, readLockfile, rootDigest } from "./lock.js";
|
|
14
20
|
const STATUS_RANK = {
|
|
15
21
|
skip: 0,
|
|
16
22
|
ok: 1,
|
|
@@ -46,18 +52,29 @@ function detectInstallKind() {
|
|
|
46
52
|
function enginesRequirement() {
|
|
47
53
|
try {
|
|
48
54
|
const pkg = JSON.parse(fs.readFileSync(path.join(packageRoot(), "package.json"), "utf8"));
|
|
49
|
-
return pkg.engines?.node ?? ">=22";
|
|
55
|
+
return pkg.engines?.node ?? ">=22.16";
|
|
50
56
|
}
|
|
51
57
|
catch {
|
|
52
|
-
return ">=22";
|
|
58
|
+
return ">=22.16";
|
|
53
59
|
}
|
|
54
60
|
}
|
|
55
61
|
function nodeSatisfies(required, version) {
|
|
56
|
-
const m = /^>=\s*(\d+)
|
|
62
|
+
const m = /^>=\s*(\d+)(?:\.(\d+))?/.exec(required.trim());
|
|
57
63
|
if (!m)
|
|
58
64
|
return true;
|
|
59
|
-
const
|
|
60
|
-
|
|
65
|
+
const parts = version
|
|
66
|
+
.replace(/^v/, "")
|
|
67
|
+
.split(".")
|
|
68
|
+
.map((x) => parseInt(x, 10));
|
|
69
|
+
const major = parts[0] ?? 0;
|
|
70
|
+
const minor = parts[1] ?? 0;
|
|
71
|
+
const needMajor = parseInt(m[1], 10);
|
|
72
|
+
const needMinor = m[2] !== undefined ? parseInt(m[2], 10) : 0;
|
|
73
|
+
if (major > needMajor)
|
|
74
|
+
return true;
|
|
75
|
+
if (major < needMajor)
|
|
76
|
+
return false;
|
|
77
|
+
return minor >= needMinor;
|
|
61
78
|
}
|
|
62
79
|
function libcLabel() {
|
|
63
80
|
if (process.platform !== "linux")
|
|
@@ -177,6 +194,25 @@ function buildEnvironment(projectPath) {
|
|
|
177
194
|
detail: git ? "repository" : "not a git repository",
|
|
178
195
|
remedy: git ? undefined : "git init",
|
|
179
196
|
});
|
|
197
|
+
const ftsOk = probeFts5Support();
|
|
198
|
+
addCheck(checks, {
|
|
199
|
+
id: "env.fts5",
|
|
200
|
+
title: "fts5",
|
|
201
|
+
status: ftsOk ? "ok" : "warn",
|
|
202
|
+
value: ftsOk,
|
|
203
|
+
detail: ftsOk
|
|
204
|
+
? "node:sqlite FTS5 available"
|
|
205
|
+
: "FTS5 unavailable — hybrid search degrades to vector+name (need Node >=22.16)",
|
|
206
|
+
remedy: ftsOk ? undefined : "Upgrade to Node.js >=22.16",
|
|
207
|
+
});
|
|
208
|
+
const embedderId = getEmbedder().id;
|
|
209
|
+
addCheck(checks, {
|
|
210
|
+
id: "env.embedder",
|
|
211
|
+
title: "embedder",
|
|
212
|
+
status: "ok",
|
|
213
|
+
value: embedderId,
|
|
214
|
+
detail: `active embedder: ${embedderId}`,
|
|
215
|
+
});
|
|
180
216
|
addCheck(checks, {
|
|
181
217
|
id: "env.ast-engine",
|
|
182
218
|
title: "ast engine",
|
|
@@ -367,12 +403,23 @@ function lawsCheck(projectPath) {
|
|
|
367
403
|
}
|
|
368
404
|
const withPath = manifest.laws.filter(hasBackend).length;
|
|
369
405
|
const withBatch = manifest.laws.filter(hasBatchBackend).length;
|
|
406
|
+
const budget = estimateAlwaysOnTokens(manifest.laws);
|
|
407
|
+
if (budget.total > 2000) {
|
|
408
|
+
const top = budget.top.map((t) => `${t.id}(~${t.tokens})`).join(", ");
|
|
409
|
+
return {
|
|
410
|
+
id: "cfg.laws",
|
|
411
|
+
title: "laws",
|
|
412
|
+
status: "warn",
|
|
413
|
+
detail: `always-on laws ~${budget.total} tokens (budget 2000); top: ${top || "n/a"}`,
|
|
414
|
+
remedy: "Add scope globs to the largest always-on laws, then speclaw laws compile",
|
|
415
|
+
};
|
|
416
|
+
}
|
|
370
417
|
return {
|
|
371
418
|
id: "cfg.laws",
|
|
372
419
|
title: "laws",
|
|
373
420
|
status: "ok",
|
|
374
421
|
value: manifest.laws.length,
|
|
375
|
-
detail: `${manifest.laws.length} declared · ${withPath} path · ${withBatch} deps/graph ·
|
|
422
|
+
detail: `${manifest.laws.length} declared · ${withPath} path · ${withBatch} deps/graph · always-on ~${budget.total} tokens`,
|
|
376
423
|
};
|
|
377
424
|
}
|
|
378
425
|
async function budgetCheck(projectPath) {
|
|
@@ -397,6 +444,48 @@ async function budgetCheck(projectPath) {
|
|
|
397
444
|
};
|
|
398
445
|
}
|
|
399
446
|
}
|
|
447
|
+
async function toolSurfaceCheck(projectPath) {
|
|
448
|
+
try {
|
|
449
|
+
const { measureInstallBudget, collectRegisteredTools } = await import("./context-budget.js");
|
|
450
|
+
const full = measureInstallBudget(projectPath, false);
|
|
451
|
+
const mini = measureInstallBudget(projectPath, true);
|
|
452
|
+
const canonicalCount = collectRegisteredTools(false).filter((t) => isCanonicalTool(t.name)).length;
|
|
453
|
+
const deprecated = readDeprecatedCallCounts(projectPath);
|
|
454
|
+
const aliasDetail = deprecated.size > 0
|
|
455
|
+
? [...deprecated.entries()]
|
|
456
|
+
.map(([alias, n]) => `${alias}→${ALIAS_TARGETS[alias] ?? "?"} (${n}×)`)
|
|
457
|
+
.join("; ")
|
|
458
|
+
: "no deprecated alias calls logged";
|
|
459
|
+
const staleRefs = scanRetiredToolReferences(projectPath);
|
|
460
|
+
const staleDetail = staleRefs.length > 0
|
|
461
|
+
? `retired names in: ${[...new Set(staleRefs.map((r) => `${r.file} (${r.alias}→${r.replacement})`))].join("; ")}`
|
|
462
|
+
: undefined;
|
|
463
|
+
return {
|
|
464
|
+
id: "cfg.tool-surface",
|
|
465
|
+
title: "MCP tool surface",
|
|
466
|
+
status: staleRefs.length > 0 ? "warn" : "ok",
|
|
467
|
+
value: canonicalCount,
|
|
468
|
+
detail: [
|
|
469
|
+
`${canonicalCount}/${CANONICAL_TOOLS.length} canonical tools`,
|
|
470
|
+
`~${full.tools} tool-definition tokens (full), ~${mini.tools} (minimal)`,
|
|
471
|
+
aliasDetail,
|
|
472
|
+
staleDetail,
|
|
473
|
+
]
|
|
474
|
+
.filter(Boolean)
|
|
475
|
+
.join(" · "),
|
|
476
|
+
remedy: staleRefs.length > 0 ? "speclaw update" : undefined,
|
|
477
|
+
};
|
|
478
|
+
}
|
|
479
|
+
catch (err) {
|
|
480
|
+
return {
|
|
481
|
+
id: "cfg.tool-surface",
|
|
482
|
+
title: "MCP tool surface",
|
|
483
|
+
status: "skip",
|
|
484
|
+
detail: `could not measure: ${err.message}`,
|
|
485
|
+
remedy: "speclaw budget",
|
|
486
|
+
};
|
|
487
|
+
}
|
|
488
|
+
}
|
|
400
489
|
function freshnessCheck(projectPath) {
|
|
401
490
|
if (!indexExists(projectPath)) {
|
|
402
491
|
return {
|
|
@@ -468,6 +557,154 @@ function specsOrphansCheck(projectPath) {
|
|
|
468
557
|
remedy: `speclaw lawbook archive ${active[0]}`,
|
|
469
558
|
};
|
|
470
559
|
}
|
|
560
|
+
function integrityChecks(projectPath) {
|
|
561
|
+
// Covers: req~doctor-integrity~1
|
|
562
|
+
const out = [];
|
|
563
|
+
const abs = lockfilePath(projectPath);
|
|
564
|
+
if (!fs.existsSync(abs)) {
|
|
565
|
+
out.push({
|
|
566
|
+
id: "cfg.integrity.lock",
|
|
567
|
+
title: "rule lockfile",
|
|
568
|
+
status: "warn",
|
|
569
|
+
detail: "no speclaw.lock — rule digests are not pinned",
|
|
570
|
+
remedy: "speclaw laws lock",
|
|
571
|
+
});
|
|
572
|
+
}
|
|
573
|
+
else {
|
|
574
|
+
try {
|
|
575
|
+
const lock = readLockfile(projectPath);
|
|
576
|
+
const matches = rootDigest(lock.files) === lock.root;
|
|
577
|
+
out.push({
|
|
578
|
+
id: "cfg.integrity.lock",
|
|
579
|
+
title: "rule lockfile",
|
|
580
|
+
status: matches ? "ok" : "warn",
|
|
581
|
+
value: matches,
|
|
582
|
+
detail: matches
|
|
583
|
+
? `speclaw.lock root matches (${Object.keys(lock.files).length} files)`
|
|
584
|
+
: "speclaw.lock root does not match recomputed digests",
|
|
585
|
+
remedy: matches ? undefined : "speclaw laws lock",
|
|
586
|
+
});
|
|
587
|
+
}
|
|
588
|
+
catch (err) {
|
|
589
|
+
out.push({
|
|
590
|
+
id: "cfg.integrity.lock",
|
|
591
|
+
title: "rule lockfile",
|
|
592
|
+
status: "error",
|
|
593
|
+
detail: err.message,
|
|
594
|
+
remedy: "speclaw laws lock",
|
|
595
|
+
});
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
const imports = findExternalImports(projectPath, 4);
|
|
599
|
+
out.push({
|
|
600
|
+
id: "cfg.integrity.imports",
|
|
601
|
+
title: "external rule imports",
|
|
602
|
+
status: imports.length ? "warn" : "ok",
|
|
603
|
+
value: imports.length ? imports.slice(0, 8).join("; ") : null,
|
|
604
|
+
detail: imports.length
|
|
605
|
+
? `${imports.length} external @import hop(s) (≤4): ${imports.slice(0, 5).join("; ")}`
|
|
606
|
+
: "no external @import / @~/ paths detected in rule files",
|
|
607
|
+
remedy: imports.length
|
|
608
|
+
? "review imports that resolve outside the working directory"
|
|
609
|
+
: undefined,
|
|
610
|
+
});
|
|
611
|
+
const { files } = discoverIntegrityPaths(projectPath);
|
|
612
|
+
const outside = files.filter((f) => {
|
|
613
|
+
const n = f.split("\\").join("/");
|
|
614
|
+
return (n === ".clinerules" ||
|
|
615
|
+
n === ".windsurfrules" ||
|
|
616
|
+
n === "BUGBOT.md" ||
|
|
617
|
+
n === ".cursorrules" ||
|
|
618
|
+
n.startsWith("ai-specs/skills/") ||
|
|
619
|
+
n.startsWith(".claude/skills/"));
|
|
620
|
+
});
|
|
621
|
+
out.push({
|
|
622
|
+
id: "cfg.integrity.outside-pipeline",
|
|
623
|
+
title: "outside-pipeline rules",
|
|
624
|
+
status: "ok",
|
|
625
|
+
value: outside.length,
|
|
626
|
+
detail: outside.length > 0
|
|
627
|
+
? `${outside.length} scan-only / outside-pipeline path(s): ${outside.slice(0, 6).join(", ")}`
|
|
628
|
+
: "no outside-pipeline rule files discovered",
|
|
629
|
+
});
|
|
630
|
+
return out;
|
|
631
|
+
}
|
|
632
|
+
/**
|
|
633
|
+
* Follow `@~/…`, absolute `@/…`, and `@import "…"` targets outside the project,
|
|
634
|
+
* transitively up to `maxHops`.
|
|
635
|
+
*/
|
|
636
|
+
/* node:coverage disable */
|
|
637
|
+
function findExternalImports(projectPath, maxHops) {
|
|
638
|
+
const roots = [
|
|
639
|
+
"CLAUDE.md",
|
|
640
|
+
"AGENTS.md",
|
|
641
|
+
"LAWS.md",
|
|
642
|
+
".cursorrules",
|
|
643
|
+
".clinerules",
|
|
644
|
+
".windsurfrules",
|
|
645
|
+
];
|
|
646
|
+
const seen = new Set();
|
|
647
|
+
const out = [];
|
|
648
|
+
const queue = [];
|
|
649
|
+
for (const r of roots) {
|
|
650
|
+
const abs = path.join(projectPath, r);
|
|
651
|
+
if (fs.existsSync(abs))
|
|
652
|
+
queue.push({ file: abs, hop: 0 });
|
|
653
|
+
}
|
|
654
|
+
while (queue.length) {
|
|
655
|
+
const { file, hop } = queue.shift();
|
|
656
|
+
if (hop > maxHops || seen.has(file))
|
|
657
|
+
continue;
|
|
658
|
+
seen.add(file);
|
|
659
|
+
let text;
|
|
660
|
+
try {
|
|
661
|
+
text = fs.readFileSync(file, "utf8");
|
|
662
|
+
}
|
|
663
|
+
catch {
|
|
664
|
+
continue;
|
|
665
|
+
}
|
|
666
|
+
for (const line of text.split(/\r?\n/)) {
|
|
667
|
+
const m = /@([~/][^\s)\]>"']+)/.exec(line) ??
|
|
668
|
+
/@import\s+["']([^"']+)["']/.exec(line) ??
|
|
669
|
+
/@([A-Za-z]:[^\s)\]>"']+)/.exec(line);
|
|
670
|
+
if (!m)
|
|
671
|
+
continue;
|
|
672
|
+
const target = m[1];
|
|
673
|
+
const resolved = resolveImportTarget(file, target);
|
|
674
|
+
if (!resolved)
|
|
675
|
+
continue;
|
|
676
|
+
const projectRoot = path.resolve(projectPath);
|
|
677
|
+
const outside = target.startsWith("~/") ||
|
|
678
|
+
path.isAbsolute(target) ||
|
|
679
|
+
!resolved.startsWith(projectRoot + path.sep);
|
|
680
|
+
if (outside) {
|
|
681
|
+
const label = `${path.relative(projectPath, file) || path.basename(file)} → ${target}`;
|
|
682
|
+
out.push(label);
|
|
683
|
+
if (hop < maxHops && fs.existsSync(resolved) && fs.statSync(resolved).isFile()) {
|
|
684
|
+
queue.push({ file: resolved, hop: hop + 1 });
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
return out;
|
|
690
|
+
}
|
|
691
|
+
function resolveImportTarget(fromFile, target) {
|
|
692
|
+
if (target.startsWith("~/")) {
|
|
693
|
+
const home = process.env.HOME || process.env.USERPROFILE || "";
|
|
694
|
+
if (!home)
|
|
695
|
+
return null;
|
|
696
|
+
return path.resolve(home, target.slice(2));
|
|
697
|
+
}
|
|
698
|
+
if (target.startsWith("/") || /^[A-Za-z]:/.test(target))
|
|
699
|
+
return path.resolve(target);
|
|
700
|
+
if (target.startsWith("./") || target.startsWith("../")) {
|
|
701
|
+
return path.resolve(path.dirname(fromFile), target);
|
|
702
|
+
}
|
|
703
|
+
if (!target.includes("://"))
|
|
704
|
+
return path.resolve(path.dirname(fromFile), target);
|
|
705
|
+
return null;
|
|
706
|
+
}
|
|
707
|
+
/* node:coverage enable */
|
|
471
708
|
/** Ceremony config validity + archived level histogram. */
|
|
472
709
|
function ceremonyChecks(projectPath) {
|
|
473
710
|
const out = [];
|
|
@@ -569,16 +806,20 @@ function configurationChecks(projectPath, initialised) {
|
|
|
569
806
|
"cfg.hooks",
|
|
570
807
|
"cfg.laws",
|
|
571
808
|
"cfg.budget",
|
|
809
|
+
"cfg.tool-surface",
|
|
572
810
|
"cfg.index.freshness",
|
|
573
811
|
"cfg.specs.orphans",
|
|
574
812
|
];
|
|
575
|
-
return
|
|
576
|
-
id
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
813
|
+
return [
|
|
814
|
+
...ids.map((id) => ({
|
|
815
|
+
id,
|
|
816
|
+
title: id.replace(/^cfg\./, ""),
|
|
817
|
+
status: "skip",
|
|
818
|
+
detail: "project not initialised",
|
|
819
|
+
remedy: "speclaw init",
|
|
820
|
+
})),
|
|
821
|
+
...integrityChecks(projectPath),
|
|
822
|
+
];
|
|
582
823
|
}
|
|
583
824
|
const checks = [];
|
|
584
825
|
const manifest = readManifest(projectPath);
|
|
@@ -605,6 +846,7 @@ function configurationChecks(projectPath, initialised) {
|
|
|
605
846
|
remedy: "speclaw update",
|
|
606
847
|
});
|
|
607
848
|
checks.push(lawsCheck(projectPath));
|
|
849
|
+
checks.push(...integrityChecks(projectPath));
|
|
608
850
|
// budget + mcp + freshness + specs filled async by caller
|
|
609
851
|
return checks;
|
|
610
852
|
}
|
|
@@ -650,6 +892,7 @@ export async function doctor(projectPath, opts = {}) {
|
|
|
650
892
|
const configuration = configurationChecks(projectPath, initialised);
|
|
651
893
|
if (initialised) {
|
|
652
894
|
configuration.push(await budgetCheck(projectPath));
|
|
895
|
+
configuration.push(await toolSurfaceCheck(projectPath));
|
|
653
896
|
configuration.push(freshnessCheck(projectPath));
|
|
654
897
|
configuration.push(specsOrphansCheck(projectPath));
|
|
655
898
|
configuration.push(...ceremonyChecks(projectPath));
|
|
@@ -663,6 +906,15 @@ export async function doctor(projectPath, opts = {}) {
|
|
|
663
906
|
remedy: d.remedy,
|
|
664
907
|
});
|
|
665
908
|
}
|
|
909
|
+
for (const o of doctorOwnersChecks(projectPath)) {
|
|
910
|
+
addCheck(configuration, {
|
|
911
|
+
id: o.id,
|
|
912
|
+
title: o.title,
|
|
913
|
+
status: o.status,
|
|
914
|
+
detail: o.detail,
|
|
915
|
+
remedy: o.remedy,
|
|
916
|
+
});
|
|
917
|
+
}
|
|
666
918
|
const configured = detectConfiguredAgents(projectPath);
|
|
667
919
|
const mcpAgents = AGENTS.filter((a) => a.mcpFile && configured.includes(a.id));
|
|
668
920
|
if (mcpAgents.length === 0) {
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { readLawManifest, writeLawManifest } from "./laws.js";
|
|
4
|
+
/**
|
|
5
|
+
* Import rulesync-style markdown rules under `.rulesync/` or `rulesync/` into
|
|
6
|
+
* draft semantic laws and append them to the manifest.
|
|
7
|
+
*/
|
|
8
|
+
export function importRulesFrom(projectPath, from) {
|
|
9
|
+
if (from !== "rulesync") {
|
|
10
|
+
throw new Error(`unsupported import source "${from}" — try rulesync`);
|
|
11
|
+
}
|
|
12
|
+
const candidates = [".rulesync", "rulesync", ".rulesync/rules", "rulesync/rules"];
|
|
13
|
+
let root = null;
|
|
14
|
+
for (const c of candidates) {
|
|
15
|
+
const abs = path.join(projectPath, c);
|
|
16
|
+
if (fs.existsSync(abs) && fs.statSync(abs).isDirectory()) {
|
|
17
|
+
root = abs;
|
|
18
|
+
break;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
if (!root) {
|
|
22
|
+
throw new Error("no rulesync rules directory found (.rulesync/ or rulesync/)");
|
|
23
|
+
}
|
|
24
|
+
const report = { imported: [], skipped: [] };
|
|
25
|
+
const existing = readLawManifest(projectPath) ?? { version: 1, laws: [] };
|
|
26
|
+
const have = new Set(existing.laws.map((l) => l.id));
|
|
27
|
+
const walk = (dir) => {
|
|
28
|
+
for (const ent of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
29
|
+
const abs = path.join(dir, ent.name);
|
|
30
|
+
if (ent.isDirectory()) {
|
|
31
|
+
walk(abs);
|
|
32
|
+
continue;
|
|
33
|
+
}
|
|
34
|
+
if (!/\.(md|mdc)$/i.test(ent.name))
|
|
35
|
+
continue;
|
|
36
|
+
const rel = path.relative(projectPath, abs).split(path.sep).join("/");
|
|
37
|
+
const prose = fs.readFileSync(abs, "utf8").trim() || "(empty rule)";
|
|
38
|
+
const slug = ent.name
|
|
39
|
+
.replace(/\.(md|mdc)$/i, "")
|
|
40
|
+
.toLowerCase()
|
|
41
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
42
|
+
.replace(/^-|-$/g, "");
|
|
43
|
+
const id = `law~import-rulesync-${slug}~1`;
|
|
44
|
+
if (have.has(id)) {
|
|
45
|
+
report.skipped.push(id);
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
const law = {
|
|
49
|
+
id,
|
|
50
|
+
title: `Imported: ${ent.name}`,
|
|
51
|
+
severity: "warn",
|
|
52
|
+
scope: [],
|
|
53
|
+
prose,
|
|
54
|
+
verification: { kind: "semantic" },
|
|
55
|
+
enforcement: "feedback",
|
|
56
|
+
source: { file: rel, line: 1 },
|
|
57
|
+
status: "draft",
|
|
58
|
+
};
|
|
59
|
+
existing.laws.push(law);
|
|
60
|
+
have.add(id);
|
|
61
|
+
report.imported.push(id);
|
|
62
|
+
}
|
|
63
|
+
};
|
|
64
|
+
walk(root);
|
|
65
|
+
writeLawManifest(projectPath, existing);
|
|
66
|
+
return report;
|
|
67
|
+
}
|