diff --git a/Boot.php b/Boot.php index df6b218..60ec157 100644 --- a/Boot.php +++ b/Boot.php @@ -172,6 +172,7 @@ class Boot extends ServiceProvider $event->command(Console\Commands\ScanCommand::class); $event->command(Console\Commands\DispatchCommand::class); $event->command(Console\Commands\PrManageCommand::class); + $event->command(Console\Commands\PrepWorkspaceCommand::class); } /** diff --git a/Console/Commands/PrepWorkspaceCommand.php b/Console/Commands/PrepWorkspaceCommand.php new file mode 100644 index 0000000..211c938 --- /dev/null +++ b/Console/Commands/PrepWorkspaceCommand.php @@ -0,0 +1,493 @@ +baseUrl = rtrim((string) config('upstream.gitea.url', 'https://forge.lthn.ai'), '/'); + $this->token = (string) config('upstream.gitea.token', config('agentic.forge_token', '')); + $this->org = (string) $this->option('org'); + $this->outputDir = (string) ($this->option('output') ?? getcwd() . '/workspace'); + $this->dryRun = (bool) $this->option('dry-run'); + + $repo = $this->option('repo'); + $issueNumber = $this->option('issue') ? (int) $this->option('issue') : null; + $specsPath = (string) ($this->option('specs-path') ?? $this->expandHome('~/Code/host-uk/specs')); + $workspaceId = (int) $this->option('workspace'); + + if (! $this->token) { + $this->error('No Forge token configured. Set GITEA_TOKEN or FORGE_TOKEN in .env'); + + return self::FAILURE; + } + + if (! $repo) { + $this->error('--repo is required (e.g. --repo=go-ai)'); + + return self::FAILURE; + } + + $this->info('Preparing workspace for ' . $this->org . '/' . $repo); + $this->info('Output: ' . $this->outputDir); + + if ($this->dryRun) { + $this->warn('[DRY RUN] No files will be written.'); + } + + $this->newLine(); + + // Create output directory structure + if (! $this->dryRun) { + File::ensureDirectoryExists($this->outputDir . '/kb'); + File::ensureDirectoryExists($this->outputDir . '/specs'); + } + + // Step 1: Pull wiki pages + $wikiCount = $this->pullWiki($repo); + + // Step 2: Copy spec files + $specsCount = $this->copySpecs($specsPath); + + // Step 3: Generate TODO from issue + $issueTitle = null; + $issueBody = null; + if ($issueNumber) { + [$issueTitle, $issueBody] = $this->generateTodo($repo, $issueNumber); + } else { + $this->generateTodoSkeleton($repo); + } + + // Step 4: Generate context from vector DB + $contextCount = $this->generateContext($repo, $workspaceId, $issueTitle, $issueBody); + + // Summary + $this->newLine(); + $prefix = $this->dryRun ? '[DRY RUN] ' : ''; + $this->info($prefix . 'Workspace prep complete:'); + $this->line(' Wiki pages: ' . $wikiCount); + $this->line(' Spec files: ' . $specsCount); + $this->line(' TODO: ' . ($issueTitle ? 'from issue #' . $issueNumber : 'skeleton')); + $this->line(' Context: ' . $contextCount . ' memories'); + + return self::SUCCESS; + } + + /** + * Fetch wiki pages from Forge API and write to kb/ directory. + */ + private function pullWiki(string $repo): int + { + $this->info('Fetching wiki pages for ' . $this->org . '/' . $repo . '...'); + + $response = Http::withHeaders(['Authorization' => 'token ' . $this->token]) + ->timeout(30) + ->get($this->baseUrl . '/api/v1/repos/' . $this->org . '/' . $repo . '/wiki/pages'); + + if (! $response->successful()) { + if ($response->status() === 404) { + $this->warn(' No wiki found for ' . $repo); + if (! $this->dryRun) { + File::put( + $this->outputDir . '/kb/README.md', + '# No wiki found for ' . $repo . "\n\nThis repo has no wiki pages on Forge.\n" + ); + } + + return 0; + } + $this->error(' Wiki API error: ' . $response->status()); + + return 0; + } + + $pages = $response->json() ?? []; + + if (empty($pages)) { + $this->warn(' Wiki exists but has no pages.'); + if (! $this->dryRun) { + File::put( + $this->outputDir . '/kb/README.md', + '# No wiki found for ' . $repo . "\n\nThis repo has no wiki pages on Forge.\n" + ); + } + + return 0; + } + + $count = 0; + foreach ($pages as $page) { + $title = $page['title'] ?? 'Untitled'; + $subUrl = $page['sub_url'] ?? $title; + + if ($this->dryRun) { + $this->line(' [would fetch] ' . $title); + $count++; + + continue; + } + + // Fetch individual page content using sub_url (Forgejo's internal page identifier) + $pageResponse = Http::withHeaders(['Authorization' => 'token ' . $this->token]) + ->timeout(30) + ->get($this->baseUrl . '/api/v1/repos/' . $this->org . '/' . $repo . '/wiki/page/' . urlencode($subUrl)); + + if (! $pageResponse->successful()) { + $this->warn(' Failed to fetch: ' . $title); + + continue; + } + + $pageData = $pageResponse->json(); + $contentBase64 = $pageData['content_base64'] ?? ''; + + if (empty($contentBase64)) { + continue; + } + + $content = base64_decode($contentBase64); + $filename = preg_replace('/[^a-zA-Z0-9_\-.]/', '-', $title) . '.md'; + + File::put($this->outputDir . '/kb/' . $filename, $content); + $this->line(' ' . $title); + $count++; + } + + $this->info(' ' . $count . ' wiki page(s) saved to kb/'); + + return $count; + } + + /** + * Copy protocol spec files to specs/ directory. + */ + private function copySpecs(string $specsPath): int + { + $this->info('Copying spec files...'); + + $specFiles = ['AGENT_CONTEXT.md', 'TASK_PROTOCOL.md']; + $count = 0; + + foreach ($specFiles as $file) { + $source = $specsPath . '/' . $file; + + if (! File::exists($source)) { + $this->warn(' Not found: ' . $source); + + continue; + } + + if ($this->dryRun) { + $this->line(' [would copy] ' . $file); + $count++; + + continue; + } + + File::copy($source, $this->outputDir . '/specs/' . $file); + $this->line(' ' . $file); + $count++; + } + + $this->info(' ' . $count . ' spec file(s) copied.'); + + return $count; + } + + /** + * Fetch a Forge issue and generate TODO.md in TASK_PROTOCOL format. + * + * @return array{0: string|null, 1: string|null} [title, body] + */ + private function generateTodo(string $repo, int $issueNumber): array + { + $this->info('Generating TODO from issue #' . $issueNumber . '...'); + + $response = Http::withHeaders(['Authorization' => 'token ' . $this->token]) + ->timeout(30) + ->get($this->baseUrl . '/api/v1/repos/' . $this->org . '/' . $repo . '/issues/' . $issueNumber); + + if (! $response->successful()) { + $this->error(' Failed to fetch issue #' . $issueNumber . ': ' . $response->status()); + $this->generateTodoSkeleton($repo); + + return [null, null]; + } + + $issue = $response->json(); + $title = $issue['title'] ?? 'Untitled'; + $body = $issue['body'] ?? ''; + + // Extract objective (first paragraph or up to 500 chars) + $objective = $this->extractObjective($body); + + // Extract checklist items + $checklistItems = $this->extractChecklist($body); + + $todoContent = '# TASK: ' . $title . "\n\n"; + $todoContent .= '**Status:** ready' . "\n"; + $todoContent .= '**Source:** ' . $this->baseUrl . '/' . $this->org . '/' . $repo . '/issues/' . $issueNumber . "\n"; + $todoContent .= '**Created:** ' . now()->toDateTimeString() . "\n"; + $todoContent .= '**Repo:** ' . $this->org . '/' . $repo . "\n"; + $todoContent .= "\n---\n\n"; + + $todoContent .= "## Objective\n\n" . $objective . "\n"; + $todoContent .= "\n---\n\n"; + + $todoContent .= "## Acceptance Criteria\n\n"; + if (! empty($checklistItems)) { + foreach ($checklistItems as $item) { + $todoContent .= '- [ ] ' . $item . "\n"; + } + } else { + $todoContent .= "_No checklist items found in issue. Agent should define acceptance criteria._\n"; + } + $todoContent .= "\n---\n\n"; + + $todoContent .= "## Implementation Checklist\n\n"; + $todoContent .= "_To be filled by the agent during planning._\n"; + $todoContent .= "\n---\n\n"; + + $todoContent .= "## Notes\n\n"; + $todoContent .= "Full issue body preserved below for reference.\n\n"; + $todoContent .= "
\nOriginal Issue\n\n"; + $todoContent .= $body . "\n\n"; + $todoContent .= "
\n"; + + if ($this->dryRun) { + $this->line(' [would write] TODO.md from: ' . $title); + if (! empty($checklistItems)) { + $this->line(' Checklist items: ' . count($checklistItems)); + } + } else { + File::put($this->outputDir . '/TODO.md', $todoContent); + $this->line(' TODO.md generated from: ' . $title); + } + + return [$title, $body]; + } + + /** + * Generate a minimal TODO.md skeleton when no issue is provided. + */ + private function generateTodoSkeleton(string $repo): void + { + $content = "# TASK: [Define task]\n\n"; + $content .= '**Status:** ready' . "\n"; + $content .= '**Created:** ' . now()->toDateTimeString() . "\n"; + $content .= '**Repo:** ' . $this->org . '/' . $repo . "\n"; + $content .= "\n---\n\n"; + $content .= "## Objective\n\n_Define the objective._\n"; + $content .= "\n---\n\n"; + $content .= "## Acceptance Criteria\n\n- [ ] _Define criteria_\n"; + $content .= "\n---\n\n"; + $content .= "## Implementation Checklist\n\n_To be filled by the agent._\n"; + + if ($this->dryRun) { + $this->line(' [would write] TODO.md skeleton'); + } else { + File::put($this->outputDir . '/TODO.md', $content); + $this->line(' TODO.md skeleton generated (no --issue provided)'); + } + } + + /** + * Query BrainService for relevant context and write CONTEXT.md. + */ + private function generateContext(string $repo, int $workspaceId, ?string $issueTitle, ?string $issueBody): int + { + $this->info('Querying vector DB for context...'); + + try { + $brain = app(BrainService::class); + + // Query 1: Repo-specific knowledge + $repoResults = $brain->recall( + 'How does ' . $repo . ' work? Architecture and key interfaces.', + 10, + ['project' => $repo], + $workspaceId + ); + + $repoMemories = $repoResults['memories'] ?? []; + $repoScoreMap = $repoResults['scores'] ?? []; + + // Query 2: Issue-specific context + $issueMemories = []; + $issueScoreMap = []; + if ($issueTitle) { + $query = $issueTitle . ' ' . mb_substr((string) $issueBody, 0, 500); + $issueResults = $brain->recall($query, 5, [], $workspaceId); + $issueMemories = $issueResults['memories'] ?? []; + $issueScoreMap = $issueResults['scores'] ?? []; + } + + $totalMemories = count($repoMemories) + count($issueMemories); + + $content = '# Agent Context — ' . $repo . "\n\n"; + $content .= '> Auto-generated by `agentic:prep-workspace`. Query the vector DB for more.' . "\n\n"; + + $content .= "## Repo Knowledge\n\n"; + if (! empty($repoMemories)) { + foreach ($repoMemories as $i => $memory) { + $memId = $memory['id'] ?? ''; + $score = $repoScoreMap[$memId] ?? 0; + $memContent = $memory['content'] ?? ''; + $memProject = $memory['project'] ?? 'unknown'; + $memType = $memory['type'] ?? 'memory'; + $content .= '### ' . ($i + 1) . '. ' . $memProject . ' [' . $memType . '] (score: ' . round((float) $score, 3) . ")\n\n"; + $content .= $memContent . "\n\n"; + } + } else { + $content .= "_No repo-specific memories found. The vector DB may not have been seeded for this repo._\n\n"; + } + + $content .= "## Task-Relevant Context\n\n"; + if (! empty($issueMemories)) { + foreach ($issueMemories as $i => $memory) { + $memId = $memory['id'] ?? ''; + $score = $issueScoreMap[$memId] ?? 0; + $memContent = $memory['content'] ?? ''; + $memProject = $memory['project'] ?? 'unknown'; + $memType = $memory['type'] ?? 'memory'; + $content .= '### ' . ($i + 1) . '. ' . $memProject . ' [' . $memType . '] (score: ' . round((float) $score, 3) . ")\n\n"; + $content .= $memContent . "\n\n"; + } + } elseif ($issueTitle) { + $content .= "_No task-relevant memories found._\n\n"; + } else { + $content .= "_No issue provided — skipped task-specific recall._\n\n"; + } + + if ($this->dryRun) { + $this->line(' [would write] CONTEXT.md with ' . $totalMemories . ' memories'); + } else { + File::put($this->outputDir . '/CONTEXT.md', $content); + $this->line(' CONTEXT.md generated with ' . $totalMemories . ' memories'); + } + + return $totalMemories; + } catch (\Throwable $e) { + $this->warn(' BrainService unavailable: ' . $e->getMessage()); + + $content = '# Agent Context — ' . $repo . "\n\n"; + $content .= "> Vector DB was unavailable when this workspace was prepared.\n"; + $content .= "> Run `agentic:prep-workspace` again once Ollama/Qdrant are reachable.\n"; + + if (! $this->dryRun) { + File::put($this->outputDir . '/CONTEXT.md', $content); + } + + return 0; + } + } + + /** + * Extract the first paragraph or up to 500 characters as the objective. + */ + private function extractObjective(string $body): string + { + if (empty($body)) { + return '_No description provided._'; + } + + // Find first paragraph (text before a blank line) + $paragraphs = preg_split('/\n\s*\n/', $body, 2); + $first = trim($paragraphs[0] ?? $body); + + if (mb_strlen($first) > 500) { + return mb_substr($first, 0, 497) . '...'; + } + + return $first; + } + + /** + * Extract checklist items from markdown body. + * + * Matches `- [ ] text` and `- [x] text` lines. + * + * @return array + */ + private function extractChecklist(string $body): array + { + $items = []; + + if (preg_match_all('/- \[[ xX]\] (.+)/', $body, $matches)) { + foreach ($matches[1] as $item) { + $items[] = trim($item); + } + } + + return $items; + } + + /** + * Truncate a string to a maximum length. + */ + private function truncate(string $text, int $length): string + { + if (mb_strlen($text) <= $length) { + return $text; + } + + return mb_substr($text, 0, $length - 3) . '...'; + } + + /** + * Expand ~ to the user's home directory. + */ + private function expandHome(string $path): string + { + if (str_starts_with($path, '~/')) { + $home = $_SERVER['HOME'] ?? getenv('HOME') ?: '/tmp'; + + return $home . substr($path, 1); + } + + return $path; + } +}