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"); + }); +});