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; } }