@intellectronica/ruler 0.3.39 → 0.3.41

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
@@ -245,7 +245,7 @@ The `apply` command looks for `.ruler/` in the current directory tree, reading t
245
245
  | `--no-backup` | Disable creation of `.bak` backup files. |
246
246
  | `--skills` | Enable skills support (experimental, default: enabled). |
247
247
  | `--no-skills` | Disable skills support. |
248
- | `--subagents` | Enable subagents support (experimental, default: enabled). |
248
+ | `--subagents` | Enable subagents support (experimental, default: disabled). |
249
249
  | `--no-subagents` | Disable subagents support. |
250
250
  | `--dry-run` | Preview changes without writing files. |
251
251
  | `--local-only` | Skip `$XDG_CONFIG_HOME` when looking for configuration. |
@@ -740,18 +740,18 @@ Ruler can distribute named, delegatable **subagents** from a single source of tr
740
740
 
741
741
  For agents with a native subagent primitive, Ruler writes one file per subagent into the target directory:
742
742
 
743
- | Agent | Target location | Format |
744
- | ----------------- | ------------------------------ | ------ |
745
- | Claude Code | `.claude/agents/<name>.md` | Markdown + YAML frontmatter |
746
- | Cursor | `.cursor/agents/<name>.md` | Markdown + YAML frontmatter |
747
- | OpenAI Codex CLI | `.codex/agents/<name>.toml` | TOML (one self-contained file per agent) |
748
- | GitHub Copilot | `.github/agents/<name>.md` | Markdown + YAML frontmatter |
743
+ | Agent | Target location | Format |
744
+ | ---------------- | ------------------------------------ | ---------------------------------------- |
745
+ | Claude Code | `.claude/agents/<relative-path>.md` | Markdown + YAML frontmatter |
746
+ | Cursor | `.cursor/agents/<relative-path>.md` | Markdown + YAML frontmatter |
747
+ | OpenAI Codex CLI | `.codex/agents/<relative-path>.toml` | TOML (one self-contained file per agent) |
748
+ | GitHub Copilot | `.github/agents/<relative-path>.md` | Markdown + YAML frontmatter |
749
749
 
750
750
  Other agents (Windsurf, RooCode, Aider, Gemini CLI, …) do not yet have a comparable native subagent primitive and are skipped with a warning. Subagent propagation will be added when those agents ship a comparable file format.
751
751
 
752
752
  ### Source Format
753
753
 
754
- Author each subagent as `.ruler/agents/<name>.md`:
754
+ Author each subagent as `.ruler/agents/<name>.md` (nested folders are supported and preserved in outputs):
755
755
 
756
756
  ```markdown
757
757
  ---
@@ -772,37 +772,43 @@ a structured verdict.
772
772
 
773
773
  **Required frontmatter fields:**
774
774
 
775
- | Field | Type | Notes |
776
- | ------------- | ------ | ----------------------------------------------------- |
775
+ | Field | Type | Notes |
776
+ | ------------- | ------ | -------------------------------------------------------------------------- |
777
777
  | `name` | string | Must match the filename stem (`code-reviewer.md` → `name: code-reviewer`). |
778
- | `description` | string | When the parent agent should delegate to this subagent. |
778
+ | `description` | string | When the parent agent should delegate to this subagent. |
779
779
 
780
780
  **Optional frontmatter fields:**
781
781
 
782
- | Field | Type | Used by | Default behavior |
783
- | --------------- | ---------------- | ------------------------------------------------ | --------------------------------------------- |
784
- | `tools` | string[] | Claude (verbatim), Copilot (mapped to aliases) | Cursor / Codex ignore; omitted if absent. |
785
- | `model` | string | All four targets | Cursor defaults to `inherit`; others omit. |
786
- | `readonly` | boolean | Cursor (verbatim), Codex (`sandbox_mode`), Copilot (`disable-model-invocation`) | Defaults to `false` for Cursor; omitted otherwise. |
787
- | `is_background` | boolean | Cursor only | Defaults to `false` for Cursor. |
782
+ | Field | Type | Used by | Default behavior |
783
+ | --------------- | -------- | ------------------------------------------------------------------------------- | -------------------------------------------------- |
784
+ | `tools` | string[] | Claude (verbatim), Copilot (mapped to aliases) | Cursor / Codex ignore; omitted if absent. |
785
+ | `model` | string | All four targets | Cursor defaults to `inherit`; others omit. |
786
+ | `readonly` | boolean | Cursor (verbatim), Codex (`sandbox_mode`), Copilot (`disable-model-invocation`) | Defaults to `false` for Cursor; omitted otherwise. |
787
+ | `is_background` | boolean | Cursor only | Defaults to `false` for Cursor. |
788
788
 
789
789
  For GitHub Copilot, source `tools` (Claude vocabulary: `Read`, `Grep`, `Bash`, …) are translated to Copilot's aliases (`read`, `search`, `execute`, …). Tools that do not have a Copilot equivalent are dropped silently on a normal apply; pass `--verbose` (or use `--dry-run` to preview) to see which tools were dropped.
790
790
 
791
791
  ### Configuration
792
792
 
793
- Subagents are enabled by default. Toggle them via CLI flag or `ruler.toml`:
793
+ Subagent propagation is **disabled by default**. Opt in via CLI flag or `ruler.toml`:
794
794
 
795
795
  ```bash
796
- ruler apply --no-subagents # disable subagent propagation for one run
796
+ ruler apply --subagents # enable subagent propagation for one run
797
797
  ```
798
798
 
799
799
  ```toml
800
800
  # .ruler/ruler.toml
801
- [subagents]
802
- enabled = false
801
+ [agents]
802
+ enabled = true
803
+ # include_in_rules = true # also append .ruler/agents/*.md into top-level CLAUDE.md / AGENTS.md (default: false)
804
+ # cleanup_orphaned = true # allow ruler to delete stale native subagent dirs (default: false)
803
805
  ```
804
806
 
805
- CLI flags take precedence over `ruler.toml`, which takes precedence over the default (enabled).
807
+ > **Note:** the previous release used `[subagents]` for these keys. `[subagents]` is still honored as a fallback with a deprecation warning, and will be removed in a future release. Please migrate to `[agents]`.
808
+
809
+ `[agents] enabled` controls only native subagent propagation from `.ruler/agents/`. It is independent from `[agents.<name>] enabled` (which toggles per-coding-agent output like `CLAUDE.md` / `AGENTS.md`).
810
+
811
+ CLI flags take precedence over `ruler.toml`, which takes precedence over the default (disabled).
806
812
 
807
813
  ### Validation
808
814
 
@@ -832,7 +838,7 @@ Use `--no-gitignore` to opt out.
832
838
 
833
839
  ### Cleanup
834
840
 
835
- Subagent propagation does **not** currently have explicit `ruler revert` support. To remove generated subagent directories, set `[subagents] enabled = false` (or pass `--no-subagents`) and run `ruler apply` once. Cleanup will run for all four targets even if no source `.ruler/agents/` directory exists.
841
+ Subagent propagation does **not** currently have explicit `ruler revert` support. By default, `ruler apply` is non-destructive and leaves existing native subagent directories untouched when subagents are disabled or missing. To allow automatic cleanup of stale generated directories, set `[agents] cleanup_orphaned = true`, then disable subagents (`[agents] enabled = false` or `--no-subagents`) and run `ruler apply`.
836
842
 
837
843
  ### Example Workflow
838
844
 
@@ -850,10 +856,13 @@ readonly: true
850
856
  You review code changes for quality.
851
857
  EOF
852
858
 
853
- # 2. Apply (subagents enabled by default)
859
+ # 2. Opt subagents in (default is disabled — see [agents] section above)
860
+ echo -e "\n[agents]\nenabled = true" >> .ruler/ruler.toml
861
+
862
+ # 3. Apply
854
863
  ruler apply
855
864
 
856
- # 3. The subagent is now available in each agent's native location:
865
+ # 4. The subagent is now available in each agent's native location:
857
866
  # - Claude Code: .claude/agents/code-reviewer.md
858
867
  # - Cursor: .cursor/agents/code-reviewer.md
859
868
  # - Codex CLI: .codex/agents/code-reviewer.toml
@@ -862,7 +871,7 @@ ruler apply
862
871
 
863
872
  ### Limitations
864
873
 
865
- - **No explicit revert command.** Cleanup happens via `[subagents] enabled = false` on a subsequent `apply`.
874
+ - **No explicit revert command.** Optional cleanup is available via `[agents] cleanup_orphaned = true` and a subsequent `apply`.
866
875
  - **Atomic replace, not merge.** Ruler regenerates each agent's subagent directory from the source on every apply. Manual edits to generated files will be overwritten.
867
876
  - **No support yet for agents without a native subagent primitive.** Windsurf, RooCode, Aider, Gemini CLI, and others are skipped with a warning. Propagation will be added when those agents ship a comparable file format.
868
877
 
@@ -80,7 +80,7 @@ function run() {
80
80
  })
81
81
  .option('subagents', {
82
82
  type: 'boolean',
83
- description: 'Enable/disable subagents support (experimental, default: enabled)',
83
+ description: 'Enable/disable subagents support (experimental, default: disabled)',
84
84
  });
85
85
  }, handlers_1.applyHandler)
86
86
  .command('init', 'Scaffold a .ruler directory with default files', (y) => {
@@ -114,7 +114,7 @@ async function applyHandler(argv) {
114
114
  else {
115
115
  skillsEnabled = undefined; // Let config/default decide
116
116
  }
117
- // Determine subagents preference: CLI > TOML > Default (enabled)
117
+ // Determine subagents preference: CLI > TOML > Default (disabled)
118
118
  let subagentsEnabled;
119
119
  if (argv.subagents !== undefined) {
120
120
  subagentsEnabled = argv.subagents;
@@ -33,6 +33,7 @@ var __importStar = (this && this.__importStar) || (function () {
33
33
  };
34
34
  })();
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports._resetLegacySubagentsWarningForTests = _resetLegacySubagentsWarningForTests;
36
37
  exports.loadConfig = loadConfig;
37
38
  const fs_1 = require("fs");
38
39
  const path = __importStar(require("path"));
@@ -40,6 +41,21 @@ const os = __importStar(require("os"));
40
41
  const toml_1 = require("@iarna/toml");
41
42
  const zod_1 = require("zod");
42
43
  const constants_1 = require("../constants");
44
+ // One-shot guard so the deprecation message fires once per process even when
45
+ // `loadConfig` is called multiple times (e.g. nested mode walks every
46
+ // `.ruler` directory).
47
+ let _legacySubagentsWarned = false;
48
+ function warnLegacySubagentsSection() {
49
+ if (_legacySubagentsWarned)
50
+ return;
51
+ _legacySubagentsWarned = true;
52
+ (0, constants_1.logWarn)('`[subagents]` is deprecated; rename it to `[agents]` in your ruler.toml. ' +
53
+ 'The legacy section is honored for now and will be removed in a future release.');
54
+ }
55
+ /** Test helper — re-arms the deprecation guard so suites can assert it fires. */
56
+ function _resetLegacySubagentsWarningForTests() {
57
+ _legacySubagentsWarned = false;
58
+ }
43
59
  const mcpConfigSchema = zod_1.z
44
60
  .object({
45
61
  enabled: zod_1.z.boolean().optional(),
@@ -55,9 +71,26 @@ const agentConfigSchema = zod_1.z
55
71
  mcp: mcpConfigSchema,
56
72
  })
57
73
  .optional();
74
+ // `[agents]` is a heterogeneous table that holds two unrelated kinds of keys:
75
+ // - reserved subagent-control booleans (`enabled`, `include_in_rules`)
76
+ // - one nested table per coding-agent integration (`[agents.claude]`, etc.)
77
+ // Reserved keys are validated by the object shape; everything else falls
78
+ // through `catchall` and is treated as a per-agent config record.
79
+ const SUBAGENT_RESERVED_KEYS = new Set([
80
+ 'enabled',
81
+ 'include_in_rules',
82
+ 'cleanup_orphaned',
83
+ ]);
58
84
  const rulerConfigSchema = zod_1.z.object({
59
85
  default_agents: zod_1.z.array(zod_1.z.string()).optional(),
60
- agents: zod_1.z.record(zod_1.z.string(), agentConfigSchema).optional(),
86
+ agents: zod_1.z
87
+ .object({
88
+ enabled: zod_1.z.boolean().optional(),
89
+ include_in_rules: zod_1.z.boolean().optional(),
90
+ cleanup_orphaned: zod_1.z.boolean().optional(),
91
+ })
92
+ .catchall(agentConfigSchema)
93
+ .optional(),
61
94
  mcp: zod_1.z
62
95
  .object({
63
96
  enabled: zod_1.z.boolean().optional(),
@@ -75,9 +108,15 @@ const rulerConfigSchema = zod_1.z.object({
75
108
  enabled: zod_1.z.boolean().optional(),
76
109
  })
77
110
  .optional(),
111
+ // Deprecated: kept in the schema only so that legacy `[subagents]` blocks
112
+ // are preserved through validation. The parser reads from here as a
113
+ // fallback when the new `[agents]` keys are absent and emits a one-time
114
+ // deprecation warning. Remove in the next minor release.
78
115
  subagents: zod_1.z
79
116
  .object({
80
117
  enabled: zod_1.z.boolean().optional(),
118
+ include_in_rules: zod_1.z.boolean().optional(),
119
+ cleanup_orphaned: zod_1.z.boolean().optional(),
81
120
  })
82
121
  .optional(),
83
122
  nested: zod_1.z.boolean().optional(),
@@ -155,6 +194,11 @@ async function loadConfig(options) {
155
194
  : {};
156
195
  const agentConfigs = {};
157
196
  for (const [name, section] of Object.entries(agentsSection)) {
197
+ // Reserved subagent-control keys live alongside per-agent records in
198
+ // the same `[agents]` table; skip them here so we only process actual
199
+ // coding-agent integrations as agent configs.
200
+ if (SUBAGENT_RESERVED_KEYS.has(name))
201
+ continue;
158
202
  if (section && typeof section === 'object') {
159
203
  const sectionObj = section;
160
204
  const cfg = {};
@@ -219,14 +263,48 @@ async function loadConfig(options) {
219
263
  if (typeof rawSkillsSection.enabled === 'boolean') {
220
264
  skillsConfig.enabled = rawSkillsSection.enabled;
221
265
  }
222
- const rawSubagentsSection = raw.subagents &&
266
+ // Subagent control lives under `[agents]` (alongside per-agent records).
267
+ // The reserved keys `enabled` and `include_in_rules` are pulled out here
268
+ // and surfaced internally as `LoadedConfig.subagents` for the rest of the
269
+ // codebase, which still uses the `Subagent*` naming.
270
+ //
271
+ // Backward-compatibility: the previous release used `[subagents]` for the
272
+ // same two keys. We still read those as a fallback when the matching
273
+ // `[agents]` key is absent, and emit a one-time deprecation warning so
274
+ // existing configs keep working while users migrate.
275
+ const rawLegacySubagentsSection = raw.subagents &&
223
276
  typeof raw.subagents === 'object' &&
224
277
  !Array.isArray(raw.subagents)
225
278
  ? raw.subagents
226
279
  : {};
280
+ const legacyHasContent = typeof rawLegacySubagentsSection.enabled === 'boolean' ||
281
+ typeof rawLegacySubagentsSection.include_in_rules === 'boolean' ||
282
+ typeof rawLegacySubagentsSection.cleanup_orphaned === 'boolean';
283
+ if (legacyHasContent) {
284
+ warnLegacySubagentsSection();
285
+ }
227
286
  const subagentsConfig = {};
228
- if (typeof rawSubagentsSection.enabled === 'boolean') {
229
- subagentsConfig.enabled = rawSubagentsSection.enabled;
287
+ if (typeof agentsSection.enabled === 'boolean') {
288
+ subagentsConfig.enabled = agentsSection.enabled;
289
+ }
290
+ else if (typeof rawLegacySubagentsSection.enabled === 'boolean') {
291
+ subagentsConfig.enabled = rawLegacySubagentsSection.enabled;
292
+ }
293
+ if (typeof agentsSection.include_in_rules === 'boolean') {
294
+ subagentsConfig.include_in_rules =
295
+ agentsSection.include_in_rules;
296
+ }
297
+ else if (typeof rawLegacySubagentsSection.include_in_rules === 'boolean') {
298
+ subagentsConfig.include_in_rules =
299
+ rawLegacySubagentsSection.include_in_rules;
300
+ }
301
+ if (typeof agentsSection.cleanup_orphaned === 'boolean') {
302
+ subagentsConfig.cleanup_orphaned =
303
+ agentsSection.cleanup_orphaned;
304
+ }
305
+ else if (typeof rawLegacySubagentsSection.cleanup_orphaned === 'boolean') {
306
+ subagentsConfig.cleanup_orphaned =
307
+ rawLegacySubagentsSection.cleanup_orphaned;
230
308
  }
231
309
  const nestedDefined = typeof raw.nested === 'boolean';
232
310
  const nested = nestedDefined ? raw.nested : false;
@@ -44,6 +44,7 @@ const fs_1 = require("fs");
44
44
  const path = __importStar(require("path"));
45
45
  const os = __importStar(require("os"));
46
46
  const constants_1 = require("../constants");
47
+ const SUBAGENTS_DIR_NAME = path.basename(constants_1.RULER_SUBAGENTS_PATH);
47
48
  /**
48
49
  * Gets the XDG config directory path, falling back to ~/.config if XDG_CONFIG_HOME is not set.
49
50
  */
@@ -93,9 +94,18 @@ async function findRulerDir(startPath, checkGlobal = true) {
93
94
  /**
94
95
  * Recursively reads all Markdown (.md) files in rulerDir, returning their paths and contents.
95
96
  * Files are sorted alphabetically by path.
97
+ *
98
+ * `.ruler/skills/` is always skipped (skills are propagated separately).
99
+ * `.ruler/agents/` is skipped unless `options.includeAgents` is `true`.
96
100
  */
97
- async function readMarkdownFiles(rulerDir) {
101
+ async function readMarkdownFiles(rulerDir, options = {}) {
98
102
  const mdFiles = [];
103
+ const includeAgents = options.includeAgents === true;
104
+ // Tracks whether we skipped a `.ruler/agents` subtree so the root-AGENTS.md
105
+ // fallback below still recognises ruler content as present and does not
106
+ // resurrect a previously generated root AGENTS.md (which may itself contain
107
+ // the very agent docs we're now excluding).
108
+ let sawExcludedAgents = false;
99
109
  // Gather all markdown files (recursive) first
100
110
  async function walk(dir) {
101
111
  const entries = await fs_1.promises.readdir(dir, { withFileTypes: true });
@@ -115,13 +125,22 @@ async function readMarkdownFiles(rulerDir) {
115
125
  }
116
126
  }
117
127
  if (isDir) {
118
- // Skip .ruler/skills; skills are propagated separately and should not be concatenated
119
128
  const relativeFromRoot = path.relative(rulerDir, fullPath);
129
+ // Skip .ruler/skills; skills are propagated separately and should not be concatenated
120
130
  const isSkillsDir = relativeFromRoot === constants_1.SKILLS_DIR ||
121
131
  relativeFromRoot.startsWith(`${constants_1.SKILLS_DIR}${path.sep}`);
122
132
  if (isSkillsDir) {
123
133
  continue;
124
134
  }
135
+ // Skip .ruler/agents unless explicitly opted in via subagents.include_in_rules.
136
+ // Subagents are propagated separately to native locations and should not pollute
137
+ // the top-level rule concatenation by default.
138
+ const isAgentsDir = relativeFromRoot === SUBAGENTS_DIR_NAME ||
139
+ relativeFromRoot.startsWith(`${SUBAGENTS_DIR_NAME}${path.sep}`);
140
+ if (isAgentsDir && !includeAgents) {
141
+ sawExcludedAgents = true;
142
+ continue;
143
+ }
125
144
  await walk(fullPath);
126
145
  }
127
146
  else if (isFile && entry.name.endsWith('.md')) {
@@ -170,9 +189,12 @@ async function readMarkdownFiles(rulerDir) {
170
189
  const stat = await fs_1.promises.stat(rootAgentsPath);
171
190
  if (stat.isFile()) {
172
191
  const content = await fs_1.promises.readFile(rootAgentsPath, 'utf8');
173
- // Check if this is a generated file and we have other .ruler files
192
+ // Check if this is a generated file and we have other .ruler files.
193
+ // `sawExcludedAgents` counts as "ruler content present" so a stale
194
+ // generated root AGENTS.md isn't resurrected when `.ruler/agents` was
195
+ // the only source under `.ruler` and is now being skipped.
174
196
  const isGenerated = content.startsWith('<!-- Generated by Ruler -->');
175
- const hasRulerFiles = others.length > 0 || primaryFile !== null;
197
+ const hasRulerFiles = others.length > 0 || primaryFile !== null || sawExcludedAgents;
176
198
  // Additional check: if AGENTS.md contains ruler source comments and we have ruler files,
177
199
  // it's likely a corrupted generated file that should be skipped
178
200
  const containsRulerSources = content.includes('<!-- Source: .ruler/') ||
@@ -62,16 +62,13 @@ async function discoverSubagents(projectRoot) {
62
62
  catch {
63
63
  return { subagents: [], warnings: [] };
64
64
  }
65
- const entries = await fs.readdir(dir, { withFileTypes: true });
66
- const mdFiles = entries
67
- .filter((entry) => entry.isFile() && entry.name.endsWith('.md'))
68
- .map((entry) => path.join(dir, entry.name))
69
- .sort();
65
+ const mdFiles = await listMarkdownFilesRecursive(dir);
70
66
  const subagents = [];
71
67
  const warnings = [];
72
68
  for (const filePath of mdFiles) {
73
69
  const info = await (0, SubagentsUtils_1.loadSubagentFile)(filePath);
74
70
  if (info.valid) {
71
+ info.sourceRelativePath = path.relative(dir, filePath);
75
72
  subagents.push(info);
76
73
  }
77
74
  else if (info.error) {
@@ -80,6 +77,22 @@ async function discoverSubagents(projectRoot) {
80
77
  }
81
78
  return { subagents, warnings };
82
79
  }
80
+ async function listMarkdownFilesRecursive(dir) {
81
+ const results = [];
82
+ const entries = await fs.readdir(dir, { withFileTypes: true });
83
+ for (const entry of entries) {
84
+ const fullPath = path.join(dir, entry.name);
85
+ if (entry.isDirectory()) {
86
+ const nested = await listMarkdownFilesRecursive(fullPath);
87
+ results.push(...nested);
88
+ continue;
89
+ }
90
+ if (entry.isFile() && entry.name.endsWith('.md')) {
91
+ results.push(fullPath);
92
+ }
93
+ }
94
+ return results.sort();
95
+ }
83
96
  const SUBAGENT_TARGET_TO_IDENTIFIERS = new Map([
84
97
  ['claude', ['claude']],
85
98
  ['cursor', ['cursor']],
@@ -167,7 +180,9 @@ async function writeAgentsDirectoryAtomic(targetDir, files) {
167
180
  await fs.mkdir(tempDir, { recursive: true });
168
181
  try {
169
182
  for (const { name, content } of files) {
170
- await fs.writeFile(path.join(tempDir, name), content, 'utf8');
183
+ const outputPath = path.join(tempDir, name);
184
+ await fs.mkdir(path.dirname(outputPath), { recursive: true });
185
+ await fs.writeFile(outputPath, content, 'utf8');
171
186
  }
172
187
  try {
173
188
  await fs.rm(targetDir, { recursive: true, force: true });
@@ -187,6 +202,19 @@ async function writeAgentsDirectoryAtomic(targetDir, files) {
187
202
  throw error;
188
203
  }
189
204
  }
205
+ function getSourceRelativeMdPath(sub) {
206
+ const fromSource = sub.sourceRelativePath;
207
+ if (typeof fromSource === 'string' &&
208
+ fromSource.length > 0 &&
209
+ !path.isAbsolute(fromSource) &&
210
+ !fromSource.startsWith('..')) {
211
+ return fromSource;
212
+ }
213
+ return `${sub.name}.md`;
214
+ }
215
+ function withExtension(filePath, ext) {
216
+ return filePath.replace(/\.md$/i, ext);
217
+ }
190
218
  function buildClaudeFile(sub) {
191
219
  const fm = sub.frontmatter;
192
220
  const meta = {
@@ -276,10 +304,10 @@ async function propagateSubagentsForClaude(projectRoot, subagents, options) {
276
304
  return [];
277
305
  const targetDir = path.join(projectRoot, constants_1.CLAUDE_SUBAGENTS_PATH);
278
306
  if (options.dryRun) {
279
- return subagents.map((s) => `Write ${path.join(constants_1.CLAUDE_SUBAGENTS_PATH, `${s.name}.md`)}`);
307
+ return subagents.map((s) => `Write ${path.join(constants_1.CLAUDE_SUBAGENTS_PATH, getSourceRelativeMdPath(s))}`);
280
308
  }
281
309
  const files = subagents.map((s) => ({
282
- name: `${s.name}.md`,
310
+ name: getSourceRelativeMdPath(s),
283
311
  content: buildClaudeFile(s),
284
312
  }));
285
313
  await writeAgentsDirectoryAtomic(targetDir, files);
@@ -290,10 +318,10 @@ async function propagateSubagentsForCursor(projectRoot, subagents, options) {
290
318
  return [];
291
319
  const targetDir = path.join(projectRoot, constants_1.CURSOR_SUBAGENTS_PATH);
292
320
  if (options.dryRun) {
293
- return subagents.map((s) => `Write ${path.join(constants_1.CURSOR_SUBAGENTS_PATH, `${s.name}.md`)}`);
321
+ return subagents.map((s) => `Write ${path.join(constants_1.CURSOR_SUBAGENTS_PATH, getSourceRelativeMdPath(s))}`);
294
322
  }
295
323
  const files = subagents.map((s) => ({
296
- name: `${s.name}.md`,
324
+ name: getSourceRelativeMdPath(s),
297
325
  content: buildCursorFile(s),
298
326
  }));
299
327
  await writeAgentsDirectoryAtomic(targetDir, files);
@@ -304,10 +332,10 @@ async function propagateSubagentsForCodex(projectRoot, subagents, options) {
304
332
  return [];
305
333
  const targetDir = path.join(projectRoot, constants_1.CODEX_SUBAGENTS_PATH);
306
334
  if (options.dryRun) {
307
- return subagents.map((s) => `Write ${path.join(constants_1.CODEX_SUBAGENTS_PATH, `${s.name}.toml`)}`);
335
+ return subagents.map((s) => `Write ${path.join(constants_1.CODEX_SUBAGENTS_PATH, withExtension(getSourceRelativeMdPath(s), '.toml'))}`);
308
336
  }
309
337
  const files = subagents.map((s) => ({
310
- name: `${s.name}.toml`,
338
+ name: withExtension(getSourceRelativeMdPath(s), '.toml'),
311
339
  content: buildCodexFile(s),
312
340
  }));
313
341
  await writeAgentsDirectoryAtomic(targetDir, files);
@@ -325,12 +353,12 @@ async function propagateSubagentsForCopilot(projectRoot, subagents, options) {
325
353
  // emits when dryRun is true so users previewing a change can see
326
354
  // which tools would be dropped before it actually happens.
327
355
  buildCopilotFile(s, true, verbose);
328
- planLines.push(`Write ${path.join(constants_1.COPILOT_SUBAGENTS_PATH, `${s.name}.md`)}`);
356
+ planLines.push(`Write ${path.join(constants_1.COPILOT_SUBAGENTS_PATH, getSourceRelativeMdPath(s))}`);
329
357
  }
330
358
  return planLines;
331
359
  }
332
360
  const files = subagents.map((s) => ({
333
- name: `${s.name}.md`,
361
+ name: getSourceRelativeMdPath(s),
334
362
  content: buildCopilotFile(s, false, verbose).content,
335
363
  }));
336
364
  await writeAgentsDirectoryAtomic(targetDir, files);
@@ -363,10 +391,24 @@ async function cleanupAllSubagentsDirectories(projectRoot, dryRun, verbose) {
363
391
  /* ------------------------------------------------------------------ */
364
392
  /* Orchestrator */
365
393
  /* ------------------------------------------------------------------ */
366
- async function propagateSubagents(projectRoot, agents, subagentsEnabled, verbose, dryRun) {
367
- if (!subagentsEnabled) {
368
- (0, constants_1.logVerboseInfo)('Subagents support disabled, cleaning up subagent directories', verbose, dryRun);
394
+ async function propagateSubagents(projectRoot, agents, subagentsEnabled, cleanupOrphaned, verbose, dryRun) {
395
+ const maybeCleanupAllSubagentsDirectories = async () => {
396
+ if (!cleanupOrphaned) {
397
+ (0, constants_1.logVerboseInfo)('Subagent cleanup skipped (set [agents] cleanup_orphaned = true to enable directory cleanup)', verbose, dryRun);
398
+ return;
399
+ }
369
400
  await cleanupAllSubagentsDirectories(projectRoot, dryRun, verbose);
401
+ };
402
+ const maybeCleanupSubagentsDir = async (relPath) => {
403
+ if (!cleanupOrphaned)
404
+ return;
405
+ await cleanupSubagentsDir(projectRoot, relPath, dryRun, verbose);
406
+ };
407
+ if (!subagentsEnabled) {
408
+ (0, constants_1.logVerboseInfo)(cleanupOrphaned
409
+ ? 'Subagents support disabled, cleaning up subagent directories'
410
+ : 'Subagents support disabled, leaving existing subagent directories unchanged', verbose, dryRun);
411
+ await maybeCleanupAllSubagentsDirectories();
370
412
  return;
371
413
  }
372
414
  const sourceDir = path.join(projectRoot, constants_1.RULER_SUBAGENTS_PATH);
@@ -374,16 +416,20 @@ async function propagateSubagents(projectRoot, agents, subagentsEnabled, verbose
374
416
  await fs.access(sourceDir);
375
417
  }
376
418
  catch {
377
- (0, constants_1.logVerboseInfo)('No .ruler/agents directory found, cleaning up any stale managed subagent directories', verbose, dryRun);
378
- await cleanupAllSubagentsDirectories(projectRoot, dryRun, verbose);
419
+ (0, constants_1.logVerboseInfo)(cleanupOrphaned
420
+ ? 'No .ruler/agents directory found, cleaning up any stale managed subagent directories'
421
+ : 'No .ruler/agents directory found; leaving existing subagent directories unchanged', verbose, dryRun);
422
+ await maybeCleanupAllSubagentsDirectories();
379
423
  return;
380
424
  }
381
425
  const { subagents, warnings } = await discoverSubagents(projectRoot);
382
426
  for (const w of warnings)
383
427
  (0, constants_1.logWarn)(w, dryRun);
384
428
  if (subagents.length === 0) {
385
- (0, constants_1.logVerboseInfo)('No valid subagents found in .ruler/agents; cleaning up any stale managed subagent directories', verbose, dryRun);
386
- await cleanupAllSubagentsDirectories(projectRoot, dryRun, verbose);
429
+ (0, constants_1.logVerboseInfo)(cleanupOrphaned
430
+ ? 'No valid subagents found in .ruler/agents; cleaning up any stale managed subagent directories'
431
+ : 'No valid subagents found in .ruler/agents; leaving existing subagent directories unchanged', verbose, dryRun);
432
+ await maybeCleanupAllSubagentsDirectories();
387
433
  return;
388
434
  }
389
435
  (0, constants_1.logVerboseInfo)(`Discovered ${subagents.length} subagent(s)`, verbose, dryRun);
@@ -401,7 +447,7 @@ async function propagateSubagents(projectRoot, agents, subagentsEnabled, verbose
401
447
  const allTargets = ['claude', 'cursor', 'codex', 'copilot'];
402
448
  for (const target of allTargets) {
403
449
  if (!targets.has(target)) {
404
- await cleanupSubagentsDir(projectRoot, SUBAGENT_TARGET_PATHS[target], dryRun, verbose);
450
+ await maybeCleanupSubagentsDir(SUBAGENT_TARGET_PATHS[target]);
405
451
  }
406
452
  }
407
453
  if (supporting.length === 0) {
@@ -56,25 +56,23 @@ const constants_1 = require("../constants");
56
56
  async function loadNestedConfigurations(projectRoot, configPath, localOnly, resolvedNested) {
57
57
  const { dirs: rulerDirs } = await findRulerDirectories(projectRoot, localOnly, true);
58
58
  const results = [];
59
- const rulerDirConfigs = await processIndependentRulerDirs(rulerDirs);
60
- for (const { rulerDir, files } of rulerDirConfigs) {
59
+ // Load config first so we know whether `.ruler/agents/` should be included
60
+ // in the rule concatenation for each directory.
61
+ for (const rulerDir of rulerDirs) {
61
62
  const config = await loadConfigForRulerDir(rulerDir, configPath, resolvedNested);
63
+ const files = await FileSystemUtils.readMarkdownFiles(rulerDir, {
64
+ includeAgents: shouldIncludeAgentsInRules(config),
65
+ });
62
66
  results.push(await createHierarchicalConfiguration(rulerDir, files, config, configPath));
63
67
  }
64
68
  return results;
65
69
  }
66
70
  /**
67
- * Processes each .ruler directory independently, returning configuration for each.
68
- * Each .ruler directory gets its own rules (not merged with others).
71
+ * Returns true when `.ruler/agents/*.md` should be concatenated into the
72
+ * generated top-level rule files. Defaults to false.
69
73
  */
70
- async function processIndependentRulerDirs(rulerDirs) {
71
- const results = [];
72
- // Process each .ruler directory independently
73
- for (const rulerDir of rulerDirs) {
74
- const files = await FileSystemUtils.readMarkdownFiles(rulerDir);
75
- results.push({ rulerDir, files });
76
- }
77
- return results;
74
+ function shouldIncludeAgentsInRules(config) {
75
+ return config.subagents?.include_in_rules === true;
78
76
  }
79
77
  async function createHierarchicalConfiguration(rulerDir, files, config, cliConfigPath) {
80
78
  await warnAboutLegacyMcpJson(rulerDir);
@@ -146,6 +144,8 @@ function cloneLoadedConfig(config) {
146
144
  cliAgents: config.cliAgents ? [...config.cliAgents] : undefined,
147
145
  mcp: config.mcp ? { ...config.mcp } : undefined,
148
146
  gitignore: config.gitignore ? { ...config.gitignore } : undefined,
147
+ skills: config.skills ? { ...config.skills } : undefined,
148
+ subagents: config.subagents ? { ...config.subagents } : undefined,
149
149
  nested: config.nested,
150
150
  nestedDefined: config.nestedDefined,
151
151
  };
@@ -203,8 +203,11 @@ async function loadSingleConfiguration(projectRoot, configPath, localOnly) {
203
203
  projectRoot,
204
204
  configPath,
205
205
  });
206
- // Read rule files
207
- const files = await FileSystemUtils.readMarkdownFiles(rulerDirs[0]);
206
+ // Read rule files. `.ruler/agents/` is only included when
207
+ // `[agents] include_in_rules = true`.
208
+ const files = await FileSystemUtils.readMarkdownFiles(rulerDirs[0], {
209
+ includeAgents: shouldIncludeAgentsInRules(config),
210
+ });
208
211
  // Concatenate rules
209
212
  const concatenatedRules = (0, RuleProcessor_1.concatenateRules)(files, path.dirname(primaryDir));
210
213
  // Load unified config to get merged MCP configuration
package/dist/lib.js CHANGED
@@ -55,14 +55,23 @@ function resolveSkillsEnabled(cliFlag, configSetting) {
55
55
  }
56
56
  /**
57
57
  * Resolves subagents enabled state based on precedence:
58
- * CLI flag > ruler.toml > default (enabled).
58
+ * CLI flag > ruler.toml > default (disabled).
59
+ *
60
+ * When neither `[agents] enabled` (nor the legacy `[subagents] enabled`)
61
+ * nor a CLI flag is provided, propagation is disabled by default per spec.
62
+ * Subagent definitions are an opt-in feature — propagating them silently
63
+ * could leak runtime prompts into native subagent locations on projects
64
+ * that never intended to use the feature.
59
65
  */
60
66
  function resolveSubagentsEnabled(cliFlag, configSetting) {
61
67
  return cliFlag !== undefined
62
68
  ? cliFlag
63
69
  : configSetting !== undefined
64
70
  ? configSetting
65
- : true; // default to enabled
71
+ : false; // default to disabled — see spec: subagents must opt in
72
+ }
73
+ function resolveSubagentsCleanupOrphaned(configSetting) {
74
+ return configSetting === true;
66
75
  }
67
76
  /**
68
77
  * Applies ruler configurations for all supported AI agents.
@@ -113,12 +122,13 @@ async function applyAllAgentConfigs(projectRoot, includedAgents, configPath, cli
113
122
  }
114
123
  // Propagate subagents (mirrors skills handling for nested mode).
115
124
  const subagentsEnabledResolved = resolveSubagentsEnabled(subagentsEnabled, rootConfig.subagents?.enabled);
125
+ const subagentsCleanupOrphaned = resolveSubagentsCleanupOrphaned(rootConfig.subagents?.cleanup_orphaned);
116
126
  {
117
127
  const { propagateSubagents } = await Promise.resolve().then(() => __importStar(require('./core/SubagentsProcessor')));
118
128
  for (const configEntry of hierarchicalConfigs) {
119
129
  const nestedRoot = path.dirname(configEntry.rulerDir);
120
130
  (0, constants_1.logVerbose)(`Propagating subagents for nested directory: ${nestedRoot}`, verbose);
121
- await propagateSubagents(nestedRoot, selectedAgents, subagentsEnabledResolved, verbose, dryRun);
131
+ await propagateSubagents(nestedRoot, selectedAgents, subagentsEnabledResolved, subagentsCleanupOrphaned, verbose, dryRun);
122
132
  }
123
133
  }
124
134
  generatedPaths = await (0, apply_engine_1.processHierarchicalConfigurations)(selectedAgents, hierarchicalConfigs, verbose, dryRun, cliMcpEnabled, cliMcpStrategy, backup);
@@ -140,9 +150,10 @@ async function applyAllAgentConfigs(projectRoot, includedAgents, configPath, cli
140
150
  }
141
151
  // Propagate subagents (mirrors skills handling).
142
152
  const subagentsEnabledResolvedSingle = resolveSubagentsEnabled(subagentsEnabled, singleConfig.config.subagents?.enabled);
153
+ const subagentsCleanupOrphanedSingle = resolveSubagentsCleanupOrphaned(singleConfig.config.subagents?.cleanup_orphaned);
143
154
  {
144
155
  const { propagateSubagents } = await Promise.resolve().then(() => __importStar(require('./core/SubagentsProcessor')));
145
- await propagateSubagents(projectRoot, selectedAgents, subagentsEnabledResolvedSingle, verbose, dryRun);
156
+ await propagateSubagents(projectRoot, selectedAgents, subagentsEnabledResolvedSingle, subagentsCleanupOrphanedSingle, verbose, dryRun);
146
157
  }
147
158
  generatedPaths = await (0, apply_engine_1.processSingleConfiguration)(selectedAgents, singleConfig, projectRoot, verbose, dryRun, cliMcpEnabled, cliMcpStrategy, backup);
148
159
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@intellectronica/ruler",
3
- "version": "0.3.39",
3
+ "version": "0.3.41",
4
4
  "description": "Ruler — apply the same rules to all coding agents",
5
5
  "main": "dist/lib.js",
6
6
  "scripts": {
@@ -58,6 +58,7 @@
58
58
  "eslint-config-prettier": "^10.1.8",
59
59
  "eslint-plugin-prettier": "^5.5.4",
60
60
  "jest": "^29.7.0",
61
+ "jest-util": "^29.7.0",
61
62
  "prettier": "^3.6.2",
62
63
  "ts-jest": "^29.4.5",
63
64
  "typescript": "^5.9.3",