feat(cli): agenthub start <agent> kennt den Architekten — Lagebericht statt Loop

Der Architekt kann prinzipiell nicht in agenthub_work blockieren (er ist die
interaktive Gegenstelle des Menschen). Sein Sessionstart ist deshalb ein
Lagebericht: Hub-Status, Board, wer woran arbeitet inkl. "kein Check-in seit",
wer auf Übernahme wartet, und eine ausdrückliche Warnung, wenn etwas auf sein
Review wartet. Dazu das rollenspezifische Briefing (pollen statt blockieren,
Approve ist sein Gate, Push gehört dem Menschen, "still" ≠ "tot").

BUGFIX dabei: Die Rolle wurde aus der LOKALEN Config gelesen und hing damit am
Startverzeichnis — das Quellrepo hat ein .agenthub OHNE Config, wodurch
loadConfig warf und die Auflösung stumm auf "implementer" zurückfiel. Der
Architekt bekam so das Implementer-Briefing und hätte sich einen Task
geclaimt. Jetzt kommt die Rolle vom Hub-Roster, wenn ein Hub erreichbar ist;
der lokale Weg greift nur noch ohne Server.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
chahinebrini 2026-07-29 18:44:15 +02:00
parent b5533359d5
commit e2bbae2044
2 changed files with 104 additions and 8 deletions

View File

@ -56,20 +56,105 @@ function briefing(agent: string, role: string): string {
].join('\n'); ].join('\n');
} }
export async function startAgentSession(options: StartAgentOptions): Promise<void> { /**
* Der Architekt startet anders als ein Implementer: er kann prinzipiell NICHT
* in `agenthub_work` blockieren (er ist die interaktive Gegenstelle des
* Menschen), also ist Pollen für ihn kein Workaround, sondern der richtige Weg.
* Sein Sessionstart ist deshalb ein Lagebericht, kein Loop-Einstieg.
*/
function architectBriefing(agent: string): string {
return [
`Du bist "${agent}" — Architekt im AgentHub.`,
'',
'DEIN UNTERSCHIED ZU DEN IMPLEMENTERN:',
'Du gehst NICHT in agenthub_work. Ein blockierender Loop ist für dich',
'unmöglich, weil du gleichzeitig mit dem Menschen sprichst. Stattdessen',
'prüfst du den Stand BEI JEDEM ZUG selbst:',
' GET /architect/pulse?since=<nextSeq> → Reviews, dormante Agenten,',
' neue Nachrichten, Änderungen. Klein genug für jeden Zug.',
'',
'DEINE GATES:',
' - Review/Approve ist DEIN Gate. Du fragst dafür niemanden um Erlaubnis.',
' - Push, Release-Builds und Grundsatzentscheidungen gehören dem Menschen.',
' - Was auf `review` steht, wartet auf DICH — melde es ungefragt.',
' - Agenten mit offener Review sind gebunden (DEC-0035): erst dein done',
' oder reopen gibt sie frei.',
'',
'WENN ETWAS STILL AUSSIEHT:',
'Ein Agent, der arbeitet, empfängt nichts — er ist taub bis zu seinem',
'nächsten Check-in. "Kein Log seit X" heißt also nicht "tot". Prüfe erst',
'das Task-Log, bevor du jemanden für dormant erklärst.',
].join('\n');
}
async function architectHealthReport(serverUrl: string | undefined): Promise<string[]> {
const out: string[] = [];
if (!serverUrl) {
out.push('HUB: kein Server erreichbar — lokaler Dateimodus.');
return out;
}
try {
const health = await remoteClient.getHealth(serverUrl);
const c = health.counts;
out.push(`HUB: ${health.status} · v${health.version} · seit ${Math.round(health.uptimeSec / 60)} min`);
out.push(`BOARD: ${c.open} offen · ${c.inProgress} in Arbeit · ${c.review} in Review`);
const busy = health.agents.filter((a) => a.state === 'busy');
const waiting = health.agents.filter((a) => a.pendingCount > 0 && a.state !== 'busy');
if (busy.length) {
out.push('IN ARBEIT:');
for (const a of busy) {
const deaf = a.deafForSec != null ? ` · kein Check-in seit ${Math.round(a.deafForSec / 60)} min` : '';
out.push(` ${a.name}${a.taskId ?? '?'}${deaf}`);
}
}
if (waiting.length) {
out.push('WARTET AUF ÜBERNAHME:');
for (const a of waiting) out.push(` ${a.name}: ${a.pendingCount} Task(s)`);
}
if (c.review > 0) out.push(`⚠️ ${c.review} Task(s) warten auf DEIN Review — zuerst erledigen.`);
if (!busy.length && !waiting.length && !c.review) out.push('Nichts hängt. Sauberer Start.');
} catch {
out.push('HUB: nicht erreichbar. Läuft `agenthub server start --host 0.0.0.0` aus dem Projekt-Root?');
}
return out;
}
/** Gibt die aufgelöste Rolle zurück, damit der Aufrufer weiß, ob noch ein
* Implementer-Onboarding (Task claimen) folgen soll. */
export async function startAgentSession(options: StartAgentOptions): Promise<string> {
const { projectCwd, serverUrl } = options; const { projectCwd, serverUrl } = options;
let agent = options.agent; let agent = options.agent;
let role = options.role; let role = options.role;
// Rolle IMMER aus dem Roster des Hubs, wenn einer erreichbar ist: der Server
// hält die Wahrheit. Der lokale Weg greift nur ohne Hub — sonst hängt das
// Ergebnis davon ab, aus welchem Verzeichnis der Befehl gestartet wurde
// (das Quellrepo hat z. B. ein .agenthub OHNE Config, was die lokale
// Auflösung stumm auf "implementer" zurückfallen ließ).
if (!role && serverUrl) {
try {
const health = await remoteClient.getHealth(serverUrl);
const match = health.agents.find((a) => a.name.toLowerCase() === agent.toLowerCase());
if (match) {
agent = match.name;
role = match.role;
}
} catch {
// Hub nicht erreichbar — lokaler Weg unten.
}
}
if (!role) {
try { try {
agent = resolveAgentName(projectCwd, options.agent); agent = resolveAgentName(projectCwd, options.agent);
if (!role) {
const config = loadConfig(projectCwd); const config = loadConfig(projectCwd);
const architect = config.roles?.architect?.preferredAgent; const architect = config.roles?.architect?.preferredAgent;
role = agent === architect ? 'architect' : (config.agents?.[agent]?.role ?? 'implementer'); role = agent === architect ? 'architect' : (config.agents?.[agent]?.role ?? 'implementer');
}
} catch { } catch {
role = role ?? 'implementer'; role = 'implementer';
}
} }
if (serverUrl) { if (serverUrl) {
@ -81,6 +166,14 @@ export async function startAgentSession(options: StartAgentOptions): Promise<voi
} }
} }
// Architekt: Lagebericht statt Loop-Einstieg.
if (role === 'architect') {
console.log(architectBriefing(agent));
console.log('');
for (const line of await architectHealthReport(serverUrl)) console.log(line);
return 'architect';
}
console.log(briefing(agent, role ?? 'implementer')); console.log(briefing(agent, role ?? 'implementer'));
// Was liegt gerade an? Damit die Session nicht blind in den Loop geht. // Was liegt gerade an? Damit die Session nicht blind in den Loop geht.
@ -98,4 +191,5 @@ export async function startAgentSession(options: StartAgentOptions): Promise<voi
console.log(''); console.log('');
console.log('AKTUELL: Status nicht abrufbar — geh trotzdem in den Loop.'); console.log('AKTUELL: Status nicht abrufbar — geh trotzdem in den Loop.');
} }
return role ?? 'implementer';
} }

View File

@ -885,11 +885,13 @@ export function createProgram(cwd: string): Command {
process.exit(1); process.exit(1);
} }
const { serverUrl, projectCwd } = await resolveContext(program, cwd); const { serverUrl, projectCwd } = await resolveContext(program, cwd);
let role = options.role ?? 'implementer';
if (options.briefing !== false) { if (options.briefing !== false) {
await startAgentSession({ serverUrl, projectCwd, agent, role: options.role }); role = await startAgentSession({ serverUrl, projectCwd, agent, role: options.role });
console.log(''); console.log('');
} }
const role = options.role ?? 'implementer'; // Der Architekt claimt nichts — sein Start ist der Lagebericht.
if (role === 'architect') return;
if (serverUrl) { if (serverUrl) {
await runRemote(serverUrl, () => startAgent({ serverUrl, projectCwd, agent, role })); await runRemote(serverUrl, () => startAgent({ serverUrl, projectCwd, agent, role }));
} else { } else {