@esneiderbravo/speclaw 0.4.0 → 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 (45) 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 +32 -10
  7. package/dist/cli/commands/update.js +28 -0
  8. package/dist/cli/commands/verify.js +8 -0
  9. package/dist/cli/index.js +13 -4
  10. package/dist/modules/compass/budget.js +128 -0
  11. package/dist/modules/compass/db.js +290 -30
  12. package/dist/modules/compass/embed-input.js +28 -0
  13. package/dist/modules/compass/embedder.js +3 -1
  14. package/dist/modules/compass/explore-rich.js +10 -5
  15. package/dist/modules/compass/extract.js +86 -0
  16. package/dist/modules/compass/hybrid.js +318 -0
  17. package/dist/modules/compass/indexer.js +204 -33
  18. package/dist/modules/compass/merkle.js +76 -0
  19. package/dist/modules/compass/pagerank.js +122 -0
  20. package/dist/modules/compass/rank.js +95 -0
  21. package/dist/modules/compass/register.js +8 -4
  22. package/dist/modules/foundation/check.js +4 -2
  23. package/dist/modules/foundation/compile-laws.js +212 -0
  24. package/dist/modules/foundation/dialects/agentsmd.js +95 -0
  25. package/dist/modules/foundation/dialects/claude-cursor.js +45 -0
  26. package/dist/modules/foundation/dialects/coderabbit.js +27 -0
  27. package/dist/modules/foundation/dialects/copilot.js +35 -0
  28. package/dist/modules/foundation/dialects/index.js +5 -0
  29. package/dist/modules/foundation/dialects/types.js +58 -0
  30. package/dist/modules/foundation/doctor.js +220 -14
  31. package/dist/modules/foundation/import-rules.js +67 -0
  32. package/dist/modules/foundation/integrity.js +307 -0
  33. package/dist/modules/foundation/laws-parse.js +131 -0
  34. package/dist/modules/foundation/laws.js +5 -0
  35. package/dist/modules/foundation/lock.js +283 -0
  36. package/dist/modules/foundation/ownership.js +4 -0
  37. package/dist/modules/foundation/scaffold.js +25 -0
  38. package/dist/modules/foundation/scan.js +227 -0
  39. package/dist/modules/foundation/verify.js +9 -1
  40. package/dist/modules/lawbook/coverage.js +45 -6
  41. package/dist/modules/lawbook/ears.js +417 -0
  42. package/dist/modules/lawbook/engine.js +29 -0
  43. package/dist/modules/lawbook/spec-items.js +4 -1
  44. package/dist/modules/team/owners.js +464 -0
  45. package/package.json +4 -3
@@ -37,12 +37,14 @@ export function registerCompass(server, opts = {}) {
37
37
  });
38
38
  return text(formatExploreRich(result, (mode ?? "brief")));
39
39
  });
40
- add("compass_find", "Find symbols by exact name or by concept. Use exact for identifiers, concept for meaning.", {
40
+ add("compass_find", "Hybrid search over the index: BM25, vectors, and name match. mode sets weights; pass focus for edited files.", {
41
41
  projectPath: z.string(),
42
42
  query: z.string(),
43
43
  mode: z.enum(["exact", "concept"]),
44
44
  limit: z.number().optional(),
45
- }, async ({ projectPath, query, mode, limit }) => text(await findSymbols(projectPath, query, mode, limit)));
45
+ focus: z.array(z.string()).optional(),
46
+ maxTokens: z.number().int().min(256).max(32_000).optional(),
47
+ }, async ({ projectPath, query, mode, limit, focus, maxTokens }) => text(JSON.stringify(await findSymbols(projectPath, query, mode, limit, { focus, maxTokens }), null, 2)));
46
48
  add("compass_diff_context", "Graph context of changes in one call: symbols, blast radius, tests, hotspots. Default: working tree.", {
47
49
  projectPath: z.string(),
48
50
  rev: z.string().optional(),
@@ -62,10 +64,12 @@ export function registerCompass(server, opts = {}) {
62
64
  add("compass_index", "Build or refresh the code graph index; optional watch action for live re-index.", {
63
65
  projectPath: z.string(),
64
66
  action: z.enum(["index", "start", "stop", "status"]).optional(),
65
- }, async ({ projectPath, action }) => {
67
+ force: z.boolean().optional(),
68
+ prune: z.boolean().optional(),
69
+ }, async ({ projectPath, action, force, prune }) => {
66
70
  const act = action ?? "index";
67
71
  if (act === "index")
68
- return text(await buildIndex(projectPath));
72
+ return text(await buildIndex(projectPath, { force, prune }));
69
73
  const result = act === "start"
70
74
  ? startWatch(projectPath)
71
75
  : act === "stop"
@@ -1,7 +1,7 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
3
  import { performance } from "node:perf_hooks";
4
- import { compileScope, hasBackend, manifestPath, matchCompiled, readLawManifest, } from "./laws.js";
4
+ import { compileScope, hasBackend, isActiveLaw, manifestPath, matchCompiled, readLawManifest, } from "./laws.js";
5
5
  const cache = new Map();
6
6
  /**
7
7
  * Load the project's compiled law index, using the in-process cache when the
@@ -24,7 +24,9 @@ function loadLaws(projectPath) {
24
24
  const manifest = readLawManifest(projectPath);
25
25
  if (!manifest)
26
26
  return null;
27
- const laws = manifest.laws.map((law) => ({ law, scope: compileScope(law.scope) }));
27
+ const laws = manifest.laws
28
+ .filter(isActiveLaw)
29
+ .map((law) => ({ law, scope: compileScope(law.scope) }));
28
30
  cache.set(projectPath, { mtimeMs, laws });
29
31
  return laws;
30
32
  }
@@ -0,0 +1,212 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { globError, isActiveLaw, readLawManifest, seedManifest, writeLawManifest, } from "./laws.js";
4
+ import { parseLawsFromStandards } from "./laws-parse.js";
5
+ import { agentsmdDialect, claudeRulesDialect, coderabbitDialect, copilotDialect, cursorMdcDialect, patchDelimited, } from "./dialects/index.js";
6
+ import { detectConfiguredAgents } from "../../shared/agents.js";
7
+ import { digestText, provenanceBlock, refreshLockfile } from "./lock.js";
8
+ const DIALECTS = [
9
+ claudeRulesDialect,
10
+ cursorMdcDialect,
11
+ copilotDialect,
12
+ coderabbitDialect,
13
+ agentsmdDialect,
14
+ ];
15
+ /**
16
+ * Merge seed + standards parse + on-disk manifest. Manifest wins on id conflict.
17
+ * Throws if standards declare duplicate ids.
18
+ */
19
+ export function mergeLawSources(projectPath) {
20
+ const parsed = parseLawsFromStandards(projectPath);
21
+ if (parsed.duplicates.size > 0) {
22
+ const detail = [...parsed.duplicates.entries()]
23
+ .map(([id, locs]) => `${id} at ${locs.join(" and ")}`)
24
+ .join("; ");
25
+ throw new Error(`duplicate law id(s) in docs/standards: ${detail}`);
26
+ }
27
+ const byId = new Map();
28
+ for (const law of seedManifest().laws)
29
+ byId.set(law.id, law);
30
+ for (const law of parsed.laws) {
31
+ if (!byId.has(law.id))
32
+ byId.set(law.id, law);
33
+ }
34
+ const disk = readLawManifest(projectPath);
35
+ if (disk) {
36
+ for (const law of disk.laws)
37
+ byId.set(law.id, law);
38
+ }
39
+ return [...byId.values()].sort((a, b) => a.id.localeCompare(b.id));
40
+ }
41
+ function validateScopes(laws) {
42
+ for (const law of laws) {
43
+ if (!isActiveLaw(law))
44
+ continue;
45
+ for (const p of law.scope) {
46
+ const err = globError(p);
47
+ if (err) {
48
+ throw new Error(`${law.id}: invalid scope ${JSON.stringify(p)} (${err}) — ${law.source.file}${law.source.line ? `:${law.source.line}` : ""}`);
49
+ }
50
+ }
51
+ }
52
+ }
53
+ function writeIfChanged(projectPath, abs, contents, report) {
54
+ fs.mkdirSync(path.dirname(abs), { recursive: true });
55
+ const rel = path.relative(projectPath, abs).split(path.sep).join("/");
56
+ if (fs.existsSync(abs)) {
57
+ const prev = fs.readFileSync(abs, "utf8");
58
+ if (prev === contents) {
59
+ report.unchanged.push(rel);
60
+ return;
61
+ }
62
+ }
63
+ fs.writeFileSync(abs, contents);
64
+ report.written.push(rel);
65
+ }
66
+ /** Append a data-only provenance block when missing; body digest stays stable. */
67
+ function withProvenance(contents, lawIds) {
68
+ const body = contents.replace(/<!-- speclaw:begin-provenance[\s\S]*?speclaw:end-provenance -->\n?/g, "");
69
+ const dig = digestText(body);
70
+ const base = body.endsWith("\n") ? body : body + "\n";
71
+ return base + provenanceBlock({ lawIds, digest: dig, source: "compile" });
72
+ }
73
+ function mergeCoderabbit(projectPath, abs, payloadJson, report) {
74
+ const payload = JSON.parse(payloadJson);
75
+ let text = fs.existsSync(abs)
76
+ ? fs.readFileSync(abs, "utf8")
77
+ : "reviews:\n path_instructions: []\n";
78
+ const start = "# speclaw:path_instructions:start";
79
+ const end = "# speclaw:path_instructions:end";
80
+ const blockLines = [start];
81
+ for (const e of payload.path_instructions) {
82
+ blockLines.push(` - path: ${JSON.stringify(e.path)}`);
83
+ blockLines.push(` instructions: ${JSON.stringify(e.instructions)}`);
84
+ }
85
+ blockLines.push(end);
86
+ const block = blockLines.join("\n");
87
+ const re = /# speclaw:path_instructions:start[\s\S]*?# speclaw:path_instructions:end/;
88
+ if (re.test(text))
89
+ text = text.replace(re, block);
90
+ else {
91
+ if (!/path_instructions\s*:/.test(text)) {
92
+ if (!/^reviews\s*:/m.test(text))
93
+ text = `reviews:\n path_instructions: []\n` + text;
94
+ else
95
+ text = text.replace(/^(reviews\s*:)/m, `$1\n path_instructions: []`);
96
+ }
97
+ text = text.trimEnd() + "\n" + block + "\n";
98
+ }
99
+ writeIfChanged(projectPath, abs, text.endsWith("\n") ? text : text + "\n", report);
100
+ }
101
+ function ensureClaudeRulesSymlink(projectPath, report) {
102
+ const link = path.join(projectPath, ".claude", "rules", "speclaw");
103
+ const target = path.join("..", "..", "ai-specs", "rules");
104
+ fs.mkdirSync(path.dirname(link), { recursive: true });
105
+ try {
106
+ if (fs.lstatSync(link).isSymbolicLink() || fs.existsSync(link)) {
107
+ report.unchanged.push(".claude/rules/speclaw");
108
+ return;
109
+ }
110
+ }
111
+ catch {
112
+ /* missing */
113
+ }
114
+ try {
115
+ fs.symlinkSync(target, link);
116
+ report.written.push(".claude/rules/speclaw");
117
+ }
118
+ catch (err) {
119
+ report.failed.push({ path: ".claude/rules/speclaw", error: err.message });
120
+ }
121
+ }
122
+ function applyArtifact(projectPath, art, report) {
123
+ const abs = path.join(projectPath, art.path);
124
+ try {
125
+ if (art.mode === "write") {
126
+ // Nested AGENTS: only write if parent has package.json
127
+ if (art.path.endsWith("/AGENTS.md") && art.path !== "AGENTS.md") {
128
+ const dir = path.dirname(abs);
129
+ if (!fs.existsSync(path.join(dir, "package.json")))
130
+ return;
131
+ }
132
+ const body = art.path.endsWith(".md") || art.path.endsWith(".mdc")
133
+ ? withProvenance(art.contents, art.lawIds)
134
+ : art.contents;
135
+ writeIfChanged(projectPath, abs, body, report);
136
+ return;
137
+ }
138
+ if (art.mode === "patch-delimited") {
139
+ const prev = fs.existsSync(abs) ? fs.readFileSync(abs, "utf8") : "";
140
+ const next = patchDelimited(prev, art.marker ?? "laws", art.contents);
141
+ writeIfChanged(projectPath, abs, next, report);
142
+ return;
143
+ }
144
+ if (art.mode === "merge-yaml-path-instructions") {
145
+ mergeCoderabbit(projectPath, abs, art.contents, report);
146
+ }
147
+ }
148
+ catch (err) {
149
+ report.failed.push({ path: art.path, error: err.message });
150
+ }
151
+ }
152
+ /**
153
+ * Compile project laws into agent dialects. Deterministic and idempotent.
154
+ */
155
+ export function compileLaws(opts) {
156
+ const projectPath = opts.projectPath;
157
+ const report = {
158
+ schemaVersion: 1,
159
+ written: [],
160
+ unchanged: [],
161
+ failed: [],
162
+ lawCount: 0,
163
+ draftCount: 0,
164
+ };
165
+ const laws = mergeLawSources(projectPath);
166
+ validateScopes(laws);
167
+ report.lawCount = laws.filter(isActiveLaw).length;
168
+ report.draftCount = laws.filter((l) => !isActiveLaw(l)).length;
169
+ if (opts.writeManifest !== false) {
170
+ const manifest = { version: 1, laws };
171
+ writeLawManifest(projectPath, manifest);
172
+ }
173
+ const agents = opts.agents ?? detectConfiguredAgents(projectPath);
174
+ const ctx = { projectPath, agents };
175
+ const artifacts = [];
176
+ for (const d of DIALECTS)
177
+ artifacts.push(...d.compile(laws, ctx));
178
+ // Filter nested AGENTS without package.json before write
179
+ for (const art of artifacts) {
180
+ if (art.path.endsWith("/AGENTS.md") && art.path !== "AGENTS.md") {
181
+ const dir = path.join(projectPath, path.dirname(art.path));
182
+ if (!fs.existsSync(path.join(dir, "package.json")))
183
+ continue;
184
+ }
185
+ applyArtifact(projectPath, art, report);
186
+ }
187
+ if (agents.includes("claude"))
188
+ ensureClaudeRulesSymlink(projectPath, report);
189
+ // Covers: req~lock-refresh-update~1
190
+ try {
191
+ refreshLockfile(projectPath);
192
+ }
193
+ catch {
194
+ // Lock refresh must not fail compile; `speclaw laws lock` surfaces errors.
195
+ }
196
+ return report;
197
+ }
198
+ /** Estimate always-on tokens (empty scope) with a bytes/3.6 heuristic. */
199
+ export function estimateAlwaysOnTokens(laws) {
200
+ const HEADER = 12;
201
+ const scored = laws
202
+ .filter((l) => isActiveLaw(l) && l.scope.length === 0)
203
+ .map((l) => ({
204
+ id: l.id,
205
+ tokens: Math.ceil(Buffer.byteLength(l.prose, "utf8") / 3.6) + HEADER,
206
+ }))
207
+ .sort((a, b) => b.tokens - a.tokens);
208
+ return {
209
+ total: scored.reduce((s, x) => s + x.tokens, 0),
210
+ top: scored.slice(0, 3),
211
+ };
212
+ }
@@ -0,0 +1,95 @@
1
+ import { commonPrefix, delimit, } from "./types.js";
2
+ const NESTED_THRESHOLD = 3;
3
+ function degradeSection(title, laws) {
4
+ const lines = [`## ${title}`, ""];
5
+ for (const law of laws) {
6
+ const globHint = law.scope.length > 0 ? `In files that match \`${law.scope.join("`, `")}\`, ` : "";
7
+ lines.push(`### ${law.title} (\`${law.id}\`)`);
8
+ lines.push("");
9
+ lines.push(`${globHint}${law.prose}`);
10
+ lines.push("");
11
+ }
12
+ return lines.join("\n");
13
+ }
14
+ /**
15
+ * AGENTS.md dialect: move scope into prose; optional nested AGENTS.md files.
16
+ * Emits patch-delimited content for the root file.
17
+ */
18
+ export const agentsmdDialect = {
19
+ id: "agentsmd",
20
+ compile(laws, ctx) {
21
+ const active = laws.filter((l) => (l.status ?? "active") !== "draft");
22
+ const global = active.filter((l) => l.scope.length === 0);
23
+ const scoped = active.filter((l) => l.scope.length > 0);
24
+ const byPrefix = new Map();
25
+ for (const law of scoped) {
26
+ const pref = commonPrefix(law.scope) || "_misc";
27
+ const list = byPrefix.get(pref) ?? [];
28
+ list.push(law);
29
+ byPrefix.set(pref, list);
30
+ }
31
+ const artifacts = [];
32
+ const rootParts = [
33
+ "## speclaw laws (generated)",
34
+ "",
35
+ "_Edit `docs/standards/*.md` or `.speclaw/laws-manifest.json`; do not edit this block._",
36
+ "",
37
+ ];
38
+ if (global.length)
39
+ rootParts.push(degradeSection("Global rules", global));
40
+ const nestedMention = [];
41
+ for (const [pref, group] of [...byPrefix.entries()].sort(([a], [b]) => a.localeCompare(b))) {
42
+ const pkg = pref !== "_misc" && pref.length > 0;
43
+ const nest = pkg &&
44
+ group.length >= NESTED_THRESHOLD &&
45
+ // Nested only when a package.json exists under that prefix.
46
+ true;
47
+ // Nesting is decided by the orchestrator (fs check); here we always
48
+ // put dense prefixes in a nested artifact path hint via mode write.
49
+ if (nest && pref !== "_misc") {
50
+ nestedMention.push(pref);
51
+ artifacts.push({
52
+ path: `${pref}/AGENTS.md`,
53
+ contents: delimit("laws", degradeSection(`Rules for \`${pref}\``, group) +
54
+ `\n_Generated by speclaw from law ids: ${group.map((l) => l.id).join(", ")}_\n`),
55
+ lawIds: group.map((l) => l.id),
56
+ mode: "write",
57
+ });
58
+ }
59
+ else {
60
+ const title = pref === "_misc" ? "Scoped rules" : `Rules for \`${pref}\``;
61
+ rootParts.push(degradeSection(title, group));
62
+ }
63
+ }
64
+ if (nestedMention.length) {
65
+ rootParts.push("## Nested AGENTS.md files", "");
66
+ for (const p of nestedMention)
67
+ rootParts.push(`- [\`${p}/AGENTS.md\`](${p}/AGENTS.md)`);
68
+ rootParts.push("");
69
+ }
70
+ void ctx;
71
+ artifacts.push({
72
+ path: "AGENTS.md",
73
+ contents: rootParts.join("\n"),
74
+ lawIds: active.map((l) => l.id),
75
+ mode: "patch-delimited",
76
+ marker: "laws",
77
+ });
78
+ artifacts.push({
79
+ path: "CLAUDE.md",
80
+ contents: [
81
+ "## speclaw (generated)",
82
+ "",
83
+ "Import shared agent context: `@AGENTS.md`",
84
+ "",
85
+ "Path-scoped laws live under `.claude/rules/` (symlink to `ai-specs/rules`).",
86
+ "Do not rely on Claude Code reading `AGENTS.md` without the import.",
87
+ "",
88
+ ].join("\n"),
89
+ lawIds: active.map((l) => l.id),
90
+ mode: "patch-delimited",
91
+ marker: "laws",
92
+ });
93
+ return artifacts;
94
+ },
95
+ };
@@ -0,0 +1,45 @@
1
+ import { frontmatter, lawSlug, } from "./types.js";
2
+ function body(law) {
3
+ return [`# ${law.title}`, "", `<!-- speclaw:law-id ${law.id} -->`, "", law.prose, ""].join("\n");
4
+ }
5
+ /** Claude Code `.claude/rules` via managed `ai-specs/rules/<slug>.md` + `paths:`. */
6
+ export const claudeRulesDialect = {
7
+ id: "claude-rules",
8
+ compile(laws, ctx) {
9
+ if (!ctx.agents.includes("claude"))
10
+ return [];
11
+ const active = laws.filter((l) => (l.status ?? "active") !== "draft");
12
+ return active.map((law) => {
13
+ const fm = law.scope.length === 0 ? frontmatter({}) : frontmatter({ paths: law.scope });
14
+ return {
15
+ path: `ai-specs/rules/${lawSlug(law.id)}.md`,
16
+ contents: fm + body(law),
17
+ lawIds: [law.id],
18
+ mode: "write",
19
+ };
20
+ });
21
+ },
22
+ };
23
+ /** Cursor rules as `.mdc` under `ai-specs/rules` (symlinked via `.cursor/rules`). */
24
+ export const cursorMdcDialect = {
25
+ id: "cursor-mdc",
26
+ compile(laws, ctx) {
27
+ if (!ctx.agents.includes("cursor"))
28
+ return [];
29
+ const active = laws.filter((l) => (l.status ?? "active") !== "draft");
30
+ return active.map((law) => {
31
+ const empty = law.scope.length === 0;
32
+ const fm = frontmatter({
33
+ description: law.title,
34
+ alwaysApply: empty,
35
+ ...(empty ? {} : { globs: law.scope }),
36
+ });
37
+ return {
38
+ path: `ai-specs/rules/${lawSlug(law.id)}.mdc`,
39
+ contents: fm + body(law),
40
+ lawIds: [law.id],
41
+ mode: "write",
42
+ };
43
+ });
44
+ },
45
+ };
@@ -0,0 +1,27 @@
1
+ /**
2
+ * CodeRabbit: emit a merge artifact describing path_instructions entries.
3
+ * The orchestrator merges into existing YAML without a full YAML parser —
4
+ * marker-based line surgery on `path_instructions:` list items.
5
+ */
6
+ export const coderabbitDialect = {
7
+ id: "coderabbit",
8
+ compile(laws, ctx) {
9
+ if (!ctx.agents.includes("coderabbit"))
10
+ return [];
11
+ const active = laws.filter((l) => (l.status ?? "active") !== "draft" && l.scope.length > 0);
12
+ if (active.length === 0)
13
+ return [];
14
+ const entries = active.map((law) => ({
15
+ path: law.scope[0] ?? "**",
16
+ instructions: `[speclaw:${law.id}] ${law.prose}`,
17
+ }));
18
+ return [
19
+ {
20
+ path: ".coderabbit.yaml",
21
+ contents: JSON.stringify({ path_instructions: entries }, null, 2) + "\n",
22
+ lawIds: active.map((l) => l.id),
23
+ mode: "merge-yaml-path-instructions",
24
+ },
25
+ ];
26
+ },
27
+ };
@@ -0,0 +1,35 @@
1
+ import { frontmatter, lawSlug, } from "./types.js";
2
+ /**
3
+ * Copilot path-scoped instructions. Scoped laws only — never also dump the
4
+ * same body into AGENTS (dual-read nondeterminism).
5
+ */
6
+ export const copilotDialect = {
7
+ id: "copilot-instructions",
8
+ compile(laws, ctx) {
9
+ // Emit when explicitly wanted: agent id "copilot" or always if .github exists —
10
+ // orchestrator passes agents; treat missing copilot as skip unless "agents" generic.
11
+ const want = ctx.agents.includes("copilot") ||
12
+ ctx.agents.includes("github-copilot") ||
13
+ ctx.agents.includes("agents");
14
+ if (!want)
15
+ return [];
16
+ const scoped = laws.filter((l) => (l.status ?? "active") !== "draft" && l.scope.length > 0);
17
+ return scoped.map((law) => {
18
+ const fm = frontmatter({ applyTo: law.scope.join(",") });
19
+ const body = [
20
+ `# ${law.title}`,
21
+ "",
22
+ `<!-- speclaw:law-id ${law.id} -->`,
23
+ "",
24
+ law.prose,
25
+ "",
26
+ ].join("\n");
27
+ return {
28
+ path: `.github/instructions/${lawSlug(law.id)}.instructions.md`,
29
+ contents: fm + body,
30
+ lawIds: [law.id],
31
+ mode: "write",
32
+ };
33
+ });
34
+ },
35
+ };
@@ -0,0 +1,5 @@
1
+ export { frontmatter, delimit, patchDelimited, lawSlug, commonPrefix } from "./types.js";
2
+ export { agentsmdDialect } from "./agentsmd.js";
3
+ export { claudeRulesDialect, cursorMdcDialect } from "./claude-cursor.js";
4
+ export { copilotDialect } from "./copilot.js";
5
+ export { coderabbitDialect } from "./coderabbit.js";
@@ -0,0 +1,58 @@
1
+ /** YAML-ish frontmatter for markdown rule files. */
2
+ export function frontmatter(fields) {
3
+ const lines = ["---"];
4
+ for (const [k, v] of Object.entries(fields)) {
5
+ if (Array.isArray(v)) {
6
+ lines.push(`${k}:`);
7
+ for (const item of v)
8
+ lines.push(` - ${JSON.stringify(item)}`);
9
+ }
10
+ else if (typeof v === "boolean") {
11
+ lines.push(`${k}: ${v}`);
12
+ }
13
+ else {
14
+ lines.push(`${k}: ${JSON.stringify(v)}`);
15
+ }
16
+ }
17
+ lines.push("---", "");
18
+ return lines.join("\n");
19
+ }
20
+ /** HTML comment markers for delimited patches in personalized files. */
21
+ export function delimit(marker, body) {
22
+ return `<!-- speclaw:${marker}:start -->\n${body.trim()}\n<!-- speclaw:${marker}:end -->\n`;
23
+ }
24
+ /**
25
+ * Replace or append a delimited speclaw block inside `existing` text.
26
+ * Preserves all content outside the markers.
27
+ */
28
+ export function patchDelimited(existing, marker, body) {
29
+ const block = delimit(marker, body);
30
+ const re = new RegExp(`<!--\\s*speclaw:${marker}:start\\s-->[\\s\\S]*?<!--\\s*speclaw:${marker}:end\\s-->\\n?`, "m");
31
+ if (re.test(existing))
32
+ return existing.replace(re, block);
33
+ const sep = existing.endsWith("\n") || existing.length === 0 ? "" : "\n";
34
+ return `${existing}${sep}\n${block}`;
35
+ }
36
+ /** Stable filename slug from a law id (`law~foo~1` → `law-foo-1`). */
37
+ export function lawSlug(id) {
38
+ return id.replace(/~/g, "-").replace(/[^a-zA-Z0-9._-]+/g, "-");
39
+ }
40
+ /** Longest common directory prefix of scope globs (best-effort). */
41
+ export function commonPrefix(scopes) {
42
+ const dirs = scopes
43
+ .map((s) => s
44
+ .replace(/\\/g, "/")
45
+ .replace(/\*\*.*$/, "")
46
+ .replace(/\/$/, ""))
47
+ .filter((s) => s.length > 0 && !s.includes("*"));
48
+ if (dirs.length === 0)
49
+ return "";
50
+ const parts = dirs[0].split("/");
51
+ let i = 0;
52
+ for (; i < parts.length; i++) {
53
+ const p = parts[i];
54
+ if (!dirs.every((d) => d.split("/")[i] === p))
55
+ break;
56
+ }
57
+ return parts.slice(0, i).join("/");
58
+ }