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

View File

@@ -18,6 +18,10 @@ export interface McpServerConfig {
command: string;
args?: string[];
env?: Record<string, string>;
// Optional always-in-context guidance. When set, the host writes the
// content to `.claude-fragments/mcp-<name>.md` at spawn and imports it
// into the composed CLAUDE.md.
instructions?: string;
}
export interface AdditionalMountConfig {

View File

@@ -12,6 +12,7 @@ import { OneCLI } from '@onecli-sh/sdk';
import { CONTAINER_IMAGE, DATA_DIR, GROUPS_DIR, ONECLI_URL, TIMEZONE } from './config.js';
import { readContainerConfig, writeContainerConfig } from './container-config.js';
import { CONTAINER_RUNTIME_BIN, hostGatewayArgs, readonlyMountArgs, stopContainer } from './container-runtime.js';
import { composeGroupClaudeMd } from './claude-md-compose.js';
import { getAgentGroup } from './db/agent-groups.js';
import { getDb, hasTable } from './db/connection.js';
import { initGroupFilesystem } from './group-init.js';
@@ -195,6 +196,10 @@ function buildMounts(
const claudeDir = path.join(DATA_DIR, 'v2-sessions', agentGroup.id, '.claude-shared');
syncSkillSymlinks(claudeDir, containerConfig);
// Compose CLAUDE.md fresh every spawn from the shared base, enabled skill
// fragments, and MCP server instructions. See `claude-md-compose.ts`.
composeGroupClaudeMd(agentGroup);
const mounts: VolumeMount[] = [];
const sessDir = sessionDir(agentGroup.id, session.id);
const groupDir = path.resolve(GROUPS_DIR, agentGroup.folder);
@@ -218,6 +223,13 @@ function buildMounts(
mounts.push({ hostPath: globalDir, containerPath: '/workspace/global', readonly: true });
}
// Shared CLAUDE.md — read-only, imported by the composed entry point via
// the `.claude-shared.md` symlink inside the group dir.
const sharedClaudeMd = path.join(process.cwd(), 'container', 'CLAUDE.md');
if (fs.existsSync(sharedClaudeMd)) {
mounts.push({ hostPath: sharedClaudeMd, containerPath: '/app/CLAUDE.md', readonly: true });
}
// Per-group .claude-shared at /home/node/.claude (Claude state, settings,
// skill symlinks)
mounts.push({ hostPath: claudeDir, containerPath: '/home/node/.claude', readonly: false });

View File

@@ -6,18 +6,6 @@ import { initContainerConfig } from './container-config.js';
import { log } from './log.js';
import type { AgentGroup } from './types.js';
// Container path where groups/global is mounted. The symlink we drop
// into each group's dir resolves to this target inside the container.
// It's a dangling symlink on the host — that's fine, host tools don't
// follow it and the container mount makes it valid at read time.
const GLOBAL_MEMORY_CONTAINER_PATH = '/workspace/global/CLAUDE.md';
// Symlink name inside the group's dir. Claude Code's @-import only
// follows paths inside cwd, so we can't reference /workspace/global
// directly — we symlink into the group dir and import the symlink.
export const GLOBAL_MEMORY_LINK_NAME = '.claude-global.md';
export const GLOBAL_CLAUDE_IMPORT = `@./${GLOBAL_MEMORY_LINK_NAME}`;
const DEFAULT_SETTINGS_JSON =
JSON.stringify(
{
@@ -36,11 +24,15 @@ const DEFAULT_SETTINGS_JSON =
* every step is gated on the target not already existing, so re-running on
* an already-initialized group is a no-op.
*
* Called once per group lifetime: at creation, or defensively from
* Called once per group lifetime at creation, or defensively from
* `buildMounts()` for groups that pre-date this code path.
*
* Source code and skills are shared RO mounts — not copied per-group.
* Skill symlinks are synced at spawn time by container-runner.ts.
*
* The composed `CLAUDE.md` is NOT written here — it's regenerated on every
* spawn by `composeGroupClaudeMd()` (see `claude-md-compose.ts`). Initial
* per-group instructions (if provided) seed `CLAUDE.local.md`.
*/
export function initGroupFilesystem(group: AgentGroup, opts?: { instructions?: string }): void {
const initialized: string[] = [];
@@ -52,29 +44,13 @@ export function initGroupFilesystem(group: AgentGroup, opts?: { instructions?: s
initialized.push('groupDir');
}
// groups/<folder>/.claude-global.md — symlink into the group dir so
// Claude Code's @-import can follow it. Uses lstat to avoid tripping
// existsSync on a dangling symlink (target only resolves inside the
// container).
const globalLinkPath = path.join(groupDir, GLOBAL_MEMORY_LINK_NAME);
let linkExists = false;
try {
fs.lstatSync(globalLinkPath);
linkExists = true;
} catch {
/* missing — recreate */
}
if (!linkExists) {
fs.symlinkSync(GLOBAL_MEMORY_CONTAINER_PATH, globalLinkPath);
initialized.push('.claude-global.md');
}
// groups/<folder>/CLAUDE.md — written once, then owned by the group
const claudeMdFile = path.join(groupDir, 'CLAUDE.md');
if (!fs.existsSync(claudeMdFile)) {
const body = [GLOBAL_CLAUDE_IMPORT, '', opts?.instructions ?? `# ${group.name}`].join('\n') + '\n';
fs.writeFileSync(claudeMdFile, body);
initialized.push('CLAUDE.md');
// groups/<folder>/CLAUDE.local.md — per-group agent memory, auto-loaded by
// Claude Code. Seeded with caller-provided instructions on first creation.
const claudeLocalFile = path.join(groupDir, 'CLAUDE.local.md');
if (!fs.existsSync(claudeLocalFile)) {
const body = opts?.instructions ? opts.instructions + '\n' : '';
fs.writeFileSync(claudeLocalFile, body);
initialized.push('CLAUDE.local.md');
}
// groups/<folder>/container.json — empty container config, replaces the

View File

@@ -7,6 +7,7 @@
import path from 'path';
import { DATA_DIR } from './config.js';
import { migrateGroupsToClaudeLocal } from './claude-md-compose.js';
import { initDb } from './db/connection.js';
import { runMigrations } from './db/migrations/index.js';
import { ensureContainerRuntimeRunning, cleanupOrphans } from './container-runtime.js';
@@ -63,6 +64,9 @@ async function main(): Promise<void> {
runMigrations(db);
log.info('Central DB ready', { path: dbPath });
// 1b. One-time filesystem cutover — idempotent, no-op after first run.
migrateGroupsToClaudeLocal();
// 2. Container runtime
ensureContainerRuntimeRunning();
cleanupOrphans();