From 675acffeb1656b43a4470b01495bd88dfd8bf78f Mon Sep 17 00:00:00 2001 From: Gabi Simons Date: Wed, 18 Mar 2026 12:57:40 +0200 Subject: [PATCH 01/20] feat: add script field to ScheduledTask type and database layer Adds optional `script` field to the ScheduledTask interface, with a migration for existing DBs and full support in createTask/updateTask. Co-Authored-By: Claude Sonnet 4.6 --- src/db.ts | 20 +++++++++++++++++--- src/types.ts | 1 + 2 files changed, 18 insertions(+), 3 deletions(-) diff --git a/src/db.ts b/src/db.ts index 0896f41..36e3edc 100644 --- a/src/db.ts +++ b/src/db.ts @@ -93,6 +93,15 @@ function createSchema(database: Database.Database): void { /* column already exists */ } + // Add script column if it doesn't exist (migration for existing DBs) + try { + database.exec( + `ALTER TABLE scheduled_tasks ADD COLUMN script TEXT`, + ); + } catch { + /* column already exists */ + } + // Add is_bot_message column if it doesn't exist (migration for existing DBs) try { database.exec( @@ -368,14 +377,15 @@ export function createTask( ): void { db.prepare( ` - INSERT INTO scheduled_tasks (id, group_folder, chat_jid, prompt, schedule_type, schedule_value, context_mode, next_run, status, created_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + INSERT INTO scheduled_tasks (id, group_folder, chat_jid, prompt, script, schedule_type, schedule_value, context_mode, next_run, status, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `, ).run( task.id, task.group_folder, task.chat_jid, task.prompt, + task.script || null, task.schedule_type, task.schedule_value, task.context_mode || 'isolated', @@ -410,7 +420,7 @@ export function updateTask( updates: Partial< Pick< ScheduledTask, - 'prompt' | 'schedule_type' | 'schedule_value' | 'next_run' | 'status' + 'prompt' | 'script' | 'schedule_type' | 'schedule_value' | 'next_run' | 'status' > >, ): void { @@ -421,6 +431,10 @@ export function updateTask( fields.push('prompt = ?'); values.push(updates.prompt); } + if (updates.script !== undefined) { + fields.push('script = ?'); + values.push(updates.script || null); + } if (updates.schedule_type !== undefined) { fields.push('schedule_type = ?'); values.push(updates.schedule_type); diff --git a/src/types.ts b/src/types.ts index acbb08a..bcef463 100644 --- a/src/types.ts +++ b/src/types.ts @@ -58,6 +58,7 @@ export interface ScheduledTask { group_folder: string; chat_jid: string; prompt: string; + script?: string | null; schedule_type: 'cron' | 'interval' | 'once'; schedule_value: string; context_mode: 'group' | 'isolated'; From a516cc5cfea2eceb14cd694df3b39d0356835ea9 Mon Sep 17 00:00:00 2001 From: Gabi Simons Date: Wed, 18 Mar 2026 13:28:36 +0200 Subject: [PATCH 02/20] feat: add script parameter to MCP task tools Add optional `script` field to schedule_task and update_task MCP tools, allowing agents to attach a pre-flight bash script that controls whether the task agent is woken up. Co-Authored-By: Claude Sonnet 4.6 --- container/agent-runner/src/ipc-mcp-stdio.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/container/agent-runner/src/ipc-mcp-stdio.ts b/container/agent-runner/src/ipc-mcp-stdio.ts index 9de0138..5b03478 100644 --- a/container/agent-runner/src/ipc-mcp-stdio.ts +++ b/container/agent-runner/src/ipc-mcp-stdio.ts @@ -91,6 +91,7 @@ SCHEDULE VALUE FORMAT (all times are LOCAL timezone): schedule_value: z.string().describe('cron: "*/5 * * * *" | interval: milliseconds like "300000" | once: local timestamp like "2026-02-01T15:30:00" (no Z suffix!)'), context_mode: z.enum(['group', 'isolated']).default('group').describe('group=runs with chat history and memory, isolated=fresh session (include context in prompt)'), target_group_jid: z.string().optional().describe('(Main group only) JID of the group to schedule the task for. Defaults to the current group.'), + script: z.string().optional().describe('Optional bash script to run before waking the agent. Script must output JSON on the last line of stdout: { "wakeAgent": boolean, "data"?: any }. If wakeAgent is false, the agent is not called. Test your script with bash -c "..." before scheduling.'), }, async (args) => { // Validate schedule_value before writing IPC @@ -136,6 +137,7 @@ SCHEDULE VALUE FORMAT (all times are LOCAL timezone): type: 'schedule_task', taskId, prompt: args.prompt, + script: args.script || undefined, schedule_type: args.schedule_type, schedule_value: args.schedule_value, context_mode: args.context_mode || 'group', @@ -255,6 +257,7 @@ server.tool( prompt: z.string().optional().describe('New prompt for the task'), schedule_type: z.enum(['cron', 'interval', 'once']).optional().describe('New schedule type'), schedule_value: z.string().optional().describe('New schedule value (see schedule_task for format)'), + script: z.string().optional().describe('New script for the task. Set to empty string to remove the script.'), }, async (args) => { // Validate schedule_value if provided @@ -288,6 +291,7 @@ server.tool( timestamp: new Date().toISOString(), }; if (args.prompt !== undefined) data.prompt = args.prompt; + if (args.script !== undefined) data.script = args.script; if (args.schedule_type !== undefined) data.schedule_type = args.schedule_type; if (args.schedule_value !== undefined) data.schedule_value = args.schedule_value; From 0f283cbdd33a594665812ac4997e9ed0f736caf1 Mon Sep 17 00:00:00 2001 From: Gabi Simons Date: Wed, 18 Mar 2026 13:31:12 +0200 Subject: [PATCH 03/20] feat: pass script through IPC task processing Thread the optional `script` field through the IPC layer so it is persisted when an agent calls schedule_task, and updated when an agent calls update_task (empty string clears the script). Co-Authored-By: Claude Sonnet 4.6 --- src/ipc.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/ipc.ts b/src/ipc.ts index 48efeb5..043b07a 100644 --- a/src/ipc.ts +++ b/src/ipc.ts @@ -162,6 +162,7 @@ export async function processTaskIpc( schedule_type?: string; schedule_value?: string; context_mode?: string; + script?: string; groupFolder?: string; chatJid?: string; targetJid?: string; @@ -260,6 +261,7 @@ export async function processTaskIpc( group_folder: targetFolder, chat_jid: targetJid, prompt: data.prompt, + script: data.script || null, schedule_type: scheduleType, schedule_value: data.schedule_value, context_mode: contextMode, @@ -352,6 +354,7 @@ export async function processTaskIpc( const updates: Parameters[1] = {}; if (data.prompt !== undefined) updates.prompt = data.prompt; + if (data.script !== undefined) updates.script = data.script || null; if (data.schedule_type !== undefined) updates.schedule_type = data.schedule_type as | 'cron' From eb65121938210a1b8cb4d5909843d7be518c2fa1 Mon Sep 17 00:00:00 2001 From: Gabi Simons Date: Wed, 18 Mar 2026 13:38:14 +0200 Subject: [PATCH 04/20] feat: add script to ContainerInput and task snapshot Co-Authored-By: Claude Sonnet 4.6 --- container/agent-runner/src/index.ts | 1 + src/container-runner.ts | 2 ++ 2 files changed, 3 insertions(+) diff --git a/container/agent-runner/src/index.ts b/container/agent-runner/src/index.ts index 96cb4a4..2cd34c9 100644 --- a/container/agent-runner/src/index.ts +++ b/container/agent-runner/src/index.ts @@ -27,6 +27,7 @@ interface ContainerInput { isMain: boolean; isScheduledTask?: boolean; assistantName?: string; + script?: string; } interface ContainerOutput { diff --git a/src/container-runner.ts b/src/container-runner.ts index be6f356..469fe11 100644 --- a/src/container-runner.ts +++ b/src/container-runner.ts @@ -41,6 +41,7 @@ export interface ContainerInput { isMain: boolean; isScheduledTask?: boolean; assistantName?: string; + script?: string; } export interface ContainerOutput { @@ -649,6 +650,7 @@ export function writeTasksSnapshot( id: string; groupFolder: string; prompt: string; + script?: string | null; schedule_type: string; schedule_value: string; status: string; From 42d098c3c1f5835f8cd77dd0205f74b687239b25 Mon Sep 17 00:00:00 2001 From: Gabi Simons Date: Wed, 18 Mar 2026 13:38:28 +0200 Subject: [PATCH 05/20] feat: pass script from task scheduler to container Co-Authored-By: Claude Sonnet 4.6 --- src/task-scheduler.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/task-scheduler.ts b/src/task-scheduler.ts index d0abd2e..f2b964d 100644 --- a/src/task-scheduler.ts +++ b/src/task-scheduler.ts @@ -139,6 +139,7 @@ async function runTask( id: t.id, groupFolder: t.group_folder, prompt: t.prompt, + script: t.script, schedule_type: t.schedule_type, schedule_value: t.schedule_value, status: t.status, @@ -179,6 +180,7 @@ async function runTask( isMain, isScheduledTask: true, assistantName: ASSISTANT_NAME, + script: task.script || undefined, }, (proc, containerName) => deps.onProcess(task.chat_jid, proc, containerName, task.group_folder), From 9f5aff99b68b0dbd9c23f4ac6907f29d2a7036df Mon Sep 17 00:00:00 2001 From: Gabi Simons Date: Wed, 18 Mar 2026 13:43:56 +0200 Subject: [PATCH 06/20] feat: add script execution phase to agent-runner Co-Authored-By: Claude Opus 4.6 (1M context) --- container/agent-runner/src/index.ts | 70 +++++++++++++++++++++++++++++ 1 file changed, 70 insertions(+) diff --git a/container/agent-runner/src/index.ts b/container/agent-runner/src/index.ts index 2cd34c9..382439f 100644 --- a/container/agent-runner/src/index.ts +++ b/container/agent-runner/src/index.ts @@ -16,6 +16,7 @@ import fs from 'fs'; import path from 'path'; +import { execFile } from 'child_process'; import { query, HookCallback, PreCompactHookInput } from '@anthropic-ai/claude-agent-sdk'; import { fileURLToPath } from 'url'; @@ -465,6 +466,55 @@ async function runQuery( return { newSessionId, lastAssistantUuid, closedDuringQuery }; } +interface ScriptResult { + wakeAgent: boolean; + data?: unknown; +} + +const SCRIPT_TIMEOUT_MS = 30_000; + +async function runScript(script: string): Promise { + const scriptPath = '/tmp/task-script.sh'; + fs.writeFileSync(scriptPath, script, { mode: 0o755 }); + + return new Promise((resolve) => { + execFile('bash', [scriptPath], { + timeout: SCRIPT_TIMEOUT_MS, + maxBuffer: 1024 * 1024, + env: process.env, + }, (error, stdout, stderr) => { + if (stderr) { + log(`Script stderr: ${stderr.slice(0, 500)}`); + } + + if (error) { + log(`Script error: ${error.message}`); + return resolve(null); + } + + // Parse last non-empty line of stdout as JSON + const lines = stdout.trim().split('\n'); + const lastLine = lines[lines.length - 1]; + if (!lastLine) { + log('Script produced no output'); + return resolve(null); + } + + try { + const result = JSON.parse(lastLine); + if (typeof result.wakeAgent !== 'boolean') { + log(`Script output missing wakeAgent boolean: ${lastLine.slice(0, 200)}`); + return resolve(null); + } + resolve(result as ScriptResult); + } catch { + log(`Script output is not valid JSON: ${lastLine.slice(0, 200)}`); + resolve(null); + } + }); + }); +} + async function main(): Promise { let containerInput: ContainerInput; @@ -506,6 +556,26 @@ async function main(): Promise { prompt += '\n' + pending.join('\n'); } + // Script phase: run script before waking agent + if (containerInput.script && containerInput.isScheduledTask) { + log('Running task script...'); + const scriptResult = await runScript(containerInput.script); + + if (!scriptResult || !scriptResult.wakeAgent) { + const reason = scriptResult ? 'wakeAgent=false' : 'script error/no output'; + log(`Script decided not to wake agent: ${reason}`); + writeOutput({ + status: 'success', + result: `Script: ${reason}`, + }); + return; + } + + // Script says wake agent — enrich prompt with script data + log(`Script wakeAgent=true, enriching prompt with data`); + prompt = `[SCHEDULED TASK]\n\nScript output:\n${JSON.stringify(scriptResult.data, null, 2)}\n\nInstructions:\n${containerInput.prompt}`; + } + // Query loop: run query → wait for IPC message → run new query → repeat let resumeAt: string | undefined; try { From a4dc3a744668e3202ce97c740d68c4cf1b3bb1a7 Mon Sep 17 00:00:00 2001 From: Gabi Simons Date: Wed, 18 Mar 2026 13:45:01 +0200 Subject: [PATCH 07/20] docs: add task script instructions to agent CLAUDE.md --- groups/main/CLAUDE.md | 39 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/groups/main/CLAUDE.md b/groups/main/CLAUDE.md index 11e846b..0580e4b 100644 --- a/groups/main/CLAUDE.md +++ b/groups/main/CLAUDE.md @@ -244,3 +244,42 @@ When scheduling tasks for other groups, use the `target_group_jid` parameter wit - `schedule_task(prompt: "...", schedule_type: "cron", schedule_value: "0 9 * * 1", target_group_jid: "120363336345536173@g.us")` The task will run in that group's context with access to their files and memory. + +--- + +## Task Scripts + +When scheduling tasks that check a condition before acting (new PRs, website changes, API status), use the `script` parameter. The script runs first — if there's nothing to do, you don't wake up. + +### How it works + +1. You provide a bash `script` alongside the `prompt` when scheduling +2. When the task fires, the script runs first (30-second timeout) +3. Script prints JSON to stdout: `{ "wakeAgent": true/false, "data": {...} }` +4. If `wakeAgent: false` — nothing happens, task waits for next run +5. If `wakeAgent: true` — you wake up and receive the script's data + prompt + +### Always test your script first + +Before scheduling, run the script in your sandbox to verify it works: + +```bash +bash -c 'node --input-type=module -e " + const r = await fetch(\"https://api.github.com/repos/owner/repo/pulls?state=open\"); + const prs = await r.json(); + console.log(JSON.stringify({ wakeAgent: prs.length > 0, data: prs.slice(0, 5) })); +"' +``` + +### When NOT to use scripts + +If a task requires your judgment every time (daily briefings, reminders, reports), skip the script — just use a regular prompt. + +### Frequent task guidance + +If a user wants tasks running more than ~2x daily and a script can't reduce agent wake-ups: + +- Explain that each wake-up uses API credits and risks rate limits +- Suggest restructuring with a script that checks the condition first +- If the user needs an LLM to evaluate data, suggest using an API key with direct Anthropic API calls inside the script +- Help the user find the minimum viable frequency From b7f1d48423646e825500e02618c2a62b12d1dd9f Mon Sep 17 00:00:00 2001 From: Gabi Simons Date: Wed, 18 Mar 2026 14:04:31 +0200 Subject: [PATCH 08/20] style: fix prettier formatting in db.ts --- src/db.ts | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/src/db.ts b/src/db.ts index 36e3edc..87dd941 100644 --- a/src/db.ts +++ b/src/db.ts @@ -95,9 +95,7 @@ function createSchema(database: Database.Database): void { // Add script column if it doesn't exist (migration for existing DBs) try { - database.exec( - `ALTER TABLE scheduled_tasks ADD COLUMN script TEXT`, - ); + database.exec(`ALTER TABLE scheduled_tasks ADD COLUMN script TEXT`); } catch { /* column already exists */ } @@ -420,7 +418,12 @@ export function updateTask( updates: Partial< Pick< ScheduledTask, - 'prompt' | 'script' | 'schedule_type' | 'schedule_value' | 'next_run' | 'status' + | 'prompt' + | 'script' + | 'schedule_type' + | 'schedule_value' + | 'next_run' + | 'status' > >, ): void { From 5a12ddd4cba283bec12bb68c5c712e2dfded1700 Mon Sep 17 00:00:00 2001 From: glifocat Date: Thu, 5 Mar 2026 20:38:32 +0000 Subject: [PATCH 09/20] fix(register): create CLAUDE.md in group folder from template When registering a new group, create CLAUDE.md in the group folder from the appropriate template (groups/main/ for main groups, groups/global/ for others). Without this, the container agent runs with no CLAUDE.md since its CWD is /workspace/group (the group folder). Also update the name-replacement glob to cover all groups/*/CLAUDE.md files rather than only two hardcoded paths, so newly created files and any future group folders are updated correctly. Co-Authored-By: Claude Sonnet 4.6 --- setup/register.ts | 30 ++++++++++++++++++++++++++---- 1 file changed, 26 insertions(+), 4 deletions(-) diff --git a/setup/register.ts b/setup/register.ts index eeafa90..6e32cd8 100644 --- a/setup/register.ts +++ b/setup/register.ts @@ -116,6 +116,27 @@ export async function run(args: string[]): Promise { recursive: true, }); + // Create CLAUDE.md in the new group folder from template if it doesn't exist. + // The agent runs with CWD=/workspace/group and loads CLAUDE.md from there. + const groupClaudeMdPath = path.join( + projectRoot, + 'groups', + parsed.folder, + 'CLAUDE.md', + ); + if (!fs.existsSync(groupClaudeMdPath)) { + const templatePath = parsed.isMain + ? path.join(projectRoot, 'groups', 'main', 'CLAUDE.md') + : path.join(projectRoot, 'groups', 'global', 'CLAUDE.md'); + if (fs.existsSync(templatePath)) { + fs.copyFileSync(templatePath, groupClaudeMdPath); + logger.info( + { file: groupClaudeMdPath, template: templatePath }, + 'Created CLAUDE.md from template', + ); + } + } + // Update assistant name in CLAUDE.md files if different from default let nameUpdated = false; if (parsed.assistantName !== 'Andy') { @@ -124,10 +145,11 @@ export async function run(args: string[]): Promise { 'Updating assistant name', ); - const mdFiles = [ - path.join(projectRoot, 'groups', 'global', 'CLAUDE.md'), - path.join(projectRoot, 'groups', parsed.folder, 'CLAUDE.md'), - ]; + const groupsDir = path.join(projectRoot, 'groups'); + const mdFiles = fs + .readdirSync(groupsDir) + .map((d) => path.join(groupsDir, d, 'CLAUDE.md')) + .filter((f) => fs.existsSync(f)); for (const mdFile of mdFiles) { if (fs.existsSync(mdFile)) { From b6e18688c206b2e50961a17cc9232c2b6fd83877 Mon Sep 17 00:00:00 2001 From: glifocat Date: Tue, 24 Mar 2026 12:35:13 +0100 Subject: [PATCH 10/20] test: add coverage for CLAUDE.md template copy in register step Adds 5 tests verifying the template copy and glob-based name update logic introduced in the parent commit: - copies global template for non-main groups - copies main template for main groups - does not overwrite existing CLAUDE.md - updates name across all groups/*/CLAUDE.md files - handles missing template gracefully (no crash) Co-Authored-By: Claude Opus 4.6 (1M context) --- setup/register.test.ts | 152 ++++++++++++++++++++++++++++++++++++++++- 1 file changed, 150 insertions(+), 2 deletions(-) diff --git a/setup/register.test.ts b/setup/register.test.ts index d47d95c..b3bd463 100644 --- a/setup/register.test.ts +++ b/setup/register.test.ts @@ -1,4 +1,7 @@ -import { describe, it, expect, beforeEach } from 'vitest'; +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import { afterEach, describe, it, expect, beforeEach } from 'vitest'; import Database from 'better-sqlite3'; @@ -6,7 +9,7 @@ import Database from 'better-sqlite3'; * Tests for the register step. * * Verifies: parameterized SQL (no injection), file templating, - * apostrophe in names, .env updates. + * apostrophe in names, .env updates, CLAUDE.md template copy. */ function createTestDb(): Database.Database { @@ -255,3 +258,148 @@ describe('file templating', () => { expect(envContent).toContain('ASSISTANT_NAME="Nova"'); }); }); + +describe('CLAUDE.md template copy', () => { + let tmpDir: string; + let groupsDir: string; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'nanoclaw-register-test-')); + groupsDir = path.join(tmpDir, 'groups'); + fs.mkdirSync(path.join(groupsDir, 'main'), { recursive: true }); + fs.mkdirSync(path.join(groupsDir, 'global'), { recursive: true }); + fs.writeFileSync( + path.join(groupsDir, 'main', 'CLAUDE.md'), + '# Andy\n\nYou are Andy, a personal assistant.\n\n## Admin Context\n\nThis is the **main channel**.', + ); + fs.writeFileSync( + path.join(groupsDir, 'global', 'CLAUDE.md'), + '# Andy\n\nYou are Andy, a personal assistant.', + ); + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('copies global template for non-main group', () => { + const folder = 'telegram_dev-team'; + const folderDir = path.join(groupsDir, folder); + fs.mkdirSync(path.join(folderDir, 'logs'), { recursive: true }); + + const dest = path.join(folderDir, 'CLAUDE.md'); + const templatePath = path.join(groupsDir, 'global', 'CLAUDE.md'); + + // Replicate register.ts logic: copy template if dest doesn't exist + if (!fs.existsSync(dest)) { + if (fs.existsSync(templatePath)) { + fs.copyFileSync(templatePath, dest); + } + } + + expect(fs.existsSync(dest)).toBe(true); + expect(fs.readFileSync(dest, 'utf-8')).toContain('You are Andy'); + // Should NOT contain main-specific content + expect(fs.readFileSync(dest, 'utf-8')).not.toContain('Admin Context'); + }); + + it('copies main template for main group', () => { + const folder = 'whatsapp_main'; + const folderDir = path.join(groupsDir, folder); + fs.mkdirSync(path.join(folderDir, 'logs'), { recursive: true }); + + const dest = path.join(folderDir, 'CLAUDE.md'); + const isMain = true; + const templatePath = isMain + ? path.join(groupsDir, 'main', 'CLAUDE.md') + : path.join(groupsDir, 'global', 'CLAUDE.md'); + + if (!fs.existsSync(dest)) { + if (fs.existsSync(templatePath)) { + fs.copyFileSync(templatePath, dest); + } + } + + expect(fs.existsSync(dest)).toBe(true); + expect(fs.readFileSync(dest, 'utf-8')).toContain('Admin Context'); + }); + + it('does not overwrite existing CLAUDE.md', () => { + const folder = 'slack_main'; + const folderDir = path.join(groupsDir, folder); + fs.mkdirSync(folderDir, { recursive: true }); + + const dest = path.join(folderDir, 'CLAUDE.md'); + fs.writeFileSync(dest, '# Custom\n\nUser-modified content.'); + + const templatePath = path.join(groupsDir, 'global', 'CLAUDE.md'); + if (!fs.existsSync(dest)) { + if (fs.existsSync(templatePath)) { + fs.copyFileSync(templatePath, dest); + } + } + + expect(fs.readFileSync(dest, 'utf-8')).toContain('User-modified content'); + expect(fs.readFileSync(dest, 'utf-8')).not.toContain('You are Andy'); + }); + + it('updates name in all groups/*/CLAUDE.md files', () => { + // Create a few group folders with CLAUDE.md + for (const folder of ['whatsapp_main', 'telegram_friends']) { + const dir = path.join(groupsDir, folder); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync( + path.join(dir, 'CLAUDE.md'), + '# Andy\n\nYou are Andy, a personal assistant.', + ); + } + + const assistantName = 'Luna'; + + // Replicate register.ts glob logic + const mdFiles = fs + .readdirSync(groupsDir) + .map((d) => path.join(groupsDir, d, 'CLAUDE.md')) + .filter((f) => fs.existsSync(f)); + + for (const mdFile of mdFiles) { + let content = fs.readFileSync(mdFile, 'utf-8'); + content = content.replace(/^# Andy$/m, `# ${assistantName}`); + content = content.replace(/You are Andy/g, `You are ${assistantName}`); + fs.writeFileSync(mdFile, content); + } + + // All CLAUDE.md files should be updated, including templates and groups + for (const folder of ['main', 'global', 'whatsapp_main', 'telegram_friends']) { + const content = fs.readFileSync( + path.join(groupsDir, folder, 'CLAUDE.md'), + 'utf-8', + ); + expect(content).toContain('# Luna'); + expect(content).toContain('You are Luna'); + expect(content).not.toContain('Andy'); + } + }); + + it('handles missing template gracefully', () => { + // Remove templates + fs.unlinkSync(path.join(groupsDir, 'global', 'CLAUDE.md')); + fs.unlinkSync(path.join(groupsDir, 'main', 'CLAUDE.md')); + + const folder = 'discord_general'; + const folderDir = path.join(groupsDir, folder); + fs.mkdirSync(path.join(folderDir, 'logs'), { recursive: true }); + + const dest = path.join(folderDir, 'CLAUDE.md'); + const templatePath = path.join(groupsDir, 'global', 'CLAUDE.md'); + + if (!fs.existsSync(dest)) { + if (fs.existsSync(templatePath)) { + fs.copyFileSync(templatePath, dest); + } + } + + // No crash, no file created + expect(fs.existsSync(dest)).toBe(false); + }); +}); From 07dc8c977c9b2c582896996dbe2bb4936e94269d Mon Sep 17 00:00:00 2001 From: glifocat Date: Tue, 24 Mar 2026 12:39:21 +0100 Subject: [PATCH 11/20] test: cover multi-channel main and cross-channel name propagation Replaces single-channel tests with multi-channel scenarios: - each channel can have its own main with admin context - non-main groups across channels get global template - custom name propagates to all channels and groups - user-modified CLAUDE.md preserved on re-registration - missing templates handled gracefully Co-Authored-By: Claude Opus 4.6 (1M context) --- setup/register.test.ts | 212 +++++++++++++++++++++++------------------ 1 file changed, 118 insertions(+), 94 deletions(-) diff --git a/setup/register.test.ts b/setup/register.test.ts index b3bd463..11f0f5f 100644 --- a/setup/register.test.ts +++ b/setup/register.test.ts @@ -263,6 +263,52 @@ describe('CLAUDE.md template copy', () => { let tmpDir: string; let groupsDir: string; + // Replicates register.ts template copy + name update logic + function simulateRegister( + folder: string, + isMain: boolean, + assistantName = 'Andy', + ): void { + const folderDir = path.join(groupsDir, folder); + fs.mkdirSync(path.join(folderDir, 'logs'), { recursive: true }); + + // Template copy (register.ts lines 119-138) + const dest = path.join(folderDir, 'CLAUDE.md'); + if (!fs.existsSync(dest)) { + const templatePath = isMain + ? path.join(groupsDir, 'main', 'CLAUDE.md') + : path.join(groupsDir, 'global', 'CLAUDE.md'); + if (fs.existsSync(templatePath)) { + fs.copyFileSync(templatePath, dest); + } + } + + // Name update across all groups (register.ts lines 140-165) + if (assistantName !== 'Andy') { + const mdFiles = fs + .readdirSync(groupsDir) + .map((d) => path.join(groupsDir, d, 'CLAUDE.md')) + .filter((f) => fs.existsSync(f)); + + for (const mdFile of mdFiles) { + let content = fs.readFileSync(mdFile, 'utf-8'); + content = content.replace(/^# Andy$/m, `# ${assistantName}`); + content = content.replace( + /You are Andy/g, + `You are ${assistantName}`, + ); + fs.writeFileSync(mdFile, content); + } + } + } + + function readGroupMd(folder: string): string { + return fs.readFileSync( + path.join(groupsDir, folder, 'CLAUDE.md'), + 'utf-8', + ); + } + beforeEach(() => { tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'nanoclaw-register-test-')); groupsDir = path.join(tmpDir, 'groups'); @@ -283,123 +329,101 @@ describe('CLAUDE.md template copy', () => { }); it('copies global template for non-main group', () => { - const folder = 'telegram_dev-team'; - const folderDir = path.join(groupsDir, folder); - fs.mkdirSync(path.join(folderDir, 'logs'), { recursive: true }); + simulateRegister('telegram_dev-team', false); - const dest = path.join(folderDir, 'CLAUDE.md'); - const templatePath = path.join(groupsDir, 'global', 'CLAUDE.md'); - - // Replicate register.ts logic: copy template if dest doesn't exist - if (!fs.existsSync(dest)) { - if (fs.existsSync(templatePath)) { - fs.copyFileSync(templatePath, dest); - } - } - - expect(fs.existsSync(dest)).toBe(true); - expect(fs.readFileSync(dest, 'utf-8')).toContain('You are Andy'); - // Should NOT contain main-specific content - expect(fs.readFileSync(dest, 'utf-8')).not.toContain('Admin Context'); + const content = readGroupMd('telegram_dev-team'); + expect(content).toContain('You are Andy'); + expect(content).not.toContain('Admin Context'); }); it('copies main template for main group', () => { - const folder = 'whatsapp_main'; - const folderDir = path.join(groupsDir, folder); - fs.mkdirSync(path.join(folderDir, 'logs'), { recursive: true }); + simulateRegister('whatsapp_main', true); - const dest = path.join(folderDir, 'CLAUDE.md'); - const isMain = true; - const templatePath = isMain - ? path.join(groupsDir, 'main', 'CLAUDE.md') - : path.join(groupsDir, 'global', 'CLAUDE.md'); - - if (!fs.existsSync(dest)) { - if (fs.existsSync(templatePath)) { - fs.copyFileSync(templatePath, dest); - } - } - - expect(fs.existsSync(dest)).toBe(true); - expect(fs.readFileSync(dest, 'utf-8')).toContain('Admin Context'); + expect(readGroupMd('whatsapp_main')).toContain('Admin Context'); }); - it('does not overwrite existing CLAUDE.md', () => { - const folder = 'slack_main'; - const folderDir = path.join(groupsDir, folder); - fs.mkdirSync(folderDir, { recursive: true }); + it('each channel can have its own main with admin context', () => { + simulateRegister('whatsapp_main', true); + simulateRegister('telegram_main', true); + simulateRegister('slack_main', true); + simulateRegister('discord_main', true); - const dest = path.join(folderDir, 'CLAUDE.md'); - fs.writeFileSync(dest, '# Custom\n\nUser-modified content.'); - - const templatePath = path.join(groupsDir, 'global', 'CLAUDE.md'); - if (!fs.existsSync(dest)) { - if (fs.existsSync(templatePath)) { - fs.copyFileSync(templatePath, dest); - } + for (const folder of [ + 'whatsapp_main', + 'telegram_main', + 'slack_main', + 'discord_main', + ]) { + const content = readGroupMd(folder); + expect(content).toContain('Admin Context'); + expect(content).toContain('You are Andy'); } - - expect(fs.readFileSync(dest, 'utf-8')).toContain('User-modified content'); - expect(fs.readFileSync(dest, 'utf-8')).not.toContain('You are Andy'); }); - it('updates name in all groups/*/CLAUDE.md files', () => { - // Create a few group folders with CLAUDE.md - for (const folder of ['whatsapp_main', 'telegram_friends']) { - const dir = path.join(groupsDir, folder); - fs.mkdirSync(dir, { recursive: true }); - fs.writeFileSync( - path.join(dir, 'CLAUDE.md'), - '# Andy\n\nYou are Andy, a personal assistant.', - ); + it('non-main groups across channels get global template', () => { + simulateRegister('whatsapp_main', true); + simulateRegister('telegram_friends', false); + simulateRegister('slack_engineering', false); + simulateRegister('discord_general', false); + + expect(readGroupMd('whatsapp_main')).toContain('Admin Context'); + for (const folder of [ + 'telegram_friends', + 'slack_engineering', + 'discord_general', + ]) { + const content = readGroupMd(folder); + expect(content).toContain('You are Andy'); + expect(content).not.toContain('Admin Context'); } + }); - const assistantName = 'Luna'; + it('custom name propagates to all channels and groups', () => { + // Register multiple channels, last one sets custom name + simulateRegister('whatsapp_main', true); + simulateRegister('telegram_main', true); + simulateRegister('slack_devs', false); + // Final registration triggers name update across all + simulateRegister('discord_main', true, 'Luna'); - // Replicate register.ts glob logic - const mdFiles = fs - .readdirSync(groupsDir) - .map((d) => path.join(groupsDir, d, 'CLAUDE.md')) - .filter((f) => fs.existsSync(f)); - - for (const mdFile of mdFiles) { - let content = fs.readFileSync(mdFile, 'utf-8'); - content = content.replace(/^# Andy$/m, `# ${assistantName}`); - content = content.replace(/You are Andy/g, `You are ${assistantName}`); - fs.writeFileSync(mdFile, content); - } - - // All CLAUDE.md files should be updated, including templates and groups - for (const folder of ['main', 'global', 'whatsapp_main', 'telegram_friends']) { - const content = fs.readFileSync( - path.join(groupsDir, folder, 'CLAUDE.md'), - 'utf-8', - ); + for (const folder of [ + 'main', + 'global', + 'whatsapp_main', + 'telegram_main', + 'slack_devs', + 'discord_main', + ]) { + const content = readGroupMd(folder); expect(content).toContain('# Luna'); expect(content).toContain('You are Luna'); expect(content).not.toContain('Andy'); } }); - it('handles missing template gracefully', () => { - // Remove templates + it('does not overwrite user-modified CLAUDE.md', () => { + simulateRegister('slack_main', true); + // User customizes the file + fs.writeFileSync( + path.join(groupsDir, 'slack_main', 'CLAUDE.md'), + '# Custom\n\nUser-modified content.', + ); + // Re-registering same folder (e.g. re-running /add-slack) + simulateRegister('slack_main', true); + + const content = readGroupMd('slack_main'); + expect(content).toContain('User-modified content'); + expect(content).not.toContain('Admin Context'); + }); + + it('handles missing templates gracefully', () => { fs.unlinkSync(path.join(groupsDir, 'global', 'CLAUDE.md')); fs.unlinkSync(path.join(groupsDir, 'main', 'CLAUDE.md')); - const folder = 'discord_general'; - const folderDir = path.join(groupsDir, folder); - fs.mkdirSync(path.join(folderDir, 'logs'), { recursive: true }); + simulateRegister('discord_general', false); - const dest = path.join(folderDir, 'CLAUDE.md'); - const templatePath = path.join(groupsDir, 'global', 'CLAUDE.md'); - - if (!fs.existsSync(dest)) { - if (fs.existsSync(templatePath)) { - fs.copyFileSync(templatePath, dest); - } - } - - // No crash, no file created - expect(fs.existsSync(dest)).toBe(false); + expect( + fs.existsSync(path.join(groupsDir, 'discord_general', 'CLAUDE.md')), + ).toBe(false); }); }); From 3207c35e50540618c990f8062b4c8a5d40ae0621 Mon Sep 17 00:00:00 2001 From: glifocat Date: Tue, 24 Mar 2026 12:44:24 +0100 Subject: [PATCH 12/20] fix: promote CLAUDE.md to main template when group becomes main MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a non-main group is re-registered with --is-main, the existing CLAUDE.md (copied from global template) lacked admin context. Now register.ts detects this promotion case and replaces it with the main template. Files that already contain "## Admin Context" are preserved. Adds tests for: - promoting non-main to main upgrades the template - cross-channel promotion (e.g. Telegram non-main → main) - promotion with custom assistant name - re-registration preserves user-modified main CLAUDE.md - re-registration preserves user-modified non-main CLAUDE.md Co-Authored-By: Claude Opus 4.6 (1M context) --- setup/register.test.ts | 79 +++++++++++++++++++++++++++++++++++------- setup/register.ts | 29 ++++++++++++---- 2 files changed, 88 insertions(+), 20 deletions(-) diff --git a/setup/register.test.ts b/setup/register.test.ts index 11f0f5f..859a457 100644 --- a/setup/register.test.ts +++ b/setup/register.test.ts @@ -272,18 +272,24 @@ describe('CLAUDE.md template copy', () => { const folderDir = path.join(groupsDir, folder); fs.mkdirSync(path.join(folderDir, 'logs'), { recursive: true }); - // Template copy (register.ts lines 119-138) + // Template copy + promotion (register.ts lines 119-148) const dest = path.join(folderDir, 'CLAUDE.md'); - if (!fs.existsSync(dest)) { - const templatePath = isMain - ? path.join(groupsDir, 'main', 'CLAUDE.md') - : path.join(groupsDir, 'global', 'CLAUDE.md'); + const templatePath = isMain + ? path.join(groupsDir, 'main', 'CLAUDE.md') + : path.join(groupsDir, 'global', 'CLAUDE.md'); + const fileExists = fs.existsSync(dest); + const needsPromotion = + isMain && + fileExists && + !fs.readFileSync(dest, 'utf-8').includes('## Admin Context'); + + if (!fileExists || needsPromotion) { if (fs.existsSync(templatePath)) { fs.copyFileSync(templatePath, dest); } } - // Name update across all groups (register.ts lines 140-165) + // Name update across all groups (register.ts lines 150-175) if (assistantName !== 'Andy') { const mdFiles = fs .readdirSync(groupsDir) @@ -401,19 +407,66 @@ describe('CLAUDE.md template copy', () => { } }); - it('does not overwrite user-modified CLAUDE.md', () => { + it('does not overwrite main CLAUDE.md that already has admin context', () => { simulateRegister('slack_main', true); - // User customizes the file - fs.writeFileSync( - path.join(groupsDir, 'slack_main', 'CLAUDE.md'), - '# Custom\n\nUser-modified content.', - ); + // User appends custom content to the main template + const mdPath = path.join(groupsDir, 'slack_main', 'CLAUDE.md'); + fs.appendFileSync(mdPath, '\n\n## My Custom Section\n\nUser notes here.'); // Re-registering same folder (e.g. re-running /add-slack) simulateRegister('slack_main', true); const content = readGroupMd('slack_main'); + // Preserved: has both admin context AND user additions + expect(content).toContain('Admin Context'); + expect(content).toContain('My Custom Section'); + }); + + it('does not overwrite non-main CLAUDE.md on re-registration', () => { + simulateRegister('telegram_friends', false); + // User customizes the file + const mdPath = path.join(groupsDir, 'telegram_friends', 'CLAUDE.md'); + fs.writeFileSync(mdPath, '# Custom\n\nUser-modified content.'); + // Re-registering same folder as non-main + simulateRegister('telegram_friends', false); + + const content = readGroupMd('telegram_friends'); expect(content).toContain('User-modified content'); - expect(content).not.toContain('Admin Context'); + }); + + it('promotes non-main group to main when re-registered with isMain', () => { + // Initially registered as non-main (gets global template) + simulateRegister('telegram_main', false); + expect(readGroupMd('telegram_main')).not.toContain('Admin Context'); + + // User switches this channel to main + simulateRegister('telegram_main', true); + expect(readGroupMd('telegram_main')).toContain('Admin Context'); + }); + + it('promotes across channels — WhatsApp non-main to Telegram main', () => { + // Start with WhatsApp as main, Telegram as non-main + simulateRegister('whatsapp_main', true); + simulateRegister('telegram_control', false); + + expect(readGroupMd('whatsapp_main')).toContain('Admin Context'); + expect(readGroupMd('telegram_control')).not.toContain('Admin Context'); + + // User decides Telegram should be the new main + simulateRegister('telegram_control', true); + expect(readGroupMd('telegram_control')).toContain('Admin Context'); + }); + + it('promotion updates assistant name in promoted file', () => { + // Register as non-main with default name + simulateRegister('slack_ops', false); + expect(readGroupMd('slack_ops')).toContain('You are Andy'); + + // Promote to main with custom name + simulateRegister('slack_ops', true, 'Nova'); + const content = readGroupMd('slack_ops'); + expect(content).toContain('Admin Context'); + expect(content).toContain('You are Nova'); + expect(content).not.toContain('Andy'); }); it('handles missing templates gracefully', () => { diff --git a/setup/register.ts b/setup/register.ts index 6e32cd8..270ebfa 100644 --- a/setup/register.ts +++ b/setup/register.ts @@ -116,7 +116,7 @@ export async function run(args: string[]): Promise { recursive: true, }); - // Create CLAUDE.md in the new group folder from template if it doesn't exist. + // Create or upgrade CLAUDE.md in the group folder from the appropriate template. // The agent runs with CWD=/workspace/group and loads CLAUDE.md from there. const groupClaudeMdPath = path.join( projectRoot, @@ -124,15 +124,30 @@ export async function run(args: string[]): Promise { parsed.folder, 'CLAUDE.md', ); - if (!fs.existsSync(groupClaudeMdPath)) { - const templatePath = parsed.isMain - ? path.join(projectRoot, 'groups', 'main', 'CLAUDE.md') - : path.join(projectRoot, 'groups', 'global', 'CLAUDE.md'); + const mainTemplatePath = path.join(projectRoot, 'groups', 'main', 'CLAUDE.md'); + const globalTemplatePath = path.join(projectRoot, 'groups', 'global', 'CLAUDE.md'); + const templatePath = parsed.isMain ? mainTemplatePath : globalTemplatePath; + const fileExists = fs.existsSync(groupClaudeMdPath); + + // Promotion case: group was registered as non-main (got global template) + // and is now being re-registered as main. Replace with main template. + const needsPromotion = + parsed.isMain && + fileExists && + !fs.readFileSync(groupClaudeMdPath, 'utf-8').includes('## Admin Context'); + + if (!fileExists || needsPromotion) { if (fs.existsSync(templatePath)) { fs.copyFileSync(templatePath, groupClaudeMdPath); logger.info( - { file: groupClaudeMdPath, template: templatePath }, - 'Created CLAUDE.md from template', + { + file: groupClaudeMdPath, + template: templatePath, + promoted: needsPromotion, + }, + needsPromotion + ? 'Promoted CLAUDE.md to main template' + : 'Created CLAUDE.md from template', ); } } From 57085cc02e42ef9be73ca5b5da903da11b898971 Mon Sep 17 00:00:00 2001 From: glifocat Date: Tue, 24 Mar 2026 13:09:26 +0100 Subject: [PATCH 13/20] =?UTF-8?q?fix:=20revert=20promotion=20logic=20?= =?UTF-8?q?=E2=80=94=20never=20overwrite=20existing=20CLAUDE.md?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The promotion logic (overwriting CLAUDE.md when a group becomes main) is unsafe. Real-world setups use is_main for groups that intentionally lack admin context — e.g. a family chat (whatsapp_casa) with 144 lines of custom persona, PARA workspace, task management, and family context. Overwriting based on missing "## Admin Context" would destroy user work. register.ts now follows a simple rule: create template for new folders, never touch existing files. Tests updated to verify preservation across re-registration and main promotion scenarios. Co-Authored-By: Claude Opus 4.6 (1M context) --- setup/register.test.ts | 98 +++++++++++++++++------------------------- setup/register.ts | 32 +++++--------- 2 files changed, 50 insertions(+), 80 deletions(-) diff --git a/setup/register.test.ts b/setup/register.test.ts index 859a457..5a70740 100644 --- a/setup/register.test.ts +++ b/setup/register.test.ts @@ -272,24 +272,18 @@ describe('CLAUDE.md template copy', () => { const folderDir = path.join(groupsDir, folder); fs.mkdirSync(path.join(folderDir, 'logs'), { recursive: true }); - // Template copy + promotion (register.ts lines 119-148) + // Template copy — never overwrite existing (register.ts lines 119-135) const dest = path.join(folderDir, 'CLAUDE.md'); - const templatePath = isMain - ? path.join(groupsDir, 'main', 'CLAUDE.md') - : path.join(groupsDir, 'global', 'CLAUDE.md'); - const fileExists = fs.existsSync(dest); - const needsPromotion = - isMain && - fileExists && - !fs.readFileSync(dest, 'utf-8').includes('## Admin Context'); - - if (!fileExists || needsPromotion) { + if (!fs.existsSync(dest)) { + const templatePath = isMain + ? path.join(groupsDir, 'main', 'CLAUDE.md') + : path.join(groupsDir, 'global', 'CLAUDE.md'); if (fs.existsSync(templatePath)) { fs.copyFileSync(templatePath, dest); } } - // Name update across all groups (register.ts lines 150-175) + // Name update across all groups (register.ts lines 140-165) if (assistantName !== 'Andy') { const mdFiles = fs .readdirSync(groupsDir) @@ -407,66 +401,54 @@ describe('CLAUDE.md template copy', () => { } }); - it('does not overwrite main CLAUDE.md that already has admin context', () => { + it('never overwrites existing CLAUDE.md on re-registration', () => { simulateRegister('slack_main', true); - // User appends custom content to the main template + // User customizes the file extensively (persona, workspace, rules) const mdPath = path.join(groupsDir, 'slack_main', 'CLAUDE.md'); - fs.appendFileSync(mdPath, '\n\n## My Custom Section\n\nUser notes here.'); + fs.writeFileSync( + mdPath, + '# Gambi\n\nCustom persona with workspace rules and family context.', + ); // Re-registering same folder (e.g. re-running /add-slack) simulateRegister('slack_main', true); const content = readGroupMd('slack_main'); - // Preserved: has both admin context AND user additions - expect(content).toContain('Admin Context'); - expect(content).toContain('My Custom Section'); + expect(content).toContain('Custom persona'); + expect(content).not.toContain('Admin Context'); }); - it('does not overwrite non-main CLAUDE.md on re-registration', () => { - simulateRegister('telegram_friends', false); - // User customizes the file - const mdPath = path.join(groupsDir, 'telegram_friends', 'CLAUDE.md'); - fs.writeFileSync(mdPath, '# Custom\n\nUser-modified content.'); - // Re-registering same folder as non-main - simulateRegister('telegram_friends', false); + it('never overwrites when non-main becomes main (isMain changes)', () => { + // User registers a family group as non-main + simulateRegister('whatsapp_casa', false); + // User extensively customizes it (PARA system, task management, etc.) + const mdPath = path.join(groupsDir, 'whatsapp_casa', 'CLAUDE.md'); + fs.writeFileSync( + mdPath, + '# Casa\n\nFamily group with PARA system, task management, shopping lists.', + ); + // Later, user promotes to main (no trigger required) — CLAUDE.md must be preserved + simulateRegister('whatsapp_casa', true); - const content = readGroupMd('telegram_friends'); - expect(content).toContain('User-modified content'); + const content = readGroupMd('whatsapp_casa'); + expect(content).toContain('PARA system'); + expect(content).not.toContain('Admin Context'); }); - it('promotes non-main group to main when re-registered with isMain', () => { - // Initially registered as non-main (gets global template) - simulateRegister('telegram_main', false); - expect(readGroupMd('telegram_main')).not.toContain('Admin Context'); - - // User switches this channel to main - simulateRegister('telegram_main', true); - expect(readGroupMd('telegram_main')).toContain('Admin Context'); - }); - - it('promotes across channels — WhatsApp non-main to Telegram main', () => { - // Start with WhatsApp as main, Telegram as non-main + it('preserves custom CLAUDE.md across channels when changing main', () => { + // Real-world scenario: WhatsApp main + customized Discord research channel simulateRegister('whatsapp_main', true); - simulateRegister('telegram_control', false); + simulateRegister('discord_main', false); + const discordPath = path.join(groupsDir, 'discord_main', 'CLAUDE.md'); + fs.writeFileSync( + discordPath, + '# Gambi HQ — Research Assistant\n\nResearch workflows for Laura and Ethan.', + ); + // Discord becomes main too — custom content must survive + simulateRegister('discord_main', true); + expect(readGroupMd('discord_main')).toContain('Research Assistant'); + // WhatsApp main also untouched expect(readGroupMd('whatsapp_main')).toContain('Admin Context'); - expect(readGroupMd('telegram_control')).not.toContain('Admin Context'); - - // User decides Telegram should be the new main - simulateRegister('telegram_control', true); - expect(readGroupMd('telegram_control')).toContain('Admin Context'); - }); - - it('promotion updates assistant name in promoted file', () => { - // Register as non-main with default name - simulateRegister('slack_ops', false); - expect(readGroupMd('slack_ops')).toContain('You are Andy'); - - // Promote to main with custom name - simulateRegister('slack_ops', true, 'Nova'); - const content = readGroupMd('slack_ops'); - expect(content).toContain('Admin Context'); - expect(content).toContain('You are Nova'); - expect(content).not.toContain('Andy'); }); it('handles missing templates gracefully', () => { diff --git a/setup/register.ts b/setup/register.ts index 270ebfa..c08d910 100644 --- a/setup/register.ts +++ b/setup/register.ts @@ -116,38 +116,26 @@ export async function run(args: string[]): Promise { recursive: true, }); - // Create or upgrade CLAUDE.md in the group folder from the appropriate template. + // Create CLAUDE.md in the new group folder from template if it doesn't exist. // The agent runs with CWD=/workspace/group and loads CLAUDE.md from there. + // Never overwrite an existing CLAUDE.md — users customize these extensively + // (persona, workspace structure, communication rules, family context, etc.) + // and a stock template replacement would destroy that work. const groupClaudeMdPath = path.join( projectRoot, 'groups', parsed.folder, 'CLAUDE.md', ); - const mainTemplatePath = path.join(projectRoot, 'groups', 'main', 'CLAUDE.md'); - const globalTemplatePath = path.join(projectRoot, 'groups', 'global', 'CLAUDE.md'); - const templatePath = parsed.isMain ? mainTemplatePath : globalTemplatePath; - const fileExists = fs.existsSync(groupClaudeMdPath); - - // Promotion case: group was registered as non-main (got global template) - // and is now being re-registered as main. Replace with main template. - const needsPromotion = - parsed.isMain && - fileExists && - !fs.readFileSync(groupClaudeMdPath, 'utf-8').includes('## Admin Context'); - - if (!fileExists || needsPromotion) { + if (!fs.existsSync(groupClaudeMdPath)) { + const templatePath = parsed.isMain + ? path.join(projectRoot, 'groups', 'main', 'CLAUDE.md') + : path.join(projectRoot, 'groups', 'global', 'CLAUDE.md'); if (fs.existsSync(templatePath)) { fs.copyFileSync(templatePath, groupClaudeMdPath); logger.info( - { - file: groupClaudeMdPath, - template: templatePath, - promoted: needsPromotion, - }, - needsPromotion - ? 'Promoted CLAUDE.md to main template' - : 'Created CLAUDE.md from template', + { file: groupClaudeMdPath, template: templatePath }, + 'Created CLAUDE.md from template', ); } } From d05a8dec495713a09bbe7ed8d73c480758d49910 Mon Sep 17 00:00:00 2001 From: Daniel M Date: Tue, 24 Mar 2026 15:21:13 +0000 Subject: [PATCH 14/20] fix: refresh stale agent-runner source cache on code changes Closes #1361 Co-Authored-By: Claude Opus 4.6 (1M context) --- src/container-runner.ts | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/src/container-runner.ts b/src/container-runner.ts index 1dc607f..47a8387 100644 --- a/src/container-runner.ts +++ b/src/container-runner.ts @@ -191,8 +191,17 @@ function buildVolumeMounts( group.folder, 'agent-runner-src', ); - if (!fs.existsSync(groupAgentRunnerDir) && fs.existsSync(agentRunnerSrc)) { - fs.cpSync(agentRunnerSrc, groupAgentRunnerDir, { recursive: true }); + if (fs.existsSync(agentRunnerSrc)) { + const srcIndex = path.join(agentRunnerSrc, 'index.ts'); + const cachedIndex = path.join(groupAgentRunnerDir, 'index.ts'); + const needsCopy = + !fs.existsSync(groupAgentRunnerDir) || + !fs.existsSync(cachedIndex) || + (fs.existsSync(srcIndex) && + fs.statSync(srcIndex).mtimeMs > fs.statSync(cachedIndex).mtimeMs); + if (needsCopy) { + fs.cpSync(agentRunnerSrc, groupAgentRunnerDir, { recursive: true }); + } } mounts.push({ hostPath: groupAgentRunnerDir, From d622a79fe24aa10079a3bd39c5e777a45a9f0f8d Mon Sep 17 00:00:00 2001 From: Koshkoshinsk Date: Wed, 25 Mar 2026 11:41:25 +0000 Subject: [PATCH 15/20] fix: suppress spurious chat message on script skip When a script returns wakeAgent=false, set result to null so the host doesn't forward an internal status string to the user's chat. Co-Authored-By: Claude Opus 4.6 (1M context) --- container/agent-runner/src/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/container/agent-runner/src/index.ts b/container/agent-runner/src/index.ts index 382439f..25554f9 100644 --- a/container/agent-runner/src/index.ts +++ b/container/agent-runner/src/index.ts @@ -566,7 +566,7 @@ async function main(): Promise { log(`Script decided not to wake agent: ${reason}`); writeOutput({ status: 'success', - result: `Script: ${reason}`, + result: null, }); return; } From b7434b8a76b7fb33ba85348e0f5054e048bfbd87 Mon Sep 17 00:00:00 2001 From: Koshkoshinsk Date: Wed, 25 Mar 2026 14:07:59 +0000 Subject: [PATCH 16/20] fix: use explicit Read tool directive for diagnostics instructions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous wording ("Send diagnostics data by following ...") was too passive — Claude treated the backtick-quoted path as informational rather than an action, so the diagnostics file was never actually read and the PostHog prompt was silently skipped. Co-Authored-By: Claude Opus 4.6 (1M context) --- .claude/skills/setup/SKILL.md | 3 ++- .claude/skills/update-nanoclaw/SKILL.md | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/.claude/skills/setup/SKILL.md b/.claude/skills/setup/SKILL.md index e12e0ea..54c3d2d 100644 --- a/.claude/skills/setup/SKILL.md +++ b/.claude/skills/setup/SKILL.md @@ -287,4 +287,5 @@ Tell user to test: send a message in their registered chat. Show: `tail -f logs/ ## 9. Diagnostics -Send diagnostics data by following `.claude/skills/setup/diagnostics.md`. +1. Use the Read tool to read `.claude/skills/setup/diagnostics.md`. +2. Follow every step in that file before completing setup. diff --git a/.claude/skills/update-nanoclaw/SKILL.md b/.claude/skills/update-nanoclaw/SKILL.md index 2207910..496d409 100644 --- a/.claude/skills/update-nanoclaw/SKILL.md +++ b/.claude/skills/update-nanoclaw/SKILL.md @@ -237,4 +237,5 @@ Tell the user: ## Diagnostics -Send diagnostics data by following `.claude/skills/update-nanoclaw/diagnostics.md`. +1. Use the Read tool to read `.claude/skills/update-nanoclaw/diagnostics.md`. +2. Follow every step in that file before finishing. From 31c03cf92406271a1c5866874ab8bb1a1489e14a Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 25 Mar 2026 15:27:45 +0000 Subject: [PATCH 17/20] chore: bump version to 1.2.28 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 1e0a7e3..0b699ed 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "nanoclaw", - "version": "1.2.27", + "version": "1.2.28", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "nanoclaw", - "version": "1.2.27", + "version": "1.2.28", "dependencies": { "@onecli-sh/sdk": "^0.2.0", "better-sqlite3": "11.10.0", diff --git a/package.json b/package.json index 91746b0..a7f6a5f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "nanoclaw", - "version": "1.2.27", + "version": "1.2.28", "description": "Personal Claude assistant. Lightweight, secure, customizable.", "type": "module", "main": "dist/index.js", From 9391304e7043c9c07a839d72219c7ffbe92a704e Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 25 Mar 2026 15:27:47 +0000 Subject: [PATCH 18/20] =?UTF-8?q?docs:=20update=20token=20count=20to=2040.?= =?UTF-8?q?2k=20tokens=20=C2=B7=2020%=20of=20context=20window?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- repo-tokens/badge.svg | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/repo-tokens/badge.svg b/repo-tokens/badge.svg index 301a593..8c3b0c8 100644 --- a/repo-tokens/badge.svg +++ b/repo-tokens/badge.svg @@ -1,5 +1,5 @@ - - 40.1k tokens, 20% of context window + + 40.2k tokens, 20% of context window @@ -15,8 +15,8 @@ tokens - - 40.1k + + 40.2k From bb736f37f2ea8a23dab905c7d885492082ec2bf0 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 25 Mar 2026 15:28:25 +0000 Subject: [PATCH 19/20] chore: bump version to 1.2.29 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 0b699ed..4128040 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "nanoclaw", - "version": "1.2.28", + "version": "1.2.29", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "nanoclaw", - "version": "1.2.28", + "version": "1.2.29", "dependencies": { "@onecli-sh/sdk": "^0.2.0", "better-sqlite3": "11.10.0", diff --git a/package.json b/package.json index a7f6a5f..0822ed9 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "nanoclaw", - "version": "1.2.28", + "version": "1.2.29", "description": "Personal Claude assistant. Lightweight, secure, customizable.", "type": "module", "main": "dist/index.js", From df76dc6797807ee9702c56a30731086fca63dab6 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 25 Mar 2026 15:28:27 +0000 Subject: [PATCH 20/20] =?UTF-8?q?docs:=20update=20token=20count=20to=2041.?= =?UTF-8?q?0k=20tokens=20=C2=B7=2020%=20of=20context=20window?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- repo-tokens/badge.svg | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/repo-tokens/badge.svg b/repo-tokens/badge.svg index 8c3b0c8..be808ed 100644 --- a/repo-tokens/badge.svg +++ b/repo-tokens/badge.svg @@ -1,5 +1,5 @@ - - 40.2k tokens, 20% of context window + + 41.0k tokens, 20% of context window @@ -15,8 +15,8 @@ tokens - - 40.2k + + 41.0k