refactor: restructure project to monorepo with apps directory

This commit is contained in:
Cody Seibert
2025-12-11 20:34:49 -05:00
parent 7cb5a6a4df
commit 1bb20e5070
164 changed files with 12215 additions and 14 deletions

View File

@@ -0,0 +1,247 @@
/**
* Agent Context Parser
* Extracts useful information from agent context files for display in kanban cards
*/
export interface AgentTaskInfo {
// Task list extracted from TodoWrite tool calls
todos: {
content: string;
status: "pending" | "in_progress" | "completed";
}[];
// Progress stats
toolCallCount: number;
lastToolUsed?: string;
// Phase info
currentPhase?: "planning" | "action" | "verification";
// Summary (if feature is completed)
summary?: string;
// Estimated progress percentage based on phase and tool calls
progressPercentage: number;
}
/**
* Default model used by the feature executor
*/
export const DEFAULT_MODEL = "claude-opus-4-5-20251101";
/**
* Formats a model name for display
*/
export function formatModelName(model: string): string {
if (model.includes("opus")) return "Opus 4.5";
if (model.includes("sonnet")) return "Sonnet 4.5";
if (model.includes("haiku")) return "Haiku 4.5";
return model.split("-").slice(1, 3).join(" ");
}
/**
* Extracts todos from the context content
* Looks for TodoWrite tool calls in the format:
* TodoWrite: [{"content": "...", "status": "..."}]
*/
function extractTodos(content: string): AgentTaskInfo["todos"] {
const todos: AgentTaskInfo["todos"] = [];
// Look for TodoWrite tool inputs
const todoMatches = content.matchAll(/TodoWrite.*?(?:"todos"\s*:\s*)?(\[[\s\S]*?\](?=\s*(?:\}|$|🔧|📋|⚡|✅|❌)))/g);
for (const match of todoMatches) {
try {
// Try to find JSON array in the match
const jsonStr = match[1] || match[0];
const arrayMatch = jsonStr.match(/\[[\s\S]*?\]/);
if (arrayMatch) {
const parsed = JSON.parse(arrayMatch[0]);
if (Array.isArray(parsed)) {
for (const item of parsed) {
if (item.content && item.status) {
// Check if this todo already exists (avoid duplicates)
if (!todos.some(t => t.content === item.content)) {
todos.push({
content: item.content,
status: item.status,
});
}
}
}
}
}
} catch {
// Ignore parse errors
}
}
// Also try to extract from markdown task lists
const markdownTodos = content.matchAll(/- \[([ xX])\] (.+)/g);
for (const match of markdownTodos) {
const isCompleted = match[1].toLowerCase() === "x";
const content = match[2].trim();
if (!todos.some(t => t.content === content)) {
todos.push({
content,
status: isCompleted ? "completed" : "pending",
});
}
}
return todos;
}
/**
* Counts tool calls in the content
*/
function countToolCalls(content: string): number {
const matches = content.match(/🔧\s*Tool:/g);
return matches?.length || 0;
}
/**
* Gets the last tool used
*/
function getLastToolUsed(content: string): string | undefined {
const matches = [...content.matchAll(/🔧\s*Tool:\s*(\S+)/g)];
if (matches.length > 0) {
return matches[matches.length - 1][1];
}
return undefined;
}
/**
* Determines the current phase from the content
*/
function getCurrentPhase(content: string): "planning" | "action" | "verification" | undefined {
// Find the last phase marker
const planningIndex = content.lastIndexOf("📋");
const actionIndex = content.lastIndexOf("⚡");
const verificationIndex = content.lastIndexOf("✅");
const maxIndex = Math.max(planningIndex, actionIndex, verificationIndex);
if (maxIndex === -1) return undefined;
if (maxIndex === verificationIndex) return "verification";
if (maxIndex === actionIndex) return "action";
return "planning";
}
/**
* Extracts a summary from completed feature context
*/
function extractSummary(content: string): string | undefined {
// Look for summary sections - capture everything including subsections (###)
// Stop at same-level ## sections (but not ###), or tool markers, or end
const summaryMatch = content.match(/## Summary[^\n]*\n([\s\S]*?)(?=\n## [^#]|\n🔧|$)/i);
if (summaryMatch) {
return summaryMatch[1].trim();
}
// Look for completion markers and extract surrounding text
const completionMatch = content.match(/✓ (?:Feature|Verification|Task) (?:successfully|completed|verified)[^\n]*(?:\n[^\n]{1,200})?/i);
if (completionMatch) {
return completionMatch[0].trim();
}
// Look for "What was done" type sections
const whatWasDoneMatch = content.match(/(?:What was done|Changes made|Implemented)[^\n]*\n([\s\S]*?)(?=\n## [^#]|\n🔧|$)/i);
if (whatWasDoneMatch) {
return whatWasDoneMatch[1].trim();
}
return undefined;
}
/**
* Calculates progress percentage based on phase and context
* Uses a more dynamic approach that better reflects actual progress
*/
function calculateProgress(phase: AgentTaskInfo["currentPhase"], toolCallCount: number, todos: AgentTaskInfo["todos"]): number {
// If we have todos, primarily use them for progress calculation
if (todos.length > 0) {
const completedCount = todos.filter(t => t.status === "completed").length;
const inProgressCount = todos.filter(t => t.status === "in_progress").length;
// Weight: completed = 1, in_progress = 0.5, pending = 0
const progress = ((completedCount + inProgressCount * 0.5) / todos.length) * 90;
// Add a small base amount and cap at 95%
return Math.min(5 + progress, 95);
}
// Fallback: use phase-based progress with tool call scaling
let phaseProgress = 0;
switch (phase) {
case "planning":
// Planning phase: 5-25%
phaseProgress = 5 + Math.min(toolCallCount * 1, 20);
break;
case "action":
// Action phase: 25-75% based on tool calls (logarithmic scaling)
phaseProgress = 25 + Math.min(Math.log2(toolCallCount + 1) * 10, 50);
break;
case "verification":
// Verification phase: 75-95%
phaseProgress = 75 + Math.min(toolCallCount * 0.5, 20);
break;
default:
// Starting: just use tool calls
phaseProgress = Math.min(toolCallCount * 0.5, 10);
}
return Math.min(Math.round(phaseProgress), 95);
}
/**
* Parses agent context content and extracts useful information
*/
export function parseAgentContext(content: string): AgentTaskInfo {
if (!content || !content.trim()) {
return {
todos: [],
toolCallCount: 0,
progressPercentage: 0,
};
}
const todos = extractTodos(content);
const toolCallCount = countToolCalls(content);
const lastToolUsed = getLastToolUsed(content);
const currentPhase = getCurrentPhase(content);
const summary = extractSummary(content);
const progressPercentage = calculateProgress(currentPhase, toolCallCount, todos);
return {
todos,
toolCallCount,
lastToolUsed,
currentPhase,
summary,
progressPercentage,
};
}
/**
* Quick stats for display in card badges
*/
export interface QuickStats {
toolCalls: number;
completedTasks: number;
totalTasks: number;
phase?: string;
}
/**
* Extracts quick stats from context for compact display
*/
export function getQuickStats(content: string): QuickStats {
const info = parseAgentContext(content);
return {
toolCalls: info.toolCallCount,
completedTasks: info.todos.filter(t => t.status === "completed").length,
totalTasks: info.todos.length,
phase: info.currentPhase,
};
}

2292
apps/app/src/lib/electron.ts Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,434 @@
/**
* Log Parser Utility
* Parses agent output into structured sections for display
*/
export type LogEntryType =
| "prompt"
| "tool_call"
| "tool_result"
| "phase"
| "error"
| "success"
| "info"
| "debug"
| "warning"
| "thinking";
export interface LogEntry {
id: string;
type: LogEntryType;
title: string;
content: string;
timestamp?: string;
collapsed?: boolean;
metadata?: {
toolName?: string;
phase?: string;
[key: string]: string | undefined;
};
}
/**
* Generates a deterministic ID based on content and position
* This ensures the same log entry always gets the same ID,
* preserving expanded/collapsed state when new logs stream in
*
* Uses only the first 200 characters of content to ensure stability
* even when entries are merged (which appends content at the end)
*/
const generateDeterministicId = (content: string, lineIndex: number): string => {
// Use first 200 chars to ensure stability when entries are merged
const stableContent = content.slice(0, 200);
// Simple hash function for the content
let hash = 0;
const str = stableContent + '|' + lineIndex.toString();
for (let i = 0; i < str.length; i++) {
const char = str.charCodeAt(i);
hash = ((hash << 5) - hash) + char;
hash = hash & hash; // Convert to 32bit integer
}
return 'log_' + Math.abs(hash).toString(36);
};
/**
* Detects the type of log entry based on content patterns
*/
function detectEntryType(content: string): LogEntryType {
const trimmed = content.trim();
// Tool calls
if (trimmed.startsWith("🔧 Tool:") || trimmed.match(/^Tool:\s*/)) {
return "tool_call";
}
// Tool results / Input
if (trimmed.startsWith("Input:") || trimmed.startsWith("Result:") || trimmed.startsWith("Output:")) {
return "tool_result";
}
// Phase changes
if (
trimmed.startsWith("📋") ||
trimmed.startsWith("⚡") ||
trimmed.startsWith("✅") ||
trimmed.match(/^(Planning|Action|Verification)/i) ||
trimmed.match(/\[Phase:\s*([^\]]+)\]/) ||
trimmed.match(/Phase:\s*\w+/i)
) {
return "phase";
}
// Feature creation events
if (
trimmed.match(/\[Feature Creation\]/i) ||
trimmed.match(/Feature Creation/i) ||
trimmed.match(/Creating feature/i)
) {
return "success";
}
// Errors
if (trimmed.startsWith("❌") || trimmed.toLowerCase().includes("error:")) {
return "error";
}
// Success messages
if (
trimmed.startsWith("✅") ||
trimmed.toLowerCase().includes("success") ||
trimmed.toLowerCase().includes("completed")
) {
return "success";
}
// Warnings
if (trimmed.startsWith("⚠️") || trimmed.toLowerCase().includes("warning:")) {
return "warning";
}
// Thinking/Preparation info
if (
trimmed.toLowerCase().includes("ultrathink") ||
trimmed.toLowerCase().includes("thinking level") ||
trimmed.toLowerCase().includes("estimated cost") ||
trimmed.toLowerCase().includes("estimated time") ||
trimmed.toLowerCase().includes("budget tokens") ||
trimmed.match(/thinking.*preparation/i)
) {
return "thinking";
}
// Debug info (JSON, stack traces, etc.)
if (
trimmed.startsWith("{") ||
trimmed.startsWith("[") ||
trimmed.includes("at ") ||
trimmed.match(/^\s*\d+\s*\|/)
) {
return "debug";
}
// Default to info
return "info";
}
/**
* Extracts tool name from a tool call entry
*/
function extractToolName(content: string): string | undefined {
const match = content.match(/🔧\s*Tool:\s*(\S+)/);
return match?.[1];
}
/**
* Extracts phase name from a phase entry
*/
function extractPhase(content: string): string | undefined {
if (content.includes("📋")) return "planning";
if (content.includes("⚡")) return "action";
if (content.includes("✅")) return "verification";
// Extract from [Phase: ...] format
const phaseMatch = content.match(/\[Phase:\s*([^\]]+)\]/);
if (phaseMatch) {
return phaseMatch[1].toLowerCase();
}
const match = content.match(/^(Planning|Action|Verification)/i);
return match?.[1]?.toLowerCase();
}
/**
* Generates a title for a log entry
*/
function generateTitle(type: LogEntryType, content: string): string {
switch (type) {
case "tool_call": {
const toolName = extractToolName(content);
return toolName ? `Tool Call: ${toolName}` : "Tool Call";
}
case "tool_result":
return "Tool Input/Result";
case "phase": {
const phase = extractPhase(content);
if (phase) {
// Capitalize first letter of each word
const formatted = phase.split(/\s+/).map(word =>
word.charAt(0).toUpperCase() + word.slice(1)
).join(" ");
return `Phase: ${formatted}`;
}
return "Phase Change";
}
case "error":
return "Error";
case "success":
return "Success";
case "warning":
return "Warning";
case "thinking":
return "Thinking Level";
case "debug":
return "Debug Info";
case "prompt":
return "Prompt";
default:
return "Info";
}
}
/**
* Parses raw log output into structured entries
*/
export function parseLogOutput(rawOutput: string): LogEntry[] {
if (!rawOutput || !rawOutput.trim()) {
return [];
}
const entries: LogEntry[] = [];
const lines = rawOutput.split("\n");
let currentEntry: Omit<LogEntry, 'id'> & { id?: string } | null = null;
let currentContent: string[] = [];
let entryStartLine = 0; // Track the starting line for deterministic ID generation
const finalizeEntry = () => {
if (currentEntry && currentContent.length > 0) {
currentEntry.content = currentContent.join("\n").trim();
if (currentEntry.content) {
// Generate deterministic ID based on content and position
const entryWithId: LogEntry = {
...currentEntry as Omit<LogEntry, 'id'>,
id: generateDeterministicId(currentEntry.content, entryStartLine),
};
entries.push(entryWithId);
}
}
currentContent = [];
};
let lineIndex = 0;
for (const line of lines) {
const trimmedLine = line.trim();
// Skip empty lines at the beginning
if (!trimmedLine && !currentEntry) {
lineIndex++;
continue;
}
// Detect if this line starts a new entry
const lineType = detectEntryType(trimmedLine);
const isNewEntry =
trimmedLine.startsWith("🔧") ||
trimmedLine.startsWith("📋") ||
trimmedLine.startsWith("⚡") ||
trimmedLine.startsWith("✅") ||
trimmedLine.startsWith("❌") ||
trimmedLine.startsWith("⚠️") ||
trimmedLine.startsWith("🧠") ||
trimmedLine.match(/\[Phase:\s*([^\]]+)\]/) ||
trimmedLine.match(/\[Feature Creation\]/i) ||
trimmedLine.match(/\[Tool\]/i) ||
trimmedLine.match(/\[Agent\]/i) ||
trimmedLine.match(/\[Complete\]/i) ||
trimmedLine.match(/\[ERROR\]/i) ||
trimmedLine.match(/\[Status\]/i) ||
trimmedLine.toLowerCase().includes("ultrathink preparation") ||
trimmedLine.toLowerCase().includes("thinking level") ||
(trimmedLine.startsWith("Input:") && currentEntry?.type === "tool_call");
if (isNewEntry) {
// Finalize previous entry
finalizeEntry();
// Track starting line for deterministic ID
entryStartLine = lineIndex;
// Start new entry (ID will be generated when finalizing)
currentEntry = {
type: lineType,
title: generateTitle(lineType, trimmedLine),
content: "",
metadata: {
toolName: extractToolName(trimmedLine),
phase: extractPhase(trimmedLine),
},
};
currentContent.push(trimmedLine);
} else if (currentEntry) {
// Continue current entry
currentContent.push(line);
} else {
// Track starting line for deterministic ID
entryStartLine = lineIndex;
// No current entry, create a default info entry
currentEntry = {
type: "info",
title: "Info",
content: "",
};
currentContent.push(line);
}
lineIndex++;
}
// Finalize last entry
finalizeEntry();
// Merge consecutive entries of the same type if they're both debug or info
const mergedEntries = mergeConsecutiveEntries(entries);
return mergedEntries;
}
/**
* Merges consecutive entries of the same type for cleaner display
*/
function mergeConsecutiveEntries(entries: LogEntry[]): LogEntry[] {
if (entries.length <= 1) return entries;
const merged: LogEntry[] = [];
let current: LogEntry | null = null;
let mergeIndex = 0;
for (const entry of entries) {
if (
current &&
(current.type === "debug" || current.type === "info") &&
current.type === entry.type
) {
// Merge into current - regenerate ID based on merged content
current.content += "\n\n" + entry.content;
current.id = generateDeterministicId(current.content, mergeIndex);
} else {
if (current) {
merged.push(current);
}
current = { ...entry };
mergeIndex = merged.length;
}
}
if (current) {
merged.push(current);
}
return merged;
}
/**
* Gets the color classes for a log entry type
*/
export function getLogTypeColors(type: LogEntryType): {
bg: string;
border: string;
text: string;
icon: string;
badge: string;
} {
switch (type) {
case "prompt":
return {
bg: "bg-blue-500/10",
border: "border-l-blue-500",
text: "text-blue-300",
icon: "text-blue-400",
badge: "bg-blue-500/20 text-blue-300",
};
case "tool_call":
return {
bg: "bg-amber-500/10",
border: "border-l-amber-500",
text: "text-amber-300",
icon: "text-amber-400",
badge: "bg-amber-500/20 text-amber-300",
};
case "tool_result":
return {
bg: "bg-slate-500/10",
border: "border-l-slate-400",
text: "text-slate-300",
icon: "text-slate-400",
badge: "bg-slate-500/20 text-slate-300",
};
case "phase":
return {
bg: "bg-cyan-500/10",
border: "border-l-cyan-500",
text: "text-cyan-300",
icon: "text-cyan-400",
badge: "bg-cyan-500/20 text-cyan-300",
};
case "error":
return {
bg: "bg-red-500/10",
border: "border-l-red-500",
text: "text-red-300",
icon: "text-red-400",
badge: "bg-red-500/20 text-red-300",
};
case "success":
return {
bg: "bg-emerald-500/10",
border: "border-l-emerald-500",
text: "text-emerald-300",
icon: "text-emerald-400",
badge: "bg-emerald-500/20 text-emerald-300",
};
case "warning":
return {
bg: "bg-orange-500/10",
border: "border-l-orange-500",
text: "text-orange-300",
icon: "text-orange-400",
badge: "bg-orange-500/20 text-orange-300",
};
case "thinking":
return {
bg: "bg-indigo-500/10",
border: "border-l-indigo-500",
text: "text-indigo-300",
icon: "text-indigo-400",
badge: "bg-indigo-500/20 text-indigo-300",
};
case "debug":
return {
bg: "bg-primary/10",
border: "border-l-primary",
text: "text-primary",
icon: "text-primary",
badge: "bg-primary/20 text-primary",
};
default:
return {
bg: "bg-zinc-500/10",
border: "border-l-zinc-500",
text: "text-zinc-300",
icon: "text-zinc-400",
badge: "bg-zinc-500/20 text-zinc-300",
};
}
}

View File

@@ -0,0 +1,195 @@
/**
* Project initialization utilities
*
* Handles the setup of the .automaker directory structure when opening
* new or existing projects.
*/
import { getElectronAPI } from "./electron";
export interface ProjectInitResult {
success: boolean;
isNewProject: boolean;
error?: string;
createdFiles?: string[];
existingFiles?: string[];
}
/**
* Required files and directories in the .automaker directory
* Note: app_spec.txt is NOT created automatically - user must set it up via the spec editor
*/
const REQUIRED_STRUCTURE: {
directories: string[];
files: Record<string, string>;
} = {
directories: [
".automaker",
".automaker/context",
".automaker/features",
".automaker/images",
],
files: {},
};
/**
* Initializes the .automaker directory structure for a project
*
* @param projectPath - The root path of the project
* @returns Result indicating what was created or if the project was already initialized
*/
export async function initializeProject(
projectPath: string
): Promise<ProjectInitResult> {
const api = getElectronAPI();
const createdFiles: string[] = [];
const existingFiles: string[] = [];
try {
// Create all required directories
for (const dir of REQUIRED_STRUCTURE.directories) {
const fullPath = `${projectPath}/${dir}`;
await api.mkdir(fullPath);
}
// Check and create required files
for (const [relativePath, defaultContent] of Object.entries(
REQUIRED_STRUCTURE.files
)) {
const fullPath = `${projectPath}/${relativePath}`;
const exists = await api.exists(fullPath);
if (!exists) {
await api.writeFile(fullPath, defaultContent as string);
createdFiles.push(relativePath);
} else {
existingFiles.push(relativePath);
}
}
// Determine if this is a new project (no files needed to be created since features/ is empty by default)
const isNewProject =
createdFiles.length === 0 && existingFiles.length === 0;
return {
success: true,
isNewProject,
createdFiles,
existingFiles,
};
} catch (error) {
console.error("[project-init] Failed to initialize project:", error);
return {
success: false,
isNewProject: false,
error: error instanceof Error ? error.message : "Unknown error occurred",
};
}
}
/**
* Checks if a project has the required .automaker structure
*
* @param projectPath - The root path of the project
* @returns true if all required files/directories exist
*/
export async function isProjectInitialized(
projectPath: string
): Promise<boolean> {
const api = getElectronAPI();
try {
// Check all required directories exist (no files required - features/ folder is source of truth)
for (const dir of REQUIRED_STRUCTURE.directories) {
const fullPath = `${projectPath}/${dir}`;
const exists = await api.exists(fullPath);
if (!exists) {
return false;
}
}
return true;
} catch (error) {
console.error(
"[project-init] Error checking project initialization:",
error
);
return false;
}
}
/**
* Gets a summary of what needs to be initialized for a project
*
* @param projectPath - The root path of the project
* @returns List of missing files/directories
*/
export async function getProjectInitStatus(projectPath: string): Promise<{
initialized: boolean;
missingFiles: string[];
existingFiles: string[];
}> {
const api = getElectronAPI();
const missingFiles: string[] = [];
const existingFiles: string[] = [];
try {
// Check directories (no files required - features/ folder is source of truth)
for (const dir of REQUIRED_STRUCTURE.directories) {
const fullPath = `${projectPath}/${dir}`;
const exists = await api.exists(fullPath);
if (exists) {
existingFiles.push(dir);
} else {
missingFiles.push(dir);
}
}
return {
initialized: missingFiles.length === 0,
missingFiles,
existingFiles,
};
} catch (error) {
console.error("[project-init] Error getting project status:", error);
return {
initialized: false,
missingFiles: REQUIRED_STRUCTURE.directories,
existingFiles: [],
};
}
}
/**
* Checks if the app_spec.txt file exists for a project
*
* @param projectPath - The root path of the project
* @returns true if app_spec.txt exists
*/
export async function hasAppSpec(projectPath: string): Promise<boolean> {
const api = getElectronAPI();
try {
const fullPath = `${projectPath}/.automaker/app_spec.txt`;
return await api.exists(fullPath);
} catch (error) {
console.error("[project-init] Error checking app_spec.txt:", error);
return false;
}
}
/**
* Checks if the .automaker directory exists for a project
*
* @param projectPath - The root path of the project
* @returns true if .automaker directory exists
*/
export async function hasAutomakerDir(projectPath: string): Promise<boolean> {
const api = getElectronAPI();
try {
const fullPath = `${projectPath}/.automaker`;
return await api.exists(fullPath);
} catch (error) {
console.error("[project-init] Error checking .automaker dir:", error);
return false;
}
}

45
apps/app/src/lib/utils.ts Normal file
View File

@@ -0,0 +1,45 @@
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
import type { AgentModel } from "@/store/app-store"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
/**
* Check if a model is a Codex/OpenAI model (doesn't support thinking)
*/
export function isCodexModel(model?: AgentModel | string): boolean {
if (!model) return false;
const codexModels: string[] = [
"gpt-5.1-codex-max",
"gpt-5.1-codex",
"gpt-5.1-codex-mini",
"gpt-5.1",
];
return codexModels.includes(model);
}
/**
* Determine if the current model supports extended thinking controls
*/
export function modelSupportsThinking(model?: AgentModel | string): boolean {
if (!model) return true;
return !isCodexModel(model);
}
/**
* Get display name for a model
*/
export function getModelDisplayName(model: AgentModel | string): string {
const displayNames: Record<string, string> = {
haiku: "Claude Haiku",
sonnet: "Claude Sonnet",
opus: "Claude Opus",
"gpt-5.1-codex-max": "GPT-5.1 Codex Max",
"gpt-5.1-codex": "GPT-5.1 Codex",
"gpt-5.1-codex-mini": "GPT-5.1 Codex Mini",
"gpt-5.1": "GPT-5.1",
};
return displayNames[model] || model;
}