@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.
Files changed (74) hide show
  1. package/README.md +88 -72
  2. package/dist/cli/commands/index-build.js +12 -3
  3. package/dist/cli/commands/lawbook.js +1 -0
  4. package/dist/cli/commands/laws.js +149 -8
  5. package/dist/cli/commands/owners.js +44 -0
  6. package/dist/cli/commands/query.js +52 -10
  7. package/dist/cli/commands/update.js +35 -5
  8. package/dist/cli/commands/verify.js +8 -0
  9. package/dist/cli/index.js +15 -4
  10. package/dist/modules/compass/budget.js +128 -0
  11. package/dist/modules/compass/db.js +290 -30
  12. package/dist/modules/compass/diff-context.js +134 -0
  13. package/dist/modules/compass/embed-input.js +28 -0
  14. package/dist/modules/compass/embedder.js +3 -1
  15. package/dist/modules/compass/explore-rich.js +134 -0
  16. package/dist/modules/compass/extract.js +86 -0
  17. package/dist/modules/compass/hybrid.js +318 -0
  18. package/dist/modules/compass/impact-summary.js +33 -0
  19. package/dist/modules/compass/indexer.js +204 -33
  20. package/dist/modules/compass/merkle.js +76 -0
  21. package/dist/modules/compass/pagerank.js +122 -0
  22. package/dist/modules/compass/rank.js +95 -0
  23. package/dist/modules/compass/register.js +169 -75
  24. package/dist/modules/foundation/check.js +4 -2
  25. package/dist/modules/foundation/compile-laws.js +212 -0
  26. package/dist/modules/foundation/context-budget.js +1 -14
  27. package/dist/modules/foundation/dialects/agentsmd.js +95 -0
  28. package/dist/modules/foundation/dialects/claude-cursor.js +45 -0
  29. package/dist/modules/foundation/dialects/coderabbit.js +27 -0
  30. package/dist/modules/foundation/dialects/copilot.js +35 -0
  31. package/dist/modules/foundation/dialects/index.js +5 -0
  32. package/dist/modules/foundation/dialects/types.js +58 -0
  33. package/dist/modules/foundation/doctor.js +266 -14
  34. package/dist/modules/foundation/import-rules.js +67 -0
  35. package/dist/modules/foundation/integrity.js +307 -0
  36. package/dist/modules/foundation/laws-parse.js +131 -0
  37. package/dist/modules/foundation/laws.js +5 -0
  38. package/dist/modules/foundation/lock.js +283 -0
  39. package/dist/modules/foundation/ownership.js +4 -0
  40. package/dist/modules/foundation/register-core.js +57 -88
  41. package/dist/modules/foundation/register.js +1 -21
  42. package/dist/modules/foundation/scaffold.js +25 -0
  43. package/dist/modules/foundation/scan.js +227 -0
  44. package/dist/modules/foundation/setup-tool.js +96 -0
  45. package/dist/modules/foundation/verify.js +9 -1
  46. package/dist/modules/lawbook/assets/commands/archive.md +1 -1
  47. package/dist/modules/lawbook/assets/commands/draft.md +1 -1
  48. package/dist/modules/lawbook/assets/commands/explore.md +1 -1
  49. package/dist/modules/lawbook/assets/commands/sync.md +2 -2
  50. package/dist/modules/lawbook/assets/skills/archive/SKILL.md +1 -1
  51. package/dist/modules/lawbook/assets/skills/archive/steps/03-validate-and-sync.md +3 -3
  52. package/dist/modules/lawbook/assets/skills/archive/steps/04-archive.md +1 -1
  53. package/dist/modules/lawbook/assets/skills/draft/steps/02-understand.md +1 -1
  54. package/dist/modules/lawbook/assets/skills/draft/steps/05-validate.md +1 -1
  55. package/dist/modules/lawbook/assets/skills/explore/steps/01-investigate.md +1 -1
  56. package/dist/modules/lawbook/assets/skills/quick/steps/02-implement.md +1 -1
  57. package/dist/modules/lawbook/assets/skills/sync/SKILL.md +1 -1
  58. package/dist/modules/lawbook/assets/skills/sync/steps/03-validate.md +1 -1
  59. package/dist/modules/lawbook/assets/skills/sync/steps/04-promote.md +1 -1
  60. package/dist/modules/lawbook/change-tool.js +90 -0
  61. package/dist/modules/lawbook/coverage.js +45 -6
  62. package/dist/modules/lawbook/ears.js +417 -0
  63. package/dist/modules/lawbook/engine.js +29 -0
  64. package/dist/modules/lawbook/register.js +96 -54
  65. package/dist/modules/lawbook/spec-items.js +4 -1
  66. package/dist/modules/team/owners.js +464 -0
  67. package/dist/modules/tools/register.js +4 -26
  68. package/dist/shared/deprecation.js +99 -0
  69. package/dist/shared/exposure.js +4 -19
  70. package/dist/shared/git.js +25 -0
  71. package/dist/shared/mcp.js +29 -3
  72. package/dist/shared/output-budget.js +68 -0
  73. package/dist/shared/tool-catalog.js +49 -0
  74. package/package.json +4 -3
@@ -1,6 +1,8 @@
1
- import { explore, search, recall, impact, trace } from "../../modules/compass/query.js";
1
+ import { explore, impact, trace } from "../../modules/compass/query.js";
2
+ import { hybridSearch } from "../../modules/compass/hybrid.js";
2
3
  import { affectedTests } from "../../modules/compass/affected.js";
3
4
  import { hotspots, coupling } from "../../modules/compass/hotspots.js";
5
+ import { diffContext, formatDiffContext } from "../../modules/compass/diff-context.js";
4
6
  import { list } from "../lib/args.js";
5
7
  import { ui } from "../lib/ui.js";
6
8
  /**
@@ -15,6 +17,9 @@ export async function runQuery(cmd, flags) {
15
17
  const cwd = process.cwd();
16
18
  const args = flags._;
17
19
  const asJson = Boolean(flags.json);
20
+ const focus = list(flags.focus);
21
+ const maxTokens = flags["max-tokens"] ? Number(flags["max-tokens"]) : undefined;
22
+ const explain = Boolean(flags.explain);
18
23
  try {
19
24
  switch (cmd) {
20
25
  case "explore": {
@@ -33,16 +38,34 @@ export async function runQuery(cmd, flags) {
33
38
  r.callers?.forEach((c) => ui.info(`${c.name} (${c.file}:${c.line})`));
34
39
  return;
35
40
  }
36
- case "search": {
37
- const hits = search(cwd, need(args[0], "search <query>"));
38
- ui.heading(`${hits.length} result(s)`);
39
- hits.forEach((h) => ui.info(`${h.name} (${h.kind}) ${h.file}:${h.line}`));
40
- return;
41
- }
41
+ case "search":
42
42
  case "recall": {
43
- const hits = await recall(cwd, need(args[0], 'recall "<query>"'));
44
- ui.heading(`${hits.length} result(s) by meaning`);
45
- hits.forEach((h) => ui.info(`${h.score.toFixed(3)} ${h.name} (${h.kind}) ${h.file}:${h.line}`));
43
+ const q = need(args[0], cmd === "recall" ? 'recall "<query>"' : "search <query>");
44
+ const mode = cmd === "recall" ? "concept" : "exact";
45
+ const result = await hybridSearch(cwd, q, {
46
+ mode,
47
+ focus: focus.length ? focus : undefined,
48
+ maxTokens,
49
+ });
50
+ if (asJson) {
51
+ console.log(JSON.stringify(result, null, 2));
52
+ return;
53
+ }
54
+ ui.heading(`${result.hits.length} hybrid hit(s) · route=${result.route} · tokens=${result.tokens}/${result.budget}`);
55
+ if (result.degraded.length)
56
+ ui.warn(`degraded: ${result.degraded.join(", ")}`);
57
+ if (result.focus.length)
58
+ ui.info(`focus: ${result.focus.join(", ")}`);
59
+ for (const h of result.hits) {
60
+ const sig = explain
61
+ ? ` [bm25=${h.signals.bm25Rank ?? "-"} knn=${h.signals.knnRank ?? "-"} name=${h.signals.nameRank ?? "-"} pr=${h.signals.pagerank.toFixed(4)} hops=${h.signals.hops} score=${h.signals.score.toFixed(4)}]`
62
+ : "";
63
+ ui.info(`${h.name} (${h.kind}) ${h.file}:${h.line}${sig}`);
64
+ }
65
+ if (explain && result.rendered) {
66
+ ui.heading("TreeContext");
67
+ console.log(result.rendered);
68
+ }
46
69
  return;
47
70
  }
48
71
  case "impact": {
@@ -161,6 +184,25 @@ export async function runQuery(cmd, flags) {
161
184
  result.warnings.forEach((w) => ui.warn(w));
162
185
  return;
163
186
  }
187
+ case "diff-context": {
188
+ const files = list(flags.file);
189
+ const rev = typeof flags.rev === "string" ? flags.rev : undefined;
190
+ if (files.length === 0 && !rev && !flags.worktree) {
191
+ need(undefined, "diff-context [--file <path>...] [--rev <ref>] [--worktree]");
192
+ }
193
+ const result = diffContext({
194
+ projectPath: cwd,
195
+ rev: flags.worktree ? "WORKTREE" : rev,
196
+ paths: files.length ? files : undefined,
197
+ mode: flags.full ? "full" : "brief",
198
+ });
199
+ if (asJson) {
200
+ console.log(JSON.stringify(result, null, 2));
201
+ return;
202
+ }
203
+ console.log(formatDiffContext(result));
204
+ return;
205
+ }
164
206
  case "trace": {
165
207
  const r = trace(cwd, need(args[0], "trace <from> <to>"), need(args[1], "trace <from> <to>"));
166
208
  ui.heading(`Trace ${r.from} → ${r.to}`);
@@ -136,11 +136,41 @@ const MIGRATIONS = [
136
136
  "- Preserve all project-specific wording; only apply these speclaw-authored changes.",
137
137
  },
138
138
  {
139
- version: "0.3.13",
140
- describe: "Bugfix specsdraft --bug, bugfix.md, lawbook_investigate",
141
- agentPrompt: "- Mention bug changes: `speclaw lawbook draft --bug`, `bugfix.md` (repro + regression + prevention), " +
142
- "and `lawbook_investigate` / the investigate skill for graph-backed RCA. Feature ceremony unchanged; " +
143
- "`changeType: bug` in `change.json`. Security-withheld mode is not in this release.\n" +
139
+ version: "0.4.0",
140
+ describe: "Tool surfaceeight canonical MCP tools, aliases deprecated",
141
+ agentPrompt: "- MCP exposes eight canonical tools: `compass_explore`, `compass_find`, `compass_diff_context`, " +
142
+ "`compass_index`, `lawbook_change`, `lawbook_investigate`, `speclaw_setup`, `speclaw_check`. " +
143
+ "Retired names (e.g. `compass_search`, `lawbook_validate`, `init_project`) are aliases with " +
144
+ "`[deprecated]` responses — prefer the canonical names. `scaffold`, `doctor`, `compass_visualize`, " +
145
+ "and `law_verify` are CLI-only. Minimal profile omits setup/check/investigate/index.\n" +
146
+ "- Preserve all project-specific wording; only apply these speclaw-authored changes.",
147
+ },
148
+ {
149
+ version: "0.4.1",
150
+ describe: "Rule integrity — speclaw.lock digests + injection scan",
151
+ agentPrompt: "- Mention committed `speclaw.lock` at the repo root (never under `.speclaw/`), " +
152
+ "`speclaw laws lock` / `accept` / `scan`, and that digest acceptance is interactive TTY only " +
153
+ "(never via MCP). `speclaw verify` folds integrity findings with deps/graph. " +
154
+ "Strict paths include AGENTS.md / CLAUDE.md / compiled rules; standards docs are advisory.\n" +
155
+ "- Preserve all project-specific wording; only apply these speclaw-authored changes.",
156
+ },
157
+ {
158
+ version: "0.4.2",
159
+ describe: "Spec ownership — team.owners → CODEOWNERS",
160
+ agentPrompt: '- Optional `team.owners` in `lawbook/config.yaml` maps capability names (and `"*"`) to ' +
161
+ "`@user` / `@org/team` / email owners. Run `speclaw owners --write` to compile a managed " +
162
+ "block at the **end** of `.github/CODEOWNERS` (GitHub: last match wins). `speclaw doctor` " +
163
+ "errors if content appears after the end marker. No new MCP tool — CLI only. " +
164
+ "deriveFromTraceability is not enabled in this release.\n" +
165
+ "- Preserve all project-specific wording; only apply these speclaw-authored changes.",
166
+ },
167
+ {
168
+ version: "1.0.0",
169
+ describe: "speclaw 1.0 — official release (enforcement + graph + lawbook + owners)",
170
+ agentPrompt: "- speclaw **1.0** is the official release: Foundation (hooks + `speclaw.lock` integrity), " +
171
+ "Compass (schema 10, eight canonical MCP tools), Lawbook (ceremony 0–3, coverage, drift, " +
172
+ "bugfix), and Team (`team.owners` → `speclaw owners --write`). Install remains " +
173
+ "`npx @esneiderbravo/speclaw@latest init`. CI consumers use `esneiderbravo/speclaw@v1`.\n" +
144
174
  "- Preserve all project-specific wording; only apply these speclaw-authored changes.",
145
175
  },
146
176
  ];
@@ -8,6 +8,7 @@ import { toMarkdown } from "../../modules/foundation/report-md.js";
8
8
  import { toSarif } from "../../modules/foundation/sarif.js";
9
9
  import { loadManifestForVerify } from "../../modules/foundation/laws.js";
10
10
  import { verifyLaws } from "../../modules/foundation/verify.js";
11
+ import { foldIntegrityIntoReport, verifyIntegrity } from "../../modules/foundation/integrity.js";
11
12
  import { driftFindingsForVerify } from "../../modules/lawbook/drift.js";
12
13
  const FORMATS = new Set(["text", "json", "sarif", "markdown"]);
13
14
  /**
@@ -46,6 +47,13 @@ export async function runVerify(flags) {
46
47
  engines: engines.length ? engines : undefined,
47
48
  lawIds: list(flags.law).length ? list(flags.law) : undefined,
48
49
  });
50
+ // Rule-file integrity (speclaw.lock digests + injection scan). Soft when no lock.
51
+ // Covers: req~integrity-verify~1
52
+ const integrity = verifyIntegrity({ projectPath: cwd });
53
+ foldIntegrityIntoReport(report, integrity);
54
+ if (integrity.guidance && format === "text" && flags.json !== true) {
55
+ ui.info(integrity.guidance);
56
+ }
49
57
  // Structural spec↔code drift (when anchors exist) contributes semantic/deleted
50
58
  // findings into the same report stream used by SARIF / exit codes.
51
59
  for (const f of driftFindingsForVerify(cwd)) {
package/dist/cli/index.js CHANGED
@@ -18,13 +18,14 @@ Setup
18
18
  agent add <id> Configure another agent later (symlinks + MCP)
19
19
 
20
20
  Compass (code intelligence — the same surface agents use via MCP)
21
- index (Re)build the local code graph, with progress
21
+ index (Re)build the local code graph (--force / --prune)
22
22
  watch Keep the index fresh on file changes
23
23
  explore <node> A node's source + callers/callees
24
- search <query> Find nodes by name/keyword
25
- recall "<query>" Find code by meaning (semantic)
24
+ search <query> Hybrid find (BM25+vector+name); --focus --max-tokens --explain
25
+ recall "<query>" Hybrid find with concept weights; same flags as search
26
26
  impact <node> Blast radius (grouped by module; --flat / --json)
27
27
  affected-tests Tests affected by a change (--file / --from-diff / --json)
28
+ diff-context Change context for a diff (--file / --rev / --worktree / --json)
28
29
  hotspots Rank files by recent churn × AST complexity (--json / --sort)
29
30
  coupling <file> Temporal co-change partners for a file (--json)
30
31
  trace <from> <to> A call path between two nodes
@@ -46,10 +47,16 @@ Other
46
47
  budget Measure always-on context cost (tools, skills, instructions)
47
48
  coverage Requirement → impl → test coverage (--json, --tap, --adopt, --write)
48
49
  drift Spec↔code drift (--json, --reseal, --reverse, --fail-on)
50
+ owners Compile team.owners → .github/CODEOWNERS (--write / --check / --diff)
49
51
  telemetry status Confirm speclaw ships no telemetry
50
52
  check Evaluate an action against the laws (hooks call this; --dry-run to preview)
51
53
  laws verify Verify the deterministic dependency/graph laws against the index
52
- verify Verify laws for CI: exit codes, --sarif, --json, --strict-engines
54
+ laws compile Compile laws into agent rule dialects (AGENTS / Claude / Cursor / …)
55
+ laws import Import third-party rules as draft laws (--from rulesync)
56
+ laws lock Create/refresh committed speclaw.lock digests for rule files
57
+ laws accept <path> Interactively accept a changed rule-file digest (TTY only)
58
+ laws scan Scan rule/skill files for prompt-injection patterns
59
+ verify Verify laws + integrity for CI: exit codes, --sarif, --json, --strict-engines
53
60
  mcp Start the MCP server (used by your agent's config)
54
61
  help Show this help
55
62
  --version Print the installed speclaw version
@@ -73,6 +80,7 @@ const HEADER_COMMANDS = new Set([
73
80
  "coverage",
74
81
  "drift",
75
82
  "telemetry",
83
+ "owners",
76
84
  "index",
77
85
  "watch",
78
86
  "lawbook",
@@ -144,6 +152,7 @@ async function dispatch(cmd, flags) {
144
152
  case "impact":
145
153
  case "trace":
146
154
  case "affected-tests":
155
+ case "diff-context":
147
156
  case "hotspots":
148
157
  case "coupling":
149
158
  return (await import("./commands/query.js")).runQuery(cmd, flags);
@@ -163,6 +172,8 @@ async function dispatch(cmd, flags) {
163
172
  return (await import("./commands/drift.js")).runDrift(flags);
164
173
  case "telemetry":
165
174
  return (await import("./commands/telemetry.js")).runTelemetry(flags);
175
+ case "owners":
176
+ return (await import("./commands/owners.js")).runOwners(flags);
166
177
  case "check":
167
178
  return (await import("./commands/check.js")).runCheck(flags);
168
179
  case "laws":
@@ -0,0 +1,128 @@
1
+ /**
2
+ * Fit ranked retrieval hits into a token budget via binary search, and render
3
+ * a compact TreeContext with elision markers.
4
+ */
5
+ /**
6
+ * Rough token estimate: ~4 chars/token, sampling every 100th line on large text.
7
+ *
8
+ * @param s - Text to estimate.
9
+ */
10
+ export function estimateTokens(s) {
11
+ if (s.length < 4000)
12
+ return Math.ceil(s.length / 4);
13
+ const lines = s.split("\n");
14
+ if (lines.length < 200)
15
+ return Math.ceil(s.length / 4);
16
+ let sampled = 0;
17
+ let count = 0;
18
+ for (let i = 0; i < lines.length; i += 100) {
19
+ sampled += lines[i].length + 1;
20
+ count++;
21
+ }
22
+ const avg = sampled / Math.max(count, 1);
23
+ return Math.ceil((avg * lines.length) / 4);
24
+ }
25
+ /**
26
+ * Render hits as a TreeContext block with `⋮` elision between non-adjacent lines.
27
+ *
28
+ * @param hits - Ordered hits to include.
29
+ */
30
+ export function renderTreeContext(hits) {
31
+ if (hits.length === 0)
32
+ return "";
33
+ const byFile = new Map();
34
+ for (const h of hits) {
35
+ const list = byFile.get(h.file) ?? [];
36
+ list.push(h);
37
+ byFile.set(h.file, list);
38
+ }
39
+ const parts = [];
40
+ for (const [file, list] of byFile) {
41
+ parts.push(`# ${file}`);
42
+ list.sort((a, b) => a.line - b.line);
43
+ let lastLine = -Infinity;
44
+ for (const h of list) {
45
+ if (h.line - lastLine > 3 && lastLine !== -Infinity)
46
+ parts.push("⋮");
47
+ const sig = h.signature ?? `${h.kind} ${h.name}`;
48
+ parts.push(`${h.line}| ${sig}`);
49
+ if (h.excerpt) {
50
+ const lines = h.excerpt.split("\n");
51
+ if (lines.length > 6) {
52
+ parts.push(...lines.slice(0, 3).map((l) => ` ${l}`));
53
+ parts.push(" ⋮");
54
+ parts.push(...lines.slice(-2).map((l) => ` ${l}`));
55
+ }
56
+ else {
57
+ parts.push(...lines.map((l) => ` ${l}`));
58
+ }
59
+ }
60
+ lastLine = h.line;
61
+ }
62
+ }
63
+ return parts.join("\n");
64
+ }
65
+ /**
66
+ * Binary-search how many leading hits fit `maxTokens` within 15% tolerance.
67
+ * Never returns empty when hits is non-empty — truncates the first hit instead.
68
+ *
69
+ * @param ranked - Hits in final rank order.
70
+ * @param maxTokens - Token budget.
71
+ */
72
+ export function fitToBudget(ranked, maxTokens) {
73
+ const budget = Math.max(1, maxTokens);
74
+ if (ranked.length === 0) {
75
+ return { rendered: "", tokens: 0, budget, hitCount: 0 };
76
+ }
77
+ let lower = 1;
78
+ let upper = ranked.length;
79
+ let best = renderTreeContext(ranked.slice(0, 1));
80
+ let bestCount = 1;
81
+ while (lower <= upper) {
82
+ const mid = (lower + upper) >> 1;
83
+ const tree = renderTreeContext(ranked.slice(0, mid));
84
+ const n = estimateTokens(tree);
85
+ if (Math.abs(n - budget) / budget < 0.15) {
86
+ return { rendered: tree, tokens: n, budget, hitCount: mid };
87
+ }
88
+ if (n <= budget) {
89
+ best = tree;
90
+ bestCount = mid;
91
+ lower = mid + 1;
92
+ }
93
+ else {
94
+ upper = mid - 1;
95
+ }
96
+ }
97
+ // Single oversized hit: truncate excerpt.
98
+ if (bestCount === 1 && estimateTokens(best) > budget) {
99
+ const h = { ...ranked[0], excerpt: truncateExcerpt(ranked[0].excerpt, budget) };
100
+ const rendered = renderTreeContext([h]);
101
+ return { rendered, tokens: estimateTokens(rendered), budget, hitCount: 1 };
102
+ }
103
+ return {
104
+ rendered: best,
105
+ tokens: estimateTokens(best),
106
+ budget,
107
+ hitCount: bestCount,
108
+ };
109
+ }
110
+ function truncateExcerpt(excerpt, budget) {
111
+ if (!excerpt)
112
+ return "";
113
+ const maxChars = Math.max(32, budget * 3);
114
+ if (excerpt.length <= maxChars)
115
+ return excerpt;
116
+ return `${excerpt.slice(0, maxChars)}\n⋮`;
117
+ }
118
+ /**
119
+ * Default token budget inspired by aider: clamp between 1024 and 4096, ×8 when
120
+ * there is no focus set.
121
+ *
122
+ * @param hasFocus - Whether a non-empty focus file set is in use.
123
+ * @param maxInput - Optional model input window hint.
124
+ */
125
+ export function defaultBudget(hasFocus, maxInput = 32_000) {
126
+ const base = Math.max(1024, Math.min(Math.floor(maxInput / 8), 4096));
127
+ return hasFocus ? base : base * 8;
128
+ }