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:
Eugene Kravets authored and GitHub committed 2026-04-20 23:03:18 +02:00
1 parent a8b416cfb2
commit 0da933fdee
11 files changed
+281 -24

No files matched your search

+1 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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)
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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.
+1 -1
View File
@@ -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 |
|------|--------|---------|
+1 -1
View File
@@ -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
View File
@@ -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);
+4 -1
View File
@@ -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
View File
@@ -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")}`;
}
+174
View File
@@ -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");
});
});