@automagik/genie 4.260331.4 → 4.260331.6
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/CLAUDE.md +31 -5
- package/dist/genie.js +652 -615
- 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/skills/genie/SKILL.md +71 -233
- package/skills/genie/reference/lifecycle.md +65 -0
- 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/genie.ts +26 -3
- 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/index.ts +6 -0
- package/src/term-commands/agent/send.ts +16 -2
- package/src/term-commands/agents.ts +22 -3
- package/src/term-commands/dir.ts +0 -92
- package/src/term-commands/dispatch.ts +52 -3
- package/src/term-commands/msg.ts +54 -14
- package/src/term-commands/state.ts +35 -5
- package/src/term-commands/team.ts +23 -8
|
@@ -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
|
+
}
|
|
@@ -37,4 +37,10 @@ export function registerAgentCommands(program: Command): void {
|
|
|
37
37
|
registerAgentBrief(agent);
|
|
38
38
|
registerAgentLog(agent);
|
|
39
39
|
registerAgentSend(agent);
|
|
40
|
+
|
|
41
|
+
agent.on('command:*', (operands: string[]) => {
|
|
42
|
+
const cmd = operands[0];
|
|
43
|
+
const available = agent.commands.map((c) => c.name()).join(', ');
|
|
44
|
+
agent.error(`Unknown agent command '${cmd}'. Available: ${available}`);
|
|
45
|
+
});
|
|
40
46
|
}
|
|
@@ -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);
|
package/src/term-commands/dir.ts
CHANGED
|
@@ -33,7 +33,6 @@ import { printSyncResult, syncAgentDirectory } from '../lib/agent-sync.js';
|
|
|
33
33
|
import { getActor, recordAuditEvent } from '../lib/audit.js';
|
|
34
34
|
import { ALL_BUILTINS } from '../lib/builtin-agents.js';
|
|
35
35
|
import { contractPath } from '../lib/genie-config.js';
|
|
36
|
-
import { findOmniAgent, registerAgentInOmni, resolveOmniApiUrl } from '../lib/omni-registration.js';
|
|
37
36
|
|
|
38
37
|
export function registerDirNamespace(program: Command): void {
|
|
39
38
|
const dir = program.command('dir').description('Agent directory management');
|
|
@@ -420,94 +419,3 @@ function printBuiltinsTable(): void {
|
|
|
420
419
|
}
|
|
421
420
|
console.log('');
|
|
422
421
|
}
|
|
423
|
-
|
|
424
|
-
// ============================================================================
|
|
425
|
-
// Agent namespace — genie agent register
|
|
426
|
-
// ============================================================================
|
|
427
|
-
|
|
428
|
-
interface RegisterOptions {
|
|
429
|
-
dir: string;
|
|
430
|
-
repo?: string;
|
|
431
|
-
promptMode: string;
|
|
432
|
-
model?: string;
|
|
433
|
-
roles?: string[];
|
|
434
|
-
global?: boolean;
|
|
435
|
-
skipOmni?: boolean;
|
|
436
|
-
}
|
|
437
|
-
|
|
438
|
-
async function handleOmniRegistration(
|
|
439
|
-
name: string,
|
|
440
|
-
options: { model?: string; roles?: string[]; global?: boolean },
|
|
441
|
-
): Promise<void> {
|
|
442
|
-
const omniUrl = await resolveOmniApiUrl();
|
|
443
|
-
if (!omniUrl) return;
|
|
444
|
-
|
|
445
|
-
console.log(`\nRegistering in Omni (${omniUrl})...`);
|
|
446
|
-
|
|
447
|
-
const existingId = await findOmniAgent(name);
|
|
448
|
-
if (existingId) {
|
|
449
|
-
console.log(` Agent already exists in Omni: ${existingId}`);
|
|
450
|
-
await directory.edit(name, { omniAgentId: existingId }, { global: options.global });
|
|
451
|
-
console.log(' Linked existing Omni agent to directory entry.');
|
|
452
|
-
return;
|
|
453
|
-
}
|
|
454
|
-
|
|
455
|
-
const omniAgentId = await registerAgentInOmni(name, {
|
|
456
|
-
model: options.model,
|
|
457
|
-
roles: options.roles,
|
|
458
|
-
});
|
|
459
|
-
if (omniAgentId) {
|
|
460
|
-
await directory.edit(name, { omniAgentId }, { global: options.global });
|
|
461
|
-
console.log(` Omni agent created: ${omniAgentId}`);
|
|
462
|
-
console.log(' Session isolation: per-person + per-channel');
|
|
463
|
-
}
|
|
464
|
-
}
|
|
465
|
-
|
|
466
|
-
async function handleAgentRegister(name: string, options: RegisterOptions): Promise<void> {
|
|
467
|
-
const promptMode = validatePromptMode(options.promptMode);
|
|
468
|
-
|
|
469
|
-
const roles = normalizeRoles(options.roles);
|
|
470
|
-
const entry = await directory.add(
|
|
471
|
-
{
|
|
472
|
-
name,
|
|
473
|
-
dir: resolvePath(options.dir),
|
|
474
|
-
repo: options.repo ? resolvePath(options.repo) : undefined,
|
|
475
|
-
promptMode,
|
|
476
|
-
model: options.model,
|
|
477
|
-
roles,
|
|
478
|
-
},
|
|
479
|
-
{ global: options.global },
|
|
480
|
-
);
|
|
481
|
-
|
|
482
|
-
const scope = options.global ? 'global' : 'project';
|
|
483
|
-
console.log(`Agent "${entry.name}" registered (${scope}).`);
|
|
484
|
-
printEntry(entry);
|
|
485
|
-
|
|
486
|
-
if (!options.skipOmni) {
|
|
487
|
-
await handleOmniRegistration(name, { ...options, roles });
|
|
488
|
-
}
|
|
489
|
-
}
|
|
490
|
-
|
|
491
|
-
export function registerAgentNamespace(program: Command): void {
|
|
492
|
-
const agent = program.command('agent').description('Agent lifecycle management');
|
|
493
|
-
|
|
494
|
-
agent
|
|
495
|
-
.command('register <name>')
|
|
496
|
-
.description('Register an agent locally and auto-register in Omni when configured')
|
|
497
|
-
.requiredOption('--dir <path>', 'Agent folder (CWD + AGENTS.md)')
|
|
498
|
-
.option('--repo <path>', 'Default git repo (overridden by team)')
|
|
499
|
-
.option('--prompt-mode <mode>', 'Prompt mode: append or system', 'append')
|
|
500
|
-
.option('--model <model>', 'Default model (sonnet, opus, codex)')
|
|
501
|
-
.option('--roles <roles...>', 'Built-in roles this agent can orchestrate')
|
|
502
|
-
.option('--global', 'Write to global directory instead of project')
|
|
503
|
-
.option('--skip-omni', 'Skip Omni auto-registration')
|
|
504
|
-
.action(async (name: string, options: RegisterOptions) => {
|
|
505
|
-
try {
|
|
506
|
-
await handleAgentRegister(name, options);
|
|
507
|
-
} catch (error) {
|
|
508
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
509
|
-
console.error(`Error: ${message}`);
|
|
510
|
-
process.exit(1);
|
|
511
|
-
}
|
|
512
|
-
});
|
|
513
|
-
}
|
|
@@ -24,6 +24,7 @@ import { tmpdir } from 'node:os';
|
|
|
24
24
|
import { join } from 'node:path';
|
|
25
25
|
import type { Command } from 'commander';
|
|
26
26
|
import * as protocolRouter from '../lib/protocol-router.js';
|
|
27
|
+
import { parseWishRef, resolveWish } from '../lib/wish-resolve.js';
|
|
27
28
|
import type { GroupDefinition } from '../lib/wish-state.js';
|
|
28
29
|
import * as wishState from '../lib/wish-state.js';
|
|
29
30
|
import { handleWorkerSpawn } from './agents.js';
|
|
@@ -291,6 +292,28 @@ function buildFallbackWaves(content: string): Wave[] {
|
|
|
291
292
|
];
|
|
292
293
|
}
|
|
293
294
|
|
|
295
|
+
// ============================================================================
|
|
296
|
+
// Leader Resolution
|
|
297
|
+
// ============================================================================
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* Resolve the leader name for --to in dispatch prompts.
|
|
301
|
+
* Uses GENIE_TEAM to look up the team config's leader field.
|
|
302
|
+
* Falls back to 'team-lead' for legacy teams.
|
|
303
|
+
*/
|
|
304
|
+
async function resolveLeaderTarget(): Promise<string> {
|
|
305
|
+
const teamName = process.env.GENIE_TEAM;
|
|
306
|
+
if (!teamName) return 'team-lead';
|
|
307
|
+
|
|
308
|
+
try {
|
|
309
|
+
const teamManager = await import('../lib/team-manager.js');
|
|
310
|
+
const config = await teamManager.getTeam(teamName);
|
|
311
|
+
return config?.leader || 'team-lead';
|
|
312
|
+
} catch {
|
|
313
|
+
return 'team-lead';
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
294
317
|
// ============================================================================
|
|
295
318
|
// Auto-Orchestration (fire-and-forget)
|
|
296
319
|
// ============================================================================
|
|
@@ -333,7 +356,28 @@ export function detectWorkMode(
|
|
|
333
356
|
* notifying the team-lead.
|
|
334
357
|
*/
|
|
335
358
|
async function autoOrchestrateCommand(slug: string): Promise<void> {
|
|
336
|
-
|
|
359
|
+
let wishPath: string;
|
|
360
|
+
let actualSlug = slug;
|
|
361
|
+
|
|
362
|
+
// Check for namespace/slug format — resolve and auto-create team
|
|
363
|
+
const parsed = parseWishRef(slug);
|
|
364
|
+
if (parsed.namespace) {
|
|
365
|
+
const resolved = await resolveWish(slug);
|
|
366
|
+
wishPath = resolved.wishPath;
|
|
367
|
+
actualSlug = resolved.slug;
|
|
368
|
+
|
|
369
|
+
// Auto-create team using the resolved repo and session
|
|
370
|
+
const { handleTeamCreate } = await import('./team.js');
|
|
371
|
+
await handleTeamCreate(actualSlug, {
|
|
372
|
+
repo: resolved.repo,
|
|
373
|
+
branch: 'dev',
|
|
374
|
+
wish: actualSlug,
|
|
375
|
+
tmuxSession: resolved.session,
|
|
376
|
+
});
|
|
377
|
+
return; // handleTeamCreate spawns the leader, which runs the full lifecycle
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
wishPath = join(process.cwd(), '.genie', 'wishes', slug, 'WISH.md');
|
|
337
381
|
|
|
338
382
|
if (!existsSync(wishPath)) {
|
|
339
383
|
console.error(`❌ Wish not found: ${wishPath}`);
|
|
@@ -341,6 +385,9 @@ async function autoOrchestrateCommand(slug: string): Promise<void> {
|
|
|
341
385
|
process.exit(1);
|
|
342
386
|
}
|
|
343
387
|
|
|
388
|
+
// Best-effort: sync wish to PG index (non-blocking)
|
|
389
|
+
import('../lib/wish-sync.js').then((ws) => ws.syncWishes(process.cwd())).catch(() => {});
|
|
390
|
+
|
|
344
391
|
const content = await readFile(wishPath, 'utf-8');
|
|
345
392
|
const groups = parseWishGroups(content);
|
|
346
393
|
const waves = parseExecutionStrategy(content);
|
|
@@ -545,7 +592,8 @@ async function workDispatchCommand(agentName: string, ref: string): Promise<void
|
|
|
545
592
|
console.log(` Group: ${group}`);
|
|
546
593
|
|
|
547
594
|
const effectiveRole = `${agentName}-${group}`;
|
|
548
|
-
const
|
|
595
|
+
const leaderTarget = await resolveLeaderTarget();
|
|
596
|
+
const workPrompt = `Execute Group ${group} of wish "${slug}". Your full context is in the system prompt. Read the wish at ${wishPath} if needed. Implement all deliverables, run validation, and report completion.\n\nWhen done:\n1. Run: genie done ${slug}#${group}\n2. Run: genie send 'Group ${group} complete. <summary>' --to ${leaderTarget}`;
|
|
549
597
|
await handleWorkerSpawn(agentName, {
|
|
550
598
|
provider: 'claude',
|
|
551
599
|
team: process.env.GENIE_TEAM ?? 'genie',
|
|
@@ -620,7 +668,8 @@ async function reviewCommand(agentName: string, ref: string): Promise<void> {
|
|
|
620
668
|
console.log(` Group: ${group}`);
|
|
621
669
|
if (diff) console.log(` Diff: ${diff.split('\n').length} lines`);
|
|
622
670
|
|
|
623
|
-
const
|
|
671
|
+
const reviewLeaderTarget = await resolveLeaderTarget();
|
|
672
|
+
const reviewPrompt = `Review "${ref}". Your context and diff are in the system prompt. Evaluate against acceptance criteria and return SHIP, FIX-FIRST, or BLOCKED with severity-tagged findings.\n\nWhen done, report your verdict:\nRun: genie send '<SHIP|FIX-FIRST|BLOCKED> — <summary>' --to ${reviewLeaderTarget}`;
|
|
624
673
|
await handleWorkerSpawn(agentName, {
|
|
625
674
|
provider: 'claude',
|
|
626
675
|
team: process.env.GENIE_TEAM ?? 'genie',
|