commit 18278a33578e6bca254a839bd02519ff0dc5341b Author: Alan Wizemann Date: Tue Mar 31 02:30:04 2026 -0400 Initial release: Scarf — macOS GUI for the Hermes AI agent Native SwiftUI app providing full visibility into the Hermes AI agent: - Dashboard with system health, token usage, and cost tracking - Sessions browser with conversation detail and FTS5 search - Activity feed with tool call inspector (read/edit/execute/fetch/browser) - Embedded terminal chat via SwiftTerm with full ANSI/Rich rendering - Memory viewer/editor with live file-watching refresh - Skills browser by category with file content viewer - Cron job viewer with output display - Real-time log tailing with level filtering - Settings display with raw config and Finder path links - Menu bar status icon with quick actions Architecture: MVVM-Feature, zero dependencies beyond SwiftTerm, read-only SQLite access, Swift 6 strict concurrency. Co-Authored-By: Claude Opus 4.6 (1M context) diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000..674fb65 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,28 @@ +--- +name: Bug Report +about: Report a bug in Scarf +title: '' +labels: bug +assignees: '' +--- + +**Describe the bug** +A clear description of what the bug is. + +**To Reproduce** +Steps to reproduce the behavior: +1. Go to '...' +2. Click on '...' +3. See error + +**Expected behavior** +What you expected to happen. + +**Screenshots** +If applicable, add screenshots. + +**Environment** +- macOS version: +- Xcode version: +- Hermes version: +- Scarf version/commit: diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 0000000..500e30b --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,19 @@ +--- +name: Feature Request +about: Suggest an idea for Scarf +title: '' +labels: enhancement +assignees: '' +--- + +**Is your feature request related to a problem?** +A clear description of the problem. + +**Describe the solution you'd like** +What you want to happen. + +**Describe alternatives you've considered** +Other solutions you've thought about. + +**Additional context** +Any other context, screenshots, or mockups. diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..68d1116 --- /dev/null +++ b/.gitignore @@ -0,0 +1,45 @@ +# Xcode +build/ +DerivedData/ +*.pbxuser +!default.pbxuser +*.mode1v3 +!default.mode1v3 +*.mode2v3 +!default.mode2v3 +*.perspectivev3 +!default.perspectivev3 +xcuserdata/ +*.xccheckout +*.moved-aside +*.hmap +*.ipa +*.dSYM.zip +*.dSYM + +# Swift Package Manager +.build/ +Packages/ +Package.pins +Package.resolved +*.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/ +.swiftpm/ + +# macOS +.DS_Store +.AppleDouble +.LSOverride +._* +.Spotlight-V100 +.Trashes + +# IDE +*.swp +*.swo +*~ +.idea/ +.vscode/ + +# Claude Code +.claude/ +scarf/standards/backups/ diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..ae55c80 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,40 @@ +# Scarf — macOS GUI for the Hermes AI Agent + +## Project Structure + +``` +scarf/scarf/ Xcode project root (PBXFileSystemSynchronizedRootGroup — auto-discovers files) + scarf/ Main app target source + Core/Services/ HermesDataService, HermesFileService, HermesLogService, ACPClient, HermesFileWatcher + Core/Models/ Plain structs: HermesSession, HermesMessage, HermesConfig, etc. + Features/ MVVM-F feature modules (Dashboard, Sessions, Activity, Chat, Memory, Skills, Cron, Logs, Settings) + Navigation/ AppCoordinator, SidebarView + docs/ PRD, Architecture, Discovery notes + standards/ Copied development standards (read-only reference) +``` + +## Architecture Rules + +- **MVVM-F**: Features never import sibling features. Cross-feature goes through services. +- **AppCoordinator**: Single `@Observable` coordinator for all navigation state, injected via `.environment()`. +- **No external dependencies**: System SQLite3, Foundation JSON, AttributedString markdown. +- **Read-only DB access**: Never write to `~/.hermes/state.db`. Only write to memory files and cron jobs. +- **Sandbox disabled**: App reads `~/.hermes/` directly. +- **Swift 6 concurrency**: `@MainActor` default. Services use `nonisolated` + async/await. + +## Key Paths + +- Hermes home: `~/.hermes/` +- SQLite DB: `~/.hermes/state.db` (WAL mode, read-only) +- Config: `~/.hermes/config.yaml` +- Memory: `~/.hermes/memories/MEMORY.md`, `~/.hermes/memories/USER.md` +- Sessions: `~/.hermes/sessions/session_*.json` +- Cron: `~/.hermes/cron/jobs.json` +- Logs: `~/.hermes/logs/errors.log`, `~/.hermes/logs/gateway.log` +- ACP: `hermes acp` subprocess (stdio JSON-RPC) + +## Build + +```bash +xcodebuild -project scarf/scarf.xcodeproj -scheme scarf -configuration Debug build +``` diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..c73bff0 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,49 @@ +# Contributing to Scarf + +Thanks for your interest in contributing to Scarf. + +## Getting Started + +1. Fork and clone the repo +2. Open `scarf/scarf.xcodeproj` in Xcode 26.3+ +3. Build and run (requires macOS 26.2+ and Hermes installed at `~/.hermes/`) + +## Architecture + +Scarf uses the MVVM-Feature pattern. Each feature is a self-contained module under `Features/`: + +``` +Features/FeatureName/ + Views/ SwiftUI views + ViewModels/ @Observable view models +``` + +Rules: +- Features never import sibling features directly +- Cross-feature navigation goes through `AppCoordinator` +- Services in `Core/Services/` are shared across features +- Models in `Core/Models/` are plain structs + +## Guidelines + +- Keep it simple. Minimal dependencies, no over-engineering. +- No commented-out code, TODOs, or deferred functionality in PRs. +- All code must build with zero warnings. +- Follow existing patterns — look at how similar features are built before adding new ones. +- The app only reads from `~/.hermes/state.db` (never writes). Memory files are the exception. +- Swift 6 strict concurrency: `@MainActor` default isolation, `nonisolated` for service methods. + +## Reporting Issues + +Open an issue with: +- What you expected to happen +- What actually happened +- macOS version and Hermes version +- Steps to reproduce + +## Pull Requests + +- Open an issue first to discuss the change +- One feature or fix per PR +- Include a clear description of what changed and why +- Ensure the project builds with `xcodebuild -project scarf/scarf.xcodeproj -scheme scarf build` diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..92cd657 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Alan Wizemann + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..3e4ae7d --- /dev/null +++ b/README.md @@ -0,0 +1,105 @@ +# Scarf + +A native macOS companion app for the [Hermes AI agent](https://github.com/hermes-ai/hermes-agent). Scarf gives you full visibility into what Hermes is doing, when, and what it creates — replacing CLI opacity with a clean, native interface. + +![macOS](https://img.shields.io/badge/macOS-26.2+-blue) ![Swift](https://img.shields.io/badge/Swift-6-orange) ![License](https://img.shields.io/badge/license-MIT-green) + +## Features + +- **Dashboard** — System health, token usage, cost tracking, recent sessions at a glance +- **Sessions Browser** — Full conversation history with message rendering, tool call inspection, and full-text search (FTS5) +- **Activity Feed** — Real-time tool execution log with filtering by kind (read/edit/execute/fetch/browser) and detail inspector +- **Live Chat** — Embedded terminal running `hermes chat` with full ANSI color and Rich formatting via [SwiftTerm](https://github.com/migueldeicaza/SwiftTerm) +- **Memory Viewer/Editor** — View and edit Hermes's MEMORY.md and USER.md with live refresh +- **Skills Browser** — Browse all installed skills by category with file content viewer +- **Cron Manager** — View scheduled jobs, their status, prompts, and output +- **Log Viewer** — Real-time tailing of error and gateway logs with level filtering +- **Settings** — Read-only config display with raw YAML viewer and Finder path links +- **Menu Bar** — Status icon showing Hermes running state with quick actions + +## Requirements + +- macOS 26.2+ +- Xcode 26.3+ +- [Hermes agent](https://github.com/hermes-ai/hermes-agent) installed at `~/.hermes/` + +## Building + +```bash +git clone https://github.com/yourusername/scarf.git +cd scarf/scarf +open scarf.xcodeproj +``` + +Or from the command line: + +```bash +xcodebuild -project scarf/scarf.xcodeproj -scheme scarf -configuration Debug build +``` + +## Architecture + +Scarf follows the **MVVM-Feature** pattern with zero external dependencies beyond SwiftTerm: + +``` +scarf/ + Core/ + Models/ Plain data structs (HermesSession, HermesMessage, HermesConfig, etc.) + Services/ Data access (SQLite reader, file I/O, log tailing, file watcher) + Features/ Self-contained feature modules + Dashboard/ System overview and stats + Sessions/ Conversation browser with detail view + Activity/ Tool execution feed with inspector + Chat/ Embedded terminal via SwiftTerm + Memory/ Memory viewer and editor + Skills/ Skill browser by category + Cron/ Scheduled job viewer + Logs/ Real-time log viewer + Settings/ Configuration display + Navigation/ AppCoordinator + SidebarView +``` + +### Data Sources + +Scarf reads Hermes data directly from `~/.hermes/`: + +| Source | Format | Access | +|--------|--------|--------| +| `state.db` | SQLite (WAL mode) | Read-only | +| `config.yaml` | YAML | Read-only | +| `memories/*.md` | Markdown | Read/Write | +| `cron/jobs.json` | JSON | Read-only | +| `logs/*.log` | Text | Read-only | +| `gateway_state.json` | JSON | Read-only | +| `skills/` | Directory tree | Read-only | +| `hermes chat` | Terminal subprocess | Interactive | + +The app **never writes** to `state.db` — it opens in read-only mode to avoid WAL contention with Hermes. + +### Dependencies + +| Package | Purpose | +|---------|---------| +| [SwiftTerm](https://github.com/migueldeicaza/SwiftTerm) | Terminal emulator for the Chat feature | + +Everything else uses system frameworks: SQLite3 C API, Foundation JSON, AttributedString markdown, GCD file watching. + +## How It Works + +Scarf is a passive observer. It watches `~/.hermes/` for file changes and polls the SQLite database for new sessions and messages. The Chat tab spawns `hermes chat` as a subprocess in a pseudo-terminal, giving you the full interactive Hermes CLI experience with proper ANSI rendering. + +The app sandbox is disabled because Scarf needs direct access to `~/.hermes/` and the ability to spawn the Hermes binary. + +## Contributing + +Contributions are welcome. Please open an issue to discuss what you'd like to change before submitting a PR. + +1. Fork the repo +2. Create your feature branch (`git checkout -b feature/my-feature`) +3. Commit your changes (`git commit -m 'Add my feature'`) +4. Push to the branch (`git push origin feature/my-feature`) +5. Open a Pull Request + +## License + +[MIT](LICENSE) diff --git a/scarf/docs/ARCHITECTURE.md b/scarf/docs/ARCHITECTURE.md new file mode 100644 index 0000000..4ab364f --- /dev/null +++ b/scarf/docs/ARCHITECTURE.md @@ -0,0 +1,111 @@ +# Scarf — Architecture + +## Pattern: MVVM-Feature + +Per project standards, every feature is a self-contained module owning Models, ViewModels, and Views. + +``` +scarf/ + Core/ + Services/ Hermes data access (SQLite, file I/O, ACP) + Models/ Plain data structs for Hermes entities + Features/ + Dashboard/ + Views/ DashboardView + ViewModels/ DashboardViewModel + Sessions/ + Views/ SessionsView, SessionDetailView + ViewModels/ SessionsViewModel + Activity/ + Views/ ActivityView + ViewModels/ ActivityViewModel + Chat/ + Views/ ChatView + ViewModels/ ChatViewModel + Memory/ + Views/ MemoryView + ViewModels/ MemoryViewModel + Skills/ + Views/ SkillsView + ViewModels/ SkillsViewModel + Cron/ + Views/ CronView + ViewModels/ CronViewModel + Logs/ + Views/ LogsView + ViewModels/ LogsViewModel + Settings/ + Views/ SettingsView + ViewModels/ SettingsViewModel + Navigation/ + AppCoordinator.swift + SidebarView.swift +``` + +## Navigation + +`AppCoordinator` is `@Observable` and injected via `.environment()` at the app root. It owns: +- `selectedSection: SidebarSection` — which feature is active +- `selectedSessionID: String?` — drill-down into a session + +One `NavigationSplitView` at top level, driven by the coordinator. Leaf views read but never own navigation state. + +## Services + +### HermesDataService +- Opens `~/.hermes/state.db` read-only via SQLite3 C API +- Queries `sessions` and `messages` tables +- Provides session list, message history, search (FTS5), and aggregate stats +- Polling-based refresh (watches WAL modification time) + +### HermesFileService +- Reads config.yaml (simple line parser for the YAML subset we need) +- Reads/writes memory markdown files +- Reads cron jobs.json, gateway_state.json, session JSON files +- Reads skill directory structure + +### HermesLogService +- Tails log files using file handle + periodic polling +- Parses log level from line format + +### ACPClient +- Spawns `hermes acp` via Foundation `Process` +- Writes JSON-RPC to stdin, reads from stdout +- Streams events: ToolCallStart, ToolCallProgress, AgentMessage, AgentThought +- Manages session lifecycle + +### HermesFileWatcher +- Uses `DispatchSource.makeFileSystemObjectSource` on key directories +- Triggers refresh callbacks when Hermes writes new data + +## Dependencies + +Zero external SPM packages: +- **SQLite**: System `sqlite3` C library (available on macOS, `import SQLite3` not needed — use `libsqlite3`) +- **JSON**: Foundation `JSONDecoder` / `JSONSerialization` +- **YAML**: Custom lightweight parser for flat config structure +- **Markdown**: `AttributedString(markdown:)` (built into Foundation) +- **File watching**: GCD `DispatchSource` +- **Subprocess**: Foundation `Process` + `Pipe` + +## Sandbox + +Disabled. This app reads directly from `~/.hermes/` which is outside any app sandbox container. The `ENABLE_APP_SANDBOX` build setting is set to `NO`. + +## Concurrency + +- Swift 6 strict concurrency with `@MainActor` default isolation +- Services use `nonisolated` methods with async/await for I/O +- `@Observable` ViewModels on MainActor, call into nonisolated services +- ACP client runs its read loop on a background task + +## Data Flow + +``` +~/.hermes/state.db ──→ HermesDataService ──→ ViewModels ──→ Views +~/.hermes/config.yaml ──→ HermesFileService ──→ ViewModels ──→ Views +~/.hermes/memories/ ──→ HermesFileService ──→ ViewModels ──→ Views +~/.hermes/logs/ ──→ HermesLogService ──→ ViewModels ──→ Views +hermes acp (subprocess) ──→ ACPClient ──→ ChatViewModel ──→ ChatView +HermesFileWatcher ──→ triggers refresh on all services +``` diff --git a/scarf/docs/HERMES_DISCOVERY.md b/scarf/docs/HERMES_DISCOVERY.md new file mode 100644 index 0000000..5fea4d9 --- /dev/null +++ b/scarf/docs/HERMES_DISCOVERY.md @@ -0,0 +1,183 @@ +# Hermes Agent — Discovery Notes + +## Installation + +- Binary: `~/.local/bin/hermes` (symlink to venv wrapper) +- Codebase: `~/.hermes/hermes-agent/` (Python 3.11 venv) +- Version: v0.6.0 (March 30, 2026) +- Runs as daemon process + +## What Hermes Does + +A self-improving AI agent with tool-calling capabilities: +- Interactive terminal chat with syntax highlighting +- 40+ tools (terminal, file, browser, web, code execution, vision, etc.) +- Autonomous skill creation from complex tasks +- Persistent memory (MEMORY.md + USER.md) with periodic nudges +- Multi-platform messaging gateway (Telegram, Discord, Slack, WhatsApp, Signal, Email) +- Cron scheduler for recurring tasks +- Session persistence in SQLite with FTS5 search +- Subagent delegation for parallel workstreams +- MCP (Model Context Protocol) integration +- ACP (Agent Client Protocol) for IDE integration + +## File System Layout + +``` +~/.hermes/ + hermes-agent/ Python codebase (70 directories) + run_agent.py Core agent loop + cli.py Terminal UI + model_tools.py Tool dispatcher + toolsets.py Tool definitions + agent/ Agent internals + tools/ 40+ tool implementations + gateway/ Multi-platform messaging + cron/ Scheduler implementation + hermes_cli/ CLI command handlers + acp_adapter/ Agent Client Protocol server + venv/ Python environment + config.yaml User configuration (8.8 KB) + .env API keys (encrypted) + auth.json OAuth tokens + state.db SQLite session database (WAL mode) + sessions/ JSON conversation snapshots + memories/ MEMORY.md, USER.md + skills/ 29 installed skills across 15+ categories + cron/ + jobs.json Scheduled job definitions + output/ Job execution output + logs/ + errors.log Application errors + gateway.log Gateway platform logs + gateway_state.json Gateway process lifecycle +``` + +## SQLite Schema (state.db, version 6) + +### sessions table +```sql +id TEXT PRIMARY KEY, +source TEXT, -- 'cli', 'telegram', 'discord', etc. +user_id TEXT, +model TEXT, +model_config TEXT, -- JSON +system_prompt TEXT, +parent_session_id TEXT, -- Session splitting on compression +started_at REAL, +ended_at REAL, +end_reason TEXT, +message_count INTEGER, +tool_call_count INTEGER, +input_tokens INTEGER, +output_tokens INTEGER, +cache_read_tokens INTEGER, +cache_write_tokens INTEGER, +reasoning_tokens INTEGER, +billing_provider TEXT, +billing_base_url TEXT, +billing_mode TEXT, +estimated_cost_usd REAL, +actual_cost_usd REAL, +cost_status TEXT, +cost_source TEXT, +pricing_version TEXT, +title TEXT UNIQUE +``` + +### messages table +```sql +id INTEGER PRIMARY KEY, +session_id TEXT, +role TEXT, -- 'user' or 'assistant' +content TEXT, +tool_call_id TEXT, +tool_calls TEXT, -- JSON array of tool invocations +tool_name TEXT, +timestamp REAL, +token_count INTEGER, +finish_reason TEXT, +reasoning TEXT, +reasoning_details TEXT, +codex_reasoning_items TEXT +``` + +### messages_fts (FTS5 virtual table) +Full-text search on message content. + +## Session JSON Format + +```json +{ + "session_id": "YYYYmmdd_HHMMSS_6hexchars", + "model": "claude-haiku-4-5-20251001", + "platform": "cli", + "session_start": "ISO8601", + "last_updated": "ISO8601", + "system_prompt": "...", + "tools": [{"type": "function", "function": {"name": "...", "description": "...", "parameters": {...}}}], + "messages": [ + {"role": "user", "content": "..."}, + {"role": "assistant", "content": "...", "tool_calls": [ + {"id": "call_...", "type": "function", "function": {"name": "terminal", "arguments": "{\"command\": \"...\"}"}} + ]} + ] +} +``` + +## Cron Jobs Format + +```json +{ + "jobs": [{ + "id": "12hexchars", + "name": "Job Name", + "prompt": "What to do", + "skills": ["skill-name"], + "schedule": {"kind": "once|cron", "run_at": "ISO8601", "display": "human readable"}, + "repeat": {"times": 1, "completed": 0}, + "enabled": true, + "state": "scheduled|running|completed", + "deliver": "origin|telegram|discord", + "next_run_at": "ISO8601", + "last_run_at": "ISO8601|null", + "last_error": "string|null" + }] +} +``` + +## Config Structure (config.yaml) + +Key sections: model (default, provider), agent (max_turns, tool_use_enforcement, personalities), terminal (backend, cwd, timeout), memory (enabled, char limits, nudge interval), display (personality, streaming, show_reasoning), platform_toolsets (tools per platform). + +## ACP (Agent Client Protocol) + +- Entry: `hermes acp` or `python -m acp_adapter.entry` +- Transport: stdio JSON-RPC (not HTTP) +- Lifecycle: initialize() -> new_session()/load_session() -> send messages +- Events emitted: ToolCallStart, ToolCallProgress, AgentMessage, AgentThought, SessionUpdate +- Tool kinds: read, edit, execute, fetch, search, think, other +- Tool call IDs: `tc-{uuid.hex[:12]}` + +## Log Format + +``` +YYYY-MM-DD HH:MM:SS,MMM LEVEL logger_name: message +``` + +## Gateway State + +```json +{ + "pid": 12345, + "kind": "hermes-gateway", + "gateway_state": "running|startup_failed", + "exit_reason": "string|null", + "platforms": {}, + "updated_at": "ISO8601" +} +``` + +## SQLite Contention Notes + +Hermes uses WAL mode with aggressive retry (15 retries, 20-150ms jitter). Scarf must only open state.db in read-only mode to avoid write contention. Checkpoint every 50 writes. WAL file modification is a good signal for refresh. diff --git a/scarf/docs/PRD.md b/scarf/docs/PRD.md new file mode 100644 index 0000000..587b164 --- /dev/null +++ b/scarf/docs/PRD.md @@ -0,0 +1,97 @@ +# Scarf — Product Requirements Document + +## Overview + +Scarf is a native macOS application that provides a graphical interface for the Hermes AI agent. Hermes is a CLI-based AI agent with 40+ tools, multi-platform messaging, autonomous skill creation, persistent memory, and scheduled automation. Scarf gives users visibility into what Hermes is doing, when, and what it creates. + +## Problem + +Hermes operates entirely through CLI with no visual dashboard. Users cannot easily: +- See what the agent is currently doing or has done +- Browse conversation history across sessions +- Monitor tool executions in real-time +- Manage memory, skills, or cron jobs visually +- Chat with the agent through a native interface + +## Target User + +Developer running Hermes locally on macOS who wants transparency and control over agent activity. + +## Core Features + +### 1. Dashboard +- System health overview (model, provider, connection status) +- Active session indicator +- Token usage and cost summary (aggregated from session data) +- Gateway platform connection status +- Recent activity feed + +### 2. Sessions Browser +- List all conversation sessions with metadata (source, message count, tool calls, cost, duration) +- Full conversation detail view with message rendering +- Full-text search across all sessions (via SQLite FTS5) +- Session lineage tracking (parent_session_id chains) + +### 3. Activity Feed +- Real-time tool execution monitoring (the core transparency feature) +- Each entry: tool name, kind, arguments, result preview, timestamp +- Filterable by tool type, session, time range +- Color-coded by tool kind (read/edit/execute/fetch) + +### 4. Live Chat +- Send messages to Hermes via ACP (Agent Client Protocol) +- Stream responses with tool calls shown inline +- Session management (new, load, resume) + +### 5. Memory Viewer/Editor +- Display MEMORY.md and USER.md with markdown rendering +- Edit and save changes +- Character count vs configured limits + +### 6. Skills Browser +- Tree view by category +- Skill metadata display +- Search and filter + +### 7. Cron Manager +- View scheduled jobs with status, next/last run times +- View job output +- Enable/disable jobs + +### 8. Log Viewer +- Real-time log tailing (errors.log, gateway.log) +- Level-based filtering and text search + +### 9. Menu Bar Presence +- Status icon showing Hermes state (running/idle/error) +- Quick access to recent session, new chat + +## Technical Constraints + +- macOS 26.2+ (SwiftUI, Swift 6 concurrency) +- No external SPM dependencies — uses system SQLite3 C API, Foundation JSON +- Reads Hermes data from `~/.hermes/` (requires sandbox disabled) +- ACP communication via subprocess stdio JSON-RPC +- App sandbox disabled (developer tool needing filesystem access) + +## Data Sources + +| Source | Path | Format | Access | +|--------|------|--------|--------| +| Sessions DB | `~/.hermes/state.db` | SQLite (WAL) | Read-only | +| Session files | `~/.hermes/sessions/*.json` | JSON | Read-only | +| Config | `~/.hermes/config.yaml` | YAML | Read/Write | +| Memory | `~/.hermes/memories/*.md` | Markdown | Read/Write | +| Cron jobs | `~/.hermes/cron/jobs.json` | JSON | Read/Write | +| Cron output | `~/.hermes/cron/output/` | Text | Read-only | +| Logs | `~/.hermes/logs/*.log` | Text | Read-only | +| Gateway state | `~/.hermes/gateway_state.json` | JSON | Read-only | +| Skills | `~/.hermes/skills/` | Directory tree | Read-only | +| ACP | `hermes acp` subprocess | JSON-RPC stdio | Bidirectional | + +## Non-Goals (v1) + +- Config editing UI (read-only display for v1, except memory) +- Skill creation or management +- Gateway platform management +- Multi-user support diff --git a/scarf/scarf.xcodeproj/project.pbxproj b/scarf/scarf.xcodeproj/project.pbxproj new file mode 100644 index 0000000..a2b086d --- /dev/null +++ b/scarf/scarf.xcodeproj/project.pbxproj @@ -0,0 +1,604 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 77; + objects = { + +/* Begin PBXBuildFile section */ + 53495AB62F7B992C00BD31AD /* SwiftTerm in Frameworks */ = {isa = PBXBuildFile; productRef = 53SWIFTTERM0001 /* SwiftTerm */; }; +/* End PBXBuildFile section */ + +/* Begin PBXContainerItemProxy section */ + 534959502F7B83B700BD31AD /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 534959382F7B83B600BD31AD /* Project object */; + proxyType = 1; + remoteGlobalIDString = 5349593F2F7B83B600BD31AD; + remoteInfo = scarf; + }; + 5349595A2F7B83B700BD31AD /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 534959382F7B83B600BD31AD /* Project object */; + proxyType = 1; + remoteGlobalIDString = 5349593F2F7B83B600BD31AD; + remoteInfo = scarf; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXFileReference section */ + 534959402F7B83B600BD31AD /* scarf.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = scarf.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 5349594F2F7B83B700BD31AD /* scarfTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = scarfTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 534959592F7B83B700BD31AD /* scarfUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = scarfUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; +/* End PBXFileReference section */ + +/* Begin PBXFileSystemSynchronizedRootGroup section */ + 534959422F7B83B600BD31AD /* scarf */ = { + isa = PBXFileSystemSynchronizedRootGroup; + path = scarf; + sourceTree = ""; + }; + 534959522F7B83B700BD31AD /* scarfTests */ = { + isa = PBXFileSystemSynchronizedRootGroup; + path = scarfTests; + sourceTree = ""; + }; + 5349595C2F7B83B700BD31AD /* scarfUITests */ = { + isa = PBXFileSystemSynchronizedRootGroup; + path = scarfUITests; + sourceTree = ""; + }; +/* End PBXFileSystemSynchronizedRootGroup section */ + +/* Begin PBXFrameworksBuildPhase section */ + 5349593D2F7B83B600BD31AD /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 53495AB62F7B992C00BD31AD /* SwiftTerm in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 5349594C2F7B83B700BD31AD /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 534959562F7B83B700BD31AD /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 534959372F7B83B600BD31AD = { + isa = PBXGroup; + children = ( + 534959422F7B83B600BD31AD /* scarf */, + 534959522F7B83B700BD31AD /* scarfTests */, + 5349595C2F7B83B700BD31AD /* scarfUITests */, + 534959412F7B83B600BD31AD /* Products */, + ); + sourceTree = ""; + }; + 534959412F7B83B600BD31AD /* Products */ = { + isa = PBXGroup; + children = ( + 534959402F7B83B600BD31AD /* scarf.app */, + 5349594F2F7B83B700BD31AD /* scarfTests.xctest */, + 534959592F7B83B700BD31AD /* scarfUITests.xctest */, + ); + name = Products; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 5349593F2F7B83B600BD31AD /* scarf */ = { + isa = PBXNativeTarget; + buildConfigurationList = 534959632F7B83B700BD31AD /* Build configuration list for PBXNativeTarget "scarf" */; + buildPhases = ( + 5349593C2F7B83B600BD31AD /* Sources */, + 5349593D2F7B83B600BD31AD /* Frameworks */, + 5349593E2F7B83B600BD31AD /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + fileSystemSynchronizedGroups = ( + 534959422F7B83B600BD31AD /* scarf */, + ); + name = scarf; + packageProductDependencies = ( + 53SWIFTTERM0001 /* SwiftTerm */, + ); + productName = scarf; + productReference = 534959402F7B83B600BD31AD /* scarf.app */; + productType = "com.apple.product-type.application"; + }; + 5349594E2F7B83B700BD31AD /* scarfTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 534959662F7B83B700BD31AD /* Build configuration list for PBXNativeTarget "scarfTests" */; + buildPhases = ( + 5349594B2F7B83B700BD31AD /* Sources */, + 5349594C2F7B83B700BD31AD /* Frameworks */, + 5349594D2F7B83B700BD31AD /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + 534959512F7B83B700BD31AD /* PBXTargetDependency */, + ); + fileSystemSynchronizedGroups = ( + 534959522F7B83B700BD31AD /* scarfTests */, + ); + name = scarfTests; + packageProductDependencies = ( + ); + productName = scarfTests; + productReference = 5349594F2F7B83B700BD31AD /* scarfTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; + 534959582F7B83B700BD31AD /* scarfUITests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 534959692F7B83B700BD31AD /* Build configuration list for PBXNativeTarget "scarfUITests" */; + buildPhases = ( + 534959552F7B83B700BD31AD /* Sources */, + 534959562F7B83B700BD31AD /* Frameworks */, + 534959572F7B83B700BD31AD /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + 5349595B2F7B83B700BD31AD /* PBXTargetDependency */, + ); + fileSystemSynchronizedGroups = ( + 5349595C2F7B83B700BD31AD /* scarfUITests */, + ); + name = scarfUITests; + packageProductDependencies = ( + ); + productName = scarfUITests; + productReference = 534959592F7B83B700BD31AD /* scarfUITests.xctest */; + productType = "com.apple.product-type.bundle.ui-testing"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 534959382F7B83B600BD31AD /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = 1; + LastSwiftUpdateCheck = 2630; + LastUpgradeCheck = 2630; + TargetAttributes = { + 5349593F2F7B83B600BD31AD = { + CreatedOnToolsVersion = 26.3; + }; + 5349594E2F7B83B700BD31AD = { + CreatedOnToolsVersion = 26.3; + TestTargetID = 5349593F2F7B83B600BD31AD; + }; + 534959582F7B83B700BD31AD = { + CreatedOnToolsVersion = 26.3; + TestTargetID = 5349593F2F7B83B600BD31AD; + }; + }; + }; + buildConfigurationList = 5349593B2F7B83B600BD31AD /* Build configuration list for PBXProject "scarf" */; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 534959372F7B83B600BD31AD; + minimizedProjectReferenceProxies = 1; + packageReferences = ( + 53SWIFTTERM0002 /* XCRemoteSwiftPackageReference "SwiftTerm" */, + ); + preferredProjectObjectVersion = 77; + productRefGroup = 534959412F7B83B600BD31AD /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 5349593F2F7B83B600BD31AD /* scarf */, + 5349594E2F7B83B700BD31AD /* scarfTests */, + 534959582F7B83B700BD31AD /* scarfUITests */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 5349593E2F7B83B600BD31AD /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 5349594D2F7B83B700BD31AD /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 534959572F7B83B700BD31AD /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 5349593C2F7B83B600BD31AD /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 5349594B2F7B83B700BD31AD /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 534959552F7B83B700BD31AD /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + 534959512F7B83B700BD31AD /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 5349593F2F7B83B600BD31AD /* scarf */; + targetProxy = 534959502F7B83B700BD31AD /* PBXContainerItemProxy */; + }; + 5349595B2F7B83B700BD31AD /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 5349593F2F7B83B600BD31AD /* scarf */; + targetProxy = 5349595A2F7B83B700BD31AD /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin XCBuildConfiguration section */ + 534959612F7B83B700BD31AD /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + DEVELOPMENT_TEAM = 3Q6X2L86C4; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MACOSX_DEPLOYMENT_TARGET = 26.2; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = macosx; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + }; + name = Debug; + }; + 534959622F7B83B700BD31AD /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + DEVELOPMENT_TEAM = 3Q6X2L86C4; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MACOSX_DEPLOYMENT_TARGET = 26.2; + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + SDKROOT = macosx; + SWIFT_COMPILATION_MODE = wholemodule; + }; + name = Release; + }; + 534959642F7B83B700BD31AD /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = 3Q6X2L86C4; + ENABLE_APP_SANDBOX = NO; + ENABLE_HARDENED_RUNTIME = YES; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.scarf; + PRODUCT_NAME = "$(TARGET_NAME)"; + REGISTER_APP_GROUPS = YES; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + }; + name = Debug; + }; + 534959652F7B83B700BD31AD /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = 3Q6X2L86C4; + ENABLE_APP_SANDBOX = NO; + ENABLE_HARDENED_RUNTIME = YES; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.scarf; + PRODUCT_NAME = "$(TARGET_NAME)"; + REGISTER_APP_GROUPS = YES; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + }; + name = Release; + }; + 534959672F7B83B700BD31AD /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = 3Q6X2L86C4; + GENERATE_INFOPLIST_FILE = YES; + MACOSX_DEPLOYMENT_TARGET = 26.2; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.scarfTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + STRING_CATALOG_GENERATE_SYMBOLS = NO; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/scarf.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/scarf"; + }; + name = Debug; + }; + 534959682F7B83B700BD31AD /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = 3Q6X2L86C4; + GENERATE_INFOPLIST_FILE = YES; + MACOSX_DEPLOYMENT_TARGET = 26.2; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.scarfTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + STRING_CATALOG_GENERATE_SYMBOLS = NO; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/scarf.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/scarf"; + }; + name = Release; + }; + 5349596A2F7B83B700BD31AD /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = 3Q6X2L86C4; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.scarfUITests; + PRODUCT_NAME = "$(TARGET_NAME)"; + STRING_CATALOG_GENERATE_SYMBOLS = NO; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TEST_TARGET_NAME = scarf; + }; + name = Debug; + }; + 5349596B2F7B83B700BD31AD /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = 3Q6X2L86C4; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.scarfUITests; + PRODUCT_NAME = "$(TARGET_NAME)"; + STRING_CATALOG_GENERATE_SYMBOLS = NO; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TEST_TARGET_NAME = scarf; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 5349593B2F7B83B600BD31AD /* Build configuration list for PBXProject "scarf" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 534959612F7B83B700BD31AD /* Debug */, + 534959622F7B83B700BD31AD /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 534959632F7B83B700BD31AD /* Build configuration list for PBXNativeTarget "scarf" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 534959642F7B83B700BD31AD /* Debug */, + 534959652F7B83B700BD31AD /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 534959662F7B83B700BD31AD /* Build configuration list for PBXNativeTarget "scarfTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 534959672F7B83B700BD31AD /* Debug */, + 534959682F7B83B700BD31AD /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 534959692F7B83B700BD31AD /* Build configuration list for PBXNativeTarget "scarfUITests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 5349596A2F7B83B700BD31AD /* Debug */, + 5349596B2F7B83B700BD31AD /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + +/* Begin XCRemoteSwiftPackageReference section */ + 53SWIFTTERM0002 /* XCRemoteSwiftPackageReference "SwiftTerm" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/migueldeicaza/SwiftTerm.git"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 1.0.0; + }; + }; +/* End XCRemoteSwiftPackageReference section */ + +/* Begin XCSwiftPackageProductDependency section */ + 53SWIFTTERM0001 /* SwiftTerm */ = { + isa = XCSwiftPackageProductDependency; + package = 53SWIFTTERM0002 /* XCRemoteSwiftPackageReference "SwiftTerm" */; + productName = SwiftTerm; + }; +/* End XCSwiftPackageProductDependency section */ + }; + rootObject = 534959382F7B83B600BD31AD /* Project object */; +} diff --git a/scarf/scarf.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/scarf/scarf.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..919434a --- /dev/null +++ b/scarf/scarf.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/scarf/scarf/Assets.xcassets/AccentColor.colorset/Contents.json b/scarf/scarf/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 0000000..eb87897 --- /dev/null +++ b/scarf/scarf/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,11 @@ +{ + "colors" : [ + { + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/scarf/scarf/Assets.xcassets/AppIcon.appiconset/Contents.json b/scarf/scarf/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..3f00db4 --- /dev/null +++ b/scarf/scarf/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,58 @@ +{ + "images" : [ + { + "idiom" : "mac", + "scale" : "1x", + "size" : "16x16" + }, + { + "idiom" : "mac", + "scale" : "2x", + "size" : "16x16" + }, + { + "idiom" : "mac", + "scale" : "1x", + "size" : "32x32" + }, + { + "idiom" : "mac", + "scale" : "2x", + "size" : "32x32" + }, + { + "idiom" : "mac", + "scale" : "1x", + "size" : "128x128" + }, + { + "idiom" : "mac", + "scale" : "2x", + "size" : "128x128" + }, + { + "idiom" : "mac", + "scale" : "1x", + "size" : "256x256" + }, + { + "idiom" : "mac", + "scale" : "2x", + "size" : "256x256" + }, + { + "idiom" : "mac", + "scale" : "1x", + "size" : "512x512" + }, + { + "idiom" : "mac", + "scale" : "2x", + "size" : "512x512" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/scarf/scarf/Assets.xcassets/Contents.json b/scarf/scarf/Assets.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/scarf/scarf/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/scarf/scarf/ContentView.swift b/scarf/scarf/ContentView.swift new file mode 100644 index 0000000..895ab67 --- /dev/null +++ b/scarf/scarf/ContentView.swift @@ -0,0 +1,37 @@ +import SwiftUI + +struct ContentView: View { + @Environment(AppCoordinator.self) private var coordinator + + var body: some View { + NavigationSplitView { + SidebarView() + } detail: { + detailView + } + } + + @ViewBuilder + private var detailView: some View { + switch coordinator.selectedSection { + case .dashboard: + DashboardView() + case .sessions: + SessionsView() + case .activity: + ActivityView() + case .chat: + ChatView() + case .memory: + MemoryView() + case .skills: + SkillsView() + case .cron: + CronView() + case .logs: + LogsView() + case .settings: + SettingsView() + } + } +} diff --git a/scarf/scarf/Core/Models/HermesConfig.swift b/scarf/scarf/Core/Models/HermesConfig.swift new file mode 100644 index 0000000..9cb2107 --- /dev/null +++ b/scarf/scarf/Core/Models/HermesConfig.swift @@ -0,0 +1,61 @@ +import Foundation + +struct HermesConfig: Sendable { + var model: String + var provider: String + var maxTurns: Int + var personality: String + var terminalBackend: String + var memoryEnabled: Bool + var memoryCharLimit: Int + var userCharLimit: Int + var nudgeInterval: Int + var streaming: Bool + var showReasoning: Bool + var verbose: Bool + + static let empty = HermesConfig( + model: "unknown", + provider: "unknown", + maxTurns: 0, + personality: "default", + terminalBackend: "local", + memoryEnabled: false, + memoryCharLimit: 0, + userCharLimit: 0, + nudgeInterval: 0, + streaming: true, + showReasoning: false, + verbose: false + ) +} + +struct GatewayState: Sendable, Codable { + let pid: Int? + let kind: String? + let gatewayState: String? + let exitReason: String? + let platforms: [String: PlatformState]? + let updatedAt: String? + + enum CodingKeys: String, CodingKey { + case pid, kind + case gatewayState = "gateway_state" + case exitReason = "exit_reason" + case platforms + case updatedAt = "updated_at" + } + + var isRunning: Bool { + gatewayState == "running" + } + + var statusText: String { + gatewayState ?? "unknown" + } +} + +struct PlatformState: Sendable, Codable { + let connected: Bool? + let error: String? +} diff --git a/scarf/scarf/Core/Models/HermesConstants.swift b/scarf/scarf/Core/Models/HermesConstants.swift new file mode 100644 index 0000000..2f092cb --- /dev/null +++ b/scarf/scarf/Core/Models/HermesConstants.swift @@ -0,0 +1,19 @@ +import Foundation + +enum HermesPaths: Sendable { + // Using ProcessInfo to avoid main-actor isolation issues with FileManager/NSHomeDirectory + nonisolated static let home: String = ProcessInfo.processInfo.environment["HOME"]! + "/.hermes" + nonisolated static let stateDB: String = home + "/state.db" + nonisolated static let configYAML: String = home + "/config.yaml" + nonisolated static let memoriesDir: String = home + "/memories" + nonisolated static let memoryMD: String = memoriesDir + "/MEMORY.md" + nonisolated static let userMD: String = memoriesDir + "/USER.md" + nonisolated static let sessionsDir: String = home + "/sessions" + nonisolated static let cronJobsJSON: String = home + "/cron/jobs.json" + nonisolated static let cronOutputDir: String = home + "/cron/output" + nonisolated static let gatewayStateJSON: String = home + "/gateway_state.json" + nonisolated static let skillsDir: String = home + "/skills" + nonisolated static let errorsLog: String = home + "/logs/errors.log" + nonisolated static let gatewayLog: String = home + "/logs/gateway.log" + nonisolated static let hermesBinary: String = ProcessInfo.processInfo.environment["HOME"]! + "/.local/bin/hermes" +} diff --git a/scarf/scarf/Core/Models/HermesCronJob.swift b/scarf/scarf/Core/Models/HermesCronJob.swift new file mode 100644 index 0000000..ca6c5a1 --- /dev/null +++ b/scarf/scarf/Core/Models/HermesCronJob.swift @@ -0,0 +1,57 @@ +import Foundation + +struct HermesCronJob: Identifiable, Sendable, Codable { + let id: String + let name: String + let prompt: String + let skills: [String]? + let model: String? + let schedule: CronSchedule + let enabled: Bool + let state: String + let deliver: String? + let nextRunAt: String? + let lastRunAt: String? + let lastError: String? + + enum CodingKeys: String, CodingKey { + case id, name, prompt, skills, model, schedule, enabled, state, deliver + case nextRunAt = "next_run_at" + case lastRunAt = "last_run_at" + case lastError = "last_error" + } + + var stateIcon: String { + switch state { + case "scheduled": return "clock" + case "running": return "play.circle" + case "completed": return "checkmark.circle" + case "failed": return "xmark.circle" + default: return "questionmark.circle" + } + } +} + +struct CronSchedule: Sendable, Codable { + let kind: String + let runAt: String? + let display: String? + let expression: String? + + enum CodingKeys: String, CodingKey { + case kind + case runAt = "run_at" + case display + case expression + } +} + +struct CronJobsFile: Sendable, Codable { + let jobs: [HermesCronJob] + let updatedAt: String? + + enum CodingKeys: String, CodingKey { + case jobs + case updatedAt = "updated_at" + } +} diff --git a/scarf/scarf/Core/Models/HermesMessage.swift b/scarf/scarf/Core/Models/HermesMessage.swift new file mode 100644 index 0000000..6383e79 --- /dev/null +++ b/scarf/scarf/Core/Models/HermesMessage.swift @@ -0,0 +1,121 @@ +import Foundation + +struct HermesMessage: Identifiable, Sendable { + let id: Int + let sessionId: String + let role: String + let content: String + let toolCallId: String? + let toolCalls: [HermesToolCall] + let toolName: String? + let timestamp: Date? + let tokenCount: Int? + let finishReason: String? + + var isUser: Bool { role == "user" } + var isAssistant: Bool { role == "assistant" } + var isToolResult: Bool { role == "tool" } +} + +struct HermesToolCall: Identifiable, Sendable, Codable { + var id: String { callId } + let callId: String + let functionName: String + let arguments: String + + enum CodingKeys: String, CodingKey { + case callId = "id" + case type + case function + } + + enum FunctionKeys: String, CodingKey { + case name + case arguments + } + + init(callId: String, functionName: String, arguments: String) { + self.callId = callId + self.functionName = functionName + self.arguments = arguments + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + callId = try container.decode(String.self, forKey: .callId) + let funcContainer = try container.nestedContainer(keyedBy: FunctionKeys.self, forKey: .function) + functionName = try funcContainer.decode(String.self, forKey: .name) + arguments = try funcContainer.decode(String.self, forKey: .arguments) + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(callId, forKey: .callId) + try container.encode("function", forKey: .type) + var funcContainer = container.nestedContainer(keyedBy: FunctionKeys.self, forKey: .function) + try funcContainer.encode(functionName, forKey: .name) + try funcContainer.encode(arguments, forKey: .arguments) + } + + var toolKind: ToolKind { + switch functionName { + case "read_file", "search_files", "vision_analyze": return .read + case "write_file", "patch": return .edit + case "terminal": return .execute + case "web_search", "web_extract": return .fetch + case "browser_navigate", "browser_click", "browser_screenshot": return .browser + default: return .other + } + } + + var argumentsSummary: String { + guard let data = arguments.data(using: .utf8), + let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { + return arguments + } + if let command = json["command"] as? String { + return command + } + if let path = json["path"] as? String { + return path + } + if let query = json["query"] as? String { + return query + } + if let url = json["url"] as? String { + return url + } + return arguments.prefix(120) + (arguments.count > 120 ? "..." : "") + } +} + +enum ToolKind: String, Sendable, CaseIterable { + case read + case edit + case execute + case fetch + case browser + case other + + var icon: String { + switch self { + case .read: return "doc.text.magnifyingglass" + case .edit: return "pencil" + case .execute: return "terminal" + case .fetch: return "globe" + case .browser: return "safari" + case .other: return "gearshape" + } + } + + var color: String { + switch self { + case .read: return "green" + case .edit: return "blue" + case .execute: return "orange" + case .fetch: return "purple" + case .browser: return "indigo" + case .other: return "gray" + } + } +} diff --git a/scarf/scarf/Core/Models/HermesSession.swift b/scarf/scarf/Core/Models/HermesSession.swift new file mode 100644 index 0000000..9d67d23 --- /dev/null +++ b/scarf/scarf/Core/Models/HermesSession.swift @@ -0,0 +1,42 @@ +import Foundation + +struct HermesSession: Identifiable, Sendable { + let id: String + let source: String + let userId: String? + let model: String? + let title: String? + let parentSessionId: String? + let startedAt: Date? + let endedAt: Date? + let endReason: String? + let messageCount: Int + let toolCallCount: Int + let inputTokens: Int + let outputTokens: Int + let cacheReadTokens: Int + let cacheWriteTokens: Int + let estimatedCostUSD: Double? + + var totalTokens: Int { inputTokens + outputTokens } + + var duration: TimeInterval? { + guard let start = startedAt, let end = endedAt else { return nil } + return end.timeIntervalSince(start) + } + + var displayTitle: String { + title ?? id + } + + var sourceIcon: String { + switch source { + case "cli": return "terminal" + case "telegram": return "paperplane" + case "discord": return "bubble.left.and.bubble.right" + case "slack": return "number" + case "email": return "envelope" + default: return "bubble.left" + } + } +} diff --git a/scarf/scarf/Core/Models/HermesSkill.swift b/scarf/scarf/Core/Models/HermesSkill.swift new file mode 100644 index 0000000..351b248 --- /dev/null +++ b/scarf/scarf/Core/Models/HermesSkill.swift @@ -0,0 +1,15 @@ +import Foundation + +struct HermesSkillCategory: Identifiable, Sendable { + let id: String + let name: String + let skills: [HermesSkill] +} + +struct HermesSkill: Identifiable, Sendable { + let id: String + let name: String + let category: String + let path: String + let files: [String] +} diff --git a/scarf/scarf/Core/Services/HermesDataService.swift b/scarf/scarf/Core/Services/HermesDataService.swift new file mode 100644 index 0000000..f40f864 --- /dev/null +++ b/scarf/scarf/Core/Services/HermesDataService.swift @@ -0,0 +1,234 @@ +import Foundation +import SQLite3 + +actor HermesDataService { + private var db: OpaquePointer? + + func open() -> Bool { + let path = HermesPaths.stateDB + guard FileManager.default.fileExists(atPath: path) else { return false } + let flags = SQLITE_OPEN_READONLY | SQLITE_OPEN_NOMUTEX + let result = sqlite3_open_v2(path, &db, flags, nil) + guard result == SQLITE_OK else { + db = nil + return false + } + sqlite3_exec(db, "PRAGMA journal_mode=WAL", nil, nil, nil) + return true + } + + func close() { + if let db { + sqlite3_close(db) + } + db = nil + } + + func fetchSessions(limit: Int = 100) -> [HermesSession] { + guard let db else { return [] } + let sql = """ + SELECT id, source, user_id, model, title, parent_session_id, + started_at, ended_at, end_reason, message_count, tool_call_count, + input_tokens, output_tokens, cache_read_tokens, cache_write_tokens, + estimated_cost_usd + FROM sessions + ORDER BY started_at DESC + LIMIT ? + """ + var stmt: OpaquePointer? + guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK else { return [] } + defer { sqlite3_finalize(stmt) } + sqlite3_bind_int(stmt, 1, Int32(limit)) + + var sessions: [HermesSession] = [] + while sqlite3_step(stmt) == SQLITE_ROW { + sessions.append(sessionFromRow(stmt!)) + } + return sessions + } + + func fetchMessages(sessionId: String) -> [HermesMessage] { + guard let db else { return [] } + let sql = """ + SELECT id, session_id, role, content, tool_call_id, tool_calls, + tool_name, timestamp, token_count, finish_reason + FROM messages + WHERE session_id = ? + ORDER BY timestamp ASC + """ + var stmt: OpaquePointer? + guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK else { return [] } + defer { sqlite3_finalize(stmt) } + sqlite3_bind_text(stmt, 1, sessionId, -1, unsafeBitCast(-1, to: sqlite3_destructor_type.self)) + + var messages: [HermesMessage] = [] + while sqlite3_step(stmt) == SQLITE_ROW { + messages.append(messageFromRow(stmt!)) + } + return messages + } + + func searchMessages(query: String, limit: Int = 50) -> [HermesMessage] { + guard let db else { return [] } + let sql = """ + SELECT m.id, m.session_id, m.role, m.content, m.tool_call_id, m.tool_calls, + m.tool_name, m.timestamp, m.token_count, m.finish_reason + FROM messages_fts fts + JOIN messages m ON m.id = fts.rowid + WHERE messages_fts MATCH ? + ORDER BY rank + LIMIT ? + """ + var stmt: OpaquePointer? + guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK else { return [] } + defer { sqlite3_finalize(stmt) } + sqlite3_bind_text(stmt, 1, query, -1, unsafeBitCast(-1, to: sqlite3_destructor_type.self)) + sqlite3_bind_int(stmt, 2, Int32(limit)) + + var messages: [HermesMessage] = [] + while sqlite3_step(stmt) == SQLITE_ROW { + messages.append(messageFromRow(stmt!)) + } + return messages + } + + func fetchRecentToolCalls(limit: Int = 50) -> [HermesMessage] { + guard let db else { return [] } + let sql = """ + SELECT id, session_id, role, content, tool_call_id, tool_calls, + tool_name, timestamp, token_count, finish_reason + FROM messages + WHERE tool_calls IS NOT NULL AND tool_calls != '[]' AND tool_calls != '' + ORDER BY timestamp DESC + LIMIT ? + """ + var stmt: OpaquePointer? + guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK else { return [] } + defer { sqlite3_finalize(stmt) } + sqlite3_bind_int(stmt, 1, Int32(limit)) + + var messages: [HermesMessage] = [] + while sqlite3_step(stmt) == SQLITE_ROW { + messages.append(messageFromRow(stmt!)) + } + return messages + } + + struct SessionStats: Sendable { + let totalSessions: Int + let totalMessages: Int + let totalToolCalls: Int + let totalInputTokens: Int + let totalOutputTokens: Int + let totalCostUSD: Double + } + + func fetchStats() -> SessionStats { + guard let db else { + return SessionStats(totalSessions: 0, totalMessages: 0, totalToolCalls: 0, + totalInputTokens: 0, totalOutputTokens: 0, totalCostUSD: 0) + } + let sql = """ + SELECT COUNT(*), COALESCE(SUM(message_count),0), COALESCE(SUM(tool_call_count),0), + COALESCE(SUM(input_tokens),0), COALESCE(SUM(output_tokens),0), + COALESCE(SUM(estimated_cost_usd),0) + FROM sessions + """ + var stmt: OpaquePointer? + guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK else { + return SessionStats(totalSessions: 0, totalMessages: 0, totalToolCalls: 0, + totalInputTokens: 0, totalOutputTokens: 0, totalCostUSD: 0) + } + defer { sqlite3_finalize(stmt) } + + guard sqlite3_step(stmt) == SQLITE_ROW else { + return SessionStats(totalSessions: 0, totalMessages: 0, totalToolCalls: 0, + totalInputTokens: 0, totalOutputTokens: 0, totalCostUSD: 0) + } + return SessionStats( + totalSessions: Int(sqlite3_column_int(stmt, 0)), + totalMessages: Int(sqlite3_column_int(stmt, 1)), + totalToolCalls: Int(sqlite3_column_int(stmt, 2)), + totalInputTokens: Int(sqlite3_column_int(stmt, 3)), + totalOutputTokens: Int(sqlite3_column_int(stmt, 4)), + totalCostUSD: sqlite3_column_double(stmt, 5) + ) + } + + func stateDBModificationDate() -> Date? { + let walPath = HermesPaths.stateDB + "-wal" + let dbPath = HermesPaths.stateDB + let fm = FileManager.default + let walDate = (try? fm.attributesOfItem(atPath: walPath))?[.modificationDate] as? Date + let dbDate = (try? fm.attributesOfItem(atPath: dbPath))?[.modificationDate] as? Date + if let w = walDate, let d = dbDate { + return max(w, d) + } + return walDate ?? dbDate + } + + // MARK: - Row Parsing + + private func sessionFromRow(_ stmt: OpaquePointer) -> HermesSession { + HermesSession( + id: columnText(stmt, 0), + source: columnText(stmt, 1), + userId: columnOptionalText(stmt, 2), + model: columnOptionalText(stmt, 3), + title: columnOptionalText(stmt, 4), + parentSessionId: columnOptionalText(stmt, 5), + startedAt: columnDate(stmt, 6), + endedAt: columnDate(stmt, 7), + endReason: columnOptionalText(stmt, 8), + messageCount: Int(sqlite3_column_int(stmt, 9)), + toolCallCount: Int(sqlite3_column_int(stmt, 10)), + inputTokens: Int(sqlite3_column_int(stmt, 11)), + outputTokens: Int(sqlite3_column_int(stmt, 12)), + cacheReadTokens: Int(sqlite3_column_int(stmt, 13)), + cacheWriteTokens: Int(sqlite3_column_int(stmt, 14)), + estimatedCostUSD: sqlite3_column_type(stmt, 15) != SQLITE_NULL ? sqlite3_column_double(stmt, 15) : nil + ) + } + + private func messageFromRow(_ stmt: OpaquePointer) -> HermesMessage { + let toolCallsJSON = columnOptionalText(stmt, 5) + let toolCalls = parseToolCalls(toolCallsJSON) + return HermesMessage( + id: Int(sqlite3_column_int(stmt, 0)), + sessionId: columnText(stmt, 1), + role: columnText(stmt, 2), + content: columnText(stmt, 3), + toolCallId: columnOptionalText(stmt, 4), + toolCalls: toolCalls, + toolName: columnOptionalText(stmt, 6), + timestamp: columnDate(stmt, 7), + tokenCount: sqlite3_column_type(stmt, 8) != SQLITE_NULL ? Int(sqlite3_column_int(stmt, 8)) : nil, + finishReason: columnOptionalText(stmt, 9) + ) + } + + private func parseToolCalls(_ json: String?) -> [HermesToolCall] { + guard let json, !json.isEmpty, + let data = json.data(using: .utf8) else { return [] } + return (try? JSONDecoder().decode([HermesToolCall].self, from: data)) ?? [] + } + + private func columnText(_ stmt: OpaquePointer, _ col: Int32) -> String { + if let cStr = sqlite3_column_text(stmt, col) { + return String(cString: cStr) + } + return "" + } + + private func columnOptionalText(_ stmt: OpaquePointer, _ col: Int32) -> String? { + guard sqlite3_column_type(stmt, col) != SQLITE_NULL, + let cStr = sqlite3_column_text(stmt, col) else { return nil } + return String(cString: cStr) + } + + private func columnDate(_ stmt: OpaquePointer, _ col: Int32) -> Date? { + guard sqlite3_column_type(stmt, col) != SQLITE_NULL else { return nil } + let value = sqlite3_column_double(stmt, col) + return Date(timeIntervalSince1970: value) + } +} diff --git a/scarf/scarf/Core/Services/HermesFileService.swift b/scarf/scarf/Core/Services/HermesFileService.swift new file mode 100644 index 0000000..2b764e2 --- /dev/null +++ b/scarf/scarf/Core/Services/HermesFileService.swift @@ -0,0 +1,159 @@ +import Foundation + +struct HermesFileService: Sendable { + + // MARK: - Config + + func loadConfig() -> HermesConfig { + guard let content = readFile(HermesPaths.configYAML) else { return .empty } + return parseConfig(content) + } + + private func parseConfig(_ yaml: String) -> HermesConfig { + var values: [String: String] = [:] + var currentSection = "" + + for line in yaml.components(separatedBy: "\n") { + let trimmed = line.trimmingCharacters(in: .whitespaces) + if trimmed.isEmpty || trimmed.hasPrefix("#") { continue } + + let indent = line.prefix(while: { $0 == " " }).count + if indent == 0 && trimmed.hasSuffix(":") { + currentSection = String(trimmed.dropLast()) + continue + } + + if let colonIdx = trimmed.firstIndex(of: ":") { + let key = String(trimmed[trimmed.startIndex.. GatewayState? { + guard let data = readFileData(HermesPaths.gatewayStateJSON) else { return nil } + return try? JSONDecoder().decode(GatewayState.self, from: data) + } + + // MARK: - Memory + + func loadMemory() -> String { + readFile(HermesPaths.memoryMD) ?? "" + } + + func loadUserProfile() -> String { + readFile(HermesPaths.userMD) ?? "" + } + + func saveMemory(_ content: String) { + writeFile(HermesPaths.memoryMD, content: content) + } + + func saveUserProfile(_ content: String) { + writeFile(HermesPaths.userMD, content: content) + } + + // MARK: - Cron + + func loadCronJobs() -> [HermesCronJob] { + guard let data = readFileData(HermesPaths.cronJobsJSON) else { return [] } + let file = try? JSONDecoder().decode(CronJobsFile.self, from: data) + return file?.jobs ?? [] + } + + func loadCronOutput(jobId: String) -> String? { + let dir = HermesPaths.cronOutputDir + let fm = FileManager.default + guard let files = try? fm.contentsOfDirectory(atPath: dir) else { return nil } + let matching = files.filter { $0.contains(jobId) }.sorted().last + guard let filename = matching else { return nil } + return readFile(dir + "/" + filename) + } + + // MARK: - Skills + + func loadSkills() -> [HermesSkillCategory] { + let dir = HermesPaths.skillsDir + let fm = FileManager.default + guard let categories = try? fm.contentsOfDirectory(atPath: dir) else { return [] } + + return categories.sorted().compactMap { categoryName in + let categoryPath = dir + "/" + categoryName + var isDir: ObjCBool = false + guard fm.fileExists(atPath: categoryPath, isDirectory: &isDir), isDir.boolValue else { return nil } + guard let skillNames = try? fm.contentsOfDirectory(atPath: categoryPath) else { return nil } + + let skills = skillNames.sorted().compactMap { skillName -> HermesSkill? in + let skillPath = categoryPath + "/" + skillName + var isSkillDir: ObjCBool = false + guard fm.fileExists(atPath: skillPath, isDirectory: &isSkillDir), isSkillDir.boolValue else { return nil } + let files = (try? fm.contentsOfDirectory(atPath: skillPath)) ?? [] + return HermesSkill( + id: categoryName + "/" + skillName, + name: skillName, + category: categoryName, + path: skillPath, + files: files.sorted() + ) + } + + guard !skills.isEmpty else { return nil } + return HermesSkillCategory(id: categoryName, name: categoryName, skills: skills) + } + } + + func loadSkillContent(path: String) -> String { + readFile(path) ?? "" + } + + // MARK: - Hermes Process + + func isHermesRunning() -> Bool { + let pipe = Pipe() + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/pgrep") + process.arguments = ["-f", "hermes"] + process.standardOutput = pipe + process.standardError = Pipe() + do { + try process.run() + process.waitUntilExit() + let data = pipe.fileHandleForReading.readDataToEndOfFile() + return !data.isEmpty + } catch { + return false + } + } + + // MARK: - File I/O + + private func readFile(_ path: String) -> String? { + try? String(contentsOfFile: path, encoding: .utf8) + } + + private func readFileData(_ path: String) -> Data? { + FileManager.default.contents(atPath: path) + } + + private func writeFile(_ path: String, content: String) { + try? content.write(toFile: path, atomically: true, encoding: .utf8) + } +} diff --git a/scarf/scarf/Core/Services/HermesFileWatcher.swift b/scarf/scarf/Core/Services/HermesFileWatcher.swift new file mode 100644 index 0000000..f00b493 --- /dev/null +++ b/scarf/scarf/Core/Services/HermesFileWatcher.swift @@ -0,0 +1,62 @@ +import Foundation + +@Observable +final class HermesFileWatcher { + private(set) var lastChangeDate = Date() + private var sources: [DispatchSourceFileSystemObject] = [] + private var timer: Timer? + + func startWatching() { + let paths = [ + HermesPaths.stateDB, + HermesPaths.stateDB + "-wal", + HermesPaths.configYAML, + HermesPaths.memoryMD, + HermesPaths.userMD, + HermesPaths.cronJobsJSON, + HermesPaths.gatewayStateJSON, + HermesPaths.errorsLog, + HermesPaths.gatewayLog + ] + + for path in paths { + watchFile(path) + } + + timer = Timer.scheduledTimer(withTimeInterval: 5.0, repeats: true) { [weak self] _ in + self?.lastChangeDate = Date() + } + } + + func stopWatching() { + for source in sources { + source.cancel() + } + sources.removeAll() + timer?.invalidate() + timer = nil + } + + private func watchFile(_ path: String) { + let fd = Darwin.open(path, O_EVTONLY) + guard fd >= 0 else { return } + + let source = DispatchSource.makeFileSystemObjectSource( + fileDescriptor: fd, + eventMask: [.write, .extend, .rename], + queue: .main + ) + source.setEventHandler { [weak self] in + self?.lastChangeDate = Date() + } + source.setCancelHandler { + Darwin.close(fd) + } + source.resume() + sources.append(source) + } + + deinit { + stopWatching() + } +} diff --git a/scarf/scarf/Core/Services/HermesLogService.swift b/scarf/scarf/Core/Services/HermesLogService.swift new file mode 100644 index 0000000..b05199d --- /dev/null +++ b/scarf/scarf/Core/Services/HermesLogService.swift @@ -0,0 +1,90 @@ +import Foundation + +struct LogEntry: Identifiable, Sendable { + let id: Int + let timestamp: String + let level: LogLevel + let logger: String + let message: String + let raw: String + + enum LogLevel: String, Sendable, CaseIterable { + case debug = "DEBUG" + case info = "INFO" + case warning = "WARNING" + case error = "ERROR" + case critical = "CRITICAL" + + var color: String { + switch self { + case .debug: return "secondary" + case .info: return "primary" + case .warning: return "orange" + case .error: return "red" + case .critical: return "red" + } + } + } +} + +actor HermesLogService { + private var fileHandle: FileHandle? + private var currentPath: String? + private var entryCounter = 0 + + func openLog(path: String) { + closeLog() + currentPath = path + fileHandle = FileHandle(forReadingAtPath: path) + } + + func closeLog() { + try? fileHandle?.close() + fileHandle = nil + currentPath = nil + } + + func readLastLines(count: Int = 200) -> [LogEntry] { + guard let path = currentPath, + let data = FileManager.default.contents(atPath: path) else { return [] } + let content = String(data: data, encoding: .utf8) ?? "" + let lines = content.components(separatedBy: "\n").filter { !$0.isEmpty } + let lastLines = Array(lines.suffix(count)) + return lastLines.map { parseLine($0) } + } + + func readNewLines() -> [LogEntry] { + guard let handle = fileHandle else { return [] } + let data = handle.availableData + guard !data.isEmpty else { return [] } + let content = String(data: data, encoding: .utf8) ?? "" + let lines = content.components(separatedBy: "\n").filter { !$0.isEmpty } + return lines.map { parseLine($0) } + } + + func seekToEnd() { + fileHandle?.seekToEndOfFile() + } + + private func parseLine(_ line: String) -> LogEntry { + entryCounter += 1 + // Format: YYYY-MM-DD HH:MM:SS,MMM LEVEL logger: message + let pattern = #"^(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2},\d{3})\s+(DEBUG|INFO|WARNING|ERROR|CRITICAL)\s+(\S+?):\s+(.*)$"# + if let regex = try? NSRegularExpression(pattern: pattern), + let match = regex.firstMatch(in: line, range: NSRange(line.startIndex..., in: line)) { + let timestamp = String(line[Range(match.range(at: 1), in: line)!]) + let levelStr = String(line[Range(match.range(at: 2), in: line)!]) + let logger = String(line[Range(match.range(at: 3), in: line)!]) + let message = String(line[Range(match.range(at: 4), in: line)!]) + return LogEntry( + id: entryCounter, + timestamp: timestamp, + level: LogEntry.LogLevel(rawValue: levelStr) ?? .info, + logger: logger, + message: message, + raw: line + ) + } + return LogEntry(id: entryCounter, timestamp: "", level: .info, logger: "", message: line, raw: line) + } +} diff --git a/scarf/scarf/Features/Activity/ViewModels/ActivityViewModel.swift b/scarf/scarf/Features/Activity/ViewModels/ActivityViewModel.swift new file mode 100644 index 0000000..369863b --- /dev/null +++ b/scarf/scarf/Features/Activity/ViewModels/ActivityViewModel.swift @@ -0,0 +1,68 @@ +import Foundation + +@Observable +final class ActivityViewModel { + private let dataService = HermesDataService() + + var toolMessages: [HermesMessage] = [] + var filterKind: ToolKind? + var selectedEntry: ActivityEntry? + var isLoading = true + + var filteredActivity: [ActivityEntry] { + let entries = toolMessages.flatMap { message in + message.toolCalls.map { call in + ActivityEntry( + id: call.callId, + sessionId: message.sessionId, + toolName: call.functionName, + kind: call.toolKind, + summary: call.argumentsSummary, + arguments: call.arguments, + messageContent: message.content, + timestamp: message.timestamp + ) + } + } + if let filterKind { + return entries.filter { $0.kind == filterKind } + } + return entries + } + + func load() async { + isLoading = true + let opened = await dataService.open() + guard opened else { + isLoading = false + return + } + toolMessages = await dataService.fetchRecentToolCalls(limit: 200) + isLoading = false + } + + func cleanup() async { + await dataService.close() + } +} + +struct ActivityEntry: Identifiable, Sendable { + let id: String + let sessionId: String + let toolName: String + let kind: ToolKind + let summary: String + let arguments: String + let messageContent: String + let timestamp: Date? + + var prettyArguments: String { + guard let data = arguments.data(using: .utf8), + let json = try? JSONSerialization.jsonObject(with: data, options: []), + let pretty = try? JSONSerialization.data(withJSONObject: json, options: [.prettyPrinted, .sortedKeys]), + let str = String(data: pretty, encoding: .utf8) else { + return arguments + } + return str + } +} diff --git a/scarf/scarf/Features/Activity/Views/ActivityView.swift b/scarf/scarf/Features/Activity/Views/ActivityView.swift new file mode 100644 index 0000000..5d7c668 --- /dev/null +++ b/scarf/scarf/Features/Activity/Views/ActivityView.swift @@ -0,0 +1,185 @@ +import SwiftUI + +struct ActivityView: View { + @State private var viewModel = ActivityViewModel() + @Environment(AppCoordinator.self) private var coordinator + + var body: some View { + VStack(spacing: 0) { + filterBar + Divider() + HSplitView { + activityList + .frame(minWidth: 350, idealWidth: 450) + activityDetail + .frame(minWidth: 300) + } + } + .navigationTitle("Activity") + .task { await viewModel.load() } + .onDisappear { Task { await viewModel.cleanup() } } + } + + private var filterBar: some View { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 8) { + FilterChip(label: "All", isSelected: viewModel.filterKind == nil) { + viewModel.filterKind = nil + } + ForEach(ToolKind.allCases, id: \.rawValue) { kind in + FilterChip(label: kind.rawValue.capitalized, isSelected: viewModel.filterKind == kind) { + viewModel.filterKind = kind + } + } + } + .padding(.horizontal) + .padding(.vertical, 8) + } + } + + private var activityList: some View { + List(selection: Binding( + get: { viewModel.selectedEntry?.id }, + set: { id in + if let id { + viewModel.selectedEntry = viewModel.filteredActivity.first(where: { $0.id == id }) + } else { + viewModel.selectedEntry = nil + } + } + )) { + ForEach(viewModel.filteredActivity) { entry in + HStack(spacing: 10) { + Image(systemName: entry.kind.icon) + .foregroundStyle(colorForKind(entry.kind)) + .frame(width: 20) + VStack(alignment: .leading, spacing: 2) { + Text(entry.toolName) + .font(.system(.body, design: .monospaced, weight: .medium)) + Text(entry.summary) + .font(.caption.monospaced()) + .foregroundStyle(.secondary) + .lineLimit(2) + } + Spacer() + if let time = entry.timestamp { + Text(time, style: .time) + .font(.caption) + .foregroundStyle(.secondary) + } + } + .tag(entry.id) + .padding(.vertical, 2) + } + } + .listStyle(.inset) + .overlay { + if viewModel.filteredActivity.isEmpty && !viewModel.isLoading { + ContentUnavailableView("No Activity", systemImage: "bolt.horizontal", description: Text("No tool calls found")) + } + } + } + + @ViewBuilder + private var activityDetail: some View { + if let entry = viewModel.selectedEntry { + ScrollView { + VStack(alignment: .leading, spacing: 16) { + HStack(spacing: 8) { + Image(systemName: entry.kind.icon) + .font(.title2) + .foregroundStyle(colorForKind(entry.kind)) + VStack(alignment: .leading, spacing: 2) { + Text(entry.toolName) + .font(.title3.bold().monospaced()) + Text(entry.kind.rawValue.capitalized) + .font(.caption) + .foregroundStyle(.secondary) + } + } + + HStack(spacing: 16) { + if let time = entry.timestamp { + Label(time.formatted(.dateTime.month().day().hour().minute().second()), systemImage: "clock") + } + Button { + coordinator.selectedSessionId = entry.sessionId + coordinator.selectedSection = .sessions + } label: { + Label(String(entry.sessionId.prefix(20)), systemImage: "bubble.left.and.bubble.right") + } + .buttonStyle(.plain) + .foregroundStyle(Color.accentColor) + .help("Open session") + } + .font(.caption) + .foregroundStyle(.secondary) + + Divider() + + VStack(alignment: .leading, spacing: 4) { + Text("Arguments") + .font(.caption.bold()) + .foregroundStyle(.secondary) + Text(entry.prettyArguments) + .font(.system(.caption, design: .monospaced)) + .textSelection(.enabled) + .padding(8) + .frame(maxWidth: .infinity, alignment: .leading) + .background(.quaternary.opacity(0.5)) + .clipShape(RoundedRectangle(cornerRadius: 6)) + } + + if !entry.messageContent.isEmpty { + VStack(alignment: .leading, spacing: 4) { + Text("Assistant Message") + .font(.caption.bold()) + .foregroundStyle(.secondary) + Text(entry.messageContent) + .font(.caption) + .textSelection(.enabled) + .padding(8) + .frame(maxWidth: .infinity, alignment: .leading) + .background(.quaternary.opacity(0.5)) + .clipShape(RoundedRectangle(cornerRadius: 6)) + } + } + } + .padding() + .frame(maxWidth: .infinity, alignment: .topLeading) + } + } else { + ContentUnavailableView("Select a Tool Call", systemImage: "bolt.horizontal", description: Text("Choose an entry from the list")) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + } + + private func colorForKind(_ kind: ToolKind) -> Color { + switch kind { + case .read: return .green + case .edit: return .blue + case .execute: return .orange + case .fetch: return .purple + case .browser: return .indigo + case .other: return .secondary + } + } +} + +struct FilterChip: View { + let label: String + let isSelected: Bool + let action: () -> Void + + var body: some View { + Button(action: action) { + Text(label) + .font(.caption) + .padding(.horizontal, 10) + .padding(.vertical, 4) + .background(isSelected ? Color.accentColor.opacity(0.2) : Color.secondary.opacity(0.1)) + .clipShape(Capsule()) + } + .buttonStyle(.plain) + } +} diff --git a/scarf/scarf/Features/Chat/ViewModels/ChatViewModel.swift b/scarf/scarf/Features/Chat/ViewModels/ChatViewModel.swift new file mode 100644 index 0000000..272671d --- /dev/null +++ b/scarf/scarf/Features/Chat/ViewModels/ChatViewModel.swift @@ -0,0 +1,10 @@ +import Foundation + +@Observable +final class ChatViewModel { + var sessionId = UUID() + + var hermesBinaryExists: Bool { + FileManager.default.fileExists(atPath: HermesPaths.hermesBinary) + } +} diff --git a/scarf/scarf/Features/Chat/Views/ChatView.swift b/scarf/scarf/Features/Chat/Views/ChatView.swift new file mode 100644 index 0000000..21b3724 --- /dev/null +++ b/scarf/scarf/Features/Chat/Views/ChatView.swift @@ -0,0 +1,54 @@ +import SwiftUI + +struct ChatView: View { + @State private var viewModel = ChatViewModel() + + var body: some View { + VStack(spacing: 0) { + toolbar + Divider() + terminalArea + } + .navigationTitle("Chat") + } + + private var toolbar: some View { + HStack { + Image(systemName: "terminal") + .foregroundStyle(.secondary) + Text("Hermes Terminal") + .font(.caption) + .foregroundStyle(.secondary) + Spacer() + if !viewModel.hermesBinaryExists { + Label("Hermes binary not found", systemImage: "exclamationmark.triangle") + .font(.caption) + .foregroundStyle(.red) + } + Button("New Session") { + viewModel.sessionId = UUID() + } + .controlSize(.small) + } + .padding(.horizontal) + .padding(.vertical, 6) + } + + @ViewBuilder + private var terminalArea: some View { + if viewModel.hermesBinaryExists { + TerminalRepresentable( + command: HermesPaths.hermesBinary, + arguments: ["chat"], + environment: [:] + ) + .id(viewModel.sessionId) + } else { + ContentUnavailableView( + "Hermes Not Found", + systemImage: "terminal", + description: Text("Expected at \(HermesPaths.hermesBinary)") + ) + } + } +} diff --git a/scarf/scarf/Features/Chat/Views/TerminalRepresentable.swift b/scarf/scarf/Features/Chat/Views/TerminalRepresentable.swift new file mode 100644 index 0000000..f87c1f6 --- /dev/null +++ b/scarf/scarf/Features/Chat/Views/TerminalRepresentable.swift @@ -0,0 +1,53 @@ +import SwiftUI +import AppKit +import SwiftTerm + +struct TerminalRepresentable: NSViewRepresentable { + let command: String + let arguments: [String] + let environment: [String: String] + + func makeNSView(context: Context) -> LocalProcessTerminalView { + let terminal = LocalProcessTerminalView(frame: .zero) + terminal.font = NSFont.monospacedSystemFont(ofSize: 13, weight: .regular) + terminal.nativeBackgroundColor = NSColor(red: 0.11, green: 0.12, blue: 0.14, alpha: 1.0) + terminal.nativeForegroundColor = NSColor(red: 0.85, green: 0.87, blue: 0.91, alpha: 1.0) + terminal.processDelegate = context.coordinator + + var env = ProcessInfo.processInfo.environment + for (key, value) in environment { + env[key] = value + } + env["TERM"] = "xterm-256color" + env["COLORTERM"] = "truecolor" + + let envArray = env.map { "\($0.key)=\($0.value)" } + + terminal.startProcess( + executable: command, + args: arguments, + environment: envArray, + execName: nil + ) + return terminal + } + + func updateNSView(_ nsView: LocalProcessTerminalView, context: Context) {} + + func makeCoordinator() -> Coordinator { + Coordinator() + } + + final class Coordinator: NSObject, LocalProcessTerminalViewDelegate { + func sizeChanged(source: LocalProcessTerminalView, newCols: Int, newRows: Int) {} + + func setTerminalTitle(source: LocalProcessTerminalView, title: String) {} + + func hostCurrentDirectoryUpdate(source: TerminalView, directory: String?) {} + + func processTerminated(source: TerminalView, exitCode: Int32?) { + let terminal = source.getTerminal() + terminal.feed(text: "\r\n[Process exited with code \(exitCode ?? -1)]\r\n") + } + } +} diff --git a/scarf/scarf/Features/Cron/ViewModels/CronViewModel.swift b/scarf/scarf/Features/Cron/ViewModels/CronViewModel.swift new file mode 100644 index 0000000..4285544 --- /dev/null +++ b/scarf/scarf/Features/Cron/ViewModels/CronViewModel.swift @@ -0,0 +1,19 @@ +import Foundation + +@Observable +final class CronViewModel { + private let fileService = HermesFileService() + + var jobs: [HermesCronJob] = [] + var selectedJob: HermesCronJob? + var jobOutput: String? + + func load() { + jobs = fileService.loadCronJobs() + } + + func selectJob(_ job: HermesCronJob) { + selectedJob = job + jobOutput = fileService.loadCronOutput(jobId: job.id) + } +} diff --git a/scarf/scarf/Features/Cron/Views/CronView.swift b/scarf/scarf/Features/Cron/Views/CronView.swift new file mode 100644 index 0000000..c73086e --- /dev/null +++ b/scarf/scarf/Features/Cron/Views/CronView.swift @@ -0,0 +1,145 @@ +import SwiftUI + +struct CronView: View { + @State private var viewModel = CronViewModel() + + var body: some View { + HSplitView { + jobsList + .frame(minWidth: 300, idealWidth: 350) + jobDetail + .frame(minWidth: 400) + } + .navigationTitle("Cron Jobs") + .onAppear { viewModel.load() } + } + + private var jobsList: some View { + List(selection: Binding( + get: { viewModel.selectedJob?.id }, + set: { id in + if let id, let job = viewModel.jobs.first(where: { $0.id == id }) { + viewModel.selectJob(job) + } else { + viewModel.selectedJob = nil + viewModel.jobOutput = nil + } + } + )) { + ForEach(viewModel.jobs) { job in + HStack { + Image(systemName: job.stateIcon) + .foregroundStyle(job.enabled ? .primary : .secondary) + VStack(alignment: .leading, spacing: 2) { + Text(job.name) + .lineLimit(1) + Text(job.schedule.display ?? job.schedule.kind) + .font(.caption) + .foregroundStyle(.secondary) + } + Spacer() + if !job.enabled { + Text("Disabled") + .font(.caption2) + .foregroundStyle(.secondary) + } + } + .tag(job.id) + } + } + .listStyle(.inset) + .overlay { + if viewModel.jobs.isEmpty { + ContentUnavailableView("No Cron Jobs", systemImage: "clock.arrow.2.circlepath", description: Text("No scheduled jobs configured")) + } + } + } + + @ViewBuilder + private var jobDetail: some View { + if let job = viewModel.selectedJob { + ScrollView { + VStack(alignment: .leading, spacing: 16) { + VStack(alignment: .leading, spacing: 8) { + Text(job.name) + .font(.title2.bold()) + HStack(spacing: 16) { + Label(job.state, systemImage: job.stateIcon) + Label(job.schedule.display ?? job.schedule.kind, systemImage: "clock") + Label(job.enabled ? "Enabled" : "Disabled", systemImage: job.enabled ? "checkmark.circle" : "xmark.circle") + if let deliver = job.deliver { + Label("Deliver: \(deliver)", systemImage: "paperplane") + } + } + .font(.caption) + .foregroundStyle(.secondary) + } + Divider() + VStack(alignment: .leading, spacing: 4) { + Text("Prompt") + .font(.caption.bold()) + .foregroundStyle(.secondary) + Text(job.prompt) + .textSelection(.enabled) + .padding(8) + .frame(maxWidth: .infinity, alignment: .leading) + .background(.quaternary.opacity(0.5)) + .clipShape(RoundedRectangle(cornerRadius: 6)) + } + if let skills = job.skills, !skills.isEmpty { + VStack(alignment: .leading, spacing: 4) { + Text("Skills") + .font(.caption.bold()) + .foregroundStyle(.secondary) + HStack { + ForEach(skills, id: \.self) { skill in + Text(skill) + .font(.caption.monospaced()) + .padding(.horizontal, 8) + .padding(.vertical, 2) + .background(.quaternary) + .clipShape(Capsule()) + } + } + } + } + if let nextRun = job.nextRunAt { + Label("Next run: \(nextRun)", systemImage: "arrow.forward.circle") + .font(.caption) + .foregroundStyle(.secondary) + } + if let lastRun = job.lastRunAt { + Label("Last run: \(lastRun)", systemImage: "arrow.backward.circle") + .font(.caption) + .foregroundStyle(.secondary) + } + if let error = job.lastError { + Label(error, systemImage: "exclamationmark.triangle") + .font(.caption) + .foregroundStyle(.red) + } + if let output = viewModel.jobOutput { + Divider() + VStack(alignment: .leading, spacing: 4) { + Text("Last Output") + .font(.caption.bold()) + .foregroundStyle(.secondary) + Text(output) + .font(.system(.caption, design: .monospaced)) + .textSelection(.enabled) + .padding(8) + .frame(maxWidth: .infinity, alignment: .leading) + .background(.quaternary.opacity(0.5)) + .clipShape(RoundedRectangle(cornerRadius: 6)) + } + } + } + .padding() + .frame(maxWidth: .infinity, alignment: .topLeading) + } + } else { + ContentUnavailableView("Select a Job", systemImage: "clock.arrow.2.circlepath", description: Text("Choose a cron job from the list")) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + } +} diff --git a/scarf/scarf/Features/Dashboard/ViewModels/DashboardViewModel.swift b/scarf/scarf/Features/Dashboard/ViewModels/DashboardViewModel.swift new file mode 100644 index 0000000..7a6f464 --- /dev/null +++ b/scarf/scarf/Features/Dashboard/ViewModels/DashboardViewModel.swift @@ -0,0 +1,31 @@ +import Foundation + +@Observable +final class DashboardViewModel { + private let dataService = HermesDataService() + private let fileService = HermesFileService() + + var stats = HermesDataService.SessionStats( + totalSessions: 0, totalMessages: 0, totalToolCalls: 0, + totalInputTokens: 0, totalOutputTokens: 0, totalCostUSD: 0 + ) + var recentSessions: [HermesSession] = [] + var config = HermesConfig.empty + var gatewayState: GatewayState? + var hermesRunning = false + var isLoading = true + + func load() async { + isLoading = true + let opened = await dataService.open() + if opened { + stats = await dataService.fetchStats() + recentSessions = await dataService.fetchSessions(limit: 5) + await dataService.close() + } + config = fileService.loadConfig() + gatewayState = fileService.loadGatewayState() + hermesRunning = fileService.isHermesRunning() + isLoading = false + } +} diff --git a/scarf/scarf/Features/Dashboard/Views/DashboardView.swift b/scarf/scarf/Features/Dashboard/Views/DashboardView.swift new file mode 100644 index 0000000..3830d7f --- /dev/null +++ b/scarf/scarf/Features/Dashboard/Views/DashboardView.swift @@ -0,0 +1,173 @@ +import SwiftUI + +struct DashboardView: View { + @State private var viewModel = DashboardViewModel() + @Environment(AppCoordinator.self) private var coordinator + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 20) { + statusSection + statsSection + recentSessionsSection + } + .padding() + .frame(maxWidth: .infinity, alignment: .topLeading) + } + .navigationTitle("Dashboard") + .task { await viewModel.load() } + } + + private var statusSection: some View { + HStack(spacing: 16) { + StatusCard( + title: "Hermes", + value: viewModel.hermesRunning ? "Running" : "Stopped", + icon: "circle.fill", + color: viewModel.hermesRunning ? .green : .secondary + ) + StatusCard( + title: "Model", + value: viewModel.config.model, + icon: "cpu", + color: .blue + ) + StatusCard( + title: "Provider", + value: viewModel.config.provider, + icon: "cloud", + color: .purple + ) + StatusCard( + title: "Gateway", + value: viewModel.gatewayState?.statusText ?? "unknown", + icon: "network", + color: viewModel.gatewayState?.isRunning == true ? .green : .secondary + ) + } + } + + private var statsSection: some View { + VStack(alignment: .leading, spacing: 8) { + Text("Usage Stats") + .font(.headline) + HStack(spacing: 16) { + StatCard(label: "Sessions", value: "\(viewModel.stats.totalSessions)") + StatCard(label: "Messages", value: "\(viewModel.stats.totalMessages)") + StatCard(label: "Tool Calls", value: "\(viewModel.stats.totalToolCalls)") + StatCard(label: "Tokens", value: formatTokens(viewModel.stats.totalInputTokens + viewModel.stats.totalOutputTokens)) + StatCard(label: "Est. Cost", value: String(format: "$%.2f", viewModel.stats.totalCostUSD)) + } + } + } + + private var recentSessionsSection: some View { + VStack(alignment: .leading, spacing: 8) { + HStack { + Text("Recent Sessions") + .font(.headline) + Spacer() + Button("View All") { + coordinator.selectedSection = .sessions + } + .buttonStyle(.link) + } + ForEach(viewModel.recentSessions) { session in + SessionRow(session: session) + .contentShape(Rectangle()) + .onTapGesture { + coordinator.selectedSessionId = session.id + coordinator.selectedSection = .sessions + } + } + if viewModel.recentSessions.isEmpty && !viewModel.isLoading { + Text("No sessions found") + .foregroundStyle(.secondary) + } + } + } + + private func formatTokens(_ count: Int) -> String { + if count >= 1_000_000 { + return String(format: "%.1fM", Double(count) / 1_000_000) + } else if count >= 1_000 { + return String(format: "%.1fK", Double(count) / 1_000) + } + return "\(count)" + } +} + +struct StatusCard: View { + let title: String + let value: String + let icon: String + let color: Color + + var body: some View { + VStack(alignment: .leading, spacing: 4) { + HStack(spacing: 4) { + Image(systemName: icon) + .foregroundStyle(color) + .font(.caption) + Text(title) + .font(.caption) + .foregroundStyle(.secondary) + } + Text(value) + .font(.system(.body, design: .monospaced)) + .lineLimit(1) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(12) + .background(.quaternary.opacity(0.5)) + .clipShape(RoundedRectangle(cornerRadius: 8)) + } +} + +struct StatCard: View { + let label: String + let value: String + + var body: some View { + VStack(spacing: 4) { + Text(value) + .font(.system(.title2, design: .monospaced, weight: .semibold)) + Text(label) + .font(.caption) + .foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity) + .padding(12) + .background(.quaternary.opacity(0.5)) + .clipShape(RoundedRectangle(cornerRadius: 8)) + } +} + +struct SessionRow: View { + let session: HermesSession + + var body: some View { + HStack { + Image(systemName: session.sourceIcon) + .foregroundStyle(.secondary) + .frame(width: 20) + VStack(alignment: .leading, spacing: 2) { + Text(session.displayTitle) + .lineLimit(1) + if let date = session.startedAt { + Text(date, style: .relative) + .font(.caption) + .foregroundStyle(.secondary) + } + } + Spacer() + HStack(spacing: 12) { + Label("\(session.messageCount)", systemImage: "bubble.left") + Label("\(session.toolCallCount)", systemImage: "wrench") + } + .font(.caption) + .foregroundStyle(.secondary) + } + .padding(.vertical, 4) + } +} diff --git a/scarf/scarf/Features/Logs/ViewModels/LogsViewModel.swift b/scarf/scarf/Features/Logs/ViewModels/LogsViewModel.swift new file mode 100644 index 0000000..a587156 --- /dev/null +++ b/scarf/scarf/Features/Logs/ViewModels/LogsViewModel.swift @@ -0,0 +1,72 @@ +import Foundation + +@Observable +final class LogsViewModel { + private let logService = HermesLogService() + + var entries: [LogEntry] = [] + var selectedLogFile: LogFile = .errors + var filterLevel: LogEntry.LogLevel? + var searchText = "" + private var pollTimer: Timer? + + enum LogFile: String, CaseIterable, Identifiable { + case errors = "errors.log" + case gateway = "gateway.log" + + var id: String { rawValue } + + var path: String { + switch self { + case .errors: return HermesPaths.errorsLog + case .gateway: return HermesPaths.gatewayLog + } + } + } + + var filteredEntries: [LogEntry] { + entries.filter { entry in + let levelOk = filterLevel == nil || entry.level == filterLevel + let searchOk = searchText.isEmpty || entry.raw.localizedCaseInsensitiveContains(searchText) + return levelOk && searchOk + } + } + + func load() async { + await logService.openLog(path: selectedLogFile.path) + entries = await logService.readLastLines(count: 500) + await logService.seekToEnd() + startPolling() + } + + func switchLogFile(_ file: LogFile) async { + selectedLogFile = file + entries = [] + await logService.openLog(path: file.path) + entries = await logService.readLastLines(count: 500) + await logService.seekToEnd() + } + + func startPolling() { + pollTimer?.invalidate() + pollTimer = Timer.scheduledTimer(withTimeInterval: 2.0, repeats: true) { [weak self] _ in + guard let self else { return } + Task { @MainActor in + let newEntries = await self.logService.readNewLines() + if !newEntries.isEmpty { + self.entries.append(contentsOf: newEntries) + } + } + } + } + + func stopPolling() { + pollTimer?.invalidate() + pollTimer = nil + } + + func cleanup() async { + stopPolling() + await logService.closeLog() + } +} diff --git a/scarf/scarf/Features/Logs/Views/LogsView.swift b/scarf/scarf/Features/Logs/Views/LogsView.swift new file mode 100644 index 0000000..8bb1268 --- /dev/null +++ b/scarf/scarf/Features/Logs/Views/LogsView.swift @@ -0,0 +1,85 @@ +import SwiftUI + +struct LogsView: View { + @State private var viewModel = LogsViewModel() + + var body: some View { + VStack(spacing: 0) { + toolbar + Divider() + logList + } + .navigationTitle("Logs") + .searchable(text: $viewModel.searchText, prompt: "Filter logs...") + .task { await viewModel.load() } + .onDisappear { Task { await viewModel.cleanup() } } + } + + private var toolbar: some View { + HStack(spacing: 12) { + Picker("Log File", selection: Binding( + get: { viewModel.selectedLogFile }, + set: { file in Task { await viewModel.switchLogFile(file) } } + )) { + ForEach(LogsViewModel.LogFile.allCases) { file in + Text(file.rawValue).tag(file) + } + } + .pickerStyle(.segmented) + .frame(maxWidth: 300) + + Spacer() + + Picker("Level", selection: $viewModel.filterLevel) { + Text("All Levels").tag(LogEntry.LogLevel?.none) + ForEach(LogEntry.LogLevel.allCases, id: \.rawValue) { level in + Text(level.rawValue).tag(LogEntry.LogLevel?.some(level)) + } + } + .frame(maxWidth: 150) + + Text("\(viewModel.filteredEntries.count) entries") + .font(.caption) + .foregroundStyle(.secondary) + } + .padding(.horizontal) + .padding(.vertical, 8) + } + + private var logList: some View { + ScrollViewReader { proxy in + List(viewModel.filteredEntries) { entry in + HStack(alignment: .top, spacing: 8) { + Text(entry.timestamp) + .font(.caption.monospaced()) + .foregroundStyle(.secondary) + .frame(width: 140, alignment: .leading) + Text(entry.level.rawValue) + .font(.caption.monospaced().bold()) + .foregroundStyle(colorForLevel(entry.level)) + .frame(width: 60, alignment: .leading) + Text(entry.message) + .font(.system(.caption, design: .monospaced)) + .textSelection(.enabled) + .lineLimit(3) + } + .id(entry.id) + } + .listStyle(.inset) + .onChange(of: viewModel.entries.count) { + if let last = viewModel.filteredEntries.last { + proxy.scrollTo(last.id, anchor: .bottom) + } + } + } + } + + private func colorForLevel(_ level: LogEntry.LogLevel) -> Color { + switch level { + case .debug: return .secondary + case .info: return .primary + case .warning: return .orange + case .error, .critical: return .red + } + } +} diff --git a/scarf/scarf/Features/Memory/ViewModels/MemoryViewModel.swift b/scarf/scarf/Features/Memory/ViewModels/MemoryViewModel.swift new file mode 100644 index 0000000..ffcded0 --- /dev/null +++ b/scarf/scarf/Features/Memory/ViewModels/MemoryViewModel.swift @@ -0,0 +1,46 @@ +import Foundation + +@Observable +final class MemoryViewModel { + private let fileService = HermesFileService() + + var memoryContent = "" + var userContent = "" + var isEditing = false + var editingFile: EditTarget = .memory + var editText = "" + + enum EditTarget { + case memory, user + } + + var memoryCharCount: Int { memoryContent.count } + var userCharCount: Int { userContent.count } + + func load() { + memoryContent = fileService.loadMemory() + userContent = fileService.loadUserProfile() + } + + func startEditing(_ target: EditTarget) { + editingFile = target + editText = target == .memory ? memoryContent : userContent + isEditing = true + } + + func save() { + switch editingFile { + case .memory: + fileService.saveMemory(editText) + memoryContent = editText + case .user: + fileService.saveUserProfile(editText) + userContent = editText + } + isEditing = false + } + + func cancelEditing() { + isEditing = false + } +} diff --git a/scarf/scarf/Features/Memory/Views/MemoryView.swift b/scarf/scarf/Features/Memory/Views/MemoryView.swift new file mode 100644 index 0000000..f1fc32c --- /dev/null +++ b/scarf/scarf/Features/Memory/Views/MemoryView.swift @@ -0,0 +1,77 @@ +import SwiftUI + +struct MemoryView: View { + @State private var viewModel = MemoryViewModel() + @Environment(HermesFileWatcher.self) private var fileWatcher + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 20) { + memorySection("Agent Memory", content: viewModel.memoryContent, charCount: viewModel.memoryCharCount, target: .memory) + memorySection("User Profile", content: viewModel.userContent, charCount: viewModel.userCharCount, target: .user) + } + .padding() + .frame(maxWidth: .infinity, alignment: .topLeading) + } + .navigationTitle("Memory") + .onAppear { viewModel.load() } + .onChange(of: fileWatcher.lastChangeDate) { + viewModel.load() + } + .sheet(isPresented: $viewModel.isEditing) { + editorSheet + } + } + + private func memorySection(_ title: String, content: String, charCount: Int, target: MemoryViewModel.EditTarget) -> some View { + VStack(alignment: .leading, spacing: 8) { + HStack { + Text(title) + .font(.headline) + Spacer() + Text("\(charCount) chars") + .font(.caption) + .foregroundStyle(.secondary) + Button("Edit") { + viewModel.startEditing(target) + } + .controlSize(.small) + } + if content.isEmpty { + Text("Empty") + .foregroundStyle(.secondary) + .padding() + } else { + Text(markdownAttributed(content)) + .textSelection(.enabled) + .padding() + .frame(maxWidth: .infinity, alignment: .leading) + .background(.quaternary.opacity(0.5)) + .clipShape(RoundedRectangle(cornerRadius: 8)) + } + } + } + + private var editorSheet: some View { + VStack(spacing: 0) { + HStack { + Text(viewModel.editingFile == .memory ? "Edit Agent Memory" : "Edit User Profile") + .font(.headline) + Spacer() + Button("Cancel") { viewModel.cancelEditing() } + Button("Save") { viewModel.save() } + .buttonStyle(.borderedProminent) + } + .padding() + Divider() + TextEditor(text: $viewModel.editText) + .font(.system(.body, design: .monospaced)) + .padding(8) + } + .frame(minWidth: 600, minHeight: 400) + } + + private func markdownAttributed(_ text: String) -> AttributedString { + (try? AttributedString(markdown: text, options: .init(interpretedSyntax: .inlineOnlyPreservingWhitespace))) ?? AttributedString(text) + } +} diff --git a/scarf/scarf/Features/Sessions/ViewModels/SessionsViewModel.swift b/scarf/scarf/Features/Sessions/ViewModels/SessionsViewModel.swift new file mode 100644 index 0000000..a71f0e4 --- /dev/null +++ b/scarf/scarf/Features/Sessions/ViewModels/SessionsViewModel.swift @@ -0,0 +1,45 @@ +import Foundation + +@Observable +final class SessionsViewModel { + private let dataService = HermesDataService() + + var sessions: [HermesSession] = [] + var selectedSession: HermesSession? + var messages: [HermesMessage] = [] + var searchText = "" + var searchResults: [HermesMessage] = [] + var isSearching = false + + func load() async { + let opened = await dataService.open() + guard opened else { return } + sessions = await dataService.fetchSessions(limit: 500) + } + + func selectSession(_ session: HermesSession) async { + selectedSession = session + messages = await dataService.fetchMessages(sessionId: session.id) + } + + func selectSessionById(_ id: String) async { + if let session = sessions.first(where: { $0.id == id }) { + await selectSession(session) + } + } + + func search() async { + let query = searchText.trimmingCharacters(in: .whitespaces) + guard !query.isEmpty else { + searchResults = [] + isSearching = false + return + } + isSearching = true + searchResults = await dataService.searchMessages(query: query) + } + + func cleanup() async { + await dataService.close() + } +} diff --git a/scarf/scarf/Features/Sessions/Views/SessionDetailView.swift b/scarf/scarf/Features/Sessions/Views/SessionDetailView.swift new file mode 100644 index 0000000..0cae7fe --- /dev/null +++ b/scarf/scarf/Features/Sessions/Views/SessionDetailView.swift @@ -0,0 +1,126 @@ +import SwiftUI + +struct SessionDetailView: View { + let session: HermesSession + let messages: [HermesMessage] + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + sessionHeader + Divider() + messagesList + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + } + + private var sessionHeader: some View { + VStack(alignment: .leading, spacing: 6) { + Text(session.displayTitle) + .font(.title3.bold()) + HStack(spacing: 16) { + Label(session.source, systemImage: session.sourceIcon) + Label(session.model ?? "unknown", systemImage: "cpu") + Label("\(session.messageCount) msgs", systemImage: "bubble.left") + Label("\(session.toolCallCount) tools", systemImage: "wrench") + if let cost = session.estimatedCostUSD { + Label(String(format: "$%.4f", cost), systemImage: "dollarsign.circle") + } + if let date = session.startedAt { + Label(date.formatted(.dateTime.month().day().hour().minute()), systemImage: "calendar") + } + } + .font(.caption) + .foregroundStyle(.secondary) + } + .padding() + } + + private var messagesList: some View { + ScrollView { + LazyVStack(alignment: .leading, spacing: 12) { + ForEach(messages) { message in + MessageBubble(message: message) + } + } + .padding() + } + } +} + +struct MessageBubble: View { + let message: HermesMessage + + var body: some View { + VStack(alignment: message.isUser ? .trailing : .leading, spacing: 4) { + HStack { + if message.isUser { Spacer(minLength: 60) } + VStack(alignment: .leading, spacing: 6) { + if !message.content.isEmpty { + Text(message.content) + .textSelection(.enabled) + } + if !message.toolCalls.isEmpty { + ForEach(message.toolCalls) { call in + ToolCallBadge(call: call) + } + } + } + .padding(10) + .background(message.isUser ? Color.accentColor.opacity(0.15) : Color.secondary.opacity(0.1)) + .clipShape(RoundedRectangle(cornerRadius: 10)) + if !message.isUser { Spacer(minLength: 60) } + } + if let time = message.timestamp { + Text(time, style: .time) + .font(.caption2) + .foregroundStyle(.tertiary) + } + } + .frame(maxWidth: .infinity, alignment: message.isUser ? .trailing : .leading) + } +} + +struct ToolCallBadge: View { + let call: HermesToolCall + + @State private var expanded = false + + var body: some View { + VStack(alignment: .leading, spacing: 4) { + Button { + expanded.toggle() + } label: { + HStack(spacing: 4) { + Image(systemName: call.toolKind.icon) + .foregroundStyle(toolColor) + Text(call.functionName) + .font(.caption.monospaced()) + Image(systemName: expanded ? "chevron.down" : "chevron.right") + .font(.caption2) + } + } + .buttonStyle(.plain) + + if expanded { + Text(call.argumentsSummary) + .font(.caption.monospaced()) + .foregroundStyle(.secondary) + .textSelection(.enabled) + .padding(6) + .background(.quaternary) + .clipShape(RoundedRectangle(cornerRadius: 4)) + } + } + } + + private var toolColor: Color { + switch call.toolKind { + case .read: return .green + case .edit: return .blue + case .execute: return .orange + case .fetch: return .purple + case .browser: return .indigo + case .other: return .secondary + } + } +} diff --git a/scarf/scarf/Features/Sessions/Views/SessionsView.swift b/scarf/scarf/Features/Sessions/Views/SessionsView.swift new file mode 100644 index 0000000..bb0f6f5 --- /dev/null +++ b/scarf/scarf/Features/Sessions/Views/SessionsView.swift @@ -0,0 +1,83 @@ +import SwiftUI + +struct SessionsView: View { + @State private var viewModel = SessionsViewModel() + @Environment(AppCoordinator.self) private var coordinator + + var body: some View { + HSplitView { + sessionList + .frame(minWidth: 280, idealWidth: 320) + sessionDetail + .frame(minWidth: 400) + } + .navigationTitle("Sessions") + .searchable(text: $viewModel.searchText, prompt: "Search messages...") + .onSubmit(of: .search) { Task { await viewModel.search() } } + .onChange(of: viewModel.searchText) { + if viewModel.searchText.isEmpty { + viewModel.isSearching = false + viewModel.searchResults = [] + } + } + .task { + await viewModel.load() + if let id = coordinator.selectedSessionId { + await viewModel.selectSessionById(id) + coordinator.selectedSessionId = nil + } + } + .onDisappear { Task { await viewModel.cleanup() } } + } + + private var sessionList: some View { + List(selection: Binding( + get: { viewModel.selectedSession?.id }, + set: { id in + if let id, let session = viewModel.sessions.first(where: { $0.id == id }) { + Task { await viewModel.selectSession(session) } + } else { + viewModel.selectedSession = nil + viewModel.messages = [] + } + } + )) { + if viewModel.isSearching { + Section("Search Results (\(viewModel.searchResults.count))") { + ForEach(viewModel.searchResults) { message in + VStack(alignment: .leading, spacing: 2) { + Text(message.content.prefix(100)) + .lineLimit(2) + .font(.caption) + Text(message.sessionId) + .font(.caption2) + .foregroundStyle(.secondary) + } + .tag(message.sessionId) + .contentShape(Rectangle()) + .onTapGesture { + Task { await viewModel.selectSessionById(message.sessionId) } + } + } + } + } else { + ForEach(viewModel.sessions) { session in + SessionRow(session: session) + .tag(session.id) + } + } + } + .listStyle(.inset) + } + + @ViewBuilder + private var sessionDetail: some View { + if let session = viewModel.selectedSession { + SessionDetailView(session: session, messages: viewModel.messages) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + } else { + ContentUnavailableView("Select a Session", systemImage: "bubble.left.and.bubble.right", description: Text("Choose a session from the list")) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + } +} diff --git a/scarf/scarf/Features/Settings/ViewModels/SettingsViewModel.swift b/scarf/scarf/Features/Settings/ViewModels/SettingsViewModel.swift new file mode 100644 index 0000000..b75d31f --- /dev/null +++ b/scarf/scarf/Features/Settings/ViewModels/SettingsViewModel.swift @@ -0,0 +1,18 @@ +import Foundation + +@Observable +final class SettingsViewModel { + private let fileService = HermesFileService() + + var config = HermesConfig.empty + var gatewayState: GatewayState? + var hermesRunning = false + var rawConfigYAML = "" + + func load() { + config = fileService.loadConfig() + gatewayState = fileService.loadGatewayState() + hermesRunning = fileService.isHermesRunning() + rawConfigYAML = (try? String(contentsOfFile: HermesPaths.configYAML, encoding: .utf8)) ?? "" + } +} diff --git a/scarf/scarf/Features/Settings/Views/SettingsView.swift b/scarf/scarf/Features/Settings/Views/SettingsView.swift new file mode 100644 index 0000000..020b97c --- /dev/null +++ b/scarf/scarf/Features/Settings/Views/SettingsView.swift @@ -0,0 +1,139 @@ +import SwiftUI + +struct SettingsView: View { + @State private var viewModel = SettingsViewModel() + @State private var showRawConfig = false + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 20) { + configSection + gatewaySection + pathsSection + rawConfigSection + } + .padding() + .frame(maxWidth: .infinity, alignment: .topLeading) + } + .navigationTitle("Settings") + .onAppear { viewModel.load() } + } + + private var configSection: some View { + VStack(alignment: .leading, spacing: 12) { + Text("Configuration") + .font(.headline) + LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], alignment: .leading, spacing: 8) { + SettingRow(label: "Model", value: viewModel.config.model) + SettingRow(label: "Provider", value: viewModel.config.provider) + SettingRow(label: "Personality", value: viewModel.config.personality) + SettingRow(label: "Max Turns", value: "\(viewModel.config.maxTurns)") + SettingRow(label: "Terminal Backend", value: viewModel.config.terminalBackend) + SettingRow(label: "Memory Enabled", value: viewModel.config.memoryEnabled ? "Yes" : "No") + SettingRow(label: "Memory Char Limit", value: "\(viewModel.config.memoryCharLimit)") + SettingRow(label: "User Char Limit", value: "\(viewModel.config.userCharLimit)") + SettingRow(label: "Nudge Interval", value: "\(viewModel.config.nudgeInterval) turns") + SettingRow(label: "Streaming", value: viewModel.config.streaming ? "Yes" : "No") + SettingRow(label: "Show Reasoning", value: viewModel.config.showReasoning ? "Yes" : "No") + SettingRow(label: "Verbose", value: viewModel.config.verbose ? "Yes" : "No") + } + } + } + + private var gatewaySection: some View { + VStack(alignment: .leading, spacing: 8) { + Text("Gateway") + .font(.headline) + HStack(spacing: 16) { + Label( + viewModel.gatewayState?.statusText ?? "unknown", + systemImage: viewModel.gatewayState?.isRunning == true ? "circle.fill" : "circle" + ) + .foregroundStyle(viewModel.gatewayState?.isRunning == true ? .green : .secondary) + if let reason = viewModel.gatewayState?.exitReason { + Text(reason) + .font(.caption) + .foregroundStyle(.secondary) + } + } + } + } + + private var pathsSection: some View { + VStack(alignment: .leading, spacing: 8) { + Text("Paths") + .font(.headline) + VStack(alignment: .leading, spacing: 4) { + PathRow(label: "Hermes Home", path: HermesPaths.home) + PathRow(label: "State DB", path: HermesPaths.stateDB) + PathRow(label: "Config", path: HermesPaths.configYAML) + PathRow(label: "Memory", path: HermesPaths.memoriesDir) + PathRow(label: "Sessions", path: HermesPaths.sessionsDir) + PathRow(label: "Skills", path: HermesPaths.skillsDir) + PathRow(label: "Logs", path: HermesPaths.errorsLog) + } + } + } + + private var rawConfigSection: some View { + VStack(alignment: .leading, spacing: 8) { + HStack { + Text("Raw Config") + .font(.headline) + Button(showRawConfig ? "Hide" : "Show") { + showRawConfig.toggle() + } + .controlSize(.small) + } + if showRawConfig { + Text(viewModel.rawConfigYAML) + .font(.system(.caption, design: .monospaced)) + .textSelection(.enabled) + .padding(8) + .frame(maxWidth: .infinity, alignment: .leading) + .background(.quaternary.opacity(0.5)) + .clipShape(RoundedRectangle(cornerRadius: 6)) + } + } + } +} + +struct SettingRow: View { + let label: String + let value: String + + var body: some View { + HStack { + Text(label) + .font(.caption) + .foregroundStyle(.secondary) + .frame(width: 120, alignment: .trailing) + Text(value) + .font(.system(.caption, design: .monospaced)) + } + } +} + +struct PathRow: View { + let label: String + let path: String + + var body: some View { + HStack { + Text(label) + .font(.caption) + .foregroundStyle(.secondary) + .frame(width: 100, alignment: .trailing) + Text(path) + .font(.system(.caption, design: .monospaced)) + .textSelection(.enabled) + Button { + NSWorkspace.shared.selectFile(nil, inFileViewerRootedAtPath: path) + } label: { + Image(systemName: "folder") + .font(.caption) + } + .buttonStyle(.plain) + } + } +} diff --git a/scarf/scarf/Features/Skills/ViewModels/SkillsViewModel.swift b/scarf/scarf/Features/Skills/ViewModels/SkillsViewModel.swift new file mode 100644 index 0000000..5d8820b --- /dev/null +++ b/scarf/scarf/Features/Skills/ViewModels/SkillsViewModel.swift @@ -0,0 +1,50 @@ +import Foundation + +@Observable +final class SkillsViewModel { + private let fileService = HermesFileService() + + var categories: [HermesSkillCategory] = [] + var selectedSkill: HermesSkill? + var skillContent = "" + var selectedFileName: String? + var searchText = "" + + var filteredCategories: [HermesSkillCategory] { + guard !searchText.isEmpty else { return categories } + return categories.compactMap { category in + let filtered = category.skills.filter { + $0.name.localizedCaseInsensitiveContains(searchText) || + $0.category.localizedCaseInsensitiveContains(searchText) + } + guard !filtered.isEmpty else { return nil } + return HermesSkillCategory(id: category.id, name: category.name, skills: filtered) + } + } + + var totalSkillCount: Int { + categories.reduce(0) { $0 + $1.skills.count } + } + + func load() { + categories = fileService.loadSkills() + } + + func selectSkill(_ skill: HermesSkill) { + selectedSkill = skill + let mainFile = skill.files.first(where: { $0.hasSuffix(".md") }) ?? skill.files.first + if let file = mainFile { + selectedFileName = file + skillContent = fileService.loadSkillContent(path: skill.path + "/" + file) + } else { + selectedFileName = nil + skillContent = "" + } + } + + func selectFile(_ file: String) { + guard let skill = selectedSkill else { return } + selectedFileName = file + skillContent = fileService.loadSkillContent(path: skill.path + "/" + file) + } +} diff --git a/scarf/scarf/Features/Skills/Views/SkillsView.swift b/scarf/scarf/Features/Skills/Views/SkillsView.swift new file mode 100644 index 0000000..112d9bb --- /dev/null +++ b/scarf/scarf/Features/Skills/Views/SkillsView.swift @@ -0,0 +1,96 @@ +import SwiftUI + +struct SkillsView: View { + @State private var viewModel = SkillsViewModel() + + var body: some View { + HSplitView { + skillsList + .frame(minWidth: 250, idealWidth: 300) + skillDetail + .frame(minWidth: 400) + } + .navigationTitle("Skills (\(viewModel.totalSkillCount))") + .searchable(text: $viewModel.searchText, prompt: "Filter skills...") + .onAppear { viewModel.load() } + } + + private var skillsList: some View { + List(selection: Binding( + get: { viewModel.selectedSkill?.id }, + set: { id in + if let id { + for category in viewModel.filteredCategories { + if let skill = category.skills.first(where: { $0.id == id }) { + viewModel.selectSkill(skill) + return + } + } + } + viewModel.selectedSkill = nil + viewModel.skillContent = "" + } + )) { + ForEach(viewModel.filteredCategories) { category in + Section(category.name) { + ForEach(category.skills) { skill in + Label(skill.name, systemImage: "lightbulb") + .tag(skill.id) + } + } + } + } + .listStyle(.sidebar) + } + + @ViewBuilder + private var skillDetail: some View { + if let skill = viewModel.selectedSkill { + ScrollView { + VStack(alignment: .leading, spacing: 12) { + Text(skill.name) + .font(.title2.bold()) + HStack { + Label(skill.category, systemImage: "folder") + Label("\(skill.files.count) files", systemImage: "doc") + } + .font(.caption) + .foregroundStyle(.secondary) + Divider() + if !skill.files.isEmpty { + VStack(alignment: .leading, spacing: 4) { + Text("Files") + .font(.caption.bold()) + .foregroundStyle(.secondary) + ForEach(skill.files, id: \.self) { file in + Button { + viewModel.selectFile(file) + } label: { + HStack(spacing: 4) { + Image(systemName: viewModel.selectedFileName == file ? "doc.fill" : "doc") + .font(.caption) + Text(file) + .font(.caption.monospaced()) + } + .foregroundStyle(viewModel.selectedFileName == file ? .primary : .secondary) + } + .buttonStyle(.plain) + } + } + } + if !viewModel.skillContent.isEmpty { + Divider() + Text(viewModel.skillContent) + .font(.system(.body, design: .monospaced)) + .textSelection(.enabled) + } + } + .padding() + .frame(maxWidth: .infinity, alignment: .topLeading) + } + } else { + ContentUnavailableView("Select a Skill", systemImage: "lightbulb", description: Text("Choose a skill from the list")) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + } +} diff --git a/scarf/scarf/Navigation/AppCoordinator.swift b/scarf/scarf/Navigation/AppCoordinator.swift new file mode 100644 index 0000000..97724a7 --- /dev/null +++ b/scarf/scarf/Navigation/AppCoordinator.swift @@ -0,0 +1,35 @@ +import Foundation + +enum SidebarSection: String, CaseIterable, Identifiable { + case dashboard = "Dashboard" + case sessions = "Sessions" + case activity = "Activity" + case chat = "Chat" + case memory = "Memory" + case skills = "Skills" + case cron = "Cron" + case logs = "Logs" + case settings = "Settings" + + var id: String { rawValue } + + var icon: String { + switch self { + case .dashboard: return "gauge.with.dots.needle.33percent" + case .sessions: return "bubble.left.and.bubble.right" + case .activity: return "bolt.horizontal" + case .chat: return "text.bubble" + case .memory: return "brain" + case .skills: return "lightbulb" + case .cron: return "clock.arrow.2.circlepath" + case .logs: return "doc.text" + case .settings: return "gearshape" + } + } +} + +@Observable +final class AppCoordinator { + var selectedSection: SidebarSection = .dashboard + var selectedSessionId: String? +} diff --git a/scarf/scarf/Navigation/SidebarView.swift b/scarf/scarf/Navigation/SidebarView.swift new file mode 100644 index 0000000..e72a305 --- /dev/null +++ b/scarf/scarf/Navigation/SidebarView.swift @@ -0,0 +1,31 @@ +import SwiftUI + +struct SidebarView: View { + @Environment(AppCoordinator.self) private var coordinator + + var body: some View { + @Bindable var coordinator = coordinator + List(selection: $coordinator.selectedSection) { + Section("Monitor") { + ForEach([SidebarSection.dashboard, .sessions, .activity]) { section in + Label(section.rawValue, systemImage: section.icon) + .tag(section) + } + } + Section("Interact") { + ForEach([SidebarSection.chat, .memory, .skills]) { section in + Label(section.rawValue, systemImage: section.icon) + .tag(section) + } + } + Section("Manage") { + ForEach([SidebarSection.cron, .logs, .settings]) { section in + Label(section.rawValue, systemImage: section.icon) + .tag(section) + } + } + } + .listStyle(.sidebar) + .navigationTitle("Scarf") + } +} diff --git a/scarf/scarf/scarfApp.swift b/scarf/scarf/scarfApp.swift new file mode 100644 index 0000000..ad816a3 --- /dev/null +++ b/scarf/scarf/scarfApp.swift @@ -0,0 +1,89 @@ +import SwiftUI + +@main +struct ScarfApp: App { + @State private var coordinator = AppCoordinator() + @State private var fileWatcher = HermesFileWatcher() + @State private var menuBarStatus = MenuBarStatus() + + var body: some Scene { + WindowGroup { + ContentView() + .environment(coordinator) + .environment(fileWatcher) + .onAppear { + fileWatcher.startWatching() + menuBarStatus.startPolling() + } + .onDisappear { + fileWatcher.stopWatching() + menuBarStatus.stopPolling() + } + } + .defaultSize(width: 1100, height: 700) + + MenuBarExtra("Scarf", systemImage: menuBarStatus.icon) { + MenuBarMenu(status: menuBarStatus, coordinator: coordinator) + } + } +} + +@Observable +final class MenuBarStatus { + private let fileService = HermesFileService() + private var timer: Timer? + + var hermesRunning = false + var gatewayRunning = false + + var icon: String { + hermesRunning ? "hare.fill" : "hare" + } + + func startPolling() { + refresh() + timer = Timer.scheduledTimer(withTimeInterval: 10.0, repeats: true) { [weak self] _ in + self?.refresh() + } + } + + func stopPolling() { + timer?.invalidate() + timer = nil + } + + private func refresh() { + hermesRunning = fileService.isHermesRunning() + gatewayRunning = fileService.loadGatewayState()?.isRunning ?? false + } +} + +struct MenuBarMenu: View { + let status: MenuBarStatus + let coordinator: AppCoordinator + + var body: some View { + VStack { + Label(status.hermesRunning ? "Hermes Running" : "Hermes Stopped", systemImage: status.hermesRunning ? "circle.fill" : "circle") + Label(status.gatewayRunning ? "Gateway Running" : "Gateway Stopped", systemImage: status.gatewayRunning ? "circle.fill" : "circle") + Divider() + Button("Open Dashboard") { + coordinator.selectedSection = .dashboard + NSApplication.shared.activate() + } + Button("New Chat") { + coordinator.selectedSection = .chat + NSApplication.shared.activate() + } + Button("View Sessions") { + coordinator.selectedSection = .sessions + NSApplication.shared.activate() + } + Divider() + Button("Quit Scarf") { + NSApplication.shared.terminate(nil) + } + .keyboardShortcut("q") + } + } +} diff --git a/scarf/scarfTests/scarfTests.swift b/scarf/scarfTests/scarfTests.swift new file mode 100644 index 0000000..c8429aa --- /dev/null +++ b/scarf/scarfTests/scarfTests.swift @@ -0,0 +1,17 @@ +// +// scarfTests.swift +// scarfTests +// +// Created by Alan Wizemann on 3/31/26. +// + +import Testing +@testable import scarf + +struct scarfTests { + + @Test func example() async throws { + // Write your test here and use APIs like `#expect(...)` to check expected conditions. + } + +} diff --git a/scarf/scarfUITests/scarfUITests.swift b/scarf/scarfUITests/scarfUITests.swift new file mode 100644 index 0000000..500e06e --- /dev/null +++ b/scarf/scarfUITests/scarfUITests.swift @@ -0,0 +1,41 @@ +// +// scarfUITests.swift +// scarfUITests +// +// Created by Alan Wizemann on 3/31/26. +// + +import XCTest + +final class scarfUITests: XCTestCase { + + override func setUpWithError() throws { + // Put setup code here. This method is called before the invocation of each test method in the class. + + // In UI tests it is usually best to stop immediately when a failure occurs. + continueAfterFailure = false + + // In UI tests it’s important to set the initial state - such as interface orientation - required for your tests before they run. The setUp method is a good place to do this. + } + + override func tearDownWithError() throws { + // Put teardown code here. This method is called after the invocation of each test method in the class. + } + + @MainActor + func testExample() throws { + // UI tests must launch the application that they test. + let app = XCUIApplication() + app.launch() + + // Use XCTAssert and related functions to verify your tests produce the correct results. + } + + @MainActor + func testLaunchPerformance() throws { + // This measures how long it takes to launch your application. + measure(metrics: [XCTApplicationLaunchMetric()]) { + XCUIApplication().launch() + } + } +} diff --git a/scarf/scarfUITests/scarfUITestsLaunchTests.swift b/scarf/scarfUITests/scarfUITestsLaunchTests.swift new file mode 100644 index 0000000..96828a2 --- /dev/null +++ b/scarf/scarfUITests/scarfUITestsLaunchTests.swift @@ -0,0 +1,33 @@ +// +// scarfUITestsLaunchTests.swift +// scarfUITests +// +// Created by Alan Wizemann on 3/31/26. +// + +import XCTest + +final class scarfUITestsLaunchTests: XCTestCase { + + override class var runsForEachTargetApplicationUIConfiguration: Bool { + true + } + + override func setUpWithError() throws { + continueAfterFailure = false + } + + @MainActor + func testLaunch() throws { + let app = XCUIApplication() + app.launch() + + // Insert steps here to perform after app launch but before taking a screenshot, + // such as logging into a test account or navigating somewhere in the app + + let attachment = XCTAttachment(screenshot: app.screenshot()) + attachment.name = "Launch Screen" + attachment.lifetime = .keepAlways + add(attachment) + } +} diff --git a/scarf/standards/01-architecture.md b/scarf/standards/01-architecture.md new file mode 100644 index 0000000..d7d566d --- /dev/null +++ b/scarf/standards/01-architecture.md @@ -0,0 +1,232 @@ +# 01 — Architecture Standard + +Applies to: **InControl, ShabuBox, Threader, Modeler** +Swift 6 / SwiftUI / SwiftData / macOS-native + +--- + +## 1. MVVM-F (MVVM-Feature) Pattern + +Every feature is a self-contained module that owns its **Models**, **ViewModels**, and **Views**. + +``` +Features/ + Email/ + Models/ + ViewModels/ + Views/ + Projects/ + Models/ + ViewModels/ + Views/ +``` + +Rules: +- Feature modules never import or reference another feature's ViewModel directly. +- Cross-feature communication goes through **shared services** injected into each feature. +- A feature may depend on `Core/` and `Services/` but never on a sibling feature. + +--- + +## 2. AppCoordinator + +`AppCoordinator` is the **single source of truth** for all navigation state. + +```swift +@Observable +final class AppCoordinator { + var selectedSection: SidebarSection = .inbox + var selectedFileID: PersistentIdentifier? + var isFilePreviewOpen: Bool = false + // ... all navigation-related state lives here +} +``` + +Rules: +- `AppCoordinator` is `@Observable` and injected via `.environment()` at the app root. +- All navigation mutations flow through `AppCoordinator` methods. +- Leaf views **read** coordinator state but never own independent navigation state. +- Never deep-nest `NavigationStack` inside leaf views. One `NavigationStack` (or `NavigationSplitView`) at the top level, driven by the coordinator. + +--- + +## 3. AppState vs AppCoordinator + +These are **separate concerns**. Do not merge them. + +| Concern | Owner | Examples | +|---|---|---| +| Navigation | `AppCoordinator` | `selectedSection`, `selectedFileID`, `isFilePreviewOpen`, modal presentation flags | +| Cross-section shared state | `AppState` | Global search query, loading states, feature flags, user preferences | + +Rules: +- Never duplicate a navigation property in both `AppCoordinator` and `AppState`. +- `AppState` does not drive navigation. `AppCoordinator` does not hold domain data. +- Both are `@Observable` and injected via `.environment()`. + +--- + +## 4. Directory Layout (Single-Platform) + +Standard layout for macOS-only projects (InControl, Threader, Modeler). + +``` +ProjectName/ + App/ — App entry point, AppCoordinator, AppLoadingState + Core/ — Models, Services, Utilities, Schema + Features/ — Per-feature modules (Email/, Projects/, Dashboard/, Settings/) + Services/ — Shared services (Storage, AI, Sync, Network) + Shared/ — Reusable components, Extensions + Resources/ — Assets, Localizations +``` + +Each feature directory mirrors the MVVM-F structure internally: + +``` +Features/ + Email/ + Models/ — Feature-specific data types + ViewModels/ — Feature logic, @Observable classes + Views/ — SwiftUI views +``` + +--- + +## 5. Directory Layout (Multiplatform) + +Used by ShabuBox (macOS + iOS companion). This layout splits platform-specific code into separate targets while sharing models and design tokens through a shared library. + +``` +ProjectName/ + ProjectName/ — macOS app target + App/ + Core/ + Features/ + Services/ + Views/ + ProjectNameMobile/ — iOS companion target + App/ + Features/ + Views/ + Shared/ — Shared library (both targets link this) + Models/ — All SwiftData @Model types + DesignSystem/ — Shared theme tokens + SchemaVersioning.swift + CloudKitSyncHelper.swift + LoggingKit.swift + PathUtilities.swift + ModelContext+SafeSave.swift +``` + +Rules: +- **Models are ALWAYS shared.** Never duplicate a `@Model` type per platform. +- Schema versioning lives in `Shared/` so both targets use the same migration plan. +- Platform-specific services live in their respective target directories. +- The `Shared/` library contains no UI code — only models, utilities, and design tokens. + +--- + +## 6. Service Orchestration + +Use the **Coordinator Pattern** to decouple UI state from background processing. + +```swift +// Services are injected, never accessed as singletons +@Observable +final class FilePipelineService { + private let ocrService: OCRServiceProtocol + private let indexingService: IndexingServiceProtocol + + init(ocrService: OCRServiceProtocol, indexingService: IndexingServiceProtocol) { + self.ocrService = ocrService + self.indexingService = indexingService + } +} +``` + +Rules: +- Services are **injected** via initializer or environment — never accessed as global singletons. +- Background processing (indexing, OCR, sync, ingestion) is orchestrated by dedicated service classes, not by ViewModels. +- ViewModels call service methods; they do not manage background task lifecycles directly. +- Long-running operations report progress through `AppLoadingState` in the footer. + +--- + +## 7. Protocol-Driven Design + +All engines and services expose **protocol interfaces**, not concrete types. + +```swift +protocol TextGenerating: Sendable { + func generate(prompt: String) async throws -> String +} + +protocol GenerationEngine: Sendable { + func run(config: GenerationConfig) async throws -> GenerationResult +} +``` + +Rules: +- Define a protocol for every service boundary (storage, AI, network, sync). +- ViewModels and orchestrators depend on protocols, not concrete implementations. +- Test suites swap real services for **protocol-conforming mocks**. +- This enables backend swappability (e.g., local LLM vs hosted LLM behind the same `TextGenerating` protocol). + +--- + +## 8. @Observable Architecture + +Use the `@Observable` macro exclusively. Do **not** use `ObservableObject` / `@Published` / Combine. + +```swift +@Observable +final class AppState { + var searchQuery: String = "" + var isLoading: Bool = false +} + +// Injection at app root +@main struct MyApp: App { + @State private var appState = AppState() + @State private var coordinator = AppCoordinator() + + var body: some Scene { + WindowGroup { + ContentView() + .environment(appState) + .environment(coordinator) + } + } +} +``` + +Rules: +- Root state objects (`AppState`, `AppCoordinator`) are injected via `.environment()`. +- Domain services are `@Observable` classes or actors. +- Views read state directly from environment objects — no bindings to published properties. +- All mutations go through service or coordinator methods, not direct property writes from views. +- No Combine. Use `@Observable` + `async/await` for reactive patterns. + +--- + +## 9. Navigation Rules + +All navigation is driven by `AppCoordinator`. + +```swift +// Three-column layout (standard for these apps) +NavigationSplitView { + Sidebar(coordinator: coordinator) +} content: { + ContentList(coordinator: coordinator) +} detail: { + DetailView(coordinator: coordinator) +} +``` + +Rules: +- Use `NavigationSplitView` for three-column layouts (sidebar / content / detail). +- **One** `NavigationStack` or `NavigationSplitView` at the top level. Never nest additional stacks inside feature views. +- Modal flows (sheets, alerts, inspectors) are presented via boolean flags on `AppCoordinator`. +- Deep linking and programmatic navigation go through coordinator methods. +- Use `.navigationDestination(for:)` driven by coordinator state for push-based navigation within a column. diff --git a/scarf/standards/02-swiftdata.md b/scarf/standards/02-swiftdata.md new file mode 100644 index 0000000..0e00d8b --- /dev/null +++ b/scarf/standards/02-swiftdata.md @@ -0,0 +1,328 @@ +# SwiftData Standard + +Applies to: InControl, ShabuBox, Threader, Modeler + +--- + +## 1. Schema Versioning + +Every SwiftData model change goes through `VersionedSchema` + `SchemaMigrationPlan`. No exceptions. + +### Rules + +| Rule | Detail | +|------|--------| +| Always version | Every model or stored-property addition, removal, or rename requires a new `VersionedSchema` enum and a corresponding `MigrationStage` in the migration plan. | +| Never modify existing versions | Once a `VersionedSchema` is shipped, treat it as immutable. Create a new version instead. | +| List ALL active models | Each `VersionedSchema.models` array must contain every model that should exist after that version. Omitting a model drops its table on migration. | +| No unversioned schemas | Never pass `Schema([...])` directly to `ModelContainer`. Always use `Schema(versionedSchema:)` with `migrationPlan:`. | + +### ModelContainerFactory + +```swift +// Correct +let schema = Schema(versionedSchema: AppSchemaV3.self) +let config = ModelConfiguration(schema: schema) +let container = try ModelContainer( + for: schema, + migrationPlan: AppMigrationPlan.self, + configurations: [config] +) + +// Wrong -- unversioned, no migration plan +let container = try ModelContainer(for: MyModel.self) +``` + +### Migration Stages + +Use **lightweight** stages for structural changes (additions, removals, renames). These are the only stages compatible with CloudKit `.automatic` sync. + +```swift +// Lightweight -- safe for CloudKit .automatic +.lightweight(fromVersion: AppSchemaV1.self, toVersion: AppSchemaV2.self) +``` + +Use **custom** stages only when data must be transformed (splitting a field, computing a derived value). Custom stages require CloudKit sync mode `.none` -- coordinate with the sync layer before adding one. + +```swift +// Custom -- requires CloudKit .none +.custom( + fromVersion: AppSchemaV2.self, + toVersion: AppSchemaV3.self, + willMigrate: { context in + // transform data here + try context.save() + }, + didMigrate: nil +) +``` + +### Decision Table + +| Change type | Stage | CloudKit compatible | +|-------------|-------|---------------------| +| Add model | lightweight | Yes | +| Add optional property | lightweight | Yes | +| Remove property | lightweight | Yes | +| Rename property (`@Attribute(originalName:)`) | lightweight | Yes | +| Split field into two | custom | No -- use `.none` sync | +| Backfill computed values | custom | No -- use `.none` sync | + +--- + +## 2. Skeleton-First Pattern + +Create the record with `status = .processing` immediately so the UI shows it right away. Fill in real data as background processing completes. + +```swift +// 1. Insert skeleton record -- instant UI feedback +let record = MyRecord(name: placeholder, status: .processing) +modelContext.insert(record) +try modelContext.save() + +// 2. Process in background +let result = await heavyWork() + +// 3. Update the record +record.name = result.name +record.status = .ready +try modelContext.save() +``` + +The user sees the record appear in the list immediately. As processing finishes, the record fills in and the status indicator clears. + +--- + +## 3. Indexing + +Add `#Index` on fields that appear in predicates, sort descriptors, or frequent lookups. + +```swift +@Model +final class Task { + var title: String + var dueDate: Date? + var status: String + var projectID: UUID? + var updatedAt: Date + // ... +} + +// Declare indexes for query performance +extension Task { + static let indexes: [[IndexColumn]] = [ + [\.dueDate], + [\.projectID], + [\.status], + [\.updatedAt] + ] +} +``` + +### Minimum Indexes by Domain + +| Entity | Indexed fields | +|--------|---------------| +| Task | dueDate, projectID, columnID, status, updatedAt | +| Expense | date, projectID, categoryID, status | +| Opportunity | stageID, targetCloseDate, updatedAt | +| ExternalLink | provider, externalID, (entityType + entityID) | + +--- + +## 4. Query Patterns + +### Use DataStoreActor for Background Queries + +All views should use background actor queries. Do not use `@Query` or synchronous `DataStore` calls for production data access. + +```swift +// Proven pattern: background actor query + main-thread model resolution +let (ids, count) = try await dataStoreActor.fetchItemsWithCount(page: page, limit: limit) + +await MainActor.run { + loadedItems = modelContext.items(from: ids) +} +``` + +### Prefer Database-Level Filtering + +Use `#Predicate` for filtering whenever possible. In-memory filtering fetches all matching rows into memory and becomes a scalability problem at 10k+ records. + +```swift +// Good -- database-level filtering +let predicate = #Predicate { $0.status == "active" && $0.projectID == targetID } +let descriptor = FetchDescriptor(predicate: predicate) +let results = try modelContext.fetch(descriptor) + +// Bad -- fetches everything, filters in Swift +let all = try modelContext.fetch(FetchDescriptor()) +let filtered = all.filter { $0.status == "active" } +``` + +If in-memory filtering is unavoidable (e.g., complex path-prefix matching), add a `fetchLimit` cap and document why database-level filtering was not possible. + +```swift +// Acceptable only with justification and cap +var descriptor = FetchDescriptor(predicate: basePredicate) +descriptor.fetchLimit = 500 // Cap to prevent unbounded memory +let items = try modelContext.fetch(descriptor) +// In-memory filter required: destinationPath prefix matching not supported by #Predicate +let filtered = items.filter { $0.destinationPath.hasPrefix(targetPath) } +``` + +--- + +## 5. Safe Fetch + +Never use bare `try?` on `modelContext.fetch()`. Use the safe wrappers that log failures. + +```swift +// Good -- logs the error, returns empty array on failure +let items = dataStore.safeFetch(descriptor, operation: "loading active tasks") + +// Good -- logs the error, returns 0 on failure +let count = dataStore.safeFetchCount(descriptor, operation: "counting inbox items") + +// Bad -- silently swallows fetch errors +let items = try? modelContext.fetch(descriptor) +``` + +`safeFetch` and `safeFetchCount` wrap the call in `do/try/catch`, log with `logger.error()` including the operation name, and return a safe default (empty array or zero). + +--- + +## 6. Data Modeling Conventions + +### Primary Keys + +All entities use `UUID` primary keys. + +```swift +@Attribute(.unique) var id: UUID = UUID() +``` + +### Timestamps + +Every entity carries automatic timestamps. + +```swift +var createdAt: Date = Date() +var updatedAt: Date = Date() +``` + +Update `updatedAt` on every mutation. + +### Soft Delete + +Prefer archiving over hard deletion for auditability and sync safety. + +```swift +var isArchived: Bool = false +var archivedAt: Date? +``` + +### Money + +Store monetary values as `Int64` minor units (cents) plus an ISO 4217 currency code. Never use floating-point for money. + +```swift +var amountMinor: Int64 // e.g., 1999 = $19.99 +var currencyCode: String // e.g., "USD" +``` + +### Kanban Ordering + +Use `sortIndex: Double` for position within a column. Doubles allow cheap insertion between two items without rewriting the entire list. + +```swift +var sortIndex: Double + +// Insert between items at 1.0 and 2.0 +newItem.sortIndex = 1.5 +``` + +### Many-to-Many Relationships + +Use explicit join tables instead of native SwiftData many-to-many. This is CloudKit-friendly and supports auditing. + +```swift +@Model +final class EntityTag { + @Attribute(.unique) var id: UUID = UUID() + var entityType: String + var entityID: UUID + var tagID: UUID + var createdAt: Date = Date() +} +``` + +--- + +## 7. Actor Safety + +Isolate SwiftData models within the appropriate actor to prevent data races. + +### Logger in @Model Classes + +`@Model` classes are actor-isolated, but `Logger` is not `Sendable`. Declare the logger at file scope with `nonisolated(unsafe)` to avoid concurrency warnings. + +```swift +private nonisolated(unsafe) let logger = Logger( + subsystem: "com.yourapp", + category: "MyModel" +) + +@Model +final class MyModel { + // Use `logger` freely inside the class +} +``` + +For structs (including SwiftUI views), use `private static let logger` instead. + +--- + +## 8. Error Handling in Models + +### Encode / Decode + +Never use `try?` on encode or decode operations. A silent failure here means corrupted or lost data. + +```swift +// Good +do { + let data = try JSONEncoder().encode(value) + self.storedData = data +} catch { + logger.error("Failed to encode value: \(error)") + self.storedData = Data() // explicit fallback +} + +// Bad -- silently drops data +self.storedData = try? JSONEncoder().encode(value) +``` + +### modelContext.save() + +Always wrap saves in `do/try/catch`. Save failures indicate data corruption or constraint violations -- they must never be silently ignored. + +```swift +do { + try modelContext.save() +} catch { + logger.error("Save failed: \(error)") +} +``` + +### When bare try? Is Acceptable + +Only for truly ignorable, idempotent operations. Always include a comment. + +```swift +// Ignorable: removing file before overwrite +try? FileManager.default.removeItem(at: tempURL) + +// Ignorable: sleep cancellation +try? await Task.sleep(for: .seconds(1)) +``` diff --git a/scarf/standards/03-storage-and-sandboxing.md b/scarf/standards/03-storage-and-sandboxing.md new file mode 100644 index 0000000..594c077 --- /dev/null +++ b/scarf/standards/03-storage-and-sandboxing.md @@ -0,0 +1,147 @@ +# 03 — Storage & Sandboxing + +Standards for iCloud storage, file operations, sandboxing, and path security across all macOS apps. + +--- + +## 1. iCloud-First Strategy + +All user data lives in the Ubiquity Container (`Documents/`). This provides zero-config cross-device sync via CloudKit. + +- Use `FileManager.default.url(forUbiquityContainerIdentifier:)` to discover the container +- Store user-created content in `Documents/` within the container +- Monitor `NSMetadataQuery` for `ubiquitousItemHasUnresolvedConflictsKey` to detect sync conflicts +- For apps without iCloud (e.g., hardware-specific apps), store in Application Support + +--- + +## 2. NSFileCoordinator Mandate + +**ALL file operations within the iCloud library root must use NSFileCoordinator.** Direct `FileManager.default` calls are prohibited. + +| Operation | Required API | +|-----------|-------------| +| Read file | `FileCoordinatorService.shared.coordinatedRead()` | +| Write file | `FileCoordinatorService.shared.coordinatedWrite()` | +| Move file | `FileCoordinatorService.shared.coordinatedMove()` (locks both source + destination) | +| Delete file | `SafeFileOperations.shared.coordinatedDelete()` | +| Async file ops | `AsyncFileManager.shared.*` (routes through FileCoordinatorService) | +| Create directory | `AsyncFileManager.shared.createDirectory()` | +| List directory | `AsyncFileManager.shared.contentsOfDirectory()` | + +### Allowed FileManager.default Exceptions + +Direct `FileManager.default` is acceptable ONLY for: + +- **`.url(forUbiquityContainerIdentifier:)`** — iCloud container discovery +- **`.temporaryDirectory`** — Temp path access (no coordination needed) +- **App bundle resources** — Read-only checks outside iCloud +- **Pre-container-init checks** — Migration coordinators before container is available + +### Async Wrappers + +Always prefer async wrappers over synchronous coordination: + +```swift +// Use these: +FileCoordinatorService.coordinatedReadAsync() +FileCoordinatorService.coordinatedWriteAsync() +FileCoordinatorService.coordinatedDelete() +FileCoordinatorService.coordinatedCreateDirectory() +``` + +--- + +## 3. TOCTOU Prevention + +Never use `fileExists` before an operation that would fail gracefully without it. + +| Anti-Pattern | Correct Pattern | +|---|---| +| `if fileExists { removeItem }` | `try? removeItem` (ignore ENOENT) | +| `if fileExists { moveItem }` | `try moveItem` (handle error in catch) | +| `if !fileExists { createDirectory }` | `createDirectory(withIntermediateDirectories: true)` | +| `if fileExists { read }` | `try read` (handle ENOENT in catch) | + +### Valid Uses of `fileExists` + +Where the check IS the operation (no subsequent file op): + +- **User-facing warnings** — "File missing" display-only messages +- **Migration gates** — "Does old database exist?" (read-only decision) +- **Cache hit checks** — Thumbnail cache optimization where race is harmless + +--- + +## 4. Safe Deletion Paths + +Coordinated delete operations should only allow deletion within controlled directories: + +- `/tmp/` +- `/Caches/` +- `/_Duplicate` +- `/_Deleted` +- `/_InBox` +- `/.bento/` (or app-specific internal directory) + +Deletion outside these paths requires explicit user confirmation. + +--- + +## 5. Conflict Resolution + +- Monitor `NSMetadataQuery` for `ubiquitousItemHasUnresolvedConflictsKey` +- No silent last-writer-wins — merge deterministically or queue for user resolution +- Implement conflict resolution UI for user-facing data conflicts +- See `08-data-integrity.md` for CloudKit sync safety guards + +--- + +## 6. Sandboxing + +### Security-Scoped Bookmarks + +- All apps are sandboxed for App Store distribution +- Use security-scoped bookmarks for persistent access to user-selected folders outside the container +- Always call `startAccessingSecurityScopedResource()` / `stopAccessingSecurityScopedResource()` in balanced pairs + +### Inbox Pattern + +- New documents enter through a centralized `_InBox/` directory for review and classification +- System folder names use singular with underscore prefix: `_InBox`, `_Duplicate`, `_Deleted` +- Never use plural forms (`_Deleted` not `_Deleteds`) + +--- + +## 7. Path Security + +### Filename Sanitization + +- Sanitize ALL filenames from external sources via `PathUtilities.sanitizeFilename()` / `sanitizePath()` / `resolveURL(relativePath:rootURL:)` +- Never trust filenames from: file imports, drag-and-drop, network responses, user input +- Prevent path traversal (`../`) attacks + +### LLM Prompt Injection Defense + +- All document text must pass through `sanitizeForPrompt()` before embedding in LLM prompts (RAG chunks, summaries, chat context) +- When adding new LLM provider support, add that provider's format markers to `sanitizeForPrompt()` (special tokens like ``, ``, bracket delimiters like `[SYSTEM]`) +- Test sanitization against the provider's known injection vectors + +--- + +## 8. Performance Notes + +- **`autoreleasepool`** is required in any loop processing images, thumbnails, or PDF pages (synchronous only — no `await` inside the autoreleasepool block) +- **Single `resourceValues(forKeys:)`** — Never call `fileExists` + `attributesOfItem` separately; use one `resourceValues(forKeys:)` call +- **Cap PDF rendering** at 4096px maximum dimension +- **Close both Pipe file handles** after subprocess communication +- **Check `Task.isCancelled`** at the top of every iteration in long file-processing loops + +--- + +## 9. URL Storage + +When storing URLs via `@AppStorage`, remember: +- `URL.absoluteString` includes the `file://` scheme +- Reconstruct with `URL(string:)`, **never** `URL(fileURLWithPath:)` (which would double the scheme) +- Document this pattern wherever URL storage is used diff --git a/scarf/standards/04-swift-conventions.md b/scarf/standards/04-swift-conventions.md new file mode 100644 index 0000000..47d9e82 --- /dev/null +++ b/scarf/standards/04-swift-conventions.md @@ -0,0 +1,150 @@ +# 04 — Swift Conventions + +Centralized Swift standards for InControl, ShabuBox, Threader, and Modeler. +All four apps target macOS, use Swift 6, SwiftUI, and SwiftData. + +--- + +## 1. Swift 6 Concurrency Rules + +| Rule | Details | +|------|---------| +| Shared mutable state | Must be `@MainActor` or actor-isolated. No unprotected shared vars. | +| `@Sendable` closures | All closures in `Task`, `Task.detached`, and `withCheckedThrowingContinuation` must be `@Sendable`. | +| async/await | Prefer over callbacks and closures in all new code. | +| Progress reporting | Use `AsyncThrowingStream`, not callback-based progress handlers. | +| DispatchQueue | Never use when Swift Concurrency works. No `DispatchQueue.main.async` -- use `@MainActor` instead. | +| File I/O on @MainActor | Prohibited. Dispatch via `Task.detached { }.value` or an async file manager. | +| Boolean flags | Use `os_unfair_lock` for thread-safe boolean flags (not `NSLock`). | +| Logger in @Model classes | `private nonisolated(unsafe) let logger = Logger(...)` at file scope. Required because `@Model` classes are not Sendable. | + +--- + +## 2. Logging Standard + +**No `print()` in production code.** Use `os.Logger` exclusively. +`print()` is only acceptable in `#Preview` blocks and test helpers. + +### Subsystem and Category + +- **Subsystem**: `"com..app"` -- always a static string literal. Never use `Bundle.main.bundleIdentifier ?? "..."` or any dynamic expression. +- **Category**: The type name (e.g., `"EmailSyncService"`, `"ConversationView"`). + +### Declaration Patterns + +| Context | Declaration | Access | +|---------|-------------|--------| +| Class or actor | `private let logger = Logger(subsystem: "com..app", category: "ClassName")` | `logger` | +| Struct or SwiftUI view | `private static let logger = Logger(subsystem: "com..app", category: "StructName")` | `Self.logger` | +| Nested struct (e.g., sheet inside a view) | Declares its own `private static let logger`. Cannot reference the parent's `Self.logger`. | `Self.logger` | +| `@Model` class | `private nonisolated(unsafe) let logger = Logger(subsystem: "com..app", category: "ModelName")` | `logger` | + +### Enum Interpolation + +`os.Logger` string interpolation requires types conforming to specific protocols. For enums and other non-conforming types: + +```swift +logger.info("State changed to \(String(describing: newState))") +``` + +### Log Levels + +| Level | Use | +|-------|-----| +| `.info` | Normal operational flow | +| `.warning` | Expected failures (file not found, timeout, network unavailable) | +| `.error` | Unexpected failures (encoding bugs, logic errors, constraint violations) | +| `.debug` | Verbose or sensitive output (only visible in debug builds) | + +### Never Log + +- Tokens or credentials +- Full API response bodies +- Log status codes and error types only + +--- + +## 3. Error Handling + +### Catch Blocks + +Every `catch` must do at least one of: + +1. Log with `logger.error()` or `logger.warning()` +2. Re-throw +3. Return `Result.failure` + +**No empty catch blocks.** Ever. + +### modelContext.save() + +Always wrap in explicit error handling: + +```swift +do { + try modelContext.save() +} catch { + logger.error("Failed to save context: \(error)") +} +``` + +Never use bare `try? modelContext.save()` -- save failures indicate data corruption or constraint violations. + +### Bare try? + +Acceptable only for truly ignorable operations: + +- `try? await Task.sleep(...)` +- `try? FileManager.default.removeItem(...)` before an overwrite +- Other idempotent operations + +**Always add a comment explaining why the error is ignorable.** + +### Multi-Step Operations + +Any operation with 3+ sequential steps that modify state (migration, batch import, sync) must: + +- Implement rollback, **or** +- Be idempotent + +Verification after the operation must throw on failure (not just log) so the caller can roll back. + +--- + +## 4. File Size Limits + +| File type | Max lines | Action when approaching limit | +|-----------|-----------|-------------------------------| +| Services | ~1,000 | Extract helper types | +| Views | ~800 | Extract sub-views into separate files; move single-use `@State` into the sub-view | + +### Extraction Pattern + +Prefer `@MainActor enum HelperName` with static methods for stateless extraction: + +```swift +@MainActor +enum EmailMessageStorageHelper { + static func store(_ message: Message, in context: ModelContext) throws { + // ... + } +} +``` + +--- + +## 5. Anti-Patterns -- What NOT to Do + +| Anti-Pattern | Correct Approach | +|-------------|-----------------| +| Force unwrapping (`!`) | Use `guard let`, `if let`, or nil-coalescing. Exception: IBOutlets (none in SwiftUI). | +| `DispatchQueue.main.async` | Use `@MainActor` | +| Combine (`ObservableObject` / `@Published`) | Use `@Observable` macro + async/await in all new code | +| UIKit types | macOS uses AppKit: `NSImage`, `NSWorkspace`, etc. | +| Hardcoded paths | Derive from configuration or `AppState` directories | +| Synchronous file I/O on main thread | Dispatch to background via `Task.detached` or async file manager | +| `print()` in production | Use `os.Logger` | +| Bare `try?` on important operations | Use `do/try/catch` with logging. Bare `try?` only for ignorable ops (with comment). | +| `@AppStorage` with `URL.absoluteString` | Document that reconstruction requires `URL(string:)`, **not** `URL(fileURLWithPath:)`, because the stored value includes the `file://` scheme. | +| `Date()` allocations in hot paths | Use `os_signpost` or gate behind `#if DEBUG`. Do not add `Date()` timing to actor methods without a debug guard. | +| `ObservableObject` / `@Published` in new code | Use `@Observable` macro exclusively | diff --git a/scarf/standards/05-design-system.md b/scarf/standards/05-design-system.md new file mode 100644 index 0000000..88d01fe --- /dev/null +++ b/scarf/standards/05-design-system.md @@ -0,0 +1,508 @@ +# 05 -- Design System + +Centralized visual and interaction standard for all macOS apps in this suite. + +Each project keeps its own concrete theme file (e.g., `ShabuBoxTheme.swift`, +`DSTokens.swift`). This document defines the structure and rules those themes +must follow. + +**Aesthetic target:** Things-like + Liquid Glass. Content is stable and legible; +glass is chrome and framing only. + +--- + +## 1. Core Principles + +1. **Content-first.** Glass is reserved for chrome and floating UI. Primary reading and editing surfaces remain stable and high-contrast. +2. **Calm density.** Use whitespace and grouping; avoid excessive dividers and "always visible" buttons. +3. **Keyboard-first.** Every primary action must be accessible via keyboard and reflected in menus with shortcuts. +4. **One coherent material model.** Use a small set of material tiers (see Section 3), consistently applied. +5. **Accessibility always.** The UI must remain usable with Reduce Transparency, Increase Contrast, Reduce Motion, and Large Dynamic Type. +6. **Native components by default.** Use system controls (Buttons, Lists, Toolbars, Forms) unless a custom control is materially better. + +--- + +## 2. Aesthetic + +The visual language blends the calm, content-first productivity feel of +**Things** (Cultured Code) with the modern navigation chrome and depth of +Apple's **Liquid Glass**. + +**North star:** content is stable and legible; glass is used as chrome and +framing, never as the primary reading surface. + +- Prefer quiet backgrounds and generous whitespace over ornament. +- Depth comes from the material tiers below, not from decorative gradients. +- Accent color is used sparingly to convey meaning, not for decoration. + +--- + +## 3. Material Tiers + +Use only these three tiers across the app. + +### Tier A -- Chrome Glass (default) + +| Where | Sidebar background, toolbar background, tab bars, navigation chrome | +|-------|---------------------------------------------------------------------| +| Character | Subtle translucency, subtle border stroke, minimal shadow | + +### Tier B -- Floating Glass + +| Where | Popovers, menus, pickers, floating inspector panels, tooltips | +|-------|---------------------------------------------------------------| +| Character | Stronger separation than Tier A (stroke + shadow). Content remains legible over any wallpaper. | + +### Tier C -- Content Plate (stabilizer) + +| Where | Text fields, long lists, forms, editors, notes | +|-------|------------------------------------------------| +| Character | More opaque background, clear edge separation. Keeps text crisp. | + +Used *inside* glass containers whenever content is multi-line, form inputs, or +long lists. + +### Hard Constraints + +- **Never place long-form reading surfaces directly on Tier A or Tier B glass.** +- Always wrap dense text fields and lists in a Tier C plate. +- Respect Reduce Transparency: when enabled, glass becomes opaque with standard + system background colors. Do not fight the system. + +--- + +## 4. Typography + +Use Apple semantic text styles instead of hard-coded sizes. + +### Semantic Styles + +| Style | Typical Size / Weight | Usage | +|-------|----------------------|-------| +| `.largeTitle` | 28pt Bold | Screen titles (rare) | +| `.title` | 22pt Semibold | Section headers | +| `.title2` | 20pt Semibold | Sub-section headers | +| `.title3` | 18pt Semibold | Card titles | +| `.headline` | 17pt Semibold | Emphasized body | +| `.body` | 15pt Regular | Primary content | +| `.callout` | 14pt Regular | Supporting text | +| `.subheadline` | 13pt Regular | Secondary labels | +| `.footnote` | 12pt Regular | Metadata | +| `.caption` | 11pt Regular | Timestamps, hints | +| `.caption` (bold) | 11pt Semibold | Badge text | + +### Rules + +- Max **one** primary headline per view. +- Secondary text uses `.foregroundStyle(.secondary)`. +- Max **3 text styles** per view unless it is a dense inspector. +- If a design requires a size not in the table, add a named token to the + project theme file first. Never use `.font(.system(size: N))` with an + unlisted size. + +### Token Example (project theme file) + +```swift +enum Typography { + static let largeTitle = Font.system(.largeTitle, weight: .bold) + static let title = Font.system(.title, weight: .semibold) + static let headline = Font.system(.headline, weight: .semibold) + static let body = Font.system(.body) + static let subheadline = Font.system(.subheadline) + static let footnote = Font.system(.footnote) + static let caption = Font.system(.caption) + static let captionBold = Font.system(.caption, weight: .semibold) + static let badge = Font.system(size: 10, weight: .bold) + static let sectionHeader = Font.system(.caption, weight: .semibold) + .smallCaps() +} +``` + +--- + +## 5. Iconography + +- Use **SF Symbols exclusively**. +- Consistent symbol per meaning -- do not rotate icons for novelty. +- Use **filled** variants for selected states; regular (outline) for default. +- Icon-only buttons **must** have a tooltip and an accessibility label. + +--- + +## 6. Spacing + +All spacing is on a **4pt grid**. This is more flexible than a strict 8pt grid +while still maintaining alignment. + +### Token Table + +| Token | Value | +|-------|-------| +| `xxs` | 4 pt | +| `xs` | 8 pt | +| `sm` | 12 pt | +| `md` | 16 pt | +| `lg` | 20 pt | +| `xl` | 24 pt | +| `xxl` | 32 pt | +| `xxxl` | 40 pt | +| `xxxxl` | 48 pt | + +### Token Example + +```swift +enum Spacing { + static let xxs: CGFloat = 4 + static let xs: CGFloat = 8 + static let sm: CGFloat = 12 + static let md: CGFloat = 16 + static let lg: CGFloat = 20 + static let xl: CGFloat = 24 + static let xxl: CGFloat = 32 + static let xxxl: CGFloat = 40 + static let xxxxl: CGFloat = 48 +} +``` + +--- + +## 7. Corner Radii + +### Token Table + +| Token | Value | Usage | +|-------|-------|-------| +| `xs` | 4 pt | Small badges, pills | +| `sm` | 8 pt | Controls, buttons | +| `md` | 12 pt | Cards, input fields | +| `lg` | 16 pt | Panels, sheets | +| `xl` | 20 pt | Large floating panels | +| `full` | 999 pt | Circular / capsule | + +### Guideline + +- **Controls:** 8--12 pt (`sm` to `md`). +- **Panels / cards:** 12--16 pt (`md` to `lg`). +- If a design requires a radius not in the table, add the token to the project + theme first. Do not use inline `.clipShape(RoundedRectangle(cornerRadius: N))` + with non-token values. + +--- + +## 8. Color + +### Approach + +- Prefer **system semantic colors** (`primary`, `secondary`, `tertiary`, + `separator`, `windowBackground`, `textBackground`, etc.) for automatic + light/dark and accessibility support. +- The accent tint conveys meaning; avoid decorative tint floods. +- Avoid large tinted glass areas behind dense text. + +### Semantic Color Categories + +Each project theme should define tokens in these categories: + +| Category | Example Tokens | +|----------|---------------| +| Background | `primaryBackground`, `secondaryBackground`, `tertiaryBackground`, `sidebarBackground` | +| Text | `textPrimary`, `textSecondary`, `textTertiary` | +| Accent | `accent`, project-specific accent variants | +| Status | `success`, `warning`, `error`, `info` | +| File-type (if applicable) | `fileDocument`, `fileImage`, `fileVideo`, `fileAudio`, `fileArchive`, `fileOther` | + +### Rules + +- No custom colors unless branding requires it. Keep branding to `tint` and + small accents. +- All custom colors must have both light and dark variants defined in an asset + catalog or via `Color(light:dark:)`. + +--- + +## 9. Shadows + +### Token Table + +| Token | Radius | Opacity | Usage | +|-------|--------|---------|-------| +| `subtle` | 4 pt | 0.04 | Default card resting state | +| `medium` | 8 pt | 0.08 | Elevated elements | +| `elevated` | 16 pt | 0.12 | Floating panels | +| `hover` | 12 pt | 0.15 | Hover state lift | + +### Token Example + +```swift +enum Shadows { + static let subtle = (radius: CGFloat(4), opacity: 0.04) + static let medium = (radius: CGFloat(8), opacity: 0.08) + static let elevated = (radius: CGFloat(16), opacity: 0.12) + static let hover = (radius: CGFloat(12), opacity: 0.15) +} +``` + +--- + +## 10. Animations & Transitions + +### Animation Tokens + +| Token | Definition | Usage | +|-------|-----------|-------| +| `quick` | `easeOut(duration: 0.15)` | Hover, focus, toggle | +| `standard` | `spring(response: 0.3, dampingFraction: 0.8)` | Selection, card interaction | +| `smooth` | `spring(response: 0.4, dampingFraction: 0.85)` | Panel resize, expand/collapse | +| `slow` | `spring(response: 0.5, dampingFraction: 0.9)` | View switches, overlays | +| `interactive` | `spring(response: 0.3, dampingFraction: 0.7)` | User-driven toggle/tap | +| `appear` | `easeOut(duration: 0.2)` | Content fade-in | + +### Replacement Mapping + +When reviewing existing code, replace ad-hoc values: + +| Ad-hoc Value | Replace With | +|---|---| +| `.easeInOut(duration: 0.15)` | `Animation.quick` | +| `.easeOut(duration: 0.2)` | `Animation.appear` | +| `.spring(response: 0.3)` | `Animation.standard` | +| `.spring(response: 0.3, dampingFraction: 0.7)` | `Animation.interactive` | +| `.spring(response: 0.4, dampingFraction: 0.85)` | `Animation.smooth` | +| Long-running repeating anims (2s+) | Keep inline | + +### Transition Tokens + +| Token | Effect | Usage | +|-------|--------|-------| +| `slideTrailing` | Move from trailing edge | Right panels, slide-in detail | +| `slideLeading` | Move from leading edge | Left panels | +| `slideBottom` | Move from bottom | Toasts, bottom sheets | +| `scaleUp` | Scale up + fade | Modals | +| `scaleSmall` | Small scale pulse | Loading indicators | +| `fade` | Opacity crossfade | Section switching | + +--- + +## 11. View Modifiers + +Each project theme should provide these standard view modifiers (names may vary +by project): + +| Modifier | Purpose | +|----------|---------| +| `.glassCard(radius:shadow:)` | Frosted glass container with `.ultraThinMaterial` | +| `.solidCard(radius:shadow:backgroundColor:)` | Opaque card with secondary background | +| `.hoverScale(scale:hoverShadow:restShadow:)` | Scale ~1.03 + shadow lift on hover | +| `.themeBadge(color:style:)` | Capsule badge (`.filled` or `.tinted`) | +| `.sidebarItem(isSelected:accentColor:)` | Left accent bar + selection background | +| `.shimmer()` | Loading skeleton animation | +| `.themeShadow(_:)` | Apply a shadow token | +| `.cardPress(scale:)` | Press-down micro-interaction | +| `.sectionTransition(id:)` | Fade on content ID change | + +--- + +## 12. Layout System + +### Primary Window Template (3-column) + +Use `NavigationSplitView` for the primary structure: + +``` ++----------+---------------------+-----------+ +| Sidebar | Content List | Inspector | +| (nav) | (work items) | (detail) | ++----------+---------------------+-----------+ +``` + +1. **Sidebar:** Perspectives and containers (areas / projects / sections). +2. **Content list:** Items within the current scope. +3. **Inspector / detail:** Editable properties, notes, metadata. + +### Sidebar Structure + +- Short labels (1--2 words). +- Optional count badges. +- Clear selection highlight and hierarchy indentation. +- Settings is **not** a sidebar item -- access via `Cmd+,` (native Settings scene). + +### Slide-In Detail Pattern + +For module views, use a single-pane layout where the detail view animates in +from the trailing edge: + +``` ++----------+----------------------------+ +| Sidebar | Module Content | +| | +------------------------+ | +| | | Detail (slides in) | | +| | +------------------------+ | ++----------+----------------------------+ +``` + +- Width: 400--500 pt (configurable). +- Background: Tier B (Floating Glass) material. +- Dismiss via close button or clicking outside. +- Animation: `spring(response: 0.3, dampingFraction: 0.8)` with + `move(edge: .trailing)` + opacity. + +### Layout Constants + +| Constant | Value | +|----------|-------| +| Sidebar expanded | ~200 pt | +| Sidebar collapsed | ~56 pt | +| Sidebar item height | 36 pt | +| Right info panel | 280 pt (240 pt min) | +| Grid cells | 160--200 pt, aspect ratio ~1.29 | +| Grid spacing | 16 pt | +| List row height | 52 pt | +| List row icon size | 32 pt | +| Toolbar height | 36 pt | + +--- + +## 13. Components + +### Buttons + +Prefer system button styles: + +| Style | Usage | +|-------|-------| +| `.borderedProminent` | Primary action (0--1 per view) | +| `.bordered` | Secondary actions | +| `.plain` | Tertiary / inline | +| Toolbar icon buttons | Icon-only with tooltip + accessibility label | + +**Rules:** +- One primary action per view. +- Do not place prominent buttons on glass without a Tier C plate behind them. + +### Tab Bar + +- Use for view-mode switching (e.g., Board / List), not for status filters. +- Icon + text for each tab. +- Selected state: accent color with ~15% opacity background. +- Unselected state: `.secondary` foreground. +- Tab enums should conform to a `TabItem`-style protocol (label + icon). + +### Text Fields & Editors + +- Search is global and always reachable (toolbar / search field). +- Inline title editing: commit on Return, cancel on Escape. +- Notes editor lives on a Tier C plate even if surrounding UI is glass. + +### Lists + +**Row anatomy:** +- Leading: status control (checkbox / icon). +- Center: title (`.body`), optional secondary line (`.secondary`). +- Trailing: metadata (date / badges), de-emphasized. + +**Interactions:** +- Hover reveals secondary actions (overflow menu, quick actions) -- primary + info never jumps. +- Drag-and-drop reorder where applicable. +- Selection state must be obvious under Increase Contrast. + +### Inspector + +A calm, form-like panel. Recommended sections: +- Title + status +- Schedule / dates +- Tags / people +- Notes +- Attachments / links + +Use popovers for quick pickers, sheets for multi-step edits. + +### Modals + +| Type | Usage | +|------|-------| +| Popover | Quick pickers / actions | +| Sheet | Multi-field create / edit flows | +| Alert | Destructive / irreversible confirmation only | + +**Sheet rules:** +- Clear title. +- Cancel on the leading side, primary action on the trailing side. +- Minimal fields per step (avoid mega-sheets). + +### Context Menus + +- Provide context menus for all list rows. +- Menu items mirror keyboard shortcuts. + +--- + +## 14. Rules for New Views + +Before shipping any new view, verify all of the following: + +1. [ ] Import project theme tokens -- never hardcode colors, spacing, fonts, or animation values. +2. [ ] Use `.glassCard()` or `.solidCard()` (or project equivalent) for containers. +3. [ ] Use theme animation tokens for all animations. +4. [ ] Use theme spacing tokens for all padding and gaps. +5. [ ] Use theme typography tokens for all font specs. +6. [ ] Add `.hoverScale()` or equivalent for interactive cards / elements. +7. [ ] Support light and dark mode through semantic colors. +8. [ ] One clear primary action (or none) per view. +9. [ ] Visual hierarchy scannable in under 1 second. +10. [ ] No dense content placed directly on glass -- use Tier C plate. +11. [ ] Secondary actions hidden until hover or context menu. +12. [ ] Keyboard shortcuts for primary actions, discoverable via menu / tooltips. +13. [ ] Works with Reduce Transparency enabled. +14. [ ] When SwiftUI type-checker times out, extract sub-views into `@ViewBuilder` computed properties. + +--- + +## 15. macOS Platform Standards + +All apps in this suite must follow these macOS conventions: + +- **`NavigationSplitView`** for multi-column layouts. +- **SF Symbols** exclusively for icons. +- **Dark Mode** support -- all custom colors must work in both appearances. +- **Dynamic Type** support -- use semantic text styles, never fixed pixel sizes + as the sole font definition. +- **Keyboard navigation** -- shortcuts for all primary actions. Benchmark + depth: Things-level (Go-To shortcuts, search / command palette, context- + sensitive "New item", inspector toggle). +- **`.searchable`** modifier for search integration. +- **`.inspector`** modifier for inspector panels where appropriate. +- **`.confirmationDialog`** for destructive action confirmation. +- **Settings** via SwiftUI `Settings` scene, accessed by `Cmd+,`. Settings is + never a sidebar item. +- Follow Apple HIG for toolbar placement and controls. + +--- + +## 16. Accessibility Requirements + +The UI must work correctly with all of the following enabled: + +| Setting | Requirement | +|---------|-------------| +| **Reduce Transparency** | Glass becomes opaque with system background colors. Do not fight the system. | +| **Increase Contrast** | Selection states, focus rings, and borders must remain clearly visible. | +| **Large Dynamic Type** | Layouts must reflow; no truncation of primary content. | +| **Reduce Motion** | Replace spring animations with simple crossfades or instant transitions. | + +### Additional Requirements + +- **VoiceOver labels** on all interactive elements. +- All visual states (default, hover, pressed, disabled, selected, focus ring) + must be defined for every interactive component. +- All glass surfaces must be tested on busy wallpapers to guarantee legibility. +- When Reduce Transparency is enabled, do not attempt to restore translucency. + +--- + +## References + +- [Apple Liquid Glass Overview](https://developer.apple.com/documentation/TechnologyOverviews/liquid-glass) +- [Apple Human Interface Guidelines](https://developer.apple.com/design/human-interface-guidelines/) +- [SF Symbols](https://developer.apple.com/sf-symbols/) +- [Things by Cultured Code](https://culturedcode.com/things/features/) diff --git a/scarf/standards/06-editor-patterns.md b/scarf/standards/06-editor-patterns.md new file mode 100644 index 0000000..dbe776e --- /dev/null +++ b/scarf/standards/06-editor-patterns.md @@ -0,0 +1,255 @@ +# 06 — Editor Patterns + +Modern Liquid Glass tabbed editor architecture for all add/edit sheets. + +--- + +## Overview + +All add/edit sheets follow a consistent tabbed architecture with: +- Wide canvas (800-900px x 600-700px) +- Tabbed navigation for progressive disclosure +- Smart natural language input prioritized as Tab 0 +- Consistent spacing and visual hierarchy +- Instant tab switching with smooth transitions + +--- + +## Architecture + +### Sheet Presentation + +```swift +.sheet(isPresented: $showEditor) { + NavigationStack { + EditorView(mode: editorMode, onDismiss: { showEditor = false }) + } + .frame(minWidth: 800, idealWidth: 900, minHeight: 600, idealHeight: 700) +} +``` + +- Always wrap in `NavigationStack` for toolbar support +- Use `minWidth/idealWidth` and `minHeight/idealHeight` + +### Tab Structure + +```swift +private let tabs: [DSEditorTab] = [ + DSEditorTab(title: "Quick Entry", icon: "sparkles"), // Tab 0: Always NL input + DSEditorTab(title: "Details", icon: "info.circle"), // Tab 1: Core fields + DSEditorTab(title: "Financial", icon: "dollarsign.circle"), // Tab 2+: Domain-specific + DSEditorTab(title: "Terms", icon: "doc.text") +] + +@State private var selectedTab: Int = 0 +``` + +### Body Structure + +```swift +var body: some View { + VStack(spacing: 0) { + DSEditorTabBar(tabs: tabs, selectedTab: $selectedTab) + Divider() + ZStack { + if selectedTab == 0 { quickEntryTab } + if selectedTab == 1 { detailsTab } + if selectedTab == 2 { financialTab } + if selectedTab == 3 { termsTab } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + .navigationTitle(mode.isCreate ? "New Entity" : "Edit Entity") + .toolbar { /* cancel + save */ } + .onAppear { loadExisting() } +} +``` + +**Rules**: +- `VStack` with `spacing: 0` for tab bar + content +- `ZStack` with conditional `if` rendering (NOT `switch` statement) +- Each tab gets `.transition(.opacity.animation(.easeInOut(duration: 0.15)))` + +### Tab Content Template + +```swift +private var tabName: some View { + ScrollView { + VStack(spacing: DS.Spacing.lg) { + DSFormSection("Section Title", icon: "icon.name") { + VStack(spacing: DS.Spacing.md) { + // Fields + } + } + } + .frame(maxWidth: .infinity) + .padding(DS.Spacing.xl) + .padding(.top, DS.Spacing.md) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) +} +``` + +--- + +## Tab Types + +### Tab 0: Quick Entry (All Editors) + +Natural language input with live parsing preview. + +```swift +DSSmartInputField( + placeholder: "Entity description example...", + text: $smartInput +) +``` + +Examples by entity type: +- **Estimate**: "Estimate for BKSI for $5000 due March 15 with Net 30 terms" +- **Person**: "John Doe at Apple, john@apple.com, (555) 123-4567" +- **Project**: "$50k website redesign for Acme Corp, starting March 1" +- **Expense**: "$250 client dinner at Restaurant with Jane from Acme" + +### Tab 1: Details + +Core entity fields. Use `DSTwoColumnRow` for related pairs (number/status, date/expiry). Keep single-column for complex fields (text editors, long pickers). + +### Tab 2+: Domain-Specific + +Financial (discount, tax, budget), Terms (payment, T&C), Contacts (related people/orgs), Time (tracking, estimates), Attachments, etc. + +--- + +## Design System Components + +| Component | Purpose | Key Features | +|-----------|---------|-------------| +| `DSEditorTabBar` | Custom tab bar | 56pt height, full-area clickable, accent highlight, 0.15s animation | +| `DSFormSection` | Grouped form fields | DSPlate styling, 16pt corners, 16pt padding, icon + title header | +| `DSTextField` | Single-line input | Label above, 8pt padding, quinary background, 10pt corners | +| `DSTextEditor` | Multi-line input | Configurable height (default 100pt), scrollable | +| `DSPickerRow` | Menu-style picker | Menu style, hidden redundant label, full width | +| `DSDatePickerRow` | Compact date picker | Supports .date, .hourAndMinute, or both | +| `DSToggleRow` | Toggle switch | System switch style | +| `DSTwoColumnRow` | Side-by-side layout | Equal-width columns, 24pt gap, top alignment | +| `DSSmartInputField` | NL input | 120pt height, accent border, ready for parsing | +| `DSAmountField` | Currency input | Label + prefix ($) + input + optional suffix | +| `DSNumericField` | Numeric input | Label + input + suffix (%) | + +--- + +## Spacing Standards + +| Context | Token | Value | +|---------|-------|-------| +| Outer padding (horizontal) | `DS.Spacing.xl` | 32pt | +| Top padding (additional) | `DS.Spacing.md` | 16pt | +| Between sections | `DS.Spacing.lg` | 24pt | +| Between fields | `DS.Spacing.md` | 16pt | +| Label-to-input | `DS.Spacing.xs` | 4pt | +| Internal section padding | `DS.Spacing.md` | 16pt | +| Tab bar height | Fixed | 56pt | +| Two-column gap | `DS.Spacing.lg` | 24pt | + +--- + +## Common Patterns + +### Create vs Edit Mode + +```swift +let mode: EditorMode + +.navigationTitle(mode.isCreate ? "New Entity" : "Edit Entity") +.toolbar { + ToolbarItem(placement: .confirmationAction) { + Button(mode.isCreate ? "Create" : "Save") { save() } + .buttonStyle(.borderedProminent) + } +} +``` + +### Loading Existing Data + +```swift +private func loadExisting() { + if case .edit(let entity) = mode { + field1 = entity.field1 + field2 = entity.field2 ?? "" + } else { + number = Entity.generateNumber() + } +} +``` + +### Width Consistency + +Apply `.frame(maxWidth: .infinity)` at every level: +1. `DSFormSection` content VStack +2. `DSPickerRow` Picker +3. Tab content VStack (before padding) +4. Tab content ScrollView (at end) + +--- + +## Checklist for New Editor + +- [ ] 3+ tabs with `DSEditorTab` definitions +- [ ] `ZStack` with conditional `if` rendering (not `switch`) +- [ ] Tab 0: Quick Entry with `DSSmartInputField` +- [ ] Tab 1: Details with entity-specific fields +- [ ] All tabs: consistent padding (`xl` + top `md`) +- [ ] All tabs: `maxWidth/maxHeight` frames +- [ ] `DSFormSection` for all content groups +- [ ] `DSTwoColumnRow` for related field pairs +- [ ] `.borderedProminent` on save/create button +- [ ] Sheet frame: 800-900px x 600-700px +- [ ] Smooth 0.15s tab transitions +- [ ] Width consistency cascade +- [ ] Full tab area clickable +- [ ] Icons on all tabs and sections + +--- + +## Migration from Old Form Style + +**Before** (Form-based): +```swift +Form { + Section("Title") { + TextField("Field", text: $value) + } +} +.formStyle(.grouped) +``` + +**After** (Tabbed editor): +```swift +VStack(spacing: 0) { + DSEditorTabBar(tabs: tabs, selectedTab: $selectedTab) + Divider() + ZStack { + if selectedTab == 0 { /* Quick Entry */ } + if selectedTab == 1 { + ScrollView { + VStack(spacing: DS.Spacing.lg) { + DSFormSection("Title", icon: "icon") { + DSTextField("Field", text: $value) + } + } + .frame(maxWidth: .infinity) + .padding(DS.Spacing.xl) + .padding(.top, DS.Spacing.md) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + } +} +``` + +--- + +## Note + +Each project implements these DS components in its own design system namespace (e.g., `InControlDS`, `ShabuBoxTheme`). The pattern and API surface should be consistent across projects even if the concrete implementation differs. diff --git a/scarf/standards/07-ai-integration.md b/scarf/standards/07-ai-integration.md new file mode 100644 index 0000000..ff2b442 --- /dev/null +++ b/scarf/standards/07-ai-integration.md @@ -0,0 +1,164 @@ +# 07 — AI Integration + +Standards for AI/ML capabilities across all macOS apps. + +--- + +## 1. Native-First Principle + +Always prefer Apple's on-device frameworks before reaching for LLMs: + +| Task | Framework | Level | +|------|-----------|-------| +| Text recognition (OCR) | Apple Vision | Level 1 (primary) | +| Entity extraction (names, dates, places) | NaturalLanguage | Level 1 (primary) | +| Text classification | NaturalLanguage | Level 1 (primary) | +| Language detection | NaturalLanguage | Level 1 (primary) | +| Image classification | Vision | Level 1 (primary) | +| Barcode/QR detection | Vision | Level 1 (primary) | +| Summarization | LLM via protocol | Level 3 (enrichment) | +| Complex reasoning | LLM via protocol | Level 3 (enrichment) | + +**Why**: On-device frameworks are fast, private, free, and work offline. LLMs are slow, may require network, and cost compute. + +--- + +## 2. LLM Protocol Layer + +Access LLMs through protocols for backend swappability: + +```swift +protocol TextGenerating: Sendable { + func generate(prompt: String, maxTokens: Int) async throws -> String + func generate(prompt: String, maxTokens: Int) -> AsyncThrowingStream +} +``` + +This allows swapping between: +- Local models (MLX Swift, Core ML) +- Cloud APIs (Anthropic, OpenAI) +- Mock implementations (testing) + +Without changing any calling code. + +### Implementation Rules + +- Service layer holds the active `TextGenerating` implementation +- Views never call LLM APIs directly +- Configuration determines which backend is active +- All backends conform to the same protocol + +--- + +## 3. Hardware Acceleration + +- Use **Accelerate framework** (vDSP) for vector math and similarity search +- Use **Core ML** for model inference when models are available in `.mlmodel` format +- Use **Metal Performance Shaders** only when Accelerate is insufficient +- For apps with Python backends (e.g., Modeler), communicate via JSON-lines over stdin/stdout + +### Python Backend Protocol (when applicable) + +```json +// Request (one JSON object per line on stdin) +{"id": "req-1", "method": "generate", "params": {"prompt": "...", "steps": 50}} + +// Response (one JSON object per line on stdout) +{"id": "req-1", "type": "progress", "step": 25, "total": 50} +{"id": "req-1", "type": "image_result", "path": "/path/to/output.png"} +{"id": "req-1", "type": "complete"} +``` + +- Backend is a managed subprocess — start on demand, health-check, auto-restart +- Close both Pipe file handles after communication + +--- + +## 4. Memory Hygiene + +```swift +// autoreleasepool in ALL loops processing images, thumbnails, or PDFs +for page in pages { + autoreleasepool { + // Process page (synchronous only — no await inside) + let image = renderPage(page) + processImage(image) + } +} + +// Check cancellation in long loops +for item in items { + guard !Task.isCancelled else { break } + // process... +} +``` + +- **autoreleasepool**: Required for image/thumbnail/PDF processing loops. Synchronous only — no `await` inside the autoreleasepool block +- **PDF rendering cap**: Max 4096px in any dimension +- **Task.isCancelled**: Check at top of every iteration in long loops +- **Pipe handles**: Always close both ends after subprocess communication + +--- + +## 5. Pipeline Architecture + +For apps with file ingestion or multi-stage processing, use a stage-based pipeline: + +### Two-Phase Architecture + +**Phase 1: "Ready-to-Review"** (target: <5s per file) +- First-page thumbnail only +- First-page OCR (1-3s) +- Fast classification +- Skeleton Record created immediately for UI feedback + +**Phase 2: "Full Processing"** (deferred, on-demand) +- Complete OCR (all pages) +- AI analysis and summarization +- Embedding generation +- Visual fingerprinting + +### Job Priority System + +Each processing job conforms to a pipeline protocol and runs at a defined priority: + +```swift +protocol ProcessingJob { + var priority: Int { get } // Lower = runs first + func process(_ item: Item) async throws +} +``` + +Jobs register in the pipeline service and execute in priority order. Each job must: +- Have its own `os.Logger` +- Log entry/exit at `.info` level +- Check `Task.isCancelled` in loops +- Handle errors without blocking subsequent jobs + +--- + +## 6. Prompt Injection Defense + +All document text must be sanitized before embedding in LLM prompts: + +```swift +let safeText = LLMService.sanitizeForPrompt(rawDocumentText) +let prompt = "Summarize the following document:\n\n\(safeText)" +``` + +### Sanitization Rules + +- Strip provider-specific special tokens (``, ``, ``, `[INST]`, `[/INST]`) +- Strip bracket delimiters (`[SYSTEM]`, `[USER]`, `[ASSISTANT]`) +- Strip XML-style role tags (`<|system|>`, `<|user|>`, `<|assistant|>`) +- When adding new LLM provider support, add that provider's format markers +- Test sanitization against the provider's known injection vectors + +--- + +## 7. When NOT to Use AI + +- Don't use LLMs for tasks that deterministic code handles well (date parsing, number formatting, sorting) +- Don't call LLMs on the critical path of user interactions (search, navigation, filtering) +- Don't store LLM outputs as authoritative data — they are suggestions for user review +- Don't send sensitive data (credentials, financial details) to cloud LLM APIs without user consent diff --git a/scarf/standards/08-data-integrity.md b/scarf/standards/08-data-integrity.md new file mode 100644 index 0000000..5795f15 --- /dev/null +++ b/scarf/standards/08-data-integrity.md @@ -0,0 +1,87 @@ +# 08 — Data Integrity + +Rules for preventing data loss during backup, restore, migration, and sync operations. + +--- + +## 1. Backup/Restore Serialization Parity + +Every persisted `@Model` field must have a corresponding field in its `Backup*` struct. When adding a field to a model, update both directions: + +- `Backup*.init(from:)` -- read from model +- `Backup*.toModel()` -- write to new model +- The top-level backup container struct if it is a new model type + +**No silent data dropping.** Never assign `[]` or `nil` to backup fields unconditionally. If a field must be skipped: + +- Add a `// WORKAROUND:` comment with the reason +- Log at `.warning` level +- Document what data is lost and how it can be recovered + +**Roundtrip testing required.** Any change to a `@Model` class or its `Backup*` struct requires a test that: + +1. Creates a model with all fields populated +2. Converts to the backup representation and back +3. Asserts all fields match + +**Known gaps.** When a backup struct intentionally omits fields, add a `// TODO:` comment listing the missing fields and the reason. Track these as technical debt. Do not ship new backup structs with undocumented omissions. + +--- + +## 2. Migration Safety + +### Rollback Contract + +Multi-step migrations must: + +- **Store original state** before any destructive step (rename, delete, overwrite) +- **Wrap all steps** in a single `do/catch` that calls `rollback()` on failure +- **Test `rollback()` independently** -- it is its own code path and needs its own coverage +- **Never mark migration complete** until all verification passes + +### Verification Must Throw + +Validation methods (e.g., `verifyDataIntegrity()`) must `throw` on failure, not just log. The caller's `catch` block handles rollback. A log-only verification is a no-op for safety -- the migration proceeds as if everything succeeded. + +### No New Steps Without Rollback Coverage + +Before adding a step to any migration coordinator, verify that `rollback()` handles the new state and cleans up any artifacts the step creates. + +--- + +## 3. CloudKit Sync Safety + +### Explicit Conflict Resolution + +Do not use last-write-wins without user notification. When the sync provider returns a conflict: + +- Log conflict details +- Either merge deterministically (union for arrays, latest timestamp for scalars) or queue for user resolution +- Never silently discard either version + +### Safety Guards + +Maintain these defensive patterns: + +| Guard | Purpose | +|-------|---------| +| Abort if >80% of records affected | Prevents runaway repair/cleanup from wiping the dataset | +| Self-refreshing flag | Prevents recursive sync notification loops | +| 0.25s debounce on notification handlers | Prevents rapid-fire processing of batched notifications | + +### Fetch Error Handling + +Use safe fetch wrappers (e.g., `safeFetch()`, `safeFetchCount()`) in all sync and monitor code paths. Never use bare `try?` on fetch calls -- errors in sync paths must be logged and handled, not silently swallowed. + +--- + +## 4. SwiftData Version Coexistence + +V1 and V2 schema versions cannot share live model types in the same process. Attempting to register both causes a "Duplicate checksums" crash. + +When planning a schema version transition: + +- Use `VersionedSchema` and `SchemaMigrationPlan` +- Test the migration path from V1 to V2 in isolation +- Confirm the old schema version is fully removed before the new one is registered +- Never modify existing schema versions -- always create a new one diff --git a/scarf/standards/09-performance.md b/scarf/standards/09-performance.md new file mode 100644 index 0000000..7f41dd3 --- /dev/null +++ b/scarf/standards/09-performance.md @@ -0,0 +1,159 @@ +# 09 — Performance + +Patterns for keeping SwiftUI views responsive, queries efficient, and memory bounded. + +--- + +## 1. Component Extraction Pattern + +### Problem + +SwiftUI views with high complexity (1000+ lines, dozens of `@State` variables) cause slow task scheduling (3-4s) and expensive view reconciliation. Database queries are typically fast -- the bottleneck is view complexity. + +### Solution: Isolated Components + +Break monolithic views into isolated child components. The parent becomes a lightweight coordinator with minimal state. Each child owns its section-specific state locally. + +``` +CoordinatorView (minimal state) + |- SectionAListView (isolated state) + |- SectionBListView (isolated state) + |- SectionCListView (isolated state) + `- SectionDListView (isolated state) +``` + +### Proven Results + +| Operation | Before | After | Improvement | +|-----------|--------|-------|-------------| +| Pagination | 3,800ms | 37ms | ~100x | +| Filter change | 3,500ms | 45ms | ~78x | +| Sort change | 3,200ms | 52ms | ~62x | +| Search | 3,600ms | 35ms | ~103x | + +### Implementation Steps + +1. **Identify state to extract** (section-specific): current page, loaded items, loaded total count, loading flags, sort option, items per page, view mode, multi-select mode, selected IDs. + +2. **Keep in parent** (shared across sections): navigation selection, search text, preview item, root configuration. + +3. **Create a dedicated view file** for each section: + - Receive `modelContext` and data actor from environment + - Accept bindings from parent for shared state + - Use local `@State` for section-specific state + - Use callbacks for parent actions (`onItemTap`, `onAction`) + +4. **`.task(id:)` must include ALL dependencies:** + ```swift + .task(id: "\(currentPage)-\(itemsPerPage)-\(searchText)-\(sortOption)-\(filter)") { + await loadItems() + } + ``` + Forgetting any dependency (e.g., `itemsPerPage`) means changing it will not trigger a reload. + +5. **Use background actor queries** (see Section 3 below). + +6. **Use `@ViewBuilder` computed properties** for complex toolbars and conditional layouts to prevent SwiftUI type-checker timeouts. + +### Common Issues + +| Issue | Solution | +|-------|----------| +| Items per page not reloading | Add `itemsPerPage` to `.task(id:)` | +| Faulted SwiftData objects | Filter with `object.modelContext != nil` | +| Search not debouncing | Add debounce task + `onChange` handler (700ms) | +| Modal shows empty state | Add `.task` to refresh data on modal appear | + +--- + +## 2. State Ownership Matrix + +| Owner | State | +|-------|-------| +| **Component owns** | Pagination, view preferences, selection, loading flags | +| **Parent owns** | Navigation, global search, preview/modal, root config | +| **Pass as bindings** | Filters affecting multiple sections, shared search text | +| **Pass as callbacks** | Navigation actions, preview actions | + +Target: ~10 `@State` variables per view. If a view exceeds this, it is a candidate for extraction. + +--- + +## 3. Background Actor Query Pattern + +All views should use background actor queries instead of `@Query` or synchronous data store calls. + +```swift +// Background actor query +let (ids, count) = try await actor.fetchItemsWithCount(page:, limit:) +// Main thread model conversion +loadedItems = modelContext.items(from: ids) +``` + +### Filtering Rules + +- Use database-level `#Predicate` filtering whenever possible. +- If in-memory filtering is unavoidable, add a `fetchLimit` cap and document why the predicate approach does not work. +- Any new filter dimension must default to database-level filtering. In-memory filtering of unbounded result sets is a scalability risk. + +--- + +## 4. View Complexity Limits + +| Metric | Guideline | +|--------|-----------| +| `@State` variables per view | ~10 (target) | +| Service file size | ~1,000 lines max | +| View file size | ~800 lines max | + +When the Swift type-checker times out on a view body, extract sub-expressions into `@ViewBuilder` computed properties or separate files. + +--- + +## 5. Memory Management + +- **`autoreleasepool`** in all loops processing images, thumbnails, or PDFs. Synchronous only -- no `await` inside the pool. +- **No `Date()` allocations in hot paths** without `#if DEBUG`. Use `os_signpost` intervals for production performance measurement. +- **Cap PDF rendering** at 4096px maximum dimension. +- **Never cache large arrays.** Use database-level filtering with predicates. Fetch on demand. + +--- + +## 6. Refactoring Priority Matrix + +Use this template when prioritizing refactoring work: + +``` +HIGH IMPACT + LOW RISK = DO FIRST +|- Extract shared components (reusable controls, footers, toolbars) +|- Create centralized observable state +`- Async file operations + +HIGH IMPACT + MEDIUM RISK = DO NEXT +|- Async model saves +|- ViewModel extraction from large views +`- Async processing pipelines + +MEDIUM IMPACT + MEDIUM RISK = DO LATER +|- Replace remaining @Query usage with actor pattern +`- Async indexing/background services +``` + +### Metrics to Track + +| Metric | Measure | +|--------|---------| +| Initial load time | Target < 100ms | +| `@State` count in coordinator views | Target ~10 | +| Duplicate code lines | Target < 50 | +| Views using synchronous queries | Target 0 | +| Main thread blocking operations | Target 0 | + +--- + +## 7. Lazy Loading + +- **Never cache large arrays.** Always query the database for the current page. +- **Database-level filtering** with `#Predicate` is the default. Index frequently queried fields. +- **`fetchLimit` cap** when in-memory filtering is unavoidable. Document the reason. +- **Debug logging** with `Date()` must be gated behind `#if DEBUG`. Do not add `Date()` timing to methods called per-page without the guard. diff --git a/scarf/standards/10-testing.md b/scarf/standards/10-testing.md new file mode 100644 index 0000000..32a6df2 --- /dev/null +++ b/scarf/standards/10-testing.md @@ -0,0 +1,140 @@ +# 10 — Testing + +Standards for writing reliable, deterministic, and maintainable tests in Swift projects. + +--- + +## 1. Framework + +Use the **Swift Testing** framework for all new tests: + +- `@Suite` for test groupings +- `@Test` for individual test cases +- `#expect` and `#require` for assertions + +Do not use XCTest for new tests. Existing XCTest suites may be migrated incrementally. + +--- + +## 2. Mocking + +Use **protocol-oriented mocking**: + +- All services expose a protocol interface (e.g., `protocol FileManaging`) +- Concrete implementations conform to the protocol +- Tests swap real implementations for mock/stub versions +- This enables isolated unit testing without side effects + +```swift +protocol DataProviding: Sendable { + func fetchItems(page: Int, limit: Int) async throws -> [Item] +} + +struct MockDataProvider: DataProviding { + var items: [Item] = [] + func fetchItems(page: Int, limit: Int) async throws -> [Item] { + Array(items.prefix(limit)) + } +} +``` + +--- + +## 3. Timing + +**No timing-dependent tests.** Never rely on fixed `sleep` durations. + +Use polling with early exit: + +```swift +for _ in 0..<20 { + if await service.isComplete { break } + try await Task.sleep(for: .milliseconds(100)) +} +#expect(await service.isComplete) +``` + +- Maximum 20 iterations at 100ms each (2s total timeout) +- Break as soon as the condition is met +- Assert after the loop, not inside it + +--- + +## 4. Singleton Isolation + +Shared state must be clean before each test: + +1. Call the service's cleanup/reset method +2. `await Task.yield()` to let pending work complete +3. Then run assertions + +```swift +await SharedService.shared.reset() +await Task.yield() + +// Now safe to test +await SharedService.shared.doWork() +#expect(await SharedService.shared.result == expected) +``` + +Reset shared state between tests to prevent ordering dependencies. + +--- + +## 5. Cooperative Cancellation + +Verify that long-running tasks respond to `Task.isCancelled`: + +- Start a long operation in a `Task` +- Cancel it mid-flight +- Assert that cancellation produces expected cleanup (partial results discarded, temporary files removed, state reset) + +```swift +let task = Task { await service.processLargeDataset() } +try await Task.sleep(for: .milliseconds(50)) +task.cancel() +let result = await task.value +#expect(result == .cancelled) +``` + +--- + +## 6. Roundtrip Tests + +**Required** for any change to backup/restore logic or serialization formats. + +Every roundtrip test must: + +1. Create a model instance with **all fields populated** (no defaults) +2. Encode to the serialization format (backup struct, JSON, etc.) +3. Decode back to a model instance +4. Assert every field matches the original + +Do not skip optional fields -- populate them with non-nil values to verify they survive the round trip. + +--- + +## 7. Integration Tests + +For systems that communicate with subprocesses or external services: + +- Test the **request/response cycle** end to end +- For subprocess protocols (e.g., JSON-lines over stdin/stdout), verify: + - Request serialization produces valid output + - Response deserialization handles all message types (progress, result, error) + - Error responses are surfaced correctly +- Use mock subprocess runners or in-process test servers when possible + +--- + +## 8. Logging in Tests + +**No `print()` in production code or test helpers.** Use `os.Logger` with an appropriate test subsystem: + +```swift +private let logger = Logger(subsystem: "com.app.tests", category: "ServiceTests") +``` + +`print()` is only acceptable in `#Preview` blocks for quick debugging during development. + +Rationale: `os.Logger` output is filterable, has log levels, and does not pollute test console output with unstructured noise. diff --git a/scarf/standards/11-multiplatform.md b/scarf/standards/11-multiplatform.md new file mode 100644 index 0000000..6e30911 --- /dev/null +++ b/scarf/standards/11-multiplatform.md @@ -0,0 +1,274 @@ +# 11 — Multiplatform (iOS Companion Apps) + +Standards for building iOS companion apps alongside macOS apps, sharing code and syncing via CloudKit. + +**Reference implementation**: ShabuBox (working macOS + iOS with CloudKit sync) + +--- + +## 1. Shared Library Structure + +Any code that both platforms need goes in `Shared/`. Models are ALWAYS shared — never duplicate a `@Model` type per platform. + +``` +ProjectName/ + ProjectName/ -- macOS app target + App/ + Core/ + Features/ + Services/ + Views/ + ProjectNameMobile/ -- iOS companion target + App/ -- iOS app entry point + Features/ -- iOS-specific views/flows + Views/ -- iOS-specific UI + Info.plist -- UIBackgroundModes, etc. + Shared/ -- Shared library (both targets link this) + Models/ -- ALL SwiftData @Model types + DesignSystem/ -- Shared theme tokens (platform-adapted) + SchemaVersioning.swift -- VersionedSchema + MigrationPlan + CloudKitSyncHelper.swift + LoggingKit.swift + PathUtilities.swift + ModelContext+SafeSave.swift +``` + +### What Goes in Shared/ + +| Category | Examples | Why Shared | +|----------|----------|-----------| +| SwiftData models | All `@Model` types | Schema must be identical for CloudKit sync | +| Schema versioning | `VersionedSchema`, `SchemaMigrationPlan` | Version mismatch breaks sync | +| Sync helpers | `CloudKitSyncHelper`, manual sync utilities | Both platforms need sync control | +| Design tokens | Theme colors, spacing, typography values | Visual consistency across platforms | +| Logging | Logger configuration, safe-save extensions | Consistent error handling | +| Path utilities | Filename sanitization, path security | Same rules apply everywhere | + +### What Stays Platform-Specific + +| Category | macOS Target | iOS Target | +|----------|-------------|-----------| +| App entry point | `ProjectNameApp.swift` | `ProjectName_MobileApp.swift` | +| Navigation | `NavigationSplitView` (3-column) | `NavigationStack` or compact `NavigationSplitView` | +| Views | Desktop-optimized layouts | Touch-optimized layouts | +| Services | Desktop-specific services (file monitoring, etc.) | iOS-specific services | +| Platform APIs | AppKit (`NSImage`, `NSWorkspace`) | UIKit (`UIImage`, UIBackgroundModes) | + +--- + +## 2. CloudKit Container Setup + +### Both Platforms Must Match + +```swift +// CRITICAL: Both macOS and iOS must use identical configuration +let config = ModelConfiguration( + cloudKitDatabase: .private("iCloud.com.yourapp.identifier") +) +``` + +**Never** use `.automatic` — it can infer different containers per platform, causing sync to silently fail. + +### Entitlements (macOS) + +```xml +com.apple.developer.icloud-container-identifiers + + iCloud.com.yourapp.identifier + +com.apple.developer.icloud-services + + CloudKit + CloudDocuments + +``` + +### Entitlements (iOS) + +```xml +com.apple.developer.icloud-container-identifiers + + iCloud.com.yourapp.identifier + +com.apple.developer.icloud-services + + CloudKit + CloudDocuments + +com.apple.developer.aps-environment +development +``` + +**Common mistake**: Using `aps-environment` instead of `com.apple.developer.aps-environment` for the APS key. The wrong key silently disables push notifications for CloudKit changes. + +--- + +## 3. iOS Background Sync + +### Info.plist Configuration + +```xml +UIBackgroundModes + + remote-notification + fetch + +``` + +### Sync Behavior + +- **Typical delay**: 5-30 seconds between devices +- **Simulator**: 30-60 seconds (slower than physical devices) +- **Background**: System may defer sync to save battery +- **Sync triggers**: App launch, app background, periodic intervals, network restored, significant changes + +### Manual Sync Trigger + +Provide a way for users to force sync (pull-to-refresh, sync button): + +```swift +// Trigger foreground sync +try modelContext.save() +// CloudKit will push changes on next sync cycle +``` + +--- + +## 4. Platform-Adaptive UI + +### Navigation Patterns + +```swift +#if os(macOS) +NavigationSplitView { + Sidebar() +} content: { + ContentList() +} detail: { + DetailView() +} +#else +NavigationStack { + ContentList() +} +#endif +``` + +### Size Classes + +```swift +@Environment(\.horizontalSizeClass) private var sizeClass + +var body: some View { + if sizeClass == .compact { + // iPhone layout (single column) + } else { + // iPad layout (split view) + } +} +``` + +### Platform-Specific Modifiers + +```swift +extension View { + @ViewBuilder + func platformToolbar() -> some View { + #if os(macOS) + self.toolbar { /* macOS toolbar items */ } + #else + self.toolbar { /* iOS toolbar items */ } + #endif + } +} +``` + +--- + +## 5. Shared Design System with Platform Adaptation + +Define tokens once in `Shared/DesignSystem/`, apply platform-specific modifiers: + +```swift +// Shared/DesignSystem/AppTheme.swift +enum AppTheme { + enum Spacing { + static let sm: CGFloat = 8 + static let md: CGFloat = 16 + static let lg: CGFloat = 24 + } + + enum Typography { + static let title: Font = .title + static let body: Font = .body + } +} +``` + +Platform-specific extensions live in each target: + +```swift +// macOS target +extension View { + func appCard() -> some View { + self.background(.ultraThinMaterial) + .clipShape(RoundedRectangle(cornerRadius: 12)) + } +} + +// iOS target +extension View { + func appCard() -> some View { + self.background(.regularMaterial) + .clipShape(RoundedRectangle(cornerRadius: 16)) + } +} +``` + +--- + +## 6. Schema Versioning Across Platforms + +**Both platforms MUST use identical `VersionedSchema` definitions.** This is why schema versioning lives in `Shared/`. + +- Add new schema versions in `Shared/SchemaVersioning.swift` +- Both targets pick up the change automatically +- Test migration on both platforms before shipping +- A schema version mismatch between platforms causes sync failures or crashes + +--- + +## 7. Testing Across Platforms + +| Test Type | Location | Runs On | +|-----------|----------|---------| +| Model tests | `SharedTests/` | Both platforms | +| Service tests | `SharedTests/` | Both platforms | +| macOS UI tests | `ProjectNameTests/` | macOS only | +| iOS UI tests | `ProjectNameMobileTests/` | iOS only | +| Sync roundtrip tests | `SharedTests/` | Both platforms | + +--- + +## 8. CloudKit Troubleshooting + +| Symptom | Likely Cause | Fix | +|---------|-------------|-----| +| Simple models sync, complex models don't | Container ID mismatch (`.automatic` vs explicit) | Use explicit `.private("iCloud.com....")` on both | +| iOS doesn't receive remote changes | Wrong APS entitlement key | Use `com.apple.developer.aps-environment` | +| Sync works on device, not simulator | Simulator CloudKit delays | Wait longer (30-60s), test on device for real behavior | +| "Duplicate checksums" crash | V1 and V2 models sharing live types | Plan version transitions — see `08-data-integrity.md` | +| Cascade deletes cause sync issues | Complex relationship graphs | Simplify relationships, use explicit join tables | + +--- + +## 9. When NOT to Go Multiplatform + +Not every app needs an iOS companion. Skip it when: + +- **Hardware-specific requirements**: Apps targeting Apple Silicon GPU (e.g., ML model training), large displays, or specific peripherals +- **Desktop-only workflows**: Complex multi-window setups, file system manipulation, development tools +- **Python/subprocess backends**: Apps that rely on bundled Python environments or native subprocess management +- **No user data to sync**: If the app doesn't have user-created content that benefits from cross-device access + +Example: Modeler (AI photo generation) requires Apple Silicon Max with 32GB+ RAM and a bundled Python environment — an iOS companion would not be practical. diff --git a/scarf/standards/AUDIT_CHECKLIST.md b/scarf/standards/AUDIT_CHECKLIST.md new file mode 100644 index 0000000..23f07b8 --- /dev/null +++ b/scarf/standards/AUDIT_CHECKLIST.md @@ -0,0 +1,166 @@ +# Audit Checklist + +Copy this template per project. Check each item, note gaps, and record severity. + +**Project**: _______________ +**Date**: _______________ +**Auditor**: _______________ + +--- + +## 01 Architecture + +- [ ] Uses MVVM-F with feature modules (Models/ViewModels/Views per feature) +- [ ] AppCoordinator centralizes all navigation state +- [ ] No navigation state duplication between AppCoordinator and AppState +- [ ] Directory layout matches convention (`App/`, `Core/`, `Features/`, `Services/`, `Shared/`) +- [ ] Protocol-driven service interfaces (not concrete types) +- [ ] @Observable macro used (not ObservableObject/@Published) in new code +- [ ] No deep-nested NavigationStack in leaf views + +## 02 SwiftData + +- [ ] VersionedSchema + SchemaMigrationPlan in use +- [ ] No bare `Schema([...])` — uses `Schema(versionedSchema:)` + `migrationPlan:` +- [ ] Each VersionedSchema lists ALL active models +- [ ] Skeleton Records for immediate UI feedback (`status = .processing`) +- [ ] `#Index` macro on frequently queried fields +- [ ] UUID primary keys on all models +- [ ] `createdAt` / `updatedAt` timestamps on all models +- [ ] Soft delete pattern (`isArchived` + `archivedAt`) where applicable +- [ ] Money stored as `Int64` minor units + `currencyCode` (ISO 4217) +- [ ] Explicit join tables for many-to-many (CloudKit-friendly) +- [ ] Safe fetch wrappers used (no bare `try?` on `modelContext.fetch()`) +- [ ] DataStoreActor for background queries (not @Query in views) + +## 03 Storage & Sandboxing + +- [ ] NSFileCoordinator for ALL file ops within library root +- [ ] No prohibited `FileManager.default` calls (check lookup table) +- [ ] TOCTOU anti-patterns eliminated (no `fileExists` before idempotent ops) +- [ ] Security-scoped bookmarks for user-selected external folders +- [ ] Filename sanitization from external sources (`PathUtilities`) +- [ ] Inbox pattern (`_InBox/`) for new documents +- [ ] System folders use singular prefix (`_InBox`, `_Deleted`, `_Duplicate`) +- [ ] LLM prompt injection sanitization on document text (if AI features exist) + +## 04 Swift Conventions + +- [ ] No `print()` in production code — `os.Logger` exclusively +- [ ] `print()` only in `#Preview` blocks and test helpers +- [ ] Logger subsystem follows `"com..app"` pattern +- [ ] Logger category matches type name +- [ ] Classes/actors: `private let logger = Logger(...)` +- [ ] Structs/views: `private static let logger = Logger(...)`, accessed via `Self.logger` +- [ ] Every `catch` block: logs / rethrows / returns `Result.failure` (no empty catches) +- [ ] `modelContext.save()` always in `do/try/catch` with logging +- [ ] No bare `try?` on important operations (only for truly ignorable ops, with comment) +- [ ] Multi-step operations (3+ steps) have rollback or are idempotent +- [ ] File size limits: services ~1,000 lines, views ~800 lines +- [ ] No force unwrapping (`!`) in SwiftUI code +- [ ] No `DispatchQueue` when Swift Concurrency works +- [ ] No `Combine` in new code — use `@Observable` + async/await +- [ ] No UIKit types (use AppKit: `NSImage`, `NSWorkspace`) +- [ ] All `Task`/`Task.detached` closures are `@Sendable` +- [ ] No sync file I/O on `@MainActor` +- [ ] `os_unfair_lock` for thread-safe flags (not `NSLock`) + +## 05 Design System + +- [ ] Centralized theme file exists (e.g., `AppTheme.swift`) +- [ ] No hardcoded colors — uses theme tokens +- [ ] No hardcoded fonts — uses Apple semantic styles or theme tokens +- [ ] No hardcoded spacing — uses spacing tokens +- [ ] No hardcoded animations — uses animation tokens +- [ ] No hardcoded corner radii — uses radius tokens +- [ ] Material tier rules followed (glass for chrome, plates for content) +- [ ] Keyboard-first: shortcuts for all primary actions +- [ ] SF Symbols exclusively for icons +- [ ] Filled variants for selected states +- [ ] Icon-only buttons have tooltip + accessibility label +- [ ] Accessibility: works with Reduce Transparency +- [ ] Accessibility: works with Increase Contrast +- [ ] Accessibility: works with Large Dynamic Type +- [ ] Accessibility: works with Reduce Motion + +## 06 Editor Patterns + +- [ ] Editors follow tabbed architecture (or N/A) +- [ ] Tab 0: Quick Entry with natural language input +- [ ] Tab 1: Details with core entity fields +- [ ] `ZStack` with conditional `if` rendering (not `switch`) +- [ ] `DSFormSection` for all content groups +- [ ] Wide canvas (800-900px x 600-700px) +- [ ] 0.15s tab transitions +- [ ] `.borderedProminent` on save/create button + +## 07 AI Integration + +- [ ] Native-first: Vision/NaturalLanguage before LLMs (or N/A) +- [ ] LLM accessed via protocol (`TextGenerating` or equivalent) +- [ ] `autoreleasepool` in image/PDF processing loops +- [ ] `Task.isCancelled` checked in long processing loops +- [ ] Prompt injection sanitization on document text +- [ ] No LLM calls on critical UI path + +## 08 Data Integrity + +- [ ] Backup/restore: every @Model field has Backup* struct field (or N/A) +- [ ] No silent data dropping in backup structs +- [ ] Roundtrip tests for serialization changes +- [ ] Migration steps have rollback contracts +- [ ] Verification throws on failure (not just logs) +- [ ] CloudKit conflict resolution is explicit (not silent last-write-wins) +- [ ] Safety guards: abort batch operations if >80% affected +- [ ] Debounce on sync notification handlers + +## 09 Performance + +- [ ] No views with 20+ @State variables +- [ ] Complex views use component extraction pattern +- [ ] DataStoreActor for background queries +- [ ] `autoreleasepool` in image/thumbnail/PDF loops +- [ ] No `Date()` allocations in hot paths without `#if DEBUG` +- [ ] `@ViewBuilder` used when type-checker times out +- [ ] Database-level `#Predicate` filtering (not in-memory) + +## 10 Testing + +- [ ] Swift Testing framework (`@Suite`, `@Test` macros) +- [ ] Protocol-based mock implementations for services +- [ ] No timing-dependent tests (uses polling with early exit) +- [ ] Singleton state cleaned up between tests +- [ ] Cooperative cancellation tested for long-running tasks +- [ ] Roundtrip tests for backup/restore changes +- [ ] No `print()` in test code (except `#Preview`) + +## 11 Multiplatform + +- [ ] Shared/ directory for all code used by both platforms (or N/A) +- [ ] ALL @Model types in Shared/ (never duplicated per platform) +- [ ] Schema versioning in Shared/ +- [ ] Explicit CloudKit container ID (not `.automatic`) +- [ ] iOS entitlements use `com.apple.developer.aps-environment` (not `aps-environment`) +- [ ] `UIBackgroundModes` configured (remote-notification, fetch) +- [ ] Platform-adaptive navigation (`#if os(...)`) +- [ ] Shared design tokens with platform-specific modifiers + +--- + +## Summary + +| Standard | Status | Gaps Found | Severity | +|----------|--------|-----------|----------| +| 01 Architecture | | | | +| 02 SwiftData | | | | +| 03 Storage/Sandboxing | | | | +| 04 Swift Conventions | | | | +| 05 Design System | | | | +| 06 Editor Patterns | | | | +| 07 AI Integration | | | | +| 08 Data Integrity | | | | +| 09 Performance | | | | +| 10 Testing | | | | +| 11 Multiplatform | | | | + +**Severity Levels**: Critical (data loss / crash risk) | High (correctness / maintainability) | Medium (consistency / best practice) | Low (polish / nice-to-have) diff --git a/scarf/standards/AUDIT_INSTRUCTIONS_PROMPT.md b/scarf/standards/AUDIT_INSTRUCTIONS_PROMPT.md new file mode 100644 index 0000000..70e1c4a --- /dev/null +++ b/scarf/standards/AUDIT_INSTRUCTIONS_PROMPT.md @@ -0,0 +1,89 @@ +# Audit & Remediate Project Instructions Against Centralized Standards + +Use this prompt in a fresh Claude Code session for each project. Replace the two variables at the top. + +--- + +## Project Configuration +- **PROJECT_ROOT**: `/Users/awizemann/Developer/ShabuBox` +- **PROJECT_NAME**: `ShabuBox` + +## Instructions + +This project follows centralized standards at `/Users/awizemann/Developer/standards/`. Read `INDEX.md` first, then audit this project against all 11 standards and fix violations. + +### Phase 1: Discovery (Parallel Agents) + +Launch up to 3 explore agents IN PARALLEL, each covering a subset of standards: + +**Agent 1 — Code Quality & Conventions (Standards 04, 02, 01)** +Search the project's Swift source files (exclude build/, .build/, SourcePackages/, Pods/) for: +- `print(` in production code (not #Preview, not test files) → should be os.Logger +- Force unwraps (`!`) — patterns like `.first!`, `as!`, `URL(...)!` (not `!=`, `!==`) +- Bare `try?` followed by `modelContext`, `.fetch(`, `.save(`, `encode`, `decode` +- `DispatchQueue` usage (should prefer @MainActor / Swift Concurrency) +- `ObservableObject` / `@Published` (should be @Observable in new code) +- `import Combine` (should use async/await) +- `NSLock` (should use os_unfair_lock) +- `@Query` in view files (should use DataStoreActor) +- Files over 800 lines (views) or 1000 lines (services) — report names and line counts +- Logger subsystem strings — check consistency (should be `"com..app"`) +- Logger declaration patterns — classes should use `private let`, structs/views should use `private static let` +- Schema versioning — check for bare `Schema([` without `versionedSchema:` +Report exact file paths, line numbers, and counts for each category. + +**Agent 2 — Storage, Security & File Operations (Standards 03, 08)** +Search the project's Swift source files for: +- `FileManager.default` in service/feature files (should use FileCoordinatorService) +- `fileExists` calls — classify each as valid (display-only, migration gate, cache) or TOCTOU anti-pattern +- Direct file operations without NSFileCoordinator in library root paths +- Bare `try?` on fetch/save in sync-related code +- Missing `autoreleasepool` in loops processing images/thumbnails/PDFs +- `Date()` allocations in hot paths without `#if DEBUG` +- Backup/restore structs — check if @Model fields have corresponding Backup* fields +- Migration code — check for rollback contracts +Report exact file paths, line numbers, and counts. + +**Agent 3 — Design System & UI (Standards 05, 06, 09)** +Search the project's Swift source files for: +- Hardcoded colors: `Color(` with literal values, `.foregroundColor(.red/blue/etc)`, `NSColor(calibratedRed:`, `NSColor(red:` +- Hardcoded fonts: `.font(.system(size:` instead of theme tokens +- Hardcoded spacing: `.padding(` with literal numeric values (not theme tokens like DS.Spacing or AppTheme) +- Hardcoded corner radii: `.cornerRadius(` or `RoundedRectangle(cornerRadius:` with literal values +- Hardcoded shadows: `.shadow(` with literal values instead of theme tokens +- Hardcoded animations: `.animation(.easeInOut(duration:` with literal values instead of theme tokens +- Views with 20+ @State variables (performance risk) +- Missing accessibility: interactive elements without `.accessibilityLabel` +Report counts per category and the worst-offending files. + +### Phase 2: Report + +After all agents complete, compile a single audit report with: + +1. **Summary table** — category, violation count, severity (Critical/High/Medium/Low) +2. **Top 10 files to fix** — ranked by total violations across all categories +3. **Per-standard breakdown** — what's compliant, what's not, specific file:line references +4. **Recommendations** — prioritized remediation order + +### Phase 3: Backfill Project Instructions + +1. **Backup first**: Copy every instruction/guideline file (.md files in .claude/rules/, CLAUDE.md, .agent/, docs/ that contain instructions) to `/Users/awizemann/Developer/standards/backups/{PROJECT_NAME}/2026-03-26/` + +2. **Update the project's main instruction file** (CLAUDE.md or .agent/instructions.md): + - Add a Standards section at the top referencing `/Users/awizemann/Developer/standards/` + - Remove any rules that are now fully covered by the centralized standards (architecture patterns, SwiftData conventions, file operation rules, error handling rules, logging rules, testing rules, design system principles, etc.) + - KEEP all project-specific content: build commands, key file paths, project-specific services, critical lessons, known hotspots, app-specific pipeline/processing details, unique integrations + - Add a "Known Audit Gaps" section with the violation counts from the audit + - Add a "Top Files to Fix" section listing the worst offenders + +3. **Update any supplementary instruction files** (.claude/rules/*.md, design system docs, etc.): + - Add a header line referencing the corresponding centralized standard + - Keep project-specific details (hotspot lists, violation counts, app-specific patterns) + - Remove duplicated generic rules + +### Important Rules +- This is a READ + WRITE task — you should make the file changes +- Do NOT modify any Swift source code — only .md instruction/guideline files +- Do NOT delete any files — only edit them +- Preserve all project-specific content (PRDs, screen layouts, data schemas, development plans, critical lessons) +- When in doubt about whether content is project-specific or generic, keep it diff --git a/scarf/standards/CODE_REMEDIATION_PROMPT.md b/scarf/standards/CODE_REMEDIATION_PROMPT.md new file mode 100644 index 0000000..71c5927 --- /dev/null +++ b/scarf/standards/CODE_REMEDIATION_PROMPT.md @@ -0,0 +1,210 @@ +# Code Compliance Remediation + +Use this prompt in a fresh Claude Code session AFTER the instruction audit has been completed. Replace the two variables at the top. + +--- + +## Project Configuration +- **PROJECT_ROOT**: `/Users/awizemann/Developer/ShabuBox` +- **PROJECT_NAME**: `ShabuBox` +- **STANDARDS_PATH**: `/Users/awizemann/Developer/standards` + +## Instructions + +This project has been audited against centralized standards. The audit gaps are documented in the project's instruction file (CLAUDE.md or .agent/instructions.md) under "Known Audit Gaps." Your job is to fix the violations in the actual Swift source code, working through them in priority order. + +**Read the project's instruction file first** to find the audit gaps table and top files to fix. + +--- + +### Execution Model: Serial Tiers with Verification + +Work through these tiers ONE AT A TIME, in order. After completing each tier: +1. Build the project to verify no regressions +2. Report what was fixed and what remains +3. Only proceed to the next tier after a clean build + +If a tier has more than ~15 files to change, break it into sub-batches of 8-10 files, building between each batch. + +**Do NOT run multiple tiers in parallel** — later tiers may touch the same files as earlier ones. + +--- + +### Tier 1: Safety-Critical (Crashes & Data Loss) + +**1a. Force Unwraps (`!`)** +For each force unwrap found in the audit: +- `.first!` → `guard let first = collection.first else { return }` or `.first.map { ... }` +- `as! Type` → `as? Type` with guard/if-let and logger.error for unexpected type +- `URL(string:)!` → `guard let url = URL(string:) else { logger.error(...); return }` +- Do NOT change force unwraps inside `#Preview` blocks or test files + +**1b. Bare `try?` on Critical Operations** +For each bare `try?` on `modelContext.save()`, `modelContext.fetch()`, `encode`, `decode`: +```swift +// BEFORE +let results = try? modelContext.fetch(descriptor) + +// AFTER +let results: [Entity] +do { + results = try modelContext.fetch(descriptor) +} catch { + logger.error("Failed to fetch entities: \(error)") + results = [] // or return, or throw, depending on context +} +``` +- If the file doesn't have a logger, add one following the standard: + - Classes/actors: `private let logger = Logger(subsystem: "com..app", category: "TypeName")` + - Structs/views: `private static let logger = Logger(subsystem: "com..app", category: "TypeName")` +- Add `import os` if not already present +- For `try? modelContext.save()`, always wrap in do/catch — saves should never silently fail + +**Build and verify after Tier 1.** + +--- + +### Tier 2: Logging & Error Handling + +**2a. Replace `print()` with `os.Logger`** +For each `print()` call in production code (not #Preview, not test files): +- Add a logger declaration if the file doesn't have one (follow the struct vs class pattern from standards) +- Replace `print("message")` → `logger.error("message")` or `logger.warning("message")` or `logger.info("message")` based on context: + - Error/failure messages → `logger.error()` + - Expected failure paths → `logger.warning()` + - Informational/debug → `logger.info()` or `logger.debug()` +- Add `import os` if not already present +- Do NOT touch `print()` inside `#Preview` blocks + +**2b. Logger Subsystem Consistency** +If the audit found multiple subsystem strings: +- Standardize ALL Logger declarations to use the canonical subsystem (check the project's instruction file for the correct one) +- Search and replace across all files + +**2c. Logger Declaration Pattern** +- Classes/actors should use: `private let logger = Logger(...)` +- Structs (including SwiftUI views) should use: `private static let logger = Logger(...)` accessed via `Self.logger` +- Nested structs must declare their own logger + +**Build and verify after Tier 2.** + +--- + +### Tier 3: File Operations & Concurrency + +**3a. FileManager.default Violations** +For each `FileManager.default` call inside the library root (iCloud container): +- Replace with the appropriate coordinated API: + - Read → `FileCoordinatorService.shared.coordinatedRead()` + - Write → `FileCoordinatorService.shared.coordinatedWrite()` + - Move → `FileCoordinatorService.shared.coordinatedMove()` + - Delete → `SafeFileOperations.shared.coordinatedDelete()` + - Create directory → `AsyncFileManager.shared.createDirectory()` + - List directory → `AsyncFileManager.shared.contentsOfDirectory()` +- Leave allowed exceptions: `.url(forUbiquityContainerIdentifier:)`, `.temporaryDirectory`, app bundle reads +- If the project doesn't have FileCoordinatorService/AsyncFileManager, skip this sub-tier and note it + +**3b. fileExists TOCTOU Anti-Patterns** +For each `fileExists` that precedes a file operation: +- `if fileExists { removeItem }` → `try? removeItem` (ignore ENOENT) +- `if fileExists { moveItem }` → `try moveItem` in do/catch +- `if !fileExists { createDirectory }` → `createDirectory(withIntermediateDirectories: true)` +- Leave valid uses: display-only warnings, migration gates, cache hit checks + +**3c. DispatchQueue → @MainActor** +For `DispatchQueue.main.async { }` calls: +- If inside an async context: replace with `await MainActor.run { }` or mark the closure `@MainActor` +- If the containing function should be main-actor-isolated: add `@MainActor` to the function +- Be careful with this one — only change if you're confident the surrounding code supports it +- Skip if the DispatchQueue usage is for intentional delayed execution or debouncing + +**Build and verify after Tier 3.** + +--- + +### Tier 4: Design System Token Compliance + +**This tier is the largest. Break into batches of 8-10 files, building between each.** + +Before starting, read the project's design system / theme file to understand available tokens. Search for files like `*Theme.swift`, `*Tokens.swift`, `DS.swift`, `DesignSystem/` directory. + +**4a. Hardcoded Font Sizes** +- `.font(.system(size: 14))` → `.font(AppTheme.Typography.body)` (or the project's equivalent token) +- Map common sizes to the nearest semantic token +- If no matching token exists, add one to the theme file first, then use it + +**4b. Hardcoded Colors** +- `Color(.red)` or `Color(nsColor: ...)` → `AppTheme.Colors.error` (or equivalent) +- `NSColor(calibratedRed: ...)` → theme token +- Leave system semantic colors that are already correct (e.g., `.primary`, `.secondary`) +- Leave colors defined in the theme file itself + +**4c. Hardcoded Spacing** +- `.padding(8)` → `.padding(AppTheme.Spacing.sm)` (or equivalent) +- `.padding(.horizontal, 16)` → `.padding(.horizontal, AppTheme.Spacing.md)` +- Map numeric values to nearest token: 4→xxs, 8→xs/sm, 12→sm, 16→md, 20→lg, 24→xl, 32→xxl +- If the project's tokens use different names, use those + +**4d. Hardcoded Corner Radii** +- `.cornerRadius(8)` → `AppTheme.CornerRadius.sm` (or equivalent) +- `RoundedRectangle(cornerRadius: 12)` → `RoundedRectangle(cornerRadius: AppTheme.CornerRadius.md)` + +**4e. Hardcoded Shadows & Animations** +- `.shadow(radius: 4)` → `.themeShadow(.subtle)` (or equivalent modifier) +- `.animation(.easeInOut(duration: 0.3))` → `.animation(AppTheme.Animation.standard)` (or equivalent) + +**Build and verify after each batch of 8-10 files in Tier 4.** + +--- + +### Tier 5: Structural Improvements (Large Files & @Query Migration) + +**This tier involves the most complex changes. One file at a time, build after each.** + +**5a. Oversized Files (>800 lines for views, >1000 for services)** +For each oversized file from the audit: +- Identify logical sections that can be extracted into separate files +- Create new files with extracted views/helpers +- Use `@MainActor enum HelperName` with static methods for extracted logic +- Use separate `struct SubViewName: View` for extracted views +- Keep the parent file as the coordinator, passing state via bindings and callbacks +- Follow the state ownership matrix from `09-performance.md`: + - Component owns: pagination, view preferences, selection, loading state + - Parent owns: navigation, global search, preview state + - Pass as bindings: shared filters + - Pass as callbacks: navigation actions + +**5b. @Query → DataStoreActor Migration** +This is an architectural change — only do if the project already has a DataStoreActor pattern established: +- Replace `@Query var items: [Entity]` with a DataStoreActor-based fetch +- Add `.task { await loadItems() }` to trigger the fetch +- Store results in `@State private var items: [Entity] = []` +- If the project doesn't have DataStoreActor infrastructure, skip this and note it + +**Build and verify after each file in Tier 5.** + +--- + +### Final Verification + +After all tiers are complete: +1. Run a full build: `xcodebuild -scheme {SCHEME} -destination 'platform=macOS' build` +2. Run tests if available: `xcodebuild test -scheme {SCHEME} -destination 'platform=macOS'` +3. Re-run the audit searches from the original audit to get updated violation counts +4. Update the project's instruction file — change the "Known Audit Gaps" table with new counts (hopefully zeros or much lower) +5. Report a before/after comparison table + +--- + +### Rules + +- **Always build between tiers** — do not proceed if the build is broken +- **Never change test files** unless they have print() statements (Tier 2a only) +- **Never change #Preview blocks** +- **Never change files in build/, .build/, SourcePackages/, Pods/** +- **Preserve existing behavior** — these are refactors, not feature changes +- **When uncertain about a change, skip it** and note it in the report rather than risk breaking something +- **Add imports** (`import os`, `import OSLog`) only when needed for Logger +- **Do not refactor surrounding code** — only fix the specific violation pattern +- **If a file has multiple violation types, fix them all in one pass** to avoid repeated reads/writes +- **Keep changes minimal** — the goal is compliance, not improvement diff --git a/scarf/standards/INDEX.md b/scarf/standards/INDEX.md new file mode 100644 index 0000000..b7a9d8e --- /dev/null +++ b/scarf/standards/INDEX.md @@ -0,0 +1,65 @@ +# macOS App Suite — Centralized Standards + +**Version**: 1.0 +**Last Updated**: 2026-03-26 +**Applies To**: InControl, ShabuBox, Threader, Modeler, and all future macOS apps in the suite + +--- + +## Standards Files + +| # | File | Description | +|---|------|-------------| +| 01 | [architecture.md](01-architecture.md) | MVVM-F pattern, AppCoordinator, directory layout (single-platform and multiplatform) | +| 02 | [swiftdata.md](02-swiftdata.md) | SwiftData persistence, schema versioning, Skeleton Records, query patterns, data modeling conventions | +| 03 | [storage-and-sandboxing.md](03-storage-and-sandboxing.md) | iCloud storage, NSFileCoordinator, TOCTOU prevention, sandboxing, path security | +| 04 | [swift-conventions.md](04-swift-conventions.md) | Swift 6 concurrency, os.Logger standard, error handling, file size limits, anti-patterns | +| 05 | [design-system.md](05-design-system.md) | Visual design principles, material tiers, typography, spacing, layout, components, checklists | +| 06 | [editor-patterns.md](06-editor-patterns.md) | Tabbed editor architecture, form components, Quick Entry, completion checklists | +| 07 | [ai-integration.md](07-ai-integration.md) | Native-first AI, LLM protocol layer, pipeline architecture, prompt injection defense | +| 08 | [data-integrity.md](08-data-integrity.md) | Backup/restore parity, migration rollback contracts, CloudKit sync safety | +| 09 | [performance.md](09-performance.md) | Component extraction pattern, state ownership, view complexity, memory management | +| 10 | [testing.md](10-testing.md) | Swift Testing framework, protocol mocking, timing rules, cancellation testing | +| 11 | [multiplatform.md](11-multiplatform.md) | iOS companion apps, shared libraries, CloudKit sync, platform-adaptive UI | +| -- | [AUDIT_CHECKLIST.md](AUDIT_CHECKLIST.md) | Per-project audit template (~70 checkboxes) for gap detection | + +--- + +## Project Conformance Matrix + +| Standard | InControl | ShabuBox | Threader | Modeler | +|----------|:---------:|:--------:|:--------:|:-------:| +| 01 Architecture | Good | Good | Good | Good | +| 02 SwiftData | Good | Partial | Good | Basic | +| 03 Storage/Sandboxing | Partial | Best | Good | N/A | +| 04 Swift Conventions | Partial | Good | Best | Good | +| 05 Design System | Best (principles) | Best (tokens) | Minimal | Minimal | +| 06 Editor Patterns | Best | Gap | Gap | Gap | +| 07 AI Integration | Good | Best | Good | Different | +| 08 Data Integrity | Gap | Best | Gap | Gap | +| 09 Performance | Gap | Best | Gap | Gap | +| 10 Testing | Basic | Good | Implicit | Basic | +| 11 Multiplatform | Ready | Best | Partial | N/A | + +**Legend**: Best = reference implementation | Good = compliant | Partial = some coverage | Gap = not documented | N/A = not applicable + +--- + +## How to Use These Standards + +### For New Projects +Add this to your project's `CLAUDE.md`: +```markdown +## Standards +This project follows the centralized standards at `/Users/awizemann/Developer/standards/`. +See INDEX.md for the full list. Project-specific details below. +``` + +### For Existing Projects +1. Run the audit checklist against your project +2. Identify gaps +3. Update project CLAUDE.md to reference standards and remove duplicated rules +4. Keep only project-specific content (PRD, screen layouts, key file paths, critical lessons) + +### Backups +Pre-edit snapshots of all modified project files are stored in `backups/{ProjectName}/{YYYY-MM-DD}/`.