* PILOTDECK.md instruction file discovery — multi-scope instruction hierarchy.
*
* Files are loaded in the following order (later = higher priority, model pays
* more attention to content that appears later in the system prompt):
*
* 1. Managed — $PILOTDECK_MANAGED_CONFIG/PILOTDECK.md
* 2. User — ~/.pilotdeck/PILOTDECK.md
* 3. User rules — ~/.pilotdeck/rules/*.md
* 4. Project — per directory from projectRoot toward cwd:
* <dir>/PILOTDECK.md
* <dir>/.pilotdeck/PILOTDECK.md
* <dir>/.pilotdeck/rules/*.md
* 5. Local — <dir>/PILOTDECK.local.md (private, not committed)
*
* Design mirrors the legacy upstream instruction-file discovery, adapted to
* PilotDeck path conventions (~/.pilotdeck/, .pilotdeck/).
*/
import { readFile, readdir } from "node:fs/promises";
import { dirname, join, resolve } from "node:path";
export type InstructionScope =
| "managed"
| "user"
| "project"
| "project-rules"
| "local";
export type InstructionLayer = {
scope: InstructionScope;
path: string;
content: string;
};
export class InstructionDiscovery {
constructor(
private readonly projectRoot: string,
private readonly cwd: string,
private readonly pilotHome: string,
) {}
async discover(): Promise<InstructionLayer[]> {
const layers: InstructionLayer[] = [];
const seen = new Set<string>();
const managedDir = process.env.PILOTDECK_MANAGED_CONFIG;
if (managedDir) {
await this.tryAdd(layers, seen, "managed", join(managedDir, "PILOTDECK.md"));
}
await this.tryAdd(layers, seen, "user", join(this.pilotHome, "PILOTDECK.md"));
await this.tryAddRulesDir(layers, seen, "user", join(this.pilotHome, "rules"));
const dirs = this.collectDirectoryChain();
for (const dir of dirs) {
await this.tryAdd(layers, seen, "project", join(dir, "PILOTDECK.md"));
await this.tryAdd(layers, seen, "project", join(dir, ".pilotdeck", "PILOTDECK.md"));
await this.tryAddRulesDir(layers, seen, "project-rules", join(dir, ".pilotdeck", "rules"));
await this.tryAdd(layers, seen, "local", join(dir, "PILOTDECK.local.md"));
}
return layers;
}
* Collect the directory chain from projectRoot down to cwd (inclusive).
* Returns directories in root-first order so closer-to-cwd directories
* are loaded last (= higher priority).
*/
private collectDirectoryChain(): string[] {
const root = resolve(this.projectRoot);
const current = resolve(this.cwd);
if (root === current) {
return [root];
}
const dirs: string[] = [];
let dir = current;
while (dir !== root && dir !== dirname(dir)) {
dirs.push(dir);
dir = dirname(dir);
}
dirs.push(root);
dirs.reverse();
return dirs;
}
private async tryAdd(
layers: InstructionLayer[],
seen: Set<string>,
scope: InstructionScope,
filePath: string,
): Promise<void> {
const resolved = resolve(filePath);
if (seen.has(resolved)) return;
seen.add(resolved);
try {
const content = await readFile(resolved, "utf-8");
const trimmed = content.trim();
if (trimmed.length > 0) {
layers.push({ scope, path: resolved, content: trimmed });
}
} catch {
}
}
private async tryAddRulesDir(
layers: InstructionLayer[],
seen: Set<string>,
scope: InstructionScope,
dirPath: string,
): Promise<void> {
try {
const entries = await readdir(dirPath, { withFileTypes: true });
const mdFiles = entries
.filter(e => e.isFile() && e.name.endsWith(".md") && e.name !== "SKILL.md")
.map(e => e.name)
.sort();
for (const file of mdFiles) {
await this.tryAdd(layers, seen, scope, join(dirPath, file));
}
} catch {
}
}
}
export function scopeDescription(scope: InstructionScope): string {
switch (scope) {
case "managed":
return " (managed instructions, set by administrator)";
case "user":
return " (user's global instructions for all projects)";
case "project":
return " (project instructions, checked into the codebase)";
case "project-rules":
return " (project rule, checked into the codebase)";
case "local":
return " (user's private project instructions, not checked in)";
}
}