@esneiderbravo/speclaw 0.3.4 → 0.3.7

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.
Files changed (60) hide show
  1. package/README.md +66 -15
  2. package/dist/cli/commands/budget.js +36 -0
  3. package/dist/cli/commands/doctor.js +53 -11
  4. package/dist/cli/commands/init.js +3 -1
  5. package/dist/cli/commands/telemetry.js +16 -0
  6. package/dist/cli/commands/update.js +36 -3
  7. package/dist/cli/index.js +19 -4
  8. package/dist/modules/compass/indexer.js +10 -0
  9. package/dist/modules/compass/map.js +114 -0
  10. package/dist/modules/compass/register.js +30 -64
  11. package/dist/modules/foundation/assets/docs/compass.template.md +3 -0
  12. package/dist/modules/foundation/context-budget.js +58 -0
  13. package/dist/modules/foundation/doctor.js +626 -161
  14. package/dist/modules/foundation/graph.js +7 -4
  15. package/dist/modules/foundation/hooks.js +12 -0
  16. package/dist/modules/foundation/laws.js +1 -0
  17. package/dist/modules/foundation/register-core.js +108 -0
  18. package/dist/modules/foundation/register.js +22 -159
  19. package/dist/modules/foundation/scaffold.js +1 -1
  20. package/dist/modules/lawbook/assets/skills/archive/SKILL.md +1 -31
  21. package/dist/modules/lawbook/assets/skills/archive/steps/01-confirm-done.md +7 -0
  22. package/dist/modules/lawbook/assets/skills/archive/steps/02-reconcile.md +15 -0
  23. package/dist/modules/lawbook/assets/skills/archive/steps/03-validate-and-sync.md +7 -0
  24. package/dist/modules/lawbook/assets/skills/archive/steps/04-archive.md +9 -0
  25. package/dist/modules/lawbook/assets/skills/archive/steps/05-report.md +7 -0
  26. package/dist/modules/lawbook/assets/skills/build/SKILL.md +2 -100
  27. package/dist/modules/lawbook/assets/skills/build/steps/01-load-change.md +7 -0
  28. package/dist/modules/lawbook/assets/skills/build/steps/02-branch.md +6 -0
  29. package/dist/modules/lawbook/assets/skills/build/steps/03-implement.md +11 -0
  30. package/dist/modules/lawbook/assets/skills/build/steps/04-quality-gates.md +10 -0
  31. package/dist/modules/lawbook/assets/skills/build/steps/05-manual-verification.md +22 -0
  32. package/dist/modules/lawbook/assets/skills/build/steps/06-discipline-reports.md +44 -0
  33. package/dist/modules/lawbook/assets/skills/build/steps/07-hand-off.md +9 -0
  34. package/dist/modules/lawbook/assets/skills/draft/SKILL.md +3 -80
  35. package/dist/modules/lawbook/assets/skills/draft/steps/01-ensure-workspace.md +5 -0
  36. package/dist/modules/lawbook/assets/skills/draft/steps/02-understand.md +12 -0
  37. package/dist/modules/lawbook/assets/skills/draft/steps/03-name-capabilities.md +14 -0
  38. package/dist/modules/lawbook/assets/skills/draft/steps/04-write-artifacts.md +41 -0
  39. package/dist/modules/lawbook/assets/skills/draft/steps/05-validate.md +11 -0
  40. package/dist/modules/lawbook/assets/skills/draft/steps/06-hand-off.md +5 -0
  41. package/dist/modules/lawbook/assets/skills/explore/SKILL.md +6 -22
  42. package/dist/modules/lawbook/assets/skills/explore/steps/01-investigate.md +16 -0
  43. package/dist/modules/lawbook/assets/skills/explore/steps/02-summarize.md +7 -0
  44. package/dist/modules/lawbook/assets/skills/sync/SKILL.md +1 -30
  45. package/dist/modules/lawbook/assets/skills/sync/steps/01-confirm.md +5 -0
  46. package/dist/modules/lawbook/assets/skills/sync/steps/02-reconcile.md +16 -0
  47. package/dist/modules/lawbook/assets/skills/sync/steps/03-validate.md +6 -0
  48. package/dist/modules/lawbook/assets/skills/sync/steps/04-promote.md +10 -0
  49. package/dist/modules/lawbook/assets/skills/sync/steps/05-report.md +7 -0
  50. package/dist/modules/lawbook/register.js +17 -36
  51. package/dist/modules/tools/register.js +15 -15
  52. package/dist/server.js +13 -7
  53. package/dist/shared/budget.js +159 -0
  54. package/dist/shared/exposure.js +110 -0
  55. package/dist/shared/manifest.js +11 -2
  56. package/dist/shared/mcp.js +33 -0
  57. package/dist/shared/redact.js +90 -0
  58. package/dist/shared/schema-tokens.js +86 -0
  59. package/dist/shared/tokens.js +41 -0
  60. package/package.json +2 -1
@@ -0,0 +1,159 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { isMinimalMode, loadDeclaredBudget, packageRoot } from "./exposure.js";
4
+ import { toolDefinitionTokens } from "./schema-tokens.js";
5
+ import { estimateTokens } from "./tokens.js";
6
+ /**
7
+ * Measure always-on context cost across the four surfaces.
8
+ *
9
+ * @param opts - Paths and optional pre-collected tool definitions.
10
+ * @returns Structured measurement; `total` excludes path-scoped surface D.
11
+ */
12
+ export function measureBudget(opts = {}) {
13
+ const projectPath = opts.projectPath ?? process.cwd();
14
+ const packagePath = opts.packagePath ?? packageRoot();
15
+ const minimal = opts.minimal ?? isMinimalMode(projectPath);
16
+ const toolDefs = opts.tools ?? [];
17
+ const toolDetails = toolDefs.map((t) => ({
18
+ name: t.name,
19
+ tokens: toolDefinitionTokens(t),
20
+ }));
21
+ const tools = toolDetails.reduce((s, t) => s + t.tokens, 0);
22
+ const skillRoots = [
23
+ path.join(packagePath, "src/modules/lawbook/assets/skills"),
24
+ path.join(packagePath, "dist/modules/lawbook/assets/skills"),
25
+ path.join(projectPath, "ai-specs/skills"),
26
+ ];
27
+ const commandRoots = [
28
+ path.join(packagePath, "src/modules/lawbook/assets/commands"),
29
+ path.join(packagePath, "dist/modules/lawbook/assets/commands"),
30
+ path.join(projectPath, "ai-specs/commands"),
31
+ ];
32
+ const skillFiles = firstExistingFiles(skillRoots, (dir) => collectSkillBudgetFiles(dir));
33
+ const commandFiles = firstExistingFiles(commandRoots, (dir) => listFilesRecursive(dir).filter((f) => f.endsWith(".md")));
34
+ const scDetails = [...skillFiles, ...commandFiles].map((f) => ({
35
+ path: path.relative(packagePath, f) || path.relative(projectPath, f) || f,
36
+ tokens: estimateTokens(safeRead(f)),
37
+ }));
38
+ const skillsAndCommands = scDetails.reduce((s, x) => s + x.tokens, 0);
39
+ const alwaysOnPaths = ["CLAUDE.md", "AGENTS.md", "LAWS.md", "docs/compass.md"].map((rel) => path.join(projectPath, rel));
40
+ const alwaysOnDetails = alwaysOnPaths
41
+ .filter((p) => fs.existsSync(p))
42
+ .map((p) => ({
43
+ path: path.relative(projectPath, p),
44
+ tokens: estimateTokens(safeRead(p)),
45
+ }));
46
+ const alwaysOnInstructions = alwaysOnDetails.reduce((s, x) => s + x.tokens, 0);
47
+ const pathScopedDetails = collectPathScoped(projectPath);
48
+ const pathScoped = pathScopedDetails.reduce((s, x) => s + x.tokens, 0);
49
+ return {
50
+ tools,
51
+ skillsAndCommands,
52
+ alwaysOnInstructions,
53
+ pathScoped,
54
+ total: tools + skillsAndCommands + alwaysOnInstructions,
55
+ profile: minimal ? "minimal" : "full",
56
+ toolCount: toolDefs.length,
57
+ details: {
58
+ tools: toolDetails,
59
+ skillsAndCommands: scDetails,
60
+ alwaysOn: alwaysOnDetails,
61
+ pathScoped: pathScopedDetails,
62
+ },
63
+ };
64
+ }
65
+ /**
66
+ * Format a human-readable budget table.
67
+ *
68
+ * @param m - Measurement.
69
+ * @param declared - Optional declared ceilings for an ok/over column.
70
+ */
71
+ export function formatBudgetTable(m, declared) {
72
+ const d = declared ?? loadDeclaredBudget();
73
+ const row = (label, tokens, cap) => {
74
+ const capStr = cap === null ? "—".padStart(8) : String(cap).padStart(8);
75
+ const status = cap === null ? "" : tokens <= cap ? " ok" : " OVER";
76
+ return `${label.padEnd(40)} ${String(tokens).padStart(8)} ${capStr}${status}`;
77
+ };
78
+ const lines = [
79
+ "Superficie tokens presupuesto",
80
+ row(`A tools MCP (${m.toolCount}, ${m.profile})`, m.tools, d.surfaces.tools),
81
+ row("B skills + commands", m.skillsAndCommands, d.surfaces.skillsAndCommands),
82
+ row("C always-on instructions", m.alwaysOnInstructions, d.surfaces.alwaysOnInstructions),
83
+ row("D path-scoped rules", m.pathScoped, null),
84
+ `${"".padEnd(40)} ${"──────".padStart(8)} ${"──────".padStart(8)}`,
85
+ row("TOTAL always-on", m.total, m.profile === "minimal" ? d.minimal.total : d.total),
86
+ ];
87
+ return lines.join("\n");
88
+ }
89
+ function safeRead(file) {
90
+ try {
91
+ return fs.readFileSync(file, "utf8");
92
+ }
93
+ catch {
94
+ return "";
95
+ }
96
+ }
97
+ function listFilesRecursive(dir) {
98
+ if (!fs.existsSync(dir))
99
+ return [];
100
+ const out = [];
101
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
102
+ const full = path.join(dir, entry.name);
103
+ if (entry.isDirectory())
104
+ out.push(...listFilesRecursive(full));
105
+ else
106
+ out.push(full);
107
+ }
108
+ return out;
109
+ }
110
+ /** Skill budget: dispatcher SKILL.md + only that counts for B's "loaded at turn" description;
111
+ * for always-present skill index we count SKILL.md bodies (dispatchers) fully and step files
112
+ * are loaded on demand — still include step files in B per design (commands full; skills
113
+ * frontmatter+description each turn). For simplicity and honesty we count entire SKILL.md
114
+ * (dispatcher) and do NOT count steps/ toward always-on B (JIT). */
115
+ function collectSkillBudgetFiles(skillsRoot) {
116
+ if (!fs.existsSync(skillsRoot))
117
+ return [];
118
+ const files = [];
119
+ for (const entry of fs.readdirSync(skillsRoot, { withFileTypes: true })) {
120
+ if (!entry.isDirectory())
121
+ continue;
122
+ const skillMd = path.join(skillsRoot, entry.name, "SKILL.md");
123
+ if (fs.existsSync(skillMd))
124
+ files.push(skillMd);
125
+ }
126
+ return files;
127
+ }
128
+ function firstExistingFiles(roots, collect) {
129
+ for (const root of roots) {
130
+ if (!fs.existsSync(root))
131
+ continue;
132
+ const files = collect(root);
133
+ if (files.length)
134
+ return files;
135
+ }
136
+ return [];
137
+ }
138
+ function collectPathScoped(projectPath) {
139
+ const details = [];
140
+ const ruleDirs = [
141
+ path.join(projectPath, ".claude/rules"),
142
+ path.join(projectPath, "ai-specs/rules"),
143
+ ];
144
+ for (const dir of ruleDirs) {
145
+ if (!fs.existsSync(dir))
146
+ continue;
147
+ for (const f of listFilesRecursive(dir).filter((p) => p.endsWith(".md"))) {
148
+ const body = safeRead(f);
149
+ // Heuristic: frontmatter with paths: means path-scoped lazy load.
150
+ if (/^---[\s\S]*?^paths:\s*$/m.test(body) || /^---[\s\S]*?^paths:/m.test(body)) {
151
+ details.push({
152
+ path: path.relative(projectPath, f),
153
+ tokens: estimateTokens(body),
154
+ });
155
+ }
156
+ }
157
+ }
158
+ return details;
159
+ }
@@ -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
+ }
@@ -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
- fs.writeFileSync(p, JSON.stringify({ version, packs: merged, baselines: mergedBaselines }, null, 2) + "\n");
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
  }
@@ -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,90 @@
1
+ import os from "node:os";
2
+ import path from "node:path";
3
+ /**
4
+ * Replace every occurrence of `from` that sits on a path boundary (not as a
5
+ * substring of a longer path). Prevents `/home/runner` from mangling
6
+ * `/opt/home/runner/...` into `/opt~/...` on GitHub Actions.
7
+ */
8
+ function replacePathToken(text, from, to) {
9
+ if (!from || from.length < 2)
10
+ return text;
11
+ let out = text;
12
+ let idx = 0;
13
+ while ((idx = out.indexOf(from, idx)) !== -1) {
14
+ const before = idx === 0 ? "" : out[idx - 1];
15
+ const afterIdx = idx + from.length;
16
+ const after = afterIdx >= out.length ? "" : out[afterIdx];
17
+ // Preceding char must not continue a path segment (blocks /opt + /home/…).
18
+ const beforeOk = idx === 0 || !/[A-Za-z0-9._-]/.test(before);
19
+ // Following char must end the token or continue as a separator.
20
+ const afterOk = after === "" || after === "/" || after === "\\" || /[\s'",):]/.test(after);
21
+ if (beforeOk && afterOk) {
22
+ out = out.slice(0, idx) + to + out.slice(afterIdx);
23
+ idx += to.length;
24
+ }
25
+ else {
26
+ idx += 1;
27
+ }
28
+ }
29
+ return out;
30
+ }
31
+ /**
32
+ * Redact absolute paths and usernames so a doctor report is safe to paste into
33
+ * a public issue. Home becomes `~`, the project root becomes `<project>`, and
34
+ * OS usernames are scrubbed from path segments.
35
+ *
36
+ * @param text - Arbitrary detail / remedy text that may contain paths.
37
+ * @param projectPath - Absolute project root to replace with `<project>`.
38
+ * @returns Redacted text (POSIX and Windows separators handled).
39
+ */
40
+ export function redactText(text, projectPath) {
41
+ let out = text;
42
+ const home = os.homedir();
43
+ const user = os.userInfo().username;
44
+ const replacements = [];
45
+ const queueReplacement = (from, to) => {
46
+ if (from && from.length > 1)
47
+ replacements.push([from, to]);
48
+ };
49
+ queueReplacement(path.resolve(projectPath), "<project>");
50
+ queueReplacement(projectPath.replace(/\//g, "\\"), "<project>");
51
+ queueReplacement(home, "~");
52
+ queueReplacement(home.replace(/\//g, "\\"), "~");
53
+ queueReplacement(`C:\\Users\\${user}`, "~");
54
+ queueReplacement(`c:\\Users\\${user}`, "~");
55
+ replacements.sort((a, b) => b[0].length - a[0].length);
56
+ for (const [from, to] of replacements) {
57
+ out = replacePathToken(out, from, to);
58
+ if (from.includes("\\"))
59
+ out = replacePathToken(out, from.replace(/\\/g, "/"), to);
60
+ }
61
+ if (user && user.length > 1) {
62
+ const seg = new RegExp(`(^|[/\\\\])${escapeRegExp(user)}(?=[/\\\\]|$)`, "gi");
63
+ out = out.replace(seg, `$1<user>`);
64
+ }
65
+ return out;
66
+ }
67
+ /**
68
+ * Deep-redact every string field in a JSON-compatible value.
69
+ *
70
+ * @param value - Report fragment.
71
+ * @param projectPath - Project root for `<project>` substitution.
72
+ */
73
+ export function redactValue(value, projectPath) {
74
+ if (typeof value === "string")
75
+ return redactText(value, projectPath);
76
+ if (Array.isArray(value)) {
77
+ return value.map((v) => redactValue(v, projectPath));
78
+ }
79
+ if (value && typeof value === "object") {
80
+ const out = {};
81
+ for (const [k, v] of Object.entries(value)) {
82
+ out[k] = redactValue(v, projectPath);
83
+ }
84
+ return out;
85
+ }
86
+ return value;
87
+ }
88
+ function escapeRegExp(s) {
89
+ return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
90
+ }
@@ -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.4",
3
+ "version": "0.3.7",
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"