summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorsoryu-co <bot@soryu.co>2026-04-24 16:45:51 +0000
committersoryu-co <bot@soryu.co>2026-04-24 16:45:51 +0000
commit105730ceaa292b1e3589c23d5aad8f35ccf04b8e (patch)
tree6cf0c05e911cf62e3cd102377a4e24876b2cb064
parent8f8e3b54cbecf51ce6a87e9e028caaad428879be (diff)
downloadsoryu-105730ceaa292b1e3589c23d5aad8f35ccf04b8e.tar.gz
soryu-105730ceaa292b1e3589c23d5aad8f35ccf04b8e.zip
Makima iOS M3-M8 — Home, Tasks, WebSocket, notifications, polish
Extends the M2 auth scaffold with the rest of the v1 surface area. All milestones from docs/ios-v1-plan.md land here. M3 — Composite Home dashboard - HomeStore (@Observable): parallel fetch of contracts, daemons, directives, tasks, latest listen session. Each card degrades independently on error. - HomeView: NavigationStack routing to Contracts, Tasks, Daemons, Directives, Listen, and per-item detail views. Pull-to-refresh. - HomeCards: SectionCard chrome + Contracts/Daemons/Directives/Listen/ RecentTasks cards with StatusDot, CountPill, relative-time helpers. M4 — Contracts + Tasks - ContractsListView: filter (active/completed/archived/all), sorted by updatedAt. - ContractDetailView: phase card + per-contract tasks. - TasksListView: status filter pills. - TaskDetailView: metadata card, completion-gate card (rendered only when <COMPLETION_GATE> present in output), output card that merges polled body with live WS events. Polls every 8s as a fallback when WS is offline. M5 — WebSocket + markdown/code rendering - TaskWebSocket: URLSessionWebSocketTask wrapper for /api/v1/mesh/tasks/subscribe. Wire messages match mesh_ws.rs (subscribeAll / subscribe / subscribeOutput / taskUpdated / taskOutput / error). Exponential-backoff reconnect (1s -> 30s cap), status callback feeds the masthead WS pill. - CompletionGate: parses <COMPLETION_GATE>…</COMPLETION_GATE> into {ready, reason, progress, blockers}. CompletionGateView renders it as a status card with dashed accent border (ok/warn tint). - MarkdownBlocks: hand-rolled split-on-fences renderer. Prose blocks via AttributedString(markdown:), code blocks via a monospaced, horizontally- scrollable CodeBlockView with optional language chip. - TaskOutputRenderer: merges polled body + streamed LIVE// events with per-kind colour (assistant / tool_use / tool_result / error). M6 — Directives, Daemons, Listen (read-only) - DirectivesListView: status dot (pending -> warn), goal preview. Notes that answering questions is web-only in v1. - DaemonsListView: online/total, heartbeat relative time, task concurrency. - ListenHistoryView: session preview list; tolerates 404 if the endpoint isn't deployed on the user's instance. Notes that live listen is web-only in v1. - ScreenShell: shared chrome (back chevron, title, WS pill, grid bg) for all detail screens. M7 — Notifications + deep links - NotificationCenterBridge: requests .alert/.badge/.sound auth on first launch; fires local notifications on task-done/failed/blocked and directive-question events with a makima:// deepLink in userInfo. - DeepLink enum: parses makima://task/<uuid>, makima://contract/<uuid>, makima://directive/<uuid>. Wired in MakimaApp via .onOpenURL; AppState holds a pendingDeepLink for consumers. - URL scheme registered in project.yml's CFBundleURLTypes. M8 — Polish - RELEASING.md: pre-flight checklist, xcodebuild archive/export flow, ExportOptions.plist template, App Store submission checklist. - README: full 'what's implemented' matrix, architecture overview, deep-link reference, privacy statement. M2 bug fixes rolled in - ListEnvelope<Item>: generic decoder for the daemons/tasks/contracts/ directives/orders wrapper endpoints ({items:[...], total:N}). Falls back to a bare array for resilience. - AuthStore: now uses ListEnvelope<Daemon> for the probe (was bare array). - rotateKey: accepts both apiKey and api_key casings in the response. - Logo: adds missing import UIKit. Tests added - CompletionGateTests: parse ready=true, blockers list, nil on missing. - MarkdownBlocksTests: prose/code splitting, pure prose. - DeepLinkTests: task/directive parsing, wrong-scheme rejection, unknown host rejection. - ListEnvelopeTests: daemons wrapper, tasks wrapper, bare-array fallback. - APIClientTests: updated stub to return the envelope shape. Not wired (deferred to v1.1 per plan) - Supabase OAuth (Authorization: Bearer path) - APNs push via services/push-proxy/ - Answer directive questions in-app - Live Listen with mic + transcription - Speak (TTS) - Files editor, mesh merge UI, worktree diff viewer, daemon reauth flow File count: 34 new/modified, ~2670 LOC.
-rw-r--r--makima/ios/README.md68
-rw-r--r--makima/ios/Sources/Makima/App/AppState.swift43
-rw-r--r--makima/ios/Sources/Makima/App/MakimaApp.swift21
-rw-r--r--makima/ios/Sources/Makima/App/RootView.swift115
-rw-r--r--makima/ios/Sources/Makima/Design/Components/Logo.swift1
-rw-r--r--makima/ios/Sources/Makima/Design/Components/ScreenShell.swift88
-rw-r--r--makima/ios/Sources/Makima/Features/Contracts/ContractDetailView.swift80
-rw-r--r--makima/ios/Sources/Makima/Features/Contracts/ContractsListView.swift79
-rw-r--r--makima/ios/Sources/Makima/Features/Daemons/DaemonsListView.swift61
-rw-r--r--makima/ios/Sources/Makima/Features/Directives/DirectivesListView.swift64
-rw-r--r--makima/ios/Sources/Makima/Features/Home/HomeCards.swift352
-rw-r--r--makima/ios/Sources/Makima/Features/Home/HomeView.swift136
-rw-r--r--makima/ios/Sources/Makima/Features/Listen/ListenHistoryView.swift68
-rw-r--r--makima/ios/Sources/Makima/Features/Tasks/TaskDetailView.swift166
-rw-r--r--makima/ios/Sources/Makima/Features/Tasks/TaskOutputRenderer.swift84
-rw-r--r--makima/ios/Sources/Makima/Features/Tasks/TasksListView.swift76
-rw-r--r--makima/ios/Sources/Makima/Markdown/CompletionGate.swift47
-rw-r--r--makima/ios/Sources/Makima/Markdown/CompletionGateView.swift61
-rw-r--r--makima/ios/Sources/Makima/Markdown/MarkdownRenderer.swift123
-rw-r--r--makima/ios/Sources/Makima/Models/Contract.swift21
-rw-r--r--makima/ios/Sources/Makima/Models/Daemon.swift27
-rw-r--r--makima/ios/Sources/Makima/Models/Directive.swift16
-rw-r--r--makima/ios/Sources/Makima/Models/ListEnvelope.swift31
-rw-r--r--makima/ios/Sources/Makima/Models/ListenSession.swift16
-rw-r--r--makima/ios/Sources/Makima/Models/Task.swift51
-rw-r--r--makima/ios/Sources/Makima/Net/AuthStore.swift39
-rw-r--r--makima/ios/Sources/Makima/Net/NotificationCenterBridge.swift60
-rw-r--r--makima/ios/Sources/Makima/Net/TaskWebSocket.swift242
-rw-r--r--makima/ios/Sources/Makima/Stores/HomeStore.swift65
-rw-r--r--makima/ios/Tests/MakimaTests/APIClientTests.swift6
-rw-r--r--makima/ios/Tests/MakimaTests/CompletionGateTests.swift38
-rw-r--r--makima/ios/Tests/MakimaTests/DeepLinkTests.swift23
-rw-r--r--makima/ios/Tests/MakimaTests/ListEnvelopeTests.swift27
-rw-r--r--makima/ios/Tests/MakimaTests/MarkdownBlocksTests.swift30
-rw-r--r--makima/ios/docs/RELEASING.md75
35 files changed, 2336 insertions, 164 deletions
diff --git a/makima/ios/README.md b/makima/ios/README.md
index 2a6a664..e4b0035 100644
--- a/makima/ios/README.md
+++ b/makima/ios/README.md
@@ -2,25 +2,71 @@
Native iPhone client for [Makima](https://makima.jp) — distributed mesh listening and AI daemon orchestration platform by Soryu LTD.
-**Status:** pre-alpha (M0 scaffold). See [`docs/DEVELOPMENT.md`](docs/DEVELOPMENT.md).
-
-**Plan:** see `/root/notes/makima/ios-v1-plan.md` on the infra host, or the mirrored copy in `docs/PLAN.md` once synced.
-
## Quick start
```bash
cd makima/ios
-make bootstrap # installs xcodegen if missing
-make ios-sim-fast # builds for iPhone 16 Pro simulator
+make bootstrap # installs xcodegen + xcbeautify
+make ios-sim-fast # builds and launches iPhone 16 Pro simulator
```
-Xcode 16+, iOS 18+ target, Swift 5.10+, pure SwiftUI. No third-party packages at M0/M1.
+Xcode 16+, iOS 18+ target, Swift 5.10+. No third-party packages.
+
+## First launch
+
+1. Enter a server URL (default `https://makima.jp`).
+2. Tap **Open Web Settings** → create an API key on the server.
+3. Paste the `mk_…` key back. The app validates via `GET /mesh/daemons` then takes you to Home.
+
+Keychain stores the API key per server profile (`kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly`). Change server or sign out any time from Settings.
+
+## What's implemented
+
+| Surface | Status |
+|---|---|
+| Onboarding (URL + key paste) | ✓ |
+| Composite Home (Contracts · Daemons · Directives · Listen · Recent Tasks) | ✓ |
+| Contracts list + detail with tasks | ✓ |
+| Tasks list + detail with live output | ✓ |
+| Task output livestream (`/mesh/tasks/subscribe` WebSocket) | ✓ |
+| Markdown + code-block rendering | ✓ |
+| Completion Gate card | ✓ |
+| Directives / Daemons / Listen read-only | ✓ |
+| Local notifications (task done / failed / blocked, directive question) | ✓ |
+| `makima://` deep links (task / directive / contract) | ✓ |
+| Supabase OAuth sign-in | v1.1 |
+| APNs push notifications | v1.1 (see `services/push-proxy/`) |
+| Answer directive questions from the app | v1.1 |
+| Live Listen (mic + transcription) | v1.1 |
## Architecture
-Pure native SwiftUI with `@Observable` stores, `URLSession` networking, `URLSessionWebSocketTask` for task livestream, SwiftData for local cache, Keychain for the `x-makima-api-key` credential.
+Pure SwiftUI with `@Observable` stores, `URLSession` for HTTP,
+`URLSessionWebSocketTask` for the task livestream, SwiftData-free (UserDefaults + Keychain only), zero third-party packages.
+
+- `Sources/Makima/Net/` — APIClient, Keychain, ServerProfile + Store, AuthStore, TaskWebSocket, NotificationCenterBridge
+- `Sources/Makima/Stores/` — HomeStore (+ others as they land)
+- `Sources/Makima/Features/` — screen-level views grouped by surface
+- `Sources/Makima/Models/` — Codable types matching Makima's camelCase schema
+- `Sources/Makima/Design/` — Palette, Typography, reusable components
+- `Sources/Makima/Markdown/` — MarkdownBlocks, CodeBlockView, CompletionGate(+View)
+- `Sources/Makima/App/` — entry point, router, AppState
+
+## Auth
+
+Uses the custom `x-makima-api-key: mk_…` header on every HTTP request and the
+WebSocket upgrade (verified against `makima/src/server/auth.rs`).
+`Authorization: Bearer` is reserved for the Supabase OAuth flow in v1.1.
+
+## Deep links
+
+The app registers `makima://` and handles three destinations:
+
+- `makima://task/<uuid>` — Task detail
+- `makima://contract/<uuid>` — Contract detail
+- `makima://directive/<uuid>` — Directives list (item-level deep link lands with answer support in v1.1)
-No cross-platform core, no React Native, no Expo. See `../../README.md` for the wider monorepo.
+Local notifications fired from WebSocket events include the matching deep link in `userInfo.deepLink`.
## License
@@ -28,4 +74,6 @@ MIT — see repo-root `LICENSE`.
## Privacy
-No analytics. No telemetry. The app talks directly to the Makima server URL you configure — by default `https://makima.jp`, but any reachable instance works (`Settings → Server URL`). Credentials are stored in iOS Keychain, scoped per server URL. Full privacy policy: this README.
+No analytics. No telemetry. No hosted relay. The app talks directly to the
+Makima server URL you configure. Credentials live in iOS Keychain, scoped
+per server URL, never leave the device. Full privacy policy: this README.
diff --git a/makima/ios/Sources/Makima/App/AppState.swift b/makima/ios/Sources/Makima/App/AppState.swift
index acc3060..806d7d0 100644
--- a/makima/ios/Sources/Makima/App/AppState.swift
+++ b/makima/ios/Sources/Makima/App/AppState.swift
@@ -1,23 +1,50 @@
import SwiftUI
+import Combine
-/// Top-level state bag shared through the SwiftUI environment.
@MainActor
@Observable
final class AppState {
let auth: AuthStore
+ var wsStatus: WebSocketStatus = .idle
+ var webSocket: TaskWebSocket?
+
+ /// Pending deep-link to open once the app has finished routing.
+ var pendingDeepLink: DeepLink?
init(auth: AuthStore = AuthStore()) {
self.auth = auth
}
-}
-private struct AppStateKey: EnvironmentKey {
- static let defaultValue: AppState = AppState()
+ // Lazily create the websocket. Lives for the lifetime of the session.
+ func ensureWebSocket() {
+ guard webSocket == nil, let client = auth.client else { return }
+ let ws = TaskWebSocket(profile: client.profile, apiKey: client.apiKey)
+ ws.onStatusChange = { [weak self] s in Task { @MainActor in self?.wsStatus = s } }
+ ws.connect()
+ webSocket = ws
+ }
+
+ func teardownWebSocket() {
+ webSocket?.disconnect()
+ webSocket = nil
+ wsStatus = .idle
+ }
}
-extension EnvironmentValues {
- var appState: AppState {
- get { self[AppStateKey.self] }
- set { self[AppStateKey.self] = newValue }
+enum DeepLink: Hashable {
+ case task(id: String)
+ case directive(id: String)
+ case contract(id: String)
+
+ init?(url: URL) {
+ guard url.scheme == "makima", let host = url.host else { return nil }
+ let id = url.pathComponents.dropFirst().first ?? ""
+ guard !id.isEmpty else { return nil }
+ switch host {
+ case "task": self = .task(id: id)
+ case "directive": self = .directive(id: id)
+ case "contract": self = .contract(id: id)
+ default: return nil
+ }
}
}
diff --git a/makima/ios/Sources/Makima/App/MakimaApp.swift b/makima/ios/Sources/Makima/App/MakimaApp.swift
index e0209b2..761b3c0 100644
--- a/makima/ios/Sources/Makima/App/MakimaApp.swift
+++ b/makima/ios/Sources/Makima/App/MakimaApp.swift
@@ -2,7 +2,11 @@ import SwiftUI
@main
struct MakimaApp: App {
- @State private var appState = AppState()
+ @State private var appState: AppState
+
+ init() {
+ _appState = State(initialValue: MainActor.assumeIsolated { AppState() })
+ }
var body: some Scene {
WindowGroup {
@@ -11,6 +15,21 @@ struct MakimaApp: App {
.environment(appState.auth)
.preferredColorScheme(.dark)
.tint(Palette.accent)
+ .onOpenURL { url in
+ if let link = DeepLink(url: url) {
+ appState.pendingDeepLink = link
+ }
+ }
+ .task {
+ await NotificationCenterBridge.requestAuthorizationIfNeeded()
+ }
+ .onChange(of: appState.auth.state) { _, newState in
+ if newState == .authenticated {
+ appState.ensureWebSocket()
+ } else {
+ appState.teardownWebSocket()
+ }
+ }
}
}
}
diff --git a/makima/ios/Sources/Makima/App/RootView.swift b/makima/ios/Sources/Makima/App/RootView.swift
index 7d22c1a..a7665e1 100644
--- a/makima/ios/Sources/Makima/App/RootView.swift
+++ b/makima/ios/Sources/Makima/App/RootView.swift
@@ -1,14 +1,13 @@
import SwiftUI
/// Top-level router: Onboarding until we have a validated ServerProfile + API
-/// key, then Home (placeholder in M2; real Home ships at M3).
+/// key, then Home.
struct RootView: View {
@Environment(AuthStore.self) private var auth
var body: some View {
ZStack {
Palette.background.ignoresSafeArea()
- GridOverlay()
switch auth.state {
case .needsOnboarding, .error:
@@ -18,7 +17,7 @@ struct RootView: View {
ValidatingView()
.transition(.opacity)
case .authenticated:
- HomePlaceholderView()
+ HomeView()
.transition(.opacity)
}
}
@@ -26,108 +25,6 @@ struct RootView: View {
}
}
-// MARK: - Home placeholder (real Home arrives at M3)
-
-struct HomePlaceholderView: View {
- @Environment(AuthStore.self) private var auth
- @State private var showSettings = false
-
- var body: some View {
- VStack(spacing: 0) {
- MastheadBar(
- serverLabel: auth.client?.profile.label ?? ServerProfile.defaultLabel,
- wsStatus: .idle,
- version: (Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String) ?? "0.0.0"
- )
- NavStripPlaceholder()
-
- ScrollView {
- VStack(spacing: 24) {
- Spacer(minLength: 24)
- Logo(size: 120)
-
- VStack(spacing: 6) {
- Badge(text: "支配する", subtitle: "CONTROL SYSTEM")
- Text("Authenticated")
- .font(Typography.titleChrome)
- .foregroundStyle(Palette.foreground)
- .tracking(1)
- Text("Home arrives at M3. Settings below.")
- .font(Typography.body)
- .foregroundStyle(Palette.foregroundMuted)
- }
-
- sessionCard
- .padding(.horizontal, 16)
-
- Button {
- showSettings = true
- } label: {
- Text("OPEN SETTINGS")
- .font(Typography.navLabel)
- .tracking(1)
- .padding(.horizontal, 18)
- .padding(.vertical, 10)
- .frame(maxWidth: .infinity)
- .background(Palette.panel)
- .foregroundStyle(Palette.accent)
- .overlay(Rectangle().strokeBorder(Palette.border, lineWidth: 1))
- }
- .padding(.horizontal, 16)
-
- Spacer(minLength: 40)
- }
- .padding(.top, 12)
- }
- }
- .sheet(isPresented: $showSettings) {
- SettingsView().environment(auth)
- }
- }
-
- private var sessionCard: some View {
- VStack(alignment: .leading, spacing: 10) {
- HStack {
- Text("SESSION//")
- .font(Typography.navLabel)
- .foregroundStyle(Palette.foregroundMuted)
- Spacer()
- Text("OK")
- .font(Typography.navLabel)
- .foregroundStyle(Palette.ok)
- }
- Divider().overlay(Palette.borderMuted)
-
- row("Server", auth.client?.profile.baseURLString ?? "—")
- row("Profile", auth.client?.profile.label ?? "—")
- row("Key", maskedKey(auth.client?.apiKey))
- }
- .padding(14)
- .dashedBorder()
- }
-
- private func row(_ label: String, _ value: String) -> some View {
- HStack {
- Text(label)
- .font(Typography.body)
- .foregroundStyle(Palette.foreground)
- Spacer()
- Text(value)
- .font(Typography.mono)
- .foregroundStyle(Palette.foregroundMuted)
- .lineLimit(1)
- .truncationMode(.middle)
- }
- }
-
- private func maskedKey(_ key: String?) -> String {
- guard let key, key.count > 6 else { return "—" }
- let prefix = key.prefix(4)
- let suffix = key.suffix(4)
- return "\(prefix)…\(suffix)"
- }
-}
-
struct ValidatingView: View {
var body: some View {
VStack(spacing: 16) {
@@ -144,11 +41,3 @@ struct ValidatingView: View {
}
}
}
-
-#Preview("Home placeholder") {
- let state = AppState()
- return HomePlaceholderView()
- .environment(state)
- .environment(state.auth)
- .preferredColorScheme(.dark)
-}
diff --git a/makima/ios/Sources/Makima/Design/Components/Logo.swift b/makima/ios/Sources/Makima/Design/Components/Logo.swift
index 01d263e..98cda9b 100644
--- a/makima/ios/Sources/Makima/Design/Components/Logo.swift
+++ b/makima/ios/Sources/Makima/Design/Components/Logo.swift
@@ -1,4 +1,5 @@
import SwiftUI
+import UIKit
/// Makima concentric-ring logo.
/// Loads from bundled `makima-logo.svg` if present; otherwise falls back to
diff --git a/makima/ios/Sources/Makima/Design/Components/ScreenShell.swift b/makima/ios/Sources/Makima/Design/Components/ScreenShell.swift
new file mode 100644
index 0000000..41f17bb
--- /dev/null
+++ b/makima/ios/Sources/Makima/Design/Components/ScreenShell.swift
@@ -0,0 +1,88 @@
+import SwiftUI
+
+/// Consistent screen chrome: background + grid + masthead + content scroll.
+/// Shows a back chevron when inside a navigation stack.
+struct ScreenShell<Content: View>: View {
+ let title: String
+ @ViewBuilder let content: () -> Content
+ @Environment(\.dismiss) private var dismiss
+ @Environment(AppState.self) private var appState
+
+ var body: some View {
+ ZStack {
+ Palette.background.ignoresSafeArea()
+ GridOverlay()
+ VStack(spacing: 0) {
+ HStack(spacing: 10) {
+ Button { dismiss() } label: {
+ Image(systemName: "chevron.left")
+ .foregroundStyle(Palette.accent)
+ .padding(8)
+ .background(Palette.panel)
+ .overlay(Rectangle().strokeBorder(Palette.border, lineWidth: 1))
+ }
+ .accessibilityLabel("Back")
+
+ Text(title)
+ .font(.system(.caption, design: .monospaced).weight(.semibold))
+ .tracking(2)
+ .foregroundStyle(Palette.foreground)
+ .lineLimit(1)
+ Spacer()
+ Text("WS//\(appState.wsStatus.label.replacingOccurrences(of: "WS//", with: ""))")
+ .font(Typography.navLabel)
+ .foregroundStyle(appState.wsStatus.color)
+ }
+ .padding(.horizontal, 14)
+ .padding(.vertical, 10)
+ .background(Palette.backgroundDeep)
+ .overlay(alignment: .bottom) {
+ Rectangle().fill(Palette.border).frame(height: 2)
+ }
+
+ ScrollView { content() }
+ }
+ }
+ .toolbar(.hidden, for: .navigationBar)
+ }
+}
+
+struct KeyValue: View {
+ let label: String
+ let value: String
+
+ var body: some View {
+ HStack {
+ Text(label)
+ .font(Typography.navLabel)
+ .foregroundStyle(Palette.foregroundMuted)
+ Spacer()
+ Text(value)
+ .font(Typography.mono)
+ .foregroundStyle(Palette.foreground)
+ .lineLimit(1)
+ .truncationMode(.middle)
+ }
+ }
+}
+
+struct ErrorBanner: View {
+ let message: String
+
+ var body: some View {
+ HStack(alignment: .top, spacing: 8) {
+ Rectangle().fill(Palette.danger).frame(width: 3)
+ VStack(alignment: .leading, spacing: 4) {
+ Text("ERROR//")
+ .font(Typography.navLabel)
+ .foregroundStyle(Palette.danger)
+ Text(message)
+ .font(Typography.body)
+ .foregroundStyle(Palette.body)
+ }
+ }
+ .padding(10)
+ .background(Palette.panel)
+ .overlay(Rectangle().strokeBorder(Palette.danger.opacity(0.4), lineWidth: 1))
+ }
+}
diff --git a/makima/ios/Sources/Makima/Features/Contracts/ContractDetailView.swift b/makima/ios/Sources/Makima/Features/Contracts/ContractDetailView.swift
new file mode 100644
index 0000000..b61516b
--- /dev/null
+++ b/makima/ios/Sources/Makima/Features/Contracts/ContractDetailView.swift
@@ -0,0 +1,80 @@
+import SwiftUI
+
+struct ContractDetailView: View {
+ @Environment(AuthStore.self) private var auth
+ let contract: Contract
+
+ @State private var tasks: [MakimaTask] = []
+ @State private var loading = false
+ @State private var errorMessage: String?
+
+ var body: some View {
+ ScreenShell(title: contract.name.uppercased()) {
+ VStack(spacing: 16) {
+ phaseCard
+ tasksCard
+ }
+ .padding(16)
+ }
+ .task { await reload() }
+ .refreshable { await reload() }
+ }
+
+ private var phaseCard: some View {
+ SectionCard(title: "PHASE") {
+ VStack(alignment: .leading, spacing: 8) {
+ KeyValue(label: "Phase", value: contract.phase.uppercased())
+ KeyValue(label: "Status", value: contract.status.uppercased())
+ KeyValue(label: "Type", value: contract.contractType)
+ if let desc = contract.description, !desc.isEmpty {
+ Divider().overlay(Palette.borderMuted)
+ Text(desc)
+ .font(Typography.body)
+ .foregroundStyle(Palette.body)
+ }
+ }
+ }
+ }
+
+ private var tasksCard: some View {
+ SectionCard(title: "TASKS",
+ trailing: {
+ Text("\(tasks.count)")
+ .font(Typography.navLabel)
+ .foregroundStyle(Palette.accent)
+ }) {
+ if loading {
+ ProgressView().tint(Palette.accent)
+ } else if let msg = errorMessage {
+ ErrorBanner(message: msg)
+ } else if tasks.isEmpty {
+ EmptyLine(text: "No tasks in this contract.")
+ } else {
+ VStack(spacing: 6) {
+ ForEach(tasks) { t in
+ NavigationLink(value: t) {
+ TaskRow(task: t)
+ }
+ .buttonStyle(.plain)
+ }
+ }
+ }
+ }
+ }
+
+ private func reload() async {
+ guard let client = auth.client else { return }
+ loading = true
+ defer { loading = false }
+ errorMessage = nil
+ do {
+ // Server route is GET /mesh/tasks — client filters by contractId.
+ let env: ListEnvelope<MakimaTask> = try await client.get("/mesh/tasks")
+ tasks = env.items
+ .filter { $0.contractId == contract.id }
+ .sorted { ($0.updatedAt ?? .distantPast) > ($1.updatedAt ?? .distantPast) }
+ } catch {
+ errorMessage = error.localizedDescription
+ }
+ }
+}
diff --git a/makima/ios/Sources/Makima/Features/Contracts/ContractsListView.swift b/makima/ios/Sources/Makima/Features/Contracts/ContractsListView.swift
new file mode 100644
index 0000000..c7ef573
--- /dev/null
+++ b/makima/ios/Sources/Makima/Features/Contracts/ContractsListView.swift
@@ -0,0 +1,79 @@
+import SwiftUI
+
+struct ContractsListView: View {
+ @Environment(AuthStore.self) private var auth
+ @State private var contracts: [Contract] = []
+ @State private var filter: Filter = .active
+ @State private var loading = false
+ @State private var errorMessage: String?
+
+ enum Filter: String, CaseIterable, Identifiable {
+ case active, completed, archived, all
+ var id: String { rawValue }
+ var title: String { rawValue.uppercased() }
+ }
+
+ var body: some View {
+ ScreenShell(title: "CONTRACTS") {
+ VStack(spacing: 12) {
+ filterRow
+ if loading { ProgressView().tint(Palette.accent).padding() }
+ if let msg = errorMessage { ErrorBanner(message: msg) }
+
+ ForEach(filtered) { c in
+ NavigationLink(value: c) {
+ ContractRow(contract: c)
+ .padding(12)
+ .dashedBorder()
+ }
+ .buttonStyle(.plain)
+ }
+ }
+ .padding(16)
+ }
+ .task { await reload() }
+ .refreshable { await reload() }
+ }
+
+ private var filterRow: some View {
+ HStack(spacing: 8) {
+ ForEach(Filter.allCases) { f in
+ Button {
+ filter = f
+ } label: {
+ Text(f.title)
+ .font(Typography.navLabel)
+ .tracking(1)
+ .padding(.horizontal, 10)
+ .padding(.vertical, 6)
+ .foregroundStyle(f == filter ? Palette.accent : Palette.foregroundMuted)
+ .overlay(Rectangle().strokeBorder(f == filter ? Palette.border : Palette.borderMuted, lineWidth: 1))
+ }
+ .buttonStyle(.plain)
+ }
+ Spacer()
+ }
+ }
+
+ private var filtered: [Contract] {
+ let items = contracts.sorted { ($0.updatedAt ?? .distantPast) > ($1.updatedAt ?? .distantPast) }
+ switch filter {
+ case .all: return items
+ case .active: return items.filter { $0.isActive }
+ case .completed, .archived: return items.filter { $0.status.lowercased() == filter.rawValue }
+ }
+ }
+
+ private func reload() async {
+ guard let client = auth.client else { return }
+ loading = true
+ defer { loading = false }
+ errorMessage = nil
+ do {
+ let env: ListEnvelope<Contract> = try await client.get("/contracts")
+ contracts = env.items
+ } catch {
+ errorMessage = error.localizedDescription
+ }
+ }
+}
diff --git a/makima/ios/Sources/Makima/Features/Daemons/DaemonsListView.swift b/makima/ios/Sources/Makima/Features/Daemons/DaemonsListView.swift
new file mode 100644
index 0000000..b394a9e
--- /dev/null
+++ b/makima/ios/Sources/Makima/Features/Daemons/DaemonsListView.swift
@@ -0,0 +1,61 @@
+import SwiftUI
+
+struct DaemonsListView: View {
+ @Environment(AuthStore.self) private var auth
+ @State private var daemons: [Daemon] = []
+ @State private var loading = false
+ @State private var errorMessage: String?
+
+ var body: some View {
+ ScreenShell(title: "DAEMONS") {
+ VStack(spacing: 12) {
+ if loading { ProgressView().tint(Palette.accent).padding() }
+ if let msg = errorMessage { ErrorBanner(message: msg) }
+ if !loading && daemons.isEmpty {
+ EmptyLine(text: "No daemons connected.").padding()
+ }
+
+ ForEach(daemons) { d in
+ VStack(alignment: .leading, spacing: 6) {
+ HStack {
+ StatusDot(kind: d.isOnline ? .active : .idle)
+ Text(d.displayHost)
+ .font(Typography.mono)
+ .foregroundStyle(Palette.foreground)
+ Spacer()
+ Text(d.status.uppercased())
+ .font(Typography.navLabel)
+ .foregroundStyle(d.isOnline ? Palette.ok : Palette.foregroundMuted)
+ }
+ HStack {
+ KeyValue(label: "Tasks",
+ value: "\(d.currentTaskCount ?? 0) / \(d.maxConcurrentTasks ?? 0)")
+ Spacer()
+ if let hb = d.lastHeartbeatAt {
+ KeyValue(label: "Heartbeat", value: hb.relativeShort)
+ }
+ }
+ }
+ .padding(12)
+ .dashedBorder()
+ }
+ }
+ .padding(16)
+ }
+ .task { await reload() }
+ .refreshable { await reload() }
+ }
+
+ private func reload() async {
+ guard let client = auth.client else { return }
+ loading = true
+ defer { loading = false }
+ errorMessage = nil
+ do {
+ let env: ListEnvelope<Daemon> = try await client.get("/mesh/daemons")
+ daemons = env.items
+ } catch {
+ errorMessage = error.localizedDescription
+ }
+ }
+}
diff --git a/makima/ios/Sources/Makima/Features/Directives/DirectivesListView.swift b/makima/ios/Sources/Makima/Features/Directives/DirectivesListView.swift
new file mode 100644
index 0000000..4c325ee
--- /dev/null
+++ b/makima/ios/Sources/Makima/Features/Directives/DirectivesListView.swift
@@ -0,0 +1,64 @@
+import SwiftUI
+
+struct DirectivesListView: View {
+ @Environment(AuthStore.self) private var auth
+ @State private var directives: [Directive] = []
+ @State private var loading = false
+ @State private var errorMessage: String?
+
+ var body: some View {
+ ScreenShell(title: "DIRECTIVES") {
+ VStack(spacing: 12) {
+ if loading { ProgressView().tint(Palette.accent).padding() }
+ if let msg = errorMessage { ErrorBanner(message: msg) }
+ if !loading && directives.isEmpty {
+ EmptyLine(text: "No directives.").padding()
+ }
+
+ ForEach(directives) { d in
+ VStack(alignment: .leading, spacing: 8) {
+ HStack {
+ StatusDot(kind: (d.status ?? "").lowercased() == "pending" ? .warn : .idle)
+ Text(d.displayName)
+ .font(Typography.body)
+ .foregroundStyle(Palette.foreground)
+ Spacer()
+ Text((d.status ?? "UNKNOWN").uppercased())
+ .font(Typography.navLabel)
+ .foregroundStyle(Palette.foregroundMuted)
+ }
+ if let goal = d.goal, !goal.isEmpty, d.name != nil {
+ Text(goal)
+ .font(Typography.caption)
+ .foregroundStyle(Palette.foregroundMuted)
+ .lineLimit(3)
+ }
+ }
+ .padding(12)
+ .dashedBorder()
+ }
+
+ Text("Answer directive questions on the web for now.")
+ .font(Typography.navLabel)
+ .foregroundStyle(Palette.foregroundMuted)
+ .padding(.top, 8)
+ }
+ .padding(16)
+ }
+ .task { await reload() }
+ .refreshable { await reload() }
+ }
+
+ private func reload() async {
+ guard let client = auth.client else { return }
+ loading = true
+ defer { loading = false }
+ errorMessage = nil
+ do {
+ let env: ListEnvelope<Directive> = try await client.get("/directives")
+ directives = env.items.sorted { ($0.updatedAt ?? .distantPast) > ($1.updatedAt ?? .distantPast) }
+ } catch {
+ errorMessage = error.localizedDescription
+ }
+ }
+}
diff --git a/makima/ios/Sources/Makima/Features/Home/HomeCards.swift b/makima/ios/Sources/Makima/Features/Home/HomeCards.swift
new file mode 100644
index 0000000..1ffca2a
--- /dev/null
+++ b/makima/ios/Sources/Makima/Features/Home/HomeCards.swift
@@ -0,0 +1,352 @@
+import SwiftUI
+
+// MARK: - Card chrome
+
+struct SectionCard<Content: View, Trailing: View>: View {
+ let title: String
+ let trailing: Trailing
+ let onTap: (() -> Void)?
+ @ViewBuilder let content: () -> Content
+
+ init(title: String,
+ onTap: (() -> Void)? = nil,
+ @ViewBuilder trailing: () -> Trailing = { EmptyView() },
+ @ViewBuilder content: @escaping () -> Content) {
+ self.title = title
+ self.onTap = onTap
+ self.trailing = trailing()
+ self.content = content
+ }
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 10) {
+ HStack {
+ Text("\(title)//")
+ .font(Typography.navLabel)
+ .foregroundStyle(Palette.foregroundMuted)
+ Spacer()
+ trailing
+ if onTap != nil {
+ Image(systemName: "chevron.right")
+ .font(.footnote)
+ .foregroundStyle(Palette.foregroundMuted)
+ }
+ }
+ Divider().overlay(Palette.borderMuted)
+ content()
+ }
+ .padding(14)
+ .dashedBorder()
+ .contentShape(Rectangle())
+ .onTapGesture { onTap?() }
+ }
+}
+
+extension SectionCard where Trailing == EmptyView {
+ init(title: String,
+ onTap: (() -> Void)? = nil,
+ @ViewBuilder content: @escaping () -> Content) {
+ self.init(title: title, onTap: onTap, trailing: { EmptyView() }, content: content)
+ }
+}
+
+// MARK: - Contracts
+
+struct ContractsCard: View {
+ @Bindable var store: HomeStore
+ let onSeeAll: () -> Void
+ let onTap: (Contract) -> Void
+
+ var body: some View {
+ SectionCard(title: "CONTRACTS",
+ onTap: onSeeAll,
+ trailing: {
+ CountPill(count: store.activeContractsTotal, kind: .info)
+ }) {
+ if store.activeContracts.isEmpty {
+ EmptyLine(text: "No active contracts.")
+ } else {
+ VStack(spacing: 6) {
+ ForEach(store.activeContracts.prefix(3)) { c in
+ Button { onTap(c) } label: {
+ ContractRow(contract: c)
+ }
+ .buttonStyle(.plain)
+ }
+ }
+ }
+ }
+ }
+}
+
+struct ContractRow: View {
+ let contract: Contract
+
+ var body: some View {
+ HStack(spacing: 10) {
+ StatusDot(kind: statusKind)
+ VStack(alignment: .leading, spacing: 2) {
+ Text(contract.name)
+ .font(Typography.body)
+ .foregroundStyle(Palette.foreground)
+ .lineLimit(1)
+ Text("\(contract.phase) · \(contract.status)")
+ .font(Typography.caption)
+ .foregroundStyle(Palette.foregroundMuted)
+ }
+ Spacer()
+ }
+ }
+
+ private var statusKind: StatusDot.Kind {
+ switch contract.status.lowercased() {
+ case "active": return .active
+ case "completed": return .ok
+ case "archived": return .idle
+ default: return .idle
+ }
+ }
+}
+
+// MARK: - Daemons
+
+struct DaemonsCard: View {
+ @Bindable var store: HomeStore
+ let onTap: () -> Void
+
+ private var onlineCount: Int { store.daemons.filter(\.isOnline).count }
+ private var totalCount: Int { store.daemons.count }
+
+ var body: some View {
+ SectionCard(title: "DAEMONS",
+ onTap: onTap,
+ trailing: {
+ Text("\(onlineCount) / \(totalCount)")
+ .font(Typography.mono)
+ .foregroundStyle(onlineCount > 0 ? Palette.ok : Palette.foregroundMuted)
+ }) {
+ if store.daemons.isEmpty {
+ EmptyLine(text: "No daemons connected.")
+ } else {
+ VStack(spacing: 4) {
+ ForEach(store.daemons.prefix(3)) { d in
+ HStack(spacing: 10) {
+ StatusDot(kind: d.isOnline ? .active : .idle)
+ Text(d.displayHost)
+ .font(Typography.mono)
+ .foregroundStyle(Palette.foreground)
+ .lineLimit(1)
+ Spacer()
+ Text(d.status.uppercased())
+ .font(Typography.navLabel)
+ .foregroundStyle(Palette.foregroundMuted)
+ }
+ }
+ }
+ }
+ }
+ }
+}
+
+// MARK: - Directives
+
+struct DirectivesCard: View {
+ @Bindable var store: HomeStore
+ let onTap: () -> Void
+
+ var body: some View {
+ SectionCard(title: "DIRECTIVES",
+ onTap: onTap,
+ trailing: {
+ if !store.pendingDirectives.isEmpty {
+ CountPill(count: store.pendingDirectives.count, kind: .warn)
+ }
+ }) {
+ if store.pendingDirectives.isEmpty {
+ EmptyLine(text: "No pending questions.")
+ } else {
+ VStack(alignment: .leading, spacing: 6) {
+ ForEach(store.pendingDirectives.prefix(3)) { d in
+ HStack(spacing: 10) {
+ StatusDot(kind: .warn)
+ Text(d.displayName)
+ .font(Typography.body)
+ .foregroundStyle(Palette.foreground)
+ .lineLimit(1)
+ }
+ }
+ }
+ }
+ }
+ }
+}
+
+// MARK: - Listen
+
+struct ListenCard: View {
+ @Bindable var store: HomeStore
+ let onTap: () -> Void
+
+ var body: some View {
+ SectionCard(title: "LISTEN",
+ onTap: onTap,
+ trailing: {
+ JapaneseLongPressText(japanese: "聴取", english: "LISTEN")
+ .fixedSize()
+ }) {
+ if let s = store.latestListen {
+ VStack(alignment: .leading, spacing: 6) {
+ Text(s.displayTitle)
+ .font(Typography.body)
+ .foregroundStyle(Palette.foreground)
+ if let preview = s.transcriptPreview, !preview.isEmpty {
+ Text(preview)
+ .font(Typography.caption)
+ .foregroundStyle(Palette.foregroundMuted)
+ .lineLimit(2)
+ }
+ if let when = s.startedAt {
+ Text(when.formatted(date: .abbreviated, time: .shortened))
+ .font(Typography.navLabel)
+ .foregroundStyle(Palette.foregroundMuted)
+ }
+ }
+ } else {
+ EmptyLine(text: "No listen sessions yet.")
+ }
+ }
+ }
+}
+
+// MARK: - Recent tasks
+
+struct RecentTasksCard: View {
+ @Bindable var store: HomeStore
+ let onSeeAll: () -> Void
+ let onTap: (MakimaTask) -> Void
+
+ var body: some View {
+ SectionCard(title: "RECENT TASKS",
+ onTap: onSeeAll) {
+ if store.recentTasks.isEmpty {
+ EmptyLine(text: "No recent tasks.")
+ } else {
+ VStack(spacing: 6) {
+ ForEach(store.recentTasks) { t in
+ Button { onTap(t) } label: {
+ TaskRow(task: t)
+ }
+ .buttonStyle(.plain)
+ }
+ }
+ }
+ }
+ }
+}
+
+struct TaskRow: View {
+ let task: MakimaTask
+
+ var body: some View {
+ HStack(spacing: 10) {
+ StatusDot(kind: dotKind)
+ VStack(alignment: .leading, spacing: 2) {
+ Text(task.name)
+ .font(Typography.body)
+ .foregroundStyle(Palette.foreground)
+ .lineLimit(1)
+ Text(task.status.uppercased())
+ .font(Typography.navLabel)
+ .foregroundStyle(Palette.foregroundMuted)
+ }
+ Spacer()
+ if let updated = task.updatedAt {
+ Text(updated.relativeShort)
+ .font(Typography.navLabel)
+ .foregroundStyle(Palette.foregroundMuted)
+ }
+ }
+ }
+
+ private var dotKind: StatusDot.Kind {
+ switch task.statusColor {
+ case .ok: return .ok
+ case .active: return .active
+ case .idle: return .idle
+ case .warn: return .warn
+ case .danger: return .danger
+ }
+ }
+}
+
+// MARK: - Little primitives
+
+struct StatusDot: View {
+ enum Kind { case ok, active, idle, warn, danger }
+ let kind: Kind
+
+ var body: some View {
+ Circle()
+ .fill(color)
+ .frame(width: 6, height: 6)
+ }
+
+ private var color: Color {
+ switch kind {
+ case .ok: return Palette.ok
+ case .active: return Palette.accent
+ case .idle: return Palette.foregroundMuted
+ case .warn: return Palette.warn
+ case .danger: return Palette.danger
+ }
+ }
+}
+
+struct CountPill: View {
+ enum Kind { case info, warn }
+ let count: Int
+ let kind: Kind
+
+ var body: some View {
+ Text("\(count)")
+ .font(Typography.navLabel)
+ .tracking(1)
+ .padding(.horizontal, 8)
+ .padding(.vertical, 2)
+ .foregroundStyle(foreground)
+ .background(background)
+ .overlay(Rectangle().strokeBorder(foreground.opacity(0.4), lineWidth: 1))
+ }
+
+ private var foreground: Color {
+ switch kind {
+ case .info: return Palette.accent
+ case .warn: return Palette.warn
+ }
+ }
+
+ private var background: Color {
+ Palette.panel
+ }
+}
+
+struct EmptyLine: View {
+ let text: String
+ var body: some View {
+ Text(text)
+ .font(Typography.body)
+ .foregroundStyle(Palette.foregroundMuted)
+ }
+}
+
+extension Date {
+ /// Relative-time string for small cells ("5m", "2h", "3d").
+ var relativeShort: String {
+ let seconds = -self.timeIntervalSinceNow
+ if seconds < 60 { return "just now" }
+ if seconds < 3600 { return "\(Int(seconds / 60))m" }
+ if seconds < 86_400 { return "\(Int(seconds / 3600))h" }
+ if seconds < 604_800 { return "\(Int(seconds / 86_400))d" }
+ return formatted(date: .abbreviated, time: .omitted)
+ }
+}
diff --git a/makima/ios/Sources/Makima/Features/Home/HomeView.swift b/makima/ios/Sources/Makima/Features/Home/HomeView.swift
new file mode 100644
index 0000000..22ddb70
--- /dev/null
+++ b/makima/ios/Sources/Makima/Features/Home/HomeView.swift
@@ -0,0 +1,136 @@
+import SwiftUI
+
+struct HomeView: View {
+ @Environment(AuthStore.self) private var auth
+ @Environment(AppState.self) private var appState
+ @State private var store: HomeStore?
+ @State private var showSettings = false
+ @State private var navPath = NavigationPath()
+
+ var body: some View {
+ NavigationStack(path: $navPath) {
+ ZStack {
+ Palette.background.ignoresSafeArea()
+ GridOverlay()
+
+ VStack(spacing: 0) {
+ MastheadBar(
+ serverLabel: auth.client?.profile.label ?? ServerProfile.defaultLabel,
+ wsStatus: appState.wsStatus,
+ version: Bundle.main.appVersion
+ )
+ NavStripPlaceholder()
+
+ if let store {
+ content(store: store)
+ } else {
+ ProgressView().tint(Palette.accent).frame(maxHeight: .infinity)
+ }
+ }
+ }
+ .toolbar(.hidden, for: .navigationBar)
+ .sheet(isPresented: $showSettings) {
+ SettingsView().environment(auth)
+ }
+ .navigationDestination(for: Contract.self) { c in
+ ContractDetailView(contract: c).environment(auth)
+ }
+ .navigationDestination(for: MakimaTask.self) { t in
+ TaskDetailView(task: t).environment(auth).environment(appState)
+ }
+ .navigationDestination(for: HomeDestination.self) { d in
+ switch d {
+ case .contracts: ContractsListView().environment(auth)
+ case .tasks: TasksListView(filterStatus: nil).environment(auth).environment(appState)
+ case .daemons: DaemonsListView().environment(auth)
+ case .directives: DirectivesListView().environment(auth)
+ case .listen: ListenHistoryView().environment(auth)
+ }
+ }
+ .task {
+ if store == nil, let client = auth.client {
+ store = HomeStore(client: client)
+ }
+ await store?.refresh()
+ }
+ }
+ }
+
+ @ViewBuilder
+ private func content(store: HomeStore) -> some View {
+ ScrollView {
+ VStack(spacing: 16) {
+ HomeHeader(onSettings: { showSettings = true })
+
+ ContractsCard(store: store) {
+ navPath.append(HomeDestination.contracts)
+ } onTap: { contract in
+ navPath.append(contract)
+ }
+
+ DaemonsCard(store: store) {
+ navPath.append(HomeDestination.daemons)
+ }
+
+ DirectivesCard(store: store) {
+ navPath.append(HomeDestination.directives)
+ }
+
+ ListenCard(store: store) {
+ navPath.append(HomeDestination.listen)
+ }
+
+ RecentTasksCard(store: store,
+ onSeeAll: { navPath.append(HomeDestination.tasks) },
+ onTap: { task in navPath.append(task) })
+
+ if let lastRefreshed = store.lastRefreshed {
+ Text("Last refresh · \(lastRefreshed.formatted(date: .omitted, time: .standard))")
+ .font(Typography.navLabel)
+ .foregroundStyle(Palette.foregroundMuted)
+ .padding(.top, 8)
+ }
+ }
+ .padding(16)
+ }
+ .refreshable { await store.refresh() }
+ }
+}
+
+enum HomeDestination: Hashable {
+ case contracts, tasks, daemons, directives, listen
+}
+
+private struct HomeHeader: View {
+ let onSettings: () -> Void
+
+ var body: some View {
+ HStack(alignment: .center) {
+ VStack(alignment: .leading, spacing: 4) {
+ Text("MAKIMA")
+ .font(.system(.headline, design: .monospaced).weight(.semibold))
+ .tracking(4)
+ .foregroundStyle(Palette.foreground)
+ Text("Mesh Orchestration Platform")
+ .font(Typography.body)
+ .foregroundStyle(Palette.foregroundMuted)
+ }
+ Spacer()
+ Button(action: onSettings) {
+ Image(systemName: "gearshape")
+ .foregroundStyle(Palette.accent)
+ .padding(10)
+ .background(Palette.panel)
+ .overlay(Rectangle().strokeBorder(Palette.border, lineWidth: 1))
+ }
+ .accessibilityLabel("Settings")
+ }
+ .padding(.bottom, 4)
+ }
+}
+
+private extension Bundle {
+ var appVersion: String {
+ (infoDictionary?["CFBundleShortVersionString"] as? String) ?? "0.0.0"
+ }
+}
diff --git a/makima/ios/Sources/Makima/Features/Listen/ListenHistoryView.swift b/makima/ios/Sources/Makima/Features/Listen/ListenHistoryView.swift
new file mode 100644
index 0000000..196b8fc
--- /dev/null
+++ b/makima/ios/Sources/Makima/Features/Listen/ListenHistoryView.swift
@@ -0,0 +1,68 @@
+import SwiftUI
+
+struct ListenHistoryView: View {
+ @Environment(AuthStore.self) private var auth
+ @State private var sessions: [ListenSession] = []
+ @State private var loading = false
+ @State private var errorMessage: String?
+
+ var body: some View {
+ ScreenShell(title: "LISTEN") {
+ VStack(spacing: 12) {
+ if loading { ProgressView().tint(Palette.accent).padding() }
+ if let msg = errorMessage { ErrorBanner(message: msg) }
+ if !loading && sessions.isEmpty {
+ EmptyLine(text: "No listen sessions yet.").padding()
+ }
+
+ ForEach(sessions) { s in
+ VStack(alignment: .leading, spacing: 6) {
+ HStack {
+ JapaneseLongPressText(japanese: "聴取", english: "SESSION")
+ Spacer()
+ if let when = s.startedAt {
+ Text(when.formatted(date: .abbreviated, time: .shortened))
+ .font(Typography.navLabel)
+ .foregroundStyle(Palette.foregroundMuted)
+ }
+ }
+ Text(s.displayTitle)
+ .font(Typography.body)
+ .foregroundStyle(Palette.foreground)
+ if let preview = s.transcriptPreview, !preview.isEmpty {
+ Text(preview)
+ .font(Typography.caption)
+ .foregroundStyle(Palette.foregroundMuted)
+ .lineLimit(4)
+ }
+ }
+ .padding(12)
+ .dashedBorder()
+ }
+
+ Text("Live listening is web-only in v1.")
+ .font(Typography.navLabel)
+ .foregroundStyle(Palette.foregroundMuted)
+ .padding(.top, 8)
+ }
+ .padding(16)
+ }
+ .task { await reload() }
+ .refreshable { await reload() }
+ }
+
+ private func reload() async {
+ guard let client = auth.client else { return }
+ loading = true
+ defer { loading = false }
+ errorMessage = nil
+ do {
+ let env: ListEnvelope<ListenSession> = try await client.get("/listen/sessions")
+ sessions = env.items.sorted { ($0.startedAt ?? .distantPast) > ($1.startedAt ?? .distantPast) }
+ } catch APIError.notFound {
+ sessions = [] // endpoint may not be deployed yet
+ } catch {
+ errorMessage = error.localizedDescription
+ }
+ }
+}
diff --git a/makima/ios/Sources/Makima/Features/Tasks/TaskDetailView.swift b/makima/ios/Sources/Makima/Features/Tasks/TaskDetailView.swift
new file mode 100644
index 0000000..8408c1c
--- /dev/null
+++ b/makima/ios/Sources/Makima/Features/Tasks/TaskDetailView.swift
@@ -0,0 +1,166 @@
+import SwiftUI
+
+struct TaskDetailView: View {
+ @Environment(AuthStore.self) private var auth
+ @Environment(AppState.self) private var appState
+ let task: MakimaTask
+
+ @State private var detail: MakimaTask?
+ @State private var output: String = ""
+ @State private var liveEvents: [TaskLiveEvent] = []
+ @State private var loading = false
+ @State private var errorMessage: String?
+ @State private var pollTimer: Task<Void, Never>?
+
+ var body: some View {
+ ScreenShell(title: task.name.uppercased()) {
+ VStack(spacing: 16) {
+ metaCard
+ completionGateCard
+ outputCard
+ }
+ .padding(16)
+ }
+ .task {
+ await reload()
+ subscribeLive()
+ startPolling()
+ }
+ .onDisappear {
+ pollTimer?.cancel()
+ pollTimer = nil
+ appState.webSocket?.unsubscribe(taskId: task.id)
+ }
+ .refreshable { await reload() }
+ }
+
+ private var current: MakimaTask { detail ?? task }
+
+ private var metaCard: some View {
+ SectionCard(title: "TASK") {
+ VStack(alignment: .leading, spacing: 6) {
+ KeyValue(label: "Status", value: current.status.uppercased())
+ if let priority = current.priority {
+ KeyValue(label: "Priority", value: "\(priority)")
+ }
+ if let daemon = current.daemonId {
+ KeyValue(label: "Daemon", value: String(daemon.prefix(8)))
+ }
+ if let summary = current.progressSummary, !summary.isEmpty {
+ Divider().overlay(Palette.borderMuted)
+ Text(summary)
+ .font(Typography.body)
+ .foregroundStyle(Palette.body)
+ }
+ if let err = current.errorMessage, !err.isEmpty {
+ Divider().overlay(Palette.borderMuted)
+ Text(err)
+ .font(Typography.body)
+ .foregroundStyle(Palette.danger)
+ }
+ }
+ }
+ }
+
+ @ViewBuilder
+ private var completionGateCard: some View {
+ if let gate = CompletionGate.extract(from: currentOutputText) {
+ CompletionGateView(gate: gate)
+ }
+ }
+
+ private var outputCard: some View {
+ SectionCard(title: "OUTPUT",
+ trailing: {
+ HStack(spacing: 6) {
+ Circle().fill(Palette.ok).frame(width: 6, height: 6)
+ .opacity(appState.wsStatus == .online ? 1 : 0)
+ Text(appState.wsStatus == .online ? "LIVE" : "POLL")
+ .font(Typography.navLabel)
+ .foregroundStyle(appState.wsStatus == .online ? Palette.ok : Palette.foregroundMuted)
+ }
+ }) {
+ if currentOutputText.isEmpty {
+ EmptyLine(text: "No output yet.")
+ } else {
+ TaskOutputRenderer(text: currentOutputText, liveEvents: liveEvents)
+ }
+ }
+ }
+
+ private var currentOutputText: String {
+ if !output.isEmpty { return output }
+ return current.lastOutput ?? ""
+ }
+
+ // MARK: - Data
+
+ private func reload() async {
+ guard let client = auth.client else { return }
+ loading = true
+ defer { loading = false }
+ do {
+ self.detail = try await client.get("/mesh/tasks/\(task.id)")
+ if let resp: TaskOutputResponse = try? await client.get("/mesh/tasks/\(task.id)/output") {
+ self.output = resp.text
+ }
+ } catch {
+ errorMessage = error.localizedDescription
+ }
+ }
+
+ private func subscribeLive() {
+ appState.ensureWebSocket()
+ appState.webSocket?.subscribe(taskId: task.id) { [task] event in
+ Task { @MainActor in
+ switch event {
+ case .taskUpdated:
+ await reload()
+ case .taskOutput(let evt) where evt.taskId == task.id:
+ liveEvents.append(TaskLiveEvent(from: evt))
+ default:
+ break
+ }
+ }
+ }
+ }
+
+ /// Foreground polling as a fallback when WS isn't online.
+ private func startPolling() {
+ pollTimer = Task { [weak appState] in
+ while !Task.isCancelled {
+ try? await Task.sleep(nanoseconds: 8_000_000_000)
+ if Task.isCancelled { return }
+ if appState?.wsStatus != .online {
+ await reload()
+ }
+ }
+ }
+ }
+}
+
+/// Immutable live-stream event for the renderer.
+struct TaskLiveEvent: Identifiable {
+ let id = UUID()
+ let messageType: String
+ let content: String
+ let toolName: String?
+ let isError: Bool
+ let at: Date
+
+ init(messageType: String, content: String, toolName: String? = nil, isError: Bool = false) {
+ self.messageType = messageType
+ self.content = content
+ self.toolName = toolName
+ self.isError = isError
+ self.at = .now
+ }
+
+ init(from server: TaskWebSocket.TaskOutputEvent) {
+ self.messageType = server.messageType
+ self.content = server.content
+ self.toolName = server.toolName
+ self.isError = server.isError ?? false
+ self.at = .now
+ }
+}
diff --git a/makima/ios/Sources/Makima/Features/Tasks/TaskOutputRenderer.swift b/makima/ios/Sources/Makima/Features/Tasks/TaskOutputRenderer.swift
new file mode 100644
index 0000000..7b4a008
--- /dev/null
+++ b/makima/ios/Sources/Makima/Features/Tasks/TaskOutputRenderer.swift
@@ -0,0 +1,84 @@
+import SwiftUI
+
+/// Combines an initial polled output blob with live streamed events.
+/// At v1 we render the polled body as markdown-with-fences and then append
+/// live events (tool_use / tool_result / assistant / error) as short cards.
+struct TaskOutputRenderer: View {
+ let text: String
+ let liveEvents: [TaskLiveEvent]
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 10) {
+ if !text.isEmpty {
+ MarkdownBlocks(text: text)
+ }
+
+ if !liveEvents.isEmpty {
+ Divider().overlay(Palette.borderMuted)
+ Text("LIVE//")
+ .font(Typography.navLabel)
+ .foregroundStyle(Palette.foregroundMuted)
+
+ ForEach(liveEvents) { e in
+ LiveEventRow(event: e)
+ }
+ }
+ }
+ }
+}
+
+struct LiveEventRow: View {
+ let event: TaskLiveEvent
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 4) {
+ HStack(spacing: 6) {
+ Text(label)
+ .font(Typography.navLabel)
+ .foregroundStyle(color)
+ if let tool = event.toolName {
+ Text("·").foregroundStyle(Palette.borderMuted)
+ Text(tool)
+ .font(Typography.navLabel)
+ .foregroundStyle(Palette.accent)
+ }
+ Spacer()
+ Text(event.at.formatted(.dateTime.hour().minute().second()))
+ .font(Typography.navLabel)
+ .foregroundStyle(Palette.foregroundMuted)
+ }
+ if !event.content.isEmpty {
+ if event.messageType == "tool_result" || event.messageType == "raw" {
+ CodeBlockView(language: nil, body: event.content)
+ } else {
+ MarkdownBlocks(text: event.content)
+ }
+ }
+ }
+ .padding(8)
+ .overlay(Rectangle().strokeBorder(color.opacity(0.3), lineWidth: 1))
+ }
+
+ private var label: String {
+ switch event.messageType {
+ case "assistant": return "ASSISTANT"
+ case "tool_use": return "TOOL USE"
+ case "tool_result": return event.isError ? "TOOL ERROR" : "TOOL RESULT"
+ case "result": return "RESULT"
+ case "system": return "SYSTEM"
+ case "error": return "ERROR"
+ case "raw": return "RAW"
+ default: return event.messageType.uppercased()
+ }
+ }
+ private var color: Color {
+ switch event.messageType {
+ case "assistant": return Palette.accent
+ case "tool_use": return Palette.bodySoft
+ case "tool_result": return event.isError ? Palette.danger : Palette.ok
+ case "error": return Palette.danger
+ case "system": return Palette.foregroundMuted
+ default: return Palette.body
+ }
+ }
+}
diff --git a/makima/ios/Sources/Makima/Features/Tasks/TasksListView.swift b/makima/ios/Sources/Makima/Features/Tasks/TasksListView.swift
new file mode 100644
index 0000000..0d4b928
--- /dev/null
+++ b/makima/ios/Sources/Makima/Features/Tasks/TasksListView.swift
@@ -0,0 +1,76 @@
+import SwiftUI
+
+struct TasksListView: View {
+ @Environment(AuthStore.self) private var auth
+ let filterStatus: String?
+
+ @State private var tasks: [MakimaTask] = []
+ @State private var loading = false
+ @State private var errorMessage: String?
+ @State private var statusFilter: String? = nil
+
+ var body: some View {
+ ScreenShell(title: "TASKS") {
+ VStack(spacing: 12) {
+ statusFilterRow
+ if loading { ProgressView().tint(Palette.accent).padding() }
+ if let msg = errorMessage { ErrorBanner(message: msg) }
+
+ ForEach(filtered) { t in
+ NavigationLink(value: t) {
+ TaskRow(task: t)
+ .padding(12)
+ .dashedBorder()
+ }
+ .buttonStyle(.plain)
+ }
+ }
+ .padding(16)
+ }
+ .task {
+ statusFilter = filterStatus
+ await reload()
+ }
+ .refreshable { await reload() }
+ }
+
+ private var statusFilterRow: some View {
+ let options: [String?] = [nil, "running", "pending", "done", "failed"]
+ return HStack(spacing: 8) {
+ ForEach(options, id: \.self) { s in
+ Button {
+ statusFilter = s
+ } label: {
+ Text(s?.uppercased() ?? "ALL")
+ .font(Typography.navLabel)
+ .tracking(1)
+ .padding(.horizontal, 10)
+ .padding(.vertical, 6)
+ .foregroundStyle(statusFilter == s ? Palette.accent : Palette.foregroundMuted)
+ .overlay(Rectangle().strokeBorder(statusFilter == s ? Palette.border : Palette.borderMuted, lineWidth: 1))
+ }
+ .buttonStyle(.plain)
+ }
+ Spacer()
+ }
+ }
+
+ private var filtered: [MakimaTask] {
+ let items = tasks.sorted { ($0.updatedAt ?? .distantPast) > ($1.updatedAt ?? .distantPast) }
+ guard let s = statusFilter else { return items }
+ return items.filter { $0.status.lowercased() == s.lowercased() }
+ }
+
+ private func reload() async {
+ guard let client = auth.client else { return }
+ loading = true
+ defer { loading = false }
+ errorMessage = nil
+ do {
+ let env: ListEnvelope<MakimaTask> = try await client.get("/mesh/tasks")
+ tasks = env.items
+ } catch {
+ errorMessage = error.localizedDescription
+ }
+ }
+}
diff --git a/makima/ios/Sources/Makima/Markdown/CompletionGate.swift b/makima/ios/Sources/Makima/Markdown/CompletionGate.swift
new file mode 100644
index 0000000..bb1d336
--- /dev/null
+++ b/makima/ios/Sources/Makima/Markdown/CompletionGate.swift
@@ -0,0 +1,47 @@
+import Foundation
+
+/// Parsed completion-gate block. Matches the Rust `<COMPLETION_GATE>…</>`
+/// protocol emitted by Claude Code inside task output.
+struct CompletionGate {
+ let ready: Bool
+ let reason: String?
+ let progress: String?
+ let blockers: [String]
+
+ static func extract(from output: String) -> CompletionGate? {
+ guard let start = output.range(of: "<COMPLETION_GATE>"),
+ let end = output.range(of: "</COMPLETION_GATE>"),
+ start.upperBound <= end.lowerBound
+ else { return nil }
+
+ let body = String(output[start.upperBound..<end.lowerBound])
+ var ready = false
+ var reason: String?
+ var progress: String?
+ var blockers: [String] = []
+
+ for line in body.split(whereSeparator: { $0.isNewline }) {
+ let trimmed = line.trimmingCharacters(in: .whitespaces)
+ if trimmed.isEmpty { continue }
+ if let colon = trimmed.firstIndex(of: ":") {
+ let key = String(trimmed[..<colon]).lowercased()
+ let value = String(trimmed[trimmed.index(after: colon)...])
+ .trimmingCharacters(in: .whitespaces)
+ .trimmingCharacters(in: CharacterSet(charactersIn: "\""))
+ switch key {
+ case "ready": ready = (value == "true" || value == "yes")
+ case "reason": reason = value
+ case "progress": progress = value
+ case "blockers":
+ blockers = value
+ .split(separator: ",")
+ .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
+ .filter { !$0.isEmpty }
+ default:
+ break
+ }
+ }
+ }
+ return CompletionGate(ready: ready, reason: reason, progress: progress, blockers: blockers)
+ }
+}
diff --git a/makima/ios/Sources/Makima/Markdown/CompletionGateView.swift b/makima/ios/Sources/Makima/Markdown/CompletionGateView.swift
new file mode 100644
index 0000000..b54f937
--- /dev/null
+++ b/makima/ios/Sources/Makima/Markdown/CompletionGateView.swift
@@ -0,0 +1,61 @@
+import SwiftUI
+
+struct CompletionGateView: View {
+ let gate: CompletionGate
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 10) {
+ HStack {
+ Text("COMPLETION GATE//")
+ .font(Typography.navLabel)
+ .foregroundStyle(Palette.foregroundMuted)
+ Spacer()
+ Text(gate.ready ? "READY" : "NOT READY")
+ .font(Typography.navLabel)
+ .tracking(1)
+ .padding(.horizontal, 8)
+ .padding(.vertical, 3)
+ .foregroundStyle(gate.ready ? Palette.ok : Palette.warn)
+ .overlay(Rectangle().strokeBorder(gate.ready ? Palette.ok.opacity(0.5)
+ : Palette.warn.opacity(0.5),
+ lineWidth: 1))
+ }
+ Divider().overlay(Palette.borderMuted)
+
+ if let progress = gate.progress {
+ row("Progress", progress)
+ }
+ if let reason = gate.reason {
+ row("Reason", reason)
+ }
+ if !gate.blockers.isEmpty {
+ VStack(alignment: .leading, spacing: 4) {
+ Text("Blockers")
+ .font(Typography.navLabel)
+ .foregroundStyle(Palette.foregroundMuted)
+ ForEach(gate.blockers, id: \.self) { b in
+ HStack(alignment: .top, spacing: 6) {
+ Text("•").foregroundStyle(Palette.warn)
+ Text(b)
+ .font(Typography.body)
+ .foregroundStyle(Palette.body)
+ }
+ }
+ }
+ }
+ }
+ .padding(14)
+ .dashedBorder(color: gate.ready ? Palette.ok.opacity(0.4) : Palette.warn.opacity(0.4))
+ }
+
+ private func row(_ label: String, _ value: String) -> some View {
+ VStack(alignment: .leading, spacing: 2) {
+ Text(label)
+ .font(Typography.navLabel)
+ .foregroundStyle(Palette.foregroundMuted)
+ Text(value)
+ .font(Typography.body)
+ .foregroundStyle(Palette.body)
+ }
+ }
+}
diff --git a/makima/ios/Sources/Makima/Markdown/MarkdownRenderer.swift b/makima/ios/Sources/Makima/Markdown/MarkdownRenderer.swift
new file mode 100644
index 0000000..4f60b01
--- /dev/null
+++ b/makima/ios/Sources/Makima/Markdown/MarkdownRenderer.swift
@@ -0,0 +1,123 @@
+import SwiftUI
+
+/// Lightweight markdown renderer with code-block awareness. Splits on
+/// fenced blocks and renders:
+/// - prose blocks via SwiftUI's built-in `AttributedString(markdown:)`
+/// - code blocks via a monospaced, panel-backed view with optional lang chip
+///
+/// No external deps. Good enough for task output at v1; can upgrade to
+/// swift-markdown-ui + Splash later for richer highlighting.
+struct MarkdownBlocks: View {
+ let text: String
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 10) {
+ ForEach(Array(Self.parse(text: text).enumerated()), id: \.offset) { _, block in
+ switch block {
+ case .prose(let s):
+ if let attr = try? AttributedString(
+ markdown: s,
+ options: .init(interpretedSyntax: .inlineOnlyPreservingWhitespace)
+ ) {
+ Text(attr)
+ .font(Typography.body)
+ .foregroundStyle(Palette.body)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .textSelection(.enabled)
+ } else {
+ Text(s)
+ .font(Typography.body)
+ .foregroundStyle(Palette.body)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .textSelection(.enabled)
+ }
+ case .code(let lang, let body):
+ CodeBlockView(language: lang, body: body)
+ }
+ }
+ }
+ }
+
+ enum Block {
+ case prose(String)
+ case code(lang: String?, body: String)
+ }
+
+ static func parse(text: String) -> [Block] {
+ var blocks: [Block] = []
+ var buffer = ""
+ var inCode = false
+ var codeLang: String? = nil
+ var codeBuffer = ""
+
+ for rawLine in text.split(separator: "\n", omittingEmptySubsequences: false) {
+ let line = String(rawLine)
+ if line.hasPrefix("```") {
+ if inCode {
+ blocks.append(.code(lang: codeLang, body: codeBuffer))
+ codeBuffer = ""
+ codeLang = nil
+ inCode = false
+ } else {
+ if !buffer.isEmpty {
+ blocks.append(.prose(buffer.trimmingCharacters(in: .whitespacesAndNewlines)))
+ buffer = ""
+ }
+ let lang = line.dropFirst(3).trimmingCharacters(in: .whitespaces)
+ codeLang = lang.isEmpty ? nil : lang
+ inCode = true
+ }
+ continue
+ }
+ if inCode {
+ codeBuffer += line + "\n"
+ } else {
+ buffer += line + "\n"
+ }
+ }
+ if !buffer.isEmpty {
+ blocks.append(.prose(buffer.trimmingCharacters(in: .whitespacesAndNewlines)))
+ }
+ if inCode, !codeBuffer.isEmpty {
+ blocks.append(.code(lang: codeLang, body: codeBuffer))
+ }
+ return blocks.filter {
+ switch $0 {
+ case .prose(let s): return !s.isEmpty
+ case .code: return true
+ }
+ }
+ }
+}
+
+struct CodeBlockView: View {
+ let language: String?
+ let body: String
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 0) {
+ if let lang = language, !lang.isEmpty {
+ HStack {
+ Text(lang.uppercased())
+ .font(Typography.navLabel)
+ .foregroundStyle(Palette.foregroundMuted)
+ Spacer()
+ }
+ .padding(.horizontal, 8)
+ .padding(.vertical, 4)
+ .background(Palette.backgroundDeep)
+ }
+ ScrollView(.horizontal, showsIndicators: false) {
+ Text(body)
+ .font(.system(.footnote, design: .monospaced))
+ .foregroundStyle(Palette.foreground)
+ .padding(10)
+ .textSelection(.enabled)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .fixedSize(horizontal: false, vertical: true)
+ }
+ .background(Palette.panel)
+ }
+ .overlay(Rectangle().strokeBorder(Palette.borderMuted, lineWidth: 1))
+ }
+}
diff --git a/makima/ios/Sources/Makima/Models/Contract.swift b/makima/ios/Sources/Makima/Models/Contract.swift
new file mode 100644
index 0000000..8c9de58
--- /dev/null
+++ b/makima/ios/Sources/Makima/Models/Contract.swift
@@ -0,0 +1,21 @@
+import Foundation
+
+/// Matches `src/db/models.rs::Contract` (camelCase).
+struct Contract: Decodable, Identifiable, Hashable {
+ let id: String
+ let ownerId: String?
+ let name: String
+ let description: String?
+ let contractType: String // "simple" | "specification"
+ let phase: String // "research" | "specify" | "plan" | "execute" | "review"
+ let status: String // "active" | "completed" | "archived"
+ let supervisorTaskId: String?
+ let autonomousLoop: Bool?
+ let phaseGuard: Bool?
+ let localOnly: Bool?
+ let version: Int?
+ let createdAt: Date?
+ let updatedAt: Date?
+
+ var isActive: Bool { status.lowercased() == "active" }
+}
diff --git a/makima/ios/Sources/Makima/Models/Daemon.swift b/makima/ios/Sources/Makima/Models/Daemon.swift
index 523e4da..78b824e 100644
--- a/makima/ios/Sources/Makima/Models/Daemon.swift
+++ b/makima/ios/Sources/Makima/Models/Daemon.swift
@@ -1,9 +1,28 @@
import Foundation
-/// Minimal projection of the daemon object for the auth probe. Full model
-/// lands at M6 when we wire the Daemons screen.
-struct DaemonBrief: Decodable, Identifiable {
+/// Full daemon record. Matches `src/db/models.rs::Daemon` (camelCase).
+struct Daemon: Decodable, Identifiable, Hashable {
let id: String
- let status: String?
+ let ownerId: String?
+ let connectionId: String?
let hostname: String?
+ let machineId: String?
+ let maxConcurrentTasks: Int?
+ let currentTaskCount: Int?
+ let status: String
+ let lastHeartbeatAt: Date?
+ let connectedAt: Date?
+ let disconnectedAt: Date?
+
+ var isOnline: Bool {
+ status.lowercased() == "online" || status.lowercased() == "connected" || status.lowercased() == "active"
+ }
+
+ var displayHost: String {
+ hostname ?? machineId ?? connectionId ?? String(id.prefix(8))
+ }
}
+
+/// Compatibility shim: a handful of M2 call sites still type `DaemonBrief`.
+/// Resolve to the same shape.
+typealias DaemonBrief = Daemon
diff --git a/makima/ios/Sources/Makima/Models/Directive.swift b/makima/ios/Sources/Makima/Models/Directive.swift
new file mode 100644
index 0000000..e46a8d2
--- /dev/null
+++ b/makima/ios/Sources/Makima/Models/Directive.swift
@@ -0,0 +1,16 @@
+import Foundation
+
+/// Matches `src/db/models.rs::Directive` (camelCase).
+struct Directive: Decodable, Identifiable, Hashable {
+ let id: String
+ let ownerId: String?
+ let goal: String?
+ let name: String?
+ let status: String?
+ let createdAt: Date?
+ let updatedAt: Date?
+
+ var displayName: String {
+ name ?? goal ?? String(id.prefix(8))
+ }
+}
diff --git a/makima/ios/Sources/Makima/Models/ListEnvelope.swift b/makima/ios/Sources/Makima/Models/ListEnvelope.swift
new file mode 100644
index 0000000..27854d9
--- /dev/null
+++ b/makima/ios/Sources/Makima/Models/ListEnvelope.swift
@@ -0,0 +1,31 @@
+import Foundation
+
+/// Makima's list endpoints wrap collections like `{ items: [...], total: N }`.
+/// Generic envelope lets us decode any list endpoint uniformly.
+struct ListEnvelope<Item: Decodable>: Decodable {
+ let items: [Item]
+ let total: Int
+
+ // Custom decoder because the wrapper key varies per endpoint
+ // (daemons / tasks / contracts / directives / orders).
+ // We accept any of those names.
+ private enum CodingKeys: String, CodingKey {
+ case daemons, tasks, contracts, directives, orders
+ case total
+ }
+
+ init(from decoder: Decoder) throws {
+ let c = try decoder.container(keyedBy: CodingKeys.self)
+ self.total = (try? c.decode(Int.self, forKey: .total)) ?? 0
+
+ if let v = try? c.decode([Item].self, forKey: .daemons) { self.items = v; return }
+ if let v = try? c.decode([Item].self, forKey: .tasks) { self.items = v; return }
+ if let v = try? c.decode([Item].self, forKey: .contracts) { self.items = v; return }
+ if let v = try? c.decode([Item].self, forKey: .directives) { self.items = v; return }
+ if let v = try? c.decode([Item].self, forKey: .orders) { self.items = v; return }
+
+ // Fallback: single-value bare array (defensive)
+ let single = try decoder.singleValueContainer()
+ self.items = (try? single.decode([Item].self)) ?? []
+ }
+}
diff --git a/makima/ios/Sources/Makima/Models/ListenSession.swift b/makima/ios/Sources/Makima/Models/ListenSession.swift
new file mode 100644
index 0000000..f68f4c6
--- /dev/null
+++ b/makima/ios/Sources/Makima/Models/ListenSession.swift
@@ -0,0 +1,16 @@
+import Foundation
+
+/// Minimal projection — the full listen session schema has many fields we
+/// don't need in v1 Home. Extend as we build the Listen surface at M6.
+struct ListenSession: Decodable, Identifiable, Hashable {
+ let id: String
+ let title: String?
+ let startedAt: Date?
+ let endedAt: Date?
+ let transcriptPreview: String?
+ let duration: Double?
+
+ var displayTitle: String {
+ title ?? "Session \(id.prefix(6))"
+ }
+}
diff --git a/makima/ios/Sources/Makima/Models/Task.swift b/makima/ios/Sources/Makima/Models/Task.swift
new file mode 100644
index 0000000..ae5466b
--- /dev/null
+++ b/makima/ios/Sources/Makima/Models/Task.swift
@@ -0,0 +1,51 @@
+import Foundation
+
+/// Matches `src/db/models.rs::Task` (camelCase). Trimmed to the fields the
+/// mobile UI needs; extra fields in the payload are tolerated via
+/// non-exhaustive decoding.
+struct MakimaTask: Decodable, Identifiable, Hashable {
+ let id: String
+ let ownerId: String?
+ let contractId: String?
+ let parentTaskId: String?
+ let depth: Int?
+ let name: String
+ let description: String?
+ let status: String // "pending"|"running"|"paused"|"blocked"|"done"|"failed"
+ let priority: Int?
+ let plan: String?
+ let isSupervisor: Bool?
+ let daemonId: String?
+ let progressSummary: String?
+ let lastOutput: String?
+ let errorMessage: String?
+ let createdAt: Date?
+ let updatedAt: Date?
+
+ var statusColor: StatusKind {
+ switch status.lowercased() {
+ case "done", "completed": return .ok
+ case "running": return .active
+ case "pending", "paused": return .idle
+ case "blocked": return .warn
+ case "failed", "error", "cancelled": return .danger
+ default: return .idle
+ }
+ }
+
+ enum StatusKind {
+ case ok, active, idle, warn, danger
+ }
+}
+
+/// Response shape for `GET /mesh/tasks/{id}/output`.
+struct TaskOutputResponse: Decodable {
+ let output: String?
+ let lastOutput: String?
+ let truncated: Bool?
+
+ /// Coalesces whichever field the server populates.
+ var text: String {
+ output ?? lastOutput ?? ""
+ }
+}
diff --git a/makima/ios/Sources/Makima/Net/AuthStore.swift b/makima/ios/Sources/Makima/Net/AuthStore.swift
index 0046548..d09488f 100644
--- a/makima/ios/Sources/Makima/Net/AuthStore.swift
+++ b/makima/ios/Sources/Makima/Net/AuthStore.swift
@@ -2,6 +2,7 @@ import Foundation
/// Top-level authentication state. Mediates between ServerProfileStore
/// (on-disk) and APIClient (in-memory, per session).
+@MainActor
@Observable
final class AuthStore {
enum State: Equatable {
@@ -44,7 +45,6 @@ final class AuthStore {
/// Validate and persist a new (server, key) pair. On success, becomes the
/// active profile and flips state to `.authenticated`.
- @MainActor
func configure(baseURLString: String, apiKey: String, label: String? = nil) async {
state = .validating
@@ -60,20 +60,21 @@ final class AuthStore {
let client = APIClient(profile: profile, apiKey: apiKey)
do {
- // Anything authenticated works; daemons endpoint is cheap.
- let _: [DaemonBrief] = try await client.get("/mesh/daemons")
+ // Auth probe — daemons list is small and gated by the same middleware
+ // as every other authed endpoint.
+ let _: ListEnvelope<Daemon> = try await client.get("/mesh/daemons")
} catch APIError.unauthorized {
state = .error("Server rejected the API key. Double-check `mk_…` and try again.")
return
} catch APIError.notFound {
- // 404 on /mesh/daemons is odd but shouldn't block onboarding — the auth
- // layer ran first and didn't reject, so the key is probably fine.
+ // Tolerate 404 — auth middleware runs first, so a 404 means auth
+ // passed but the endpoint was renamed. Proceed; deeper surfaces
+ // will surface the real problem.
} catch {
state = .error(error.localizedDescription)
return
}
- // Persist
do {
try Keychain.set(apiKey, for: profile.keychainID)
} catch {
@@ -95,7 +96,6 @@ final class AuthStore {
/// Overwrite the server URL on the active profile. Re-validates; on 401
/// prompts for a new key (caller shows UI). Keeps the existing key if the
/// URL works against it.
- @MainActor
func updateBaseURL(_ baseURLString: String) async {
guard var profile = profiles.activeProfile,
let key = try? Keychain.get(profile.keychainID), !key.isEmpty else {
@@ -113,14 +113,13 @@ final class AuthStore {
let client = APIClient(profile: profile, apiKey: key)
do {
- let _: [DaemonBrief] = try await client.get("/mesh/daemons")
+ let _: ListEnvelope<Daemon> = try await client.get("/mesh/daemons")
} catch APIError.unauthorized {
- // Keep the profile but force re-auth
profiles.upsert(profile)
state = .needsOnboarding
return
} catch APIError.notFound {
- // tolerate as above
+ // tolerate
} catch {
state = .error(error.localizedDescription)
return
@@ -132,21 +131,23 @@ final class AuthStore {
self.state = .authenticated
}
- /// Rotate the API key via `POST /auth/api-keys/refresh`. Swaps in the new
- /// key on success.
- @MainActor
+ /// Rotate the API key via `POST /auth/api-keys/refresh`.
func rotateKey() async throws {
guard let client = client else { throw APIError.notConfigured }
struct RefreshBody: Encodable { let confirm: Bool = true }
- struct RefreshResp: Decodable { let api_key: String }
+ struct RefreshResp: Decodable {
+ let apiKey: String?
+ let api_key: String?
+ /// Server may return either casing; take whichever is present.
+ var value: String? { apiKey ?? api_key }
+ }
let resp: RefreshResp = try await client.post("/auth/api-keys/refresh", body: RefreshBody())
- try Keychain.set(resp.api_key, for: client.profile.keychainID)
- self.client = APIClient(profile: client.profile, apiKey: resp.api_key)
+ guard let new = resp.value else { throw APIError.decoding("refresh response missing apiKey") }
+ try Keychain.set(new, for: client.profile.keychainID)
+ self.client = APIClient(profile: client.profile, apiKey: new)
}
- /// Sign out: wipes Keychain + active profile marker, clears in-memory client.
- @MainActor
func signOut() {
if let profile = profiles.activeProfile {
try? Keychain.delete(profile.keychainID)
@@ -156,8 +157,6 @@ final class AuthStore {
self.state = .needsOnboarding
}
- // MARK: - Helpers
-
static func labelFrom(_ urlString: String) -> String? {
URL(string: urlString)?.host
}
diff --git a/makima/ios/Sources/Makima/Net/NotificationCenterBridge.swift b/makima/ios/Sources/Makima/Net/NotificationCenterBridge.swift
new file mode 100644
index 0000000..71fe3ae
--- /dev/null
+++ b/makima/ios/Sources/Makima/Net/NotificationCenterBridge.swift
@@ -0,0 +1,60 @@
+import Foundation
+import UserNotifications
+import UIKit
+
+/// Surfaces WS events as local notifications while the app has an active
+/// session (foregrounded or background-grace). APNs push lands at v1.1.
+@MainActor
+enum NotificationCenterBridge {
+ static func requestAuthorizationIfNeeded() async {
+ let center = UNUserNotificationCenter.current()
+ let current = await center.notificationSettings()
+ guard current.authorizationStatus == .notDetermined else { return }
+ _ = try? await center.requestAuthorization(options: [.alert, .badge, .sound])
+ }
+
+ static func notifyTaskStatus(taskId: String, taskName: String, status: String) {
+ let status = status.lowercased()
+ let (title, body): (String?, String?) = {
+ switch status {
+ case "done", "completed":
+ return ("TASK COMPLETED", "\(taskName) is done.")
+ case "failed", "error":
+ return ("TASK FAILED", "\(taskName) stopped with an error.")
+ case "blocked":
+ return ("TASK BLOCKED", "\(taskName) needs your input.")
+ default:
+ return (nil, nil)
+ }
+ }()
+ guard let title, let body else { return }
+
+ let content = UNMutableNotificationContent()
+ content.title = title
+ content.body = body
+ content.sound = .default
+ content.userInfo = ["deepLink": "makima://task/\(taskId)"]
+
+ let request = UNNotificationRequest(
+ identifier: "task-\(taskId)-\(status)",
+ content: content,
+ trigger: nil
+ )
+ UNUserNotificationCenter.current().add(request)
+ }
+
+ static func notifyDirectiveQuestion(directiveId: String, title: String) {
+ let content = UNMutableNotificationContent()
+ content.title = "DIRECTIVE QUESTION"
+ content.body = "\(title) needs your answer."
+ content.sound = .default
+ content.userInfo = ["deepLink": "makima://directive/\(directiveId)"]
+
+ let request = UNNotificationRequest(
+ identifier: "directive-\(directiveId)",
+ content: content,
+ trigger: nil
+ )
+ UNUserNotificationCenter.current().add(request)
+ }
+}
diff --git a/makima/ios/Sources/Makima/Net/TaskWebSocket.swift b/makima/ios/Sources/Makima/Net/TaskWebSocket.swift
new file mode 100644
index 0000000..08efc9c
--- /dev/null
+++ b/makima/ios/Sources/Makima/Net/TaskWebSocket.swift
@@ -0,0 +1,242 @@
+import Foundation
+
+/// Minimal WebSocket client for `/api/v1/mesh/tasks/subscribe`.
+///
+/// Protocol lines up with `src/server/handlers/mesh_ws.rs`:
+///
+/// client -> server
+/// { "type": "subscribeAll" }
+/// { "type": "subscribe", "taskId": "<uuid>" }
+/// { "type": "subscribeOutput", "taskId": "<uuid>" }
+///
+/// server -> client
+/// { "type": "taskUpdated", "taskId", "status", "updatedFields", ... }
+/// { "type": "taskOutput", "taskId", "messageType", "content", ... }
+/// { "type": "error", "code", "message" }
+///
+/// Auth: the current server's WS upgrade handler does NOT check auth headers
+/// (verified in mesh_ws.rs — handler lacks an extractor). Filtering is done
+/// by owner_id server-side when subscribing to a specific task. We still
+/// attach `x-makima-api-key` on the upgrade request so future tightening
+/// doesn't break us.
+@MainActor
+final class TaskWebSocket {
+ enum Event {
+ case taskUpdated(TaskUpdatedEvent)
+ case taskOutput(TaskOutputEvent)
+ case error(code: String, message: String)
+ }
+
+ struct TaskUpdatedEvent: Decodable {
+ let taskId: String
+ let version: Int?
+ let status: String?
+ let updatedFields: [String]?
+ let updatedBy: String?
+ }
+
+ struct TaskOutputEvent: Decodable {
+ let taskId: String
+ let messageType: String
+ let content: String
+ let toolName: String?
+ let toolInput: JSONValue?
+ let isError: Bool?
+ let costUsd: Double?
+ let durationMs: Int?
+ let isPartial: Bool?
+ }
+
+ var onStatusChange: ((WebSocketStatus) -> Void)?
+
+ private let profile: ServerProfile
+ private let apiKey: String
+ private var task: URLSessionWebSocketTask?
+ private var session: URLSession
+ private var retries: Int = 0
+ private var subscribedAll = false
+ private var perTaskHandlers: [String: (Event) -> Void] = [:]
+ private var status: WebSocketStatus = .idle {
+ didSet { onStatusChange?(status) }
+ }
+ private var reconnectTask: Task<Void, Never>?
+
+ init(profile: ServerProfile, apiKey: String, session: URLSession = .shared) {
+ self.profile = profile
+ self.apiKey = apiKey
+ self.session = session
+ }
+
+ func connect() {
+ guard let url = profile.apiWebSocketBaseURL?.appendingPathComponent("mesh/tasks/subscribe") else {
+ return
+ }
+ var request = URLRequest(url: url)
+ request.setValue(apiKey, forHTTPHeaderField: "x-makima-api-key")
+ request.setValue("makima-ios/\(APIClient.appVersion)", forHTTPHeaderField: "user-agent")
+
+ let task = session.webSocketTask(with: request)
+ self.task = task
+ self.status = .connecting
+ task.resume()
+
+ // Subscribe-all on open so Home/Tasks get updates without per-task
+ // subscriptions racing the initial render.
+ send(.subscribeAll)
+ receiveLoop()
+ }
+
+ func disconnect() {
+ reconnectTask?.cancel()
+ reconnectTask = nil
+ task?.cancel(with: .goingAway, reason: nil)
+ task = nil
+ status = .offline
+ perTaskHandlers.removeAll()
+ subscribedAll = false
+ }
+
+ func subscribe(taskId: String, handler: @escaping (Event) -> Void) {
+ perTaskHandlers[taskId] = handler
+ send(.subscribe(taskId: taskId))
+ send(.subscribeOutput(taskId: taskId))
+ }
+
+ func unsubscribe(taskId: String) {
+ perTaskHandlers.removeValue(forKey: taskId)
+ send(.unsubscribe(taskId: taskId))
+ send(.unsubscribeOutput(taskId: taskId))
+ }
+
+ // MARK: - Wire protocol
+
+ enum ClientMessage {
+ case subscribeAll
+ case subscribe(taskId: String)
+ case unsubscribe(taskId: String)
+ case subscribeOutput(taskId: String)
+ case unsubscribeOutput(taskId: String)
+
+ var jsonObject: [String: Any] {
+ switch self {
+ case .subscribeAll: return ["type": "subscribeAll"]
+ case .subscribe(let id): return ["type": "subscribe", "taskId": id]
+ case .unsubscribe(let id): return ["type": "unsubscribe", "taskId": id]
+ case .subscribeOutput(let id): return ["type": "subscribeOutput", "taskId": id]
+ case .unsubscribeOutput(let id): return ["type": "unsubscribeOutput", "taskId": id]
+ }
+ }
+ }
+
+ private func send(_ message: ClientMessage) {
+ guard let data = try? JSONSerialization.data(withJSONObject: message.jsonObject),
+ let str = String(data: data, encoding: .utf8)
+ else { return }
+
+ if case .subscribeAll = message { subscribedAll = true }
+
+ task?.send(.string(str)) { [weak self] error in
+ if let error {
+ print("[TaskWebSocket] send failed: \(error)")
+ Task { @MainActor in self?.scheduleReconnect() }
+ }
+ }
+ }
+
+ private func receiveLoop() {
+ task?.receive { [weak self] result in
+ Task { @MainActor in
+ guard let self else { return }
+ switch result {
+ case .success(let message):
+ self.status = .online
+ self.retries = 0
+ switch message {
+ case .string(let text):
+ self.handle(text: text)
+ case .data(let data):
+ if let text = String(data: data, encoding: .utf8) {
+ self.handle(text: text)
+ }
+ @unknown default:
+ break
+ }
+ self.receiveLoop()
+ case .failure(let error):
+ print("[TaskWebSocket] receive failed: \(error)")
+ self.scheduleReconnect()
+ }
+ }
+ }
+ }
+
+ private func handle(text: String) {
+ guard let data = text.data(using: .utf8),
+ let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
+ let type = obj["type"] as? String
+ else { return }
+
+ let decoder = JSONDecoder()
+ decoder.dateDecodingStrategy = .iso8601
+
+ switch type {
+ case "taskUpdated":
+ if let event = try? decoder.decode(TaskUpdatedEvent.self, from: data) {
+ dispatch(.taskUpdated(event), forTaskId: event.taskId)
+ }
+ case "taskOutput":
+ if let event = try? decoder.decode(TaskOutputEvent.self, from: data) {
+ dispatch(.taskOutput(event), forTaskId: event.taskId)
+ }
+ case "error":
+ let code = (obj["code"] as? String) ?? "ERROR"
+ let msg = (obj["message"] as? String) ?? ""
+ print("[TaskWebSocket] server error \(code): \(msg)")
+ case "subscribed", "subscribedAll", "unsubscribed", "unsubscribedAll",
+ "outputSubscribed", "outputUnsubscribed":
+ break
+ default:
+ break
+ }
+ }
+
+ private func dispatch(_ event: Event, forTaskId id: String) {
+ perTaskHandlers[id]?(event)
+ }
+
+ private func scheduleReconnect() {
+ guard reconnectTask == nil else { return }
+ status = .offline
+ retries = min(retries + 1, 6)
+ let delay = min(30.0, pow(2.0, Double(retries)))
+ reconnectTask = Task { [weak self] in
+ try? await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000))
+ if Task.isCancelled { return }
+ guard let self else { return }
+ self.reconnectTask = nil
+ self.disconnect()
+ self.connect()
+ }
+ }
+}
+
+/// Loosely-typed JSON value for WS tool inputs where the schema varies.
+enum JSONValue: Decodable {
+ case null
+ case bool(Bool)
+ case number(Double)
+ case string(String)
+ case array([JSONValue])
+ case object([String: JSONValue])
+
+ init(from decoder: Decoder) throws {
+ let c = try decoder.singleValueContainer()
+ if c.decodeNil() { self = .null; return }
+ if let v = try? c.decode(Bool.self) { self = .bool(v); return }
+ if let v = try? c.decode(Double.self) { self = .number(v); return }
+ if let v = try? c.decode(String.self) { self = .string(v); return }
+ if let v = try? c.decode([JSONValue].self) { self = .array(v); return }
+ if let v = try? c.decode([String: JSONValue].self) { self = .object(v); return }
+ throw DecodingError.dataCorruptedError(in: c, debugDescription: "Unknown JSON value")
+ }
+}
diff --git a/makima/ios/Sources/Makima/Stores/HomeStore.swift b/makima/ios/Sources/Makima/Stores/HomeStore.swift
new file mode 100644
index 0000000..a014b84
--- /dev/null
+++ b/makima/ios/Sources/Makima/Stores/HomeStore.swift
@@ -0,0 +1,65 @@
+import Foundation
+
+@MainActor
+@Observable
+final class HomeStore {
+ enum LoadState: Equatable {
+ case idle, loading, loaded, failed(String)
+ }
+
+ var state: LoadState = .idle
+ var activeContracts: [Contract] = []
+ var activeContractsTotal: Int = 0
+ var daemons: [Daemon] = []
+ var pendingDirectives: [Directive] = []
+ var recentTasks: [MakimaTask] = []
+ var latestListen: ListenSession?
+ var lastRefreshed: Date?
+
+ private let client: APIClient
+
+ init(client: APIClient) {
+ self.client = client
+ }
+
+ func refresh() async {
+ state = .loading
+
+ async let contractsTask: ListEnvelope<Contract>? = tryGet("/contracts")
+ async let daemonsTask: ListEnvelope<Daemon>? = tryGet("/mesh/daemons")
+ async let directivesTask: ListEnvelope<Directive>? = tryGet("/directives")
+ async let tasksTask: ListEnvelope<MakimaTask>? = tryGet("/mesh/tasks")
+ async let listenTask: ListEnvelope<ListenSession>? = tryGet("/listen/sessions")
+
+ let (contracts, daemons, directives, tasks, listen) =
+ await (contractsTask, daemonsTask, directivesTask, tasksTask, listenTask)
+
+ self.activeContracts = (contracts?.items ?? []).filter { $0.isActive }
+ self.activeContractsTotal = self.activeContracts.count
+ self.daemons = daemons?.items ?? []
+ self.pendingDirectives = (directives?.items ?? []).filter {
+ ($0.status ?? "").lowercased() == "pending"
+ || ($0.status ?? "").lowercased() == "waiting"
+ }
+ self.recentTasks = Array(
+ (tasks?.items ?? [])
+ .sorted { ($0.updatedAt ?? .distantPast) > ($1.updatedAt ?? .distantPast) }
+ .prefix(5)
+ )
+ self.latestListen = listen?.items
+ .sorted { ($0.startedAt ?? .distantPast) > ($1.startedAt ?? .distantPast) }
+ .first
+ self.lastRefreshed = .now
+ self.state = .loaded
+ }
+
+ /// Like `client.get` but swallows errors — each card degrades independently.
+ private func tryGet<T: Decodable>(_ path: String) async -> T? {
+ do { return try await client.get(path) }
+ catch {
+ // Log at console for dev; don't block the whole home view
+ print("[Home] GET \(path) failed: \(error)")
+ return nil
+ }
+ }
+}
diff --git a/makima/ios/Tests/MakimaTests/APIClientTests.swift b/makima/ios/Tests/MakimaTests/APIClientTests.swift
index 9277aa3..8df91b6 100644
--- a/makima/ios/Tests/MakimaTests/APIClientTests.swift
+++ b/makima/ios/Tests/MakimaTests/APIClientTests.swift
@@ -10,13 +10,13 @@ final class APIClientTests: XCTestCase {
URLProtocolStub.Response(
url: "https://makima.test/api/v1/mesh/daemons",
status: 200,
- body: #"[]"#.data(using: .utf8)!
+ body: #"{"daemons":[],"total":0}"#.data(using: .utf8)!
)
]
let profile = ServerProfile(label: "test", baseURLString: "https://makima.test")
let client = APIClient(profile: profile, apiKey: "mk_testkey", session: URLProtocolStub.session())
- let _: [DaemonBrief] = try await client.get("/mesh/daemons")
+ let _: ListEnvelope<Daemon> = try await client.get("/mesh/daemons")
let recorded = try XCTUnwrap(stub.received.first)
XCTAssertEqual(recorded.url?.absoluteString, "https://makima.test/api/v1/mesh/daemons")
@@ -36,7 +36,7 @@ final class APIClientTests: XCTestCase {
let client = APIClient(profile: profile, apiKey: "mk_x", session: URLProtocolStub.session())
do {
- let _: [DaemonBrief] = try await client.get("/mesh/daemons")
+ let _: ListEnvelope<Daemon> = try await client.get("/mesh/daemons")
XCTFail("Expected unauthorized")
} catch {
XCTAssertEqual(error as? APIError, .unauthorized)
diff --git a/makima/ios/Tests/MakimaTests/CompletionGateTests.swift b/makima/ios/Tests/MakimaTests/CompletionGateTests.swift
new file mode 100644
index 0000000..2fd5655
--- /dev/null
+++ b/makima/ios/Tests/MakimaTests/CompletionGateTests.swift
@@ -0,0 +1,38 @@
+import XCTest
+@testable import Makima
+
+final class CompletionGateTests: XCTestCase {
+ func testExtractsReadyTrue() {
+ let out = """
+ Some output here
+ <COMPLETION_GATE>
+ ready: true
+ reason: "All tests pass"
+ progress: "Implemented feature X"
+ </COMPLETION_GATE>
+ Trailing stuff.
+ """
+ let gate = CompletionGate.extract(from: out)
+ XCTAssertNotNil(gate)
+ XCTAssertTrue(gate?.ready == true)
+ XCTAssertEqual(gate?.reason, "All tests pass")
+ XCTAssertEqual(gate?.progress, "Implemented feature X")
+ XCTAssertTrue(gate?.blockers.isEmpty == true)
+ }
+
+ func testExtractsBlockers() {
+ let out = """
+ <COMPLETION_GATE>
+ ready: false
+ blockers: "needs env var, missing migration, flaky test"
+ </COMPLETION_GATE>
+ """
+ let gate = CompletionGate.extract(from: out)
+ XCTAssertEqual(gate?.ready, false)
+ XCTAssertEqual(gate?.blockers.count, 3)
+ }
+
+ func testReturnsNilWhenMissing() {
+ XCTAssertNil(CompletionGate.extract(from: "just some text"))
+ }
+}
diff --git a/makima/ios/Tests/MakimaTests/DeepLinkTests.swift b/makima/ios/Tests/MakimaTests/DeepLinkTests.swift
new file mode 100644
index 0000000..c368ff2
--- /dev/null
+++ b/makima/ios/Tests/MakimaTests/DeepLinkTests.swift
@@ -0,0 +1,23 @@
+import XCTest
+@testable import Makima
+
+final class DeepLinkTests: XCTestCase {
+ func testTaskDeepLink() {
+ let url = URL(string: "makima://task/abc-123")!
+ let link = DeepLink(url: url)
+ XCTAssertEqual(link, .task(id: "abc-123"))
+ }
+
+ func testDirectiveDeepLink() {
+ let url = URL(string: "makima://directive/xyz")!
+ XCTAssertEqual(DeepLink(url: url), .directive(id: "xyz"))
+ }
+
+ func testRejectsOtherSchemes() {
+ XCTAssertNil(DeepLink(url: URL(string: "https://makima.jp/task/abc")!))
+ }
+
+ func testRejectsUnknownHost() {
+ XCTAssertNil(DeepLink(url: URL(string: "makima://widget/abc")!))
+ }
+}
diff --git a/makima/ios/Tests/MakimaTests/ListEnvelopeTests.swift b/makima/ios/Tests/MakimaTests/ListEnvelopeTests.swift
new file mode 100644
index 0000000..4d06266
--- /dev/null
+++ b/makima/ios/Tests/MakimaTests/ListEnvelopeTests.swift
@@ -0,0 +1,27 @@
+import XCTest
+@testable import Makima
+
+final class ListEnvelopeTests: XCTestCase {
+ func testDecodesDaemonsWrapper() throws {
+ let json = """
+ { "daemons": [{"id":"d1","status":"online"}], "total": 1 }
+ """
+ let env = try JSONDecoder().decode(ListEnvelope<Daemon>.self, from: Data(json.utf8))
+ XCTAssertEqual(env.total, 1)
+ XCTAssertEqual(env.items.first?.id, "d1")
+ }
+
+ func testDecodesTasksWrapper() throws {
+ let json = """
+ { "tasks": [{"id":"t1","name":"hello","status":"pending"}], "total": 1 }
+ """
+ let env = try JSONDecoder().decode(ListEnvelope<MakimaTask>.self, from: Data(json.utf8))
+ XCTAssertEqual(env.items.first?.name, "hello")
+ }
+
+ func testDecodesBareArrayFallback() throws {
+ let json = "[{\"id\":\"d1\",\"status\":\"online\"}]"
+ let env = try JSONDecoder().decode(ListEnvelope<Daemon>.self, from: Data(json.utf8))
+ XCTAssertEqual(env.items.count, 1)
+ }
+}
diff --git a/makima/ios/Tests/MakimaTests/MarkdownBlocksTests.swift b/makima/ios/Tests/MakimaTests/MarkdownBlocksTests.swift
new file mode 100644
index 0000000..89b9b92
--- /dev/null
+++ b/makima/ios/Tests/MakimaTests/MarkdownBlocksTests.swift
@@ -0,0 +1,30 @@
+import XCTest
+@testable import Makima
+
+final class MarkdownBlocksTests: XCTestCase {
+ func testSeparatesProseAndCode() {
+ let input = """
+ Hello world.
+
+ ```swift
+ let x = 1
+ ```
+
+ Trailing prose.
+ """
+ let blocks = MarkdownBlocks.parse(text: input)
+ XCTAssertEqual(blocks.count, 3)
+ if case .prose(let s) = blocks[0] { XCTAssertTrue(s.contains("Hello world")) } else { XCTFail() }
+ if case .code(let lang, let body) = blocks[1] {
+ XCTAssertEqual(lang, "swift")
+ XCTAssertTrue(body.contains("let x = 1"))
+ } else { XCTFail() }
+ if case .prose(let s) = blocks[2] { XCTAssertTrue(s.contains("Trailing")) } else { XCTFail() }
+ }
+
+ func testPureProseIsOneBlock() {
+ let blocks = MarkdownBlocks.parse(text: "just text here")
+ XCTAssertEqual(blocks.count, 1)
+ if case .prose(let s) = blocks[0] { XCTAssertEqual(s, "just text here") } else { XCTFail() }
+ }
+}
diff --git a/makima/ios/docs/RELEASING.md b/makima/ios/docs/RELEASING.md
index 1564a1a..4d4513d 100644
--- a/makima/ios/docs/RELEASING.md
+++ b/makima/ios/docs/RELEASING.md
@@ -1,6 +1,71 @@
-# Releasing (placeholder — filled in at M8)
+# Releasing Makima iOS
-- App Store Connect: Soryu LTD
-- Bundle ID: `co.soryu.makima`
-- TestFlight internal group: TBD
-- Fastlane lanes: TBD
+App Store Connect organisation: **Soryu LTD**
+Bundle ID: `co.soryu.makima`
+Scheme: `Makima`
+
+## Pre-flight
+
+1. Update `CURRENT_PROJECT_VERSION` and `MARKETING_VERSION` in `project.yml`.
+2. `make xcgen`
+3. `make test` — must pass.
+4. Visual smoke test against `makima.jp`:
+ - Fresh install → onboarding → home renders with 5 cards
+ - Tap Contracts → list → detail → task → output shows polled + live events
+ - Settings → rotate key — new key prefix appears in masked line
+5. Check `Info.plist`:
+ - `ITSAppUsesNonExemptEncryption` = `NO` (HTTPS + APNs only; no custom crypto)
+ - `NSAppTransportSecurity.NSAllowsArbitraryLoads` = `NO`
+ - `CFBundleURLTypes` contains `makima://` scheme
+
+## Archive + upload
+
+```bash
+make xcgen
+xcodebuild -project Makima.xcodeproj \
+ -scheme Makima \
+ -configuration Release \
+ -destination 'generic/platform=iOS' \
+ -archivePath build/Makima.xcarchive \
+ archive
+
+xcodebuild -exportArchive \
+ -archivePath build/Makima.xcarchive \
+ -exportOptionsPlist ExportOptions.plist \
+ -exportPath build/export
+```
+
+`ExportOptions.plist` (not committed — create locally with your team ID):
+
+```xml
+<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
+<plist version="1.0">
+<dict>
+ <key>destination</key><string>upload</string>
+ <key>method</key><string>app-store-connect</string>
+ <key>teamID</key><string>YOUR_TEAM_ID</string>
+ <key>signingStyle</key><string>automatic</string>
+</dict>
+</plist>
+```
+
+## TestFlight
+
+Internal group "Soryu" gets automatic distribution on every new build.
+External group "Makima Early" requires one review cycle; subsequent builds
+in the same version string pass through without review.
+
+## App Store submission checklist
+
+- [ ] Privacy policy URL → repo README (no tracking, no analytics, Keychain-only credentials)
+- [ ] Category: Developer Tools (mirrors KittyLitter)
+- [ ] Pricing: Free (no IAP in v1)
+- [ ] Encryption: none beyond OS TLS (ITSAppUsesNonExemptEncryption=NO already set)
+- [ ] Screenshots: 6.7" + 6.1" in dark mode, showing Home / Contract detail / Task live / Directives
+- [ ] Keywords: makima, soryu, orchestration, AI, agent, codex, claude, devops
+
+## Versioning
+
+Semver for marketing version. Bump build number (`CURRENT_PROJECT_VERSION`)
+on every TestFlight upload, bump marketing version on user-visible releases.