mirror of
https://github.com/proxysoul/Empryo.git
synced 2026-11-07 06:25:57 -10:00
Load instruction files from the home directory (#49)
## Summary This fixes a gap in instruction loading: `instructionFiles` could be enabled globally, but the loader still only looked in the repo root. This change: - loads enabled instruction sources from both the project directory and the user's home directory - keeps both project and global instructions in the prompt and makes global precedence explicit when both exist - adds targeted loader tests and updates the docs for prompt assembly, config, and headless behavior ## Testing - `bun test tests/instructions-loader.test.ts tests/shiki-marked.test.ts tests/prompt-builder.test.ts` - `bunx biome check src/core/instructions.ts tests/instructions-loader.test.ts`
This commit is contained in:
1 parent
a8b416cfb2
commit
0da933fdee
11 files changed
+281
-24
No files matched your search
@@ -50,7 +50,7 @@ macOS (ARM64, x64) and Linux (x64, ARM64). No native Windows support — use WSL
|
||||
- Piped input: `echo "prompt" | soulforge --headless`
|
||||
- Exit codes: 0=success, 1=error, 2=timeout, 130=abort
|
||||
- Custom providers: add `providers` array to config (OpenAI-compatible APIs)
|
||||
- Project instructions: `SOULFORGE.md` loaded by default, `CLAUDE.md` + 9 others via `/instructions`
|
||||
- Project instructions: enabled sources load from the project root and matching home-scoped files; `SOULFORGE.md` / `~/.soulforge/instructions.md` are on by default and `CLAUDE.md` + others are available via `/instructions`
|
||||
|
||||
## Conventions
|
||||
|
||||
|
||||
+1
-1
@@ -86,7 +86,7 @@ Graph-powered code intelligence — multi-agent coding with codebase-aware AI.
|
||||
- Custom providers use `createOpenAI({ baseURL, apiKey })` pattern (same as Ollama)
|
||||
- Config is layered: global (`~/.soulforge/config.json`) > project (`.soulforge/config.json`)
|
||||
- Skills scan: `~/.soulforge/skills/`, `~/.agents/skills/`, `~/.claude/skills/` (+ project-local)
|
||||
- Instruction files: SOULFORGE.md on by default, others toggled via `/instructions` or config
|
||||
- Instruction files: enabled sources load from the project root and matching home-scoped files, with `SOULFORGE.md` / `~/.soulforge/instructions.md` on by default
|
||||
|
||||
### Tool Suite
|
||||
|
||||
|
||||
+1
-1
@@ -376,7 +376,7 @@ boot.tsx (CLI flag detected)
|
||||
→ initConfig() + registerCustomProviders()
|
||||
→ run.ts (runPrompt)
|
||||
→ ContextManager.createAsync() (repo map, memory)
|
||||
→ loadInstructions() (SOULFORGE.md + --system)
|
||||
→ loadInstructions() (project/global instructions + --system)
|
||||
→ SessionManager.loadSession() (if --session)
|
||||
→ createForgeAgent() (same agent as TUI)
|
||||
→ agent.stream() (async iterator)
|
||||
|
||||
@@ -22,7 +22,7 @@ The system prompt is assembled from these sections:
|
||||
1. **Family base prompt** — identity, tone, style, workflow (per-model)
|
||||
2. **Shared rules** — tool policy, conventions, commit restrictions (same for all)
|
||||
3. **Tool guidance** — priority list, editing rules, dispatch rules
|
||||
4. **Project context** — cwd, toolchain, project instructions (SOULFORGE.md, CLAUDE.md, etc.)
|
||||
4. **Project context** — cwd, toolchain, project instructions (project-local plus home-scoped instruction files such as `SOULFORGE.md`, `~/.soulforge/instructions.md`, `AGENTS.md`, etc.)
|
||||
5. **Forbidden files** — security patterns
|
||||
6. **Editor context** — open file, cursor position, visual selection
|
||||
7. **Git context** — branch, status, conflicts
|
||||
|
||||
@@ -34,7 +34,7 @@ The system prompt is assembled from these sections:
|
||||
Priority list, editing rules, dispatch rules (with/without Soul Map).
|
||||
</Step>
|
||||
<Step title="Project context">
|
||||
cwd, toolchain, project instructions (SOULFORGE.md, CLAUDE.md, etc.).
|
||||
cwd, toolchain, project instructions (project-local plus home-scoped instruction files such as `SOULFORGE.md`, `~/.soulforge/instructions.md`, `AGENTS.md`, etc.).
|
||||
</Step>
|
||||
<Step title="Forbidden files">
|
||||
Security patterns.
|
||||
|
||||
@@ -238,7 +238,7 @@ If a custom provider `id` matches a built-in (e.g. `"anthropic"`), it auto-renam
|
||||
|
||||
## Project instructions
|
||||
|
||||
SoulForge loads `SOULFORGE.md` from your project root as project-specific instructions. You can also load instruction files from other AI tools:
|
||||
SoulForge loads enabled instruction files from both your project root and your home directory. For example, the `soulforge` source can come from `SOULFORGE.md` in the repo or `~/.soulforge/instructions.md`, and the `codex` source can come from `AGENTS.md` in the repo or `~/.agents/instructions.md`. When both scopes exist, global instructions are loaded too and take priority over project-local instructions.
|
||||
|
||||
| File | Source | Default |
|
||||
|------|--------|---------|
|
||||
|
||||
@@ -242,7 +242,7 @@ boot.tsx (CLI flag detected)
|
||||
-> initConfig() + registerCustomProviders()
|
||||
-> run.ts (runPrompt)
|
||||
-> ContextManager.createAsync() (repo map, memory)
|
||||
-> loadInstructions() (SOULFORGE.md + --system)
|
||||
-> loadInstructions() (project/global instructions + --system)
|
||||
-> SessionManager.loadSession() (if --session)
|
||||
-> createForgeAgent() (same agent as TUI)
|
||||
-> agent.stream() (async iterator)
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useShallow } from "zustand/react/shallow";
|
||||
import { ContextManager, type SharedContextResources } from "../../core/context/manager.js";
|
||||
import { getWorkspaceCoordinator } from "../../core/coordination/WorkspaceCoordinator.js";
|
||||
import { icon } from "../../core/icons.js";
|
||||
import { buildInstructionPrompt, loadInstructions } from "../../core/instructions.js";
|
||||
import type { ProviderStatus } from "../../core/llm/provider.js";
|
||||
import { clearTabSessionPatterns } from "../../core/security/forbidden.js";
|
||||
import type { SessionManager } from "../../core/sessions/manager.js";
|
||||
@@ -187,6 +188,11 @@ export const TabInstance = memo(function TabInstance({
|
||||
contextManager.setTaskRouter(effectiveConfig.taskRouter);
|
||||
}, [effectiveConfig.taskRouter, contextManager]);
|
||||
|
||||
useEffect(() => {
|
||||
const loaded = loadInstructions(cwd, effectiveConfig.instructionFiles);
|
||||
contextManager.setProjectInstructions(buildInstructionPrompt(loaded));
|
||||
}, [effectiveConfig.instructionFiles, cwd, contextManager]);
|
||||
|
||||
useEffect(() => {
|
||||
if (effectiveConfig.semanticSummaries !== undefined)
|
||||
contextManager.setSemanticSummaries(effectiveConfig.semanticSummaries);
|
||||
|
||||
@@ -84,6 +84,7 @@ export class ContextManager {
|
||||
private semanticModeExplicit = false;
|
||||
private isChild = false;
|
||||
private projectInstructions = "";
|
||||
private projectInstructionsVersion = 0;
|
||||
private static readonly REPO_MAP_TTL = 5_000; // 5s — covers getContextBreakdown + buildSystemPrompt in same prompt
|
||||
|
||||
private static readonly FILE_TREE_TTL = 30_000; // 30s
|
||||
@@ -280,7 +281,9 @@ export class ContextManager {
|
||||
}
|
||||
|
||||
setProjectInstructions(content: string): void {
|
||||
if (this.projectInstructions === content) return;
|
||||
this.projectInstructions = content;
|
||||
this.projectInstructionsVersion++;
|
||||
}
|
||||
|
||||
setForgeMode(mode: ForgeMode): void {
|
||||
@@ -493,7 +496,7 @@ export class ContextManager {
|
||||
const skillNames = [...this.skills.keys()].sort().join(",");
|
||||
const memGen = this.memoryManager.generation;
|
||||
const mode = this.forgeMode;
|
||||
return `${String(gen)}|${modelId}|${mode}|${String(skillCount)}:${skillNames}|m${String(memGen)}`;
|
||||
return `${String(gen)}|${modelId}|${mode}|${String(skillCount)}:${skillNames}|m${String(memGen)}|pi${String(this.projectInstructionsVersion)}`;
|
||||
}
|
||||
|
||||
waitForRepoMap(timeoutMs = 120_000, signal?: AbortSignal): Promise<boolean> {
|
||||
|
||||
+90
-16
@@ -1,4 +1,5 @@
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { existsSync, readFileSync, realpathSync } from "node:fs";
|
||||
import { homedir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { marked } from "marked";
|
||||
|
||||
@@ -9,11 +10,13 @@ interface InstructionSource {
|
||||
defaultEnabled: boolean;
|
||||
}
|
||||
|
||||
// Unlike skills, global instruction files are user-authored personal steering.
|
||||
// Keep global content later in prompt so it overrides repo-local guidance on conflicts.
|
||||
export const INSTRUCTION_SOURCES: InstructionSource[] = [
|
||||
{
|
||||
id: "soulforge",
|
||||
label: "SOULFORGE.md",
|
||||
files: ["SOULFORGE.md", ".soulforge/instructions.md"],
|
||||
files: ["SOULFORGE.md", ".soulforge/SOULFORGE.md", ".soulforge/instructions.md"],
|
||||
defaultEnabled: true,
|
||||
},
|
||||
{
|
||||
@@ -70,6 +73,7 @@ interface LoadedInstruction {
|
||||
source: string;
|
||||
file: string;
|
||||
content: string;
|
||||
scope: "project" | "global";
|
||||
}
|
||||
|
||||
interface InstructionSection {
|
||||
@@ -128,28 +132,68 @@ export function parseInstructionStructure(content: string): InstructionStructure
|
||||
return { sections, codeBlocks, raw: content };
|
||||
}
|
||||
|
||||
export function loadInstructions(cwd: string, enabledIds?: string[]): LoadedInstruction[] {
|
||||
interface LoadInstructionOptions {
|
||||
homeDir?: string;
|
||||
}
|
||||
|
||||
function canonicalPath(path: string): string {
|
||||
try {
|
||||
return realpathSync(path);
|
||||
} catch {
|
||||
return path;
|
||||
}
|
||||
}
|
||||
|
||||
function readInstructionFromRoot(
|
||||
rootDir: string,
|
||||
source: InstructionSource,
|
||||
scope: LoadedInstruction["scope"],
|
||||
): LoadedInstruction | null {
|
||||
for (const file of source.files) {
|
||||
const fullPath = join(rootDir, file);
|
||||
if (!existsSync(fullPath)) continue;
|
||||
|
||||
try {
|
||||
const content = readFileSync(fullPath, "utf-8").trim();
|
||||
if (content.length > 0) {
|
||||
return { source: source.id, file, content, scope };
|
||||
}
|
||||
} catch {}
|
||||
break;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function loadInstructions(
|
||||
cwd: string,
|
||||
enabledIds?: string[],
|
||||
opts?: LoadInstructionOptions,
|
||||
): LoadedInstruction[] {
|
||||
const enabled = new Set(
|
||||
enabledIds ?? INSTRUCTION_SOURCES.filter((s) => s.defaultEnabled).map((s) => s.id),
|
||||
);
|
||||
|
||||
const results: LoadedInstruction[] = [];
|
||||
const projectRoot = cwd;
|
||||
const globalRoot = opts?.homeDir ?? homedir();
|
||||
const hasDistinctGlobalRoot = canonicalPath(globalRoot) !== canonicalPath(projectRoot);
|
||||
|
||||
for (const source of INSTRUCTION_SOURCES) {
|
||||
if (!enabled.has(source.id)) continue;
|
||||
|
||||
for (const file of source.files) {
|
||||
const fullPath = join(cwd, file);
|
||||
if (!existsSync(fullPath)) continue;
|
||||
const projectMatch = readInstructionFromRoot(projectRoot, source, "project");
|
||||
const globalMatch = hasDistinctGlobalRoot
|
||||
? readInstructionFromRoot(globalRoot, source, "global")
|
||||
: null;
|
||||
const sameInstructionFile =
|
||||
projectMatch &&
|
||||
globalMatch &&
|
||||
canonicalPath(join(projectRoot, projectMatch.file)) ===
|
||||
canonicalPath(join(globalRoot, globalMatch.file));
|
||||
|
||||
try {
|
||||
const content = readFileSync(fullPath, "utf-8").trim();
|
||||
if (content.length > 0) {
|
||||
results.push({ source: source.id, file, content });
|
||||
}
|
||||
} catch {}
|
||||
break;
|
||||
}
|
||||
if (projectMatch) results.push(projectMatch);
|
||||
if (globalMatch && !sameInstructionFile) results.push(globalMatch);
|
||||
}
|
||||
|
||||
return results;
|
||||
@@ -158,9 +202,39 @@ export function loadInstructions(cwd: string, enabledIds?: string[]): LoadedInst
|
||||
export function buildInstructionPrompt(instructions: LoadedInstruction[]): string {
|
||||
if (instructions.length === 0) return "";
|
||||
|
||||
const projectInstructions = instructions.filter((inst) => inst.scope === "project");
|
||||
const globalInstructions = instructions.filter((inst) => inst.scope === "global");
|
||||
const parts: string[] = [];
|
||||
for (const inst of instructions) {
|
||||
parts.push(`[${inst.file}]\n${inst.content}`);
|
||||
|
||||
if (globalInstructions.length > 0) {
|
||||
parts.push(
|
||||
"Global instruction files apply across all projects and take priority over project-local instruction files when they conflict.",
|
||||
);
|
||||
}
|
||||
|
||||
if (projectInstructions.length > 0) {
|
||||
const projectParts: string[] = [];
|
||||
for (const inst of projectInstructions) {
|
||||
projectParts.push(
|
||||
globalInstructions.length > 0
|
||||
? `[project:${inst.file}]\n${inst.content}`
|
||||
: `[${inst.file}]\n${inst.content}`,
|
||||
);
|
||||
}
|
||||
parts.push(
|
||||
globalInstructions.length > 0
|
||||
? `Project-local instruction files:\n${projectParts.join("\n\n")}`
|
||||
: projectParts.join("\n\n"),
|
||||
);
|
||||
}
|
||||
|
||||
if (globalInstructions.length > 0) {
|
||||
const globalParts: string[] = [];
|
||||
for (const inst of globalInstructions) {
|
||||
globalParts.push(`[global:${inst.file}]\n${inst.content}`);
|
||||
}
|
||||
parts.push(`Global instruction files:\n${globalParts.join("\n\n")}`);
|
||||
}
|
||||
|
||||
return `Project instructions:\n${parts.join("\n\n")}`;
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from "bun:test";
|
||||
import { mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { buildInstructionPrompt, loadInstructions } from "../src/core/instructions.js";
|
||||
import { ContextManager } from "../src/core/context/manager.js";
|
||||
|
||||
describe("instruction loading", () => {
|
||||
let rootDir: string;
|
||||
let projectDir: string;
|
||||
let homeDir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
rootDir = mkdtempSync(join(tmpdir(), "sf-instructions-"));
|
||||
projectDir = join(rootDir, "project");
|
||||
homeDir = join(rootDir, "home");
|
||||
mkdirSync(projectDir, { recursive: true });
|
||||
mkdirSync(homeDir, { recursive: true });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(rootDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("loads project-local instructions when no global file exists", () => {
|
||||
writeFileSync(join(projectDir, "AGENTS.md"), "project codex");
|
||||
|
||||
const loaded = loadInstructions(projectDir, ["codex"], { homeDir });
|
||||
|
||||
expect(loaded).toHaveLength(1);
|
||||
expect(loaded[0]).toMatchObject({
|
||||
source: "codex",
|
||||
file: "AGENTS.md",
|
||||
scope: "project",
|
||||
content: "project codex",
|
||||
});
|
||||
expect(buildInstructionPrompt(loaded)).toBe("Project instructions:\n[AGENTS.md]\nproject codex");
|
||||
});
|
||||
|
||||
it("loads global instructions from the home directory", () => {
|
||||
mkdirSync(join(homeDir, ".soulforge"), { recursive: true });
|
||||
writeFileSync(join(homeDir, ".soulforge", "SOULFORGE.md"), "global soulforge");
|
||||
|
||||
const loaded = loadInstructions(projectDir, ["soulforge"], { homeDir });
|
||||
|
||||
expect(loaded).toHaveLength(1);
|
||||
expect(loaded[0]).toMatchObject({
|
||||
source: "soulforge",
|
||||
file: ".soulforge/SOULFORGE.md",
|
||||
scope: "global",
|
||||
content: "global soulforge",
|
||||
});
|
||||
});
|
||||
|
||||
it("includes both project-local and global instructions in the prompt", () => {
|
||||
writeFileSync(join(projectDir, "AGENTS.md"), "project codex");
|
||||
mkdirSync(join(homeDir, ".agents"), { recursive: true });
|
||||
writeFileSync(join(homeDir, ".agents", "instructions.md"), "global codex");
|
||||
|
||||
const loaded = loadInstructions(projectDir, ["codex"], { homeDir });
|
||||
const prompt = buildInstructionPrompt(loaded);
|
||||
|
||||
expect(loaded.map((inst) => inst.scope)).toEqual(["project", "global"]);
|
||||
expect(prompt).toContain(
|
||||
"Global instruction files apply across all projects and take priority over project-local instruction files when they conflict.",
|
||||
);
|
||||
expect(prompt).toContain("Project-local instruction files:");
|
||||
expect(prompt).toContain("[project:AGENTS.md]");
|
||||
expect(prompt).toContain("project codex");
|
||||
expect(prompt).toContain("[global:.agents/instructions.md]");
|
||||
expect(prompt).toContain("global codex");
|
||||
expect(prompt.indexOf("Global instruction files apply across all projects")).toBeLessThan(
|
||||
prompt.indexOf("[project:AGENTS.md]"),
|
||||
);
|
||||
expect(prompt.indexOf("global codex")).toBeGreaterThan(prompt.indexOf("project codex"));
|
||||
});
|
||||
|
||||
it("keeps both scopes when project and global instructions share source", () => {
|
||||
writeFileSync(join(projectDir, "SOULFORGE.md"), "project soulforge");
|
||||
mkdirSync(join(homeDir, ".soulforge"), { recursive: true });
|
||||
writeFileSync(join(homeDir, ".soulforge", "SOULFORGE.md"), "global soulforge");
|
||||
|
||||
const loaded = loadInstructions(projectDir, ["soulforge"], { homeDir });
|
||||
const prompt = buildInstructionPrompt(loaded);
|
||||
|
||||
expect(loaded.map((inst) => inst.scope)).toEqual(["project", "global"]);
|
||||
expect(loaded.map((inst) => inst.content)).toEqual(["project soulforge", "global soulforge"]);
|
||||
expect(loaded.map((inst) => inst.file)).toEqual(["SOULFORGE.md", ".soulforge/SOULFORGE.md"]);
|
||||
expect(prompt.indexOf("global soulforge")).toBeGreaterThan(prompt.indexOf("project soulforge"));
|
||||
});
|
||||
|
||||
it("loads global instructions from homedir() by default", () => {
|
||||
const actualHome = process.env.HOME ?? tmpdir();
|
||||
const homeInstructionsDir = join(actualHome, ".soulforge");
|
||||
const instructionsFile = join(homeInstructionsDir, "SOULFORGE.md");
|
||||
let previousContent: string | null = null;
|
||||
let hadPrevious = false;
|
||||
|
||||
try {
|
||||
previousContent = Bun.file(instructionsFile).textSync();
|
||||
hadPrevious = true;
|
||||
} catch {}
|
||||
|
||||
mkdirSync(homeInstructionsDir, { recursive: true });
|
||||
writeFileSync(instructionsFile, "global soulforge");
|
||||
|
||||
try {
|
||||
const loaded = loadInstructions(projectDir, ["soulforge"]);
|
||||
|
||||
expect(loaded).toHaveLength(1);
|
||||
expect(loaded[0]).toMatchObject({
|
||||
source: "soulforge",
|
||||
file: ".soulforge/SOULFORGE.md",
|
||||
scope: "global",
|
||||
content: "global soulforge",
|
||||
});
|
||||
} finally {
|
||||
if (!hadPrevious) rmSync(instructionsFile, { force: true });
|
||||
else writeFileSync(instructionsFile, previousContent ?? "");
|
||||
}
|
||||
});
|
||||
|
||||
it("dedupes project and global roots when they resolve to same directory", () => {
|
||||
writeFileSync(join(projectDir, "SOULFORGE.md"), "project soulforge");
|
||||
const linkedHome = join(rootDir, "linked-home");
|
||||
symlinkSync(projectDir, linkedHome, "dir");
|
||||
|
||||
const loaded = loadInstructions(`${projectDir}/`, ["soulforge"], { homeDir: linkedHome });
|
||||
|
||||
expect(loaded).toHaveLength(1);
|
||||
expect(loaded[0]).toMatchObject({
|
||||
source: "soulforge",
|
||||
file: "SOULFORGE.md",
|
||||
scope: "project",
|
||||
content: "project soulforge",
|
||||
});
|
||||
});
|
||||
|
||||
it("dedupes symlinked global file targeting project instruction", () => {
|
||||
writeFileSync(join(projectDir, "SOULFORGE.md"), "project soulforge");
|
||||
mkdirSync(join(homeDir, ".soulforge"), { recursive: true });
|
||||
symlinkSync(join(projectDir, "SOULFORGE.md"), join(homeDir, ".soulforge", "SOULFORGE.md"));
|
||||
|
||||
const loaded = loadInstructions(projectDir, ["soulforge"], { homeDir });
|
||||
|
||||
expect(loaded).toHaveLength(1);
|
||||
expect(loaded[0]).toMatchObject({
|
||||
source: "soulforge",
|
||||
file: "SOULFORGE.md",
|
||||
scope: "project",
|
||||
content: "project soulforge",
|
||||
});
|
||||
});
|
||||
|
||||
it("invalidates ContextManager instruction cache key when instructions change", () => {
|
||||
const ctx = Object.create(ContextManager.prototype) as ContextManager;
|
||||
Object.assign(ctx as object, {
|
||||
repoMapGeneration: 0,
|
||||
skills: new Map(),
|
||||
memoryManager: { generation: 0 },
|
||||
forgeMode: "default",
|
||||
projectInstructions: "",
|
||||
projectInstructionsVersion: 0,
|
||||
isChild: false,
|
||||
});
|
||||
|
||||
const before = ctx.getInstructionsCacheKey("anthropic/claude-sonnet-4-6");
|
||||
ctx.setProjectInstructions("global soulforge");
|
||||
const after = ctx.getInstructionsCacheKey("anthropic/claude-sonnet-4-6");
|
||||
|
||||
expect(after).not.toBe(before);
|
||||
expect(after).toContain("|pi1");
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user