@automagik/genie 4.260331.3 → 4.260331.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +1 -1
- package/dist/genie.js +179 -170
- package/openclaw.plugin.json +1 -1
- package/package.json +1 -1
- package/plugins/genie/.claude-plugin/plugin.json +1 -1
- package/plugins/genie/package.json +1 -1
- package/src/db/migrations/016_team_spawner.sql +5 -0
- package/src/db/migrations/017_wishes_table.sql +18 -0
- package/src/genie-commands/session.ts +19 -7
- package/src/hooks/handlers/auto-spawn.ts +14 -0
- package/src/lib/agent-registry.ts +25 -5
- package/src/lib/claude-native-teams.ts +69 -45
- package/src/lib/protocol-router-spawn.ts +10 -1
- package/src/lib/qa-runner.ts +1 -1
- package/src/lib/team-auto-spawn.ts +7 -2
- package/src/lib/team-manager.test.ts +45 -0
- package/src/lib/team-manager.ts +20 -3
- package/src/lib/wish-resolve.test.ts +108 -0
- package/src/lib/wish-resolve.ts +124 -0
- package/src/lib/wish-sync.test.ts +141 -0
- package/src/lib/wish-sync.ts +182 -0
- package/src/term-commands/agent/send.ts +16 -2
- package/src/term-commands/agents.ts +22 -3
- package/src/term-commands/dispatch.ts +52 -3
- package/src/term-commands/msg.ts +46 -14
- package/src/term-commands/state.ts +35 -5
- package/src/term-commands/team.ts +15 -8
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for Wish Resolution — namespace/slug parsing and path resolution.
|
|
3
|
+
* Run with: bun test src/lib/wish-resolve.test.ts
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { afterAll, beforeAll, describe, expect, test } from 'bun:test';
|
|
7
|
+
import { mkdir, rm, writeFile } from 'node:fs/promises';
|
|
8
|
+
import { join } from 'node:path';
|
|
9
|
+
import { parseWishRef, resolveWish } from './wish-resolve.js';
|
|
10
|
+
|
|
11
|
+
// ============================================================================
|
|
12
|
+
// parseWishRef (pure parsing — no filesystem)
|
|
13
|
+
// ============================================================================
|
|
14
|
+
|
|
15
|
+
describe('parseWishRef', () => {
|
|
16
|
+
test('parses namespace/slug format', () => {
|
|
17
|
+
const ref = parseWishRef('genie/fix-tmux-session-explosion');
|
|
18
|
+
expect(ref.namespace).toBe('genie');
|
|
19
|
+
expect(ref.slug).toBe('fix-tmux-session-explosion');
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
test('parses bare slug without namespace', () => {
|
|
23
|
+
const ref = parseWishRef('fix-tmux-session-explosion');
|
|
24
|
+
expect(ref.namespace).toBeUndefined();
|
|
25
|
+
expect(ref.slug).toBe('fix-tmux-session-explosion');
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test('trims whitespace', () => {
|
|
29
|
+
const ref = parseWishRef(' genie/slug ');
|
|
30
|
+
expect(ref.namespace).toBe('genie');
|
|
31
|
+
expect(ref.slug).toBe('slug');
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test('throws on empty string', () => {
|
|
35
|
+
expect(() => parseWishRef('')).toThrow('cannot be empty');
|
|
36
|
+
expect(() => parseWishRef(' ')).toThrow('cannot be empty');
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test('throws on empty namespace', () => {
|
|
40
|
+
expect(() => parseWishRef('/slug')).toThrow('namespace is empty');
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test('throws on empty slug', () => {
|
|
44
|
+
expect(() => parseWishRef('genie/')).toThrow('slug is empty');
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test('throws on slug with nested slashes', () => {
|
|
48
|
+
expect(() => parseWishRef('genie/a/b')).toThrow('slug cannot contain "/"');
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
// ============================================================================
|
|
53
|
+
// resolveWish (filesystem-dependent)
|
|
54
|
+
// ============================================================================
|
|
55
|
+
|
|
56
|
+
describe('resolveWish', () => {
|
|
57
|
+
const TEST_DIR = '/tmp/wish-resolve-test';
|
|
58
|
+
const FAKE_REPOS = join(TEST_DIR, 'repos');
|
|
59
|
+
|
|
60
|
+
beforeAll(async () => {
|
|
61
|
+
await rm(TEST_DIR, { recursive: true, force: true });
|
|
62
|
+
|
|
63
|
+
// Create a fake repo with a wish
|
|
64
|
+
const repoDir = join(FAKE_REPOS, 'myrepo');
|
|
65
|
+
const wishDir = join(repoDir, '.genie', 'wishes', 'fix-bug');
|
|
66
|
+
await mkdir(wishDir, { recursive: true });
|
|
67
|
+
await writeFile(join(wishDir, 'WISH.md'), '# Fix Bug Wish');
|
|
68
|
+
|
|
69
|
+
// Create a bare .git dir so resolveRepoSession doesn't fail
|
|
70
|
+
await mkdir(join(repoDir, '.git'), { recursive: true });
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
afterAll(async () => {
|
|
74
|
+
await rm(TEST_DIR, { recursive: true, force: true });
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test('throws for nonexistent repo namespace', async () => {
|
|
78
|
+
await expect(resolveWish('nonexistent/some-slug')).rejects.toThrow('not found at');
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test('throws for nonexistent wish in valid repo', async () => {
|
|
82
|
+
// This test requires a repo at the expected REPOS_BASE location.
|
|
83
|
+
// Since we can't mock the path, we test the error message format.
|
|
84
|
+
await expect(resolveWish('nonexistent/slug')).rejects.toThrow('not found');
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test('bare slug without namespace errors with helpful message', async () => {
|
|
88
|
+
// cwd won't have this wish
|
|
89
|
+
await expect(resolveWish('nonexistent-wish-slug')).rejects.toThrow('Use namespace/slug format');
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test('bare slug found in cwd resolves correctly', async () => {
|
|
93
|
+
// Temporarily change cwd to a dir with a wish
|
|
94
|
+
const repoDir = join(FAKE_REPOS, 'myrepo');
|
|
95
|
+
const originalCwd = process.cwd();
|
|
96
|
+
process.chdir(repoDir);
|
|
97
|
+
|
|
98
|
+
try {
|
|
99
|
+
const result = await resolveWish('fix-bug');
|
|
100
|
+
expect(result.slug).toBe('fix-bug');
|
|
101
|
+
expect(result.repo).toBe(repoDir);
|
|
102
|
+
expect(result.wishPath).toContain('fix-bug/WISH.md');
|
|
103
|
+
expect(result.session).toBeTruthy();
|
|
104
|
+
} finally {
|
|
105
|
+
process.chdir(originalCwd);
|
|
106
|
+
}
|
|
107
|
+
});
|
|
108
|
+
});
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Wish Resolution — resolve namespace/slug references to concrete paths.
|
|
3
|
+
*
|
|
4
|
+
* Convention: `{namespace}/{slug}` where namespace = repo basename.
|
|
5
|
+
* Repos live at /home/genie/workspace/repos/{namespace}.
|
|
6
|
+
* WISH.md lives at {repo}/.genie/wishes/{slug}/WISH.md.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { existsSync } from 'node:fs';
|
|
10
|
+
import { join } from 'node:path';
|
|
11
|
+
import { resolveRepoSession } from './tmux.js';
|
|
12
|
+
|
|
13
|
+
const REPOS_BASE = '/home/genie/workspace/repos';
|
|
14
|
+
|
|
15
|
+
// ============================================================================
|
|
16
|
+
// Types
|
|
17
|
+
// ============================================================================
|
|
18
|
+
|
|
19
|
+
interface WishRef {
|
|
20
|
+
namespace?: string;
|
|
21
|
+
slug: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
interface ResolvedWish {
|
|
25
|
+
repo: string;
|
|
26
|
+
wishPath: string;
|
|
27
|
+
session: string;
|
|
28
|
+
slug: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// ============================================================================
|
|
32
|
+
// Parsing
|
|
33
|
+
// ============================================================================
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Parse a wish reference string into namespace and slug.
|
|
37
|
+
*
|
|
38
|
+
* Formats:
|
|
39
|
+
* "namespace/slug" → { namespace: "namespace", slug: "slug" }
|
|
40
|
+
* "slug" → { slug: "slug" }
|
|
41
|
+
*
|
|
42
|
+
* Only the first `/` is used as delimiter — slugs may not contain `/`.
|
|
43
|
+
*/
|
|
44
|
+
export function parseWishRef(ref: string): WishRef {
|
|
45
|
+
const trimmed = ref.trim();
|
|
46
|
+
if (!trimmed) {
|
|
47
|
+
throw new Error('Wish reference cannot be empty');
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const slashIndex = trimmed.indexOf('/');
|
|
51
|
+
if (slashIndex === -1) {
|
|
52
|
+
return { slug: trimmed };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const namespace = trimmed.slice(0, slashIndex);
|
|
56
|
+
const slug = trimmed.slice(slashIndex + 1);
|
|
57
|
+
|
|
58
|
+
if (!namespace) {
|
|
59
|
+
throw new Error(`Invalid wish reference "${ref}": namespace is empty`);
|
|
60
|
+
}
|
|
61
|
+
if (!slug) {
|
|
62
|
+
throw new Error(`Invalid wish reference "${ref}": slug is empty`);
|
|
63
|
+
}
|
|
64
|
+
if (slug.includes('/')) {
|
|
65
|
+
throw new Error(`Invalid wish reference "${ref}": slug cannot contain "/"`);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return { namespace, slug };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// ============================================================================
|
|
72
|
+
// Resolution
|
|
73
|
+
// ============================================================================
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Resolve a wish reference to a concrete repo path, wish path, and tmux session.
|
|
77
|
+
*
|
|
78
|
+
* @param ref - Wish reference in "namespace/slug" or "slug" format
|
|
79
|
+
* @returns Resolved wish with repo, wishPath, session, and slug
|
|
80
|
+
* @throws If repo not found, wish not found, or bare slug without namespace
|
|
81
|
+
*/
|
|
82
|
+
export async function resolveWish(ref: string): Promise<ResolvedWish> {
|
|
83
|
+
const parsed = parseWishRef(ref);
|
|
84
|
+
|
|
85
|
+
if (!parsed.namespace) {
|
|
86
|
+
// Bare slug — check cwd first
|
|
87
|
+
const cwdWishPath = join(process.cwd(), '.genie', 'wishes', parsed.slug, 'WISH.md');
|
|
88
|
+
if (existsSync(cwdWishPath)) {
|
|
89
|
+
const repo = process.cwd();
|
|
90
|
+
const session = await resolveRepoSession(repo);
|
|
91
|
+
return {
|
|
92
|
+
repo,
|
|
93
|
+
wishPath: cwdWishPath,
|
|
94
|
+
session,
|
|
95
|
+
slug: parsed.slug,
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
throw new Error(
|
|
100
|
+
`Wish "${parsed.slug}" not found in current directory. Use namespace/slug format (e.g., genie/${parsed.slug}) to specify the repo.`,
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Namespace provided — resolve repo
|
|
105
|
+
const repo = join(REPOS_BASE, parsed.namespace);
|
|
106
|
+
if (!existsSync(repo)) {
|
|
107
|
+
throw new Error(`Repository "${parsed.namespace}" not found at ${repo}. Available repos are in ${REPOS_BASE}.`);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Verify WISH.md exists
|
|
111
|
+
const wishPath = join(repo, '.genie', 'wishes', parsed.slug, 'WISH.md');
|
|
112
|
+
if (!existsSync(wishPath)) {
|
|
113
|
+
throw new Error(`Wish "${parsed.slug}" not found in repo "${parsed.namespace}". Expected: ${wishPath}`);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const session = await resolveRepoSession(repo);
|
|
117
|
+
|
|
118
|
+
return {
|
|
119
|
+
repo,
|
|
120
|
+
wishPath,
|
|
121
|
+
session,
|
|
122
|
+
slug: parsed.slug,
|
|
123
|
+
};
|
|
124
|
+
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for Wish Sync — filesystem to PG wish indexing.
|
|
3
|
+
* Run with: bun test src/lib/wish-sync.test.ts
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { afterAll, beforeAll, describe, expect, test } from 'bun:test';
|
|
7
|
+
import { mkdir, rm, writeFile } from 'node:fs/promises';
|
|
8
|
+
import { join } from 'node:path';
|
|
9
|
+
import { getConnection } from './db.js';
|
|
10
|
+
import { DB_AVAILABLE, setupTestSchema } from './test-db.js';
|
|
11
|
+
import { getWish, listWishes, parseWishStatus, syncWishes } from './wish-sync.js';
|
|
12
|
+
|
|
13
|
+
// ============================================================================
|
|
14
|
+
// parseWishStatus (pure parsing — no DB)
|
|
15
|
+
// ============================================================================
|
|
16
|
+
|
|
17
|
+
describe('parseWishStatus', () => {
|
|
18
|
+
test('parses status from markdown table', () => {
|
|
19
|
+
const content = '| **Status** | APPROVED |\n| **Slug** | test |';
|
|
20
|
+
expect(parseWishStatus(content)).toBe('APPROVED');
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test('parses status with extra whitespace', () => {
|
|
24
|
+
const content = '| **Status** | IN_PROGRESS |';
|
|
25
|
+
expect(parseWishStatus(content)).toBe('IN_PROGRESS');
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test('returns DRAFT when no status found', () => {
|
|
29
|
+
const content = '# Some wish\n\nNo table here.';
|
|
30
|
+
expect(parseWishStatus(content)).toBe('DRAFT');
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test('handles DRAFT status', () => {
|
|
34
|
+
const content = '| **Status** | DRAFT |';
|
|
35
|
+
expect(parseWishStatus(content)).toBe('DRAFT');
|
|
36
|
+
});
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
// ============================================================================
|
|
40
|
+
// syncWishes + queries (DB-dependent)
|
|
41
|
+
// ============================================================================
|
|
42
|
+
|
|
43
|
+
describe.skipIf(!DB_AVAILABLE)('pg', () => {
|
|
44
|
+
let cleanupSchema: () => Promise<void>;
|
|
45
|
+
const TEST_DIR = '/tmp/wish-sync-test';
|
|
46
|
+
const TEST_REPO = join(TEST_DIR, 'test-repo');
|
|
47
|
+
|
|
48
|
+
async function setupTestRepo(): Promise<void> {
|
|
49
|
+
await rm(TEST_DIR, { recursive: true, force: true });
|
|
50
|
+
|
|
51
|
+
// Create a repo with two wishes
|
|
52
|
+
const wish1Dir = join(TEST_REPO, '.genie', 'wishes', 'fix-auth');
|
|
53
|
+
const wish2Dir = join(TEST_REPO, '.genie', 'wishes', 'add-search');
|
|
54
|
+
await mkdir(wish1Dir, { recursive: true });
|
|
55
|
+
await mkdir(wish2Dir, { recursive: true });
|
|
56
|
+
|
|
57
|
+
await writeFile(
|
|
58
|
+
join(wish1Dir, 'WISH.md'),
|
|
59
|
+
'# Fix Auth\n\n| Field | Value |\n|-------|-------|\n| **Status** | APPROVED |\n| **Slug** | fix-auth |\n',
|
|
60
|
+
);
|
|
61
|
+
await writeFile(
|
|
62
|
+
join(wish2Dir, 'WISH.md'),
|
|
63
|
+
'# Add Search\n\n| Field | Value |\n|-------|-------|\n| **Status** | DRAFT |\n| **Slug** | add-search |\n',
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
beforeAll(async () => {
|
|
68
|
+
cleanupSchema = await setupTestSchema();
|
|
69
|
+
await setupTestRepo();
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
afterAll(async () => {
|
|
73
|
+
await rm(TEST_DIR, { recursive: true, force: true });
|
|
74
|
+
await cleanupSchema();
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test('syncWishes upserts wishes from a repo', async () => {
|
|
78
|
+
const count = await syncWishes(TEST_REPO);
|
|
79
|
+
expect(count).toBe(2);
|
|
80
|
+
|
|
81
|
+
// Verify they're in PG
|
|
82
|
+
const sql = await getConnection();
|
|
83
|
+
const rows = await sql`SELECT * FROM wishes WHERE repo = ${TEST_REPO} ORDER BY slug`;
|
|
84
|
+
expect(rows.length).toBe(2);
|
|
85
|
+
expect(rows[0].slug).toBe('add-search');
|
|
86
|
+
expect(rows[0].status).toBe('DRAFT');
|
|
87
|
+
expect(rows[1].slug).toBe('fix-auth');
|
|
88
|
+
expect(rows[1].status).toBe('APPROVED');
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test('syncWishes is idempotent — multiple runs do not duplicate', async () => {
|
|
92
|
+
await syncWishes(TEST_REPO);
|
|
93
|
+
await syncWishes(TEST_REPO);
|
|
94
|
+
|
|
95
|
+
const sql = await getConnection();
|
|
96
|
+
const rows = await sql`SELECT * FROM wishes WHERE repo = ${TEST_REPO}`;
|
|
97
|
+
expect(rows.length).toBe(2);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test('syncWishes updates status on re-sync', async () => {
|
|
101
|
+
// Update the WISH.md status
|
|
102
|
+
const wish1Path = join(TEST_REPO, '.genie', 'wishes', 'fix-auth', 'WISH.md');
|
|
103
|
+
await writeFile(
|
|
104
|
+
wish1Path,
|
|
105
|
+
'# Fix Auth\n\n| Field | Value |\n|-------|-------|\n| **Status** | DONE |\n| **Slug** | fix-auth |\n',
|
|
106
|
+
);
|
|
107
|
+
|
|
108
|
+
await syncWishes(TEST_REPO);
|
|
109
|
+
|
|
110
|
+
const sql = await getConnection();
|
|
111
|
+
const rows = await sql`SELECT status FROM wishes WHERE slug = ${'fix-auth'} AND repo = ${TEST_REPO}`;
|
|
112
|
+
expect(rows[0].status).toBe('DONE');
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test('listWishes returns all wishes', async () => {
|
|
116
|
+
const wishes = await listWishes();
|
|
117
|
+
expect(wishes.length).toBeGreaterThanOrEqual(2);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test('listWishes filters by repo', async () => {
|
|
121
|
+
const wishes = await listWishes({ repo: TEST_REPO });
|
|
122
|
+
expect(wishes.length).toBe(2);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test('listWishes filters by status', async () => {
|
|
126
|
+
const wishes = await listWishes({ status: 'DRAFT' });
|
|
127
|
+
expect(wishes.some((w) => w.slug === 'add-search')).toBe(true);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test('getWish returns a single wish', async () => {
|
|
131
|
+
const wish = await getWish('add-search', TEST_REPO);
|
|
132
|
+
expect(wish).not.toBeNull();
|
|
133
|
+
expect(wish!.slug).toBe('add-search');
|
|
134
|
+
expect(wish!.status).toBe('DRAFT');
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test('getWish returns null for nonexistent wish', async () => {
|
|
138
|
+
const wish = await getWish('nonexistent', TEST_REPO);
|
|
139
|
+
expect(wish).toBeNull();
|
|
140
|
+
});
|
|
141
|
+
});
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Wish Sync — sync .genie/wishes/ from filesystem to PG for cross-repo querying.
|
|
3
|
+
*
|
|
4
|
+
* WISH.md files are the source of truth. PG is the index.
|
|
5
|
+
* Sync is idempotent — multiple runs don't create duplicates.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { existsSync, readFileSync, readdirSync } from 'node:fs';
|
|
9
|
+
import { basename, join } from 'node:path';
|
|
10
|
+
import { getConnection } from './db.js';
|
|
11
|
+
|
|
12
|
+
const REPOS_BASE = '/home/genie/workspace/repos';
|
|
13
|
+
|
|
14
|
+
// ============================================================================
|
|
15
|
+
// Status Parsing
|
|
16
|
+
// ============================================================================
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Parse the Status field from a WISH.md markdown table.
|
|
20
|
+
* Looks for `| **Status** | <value> |` pattern.
|
|
21
|
+
*/
|
|
22
|
+
export function parseWishStatus(content: string): string {
|
|
23
|
+
const match = content.match(/\|\s*\*\*Status\*\*\s*\|\s*([^|]+)/i);
|
|
24
|
+
if (match) return match[1].trim();
|
|
25
|
+
return 'DRAFT';
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// ============================================================================
|
|
29
|
+
// Filesystem Scanning
|
|
30
|
+
// ============================================================================
|
|
31
|
+
|
|
32
|
+
interface DiscoveredWish {
|
|
33
|
+
slug: string;
|
|
34
|
+
repo: string;
|
|
35
|
+
namespace: string;
|
|
36
|
+
status: string;
|
|
37
|
+
filePath: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Scan a single repo for wishes in .genie/wishes/\*\/WISH.md.
|
|
42
|
+
*/
|
|
43
|
+
function scanRepoWishes(repoPath: string): DiscoveredWish[] {
|
|
44
|
+
const wishesDir = join(repoPath, '.genie', 'wishes');
|
|
45
|
+
if (!existsSync(wishesDir)) return [];
|
|
46
|
+
|
|
47
|
+
const results: DiscoveredWish[] = [];
|
|
48
|
+
const namespace = basename(repoPath);
|
|
49
|
+
|
|
50
|
+
try {
|
|
51
|
+
const entries = readdirSync(wishesDir, { withFileTypes: true });
|
|
52
|
+
for (const entry of entries) {
|
|
53
|
+
if (!entry.isDirectory()) continue;
|
|
54
|
+
const wishPath = join(wishesDir, entry.name, 'WISH.md');
|
|
55
|
+
if (!existsSync(wishPath)) continue;
|
|
56
|
+
|
|
57
|
+
try {
|
|
58
|
+
const content = readFileSync(wishPath, 'utf-8');
|
|
59
|
+
results.push({
|
|
60
|
+
slug: entry.name,
|
|
61
|
+
repo: repoPath,
|
|
62
|
+
namespace,
|
|
63
|
+
status: parseWishStatus(content),
|
|
64
|
+
filePath: wishPath,
|
|
65
|
+
});
|
|
66
|
+
} catch {
|
|
67
|
+
// Unreadable file — skip
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
} catch {
|
|
71
|
+
// Can't read wishes dir — skip
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return results;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Discover all wishes across all repos in REPOS_BASE, or a single repo.
|
|
79
|
+
*/
|
|
80
|
+
function discoverWishes(repoPath?: string): DiscoveredWish[] {
|
|
81
|
+
if (repoPath) return scanRepoWishes(repoPath);
|
|
82
|
+
|
|
83
|
+
if (!existsSync(REPOS_BASE)) return [];
|
|
84
|
+
|
|
85
|
+
const results: DiscoveredWish[] = [];
|
|
86
|
+
try {
|
|
87
|
+
const entries = readdirSync(REPOS_BASE, { withFileTypes: true });
|
|
88
|
+
for (const entry of entries) {
|
|
89
|
+
if (!entry.isDirectory()) continue;
|
|
90
|
+
results.push(...scanRepoWishes(join(REPOS_BASE, entry.name)));
|
|
91
|
+
}
|
|
92
|
+
} catch {
|
|
93
|
+
// Can't read repos base — return empty
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return results;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// ============================================================================
|
|
100
|
+
// PG Sync
|
|
101
|
+
// ============================================================================
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Sync wishes from filesystem to PG.
|
|
105
|
+
* Upserts discovered wishes — idempotent.
|
|
106
|
+
*
|
|
107
|
+
* @param repoPath - If provided, only sync wishes from this repo. Otherwise scan all repos.
|
|
108
|
+
*/
|
|
109
|
+
export async function syncWishes(repoPath?: string): Promise<number> {
|
|
110
|
+
const wishes = discoverWishes(repoPath);
|
|
111
|
+
if (wishes.length === 0) return 0;
|
|
112
|
+
|
|
113
|
+
const sql = await getConnection();
|
|
114
|
+
|
|
115
|
+
for (const wish of wishes) {
|
|
116
|
+
await sql`
|
|
117
|
+
INSERT INTO wishes (slug, repo, namespace, status, file_path)
|
|
118
|
+
VALUES (${wish.slug}, ${wish.repo}, ${wish.namespace}, ${wish.status}, ${wish.filePath})
|
|
119
|
+
ON CONFLICT (slug, repo) DO UPDATE SET
|
|
120
|
+
namespace = EXCLUDED.namespace,
|
|
121
|
+
status = EXCLUDED.status,
|
|
122
|
+
file_path = EXCLUDED.file_path,
|
|
123
|
+
updated_at = now()
|
|
124
|
+
`;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return wishes.length;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// ============================================================================
|
|
131
|
+
// Queries
|
|
132
|
+
// ============================================================================
|
|
133
|
+
|
|
134
|
+
interface WishRow {
|
|
135
|
+
id: number;
|
|
136
|
+
slug: string;
|
|
137
|
+
repo: string;
|
|
138
|
+
namespace: string | null;
|
|
139
|
+
status: string;
|
|
140
|
+
file_path: string;
|
|
141
|
+
created_at: string;
|
|
142
|
+
updated_at: string;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* List wishes from PG with optional filters.
|
|
147
|
+
*/
|
|
148
|
+
export async function listWishes(filters?: {
|
|
149
|
+
repo?: string;
|
|
150
|
+
status?: string;
|
|
151
|
+
namespace?: string;
|
|
152
|
+
}): Promise<WishRow[]> {
|
|
153
|
+
const sql = await getConnection();
|
|
154
|
+
|
|
155
|
+
if (filters?.repo && filters?.status) {
|
|
156
|
+
return sql`SELECT * FROM wishes WHERE repo = ${filters.repo} AND status = ${filters.status} ORDER BY updated_at DESC`;
|
|
157
|
+
}
|
|
158
|
+
if (filters?.repo) {
|
|
159
|
+
return sql`SELECT * FROM wishes WHERE repo = ${filters.repo} ORDER BY updated_at DESC`;
|
|
160
|
+
}
|
|
161
|
+
if (filters?.status) {
|
|
162
|
+
return sql`SELECT * FROM wishes WHERE status = ${filters.status} ORDER BY updated_at DESC`;
|
|
163
|
+
}
|
|
164
|
+
if (filters?.namespace) {
|
|
165
|
+
return sql`SELECT * FROM wishes WHERE namespace = ${filters.namespace} ORDER BY updated_at DESC`;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
return sql`SELECT * FROM wishes ORDER BY updated_at DESC`;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Get a single wish by slug, optionally scoped to a repo.
|
|
173
|
+
*/
|
|
174
|
+
export async function getWish(slug: string, repo?: string): Promise<WishRow | null> {
|
|
175
|
+
const sql = await getConnection();
|
|
176
|
+
|
|
177
|
+
const rows = repo
|
|
178
|
+
? await sql`SELECT * FROM wishes WHERE slug = ${slug} AND repo = ${repo} LIMIT 1`
|
|
179
|
+
: await sql`SELECT * FROM wishes WHERE slug = ${slug} ORDER BY updated_at DESC LIMIT 1`;
|
|
180
|
+
|
|
181
|
+
return rows.length > 0 ? (rows[0] as WishRow) : null;
|
|
182
|
+
}
|
|
@@ -44,6 +44,18 @@ export function registerAgentSend(parent: Command): void {
|
|
|
44
44
|
// Hierarchy ACL
|
|
45
45
|
// ============================================================================
|
|
46
46
|
|
|
47
|
+
/** Check if an agent name matches the team's leader (by alias or actual name). */
|
|
48
|
+
async function isTeamLeader(agentName: string, teamName: string): Promise<boolean> {
|
|
49
|
+
if (agentName === 'team-lead') return true;
|
|
50
|
+
try {
|
|
51
|
+
const teamMgr = await import('../../lib/team-manager.js');
|
|
52
|
+
const config = await teamMgr.getTeam(teamName);
|
|
53
|
+
return agentName === config?.leader;
|
|
54
|
+
} catch {
|
|
55
|
+
return false;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
47
59
|
/**
|
|
48
60
|
* Check hierarchy: sender can reach recipient if:
|
|
49
61
|
* 1. sender is 'cli' (bypass)
|
|
@@ -74,8 +86,10 @@ async function checkHierarchy(from: string, to: string): Promise<{ allowed: bool
|
|
|
74
86
|
// Siblings: same manager
|
|
75
87
|
if (sender.reportsTo && sender.reportsTo === recipient.reportsTo) return { allowed: true };
|
|
76
88
|
|
|
77
|
-
//
|
|
78
|
-
if (
|
|
89
|
+
// Leader can reach anyone in their team
|
|
90
|
+
if (sender.team && sender.team === recipient.team && (await isTeamLeader(from, sender.team))) {
|
|
91
|
+
return { allowed: true };
|
|
92
|
+
}
|
|
79
93
|
|
|
80
94
|
const manager = sender.reportsTo ?? 'your manager';
|
|
81
95
|
return {
|
|
@@ -38,6 +38,19 @@ import { isPaneAlive } from '../lib/tmux.js';
|
|
|
38
38
|
// Helper Functions
|
|
39
39
|
// ============================================================================
|
|
40
40
|
|
|
41
|
+
/**
|
|
42
|
+
* Resolve the leader name for a team from team config.
|
|
43
|
+
* Falls back to 'team-lead' for legacy teams without a leader set.
|
|
44
|
+
*/
|
|
45
|
+
async function resolveTeamLeaderName(teamNameOrDefault: string): Promise<string> {
|
|
46
|
+
try {
|
|
47
|
+
const config = await teamManager.getTeam(teamNameOrDefault);
|
|
48
|
+
return config?.leader || 'team-lead';
|
|
49
|
+
} catch {
|
|
50
|
+
return 'team-lead';
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
41
54
|
/** Check if a process is alive by PID file. */
|
|
42
55
|
function isRelayAlive(pidFile: string): boolean {
|
|
43
56
|
const { readFileSync, existsSync } = require('node:fs');
|
|
@@ -79,6 +92,7 @@ async function ensureOtelRelay(team: string): Promise<boolean> {
|
|
|
79
92
|
if (isRelayAlive(pidFile)) return true;
|
|
80
93
|
|
|
81
94
|
const inboxDir = join(process.env.CLAUDE_CONFIG_DIR ?? join(homedir(), '.claude'), 'teams', team, 'inboxes');
|
|
95
|
+
const leaderInboxName = nativeTeams.sanitizeTeamName(await resolveTeamLeaderName(team));
|
|
82
96
|
const escapedRelayDir = relayDir.replace(/\\/g, '\\\\').replace(/'/g, "\\'");
|
|
83
97
|
const escapedInboxDir = inboxDir.replace(/\\/g, '\\\\').replace(/'/g, "\\'");
|
|
84
98
|
const escapedPidFile = pidFile.replace(/\\/g, '\\\\').replace(/'/g, "\\'");
|
|
@@ -103,7 +117,7 @@ import { join } from 'path';
|
|
|
103
117
|
|
|
104
118
|
const RELAY_DIR = '${escapedRelayDir}';
|
|
105
119
|
const INBOX_DIR = '${escapedInboxDir}';
|
|
106
|
-
const INBOX = join(INBOX_DIR, '
|
|
120
|
+
const INBOX = join(INBOX_DIR, '${leaderInboxName}.json');
|
|
107
121
|
const PID_FILE = '${escapedPidFile}';
|
|
108
122
|
const PORT = ${OTEL_RELAY_PORT};
|
|
109
123
|
const SILENCE_MS = 5000;
|
|
@@ -570,7 +584,9 @@ async function notifySpawnJoin(ctx: SpawnCtx, paneId: string): Promise<void> {
|
|
|
570
584
|
cwd: ctx.cwd,
|
|
571
585
|
planModeRequired: nt.planModeRequired,
|
|
572
586
|
});
|
|
573
|
-
|
|
587
|
+
// Resolve the actual leader name for the inbox notification
|
|
588
|
+
const leaderName = await resolveTeamLeaderName(ctx.validated.team);
|
|
589
|
+
await nativeTeams.writeNativeInbox(ctx.validated.team, leaderName, {
|
|
574
590
|
from: ctx.agentName,
|
|
575
591
|
text: `Worker ${ctx.agentName} (${ctx.validated.provider}) joined team ${ctx.validated.team}. cwd: ${ctx.cwd}. Ready for tasks.`,
|
|
576
592
|
summary: `${ctx.agentName} (${ctx.validated.provider}) joined`,
|
|
@@ -1430,7 +1446,10 @@ function formatGroupStatus(
|
|
|
1430
1446
|
* Returns undefined if no wish context found.
|
|
1431
1447
|
*/
|
|
1432
1448
|
export async function buildResumeContext(agent: registry.Agent): Promise<string | undefined> {
|
|
1433
|
-
if (
|
|
1449
|
+
// Check if this agent is a leader (by role 'team-lead' or matching the team's leader name)
|
|
1450
|
+
const isLeader =
|
|
1451
|
+
agent.role === 'team-lead' || (agent.team && agent.role === (await resolveTeamLeaderName(agent.team)));
|
|
1452
|
+
if (isLeader && agent.wishSlug) {
|
|
1434
1453
|
try {
|
|
1435
1454
|
const wishState = await import('../lib/wish-state.js');
|
|
1436
1455
|
const state = await wishState.getState(agent.wishSlug, agent.repoPath);
|