diff --git a/package.json b/package.json index de05337..9c5bba9 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "agenthub", - "version": "0.2.2", + "version": "0.3.0", "description": "Local coordination layer for AI coding agents", "type": "module", "main": "./dist/index.js", diff --git a/src/cli/commands/start.ts b/src/cli/commands/start.ts new file mode 100644 index 0000000..4e74a9f --- /dev/null +++ b/src/cli/commands/start.ts @@ -0,0 +1,99 @@ +import { remoteClient } from '../remoteClient.js'; +import { + listTasks as svcListTasks, + getTask as svcGetTask, + claimTask as svcClaimTask, +} from '../../core/services/taskService.js'; +import { listHandoffs as svcListHandoffs, getHandoff as svcGetHandoff } from '../../core/services/handoffService.js'; + +/** + * `agenthub start --agent --role ` — one-command onboarding for an + * agent. Announces presence, claims the open task addressed to this agent, and + * prints the task body + its handoff + the next step, so the agent can begin + * immediately without the human stitching commands together. + * + * "Addressed to " = task title starts with ":" (the delegation + * convention) OR a handoff for that task has toAgent === . + */ +export async function startAgent(opts: { + serverUrl?: string; + projectCwd: string; + agent: string; + role: string; +}): Promise { + const { serverUrl, projectCwd, agent, role } = opts; + const a = agent.toLowerCase(); + + // 1. Announce (presence is best-effort — never block onboarding on it). + if (serverUrl) { + try { + await remoteClient.announce(serverUrl, agent, role); + } catch { + /* ignore */ + } + } + console.log(`AgentHub: ${agent} joined (${role})`); + + // 2. Gather open role tasks + handoffs (handoffs carry taskId + toAgent). + const tasks = serverUrl + ? await remoteClient.listTasks(serverUrl, { role, status: 'open' }) + : svcListTasks(projectCwd, { role, status: 'open' }); + const handoffs = serverUrl ? await remoteClient.listHandoffs(serverUrl) : svcListHandoffs(projectCwd); + + const addressedByHandoff = new Set( + handoffs + .filter((h) => h.toAgent && String(h.toAgent).toLowerCase() === a && h.taskId) + .map((h) => String(h.taskId)), + ); + const mine = tasks.filter( + (t) => (t.title ?? '').toLowerCase().startsWith(`${a}:`) || addressedByHandoff.has(t.id), + ); + + // 3. Nothing addressed → guide the agent instead of guessing. + if (mine.length === 0) { + if (tasks.length === 0) { + console.log(`AgentHub: no open ${role} tasks — waiting for the architect to delegate.`); + } else { + console.log(`AgentHub: no task addressed to ${agent}. Open ${role} tasks:`); + for (const t of tasks) console.log(` ${t.id} ${t.title ?? ''}`); + console.log(`→ claim one yourself: agenthub task claim --agent ${agent}`); + } + return; + } + + // 4. Claim the addressed task. + const task = mine[0]; + if (serverUrl) await remoteClient.claimTask(serverUrl, task.id, agent); + else svcClaimTask(projectCwd, task.id, agent); + console.log(`AgentHub: Task claimed ${task.id} ${task.title ?? ''}`); + + // 5. Print the task body. + try { + const detail = serverUrl ? await remoteClient.getTask(serverUrl, task.id) : svcGetTask(projectCwd, task.id); + if (detail.body && detail.body.trim()) { + console.log(`\n─ Task ${task.id} ──────────────`); + console.log(detail.body.trim()); + } + } catch { + /* body is optional */ + } + + // 6. Print the handoff for this task (scope + acceptance criteria). + const hof = handoffs.find((h) => h.taskId === task.id); + if (hof) { + try { + const hd = serverUrl ? await remoteClient.getHandoff(serverUrl, hof.id) : svcGetHandoff(projectCwd, hof.id); + console.log(`\n─ Handoff ${hof.id} ──────────────`); + console.log(hd.handoff.summary); + if (hd.body && hd.body.trim()) console.log(hd.body.trim()); + } catch { + /* handoff is optional */ + } + } + + // 7. Next steps — the review gate, spelled out. + console.log(`\n─ Next ──────────────`); + console.log(`Implement the task, then submit for review: agenthub task review ${task.id}`); + console.log(`Report what you did: agenthub memory add --title "${task.id} result" --category implementation --content "…"`); + console.log(`Only the architect closes a task (\`done\`). If reopened, address the feedback and review again.`); +} diff --git a/src/cli/index.ts b/src/cli/index.ts index b6ae076..255119d 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -9,6 +9,7 @@ import { delegate } from './commands/delegate.js'; import { serverStart } from './commands/server.js'; import { update } from './commands/update.js'; import { watchEvents } from './commands/watch.js'; +import { startAgent } from './commands/start.js'; import { loadConfig, saveConfig } from '../core/config.js'; import { findProjectRoot } from '../core/paths.js'; import { discoverServer } from '../discovery.js'; @@ -112,7 +113,7 @@ async function runRemote(serverUrl: string, fn: () => Promise): Promise', 'AgentHub server URL (env: AGENTHUB_SERVER)'); program @@ -484,6 +485,21 @@ export function createProgram(cwd: string): Command { } }); + // ─── start (onboarding) ──────────────────────────────────────────────────── + program + .command('start') + .description('Onboard: announce, claim the task addressed to you, print it + its handoff') + .requiredOption('--agent ', 'Agent name') + .option('--role ', 'Role (default: implementer)', 'implementer') + .action(async (options) => { + const { serverUrl, projectCwd } = await resolveContext(program, cwd); + if (serverUrl) { + await runRemote(serverUrl, () => startAgent({ serverUrl, projectCwd, agent: options.agent, role: options.role })); + } else { + await startAgent({ projectCwd, agent: options.agent, role: options.role }); + } + }); + // ─── watch ─────────────────────────────────────────────────────────────── program .command('watch') diff --git a/src/core/templates.ts b/src/core/templates.ts index 4aaa88f..a55a7b9 100644 --- a/src/core/templates.ts +++ b/src/core/templates.ts @@ -5,8 +5,13 @@ 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. +## Start here +**If you are an implementer, RUN this on your first turn (don't just read it):** +\`agenthub start --agent --role implementer\` — it announces you, claims the +task addressed to you, and prints it + its handoff so you can begin immediately. + ## Golden rules (every agent) -1. Announce yourself on start: \`agenthub hello --agent --role \`. +1. Announce yourself on start (\`agenthub start …\`, or \`agenthub hello --agent --role \`). 2. Read \`.agenthub/status/latest.md\` and the handoff(s) for your task first. 3. **Only the architect marks a task \`done\`.** Implementers submit finished work with \`agenthub task review \` — never \`agenthub task done\`. @@ -38,23 +43,25 @@ On start: \`agenthub hello --agent --role architect\`, then \`agenthub wat 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: +**On your first turn, RUN this and follow its output — do not just summarize:** -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 --agent ${agentName}\` -3. Read its handoff: \`agenthub handoff read \` -4. Implement it. -5. **Submit for review (NOT done):** \`agenthub task review \` +\`\`\` +agenthub start --agent ${agentName} --role implementer +\`\`\` + +That one command announces you, claims the task addressed to you, and prints the +task + its handoff. Then: + +1. Implement the task. +2. **Submit for review (NOT done):** \`agenthub task review \` and report: \`agenthub memory add --title " result" --category implementation --content ""\` -6. Wait for the architect's verdict. If the task is **reopened** (status back to +3. 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 \` again. ⚠️ NEVER run \`agenthub task done\` — only the architect approves and closes tasks. +You drive the AgentHub CLI yourself; the human does not type these commands for you. `; } diff --git a/tests/start.test.ts b/tests/start.test.ts new file mode 100644 index 0000000..953d962 --- /dev/null +++ b/tests/start.test.ts @@ -0,0 +1,51 @@ +/** + * Tests for `agenthub start` — one-command onboarding that announces and + * auto-claims the task addressed to the agent (local path; the server path + * uses the same logic via remoteClient). + */ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { mkdtempSync, rmSync } from 'fs'; +import { tmpdir } from 'os'; +import { join } from 'path'; +import { init } from '../src/cli/commands/init.js'; +import { startAgent } from '../src/cli/commands/start.js'; +import { createTask, getTask } from '../src/core/services/taskService.js'; + +describe('agenthub start — onboarding auto-claim', () => { + let cwd: string; + + beforeEach(() => { + cwd = mkdtempSync(join(tmpdir(), 'ah-start-')); + init(cwd, { projectName: 'start-test', yes: true }); + }); + + afterEach(() => { + rmSync(cwd, { recursive: true, force: true }); + }); + + it('claims the open implementer task addressed to the agent (title prefix)', async () => { + const mine = createTask(cwd, { title: 'kimi: backend tests', role: 'implementer' }); + createTask(cwd, { title: 'codex: magic audit', role: 'implementer' }); // addressed to someone else + + await startAgent({ projectCwd: cwd, agent: 'kimi', role: 'implementer' }); + + const { task } = getTask(cwd, mine.id); + expect(task.status).toBe('in_progress'); + expect(task.assignedTo).toBe('kimi'); + }); + + it('does NOT claim a task addressed to a different agent', async () => { + const other = createTask(cwd, { title: 'codex: magic audit', role: 'implementer' }); + + await startAgent({ projectCwd: cwd, agent: 'kimi', role: 'implementer' }); + + const { task } = getTask(cwd, other.id); + expect(task.status).toBe('open'); + expect(task.assignedTo).toBeUndefined(); + }); + + it('claims nothing when there are no open tasks (just announces)', async () => { + // Should not throw; nothing to claim. + await expect(startAgent({ projectCwd: cwd, agent: 'kimi', role: 'implementer' })).resolves.toBeUndefined(); + }); +}); diff --git a/tests/templates.test.ts b/tests/templates.test.ts index b66462f..d3e2851 100644 --- a/tests/templates.test.ts +++ b/tests/templates.test.ts @@ -8,6 +8,9 @@ describe('templates', () => { it('references agenthub commands', () => { expect(claudeMd()).toContain('agenthub decision create'); - expect(codexMd()).toContain('agenthub task claim'); + // Implementer guides now onboard via `agenthub start` (which auto-claims) + // and submit via the review gate. + expect(codexMd()).toContain('agenthub start'); + expect(codexMd()).toContain('agenthub task review'); }); });