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) <noreply@anthropic.com>
This commit is contained in:
Alan Wizemann
2026-03-31 02:30:04 -04:00
commit 18278a3357
67 changed files with 7076 additions and 0 deletions
+28
View File
@@ -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:
+19
View File
@@ -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.
+45
View File
@@ -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/
+40
View File
@@ -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
```
+49
View File
@@ -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`
+21
View File
@@ -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.
+105
View File
@@ -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)
+111
View File
@@ -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
```
+183
View File
@@ -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.
+97
View File
@@ -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
+604
View File
@@ -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 = "<group>";
};
534959522F7B83B700BD31AD /* scarfTests */ = {
isa = PBXFileSystemSynchronizedRootGroup;
path = scarfTests;
sourceTree = "<group>";
};
5349595C2F7B83B700BD31AD /* scarfUITests */ = {
isa = PBXFileSystemSynchronizedRootGroup;
path = scarfUITests;
sourceTree = "<group>";
};
/* 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 = "<group>";
};
534959412F7B83B600BD31AD /* Products */ = {
isa = PBXGroup;
children = (
534959402F7B83B600BD31AD /* scarf.app */,
5349594F2F7B83B700BD31AD /* scarfTests.xctest */,
534959592F7B83B700BD31AD /* scarfUITests.xctest */,
);
name = Products;
sourceTree = "<group>";
};
/* 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 */;
}
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<Workspace
version = "1.0">
<FileRef
location = "self:">
</FileRef>
</Workspace>
@@ -0,0 +1,11 @@
{
"colors" : [
{
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
@@ -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
}
}
@@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}
+37
View File
@@ -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()
}
}
}
@@ -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?
}
@@ -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"
}
@@ -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"
}
}
+121
View File
@@ -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"
}
}
}
@@ -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"
}
}
}
+15
View File
@@ -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]
}
@@ -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)
}
}
@@ -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..<colonIdx]).trimmingCharacters(in: .whitespaces)
let val = String(trimmed[trimmed.index(after: colonIdx)...]).trimmingCharacters(in: .whitespaces)
values[currentSection + "." + key] = val
}
}
return HermesConfig(
model: values["model.default"] ?? "unknown",
provider: values["model.provider"] ?? "unknown",
maxTurns: Int(values["agent.max_turns"] ?? "") ?? 0,
personality: values["display.personality"] ?? "default",
terminalBackend: values["terminal.backend"] ?? "local",
memoryEnabled: values["memory.memory_enabled"] == "true",
memoryCharLimit: Int(values["memory.memory_char_limit"] ?? "") ?? 0,
userCharLimit: Int(values["memory.user_char_limit"] ?? "") ?? 0,
nudgeInterval: Int(values["memory.nudge_interval"] ?? "") ?? 0,
streaming: values["display.streaming"] != "false",
showReasoning: values["display.show_reasoning"] == "true",
verbose: values["agent.verbose"] == "true"
)
}
// MARK: - Gateway State
func loadGatewayState() -> 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)
}
}
@@ -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()
}
}
@@ -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)
}
}
@@ -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
}
}
@@ -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)
}
}
@@ -0,0 +1,10 @@
import Foundation
@Observable
final class ChatViewModel {
var sessionId = UUID()
var hermesBinaryExists: Bool {
FileManager.default.fileExists(atPath: HermesPaths.hermesBinary)
}
}
@@ -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)")
)
}
}
}
@@ -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")
}
}
}
@@ -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)
}
}
@@ -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)
}
}
}
@@ -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
}
}
@@ -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)
}
}
@@ -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()
}
}
@@ -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
}
}
}
@@ -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
}
}
@@ -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)
}
}
@@ -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()
}
}
@@ -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
}
}
}
@@ -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)
}
}
}
@@ -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)) ?? ""
}
}
@@ -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)
}
}
}
@@ -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)
}
}
@@ -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)
}
}
}
@@ -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?
}
+31
View File
@@ -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")
}
}
+89
View File
@@ -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")
}
}
}
+17
View File
@@ -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.
}
}
+41
View File
@@ -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 its 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()
}
}
}
@@ -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)
}
}
+232
View File
@@ -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.
+328
View File
@@ -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<Task>]] = [
[\.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<Task> { $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<Task>())
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<Item>(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))
```
@@ -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 `<s>`, `<bos>`, 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
+150
View File
@@ -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.<appname>.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>.app", category: "ClassName")` | `logger` |
| Struct or SwiftUI view | `private static let logger = Logger(subsystem: "com.<app>.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>.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 |
+508
View File
@@ -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/)
+255
View File
@@ -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<Entity>
.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.
+164
View File
@@ -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<String, Error>
}
```
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 (`<s>`, `<bos>`, `</s>`, `[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
+87
View File
@@ -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
+159
View File
@@ -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.
+140
View File
@@ -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.
+274
View File
@@ -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
<key>com.apple.developer.icloud-container-identifiers</key>
<array>
<string>iCloud.com.yourapp.identifier</string>
</array>
<key>com.apple.developer.icloud-services</key>
<array>
<string>CloudKit</string>
<string>CloudDocuments</string>
</array>
```
### Entitlements (iOS)
```xml
<key>com.apple.developer.icloud-container-identifiers</key>
<array>
<string>iCloud.com.yourapp.identifier</string>
</array>
<key>com.apple.developer.icloud-services</key>
<array>
<string>CloudKit</string>
<string>CloudDocuments</string>
</array>
<key>com.apple.developer.aps-environment</key> <!-- NOT "aps-environment" -->
<string>development</string>
```
**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
<key>UIBackgroundModes</key>
<array>
<string>remote-notification</string>
<string>fetch</string>
</array>
```
### 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.
+166
View File
@@ -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.<appname>.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)
@@ -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.<appname>.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
+210
View File
@@ -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.<appname>.app", category: "TypeName")`
- Structs/views: `private static let logger = Logger(subsystem: "com.<appname>.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
+65
View File
@@ -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}/`.