@intellectronica/ruler 0.3.39 → 0.3.40

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
@@ -790,19 +790,24 @@ For GitHub Copilot, source `tools` (Claude vocabulary: `Read`, `Grep`, `Bash`,
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)
803
804
  ```
804
805
 
805
- CLI flags take precedence over `ruler.toml`, which takes precedence over the default (enabled).
806
+ > **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]`.
807
+
808
+ `[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`).
809
+
810
+ CLI flags take precedence over `ruler.toml`, which takes precedence over the default (disabled).
806
811
 
807
812
  ### Validation
808
813
 
@@ -832,7 +837,7 @@ Use `--no-gitignore` to opt out.
832
837
 
833
838
  ### Cleanup
834
839
 
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.
840
+ Subagent propagation does **not** currently have explicit `ruler revert` support. To remove generated subagent directories, set `[agents] 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.
836
841
 
837
842
  ### Example Workflow
838
843
 
@@ -850,10 +855,13 @@ readonly: true
850
855
  You review code changes for quality.
851
856
  EOF
852
857
 
853
- # 2. Apply (subagents enabled by default)
858
+ # 2. Opt subagents in (default is disabled — see [agents] section above)
859
+ echo -e "\n[agents]\nenabled = true" >> .ruler/ruler.toml
860
+
861
+ # 3. Apply
854
862
  ruler apply
855
863
 
856
- # 3. The subagent is now available in each agent's native location:
864
+ # 4. The subagent is now available in each agent's native location:
857
865
  # - Claude Code: .claude/agents/code-reviewer.md
858
866
  # - Cursor: .cursor/agents/code-reviewer.md
859
867
  # - Codex CLI: .codex/agents/code-reviewer.toml
@@ -862,7 +870,7 @@ ruler apply
862
870
 
863
871
  ### Limitations
864
872
 
865
- - **No explicit revert command.** Cleanup happens via `[subagents] enabled = false` on a subsequent `apply`.
873
+ - **No explicit revert command.** Cleanup happens via `[agents] enabled = false` on a subsequent `apply`.
866
874
  - **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
875
  - **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
876
 
@@ -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,21 @@ 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(['enabled', 'include_in_rules']);
58
80
  const rulerConfigSchema = zod_1.z.object({
59
81
  default_agents: zod_1.z.array(zod_1.z.string()).optional(),
60
- agents: zod_1.z.record(zod_1.z.string(), agentConfigSchema).optional(),
82
+ agents: zod_1.z
83
+ .object({
84
+ enabled: zod_1.z.boolean().optional(),
85
+ include_in_rules: zod_1.z.boolean().optional(),
86
+ })
87
+ .catchall(agentConfigSchema)
88
+ .optional(),
61
89
  mcp: zod_1.z
62
90
  .object({
63
91
  enabled: zod_1.z.boolean().optional(),
@@ -75,9 +103,14 @@ const rulerConfigSchema = zod_1.z.object({
75
103
  enabled: zod_1.z.boolean().optional(),
76
104
  })
77
105
  .optional(),
106
+ // Deprecated: kept in the schema only so that legacy `[subagents]` blocks
107
+ // are preserved through validation. The parser reads from here as a
108
+ // fallback when the new `[agents]` keys are absent and emits a one-time
109
+ // deprecation warning. Remove in the next minor release.
78
110
  subagents: zod_1.z
79
111
  .object({
80
112
  enabled: zod_1.z.boolean().optional(),
113
+ include_in_rules: zod_1.z.boolean().optional(),
81
114
  })
82
115
  .optional(),
83
116
  nested: zod_1.z.boolean().optional(),
@@ -155,6 +188,11 @@ async function loadConfig(options) {
155
188
  : {};
156
189
  const agentConfigs = {};
157
190
  for (const [name, section] of Object.entries(agentsSection)) {
191
+ // Reserved subagent-control keys live alongside per-agent records in
192
+ // the same `[agents]` table; skip them here so we only process actual
193
+ // coding-agent integrations as agent configs.
194
+ if (SUBAGENT_RESERVED_KEYS.has(name))
195
+ continue;
158
196
  if (section && typeof section === 'object') {
159
197
  const sectionObj = section;
160
198
  const cfg = {};
@@ -219,14 +257,39 @@ async function loadConfig(options) {
219
257
  if (typeof rawSkillsSection.enabled === 'boolean') {
220
258
  skillsConfig.enabled = rawSkillsSection.enabled;
221
259
  }
222
- const rawSubagentsSection = raw.subagents &&
260
+ // Subagent control lives under `[agents]` (alongside per-agent records).
261
+ // The reserved keys `enabled` and `include_in_rules` are pulled out here
262
+ // and surfaced internally as `LoadedConfig.subagents` for the rest of the
263
+ // codebase, which still uses the `Subagent*` naming.
264
+ //
265
+ // Backward-compatibility: the previous release used `[subagents]` for the
266
+ // same two keys. We still read those as a fallback when the matching
267
+ // `[agents]` key is absent, and emit a one-time deprecation warning so
268
+ // existing configs keep working while users migrate.
269
+ const rawLegacySubagentsSection = raw.subagents &&
223
270
  typeof raw.subagents === 'object' &&
224
271
  !Array.isArray(raw.subagents)
225
272
  ? raw.subagents
226
273
  : {};
274
+ const legacyHasContent = typeof rawLegacySubagentsSection.enabled === 'boolean' ||
275
+ typeof rawLegacySubagentsSection.include_in_rules === 'boolean';
276
+ if (legacyHasContent) {
277
+ warnLegacySubagentsSection();
278
+ }
227
279
  const subagentsConfig = {};
228
- if (typeof rawSubagentsSection.enabled === 'boolean') {
229
- subagentsConfig.enabled = rawSubagentsSection.enabled;
280
+ if (typeof agentsSection.enabled === 'boolean') {
281
+ subagentsConfig.enabled = agentsSection.enabled;
282
+ }
283
+ else if (typeof rawLegacySubagentsSection.enabled === 'boolean') {
284
+ subagentsConfig.enabled = rawLegacySubagentsSection.enabled;
285
+ }
286
+ if (typeof agentsSection.include_in_rules === 'boolean') {
287
+ subagentsConfig.include_in_rules =
288
+ agentsSection.include_in_rules;
289
+ }
290
+ else if (typeof rawLegacySubagentsSection.include_in_rules === 'boolean') {
291
+ subagentsConfig.include_in_rules =
292
+ rawLegacySubagentsSection.include_in_rules;
230
293
  }
231
294
  const nestedDefined = typeof raw.nested === 'boolean';
232
295
  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/') ||
@@ -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,20 @@ 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
66
72
  }
67
73
  /**
68
74
  * Applies ruler configurations for all supported AI agents.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@intellectronica/ruler",
3
- "version": "0.3.39",
3
+ "version": "0.3.40",
4
4
  "description": "Ruler — apply the same rules to all coding agents",
5
5
  "main": "dist/lib.js",
6
6
  "scripts": {