fix(v2): use in-tree symlink for global CLAUDE.md @import

Claude Code's @-import directive only follows paths inside the project
memory tree (cwd + ancestors). Both `@/workspace/global/CLAUDE.md` and
`@../global/CLAUDE.md` are silently ignored because `/workspace/global`
is outside `/workspace/agent` (the cwd). The import line is parsed but
the content is never loaded — validated with a sentinel passphrase test
against a live container.

Fix: drop a `.claude-global.md` symlink into each group's dir pointing
at `/workspace/global/CLAUDE.md`. The link path is absolute on container
terms (dangling on host, valid via the /workspace/global mount) and the
symlink file itself is inside cwd, so Claude's @-import is happy. The
group's CLAUDE.md imports via `@./.claude-global.md`.

- src/group-init.ts: initGroupFilesystem now drops the symlink (idempotent,
  uses lstat so existsSync doesn't trip on the dangling target on the
  host). Default CLAUDE.md body uses `@./.claude-global.md`.
- scripts/migrate-group-claude-md.ts: creates the symlink for existing
  groups and rewrites any broken `@/workspace/global/CLAUDE.md` or
  `@../global/CLAUDE.md` import line to `@./.claude-global.md`.
- groups/main/CLAUDE.md: migration rewrote the import.

Validated: live container with the symlinked import correctly surfaces
global CLAUDE.md content (passphrase `quinoa-submarine-42` added to
global, retrieved via claude -p, removed).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
gavrielc
2026-04-13 16:46:36 +03:00
parent 9a955b9b01
commit 871bfa1809
3 changed files with 96 additions and 22 deletions
+1 -2
View File
@@ -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.
+67 -19
View File
@@ -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/<folder>/.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}`,
);
+28 -1
View File
@@ -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/<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)) {