Something went wrong. Try again.
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387/** * Default `ResourceLoader` — deterministic discovery + precedence (ARCHITECTURE §4.9). * * Discovers typed resources from three injected origin groups — bundled roots, user * roots, and the project cwd plus its ancestors — and merges them with the pinned * precedence: bundled → user → project (project wins). Named resources (skills, agents, * rules, extensions) override by stable `key`; context files layer in source order with * same-key replacement. The result is a single wire-safe `ResourceSnapshot`. * * Foundation scope (saga d4ks8c.2): generic metadata discovery only. A record's `name` is * the file stem and `description` is empty; Pi/Agent-Skills frontmatter, SHARD.md/AGENTS.md, * and rule semantics arrive in d4ks8c.5. Only regular files in per-kind subdirectories are * discovered (directory-based skill conventions like `SKILL.md` are d4ks8c.5). * * Robustness guarantees: * - **Missing dirs are harmless**: a missing root or kind-subdir is skipped (an `info` * diagnostic for a missing *root* only); load never throws. * - **Unreadable/malformed diagnosed**: broken symlinks, unreadable paths, and empty * resource names produce `warning` diagnostics and are skipped. * - **Normalized paths**: every stored path is `/`-joined so stable keys match across * platforms. * - **Cycle/symlink safe**: directory symlinks are never followed (non-recursive scan); * per-directory realpath dedupe drops symlink loops and double-links with a `warning`. * * The filesystem is an injected seam (`ResourceFilesystem`) so tests run hermetically; the * real process supplies `nodeResourceFilesystem()`. */import { join, basename, extname } from 'node:path';import type { ResourceLoader, ResourceSnapshot, ResourceRecord, ResourceKind, ResourceOrigin, ResourceDiagnostic, SkillIndexEntry,} from '@/contracts/resource';import { normalizePath, relativePosix, isDirectory, type ResourceFilesystem,} from './filesystem';import { nodeResourceFilesystem } from './filesystem';import { resolveProjectAncestors, type ProjectBoundaryResolver,} from '@/core/project-boundary';import { makeDiagnostic } from './diagnostic';import { mergeNamedLayers, mergeContextLayers, NAMED_KINDS, type ResourceLayer,} from './precedence';import { formatSkillIndex } from './skill-index';
/** Default per-kind subdirectory names scanned under each root. */const DEFAULT_KIND_DIRS: Record<ResourceKind, string[]> = { skill: ['skills'], agent: ['agents'], context: ['context'], rule: ['rules'], extension: ['extensions'],};
/** Default file extensions treated as resource files. */const DEFAULT_EXTENSIONS = ['.md'];
/** All kinds the loader discovers, in snapshot-array order. */const ALL_KINDS: ReadonlyArray<ResourceKind> = [ 'skill', 'agent', 'context', 'rule', 'extension',] as const;
/** * Configuration for {@link DefaultResourceLoader}. Roots are injected (never hard-coded) * so the same loader serves bundled, user-config, and project layouts. Precedence is * fixed: `bundledRoots` (lowest) → `userRoots` → project (highest). */export interface ResourceLoaderConfig { /** Bundled resource roots — ship defaults, lowest precedence. */ bundledRoots: string[]; /** User resource roots (e.g. config dir), middle precedence. */ userRoots: string[]; /** Project cwd. Ancestor dirs up to the git/fs root form the project layer (highest). */ cwd: string; /** Stop the ancestor walk after including the first dir containing `.git`. Default `true`. */ stopAtGitRoot?: boolean; /** * Injectable project-boundary resolver. When present, its canonical top-level is * authoritative for the ancestor walk (a linked worktree's worktree root wins; discovery * stops there and does not consult filesystem markers). When absent, the cheap filesystem * fallback recognizes a `.git` regular file (linked worktree) OR directory (main checkout). * Default `undefined` so hermetic fake-fs tests stay Git-free. */ boundaryResolver?: ProjectBoundaryResolver; /** Per-kind subdirectory names; defaults to {@link DEFAULT_KIND_DIRS}. */ kindDirs?: Partial<Record<ResourceKind, string[]>>; /** File extensions treated as resource files (with leading `.`); default `['.md']`. */ extensions?: string[];}
/** One unit of work: scan a single root for one origin. */interface ScanLayer { origin: ResourceOrigin; root: string;}
/** * Resolve the project layer roots by walking up from `cwd`. Returns `[cwd, parent, …, * boundary | fsRoot]` inclusive. With `stopAtGitRoot` (default) the walk stops after the * project boundary: when a {@link ProjectBoundaryResolver} is injected its canonical * top-level is authoritative ("injected wins"); otherwise the cheap filesystem fallback * recognizes a `.git` regular file (linked worktree) OR directory (main checkout), fixing the * bug where a linked worktree's `.git` FILE did not stop ancestor scans. `stopAtGitRoot: false` * walks to the filesystem root without invoking or obeying the boundary. The caller reverses * this so the least-specific ancestor is processed first and `cwd` wins same-key overrides. */export async function resolveProjectRoots( fs: ResourceFilesystem, cwd: string, opts: { stopAtGitRoot?: boolean; boundaryResolver?: ProjectBoundaryResolver } = {},): Promise<string[]> { // Pass `fs.realpath` as the canonicalizer so a symlink-spelled cwd collapses to the same // canonical root an injected boundary resolver returns (realpath-resolved). For a non- // symlink path `realpath` returns the path itself, so hermetic fake-fs tests are unchanged. return resolveProjectAncestors(fs, cwd, { ...opts, canonicalize: (p) => fs.realpath(p) });}
/** Compute the stable key for a discovered file within its kind-directory. */function keyOf(kind: ResourceKind, dirAbs: string, abs: string, stem: string): string { // Context keys by normalized relative path (filename) so distinct files accumulate and // same-named files across layers replace. Named kinds key by file stem. return kind === 'context' ? relativePosix(dirAbs, abs) : stem;}
/** * Scan one root for all kinds. Returns per-kind layers (one per kind that yielded records) * plus diagnostics. Within a single kind-directory, same-key collisions and realpath * duplicates are diagnosed as `warning` and only the first (lowest sorted name) is kept, so * every returned layer has unique keys by construction. */async function scanRoot( fs: ResourceFilesystem, origin: ResourceOrigin, rootAbs: string, kindDirs: Record<ResourceKind, string[]>, exts: string[],): Promise<{ layers: Map<ResourceKind, ResourceLayer>; diagnostics: ResourceDiagnostic[] }> { const diagnostics: ResourceDiagnostic[] = []; const layers = new Map<ResourceKind, ResourceLayer>(); if (!(await isDirectory(fs, rootAbs))) { // Missing root is harmless but observable. diagnostics.push(makeDiagnostic('info', `root directory not found: ${normalizePath(rootAbs)}`, { origin })); return { layers, diagnostics }; } for (const kind of ALL_KINDS) { const records: ResourceRecord[] = []; const seenReal = new Set<string>(); const seenKeys = new Set<string>(); for (const kindDirName of kindDirs[kind]) { const dirAbs = join(rootAbs, kindDirName); const entries = await fs.readdir(dirAbs); if (entries === undefined) continue; // missing/unreadable kind-subdir: harmless, silent // Deterministic order: sort by entry name. const sorted = [...entries].sort((a, b) => a.name < b.name ? -1 : a.name > b.name ? 1 : 0, ); for (const ent of sorted) { // Resolve the target kind for symlinks (do NOT follow directory symlinks — non- // recursive scan, so no descent and no cycle risk; we still need the target kind // to decide whether the link is a file resource). let isFile = ent.isFile; const isSymlink = ent.isSymbolicLink; if (isSymlink) { const st = await fs.stat(join(dirAbs, ent.name)); if (st === undefined) { diagnostics.push( makeDiagnostic('warning', `broken symlink in ${kind} directory: ${ent.name}`, { kind, path: normalizePath(join(dirAbs, ent.name)), }), ); continue; } isFile = st.isFile; } if (!isFile) continue; // directories and other kinds are skipped (d4ks8c.5 handles dir skills) const ext = extname(ent.name); if (!exts.includes(ext)) continue; const stem = basename(ent.name, ext); const abs = join(dirAbs, ent.name); if (stem === '') { diagnostics.push( makeDiagnostic('warning', `resource file has empty name: ${ent.name}`, { kind, path: normalizePath(abs), }), ); continue; } const real = await fs.realpath(abs); if (real === undefined) { diagnostics.push( makeDiagnostic('warning', `unreadable resource path: ${ent.name}`, { kind, path: normalizePath(abs), }), ); continue; } if (seenReal.has(real)) { diagnostics.push( makeDiagnostic('warning', `duplicate resource (symlink resolves to same file): ${ent.name}`, { kind, path: normalizePath(abs), }), ); continue; } seenReal.add(real); const key = keyOf(kind, dirAbs, abs, stem); if (seenKeys.has(key)) { diagnostics.push( makeDiagnostic('warning', `duplicate ${kind} name '${key}' in ${kindDirName}/`, { kind, path: normalizePath(abs), }), ); continue; } seenKeys.add(key); records.push({ kind, name: stem, description: '', path: normalizePath(abs), origin, key, }); } } if (records.length > 0 || kind === 'context') { // Always register a context layer (even if empty) so source-order layering is // contiguous; named kinds only register when they produced records. layers.set(kind, { origin, records }); } } return { layers, diagnostics };}
/** * The default L2 `ResourceLoader`. Construct with injected roots and (optionally) a * filesystem seam; `load()` returns the merged snapshot. *//** Normalized config held by {@link DefaultResourceLoader}. */export interface ResolvedResourceLoaderConfig { bundledRoots: string[]; userRoots: string[]; cwd: string; stopAtGitRoot: boolean; boundaryResolver?: ProjectBoundaryResolver; kindDirs?: Partial<Record<ResourceKind, string[]>>; extensions?: string[];}
export class DefaultResourceLoader implements ResourceLoader { private readonly fs: ResourceFilesystem; private readonly config: ResolvedResourceLoaderConfig;
constructor(config: ResourceLoaderConfig, fs?: ResourceFilesystem) { this.fs = fs ?? nodeResourceFilesystem(); const resolved: ResolvedResourceLoaderConfig = { bundledRoots: config.bundledRoots, userRoots: config.userRoots, cwd: config.cwd, stopAtGitRoot: config.stopAtGitRoot ?? true, }; if (config.boundaryResolver !== undefined) resolved.boundaryResolver = config.boundaryResolver; if (config.kindDirs !== undefined) resolved.kindDirs = config.kindDirs; if (config.extensions !== undefined) resolved.extensions = config.extensions; this.config = resolved; }
private kindDirsConfig(): Record<ResourceKind, string[]> { const custom = this.config.kindDirs ?? {}; const out = {} as Record<ResourceKind, string[]>; for (const k of ALL_KINDS) { out[k] = custom[k] ?? DEFAULT_KIND_DIRS[k]!; } return out; }
private extensionsConfig(): string[] { return this.config.extensions ?? DEFAULT_EXTENSIONS; }
async load(): Promise<ResourceSnapshot> { const kindDirs = this.kindDirsConfig(); const exts = this.extensionsConfig();
// Ordered scan layers: bundled (config order) → user (config order) → project // (least-specific ancestor → cwd, so cwd wins same-key overrides). const scanLayers: ScanLayer[] = []; for (const root of this.config.bundledRoots) scanLayers.push({ origin: 'bundled', root }); for (const root of this.config.userRoots) scanLayers.push({ origin: 'user', root }); const projectRoots = await resolveProjectRoots(this.fs, this.config.cwd, { stopAtGitRoot: this.config.stopAtGitRoot, ...(this.config.boundaryResolver !== undefined ? { boundaryResolver: this.config.boundaryResolver } : undefined), }); // Reverse so the highest ancestor is scanned first and cwd is scanned last. for (const root of projectRoots.reverse()) scanLayers.push({ origin: 'project', root });
// Per-kind ordered layer lists for the merge, plus all scan diagnostics. const perKindLayers = new Map<ResourceKind, ResourceLayer[]>(); for (const k of ALL_KINDS) perKindLayers.set(k, []); const diagnostics: ResourceDiagnostic[] = [];
for (const layer of scanLayers) { const { layers, diagnostics: layerDiags } = await scanRoot( this.fs, layer.origin, layer.root, kindDirs, exts, ); diagnostics.push(...layerDiags); for (const [kind, rl] of layers) { perKindLayers.get(kind)!.push(rl); } }
// Merge: named kinds by override-by-key; context by source-order replacement. const merged: Record<ResourceKind, ResourceRecord[]> = { skill: [], agent: [], context: [], rule: [], extension: [], }; for (const kind of NAMED_KINDS) { const r = mergeNamedLayers(perKindLayers.get(kind)!); merged[kind] = r.records; diagnostics.push(...r.diagnostics); } const ctx = mergeContextLayers(perKindLayers.get('context')!); merged.context = ctx.records; diagnostics.push(...ctx.diagnostics);
// Progressive-disclosure skill index: name/description/path only, sorted by name. const skillIndex: SkillIndexEntry[] = merged.skill .map((s) => ({ name: s.name, description: s.description, path: s.path })) .sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
// Stable diagnostic order: sort by (severity rank, message, path) for deterministic // snapshots across runs. Errors first, then warnings, then info. const rank: Record<string, number> = { error: 0, warning: 1, info: 2 }; diagnostics.sort((a, b) => { const s = (rank[a.severity] ?? 9) - (rank[b.severity] ?? 9); if (s !== 0) return s; if (a.message !== b.message) return a.message < b.message ? -1 : 1; return (a.path ?? '') < (b.path ?? '') ? -1 : (a.path ?? '') > (b.path ?? '') ? 1 : 0; });
return { skills: merged.skill, agents: merged.agent, contextFiles: merged.context, rules: merged.rule, extensions: merged.extension, diagnostics, skillIndex, }; }}
/** Render the skill-index section for the system prompt. Convenience export. */export function renderSkillIndex(snapshot: ResourceSnapshot): string { return formatSkillIndex(snapshot.skillIndex);}