@esneiderbravo/speclaw 0.3.4 → 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 +24 -0
- 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 +23 -3
- package/dist/cli/index.js +11 -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/context-budget.js +45 -0
- package/dist/modules/foundation/doctor.js +9 -0
- package/dist/modules/foundation/register.js +71 -120
- package/dist/modules/foundation/scaffold.js +1 -1
- 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/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
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
import { readManifest } from "./manifest.js";
|
|
5
|
+
/**
|
|
6
|
+
* Tools omitted when the exposure profile is `minimal`. Kept tools are the
|
|
7
|
+
* discovery + law loop: compass_explore/search/recall, lawbook_validate/sync,
|
|
8
|
+
* law_verify, speclaw_check.
|
|
9
|
+
*/
|
|
10
|
+
export const MINIMAL_OMIT = new Set([
|
|
11
|
+
"compass_index",
|
|
12
|
+
"compass_watch",
|
|
13
|
+
"compass_impact",
|
|
14
|
+
"compass_trace",
|
|
15
|
+
"compass_visualize",
|
|
16
|
+
"lawbook_init",
|
|
17
|
+
"lawbook_archive",
|
|
18
|
+
"lawbook_list",
|
|
19
|
+
"init_project",
|
|
20
|
+
"scaffold",
|
|
21
|
+
"configure_agent",
|
|
22
|
+
"doctor",
|
|
23
|
+
"add_pack",
|
|
24
|
+
"list_packs",
|
|
25
|
+
]);
|
|
26
|
+
/**
|
|
27
|
+
* Whether minimal exposure is active for this process / project.
|
|
28
|
+
* `SPECLAW_MINIMAL=1` wins; otherwise the project manifest's `minimal` flag.
|
|
29
|
+
*
|
|
30
|
+
* @param cwd - Project root to read the manifest from (default `process.cwd()`).
|
|
31
|
+
*/
|
|
32
|
+
export function isMinimalMode(cwd = process.cwd()) {
|
|
33
|
+
if (process.env.SPECLAW_MINIMAL === "1")
|
|
34
|
+
return true;
|
|
35
|
+
return Boolean(readManifest(cwd)?.minimal);
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Whether a tool name should be registered under the given profile.
|
|
39
|
+
*
|
|
40
|
+
* @param name - MCP tool name.
|
|
41
|
+
* @param minimal - Active exposure profile.
|
|
42
|
+
*/
|
|
43
|
+
export function shouldExpose(name, minimal) {
|
|
44
|
+
return !(minimal && MINIMAL_OMIT.has(name));
|
|
45
|
+
}
|
|
46
|
+
const DEFAULT_BUDGET = {
|
|
47
|
+
schemaVersion: 1,
|
|
48
|
+
estimator: "speclaw/estimate-v1",
|
|
49
|
+
surfaces: {
|
|
50
|
+
tools: 12000,
|
|
51
|
+
skillsAndCommands: 4000,
|
|
52
|
+
alwaysOnInstructions: 8000,
|
|
53
|
+
},
|
|
54
|
+
total: 24000,
|
|
55
|
+
perTool: 800,
|
|
56
|
+
maxDescriptionWords: 25,
|
|
57
|
+
dispatcher: 400,
|
|
58
|
+
map: 300,
|
|
59
|
+
minimal: { tools: 4500, total: 12000 },
|
|
60
|
+
note: "Placeholder ceilings — replaced after the first post-rewrite measurement.",
|
|
61
|
+
};
|
|
62
|
+
/**
|
|
63
|
+
* Resolve the directory that holds speclaw's package root (where
|
|
64
|
+
* `token-budget.json` lives when developing or when shipped next to package.json).
|
|
65
|
+
*/
|
|
66
|
+
export function packageRoot() {
|
|
67
|
+
// Walk up from this module: src/shared, dist/shared, or dist-test/src/shared.
|
|
68
|
+
// Prefer a directory that actually contains token-budget.json — dist-test also
|
|
69
|
+
// has package.json with this package's name, so name-matching alone is wrong.
|
|
70
|
+
let dir = path.dirname(fileURLToPath(import.meta.url));
|
|
71
|
+
let named = null;
|
|
72
|
+
for (let i = 0; i < 8; i++) {
|
|
73
|
+
if (fs.existsSync(path.join(dir, "token-budget.json")))
|
|
74
|
+
return dir;
|
|
75
|
+
const pkg = path.join(dir, "package.json");
|
|
76
|
+
if (!named && fs.existsSync(pkg)) {
|
|
77
|
+
try {
|
|
78
|
+
const name = JSON.parse(fs.readFileSync(pkg, "utf8")).name;
|
|
79
|
+
if (name === "@esneiderbravo/speclaw")
|
|
80
|
+
named = dir;
|
|
81
|
+
}
|
|
82
|
+
catch {
|
|
83
|
+
/* continue */
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
const parent = path.dirname(dir);
|
|
87
|
+
if (parent === dir)
|
|
88
|
+
break;
|
|
89
|
+
dir = parent;
|
|
90
|
+
}
|
|
91
|
+
if (named)
|
|
92
|
+
return named;
|
|
93
|
+
return process.cwd();
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Load declared budget ceilings. Missing file → embedded defaults (tests may
|
|
97
|
+
* still override via the committed file once written).
|
|
98
|
+
*
|
|
99
|
+
* @param root - Directory containing `token-budget.json`.
|
|
100
|
+
*/
|
|
101
|
+
export function loadDeclaredBudget(root = packageRoot()) {
|
|
102
|
+
const p = path.join(root, "token-budget.json");
|
|
103
|
+
try {
|
|
104
|
+
const raw = JSON.parse(fs.readFileSync(p, "utf8"));
|
|
105
|
+
return { ...DEFAULT_BUDGET, ...raw, surfaces: { ...DEFAULT_BUDGET.surfaces, ...raw.surfaces } };
|
|
106
|
+
}
|
|
107
|
+
catch {
|
|
108
|
+
return { ...DEFAULT_BUDGET };
|
|
109
|
+
}
|
|
110
|
+
}
|
package/dist/shared/manifest.js
CHANGED
|
@@ -18,6 +18,7 @@ export function readManifest(projectPath) {
|
|
|
18
18
|
baselines: m.baselines && typeof m.baselines === "object"
|
|
19
19
|
? m.baselines
|
|
20
20
|
: {},
|
|
21
|
+
minimal: Boolean(m.minimal),
|
|
21
22
|
};
|
|
22
23
|
}
|
|
23
24
|
catch {
|
|
@@ -32,12 +33,20 @@ export function readManifest(projectPath) {
|
|
|
32
33
|
* @param version - The speclaw version doing the write.
|
|
33
34
|
* @param packs - Pack names installed in this run.
|
|
34
35
|
* @param baselines - Managed-file hashes to merge over the recorded ones.
|
|
36
|
+
* @param opts - Optional `minimal` flag; omitted keeps the prior value (default false).
|
|
35
37
|
*/
|
|
36
|
-
export function writeManifest(projectPath, version, packs, baselines = {}) {
|
|
38
|
+
export function writeManifest(projectPath, version, packs, baselines = {}, opts = {}) {
|
|
37
39
|
const prev = readManifest(projectPath);
|
|
38
40
|
const merged = Array.from(new Set([...(prev?.packs ?? []), ...packs]));
|
|
39
41
|
const mergedBaselines = { ...(prev?.baselines ?? {}), ...baselines };
|
|
42
|
+
const minimal = opts.minimal !== undefined ? opts.minimal : (prev?.minimal ?? false);
|
|
40
43
|
const p = manifestPath(projectPath);
|
|
41
44
|
fs.mkdirSync(path.dirname(p), { recursive: true });
|
|
42
|
-
|
|
45
|
+
const body = {
|
|
46
|
+
version,
|
|
47
|
+
packs: merged,
|
|
48
|
+
baselines: mergedBaselines,
|
|
49
|
+
...(minimal ? { minimal: true } : {}),
|
|
50
|
+
};
|
|
51
|
+
fs.writeFileSync(p, JSON.stringify(body, null, 2) + "\n");
|
|
43
52
|
}
|
package/dist/shared/mcp.js
CHANGED
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
import { loadDeclaredBudget } from "./exposure.js";
|
|
2
|
+
import { toolDefinitionTokens } from "./schema-tokens.js";
|
|
3
|
+
import { countWords } from "./tokens.js";
|
|
1
4
|
/**
|
|
2
5
|
* Wrap a value as an MCP text tool-result.
|
|
3
6
|
*
|
|
@@ -14,3 +17,33 @@ export function text(value) {
|
|
|
14
17
|
],
|
|
15
18
|
};
|
|
16
19
|
}
|
|
20
|
+
/**
|
|
21
|
+
* Register one MCP tool after enforcing the context-budget caps (description
|
|
22
|
+
* word count and estimated definition tokens). Does **not** set
|
|
23
|
+
* `defer_loading` — that is not author-settable for MCP servers.
|
|
24
|
+
*
|
|
25
|
+
* @param server - MCP server to register on.
|
|
26
|
+
* @param spec - Tool name, description, schema, and handler.
|
|
27
|
+
* @throws If the description or definition cost exceeds the declared cap.
|
|
28
|
+
*/
|
|
29
|
+
export function defineTool(server, spec) {
|
|
30
|
+
const budget = loadDeclaredBudget();
|
|
31
|
+
const words = countWords(spec.description);
|
|
32
|
+
if (words > budget.maxDescriptionWords) {
|
|
33
|
+
throw new Error(`tool ${spec.name}: description is ${words} words (cap ${budget.maxDescriptionWords})`);
|
|
34
|
+
}
|
|
35
|
+
const cost = toolDefinitionTokens({
|
|
36
|
+
name: spec.name,
|
|
37
|
+
description: spec.description,
|
|
38
|
+
inputSchema: spec.inputSchema,
|
|
39
|
+
});
|
|
40
|
+
if (cost > budget.perTool) {
|
|
41
|
+
throw new Error(`tool ${spec.name}: ${cost} tokens exceeds the ${budget.perTool} per-tool cap`);
|
|
42
|
+
}
|
|
43
|
+
const inputSchema = (spec.inputSchema ?? {});
|
|
44
|
+
server.registerTool(spec.name, {
|
|
45
|
+
description: spec.description,
|
|
46
|
+
inputSchema,
|
|
47
|
+
...(spec.annotations ? { annotations: spec.annotations } : {}),
|
|
48
|
+
}, spec.handler);
|
|
49
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { estimateTokens } from "./tokens.js";
|
|
2
|
+
/**
|
|
3
|
+
* Best-effort JSON Schema for the Zod shapes speclaw registers as MCP
|
|
4
|
+
* `inputSchema` records. Stable and offline — used only for token budgeting,
|
|
5
|
+
* not for validation. Prefer structural fidelity over full Zod coverage.
|
|
6
|
+
*
|
|
7
|
+
* @param shape - Record of Zod fields as passed to `registerTool`.
|
|
8
|
+
* @returns A JSON-Schema-like plain object suitable for `JSON.stringify`.
|
|
9
|
+
*/
|
|
10
|
+
export function zodShapeToJsonSchema(shape) {
|
|
11
|
+
const properties = {};
|
|
12
|
+
const required = [];
|
|
13
|
+
for (const [key, schema] of Object.entries(shape ?? {})) {
|
|
14
|
+
const { json, optional } = zodTypeToJson(schema);
|
|
15
|
+
properties[key] = json;
|
|
16
|
+
if (!optional)
|
|
17
|
+
required.push(key);
|
|
18
|
+
}
|
|
19
|
+
return {
|
|
20
|
+
type: "object",
|
|
21
|
+
properties,
|
|
22
|
+
...(required.length ? { required } : {}),
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
function zodTypeToJson(schema) {
|
|
26
|
+
const def = schema._def;
|
|
27
|
+
const typeName = def.typeName ?? "";
|
|
28
|
+
const description = def.description;
|
|
29
|
+
if (typeName === "ZodOptional" || typeName === "ZodDefault") {
|
|
30
|
+
const inner = zodTypeToJson(def.innerType);
|
|
31
|
+
return { json: withDesc(inner.json, description), optional: true };
|
|
32
|
+
}
|
|
33
|
+
if (typeName === "ZodString") {
|
|
34
|
+
return { json: withDesc({ type: "string" }, description), optional: false };
|
|
35
|
+
}
|
|
36
|
+
if (typeName === "ZodNumber") {
|
|
37
|
+
return { json: withDesc({ type: "number" }, description), optional: false };
|
|
38
|
+
}
|
|
39
|
+
if (typeName === "ZodBoolean") {
|
|
40
|
+
return { json: withDesc({ type: "boolean" }, description), optional: false };
|
|
41
|
+
}
|
|
42
|
+
if (typeName === "ZodEnum") {
|
|
43
|
+
return {
|
|
44
|
+
json: withDesc({ type: "string", enum: def.values ?? [] }, description),
|
|
45
|
+
optional: false,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
if (typeName === "ZodArray") {
|
|
49
|
+
const item = zodTypeToJson(def.type);
|
|
50
|
+
return {
|
|
51
|
+
json: withDesc({ type: "array", items: item.json }, description),
|
|
52
|
+
optional: false,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
if (typeName === "ZodObject") {
|
|
56
|
+
return {
|
|
57
|
+
json: withDesc(zodShapeToJsonSchema(def.shape?.() ?? {}), description),
|
|
58
|
+
optional: false,
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
if (typeName === "ZodRecord") {
|
|
62
|
+
const value = def.valueType ? zodTypeToJson(def.valueType).json : {};
|
|
63
|
+
return {
|
|
64
|
+
json: withDesc({ type: "object", additionalProperties: value }, description),
|
|
65
|
+
optional: false,
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
if (typeName === "ZodUnknown" || typeName === "ZodAny") {
|
|
69
|
+
return { json: withDesc({}, description), optional: false };
|
|
70
|
+
}
|
|
71
|
+
// Fallback: type name only — still deterministic for budgeting.
|
|
72
|
+
return { json: withDesc({ type: typeName || "unknown" }, description), optional: false };
|
|
73
|
+
}
|
|
74
|
+
function withDesc(json, description) {
|
|
75
|
+
return description ? { ...json, description } : json;
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Estimate tokens for one tool definition (name + description + JSON Schema).
|
|
79
|
+
*
|
|
80
|
+
* @param tool - Registered tool fields.
|
|
81
|
+
* @returns Estimated definition cost.
|
|
82
|
+
*/
|
|
83
|
+
export function toolDefinitionTokens(tool) {
|
|
84
|
+
const schemaJson = JSON.stringify(zodShapeToJsonSchema(tool.inputSchema));
|
|
85
|
+
return estimateTokens(tool.name) + estimateTokens(tool.description) + estimateTokens(schemaJson);
|
|
86
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Deterministic token estimator. NOT a tokenizer: intended accuracy about ±8%
|
|
3
|
+
* against Anthropic's tokenizer on speclaw's own asset corpus. Intentionally
|
|
4
|
+
* not exact — a real BPE vocab is a multi-megabyte dependency, and exact counts
|
|
5
|
+
* are model-dependent. The CI gate needs a number that is stable across versions.
|
|
6
|
+
*
|
|
7
|
+
* Contract: monotone (more text ⇒ more or equal tokens) and stable across runs
|
|
8
|
+
* and processes. Performs no network I/O.
|
|
9
|
+
*
|
|
10
|
+
* @param text - Input to estimate.
|
|
11
|
+
* @returns Estimated token count (non-negative integer).
|
|
12
|
+
*/
|
|
13
|
+
export function estimateTokens(text) {
|
|
14
|
+
if (text.length === 0)
|
|
15
|
+
return 0;
|
|
16
|
+
const chunks = text.match(/[A-Za-z]+|\d+|\s+|[^\sA-Za-z\d]/g) ?? [];
|
|
17
|
+
let total = 0;
|
|
18
|
+
for (const c of chunks) {
|
|
19
|
+
if (/^[A-Za-z]+$/.test(c))
|
|
20
|
+
total += Math.ceil(c.length / 4.1);
|
|
21
|
+
else if (/^\d+$/.test(c))
|
|
22
|
+
total += Math.ceil(c.length / 2.5);
|
|
23
|
+
else if (/^\s+$/.test(c))
|
|
24
|
+
total += c.includes("\n") ? 1 : 0;
|
|
25
|
+
else
|
|
26
|
+
total += 1;
|
|
27
|
+
}
|
|
28
|
+
return total;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Count whitespace-separated words in a description (for the ≤25-word cap).
|
|
32
|
+
*
|
|
33
|
+
* @param description - Tool description prose.
|
|
34
|
+
* @returns Word count.
|
|
35
|
+
*/
|
|
36
|
+
export function countWords(description) {
|
|
37
|
+
const trimmed = description.trim();
|
|
38
|
+
if (!trimmed)
|
|
39
|
+
return 0;
|
|
40
|
+
return trimmed.split(/\s+/).length;
|
|
41
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@esneiderbravo/speclaw",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.5",
|
|
4
4
|
"publishConfig": {
|
|
5
5
|
"access": "public"
|
|
6
6
|
},
|
|
@@ -41,6 +41,7 @@
|
|
|
41
41
|
"lint": "eslint .",
|
|
42
42
|
"format": "prettier --write .",
|
|
43
43
|
"check": "prettier --check . && eslint .",
|
|
44
|
+
"budget:calibrate": "node scripts/budget-calibrate.mjs",
|
|
44
45
|
"pretest": "tsc -p tsconfig.test.json && node scripts/prep-test-assets.mjs",
|
|
45
46
|
"test": "node --test --experimental-test-coverage --test-coverage-lines=80 --test-coverage-functions=80 --test-coverage-branches=80 --test-coverage-exclude='dist-test/test/**' --test-coverage-exclude='dist/**' 'dist-test/test/**/*.test.js'",
|
|
46
47
|
"prepublishOnly": "npm run build"
|