rulebookguard 0.1.0 → 0.2.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.
package/README.md CHANGED
@@ -36,9 +36,12 @@ testGlobs: # required: files scanned for @rule tags
36
36
  - 'src/**/*.test.ts'
37
37
  - 'tests/**/test_*.py'
38
38
  contextBudget: 30 # default: 30; lint warns when a context holds more rules
39
+ language: es # default: en; language of the text that rulebookguard generates
39
40
  ```
40
41
 
41
- `schema` and `testGlobs` are required (`testGlobs: []` is valid but finds no tags). Unknown fields are rejected. The JSON Schemas ship with the package under `rulebookguard/schemas/v1/`.
42
+ `schema` and `testGlobs` are required (`testGlobs: []` is valid but finds no tags). Unknown fields are rejected.
43
+
44
+ `language` (`en` or `es`) sets the language of the text rulebookguard writes for you, such as the template of `new`. Rule content is free text and is never checked for language, so rules can be written in any language whatever this setting says. `language` needs rulebookguard 0.1.1 or later; older versions reject it as an unknown field. The JSON Schemas ship with the package under `rulebookguard/schemas/v1/`.
42
45
 
43
46
  Inside a git work tree, files are listed with `git ls-files --cached --others --exclude-standard`, so anything in `.gitignore` (`dist/`, `coverage/`, caches) is never matched by anchors or test globs; files inside submodules are listed too. A submodule path that is a symlink, or that resolves outside the rulebook root, is skipped. A test file that is a symlink is scanned for `@rule` tags only when its target lies inside the rulebook root. If one submodule cannot be listed, commands print `warning: could not list files of submodule "<path>" (<reason>); skipping it.` and keep the rest of the git listing. Outside git, or when the rulebook root itself is git-ignored, the whole tree is walked, skipping only `.git` and `node_modules`. If git fails (for example, git is missing from `PATH` or the repository has dubious ownership), commands print one warning on stderr and walk the tree instead.
44
47
 
@@ -224,7 +227,7 @@ Exits 1 for an unknown id.
224
227
 
225
228
  ### `new <context>`
226
229
 
227
- Scaffolds the next free id of a context as a `proposed` rule and prints its path. Ids are never reused, and an existing file is never overwritten.
230
+ Scaffolds the next free id of a context as a `proposed` rule and prints its path. Ids are never reused, and an existing file is never overwritten. The placeholder text of the new rule follows `language` in the config.
228
231
 
229
232
  ```sh
230
233
  $ rulebookguard new clients
@@ -233,6 +236,30 @@ docs/rules/clients/R-CLI-004.md
233
236
 
234
237
  Exits 1 for an unknown context or when the context has no free id left.
235
238
 
239
+ ### `hook`
240
+
241
+ A Claude Code hook for `PreToolUse` and `PostToolUse` events of tools that take a `file_path` (the Claude Code plugin below wires it up). It reads the hook event as JSON on stdin, takes `tool_input.file_path` (relative paths resolve against the event's `cwd`), finds `rulebookguard.config.yaml` by walking up from that file, and prints the active rules anchored to it as additional context for Claude, under the same `hookEventName`:
242
+
243
+ ```sh
244
+ $ echo '{"hook_event_name":"PostToolUse","tool_name":"Read","tool_input":{"file_path":"/repo/src/billing/invoice.go"}}' | rulebookguard hook
245
+ {"hookSpecificOutput":{"hookEventName":"PostToolUse","additionalContext":"Business rules anchored to src/billing/invoice.go (rulebookguard). …"}}
246
+ ```
247
+
248
+ Claude Code adds the context next to the tool result, so a `PostToolUse` hook on `Read` delivers the rules together with the file contents, before Claude writes a change (Claude Code requires reading a file before editing it).
249
+
250
+ Each rule comes with its id, context, summary, rule file and full text; when the texts add up to more than 10,000 characters only the summaries are listed, with a pointer to `rulebookguard show <id>`. The header follows `language` in the config. The hook never blocks a tool and never answers the permission prompt: with no config, a path outside the rulebook root, no active rule, another event, unreadable input or any error, it prints nothing and exits 0.
251
+
252
+ ### `mcp [--project <dir>]`
253
+
254
+ An [MCP](https://modelcontextprotocol.io) server over stdio for AI agents, with two read-only tools:
255
+
256
+ | Tool | Input | Answers |
257
+ | ------------ | ------------------------------------------------------ | --------------------------------------------- |
258
+ | `rules_for` | `path`: absolute, or relative to the project directory | what `rulebookguard for <path> --json` prints |
259
+ | `rules_show` | `id`: a rule id | what `rulebookguard show <id> --json` prints |
260
+
261
+ The rulebook is found from `--project` (default: the current directory). A command that would fail answers a tool error carrying the same message the CLI prints, for example `Unknown rule id "R-NOPE-001". Run "rulebookguard list" to list them.`
262
+
236
263
  ### `lint [--base <ref>] [--json]`
237
264
 
238
265
  Checks the whole catalog. Each finding is printed as `<severity> <code> <file>[:line] <message>`, followed by a count.
@@ -292,9 +319,28 @@ jobs:
292
319
 
293
320
  Pin the version, or add rulebookguard as a dev dependency and run it from the lockfile, so a new release never changes CI results by itself.
294
321
 
322
+ ## Claude Code plugin
323
+
324
+ The plugin in [`plugin/`](plugin) connects the rulebook to Claude Code:
325
+
326
+ - **Hooks:** after every `Read` and before every `Write`, it runs `rulebookguard hook`, so Claude gets the active rules anchored to a file together with its contents, before it writes a change. A `Write` of a new file, which needs no `Read`, gets them right after the file is created.
327
+ - **MCP server:** the `rules_for` and `rules_show` tools from `rulebookguard mcp`, for rules of files Claude has not opened yet.
328
+ - **Skill** `rulebookguard:rules`: the protocol, that is, follow the rules, and change a rule (then its tests, then the code) only when the user asks for the behavior change.
329
+
330
+ Install it from this repository's marketplace:
331
+
332
+ ```sh
333
+ claude plugin marketplace add JuanCJR/rulebookguard
334
+ claude plugin install rulebookguard@rulebookguard
335
+ ```
336
+
337
+ Or, inside Claude Code 2.1.275 or later: `/plugin install rulebookguard --marketplace JuanCJR/rulebookguard`.
338
+
339
+ The plugin runs the project's own rulebookguard (`node_modules/rulebookguard`, in the project directory or a parent) when its version is at least the plugin's; otherwise it runs `npx rulebookguard@<plugin version>`, which needs Node.js and network access the first time. The hooks never block a tool: any failure, including a timeout, lets it go ahead without rules.
340
+
295
341
  ## Releasing
296
342
 
297
- Releases are published to npm from GitHub Actions with [trusted publishing](https://docs.npmjs.com/trusted-publishers), so no npm token is stored and every version carries a provenance attestation. To release, bump `version` in `package.json`, merge it to `main`, then push a matching tag:
343
+ Releases are published to npm from GitHub Actions with [trusted publishing](https://docs.npmjs.com/trusted-publishers), so no npm token is stored and every version carries a provenance attestation. To release, bump `version` in `package.json` and in `plugin/.claude-plugin/plugin.json` (a test keeps them equal), merge it to `main`, then push a matching tag:
298
344
 
299
345
  ```sh
300
346
  git tag v0.1.0
package/dist/cli.mjs CHANGED
@@ -1,8 +1,138 @@
1
1
  #!/usr/bin/env node
2
- import { M as FormatError, N as formatIssue, _ as expandGlobs, a as CliError, c as openCatalog, d as scanTestTags, h as rulesForPath, l as writeJson, o as findRule, r as lintCommand, s as gitFallbackWarning, u as writeLines, y as toRepoPath } from "./lint-V-Uw5ueZ.mjs";
2
+ import { M as FormatError, N as formatIssue, _ as expandGlobs, a as CliError, c as openCatalog, d as scanTestTags, f as findRulebookRoot, h as rulesForPath, l as writeJson, o as findRule, p as loadCatalog, r as lintCommand, s as gitFallbackWarning, u as writeLines, y as toRepoPath } from "./lint-DtD_Ekei.mjs";
3
3
  import { readdir, writeFile } from "node:fs/promises";
4
- import { isAbsolute, join, relative, resolve } from "node:path";
4
+ import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
5
5
  import { parseArgs } from "node:util";
6
+ import { StdioServerTransport } from "@modelcontextprotocol/server/stdio";
7
+ import { McpServer } from "@modelcontextprotocol/server";
8
+ import * as z from "zod/v4";
9
+ // src/commands/hook.ts
10
+ const HOOK_BODY_LIMIT = 1e4;
11
+ const TEXT = {
12
+ en: {
13
+ header: (path) => `Business rules anchored to ${path} (rulebookguard). They are the source of truth: keep any change to this file consistent with them, and change the rule first if a change alters its behavior.`,
14
+ ruleFile: "Rule file",
15
+ omitted: `Rule texts omitted (over ${HOOK_BODY_LIMIT} characters); run \`rulebookguard show <id>\` for the full text.`
16
+ },
17
+ es: {
18
+ header: (path) => `Reglas de negocio ancladas a ${path} (rulebookguard). Son la fuente de verdad: mantén cualquier cambio a este archivo coherente con ellas y, si un cambio altera su comportamiento, cambia primero la regla.`,
19
+ ruleFile: "Archivo de la regla",
20
+ omitted: `Se omitió el texto de las reglas (más de ${HOOK_BODY_LIMIT} caracteres); ejecuta \`rulebookguard show <id>\` para verlo completo.`
21
+ }
22
+ };
23
+ async function hookCommand(io) {
24
+ try {
25
+ const event = parseEvent(await (io.stdin?.() ?? Promise.resolve("")));
26
+ const context = event === void 0 ? void 0 : await hookContext(event, io.cwd);
27
+ if (event !== void 0 && context !== void 0) io.stdout(`${JSON.stringify({ hookSpecificOutput: {
28
+ hookEventName: event.name,
29
+ additionalContext: context
30
+ } })}\n`);
31
+ } catch {}
32
+ }
33
+ const TOOL_EVENTS = ["PreToolUse", "PostToolUse"];
34
+ async function hookContext(event, cwd) {
35
+ const file = resolve(event.cwd ?? cwd, event.filePath);
36
+ const root = await findRulebookRoot(dirname(file));
37
+ if (root === void 0) return;
38
+ const path = relative(root, file);
39
+ if (path === "" || path === ".." || path.startsWith(`..${sep}`) || isAbsolute(path)) return;
40
+ const catalog = await loadCatalog(root);
41
+ const repoPath = path.split(sep).join("/");
42
+ const rules = rulesForPath(catalog.rules, repoPath).filter((rule) => rule.status === "active");
43
+ if (rules.length === 0) return;
44
+ return render(repoPath, rules, TEXT[catalog.config.language ?? "en"]);
45
+ }
46
+ function parseEvent(input) {
47
+ let event;
48
+ try {
49
+ event = JSON.parse(input);
50
+ } catch {
51
+ return;
52
+ }
53
+ if (typeof event !== "object" || event === null) return;
54
+ const { hook_event_name: name, tool_input: toolInput, cwd } = event;
55
+ const filePath = toolInput?.file_path;
56
+ const toolEvent = TOOL_EVENTS.find((candidate) => candidate === name);
57
+ if (toolEvent === void 0 || typeof filePath !== "string" || filePath === "") return;
58
+ return typeof cwd === "string" ? {
59
+ name: toolEvent,
60
+ filePath,
61
+ cwd
62
+ } : {
63
+ name: toolEvent,
64
+ filePath
65
+ };
66
+ }
67
+ function render(path, rules, text) {
68
+ const withBodies = rules.reduce((total, rule) => total + rule.body.trim().length, 0) <= HOOK_BODY_LIMIT;
69
+ const sections = rules.map((rule) => {
70
+ const lines = [`## ${rule.id} (${rule.context}): ${rule.summary}`, `${text.ruleFile}: ${rule.file}`];
71
+ return withBodies ? [
72
+ ...lines,
73
+ "",
74
+ rule.body.trim()
75
+ ].join("\n") : lines.join("\n");
76
+ });
77
+ const parts = [text.header(path), ...sections];
78
+ if (!withBodies) parts.push(text.omitted);
79
+ return parts.join("\n\n");
80
+ }
81
+ // package.json
82
+ var version = "0.2.0";
83
+ // src/mcp/server.ts
84
+ function createRulesServer(projectDir) {
85
+ const server = new McpServer({
86
+ name: "rulebookguard",
87
+ version
88
+ });
89
+ server.registerTool("rules_for", {
90
+ description: "Active business rules anchored to a file (id, context, summary, rule file). Call it before changing a file; the rules are the source of truth for its behavior.",
91
+ inputSchema: z.object({ path: z.string().describe("File path, absolute or relative to the project directory") }),
92
+ annotations: { readOnlyHint: true }
93
+ }, ({ path }) => cli(projectDir, [
94
+ "for",
95
+ path,
96
+ "--json"
97
+ ]));
98
+ server.registerTool("rules_show", {
99
+ description: "Full text of one business rule: summary, anchors, entities, supersedes and the rule body with its examples.",
100
+ inputSchema: z.object({ id: z.string().describe("Rule id, for example R-CLI-001") }),
101
+ annotations: { readOnlyHint: true }
102
+ }, ({ id }) => cli(projectDir, [
103
+ "show",
104
+ id,
105
+ "--json"
106
+ ]));
107
+ return server;
108
+ }
109
+ async function cli(cwd, args) {
110
+ let stdout = "";
111
+ let stderr = "";
112
+ if (await runCli(args, {
113
+ cwd,
114
+ stdout: (text) => {
115
+ stdout += text;
116
+ },
117
+ stderr: (text) => {
118
+ stderr += text;
119
+ }
120
+ }) === 0) return { content: [{
121
+ type: "text",
122
+ text: stdout
123
+ }] };
124
+ return {
125
+ content: [{
126
+ type: "text",
127
+ text: stderr.trim()
128
+ }],
129
+ isError: true
130
+ };
131
+ }
132
+ // src/commands/mcp.ts
133
+ async function mcpCommand(io, project) {
134
+ await createRulesServer(resolve(io.cwd, project ?? ".")).connect(new StdioServerTransport());
135
+ }
6
136
  // src/commands/new.ts
7
137
  const MAX_NUMBER = 999;
8
138
  async function newCommand(catalog, io, name) {
@@ -17,32 +147,51 @@ async function newCommand(catalog, io, name) {
17
147
  const id = `R-${context.prefix}-${String(last + 1).padStart(3, "0")}`;
18
148
  const file = `${directory}/${id}.md`;
19
149
  try {
20
- await writeFile(join(catalog.root, file), template(id, context.context), { flag: "wx" });
150
+ await writeFile(join(catalog.root, file), template(id, context.context, catalog.config.language ?? "en"), { flag: "wx" });
21
151
  } catch (error) {
22
152
  if (error.code === "EEXIST") throw new CliError(`${file} already exists; nothing was written.`);
23
153
  throw error;
24
154
  }
25
155
  writeLines(io, [file]);
26
156
  }
27
- function template(id, context) {
157
+ const TEMPLATE_TEXT = {
158
+ en: {
159
+ summary: "TODO: one-line summary of the rule.",
160
+ title: "TODO: rule title",
161
+ intro: "TODO: state the rule in full, then add examples as Gherkin scenarios or tables.",
162
+ scenario: `Scenario: TODO
163
+ Given TODO
164
+ When TODO
165
+ Then TODO`
166
+ },
167
+ es: {
168
+ summary: "TODO: resumen de la regla en una línea.",
169
+ title: "TODO: título de la regla",
170
+ intro: "TODO: describe la regla completa y agrega ejemplos como escenarios Gherkin o tablas.",
171
+ scenario: `# language: es
172
+ Escenario: TODO
173
+ Dado TODO
174
+ Cuando TODO
175
+ Entonces TODO`
176
+ }
177
+ };
178
+ function template(id, context, language) {
179
+ const text = TEMPLATE_TEXT[language];
28
180
  return `---
29
181
  id: ${id}
30
182
  context: ${context}
31
183
  entities: []
32
184
  status: proposed
33
185
  anchors: []
34
- summary: 'TODO: one-line summary of the rule.'
186
+ summary: '${text.summary}'
35
187
  ---
36
188
 
37
- # TODO: rule title
189
+ # ${text.title}
38
190
 
39
- TODO: state the rule in full, then add examples as Gherkin scenarios or tables.
191
+ ${text.intro}
40
192
 
41
193
  \`\`\`gherkin
42
- Scenario: TODO
43
- Given TODO
44
- When TODO
45
- Then TODO
194
+ ${text.scenario}
46
195
  \`\`\`
47
196
  `;
48
197
  }
@@ -175,6 +324,10 @@ Commands:
175
324
  new <context> scaffold the next free rule id as a proposed rule
176
325
  lint [--base <ref>] [--json] check the catalog, anchors and test tags;
177
326
  --base: rules changed since <ref> need a changed test
327
+ hook Claude Code PreToolUse hook: reads the event on stdin
328
+ and prints the rules anchored to the edited file
329
+ mcp [--project <dir>] MCP server over stdio with rules_for and rules_show;
330
+ --project: directory to find the rulebook from (default: cwd)
178
331
 
179
332
  Exit codes: 0 success, 1 failure (lint: any error), 2 usage error.
180
333
  `;
@@ -212,6 +365,14 @@ const COMMANDS = {
212
365
  argument: "context",
213
366
  options: {}
214
367
  },
368
+ hook: {
369
+ usage: "rulebookguard hook",
370
+ options: {}
371
+ },
372
+ mcp: {
373
+ usage: "rulebookguard mcp [--project <dir>]",
374
+ options: { project: { type: "string" } }
375
+ },
215
376
  lint: {
216
377
  usage: "rulebookguard lint [--base <ref>] [--json]",
217
378
  options: {
@@ -245,6 +406,14 @@ async function dispatch(argv, io) {
245
406
  if (spec === void 0) throw new CliError(`Unknown command "${name}".\n${USAGE.trimEnd()}`, 2);
246
407
  const { values, argument } = parse(spec, rest);
247
408
  const json = values.json === true;
409
+ if (name === "hook") {
410
+ await hookCommand(io);
411
+ return 0;
412
+ }
413
+ if (name === "mcp") {
414
+ await mcpCommand(io, values.project);
415
+ return 0;
416
+ }
248
417
  if (name === "lint") return lintCommand(io, {
249
418
  base: values.base,
250
419
  json
@@ -298,6 +467,11 @@ function parse(spec, args) {
298
467
  process.exitCode = await runCli(process.argv.slice(2), {
299
468
  cwd: process.cwd(),
300
469
  stdout: (text) => process.stdout.write(text),
301
- stderr: (text) => process.stderr.write(text)
470
+ stderr: (text) => process.stderr.write(text),
471
+ stdin: async () => {
472
+ let text = "";
473
+ for await (const chunk of process.stdin) text += String(chunk);
474
+ return text;
475
+ }
302
476
  });
303
477
  export {};
package/dist/index.d.mts CHANGED
@@ -1,9 +1,12 @@
1
1
  type RuleStatus = "proposed" | "active" | "deprecated";
2
+ type RuleLanguage = "en" | "es";
2
3
  interface RulebookConfig {
3
4
  schema: 1;
4
5
  rulesDir: string;
5
6
  testGlobs: string[];
6
7
  contextBudget: number;
8
+ /** Language the generated rule text is written in; `en` when unset. */
9
+ language?: RuleLanguage;
7
10
  }
8
11
  interface ContextFrontmatter {
9
12
  context: string;
@@ -146,4 +149,4 @@ interface LintOptions {
146
149
  export declare function lintCatalog(catalog: Catalog, tags?: TagIndex, onGitFallback?: GitFallbackListener): Promise<LintFinding[]>;
147
150
  export declare function pendingImplementation(catalog: Catalog, tags: TagIndex, base: string, changed: string[]): LintFinding[];
148
151
  export declare function formatFinding(finding: LintFinding): string;
149
- export type { Catalog, CatalogIssue, CatalogIssueCode, ContextDoc, ContextFrontmatter, ContextsJson, ForJson, FormatIssue, Frontmatter, GitFallbackListener, ImpactJson, LintFinding, LintJson, LintOptions, LintSeverity, ListJson, RuleDoc, RuleFrontmatter, RuleStatus, RuleSummaryJson, RuleTag, RulebookConfig, ShowJson, TagIndex, TagLocation };
152
+ export type { Catalog, CatalogIssue, CatalogIssueCode, ContextDoc, ContextFrontmatter, ContextsJson, ForJson, FormatIssue, Frontmatter, GitFallbackListener, ImpactJson, LintFinding, LintJson, LintOptions, LintSeverity, ListJson, RuleDoc, RuleFrontmatter, RuleLanguage, RuleStatus, RuleSummaryJson, RuleTag, RulebookConfig, ShowJson, TagIndex, TagLocation };
package/dist/index.mjs CHANGED
@@ -1 +1 @@
1
- export { k as CONFIG_FILE_NAME, T as CONTEXT_FILE_NAME, M as FormatError, b as RULE_TAG_PATTERN, g as createGlobMatcher, _ as expandGlobs, x as findRuleTags, f as findRulebookRoot, t as formatFinding, N as formatIssue, S as isRuleId, n as lintCatalog, v as listRepoFiles, p as loadCatalog, A as loadConfig, E as loadContext, C as loadRule, j as parseConfig, D as parseContext, w as parseRule, i as pendingImplementation, m as relativeToRoot, h as rulesForPath, d as scanTestTags, O as splitFrontmatter, y as toRepoPath } from "./lint-V-Uw5ueZ.mjs";
1
+ export { k as CONFIG_FILE_NAME, T as CONTEXT_FILE_NAME, M as FormatError, b as RULE_TAG_PATTERN, g as createGlobMatcher, _ as expandGlobs, x as findRuleTags, f as findRulebookRoot, t as formatFinding, N as formatIssue, S as isRuleId, n as lintCatalog, v as listRepoFiles, p as loadCatalog, A as loadConfig, E as loadContext, C as loadRule, j as parseConfig, D as parseContext, w as parseRule, i as pendingImplementation, m as relativeToRoot, h as rulesForPath, d as scanTestTags, O as splitFrontmatter, y as toRepoPath } from "./lint-DtD_Ekei.mjs";
@@ -52,7 +52,8 @@ var config_schema_default = {
52
52
  "type": "integer",
53
53
  "minimum": 1,
54
54
  "default": 30
55
- }
55
+ },
56
+ "language": { "enum": ["en", "es"] }
56
57
  },
57
58
  required: ["schema", "testGlobs"],
58
59
  additionalProperties: false
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rulebookguard",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Keep business rules as the single source of truth, bound to code and tests.",
5
5
  "homepage": "https://github.com/JuanCJR/rulebookguard#readme",
6
6
  "bugs": {
@@ -28,11 +28,14 @@
28
28
  "./package.json": "./package.json"
29
29
  },
30
30
  "dependencies": {
31
+ "@modelcontextprotocol/server": "^2.3.1",
31
32
  "ajv": "^8.20.0",
32
33
  "picomatch": "^4.0.7",
33
- "yaml": "^2.9.1"
34
+ "yaml": "^2.9.1",
35
+ "zod": "^4.6.7"
34
36
  },
35
37
  "devDependencies": {
38
+ "@modelcontextprotocol/client": "2.3.1",
36
39
  "@types/node": "^20.19.43",
37
40
  "@types/picomatch": "^4.0.3",
38
41
  "typescript": "^7.0.2",
@@ -7,7 +7,8 @@
7
7
  "schema": { "const": 1 },
8
8
  "rulesDir": { "type": "string", "minLength": 1, "default": "docs/rules" },
9
9
  "testGlobs": { "type": "array", "items": { "type": "string", "minLength": 1 } },
10
- "contextBudget": { "type": "integer", "minimum": 1, "default": 30 }
10
+ "contextBudget": { "type": "integer", "minimum": 1, "default": 30 },
11
+ "language": { "enum": ["en", "es"] }
11
12
  },
12
13
  "required": ["schema", "testGlobs"],
13
14
  "additionalProperties": false