diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e970393..e55e358 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,13 +15,13 @@ jobs: name: Test & Build runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 with: submodules: true - - uses: actions/setup-go@v5 + - uses: actions/setup-go@v6 with: - go-version: '1.24' + go-version: '1.25' cache-dependency-path: src/mcp/go.sum - name: Check go mod tidy @@ -51,7 +51,7 @@ jobs: fi - name: Upload coverage - uses: codecov/codecov-action@v4 + uses: codecov/codecov-action@v5 with: file: src/mcp/coverage.out fail_ci_if_error: false @@ -61,17 +61,17 @@ jobs: name: Lint runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 with: submodules: true - - uses: actions/setup-go@v5 + - uses: actions/setup-go@v6 with: - go-version: '1.24' + go-version: '1.25' cache-dependency-path: src/mcp/go.sum - uses: golangci/golangci-lint-action@v7 with: - version: v2.1.6 + version: v2.11.4 working-directory: src/mcp args: --timeout=5m diff --git a/.github/workflows/install-test.yml b/.github/workflows/install-test.yml index f869d59..9c53cc2 100644 --- a/.github/workflows/install-test.yml +++ b/.github/workflows/install-test.yml @@ -12,7 +12,7 @@ jobs: name: Install (Linux) runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Test install.sh (source build fallback) run: | @@ -29,11 +29,11 @@ jobs: name: Install (macOS) runs-on: macos-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - - uses: actions/setup-go@v5 + - uses: actions/setup-go@v6 with: - go-version: '1.24' + go-version: '1.25' - name: Test install.sh (source build fallback) run: bash install.sh diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 085ae01..cbcee68 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -13,14 +13,14 @@ jobs: name: Pre-release tests runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 with: submodules: true fetch-depth: 0 - - uses: actions/setup-go@v5 + - uses: actions/setup-go@v6 with: - go-version: '1.24' + go-version: '1.25' cache-dependency-path: src/mcp/go.sum - name: Run tests @@ -32,14 +32,14 @@ jobs: needs: test runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 with: submodules: true fetch-depth: 0 - - uses: actions/setup-go@v5 + - uses: actions/setup-go@v6 with: - go-version: '1.24' + go-version: '1.25' cache-dependency-path: src/mcp/go.sum - uses: goreleaser/goreleaser-action@v6 diff --git a/.github/workflows/update-fpf.yml b/.github/workflows/update-fpf.yml index 3330ae8..9d38018 100644 --- a/.github/workflows/update-fpf.yml +++ b/.github/workflows/update-fpf.yml @@ -13,14 +13,14 @@ jobs: update-fpf: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 with: submodules: true fetch-depth: 0 - - uses: actions/setup-go@v5 + - uses: actions/setup-go@v6 with: - go-version: '1.24' + go-version: '1.25' - name: Get current submodule commit id: current diff --git a/.goreleaser.yaml b/.goreleaser.yaml index 6e2bf54..38e9237 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -37,11 +37,7 @@ release: header: | ## Quint Code {{ .Tag }} - **Complete product redesign.** v5 is a new product — not backward-compatible with v4. - - New: FPF E.9 decision records, computed R_eff with evidence decay, problem lifecycle (Backlog → In Progress → Addressed), diversity check, archive recall, parity enforcement, indicator roles, Goldilocks signals, lemniscate feedback loop, universal artifact refresh, and 4243 indexed FPF spec sections. - - 6 MCP tools. Works with Claude Code, Cursor, Gemini CLI, Codex CLI. + FPF-native engineering decision tool. 6 MCP tools. Works with Claude Code, Cursor, Gemini CLI, Codex CLI. ### Install @@ -49,4 +45,4 @@ release: curl -fsSL https://raw.githubusercontent.com/m0n0x41d/quint-code/main/install.sh | bash ``` - See [CHANGELOG](https://github.com/m0n0x41d/quint-code/blob/main/src/mcp/CHANGELOG.md) for full details. + See [CHANGELOG](https://github.com/m0n0x41d/quint-code/blob/main/CHANGELOG.md) for full details. diff --git a/CHANGELOG.md b/CHANGELOG.md index 804a1d2..94a3c02 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,23 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## [Unreleased] +## [5.3.0] — 2026-03-24 + +### Added + +- **Interactive terminal dashboard (`quint-code board`)** — Bubbletea v2 TUI with four tabs: Overview (health, activity, depth distribution, coverage, contexts, evidence), Problems (backlog with drill-in), Decisions (list with R_eff/drift, drill-in with glamour markdown), Modules (coverage tree). Live refresh every 3s. Connected tab borders, alternating row colors, adaptive dark/light theme, dynamic help bar. Exit code 1 with `--check` flag for CI/hooks. +- **Decision mode computed from artifact chain** — `inferModeFromChain` derives mode from linked problems (characterization) and portfolios (comparison). Agent-declared mode can only escalate, not downgrade. Fixes misclassification where full-cycle decisions were recorded as tactical. +- **FTS5 search keyword enrichment** — `search_keywords` column on artifacts, indexed by FTS5. Agent generates synonyms and related terms at write time. Accepted on `quint_note` and `quint_decision`. Migration 15 rebuilds FTS5 index. +- **C/C++ header-only module detection** — `-I` include directories from `compile_commands.json` are registered as modules (FileCount=0), so dependency edges to `include/` directories are no longer dropped by `ScanDependencies`. + +### Fixed + +- **`/q-refresh scan` now rescans modules** — module structure updates alongside drift and stale checks, keeping dependency graph fresh without requiring a separate `coverage` action. +- **C/C++ symlink-safe include resolution** — `resolveInclude` canonicalizes both `projectRoot` and `-I` paths with `EvalSymlinks` before computing relative paths. Fixes silent edge loss on macOS symlinked checkouts. +- **Notes excluded from drift detection** — notes are observations, not implementations. ScanStale no longer flags notes with affected_files as "no baseline." +- **Module scanner excludes `.claude` and `.context` directories** — Claude Code worktrees and reference repos no longer inflate module count. +- **q-reason skill context-aware entry** — skill no longer always falls through into full FPF cycle. Three paths: think-and-respond (no artifacts), prepare-and-wait (human drives), full autonomous cycle (agent drives). Default is prepare-and-wait. + ## [5.2.0] — 2026-03-23 ### Added diff --git a/src/mcp/.golangci.yml b/src/mcp/.golangci.yml index 986b0f9..eedad22 100644 --- a/src/mcp/.golangci.yml +++ b/src/mcp/.golangci.yml @@ -32,13 +32,18 @@ linters: excludes: - G301 # directory permissions 0755 — fine for user-facing dirs - G302 # file permissions 0644 — fine for user-facing files + - G204 # subprocess with variable — we call quint/git as subprocess intentionally - G304 # file inclusion via variable — we read user-specified paths intentionally - G306 # WriteFile permissions 0644 — fine for markdown/config files + - G703 # path traversal — we construct paths from project root intentionally exclusions: rules: - linters: - staticcheck text: "QF1003" + - linters: + - staticcheck + text: "QF1012" - linters: - errcheck source: "defer .*(Close|Rollback)\\(\\)" diff --git a/src/mcp/cmd/board.go b/src/mcp/cmd/board.go new file mode 100644 index 0000000..9c1cdb1 --- /dev/null +++ b/src/mcp/cmd/board.go @@ -0,0 +1,141 @@ +package cmd + +import ( + "database/sql" + "fmt" + "os" + "path/filepath" + + tea "charm.land/bubbletea/v2" + "github.com/spf13/cobra" + + "github.com/m0n0x41d/quint-code/internal/artifact" + "github.com/m0n0x41d/quint-code/internal/project" + "github.com/m0n0x41d/quint-code/internal/ui" +) + +var checkMode bool + +var boardCmd = &cobra.Command{ + Use: "board", + Short: "Interactive dashboard — decision health, coverage, drift, problems", + Long: `Launch the Quint Code dashboard. + +Shows decision health, module coverage, drift alerts, problem pipeline, +and evidence quality in an interactive terminal UI. + +Navigation: tab/1-4 switch views, j/k navigate, enter drill in, esc back, q quit. + +Use --check for CI/hooks: exits with code 1 if critical issues exist +(R_eff < 0.3, decisions expired > 30 days).`, + RunE: runBoard, +} + +func init() { + boardCmd.Flags().BoolVar(&checkMode, "check", false, "Health check mode: print summary and exit with code 1 if critical issues") + rootCmd.AddCommand(boardCmd) +} + +func runBoard(cmd *cobra.Command, _ []string) error { + // Find project root + projectRoot, err := findProjectRoot() + if err != nil { + return fmt.Errorf("not a quint-code project (no .quint/ directory found): %w", err) + } + + quintDir := filepath.Join(projectRoot, ".quint") + + // Load project config + projCfg, err := project.Load(quintDir) + if err != nil { + return fmt.Errorf("load project config: %w", err) + } + if projCfg == nil { + return fmt.Errorf("project not initialized — run 'quint-code init' first") + } + + // Open DB + dbPath, err := projCfg.DBPath() + if err != nil { + return fmt.Errorf("get DB path: %w", err) + } + + // Open DB with WAL mode and busy timeout. + // MCP server may hold a write connection to the same DB — + // WAL allows concurrent readers, busy timeout prevents instant SQLITE_BUSY. + dsn := dbPath + "?_pragma=journal_mode(WAL)&_pragma=busy_timeout(3000)" + sqlDB, err := sql.Open("sqlite", dsn) + if err != nil { + return fmt.Errorf("open DB: %w", err) + } + defer sqlDB.Close() + + store := artifact.NewStore(sqlDB) + projectName := projCfg.Name + + // Load all data + data, err := ui.LoadBoardData(store, sqlDB, projectName, projectRoot) + if err != nil { + return fmt.Errorf("load board data: %w", err) + } + + // Check mode: print summary and exit + if checkMode { + return runCheck(data) + } + + // Interactive mode + model := ui.New(data, store, sqlDB, projectName, projectRoot) + p := tea.NewProgram(model) + finalModel, err := p.Run() + if err != nil { + return fmt.Errorf("board: %w", err) + } + + // Interactive mode always exits 0 — user saw the dashboard. + // Use --check for non-zero exit on critical issues. + _ = finalModel + return nil +} + +func runCheck(data *ui.BoardData) error { + fmt.Printf("Quint Code Health: %s\n", data.ProjectName) + fmt.Printf(" Decisions: %d shipped, %d pending\n", data.ShippedCount, data.PendingCount) + fmt.Printf(" Problems: %d backlog, %d addressed\n", len(data.BacklogProblems), data.AddressedCount) + fmt.Printf(" Stale: %d items\n", len(data.StaleItems)) + + if data.CoverageReport != nil { + cr := data.CoverageReport + pct := 0 + if cr.TotalModules > 0 { + pct = (cr.CoveredCount + cr.PartialCount) * 100 / cr.TotalModules + } + fmt.Printf(" Coverage: %d%% (%d/%d modules)\n", pct, cr.CoveredCount+cr.PartialCount, cr.TotalModules) + } + + if data.CriticalCount > 0 { + fmt.Printf("\n CRITICAL: %d issue(s) require attention\n", data.CriticalCount) + os.Exit(1) + } + + fmt.Println("\n OK: no critical issues") + return nil +} + +func findProjectRoot() (string, error) { + dir, err := os.Getwd() + if err != nil { + return "", err + } + + for { + if _, err := os.Stat(filepath.Join(dir, ".quint")); err == nil { + return dir, nil + } + parent := filepath.Dir(dir) + if parent == dir { + return "", fmt.Errorf("no .quint/ found") + } + dir = parent + } +} diff --git a/src/mcp/cmd/serve.go b/src/mcp/cmd/serve.go index 90020b3..9b4447c 100644 --- a/src/mcp/cmd/serve.go +++ b/src/mcp/cmd/serve.go @@ -294,6 +294,9 @@ func handleQuintNote(ctx context.Context, store *artifact.Store, quintDir string input.Context = v } input.AffectedFiles = parseStringArrayFromArgs(args, "affected_files") + if v, ok := args["search_keywords"].(string); ok { + input.SearchKeywords = v + } validation := artifact.ValidateNote(ctx, store, input) navStrip := artifact.BuildNavStrip(ctx, store, input.Context) @@ -535,6 +538,9 @@ func handleQuintDecision(ctx context.Context, store *artifact.Store, quintDir st input.EvidenceReqs = parseStringArrayFromArgs(args, "evidence_requirements") input.RefreshTriggers = parseStringArrayFromArgs(args, "refresh_triggers") input.AffectedFiles = parseStringArrayFromArgs(args, "affected_files") + if v, ok := args["search_keywords"].(string); ok { + input.SearchKeywords = v + } if rb, ok := args["rollback"].(map[string]interface{}); ok { rollback := &artifact.RollbackSpec{} @@ -761,6 +767,16 @@ func handleQuintRefresh(ctx context.Context, store *artifact.Store, quintDir str switch artifact.RefreshAction(action) { case artifact.RefreshScan: projectRoot := filepath.Dir(quintDir) + + // Rescan modules before drift detection — keeps dependency graph fresh + scanner := codebase.NewScanner(store.DB()) + if _, err := scanner.ScanModules(ctx, projectRoot); err != nil { + logger.Warn().Err(err).Msg("refresh: module rescan failed (non-fatal)") + } + if _, err := scanner.ScanDependencies(ctx, projectRoot); err != nil { + _ = err // non-fatal + } + items, err := artifact.ScanStale(ctx, store, projectRoot) if err != nil { return "", err diff --git a/src/mcp/cmd/skill/q-reason/SKILL.md b/src/mcp/cmd/skill/q-reason/SKILL.md index 37cb825..734ba66 100644 --- a/src/mcp/cmd/skill/q-reason/SKILL.md +++ b/src/mcp/cmd/skill/q-reason/SKILL.md @@ -14,6 +14,29 @@ This skill activates structured engineering reasoning powered by FPF (First Prin --- +## Context-aware entry — read what the user actually wants + +**Before doing anything, assess the user's intent from context and arguments.** Do NOT always fall through into the full FPF cycle. There are three distinct paths: + +### Path 1: Think and respond (no artifacts) +**Trigger:** "think about X", "what do you think about X", "analyze X", "is this the right approach?", "what are our options?" + +The user wants structured thinking, not tool calls. Reason through the problem using FPF principles (weakest link, parity, distinguish object/description/carrier, etc.). Give a well-structured answer. **Do not call quint tools** unless the user explicitly asks to persist something. + +### Path 2: Prepare for human-driven cycle (research + wait) +**Trigger:** "/q-reason [topic], prepare for framing", "let's think about X before deciding", "I want to reason through X" + +The user wants to drive the cycle themselves. Gather context (read relevant code, search existing decisions, research). Present findings. **Stop and wait** for the user to decide the next step — they will call `/q-frame`, `/q-char`, etc. when ready. + +### Path 3: Full autonomous cycle (agent drives) +**Trigger:** "/q-reason [topic] and implement", "figure out the best approach and do it", "fix everything", explicit delegation to agent + +The user wants the agent to run the full cycle: frame → explore → decide → implement. Only in this mode does the agent drive without pausing. + +**If unclear which path:** default to Path 2 (prepare and wait). Never default to Path 3. Ask: "Want me to think this through and present options, or drive the full cycle and implement?" + +--- + ## What you have ### Quint tools (MCP) — persist reasoning as artifacts diff --git a/src/mcp/db/migrations.go b/src/mcp/db/migrations.go index 8c873ee..e044768 100644 --- a/src/mcp/db/migrations.go +++ b/src/mcp/db/migrations.go @@ -204,6 +204,11 @@ var migrations = []struct { description: "Codebase awareness: module map and dependency graph", sql: "", // Applied as individual statements below (migration14Statements) }, + { + version: 15, + description: "FTS5 enrichment: search_keywords column for semantic search", + sql: "", // Applied as individual statements below (migration15Statements) + }, } var migration9Statements = []string{ @@ -238,6 +243,41 @@ var migration12Statements = []string{ )`, } +var migration15Statements = []string{ + // Add search_keywords column to artifacts + "ALTER TABLE artifacts ADD COLUMN search_keywords TEXT DEFAULT ''", + + // Rebuild FTS5 table and triggers to include search_keywords + "DROP TRIGGER IF EXISTS artifacts_fts_insert", + "DROP TRIGGER IF EXISTS artifacts_fts_update", + "DROP TRIGGER IF EXISTS artifacts_fts_delete", + "DROP TABLE IF EXISTS artifacts_fts", + + `CREATE VIRTUAL TABLE IF NOT EXISTS artifacts_fts USING fts5( + id, title, content, kind, search_keywords, + tokenize='porter unicode61' + )`, + + `CREATE TRIGGER IF NOT EXISTS artifacts_fts_insert AFTER INSERT ON artifacts BEGIN + INSERT INTO artifacts_fts(id, title, content, kind, search_keywords) + VALUES (new.id, new.title, new.content, new.kind, new.search_keywords); + END`, + + `CREATE TRIGGER IF NOT EXISTS artifacts_fts_update AFTER UPDATE ON artifacts BEGIN + DELETE FROM artifacts_fts WHERE id = old.id; + INSERT INTO artifacts_fts(id, title, content, kind, search_keywords) + VALUES (new.id, new.title, new.content, new.kind, new.search_keywords); + END`, + + `CREATE TRIGGER IF NOT EXISTS artifacts_fts_delete AFTER DELETE ON artifacts BEGIN + DELETE FROM artifacts_fts WHERE id = old.id; + END`, + + // Rebuild FTS5 index from existing artifacts + `INSERT INTO artifacts_fts(id, title, content, kind, search_keywords) + SELECT id, title, content, kind, COALESCE(search_keywords, '') FROM artifacts`, +} + var migration14Statements = []string{ `CREATE TABLE IF NOT EXISTS codebase_modules ( module_id TEXT PRIMARY KEY, @@ -480,6 +520,15 @@ func RunMigrations(conn *sql.DB) error { } } } + } else if m.version == 15 { + // FTS5 enrichment: search_keywords + for _, stmt := range migration15Statements { + if _, execErr := conn.Exec(stmt); execErr != nil { + if !isDuplicateColumnError(execErr) && !strings.Contains(execErr.Error(), "already exists") { + return fmt.Errorf("migration %d statement failed: %w", m.version, execErr) + } + } + } } else if m.sql != "" { _, execErr := conn.Exec(m.sql) if execErr != nil && !isDuplicateColumnError(execErr) { diff --git a/src/mcp/go.mod b/src/mcp/go.mod index e325d97..fbf4998 100644 --- a/src/mcp/go.mod +++ b/src/mcp/go.mod @@ -1,8 +1,13 @@ module github.com/m0n0x41d/quint-code -go 1.24.0 +go 1.25.8 require ( + charm.land/bubbletea/v2 v2.0.2 + charm.land/glamour/v2 v2.0.0 + charm.land/lipgloss/v2 v2.0.2 + github.com/charmbracelet/x/ansi v0.11.6 + github.com/charmbracelet/x/term v0.2.2 github.com/rs/zerolog v1.34.0 github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06 github.com/spf13/cobra v1.10.2 @@ -11,16 +16,38 @@ require ( ) require ( + github.com/alecthomas/chroma/v2 v2.14.0 // indirect + github.com/aymerick/douceur v0.2.0 // indirect + github.com/charmbracelet/colorprofile v0.4.2 // indirect + github.com/charmbracelet/ultraviolet v0.0.0-20260205113103-524a6607adb8 // indirect + github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf // indirect + github.com/charmbracelet/x/termios v0.1.1 // indirect + github.com/charmbracelet/x/windows v0.2.2 // indirect + github.com/clipperhouse/displaywidth v0.11.0 // indirect + github.com/clipperhouse/uax29/v2 v2.7.0 // indirect + github.com/dlclark/regexp2 v1.11.0 // indirect github.com/dustin/go-humanize v1.0.1 // indirect github.com/google/uuid v1.6.0 // indirect + github.com/gorilla/css v1.0.1 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/lucasb-eyer/go-colorful v1.3.0 // indirect github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-runewidth v0.0.20 // indirect + github.com/microcosm-cc/bluemonday v1.0.27 // indirect + github.com/muesli/cancelreader v0.2.2 // indirect github.com/ncruces/go-strftime v0.1.9 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + github.com/rivo/uniseg v0.4.7 // indirect github.com/spf13/pflag v1.0.9 // indirect + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect + github.com/yuin/goldmark v1.7.8 // indirect + github.com/yuin/goldmark-emoji v1.0.5 // indirect golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect - golang.org/x/sys v0.36.0 // indirect + golang.org/x/net v0.39.0 // indirect + golang.org/x/sync v0.19.0 // indirect + golang.org/x/sys v0.42.0 // indirect + golang.org/x/text v0.24.0 // indirect modernc.org/libc v1.66.10 // indirect modernc.org/mathutil v1.7.1 // indirect modernc.org/memory v1.11.0 // indirect diff --git a/src/mcp/go.sum b/src/mcp/go.sum index bd7d625..5c48c47 100644 --- a/src/mcp/go.sum +++ b/src/mcp/go.sum @@ -1,7 +1,45 @@ +charm.land/bubbletea/v2 v2.0.2 h1:4CRtRnuZOdFDTWSff9r8QFt/9+z6Emubz3aDMnf/dx0= +charm.land/bubbletea/v2 v2.0.2/go.mod h1:3LRff2U4WIYXy7MTxfbAQ+AdfM3D8Xuvz2wbsOD9OHQ= +charm.land/glamour/v2 v2.0.0 h1:IDBoqLEy7Hdpb9VOXN+khLP/XSxtJy1VsHuW/yF87+U= +charm.land/glamour/v2 v2.0.0/go.mod h1:kjq9WB0s8vuUYZNYey2jp4Lgd9f4cKdzAw88FZtpj/w= +charm.land/lipgloss/v2 v2.0.2 h1:xFolbF8JdpNkM2cEPTfXEcW1p6NRzOWTSamRfYEw8cs= +charm.land/lipgloss/v2 v2.0.2/go.mod h1:KjPle2Qd3YmvP1KL5OMHiHysGcNwq6u83MUjYkFvEkM= +github.com/alecthomas/assert/v2 v2.7.0 h1:QtqSACNS3tF7oasA8CU6A6sXZSBDqnm7RfpLl9bZqbE= +github.com/alecthomas/assert/v2 v2.7.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k= +github.com/alecthomas/chroma/v2 v2.14.0 h1:R3+wzpnUArGcQz7fCETQBzO5n9IMNi13iIs46aU4V9E= +github.com/alecthomas/chroma/v2 v2.14.0/go.mod h1:QolEbTfmUHIMVpBqxeDnNBj2uoeI4EbYP4i6n68SG4I= +github.com/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc= +github.com/alecthomas/repr v0.4.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= +github.com/aymanbagabas/go-udiff v0.4.1 h1:OEIrQ8maEeDBXQDoGCbbTTXYJMYRCRO1fnodZ12Gv5o= +github.com/aymanbagabas/go-udiff v0.4.1/go.mod h1:0L9PGwj20lrtmEMeyw4WKJ/TMyDtvAoK9bf2u/mNo3w= +github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk= +github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= +github.com/charmbracelet/colorprofile v0.4.2 h1:BdSNuMjRbotnxHSfxy+PCSa4xAmz7szw70ktAtWRYrY= +github.com/charmbracelet/colorprofile v0.4.2/go.mod h1:0rTi81QpwDElInthtrQ6Ni7cG0sDtwAd4C4le060fT8= +github.com/charmbracelet/ultraviolet v0.0.0-20260205113103-524a6607adb8 h1:eyFRbAmexyt43hVfeyBofiGSEmJ7krjLOYt/9CF5NKA= +github.com/charmbracelet/ultraviolet v0.0.0-20260205113103-524a6607adb8/go.mod h1:SQpCTRNBtzJkwku5ye4S3HEuthAlGy2n9VXZnWkEW98= +github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8= +github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ= +github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f h1:pk6gmGpCE7F3FcjaOEKYriCvpmIN4+6OS/RD0vm4uIA= +github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f/go.mod h1:IfZAMTHB6XkZSeXUqriemErjAWCCzT0LwjKFYCZyw0I= +github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf h1:rLG0Yb6MQSDKdB52aGX55JT1oi0P0Kuaj7wi1bLUpnI= +github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf/go.mod h1:B3UgsnsBZS/eX42BlaNiJkD1pPOUa+oF1IYC6Yd2CEU= +github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= +github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI= +github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY= +github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo= +github.com/charmbracelet/x/windows v0.2.2 h1:IofanmuvaxnKHuV04sC0eBy/smG6kIKrWG2/jYn2GuM= +github.com/charmbracelet/x/windows v0.2.2/go.mod h1:/8XtdKZzedat74NQFn0NGlGL4soHB0YQZrETF96h75k= +github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8= +github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0= +github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk= +github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI= +github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= @@ -9,14 +47,26 @@ github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17k github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8= +github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0= +github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= +github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag= +github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-runewidth v0.0.20 h1:WcT52H91ZUAwy8+HUkdM3THM6gXqXuLJi9O3rjcQQaQ= +github.com/mattn/go-runewidth v0.0.20/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= +github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk= +github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA= +github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= +github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= @@ -24,6 +74,8 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0= github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY= github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ= @@ -37,18 +89,29 @@ github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.6.1 h1:hDPOHmpOpP40lSULcqw7IrRb/u7w6RpDC9399XyoNd0= github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= +github.com/yuin/goldmark v1.7.1/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E= +github.com/yuin/goldmark v1.7.8 h1:iERMLn0/QJeHFhxSt3p6PeN9mGnvIKSpG9YYorDMnic= +github.com/yuin/goldmark v1.7.8/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E= +github.com/yuin/goldmark-emoji v1.0.5 h1:EMVWyCGPlXJfUXBXpuMu+ii3TIaxbVBnEX9uaDC4cIk= +github.com/yuin/goldmark-emoji v1.0.5/go.mod h1:tTkZEbwu5wkPmgTcitqddVxY9osFZiavD+r4AzQrh1U= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o= golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8= golang.org/x/mod v0.27.0 h1:kb+q2PyFnEADO2IEF935ehFUXlWiNjJWtRNgBLSfbxQ= golang.org/x/mod v0.27.0/go.mod h1:rWI627Fq0DEoudcK+MBkNkCe0EetEaDSwJJkCcjpazc= -golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw= -golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/net v0.39.0 h1:ZCu7HMWDxpXpaiKdhzIfaltL9Lp31x/3fCP11bc6/fY= +golang.org/x/net v0.39.0/go.mod h1:X7NRbYVEA+ewNkCNyJ513WmMdQ3BineSwVtN2zD/d+E= +golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= +golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k= -golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= +golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0= +golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU= golang.org/x/tools v0.36.0 h1:kWS0uv/zsvHEle1LbV5LE8QujrxB3wfQyxHfhOk0Qkg= golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= diff --git a/src/mcp/internal/artifact/decision.go b/src/mcp/internal/artifact/decision.go index f81de1b..f5c58e3 100644 --- a/src/mcp/internal/artifact/decision.go +++ b/src/mcp/internal/artifact/decision.go @@ -36,6 +36,7 @@ type DecideInput struct { Context string `json:"context,omitempty"` Mode string `json:"mode,omitempty"` AffectedFiles []string `json:"affected_files,omitempty"` + SearchKeywords string `json:"search_keywords,omitempty"` } // RejectionReason explains why a variant was not selected. @@ -73,9 +74,9 @@ func Decide(ctx context.Context, store *Store, quintDir string, input DecideInpu id := GenerateID(KindDecisionRecord, seq) now := time.Now().UTC() - mode := Mode(input.Mode) - if mode == "" { - mode = ModeStandard + declaredMode := Mode(input.Mode) + if declaredMode == "" { + declaredMode = ModeStandard } // Merge ProblemRef (single, backward compat) + ProblemRefs (array) into one list @@ -101,6 +102,13 @@ func Decide(ctx context.Context, store *Store, quintDir string, input DecideInpu links = append(links, Link{Ref: input.PortfolioRef, Type: "based_on"}) } + // Compute mode from artifact chain — what actually happened, not what agent claimed. + // Chain evidence: problem exists → not note. Characterization → not tactical. + // Portfolio with comparison → standard minimum. + // Compute mode from artifact chain — what actually happened, not what agent claimed. + chainMode := inferModeFromChain(ctx, store, problemRefs, input.PortfolioRef) + mode := maxMode(declaredMode, chainMode) + // Inherit context from linked artifacts if input.Context == "" { if input.PortfolioRef != "" { @@ -282,7 +290,8 @@ func Decide(ctx context.Context, store *Store, quintDir string, input DecideInpu UpdatedAt: now, Links: links, }, - Body: body.String(), + Body: body.String(), + SearchKeywords: input.SearchKeywords, } if err := store.Create(ctx, a); err != nil { @@ -384,9 +393,7 @@ func CheckDrift(ctx context.Context, store *Store, projectRoot string) ([]DriftR return nil, fmt.Errorf("list decisions: %w", err) } - // Also check notes with affected_files - notes, _ := store.ListByKind(ctx, KindNote, 500) - decisions = append(decisions, notes...) + // Notes are observations, not implementations — skip baseline/drift checks for them var reports []DriftReport @@ -902,6 +909,74 @@ func scoreEvidence(e EvidenceItem, now time.Time) float64 { return reff.ScoreEvidence(e.Verdict, e.CongruenceLevel, e.ValidUntil, now) } +// modeRank maps Mode to a numeric rank for comparison. +func modeRank(m Mode) int { + switch m { + case ModeNote: + return 0 + case ModeTactical: + return 1 + case ModeStandard: + return 2 + case ModeDeep: + return 3 + default: + return 1 + } +} + +// maxMode returns the higher of two modes (deeper reasoning wins). +func maxMode(a, b Mode) Mode { + if modeRank(a) >= modeRank(b) { + return a + } + return b +} + +// inferModeFromChain determines the minimum mode based on what artifacts +// actually exist in the reasoning chain. This reflects what happened, +// not what the agent declared. +func inferModeFromChain(ctx context.Context, store *Store, problemRefs []string, portfolioRef string) Mode { + // No linked problem → note-level (agent just called decide directly) + if len(problemRefs) == 0 && portfolioRef == "" { + return ModeTactical + } + + // Check if any linked problem has characterization + hasCharacterization := false + for _, ref := range problemRefs { + prob, err := store.Get(ctx, ref) + if err != nil { + continue + } + if strings.Contains(prob.Body, "## Characterization") { + hasCharacterization = true + break + } + } + + // Check if portfolio has comparison + hasComparison := false + if portfolioRef != "" { + portfolio, err := store.Get(ctx, portfolioRef) + if err == nil { + hasComparison = strings.Contains(portfolio.Body, "## Comparison") + } + } + + // Derive mode from chain evidence + switch { + case hasCharacterization && hasComparison: + return ModeStandard + case hasCharacterization || hasComparison: + return ModeStandard + case len(problemRefs) > 0: + return ModeTactical // has problem but no char/compare = tactical with frame + default: + return ModeTactical + } +} + // FormatDecisionResponse builds the MCP tool response. func FormatDecisionResponse(action string, a *Artifact, filePath string, extra string, navStrip string) string { var sb strings.Builder diff --git a/src/mcp/internal/artifact/note.go b/src/mcp/internal/artifact/note.go index 0bfdb72..303bb3b 100644 --- a/src/mcp/internal/artifact/note.go +++ b/src/mcp/internal/artifact/note.go @@ -9,12 +9,13 @@ import ( // NoteInput is the input for creating a note. type NoteInput struct { - Title string `json:"title"` - Rationale string `json:"rationale"` - AffectedFiles []string `json:"affected_files,omitempty"` - Evidence string `json:"evidence,omitempty"` - Context string `json:"context,omitempty"` - ValidUntil string `json:"valid_until,omitempty"` + Title string `json:"title"` + Rationale string `json:"rationale"` + AffectedFiles []string `json:"affected_files,omitempty"` + Evidence string `json:"evidence,omitempty"` + Context string `json:"context,omitempty"` + ValidUntil string `json:"valid_until,omitempty"` + SearchKeywords string `json:"search_keywords,omitempty"` } // NoteValidation holds the result of pre-recording checks. @@ -280,7 +281,8 @@ func CreateNote(ctx context.Context, store *Store, quintDir string, input NoteIn CreatedAt: now, UpdatedAt: now, }, - Body: body.String(), + Body: body.String(), + SearchKeywords: input.SearchKeywords, } // DB write diff --git a/src/mcp/internal/artifact/store.go b/src/mcp/internal/artifact/store.go index 1710e42..c50f5cc 100644 --- a/src/mcp/internal/artifact/store.go +++ b/src/mcp/internal/artifact/store.go @@ -38,13 +38,14 @@ func (s *Store) Create(ctx context.Context, a *Artifact) error { } _, err := s.db.ExecContext(ctx, ` - INSERT INTO artifacts (id, kind, version, status, context, mode, title, content, valid_until, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + INSERT INTO artifacts (id, kind, version, status, context, mode, title, content, valid_until, created_at, updated_at, search_keywords) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, a.Meta.ID, string(a.Meta.Kind), a.Meta.Version, string(a.Meta.Status), a.Meta.Context, string(a.Meta.Mode), a.Meta.Title, a.Body, a.Meta.ValidUntil, a.Meta.CreatedAt.Format(time.RFC3339), a.Meta.UpdatedAt.Format(time.RFC3339), + a.SearchKeywords, ) if err != nil { if strings.Contains(err.Error(), "UNIQUE constraint") { @@ -66,12 +67,13 @@ func (s *Store) Create(ctx context.Context, a *Artifact) error { func (s *Store) Get(ctx context.Context, id string) (*Artifact, error) { var a Artifact var kind, status, mode, validUntil, context_, createdAt, updatedAt string + var searchKeywords sql.NullString err := s.db.QueryRowContext(ctx, ` - SELECT id, kind, version, status, context, mode, title, content, valid_until, created_at, updated_at + SELECT id, kind, version, status, context, mode, title, content, valid_until, created_at, updated_at, COALESCE(search_keywords, '') FROM artifacts WHERE id = ?`, id, ).Scan( &a.Meta.ID, &kind, &a.Meta.Version, &status, &context_, &mode, - &a.Meta.Title, &a.Body, &validUntil, &createdAt, &updatedAt, + &a.Meta.Title, &a.Body, &validUntil, &createdAt, &updatedAt, &searchKeywords, ) if err != nil { return nil, fmt.Errorf("get artifact %s: %w", id, err) @@ -83,6 +85,7 @@ func (s *Store) Get(ctx context.Context, id string) (*Artifact, error) { a.Meta.ValidUntil = validUntil a.Meta.CreatedAt, _ = time.Parse(time.RFC3339, createdAt) a.Meta.UpdatedAt, _ = time.Parse(time.RFC3339, updatedAt) + a.SearchKeywords = searchKeywords.String links, err := s.GetLinks(ctx, id) if err == nil { @@ -98,11 +101,12 @@ func (s *Store) Update(ctx context.Context, a *Artifact) error { a.Meta.Version++ result, err := s.db.ExecContext(ctx, ` - UPDATE artifacts SET kind=?, version=?, status=?, context=?, mode=?, title=?, content=?, valid_until=?, updated_at=? + UPDATE artifacts SET kind=?, version=?, status=?, context=?, mode=?, title=?, content=?, valid_until=?, updated_at=?, search_keywords=? WHERE id=?`, string(a.Meta.Kind), a.Meta.Version, string(a.Meta.Status), a.Meta.Context, string(a.Meta.Mode), a.Meta.Title, a.Body, a.Meta.ValidUntil, a.Meta.UpdatedAt.Format(time.RFC3339), + a.SearchKeywords, a.Meta.ID, ) if err != nil { @@ -116,15 +120,23 @@ func (s *Store) Update(ctx context.Context, a *Artifact) error { } // ListByKind returns artifacts of a given kind, ordered by creation time descending. +// If kind is empty, returns all artifacts regardless of kind. func (s *Store) ListByKind(ctx context.Context, kind Kind, limit int) ([]*Artifact, error) { if limit <= 0 { limit = 50 } - rows, err := s.db.QueryContext(ctx, ` - SELECT id, kind, version, status, context, mode, title, content, valid_until, created_at, updated_at - FROM artifacts WHERE kind = ? ORDER BY created_at DESC LIMIT ?`, - string(kind), limit, - ) + var rows *sql.Rows + var err error + if kind == "" { + rows, err = s.db.QueryContext(ctx, ` + SELECT id, kind, version, status, context, mode, title, content, valid_until, created_at, updated_at + FROM artifacts ORDER BY created_at DESC LIMIT ?`, limit) + } else { + rows, err = s.db.QueryContext(ctx, ` + SELECT id, kind, version, status, context, mode, title, content, valid_until, created_at, updated_at + FROM artifacts WHERE kind = ? ORDER BY created_at DESC LIMIT ?`, + string(kind), limit) + } if err != nil { return nil, err } @@ -170,6 +182,7 @@ func (s *Store) Search(ctx context.Context, query string, limit int) ([]*Artifac } terms := strings.Fields(query) + var ftsTerms []string for _, t := range terms { // Strip all FTS5 special/operator characters that break queries diff --git a/src/mcp/internal/artifact/store_test.go b/src/mcp/internal/artifact/store_test.go index 6583b32..419f857 100644 --- a/src/mcp/internal/artifact/store_test.go +++ b/src/mcp/internal/artifact/store_test.go @@ -25,7 +25,8 @@ func setupTestDB(t *testing.T) *Store { id TEXT PRIMARY KEY, kind TEXT NOT NULL, version INTEGER NOT NULL DEFAULT 1, status TEXT NOT NULL DEFAULT 'active', context TEXT, mode TEXT, title TEXT NOT NULL, content TEXT NOT NULL, file_path TEXT, - valid_until TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL)`, + valid_until TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, + search_keywords TEXT DEFAULT '')`, `CREATE TABLE artifact_links ( source_id TEXT NOT NULL, target_id TEXT NOT NULL, link_type TEXT NOT NULL, created_at TEXT NOT NULL, PRIMARY KEY (source_id, target_id, link_type))`, @@ -37,13 +38,13 @@ func setupTestDB(t *testing.T) *Store { `CREATE TABLE affected_files ( artifact_id TEXT NOT NULL, file_path TEXT NOT NULL, file_hash TEXT, PRIMARY KEY (artifact_id, file_path))`, - `CREATE VIRTUAL TABLE artifacts_fts USING fts5(id, title, content, kind, tokenize='porter unicode61')`, + `CREATE VIRTUAL TABLE artifacts_fts USING fts5(id, title, content, kind, search_keywords, tokenize='porter unicode61')`, `CREATE TRIGGER artifacts_fts_insert AFTER INSERT ON artifacts BEGIN - INSERT INTO artifacts_fts(id, title, content, kind) VALUES (new.id, new.title, new.content, new.kind); + INSERT INTO artifacts_fts(id, title, content, kind, search_keywords) VALUES (new.id, new.title, new.content, new.kind, new.search_keywords); END`, `CREATE TRIGGER artifacts_fts_update AFTER UPDATE ON artifacts BEGIN DELETE FROM artifacts_fts WHERE id = old.id; - INSERT INTO artifacts_fts(id, title, content, kind) VALUES (new.id, new.title, new.content, new.kind); + INSERT INTO artifacts_fts(id, title, content, kind, search_keywords) VALUES (new.id, new.title, new.content, new.kind, new.search_keywords); END`, `CREATE TRIGGER artifacts_fts_delete AFTER DELETE ON artifacts BEGIN DELETE FROM artifacts_fts WHERE id = old.id; @@ -166,6 +167,40 @@ func TestSearch(t *testing.T) { } } +func TestSearch_KeywordsEnrichment(t *testing.T) { + store := setupTestDB(t) + ctx := context.Background() + + // Create an artifact about Redis session cache, with search keywords + // that include "caching" and "in-memory" — terms NOT in the title or body + store.Create(ctx, &Artifact{ + Meta: Meta{ID: "dec-002", Kind: KindDecisionRecord, Title: "Redis for session store"}, + Body: "Selected Redis for session persistence with 15min TTL", + SearchKeywords: "cache caching in-memory key-value nosql session store redis", + }) + + // Search for "caching strategy" — no match in title/body, but matches keywords + results, err := store.Search(ctx, "caching strategy", 10) + if err != nil { + t.Fatal(err) + } + if len(results) == 0 { + t.Fatal("expected search results via keyword enrichment — 'caching' should match search_keywords") + } + if results[0].Meta.ID != "dec-002" { + t.Errorf("expected dec-002, got %s", results[0].Meta.ID) + } + + // Search for "nosql" — only in keywords, not in title or body + results2, err := store.Search(ctx, "nosql", 10) + if err != nil { + t.Fatal(err) + } + if len(results2) == 0 { + t.Fatal("expected search results for 'nosql' via keywords") + } +} + func TestLinks(t *testing.T) { store := setupTestDB(t) ctx := context.Background() diff --git a/src/mcp/internal/artifact/types.go b/src/mcp/internal/artifact/types.go index 9a84d8e..55dc90e 100644 --- a/src/mcp/internal/artifact/types.go +++ b/src/mcp/internal/artifact/types.go @@ -113,8 +113,9 @@ type Meta struct { // Artifact holds metadata + markdown body for any artifact type. type Artifact struct { - Meta Meta `yaml:"meta" json:"meta"` - Body string `yaml:"-" json:"body"` // markdown content after frontmatter + Meta Meta `yaml:"meta" json:"meta"` + Body string `yaml:"-" json:"body"` // markdown content after frontmatter + SearchKeywords string `yaml:"-" json:"search_keywords"` // agent-generated synonyms/related terms for FTS5 } // GenerateID creates a deterministic artifact ID. diff --git a/src/mcp/internal/codebase/c_cpp.go b/src/mcp/internal/codebase/c_cpp.go index c1d5c9a..fa486e8 100644 --- a/src/mcp/internal/codebase/c_cpp.go +++ b/src/mcp/internal/codebase/c_cpp.go @@ -105,6 +105,31 @@ func (c *CCppLang) detectFromCompileCommands(ccjPath, projectRoot string) ([]Mod dirFiles[dir]++ } + // Also register -I include directories as modules so dependency edges + // targeting header-only directories (e.g. include/) are not dropped. + includeDirs := make(map[string]bool) + for _, cmd := range commands { + for _, incDir := range parseIncludeFlags(cmd) { + absInc, err := filepath.Abs(incDir) + if err != nil { + continue + } + if resolved, err := filepath.EvalSymlinks(absInc); err == nil { + absInc = resolved + } + rel, err := filepath.Rel(canonicalRoot, absInc) + if err != nil || strings.HasPrefix(rel, "..") { + continue + } + if rel == "." { + rel = "" + } + if _, exists := dirFiles[rel]; !exists { + includeDirs[rel] = true + } + } + } + var modules []Module for dir, count := range dirFiles { name := filepath.Base(dir) @@ -122,6 +147,24 @@ func (c *CCppLang) detectFromCompileCommands(ccjPath, projectRoot string) ([]Mod }) } + // Header-only modules get FileCount=0 — they contain headers but + // no translation units listed in compile_commands.json. + for dir := range includeDirs { + name := filepath.Base(dir) + id := moduleID(dir) + if dir == "" { + name = filepath.Base(projectRoot) + id = "mod-root" + } + modules = append(modules, Module{ + ID: id, + Path: dir, + Name: name, + Lang: "c_cpp", + FileCount: 0, + }) + } + // If all files ended up in root, that's fine -- single module project return modules, nil } @@ -265,9 +308,17 @@ func (c *CCppLang) ParseImports(filePath string, projectRoot string) ([]ImportEd // resolveInclude tries to find which directory an included file belongs to. // It checks: relative to source, then each -I path from compile_commands.json. func (c *CCppLang) resolveInclude(includePath, sourceDir, projectRoot string, includePaths []string) string { + // Canonicalize projectRoot so -I paths (which may be symlink-resolved) + // produce valid relative paths on macOS (/tmp → /private/tmp). + canonicalRoot := projectRoot + if resolved, err := filepath.EvalSymlinks(projectRoot); err == nil { + canonicalRoot = resolved + } + canonicalRoot, _ = filepath.Abs(canonicalRoot) + // 1. Relative to source file's directory candidate := filepath.Join(sourceDir, includePath) - candidateAbs := filepath.Join(projectRoot, candidate) + candidateAbs := filepath.Join(canonicalRoot, candidate) if _, err := os.Stat(candidateAbs); err == nil { dir := filepath.Dir(candidate) if dir == "." { @@ -278,18 +329,21 @@ func (c *CCppLang) resolveInclude(includePath, sourceDir, projectRoot string, in // 2. Relative to each include path from compile_commands.json for _, incDir := range includePaths { - // Make include path relative to project root - relInc := incDir - if filepath.IsAbs(incDir) { - var err error - relInc, err = filepath.Rel(projectRoot, incDir) - if err != nil || strings.HasPrefix(relInc, "..") { - continue - } + // Canonicalize -I path too before computing relative + absInc, err := filepath.Abs(incDir) + if err != nil { + continue + } + if resolved, err := filepath.EvalSymlinks(absInc); err == nil { + absInc = resolved + } + relInc, err := filepath.Rel(canonicalRoot, absInc) + if err != nil || strings.HasPrefix(relInc, "..") { + continue } candidate := filepath.Join(relInc, includePath) - candidateAbs := filepath.Join(projectRoot, candidate) + candidateAbs := filepath.Join(canonicalRoot, candidate) if _, err := os.Stat(candidateAbs); err == nil { dir := filepath.Dir(candidate) if dir == "." { @@ -301,7 +355,7 @@ func (c *CCppLang) resolveInclude(includePath, sourceDir, projectRoot string, in // 3. Try from project root candidate = includePath - candidateAbs = filepath.Join(projectRoot, candidate) + candidateAbs = filepath.Join(canonicalRoot, candidate) if _, err := os.Stat(candidateAbs); err == nil { dir := filepath.Dir(candidate) if dir == "." { diff --git a/src/mcp/internal/codebase/codebase_test.go b/src/mcp/internal/codebase/codebase_test.go index 9305483..3b8965f 100644 --- a/src/mcp/internal/codebase/codebase_test.go +++ b/src/mcp/internal/codebase/codebase_test.go @@ -320,11 +320,12 @@ func TestCCppDetectModulesWithCompileCommands(t *testing.T) { t.Fatal(err) } - if len(modules) < 2 { - t.Fatalf("expected >=2 modules (src, src/net), got %d: %v", len(modules), moduleNames(modules)) + if len(modules) < 3 { + t.Fatalf("expected >=3 modules (src, src/net, include), got %d: %v", len(modules), moduleNames(modules)) } // Check src/net module has 2 files + foundInclude := false for _, m := range modules { if m.Path == "src/net" { if m.FileCount != 2 { @@ -334,6 +335,15 @@ func TestCCppDetectModulesWithCompileCommands(t *testing.T) { t.Errorf("src/net module: expected lang=c_cpp, got %s", m.Lang) } } + if m.Path == "include" { + foundInclude = true + if m.FileCount != 0 { + t.Errorf("include module: expected 0 files (header-only), got %d", m.FileCount) + } + } + } + if !foundInclude { + t.Errorf("expected include/ directory to be registered as module from -I flags, got: %v", moduleNames(modules)) } } @@ -472,6 +482,82 @@ func TestCCppParseImportsRelative(t *testing.T) { } } +func TestCCppResolveIncludeWithSymlinks(t *testing.T) { + // Create a real project directory + realDir := t.TempDir() + writeFile(t, realDir, "Makefile", "all: myapp\n") + writeFile(t, realDir, "src/main.c", `#include "utils.h"`+"\n") + writeFile(t, realDir, "include/utils.h", "void helper();\n") + + // -I flags use the real path + ccj := `[ + {"directory": "` + realDir + `", "command": "gcc -I` + realDir + `/include -c src/main.c", "file": "src/main.c"} + ]` + writeFile(t, realDir, "compile_commands.json", ccj) + + // Create a symlink to the project — simulates macOS /tmp → /private/tmp + symlinkDir := filepath.Join(t.TempDir(), "linked-project") + if err := os.Symlink(realDir, symlinkDir); err != nil { + t.Skip("symlinks not supported on this OS") + } + + // Parse imports using the SYMLINK path (user's working dir) + // but -I flags point to the REAL path + parser := &CCppLang{} + edges, err := parser.ParseImports(filepath.Join(symlinkDir, "src/main.c"), symlinkDir) + if err != nil { + t.Fatal(err) + } + + if len(edges) == 0 { + t.Fatal("expected include edge via -I flag through symlink, got 0 edges") + } + if edges[0].TargetModule != "mod-include" { + t.Errorf("expected target mod-include, got %s", edges[0].TargetModule) + } +} + +func TestCCppHeaderOnlyModulesFromIncludeFlags(t *testing.T) { + root := t.TempDir() + + // src/ has source files, include/ and third_party/lib/ have only headers + writeFile(t, root, "src/main.c", "int main() { return 0; }\n") + writeFile(t, root, "include/api.h", "void api();\n") + writeFile(t, root, "third_party/lib/vendor.h", "void vendor();\n") + + ccj := `[ + {"directory": "` + root + `", "command": "gcc -I` + root + `/include -I` + root + `/third_party/lib -c src/main.c", "file": "src/main.c"} + ]` + writeFile(t, root, "compile_commands.json", ccj) + + detector := &CCppLang{} + modules, err := detector.DetectModules(root) + if err != nil { + t.Fatal(err) + } + + paths := make(map[string]int) + for _, m := range modules { + paths[m.Path] = m.FileCount + } + + // src should be a source module + if _, ok := paths["src"]; !ok { + t.Errorf("expected src module, got: %v", moduleNames(modules)) + } + // include and third_party/lib should be header-only modules (FileCount=0) + if fc, ok := paths["include"]; !ok { + t.Errorf("expected include module from -I flags, got: %v", moduleNames(modules)) + } else if fc != 0 { + t.Errorf("include module should have FileCount=0 (header-only), got %d", fc) + } + if fc, ok := paths["third_party/lib"]; !ok { + t.Errorf("expected third_party/lib module from -I flags, got: %v", moduleNames(modules)) + } else if fc != 0 { + t.Errorf("third_party/lib module should have FileCount=0, got %d", fc) + } +} + // --- Scanner Integration Tests --- func TestScannerFullPipeline(t *testing.T) { diff --git a/src/mcp/internal/codebase/registry.go b/src/mcp/internal/codebase/registry.go index 9f5d98e..878ab3c 100644 --- a/src/mcp/internal/codebase/registry.go +++ b/src/mcp/internal/codebase/registry.go @@ -150,7 +150,7 @@ func GetIgnoreChecker(projectRoot string) *IgnoreChecker { func IsExcludedDir(name string) bool { // Minimal hardcoded set — only things that should NEVER be scanned switch name { - case ".git", ".quint": + case ".git", ".quint", ".claude", ".context": return true } return false diff --git a/src/mcp/internal/fpf/server.go b/src/mcp/internal/fpf/server.go index d074287..ebf1fcd 100644 --- a/src/mcp/internal/fpf/server.go +++ b/src/mcp/internal/fpf/server.go @@ -189,6 +189,10 @@ func (s *Server) handleToolsList(req JSONRPCRequest) { "type": "string", "description": "Supporting evidence (benchmarks, test results, references)", }, + "search_keywords": map[string]string{ + "type": "string", + "description": "Space-separated synonyms and related terms for search enrichment (e.g., 'redis cache caching in-memory key-value nosql')", + }, "context": map[string]string{ "type": "string", "description": "Optional context name for grouping (e.g., 'auth', 'payments')", @@ -440,6 +444,10 @@ func (s *Server) handleToolsList(req JSONRPCRequest) { "type": "array", "items": map[string]string{"type": "string"}, "description": "(decide/baseline) Files affected by this decision. For baseline: optional — if provided, replaces the file list before snapshotting.", }, + "search_keywords": map[string]string{ + "type": "string", + "description": "(decide) Space-separated synonyms and related terms for search enrichment", + }, "findings": map[string]string{ "type": "string", "description": "(measure) What actually happened after implementation", }, diff --git a/src/mcp/internal/ui/data.go b/src/mcp/internal/ui/data.go new file mode 100644 index 0000000..fb8a62e --- /dev/null +++ b/src/mcp/internal/ui/data.go @@ -0,0 +1,336 @@ +// Package ui implements the interactive terminal dashboard for quint-code. +// Read-only — never writes to DB. +package ui + +import ( + "context" + "database/sql" + "fmt" + "time" + + "github.com/m0n0x41d/quint-code/internal/artifact" + "github.com/m0n0x41d/quint-code/internal/codebase" + "github.com/m0n0x41d/quint-code/internal/reff" +) + +// BoardData holds all data needed by the dashboard, loaded once at startup. +type BoardData struct { + ProjectName string + ProjectRoot string + + // Decisions + Decisions []*artifact.Artifact + ShippedCount int + PendingCount int + DecisionREff map[string]float64 // decision ID → R_eff + DecisionDrift map[string]string // decision ID → "clean"/"drift"/"no baseline" + + // Problems + BacklogProblems []*artifact.Artifact + InProgressProblems []*artifact.Artifact + AddressedCount int + + // Notes + RecentNotes []*artifact.Artifact + + // Modules + CoverageReport *codebase.CoverageReport + + // Health + StaleItems []artifact.StaleItem + CriticalCount int // exit code = 1 if > 0 + + // Activity + RecentActivity []ActivityItem + + // Expiring + ExpiringSoon []ExpiringItem + + // Depth distribution + TacticalCount int + StandardCount int + DeepCount int + + // Evidence stats + EvidenceTotal int + EvidenceAvgAge int // days + EvidenceOldest int // days + EvidenceExpired int + + // Context groups + ContextGroups map[string]int // context name → decision count +} + +// ActivityItem is a recent event for the timeline. +type ActivityItem struct { + Date time.Time + Kind string + Title string + ID string +} + +// ExpiringItem is a decision expiring within 30 days. +type ExpiringItem struct { + ID string + Title string + ExpiresIn int // days +} + +// LoadBoardData populates all dashboard data from the Store. +func LoadBoardData(store *artifact.Store, db *sql.DB, projectName, projectRoot string) (*BoardData, error) { + ctx := context.Background() + data := &BoardData{ + ProjectName: projectName, + ProjectRoot: projectRoot, + DecisionREff: make(map[string]float64), + DecisionDrift: make(map[string]string), + ContextGroups: make(map[string]int), + } + + // Decisions + decisions, err := store.ListByKind(ctx, artifact.KindDecisionRecord, 100) + if err != nil { + return nil, fmt.Errorf("load decisions: %w", err) + } + data.Decisions = filterActive(decisions) + + for _, d := range data.Decisions { + if hasMeasurement(ctx, store, d.Meta.ID) { + data.ShippedCount++ + } else { + data.PendingCount++ + } + + // R_eff + data.DecisionREff[d.Meta.ID] = computeREff(ctx, store, d.Meta.ID) + + // Context groups + ctx_ := d.Meta.Context + if ctx_ == "" { + ctx_ = "(no context)" + } + data.ContextGroups[ctx_]++ + + // Depth distribution + switch d.Meta.Mode { + case artifact.ModeTactical: + data.TacticalCount++ + case artifact.ModeStandard: + data.StandardCount++ + case artifact.ModeDeep: + data.DeepCount++ + } + } + + // Drift status + driftReports, _ := artifact.CheckDrift(ctx, store, projectRoot) + for _, r := range driftReports { + if !r.HasBaseline { + data.DecisionDrift[r.DecisionID] = "no baseline" + continue + } + hasDrift := false + for _, f := range r.Files { + if f.Status == artifact.DriftModified || f.Status == artifact.DriftMissing { + hasDrift = true + break + } + } + if hasDrift { + data.DecisionDrift[r.DecisionID] = "drift" + } else { + data.DecisionDrift[r.DecisionID] = "clean" + } + } + + // Problems + problems, _ := store.ListByKind(ctx, artifact.KindProblemCard, 100) + for _, p := range problems { + if p.Meta.Status != artifact.StatusActive { + continue + } + hasPortfolio := hasLinkedKind(ctx, store, p.Meta.ID, artifact.KindSolutionPortfolio) + hasDecision := hasLinkedKind(ctx, store, p.Meta.ID, artifact.KindDecisionRecord) + switch { + case hasDecision: + data.AddressedCount++ + case hasPortfolio: + data.InProgressProblems = append(data.InProgressProblems, p) + default: + data.BacklogProblems = append(data.BacklogProblems, p) + } + } + + // Notes + notes, _ := store.ListByKind(ctx, artifact.KindNote, 5) + data.RecentNotes = filterActive(notes) + + // Stale items + data.StaleItems, _ = artifact.ScanStale(ctx, store, projectRoot) + for _, item := range data.StaleItems { + if item.DaysStale > 30 { + data.CriticalCount++ + } + } + + // Check R_eff < 0.3 + for _, r := range data.DecisionREff { + if r > 0 && r < 0.3 { + data.CriticalCount++ + } + } + + // Coverage + scanner := codebase.NewScanner(db) + if !scanner.ModulesLastScanned(ctx).IsZero() { + report, err := codebase.ComputeCoverage(ctx, db) + if err == nil && report.TotalModules > 0 { + data.CoverageReport = report + } + } + + // Expiring soon (within 30 days) + now := time.Now() + for _, d := range data.Decisions { + if d.Meta.ValidUntil == "" { + continue + } + exp, err := time.Parse(time.RFC3339, d.Meta.ValidUntil) + if err != nil { + continue + } + daysLeft := int(exp.Sub(now).Hours() / 24) + if daysLeft > 0 && daysLeft <= 30 { + data.ExpiringSoon = append(data.ExpiringSoon, ExpiringItem{ + ID: d.Meta.ID, + Title: d.Meta.Title, + ExpiresIn: daysLeft, + }) + } + } + + // Activity (last 7 days) + sevenDaysAgo := now.Add(-7 * 24 * time.Hour) + allArtifacts, _ := store.ListByKind(ctx, "", 50) // all kinds + for _, a := range allArtifacts { + if a.Meta.CreatedAt.After(sevenDaysAgo) { + data.RecentActivity = append(data.RecentActivity, ActivityItem{ + Date: a.Meta.CreatedAt, + Kind: string(a.Meta.Kind), + Title: a.Meta.Title, + ID: a.Meta.ID, + }) + } + } + + // Evidence stats + loadEvidenceStats(ctx, db, data) + + return data, nil +} + +func filterActive(artifacts []*artifact.Artifact) []*artifact.Artifact { + var result []*artifact.Artifact + for _, a := range artifacts { + if a.Meta.Status == artifact.StatusActive || a.Meta.Status == artifact.StatusRefreshDue { + result = append(result, a) + } + } + return result +} + +func hasMeasurement(ctx context.Context, store *artifact.Store, decisionID string) bool { + evidence, err := store.GetEvidenceItems(ctx, decisionID) + if err != nil { + return false + } + for _, e := range evidence { + if e.Type == "measurement" && e.Verdict != "superseded" { + return true + } + } + return false +} + +func computeREff(ctx context.Context, store *artifact.Store, artifactID string) float64 { + evidence, err := store.GetEvidenceItems(ctx, artifactID) + if err != nil || len(evidence) == 0 { + return 0 + } + now := time.Now() + minScore := 1.0 + hasActive := false + for _, e := range evidence { + if e.Verdict == "superseded" { + continue + } + score := reff.ScoreEvidence(e.Verdict, e.CongruenceLevel, e.ValidUntil, now) + if score < minScore { + minScore = score + } + hasActive = true + } + if !hasActive { + return 0 + } + return minScore +} + +func hasLinkedKind(ctx context.Context, store *artifact.Store, artifactID string, kind artifact.Kind) bool { + backlinks, err := store.GetBacklinks(ctx, artifactID) + if err != nil { + return false + } + for _, link := range backlinks { + a, err := store.Get(ctx, link.Ref) + if err != nil { + continue + } + if a.Meta.Kind == kind && (a.Meta.Status == artifact.StatusActive || a.Meta.Status == artifact.StatusRefreshDue) { + return true + } + } + return false +} + +func loadEvidenceStats(ctx context.Context, db *sql.DB, data *BoardData) { + now := time.Now() + + rows, err := db.QueryContext(ctx, `SELECT created_at, valid_until, verdict FROM evidence_items WHERE verdict != 'superseded'`) + if err != nil { + return + } + defer rows.Close() + + var totalAge float64 + oldest := 0 + for rows.Next() { + var createdAt, validUntil, verdict string + if rows.Scan(&createdAt, &validUntil, &verdict) != nil { + continue + } + data.EvidenceTotal++ + + created, err := time.Parse(time.RFC3339, createdAt) + if err != nil { + continue + } + age := int(now.Sub(created).Hours() / 24) + totalAge += float64(age) + if age > oldest { + oldest = age + } + + if validUntil != "" { + exp, err := time.Parse(time.RFC3339, validUntil) + if err == nil && exp.Before(now) { + data.EvidenceExpired++ + } + } + } + + data.EvidenceOldest = oldest + if data.EvidenceTotal > 0 { + data.EvidenceAvgAge = int(totalAge / float64(data.EvidenceTotal)) + } +} diff --git a/src/mcp/internal/ui/decisions.go b/src/mcp/internal/ui/decisions.go new file mode 100644 index 0000000..a1f83b7 --- /dev/null +++ b/src/mcp/internal/ui/decisions.go @@ -0,0 +1,181 @@ +package ui + +import ( + "fmt" + "strings" + + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + "github.com/charmbracelet/x/ansi" +) + +// DecisionsView shows all decisions with R_eff, status, and drill-in. +type DecisionsView struct { + data *BoardData + cursor int + detail bool + scroll int +} + +func NewDecisionsView(data *BoardData) *DecisionsView { + return &DecisionsView{data: data} +} + +func (v *DecisionsView) UpdateData(data *BoardData) { v.data = data } + +func (v *DecisionsView) Title() string { + return fmt.Sprintf("Decisions (%d)", len(v.data.Decisions)) +} + +func (v *DecisionsView) HelpKeys() []HelpItem { + if v.detail { + return []HelpItem{{"j/k", "scroll"}, {"esc", "back"}} + } + if len(v.data.Decisions) == 0 { + return nil + } + return []HelpItem{{"j/k", "navigate"}, {"enter", "detail"}} +} + +func (v *DecisionsView) HandleKey(msg tea.KeyMsg) bool { + if v.detail { + switch msg.String() { + case "esc", "backspace": + v.detail = false + v.scroll = 0 + return true + case "j", "down": + v.scroll++ + return true + case "k", "up": + if v.scroll > 0 { + v.scroll-- + } + return true + } + return false + } + + switch msg.String() { + case "j", "down": + if v.cursor < len(v.data.Decisions)-1 { + v.cursor++ + } + return true + case "k", "up": + if v.cursor > 0 { + v.cursor-- + } + return true + case "g": + v.cursor = 0 + return true + case "G": + if len(v.data.Decisions) > 0 { + v.cursor = len(v.data.Decisions) - 1 + } + return true + case "enter": + if len(v.data.Decisions) > 0 { + v.detail = true + } + return true + } + return false +} + +// padRight pads a styled string to a fixed visible width using spaces. +// Unlike fmt %-*s, this correctly handles ANSI escape codes. +func padRight(s string, width int) string { + visible := ansi.StringWidth(s) + if visible >= width { + return s + } + return s + strings.Repeat(" ", width-visible) +} + +func (v *DecisionsView) Render(width, height int, s Styles) string { + if len(v.data.Decisions) == 0 { + return "\n " + s.DimText.Render("No decisions yet. Use /q-decide to create one.") + "\n" + } + + if v.detail && v.cursor < len(v.data.Decisions) { + return renderArtifactDetail(v.data.Decisions[v.cursor], width, height, v.scroll, s) + } + + // Fixed column widths + const ( + colStatus = 3 + colREff = 6 + colDrift = 8 + ) + colTitle := width - colStatus - colREff - colDrift - 8 + if colTitle < 20 { + colTitle = 20 + } + + var b strings.Builder + b.WriteString("\n") + + // Header + hdr := lipgloss.NewStyle().Foreground(s.Theme.Dim).Bold(true) + b.WriteString(fmt.Sprintf(" %s%s%s%s\n", + padRight("", colStatus+1), + padRight(hdr.Render("R_eff"), colREff+1), + padRight(hdr.Render("Drift"), colDrift+1), + hdr.Render("Title"))) + + sep := lipgloss.NewStyle().Foreground(s.Theme.Border) + b.WriteString(fmt.Sprintf(" %s\n", sep.Render(strings.Repeat("─", width-4)))) + + for i, d := range v.data.Decisions { + if i >= height-4 { + b.WriteString(fmt.Sprintf(" %s\n", + s.DimText.Render(fmt.Sprintf("... +%d more", len(v.data.Decisions)-i)))) + break + } + + // Build each column as a styled string, then pad to fixed width + reff := v.data.DecisionREff[d.Meta.ID] + + statusStr := s.Warning.Render("⏳") + if reff > 0 { + statusStr = s.OK.Render("✓") + } + + reffStr := s.DimText.Render("—") + if reff > 0 { + reffStr = s.REffStyle(reff).Render(fmt.Sprintf("%.2f", reff)) + } + + drift := v.data.DecisionDrift[d.Meta.ID] + driftStr := s.DimText.Render("—") + switch drift { + case "clean": + driftStr = s.OK.Render("clean") + case "drift": + driftStr = s.Error.Render("DRIFT") + case "no baseline": + driftStr = s.Warning.Render("no bl") + } + + title := truncate(d.Meta.Title, colTitle) + + line := fmt.Sprintf(" %s %s %s %s", + padRight(statusStr, colStatus), + padRight(reffStr, colREff), + padRight(driftStr, colDrift), + title) + + if i == v.cursor { + b.WriteString(s.SelectedItem.Width(width - 2).Render(line)) + } else if i%2 == 0 { + b.WriteString(s.DimRow.Render(line)) + } else { + b.WriteString(line) + } + b.WriteString("\n") + } + + return b.String() +} diff --git a/src/mcp/internal/ui/model.go b/src/mcp/internal/ui/model.go new file mode 100644 index 0000000..dd6de4f --- /dev/null +++ b/src/mcp/internal/ui/model.go @@ -0,0 +1,307 @@ +package ui + +import ( + "database/sql" + "fmt" + "strings" + "time" + + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + + "github.com/m0n0x41d/quint-code/internal/artifact" +) + +// Tab identifies a dashboard view. +type Tab int + +const ( + TabOverview Tab = iota + TabProblems + TabDecisions + TabModules +) + +var tabNames = []string{"Overview", "Problems", "Decisions", "Modules"} + +const refreshInterval = 3 * time.Second + +// HelpItem is a key binding shown in the help bar. +type HelpItem struct { + Key string + Desc string +} + +// View renders a single tab's content. +type View interface { + Render(width, height int, styles Styles) string + HandleKey(msg tea.KeyMsg) bool + Title() string + HelpKeys() []HelpItem + UpdateData(data *BoardData) +} + +// refreshMsg triggers a data reload from DB. +type refreshMsg struct{} + +// Model is the top-level bubbletea model for the dashboard. +type Model struct { + data *BoardData + styles Styles + views map[Tab]View + active Tab + width int + height int + ready bool + + // DB connection for live refresh + store *artifact.Store + db *sql.DB + projectName string + projectRoot string +} + +// New creates a new dashboard model with live refresh capability. +func New(data *BoardData, store *artifact.Store, db *sql.DB, projectName, projectRoot string) Model { + m := Model{ + data: data, + active: TabOverview, + views: make(map[Tab]View), + store: store, + db: db, + projectName: projectName, + projectRoot: projectRoot, + } + m.views[TabOverview] = NewOverviewView(data) + m.views[TabProblems] = NewProblemsView(data) + m.views[TabDecisions] = NewDecisionsView(data) + m.views[TabModules] = NewModulesView(data) + return m +} + +// CriticalCount returns the number of critical health issues. +func (m Model) CriticalCount() int { + return m.data.CriticalCount +} + +func (m Model) Init() tea.Cmd { + return tea.Tick(refreshInterval, func(time.Time) tea.Msg { + return refreshMsg{} + }) +} + +func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.WindowSizeMsg: + m.width = msg.Width + m.height = msg.Height + m.styles = NewStyles(m.width) + m.ready = true + return m, nil + + case refreshMsg: + m.reload() + // Schedule next tick + return m, tea.Tick(refreshInterval, func(time.Time) tea.Msg { + return refreshMsg{} + }) + + case tea.KeyMsg: + switch msg.String() { + case "q", "ctrl+c": + return m, tea.Quit + case "tab": + m.active = (m.active + 1) % Tab(len(tabNames)) + m.reload() + return m, nil + case "shift+tab": + m.active = (m.active - 1 + Tab(len(tabNames))) % Tab(len(tabNames)) + m.reload() + return m, nil + case "1": + m.active = TabOverview + m.reload() + return m, nil + case "2": + m.active = TabProblems + m.reload() + return m, nil + case "3": + m.active = TabDecisions + m.reload() + return m, nil + case "4": + m.active = TabModules + m.reload() + return m, nil + } + + if view, ok := m.views[m.active]; ok { + view.HandleKey(msg) + } + } + + return m, nil +} + +// reload refreshes data from DB without resetting view state (cursor, detail mode). +func (m *Model) reload() { + data, err := LoadBoardData(m.store, m.db, m.projectName, m.projectRoot) + if err != nil { + return + } + m.data = data + for _, v := range m.views { + v.UpdateData(data) + } +} + +func (m Model) View() tea.View { + if !m.ready { + return tea.NewView("Loading...") + } + + header := m.renderHeader() + tabBar := m.renderTabBar() + helpBar := m.renderHelp() + + headerH := lipgloss.Height(header) + tabBarH := lipgloss.Height(tabBar) + helpH := lipgloss.Height(helpBar) + contentH := m.height - headerH - tabBarH - helpH + + content := "" + if view, ok := m.views[m.active]; ok { + content = view.Render(m.width-2, contentH, m.styles) + } + + // Pad content to push help to bottom + contentLines := lipgloss.Height(content) + if contentLines < contentH { + content += strings.Repeat("\n", contentH-contentLines) + } + + result := header + "\n" + tabBar + "\n" + content + helpBar + + v := tea.NewView(result) + v.AltScreen = true + return v +} + +func (m Model) renderHeader() string { + t := m.styles.Theme + + projectStyle := lipgloss.NewStyle().Bold(true).Foreground(t.Cyan) + statsStyle := lipgloss.NewStyle().Foreground(t.Dim) + + decCount := len(m.data.Decisions) + modCount := 0 + covPct := 0 + if m.data.CoverageReport != nil { + modCount = m.data.CoverageReport.TotalModules + if modCount > 0 { + covPct = (m.data.CoverageReport.CoveredCount + m.data.CoverageReport.PartialCount) * 100 / modCount + } + } + + health := m.styles.OK.Render("healthy") + if m.data.CriticalCount > 0 { + health = m.styles.Error.Render(fmt.Sprintf("%d critical", m.data.CriticalCount)) + } + + header := fmt.Sprintf(" %s %s %s", + projectStyle.Render(m.data.ProjectName), + statsStyle.Render(fmt.Sprintf("%d decisions · %d modules · %d%% governed", + decCount, modCount, covPct)), + health, + ) + + return m.styles.Header.Render(header) +} + +func (m Model) renderTabBar() string { + var tabs []string + + for i, name := range tabNames { + title := name + if view, ok := m.views[Tab(i)]; ok { + title = view.Title() + } + label := fmt.Sprintf(" %d %s ", i+1, title) + + isActive := Tab(i) == m.active + isFirst := i == 0 + isLast := i == len(tabNames)-1 + + border := lipgloss.RoundedBorder() + if isActive { + border.Bottom = " " + border.BottomLeft = "│" + border.BottomRight = "│" + } else { + border.Bottom = "─" + border.BottomLeft = "┴" + border.BottomRight = "┴" + } + if isFirst && isActive { + border.BottomLeft = "│" + } else if isFirst { + border.BottomLeft = "└" + } + if isLast && isActive { + border.BottomRight = "│" + } else if isLast { + border.BottomRight = "┘" + } + + style := lipgloss.NewStyle(). + Border(border). + BorderForeground(m.styles.Theme.Border). + Padding(0, 1) + + if isActive { + style = style.Bold(true).Foreground(m.styles.Theme.Bold) + } else { + style = style.Foreground(m.styles.Theme.Dim) + } + + tabs = append(tabs, style.Render(label)) + } + + row := lipgloss.JoinHorizontal(lipgloss.Bottom, tabs...) + + // Fill remaining width with bottom border + rowWidth := lipgloss.Width(row) + if rowWidth < m.width { + gap := lipgloss.NewStyle(). + BorderBottom(true). + BorderStyle(lipgloss.NormalBorder()). + BorderForeground(m.styles.Theme.Border). + Width(m.width - rowWidth). + Render("") + row = lipgloss.JoinHorizontal(lipgloss.Bottom, row, gap) + } + + return row +} + +func (m Model) renderHelp() string { + s := m.styles + + items := []HelpItem{ + {"tab", "switch"}, + {"1-4", "jump"}, + } + + if view, ok := m.views[m.active]; ok { + items = append(items, view.HelpKeys()...) + } + + items = append(items, HelpItem{"q", "quit"}) + + var parts []string + for _, item := range items { + parts = append(parts, fmt.Sprintf("%s %s", s.HelpKey.Render(item.Key), item.Desc)) + } + return s.HelpBar.Render(" " + strings.Join(parts, " · ")) +} diff --git a/src/mcp/internal/ui/modules.go b/src/mcp/internal/ui/modules.go new file mode 100644 index 0000000..d137f67 --- /dev/null +++ b/src/mcp/internal/ui/modules.go @@ -0,0 +1,150 @@ +package ui + +import ( + "fmt" + "strings" + + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" +) + +// ModulesView shows module coverage tree. +type ModulesView struct { + data *BoardData + cursor int +} + +func NewModulesView(data *BoardData) *ModulesView { + return &ModulesView{data: data} +} + +func (v *ModulesView) UpdateData(data *BoardData) { v.data = data } + +func (v *ModulesView) Title() string { + if v.data.CoverageReport == nil { + return "Modules" + } + return fmt.Sprintf("Modules (%d)", v.data.CoverageReport.TotalModules) +} + +func (v *ModulesView) HelpKeys() []HelpItem { + if v.data.CoverageReport == nil || len(v.data.CoverageReport.Modules) == 0 { + return nil + } + return []HelpItem{{"j/k", "navigate"}} +} + +func (v *ModulesView) HandleKey(msg tea.KeyMsg) bool { + if v.data.CoverageReport == nil { + return false + } + count := len(v.data.CoverageReport.Modules) + + switch msg.String() { + case "j", "down": + if v.cursor < count-1 { + v.cursor++ + } + return true + case "k", "up": + if v.cursor > 0 { + v.cursor-- + } + return true + case "g": + v.cursor = 0 + return true + case "G": + if count > 0 { + v.cursor = count - 1 + } + return true + } + return false +} + +func (v *ModulesView) Render(width, height int, s Styles) string { + cr := v.data.CoverageReport + if cr == nil { + return "\n " + s.DimText.Render("No modules scanned. Run /q-refresh or /q-status first.") + "\n" + } + + // Column widths + const ( + colIcon = 3 + colLang = 5 + colDec = 10 + ) + colPath := width - colIcon - colLang - colDec - 10 + if colPath < 20 { + colPath = 20 + } + + var b strings.Builder + b.WriteString("\n") + + // Coverage summary bar + pct := 0 + if cr.TotalModules > 0 { + pct = (cr.CoveredCount + cr.PartialCount) * 100 / cr.TotalModules + } + barW := width - 25 + if barW < 10 { + barW = 10 + } + filled := barW * pct / 100 + bar := s.OK.Render(strings.Repeat("█", filled)) + + s.DimText.Render(strings.Repeat("░", barW-filled)) + b.WriteString(fmt.Sprintf(" %s %s %d%%\n\n", + bar, + s.Label.Render(fmt.Sprintf("%d/%d governed", cr.CoveredCount+cr.PartialCount, cr.TotalModules)), + pct)) + + // Header + hdr := lipgloss.NewStyle().Foreground(s.Theme.Dim).Bold(true) + b.WriteString(fmt.Sprintf(" %s %s %s %s\n", + padRight("", colIcon), + padRight(hdr.Render("Module"), colPath), + padRight(hdr.Render("Lang"), colLang), + hdr.Render("Decisions"))) + + sep := lipgloss.NewStyle().Foreground(s.Theme.Border) + b.WriteString(fmt.Sprintf(" %s\n", sep.Render(strings.Repeat("─", width-4)))) + + for i, mod := range cr.Modules { + if i >= height-6 { + b.WriteString(fmt.Sprintf(" %s\n", + s.DimText.Render(fmt.Sprintf("... +%d more", len(cr.Modules)-i)))) + break + } + + icon := s.OK.Render("✓") + decStr := s.Value.Render(fmt.Sprintf("%d", mod.DecisionCount)) + if mod.DecisionCount == 0 { + icon = s.Error.Render("✗") + decStr = s.Error.Render("blind") + } + + path := mod.Module.Path + if path == "" { + path = "(root)" + } + + line := fmt.Sprintf(" %s %s %s %s", + padRight(icon, colIcon), + padRight(truncate(path, colPath), colPath), + padRight(s.DimText.Render(mod.Module.Lang), colLang), + decStr) + + if i == v.cursor { + b.WriteString(s.SelectedItem.Width(width - 2).Render(line)) + } else if i%2 == 0 { + b.WriteString(s.DimRow.Render(line)) + } else { + b.WriteString(line) + } + b.WriteString("\n") + } + + return b.String() +} diff --git a/src/mcp/internal/ui/overview.go b/src/mcp/internal/ui/overview.go new file mode 100644 index 0000000..b0d1e37 --- /dev/null +++ b/src/mcp/internal/ui/overview.go @@ -0,0 +1,251 @@ +package ui + +import ( + "fmt" + "image/color" + "sort" + "strings" + + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" +) + +// OverviewView renders the health dashboard. +type OverviewView struct { + data *BoardData +} + +func NewOverviewView(data *BoardData) *OverviewView { + return &OverviewView{data: data} +} + +func (v *OverviewView) Title() string { return "Overview" } + +func (v *OverviewView) UpdateData(data *BoardData) { v.data = data } + +func (v *OverviewView) HandleKey(_ tea.KeyMsg) bool { return false } + +func (v *OverviewView) HelpKeys() []HelpItem { return nil } + +func (v *OverviewView) Render(width, height int, s Styles) string { + halfW := width/2 - 2 + + // Left column + left := v.renderHealth(s) + "\n" + + v.renderExpiring(s) + "\n" + + v.renderContexts(s, halfW) + "\n" + + v.renderEvidence(s) + + // Right column + right := v.renderActivity(s) + "\n" + + v.renderDepth(s, halfW) + "\n" + + v.renderCoverage(s, halfW) + + leftBlock := lipgloss.NewStyle().Width(halfW).Render(left) + rightBlock := lipgloss.NewStyle().Width(halfW).Render(right) + + return lipgloss.JoinHorizontal(lipgloss.Top, " "+leftBlock, " "+rightBlock) +} + +func (v *OverviewView) renderHealth(s Styles) string { + d := v.data + var b strings.Builder + + b.WriteString(s.Section.Render("HEALTH")) + b.WriteString("\n") + + if d.ShippedCount > 0 { + b.WriteString(fmt.Sprintf(" %s %d shipped\n", s.OK.Render("✓"), d.ShippedCount)) + } + if d.PendingCount > 0 { + b.WriteString(fmt.Sprintf(" %s %d pending implementation\n", s.Warning.Render("⏳"), d.PendingCount)) + } + if len(d.StaleItems) > 0 { + b.WriteString(fmt.Sprintf(" %s %d stale\n", s.Error.Render("✗"), len(d.StaleItems))) + } + if d.ShippedCount > 0 && d.PendingCount == 0 && len(d.StaleItems) == 0 { + b.WriteString(fmt.Sprintf(" %s\n", s.OK.Render("No issues"))) + } + + // Problems pipeline + b.WriteString(fmt.Sprintf("\n %s %d backlog", + s.Label.Render("Problems:"), len(d.BacklogProblems))) + if len(d.InProgressProblems) > 0 { + b.WriteString(fmt.Sprintf(", %d exploring", len(d.InProgressProblems))) + } + b.WriteString(fmt.Sprintf(", %d addressed\n", d.AddressedCount)) + + return b.String() +} + +func (v *OverviewView) renderExpiring(s Styles) string { + d := v.data + if len(d.ExpiringSoon) == 0 { + return "" + } + + var b strings.Builder + b.WriteString(s.Section.Render("EXPIRING SOON")) + b.WriteString("\n") + for _, e := range d.ExpiringSoon { + style := s.Warning + if e.ExpiresIn <= 7 { + style = s.Error + } + b.WriteString(fmt.Sprintf(" %s %s\n", + style.Render(fmt.Sprintf("%dd", e.ExpiresIn)), + s.DimText.Render(truncate(e.Title, 40)))) + } + return b.String() +} + +func (v *OverviewView) renderContexts(s Styles, width int) string { + d := v.data + if len(d.ContextGroups) == 0 { + return "" + } + + var b strings.Builder + b.WriteString(s.Section.Render("BY CONTEXT")) + b.WriteString("\n") + + // Sort by count descending + type kv struct { + K string + V int + } + var sorted []kv + for k, v := range d.ContextGroups { + sorted = append(sorted, kv{k, v}) + } + sort.SliceStable(sorted, func(i, j int) bool { + if sorted[i].V != sorted[j].V { + return sorted[i].V > sorted[j].V + } + return sorted[i].K < sorted[j].K + }) + + for _, kv := range sorted { + b.WriteString(fmt.Sprintf(" %s %s\n", + s.Value.Render(fmt.Sprintf("%2d", kv.V)), + s.Label.Render(kv.K))) + } + return b.String() +} + +func (v *OverviewView) renderEvidence(s Styles) string { + d := v.data + if d.EvidenceTotal == 0 { + return "" + } + + var b strings.Builder + b.WriteString(s.Section.Render("EVIDENCE")) + b.WriteString("\n") + b.WriteString(fmt.Sprintf(" %s items, avg age %s, oldest %s\n", + s.Value.Render(fmt.Sprintf("%d", d.EvidenceTotal)), + s.Label.Render(fmt.Sprintf("%dd", d.EvidenceAvgAge)), + s.Label.Render(fmt.Sprintf("%dd", d.EvidenceOldest)))) + if d.EvidenceExpired > 0 { + b.WriteString(fmt.Sprintf(" %s\n", s.Error.Render(fmt.Sprintf("%d expired", d.EvidenceExpired)))) + } + return b.String() +} + +func (v *OverviewView) renderActivity(s Styles) string { + d := v.data + + var b strings.Builder + b.WriteString(s.Section.Render("ACTIVITY (7 days)")) + b.WriteString("\n") + + if len(d.RecentActivity) == 0 { + b.WriteString(fmt.Sprintf(" %s\n", s.DimText.Render("No recent activity"))) + return b.String() + } + + // Count by kind + counts := make(map[string]int) + for _, a := range d.RecentActivity { + counts[a.Kind]++ + } + for kind, count := range counts { + b.WriteString(fmt.Sprintf(" %s %s\n", + s.Value.Render(fmt.Sprintf("%d", count)), + s.Label.Render(kind))) + } + return b.String() +} + +func (v *OverviewView) renderDepth(s Styles, width int) string { + d := v.data + + var b strings.Builder + b.WriteString(s.Section.Render("DEPTH")) + b.WriteString("\n") + + total := d.TacticalCount + d.StandardCount + d.DeepCount + if total == 0 { + b.WriteString(fmt.Sprintf(" %s\n", s.DimText.Render("No decisions"))) + return b.String() + } + + barW := width - 20 + if barW < 10 { + barW = 10 + } + + b.WriteString(fmt.Sprintf(" tactical %3d %s\n", d.TacticalCount, renderBar(d.TacticalCount, total, barW, s.OK, s.Theme.Dim))) + b.WriteString(fmt.Sprintf(" standard %3d %s\n", d.StandardCount, renderBar(d.StandardCount, total, barW, s.Subtitle, s.Theme.Dim))) + b.WriteString(fmt.Sprintf(" deep %3d %s\n", d.DeepCount, renderBar(d.DeepCount, total, barW, s.Warning, s.Theme.Dim))) + return b.String() +} + +func (v *OverviewView) renderCoverage(s Styles, width int) string { + d := v.data + if d.CoverageReport == nil { + return "" + } + + cr := d.CoverageReport + var b strings.Builder + b.WriteString(s.Section.Render("MODULE COVERAGE")) + b.WriteString("\n") + + pct := 0 + if cr.TotalModules > 0 { + pct = (cr.CoveredCount + cr.PartialCount) * 100 / cr.TotalModules + } + + barW := width - 15 + if barW < 10 { + barW = 10 + } + filled := barW * pct / 100 + bar := s.OK.Render(strings.Repeat("█", filled)) + + s.DimText.Render(strings.Repeat("░", barW-filled)) + b.WriteString(fmt.Sprintf(" %s %s %d%%\n", bar, + s.Label.Render(fmt.Sprintf("%d/%d", cr.CoveredCount+cr.PartialCount, cr.TotalModules)), + pct)) + + return b.String() +} + +func renderBar(value, total, width int, style lipgloss.Style, dimColor color.Color) string { + if total == 0 { + return "" + } + filled := width * value / total + if filled == 0 && value > 0 { + filled = 1 + } + return style.Render(strings.Repeat("█", filled)) + + lipgloss.NewStyle().Foreground(dimColor).Render(strings.Repeat("░", width-filled)) +} + +func truncate(s string, max int) string { + if len(s) <= max { + return s + } + return s[:max-1] + "…" +} diff --git a/src/mcp/internal/ui/problems.go b/src/mcp/internal/ui/problems.go new file mode 100644 index 0000000..8152113 --- /dev/null +++ b/src/mcp/internal/ui/problems.go @@ -0,0 +1,213 @@ +package ui + +import ( + "fmt" + "strings" + + tea "charm.land/bubbletea/v2" + "charm.land/glamour/v2" + + "github.com/m0n0x41d/quint-code/internal/artifact" +) + +// ProblemsView shows backlog and in-progress problems with drill-in. +type ProblemsView struct { + data *BoardData + cursor int + detail bool + scroll int +} + +func NewProblemsView(data *BoardData) *ProblemsView { + return &ProblemsView{data: data} +} + +func (v *ProblemsView) UpdateData(data *BoardData) { v.data = data } + +func (v *ProblemsView) Title() string { + count := len(v.data.BacklogProblems) + len(v.data.InProgressProblems) + if count == 0 { + return "Problems" + } + return fmt.Sprintf("Problems (%d)", count) +} + +func (v *ProblemsView) HelpKeys() []HelpItem { + if v.detail { + return []HelpItem{{"j/k", "scroll"}, {"esc", "back"}} + } + if len(v.listItems()) == 0 { + return nil + } + return []HelpItem{{"j/k", "navigate"}, {"enter", "detail"}} +} + +func (v *ProblemsView) HandleKey(msg tea.KeyMsg) bool { + if v.detail { + switch msg.String() { + case "esc", "backspace": + v.detail = false + v.scroll = 0 + return true + case "j", "down": + v.scroll++ + return true + case "k", "up": + if v.scroll > 0 { + v.scroll-- + } + return true + } + return false + } + + items := v.listItems() + switch msg.String() { + case "j", "down": + if v.cursor < len(items)-1 { + v.cursor++ + } + return true + case "k", "up": + if v.cursor > 0 { + v.cursor-- + } + return true + case "g": + v.cursor = 0 + return true + case "G": + if len(items) > 0 { + v.cursor = len(items) - 1 + } + return true + case "enter": + if len(items) > 0 { + v.detail = true + } + return true + } + return false +} + +type listEntry struct { + art *artifact.Artifact + status string +} + +func (v *ProblemsView) listItems() []listEntry { + var items []listEntry + for _, p := range v.data.BacklogProblems { + items = append(items, listEntry{art: p, status: "backlog"}) + } + for _, p := range v.data.InProgressProblems { + items = append(items, listEntry{art: p, status: "exploring"}) + } + return items +} + +func (v *ProblemsView) Render(width, height int, s Styles) string { + items := v.listItems() + + if len(items) == 0 { + return "\n " + s.DimText.Render("No active problems. Clean backlog.") + "\n" + } + + if v.detail && v.cursor < len(items) { + return renderArtifactDetail(items[v.cursor].art, width, height, v.scroll, s) + } + + var b strings.Builder + b.WriteString("\n") + + for i, item := range items { + if i >= height-2 { + b.WriteString(fmt.Sprintf(" %s\n", s.DimText.Render(fmt.Sprintf("... +%d more", len(items)-i)))) + break + } + + statusStyle := s.Warning + if item.status == "exploring" { + statusStyle = s.Subtitle + } + + line := fmt.Sprintf(" %s %s %s", + statusStyle.Render(fmt.Sprintf("%-10s", item.status)), + s.DimText.Render(item.art.Meta.ID), + truncate(item.art.Meta.Title, width-35)) + + if i == v.cursor { + b.WriteString(s.SelectedItem.Width(width - 2).Render(line)) + } else if i%2 == 0 { + b.WriteString(s.DimRow.Render(line)) + } else { + b.WriteString(line) + } + b.WriteString("\n") + } + + return b.String() +} + +// renderArtifactDetail renders a single artifact with glamour markdown. +// Shared by problems and decisions views. +func renderArtifactDetail(a *artifact.Artifact, width, height, scroll int, s Styles) string { + var b strings.Builder + + // Header with metadata + b.WriteString("\n") + b.WriteString(fmt.Sprintf(" %s %s\n", + s.Title.Render(a.Meta.Title), + s.DimText.Render(a.Meta.ID))) + + meta := []string{} + if a.Meta.Mode != "" { + meta = append(meta, fmt.Sprintf("mode: %s", a.Meta.Mode)) + } + if a.Meta.Context != "" { + meta = append(meta, fmt.Sprintf("context: %s", a.Meta.Context)) + } + if a.Meta.ValidUntil != "" && len(a.Meta.ValidUntil) >= 10 { + meta = append(meta, fmt.Sprintf("valid until: %s", a.Meta.ValidUntil[:10])) + } + if len(meta) > 0 { + b.WriteString(fmt.Sprintf(" %s\n", s.DimText.Render(strings.Join(meta, " · ")))) + } + b.WriteString("\n") + + // Render body with glamour + renderWidth := width - 6 + if renderWidth < 40 { + renderWidth = 40 + } + + renderer, err := glamour.NewTermRenderer( + glamour.WithEnvironmentConfig(), + glamour.WithWordWrap(renderWidth), + ) + + body := a.Body + if err == nil { + rendered, renderErr := renderer.Render(body) + if renderErr == nil { + body = rendered + } + } + + // Indent each line + for _, line := range strings.Split(strings.TrimRight(body, "\n"), "\n") { + b.WriteString(" " + line + "\n") + } + + b.WriteString(fmt.Sprintf("\n %s\n", s.DimText.Render("esc to go back · j/k to scroll"))) + + // Apply scroll + lines := strings.Split(b.String(), "\n") + if scroll > 0 && scroll < len(lines) { + lines = lines[scroll:] + } + if len(lines) > height { + lines = lines[:height] + } + return strings.Join(lines, "\n") +} diff --git a/src/mcp/internal/ui/styles.go b/src/mcp/internal/ui/styles.go new file mode 100644 index 0000000..f7714ec --- /dev/null +++ b/src/mcp/internal/ui/styles.go @@ -0,0 +1,173 @@ +package ui + +import ( + "image/color" + "os" + + "charm.land/lipgloss/v2" + "github.com/charmbracelet/x/term" +) + +// Theme holds the dashboard color palette, adaptive to terminal background. +type Theme struct { + Green color.Color + Yellow color.Color + Red color.Color + Blue color.Color + Cyan color.Color + Magenta color.Color + Dim color.Color + Text color.Color + Bold color.Color + BgSub color.Color // subtle background for header/selected + Border color.Color +} + +func detectTheme() Theme { + dark := lipgloss.HasDarkBackground(os.Stdin, os.Stdout) + if dark { + return Theme{ + Green: lipgloss.Color("#22c55e"), + Yellow: lipgloss.Color("#eab308"), + Red: lipgloss.Color("#ef4444"), + Blue: lipgloss.Color("#60a5fa"), + Cyan: lipgloss.Color("#22d3ee"), + Magenta: lipgloss.Color("#c084fc"), + Dim: lipgloss.Color("#6b7280"), + Text: lipgloss.Color("#d1d5db"), + Bold: lipgloss.Color("#f3f4f6"), + BgSub: lipgloss.Color("#1f2937"), + Border: lipgloss.Color("#4b5563"), + } + } + return Theme{ + Green: lipgloss.Color("#16a34a"), + Yellow: lipgloss.Color("#ca8a04"), + Red: lipgloss.Color("#dc2626"), + Blue: lipgloss.Color("#2563eb"), + Cyan: lipgloss.Color("#0891b2"), + Magenta: lipgloss.Color("#9333ea"), + Dim: lipgloss.Color("#9ca3af"), + Text: lipgloss.Color("#374151"), + Bold: lipgloss.Color("#111827"), + BgSub: lipgloss.Color("#f3f4f6"), + Border: lipgloss.Color("#d1d5db"), + } +} + +// Styles holds reusable styles for the dashboard. +type Styles struct { + Theme Theme + + // Tabs + ActiveTab lipgloss.Style + InactiveTab lipgloss.Style + + // Content + Title lipgloss.Style + Subtitle lipgloss.Style + Label lipgloss.Style + Value lipgloss.Style + DimText lipgloss.Style + Section lipgloss.Style + + // Status indicators + OK lipgloss.Style + Warning lipgloss.Style + Error lipgloss.Style + + // List items + SelectedItem lipgloss.Style + NormalItem lipgloss.Style + DimRow lipgloss.Style + + // Header / help + Header lipgloss.Style + HelpBar lipgloss.Style + HelpKey lipgloss.Style +} + +// NewStyles creates the style set for a given terminal width. +func NewStyles(width int) Styles { + t := detectTheme() + + return Styles{ + Theme: t, + + ActiveTab: lipgloss.NewStyle(). + Bold(true). + Foreground(t.Bold). + Background(t.BgSub). + Padding(0, 2), + InactiveTab: lipgloss.NewStyle(). + Foreground(t.Dim). + Padding(0, 2), + + Title: lipgloss.NewStyle(). + Bold(true). + Foreground(t.Bold), + Subtitle: lipgloss.NewStyle(). + Foreground(t.Cyan), + Label: lipgloss.NewStyle(). + Foreground(t.Dim), + Value: lipgloss.NewStyle(). + Foreground(t.Text), + DimText: lipgloss.NewStyle(). + Foreground(t.Dim), + Section: lipgloss.NewStyle(). + Bold(true). + Foreground(t.Blue). + MarginTop(1), + + OK: lipgloss.NewStyle(). + Foreground(t.Green), + Warning: lipgloss.NewStyle(). + Foreground(t.Yellow), + Error: lipgloss.NewStyle(). + Foreground(t.Red), + + SelectedItem: lipgloss.NewStyle(). + Foreground(lipgloss.Color("#ffffff")). + Background(lipgloss.Color("#3b82f6")). + Bold(true), + NormalItem: lipgloss.NewStyle(). + Foreground(t.Text), + DimRow: lipgloss.NewStyle(). + Foreground(t.Dim), + + Header: lipgloss.NewStyle(). + Background(t.BgSub). + Foreground(t.Text). + Padding(0, 1). + Width(width). + BorderBottom(true). + BorderStyle(lipgloss.ThickBorder()). + BorderForeground(t.Blue), + + HelpBar: lipgloss.NewStyle(). + Foreground(t.Dim). + Background(t.BgSub). + Width(width). + Padding(0, 1), + HelpKey: lipgloss.NewStyle(). + Foreground(t.Text). + Bold(true), + } +} + +// REffStyle returns the appropriate style for an R_eff value. +func (s Styles) REffStyle(reff float64) lipgloss.Style { + switch { + case reff >= 0.7: + return lipgloss.NewStyle().Foreground(s.Theme.Green) + case reff >= 0.3: + return lipgloss.NewStyle().Foreground(s.Theme.Yellow) + default: + return lipgloss.NewStyle().Foreground(s.Theme.Red) + } +} + +// IsTerminal checks if stdout is a TTY. +func IsTerminal() bool { + return term.IsTerminal(os.Stdout.Fd()) +}