diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e970393b..59147bab 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -4,7 +4,7 @@ on: pull_request: branches: [main, dev] push: - branches: [main, dev] + branches: [main] concurrency: group: ci-${{ github.ref }} @@ -15,33 +15,61 @@ 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' - cache-dependency-path: src/mcp/go.sum + go-version: '1.25' + cache-dependency-path: go.sum + + - name: Prepare desktop embed placeholder + run: | + mkdir -p desktop/frontend/dist + printf 'ci placeholder\n' > desktop/frontend/dist/.ci-placeholder - name: Check go mod tidy - working-directory: src/mcp run: | go mod tidy git diff --exit-code go.mod go.sum || (echo "go.mod/go.sum not tidy — run 'go mod tidy'" && exit 1) - - name: Run tests - working-directory: src/mcp - run: go test -v -race -coverprofile=coverage.out ./... + - name: Run race tests + run: go test -v -race $(go list ./... | grep -v /desktop) + + - name: Collect coverage + run: | + packages=$(go list ./... | grep -v /desktop) + profile=coverage.out + tmp_profile=$(mktemp) + found_test_package=false + + echo 'mode: set' > "$profile" + + while IFS= read -r pkg; do + dir=$(go list -f '{{.Dir}}' "$pkg") + if ! find "$dir" -maxdepth 1 -name '*_test.go' | grep -q .; then + continue + fi + + go test -v -coverprofile="$tmp_profile" "$pkg" + tail -n +2 "$tmp_profile" >> "$profile" + found_test_package=true + done <&2 + exit 1 + fi - name: Build binary - working-directory: src/mcp - run: go build -o quint-code -trimpath . + run: go build -o haft -trimpath ./cmd/haft/ - name: Smoke test — binary starts and responds to MCP initialize - working-directory: src/mcp run: | - RESPONSE=$(echo '{"jsonrpc":"2.0","method":"initialize","id":1,"params":{}}' | timeout 5 ./quint-code serve 2>/dev/null || true) + RESPONSE=$(echo '{"jsonrpc":"2.0","method":"initialize","id":1,"params":{}}' | timeout 5 ./haft serve 2>/dev/null || true) if echo "$RESPONSE" | grep -q '"protocolVersion"'; then echo "Smoke test passed: MCP server responds correctly" else @@ -51,9 +79,9 @@ jobs: fi - name: Upload coverage - uses: codecov/codecov-action@v4 + uses: codecov/codecov-action@v5 with: - file: src/mcp/coverage.out + file: coverage.out fail_ci_if_error: false continue-on-error: true @@ -61,17 +89,21 @@ 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' - cache-dependency-path: src/mcp/go.sum + go-version: '1.25' + cache-dependency-path: go.sum + + - name: Prepare desktop embed placeholder + run: | + mkdir -p desktop/frontend/dist + printf 'ci placeholder\n' > desktop/frontend/dist/.ci-placeholder - uses: golangci/golangci-lint-action@v7 with: - version: v2.1.6 - working-directory: src/mcp + version: v2.11.4 args: --timeout=5m diff --git a/.github/workflows/install-test.yml b/.github/workflows/install-test.yml index f869d59e..e9062b21 100644 --- a/.github/workflows/install-test.yml +++ b/.github/workflows/install-test.yml @@ -12,33 +12,32 @@ 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: | - # No release exists for this commit, so install.sh falls back to source build - # This tests: clone, go build, PATH detection - bash install.sh + - uses: actions/setup-go@v6 + with: + go-version: '1.25' + + - name: Build haft + run: go build -o haft -trimpath ./cmd/haft/ - name: Verify binary works run: | - export PATH="$HOME/.local/bin:$PATH" - quint-code --help || quint-code version || echo "Binary runs" + ./haft version || echo "Binary runs" install-macos: 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 + - name: Build haft + run: go build -o haft -trimpath ./cmd/haft/ - name: Verify binary works run: | - export PATH="$HOME/.local/bin:$PATH" - quint-code --help || quint-code version || echo "Binary runs" + ./haft version || echo "Binary runs" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 085ae014..cdee2324 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -13,34 +13,46 @@ 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' - cache-dependency-path: src/mcp/go.sum + go-version: '1.25' + cache-dependency-path: go.sum + + - name: Prepare desktop embed placeholder + run: | + mkdir -p desktop/frontend/dist + printf 'ci placeholder\n' > desktop/frontend/dist/.ci-placeholder - name: Run tests - working-directory: src/mcp - run: go test -race ./... + run: go test -race $(go list ./... | grep -v /desktop) release: name: Release 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.25' + cache-dependency-path: go.sum + + - uses: actions/setup-node@v6 with: - go-version: '1.24' - cache-dependency-path: src/mcp/go.sum + node-version: '24' + cache: 'npm' + cache-dependency-path: | + desktop/frontend/package-lock.json + tui/package-lock.json - uses: goreleaser/goreleaser-action@v6 with: diff --git a/.github/workflows/update-fpf.yml b/.github/workflows/update-fpf.yml index 3330ae89..456ceeb6 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 @@ -51,10 +51,9 @@ jobs: - name: Rebuild FTS5 index if: steps.check.outputs.changed == 'true' - working-directory: src/mcp run: | NEW_SHA=${{ steps.check.outputs.new_sha }} - go run ./cmd/indexer/ ../../data/FPF/FPF-Spec.md ./cmd/fpf.db "$NEW_SHA" + go run ./cmd/indexer/ data/FPF/FPF-Spec.md internal/cli/fpf.db "$NEW_SHA" - name: Create pull request if: steps.check.outputs.changed == 'true' @@ -69,7 +68,7 @@ jobs: git config user.email "github-actions[bot]@users.noreply.github.com" git checkout -b "$BRANCH" - git add data/FPF src/mcp/cmd/fpf.db + git add data/FPF internal/cli/fpf.db git commit -m "chore: update FPF spec index to ${SHORT} FPF upstream: ${OLD_SHORT} → ${SHORT} @@ -83,7 +82,7 @@ jobs: Submodule \`data/FPF\` updated to [\`${SHORT}\`](https://github.com/ailev/FPF/commit/$NEW_SHA). - FTS5 index rebuilt: \`src/mcp/cmd/fpf.db\` + FTS5 index rebuilt: \`internal/cli/fpf.db\` **Previous:** [\`${OLD_SHORT}\`](https://github.com/ailev/FPF/commit/$OLD_SHA) **New:** [\`${SHORT}\`](https://github.com/ailev/FPF/commit/$NEW_SHA)" \ diff --git a/.gitignore b/.gitignore index 0f892547..838a6040 100644 --- a/.gitignore +++ b/.gitignore @@ -1,9 +1,32 @@ .quint +.haft .context .mcp.json -.codex/config.toml quint-mcp.json src/mcp/quint-code src/mcp/quint-mcp TODO.md -skills +/skills/ +/haft + +.DS_Store +.task/ +tmp/ +__pycache__/ +node_modules/ +dist/ +build/ +.cache/ +*.log +coverage.out + +.claude/ +.codex/ +.crush/ +.cursor/ +.zenflow/ +desktop/desktop +.zenflow/ +.zenflow/** +DESIGN.md +.playwright-mcp/ diff --git a/.golangci.yml b/.golangci.yml new file mode 100644 index 00000000..d5d4851c --- /dev/null +++ b/.golangci.yml @@ -0,0 +1,77 @@ +version: "2" + +run: + timeout: 5m + tests: false + +linters: + enable: + - errcheck + - govet + - ineffassign + - staticcheck + - unused + - misspell + - unconvert + - gosec + - bodyclose + - gocritic + - nolintlint + settings: + misspell: + locale: US + gocritic: + disabled-checks: + - ifElseChain + - singleCaseSwitch + - elseif + nolintlint: + require-explanation: true + require-specific: true + gosec: + 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: + paths: + - desktop/ + rules: + - linters: + - errcheck + source: "Send[A-Z][A-Za-z]+\\(" + - linters: + - errcheck + source: "os\\.Stderr\\.WriteString\\(" + - linters: + - errcheck + source: "syscall\\.Syscall6?\\(" + - linters: + - staticcheck + text: "QF1003" + - linters: + - staticcheck + text: "QF1012" + - linters: + - errcheck + source: "defer .*(Close|Rollback)\\(\\)" + - path: internal/provider/openai.go + linters: + - gosec + text: "G117:" + - path: internal/cli/login.go + linters: + - gosec + text: "G117:" + - path: internal/cli/term_echo_linux.go + linters: + - gosec + text: "G115:" + +formatters: + enable: + - gofmt + - goimports diff --git a/.goreleaser.yaml b/.goreleaser.yaml index 6e2bf54a..916c5a09 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -1,31 +1,62 @@ version: 2 -project_name: quint-code +project_name: haft + +before: + hooks: + - sh -c "cd desktop/frontend && npm ci && npm run build" + - sh -c "cd tui && npm ci && npm run build" builds: - - id: quint-code - dir: src/mcp - main: . - binary: quint-code + - id: haft + dir: . + main: ./cmd/haft/ + binary: haft flags: - -trimpath ldflags: - -s -w - - -X github.com/m0n0x41d/quint-code/cmd.Version={{ .Version }} - - -X github.com/m0n0x41d/quint-code/cmd.Commit={{ .ShortCommit }} - - -X github.com/m0n0x41d/quint-code/cmd.BuildDate={{ .Date }} + - -X github.com/m0n0x41d/haft/internal/cli.Version={{ .Version }} + - -X github.com/m0n0x41d/haft/internal/cli.Commit={{ .ShortCommit }} + - -X github.com/m0n0x41d/haft/internal/cli.BuildDate={{ .Date }} goos: - linux - darwin goarch: - amd64 - arm64 + - id: haft-desktop + dir: . + main: ./desktop + binary: haft-desktop + flags: + - -trimpath + goos: + - darwin + goarch: + - amd64 + - arm64 archives: - - id: default + - id: haft-cli + ids: + - haft formats: - tar.gz - name_template: "{{ .ProjectName }}-{{ .Os }}-{{ .Arch }}" + name_template: "{{ .ProjectName }}-cli-{{ .Os }}-{{ .Arch }}" + files: + - README* + - LICENSE* + - CHANGELOG* + - src: tui/dist/tui.mjs + dst: tui + strip_parent: true + - id: haft-desktop + ids: + - haft-desktop + formats: + - zip + name_template: "{{ .ProjectName }}-desktop-{{ .Os }}-{{ .Arch }}" checksum: name_template: 'checksums.txt' @@ -35,18 +66,18 @@ changelog: release: header: | - ## Quint Code {{ .Tag }} - - **Complete product redesign.** v5 is a new product — not backward-compatible with v4. + ## Haft {{ .Tag }} - 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. + Engineering agent with FPF reasoning discipline. Interactive CLI + MCP server. ### Install ```bash - curl -fsSL https://raw.githubusercontent.com/m0n0x41d/quint-code/main/install.sh | bash + go install github.com/m0n0x41d/haft/cmd/haft@latest ``` - See [CHANGELOG](https://github.com/m0n0x41d/quint-code/blob/main/src/mcp/CHANGELOG.md) for full details. + ### Desktop Artifact + + Release archives now include a `haft-desktop` build for macOS alongside the CLI bundles. + + See [CHANGELOG](https://github.com/m0n0x41d/quint-code/blob/main/CHANGELOG.md) for full details. diff --git a/.quint/.gitignore b/.quint/.gitignore deleted file mode 100644 index 8d037245..00000000 --- a/.quint/.gitignore +++ /dev/null @@ -1 +0,0 @@ -quint.db diff --git a/.quint/project.yaml b/.quint/project.yaml deleted file mode 100644 index b0148bfc..00000000 --- a/.quint/project.yaml +++ /dev/null @@ -1,2 +0,0 @@ -id: qnt_2fa3bf77 -name: quint-code diff --git a/CHANGELOG.md b/CHANGELOG.md index d615ad78..26c8f815 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,118 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). +## [Unreleased] + +## [6.0.0] — 2026-04-13 + +### Breaking Changes + +- **Product renamed from quint-code to Haft** — binary, MCP tools (`quint_*` → `haft_*`), slash commands (`/q-*` → `/h-*`), skill names, and docs all use `haft` naming. Existing MCP configs, skill references, and slash commands from v5.x will not work without updating. +- **Decision data model replaced** — claim-aware decision kernel with structured claims, predictions, and claim-bound evidence replaces markdown-only reconstruction. Existing decision artifacts require migration. +- **Reasoning model changed** — 5-mode activity model (Understand / Explore / Choose / Execute / Verify) replaces the artifact-centric 6-step protocol. Skill instructions, prompts, and agent behavior follow the new model. +- **`/h-verify` replaces `/h-refresh`** — `/h-refresh` is deprecated and auto-cleaned on install. Use `/h-verify` for discovery (scan + drift + pending verify_after) and triage. + +Note: older changelog entries keep historical `quint-code`, `quint_*`, and `/q-*` names where they describe behavior, commands, or releases from that era. + +### Added + +- **Desktop app (pre-alpha)** — Wails v2 desktop application with dashboard, problem board, decision detail with evidence F/G/R decomposition, portfolio comparison with Pareto front visualization, task spawning (Claude Code / Codex), agent chat view, terminal panel (Cmd+\`), multi-project management, and search (Cmd+K). Dark theme following the design system. Pre-alpha: not recommended for production use. +- **Standalone Haft runtime** — local-first `haft agent` / TUI flow with persisted sessions, checkpointed vs autonomous execution, permission and question dialogs, model/session pickers, compaction, spawned subagents, and a typed JSON-RPC protocol between UI and runtime. +- **Knowledge graph** — `internal/graph` package providing unified query interface over existing artifact, module, and dependency tables. Queries: FindDecisionsForFile, FindInvariantsForFile, FindModuleForFile, TransitiveDependents, ComputeImpactSet. All cycle-safe with depth limiting. 17 tests. +- **Invariant injection into agent prompts** — when implementing a decision, agents receive invariants from ALL decisions governing the affected files, not just the current decision's own invariants. Invariants tagged with source decision ID. +- **Invariant verification** — automated checking of "no dependency from X to Y" and "no circular dependencies" patterns against the live module dependency graph. Returns holds/violated/unknown per invariant. +- **Governance invariant alerts** — governance scanner now runs invariant verification on decisions with drift findings, creating problem candidates for violations. +- **Probe-or-commit readiness gate** — AssessComparisonReadiness evaluates portfolio comparison quality: variant count, dimension coverage, score fill rate, constraint presence, parity plan. Returns commit/probe/widen/reroute with specific recommendations. Shown in desktop Portfolios page. +- **Evidence F/G/R decomposition** — decision detail page shows per-evidence formality level (F0-F3), congruence level (CL0-CL3), verdict badges, freshness indicators, and coverage gaps (claims without evidence). +- **Auto-run toggle for agent tasks** — per-task toggle between checkpointed (agent pauses) and auto-run (agent proceeds without intervention) modes. Persisted across app restart. +- **`haft sync` for team workflow** — syncs `.haft/` markdown files into local SQLite database after `git pull`. Enables team collaboration where `.haft/*.md` in git is the shared source of truth and each engineer has their own local database. +- **Probe-or-commit behavioral gate** — Choose mode now includes a readiness checklist before comparison: dimension coverage, variant diversity, and whether a specific next investigation could change the ranking. Returns commit / probe / widen / reroute. +- **Language precision triggers** — Understand and Choose modes catch ambiguous terms (service, process, quality, component) and subjective comparison dimensions (maintainable, simple, scalable) before they corrupt downstream reasoning. +- **`verify_after` field on claims** — `DecisionClaim` and `PredictionInput` now accept `verify_after` (RFC3339 or YYYY-MM-DD). Claims with past verify_after dates that remain unverified are surfaced by `haft_refresh(scan)` as `pending_verification` stale items with observable and threshold details. MCP schema updated. +- **Constraint-aware Pareto computation** — `computeParetoFront()` now eliminates variants that are strictly worst on all comparable peers for any constraint dimension before dominance computation. Constraint violations are reported as warnings. Variants with missing constraint data are preserved conservatively. +- **Standalone agent refresh tool parity** — `HaftRefreshTool` now exposes all 6 actions (scan, drift, waive, reopen, supersede, deprecate) matching the MCP server schema. Previously only scan/drift were available to the standalone agent. +- **Explicit reroute map** — legitimate upstream transitions documented: Choose → Understand (comparison reveals bad framing), Explore → Understand (wrong problem type), Execute → Choose, Verify → any earlier mode. +- **Claim-aware decision kernel** — decisions now persist canonical structured claims, predictions, claim-bound evidence, live measurement status, and deterministic Pareto/coverage state instead of relying on markdown-only reconstruction. +- **Deterministic projections** — projection views now render the same artifact graph for different audiences, including engineer, manager, audit, compare, delegated-agent brief, and change-rationale handoff surfaces. +- **Route-aware FPF retrieval** — indexed section summaries, route expansion, explain/full controls, golden-query evaluation, tree drill-down, and experimental semantic retrieval over the embedded FPF corpus. +- **Broader codebase awareness** — C/C++ module and include detection, symbol hashing, richer module/dependency scanning, and module-governance reporting in status/coverage flows. +- **Expanded client integrations** — `haft init` now installs MCP/command surfaces for Claude Code, Cursor, Gemini CLI, Codex CLI/App, and Air while keeping the same local binary/runtime. +- **`haft_problem(action="close")`** — marks a ProblemCard as `addressed`. Previously required manual frontmatter editing. Exposed in MCP schema for both plugin and standalone modes. ([#43](https://github.com/m0n0x41d/quint-code/issues/43)) +- **Auto-baseline after `decide`** — when `affected_files` are provided, file hashes are snapshotted immediately after the decision is recorded. No more manual `haft_decision(action="baseline")` calls. ([#43](https://github.com/m0n0x41d/quint-code/issues/43)) + +### Changed + +- **Core architecture refactored into explicit layers** — artifact build/store logic, presentation formatting, protocol transport, agent runtime, and TUI shell now live as clearer functional boundaries with purer `Build*`/formatting paths and thinner orchestration shells. +- **Agent execution moved beyond slash-command steering** — the repo now supports both MCP/plugin workflows and a standalone agent/TUI loop, with persisted execution mode aliases and compatibility bridges for older symbiotic/collaborative terminology. +- **Provider/model support expanded** — the registry and CLI now support multi-provider model discovery/switching with GPT-5.4-class defaults/fallbacks instead of the older 5.3-era baseline. +- **FPF search quality improved materially** — deterministic route lookup, better weighting/sanitization, explicit section summaries, and MCP-accessible spec search replaced the older narrower retrieval path. +- **`haft init --codex` TOML generation fixed** — idempotent section replacement instead of append, prevents duplicate key errors on repeated init. + +### Fixed + +- **`haft serve` / plugin mode now matches the core claim model** — served MCP schema and handlers understand predictions, strict decision/measurement arrays, claim refs/scope, and projection views instead of lagging behind the direct runtime. +- **Slash-command guidance no longer points users at stale `/q-*` actions** — note validation, nav strips, MCP presentation text, and h-reason docs now consistently steer users through the `h-*` surface, with `/h-view` as the advanced projection entry point. +- **Large pasted prompts no longer explode the TUI** — oversized pasted text is collapsed to `[N rows inserted]` placeholders in the input/queue/transcript UI, while the raw content is preserved and expanded only at submit time. +- **Queued follow-up messages preserve real prompt state** — multiline text, attachments, and hidden collapsed-paste payloads now survive queueing, replay, and draft restore paths without truncation or accidental `trim()` damage. +- **Decision/evidence integrity issues tightened** — malformed compare/measure payloads now fail loudly, Pareto fronts are computed deterministically, and claim/evidence bindings keep canonical scope instead of silently degrading. +- **Governance shutdown no longer panics on double-close** — `sync.Once` prevents channel double-close during fast project switching. +- **SwitchProject validates new project before teardown** — pre-checks DB accessibility, preventing zombie state if the target project is broken. +- **Task auto_run field restored from database** — was persisted but silently lost on restart. +- **WAL mode + busy_timeout on all SQLite connections** — prevents SQLITE_BUSY during concurrent governance scanner and UI queries. +- **Null safety across all Go→JSON view projections** — nil slices now serialize as `[]` not `null`, preventing frontend TypeError crashes on 30+ array fields. +- **Task runner race conditions fixed** — state copied under mutex before use outside lock in shutdown, cancel, and finalize paths. +- **Atomic file writes for config and registry** — temp file + rename prevents corruption from concurrent access. +- **Task timeout enforcement** — agent processes killed after configurable timeout (default 300 min), preventing zombie processes. +- **Artifact Create uses single transaction** — artifact insert and link inserts wrapped in one transaction, preventing partial state on link failure. +- **tableHasColumn PRAGMA cached** — eliminated 2 PRAGMA queries per evidence operation. +- **Large agent output truncated** — outputs over 500 lines show last 200 with "Show full output" button, preventing WebView freezing. +- **Search race condition fixed** — stale results from earlier queries no longer briefly flash. + +## [5.3.1] — 2026-03-25 + +### Fixed + +- **NavStrip no longer triggers agent auto-execution** — "Next:" label replaced with "Available:" + explicit guard line ("do not auto-execute"). Slash commands (`/q-explore`, `/q-decide`) replace tool call syntax (`quint_solution(action="explore", ...)`), so agents read them as user actions, not callable functions. +- **NavStrip is mode-aware** — available actions now reflect the current depth mode. Tactical shows `/q-explore | /q-decide` (short cycle). Standard without characterization shows `/q-char | /q-explore` — making `/q-char` visible as the gateway to the full cycle. Standard with characterization shows only `/q-explore`. EXPLORING in tactical shows `/q-decide | /q-compare (upgrade)` instead of always suggesting compare. +- **`quint_solution(action="compare")` rejected valid dimensions** — compare handler used raw type assertions instead of `parseStringArrayFromArgs` helper. When MCP clients serialized `dimensions` or `non_dominated_set` as JSON strings (common without schema loaded), the assertion silently failed, producing a misleading "at least one comparison dimension is required" error. Same fix applied to `scores` (new `parseNestedStringMapFromArgs` helper) and measure handler arrays (`criteria_met`, `criteria_not_met`, `measurements`). +- **"No baseline" scan confused with "not implemented"** — `CheckDrift` now checks git history for affected files when no baseline exists. Distinguishes "files changed since decision (likely implemented, needs baseline+measure)" from "files unchanged (not yet implemented)". Prevents agents from misreporting implemented decisions as not started. + +### Added + +- **NavStrip interpretation in q-reason skill** — new section documenting that "Available:" is a menu for the user, not instructions for the agent. Clarifies that tactical mode has fewer steps but the same human consent gates, and only Path 3 (explicit delegation) overrides the guard. +- **Proactive check for "no baseline" in q-reason skill** — instructs agents to not assume "no baseline" means "not implemented" and to check git history before reporting status. + +## [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 + +- **C/C++ module detection** — `compile_commands.json` as primary source (searches project root, `build/`, `cmake-build-*/`). Falls back to directory-based heuristic with `Makefile`/`CMakeLists.txt`/`meson.build` markers. Graceful fallback if `compile_commands.json` paths don't resolve. +- **C/C++ import parsing** — extracts `#include "..."` edges (skips `<...>` system includes). Resolves include paths using `-I` flags from `compile_commands.json`. Falls back to relative and project-root resolution. +- **C/C++ extensions** — `.c`, `.h`, `.cpp`, `.cc`, `.cxx`, `.hpp`, `.hxx` registered in language registry. + +### Fixed + +- **`quint_solution(action="explore")` rejected valid variants** — MCP clients that serialize the `variants` array as a JSON string (instead of a parsed array) caused silent parsing failure, resulting in a misleading "genuinely distinct options" error with 0 variants. Same fix applied to all array fields across note/problem/decision handlers. ([#33](https://github.com/m0n0x41d/quint-code/issues/33)) +- **Status always rescans modules** — `quint_query(action="status")` now runs a fresh module scan instead of showing stale cached data. Previously required `action="coverage"` to trigger rescan. +- **Symlink-safe path resolution** — C/C++ module detection uses `filepath.EvalSymlinks` on project root and source paths for reliable matching on macOS and symlinked project directories. + ## [5.1.0] — 2026-03-20 ### Added — Computed Features diff --git a/CLAUDE.md b/CLAUDE.md index f892ec22..48b1f249 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -60,7 +60,7 @@ When reasoning through problems, apply these principles: ### 2. Investigate the Codebase -- **Check `.quint/` directory** — Decisions, problems, notes (markdown projections) +- **Check `.haft/` directory** — Decisions, problems, notes (markdown projections) - Explore relevant files and directories - Search for key functions, classes, variables - Identify root cause @@ -75,7 +75,7 @@ When reasoning through problems, apply these principles: ### 4. Plan the Solution (Collaborative) -- **For significant changes: use `/q-reason` or `/q-frame`** +- **For significant changes: use `/h-reason` or `/h-frame`** - Break fix into manageable, incremental steps - Each step should be specific, simple, and verifiable - Actually execute each step (don't just say "I will do X" - DO X) @@ -132,7 +132,7 @@ REVERSIBILITY: [Can we undo in 2 weeks? 2 months? Never?] RECOMMENDATION: [Which + why, or "need your input on X"] ``` -## FPF Mode (Structured Reasoning with Quint Code) +## FPF Mode (Structured Reasoning with Haft) **When to use:** @@ -147,27 +147,27 @@ RECOMMENDATION: [Which + why, or "need your input on X"] - Easily reversible decisions - Time-critical situations where overhead isn't justified -**Activation:** Run `/q-reason` and describe the problem. The agent auto-selects depth. +**Activation:** Run `/h-reason` and describe the problem. The agent auto-selects depth. -**Commands:** +**Five modes:** -| Command | What it does | -|---------|-------------| -| `/q-note` | Capture micro-decisions with rationale validation | -| `/q-frame` | Frame the problem — signal, constraints, acceptance | -| `/q-char` | Define comparison dimensions with roles (constraint/target/observation) | -| `/q-explore` | Generate genuinely distinct variants with weakest link | -| `/q-compare` | Fair comparison with parity enforcement | -| `/q-decide` | FPF E.9 decision contract — invariants, DO/DON'T, rollback | -| `/q-refresh` | Manage artifact lifecycle — waive, reopen, supersede, deprecate | -| `/q-status` | Dashboard — Shipped/Pending decisions, problems, module coverage | -| `/q-search` | Full-text search across all artifacts | -| `/q-problems` | List problems with Goldilocks readiness + complexity signals | +| Mode | Command | What it does | +|------|---------|-------------| +| Understand | `/h-frame` | Frame the problem — signal, constraints, acceptance | +| Explore | `/h-char` | Define comparison dimensions (constraint/target/observation) | +| Explore | `/h-explore` | Generate genuinely distinct variants with weakest link | +| Choose | `/h-compare` | Fair comparison with parity enforcement | +| Execute | `/h-decide` | Decision contract — invariants, DO/DON'T, rollback | +| Verify | `/h-verify` | Check stale artifacts, code drift, pending claims | +| — | `/h-note` | Micro-decision with rationale validation | +| — | `/h-status` | Dashboard — decisions, problems, module coverage | +| — | `/h-search` | Full-text search across all artifacts | +| — | `/h-problems` | List problems with readiness + complexity signals | **Recommended protocol (for best results):** ``` -/q-frame → /q-char → /q-explore → /q-compare → /q-decide +/h-frame → /h-char → /h-explore → /h-compare → /h-decide what's what genuinely fair engineering broken? matters? different comparison contract options @@ -179,16 +179,16 @@ RECOMMENDATION: [Which + why, or "need your input on X"] - **Evidence Decay**: Expired evidence scores 0.1. R_eff < 0.5 → stale. R_eff < 0.3 → AT RISK. - **Indicator Roles**: constraint (hard limit), target (optimize), observation (Anti-Goodhart). - **Parity**: Same inputs, same scope, same budget for all options — or the comparison is junk. -- **Codebase Awareness**: Module coverage shows which parts of the architecture have decisions. `/q-status` includes module coverage section. -- **Cross-Project Recall**: Decisions from other projects surface during `/q-frame` with CL2/CL1 penalties. +- **Codebase Awareness**: Module coverage shows which parts of the architecture have decisions. `/h-status` includes module coverage section. +- **Cross-Project Recall**: Decisions from other projects surface during `/h-frame` with CL2/CL1 penalties. -**State Location:** `.quint/` directory (markdown projections, git-tracked). Database in `~/.quint-code/`. +**State Location:** `.haft/` directory (markdown projections, git-tracked). Database in `~/.haft/`. **Key Principle:** You (the agent) generate options with evidence. Human decides. This is the Transformer Mandate — a system cannot transform itself. ## Critical Reminders -1. **Decision Framework vs FPF**: Quick decisions → inline framework. Complex/persistent → `/q-reason` +1. **Decision Framework vs FPF**: Quick decisions → inline framework. Complex/persistent → `/h-reason` 3. **Actually Do Work**: When you say "I will do X", DO X 4. **No Commits Without Permission**: Only commit when explicitly asked 5. **Test Contracts**: Test behavior through public interfaces, not implementation @@ -215,7 +215,7 @@ RECOMMENDATION: [Which + why, or "need your input on X"] **Evidence Decay** — Evidence has `valid_until`. Expired evidence scores 0.1 (weak, not absent). Graduated epistemic debt sorted by severity. -**DRR (Decision Record)** — FPF E.9 four-component structure: Problem Frame, Decision/Contract, Rationale, Consequences. Created via `/q-decide`. +**DRR (Decision Record)** — FPF E.9 four-component structure: Problem Frame, Decision/Contract, Rationale, Consequences. Created via `/h-decide`. **Indicator Roles** — Each comparison dimension tagged as: - `constraint` — hard limit, must satisfy @@ -226,7 +226,7 @@ RECOMMENDATION: [Which + why, or "need your input on X"] ### Artifact Lifecycle ``` -/q-frame → /q-char → /q-explore → /q-compare → /q-decide +/h-frame → /h-char → /h-explore → /h-compare → /h-decide problem dims variants fair check DRR contract Problems: Backlog → In Progress → Addressed diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c53d10f6..1d268a31 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,9 +1,9 @@ -# Contributing to Quint Code +# Contributing to Haft ## Workflow 1. **Create an issue first** — Open an issue with the `proposal` label. Include: - - Rationale — either FPF methodology alignment or UX improvement (feature request) + - Rationale — either FPF methodology alignment or UX improvement - Question or problem statement - Proposed solution @@ -29,15 +29,23 @@ git config core.hooksPath .githooks # https://golangci-lint.run/welcome/install/ curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/HEAD/install.sh | sh -s -- -b $(go env GOPATH)/bin -# Build -cd src/mcp -go build -o ~/.local/bin/quint-code -trimpath . +# Build the current binary +go build -o ~/.local/bin/haft -trimpath . # Run tests go test -race ./... ``` -The pre-commit hook runs the same checks as CI: `go mod tidy`, `golangci-lint`, `go test -race`, `go build`. If it fails, CI will fail too. Fix locally before pushing. +The GitHub repository path still uses the historical `quint-code` name. The current module and binary are `haft`. + +The pre-commit hook runs the same checks as CI: `go mod tidy`, `golangci-lint`, `go test -race`, `go build`. If it fails locally, CI will fail too. + +## Documentation expectations + +- Current-facing docs should use `haft`, `haft_*`, and `/h-*` naming. +- Historical references to `quint-code`, `quint_*`, or `.quint/` should stay only where they document release history or migrations. +- Keep references to Anatoly Levenchuk and FPF intact. +- Do not forget that both **MCP tool mode** and **command-driven mode** are supported. ## Want to Help but No Proposal? diff --git a/README.md b/README.md index c4a74a57..5b589a83 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,25 @@ -Quint Code +Haft **Engineering decisions that know when they're stale.** Frame problems. Compare options fairly. Record decisions as contracts. Know when to revisit. -Supports: Claude Code, Cursor, Gemini CLI, Codex CLI, Codex App, Air +--- + +## What is Haft? + +Haft is a local-first engineering governor for software projects. It helps engineers frame problems before solving them, compare options honestly, record decisions as contracts with invariants, track evidence with decay, and know when to revisit. + +**Think → Run → Govern.** + +### Two primary surfaces + +- **Desktop app** — visual cockpit for reasoning state, agent orchestration, and governance dashboard +- **MCP plugin** — reasoning tools for AI coding agents (Claude Code, Cursor, Gemini CLI, Codex, Air) + +Both share the same kernel. Desktop is where humans think. MCP is where agents think. + +> **Note:** The TUI (`haft agent`) and Desktop app are in **pre-alpha** and under active development. They are not recommended for production use. The MCP plugin mode (`haft serve`) is the stable, proven interface. --- @@ -14,101 +29,109 @@ Supports: Claude Code, Cursor, Gemini CLI, Codex CLI, Codex App, Air curl -fsSL https://raw.githubusercontent.com/m0n0x41d/quint-code/main/install.sh | bash ``` +The install URL still points at the historical `quint-code` repository path. The installed binary is `haft`. + Then in your project, run init **with your tool's flag**: ```bash # Claude Code (default if no flag) -quint-code init +haft init + +# Claude Code with repo-local commands +haft init --local # Cursor -quint-code init --cursor +haft init --cursor # Gemini CLI -quint-code init --gemini +haft init --gemini # Codex CLI / Codex App -quint-code init --codex +haft init --codex # JetBrains Air -quint-code init --air +haft init --air # All tools at once -quint-code init --all +haft init --all ``` ### What init does per tool -The binary is the same — only the MCP config location differs: - -| Tool | MCP Config | Commands | Skill | -|------|-----------|----------|-------| -| Claude Code | `.mcp.json` (project root) | `~/.claude/commands/` | `~/.claude/skills/q-reason/` | -| Cursor | `.cursor/mcp.json` | `~/.cursor/commands/` | `~/.cursor/skills/q-reason/` | -| Gemini CLI | `~/.gemini/settings.json` | `~/.gemini/commands/` | — | -| Codex CLI | `.codex/config.toml` | `~/.codex/prompts/` | `~/.agents/skills/q-reason/` | -| Air | `.codex/config.toml` | project `skills/` | project `skills/q-reason/` | - -**Important for Cursor:** After init, open Cursor Settings → MCP → find `quint-code` → enable the toggle. Cursor adds MCP servers as disabled by default. +The binary is the same — only the MCP config and command/prompt installation locations differ: -**Note:** Cursor also picks up Claude Code commands from `~/.claude/commands/` — so slash commands work even without `--cursor`. But MCP config (`.cursor/mcp.json`) must be set up for the tools to connect. +| Tool | MCP Config | Commands / Prompts | Skill | +|------|-----------|--------------------|-------| +| Claude Code | `.mcp.json` (project root) | `~/.claude/commands/` or `.claude/commands/` with `--local` | `~/.claude/skills/h-reason/` or local install with `--local` | +| Cursor | `.cursor/mcp.json` | `~/.cursor/commands/` or `.cursor/commands/` with `--local` | `~/.cursor/skills/h-reason/` or local install with `--local` | +| Gemini CLI | `~/.gemini/settings.json` | `~/.gemini/commands/` or local install with `--local` | — | +| Codex CLI / Codex App | `.codex/config.toml` | `~/.codex/prompts/` or `.codex/prompts/` with `--local` | `~/.agents/skills/h-reason/` | +| Air | `.codex/config.toml` | project `skills/` | project `skills/h-reason/` | -Existing project? Run `/q-onboard` after init — the agent scans your codebase for existing decisions worth capturing. - -**First time?** Ask the agent to explain how it works: - -``` -/q-reason explain how to work with quint-code effectively — what commands exist, when to use each one, and what's the recommended workflow -``` +**Important for Cursor:** After init, open Cursor Settings → MCP → find `haft` → enable the toggle. Cursor adds MCP servers as disabled by default. -The agent has full knowledge of all Quint tools and will walk you through them in context of your project. +Existing project? Run `/h-onboard` after init — the agent scans your codebase for existing decisions worth capturing. --- ## How It Works -### One command: `/q-reason` +### Six MCP tools + +| Tool | What it does | +|------|-------------| +| `haft_note` | Micro-decisions with validation + auto-expiry | +| `haft_problem` | Frame problems, define comparison dimensions with roles | +| `haft_solution` | Explore variants with diversity check, compare with parity | +| `haft_decision` | Decision contract with invariants, claims, evidence, baseline lifecycle | +| `haft_refresh` | Lifecycle management for all artifacts | +| `haft_query` | Search, status dashboard, file-to-decision lookup, FPF spec search | + +### One command: `/h-reason` Describe your problem. The agent frames it, generates alternatives, compares them fairly, and records the decision — all in one command. It auto-selects the right depth. ### Or drive each step manually ``` -/q-frame → /q-char → /q-explore → /q-compare → /q-decide +/h-frame → /h-char → /h-explore → /h-compare → /h-decide what's what genuinely fair engineering broken? matters? different comparison contract options ``` -### Micro-decisions on the fly - -The agent captures decisions automatically when it notices them in conversation. No rationale — no record. Conflicts with active decisions are flagged. Auto-expires in 90 days. +### Evidence workflow -### When decisions go stale +Attach evidence to decisions with `haft_decision(action="evidence", ...)`. Evidence has formality levels (F0-F3), congruence levels (CL0-CL3), and expiry dates. Trust scores (R_eff) degrade as evidence ages. Stale evidence triggers refresh. -`/q-status` shows what's expired and what needs attention. `/q-refresh` manages the lifecycle of ALL artifact types — waive, reopen, supersede, or deprecate. +Use `haft_decision(action="measure", ...)` for post-implementation verification. Pair with `haft_decision(action="baseline", ...)` to snapshot affected files before measuring. --- ## What Makes It Different -- **Decisions are live** — they have computed trust scores (R_eff) that degrade as evidence ages. An expired benchmark drops the whole score. -- **Comparison is honest** — parity enforced, dimensions cross-checked, asymmetric scoring warned. Anti-Goodhart: tag dimensions as "observation" to prevent optimizing the wrong metric. -- **Memory across sessions** — when you frame a problem, the tool surfaces related past decisions. When you explore, it checks for similar variants. -- **The loop closes** — failed measurements suggest reopening. Evidence decay triggers review. Periodic refresh prompts ensure nothing goes stale silently. -- **Decisions are contracts** — FPF E.9 format: Problem Frame, Decision (invariants + DO/DON'T), Rationale, Consequences. A new engineer reads it 6 months later and gets everything. +- **Decisions are live** — computed trust scores (R_eff) degrade as evidence ages +- **Comparison is honest** — parity enforced, constraint-aware Pareto elimination, anti-Goodhart observation indicators +- **Invariants linked to code** — knowledge graph maps decisions to modules via dependency graph +- **Memory across sessions** — related past decisions surface during framing, similar variants during exploration +- **The loop closes** — failed measurements reopen decisions, evidence decay triggers review, drift detection flags violations +- **Decisions are contracts** — invariants, claims with thresholds, rollback plan, valid-until date --- -## 6 Tools +## Desktop App (pre-alpha) -| Tool | What it does | -|------|-------------| -| `quint_note` | Micro-decisions with validation + auto-expiry | -| `quint_problem` | Frame problems, define comparison dimensions with roles | -| `quint_solution` | Explore variants with diversity check, compare with parity | -| `quint_decision` | FPF E.9 decision contract, impact measurement, evidence | -| `quint_refresh` | Lifecycle management for all artifacts | -| `quint_query` | Search, status dashboard, file-to-decision lookup | +> **Warning:** The desktop app is in pre-alpha. Use at your own risk. + +Built with Wails v2 (Go + React). Run with: + +```bash +task desktop # dev mode with hot reload +task desktop:build # production .app bundle +task desktop:open # build and open +``` + +Features: dashboard with governance findings, problem board, decision detail with evidence decomposition, portfolio comparison with Pareto front, task spawning, agent chat view, terminal panel, multi-project management, search (Cmd+K). --- @@ -116,20 +139,17 @@ The agent captures decisions automatically when it notices them in conversation. [FPF](https://github.com/ailev/FPF) by [Anatoly Levenchuk](https://www.linkedin.com/in/ailev/) — a rigorous, transdisciplinary architecture for thinking. -`/q-reason` gives your AI agent an FPF-native operating system for engineering decisions: problem framing before solutions, characterization before comparison, parity enforcement, evidence with congruence penalties, weakest-link assurance, and the lemniscate cycle that closes itself when evidence ages or measurements fail. +`/h-reason` gives your AI agent an FPF-native operating system for engineering decisions: problem framing before solutions, characterization before comparison, parity enforcement, evidence with congruence penalties, weakest-link assurance, and the lemniscate cycle that closes itself when evidence ages or measurements fail. -`quint-code fpf search` gives you access to 4243 indexed sections from the FPF specification — the agent can look up any concept on demand. +`haft fpf search` gives access to the indexed FPF specification with tiered retrieval: exact pattern id → route-aware concept matching → keyword fallback. --- -## Learn More - -See the [documentation](https://quint.codes/learn) for detailed guides on decision modes, the DRR format, computed features, and lifecycle management. - ## Requirements -- Go 1.24+ (for building from source) -- Any MCP-capable AI tool +- Go 1.25+ (for building from source) +- Any MCP-capable AI tool for plugin mode +- Wails v2 for desktop app (optional) ## License diff --git a/Taskfile.yaml b/Taskfile.yaml index 3dbf1d0c..bb25dff1 100644 --- a/Taskfile.yaml +++ b/Taskfile.yaml @@ -1,9 +1,13 @@ version: '3' vars: - BIN_NAME: quint-code + BIN_NAME: haft BIN_DIR: ~/.local/bin - SRC_DIR: src/mcp + TUI_DIR: tui + TUI_BUNDLE: tui/dist/tui.mjs + TUI_INSTALL_DIR: ~/.haft/tui + FPF_SPEC: data/FPF/FPF-Spec.md + FPF_DB: internal/cli/fpf.db tasks: default: @@ -13,40 +17,108 @@ tasks: test: desc: Run all tests - dir: '{{.SRC_DIR}}' cmds: - - go test -v ./... + - go test $(go list ./... | grep -v /desktop) lint: - desc: Run linter (go vet) - dir: '{{.SRC_DIR}}' + desc: Run linter (go vet + typecheck) cmds: - go vet ./... + - cd {{.TUI_DIR}} && bun run typecheck + + tui: + desc: Build TUI bundle + cmds: + - cd {{.TUI_DIR}} && bun run build + sources: + - tui/src/**/*.ts + - tui/src/**/*.tsx + generates: + - '{{.TUI_BUNDLE}}' + + fpf-pull: + desc: Pull FPF submodule to latest upstream + cmds: + - git -C data/FPF fetch origin + - git -C data/FPF checkout origin/main + + fpf-index: + desc: Rebuild embedded SQLite index from current FPF checkout + cmds: + - go run ./cmd/indexer {{.FPF_SPEC}} {{.FPF_DB}} "$(git -C data/FPF rev-parse HEAD 2>/dev/null || true)" + sources: + - '{{.FPF_SPEC}}' + - internal/fpf/fpf-routes.json + - cmd/indexer/**/*.go + - internal/fpf/**/*.go + generates: + - '{{.FPF_DB}}' + + fpf-refresh: + desc: Pull FPF submodule to latest upstream and rebuild embedded SQLite index + deps: [fpf-pull] + cmds: + - task fpf-index build: - desc: Build binary - dir: '{{.SRC_DIR}}' + desc: Build Go binary + TUI bundle + deps: [tui, fpf-index] cmds: - - go build -o {{.BIN_NAME}} -trimpath . + - go build -o {{.BIN_NAME}} -trimpath ./cmd/haft/ generates: - '{{.BIN_NAME}}' install: - desc: Build and install to ~/.local/bin - dir: '{{.SRC_DIR}}' + desc: Build and install haft (binary + TUI) locally + deps: [tui, fpf-index] cmds: - - go build -o {{.BIN_DIR}}/{{.BIN_NAME}} -trimpath . - - echo "Installed to {{.BIN_DIR}}/{{.BIN_NAME}}" + - go build -o {{.BIN_DIR}}/{{.BIN_NAME}} -trimpath ./cmd/haft/ + - mkdir -p {{.TUI_INSTALL_DIR}} + - cp {{.TUI_BUNDLE}} {{.TUI_INSTALL_DIR}}/bundle.mjs + - echo "Installed {{.BIN_DIR}}/{{.BIN_NAME}}" + - echo "Installed {{.TUI_INSTALL_DIR}}/bundle.mjs" + + dev: + desc: Build Go binary only (TUI runs from source via bun) + deps: [fpf-index] + cmds: + - go build -o {{.BIN_NAME}} -trimpath ./cmd/haft/ + + doctor: + desc: Run haft doctor health check + cmds: + - go run ./cmd/haft doctor + + desktop: + desc: Run Haft desktop app (wails dev with hot reload) + dir: desktop + cmds: + - wails dev + + desktop:build: + desc: Build Haft desktop app (production .app bundle) + dir: desktop + cmds: + - wails build + - echo "Built desktop/build/bin/haft.app" + + desktop:open: + desc: Build and open Haft desktop app + deps: [desktop:build] + cmds: + - open desktop/build/bin/haft.app clean: - desc: Remove local build binary - dir: '{{.SRC_DIR}}' + desc: Remove local build artifacts cmds: - rm -f {{.BIN_NAME}} + - rm -rf tui/dist cleanup: - desc: Remove all built binaries (local + installed) + desc: Remove all built binaries and installed files cmds: - - rm -f {{.SRC_DIR}}/{{.BIN_NAME}} + - rm -f {{.BIN_NAME}} + - rm -rf tui/dist - rm -f {{.BIN_DIR}}/{{.BIN_NAME}} - - echo "Removed {{.SRC_DIR}}/{{.BIN_NAME}} and {{.BIN_DIR}}/{{.BIN_NAME}}" + - rm -rf {{.TUI_INSTALL_DIR}} + - echo "Cleaned up" diff --git a/assets/banner.svg b/assets/banner.svg index b72b76ea..748d109d 100644 --- a/assets/banner.svg +++ b/assets/banner.svg @@ -1,17 +1,14 @@ - + - ██████╗ ██╗ ██╗██╗███╗ ██╗████████╗ ██████╗ ██████╗ ██████╗ ███████╗ - ██╔═══██╗██║ ██║██║████╗ ██║╚══██╔══╝ ██╔════╝██╔═══██╗██╔══██╗██╔════╝ - ██║ ██║██║ ██║██║██╔██╗ ██║ ██║ ██║ ██║ ██║██║ ██║█████╗ - ██║▄▄ ██║██║ ██║██║██║╚██╗██║ ██║ ██║ ██║ ██║██║ ██║██╔══╝ - ╚██████╔╝╚██████╔╝██║██║ ╚████║ ██║ ╚██████╗╚██████╔╝██████╔╝███████╗ - ╚══▀▀═╝ ╚═════╝ ╚═╝╚═╝ ╚═══╝ ╚═╝ ╚═════╝ ╚═════╝ ╚═════╝ ╚══════╝ - - Distilled First Principles Framework for AI Tools - \ No newline at end of file + ██╗ ██╗ █████╗ ███████╗████████╗ + ██║ ██║██╔══██╗██╔════╝╚══██╔══╝ + ███████║███████║█████╗ ██║ + ██╔══██║██╔══██║██╔══╝ ██║ + ██║ ██║██║ ██║██║ ██║ + ╚═╝ ╚═╝╚═╝ ╚═╝╚═╝ ╚═╝ + diff --git a/assurance/calculator.go b/assurance/calculator.go new file mode 100644 index 00000000..a722e76f --- /dev/null +++ b/assurance/calculator.go @@ -0,0 +1,244 @@ +package assurance + +import ( + "context" + "database/sql" + "math" + "strings" + "time" + + "github.com/m0n0x41d/haft/internal/reff" +) + +type AssuranceReport struct { + HolonID string + FinalScore float64 + SelfScore float64 // Score based on own evidence + FormalityScore int // F_eff = min(F_i) after normalizing legacy data to F0-F3 + WeakestLink string // ID of the dependency pulling the score down + DecayPenalty float64 + Factors []string // Textual explanations for AI +} + +type Calculator struct { + DB *sql.DB +} + +func New(db *sql.DB) *Calculator { + return &Calculator{DB: db} +} + +func (c *Calculator) CalculateReliability(ctx context.Context, holonID string) (*AssuranceReport, error) { + visited := make(map[string]bool) + return c.calculateReliabilityWithVisited(ctx, holonID, visited) +} + +func (c *Calculator) calculateReliabilityWithVisited(ctx context.Context, holonID string, visited map[string]bool) (*AssuranceReport, error) { + // Cycle detection: return neutral (1.0) to break cycle without penalizing + if visited[holonID] { + return &AssuranceReport{ + HolonID: holonID, + FinalScore: 1.0, // Neutral - don't penalize for cycle + SelfScore: 1.0, + Factors: []string{"Cycle detected, skipping re-evaluation"}, + }, nil + } + visited[holonID] = true + + report := &AssuranceReport{HolonID: holonID} + now := time.Now().UTC() + + // 1. Calculate Self Score (based on Evidence) + // B.3.4: Check for expired evidence + congruence penalty + // C.2.3: F_eff = min(F_i) for all evidence (Formality level) + rows, err := c.DB.QueryContext(ctx, + `SELECT id, type, verdict, COALESCE(valid_until, ''), COALESCE(formality_level, 5), COALESCE(congruence_level, -1) + FROM evidence WHERE holon_id = ?`, holonID) + if err != nil { + return nil, err + } + defer func() { _ = rows.Close() }() + + minScore := 1.0 + minFormality := 3 + var hasEvidence bool + for rows.Next() { + var evidenceID, evidenceType, verdict string + var validUntil string + var formalityLevel int + var congruenceLevel int + if err := rows.Scan(&evidenceID, &evidenceType, &verdict, &validUntil, &formalityLevel, &congruenceLevel); err != nil { + continue + } + hasEvidence = true + _ = evidenceID // Used for potential future logging + + effectiveCL := resolveEvidenceCongruenceLevel(evidenceType, congruenceLevel) + score := reff.ScoreTypedEvidence(evidenceType, verdict, effectiveCL, validUntil, now) + + if effectiveCL < 3 { + report.Factors = append(report.Factors, congruencePenaltyFactor(evidenceType, congruenceLevel, effectiveCL)) + } + + // Evidence Decay Logic (B.3.4: time-based expiration) + if expiry, ok := reff.ParseValidUntil(validUntil); ok && now.After(expiry) { + report.Factors = append(report.Factors, "Evidence expired (Decay applied)") + score = 0.1 // Penalty for expiration, not zero but close + report.DecayPenalty += 0.9 // Track how much was lost + } + + // WLNK: weakest evidence determines self score + if score < minScore { + minScore = score + } + + normalizedFormality := normalizeFormalityLevel(formalityLevel) + if normalizedFormality < minFormality { + minFormality = normalizedFormality + } + } + + if hasEvidence { + report.SelfScore = minScore + report.FormalityScore = minFormality + } else { + report.SelfScore = 0.0 + report.FormalityScore = 0 + report.Factors = append(report.Factors, "No evidence found (L0)") + } + + // 2. Calculate Dependencies Score (Weakest Link + CL Penalty) + // B.3: R_eff = max(0, min(R_dep) - Penalty(CL)) + // Relation directionality: + // - componentOf: Part → Whole (source is part OF target) + // - dependsOn / dependsOnProjected: source DEPENDS ON target + // When calculating reliability for holonID: + // - componentOf: find rows where target_id = holonID, dependency is source_id + // - dependsOn*: find rows where source_id = holonID, dependency is target_id + depRows, err := c.DB.QueryContext(ctx, ` + SELECT source_id AS dep_id, congruence_level FROM relations + WHERE target_id = ? AND relation_type = 'componentOf' + UNION + SELECT target_id AS dep_id, congruence_level FROM relations + WHERE source_id = ? AND relation_type IN ('dependsOn', 'dependsOnProjected')`, holonID, holonID) + + if err != nil { + return nil, err + } + + // Collect deps first to avoid holding cursor during recursive calls + type dep struct { + id string + cl int + } + var deps []dep + for depRows.Next() { + var d dep + if err := depRows.Scan(&d.id, &d.cl); err != nil { + continue + } + deps = append(deps, d) + } + _ = depRows.Close() + + minDepScore := 1.0 + for _, d := range deps { + depReport, err := c.calculateReliabilityWithVisited(ctx, d.id, visited) + if err != nil { + depReport = &AssuranceReport{FinalScore: 0.0} + } + + // CL Penalty: CL=3 (0.0), CL=2 (0.1), CL=1 (0.4), CL=0 (0.9) + penalty := calculateCLPenalty(d.cl) + effectiveR := math.Max(0, depReport.FinalScore-penalty) + + if effectiveR < minDepScore { + minDepScore = effectiveR + report.WeakestLink = d.id + } + + if penalty > 0 { + report.Factors = append(report.Factors, "CL Penalty applied for "+d.id) + } + } + + hasDeps := len(deps) > 0 + + // 3. Weakest Link Principle (WLNK) + // The final rating cannot be higher than the weakest link (self or dependency) + if hasDeps { + report.FinalScore = math.Min(report.SelfScore, minDepScore) + } else { + report.FinalScore = report.SelfScore + } + + if _, err := c.DB.ExecContext(ctx, "UPDATE holons SET cached_r_score = ? WHERE id = ?", report.FinalScore, holonID); err != nil { + report.Factors = append(report.Factors, "Warning: cache update failed") + } + + return report, nil +} + +func calculateCLPenalty(cl int) float64 { + switch cl { + case 3: + return 0.0 + case 2: + return 0.1 + case 1: + return 0.4 + default: + return 0.9 + } +} + +// evidenceTypeToCLPenalty maps evidence source type to congruence penalty. +// internal/audit_report = CL3 (same context, no penalty) +// external = CL2 (similar context, 10% penalty) +// research = CL1 (different context, 40% penalty) +func evidenceTypeToCongruenceLevel(evidenceType string) int { + switch strings.ToLower(evidenceType) { + case "internal", "audit_report": + return 3 + case "external": + return 2 + case "research": + return 1 + default: + return 3 + } +} + +func resolveEvidenceCongruenceLevel(evidenceType string, stored int) int { + if stored >= 0 && stored <= 3 { + return stored + } + return evidenceTypeToCongruenceLevel(evidenceType) +} + +func congruencePenaltyFactor(evidenceType string, stored int, effective int) string { + if stored < 0 { + switch strings.ToLower(evidenceType) { + case "external": + return "External evidence CL2 penalty applied" + case "research": + return "Research evidence CL1 penalty applied" + } + } + return "Evidence congruence penalty applied" +} + +func normalizeFormalityLevel(level int) int { + switch { + case level < 0: + return 0 + case level <= 3: + return level + case level <= 5: + return 1 + case level <= 8: + return 2 + default: + return 3 + } +} diff --git a/assurance/calculator_test.go b/assurance/calculator_test.go new file mode 100644 index 00000000..f1904c51 --- /dev/null +++ b/assurance/calculator_test.go @@ -0,0 +1,351 @@ +package assurance + +import ( + "context" + "database/sql" + "testing" + "time" + + _ "modernc.org/sqlite" +) + +func setupTestDB(t *testing.T) *sql.DB { + // Use cache=shared to share DB across connections in the pool + db, err := sql.Open("sqlite", "file:memdb1?mode=memory&cache=shared") + if err != nil { + t.Fatalf("failed to open db: %v", err) + } + db.SetMaxOpenConns(1) // Ensure single connection to avoid issues + + schema := ` + CREATE TABLE holons (id TEXT PRIMARY KEY, cached_r_score REAL DEFAULT 0.0); + CREATE TABLE evidence ( + id TEXT PRIMARY KEY, + holon_id TEXT, + type TEXT, + verdict TEXT, + valid_until DATETIME, + formality_level INTEGER DEFAULT 5, + congruence_level INTEGER + ); + CREATE TABLE relations (source_id TEXT, target_id TEXT, relation_type TEXT, congruence_level INTEGER); + ` + if _, err := db.Exec(schema); err != nil { + t.Fatalf("failed to init schema: %v", err) + } + return db +} + +func TestCalculateReliability_SelfScore(t *testing.T) { + db := setupTestDB(t) + defer db.Close() + + // Insert evidence for holon A (PASS) + _, err := db.Exec("INSERT INTO evidence (id, holon_id, type, verdict, valid_until) VALUES ('e1', 'A', 'internal', 'pass', ?)", time.Now().Add(24*time.Hour)) + if err != nil { + t.Fatalf("failed to insert evidence: %v", err) + } + + calc := New(db) + report, err := calc.CalculateReliability(context.Background(), "A") + if err != nil { + t.Fatalf("CalculateReliability failed: %v", err) + } + + if report.FinalScore != 1.0 { + t.Errorf("Expected score 1.0, got %f", report.FinalScore) + } +} + +func TestCalculateReliability_NullValidUntil(t *testing.T) { + db := setupTestDB(t) + defer db.Close() + + // Insert evidence with NULL valid_until (perpetual - no decay) + _, err := db.Exec("INSERT INTO evidence (id, holon_id, type, verdict, valid_until) VALUES ('e1', 'A', 'internal', 'pass', NULL)") + if err != nil { + t.Fatalf("failed to insert evidence: %v", err) + } + + calc := New(db) + report, err := calc.CalculateReliability(context.Background(), "A") + if err != nil { + t.Fatalf("CalculateReliability failed: %v", err) + } + + // NULL valid_until = perpetual evidence, no decay penalty + if report.FinalScore != 1.0 { + t.Errorf("Expected score 1.0 for NULL valid_until (perpetual), got %f", report.FinalScore) + } + if report.DecayPenalty != 0.0 { + t.Errorf("Expected DecayPenalty 0.0, got %f", report.DecayPenalty) + } +} + +func TestCalculateReliability_EvidenceDecay(t *testing.T) { + db := setupTestDB(t) + defer db.Close() + + // Insert expired evidence for holon A + expired := time.Now().Add(-24 * time.Hour) + _, err := db.Exec("INSERT INTO evidence (id, holon_id, type, verdict, valid_until) VALUES ('e1', 'A', 'internal', 'pass', ?)", expired) + if err != nil { + t.Fatalf("failed to insert evidence: %v", err) + } + + calc := New(db) + report, err := calc.CalculateReliability(context.Background(), "A") + if err != nil { + t.Fatalf("CalculateReliability failed: %v", err) + } + + // Should be penalized (0.1) + if report.FinalScore != 0.1 { + t.Errorf("Expected score 0.1 due to decay, got %f", report.FinalScore) + } +} + +func TestCalculateReliability_WeakestLink(t *testing.T) { + db := setupTestDB(t) + defer db.Close() + + _, _ = db.Exec("INSERT INTO evidence (id, holon_id, type, verdict, valid_until) VALUES ('e1', 'A', 'internal', 'pass', ?)", time.Now().Add(24*time.Hour)) + _, _ = db.Exec("INSERT INTO evidence (id, holon_id, type, verdict, valid_until) VALUES ('e2', 'B', 'internal', 'fail', ?)", time.Now().Add(24*time.Hour)) + + // B is component of A + _, _ = db.Exec("INSERT INTO relations (source_id, target_id, relation_type, congruence_level) VALUES ('B', 'A', 'componentOf', 3)") + + calc := New(db) + report, err := calc.CalculateReliability(context.Background(), "A") + if err != nil { + t.Fatalf("CalculateReliability failed: %v", err) + } + + // B has 0.0. A has 1.0. Weakest link is B. Result should be 0.0. + if report.FinalScore != 0.0 { + t.Errorf("Expected score 0.0 (weakest link), got %f", report.FinalScore) + } +} + +func TestCalculateReliability_CLPenalty(t *testing.T) { + db := setupTestDB(t) + defer db.Close() + + _, _ = db.Exec("INSERT INTO evidence (id, holon_id, type, verdict, valid_until) VALUES ('e1', 'A', 'internal', 'pass', ?)", time.Now().Add(24*time.Hour)) + _, _ = db.Exec("INSERT INTO evidence (id, holon_id, type, verdict, valid_until) VALUES ('e2', 'B', 'internal', 'pass', ?)", time.Now().Add(24*time.Hour)) + + _, _ = db.Exec("INSERT INTO relations (source_id, target_id, relation_type, congruence_level) VALUES ('B', 'A', 'componentOf', 1)") + + calc := New(db) + report, err := calc.CalculateReliability(context.Background(), "A") + if err != nil { + t.Fatalf("CalculateReliability failed: %v", err) + } + + if report.FinalScore != 0.6 { + t.Errorf("Expected score 0.6 (CL penalty), got %f", report.FinalScore) + } +} + +func TestCalculateReliability_CycleDetection(t *testing.T) { + db := setupTestDB(t) + defer db.Close() + + // Create A→B→C→A cycle via componentOf relations + // A contains B, B contains C, C contains A (circular) + _, _ = db.Exec("INSERT INTO evidence (id, holon_id, type, verdict, valid_until) VALUES ('e1', 'A', 'internal', 'pass', ?)", time.Now().Add(24*time.Hour)) + _, _ = db.Exec("INSERT INTO evidence (id, holon_id, type, verdict, valid_until) VALUES ('e2', 'B', 'internal', 'pass', ?)", time.Now().Add(24*time.Hour)) + _, _ = db.Exec("INSERT INTO evidence (id, holon_id, type, verdict, valid_until) VALUES ('e3', 'C', 'internal', 'pass', ?)", time.Now().Add(24*time.Hour)) + + // B is component of A, C is component of B, A is component of C (cycle!) + _, _ = db.Exec("INSERT INTO relations (source_id, target_id, relation_type, congruence_level) VALUES ('B', 'A', 'componentOf', 3)") + _, _ = db.Exec("INSERT INTO relations (source_id, target_id, relation_type, congruence_level) VALUES ('C', 'B', 'componentOf', 3)") + _, _ = db.Exec("INSERT INTO relations (source_id, target_id, relation_type, congruence_level) VALUES ('A', 'C', 'componentOf', 3)") + + calc := New(db) + report, err := calc.CalculateReliability(context.Background(), "A") + + // Should not error or hang - cycle should be detected and handled gracefully + if err != nil { + t.Fatalf("CalculateReliability failed on cycle: %v", err) + } + + // All have passing evidence, no CL penalty, cycle should not affect final score + // Each node has self-score 1.0, and deps also 1.0 (cycle broken by visited check) + if report.FinalScore != 1.0 { + t.Errorf("Expected score 1.0 (cycle handled gracefully), got %f", report.FinalScore) + } +} + +func TestCalculateReliability_ExternalEvidencePenalty(t *testing.T) { + db := setupTestDB(t) + defer db.Close() + + // Insert external evidence for holon A (should get CL2 penalty: 10%) + _, err := db.Exec("INSERT INTO evidence (id, holon_id, type, verdict, valid_until) VALUES ('e1', 'A', 'external', 'pass', ?)", time.Now().Add(24*time.Hour)) + if err != nil { + t.Fatalf("failed to insert evidence: %v", err) + } + + calc := New(db) + report, err := calc.CalculateReliability(context.Background(), "A") + if err != nil { + t.Fatalf("CalculateReliability failed: %v", err) + } + + // External evidence should have 10% penalty: 1.0 - 0.1 = 0.9 + if report.FinalScore != 0.9 { + t.Errorf("Expected score 0.9 (external CL2 penalty), got %f", report.FinalScore) + } + + // Check that the penalty factor was recorded + hasPenaltyFactor := false + for _, f := range report.Factors { + if f == "External evidence CL2 penalty applied" { + hasPenaltyFactor = true + break + } + } + if !hasPenaltyFactor { + t.Errorf("Expected 'External evidence CL2 penalty applied' in factors, got %v", report.Factors) + } +} + +func TestCalculateReliability_MixedEvidenceWLNK(t *testing.T) { + db := setupTestDB(t) + defer db.Close() + + // Insert both internal and external evidence for holon A + // WLNK should use the weaker one (external with penalty) + _, _ = db.Exec("INSERT INTO evidence (id, holon_id, type, verdict, valid_until) VALUES ('e1', 'A', 'internal', 'pass', ?)", time.Now().Add(24*time.Hour)) + _, _ = db.Exec("INSERT INTO evidence (id, holon_id, type, verdict, valid_until) VALUES ('e2', 'A', 'external', 'pass', ?)", time.Now().Add(24*time.Hour)) + + calc := New(db) + report, err := calc.CalculateReliability(context.Background(), "A") + if err != nil { + t.Fatalf("CalculateReliability failed: %v", err) + } + + // WLNK: min(1.0 internal, 0.9 external) = 0.9 + if report.FinalScore != 0.9 { + t.Errorf("Expected score 0.9 (WLNK on mixed evidence), got %f", report.FinalScore) + } +} + +func TestCalculateReliability_AttachedEvidencePreservesDedicatedBaseScore(t *testing.T) { + db := setupTestDB(t) + defer db.Close() + + _, err := db.Exec( + "INSERT INTO evidence (id, holon_id, type, verdict, valid_until, congruence_level) VALUES ('e1', 'A', 'attached', 'partial', ?, 1)", + time.Now().Add(24*time.Hour), + ) + if err != nil { + t.Fatalf("failed to insert attached evidence: %v", err) + } + + calc := New(db) + report, err := calc.CalculateReliability(context.Background(), "A") + if err != nil { + t.Fatalf("CalculateReliability failed: %v", err) + } + + if report.FinalScore != 0.3 { + t.Errorf("Expected score 0.3 for attached evidence, got %f", report.FinalScore) + } +} + +func TestCalculateReliability_FormalityNormalization(t *testing.T) { + db := setupTestDB(t) + defer db.Close() + + _, err := db.Exec( + "INSERT INTO evidence (id, holon_id, type, verdict, valid_until, formality_level) VALUES ('e1', 'A', 'internal', 'pass', ?, 7)", + time.Now().Add(24*time.Hour), + ) + if err != nil { + t.Fatalf("failed to insert evidence: %v", err) + } + + calc := New(db) + report, err := calc.CalculateReliability(context.Background(), "A") + if err != nil { + t.Fatalf("CalculateReliability failed: %v", err) + } + + if report.FormalityScore != 2 { + t.Errorf("Expected normalized formality score 2, got %d", report.FormalityScore) + } +} + +func TestCalculateReliability_PrefersPersistedCongruenceLevel(t *testing.T) { + db := setupTestDB(t) + defer db.Close() + + _, err := db.Exec( + `INSERT INTO evidence (id, holon_id, type, verdict, valid_until, congruence_level) + VALUES ('e1', 'A', 'measurement', 'accepted', ?, 1)`, + time.Now().Add(24*time.Hour), + ) + if err != nil { + t.Fatalf("failed to insert evidence: %v", err) + } + + calc := New(db) + report, err := calc.CalculateReliability(context.Background(), "A") + if err != nil { + t.Fatalf("CalculateReliability failed: %v", err) + } + + if report.FinalScore != 0.6 { + t.Errorf("Expected score 0.6 from persisted CL1 evidence, got %f", report.FinalScore) + } +} + +func TestCalculateReliability_DateOnlyValidity(t *testing.T) { + db := setupTestDB(t) + defer db.Close() + + _, err := db.Exec( + "INSERT INTO evidence (id, holon_id, type, verdict, valid_until, formality_level) VALUES ('e1', 'A', 'internal', 'pass', '2020-01-01', 2)", + ) + if err != nil { + t.Fatalf("failed to insert evidence: %v", err) + } + + calc := New(db) + report, err := calc.CalculateReliability(context.Background(), "A") + if err != nil { + t.Fatalf("CalculateReliability failed: %v", err) + } + + if report.FinalScore != 0.1 { + t.Errorf("Expected expired date-only evidence to decay to 0.1, got %f", report.FinalScore) + } +} + +func TestCalculateReliability_AcceptsArtifactVerdicts(t *testing.T) { + db := setupTestDB(t) + defer db.Close() + + _, err := db.Exec( + "INSERT INTO evidence (id, holon_id, type, verdict, valid_until, formality_level) VALUES ('e1', 'A', 'measurement', 'accepted', ?, 2)", + time.Now().Add(24*time.Hour), + ) + if err != nil { + t.Fatalf("failed to insert evidence: %v", err) + } + + calc := New(db) + report, err := calc.CalculateReliability(context.Background(), "A") + if err != nil { + t.Fatalf("CalculateReliability failed: %v", err) + } + + if report.FinalScore != 1.0 { + t.Errorf("Expected score 1.0 for accepted measurement, got %f", report.FinalScore) + } + if report.FormalityScore != 2 { + t.Errorf("Expected formality score 2, got %d", report.FormalityScore) + } +} diff --git a/cmd/haft/main.go b/cmd/haft/main.go new file mode 100644 index 00000000..04b73efe --- /dev/null +++ b/cmd/haft/main.go @@ -0,0 +1,7 @@ +package main + +import "github.com/m0n0x41d/haft/internal/cli" + +func main() { + cli.Execute() +} diff --git a/cmd/indexer/main.go b/cmd/indexer/main.go new file mode 100644 index 00000000..10ec9c2d --- /dev/null +++ b/cmd/indexer/main.go @@ -0,0 +1,109 @@ +package main + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "time" + + "github.com/m0n0x41d/haft/internal/fpf" + _ "modernc.org/sqlite" +) + +const routeArtifactPath = "internal/fpf/fpf-routes.json" + +func main() { + if err := run(); err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } +} + +func run() error { + if len(os.Args) < 2 { + return fmt.Errorf("usage: indexer [output.db] [fpf-commit-sha]") + } + + specPath := os.Args[1] + dbPath := filepath.Join("internal", "cli", "fpf.db") + if len(os.Args) >= 3 { + dbPath = os.Args[2] + } + commitSHA := "" + if len(os.Args) >= 4 { + commitSHA = os.Args[3] + } + + return buildIndex(specPath, dbPath, commitSHA, routeArtifactPath) +} + +func buildIndex(specPath, dbPath, commitSHA, routePath string) error { + corpus, err := fpf.LoadSpecIndexCorpus(specPath) + if err != nil { + return fmt.Errorf("load production spec corpus: %w", err) + } + + routes, err := fpf.LoadRoutes(routePath) + if err != nil { + return fmt.Errorf("loading routes: %w", err) + } + + if err := fpf.BuildSpecIndex(dbPath, corpus.Indexed, routes); err != nil { + return fmt.Errorf("building index: %w", err) + } + + metadata := buildSpecIndexMetadata(specPath, len(corpus.Indexed), commitSHA, time.Now().UTC()) + if err := fpf.SetSpecMetaEntries(dbPath, metadata); err != nil { + return fmt.Errorf("setting meta: %w", err) + } + + fmt.Printf("Indexed %d chunks (from %d total) into %s\n", len(corpus.Indexed), len(corpus.Chunks), dbPath) + return nil +} + +func buildSpecIndexMetadata(specPath string, indexedSections int, explicitCommit string, buildTime time.Time) map[string]string { + return map[string]string{ + "fpf_commit": resolveSpecCommit(explicitCommit, specPath), + "indexed_sections": fmt.Sprintf("%d", indexedSections), + "build_time": buildTime.UTC().Format(time.RFC3339), + "spec_path": filepath.Clean(specPath), + "schema_version": fpf.SpecIndexSchemaVersion, + } +} + +func resolveSpecCommit(explicitCommit, specPath string) string { + commit := strings.TrimSpace(explicitCommit) + if commit != "" { + return commit + } + + return detectSpecCommit(specPath) +} + +func detectSpecCommit(specPath string) string { + gitDir, err := specGitLookupDir(specPath) + if err != nil { + return "" + } + + cmd := exec.Command("git", "rev-parse", "HEAD") + cmd.Dir = gitDir + + output, err := cmd.Output() + if err != nil { + return "" + } + + return strings.TrimSpace(string(output)) +} + +func specGitLookupDir(specPath string) (string, error) { + absPath, err := filepath.Abs(specPath) + if err != nil { + return "", err + } + + return filepath.Dir(absPath), nil +} diff --git a/cmd/indexer/main_test.go b/cmd/indexer/main_test.go new file mode 100644 index 00000000..7466be56 --- /dev/null +++ b/cmd/indexer/main_test.go @@ -0,0 +1,144 @@ +package main + +import ( + "database/sql" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + "time" + + _ "modernc.org/sqlite" +) + +func TestResolveSpecCommit(t *testing.T) { + specPath := filepath.Join(t.TempDir(), "FPF-Spec.md") + + tests := []struct { + name string + explicitCommit string + want string + }{ + { + name: "empty", + explicitCommit: "", + want: "", + }, + { + name: "trimmed", + explicitCommit: " abc123 ", + want: "abc123", + }, + } + + for _, tt := range tests { + got := resolveSpecCommit(tt.explicitCommit, specPath) + if got != tt.want { + t.Fatalf("%s: resolveSpecCommit(%q) = %q, want %q", tt.name, tt.explicitCommit, got, tt.want) + } + } +} + +func TestResolveSpecCommit_DetectsGitCommitFromSpecPath(t *testing.T) { + repoDir := t.TempDir() + specDir := filepath.Join(repoDir, "data", "FPF") + specPath := filepath.Join(specDir, "FPF-Spec.md") + + if err := os.MkdirAll(specDir, 0o755); err != nil { + t.Fatalf("mkdir spec dir: %v", err) + } + if err := os.WriteFile(specPath, []byte("# spec\n"), 0o644); err != nil { + t.Fatalf("write spec: %v", err) + } + + runGit(t, repoDir, "init") + runGit(t, repoDir, "config", "user.email", "test@example.com") + runGit(t, repoDir, "config", "user.name", "Test User") + runGit(t, repoDir, "add", ".") + runGit(t, repoDir, "commit", "-m", "init") + + want := strings.TrimSpace(runGit(t, repoDir, "rev-parse", "HEAD")) + got := resolveSpecCommit("", specPath) + + if got != want { + t.Fatalf("resolveSpecCommit() = %q, want %q", got, want) + } +} + +func TestBuildSpecIndexMetadata_LeavesCommitEmptyOutsideGit(t *testing.T) { + buildTime := time.Date(2026, time.March, 26, 12, 34, 56, 0, time.UTC) + specPath := filepath.Join(t.TempDir(), "FPF-Spec.md") + metadata := buildSpecIndexMetadata(specPath, 42, "", buildTime) + + if metadata["fpf_commit"] != "" { + t.Fatalf("expected empty fpf_commit outside git, got %q", metadata["fpf_commit"]) + } + if metadata["indexed_sections"] != "42" { + t.Fatalf("unexpected indexed_sections %q", metadata["indexed_sections"]) + } +} + +func runGit(t *testing.T, dir string, args ...string) string { + t.Helper() + + cmd := exec.Command("git", args...) + cmd.Dir = dir + + output, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("git %s: %v\n%s", strings.Join(args, " "), err, output) + } + + return string(output) +} + +func TestBuildIndex_PreservesHeadingOnlyRootPatternShells(t *testing.T) { + tempDir := t.TempDir() + specPath := filepath.Join(tempDir, "FPF-Spec.md") + dbPath := filepath.Join(tempDir, "fpf.db") + routePath := filepath.Join(tempDir, "routes.json") + + spec := `## A.17 - Canonical “Characteristic” (A.CHR-NORM) + +### A.17:1 - Context + +To have reproducibility and explainability there is a need to measure various aspects of systems or knowledge artifacts. +` + routes := `{"routes":[]}` + + if err := os.WriteFile(specPath, []byte(spec), 0o644); err != nil { + t.Fatalf("write spec: %v", err) + } + if err := os.WriteFile(routePath, []byte(routes), 0o644); err != nil { + t.Fatalf("write routes: %v", err) + } + + if err := buildIndex(specPath, dbPath, "", routePath); err != nil { + t.Fatalf("buildIndex() error: %v", err) + } + + db, err := sql.Open("sqlite", dbPath) + if err != nil { + t.Fatalf("open db: %v", err) + } + defer db.Close() + + var count int + err = db.QueryRow(`SELECT count(*) FROM sections WHERE pattern_id = ?`, "A.17").Scan(&count) + if err != nil { + t.Fatalf("count A.17: %v", err) + } + if count != 1 { + t.Fatalf("expected A.17 root shell in built index, got count %d", count) + } + + var aliasesJSON string + err = db.QueryRow(`SELECT aliases_json FROM sections WHERE pattern_id = ?`, "A.17").Scan(&aliasesJSON) + if err != nil { + t.Fatalf("read aliases_json: %v", err) + } + if !strings.Contains(aliasesJSON, "A.CHR-NORM") { + t.Fatalf("expected technical alias in aliases_json, got %q", aliasesJSON) + } +} diff --git a/data/FPF b/data/FPF index 3f88103b..0dcd0514 160000 --- a/data/FPF +++ b/data/FPF @@ -1 +1 @@ -Subproject commit 3f88103b2825cccdb372e988f867c476dd01b5c8 +Subproject commit 0dcd0514c4ff2834a049f2da9e4f2d20ebe83e52 diff --git a/src/mcp/db/db.go b/db/db.go similarity index 100% rename from src/mcp/db/db.go rename to db/db.go diff --git a/db/migrate.go b/db/migrate.go new file mode 100644 index 00000000..e7666c0f --- /dev/null +++ b/db/migrate.go @@ -0,0 +1,77 @@ +package db + +import ( + "database/sql" + "fmt" + "strings" +) + +// Migration defines a single versioned schema change. +type Migration struct { + Version int + Description string + Statements []string // executed sequentially within the version +} + +// Migrate applies all pending migrations to the database. +// Tracks applied versions in the given table name (e.g., "schema_version"). +// Skips already-applied versions. Idempotent for ALTER TABLE / CREATE TABLE +// statements (catches "duplicate column" and "already exists" errors). +// +// Portable: uses only standard SQL (CREATE TABLE IF NOT EXISTS, INSERT, SELECT). +func Migrate(conn *sql.DB, versionTable string, migrations []Migration) error { + // Ensure version tracking table exists + _, err := conn.Exec(fmt.Sprintf( + `CREATE TABLE IF NOT EXISTS %s (version INTEGER PRIMARY KEY, applied_at TEXT DEFAULT CURRENT_TIMESTAMP)`, + versionTable, + )) + if err != nil { + return fmt.Errorf("create %s table: %w", versionTable, err) + } + + for _, m := range migrations { + // Skip already-applied migrations + var exists int + row := conn.QueryRow( + fmt.Sprintf("SELECT 1 FROM %s WHERE version = ?", versionTable), + m.Version, + ) + if row.Scan(&exists) == nil && exists == 1 { + continue + } + + // Execute all statements for this migration + for _, stmt := range m.Statements { + stmt = strings.TrimSpace(stmt) + if stmt == "" { + continue + } + if _, execErr := conn.Exec(stmt); execErr != nil { + if !isIdempotentError(execErr) { + return fmt.Errorf("migration %d (%s) failed: %w", m.Version, m.Description, execErr) + } + } + } + + // Record applied version + if _, err := conn.Exec( + fmt.Sprintf("INSERT INTO %s (version) VALUES (?)", versionTable), + m.Version, + ); err != nil { + return fmt.Errorf("record migration %d: %w", m.Version, err) + } + } + + return nil +} + +// isIdempotentError returns true for errors that mean "already done" — +// safe to ignore when re-running migrations on existing databases. +func isIdempotentError(err error) bool { + if err == nil { + return false + } + msg := err.Error() + return strings.Contains(msg, "duplicate column") || + strings.Contains(msg, "already exists") +} diff --git a/db/migrations.go b/db/migrations.go new file mode 100644 index 00000000..9c8bc428 --- /dev/null +++ b/db/migrations.go @@ -0,0 +1,393 @@ +package db + +import "database/sql" + +// RunMigrations applies all pending kernel migrations to the database. +// Uses the shared Migrate runner with version tracking. +func RunMigrations(conn *sql.DB) error { + return Migrate(conn, "schema_version", kernelMigrations) +} + +// kernelMigrations defines the kernel schema evolution. +// Each migration has a version, description, and list of SQL statements. +// Append new migrations at the end. Never modify or reorder existing ones. +var kernelMigrations = []Migration{ + { + Version: 1, + Description: "Add parent_id to holons for L0->L1->L2 chain tracking", + Statements: []string{`ALTER TABLE holons ADD COLUMN parent_id TEXT REFERENCES holons(id)`}, + }, + { + Version: 2, + Description: "Add cached_r_score to holons for trust calculus", + Statements: []string{`ALTER TABLE holons ADD COLUMN cached_r_score REAL DEFAULT 0.0`}, + }, + { + Version: 3, + Description: "Add fpf_state table for FSM state", + Statements: []string{`CREATE TABLE IF NOT EXISTS fpf_state ( + context_id TEXT PRIMARY KEY, + active_role TEXT, + active_session_id TEXT, + active_role_context TEXT, + last_commit TEXT, + assurance_threshold REAL DEFAULT 0.8 CHECK(assurance_threshold BETWEEN 0.0 AND 1.0), + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP + )`}, + }, + { + Version: 4, + Description: "Add FTS5 tables for full-text search", + Statements: []string{ + `CREATE VIRTUAL TABLE IF NOT EXISTS holons_fts USING fts5( + id, title, content, content='holons', content_rowid='rowid')`, + `CREATE VIRTUAL TABLE IF NOT EXISTS evidence_fts USING fts5( + id, content, content='evidence', content_rowid='rowid')`, + `INSERT INTO holons_fts(holons_fts) VALUES('rebuild')`, + `INSERT INTO evidence_fts(evidence_fts) VALUES('rebuild')`, + `DROP TRIGGER IF EXISTS holons_ai`, + `CREATE TRIGGER holons_ai AFTER INSERT ON holons BEGIN + INSERT INTO holons_fts(rowid, id, title, content) VALUES (new.rowid, new.id, new.title, new.content); + END`, + `DROP TRIGGER IF EXISTS holons_ad`, + `CREATE TRIGGER holons_ad AFTER DELETE ON holons BEGIN + INSERT INTO holons_fts(holons_fts, rowid, id, title, content) VALUES('delete', old.rowid, old.id, old.title, old.content); + END`, + `DROP TRIGGER IF EXISTS holons_au`, + `CREATE TRIGGER holons_au AFTER UPDATE ON holons BEGIN + INSERT INTO holons_fts(holons_fts, rowid, id, title, content) VALUES('delete', old.rowid, old.id, old.title, old.content); + INSERT INTO holons_fts(rowid, id, title, content) VALUES (new.rowid, new.id, new.title, new.content); + END`, + `DROP TRIGGER IF EXISTS evidence_ai`, + `CREATE TRIGGER evidence_ai AFTER INSERT ON evidence BEGIN + INSERT INTO evidence_fts(rowid, id, content) VALUES (new.rowid, new.id, new.content); + END`, + `DROP TRIGGER IF EXISTS evidence_ad`, + `CREATE TRIGGER evidence_ad AFTER DELETE ON evidence BEGIN + INSERT INTO evidence_fts(evidence_fts, rowid, id, content) VALUES('delete', old.rowid, old.id, old.content); + END`, + `DROP TRIGGER IF EXISTS evidence_au`, + `CREATE TRIGGER evidence_au AFTER UPDATE ON evidence BEGIN + INSERT INTO evidence_fts(evidence_fts, rowid, id, content) VALUES('delete', old.rowid, old.id, old.content); + INSERT INTO evidence_fts(rowid, id, content) VALUES (new.rowid, new.id, new.content); + END`, + }, + }, + { + Version: 5, + Description: "Auto-resolve legacy reset DRRs", + Statements: []string{ + `DROP TRIGGER IF EXISTS evidence_ai`, + `INSERT INTO evidence (id, holon_id, type, content, verdict, created_at) + SELECT 'migration-cleanup-' || id, id, 'abandonment', + 'Migrated: reset session marker, not a real decision.', 'PASS', CURRENT_TIMESTAMP + FROM holons + WHERE (type = 'DRR' OR layer = 'DRR') AND content LIKE '%No Decision%Reset%' + AND NOT EXISTS (SELECT 1 FROM evidence e WHERE e.holon_id = holons.id AND e.type IN ('implementation', 'abandonment', 'supersession'))`, + `INSERT INTO evidence_fts(evidence_fts) VALUES('rebuild')`, + `CREATE TRIGGER evidence_ai AFTER INSERT ON evidence BEGIN + INSERT INTO evidence_fts(rowid, id, content) VALUES (new.rowid, new.id, new.content); + END`, + }, + }, + { + Version: 6, + Description: "Add active_holons view", + Statements: []string{ + `CREATE VIEW IF NOT EXISTS active_holons AS + SELECT h.* FROM holons h + WHERE h.layer NOT IN ('invalid') + AND NOT EXISTS (SELECT 1 FROM relations r WHERE r.target_id = h.id AND r.relation_type IN ('selects', 'rejects', 'closes'))`, + }, + }, + { + Version: 7, + Description: "Code Change Awareness: staleness tracking", + Statements: []string{ + "ALTER TABLE evidence ADD COLUMN carrier_hash TEXT", + "ALTER TABLE evidence ADD COLUMN carrier_commit TEXT", + "ALTER TABLE evidence ADD COLUMN is_stale INTEGER DEFAULT 0", + "ALTER TABLE evidence ADD COLUMN stale_reason TEXT", + "ALTER TABLE evidence ADD COLUMN stale_since DATETIME", + "ALTER TABLE holons ADD COLUMN needs_reverification INTEGER DEFAULT 0", + "ALTER TABLE holons ADD COLUMN reverification_reason TEXT", + "ALTER TABLE holons ADD COLUMN reverification_since DATETIME", + "ALTER TABLE fpf_state ADD COLUMN last_commit_at DATETIME", + "CREATE INDEX IF NOT EXISTS idx_evidence_carrier ON evidence(carrier_ref)", + "CREATE INDEX IF NOT EXISTS idx_evidence_stale ON evidence(is_stale)", + "CREATE INDEX IF NOT EXISTS idx_holons_reverification ON holons(needs_reverification)", + }, + }, + { + Version: 8, + Description: "Decision Contexts: context_status and updated active_holons view", + Statements: []string{ + "ALTER TABLE holons ADD COLUMN context_status TEXT DEFAULT NULL", + "CREATE INDEX IF NOT EXISTS idx_holons_context_status ON holons(context_status)", + "CREATE INDEX IF NOT EXISTS idx_relations_memberof ON relations(target_id, relation_type)", + "DROP VIEW IF EXISTS active_holons", + `CREATE VIEW active_holons AS + SELECT h.* FROM holons h + WHERE h.layer NOT IN ('invalid') AND h.type != 'context' + AND (h.context_status IS NULL OR h.context_status = 'open') + AND NOT EXISTS (SELECT 1 FROM relations r WHERE r.target_id = h.id AND r.relation_type IN ('selects', 'rejects', 'closes'))`, + }, + }, + { + Version: 9, + Description: "Predictions tracking for L1-L2 enforcement", + Statements: []string{ + `CREATE TABLE IF NOT EXISTS predictions ( + id TEXT PRIMARY KEY, holon_id TEXT NOT NULL, content TEXT NOT NULL, + covered INTEGER DEFAULT 0, covered_by TEXT, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY(holon_id) REFERENCES holons(id), FOREIGN KEY(covered_by) REFERENCES evidence(id))`, + "CREATE INDEX IF NOT EXISTS idx_predictions_holon ON predictions(holon_id)", + "CREATE INDEX IF NOT EXISTS idx_predictions_uncovered ON predictions(holon_id) WHERE covered = 0", + }, + }, + { + Version: 10, + Description: "Formality level on evidence for F-G-R triad", + Statements: []string{"ALTER TABLE evidence ADD COLUMN formality_level INTEGER DEFAULT 5"}, + }, + { + Version: 11, + Description: "Approach type on holons for NQD-CAL diversity", + Statements: []string{ + "ALTER TABLE holons ADD COLUMN approach_type TEXT DEFAULT NULL", + "CREATE INDEX IF NOT EXISTS idx_holons_approach_type ON holons(approach_type)", + }, + }, + { + Version: 12, + Description: "Context facts for context.md projection", + Statements: []string{ + `CREATE TABLE IF NOT EXISTS context_facts ( + category TEXT PRIMARY KEY, content TEXT NOT NULL, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP)`, + }, + }, + { + Version: 13, + Description: "v5 artifact model", + Statements: []string{ + `CREATE TABLE IF NOT EXISTS artifacts ( + 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)`, + `CREATE TABLE IF NOT EXISTS artifact_links ( + source_id TEXT NOT NULL REFERENCES artifacts(id), target_id TEXT NOT NULL REFERENCES artifacts(id), + link_type TEXT NOT NULL, created_at TEXT NOT NULL, PRIMARY KEY (source_id, target_id, link_type))`, + `CREATE TABLE IF NOT EXISTS evidence_items ( + id TEXT PRIMARY KEY, artifact_ref TEXT NOT NULL REFERENCES artifacts(id), + type TEXT NOT NULL, content TEXT NOT NULL, verdict TEXT, carrier_ref TEXT, + congruence_level INTEGER DEFAULT 3, formality_level INTEGER DEFAULT 5, + valid_until TEXT, created_at TEXT NOT NULL)`, + `CREATE TABLE IF NOT EXISTS affected_files ( + artifact_id TEXT NOT NULL REFERENCES artifacts(id), file_path TEXT NOT NULL, + file_hash TEXT, PRIMARY KEY (artifact_id, file_path))`, + `CREATE VIRTUAL TABLE IF NOT EXISTS artifacts_fts USING fts5( + id, title, content, kind, tokenize='porter unicode61')`, + `CREATE TRIGGER IF NOT EXISTS 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); + 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) VALUES (new.id, new.title, new.content, new.kind); + END`, + `CREATE TRIGGER IF NOT EXISTS artifacts_fts_delete AFTER DELETE ON artifacts BEGIN + DELETE FROM artifacts_fts WHERE id = old.id; + END`, + "CREATE INDEX IF NOT EXISTS idx_artifacts_kind ON artifacts(kind)", + "CREATE INDEX IF NOT EXISTS idx_artifacts_context ON artifacts(context)", + "CREATE INDEX IF NOT EXISTS idx_artifacts_status ON artifacts(status)", + "CREATE INDEX IF NOT EXISTS idx_artifact_links_target ON artifact_links(target_id, link_type)", + "CREATE INDEX IF NOT EXISTS idx_evidence_items_ref ON evidence_items(artifact_ref)", + "CREATE INDEX IF NOT EXISTS idx_affected_files_path ON affected_files(file_path)", + }, + }, + { + Version: 14, + Description: "Codebase awareness: module map and dependency graph", + Statements: []string{ + `CREATE TABLE IF NOT EXISTS codebase_modules ( + module_id TEXT PRIMARY KEY, path TEXT NOT NULL UNIQUE, name TEXT NOT NULL, + lang TEXT, file_count INTEGER DEFAULT 0, last_scanned TEXT NOT NULL)`, + "CREATE INDEX IF NOT EXISTS idx_codebase_modules_path ON codebase_modules(path)", + `CREATE TABLE IF NOT EXISTS module_dependencies ( + source_module TEXT NOT NULL, target_module TEXT NOT NULL, + dep_type TEXT NOT NULL DEFAULT 'import', file_path TEXT, last_scanned TEXT NOT NULL, + PRIMARY KEY (source_module, target_module, dep_type))`, + "CREATE INDEX IF NOT EXISTS idx_module_deps_target ON module_dependencies(target_module)", + }, + }, + { + Version: 15, + Description: "FTS5 enrichment: search_keywords column", + Statements: []string{ + "ALTER TABLE artifacts ADD COLUMN search_keywords TEXT DEFAULT ''", + "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`, + `INSERT INTO artifacts_fts(id, title, content, kind, search_keywords) + SELECT id, title, content, kind, COALESCE(search_keywords, '') FROM artifacts`, + }, + }, + { + Version: 16, + Description: "Structured fields: canonical data alongside markdown body", + Statements: []string{"ALTER TABLE artifacts ADD COLUMN structured_data TEXT DEFAULT ''"}, + }, + { + Version: 17, + Description: "Symbol-level baselines for tree-sitter powered drift detection", + Statements: []string{ + `CREATE TABLE IF NOT EXISTS affected_symbols ( + artifact_id TEXT NOT NULL REFERENCES artifacts(id), + file_path TEXT NOT NULL, + symbol_name TEXT NOT NULL, + symbol_kind TEXT NOT NULL, + symbol_line INTEGER, + symbol_end_line INTEGER, + symbol_hash TEXT, + PRIMARY KEY (artifact_id, file_path, symbol_name) + )`, + "CREATE INDEX IF NOT EXISTS idx_affected_symbols_file ON affected_symbols(file_path)", + "CREATE INDEX IF NOT EXISTS idx_affected_symbols_artifact ON affected_symbols(artifact_id)", + }, + }, + { + Version: 18, + Description: "Claim scope on persisted evidence", + Statements: []string{ + "ALTER TABLE evidence ADD COLUMN claim_scope TEXT DEFAULT '[]'", + "ALTER TABLE evidence_items ADD COLUMN claim_scope TEXT DEFAULT '[]'", + }, + }, + { + Version: 19, + Description: "Epistemic debt budget on shared FPF state", + Statements: []string{ + "ALTER TABLE fpf_state ADD COLUMN epistemic_debt_budget REAL DEFAULT 30.0", + }, + }, + { + Version: 20, + Description: "Congruence level on durable evidence", + Statements: []string{ + "ALTER TABLE evidence ADD COLUMN congruence_level INTEGER", + }, + }, + { + Version: 21, + Description: "Exact claim refs on persisted artifact evidence", + Statements: []string{ + "ALTER TABLE evidence_items ADD COLUMN claim_refs TEXT DEFAULT '[]'", + }, + }, + { + Version: 22, + Description: "Desktop runtime task persistence", + Statements: []string{ + `CREATE TABLE IF NOT EXISTS desktop_tasks ( + id TEXT PRIMARY KEY, + project_name TEXT NOT NULL, + project_path TEXT NOT NULL, + title TEXT NOT NULL, + agent TEXT NOT NULL, + status TEXT NOT NULL, + prompt TEXT NOT NULL, + branch TEXT NOT NULL DEFAULT '', + worktree INTEGER NOT NULL DEFAULT 0, + worktree_path TEXT NOT NULL DEFAULT '', + reused_worktree INTEGER NOT NULL DEFAULT 0, + error_message TEXT NOT NULL DEFAULT '', + output_tail TEXT NOT NULL DEFAULT '', + started_at TEXT NOT NULL, + completed_at TEXT, + updated_at TEXT NOT NULL, + archived_at TEXT + )`, + "CREATE INDEX IF NOT EXISTS idx_desktop_tasks_project_status ON desktop_tasks(project_path, status)", + "CREATE INDEX IF NOT EXISTS idx_desktop_tasks_started_at ON desktop_tasks(started_at DESC)", + "CREATE INDEX IF NOT EXISTS idx_desktop_tasks_worktree_path ON desktop_tasks(worktree_path)", + }, + }, + { + Version: 23, + Description: "Desktop governance scan state and problem candidates", + Statements: []string{ + `CREATE TABLE IF NOT EXISTS desktop_governance_state ( + state_key TEXT PRIMARY KEY, + state_value TEXT NOT NULL DEFAULT '', + updated_at TEXT NOT NULL + )`, + `CREATE TABLE IF NOT EXISTS desktop_problem_candidates ( + id TEXT PRIMARY KEY, + title TEXT NOT NULL, + signal TEXT NOT NULL, + acceptance TEXT NOT NULL, + context TEXT NOT NULL DEFAULT 'desktop-governance', + category TEXT NOT NULL, + source_artifact_ref TEXT NOT NULL DEFAULT '', + source_title TEXT NOT NULL DEFAULT '', + status TEXT NOT NULL DEFAULT 'active', + problem_ref TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + )`, + "CREATE INDEX IF NOT EXISTS idx_desktop_problem_candidates_status ON desktop_problem_candidates(status, updated_at DESC)", + "CREATE INDEX IF NOT EXISTS idx_desktop_problem_candidates_source ON desktop_problem_candidates(source_artifact_ref, status)", + }, + }, + { + Version: 24, + Description: "Desktop automation flows", + Statements: []string{ + `CREATE TABLE IF NOT EXISTS desktop_flows ( + id TEXT PRIMARY KEY, + project_name TEXT NOT NULL, + project_path TEXT NOT NULL, + title TEXT NOT NULL, + description TEXT NOT NULL DEFAULT '', + template_id TEXT NOT NULL DEFAULT '', + agent TEXT NOT NULL, + prompt TEXT NOT NULL, + schedule TEXT NOT NULL, + branch TEXT NOT NULL DEFAULT '', + use_worktree INTEGER NOT NULL DEFAULT 0, + enabled INTEGER NOT NULL DEFAULT 1, + last_task_id TEXT NOT NULL DEFAULT '', + last_run_at TEXT, + next_run_at TEXT, + last_error TEXT NOT NULL DEFAULT '', + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + )`, + "CREATE INDEX IF NOT EXISTS idx_desktop_flows_project_enabled ON desktop_flows(project_path, enabled)", + "CREATE INDEX IF NOT EXISTS idx_desktop_flows_next_run ON desktop_flows(next_run_at)", + }, + }, + { + Version: 25, + Description: "Add auto_run column to desktop tasks", + Statements: []string{ + "ALTER TABLE desktop_tasks ADD COLUMN auto_run INTEGER NOT NULL DEFAULT 0", + }, + }, +} diff --git a/db/migrations_test.go b/db/migrations_test.go new file mode 100644 index 00000000..f5375bef --- /dev/null +++ b/db/migrations_test.go @@ -0,0 +1,125 @@ +package db + +import ( + "database/sql" + "path/filepath" + "testing" + + _ "modernc.org/sqlite" +) + +func TestRunMigrations_FreshDatabase(t *testing.T) { + tempDir := t.TempDir() + dbPath := filepath.Join(tempDir, "test.db") + + store, err := NewStore(dbPath) + if err != nil { + t.Fatalf("NewStore failed: %v", err) + } + defer store.Close() + + // Check schema_version table exists and has entries + var count int + err = store.conn.QueryRow("SELECT COUNT(*) FROM schema_version").Scan(&count) + if err != nil { + t.Fatalf("Failed to query schema_version: %v", err) + } + if count != len(kernelMigrations) { + t.Errorf("Expected %d kernelMigrations recorded, got %d", len(kernelMigrations), count) + } +} + +func TestRunMigrations_ExistingDatabase(t *testing.T) { + tempDir := t.TempDir() + dbPath := filepath.Join(tempDir, "test.db") + + // Create database with old schema (no parent_id, no cached_r_score) + conn, err := sql.Open("sqlite", dbPath) + if err != nil { + t.Fatalf("Failed to open db: %v", err) + } + + oldSchema := `CREATE TABLE holons ( + id TEXT PRIMARY KEY, + type TEXT NOT NULL, + kind TEXT, + layer TEXT NOT NULL, + title TEXT NOT NULL, + content TEXT NOT NULL, + context_id TEXT NOT NULL, + scope TEXT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP + )` + if _, err := conn.Exec(oldSchema); err != nil { + t.Fatalf("Failed to create old schema: %v", err) + } + conn.Close() + + // Now open with NewStore which runs kernelMigrations + store, err := NewStore(dbPath) + if err != nil { + t.Fatalf("NewStore failed: %v", err) + } + defer store.Close() + + // Verify new columns exist by querying them + var parentID sql.NullString + var cachedRScore sql.NullFloat64 + err = store.conn.QueryRow("SELECT parent_id, cached_r_score FROM holons LIMIT 1").Scan(&parentID, &cachedRScore) + // Will get sql.ErrNoRows since table is empty, but query should not fail due to missing columns + if err != nil && err != sql.ErrNoRows { + t.Errorf("New columns should exist: %v", err) + } + + // Verify kernelMigrations are recorded + var count int + store.conn.QueryRow("SELECT COUNT(*) FROM schema_version").Scan(&count) + if count != len(kernelMigrations) { + t.Errorf("Expected %d kernelMigrations recorded, got %d", len(kernelMigrations), count) + } +} + +func TestRunMigrations_Idempotent(t *testing.T) { + tempDir := t.TempDir() + dbPath := filepath.Join(tempDir, "test.db") + + // Run kernelMigrations twice + store1, err := NewStore(dbPath) + if err != nil { + t.Fatalf("First NewStore failed: %v", err) + } + store1.Close() + + store2, err := NewStore(dbPath) + if err != nil { + t.Fatalf("Second NewStore failed: %v", err) + } + defer store2.Close() + + // Should still have same number of migration records + var count int + store2.conn.QueryRow("SELECT COUNT(*) FROM schema_version").Scan(&count) + if count != len(kernelMigrations) { + t.Errorf("Expected %d kernelMigrations, got %d (not idempotent)", len(kernelMigrations), count) + } +} + +func TestRunMigrations_AddsEpistemicDebtBudget(t *testing.T) { + tempDir := t.TempDir() + dbPath := filepath.Join(tempDir, "test.db") + + store, err := NewStore(dbPath) + if err != nil { + t.Fatalf("NewStore failed: %v", err) + } + defer store.Close() + + var budget sql.NullFloat64 + err = store.conn.QueryRow( + "SELECT epistemic_debt_budget FROM fpf_state LIMIT 1", + ).Scan(&budget) + if err != nil && err != sql.ErrNoRows { + t.Fatalf("query epistemic_debt_budget: %v", err) + } +} diff --git a/src/mcp/db/models.go b/db/models.go similarity index 100% rename from src/mcp/db/models.go rename to db/models.go diff --git a/src/mcp/db/queries.go b/db/queries.go similarity index 100% rename from src/mcp/db/queries.go rename to db/queries.go diff --git a/src/mcp/db/query.sql.go b/db/query.sql.go similarity index 100% rename from src/mcp/db/query.sql.go rename to db/query.sql.go diff --git a/db/store.go b/db/store.go new file mode 100644 index 00000000..b63532e1 --- /dev/null +++ b/db/store.go @@ -0,0 +1,1179 @@ +// Package db provides SQLite database management for Haft. +// +// LEGACY NOTE (v5): The holons/evidence/relations schema and Store methods +// below are from v4. They are retained because: +// 1. The migration system references them (migrations 1-12) +// 2. The assurance/calculator.go WLNK kernel reads from these tables +// 3. Existing .haft/haft.db files may contain v4 data +// +// v5 artifacts use the separate tables defined in migration 13 +// (artifacts, artifact_links, evidence_items, affected_files, artifacts_fts) +// and are managed by internal/artifact.Store. +// +// Do not add new features to the v4 schema. New work goes in internal/artifact/. +package db + +import ( + "context" + "database/sql" + "fmt" + "strings" + "time" + + _ "modernc.org/sqlite" +) + +const schema = ` +CREATE TABLE IF NOT EXISTS holons ( + id TEXT PRIMARY KEY, + type TEXT NOT NULL, + kind TEXT, + layer TEXT NOT NULL, + title TEXT NOT NULL, + content TEXT NOT NULL, + context_id TEXT NOT NULL, + scope TEXT, + parent_id TEXT REFERENCES holons(id), + cached_r_score REAL DEFAULT 0.0 CHECK(cached_r_score BETWEEN 0.0 AND 1.0), + needs_reverification INTEGER DEFAULT 0, + reverification_reason TEXT, + reverification_since DATETIME, + context_status TEXT DEFAULT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP +); +CREATE TABLE IF NOT EXISTS evidence ( + id TEXT PRIMARY KEY, + holon_id TEXT NOT NULL, + type TEXT NOT NULL, + content TEXT NOT NULL, + verdict TEXT NOT NULL, + assurance_level TEXT, + formality_level INTEGER DEFAULT 5 CHECK(formality_level BETWEEN 0 AND 9), + congruence_level INTEGER CHECK(congruence_level BETWEEN 0 AND 3), + claim_scope TEXT DEFAULT '[]', + carrier_ref TEXT, + carrier_hash TEXT, + carrier_commit TEXT, + is_stale INTEGER DEFAULT 0, + stale_reason TEXT, + stale_since DATETIME, + valid_until DATETIME, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP +); +CREATE TABLE IF NOT EXISTS relations ( + source_id TEXT NOT NULL, + target_id TEXT NOT NULL, + relation_type TEXT NOT NULL, + congruence_level INTEGER DEFAULT 3 CHECK(congruence_level BETWEEN 0 AND 3), + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (source_id, target_id, relation_type) +); +CREATE TABLE IF NOT EXISTS characteristics ( + id TEXT PRIMARY KEY, + holon_id TEXT NOT NULL, + name TEXT NOT NULL, + scale TEXT NOT NULL, + value TEXT NOT NULL, + unit TEXT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY(holon_id) REFERENCES holons(id) +); +CREATE TABLE IF NOT EXISTS work_records ( + id TEXT PRIMARY KEY, + method_ref TEXT NOT NULL, + performer_ref TEXT NOT NULL, + started_at DATETIME NOT NULL, + ended_at DATETIME, + resource_ledger TEXT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP +); +CREATE TABLE IF NOT EXISTS audit_log ( + id TEXT PRIMARY KEY, + timestamp DATETIME DEFAULT CURRENT_TIMESTAMP, + tool_name TEXT NOT NULL, + operation TEXT NOT NULL, + actor TEXT NOT NULL, + target_id TEXT, + input_hash TEXT, + result TEXT NOT NULL, + details TEXT, + context_id TEXT NOT NULL DEFAULT 'default' +); +CREATE TABLE IF NOT EXISTS waivers ( + id TEXT PRIMARY KEY, + evidence_id TEXT NOT NULL, + waived_by TEXT NOT NULL, + waived_until DATETIME NOT NULL, + rationale TEXT NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY(evidence_id) REFERENCES evidence(id) +); +CREATE INDEX IF NOT EXISTS idx_relations_target ON relations(target_id, relation_type); +CREATE INDEX IF NOT EXISTS idx_relations_source ON relations(source_id, relation_type); +CREATE INDEX IF NOT EXISTS idx_waivers_evidence ON waivers(evidence_id); + +-- FTS5 virtual tables for full-text search +CREATE VIRTUAL TABLE IF NOT EXISTS holons_fts USING fts5( + id, + title, + content, + content='holons', + content_rowid='rowid' +); + +CREATE VIRTUAL TABLE IF NOT EXISTS evidence_fts USING fts5( + id, + content, + content='evidence', + content_rowid='rowid' +); + +-- Triggers to keep FTS in sync with holons +CREATE TRIGGER IF NOT EXISTS holons_ai AFTER INSERT ON holons BEGIN + INSERT INTO holons_fts(rowid, id, title, content) + VALUES (new.rowid, new.id, new.title, new.content); +END; + +CREATE TRIGGER IF NOT EXISTS holons_ad AFTER DELETE ON holons BEGIN + INSERT INTO holons_fts(holons_fts, rowid, id, title, content) + VALUES('delete', old.rowid, old.id, old.title, old.content); +END; + +CREATE TRIGGER IF NOT EXISTS holons_au AFTER UPDATE ON holons BEGIN + INSERT INTO holons_fts(holons_fts, rowid, id, title, content) + VALUES('delete', old.rowid, old.id, old.title, old.content); + INSERT INTO holons_fts(rowid, id, title, content) + VALUES (new.rowid, new.id, new.title, new.content); +END; + +-- Triggers to keep FTS in sync with evidence +CREATE TRIGGER IF NOT EXISTS evidence_ai AFTER INSERT ON evidence BEGIN + INSERT INTO evidence_fts(rowid, id, content) + VALUES (new.rowid, new.id, new.content); +END; + +CREATE TRIGGER IF NOT EXISTS evidence_ad AFTER DELETE ON evidence BEGIN + INSERT INTO evidence_fts(evidence_fts, rowid, id, content) + VALUES('delete', old.rowid, old.id, old.content); +END; + +CREATE TRIGGER IF NOT EXISTS evidence_au AFTER UPDATE ON evidence BEGIN + INSERT INTO evidence_fts(evidence_fts, rowid, id, content) + VALUES('delete', old.rowid, old.id, old.content); + INSERT INTO evidence_fts(rowid, id, content) + VALUES (new.rowid, new.id, new.content); +END; +` + +type Store struct { + conn *sql.DB + q *Queries +} + +func NewStore(dbPath string) (*Store, error) { + conn, err := sql.Open("sqlite", dbPath) + if err != nil { + return nil, err + } + + // WAL mode + busy timeout: prevent SQLITE_BUSY when multiple goroutines + // (governance scanner, dashboard, task store) access the same DB concurrently. + _, _ = conn.Exec("PRAGMA journal_mode=WAL") + _, _ = conn.Exec("PRAGMA busy_timeout=5000") + + if _, err := conn.Exec(schema); err != nil { + return nil, fmt.Errorf("failed to init schema: %v", err) + } + + if err := RunMigrations(conn); err != nil { + return nil, fmt.Errorf("failed to run migrations: %v", err) + } + + return &Store{ + conn: conn, + q: New(), + }, nil +} + +func (s *Store) GetRawDB() *sql.DB { + return s.conn +} + +func (s *Store) Close() error { + return s.conn.Close() +} + +func (s *Store) CreateHolon(ctx context.Context, id, typ, kind, layer, title, content, contextID, scope, parentID, approachType string) error { + now := sql.NullTime{Time: time.Now(), Valid: true} + return s.q.CreateHolon(ctx, s.conn, CreateHolonParams{ + ID: id, + Type: typ, + Kind: toNullString(kind), + Layer: layer, + Title: title, + Content: content, + ContextID: contextID, + Scope: toNullString(scope), + ParentID: toNullString(parentID), + ApproachType: toNullString(approachType), + CreatedAt: now, + UpdatedAt: now, + }) +} + +func (s *Store) GetHolon(ctx context.Context, id string) (Holon, error) { + return s.q.GetHolon(ctx, s.conn, id) +} + +func (s *Store) GetHolonTitle(ctx context.Context, id string) (string, error) { + return s.q.GetHolonTitle(ctx, s.conn, id) +} + +func (s *Store) ListAllHolonIDs(ctx context.Context) ([]string, error) { + return s.q.ListAllHolonIDs(ctx, s.conn) +} + +func (s *Store) UpdateHolonLayer(ctx context.Context, id, layer string) error { + return s.q.UpdateHolonLayer(ctx, s.conn, UpdateHolonLayerParams{ + ID: id, + Layer: layer, + UpdatedAt: sql.NullTime{Time: time.Now(), Valid: true}, + }) +} + +func (s *Store) RecordWork(ctx context.Context, id, methodRef, performerRef string, startedAt, endedAt time.Time, ledger string) error { + return s.q.RecordWork(ctx, s.conn, RecordWorkParams{ + ID: id, + MethodRef: methodRef, + PerformerRef: performerRef, + StartedAt: startedAt, + EndedAt: sql.NullTime{Time: endedAt, Valid: true}, + ResourceLedger: toNullString(ledger), + CreatedAt: sql.NullTime{Time: time.Now(), Valid: true}, + }) +} + +func (s *Store) AddEvidence(ctx context.Context, id, holonID, typ, content, verdict, assuranceLevel string, formalityLevel int, carrierRef, carrierHash, carrierCommit, validUntil string) error { + var vUntil sql.NullTime + if validUntil != "" { + t, err := time.Parse(time.RFC3339, validUntil) + if err != nil { + t, err = time.Parse("2006-01-02", validUntil) + } + if err == nil { + vUntil = sql.NullTime{Time: t, Valid: true} + } + } + + return s.q.AddEvidence(ctx, s.conn, AddEvidenceParams{ + ID: id, + HolonID: holonID, + Type: typ, + Content: content, + Verdict: verdict, + AssuranceLevel: toNullString(assuranceLevel), + FormalityLevel: sql.NullInt64{Int64: int64(formalityLevel), Valid: true}, + CarrierRef: toNullString(carrierRef), + CarrierHash: toNullString(carrierHash), + CarrierCommit: toNullString(carrierCommit), + ValidUntil: vUntil, + CreatedAt: sql.NullTime{Time: time.Now(), Valid: true}, + }) +} + +func (s *Store) GetEvidence(ctx context.Context, holonID string) ([]Evidence, error) { + return s.q.GetEvidenceByHolon(ctx, s.conn, holonID) +} + +func (s *Store) GetEvidenceWithCarrier(ctx context.Context) ([]Evidence, error) { + return s.q.GetEvidenceWithCarrier(ctx, s.conn) +} + +func (s *Store) GetEvidenceWithCarrierCommit(ctx context.Context) ([]GetEvidenceWithCarrierCommitRow, error) { + return s.q.GetEvidenceWithCarrierCommit(ctx, s.conn) +} + +func (s *Store) Link(ctx context.Context, source, target, relType string) error { + return s.q.AddRelation(ctx, s.conn, AddRelationParams{ + SourceID: source, + TargetID: target, + RelationType: relType, + CreatedAt: sql.NullTime{Time: time.Now(), Valid: true}, + }) +} + +func (s *Store) CreateRelation(ctx context.Context, sourceID, relationType, targetID string, cl int) error { + return s.q.CreateRelation(ctx, s.conn, CreateRelationParams{ + SourceID: sourceID, + RelationType: relationType, + TargetID: targetID, + CongruenceLevel: sql.NullInt64{Int64: int64(cl), Valid: true}, + }) +} + +func (s *Store) GetComponentsOf(ctx context.Context, targetID string) ([]GetComponentsOfRow, error) { + return s.q.GetComponentsOf(ctx, s.conn, targetID) +} + +func (s *Store) GetCollectionMembers(ctx context.Context, targetID string) ([]GetCollectionMembersRow, error) { + return s.q.GetCollectionMembers(ctx, s.conn, targetID) +} + +func (s *Store) GetDependencies(ctx context.Context, sourceID string) ([]GetDependenciesRow, error) { + return s.q.GetDependencies(ctx, s.conn, sourceID) +} + +func (s *Store) GetRelationsByTarget(ctx context.Context, targetID, relationType string) ([]Relation, error) { + return s.q.GetRelationsByTarget(ctx, s.conn, GetRelationsByTargetParams{ + TargetID: targetID, + RelationType: relationType, + }) +} + +func (s *Store) GetHolonsByParent(ctx context.Context, parentID string) ([]Holon, error) { + return s.q.GetHolonsByParent(ctx, s.conn, toNullString(parentID)) +} + +func (s *Store) GetHolonLineage(ctx context.Context, id string) ([]GetHolonLineageRow, error) { + return s.q.GetHolonLineage(ctx, s.conn, id) +} + +func (s *Store) CountHolonsByLayer(ctx context.Context, contextID string) ([]CountHolonsByLayerRow, error) { + return s.q.CountHolonsByLayer(ctx, s.conn, contextID) +} + +// CountActiveHolonsByLayer returns counts by layer, excluding holons in resolved decisions. +func (s *Store) CountActiveHolonsByLayer(ctx context.Context) ([]CountActiveHolonsByLayerRow, error) { + return s.q.CountActiveHolonsByLayer(ctx, s.conn) +} + +// CountArchivedHolonsByLayer returns counts by layer for holons in resolved decisions. +func (s *Store) CountArchivedHolonsByLayer(ctx context.Context) ([]CountArchivedHolonsByLayerRow, error) { + return s.q.CountArchivedHolonsByLayer(ctx, s.conn) +} + +// GetActiveRecentHolons returns recent holons not belonging to resolved decisions. +// Uses active_holons view (migration v6) as single source of truth. +func (s *Store) GetActiveRecentHolons(ctx context.Context, limit int) ([]Holon, error) { + if limit <= 0 { + limit = 10 + } + activeHolons, err := s.q.GetActiveRecentHolons(ctx, s.conn, int64(limit)) + if err != nil { + return nil, err + } + // Convert ActiveHolon (from view) to Holon (identical structure) + holons := make([]Holon, len(activeHolons)) + for i, ah := range activeHolons { + holons[i] = Holon{ + ID: ah.ID, + Type: ah.Type, + Kind: ah.Kind, + Layer: ah.Layer, + Title: ah.Title, + Content: ah.Content, + ContextID: ah.ContextID, + Scope: ah.Scope, + ParentID: ah.ParentID, + CachedRScore: ah.CachedRScore, + CreatedAt: ah.CreatedAt, + UpdatedAt: ah.UpdatedAt, + } + } + return holons, nil +} + +func (s *Store) GetLatestHolonByContext(ctx context.Context, contextID string) (Holon, error) { + return s.q.GetLatestHolonByContext(ctx, s.conn, contextID) +} + +func (s *Store) InsertAuditLog(ctx context.Context, id, toolName, operation, actor, targetID, inputHash, result, details, contextID string) error { + return s.q.InsertAuditLog(ctx, s.conn, InsertAuditLogParams{ + ID: id, + ToolName: toolName, + Operation: operation, + Actor: actor, + TargetID: toNullString(targetID), + InputHash: toNullString(inputHash), + Result: result, + Details: toNullString(details), + ContextID: contextID, + }) +} + +func (s *Store) GetAuditLogByContext(ctx context.Context, contextID string) ([]AuditLog, error) { + return s.q.GetAuditLogByContext(ctx, s.conn, contextID) +} + +func (s *Store) GetAuditLogByTarget(ctx context.Context, targetID string) ([]AuditLog, error) { + return s.q.GetAuditLogByTarget(ctx, s.conn, toNullString(targetID)) +} + +func (s *Store) GetRecentAuditLog(ctx context.Context, limit int64) ([]AuditLog, error) { + return s.q.GetRecentAuditLog(ctx, s.conn, limit) +} + +func (s *Store) CreateWaiver(ctx context.Context, id, evidenceID, waivedBy string, waivedUntil time.Time, rationale string) error { + return s.q.CreateWaiver(ctx, s.conn, CreateWaiverParams{ + ID: id, + EvidenceID: evidenceID, + WaivedBy: waivedBy, + WaivedUntil: waivedUntil, + Rationale: rationale, + CreatedAt: sql.NullTime{Time: time.Now(), Valid: true}, + }) +} + +func (s *Store) GetActiveWaiverForEvidence(ctx context.Context, evidenceID string) (Waiver, error) { + return s.q.GetActiveWaiverForEvidence(ctx, s.conn, evidenceID) +} + +func (s *Store) GetAllActiveWaivers(ctx context.Context) ([]Waiver, error) { + return s.q.GetAllActiveWaivers(ctx, s.conn) +} + +func (s *Store) GetEvidenceByID(ctx context.Context, id string) (Evidence, error) { + return s.q.GetEvidenceByID(ctx, s.conn, id) +} + +func toNullString(s string) sql.NullString { + if s == "" { + return sql.NullString{} + } + return sql.NullString{String: s, Valid: true} +} + +type SearchResult struct { + ID string + Type string // "holon" or "evidence" + Title string + Snippet string + Layer string + Scope string // affected_scope for DRRs (JSON array of file patterns) + RScore float64 + UpdatedAt time.Time +} + +// sanitizeFTS5Query escapes special FTS5 characters to prevent query parse errors. +// FTS5 treats - * " ( ) etc. as operators. We wrap in quotes for phrase matching. +func sanitizeFTS5Query(query string) string { + query = strings.TrimSpace(query) + if query == "" { + return query + } + if strings.HasPrefix(query, `"`) && strings.HasSuffix(query, `"`) { + escaped := strings.ReplaceAll(query[1:len(query)-1], `"`, `""`) + return `"` + escaped + `"` + } + return buildFTS5ANDQuery(query) +} + +func buildFTS5ANDQuery(text string) string { + words := strings.Fields(text) + var terms []string + seen := make(map[string]bool) + + for _, w := range words { + clean := strings.Trim(w, ".,;:!?'()[]{}") + lower := strings.ToLower(clean) + if len(clean) < 2 || seen[lower] { + continue + } + seen[lower] = true + escaped := strings.ReplaceAll(clean, `"`, `""`) + terms = append(terms, `"`+escaped+`"`) + if len(terms) >= 10 { + break + } + } + + if len(terms) == 0 { + return "" + } + return strings.Join(terms, " AND ") +} + +// buildFTS5ORQuery splits text into words and builds an OR query for FTS5. +// Returns words joined with OR, each word quoted for safety. +// Filters short words (<3 chars) and limits to 10 terms. +func buildFTS5ORQuery(text string) string { + words := strings.Fields(strings.ToLower(text)) + var terms []string + seen := make(map[string]bool) + + for _, w := range words { + clean := strings.Trim(w, ".,;:!?\"'()[]{}") // Remove punctuation + if len(clean) < 3 || seen[clean] { + continue + } + seen[clean] = true + escaped := strings.ReplaceAll(clean, `"`, `""`) + terms = append(terms, `"`+escaped+`"`) + if len(terms) >= 10 { + break + } + } + + if len(terms) == 0 { + return "" + } + return strings.Join(terms, " OR ") +} + +// SearchOR performs full-text search using OR of individual words. +// Better for semantic matching where any word match is relevant. +func (s *Store) SearchOR(ctx context.Context, text, scope, layerFilter, statusFilter string, limit int) ([]SearchResult, error) { + orQuery := buildFTS5ORQuery(text) + if orQuery == "" { + return nil, nil + } + return s.searchHolonsRaw(ctx, orQuery, layerFilter, statusFilter, limit) +} + +// Search performs full-text search across holons and evidence. +// scope: "holons", "evidence", "all" +// layerFilter: "L0", "L1", "L2", "" (all layers) +func (s *Store) Search(ctx context.Context, query, scope, layerFilter, statusFilter string, limit int) ([]SearchResult, error) { + if limit <= 0 { + limit = 10 + } + if limit > 50 { + limit = 50 + } + + safeQuery := sanitizeFTS5Query(query) + var results []SearchResult + + // Search holons + if scope == "holons" || scope == "all" || scope == "" { + holonResults, err := s.searchHolons(ctx, safeQuery, layerFilter, statusFilter, limit) + if err != nil { + return nil, fmt.Errorf("holon search failed: %w", err) + } + results = append(results, holonResults...) + } + + // Search evidence + if scope == "evidence" || scope == "all" || scope == "" { + evidenceResults, err := s.searchEvidence(ctx, safeQuery, limit) + if err != nil { + return nil, fmt.Errorf("evidence search failed: %w", err) + } + results = append(results, evidenceResults...) + } + + // Limit total results + if len(results) > limit { + results = results[:limit] + } + + return results, nil +} + +func (s *Store) searchHolons(ctx context.Context, query, layerFilter, statusFilter string, limit int) ([]SearchResult, error) { + var sqlQuery string + var args []interface{} + + if statusFilter != "" { + if statusFilter == "open" { + sqlQuery = ` + SELECT h.id, h.title, h.layer, h.scope, h.cached_r_score, h.updated_at, + snippet(holons_fts, 2, '**', '**', '...', 32) as snippet + FROM holons_fts + JOIN holons h ON holons_fts.id = h.id + WHERE holons_fts MATCH ? + AND (h.type = 'DRR' OR h.layer = 'DRR') + AND NOT EXISTS ( + SELECT 1 FROM evidence e + WHERE e.holon_id = h.id + AND e.type IN ('implementation', 'abandonment', 'supersession') + ) + ORDER BY rank + LIMIT ? + ` + args = []interface{}{query, limit} + } else { + evidenceType := map[string]string{ + "implemented": "implementation", + "abandoned": "abandonment", + "superseded": "supersession", + }[statusFilter] + if evidenceType == "" { + evidenceType = statusFilter + } + sqlQuery = ` + SELECT h.id, h.title, h.layer, h.scope, h.cached_r_score, h.updated_at, + snippet(holons_fts, 2, '**', '**', '...', 32) as snippet + FROM holons_fts + JOIN holons h ON holons_fts.id = h.id + WHERE holons_fts MATCH ? + AND (h.type = 'DRR' OR h.layer = 'DRR') + AND EXISTS ( + SELECT 1 FROM evidence e + WHERE e.holon_id = h.id + AND e.type = ? + ) + ORDER BY rank + LIMIT ? + ` + args = []interface{}{query, evidenceType, limit} + } + } else if layerFilter != "" { + sqlQuery = ` + SELECT h.id, h.title, h.layer, h.scope, h.cached_r_score, h.updated_at, + snippet(holons_fts, 2, '**', '**', '...', 32) as snippet + FROM holons_fts + JOIN holons h ON holons_fts.id = h.id + WHERE holons_fts MATCH ? + AND h.layer = ? + ORDER BY rank + LIMIT ? + ` + args = []interface{}{query, layerFilter, limit} + } else { + sqlQuery = ` + SELECT h.id, h.title, h.layer, h.scope, h.cached_r_score, h.updated_at, + snippet(holons_fts, 2, '**', '**', '...', 32) as snippet + FROM holons_fts + JOIN holons h ON holons_fts.id = h.id + WHERE holons_fts MATCH ? + ORDER BY rank + LIMIT ? + ` + args = []interface{}{query, limit} + } + + rows, err := s.conn.QueryContext(ctx, sqlQuery, args...) + if err != nil { + return nil, err + } + defer rows.Close() + + var results []SearchResult + for rows.Next() { + var r SearchResult + var updatedAt sql.NullTime + var rScore sql.NullFloat64 + var scope sql.NullString + if err := rows.Scan(&r.ID, &r.Title, &r.Layer, &scope, &rScore, &updatedAt, &r.Snippet); err != nil { + continue + } + if scope.Valid { + r.Scope = scope.String + } + r.Type = "holon" + if rScore.Valid { + r.RScore = rScore.Float64 + } + if updatedAt.Valid { + r.UpdatedAt = updatedAt.Time + } + results = append(results, r) + } + + return results, rows.Err() +} + +// searchHolonsRaw executes a raw FTS5 query without sanitization. +// Used for pre-built queries like OR queries. +func (s *Store) searchHolonsRaw(ctx context.Context, rawQuery, layerFilter, statusFilter string, limit int) ([]SearchResult, error) { + if limit <= 0 { + limit = 10 + } + if limit > 50 { + limit = 50 + } + + var sqlQuery string + var args []interface{} + + if layerFilter != "" { + sqlQuery = ` + SELECT h.id, h.title, h.layer, h.scope, h.cached_r_score, h.updated_at, + snippet(holons_fts, 2, '**', '**', '...', 32) as snippet + FROM holons_fts + JOIN holons h ON holons_fts.id = h.id + WHERE holons_fts MATCH ? + AND h.layer = ? + ORDER BY rank + LIMIT ? + ` + args = []interface{}{rawQuery, layerFilter, limit} + } else { + sqlQuery = ` + SELECT h.id, h.title, h.layer, h.scope, h.cached_r_score, h.updated_at, + snippet(holons_fts, 2, '**', '**', '...', 32) as snippet + FROM holons_fts + JOIN holons h ON holons_fts.id = h.id + WHERE holons_fts MATCH ? + ORDER BY rank + LIMIT ? + ` + args = []interface{}{rawQuery, limit} + } + + rows, err := s.conn.QueryContext(ctx, sqlQuery, args...) + if err != nil { + return nil, err + } + defer rows.Close() + + var results []SearchResult + for rows.Next() { + var r SearchResult + var updatedAt sql.NullTime + var rScore sql.NullFloat64 + var scope sql.NullString + if err := rows.Scan(&r.ID, &r.Title, &r.Layer, &scope, &rScore, &updatedAt, &r.Snippet); err != nil { + continue + } + if scope.Valid { + r.Scope = scope.String + } + r.Type = "holon" + if rScore.Valid { + r.RScore = rScore.Float64 + } + if updatedAt.Valid { + r.UpdatedAt = updatedAt.Time + } + results = append(results, r) + } + + return results, rows.Err() +} + +func (s *Store) searchEvidence(ctx context.Context, query string, limit int) ([]SearchResult, error) { + sqlQuery := ` + SELECT e.id, e.holon_id, e.type, e.created_at, + snippet(evidence_fts, 1, '**', '**', '...', 32) as snippet + FROM evidence_fts + JOIN evidence e ON evidence_fts.id = e.id + WHERE evidence_fts MATCH ? + ORDER BY rank + LIMIT ? + ` + + rows, err := s.conn.QueryContext(ctx, sqlQuery, query, limit) + if err != nil { + return nil, err + } + defer rows.Close() + + var results []SearchResult + for rows.Next() { + var r SearchResult + var holonID, evidenceType string + var createdAt sql.NullTime + if err := rows.Scan(&r.ID, &holonID, &evidenceType, &createdAt, &r.Snippet); err != nil { + continue + } + r.Type = "evidence" + r.Title = fmt.Sprintf("%s for %s", evidenceType, holonID) + if createdAt.Valid { + r.UpdatedAt = createdAt.Time + } + results = append(results, r) + } + + return results, rows.Err() +} + +func (s *Store) GetRecentHolons(ctx context.Context, limit int) ([]Holon, error) { + if limit <= 0 { + limit = 10 + } + + rows, err := s.conn.QueryContext(ctx, ` + SELECT id, type, kind, layer, title, content, context_id, scope, parent_id, + cached_r_score, created_at, updated_at + FROM holons + ORDER BY updated_at DESC + LIMIT ? + `, limit) + if err != nil { + return nil, err + } + defer rows.Close() + + var holons []Holon + for rows.Next() { + var h Holon + if err := rows.Scan(&h.ID, &h.Type, &h.Kind, &h.Layer, &h.Title, &h.Content, + &h.ContextID, &h.Scope, &h.ParentID, &h.CachedRScore, &h.CreatedAt, &h.UpdatedAt); err != nil { + continue + } + holons = append(holons, h) + } + + return holons, rows.Err() +} + +func (s *Store) GetDecayingEvidence(ctx context.Context, daysAhead int) ([]Evidence, error) { + rows, err := s.conn.QueryContext(ctx, ` + SELECT e.id, e.holon_id, e.type, e.content, e.verdict, e.assurance_level, + e.carrier_ref, e.valid_until, e.created_at + FROM evidence e + LEFT JOIN ( + SELECT evidence_id, MAX(waived_until) as latest_waiver + FROM waivers + GROUP BY evidence_id + ) w ON e.id = w.evidence_id + WHERE e.valid_until IS NOT NULL + AND date(e.valid_until) BETWEEN date('now') AND date('now', '+' || ? || ' days') + AND (w.latest_waiver IS NULL OR w.latest_waiver < datetime('now')) + ORDER BY e.valid_until ASC + `, daysAhead) + if err != nil { + return nil, err + } + defer rows.Close() + + var evidence []Evidence + for rows.Next() { + var e Evidence + if err := rows.Scan(&e.ID, &e.HolonID, &e.Type, &e.Content, &e.Verdict, + &e.AssuranceLevel, &e.CarrierRef, &e.ValidUntil, &e.CreatedAt); err != nil { + continue + } + evidence = append(evidence, e) + } + + return evidence, rows.Err() +} + +// ============================================ +// COMPACTION METHODS (v5.0.0) +// ============================================ +// Note: Evidence staleness by carrier-file hash was removed in v5.1.0. +// Time-based decay via valid_until remains as per FPF spec B.3.4. + +func (s *Store) CountCompactableHolons(ctx context.Context, retentionDays int64) (int64, error) { + return s.q.CountCompactableHolons(ctx, s.conn, retentionDays) +} + +func (s *Store) GetArchivedHolonsForCompaction(ctx context.Context, retentionDays int64) ([]GetArchivedHolonsForCompactionRow, error) { + return s.q.GetArchivedHolonsForCompaction(ctx, s.conn, retentionDays) +} + +func (s *Store) CompactHolon(ctx context.Context, holonID string) error { + // Waivers reference evidence, must delete first + if err := s.q.DeleteWaiversForHolon(ctx, s.conn, holonID); err != nil { + return fmt.Errorf("delete waivers: %w", err) + } + if err := s.q.DeleteEvidenceForHolon(ctx, s.conn, holonID); err != nil { + return fmt.Errorf("delete evidence: %w", err) + } + if err := s.q.DeleteCharacteristicsForHolon(ctx, s.conn, holonID); err != nil { + return fmt.Errorf("delete characteristics: %w", err) + } + if err := s.q.CompactHolonContent(ctx, s.conn, holonID); err != nil { + return fmt.Errorf("compact content: %w", err) + } + return nil +} + +// MarkHolonNeedsReverification flags a holon as needing re-verification +func (s *Store) MarkHolonNeedsReverification(ctx context.Context, holonID, reason string) error { + return s.q.MarkHolonNeedsReverification(ctx, s.conn, MarkHolonNeedsReverificationParams{ + ReverificationReason: toNullString(reason), + ID: holonID, + }) +} + +// ClearHolonReverification clears the reverification flag +func (s *Store) ClearHolonReverification(ctx context.Context, holonID string) error { + return s.q.ClearHolonReverification(ctx, s.conn, holonID) +} + +// GetHolonsNeedingReverification returns all active holons flagged for reverification +func (s *Store) GetHolonsNeedingReverification(ctx context.Context) ([]ActiveHolon, error) { + return s.q.GetHolonsNeedingReverification(ctx, s.conn) +} + +// CountHolonsNeedingReverification returns count of flagged holons +func (s *Store) CountHolonsNeedingReverification(ctx context.Context) (int64, error) { + return s.q.CountHolonsNeedingReverification(ctx, s.conn) +} + +// UpdateLastCommit records the last processed git commit +func (s *Store) UpdateLastCommit(ctx context.Context, contextID, commitHash string) error { + return s.q.UpdateLastCommit(ctx, s.conn, UpdateLastCommitParams{ + LastCommit: toNullString(commitHash), + ContextID: contextID, + }) +} + +// GetLastCommit returns the last processed git commit +func (s *Store) GetLastCommit(ctx context.Context, contextID string) (string, time.Time, error) { + row, err := s.q.GetLastCommit(ctx, s.conn, contextID) + if err != nil { + return "", time.Time{}, err + } + var commitAt time.Time + if row.LastCommitAt.Valid { + commitAt = row.LastCommitAt.Time + } + return row.LastCommit.String, commitAt, nil +} + +// GetEvidenceByCarrierPattern finds all evidence referencing files matching a pattern +func (s *Store) GetEvidenceByCarrierPattern(ctx context.Context, pattern string) ([]GetEvidenceByCarrierPatternRow, error) { + return s.q.GetEvidenceByCarrierPattern(ctx, s.conn, toNullString(pattern)) +} + +// GetDependents returns holons that depend on the given holon +func (s *Store) GetDependents(ctx context.Context, holonID string) ([]GetDependentsRow, error) { + return s.q.GetDependents(ctx, s.conn, holonID) +} + +// UpdateHolonRScore updates the cached R score for a holon +func (s *Store) UpdateHolonRScore(ctx context.Context, holonID string, score float64) error { + return s.q.UpdateHolonRScore(ctx, s.conn, UpdateHolonRScoreParams{ + CachedRScore: sql.NullFloat64{Float64: score, Valid: true}, + UpdatedAt: sql.NullTime{Time: time.Now(), Valid: true}, + ID: holonID, + }) +} + +// ============================================ +// DECISION CONTEXT METHODS (v5.0.0) +// ============================================ + +// ContextSummary represents a decision context with its hypotheses +type ContextSummary struct { + ID string + Title string + Content string + Status string // open, closed, abandoned + CreatedAt time.Time + UpdatedAt time.Time +} + +// CloseContext marks a context as closed (decision made) +func (s *Store) CloseContext(ctx context.Context, id string) error { + _, err := s.conn.ExecContext(ctx, ` + UPDATE holons SET context_status = 'closed', updated_at = ? + WHERE id = ? AND type = 'decision_context'`, + time.Now(), id) + return err +} + +// AbandonContext marks a context as abandoned +func (s *Store) AbandonContext(ctx context.Context, id string) error { + _, err := s.conn.ExecContext(ctx, ` + UPDATE holons SET context_status = 'abandoned', updated_at = ? + WHERE id = ? AND type = 'decision_context'`, + time.Now(), id) + return err +} + +// GetHypothesesInContext returns all hypotheses belonging to a context via memberOf relation +func (s *Store) GetHypothesesInContext(ctx context.Context, contextID string) ([]Holon, error) { + rows, err := s.conn.QueryContext(ctx, ` + SELECT h.id, h.type, h.kind, h.layer, h.title, h.content, h.context_id, h.scope, + h.parent_id, h.cached_r_score, h.created_at, h.updated_at + FROM holons h + JOIN relations r ON h.id = r.source_id + WHERE r.target_id = ? AND r.relation_type = 'memberOf' + ORDER BY h.layer, h.updated_at DESC`, + contextID) + if err != nil { + return nil, err + } + defer rows.Close() + + var holons []Holon + for rows.Next() { + var h Holon + if err := rows.Scan(&h.ID, &h.Type, &h.Kind, &h.Layer, &h.Title, &h.Content, + &h.ContextID, &h.Scope, &h.ParentID, &h.CachedRScore, &h.CreatedAt, &h.UpdatedAt); err != nil { + continue + } + holons = append(holons, h) + } + return holons, rows.Err() +} + +// GetOrphanHypotheses returns hypotheses not belonging to any context +func (s *Store) GetOrphanHypotheses(ctx context.Context) ([]Holon, error) { + rows, err := s.conn.QueryContext(ctx, ` + SELECT h.id, h.type, h.kind, h.layer, h.title, h.content, h.context_id, h.scope, + h.parent_id, h.cached_r_score, h.created_at, h.updated_at + FROM holons h + WHERE h.type = 'hypothesis' + AND h.layer NOT IN ('invalid') + AND NOT EXISTS ( + SELECT 1 FROM relations r + WHERE r.source_id = h.id AND r.relation_type = 'memberOf' + ) + AND NOT EXISTS ( + SELECT 1 FROM relations r + WHERE r.target_id = h.id AND r.relation_type IN ('selects', 'rejects') + ) + ORDER BY h.updated_at DESC`) + if err != nil { + return nil, err + } + defer rows.Close() + + var holons []Holon + for rows.Next() { + var h Holon + if err := rows.Scan(&h.ID, &h.Type, &h.Kind, &h.Layer, &h.Title, &h.Content, + &h.ContextID, &h.Scope, &h.ParentID, &h.CachedRScore, &h.CreatedAt, &h.UpdatedAt); err != nil { + continue + } + holons = append(holons, h) + } + return holons, rows.Err() +} + +// GetContextByID returns a context by its ID +func (s *Store) GetContextByID(ctx context.Context, id string) (ContextSummary, error) { + var c ContextSummary + var status sql.NullString + var createdAt, updatedAt sql.NullTime + err := s.conn.QueryRowContext(ctx, ` + SELECT id, title, content, context_status, created_at, updated_at + FROM holons + WHERE id = ? AND type = 'decision_context'`, + id).Scan(&c.ID, &c.Title, &c.Content, &status, &createdAt, &updatedAt) + if err != nil { + return c, err + } + if status.Valid { + c.Status = status.String + } + if createdAt.Valid { + c.CreatedAt = createdAt.Time + } + if updatedAt.Valid { + c.UpdatedAt = updatedAt.Time + } + return c, nil +} + +// GetHypothesisContext returns the context ID for a hypothesis (via memberOf relation) +func (s *Store) GetHypothesisContext(ctx context.Context, hypothesisID string) (string, error) { + var contextID string + err := s.conn.QueryRowContext(ctx, ` + SELECT r.target_id + FROM relations r + JOIN holons h ON r.target_id = h.id + WHERE r.source_id = ? AND r.relation_type = 'memberOf' AND h.type = 'decision_context' + LIMIT 1`, + hypothesisID).Scan(&contextID) + if err == sql.ErrNoRows { + return "", nil // no context + } + return contextID, err +} + +// ============================================ +// PREDICTIONS METHODS (v5.1.0) +// ============================================ + +func (s *Store) AddPrediction(ctx context.Context, id, holonID, content string) error { + return s.q.AddPrediction(ctx, s.conn, AddPredictionParams{ + ID: id, + HolonID: holonID, + Content: content, + }) +} + +func (s *Store) GetPredictionsByHolon(ctx context.Context, holonID string) ([]Prediction, error) { + return s.q.GetPredictionsByHolon(ctx, s.conn, holonID) +} + +func (s *Store) GetUncoveredPredictions(ctx context.Context, holonID string) ([]GetUncoveredPredictionsRow, error) { + return s.q.GetUncoveredPredictions(ctx, s.conn, holonID) +} + +func (s *Store) MarkPredictionCovered(ctx context.Context, predictionID, evidenceID string) error { + return s.q.MarkPredictionCovered(ctx, s.conn, MarkPredictionCoveredParams{ + CoveredBy: toNullString(evidenceID), + ID: predictionID, + }) +} + +func (s *Store) CountUncoveredPredictions(ctx context.Context, holonID string) (int64, error) { + return s.q.CountUncoveredPredictions(ctx, s.conn, holonID) +} + +// ============================================ +// CONTEXT FACTS METHODS (v5.2.0) +// ============================================ + +type ContextFact struct { + Category string + Content string + UpdatedAt time.Time +} + +func (s *Store) UpsertContextFact(ctx context.Context, category, content string) error { + _, err := s.conn.ExecContext(ctx, ` + INSERT INTO context_facts (category, content, updated_at) + VALUES (?, ?, CURRENT_TIMESTAMP) + ON CONFLICT(category) DO UPDATE SET + content = excluded.content, + updated_at = CURRENT_TIMESTAMP`, + category, content) + return err +} + +func (s *Store) AppendContextFact(ctx context.Context, category, content string) error { + _, err := s.conn.ExecContext(ctx, ` + INSERT INTO context_facts (category, content, updated_at) + VALUES (?, ?, CURRENT_TIMESTAMP) + ON CONFLICT(category) DO UPDATE SET + content = context_facts.content || char(10) || excluded.content, + updated_at = CURRENT_TIMESTAMP`, + category, content) + return err +} + +func (s *Store) DeleteContextFact(ctx context.Context, category string) error { + _, err := s.conn.ExecContext(ctx, `DELETE FROM context_facts WHERE category = ?`, category) + return err +} + +func (s *Store) GetAllContextFacts(ctx context.Context) ([]ContextFact, error) { + rows, err := s.conn.QueryContext(ctx, ` + SELECT category, content, updated_at + FROM context_facts + ORDER BY category`) + if err != nil { + return nil, err + } + defer rows.Close() + + var facts []ContextFact + for rows.Next() { + var f ContextFact + var updatedAt sql.NullTime + if err := rows.Scan(&f.Category, &f.Content, &updatedAt); err != nil { + continue + } + if updatedAt.Valid { + f.UpdatedAt = updatedAt.Time + } + facts = append(facts, f) + } + return facts, rows.Err() +} + +func (s *Store) GetContextFact(ctx context.Context, category string) (ContextFact, error) { + var f ContextFact + var updatedAt sql.NullTime + err := s.conn.QueryRowContext(ctx, ` + SELECT category, content, updated_at + FROM context_facts + WHERE category = ?`, + category).Scan(&f.Category, &f.Content, &updatedAt) + if updatedAt.Valid { + f.UpdatedAt = updatedAt.Time + } + return f, err +} diff --git a/src/mcp/db/store_test.go b/db/store_test.go similarity index 100% rename from src/mcp/db/store_test.go rename to db/store_test.go diff --git a/desktop/agents.go b/desktop/agents.go new file mode 100644 index 00000000..aba45980 --- /dev/null +++ b/desktop/agents.go @@ -0,0 +1,1135 @@ +package main + +import ( + "context" + "encoding/json" + "fmt" + "os" + "os/exec" + "path/filepath" + "sort" + "strings" + "sync" + "time" + + "github.com/wailsapp/wails/v2/pkg/runtime" +) + +// AgentKind identifies a supported coding agent. +type AgentKind string + +const ( + AgentClaude AgentKind = "claude" + AgentCodex AgentKind = "codex" + AgentHaft AgentKind = "haft" +) + +const ( + taskOutputMaxLines = 500 + taskOutputFlushInterval = 350 * time.Millisecond +) + +// InstalledAgent describes a detected agent binary. +type InstalledAgent struct { + Kind string `json:"kind"` + Name string `json:"name"` + Path string `json:"path"` + Version string `json:"version"` +} + +// TaskState tracks a running or persisted agent task. +type TaskState struct { + ID string `json:"id"` + Title string `json:"title"` + Agent string `json:"agent"` + Project string `json:"project"` + ProjectPath string `json:"project_path"` + Status string `json:"status"` // pending, running, completed, failed, cancelled, interrupted + Prompt string `json:"prompt"` + Branch string `json:"branch"` + Worktree bool `json:"worktree"` + WorktreePath string `json:"worktree_path"` + ReusedWorktree bool `json:"reused_worktree"` + StartedAt string `json:"started_at"` + CompletedAt string `json:"completed_at"` + ErrorMessage string `json:"error_message"` + Output string `json:"output"` // bounded output tail + AutoRun bool `json:"auto_run"` // true = agent runs without pausing +} + +type TaskOutputEvent struct { + ID string `json:"id"` + Chunk string `json:"chunk"` + Output string `json:"output"` +} + +// taskRunner manages running agent subprocesses. +type taskRunner struct { + mu sync.Mutex + tasks map[string]*runningTask + seq int + app *App + store *desktopTaskStore +} + +type runningTask struct { + state TaskState + cmd *exec.Cmd + cancel context.CancelFunc + output *taskOutputBuffer + flushStop chan struct{} + flushDone chan struct{} + flushOnce sync.Once +} + +type taskOutputWriter struct { + buffer *taskOutputBuffer + onUpdate func(chunk string, output string) +} + +type taskOutputBuffer struct { + mu sync.Mutex + lines []string + partial string + maxLines int +} + +type worktreeHandle struct { + Path string + Reused bool +} + +func newTaskRunner(app *App, store *desktopTaskStore) *taskRunner { + return &taskRunner{ + tasks: make(map[string]*runningTask), + app: app, + store: store, + } +} + +func (r *taskRunner) restore(ctx context.Context, projectPath string) error { + if r == nil || r.store == nil || projectPath == "" { + return nil + } + + return r.store.MarkRunningTasksInterrupted(ctx, projectPath) +} + +func (r *taskRunner) hasRunningTasks() bool { + r.mu.Lock() + defer r.mu.Unlock() + + for _, rt := range r.tasks { + if rt.state.Status == "running" { + return true + } + } + + return false +} + +func (r *taskRunner) shutdown() { + if r == nil { + return + } + + r.mu.Lock() + + // Copy state snapshots under lock to avoid races with finalizeTask. + type shutdownItem struct { + rt *runningTask + state TaskState + wasRun bool + } + items := make([]shutdownItem, 0, len(r.tasks)) + for _, rt := range r.tasks { + items = append(items, shutdownItem{ + rt: rt, + state: rt.state, // copy + wasRun: rt.state.Status == "running", + }) + } + + r.mu.Unlock() + + for _, item := range items { + if !item.wasRun { + continue + } + + item.rt.cancel() + item.rt.stopFlusher() + + state := item.state + state.Status = "interrupted" + state.ErrorMessage = "Desktop app shut down before the task completed." + state.CompletedAt = nowRFC3339() + state.Output = item.rt.output.String() + + if err := r.persistState(state); err != nil { + r.app.emitAppError("shutdown tasks", err) + } + } +} + +func (r *taskRunner) nextTaskID() string { + r.mu.Lock() + defer r.mu.Unlock() + + r.seq++ + + return fmt.Sprintf("task-%d-%d", time.Now().Unix(), r.seq) +} + +func (r *taskRunner) register(rt *runningTask) { + r.mu.Lock() + r.tasks[rt.state.ID] = rt + r.mu.Unlock() +} + +func (r *taskRunner) list(ctx context.Context, projectPath string) ([]TaskState, error) { + if r == nil { + return []TaskState{}, nil + } + + persisted := make([]TaskState, 0) + + if r.store != nil && projectPath != "" { + tasks, err := r.store.ListTasks(ctx, projectPath) + if err != nil { + return nil, err + } + + persisted = tasks + } + + r.mu.Lock() + + live := make(map[string]TaskState, len(r.tasks)) + + for id, rt := range r.tasks { + state := rt.state + state.Output = rt.output.String() + live[id] = state + } + + r.mu.Unlock() + + result := make([]TaskState, 0, len(persisted)+len(live)) + seen := make(map[string]bool, len(persisted)) + + for _, state := range persisted { + if current, ok := live[state.ID]; ok { + state = current + } + + result = append(result, state) + seen[state.ID] = true + } + + for id, state := range live { + if seen[id] { + continue + } + + result = append(result, state) + } + + sort.Slice(result, func(i int, j int) bool { + return result[i].StartedAt > result[j].StartedAt + }) + + return result, nil +} + +func (r *taskRunner) currentOutput(id string) (string, bool) { + r.mu.Lock() + defer r.mu.Unlock() + + rt, ok := r.tasks[id] + if !ok { + return "", false + } + + return rt.output.String(), true +} + +func (r *taskRunner) stopFlusher(id string) { + r.mu.Lock() + rt, ok := r.tasks[id] + r.mu.Unlock() + + if ok { + rt.stopFlusher() + } +} + +func (r *taskRunner) startOutputFlusher(rt *runningTask) { + ticker := time.NewTicker(taskOutputFlushInterval) + + go func() { + defer close(rt.flushDone) + defer ticker.Stop() + + lastFlushed := "" + + for { + select { + case <-ticker.C: + snapshot := rt.output.String() + if snapshot == lastFlushed { + continue + } + + lastFlushed = snapshot + + if err := r.flushOutput(rt.state.ID, snapshot); err != nil { + r.app.emitAppError("task output persistence", err) + } + case <-rt.flushStop: + snapshot := rt.output.String() + + if snapshot != lastFlushed { + if err := r.flushOutput(rt.state.ID, snapshot); err != nil { + r.app.emitAppError("task output persistence", err) + } + } + + return + } + } + }() +} + +func (r *taskRunner) flushOutput(id string, output string) error { + if r == nil || r.store == nil { + return nil + } + + return r.store.UpdateOutput(context.Background(), id, output) +} + +func (r *taskRunner) persistState(state TaskState) error { + if r == nil || r.store == nil { + return nil + } + + return r.store.UpsertTask(context.Background(), state) +} + +func (r *taskRunner) emitTaskOutput(id string, chunk string, output string) { + if r == nil || r.app == nil || r.app.ctx == nil { + return + } + + runtime.EventsEmit( + r.app.ctx, + "task.output", + TaskOutputEvent{ + ID: id, + Chunk: chunk, + Output: output, + }, + ) +} + +func (r *taskRunner) emitTaskStatus(state TaskState) { + if r == nil || r.app == nil || r.app.ctx == nil { + return + } + + runtime.EventsEmit(r.app.ctx, "task.status", state) +} + +func (r *taskRunner) setAutoRun(id string, autoRun bool) error { + r.mu.Lock() + defer r.mu.Unlock() + + rt, ok := r.tasks[id] + if !ok { + // Task might be persisted but not in memory — update store directly + if r.store != nil { + return r.store.SetAutoRun(context.Background(), id, autoRun) + } + return fmt.Errorf("task not found: %s", id) + } + + rt.state.AutoRun = autoRun + if r.store != nil { + _ = r.store.SetAutoRun(context.Background(), id, autoRun) + } + return nil +} + +func (r *taskRunner) finalizeTask(rt *runningTask, waitErr error) { + rt.stopFlusher() + + r.mu.Lock() + + current, ok := r.tasks[rt.state.ID] + if !ok { + r.mu.Unlock() + return + } + + state := current.state + state.Output = current.output.String() + state.CompletedAt = nowRFC3339() + + switch { + case state.Status == "cancelled": + // Preserve explicit cancellation from CancelTask. + case waitErr != nil: + state.Status = "failed" + state.ErrorMessage = waitErr.Error() + default: + state.Status = "completed" + state.ErrorMessage = "" + } + + current.state = state + delete(r.tasks, rt.state.ID) + + r.mu.Unlock() + + if state.Status == "cancelled" { + message, cleanupErr := cleanupWorktree(rt.state.ProjectPath, state, false) + if cleanupErr != nil { + state.ErrorMessage = cleanupErr.Error() + state.Output = appendTaskNote(state.Output, cleanupErr.Error()) + } + + if message != "" { + state.Output = appendTaskNote(state.Output, message) + } + } + + if err := r.persistState(state); err != nil { + r.app.emitAppError("finalize task", err) + } + + r.emitTaskStatus(state) + r.app.notifyTaskState(state) +} + +func (rt *runningTask) stopFlusher() { + rt.flushOnce.Do(func() { + close(rt.flushStop) + <-rt.flushDone + }) +} + +func newTaskOutputBuffer(maxLines int, seed string) *taskOutputBuffer { + buffer := &taskOutputBuffer{maxLines: maxLines} + + if seed != "" { + buffer.Append(seed) + } + + return buffer +} + +func (w *taskOutputWriter) Write(p []byte) (int, error) { + chunk := string(p) + output := w.buffer.Append(chunk) + + if w.onUpdate != nil { + w.onUpdate(chunk, output) + } + + return len(p), nil +} + +func (b *taskOutputBuffer) Append(chunk string) string { + b.mu.Lock() + defer b.mu.Unlock() + + text := b.partial + chunk + parts := strings.Split(text, "\n") + + if strings.HasSuffix(text, "\n") { + b.lines = append(b.lines, parts[:len(parts)-1]...) + b.partial = "" + } else { + if len(parts) > 1 { + b.lines = append(b.lines, parts[:len(parts)-1]...) + } + + if len(parts) > 0 { + b.partial = parts[len(parts)-1] + } + } + + if b.maxLines > 0 && len(b.lines) > b.maxLines { + b.lines = append([]string(nil), b.lines[len(b.lines)-b.maxLines:]...) + } + + return b.snapshotLocked() +} + +func (b *taskOutputBuffer) String() string { + b.mu.Lock() + defer b.mu.Unlock() + + return b.snapshotLocked() +} + +func (b *taskOutputBuffer) snapshotLocked() string { + if len(b.lines) == 0 { + return b.partial + } + + if b.partial == "" { + return strings.Join(b.lines, "\n") + } + + parts := append(append([]string(nil), b.lines...), b.partial) + return strings.Join(parts, "\n") +} + +// --- App binding methods --- + +// DetectAgents finds installed coding agents. +func (a *App) DetectAgents() ([]InstalledAgent, error) { + var agents []InstalledAgent + + if path, err := exec.LookPath("claude"); err == nil { + version := getVersion(path, "--version") + agents = append(agents, InstalledAgent{ + Kind: string(AgentClaude), + Name: "Claude Code", + Path: path, + Version: version, + }) + } + + if path, err := exec.LookPath("codex"); err == nil { + version := getVersion(path, "--version") + agents = append(agents, InstalledAgent{ + Kind: string(AgentCodex), + Name: "Codex", + Path: path, + Version: version, + }) + } + + if path, err := exec.LookPath("haft"); err == nil { + version := getVersion(path, "version") + agents = append(agents, InstalledAgent{ + Kind: string(AgentHaft), + Name: "Haft Agent", + Path: path, + Version: version, + }) + } + + return agents, nil +} + +// SpawnTask creates and starts a new agent task. +func (a *App) SpawnTask(agentKind string, prompt string, useWorktree bool, branchName string) (*TaskState, error) { + return a.spawnTaskWithTitle(agentKind, prompt, useWorktree, branchName, "") +} + +func (a *App) spawnTaskWithTitle( + agentKind string, + prompt string, + useWorktree bool, + branchName string, + title string, +) (*TaskState, error) { + if a.projectRoot == "" { + return nil, fmt.Errorf("no active project") + } + + if a.dbConn == nil { + return nil, fmt.Errorf("no database connection") + } + + if a.tasks == nil { + a.tasks = newTaskRunner(a, newDesktopTaskStore(a.dbConn.GetRawDB())) + } + + agentKind = normalizeAgentKind(agentKind, string(AgentClaude)) + branchName = strings.TrimSpace(branchName) + + if useWorktree && branchName == "" { + branchName = fmt.Sprintf("haft-task-%d", time.Now().Unix()) + } + + cfg := defaultDesktopConfig() + warnings := make([]string, 0) + + loadedConfig, err := loadDesktopConfig() + if err == nil && loadedConfig != nil { + cfg = *loadedConfig + } else if err != nil { + warnings = append(warnings, fmt.Sprintf("warning: desktop config could not be loaded: %v", err)) + } + + workDir := a.projectRoot + worktree := worktreeHandle{} + + state := TaskState{ + ID: a.tasks.nextTaskID(), + Title: firstNonEmpty(strings.TrimSpace(title), truncate(prompt, 60)), + Agent: agentKind, + Project: a.projectName, + ProjectPath: a.projectRoot, + Status: "running", + Prompt: prompt, + Branch: branchName, + Worktree: useWorktree, + StartedAt: nowRFC3339(), + } + + if useWorktree { + worktree, err = createWorktree(a.projectRoot, branchName) + if err != nil { + return nil, fmt.Errorf("create worktree: %w", err) + } + + workDir = worktree.Path + state.WorktreePath = worktree.Path + state.ReusedWorktree = worktree.Reused + } + + if cfg.AutoWireMCP { + if err := wireHaftMCP(agentKind, a.projectRoot); err != nil { + warnings = append(warnings, fmt.Sprintf("warning: failed to wire Haft MCP: %v", err)) + } + } + + args := buildAgentArgs(AgentKind(agentKind), prompt) + if len(args) == 0 { + return nil, fmt.Errorf("unsupported agent: %s", agentKind) + } + + // Use task timeout from config to prevent zombie agent processes. + timeout := time.Duration(cfg.TaskTimeoutMinutes) * time.Minute + if timeout <= 0 { + timeout = 300 * time.Minute // fallback: 5 hours + } + ctx, cancel := context.WithTimeout(context.Background(), timeout) + cmd := exec.CommandContext(ctx, args[0], args[1:]...) + cmd.Dir = workDir + cmd.Env = append( + os.Environ(), + "HAFT_PROJECT_ROOT="+a.projectRoot, + "HAFT_TASK_ID="+state.ID, + "HAFT_TASK_BRANCH="+state.Branch, + "HAFT_TASK_WORKTREE="+state.WorktreePath, + ) + + initialOutput := "" + if len(warnings) > 0 { + initialOutput = strings.Join(warnings, "\n") + "\n" + } + + rt := &runningTask{ + state: state, + cmd: cmd, + cancel: cancel, + output: newTaskOutputBuffer(taskOutputMaxLines, initialOutput), + flushStop: make(chan struct{}), + flushDone: make(chan struct{}), + } + + writer := &taskOutputWriter{ + buffer: rt.output, + onUpdate: func(chunk string, output string) { + a.tasks.emitTaskOutput(state.ID, chunk, output) + }, + } + + cmd.Stdout = writer + cmd.Stderr = writer + + rt.state.Output = rt.output.String() + + if err := a.tasks.persistState(rt.state); err != nil { + cancel() + + if state.WorktreePath != "" && !state.ReusedWorktree { + _, _ = cleanupWorktree(a.projectRoot, state, true) + } + + return nil, fmt.Errorf("persist task: %w", err) + } + + if err := cmd.Start(); err != nil { + cancel() + + rt.state.Status = "failed" + rt.state.ErrorMessage = err.Error() + rt.state.CompletedAt = nowRFC3339() + rt.state.Output = appendTaskNote(rt.state.Output, err.Error()) + + _ = a.tasks.persistState(rt.state) + + if state.WorktreePath != "" && !state.ReusedWorktree { + _, _ = cleanupWorktree(a.projectRoot, state, true) + } + + return nil, fmt.Errorf("start %s: %w", agentKind, err) + } + + a.tasks.register(rt) + a.tasks.startOutputFlusher(rt) + a.tasks.emitTaskStatus(rt.state) + + go func() { + waitErr := cmd.Wait() + a.tasks.finalizeTask(rt, waitErr) + }() + + result := rt.state + return &result, nil +} + +// ListTasks returns all non-archived tasks for the active project. +func (a *App) ListTasks() ([]TaskState, error) { + if a.tasks == nil { + return []TaskState{}, nil + } + + return a.tasks.list(a.ctx, a.projectRoot) +} + +// GetTaskOutput returns the current buffered output for a task. +func (a *App) GetTaskOutput(id string) (string, error) { + if a.tasks != nil { + if output, ok := a.tasks.currentOutput(id); ok { + return output, nil + } + } + + if a.tasks == nil || a.tasks.store == nil { + return "", fmt.Errorf("task not found: %s", id) + } + + output, err := a.tasks.store.GetTaskOutput(a.ctx, id) + if err != nil { + return "", fmt.Errorf("get task output %s: %w", id, err) + } + + return output, nil +} + +// CancelTask stops a running task. +func (a *App) CancelTask(id string) error { + if a.tasks == nil { + return fmt.Errorf("no tasks") + } + + a.tasks.mu.Lock() + rt, ok := a.tasks.tasks[id] + var stateCopy TaskState + if ok { + rt.state.Status = "cancelled" + rt.state.ErrorMessage = "" + rt.state.Output = rt.output.String() + stateCopy = rt.state // copy under lock + } + a.tasks.mu.Unlock() + + if !ok { + return fmt.Errorf("task not found: %s", id) + } + + if err := a.tasks.persistState(stateCopy); err != nil { + return fmt.Errorf("persist cancelled task: %w", err) + } + + rt.cancel() + a.tasks.emitTaskStatus(stateCopy) + + return nil +} + +// SetTaskAutoRun toggles auto-run mode for a task. +// Auto-run: agent proceeds without user intervention. +// Checkpointed: agent pauses at natural breakpoints, user clicks "Continue". +func (a *App) SetTaskAutoRun(id string, autoRun bool) error { + if a.tasks == nil { + return fmt.Errorf("no task runner") + } + return a.tasks.setAutoRun(id, autoRun) +} + +// ArchiveTask hides a completed task and cleans up its worktree when safe. +func (a *App) ArchiveTask(id string) error { + if a.tasks == nil || a.tasks.store == nil { + return fmt.Errorf("no task store") + } + + a.tasks.mu.Lock() + _, running := a.tasks.tasks[id] + a.tasks.mu.Unlock() + + if running { + return fmt.Errorf("cannot archive a running task") + } + + state, err := a.tasks.store.GetTask(a.ctx, id) + if err != nil { + return fmt.Errorf("load task %s: %w", id, err) + } + + if state.WorktreePath != "" { + message, cleanupErr := cleanupWorktree(a.projectRoot, *state, false) + if cleanupErr != nil { + return cleanupErr + } + + if message != "" { + state.Output = appendTaskNote(state.Output, message) + + if err := a.tasks.persistState(*state); err != nil { + return fmt.Errorf("persist task cleanup note: %w", err) + } + } + } + + if err := a.tasks.store.ArchiveTask(a.ctx, id); err != nil { + return fmt.Errorf("archive task %s: %w", id, err) + } + + return nil +} + +func (a *App) HandoffTask(id string, targetAgent string) (*TaskState, error) { + if a.tasks == nil || a.tasks.store == nil { + return nil, fmt.Errorf("no task store") + } + + source, err := a.loadTaskState(strings.TrimSpace(id)) + if err != nil { + return nil, err + } + + nextAgent := normalizeAgentKind(targetAgent, string(AgentClaude)) + if nextAgent == source.Agent { + return nil, fmt.Errorf("handoff target must be different from the source agent") + } + + useWorktree := source.Worktree && source.Status != "running" + branch := "" + if useWorktree { + branch = source.Branch + } + + prompt := buildHandoffPrompt(*source, nextAgent) + + return a.spawnTaskWithTitle( + nextAgent, + prompt, + useWorktree, + branch, + fmt.Sprintf("Handoff: %s", source.Title), + ) +} + +// --- Helpers --- + +func (a *App) loadTaskState(id string) (*TaskState, error) { + if a.tasks == nil { + return nil, fmt.Errorf("task not found: %s", id) + } + + a.tasks.mu.Lock() + rt, ok := a.tasks.tasks[id] + a.tasks.mu.Unlock() + + if ok { + state := rt.state + state.Output = rt.output.String() + return &state, nil + } + + state, err := a.tasks.store.GetTask(a.ctx, id) + if err != nil { + return nil, fmt.Errorf("load task %s: %w", id, err) + } + + return state, nil +} + +func getVersion(path string, flag string) string { + out, err := exec.Command(path, flag).Output() + if err != nil { + return "unknown" + } + + return strings.TrimSpace(strings.Split(string(out), "\n")[0]) +} + +func buildAgentArgs(kind AgentKind, prompt string) []string { + switch kind { + case AgentClaude: + return []string{ + "claude", "-p", prompt, + "--verbose", + "--output-format", "text", + } + case AgentCodex: + return []string{ + "codex", "exec", + "--full-auto", + prompt, + } + case AgentHaft: + return []string{"haft", "agent", prompt} + default: + return nil + } +} + +func createWorktree(projectRoot string, branch string) (worktreeHandle, error) { + wtDir := filepath.Join(projectRoot, ".haft", "worktrees", branch) + + if err := os.MkdirAll(filepath.Dir(wtDir), 0o755); err != nil { + return worktreeHandle{}, err + } + + if info, err := os.Stat(wtDir); err == nil && info.IsDir() { + if isGitWorktree(wtDir) { + return worktreeHandle{Path: wtDir, Reused: true}, nil + } + + return worktreeHandle{}, fmt.Errorf("path already exists and is not a git worktree: %s", wtDir) + } + + createNew := exec.Command("git", "worktree", "add", "-b", branch, wtDir) + createNew.Dir = projectRoot + firstOutput, firstErr := createNew.CombinedOutput() + if firstErr == nil { + return worktreeHandle{Path: wtDir, Reused: false}, nil + } + + attachExisting := exec.Command("git", "worktree", "add", wtDir, branch) + attachExisting.Dir = projectRoot + secondOutput, secondErr := attachExisting.CombinedOutput() + if secondErr == nil { + return worktreeHandle{Path: wtDir, Reused: false}, nil + } + + if isGitWorktree(wtDir) { + return worktreeHandle{Path: wtDir, Reused: true}, nil + } + + return worktreeHandle{}, fmt.Errorf( + "git worktree add failed: %s / %s", + strings.TrimSpace(string(firstOutput)), + strings.TrimSpace(string(secondOutput)), + ) +} + +func cleanupWorktree(projectRoot string, state TaskState, force bool) (string, error) { + if state.WorktreePath == "" { + return "", nil + } + + if state.ReusedWorktree { + return fmt.Sprintf("Skipped cleanup for reused worktree %s.", state.WorktreePath), nil + } + + if _, err := os.Stat(state.WorktreePath); os.IsNotExist(err) { + return fmt.Sprintf("Worktree %s was already removed.", state.WorktreePath), nil + } + + dirty, err := isWorktreeDirty(state.WorktreePath) + if err != nil { + return "", err + } + + if dirty && !force { + return "", fmt.Errorf("worktree %s has uncommitted changes; refusing to remove automatically", state.WorktreePath) + } + + args := []string{"worktree", "remove"} + if force { + args = append(args, "--force") + } + args = append(args, state.WorktreePath) + + cmd := exec.Command("git", args...) + cmd.Dir = projectRoot + output, err := cmd.CombinedOutput() + if err != nil { + return "", fmt.Errorf("git worktree remove %s: %s", state.WorktreePath, strings.TrimSpace(string(output))) + } + + return fmt.Sprintf("Removed worktree %s.", state.WorktreePath), nil +} + +func isGitWorktree(path string) bool { + cmd := exec.Command("git", "-C", path, "rev-parse", "--is-inside-work-tree") + output, err := cmd.Output() + if err != nil { + return false + } + + return strings.TrimSpace(string(output)) == "true" +} + +func isWorktreeDirty(path string) (bool, error) { + cmd := exec.Command("git", "-C", path, "status", "--porcelain") + output, err := cmd.Output() + if err != nil { + return false, fmt.Errorf("git status %s: %w", path, err) + } + + return strings.TrimSpace(string(output)) != "", nil +} + +// wireHaftMCP injects haft MCP server into the agent's config. +func wireHaftMCP(agentKind string, projectRoot string) error { + switch AgentKind(agentKind) { + case AgentClaude: + return wireHaftMCPClaude(projectRoot) + case AgentCodex: + return nil + default: + return nil + } +} + +// wireHaftMCPClaude adds haft to ~/.claude.json mcpServers if not present. +func wireHaftMCPClaude(projectRoot string) error { + home, err := os.UserHomeDir() + if err != nil { + return err + } + + haftPath, err := exec.LookPath("haft") + if err != nil { + return fmt.Errorf("haft binary not found in PATH") + } + + configPath := filepath.Join(home, ".claude.json") + config := make(map[string]interface{}) + + data, err := os.ReadFile(configPath) + if err != nil && !os.IsNotExist(err) { + return fmt.Errorf("read %s: %w", configPath, err) + } + + if len(data) > 0 { + if err := json.Unmarshal(data, &config); err != nil { + return fmt.Errorf("parse %s: %w", configPath, err) + } + } + + var servers map[string]interface{} + + raw, exists := config["mcpServers"] + if exists { + var ok bool + servers, ok = raw.(map[string]interface{}) + if !ok { + return fmt.Errorf("mcpServers in %s is not an object", configPath) + } + } else { + servers = make(map[string]interface{}) + config["mcpServers"] = servers + } + + if _, has := servers["haft"]; has { + return nil + } + + servers["haft"] = map[string]interface{}{ + "type": "stdio", + "command": haftPath, + "args": []string{"serve", "--project", projectRoot}, + } + + output, err := json.MarshalIndent(config, "", " ") + if err != nil { + return fmt.Errorf("marshal config: %w", err) + } + + return atomicWriteFile(configPath, append(output, '\n'), 0o644) +} + +func appendTaskNote(output string, note string) string { + note = strings.TrimSpace(note) + if note == "" { + return output + } + + if output == "" { + return "[haft] " + note + } + + return output + "\n[haft] " + note +} + +func buildHandoffPrompt(source TaskState, targetAgent string) string { + var prompt strings.Builder + + prompt.WriteString(fmt.Sprintf("## Task Handoff: %s\n\n", source.Title)) + prompt.WriteString(fmt.Sprintf("Previous agent: %s\n", source.Agent)) + prompt.WriteString(fmt.Sprintf("Target agent: %s\n", targetAgent)) + prompt.WriteString(fmt.Sprintf("Previous status: %s\n", source.Status)) + if source.Project != "" { + prompt.WriteString(fmt.Sprintf("Project: %s\n", source.Project)) + } + if source.Branch != "" { + prompt.WriteString(fmt.Sprintf("Branch: %s\n", source.Branch)) + } + if source.WorktreePath != "" { + prompt.WriteString(fmt.Sprintf("Workspace: %s\n", source.WorktreePath)) + } + prompt.WriteString("\n") + + prompt.WriteString("## Original Brief\n") + prompt.WriteString(strings.TrimSpace(source.Prompt)) + prompt.WriteString("\n\n") + + prompt.WriteString("## Recent Output Tail\n") + prompt.WriteString("```text\n") + prompt.WriteString(lastTaskOutputLines(source.Output, 120)) + prompt.WriteString("\n```\n\n") + + prompt.WriteString("## Instructions\n") + prompt.WriteString("1. Read the original brief and recent output before touching the code.\n") + prompt.WriteString("2. Reconstruct the current repo state from the workspace instead of trusting the previous agent blindly.\n") + prompt.WriteString("3. Continue the work, call out anything already done, and make the remaining risks explicit.\n") + if source.Status == "running" { + prompt.WriteString("4. The previous task was still marked running when this handoff was created. Avoid assuming its workspace state is final.\n") + } else { + prompt.WriteString("4. Treat the previous output as context, not proof. Verify what landed before you continue.\n") + } + + return prompt.String() +} + +func lastTaskOutputLines(output string, maxLines int) string { + trimmed := strings.TrimSpace(output) + if trimmed == "" { + return "(no task output recorded yet)" + } + + lines := strings.Split(trimmed, "\n") + if maxLines <= 0 || len(lines) <= maxLines { + return strings.Join(lines, "\n") + } + + start := len(lines) - maxLines + tail := lines[start:] + return strings.Join(tail, "\n") +} + +func truncate(s string, n int) string { + if len(s) <= n { + return s + } + + return s[:n-3] + "..." +} + +func nowRFC3339() string { + return time.Now().UTC().Format(time.RFC3339) +} diff --git a/desktop/app.go b/desktop/app.go new file mode 100644 index 00000000..f3b762f0 --- /dev/null +++ b/desktop/app.go @@ -0,0 +1,718 @@ +package main + +import ( + "context" + "fmt" + "os" + "os/exec" + "path/filepath" + "sort" + "strings" + "time" + + "github.com/m0n0x41d/haft/db" + "github.com/m0n0x41d/haft/internal/artifact" + "github.com/m0n0x41d/haft/internal/graph" + "github.com/m0n0x41d/haft/internal/project" + "github.com/wailsapp/wails/v2/pkg/runtime" +) + +// App is the Wails binding layer. Exported methods become callable from the React frontend. +// This is a thin adapter — all domain logic lives in internal/artifact. +type App struct { + ctx context.Context + store *artifact.Store + dbConn *db.Store + projectName string + projectRoot string + tasks *taskRunner + flows *flowController + governance *governanceController + terminals *terminalManager +} + +func NewApp() *App { + return &App{} +} + +func (a *App) startup(ctx context.Context) { + a.ctx = ctx + + root := strings.TrimSpace(a.projectRoot) + if root == "" { + detectedRoot, err := findProjectRoot() + if err != nil { + fmt.Fprintf(os.Stderr, "haft desktop: no .haft/ directory found: %v\n", err) + return + } + + root = detectedRoot + } + + absRoot, err := filepath.Abs(root) + if err != nil { + fmt.Fprintf(os.Stderr, "haft desktop: failed to resolve project root: %v\n", err) + return + } + a.projectRoot = absRoot + + haftDir := filepath.Join(a.projectRoot, ".haft") + projCfg, err := project.Load(haftDir) + if err != nil || projCfg == nil { + fmt.Fprintf(os.Stderr, "haft desktop: failed to load project config: %v\n", err) + return + } + a.projectName = projCfg.Name + + dbPath, err := projCfg.DBPath() + if err != nil { + fmt.Fprintf(os.Stderr, "haft desktop: failed to get DB path: %v\n", err) + return + } + + database, err := db.NewStore(dbPath) + if err != nil { + fmt.Fprintf(os.Stderr, "haft desktop: failed to open DB: %v\n", err) + return + } + // Enable WAL mode + busy timeout to prevent SQLITE_BUSY when + // governance scanner and UI queries run concurrently. + rawDB := database.GetRawDB() + _, _ = rawDB.Exec("PRAGMA journal_mode=WAL") + _, _ = rawDB.Exec("PRAGMA busy_timeout=5000") + + a.dbConn = database + a.store = artifact.NewStore(rawDB) + a.tasks = newTaskRunner(a, newDesktopTaskStore(database.GetRawDB())) + a.flows = newFlowController(a, newDesktopFlowStore(database.GetRawDB())) + a.governance = newGovernanceController(a, a.store, database.GetRawDB(), a.projectRoot) + a.terminals = newTerminalManager(a) + + if err := a.tasks.restore(a.ctx, a.projectRoot); err != nil { + fmt.Fprintf(os.Stderr, "haft desktop: failed to restore desktop tasks: %v\n", err) + } + + if err := a.flows.reload(a.ctx); err != nil { + fmt.Fprintf(os.Stderr, "haft desktop: failed to start flow scheduler: %v\n", err) + } + + if a.canUseNotifications() { + if err := runtime.InitializeNotifications(a.ctx); err != nil { + a.emitAppError("notifications", err) + } + } + + if a.governance != nil && (a.canEmitEvents() || a.canUseNotifications()) { + a.governance.start(a.ctx) + } +} + +func (a *App) shutdown(_ context.Context) { + if a.governance != nil { + a.governance.shutdown() + } + + if a.flows != nil { + a.flows.shutdown() + } + + if a.terminals != nil { + a.terminals.shutdown() + } + + if a.canUseNotifications() { + runtime.CleanupNotifications(a.ctx) + } + + if a.tasks != nil { + a.tasks.shutdown() + } + + if a.dbConn != nil { + a.dbConn.Close() + } +} + +// --- Binding methods: read-only views for the frontend --- + +func (a *App) GetDashboard() (*DashboardView, error) { + if a.store == nil { + return nil, fmt.Errorf("no database connection") + } + + problems, _ := a.store.ListActiveByKind(a.ctx, artifact.KindProblemCard, 100) + decisions, _ := a.store.ListActiveByKind(a.ctx, artifact.KindDecisionRecord, 100) + stale, _ := a.store.FindStaleArtifacts(a.ctx) + notes, _ := a.store.ListActiveByKind(a.ctx, artifact.KindNote, 50) + portfolios, _ := a.store.ListActiveByKind(a.ctx, artifact.KindSolutionPortfolio, 100) + + return &DashboardView{ + ProjectName: a.projectName, + ProblemCount: len(problems), + DecisionCount: len(decisions), + PortfolioCount: len(portfolios), + NoteCount: len(notes), + StaleCount: len(stale), + RecentProblems: mapArtifacts(problems, toProblemView, 8), + RecentDecisions: mapArtifacts(decisions, toDecisionView, 8), + StaleItems: mapArtifacts(stale, toArtifactView, 10), + }, nil +} + +func (a *App) ListProblems() ([]ProblemView, error) { + if a.store == nil { + return nil, fmt.Errorf("no database connection") + } + arts, err := a.store.ListActiveByKind(a.ctx, artifact.KindProblemCard, 200) + if err != nil { + return nil, err + } + return mapArtifacts(arts, toProblemView, 0), nil +} + +func (a *App) ListDecisions() ([]DecisionView, error) { + if a.store == nil { + return nil, fmt.Errorf("no database connection") + } + arts, err := a.store.ListActiveByKind(a.ctx, artifact.KindDecisionRecord, 200) + if err != nil { + return nil, err + } + return mapArtifacts(arts, toDecisionView, 0), nil +} + +func (a *App) GetProblem(id string) (*ProblemDetailView, error) { + if a.store == nil { + return nil, fmt.Errorf("no database connection") + } + art, err := a.store.Get(a.ctx, id) + if err != nil { + return nil, err + } + v := toProblemDetail(a.ctx, art, a.store) + return &v, nil +} + +func (a *App) GetDecision(id string) (*DecisionDetailView, error) { + if a.store == nil { + return nil, fmt.Errorf("no database connection") + } + + _, view, err := a.loadDecisionDetail(id) + if err != nil { + return nil, err + } + + return &view, nil +} + +func (a *App) GetPortfolio(id string) (*PortfolioDetailView, error) { + if a.store == nil { + return nil, fmt.Errorf("no database connection") + } + art, err := a.store.Get(a.ctx, id) + if err != nil { + return nil, err + } + v := toPortfolioDetail(art) + return &v, nil +} + +func (a *App) ListPortfolios() ([]PortfolioSummaryView, error) { + if a.store == nil { + return nil, fmt.Errorf("no database connection") + } + arts, err := a.store.ListActiveByKind(a.ctx, artifact.KindSolutionPortfolio, 200) + if err != nil { + return nil, err + } + return mapArtifacts(arts, toPortfolioSummary, 0), nil +} + +func (a *App) OpenDirectoryPicker() (string, error) { + defaultDirectory := a.projectRoot + if defaultDirectory == "" { + home, err := os.UserHomeDir() + if err == nil { + defaultDirectory = home + } + } + + return runtime.OpenDirectoryDialog(a.ctx, runtime.OpenDialogOptions{ + Title: "Choose project directory", + DefaultDirectory: defaultDirectory, + CanCreateDirectories: true, + }) +} + +func (a *App) OpenPathInIDE(path string) error { + targetPath := strings.TrimSpace(path) + if targetPath == "" { + return fmt.Errorf("path is required") + } + + absPath, err := filepath.Abs(targetPath) + if err != nil { + return fmt.Errorf("resolve path: %w", err) + } + + info, err := os.Stat(absPath) + if err != nil { + return fmt.Errorf("open path %s: %w", absPath, err) + } + + if !info.IsDir() { + return fmt.Errorf("path is not a directory: %s", absPath) + } + + cfg := defaultDesktopConfig() + loadedConfig, err := loadDesktopConfig() + if err == nil && loadedConfig != nil { + cfg = *loadedConfig + } + + command := buildIDECommand(cfg.DefaultIDE, absPath) + commandPath, err := exec.LookPath(command[0]) + if err != nil { + return fmt.Errorf("%s not found in PATH", command[0]) + } + + openCommand := exec.Command(commandPath, command[1:]...) + + if err := openCommand.Start(); err != nil { + return fmt.Errorf("start %s: %w", command[0], err) + } + + return nil +} + +// ImplementDecision spawns an agent with the full decision context as prompt. +// This is the Decision-Anchored Implementation flow — the AIEE differentiator. +func (a *App) ImplementDecision(decisionID string, agentKind string, useWorktree bool, branchName string) (*TaskState, error) { + if a.store == nil { + return nil, fmt.Errorf("no database connection") + } + + dec, detail, err := a.loadDecisionDetail(decisionID) + if err != nil { + return nil, fmt.Errorf("decision not found: %w", err) + } + + problems := a.loadDecisionProblems(detail.ProblemRefs) + + // Enrich with invariants from ALL decisions governing the affected files, + // not just this decision's own invariants. This is the knowledge graph value: + // agents see the full architectural context, not just one decision's view. + detail = a.enrichWithGraphInvariants(detail) + + prompt := buildImplementationPrompt(dec, detail, problems) + + if branchName == "" { + branchName = fmt.Sprintf("implement-%s", decisionID) + } + + return a.spawnTaskWithTitle( + agentKind, + prompt, + useWorktree, + branchName, + decisionTaskTitle("Implement", detail), + ) +} + +// VerifyDecision spawns an agent to verify a decision's claims. +func (a *App) VerifyDecision(decisionID string, agentKind string) (*TaskState, error) { + if a.store == nil { + return nil, fmt.Errorf("no database connection") + } + + dec, detail, err := a.loadDecisionDetail(decisionID) + if err != nil { + return nil, fmt.Errorf("decision not found: %w", err) + } + + prompt := buildVerificationPrompt(dec, detail) + + return a.spawnTaskWithTitle( + agentKind, + prompt, + false, + "", + decisionTaskTitle("Verify", detail), + ) +} + +func (a *App) SearchArtifacts(query string) ([]ArtifactView, error) { + if a.store == nil { + return nil, fmt.Errorf("no database connection") + } + arts, err := a.store.Search(a.ctx, query, 50) + if err != nil { + return nil, err + } + return mapArtifacts(arts, toArtifactView, 0), nil +} + +// AssessComparisonReadiness evaluates whether a portfolio is ready for fair comparison. +// This is the probe-or-commit gate: commit (ready), probe (need data), widen (need variants), reroute (wrong framing). +func (a *App) AssessComparisonReadiness(portfolioID string) (*graph.ReadinessReport, error) { + if a.dbConn == nil { + return nil, fmt.Errorf("no database connection") + } + return graph.AssessReadiness(a.ctx, a.dbConn.GetRawDB(), portfolioID) +} + +func (a *App) GetCoverage() (*CoverageView, error) { + if a.store == nil { + return nil, fmt.Errorf("no database connection") + } + + coverage, err := buildCoverageView(a.ctx, a.store.DB(), a.projectRoot, nil) + if err != nil { + return nil, err + } + + return &coverage, nil +} + +func (a *App) GetGovernanceOverview() (*GovernanceOverviewView, error) { + if a.governance == nil { + return &GovernanceOverviewView{}, nil // not yet initialized (e.g. during project switch) + } + + overview, err := a.governance.snapshotOrScan(a.ctx) + if err != nil { + return nil, err + } + + return &overview, nil +} + +func (a *App) RefreshGovernance() (*GovernanceOverviewView, error) { + if a.governance == nil { + return &GovernanceOverviewView{}, nil + } + + overview, err := a.governance.scan(a.ctx, true) + if err != nil { + return nil, err + } + + return &overview, nil +} + +func (a *App) ListProblemCandidates() ([]ProblemCandidateView, error) { + overview, err := a.GetGovernanceOverview() + if err != nil { + return nil, err + } + + return overview.ProblemCandidates, nil +} + +func (a *App) DismissProblemCandidate(id string) error { + if a.governance == nil || a.governance.state == nil { + return fmt.Errorf("governance controller is not initialized") + } + + if _, err := a.governance.state.GetCandidate(a.ctx, strings.TrimSpace(id)); err != nil { + return fmt.Errorf("load problem candidate %s: %w", id, err) + } + + if err := a.governance.state.SetCandidateStatus(a.ctx, id, candidateStatusDismissed, ""); err != nil { + return fmt.Errorf("dismiss problem candidate %s: %w", id, err) + } + + if _, err := a.governance.scan(a.ctx, false); err != nil { + return fmt.Errorf("refresh governance after dismissal: %w", err) + } + + return nil +} + +func (a *App) AdoptProblemCandidate(id string) (*ProblemDetailView, error) { + if a.store == nil { + return nil, fmt.Errorf("no database connection") + } + if a.governance == nil || a.governance.state == nil { + return nil, fmt.Errorf("governance controller is not initialized") + } + + candidate, err := a.governance.state.GetCandidate(a.ctx, strings.TrimSpace(id)) + if err != nil { + return nil, fmt.Errorf("load problem candidate %s: %w", id, err) + } + + if candidate.Status == candidateStatusDismissed { + return nil, fmt.Errorf("problem candidate %s has already been dismissed", id) + } + + if candidate.Status == candidateStatusAdopted && candidate.ProblemRef != "" { + problem, err := a.store.Get(a.ctx, candidate.ProblemRef) + if err != nil { + return nil, fmt.Errorf("load adopted problem %s: %w", candidate.ProblemRef, err) + } + view := toProblemDetail(a.ctx, problem, a.store) + return &view, nil + } + + created, _, err := artifact.FrameProblem(a.ctx, a.store, a.haftDir(), artifact.ProblemFrameInput{ + Title: candidate.Title, + Signal: candidate.Signal, + Acceptance: candidate.Acceptance, + Context: candidate.Context, + Mode: "tactical", + BlastRadius: "Governance follow-up from the desktop decision loop", + Reversibility: "high", + Constraints: []string{ + "Validate the surfaced governance finding with fresh evidence before making irreversible changes.", + }, + OptimizationTargets: []string{ + "Close the surfaced governance gap quickly", + }, + }) + if err != nil { + return nil, fmt.Errorf("frame problem for candidate %s: %w", id, err) + } + + if candidate.SourceArtifactRef != "" { + if _, err := a.store.Get(a.ctx, candidate.SourceArtifactRef); err == nil { + _ = a.store.AddLink(a.ctx, created.Meta.ID, candidate.SourceArtifactRef, "based_on") + } + } + + if err := a.governance.state.SetCandidateStatus(a.ctx, id, candidateStatusAdopted, created.Meta.ID); err != nil { + return nil, fmt.Errorf("mark problem candidate %s adopted: %w", id, err) + } + + if _, err := a.governance.scan(a.ctx, false); err != nil { + return nil, fmt.Errorf("refresh governance after adoption: %w", err) + } + + view := toProblemDetail(a.ctx, created, a.store) + return &view, nil +} + +func (a *App) loadDecisionDetail(id string) (*artifact.Artifact, DecisionDetailView, error) { + art, err := a.store.Get(a.ctx, id) + if err != nil { + return nil, DecisionDetailView{}, err + } + + affectedFiles, coverageModules, coverageWarnings := a.loadDecisionGovernance(art.Meta.ID) + evidence := a.loadDecisionEvidence(art) + view := toDecisionDetail(art, affectedFiles, coverageModules, coverageWarnings, evidence) + + return art, view, nil +} + +func (a *App) loadDecisionGovernance(id string) ([]string, []CoverageModuleView, []string) { + warnings := make([]string, 0) + + if a.store == nil { + return nil, nil, []string{"Decision governance context is unavailable because no database is connected."} + } + + affectedFileRows, err := a.store.GetAffectedFiles(a.ctx, id) + if err != nil { + warnings = append(warnings, fmt.Sprintf("Load affected files: %v", err)) + } + + affectedFiles := make([]string, 0, len(affectedFileRows)) + for _, file := range affectedFileRows { + affectedFiles = append(affectedFiles, file.Path) + } + sort.Strings(affectedFiles) + + if len(affectedFiles) == 0 { + warnings = append(warnings, "No affected files are recorded for this decision yet.") + return affectedFiles, nil, warnings + } + + coverage, err := buildCoverageView(a.ctx, a.store.DB(), a.projectRoot, affectedFiles) + if err != nil { + warnings = append(warnings, fmt.Sprintf("Coverage context is unavailable: %v", err)) + return affectedFiles, nil, warnings + } + + impacted := make([]CoverageModuleView, 0) + for _, module := range coverage.Modules { + if !module.Impacted { + continue + } + impacted = append(impacted, module) + } + + if len(impacted) == 0 { + warnings = append(warnings, "Affected files do not map to any scanned module yet.") + } + + return affectedFiles, impacted, warnings +} + +func (a *App) loadDecisionEvidence(art *artifact.Artifact) EvidenceSummaryView { + summary := EvidenceSummaryView{ + Items: []EvidenceItemView{}, + CoverageGaps: []string{}, + } + + if a.store == nil || art == nil { + return summary + } + + df := art.UnmarshalDecisionFields() + summary.TotalClaims = len(df.Claims) + + items, err := a.store.GetEvidenceItems(a.ctx, art.Meta.ID) + if err != nil { + return summary + } + + now := time.Now().UTC() + coveredClaims := make(map[string]bool) + + for _, item := range items { + isExpired := false + if item.ValidUntil != "" { + if t, err := time.Parse(time.RFC3339, item.ValidUntil); err == nil { + isExpired = now.After(t) + } else if t, err := time.Parse("2006-01-02", item.ValidUntil); err == nil { + isExpired = now.After(t) + } + } + + for _, ref := range item.ClaimRefs { + coveredClaims[ref] = true + } + for _, scope := range item.ClaimScope { + coveredClaims[scope] = true + } + + summary.Items = append(summary.Items, EvidenceItemView{ + ID: item.ID, + Type: item.Type, + Content: item.Content, + Verdict: item.Verdict, + FormalityLevel: item.FormalityLevel, + CongruenceLevel: item.CongruenceLevel, + ClaimRefs: safeStrings(item.ClaimRefs), + ValidUntil: item.ValidUntil, + IsExpired: isExpired, + }) + } + + summary.CoveredClaims = len(coveredClaims) + + // Find coverage gaps: claims that have no evidence + for _, claim := range df.Claims { + if !coveredClaims[claim.ID] { + summary.CoverageGaps = append(summary.CoverageGaps, claim.ID+": "+claim.Claim) + } + } + + return summary +} + +func (a *App) loadDecisionProblems(problemRefs []string) []*artifact.Artifact { + if a.store == nil || len(problemRefs) == 0 { + return nil + } + + problems := make([]*artifact.Artifact, 0, len(problemRefs)) + for _, problemRef := range problemRefs { + problem, err := a.store.Get(a.ctx, problemRef) + if err != nil { + continue + } + problems = append(problems, problem) + } + + return problems +} + +// enrichWithGraphInvariants queries the knowledge graph for invariants +// from OTHER decisions that govern the same affected files. Deduplicates +// against the decision's own invariants and appends with source attribution. +func (a *App) enrichWithGraphInvariants(detail DecisionDetailView) DecisionDetailView { + if a.dbConn == nil || len(detail.AffectedFiles) == 0 { + return detail + } + + gs := graph.NewStore(a.dbConn.GetRawDB()) + + // Collect existing invariant texts for dedup + existing := make(map[string]bool, len(detail.Invariants)) + for _, inv := range detail.Invariants { + existing[inv] = true + } + + var extra []string + for _, filePath := range detail.AffectedFiles { + invariants, err := gs.FindInvariantsForFile(a.ctx, filePath) + if err != nil { + continue + } + for _, inv := range invariants { + // Skip invariants from this decision (already included) + if inv.DecisionID == detail.ID { + continue + } + tagged := fmt.Sprintf("[%s] %s", inv.DecisionID, inv.Text) + if !existing[tagged] && !existing[inv.Text] { + existing[tagged] = true + extra = append(extra, tagged) + } + } + } + + if len(extra) > 0 { + // Append graph-sourced invariants after the decision's own + enriched := make([]string, 0, len(detail.Invariants)+len(extra)) + enriched = append(enriched, detail.Invariants...) + enriched = append(enriched, extra...) + detail.Invariants = enriched + } + + return detail +} + +// --- Helpers --- + +func findProjectRoot() (string, error) { + dir, err := os.Getwd() + if err != nil { + return "", err + } + for { + if _, err := os.Stat(filepath.Join(dir, ".haft")); err == nil { + return dir, nil + } + parent := filepath.Dir(dir) + if parent == dir { + return "", fmt.Errorf("no .haft/ found") + } + dir = parent + } +} + +func mapArtifacts[T any](arts []*artifact.Artifact, fn func(*artifact.Artifact) T, limit int) []T { + if limit <= 0 || limit > len(arts) { + limit = len(arts) + } + result := make([]T, 0, limit) + for i := range limit { + result = append(result, fn(arts[i])) + } + return result +} + +func (a *App) emitAppError(scope string, err error) { + if err == nil { + return + } + + a.emitEvent("app.error", map[string]string{ + "scope": scope, + "message": err.Error(), + }) +} diff --git a/desktop/authoring.go b/desktop/authoring.go new file mode 100644 index 00000000..2b2864ad --- /dev/null +++ b/desktop/authoring.go @@ -0,0 +1,595 @@ +package main + +import ( + "fmt" + "path/filepath" + "strings" + + "github.com/m0n0x41d/haft/internal/artifact" +) + +type ProblemCreateInput struct { + Title string `json:"title"` + Signal string `json:"signal"` + Acceptance string `json:"acceptance"` + BlastRadius string `json:"blast_radius"` + Reversibility string `json:"reversibility"` + Context string `json:"context"` + Mode string `json:"mode"` + Constraints []string `json:"constraints"` + OptimizationTargets []string `json:"optimization_targets"` + ObservationIndicators []string `json:"observation_indicators"` +} + +type ComparisonDimensionInput struct { + Name string `json:"name"` + ScaleType string `json:"scale_type"` + Unit string `json:"unit"` + Polarity string `json:"polarity"` + Role string `json:"role"` + HowToMeasure string `json:"how_to_measure"` + ValidUntil string `json:"valid_until"` +} + +type NormRuleInput struct { + Dimension string `json:"dimension"` + Method string `json:"method"` +} + +type ParityPlanInput struct { + BaselineSet []string `json:"baseline_set"` + Window string `json:"window"` + Budget string `json:"budget"` + Normalization []NormRuleInput `json:"normalization"` + MissingDataPolicy string `json:"missing_data_policy"` + PinnedConditions []string `json:"pinned_conditions"` +} + +type ProblemCharacterizationInput struct { + ProblemRef string `json:"problem_ref"` + Dimensions []ComparisonDimensionInput `json:"dimensions"` + ParityRules string `json:"parity_rules"` + ParityPlan *ParityPlanInput `json:"parity_plan"` +} + +type PortfolioVariantInput struct { + ID string `json:"id"` + Title string `json:"title"` + Description string `json:"description"` + Strengths []string `json:"strengths"` + WeakestLink string `json:"weakest_link"` + NoveltyMarker string `json:"novelty_marker"` + Risks []string `json:"risks"` + SteppingStone bool `json:"stepping_stone"` + SteppingStoneBasis string `json:"stepping_stone_basis"` + DiversityRole string `json:"diversity_role"` + AssumptionNotes string `json:"assumption_notes"` + RollbackNotes string `json:"rollback_notes"` + EvidenceRefs []string `json:"evidence_refs"` +} + +type PortfolioCreateInput struct { + ProblemRef string `json:"problem_ref"` + Context string `json:"context"` + Mode string `json:"mode"` + NoSteppingStoneRationale string `json:"no_stepping_stone_rationale"` + Variants []PortfolioVariantInput `json:"variants"` +} + +type DominatedNoteInput struct { + Variant string `json:"variant"` + DominatedBy []string `json:"dominated_by"` + Summary string `json:"summary"` +} + +type TradeoffNoteInput struct { + Variant string `json:"variant"` + Summary string `json:"summary"` +} + +type PortfolioCompareInput struct { + PortfolioRef string `json:"portfolio_ref"` + Dimensions []string `json:"dimensions"` + Scores map[string]map[string]string `json:"scores"` + NonDominatedSet []string `json:"non_dominated_set"` + Incomparable [][]string `json:"incomparable"` + DominatedNotes []DominatedNoteInput `json:"dominated_notes"` + ParetoTradeoffs []TradeoffNoteInput `json:"pareto_tradeoffs"` + PolicyApplied string `json:"policy_applied"` + SelectedRef string `json:"selected_ref"` + Recommendation string `json:"recommendation"` + ParityPlan *ParityPlanInput `json:"parity_plan"` +} + +type DecisionRejectionInput struct { + Variant string `json:"variant"` + Reason string `json:"reason"` +} + +type DecisionPredictionInput struct { + Claim string `json:"claim"` + Observable string `json:"observable"` + Threshold string `json:"threshold"` + VerifyAfter string `json:"verify_after"` +} + +type DecisionRollbackInput struct { + Triggers []string `json:"triggers"` + Steps []string `json:"steps"` + BlastRadius string `json:"blast_radius"` +} + +type DecisionCreateInput struct { + ProblemRef string `json:"problem_ref"` + ProblemRefs []string `json:"problem_refs"` + PortfolioRef string `json:"portfolio_ref"` + SelectedRef string `json:"selected_ref"` + SelectedTitle string `json:"selected_title"` + WhySelected string `json:"why_selected"` + SelectionPolicy string `json:"selection_policy"` + CounterArgument string `json:"counterargument"` + WhyNotOthers []DecisionRejectionInput `json:"why_not_others"` + Invariants []string `json:"invariants"` + PreConditions []string `json:"pre_conditions"` + PostConditions []string `json:"post_conditions"` + Admissibility []string `json:"admissibility"` + EvidenceRequirements []string `json:"evidence_requirements"` + Rollback *DecisionRollbackInput `json:"rollback"` + RefreshTriggers []string `json:"refresh_triggers"` + WeakestLink string `json:"weakest_link"` + ValidUntil string `json:"valid_until"` + Context string `json:"context"` + Mode string `json:"mode"` + AffectedFiles []string `json:"affected_files"` + Predictions []DecisionPredictionInput `json:"predictions"` + SearchKeywords string `json:"search_keywords"` + FirstModuleCoverage bool `json:"first_module_coverage"` +} + +func (a *App) CreateProblem(input ProblemCreateInput) (*ProblemDetailView, error) { + if a.store == nil { + return nil, fmt.Errorf("no database connection") + } + + created, _, err := artifact.FrameProblem(a.ctx, a.store, a.haftDir(), artifact.ProblemFrameInput{ + Title: strings.TrimSpace(input.Title), + Signal: strings.TrimSpace(input.Signal), + Constraints: compactStrings(input.Constraints), + OptimizationTargets: compactStrings(input.OptimizationTargets), + ObservationIndicators: compactStrings(input.ObservationIndicators), + Acceptance: strings.TrimSpace(input.Acceptance), + BlastRadius: strings.TrimSpace(input.BlastRadius), + Reversibility: strings.TrimSpace(input.Reversibility), + Context: strings.TrimSpace(input.Context), + Mode: strings.TrimSpace(input.Mode), + }) + if err != nil { + return nil, err + } + + view := toProblemDetail(a.ctx, created, a.store) + return &view, nil +} + +func (a *App) CharacterizeProblem(input ProblemCharacterizationInput) (*ProblemDetailView, error) { + if a.store == nil { + return nil, fmt.Errorf("no database connection") + } + + updated, _, err := artifact.CharacterizeProblem(a.ctx, a.store, a.haftDir(), artifact.CharacterizeInput{ + ProblemRef: strings.TrimSpace(input.ProblemRef), + Dimensions: toArtifactDimensions(input.Dimensions), + ParityRules: strings.TrimSpace(input.ParityRules), + ParityPlan: toArtifactParityPlan(input.ParityPlan), + }) + if err != nil { + return nil, err + } + + view := toProblemDetail(a.ctx, updated, a.store) + return &view, nil +} + +func (a *App) CreatePortfolio(input PortfolioCreateInput) (*PortfolioDetailView, error) { + if a.store == nil { + return nil, fmt.Errorf("no database connection") + } + + created, _, err := artifact.ExploreSolutions(a.ctx, a.store, a.haftDir(), artifact.ExploreInput{ + ProblemRef: strings.TrimSpace(input.ProblemRef), + Context: strings.TrimSpace(input.Context), + Mode: strings.TrimSpace(input.Mode), + NoSteppingStoneRationale: strings.TrimSpace(input.NoSteppingStoneRationale), + Variants: toArtifactVariants(input.Variants), + }) + if err != nil { + return nil, err + } + + view := toPortfolioDetail(created) + return &view, nil +} + +func (a *App) ComparePortfolio(input PortfolioCompareInput) (*PortfolioDetailView, error) { + if a.store == nil { + return nil, fmt.Errorf("no database connection") + } + + updated, _, err := artifact.CompareSolutions(a.ctx, a.store, a.haftDir(), artifact.CompareInput{ + PortfolioRef: strings.TrimSpace(input.PortfolioRef), + Results: artifact.ComparisonResult{ + Dimensions: compactStrings(input.Dimensions), + Scores: normalizeScoreMatrix(input.Scores), + NonDominatedSet: compactStrings(input.NonDominatedSet), + Incomparable: normalizePairs(input.Incomparable), + DominatedVariants: toArtifactDominatedNotes(input.DominatedNotes), + ParetoTradeoffs: toArtifactTradeoffNotes(input.ParetoTradeoffs), + PolicyApplied: strings.TrimSpace(input.PolicyApplied), + SelectedRef: strings.TrimSpace(input.SelectedRef), + RecommendationRationale: strings.TrimSpace(input.Recommendation), + ParityPlan: toArtifactParityPlan(input.ParityPlan), + }, + }) + if err != nil { + return nil, err + } + + view := toPortfolioDetail(updated) + return &view, nil +} + +func (a *App) CreateDecision(input DecisionCreateInput) (*DecisionDetailView, error) { + if a.store == nil { + return nil, fmt.Errorf("no database connection") + } + + resolved := normalizeDecisionCreateInput(input) + resolved, err := a.enrichDecisionFromPortfolio(resolved) + if err != nil { + return nil, err + } + + created, _, err := artifact.Decide(a.ctx, a.store, a.haftDir(), artifact.DecideInput{ + ProblemRef: resolved.ProblemRef, + ProblemRefs: compactStrings(resolved.ProblemRefs), + PortfolioRef: resolved.PortfolioRef, + SelectedTitle: resolved.SelectedTitle, + WhySelected: resolved.WhySelected, + SelectionPolicy: resolved.SelectionPolicy, + CounterArgument: resolved.CounterArgument, + WhyNotOthers: toArtifactRejections(resolved.WhyNotOthers), + Invariants: compactStrings(resolved.Invariants), + PreConditions: compactStrings(resolved.PreConditions), + PostConditions: compactStrings(resolved.PostConditions), + Admissibility: compactStrings(resolved.Admissibility), + EvidenceReqs: compactStrings(resolved.EvidenceRequirements), + Rollback: toArtifactRollback(resolved.Rollback), + RefreshTriggers: compactStrings(resolved.RefreshTriggers), + WeakestLink: strings.TrimSpace(resolved.WeakestLink), + ValidUntil: strings.TrimSpace(resolved.ValidUntil), + Context: strings.TrimSpace(resolved.Context), + Mode: strings.TrimSpace(resolved.Mode), + AffectedFiles: compactStrings(resolved.AffectedFiles), + Predictions: toArtifactPredictions(resolved.Predictions), + SearchKeywords: strings.TrimSpace(resolved.SearchKeywords), + FirstModuleCoverage: resolved.FirstModuleCoverage, + }) + if err != nil { + return nil, err + } + + _, view, err := a.loadDecisionDetail(created.Meta.ID) + if err != nil { + return nil, err + } + + return &view, nil +} + +func (a *App) haftDir() string { + return filepath.Join(a.projectRoot, ".haft") +} + +func normalizeDecisionCreateInput(input DecisionCreateInput) DecisionCreateInput { + input.ProblemRef = strings.TrimSpace(input.ProblemRef) + input.ProblemRefs = compactStrings(input.ProblemRefs) + input.PortfolioRef = strings.TrimSpace(input.PortfolioRef) + input.SelectedRef = strings.TrimSpace(input.SelectedRef) + input.SelectedTitle = strings.TrimSpace(input.SelectedTitle) + input.WhySelected = strings.TrimSpace(input.WhySelected) + input.SelectionPolicy = strings.TrimSpace(input.SelectionPolicy) + input.CounterArgument = strings.TrimSpace(input.CounterArgument) + input.WeakestLink = strings.TrimSpace(input.WeakestLink) + input.ValidUntil = strings.TrimSpace(input.ValidUntil) + input.Context = strings.TrimSpace(input.Context) + input.Mode = strings.TrimSpace(input.Mode) + input.SearchKeywords = strings.TrimSpace(input.SearchKeywords) + input.Invariants = compactStrings(input.Invariants) + input.PreConditions = compactStrings(input.PreConditions) + input.PostConditions = compactStrings(input.PostConditions) + input.Admissibility = compactStrings(input.Admissibility) + input.EvidenceRequirements = compactStrings(input.EvidenceRequirements) + input.RefreshTriggers = compactStrings(input.RefreshTriggers) + input.AffectedFiles = compactStrings(input.AffectedFiles) + + return input +} + +func (a *App) enrichDecisionFromPortfolio(input DecisionCreateInput) (DecisionCreateInput, error) { + if input.PortfolioRef == "" { + return input, nil + } + + portfolio, err := a.store.Get(a.ctx, input.PortfolioRef) + if err != nil { + return input, fmt.Errorf("portfolio %s not found: %w", input.PortfolioRef, err) + } + + fields := portfolio.UnmarshalPortfolioFields() + variantByID := make(map[string]artifact.Variant) + variantByTitle := make(map[string]artifact.Variant) + + for _, variant := range fields.Variants { + variantByID[variant.ID] = variant + variantByTitle[strings.TrimSpace(variant.Title)] = variant + } + + selectedRef := input.SelectedRef + if input.SelectedTitle == "" && selectedRef != "" { + if variant, ok := variantByID[selectedRef]; ok { + input.SelectedTitle = strings.TrimSpace(variant.Title) + } + } + + if input.SelectedTitle == "" { + input.SelectedTitle = selectedRef + } + + selectedVariant, hasSelectedVariant := variantByID[selectedRef] + if !hasSelectedVariant { + selectedVariant, hasSelectedVariant = variantByTitle[input.SelectedTitle] + } + + if input.WeakestLink == "" && hasSelectedVariant { + input.WeakestLink = strings.TrimSpace(selectedVariant.WeakestLink) + } + + if input.ProblemRef == "" && len(input.ProblemRefs) == 0 && strings.TrimSpace(fields.ProblemRef) != "" { + input.ProblemRef = strings.TrimSpace(fields.ProblemRef) + } + + if input.Context == "" && portfolio.Meta.Context != "" { + input.Context = string(portfolio.Meta.Context) + } + + if input.Mode == "" && portfolio.Meta.Mode != "" { + input.Mode = string(portfolio.Meta.Mode) + } + + if len(input.WhyNotOthers) == 0 { + input.WhyNotOthers = defaultDecisionRejections(fields, input.SelectedTitle) + return input, nil + } + + normalized := make([]DecisionRejectionInput, 0, len(input.WhyNotOthers)) + for _, rejection := range input.WhyNotOthers { + variant := strings.TrimSpace(rejection.Variant) + reason := strings.TrimSpace(rejection.Reason) + if mapped, ok := variantByID[variant]; ok { + variant = strings.TrimSpace(mapped.Title) + } + normalized = append(normalized, DecisionRejectionInput{Variant: variant, Reason: reason}) + } + input.WhyNotOthers = normalized + + return input, nil +} + +func defaultDecisionRejections(fields artifact.PortfolioFields, selectedTitle string) []DecisionRejectionInput { + rejections := make([]DecisionRejectionInput, 0, len(fields.Variants)) + + for _, variant := range fields.Variants { + title := strings.TrimSpace(variant.Title) + if title == "" || strings.EqualFold(title, selectedTitle) { + continue + } + + rejections = append(rejections, DecisionRejectionInput{ + Variant: title, + Reason: fmt.Sprintf("Did not beat %s under the active comparison policy.", selectedTitle), + }) + } + + return rejections +} + +func toArtifactDimensions(inputs []ComparisonDimensionInput) []artifact.ComparisonDimension { + dimensions := make([]artifact.ComparisonDimension, 0, len(inputs)) + + for _, input := range inputs { + dimensions = append(dimensions, artifact.ComparisonDimension{ + Name: strings.TrimSpace(input.Name), + ScaleType: strings.TrimSpace(input.ScaleType), + Unit: strings.TrimSpace(input.Unit), + Polarity: strings.TrimSpace(input.Polarity), + Role: strings.TrimSpace(input.Role), + HowToMeasure: strings.TrimSpace(input.HowToMeasure), + ValidUntil: strings.TrimSpace(input.ValidUntil), + }) + } + + return dimensions +} + +func toArtifactParityPlan(input *ParityPlanInput) *artifact.ParityPlan { + if input == nil { + return nil + } + + plan := &artifact.ParityPlan{ + BaselineSet: compactStrings(input.BaselineSet), + Window: strings.TrimSpace(input.Window), + Budget: strings.TrimSpace(input.Budget), + MissingDataPolicy: strings.TrimSpace(input.MissingDataPolicy), + PinnedConditions: compactStrings(input.PinnedConditions), + } + + for _, rule := range input.Normalization { + plan.Normalization = append(plan.Normalization, artifact.NormRule{ + Dimension: strings.TrimSpace(rule.Dimension), + Method: strings.TrimSpace(rule.Method), + }) + } + + return plan +} + +func toArtifactVariants(inputs []PortfolioVariantInput) []artifact.Variant { + variants := make([]artifact.Variant, 0, len(inputs)) + + for _, input := range inputs { + variants = append(variants, artifact.Variant{ + ID: strings.TrimSpace(input.ID), + Title: strings.TrimSpace(input.Title), + Description: strings.TrimSpace(input.Description), + Strengths: compactStrings(input.Strengths), + WeakestLink: strings.TrimSpace(input.WeakestLink), + NoveltyMarker: strings.TrimSpace(input.NoveltyMarker), + Risks: compactStrings(input.Risks), + SteppingStone: input.SteppingStone, + SteppingStoneBasis: strings.TrimSpace(input.SteppingStoneBasis), + DiversityRole: strings.TrimSpace(input.DiversityRole), + AssumptionNotes: strings.TrimSpace(input.AssumptionNotes), + RollbackNotes: strings.TrimSpace(input.RollbackNotes), + EvidenceRefs: compactStrings(input.EvidenceRefs), + }) + } + + return variants +} + +func toArtifactDominatedNotes(inputs []DominatedNoteInput) []artifact.DominatedVariantExplanation { + notes := make([]artifact.DominatedVariantExplanation, 0, len(inputs)) + + for _, input := range inputs { + notes = append(notes, artifact.DominatedVariantExplanation{ + Variant: strings.TrimSpace(input.Variant), + DominatedBy: compactStrings(input.DominatedBy), + Summary: strings.TrimSpace(input.Summary), + }) + } + + return notes +} + +func toArtifactTradeoffNotes(inputs []TradeoffNoteInput) []artifact.ParetoTradeoffNote { + notes := make([]artifact.ParetoTradeoffNote, 0, len(inputs)) + + for _, input := range inputs { + notes = append(notes, artifact.ParetoTradeoffNote{ + Variant: strings.TrimSpace(input.Variant), + Summary: strings.TrimSpace(input.Summary), + }) + } + + return notes +} + +func toArtifactRejections(inputs []DecisionRejectionInput) []artifact.RejectionReason { + rejections := make([]artifact.RejectionReason, 0, len(inputs)) + + for _, input := range inputs { + rejections = append(rejections, artifact.RejectionReason{ + Variant: strings.TrimSpace(input.Variant), + Reason: strings.TrimSpace(input.Reason), + }) + } + + return rejections +} + +func toArtifactRollback(input *DecisionRollbackInput) *artifact.RollbackSpec { + if input == nil { + return nil + } + + return &artifact.RollbackSpec{ + Triggers: compactStrings(input.Triggers), + Steps: compactStrings(input.Steps), + BlastRadius: strings.TrimSpace(input.BlastRadius), + } +} + +func toArtifactPredictions(inputs []DecisionPredictionInput) []artifact.PredictionInput { + predictions := make([]artifact.PredictionInput, 0, len(inputs)) + + for _, input := range inputs { + predictions = append(predictions, artifact.PredictionInput{ + Claim: strings.TrimSpace(input.Claim), + Observable: strings.TrimSpace(input.Observable), + Threshold: strings.TrimSpace(input.Threshold), + VerifyAfter: strings.TrimSpace(input.VerifyAfter), + }) + } + + return predictions +} + +func compactStrings(values []string) []string { + compacted := make([]string, 0, len(values)) + + for _, value := range values { + trimmed := strings.TrimSpace(value) + if trimmed == "" { + continue + } + + compacted = append(compacted, trimmed) + } + + return compacted +} + +func normalizeScoreMatrix(scores map[string]map[string]string) map[string]map[string]string { + if len(scores) == 0 { + return map[string]map[string]string{} + } + + normalized := make(map[string]map[string]string, len(scores)) + + for variant, dimensionScores := range scores { + variantID := strings.TrimSpace(variant) + if variantID == "" { + continue + } + + row := make(map[string]string, len(dimensionScores)) + for dimension, value := range dimensionScores { + dimensionName := strings.TrimSpace(dimension) + if dimensionName == "" { + continue + } + + row[dimensionName] = strings.TrimSpace(value) + } + + normalized[variantID] = row + } + + return normalized +} + +func normalizePairs(pairs [][]string) [][]string { + normalized := make([][]string, 0, len(pairs)) + + for _, pair := range pairs { + current := compactStrings(pair) + if len(current) == 0 { + continue + } + + normalized = append(normalized, current) + } + + return normalized +} diff --git a/desktop/authoring_test.go b/desktop/authoring_test.go new file mode 100644 index 00000000..7cb90462 --- /dev/null +++ b/desktop/authoring_test.go @@ -0,0 +1,211 @@ +package main + +import ( + "context" + "os" + "path/filepath" + "testing" +) + +func TestDesktopReasoningAuthoringFlow(t *testing.T) { + app := newAuthoringTestApp(t) + defer app.shutdown(context.Background()) + + problem, err := app.CreateProblem(ProblemCreateInput{ + Title: "Desktop reasoning authoring gap", + Signal: "The desktop shell can view reasoning artifacts but cannot author them.", + Acceptance: "An operator can frame, compare, and decide from the desktop UI without dropping to CLI.", + BlastRadius: "Desktop authoring workflows and the local project database", + Mode: "standard", + Constraints: []string{ + "Use shared artifact logic as the single source of truth", + "Keep the desktop layer reversible", + }, + OptimizationTargets: []string{ + "Authoring loop completion time", + "Parity with CLI validation rules", + }, + }) + if err != nil { + t.Fatalf("CreateProblem: %v", err) + } + + characterized, err := app.CharacterizeProblem(ProblemCharacterizationInput{ + ProblemRef: problem.ID, + Dimensions: []ComparisonDimensionInput{ + { + Name: "operator load", + ScaleType: "ordinal", + Polarity: "lower_better", + Role: "target", + HowToMeasure: "Estimate setup and editing effort for each flow", + }, + { + Name: "implementation risk", + ScaleType: "ordinal", + Polarity: "lower_better", + Role: "constraint", + HowToMeasure: "Can the desktop shell reuse existing backend logic without divergence?", + }, + }, + ParityPlan: &ParityPlanInput{ + BaselineSet: []string{"var-1", "var-2"}, + Window: "single release step", + Budget: "one desktop iteration", + MissingDataPolicy: "explicit_abstain", + PinnedConditions: []string{ + "Use the same project and artifact store", + }, + }, + }) + if err != nil { + t.Fatalf("CharacterizeProblem: %v", err) + } + + if characterized.LatestCharacterization == nil { + t.Fatal("expected latest characterization") + } + + portfolio, err := app.CreatePortfolio(PortfolioCreateInput{ + ProblemRef: problem.ID, + Variants: []PortfolioVariantInput{ + { + ID: "var-1", + Title: "Inline modal authoring", + Description: "Keep authoring forms inside the existing pages with backend persistence.", + WeakestLink: "Dense forms can become visually heavy.", + NoveltyMarker: "Preserves the current navigation model.", + SteppingStone: true, + SteppingStoneBasis: "Adds the missing write path without redesigning the shell.", + Strengths: []string{ + "Low blast radius", + "Fastest to ship", + }, + Risks: []string{ + "Can feel cramped on smaller screens", + }, + }, + { + ID: "var-2", + Title: "Dedicated authoring workspace", + Description: "Introduce a separate composer flow for problems, compare, and decisions.", + WeakestLink: "Larger UI and navigation change.", + NoveltyMarker: "Adds a focused workspace instead of page-local composition.", + Strengths: []string{ + "More room for dense forms", + }, + Risks: []string{ + "Higher implementation cost", + }, + }, + }, + }) + if err != nil { + t.Fatalf("CreatePortfolio: %v", err) + } + + compared, err := app.ComparePortfolio(PortfolioCompareInput{ + PortfolioRef: portfolio.ID, + Dimensions: []string{ + "operator load", + "implementation risk", + }, + Scores: map[string]map[string]string{ + "var-1": { + "operator load": "Low", + "implementation risk": "Low", + }, + "var-2": { + "operator load": "Medium", + "implementation risk": "Medium", + }, + }, + PolicyApplied: "Prefer the option that closes the loop quickly without splitting validation logic.", + SelectedRef: "var-1", + DominatedNotes: []DominatedNoteInput{ + { + Variant: "var-2", + DominatedBy: []string{"var-1"}, + Summary: "The dedicated workspace adds more UI and navigation work without beating the lighter inline flow on either dimension.", + }, + }, + ParetoTradeoffs: []TradeoffNoteInput{ + { + Variant: "var-1", + Summary: "Wins on delivery speed and consistency, but still needs careful layout discipline to stay readable.", + }, + }, + Recommendation: "Use the inline authoring flow first, then graduate to a dedicated workspace only if the interaction density outgrows the current shell.", + }) + if err != nil { + t.Fatalf("ComparePortfolio: %v", err) + } + + if compared.Comparison == nil { + t.Fatal("expected stored comparison") + } + + if got := compared.Comparison.NonDominatedSet; len(got) != 1 || got[0] != "var-1" { + t.Fatalf("unexpected Pareto front: %#v", got) + } + + decision, err := app.CreateDecision(DecisionCreateInput{ + PortfolioRef: portfolio.ID, + SelectedRef: "var-1", + WhySelected: "It closes the missing authoring loop while preserving shared compare and decision validation in Go.", + SelectionPolicy: "Choose the smallest reversible step that keeps the rules authoritative in one place.", + CounterArgument: "Inline authoring could turn the existing pages into crowded multi-mode surfaces.", + Rollback: &DecisionRollbackInput{ + Triggers: []string{ + "Form density blocks routine authoring work", + }, + Steps: []string{ + "Extract the forms into a dedicated workspace route", + "Keep the same backend bindings and artifact contracts", + }, + BlastRadius: "Frontend composition only", + }, + Invariants: []string{ + "Compare validation remains backend-authoritative", + "Desktop authoring writes the same artifact structures as CLI/MCP flows", + }, + }) + if err != nil { + t.Fatalf("CreateDecision: %v", err) + } + + if decision.SelectedTitle != "Inline modal authoring" { + t.Fatalf("expected selected title to resolve from variant, got %q", decision.SelectedTitle) + } + + if len(decision.WhyNotOthers) == 0 { + t.Fatal("expected rejected alternatives to be defaulted from the portfolio") + } +} + +func newAuthoringTestApp(t *testing.T) *App { + t.Helper() + + home := t.TempDir() + t.Setenv("HOME", home) + + projectRoot := filepath.Join(t.TempDir(), "desktop-authoring") + if err := os.MkdirAll(projectRoot, 0o755); err != nil { + t.Fatalf("MkdirAll: %v", err) + } + + setup := NewApp() + if _, err := setup.InitProject(projectRoot); err != nil { + t.Fatalf("InitProject: %v", err) + } + + app := NewApp() + app.projectRoot = projectRoot + app.startup(context.Background()) + + if app.store == nil { + t.Fatal("expected artifact store after startup") + } + + return app +} diff --git a/desktop/config.go b/desktop/config.go new file mode 100644 index 00000000..d138c009 --- /dev/null +++ b/desktop/config.go @@ -0,0 +1,249 @@ +package main + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" +) + +type AgentPreset struct { + Name string `json:"name"` + AgentKind string `json:"agent_kind"` + Model string `json:"model"` + Role string `json:"role"` +} + +const currentDesktopConfigVersion = 1 + +type DesktopConfig struct { + ConfigVersion int `json:"config_version"` + DefaultAgent string `json:"default_agent"` + ReviewAgent string `json:"review_agent"` + VerifyAgent string `json:"verify_agent"` + AgentPresets []AgentPreset `json:"agent_presets"` + TaskTimeoutMinutes int `json:"task_timeout_minutes"` + SoundEnabled bool `json:"sound_enabled"` + NotifyEnabled bool `json:"notify_enabled"` + DefaultIDE string `json:"default_ide"` + DefaultWorktree bool `json:"default_worktree"` + AutoWireMCP bool `json:"auto_wire_mcp"` + DefaultAutoRun bool `json:"default_auto_run"` // true = agent runs without pausing, false = stop-and-ask +} + +func defaultDesktopConfig() DesktopConfig { + return DesktopConfig{ + ConfigVersion: currentDesktopConfigVersion, + DefaultAgent: string(AgentClaude), + ReviewAgent: string(AgentCodex), + VerifyAgent: string(AgentClaude), + AgentPresets: defaultAgentPresets(), + TaskTimeoutMinutes: 300, + SoundEnabled: true, + NotifyEnabled: true, + DefaultIDE: "code", + DefaultWorktree: true, + AutoWireMCP: true, + } +} + +func defaultAgentPresets() []AgentPreset { + return []AgentPreset{ + {Name: "Implementation", AgentKind: string(AgentClaude), Role: "implementation"}, + {Name: "Review", AgentKind: string(AgentCodex), Role: "review"}, + {Name: "Verify", AgentKind: string(AgentClaude), Role: "verify"}, + } +} + +func desktopConfigPath() (string, error) { + home, err := os.UserHomeDir() + if err != nil { + return "", err + } + + dir := filepath.Join(home, ".haft") + if err := os.MkdirAll(dir, 0o755); err != nil { + return "", err + } + + return filepath.Join(dir, "desktop-config.json"), nil +} + +func loadDesktopConfig() (*DesktopConfig, error) { + path, err := desktopConfigPath() + if err != nil { + return nil, err + } + + cfg := defaultDesktopConfig() + + data, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + return &cfg, nil + } + return nil, err + } + + storedVersion, err := detectDesktopConfigVersion(data) + if err != nil { + return nil, fmt.Errorf("parse %s: %w", path, err) + } + + if err := json.Unmarshal(data, &cfg); err != nil { + return nil, fmt.Errorf("parse %s: %w", path, err) + } + + cfg = migrateDesktopConfig(cfg, storedVersion) + cfg = normalizeDesktopConfig(cfg) + return &cfg, nil +} + +func saveDesktopConfig(cfg DesktopConfig) error { + path, err := desktopConfigPath() + if err != nil { + return err + } + + cfg = normalizeDesktopConfig(cfg) + + data, err := json.MarshalIndent(cfg, "", " ") + if err != nil { + return err + } + + return atomicWriteFile(path, append(data, '\n'), 0o644) +} + +func detectDesktopConfigVersion(data []byte) (int, error) { + var payload struct { + ConfigVersion *int `json:"config_version"` + } + + err := json.Unmarshal(data, &payload) + if err != nil { + return 0, err + } + + if payload.ConfigVersion == nil { + return 0, nil + } + + return *payload.ConfigVersion, nil +} + +func migrateDesktopConfig(cfg DesktopConfig, storedVersion int) DesktopConfig { + if storedVersion >= currentDesktopConfigVersion { + return cfg + } + + cfg.ConfigVersion = currentDesktopConfigVersion + return cfg +} + +func normalizeDesktopConfig(cfg DesktopConfig) DesktopConfig { + defaults := defaultDesktopConfig() + + if cfg.ConfigVersion <= 0 { + cfg.ConfigVersion = defaults.ConfigVersion + } + + cfg.DefaultAgent = normalizeAgentKind(cfg.DefaultAgent, defaults.DefaultAgent) + cfg.ReviewAgent = normalizeAgentKind(cfg.ReviewAgent, defaults.ReviewAgent) + cfg.VerifyAgent = normalizeAgentKind(cfg.VerifyAgent, defaults.VerifyAgent) + + if cfg.TaskTimeoutMinutes <= 0 { + cfg.TaskTimeoutMinutes = defaults.TaskTimeoutMinutes + } + + cfg.DefaultIDE = normalizeIDE(cfg.DefaultIDE, defaults.DefaultIDE) + cfg.AgentPresets = normalizeAgentPresets(cfg.AgentPresets) + + if len(cfg.AgentPresets) == 0 { + cfg.AgentPresets = defaults.AgentPresets + } + + return cfg +} + +func normalizeAgentPresets(presets []AgentPreset) []AgentPreset { + defaults := defaultDesktopConfig() + normalized := make([]AgentPreset, 0, len(presets)) + seen := make(map[string]bool) + + for _, preset := range presets { + name := strings.TrimSpace(preset.Name) + role := strings.TrimSpace(preset.Role) + + if name == "" { + continue + } + + preset.Name = name + preset.Role = role + preset.AgentKind = normalizeAgentKind(preset.AgentKind, defaults.DefaultAgent) + preset.Model = strings.TrimSpace(preset.Model) + + key := strings.ToLower(name) + "|" + strings.ToLower(role) + if seen[key] { + continue + } + + seen[key] = true + normalized = append(normalized, preset) + } + + return normalized +} + +func normalizeAgentKind(value string, fallback string) string { + switch strings.ToLower(strings.TrimSpace(value)) { + case string(AgentClaude): + return string(AgentClaude) + case string(AgentCodex): + return string(AgentCodex) + case string(AgentHaft): + return string(AgentHaft) + default: + return fallback + } +} + +func normalizeIDE(value string, fallback string) string { + switch strings.ToLower(strings.TrimSpace(value)) { + case "code": + return "code" + case "zed": + return "zed" + case "idea": + return "idea" + default: + return fallback + } +} + +func buildIDECommand(ide string, targetPath string) []string { + switch normalizeIDE(ide, defaultDesktopConfig().DefaultIDE) { + case "zed": + return []string{"zed", targetPath} + case "idea": + return []string{"idea", targetPath} + default: + return []string{"code", targetPath} + } +} + +func (a *App) GetConfig() (*DesktopConfig, error) { + return loadDesktopConfig() +} + +func (a *App) SaveConfig(cfg DesktopConfig) (*DesktopConfig, error) { + cfg = normalizeDesktopConfig(cfg) + + if err := saveDesktopConfig(cfg); err != nil { + return nil, err + } + + return &cfg, nil +} diff --git a/desktop/config_test.go b/desktop/config_test.go new file mode 100644 index 00000000..325c4606 --- /dev/null +++ b/desktop/config_test.go @@ -0,0 +1,193 @@ +package main + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" +) + +func TestLoadDesktopConfigAppliesDefaults(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + + path, err := desktopConfigPath() + if err != nil { + t.Fatalf("desktopConfigPath: %v", err) + } + + data := []byte(`{"default_agent":"codex","notify_enabled":false}`) + if err := os.WriteFile(path, data, 0o644); err != nil { + t.Fatalf("WriteFile: %v", err) + } + + cfg, err := loadDesktopConfig() + if err != nil { + t.Fatalf("loadDesktopConfig: %v", err) + } + + if cfg.DefaultAgent != string(AgentCodex) { + t.Fatalf("expected default agent codex, got %q", cfg.DefaultAgent) + } + + if cfg.NotifyEnabled { + t.Fatalf("expected notify_enabled=false") + } + + if cfg.ConfigVersion != currentDesktopConfigVersion { + t.Fatalf("expected config version %d, got %d", currentDesktopConfigVersion, cfg.ConfigVersion) + } + + if cfg.TaskTimeoutMinutes != 300 { + t.Fatalf("expected default timeout 300, got %d", cfg.TaskTimeoutMinutes) + } + + if len(cfg.AgentPresets) == 0 { + t.Fatalf("expected default presets to be populated") + } +} + +func TestSaveDesktopConfigRoundTrip(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + + cfg := defaultDesktopConfig() + cfg.DefaultAgent = string(AgentHaft) + cfg.TaskTimeoutMinutes = 42 + cfg.DefaultIDE = "zed" + cfg.AgentPresets = []AgentPreset{ + {Name: "Implement", AgentKind: string(AgentClaude), Role: "implementation"}, + {Name: "Review", AgentKind: string(AgentCodex), Role: "review", Model: "gpt-5"}, + } + + if err := saveDesktopConfig(cfg); err != nil { + t.Fatalf("saveDesktopConfig: %v", err) + } + + loaded, err := loadDesktopConfig() + if err != nil { + t.Fatalf("loadDesktopConfig: %v", err) + } + + if loaded.DefaultAgent != string(AgentHaft) { + t.Fatalf("expected default agent haft, got %q", loaded.DefaultAgent) + } + + if loaded.TaskTimeoutMinutes != 42 { + t.Fatalf("expected timeout 42, got %d", loaded.TaskTimeoutMinutes) + } + + if loaded.DefaultIDE != "zed" { + t.Fatalf("expected IDE zed, got %q", loaded.DefaultIDE) + } + + if len(loaded.AgentPresets) != 2 { + t.Fatalf("expected 2 presets, got %d", len(loaded.AgentPresets)) + } + + configPath := filepath.Join(home, ".haft", "desktop-config.json") + + if _, err := os.Stat(configPath); err != nil { + t.Fatalf("expected config file to exist: %v", err) + } + + data, err := os.ReadFile(configPath) + if err != nil { + t.Fatalf("ReadFile: %v", err) + } + + var payload map[string]any + + err = json.Unmarshal(data, &payload) + if err != nil { + t.Fatalf("Unmarshal: %v", err) + } + + versionValue, ok := payload["config_version"].(float64) + if !ok { + t.Fatalf("expected config_version to be written") + } + + if int(versionValue) != currentDesktopConfigVersion { + t.Fatalf("expected written config version %d, got %d", currentDesktopConfigVersion, int(versionValue)) + } +} + +func TestLoadDesktopConfigMigratesLegacyConfig(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + + path, err := desktopConfigPath() + if err != nil { + t.Fatalf("desktopConfigPath: %v", err) + } + + data := []byte(`{ + "default_agent": "codex", + "notify_enabled": false, + "future_field": "ignored" +}`) + err = os.WriteFile(path, data, 0o644) + if err != nil { + t.Fatalf("WriteFile: %v", err) + } + + cfg, err := loadDesktopConfig() + if err != nil { + t.Fatalf("loadDesktopConfig: %v", err) + } + + if cfg.ConfigVersion != currentDesktopConfigVersion { + t.Fatalf("expected migrated config version %d, got %d", currentDesktopConfigVersion, cfg.ConfigVersion) + } + + if cfg.DefaultAgent != string(AgentCodex) { + t.Fatalf("expected default agent codex, got %q", cfg.DefaultAgent) + } + + if cfg.TaskTimeoutMinutes != defaultDesktopConfig().TaskTimeoutMinutes { + t.Fatalf( + "expected timeout %d, got %d", + defaultDesktopConfig().TaskTimeoutMinutes, + cfg.TaskTimeoutMinutes, + ) + } + + if cfg.AutoWireMCP != defaultDesktopConfig().AutoWireMCP { + t.Fatalf("expected auto_wire_mcp default %t, got %t", defaultDesktopConfig().AutoWireMCP, cfg.AutoWireMCP) + } +} + +func TestBuildIDECommandUsesNormalizedIDE(t *testing.T) { + testCases := []struct { + name string + ide string + expected []string + }{ + {name: "default to vscode", ide: "", expected: []string{"code", "/tmp/project"}}, + {name: "normalize zed", ide: "ZeD", expected: []string{"zed", "/tmp/project"}}, + {name: "normalize idea", ide: "idea", expected: []string{"idea", "/tmp/project"}}, + {name: "fallback on unknown", ide: "custom", expected: []string{"code", "/tmp/project"}}, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + command := buildIDECommand(tc.ide, "/tmp/project") + + if len(command) != len(tc.expected) { + t.Fatalf("expected %d command parts, got %d", len(tc.expected), len(command)) + } + + for index := range tc.expected { + if command[index] != tc.expected[index] { + t.Fatalf( + "expected command[%d]=%q, got %q", + index, + tc.expected[index], + command[index], + ) + } + } + }) + } +} diff --git a/desktop/decision_flow.go b/desktop/decision_flow.go new file mode 100644 index 00000000..a284d08b --- /dev/null +++ b/desktop/decision_flow.go @@ -0,0 +1,188 @@ +package main + +import ( + "fmt" + "strings" + + "github.com/m0n0x41d/haft/internal/artifact" +) + +func buildImplementationPrompt( + decision *artifact.Artifact, + detail DecisionDetailView, + problems []*artifact.Artifact, +) string { + var brief strings.Builder + + writeSectionTitle(&brief, "Implement Decision", firstNonEmpty(detail.SelectedTitle, decision.Meta.Title)) + writeMetaLine(&brief, "Decision ID", detail.ID) + writeMetaLine(&brief, "Selected", firstNonEmpty(detail.SelectedTitle, detail.Title)) + writeMetaLine(&brief, "Selection policy", detail.SelectionPolicy) + writeBlankLine(&brief) + + writeProblemContexts(&brief, problems) + writeParagraphSection(&brief, "Why Selected", detail.WhySelected) + writeParagraphSection(&brief, "Counterargument", detail.CounterArgument) + writeStringListSection(&brief, "Invariants (must hold)", detail.Invariants, "- ") + writeStringListSection(&brief, "Not Acceptable", detail.Admissibility, "- ") + writeStringListSection(&brief, "Affected Files", detail.AffectedFiles, "- ") + writeCoverageSection(&brief, detail.CoverageModules) + writeStringListSection(&brief, "Coverage Warnings", detail.CoverageWarnings, "- ") + writeStringListSection(&brief, "Post-conditions", detail.PostConditions, "- [ ] ") + writeClaimsSection(&brief, detail.Claims) + writeInstructionSection( + &brief, + []string{ + "Inspect the current code path before editing and keep the change scoped to the selected decision.", + "Preserve every invariant and every admissibility boundary while implementing.", + "Treat the affected files list as the primary implementation scope. If scope must expand, make that explicit in your rationale and evidence.", + "After implementation, verify each post-condition and note any claim that still needs asynchronous evidence.", + "Use h-reason discipline while coding: frame the actual local problem before choosing a non-trivial variant.", + }, + ) + + return brief.String() +} + +func buildVerificationPrompt( + decision *artifact.Artifact, + detail DecisionDetailView, +) string { + var prompt strings.Builder + + writeSectionTitle(&prompt, "Verify Decision", firstNonEmpty(detail.SelectedTitle, decision.Meta.Title)) + writeMetaLine(&prompt, "Decision ID", detail.ID) + writeMetaLine(&prompt, "Selected", firstNonEmpty(detail.SelectedTitle, detail.Title)) + writeBlankLine(&prompt) + + writeStringListSection(&prompt, "Affected Files", detail.AffectedFiles, "- ") + writeCoverageSection(&prompt, detail.CoverageModules) + writeStringListSection(&prompt, "Coverage Warnings", detail.CoverageWarnings, "- ") + writeClaimsSection(&prompt, detail.Claims) + writeInstructionSection( + &prompt, + []string{ + "Check each claim with concrete evidence. Use commands, tests, file inspection, or dashboards as needed.", + "Prioritize claims whose verify_after window has passed, but do not skip the rest.", + "Assess each claim as supported, weakened, refuted, or inconclusive with an explicit reason.", + "Call haft_decision(action=\"measure\") with the measured findings once evidence is gathered.", + "Do not fabricate evidence or mark a claim supported without a concrete observable.", + }, + ) + + return prompt.String() +} + +func decisionTaskTitle(prefix string, detail DecisionDetailView) string { + label := firstNonEmpty(detail.SelectedTitle, detail.Title, detail.ID) + return fmt.Sprintf("%s: %s", prefix, label) +} + +func writeProblemContexts(builder *strings.Builder, problems []*artifact.Artifact) { + if len(problems) == 0 { + return + } + + builder.WriteString("## Problem Context\n") + for _, problem := range problems { + fields := problem.UnmarshalProblemFields() + + builder.WriteString(fmt.Sprintf("- %s\n", problem.Meta.Title)) + if fields.Signal != "" { + builder.WriteString(fmt.Sprintf(" Signal: %s\n", fields.Signal)) + } + if fields.Acceptance != "" { + builder.WriteString(fmt.Sprintf(" Acceptance: %s\n", fields.Acceptance)) + } + for _, constraint := range fields.Constraints { + builder.WriteString(fmt.Sprintf(" Constraint: %s\n", constraint)) + } + } + writeBlankLine(builder) +} + +func writeClaimsSection(builder *strings.Builder, claims []ClaimView) { + if len(claims) == 0 { + return + } + + builder.WriteString("## Claims\n") + for _, claim := range claims { + builder.WriteString(fmt.Sprintf("- %s\n", claim.Claim)) + builder.WriteString(fmt.Sprintf(" Observable: %s\n", claim.Observable)) + builder.WriteString(fmt.Sprintf(" Threshold: %s\n", claim.Threshold)) + builder.WriteString(fmt.Sprintf(" Current status: %s\n", claim.Status)) + if claim.VerifyAfter != "" { + builder.WriteString(fmt.Sprintf(" Verify after: %s\n", claim.VerifyAfter)) + } + } + writeBlankLine(builder) +} + +func writeCoverageSection(builder *strings.Builder, modules []CoverageModuleView) { + if len(modules) == 0 { + return + } + + builder.WriteString("## Impacted Modules\n") + for _, module := range modules { + path := firstNonEmpty(module.Path, "(root)") + builder.WriteString(fmt.Sprintf("- %s [%s] status=%s decisions=%d\n", path, module.Lang, module.Status, module.DecisionCount)) + for _, filePath := range module.Files { + builder.WriteString(fmt.Sprintf(" File: %s\n", filePath)) + } + } + writeBlankLine(builder) +} + +func writeStringListSection(builder *strings.Builder, title string, values []string, prefix string) { + if len(values) == 0 { + return + } + + builder.WriteString(fmt.Sprintf("## %s\n", title)) + for _, value := range values { + builder.WriteString(prefix) + builder.WriteString(value) + builder.WriteString("\n") + } + writeBlankLine(builder) +} + +func writeParagraphSection(builder *strings.Builder, title string, value string) { + if strings.TrimSpace(value) == "" { + return + } + + builder.WriteString(fmt.Sprintf("## %s\n", title)) + builder.WriteString(value) + builder.WriteString("\n\n") +} + +func writeInstructionSection(builder *strings.Builder, instructions []string) { + if len(instructions) == 0 { + return + } + + builder.WriteString("## Instructions\n") + for index, instruction := range instructions { + builder.WriteString(fmt.Sprintf("%d. %s\n", index+1, instruction)) + } + writeBlankLine(builder) +} + +func writeSectionTitle(builder *strings.Builder, title string, value string) { + builder.WriteString(fmt.Sprintf("## %s: %s\n\n", title, value)) +} + +func writeMetaLine(builder *strings.Builder, label string, value string) { + if strings.TrimSpace(value) == "" { + return + } + + builder.WriteString(fmt.Sprintf("%s: %s\n", label, value)) +} + +func writeBlankLine(builder *strings.Builder) { + builder.WriteString("\n") +} diff --git a/desktop/decision_flow_test.go b/desktop/decision_flow_test.go new file mode 100644 index 00000000..85c97394 --- /dev/null +++ b/desktop/decision_flow_test.go @@ -0,0 +1,101 @@ +package main + +import ( + "strings" + "testing" + + "github.com/m0n0x41d/haft/internal/artifact" +) + +func TestBuildImplementationPrompt_IncludesGovernanceContext(t *testing.T) { + decision := &artifact.Artifact{ + Meta: artifact.Meta{ + ID: "dec-123", + Title: "Desktop governance loop", + }, + } + + detail := DecisionDetailView{ + ID: "dec-123", + Title: "Desktop governance loop", + SelectedTitle: "Desktop governance loop", + SelectionPolicy: "Prefer backend-authoritative governance.", + WhySelected: "It keeps coverage and stale rules in one place.", + Invariants: []string{"Use shared artifact logic."}, + Admissibility: []string{"Do not fork rules into the frontend."}, + AffectedFiles: []string{"desktop/app.go"}, + CoverageModules: []CoverageModuleView{ + { + Path: "desktop", + Lang: "go", + Status: "covered", + DecisionCount: 2, + Files: []string{"desktop/app.go"}, + }, + }, + Claims: []ClaimView{ + { + Claim: "Verification remains possible from the desktop shell.", + Observable: "verification task", + Threshold: "measurement recorded", + Status: "unverified", + VerifyAfter: "2026-04-16", + }, + }, + } + + problem := &artifact.Artifact{ + Meta: artifact.Meta{Title: "Desktop governance gap"}, + StructuredData: `{"signal":"The operator cannot see governance scope.","acceptance":"The operator sees coverage and stale findings.","constraints":["Reuse shared Go logic."]}`, + } + + prompt := buildImplementationPrompt(decision, detail, []*artifact.Artifact{problem}) + + expectedSnippets := []string{ + "## Implement Decision: Desktop governance loop", + "## Problem Context", + "desktop/app.go", + "status=covered", + "Verify after: 2026-04-16", + } + + for _, snippet := range expectedSnippets { + if !strings.Contains(prompt, snippet) { + t.Fatalf("implementation prompt missing %q:\n%s", snippet, prompt) + } + } +} + +func TestBuildVerificationPrompt_IncludesClaimWindows(t *testing.T) { + decision := &artifact.Artifact{ + Meta: artifact.Meta{ + ID: "dec-verify", + Title: "Verify desktop governance", + }, + } + + detail := DecisionDetailView{ + ID: "dec-verify", + SelectedTitle: "Verify desktop governance", + AffectedFiles: []string{"internal/auth/auth.go"}, + Claims: []ClaimView{ + { + Claim: "Due claim", + Observable: "desktop verification task", + Threshold: "measurement recorded", + Status: "unverified", + VerifyAfter: "2026-04-16", + }, + }, + } + + prompt := buildVerificationPrompt(decision, detail) + + if !strings.Contains(prompt, "Verify after: 2026-04-16") { + t.Fatalf("verification prompt should include verify_after:\n%s", prompt) + } + + if !strings.Contains(prompt, "haft_decision(action=\"measure\")") { + t.Fatalf("verification prompt should include measurement instruction:\n%s", prompt) + } +} diff --git a/desktop/flows.go b/desktop/flows.go new file mode 100644 index 00000000..382ea1be --- /dev/null +++ b/desktop/flows.go @@ -0,0 +1,734 @@ +package main + +import ( + "context" + "database/sql" + "fmt" + "sort" + "strings" + "sync" + "time" + + "github.com/robfig/cron/v3" +) + +type FlowInput struct { + ID string `json:"id"` + Title string `json:"title"` + Description string `json:"description"` + TemplateID string `json:"template_id"` + Agent string `json:"agent"` + Prompt string `json:"prompt"` + Schedule string `json:"schedule"` + Branch string `json:"branch"` + UseWorktree bool `json:"use_worktree"` + Enabled bool `json:"enabled"` +} + +type DesktopFlow struct { + ID string `json:"id"` + ProjectName string `json:"project_name"` + ProjectPath string `json:"project_path"` + Title string `json:"title"` + Description string `json:"description"` + TemplateID string `json:"template_id"` + Agent string `json:"agent"` + Prompt string `json:"prompt"` + Schedule string `json:"schedule"` + Branch string `json:"branch"` + UseWorktree bool `json:"use_worktree"` + Enabled bool `json:"enabled"` + LastTaskID string `json:"last_task_id"` + LastRunAt string `json:"last_run_at"` + NextRunAt string `json:"next_run_at"` + LastError string `json:"last_error"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` +} + +type FlowTemplate struct { + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + Agent string `json:"agent"` + Schedule string `json:"schedule"` + Prompt string `json:"prompt"` + Branch string `json:"branch"` + UseWorktree bool `json:"use_worktree"` +} + +type flowController struct { + app *App + store *desktopFlowStore + + mu sync.Mutex + cron *cron.Cron +} + +type desktopFlowStore struct { + db *sql.DB +} + +type flowRowScanner interface { + Scan(dest ...any) error +} + +func newFlowController(app *App, store *desktopFlowStore) *flowController { + return &flowController{ + app: app, + store: store, + } +} + +func newDesktopFlowStore(db *sql.DB) *desktopFlowStore { + return &desktopFlowStore{db: db} +} + +func defaultFlowTemplates() []FlowTemplate { + return []FlowTemplate{ + { + ID: "decision-refresh", + Name: "Decision Refresh", + Description: "Verify due decisions and turn stale reasoning into scheduled operator work.", + Agent: string(AgentClaude), + Schedule: "0 9 * * 1", + Branch: "flows/decision-refresh", + UseWorktree: true, + Prompt: strings.TrimSpace(` +Review active decisions with expired or near-expired validity windows. + +Instructions: +- List decisions that need refresh or measurement. +- Spawn or update the appropriate verification follow-up. +- Record clear next actions for any decision that remains stale after inspection. +`), + }, + { + ID: "drift-scan", + Name: "Drift Detection", + Description: "Run a recurring drift-focused review against baselined files and decisions.", + Agent: string(AgentCodex), + Schedule: "0 10 * * 1-5", + Branch: "flows/drift-scan", + UseWorktree: true, + Prompt: strings.TrimSpace(` +Scan the current project for drift against decision baselines and recently affected files. + +Instructions: +- Review recorded baselines and affected files. +- Surface files or modules that have drifted since the last baseline. +- Summarize the highest-priority follow-up problems or verification tasks. +`), + }, + { + ID: "dependency-audit", + Name: "Dependency Audit", + Description: "Check the project for outdated dependencies and risky upgrade pressure.", + Agent: string(AgentCodex), + Schedule: "0 11 * * 1", + Branch: "flows/dependency-audit", + UseWorktree: true, + Prompt: strings.TrimSpace(` +Audit dependencies for outdated or risky versions. + +Instructions: +- Inspect the project's package and module manifests. +- Highlight outdated or vulnerable dependencies. +- Recommend the smallest safe remediation steps and note anything that should become a tracked problem. +`), + }, + { + ID: "evidence-health", + Name: "Evidence Health", + Description: "Look for weak evidence and decisions that should be refreshed before they decay further.", + Agent: string(AgentClaude), + Schedule: "0 14 * * 5", + Branch: "flows/evidence-health", + UseWorktree: true, + Prompt: strings.TrimSpace(` +Review decision evidence health across the active project. + +Instructions: +- Identify decisions whose evidence is weak, stale, or missing. +- Summarize which claims or measurements should be refreshed next. +- Record explicit operator follow-up recommendations instead of vague warnings. +`), + }, + { + ID: "coverage-report", + Name: "Coverage Report", + Description: "Generate a weekly governance coverage snapshot for the current project.", + Agent: string(AgentHaft), + Schedule: "0 15 * * 1", + Branch: "flows/coverage-report", + UseWorktree: false, + Prompt: strings.TrimSpace(` +Summarize module governance coverage for the current project. + +Instructions: +- Inspect governed, partial, and blind modules. +- Call out any critical modules that still lack decision coverage. +- End with a short ranked list of follow-up tasks. +`), + }, + } +} + +func parseFlowSchedule(value string) (cron.Schedule, error) { + schedule := strings.TrimSpace(value) + if schedule == "" { + return nil, fmt.Errorf("schedule is required") + } + + return cron.ParseStandard(schedule) +} + +func computeFlowNextRun(value string, from time.Time) (string, error) { + schedule, err := parseFlowSchedule(value) + if err != nil { + return "", err + } + + nextRun := schedule.Next(from) + if nextRun.IsZero() { + return "", nil + } + + return nextRun.UTC().Format(time.RFC3339), nil +} + +func normalizeFlowInput(input FlowInput, cfg DesktopConfig) (FlowInput, error) { + input.Title = strings.TrimSpace(input.Title) + input.Description = strings.TrimSpace(input.Description) + input.TemplateID = strings.TrimSpace(input.TemplateID) + input.Agent = normalizeAgentKind(input.Agent, cfg.DefaultAgent) + input.Prompt = strings.TrimSpace(input.Prompt) + input.Schedule = strings.TrimSpace(input.Schedule) + input.Branch = strings.TrimSpace(input.Branch) + + if input.Title == "" { + return FlowInput{}, fmt.Errorf("flow title is required") + } + + if input.Prompt == "" { + return FlowInput{}, fmt.Errorf("flow prompt is required") + } + + if _, err := parseFlowSchedule(input.Schedule); err != nil { + return FlowInput{}, fmt.Errorf("invalid flow schedule: %w", err) + } + + return input, nil +} + +func buildFlowFromInput(input FlowInput, projectName string, projectPath string) (DesktopFlow, error) { + cfg := defaultDesktopConfig() + loadedConfig, err := loadDesktopConfig() + if err == nil && loadedConfig != nil { + cfg = *loadedConfig + } + + normalized, err := normalizeFlowInput(input, cfg) + if err != nil { + return DesktopFlow{}, err + } + + now := nowRFC3339() + flowID := firstNonEmpty(strings.TrimSpace(normalized.ID), fmt.Sprintf("flow-%d", time.Now().UnixNano())) + nextRunAt := "" + if normalized.Enabled { + nextRunAt, err = computeFlowNextRun(normalized.Schedule, time.Now()) + if err != nil { + return DesktopFlow{}, err + } + } + + return DesktopFlow{ + ID: flowID, + ProjectName: projectName, + ProjectPath: projectPath, + Title: normalized.Title, + Description: normalized.Description, + TemplateID: normalized.TemplateID, + Agent: normalized.Agent, + Prompt: normalized.Prompt, + Schedule: normalized.Schedule, + Branch: normalized.Branch, + UseWorktree: normalized.UseWorktree, + Enabled: normalized.Enabled, + NextRunAt: nextRunAt, + CreatedAt: now, + UpdatedAt: now, + }, nil +} + +func (s *desktopFlowStore) UpsertFlow(ctx context.Context, flow DesktopFlow) error { + if s == nil || s.db == nil { + return fmt.Errorf("desktop flow store is not initialized") + } + + createdAt := firstNonEmpty(flow.CreatedAt, nowRFC3339()) + updatedAt := firstNonEmpty(flow.UpdatedAt, nowRFC3339()) + + _, err := s.db.ExecContext( + ctx, + `INSERT INTO desktop_flows ( + id, + project_name, + project_path, + title, + description, + template_id, + agent, + prompt, + schedule, + branch, + use_worktree, + enabled, + last_task_id, + last_run_at, + next_run_at, + last_error, + created_at, + updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(id) DO UPDATE SET + project_name = excluded.project_name, + project_path = excluded.project_path, + title = excluded.title, + description = excluded.description, + template_id = excluded.template_id, + agent = excluded.agent, + prompt = excluded.prompt, + schedule = excluded.schedule, + branch = excluded.branch, + use_worktree = excluded.use_worktree, + enabled = excluded.enabled, + last_task_id = excluded.last_task_id, + last_run_at = excluded.last_run_at, + next_run_at = excluded.next_run_at, + last_error = excluded.last_error, + updated_at = excluded.updated_at`, + flow.ID, + flow.ProjectName, + flow.ProjectPath, + flow.Title, + flow.Description, + flow.TemplateID, + flow.Agent, + flow.Prompt, + flow.Schedule, + flow.Branch, + boolToInt(flow.UseWorktree), + boolToInt(flow.Enabled), + flow.LastTaskID, + nullString(flow.LastRunAt), + nullString(flow.NextRunAt), + flow.LastError, + createdAt, + updatedAt, + ) + + return err +} + +func (s *desktopFlowStore) ListFlows(ctx context.Context, projectPath string) ([]DesktopFlow, error) { + if s == nil || s.db == nil { + return nil, fmt.Errorf("desktop flow store is not initialized") + } + + rows, err := s.db.QueryContext( + ctx, + `SELECT + id, + project_name, + project_path, + title, + description, + template_id, + agent, + prompt, + schedule, + branch, + use_worktree, + enabled, + last_task_id, + COALESCE(last_run_at, ''), + COALESCE(next_run_at, ''), + last_error, + created_at, + updated_at + FROM desktop_flows + WHERE project_path = ? + ORDER BY updated_at DESC, title ASC`, + projectPath, + ) + if err != nil { + return nil, err + } + defer rows.Close() + + result := make([]DesktopFlow, 0) + for rows.Next() { + flow, err := scanDesktopFlow(rows) + if err != nil { + return nil, err + } + + result = append(result, flow) + } + + if err := rows.Err(); err != nil { + return nil, err + } + + return result, nil +} + +func (s *desktopFlowStore) GetFlow(ctx context.Context, id string) (*DesktopFlow, error) { + if s == nil || s.db == nil { + return nil, fmt.Errorf("desktop flow store is not initialized") + } + + row := s.db.QueryRowContext( + ctx, + `SELECT + id, + project_name, + project_path, + title, + description, + template_id, + agent, + prompt, + schedule, + branch, + use_worktree, + enabled, + last_task_id, + COALESCE(last_run_at, ''), + COALESCE(next_run_at, ''), + last_error, + created_at, + updated_at + FROM desktop_flows + WHERE id = ?`, + id, + ) + + flow, err := scanDesktopFlow(row) + if err != nil { + return nil, err + } + + return &flow, nil +} + +func (s *desktopFlowStore) DeleteFlow(ctx context.Context, id string) error { + if s == nil || s.db == nil { + return fmt.Errorf("desktop flow store is not initialized") + } + + _, err := s.db.ExecContext(ctx, `DELETE FROM desktop_flows WHERE id = ?`, id) + return err +} + +func scanDesktopFlow(scanner flowRowScanner) (DesktopFlow, error) { + var flow DesktopFlow + var useWorktree int + var enabled int + + err := scanner.Scan( + &flow.ID, + &flow.ProjectName, + &flow.ProjectPath, + &flow.Title, + &flow.Description, + &flow.TemplateID, + &flow.Agent, + &flow.Prompt, + &flow.Schedule, + &flow.Branch, + &useWorktree, + &enabled, + &flow.LastTaskID, + &flow.LastRunAt, + &flow.NextRunAt, + &flow.LastError, + &flow.CreatedAt, + &flow.UpdatedAt, + ) + if err != nil { + return DesktopFlow{}, err + } + + flow.UseWorktree = useWorktree == 1 + flow.Enabled = enabled == 1 + + return flow, nil +} + +func (c *flowController) list(ctx context.Context, projectPath string) ([]DesktopFlow, error) { + if c == nil || c.store == nil { + return []DesktopFlow{}, nil + } + + return c.store.ListFlows(ctx, projectPath) +} + +func (c *flowController) shutdown() { + if c == nil { + return + } + + c.mu.Lock() + current := c.cron + c.cron = nil + c.mu.Unlock() + + if current == nil { + return + } + + stopCtx := current.Stop() + <-stopCtx.Done() +} + +func (c *flowController) reload(ctx context.Context) error { + if c == nil || c.store == nil || c.app == nil || c.app.projectRoot == "" { + return nil + } + + c.shutdown() + + flows, err := c.store.ListFlows(ctx, c.app.projectRoot) + if err != nil { + return err + } + + engine := cron.New() + now := time.Now() + + for _, flow := range flows { + flow.UpdatedAt = nowRFC3339() + flow.LastError = "" + flow.NextRunAt = "" + + if flow.Enabled { + nextRunAt, nextErr := computeFlowNextRun(flow.Schedule, now) + if nextErr != nil { + flow.LastError = nextErr.Error() + } else { + flow.NextRunAt = nextRunAt + + flowID := flow.ID + _, addErr := engine.AddFunc(flow.Schedule, func() { + if _, runErr := c.runNow(context.Background(), flowID); runErr != nil && c.app != nil { + c.app.emitAppError("scheduled flow", runErr) + } + }) + if addErr != nil { + flow.NextRunAt = "" + flow.LastError = addErr.Error() + } + } + } + + if err := c.store.UpsertFlow(ctx, flow); err != nil { + return err + } + } + + engine.Start() + + c.mu.Lock() + c.cron = engine + c.mu.Unlock() + + return nil +} + +func (c *flowController) runNow(ctx context.Context, id string) (*TaskState, error) { + if c == nil || c.store == nil || c.app == nil { + return nil, fmt.Errorf("flow controller is not initialized") + } + + flow, err := c.store.GetFlow(ctx, id) + if err != nil { + return nil, fmt.Errorf("load flow %s: %w", id, err) + } + + task, err := c.app.spawnTaskWithTitle( + flow.Agent, + flow.Prompt, + flow.UseWorktree, + flow.Branch, + fmt.Sprintf("Flow: %s", flow.Title), + ) + + now := time.Now() + flow.UpdatedAt = nowRFC3339() + // Don't clear LastTaskID here — only update it after successful spawn (line below). + // Clearing before checking err loses the reference to the previous successful task. + flow.LastError = "" + + if flow.Enabled { + flow.NextRunAt, _ = computeFlowNextRun(flow.Schedule, now) + } + + if err != nil { + flow.LastError = err.Error() + storeErr := c.store.UpsertFlow(ctx, *flow) + if storeErr != nil && c.app != nil { + c.app.emitAppError("flow persistence", storeErr) + } + return nil, fmt.Errorf("run flow %s: %w", flow.Title, err) + } + + flow.LastRunAt = now.UTC().Format(time.RFC3339) + flow.LastTaskID = task.ID + + if err := c.store.UpsertFlow(ctx, *flow); err != nil { + return nil, err + } + + return task, nil +} + +var flowInitMu sync.Mutex + +func (a *App) ensureFlowController() { + if a == nil || a.dbConn == nil { + return + } + + flowInitMu.Lock() + defer flowInitMu.Unlock() + + if a.flows != nil { + return + } + + a.flows = newFlowController(a, newDesktopFlowStore(a.dbConn.GetRawDB())) +} + +func (a *App) ListFlows() ([]DesktopFlow, error) { + if a.projectRoot == "" { + return []DesktopFlow{}, nil + } + + a.ensureFlowController() + return a.flows.list(a.ctx, a.projectRoot) +} + +func (a *App) ListFlowTemplates() ([]FlowTemplate, error) { + templates := defaultFlowTemplates() + + sort.Slice(templates, func(i int, j int) bool { + return templates[i].Name < templates[j].Name + }) + + return templates, nil +} + +func (a *App) CreateFlow(input FlowInput) (*DesktopFlow, error) { + if a.projectRoot == "" { + return nil, fmt.Errorf("no active project") + } + + a.ensureFlowController() + + flow, err := buildFlowFromInput(input, a.projectName, a.projectRoot) + if err != nil { + return nil, err + } + + if err := a.flows.store.UpsertFlow(a.ctx, flow); err != nil { + return nil, err + } + + if err := a.flows.reload(a.ctx); err != nil { + return nil, err + } + + return a.flows.store.GetFlow(a.ctx, flow.ID) +} + +func (a *App) UpdateFlow(input FlowInput) (*DesktopFlow, error) { + if strings.TrimSpace(input.ID) == "" { + return nil, fmt.Errorf("flow id is required") + } + + a.ensureFlowController() + + existing, err := a.flows.store.GetFlow(a.ctx, input.ID) + if err != nil { + return nil, err + } + + flow, err := buildFlowFromInput(input, existing.ProjectName, existing.ProjectPath) + if err != nil { + return nil, err + } + + flow.CreatedAt = existing.CreatedAt + flow.LastTaskID = existing.LastTaskID + flow.LastRunAt = existing.LastRunAt + flow.LastError = existing.LastError + + if err := a.flows.store.UpsertFlow(a.ctx, flow); err != nil { + return nil, err + } + + if err := a.flows.reload(a.ctx); err != nil { + return nil, err + } + + return a.flows.store.GetFlow(a.ctx, flow.ID) +} + +func (a *App) ToggleFlow(id string, enabled bool) (*DesktopFlow, error) { + a.ensureFlowController() + + flow, err := a.flows.store.GetFlow(a.ctx, strings.TrimSpace(id)) + if err != nil { + return nil, err + } + + flow.Enabled = enabled + flow.UpdatedAt = nowRFC3339() + if !enabled { + flow.NextRunAt = "" + } + if enabled { + flow.NextRunAt, err = computeFlowNextRun(flow.Schedule, time.Now()) + if err != nil { + return nil, err + } + } + + if err := a.flows.store.UpsertFlow(a.ctx, *flow); err != nil { + return nil, err + } + + if err := a.flows.reload(a.ctx); err != nil { + return nil, err + } + + return a.flows.store.GetFlow(a.ctx, flow.ID) +} + +func (a *App) DeleteFlow(id string) error { + a.ensureFlowController() + + if err := a.flows.store.DeleteFlow(a.ctx, strings.TrimSpace(id)); err != nil { + return err + } + + return a.flows.reload(a.ctx) +} + +func (a *App) RunFlowNow(id string) (*TaskState, error) { + a.ensureFlowController() + return a.flows.runNow(a.ctx, strings.TrimSpace(id)) +} diff --git a/desktop/flows_test.go b/desktop/flows_test.go new file mode 100644 index 00000000..9d64399b --- /dev/null +++ b/desktop/flows_test.go @@ -0,0 +1,200 @@ +package main + +import ( + "context" + "os" + "path/filepath" + "testing" + + "github.com/m0n0x41d/haft/db" + "github.com/m0n0x41d/haft/internal/project" +) + +func TestFlowCRUDAndSchedulingMetadata(t *testing.T) { + app := newAutomationTestApp(t, "automation-main") + defer app.shutdown(context.Background()) + + created, err := app.CreateFlow(FlowInput{ + Title: "Weekly decision refresh", + Description: "Verify due decisions every Monday.", + TemplateID: "decision-refresh", + Agent: "claude", + Prompt: "Review active decisions with expired or near-expired validity windows.", + Schedule: "0 9 * * 1", + Branch: "flows/decision-refresh", + UseWorktree: true, + Enabled: true, + }) + if err != nil { + t.Fatalf("CreateFlow: %v", err) + } + + if created.NextRunAt == "" { + t.Fatalf("expected next_run_at to be populated") + } + + updated, err := app.UpdateFlow(FlowInput{ + ID: created.ID, + Title: "Weekly decision refresh", + Description: "Updated description", + TemplateID: created.TemplateID, + Agent: "codex", + Prompt: created.Prompt, + Schedule: created.Schedule, + Branch: created.Branch, + UseWorktree: created.UseWorktree, + Enabled: created.Enabled, + }) + if err != nil { + t.Fatalf("UpdateFlow: %v", err) + } + + if updated.Agent != "codex" { + t.Fatalf("expected updated agent codex, got %q", updated.Agent) + } + + paused, err := app.ToggleFlow(created.ID, false) + if err != nil { + t.Fatalf("ToggleFlow: %v", err) + } + + if paused.Enabled { + t.Fatalf("expected flow to be paused") + } + + if paused.NextRunAt != "" { + t.Fatalf("expected paused flow to clear next_run_at, got %q", paused.NextRunAt) + } + + flows, err := app.ListFlows() + if err != nil { + t.Fatalf("ListFlows: %v", err) + } + + if len(flows) != 1 { + t.Fatalf("expected 1 flow, got %d", len(flows)) + } +} + +func TestListAllTasksAggregatesRegisteredProjects(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + + setup := NewApp() + firstProjectRoot := filepath.Join(t.TempDir(), "project-a") + secondProjectRoot := filepath.Join(t.TempDir(), "project-b") + + if err := os.MkdirAll(firstProjectRoot, 0o755); err != nil { + t.Fatalf("MkdirAll project-a: %v", err) + } + + if err := os.MkdirAll(secondProjectRoot, 0o755); err != nil { + t.Fatalf("MkdirAll project-b: %v", err) + } + + if _, err := setup.InitProject(firstProjectRoot); err != nil { + t.Fatalf("InitProject project-a: %v", err) + } + + if _, err := setup.InitProject(secondProjectRoot); err != nil { + t.Fatalf("InitProject project-b: %v", err) + } + + app := NewApp() + app.projectRoot = firstProjectRoot + app.startup(context.Background()) + defer app.shutdown(context.Background()) + + firstStore := openAutomationTaskStore(t, firstProjectRoot) + secondStore := openAutomationTaskStore(t, secondProjectRoot) + ctx := context.Background() + + firstTask := TaskState{ + ID: "task-a", + Title: "Project A task", + Agent: "claude", + Project: "project-a", + ProjectPath: firstProjectRoot, + Status: "running", + Prompt: "Inspect project A", + StartedAt: nowRFC3339(), + } + secondTask := TaskState{ + ID: "task-b", + Title: "Project B task", + Agent: "codex", + Project: "project-b", + ProjectPath: secondProjectRoot, + Status: "completed", + Prompt: "Inspect project B", + StartedAt: nowRFC3339(), + } + + if err := firstStore.UpsertTask(ctx, firstTask); err != nil { + t.Fatalf("UpsertTask project-a: %v", err) + } + + if err := secondStore.UpsertTask(ctx, secondTask); err != nil { + t.Fatalf("UpsertTask project-b: %v", err) + } + + allTasks, err := app.ListAllTasks() + if err != nil { + t.Fatalf("ListAllTasks: %v", err) + } + + if len(allTasks) != 2 { + t.Fatalf("expected 2 aggregated tasks, got %d", len(allTasks)) + } +} + +func newAutomationTestApp(t *testing.T, name string) *App { + t.Helper() + + home := t.TempDir() + t.Setenv("HOME", home) + + projectRoot := filepath.Join(t.TempDir(), name) + if err := os.MkdirAll(projectRoot, 0o755); err != nil { + t.Fatalf("MkdirAll: %v", err) + } + + setup := NewApp() + if _, err := setup.InitProject(projectRoot); err != nil { + t.Fatalf("InitProject: %v", err) + } + + app := NewApp() + app.projectRoot = projectRoot + app.startup(context.Background()) + + if app.dbConn == nil { + t.Fatal("expected database connection after startup") + } + + return app +} + +func openAutomationTaskStore(t *testing.T, projectRoot string) *desktopTaskStore { + t.Helper() + + cfg, err := project.Load(filepath.Join(projectRoot, ".haft")) + if err != nil { + t.Fatalf("project.Load(%s): %v", projectRoot, err) + } + + dbPath, err := cfg.DBPath() + if err != nil { + t.Fatalf("DBPath(%s): %v", projectRoot, err) + } + + database, err := db.NewStore(dbPath) + if err != nil { + t.Fatalf("db.NewStore(%s): %v", projectRoot, err) + } + t.Cleanup(func() { + _ = database.Close() + }) + + return newDesktopTaskStore(database.GetRawDB()) +} diff --git a/desktop/frontend/.gitignore b/desktop/frontend/.gitignore new file mode 100644 index 00000000..a547bf36 --- /dev/null +++ b/desktop/frontend/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/desktop/frontend/README.md b/desktop/frontend/README.md new file mode 100644 index 00000000..7dbf7ebf --- /dev/null +++ b/desktop/frontend/README.md @@ -0,0 +1,73 @@ +# React + TypeScript + Vite + +This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules. + +Currently, two official plugins are available: + +- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs) +- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) + +## React Compiler + +The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation). + +## Expanding the ESLint configuration + +If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules: + +```js +export default defineConfig([ + globalIgnores(['dist']), + { + files: ['**/*.{ts,tsx}'], + extends: [ + // Other configs... + + // Remove tseslint.configs.recommended and replace with this + tseslint.configs.recommendedTypeChecked, + // Alternatively, use this for stricter rules + tseslint.configs.strictTypeChecked, + // Optionally, add this for stylistic rules + tseslint.configs.stylisticTypeChecked, + + // Other configs... + ], + languageOptions: { + parserOptions: { + project: ['./tsconfig.node.json', './tsconfig.app.json'], + tsconfigRootDir: import.meta.dirname, + }, + // other options... + }, + }, +]) +``` + +You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules: + +```js +// eslint.config.js +import reactX from 'eslint-plugin-react-x' +import reactDom from 'eslint-plugin-react-dom' + +export default defineConfig([ + globalIgnores(['dist']), + { + files: ['**/*.{ts,tsx}'], + extends: [ + // Other configs... + // Enable lint rules for React + reactX.configs['recommended-typescript'], + // Enable lint rules for React DOM + reactDom.configs.recommended, + ], + languageOptions: { + parserOptions: { + project: ['./tsconfig.node.json', './tsconfig.app.json'], + tsconfigRootDir: import.meta.dirname, + }, + // other options... + }, + }, +]) +``` diff --git a/desktop/frontend/eslint.config.js b/desktop/frontend/eslint.config.js new file mode 100644 index 00000000..18d7feab --- /dev/null +++ b/desktop/frontend/eslint.config.js @@ -0,0 +1,27 @@ +import js from '@eslint/js' +import globals from 'globals' +import reactHooks from 'eslint-plugin-react-hooks' +import reactRefresh from 'eslint-plugin-react-refresh' +import tseslint from 'typescript-eslint' +import { defineConfig, globalIgnores } from 'eslint/config' + +export default defineConfig([ + globalIgnores(['dist', 'wailsjs']), + { + files: ['**/*.{ts,tsx}'], + extends: [ + js.configs.recommended, + tseslint.configs.recommended, + reactHooks.configs.flat.recommended, + reactRefresh.configs.vite, + ], + languageOptions: { + ecmaVersion: 2020, + globals: globals.browser, + }, + rules: { + '@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }], + 'react-hooks/set-state-in-effect': 'off', + }, + }, +]) diff --git a/desktop/frontend/index.html b/desktop/frontend/index.html new file mode 100644 index 00000000..0fca6f04 --- /dev/null +++ b/desktop/frontend/index.html @@ -0,0 +1,13 @@ + + + + + + + frontend + + +
+ + + diff --git a/desktop/frontend/package-lock.json b/desktop/frontend/package-lock.json new file mode 100644 index 00000000..f609a47f --- /dev/null +++ b/desktop/frontend/package-lock.json @@ -0,0 +1,3332 @@ +{ + "name": "frontend", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "frontend", + "version": "0.0.0", + "dependencies": { + "@xterm/addon-fit": "^0.11.0", + "@xterm/xterm": "^6.0.0", + "lucide-react": "^1.8.0", + "react": "^19.2.4", + "react-dom": "^19.2.4" + }, + "devDependencies": { + "@eslint/js": "^9.39.4", + "@tailwindcss/vite": "^4.2.2", + "@types/node": "^24.12.2", + "@types/react": "^19.2.14", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^6.0.1", + "eslint": "^9.39.4", + "eslint-plugin-react-hooks": "^7.0.1", + "eslint-plugin-react-refresh": "^0.5.2", + "globals": "^17.4.0", + "tailwindcss": "^4.2.2", + "typescript": "~6.0.2", + "typescript-eslint": "^8.58.0", + "vite": "^8.0.4" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz", + "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", + "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helpers": "^7.28.6", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.1", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", + "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", + "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.28.6", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", + "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", + "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-validator-identifier": "^7.28.5", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.2.tgz", + "integrity": "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz", + "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/template": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", + "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", + "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@emnapi/core": { + "version": "1.9.2", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.2.tgz", + "integrity": "sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.1", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.9.2", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.2.tgz", + "integrity": "sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", + "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.21.2", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.2.tgz", + "integrity": "sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^2.1.7", + "debug": "^4.3.1", + "minimatch": "^3.1.5" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", + "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/core": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", + "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "3.3.5", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.5.tgz", + "integrity": "sha512-4IlJx0X0qftVsN5E+/vGujTRIFtwuLbNsVUe7TO6zYPDR1O6nFwvwhIKEKSrl6dZchmYBITazxKoUYOjdtjlRg==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.14.0", + "debug": "^4.3.2", + "espree": "^10.0.1", + "globals": "^14.0.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.1", + "minimatch": "^3.1.5", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/eslintrc/node_modules/globals": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@eslint/js": { + "version": "9.39.4", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.4.tgz", + "integrity": "sha512-nE7DEIchvtiFTwBw4Lfbu59PG+kCofhjsKaCWzxTpt4lfRjRMqG6uMBzKXuEcyXhOHoUp9riAm7/aWYGhXZ9cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + } + }, + "node_modules/@eslint/object-schema": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", + "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", + "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0", + "levn": "^0.4.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.1", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", + "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.7", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz", + "integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.1", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.3.tgz", + "integrity": "sha512-xK9sGVbJWYb08+mTJt3/YV24WxvxpXcXtP6B172paPZ+Ts69Re9dAr7lKwJoeIx8OoeuimEiRZ7umkiUVClmmQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.124.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.124.0.tgz", + "integrity": "sha512-VBFWMTBvHxS11Z5Lvlr3IWgrwhMTXV+Md+EQF0Xf60+wAdsGFTBx7X7K/hP4pi8N7dcm1RvcHwDxZ16Qx8keUg==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.15.tgz", + "integrity": "sha512-YYe6aWruPZDtHNpwu7+qAHEMbQ/yRl6atqb/AhznLTnD3UY99Q1jE7ihLSahNWkF4EqRPVC4SiR4O0UkLK02tA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.15.tgz", + "integrity": "sha512-oArR/ig8wNTPYsXL+Mzhs0oxhxfuHRfG7Ikw7jXsw8mYOtk71W0OkF2VEVh699pdmzjPQsTjlD1JIOoHkLP1Fg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.15.tgz", + "integrity": "sha512-YzeVqOqjPYvUbJSWJ4EDL8ahbmsIXQpgL3JVipmN+MX0XnXMeWomLN3Fb+nwCmP/jfyqte5I3XRSm7OfQrbyxw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.15.tgz", + "integrity": "sha512-9Erhx956jeQ0nNTyif1+QWAXDRD38ZNjr//bSHrt6wDwB+QkAfl2q6Mn1k6OBPerznjRmbM10lgRb1Pli4xZPw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.15.tgz", + "integrity": "sha512-cVwk0w8QbZJGTnP/AHQBs5yNwmpgGYStL88t4UIaqcvYJWBfS0s3oqVLZPwsPU6M0zlW4GqjP0Zq5MnAGwFeGA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.15.tgz", + "integrity": "sha512-eBZ/u8iAK9SoHGanqe/jrPnY0JvBN6iXbVOsbO38mbz+ZJsaobExAm1Iu+rxa4S1l2FjG0qEZn4Rc6X8n+9M+w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.15.tgz", + "integrity": "sha512-ZvRYMGrAklV9PEkgt4LQM6MjQX2P58HPAuecwYObY2DhS2t35R0I810bKi0wmaYORt6m/2Sm+Z+nFgb0WhXNcQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.15.tgz", + "integrity": "sha512-VDpgGBzgfg5hLg+uBpCLoFG5kVvEyafmfxGUV0UHLcL5irxAK7PKNeC2MwClgk6ZAiNhmo9FLhRYgvMmedLtnQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.15.tgz", + "integrity": "sha512-y1uXY3qQWCzcPgRJATPSOUP4tCemh4uBdY7e3EZbVwCJTY3gLJWnQABgeUetvED+bt1FQ01OeZwvhLS2bpNrAQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.15.tgz", + "integrity": "sha512-023bTPBod7J3Y/4fzAN6QtpkSABR0rigtrwaP+qSEabUh5zf6ELr9Nc7GujaROuPY3uwdSIXWrvhn1KxOvurWA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.15.tgz", + "integrity": "sha512-witB2O0/hU4CgfOOKUoeFgQ4GktPi1eEbAhaLAIpgD6+ZnhcPkUtPsoKKHRzmOoWPZue46IThdSgdo4XneOLYw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.15.tgz", + "integrity": "sha512-UCL68NJ0Ud5zRipXZE9dF5PmirzJE4E4BCIOOssEnM7wLDsxjc6Qb0sGDxTNRTP53I6MZpygyCpY8Aa8sPfKPg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.15.tgz", + "integrity": "sha512-ApLruZq/ig+nhaE7OJm4lDjayUnOHVUa77zGeqnqZ9pn0ovdVbbNPerVibLXDmWeUZXjIYIT8V3xkT58Rm9u5Q==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.9.2", + "@emnapi/runtime": "1.9.2", + "@napi-rs/wasm-runtime": "^1.1.3" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.15.tgz", + "integrity": "sha512-KmoUoU7HnN+Si5YWJigfTws1jz1bKBYDQKdbLspz0UaqjjFkddHsqorgiW1mxcAj88lYUE6NC/zJNwT+SloqtA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.15.tgz", + "integrity": "sha512-3P2A8L+x75qavWLe/Dll3EYBJLQmtkJN8rfh+U/eR3MqMgL/h98PhYI+JFfXuDPgPeCB7iZAKiqii5vqOvnA0g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-rc.7", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.7.tgz", + "integrity": "sha512-qujRfC8sFVInYSPPMLQByRh7zhwkGFS4+tyMQ83srV1qrxL4g8E2tyxVVyxd0+8QeBM1mIk9KbWxkegRr76XzA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tailwindcss/node": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.2.2.tgz", + "integrity": "sha512-pXS+wJ2gZpVXqFaUEjojq7jzMpTGf8rU6ipJz5ovJV6PUGmlJ+jvIwGrzdHdQ80Sg+wmQxUFuoW1UAAwHNEdFA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "enhanced-resolve": "^5.19.0", + "jiti": "^2.6.1", + "lightningcss": "1.32.0", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.2.2" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.2.2.tgz", + "integrity": "sha512-qEUA07+E5kehxYp9BVMpq9E8vnJuBHfJEC0vPC5e7iL/hw7HR61aDKoVoKzrG+QKp56vhNZe4qwkRmMC0zDLvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.2.2", + "@tailwindcss/oxide-darwin-arm64": "4.2.2", + "@tailwindcss/oxide-darwin-x64": "4.2.2", + "@tailwindcss/oxide-freebsd-x64": "4.2.2", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.2.2", + "@tailwindcss/oxide-linux-arm64-gnu": "4.2.2", + "@tailwindcss/oxide-linux-arm64-musl": "4.2.2", + "@tailwindcss/oxide-linux-x64-gnu": "4.2.2", + "@tailwindcss/oxide-linux-x64-musl": "4.2.2", + "@tailwindcss/oxide-wasm32-wasi": "4.2.2", + "@tailwindcss/oxide-win32-arm64-msvc": "4.2.2", + "@tailwindcss/oxide-win32-x64-msvc": "4.2.2" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.2.2.tgz", + "integrity": "sha512-dXGR1n+P3B6748jZO/SvHZq7qBOqqzQ+yFrXpoOWWALWndF9MoSKAT3Q0fYgAzYzGhxNYOoysRvYlpixRBBoDg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.2.2.tgz", + "integrity": "sha512-iq9Qjr6knfMpZHj55/37ouZeykwbDqF21gPFtfnhCCKGDcPI/21FKC9XdMO/XyBM7qKORx6UIhGgg6jLl7BZlg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.2.2.tgz", + "integrity": "sha512-BlR+2c3nzc8f2G639LpL89YY4bdcIdUmiOOkv2GQv4/4M0vJlpXEa0JXNHhCHU7VWOKWT/CjqHdTP8aUuDJkuw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.2.2.tgz", + "integrity": "sha512-YUqUgrGMSu2CDO82hzlQ5qSb5xmx3RUrke/QgnoEx7KvmRJHQuZHZmZTLSuuHwFf0DJPybFMXMYf+WJdxHy/nQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.2.2.tgz", + "integrity": "sha512-FPdhvsW6g06T9BWT0qTwiVZYE2WIFo2dY5aCSpjG/S/u1tby+wXoslXS0kl3/KXnULlLr1E3NPRRw0g7t2kgaQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.2.2.tgz", + "integrity": "sha512-4og1V+ftEPXGttOO7eCmW7VICmzzJWgMx+QXAJRAhjrSjumCwWqMfkDrNu1LXEQzNAwz28NCUpucgQPrR4S2yw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.2.2.tgz", + "integrity": "sha512-oCfG/mS+/+XRlwNjnsNLVwnMWYH7tn/kYPsNPh+JSOMlnt93mYNCKHYzylRhI51X+TbR+ufNhhKKzm6QkqX8ag==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.2.2.tgz", + "integrity": "sha512-rTAGAkDgqbXHNp/xW0iugLVmX62wOp2PoE39BTCGKjv3Iocf6AFbRP/wZT/kuCxC9QBh9Pu8XPkv/zCZB2mcMg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.2.2.tgz", + "integrity": "sha512-XW3t3qwbIwiSyRCggeO2zxe3KWaEbM0/kW9e8+0XpBgyKU4ATYzcVSMKteZJ1iukJ3HgHBjbg9P5YPRCVUxlnQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.2.2.tgz", + "integrity": "sha512-eKSztKsmEsn1O5lJ4ZAfyn41NfG7vzCg496YiGtMDV86jz1q/irhms5O0VrY6ZwTUkFy/EKG3RfWgxSI3VbZ8Q==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.8.1", + "@emnapi/runtime": "^1.8.1", + "@emnapi/wasi-threads": "^1.1.0", + "@napi-rs/wasm-runtime": "^1.1.1", + "@tybys/wasm-util": "^0.10.1", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.2.2.tgz", + "integrity": "sha512-qPmaQM4iKu5mxpsrWZMOZRgZv1tOZpUm+zdhhQP0VhJfyGGO3aUKdbh3gDZc/dPLQwW4eSqWGrrcWNBZWUWaXQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.2.2.tgz", + "integrity": "sha512-1T/37VvI7WyH66b+vqHj/cLwnCxt7Qt3WFu5Q8hk65aOvlwAhs7rAp1VkulBJw/N4tMirXjVnylTR72uI0HGcA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/vite": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.2.2.tgz", + "integrity": "sha512-mEiF5HO1QqCLXoNEfXVA1Tzo+cYsrqV7w9Juj2wdUFyW07JRenqMG225MvPwr3ZD9N1bFQj46X7r33iHxLUW0w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tailwindcss/node": "4.2.2", + "@tailwindcss/oxide": "4.2.2", + "tailwindcss": "4.2.2" + }, + "peerDependencies": { + "vite": "^5.2.0 || ^6 || ^7 || ^8" + } + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", + "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "24.12.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.12.2.tgz", + "integrity": "sha512-A1sre26ke7HDIuY/M23nd9gfB+nrmhtYyMINbjI1zHJxYteKR6qSMX56FsmjMcDb3SMcjJg5BiRRgOCC/yBD0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.16.0" + } + }, + "node_modules/@types/react": { + "version": "19.2.14", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", + "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", + "dev": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.58.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.58.1.tgz", + "integrity": "sha512-eSkwoemjo76bdXl2MYqtxg51HNwUSkWfODUOQ3PaTLZGh9uIWWFZIjyjaJnex7wXDu+TRx+ATsnSxdN9YWfRTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.58.1", + "@typescript-eslint/type-utils": "8.58.1", + "@typescript-eslint/utils": "8.58.1", + "@typescript-eslint/visitor-keys": "8.58.1", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.58.1", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.58.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.58.1.tgz", + "integrity": "sha512-gGkiNMPqerb2cJSVcruigx9eHBlLG14fSdPdqMoOcBfh+vvn4iCq2C8MzUB89PrxOXk0y3GZ1yIWb9aOzL93bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.58.1", + "@typescript-eslint/types": "8.58.1", + "@typescript-eslint/typescript-estree": "8.58.1", + "@typescript-eslint/visitor-keys": "8.58.1", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.58.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.58.1.tgz", + "integrity": "sha512-gfQ8fk6cxhtptek+/8ZIqw8YrRW5048Gug8Ts5IYcMLCw18iUgrZAEY/D7s4hkI0FxEfGakKuPK/XUMPzPxi5g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.58.1", + "@typescript-eslint/types": "^8.58.1", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.58.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.58.1.tgz", + "integrity": "sha512-TPYUEqJK6avLcEjumWsIuTpuYODTTDAtoMdt8ZZa93uWMTX13Nb8L5leSje1NluammvU+oI3QRr5lLXPgihX3w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.58.1", + "@typescript-eslint/visitor-keys": "8.58.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.58.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.58.1.tgz", + "integrity": "sha512-JAr2hOIct2Q+qk3G+8YFfqkqi7sC86uNryT+2i5HzMa2MPjw4qNFvtjnw1IiA1rP7QhNKVe21mSSLaSjwA1Olw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.58.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.58.1.tgz", + "integrity": "sha512-HUFxvTJVroT+0rXVJC7eD5zol6ID+Sn5npVPWoFuHGg9Ncq5Q4EYstqR+UOqaNRFXi5TYkpXXkLhoCHe3G0+7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.58.1", + "@typescript-eslint/typescript-estree": "8.58.1", + "@typescript-eslint/utils": "8.58.1", + "debug": "^4.4.3", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.58.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.58.1.tgz", + "integrity": "sha512-io/dV5Aw5ezwzfPBBWLoT+5QfVtP8O7q4Kftjn5azJ88bYyp/ZMCsyW1lpKK46EXJcaYMZ1JtYj+s/7TdzmQMw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.58.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.58.1.tgz", + "integrity": "sha512-w4w7WR7GHOjqqPnvAYbazq+Y5oS68b9CzasGtnd6jIeOIeKUzYzupGTB2T4LTPSv4d+WPeccbxuneTFHYgAAWg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.58.1", + "@typescript-eslint/tsconfig-utils": "8.58.1", + "@typescript-eslint/types": "8.58.1", + "@typescript-eslint/visitor-keys": "8.58.1", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", + "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.58.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.58.1.tgz", + "integrity": "sha512-Ln8R0tmWC7pTtLOzgJzYTXSCjJ9rDNHAqTaVONF4FEi2qwce8mD9iSOxOpLFFvWp/wBFlew0mjM1L1ihYWfBdQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.58.1", + "@typescript-eslint/types": "8.58.1", + "@typescript-eslint/typescript-estree": "8.58.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.58.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.58.1.tgz", + "integrity": "sha512-y+vH7QE8ycjoa0bWciFg7OpFcipUuem1ujhrdLtq1gByKwfbC7bPeKsiny9e0urg93DqwGcHey+bGRKCnF1nZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.58.1", + "eslint-visitor-keys": "^5.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.1.tgz", + "integrity": "sha512-l9X/E3cDb+xY3SWzlG1MOGt2usfEHGMNIaegaUGFsLkb3RCn/k8/TOXBcab+OndDI4TBtktT8/9BwwW8Vi9KUQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rolldown/pluginutils": "1.0.0-rc.7" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", + "babel-plugin-react-compiler": "^1.0.0", + "vite": "^8.0.0" + }, + "peerDependenciesMeta": { + "@rolldown/plugin-babel": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + } + } + }, + "node_modules/@xterm/addon-fit": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@xterm/addon-fit/-/addon-fit-0.11.0.tgz", + "integrity": "sha512-jYcgT6xtVYhnhgxh3QgYDnnNMYTcf8ElbxxFzX0IZo+vabQqSPAjC3c1wJrKB5E19VwQei89QCiZZP86DCPF7g==", + "license": "MIT" + }, + "node_modules/@xterm/xterm": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-6.0.0.tgz", + "integrity": "sha512-TQwDdQGtwwDt+2cgKDLn0IRaSxYu1tSUjgKarSDkUM0ZNiSRXFpjxEsvc/Zgc5kq5omJ+V0a8/kIM2WD3sMOYg==", + "license": "MIT", + "workspaces": [ + "addons/*" + ] + }, + "node_modules/acorn": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ajv": { + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", + "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.17", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.17.tgz", + "integrity": "sha512-HdrkN8eVG2CXxeifv/VdJ4A4RSra1DTW8dc/hdxzhGHN8QePs6gKaWM9pHPcpCoxYZJuOZ8drHmbdpLHjCYjLA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/brace-expansion": { + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz", + "integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/browserslist": { + "version": "4.28.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", + "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.10.12", + "caniuse-lite": "^1.0.30001782", + "electron-to-chromium": "^1.5.328", + "node-releases": "^2.0.36", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001787", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001787.tgz", + "integrity": "sha512-mNcrMN9KeI68u7muanUpEejSLghOKlVhRqS/Za2IeyGllJ9I9otGpR9g3nsw7n4W378TE/LyIteA0+/FOZm4Kg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.334", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.334.tgz", + "integrity": "sha512-mgjZAz7Jyx1SRCwEpy9wefDS7GvNPazLthHg8eQMJ76wBdGQQDW33TCrUTvQ4wzpmOrv2zrFoD3oNufMdyMpog==", + "dev": true, + "license": "ISC" + }, + "node_modules/enhanced-resolve": { + "version": "5.20.1", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.20.1.tgz", + "integrity": "sha512-Qohcme7V1inbAfvjItgw0EaxVX5q2rdVEZHRBrEQdRZTssLDGsL8Lwrznl8oQ/6kuTJONLaDcGjkNP247XEhcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.0" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "9.39.4", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.4.tgz", + "integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.1", + "@eslint/config-array": "^0.21.2", + "@eslint/config-helpers": "^0.4.2", + "@eslint/core": "^0.17.0", + "@eslint/eslintrc": "^3.3.5", + "@eslint/js": "9.39.4", + "@eslint/plugin-kit": "^0.4.1", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.14.0", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^8.4.0", + "eslint-visitor-keys": "^4.2.1", + "espree": "^10.4.0", + "esquery": "^1.5.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.5", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-plugin-react-hooks": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-7.0.1.tgz", + "integrity": "sha512-O0d0m04evaNzEPoSW+59Mezf8Qt0InfgGIBJnpC0h3NH/WjUAR7BIKUfysC6todmtiZ/A0oUVS8Gce0WhBrHsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.24.4", + "@babel/parser": "^7.24.4", + "hermes-parser": "^0.25.1", + "zod": "^3.25.0 || ^4.0.0", + "zod-validation-error": "^3.5.0 || ^4.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0" + } + }, + "node_modules/eslint-plugin-react-refresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.5.2.tgz", + "integrity": "sha512-hmgTH57GfzoTFjVN0yBwTggnsVUF2tcqi7RJZHqi9lIezSs4eFyAMktA68YD4r5kNw1mxyY4dmkyoFDb3FIqrA==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "eslint": "^9 || ^10" + } + }, + "node_modules/eslint-scope": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", + "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.15.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", + "dev": true, + "license": "ISC" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/globals": { + "version": "17.4.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-17.4.0.tgz", + "integrity": "sha512-hjrNztw/VajQwOLsMNT1cbJiH2muO3OROCHnbehc8eY5JyD2gqz4AcMHPqgaOR59DjgUjYAYLeH699g/eWi2jw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/hermes-estree": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz", + "integrity": "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==", + "dev": true, + "license": "MIT" + }, + "node_modules/hermes-parser": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.25.1.tgz", + "integrity": "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hermes-estree": "0.25.1" + } + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/jiti": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", + "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/lucide-react": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.8.0.tgz", + "integrity": "sha512-WuvlsjngSk7TnTBJ1hsCy3ql9V9VOdcPkd3PKcSmM34vJD8KG6molxz7m7zbYFgICwsanQWmJ13JlYs4Zp7Arw==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-releases": { + "version": "2.0.37", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.37.tgz", + "integrity": "sha512-1h5gKZCF+pO/o3Iqt5Jp7wc9rH3eJJ0+nh/CIoiRwjRxde/hAHyLPXYN4V3CqKAbiZPSeJFSWHmJsbkicta0Eg==", + "dev": true, + "license": "MIT" + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.9", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.9.tgz", + "integrity": "sha512-7a70Nsot+EMX9fFU3064K/kdHWZqGVY+BADLyXc8Dfv+mTLLVl6JzJpPaCZ2kQL9gIJvKXSLMHhqdRRjwQeFtw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/react": { + "version": "19.2.5", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.5.tgz", + "integrity": "sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.5", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.5.tgz", + "integrity": "sha512-J5bAZz+DXMMwW/wV3xzKke59Af6CHY7G4uYLN1OvBcKEsWOs4pQExj86BBKamxl/Ik5bx9whOrvBlSDfWzgSag==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.5" + } + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/rolldown": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.15.tgz", + "integrity": "sha512-Ff31guA5zT6WjnGp0SXw76X6hzGRk/OQq2hE+1lcDe+lJdHSgnSX6nK3erbONHyCbpSj9a9E+uX/OvytZoWp2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.124.0", + "@rolldown/pluginutils": "1.0.0-rc.15" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.0.0-rc.15", + "@rolldown/binding-darwin-arm64": "1.0.0-rc.15", + "@rolldown/binding-darwin-x64": "1.0.0-rc.15", + "@rolldown/binding-freebsd-x64": "1.0.0-rc.15", + "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.15", + "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.15", + "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.15", + "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.15", + "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.15", + "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.15", + "@rolldown/binding-linux-x64-musl": "1.0.0-rc.15", + "@rolldown/binding-openharmony-arm64": "1.0.0-rc.15", + "@rolldown/binding-wasm32-wasi": "1.0.0-rc.15", + "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.15", + "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.15" + } + }, + "node_modules/rolldown/node_modules/@rolldown/pluginutils": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.15.tgz", + "integrity": "sha512-UromN0peaE53IaBRe9W7CjrZgXl90fqGpK+mIZbA3qSTeYqg3pqpROBdIPvOG3F5ereDHNwoHBI2e50n1BDr1g==", + "dev": true, + "license": "MIT" + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/tailwindcss": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.2.tgz", + "integrity": "sha512-KWBIxs1Xb6NoLdMVqhbhgwZf2PGBpPEiwOqgI4pFIYbNTfBXiKYyWoTsXgBQ9WFg/OlhnvHaY+AEpW7wSmFo2Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/tapable": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.2.tgz", + "integrity": "sha512-1MOpMXuhGzGL5TTCZFItxCc0AARf1EZFQkGqMm7ERKj8+Hgr5oLvJOVFcC+lRmR8hCe2S3jC4T5D7Vg/d7/fhA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.16", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", + "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/ts-api-utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/typescript": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.2.tgz", + "integrity": "sha512-bGdAIrZ0wiGDo5l8c++HWtbaNCWTS4UTv7RaTH/ThVIgjkveJt83m74bBHMJkuCbslY8ixgLBVZJIOiQlQTjfQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/typescript-eslint": { + "version": "8.58.1", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.58.1.tgz", + "integrity": "sha512-gf6/oHChByg9HJvhMO1iBexJh12AqqTfnuxscMDOVqfJW3htsdRJI/GfPpHTTcyeB8cSTUY2JcZmVgoyPqcrDg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.58.1", + "@typescript-eslint/parser": "8.58.1", + "@typescript-eslint/typescript-estree": "8.58.1", + "@typescript-eslint/utils": "8.58.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/undici-types": { + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", + "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", + "dev": true, + "license": "MIT" + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/vite": { + "version": "8.0.8", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.8.tgz", + "integrity": "sha512-dbU7/iLVa8KZALJyLOBOQ88nOXtNG8vxKuOT4I2mD+Ya70KPceF4IAmDsmU0h1Qsn5bPrvsY9HJstCRh3hG6Uw==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.32.0", + "picomatch": "^4.0.4", + "postcss": "^8.5.8", + "rolldown": "1.0.0-rc.15", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.1.0", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/zod": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", + "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-validation-error": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/zod-validation-error/-/zod-validation-error-4.0.2.tgz", + "integrity": "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + } + } + } +} diff --git a/desktop/frontend/package.json b/desktop/frontend/package.json new file mode 100644 index 00000000..e76a7699 --- /dev/null +++ b/desktop/frontend/package.json @@ -0,0 +1,35 @@ +{ + "name": "frontend", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "lint": "eslint .", + "preview": "vite preview" + }, + "dependencies": { + "@xterm/addon-fit": "^0.11.0", + "@xterm/xterm": "^6.0.0", + "lucide-react": "^1.8.0", + "react": "^19.2.4", + "react-dom": "^19.2.4" + }, + "devDependencies": { + "@eslint/js": "^9.39.4", + "@tailwindcss/vite": "^4.2.2", + "@types/node": "^24.12.2", + "@types/react": "^19.2.14", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^6.0.1", + "eslint": "^9.39.4", + "eslint-plugin-react-hooks": "^7.0.1", + "eslint-plugin-react-refresh": "^0.5.2", + "globals": "^17.4.0", + "tailwindcss": "^4.2.2", + "typescript": "~6.0.2", + "typescript-eslint": "^8.58.0", + "vite": "^8.0.4" + } +} diff --git a/desktop/frontend/package.json.md5 b/desktop/frontend/package.json.md5 new file mode 100755 index 00000000..84ca0998 --- /dev/null +++ b/desktop/frontend/package.json.md5 @@ -0,0 +1 @@ +d757aadd1e73f3f677333446f3451709 \ No newline at end of file diff --git a/desktop/frontend/public/favicon.svg b/desktop/frontend/public/favicon.svg new file mode 100644 index 00000000..6893eb13 --- /dev/null +++ b/desktop/frontend/public/favicon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/desktop/frontend/src/App.tsx b/desktop/frontend/src/App.tsx new file mode 100644 index 00000000..6533eeb5 --- /dev/null +++ b/desktop/frontend/src/App.tsx @@ -0,0 +1,641 @@ +import { useEffect, useState } from "react"; +import { + PanelLeftClose, + PanelLeftOpen, + Plus, + Zap, + Search, + MoreHorizontal, + Settings as SettingsIcon, + ChevronRight, + ChevronDown, + LayoutDashboard, + AlertTriangle, + Scale, + CheckCircle2, + Archive, + ListTodo, + FolderPlus, +} from "lucide-react"; +import { Dashboard } from "./pages/Dashboard"; +import { Problems } from "./pages/Problems"; +import { Decisions } from "./pages/Decisions"; +import { Portfolios } from "./pages/Portfolios"; +import { Settings } from "./pages/Settings"; +import { Tasks } from "./pages/Tasks"; +import { Flows as Jobs } from "./pages/Jobs"; +import { NotificationViewport, type DesktopNotification } from "./components/Notifications"; +import { SearchOverlay } from "./components/SearchOverlay"; +import { TerminalPanel } from "./components/TerminalPanel"; +import { ToastViewport } from "./components/Toast"; +import { listenForErrors, reportError, type AppErrorDetail } from "./lib/errors"; +import { listProjects, switchProject, listTasks, type ProjectInfo, type TaskState } from "./lib/api"; +import { EventsOn, WindowToggleMaximise } from "../wailsjs/runtime/runtime"; + +type Page = "dashboard" | "problems" | "portfolios" | "decisions" | "jobs" | "tasks" | "settings"; + +const REASONING_NAV: { id: Page; label: string; icon: typeof LayoutDashboard }[] = [ + { id: "dashboard", label: "Overview", icon: LayoutDashboard }, + { id: "problems", label: "Problems", icon: AlertTriangle }, + { id: "portfolios", label: "Comparison", icon: Scale }, + { id: "decisions", label: "Decisions", icon: CheckCircle2 }, +]; + +export default function App() { + const [page, setPage] = useState("dashboard"); + const [selectedId, setSelectedId] = useState(null); + const [projects, setProjects] = useState([]); + const [tasks, setTasks] = useState([]); + const [refreshKey, setRefreshKey] = useState(0); + const [searchOpen, setSearchOpen] = useState(false); + const [sidebarExpanded, setSidebarExpanded] = useState(true); + const [expandedProjects, setExpandedProjects] = useState>(new Set()); + const [showNewTask, setShowNewTask] = useState(false); + const [showPlusMenu, setShowPlusMenu] = useState(false); + const [showNewProject, setShowNewProject] = useState(false); + const [toasts, setToasts] = useState([]); + const [notifications, setNotifications] = useState([]); + const [terminalOpen, setTerminalOpen] = useState(false); + + useEffect(() => { + const handler = (e: KeyboardEvent) => { + if ((e.metaKey || e.ctrlKey) && e.key === "k") { + e.preventDefault(); + setSearchOpen((v) => !v); + } + + if ((e.metaKey || e.ctrlKey) && e.key === "`") { + e.preventDefault(); + setTerminalOpen((current) => !current); + } + }; + window.addEventListener("keydown", handler); + return () => window.removeEventListener("keydown", handler); + }, []); + + useEffect(() => { + listProjects() + .then((p) => { + setProjects(p); + + const active = p.find((proj) => proj.is_active); + if (active) setExpandedProjects(new Set([active.path])); + }) + .catch((error) => { + reportError(error, "projects"); + }); + }, [refreshKey]); + + useEffect(() => { + const load = () => + listTasks() + .then(setTasks) + .catch((error) => { + reportError(error, "tasks"); + }); + + load(); + // Sidebar task list: 10s poll is enough for status dots. + // Tasks.tsx has its own 2s poll + event-driven updates for the active task. + const interval = setInterval(load, 10000); + return () => clearInterval(interval); + }, [refreshKey]); + + useEffect(() => { + const stopListening = listenForErrors((detail) => { + setToasts((current) => [...current, detail].slice(-4)); + }); + + let stopBackendErrors: (() => void) | undefined; + + try { + stopBackendErrors = EventsOn("app.error", (payload: { scope?: string; message?: string }) => { + reportError(payload?.message ?? "Unexpected error", payload?.scope); + }); + } catch { + stopBackendErrors = undefined; + } + + let stopNotifications: (() => void) | undefined; + + try { + stopNotifications = EventsOn("notification.push", (payload: DesktopNotification) => { + setNotifications((current) => [...current, payload].slice(-4)); + }); + } catch { + stopNotifications = undefined; + } + + return () => { + stopListening(); + stopBackendErrors?.(); + stopNotifications?.(); + }; + }, []); + + const navigate = (p: Page, id?: string) => { + setPage(p); + setSelectedId(id ?? null); + }; + + const handleSwitchProject = async (path: string) => { + try { + await switchProject(path); + setRefreshKey((k) => k + 1); + setPage("dashboard"); + setSelectedId(null); + } catch (error) { + reportError(error, "switch project"); + } + }; + + const handleOpenTask = async (task: TaskState) => { + if (task.project_path && task.project_path !== activeProject?.path) { + await handleSwitchProject(task.project_path); + } + + setPage("tasks"); + setSelectedId(task.id); + }; + + const toggleProject = (path: string) => { + setExpandedProjects((prev) => { + const next = new Set(prev); + if (next.has(path)) next.delete(path); + else next.add(path); + return next; + }); + }; + + const activeProject = projects.find((p) => p.is_active); + const projectTasks = (projectPath: string) => + tasks.filter((task) => task.project_path === projectPath); + + return ( +
+ {/* Icon rail */} +
+
+ + setSidebarExpanded(!sidebarExpanded)} + active={sidebarExpanded} + /> +
+ setShowPlusMenu(!showPlusMenu)} + accent + /> + {showPlusMenu && ( + <> +
setShowPlusMenu(false)} /> +
+ + +
+ + )} +
+ setPage("jobs")} + active={page === "jobs"} + /> + setSearchOpen(true)} /> + {}} /> + +
+ + navigate("settings")} + active={page === "settings"} + /> + 0.1 +
+ + {/* Sidebar */} + {sidebarExpanded && ( +
+
{ try { WindowToggleMaximise(); } catch { /* ignore */ } }} + /> + + {/* Project tree */} +
+ {projects.map((proj) => { + const isExpanded = expandedProjects.has(proj.path); + const pTasks = projectTasks(proj.path); + return ( +
+
+ + + +
+ + {isExpanded && ( +
+ {pTasks.length === 0 && ( +

No tasks

+ )} + {pTasks.map((t) => ( + { setPage("tasks"); setSelectedId(t.id); }} + onArchive={async () => { + try { + const { archiveTask: doArchive } = await import("./lib/api"); + await doArchive(t.id); + setTasks((prev) => prev.filter((x) => x.id !== t.id)); + if (selectedId === t.id) setSelectedId(null); + } catch (e) { console.error(e); } + }} + /> + ))} +
+ )} +
+ ); + })} +
+ + {/* Reasoning nav */} +
+

Reasoning

+ {REASONING_NAV.map((item) => { + const Icon = item.icon; + return ( + + ); + })} +
+
+ )} + + {/* Main */} +
+
+
{ try { WindowToggleMaximise(); } catch { /* ignore */ } }} + > +

+ {activeProject?.name && {activeProject.name} / } + {pageTitle(page)} +

+
+ + +
+
+ +
+ {page === "dashboard" && } + {page === "problems" && } + {page === "portfolios" && } + {page === "decisions" && } + {page === "jobs" && } + {page === "tasks" && ( + setShowNewTask(false)} + /> + )} + {page === "settings" && ( + { + setRefreshKey((key) => key + 1); + }} + /> + )} +
+
+ + setTerminalOpen(false)} + /> +
+ + {/* New Project modal */} + {showNewProject && ( + setShowNewProject(false)} + onProjectAdded={() => { setRefreshKey((k) => k + 1); setShowNewProject(false); }} + /> + )} + + setSearchOpen(false)} onNavigate={(p, id) => navigate(p as Page, id)} /> + { + setNotifications((current) => current.filter((notification) => notification.id !== id)); + }} + /> + { + setToasts((current) => current.filter((toast) => toast.id !== id)); + }} + /> +
+ ); +} + +function pageTitle(page: Page): string { + if (page === "tasks") { + return "Tasks"; + } + + if (page === "jobs") { + return "Jobs"; + } + + if (page === "settings") { + return "Settings"; + } + + return REASONING_NAV.find((item) => item.id === page)?.label ?? "Workspace"; +} + +function NewProjectModal({ + onClose, + onProjectAdded, +}: { + onClose: () => void; + onProjectAdded: () => void; +}) { + const [discovered, setDiscovered] = useState([]); + const [scanning, setScanning] = useState(false); + const [selectedPath, setSelectedPath] = useState(""); + + useEffect(() => { + setScanning(true); + import("./lib/api").then(({ scanForProjects, listProjects }) => + Promise.all([scanForProjects(), listProjects()]).then(([found, existing]) => { + const existingPaths = new Set(existing.map((p: ProjectInfo) => p.path)); + setDiscovered(found.filter((f: ProjectInfo) => !existingPaths.has(f.path))); + setScanning(false); + }) + ).catch(() => setScanning(false)); + }, []); + + const handlePick = async () => { + try { + const { openDirectoryPicker } = await import("./lib/api"); + const path = await openDirectoryPicker(); + if (path) setSelectedPath(path); + } catch { /* ignore */ } + }; + + const handleAdd = async (path: string) => { + try { + const { addProject } = await import("./lib/api"); + await addProject(path); + onProjectAdded(); + } catch (e) { console.error(e); } + }; + + const handleInit = async (path: string) => { + try { + const { initProject } = await import("./lib/api"); + await initProject(path); + onProjectAdded(); + } catch (e) { console.error(e); } + }; + + return ( +
+
+
+

New project

+ +
+ +
+ {/* Manual path */} +
+ setSelectedPath(e.target.value)} + placeholder="/path/to/project" + className="flex-1 rounded-lg border border-border bg-surface-2 px-3 py-2 font-mono text-sm text-text-primary" + /> + +
+ + {selectedPath && ( +
+ + +
+ )} + + {/* Discovered projects */} + {scanning ? ( +

Scanning for projects...

+ ) : discovered.length > 0 ? ( +
+

Suggested

+
+ {discovered.map((p) => ( + + ))} +
+
+ ) : ( +

No additional .haft/ projects found

+ )} +
+
+
+ ); +} + +function RailBtn({ icon: Icon, tip, onClick, active, accent }: { + icon: typeof Plus; + tip: string; + onClick: () => void; + active?: boolean; + accent?: boolean; +}) { + return ( + + ); +} + +function SidebarTask({ + task, + selected, + onSelect, + onArchive, +}: { + task: TaskState; + selected: boolean; + onSelect: () => void; + onArchive: () => void; +}) { + const [menuOpen, setMenuOpen] = useState(false); + + return ( +
+
{ e.preventDefault(); setMenuOpen(!menuOpen); }} + onKeyDown={(e) => { if (e.key === "Enter") onSelect(); }} + className={`w-full flex items-center gap-1.5 px-2 py-1 rounded text-xs cursor-pointer transition-colors ${ + selected + ? "bg-surface-2 text-text-primary" + : "text-text-secondary hover:bg-surface-2" + }`} + > + + {task.title} + { e.stopPropagation(); setMenuOpen(!menuOpen); }} + onKeyDown={(e) => { if (e.key === "Enter") { e.stopPropagation(); setMenuOpen(!menuOpen); } }} + className="opacity-0 group-hover:opacity-100 text-text-muted hover:text-text-primary p-0.5 transition-opacity shrink-0 cursor-pointer" + > + + +
+ + {menuOpen && ( + <> +
setMenuOpen(false)} /> +
+ + {/* Delete removed — archive is the only safe operation */} +
+ + )} +
+ ); +} + +function StatusDot({ status }: { status: string }) { + const colors: Record = { + running: "bg-blue-400 animate-pulse", + completed: "bg-success", + failed: "bg-danger", + cancelled: "bg-text-muted", + pending: "bg-warning", + }; + return ; +} diff --git a/desktop/frontend/src/components/DecisionForm.tsx b/desktop/frontend/src/components/DecisionForm.tsx new file mode 100644 index 00000000..290306d2 --- /dev/null +++ b/desktop/frontend/src/components/DecisionForm.tsx @@ -0,0 +1,550 @@ +import { useEffect, useState } from "react"; + +import { + type DecisionCreateInput, + type DecisionPredictionInput, + type DecisionRejectionInput, + type PortfolioDetail, + type VariantView, +} from "../lib/api"; + +interface DecisionFormProps { + portfolio: PortfolioDetail; + onSubmit: (value: DecisionCreateInput) => Promise | void; + onCancel?: () => void; + submitting: boolean; +} + +export function DecisionForm({ + portfolio, + onSubmit, + onCancel, + submitting, +}: DecisionFormProps) { + const [value, setValue] = useState(() => buildDecisionInput(portfolio)); + + useEffect(() => { + setValue(buildDecisionInput(portfolio)); + }, [portfolio]); + + const selectedVariant = portfolio.variants.find( + (variant) => variant.id === value.selected_ref || variant.title === value.selected_title, + ); + const mode = value.mode || "standard"; + + const submit = async (event: React.FormEvent) => { + event.preventDefault(); + await onSubmit({ + ...value, + selected_title: selectedVariant?.title ?? value.selected_title.trim(), + weakest_link: value.weakest_link.trim() || selectedVariant?.weakest_link || "", + }); + }; + + return ( +
+
+
+

Decide

+

Record the selected variant and the contract around it

+
+ {onCancel && ( + + )} +
+ +
+ + + + + + + +
+ + +