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:
gavrielc
2026-04-22 03:18:12 +03:00
parent 8a12fa61ac
commit c8fc1da719
11 changed files with 802 additions and 150 deletions

182
src/claude-md-compose.ts Normal file
View 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);
}