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.
- package/README.md +44 -1
- package/dist/cli.mjs +158 -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
|
|
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.
|
|
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",
|