Compare commits
10 Commits
c4fbbbd865
...
22f3c7dda6
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
22f3c7dda6 | ||
|
|
37ddaae043 | ||
|
|
8dc9c96606 | ||
|
|
1197d85fdc | ||
|
|
abab656969 | ||
|
|
5adaaad216 | ||
|
|
7c20dd91d9 | ||
|
|
d728c616d1 | ||
|
|
fed43e70ed | ||
|
|
0cc88603e1 |
28
README.md
Normal file
28
README.md
Normal file
@ -0,0 +1,28 @@
|
||||
# AgentHub
|
||||
|
||||
Local coordination layer for AI coding agents.
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
npx agenthub init
|
||||
```
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
agenthub init
|
||||
agenthub task create --title "Implement DNS cache" --role implementer
|
||||
agenthub handoff create --fromRole architect --toRole implementer
|
||||
agenthub status --update
|
||||
```
|
||||
|
||||
## Supported Agents
|
||||
|
||||
- Claude Code
|
||||
- Codex CLI
|
||||
- Kimi Code CLI
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
@ -3,6 +3,9 @@
|
||||
"version": "0.1.0",
|
||||
"description": "Local coordination layer for AI coding agents",
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"files": ["dist", "bin"],
|
||||
"bin": {
|
||||
"agenthub": "./bin/agenthub.js"
|
||||
},
|
||||
@ -30,5 +33,8 @@
|
||||
"vitest": "^3.0.0"
|
||||
},
|
||||
"keywords": ["ai", "agents", "claude", "codex", "kimi", "collaboration"],
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"pnpm": {
|
||||
"onlyBuiltDependencies": ["better-sqlite3"]
|
||||
}
|
||||
}
|
||||
|
||||
2030
pnpm-lock.yaml
generated
Normal file
2030
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
55
src/cli/commands/decision.ts
Normal file
55
src/cli/commands/decision.ts
Normal file
@ -0,0 +1,55 @@
|
||||
import { input } from '@inquirer/prompts';
|
||||
import { join } from 'path';
|
||||
import { getEntityDir } from '../../core/paths.js';
|
||||
import { getNextId } from '../../core/counter.js';
|
||||
import { readEntity, writeEntity } from '../../core/files.js';
|
||||
import { DecisionSchema, type Decision } from '../../core/schema.js';
|
||||
import { Index } from '../../core/index.js';
|
||||
|
||||
export async function decisionCreate(cwd: string, options: Partial<Decision> = {}): Promise<void> {
|
||||
const title = options.title ?? await input({ message: 'Decision title:' });
|
||||
const context = options.context ?? await input({ message: 'Context:' });
|
||||
const decision = options.decision ?? await input({ message: 'Decision:' });
|
||||
|
||||
const now = new Date().toISOString();
|
||||
const record: Decision = DecisionSchema.parse({
|
||||
id: getNextId(cwd, 'decision'),
|
||||
title,
|
||||
context,
|
||||
decision,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
|
||||
const filePath = join(getEntityDir(cwd, 'decisions'), `${record.id}.md`);
|
||||
writeEntity(filePath, record, `# ${record.title}\n\n## Decision\n\n${record.decision}\n\n## Context\n\n${record.context}`);
|
||||
|
||||
const index = new Index(cwd);
|
||||
index.upsert({
|
||||
id: record.id,
|
||||
type: 'decision',
|
||||
title: record.title,
|
||||
content: `${record.context} ${record.decision}`,
|
||||
filePath,
|
||||
createdAt: record.createdAt,
|
||||
updatedAt: record.updatedAt,
|
||||
});
|
||||
index.close();
|
||||
|
||||
console.log(`Decision recorded: ${record.id}`);
|
||||
}
|
||||
|
||||
export function decisionList(cwd: string): void {
|
||||
const index = new Index(cwd);
|
||||
const decisions = index.list('decision');
|
||||
index.close();
|
||||
|
||||
if (decisions.length === 0) {
|
||||
console.log('No decisions found.');
|
||||
return;
|
||||
}
|
||||
|
||||
for (const d of decisions) {
|
||||
console.log(`${d.id}: ${d.title}`);
|
||||
}
|
||||
}
|
||||
39
src/cli/commands/delegate.ts
Normal file
39
src/cli/commands/delegate.ts
Normal file
@ -0,0 +1,39 @@
|
||||
import { loadConfig } from '../../core/config.js';
|
||||
import { Index } from '../../core/index.js';
|
||||
import { handoffCreate } from './handoff.js';
|
||||
|
||||
export async function delegate(cwd: string, options: { auto?: boolean } = {}): Promise<void> {
|
||||
const config = loadConfig(cwd);
|
||||
const index = new Index(cwd);
|
||||
const openTasks = index.list('task', { status: 'open' });
|
||||
index.close();
|
||||
|
||||
if (openTasks.length === 0) {
|
||||
console.log('No open tasks to delegate.');
|
||||
return;
|
||||
}
|
||||
|
||||
const task = openTasks[0];
|
||||
const role = task.role ?? 'implementer';
|
||||
const preferredAgent = config.roles[role]?.preferredAgent ?? 'codex';
|
||||
|
||||
console.log('Suggested delegation:');
|
||||
console.log(` Task: ${task.id} — ${task.title}`);
|
||||
console.log(` Role: ${role}`);
|
||||
console.log(` Preferred agent: ${preferredAgent}`);
|
||||
|
||||
if (config.delegationMode === 'auto' || options.auto) {
|
||||
await handoffCreate(cwd, {
|
||||
fromRole: 'user',
|
||||
toRole: role,
|
||||
toAgent: preferredAgent,
|
||||
taskId: task.id,
|
||||
summary: `Delegate ${task.id} to ${role}`,
|
||||
context: `Task "${task.title}" should be handled by ${preferredAgent} in ${role} role.`,
|
||||
});
|
||||
console.log('Handoff created automatically.');
|
||||
} else {
|
||||
console.log('Run with --auto to create the handoff, or run:');
|
||||
console.log(` agenthub handoff create --taskId ${task.id}`);
|
||||
}
|
||||
}
|
||||
71
src/cli/commands/handoff.ts
Normal file
71
src/cli/commands/handoff.ts
Normal file
@ -0,0 +1,71 @@
|
||||
import { input, select } from '@inquirer/prompts';
|
||||
import { join } from 'path';
|
||||
import { getEntityDir } from '../../core/paths.js';
|
||||
import { getNextId } from '../../core/counter.js';
|
||||
import { readEntity, writeEntity } from '../../core/files.js';
|
||||
import { HandoffSchema, type Handoff } from '../../core/schema.js';
|
||||
import { Index } from '../../core/index.js';
|
||||
|
||||
const roles = ['architect', 'implementer', 'reviewer', 'tester'];
|
||||
|
||||
export async function handoffCreate(cwd: string, options: Partial<Handoff> = {}): Promise<void> {
|
||||
const fromRole = options.fromRole ?? await select({ message: 'From role:', choices: roles.map((r) => ({ name: r, value: r })) });
|
||||
const toRole = options.toRole ?? await select({ message: 'To role:', choices: roles.map((r) => ({ name: r, value: r })) });
|
||||
const taskId = options.taskId ?? await input({ message: 'Related task id (optional):' });
|
||||
const summary = options.summary ?? await input({ message: 'Summary:' });
|
||||
const context = options.context ?? await input({ message: 'Context:' });
|
||||
|
||||
const now = new Date().toISOString();
|
||||
const handoff: Handoff = HandoffSchema.parse({
|
||||
id: getNextId(cwd, 'handoff'),
|
||||
fromRole,
|
||||
toRole,
|
||||
fromAgent: options.fromAgent,
|
||||
toAgent: options.toAgent,
|
||||
taskId: taskId || undefined,
|
||||
summary,
|
||||
context,
|
||||
createdAt: now,
|
||||
});
|
||||
|
||||
const filePath = join(getEntityDir(cwd, 'handoffs'), `${handoff.id}.md`);
|
||||
writeEntity(filePath, handoff, `# ${handoff.summary}\n\n${handoff.context}`);
|
||||
|
||||
const index = new Index(cwd);
|
||||
index.upsert({
|
||||
id: handoff.id,
|
||||
type: 'handoff',
|
||||
title: handoff.summary,
|
||||
content: handoff.context,
|
||||
filePath,
|
||||
createdAt: handoff.createdAt,
|
||||
updatedAt: handoff.createdAt,
|
||||
});
|
||||
index.close();
|
||||
|
||||
console.log(`Handoff created: ${handoff.id}`);
|
||||
}
|
||||
|
||||
export function handoffRead(cwd: string, id: string): void {
|
||||
const filePath = join(getEntityDir(cwd, 'handoffs'), `${id}.md`);
|
||||
const { frontmatter, body } = readEntity(filePath);
|
||||
console.log(`# ${frontmatter.summary}`);
|
||||
console.log(`From: ${frontmatter.fromRole} → ${frontmatter.toRole}`);
|
||||
if (frontmatter.taskId) console.log(`Task: ${frontmatter.taskId}`);
|
||||
console.log('\n' + body);
|
||||
}
|
||||
|
||||
export function handoffList(cwd: string): void {
|
||||
const index = new Index(cwd);
|
||||
const handoffs = index.list('handoff');
|
||||
index.close();
|
||||
|
||||
if (handoffs.length === 0) {
|
||||
console.log('No handoffs found.');
|
||||
return;
|
||||
}
|
||||
|
||||
for (const h of handoffs) {
|
||||
console.log(`${h.id}: ${h.title}`);
|
||||
}
|
||||
}
|
||||
@ -1,7 +1,7 @@
|
||||
import { input, select } from '@inquirer/prompts';
|
||||
import { join } from 'path';
|
||||
import { getEntityDir } from '../../core/paths.js';
|
||||
import { readEntity, writeEntity, listEntities } from '../../core/files.js';
|
||||
import { writeEntity } from '../../core/files.js';
|
||||
import { MemorySchema, type Memory } from '../../core/schema.js';
|
||||
import { Index } from '../../core/index.js';
|
||||
|
||||
|
||||
5
src/cli/commands/server.ts
Normal file
5
src/cli/commands/server.ts
Normal file
@ -0,0 +1,5 @@
|
||||
import { startServer } from '../../server/index.js';
|
||||
|
||||
export async function serverStart(cwd: string, options: { port?: number } = {}): Promise<void> {
|
||||
await startServer(cwd, options.port ?? 3377);
|
||||
}
|
||||
14
src/cli/commands/status.ts
Normal file
14
src/cli/commands/status.ts
Normal file
@ -0,0 +1,14 @@
|
||||
import { existsSync } from 'fs';
|
||||
import { generateStatus } from '../../core/status.js';
|
||||
import { readEntity } from '../../core/files.js';
|
||||
import { getStatusPath } from '../../core/paths.js';
|
||||
|
||||
export function status(cwd: string, options: { update?: boolean } = {}): void {
|
||||
const statusPath = getStatusPath(cwd);
|
||||
if (options.update || !existsSync(statusPath)) {
|
||||
generateStatus(cwd);
|
||||
}
|
||||
|
||||
const { body } = readEntity(statusPath);
|
||||
console.log(body);
|
||||
}
|
||||
@ -2,7 +2,7 @@ import { input, select } from '@inquirer/prompts';
|
||||
import { join } from 'path';
|
||||
import { getEntityDir } from '../../core/paths.js';
|
||||
import { getNextId } from '../../core/counter.js';
|
||||
import { readEntity, writeEntity, listEntities } from '../../core/files.js';
|
||||
import { readEntity, writeEntity } from '../../core/files.js';
|
||||
import { TaskSchema, type Task } from '../../core/schema.js';
|
||||
import { Index } from '../../core/index.js';
|
||||
|
||||
|
||||
125
src/cli/index.ts
Normal file
125
src/cli/index.ts
Normal file
@ -0,0 +1,125 @@
|
||||
import { Command } from 'commander';
|
||||
import { init } from './commands/init.js';
|
||||
import { status } from './commands/status.js';
|
||||
import { memoryAdd, memorySearch, memoryList } from './commands/memory.js';
|
||||
import { taskCreate, taskList, taskShow, taskClaim, taskDone } from './commands/task.js';
|
||||
import { handoffCreate, handoffRead, handoffList } from './commands/handoff.js';
|
||||
import { decisionCreate, decisionList } from './commands/decision.js';
|
||||
import { delegate } from './commands/delegate.js';
|
||||
import { serverStart } from './commands/server.js';
|
||||
|
||||
export function createProgram(cwd: string): Command {
|
||||
const program = new Command('agenthub')
|
||||
.description('Local coordination layer for AI coding agents')
|
||||
.version('0.1.0');
|
||||
|
||||
program
|
||||
.command('init')
|
||||
.description('Initialize AgentHub in the current directory')
|
||||
.option('-n, --project-name <name>', 'Project name')
|
||||
.option('-y, --yes', 'Use defaults without prompts')
|
||||
.action((options) => init(cwd, options));
|
||||
|
||||
program
|
||||
.command('status')
|
||||
.description('Show project status')
|
||||
.option('-u, --update', 'Regenerate status before showing')
|
||||
.action((options) => status(cwd, options));
|
||||
|
||||
const memoryCmd = new Command('memory').description('Manage memory entries');
|
||||
memoryCmd
|
||||
.command('add')
|
||||
.description('Add a memory entry')
|
||||
.option('--title <title>', 'Title')
|
||||
.option('--category <category>', 'Category')
|
||||
.option('--content <content>', 'Content')
|
||||
.action((options) => memoryAdd(cwd, options));
|
||||
memoryCmd
|
||||
.command('search <query>')
|
||||
.description('Search memory and tasks')
|
||||
.action((query) => memorySearch(cwd, query));
|
||||
memoryCmd
|
||||
.command('list')
|
||||
.description('List memory entries')
|
||||
.action(() => memoryList(cwd));
|
||||
program.addCommand(memoryCmd);
|
||||
|
||||
const taskCmd = new Command('task').description('Manage tasks');
|
||||
taskCmd
|
||||
.command('create')
|
||||
.description('Create a task')
|
||||
.option('--title <title>', 'Title')
|
||||
.option('--role <role>', 'Role')
|
||||
.option('--priority <priority>', 'Priority')
|
||||
.action((options) => taskCreate(cwd, options));
|
||||
taskCmd
|
||||
.command('list')
|
||||
.description('List tasks')
|
||||
.option('--status <status>', 'Filter by status')
|
||||
.option('--role <role>', 'Filter by role')
|
||||
.action((options) => taskList(cwd, options));
|
||||
taskCmd
|
||||
.command('show <id>')
|
||||
.description('Show a task')
|
||||
.action((id) => taskShow(cwd, id));
|
||||
taskCmd
|
||||
.command('claim <id>')
|
||||
.description('Claim a task')
|
||||
.requiredOption('--agent <agent>', 'Agent name')
|
||||
.action((id, options) => taskClaim(cwd, id, options.agent));
|
||||
taskCmd
|
||||
.command('done <id>')
|
||||
.description('Mark task as done')
|
||||
.action((id) => taskDone(cwd, id));
|
||||
program.addCommand(taskCmd);
|
||||
|
||||
const handoffCmd = new Command('handoff').description('Manage handoffs');
|
||||
handoffCmd
|
||||
.command('create')
|
||||
.description('Create a handoff')
|
||||
.option('--fromRole <role>', 'From role')
|
||||
.option('--toRole <role>', 'To role')
|
||||
.option('--taskId <id>', 'Related task id')
|
||||
.option('--summary <summary>', 'Summary')
|
||||
.option('--context <context>', 'Context')
|
||||
.action((options) => handoffCreate(cwd, options));
|
||||
handoffCmd
|
||||
.command('read <id>')
|
||||
.description('Read a handoff')
|
||||
.action((id) => handoffRead(cwd, id));
|
||||
handoffCmd
|
||||
.command('list')
|
||||
.description('List handoffs')
|
||||
.action(() => handoffList(cwd));
|
||||
program.addCommand(handoffCmd);
|
||||
|
||||
const decisionCmd = new Command('decision').description('Manage decisions');
|
||||
decisionCmd
|
||||
.command('create')
|
||||
.description('Create a decision record')
|
||||
.option('--title <title>', 'Title')
|
||||
.option('--context <context>', 'Context')
|
||||
.option('--decision <decision>', 'Decision')
|
||||
.action((options) => decisionCreate(cwd, options));
|
||||
decisionCmd
|
||||
.command('list')
|
||||
.description('List decisions')
|
||||
.action(() => decisionList(cwd));
|
||||
program.addCommand(decisionCmd);
|
||||
|
||||
program
|
||||
.command('delegate')
|
||||
.description('Suggest or auto-delegate open tasks')
|
||||
.option('--auto', 'Create handoff automatically')
|
||||
.action((options) => delegate(cwd, options));
|
||||
|
||||
const serverCmd = new Command('server').description('Optional local API server');
|
||||
serverCmd
|
||||
.command('start')
|
||||
.description('Start the optional AgentHub API server')
|
||||
.option('-p, --port <port>', 'Port', '3377')
|
||||
.action((options) => serverStart(cwd, { port: parseInt(options.port, 10) }));
|
||||
program.addCommand(serverCmd);
|
||||
|
||||
return program;
|
||||
}
|
||||
4
src/index.ts
Normal file
4
src/index.ts
Normal file
@ -0,0 +1,4 @@
|
||||
import { createProgram } from './cli/index.js';
|
||||
|
||||
const program = createProgram(process.cwd());
|
||||
program.parse();
|
||||
15
src/server/index.ts
Normal file
15
src/server/index.ts
Normal file
@ -0,0 +1,15 @@
|
||||
import Fastify from 'fastify';
|
||||
import { registerRoutes } from './routes.js';
|
||||
|
||||
export async function startServer(cwd: string, port = 3377): Promise<void> {
|
||||
const app = Fastify({ logger: false });
|
||||
await registerRoutes(app, cwd);
|
||||
|
||||
try {
|
||||
await app.listen({ port, host: '127.0.0.1' });
|
||||
console.log(`AgentHub server listening on http://127.0.0.1:${port}`);
|
||||
} catch (err) {
|
||||
app.log.error(err);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
27
src/server/routes.ts
Normal file
27
src/server/routes.ts
Normal file
@ -0,0 +1,27 @@
|
||||
import { FastifyInstance } from 'fastify';
|
||||
import { Index } from '../core/index.js';
|
||||
|
||||
export async function registerRoutes(app: FastifyInstance, cwd: string): Promise<void> {
|
||||
app.get('/status', async () => {
|
||||
const index = new Index(cwd);
|
||||
const open = index.list('task', { status: 'open' });
|
||||
const inProgress = index.list('task', { status: 'in_progress' });
|
||||
index.close();
|
||||
return { open: open.length, inProgress: inProgress.length };
|
||||
});
|
||||
|
||||
app.get('/tasks', async () => {
|
||||
const index = new Index(cwd);
|
||||
const tasks = index.list('task');
|
||||
index.close();
|
||||
return tasks;
|
||||
});
|
||||
|
||||
app.get('/search', async (request) => {
|
||||
const { q } = request.query as { q: string };
|
||||
const index = new Index(cwd);
|
||||
const results = index.search(q);
|
||||
index.close();
|
||||
return results;
|
||||
});
|
||||
}
|
||||
23
tests/e2e.test.ts
Normal file
23
tests/e2e.test.ts
Normal file
@ -0,0 +1,23 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { mkdtempSync, rmSync, existsSync } from 'fs';
|
||||
import { tmpdir } from 'os';
|
||||
import { join } from 'path';
|
||||
import { execSync } from 'child_process';
|
||||
|
||||
describe('e2e', () => {
|
||||
let cwd: string;
|
||||
|
||||
beforeEach(() => {
|
||||
cwd = mkdtempSync(join(tmpdir(), 'agenthub-e2e-'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(cwd, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('initializes a project', () => {
|
||||
const bin = join(process.cwd(), 'bin', 'agenthub.js');
|
||||
execSync(`node ${bin} init --yes --project-name e2e-test`, { cwd });
|
||||
expect(existsSync(join(cwd, '.agenthub', 'agenthub.config.json'))).toBe(true);
|
||||
});
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user