@esneiderbravo/speclaw 0.3.3 → 0.3.5
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 +54 -1
- package/dist/cli/commands/budget.js +36 -0
- package/dist/cli/commands/doctor.js +11 -1
- package/dist/cli/commands/init.js +3 -1
- package/dist/cli/commands/update.js +30 -3
- package/dist/cli/commands/verify.js +118 -0
- package/dist/cli/index.js +14 -3
- package/dist/modules/compass/indexer.js +9 -0
- package/dist/modules/compass/map.js +114 -0
- package/dist/modules/compass/register.js +30 -64
- package/dist/modules/foundation/assets/docs/compass.template.md +3 -0
- package/dist/modules/foundation/assets/laws/laws-manifest.json +49 -0
- package/dist/modules/foundation/assets/workflows/speclaw.yml +26 -0
- package/dist/modules/foundation/ci.js +41 -0
- package/dist/modules/foundation/context-budget.js +45 -0
- package/dist/modules/foundation/deps.js +1 -1
- package/dist/modules/foundation/doctor.js +9 -0
- package/dist/modules/foundation/graph.js +1 -1
- package/dist/modules/foundation/laws.js +28 -0
- package/dist/modules/foundation/register.js +71 -120
- package/dist/modules/foundation/report-md.js +33 -0
- package/dist/modules/foundation/sarif.js +96 -0
- package/dist/modules/foundation/scaffold.js +32 -12
- package/dist/modules/foundation/verify-model.js +14 -0
- package/dist/modules/foundation/verify.js +16 -17
- package/dist/modules/lawbook/assets/skills/archive/SKILL.md +1 -31
- package/dist/modules/lawbook/assets/skills/archive/steps/01-confirm-done.md +7 -0
- package/dist/modules/lawbook/assets/skills/archive/steps/02-reconcile.md +15 -0
- package/dist/modules/lawbook/assets/skills/archive/steps/03-validate-and-sync.md +7 -0
- package/dist/modules/lawbook/assets/skills/archive/steps/04-archive.md +9 -0
- package/dist/modules/lawbook/assets/skills/archive/steps/05-report.md +7 -0
- package/dist/modules/lawbook/assets/skills/build/SKILL.md +2 -100
- package/dist/modules/lawbook/assets/skills/build/steps/01-load-change.md +7 -0
- package/dist/modules/lawbook/assets/skills/build/steps/02-branch.md +6 -0
- package/dist/modules/lawbook/assets/skills/build/steps/03-implement.md +11 -0
- package/dist/modules/lawbook/assets/skills/build/steps/04-quality-gates.md +10 -0
- package/dist/modules/lawbook/assets/skills/build/steps/05-manual-verification.md +22 -0
- package/dist/modules/lawbook/assets/skills/build/steps/06-discipline-reports.md +44 -0
- package/dist/modules/lawbook/assets/skills/build/steps/07-hand-off.md +9 -0
- package/dist/modules/lawbook/assets/skills/draft/SKILL.md +3 -80
- package/dist/modules/lawbook/assets/skills/draft/steps/01-ensure-workspace.md +5 -0
- package/dist/modules/lawbook/assets/skills/draft/steps/02-understand.md +12 -0
- package/dist/modules/lawbook/assets/skills/draft/steps/03-name-capabilities.md +14 -0
- package/dist/modules/lawbook/assets/skills/draft/steps/04-write-artifacts.md +41 -0
- package/dist/modules/lawbook/assets/skills/draft/steps/05-validate.md +11 -0
- package/dist/modules/lawbook/assets/skills/draft/steps/06-hand-off.md +5 -0
- package/dist/modules/lawbook/assets/skills/explore/SKILL.md +6 -22
- package/dist/modules/lawbook/assets/skills/explore/steps/01-investigate.md +16 -0
- package/dist/modules/lawbook/assets/skills/explore/steps/02-summarize.md +7 -0
- package/dist/modules/lawbook/assets/skills/sync/SKILL.md +1 -30
- package/dist/modules/lawbook/assets/skills/sync/steps/01-confirm.md +5 -0
- package/dist/modules/lawbook/assets/skills/sync/steps/02-reconcile.md +16 -0
- package/dist/modules/lawbook/assets/skills/sync/steps/03-validate.md +6 -0
- package/dist/modules/lawbook/assets/skills/sync/steps/04-promote.md +10 -0
- package/dist/modules/lawbook/assets/skills/sync/steps/05-report.md +7 -0
- package/dist/modules/lawbook/register.js +17 -36
- package/dist/modules/tools/register.js +15 -15
- package/dist/server.js +13 -7
- package/dist/shared/budget.js +159 -0
- package/dist/shared/exposure.js +110 -0
- package/dist/shared/git.js +49 -0
- package/dist/shared/manifest.js +11 -2
- package/dist/shared/mcp.js +33 -0
- package/dist/shared/schema-tokens.js +86 -0
- package/dist/shared/tokens.js +41 -0
- package/package.json +2 -1
|
@@ -1,78 +1,44 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
|
-
import { text } from "../../shared/mcp.js";
|
|
2
|
+
import { defineTool, text } from "../../shared/mcp.js";
|
|
3
|
+
import { shouldExpose } from "../../shared/exposure.js";
|
|
3
4
|
import { buildIndex } from "./indexer.js";
|
|
4
5
|
import { explore, search, recall, impact, trace } from "./query.js";
|
|
5
6
|
import { startWatch, stopWatch, watchStatus } from "./watcher.js";
|
|
6
7
|
import { visualize } from "./visualize.js";
|
|
7
8
|
// ─── Compass: speclaw's own code-intelligence engine (no external deps) ───
|
|
8
|
-
// A local graph of the codebase (nodes = definitions, edges = calls/imports)
|
|
9
|
-
// plus a local vector store for semantic recall. Lives in .speclaw/ (gitignored).
|
|
10
9
|
/**
|
|
11
|
-
* Register
|
|
12
|
-
* trace, watch) on the given server.
|
|
10
|
+
* Register Compass MCP tools on the given server.
|
|
13
11
|
*
|
|
14
|
-
* @param server - The MCP server to register
|
|
12
|
+
* @param server - The MCP server to register on.
|
|
13
|
+
* @param opts - Exposure options (`minimal` omits setup/specialized tools).
|
|
15
14
|
*/
|
|
16
|
-
export function registerCompass(server) {
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
}, async ({ projectPath, node }) => text(
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
limit: z.number().optional().describe("Max results (default 25)"),
|
|
34
|
-
},
|
|
35
|
-
}, async ({ projectPath, query, limit }) => text(search(projectPath, query, limit ?? 25)));
|
|
36
|
-
server.registerTool("compass_recall", {
|
|
37
|
-
description: "Semantic search of the Compass: describe what you're looking for in natural language ('where auth tokens are validated') and get the nodes ranked by meaning, using the local vector store — even when the identifier names don't contain your words. Requires compass_index to have run.",
|
|
38
|
-
inputSchema: {
|
|
39
|
-
projectPath: z.string().describe("Absolute path to the project"),
|
|
40
|
-
query: z.string().describe("Natural-language description of the code you want"),
|
|
41
|
-
limit: z.number().optional().describe("Max results (default 15)"),
|
|
42
|
-
},
|
|
43
|
-
}, async ({ projectPath, query, limit }) => text(await recall(projectPath, query, limit ?? 15)));
|
|
44
|
-
server.registerTool("compass_impact", {
|
|
45
|
-
description: "Blast radius: every node that transitively calls the target, up to a depth. Answers 'what could break if I change this?' before editing. Includes dynamic-dispatch callers (matched by name). Requires compass_index.",
|
|
46
|
-
inputSchema: {
|
|
47
|
-
projectPath: z.string().describe("Absolute path to the project"),
|
|
48
|
-
node: z.string().describe("Node name whose dependents you want"),
|
|
49
|
-
maxDepth: z.number().optional().describe("Max hops to traverse (default 4)"),
|
|
50
|
-
},
|
|
51
|
-
}, async ({ projectPath, node, maxDepth }) => text(impact(projectPath, node, maxDepth ?? 4)));
|
|
52
|
-
server.registerTool("compass_trace", {
|
|
53
|
-
description: "Trace a call path from one node to another: returns the chain of calls linking them (or null if none within depth). Useful to understand how an entrypoint reaches a sink. Requires compass_index.",
|
|
54
|
-
inputSchema: {
|
|
55
|
-
projectPath: z.string().describe("Absolute path to the project"),
|
|
56
|
-
from: z.string().describe("Starting node name"),
|
|
57
|
-
to: z.string().describe("Target node name"),
|
|
58
|
-
maxDepth: z.number().optional().describe("Max hops to search (default 8)"),
|
|
59
|
-
},
|
|
15
|
+
export function registerCompass(server, opts = {}) {
|
|
16
|
+
const minimal = Boolean(opts.minimal);
|
|
17
|
+
const add = (name, description, inputSchema, handler) => {
|
|
18
|
+
if (!shouldExpose(name, minimal))
|
|
19
|
+
return;
|
|
20
|
+
defineTool(server, { name, description, inputSchema, handler });
|
|
21
|
+
};
|
|
22
|
+
add("compass_index", "Build or refresh the local code graph index. Run once per project, then on demand.", { projectPath: z.string() }, async ({ projectPath }) => text(await buildIndex(projectPath)));
|
|
23
|
+
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)));
|
|
24
|
+
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)));
|
|
25
|
+
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)));
|
|
26
|
+
add("compass_impact", "List transitive callers of a symbol (blast radius) before editing.", { projectPath: z.string(), node: z.string(), maxDepth: z.number().optional() }, async ({ projectPath, node, maxDepth }) => text(impact(projectPath, node, maxDepth ?? 4)));
|
|
27
|
+
add("compass_trace", "Find a call path between two symbols within a depth limit.", {
|
|
28
|
+
projectPath: z.string(),
|
|
29
|
+
from: z.string(),
|
|
30
|
+
to: z.string(),
|
|
31
|
+
maxDepth: z.number().optional(),
|
|
60
32
|
}, async ({ projectPath, from, to, maxDepth }) => text(trace(projectPath, from, to, maxDepth ?? 8)));
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
depth: z.number().optional().describe("BFS depth around the focus node (default 2)"),
|
|
67
|
-
limit: z.number().optional().describe("Max nodes for the whole-graph view (default 300)"),
|
|
68
|
-
},
|
|
33
|
+
add("compass_visualize", "Write an offline HTML graph to .speclaw/graph.html for interactive exploration.", {
|
|
34
|
+
projectPath: z.string(),
|
|
35
|
+
node: z.string().optional(),
|
|
36
|
+
depth: z.number().optional(),
|
|
37
|
+
limit: z.number().optional(),
|
|
69
38
|
}, async ({ projectPath, node, depth, limit }) => text(visualize(projectPath, { focus: node, depth, limit })));
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
projectPath: z.string().describe("Absolute path to the project"),
|
|
74
|
-
action: z.enum(["start", "stop", "status"]).describe("start, stop, or status"),
|
|
75
|
-
},
|
|
39
|
+
add("compass_watch", "Start, stop, or status a debounced file watcher that re-indexes on change.", {
|
|
40
|
+
projectPath: z.string(),
|
|
41
|
+
action: z.enum(["start", "stop", "status"]),
|
|
76
42
|
}, async ({ projectPath, action }) => {
|
|
77
43
|
const result = action === "start"
|
|
78
44
|
? startWatch(projectPath)
|
|
@@ -42,6 +42,9 @@ a missing graph is not license to skip Compass. The only legitimate fallbacks
|
|
|
42
42
|
to Grep/Read: a Compass call returned nothing useful for your query, or the
|
|
43
43
|
target isn't indexed code (stylesheets, JSON/config, markdown, logs).
|
|
44
44
|
|
|
45
|
+
<!-- speclaw:map:start -->
|
|
46
|
+
<!-- speclaw:map:end -->
|
|
47
|
+
|
|
45
48
|
## Project-specific starting points
|
|
46
49
|
|
|
47
50
|
<!-- Filled in during speclaw init: the project's real entrypoints, core
|
|
@@ -44,6 +44,55 @@
|
|
|
44
44
|
"verification": { "kind": "path" },
|
|
45
45
|
"enforcement": "feedback",
|
|
46
46
|
"source": { "file": "LAWS.md" }
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
"id": "law~shared-stays-inner~1",
|
|
50
|
+
"title": "shared stays the innermost layer",
|
|
51
|
+
"rationale": "shared/ must not import modules/ or cli/, or the layering law is inverted.",
|
|
52
|
+
"severity": "error",
|
|
53
|
+
"scope": ["src/shared/**"],
|
|
54
|
+
"prose": "src/shared must not import from src/modules or src/cli.",
|
|
55
|
+
"verification": {
|
|
56
|
+
"kind": "deps",
|
|
57
|
+
"rule": {
|
|
58
|
+
"from": "^src/shared/",
|
|
59
|
+
"to": "^src/(modules|cli)/",
|
|
60
|
+
"type": "forbidden",
|
|
61
|
+
"edgeKinds": ["import"]
|
|
62
|
+
}
|
|
63
|
+
},
|
|
64
|
+
"enforcement": "gate",
|
|
65
|
+
"source": { "file": "docs/standards/architecture.md" }
|
|
66
|
+
},
|
|
67
|
+
{
|
|
68
|
+
"id": "law~compass-does-not-import-foundation~1",
|
|
69
|
+
"title": "Compass does not import foundation",
|
|
70
|
+
"rationale": "foundation already imports compass; the reverse would be a module cycle.",
|
|
71
|
+
"severity": "error",
|
|
72
|
+
"scope": ["src/modules/compass/**"],
|
|
73
|
+
"prose": "src/modules/compass must not import from src/modules/foundation.",
|
|
74
|
+
"verification": {
|
|
75
|
+
"kind": "deps",
|
|
76
|
+
"rule": {
|
|
77
|
+
"from": "^src/modules/compass/",
|
|
78
|
+
"to": "^src/modules/foundation/",
|
|
79
|
+
"type": "forbidden",
|
|
80
|
+
"edgeKinds": ["import"]
|
|
81
|
+
}
|
|
82
|
+
},
|
|
83
|
+
"enforcement": "gate",
|
|
84
|
+
"source": { "file": "docs/standards/architecture.md" }
|
|
85
|
+
},
|
|
86
|
+
{
|
|
87
|
+
"id": "law~no-module-cycles~1",
|
|
88
|
+
"title": "No circular module dependencies",
|
|
89
|
+
"rationale": "Modules may reuse another module's exported helper, but there are no circular dependencies.",
|
|
90
|
+
"severity": "error",
|
|
91
|
+
"scope": ["src/modules/**"],
|
|
92
|
+
"prose": "There are no circular dependencies between modules.",
|
|
93
|
+
"verification": { "kind": "graph", "rule": { "circular": true } },
|
|
94
|
+
"enforcement": "gate",
|
|
95
|
+
"source": { "file": "docs/standards/architecture.md" }
|
|
47
96
|
}
|
|
48
97
|
]
|
|
49
98
|
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# Consumer template. init/update write this only when the path is missing.
|
|
2
|
+
# Never the untrusted PR trigger. The verify job has no secrets.
|
|
3
|
+
name: ⚖️ speclaw
|
|
4
|
+
on: pull_request
|
|
5
|
+
|
|
6
|
+
permissions: {}
|
|
7
|
+
|
|
8
|
+
jobs:
|
|
9
|
+
verify:
|
|
10
|
+
name: ⚖️ Verify laws
|
|
11
|
+
runs-on: ubuntu-latest
|
|
12
|
+
permissions:
|
|
13
|
+
contents: read
|
|
14
|
+
security-events: write
|
|
15
|
+
steps:
|
|
16
|
+
- name: 📥 Checkout
|
|
17
|
+
uses: actions/checkout@v5
|
|
18
|
+
with:
|
|
19
|
+
fetch-depth: 0
|
|
20
|
+
- name: ⚖️ Verify
|
|
21
|
+
uses: esneiderbravo/speclaw@v1
|
|
22
|
+
- name: 🛡️ Upload SARIF
|
|
23
|
+
uses: github/codeql-action/upload-sarif@v4
|
|
24
|
+
if: always()
|
|
25
|
+
with:
|
|
26
|
+
sarif_file: speclaw.sarif
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
const RANK = { error: 3, warn: 2, info: 1 };
|
|
2
|
+
/**
|
|
3
|
+
* Parse `--fail-on`. An omitted flag is `error`; any other string is invalid
|
|
4
|
+
* (the CLI maps that to exit 2).
|
|
5
|
+
*
|
|
6
|
+
* @param raw - The flag value from `parseFlags`.
|
|
7
|
+
*/
|
|
8
|
+
export function parseFailOn(raw) {
|
|
9
|
+
if (raw === undefined || raw === true)
|
|
10
|
+
return "error";
|
|
11
|
+
if (raw === "error" || raw === "warn" || raw === "info")
|
|
12
|
+
return raw;
|
|
13
|
+
return null;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Stable identity of a finding, reused as the SARIF `partialFingerprints`
|
|
17
|
+
* value. There is no known-violations baseline in this slice, so the
|
|
18
|
+
* fingerprint is local: law + file + line.
|
|
19
|
+
*
|
|
20
|
+
* @param f - A batch finding.
|
|
21
|
+
*/
|
|
22
|
+
export function fingerprint(f) {
|
|
23
|
+
return `${f.lawId}:${f.file}:${f.line ?? 0}`;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Map a {@link VerifyReport} onto the public `speclaw verify` exit codes
|
|
27
|
+
* `0` / `1` / `4`. Usage (`2`) and environment (`3`) errors are decided by
|
|
28
|
+
* the CLI before this runs.
|
|
29
|
+
*
|
|
30
|
+
* @param report - The batch report.
|
|
31
|
+
* @param opts.failOn - Minimum severity that fails the process.
|
|
32
|
+
* @param opts.strictEngines - When true, any skip becomes exit `4`.
|
|
33
|
+
*/
|
|
34
|
+
export function verifyExitCode(report, opts) {
|
|
35
|
+
const threshold = RANK[opts.failOn];
|
|
36
|
+
if (report.findings.some((f) => RANK[f.severity] >= threshold))
|
|
37
|
+
return 1;
|
|
38
|
+
if (opts.strictEngines && report.skipped.length > 0)
|
|
39
|
+
return 4;
|
|
40
|
+
return 0;
|
|
41
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { registerCompass } from "../compass/register.js";
|
|
2
|
+
import { registerSpec } from "../lawbook/register.js";
|
|
3
|
+
import { registerTools } from "../tools/register.js";
|
|
4
|
+
import { registerFoundation } from "./register.js";
|
|
5
|
+
import { measureBudget } from "../../shared/budget.js";
|
|
6
|
+
import { isMinimalMode, packageRoot } from "../../shared/exposure.js";
|
|
7
|
+
/**
|
|
8
|
+
* Collect tool definitions as the MCP server would register them for a profile.
|
|
9
|
+
*
|
|
10
|
+
* @param minimal - Exposure profile.
|
|
11
|
+
*/
|
|
12
|
+
export function collectRegisteredTools(minimal) {
|
|
13
|
+
const tools = [];
|
|
14
|
+
const server = {
|
|
15
|
+
registerTool(name, config) {
|
|
16
|
+
tools.push({
|
|
17
|
+
name,
|
|
18
|
+
description: config.description ?? "",
|
|
19
|
+
inputSchema: config.inputSchema,
|
|
20
|
+
});
|
|
21
|
+
},
|
|
22
|
+
};
|
|
23
|
+
const opts = { minimal };
|
|
24
|
+
const stub = server;
|
|
25
|
+
registerFoundation(stub, opts);
|
|
26
|
+
registerSpec(stub, opts);
|
|
27
|
+
registerCompass(stub, opts);
|
|
28
|
+
registerTools(stub, opts);
|
|
29
|
+
return tools;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Measure context budget for an install (registered tools + project files).
|
|
33
|
+
*
|
|
34
|
+
* @param projectPath - Project root.
|
|
35
|
+
* @param minimal - Optional forced profile.
|
|
36
|
+
*/
|
|
37
|
+
export function measureInstallBudget(projectPath, minimal) {
|
|
38
|
+
const profile = minimal ?? isMinimalMode(projectPath);
|
|
39
|
+
return measureBudget({
|
|
40
|
+
projectPath,
|
|
41
|
+
packagePath: packageRoot(),
|
|
42
|
+
tools: collectRegisteredTools(profile),
|
|
43
|
+
minimal: profile,
|
|
44
|
+
});
|
|
45
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { underPaths } from "./verify.js";
|
|
1
|
+
import { underPaths } from "./verify-model.js";
|
|
2
2
|
/** Substitute `$1`, `$2`, … in a pattern with capture groups from a match. */
|
|
3
3
|
function applyGroups(pattern, match) {
|
|
4
4
|
return pattern.replace(/\$(\d+)/g, (_whole, d) => match[Number(d)] ?? "");
|
|
@@ -1,6 +1,7 @@
|
|
|
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
5
|
import { globError, hasBackend, hasBatchBackend, readLawManifest } from "./laws.js";
|
|
5
6
|
/**
|
|
6
7
|
* Run the speclaw installation health checks against a project: ai-specs and
|
|
@@ -101,6 +102,14 @@ export function doctor(projectPath) {
|
|
|
101
102
|
detail: has(".mcp.json") ? "present" : "missing — scaffold writes it",
|
|
102
103
|
});
|
|
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
|
+
});
|
|
104
113
|
return checks;
|
|
105
114
|
}
|
|
106
115
|
/** The law ids recorded as loaded into agent context, from the append-only log. */
|
|
@@ -151,6 +151,34 @@ export function seedManifest() {
|
|
|
151
151
|
const raw = JSON.parse(fs.readFileSync(path.join(ASSETS, "laws", "laws-manifest.json"), "utf8"));
|
|
152
152
|
return manifestSchema.parse(raw);
|
|
153
153
|
}
|
|
154
|
+
/**
|
|
155
|
+
* The manifest the batch verifier should use: the project's file when present,
|
|
156
|
+
* otherwise the shipped seed (so a clean CI clone does not silently pass).
|
|
157
|
+
*
|
|
158
|
+
* @param projectPath - Project root to read from.
|
|
159
|
+
*/
|
|
160
|
+
export function loadManifestForVerify(projectPath) {
|
|
161
|
+
return readLawManifest(projectPath) ?? seedManifest();
|
|
162
|
+
}
|
|
163
|
+
/**
|
|
164
|
+
* Append shipped seed laws whose `id` is not already in `existing`. Existing
|
|
165
|
+
* entries are never overwritten — a curated law keeps its prose, scope, and
|
|
166
|
+
* enforcement across `update`.
|
|
167
|
+
*
|
|
168
|
+
* @param existing - The project's current manifest.
|
|
169
|
+
* @returns The merged manifest and the ids that were added.
|
|
170
|
+
*/
|
|
171
|
+
export function mergeSeedLaws(existing) {
|
|
172
|
+
const seed = seedManifest();
|
|
173
|
+
const have = new Set(existing.laws.map((l) => l.id));
|
|
174
|
+
const extra = seed.laws.filter((l) => !have.has(l.id));
|
|
175
|
+
if (extra.length === 0)
|
|
176
|
+
return { manifest: existing, added: [] };
|
|
177
|
+
return {
|
|
178
|
+
manifest: { ...existing, laws: [...existing.laws, ...extra] },
|
|
179
|
+
added: extra.map((l) => l.id),
|
|
180
|
+
};
|
|
181
|
+
}
|
|
154
182
|
// ─── Glob matching (the `path` backend) ──────────────────────────────────────
|
|
155
183
|
/**
|
|
156
184
|
* Validate a scope glob without compiling it for use, so generation can fail
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
|
-
import { text } from "../../shared/mcp.js";
|
|
2
|
+
import { defineTool, text } from "../../shared/mcp.js";
|
|
3
|
+
import { shouldExpose } from "../../shared/exposure.js";
|
|
3
4
|
import { scaffold } from "./scaffold.js";
|
|
4
5
|
import { doctor } from "./doctor.js";
|
|
5
6
|
import { checkAction } from "./check.js";
|
|
@@ -7,77 +8,57 @@ import { verifyLaws } from "./verify.js";
|
|
|
7
8
|
import { loadPacks } from "../tools/packs.js";
|
|
8
9
|
import { AGENTS, configureAgent } from "../../shared/agents.js";
|
|
9
10
|
import { emptyReport } from "../../shared/install.js";
|
|
11
|
+
/** Human help text for init_project's questionnaire (not embedded in MCP schemas). */
|
|
12
|
+
const profileFieldHelp = {
|
|
13
|
+
project_name: "Short project name, e.g. the repo name",
|
|
14
|
+
project_description: "One-line description of what the project does",
|
|
15
|
+
organization: "Company/team name",
|
|
16
|
+
stack_summary: "e.g. 'Next.js 15 + TypeScript frontend, FastAPI + PostgreSQL backend'",
|
|
17
|
+
architecture: "e.g. 'hexagonal architecture with bounded contexts'",
|
|
18
|
+
test_commands: "Real commands, e.g. 'pytest backend/tests && npm run test'",
|
|
19
|
+
lint_commands: "Real commands, e.g. 'ruff check . && npm run lint && tsc --noEmit'",
|
|
20
|
+
branch_pattern: "e.g. 'feature/<ticket-id>-<slug>'",
|
|
21
|
+
commit_style: "e.g. 'conventional commits, imperative, English'",
|
|
22
|
+
custom_laws: "Extra markdown for LAWS.md — project-specific binding rules",
|
|
23
|
+
compass_hints: "Markdown bullets with real entrypoints for docs/compass.md",
|
|
24
|
+
base_standards_extra: "Extra cross-cutting rules for base-standards.md",
|
|
25
|
+
modules_table: "Markdown table of modules/bounded contexts",
|
|
26
|
+
layering_rules: "Layers and allowed dependencies for architecture.md",
|
|
27
|
+
backend_layers: "Backend layer table for backend-standards.md",
|
|
28
|
+
frontend_layers: "Frontend layer table for frontend-standards.md",
|
|
29
|
+
versioning_rules: "Versioning/release convention for conventions.md",
|
|
30
|
+
documentation_extra: "Repo-specific docstring notes for documentation.md",
|
|
31
|
+
};
|
|
32
|
+
/** Lean Zod shape for scaffold — no .describe() text (that cost rides in every request). */
|
|
10
33
|
const profileShape = {
|
|
11
|
-
project_name: z.string()
|
|
12
|
-
project_description: z
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
lint_commands: z
|
|
30
|
-
.string()
|
|
31
|
-
.optional()
|
|
32
|
-
.describe("Real commands, e.g. 'ruff check . && npm run lint && tsc --noEmit'"),
|
|
33
|
-
branch_pattern: z.string().optional().describe("e.g. 'feature/<ticket-id>-<slug>'"),
|
|
34
|
-
commit_style: z.string().optional().describe("e.g. 'conventional commits, imperative, English'"),
|
|
35
|
-
custom_laws: z
|
|
36
|
-
.string()
|
|
37
|
-
.optional()
|
|
38
|
-
.describe("Extra markdown appended to LAWS.md — project-specific binding rules the analysis surfaced"),
|
|
39
|
-
compass_hints: z
|
|
40
|
-
.string()
|
|
41
|
-
.optional()
|
|
42
|
-
.describe("Markdown bullets with the repo's real entrypoints and common traces, inserted into docs/compass.md"),
|
|
43
|
-
base_standards_extra: z
|
|
44
|
-
.string()
|
|
45
|
-
.optional()
|
|
46
|
-
.describe("Markdown with any project-specific cross-cutting rules, appended to docs/standards/base-standards.md"),
|
|
47
|
-
modules_table: z
|
|
48
|
-
.string()
|
|
49
|
-
.optional()
|
|
50
|
-
.describe("Markdown table of the repo's real modules/bounded contexts + one-line responsibility, for docs/standards/architecture.md"),
|
|
51
|
-
layering_rules: z
|
|
52
|
-
.string()
|
|
53
|
-
.optional()
|
|
54
|
-
.describe("Markdown describing the layers and their allowed dependencies, for docs/standards/architecture.md"),
|
|
55
|
-
backend_layers: z
|
|
56
|
-
.string()
|
|
57
|
-
.optional()
|
|
58
|
-
.describe("Markdown layer table (Layer | File | Responsibility) from the real backend, for docs/standards/backend-standards.md"),
|
|
59
|
-
frontend_layers: z
|
|
60
|
-
.string()
|
|
61
|
-
.optional()
|
|
62
|
-
.describe("Markdown layer table from the real frontend, for docs/standards/frontend-standards.md"),
|
|
63
|
-
versioning_rules: z
|
|
64
|
-
.string()
|
|
65
|
-
.optional()
|
|
66
|
-
.describe("The repo's versioning/release convention, for docs/standards/conventions.md"),
|
|
67
|
-
documentation_extra: z
|
|
68
|
-
.string()
|
|
69
|
-
.optional()
|
|
70
|
-
.describe("Repo-specific docstring notes (keep only the languages used, the enforced linter), appended to docs/standards/documentation.md"),
|
|
34
|
+
project_name: z.string(),
|
|
35
|
+
project_description: z.string().optional(),
|
|
36
|
+
organization: z.string().optional(),
|
|
37
|
+
stack_summary: z.string().optional(),
|
|
38
|
+
architecture: z.string().optional(),
|
|
39
|
+
test_commands: z.string().optional(),
|
|
40
|
+
lint_commands: z.string().optional(),
|
|
41
|
+
branch_pattern: z.string().optional(),
|
|
42
|
+
commit_style: z.string().optional(),
|
|
43
|
+
custom_laws: z.string().optional(),
|
|
44
|
+
compass_hints: z.string().optional(),
|
|
45
|
+
base_standards_extra: z.string().optional(),
|
|
46
|
+
modules_table: z.string().optional(),
|
|
47
|
+
layering_rules: z.string().optional(),
|
|
48
|
+
backend_layers: z.string().optional(),
|
|
49
|
+
frontend_layers: z.string().optional(),
|
|
50
|
+
versioning_rules: z.string().optional(),
|
|
51
|
+
documentation_extra: z.string().optional(),
|
|
71
52
|
};
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
}, async () => {
|
|
53
|
+
/** Register foundation MCP tools. */
|
|
54
|
+
export function registerFoundation(server, opts = {}) {
|
|
55
|
+
const minimal = Boolean(opts.minimal);
|
|
56
|
+
const add = (name, description, inputSchema, handler) => {
|
|
57
|
+
if (!shouldExpose(name, minimal))
|
|
58
|
+
return;
|
|
59
|
+
defineTool(server, { name, description, inputSchema, handler });
|
|
60
|
+
};
|
|
61
|
+
add("init_project", "Start here to initialize speclaw: returns the analysis questionnaire and packs.", { projectPath: z.string() }, async () => {
|
|
81
62
|
const packs = loadPacks();
|
|
82
63
|
return text({
|
|
83
64
|
instructions: [
|
|
@@ -89,67 +70,37 @@ export function registerFoundation(server) {
|
|
|
89
70
|
"6. Call the 'scaffold' tool with { projectPath, profile, packs }.",
|
|
90
71
|
"7. Follow the nextSteps returned by scaffold: complete the HTML-comment sections still left in docs/standards/*, then run the lawbook_init and compass_index tools (both built into speclaw — no external installs).",
|
|
91
72
|
],
|
|
92
|
-
profileFields:
|
|
73
|
+
profileFields: profileFieldHelp,
|
|
93
74
|
packs,
|
|
94
75
|
});
|
|
95
76
|
});
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
packs: z.array(z.string()).describe("Optional tool pack names (quality, workflow, agents)"),
|
|
102
|
-
agents: z
|
|
103
|
-
.array(z.string())
|
|
104
|
-
.optional()
|
|
105
|
-
.describe(`Agent ids to configure (symlinks + MCP): ${AGENTS.map((a) => a.id).join(", ")}. Usually the CLI handles this; omit to write content only.`),
|
|
106
|
-
},
|
|
77
|
+
add("scaffold", "Write foundation, lawbook workflow, packs, IDE symlinks, and .mcp.json. Never overwrites.", {
|
|
78
|
+
projectPath: z.string(),
|
|
79
|
+
profile: z.object(profileShape),
|
|
80
|
+
packs: z.array(z.string()),
|
|
81
|
+
agents: z.array(z.string()).optional(),
|
|
107
82
|
}, async ({ projectPath, profile, packs, agents }) => text(scaffold(projectPath, profile, packs, agents ?? [])));
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
projectPath: z.string().describe("Absolute path to the project"),
|
|
112
|
-
agent: z
|
|
113
|
-
.enum(AGENTS.map((a) => a.id))
|
|
114
|
-
.describe("Agent id to configure"),
|
|
115
|
-
},
|
|
83
|
+
add("configure_agent", "Add one agent's IDE symlinks and MCP config to an already-scaffolded project.", {
|
|
84
|
+
projectPath: z.string(),
|
|
85
|
+
agent: z.enum(AGENTS.map((a) => a.id)),
|
|
116
86
|
}, async ({ projectPath, agent }) => {
|
|
117
87
|
const report = emptyReport();
|
|
118
88
|
configureAgent(projectPath, agent, report);
|
|
119
89
|
return text(report);
|
|
120
90
|
});
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
event: z
|
|
127
|
-
.enum(["PreToolUse", "PostToolUse", "Stop", "InstructionsLoaded"])
|
|
128
|
-
.describe("The hook event that fired"),
|
|
129
|
-
toolName: z.string().optional().describe("The tool the agent is invoking, when relevant"),
|
|
130
|
-
payload: z.record(z.unknown()).describe("The raw hook event payload from the agent"),
|
|
131
|
-
},
|
|
91
|
+
add("speclaw_check", "Invoked by speclaw's hooks to enforce laws — do not call directly.", {
|
|
92
|
+
projectPath: z.string(),
|
|
93
|
+
event: z.enum(["PreToolUse", "PostToolUse", "Stop", "InstructionsLoaded"]),
|
|
94
|
+
toolName: z.string().optional(),
|
|
95
|
+
payload: z.record(z.unknown()),
|
|
132
96
|
}, async ({ projectPath, event, toolName, payload }) => text(checkAction({ projectPath, event: event, toolName, payload })));
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
paths: z
|
|
139
|
-
.array(z.string())
|
|
140
|
-
.optional()
|
|
141
|
-
.describe("Restrict to source files under these project-relative paths"),
|
|
142
|
-
engines: z
|
|
143
|
-
.array(z.enum(["deps", "graph"]))
|
|
144
|
-
.optional()
|
|
145
|
-
.describe("Which batch engines to run; omit for all"),
|
|
146
|
-
lawIds: z.array(z.string()).optional().describe("Restrict to these law ids"),
|
|
147
|
-
},
|
|
97
|
+
add("law_verify", "Verify deterministic deps/graph laws and return violations by file.", {
|
|
98
|
+
projectPath: z.string(),
|
|
99
|
+
paths: z.array(z.string()).optional(),
|
|
100
|
+
engines: z.array(z.enum(["deps", "graph"])).optional(),
|
|
101
|
+
lawIds: z.array(z.string()).optional(),
|
|
148
102
|
}, async ({ projectPath, paths, engines, lawIds }) => text(verifyLaws({ projectPath, paths, engines: engines, lawIds })));
|
|
149
|
-
|
|
150
|
-
description: "Verify a speclaw installation: ai-specs presence, the foundation (LAWS.md + standards + agent contracts), IDE symlinks health, the lawbook/ workflow, the Compass index, and .mcp.json wiring. Returns a checklist with remediation hints.",
|
|
151
|
-
inputSchema: { projectPath: z.string().describe("Absolute path to the project") },
|
|
152
|
-
}, async ({ projectPath }) => {
|
|
103
|
+
add("doctor", "Verify the speclaw install: foundation, symlinks, lawbook, Compass, and MCP wiring.", { projectPath: z.string() }, async ({ projectPath }) => {
|
|
153
104
|
const checks = doctor(projectPath);
|
|
154
105
|
const failed = checks.filter((c) => !c.ok);
|
|
155
106
|
return text({
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Markdown projection of a {@link VerifyReport} for `$GITHUB_STEP_SUMMARY`
|
|
3
|
+
* (and stdout `--format markdown`). Honest by construction: it lists findings
|
|
4
|
+
* and skips, and never claims a requirement is covered (trace is not in this
|
|
5
|
+
* slice).
|
|
6
|
+
*
|
|
7
|
+
* @param report - The batch report.
|
|
8
|
+
*/
|
|
9
|
+
export function toMarkdown(report) {
|
|
10
|
+
const lines = [
|
|
11
|
+
"## speclaw · law verification",
|
|
12
|
+
"",
|
|
13
|
+
`**${report.summary.passed}** passed · **${report.summary.failed}** failed · **${report.summary.skipped}** skipped · **${report.summary.unknown}** unknown`,
|
|
14
|
+
"",
|
|
15
|
+
];
|
|
16
|
+
if (report.findings.length > 0) {
|
|
17
|
+
lines.push("### Findings", "", "| Law | Severity | Location |", "| :-- | :-- | :-- |");
|
|
18
|
+
for (const f of report.findings) {
|
|
19
|
+
const at = f.line ? `${f.file}:${f.line}` : f.file;
|
|
20
|
+
lines.push(`| \`${f.lawId}\` | ${f.severity} | \`${at}\` |`);
|
|
21
|
+
}
|
|
22
|
+
lines.push("");
|
|
23
|
+
}
|
|
24
|
+
if (report.skipped.length > 0) {
|
|
25
|
+
lines.push("<details><summary>Not evaluated</summary>", "");
|
|
26
|
+
for (const s of report.skipped) {
|
|
27
|
+
lines.push(`- \`${s.lawId}\` — ${s.reason}${s.detail ? `: ${s.detail}` : ""}`);
|
|
28
|
+
}
|
|
29
|
+
lines.push("", "</details>", "");
|
|
30
|
+
}
|
|
31
|
+
lines.push("_Deterministic · no model · no network. Reproduce with `speclaw verify`._", "");
|
|
32
|
+
return lines.join("\n");
|
|
33
|
+
}
|