From 9958df128172b7a7a13dd29a62434ba00828c6cb Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sat, 14 Mar 2026 05:59:45 +0000 Subject: [PATCH 01/13] chore(version): bump to 3.260314.3 [skip ci] --- .claude-plugin/marketplace.json | 2 +- openclaw.plugin.json | 2 +- package.json | 2 +- plugins/genie/.claude-plugin/plugin.json | 2 +- plugins/genie/package.json | 2 +- src/lib/version.ts | 2 +- 6 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 8e3203bf5..31e16052b 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -10,7 +10,7 @@ "plugins": [ { "name": "genie", - "version": "3.260310.5", + "version": "3.260314.3", "source": "./plugins/genie", "description": "Human-AI partnership for Claude Code. Share a terminal, orchestrate workers, evolve together. Brainstorm ideas, wish them into plans, make with parallel agents, ship as one team. A coding genie that grows with your project." } diff --git a/openclaw.plugin.json b/openclaw.plugin.json index 36747df90..b022cce9d 100644 --- a/openclaw.plugin.json +++ b/openclaw.plugin.json @@ -2,7 +2,7 @@ "id": "genie", "name": "Genie", "description": "Skills, agents, and hooks for the Genie CLI terminal orchestration toolkit", - "version": "3.260310.5", + "version": "3.260314.3", "configSchema": { "type": "object", "additionalProperties": false, diff --git a/package.json b/package.json index 0e786f293..2b22ee1f2 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@automagik/genie", - "version": "3.260310.5", + "version": "3.260314.3", "description": "Collaborative terminal toolkit for human + AI workflows", "type": "module", "bin": { diff --git a/plugins/genie/.claude-plugin/plugin.json b/plugins/genie/.claude-plugin/plugin.json index 8e0242457..128d0333c 100644 --- a/plugins/genie/.claude-plugin/plugin.json +++ b/plugins/genie/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "genie", - "version": "3.260310.5", + "version": "3.260314.3", "description": "Human-AI partnership for Claude Code. Share a terminal, orchestrate workers, evolve together. Brainstorm ideas, turn them into wishes, execute with /work, validate with /review, and ship as one team.", "author": { "name": "Namastex Labs" diff --git a/plugins/genie/package.json b/plugins/genie/package.json index 6fa287a3f..31130046b 100644 --- a/plugins/genie/package.json +++ b/plugins/genie/package.json @@ -1,6 +1,6 @@ { "name": "genie-plugin", - "version": "3.260310.5", + "version": "3.260314.3", "private": true, "description": "Runtime dependencies for genie bundled CLIs", "type": "module", diff --git a/src/lib/version.ts b/src/lib/version.ts index 83aba1533..64ff3c44c 100644 --- a/src/lib/version.ts +++ b/src/lib/version.ts @@ -1,2 +1,2 @@ // Runtime version (baked in at build time) -export const VERSION = '3.260310.5'; +export const VERSION = '3.260314.3'; From f0f65ffbca72b68b42cf53b3aa03c276191322c1 Mon Sep 17 00:00:00 2001 From: Test User Date: Sat, 14 Mar 2026 14:12:32 -0300 Subject: [PATCH 02/13] =?UTF-8?q?fix:=20v2=20QA=20fixes=20groups=201,4,5,6?= =?UTF-8?q?=20=E2=80=94=20file-lock,=20global=20teams,=20spawn=20CWD,=20ve?= =?UTF-8?q?rsion?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Group 1: Extract shared file-lock utility (src/lib/file-lock.ts) - Deduplicate lock pattern from agent-directory, wish-state, agent-registry - All 3 modules now import from file-lock.ts Group 4: Spawn & team fixes - Fix spawn CWD to use team worktree path for built-in agents (#546) - Add validateBranchName() in team-manager (#551) - Make parseWishGroups() case-insensitive (#554) Group 5: Global team configs (#558) - Move team configs from /.genie/teams/ to ~/.genie/teams/ - Drop repoPath param from getTeam/listTeams/listMembers - All team commands resolve repo from stored config, not CWD - Update all callers across codebase Group 6: Fix genie update (#559) - Read version from package.json at runtime instead of hardcoded 3 test failures remain (team-manager signature changes in test mocks) Groups 2 (concurrency) and 3 (wish state hardening) still pending. Co-Authored-By: Paperclip --- src/lib/agent-directory.ts | 72 +------------------ src/lib/agent-registry.ts | 79 ++------------------ src/lib/file-lock.ts | 111 +++++++++++++++++++++++++++++ src/lib/protocol-router-spawn.ts | 4 +- src/lib/team-manager.test.ts | 97 +++++++++++++++++++------ src/lib/team-manager.ts | 90 ++++++++++++++++------- src/lib/version.ts | 42 ++++++++++- src/lib/wish-state.ts | 68 +----------------- src/term-commands/agents.ts | 12 +++- src/term-commands/dispatch.test.ts | 43 ++++++++++- src/term-commands/dispatch.ts | 2 +- src/term-commands/msg.ts | 8 +-- src/term-commands/team.ts | 30 ++++---- 13 files changed, 372 insertions(+), 286 deletions(-) create mode 100644 src/lib/file-lock.ts diff --git a/src/lib/agent-directory.ts b/src/lib/agent-directory.ts index 18fb4bfd3..4b72e366d 100644 --- a/src/lib/agent-directory.ts +++ b/src/lib/agent-directory.ts @@ -10,10 +10,11 @@ */ import { existsSync } from 'node:fs'; -import { mkdir, open, readFile, stat, unlink, writeFile } from 'node:fs/promises'; +import { mkdir, readFile, writeFile } from 'node:fs/promises'; import { homedir } from 'node:os'; import { dirname, join } from 'node:path'; import { BUILTIN_COUNCIL_MEMBERS, BUILTIN_ROLES, type BuiltinAgent } from './builtin-agents.js'; +import { acquireLock } from './file-lock.js'; // ============================================================================ // Types @@ -63,73 +64,6 @@ function getDirectoryFilePath(): string { return join(getGlobalDir(), 'agent-directory.json'); } -// ============================================================================ -// File Locking — prevents concurrent load/modify/save races -// ============================================================================ - -const LOCK_TIMEOUT_MS = 5000; -const LOCK_RETRY_MS = 50; -const LOCK_STALE_MS = 10000; - -async function tryCleanStaleLock(lockPath: string): Promise { - try { - const lockStat = await stat(lockPath); - if (Date.now() - lockStat.mtimeMs > LOCK_STALE_MS) { - try { - await unlink(lockPath); - } catch { - /* race with other cleanup */ - } - return true; - } - } catch { - return true; // lock gone, retry - } - return false; -} - -async function tryCreateLock(lockPath: string): Promise<(() => Promise) | null> { - try { - const handle = await open(lockPath, 'wx'); - await handle.writeFile(String(process.pid)); - await handle.close(); - return async () => { - try { - await unlink(lockPath); - } catch { - /* already removed */ - } - }; - } catch (err) { - const errCode = err instanceof Error && 'code' in err ? (err as NodeJS.ErrnoException).code : undefined; - if (errCode !== 'EEXIST') throw err; - return null; - } -} - -async function acquireLock(): Promise<() => Promise> { - const lockPath = `${getDirectoryFilePath()}.lock`; - const deadline = Date.now() + LOCK_TIMEOUT_MS; - - while (true) { - const release = await tryCreateLock(lockPath); - if (release) return release; - - const cleaned = await tryCleanStaleLock(lockPath); - if (cleaned) continue; - - if (Date.now() > deadline) { - try { - await unlink(lockPath); - } catch { - throw new Error(`Directory lock timeout: could not remove stale lock at ${lockPath}`); - } - continue; - } - await new Promise((r) => setTimeout(r, LOCK_RETRY_MS)); - } -} - // ============================================================================ // Internal // ============================================================================ @@ -151,7 +85,7 @@ async function saveDirectory(data: AgentDirectoryData): Promise { } async function withDirectory(fn: (data: AgentDirectoryData) => T | Promise): Promise { - const release = await acquireLock(); + const release = await acquireLock(getDirectoryFilePath()); try { const data = await loadDirectory(); const result = await fn(data); diff --git a/src/lib/agent-registry.ts b/src/lib/agent-registry.ts index b0e7a9e1a..63adb7923 100644 --- a/src/lib/agent-registry.ts +++ b/src/lib/agent-registry.ts @@ -6,9 +6,10 @@ * global file at `~/.genie/workers.json`. */ -import { mkdir, open, readFile, stat, unlink, writeFile } from 'node:fs/promises'; +import { mkdir, readFile, writeFile } from 'node:fs/promises'; import { homedir } from 'node:os'; import { dirname, join } from 'node:path'; +import { acquireLock } from './file-lock.js'; import type { ProviderName } from './provider-adapters.js'; // ============================================================================ @@ -144,82 +145,12 @@ async function saveRegistry(registry: AgentRegistry, registryPath?: string): Pro } // ============================================================================ -// File Locking — prevents concurrent load/modify/save races +// Locked read-modify-write // ============================================================================ -const LOCK_TIMEOUT_MS = 5000; -const LOCK_RETRY_MS = 50; -const LOCK_STALE_MS = 10000; - -async function tryCleanStaleLock(lockPath: string): Promise { - try { - const lockStat = await stat(lockPath); - if (Date.now() - lockStat.mtimeMs > LOCK_STALE_MS) { - try { - await unlink(lockPath); - } catch { - /* race with other cleanup */ - } - return true; - } - } catch { - return true; // lock gone, retry - } - return false; -} - -function createReleaseFn(lockPath: string): () => Promise { - return async () => { - try { - await unlink(lockPath); - } catch { - /* already removed */ - } - }; -} - -async function tryCreateLock(lockPath: string): Promise<(() => Promise) | null> { - try { - const handle = await open(lockPath, 'wx'); - await handle.writeFile(String(process.pid)); - await handle.close(); - return createReleaseFn(lockPath); - } catch (err) { - const errCode = err instanceof Error && 'code' in err ? (err as NodeJS.ErrnoException).code : undefined; - if (errCode !== 'EEXIST') throw err; - return null; - } -} - -async function forceRemoveLock(lockPath: string): Promise { - try { - await unlink(lockPath); - } catch { - throw new Error(`Registry lock timeout: could not remove stale lock at ${lockPath}`); - } -} - -async function acquireLock(registryPath?: string): Promise<() => Promise> { - const lockPath = `${registryPath ?? getRegistryFilePath()}.lock`; - const deadline = Date.now() + LOCK_TIMEOUT_MS; - - while (true) { - const release = await tryCreateLock(lockPath); - if (release) return release; - - const cleaned = await tryCleanStaleLock(lockPath); - if (cleaned) continue; - - if (Date.now() > deadline) { - await forceRemoveLock(lockPath); - continue; - } - await new Promise((r) => setTimeout(r, LOCK_RETRY_MS)); - } -} - async function withRegistry(fn: (reg: AgentRegistry) => T | Promise, registryPath?: string): Promise { - const release = await acquireLock(registryPath); + const filePath = registryPath ?? getRegistryFilePath(); + const release = await acquireLock(filePath); try { const reg = await loadRegistry(registryPath); const result = await fn(reg); diff --git a/src/lib/file-lock.ts b/src/lib/file-lock.ts new file mode 100644 index 000000000..aafa6e115 --- /dev/null +++ b/src/lib/file-lock.ts @@ -0,0 +1,111 @@ +/** + * File Lock — Shared file-locking utility for concurrent access protection. + * + * Uses exclusive file creation (O_EXCL) as a cross-process mutex. + * Stale locks (older than LOCK_STALE_MS) are auto-cleaned. + * + * Used by: agent-directory, wish-state, agent-registry, mailbox, team-chat. + */ + +import { open, stat, unlink } from 'node:fs/promises'; + +// ============================================================================ +// Constants +// ============================================================================ + +export const LOCK_TIMEOUT_MS = 5000; +export const LOCK_RETRY_MS = 50; +export const LOCK_STALE_MS = 10000; + +// ============================================================================ +// Internal helpers +// ============================================================================ + +async function tryCleanStaleLock(lockPath: string): Promise { + try { + const lockStat = await stat(lockPath); + if (Date.now() - lockStat.mtimeMs > LOCK_STALE_MS) { + try { + await unlink(lockPath); + } catch { + /* race with other cleanup */ + } + return true; + } + } catch { + return true; // lock gone, retry + } + return false; +} + +async function tryCreateLock(lockPath: string): Promise<(() => Promise) | null> { + try { + const handle = await open(lockPath, 'wx'); + await handle.writeFile(String(process.pid)); + await handle.close(); + return async () => { + try { + await unlink(lockPath); + } catch { + /* already removed */ + } + }; + } catch (err) { + const errCode = err instanceof Error && 'code' in err ? (err as NodeJS.ErrnoException).code : undefined; + if (errCode !== 'EEXIST') throw err; + return null; + } +} + +// ============================================================================ +// Public API +// ============================================================================ + +/** + * Acquire an exclusive file lock at `filePath.lock`. + * + * Returns a release function that MUST be called when done. + * Throws if the lock cannot be acquired within LOCK_TIMEOUT_MS. + * + * @param filePath — The file to lock (lock file will be `${filePath}.lock`) + */ +export async function acquireLock(filePath: string): Promise<() => Promise> { + const lockPath = `${filePath}.lock`; + const deadline = Date.now() + LOCK_TIMEOUT_MS; + + while (true) { + const release = await tryCreateLock(lockPath); + if (release) return release; + + const cleaned = await tryCleanStaleLock(lockPath); + if (cleaned) continue; + + if (Date.now() > deadline) { + try { + await unlink(lockPath); + } catch { + throw new Error(`Lock timeout: could not remove stale lock at ${lockPath}`); + } + continue; + } + await new Promise((r) => setTimeout(r, LOCK_RETRY_MS)); + } +} + +/** + * Execute a function while holding a file lock. + * + * Acquires the lock, runs `fn`, then releases — even if `fn` throws. + * + * @public - used via dynamic namespace import in mailbox.ts and team-chat.ts + * @param lockPath — The file to lock (lock file will be `${lockPath}.lock`) + * @param fn — Function to execute under the lock + */ +export async function withLock(lockPath: string, fn: () => T | Promise): Promise { + const release = await acquireLock(lockPath); + try { + return await fn(); + } finally { + await release(); + } +} diff --git a/src/lib/protocol-router-spawn.ts b/src/lib/protocol-router-spawn.ts index 6b1cfa47e..b937df0fd 100644 --- a/src/lib/protocol-router-spawn.ts +++ b/src/lib/protocol-router-spawn.ts @@ -22,8 +22,8 @@ import { applyPaneColor, ensureTeamWindow, listWindows } from './tmux.js'; const execAsync = promisify(exec); -async function resolveParentSession(repoPath: string, team: string): Promise { - const teamConfig = await teamManager.getTeam(repoPath, team); +async function resolveParentSession(_repoPath: string, team: string): Promise { + const teamConfig = await teamManager.getTeam(team); if (teamConfig?.nativeTeamParentSessionId) return teamConfig.nativeTeamParentSessionId; return (await nativeTeams.discoverClaudeSessionId()) ?? crypto.randomUUID(); } diff --git a/src/lib/team-manager.test.ts b/src/lib/team-manager.test.ts index e395eb3bc..e939601e7 100644 --- a/src/lib/team-manager.test.ts +++ b/src/lib/team-manager.test.ts @@ -8,7 +8,16 @@ import { existsSync } from 'node:fs'; import { mkdir, rm, writeFile } from 'node:fs/promises'; import { join } from 'node:path'; import { $ } from 'bun'; -import { createTeam, disbandTeam, fireAgent, getTeam, hireAgent, listMembers, listTeams } from './team-manager.js'; +import { + createTeam, + disbandTeam, + fireAgent, + getTeam, + hireAgent, + listMembers, + listTeams, + validateBranchName, +} from './team-manager.js'; // ============================================================================ // Test Setup @@ -16,6 +25,7 @@ import { createTeam, disbandTeam, fireAgent, getTeam, hireAgent, listMembers, li const TEST_DIR = '/tmp/team-manager-test'; const TEST_REPO = join(TEST_DIR, 'test-repo'); +const TEST_GENIE_HOME = join(TEST_DIR, 'genie-home'); async function setupTestRepo(): Promise { try { @@ -25,6 +35,7 @@ async function setupTestRepo(): Promise { } await mkdir(TEST_REPO, { recursive: true }); + await mkdir(TEST_GENIE_HOME, { recursive: true }); await $`git -C ${TEST_REPO} init`.quiet(); await $`git -C ${TEST_REPO} config user.email "test@test.com"`.quiet(); await $`git -C ${TEST_REPO} config user.name "Test"`.quiet(); @@ -36,6 +47,9 @@ async function setupTestRepo(): Promise { // Create dev branch (current branch acts as dev) await $`git -C ${TEST_REPO} branch dev`.quiet(); + + // Point GENIE_HOME to test directory for global team storage + process.env.GENIE_HOME = TEST_GENIE_HOME; } async function cleanupTestRepo(): Promise { @@ -65,6 +79,8 @@ async function cleanupTestRepo(): Promise { } catch { // Ignore } + + process.env.GENIE_HOME = undefined; } // ============================================================================ @@ -80,6 +96,31 @@ describe('Team Manager', () => { await cleanupTestRepo(); }); + describe('validateBranchName', () => { + test('accepts valid branch names', () => { + expect(() => validateBranchName('feat/auth-bug')).not.toThrow(); + expect(() => validateBranchName('fix/thing')).not.toThrow(); + expect(() => validateBranchName('chore/cleanup')).not.toThrow(); + }); + + test('rejects names with spaces', () => { + expect(() => validateBranchName('spaces here')).toThrow('must be a valid git branch name'); + expect(() => validateBranchName('spaces here')).toThrow('contains spaces'); + }); + + test('rejects names with ..', () => { + expect(() => validateBranchName('feat..test')).toThrow('contains ".."'); + }); + + test('rejects names starting with -', () => { + expect(() => validateBranchName('-bad')).toThrow('starts with "-"'); + }); + + test('rejects names ending with .lock', () => { + expect(() => validateBranchName('feat/test.lock')).toThrow('ends with ".lock"'); + }); + }); + describe('createTeam', () => { test('creates team with worktree', async () => { const config = await createTeam('feat/test-create', TEST_REPO, 'dev'); @@ -107,26 +148,36 @@ describe('Team Manager', () => { const result = await $`git -C ${config.worktreePath} branch --show-current`.quiet(); expect(result.stdout.toString().trim()).toBe('feat/branched'); }); + + test('stores config at global GENIE_HOME/teams/', async () => { + await createTeam('feat/global-check', TEST_REPO, 'dev'); + const configPath = join(TEST_GENIE_HOME, 'teams', 'feat--global-check.json'); + expect(existsSync(configPath)).toBe(true); + }); + + test('rejects invalid branch names', async () => { + await expect(createTeam('spaces here', TEST_REPO, 'dev')).rejects.toThrow('must be a valid git branch name'); + }); }); describe('getTeam', () => { test('returns team config for existing team', async () => { await createTeam('feat/get-test', TEST_REPO, 'dev'); - const config = await getTeam(TEST_REPO, 'feat/get-test'); + const config = await getTeam('feat/get-test'); expect(config).not.toBeNull(); expect(config!.name).toBe('feat/get-test'); }); test('returns null for non-existent team', async () => { - const config = await getTeam(TEST_REPO, 'nonexistent'); + const config = await getTeam('nonexistent'); expect(config).toBeNull(); }); }); describe('listTeams', () => { - test('lists all teams', async () => { - const teams = await listTeams(TEST_REPO); + test('lists all teams globally', async () => { + const teams = await listTeams(); expect(teams.length).toBeGreaterThan(0); const names = teams.map((t) => t.name); @@ -137,73 +188,73 @@ describe('Team Manager', () => { describe('hireAgent', () => { test('adds agent to team members', async () => { await createTeam('feat/hire-test', TEST_REPO, 'dev'); - const added = await hireAgent('feat/hire-test', 'implementor', TEST_REPO); + const added = await hireAgent('feat/hire-test', 'implementor'); expect(added).toEqual(['implementor']); - const config = await getTeam(TEST_REPO, 'feat/hire-test'); + const config = await getTeam('feat/hire-test'); expect(config!.members).toContain('implementor'); }); test('returns empty array for duplicate hire', async () => { await createTeam('feat/hire-dup', TEST_REPO, 'dev'); - await hireAgent('feat/hire-dup', 'tester', TEST_REPO); - const added = await hireAgent('feat/hire-dup', 'tester', TEST_REPO); + await hireAgent('feat/hire-dup', 'tester'); + const added = await hireAgent('feat/hire-dup', 'tester'); expect(added).toEqual([]); }); test('hire council adds all 10 council members', async () => { await createTeam('feat/hire-council', TEST_REPO, 'dev'); - const added = await hireAgent('feat/hire-council', 'council', TEST_REPO); + const added = await hireAgent('feat/hire-council', 'council'); expect(added.length).toBe(10); expect(added).toContain('council-questioner'); expect(added).toContain('council-architect'); - const config = await getTeam(TEST_REPO, 'feat/hire-council'); + const config = await getTeam('feat/hire-council'); expect(config!.members.length).toBe(10); }); test('throws for non-existent team', async () => { - expect(hireAgent('nonexistent', 'agent', TEST_REPO)).rejects.toThrow('not found'); + expect(hireAgent('nonexistent', 'agent')).rejects.toThrow('not found'); }); }); describe('fireAgent', () => { test('removes agent from team members', async () => { await createTeam('feat/fire-test', TEST_REPO, 'dev'); - await hireAgent('feat/fire-test', 'reviewer', TEST_REPO); + await hireAgent('feat/fire-test', 'reviewer'); - const removed = await fireAgent('feat/fire-test', 'reviewer', TEST_REPO); + const removed = await fireAgent('feat/fire-test', 'reviewer'); expect(removed).toBe(true); - const config = await getTeam(TEST_REPO, 'feat/fire-test'); + const config = await getTeam('feat/fire-test'); expect(config!.members).not.toContain('reviewer'); }); test('returns false for agent not in team', async () => { await createTeam('feat/fire-miss', TEST_REPO, 'dev'); - const removed = await fireAgent('feat/fire-miss', 'nobody', TEST_REPO); + const removed = await fireAgent('feat/fire-miss', 'nobody'); expect(removed).toBe(false); }); test('throws for non-existent team', async () => { - expect(fireAgent('nonexistent', 'agent', TEST_REPO)).rejects.toThrow('not found'); + expect(fireAgent('nonexistent', 'agent')).rejects.toThrow('not found'); }); }); describe('listMembers', () => { test('returns members of existing team', async () => { await createTeam('feat/list-members', TEST_REPO, 'dev'); - await hireAgent('feat/list-members', 'debugger', TEST_REPO); + await hireAgent('feat/list-members', 'debugger'); - const members = await listMembers(TEST_REPO, 'feat/list-members'); + const members = await listMembers('feat/list-members'); expect(members).toEqual(['debugger']); }); test('returns null for non-existent team', async () => { - const members = await listMembers(TEST_REPO, 'nonexistent'); + const members = await listMembers('nonexistent'); expect(members).toBeNull(); }); }); @@ -213,19 +264,19 @@ describe('Team Manager', () => { const config = await createTeam('feat/disband-test', TEST_REPO, 'dev'); const worktreePath = config.worktreePath; - const disbanded = await disbandTeam(TEST_REPO, 'feat/disband-test'); + const disbanded = await disbandTeam('feat/disband-test'); expect(disbanded).toBe(true); // Worktree should be gone expect(existsSync(worktreePath)).toBe(false); // Team config should be gone - const team = await getTeam(TEST_REPO, 'feat/disband-test'); + const team = await getTeam('feat/disband-test'); expect(team).toBeNull(); }); test('returns false for non-existent team', async () => { - const disbanded = await disbandTeam(TEST_REPO, 'nonexistent'); + const disbanded = await disbandTeam('nonexistent'); expect(disbanded).toBe(false); }); }); diff --git a/src/lib/team-manager.ts b/src/lib/team-manager.ts index d3903a272..b6503cfc1 100644 --- a/src/lib/team-manager.ts +++ b/src/lib/team-manager.ts @@ -1,13 +1,14 @@ /** * Team Manager — CRUD for team lifecycle with git worktree integration. * - * Teams are stored as JSON files in `.genie/teams/.json`. + * Teams are stored as JSON files in `~/.genie/teams/.json` (global). * Each team owns a git worktree at `/`. * Team name IS the branch name (conventional prefixes: feat/, fix/, chore/, etc.). */ import { existsSync } from 'node:fs'; import { mkdir, readFile, readdir, rm, unlink, writeFile } from 'node:fs/promises'; +import { homedir } from 'node:os'; import path, { join } from 'node:path'; import { $ } from 'bun'; import * as registry from './agent-registry.js'; @@ -42,11 +43,15 @@ export interface TeamConfig { } // ============================================================================ -// Paths +// Paths — global team storage at ~/.genie/teams/ // ============================================================================ -function teamsDir(repoPath: string): string { - return join(repoPath, '.genie', 'teams'); +function getGenieDir(): string { + return process.env.GENIE_HOME ?? join(homedir(), '.genie'); +} + +function teamsDir(): string { + return join(getGenieDir(), 'teams'); } /** Sanitize team name for use as a filename (slashes become dashes). */ @@ -54,9 +59,9 @@ function safeFileName(name: string): string { return name.replace(/\//g, '--'); } -function teamFilePath(repoPath: string, name: string): string { +function teamFilePath(name: string): string { const safeName = safeFileName(path.basename(name) === name ? name : name); - return join(teamsDir(repoPath), `${safeName}.json`); + return join(teamsDir(), `${safeName}.json`); } /** Resolve the worktree base directory from config. */ @@ -68,6 +73,38 @@ function getWorktreeBase(repoPath: string): string { return join(repoPath, base); } +// ============================================================================ +// Branch Name Validation +// ============================================================================ + +/** + * Validate that a team name is a valid git branch name. + * Follows `git check-ref-format` rules for refs/heads/. + */ +export function validateBranchName(name: string): void { + const errors: string[] = []; + + if (/\s/.test(name)) errors.push('contains spaces'); + if (name.includes('..')) errors.push('contains ".."'); + if (name.includes('~')) errors.push('contains "~"'); + if (name.includes('^')) errors.push('contains "^"'); + if (name.includes(':')) errors.push('contains ":"'); + if (name.includes('?')) errors.push('contains "?"'); + if (name.includes('*')) errors.push('contains "*"'); + if (name.includes('[')) errors.push('contains "["'); + if (name.includes('\\')) errors.push('contains "\\"'); + // biome-ignore lint/suspicious/noControlCharactersInRegex: validating git ref format + if (/[\x00-\x1f\x7f]/.test(name)) errors.push('contains control characters'); + if (name.endsWith('.lock')) errors.push('ends with ".lock"'); + if (name.endsWith('/')) errors.push('ends with "/"'); + if (name.endsWith('.')) errors.push('ends with "."'); + if (name.startsWith('-')) errors.push('starts with "-"'); + + if (errors.length > 0) { + throw new Error(`Invalid team name '${name}': must be a valid git branch name (${errors.join(', ')})`); + } +} + // ============================================================================ // Agent Kill Helper // ============================================================================ @@ -142,15 +179,17 @@ async function ensureWorktree( * Create a new team with a git worktree. * * Idempotent — if the team already exists, returns existing config. - * Steps: git pull on baseBranch → git worktree add → persist config. + * Steps: validate name → git pull on baseBranch → git worktree add → persist config. */ export async function createTeam(name: string, repo: string, baseBranch = 'dev'): Promise { + validateBranchName(name); + const repoPath = path.resolve(repo); - const dir = teamsDir(repoPath); + const dir = teamsDir(); await mkdir(dir, { recursive: true }); // Idempotent: return existing team if it already exists - const filePath = teamFilePath(repoPath, name); + const filePath = teamFilePath(name); if (existsSync(filePath)) { const content = await readFile(filePath, 'utf-8'); return JSON.parse(content); @@ -191,8 +230,8 @@ export async function createTeam(name: string, repo: string, baseBranch = 'dev') * * Special case: if agentName is "council", hires all 10 built-in council members. */ -export async function hireAgent(teamName: string, agentName: string, repoPath: string): Promise { - const config = await getTeam(repoPath, teamName); +export async function hireAgent(teamName: string, agentName: string): Promise { + const config = await getTeam(teamName); if (!config) { throw new Error(`Team "${teamName}" not found.`); } @@ -212,7 +251,7 @@ export async function hireAgent(teamName: string, agentName: string, repoPath: s added = [agentName]; } - const filePath = teamFilePath(repoPath, teamName); + const filePath = teamFilePath(teamName); await writeFile(filePath, JSON.stringify(config, null, 2)); return added; } @@ -221,8 +260,8 @@ export async function hireAgent(teamName: string, agentName: string, repoPath: s * Remove an agent from a team's members list. * Returns true if the agent was removed, false if not found. */ -export async function fireAgent(teamName: string, agentName: string, repoPath: string): Promise { - const config = await getTeam(repoPath, teamName); +export async function fireAgent(teamName: string, agentName: string): Promise { + const config = await getTeam(teamName); if (!config) { throw new Error(`Team "${teamName}" not found.`); } @@ -231,7 +270,7 @@ export async function fireAgent(teamName: string, agentName: string, repoPath: s if (idx === -1) return false; config.members.splice(idx, 1); - const filePath = teamFilePath(repoPath, teamName); + const filePath = teamFilePath(teamName); await writeFile(filePath, JSON.stringify(config, null, 2)); // Best-effort kill running agent @@ -248,8 +287,8 @@ export async function fireAgent(teamName: string, agentName: string, repoPath: s * Disband a team: remove git worktree and delete team config. * Returns true if the team was found and disbanded. */ -export async function disbandTeam(repoPath: string, teamName: string): Promise { - const config = await getTeam(repoPath, teamName); +export async function disbandTeam(teamName: string): Promise { + const config = await getTeam(teamName); if (!config) return false; // Clean up native teams if enabled @@ -271,6 +310,7 @@ export async function disbandTeam(repoPath: string, teamName: string): Promise { +export async function getTeam(name: string): Promise { try { - const content = await readFile(teamFilePath(repoPath, name), 'utf-8'); + const content = await readFile(teamFilePath(name), 'utf-8'); return JSON.parse(content); } catch { return null; } } -/** List all teams in a repo. */ -export async function listTeams(repoPath: string): Promise { - const dir = teamsDir(repoPath); +/** List all teams globally. */ +export async function listTeams(): Promise { + const dir = teamsDir(); try { const files = await readdir(dir); const teams: TeamConfig[] = []; @@ -328,8 +368,8 @@ export async function listTeams(repoPath: string): Promise { } /** List members of a team. Returns null if team not found. */ -export async function listMembers(repoPath: string, teamName: string): Promise { - const config = await getTeam(repoPath, teamName); +export async function listMembers(teamName: string): Promise { + const config = await getTeam(teamName); if (!config) return null; return config.members; } diff --git a/src/lib/version.ts b/src/lib/version.ts index 64ff3c44c..2198d5e71 100644 --- a/src/lib/version.ts +++ b/src/lib/version.ts @@ -1,2 +1,40 @@ -// Runtime version (baked in at build time) -export const VERSION = '3.260314.3'; +/** + * Version — reads from package.json at runtime so `genie update` reflects the new version. + * + * Resolution order: + * 1. package.json relative to this file (dev / bun run) + * 2. package.json in the project root (compiled dist) + * 3. Hardcoded fallback + */ + +import { existsSync, readFileSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; + +const FALLBACK_VERSION = '0.0.0-unknown'; + +function readVersionFromPackageJson(): string { + // Try paths relative to this module + const candidates = [ + // From src/lib/version.ts → ../../package.json + resolve(dirname(import.meta.dir ?? __dirname), '..', '..', 'package.json'), + // From dist/genie.js → ../package.json + resolve(dirname(import.meta.dir ?? __dirname), '..', 'package.json'), + // From dist/genie.js → ./package.json (if placed alongside) + resolve(dirname(import.meta.dir ?? __dirname), 'package.json'), + ]; + + for (const candidate of candidates) { + try { + if (existsSync(candidate)) { + const pkg = JSON.parse(readFileSync(candidate, 'utf-8')); + if (pkg.version) return pkg.version; + } + } catch { + // Try next candidate + } + } + + return FALLBACK_VERSION; +} + +export const VERSION = readVersionFromPackageJson(); diff --git a/src/lib/wish-state.ts b/src/lib/wish-state.ts index d482f3235..f63959292 100644 --- a/src/lib/wish-state.ts +++ b/src/lib/wish-state.ts @@ -10,9 +10,10 @@ * in_progress → done (via completeGroup, recalculates dependents) */ -import { mkdir, open, readFile, stat, unlink, writeFile } from 'node:fs/promises'; +import { mkdir, readFile, writeFile } from 'node:fs/promises'; import { dirname, join } from 'node:path'; import { z } from 'zod'; +import { acquireLock } from './file-lock.js'; // ============================================================================ // Schemas @@ -47,72 +48,9 @@ export interface GroupDefinition { } // ============================================================================ -// File Locking — prevents concurrent state file races +// State locking // ============================================================================ -const LOCK_TIMEOUT_MS = 5000; -const LOCK_RETRY_MS = 50; -const LOCK_STALE_MS = 10000; - -async function tryCleanStaleLock(lockPath: string): Promise { - try { - const lockStat = await stat(lockPath); - if (Date.now() - lockStat.mtimeMs > LOCK_STALE_MS) { - try { - await unlink(lockPath); - } catch { - /* race with other cleanup */ - } - return true; - } - } catch { - return true; // lock gone, retry - } - return false; -} - -async function tryCreateLock(lockPath: string): Promise<(() => Promise) | null> { - try { - const handle = await open(lockPath, 'wx'); - await handle.writeFile(String(process.pid)); - await handle.close(); - return async () => { - try { - await unlink(lockPath); - } catch { - /* already removed */ - } - }; - } catch (err) { - const errCode = err instanceof Error && 'code' in err ? (err as NodeJS.ErrnoException).code : undefined; - if (errCode !== 'EEXIST') throw err; - return null; - } -} - -async function acquireLock(statePath: string): Promise<() => Promise> { - const lockPath = `${statePath}.lock`; - const deadline = Date.now() + LOCK_TIMEOUT_MS; - - while (true) { - const release = await tryCreateLock(lockPath); - if (release) return release; - - const cleaned = await tryCleanStaleLock(lockPath); - if (cleaned) continue; - - if (Date.now() > deadline) { - try { - await unlink(lockPath); - } catch { - throw new Error(`State lock timeout: could not remove stale lock at ${lockPath}`); - } - continue; - } - await new Promise((r) => setTimeout(r, LOCK_RETRY_MS)); - } -} - async function withStateLock(statePath: string, fn: (state: WishState) => T | Promise): Promise { const release = await acquireLock(statePath); try { diff --git a/src/term-commands/agents.ts b/src/term-commands/agents.ts index f0f46bbfa..9b96fa99d 100644 --- a/src/term-commands/agents.ts +++ b/src/term-commands/agents.ts @@ -614,10 +614,10 @@ async function rejectDuplicateRole(team: string, role: string): Promise { /** Resolve parent session ID and set up native team infrastructure. */ async function resolveNativeTeam( team: string, - repoPath: string, + _repoPath: string, options: { provider: string; role?: string; color?: string; planMode?: boolean; permissionMode?: string }, ): Promise<{ parentSessionId: string; spawnColor: ClaudeTeamColor; nativeTeam?: SpawnParams['nativeTeam'] }> { - const teamConfig = await teamManager.getTeam(repoPath, team); + const teamConfig = await teamManager.getTeam(team); let parentSessionId = teamConfig?.nativeTeamParentSessionId; if (!parentSessionId) { parentSessionId = (await nativeTeams.discoverClaudeSessionId()) ?? crypto.randomUUID(); @@ -732,7 +732,7 @@ async function buildSpawnParams( export async function handleWorkerSpawn(name: string, options: SpawnOptions): Promise { // 1. Resolve agent from directory or built-ins - const agent = await resolveAgentForSpawn(name, options); + let agent = await resolveAgentForSpawn(name, options); // 2. Resolve team const team = options.team || (await nativeTeams.discoverTeamName()); @@ -742,6 +742,12 @@ export async function handleWorkerSpawn(name: string, options: SpawnOptions): Pr } await rejectDuplicateRole(team, name); + // 2b. Override CWD with team worktree path if available + const teamConfig = await teamManager.getTeam(team); + if (teamConfig?.worktreePath) { + agent = { ...agent, repoPath: teamConfig.worktreePath }; + } + // 3. Build params const { params, parentSessionId, spawnColor } = await buildSpawnParams(name, team, options, agent); diff --git a/src/term-commands/dispatch.test.ts b/src/term-commands/dispatch.test.ts index 3d0041c4b..9b2e03451 100644 --- a/src/term-commands/dispatch.test.ts +++ b/src/term-commands/dispatch.test.ts @@ -18,7 +18,7 @@ import { join } from 'node:path'; import * as wishState from '../lib/wish-state.js'; import { parseRef } from './state.js'; -import { buildContextPrompt, extractGroup, extractWishContext, writeContextFile } from './dispatch.js'; +import { buildContextPrompt, extractGroup, extractWishContext, parseWishGroups, writeContextFile } from './dispatch.js'; // ============================================================================ // Sample WISH.md content for testing @@ -502,3 +502,44 @@ describe('dispatch commands - state machine integration', () => { expect(() => wishState.startGroup('test-wish-4', '3', 'agent-c', tempDir)).toThrow('dependency "2" is in_progress'); }); }); + +// ============================================================================ +// parseWishGroups — case-insensitive parsing +// ============================================================================ + +describe('parseWishGroups()', () => { + it('should parse standard Group headings', () => { + const groups = parseWishGroups(SAMPLE_WISH); + expect(groups.length).toBe(3); + expect(groups[0].name).toBe('1'); + expect(groups[1].name).toBe('2'); + expect(groups[2].name).toBe('3'); + }); + + it('should parse lowercase group headings (case-insensitive)', () => { + const content = '### group 1: Test\n**depends-on:** none\n\n### group 2: Next\n**depends-on:** Group 1'; + const groups = parseWishGroups(content); + expect(groups.length).toBe(2); + expect(groups[0].name).toBe('1'); + expect(groups[0].dependsOn).toEqual([]); + expect(groups[1].name).toBe('2'); + expect(groups[1].dependsOn).toEqual(['1']); + }); + + it('should parse mixed case group headings', () => { + const content = '### GROUP 1: Loud\n**depends-on:** none\n\n### Group 2: Normal\n**depends-on:** Group 1'; + const groups = parseWishGroups(content); + expect(groups.length).toBe(2); + }); + + it('should parse depends-on with Group prefix', () => { + const groups = parseWishGroups(SAMPLE_WISH); + expect(groups[1].dependsOn).toEqual(['1']); + expect(groups[2].dependsOn).toEqual(['2']); + }); + + it('should handle depends-on: none', () => { + const groups = parseWishGroups(SAMPLE_WISH); + expect(groups[0].dependsOn).toEqual([]); + }); +}); diff --git a/src/term-commands/dispatch.ts b/src/term-commands/dispatch.ts index 8958102e9..412f90ed9 100644 --- a/src/term-commands/dispatch.ts +++ b/src/term-commands/dispatch.ts @@ -144,7 +144,7 @@ function getGitDiff(): string { */ export function parseWishGroups(content: string): GroupDefinition[] { const groups: GroupDefinition[] = []; - const groupPattern = /^### Group (\d+):/gm; + const groupPattern = /^### Group (\d+):/gim; let match: RegExpExecArray | null; while ((match = groupPattern.exec(content)) !== null) { diff --git a/src/term-commands/msg.ts b/src/term-commands/msg.ts index 3900d5653..754ee9369 100644 --- a/src/term-commands/msg.ts +++ b/src/term-commands/msg.ts @@ -105,11 +105,11 @@ async function findMemberByPane(teamName: string, paneId: string): Promise { +export async function checkSendScope(_repoPath: string, sender: string, recipient: string): Promise { if (sender === 'cli') return null; const teamManager = await getTeamManager(); - const teams = await teamManager.listTeams(repoPath); + const teams = await teamManager.listTeams(); // Find teams where sender is a member let senderTeams = teams.filter((t) => t.members.includes(sender)); @@ -140,9 +140,9 @@ export async function checkSendScope(repoPath: string, sender: string, recipient /** * Find the team for a given agent (for broadcast and chat auto-detection). */ -async function findAgentTeam(repoPath: string, agentName: string): Promise { +async function findAgentTeam(_repoPath: string, agentName: string): Promise { const teamManager = await getTeamManager(); - const teams = await teamManager.listTeams(repoPath); + const teams = await teamManager.listTeams(); // Check membership const memberTeam = teams.find((t) => t.members.includes(agentName)); diff --git a/src/term-commands/team.ts b/src/term-commands/team.ts index b076313ee..fd71e10f5 100644 --- a/src/term-commands/team.ts +++ b/src/term-commands/team.ts @@ -45,14 +45,13 @@ export function registerTeamNamespace(program: Command): void { .option('--team ', 'Team name (auto-detects from leader context if omitted)') .action(async (agent: string, options: { team?: string }) => { try { - const repoPath = process.cwd(); - const teamName = options.team ?? (await autoDetectTeam(repoPath)); + const teamName = options.team ?? (await autoDetectTeam()); if (!teamName) { console.error('Error: Could not detect team. Use --team to specify.'); process.exit(1); } - const added = await teamManager.hireAgent(teamName, agent, repoPath); + const added = await teamManager.hireAgent(teamName, agent); if (added.length === 0) { console.log(`Agent "${agent}" is already a member of "${teamName}".`); } else if (agent === 'council') { @@ -77,14 +76,13 @@ export function registerTeamNamespace(program: Command): void { .option('--team ', 'Team name (auto-detects from leader context if omitted)') .action(async (agent: string, options: { team?: string }) => { try { - const repoPath = process.cwd(); - const teamName = options.team ?? (await autoDetectTeam(repoPath)); + const teamName = options.team ?? (await autoDetectTeam()); if (!teamName) { console.error('Error: Could not detect team. Use --team to specify.'); process.exit(1); } - const removed = await teamManager.fireAgent(teamName, agent, repoPath); + const removed = await teamManager.fireAgent(teamName, agent); if (removed) { console.log(`Fired "${agent}" from team "${teamName}".`); } else { @@ -106,11 +104,10 @@ export function registerTeamNamespace(program: Command): void { .option('--json', 'Output as JSON') .action(async (name: string | undefined, options: { json?: boolean }) => { try { - const repoPath = process.cwd(); if (name) { - await printMembers(repoPath, name, options.json); + await printMembers(name, options.json); } else { - await printTeams(repoPath, options.json); + await printTeams(options.json); } } catch (error) { const message = error instanceof Error ? error.message : String(error); @@ -125,8 +122,7 @@ export function registerTeamNamespace(program: Command): void { .description('Disband a team: kill members, remove worktree, delete config') .action(async (name: string) => { try { - const repoPath = process.cwd(); - const disbanded = await teamManager.disbandTeam(repoPath, name); + const disbanded = await teamManager.disbandTeam(name); if (disbanded) { console.log(`Team "${name}" disbanded.`); } else { @@ -146,19 +142,19 @@ export function registerTeamNamespace(program: Command): void { // ============================================================================ /** Auto-detect team name from GENIE_TEAM env var. */ -async function autoDetectTeam(repoPath: string): Promise { +async function autoDetectTeam(): Promise { const envTeam = process.env.GENIE_TEAM; if (envTeam) return envTeam; - const teams = await teamManager.listTeams(repoPath); + const teams = await teamManager.listTeams(); if (teams.length === 1) return teams[0].name; return null; } /** Print members of a specific team. */ -async function printMembers(repoPath: string, name: string, json?: boolean): Promise { - const members = await teamManager.listMembers(repoPath, name); +async function printMembers(name: string, json?: boolean): Promise { + const members = await teamManager.listMembers(name); if (members === null) { console.error(`Team "${name}" not found.`); process.exit(1); @@ -184,8 +180,8 @@ async function printMembers(repoPath: string, name: string, json?: boolean): Pro } /** Print all teams. */ -async function printTeams(repoPath: string, json?: boolean): Promise { - const teams = await teamManager.listTeams(repoPath); +async function printTeams(json?: boolean): Promise { + const teams = await teamManager.listTeams(); if (json) { console.log(JSON.stringify(teams, null, 2)); From 4fd5fc220a477555a59eada038caf0c2620a7288 Mon Sep 17 00:00:00 2001 From: Test User Date: Sat, 14 Mar 2026 14:27:00 -0300 Subject: [PATCH 03/13] =?UTF-8?q?fix:=20complete=20v2=20QA=20fixes=20?= =?UTF-8?q?=E2=80=94=20groups=202,3=20+=20test=20fixes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix 3 failing tests: isolate GENIE_HOME in team.test.ts and msg.test.ts so team manager reads from test-local directories - Add file-lock concurrency protection to mailbox.send(), mailbox.markDelivered(), and team-chat.postMessage() - Add wish-state validation: self-dep, dangling dep, and cycle detection (Kahn's algorithm) in createState() - Enforce strict completeGroup() transitions (must be in_progress) - Add resetGroup() to revert in_progress groups back to ready - Add JSDoc to getState() documenting lockless-by-design reads - Add reset CLI command in state.ts - Add comprehensive tests for validation and resetGroup --- src/lib/mailbox.ts | 58 +++++++++----- src/lib/team-chat.ts | 30 ++++--- src/lib/wish-state.test.ts | 141 +++++++++++++++++++++++++++++++-- src/lib/wish-state.ts | 97 +++++++++++++++++++++-- src/term-commands/msg.test.ts | 2 + src/term-commands/state.ts | 18 +++++ src/term-commands/team.test.ts | 7 +- 7 files changed, 304 insertions(+), 49 deletions(-) diff --git a/src/lib/mailbox.ts b/src/lib/mailbox.ts index 46c806b20..f93c8139e 100644 --- a/src/lib/mailbox.ts +++ b/src/lib/mailbox.ts @@ -12,6 +12,7 @@ import { mkdir, readFile, writeFile } from 'node:fs/promises'; import path, { join } from 'node:path'; import { v4 as uuidv4 } from 'uuid'; import type { NativeInboxMessage } from './claude-native-teams.js'; +import { acquireLock } from './file-lock.js'; // ============================================================================ // Types @@ -86,22 +87,29 @@ function generateMessageId(): string { * This persists BEFORE any delivery attempt (DEC-7). */ export async function send(repoPath: string, from: string, to: string, body: string): Promise { - const mailbox = await loadMailbox(repoPath, to); - - const message: MailboxMessage = { - id: generateMessageId(), - from, - to, - body, - createdAt: new Date().toISOString(), - read: false, - deliveredAt: null, - }; - - mailbox.messages.push(message); - await saveMailbox(repoPath, mailbox); - - return message; + // Ensure mailbox directory exists before acquiring lock (lock file needs parent dir) + await mkdir(mailboxDir(repoPath), { recursive: true }); + const release = await acquireLock(mailboxFilePath(repoPath, to)); + try { + const mailbox = await loadMailbox(repoPath, to); + + const message: MailboxMessage = { + id: generateMessageId(), + from, + to, + body, + createdAt: new Date().toISOString(), + read: false, + deliveredAt: null, + }; + + mailbox.messages.push(message); + await saveMailbox(repoPath, mailbox); + + return message; + } finally { + await release(); + } } /** @@ -116,12 +124,18 @@ export async function inbox(repoPath: string, workerId: string): Promise { - const mailbox = await loadMailbox(repoPath, workerId); - const msg = mailbox.messages.find((m) => m.id === messageId); - if (!msg) return false; - msg.deliveredAt = new Date().toISOString(); - await saveMailbox(repoPath, mailbox); - return true; + await mkdir(mailboxDir(repoPath), { recursive: true }); + const release = await acquireLock(mailboxFilePath(repoPath, workerId)); + try { + const mailbox = await loadMailbox(repoPath, workerId); + const msg = mailbox.messages.find((m) => m.id === messageId); + if (!msg) return false; + msg.deliveredAt = new Date().toISOString(); + await saveMailbox(repoPath, mailbox); + return true; + } finally { + await release(); + } } /** diff --git a/src/lib/team-chat.ts b/src/lib/team-chat.ts index d82055457..7fc08eccc 100644 --- a/src/lib/team-chat.ts +++ b/src/lib/team-chat.ts @@ -8,6 +8,7 @@ import { appendFile, mkdir, readFile } from 'node:fs/promises'; import { join } from 'node:path'; import { v4 as uuidv4 } from 'uuid'; +import { acquireLock } from './file-lock.js'; // ============================================================================ // Types @@ -51,20 +52,25 @@ export async function postMessage( sender: string, body: string, ): Promise { - const msg: ChatMessage = { - id: `chat-${uuidv4()}`, - sender, - body, - timestamp: new Date().toISOString(), - }; - + // Ensure chat directory exists before acquiring lock (lock file needs parent dir) const dir = chatDir(repoPath); await mkdir(dir, { recursive: true }); - - const filePath = chatFilePath(repoPath, teamName); - await appendFile(filePath, `${JSON.stringify(msg)}\n`); - - return msg; + const release = await acquireLock(chatFilePath(repoPath, teamName)); + try { + const msg: ChatMessage = { + id: `chat-${uuidv4()}`, + sender, + body, + timestamp: new Date().toISOString(), + }; + + const filePath = chatFilePath(repoPath, teamName); + await appendFile(filePath, `${JSON.stringify(msg)}\n`); + + return msg; + } finally { + await release(); + } } /** diff --git a/src/lib/wish-state.test.ts b/src/lib/wish-state.test.ts index 80ee934d3..05dc91791 100644 --- a/src/lib/wish-state.test.ts +++ b/src/lib/wish-state.test.ts @@ -2,7 +2,15 @@ import { afterEach, beforeEach, describe, expect, test } from 'bun:test'; import { mkdtemp, readFile, rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; -import { type GroupDefinition, completeGroup, createState, getGroupState, getState, startGroup } from './wish-state.js'; +import { + type GroupDefinition, + completeGroup, + createState, + getGroupState, + getState, + resetGroup, + startGroup, +} from './wish-state.js'; describe('wish-state', () => { let cwd: string; @@ -181,7 +189,9 @@ describe('wish-state', () => { await startGroup('test-wish', '1', 'agent-a', cwd); await completeGroup('test-wish', '1', cwd); - await expect(completeGroup('test-wish', '1', cwd)).rejects.toThrow('already done'); + await expect(completeGroup('test-wish', '1', cwd)).rejects.toThrow( + 'Cannot complete group "1": must be in_progress (currently done)', + ); }); test('refuses when group not found', async () => { @@ -198,16 +208,17 @@ describe('wish-state', () => { expect(groupState?.status).toBe('blocked'); await expect(completeGroup('test-wish', '2', cwd)).rejects.toThrow( - 'Cannot complete group "2": it is blocked (dependencies not met)', + 'Cannot complete group "2": must be in_progress (currently blocked)', ); }); - test('allows completing a ready group (skip in_progress)', async () => { + test('rejects ready groups (must be in_progress)', async () => { await createState('test-wish', sampleGroups, cwd); - // Group 1 is ready, complete directly without starting - const result = await completeGroup('test-wish', '1', cwd); - expect(result.status).toBe('done'); + // Group 1 is ready but not in_progress + await expect(completeGroup('test-wish', '1', cwd)).rejects.toThrow( + 'Cannot complete group "1": must be in_progress (currently ready)', + ); }); }); @@ -282,4 +293,120 @@ describe('wish-state', () => { } }); }); + + // ============================================================================ + // Validation: cycle detection, dangling deps, self-deps + // ============================================================================ + + describe('createState validation', () => { + test('rejects self-dependency', async () => { + const groups: GroupDefinition[] = [{ name: 'a', dependsOn: ['a'] }]; + await expect(createState('self-dep', groups, cwd)).rejects.toThrow('Group "a" depends on itself'); + }); + + test('rejects dangling dependency', async () => { + const groups: GroupDefinition[] = [{ name: 'a', dependsOn: ['nonexistent'] }]; + await expect(createState('dangling', groups, cwd)).rejects.toThrow( + 'Group "a" depends on non-existent group "nonexistent"', + ); + }); + + test('rejects cyclic dependencies (A→B→A)', async () => { + const groups: GroupDefinition[] = [ + { name: 'a', dependsOn: ['b'] }, + { name: 'b', dependsOn: ['a'] }, + ]; + await expect(createState('cycle', groups, cwd)).rejects.toThrow('Dependency cycle detected'); + }); + + test('rejects larger cycle (A→B→C→A)', async () => { + const groups: GroupDefinition[] = [ + { name: 'a', dependsOn: ['c'] }, + { name: 'b', dependsOn: ['a'] }, + { name: 'c', dependsOn: ['b'] }, + ]; + await expect(createState('big-cycle', groups, cwd)).rejects.toThrow('Dependency cycle detected'); + }); + + test('allows valid DAG', async () => { + const groups: GroupDefinition[] = [ + { name: 'a' }, + { name: 'b', dependsOn: ['a'] }, + { name: 'c', dependsOn: ['a'] }, + { name: 'd', dependsOn: ['b', 'c'] }, + ]; + const state = await createState('valid-dag', groups, cwd); + expect(state.groups.a.status).toBe('ready'); + expect(state.groups.d.status).toBe('blocked'); + }); + }); + + // ============================================================================ + // resetGroup + // ============================================================================ + + describe('resetGroup', () => { + test('resets in_progress group to ready', async () => { + await createState('test-wish', sampleGroups, cwd); + await startGroup('test-wish', '1', 'agent-a', cwd); + + const result = await resetGroup('test-wish', '1', cwd); + + expect(result.status).toBe('ready'); + expect(result.assignee).toBeUndefined(); + expect(result.startedAt).toBeUndefined(); + }); + + test('persists reset to disk', async () => { + await createState('test-wish', sampleGroups, cwd); + await startGroup('test-wish', '1', 'agent-a', cwd); + await resetGroup('test-wish', '1', cwd); + + const state = await getState('test-wish', cwd); + expect(state?.groups['1'].status).toBe('ready'); + expect(state?.groups['1'].assignee).toBeUndefined(); + }); + + test('rejects reset of ready group', async () => { + await createState('test-wish', sampleGroups, cwd); + + await expect(resetGroup('test-wish', '1', cwd)).rejects.toThrow( + 'Cannot reset: must be in_progress (currently ready)', + ); + }); + + test('rejects reset of blocked group', async () => { + await createState('test-wish', sampleGroups, cwd); + + await expect(resetGroup('test-wish', '2', cwd)).rejects.toThrow( + 'Cannot reset: must be in_progress (currently blocked)', + ); + }); + + test('rejects reset of done group', async () => { + await createState('test-wish', sampleGroups, cwd); + await startGroup('test-wish', '1', 'agent-a', cwd); + await completeGroup('test-wish', '1', cwd); + + await expect(resetGroup('test-wish', '1', cwd)).rejects.toThrow( + 'Cannot reset: must be in_progress (currently done)', + ); + }); + + test('rejects reset of nonexistent group', async () => { + await createState('test-wish', sampleGroups, cwd); + + await expect(resetGroup('test-wish', 'nonexistent', cwd)).rejects.toThrow('not found'); + }); + + test('allows re-start after reset', async () => { + await createState('test-wish', sampleGroups, cwd); + await startGroup('test-wish', '1', 'agent-a', cwd); + await resetGroup('test-wish', '1', cwd); + + const result = await startGroup('test-wish', '1', 'agent-b', cwd); + expect(result.status).toBe('in_progress'); + expect(result.assignee).toBe('agent-b'); + }); + }); }); diff --git a/src/lib/wish-state.ts b/src/lib/wish-state.ts index f63959292..9ce342312 100644 --- a/src/lib/wish-state.ts +++ b/src/lib/wish-state.ts @@ -111,6 +111,75 @@ function recalculateDependents(state: WishState): void { } } +// ============================================================================ +// Validation +// ============================================================================ + +/** Check for self-dependencies and references to non-existent groups. */ +function validateGroupRefs(groups: GroupDefinition[]): void { + const groupNames = new Set(groups.map((g) => g.name)); + + for (const group of groups) { + if (group.dependsOn?.includes(group.name)) { + throw new Error(`Group "${group.name}" depends on itself`); + } + for (const dep of group.dependsOn ?? []) { + if (!groupNames.has(dep)) { + throw new Error(`Group "${group.name}" depends on non-existent group "${dep}"`); + } + } + } +} + +/** Detect dependency cycles using Kahn's topological sort algorithm. */ +function detectCycles(groups: GroupDefinition[]): void { + const inDegree: Record = {}; + const adjacency: Record = {}; + + for (const group of groups) { + inDegree[group.name] = (group.dependsOn ?? []).length; + adjacency[group.name] = []; + } + for (const group of groups) { + for (const dep of group.dependsOn ?? []) { + adjacency[dep].push(group.name); + } + } + + const queue: string[] = Object.entries(inDegree) + .filter(([, deg]) => deg === 0) + .map(([name]) => name); + let processed = 0; + + while (queue.length > 0) { + const node = queue.shift(); + if (!node) break; + processed++; + for (const neighbor of adjacency[node]) { + inDegree[neighbor]--; + if (inDegree[neighbor] === 0) { + queue.push(neighbor); + } + } + } + + if (processed !== groups.length) { + const remaining = Object.entries(inDegree) + .filter(([, deg]) => deg > 0) + .map(([name]) => name); + throw new Error(`Dependency cycle detected among groups: ${remaining.join(', ')}`); + } +} + +/** + * Validate group definitions: no self-deps, no dangling deps, no cycles. + * Throws on the first violation found. + */ +function validateGroups(groups: GroupDefinition[]): void { + validateGroupRefs(groups); + detectCycles(groups); +} + // ============================================================================ // Public API // ============================================================================ @@ -120,6 +189,8 @@ function recalculateDependents(state: WishState): void { * Groups with no dependencies start as `ready`. Others start as `blocked`. */ export async function createState(slug: string, groups: GroupDefinition[], cwd?: string): Promise { + validateGroups(groups); + const statePath = getStatePath(slug, cwd); await mkdir(dirname(statePath), { recursive: true }); @@ -199,12 +270,8 @@ export async function completeGroup(slug: string, groupName: string, cwd?: strin throw new Error(`Group "${groupName}" not found in wish "${slug}"`); } - if (group.status === 'done') { - throw new Error(`Group "${groupName}" is already done`); - } - - if (group.status === 'blocked') { - throw new Error(`Cannot complete group "${groupName}": it is blocked (dependencies not met)`); + if (group.status !== 'in_progress') { + throw new Error(`Cannot complete group "${groupName}": must be in_progress (currently ${group.status})`); } group.status = 'done'; @@ -218,8 +285,24 @@ export async function completeGroup(slug: string, groupName: string, cwd?: strin } /** - * Read current state for a wish. + * Reset an in-progress group back to ready. + * Clears assignee and startedAt. Only valid from in_progress status. */ +export async function resetGroup(slug: string, groupName: string, cwd?: string): Promise { + const statePath = getStatePath(slug, cwd); + return withStateLock(statePath, (state) => { + const group = state.groups[groupName]; + if (!group) throw new Error(`Group "${groupName}" not found`); + if (group.status !== 'in_progress') + throw new Error(`Cannot reset: must be in_progress (currently ${group.status})`); + group.status = 'ready'; + group.assignee = undefined; + group.startedAt = undefined; + return { ...group }; + }); +} + +/** Read current state. Lockless by design — reads are eventually consistent. */ export async function getState(slug: string, cwd?: string): Promise { const statePath = getStatePath(slug, cwd); return loadState(statePath); diff --git a/src/term-commands/msg.test.ts b/src/term-commands/msg.test.ts index 11d516aaa..cd2a0ace7 100644 --- a/src/term-commands/msg.test.ts +++ b/src/term-commands/msg.test.ts @@ -124,6 +124,8 @@ describe('checkSendScope', () => { tempDir = await mkdtemp(join(tmpdir(), 'scope-test-')); // Create .genie/teams directory await mkdir(join(tempDir, '.genie', 'teams'), { recursive: true }); + // Point GENIE_HOME to tempDir/.genie so listTeams() finds team files written here + process.env.GENIE_HOME = join(tempDir, '.genie'); }); afterEach(async () => { diff --git a/src/term-commands/state.ts b/src/term-commands/state.ts index 176298d48..cfcf7bedf 100644 --- a/src/term-commands/state.ts +++ b/src/term-commands/state.ts @@ -158,4 +158,22 @@ export function registerStateCommands(program: Command): void { .action(async (slug: string) => { await statusCommand(slug); }); + + program + .command('reset ') + .description('Reset an in-progress group back to ready (format: #)') + .action(async (ref: string) => { + try { + const { slug, group } = parseRef(ref); + const result = await wishState.resetGroup(slug, group); + console.log(`🔄 Group "${group}" reset to ready in wish "${slug}"`); + if (result.status === 'ready') { + console.log(' Status: ready (assignee cleared)'); + } + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + console.error(`❌ ${message}`); + process.exit(1); + } + }); } diff --git a/src/term-commands/team.test.ts b/src/term-commands/team.test.ts index c07393768..a8b5fa1ad 100644 --- a/src/term-commands/team.test.ts +++ b/src/term-commands/team.test.ts @@ -14,6 +14,7 @@ import { $ } from 'bun'; const TEST_DIR = '/tmp/team-cli-test'; const TEST_REPO = join(TEST_DIR, 'test-repo'); +const TEST_GENIE_HOME = join(TEST_DIR, 'genie-home'); // Path to the genie CLI entrypoint const GENIE_BIN = join(import.meta.dir, '..', 'genie.ts'); @@ -26,6 +27,7 @@ async function setupTestRepo(): Promise { } await mkdir(TEST_REPO, { recursive: true }); + await mkdir(TEST_GENIE_HOME, { recursive: true }); await $`git -C ${TEST_REPO} init`.quiet(); await $`git -C ${TEST_REPO} config user.email "test@test.com"`.quiet(); await $`git -C ${TEST_REPO} config user.name "Test"`.quiet(); @@ -67,7 +69,10 @@ async function cleanupTestRepo(): Promise { /** Run genie CLI command and return stdout. */ async function genie(...args: string[]): Promise<{ stdout: string; exitCode: number }> { try { - const result = await $`bun ${GENIE_BIN} ${args}`.quiet().cwd(TEST_REPO); + const result = await $`bun ${GENIE_BIN} ${args}` + .quiet() + .cwd(TEST_REPO) + .env({ ...process.env, GENIE_HOME: TEST_GENIE_HOME }); return { stdout: result.stdout.toString(), exitCode: 0 }; } catch (err: unknown) { const shellErr = err as { stdout?: Buffer; exitCode?: number }; From 8c0d5f707f68737d8e80d27949c0dac417871874 Mon Sep 17 00:00:00 2001 From: Test User Date: Sat, 14 Mar 2026 14:58:52 -0300 Subject: [PATCH 04/13] ci: publish @next to npm on dev merge Add build + publish steps to version.yml so dev merges publish to npm under the `next` dist-tag. Install with: bun add -g @automagik/genie@next Co-Authored-By: Paperclip --- .github/workflows/version.yml | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/.github/workflows/version.yml b/.github/workflows/version.yml index 8b48f7752..e363ebf24 100644 --- a/.github/workflows/version.yml +++ b/.github/workflows/version.yml @@ -81,3 +81,18 @@ jobs: git tag "v${VERSION}" git push --atomic origin HEAD:refs/heads/dev "refs/tags/v${VERSION}" + + - name: Build CLI + run: bun run build + + - name: Publish dev release to npm + env: + NPM_TOKEN: ${{ secrets.NPM_TOKEN }} + NPM_CONFIG_TOKEN: ${{ secrets.NPM_TOKEN }} + HUSKY: "0" + run: | + if [ -z "$NPM_TOKEN" ]; then + echo "⚠️ NPM_TOKEN not set — skipping dev publish" + exit 0 + fi + bun publish --access public --tag next From cd828f26c934e4fc14ad039223c17a9fa39c2bf8 Mon Sep 17 00:00:00 2001 From: Test User Date: Sat, 14 Mar 2026 15:06:50 -0300 Subject: [PATCH 05/13] feat(update): add --next/--stable channel switching genie update --next Switch to dev builds (@next npm tag) genie update --stable Switch to stable releases (@latest npm tag) genie update Uses last selected channel (default: latest) Channel preference persisted in ~/.genie/config.json (updateChannel field). Co-Authored-By: Paperclip --- src/genie-commands/update.ts | 60 ++++++++++++++++++++++++++++-------- src/genie.ts | 7 ++++- src/types/genie-config.ts | 2 ++ 3 files changed, 56 insertions(+), 13 deletions(-) diff --git a/src/genie-commands/update.ts b/src/genie-commands/update.ts index c4160b07f..cb8f56391 100644 --- a/src/genie-commands/update.ts +++ b/src/genie-commands/update.ts @@ -3,7 +3,7 @@ import { existsSync } from 'node:fs'; import { chmod, copyFile, mkdir, unlink } from 'node:fs/promises'; import { homedir } from 'node:os'; import { join } from 'node:path'; -import { genieConfigExists, loadGenieConfig } from '../lib/genie-config.js'; +import { genieConfigExists, loadGenieConfig, saveGenieConfig } from '../lib/genie-config.js'; const GENIE_HOME = process.env.GENIE_HOME || join(homedir(), '.genie'); const GENIE_SRC = join(GENIE_HOME, 'src'); @@ -144,26 +144,26 @@ async function detectInstallationType(): Promise { return hasBun ? 'bun' : 'npm'; } -async function updateViaBun(): Promise { - log('Updating via bun...'); - const result = await runCommand('bun', ['install', '-g', '@automagik/genie@latest']); +async function updateViaBun(channel: string): Promise { + log(`Updating via bun (channel: ${channel})...`); + const result = await runCommand('bun', ['install', '-g', `@automagik/genie@${channel}`]); if (!result.success) { error('Failed to update via bun'); process.exit(1); } console.log(); - success('Genie CLI updated!'); + success(`Genie CLI updated (${channel})!`); } -async function updateViaNpm(): Promise { - log('Updating via npm...'); - const result = await runCommand('npm', ['install', '-g', '@automagik/genie@latest']); +async function updateViaNpm(channel: string): Promise { + log(`Updating via npm (channel: ${channel})...`); + const result = await runCommand('npm', ['install', '-g', `@automagik/genie@${channel}`]); if (!result.success) { error('Failed to update via npm'); process.exit(1); } console.log(); - success('Genie CLI updated!'); + success(`Genie CLI updated (${channel})!`); } async function updateSource(): Promise { @@ -286,14 +286,50 @@ async function symlinkOrCopy(src: string, dest: string): Promise { } } -export async function updateCommand(): Promise { +async function resolveChannel(options: { next?: boolean; stable?: boolean }): Promise { + // Explicit flags override everything + if (options.next) return 'next'; + if (options.stable) return 'latest'; + + // Read saved channel from config + if (genieConfigExists()) { + try { + const config = await loadGenieConfig(); + if (config.updateChannel) return config.updateChannel; + } catch { + // Ignore config errors + } + } + + return 'latest'; +} + +async function persistChannel(channel: string): Promise { + try { + const config = await loadGenieConfig(); + config.updateChannel = channel as 'latest' | 'next'; + await saveGenieConfig(config); + } catch { + // Non-fatal — channel preference lost but update still works + } +} + +export async function updateCommand(options: { next?: boolean; stable?: boolean } = {}): Promise { console.log(); console.log('\x1b[1m🧞 Genie CLI Update\x1b[0m'); console.log('\x1b[2m────────────────────────────────────\x1b[0m'); console.log(); + const channel = await resolveChannel(options); + + // Persist channel when explicitly switching + if (options.next || options.stable) { + await persistChannel(channel); + } + const installType = await detectInstallationType(); log(`Detected installation: ${installType}`); + log(`Channel: ${channel}${channel === 'next' ? ' (dev builds)' : ' (stable)'}`); console.log(); if (installType === 'unknown') { @@ -312,10 +348,10 @@ export async function updateCommand(): Promise { await updateSource(); break; case 'bun': - await updateViaBun(); + await updateViaBun(channel); break; case 'npm': - await updateViaNpm(); + await updateViaNpm(channel); break; } } diff --git a/src/genie.ts b/src/genie.ts index 41717cf5e..08ba98839 100644 --- a/src/genie.ts +++ b/src/genie.ts @@ -92,7 +92,12 @@ program }); program.command('doctor').description('Run diagnostic checks on genie installation').action(doctorCommand); -program.command('update').description('Update Genie CLI to the latest version').action(updateCommand); +program + .command('update') + .description('Update Genie CLI to the latest version') + .option('--next', 'Switch to dev builds (npm @next tag)') + .option('--stable', 'Switch to stable releases (npm @latest tag)') + .action(updateCommand); program.command('uninstall').description('Remove Genie CLI and clean up hooks').action(uninstallCommand); const shortcuts = program.command('shortcuts').description('Manage tmux keyboard shortcuts'); diff --git a/src/types/genie-config.ts b/src/types/genie-config.ts index 22987327b..f09261f19 100644 --- a/src/types/genie-config.ts +++ b/src/types/genie-config.ts @@ -76,6 +76,8 @@ export const GenieConfigSchema = z.object({ shortcuts: ShortcutsConfigSchema.default({}), codex: CodexConfigSchema.optional(), installMethod: z.enum(['source', 'npm', 'bun']).optional(), + // npm dist-tag channel: 'latest' (stable) or 'next' (dev builds) + updateChannel: z.enum(['latest', 'next']).default('latest'), setupComplete: z.boolean().default(false), lastSetupAt: z.string().optional(), // Path to genie-cli source directory (for dev mode sync) From c4d52c772dc0e449a6f40ee175d483bbd199bcf0 Mon Sep 17 00:00:00 2001 From: Test User Date: Sat, 14 Mar 2026 15:42:30 -0300 Subject: [PATCH 06/13] feat(update): sync Claude Code plugin after CLI update After bun/npm install, copies the plugin from the installed package to ~/.claude/plugins/cache/automagik/genie// and updates installed_plugins.json so Claude Code loads the new skills/hooks without manual reinstall. Co-Authored-By: Paperclip --- src/genie-commands/update.ts | 107 ++++++++++++++++++++++++++++++++++- 1 file changed, 106 insertions(+), 1 deletion(-) diff --git a/src/genie-commands/update.ts b/src/genie-commands/update.ts index cb8f56391..ddff941fc 100644 --- a/src/genie-commands/update.ts +++ b/src/genie-commands/update.ts @@ -1,5 +1,5 @@ import { spawn } from 'node:child_process'; -import { existsSync } from 'node:fs'; +import { copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs'; import { chmod, copyFile, mkdir, unlink } from 'node:fs/promises'; import { homedir } from 'node:os'; import { join } from 'node:path'; @@ -286,6 +286,109 @@ async function symlinkOrCopy(src: string, dest: string): Promise { } } +// ============================================================================ +// Plugin Sync — update Claude Code plugin cache after CLI update +// ============================================================================ + +function copyDirSync(src: string, dest: string): void { + mkdirSync(dest, { recursive: true }); + for (const entry of readdirSync(src, { withFileTypes: true })) { + const srcPath = join(src, entry.name); + const destPath = join(dest, entry.name); + if (entry.isDirectory()) { + copyDirSync(srcPath, destPath); + } else { + copyFileSync(srcPath, destPath); + } + } +} + +function resolveGlobalPkgDir(): string | null { + // Bun global install location + const bunPath = join(homedir(), '.bun', 'install', 'global', 'node_modules', '@automagik', 'genie'); + if (existsSync(bunPath)) return bunPath; + + // npm global install location (try common paths) + const npmPaths = [ + join(homedir(), '.npm-global', 'lib', 'node_modules', '@automagik', 'genie'), + '/usr/local/lib/node_modules/@automagik/genie', + '/usr/lib/node_modules/@automagik/genie', + ]; + for (const p of npmPaths) { + if (existsSync(p)) return p; + } + + return null; +} + +async function syncPlugin(): Promise { + log('Syncing Claude Code plugin...'); + + const globalPkgDir = resolveGlobalPkgDir(); + if (!globalPkgDir) { + log('Could not find installed package — skipping plugin sync'); + return; + } + + const pluginSrc = join(globalPkgDir, 'plugins', 'genie'); + if (!existsSync(pluginSrc)) { + log('Plugin source not found in package — skipping plugin sync'); + return; + } + + // Read version from installed package + let version: string; + try { + const pkg = JSON.parse(readFileSync(join(globalPkgDir, 'package.json'), 'utf-8')); + version = pkg.version; + } catch { + log('Could not read package version — skipping plugin sync'); + return; + } + + // Copy to Claude Code plugin cache + const claudePlugins = join(homedir(), '.claude', 'plugins'); + const cacheDir = join(claudePlugins, 'cache', 'automagik', 'genie', version); + + try { + // Clean existing cache dir if it exists (stale version) + if (existsSync(cacheDir)) { + rmSync(cacheDir, { recursive: true, force: true }); + } + copyDirSync(pluginSrc, cacheDir); + } catch (err) { + error(`Failed to copy plugin: ${err}`); + return; + } + + // Update installed_plugins.json registry + const registryPath = join(claudePlugins, 'installed_plugins.json'); + try { + if (existsSync(registryPath)) { + const registry = JSON.parse(readFileSync(registryPath, 'utf-8')); + const entries = registry.plugins?.['genie@automagik']; + if (Array.isArray(entries)) { + for (const entry of entries) { + if (entry.scope === 'user') { + entry.installPath = cacheDir; + entry.version = version; + entry.lastUpdated = new Date().toISOString(); + } + } + writeFileSync(registryPath, JSON.stringify(registry, null, 2)); + } + } + } catch (err) { + log(`Registry update failed (non-fatal): ${err}`); + } + + success(`Plugin synced to v${version}`); +} + +// ============================================================================ +// Channel Management +// ============================================================================ + async function resolveChannel(options: { next?: boolean; stable?: boolean }): Promise { // Explicit flags override everything if (options.next) return 'next'; @@ -349,9 +452,11 @@ export async function updateCommand(options: { next?: boolean; stable?: boolean break; case 'bun': await updateViaBun(channel); + await syncPlugin(); break; case 'npm': await updateViaNpm(channel); + await syncPlugin(); break; } } From ae6a940df90f40b0c7688c13aa39bba18c43f931 Mon Sep 17 00:00:00 2001 From: Test User Date: Sat, 14 Mar 2026 15:55:04 -0300 Subject: [PATCH 07/13] fix(update): resolve plugin path by install type, use npm root -g - Pass installType to syncPlugin() so bun updates resolve bun path and npm updates resolve npm path (no stale cross-resolution) - Use `npm root -g` for dynamic npm global dir resolution (supports nvm/fnm/volta managed installs) - Keep fallback chain for edge cases Co-Authored-By: Paperclip --- src/genie-commands/update.ts | 45 ++++++++++++++++++++++-------------- 1 file changed, 28 insertions(+), 17 deletions(-) diff --git a/src/genie-commands/update.ts b/src/genie-commands/update.ts index ddff941fc..fc8f77e2b 100644 --- a/src/genie-commands/update.ts +++ b/src/genie-commands/update.ts @@ -303,28 +303,39 @@ function copyDirSync(src: string, dest: string): void { } } -function resolveGlobalPkgDir(): string | null { - // Bun global install location - const bunPath = join(homedir(), '.bun', 'install', 'global', 'node_modules', '@automagik', 'genie'); - if (existsSync(bunPath)) return bunPath; - - // npm global install location (try common paths) - const npmPaths = [ - join(homedir(), '.npm-global', 'lib', 'node_modules', '@automagik', 'genie'), - '/usr/local/lib/node_modules/@automagik/genie', - '/usr/lib/node_modules/@automagik/genie', - ]; - for (const p of npmPaths) { - if (existsSync(p)) return p; +async function resolveGlobalPkgDir(installType: InstallationType): Promise { + // Prefer the package manager that was actually used for this update + if (installType === 'bun') { + const bunPath = join(homedir(), '.bun', 'install', 'global', 'node_modules', '@automagik', 'genie'); + if (existsSync(bunPath)) return bunPath; + } + + if (installType === 'npm') { + // Dynamic resolution via npm root -g (handles nvm/fnm/volta) + const npmRootResult = await runCommandSilent('npm', ['root', '-g']); + if (npmRootResult.success) { + const npmPath = join(npmRootResult.output.trim(), '@automagik', 'genie'); + if (existsSync(npmPath)) return npmPath; + } + } + + // Fallback: try both regardless of installType + const bunFallback = join(homedir(), '.bun', 'install', 'global', 'node_modules', '@automagik', 'genie'); + if (existsSync(bunFallback)) return bunFallback; + + const npmRootFallback = await runCommandSilent('npm', ['root', '-g']); + if (npmRootFallback.success) { + const npmPath = join(npmRootFallback.output.trim(), '@automagik', 'genie'); + if (existsSync(npmPath)) return npmPath; } return null; } -async function syncPlugin(): Promise { +async function syncPlugin(installType: InstallationType): Promise { log('Syncing Claude Code plugin...'); - const globalPkgDir = resolveGlobalPkgDir(); + const globalPkgDir = await resolveGlobalPkgDir(installType); if (!globalPkgDir) { log('Could not find installed package — skipping plugin sync'); return; @@ -452,11 +463,11 @@ export async function updateCommand(options: { next?: boolean; stable?: boolean break; case 'bun': await updateViaBun(channel); - await syncPlugin(); + await syncPlugin(installType); break; case 'npm': await updateViaNpm(channel); - await syncPlugin(); + await syncPlugin(installType); break; } } From 149074f119e987b9b4b80d9c22443de9091904e1 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sat, 14 Mar 2026 19:05:05 +0000 Subject: [PATCH 08/13] chore(version): bump to 3.260314.4 [skip ci] --- .claude-plugin/marketplace.json | 2 +- openclaw.plugin.json | 2 +- package.json | 2 +- plugins/genie/.claude-plugin/plugin.json | 2 +- plugins/genie/package.json | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 31e16052b..0c0c8a1e1 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -10,7 +10,7 @@ "plugins": [ { "name": "genie", - "version": "3.260314.3", + "version": "3.260314.4", "source": "./plugins/genie", "description": "Human-AI partnership for Claude Code. Share a terminal, orchestrate workers, evolve together. Brainstorm ideas, wish them into plans, make with parallel agents, ship as one team. A coding genie that grows with your project." } diff --git a/openclaw.plugin.json b/openclaw.plugin.json index b022cce9d..9910d4464 100644 --- a/openclaw.plugin.json +++ b/openclaw.plugin.json @@ -2,7 +2,7 @@ "id": "genie", "name": "Genie", "description": "Skills, agents, and hooks for the Genie CLI terminal orchestration toolkit", - "version": "3.260314.3", + "version": "3.260314.4", "configSchema": { "type": "object", "additionalProperties": false, diff --git a/package.json b/package.json index 2b22ee1f2..61250fa04 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@automagik/genie", - "version": "3.260314.3", + "version": "3.260314.4", "description": "Collaborative terminal toolkit for human + AI workflows", "type": "module", "bin": { diff --git a/plugins/genie/.claude-plugin/plugin.json b/plugins/genie/.claude-plugin/plugin.json index 128d0333c..912603ebc 100644 --- a/plugins/genie/.claude-plugin/plugin.json +++ b/plugins/genie/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "genie", - "version": "3.260314.3", + "version": "3.260314.4", "description": "Human-AI partnership for Claude Code. Share a terminal, orchestrate workers, evolve together. Brainstorm ideas, turn them into wishes, execute with /work, validate with /review, and ship as one team.", "author": { "name": "Namastex Labs" diff --git a/plugins/genie/package.json b/plugins/genie/package.json index 31130046b..7d83be208 100644 --- a/plugins/genie/package.json +++ b/plugins/genie/package.json @@ -1,6 +1,6 @@ { "name": "genie-plugin", - "version": "3.260314.3", + "version": "3.260314.4", "private": true, "description": "Runtime dependencies for genie bundled CLIs", "type": "module", From afd9baddcaf1839af02d22cede7c5f9ad7baa141 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sat, 14 Mar 2026 19:07:00 +0000 Subject: [PATCH 09/13] chore(version): bump to 3.260314.5 [skip ci] --- .claude-plugin/marketplace.json | 2 +- openclaw.plugin.json | 2 +- package.json | 2 +- plugins/genie/.claude-plugin/plugin.json | 2 +- plugins/genie/package.json | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 0c0c8a1e1..cbb95919f 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -10,7 +10,7 @@ "plugins": [ { "name": "genie", - "version": "3.260314.4", + "version": "3.260314.5", "source": "./plugins/genie", "description": "Human-AI partnership for Claude Code. Share a terminal, orchestrate workers, evolve together. Brainstorm ideas, wish them into plans, make with parallel agents, ship as one team. A coding genie that grows with your project." } diff --git a/openclaw.plugin.json b/openclaw.plugin.json index 9910d4464..42599b9fc 100644 --- a/openclaw.plugin.json +++ b/openclaw.plugin.json @@ -2,7 +2,7 @@ "id": "genie", "name": "Genie", "description": "Skills, agents, and hooks for the Genie CLI terminal orchestration toolkit", - "version": "3.260314.4", + "version": "3.260314.5", "configSchema": { "type": "object", "additionalProperties": false, diff --git a/package.json b/package.json index 61250fa04..f9f73004f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@automagik/genie", - "version": "3.260314.4", + "version": "3.260314.5", "description": "Collaborative terminal toolkit for human + AI workflows", "type": "module", "bin": { diff --git a/plugins/genie/.claude-plugin/plugin.json b/plugins/genie/.claude-plugin/plugin.json index 912603ebc..fc9b12ce5 100644 --- a/plugins/genie/.claude-plugin/plugin.json +++ b/plugins/genie/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "genie", - "version": "3.260314.4", + "version": "3.260314.5", "description": "Human-AI partnership for Claude Code. Share a terminal, orchestrate workers, evolve together. Brainstorm ideas, turn them into wishes, execute with /work, validate with /review, and ship as one team.", "author": { "name": "Namastex Labs" diff --git a/plugins/genie/package.json b/plugins/genie/package.json index 7d83be208..71da65590 100644 --- a/plugins/genie/package.json +++ b/plugins/genie/package.json @@ -1,6 +1,6 @@ { "name": "genie-plugin", - "version": "3.260314.4", + "version": "3.260314.5", "private": true, "description": "Runtime dependencies for genie bundled CLIs", "type": "module", From e0944db01d2035bfbbc36f1738e8196028a36075 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sat, 14 Mar 2026 19:17:19 +0000 Subject: [PATCH 10/13] chore(version): bump to 3.260314.6 [skip ci] --- .claude-plugin/marketplace.json | 2 +- openclaw.plugin.json | 2 +- package.json | 2 +- plugins/genie/.claude-plugin/plugin.json | 2 +- plugins/genie/package.json | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index cbb95919f..95d878cd8 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -10,7 +10,7 @@ "plugins": [ { "name": "genie", - "version": "3.260314.5", + "version": "3.260314.6", "source": "./plugins/genie", "description": "Human-AI partnership for Claude Code. Share a terminal, orchestrate workers, evolve together. Brainstorm ideas, wish them into plans, make with parallel agents, ship as one team. A coding genie that grows with your project." } diff --git a/openclaw.plugin.json b/openclaw.plugin.json index 42599b9fc..ad8dd8e5f 100644 --- a/openclaw.plugin.json +++ b/openclaw.plugin.json @@ -2,7 +2,7 @@ "id": "genie", "name": "Genie", "description": "Skills, agents, and hooks for the Genie CLI terminal orchestration toolkit", - "version": "3.260314.5", + "version": "3.260314.6", "configSchema": { "type": "object", "additionalProperties": false, diff --git a/package.json b/package.json index f9f73004f..25eeb6163 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@automagik/genie", - "version": "3.260314.5", + "version": "3.260314.6", "description": "Collaborative terminal toolkit for human + AI workflows", "type": "module", "bin": { diff --git a/plugins/genie/.claude-plugin/plugin.json b/plugins/genie/.claude-plugin/plugin.json index fc9b12ce5..e158788e9 100644 --- a/plugins/genie/.claude-plugin/plugin.json +++ b/plugins/genie/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "genie", - "version": "3.260314.5", + "version": "3.260314.6", "description": "Human-AI partnership for Claude Code. Share a terminal, orchestrate workers, evolve together. Brainstorm ideas, turn them into wishes, execute with /work, validate with /review, and ship as one team.", "author": { "name": "Namastex Labs" diff --git a/plugins/genie/package.json b/plugins/genie/package.json index 71da65590..0a47c1fce 100644 --- a/plugins/genie/package.json +++ b/plugins/genie/package.json @@ -1,6 +1,6 @@ { "name": "genie-plugin", - "version": "3.260314.5", + "version": "3.260314.6", "private": true, "description": "Runtime dependencies for genie bundled CLIs", "type": "module", From ccd43f79152ad12e8fe5dbce470bb69e5b05ecaf Mon Sep 17 00:00:00 2001 From: Test User Date: Sat, 14 Mar 2026 16:33:10 -0300 Subject: [PATCH 11/13] fix(spawn): pass CWD to tmux split-window via -c flag (#562) The worktree CWD override in handleWorkerSpawn set ctx.cwd correctly but tmux split-window inherited the parent pane's CWD instead. Adding -c flag ensures the spawned pane starts in the team's worktree. Co-Authored-By: Paperclip --- src/term-commands/agents.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/term-commands/agents.ts b/src/term-commands/agents.ts index f0f46bbfa..e599252e5 100644 --- a/src/term-commands/agents.ts +++ b/src/term-commands/agents.ts @@ -502,7 +502,8 @@ async function launchTmuxSpawn(ctx: SpawnCtx): Promise { let paneId: string; try { - const splitCmd = `tmux split-window -d ${splitTarget} -P -F '#{pane_id}' ${ctx.fullCommand}`; + const cwdFlag = ctx.cwd ? `-c '${ctx.cwd}'` : ''; + const splitCmd = `tmux split-window -d ${splitTarget} ${cwdFlag} -P -F '#{pane_id}' ${ctx.fullCommand}`; paneId = execSync(splitCmd, { encoding: 'utf-8' }).trim(); } catch (err) { console.error(`Failed to create tmux pane: ${err instanceof Error ? err.message : 'unknown error'}`); From 523f1c51d6da6edb0bf80e0adf514bfba55c105f Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sat, 14 Mar 2026 19:34:57 +0000 Subject: [PATCH 12/13] chore(version): bump to 3.260314.7 [skip ci] --- .claude-plugin/marketplace.json | 2 +- openclaw.plugin.json | 2 +- package.json | 2 +- plugins/genie/.claude-plugin/plugin.json | 2 +- plugins/genie/package.json | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 95d878cd8..4c77c067e 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -10,7 +10,7 @@ "plugins": [ { "name": "genie", - "version": "3.260314.6", + "version": "3.260314.7", "source": "./plugins/genie", "description": "Human-AI partnership for Claude Code. Share a terminal, orchestrate workers, evolve together. Brainstorm ideas, wish them into plans, make with parallel agents, ship as one team. A coding genie that grows with your project." } diff --git a/openclaw.plugin.json b/openclaw.plugin.json index ad8dd8e5f..469010972 100644 --- a/openclaw.plugin.json +++ b/openclaw.plugin.json @@ -2,7 +2,7 @@ "id": "genie", "name": "Genie", "description": "Skills, agents, and hooks for the Genie CLI terminal orchestration toolkit", - "version": "3.260314.6", + "version": "3.260314.7", "configSchema": { "type": "object", "additionalProperties": false, diff --git a/package.json b/package.json index 25eeb6163..452814131 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@automagik/genie", - "version": "3.260314.6", + "version": "3.260314.7", "description": "Collaborative terminal toolkit for human + AI workflows", "type": "module", "bin": { diff --git a/plugins/genie/.claude-plugin/plugin.json b/plugins/genie/.claude-plugin/plugin.json index e158788e9..a71c06d8e 100644 --- a/plugins/genie/.claude-plugin/plugin.json +++ b/plugins/genie/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "genie", - "version": "3.260314.6", + "version": "3.260314.7", "description": "Human-AI partnership for Claude Code. Share a terminal, orchestrate workers, evolve together. Brainstorm ideas, turn them into wishes, execute with /work, validate with /review, and ship as one team.", "author": { "name": "Namastex Labs" diff --git a/plugins/genie/package.json b/plugins/genie/package.json index 0a47c1fce..a0d8e6bf7 100644 --- a/plugins/genie/package.json +++ b/plugins/genie/package.json @@ -1,6 +1,6 @@ { "name": "genie-plugin", - "version": "3.260314.6", + "version": "3.260314.7", "private": true, "description": "Runtime dependencies for genie bundled CLIs", "type": "module", From abcacf30efd4b51e18bea99c37446fdd634a8014 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sat, 14 Mar 2026 19:48:22 +0000 Subject: [PATCH 13/13] chore(version): bump to 3.260314.8 [skip ci] --- .claude-plugin/marketplace.json | 2 +- openclaw.plugin.json | 2 +- package.json | 2 +- plugins/genie/.claude-plugin/plugin.json | 2 +- plugins/genie/package.json | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 4c77c067e..bede7d0ed 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -10,7 +10,7 @@ "plugins": [ { "name": "genie", - "version": "3.260314.7", + "version": "3.260314.8", "source": "./plugins/genie", "description": "Human-AI partnership for Claude Code. Share a terminal, orchestrate workers, evolve together. Brainstorm ideas, wish them into plans, make with parallel agents, ship as one team. A coding genie that grows with your project." } diff --git a/openclaw.plugin.json b/openclaw.plugin.json index 469010972..50f31b39a 100644 --- a/openclaw.plugin.json +++ b/openclaw.plugin.json @@ -2,7 +2,7 @@ "id": "genie", "name": "Genie", "description": "Skills, agents, and hooks for the Genie CLI terminal orchestration toolkit", - "version": "3.260314.7", + "version": "3.260314.8", "configSchema": { "type": "object", "additionalProperties": false, diff --git a/package.json b/package.json index 452814131..7262e2c57 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@automagik/genie", - "version": "3.260314.7", + "version": "3.260314.8", "description": "Collaborative terminal toolkit for human + AI workflows", "type": "module", "bin": { diff --git a/plugins/genie/.claude-plugin/plugin.json b/plugins/genie/.claude-plugin/plugin.json index a71c06d8e..52cd63d21 100644 --- a/plugins/genie/.claude-plugin/plugin.json +++ b/plugins/genie/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "genie", - "version": "3.260314.7", + "version": "3.260314.8", "description": "Human-AI partnership for Claude Code. Share a terminal, orchestrate workers, evolve together. Brainstorm ideas, turn them into wishes, execute with /work, validate with /review, and ship as one team.", "author": { "name": "Namastex Labs" diff --git a/plugins/genie/package.json b/plugins/genie/package.json index a0d8e6bf7..d291a1630 100644 --- a/plugins/genie/package.json +++ b/plugins/genie/package.json @@ -1,6 +1,6 @@ { "name": "genie-plugin", - "version": "3.260314.7", + "version": "3.260314.8", "private": true, "description": "Runtime dependencies for genie bundled CLIs", "type": "module",