diff --git a/CLAUDE.md b/CLAUDE.md
index 40608cf..0be52d1 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -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
diff --git a/SOULFORGE.md b/SOULFORGE.md
index 6e416ba..6fc15ae 100644
--- a/SOULFORGE.md
+++ b/SOULFORGE.md
@@ -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
diff --git a/docs/headless.md b/docs/headless.md
index c5a151a..c4d73e0 100644
--- a/docs/headless.md
+++ b/docs/headless.md
@@ -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)
diff --git a/docs/prompt-system.md b/docs/prompt-system.md
index f2d5b48..5526a27 100644
--- a/docs/prompt-system.md
+++ b/docs/prompt-system.md
@@ -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
diff --git a/mintlify-docs/context/prompt-system.mdx b/mintlify-docs/context/prompt-system.mdx
index 64ead77..dc86ab9 100644
--- a/mintlify-docs/context/prompt-system.mdx
+++ b/mintlify-docs/context/prompt-system.mdx
@@ -34,7 +34,7 @@ The system prompt is assembled from these sections:
Priority list, editing rules, dispatch rules (with/without Soul Map).
- 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.).
Security patterns.
diff --git a/mintlify-docs/reference/configuration.mdx b/mintlify-docs/reference/configuration.mdx
index f76ed5c..29ffcac 100644
--- a/mintlify-docs/reference/configuration.mdx
+++ b/mintlify-docs/reference/configuration.mdx
@@ -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 |
|------|--------|---------|
diff --git a/mintlify-docs/tools/headless.mdx b/mintlify-docs/tools/headless.mdx
index 8cffb32..e69eda2 100644
--- a/mintlify-docs/tools/headless.mdx
+++ b/mintlify-docs/tools/headless.mdx
@@ -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)
diff --git a/src/components/layout/TabInstance.tsx b/src/components/layout/TabInstance.tsx
index 32cb3cf..751ebbf 100644
--- a/src/components/layout/TabInstance.tsx
+++ b/src/components/layout/TabInstance.tsx
@@ -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);
diff --git a/src/core/context/manager.ts b/src/core/context/manager.ts
index 8e654dd..e1aa733 100644
--- a/src/core/context/manager.ts
+++ b/src/core/context/manager.ts
@@ -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 {
diff --git a/src/core/instructions.ts b/src/core/instructions.ts
index 928eda5..25a7621 100644
--- a/src/core/instructions.ts
+++ b/src/core/instructions.ts
@@ -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")}`;
}
diff --git a/tests/instructions-loader.test.ts b/tests/instructions-loader.test.ts
new file mode 100644
index 0000000..12a6251
--- /dev/null
+++ b/tests/instructions-loader.test.ts
@@ -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");
+ });
+});