diff --git a/groups/main/CLAUDE.md b/groups/main/CLAUDE.md index d07793f33..cb1c3cc6b 100644 --- a/groups/main/CLAUDE.md +++ b/groups/main/CLAUDE.md @@ -1,5 +1,4 @@ -@/workspace/global/CLAUDE.md - +@./.claude-global.md # Main You are Main, a personal assistant. You help with tasks, answer questions, and can schedule reminders. diff --git a/scripts/migrate-group-claude-md.ts b/scripts/migrate-group-claude-md.ts index 568e381cc..a1c16918d 100644 --- a/scripts/migrate-group-claude-md.ts +++ b/scripts/migrate-group-claude-md.ts @@ -1,13 +1,26 @@ /** - * One-shot migration: prepend `@/workspace/global/CLAUDE.md` to each - * existing group's CLAUDE.md so it imports the global memory under the - * new model where the host no longer reads global CLAUDE.md at bootstrap. + * One-shot migration: wire each existing group up to global memory via + * an in-tree symlink + @-import. * - * - Skips entirely if `groups/global/CLAUDE.md` doesn't exist (nothing - * to import; running the script would just add a broken @-import). - * - Skips any group whose CLAUDE.md already references - * `/workspace/global/CLAUDE.md` (idempotent). - * - Skips groups with no CLAUDE.md (nothing to prepend to). + * Claude Code's @-import only follows paths inside cwd, so a direct + * `@/workspace/global/CLAUDE.md` or `@../global/CLAUDE.md` silently does + * nothing (the import line is parsed but the target file is never + * loaded into context). The working approach: + * + * 1. Symlink `groups//.claude-global.md` → + * `/workspace/global/CLAUDE.md` (container path; dangling on host, + * valid inside the container via the /workspace/global mount). + * 2. Have the group's CLAUDE.md import the symlink: + * `@./.claude-global.md`. + * + * This script: + * - Creates the symlink if missing. + * - Replaces any existing broken `@/workspace/global/CLAUDE.md` or + * `@../global/CLAUDE.md` import line with the symlink form. + * - Prepends the symlink import if neither form is present. + * - Skips entirely if `groups/global/CLAUDE.md` doesn't exist. + * + * Idempotent — safe to re-run. * * Usage: npx tsx scripts/migrate-group-claude-md.ts */ @@ -17,11 +30,14 @@ import path from 'path'; import { GROUPS_DIR } from '../src/config.js'; const GLOBAL_CLAUDE_MD = path.join(GROUPS_DIR, 'global', 'CLAUDE.md'); -const IMPORT_LINE = '@/workspace/global/CLAUDE.md'; -// Must match the @-import syntax exactly — a bare path reference inside -// instructional prose ("you can write to /workspace/global/CLAUDE.md") -// shouldn't count as "already wired." -const IMPORT_REGEX = /@\/workspace\/global\/CLAUDE\.md/; +const GLOBAL_MEMORY_CONTAINER_PATH = '/workspace/global/CLAUDE.md'; +const GLOBAL_MEMORY_LINK_NAME = '.claude-global.md'; +const IMPORT_LINE = `@./${GLOBAL_MEMORY_LINK_NAME}`; + +// Match any existing @-import that points at global/CLAUDE.md, whether +// via absolute path, relative path, or the new symlink form. +const EXISTING_IMPORT_REGEX = + /^@(?:\/workspace\/global\/CLAUDE\.md|\.\.\/global\/CLAUDE\.md|\.\/\.claude-global\.md)\s*$/m; if (!fs.existsSync(GLOBAL_CLAUDE_MD)) { console.error(`No global CLAUDE.md at ${GLOBAL_CLAUDE_MD} — nothing to migrate.`); @@ -37,12 +53,31 @@ const entries = fs.readdirSync(GROUPS_DIR, { withFileTypes: true }); let updated = 0; let alreadyWired = 0; let missingClaudeMd = 0; +let symlinksCreated = 0; for (const entry of entries) { if (!entry.isDirectory()) continue; - if (entry.name === 'global') continue; // not a group + if (entry.name === 'global') continue; - const claudeMd = path.join(GROUPS_DIR, entry.name, 'CLAUDE.md'); + const groupDir = path.join(GROUPS_DIR, entry.name); + + // Symlink (idempotent — skip if already present) + const linkPath = path.join(groupDir, GLOBAL_MEMORY_LINK_NAME); + let linkExists = false; + try { + fs.lstatSync(linkPath); + linkExists = true; + } catch { + /* missing */ + } + if (!linkExists) { + fs.symlinkSync(GLOBAL_MEMORY_CONTAINER_PATH, linkPath); + console.log(`[link] ${entry.name}: created ${GLOBAL_MEMORY_LINK_NAME}`); + symlinksCreated++; + } + + // CLAUDE.md import wiring + const claudeMd = path.join(groupDir, 'CLAUDE.md'); if (!fs.existsSync(claudeMd)) { console.log(`[skip] ${entry.name}: no CLAUDE.md`); missingClaudeMd++; @@ -50,16 +85,29 @@ for (const entry of entries) { } const body = fs.readFileSync(claudeMd, 'utf-8'); - if (IMPORT_REGEX.test(body)) { + const match = body.match(EXISTING_IMPORT_REGEX); + + if (match && match[0] === IMPORT_LINE) { console.log(`[wired] ${entry.name}: already imports ${IMPORT_LINE}`); alreadyWired++; continue; } - const newBody = `${IMPORT_LINE}\n\n${body}`; + let newBody: string; + if (match) { + // Replace the broken import with the working form + newBody = body.replace(EXISTING_IMPORT_REGEX, IMPORT_LINE); + console.log(`[fix] ${entry.name}: rewrote ${match[0]} → ${IMPORT_LINE}`); + } else { + // Prepend fresh + newBody = `${IMPORT_LINE}\n\n${body}`; + console.log(`[ok] ${entry.name}: prepended ${IMPORT_LINE}`); + } + fs.writeFileSync(claudeMd, newBody); - console.log(`[ok] ${entry.name}: prepended import`); updated++; } -console.log(`\nDone. updated=${updated} alreadyWired=${alreadyWired} missingClaudeMd=${missingClaudeMd}`); +console.log( + `\nDone. updated=${updated} alreadyWired=${alreadyWired} missingClaudeMd=${missingClaudeMd} symlinksCreated=${symlinksCreated}`, +); diff --git a/src/group-init.ts b/src/group-init.ts index 6419632a2..a04679ff5 100644 --- a/src/group-init.ts +++ b/src/group-init.ts @@ -5,7 +5,17 @@ import { DATA_DIR, GROUPS_DIR } from './config.js'; import { log } from './log.js'; import type { AgentGroup } from './types.js'; -const GLOBAL_CLAUDE_IMPORT = '@/workspace/global/CLAUDE.md'; +// 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( @@ -41,6 +51,23 @@ export function initGroupFilesystem(group: AgentGroup, opts?: { instructions?: s initialized.push('groupDir'); } + // groups//.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//CLAUDE.md — written once, then owned by the group const claudeMdFile = path.join(groupDir, 'CLAUDE.md'); if (!fs.existsSync(claudeMdFile)) {