@jazpiper/rules-doctor 0.2.0 → 0.3.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/CHANGELOG.md ADDED
@@ -0,0 +1,18 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
4
+
5
+ ## 0.3.0 - 2026-02-25
6
+
7
+ - Added `copilot` adapter support for `.github/copilot-instructions.md`.
8
+ - Switched Copilot output to marker-managed mode to preserve user-authored content.
9
+ - Added target presets for `init` (`all`, `core`, `copilot`).
10
+ - Added `preset apply` command for applying presets to existing `.agentrules/rules.yaml`.
11
+ - Added CI workflow template for drift detection via `rules-doctor check`.
12
+ - Expanded tests for shared `AGENTS.md`, Copilot marker re-sync, and `--import` + preset behavior.
13
+ - Improved README with troubleshooting and CI examples.
14
+
15
+ ## 0.2.0 - 2026-02-25
16
+
17
+ - Added multi-target adapters for `claude`, `codex`, `cursor`, `gemini`, and `opencode`.
18
+ - Added `init --import`, dry-run default for `sync`, drift `check`, and structural `doctor`.
package/README.md CHANGED
@@ -1,10 +1,14 @@
1
1
  # @jazpiper/rules-doctor
2
2
 
3
- `rules-doctor` is a Node.js CLI that keeps agent instruction files in sync from one source of truth: `.agentrules/rules.yaml`.
3
+ `rules-doctor` keeps coding-rule files in sync across multiple agent CLIs from one source of truth: `.agentrules/rules.yaml`.
4
4
 
5
- It is designed for multi-agent workflows where each coding CLI reads a different file format.
5
+ It is optimized for real project adoption:
6
+ - import existing docs (`init --import`)
7
+ - target presets (`init --preset all|core|copilot`)
8
+ - safe previews by default (`sync` is dry-run unless `--write`)
9
+ - drift detection for CI (`check`)
6
10
 
7
- By default, commands resolve paths relative to the project root (`.git` ancestor), not the current subdirectory.
11
+ All paths are resolved from project root (`.git` ancestor), not current subdirectory.
8
12
 
9
13
  ## Install
10
14
 
@@ -12,143 +16,169 @@ By default, commands resolve paths relative to the project root (`.git` ancestor
12
16
  npm install -D @jazpiper/rules-doctor
13
17
  ```
14
18
 
15
- Or run directly:
19
+ Run with:
16
20
 
17
21
  ```bash
18
- npx @jazpiper/rules-doctor init
22
+ npx rules-doctor --help
19
23
  ```
20
24
 
21
- ## Usage
25
+ ## Quick Start
22
26
 
23
- ### 1) Initialize rules
27
+ ### 1) Initialize (recommended with import)
24
28
 
25
29
  ```bash
26
- rules-doctor init
30
+ npx rules-doctor init --import
27
31
  ```
28
32
 
29
- Creates `.agentrules/rules.yaml` if it does not exist.
33
+ - Creates `.agentrules/rules.yaml`
34
+ - Reads existing docs when found (`CLAUDE.md`, `AGENTS.md`, `GEMINI.md`, `.cursor/rules/*.mdc`, `.github/copilot-instructions.md`)
35
+ - Writes `.agentrules/import-report.md`
30
36
 
31
- - If `package.json` has scripts for `lint`, `test`, or `build`, they are inferred as:
32
- - `npm run lint`
33
- - `npm run test`
34
- - `npm run build`
35
- - Missing scripts are created as TODO placeholders.
37
+ Preset example (Copilot only):
36
38
 
37
- ### 2) Sync generated docs
39
+ ```bash
40
+ npx rules-doctor init --preset copilot
41
+ ```
42
+
43
+ Preset meanings:
44
+ - `all`: all built-in targets enabled
45
+ - `core`: `claude`, `codex`, `opencode`, `cursor`, `gemini`
46
+ - `copilot`: only `copilot` enabled
47
+
48
+ Apply preset to an existing `.agentrules/rules.yaml`:
49
+
50
+ ```bash
51
+ npx rules-doctor preset apply copilot --write
52
+ ```
53
+
54
+ ### 2) Preview changes safely
38
55
 
39
56
  ```bash
40
- rules-doctor sync
41
- rules-doctor sync --target claude
42
- rules-doctor sync --target codex
43
- rules-doctor sync --target cursor,gemini,opencode,antigravity
57
+ npx rules-doctor sync --diff
44
58
  ```
45
59
 
46
- Generates/updates:
47
- - `CLAUDE.md` (`claude`, fully managed)
48
- - `AGENTS.md` (`codex`, `opencode`, marker-managed)
49
- - `.cursor/rules/rules-doctor.mdc` (`cursor`, fully managed)
50
- - `GEMINI.md` (`gemini`, fully managed)
51
- - `GEMINI.md` (`antigravity`, inferred-compatible mapping)
60
+ `sync` is dry-run by default. Nothing is written yet.
52
61
 
53
- Managed marker block in `AGENTS.md`:
62
+ ### 3) Apply changes
54
63
 
55
- ```md
56
- <!-- RULES_DOCTOR:BEGIN -->
57
- ... managed content ...
58
- <!-- RULES_DOCTOR:END -->
64
+ ```bash
65
+ npx rules-doctor sync --write
59
66
  ```
60
67
 
61
- If markers are missing, a new managed block is appended to the end of `AGENTS.md`.
68
+ Optional backups:
69
+
70
+ ```bash
71
+ npx rules-doctor sync --write --backup
72
+ ```
62
73
 
63
- ### 3) Analyze docs
74
+ ### 4) Verify drift in CI/local
64
75
 
65
76
  ```bash
66
- rules-doctor analyze
77
+ npx rules-doctor check
67
78
  ```
68
79
 
69
- Reads enabled target files from `rules.yaml`, then prints a concise report about:
70
- - missing markers
71
- - missing verify commands (`lint`/`test`/`build`)
72
- - obvious contradictions (simple heuristics across generated targets)
80
+ Returns non-zero when generated targets are out of sync.
73
81
 
74
- `--strict` makes `analyze` fail with non-zero exit code when findings exist.
82
+ ## Supported Targets
75
83
 
76
- ### 4) List supported adapters
84
+ ```bash
85
+ npx rules-doctor targets list
86
+ ```
87
+
88
+ Built-in adapters:
89
+ - `claude` -> `CLAUDE.md` (full-managed)
90
+ - `codex` -> `AGENTS.md` (marker-managed)
91
+ - `copilot` -> `.github/copilot-instructions.md` (marker-managed, preserves existing text outside managed block)
92
+ - `opencode` -> `AGENTS.md` (marker-managed)
93
+ - `cursor` -> `.cursor/rules/rules-doctor.mdc` (full-managed)
94
+ - `gemini` -> `GEMINI.md` (full-managed)
95
+
96
+ ## Command Reference
97
+
98
+ ### `init`
77
99
 
78
100
  ```bash
79
- rules-doctor targets list
101
+ npx rules-doctor init [--import]
102
+ npx rules-doctor init [--import] [--preset all|core|copilot]
80
103
  ```
81
104
 
82
- Shows built-in adapters and default file paths.
105
+ ### `preset apply`
83
106
 
84
- ### 5) Directory/Path Notes
107
+ ```bash
108
+ npx rules-doctor preset apply <all|core|copilot> [--diff] [--write]
109
+ ```
85
110
 
86
- - `claude`: searches `CLAUDE.md` from current directory upward; also supports `.claude/CLAUDE.md` and `.claude/rules/*.md`.
87
- - `codex`: looks for `AGENTS.override.md` or `AGENTS.md` from project root down to current directory.
88
- - `opencode`: reads project `AGENTS.md`, plus user global `~/.config/opencode/AGENTS.md`.
89
- - `cursor`: project rules live in `.cursor/rules/*.mdc` (legacy `.cursorrules` still exists).
90
- - `gemini`: uses `GEMINI.md` in workspace/ancestor directories and `~/.gemini/GEMINI.md`.
91
- - `antigravity`: currently mapped to `GEMINI.md` as an inferred default; verify in your environment.
111
+ ### `sync`
92
112
 
93
- ## Rules Schema (v2 Draft)
113
+ ```bash
114
+ npx rules-doctor sync [--target all|claude,codex,...] [--diff] [--write] [--backup]
115
+ ```
116
+
117
+ ### `check`
94
118
 
95
- `init` now creates a v2-compatible draft with `targets` configuration:
119
+ ```bash
120
+ npx rules-doctor check [--target all|claude,codex,...] [--diff]
121
+ ```
122
+
123
+ ### `analyze`
124
+
125
+ ```bash
126
+ npx rules-doctor analyze [--strict]
127
+ ```
128
+
129
+ ### `doctor`
130
+
131
+ ```bash
132
+ npx rules-doctor doctor [--strict]
133
+ ```
134
+
135
+ ## CI Template
136
+
137
+ Copy [docs/workflows/rules-doctor-check.yml](docs/workflows/rules-doctor-check.yml) to your repository as `.github/workflows/rules-doctor-check.yml`.
138
+ It runs `npx rules-doctor check` on push and pull requests.
139
+
140
+ Inline workflow example:
96
141
 
97
142
  ```yaml
98
- version: 2
99
- mission: "Ship safe changes quickly while keeping agent instructions consistent."
100
- workflow:
101
- - "Read relevant files before editing."
102
- - "Make the smallest correct change."
103
- - "Run verification commands before finalizing."
104
- commands:
105
- lint: "npm run lint"
106
- test: "npm run test"
107
- build: "npm run build"
108
- done:
109
- - "Commands pass or blockers are documented."
110
- - "Changed behavior is reflected in docs where needed."
111
- approvals:
112
- mode: "ask-before-destructive"
113
- notes:
114
- - "Ask before destructive actions or privileged operations."
115
- targets:
116
- claude:
117
- enabled: true
118
- path: "CLAUDE.md"
119
- codex:
120
- enabled: true
121
- path: "AGENTS.md"
122
- cursor:
123
- enabled: true
124
- path: ".cursor/rules/rules-doctor.mdc"
125
- gemini:
126
- enabled: true
127
- path: "GEMINI.md"
128
- opencode:
129
- enabled: true
130
- path: "AGENTS.md"
131
- antigravity:
132
- enabled: true
133
- path: "GEMINI.md"
134
- ```
135
-
136
- You can disable or relocate any target by editing `targets.<id>.enabled/path`.
143
+ name: Rules Doctor Check
144
+
145
+ on:
146
+ push:
147
+ pull_request:
148
+
149
+ jobs:
150
+ rules-doctor:
151
+ runs-on: ubuntu-latest
152
+ steps:
153
+ - uses: actions/checkout@v4
154
+ - uses: actions/setup-node@v4
155
+ with:
156
+ node-version: "20"
157
+ cache: npm
158
+ - run: npm ci
159
+ - run: npx rules-doctor check
160
+ ```
161
+
162
+ ## Troubleshooting
163
+
164
+ - `rules-doctor: command not found` after `npm install -D @jazpiper/rules-doctor`:
165
+ - Use `npx rules-doctor ...` (recommended for local dev dependency).
166
+ - Or add an npm script in your project: `"rules:check": "rules-doctor check"`, then run `npm run rules:check`.
167
+ - Global install (`npm i -g @jazpiper/rules-doctor`) works, but local + `npx` is safer for version consistency.
168
+ - `init` says `rules.yaml already exists`:
169
+ - Use `npx rules-doctor preset apply <preset> --write` to change target defaults on an existing project.
170
+
171
+ ## Rules Schema (v2 Draft)
172
+
173
+ See [docs/rules-v2-draft.yaml](docs/rules-v2-draft.yaml).
137
174
 
138
175
  ## Development
139
176
 
140
177
  ```bash
141
178
  npm ci
142
- npm run build
179
+ npm test
143
180
  ```
144
181
 
145
182
  ## License
146
183
 
147
184
  MIT
148
-
149
-
150
- ## CI
151
-
152
- This repo includes a GitHub Actions workflow template at `docs/workflows/ci.yml`.
153
-
154
- If you want CI, copy it to `.github/workflows/ci.yml` and push the change.
@@ -0,0 +1,15 @@
1
+ const { renderManagedRulesBody } = require("./common");
2
+
3
+ module.exports = {
4
+ id: "copilot",
5
+ name: "GitHub Copilot",
6
+ description:
7
+ "Manage .github/copilot-instructions.md via marker-managed section to preserve user content.",
8
+ defaultPath: ".github/copilot-instructions.md",
9
+ management: "marker",
10
+ markerBegin: "<!-- RULES_DOCTOR:COPILOT:BEGIN -->",
11
+ markerEnd: "<!-- RULES_DOCTOR:COPILOT:END -->",
12
+ render(rules) {
13
+ return ["# Copilot Instructions", "", renderManagedRulesBody(rules)].join("\n");
14
+ },
15
+ };
@@ -1,11 +1,11 @@
1
1
  const claude = require("./claude");
2
2
  const codex = require("./codex");
3
+ const copilot = require("./copilot");
3
4
  const cursor = require("./cursor");
4
5
  const gemini = require("./gemini");
5
6
  const opencode = require("./opencode");
6
- const antigravity = require("./antigravity");
7
7
 
8
- const ADAPTERS = [claude, codex, cursor, gemini, opencode, antigravity];
8
+ const ADAPTERS = [claude, codex, copilot, cursor, gemini, opencode];
9
9
  const ADAPTERS_BY_ID = Object.fromEntries(ADAPTERS.map((adapter) => [adapter.id, adapter]));
10
10
 
11
11
  module.exports = {
package/dist/index.js CHANGED
@@ -4,17 +4,26 @@ const { existsSync, mkdirSync, readFileSync, writeFileSync } = require("node:fs"
4
4
  const { ADAPTERS, ADAPTERS_BY_ID } = require("./adapters");
5
5
 
6
6
  const RULES_RELATIVE_PATH = ".agentrules/rules.yaml";
7
+ const IMPORT_REPORT_RELATIVE_PATH = ".agentrules/import-report.md";
8
+ const PRESET_NAMES = ["all", "core", "copilot"];
7
9
 
8
10
  function usage() {
9
11
  const targets = ADAPTERS.map((adapter) => adapter.id).join("|");
12
+ const presets = PRESET_NAMES.join("|");
10
13
  return [
11
14
  "rules-doctor",
12
15
  "",
13
16
  "Usage:",
14
- " rules-doctor init",
15
- ` rules-doctor sync [--target all|${targets}|<comma-separated-targets>]`,
16
- " rules-doctor analyze",
17
+ ` rules-doctor init [--import] [--preset ${presets}]`,
18
+ ` rules-doctor preset apply <${presets}> [--diff] [--write]`,
19
+ ` rules-doctor sync [--target all|${targets}|<comma-separated-targets>] [--diff] [--write] [--backup]`,
20
+ ` rules-doctor check [--target all|${targets}|<comma-separated-targets>] [--diff]`,
21
+ " rules-doctor analyze [--strict]",
22
+ " rules-doctor doctor [--strict]",
17
23
  " rules-doctor targets list",
24
+ "",
25
+ "Notes:",
26
+ " - sync defaults to dry-run. Add --write to apply changes.",
18
27
  ].join("\n");
19
28
  }
20
29
 
@@ -239,6 +248,40 @@ function createDefaultRules(scripts) {
239
248
  };
240
249
  }
241
250
 
251
+ function getPresetTargetIds(presetName) {
252
+ if (presetName === "all") {
253
+ return ADAPTERS.map((adapter) => adapter.id);
254
+ }
255
+
256
+ if (presetName === "core") {
257
+ return ["claude", "codex", "opencode", "cursor", "gemini"];
258
+ }
259
+
260
+ if (presetName === "copilot") {
261
+ return ["copilot"];
262
+ }
263
+
264
+ throw new Error(`Unknown preset "${presetName}". Use one of: ${PRESET_NAMES.join(", ")}`);
265
+ }
266
+
267
+ function applyTargetPreset(rules, presetName) {
268
+ const enabled = new Set(getPresetTargetIds(presetName));
269
+ const targets = rules.targets && typeof rules.targets === "object" ? rules.targets : {};
270
+
271
+ for (const adapter of ADAPTERS) {
272
+ const current = normalizeTargetConfig(targets[adapter.id], adapter.defaultPath);
273
+ targets[adapter.id] = {
274
+ enabled: enabled.has(adapter.id),
275
+ path: current.path,
276
+ };
277
+ }
278
+
279
+ return {
280
+ ...rules,
281
+ targets,
282
+ };
283
+ }
284
+
242
285
  function normalizeTargetConfig(source, fallbackPath) {
243
286
  if (typeof source === "string" && source.trim()) {
244
287
  return { enabled: true, path: source.trim() };
@@ -275,7 +318,14 @@ function normalizeRules(input, defaults) {
275
318
 
276
319
  const targets = {};
277
320
  for (const adapter of ADAPTERS) {
278
- targets[adapter.id] = normalizeTargetConfig(sourceTargets[adapter.id], defaults.targets[adapter.id].path);
321
+ const fallback = defaults.targets[adapter.id]
322
+ ? defaults.targets[adapter.id].path
323
+ : adapter.defaultPath;
324
+ const config = normalizeTargetConfig(sourceTargets[adapter.id], fallback);
325
+ targets[adapter.id] = {
326
+ enabled: typeof config.enabled === "boolean" ? config.enabled : true,
327
+ path: config.path,
328
+ };
279
329
  }
280
330
 
281
331
  for (const customId of Object.keys(sourceTargets)) {
@@ -464,43 +514,153 @@ function getTargetsFromSpec(spec) {
464
514
  return unique;
465
515
  }
466
516
 
467
- function parseSyncTargets(args) {
517
+ function parseInitArgs(args) {
518
+ const options = {
519
+ importExisting: false,
520
+ preset: "all",
521
+ };
522
+
523
+ for (let index = 0; index < (args || []).length; index += 1) {
524
+ const arg = args[index];
525
+ if (arg === "--import") {
526
+ options.importExisting = true;
527
+ continue;
528
+ }
529
+
530
+ if (arg === "--preset") {
531
+ const value = args[index + 1];
532
+ if (!value) {
533
+ throw new Error("Missing value for --preset.");
534
+ }
535
+ if (!PRESET_NAMES.includes(value)) {
536
+ throw new Error(`Unknown preset "${value}". Use one of: ${PRESET_NAMES.join(", ")}`);
537
+ }
538
+ options.preset = value;
539
+ index += 1;
540
+ continue;
541
+ }
542
+
543
+ throw new Error(`Unknown option for init: ${arg}`);
544
+ }
545
+
546
+ return options;
547
+ }
548
+
549
+ function parsePresetArgs(args) {
468
550
  if (!args || args.length === 0) {
469
- return ADAPTERS.map((adapter) => adapter.id);
551
+ throw new Error(`Missing preset subcommand. Use "rules-doctor preset apply <${PRESET_NAMES.join("|")}>".`);
552
+ }
553
+
554
+ const [subcommand, presetName, ...rest] = args;
555
+ if (subcommand !== "apply") {
556
+ throw new Error(`Unknown preset subcommand: ${subcommand}. Use "rules-doctor preset apply <preset>".`);
557
+ }
558
+
559
+ if (!presetName) {
560
+ throw new Error(`Missing preset name. Use one of: ${PRESET_NAMES.join(", ")}`);
561
+ }
562
+ if (!PRESET_NAMES.includes(presetName)) {
563
+ throw new Error(`Unknown preset "${presetName}". Use one of: ${PRESET_NAMES.join(", ")}`);
564
+ }
565
+
566
+ const options = {
567
+ presetName,
568
+ diff: false,
569
+ write: false,
570
+ };
571
+
572
+ for (const arg of rest) {
573
+ if (arg === "--diff") {
574
+ options.diff = true;
575
+ continue;
576
+ }
577
+ if (arg === "--write") {
578
+ options.write = true;
579
+ continue;
580
+ }
581
+ throw new Error(`Unknown option for preset apply: ${arg}`);
470
582
  }
471
583
 
472
- let targetSpec = "all";
473
- for (let index = 0; index < args.length; index += 1) {
584
+ return options;
585
+ }
586
+
587
+ function parseTargetedArgs(commandName, args, extra) {
588
+ const options = {
589
+ targetSpec: "all",
590
+ diff: false,
591
+ write: false,
592
+ backup: false,
593
+ };
594
+ const allowed = extra || {};
595
+
596
+ for (let index = 0; index < (args || []).length; index += 1) {
474
597
  const arg = args[index];
475
598
  if (arg === "--target") {
476
599
  const value = args[index + 1];
477
600
  if (!value) {
478
- throw new Error("Missing value for --target");
601
+ throw new Error(`Missing value for --target (${commandName})`);
479
602
  }
480
- targetSpec = value;
603
+ options.targetSpec = value;
481
604
  index += 1;
482
605
  continue;
483
606
  }
607
+ if (arg === "--diff") {
608
+ options.diff = true;
609
+ continue;
610
+ }
611
+ if (arg === "--write" && allowed.write) {
612
+ options.write = true;
613
+ continue;
614
+ }
615
+ if (arg === "--backup" && allowed.backup) {
616
+ options.backup = true;
617
+ continue;
618
+ }
619
+ throw new Error(`Unknown option for ${commandName}: ${arg}`);
620
+ }
484
621
 
485
- throw new Error(`Unknown option for sync: ${arg}`);
622
+ if (options.backup && !options.write) {
623
+ throw new Error("--backup requires --write.");
486
624
  }
487
625
 
488
- return getTargetsFromSpec(targetSpec);
626
+ return {
627
+ targetIds: getTargetsFromSpec(options.targetSpec),
628
+ diff: options.diff,
629
+ write: options.write,
630
+ backup: options.backup,
631
+ };
489
632
  }
490
633
 
491
634
  function parseAnalyzeArgs(args) {
492
- if (!args || args.length === 0) {
493
- return {};
494
- }
635
+ const options = {
636
+ strict: false,
637
+ };
495
638
 
496
- for (const arg of args) {
639
+ for (const arg of args || []) {
497
640
  if (arg === "--strict") {
498
- return { strict: true };
641
+ options.strict = true;
642
+ continue;
499
643
  }
500
644
  throw new Error(`Unknown option for analyze: ${arg}`);
501
645
  }
502
646
 
503
- return {};
647
+ return options;
648
+ }
649
+
650
+ function parseDoctorArgs(args) {
651
+ const options = {
652
+ strict: false,
653
+ };
654
+
655
+ for (const arg of args || []) {
656
+ if (arg === "--strict") {
657
+ options.strict = true;
658
+ continue;
659
+ }
660
+ throw new Error(`Unknown option for doctor: ${arg}`);
661
+ }
662
+
663
+ return options;
504
664
  }
505
665
 
506
666
  function getTargetConfig(rules, adapter) {
@@ -508,57 +668,535 @@ function getTargetConfig(rules, adapter) {
508
668
  return normalizeTargetConfig(source, adapter.defaultPath);
509
669
  }
510
670
 
511
- function initCommand(rootDir, logger) {
671
+ function normalizeHeading(value) {
672
+ return value.toLowerCase().replace(/[^a-z0-9]+/g, " ").trim();
673
+ }
674
+
675
+ function parseMarkdownSections(text) {
676
+ const sections = {};
677
+ const lines = text.replace(/\r\n/g, "\n").split("\n");
678
+ let currentHeading = null;
679
+ let currentLines = [];
680
+
681
+ function flush() {
682
+ if (!currentHeading) {
683
+ return;
684
+ }
685
+ const normalized = normalizeHeading(currentHeading);
686
+ if (!sections[normalized]) {
687
+ sections[normalized] = currentLines.join("\n").trim();
688
+ }
689
+ }
690
+
691
+ for (const line of lines) {
692
+ const heading = line.match(/^#{1,6}\s+(.+?)\s*$/);
693
+ if (heading) {
694
+ flush();
695
+ currentHeading = heading[1];
696
+ currentLines = [];
697
+ continue;
698
+ }
699
+
700
+ if (currentHeading) {
701
+ currentLines.push(line);
702
+ }
703
+ }
704
+
705
+ flush();
706
+ return sections;
707
+ }
708
+
709
+ function pickFirstNonEmptyLine(text) {
710
+ for (const line of text.split("\n")) {
711
+ const trimmed = line.trim();
712
+ if (trimmed) {
713
+ return trimmed;
714
+ }
715
+ }
716
+ return "";
717
+ }
718
+
719
+ function parseListItems(text) {
720
+ const items = [];
721
+ for (const line of text.split("\n")) {
722
+ const bullet = line.match(/^\s*[-*]\s+(.+?)\s*$/);
723
+ if (bullet) {
724
+ items.push(bullet[1].trim());
725
+ continue;
726
+ }
727
+ const numbered = line.match(/^\s*\d+\.\s+(.+?)\s*$/);
728
+ if (numbered) {
729
+ items.push(numbered[1].trim());
730
+ }
731
+ }
732
+ return items;
733
+ }
734
+
735
+ function unquoteValue(value) {
736
+ let current = value.trim();
737
+ if (current.startsWith("`") && current.endsWith("`")) {
738
+ current = current.slice(1, -1);
739
+ }
740
+ current = stripQuotes(current);
741
+ return current.trim();
742
+ }
743
+
744
+ function importCommandsFromText(text, commands) {
745
+ const merged = { ...commands };
746
+ const lines = text.replace(/\r\n/g, "\n").split("\n");
747
+ const commandNames = Object.keys(merged);
748
+
749
+ for (const raw of lines) {
750
+ const line = raw.trim();
751
+ for (const name of commandNames) {
752
+ const direct = line.match(new RegExp(`^(?:[-*]\\s*)?${name}\\s*:\\s*(.+)$`, "i"));
753
+ if (direct && direct[1]) {
754
+ merged[name] = unquoteValue(direct[1]);
755
+ }
756
+ }
757
+ }
758
+
759
+ for (const name of commandNames) {
760
+ if (!merged[name] || merged[name].includes("TODO")) {
761
+ const found = text.match(new RegExp(`\\b(?:npm run|pnpm|yarn|bun)\\s+${name}\\b`, "i"));
762
+ if (found) {
763
+ merged[name] = found[0];
764
+ }
765
+ }
766
+ }
767
+
768
+ return merged;
769
+ }
770
+
771
+ function getSectionText(sections, aliases) {
772
+ for (const alias of aliases) {
773
+ const key = normalizeHeading(alias);
774
+ if (sections[key] && sections[key].trim()) {
775
+ return sections[key].trim();
776
+ }
777
+ }
778
+ return "";
779
+ }
780
+
781
+ function collectImportSources(rootDir) {
782
+ const candidates = [
783
+ { id: "claude", path: "CLAUDE.md" },
784
+ { id: "claude-local", path: ".claude/CLAUDE.md" },
785
+ { id: "codex", path: "AGENTS.md" },
786
+ { id: "copilot", path: ".github/copilot-instructions.md" },
787
+ { id: "gemini", path: "GEMINI.md" },
788
+ { id: "cursor", path: ".cursor/rules/rules-doctor.mdc" },
789
+ ];
790
+
791
+ const uniquePaths = new Set();
792
+ const sources = [];
793
+
794
+ for (const item of candidates) {
795
+ if (uniquePaths.has(item.path)) {
796
+ continue;
797
+ }
798
+ uniquePaths.add(item.path);
799
+
800
+ const absolutePath = resolveInRoot(rootDir, item.path);
801
+ if (!existsSync(absolutePath)) {
802
+ continue;
803
+ }
804
+ sources.push({
805
+ id: item.id,
806
+ path: item.path,
807
+ absolutePath,
808
+ text: readFileSync(absolutePath, "utf8"),
809
+ });
810
+ }
811
+
812
+ return sources;
813
+ }
814
+
815
+ function importRulesFromDocs(rootDir, defaults) {
816
+ const imported = JSON.parse(JSON.stringify(defaults));
817
+ const sources = collectImportSources(rootDir);
818
+ const notes = [];
819
+
820
+ if (sources.length === 0) {
821
+ return {
822
+ rules: imported,
823
+ report: "No existing docs were found. Created default rules.",
824
+ sources,
825
+ };
826
+ }
827
+
828
+ notes.push(`Found ${sources.length} source file(s):`);
829
+ for (const source of sources) {
830
+ notes.push(`- ${source.path}`);
831
+ }
832
+
833
+ for (const source of sources) {
834
+ const sections = parseMarkdownSections(source.text);
835
+
836
+ const missionSection = getSectionText(sections, ["mission"]);
837
+ if (missionSection) {
838
+ const mission = pickFirstNonEmptyLine(missionSection);
839
+ if (mission) {
840
+ imported.mission = mission;
841
+ }
842
+ }
843
+
844
+ const workflowSection = getSectionText(sections, ["workflow", "operational loop"]);
845
+ const workflow = parseListItems(workflowSection);
846
+ if (workflow.length > 0) {
847
+ imported.workflow = workflow;
848
+ }
849
+
850
+ const doneSection = getSectionText(sections, ["done", "done criteria"]);
851
+ const done = parseListItems(doneSection);
852
+ if (done.length > 0) {
853
+ imported.done = done;
854
+ }
855
+
856
+ const approvalsSection = getSectionText(sections, ["approvals", "approval"]);
857
+ if (approvalsSection) {
858
+ const mode = approvalsSection.match(/(?:mode|policy)\s*:\s*`?([a-z0-9_-]+)`?/i);
859
+ if (mode && mode[1]) {
860
+ imported.approvals.mode = mode[1].trim();
861
+ }
862
+ const approvalNotes = parseListItems(approvalsSection);
863
+ if (approvalNotes.length > 0) {
864
+ imported.approvals.notes = approvalNotes;
865
+ }
866
+ }
867
+
868
+ imported.commands = importCommandsFromText(source.text, imported.commands);
869
+ }
870
+
871
+ for (const adapter of ADAPTERS) {
872
+ const config = getTargetConfig(imported, adapter);
873
+ const absolutePath = resolveInRoot(rootDir, config.path);
874
+ if (existsSync(absolutePath)) {
875
+ imported.targets[adapter.id].enabled = true;
876
+ }
877
+ }
878
+
879
+ notes.push("Imported mission/workflow/commands/done/approvals where detected.");
880
+ return {
881
+ rules: imported,
882
+ report: notes.join("\n"),
883
+ sources,
884
+ };
885
+ }
886
+
887
+ function initCommand(rootDir, logger, args) {
888
+ const options = parseInitArgs(args);
512
889
  const rulesFile = resolve(rootDir, RULES_RELATIVE_PATH);
513
890
  if (existsSync(rulesFile)) {
514
891
  logger.log(`rules.yaml already exists: ${rulesFile}`);
515
- return;
892
+ return 0;
516
893
  }
517
894
 
518
895
  const defaults = createDefaultRules(loadPackageScripts(rootDir));
896
+ let rules = defaults;
897
+ let importReport = "";
898
+
899
+ if (options.importExisting) {
900
+ const imported = importRulesFromDocs(rootDir, defaults);
901
+ rules = imported.rules;
902
+ importReport = imported.report;
903
+ }
904
+
905
+ rules = applyTargetPreset(rules, options.preset);
906
+
519
907
  ensureParentDirectory(rulesFile);
520
- writeFileSync(rulesFile, stringifyRules(defaults), "utf8");
908
+ writeFileSync(rulesFile, stringifyRules(rules), "utf8");
521
909
  logger.log(`Created ${rulesFile}`);
910
+ logger.log(`Applied target preset: ${options.preset}`);
911
+
912
+ if (options.importExisting) {
913
+ const reportPath = resolve(rootDir, IMPORT_REPORT_RELATIVE_PATH);
914
+ const reportContent =
915
+ options.preset === "all"
916
+ ? `${importReport}\n`
917
+ : `${importReport}\n\nApplied target preset: ${options.preset}\n`;
918
+ writeFileSync(reportPath, reportContent, "utf8");
919
+ logger.log(`Import report: ${reportPath}`);
920
+ }
921
+
922
+ return 0;
522
923
  }
523
924
 
524
- function syncCommand(rootDir, logger, args) {
525
- const { rules } = loadRules(rootDir);
526
- const selectedTargetIds = parseSyncTargets(args);
925
+ function presetApplyCommand(rootDir, logger, args) {
926
+ const options = parsePresetArgs(args);
927
+ const { rules, rulesFile } = loadRules(rootDir);
928
+ const nextRules = applyTargetPreset(
929
+ {
930
+ ...rules,
931
+ targets: { ...(rules.targets || {}) },
932
+ },
933
+ options.presetName,
934
+ );
935
+
936
+ const currentText = stringifyRules(rules);
937
+ const nextText = stringifyRules(nextRules);
938
+ const changed = currentText !== nextText;
527
939
 
528
- let updated = 0;
529
- for (const targetId of selectedTargetIds) {
940
+ logger.log("rules-doctor preset apply");
941
+ logger.log(`- root: ${rootDir}`);
942
+ logger.log(`- preset: ${options.presetName}`);
943
+ logger.log(`- mode: ${options.write ? "write" : "dry-run"}`);
944
+
945
+ if (options.diff && changed) {
946
+ logger.log("\n# diff: .agentrules/rules.yaml");
947
+ logger.log(renderSimpleDiff(currentText, nextText));
948
+ }
949
+
950
+ if (!changed) {
951
+ logger.log("No changes required: preset already applied.");
952
+ return 0;
953
+ }
954
+
955
+ if (!options.write) {
956
+ logger.log("Dry-run complete: rules.yaml would be updated. Re-run with --write.");
957
+ return 0;
958
+ }
959
+
960
+ ensureParentDirectory(rulesFile);
961
+ writeFileSync(rulesFile, nextText, "utf8");
962
+ logger.log(`Updated ${rulesFile}`);
963
+ return 0;
964
+ }
965
+
966
+ function buildTargetPlans(rootDir, rules, targetIds) {
967
+ const plans = [];
968
+
969
+ for (const targetId of targetIds) {
530
970
  const adapter = ADAPTERS_BY_ID[targetId];
531
971
  const target = getTargetConfig(rules, adapter);
972
+ const targetPath = resolveInRoot(rootDir, target.path);
973
+ const fileExists = existsSync(targetPath);
974
+ const currentText = fileExists ? readFileSync(targetPath, "utf8") : "";
532
975
 
533
976
  if (!target.enabled) {
534
- logger.log(`Skipped ${targetId} (disabled in rules.yaml).`);
977
+ plans.push({
978
+ targetId,
979
+ adapter,
980
+ enabled: false,
981
+ targetPath,
982
+ targetPathDisplay: target.path,
983
+ exists: fileExists,
984
+ currentText,
985
+ desiredText: currentText,
986
+ changed: false,
987
+ });
535
988
  continue;
536
989
  }
537
990
 
538
- const targetPath = resolveInRoot(rootDir, target.path);
539
991
  const rendered = adapter.render(rules).trim();
540
- ensureParentDirectory(targetPath);
992
+ const desiredText =
993
+ adapter.management === "marker"
994
+ ? upsertManagedSection(currentText, rendered, adapter.markerBegin, adapter.markerEnd)
995
+ : `${rendered}\n`;
996
+
997
+ plans.push({
998
+ targetId,
999
+ adapter,
1000
+ enabled: true,
1001
+ targetPath,
1002
+ targetPathDisplay: target.path,
1003
+ exists: fileExists,
1004
+ currentText,
1005
+ desiredText,
1006
+ changed: desiredText !== currentText,
1007
+ });
1008
+ }
541
1009
 
542
- if (adapter.management === "marker") {
543
- const existing = existsSync(targetPath) ? readFileSync(targetPath, "utf8") : "";
544
- const updatedText = upsertManagedSection(
545
- existing,
546
- rendered,
547
- adapter.markerBegin,
548
- adapter.markerEnd,
1010
+ return plans;
1011
+ }
1012
+
1013
+ function renderSimpleDiff(currentText, desiredText) {
1014
+ if (currentText === desiredText) {
1015
+ return "";
1016
+ }
1017
+
1018
+ const oldLines = currentText.replace(/\r\n/g, "\n").split("\n");
1019
+ const newLines = desiredText.replace(/\r\n/g, "\n").split("\n");
1020
+ const output = ["--- current", "+++ desired"];
1021
+ const maxLines = Math.max(oldLines.length, newLines.length);
1022
+ const hardLimit = 120;
1023
+ let emitted = 0;
1024
+
1025
+ for (let index = 0; index < maxLines; index += 1) {
1026
+ const oldLine = oldLines[index];
1027
+ const newLine = newLines[index];
1028
+
1029
+ if (oldLine === newLine) {
1030
+ continue;
1031
+ }
1032
+
1033
+ if (typeof oldLine !== "undefined") {
1034
+ output.push(`-${oldLine}`);
1035
+ emitted += 1;
1036
+ }
1037
+ if (typeof newLine !== "undefined") {
1038
+ output.push(`+${newLine}`);
1039
+ emitted += 1;
1040
+ }
1041
+
1042
+ if (emitted >= hardLimit) {
1043
+ output.push("... diff truncated ...");
1044
+ break;
1045
+ }
1046
+ }
1047
+
1048
+ return output.join("\n");
1049
+ }
1050
+
1051
+ function formatPlanSummary(plans) {
1052
+ const changed = plans.filter((plan) => plan.changed).length;
1053
+ const changedFiles = new Set(plans.filter((plan) => plan.changed).map((plan) => plan.targetPath)).size;
1054
+ const enabled = plans.filter((plan) => plan.enabled).length;
1055
+ const disabled = plans.length - enabled;
1056
+ return { changed, changedFiles, enabled, disabled };
1057
+ }
1058
+
1059
+ function getUniqueWritePlans(plans) {
1060
+ const byPath = new Map();
1061
+ const duplicates = [];
1062
+
1063
+ for (const plan of plans) {
1064
+ if (!plan.enabled || !plan.changed) {
1065
+ continue;
1066
+ }
1067
+
1068
+ const existing = byPath.get(plan.targetPath);
1069
+ if (!existing) {
1070
+ byPath.set(plan.targetPath, plan);
1071
+ continue;
1072
+ }
1073
+
1074
+ if (existing.desiredText !== plan.desiredText) {
1075
+ throw new Error(
1076
+ `Conflicting outputs for ${plan.targetPathDisplay}: ${existing.targetId} and ${plan.targetId} produce different content.`,
549
1077
  );
550
- writeFileSync(targetPath, updatedText, "utf8");
1078
+ }
1079
+
1080
+ duplicates.push({
1081
+ targetPathDisplay: plan.targetPathDisplay,
1082
+ winner: existing.targetId,
1083
+ duplicate: plan.targetId,
1084
+ });
1085
+ }
1086
+
1087
+ return {
1088
+ uniquePlans: Array.from(byPath.values()),
1089
+ duplicates,
1090
+ };
1091
+ }
1092
+
1093
+ function syncCommand(rootDir, logger, args) {
1094
+ const options = parseTargetedArgs("sync", args, { write: true, backup: true });
1095
+ const { rules } = loadRules(rootDir);
1096
+ const plans = buildTargetPlans(rootDir, rules, options.targetIds);
1097
+ const summary = formatPlanSummary(plans);
1098
+
1099
+ logger.log("rules-doctor sync");
1100
+ logger.log(`- root: ${rootDir}`);
1101
+ logger.log(`- selected targets: ${options.targetIds.join(", ")}`);
1102
+ logger.log(`- mode: ${options.write ? "write" : "dry-run"}`);
1103
+
1104
+ for (const plan of plans) {
1105
+ if (!plan.enabled) {
1106
+ logger.log(`- ${plan.targetId}: disabled (${plan.targetPathDisplay})`);
1107
+ continue;
1108
+ }
1109
+ if (!plan.changed) {
1110
+ logger.log(`- ${plan.targetId}: up-to-date (${plan.targetPathDisplay})`);
1111
+ continue;
1112
+ }
1113
+ logger.log(`- ${plan.targetId}: would update (${plan.targetPathDisplay})`);
1114
+ }
1115
+
1116
+ if (options.diff) {
1117
+ for (const plan of plans) {
1118
+ if (!plan.enabled || !plan.changed) {
1119
+ continue;
1120
+ }
1121
+ logger.log(`\n# diff: ${plan.targetId} (${plan.targetPathDisplay})`);
1122
+ logger.log(renderSimpleDiff(plan.currentText, plan.desiredText));
1123
+ }
1124
+ }
1125
+
1126
+ if (!options.write) {
1127
+ if (summary.changed === 0) {
1128
+ logger.log("Dry-run complete: no changes.");
551
1129
  } else {
552
- writeFileSync(targetPath, `${rendered}\n`, "utf8");
1130
+ logger.log(
1131
+ `Dry-run complete: ${summary.changedFiles} file(s) would change (${summary.changed} target mappings). Re-run with --write.`,
1132
+ );
1133
+ }
1134
+ return 0;
1135
+ }
1136
+
1137
+ const { uniquePlans, duplicates } = getUniqueWritePlans(plans);
1138
+ for (const duplicate of duplicates) {
1139
+ logger.log(
1140
+ ` note: ${duplicate.duplicate} shares output with ${duplicate.winner} at ${duplicate.targetPathDisplay}`,
1141
+ );
1142
+ }
1143
+
1144
+ const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
1145
+ for (const plan of uniquePlans) {
1146
+ ensureParentDirectory(plan.targetPath);
1147
+ if (options.backup && plan.exists) {
1148
+ const backupPath = `${plan.targetPath}.rules-doctor.bak.${timestamp}`;
1149
+ writeFileSync(backupPath, plan.currentText, "utf8");
1150
+ logger.log(` backup: ${backupPath}`);
553
1151
  }
554
1152
 
555
- logger.log(`Updated ${targetPath} (${targetId})`);
556
- updated += 1;
1153
+ writeFileSync(plan.targetPath, plan.desiredText, "utf8");
1154
+ logger.log(` updated: ${plan.targetPath}`);
557
1155
  }
558
1156
 
559
- if (updated === 0) {
560
- logger.log("No files updated.");
1157
+ logger.log(
1158
+ `Write complete: ${uniquePlans.length} file(s) updated (${summary.changed} target mappings changed).`,
1159
+ );
1160
+ return 0;
1161
+ }
1162
+
1163
+ function checkCommand(rootDir, logger, args) {
1164
+ const options = parseTargetedArgs("check", args, { write: false, backup: false });
1165
+ const { rules } = loadRules(rootDir);
1166
+ const plans = buildTargetPlans(rootDir, rules, options.targetIds);
1167
+ const summary = formatPlanSummary(plans);
1168
+
1169
+ logger.log("rules-doctor check");
1170
+ logger.log(`- root: ${rootDir}`);
1171
+ logger.log(`- selected targets: ${options.targetIds.join(", ")}`);
1172
+
1173
+ for (const plan of plans) {
1174
+ if (!plan.enabled) {
1175
+ logger.log(`- ${plan.targetId}: disabled (${plan.targetPathDisplay})`);
1176
+ continue;
1177
+ }
1178
+ logger.log(
1179
+ `- ${plan.targetId}: ${plan.changed ? "drift detected" : "in sync"} (${plan.targetPathDisplay})`,
1180
+ );
1181
+ }
1182
+
1183
+ if (options.diff) {
1184
+ for (const plan of plans) {
1185
+ if (!plan.enabled || !plan.changed) {
1186
+ continue;
1187
+ }
1188
+ logger.log(`\n# diff: ${plan.targetId} (${plan.targetPathDisplay})`);
1189
+ logger.log(renderSimpleDiff(plan.currentText, plan.desiredText));
1190
+ }
561
1191
  }
1192
+
1193
+ if (summary.changed === 0) {
1194
+ logger.log("Check complete: all selected targets are in sync.");
1195
+ return 0;
1196
+ }
1197
+
1198
+ logger.log(`Check failed: ${summary.changed} target file(s) need sync.`);
1199
+ return 1;
562
1200
  }
563
1201
 
564
1202
  function analyzeCommand(rootDir, logger, args) {
@@ -653,6 +1291,57 @@ function analyzeCommand(rootDir, logger, args) {
653
1291
  return 0;
654
1292
  }
655
1293
 
1294
+ function doctorCommand(rootDir, logger, args) {
1295
+ const options = parseDoctorArgs(args);
1296
+ const { rules, rulesExists, rulesFile } = loadRules(rootDir, { allowMissing: true });
1297
+ const issues = [];
1298
+
1299
+ logger.log("rules-doctor doctor");
1300
+ logger.log(`- root: ${rootDir}`);
1301
+ logger.log(`- rules file: ${rulesFile}`);
1302
+ logger.log(`- rules exists: ${rulesExists ? "yes" : "no (defaults assumed)"}`);
1303
+
1304
+ const pathToTargets = {};
1305
+ for (const adapter of ADAPTERS) {
1306
+ const target = getTargetConfig(rules, adapter);
1307
+ const absolutePath = resolveInRoot(rootDir, target.path);
1308
+ const exists = existsSync(absolutePath);
1309
+ const enabledText = target.enabled ? "enabled" : "disabled";
1310
+ logger.log(`- ${adapter.id}: ${enabledText}, path=${target.path}, file=${exists ? "found" : "missing"}`);
1311
+
1312
+ if (!target.enabled) {
1313
+ continue;
1314
+ }
1315
+
1316
+ const key = absolutePath;
1317
+ if (!pathToTargets[key]) {
1318
+ pathToTargets[key] = [];
1319
+ }
1320
+ pathToTargets[key].push(adapter.id);
1321
+ }
1322
+
1323
+ for (const path of Object.keys(pathToTargets)) {
1324
+ const ids = pathToTargets[path];
1325
+ if (ids.length > 1) {
1326
+ issues.push(`Multiple enabled targets map to the same file: ${ids.join(", ")} -> ${path}`);
1327
+ }
1328
+ }
1329
+
1330
+ logger.log("- Findings:");
1331
+ if (issues.length === 0) {
1332
+ logger.log("- No structural issues found.");
1333
+ return 0;
1334
+ }
1335
+ for (const issue of issues) {
1336
+ logger.log(`- ${issue}`);
1337
+ }
1338
+
1339
+ if (options.strict) {
1340
+ return 1;
1341
+ }
1342
+ return 0;
1343
+ }
1344
+
656
1345
  function targetsListCommand(logger) {
657
1346
  logger.log("Supported targets:");
658
1347
  for (const adapter of ADAPTERS) {
@@ -679,19 +1368,29 @@ function runCli(argv, options) {
679
1368
  }
680
1369
 
681
1370
  if (command === "init") {
682
- initCommand(rootDir, logger);
683
- return 0;
1371
+ return initCommand(rootDir, logger, rest);
1372
+ }
1373
+
1374
+ if (command === "preset") {
1375
+ return presetApplyCommand(rootDir, logger, rest);
684
1376
  }
685
1377
 
686
1378
  if (command === "sync") {
687
- syncCommand(rootDir, logger, rest);
688
- return 0;
1379
+ return syncCommand(rootDir, logger, rest);
1380
+ }
1381
+
1382
+ if (command === "check") {
1383
+ return checkCommand(rootDir, logger, rest);
689
1384
  }
690
1385
 
691
1386
  if (command === "analyze") {
692
1387
  return analyzeCommand(rootDir, logger, rest);
693
1388
  }
694
1389
 
1390
+ if (command === "doctor") {
1391
+ return doctorCommand(rootDir, logger, rest);
1392
+ }
1393
+
695
1394
  if (command === "targets") {
696
1395
  if (rest.length === 1 && rest[0] === "list") {
697
1396
  targetsListCommand(logger);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jazpiper/rules-doctor",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "Node.js CLI to sync shared coding rules across multiple agent/CLI targets",
5
5
  "license": "MIT",
6
6
  "bin": {
@@ -11,11 +11,15 @@
11
11
  "files": [
12
12
  "dist",
13
13
  "README.md",
14
+ "CHANGELOG.md",
14
15
  "LICENSE"
15
16
  ],
16
17
  "scripts": {
17
18
  "build": "node scripts/tsup.js src/index.js --clean --dts",
18
- "test": "npm run build && node --test"
19
+ "test": "npm run build && node --test",
20
+ "release:patch": "bash scripts/release.sh patch",
21
+ "release:minor": "bash scripts/release.sh minor",
22
+ "release:major": "bash scripts/release.sh major"
19
23
  },
20
24
  "keywords": [
21
25
  "cli",
@@ -23,10 +27,10 @@
23
27
  "rules",
24
28
  "claude",
25
29
  "codex",
30
+ "copilot",
26
31
  "cursor",
27
32
  "gemini",
28
- "opencode",
29
- "antigravity"
33
+ "opencode"
30
34
  ],
31
35
  "engines": {
32
36
  "node": ">=18"
@@ -1,12 +0,0 @@
1
- const { renderManagedRulesBody } = require("./common");
2
-
3
- module.exports = {
4
- id: "antigravity",
5
- name: "Antigravity CLI",
6
- description: "Generate GEMINI.md-compatible managed instruction file (inferred mapping).",
7
- defaultPath: "GEMINI.md",
8
- management: "full",
9
- render(rules) {
10
- return ["# GEMINI.md", "", renderManagedRulesBody(rules)].join("\n");
11
- },
12
- };