refactor(claude-md): compose per-group CLAUDE.md from shared base + fragments
Replace the per-group "written once at init, owned by the group" CLAUDE.md
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)
Principle: RW = per-group memory, RO = shared content. Source/skills/base
are shared; personality, config, working files, and Claude state stay
per-group.
Key changes:
- New `src/claude-md-compose.ts` — per-spawn composition +
`migrateGroupsToClaudeLocal()` one-time cutover.
- New `container/CLAUDE.md` — shared base, seeded verbatim from the
former `groups/global/CLAUDE.md`.
- `src/container-runner.ts` — swap `/workspace/global` mount for RO
`/app/CLAUDE.md`; call `composeGroupClaudeMd()` after
`initGroupFilesystem()`.
- `src/group-init.ts` — drop `.claude-global.md` symlink + initial
`CLAUDE.md` write; seed `CLAUDE.local.md` from `opts.instructions`.
- `src/index.ts` — call `migrateGroupsToClaudeLocal()` at startup.
- `src/container-config.ts` — add optional `instructions` field to
`McpServerConfig` (inline per-MCP guidance fragment).
- `container/Dockerfile` — drop dead `/workspace/global` mkdir.
- Remove obsolete `scripts/migrate-group-claude-md.ts`.
Migration (runs once at host startup, idempotent):
- Delete `.claude-global.md` symlinks in each group.
- Rename each `groups/<folder>/CLAUDE.md` → `CLAUDE.local.md`
(preserves existing per-group content as memory).
- Delete `groups/global/` directory.
Design docs: `docs/claude-md-composition.md` and `docs/shared-source.md`
(the latter is the sibling design discussion this refactor builds on).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
182
src/claude-md-compose.ts
Normal file
182
src/claude-md-compose.ts
Normal file
@@ -0,0 +1,182 @@
|
||||
/**
|
||||
* 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 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(`${skillName}.md`, {
|
||||
type: 'symlink',
|
||||
content: `${SHARED_SKILLS_CONTAINER_BASE}/${skillName}/instructions.md`,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MCP server fragments — inline instructions from container.json.
|
||||
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);
|
||||
}
|
||||
Reference in New Issue
Block a user