@jazpiper/rules-doctor 0.1.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 (4) hide show
  1. package/LICENSE +22 -0
  2. package/README.md +82 -0
  3. package/dist/index.js +517 -0
  4. package/package.json +30 -0
package/LICENSE ADDED
@@ -0,0 +1,22 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 jazpiper
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
22
+
package/README.md ADDED
@@ -0,0 +1,82 @@
1
+ # @jazpiper/rules-doctor
2
+
3
+ `rules-doctor` is a Node.js CLI (TypeScript) that keeps agent instruction files in sync from one source of truth: `.agentrules/rules.yaml`.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ npm install -D @jazpiper/rules-doctor
9
+ ```
10
+
11
+ Or run directly:
12
+
13
+ ```bash
14
+ npx @jazpiper/rules-doctor init
15
+ ```
16
+
17
+ ## Usage
18
+
19
+ ### 1) Initialize rules
20
+
21
+ ```bash
22
+ rules-doctor init
23
+ ```
24
+
25
+ Creates `.agentrules/rules.yaml` if it does not exist.
26
+
27
+ - If `package.json` has scripts for `lint`, `test`, or `build`, they are inferred as:
28
+ - `npm run lint`
29
+ - `npm run test`
30
+ - `npm run build`
31
+ - Missing scripts are created as TODO placeholders.
32
+
33
+ ### 2) Sync generated docs
34
+
35
+ ```bash
36
+ rules-doctor sync
37
+ rules-doctor sync --target claude
38
+ rules-doctor sync --target codex
39
+ ```
40
+
41
+ Generates/updates:
42
+ - `CLAUDE.md` (fully managed)
43
+ - `AGENTS.md` (only content inside markers is managed)
44
+
45
+ Managed marker block in `AGENTS.md`:
46
+
47
+ ```md
48
+ <!-- RULES_DOCTOR:BEGIN -->
49
+ ... managed content ...
50
+ <!-- RULES_DOCTOR:END -->
51
+ ```
52
+
53
+ If markers are missing, a new managed block is appended to the end of `AGENTS.md`.
54
+
55
+ ### 3) Analyze docs
56
+
57
+ ```bash
58
+ rules-doctor analyze
59
+ ```
60
+
61
+ Reads `CLAUDE.md` and `AGENTS.md`, then prints a concise report about:
62
+ - missing markers
63
+ - missing verify commands (`lint`/`test`/`build`)
64
+ - obvious contradictions (simple heuristics)
65
+
66
+ ## Development
67
+
68
+ ```bash
69
+ npm ci
70
+ npm run build
71
+ ```
72
+
73
+ ## License
74
+
75
+ MIT
76
+
77
+
78
+ ## CI
79
+
80
+ This repo includes a GitHub Actions workflow template at `docs/workflows/ci.yml`.
81
+
82
+ If you want CI, copy it to `.github/workflows/ci.yml` and push the change.
package/dist/index.js ADDED
@@ -0,0 +1,517 @@
1
+ #!/usr/bin/env node
2
+ const { existsSync, mkdirSync, readFileSync, writeFileSync } = require("node:fs");
3
+ const { resolve } = require("node:path");
4
+
5
+ const RULES_FILE = resolve(".agentrules/rules.yaml");
6
+ const CLAUDE_FILE = resolve("CLAUDE.md");
7
+ const AGENTS_FILE = resolve("AGENTS.md");
8
+ const MARKER_BEGIN = "<!-- RULES_DOCTOR:BEGIN -->";
9
+ const MARKER_END = "<!-- RULES_DOCTOR:END -->";
10
+
11
+ function usage() {
12
+ return [
13
+ "rules-doctor",
14
+ "",
15
+ "Usage:",
16
+ " rules-doctor init",
17
+ " rules-doctor sync [--target all|claude|codex]",
18
+ " rules-doctor analyze",
19
+ ].join("\n");
20
+ }
21
+
22
+ function readJsonFile(filePath) {
23
+ if (!existsSync(filePath)) {
24
+ return null;
25
+ }
26
+
27
+ try {
28
+ const raw = readFileSync(filePath, "utf8");
29
+ return JSON.parse(raw);
30
+ } catch {
31
+ return null;
32
+ }
33
+ }
34
+
35
+ function stripQuotes(value) {
36
+ const trimmed = value.trim();
37
+ if (
38
+ (trimmed.startsWith('"') && trimmed.endsWith('"')) ||
39
+ (trimmed.startsWith("'") && trimmed.endsWith("'"))
40
+ ) {
41
+ try {
42
+ return JSON.parse(trimmed.replace(/'/g, '"'));
43
+ } catch {
44
+ return trimmed.slice(1, -1);
45
+ }
46
+ }
47
+ return trimmed;
48
+ }
49
+
50
+ function parseScalar(value) {
51
+ const cleaned = stripQuotes(value);
52
+ if (/^-?\d+$/.test(cleaned)) {
53
+ return Number(cleaned);
54
+ }
55
+ return cleaned;
56
+ }
57
+
58
+ function parseRulesText(text) {
59
+ if (!text.trim()) {
60
+ return {};
61
+ }
62
+
63
+ try {
64
+ return JSON.parse(text);
65
+ } catch {
66
+ // YAML fallback for this project's expected shape.
67
+ }
68
+
69
+ const data = {};
70
+ const lines = text.replace(/\r\n/g, "\n").split("\n");
71
+ let section = null;
72
+ let nested = null;
73
+
74
+ for (const rawLine of lines) {
75
+ if (!rawLine.trim() || rawLine.trim().startsWith("#")) {
76
+ continue;
77
+ }
78
+
79
+ const indent = rawLine.match(/^ */)[0].length;
80
+ const line = rawLine.trim();
81
+
82
+ if (indent === 0) {
83
+ nested = null;
84
+ const top = line.match(/^([a-zA-Z0-9_-]+):(.*)$/);
85
+ if (!top) {
86
+ continue;
87
+ }
88
+
89
+ const key = top[1];
90
+ const value = top[2].trim();
91
+ if (!value) {
92
+ section = key;
93
+ if (key === "workflow" || key === "done") {
94
+ data[key] = [];
95
+ } else if (key === "commands" || key === "approvals") {
96
+ data[key] = {};
97
+ }
98
+ } else {
99
+ section = null;
100
+ data[key] = parseScalar(value);
101
+ }
102
+ continue;
103
+ }
104
+
105
+ if ((section === "workflow" || section === "done") && line.startsWith("- ")) {
106
+ data[section].push(parseScalar(line.slice(2)));
107
+ continue;
108
+ }
109
+
110
+ if (section === "commands") {
111
+ const pair = line.match(/^([a-zA-Z0-9_-]+):(.*)$/);
112
+ if (pair) {
113
+ data.commands[pair[1]] = parseScalar(pair[2].trim());
114
+ }
115
+ continue;
116
+ }
117
+
118
+ if (section === "approvals") {
119
+ if (line.startsWith("mode:")) {
120
+ data.approvals.mode = parseScalar(line.slice("mode:".length).trim());
121
+ continue;
122
+ }
123
+
124
+ if (line === "notes:") {
125
+ nested = "notes";
126
+ if (!Array.isArray(data.approvals.notes)) {
127
+ data.approvals.notes = [];
128
+ }
129
+ continue;
130
+ }
131
+
132
+ if (nested === "notes" && line.startsWith("- ")) {
133
+ data.approvals.notes.push(parseScalar(line.slice(2)));
134
+ }
135
+ }
136
+ }
137
+
138
+ return data;
139
+ }
140
+
141
+ function quoteYaml(value) {
142
+ return JSON.stringify(String(value));
143
+ }
144
+
145
+ function stringifyRules(rules) {
146
+ const lines = [
147
+ `version: ${Number.isFinite(rules.version) ? rules.version : 1}`,
148
+ `mission: ${quoteYaml(rules.mission)}`,
149
+ "workflow:",
150
+ ...rules.workflow.map((step) => ` - ${quoteYaml(step)}`),
151
+ "commands:",
152
+ ...Object.keys(rules.commands).map(
153
+ (name) => ` ${name}: ${quoteYaml(rules.commands[name])}`,
154
+ ),
155
+ "done:",
156
+ ...rules.done.map((item) => ` - ${quoteYaml(item)}`),
157
+ "approvals:",
158
+ ` mode: ${quoteYaml(rules.approvals.mode)}`,
159
+ " notes:",
160
+ ...rules.approvals.notes.map((note) => ` - ${quoteYaml(note)}`),
161
+ "",
162
+ ];
163
+
164
+ return lines.join("\n");
165
+ }
166
+
167
+ function inferCommandFromScripts(scripts, scriptName) {
168
+ if (scripts && typeof scripts[scriptName] === "string") {
169
+ return `npm run ${scriptName}`;
170
+ }
171
+ return `echo "TODO: define ${scriptName} command"`;
172
+ }
173
+
174
+ function createDefaultRules() {
175
+ const pkg = readJsonFile(resolve("package.json"));
176
+ const scripts = pkg && typeof pkg === "object" ? pkg.scripts : undefined;
177
+
178
+ return {
179
+ version: 1,
180
+ mission: "Ship safe changes quickly while keeping agent instructions consistent.",
181
+ workflow: [
182
+ "Read relevant files before editing.",
183
+ "Make the smallest correct change.",
184
+ "Run verification commands before finalizing.",
185
+ ],
186
+ commands: {
187
+ lint: inferCommandFromScripts(scripts, "lint"),
188
+ test: inferCommandFromScripts(scripts, "test"),
189
+ build: inferCommandFromScripts(scripts, "build"),
190
+ },
191
+ done: [
192
+ "Commands pass or blockers are documented.",
193
+ "Changed behavior is reflected in docs where needed.",
194
+ ],
195
+ approvals: {
196
+ mode: "ask-before-destructive",
197
+ notes: ["Ask before destructive actions or privileged operations."],
198
+ },
199
+ };
200
+ }
201
+
202
+ function normalizeRules(input) {
203
+ const source = input && typeof input === "object" ? input : {};
204
+ const commands = source.commands && typeof source.commands === "object" ? source.commands : {};
205
+ const approvals =
206
+ source.approvals && typeof source.approvals === "object" ? source.approvals : {};
207
+
208
+ const workflow = Array.isArray(source.workflow)
209
+ ? source.workflow.filter((item) => typeof item === "string")
210
+ : ["Define your workflow steps."];
211
+
212
+ const done = Array.isArray(source.done)
213
+ ? source.done.filter((item) => typeof item === "string")
214
+ : ["Define done criteria."];
215
+
216
+ const notes = Array.isArray(approvals.notes)
217
+ ? approvals.notes.filter((item) => typeof item === "string")
218
+ : [];
219
+
220
+ return {
221
+ version: typeof source.version === "number" ? source.version : 1,
222
+ mission:
223
+ typeof source.mission === "string" && source.mission.trim()
224
+ ? source.mission
225
+ : "Define your project mission.",
226
+ workflow,
227
+ commands: {
228
+ lint:
229
+ typeof commands.lint === "string"
230
+ ? commands.lint
231
+ : 'echo "TODO: define lint command"',
232
+ test:
233
+ typeof commands.test === "string"
234
+ ? commands.test
235
+ : 'echo "TODO: define test command"',
236
+ build:
237
+ typeof commands.build === "string"
238
+ ? commands.build
239
+ : 'echo "TODO: define build command"',
240
+ },
241
+ done,
242
+ approvals: {
243
+ mode:
244
+ typeof approvals.mode === "string" ? approvals.mode : "ask-before-destructive",
245
+ notes,
246
+ },
247
+ };
248
+ }
249
+
250
+ function readRulesOrThrow() {
251
+ if (!existsSync(RULES_FILE)) {
252
+ throw new Error(`Missing ${RULES_FILE}. Run "rules-doctor init" to create it first.`);
253
+ }
254
+
255
+ const raw = readFileSync(RULES_FILE, "utf8");
256
+ return normalizeRules(parseRulesText(raw));
257
+ }
258
+
259
+ function formatList(items) {
260
+ if (!Array.isArray(items) || items.length === 0) {
261
+ return "- (none)";
262
+ }
263
+ return items.map((item) => `- ${item}`).join("\n");
264
+ }
265
+
266
+ function formatCommands(commands) {
267
+ if (!commands || typeof commands !== "object") {
268
+ return "- (none)";
269
+ }
270
+
271
+ const preferredOrder = ["lint", "test", "build"];
272
+ const names = [
273
+ ...preferredOrder.filter((name) => Object.prototype.hasOwnProperty.call(commands, name)),
274
+ ...Object.keys(commands).filter((name) => !preferredOrder.includes(name)),
275
+ ];
276
+
277
+ if (names.length === 0) {
278
+ return "- (none)";
279
+ }
280
+
281
+ return names.map((name) => `- ${name}: \`${commands[name]}\``).join("\n");
282
+ }
283
+
284
+ function renderClaude(rules) {
285
+ return [
286
+ "# CLAUDE.md",
287
+ "",
288
+ "## Mission",
289
+ rules.mission,
290
+ "",
291
+ "## Workflow",
292
+ formatList(rules.workflow),
293
+ "",
294
+ "## Commands",
295
+ formatCommands(rules.commands),
296
+ "",
297
+ "## Done",
298
+ formatList(rules.done),
299
+ "",
300
+ "## Approvals",
301
+ `- Mode: \`${rules.approvals.mode}\``,
302
+ ...rules.approvals.notes.map((note) => `- ${note}`),
303
+ "",
304
+ ].join("\n");
305
+ }
306
+
307
+ function renderCodexManagedSection(rules) {
308
+ return [
309
+ "## rules-doctor Managed Rules",
310
+ "Generated from `.agentrules/rules.yaml`. Edit that file, then run `rules-doctor sync`.",
311
+ "",
312
+ "### Mission",
313
+ rules.mission,
314
+ "",
315
+ "### Operational Loop",
316
+ "1. Read relevant context and constraints before editing.",
317
+ "2. Select and run the smallest command that moves the task forward.",
318
+ "3. Apply focused changes.",
319
+ "4. Run verification commands and report exact outcomes.",
320
+ "",
321
+ "### Commands",
322
+ formatCommands(rules.commands),
323
+ "",
324
+ "### Failure Loop",
325
+ "1. Capture the exact failing command and error output.",
326
+ "2. Form one concrete hypothesis for the failure.",
327
+ "3. Apply one fix and rerun the same command.",
328
+ "4. Repeat until green or blocked, then report blocker and next action.",
329
+ "",
330
+ "### Done",
331
+ formatList(rules.done),
332
+ "",
333
+ "### Approvals",
334
+ `- Policy: \`${rules.approvals.mode}\``,
335
+ ...rules.approvals.notes.map((note) => `- ${note}`),
336
+ "",
337
+ ].join("\n");
338
+ }
339
+
340
+ function upsertManagedSection(existing, content) {
341
+ const start = existing.indexOf(MARKER_BEGIN);
342
+ const end = start >= 0 ? existing.indexOf(MARKER_END, start) : -1;
343
+
344
+ if (start >= 0 && end > start) {
345
+ const before = existing.slice(0, start + MARKER_BEGIN.length);
346
+ const after = existing.slice(end);
347
+ return `${before}\n${content.trim()}\n${after}`.replace(/\n{3,}/g, "\n\n");
348
+ }
349
+
350
+ const base = existing.trimEnd();
351
+ const prefix = base ? `${base}\n\n` : "";
352
+ return `${prefix}${MARKER_BEGIN}\n${content.trim()}\n${MARKER_END}\n`;
353
+ }
354
+
355
+ function initCommand() {
356
+ if (existsSync(RULES_FILE)) {
357
+ console.log(`rules.yaml already exists: ${RULES_FILE}`);
358
+ return;
359
+ }
360
+
361
+ mkdirSync(resolve(".agentrules"), { recursive: true });
362
+ writeFileSync(RULES_FILE, stringifyRules(createDefaultRules()), "utf8");
363
+ console.log(`Created ${RULES_FILE}`);
364
+ }
365
+
366
+ function syncCommand(target) {
367
+ const rules = readRulesOrThrow();
368
+
369
+ if (target === "all" || target === "claude") {
370
+ writeFileSync(CLAUDE_FILE, renderClaude(rules), "utf8");
371
+ console.log(`Updated ${CLAUDE_FILE}`);
372
+ }
373
+
374
+ if (target === "all" || target === "codex") {
375
+ const existing = existsSync(AGENTS_FILE) ? readFileSync(AGENTS_FILE, "utf8") : "";
376
+ const updated = upsertManagedSection(existing, renderCodexManagedSection(rules));
377
+ writeFileSync(AGENTS_FILE, updated, "utf8");
378
+ console.log(`Updated ${AGENTS_FILE}`);
379
+ }
380
+ }
381
+
382
+ function hasVerifyCommand(text) {
383
+ return /\b(npm run|pnpm|yarn)\s+(lint|test|build)\b/i.test(text);
384
+ }
385
+
386
+ function hasNoApprovalLanguage(text) {
387
+ return /never ask (for )?approval|no approvals|without approval|do not ask for approval/i.test(
388
+ text,
389
+ );
390
+ }
391
+
392
+ function hasAskApprovalLanguage(text) {
393
+ return /ask for approval|request approval|require approval|needs approval/i.test(text);
394
+ }
395
+
396
+ function hasRequireTestsLanguage(text) {
397
+ return /must run tests|always run tests|run tests before done/i.test(text);
398
+ }
399
+
400
+ function hasSkipTestsLanguage(text) {
401
+ return /skip tests|tests optional|do not run tests/i.test(text);
402
+ }
403
+
404
+ function analyzeCommand() {
405
+ const claudeExists = existsSync(CLAUDE_FILE);
406
+ const agentsExists = existsSync(AGENTS_FILE);
407
+ const claude = claudeExists ? readFileSync(CLAUDE_FILE, "utf8") : "";
408
+ const agents = agentsExists ? readFileSync(AGENTS_FILE, "utf8") : "";
409
+ const issues = [];
410
+
411
+ if (!claudeExists) {
412
+ issues.push("CLAUDE.md missing.");
413
+ }
414
+ if (!agentsExists) {
415
+ issues.push("AGENTS.md missing.");
416
+ }
417
+
418
+ if (agentsExists) {
419
+ const hasBegin = agents.includes(MARKER_BEGIN);
420
+ const hasEnd = agents.includes(MARKER_END);
421
+ if (!hasBegin || !hasEnd) {
422
+ issues.push("AGENTS.md missing rules-doctor markers.");
423
+ }
424
+ }
425
+
426
+ if (claudeExists && !hasVerifyCommand(claude)) {
427
+ issues.push("CLAUDE.md appears to be missing verify commands (lint/test/build).");
428
+ }
429
+
430
+ if (agentsExists && !hasVerifyCommand(agents)) {
431
+ issues.push("AGENTS.md appears to be missing verify commands (lint/test/build).");
432
+ }
433
+
434
+ if (
435
+ (hasNoApprovalLanguage(claude) && hasAskApprovalLanguage(agents)) ||
436
+ (hasAskApprovalLanguage(claude) && hasNoApprovalLanguage(agents))
437
+ ) {
438
+ issues.push("Potential contradiction: approval guidance differs between CLAUDE.md and AGENTS.md.");
439
+ }
440
+
441
+ if (
442
+ (hasRequireTestsLanguage(claude) && hasSkipTestsLanguage(agents)) ||
443
+ (hasSkipTestsLanguage(claude) && hasRequireTestsLanguage(agents))
444
+ ) {
445
+ issues.push("Potential contradiction: test guidance differs between CLAUDE.md and AGENTS.md.");
446
+ }
447
+
448
+ console.log("rules-doctor analyze");
449
+ console.log(`- CLAUDE.md: ${claudeExists ? "found" : "missing"}`);
450
+ console.log(`- AGENTS.md: ${agentsExists ? "found" : "missing"}`);
451
+ console.log("- Findings:");
452
+
453
+ if (issues.length === 0) {
454
+ console.log("- No obvious issues found.");
455
+ return;
456
+ }
457
+
458
+ for (const issue of issues) {
459
+ console.log(`- ${issue}`);
460
+ }
461
+ }
462
+
463
+ function parseSyncTarget(args) {
464
+ let target = "all";
465
+
466
+ for (let index = 0; index < args.length; index += 1) {
467
+ const arg = args[index];
468
+ if (arg === "--target") {
469
+ const value = args[index + 1];
470
+ if (value !== "all" && value !== "claude" && value !== "codex") {
471
+ throw new Error('Invalid --target value. Use one of: "all", "claude", "codex".');
472
+ }
473
+ target = value;
474
+ index += 1;
475
+ continue;
476
+ }
477
+
478
+ throw new Error(`Unknown option for sync: ${arg}`);
479
+ }
480
+
481
+ return target;
482
+ }
483
+
484
+ function main() {
485
+ const [command, ...args] = process.argv.slice(2);
486
+
487
+ if (!command || command === "--help" || command === "-h") {
488
+ console.log(usage());
489
+ return;
490
+ }
491
+
492
+ if (command === "init") {
493
+ initCommand();
494
+ return;
495
+ }
496
+
497
+ if (command === "sync") {
498
+ syncCommand(parseSyncTarget(args));
499
+ return;
500
+ }
501
+
502
+ if (command === "analyze") {
503
+ analyzeCommand();
504
+ return;
505
+ }
506
+
507
+ throw new Error(`Unknown command: ${command}\n\n${usage()}`);
508
+ }
509
+
510
+ try {
511
+ main();
512
+ } catch (error) {
513
+ const message = error instanceof Error ? error.message : String(error);
514
+ console.error(`Error: ${message}`);
515
+ process.exitCode = 1;
516
+ }
517
+
package/package.json ADDED
@@ -0,0 +1,30 @@
1
+ {
2
+ "name": "@jazpiper/rules-doctor",
3
+ "version": "0.1.0",
4
+ "description": "Node.js CLI to keep agent rules in sync across CLAUDE.md and AGENTS.md",
5
+ "license": "MIT",
6
+ "bin": {
7
+ "rules-doctor": "dist/index.js"
8
+ },
9
+ "main": "dist/index.js",
10
+ "types": "dist/index.d.ts",
11
+ "files": [
12
+ "dist",
13
+ "README.md",
14
+ "LICENSE"
15
+ ],
16
+ "scripts": {
17
+ "build": "node scripts/tsup.js src/index.js --clean",
18
+ "test": "npm run build && node --test"
19
+ },
20
+ "keywords": [
21
+ "cli",
22
+ "agents",
23
+ "rules",
24
+ "claude",
25
+ "codex"
26
+ ],
27
+ "engines": {
28
+ "node": ">=18"
29
+ }
30
+ }