Adds the Architect role for design/architecture investigations with persistent sessions and structured design proposals. ## New Features - **Architect role** with opus (complex) and sonnet (standard) levels - **design_task tool** — Creates To Design issues and dispatches architect - **Workflow states:** To Design → Designing → Planning - **Completion rules:** architect:done → Planning, architect:blocked → Refining - **Auto-level selection** based on complexity keywords ## Files Changed (22 files, 546 additions) ### New Files - lib/tools/design-task.ts — design_task tool implementation - lib/tools/design-task.test.ts — 16 tests for architect functionality ### Core Changes - lib/tiers.ts — ARCHITECT_LEVELS, WorkerRole type, models, emoji - lib/workflow.ts — toDesign/designing states, completion rules - lib/projects.ts — architect WorkerState on Project type - lib/dispatch.ts — architect role support in dispatch pipeline - lib/services/pipeline.ts — architect completion rules - lib/model-selector.ts — architect level selection heuristic ### Integration - index.ts — Register design_task tool, architect config schema - lib/notify.ts — architect role in notifications - lib/bootstrap-hook.ts — architect session key parsing - lib/services/tick.ts — architect in queue processing - lib/services/heartbeat.ts — architect in health checks - lib/tools/health.ts — architect in health scans - lib/tools/status.ts — architect in status dashboard - lib/tools/work-start.ts — architect role option - lib/tools/work-finish.ts — architect validation - lib/tools/project-register.ts — architect labels + role scaffolding - lib/templates.ts — architect instructions + AGENTS.md updates - lib/setup/workspace.ts — architect role file scaffolding - lib/setup/smart-model-selector.ts — architect in model assignment - lib/setup/llm-model-selector.ts — architect in LLM prompt
208 lines
5.6 KiB
TypeScript
208 lines
5.6 KiB
TypeScript
/**
|
|
* Atomic projects.json read/write operations.
|
|
* All state mutations go through this module to prevent corruption.
|
|
*/
|
|
import fs from "node:fs/promises";
|
|
import path from "node:path";
|
|
import { homedir } from "node:os";
|
|
export type WorkerState = {
|
|
active: boolean;
|
|
issueId: string | null;
|
|
startTime: string | null;
|
|
level: string | null;
|
|
sessions: Record<string, string | null>;
|
|
};
|
|
|
|
export type Project = {
|
|
name: string;
|
|
repo: string;
|
|
groupName: string;
|
|
deployUrl: string;
|
|
baseBranch: string;
|
|
deployBranch: string;
|
|
/** Messaging channel for this project's group (e.g. "telegram", "whatsapp", "discord", "slack"). Stored at registration time. */
|
|
channel?: string;
|
|
/** Project-level role execution: parallel (DEV+QA can run simultaneously) or sequential (only one role at a time). Default: parallel */
|
|
roleExecution?: "parallel" | "sequential";
|
|
maxDevWorkers?: number;
|
|
maxQaWorkers?: number;
|
|
dev: WorkerState;
|
|
qa: WorkerState;
|
|
architect: WorkerState;
|
|
};
|
|
|
|
export type ProjectsData = {
|
|
projects: Record<string, Project>;
|
|
};
|
|
|
|
function parseWorkerState(worker: Record<string, unknown>): WorkerState {
|
|
return {
|
|
active: worker.active as boolean,
|
|
issueId: worker.issueId as string | null,
|
|
startTime: worker.startTime as string | null,
|
|
level: (worker.level ?? worker.tier ?? null) as string | null,
|
|
sessions: (worker.sessions as Record<string, string | null>) ?? {},
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Create a blank WorkerState with null sessions for given level names.
|
|
*/
|
|
export function emptyWorkerState(levels: string[]): WorkerState {
|
|
const sessions: Record<string, string | null> = {};
|
|
for (const l of levels) {
|
|
sessions[l] = null;
|
|
}
|
|
return {
|
|
active: false,
|
|
issueId: null,
|
|
startTime: null,
|
|
level: null,
|
|
sessions,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Get session key for a specific level from a worker's sessions map.
|
|
*/
|
|
export function getSessionForLevel(
|
|
worker: WorkerState,
|
|
level: string,
|
|
): string | null {
|
|
return worker.sessions[level] ?? null;
|
|
}
|
|
|
|
function projectsPath(workspaceDir: string): string {
|
|
return path.join(workspaceDir, "projects", "projects.json");
|
|
}
|
|
|
|
export async function readProjects(workspaceDir: string): Promise<ProjectsData> {
|
|
const raw = await fs.readFile(projectsPath(workspaceDir), "utf-8");
|
|
const data = JSON.parse(raw) as ProjectsData;
|
|
|
|
for (const project of Object.values(data.projects)) {
|
|
project.dev = project.dev
|
|
? parseWorkerState(project.dev as unknown as Record<string, unknown>)
|
|
: emptyWorkerState([]);
|
|
project.qa = project.qa
|
|
? parseWorkerState(project.qa as unknown as Record<string, unknown>)
|
|
: emptyWorkerState([]);
|
|
project.architect = project.architect
|
|
? parseWorkerState(project.architect as unknown as Record<string, unknown>)
|
|
: emptyWorkerState([]);
|
|
if (!project.channel) {
|
|
project.channel = "telegram";
|
|
}
|
|
}
|
|
|
|
return data;
|
|
}
|
|
|
|
export async function writeProjects(
|
|
workspaceDir: string,
|
|
data: ProjectsData,
|
|
): Promise<void> {
|
|
const filePath = projectsPath(workspaceDir);
|
|
const tmpPath = filePath + ".tmp";
|
|
await fs.writeFile(tmpPath, JSON.stringify(data, null, 2) + "\n", "utf-8");
|
|
await fs.rename(tmpPath, filePath);
|
|
}
|
|
|
|
export function getProject(
|
|
data: ProjectsData,
|
|
groupId: string,
|
|
): Project | undefined {
|
|
return data.projects[groupId];
|
|
}
|
|
|
|
export function getWorker(
|
|
project: Project,
|
|
role: "dev" | "qa" | "architect",
|
|
): WorkerState {
|
|
return project[role];
|
|
}
|
|
|
|
/**
|
|
* Update worker state for a project. Only provided fields are updated.
|
|
* Sessions are merged (not replaced) when both existing and new sessions are present.
|
|
*/
|
|
export async function updateWorker(
|
|
workspaceDir: string,
|
|
groupId: string,
|
|
role: "dev" | "qa" | "architect",
|
|
updates: Partial<WorkerState>,
|
|
): Promise<ProjectsData> {
|
|
const data = await readProjects(workspaceDir);
|
|
const project = data.projects[groupId];
|
|
if (!project) {
|
|
throw new Error(`Project not found for groupId: ${groupId}`);
|
|
}
|
|
|
|
const worker = project[role];
|
|
|
|
if (updates.sessions && worker.sessions) {
|
|
updates.sessions = { ...worker.sessions, ...updates.sessions };
|
|
}
|
|
|
|
project[role] = { ...worker, ...updates };
|
|
|
|
await writeProjects(workspaceDir, data);
|
|
return data;
|
|
}
|
|
|
|
/**
|
|
* Mark a worker as active with a new task.
|
|
* Stores session key in sessions[level] when a new session is spawned.
|
|
*/
|
|
export async function activateWorker(
|
|
workspaceDir: string,
|
|
groupId: string,
|
|
role: "dev" | "qa" | "architect",
|
|
params: {
|
|
issueId: string;
|
|
level: string;
|
|
sessionKey?: string;
|
|
startTime?: string;
|
|
},
|
|
): Promise<ProjectsData> {
|
|
const updates: Partial<WorkerState> = {
|
|
active: true,
|
|
issueId: params.issueId,
|
|
level: params.level,
|
|
};
|
|
if (params.sessionKey !== undefined) {
|
|
updates.sessions = { [params.level]: params.sessionKey };
|
|
}
|
|
if (params.startTime !== undefined) {
|
|
updates.startTime = params.startTime;
|
|
}
|
|
return updateWorker(workspaceDir, groupId, role, updates);
|
|
}
|
|
|
|
/**
|
|
* Mark a worker as inactive after task completion.
|
|
* Preserves sessions map and level for reuse via updateWorker's spread.
|
|
* Clears startTime to prevent stale timestamps on inactive workers.
|
|
*/
|
|
export async function deactivateWorker(
|
|
workspaceDir: string,
|
|
groupId: string,
|
|
role: "dev" | "qa" | "architect",
|
|
): Promise<ProjectsData> {
|
|
return updateWorker(workspaceDir, groupId, role, {
|
|
active: false,
|
|
issueId: null,
|
|
startTime: null,
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Resolve repo path from projects.json repo field (handles ~/ expansion).
|
|
*/
|
|
export function resolveRepoPath(repoField: string): string {
|
|
if (repoField.startsWith("~/")) {
|
|
return repoField.replace("~", homedir());
|
|
}
|
|
return repoField;
|
|
}
|