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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user