Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
7b9d2b0
feat(protocol): define external executor identity types
booletec Sep 8, 2026
d1942ca
fix(workspace): respect external data boundary for AC operations
booletec Sep 8, 2026
7707e89
feat(protocol): expose canonical direction to adapters
booletec Sep 8, 2026
1a2eefb
feat(protocol): add versioned claim operation
booletec Sep 8, 2026
ac368d6
feat(protocol): define execution event schemas
booletec Sep 8, 2026
846fbb1
feat(protocol): persist execution lifecycle events
booletec Sep 8, 2026
ffa6c2a
feat(protocol): complete executor operations and lease visibility
booletec Sep 8, 2026
a9bde7e
feat(direction): expose executor lease activity fields
booletec Sep 8, 2026
f1b68d7
feat(agents): add identity registry and team management surface
booletec Sep 8, 2026
828e05d
feat(agents): refine skill editing
booletec Sep 8, 2026
c4dd243
feat(agents): complete identity editor and validation
booletec Sep 8, 2026
ba25ac9
feat(adapters): inject persisted agent skills into artifacts
booletec Sep 8, 2026
bd1c1d7
fix(agents): preserve defaults and protect active claims
booletec Sep 8, 2026
9255e7c
fix(agents): serialize registry api mutations
booletec Sep 8, 2026
b4ad774
chore(release): prepare cli 0.6.2
booletec Sep 8, 2026
06a1324
fix(flow): tolerate incomplete workflow instances
booletec Sep 8, 2026
6286041
fix(direction): tolerate incomplete workflow data
booletec Sep 8, 2026
6cddf01
fix(direction): handle malformed workflow collections
booletec Sep 8, 2026
2200f14
fix(ui): keep flow board accessible
booletec Sep 8, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion packages/cli/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@letra-ai/cli",
"version": "0.6.1",
"version": "0.6.2",
"type": "module",
"bin": {
"letra": "dist/index.js"
Expand Down
3 changes: 3 additions & 0 deletions packages/cli/src/adapters/builder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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: [],
Expand Down Expand Up @@ -310,6 +312,7 @@ export function buildHarnessSnapshot(root: string, options: GenerateOptions): Ha
}

return {
direction: resolveAgentDirection(root),
workflowName: workflow.name,
hasWorkflow: true,
activeStage: stage
Expand Down
6 changes: 6 additions & 0 deletions packages/cli/src/adapters/generate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<AdapterSource, string> = {
init: "# Generated by letra init. Do not edit manually.\n",
Expand Down Expand Up @@ -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);
}
Expand Down
15 changes: 15 additions & 0 deletions packages/cli/src/adapters/skill-bridge.ts
Original file line number Diff line number Diff line change
@@ -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;
4 changes: 4 additions & 0 deletions packages/cli/src/adapters/types.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import type { AgentDirectionSnapshot } from "@letra/types";

export type AdapterSource = "init" | "flow-move" | "focus" | "flow-ac";

export interface HarnessDirectionCommand {
Expand Down Expand Up @@ -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 };
Expand Down
29 changes: 22 additions & 7 deletions packages/cli/src/agent-direction/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"] {
Expand Down Expand Up @@ -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] : [],
Expand Down Expand Up @@ -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,
Expand Down
25 changes: 25 additions & 0 deletions packages/cli/src/agents/service.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
});
47 changes: 47 additions & 0 deletions packages/cli/src/agents/service.ts
Original file line number Diff line number Diff line change
@@ -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<string, Omit<AgentIdentity, "id" | "role">> = {
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<AgentIdentity>, 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" } };
}
9 changes: 9 additions & 0 deletions packages/cli/src/commands/flow-init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
Loading
Loading