mirror of
https://github.com/qwibitai/nanoclaw.git
synced 2026-06-04 10:14:47 +08:00
e64bdb3016
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>
206 lines
7.3 KiB
TypeScript
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);
|
|
}
|