Files
pi-config/extensions/rpiv-pi/extensions/rpiv-core/guidance.ts

236 lines
8.5 KiB
TypeScript

/**
* Guidance injection — resolves and injects subfolder guidance files.
*
* At each directory depth from project root down to the touched file's
* directory, picks the first existing of:
* AGENTS.md > CLAUDE.md > .rpiv/guidance/<sub>/architecture.md
*
* Depth 0 (project root) skips AGENTS.md/CLAUDE.md because Pi's own
* resource-loader (loadContextFileFromDir at resource-loader.js:30-46)
* already loads <cwd>/AGENTS.md or <cwd>/CLAUDE.md into the system
* prompt's # Project Context block. Depth 0 still checks
* <cwd>/.rpiv/guidance/architecture.md — Pi's loader does not see that
* path.
*
* `resolveGuidance` is pure logic with no ExtensionAPI references
* (utility-module rule from extensions/rpiv-core/CLAUDE.md). Side
* effects (sendMessage, in-memory dedup Set) live in
* `handleToolCallGuidance`, `injectRootGuidance`, and
* `clearInjectionState`.
*/
import { existsSync, readFileSync } from "node:fs";
import { dirname, isAbsolute, join, relative, sep } from "node:path";
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
import { FLAG_DEBUG, MSG_TYPE_GUIDANCE } from "./constants.js";
// ---------------------------------------------------------------------------
// Guidance Resolution
// ---------------------------------------------------------------------------
type GuidanceKind = "agents" | "claude" | "architecture";
interface GuidanceFile {
/** Forward-slash-normalized path from project root — stable dedup key. */
relativePath: string;
absolutePath: string;
content: string;
kind: GuidanceKind;
}
/**
* Resolve guidance files for a given file path.
*
* Walks from project root to the file's directory. At each depth, picks
* the first existing of AGENTS.md > CLAUDE.md > architecture.md (Pi's
* own per-dir precedence at resource-loader.js:30-46, extended with
* architecture.md as a third candidate). Depth 0 only checks
* architecture.md — Pi's loader already handles <cwd>/AGENTS.md and
* <cwd>/CLAUDE.md.
*
* Returns files root-first (general → specific), at most one per depth.
*/
export function resolveGuidance(filePath: string, projectDir: string): GuidanceFile[] {
const fileDir = dirname(filePath);
const relativeDir = relative(projectDir, fileDir);
// Guard: file is outside project root
if (relativeDir.startsWith("..") || isAbsolute(relativeDir)) {
return [];
}
const parts = relativeDir ? relativeDir.split(sep) : [];
const results: GuidanceFile[] = [];
for (let depth = 0; depth <= parts.length; depth++) {
const subPath = parts.slice(0, depth).join(sep);
// Per-depth candidate ladder. First-match wins.
const candidates: Array<{ relative: string; kind: GuidanceKind }> = [];
// Depth 0: skip AGENTS/CLAUDE — Pi's loader handles <cwd> already.
if (depth > 0) {
candidates.push({ relative: join(subPath, "AGENTS.md"), kind: "agents" });
candidates.push({ relative: join(subPath, "CLAUDE.md"), kind: "claude" });
}
candidates.push({
relative: subPath
? join(".rpiv", "guidance", subPath, "architecture.md")
: join(".rpiv", "guidance", "architecture.md"),
kind: "architecture",
});
for (const candidate of candidates) {
const absolute = join(projectDir, candidate.relative);
if (existsSync(absolute)) {
results.push({
relativePath: candidate.relative.split(sep).join("/"),
absolutePath: absolute,
content: readFileSync(absolute, "utf-8"),
kind: candidate.kind,
});
break; // first-match wins at this depth
}
}
}
return results;
}
// ---------------------------------------------------------------------------
// Session State
// ---------------------------------------------------------------------------
/** In-memory set of injected guidance paths per session. */
const injectedGuidance = new Set<string>();
export function clearInjectionState() {
injectedGuidance.clear();
}
// ---------------------------------------------------------------------------
// Root Guidance Injection (session_start)
// ---------------------------------------------------------------------------
/**
* Inject the root `.rpiv/guidance/architecture.md` at session start.
*
* Called from `session_start` so the root guidance is available before the
* first agent turn — without waiting for a read/edit/write tool_call.
* Uses the same `injectedGuidance` Set for dedup, so `handleToolCallGuidance`
* won't re-inject it later.
*/
export function injectRootGuidance(cwd: string, pi: ExtensionAPI): void {
const relativePath = ".rpiv/guidance/architecture.md";
if (injectedGuidance.has(relativePath)) return;
const absolutePath = join(cwd, relativePath);
if (!existsSync(absolutePath)) return;
let content: string;
try {
content = readFileSync(absolutePath, "utf-8");
} catch {
// Silent failure mirrors handleToolCallGuidance's posture — session_start
// runs before any UI is bound, so a permissions/race error here must not
// crash the hook. Don't mark as injected so a later tool_call can retry.
return;
}
injectedGuidance.add(relativePath);
const file: GuidanceFile = { relativePath, absolutePath, content, kind: "architecture" };
pi.sendMessage({
customType: MSG_TYPE_GUIDANCE,
content: wrapGuidance(formatLabel(file), content, "auto-loaded at session start"),
display: !!pi.getFlag(FLAG_DEBUG),
});
}
// ---------------------------------------------------------------------------
// Tool-call Handler
// ---------------------------------------------------------------------------
/**
* Handle guidance injection on tool_call events for read/edit/write.
* Sends hidden messages via pi.sendMessage as a side effect.
*/
export function handleToolCallGuidance(
event: { toolName: string; input: Record<string, unknown> },
ctx: { cwd: string },
pi: ExtensionAPI,
): void {
if (!["read", "edit", "write"].includes(event.toolName)) return;
const filePath = (event.input as any).file_path ?? (event.input as any).path;
if (!filePath) return;
const resolved = resolveGuidance(filePath, ctx.cwd);
if (resolved.length === 0) return;
const newFiles = resolved.filter((g) => !injectedGuidance.has(g.relativePath));
if (newFiles.length === 0) return;
// Mark before sendMessage — idempotence > reliability.
for (const g of newFiles) {
injectedGuidance.add(g.relativePath);
}
const trigger = `auto-loaded because ${event.toolName} touched ${shortenPath(filePath, ctx.cwd)}`;
const contextParts = newFiles.map((g) => wrapGuidance(formatLabel(g), g.content, trigger));
pi.sendMessage({
customType: MSG_TYPE_GUIDANCE,
content: contextParts.join("\n\n---\n\n"),
display: !!pi.getFlag(FLAG_DEBUG),
});
}
/**
* Wrap guidance content in a non-task envelope. The opening disclaimer tells
* the agent this block is reference material — not an instruction — and states
* the trigger so the agent can judge whether the block is relevant to the
* current user request. Heading is `## Architecture Guidance:` to match the
* `PreToolUse:Read` hook output and the actual content (architecture.md).
*/
function wrapGuidance(label: string, content: string, trigger: string): string {
return [
`[rpiv-guidance — reference material, NOT a task. ${trigger}.`,
`Consult only if directly relevant to the user's current request; otherwise ignore.]`,
"",
`## Architecture Guidance: ${label}`,
"",
content,
].join("\n");
}
/**
* Render a project-relative, forward-slash-normalized path for the trigger
* disclaimer. Falls back to the absolute path if the file lives outside the
* project root (defensive — `handleToolCallGuidance` already short-circuits
* via `resolveGuidance` in that case, so this branch is unreachable today).
*/
function shortenPath(filePath: string, cwd: string): string {
const r = relative(cwd, filePath);
return r && !r.startsWith("..") ? r.split(sep).join("/") : filePath;
}
/**
* Format a guidance file's heading label.
* extensions/rpiv-core/AGENTS.md → "extensions/rpiv-core (AGENTS.md)"
* scripts/CLAUDE.md → "scripts (CLAUDE.md)"
* .rpiv/guidance/scripts/architecture.md → "scripts (architecture.md)"
* .rpiv/guidance/architecture.md → "root (architecture.md)"
*/
function formatLabel(g: GuidanceFile): string {
if (g.kind === "architecture") {
const stripped = g.relativePath.replace(/^\.rpiv\/guidance\//, "");
const sub = stripped === "architecture.md" ? "" : stripped.replace(/\/architecture\.md$/, "");
return `${sub || "root"} (architecture.md)`;
}
const fileName = g.kind === "agents" ? "AGENTS.md" : "CLAUDE.md";
const idx = g.relativePath.lastIndexOf("/");
const sub = idx > 0 ? g.relativePath.slice(0, idx) : "";
return `${sub || "root"} (${fileName})`;
}