@automagik/genie 0.260202.1833 → 0.260203.43
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/.beads/README.md +81 -0
- package/.beads/config.yaml +67 -0
- package/.beads/interactions.jsonl +0 -0
- package/.beads/issues.jsonl +9 -0
- package/.beads/metadata.json +4 -0
- package/.claude/skills/brainstorm/SKILL.md +53 -0
- package/.claude/skills/genie-base/SKILL.md +66 -0
- package/.claude/skills/genie-base/assets/workspace/AGENTS.md +191 -0
- package/.claude/skills/genie-base/assets/workspace/ENVIRONMENT.md +18 -0
- package/.claude/skills/genie-base/assets/workspace/HEARTBEAT.md +4 -0
- package/.claude/skills/genie-base/assets/workspace/IDENTITY.md +17 -0
- package/.claude/skills/genie-base/assets/workspace/MEMORY.md +16 -0
- package/.claude/skills/genie-base/assets/workspace/ROLE.md +14 -0
- package/.claude/skills/genie-base/assets/workspace/SOUL.md +36 -0
- package/.claude/skills/genie-base/assets/workspace/TOOLS.md +25 -0
- package/.claude/skills/genie-base/assets/workspace/USER.md +13 -0
- package/.claude/skills/genie-base/assets/workspace/memory/2026-01-30.md +6 -0
- package/.claude/skills/genie-base/assets/workspace/memory/2026-01-31.md +16 -0
- package/.claude/skills/genie-base/assets/workspace/memory/882c22be-9710-41c1-91f8-ed82947ef6ce.txt +1 -0
- package/.claude/skills/genie-base/scripts/install-workspace.sh +107 -0
- package/.claude/skills/genie-base/scripts/sanity-sweep.sh +60 -0
- package/.claude/skills/genie-blank-init/SKILL.md +37 -0
- package/.claude/skills/genie-blank-init/assets/BOOTSTRAP.md +44 -0
- package/.claude/skills/genie-blank-init/assets/IDENTITY.md +9 -0
- package/.claude/skills/genie-blank-init/assets/SOUL.md +10 -0
- package/.claude/skills/genie-blank-init/assets/USER.md +9 -0
- package/.claude/skills/genie-blank-init/scripts/apply-blank-init.sh +117 -0
- package/.claude/skills/genie-forge/SKILL.md +171 -0
- package/.claude/skills/genie-plan-review/CLAUDE.md +11 -0
- package/.claude/skills/genie-plan-review/SKILL.md +53 -0
- package/.claude/skills/genie-review/SKILL.md +171 -0
- package/.claude/skills/genie-wish/SKILL.md +141 -0
- package/.claude-plugin/marketplace.json +18 -0
- package/.genie/.gitkeep +3 -0
- package/.genie/backlog/hooks-v2.md +82 -0
- package/.genie/wishes/upgrade-brainstorm-handoff/wish.md +124 -0
- package/.gitattributes +3 -0
- package/AGENTS.md +75 -0
- package/bun.lock +55 -0
- package/dist/claudio.js +1 -1
- package/dist/genie.js +1 -1
- package/dist/term.js +123 -99
- package/docs/CO-ORCHESTRATION-GUIDE.md +368 -0
- package/package.json +5 -1
- package/plugin/.claude-plugin/plugin.json +18 -0
- package/plugin/README.md +120 -0
- package/plugin/agents/implementor.md +92 -0
- package/plugin/agents/quality-reviewer.md +113 -0
- package/plugin/agents/spec-reviewer.md +90 -0
- package/plugin/hooks/hooks.json +3 -0
- package/plugin/references/review-criteria.md +72 -0
- package/plugin/references/wish-template.md +92 -0
- package/plugin/scripts/genie.cjs +141 -0
- package/plugin/scripts/smart-install.js +308 -0
- package/plugin/scripts/src/install-genie-cli.sh +120 -0
- package/plugin/scripts/src/validate-completion.ts +142 -0
- package/plugin/scripts/src/validate-wish.ts +137 -0
- package/plugin/scripts/term.cjs +229 -0
- package/plugin/scripts/validate-completion.cjs +16 -0
- package/plugin/scripts/validate-wish.cjs +17 -0
- package/plugin/scripts/worker-service.cjs +28 -0
- package/plugin/skills/brainstorm/SKILL.md +106 -0
- package/plugin/skills/forge/SKILL.md +171 -0
- package/plugin/skills/genie-base/SKILL.md +99 -0
- package/plugin/skills/genie-base/assets/workspace/AGENTS.md +191 -0
- package/plugin/skills/genie-base/assets/workspace/ENVIRONMENT.md +18 -0
- package/plugin/skills/genie-base/assets/workspace/HEARTBEAT.md +4 -0
- package/plugin/skills/genie-base/assets/workspace/IDENTITY.md +17 -0
- package/plugin/skills/genie-base/assets/workspace/MEMORY.md +16 -0
- package/plugin/skills/genie-base/assets/workspace/ROLE.md +14 -0
- package/plugin/skills/genie-base/assets/workspace/SOUL.md +36 -0
- package/plugin/skills/genie-base/assets/workspace/TOOLS.md +25 -0
- package/plugin/skills/genie-base/assets/workspace/USER.md +13 -0
- package/plugin/skills/genie-base/scripts/install-workspace.sh +107 -0
- package/plugin/skills/genie-base/scripts/sanity-sweep.sh +60 -0
- package/plugin/skills/genie-blank-init/SKILL.md +73 -0
- package/plugin/skills/genie-blank-init/assets/BOOTSTRAP.md +44 -0
- package/plugin/skills/genie-blank-init/assets/IDENTITY.md +9 -0
- package/plugin/skills/genie-blank-init/assets/SOUL.md +10 -0
- package/plugin/skills/genie-blank-init/assets/USER.md +9 -0
- package/plugin/skills/genie-blank-init/scripts/apply-blank-init.sh +117 -0
- package/plugin/skills/genie-cli-dev/CLAUDE.md +19 -0
- package/plugin/skills/genie-cli-dev/SKILL.md +292 -0
- package/plugin/skills/plan-review/SKILL.md +101 -0
- package/plugin/skills/review/SKILL.md +221 -0
- package/plugin/skills/wish/SKILL.md +110 -0
- package/plugin/skills/work-orchestration/SKILL.md +110 -0
- package/scripts/build.js +132 -0
- package/scripts/smart-install.js +308 -0
- package/scripts/sync.js +134 -0
- package/src/lib/beads-registry.ts +595 -0
- package/src/lib/orchestrator/event-monitor.ts +2 -0
- package/src/lib/skill-loader.ts +215 -0
- package/src/lib/tmux.ts +30 -11
- package/src/lib/version.ts +1 -1
- package/src/lib/worker-registry.ts +10 -0
- package/src/services/worker-service.ts +351 -0
- package/src/term-commands/close.ts +48 -3
- package/src/term-commands/create.ts +95 -0
- package/src/term-commands/daemon.ts +176 -0
- package/src/term-commands/kill.ts +56 -2
- package/src/term-commands/orchestrate.ts +3 -2
- package/src/term-commands/send.ts +43 -15
- package/src/term-commands/spawn.ts +446 -0
- package/src/term-commands/split.ts +20 -8
- package/src/term-commands/work.ts +279 -37
- package/src/term-commands/workers.ts +36 -2
- package/src/term.ts +120 -7
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Skill Loader - Find and load Claude skills
|
|
3
|
+
*
|
|
4
|
+
* Skills are stored in:
|
|
5
|
+
* 1. .claude/skills/<skill-name>/SKILL.md (project local)
|
|
6
|
+
* 2. ~/.claude/skills/<skill-name>/SKILL.md (user global)
|
|
7
|
+
*
|
|
8
|
+
* Skill names are simple (wish, forge, review) and map to directories:
|
|
9
|
+
* - Direct match: wish -> wish/
|
|
10
|
+
* - Prefixed: wish -> genie-wish/
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { access, readFile } from 'fs/promises';
|
|
14
|
+
import { join } from 'path';
|
|
15
|
+
import { homedir } from 'os';
|
|
16
|
+
|
|
17
|
+
export interface SkillInfo {
|
|
18
|
+
name: string;
|
|
19
|
+
path: string;
|
|
20
|
+
skillFile: string;
|
|
21
|
+
description?: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Get possible directory names for a skill
|
|
26
|
+
* e.g., "wish" -> ["wish", "genie-wish"]
|
|
27
|
+
*/
|
|
28
|
+
function skillNameToDirs(skillName: string): string[] {
|
|
29
|
+
return [skillName, `genie-${skillName}`];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Check if a path exists
|
|
34
|
+
*/
|
|
35
|
+
async function pathExists(path: string): Promise<boolean> {
|
|
36
|
+
try {
|
|
37
|
+
await access(path);
|
|
38
|
+
return true;
|
|
39
|
+
} catch {
|
|
40
|
+
return false;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Find a skill by name
|
|
46
|
+
*
|
|
47
|
+
* Search order:
|
|
48
|
+
* 1. .claude/skills/<skill-dir>/SKILL.md (project local)
|
|
49
|
+
* 2. ~/.claude/skills/<skill-dir>/SKILL.md (user global)
|
|
50
|
+
*
|
|
51
|
+
* For each location, tries both exact name and genie-prefixed name.
|
|
52
|
+
*
|
|
53
|
+
* @param skillName - Skill name (e.g., "wish", "forge", "review")
|
|
54
|
+
* @param projectRoot - Project root directory (defaults to cwd)
|
|
55
|
+
* @returns SkillInfo if found, null otherwise
|
|
56
|
+
*/
|
|
57
|
+
export async function findSkill(
|
|
58
|
+
skillName: string,
|
|
59
|
+
projectRoot?: string
|
|
60
|
+
): Promise<SkillInfo | null> {
|
|
61
|
+
const dirNames = skillNameToDirs(skillName);
|
|
62
|
+
const cwd = projectRoot || process.cwd();
|
|
63
|
+
|
|
64
|
+
// Search locations in order of precedence
|
|
65
|
+
const searchLocations = [
|
|
66
|
+
join(cwd, '.claude', 'skills'),
|
|
67
|
+
join(homedir(), '.claude', 'skills'),
|
|
68
|
+
];
|
|
69
|
+
|
|
70
|
+
for (const location of searchLocations) {
|
|
71
|
+
for (const dirName of dirNames) {
|
|
72
|
+
const skillPath = join(location, dirName);
|
|
73
|
+
const skillFile = join(skillPath, 'SKILL.md');
|
|
74
|
+
|
|
75
|
+
if (await pathExists(skillFile)) {
|
|
76
|
+
// Parse description from frontmatter if available
|
|
77
|
+
let description: string | undefined;
|
|
78
|
+
try {
|
|
79
|
+
const content = await readFile(skillFile, 'utf-8');
|
|
80
|
+
const frontmatterMatch = content.match(/^---\n([\s\S]*?)\n---/);
|
|
81
|
+
if (frontmatterMatch) {
|
|
82
|
+
const descMatch = frontmatterMatch[1].match(/description:\s*["']?([^"'\n]+)["']?/);
|
|
83
|
+
if (descMatch) {
|
|
84
|
+
description = descMatch[1];
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
} catch {
|
|
88
|
+
// Ignore parse errors
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return {
|
|
92
|
+
name: skillName,
|
|
93
|
+
path: skillPath,
|
|
94
|
+
skillFile,
|
|
95
|
+
description,
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
return null;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Validate that a skill exists and has required files
|
|
106
|
+
*
|
|
107
|
+
* @param skillPath - Path to skill directory
|
|
108
|
+
* @returns true if valid, false otherwise
|
|
109
|
+
*/
|
|
110
|
+
export async function validateSkill(skillPath: string): Promise<boolean> {
|
|
111
|
+
const skillFile = join(skillPath, 'SKILL.md');
|
|
112
|
+
return pathExists(skillFile);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Read skill content from SKILL.md
|
|
117
|
+
*
|
|
118
|
+
* @param skillFile - Path to SKILL.md
|
|
119
|
+
* @returns Skill content as string
|
|
120
|
+
*/
|
|
121
|
+
export async function readSkillContent(skillFile: string): Promise<string> {
|
|
122
|
+
return readFile(skillFile, 'utf-8');
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Build a prompt that loads a skill
|
|
127
|
+
*
|
|
128
|
+
* Reads the skill file and includes its content directly in the prompt.
|
|
129
|
+
*
|
|
130
|
+
* @param skill - SkillInfo from findSkill
|
|
131
|
+
* @param additionalPrompt - Optional additional context/instructions
|
|
132
|
+
* @returns Combined prompt string
|
|
133
|
+
*/
|
|
134
|
+
export async function buildSkillPrompt(
|
|
135
|
+
skill: SkillInfo,
|
|
136
|
+
additionalPrompt?: string
|
|
137
|
+
): Promise<string> {
|
|
138
|
+
// Read the skill content
|
|
139
|
+
const skillContent = await readFile(skill.skillFile, 'utf-8');
|
|
140
|
+
|
|
141
|
+
const parts = [
|
|
142
|
+
`You are running skill: ${skill.name}`,
|
|
143
|
+
'',
|
|
144
|
+
'## Skill Instructions',
|
|
145
|
+
'',
|
|
146
|
+
skillContent,
|
|
147
|
+
];
|
|
148
|
+
|
|
149
|
+
if (additionalPrompt) {
|
|
150
|
+
parts.push('', '---', '', '## Additional Context', '', additionalPrompt);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
return parts.join('\n');
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Parse skill name from SKILL.md frontmatter
|
|
158
|
+
* Falls back to directory name conversion if no frontmatter name
|
|
159
|
+
*/
|
|
160
|
+
async function parseSkillName(skillFile: string, dirName: string): Promise<string> {
|
|
161
|
+
try {
|
|
162
|
+
const content = await readFile(skillFile, 'utf-8');
|
|
163
|
+
const frontmatterMatch = content.match(/^---\n([\s\S]*?)\n---/);
|
|
164
|
+
if (frontmatterMatch) {
|
|
165
|
+
const nameMatch = frontmatterMatch[1].match(/name:\s*["']?([^"'\n]+)["']?/);
|
|
166
|
+
if (nameMatch) {
|
|
167
|
+
return nameMatch[1].trim();
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
} catch {
|
|
171
|
+
// Ignore errors
|
|
172
|
+
}
|
|
173
|
+
// Fallback: convert first hyphen to colon (genie-wish -> genie:wish)
|
|
174
|
+
// but leave subsequent hyphens as-is for multi-word names
|
|
175
|
+
return dirName.replace(/-/, ':');
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* List all available skills
|
|
180
|
+
*
|
|
181
|
+
* @param projectRoot - Project root directory (defaults to cwd)
|
|
182
|
+
* @returns Array of skill names found
|
|
183
|
+
*/
|
|
184
|
+
export async function listSkills(projectRoot?: string): Promise<string[]> {
|
|
185
|
+
const { readdir } = await import('fs/promises');
|
|
186
|
+
const cwd = projectRoot || process.cwd();
|
|
187
|
+
const skills: string[] = [];
|
|
188
|
+
|
|
189
|
+
const searchPaths = [
|
|
190
|
+
join(cwd, '.claude', 'skills'),
|
|
191
|
+
join(homedir(), '.claude', 'skills'),
|
|
192
|
+
];
|
|
193
|
+
|
|
194
|
+
for (const searchPath of searchPaths) {
|
|
195
|
+
try {
|
|
196
|
+
const entries = await readdir(searchPath, { withFileTypes: true });
|
|
197
|
+
for (const entry of entries) {
|
|
198
|
+
if (entry.isDirectory()) {
|
|
199
|
+
const skillFile = join(searchPath, entry.name, 'SKILL.md');
|
|
200
|
+
if (await pathExists(skillFile)) {
|
|
201
|
+
// Get skill name from frontmatter or convert directory name
|
|
202
|
+
const skillName = await parseSkillName(skillFile, entry.name);
|
|
203
|
+
if (!skills.includes(skillName)) {
|
|
204
|
+
skills.push(skillName);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
} catch {
|
|
210
|
+
// Directory doesn't exist, skip
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
return skills.sort();
|
|
215
|
+
}
|
package/src/lib/tmux.ts
CHANGED
|
@@ -222,15 +222,25 @@ export async function killPane(paneId: string): Promise<void> {
|
|
|
222
222
|
await executeTmux(`kill-pane -t '${paneId}'`);
|
|
223
223
|
}
|
|
224
224
|
|
|
225
|
+
/**
|
|
226
|
+
* Escape a string for safe use in shell single quotes.
|
|
227
|
+
* Replaces ' with '\'' (end quote, escaped quote, start quote).
|
|
228
|
+
*/
|
|
229
|
+
function escapeShellPath(path: string): string {
|
|
230
|
+
return path.replace(/'/g, "'\\''");
|
|
231
|
+
}
|
|
232
|
+
|
|
225
233
|
/**
|
|
226
234
|
* Split a tmux pane horizontally or vertically
|
|
227
235
|
*/
|
|
228
236
|
export async function splitPane(
|
|
229
237
|
targetPaneId: string,
|
|
230
238
|
direction: 'horizontal' | 'vertical' = 'vertical',
|
|
231
|
-
size?: number
|
|
239
|
+
size?: number,
|
|
240
|
+
workingDir?: string
|
|
232
241
|
): Promise<TmuxPane | null> {
|
|
233
242
|
// Build the split-window command
|
|
243
|
+
// Order follows tmux convention: flags, -c, -p, -t, -F
|
|
234
244
|
let splitCommand = 'split-window';
|
|
235
245
|
|
|
236
246
|
// Add direction flag (-h for horizontal, -v for vertical)
|
|
@@ -240,25 +250,34 @@ export async function splitPane(
|
|
|
240
250
|
splitCommand += ' -v';
|
|
241
251
|
}
|
|
242
252
|
|
|
243
|
-
// Add
|
|
244
|
-
|
|
253
|
+
// Add working directory if specified (must come before -t)
|
|
254
|
+
if (workingDir) {
|
|
255
|
+
splitCommand += ` -c '${escapeShellPath(workingDir)}'`;
|
|
256
|
+
}
|
|
245
257
|
|
|
246
258
|
// Add size if specified (as percentage)
|
|
247
259
|
if (size !== undefined && size > 0 && size < 100) {
|
|
248
260
|
splitCommand += ` -p ${size}`;
|
|
249
261
|
}
|
|
250
262
|
|
|
251
|
-
//
|
|
252
|
-
|
|
263
|
+
// Add target pane
|
|
264
|
+
splitCommand += ` -t '${targetPaneId}'`;
|
|
265
|
+
|
|
266
|
+
// Add -P flag to print new pane info, with format to get pane ID
|
|
267
|
+
splitCommand += ` -P -F '#{pane_id}'`;
|
|
253
268
|
|
|
254
|
-
//
|
|
255
|
-
const
|
|
269
|
+
// Execute the split command - returns the new pane ID
|
|
270
|
+
const newPaneId = (await executeTmux(splitCommand)).trim();
|
|
256
271
|
|
|
257
|
-
//
|
|
258
|
-
const
|
|
272
|
+
// Get the window ID for the new pane
|
|
273
|
+
const windowId = await executeTmux(`display-message -p -t '${newPaneId}' '#{window_id}'`);
|
|
259
274
|
|
|
260
|
-
|
|
261
|
-
|
|
275
|
+
return {
|
|
276
|
+
id: newPaneId,
|
|
277
|
+
windowId: windowId.trim(),
|
|
278
|
+
active: false,
|
|
279
|
+
title: ''
|
|
280
|
+
};
|
|
262
281
|
}
|
|
263
282
|
|
|
264
283
|
// Map to track ongoing command executions
|
package/src/lib/version.ts
CHANGED
|
@@ -47,6 +47,8 @@ export interface Worker {
|
|
|
47
47
|
lastStateChange: string;
|
|
48
48
|
/** Repository path where worker operates */
|
|
49
49
|
repoPath: string;
|
|
50
|
+
/** Claude session ID for resume capability */
|
|
51
|
+
claudeSessionId?: string;
|
|
50
52
|
}
|
|
51
53
|
|
|
52
54
|
export interface WorkerRegistry {
|
|
@@ -176,6 +178,14 @@ export async function findByWish(wishSlug: string): Promise<Worker[]> {
|
|
|
176
178
|
return workers.filter(w => w.wishSlug === wishSlug);
|
|
177
179
|
}
|
|
178
180
|
|
|
181
|
+
/**
|
|
182
|
+
* Find worker by Claude session ID
|
|
183
|
+
*/
|
|
184
|
+
export async function findBySessionId(sessionId: string): Promise<Worker | null> {
|
|
185
|
+
const workers = await list();
|
|
186
|
+
return workers.find(w => w.claudeSessionId === sessionId) || null;
|
|
187
|
+
}
|
|
188
|
+
|
|
179
189
|
/**
|
|
180
190
|
* Check if a worker exists for a given task
|
|
181
191
|
*/
|
|
@@ -0,0 +1,351 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
/**
|
|
3
|
+
* Worker Service for automagik-genie
|
|
4
|
+
*
|
|
5
|
+
* Background HTTP service for workflow state management.
|
|
6
|
+
* Port: 48888 (avoids collision with claude-mem's 37777)
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { createServer, IncomingMessage, ServerResponse } from 'http';
|
|
10
|
+
import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs';
|
|
11
|
+
import { join } from 'path';
|
|
12
|
+
import { homedir } from 'os';
|
|
13
|
+
import { spawn, execSync } from 'child_process';
|
|
14
|
+
|
|
15
|
+
const PORT = 48888;
|
|
16
|
+
const GENIE_DIR = join(homedir(), '.genie');
|
|
17
|
+
const PID_FILE = join(GENIE_DIR, 'worker.pid');
|
|
18
|
+
const STATE_FILE = join(GENIE_DIR, 'workflow-state.json');
|
|
19
|
+
|
|
20
|
+
// Ensure .genie directory exists
|
|
21
|
+
if (!existsSync(GENIE_DIR)) {
|
|
22
|
+
mkdirSync(GENIE_DIR, { recursive: true });
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
interface WorkflowState {
|
|
26
|
+
activeWish?: string;
|
|
27
|
+
activeForge?: {
|
|
28
|
+
wishSlug: string;
|
|
29
|
+
currentTask?: string;
|
|
30
|
+
completedTasks: string[];
|
|
31
|
+
failedTasks: string[];
|
|
32
|
+
};
|
|
33
|
+
lastUpdate: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function loadState(): WorkflowState {
|
|
37
|
+
try {
|
|
38
|
+
if (existsSync(STATE_FILE)) {
|
|
39
|
+
return JSON.parse(readFileSync(STATE_FILE, 'utf-8'));
|
|
40
|
+
}
|
|
41
|
+
} catch {
|
|
42
|
+
// Ignore parse errors
|
|
43
|
+
}
|
|
44
|
+
return { lastUpdate: new Date().toISOString() };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function saveState(state: WorkflowState): void {
|
|
48
|
+
state.lastUpdate = new Date().toISOString();
|
|
49
|
+
writeFileSync(STATE_FILE, JSON.stringify(state, null, 2));
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function json(res: ServerResponse, data: unknown, status = 200): void {
|
|
53
|
+
res.writeHead(status, { 'Content-Type': 'application/json' });
|
|
54
|
+
res.end(JSON.stringify(data));
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function parseBody(req: IncomingMessage): Promise<unknown> {
|
|
58
|
+
return new Promise((resolve, reject) => {
|
|
59
|
+
let body = '';
|
|
60
|
+
req.on('data', chunk => body += chunk);
|
|
61
|
+
req.on('end', () => {
|
|
62
|
+
try {
|
|
63
|
+
resolve(body ? JSON.parse(body) : {});
|
|
64
|
+
} catch {
|
|
65
|
+
reject(new Error('Invalid JSON'));
|
|
66
|
+
}
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async function handleRequest(req: IncomingMessage, res: ServerResponse): Promise<void> {
|
|
72
|
+
const url = new URL(req.url || '/', `http://localhost:${PORT}`);
|
|
73
|
+
const path = url.pathname;
|
|
74
|
+
const method = req.method || 'GET';
|
|
75
|
+
|
|
76
|
+
// CORS headers
|
|
77
|
+
res.setHeader('Access-Control-Allow-Origin', '*');
|
|
78
|
+
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
|
|
79
|
+
res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
|
|
80
|
+
|
|
81
|
+
if (method === 'OPTIONS') {
|
|
82
|
+
res.writeHead(204);
|
|
83
|
+
res.end();
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Health check
|
|
88
|
+
if (path === '/health' || path === '/') {
|
|
89
|
+
json(res, {
|
|
90
|
+
status: 'ok',
|
|
91
|
+
service: 'automagik-genie',
|
|
92
|
+
version: process.env.GENIE_VERSION || 'dev',
|
|
93
|
+
port: PORT,
|
|
94
|
+
uptime: process.uptime()
|
|
95
|
+
});
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Workflow status
|
|
100
|
+
if (path === '/api/workflow/status' && method === 'GET') {
|
|
101
|
+
const state = loadState();
|
|
102
|
+
json(res, state);
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Update workflow state
|
|
107
|
+
if (path === '/api/workflow/update' && method === 'POST') {
|
|
108
|
+
try {
|
|
109
|
+
const body = await parseBody(req) as Partial<WorkflowState>;
|
|
110
|
+
const state = loadState();
|
|
111
|
+
Object.assign(state, body);
|
|
112
|
+
saveState(state);
|
|
113
|
+
json(res, { success: true, state });
|
|
114
|
+
} catch (error) {
|
|
115
|
+
json(res, { error: 'Invalid request body' }, 400);
|
|
116
|
+
}
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Start wish tracking
|
|
121
|
+
if (path === '/api/workflow/wish/start' && method === 'POST') {
|
|
122
|
+
try {
|
|
123
|
+
const body = await parseBody(req) as { slug: string };
|
|
124
|
+
const state = loadState();
|
|
125
|
+
state.activeWish = body.slug;
|
|
126
|
+
saveState(state);
|
|
127
|
+
json(res, { success: true, wish: body.slug });
|
|
128
|
+
} catch {
|
|
129
|
+
json(res, { error: 'Invalid request' }, 400);
|
|
130
|
+
}
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Start forge session
|
|
135
|
+
if (path === '/api/workflow/forge/start' && method === 'POST') {
|
|
136
|
+
try {
|
|
137
|
+
const body = await parseBody(req) as { wishSlug: string };
|
|
138
|
+
const state = loadState();
|
|
139
|
+
state.activeForge = {
|
|
140
|
+
wishSlug: body.wishSlug,
|
|
141
|
+
completedTasks: [],
|
|
142
|
+
failedTasks: []
|
|
143
|
+
};
|
|
144
|
+
saveState(state);
|
|
145
|
+
json(res, { success: true, forge: state.activeForge });
|
|
146
|
+
} catch {
|
|
147
|
+
json(res, { error: 'Invalid request' }, 400);
|
|
148
|
+
}
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// Update forge task status
|
|
153
|
+
if (path === '/api/workflow/forge/task' && method === 'POST') {
|
|
154
|
+
try {
|
|
155
|
+
const body = await parseBody(req) as { task: string; status: 'started' | 'completed' | 'failed' };
|
|
156
|
+
const state = loadState();
|
|
157
|
+
if (!state.activeForge) {
|
|
158
|
+
json(res, { error: 'No active forge session' }, 400);
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
if (body.status === 'started') {
|
|
162
|
+
state.activeForge.currentTask = body.task;
|
|
163
|
+
} else if (body.status === 'completed') {
|
|
164
|
+
state.activeForge.completedTasks.push(body.task);
|
|
165
|
+
state.activeForge.currentTask = undefined;
|
|
166
|
+
} else if (body.status === 'failed') {
|
|
167
|
+
state.activeForge.failedTasks.push(body.task);
|
|
168
|
+
state.activeForge.currentTask = undefined;
|
|
169
|
+
}
|
|
170
|
+
saveState(state);
|
|
171
|
+
json(res, { success: true, forge: state.activeForge });
|
|
172
|
+
} catch {
|
|
173
|
+
json(res, { error: 'Invalid request' }, 400);
|
|
174
|
+
}
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// Context hook - inject active workflow into Claude session
|
|
179
|
+
if (path === '/api/hook/context' && method === 'GET') {
|
|
180
|
+
const state = loadState();
|
|
181
|
+
let context = '';
|
|
182
|
+
|
|
183
|
+
if (state.activeWish) {
|
|
184
|
+
context += `Active Wish: ${state.activeWish}\n`;
|
|
185
|
+
}
|
|
186
|
+
if (state.activeForge) {
|
|
187
|
+
context += `Active Forge: ${state.activeForge.wishSlug}\n`;
|
|
188
|
+
if (state.activeForge.currentTask) {
|
|
189
|
+
context += ` Current Task: ${state.activeForge.currentTask}\n`;
|
|
190
|
+
}
|
|
191
|
+
context += ` Completed: ${state.activeForge.completedTasks.length} tasks\n`;
|
|
192
|
+
if (state.activeForge.failedTasks.length > 0) {
|
|
193
|
+
context += ` Failed: ${state.activeForge.failedTasks.length} tasks\n`;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
if (context) {
|
|
198
|
+
json(res, { context });
|
|
199
|
+
} else {
|
|
200
|
+
json(res, { context: null });
|
|
201
|
+
}
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// Admin restart endpoint
|
|
206
|
+
if (path === '/api/admin/restart' && method === 'POST') {
|
|
207
|
+
json(res, { success: true, message: 'Worker restarting...' });
|
|
208
|
+
// Schedule restart after response is sent
|
|
209
|
+
setTimeout(() => {
|
|
210
|
+
process.exit(0);
|
|
211
|
+
}, 100);
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// 404 for unknown routes
|
|
216
|
+
json(res, { error: 'Not found', path }, 404);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// Check if another instance is running
|
|
220
|
+
function isAlreadyRunning(): boolean {
|
|
221
|
+
try {
|
|
222
|
+
if (existsSync(PID_FILE)) {
|
|
223
|
+
const pid = parseInt(readFileSync(PID_FILE, 'utf-8').trim(), 10);
|
|
224
|
+
// Check if process exists
|
|
225
|
+
process.kill(pid, 0);
|
|
226
|
+
return true;
|
|
227
|
+
}
|
|
228
|
+
} catch {
|
|
229
|
+
// Process doesn't exist or PID file invalid
|
|
230
|
+
}
|
|
231
|
+
return false;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// Write PID file
|
|
235
|
+
function writePidFile(): void {
|
|
236
|
+
writeFileSync(PID_FILE, String(process.pid));
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// CLI commands
|
|
240
|
+
const command = process.argv[2];
|
|
241
|
+
|
|
242
|
+
if (command === 'start') {
|
|
243
|
+
if (isAlreadyRunning()) {
|
|
244
|
+
console.log('Worker already running');
|
|
245
|
+
process.exit(0);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// Start as daemon (background process)
|
|
249
|
+
if (process.argv[3] !== '--foreground') {
|
|
250
|
+
const child = spawn(process.argv[0], [process.argv[1], 'start', '--foreground'], {
|
|
251
|
+
detached: true,
|
|
252
|
+
stdio: 'ignore'
|
|
253
|
+
});
|
|
254
|
+
child.unref();
|
|
255
|
+
console.log(`Worker started (PID: ${child.pid})`);
|
|
256
|
+
process.exit(0);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// Running in foreground
|
|
260
|
+
const server = createServer((req, res) => {
|
|
261
|
+
handleRequest(req, res).catch(err => {
|
|
262
|
+
console.error('Request error:', err);
|
|
263
|
+
json(res, { error: 'Internal server error' }, 500);
|
|
264
|
+
});
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
server.listen(PORT, '127.0.0.1', () => {
|
|
268
|
+
writePidFile();
|
|
269
|
+
console.log(`automagik-genie worker listening on http://127.0.0.1:${PORT}`);
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
// Graceful shutdown
|
|
273
|
+
process.on('SIGTERM', () => {
|
|
274
|
+
server.close();
|
|
275
|
+
process.exit(0);
|
|
276
|
+
});
|
|
277
|
+
process.on('SIGINT', () => {
|
|
278
|
+
server.close();
|
|
279
|
+
process.exit(0);
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
} else if (command === 'stop') {
|
|
283
|
+
try {
|
|
284
|
+
if (existsSync(PID_FILE)) {
|
|
285
|
+
const pid = parseInt(readFileSync(PID_FILE, 'utf-8').trim(), 10);
|
|
286
|
+
process.kill(pid, 'SIGTERM');
|
|
287
|
+
console.log('Worker stopped');
|
|
288
|
+
} else {
|
|
289
|
+
console.log('Worker not running');
|
|
290
|
+
}
|
|
291
|
+
} catch {
|
|
292
|
+
console.log('Worker not running');
|
|
293
|
+
}
|
|
294
|
+
process.exit(0);
|
|
295
|
+
|
|
296
|
+
} else if (command === 'status') {
|
|
297
|
+
if (isAlreadyRunning()) {
|
|
298
|
+
const pid = readFileSync(PID_FILE, 'utf-8').trim();
|
|
299
|
+
console.log(`Worker running (PID: ${pid})`);
|
|
300
|
+
// Try to get health status
|
|
301
|
+
try {
|
|
302
|
+
execSync(`curl -s http://127.0.0.1:${PORT}/health`, { encoding: 'utf-8' });
|
|
303
|
+
console.log('Health: OK');
|
|
304
|
+
} catch {
|
|
305
|
+
console.log('Health: Unable to connect');
|
|
306
|
+
}
|
|
307
|
+
} else {
|
|
308
|
+
console.log('Worker not running');
|
|
309
|
+
}
|
|
310
|
+
process.exit(0);
|
|
311
|
+
|
|
312
|
+
} else if (command === 'hook') {
|
|
313
|
+
// Hook subcommand for lifecycle hooks
|
|
314
|
+
const hookType = process.argv[3];
|
|
315
|
+
|
|
316
|
+
if (hookType === 'context') {
|
|
317
|
+
// Inject workflow context - called by SessionStart hook
|
|
318
|
+
try {
|
|
319
|
+
const response = execSync(`curl -s http://127.0.0.1:${PORT}/api/hook/context`, { encoding: 'utf-8' });
|
|
320
|
+
const data = JSON.parse(response);
|
|
321
|
+
if (data.context) {
|
|
322
|
+
console.log(`\n<genie-workflow>\n${data.context}</genie-workflow>\n`);
|
|
323
|
+
}
|
|
324
|
+
} catch {
|
|
325
|
+
// Worker not running, no context to inject
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
process.exit(0);
|
|
329
|
+
|
|
330
|
+
} else {
|
|
331
|
+
console.log(`
|
|
332
|
+
automagik-genie worker service
|
|
333
|
+
|
|
334
|
+
Usage:
|
|
335
|
+
worker-service start Start the worker (daemonized)
|
|
336
|
+
worker-service stop Stop the worker
|
|
337
|
+
worker-service status Check worker status
|
|
338
|
+
worker-service hook <type> Run hook command
|
|
339
|
+
|
|
340
|
+
Endpoints:
|
|
341
|
+
GET /health Health check
|
|
342
|
+
GET /api/workflow/status Get workflow state
|
|
343
|
+
POST /api/workflow/update Update workflow state
|
|
344
|
+
POST /api/workflow/wish/start Start tracking a wish
|
|
345
|
+
POST /api/workflow/forge/start Start forge session
|
|
346
|
+
POST /api/workflow/forge/task Update forge task status
|
|
347
|
+
GET /api/hook/context Get context for injection
|
|
348
|
+
POST /api/admin/restart Restart worker
|
|
349
|
+
`);
|
|
350
|
+
process.exit(0);
|
|
351
|
+
}
|