@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 +35 -26
- package/dist/cli/commands.js +1 -1
- package/dist/cli/handlers.js +1 -1
- package/dist/core/ConfigLoader.js +82 -4
- package/dist/core/FileSystemUtils.js +26 -4
- package/dist/core/SubagentsProcessor.js +68 -22
- package/dist/core/apply-engine.js +17 -14
- package/dist/lib.js +15 -4
- package/package.json +2 -1
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:
|
|
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
|
|
744
|
-
|
|
|
745
|
-
| Claude Code
|
|
746
|
-
| Cursor
|
|
747
|
-
| OpenAI Codex CLI
|
|
748
|
-
| GitHub Copilot
|
|
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
|
|
783
|
-
| --------------- |
|
|
784
|
-
| `tools` | string[]
|
|
785
|
-
| `model` | string
|
|
786
|
-
| `readonly` | boolean
|
|
787
|
-
| `is_background` | boolean
|
|
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
|
-
|
|
793
|
+
Subagent propagation is **disabled by default**. Opt in via CLI flag or `ruler.toml`:
|
|
794
794
|
|
|
795
795
|
```bash
|
|
796
|
-
ruler apply --
|
|
796
|
+
ruler apply --subagents # enable subagent propagation for one run
|
|
797
797
|
```
|
|
798
798
|
|
|
799
799
|
```toml
|
|
800
800
|
# .ruler/ruler.toml
|
|
801
|
-
[
|
|
802
|
-
enabled =
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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
|
-
#
|
|
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.**
|
|
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
|
|
package/dist/cli/commands.js
CHANGED
|
@@ -80,7 +80,7 @@ function run() {
|
|
|
80
80
|
})
|
|
81
81
|
.option('subagents', {
|
|
82
82
|
type: 'boolean',
|
|
83
|
-
description: 'Enable/disable subagents support (experimental, default:
|
|
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) => {
|
package/dist/cli/handlers.js
CHANGED
|
@@ -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 (
|
|
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
|
|
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
|
-
|
|
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
|
|
229
|
-
subagentsConfig.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
|
|
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
|
-
|
|
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,
|
|
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:
|
|
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,
|
|
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:
|
|
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,
|
|
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:
|
|
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,
|
|
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:
|
|
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
|
-
|
|
368
|
-
(
|
|
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)(
|
|
378
|
-
|
|
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)(
|
|
386
|
-
|
|
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
|
|
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
|
-
|
|
60
|
-
|
|
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
|
-
*
|
|
68
|
-
*
|
|
71
|
+
* Returns true when `.ruler/agents/*.md` should be concatenated into the
|
|
72
|
+
* generated top-level rule files. Defaults to false.
|
|
69
73
|
*/
|
|
70
|
-
|
|
71
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
-
:
|
|
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.
|
|
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",
|