@rmrdeveloper/sideroom-pi 8.7.0 → 8.8.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/README.md CHANGED
@@ -52,6 +52,7 @@ Sideroom answers each one with a small, opinionated surface:
52
52
  | Invisible work | `sideroom_todo` — a live board above the editor, always one step in focus |
53
53
  | Repo pollution | Session-branch state — the board and history die with the session, never with a commit |
54
54
  | Style drift | A pre-edit gate + the on-demand `sideroom-guidelines` skill |
55
+ | Invisible monorepo skills | `monorepo-skills` — trusted child folders join Pi's available skill list |
55
56
  | Vague plans | `sideroom-grill` — an interview that settles the words before the work |
56
57
  | Unapplied guidelines | `sideroom_rules` — mechanical checks that block or flag the lines you add |
57
58
  | Unclear answers | `sideroom_persona` — one voice: direct, plain, and free of jargon it invented |
@@ -86,6 +87,10 @@ wall of tool calls, it:
86
87
  blocked. The guides cover guard clauses, braced conditionals, fail-fast
87
88
  errors, focused units, and all 19 canonical rules without dumping them into
88
89
  the system prompt.
90
+ - **Finds the skills below the working directory.** In a trusted monorepo, Pi
91
+ normally ignores project skills stored under child folders. Sideroom scans
92
+ three levels down, adds their `.pi/skills` and `.agents/skills`, and leaves
93
+ duplicate-name warnings to Pi.
89
94
  - **Cannot sneak sloppy lines past the gate.** Each write and edit is checked
90
95
  against the mechanical rules on the added lines only: braceless
91
96
  conditionals and swallowed errors block the mutation; vague names, stale
@@ -154,6 +159,19 @@ Each guide mirrors all 19 rules in the canonical seed with idiomatic examples.
154
159
  The seed, `assets/artifacts/GUIDELINES_TEMPLATE.md`, is never pasted into the
155
160
  system prompt.
156
161
 
162
+ ### Monorepo skills — child skills are still project skills
163
+
164
+ Pi discovers `.pi/skills` only from its working directory and
165
+ `.agents/skills` from that directory and its ancestors. `monorepo-skills`
166
+ closes the downward gap: in a trusted project it scans at most three child
167
+ levels, respects `.gitignore`, `.ignore`, and `.fdignore`, and contributes the
168
+ same skill locations without writing project configuration. Skill bodies stay
169
+ on demand; only names, descriptions, and locations enter the system prompt.
170
+
171
+ Duplicate names keep Pi's precedence and diagnostics. Root and global skills
172
+ load before extension paths, so use `pi config -l` to disable one that shadows
173
+ a needed child-folder skill.
174
+
157
175
  ### Rules — enforcement, not advice
158
176
 
159
177
  The guidelines gate makes the agent read the rules; `sideroom_rules` makes it
@@ -272,6 +290,19 @@ languages use the shared table. Do not route around
272
290
  the gate through Bash or another file-mutation path. Before finishing, review
273
291
  the diff against the loaded guide and run the relevant project checks.
274
292
 
293
+ ### Monorepo-skills contract
294
+
295
+ - Trigger: `resources_discover` after project trust resolves, in every mode.
296
+ - Scope: `.pi/skills` and `.agents/skills` in child folders at most three
297
+ levels below Pi's working directory.
298
+ - Traversal: ignore-aware, no directory symlinks, deterministic child-folder
299
+ order, `.pi/skills` before `.agents/skills` inside one folder.
300
+ - Safety: untrusted projects and CLI `--no-skills`/`-ns` contribute no paths.
301
+ - Collisions: Pi keeps the first name and reports skipped paths. Child-folder
302
+ skills cannot override root or user skills added earlier.
303
+ - Prompt: one static, idempotent note prefers the skill whose location matches
304
+ the folder being edited.
305
+
275
306
  ### Rules contract
276
307
 
277
308
  - Trigger: every `write`/`edit` call, in every mode.
@@ -338,7 +369,9 @@ skills. If a third-party skill still wins, remove it from routing by adding
338
369
  `disable-model-invocation: true` to its frontmatter, or exclude its path with
339
370
  `-path` in `settings.skills`. A literal name collision keeps the first skill
340
371
  found: project `.pi/skills`, then project `.agents/skills`, then
341
- `~/.pi/agent/skills`, then `~/.agents/skills`, then packages.
372
+ `~/.pi/agent/skills`, then `~/.agents/skills`, then packages. Child-folder
373
+ paths contributed by `monorepo-skills` come later and cannot override any of
374
+ them.
342
375
 
343
376
  ## Documentation
344
377
 
@@ -0,0 +1,9 @@
1
+ const END_OF_OPTIONS = '--';
2
+ const NO_SKILLS_FLAGS = new Set(['--no-skills', '-ns']);
3
+
4
+ export function areSkillsDisabled(argv: readonly string[]): boolean {
5
+ const endOfOptions = argv.indexOf(END_OF_OPTIONS);
6
+ const optionArguments =
7
+ endOfOptions === -1 ? argv : argv.slice(0, endOfOptions);
8
+ return optionArguments.some((argument) => NO_SKILLS_FLAGS.has(argument));
9
+ }
@@ -0,0 +1,36 @@
1
+ import type { ExtensionAPI } from '@earendil-works/pi-coding-agent';
2
+ import { areSkillsDisabled } from './flags.ts';
3
+ import { appendMonorepoSkillsNote } from './prompt.ts';
4
+ import { findMonorepoSkillPaths } from './scan.ts';
5
+
6
+ // Pi loads extensions/*/index.ts through export default.
7
+ export default function registerMonorepoSkills(pi: ExtensionAPI): void {
8
+ let hasMonorepoSkills = false;
9
+
10
+ pi.on('resources_discover', (event, ctx) => {
11
+ hasMonorepoSkills = false;
12
+ if (!ctx.isProjectTrusted() || areSkillsDisabled(process.argv)) {
13
+ return;
14
+ }
15
+
16
+ const skillPaths = findMonorepoSkillPaths(event.cwd);
17
+ if (skillPaths.length === 0) {
18
+ return;
19
+ }
20
+
21
+ hasMonorepoSkills = true;
22
+ return { skillPaths };
23
+ });
24
+
25
+ pi.on('before_agent_start', (event) => {
26
+ if (!hasMonorepoSkills) {
27
+ return;
28
+ }
29
+
30
+ const systemPrompt = appendMonorepoSkillsNote(event.systemPrompt);
31
+ if (systemPrompt === event.systemPrompt) {
32
+ return;
33
+ }
34
+ return { systemPrompt };
35
+ });
36
+ }
@@ -0,0 +1,14 @@
1
+ export const MONOREPO_SKILLS_NOTE_HEADING = 'Sideroom monorepo skills:';
2
+
3
+ export const MONOREPO_SKILLS_NOTE = `${MONOREPO_SKILLS_NOTE_HEADING}
4
+ Some available skills come from child folders of this repository. When skills cover the same work, prefer the skill whose location is inside the folder you are editing.`;
5
+
6
+ export function appendMonorepoSkillsNote(systemPrompt: string): string {
7
+ if (systemPrompt.includes(MONOREPO_SKILLS_NOTE_HEADING)) {
8
+ return systemPrompt;
9
+ }
10
+ if (systemPrompt.length === 0) {
11
+ return MONOREPO_SKILLS_NOTE;
12
+ }
13
+ return `${systemPrompt}\n\n${MONOREPO_SKILLS_NOTE}`;
14
+ }
@@ -0,0 +1,225 @@
1
+ import { existsSync, lstatSync, readdirSync, readFileSync } from 'node:fs';
2
+ import { join, relative, resolve, sep } from 'node:path';
3
+ import ignore from 'ignore';
4
+
5
+ const MAX_CHILD_DEPTH = 3;
6
+ const IGNORE_FILE_NAMES = ['.gitignore', '.ignore', '.fdignore'] as const;
7
+ const NODE_MODULES_DIRECTORY = 'node_modules';
8
+ const PI_DIRECTORY = '.pi';
9
+ const AGENTS_DIRECTORY = '.agents';
10
+ const SKILLS_DIRECTORY = 'skills';
11
+ const SKILL_FILE_NAME = 'SKILL.md';
12
+ const MARKDOWN_EXTENSION = '.md';
13
+
14
+ export function findMonorepoSkillPaths(cwd: string): string[] {
15
+ const rootDirectory = resolve(cwd);
16
+ const childDirectories = collectChildDirectories(
17
+ rootDirectory,
18
+ rootDirectory,
19
+ 0,
20
+ [],
21
+ );
22
+ childDirectories.sort((left, right) =>
23
+ compareText(relative(rootDirectory, left), relative(rootDirectory, right)),
24
+ );
25
+
26
+ const skillPaths: string[] = [];
27
+ for (const childDirectory of childDirectories) {
28
+ const piSkillsDirectory = join(
29
+ childDirectory,
30
+ PI_DIRECTORY,
31
+ SKILLS_DIRECTORY,
32
+ );
33
+ if (isDirectory(piSkillsDirectory)) {
34
+ skillPaths.push(piSkillsDirectory);
35
+ }
36
+
37
+ const agentsSkillsDirectory = join(
38
+ childDirectory,
39
+ AGENTS_DIRECTORY,
40
+ SKILLS_DIRECTORY,
41
+ );
42
+ if (isDirectory(agentsSkillsDirectory)) {
43
+ skillPaths.push(...collectAgentsSkillPaths(agentsSkillsDirectory));
44
+ }
45
+ }
46
+ return skillPaths;
47
+ }
48
+
49
+ function collectChildDirectories(
50
+ directory: string,
51
+ rootDirectory: string,
52
+ depth: number,
53
+ inheritedIgnorePatterns: readonly string[],
54
+ ): string[] {
55
+ if (depth >= MAX_CHILD_DEPTH) {
56
+ return [];
57
+ }
58
+
59
+ const ignorePatterns = [
60
+ ...inheritedIgnorePatterns,
61
+ ...readIgnoreRules(directory, rootDirectory),
62
+ ];
63
+ const ignoreMatcher = ignore().add(ignorePatterns);
64
+ const childDirectories: string[] = [];
65
+ for (const entry of readDirectoryEntries(directory)) {
66
+ if (!entry.isDirectory() || isTraversalDirectory(entry.name)) {
67
+ continue;
68
+ }
69
+
70
+ const childDirectory = join(directory, entry.name);
71
+ const relativeChild = toPosixPath(relative(rootDirectory, childDirectory));
72
+ if (ignoreMatcher.ignores(`${relativeChild}/`)) {
73
+ continue;
74
+ }
75
+
76
+ childDirectories.push(
77
+ childDirectory,
78
+ ...collectChildDirectories(
79
+ childDirectory,
80
+ rootDirectory,
81
+ depth + 1,
82
+ ignorePatterns,
83
+ ),
84
+ );
85
+ }
86
+ return childDirectories;
87
+ }
88
+
89
+ function collectAgentsSkillPaths(skillsDirectory: string): string[] {
90
+ return collectAgentsSkillPathsFromDirectory(
91
+ skillsDirectory,
92
+ skillsDirectory,
93
+ [],
94
+ );
95
+ }
96
+
97
+ function collectAgentsSkillPathsFromDirectory(
98
+ directory: string,
99
+ rootDirectory: string,
100
+ inheritedIgnorePatterns: readonly string[],
101
+ ): string[] {
102
+ const ignorePatterns = [
103
+ ...inheritedIgnorePatterns,
104
+ ...readIgnoreRules(directory, rootDirectory),
105
+ ];
106
+ const ignoreMatcher = ignore().add(ignorePatterns);
107
+ const entries = readDirectoryEntries(directory);
108
+ const declaredSkill = entries.find((entry) => {
109
+ if (entry.name !== SKILL_FILE_NAME || !entry.isFile()) {
110
+ return false;
111
+ }
112
+ const relativeSkill = toPosixPath(
113
+ relative(rootDirectory, join(directory, entry.name)),
114
+ );
115
+ return !ignoreMatcher.ignores(relativeSkill);
116
+ });
117
+ if (declaredSkill) {
118
+ return [join(directory, declaredSkill.name)];
119
+ }
120
+
121
+ const skillPaths: string[] = [];
122
+ for (const entry of entries) {
123
+ if (isTraversalDirectory(entry.name)) {
124
+ continue;
125
+ }
126
+
127
+ const entryPath = join(directory, entry.name);
128
+ const relativeEntry = toPosixPath(relative(rootDirectory, entryPath));
129
+ if (entry.isFile()) {
130
+ if (
131
+ directory !== rootDirectory &&
132
+ entry.name.endsWith(MARKDOWN_EXTENSION) &&
133
+ !ignoreMatcher.ignores(relativeEntry)
134
+ ) {
135
+ skillPaths.push(entryPath);
136
+ }
137
+ continue;
138
+ }
139
+ if (!entry.isDirectory() || ignoreMatcher.ignores(`${relativeEntry}/`)) {
140
+ continue;
141
+ }
142
+ skillPaths.push(
143
+ ...collectAgentsSkillPathsFromDirectory(
144
+ entryPath,
145
+ rootDirectory,
146
+ ignorePatterns,
147
+ ),
148
+ );
149
+ }
150
+ return skillPaths;
151
+ }
152
+
153
+ function readIgnoreRules(directory: string, rootDirectory: string): string[] {
154
+ const relativeDirectory = relative(rootDirectory, directory);
155
+ const prefix = relativeDirectory ? `${toPosixPath(relativeDirectory)}/` : '';
156
+ const ignorePatterns: string[] = [];
157
+
158
+ for (const fileName of IGNORE_FILE_NAMES) {
159
+ const ignorePath = join(directory, fileName);
160
+ if (!existsSync(ignorePath)) {
161
+ continue;
162
+ }
163
+
164
+ ignorePatterns.push(
165
+ ...readFileSync(ignorePath, 'utf8')
166
+ .split(/\r?\n/)
167
+ .map((line) => prefixIgnorePattern(line, prefix))
168
+ .filter((pattern): pattern is string => pattern !== undefined),
169
+ );
170
+ }
171
+ return ignorePatterns;
172
+ }
173
+
174
+ function prefixIgnorePattern(line: string, prefix: string): string | undefined {
175
+ const trimmed = line.trim();
176
+ if (trimmed.length === 0) {
177
+ return undefined;
178
+ }
179
+ if (trimmed.startsWith('#') && !trimmed.startsWith('\\#')) {
180
+ return undefined;
181
+ }
182
+
183
+ let pattern = line;
184
+ let negated = false;
185
+ if (pattern.startsWith('!')) {
186
+ negated = true;
187
+ pattern = pattern.slice(1);
188
+ } else if (pattern.startsWith('\\!')) {
189
+ pattern = pattern.slice(1);
190
+ }
191
+ if (pattern.startsWith('/')) {
192
+ pattern = pattern.slice(1);
193
+ }
194
+
195
+ const prefixed = prefix ? `${prefix}${pattern}` : pattern;
196
+ return negated ? `!${prefixed}` : prefixed;
197
+ }
198
+
199
+ function readDirectoryEntries(directory: string) {
200
+ return readdirSync(directory, { withFileTypes: true }).sort((left, right) =>
201
+ compareText(left.name, right.name),
202
+ );
203
+ }
204
+
205
+ function isDirectory(path: string): boolean {
206
+ return existsSync(path) && lstatSync(path).isDirectory();
207
+ }
208
+
209
+ function isTraversalDirectory(name: string): boolean {
210
+ return name.startsWith('.') || name === NODE_MODULES_DIRECTORY;
211
+ }
212
+
213
+ function toPosixPath(path: string): string {
214
+ return path.split(sep).join('/');
215
+ }
216
+
217
+ function compareText(left: string, right: string): number {
218
+ if (left < right) {
219
+ return -1;
220
+ }
221
+ if (left > right) {
222
+ return 1;
223
+ }
224
+ return 0;
225
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rmrdeveloper/sideroom-pi",
3
- "version": "8.7.0",
3
+ "version": "8.8.0",
4
4
  "description": "Pi coding-agent package that asks clarifying questions, shows a live todo board, enforces coding guidelines, and will not call it done while your checks are red.",
5
5
  "type": "module",
6
6
  "files": [
@@ -94,5 +94,8 @@
94
94
  "@types/node": "^22.20.1",
95
95
  "typebox": "1.3.7",
96
96
  "typescript": "^5.6.0"
97
+ },
98
+ "dependencies": {
99
+ "ignore": "7.0.5"
97
100
  }
98
101
  }