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:
chahinebrini 2026-06-27 16:48:42 +02:00
parent 45070222d0
commit 353c139796
6 changed files with 141 additions and 47 deletions

View File

@ -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"
]
} }
} }

View File

@ -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}`);
}

View File

@ -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');

View File

@ -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`);
}, },

View File

@ -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.
`;
} }

View File

@ -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',