diff --git a/.ai-team/agents/linus/history.md b/.ai-team/agents/linus/history.md index 27837ff..b1bc334 100644 --- a/.ai-team/agents/linus/history.md +++ b/.ai-team/agents/linus/history.md @@ -1,137 +1,40 @@ -# Project Context - -- **Owner:** Jeffrey T. Fritz (csharpfritz@users.noreply.github.com) -- **Project:** VS Code extension for visualizing Squad team members and their tasks -- **Stack:** TypeScript, VS Code Extension API, potentially GitHub Copilot integration -- **Created:** 2026-02-13 - -## Learnings Summary - -### v0.1v0.2: Core Data Pipeline Foundation -- OrchestrationLogService, TeamMdService, SquadDataProvider, FileWatcherService, GitHubIssuesService with test fixtures -- Two-tier member resolution: team.md + log overlay -- Dual-directory log discovery (orchestration-log/ + log/) -- Multi-format participant extraction (inline, table, agent-routed fields) -- Prose + issue-based task extraction with deterministic task IDs ({date}-{agent-slug}) -- Flexible GitHub issue matching: labels, assignees, any-label strategies - -### GitHubIssuesService Architecture -- IGitHubIssuesService interface enables graceful degradation and late binding -- Issues use $(issues) codicon with theme color tinting (green open, purple closed) -- Squad labels filtered from display to avoid redundancy -- Default matching strategy: ['labels', 'assignees'] when no config present -- Member Aliases table parsed from team.md Issue Source section -- Separate closedCache for closed issues; fetch max 50, sorted by updated_at descending - -### SkillCatalogService & Log Parsing -- Fetches from awesome-copilot + skills.sh using Node's https module -- All methods swallow network errors and return empty arrays (graceful degradation) -- Deduplicates toward awesome-copilot version -- No npm dependencies - -### Log Summary Extraction Priority Chain -1. ## Summary section -2. | **Outcome** | value | table field -3. Heading title after em dash -4. First prose paragraph (prevents table markdown leakage) - -### OrchestrationLogService Features -- Filename regex handles both YYYY-MM-DD-topic.md and YYYY-MM-DDThhmm-topic.md formats -- Agent Routed table field fallback: | **Agent routed** | Fury (Lead) | extracts agent name, strips role suffix and pipes -- Who Worked table format parsing: xtractTableFirstColumn() helper -- Prose task extraction: "What Was Done" section highest priority, synthetic fallback per entry - -### 2026-02-15 Team Updates - Issues Service Interface Contract IGitHubIssuesService decouples tree view from implementation decided by Rusty - Issue Icons & Display Filtering uses codicon with theme tinting, Squad labels filtered decided by Rusty - Release Pipeline Workflow tag-based trigger, version verification gate, VSCE_PAT secret decided by Livingston - Closed Issues Architecture separate closedCache, max 50, sorted by updated_at descending decided by Linus - SkillCatalogService Graceful Degradation swallow network errors, return empty arrays decided by Linus - Table-Format Log Summary Extraction priority chain to prevent markdown leakage decided by Linus - Default Issue Matching & Member Aliases defaults to labels+assignees, aliases in team.md decided by Linus - E2E Validation Test Strategy TestableWebviewRenderer pattern, acceptance criteria traceability decided by Basher - -### H1 Decision Format Support -- Added support for `# Decision: {title}` (H1) format in `parseDecisionsMd()` — some projects (e.g. aspire-minecraft) use this instead of H2/H3 -- H1 decisions use `**Date:**`, `**Author:**`, `**Issue:**` metadata lines below the heading -- Section boundary: an H1 decision runs until the next H1 heading (or EOF) -- Inner `## Context`, `## Decision`, `## Rationale` subsections are NOT treated as separate decisions — they're consumed as content of the parent H1 block -- The parser skips `i` forward to `sectionEnd` after consuming an H1 decision to prevent subsection re-parsing -- Non-decision H1 headings (e.g. `# Decisions`, `# Team Log`) are skipped — only `# Decision: ` with the prefix triggers parsing -- Existing H2/H3 parsing is completely untouched — the H1 block uses `continue` before reaching H2/H3 logic - -📌 Team update (2026-02-16): Test hardening conventions established — command registration tests use triple-guard pattern (extension/isActive/workspace); tree provider tests must await getChildren(); temp directories use test-fixtures/temp-{name}-${Date.now()} with teardown; private methods accessed via (instance as any).method.bind(instance) — decided by Basher - -📌 Team update (2026-02-17): Orchestration Log vs Session Log Scope — OrchestrationLogService now uses separate discoverOrchestrationLogFiles() and parseOrchestrationLogs() methods for task status derivation; session logs in log/ remain for display only (Recent Activity, log cards). Prevents false "working" indicators from old session logs. — decided by Rusty - -### Agents Folder Scanning Fallback -- Added `discoverMembersFromAgentsFolder()` to SquadDataProvider as a second-level fallback in the member detection chain -- Detection order is now: team.md Members/Roster table → agents folder scan → orchestration log participants -- Scans `.ai-team/agents/` subdirectories, skipping `_alumni` and `scribe` -- Reads `charter.md` from each agent folder to extract role via `- **Role:** {role}` regex -- Falls back to "Squad Member" default role if no charter or no Role line found -- Folder names are capitalized for display (e.g., `danny` → `Danny`) -- Method is self-contained and handles missing/unreadable agents directory gracefully (returns empty array) -- Pre-existing test suite at `agentsFolderDiscovery.test.ts` validates all edge cases -📌 Team update (2026-02-17): Always use normalizeEol() for markdown parsing to ensure cross-platform compatibility — decided by Copilot (Jeffrey T. Fritz) - -### Coding Agent Section Parsing (2026-02-18) -- Extended `TeamMdService.parseMembers()` to parse the `## Coding Agent` section in addition to `## Members`/`## Roster` -- Bug fix: @copilot was missing from member list because it lives in its own table under `## Coding Agent` -- The `extractSection()` and `parseMarkdownTable()` methods already handle this format — just needed to add the second extraction pass -- Both sections now contribute to the unified member array returned by `parseMembers()` -- No changes needed to `parseTableRow()` — it already handles the Name/Role/Charter/Status columns correctly regardless of which section they come from - -### Active-Work Marker Detection (2026-02-18) -- Implemented `detectActiveMarkers()` in SquadDataProvider — scans `{squadFolder}/active-work/` for `.md` marker files -- Detection is mtime-based with `STALENESS_THRESHOLD_MS = 300_000` (5 minutes); stale markers are ignored -- Integrated into `getSquadMembers()` after existing status resolution (roster + log + task demotion) but before caching -- Slug matching uses `member.name.toLowerCase()` since agent folder names are already lowercase -- Handles missing directory gracefully via try/catch (returns empty set) -- SquadUI is read-only — it never creates or deletes marker files, only reads presence + mtime -- No model changes needed — `MemberStatus` already includes `'working'` -- No watcher changes needed — existing `**/{.squad,.ai-team}/**/*.md` glob covers `active-work/*.md` - -### Velocity Chart: All Closed Issues (2026-02-18) -- `buildVelocityTimeline()` previously only counted closed issues from `MemberIssueMap` (member-matched subset) -- Issues without `squad:*` labels or matching assignee aliases were silently dropped from velocity -- Fix: added `allClosedIssues?: GitHubIssue[]` parameter to both `buildDashboardData()` and `buildVelocityTimeline()` -- `allClosedIssues` is the unfiltered array from `getClosedIssues()` — every closed issue in the repo -- Deduplication via `Set` on issue number prevents double-counting -- Fallback: if `allClosedIssues` not provided, falls back to iterating `closedIssues` MemberIssueMap (backward compat) -- `closedIssues` MemberIssueMap still used for Team Overview per-member breakdown — that data is correct per-member -- `IGitHubIssuesService` interface extended with `getClosedIssues()` — already existed on GitHubIssuesService, just not in the contract -- Key files: `DashboardDataBuilder.ts` (velocity logic), `SquadDashboardWebview.ts` (data fetch), `models/index.ts` (interface) - Team update (2026-02-18): Active-work marker protocol for detecting agent status during subagent turns decided by Danny - -### Velocity Chart: Session Log Inclusion (2026-02-18) -- Velocity chart was undercounting — only orchestration-log tasks and closed GitHub issues were counted -- Session logs in `log/` contain real completed work (issue refs, outcomes, participants) but were excluded by `getTasks()` which deliberately uses orchestration-only entries -- Added `getVelocityTasks()` to `SquadDataProvider` — uses `getLogEntries()` (all logs) instead of `getOrchestrationLogEntries()` -- `getTasks()` unchanged — orchestration-only for member status isolation and tree view correctness -- `DashboardDataBuilder.buildDashboardData()` now accepts optional 9th param `velocityTasks?: Task[]`; velocity timeline uses `velocityTasks ?? tasks` -- Activity swimlanes still use orchestration-only `tasks` — only velocity benefits from session logs -- Architectural principle: velocity = all work signals; status = orchestration-only (prevents false "working" indicators from old session logs) - -### Dashboard & Decisions Pipeline Deep Dive (2026-02-18) -- **Bug: Hardcoded `.ai-team` in `SquadDataProvider.discoverMembersFromAgentsFolder()`** — line 271 used `'.ai-team'` literal instead of `this.squadFolder`. Agent folder fallback broken for `.squad` users. Fixed. -- **Bug: Hardcoded `.ai-team` in `SquadDashboardWebview.handleOpenLogEntry()`** — line 167 used `'.ai-team'` literal. Clicking "Recent Sessions" log cards failed for `.squad` users. Fixed by adding `getSquadFolder()` to `SquadDataProvider`. -- **Bug: `DecisionsTreeProvider` created `DecisionService()` without `squadFolder`** — line 434 used default `.ai-team`. Decisions tree was empty for `.squad` workspaces. Fixed: constructor now accepts and passes `squadFolder`. -- **Bug: `TeamTreeProvider` created `OrchestrationLogService()` without `squadFolder`** — line 42. Member log entries in tree view broken for `.squad` users. Fixed: constructor now accepts and passes `squadFolder`. -- Key files: `src/services/SquadDataProvider.ts` (data aggregation, caching, member resolution chain), `src/views/SquadDashboardWebview.ts` (webview panel, data fetch orchestration), `src/views/dashboard/DashboardDataBuilder.ts` (data transformation for charts), `src/views/dashboard/htmlTemplate.ts` (HTML + JS rendering, ~1226 lines) -- HTML template has good null guards: all `render*()` functions check for empty/undefined arrays before iterating. Canvas charts check `offsetWidth === 0` to skip hidden tabs. Empty states shown for missing data. -- `DashboardDataBuilder.buildDashboardData()` always returns fully-populated `DashboardData` — no null fields in the structure. -- `DecisionService` handles missing file (returns early), empty file (no headings found), and inbox subdirectories (recursive `scanDirectory`). Graceful: never throws. - - -### Team Update: 2026-02-23 - Fork-Aware Issue Fetching - **Team update (2026-02-23):** Fork-aware issue fetching shipped: when repo is a fork, SquadUI auto-detects upstream via GitHub API (GET /repos/{owner}/{repo} parent), with manual override via team.md **Upstream** | owner/repo. All issue queries (open, closed, milestones) use upstream. Fallback to configured repo if not a fork. No breaking changes repos without forks behave identically. decided by @copilot - -### Feature Roadmap & Assignments (2026-02-24) -📌 Team update (2026-02-23): Feature roadmap defined — 10 features across v1.0/v1.1/v1.2. See decisions.md. - - P1 features assigned: Decision Search & Filter (#69), Health Check (#70), Milestone Burndown Template (#75) - - P2 features assigned: Skill Usage Metrics (#74) - - v1.0 ship target with focus on decision search service, diagnostic tooling - - v1.1 enables observability (skills usage, burndown metrics) - - Key implementation: DecisionService.search() and HealthCheck diagnostic command - - Roadmap session logged to .ai-team/log/2026-02-23-feature-roadmap.md +# Linus Search Service & Health Check + +**Role:** Infrastructure & Observability +**Current Focus:** v1.0 features Decision Search (#69), Health Check (#70) + +## v1.0 Batch 1 (2026-02-23) + +**#69 Decision Search Service** +- Full-text search with relevance ranking and date/author filtering +- 37 new tests added +- PR #79 merged to squad/v1.0-features +- Status: Complete + +**v1.0 Roadmap Assignments** +- P1: Decision Search & Filter (#69) +- P1: Health Check (#70) — in progress (Batch 2) +- P1: Milestone Burndown Template (#75) +- P2: Skill Usage Metrics (#74) + +**#70 Health Check Diagnostic Command** +- `HealthCheckService` — pure TypeScript, no VS Code dependency, 4 checks: team.md, agent charters, log parse health, GitHub token +- Each check returns `HealthCheckResult { name, status: 'pass'|'fail'|'warn', message, fix? }` +- `runAll()` parallel execution, `formatResults()` human-readable output with icons +- `squadui.healthCheck` command wired to VS Code output channel (minimal extension.ts touch) +- Squad folder passed as parameter everywhere — never hardcoded +- 17 tests in `healthCheckService.test.ts` +- PR #81 → closes #70 +- Status: Complete + +## Historical Summaries + +**Earlier work (v0.1v0.2, 2026-02-13 to 2026-02-18)** archived to history-archive-v1.md. + +Key milestones: +- Core data pipeline: OrchestrationLogService, TeamMdService, SquadDataProvider +- GitHub issues integration with graceful degradation +- H1 decision format support +- Test hardening patterns +- Dashboard bugfixes (squad folder awareness, velocity chart, session log inclusion) +- Fork-aware issue fetching (2026-02-23) diff --git a/.ai-team/decisions/inbox/linus-health-check-service-pattern.md b/.ai-team/decisions/inbox/linus-health-check-service-pattern.md new file mode 100644 index 0000000..6eaf3d5 --- /dev/null +++ b/.ai-team/decisions/inbox/linus-health-check-service-pattern.md @@ -0,0 +1,17 @@ +# Decision: HealthCheckService is a pure-TypeScript service + +**Date:** 2026-02-23 +**Author:** Linus +**Issue:** #70 + +## Context +The health check command needs to validate team configuration (team.md, agent charters, orchestration logs, GitHub token). This could be implemented directly in the command handler or as a standalone service. + +## Decision +Created `HealthCheckService` as a pure TypeScript service with no VS Code API dependencies. Each check method accepts `squadFolder` and `workspaceRoot` as parameters. The command handler in `extension.ts` is minimal — just wires the service to an output channel. + +## Rationale +- Testable in isolation (Mocha tests without VS Code test runner complexity) +- Follows existing service patterns (TeamMdService, OrchestrationLogService) +- Keeps service layer decoupled from VS Code UI (Linus/Rusty boundary) +- `HealthCheckResult` interface enables structured consumption by future UI (tree view, dashboard tab) diff --git a/package.json b/package.json index f8a0c1d..2f5d32f 100644 --- a/package.json +++ b/package.json @@ -83,6 +83,12 @@ "category": "Squad", "icon": "$(open-preview)" }, + { + "command": "squadui.editCharter", + "title": "Edit Charter", + "category": "Squad", + "icon": "$(edit)" + }, { "command": "squadui.removeMember", "title": "Remove Team Member", @@ -134,6 +140,12 @@ "title": "Generate Standup Report", "category": "Squad", "icon": "$(report)" + }, + { + "command": "squadui.healthCheck", + "title": "Health Check", + "category": "Squad", + "icon": "$(checklist)" } ], "viewsWelcome": [ @@ -172,6 +184,11 @@ } ], "view/item/context": [ + { + "command": "squadui.editCharter", + "when": "view == squadTeam && viewItem == member", + "group": "inline" + }, { "command": "squadui.removeMember", "when": "view == squadTeam && viewItem == member", @@ -194,6 +211,10 @@ } ], "commandPalette": [ + { + "command": "squadui.editCharter", + "when": "false" + }, { "command": "squadui.showWorkDetails", "when": "false" diff --git a/src/extension.ts b/src/extension.ts index d21467f..b69c495 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -2,7 +2,7 @@ import * as vscode from 'vscode'; import * as path from 'path'; import * as fs from 'fs'; import { GitHubIssue } from './models'; -import { SquadDataProvider, FileWatcherService, GitHubIssuesService, SquadVersionService } from './services'; +import { SquadDataProvider, FileWatcherService, GitHubIssuesService, SquadVersionService, HealthCheckService } from './services'; import { TeamTreeProvider, SkillsTreeProvider, DecisionsTreeProvider, WorkDetailsWebview, IssueDetailWebview, SquadStatusBar, SquadDashboardWebview, StandupReportWebview } from './views'; import { registerInitSquadCommand, registerUpgradeSquadCommand, registerAddMemberCommand, registerRemoveMemberCommand, registerAddSkillCommand } from './commands'; import { detectSquadFolder, hasSquadTeam } from './utils/squadFolderDetection'; @@ -221,6 +221,32 @@ export function activate(context: vscode.ExtensionContext): void { }) ); + // Register edit charter command — opens charter in text editor + markdown preview side-by-side + context.subscriptions.push( + vscode.commands.registerCommand('squadui.editCharter', async (rawName?: unknown) => { + let memberName: string = ''; + if (typeof rawName === 'string') { + memberName = rawName; + } else if (typeof rawName === 'object' && rawName !== null) { + memberName = String((rawName as any).label || (rawName as any).memberId || (rawName as any).name || ''); + } + if (!memberName) { + vscode.window.showWarningMessage('No member selected'); + return; + } + const slug = memberName.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, ''); + const charterPath = path.join(currentRoot, squadFolderName, 'agents', slug, 'charter.md'); + if (!fs.existsSync(charterPath)) { + vscode.window.showWarningMessage(`Charter not found for ${memberName}`); + return; + } + const uri = vscode.Uri.file(charterPath); + const doc = await vscode.workspace.openTextDocument(uri); + await vscode.window.showTextDocument(doc, { preview: false }); + await vscode.commands.executeCommand('markdown.showPreviewToSide', uri); + }) + ); + // Register squad init command let allocationPollInterval: ReturnType | undefined; let initInProgress = false; @@ -493,6 +519,25 @@ export function activate(context: vscode.ExtensionContext): void { }) ); + // Register health check command — runs diagnostics and shows results in output channel + context.subscriptions.push( + vscode.commands.registerCommand('squadui.healthCheck', async () => { + const healthService = new HealthCheckService(); + const results = await healthService.runAll(squadFolderName, currentRoot); + const output = vscode.window.createOutputChannel('Squad Health Check'); + output.clear(); + output.appendLine(healthService.formatResults(results)); + output.show(true); + + const failed = results.filter(r => r.status === 'fail').length; + if (failed > 0) { + vscode.window.showWarningMessage(`Squad Health Check: ${failed} issue(s) found. See output for details.`); + } else { + vscode.window.showInformationMessage('Squad Health Check: All checks passed.'); + } + }) + ); + // Connect file watcher to tree refresh and context key update fileWatcher.onFileChange(() => { teamProvider.refresh(); diff --git a/src/services/HealthCheckService.ts b/src/services/HealthCheckService.ts new file mode 100644 index 0000000..3f9bc7e --- /dev/null +++ b/src/services/HealthCheckService.ts @@ -0,0 +1,258 @@ +/** + * Service for running diagnostic health checks on the squad configuration. + * Validates team.md structure, agent charters, orchestration logs, and GitHub config. + * + * Pure TypeScript — no VS Code API dependency, testable in isolation. + */ + +import * as fs from 'fs'; +import * as path from 'path'; +import { TeamMdService } from './TeamMdService'; +import { OrchestrationLogService } from './OrchestrationLogService'; + +/** + * Result of a single health check. + */ +export interface HealthCheckResult { + /** Human-readable check name */ + name: string; + /** Pass, fail, or warn */ + status: 'pass' | 'fail' | 'warn'; + /** Description of the result */ + message: string; + /** Actionable fix suggestion when status is fail or warn */ + fix?: string; +} + +/** + * Service that validates team configuration and reports diagnostics. + */ +export class HealthCheckService { + + /** + * Validates that team.md exists and parses correctly. + */ + async checkTeamMd(squadFolder: string, workspaceRoot: string): Promise { + const teamMdPath = path.join(workspaceRoot, squadFolder, 'team.md'); + + try { + await fs.promises.access(teamMdPath, fs.constants.R_OK); + } catch { + return { + name: 'team.md', + status: 'fail', + message: `team.md not found at ${squadFolder}/team.md`, + fix: `Create a team.md file in the ${squadFolder}/ directory, or run the "Squad: Initialize" command.`, + }; + } + + try { + const service = new TeamMdService(squadFolder as '.squad' | '.ai-team'); + const roster = await service.parseTeamMd(workspaceRoot); + + if (!roster) { + return { + name: 'team.md', + status: 'fail', + message: 'team.md exists but could not be parsed', + fix: 'Ensure team.md contains a valid Members or Roster markdown table.', + }; + } + + if (roster.members.length === 0) { + return { + name: 'team.md', + status: 'warn', + message: 'team.md parsed but no members found', + fix: 'Add a Members or Roster table with at least one team member row.', + }; + } + + return { + name: 'team.md', + status: 'pass', + message: `team.md OK — ${roster.members.length} member(s) found`, + }; + } catch (error) { + return { + name: 'team.md', + status: 'fail', + message: `team.md parse error: ${error instanceof Error ? error.message : String(error)}`, + fix: 'Check team.md for malformed markdown tables or syntax errors.', + }; + } + } + + /** + * Validates that all agent folders contain a charter.md file. + */ + async checkAgentCharters(squadFolder: string, workspaceRoot: string): Promise { + const agentsDir = path.join(workspaceRoot, squadFolder, 'agents'); + + try { + await fs.promises.access(agentsDir, fs.constants.R_OK); + } catch { + return { + name: 'Agent Charters', + status: 'warn', + message: `No agents/ directory found at ${squadFolder}/agents/`, + fix: 'Run "Squad: Initialize" to create agent folders, or create them manually.', + }; + } + + try { + const entries = await fs.promises.readdir(agentsDir, { withFileTypes: true }); + const agentDirs = entries.filter(e => e.isDirectory() && !e.name.startsWith('_')); + + if (agentDirs.length === 0) { + return { + name: 'Agent Charters', + status: 'warn', + message: 'agents/ directory exists but contains no agent folders', + fix: 'Add agent subdirectories with charter.md files.', + }; + } + + const missing: string[] = []; + for (const dir of agentDirs) { + const charterPath = path.join(agentsDir, dir.name, 'charter.md'); + try { + await fs.promises.access(charterPath, fs.constants.R_OK); + } catch { + missing.push(dir.name); + } + } + + if (missing.length > 0) { + return { + name: 'Agent Charters', + status: 'fail', + message: `Missing charter.md in: ${missing.join(', ')}`, + fix: `Create charter.md files in: ${missing.map(m => `${squadFolder}/agents/${m}/`).join(', ')}`, + }; + } + + return { + name: 'Agent Charters', + status: 'pass', + message: `All ${agentDirs.length} agent(s) have charter.md`, + }; + } catch (error) { + return { + name: 'Agent Charters', + status: 'fail', + message: `Error scanning agents/: ${error instanceof Error ? error.message : String(error)}`, + }; + } + } + + /** + * Validates that orchestration log files parse without errors. + */ + async checkOrchestrationLogs(squadFolder: string, workspaceRoot: string): Promise { + const service = new OrchestrationLogService(squadFolder as '.squad' | '.ai-team'); + + try { + const files = await service.discoverLogFiles(workspaceRoot); + + if (files.length === 0) { + return { + name: 'Orchestration Logs', + status: 'warn', + message: 'No orchestration log files found', + fix: `Create .md log files in ${squadFolder}/orchestration-log/ or ${squadFolder}/log/.`, + }; + } + + const errors: string[] = []; + for (const file of files) { + try { + await service.parseLogFile(file); + } catch (error) { + errors.push(path.basename(file)); + } + } + + if (errors.length > 0) { + return { + name: 'Orchestration Logs', + status: 'fail', + message: `${errors.length} log file(s) failed to parse: ${errors.join(', ')}`, + fix: 'Check the listed files for malformed markdown structure.', + }; + } + + return { + name: 'Orchestration Logs', + status: 'pass', + message: `All ${files.length} log file(s) parsed successfully`, + }; + } catch (error) { + return { + name: 'Orchestration Logs', + status: 'fail', + message: `Log discovery error: ${error instanceof Error ? error.message : String(error)}`, + }; + } + } + + /** + * Verifies GitHub token configuration. + * Warns if not configured (optional feature), fails if configured but unreachable. + */ + async checkGitHubConfig(): Promise { + const token = process.env.GITHUB_TOKEN || process.env.GH_TOKEN; + + if (!token) { + return { + name: 'GitHub Config', + status: 'warn', + message: 'No GitHub token found (GITHUB_TOKEN or GH_TOKEN)', + fix: 'Set GITHUB_TOKEN or GH_TOKEN environment variable to enable issue integration.', + }; + } + + return { + name: 'GitHub Config', + status: 'pass', + message: 'GitHub token is configured', + }; + } + + /** + * Runs all health checks and returns structured results. + */ + async runAll(squadFolder: string, workspaceRoot: string): Promise { + const results = await Promise.all([ + this.checkTeamMd(squadFolder, workspaceRoot), + this.checkAgentCharters(squadFolder, workspaceRoot), + this.checkOrchestrationLogs(squadFolder, workspaceRoot), + this.checkGitHubConfig(), + ]); + + return results; + } + + /** + * Formats results into a human-readable summary string. + */ + formatResults(results: HealthCheckResult[]): string { + const lines: string[] = ['Squad Health Check Results', '═'.repeat(40), '']; + + for (const r of results) { + const icon = r.status === 'pass' ? '✅' : r.status === 'warn' ? '⚠️' : '❌'; + lines.push(`${icon} ${r.name}: ${r.message}`); + if (r.fix) { + lines.push(` Fix: ${r.fix}`); + } + } + + const passed = results.filter(r => r.status === 'pass').length; + const warned = results.filter(r => r.status === 'warn').length; + const failed = results.filter(r => r.status === 'fail').length; + lines.push(''); + lines.push(`Summary: ${passed} passed, ${warned} warning(s), ${failed} failed`); + + return lines.join('\n'); + } +} diff --git a/src/services/index.ts b/src/services/index.ts index 390d59d..a3a7ded 100644 --- a/src/services/index.ts +++ b/src/services/index.ts @@ -29,3 +29,7 @@ export { type StandupSummary, type StandupPeriod } from './StandupReportService'; +export { + HealthCheckService, + type HealthCheckResult +} from './HealthCheckService'; diff --git a/src/test/suite/healthCheckService.test.ts b/src/test/suite/healthCheckService.test.ts new file mode 100644 index 0000000..7ad4d4f --- /dev/null +++ b/src/test/suite/healthCheckService.test.ts @@ -0,0 +1,227 @@ +/** + * Tests for HealthCheckService. + * Validates each diagnostic check against test fixtures and edge cases. + */ + +import * as assert from 'assert'; +import * as path from 'path'; +import * as fs from 'fs'; +import { HealthCheckService, HealthCheckResult } from '../../services/HealthCheckService'; + +const TEST_FIXTURES_ROOT = path.resolve(__dirname, '../../../test-fixtures'); + +suite('HealthCheckService', () => { + let service: HealthCheckService; + + setup(() => { + service = new HealthCheckService(); + }); + + suite('checkTeamMd()', () => { + test('passes when team.md exists and has members', async () => { + const result = await service.checkTeamMd('.ai-team', TEST_FIXTURES_ROOT); + assert.strictEqual(result.status, 'pass'); + assert.ok(result.message.includes('member(s) found')); + }); + + test('fails when squad folder does not exist', async () => { + const result = await service.checkTeamMd('.ai-team', '/nonexistent/path'); + assert.strictEqual(result.status, 'fail'); + assert.ok(result.message.includes('not found')); + assert.ok(result.fix); + }); + + test('fails when team.md is missing', async () => { + // Use a directory that exists but has no team.md + const tempDir = path.join(TEST_FIXTURES_ROOT, `temp-health-${Date.now()}`); + const squadDir = path.join(tempDir, '.ai-team'); + fs.mkdirSync(squadDir, { recursive: true }); + + try { + const result = await service.checkTeamMd('.ai-team', tempDir); + assert.strictEqual(result.status, 'fail'); + assert.ok(result.message.includes('not found')); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } + }); + + test('warns when team.md parses but has no members', async () => { + const tempDir = path.join(TEST_FIXTURES_ROOT, `temp-health-${Date.now()}`); + const squadDir = path.join(tempDir, '.ai-team'); + fs.mkdirSync(squadDir, { recursive: true }); + fs.writeFileSync(path.join(squadDir, 'team.md'), '# My Team\n\nNo table here.\n'); + + try { + const result = await service.checkTeamMd('.ai-team', tempDir); + assert.strictEqual(result.status, 'warn'); + assert.ok(result.message.includes('no members')); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } + }); + }); + + suite('checkAgentCharters()', () => { + test('warns when agents/ directory does not exist', async () => { + const tempDir = path.join(TEST_FIXTURES_ROOT, `temp-health-${Date.now()}`); + const squadDir = path.join(tempDir, '.ai-team'); + fs.mkdirSync(squadDir, { recursive: true }); + + try { + const result = await service.checkAgentCharters('.ai-team', tempDir); + assert.strictEqual(result.status, 'warn'); + assert.ok(result.message.includes('No agents/')); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } + }); + + test('warns when agents/ is empty', async () => { + const tempDir = path.join(TEST_FIXTURES_ROOT, `temp-health-${Date.now()}`); + const agentsDir = path.join(tempDir, '.ai-team', 'agents'); + fs.mkdirSync(agentsDir, { recursive: true }); + + try { + const result = await service.checkAgentCharters('.ai-team', tempDir); + assert.strictEqual(result.status, 'warn'); + assert.ok(result.message.includes('no agent folders')); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } + }); + + test('fails when agent folders are missing charter.md', async () => { + const tempDir = path.join(TEST_FIXTURES_ROOT, `temp-health-${Date.now()}`); + const agentsDir = path.join(tempDir, '.ai-team', 'agents'); + fs.mkdirSync(path.join(agentsDir, 'danny'), { recursive: true }); + fs.mkdirSync(path.join(agentsDir, 'rusty'), { recursive: true }); + // Only danny gets a charter + fs.writeFileSync(path.join(agentsDir, 'danny', 'charter.md'), '# Danny\n'); + + try { + const result = await service.checkAgentCharters('.ai-team', tempDir); + assert.strictEqual(result.status, 'fail'); + assert.ok(result.message.includes('rusty')); + assert.ok(!result.message.includes('danny')); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } + }); + + test('passes when all agents have charter.md', async () => { + const tempDir = path.join(TEST_FIXTURES_ROOT, `temp-health-${Date.now()}`); + const agentsDir = path.join(tempDir, '.ai-team', 'agents'); + fs.mkdirSync(path.join(agentsDir, 'danny'), { recursive: true }); + fs.mkdirSync(path.join(agentsDir, 'rusty'), { recursive: true }); + fs.writeFileSync(path.join(agentsDir, 'danny', 'charter.md'), '# Danny\n'); + fs.writeFileSync(path.join(agentsDir, 'rusty', 'charter.md'), '# Rusty\n'); + + try { + const result = await service.checkAgentCharters('.ai-team', tempDir); + assert.strictEqual(result.status, 'pass'); + assert.ok(result.message.includes('2 agent(s)')); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } + }); + + test('skips directories starting with underscore', async () => { + const tempDir = path.join(TEST_FIXTURES_ROOT, `temp-health-${Date.now()}`); + const agentsDir = path.join(tempDir, '.ai-team', 'agents'); + fs.mkdirSync(path.join(agentsDir, 'danny'), { recursive: true }); + fs.mkdirSync(path.join(agentsDir, '_alumni'), { recursive: true }); + fs.writeFileSync(path.join(agentsDir, 'danny', 'charter.md'), '# Danny\n'); + + try { + const result = await service.checkAgentCharters('.ai-team', tempDir); + assert.strictEqual(result.status, 'pass'); + assert.ok(result.message.includes('1 agent(s)')); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } + }); + }); + + suite('checkOrchestrationLogs()', () => { + test('passes when log files parse successfully', async () => { + const result = await service.checkOrchestrationLogs('.ai-team', TEST_FIXTURES_ROOT); + // Test fixtures have log files — should parse + assert.ok(['pass', 'warn'].includes(result.status)); + }); + + test('warns when no log files found', async () => { + const tempDir = path.join(TEST_FIXTURES_ROOT, `temp-health-${Date.now()}`); + const squadDir = path.join(tempDir, '.ai-team'); + fs.mkdirSync(squadDir, { recursive: true }); + + try { + const result = await service.checkOrchestrationLogs('.ai-team', tempDir); + assert.strictEqual(result.status, 'warn'); + assert.ok(result.message.includes('No orchestration log')); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } + }); + }); + + suite('checkGitHubConfig()', () => { + test('returns pass or warn based on environment', async () => { + const result = await service.checkGitHubConfig(); + // Can't control env in tests, but result should be valid + assert.ok(['pass', 'warn'].includes(result.status)); + assert.strictEqual(result.name, 'GitHub Config'); + }); + }); + + suite('runAll()', () => { + test('returns results for all checks', async () => { + const results = await service.runAll('.ai-team', TEST_FIXTURES_ROOT); + assert.strictEqual(results.length, 4); + assert.ok(results.some(r => r.name === 'team.md')); + assert.ok(results.some(r => r.name === 'Agent Charters')); + assert.ok(results.some(r => r.name === 'Orchestration Logs')); + assert.ok(results.some(r => r.name === 'GitHub Config')); + }); + + test('all results have valid status values', async () => { + const results = await service.runAll('.ai-team', TEST_FIXTURES_ROOT); + for (const r of results) { + assert.ok(['pass', 'fail', 'warn'].includes(r.status), `Invalid status: ${r.status}`); + } + }); + }); + + suite('formatResults()', () => { + test('formats passing results with checkmark', () => { + const results: HealthCheckResult[] = [ + { name: 'team.md', status: 'pass', message: 'OK' }, + ]; + const output = service.formatResults(results); + assert.ok(output.includes('✅')); + assert.ok(output.includes('1 passed')); + }); + + test('formats failing results with fix suggestions', () => { + const results: HealthCheckResult[] = [ + { name: 'team.md', status: 'fail', message: 'Missing', fix: 'Create it' }, + ]; + const output = service.formatResults(results); + assert.ok(output.includes('❌')); + assert.ok(output.includes('Fix: Create it')); + assert.ok(output.includes('1 failed')); + }); + + test('formats mixed results with summary counts', () => { + const results: HealthCheckResult[] = [ + { name: 'A', status: 'pass', message: 'ok' }, + { name: 'B', status: 'warn', message: 'maybe' }, + { name: 'C', status: 'fail', message: 'bad' }, + ]; + const output = service.formatResults(results); + assert.ok(output.includes('1 passed')); + assert.ok(output.includes('1 warning(s)')); + assert.ok(output.includes('1 failed')); + }); + }); +});