Compare commits
No commits in common. "c073f9a213f3cf341a056183c6f6b54a4bc47dcb" and "22f3c7dda608cd30cc4a17bd8df2a2fce05ffb5d" have entirely different histories.
c073f9a213
...
22f3c7dda6
1
.gitignore
vendored
1
.gitignore
vendored
@ -4,4 +4,3 @@ dist/
|
||||
.DS_Store
|
||||
coverage/
|
||||
.agenthub/
|
||||
.worktrees/
|
||||
|
||||
24
README.md
24
README.md
@ -23,30 +23,6 @@ agenthub status --update
|
||||
- Codex CLI
|
||||
- Kimi Code CLI
|
||||
|
||||
## Network Mode
|
||||
|
||||
AgentHub can expose a project to other machines on the same network.
|
||||
|
||||
On the host machine (e.g. Mac):
|
||||
|
||||
```bash
|
||||
cd my-project
|
||||
agenthub init
|
||||
agenthub server start --host 0.0.0.0 --port 3377
|
||||
```
|
||||
|
||||
On another machine (e.g. Windows):
|
||||
|
||||
```powershell
|
||||
$env:AGENTHUB_SERVER="http://<mac-ip>:3377"
|
||||
agenthub task create --title "Windows task" --role implementer
|
||||
agenthub status
|
||||
```
|
||||
|
||||
Use `--server http://<ip>:3377` on each command instead of the environment variable if you prefer.
|
||||
|
||||
`init` always runs locally on the host machine.
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -1,167 +0,0 @@
|
||||
# AgentHub Network Mode — Design Spec
|
||||
|
||||
**Datum:** 2026-06-25
|
||||
**Status:** Entwurf – zur Freigabe
|
||||
**Ziel:** AgentHub für parallele Agenten-Sessions auf mehreren Geräten im gleichen lokalen Netzwerk verwendbar machen (z. B. Kimi auf Mac + Kimi auf Windows).
|
||||
|
||||
---
|
||||
|
||||
## 1. Zusammenfassung
|
||||
|
||||
AgentHub bleibt ein dateibasiertes Tool. Ein Gerät im Netzwerk (hier: der Mac) hostet das Projektverzeichnis inklusive `.agenthub/` und bietet einen optionalen Fastify-Server an. Andere Geräte (z. B. Windows) nutzen die CLI im Remote-Modus und sprechen den Server über HTTP an.
|
||||
|
||||
Lokaler Dateizugriff bleibt der Default. Remote-Modus ist opt-in über `--server <url>` oder die Umgebungsvariable `AGENTHUB_SERVER`.
|
||||
|
||||
---
|
||||
|
||||
## 2. Architektur
|
||||
|
||||
```text
|
||||
┌─────────────────────┐ HTTP ┌─────────────────────┐
|
||||
│ Kimi auf Windows │ ◄──────────────────► │ AgentHub Server │
|
||||
│ agenthub --server │ │ auf Mac (0.0.0.0) │
|
||||
│ http://mac:3377 │ │ │
|
||||
└─────────────────────┘ │ liest/schreibt │
|
||||
│ .agenthub/ auf Mac │
|
||||
│ (Dateien = SSOT) │
|
||||
└─────────────────────┘
|
||||
```
|
||||
|
||||
- **Single Source of Truth:** Die Markdown-Dateien auf dem Mac.
|
||||
- **Server:** Führt die gleichen Lese-/Schreiboperationen aus wie die lokale CLI und aktualisiert den SQLite-Index.
|
||||
- **Client (Remote-Modus):** Dünne HTTP-Schicht; keine eigene Geschäftslogik.
|
||||
|
||||
---
|
||||
|
||||
## 3. CLI-Änderungen
|
||||
|
||||
### Globaler `--server`-Flag
|
||||
|
||||
```bash
|
||||
agenthub --server http://192.168.1.42:3377 task list
|
||||
agenthub --server http://mac.local:3377 status --update
|
||||
```
|
||||
|
||||
Alternativ per Env-Variable:
|
||||
|
||||
```bash
|
||||
export AGENTHUB_SERVER=http://192.168.1.42:3377
|
||||
agenthub task create --title "Foo"
|
||||
```
|
||||
|
||||
### Ausgenommene Befehle
|
||||
|
||||
- `agenthub init` bleibt **immer lokal**. Das Projekt muss auf dem Host-Rechner initialisiert werden, bevor andere Geräte remote darauf zugreifen.
|
||||
|
||||
### Fehlerbehandlung
|
||||
|
||||
- Server nicht erreichbar → `"AgentHub server at http://... is not reachable. Is 'agenthub server start --host 0.0.0.0' running?"`
|
||||
- HTTP-Fehler → Statuscode + Server-Antwort ausgeben.
|
||||
- `--server` fehlt auf Windows → Hinweis, dass entweder `--server` gesetzt oder ein SMB-Share genutzt werden muss.
|
||||
|
||||
---
|
||||
|
||||
## 4. Server-Änderungen
|
||||
|
||||
### Start-Kommando
|
||||
|
||||
```bash
|
||||
agenthub server start --host 0.0.0.0 --port 3377
|
||||
```
|
||||
|
||||
- `--host` ist neu; Default: `127.0.0.1`.
|
||||
- `--port` bleibt wie gehabt; Default: `3377`.
|
||||
- Für LAN-Nutzung muss `--host 0.0.0.0` gesetzt werden.
|
||||
|
||||
### Endpoints
|
||||
|
||||
| Methode | Endpoint | Zweck |
|
||||
|---------|----------|-------|
|
||||
| `GET` | `/status` | Aktuellen Status zurückgeben |
|
||||
| `POST` | `/status/update` | Status neu generieren |
|
||||
| `GET` | `/tasks` | Tasks auflisten |
|
||||
| `POST` | `/tasks` | Task erstellen |
|
||||
| `GET` | `/tasks/:id` | Task anzeigen |
|
||||
| `PATCH` | `/tasks/:id` | Task aktualisieren (claim, done, status) |
|
||||
| `GET` | `/handoffs` | Handoffs auflisten |
|
||||
| `POST` | `/handoffs` | Handoff erstellen |
|
||||
| `GET` | `/handoffs/:id` | Handoff anzeigen |
|
||||
| `GET` | `/decisions` | Decisions auflisten |
|
||||
| `POST` | `/decisions` | Decision erstellen |
|
||||
| `GET` | `/memory` | Memory-Einträge auflisten |
|
||||
| `POST` | `/memory` | Memory-Eintrag erstellen |
|
||||
| `GET` | `/memory/search?q=...` | Volltextsuche |
|
||||
| `POST` | `/delegate` | Delegation vorschlagen/auto |
|
||||
|
||||
Alle Endpoints verwenden die bestehenden Core-Funktionen (`taskCreate`, `taskList`, etc.).
|
||||
|
||||
### Auth (MVP)
|
||||
|
||||
- Keine Authentifizierung im MVP.
|
||||
- Header-Struktur wird so gebaut, dass später `Authorization: Bearer <token>` einfach ergänzt werden kann.
|
||||
|
||||
---
|
||||
|
||||
## 5. Client-Modus
|
||||
|
||||
Jeder CLI-Befehl entscheidet anhand von `--server` / `AGENTHUB_SERVER`, ob er lokal arbeitet oder den `RemoteClient` nutzt.
|
||||
|
||||
Beispiel `task create`:
|
||||
|
||||
```typescript
|
||||
if (serverUrl) {
|
||||
await remoteClient.createTask(serverUrl, options);
|
||||
} else {
|
||||
await localTaskCreate(cwd, options);
|
||||
}
|
||||
```
|
||||
|
||||
Der `RemoteClient` ist eine kleine Wrapper-Klasse um `fetch`.
|
||||
|
||||
---
|
||||
|
||||
## 6. Tests
|
||||
|
||||
1. **Server-Routen-Tests:** Fastify-App direkt testen, ohne Port zu öffnen.
|
||||
2. **RemoteClient-Tests:** Client gegen einen gemockten Server testen.
|
||||
3. **E2E-Test:**
|
||||
- Temporäres Projekt auf dem Mac initialisieren.
|
||||
- Server starten (`--host 127.0.0.1` reicht für E2E).
|
||||
- CLI mit `--server http://127.0.0.1:<port>` aufrufen.
|
||||
- Prüfen, dass die Datei im Projektverzeichnis landet.
|
||||
|
||||
---
|
||||
|
||||
## 7. Beispiel-Workflow
|
||||
|
||||
**Auf dem Mac:**
|
||||
|
||||
```bash
|
||||
cd ~/my-project
|
||||
agenthub init --project-name my-project
|
||||
agenthub server start --host 0.0.0.0 --port 3377
|
||||
```
|
||||
|
||||
**Auf Windows:**
|
||||
|
||||
```bash
|
||||
$env:AGENTHUB_SERVER="http://192.168.1.42:3377"
|
||||
agenthub task create --title "Implement Windows bypass check" --role implementer
|
||||
agenthub status
|
||||
```
|
||||
|
||||
**Auf dem Mac kann parallel weiter lokal gearbeitet werden:**
|
||||
|
||||
```bash
|
||||
agenthub task list
|
||||
agenthub task done TSK-0001
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. Offene Punkte / Folgearbeiten
|
||||
|
||||
1. Auth/Token-Support für unsichere Netzwerke.
|
||||
2. Bonjour/mDNS-Autodiscovery des Servers.
|
||||
3. Konflikterkennung bei gleichzeitigen Schreibzugriffen (z. B. via ETags).
|
||||
4. HTTPS/SSL für Produktivnetzwerke.
|
||||
@ -1,22 +1,54 @@
|
||||
import { input } from '@inquirer/prompts';
|
||||
import { createDecision, listDecisions } from '../../core/services/decisionService.js';
|
||||
import type { Decision } from '../../core/schema.js';
|
||||
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 record = createDecision(cwd, { title, context, 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 decisions = listDecisions(cwd);
|
||||
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}`);
|
||||
}
|
||||
|
||||
@ -1,24 +1,39 @@
|
||||
import { loadConfig } from '../../core/config.js';
|
||||
import { suggestDelegation, autoDelegate } from '../../core/services/delegateService.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 suggestion = suggestDelegation(cwd);
|
||||
if (!suggestion) {
|
||||
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: ${suggestion.task.id} — ${suggestion.task.title}`);
|
||||
console.log(` Role: ${suggestion.role}`);
|
||||
console.log(` Preferred agent: ${suggestion.preferredAgent}`);
|
||||
console.log(` Task: ${task.id} — ${task.title}`);
|
||||
console.log(` Role: ${role}`);
|
||||
console.log(` Preferred agent: ${preferredAgent}`);
|
||||
|
||||
if (config.delegationMode === 'auto' || options.auto) {
|
||||
autoDelegate(cwd);
|
||||
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 ${suggestion.task.id}`);
|
||||
console.log(` agenthub handoff create --taskId ${task.id}`);
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,6 +1,10 @@
|
||||
import { input, select } from '@inquirer/prompts';
|
||||
import { createHandoff, listHandoffs, getHandoff } from '../../core/services/handoffService.js';
|
||||
import type { Handoff } from '../../core/schema.js';
|
||||
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'];
|
||||
|
||||
@ -11,7 +15,9 @@ export async function handoffCreate(cwd: string, options: Partial<Handoff> = {})
|
||||
const summary = options.summary ?? await input({ message: 'Summary:' });
|
||||
const context = options.context ?? await input({ message: 'Context:' });
|
||||
|
||||
const handoff = createHandoff(cwd, {
|
||||
const now = new Date().toISOString();
|
||||
const handoff: Handoff = HandoffSchema.parse({
|
||||
id: getNextId(cwd, 'handoff'),
|
||||
fromRole,
|
||||
toRole,
|
||||
fromAgent: options.fromAgent,
|
||||
@ -19,25 +25,46 @@ export async function handoffCreate(cwd: string, options: Partial<Handoff> = {})
|
||||
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 { handoff, body } = getHandoff(cwd, id);
|
||||
console.log(`# ${handoff.summary}`);
|
||||
console.log(`From: ${handoff.fromRole} → ${handoff.toRole}`);
|
||||
if (handoff.taskId) console.log(`Task: ${handoff.taskId}`);
|
||||
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 handoffs = listHandoffs(cwd);
|
||||
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,6 +1,9 @@
|
||||
import { input, select } from '@inquirer/prompts';
|
||||
import { addMemory, searchMemory, listMemory } from '../../core/services/memoryService.js';
|
||||
import type { Memory } from '../../core/schema.js';
|
||||
import { join } from 'path';
|
||||
import { getEntityDir } from '../../core/paths.js';
|
||||
import { writeEntity } from '../../core/files.js';
|
||||
import { MemorySchema, type Memory } from '../../core/schema.js';
|
||||
import { Index } from '../../core/index.js';
|
||||
|
||||
export async function memoryAdd(cwd: string, options: Partial<Memory> = {}): Promise<void> {
|
||||
const title = options.title ?? await input({ message: 'Memory title:' });
|
||||
@ -16,27 +19,61 @@ export async function memoryAdd(cwd: string, options: Partial<Memory> = {}): Pro
|
||||
});
|
||||
const content = options.content ?? await input({ message: 'Content:' });
|
||||
|
||||
const memory = addMemory(cwd, { title, category, content });
|
||||
const now = new Date().toISOString();
|
||||
const id = `MEM-${Date.now()}`;
|
||||
const memory: Memory = MemorySchema.parse({
|
||||
id,
|
||||
title,
|
||||
category,
|
||||
content,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
|
||||
const filePath = join(getEntityDir(cwd, 'memory'), `${id}.md`);
|
||||
writeEntity(filePath, memory, `# ${memory.title}\n\n${memory.content}`);
|
||||
|
||||
const index = new Index(cwd);
|
||||
index.upsert({
|
||||
id: memory.id,
|
||||
type: 'memory',
|
||||
title: memory.title,
|
||||
content: memory.content,
|
||||
filePath,
|
||||
createdAt: memory.createdAt,
|
||||
updatedAt: memory.updatedAt,
|
||||
tags: JSON.stringify(memory.tags),
|
||||
});
|
||||
index.close();
|
||||
|
||||
console.log(`Memory saved as ${memory.id}.`);
|
||||
}
|
||||
|
||||
export function memorySearch(cwd: string, query: string): void {
|
||||
const results = searchMemory(cwd, query);
|
||||
const index = new Index(cwd);
|
||||
const results = index.search(query);
|
||||
index.close();
|
||||
|
||||
if (results.length === 0) {
|
||||
console.log('No results found.');
|
||||
return;
|
||||
}
|
||||
|
||||
for (const r of results) {
|
||||
console.log(`[${r.type}] ${r.id}: ${r.title}`);
|
||||
}
|
||||
}
|
||||
|
||||
export function memoryList(cwd: string): void {
|
||||
const memories = listMemory(cwd);
|
||||
const index = new Index(cwd);
|
||||
const memories = index.list('memory');
|
||||
index.close();
|
||||
|
||||
if (memories.length === 0) {
|
||||
console.log('No memory entries found.');
|
||||
return;
|
||||
}
|
||||
|
||||
for (const m of memories) {
|
||||
console.log(`${m.id}: ${m.title}`);
|
||||
}
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { startServer } from '../../server/index.js';
|
||||
|
||||
export async function serverStart(cwd: string, options: { port: number; host: string }): Promise<void> {
|
||||
await startServer(cwd, options);
|
||||
export async function serverStart(cwd: string, options: { port?: number } = {}): Promise<void> {
|
||||
await startServer(cwd, options.port ?? 3377);
|
||||
}
|
||||
|
||||
@ -1,6 +1,14 @@
|
||||
import { getStatus, updateStatus } from '../../core/services/statusService.js';
|
||||
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 body = options.update ? updateStatus(cwd) : getStatus(cwd);
|
||||
const statusPath = getStatusPath(cwd);
|
||||
if (options.update || !existsSync(statusPath)) {
|
||||
generateStatus(cwd);
|
||||
}
|
||||
|
||||
const { body } = readEntity(statusPath);
|
||||
console.log(body);
|
||||
}
|
||||
|
||||
@ -1,6 +1,10 @@
|
||||
import { input, select } from '@inquirer/prompts';
|
||||
import { createTask, listTasks, getTask, claimTask, doneTask } from '../../core/services/taskService.js';
|
||||
import type { Task } from '../../core/schema.js';
|
||||
import { join } from 'path';
|
||||
import { getEntityDir } from '../../core/paths.js';
|
||||
import { getNextId } from '../../core/counter.js';
|
||||
import { readEntity, writeEntity } from '../../core/files.js';
|
||||
import { TaskSchema, type Task } from '../../core/schema.js';
|
||||
import { Index } from '../../core/index.js';
|
||||
|
||||
export async function taskCreate(cwd: string, options: Partial<Task> = {}): Promise<void> {
|
||||
const title = options.title ?? await input({ message: 'Task title:' });
|
||||
@ -15,34 +19,84 @@ export async function taskCreate(cwd: string, options: Partial<Task> = {}): Prom
|
||||
});
|
||||
const priority = options.priority ?? 'medium';
|
||||
|
||||
const task = createTask(cwd, { title, role, priority });
|
||||
const now = new Date().toISOString();
|
||||
const task: Task = TaskSchema.parse({
|
||||
id: getNextId(cwd, 'task'),
|
||||
title,
|
||||
description: options.description ?? '',
|
||||
status: 'open',
|
||||
priority,
|
||||
role,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
|
||||
const filePath = join(getEntityDir(cwd, 'tasks'), `${task.id}.md`);
|
||||
writeEntity(filePath, task, `# ${task.title}\n\n${task.description}`);
|
||||
|
||||
const index = new Index(cwd);
|
||||
index.upsert(toIndexEntry(task, filePath));
|
||||
index.close();
|
||||
|
||||
console.log(`Created ${task.id}: ${task.title}`);
|
||||
}
|
||||
|
||||
export function taskList(cwd: string, filters?: { status?: string; role?: string }): void {
|
||||
const tasks = listTasks(cwd, filters);
|
||||
const index = new Index(cwd);
|
||||
const tasks = index.list('task', filters);
|
||||
index.close();
|
||||
|
||||
if (tasks.length === 0) {
|
||||
console.log('No tasks found.');
|
||||
return;
|
||||
}
|
||||
|
||||
for (const t of tasks) {
|
||||
console.log(`${t.id} [${t.status}] (${t.role ?? 'unassigned'}) ${t.title}`);
|
||||
}
|
||||
}
|
||||
|
||||
export function taskShow(cwd: string, id: string): void {
|
||||
const { task, body } = getTask(cwd, id);
|
||||
console.log(`# ${task.title}`);
|
||||
console.log(`Status: ${task.status} | Role: ${task.role ?? '-'} | Priority: ${task.priority}`);
|
||||
const filePath = join(getEntityDir(cwd, 'tasks'), `${id}.md`);
|
||||
const { frontmatter, body } = readEntity(filePath);
|
||||
console.log(`# ${frontmatter.title}`);
|
||||
console.log(`Status: ${frontmatter.status} | Role: ${frontmatter.role ?? '-'} | Priority: ${frontmatter.priority}`);
|
||||
console.log('\n' + body);
|
||||
}
|
||||
|
||||
export function taskClaim(cwd: string, id: string, agentName: string): void {
|
||||
claimTask(cwd, id, agentName);
|
||||
updateTask(cwd, id, { status: 'in_progress', assignedTo: agentName });
|
||||
console.log(`${id} claimed by ${agentName}.`);
|
||||
}
|
||||
|
||||
export function taskDone(cwd: string, id: string): void {
|
||||
doneTask(cwd, id);
|
||||
updateTask(cwd, id, { status: 'done' });
|
||||
console.log(`${id} marked as done.`);
|
||||
}
|
||||
|
||||
function updateTask(cwd: string, id: string, patch: Partial<Task>): void {
|
||||
const filePath = join(getEntityDir(cwd, 'tasks'), `${id}.md`);
|
||||
const { frontmatter, body } = readEntity(filePath);
|
||||
const task = TaskSchema.parse({ ...frontmatter, ...patch, updatedAt: new Date().toISOString() });
|
||||
writeEntity(filePath, task, body);
|
||||
|
||||
const index = new Index(cwd);
|
||||
index.upsert(toIndexEntry(task, filePath));
|
||||
index.close();
|
||||
}
|
||||
|
||||
function toIndexEntry(task: Task, filePath: string) {
|
||||
return {
|
||||
id: task.id,
|
||||
type: 'task',
|
||||
title: task.title,
|
||||
content: `${task.description} ${task.tags.join(' ')}`,
|
||||
filePath,
|
||||
createdAt: task.createdAt,
|
||||
updatedAt: task.updatedAt,
|
||||
status: task.status,
|
||||
role: task.role,
|
||||
assignedTo: task.assignedTo,
|
||||
tags: JSON.stringify(task.tags),
|
||||
};
|
||||
}
|
||||
|
||||
236
src/cli/index.ts
236
src/cli/index.ts
@ -7,64 +7,24 @@ 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';
|
||||
import { remoteClient, RemoteError } from './remoteClient.js';
|
||||
|
||||
function getServerUrl(program: Command): string | undefined {
|
||||
return (program.opts().server as string | undefined) || process.env.AGENTHUB_SERVER;
|
||||
}
|
||||
|
||||
function remoteOnly(): never {
|
||||
console.error('Remote mode is not supported for this command. Run it locally or omit --server.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
async function runRemote(serverUrl: string, fn: () => Promise<void>): Promise<void> {
|
||||
try {
|
||||
await fn();
|
||||
} catch (err) {
|
||||
if (err instanceof RemoteError) {
|
||||
if (err.status === 0) {
|
||||
console.error(`AgentHub server at ${serverUrl} is not reachable. Is 'agenthub server start --host 0.0.0.0' running?`);
|
||||
} else {
|
||||
console.error(`AgentHub server error (${err.status}): ${err.message}`);
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
export function createProgram(cwd: string): Command {
|
||||
const program = new Command('agenthub')
|
||||
.description('Local coordination layer for AI coding agents')
|
||||
.version('0.1.0')
|
||||
.option('--server <url>', 'AgentHub server URL (env: AGENTHUB_SERVER)');
|
||||
.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) => {
|
||||
if (getServerUrl(program)) remoteOnly();
|
||||
init(cwd, options);
|
||||
});
|
||||
.action((options) => init(cwd, options));
|
||||
|
||||
program
|
||||
.command('status')
|
||||
.description('Show project status')
|
||||
.option('-u, --update', 'Regenerate status before showing')
|
||||
.action(async (options) => {
|
||||
const serverUrl = getServerUrl(program);
|
||||
if (serverUrl) {
|
||||
await runRemote(serverUrl, async () => {
|
||||
const body = options.update ? await remoteClient.updateStatus(serverUrl) : await remoteClient.getStatus(serverUrl);
|
||||
console.log(body);
|
||||
});
|
||||
} else {
|
||||
status(cwd, options);
|
||||
}
|
||||
});
|
||||
.action((options) => status(cwd, options));
|
||||
|
||||
const memoryCmd = new Command('memory').description('Manage memory entries');
|
||||
memoryCmd
|
||||
@ -73,47 +33,15 @@ export function createProgram(cwd: string): Command {
|
||||
.option('--title <title>', 'Title')
|
||||
.option('--category <category>', 'Category')
|
||||
.option('--content <content>', 'Content')
|
||||
.action(async (options) => {
|
||||
const serverUrl = getServerUrl(program);
|
||||
if (serverUrl) {
|
||||
await runRemote(serverUrl, async () => {
|
||||
const memory = await remoteClient.addMemory(serverUrl, options);
|
||||
console.log(`Memory saved as ${memory.id}.`);
|
||||
});
|
||||
} else {
|
||||
await memoryAdd(cwd, options);
|
||||
}
|
||||
});
|
||||
.action((options) => memoryAdd(cwd, options));
|
||||
memoryCmd
|
||||
.command('search <query>')
|
||||
.description('Search memory and tasks')
|
||||
.action(async (query) => {
|
||||
const serverUrl = getServerUrl(program);
|
||||
if (serverUrl) {
|
||||
await runRemote(serverUrl, async () => {
|
||||
const results = await remoteClient.searchMemory(serverUrl, query);
|
||||
if (results.length === 0) { console.log('No results found.'); return; }
|
||||
for (const r of results) console.log(`[${r.type}] ${r.id}: ${r.title}`);
|
||||
});
|
||||
} else {
|
||||
memorySearch(cwd, query);
|
||||
}
|
||||
});
|
||||
.action((query) => memorySearch(cwd, query));
|
||||
memoryCmd
|
||||
.command('list')
|
||||
.description('List memory entries')
|
||||
.action(async () => {
|
||||
const serverUrl = getServerUrl(program);
|
||||
if (serverUrl) {
|
||||
await runRemote(serverUrl, async () => {
|
||||
const memories = await remoteClient.listMemory(serverUrl);
|
||||
if (memories.length === 0) { console.log('No memory entries found.'); return; }
|
||||
for (const m of memories) console.log(`${m.id}: ${m.title}`);
|
||||
});
|
||||
} else {
|
||||
memoryList(cwd);
|
||||
}
|
||||
});
|
||||
.action(() => memoryList(cwd));
|
||||
program.addCommand(memoryCmd);
|
||||
|
||||
const taskCmd = new Command('task').description('Manage tasks');
|
||||
@ -123,79 +51,26 @@ export function createProgram(cwd: string): Command {
|
||||
.option('--title <title>', 'Title')
|
||||
.option('--role <role>', 'Role')
|
||||
.option('--priority <priority>', 'Priority')
|
||||
.action(async (options) => {
|
||||
const serverUrl = getServerUrl(program);
|
||||
if (serverUrl) {
|
||||
await runRemote(serverUrl, async () => {
|
||||
const task = await remoteClient.createTask(serverUrl, options);
|
||||
console.log(`Created ${task.id}: ${task.title}`);
|
||||
});
|
||||
} else {
|
||||
await taskCreate(cwd, options);
|
||||
}
|
||||
});
|
||||
.action((options) => taskCreate(cwd, options));
|
||||
taskCmd
|
||||
.command('list')
|
||||
.description('List tasks')
|
||||
.option('--status <status>', 'Filter by status')
|
||||
.option('--role <role>', 'Filter by role')
|
||||
.action(async (options) => {
|
||||
const serverUrl = getServerUrl(program);
|
||||
if (serverUrl) {
|
||||
await runRemote(serverUrl, async () => {
|
||||
const tasks = await remoteClient.listTasks(serverUrl, options);
|
||||
if (tasks.length === 0) { console.log('No tasks found.'); return; }
|
||||
for (const t of tasks) console.log(`${t.id} [${t.status}] (${t.role ?? 'unassigned'}) ${t.title}`);
|
||||
});
|
||||
} else {
|
||||
taskList(cwd, options);
|
||||
}
|
||||
});
|
||||
.action((options) => taskList(cwd, options));
|
||||
taskCmd
|
||||
.command('show <id>')
|
||||
.description('Show a task')
|
||||
.action(async (id) => {
|
||||
const serverUrl = getServerUrl(program);
|
||||
if (serverUrl) {
|
||||
await runRemote(serverUrl, async () => {
|
||||
const { task, body } = await remoteClient.getTask(serverUrl, id);
|
||||
console.log(`# ${task.title}`);
|
||||
console.log(`Status: ${task.status} | Role: ${task.role ?? '-'} | Priority: ${task.priority}`);
|
||||
console.log('\n' + body);
|
||||
});
|
||||
} else {
|
||||
taskShow(cwd, id);
|
||||
}
|
||||
});
|
||||
.action((id) => taskShow(cwd, id));
|
||||
taskCmd
|
||||
.command('claim <id>')
|
||||
.description('Claim a task')
|
||||
.requiredOption('--agent <agent>', 'Agent name')
|
||||
.action(async (id, options) => {
|
||||
const serverUrl = getServerUrl(program);
|
||||
if (serverUrl) {
|
||||
await runRemote(serverUrl, async () => {
|
||||
await remoteClient.claimTask(serverUrl, id, options.agent);
|
||||
console.log(`${id} claimed by ${options.agent}.`);
|
||||
});
|
||||
} else {
|
||||
taskClaim(cwd, id, options.agent);
|
||||
}
|
||||
});
|
||||
.action((id, options) => taskClaim(cwd, id, options.agent));
|
||||
taskCmd
|
||||
.command('done <id>')
|
||||
.description('Mark a task as done')
|
||||
.action(async (id) => {
|
||||
const serverUrl = getServerUrl(program);
|
||||
if (serverUrl) {
|
||||
await runRemote(serverUrl, async () => {
|
||||
await remoteClient.doneTask(serverUrl, id);
|
||||
console.log(`${id} marked as done.`);
|
||||
});
|
||||
} else {
|
||||
taskDone(cwd, id);
|
||||
}
|
||||
});
|
||||
.description('Mark task as done')
|
||||
.action((id) => taskDone(cwd, id));
|
||||
program.addCommand(taskCmd);
|
||||
|
||||
const handoffCmd = new Command('handoff').description('Manage handoffs');
|
||||
@ -207,49 +82,15 @@ export function createProgram(cwd: string): Command {
|
||||
.option('--taskId <id>', 'Related task id')
|
||||
.option('--summary <summary>', 'Summary')
|
||||
.option('--context <context>', 'Context')
|
||||
.action(async (options) => {
|
||||
const serverUrl = getServerUrl(program);
|
||||
if (serverUrl) {
|
||||
await runRemote(serverUrl, async () => {
|
||||
const handoff = await remoteClient.createHandoff(serverUrl, options);
|
||||
console.log(`Handoff created: ${handoff.id}`);
|
||||
});
|
||||
} else {
|
||||
await handoffCreate(cwd, options);
|
||||
}
|
||||
});
|
||||
.action((options) => handoffCreate(cwd, options));
|
||||
handoffCmd
|
||||
.command('read <id>')
|
||||
.description('Read a handoff')
|
||||
.action(async (id) => {
|
||||
const serverUrl = getServerUrl(program);
|
||||
if (serverUrl) {
|
||||
await runRemote(serverUrl, async () => {
|
||||
const { handoff, body } = await remoteClient.getHandoff(serverUrl, id);
|
||||
console.log(`# ${handoff.summary}`);
|
||||
console.log(`From: ${handoff.fromRole} → ${handoff.toRole}`);
|
||||
if (handoff.taskId) console.log(`Task: ${handoff.taskId}`);
|
||||
console.log('\n' + body);
|
||||
});
|
||||
} else {
|
||||
handoffRead(cwd, id);
|
||||
}
|
||||
});
|
||||
.action((id) => handoffRead(cwd, id));
|
||||
handoffCmd
|
||||
.command('list')
|
||||
.description('List handoffs')
|
||||
.action(async () => {
|
||||
const serverUrl = getServerUrl(program);
|
||||
if (serverUrl) {
|
||||
await runRemote(serverUrl, async () => {
|
||||
const handoffs = await remoteClient.listHandoffs(serverUrl);
|
||||
if (handoffs.length === 0) { console.log('No handoffs found.'); return; }
|
||||
for (const h of handoffs) console.log(`${h.id}: ${h.title}`);
|
||||
});
|
||||
} else {
|
||||
handoffList(cwd);
|
||||
}
|
||||
});
|
||||
.action(() => handoffList(cwd));
|
||||
program.addCommand(handoffCmd);
|
||||
|
||||
const decisionCmd = new Command('decision').description('Manage decisions');
|
||||
@ -259,64 +100,25 @@ export function createProgram(cwd: string): Command {
|
||||
.option('--title <title>', 'Title')
|
||||
.option('--context <context>', 'Context')
|
||||
.option('--decision <decision>', 'Decision')
|
||||
.action(async (options) => {
|
||||
const serverUrl = getServerUrl(program);
|
||||
if (serverUrl) {
|
||||
await runRemote(serverUrl, async () => {
|
||||
const decision = await remoteClient.createDecision(serverUrl, options);
|
||||
console.log(`Decision recorded: ${decision.id}`);
|
||||
});
|
||||
} else {
|
||||
await decisionCreate(cwd, options);
|
||||
}
|
||||
});
|
||||
.action((options) => decisionCreate(cwd, options));
|
||||
decisionCmd
|
||||
.command('list')
|
||||
.description('List decisions')
|
||||
.action(async () => {
|
||||
const serverUrl = getServerUrl(program);
|
||||
if (serverUrl) {
|
||||
await runRemote(serverUrl, async () => {
|
||||
const decisions = await remoteClient.listDecisions(serverUrl);
|
||||
if (decisions.length === 0) { console.log('No decisions found.'); return; }
|
||||
for (const d of decisions) console.log(`${d.id}: ${d.title}`);
|
||||
});
|
||||
} else {
|
||||
decisionList(cwd);
|
||||
}
|
||||
});
|
||||
.action(() => decisionList(cwd));
|
||||
program.addCommand(decisionCmd);
|
||||
|
||||
program
|
||||
.command('delegate')
|
||||
.description('Suggest or auto-delegate open tasks')
|
||||
.option('--auto', 'Create handoff automatically')
|
||||
.action(async (options) => {
|
||||
const serverUrl = getServerUrl(program);
|
||||
if (serverUrl) {
|
||||
await runRemote(serverUrl, async () => {
|
||||
const result = await remoteClient.delegate(serverUrl, options.auto ?? false);
|
||||
if (!result.suggestion) { console.log('No open tasks to delegate.'); return; }
|
||||
const s = result.suggestion;
|
||||
console.log('Suggested delegation:');
|
||||
console.log(` Task: ${s.task.id} — ${s.task.title}`);
|
||||
console.log(` Role: ${s.role}`);
|
||||
console.log(` Preferred agent: ${s.preferredAgent}`);
|
||||
if (result.handoff) console.log('Handoff created automatically.');
|
||||
else console.log('Run with --auto to create the handoff.');
|
||||
});
|
||||
} else {
|
||||
await delegate(cwd, options);
|
||||
}
|
||||
});
|
||||
.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')
|
||||
.option('-h, --host <host>', 'Host to bind to', '127.0.0.1')
|
||||
.action((options) => serverStart(cwd, { port: parseInt(options.port, 10), host: options.host }));
|
||||
.action((options) => serverStart(cwd, { port: parseInt(options.port, 10) }));
|
||||
program.addCommand(serverCmd);
|
||||
|
||||
return program;
|
||||
|
||||
@ -1,100 +0,0 @@
|
||||
import type { Task, Handoff, Decision, Memory } from '../core/schema.js';
|
||||
import type { IndexEntry } from '../core/index.js';
|
||||
|
||||
export class RemoteError extends Error {
|
||||
constructor(public status: number, message: string) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
||||
async function request<T>(baseUrl: string, method: string, path: string, body?: unknown): Promise<T> {
|
||||
const url = `${baseUrl}${path}`;
|
||||
let res: Response;
|
||||
try {
|
||||
res = await fetch(url, {
|
||||
method,
|
||||
headers: body ? { 'Content-Type': 'application/json' } : undefined,
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
throw new RemoteError(0, `Cannot reach AgentHub server at ${baseUrl}: ${message}`);
|
||||
}
|
||||
|
||||
const text = await res.text();
|
||||
if (!res.ok) {
|
||||
throw new RemoteError(res.status, text || `HTTP ${res.status}`);
|
||||
}
|
||||
if (!text) {
|
||||
throw new RemoteError(res.status, 'Empty response from server');
|
||||
}
|
||||
return JSON.parse(text) as T;
|
||||
}
|
||||
|
||||
export const remoteClient = {
|
||||
async getStatus(baseUrl: string): Promise<string> {
|
||||
return request<{ body: string }>(baseUrl, 'GET', '/status').then((r) => r.body);
|
||||
},
|
||||
|
||||
async updateStatus(baseUrl: string): Promise<string> {
|
||||
return request<{ body: string }>(baseUrl, 'POST', '/status/update').then((r) => r.body);
|
||||
},
|
||||
|
||||
async createTask(baseUrl: string, options: Partial<Task>): Promise<Task> {
|
||||
return request<Task>(baseUrl, 'POST', '/tasks', options);
|
||||
},
|
||||
|
||||
async listTasks(baseUrl: string, filters?: { status?: string; role?: string }): Promise<IndexEntry[]> {
|
||||
const params = new URLSearchParams((filters ?? {}) as Record<string, string>);
|
||||
const qs = params.toString();
|
||||
return request<IndexEntry[]>(baseUrl, 'GET', `/tasks${qs ? '?' + qs : ''}`);
|
||||
},
|
||||
|
||||
async getTask(baseUrl: string, id: string): Promise<{ task: Task; body: string }> {
|
||||
return request<{ task: Task; body: string }>(baseUrl, 'GET', `/tasks/${id}`);
|
||||
},
|
||||
|
||||
async claimTask(baseUrl: string, id: string, agentName: string): Promise<Task> {
|
||||
return request<Task>(baseUrl, 'PATCH', `/tasks/${id}`, { status: 'in_progress', assignedTo: agentName });
|
||||
},
|
||||
|
||||
async doneTask(baseUrl: string, id: string): Promise<Task> {
|
||||
return request<Task>(baseUrl, 'PATCH', `/tasks/${id}`, { status: 'done' });
|
||||
},
|
||||
|
||||
async createHandoff(baseUrl: string, options: Partial<Handoff>): Promise<Handoff> {
|
||||
return request<Handoff>(baseUrl, 'POST', '/handoffs', options);
|
||||
},
|
||||
|
||||
async listHandoffs(baseUrl: string): Promise<IndexEntry[]> {
|
||||
return request<IndexEntry[]>(baseUrl, 'GET', '/handoffs');
|
||||
},
|
||||
|
||||
async getHandoff(baseUrl: string, id: string): Promise<{ handoff: Handoff; body: string }> {
|
||||
return request<{ handoff: Handoff; body: string }>(baseUrl, 'GET', `/handoffs/${id}`);
|
||||
},
|
||||
|
||||
async createDecision(baseUrl: string, options: Partial<Decision>): Promise<Decision> {
|
||||
return request<Decision>(baseUrl, 'POST', '/decisions', options);
|
||||
},
|
||||
|
||||
async listDecisions(baseUrl: string): Promise<IndexEntry[]> {
|
||||
return request<IndexEntry[]>(baseUrl, 'GET', '/decisions');
|
||||
},
|
||||
|
||||
async addMemory(baseUrl: string, options: Partial<Memory>): Promise<Memory> {
|
||||
return request<Memory>(baseUrl, 'POST', '/memory', options);
|
||||
},
|
||||
|
||||
async searchMemory(baseUrl: string, query: string): Promise<IndexEntry[]> {
|
||||
return request<IndexEntry[]>(baseUrl, 'GET', `/memory/search?q=${encodeURIComponent(query)}`);
|
||||
},
|
||||
|
||||
async listMemory(baseUrl: string): Promise<IndexEntry[]> {
|
||||
return request<IndexEntry[]>(baseUrl, 'GET', '/memory');
|
||||
},
|
||||
|
||||
async delegate(baseUrl: string, auto: boolean): Promise<{ suggestion?: { task: IndexEntry; role: string; preferredAgent: string }; handoff?: Handoff }> {
|
||||
return request<{ suggestion?: { task: IndexEntry; role: string; preferredAgent: string }; handoff?: Handoff }>(baseUrl, 'POST', `/delegate?auto=${auto}`);
|
||||
},
|
||||
};
|
||||
@ -1,53 +0,0 @@
|
||||
import { join } from 'path';
|
||||
import { getEntityDir } from '../paths.js';
|
||||
import { getNextId } from '../counter.js';
|
||||
import { readEntity, writeEntity } from '../files.js';
|
||||
import { DecisionSchema, type Decision } from '../schema.js';
|
||||
import { Index } from '../index.js';
|
||||
|
||||
export function createDecision(cwd: string, options: Partial<Decision> = {}): Decision {
|
||||
const now = new Date().toISOString();
|
||||
const record: Decision = DecisionSchema.parse({
|
||||
id: getNextId(cwd, 'decision'),
|
||||
title: options.title ?? 'Decision',
|
||||
context: options.context ?? '',
|
||||
decision: options.decision ?? '',
|
||||
status: options.status ?? 'accepted',
|
||||
consequences: options.consequences ?? [],
|
||||
alternatives: options.alternatives ?? [],
|
||||
relatedDecisions: options.relatedDecisions ?? [],
|
||||
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();
|
||||
|
||||
return record;
|
||||
}
|
||||
|
||||
export function listDecisions(cwd: string): ReturnType<Index['list']> {
|
||||
const index = new Index(cwd);
|
||||
const decisions = index.list('decision');
|
||||
index.close();
|
||||
return decisions;
|
||||
}
|
||||
|
||||
export function getDecision(cwd: string, id: string): { decision: Decision; body: string; filePath: string } {
|
||||
if (!id) throw new Error('Decision ID is required');
|
||||
const filePath = join(getEntityDir(cwd, 'decisions'), `${id}.md`);
|
||||
const { frontmatter, body } = readEntity(filePath);
|
||||
return { decision: DecisionSchema.parse(frontmatter), body, filePath };
|
||||
}
|
||||
@ -1,38 +0,0 @@
|
||||
import { loadConfig } from '../config.js';
|
||||
import { Index } from '../index.js';
|
||||
import { createHandoff } from './handoffService.js';
|
||||
import type { Handoff } from '../schema.js';
|
||||
import type { IndexEntry } from '../index.js';
|
||||
|
||||
export interface DelegationSuggestion {
|
||||
task: IndexEntry;
|
||||
role: string;
|
||||
preferredAgent: string;
|
||||
}
|
||||
|
||||
export function suggestDelegation(cwd: string): DelegationSuggestion | null {
|
||||
const config = loadConfig(cwd);
|
||||
const index = new Index(cwd);
|
||||
const openTasks = index.list('task', { status: 'open' });
|
||||
index.close();
|
||||
|
||||
if (openTasks.length === 0) return null;
|
||||
|
||||
const task = openTasks[0];
|
||||
const role = task.role ?? 'implementer';
|
||||
const preferredAgent = config.roles[role]?.preferredAgent ?? 'codex';
|
||||
return { task, role, preferredAgent };
|
||||
}
|
||||
|
||||
export function autoDelegate(cwd: string): Handoff | null {
|
||||
const suggestion = suggestDelegation(cwd);
|
||||
if (!suggestion) return null;
|
||||
return createHandoff(cwd, {
|
||||
fromRole: 'user',
|
||||
toRole: suggestion.role,
|
||||
toAgent: suggestion.preferredAgent,
|
||||
taskId: suggestion.task.id,
|
||||
summary: `Delegate ${suggestion.task.id} to ${suggestion.role}`,
|
||||
context: `Task "${suggestion.task.title}" should be handled by ${suggestion.preferredAgent} in ${suggestion.role} role.`,
|
||||
});
|
||||
}
|
||||
@ -1,55 +0,0 @@
|
||||
import { join } from 'path';
|
||||
import { getEntityDir } from '../paths.js';
|
||||
import { getNextId } from '../counter.js';
|
||||
import { readEntity, writeEntity } from '../files.js';
|
||||
import { HandoffSchema, type Handoff } from '../schema.js';
|
||||
import { Index } from '../index.js';
|
||||
|
||||
export function createHandoff(cwd: string, options: Partial<Handoff> = {}): Handoff {
|
||||
const now = new Date().toISOString();
|
||||
const handoff: Handoff = HandoffSchema.parse({
|
||||
id: getNextId(cwd, 'handoff'),
|
||||
fromRole: options.fromRole ?? 'user',
|
||||
toRole: options.toRole ?? 'user',
|
||||
fromAgent: options.fromAgent,
|
||||
toAgent: options.toAgent,
|
||||
taskId: options.taskId,
|
||||
summary: options.summary ?? 'Handoff',
|
||||
context: options.context ?? '',
|
||||
decisions: options.decisions ?? [],
|
||||
openQuestions: options.openQuestions ?? [],
|
||||
nextSteps: options.nextSteps ?? [],
|
||||
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();
|
||||
|
||||
return handoff;
|
||||
}
|
||||
|
||||
export function listHandoffs(cwd: string): ReturnType<Index['list']> {
|
||||
const index = new Index(cwd);
|
||||
const handoffs = index.list('handoff');
|
||||
index.close();
|
||||
return handoffs;
|
||||
}
|
||||
|
||||
export function getHandoff(cwd: string, id: string): { handoff: Handoff; body: string; filePath: string } {
|
||||
if (!id) throw new Error('Handoff ID is required');
|
||||
const filePath = join(getEntityDir(cwd, 'handoffs'), `${id}.md`);
|
||||
const { frontmatter, body } = readEntity(filePath);
|
||||
return { handoff: HandoffSchema.parse(frontmatter), body, filePath };
|
||||
}
|
||||
@ -1,54 +0,0 @@
|
||||
import { join } from 'path';
|
||||
import { getEntityDir } from '../paths.js';
|
||||
import { getNextId } from '../counter.js';
|
||||
import { writeEntity } from '../files.js';
|
||||
import { MemorySchema, type Memory } from '../schema.js';
|
||||
import { Index } from '../index.js';
|
||||
|
||||
export function addMemory(cwd: string, options: Partial<Memory> = {}): Memory {
|
||||
const now = new Date().toISOString();
|
||||
const id = getNextId(cwd, 'memory');
|
||||
const memory: Memory = MemorySchema.parse({
|
||||
id,
|
||||
title: options.title ?? 'Memory',
|
||||
category: options.category ?? 'technical',
|
||||
content: options.content ?? '',
|
||||
tags: options.tags ?? [],
|
||||
relatedTasks: options.relatedTasks ?? [],
|
||||
relatedDecisions: options.relatedDecisions ?? [],
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
|
||||
const filePath = join(getEntityDir(cwd, 'memory'), `${memory.id}.md`);
|
||||
writeEntity(filePath, memory, `# ${memory.title}\n\n${memory.content}`);
|
||||
|
||||
const index = new Index(cwd);
|
||||
index.upsert({
|
||||
id: memory.id,
|
||||
type: 'memory',
|
||||
title: memory.title,
|
||||
content: memory.content,
|
||||
filePath,
|
||||
createdAt: memory.createdAt,
|
||||
updatedAt: memory.updatedAt,
|
||||
tags: JSON.stringify(memory.tags),
|
||||
});
|
||||
index.close();
|
||||
|
||||
return memory;
|
||||
}
|
||||
|
||||
export function searchMemory(cwd: string, query: string): ReturnType<Index['search']> {
|
||||
const index = new Index(cwd);
|
||||
const results = index.search(query);
|
||||
index.close();
|
||||
return results;
|
||||
}
|
||||
|
||||
export function listMemory(cwd: string): ReturnType<Index['list']> {
|
||||
const index = new Index(cwd);
|
||||
const memories = index.list('memory');
|
||||
index.close();
|
||||
return memories;
|
||||
}
|
||||
@ -1,18 +0,0 @@
|
||||
import { existsSync } from 'fs';
|
||||
import { generateStatus } from '../status.js';
|
||||
import { readEntity } from '../files.js';
|
||||
import { getStatusPath } from '../paths.js';
|
||||
|
||||
export function getStatus(cwd: string): string {
|
||||
const statusPath = getStatusPath(cwd);
|
||||
if (!existsSync(statusPath)) {
|
||||
generateStatus(cwd);
|
||||
}
|
||||
const { body } = readEntity(statusPath);
|
||||
return body;
|
||||
}
|
||||
|
||||
export function updateStatus(cwd: string): string {
|
||||
generateStatus(cwd);
|
||||
return getStatus(cwd);
|
||||
}
|
||||
@ -1,80 +0,0 @@
|
||||
import { join } from 'path';
|
||||
import { getEntityDir } from '../paths.js';
|
||||
import { getNextId } from '../counter.js';
|
||||
import { readEntity, writeEntity } from '../files.js';
|
||||
import { TaskSchema, type Task } from '../schema.js';
|
||||
import { Index } from '../index.js';
|
||||
|
||||
export function createTask(cwd: string, options: Partial<Task> = {}): Task {
|
||||
const now = new Date().toISOString();
|
||||
const task: Task = TaskSchema.parse({
|
||||
id: getNextId(cwd, 'task'),
|
||||
title: options.title ?? 'Untitled',
|
||||
description: options.description ?? '',
|
||||
status: 'open',
|
||||
priority: options.priority ?? 'medium',
|
||||
role: options.role,
|
||||
assignedTo: options.assignedTo,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
|
||||
const filePath = join(getEntityDir(cwd, 'tasks'), `${task.id}.md`);
|
||||
writeEntity(filePath, task, `# ${task.title}\n\n${task.description}`);
|
||||
|
||||
const index = new Index(cwd);
|
||||
index.upsert(toIndexEntry(task, filePath));
|
||||
index.close();
|
||||
|
||||
return task;
|
||||
}
|
||||
|
||||
export function listTasks(cwd: string, filters?: { status?: string; role?: string }): ReturnType<Index['list']> {
|
||||
const index = new Index(cwd);
|
||||
const tasks = index.list('task', filters);
|
||||
index.close();
|
||||
return tasks;
|
||||
}
|
||||
|
||||
export function getTask(cwd: string, id: string): { task: Task; body: string; filePath: string } {
|
||||
if (!id) throw new Error('Task ID is required');
|
||||
const filePath = join(getEntityDir(cwd, 'tasks'), `${id}.md`);
|
||||
const { frontmatter, body } = readEntity(filePath);
|
||||
return { task: TaskSchema.parse(frontmatter), body, filePath };
|
||||
}
|
||||
|
||||
export function updateTask(cwd: string, id: string, patch: Partial<Task>): Task {
|
||||
const { task, body, filePath } = getTask(cwd, id);
|
||||
const updated: Task = TaskSchema.parse({ ...task, ...patch, updatedAt: new Date().toISOString() });
|
||||
writeEntity(filePath, updated, body);
|
||||
|
||||
const index = new Index(cwd);
|
||||
index.upsert(toIndexEntry(updated, filePath));
|
||||
index.close();
|
||||
|
||||
return updated;
|
||||
}
|
||||
|
||||
export function claimTask(cwd: string, id: string, agentName: string): Task {
|
||||
return updateTask(cwd, id, { status: 'in_progress', assignedTo: agentName });
|
||||
}
|
||||
|
||||
export function doneTask(cwd: string, id: string): Task {
|
||||
return updateTask(cwd, id, { status: 'done' });
|
||||
}
|
||||
|
||||
function toIndexEntry(task: Task, filePath: string) {
|
||||
return {
|
||||
id: task.id,
|
||||
type: 'task',
|
||||
title: task.title,
|
||||
content: `${task.description} ${task.tags.join(' ')}`,
|
||||
filePath,
|
||||
createdAt: task.createdAt,
|
||||
updatedAt: task.updatedAt,
|
||||
status: task.status,
|
||||
role: task.role,
|
||||
assignedTo: task.assignedTo,
|
||||
tags: JSON.stringify(task.tags),
|
||||
};
|
||||
}
|
||||
@ -1,7 +1,7 @@
|
||||
import { writeFileSync, mkdirSync, existsSync } from 'fs';
|
||||
import { readFileSync, writeFileSync, mkdirSync, existsSync, readdirSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { getEntityDir, getStatusPath } from './paths.js';
|
||||
import { readEntity, writeEntity, listEntities } from './files.js';
|
||||
import { readEntity, writeEntity } from './files.js';
|
||||
import { StatusSchema } from './schema.js';
|
||||
|
||||
export function generateStatus(cwd: string) {
|
||||
@ -13,7 +13,7 @@ export function generateStatus(cwd: string) {
|
||||
const blockedTasks: string[] = [];
|
||||
|
||||
if (existsSync(taskDir)) {
|
||||
for (const file of listEntities(taskDir)) {
|
||||
for (const file of listMdFiles(taskDir)) {
|
||||
const { frontmatter } = readEntity(file);
|
||||
if (frontmatter.status === 'open' || frontmatter.status === 'in_progress') {
|
||||
activeTasks.push(String(frontmatter.id));
|
||||
@ -42,8 +42,15 @@ export function generateStatus(cwd: string) {
|
||||
return status;
|
||||
}
|
||||
|
||||
function listMdFiles(dir: string): string[] {
|
||||
if (!existsSync(dir)) return [];
|
||||
return readdirSync(dir)
|
||||
.filter((f) => f.endsWith('.md'))
|
||||
.map((f) => join(dir, f));
|
||||
}
|
||||
|
||||
function recentIds(dir: string, limit: number): string[] {
|
||||
return listEntities(dir)
|
||||
return listMdFiles(dir)
|
||||
.map((f) => ({ file: f, ...readEntity(f) }))
|
||||
.sort((a, b) => String(b.frontmatter.createdAt).localeCompare(String(a.frontmatter.createdAt)))
|
||||
.slice(0, limit)
|
||||
|
||||
@ -1,24 +1,13 @@
|
||||
import Fastify from 'fastify';
|
||||
import { registerRoutes } from './routes.js';
|
||||
|
||||
export function buildApp(cwd: string) {
|
||||
export async function startServer(cwd: string, port = 3377): Promise<void> {
|
||||
const app = Fastify({ logger: false });
|
||||
registerRoutes(app, cwd);
|
||||
return app;
|
||||
}
|
||||
|
||||
export async function startServer(cwd: string, options: { port?: number; host?: string } = {}): Promise<{ app: Fastify.FastifyInstance; url: string }> {
|
||||
const app = buildApp(cwd);
|
||||
const port = options.port ?? 3377;
|
||||
const host = options.host ?? '127.0.0.1';
|
||||
await registerRoutes(app, cwd);
|
||||
|
||||
try {
|
||||
await app.listen({ port, host });
|
||||
const address = app.server.address();
|
||||
const actualPort = typeof address === 'string' ? port : (address?.port ?? port);
|
||||
const url = `http://${host === '0.0.0.0' ? '127.0.0.1' : host}:${actualPort}`;
|
||||
console.log(`AgentHub server listening on ${url}`);
|
||||
return { app, url };
|
||||
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);
|
||||
|
||||
@ -1,110 +1,27 @@
|
||||
import { FastifyInstance, FastifyReply } from 'fastify';
|
||||
import { createTask, listTasks, getTask, claimTask, doneTask } from '../core/services/taskService.js';
|
||||
import { createHandoff, listHandoffs, getHandoff } from '../core/services/handoffService.js';
|
||||
import { createDecision, listDecisions } from '../core/services/decisionService.js';
|
||||
import { addMemory, searchMemory, listMemory } from '../core/services/memoryService.js';
|
||||
import { getStatus, updateStatus } from '../core/services/statusService.js';
|
||||
import { autoDelegate, suggestDelegation } from '../core/services/delegateService.js';
|
||||
import { loadConfig } from '../core/config.js';
|
||||
import type { Task, Handoff, Decision, Memory } from '../core/schema.js';
|
||||
|
||||
function notFound(reply: FastifyReply, resource: string) {
|
||||
return reply.status(404).send({ error: `${resource} not found` });
|
||||
}
|
||||
|
||||
function badRequest(reply: FastifyReply, message: string) {
|
||||
return reply.status(400).send({ error: message });
|
||||
}
|
||||
import { FastifyInstance } from 'fastify';
|
||||
import { Index } from '../core/index.js';
|
||||
|
||||
export async function registerRoutes(app: FastifyInstance, cwd: string): Promise<void> {
|
||||
app.get('/status', async () => ({ body: getStatus(cwd) }));
|
||||
app.post('/status/update', async () => ({ body: updateStatus(cwd) }));
|
||||
|
||||
app.get('/tasks', async (request) => {
|
||||
const { status, role } = request.query as { status?: string; role?: string };
|
||||
return listTasks(cwd, { status, role });
|
||||
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.post('/tasks', async (request, reply) => {
|
||||
try {
|
||||
return createTask(cwd, request.body as Partial<Task>);
|
||||
} catch (err) {
|
||||
return badRequest(reply, err instanceof Error ? err.message : 'Invalid task');
|
||||
}
|
||||
app.get('/tasks', async () => {
|
||||
const index = new Index(cwd);
|
||||
const tasks = index.list('task');
|
||||
index.close();
|
||||
return tasks;
|
||||
});
|
||||
|
||||
app.get('/tasks/:id', async (request, reply) => {
|
||||
const { id } = request.params as { id: string };
|
||||
try {
|
||||
const { task, body } = getTask(cwd, id);
|
||||
return { task, body };
|
||||
} catch {
|
||||
return notFound(reply, 'Task');
|
||||
}
|
||||
});
|
||||
|
||||
app.patch('/tasks/:id', async (request, reply) => {
|
||||
const { id } = request.params as { id: string };
|
||||
const patch = request.body as Partial<Task>;
|
||||
if (patch.status === 'in_progress' && patch.assignedTo) {
|
||||
return claimTask(cwd, id, patch.assignedTo);
|
||||
}
|
||||
if (patch.status === 'done') {
|
||||
return doneTask(cwd, id);
|
||||
}
|
||||
return badRequest(reply, 'Unsupported patch');
|
||||
});
|
||||
|
||||
app.get('/handoffs', async () => listHandoffs(cwd));
|
||||
app.post('/handoffs', async (request, reply) => {
|
||||
try {
|
||||
return createHandoff(cwd, request.body as Partial<Handoff>);
|
||||
} catch (err) {
|
||||
return badRequest(reply, err instanceof Error ? err.message : 'Invalid handoff');
|
||||
}
|
||||
});
|
||||
app.get('/handoffs/:id', async (request, reply) => {
|
||||
const { id } = request.params as { id: string };
|
||||
try {
|
||||
const { handoff, body } = getHandoff(cwd, id);
|
||||
return { handoff, body };
|
||||
} catch {
|
||||
return notFound(reply, 'Handoff');
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/decisions', async () => listDecisions(cwd));
|
||||
app.post('/decisions', async (request, reply) => {
|
||||
try {
|
||||
return createDecision(cwd, request.body as Partial<Decision>);
|
||||
} catch (err) {
|
||||
return badRequest(reply, err instanceof Error ? err.message : 'Invalid decision');
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/memory', async () => listMemory(cwd));
|
||||
app.post('/memory', async (request, reply) => {
|
||||
try {
|
||||
return addMemory(cwd, request.body as Partial<Memory>);
|
||||
} catch (err) {
|
||||
return badRequest(reply, err instanceof Error ? err.message : 'Invalid memory');
|
||||
}
|
||||
});
|
||||
app.get('/memory/search', async (request) => {
|
||||
app.get('/search', async (request) => {
|
||||
const { q } = request.query as { q: string };
|
||||
return searchMemory(cwd, q ?? '');
|
||||
});
|
||||
|
||||
app.post('/delegate', async (request) => {
|
||||
const { auto } = request.query as { auto?: string };
|
||||
const config = loadConfig(cwd);
|
||||
const suggestion = suggestDelegation(cwd);
|
||||
if (!suggestion) return { suggestion: null };
|
||||
const shouldAuto = auto === 'true' || config.delegationMode === 'auto';
|
||||
if (shouldAuto) {
|
||||
const handoff = autoDelegate(cwd);
|
||||
return { suggestion, handoff };
|
||||
}
|
||||
return { suggestion };
|
||||
const index = new Index(cwd);
|
||||
const results = index.search(q);
|
||||
index.close();
|
||||
return results;
|
||||
});
|
||||
}
|
||||
|
||||
@ -1,19 +0,0 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { mkdtempSync, rmSync } from 'fs';
|
||||
import { tmpdir } from 'os';
|
||||
import { join } from 'path';
|
||||
import { createDecision, listDecisions, getDecision } from '../src/core/services/decisionService.js';
|
||||
|
||||
describe('decisionService', () => {
|
||||
let cwd: string;
|
||||
|
||||
beforeEach(() => { cwd = mkdtempSync(join(tmpdir(), 'ah-dec-')); });
|
||||
afterEach(() => { rmSync(cwd, { recursive: true, force: true }); });
|
||||
|
||||
it('creates and reads a decision', () => {
|
||||
const d = createDecision(cwd, { title: 'Use SQLite', context: 'c', decision: 'd' });
|
||||
expect(d.id).toBe('DEC-0001');
|
||||
expect(getDecision(cwd, d.id).decision.title).toBe('Use SQLite');
|
||||
expect(listDecisions(cwd)).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
@ -1,46 +0,0 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { mkdtempSync, rmSync } from 'fs';
|
||||
import { tmpdir } from 'os';
|
||||
import { join } from 'path';
|
||||
import { startServer } from '../src/server/index.js';
|
||||
import { remoteClient, RemoteError } from '../src/cli/remoteClient.js';
|
||||
import { createProgram } from '../src/cli/index.js';
|
||||
import { init } from '../src/cli/commands/init.js';
|
||||
|
||||
describe('network e2e', () => {
|
||||
let cwd: string;
|
||||
let server: Awaited<ReturnType<typeof startServer>>;
|
||||
|
||||
beforeEach(async () => {
|
||||
cwd = mkdtempSync(join(tmpdir(), 'ah-net-'));
|
||||
await init(cwd, { projectName: 'net-test', yes: true });
|
||||
server = await startServer(cwd, { host: '127.0.0.1', port: 0 });
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await server.app.close();
|
||||
rmSync(cwd, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('creates a task remotely via CLI and reads it back', async () => {
|
||||
const program = createProgram(cwd);
|
||||
await program.parseAsync(['node', 'agenthub', '--server', server.url, 'task', 'create', '--title', 'Remote CLI', '--role', 'implementer']);
|
||||
|
||||
const { task } = await remoteClient.getTask(server.url, 'TSK-0001');
|
||||
expect(task.title).toBe('Remote CLI');
|
||||
});
|
||||
|
||||
it('updates status remotely via CLI', async () => {
|
||||
await remoteClient.createTask(server.url, { title: 'Remote', role: 'implementer' });
|
||||
|
||||
const program = createProgram(cwd);
|
||||
await program.parseAsync(['node', 'agenthub', '--server', server.url, 'status', '--update']);
|
||||
|
||||
const status = await remoteClient.getStatus(server.url);
|
||||
expect(status).toContain('Active tasks: 1');
|
||||
});
|
||||
|
||||
it('returns a meaningful error when the server is unreachable', async () => {
|
||||
await expect(remoteClient.listTasks('http://127.0.0.1:1')).rejects.toThrow(RemoteError);
|
||||
});
|
||||
});
|
||||
@ -1,19 +0,0 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { mkdtempSync, rmSync } from 'fs';
|
||||
import { tmpdir } from 'os';
|
||||
import { join } from 'path';
|
||||
import { createHandoff, listHandoffs, getHandoff } from '../src/core/services/handoffService.js';
|
||||
|
||||
describe('handoffService', () => {
|
||||
let cwd: string;
|
||||
|
||||
beforeEach(() => { cwd = mkdtempSync(join(tmpdir(), 'ah-hof-')); });
|
||||
afterEach(() => { rmSync(cwd, { recursive: true, force: true }); });
|
||||
|
||||
it('creates and reads a handoff', () => {
|
||||
const h = createHandoff(cwd, { fromRole: 'architect', toRole: 'implementer', summary: 's', context: 'c' });
|
||||
expect(h.id).toBe('HOF-0001');
|
||||
expect(getHandoff(cwd, h.id).handoff.summary).toBe('s');
|
||||
expect(listHandoffs(cwd)).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
@ -1,19 +0,0 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { mkdtempSync, rmSync } from 'fs';
|
||||
import { tmpdir } from 'os';
|
||||
import { join } from 'path';
|
||||
import { addMemory, searchMemory, listMemory } from '../src/core/services/memoryService.js';
|
||||
|
||||
describe('memoryService', () => {
|
||||
let cwd: string;
|
||||
|
||||
beforeEach(() => { cwd = mkdtempSync(join(tmpdir(), 'ah-mem-')); });
|
||||
afterEach(() => { rmSync(cwd, { recursive: true, force: true }); });
|
||||
|
||||
it('creates and searches memory', () => {
|
||||
const m = addMemory(cwd, { title: 'DNS cache', category: 'technical', content: 'Use TTL' });
|
||||
expect(m.id.startsWith('MEM-')).toBe(true);
|
||||
expect(searchMemory(cwd, 'TTL')).toHaveLength(1);
|
||||
expect(listMemory(cwd)).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
@ -1,31 +0,0 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { remoteClient, RemoteError } from '../src/cli/remoteClient.js';
|
||||
|
||||
describe('remoteClient', () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
globalThis.fetch = vi.fn();
|
||||
});
|
||||
|
||||
it('creates a task remotely', async () => {
|
||||
const task = { id: 'TSK-0001', title: 'Remote task', status: 'open' };
|
||||
vi.mocked(fetch).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
text: () => Promise.resolve(JSON.stringify(task)),
|
||||
} as Response);
|
||||
|
||||
const result = await remoteClient.createTask('http://localhost:3377', { title: 'Remote task' });
|
||||
expect(result.id).toBe('TSK-0001');
|
||||
});
|
||||
|
||||
it('throws RemoteError on failure', async () => {
|
||||
vi.mocked(fetch).mockResolvedValueOnce({
|
||||
ok: false,
|
||||
status: 500,
|
||||
text: () => Promise.resolve('boom'),
|
||||
} as Response);
|
||||
|
||||
await expect(remoteClient.listTasks('http://localhost:3377')).rejects.toThrow(RemoteError);
|
||||
});
|
||||
});
|
||||
@ -1,63 +0,0 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { mkdtempSync, rmSync } from 'fs';
|
||||
import { tmpdir } from 'os';
|
||||
import { join } from 'path';
|
||||
import { buildApp } from '../src/server/index.js';
|
||||
import { init } from '../src/cli/commands/init.js';
|
||||
|
||||
describe('server routes', () => {
|
||||
let cwd: string;
|
||||
let app: ReturnType<typeof buildApp>;
|
||||
|
||||
beforeEach(() => {
|
||||
cwd = mkdtempSync(join(tmpdir(), 'ah-server-'));
|
||||
init(cwd, { projectName: 'server-test', yes: true });
|
||||
app = buildApp(cwd);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(cwd, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('creates a task via POST /tasks', async () => {
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/tasks',
|
||||
payload: { title: 'API task', role: 'implementer' },
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
const task = JSON.parse(res.payload);
|
||||
expect(task.id).toBe('TSK-0001');
|
||||
});
|
||||
|
||||
it('lists tasks via GET /tasks', async () => {
|
||||
await app.inject({ method: 'POST', url: '/tasks', payload: { title: 'A', role: 'implementer' } });
|
||||
const res = await app.inject({ method: 'GET', url: '/tasks' });
|
||||
expect(JSON.parse(res.payload)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('returns 404 for unknown task', async () => {
|
||||
const res = await app.inject({ method: 'GET', url: '/tasks/TSK-9999' });
|
||||
expect(res.statusCode).toBe(404);
|
||||
});
|
||||
|
||||
it('returns 400 for unsupported patch', async () => {
|
||||
await app.inject({ method: 'POST', url: '/tasks', payload: { title: 'A', role: 'implementer' } });
|
||||
const res = await app.inject({ method: 'PATCH', url: '/tasks/TSK-0001', payload: { status: 'unknown' } });
|
||||
expect(res.statusCode).toBe(400);
|
||||
});
|
||||
|
||||
it('updates status via POST /status/update', async () => {
|
||||
await app.inject({ method: 'POST', url: '/tasks', payload: { title: 'A', role: 'implementer' } });
|
||||
const res = await app.inject({ method: 'POST', url: '/status/update' });
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(JSON.parse(res.payload).body).toContain('Active tasks: 1');
|
||||
});
|
||||
|
||||
it('searches memory via GET /memory/search', async () => {
|
||||
await app.inject({ method: 'POST', url: '/memory', payload: { title: 'DNS cache', category: 'technical', content: 'Use TTL' } });
|
||||
const res = await app.inject({ method: 'GET', url: '/memory/search?q=TTL' });
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(JSON.parse(res.payload)).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
@ -1,38 +0,0 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { mkdtempSync, rmSync } from 'fs';
|
||||
import { tmpdir } from 'os';
|
||||
import { join } from 'path';
|
||||
import { createTask, listTasks, getTask, claimTask, doneTask } from '../src/core/services/taskService.js';
|
||||
|
||||
describe('taskService', () => {
|
||||
let cwd: string;
|
||||
|
||||
beforeEach(() => {
|
||||
cwd = mkdtempSync(join(tmpdir(), 'ah-task-'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(cwd, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('creates a task', () => {
|
||||
const task = createTask(cwd, { title: 'Test', role: 'implementer' });
|
||||
expect(task.id).toBe('TSK-0001');
|
||||
expect(task.title).toBe('Test');
|
||||
expect(task.status).toBe('open');
|
||||
});
|
||||
|
||||
it('lists tasks', () => {
|
||||
createTask(cwd, { title: 'A', role: 'implementer' });
|
||||
expect(listTasks(cwd)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('claims and completes a task', () => {
|
||||
const task = createTask(cwd, { title: 'B', role: 'implementer' });
|
||||
const claimed = claimTask(cwd, task.id, 'codex');
|
||||
expect(claimed.status).toBe('in_progress');
|
||||
expect(claimed.assignedTo).toBe('codex');
|
||||
const done = doneTask(cwd, task.id);
|
||||
expect(done.status).toBe('done');
|
||||
});
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user