feat: review-gate (task review/reopen) + autonomous role guides
Implements the architect-controlled workflow: implementers submit work for review, only the architect closes tasks. - CLI: `agenthub task review <id>` (implementer submits → status review) and `agenthub task reopen <id>` (architect re-triggers → status open). Server already supported both statuses; now exposed on local + --server paths, with remoteClient.reviewTask/reopenTask. - templates: AGENTS.md + role guides rewritten so agents self-drive the CLI on open (hello → claim → implement → task review → wait), with the hard rule that ONLY the architect runs `task done`. Implementer guides (codex/kimi) never call done; architect guide documents done/reopen. - tests: review/reopen SSE events + formatEvent "Task review"/"Task reopened" lines. 113/113 green. Bump 0.2.0 -> 0.2.1. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
45070222d0
commit
353c139796
20
package.json
20
package.json
@ -1,11 +1,14 @@
|
|||||||
{
|
{
|
||||||
"name": "agenthub",
|
"name": "agenthub",
|
||||||
"version": "0.2.0",
|
"version": "0.2.1",
|
||||||
"description": "Local coordination layer for AI coding agents",
|
"description": "Local coordination layer for AI coding agents",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "./dist/index.js",
|
"main": "./dist/index.js",
|
||||||
"types": "./dist/index.d.ts",
|
"types": "./dist/index.d.ts",
|
||||||
"files": ["dist", "bin"],
|
"files": [
|
||||||
|
"dist",
|
||||||
|
"bin"
|
||||||
|
],
|
||||||
"bin": {
|
"bin": {
|
||||||
"agenthub": "./bin/agenthub.js"
|
"agenthub": "./bin/agenthub.js"
|
||||||
},
|
},
|
||||||
@ -32,9 +35,18 @@
|
|||||||
"typescript": "^5.7.0",
|
"typescript": "^5.7.0",
|
||||||
"vitest": "^3.0.0"
|
"vitest": "^3.0.0"
|
||||||
},
|
},
|
||||||
"keywords": ["ai", "agents", "claude", "codex", "kimi", "collaboration"],
|
"keywords": [
|
||||||
|
"ai",
|
||||||
|
"agents",
|
||||||
|
"claude",
|
||||||
|
"codex",
|
||||||
|
"kimi",
|
||||||
|
"collaboration"
|
||||||
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"pnpm": {
|
"pnpm": {
|
||||||
"onlyBuiltDependencies": ["better-sqlite3"]
|
"onlyBuiltDependencies": [
|
||||||
|
"better-sqlite3"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { input, select } from '@inquirer/prompts';
|
import { input, select } from '@inquirer/prompts';
|
||||||
import { createTask, listTasks, getTask, claimTask, doneTask } from '../../core/services/taskService.js';
|
import { createTask, listTasks, getTask, claimTask, doneTask, reviewTask, reopenTask } from '../../core/services/taskService.js';
|
||||||
import type { Task } from '../../core/schema.js';
|
import type { Task } from '../../core/schema.js';
|
||||||
|
|
||||||
export async function taskCreate(cwd: string, options: Partial<Task> = {}): Promise<void> {
|
export async function taskCreate(cwd: string, options: Partial<Task> = {}): Promise<void> {
|
||||||
@ -54,3 +54,13 @@ export function taskDone(
|
|||||||
});
|
});
|
||||||
console.log(`AgentHub: Task done ${id}`);
|
console.log(`AgentHub: Task done ${id}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function taskReview(cwd: string, id: string): void {
|
||||||
|
reviewTask(cwd, id);
|
||||||
|
console.log(`AgentHub: Task review ${id} (awaiting architect review)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function taskReopen(cwd: string, id: string): void {
|
||||||
|
reopenTask(cwd, id);
|
||||||
|
console.log(`AgentHub: Task reopened ${id}`);
|
||||||
|
}
|
||||||
|
|||||||
@ -2,7 +2,7 @@ import { Command } from 'commander';
|
|||||||
import { init } from './commands/init.js';
|
import { init } from './commands/init.js';
|
||||||
import { status } from './commands/status.js';
|
import { status } from './commands/status.js';
|
||||||
import { memoryAdd, memorySearch, memoryList, type MemoryAddOptions } from './commands/memory.js';
|
import { memoryAdd, memorySearch, memoryList, type MemoryAddOptions } from './commands/memory.js';
|
||||||
import { taskCreate, taskList, taskShow, taskClaim, taskDone } from './commands/task.js';
|
import { taskCreate, taskList, taskShow, taskClaim, taskDone, taskReview, taskReopen } from './commands/task.js';
|
||||||
import { handoffCreate, handoffRead, handoffList } from './commands/handoff.js';
|
import { handoffCreate, handoffRead, handoffList } from './commands/handoff.js';
|
||||||
import { decisionCreate, decisionList } from './commands/decision.js';
|
import { decisionCreate, decisionList } from './commands/decision.js';
|
||||||
import { delegate } from './commands/delegate.js';
|
import { delegate } from './commands/delegate.js';
|
||||||
@ -112,7 +112,7 @@ async function runRemote(serverUrl: string, fn: () => Promise<void>): Promise<vo
|
|||||||
export function createProgram(cwd: string): Command {
|
export function createProgram(cwd: string): Command {
|
||||||
const program = new Command('agenthub')
|
const program = new Command('agenthub')
|
||||||
.description('Local coordination layer for AI coding agents')
|
.description('Local coordination layer for AI coding agents')
|
||||||
.version('0.2.0')
|
.version('0.2.1')
|
||||||
.option('--server <url>', 'AgentHub server URL (env: AGENTHUB_SERVER)');
|
.option('--server <url>', 'AgentHub server URL (env: AGENTHUB_SERVER)');
|
||||||
|
|
||||||
program
|
program
|
||||||
@ -315,6 +315,34 @@ export function createProgram(cwd: string): Command {
|
|||||||
taskDone(projectCwd, id, meta);
|
taskDone(projectCwd, id, meta);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
taskCmd
|
||||||
|
.command('review <id>')
|
||||||
|
.description('Submit a task for architect review (implementer: use this instead of done)')
|
||||||
|
.action(async (id) => {
|
||||||
|
const { serverUrl, projectCwd } = await resolveContext(program, cwd);
|
||||||
|
if (serverUrl) {
|
||||||
|
await runRemote(serverUrl, async () => {
|
||||||
|
await remoteClient.reviewTask(serverUrl, id);
|
||||||
|
console.log(`AgentHub: Task review ${id} (awaiting architect review)`);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
taskReview(projectCwd, id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
taskCmd
|
||||||
|
.command('reopen <id>')
|
||||||
|
.description('Re-trigger a task after review (architect: send back to implementer)')
|
||||||
|
.action(async (id) => {
|
||||||
|
const { serverUrl, projectCwd } = await resolveContext(program, cwd);
|
||||||
|
if (serverUrl) {
|
||||||
|
await runRemote(serverUrl, async () => {
|
||||||
|
await remoteClient.reopenTask(serverUrl, id);
|
||||||
|
console.log(`AgentHub: Task reopened ${id}`);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
taskReopen(projectCwd, id);
|
||||||
|
}
|
||||||
|
});
|
||||||
program.addCommand(taskCmd);
|
program.addCommand(taskCmd);
|
||||||
|
|
||||||
const handoffCmd = new Command('handoff').description('Manage handoffs');
|
const handoffCmd = new Command('handoff').description('Manage handoffs');
|
||||||
|
|||||||
@ -75,6 +75,14 @@ export const remoteClient = {
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async reviewTask(baseUrl: string, id: string): Promise<Task> {
|
||||||
|
return request<Task>(baseUrl, 'PATCH', `/tasks/${id}`, { status: 'review' });
|
||||||
|
},
|
||||||
|
|
||||||
|
async reopenTask(baseUrl: string, id: string): Promise<Task> {
|
||||||
|
return request<Task>(baseUrl, 'PATCH', `/tasks/${id}`, { status: 'open' });
|
||||||
|
},
|
||||||
|
|
||||||
async getTaskActivity(baseUrl: string, id: string): Promise<ActivityItem[]> {
|
async getTaskActivity(baseUrl: string, id: string): Promise<ActivityItem[]> {
|
||||||
return request<ActivityItem[]>(baseUrl, 'GET', `/tasks/${id}/activity`);
|
return request<ActivityItem[]>(baseUrl, 'GET', `/tasks/${id}/activity`);
|
||||||
},
|
},
|
||||||
|
|||||||
@ -1,58 +1,67 @@
|
|||||||
export function agentsMd(projectName: string): string {
|
export function agentsMd(projectName: string): string {
|
||||||
return `# AgentHub Instructions — ${projectName}
|
return `# AgentHub — ${projectName}
|
||||||
|
|
||||||
This project uses AgentHub for shared team memory.
|
This project coordinates multiple AI agents via AgentHub. The hub server runs on
|
||||||
|
the team's hub machine; you connect **automatically** (the project config's
|
||||||
|
\`serverUrl\`, or LAN auto-discovery) — no manual setup, no \`--server\` flag.
|
||||||
|
|
||||||
Before you start working:
|
## Golden rules (every agent)
|
||||||
1. Read \`.agenthub/status/latest.md\`.
|
1. Announce yourself on start: \`agenthub hello --agent <you> --role <your-role>\`.
|
||||||
2. Check \`.agenthub/tasks/\` for open tasks assigned to your role.
|
2. Read \`.agenthub/status/latest.md\` and the handoff(s) for your task first.
|
||||||
3. Read any handoff files relevant to your current task.
|
3. **Only the architect marks a task \`done\`.** Implementers submit finished work
|
||||||
|
with \`agenthub task review <id>\` — never \`agenthub task done\`.
|
||||||
|
4. Report results as memory: \`agenthub memory add --title "<TSK> result" --content "…"\`.
|
||||||
|
5. Never delete or overwrite files in \`.agenthub/\` unless explicitly asked.
|
||||||
|
|
||||||
While working:
|
Drive the AgentHub CLI yourself — the human does not type these commands for you.
|
||||||
- Create decision records for architectural or technical choices (\`agenthub decision create\`).
|
|
||||||
- Update task status (\`agenthub task done TSK-0001\`).
|
|
||||||
|
|
||||||
When handing off:
|
|
||||||
- Create a handoff file (\`agenthub handoff create\`).
|
|
||||||
- Include context, open questions, and next steps.
|
|
||||||
|
|
||||||
Do not delete or overwrite files in \`.agenthub/\` unless explicitly asked.
|
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function claudeMd(): string {
|
export function claudeMd(): string {
|
||||||
return `# Claude Code — Team Role
|
return `# Claude Code — AgentHub role: architect
|
||||||
|
|
||||||
You are part of the AgentHub team in this project.
|
Read \`AGENTS.md\` first. You are the **architect**: you delegate work and you hold
|
||||||
Your default roles: architect, reviewer.
|
final say. Implementers cannot close their own tasks.
|
||||||
|
|
||||||
Read \`AGENTS.md\` and \`.agenthub/status/latest.md\` before making decisions.
|
On start: \`agenthub hello --agent <you> --role architect\`, then \`agenthub watch\`.
|
||||||
Use \`agenthub decision create\` for architecture choices.
|
|
||||||
Use \`agenthub handoff create\` when passing work to the implementer.
|
- **Delegate:** \`agenthub task create --role implementer …\` + \`agenthub handoff create
|
||||||
|
--toRole implementer …\` with scope + acceptance criteria.
|
||||||
|
- **Review gate — only you close tasks.** When the stream shows
|
||||||
|
\`AgentHub: Task review … by <agent>\`, inspect the work + its memory, then:
|
||||||
|
- Satisfied → \`agenthub task done <id>\`.
|
||||||
|
- Not satisfied → \`agenthub task reopen <id>\` + a feedback handoff (what to fix).
|
||||||
|
- Record architecture choices: \`agenthub decision create\`.
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function implementerMd(cliName: string, agentName: string, roles: string): string {
|
||||||
|
return `# ${cliName} — AgentHub roles: ${roles}
|
||||||
|
|
||||||
|
Read \`AGENTS.md\` first. As an **implementer**, drive this loop yourself on open —
|
||||||
|
the human does not type AgentHub commands for you:
|
||||||
|
|
||||||
|
1. Announce: \`agenthub hello --agent ${agentName} --role implementer\`
|
||||||
|
2. Find your task: \`agenthub task list --role implementer --status open\`
|
||||||
|
→ pick the one addressed to you (title / handoff), then
|
||||||
|
\`agenthub task claim <id> --agent ${agentName}\`
|
||||||
|
3. Read its handoff: \`agenthub handoff read <HOF-id>\`
|
||||||
|
4. Implement it.
|
||||||
|
5. **Submit for review (NOT done):** \`agenthub task review <id>\`
|
||||||
|
and report: \`agenthub memory add --title "<id> result" --category implementation
|
||||||
|
--content "<what you did / how to verify it>"\`
|
||||||
|
6. Wait for the architect's verdict. If the task is **reopened** (status back to
|
||||||
|
\`open\`), read the new feedback handoff, address it, and \`agenthub task review <id>\`
|
||||||
|
again.
|
||||||
|
|
||||||
|
⚠️ NEVER run \`agenthub task done\` — only the architect approves and closes tasks.
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function codexMd(): string {
|
export function codexMd(): string {
|
||||||
return `# Codex CLI — Team Role
|
return implementerMd('Codex CLI', 'codex', 'implementer, tester');
|
||||||
|
|
||||||
You are part of the AgentHub team in this project.
|
|
||||||
Your default roles: implementer, tester.
|
|
||||||
|
|
||||||
Read \`AGENTS.md\` and \`.agenthub/status/latest.md\` before implementing.
|
|
||||||
Use \`agenthub task claim TSK-0001\` to reserve a task.
|
|
||||||
Use \`agenthub task done TSK-0001\` when implementation is complete.
|
|
||||||
Use \`agenthub handoff create\` when passing work to the reviewer.
|
|
||||||
`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function kimiMd(): string {
|
export function kimiMd(): string {
|
||||||
return `# Kimi Code CLI — Team Role
|
return implementerMd('Kimi Code CLI', 'kimi', 'implementer');
|
||||||
|
|
||||||
You are part of the AgentHub team in this project.
|
|
||||||
Your default roles: architect, implementer, reviewer.
|
|
||||||
|
|
||||||
Read \`AGENTS.md\` and \`.agenthub/status/latest.md\` before making decisions.
|
|
||||||
Use \`agenthub decision create\` for architecture choices.
|
|
||||||
Use \`agenthub handoff create\` when passing work to another agent.
|
|
||||||
`;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -131,6 +131,16 @@ describe('formatEvent (AgentHub-branded)', () => {
|
|||||||
const ev: AgentHubEvent = { type: 'agent', action: 'joined', id: 'kimi' };
|
const ev: AgentHubEvent = { type: 'agent', action: 'joined', id: 'kimi' };
|
||||||
expect(formatEvent(ev)).toBe('AgentHub: kimi joined');
|
expect(formatEvent(ev)).toBe('AgentHub: kimi joined');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('formats a review submission as "Task review <id> by <agent>"', () => {
|
||||||
|
const ev: AgentHubEvent = { type: 'task', action: 'updated', id: 'TSK-0019', status: 'review', assignedTo: 'codex' };
|
||||||
|
expect(formatEvent(ev)).toMatch(/^AgentHub: Task review\s+TSK-0019\s+by codex$/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('formats a reopened task as "Task reopened <id>"', () => {
|
||||||
|
const ev: AgentHubEvent = { type: 'task', action: 'updated', id: 'TSK-0019', status: 'open' };
|
||||||
|
expect(formatEvent(ev)).toMatch(/^AgentHub: Task reopened\s+TSK-0019$/);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// ─── 3. Integration: eventBus fires on REST mutations ────────────────────────
|
// ─── 3. Integration: eventBus fires on REST mutations ────────────────────────
|
||||||
@ -178,6 +188,23 @@ describe('eventBus mutations', () => {
|
|||||||
expect(collected[0]).toMatchObject({ type: 'task', action: 'updated', status: 'in_progress', assignedTo: 'mac-claude' });
|
expect(collected[0]).toMatchObject({ type: 'task', action: 'updated', status: 'in_progress', assignedTo: 'mac-claude' });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('emits task/updated review when an implementer submits for review', async () => {
|
||||||
|
await app.inject({ method: 'POST', url: '/tasks', payload: { title: 'T', role: 'implementer' } });
|
||||||
|
collected.length = 0;
|
||||||
|
await app.inject({ method: 'PATCH', url: '/tasks/TSK-0001', payload: { status: 'review' } });
|
||||||
|
expect(collected).toHaveLength(1);
|
||||||
|
expect(collected[0]).toMatchObject({ type: 'task', action: 'updated', status: 'review' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('emits task/updated open when the architect reopens after review', async () => {
|
||||||
|
await app.inject({ method: 'POST', url: '/tasks', payload: { title: 'T', role: 'implementer' } });
|
||||||
|
await app.inject({ method: 'PATCH', url: '/tasks/TSK-0001', payload: { status: 'review' } });
|
||||||
|
collected.length = 0;
|
||||||
|
await app.inject({ method: 'PATCH', url: '/tasks/TSK-0001', payload: { status: 'open' } });
|
||||||
|
expect(collected).toHaveLength(1);
|
||||||
|
expect(collected[0]).toMatchObject({ type: 'task', action: 'updated', status: 'open' });
|
||||||
|
});
|
||||||
|
|
||||||
it('emits handoff/created when POST /handoffs succeeds', async () => {
|
it('emits handoff/created when POST /handoffs succeeds', async () => {
|
||||||
await app.inject({
|
await app.inject({
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user