@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
|
@@ -1,19 +1,17 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
|
-
import { defineTool, text } from "../../shared/mcp.js";
|
|
2
|
+
import { defineTool, defineAliasTool, text } from "../../shared/mcp.js";
|
|
3
3
|
import { shouldExpose } from "../../shared/exposure.js";
|
|
4
|
+
import { aliasesEnabled } from "../../shared/tool-catalog.js";
|
|
5
|
+
import { logDeprecatedCall, prefixDeprecated } from "../../shared/deprecation.js";
|
|
4
6
|
import { buildIndex } from "./indexer.js";
|
|
5
|
-
import {
|
|
7
|
+
import { impact } from "./query.js";
|
|
6
8
|
import { affectedTests } from "./affected.js";
|
|
7
9
|
import { hotspots, coupling } from "./hotspots.js";
|
|
8
10
|
import { startWatch, stopWatch, watchStatus } from "./watcher.js";
|
|
9
|
-
import {
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
*
|
|
14
|
-
* @param server - The MCP server to register on.
|
|
15
|
-
* @param opts - Exposure options (`minimal` omits setup/specialized tools).
|
|
16
|
-
*/
|
|
11
|
+
import { exploreRich, findSymbols, formatExploreRich, } from "./explore-rich.js";
|
|
12
|
+
import { diffContext, formatDiffContext } from "./diff-context.js";
|
|
13
|
+
const includeEnum = z.array(z.enum(["source", "callers", "callees", "blast_radius", "tests", "hotspot"]));
|
|
14
|
+
/** Register Compass MCP tools on the given server. */
|
|
17
15
|
export function registerCompass(server, opts = {}) {
|
|
18
16
|
const minimal = Boolean(opts.minimal);
|
|
19
17
|
const add = (name, description, inputSchema, handler) => {
|
|
@@ -21,78 +19,174 @@ export function registerCompass(server, opts = {}) {
|
|
|
21
19
|
return;
|
|
22
20
|
defineTool(server, { name, description, inputSchema, handler });
|
|
23
21
|
};
|
|
24
|
-
add("
|
|
25
|
-
add("compass_explore", "Read a symbol's source plus callers and callees. Prefer this before grep or Read.", { projectPath: z.string(), node: z.string() }, async ({ projectPath, node }) => text(explore(projectPath, node)));
|
|
26
|
-
add("compass_search", "Find symbols by name or keyword (substring). Cheaper structural search than grep.", { projectPath: z.string(), query: z.string(), limit: z.number().optional() }, async ({ projectPath, query, limit }) => text(search(projectPath, query, limit ?? 25)));
|
|
27
|
-
add("compass_recall", "Find symbols by meaning via local embeddings. Use when names are unknown.", { projectPath: z.string(), query: z.string(), limit: z.number().optional() }, async ({ projectPath, query, limit }) => text(await recall(projectPath, query, limit ?? 15)));
|
|
28
|
-
add("compass_impact", "Blast radius for a symbol or files, grouped by module (not a flat dump).", {
|
|
22
|
+
add("compass_explore", "Symbol context in one call: source, callers, callees, blast radius, tests, hotspot. Prefer before grep.", {
|
|
29
23
|
projectPath: z.string(),
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
target: args.target,
|
|
48
|
-
format: args.format ?? "grouped",
|
|
49
|
-
topModules: args.topModules,
|
|
50
|
-
topPerModule: args.topPerModule,
|
|
51
|
-
})));
|
|
52
|
-
add("compass_affected_tests", "Select test files affected by a change; returns a ready-to-run command.", {
|
|
53
|
-
projectPath: z.string(),
|
|
54
|
-
files: z.array(z.string()).optional(),
|
|
55
|
-
symbols: z.array(z.string()).optional(),
|
|
56
|
-
fromDiff: z.string().optional(),
|
|
57
|
-
maxDepth: z.number().int().min(1).max(12).optional(),
|
|
58
|
-
}, async ({ projectPath, files, symbols, fromDiff, maxDepth }) => text(affectedTests(projectPath, { files, symbols, fromDiff, maxDepth })));
|
|
59
|
-
add("compass_hotspots", "Rank files by recent churn and AST complexity; two axes, no magic score.", {
|
|
60
|
-
projectPath: z.string(),
|
|
61
|
-
days: z.number().int().min(1).max(3650).optional(),
|
|
62
|
-
since: z.string().optional(),
|
|
63
|
-
sortBy: z.enum(["churn", "complexity", "combined"]).optional(),
|
|
64
|
-
limit: z.number().int().min(1).max(200).optional(),
|
|
65
|
-
}, async ({ projectPath, days, since, sortBy, limit }) => text(hotspots(projectPath, { days, since, sortBy, limit })));
|
|
66
|
-
add("compass_coupling", "Files that co-change with a target; strength, graph edge, and test-pair facts.", {
|
|
67
|
-
projectPath: z.string(),
|
|
68
|
-
file: z.string(),
|
|
69
|
-
days: z.number().int().min(1).max(3650).optional(),
|
|
70
|
-
since: z.string().optional(),
|
|
71
|
-
minShared: z.number().int().min(1).optional(),
|
|
72
|
-
maxFilesPerCommit: z.number().int().min(2).optional(),
|
|
73
|
-
limit: z.number().int().min(1).max(200).optional(),
|
|
74
|
-
}, async ({ projectPath, file, days, since, minShared, maxFilesPerCommit, limit }) => text(coupling(projectPath, file, { days, since, minShared, maxFilesPerCommit, limit })));
|
|
75
|
-
add("compass_trace", "Find a call path between two symbols within a depth limit.", {
|
|
76
|
-
projectPath: z.string(),
|
|
77
|
-
from: z.string(),
|
|
78
|
-
to: z.string(),
|
|
79
|
-
maxDepth: z.number().optional(),
|
|
80
|
-
}, async ({ projectPath, from, to, maxDepth }) => text(trace(projectPath, from, to, maxDepth ?? 8)));
|
|
81
|
-
add("compass_visualize", "Write an offline HTML graph to .speclaw/graph.html for interactive exploration.", {
|
|
24
|
+
node: z.string(),
|
|
25
|
+
to: z.string().optional(),
|
|
26
|
+
include: includeEnum.optional(),
|
|
27
|
+
mode: z.enum(["brief", "full"]).optional(),
|
|
28
|
+
maxDepth: z.number().int().min(1).max(8).optional(),
|
|
29
|
+
}, async ({ projectPath, node, to, include, mode, maxDepth }) => {
|
|
30
|
+
const result = await exploreRich({
|
|
31
|
+
projectPath,
|
|
32
|
+
node,
|
|
33
|
+
to,
|
|
34
|
+
include: include,
|
|
35
|
+
mode: (mode ?? "brief"),
|
|
36
|
+
maxDepth,
|
|
37
|
+
});
|
|
38
|
+
return text(formatExploreRich(result, (mode ?? "brief")));
|
|
39
|
+
});
|
|
40
|
+
add("compass_find", "Hybrid search over the index: BM25, vectors, and name match. mode sets weights; pass focus for edited files.", {
|
|
82
41
|
projectPath: z.string(),
|
|
83
|
-
|
|
84
|
-
|
|
42
|
+
query: z.string(),
|
|
43
|
+
mode: z.enum(["exact", "concept"]),
|
|
85
44
|
limit: z.number().optional(),
|
|
86
|
-
|
|
87
|
-
|
|
45
|
+
focus: z.array(z.string()).optional(),
|
|
46
|
+
maxTokens: z.number().int().min(256).max(32_000).optional(),
|
|
47
|
+
}, async ({ projectPath, query, mode, limit, focus, maxTokens }) => text(JSON.stringify(await findSymbols(projectPath, query, mode, limit, { focus, maxTokens }), null, 2)));
|
|
48
|
+
add("compass_diff_context", "Graph context of changes in one call: symbols, blast radius, tests, hotspots. Default: working tree.", {
|
|
49
|
+
projectPath: z.string(),
|
|
50
|
+
rev: z.string().optional(),
|
|
51
|
+
paths: z.array(z.string()).optional(),
|
|
52
|
+
mode: z.enum(["brief", "full"]).optional(),
|
|
53
|
+
maxDepth: z.number().int().min(1).max(8).optional(),
|
|
54
|
+
}, async ({ projectPath, rev, paths, mode, maxDepth }) => {
|
|
55
|
+
const result = diffContext({
|
|
56
|
+
projectPath,
|
|
57
|
+
rev,
|
|
58
|
+
paths,
|
|
59
|
+
mode: (mode ?? "brief"),
|
|
60
|
+
maxDepth,
|
|
61
|
+
});
|
|
62
|
+
return text(formatDiffContext(result, (mode ?? "brief")));
|
|
63
|
+
});
|
|
64
|
+
add("compass_index", "Build or refresh the code graph index; optional watch action for live re-index.", {
|
|
88
65
|
projectPath: z.string(),
|
|
89
|
-
action: z.enum(["start", "stop", "status"]),
|
|
90
|
-
|
|
91
|
-
|
|
66
|
+
action: z.enum(["index", "start", "stop", "status"]).optional(),
|
|
67
|
+
force: z.boolean().optional(),
|
|
68
|
+
prune: z.boolean().optional(),
|
|
69
|
+
}, async ({ projectPath, action, force, prune }) => {
|
|
70
|
+
const act = action ?? "index";
|
|
71
|
+
if (act === "index")
|
|
72
|
+
return text(await buildIndex(projectPath, { force, prune }));
|
|
73
|
+
const result = act === "start"
|
|
92
74
|
? startWatch(projectPath)
|
|
93
|
-
:
|
|
75
|
+
: act === "stop"
|
|
94
76
|
? stopWatch(projectPath)
|
|
95
77
|
: watchStatus(projectPath);
|
|
96
78
|
return text(result);
|
|
97
79
|
});
|
|
80
|
+
if (minimal || !aliasesEnabled())
|
|
81
|
+
return;
|
|
82
|
+
defineAliasTool(server, {
|
|
83
|
+
name: "compass_search",
|
|
84
|
+
description: "Deprecated alias for compass_find mode exact.",
|
|
85
|
+
inputSchema: { projectPath: z.string(), query: z.string(), limit: z.number().optional() },
|
|
86
|
+
handler: async ({ projectPath, query, limit }) => {
|
|
87
|
+
logDeprecatedCall(projectPath, "compass_search");
|
|
88
|
+
const body = JSON.stringify(await findSymbols(projectPath, query, "exact", limit), null, 2);
|
|
89
|
+
return text(prefixDeprecated("compass_search", body));
|
|
90
|
+
},
|
|
91
|
+
});
|
|
92
|
+
defineAliasTool(server, {
|
|
93
|
+
name: "compass_recall",
|
|
94
|
+
description: "Deprecated alias for compass_find mode concept.",
|
|
95
|
+
inputSchema: { projectPath: z.string(), query: z.string(), limit: z.number().optional() },
|
|
96
|
+
handler: async ({ projectPath, query, limit }) => {
|
|
97
|
+
logDeprecatedCall(projectPath, "compass_recall");
|
|
98
|
+
const body = JSON.stringify(await findSymbols(projectPath, query, "concept", limit), null, 2);
|
|
99
|
+
return text(prefixDeprecated("compass_recall", body));
|
|
100
|
+
},
|
|
101
|
+
});
|
|
102
|
+
defineAliasTool(server, {
|
|
103
|
+
name: "compass_impact",
|
|
104
|
+
description: "Deprecated alias for compass_explore blast_radius include.",
|
|
105
|
+
inputSchema: {
|
|
106
|
+
projectPath: z.string(),
|
|
107
|
+
node: z.string().optional(),
|
|
108
|
+
symbol: z.string().optional(),
|
|
109
|
+
files: z.array(z.string()).optional(),
|
|
110
|
+
maxDepth: z.number().optional(),
|
|
111
|
+
},
|
|
112
|
+
handler: async (args) => {
|
|
113
|
+
logDeprecatedCall(args.projectPath, "compass_impact");
|
|
114
|
+
const sym = args.symbol ?? args.node;
|
|
115
|
+
const body = sym
|
|
116
|
+
? JSON.stringify(await exploreRich({
|
|
117
|
+
projectPath: args.projectPath,
|
|
118
|
+
node: sym,
|
|
119
|
+
include: ["blast_radius"],
|
|
120
|
+
}), null, 2)
|
|
121
|
+
: JSON.stringify(impact(args.projectPath, { files: args.files, maxDepth: args.maxDepth ?? 4 }), null, 2);
|
|
122
|
+
return text(prefixDeprecated("compass_impact", body));
|
|
123
|
+
},
|
|
124
|
+
});
|
|
125
|
+
defineAliasTool(server, {
|
|
126
|
+
name: "compass_trace",
|
|
127
|
+
description: "Deprecated alias for compass_explore with to parameter.",
|
|
128
|
+
inputSchema: {
|
|
129
|
+
projectPath: z.string(),
|
|
130
|
+
from: z.string(),
|
|
131
|
+
to: z.string(),
|
|
132
|
+
maxDepth: z.number().optional(),
|
|
133
|
+
},
|
|
134
|
+
handler: async ({ projectPath, from, to, maxDepth }) => {
|
|
135
|
+
logDeprecatedCall(projectPath, "compass_trace");
|
|
136
|
+
const body = JSON.stringify(await exploreRich({ projectPath, node: from, to, maxDepth }), null, 2);
|
|
137
|
+
return text(prefixDeprecated("compass_trace", body));
|
|
138
|
+
},
|
|
139
|
+
});
|
|
140
|
+
defineAliasTool(server, {
|
|
141
|
+
name: "compass_affected_tests",
|
|
142
|
+
description: "Deprecated alias — use compass_diff_context or explore.",
|
|
143
|
+
inputSchema: {
|
|
144
|
+
projectPath: z.string(),
|
|
145
|
+
files: z.array(z.string()).optional(),
|
|
146
|
+
symbols: z.array(z.string()).optional(),
|
|
147
|
+
fromDiff: z.string().optional(),
|
|
148
|
+
},
|
|
149
|
+
handler: async (args) => {
|
|
150
|
+
logDeprecatedCall(args.projectPath, "compass_affected_tests");
|
|
151
|
+
const body = JSON.stringify(affectedTests(args.projectPath, args), null, 2);
|
|
152
|
+
return text(prefixDeprecated("compass_affected_tests", body));
|
|
153
|
+
},
|
|
154
|
+
});
|
|
155
|
+
defineAliasTool(server, {
|
|
156
|
+
name: "compass_hotspots",
|
|
157
|
+
description: "Deprecated alias — use compass_explore hotspot include.",
|
|
158
|
+
inputSchema: { projectPath: z.string(), limit: z.number().optional() },
|
|
159
|
+
handler: async ({ projectPath, limit }) => {
|
|
160
|
+
logDeprecatedCall(projectPath, "compass_hotspots");
|
|
161
|
+
const body = JSON.stringify(hotspots(projectPath, { limit }), null, 2);
|
|
162
|
+
return text(prefixDeprecated("compass_hotspots", body));
|
|
163
|
+
},
|
|
164
|
+
});
|
|
165
|
+
defineAliasTool(server, {
|
|
166
|
+
name: "compass_coupling",
|
|
167
|
+
description: "Deprecated alias — use compass_diff_context.",
|
|
168
|
+
inputSchema: { projectPath: z.string(), file: z.string() },
|
|
169
|
+
handler: async ({ projectPath, file }) => {
|
|
170
|
+
logDeprecatedCall(projectPath, "compass_coupling");
|
|
171
|
+
const body = JSON.stringify(coupling(projectPath, file, {}), null, 2);
|
|
172
|
+
return text(prefixDeprecated("compass_coupling", body));
|
|
173
|
+
},
|
|
174
|
+
});
|
|
175
|
+
defineAliasTool(server, {
|
|
176
|
+
name: "compass_watch",
|
|
177
|
+
description: "Deprecated alias for compass_index watch actions.",
|
|
178
|
+
inputSchema: {
|
|
179
|
+
projectPath: z.string(),
|
|
180
|
+
action: z.enum(["start", "stop", "status"]),
|
|
181
|
+
},
|
|
182
|
+
handler: async ({ projectPath, action }) => {
|
|
183
|
+
logDeprecatedCall(projectPath, "compass_watch");
|
|
184
|
+
const result = action === "start"
|
|
185
|
+
? startWatch(projectPath)
|
|
186
|
+
: action === "stop"
|
|
187
|
+
? stopWatch(projectPath)
|
|
188
|
+
: watchStatus(projectPath);
|
|
189
|
+
return text(prefixDeprecated("compass_watch", JSON.stringify(result, null, 2)));
|
|
190
|
+
},
|
|
191
|
+
});
|
|
98
192
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { performance } from "node:perf_hooks";
|
|
4
|
-
import { compileScope, hasBackend, manifestPath, matchCompiled, readLawManifest, } from "./laws.js";
|
|
4
|
+
import { compileScope, hasBackend, isActiveLaw, manifestPath, matchCompiled, readLawManifest, } from "./laws.js";
|
|
5
5
|
const cache = new Map();
|
|
6
6
|
/**
|
|
7
7
|
* Load the project's compiled law index, using the in-process cache when the
|
|
@@ -24,7 +24,9 @@ function loadLaws(projectPath) {
|
|
|
24
24
|
const manifest = readLawManifest(projectPath);
|
|
25
25
|
if (!manifest)
|
|
26
26
|
return null;
|
|
27
|
-
const laws = manifest.laws
|
|
27
|
+
const laws = manifest.laws
|
|
28
|
+
.filter(isActiveLaw)
|
|
29
|
+
.map((law) => ({ law, scope: compileScope(law.scope) }));
|
|
28
30
|
cache.set(projectPath, { mtimeMs, laws });
|
|
29
31
|
return laws;
|
|
30
32
|
}
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { globError, isActiveLaw, readLawManifest, seedManifest, writeLawManifest, } from "./laws.js";
|
|
4
|
+
import { parseLawsFromStandards } from "./laws-parse.js";
|
|
5
|
+
import { agentsmdDialect, claudeRulesDialect, coderabbitDialect, copilotDialect, cursorMdcDialect, patchDelimited, } from "./dialects/index.js";
|
|
6
|
+
import { detectConfiguredAgents } from "../../shared/agents.js";
|
|
7
|
+
import { digestText, provenanceBlock, refreshLockfile } from "./lock.js";
|
|
8
|
+
const DIALECTS = [
|
|
9
|
+
claudeRulesDialect,
|
|
10
|
+
cursorMdcDialect,
|
|
11
|
+
copilotDialect,
|
|
12
|
+
coderabbitDialect,
|
|
13
|
+
agentsmdDialect,
|
|
14
|
+
];
|
|
15
|
+
/**
|
|
16
|
+
* Merge seed + standards parse + on-disk manifest. Manifest wins on id conflict.
|
|
17
|
+
* Throws if standards declare duplicate ids.
|
|
18
|
+
*/
|
|
19
|
+
export function mergeLawSources(projectPath) {
|
|
20
|
+
const parsed = parseLawsFromStandards(projectPath);
|
|
21
|
+
if (parsed.duplicates.size > 0) {
|
|
22
|
+
const detail = [...parsed.duplicates.entries()]
|
|
23
|
+
.map(([id, locs]) => `${id} at ${locs.join(" and ")}`)
|
|
24
|
+
.join("; ");
|
|
25
|
+
throw new Error(`duplicate law id(s) in docs/standards: ${detail}`);
|
|
26
|
+
}
|
|
27
|
+
const byId = new Map();
|
|
28
|
+
for (const law of seedManifest().laws)
|
|
29
|
+
byId.set(law.id, law);
|
|
30
|
+
for (const law of parsed.laws) {
|
|
31
|
+
if (!byId.has(law.id))
|
|
32
|
+
byId.set(law.id, law);
|
|
33
|
+
}
|
|
34
|
+
const disk = readLawManifest(projectPath);
|
|
35
|
+
if (disk) {
|
|
36
|
+
for (const law of disk.laws)
|
|
37
|
+
byId.set(law.id, law);
|
|
38
|
+
}
|
|
39
|
+
return [...byId.values()].sort((a, b) => a.id.localeCompare(b.id));
|
|
40
|
+
}
|
|
41
|
+
function validateScopes(laws) {
|
|
42
|
+
for (const law of laws) {
|
|
43
|
+
if (!isActiveLaw(law))
|
|
44
|
+
continue;
|
|
45
|
+
for (const p of law.scope) {
|
|
46
|
+
const err = globError(p);
|
|
47
|
+
if (err) {
|
|
48
|
+
throw new Error(`${law.id}: invalid scope ${JSON.stringify(p)} (${err}) — ${law.source.file}${law.source.line ? `:${law.source.line}` : ""}`);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
function writeIfChanged(projectPath, abs, contents, report) {
|
|
54
|
+
fs.mkdirSync(path.dirname(abs), { recursive: true });
|
|
55
|
+
const rel = path.relative(projectPath, abs).split(path.sep).join("/");
|
|
56
|
+
if (fs.existsSync(abs)) {
|
|
57
|
+
const prev = fs.readFileSync(abs, "utf8");
|
|
58
|
+
if (prev === contents) {
|
|
59
|
+
report.unchanged.push(rel);
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
fs.writeFileSync(abs, contents);
|
|
64
|
+
report.written.push(rel);
|
|
65
|
+
}
|
|
66
|
+
/** Append a data-only provenance block when missing; body digest stays stable. */
|
|
67
|
+
function withProvenance(contents, lawIds) {
|
|
68
|
+
const body = contents.replace(/<!-- speclaw:begin-provenance[\s\S]*?speclaw:end-provenance -->\n?/g, "");
|
|
69
|
+
const dig = digestText(body);
|
|
70
|
+
const base = body.endsWith("\n") ? body : body + "\n";
|
|
71
|
+
return base + provenanceBlock({ lawIds, digest: dig, source: "compile" });
|
|
72
|
+
}
|
|
73
|
+
function mergeCoderabbit(projectPath, abs, payloadJson, report) {
|
|
74
|
+
const payload = JSON.parse(payloadJson);
|
|
75
|
+
let text = fs.existsSync(abs)
|
|
76
|
+
? fs.readFileSync(abs, "utf8")
|
|
77
|
+
: "reviews:\n path_instructions: []\n";
|
|
78
|
+
const start = "# speclaw:path_instructions:start";
|
|
79
|
+
const end = "# speclaw:path_instructions:end";
|
|
80
|
+
const blockLines = [start];
|
|
81
|
+
for (const e of payload.path_instructions) {
|
|
82
|
+
blockLines.push(` - path: ${JSON.stringify(e.path)}`);
|
|
83
|
+
blockLines.push(` instructions: ${JSON.stringify(e.instructions)}`);
|
|
84
|
+
}
|
|
85
|
+
blockLines.push(end);
|
|
86
|
+
const block = blockLines.join("\n");
|
|
87
|
+
const re = /# speclaw:path_instructions:start[\s\S]*?# speclaw:path_instructions:end/;
|
|
88
|
+
if (re.test(text))
|
|
89
|
+
text = text.replace(re, block);
|
|
90
|
+
else {
|
|
91
|
+
if (!/path_instructions\s*:/.test(text)) {
|
|
92
|
+
if (!/^reviews\s*:/m.test(text))
|
|
93
|
+
text = `reviews:\n path_instructions: []\n` + text;
|
|
94
|
+
else
|
|
95
|
+
text = text.replace(/^(reviews\s*:)/m, `$1\n path_instructions: []`);
|
|
96
|
+
}
|
|
97
|
+
text = text.trimEnd() + "\n" + block + "\n";
|
|
98
|
+
}
|
|
99
|
+
writeIfChanged(projectPath, abs, text.endsWith("\n") ? text : text + "\n", report);
|
|
100
|
+
}
|
|
101
|
+
function ensureClaudeRulesSymlink(projectPath, report) {
|
|
102
|
+
const link = path.join(projectPath, ".claude", "rules", "speclaw");
|
|
103
|
+
const target = path.join("..", "..", "ai-specs", "rules");
|
|
104
|
+
fs.mkdirSync(path.dirname(link), { recursive: true });
|
|
105
|
+
try {
|
|
106
|
+
if (fs.lstatSync(link).isSymbolicLink() || fs.existsSync(link)) {
|
|
107
|
+
report.unchanged.push(".claude/rules/speclaw");
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
catch {
|
|
112
|
+
/* missing */
|
|
113
|
+
}
|
|
114
|
+
try {
|
|
115
|
+
fs.symlinkSync(target, link);
|
|
116
|
+
report.written.push(".claude/rules/speclaw");
|
|
117
|
+
}
|
|
118
|
+
catch (err) {
|
|
119
|
+
report.failed.push({ path: ".claude/rules/speclaw", error: err.message });
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
function applyArtifact(projectPath, art, report) {
|
|
123
|
+
const abs = path.join(projectPath, art.path);
|
|
124
|
+
try {
|
|
125
|
+
if (art.mode === "write") {
|
|
126
|
+
// Nested AGENTS: only write if parent has package.json
|
|
127
|
+
if (art.path.endsWith("/AGENTS.md") && art.path !== "AGENTS.md") {
|
|
128
|
+
const dir = path.dirname(abs);
|
|
129
|
+
if (!fs.existsSync(path.join(dir, "package.json")))
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
const body = art.path.endsWith(".md") || art.path.endsWith(".mdc")
|
|
133
|
+
? withProvenance(art.contents, art.lawIds)
|
|
134
|
+
: art.contents;
|
|
135
|
+
writeIfChanged(projectPath, abs, body, report);
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
if (art.mode === "patch-delimited") {
|
|
139
|
+
const prev = fs.existsSync(abs) ? fs.readFileSync(abs, "utf8") : "";
|
|
140
|
+
const next = patchDelimited(prev, art.marker ?? "laws", art.contents);
|
|
141
|
+
writeIfChanged(projectPath, abs, next, report);
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
if (art.mode === "merge-yaml-path-instructions") {
|
|
145
|
+
mergeCoderabbit(projectPath, abs, art.contents, report);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
catch (err) {
|
|
149
|
+
report.failed.push({ path: art.path, error: err.message });
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
/**
|
|
153
|
+
* Compile project laws into agent dialects. Deterministic and idempotent.
|
|
154
|
+
*/
|
|
155
|
+
export function compileLaws(opts) {
|
|
156
|
+
const projectPath = opts.projectPath;
|
|
157
|
+
const report = {
|
|
158
|
+
schemaVersion: 1,
|
|
159
|
+
written: [],
|
|
160
|
+
unchanged: [],
|
|
161
|
+
failed: [],
|
|
162
|
+
lawCount: 0,
|
|
163
|
+
draftCount: 0,
|
|
164
|
+
};
|
|
165
|
+
const laws = mergeLawSources(projectPath);
|
|
166
|
+
validateScopes(laws);
|
|
167
|
+
report.lawCount = laws.filter(isActiveLaw).length;
|
|
168
|
+
report.draftCount = laws.filter((l) => !isActiveLaw(l)).length;
|
|
169
|
+
if (opts.writeManifest !== false) {
|
|
170
|
+
const manifest = { version: 1, laws };
|
|
171
|
+
writeLawManifest(projectPath, manifest);
|
|
172
|
+
}
|
|
173
|
+
const agents = opts.agents ?? detectConfiguredAgents(projectPath);
|
|
174
|
+
const ctx = { projectPath, agents };
|
|
175
|
+
const artifacts = [];
|
|
176
|
+
for (const d of DIALECTS)
|
|
177
|
+
artifacts.push(...d.compile(laws, ctx));
|
|
178
|
+
// Filter nested AGENTS without package.json before write
|
|
179
|
+
for (const art of artifacts) {
|
|
180
|
+
if (art.path.endsWith("/AGENTS.md") && art.path !== "AGENTS.md") {
|
|
181
|
+
const dir = path.join(projectPath, path.dirname(art.path));
|
|
182
|
+
if (!fs.existsSync(path.join(dir, "package.json")))
|
|
183
|
+
continue;
|
|
184
|
+
}
|
|
185
|
+
applyArtifact(projectPath, art, report);
|
|
186
|
+
}
|
|
187
|
+
if (agents.includes("claude"))
|
|
188
|
+
ensureClaudeRulesSymlink(projectPath, report);
|
|
189
|
+
// Covers: req~lock-refresh-update~1
|
|
190
|
+
try {
|
|
191
|
+
refreshLockfile(projectPath);
|
|
192
|
+
}
|
|
193
|
+
catch {
|
|
194
|
+
// Lock refresh must not fail compile; `speclaw laws lock` surfaces errors.
|
|
195
|
+
}
|
|
196
|
+
return report;
|
|
197
|
+
}
|
|
198
|
+
/** Estimate always-on tokens (empty scope) with a bytes/3.6 heuristic. */
|
|
199
|
+
export function estimateAlwaysOnTokens(laws) {
|
|
200
|
+
const HEADER = 12;
|
|
201
|
+
const scored = laws
|
|
202
|
+
.filter((l) => isActiveLaw(l) && l.scope.length === 0)
|
|
203
|
+
.map((l) => ({
|
|
204
|
+
id: l.id,
|
|
205
|
+
tokens: Math.ceil(Buffer.byteLength(l.prose, "utf8") / 3.6) + HEADER,
|
|
206
|
+
}))
|
|
207
|
+
.sort((a, b) => b.tokens - a.tokens);
|
|
208
|
+
return {
|
|
209
|
+
total: scored.reduce((s, x) => s + x.tokens, 0),
|
|
210
|
+
top: scored.slice(0, 3),
|
|
211
|
+
};
|
|
212
|
+
}
|
|
@@ -1,22 +1,12 @@
|
|
|
1
|
-
import { z } from "zod";
|
|
2
1
|
import { registerCompass } from "../compass/register.js";
|
|
3
2
|
import { registerSpec } from "../lawbook/register.js";
|
|
4
3
|
import { registerTools } from "../tools/register.js";
|
|
5
4
|
import { registerFoundationCore } from "./register-core.js";
|
|
6
5
|
import { measureBudget } from "../../shared/budget.js";
|
|
7
|
-
import { isMinimalMode, packageRoot
|
|
8
|
-
/** Mirrors the `doctor` tool surface without importing `doctor.ts` (avoids a cycle). */
|
|
9
|
-
const DOCTOR_TOOL_FOR_BUDGET = {
|
|
10
|
-
name: "doctor",
|
|
11
|
-
description: "Verify the speclaw install; returns a versioned DoctorReport (schemaVersion 1).",
|
|
12
|
-
inputSchema: { projectPath: z.string() },
|
|
13
|
-
};
|
|
6
|
+
import { isMinimalMode, packageRoot } from "../../shared/exposure.js";
|
|
14
7
|
/**
|
|
15
8
|
* Collect tool definitions as the MCP server would register them for a profile.
|
|
16
9
|
*
|
|
17
|
-
* Uses `registerFoundationCore` plus a static `doctor` stub so budget/doctor
|
|
18
|
-
* measurement never imports the live `doctor` implementation (module cycle).
|
|
19
|
-
*
|
|
20
10
|
* @param minimal - Exposure profile.
|
|
21
11
|
*/
|
|
22
12
|
export function collectRegisteredTools(minimal) {
|
|
@@ -33,9 +23,6 @@ export function collectRegisteredTools(minimal) {
|
|
|
33
23
|
const opts = { minimal };
|
|
34
24
|
const stub = server;
|
|
35
25
|
registerFoundationCore(stub, opts);
|
|
36
|
-
if (shouldExpose("doctor", minimal)) {
|
|
37
|
-
tools.push(DOCTOR_TOOL_FOR_BUDGET);
|
|
38
|
-
}
|
|
39
26
|
registerSpec(stub, opts);
|
|
40
27
|
registerCompass(stub, opts);
|
|
41
28
|
registerTools(stub, opts);
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { commonPrefix, delimit, } from "./types.js";
|
|
2
|
+
const NESTED_THRESHOLD = 3;
|
|
3
|
+
function degradeSection(title, laws) {
|
|
4
|
+
const lines = [`## ${title}`, ""];
|
|
5
|
+
for (const law of laws) {
|
|
6
|
+
const globHint = law.scope.length > 0 ? `In files that match \`${law.scope.join("`, `")}\`, ` : "";
|
|
7
|
+
lines.push(`### ${law.title} (\`${law.id}\`)`);
|
|
8
|
+
lines.push("");
|
|
9
|
+
lines.push(`${globHint}${law.prose}`);
|
|
10
|
+
lines.push("");
|
|
11
|
+
}
|
|
12
|
+
return lines.join("\n");
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* AGENTS.md dialect: move scope into prose; optional nested AGENTS.md files.
|
|
16
|
+
* Emits patch-delimited content for the root file.
|
|
17
|
+
*/
|
|
18
|
+
export const agentsmdDialect = {
|
|
19
|
+
id: "agentsmd",
|
|
20
|
+
compile(laws, ctx) {
|
|
21
|
+
const active = laws.filter((l) => (l.status ?? "active") !== "draft");
|
|
22
|
+
const global = active.filter((l) => l.scope.length === 0);
|
|
23
|
+
const scoped = active.filter((l) => l.scope.length > 0);
|
|
24
|
+
const byPrefix = new Map();
|
|
25
|
+
for (const law of scoped) {
|
|
26
|
+
const pref = commonPrefix(law.scope) || "_misc";
|
|
27
|
+
const list = byPrefix.get(pref) ?? [];
|
|
28
|
+
list.push(law);
|
|
29
|
+
byPrefix.set(pref, list);
|
|
30
|
+
}
|
|
31
|
+
const artifacts = [];
|
|
32
|
+
const rootParts = [
|
|
33
|
+
"## speclaw laws (generated)",
|
|
34
|
+
"",
|
|
35
|
+
"_Edit `docs/standards/*.md` or `.speclaw/laws-manifest.json`; do not edit this block._",
|
|
36
|
+
"",
|
|
37
|
+
];
|
|
38
|
+
if (global.length)
|
|
39
|
+
rootParts.push(degradeSection("Global rules", global));
|
|
40
|
+
const nestedMention = [];
|
|
41
|
+
for (const [pref, group] of [...byPrefix.entries()].sort(([a], [b]) => a.localeCompare(b))) {
|
|
42
|
+
const pkg = pref !== "_misc" && pref.length > 0;
|
|
43
|
+
const nest = pkg &&
|
|
44
|
+
group.length >= NESTED_THRESHOLD &&
|
|
45
|
+
// Nested only when a package.json exists under that prefix.
|
|
46
|
+
true;
|
|
47
|
+
// Nesting is decided by the orchestrator (fs check); here we always
|
|
48
|
+
// put dense prefixes in a nested artifact path hint via mode write.
|
|
49
|
+
if (nest && pref !== "_misc") {
|
|
50
|
+
nestedMention.push(pref);
|
|
51
|
+
artifacts.push({
|
|
52
|
+
path: `${pref}/AGENTS.md`,
|
|
53
|
+
contents: delimit("laws", degradeSection(`Rules for \`${pref}\``, group) +
|
|
54
|
+
`\n_Generated by speclaw from law ids: ${group.map((l) => l.id).join(", ")}_\n`),
|
|
55
|
+
lawIds: group.map((l) => l.id),
|
|
56
|
+
mode: "write",
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
else {
|
|
60
|
+
const title = pref === "_misc" ? "Scoped rules" : `Rules for \`${pref}\``;
|
|
61
|
+
rootParts.push(degradeSection(title, group));
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
if (nestedMention.length) {
|
|
65
|
+
rootParts.push("## Nested AGENTS.md files", "");
|
|
66
|
+
for (const p of nestedMention)
|
|
67
|
+
rootParts.push(`- [\`${p}/AGENTS.md\`](${p}/AGENTS.md)`);
|
|
68
|
+
rootParts.push("");
|
|
69
|
+
}
|
|
70
|
+
void ctx;
|
|
71
|
+
artifacts.push({
|
|
72
|
+
path: "AGENTS.md",
|
|
73
|
+
contents: rootParts.join("\n"),
|
|
74
|
+
lawIds: active.map((l) => l.id),
|
|
75
|
+
mode: "patch-delimited",
|
|
76
|
+
marker: "laws",
|
|
77
|
+
});
|
|
78
|
+
artifacts.push({
|
|
79
|
+
path: "CLAUDE.md",
|
|
80
|
+
contents: [
|
|
81
|
+
"## speclaw (generated)",
|
|
82
|
+
"",
|
|
83
|
+
"Import shared agent context: `@AGENTS.md`",
|
|
84
|
+
"",
|
|
85
|
+
"Path-scoped laws live under `.claude/rules/` (symlink to `ai-specs/rules`).",
|
|
86
|
+
"Do not rely on Claude Code reading `AGENTS.md` without the import.",
|
|
87
|
+
"",
|
|
88
|
+
].join("\n"),
|
|
89
|
+
lawIds: active.map((l) => l.id),
|
|
90
|
+
mode: "patch-delimited",
|
|
91
|
+
marker: "laws",
|
|
92
|
+
});
|
|
93
|
+
return artifacts;
|
|
94
|
+
},
|
|
95
|
+
};
|