rulebookguard 0.1.1 → 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.
Files changed (3) hide show
  1. package/README.md +44 -1
  2. package/dist/cli.mjs +158 -3
  3. package/package.json +5 -2
package/README.md CHANGED
@@ -236,6 +236,30 @@ docs/rules/clients/R-CLI-004.md
236
236
 
237
237
  Exits 1 for an unknown context or when the context has no free id left.
238
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
+
239
263
  ### `lint [--base <ref>] [--json]`
240
264
 
241
265
  Checks the whole catalog. Each finding is printed as `<severity> <code> <file>[:line] <message>`, followed by a count.
@@ -295,9 +319,28 @@ jobs:
295
319
 
296
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.
297
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
+
298
341
  ## Releasing
299
342
 
300
- 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:
301
344
 
302
345
  ```sh
303
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-DtD_Ekei.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) {
@@ -194,6 +324,10 @@ Commands:
194
324
  new <context> scaffold the next free rule id as a proposed rule
195
325
  lint [--base <ref>] [--json] check the catalog, anchors and test tags;
196
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)
197
331
 
198
332
  Exit codes: 0 success, 1 failure (lint: any error), 2 usage error.
199
333
  `;
@@ -231,6 +365,14 @@ const COMMANDS = {
231
365
  argument: "context",
232
366
  options: {}
233
367
  },
368
+ hook: {
369
+ usage: "rulebookguard hook",
370
+ options: {}
371
+ },
372
+ mcp: {
373
+ usage: "rulebookguard mcp [--project <dir>]",
374
+ options: { project: { type: "string" } }
375
+ },
234
376
  lint: {
235
377
  usage: "rulebookguard lint [--base <ref>] [--json]",
236
378
  options: {
@@ -264,6 +406,14 @@ async function dispatch(argv, io) {
264
406
  if (spec === void 0) throw new CliError(`Unknown command "${name}".\n${USAGE.trimEnd()}`, 2);
265
407
  const { values, argument } = parse(spec, rest);
266
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
+ }
267
417
  if (name === "lint") return lintCommand(io, {
268
418
  base: values.base,
269
419
  json
@@ -317,6 +467,11 @@ function parse(spec, args) {
317
467
  process.exitCode = await runCli(process.argv.slice(2), {
318
468
  cwd: process.cwd(),
319
469
  stdout: (text) => process.stdout.write(text),
320
- 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
+ }
321
476
  });
322
477
  export {};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rulebookguard",
3
- "version": "0.1.1",
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",