diff --git a/package-lock.json b/package-lock.json
index c9cb7d2..d07026e 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -13185,7 +13185,7 @@
},
"packages/cli": {
"name": "@letra-ai/cli",
- "version": "0.6.1",
+ "version": "0.6.2",
"dependencies": {
"@modelcontextprotocol/sdk": "^1.29.0",
"chalk": "^5.4.1",
diff --git a/packages/cli/package.json b/packages/cli/package.json
index ed704fe..52eafce 100644
--- a/packages/cli/package.json
+++ b/packages/cli/package.json
@@ -1,6 +1,6 @@
{
"name": "@letra-ai/cli",
- "version": "0.6.1",
+ "version": "0.6.2",
"type": "module",
"bin": {
"letra": "dist/index.js"
diff --git a/packages/cli/src/adapters/builder.ts b/packages/cli/src/adapters/builder.ts
index 27af32c..238078d 100644
--- a/packages/cli/src/adapters/builder.ts
+++ b/packages/cli/src/adapters/builder.ts
@@ -21,6 +21,7 @@ import { loadHarness, resolveHarnessRoot, DEFAULT_HARNESS_VERSION } from "../har
import { loadWorkflow } from "../commands/flow-init.js";
import { getLetraDir } from "./../workspace/resolver.js";
import { queryLog } from "../session-log.js";
+import { resolveAgentDirection } from "../agent-direction/service.js";
function countACs(
stateDir: string,
@@ -142,6 +143,7 @@ export function buildHarnessSnapshot(root: string, options: GenerateOptions): Ha
if (!options.workflow || !options.activeStageId) {
return {
+ direction: resolveAgentDirection(root),
workflowName: "letra",
hasWorkflow: false,
items: [],
@@ -310,6 +312,7 @@ export function buildHarnessSnapshot(root: string, options: GenerateOptions): Ha
}
return {
+ direction: resolveAgentDirection(root),
workflowName: workflow.name,
hasWorkflow: true,
activeStage: stage
diff --git a/packages/cli/src/adapters/generate.ts b/packages/cli/src/adapters/generate.ts
index 1f7f3b1..079e057 100644
--- a/packages/cli/src/adapters/generate.ts
+++ b/packages/cli/src/adapters/generate.ts
@@ -16,6 +16,8 @@ import {
supportedAdapterTools,
} from "./registry.js";
import type { AdapterSource, GenerateOptions } from "./types.js";
+import { loadAgents } from "../agents/service.js";
+import { buildSkillInstructions } from "./skill-bridge.js";
const ADAPTER_HEADER: Record = {
init: "# Generated by letra init. Do not edit manually.\n",
@@ -54,11 +56,15 @@ export function renderAdapterFiles(
}
const files: RenderedAdapterFile[] = [];
+ const agents = loadAgents(root).agents;
for (const artifact of instructionArtifactsForAdapters(tools)) {
let content = formatAdapterContent(snapshot, artifact.format, {
source: options.source,
displayName: artifact.displayName,
});
+ if (agents.length) {
+ content += `\n\n## Letra agent identities (registry v1)\n${agents.map((agent) => buildSkillInstructions(agent, artifact.tool)).join("\n\n")}`;
+ }
if (artifact.id === "agents-md-shared" && tools.includes("codex")) {
content = appendCodexLiveContextInstructions(content);
}
diff --git a/packages/cli/src/adapters/skill-bridge.ts b/packages/cli/src/adapters/skill-bridge.ts
new file mode 100644
index 0000000..abb3459
--- /dev/null
+++ b/packages/cli/src/adapters/skill-bridge.ts
@@ -0,0 +1,15 @@
+import type { AgentIdentity } from "@letra/types";
+
+/** Converts structured skills into deterministic adapter instructions. */
+export function buildSkillInstructions(agent: AgentIdentity, adapter = "generic"): string {
+ const lines = [`# Agent ${agent.displayName}`, `Role: ${agent.role}`, `Adapter: ${adapter}`];
+ if (agent.bio) lines.push(`Purpose: ${agent.bio}`);
+ if (agent.skills.length) {
+ lines.push("Skills:");
+ for (const skill of agent.skills) lines.push(`- ${skill.label} (${skill.level}${skill.category ? `, ${skill.category}` : ""})`);
+ }
+ const hint = agent.adapterHints?.[adapter] ?? agent.adapterHints?.generic;
+ if (hint) lines.push("Instructions:", hint);
+ return lines.join("\n");
+}
+export const skillBridge = buildSkillInstructions;
diff --git a/packages/cli/src/adapters/types.ts b/packages/cli/src/adapters/types.ts
index 94121da..e7b51a1 100644
--- a/packages/cli/src/adapters/types.ts
+++ b/packages/cli/src/adapters/types.ts
@@ -1,3 +1,5 @@
+import type { AgentDirectionSnapshot } from "@letra/types";
+
export type AdapterSource = "init" | "flow-move" | "focus" | "flow-ac";
export interface HarnessDirectionCommand {
@@ -49,6 +51,8 @@ export interface HandoffData {
}
export interface HarnessSnapshot {
+ /** Canonical versioned direction shared with CLI and MCP consumers. */
+ direction?: AgentDirectionSnapshot;
workflowName: string;
hasWorkflow: boolean;
activeStage?: { id: string; name: string };
diff --git a/packages/cli/src/agent-direction/service.ts b/packages/cli/src/agent-direction/service.ts
index 04b065f..5f3bdee 100644
--- a/packages/cli/src/agent-direction/service.ts
+++ b/packages/cli/src/agent-direction/service.ts
@@ -37,24 +37,26 @@ function slug(value: string): string {
}
function findCurrentItem(workflow: Workflow, currentItemId?: string | null): Item | null {
+ const items = Array.isArray(workflow.items) ? workflow.items : [];
+ const stages = Array.isArray(workflow.stages) ? workflow.stages : [];
if (currentItemId) {
- const explicit = workflow.items.find((item) => item.id === currentItemId);
+ const explicit = items.find((item) => item.id === currentItemId);
if (explicit) return explicit;
}
if (workflow.primaryItemId) {
- const primary = workflow.items.find((item) => item.id === workflow.primaryItemId);
+ const primary = items.find((item) => item.id === workflow.primaryItemId);
if (primary) return primary;
}
const doingStages = new Set(
- workflow.stages
+ stages
.filter(
(stage, index) =>
stage.zone === "doing" ||
- (!stage.zone && index > 0 && index < workflow.stages.length - 1),
+ (!stage.zone && index > 0 && index < stages.length - 1),
)
.map((stage) => stage.id),
);
- return workflow.items.find((item) => doingStages.has(item.stage)) ?? null;
+ return items.find((item) => doingStages.has(item.stage)) ?? null;
}
function firstPendingAC(content: string | null): AgentDirectionSnapshot["pendingAC"] {
@@ -199,6 +201,17 @@ export function createAgentDirectionSnapshot(
description: item.description,
stage: item.stage,
spec: item.spec ?? null,
+ claimedBy: item.claimedBy ?? null,
+ claimedAt: item.claimedAt ?? null,
+ claimExpiresAt: item.claimExpiresAt ?? null,
+ claimExecutorId: item.claimExecutorId ?? null,
+ claimCapability: item.claimCapability ?? null,
+ claimRevision: item.claimRevision ?? null,
+ claimTtlMinutes: item.claimTtlMinutes ?? null,
+ activityStatus: item.activityStatus ?? null,
+ activityStartedAt: item.activityStartedAt ?? null,
+ lastHeartbeatAt: item.lastHeartbeatAt ?? null,
+ lastFailure: item.lastFailure ?? null,
}
: null,
roleIds: stage ? [...stage.roleIds] : [],
@@ -243,10 +256,12 @@ export function resolveAgentDirection(root: string): AgentDirectionSnapshot {
const focus = readFocusFile(root);
const workflow = resolution.workflow;
const focusedItem =
- workflow && focus?.itemId
+ workflow && focus?.itemId && Array.isArray(workflow.items)
? (workflow.items.find((item) => item.id === focus.itemId) ?? null)
: null;
- const selectedItem = focusedItem ?? (workflow ? findCurrentItem(workflow) : null);
+ const selectedItem =
+ focusedItem ??
+ (workflow && Array.isArray(workflow.items) ? findCurrentItem(workflow) : null);
const specName = selectedItem?.spec ?? focus?.specName ?? null;
return createAgentDirectionSnapshot({
workspaceRoot: root,
diff --git a/packages/cli/src/agents/service.test.ts b/packages/cli/src/agents/service.test.ts
new file mode 100644
index 0000000..d720895
--- /dev/null
+++ b/packages/cli/src/agents/service.test.ts
@@ -0,0 +1,25 @@
+import { describe, expect, it } from "vitest";
+import { mkdtempSync, readFileSync, writeFileSync } from "node:fs";
+import { join } from "node:path";
+import { tmpdir } from "node:os";
+import { createAgent, listAgents, loadAgents, updateAgent } from "./service.js";
+import type { Workflow } from "@letra/types";
+
+const workflow = { stages: [{ id: "code", name: "Code", order: 1, allow: ["implementer"] }] } as Workflow;
+describe("agent registry", () => {
+ it("migrates role defaults and persists a versioned registry", () => {
+ const root = mkdtempSync(join(tmpdir(), "letra-agents-"));
+ writeFileSync(join(root, "workflow.json"), "{}");
+ const registry = loadAgents(root, workflow);
+ expect(registry.version).toBe("1");
+ expect(registry.agents[0].displayName).toBe("Implementador");
+ expect(JSON.parse(readFileSync(join(root, "agents.json"), "utf8")).version).toBe("1");
+ });
+ it("supports CRUD", () => {
+ const root = mkdtempSync(join(tmpdir(), "letra-agents-"));
+ writeFileSync(join(root, "workflow.json"), "{}");
+ const agent = { id: "x", displayName: "X", role: "reviewer", avatar: { type: "initials" as const, value: "X" }, color: "red", skills: [], status: "offline" as const, stageBindings: [] };
+ createAgent(root, agent, workflow); updateAgent(root, "x", { bio: "Review" }, workflow);
+ expect(listAgents(root, workflow).find((item) => item.id === "x")?.bio).toBe("Review");
+ });
+});
diff --git a/packages/cli/src/agents/service.ts b/packages/cli/src/agents/service.ts
new file mode 100644
index 0000000..1588051
--- /dev/null
+++ b/packages/cli/src/agents/service.ts
@@ -0,0 +1,47 @@
+import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
+import { join } from "node:path";
+import type { AgentIdentity, AgentRegistry, Workflow, ExternalProtocolActor, ExternalProtocolExecutor } from "@letra/types";
+import { getLetraDir, resolveWorkspaceRoot } from "../workspace/resolver.js";
+
+const DEFAULTS: Record> = {
+ analyst: { displayName: "Analista", bio: "Analisa contexto e define direção.", avatar: { type: "emoji", value: "🔎" }, color: "oklch(0.72 0.14 220)", skills: [{ id: "analysis", label: "Análise", level: "expert", category: "process" }], status: "offline", stageBindings: ["design"], adapterHints: {} },
+ implementer: { displayName: "Implementador", bio: "Transforma especificações em código.", avatar: { type: "emoji", value: "🛠️" }, color: "oklch(0.72 0.16 150)", skills: [{ id: "coding", label: "Implementação", level: "expert", category: "engineering" }], status: "offline", stageBindings: ["code"], adapterHints: {} },
+ reviewer: { displayName: "Revisor", bio: "Confronta código, spec e evidências.", avatar: { type: "emoji", value: "🔍" }, color: "oklch(0.75 0.15 80)", skills: [{ id: "review", label: "Code review", level: "expert", category: "quality" }], status: "offline", stageBindings: ["review"], adapterHints: {} },
+ security: { displayName: "Segurança", bio: "Avalia riscos e controles.", avatar: { type: "emoji", value: "🛡️" }, color: "oklch(0.68 0.18 25)", skills: [{ id: "security", label: "Segurança", level: "advanced", category: "quality" }], status: "offline", stageBindings: ["review"], adapterHints: {} },
+};
+
+function pathFor(root: string): string { return join(getLetraDir(root), "agents.json"); }
+function assertAgent(agent: AgentIdentity): AgentIdentity {
+ if (!agent || !/^[a-z0-9][a-z0-9_-]{0,63}$/.test(agent.id)) throw new Error("Agent id inválido");
+ if (!agent.displayName?.trim() || !agent.role?.trim()) throw new Error("displayName e role são obrigatórios");
+ if (!agent.avatar || !["emoji", "initials", "image"].includes(agent.avatar.type) || !agent.avatar.value?.trim()) throw new Error("avatar inválido");
+ if (typeof agent.color !== "string" || !agent.color.trim()) throw new Error("color deve ser preenchida");
+ if (!["online", "offline", "busy"].includes(agent.status)) throw new Error("status inválido");
+ if (!Array.isArray(agent.skills) || agent.skills.some((s) => !s.id || !s.label || !["beginner", "intermediate", "advanced", "expert"].includes(s.level))) throw new Error("skills inválidas");
+ if (!Array.isArray(agent.stageBindings) || (agent.adapterHints && typeof agent.adapterHints !== "object")) throw new Error("stageBindings ou adapterHints inválidos");
+ return agent;
+}
+function defaults(workflow: Workflow): AgentIdentity[] {
+ const roles = new Set(workflow.stages.flatMap((s) => s.allow ?? []));
+ return [...roles].map((role) => ({ id: role, role, ...(DEFAULTS[role] ?? { displayName: role, bio: "Agente do harness.", avatar: { type: "initials" as const, value: role.slice(0, 2).toUpperCase() }, color: "oklch(0.7 0.12 280)", skills: [], status: "offline" as const, stageBindings: workflow.stages.filter((s) => (s.allow ?? []).includes(role)).map((s) => s.id), adapterHints: {} }) }));
+}
+export function defaultAgentIdentities(workflow: Workflow): AgentIdentity[] { return defaults(workflow); }
+export function loadAgents(root: string, workflow?: Workflow): AgentRegistry {
+ const file = pathFor(root);
+ if (existsSync(file)) {
+ try { const parsed = JSON.parse(readFileSync(file, "utf8")) as AgentRegistry; if (parsed?.version === "1" && Array.isArray(parsed.agents)) { const valid = parsed.agents.filter((a) => { try { assertAgent(a); return true; } catch { return false; } }); const known = new Set(valid.map((a) => a.role)); const merged = workflow ? [...valid, ...defaults(workflow).filter((a) => !known.has(a.role))] : valid; const registry = { ...parsed, agents: merged }; if (merged.length !== parsed.agents.length) saveAgents(root, registry); return registry; } } catch { /* migrate below */ }
+ }
+ const registry: AgentRegistry = { version: "1", updatedAt: new Date().toISOString(), agents: workflow ? defaults(workflow) : [] };
+ if (workflow) saveAgents(root, registry);
+ return registry;
+}
+export function saveAgents(root: string, registry: AgentRegistry): void { const file = pathFor(root); mkdirSync(join(file, ".."), { recursive: true }); writeFileSync(file, JSON.stringify({ ...registry, version: "1", updatedAt: new Date().toISOString() }, null, 2) + "\n"); }
+export function listAgents(root: string, workflow?: Workflow): AgentIdentity[] { return loadAgents(root, workflow).agents; }
+export function createAgent(root: string, agent: AgentIdentity, workflow?: Workflow): AgentIdentity { const r = loadAgents(root, workflow); assertAgent(agent); if (r.agents.some((a) => a.id === agent.id)) throw new Error(`Agent ${agent.id} already exists`); r.agents.push(agent); saveAgents(root, r); return agent; }
+export function updateAgent(root: string, id: string, patch: Partial, workflow?: Workflow): AgentIdentity { const r = loadAgents(root, workflow); const i = r.agents.findIndex((a) => a.id === id); if (i < 0) throw new Error(`Agent ${id} not found`); const next = assertAgent({ ...r.agents[i], ...patch, avatar: patch.avatar ?? r.agents[i].avatar, skills: patch.skills ?? r.agents[i].skills, stageBindings: patch.stageBindings ?? r.agents[i].stageBindings, adapterHints: patch.adapterHints ?? r.agents[i].adapterHints, id }); r.agents[i] = next; saveAgents(root, r); return next; }
+export function deleteAgent(root: string, id: string, workflow?: Workflow): void { const r = loadAgents(root, workflow); if (!r.agents.some((a) => a.id === id)) throw new Error(`Agent ${id} not found`); if (workflow?.items.some((item) => item.claimedBy === id)) throw new Error(`Agent ${id} possui claims ativos`); r.agents = r.agents.filter((a) => a.id !== id); saveAgents(root, r); }
+export function agentsFor(root?: string, workflow?: Workflow): AgentIdentity[] { const resolved = resolveWorkspaceRoot(root); return listAgents(resolved.workspaceRoot, workflow); }
+/** Canonical identity ↔ protocol mapping used by adapters and claims. */
+export function protocolIdentity(agent: AgentIdentity, toolId: string, toolVersion = "unknown"): { actor: ExternalProtocolActor; executor: ExternalProtocolExecutor } {
+ return { actor: { agentId: agent.id, displayName: agent.displayName, toolId, toolVersion }, executor: { id: toolId, capabilities: agent.skills.map((s) => s.id), status: agent.status, transport: "cli" } };
+}
diff --git a/packages/cli/src/commands/flow-init.ts b/packages/cli/src/commands/flow-init.ts
index 739940f..751b087 100644
--- a/packages/cli/src/commands/flow-init.ts
+++ b/packages/cli/src/commands/flow-init.ts
@@ -58,6 +58,15 @@ export interface Item {
tasks?: Task[];
claimedBy?: string;
claimedAt?: string;
+ claimExecutorId?: string;
+ claimCapability?: string;
+ claimRevision?: string;
+ claimExpiresAt?: string;
+ claimTtlMinutes?: number;
+ activityStatus?: "started" | "heartbeat" | "succeeded" | "failed";
+ activityStartedAt?: string;
+ lastHeartbeatAt?: string;
+ lastFailure?: { code: string; message: string; recovery: string; at: string };
currentPhase?: string;
handoff?: ItemHandoff;
}
diff --git a/packages/cli/src/commands/flow-serve.ts b/packages/cli/src/commands/flow-serve.ts
index 9588c03..57fbecd 100644
--- a/packages/cli/src/commands/flow-serve.ts
+++ b/packages/cli/src/commands/flow-serve.ts
@@ -67,11 +67,18 @@ import { createWorkflowRoutes } from "../flow-serve/routes/workflow-routes.js";
import { createWorkspaceRoutes } from "../flow-serve/routes/workspace-routes.js";
import { createAdapterRoutes } from "../flow-serve/routes/adapter-routes.js";
import { createHandoffRoutes } from "../flow-serve/routes/handoff-routes.js";
+import { createAgentRoutes } from "../flow-serve/routes/agent-routes.js";
import { ClientAssets } from "../flow-serve/client-assets.js";
import { AutomationRuntime, type AutomationBinding } from "../flow-serve/automation-runtime.js";
import { Orchestrator } from "../orchestrator/orchestrator.js";
+import { PersistentDispatcher } from "../orchestrator/dispatcher.js";
+import { createSimulatedExecutor } from "../orchestrator/simulated-executor.js";
const DEFAULT_PORT = 3000;
+export interface FlowServerOptions {
+ autopilot?: boolean;
+ dispatcherIntervalMs?: number;
+}
/**
* Resolve the harness directory for `root`, preferring the workspace-local
@@ -107,8 +114,11 @@ export class FlowServer {
private resolution: WorkspaceResolution;
private activeWorkspaceRoot: string;
private activeDirectory: string | null = null;
+ private dispatcher: PersistentDispatcher | undefined;
+ private readonly options: FlowServerOptions;
- constructor(root: string, port: number = DEFAULT_PORT) {
+ constructor(root: string, port: number = DEFAULT_PORT, options: FlowServerOptions = {}) {
+ this.options = options;
this.clientAssets = new ClientAssets(root);
this.port = port;
this.resolution = resolveWorkspaceRoot(root);
@@ -134,6 +144,7 @@ export class FlowServer {
onHandoffEvent: (payload) => this.events.broadcastHandoff(payload),
});
this.orchestrator.registerFromManifest();
+ if (options.autopilot) this.dispatcher = this.createDispatcher();
this.router.register((context) => {
if (context.path !== "/events") return false;
this.events.handleSse(context.req, context.res);
@@ -261,6 +272,58 @@ export class FlowServer {
},
}),
);
+ this.router.register(createAgentRoutes({ loadWorkflow: (root) => this.loadWorkflow(root), broadcast: () => this.broadcast() }));
+ }
+
+ private createDispatcher(): PersistentDispatcher {
+ const simulated = createSimulatedExecutor("letra-simulated");
+ const manifest = this.orchestrator.getManifest();
+ return new PersistentDispatcher(
+ {
+ loadWorkflow: () => {
+ const workflow = this.loadWorkflow();
+ if (!workflow) throw new Error("No workflow found for dispatcher");
+ return workflow;
+ },
+ writeWorkflow: async (workflow) => {
+ writeWorkflow(this.activeWorkspaceRoot, {
+ workflow,
+ source: "orchestrator",
+ primaryItemId: workflow.primaryItemId,
+ skipSitrep: true,
+ });
+ this.broadcast();
+ },
+ advance: () => undefined,
+ claim: (itemId, executorId, agentId) =>
+ this.orchestrator.autoClaim(itemId, executorId, agentId).success,
+ },
+ () => [simulated],
+ this.options.dispatcherIntervalMs ?? 30_000,
+ {
+ stageActors: (stageId) => {
+ const templateId = this.loadWorkflow()?.template ?? "flow-main";
+ return manifest?.flows[templateId]?.stages.find((stage) => stage.id === stageId)?.agents ?? [];
+ },
+ blocksHandoff: (stage, item) => {
+ const gate = stage.gate ? manifest?.gates[stage.gate] : undefined;
+ // A human gate protects entry into the next role. The role that
+ // owns the current stage must still be allowed to execute and
+ // produce the evidence presented at that gate.
+ const currentActor = (manifest?.flows[this.loadWorkflow()?.template ?? "flow-main"]?.stages.find((entry) => entry.id === stage.id)?.agents ?? (stage as typeof stage & { agents?: string[] }).agents)?.[0];
+ return gate?.type === "human" && gate.blocksHandoff === true && item.handoff?.to !== currentActor;
+ },
+ onResult: (result) => {
+ if (result.status === "dispatched" || result.status === "failed") {
+ logEntry(this.activeWorkspaceRoot, "agent_execution_event", `Autonomous dispatcher: ${result.status}`, {
+ itemId: result.itemId,
+ details: { status: result.status, reason: result.reason ?? null },
+ });
+ }
+ this.broadcast();
+ },
+ },
+ );
}
switchWorkspace(workspaceRoot: string) {
@@ -276,6 +339,11 @@ export class FlowServer {
onHandoffEvent: (payload) => this.events.broadcastHandoff(payload),
});
this.orchestrator.registerFromManifest();
+ if (this.options.autopilot) {
+ this.dispatcher?.stop();
+ this.dispatcher = this.createDispatcher();
+ this.dispatcher.start();
+ }
this.orchestrator.startReclaimTimer();
this.broadcast();
}
@@ -373,6 +441,7 @@ export class FlowServer {
this.server.listen(this.port, () => {
this.automationRuntime.start(this.automationBinding());
this.orchestrator.startReclaimTimer();
+ this.dispatcher?.start();
resolve();
});
this.server.on("error", reject);
@@ -381,6 +450,7 @@ export class FlowServer {
stop(): void {
this.automationRuntime.stop();
+ this.dispatcher?.stop();
this.orchestrator.stopReclaimTimer();
if (this.server) this.server.close();
this.events.close();
@@ -393,7 +463,7 @@ export class FlowServer {
export async function flowServeAction(
targetPath: string | undefined,
- options?: { port?: number; open?: boolean },
+ options?: { port?: number; open?: boolean; autopilot?: boolean },
): Promise {
const root = resolve(process.cwd(), targetPath ?? ".");
const port = options?.port ?? DEFAULT_PORT;
@@ -404,7 +474,7 @@ export async function flowServeAction(
return;
}
- const server = new FlowServer(root, port);
+ const server = new FlowServer(root, port, { autopilot: options?.autopilot });
try {
await server.start();
console.log(`\n Flow Board → http://localhost:${port}\n`);
diff --git a/packages/cli/src/commands/flow.ts b/packages/cli/src/commands/flow.ts
index b5eea8f..1fe5f32 100644
--- a/packages/cli/src/commands/flow.ts
+++ b/packages/cli/src/commands/flow.ts
@@ -106,11 +106,13 @@ export default function flowCommand() {
cmd.command("serve")
.option("--port ", "Port to listen on", "3000")
.option("--open", "Open browser automatically")
+ .option("--autopilot", "Enable deterministic semiautonomous dispatcher (human gates still block)")
.description("Start local web server with live board")
- .action((options: { port?: string; open?: boolean }) => {
+ .action((options: { port?: string; open?: boolean; autopilot?: boolean }) => {
flowServeAction(undefined, {
port: options.port ? Number(options.port) : undefined,
open: options.open,
+ autopilot: options.autopilot,
});
});
diff --git a/packages/cli/src/commands/operation.ts b/packages/cli/src/commands/operation.ts
index d507aca..a9859ee 100644
--- a/packages/cli/src/commands/operation.ts
+++ b/packages/cli/src/commands/operation.ts
@@ -1,5 +1,6 @@
import { resolve } from "node:path";
import { Command, Option } from "commander";
+import { activityOperation } from "../domain-operations/service.js";
function collectEvidence(value: string, previous: string[]): string[] {
return [...previous, value];
@@ -14,6 +15,34 @@ export default function operationCommand(): Command {
"Execute controlled harness operations with structured JSON results",
);
+ command
+ .command("event ")
+ .requiredOption("--status ", "started, heartbeat, succeeded ou failed")
+ .requiredOption("--executor ", "Executor externo")
+ .requiredOption("--expected-revision ", "Direction revision returned by Letra")
+ .requiredOption("--reason ", "Reason for the operation")
+ .option("--actor ", "Identidade do actor", "agent:codex")
+ .option("--message ", "Mensagem do evento")
+ .option("--recovery ", "retry, release, handoff ou human")
+ .option("--error-code ", "Código da falha")
+ .action(async (itemId: string, options: { status: "started" | "heartbeat" | "succeeded" | "failed"; executor: string; expectedRevision: string; reason: string; actor: string; message?: string; recovery?: "retry" | "release" | "handoff" | "human"; errorCode?: string }) => {
+ const { recordExecutionEvent } = await import("../domain-operations/service.js");
+ printJson(await recordExecutionEvent(resolve(process.cwd()), { itemId, status: options.status, executorId: options.executor, expectedRevision: options.expectedRevision, reason: options.reason, actor: options.actor, message: options.message, recovery: options.recovery, errorCode: options.errorCode }));
+ });
+
+ command
+ .command("claim ")
+ .requiredOption("--executor ", "Executor externo")
+ .requiredOption("--capability ", "Capability usada")
+ .requiredOption("--expected-revision ", "Direction revision returned by Letra")
+ .requiredOption("--reason ", "Reason for the operation")
+ .option("--actor ", "Identidade do actor", "agent:codex")
+ .option("--ttl ", "TTL do claim em minutos", (value) => Number(value), 30)
+ .action(async (itemId: string, options: { executor: string; capability: string; expectedRevision: string; reason: string; actor: string; ttl: number }) => {
+ const { claimOperation } = await import("../domain-operations/service.js");
+ printJson(await claimOperation(resolve(process.cwd()), { itemId, executorId: options.executor, capability: options.capability, expectedRevision: options.expectedRevision, reason: options.reason, actor: options.actor, ttlMinutes: options.ttl }));
+ });
+
command
.command("validate")
.requiredOption("--expected-revision ", "Direction revision returned by Letra")
@@ -22,6 +51,30 @@ export default function operationCommand(): Command {
const { runValidationOperation } = await import("../domain-operations/service.js");
printJson(await runValidationOperation(resolve(process.cwd()), options));
});
+ command.command("activity [item-id]").action((itemId?: string) => printJson(activityOperation(resolve(process.cwd()), itemId)));
+ command.command("evidence ")
+ .requiredOption("--executor ", "Executor externo")
+ .requiredOption("--expected-revision ", "Direction revision")
+ .requiredOption("--reason ", "Reason")
+ .requiredOption("--kind ", "diff, file, command, test ou artifact")
+ .requiredOption("--value ", "Valor/path")
+ .requiredOption("--source ", "Origem observada")
+ .option("--actor ", "Actor", "agent:codex")
+ .action(async (itemId: string, options: { executor: string; expectedRevision: string; reason: string; kind: "diff" | "file" | "command" | "test" | "artifact"; value: string; source: string; actor: string }) => {
+ const { submitEvidenceOperation } = await import("../domain-operations/service.js");
+ printJson(await submitEvidenceOperation(resolve(process.cwd()), { itemId, executorId: options.executor, expectedRevision: options.expectedRevision, reason: options.reason, actor: options.actor, evidence: [{ kind: options.kind, value: options.value, source: options.source }] }));
+ });
+ command.command("handoff ")
+ .requiredOption("--to ", "Destino")
+ .requiredOption("--executor ", "Executor")
+ .requiredOption("--summary ", "Resumo")
+ .requiredOption("--expected-revision ", "Direction revision")
+ .requiredOption("--reason ", "Reason")
+ .option("--actor ", "Actor", "agent:codex")
+ .action(async (itemId: string, options: { to: string; executor: string; summary: string; expectedRevision: string; reason: string; actor: string }) => {
+ const { requestHandoffOperation } = await import("../domain-operations/service.js");
+ printJson(await requestHandoffOperation(resolve(process.cwd()), { itemId, to: options.to, executorId: options.executor, summary: options.summary, evidence: [], expectedRevision: options.expectedRevision, reason: options.reason, actor: options.actor }));
+ });
command
.command("complete-ac ")
diff --git a/packages/cli/src/domain-operations/service.ts b/packages/cli/src/domain-operations/service.ts
index 0ed4ae0..e7b44bf 100644
--- a/packages/cli/src/domain-operations/service.ts
+++ b/packages/cli/src/domain-operations/service.ts
@@ -1,4 +1,5 @@
-import { existsSync, readFileSync, renameSync, writeFileSync } from "node:fs";
+import { existsSync, readFileSync, renameSync, writeFileSync, realpathSync, statSync } from "node:fs";
+import { createHash } from "node:crypto";
import { join, resolve } from "node:path";
import type { AgentDirectionSnapshot } from "@letra/types";
import { resolveAgentDirection } from "../agent-direction/service.js";
@@ -10,7 +11,7 @@ import { resolveActiveFlow } from "../flow-definition/resolve.js";
import { createWorkspaceBoundary } from "../security/workspace-boundary.js";
import { logEntry, type LogAction } from "../session-log.js";
import { GateChecker } from "../harness/gate-checker.js";
-import { getLetraDir } from "./../workspace/resolver.js";
+import { getLetraDir, resolveWorkspaceRoot } from "./../workspace/resolver.js";
export type OperationOutcome = "accepted" | "rejected" | "approval-required";
@@ -41,6 +42,66 @@ export interface RequestTransitionInput extends OperationContext {
targetStageId: string;
}
+export interface ClaimOperationInput extends OperationContext {
+ itemId: string;
+ executorId: string;
+ capability: string;
+ ttlMinutes?: number;
+}
+
+export interface ExecutionEventInput extends OperationContext {
+ itemId: string;
+ executorId: string;
+ status: "started" | "heartbeat" | "succeeded" | "failed";
+ message?: string;
+ recovery?: "retry" | "release" | "handoff" | "human";
+ errorCode?: string;
+}
+
+export interface EvidenceInput extends OperationContext {
+ itemId: string;
+ executorId: string;
+ evidence: Array<{ kind: "diff" | "file" | "command" | "test" | "artifact"; value: string; source: string; observedAt?: string; sha256?: string; exitCode?: number }>;
+}
+export interface HandoffInput extends OperationContext {
+ itemId: string; to: string; summary: string; evidence: string[]; executorId: string; ttlMinutes?: number;
+}
+
+export function activityOperation(root: string, itemId?: string): AgentDirectionSnapshot["item"] {
+ const direction = resolveAgentDirection(createWorkspaceBoundary(resolve(root)).root);
+ return direction.item && (!itemId || direction.item.id === itemId) ? direction.item : null;
+}
+
+export async function submitEvidenceOperation(root: string, input: EvidenceInput): Promise {
+ const workspaceRoot = createWorkspaceBoundary(resolve(root)).root;
+ const before = resolveAgentDirection(workspaceRoot); const subject = { itemId: input.itemId, operation: "submit_evidence" };
+ const stale = checkRevision(workspaceRoot, before, input, subject); if (stale) return stale;
+ const item = loadWorkflow(workspaceRoot)?.items.find((candidate) => candidate.id === input.itemId);
+ if (!item || item.claimedBy !== input.actor || (item.claimExecutorId && item.claimExecutorId !== input.executorId)) return rejected(workspaceRoot, before, "CLAIM_REQUIRED", "A evidência exige claim vigente.", input, subject);
+ const boundary = createWorkspaceBoundary(resolveWorkspaceRoot(root).workspaceDir);
+ for (const evidence of input.evidence) {
+ if (!evidence.source?.trim() || !evidence.value?.trim()) return rejected(workspaceRoot, before, "EVIDENCE_INVALID", "Evidência exige origem e valor.", input, subject);
+ if (evidence.kind === "file" || evidence.kind === "diff" || evidence.kind === "artifact") {
+ try { const path = boundary.assertPath(evidence.value); if (existsSync(path) && statSync(path).isSymbolicLink()) throw new Error("symlink"); }
+ catch { return rejected(workspaceRoot, before, "EVIDENCE_PATH_OUTSIDE_WORKSPACE", "Path da evidência fora do workspace autorizado.", input, subject); }
+ }
+ }
+ const entry = audit(workspaceRoot, "agent_execution_event", before, { outcome: "accepted", reasonCode: "EVIDENCE_ACCEPTED", reason: input.reason, actor: input.actor, itemId: input.itemId, details: { evidence: input.evidence.map((e) => ({ ...e, observedAt: e.observedAt ?? new Date().toISOString(), sha256: e.sha256 ?? createHash("sha256").update(e.value).digest("hex") })) } });
+ return result(before, entry.id, "accepted", "EVIDENCE_ACCEPTED", input.reason, resolveAgentDirection(workspaceRoot));
+}
+
+export async function requestHandoffOperation(root: string, input: HandoffInput): Promise {
+ const workspaceRoot = createWorkspaceBoundary(resolve(root)).root; const before = resolveAgentDirection(workspaceRoot); const subject = { itemId: input.itemId, operation: "request_handoff" };
+ const stale = checkRevision(workspaceRoot, before, input, subject); if (stale) return stale;
+ const workflow = loadWorkflow(workspaceRoot); const item = workflow?.items.find((candidate) => candidate.id === input.itemId);
+ if (!workflow || !item || item.claimedBy !== input.actor || (item.claimExecutorId && item.claimExecutorId !== input.executorId)) return rejected(workspaceRoot, before, "CLAIM_REQUIRED", "Handoff exige claim vigente do executor.", input, subject);
+ if (item.handoff) return rejected(workspaceRoot, before, "HANDOFF_CONFLICT", "Já existe handoff pendente.", input, subject);
+ const now = new Date(); item.handoff = { from: input.actor ?? "unknown", to: input.to, summary: input.summary, evidence: input.evidence, timestamp: now.toISOString(), expiresAt: new Date(now.getTime() + (input.ttlMinutes ?? 30) * 60000).toISOString(), executorId: input.executorId };
+ item.claimedBy = undefined; item.claimedAt = undefined; item.claimExecutorId = undefined; item.claimCapability = undefined; item.claimRevision = undefined; item.claimExpiresAt = undefined; item.claimTtlMinutes = undefined; workflow.updatedAt = now.toISOString();
+ const write = await writeWorkflow(workspaceRoot, { workflow, source: "flow-handoff", primaryItemId: item.id, skipSitrep: true, skipLog: true, quiet: true, confineAdapterWrites: true }); if (!write.ok) return rejected(workspaceRoot, before, "HANDOFF_WRITE_FAILED", write.error ?? "Falha ao persistir handoff.", input, subject);
+ const entry = audit(workspaceRoot, "agent_execution_event", before, { outcome: "accepted", reasonCode: "HANDOFF_ACCEPTED", reason: input.reason, actor: input.actor, itemId: item.id, details: { to: input.to, executorId: input.executorId } }); return result(before, entry.id, "accepted", "HANDOFF_ACCEPTED", input.reason, resolveAgentDirection(workspaceRoot));
+}
+
function audit(
root: string,
action: LogAction,
@@ -132,6 +193,95 @@ function normalizeAcId(value: string): string {
return match ? `AC${match[1]}` : value.trim().toUpperCase();
}
+export async function claimOperation(
+ root: string,
+ input: ClaimOperationInput,
+): Promise {
+ const workspaceRoot = createWorkspaceBoundary(resolve(root)).root;
+ const before = resolveAgentDirection(workspaceRoot);
+ const subject = { itemId: input.itemId, operation: "claim" };
+ const stale = checkRevision(workspaceRoot, before, input, subject);
+ if (stale) return stale;
+ if (!input.actor?.trim())
+ return rejected(workspaceRoot, before, "ACTOR_REQUIRED", "Claim exige identidade do actor.", input, subject);
+ if (!before.item || before.item.id !== input.itemId)
+ return rejected(workspaceRoot, before, "ITEM_NOT_CURRENT", "O claim exige o item vigente.", input, subject);
+ const flow = resolveActiveFlow(workspaceRoot).flow;
+ const stage = flow?.stages.find((candidate) => candidate.id === before.item?.stage);
+ const capabilities = stage?.roles.flatMap((role) => role.capabilities) ?? [];
+ if (capabilities.length > 0 && !capabilities.includes(input.capability))
+ return rejected(workspaceRoot, before, "CAPABILITY_INVALID", `Capability não permitida: ${input.capability}.`, input, subject);
+ const workflow = loadWorkflow(workspaceRoot);
+ const item = workflow?.items.find((candidate) => candidate.id === input.itemId);
+ if (!workflow || !item) return rejected(workspaceRoot, before, "ITEM_NOT_FOUND", "Item não encontrado.", input, subject);
+ const claimExpired = item.claimExpiresAt ? Date.now() >= Date.parse(item.claimExpiresAt) : false;
+ if (item.claimedBy && !claimExpired && (item.claimedBy !== input.actor || item.claimExecutorId !== input.executorId))
+ return rejected(workspaceRoot, before, "CLAIM_CONFLICT", `Item já está sob responsabilidade de ${item.claimedBy}.`, input, subject);
+ const ttl = Math.max(1, Math.min(1440, input.ttlMinutes ?? 30));
+ const now = new Date();
+ item.claimedBy = input.actor.trim();
+ item.claimedAt = now.toISOString();
+ item.claimExecutorId = input.executorId.trim();
+ item.claimCapability = input.capability.trim();
+ item.claimRevision = before.revision;
+ item.claimTtlMinutes = ttl;
+ item.claimExpiresAt = new Date(now.getTime() + ttl * 60_000).toISOString();
+ workflow.updatedAt = now.toISOString();
+ const writeResult = await writeWorkflow(workspaceRoot, {
+ workflow,
+ source: "flow-claim",
+ primaryItemId: item.id,
+ skipSitrep: true,
+ skipLog: true,
+ quiet: true,
+ confineAdapterWrites: true,
+ });
+ if (!writeResult.ok)
+ return rejected(workspaceRoot, before, "CLAIM_WRITE_FAILED", writeResult.error ?? "Falha ao persistir claim.", input, subject);
+ const after = resolveAgentDirection(workspaceRoot);
+ const entry = audit(workspaceRoot, "agent_claim_requested", before, {
+ outcome: "accepted",
+ reasonCode: "CLAIM_ACCEPTED",
+ reason: input.reason,
+ actor: input.actor,
+ itemId: item.id,
+ details: { executorId: input.executorId, capability: input.capability, ttlMinutes: ttl, expiresAt: item.claimExpiresAt },
+ });
+ return result(before, entry.id, "accepted", "CLAIM_ACCEPTED", input.reason, after);
+}
+
+export async function recordExecutionEvent(
+ root: string,
+ input: ExecutionEventInput,
+): Promise {
+ const workspaceRoot = createWorkspaceBoundary(resolve(root)).root;
+ const before = resolveAgentDirection(workspaceRoot);
+ const subject = { itemId: input.itemId, operation: input.status };
+ const stale = checkRevision(workspaceRoot, before, input, subject);
+ if (stale) return stale;
+ if (!input.actor?.trim()) return rejected(workspaceRoot, before, "ACTOR_REQUIRED", "Evento exige identidade do actor.", input, subject);
+ if (!before.item || before.item.id !== input.itemId) return rejected(workspaceRoot, before, "ITEM_NOT_CURRENT", "Evento exige o item vigente.", input, subject);
+ const workflow = loadWorkflow(workspaceRoot);
+ const item = workflow?.items.find((candidate) => candidate.id === input.itemId);
+ if (!workflow || !item) return rejected(workspaceRoot, before, "ITEM_NOT_FOUND", "Item não encontrado.", input, subject);
+ const expired = !item.claimExpiresAt || Date.now() >= Date.parse(item.claimExpiresAt);
+ if (item.claimedBy !== input.actor || item.claimExecutorId !== input.executorId) return rejected(workspaceRoot, before, "CLAIM_REQUIRED", "Actor e executor precisam possuir o claim vigente.", input, subject);
+ if (expired) return rejected(workspaceRoot, before, "CLAIM_EXPIRED", "O lease do claim expirou; faça um novo claim.", input, subject);
+ const nowDate = new Date();
+ const now = nowDate.toISOString();
+ item.activityStatus = input.status;
+ if (input.status === "started") item.activityStartedAt = now;
+ if (input.status === "heartbeat" || input.status === "started") item.lastHeartbeatAt = now;
+ if (input.status === "heartbeat") item.claimExpiresAt = new Date(nowDate.getTime() + (item.claimTtlMinutes ?? 30) * 60_000).toISOString();
+ if (input.status === "failed") item.lastFailure = { code: input.errorCode ?? "EXECUTION_FAILED", message: input.message ?? "Execução falhou.", recovery: input.recovery ?? "human", at: now };
+ workflow.updatedAt = now;
+ const writeResult = await writeWorkflow(workspaceRoot, { workflow, source: "flow-claim", primaryItemId: item.id, skipSitrep: true, skipLog: true, quiet: true, confineAdapterWrites: true });
+ if (!writeResult.ok) return rejected(workspaceRoot, before, "EVENT_WRITE_FAILED", writeResult.error ?? "Falha ao persistir evento.", input, subject);
+ const after = resolveAgentDirection(workspaceRoot);
+ const entry = audit(workspaceRoot, "agent_execution_event", before, { outcome: "accepted", reasonCode: "EVENT_RECORDED", reason: input.reason, actor: input.actor, itemId: item.id, details: { status: input.status, executorId: input.executorId, message: input.message, recovery: input.recovery, errorCode: input.errorCode } });
+ return result(before, entry.id, "accepted", "EVENT_RECORDED", input.reason, after);
+}
+
function markPendingAc(content: string, acId: string): string | null {
const lines = content.split("\n");
const expected = normalizeAcId(acId);
@@ -181,8 +331,8 @@ export async function runValidationOperation(
}
export function completeAcOperation(root: string, input: CompleteAcInput): OperationResult {
- const boundary = createWorkspaceBoundary(resolve(root));
- const workspaceRoot = boundary.root;
+ const workspaceRoot = createWorkspaceBoundary(resolve(root)).root;
+ const boundary = createWorkspaceBoundary(resolveWorkspaceRoot(root).workspaceDir);
const before = resolveAgentDirection(workspaceRoot);
const subject = { itemId: before.item?.id, acId: input.acId, operation: "complete_ac" };
const stale = checkRevision(workspaceRoot, before, input, subject);
diff --git a/packages/cli/src/flow-definition/resolve.ts b/packages/cli/src/flow-definition/resolve.ts
index 513df43..e1ae22a 100644
--- a/packages/cli/src/flow-definition/resolve.ts
+++ b/packages/cli/src/flow-definition/resolve.ts
@@ -178,6 +178,7 @@ function mergeTemplateStage(
roles: resolveRoles(harness, roleIds, warnings, artifactRef),
agents: [...roleIds],
gate: resolveGate(harness, stageDef.gate, warnings, artifactRef),
+ preferredExecutor: stageDef.preferredExecutor,
phases: resolvePhases(harness, stageDef.phases, warnings, stageDef.id),
activity: cloneActivity(stageDef.activity),
provenance: "harness",
@@ -198,6 +199,7 @@ function workflowStageDefinition(
roles: [],
agents: [],
gate: null,
+ preferredExecutor: undefined,
phases: resolvePhases(null, stage.phases, [], stage.id),
activity: undefined,
provenance,
@@ -257,7 +259,7 @@ function resolveFromWorkflow(
harnessVersion: workflow.harnessVersion ?? null,
templateVersion: null,
name: workflow.name,
- stages: workflow.stages
+ stages: (Array.isArray(workflow.stages) ? workflow.stages : [])
.map((stage) => workflowStageDefinition(stage as Stage))
.sort((left, right) => left.order - right.order),
roles: [],
diff --git a/packages/cli/src/flow-serve/flow-serve.test.ts b/packages/cli/src/flow-serve/flow-serve.test.ts
index fab14f6..0fb214f 100644
--- a/packages/cli/src/flow-serve/flow-serve.test.ts
+++ b/packages/cli/src/flow-serve/flow-serve.test.ts
@@ -1,6 +1,7 @@
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
import { FlowServer } from "../commands/flow-serve.js";
-import { mkdtempSync, rmSync, writeFileSync, mkdirSync } from "node:fs";
+import { loadWorkflow, saveWorkflow } from "../commands/flow-init.js";
+import { mkdtempSync, rmSync, writeFileSync, mkdirSync, readdirSync, readFileSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
@@ -116,4 +117,38 @@ describe("FlowServer SSE + Orchestrator Integration", () => {
}),
);
});
+
+ it("wires the semiautonomous dispatcher only when autopilot is enabled", async () => {
+ const server = new FlowServer(root, 3003, { autopilot: true, dispatcherIntervalMs: 60_000 });
+ const dispatcher = (server as any).dispatcher;
+ expect(dispatcher).toBeDefined();
+ const startSpy = vi.spyOn(dispatcher, "start");
+ await server.start();
+ expect(startSpy).toHaveBeenCalledTimes(1);
+ server.stop();
+ });
+
+ it("runs a handoff to the human gate and leaves an auditable pause", async () => {
+ const workflow = loadWorkflow(root)!;
+ workflow.items[0].handoff = {
+ from: "design",
+ to: "implementer",
+ summary: "Implement approved work",
+ evidence: ["spec-approved"],
+ timestamp: new Date().toISOString(),
+ expiresAt: new Date(Date.now() + 1_800_000).toISOString(),
+ };
+ saveWorkflow(root, workflow);
+ const server = new FlowServer(root, 3004, { autopilot: true, dispatcherIntervalMs: 60_000 });
+ await server.start();
+ await new Promise((resolve) => setTimeout(resolve, 80));
+ server.stop();
+ const updated = loadWorkflow(root)!;
+ expect(updated.items[0].activityStatus).toBe("succeeded");
+ expect(updated.items[0].handoff?.to).toBe("human");
+ expect(updated.items[0].handoff?.evidence).toContain("simulated:implement:ITEM-1");
+ const logFiles = readdirSync(join(root, ".letra", "session-log"), { recursive: true }) as string[];
+ const logContent = logFiles.filter((file) => file.endsWith(".jsonl")).map((file) => readFileSync(join(root, ".letra", "session-log", file), "utf8")).join("\n");
+ expect(logContent).toContain("Autonomous dispatcher: dispatched");
+ });
});
diff --git a/packages/cli/src/flow-serve/routes/agent-routes.ts b/packages/cli/src/flow-serve/routes/agent-routes.ts
new file mode 100644
index 0000000..dba5c1f
--- /dev/null
+++ b/packages/cli/src/flow-serve/routes/agent-routes.ts
@@ -0,0 +1,25 @@
+import { readJson, sendError, sendJson } from "../http.js";
+import type { RouteHandler } from "../router.js";
+import { createAgent, deleteAgent, listAgents, updateAgent } from "../../agents/service.js";
+import type { AgentIdentity } from "@letra/types";
+
+let agentMutation = false;
+async function acquireAgentMutation(): Promise<() => void> { while (agentMutation) await new Promise((resolve) => setTimeout(resolve, 0)); agentMutation = true; return () => { agentMutation = false; }; }
+
+export function createAgentRoutes(dependencies: { loadWorkflow: (root: string) => any; broadcast?: () => void }): RouteHandler {
+ return async (context) => {
+ if (!context.path.startsWith("/api/agents")) return false;
+ const root = context.workspaceRoot;
+ try {
+ if (context.path === "/api/agents" && context.method === "GET") { sendJson(context.res, 200, listAgents(root, dependencies.loadWorkflow(root))); return true; }
+ if (context.path === "/api/agents" && context.method === "POST") {
+ const release = await acquireAgentMutation(); try { const agent = await readJson(context.req); const result = createAgent(root, agent, dependencies.loadWorkflow(root)); dependencies.broadcast?.(); sendJson(context.res, 201, result); return true; } finally { release(); }
+ }
+ const match = context.path.match(/^\/api\/agents\/([^/]+)$/); if (!match) return false;
+ const id = decodeURIComponent(match[1]);
+ if (context.method === "PATCH") { const release = await acquireAgentMutation(); try { const patch = await readJson>(context.req); const result = updateAgent(root, id, patch, dependencies.loadWorkflow(root)); dependencies.broadcast?.(); sendJson(context.res, 200, result); return true; } finally { release(); } }
+ if (context.method === "DELETE") { const release = await acquireAgentMutation(); try { deleteAgent(root, id, dependencies.loadWorkflow(root)); dependencies.broadcast?.(); sendJson(context.res, 200, { ok: true }); return true; } finally { release(); } }
+ return false;
+ } catch (error) { sendError(context.res, 400, (error as Error).message); return true; }
+ };
+}
diff --git a/packages/cli/src/flow-serve/routes/item-routes.test.ts b/packages/cli/src/flow-serve/routes/item-routes.test.ts
index 0494843..ee95a2a 100644
--- a/packages/cli/src/flow-serve/routes/item-routes.test.ts
+++ b/packages/cli/src/flow-serve/routes/item-routes.test.ts
@@ -62,6 +62,7 @@ function configureHumanGate(
"request-changes": "previous",
reject: "first",
},
+ gateStageId = "review",
) {
vi.mocked(deps.resolveActiveFlow).mockReturnValue({
workflow: null,
@@ -88,8 +89,8 @@ function configureHumanGate(
provenance: "harness",
},
{
- id: "review",
- name: "Review",
+ id: gateStageId,
+ name: gateStageId === "security" ? "Security" : "Review",
order: 1,
zone: "doing",
roleIds: [],
@@ -116,6 +117,17 @@ function configureHumanGate(
gate: null,
provenance: "harness",
},
+ {
+ id: "done",
+ name: "Done",
+ order: 3,
+ zone: "done",
+ roleIds: [],
+ roles: [],
+ agents: [],
+ gate: null,
+ provenance: "harness",
+ },
],
},
});
@@ -321,4 +333,23 @@ describe("item routes", () => {
expect(res.writeHead).toHaveBeenCalledWith(422, { "Content-Type": "application/json" });
expect(res.end).toHaveBeenCalledWith(expect.stringContaining("decisão humana explícita"));
});
+
+ it("resolves the final human-approved gate to Done or back to Code", async () => {
+ const { deps, writeWorkflow, logEntry } = dependencies();
+ const value = workflowAtGate();
+ value.stages = [
+ { id: "security", name: "Security", order: 1, zone: "doing" },
+ { id: "code", name: "Code", order: 2, zone: "doing" },
+ { id: "done", name: "Done", order: 3, zone: "done" },
+ ];
+ value.items = [{ id: "ITEM-1", description: "", stage: "security", createdAt: "" }];
+ configureHumanGate(deps, { approve: "done", "request-changes": "code", reject: "code" }, "security");
+ const res = response();
+ const context = createRequestContext(request("POST", '{"decision":"approve"}'), res, new URL("http://localhost/api/items/ITEM-1/gate-decisions"), { workspaceRoot: "C:\\workspace", workspaceDir: "C:\\workspace\\.letra", workflow: value });
+ await createItemRoutes(deps)(context);
+ expect(value.items[0].stage).toBe("done");
+ expect(value.items[0].handoff).toBeUndefined();
+ expect(writeWorkflow).toHaveBeenCalled();
+ expect(logEntry).toHaveBeenCalledWith("C:\\workspace", "decision", expect.any(String), expect.objectContaining({ itemId: "ITEM-1" }));
+ });
});
diff --git a/packages/cli/src/flow-serve/routes/item-routes.ts b/packages/cli/src/flow-serve/routes/item-routes.ts
index 04aa0b7..5dece20 100644
--- a/packages/cli/src/flow-serve/routes/item-routes.ts
+++ b/packages/cli/src/flow-serve/routes/item-routes.ts
@@ -169,6 +169,23 @@ export function createItemRoutes(dependencies: ItemRouteDependencies): RouteHand
const sourceStage = item.stage;
item.stage = targetStage;
+ // A decision creates the next durable handoff. Without replacing the
+ // previous handoff, request-changes could leave the item addressed to
+ // the old role and the dispatcher would pause forever at the gate.
+ const targetStageDefinition = flow.stages.find((stage) => stage.id === targetStage);
+ const targetActor = targetStageDefinition?.agents[0];
+ if (targetActor) {
+ item.handoff = {
+ from: "human:web-ui",
+ to: targetActor,
+ summary: `Gate ${gate.name} decision ${data.decision}; continue at ${targetStageDefinition?.name ?? targetStage}.`,
+ evidence: [`gate:${gate.id}:${data.decision}`],
+ timestamp: new Date().toISOString(),
+ expiresAt: new Date(Date.now() + 30 * 60_000).toISOString(),
+ };
+ } else {
+ item.handoff = undefined;
+ }
workflow.updatedAt = new Date().toISOString();
await dependencies.writeWorkflow(workspaceRoot, {
workflow,
diff --git a/packages/cli/src/harness/default/v0.2.0/flows/flow-main.yaml b/packages/cli/src/harness/default/v0.2.0/flows/flow-main.yaml
index 887817f..b7331c7 100644
--- a/packages/cli/src/harness/default/v0.2.0/flows/flow-main.yaml
+++ b/packages/cli/src/harness/default/v0.2.0/flows/flow-main.yaml
@@ -186,7 +186,9 @@ stages:
description: Security agent analyzes vulnerabilities and dependencies.
agents:
- security
- gate: security-clear
+ # Security checks run automatically; the final transition to Done is
+ # released only by the human-approved gate handled by the UI/API.
+ gate: human-approved
preferredExecutor: opencode
phases:
initialState: scan
diff --git a/packages/cli/src/harness/default/v0.2.0/gates/human-approved.yaml b/packages/cli/src/harness/default/v0.2.0/gates/human-approved.yaml
index daf1a23..404cd5d 100644
--- a/packages/cli/src/harness/default/v0.2.0/gates/human-approved.yaml
+++ b/packages/cli/src/harness/default/v0.2.0/gates/human-approved.yaml
@@ -5,6 +5,6 @@ blocking: true
blocksHandoff: true
description: Final human approval before marking item as done.
decisions:
- approve: "All checks passed. Item is complete."
- request-changes: "Needs more work. Return to implementer."
- reject: "Item is not viable. Reject."
+ approve: done
+ request-changes: code
+ reject: backlog
diff --git a/packages/cli/src/harness/default/v0.2.0/gates/spec-approved.yaml b/packages/cli/src/harness/default/v0.2.0/gates/spec-approved.yaml
index 2cf4daf..6fa0619 100644
--- a/packages/cli/src/harness/default/v0.2.0/gates/spec-approved.yaml
+++ b/packages/cli/src/harness/default/v0.2.0/gates/spec-approved.yaml
@@ -5,6 +5,6 @@ blocking: true
blocksHandoff: true
description: Human approves the spec before implementation begins.
decisions:
- approve: "Spec is clear and complete. Proceed to implementation."
- request-changes: "Spec needs revisions. Return to analyst."
- reject: "Spec is not viable. Reject item."
+ approve: code
+ request-changes: design
+ reject: backlog
diff --git a/packages/cli/src/mcp/server.test.ts b/packages/cli/src/mcp/server.test.ts
index 4bab2f9..cabb6fe 100644
--- a/packages/cli/src/mcp/server.test.ts
+++ b/packages/cli/src/mcp/server.test.ts
@@ -82,19 +82,25 @@ describe("Letra MCP read-only server", () => {
"validate",
"complete_ac",
"request_transition",
+ "get_context",
+ "get_activity",
+ "claim",
+ "execution_event",
+ "submit_evidence",
+ "request_handoff",
"list_gates",
"list_roles",
]);
expect(
tools.tools.filter((tool) => tool.annotations?.readOnlyHint === true).length,
- ).toBe(5);
+ ).toBe(7);
expect(
tools.tools.filter((tool) => tool.annotations?.readOnlyHint === false).length,
- ).toBe(3);
+ ).toBe(7);
expect(
tools.tools.filter((tool) => tool.inputSchema?.additionalProperties === false)
.length,
- ).toBe(3);
+ ).toBe(8);
const direction = toolJson(
await client.callTool({ name: "get_direction", arguments: {} }),
diff --git a/packages/cli/src/mcp/server.ts b/packages/cli/src/mcp/server.ts
index 8ec7818..5f06694 100644
--- a/packages/cli/src/mcp/server.ts
+++ b/packages/cli/src/mcp/server.ts
@@ -13,6 +13,11 @@ import {
completeAcOperation,
requestTransitionOperation,
runValidationOperation,
+ claimOperation,
+ recordExecutionEvent,
+ activityOperation,
+ submitEvidenceOperation,
+ requestHandoffOperation,
} from "../domain-operations/service.js";
import { logEntry } from "../session-log.js";
import { createWorkspaceBoundary, type WorkspaceBoundary } from "../security/workspace-boundary.js";
@@ -238,6 +243,12 @@ export function createLetraMcpServer(root: string): McpServer {
}),
),
);
+ server.registerTool("get_context", { description: "Retorna contexto operacional canônico.", annotations: readOnlyAnnotations }, async () => jsonText(auditRead("context")));
+ server.registerTool("get_activity", { description: "Retorna atividade operacional vigente.", inputSchema: { itemId: z.string().optional() }, annotations: readOnlyAnnotations }, async ({ itemId }) => jsonText(activityOperation(workspaceRoot, itemId)));
+ server.registerTool("claim", { description: "Solicita claim exclusivo.", inputSchema: { itemId: z.string(), executorId: z.string(), capability: z.string(), expectedRevision, reason, ttlMinutes: z.number().optional() }, annotations: mutationAnnotations }, async (input) => jsonText(await claimOperation(workspaceRoot, { ...input, actor: clientIdentity().actor })));
+ server.registerTool("execution_event", { description: "Registra started, heartbeat, succeeded ou failed.", inputSchema: { itemId: z.string(), status: z.enum(["started", "heartbeat", "succeeded", "failed"]), executorId: z.string(), expectedRevision, reason, message: z.string().optional(), recovery: z.enum(["retry", "release", "handoff", "human"]).optional(), errorCode: z.string().optional() }, annotations: mutationAnnotations }, async (input) => jsonText(await recordExecutionEvent(workspaceRoot, { ...input, actor: clientIdentity().actor })));
+ server.registerTool("submit_evidence", { description: "Registra evidência estruturada confinada.", inputSchema: { itemId: z.string(), executorId: z.string(), expectedRevision, reason, evidence: z.array(z.object({ kind: z.enum(["diff", "file", "command", "test", "artifact"]), value: z.string(), source: z.string(), observedAt: z.string().optional(), sha256: z.string().optional(), exitCode: z.number().optional() })) }, annotations: mutationAnnotations }, async (input) => jsonText(await submitEvidenceOperation(workspaceRoot, { ...input, actor: clientIdentity().actor })));
+ server.registerTool("request_handoff", { description: "Solicita handoff atômico.", inputSchema: { itemId: z.string(), to: z.string(), executorId: z.string(), summary: z.string(), evidence: z.array(z.string()), expectedRevision, reason }, annotations: mutationAnnotations }, async (input) => jsonText(await requestHandoffOperation(workspaceRoot, { ...input, actor: clientIdentity().actor })));
server.registerResource(
"direction",
diff --git a/packages/cli/src/orchestrator/dispatcher.test.ts b/packages/cli/src/orchestrator/dispatcher.test.ts
new file mode 100644
index 0000000..3def3bb
--- /dev/null
+++ b/packages/cli/src/orchestrator/dispatcher.test.ts
@@ -0,0 +1,86 @@
+import { describe, expect, it, vi } from "vitest";
+import { PersistentDispatcher } from "./dispatcher.js";
+import { createSimulatedExecutor } from "./simulated-executor.js";
+
+describe("PersistentDispatcher", () => {
+ it("dispatches pending handoff and skips offline executors", async () => {
+ const execute = vi.fn().mockResolvedValue({ success: true, output: "ok", artifacts: [], evidences: [] });
+ const workflow = { items: [{ id: "I", description: "", stage: "code", createdAt: "", handoff: { from: "a", to: "b", summary: "", evidence: [], timestamp: "", expiresAt: "" } }], stages: [{ id: "code", name: "Code", order: 1 }], name: "", version: "1", createdAt: "", updatedAt: "", tools: [] } as any;
+ const store = { loadWorkflow: () => workflow, writeWorkflow: vi.fn(), advance: vi.fn() };
+ const result = await new PersistentDispatcher(store, () => [{ id: "offline", label: "", capabilities: [], status: "offline", execute }, { id: "online", label: "", capabilities: [], status: "online", execute }]).dispatch();
+ expect(result[0].status).toBe("dispatched"); expect(execute).toHaveBeenCalled(); expect(store.writeWorkflow).toHaveBeenCalled();
+ });
+
+ it("records simulated evidence and the next handoff", async () => {
+ const workflow = {
+ items: [{ id: "I", description: "", stage: "code", createdAt: "", handoff: { from: "design", to: "implementer", summary: "", evidence: [], timestamp: "", expiresAt: "" } }],
+ stages: [
+ { id: "code", name: "Code", order: 1, agents: ["implementer"] },
+ { id: "review", name: "Review", order: 2, agents: ["reviewer"] },
+ ], name: "", version: "1", createdAt: "", updatedAt: "", tools: [],
+ } as any;
+ const store = { loadWorkflow: () => workflow, writeWorkflow: vi.fn((next) => Object.assign(workflow, next)), advance: vi.fn() };
+ const result = await new PersistentDispatcher(store, () => [createSimulatedExecutor("simulated", ["code"])], 30_000).dispatch();
+ expect(result[0].status).toBe("dispatched");
+ expect(workflow.items[0].activityStatus).toBe("succeeded");
+ expect(workflow.items[0].stage).toBe("review");
+ expect(workflow.items[0].handoff?.to).toBe("reviewer");
+ expect(workflow.items[0].handoff?.evidence).toContain("simulated:code:I");
+ });
+
+ it("pauses a handoff addressed to the human", async () => {
+ const workflow = { items: [{ id: "I", description: "", stage: "security", createdAt: "", handoff: { from: "security", to: "human", summary: "Approve", evidence: [], timestamp: "", expiresAt: "" } }], stages: [{ id: "security", name: "Security", order: 1 }], name: "", version: "1", createdAt: "", updatedAt: "", tools: [] } as any;
+ const store = { loadWorkflow: () => workflow, writeWorkflow: vi.fn(), advance: vi.fn() };
+ const result = await new PersistentDispatcher(store, () => [createSimulatedExecutor("simulated")]).dispatch();
+ expect(result).toEqual([{ itemId: "I", status: "waiting-human", reason: "aguardando decisão humana" }]);
+ expect(store.writeWorkflow).not.toHaveBeenCalled();
+ });
+
+ it("lets the current stage owner execute before presenting its human gate", async () => {
+ const workflow = { items: [{ id: "I", description: "", stage: "security", createdAt: "", handoff: { from: "reviewer", to: "security", summary: "Scan", evidence: [], timestamp: "", expiresAt: "" } }], stages: [{ id: "security", name: "Security", order: 1, agents: ["security"], gate: "human-approved" }], name: "", version: "1", createdAt: "", updatedAt: "", tools: [] } as any;
+ const store = { loadWorkflow: () => workflow, writeWorkflow: vi.fn((next) => Object.assign(workflow, next)), advance: vi.fn() };
+ const result = await new PersistentDispatcher(store, () => [createSimulatedExecutor("simulated", ["security"])], 30_000, { blocksHandoff: (stage, item) => stage.gate === "human-approved" && item.handoff?.to !== (stage as typeof stage & { agents?: string[] }).agents?.[0] }).dispatch();
+ expect(result[0].status).toBe("dispatched");
+ expect(workflow.items[0].handoff?.to).toBe("human");
+ });
+
+ it("uses the durable claim operation when a store provides CAS", async () => {
+ const workflow = { items: [{ id: "I", description: "", stage: "code", createdAt: "", handoff: { from: "design", to: "implementer", summary: "", evidence: [], timestamp: "", expiresAt: "" } }], stages: [{ id: "code", name: "Code", order: 1 }], name: "", version: "1", createdAt: "", updatedAt: "", tools: [] } as any;
+ const claim = vi.fn().mockResolvedValue(false);
+ const store = { loadWorkflow: () => workflow, writeWorkflow: vi.fn(), advance: vi.fn(), claim };
+ const result = await new PersistentDispatcher(store, () => [createSimulatedExecutor("simulated")]).dispatch();
+ expect(result).toEqual([]);
+ expect(claim).toHaveBeenCalledWith("I", "simulated", "implementer");
+ expect(store.writeWorkflow).not.toHaveBeenCalled();
+ });
+
+ it("walks code, review and security until the final human handoff", async () => {
+ const workflow = {
+ items: [{ id: "I", description: "", stage: "code", createdAt: "", handoff: { from: "human", to: "implementer", summary: "Approved", evidence: [], timestamp: "", expiresAt: "" } }],
+ stages: [
+ { id: "code", name: "Code", order: 1, agents: ["implementer"] },
+ { id: "review", name: "Review", order: 2, agents: ["reviewer"] },
+ { id: "security", name: "Security", order: 3, agents: ["security"], gate: "human-approved" },
+ ], name: "", version: "1", createdAt: "", updatedAt: "", tools: [],
+ } as any;
+ const store = { loadWorkflow: () => workflow, writeWorkflow: vi.fn((next) => Object.assign(workflow, next)), advance: vi.fn() };
+ const dispatcher = new PersistentDispatcher(store, () => [createSimulatedExecutor("simulated")]);
+ await dispatcher.dispatch();
+ await dispatcher.dispatch();
+ await dispatcher.dispatch();
+ expect(workflow.items[0].stage).toBe("security");
+ expect(workflow.items[0].handoff?.to).toBe("human");
+ expect(workflow.items[0].lastHeartbeatAt).toEqual(expect.any(String));
+ });
+
+ it("releases a claim when an executor throws", async () => {
+ const workflow = { items: [{ id: "I", description: "", stage: "code", createdAt: "", handoff: { from: "design", to: "implementer", summary: "", evidence: [], timestamp: "", expiresAt: "" } }], stages: [{ id: "code", name: "Code", order: 1 }], name: "", version: "1", createdAt: "", updatedAt: "", tools: [] } as any;
+ const store = { loadWorkflow: () => workflow, writeWorkflow: vi.fn((next) => Object.assign(workflow, next)), advance: vi.fn() };
+ const failing = { id: "simulated", label: "", capabilities: ["code"], status: "online" as const, execute: vi.fn().mockRejectedValue(new Error("boom")) };
+ const result = await new PersistentDispatcher(store, () => [failing]).dispatch();
+ expect(result[0].status).toBe("failed");
+ expect(workflow.items[0].claimedBy).toBeUndefined();
+ expect(workflow.items[0].activityStatus).toBe("failed");
+ expect(workflow.items[0].lastFailure?.recovery).toBe("retry");
+ });
+});
diff --git a/packages/cli/src/orchestrator/dispatcher.ts b/packages/cli/src/orchestrator/dispatcher.ts
new file mode 100644
index 0000000..dc9e64a
--- /dev/null
+++ b/packages/cli/src/orchestrator/dispatcher.ts
@@ -0,0 +1,117 @@
+import type { Item, Workflow } from "../commands/flow-init.js";
+import type { AgenticExecutor } from "../executor/executor.js";
+import type { ExecutionContext, ExecutionResult } from "../harness/types.js";
+
+export interface DispatcherStore {
+ loadWorkflow(): Workflow;
+ writeWorkflow(workflow: Workflow): Promise | void;
+ advance(itemId: string, stage: string): Promise | void;
+ /** Optional durable CAS claim. Returning false means another worker won. */
+ claim?(itemId: string, executorId: string, agentId: string): Promise | boolean;
+}
+
+export interface DispatcherResult { itemId: string; status: "dispatched" | "waiting-human" | "offline" | "failed"; reason?: string; }
+
+export interface DispatcherOptions {
+ /** Human gates are the only gates that stop an autonomous handoff. */
+ blocksHandoff?: (stage: Workflow["stages"][number], item: Item) => boolean;
+ /** Resolve actors from the harness when workflow instances omit them. */
+ stageActors?: (stageId: string) => string[];
+ onResult?: (result: DispatcherResult) => void;
+}
+
+/** Durable polling coordinator: one item is claimed before an executor starts. */
+export class PersistentDispatcher {
+ private timer: ReturnType | undefined;
+ private running = false;
+ constructor(
+ private readonly store: DispatcherStore,
+ private readonly executors: () => AgenticExecutor[],
+ private readonly intervalMs = 30_000,
+ private readonly options: DispatcherOptions = {},
+ ) {}
+ start(): void { if (this.timer) return; void this.dispatch(); this.timer = setInterval(() => void this.dispatch(), this.intervalMs); }
+ stop(): void { if (this.timer) clearInterval(this.timer); this.timer = undefined; }
+ async dispatch(): Promise {
+ if (this.running) return [];
+ this.running = true;
+ try {
+ const workflow = this.store.loadWorkflow();
+ const results: DispatcherResult[] = [];
+ for (const item of workflow.items) {
+ if (!item.handoff || item.claimedBy) continue;
+ const stage = workflow.stages.find((entry) => entry.id === item.stage);
+ if (!stage) continue;
+ if (item.handoff.to === "human" || item.handoff.to.startsWith("human:")) {
+ const result = { itemId: item.id, status: "waiting-human" as const, reason: "aguardando decisão humana" };
+ results.push(result); this.options.onResult?.(result); continue;
+ }
+ if (this.options.blocksHandoff?.(stage, item)) {
+ const result = { itemId: item.id, status: "waiting-human" as const, reason: "gate humano bloqueante" };
+ results.push(result); this.options.onResult?.(result); continue;
+ }
+ const capability = stage.id;
+ const online = this.executors().filter((entry) => entry.status !== "offline");
+ const executor = online.find((entry) => entry.capabilities.includes(capability)) ?? online[0];
+ if (!executor) { results.push({ itemId: item.id, status: "offline", reason: "nenhum executor online" }); continue; }
+ const claimed: Item = {
+ ...item,
+ claimedBy: item.handoff.to,
+ claimedAt: new Date().toISOString(),
+ claimExecutorId: executor.id,
+ activityStatus: "started",
+ activityStartedAt: new Date().toISOString(),
+ lastHeartbeatAt: new Date().toISOString(),
+ };
+ if (this.store.claim) {
+ const accepted = await this.store.claim(item.id, executor.id, item.handoff.to);
+ if (!accepted) continue;
+ } else {
+ const nextWorkflow = { ...workflow, items: workflow.items.map((entry) => entry.id === item.id ? claimed : entry) };
+ await this.store.writeWorkflow(nextWorkflow);
+ }
+ try {
+ if (typeof (executor as AgenticExecutor & { heartbeat?: () => Promise }).heartbeat === "function") {
+ await (executor as AgenticExecutor & { heartbeat: () => Promise }).heartbeat();
+ }
+ const execution = await executor.execute({ itemId: claimed.id, item: claimed, agent: item.handoff.to, stage: item.stage, spec: claimed.spec ?? null, diff: null, snapshot: { stages: workflow.stages.map((entry) => ({ ...entry, agents: this.options.stageActors?.(entry.id) ?? (entry as typeof entry & { agents?: string[] }).agents })) }, sessionLog: [], commands: [], prohibitions: [] } satisfies ExecutionContext);
+ const current = this.store.loadWorkflow();
+ const updated = current.items.map((entry) => {
+ if (entry.id !== item.id) return entry;
+ const next: Item = { ...entry, claimedBy: undefined, claimedAt: undefined, claimExecutorId: undefined, activityStatus: execution.success ? "succeeded" : "failed", lastHeartbeatAt: new Date().toISOString() };
+ if (execution.success && execution.handoff) {
+ const targetStage = workflow.stages.find((candidate) =>
+ (this.options.stageActors?.(candidate.id) ?? (candidate as typeof candidate & { agents?: string[] }).agents ?? []).includes(execution.handoff!.to),
+ );
+ if (targetStage) next.stage = targetStage.id;
+ next.handoff = {
+ from: execution.handoff.from,
+ to: execution.handoff.to,
+ summary: execution.handoff.summary,
+ evidence: execution.handoff.evidence ?? execution.evidences ?? [],
+ timestamp: execution.handoff.timestamp,
+ expiresAt: new Date(Date.now() + 30 * 60_000).toISOString(),
+ executorId: executor.id,
+ };
+ }
+ if (!execution.success) next.lastFailure = { code: "EXECUTOR_FAILED", message: execution.error ?? execution.output, recovery: "retry", at: new Date().toISOString() };
+ return next;
+ });
+ await this.store.writeWorkflow({ ...current, items: updated, updatedAt: new Date().toISOString() });
+ const result = execution.success ? { itemId: item.id, status: "dispatched" as const } : { itemId: item.id, status: "failed" as const, reason: execution.error ?? execution.output };
+ results.push(result); this.options.onResult?.(result);
+ } catch (error) {
+ const current = this.store.loadWorkflow();
+ const message = error instanceof Error ? error.message : String(error);
+ const recoveredItems = current.items.map((entry) => entry.id === item.id
+ ? { ...entry, claimedBy: undefined, claimedAt: undefined, claimExecutorId: undefined, activityStatus: "failed" as const, lastFailure: { code: "EXECUTOR_EXCEPTION", message, recovery: "retry", at: new Date().toISOString() } }
+ : entry);
+ await this.store.writeWorkflow({ ...current, items: recoveredItems, updatedAt: new Date().toISOString() });
+ const result = { itemId: item.id, status: "failed" as const, reason: error instanceof Error ? error.message : String(error) };
+ results.push(result); this.options.onResult?.(result);
+ }
+ }
+ return results;
+ } finally { this.running = false; }
+ }
+}
diff --git a/packages/cli/src/orchestrator/simulated-executor.ts b/packages/cli/src/orchestrator/simulated-executor.ts
new file mode 100644
index 0000000..ebf0ace
--- /dev/null
+++ b/packages/cli/src/orchestrator/simulated-executor.ts
@@ -0,0 +1,48 @@
+import type { RichAgenticExecutor } from "../executor/executor.js";
+import type { ExecutionContext, ExecutionResult } from "../harness/types.js";
+
+/**
+ * Deterministic executor used by the local semiautonomous flow preview.
+ * It produces auditable evidence and hands off to the next configured role;
+ * it never edits the project or approves a human gate.
+ */
+export function createSimulatedExecutor(
+ id: string,
+ capabilities: string[] = ["design", "code", "review", "security"],
+): RichAgenticExecutor {
+ return {
+ id,
+ label: `Simulated ${id}`,
+ capabilities,
+ status: "online",
+ heartbeat: async () => undefined,
+ async execute(context: ExecutionContext): Promise {
+ const stages = Array.isArray((context.snapshot as { stages?: unknown[] })?.stages)
+ ? ((context.snapshot as { stages: Array<{ id: string; order: number; agents?: string[] }> }).stages)
+ : [];
+ const current = stages.find((stage) => stage.id === context.stage);
+ const next = current
+ ? stages
+ .filter((stage) => stage.order > current.order)
+ .sort((a, b) => a.order - b.order)[0]
+ : undefined;
+ const target = next?.agents?.[0] ?? "human";
+ const timestamp = new Date().toISOString();
+ return {
+ success: true,
+ output: `Simulated execution completed for ${context.itemId} at ${context.stage}`,
+ artifacts: [],
+ evidences: [`simulated:${context.stage}:${context.itemId}`],
+ handoff: {
+ type: "handoff",
+ itemId: context.itemId,
+ from: id,
+ to: target,
+ summary: `Simulated ${context.stage} completed; awaiting ${target}.`,
+ evidence: [`simulated:${context.stage}:${context.itemId}`],
+ timestamp,
+ },
+ };
+ },
+ };
+}
diff --git a/packages/cli/src/session-log.ts b/packages/cli/src/session-log.ts
index b4b6f8a..5432817 100644
--- a/packages/cli/src/session-log.ts
+++ b/packages/cli/src/session-log.ts
@@ -36,6 +36,8 @@ export type LogAction =
| "agent_direction_read"
| "agent_validation_run"
| "agent_ac_completion_requested"
+ | "agent_claim_requested"
+ | "agent_execution_event"
| "agent_transition_requested"
| "agent_operation_rejected"
| "constitution_read"
diff --git a/packages/client/src/App.tsx b/packages/client/src/App.tsx
index 5d330f5..7a86ef8 100644
--- a/packages/client/src/App.tsx
+++ b/packages/client/src/App.tsx
@@ -9,6 +9,7 @@ import FlowView from "./components/Flow/FlowView";
import ContextView from "./components/Context/ContextView";
import type { KnowledgeTab } from "./components/Context/ContextView";
import AuditLogView from "./components/Logs/AuditLogView";
+import AgentsView from "./components/Agents/AgentsView";
import WorkspacesView from "./components/Workspaces/WorkspacesView";
import WorkspaceSettings from "./components/Workspaces/WorkspaceSettings/WorkspaceSettings";
import type { WorkspaceData } from "./components/Workspaces/WorkspacesView";
@@ -306,6 +307,8 @@ function AppContent() {
return ;
case "activity":
return ;
+ case "agents":
+ return ;
case "settings":
return (
([]); const [draft,setDraft]=useState(null); const [error,setError]=useState(""); const [saving,setSaving]=useState(false);
+ const load=()=>fetch("/api/agents").then(async r=>{if(!r.ok)throw new Error("Não foi possível carregar a equipe");return r.json();}).then(d=>Array.isArray(d)&&setAgents(d)).catch(e=>setError(e.message)); useEffect(()=>{load();},[]);
+ const field=(k:K,v:AgentIdentity[K])=>setDraft(d=>d?{...d,[k]:v}:d);
+ function editSkills(v:string){field("skills",v.split(",").map((label):AgentSkill=>({id:label.trim().toLowerCase().replace(/\s+/g,"-"),label:label.trim(),level:"intermediate",category:"engineering"})).filter(s=>s.label));}
+ async function save(){if(!draft?.id||!draft.displayName.trim()){setError("ID e nome são obrigatórios");return;}setSaving(true);setError("");const exists=agents.some(a=>a.id===draft.id);try{const r=await fetch(exists?`/api/agents/${encodeURIComponent(draft.id)}`:"/api/agents",{method:exists?"PATCH":"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(draft)});if(!r.ok)throw new Error((await r.json()).error??"Falha ao salvar agente");setDraft(null);await load();}catch(e){setError((e as Error).message);}finally{setSaving(false);}}
+ return Equipe de agentes
Identidades, skills e status do harness.
{error&&
{error}
}
{agents.map(a=>
{a.displayName}
{a.role} · {a.status}
{a.bio}
{a.skills.map(s=>{s.label} · {s.level})}
)}
{draft&&
{agents.some(a=>a.id===draft.id)?"Editar agente":"Novo agente"}
}
;
+}
diff --git a/packages/client/src/components/Flow/FlowConfigurationMap.test.tsx b/packages/client/src/components/Flow/FlowConfigurationMap.test.tsx
new file mode 100644
index 0000000..4b4715e
--- /dev/null
+++ b/packages/client/src/components/Flow/FlowConfigurationMap.test.tsx
@@ -0,0 +1,25 @@
+import { render, screen } from "@testing-library/react";
+import { describe, expect, it } from "vitest";
+import type { ActiveFlowDefinition } from "../../lib/active-flow";
+import FlowConfigurationMap from "./FlowConfigurationMap";
+
+const flow: ActiveFlowDefinition = {
+ id: "flow-test", source: "workflow-template", harnessVersion: "v2", templateVersion: "1",
+ name: "Test flow", warnings: [], roles: [{ id: "builder", label: "Builder", description: "", allowedStages: ["code"], capabilities: ["write", "test"] }],
+ stages: [{ id: "code", name: "Code", order: 1, zone: "doing", description: "Implementa", roleIds: ["builder"], roles: [{ id: "builder", label: "Builder", description: "", allowedStages: ["code"], capabilities: ["write", "test"] }], agents: ["builder"], preferredExecutor: "opencode", gate: null, provenance: "harness", phases: { initialState: "work", states: { work: { id: "work", label: "Trabalho", description: "", transitions: [{ target: "review", gate: null }] } } } }],
+};
+
+describe("FlowConfigurationMap", () => {
+ it("renders the complete harness contract", () => {
+ render();
+ expect(screen.getByText("Executor: opencode")).toBeTruthy();
+ expect(screen.getByText("Fases: Trabalho")).toBeTruthy();
+ expect(screen.getByText("Transições: Trabalho → review")).toBeTruthy();
+ expect(screen.getByText("write")).toBeTruthy();
+ });
+
+ it("labels legacy provenance when no active flow exists", () => {
+ render();
+ expect(screen.getByText(/fonte: instância do workflow/)).toBeTruthy();
+ });
+});
diff --git a/packages/client/src/components/Flow/FlowConfigurationMap.tsx b/packages/client/src/components/Flow/FlowConfigurationMap.tsx
new file mode 100644
index 0000000..ad6e1a2
--- /dev/null
+++ b/packages/client/src/components/Flow/FlowConfigurationMap.tsx
@@ -0,0 +1,110 @@
+import { Card, CardContent, Icon, Tag } from "@letra/ui";
+import type { ActiveFlowDefinition, ActiveFlowStage } from "../../lib/active-flow";
+import type { Item } from "@letra/types";
+
+interface Props {
+ stages: ActiveFlowStage[];
+ activeFlow: ActiveFlowDefinition | null;
+ items?: Item[];
+}
+
+function stageActor(stage: ActiveFlowStage): string {
+ if (stage.gate?.type === "human") return "Humano";
+ return stage.roles[0]?.label ?? stage.roleIds[0] ?? "Sem papel";
+}
+
+function stageTransitions(stage: ActiveFlowStage): string[] {
+ const transitions = Object.values(stage.phases?.states ?? {}).flatMap((phase) =>
+ (phase.transitions ?? []).map((transition) => `${phase.label} → ${transition.target}`),
+ );
+ return [...new Set(transitions)];
+}
+
+export default function FlowConfigurationMap({ stages, activeFlow, items = [] }: Props) {
+ return (
+
+
+
+
+
+
+
Configuração da esteira
+
+
+ {activeFlow?.name ?? "Fluxo legado"} · {activeFlow ? `fonte: ${activeFlow.source}` : "fonte: instância do workflow"}
+
+
+ {activeFlow?.harnessVersion ?
harness {activeFlow.harnessVersion} : null}
+
+
+ {stages.map((stage, index) => {
+ const role = stage.roles[0];
+ const configuredGate = stage.gate;
+ const expectedGate = stage.activity?.gate;
+ const isHuman = configuredGate?.type === "human" && configuredGate.blocking;
+ const hasHumanApproval = isHuman || Boolean(expectedGate);
+ const itemsInStage = items?.filter((item) => item.stage === stage.id).length ?? 0;
+ const explicitTransitions = stageTransitions(stage);
+ const nextStage = stages[index + 1];
+ return (
+
+
+
+
+ {stage.name}
+
+
+
Atua: {stageActor(stage)}
+ {role?.description ?
{role.description}
: null}
+
+ {role?.capabilities?.length ? (
+
+ {role.capabilities.map((capability) => {capability})}
+
+ ) : null}
+
+ Executor: {stage.preferredExecutor ?? "fallback do registro"}
+
+ {stage.phases ? (
+
+ Fases: {Object.values(stage.phases.states).map((phase) => phase.label).join(" · ")}
+
+ ) : null}
+ {explicitTransitions.length > 0 ? (
+
+ Transições: {explicitTransitions.join(" · ")}
+
+ ) : nextStage ? (
+
+ Transição: concluir critérios e validação → {nextStage.name}
+
+ ) : null}
+ {configuredGate ?
{isHuman ? "Gate humano" : "Gate automático"}: {configuredGate.name}
: null}
+ {expectedGate ? (
+
+
Aprovação humana: {expectedGate.label ?? "necessária"}
+ {expectedGate.decision ?
Decisão: {expectedGate.decision}
: null}
+ {expectedGate.evidence ?
Evidências: {expectedGate.evidence}
: null}
+ {itemsInStage > 0 ?
{itemsInStage} item(ns) aguardam este gate
:
Nenhum item aguardando agora
}
+
+ ) : hasHumanApproval && itemsInStage > 0 ?
Aprovação pendente para {itemsInStage} item(ns)
: null}
+
+ {index < stages.length - 1 ?
: null}
+
+ );
+ })}
+
+
+
+ );
+}
diff --git a/packages/client/src/components/Flow/FlowView.tsx b/packages/client/src/components/Flow/FlowView.tsx
index 0050842..e64d08a 100644
--- a/packages/client/src/components/Flow/FlowView.tsx
+++ b/packages/client/src/components/Flow/FlowView.tsx
@@ -3,6 +3,7 @@ import type { ResolvedSpec, Workflow } from "@letra/types";
import type { ActiveFlowDefinition } from "../../lib/active-flow";
import KanbanBoard from "./KanbanBoard";
import ActivityTimeline from "./ActivityTimeline";
+import FlowConfigurationMap from "./FlowConfigurationMap";
import ItemDetailModal from "./ItemDetailModal";
import { cn } from "../../lib/utils";
import {
@@ -548,6 +549,7 @@ export default function FlowView({
{/* ─── Left Column: Kanban ─── */}
diff --git a/packages/client/src/components/Flow/ItemDetailModal.tsx b/packages/client/src/components/Flow/ItemDetailModal.tsx
index fff3dbe..f139406 100644
--- a/packages/client/src/components/Flow/ItemDetailModal.tsx
+++ b/packages/client/src/components/Flow/ItemDetailModal.tsx
@@ -124,6 +124,8 @@ export default function ItemDetailModal({
const [specLoading, setSpecLoading] = useState(true);
const [activities, setActivities] = useState([]);
const [activitiesLoaded, setActivitiesLoaded] = useState(false);
+ const [activityPage, setActivityPage] = useState(1);
+ const [activityTotal, setActivityTotal] = useState(0);
const [showTimeline, setShowTimeline] = useState(false);
const [showAdvancedActions, setShowAdvancedActions] = useState(false);
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
@@ -147,6 +149,9 @@ export default function ItemDetailModal({
const stageAction = curStage ? stageActionLabel(curStage) : "Item registrado no fluxo.";
const owner = item.claimedBy ?? curStage?.roles[0]?.label ?? "Não atribuído";
const availableStages = resolvedStages.filter((stage) => stage.id !== item.stage);
+ const heartbeatAge = item.lastHeartbeatAt ? Date.now() - new Date(item.lastHeartbeatAt).getTime() : null;
+ const heartbeatStale = heartbeatAge !== null && heartbeatAge > 60_000;
+ const retryCount = activities.filter((entry) => /retry|retrying|re-emit/i.test(`${entry.action} ${entry.description}`)).length;
useEffect(() => {
prevFocusRef.current = document.activeElement as HTMLElement;
@@ -207,16 +212,24 @@ export default function ItemDetailModal({
return () => clearTimeout(timer);
}, []);
- useEffect(() => {
- fetch(`/api/log?item=${item.id}&limit=100`)
+ const loadActivities = useCallback((page: number) => {
+ setActivitiesLoaded(false);
+ fetch(`/api/log?item=${item.id}&limit=50&page=${page}`)
.then((response) => response.json())
.then((data) => {
- if (data.entries) setActivities(data.entries);
+ if (data.entries) setActivities((current) => page === 1 ? data.entries : [...current, ...data.entries]);
+ if (typeof data.total === "number") setActivityTotal(data.total);
})
.catch(() => {})
.finally(() => setActivitiesLoaded(true));
}, [item.id]);
+ useEffect(() => {
+ setActivityPage(1);
+ setActivities([]);
+ loadActivities(1);
+ }, [loadActivities]);
+
const handleMove = useCallback(() => {
if (!moveTarget) return;
fetch(`/api/items/${item.id}`, {
@@ -364,6 +377,31 @@ export default function ItemDetailModal({
+
+
+
+
Execução e handoff
+ {heartbeatStale ? Heartbeat expirado : null}
+
+
+ {item.handoff ? (
+
+ Handoff: {item.handoff.from} → {item.handoff.to}
+ {item.handoff.summary}
+ Expira em {item.handoff.expiresAt} · {item.handoff.evidence.length} evidência(s)
+ {item.handoff.evidence.length > 0 ? {item.handoff.evidence.join(" · ")} : Nenhuma evidência anexada ao handoff.}
+
+ ) : Nenhum handoff pendente.
}
+ {item.lastFailure ? Falha {item.lastFailure.code}: {item.lastFailure.message} · recuperação: {item.lastFailure.recovery}
: null}
+
+
+
Movimentação
@@ -426,6 +464,8 @@ export default function ItemDetailModal({
activities.length}
+ onLoadMore={() => { const next = activityPage + 1; setActivityPage(next); loadActivities(next); }}
open={showTimeline}
onOpenChange={setShowTimeline}
/>
@@ -591,11 +631,15 @@ function TaskList({
function EventLog({
activities,
loaded,
+ hasMore,
+ onLoadMore,
open,
onOpenChange,
}: {
activities: ActivityEntry[];
loaded: boolean;
+ hasMore: boolean;
+ onLoadMore: () => void;
open: boolean;
onOpenChange: (open: boolean) => void;
}) {
@@ -623,7 +667,7 @@ function EventLog({
Nenhum evento registrado para este item.
) : (
- activities.map((entry) => (
+ activities.map((entry) => (
- ))
- )}
+ ))
+ )}
+ {loaded && hasMore ? : null}
diff --git a/packages/client/src/components/Flow/KanbanBoard.tsx b/packages/client/src/components/Flow/KanbanBoard.tsx
index dcbe1a1..ea0c3ee 100644
--- a/packages/client/src/components/Flow/KanbanBoard.tsx
+++ b/packages/client/src/components/Flow/KanbanBoard.tsx
@@ -1,8 +1,10 @@
import { useState, useCallback, useRef, useEffect } from "react";
import type { ResolvedSpec, Workflow, Item } from "@letra/types";
-import { Badge, Icon, Button, Progress, Card, CardContent, Tag } from "@letra/ui";
+import { Badge, Icon, Button, Progress, Card, CardContent, Tag, AgentAvatar } from "@letra/ui";
+import type { AgentIdentity } from "@letra/types";
import { cn } from "../../lib/utils";
import { computeSlug } from "../../lib/item-utils";
+import { projectKanbanCard } from "../../lib/kanban-card-projection";
import {
doneStageIds,
humanGateStageIds,
@@ -25,6 +27,7 @@ interface Props {
allowDrop?: (item: Workflow["items"][0], targetStageId: string) => boolean;
specRefreshKey?: number;
onAddItem?: () => void;
+ onOpenSpec?: () => void;
filter?: string;
className?: string;
}
@@ -126,7 +129,9 @@ function ItemCard({
workflow,
activeFlow,
specs,
+ agents,
onClick,
+ onOpenSpec,
onDragStart,
onDragEnd,
}: {
@@ -134,12 +139,14 @@ function ItemCard({
workflow: Workflow;
activeFlow: ActiveFlowDefinition | null;
specs: ResolvedSpec[];
+ agents: AgentIdentity[];
onClick: () => void;
+ onOpenSpec?: () => void;
onDragStart: (e: React.DragEvent) => void;
onDragEnd: (e: React.DragEvent) => void;
}) {
const slug = computeSlug(item, specs, workflow);
- const daysInStage = Math.floor((Date.now() - new Date(item.createdAt).getTime()) / 86400000);
+ const projection = projectKanbanCard(item, workflow, activeFlow, specs, agents);
const isHumanGate = humanGateStageIds(workflow, activeFlow).has(item.stage);
const state = computeItemState(itemOperationalState(item, workflow, activeFlow));
@@ -169,11 +176,9 @@ function ItemCard({
source: "Evidência",
};
- const resolvedStage = orderedStages(workflow, activeFlow).find(
- (stage) => stage.id === item.stage,
- );
- const agentName = item.claimedBy ?? resolvedStage?.roles[0]?.label ?? "Não atribuído";
- const agentAction = resolvedStage ? stageActionLabel(resolvedStage) : "Processando";
+ const agent = projection.identity;
+ const agentName = projection.identity?.displayName ?? "Não atribuído";
+ const persona = projection.persona;
const isRunning = state.key === "running";
const hasProgress = progress.total > 0;
const progressValue = progress.total > 0 ? progress.done : 0;
@@ -189,7 +194,6 @@ function ItemCard({
? "agent"
: "default";
const title = item.description?.trim() || linkedSpec?.id || slug;
- const ageLabel = daysInStage === 0 ? "Hoje no fluxo" : `${daysInStage}d no fluxo`;
const cardBorder =
state.key === "blocked"
? "var(--color-danger)"
@@ -222,7 +226,7 @@ function ItemCard({
data-gate={isHumanGate ? "true" : "false"}
data-running={isRunning ? "true" : "false"}
draggable
- role="button"
+ role="group"
tabIndex={0}
aria-label={`Abrir ${item.id}: ${title}`}
onClick={onClick}
@@ -255,22 +259,12 @@ function ItemCard({
-
-
- {resolvedStage?.name ?? item.stage}
- {ageLabel}
-
-
- {linkedSpec ? `Especificação ${linkedSpec.id}` : `Evidência ${slug}`}
-
-
-
-
+ {agent ? : }
{agentName}
-
{agentAction}
+
{persona}
{hasProgress ? (
@@ -292,13 +286,10 @@ function ItemCard({
) : null}
-
-
-
-
- {state.action}
-
-
+
+
@@ -315,12 +306,14 @@ export default function KanbanBoard({
allowDrop,
specRefreshKey,
onAddItem,
+ onOpenSpec,
filter = "all",
className,
}: Props) {
const [dragOver, setDragOver] = useState
(null);
const [draggingId, setDraggingId] = useState(null);
const [specs, setSpecs] = useState([]);
+ const [agents, setAgents] = useState([]);
const dragItem = useRef(null);
const loadSpecs = useCallback(async () => {
@@ -336,6 +329,7 @@ export default function KanbanBoard({
useEffect(() => {
loadSpecs();
+ fetch("/api/agents").then((r) => r.json()).then((d) => Array.isArray(d) && setAgents(d)).catch(() => {});
}, [loadSpecs]);
useEffect(() => {
@@ -476,7 +470,9 @@ export default function KanbanBoard({
workflow={workflow}
activeFlow={activeFlow}
specs={specs}
+ agents={agents}
onClick={() => onSelectItem(item.id)}
+ onOpenSpec={item.spec && onOpenSpec ? onOpenSpec : undefined}
onDragStart={(e) => handleDragStart(e, item.id)}
onDragEnd={handleDragEnd}
/>
diff --git a/packages/client/src/components/Sidebar/Sidebar.tsx b/packages/client/src/components/Sidebar/Sidebar.tsx
index a765c21..0607e12 100644
--- a/packages/client/src/components/Sidebar/Sidebar.tsx
+++ b/packages/client/src/components/Sidebar/Sidebar.tsx
@@ -16,7 +16,7 @@ import type { IconName } from "@letra/ui";
import LogoDiamond from "../Header/LogoDiamond";
import type { WorkspaceData } from "../Workspaces/WorkspacesView";
-export type Tab = "supervision" | "work" | "knowledge" | "activity" | "settings";
+export type Tab = "supervision" | "work" | "knowledge" | "activity" | "agents" | "settings";
interface SidebarProps {
activeTab: Tab;
@@ -41,6 +41,7 @@ const PRIMARY_DESTINATIONS: NavItem[] = [
{ id: "work", label: "Trabalho", icon: "grid", color: "var(--color-primary)" },
{ id: "knowledge", label: "Conhecimento e Regras", icon: "book", color: "var(--color-agent)" },
{ id: "activity", label: "Atividade", icon: "activity", color: "var(--color-success)" },
+ { id: "agents", label: "Agentes", icon: "user", color: "var(--color-agent)" },
];
const SETTINGS_ITEMS: NavItem[] = [
diff --git a/packages/client/src/lib/agent-resolver.ts b/packages/client/src/lib/agent-resolver.ts
new file mode 100644
index 0000000..3b5d088
--- /dev/null
+++ b/packages/client/src/lib/agent-resolver.ts
@@ -0,0 +1,8 @@
+import type { AgentIdentity } from "@letra/types";
+const roleAliases: Record = { builder: "implementer", implementer: "implementer" };
+
+export function resolveAgent(agents: AgentIdentity[], claimedBy?: string | null, actor?: { agentId?: string; toolId?: string }, executorId?: string | null): AgentIdentity | undefined {
+ const raw=[actor?.agentId,claimedBy,executorId,actor?.toolId].filter(Boolean) as string[];
+ const ids=[...raw, ...raw.map((id) => roleAliases[id] ?? id)];
+ return agents.find(a=>ids.includes(a.id)) ?? agents.find(a=>ids.includes(a.role));
+}
diff --git a/packages/client/src/lib/kanban-card-projection.test.ts b/packages/client/src/lib/kanban-card-projection.test.ts
new file mode 100644
index 0000000..b3c8322
--- /dev/null
+++ b/packages/client/src/lib/kanban-card-projection.test.ts
@@ -0,0 +1,11 @@
+import { describe, expect, it } from "vitest";
+import { projectKanbanCard } from "./kanban-card-projection";
+describe("KanbanCardProjection", () => {
+ it("resolves identity and hides raw claim aliases", () => {
+ const item = { id: "I", description: "Work", stage: "code", createdAt: "" } as any;
+ const workflow = { stages: [{ id: "code", name: "Code", order: 1 }], items: [item] } as any;
+ const agent = { id: "turing", displayName: "Alan Turing", role: "implementer", avatar: { type: "initials", value: "AT" }, color: "blue", skills: [], status: "online", stageBindings: [] } as any;
+ const result = projectKanbanCard({ ...item, claimedBy: "builder" }, workflow, null, [], [agent]);
+ expect(result.identity?.displayName).toBe("Alan Turing"); expect(result.persona).toBe("implementer"); expect(result.actionLabel).toBe("Acompanhar trabalho ativo");
+ });
+});
diff --git a/packages/client/src/lib/kanban-card-projection.ts b/packages/client/src/lib/kanban-card-projection.ts
new file mode 100644
index 0000000..06f155e
--- /dev/null
+++ b/packages/client/src/lib/kanban-card-projection.ts
@@ -0,0 +1,6 @@
+import type { AgentIdentity, Item, ResolvedSpec, Workflow } from "@letra/types";
+import { computeSlug, countACs } from "./item-utils";
+import { itemOperationalState, orderedStages, type ActiveFlowDefinition, type OperationalState } from "./active-flow";
+import { resolveAgent } from "./agent-resolver";
+export interface KanbanCardProjection { itemId: string; title: string; stage: string; stageLabel: string; state: OperationalState; stateLabel: string; progress: { done: number; total: number }; identity: AgentIdentity | null; persona: string; actionLabel: "Acompanhar trabalho ativo"; }
+export function projectKanbanCard(item: Item, workflow: Workflow, activeFlow: ActiveFlowDefinition | null, specs: ResolvedSpec[], agents: AgentIdentity[]): KanbanCardProjection { const spec = item.spec ? specs.find((entry) => entry.id === item.spec) : undefined; const ac = spec ? countACs(spec.content) : { done: 0, total: 0 }; const stage = orderedStages(workflow, activeFlow).find((entry) => entry.id === item.stage); const stageRoleId = stage?.roles[0]?.id ?? stage?.roleIds[0]; const identity = resolveAgent(agents, item.claimedBy, { agentId: stageRoleId }, item.claimExecutorId) ?? null; const state = itemOperationalState(item, workflow, activeFlow); return { itemId: item.id, title: item.description?.trim() || spec?.id || computeSlug(item, specs, workflow), stage: item.stage, stageLabel: stage?.name ?? item.stage, state, stateLabel: state === "waiting" ? "Aguardando decisão" : state === "running" ? "Em execução" : state === "done" ? "Concluído" : state === "blocked" ? "Bloqueado" : "Na fila", progress: ac, identity, persona: identity?.role ?? stage?.roles[0]?.label ?? "Sem persona", actionLabel: "Acompanhar trabalho ativo" }; }
diff --git a/packages/types/src/external-protocol.test.ts b/packages/types/src/external-protocol.test.ts
new file mode 100644
index 0000000..50b6905
--- /dev/null
+++ b/packages/types/src/external-protocol.test.ts
@@ -0,0 +1,36 @@
+import { describe, expect, it } from "vitest";
+import type {
+ ExternalProtocolActor,
+ ExternalProtocolContext,
+ ExternalProtocolExecutor,
+} from "./index.js";
+
+describe("external executor protocol types", () => {
+ it("describes the actor and executor identity required by AC1", () => {
+ const actor: ExternalProtocolActor = {
+ agentId: "agent-analyst-1",
+ displayName: "Analyst",
+ toolId: "claude-code",
+ toolVersion: "1.0.0",
+ };
+ const executor: ExternalProtocolExecutor = {
+ id: "claude-code-local",
+ capabilities: ["read_context", "write_code"],
+ status: "online",
+ transport: "cli",
+ maxExecutionTime: 1800,
+ };
+ const context: ExternalProtocolContext = {
+ schemaVersion: "1",
+ workspace: { workspaceId: "ws_letra", workspaceRoot: "C:/Workspace/letra" },
+ actor,
+ executor,
+ revision: "sha256:" + "a".repeat(64),
+ timestamp: "2026-09-08T00:00:00.000Z",
+ };
+
+ expect(context.actor.toolId).toBe("claude-code");
+ expect(context.executor.capabilities).toContain("write_code");
+ expect(context.workspace.workspaceId).toBe("ws_letra");
+ });
+});
diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts
index f28b837..d24deff 100644
--- a/packages/types/src/index.ts
+++ b/packages/types/src/index.ts
@@ -1,3 +1,95 @@
+/** Versioned identity of the agent/tool making a protocol request. */
+export interface ExternalProtocolActor {
+ agentId: string;
+ displayName: string;
+ toolId: string;
+ toolVersion: string;
+}
+
+/** Capabilities an external executor advertises to the Letra control plane. */
+export type ExternalProtocolCapability = string;
+
+/** Stable workspace reference carried by protocol messages. */
+export interface ExternalProtocolWorkspaceRef {
+ workspaceId: string;
+ workspaceRoot: string;
+}
+
+/** Versioned description of an external executor and its transport. */
+export interface ExternalProtocolExecutor {
+ id: string;
+ capabilities: ExternalProtocolCapability[];
+ status: "online" | "offline" | "busy";
+ transport: "cli" | "mcp" | "webhook" | "file";
+ maxExecutionTime?: number;
+}
+
+/** Common envelope for requests made against the external executor protocol. */
+export interface ExternalProtocolContext {
+ schemaVersion: "1";
+ workspace: ExternalProtocolWorkspaceRef;
+ actor: ExternalProtocolActor;
+ executor: ExternalProtocolExecutor;
+ revision: string;
+ timestamp: string;
+}
+
+export type ExternalProtocolEventStatus = "started" | "heartbeat" | "succeeded" | "failed";
+
+export interface ExternalProtocolEvent {
+ schemaVersion: "1";
+ status: ExternalProtocolEventStatus;
+ itemId: string;
+ actor: ExternalProtocolActor;
+ executorId: string;
+ revision: string;
+ timestamp: string;
+ message?: string;
+ metadata?: Record;
+}
+
+export interface ExternalProtocolEvidence {
+ kind: "diff" | "file" | "command" | "test" | "artifact";
+ value: string;
+ sha256?: string;
+ exitCode?: number;
+ observedAt: string;
+ source: string;
+}
+
+export interface ExternalProtocolFailure extends ExternalProtocolEvent {
+ status: "failed";
+ recovery: "retry" | "release" | "handoff" | "human";
+ errorCode: string;
+}
+
+export type AgentAvatar = { type: "emoji" | "initials" | "image"; value: string };
+export interface AgentSkill {
+ id: string;
+ label: string;
+ level: "beginner" | "intermediate" | "advanced" | "expert";
+ category?: string;
+}
+export interface AgentIdentity {
+ id: string;
+ displayName: string;
+ role: string;
+ /** Canonical role references; `role` remains for backward compatibility. */
+ roleIds?: string[];
+ bio?: string;
+ avatar: AgentAvatar;
+ color: string;
+ skills: AgentSkill[];
+ status: "online" | "offline" | "busy";
+ stageBindings: string[];
+ adapterHints?: Record;
+}
+export interface AgentRegistry {
+ version: "1";
+ updatedAt: string;
+ agents: AgentIdentity[];
+}
+
export interface Stage {
id: string;
name: string;
@@ -29,7 +121,25 @@ export interface Item {
tasks?: Task[];
claimedBy?: string;
claimedAt?: string;
+ claimExecutorId?: string;
+ claimCapability?: string;
+ claimRevision?: string;
+ claimExpiresAt?: string;
+ claimTtlMinutes?: number;
+ activityStatus?: ExternalProtocolEventStatus;
+ activityStartedAt?: string;
+ lastHeartbeatAt?: string;
+ lastFailure?: { code: string; message: string; recovery: string; at: string };
currentPhase?: string;
+ handoff?: {
+ from: string;
+ to: string;
+ summary: string;
+ evidence: string[];
+ timestamp: string;
+ expiresAt: string;
+ executorId?: string;
+ };
}
export interface SpecLink {
@@ -144,6 +254,17 @@ export interface AgentDirectionSnapshot {
description: string;
stage: string;
spec: string | null;
+ claimedBy?: string | null;
+ claimedAt?: string | null;
+ claimExpiresAt?: string | null;
+ claimExecutorId?: string | null;
+ claimCapability?: string | null;
+ claimRevision?: string | null;
+ claimTtlMinutes?: number | null;
+ activityStatus?: ExternalProtocolEventStatus | null;
+ activityStartedAt?: string | null;
+ lastHeartbeatAt?: string | null;
+ lastFailure?: { code: string; message: string; recovery: string; at: string } | null;
} | null;
roleIds: string[];
allowedStageIds: string[];
@@ -166,6 +287,8 @@ export interface ResolvedSpec {
content: string;
}
+export * from "./orchestration-domain.js";
+
export type GateDecision = "approve" | "request-changes" | "reject";
export interface ResolvedFlowGate {
@@ -296,6 +419,7 @@ export interface ResolvedFlowStage {
/** @deprecated Use roleIds and roles. */
agents: string[];
gate: ResolvedFlowGate | null;
+ preferredExecutor?: string;
phases?: ResolvedStagePhases;
activity?: ResolvedFlowActivity;
provenance: "harness" | "workflow-instance";
diff --git a/packages/types/src/orchestration-domain.test.ts b/packages/types/src/orchestration-domain.test.ts
new file mode 100644
index 0000000..932d841
--- /dev/null
+++ b/packages/types/src/orchestration-domain.test.ts
@@ -0,0 +1,33 @@
+import { describe, expect, it } from "vitest";
+import { resolveActorBinding, validateDomain, type DomainCatalog } from "./orchestration-domain";
+
+const catalog: DomainCatalog = {
+ workspace: { id: "ws", root: "/repo", harnessVersion: "v2" },
+ roles: [{ id: "builder", label: "Builder", capabilities: ["code", "test"], allowedStages: ["code"] }],
+ identities: [{ id: "turing", displayName: "Alan Turing", roleIds: ["builder"], status: "online" }],
+ executors: [
+ { id: "offline", capabilities: ["code", "test"], status: "offline" },
+ { id: "zeta", capabilities: ["code", "test"], status: "online" },
+ { id: "alpha", capabilities: ["code", "test"], status: "online" },
+ ],
+};
+
+describe("canonical orchestration domain", () => {
+ it("resolves identity, role and executor deterministically and skips offline", () => {
+ expect(resolveActorBinding(catalog, "turing", "builder")).toMatchObject({ actorId: "turing:builder:alpha", executorId: "alpha" });
+ expect(resolveActorBinding(catalog, "turing", "builder", "zeta")?.executorId).toBe("zeta");
+ expect(resolveActorBinding(catalog, "turing", "missing")).toBeNull();
+ });
+
+ it("rejects orphan roles and invalid run bindings", () => {
+ const issues = validateDomain({ ...catalog, identities: [{ ...catalog.identities[0], roleIds: ["missing"] }] });
+ expect(issues.some((issue) => issue.code === "ORPHAN_ROLE")).toBe(true);
+ });
+
+ it("keeps validation safe for migrated duplicates and offline-only executors", () => {
+ const migrated = { ...catalog, roles: [...catalog.roles, { ...catalog.roles[0] }], executors: [{ id: "only", capabilities: ["code"], status: "offline" as const }] };
+ const issues = validateDomain(migrated);
+ expect(issues.map((issue) => issue.code)).toEqual(expect.arrayContaining(["DUPLICATE_ID", "CAPABILITY_MISMATCH"]));
+ expect(resolveActorBinding(migrated, "turing", "builder")).toBeNull();
+ });
+});
diff --git a/packages/types/src/orchestration-domain.ts b/packages/types/src/orchestration-domain.ts
new file mode 100644
index 0000000..4d7923f
--- /dev/null
+++ b/packages/types/src/orchestration-domain.ts
@@ -0,0 +1,41 @@
+/** Canonical entities shared by workflow, protocol and execution projections. */
+export interface WorkspaceEntity { id: string; root: string; harnessVersion: string; }
+export interface RoleEntity { id: string; label: string; capabilities: string[]; allowedStages: string[]; }
+export interface AgentIdentityEntity { id: string; displayName: string; roleIds: string[]; status: "online" | "offline" | "busy"; }
+export interface ExecutorEntity { id: string; capabilities: string[]; status: "online" | "offline" | "busy"; }
+export interface ActorBinding { actorId: string; identityId: string; roleId: string; executorId: string; }
+export interface WorkItemEntity { id: string; workspaceId: string; specId?: string; stage: string; }
+export interface ClaimEntity { itemId: string; actorId: string; executorId: string; capability: string; revision: string; expiresAt: string; }
+export interface HandoffEntity { itemId: string; fromActorId: string; toActorId: string; evidenceIds: string[]; createdAt: string; expiresAt: string; }
+export interface GateDecisionEntity { id: string; itemId: string; gateId: string; decision: "approve" | "request-changes" | "reject"; decidedBy: string; decidedAt: string; }
+export interface EvidenceEntity { id: string; itemId: string; kind: "diff" | "file" | "command" | "test" | "artifact"; uri: string; sha256?: string; observedAt: string; }
+export interface RunEntity { id: string; itemId: string; binding: ActorBinding; claim?: ClaimEntity; handoffs: HandoffEntity[]; gateDecisions: GateDecisionEntity[]; evidence: EvidenceEntity[]; status: "queued" | "running" | "failed" | "completed" | "waiting-human"; }
+
+export interface DomainCatalog {
+ workspace: WorkspaceEntity;
+ roles: RoleEntity[];
+ identities: AgentIdentityEntity[];
+ executors: ExecutorEntity[];
+}
+
+export interface DomainValidationIssue { code: "ORPHAN_ROLE" | "ORPHAN_EXECUTOR" | "CAPABILITY_MISMATCH" | "DUPLICATE_ID" | "INVALID_BINDING"; path: string; message: string; }
+
+/** Resolves the binding in one deterministic order: identity, role, then executor. */
+export function resolveActorBinding(catalog: DomainCatalog, identityId: string, roleId: string, preferredExecutorId?: string): ActorBinding | null {
+ const identity = catalog.identities.find((entry) => entry.id === identityId);
+ const role = catalog.roles.find((entry) => entry.id === roleId);
+ if (!identity || !role || !identity.roleIds.includes(roleId)) return null;
+ const candidates = catalog.executors.filter((executor) => executor.status !== "offline" && role.capabilities.every((capability) => executor.capabilities.includes(capability)));
+ const executor = (preferredExecutorId && candidates.find((entry) => entry.id === preferredExecutorId)) ?? candidates.sort((a, b) => a.id.localeCompare(b.id))[0];
+ return executor ? { actorId: `${identity.id}:${role.id}:${executor.id}`, identityId: identity.id, roleId: role.id, executorId: executor.id } : null;
+}
+
+export function validateDomain(catalog: DomainCatalog, runs: RunEntity[] = []): DomainValidationIssue[] {
+ const issues: DomainValidationIssue[] = [];
+ const checkUnique = (kind: string, entries: Array<{ id: string }>) => { const seen = new Set(); for (const entry of entries) { if (seen.has(entry.id)) issues.push({ code: "DUPLICATE_ID", path: `${kind}.${entry.id}`, message: `ID duplicado: ${entry.id}` }); seen.add(entry.id); } };
+ checkUnique("roles", catalog.roles); checkUnique("identities", catalog.identities); checkUnique("executors", catalog.executors);
+ for (const identity of catalog.identities) for (const roleId of identity.roleIds) if (!catalog.roles.some((role) => role.id === roleId)) issues.push({ code: "ORPHAN_ROLE", path: `identities.${identity.id}.roleIds`, message: `Role órfão: ${roleId}` });
+ for (const role of catalog.roles) if (!catalog.executors.some((executor) => executor.status !== "offline" && role.capabilities.every((capability) => executor.capabilities.includes(capability)))) issues.push({ code: "CAPABILITY_MISMATCH", path: `roles.${role.id}`, message: `Nenhum executor online suporta as capabilities de ${role.id}` });
+ for (const run of runs) { const binding = resolveActorBinding(catalog, run.binding.identityId, run.binding.roleId, run.binding.executorId); if (!binding || binding.actorId !== run.binding.actorId) issues.push({ code: "INVALID_BINDING", path: `runs.${run.id}.binding`, message: "ActorBinding não pode ser resolvido deterministicamente" }); }
+ return issues;
+}
diff --git a/packages/ui/src/agent-avatar.tsx b/packages/ui/src/agent-avatar.tsx
new file mode 100644
index 0000000..7d6111a
--- /dev/null
+++ b/packages/ui/src/agent-avatar.tsx
@@ -0,0 +1,10 @@
+import type { AgentIdentity } from "@letra/types";
+import { AvatarFallback, AvatarImage } from "./avatar";
+import { AvatarWithStatus } from "./avatar-with-status";
+
+export function AgentAvatar({ agent, size = "md" }: { agent: AgentIdentity; size?: "sm" | "md" | "lg" }) {
+ const fallback = agent.avatar.type === "initials" ? agent.avatar.value : agent.avatar.value;
+ return
+ {agent.avatar.type === "image" ? : {fallback}}
+ ;
+}
diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts
index 74e020b..6719065 100644
--- a/packages/ui/src/index.ts
+++ b/packages/ui/src/index.ts
@@ -31,6 +31,7 @@ export { AgentStatusIndicator } from "./agent-status-indicator";
export type { AgentState } from "./agent-status-indicator";
export { Avatar, AvatarImage, AvatarFallback } from "./avatar";
export { AvatarWithStatus } from "./avatar-with-status";
+export { AgentAvatar } from "./agent-avatar";
export { Separator } from "./separator";
export {
Breadcrumb,