Files
nanoclaw/src/claude-md-compose.ts
T
gavrielc e64bdb3016 refactor(claude-md): split shared base into module fragments, inject name at runtime
Move every agent-specific instruction out of the shared container/CLAUDE.md
so the base is genuinely universal. Persona/identity now comes from the
system-prompt addendum (buildSystemPromptAddendum now takes assistantName
and prepends "# You are {name}"). Per-module instructions live alongside
each MCP tool source:

  container/agent-runner/src/mcp-tools/core.instructions.md
  container/agent-runner/src/mcp-tools/scheduling.instructions.md
  container/agent-runner/src/mcp-tools/self-mod.instructions.md

composeGroupClaudeMd() scans that directory and emits `module-<name>.md`
fragments as symlinks to /app/src/mcp-tools/<name>.instructions.md (valid
via the existing RO source mount). Skill fragments renamed to
`skill-<name>.md` for naming consistency with `module-*` and `mcp-*`.

Mount tightening so composer-managed files can't be clobbered by agent
writes: nested RO mounts for /workspace/agent/CLAUDE.md and
/workspace/agent/.claude-fragments/. CLAUDE.local.md (per-group memory)
stays RW as the only writable CLAUDE.md-family file.

.gitignore: ignore CLAUDE.local.md, .claude-shared.md, .claude-fragments/
everywhere, and simplify groups/ rules to ignore the whole tree (per-
installation state, not tracked).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 17:14:51 +03:00

206 lines
7.3 KiB
TypeScript

/**
* CLAUDE.md composition for agent groups.
*
* Replaces the per-group "written once at init, owned by the group" pattern
* with a host-regenerated entry point that imports:
* - a shared base (`container/CLAUDE.md` mounted RO at `/app/CLAUDE.md`)
* - optional per-skill fragments (skills that ship `instructions.md`)
* - optional per-MCP-server fragments (inline `instructions` field in
* `container.json`)
* - per-group agent memory (`CLAUDE.local.md`, auto-loaded by Claude Code)
*
* Runs on every spawn from `container-runner.buildMounts()`. Deterministic —
* same inputs produce the same CLAUDE.md, and stale fragments are pruned.
*
* See `docs/claude-md-composition.md` for the full design.
*/
import fs from 'fs';
import path from 'path';
import { GROUPS_DIR } from './config.js';
import { readContainerConfig } from './container-config.js';
import { log } from './log.js';
import type { AgentGroup } from './types.js';
// Symlink targets are container paths — dangling on host (hence the readlink
// dance instead of existsSync), valid inside the container via RO mounts.
const SHARED_CLAUDE_MD_CONTAINER_PATH = '/app/CLAUDE.md';
const SHARED_SKILLS_CONTAINER_BASE = '/app/skills';
const SHARED_MCP_TOOLS_CONTAINER_BASE = '/app/src/mcp-tools';
// Host-side source paths used to discover fragment sources at compose time.
// Resolved at call time (process.cwd() = project root) so tests can swap cwd.
const MCP_TOOLS_HOST_SUBPATH = path.join('container', 'agent-runner', 'src', 'mcp-tools');
const COMPOSED_HEADER = '<!-- Composed at spawn — do not edit. Edit CLAUDE.local.md for per-group content. -->';
/**
* Regenerate `groups/<folder>/CLAUDE.md` from the shared base, enabled skill
* fragments, and MCP server fragments declared in `container.json`. Creates
* an empty `CLAUDE.local.md` if missing.
*/
export function composeGroupClaudeMd(group: AgentGroup): void {
const groupDir = path.resolve(GROUPS_DIR, group.folder);
if (!fs.existsSync(groupDir)) {
fs.mkdirSync(groupDir, { recursive: true });
}
const sharedLink = path.join(groupDir, '.claude-shared.md');
syncSymlink(sharedLink, SHARED_CLAUDE_MD_CONTAINER_PATH);
const fragmentsDir = path.join(groupDir, '.claude-fragments');
if (!fs.existsSync(fragmentsDir)) {
fs.mkdirSync(fragmentsDir, { recursive: true });
}
// Desired fragment set.
const config = readContainerConfig(group.folder);
const desired = new Map<string, { type: 'symlink' | 'inline'; content: string }>();
// Skill fragments — every skill that ships an `instructions.md`.
// TODO (shared-source refactor): respect `container.json` skill selection.
const skillsHostDir = path.join(process.cwd(), 'container', 'skills');
if (fs.existsSync(skillsHostDir)) {
for (const skillName of fs.readdirSync(skillsHostDir)) {
const hostFragment = path.join(skillsHostDir, skillName, 'instructions.md');
if (fs.existsSync(hostFragment)) {
desired.set(`skill-${skillName}.md`, {
type: 'symlink',
content: `${SHARED_SKILLS_CONTAINER_BASE}/${skillName}/instructions.md`,
});
}
}
}
// Built-in module fragments — every MCP tool source file that ships a
// sibling `<name>.instructions.md`. These describe how the agent should
// use that module's MCP tools (schedule_task, install_packages, etc.).
// Always included — these are built-in, not toggleable.
const mcpToolsHostDir = path.join(process.cwd(), MCP_TOOLS_HOST_SUBPATH);
if (fs.existsSync(mcpToolsHostDir)) {
for (const entry of fs.readdirSync(mcpToolsHostDir)) {
const match = entry.match(/^(.+)\.instructions\.md$/);
if (!match) continue;
const moduleName = match[1];
desired.set(`module-${moduleName}.md`, {
type: 'symlink',
content: `${SHARED_MCP_TOOLS_CONTAINER_BASE}/${entry}`,
});
}
}
// MCP server fragments — inline instructions from container.json for
// user-added external MCP servers.
for (const [name, mcp] of Object.entries(config.mcpServers)) {
if (mcp.instructions) {
desired.set(`mcp-${name}.md`, {
type: 'inline',
content: mcp.instructions,
});
}
}
// Reconcile: drop stale, write desired.
for (const existing of fs.readdirSync(fragmentsDir)) {
if (!desired.has(existing)) {
fs.unlinkSync(path.join(fragmentsDir, existing));
}
}
for (const [name, frag] of desired) {
const fragPath = path.join(fragmentsDir, name);
if (frag.type === 'symlink') {
syncSymlink(fragPath, frag.content);
} else {
writeAtomic(fragPath, frag.content);
}
}
// Composed entry — imports only.
const imports = ['@./.claude-shared.md'];
for (const name of [...desired.keys()].sort()) {
imports.push(`@./.claude-fragments/${name}`);
}
const body = [COMPOSED_HEADER, ...imports, ''].join('\n');
writeAtomic(path.join(groupDir, 'CLAUDE.md'), body);
const localFile = path.join(groupDir, 'CLAUDE.local.md');
if (!fs.existsSync(localFile)) {
fs.writeFileSync(localFile, '');
}
}
/**
* One-time cutover from the `groups/global/CLAUDE.md` + `.claude-global.md`
* pattern. Idempotent — safe to run on every host startup.
*
* For each group dir:
* - remove `.claude-global.md` symlink if present
* - rename `CLAUDE.md` → `CLAUDE.local.md` (only if `CLAUDE.local.md`
* doesn't already exist — preserves pre-cutover content as per-group
* memory; after the first spawn regenerates `CLAUDE.md`, this branch
* is skipped because `CLAUDE.local.md` now exists)
*
* Globally:
* - delete `groups/global/` (content already in `container/CLAUDE.md`)
*/
export function migrateGroupsToClaudeLocal(): void {
if (!fs.existsSync(GROUPS_DIR)) return;
const actions: string[] = [];
for (const entry of fs.readdirSync(GROUPS_DIR, { withFileTypes: true })) {
if (!entry.isDirectory()) continue;
if (entry.name === 'global') continue;
const groupDir = path.join(GROUPS_DIR, entry.name);
const oldGlobalLink = path.join(groupDir, '.claude-global.md');
try {
fs.lstatSync(oldGlobalLink);
fs.unlinkSync(oldGlobalLink);
actions.push(`${entry.name}/.claude-global.md removed`);
} catch {
/* already gone */
}
const claudeMd = path.join(groupDir, 'CLAUDE.md');
const claudeLocal = path.join(groupDir, 'CLAUDE.local.md');
if (fs.existsSync(claudeMd) && !fs.existsSync(claudeLocal)) {
fs.renameSync(claudeMd, claudeLocal);
actions.push(`${entry.name}/CLAUDE.md → CLAUDE.local.md`);
}
}
const globalDir = path.join(GROUPS_DIR, 'global');
if (fs.existsSync(globalDir)) {
fs.rmSync(globalDir, { recursive: true, force: true });
actions.push('groups/global/ removed');
}
if (actions.length > 0) {
log.info('Migrated groups to CLAUDE.local.md model', { actions });
}
}
function syncSymlink(linkPath: string, target: string): void {
let currentTarget: string | null = null;
try {
currentTarget = fs.readlinkSync(linkPath);
} catch {
/* missing */
}
if (currentTarget === target) return;
try {
fs.unlinkSync(linkPath);
} catch {
/* missing */
}
fs.symlinkSync(target, linkPath);
}
function writeAtomic(filePath: string, content: string): void {
const tmp = `${filePath}.tmp-${process.pid}`;
fs.writeFileSync(tmp, content);
fs.renameSync(tmp, filePath);
}