iOS port M6: YAML parser port, Settings view, Cron editing

Ports the Mac app's YAML parser into ScarfCore, unlocking iOS
Settings. Adds Cron editing (add / delete / toggle / edit). Settings
stays read-only this phase (writes need a round-trip-preserving YAML
writer — out of scope). App Store submission deferred to a later
task per the brief.

## ScarfCore — YAML infrastructure

Packages/ScarfCore/Sources/ScarfCore/Parsing/HermesYAML.swift:
  - ParsedYAML struct (values / lists / maps)
  - HermesYAML.parseNestedYAML(_:) — indent-based block parser
  - HermesYAML.stripYAMLQuotes(_:) — single-layer quote stripping

Lifted verbatim from HermesFileService.parseNestedYAML/stripYAMLQuotes
and hoisted into a standalone namespace. Scope unchanged: the subset
Hermes's config.yaml actually uses (block nesting, scalars, bullet
lists, nested maps). NOT full YAML-spec compliance.

Packages/ScarfCore/Sources/ScarfCore/Parsing/HermesConfig+YAML.swift:
  - HermesConfig.init(yaml:) — ports HermesFileService.parseConfig
    one-for-one. Every default, every key, every legacy fallback
    (platforms.slack.* vs slack.*, command_allowlist vs permanent_
    allowlist, etc.) matches the Mac implementation.
  - Forgiving: malformed YAML produces partial state + defaults
    rather than throwing. Callers surface the raw text so users can
    diagnose parse failures on their own.

## ScarfCore — Cron editing (write paths)

Packages/ScarfCore/Sources/ScarfCore/ViewModels/IOSCronViewModel.swift:
  - toggleEnabled(id:)
  - delete(id:)
  - upsert(_:)
All funnel through private saveJobs(_:) which encodes the full
CronJobsFile (.prettyPrinted + .sortedKeys), writes atomically via
transport.writeFile (Data.write-atomic from M5). Creates the cron/
directory on fresh installs.

Models/HermesCronJob.swift — both HermesCronJob and CronJobsFile
gained real public memberwise inits (Swift's synthesis was
suppressed by the hand-written Codable; first draft hacked around
this with JSON round-trips). Also HermesCronJob.withEnabled(_:)
does clean field passthrough instead of encode→mutate→decode.

## ScarfCore — iOS Settings VM

Packages/ScarfCore/Sources/ScarfCore/ViewModels/IOSSettingsViewModel.swift:
  - Reads ~/.hermes/config.yaml via ServerContext.readText
  - Parses with HermesConfig(yaml:)
  - Surfaces both parsed config and rawYAML
  - M6 read-only by design — config.yaml needs round-trip-preserving
    YAML serialization (comments, key order, whitespace) for safe
    edits; option (a) hand-write one, (b) YAML library dep, (c)
    delegate to `hermes config set` via ACP. Defer.

## iOS app

Scarf iOS/Settings/SettingsView.swift:
  - Read-only browser grouped into 10 sections matching the Mac
    app's tabs. DisclosureGroup at the bottom reveals raw YAML
    source for diagnostics.

Scarf iOS/Cron/CronListView.swift rewritten:
  - Toggle-enabled circle (tap to flip, saves atomically)
  - Swipe-to-delete
  - "+" toolbar for new job → editor sheet
  - Row-tap opens editor with existing fields populated

New CronEditorView form:
  - Name, Prompt, Enabled toggle
  - Schedule: kind picker (cron/interval/once), display, expression
    (for cron), run_at (for once)
  - Optional model + comma-separated skills + delivery route
  - Preserves runtime fields (nextRunAt, lastRunAt,
    deliveryFailures, etc.) when editing existing jobs — no reset

Dashboard's Surfaces section gains a 5th row: Settings.

## Test-suite reorganization (real bug caught)

swift-testing's `.serialized` trait serializes WITHIN one @Suite, not
across suites. Shipping M6 revealed a 3-way race on
`ServerContext.sshTransportFactory`:
  - M5's `.serialized` suite sets factory, runs, restores.
  - M6's `.serialized` suite did the same in parallel — clobbered.
  - M0b's non-serialized `serverContextMakeTransportDispatches`
    asserted the DEFAULT factory (nil) returned SSHTransport —
    saw whichever factory was temporarily installed.

Fix: one serialization domain for everything that touches the
factory. Move cron-editing + settings-load M6 tests into M5's
serialized suite. M0b's factory-dependent assertion (SSHTransport
fallback) also moves to the M5 serialized suite with an explicit
`factory = nil` reset for race-freedom. Pure YAML/config/memberwise
tests stay in the new plain (non-serialized) M6ConfigCronTests
suite — they never touch globals.

## Test results: 108 → 134 passing on Linux

19 new in M6ConfigCronTests:
  - YAML parser: scalars, bullets, nested maps, comments, quotes,
    inline {} / []
  - HermesConfig.init(yaml:): empty → defaults, model + agent,
    display, security + blocklist domains, slack legacy fallback,
    auxiliary (3 populated + 2 defaulted), permanent_allowlist vs
    command_allowlist, quoted strings
  - Memberwise inits for HermesCronJob, withEnabled(_:),
    CronJobsFile, CronSchedule

7 new in M5FeatureVMTests (.serialized):
  - defaultFactoryProducesSSHTransportForRemoteContext (moved +
    hardened with explicit factory reset)
  - cronUpsertCreatesFileFromScratch, cronToggleEnabledPersists,
    cronDeleteRemovesJob, cronUpsertReplacesMatchingId,
    cronPreservesRuntimeFieldsAcrossReloads
  - settingsLoadsFromConfigYAML, settingsSurfacesMissingFile

## Manual validation needed on Mac

1. Xcode compile clean.
2. Settings: confirm every section populates from your real
   ~/.hermes/config.yaml. Tap "View source" disclosure, verify raw
   text matches the remote file.
3. Cron: toggle-enabled survives refresh + relaunch. Swipe-delete
   works. "+" creates jobs; round-trip name/prompt/schedule/skills.
   Edit preserves runtime state.
4. Skills: unchanged from M5 (still browse-only, deferred).

Updated scarf/docs/IOS_PORT_PLAN.md with M6's shipped state, the
YAML-parser scope ceiling, the Settings-edit deferral rationale, and
the cross-suite serialization rule for future test authors.

https://claude.ai/code/session_019yMRP6mwZWfzVrPTqevx2y
This commit is contained in:
Claude
2026-04-23 00:31:17 +00:00
parent 6b731ddfb8
commit 44d2d6d6c6
12 changed files with 1625 additions and 108 deletions
@@ -32,6 +32,49 @@ public struct HermesCronJob: Identifiable, Sendable, Codable {
case timeoutSeconds = "timeout_seconds" case timeoutSeconds = "timeout_seconds"
} }
/// Memberwise init. Swift doesn't synthesize one for us because
/// of the hand-written Codable conformance. The iOS Cron editor
/// uses this to rebuild jobs from user-edited fields.
public nonisolated init(
id: String,
name: String,
prompt: String,
skills: [String]? = nil,
model: String? = nil,
schedule: CronSchedule,
enabled: Bool,
state: String,
deliver: String? = nil,
nextRunAt: String? = nil,
lastRunAt: String? = nil,
lastError: String? = nil,
preRunScript: String? = nil,
deliveryFailures: Int? = nil,
lastDeliveryError: String? = nil,
timeoutType: String? = nil,
timeoutSeconds: Int? = nil,
silent: Bool? = nil
) {
self.id = id
self.name = name
self.prompt = prompt
self.skills = skills
self.model = model
self.schedule = schedule
self.enabled = enabled
self.state = state
self.deliver = deliver
self.nextRunAt = nextRunAt
self.lastRunAt = lastRunAt
self.lastError = lastError
self.preRunScript = preRunScript
self.deliveryFailures = deliveryFailures
self.lastDeliveryError = lastDeliveryError
self.timeoutType = timeoutType
self.timeoutSeconds = timeoutSeconds
self.silent = silent
}
public nonisolated init(from decoder: any Decoder) throws { public nonisolated init(from decoder: any Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self) let c = try decoder.container(keyedBy: CodingKeys.self)
self.id = try c.decode(String.self, forKey: .id) self.id = try c.decode(String.self, forKey: .id)
@@ -115,6 +158,18 @@ public struct CronSchedule: Sendable, Codable {
case expression case expression
} }
public nonisolated init(
kind: String,
runAt: String? = nil,
display: String? = nil,
expression: String? = nil
) {
self.kind = kind
self.runAt = runAt
self.display = display
self.expression = expression
}
public nonisolated init(from decoder: any Decoder) throws { public nonisolated init(from decoder: any Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self) let c = try decoder.container(keyedBy: CodingKeys.self)
self.kind = try c.decode(String.self, forKey: .kind) self.kind = try c.decode(String.self, forKey: .kind)
@@ -144,6 +199,11 @@ public struct CronJobsFile: Sendable, Codable {
case updatedAt = "updated_at" case updatedAt = "updated_at"
} }
public nonisolated init(jobs: [HermesCronJob], updatedAt: String?) {
self.jobs = jobs
self.updatedAt = updatedAt
}
public nonisolated init(from decoder: any Decoder) throws { public nonisolated init(from decoder: any Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self) let c = try decoder.container(keyedBy: CodingKeys.self)
self.jobs = try c.decode([HermesCronJob].self, forKey: .jobs) self.jobs = try c.decode([HermesCronJob].self, forKey: .jobs)
@@ -0,0 +1,275 @@
import Foundation
/// YAML-driven `HermesConfig` constructor. Lifted verbatim (with
/// trivial adjustments to access the ScarfCore-public types) from
/// `HermesFileService.parseConfig` so the same key struct-field
/// mapping feeds both the Mac app and iOS.
///
/// **Behaviour parity.** Every default value, every key, and every
/// fallback path in this file tracks the Mac implementation
/// one-for-one. If the Mac parser learns to recognise a new key,
/// this one should too (and vice versa). The M6 test suite freezes
/// the defaults + a few recognition paths, so behaviour drift
/// surfaces on Linux CI without needing Xcode.
public extension HermesConfig {
/// Parse a `config.yaml` string into a fully-populated
/// `HermesConfig`. Missing keys fall back to `HermesConfig.empty`-
/// compatible defaults. Unknown keys are ignored Hermes is
/// forward-compatible, i.e. a config file with newer keys than
/// scarf knows still loads.
///
/// The parse is deliberately forgiving: malformed YAML produces
/// whatever partial state the parser could recover + defaults
/// for everything else, not a throw. The iOS Settings view
/// surfaces the raw file on top of this so users can spot a
/// broken key even when the struct came back defaulted.
init(yaml: String) {
let parsed = HermesYAML.parseNestedYAML(yaml)
let values = parsed.values
let lists = parsed.lists
let maps = parsed.maps
func bool(_ key: String, default def: Bool) -> Bool {
guard let v = values[key] else { return def }
return v == "true"
}
func int(_ key: String, default def: Int) -> Int {
Int(values[key] ?? "") ?? def
}
func double(_ key: String, default def: Double) -> Double {
Double(values[key] ?? "") ?? def
}
func str(_ key: String, default def: String = "") -> String {
let raw = values[key] ?? def
return HermesYAML.stripYAMLQuotes(raw)
}
let dockerEnv = maps["terminal.docker_env"] ?? [:]
let commandAllowlist = lists["permanent_allowlist"] ?? lists["command_allowlist"] ?? []
let display = DisplaySettings(
skin: str("display.skin", default: "default"),
compact: bool("display.compact", default: false),
resumeDisplay: str("display.resume_display", default: "full"),
bellOnComplete: bool("display.bell_on_complete", default: false),
inlineDiffs: bool("display.inline_diffs", default: true),
toolProgressCommand: bool("display.tool_progress_command", default: false),
toolPreviewLength: int("display.tool_preview_length", default: 0),
busyInputMode: str("display.busy_input_mode", default: "interrupt")
)
let terminal = TerminalSettings(
cwd: str("terminal.cwd", default: "."),
timeout: int("terminal.timeout", default: 180),
envPassthrough: lists["terminal.env_passthrough"] ?? [],
persistentShell: bool("terminal.persistent_shell", default: true),
dockerImage: str("terminal.docker_image"),
dockerMountCwdToWorkspace: bool("terminal.docker_mount_cwd_to_workspace", default: false),
dockerForwardEnv: lists["terminal.docker_forward_env"] ?? [],
dockerVolumes: lists["terminal.docker_volumes"] ?? [],
containerCPU: int("terminal.container_cpu", default: 0),
containerMemory: int("terminal.container_memory", default: 0),
containerDisk: int("terminal.container_disk", default: 0),
containerPersistent: bool("terminal.container_persistent", default: false),
modalImage: str("terminal.modal_image"),
modalMode: str("terminal.modal_mode", default: "auto"),
daytonaImage: str("terminal.daytona_image"),
singularityImage: str("terminal.singularity_image")
)
let browser = BrowserSettings(
inactivityTimeout: int("browser.inactivity_timeout", default: 120),
commandTimeout: int("browser.command_timeout", default: 30),
recordSessions: bool("browser.record_sessions", default: false),
allowPrivateURLs: bool("browser.allow_private_urls", default: false),
camofoxManagedPersistence: bool("browser.camofox.managed_persistence", default: false)
)
let voice = VoiceSettings(
recordKey: str("voice.record_key", default: "ctrl+b"),
maxRecordingSeconds: int("voice.max_recording_seconds", default: 120),
silenceDuration: double("voice.silence_duration", default: 3.0),
ttsProvider: str("tts.provider", default: "edge"),
ttsEdgeVoice: str("tts.edge.voice", default: "en-US-AriaNeural"),
ttsElevenLabsVoiceID: str("tts.elevenlabs.voice_id"),
ttsElevenLabsModelID: str("tts.elevenlabs.model_id", default: "eleven_multilingual_v2"),
ttsOpenAIModel: str("tts.openai.model", default: "gpt-4o-mini-tts"),
ttsOpenAIVoice: str("tts.openai.voice", default: "alloy"),
ttsNeuTTSModel: str("tts.neutts.model"),
ttsNeuTTSDevice: str("tts.neutts.device", default: "cpu"),
sttEnabled: bool("stt.enabled", default: true),
sttProvider: str("stt.provider", default: "local"),
sttLocalModel: str("stt.local.model", default: "base"),
sttLocalLanguage: str("stt.local.language"),
sttOpenAIModel: str("stt.openai.model", default: "whisper-1"),
sttMistralModel: str("stt.mistral.model", default: "voxtral-mini-latest")
)
func aux(_ name: String) -> AuxiliaryModel {
AuxiliaryModel(
provider: str("auxiliary.\(name).provider", default: "auto"),
model: str("auxiliary.\(name).model"),
baseURL: str("auxiliary.\(name).base_url"),
apiKey: str("auxiliary.\(name).api_key"),
timeout: int("auxiliary.\(name).timeout", default: 30)
)
}
let auxiliary = AuxiliarySettings(
vision: aux("vision"),
webExtract: aux("web_extract"),
compression: aux("compression"),
sessionSearch: aux("session_search"),
skillsHub: aux("skills_hub"),
approval: aux("approval"),
mcp: aux("mcp"),
flushMemories: aux("flush_memories")
)
let security = SecuritySettings(
redactSecrets: bool("security.redact_secrets", default: true),
redactPII: bool("privacy.redact_pii", default: false),
tirithEnabled: bool("security.tirith_enabled", default: true),
tirithPath: str("security.tirith_path", default: "tirith"),
tirithTimeout: int("security.tirith_timeout", default: 5),
tirithFailOpen: bool("security.tirith_fail_open", default: true),
blocklistEnabled: bool("security.website_blocklist.enabled", default: false),
blocklistDomains: lists["security.website_blocklist.domains"] ?? []
)
let humanDelay = HumanDelaySettings(
mode: str("human_delay.mode", default: "off"),
minMS: int("human_delay.min_ms", default: 800),
maxMS: int("human_delay.max_ms", default: 2500)
)
let compression = CompressionSettings(
enabled: bool("compression.enabled", default: true),
threshold: double("compression.threshold", default: 0.5),
targetRatio: double("compression.target_ratio", default: 0.2),
protectLastN: int("compression.protect_last_n", default: 20)
)
let checkpoints = CheckpointSettings(
enabled: bool("checkpoints.enabled", default: true),
maxSnapshots: int("checkpoints.max_snapshots", default: 50)
)
let logging = LoggingSettings(
level: str("logging.level", default: "INFO"),
maxSizeMB: int("logging.max_size_mb", default: 5),
backupCount: int("logging.backup_count", default: 3)
)
let delegation = DelegationSettings(
model: str("delegation.model"),
provider: str("delegation.provider"),
baseURL: str("delegation.base_url"),
apiKey: str("delegation.api_key"),
maxIterations: int("delegation.max_iterations", default: 50)
)
let discord = DiscordSettings(
requireMention: bool("discord.require_mention", default: true),
freeResponseChannels: str("discord.free_response_channels"),
autoThread: bool("discord.auto_thread", default: true),
reactions: bool("discord.reactions", default: true)
)
let telegram = TelegramSettings(
requireMention: bool("telegram.require_mention", default: true),
reactions: bool("telegram.reactions", default: false)
)
// Slack fields live under both `platforms.slack.*` (newer) and `slack.*`
// (legacy). Prefer the newer path but fall back.
let slack = SlackSettings(
replyToMode: values["platforms.slack.reply_to_mode"] ?? values["slack.reply_to_mode"] ?? "first",
requireMention: (values["platforms.slack.require_mention"] ?? values["slack.require_mention"]) != "false",
replyInThread: (values["platforms.slack.extra.reply_in_thread"] ?? "true") != "false",
replyBroadcast: (values["platforms.slack.extra.reply_broadcast"] ?? "false") == "true"
)
let matrix = MatrixSettings(
requireMention: bool("matrix.require_mention", default: true),
autoThread: bool("matrix.auto_thread", default: true),
dmMentionThreads: bool("matrix.dm_mention_threads", default: false)
)
let mattermost = MattermostSettings(
requireMention: bool("mattermost.require_mention", default: true),
replyMode: str("mattermost.reply_mode", default: "off")
)
let whatsapp = WhatsAppSettings(
unauthorizedDMBehavior: str("whatsapp.unauthorized_dm_behavior", default: "pair"),
replyPrefix: str("whatsapp.reply_prefix")
)
// Home Assistant lives under `platforms.homeassistant.extra.*`.
let homeAssistant = HomeAssistantSettings(
watchDomains: lists["platforms.homeassistant.extra.watch_domains"] ?? [],
watchEntities: lists["platforms.homeassistant.extra.watch_entities"] ?? [],
watchAll: bool("platforms.homeassistant.extra.watch_all", default: false),
ignoreEntities: lists["platforms.homeassistant.extra.ignore_entities"] ?? [],
cooldownSeconds: int("platforms.homeassistant.extra.cooldown_seconds", default: 30)
)
self.init(
model: str("model.default", default: "unknown"),
provider: str("model.provider", default: "unknown"),
maxTurns: int("agent.max_turns", default: 0),
personality: str("display.personality", default: "default"),
terminalBackend: str("terminal.backend", default: "local"),
memoryEnabled: bool("memory.memory_enabled", default: false),
memoryCharLimit: int("memory.memory_char_limit", default: 0),
userCharLimit: int("memory.user_char_limit", default: 0),
nudgeInterval: int("memory.nudge_interval", default: 0),
streaming: values["display.streaming"] != "false",
showReasoning: bool("display.show_reasoning", default: false),
verbose: bool("agent.verbose", default: false),
autoTTS: values["voice.auto_tts"] != "false",
silenceThreshold: int("voice.silence_threshold", default: QueryDefaults.defaultSilenceThreshold),
reasoningEffort: str("agent.reasoning_effort", default: "medium"),
showCost: bool("display.show_cost", default: false),
approvalMode: str("approvals.mode", default: "manual"),
browserBackend: str("browser.backend"),
memoryProvider: str("memory.provider"),
dockerEnv: dockerEnv,
commandAllowlist: commandAllowlist,
memoryProfile: str("memory.profile"),
serviceTier: str("agent.service_tier", default: "normal"),
gatewayNotifyInterval: int("agent.gateway_notify_interval", default: 600),
forceIPv4: bool("network.force_ipv4", default: false),
contextEngine: str("context.engine", default: "compressor"),
interimAssistantMessages: values["display.interim_assistant_messages"] != "false",
honchoInitOnSessionStart: bool("honcho.initOnSessionStart", default: false),
timezone: str("timezone"),
userProfileEnabled: bool("memory.user_profile_enabled", default: true),
toolUseEnforcement: str("agent.tool_use_enforcement", default: "auto"),
gatewayTimeout: int("agent.gateway_timeout", default: 1800),
approvalTimeout: int("approvals.timeout", default: 60),
fileReadMaxChars: int("file_read_max_chars", default: 100_000),
cronWrapResponse: bool("cron.wrap_response", default: true),
prefillMessagesFile: str("prefill_messages_file"),
skillsExternalDirs: lists["skills.external_dirs"] ?? [],
display: display,
terminal: terminal,
browser: browser,
voice: voice,
auxiliary: auxiliary,
security: security,
humanDelay: humanDelay,
compression: compression,
checkpoints: checkpoints,
logging: logging,
delegation: delegation,
discord: discord,
telegram: telegram,
slack: slack,
matrix: matrix,
mattermost: mattermost,
whatsapp: whatsapp,
homeAssistant: homeAssistant
)
}
}
@@ -0,0 +1,137 @@
import Foundation
/// Parsed YAML result bundle. Flat dotted-path keys point at the
/// three value shapes we care about (scalars, bullet lists, maps).
///
/// **Scope note.** This is NOT a full YAML-spec parser. It handles
/// the subset used by Hermes's `config.yaml`: indent-based block
/// nesting, string/int/bool/float scalars, `- item` bullet lists,
/// and one level of nested `key: value` maps. Anchors, aliases,
/// multi-line scalars (`|` / `>` block scalars), flow-style `[ ]` /
/// `{ }` literals, tags none of those are supported. That covers
/// 100% of what the current Hermes config actually uses.
///
/// The original implementation lived in the Mac app's
/// `HermesFileService`. Ported into ScarfCore in M6 so iOS can read
/// `config.yaml` through the same parser without having to pull in a
/// third-party YAML dependency.
public struct ParsedYAML: Sendable {
/// Scalar key-value pairs at any indent level
/// `values["section.key"] = "..."`.
public var values: [String: String]
/// Bullet-list items attached to a parent key
/// `lists["section.key"] = [...]`.
public var lists: [String: [String]]
/// Nested `key: value` maps captured under a section header
/// `maps["section"] = [key: value, ...]`.
public var maps: [String: [String: String]]
public init(
values: [String: String] = [:],
lists: [String: [String]] = [:],
maps: [String: [String: String]] = [:]
) {
self.values = values
self.lists = lists
self.maps = maps
}
}
/// Entry points for Hermes-flavored YAML parsing. Stateless, pure
/// functions no Foundation types that differ cross-platform.
public enum HermesYAML {
/// Parse a YAML string into a `ParsedYAML` bundle.
public static func parseNestedYAML(_ yaml: String) -> ParsedYAML {
var values: [String: String] = [:]
var lists: [String: [String]] = [:]
var maps: [String: [String: String]] = [:]
// Path stack: each entry is (indent, name). Pop when indent shrinks.
var stack: [(indent: Int, name: String)] = []
func currentPath(joinedWith child: String? = nil) -> String {
var parts = stack.map(\.name)
if let child { parts.append(child) }
return parts.joined(separator: ".")
}
let rawLines = yaml.components(separatedBy: "\n")
for line in rawLines {
// Skip comment-only and blank lines but preserve indent semantics.
let trimmed = line.trimmingCharacters(in: .whitespaces)
if trimmed.isEmpty || trimmed.hasPrefix("#") { continue }
let indent = line.prefix(while: { $0 == " " }).count
let isListItem = trimmed.hasPrefix("- ")
// Pop stack entries with indent >= current indent.
// Exception: a list item at the same indent as its parent key is
// valid block-style YAML ("toolsets:\n- hermes-cli") keep the
// parent so the item is attributed to it.
while let top = stack.last {
let shouldPop: Bool
if isListItem && top.indent == indent {
shouldPop = false
} else {
shouldPop = top.indent >= indent
}
if shouldPop { stack.removeLast() } else { break }
}
if isListItem {
let item = String(trimmed.dropFirst(2)).trimmingCharacters(in: .whitespaces)
let stripped = stripYAMLQuotes(item)
let path = currentPath()
guard !path.isEmpty else { continue }
lists[path, default: []].append(stripped)
continue
}
// Key-value or section line.
guard let colonIdx = trimmed.firstIndex(of: ":") else { continue }
let key = String(trimmed[trimmed.startIndex..<colonIdx]).trimmingCharacters(in: .whitespaces)
let afterColon = String(trimmed[trimmed.index(after: colonIdx)...]).trimmingCharacters(in: .whitespaces)
let path = currentPath(joinedWith: key)
if afterColon.isEmpty || afterColon == "|" || afterColon == ">" {
// Section header or empty-valued key push onto stack so children nest.
stack.append((indent: indent, name: key))
continue
}
// Inline `{}` / `[]` literals treat as empty.
if afterColon == "{}" {
values[path] = ""
maps[path] = [:]
continue
}
if afterColon == "[]" {
values[path] = ""
lists[path] = []
continue
}
values[path] = afterColon
// Also record as a map entry under the parent so blocks like
// `terminal.docker_env` are accessible as `[String: String]`
// without a separate scan.
if !stack.isEmpty {
let parentPath = currentPath()
maps[parentPath, default: [:]][key] = stripYAMLQuotes(afterColon)
}
}
return ParsedYAML(values: values, lists: lists, maps: maps)
}
/// Strip a single layer of surrounding single or double quotes from a YAML scalar.
public static func stripYAMLQuotes(_ s: String) -> String {
guard s.count >= 2 else { return s }
let first = s.first!
let last = s.last!
if (first == "'" && last == "'") || (first == "\"" && last == "\"") {
return String(s.dropFirst().dropLast())
}
return s
}
}
@@ -1,15 +1,14 @@
import Foundation import Foundation
import Observation import Observation
/// iOS read-only Cron view-state. Loads `~/.hermes/cron/jobs.json` /// iOS Cron view-state. Loads `~/.hermes/cron/jobs.json` via the
/// via the transport, decodes into `CronJobsFile` (already Codable /// transport, decodes into `CronJobsFile` (Codable, from M0a),
/// in ScarfCore), exposes the list for SwiftUI. /// exposes the sorted list for SwiftUI.
/// ///
/// M5 is read-only by design editing cron jobs (add / delete / /// M6 adds write paths: toggle enabled, delete, and upsert (add or
/// toggle enabled) is deferred until we have a clearer iOS story for /// replace a job by id). All writes re-encode the full file with a
/// rewriting `jobs.json` atomically across the SSH SFTP path. The /// fresh `updatedAt` and call `transport.writeFile` which on iOS
/// Mac app's `CronViewModel` does this through `HermesFileService`; /// dispatches to Citadel SFTP with atomic rename semantics.
/// porting that is out of scope for M5.
@Observable @Observable
@MainActor @MainActor
public final class IOSCronViewModel { public final class IOSCronViewModel {
@@ -17,6 +16,7 @@ public final class IOSCronViewModel {
public private(set) var jobs: [HermesCronJob] = [] public private(set) var jobs: [HermesCronJob] = []
public private(set) var isLoading: Bool = true public private(set) var isLoading: Bool = true
public private(set) var isSaving: Bool = false
public private(set) var lastError: String? public private(set) var lastError: String?
public init(context: ServerContext) { public init(context: ServerContext) {
@@ -43,17 +43,7 @@ public final class IOSCronViewModel {
switch result { switch result {
case .success(let file): case .success(let file):
// Sort: enabled first, then by nextRunAt ascending (nil jobs = Self.sorted(file.jobs)
// last). Matches what the Mac app does for list rendering.
jobs = file.jobs.sorted { lhs, rhs in
if lhs.enabled != rhs.enabled { return lhs.enabled }
switch (lhs.nextRunAt, rhs.nextRunAt) {
case (let l?, let r?): return l < r
case (_?, nil): return true
case (nil, _?): return false
case (nil, nil): return lhs.name < rhs.name
}
}
isLoading = false isLoading = false
case .failure(let err as LoadError): case .failure(let err as LoadError):
@@ -73,6 +63,97 @@ public final class IOSCronViewModel {
} }
} }
/// Toggle `enabled` on the job with the given id, re-encode, and
/// write back. On failure, leaves the in-memory state unchanged
/// and sets `lastError`.
@discardableResult
public func toggleEnabled(id: String) async -> Bool {
guard let idx = jobs.firstIndex(where: { $0.id == id }) else { return false }
var updated = jobs
let prev = updated[idx]
updated[idx] = prev.withEnabled(!prev.enabled)
return await saveJobs(updated)
}
/// Remove the job with `id` and save.
@discardableResult
public func delete(id: String) async -> Bool {
let updated = jobs.filter { $0.id != id }
guard updated.count != jobs.count else { return false }
return await saveJobs(updated)
}
/// Add a new job or replace an existing one with matching id.
@discardableResult
public func upsert(_ job: HermesCronJob) async -> Bool {
var updated = jobs
if let idx = updated.firstIndex(where: { $0.id == job.id }) {
updated[idx] = job
} else {
updated.append(job)
}
return await saveJobs(updated)
}
// MARK: - Internal
/// Shared persistence path: serialize `CronJobsFile` as pretty
/// JSON, write it atomically through the transport, and update
/// the in-memory list on success.
private func saveJobs(_ newJobs: [HermesCronJob]) async -> Bool {
guard !isSaving else { return false }
isSaving = true
lastError = nil
let ctx = context
let path = ctx.paths.cronJobsJSON
let iso = ISO8601DateFormatter()
iso.formatOptions = [.withInternetDateTime]
let file = CronJobsFile(jobs: newJobs, updatedAt: iso.string(from: Date()))
let ok: Bool = await Task.detached {
do {
let encoder = JSONEncoder()
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
let data = try encoder.encode(file)
let transport = ctx.makeTransport()
// Ensure the cron/ directory exists on a fresh
// Hermes install this file won't be present.
let parent = (path as NSString).deletingLastPathComponent
if !transport.fileExists(parent) {
try? transport.createDirectory(parent)
}
try transport.writeFile(path, data: data)
return true
} catch {
return false
}
}.value
isSaving = false
if ok {
jobs = Self.sorted(newJobs)
return true
} else {
lastError = "Couldn't save jobs.json — check the connection and try again."
return false
}
}
/// Sort: enabled first, then by `nextRunAt` ascending (nil last,
/// then by name). Matches the Mac app's list rendering.
private static func sorted(_ jobs: [HermesCronJob]) -> [HermesCronJob] {
jobs.sorted { lhs, rhs in
if lhs.enabled != rhs.enabled { return lhs.enabled }
switch (lhs.nextRunAt, rhs.nextRunAt) {
case (let l?, let r?): return l < r
case (_?, nil): return true
case (nil, _?): return false
case (nil, nil): return lhs.name < rhs.name
}
}
}
public enum LoadError: Error, LocalizedError { public enum LoadError: Error, LocalizedError {
case missingFile(path: String) case missingFile(path: String)
@@ -83,3 +164,32 @@ public final class IOSCronViewModel {
} }
} }
} }
// MARK: - HermesCronJob helpers
public extension HermesCronJob {
/// Return a copy with a different `enabled` flag. Used by the iOS
/// Cron list's toggle. All other fields pass through unchanged.
func withEnabled(_ newEnabled: Bool) -> HermesCronJob {
HermesCronJob(
id: id,
name: name,
prompt: prompt,
skills: skills,
model: model,
schedule: schedule,
enabled: newEnabled,
state: state,
deliver: deliver,
nextRunAt: nextRunAt,
lastRunAt: lastRunAt,
lastError: lastError,
preRunScript: preRunScript,
deliveryFailures: deliveryFailures,
lastDeliveryError: lastDeliveryError,
timeoutType: timeoutType,
timeoutSeconds: timeoutSeconds,
silent: silent
)
}
}
@@ -0,0 +1,58 @@
import Foundation
import Observation
/// iOS Settings view-state. Loads `~/.hermes/config.yaml` via the
/// transport, parses it into a `HermesConfig` with the ScarfCore
/// YAML port, and exposes the parsed struct plus a copy of the raw
/// text for users who want to see the source.
///
/// **M6 is read-only by design.** Editing config.yaml safely requires
/// either (a) a round-trip preserving YAML parser (comments, key
/// order, whitespace) or (b) delegating to `hermes config set` via
/// ACP. Either is more work than fits in M6; the Mac app's Settings
/// uses (a) via HermesFileService's manipulators. A later phase can
/// port the write side.
@Observable
@MainActor
public final class IOSSettingsViewModel {
public let context: ServerContext
/// Parsed config. Falls back to `.empty` when the file is missing
/// or malformed; `lastError` carries the reason so the UI can
/// surface it.
public private(set) var config: HermesConfig = .empty
/// Raw YAML text. Useful for the "View source" disclosure, and
/// for diagnosing parse failures (our parser is forgiving but
/// lossy on malformed input).
public private(set) var rawYAML: String = ""
public private(set) var isLoading: Bool = true
public private(set) var lastError: String?
public init(context: ServerContext) {
self.context = context
}
public func load() async {
isLoading = true
lastError = nil
let ctx = context
let path = ctx.paths.configYAML
let text: String? = await Task.detached {
ctx.readText(path)
}.value
guard let text else {
config = .empty
rawYAML = ""
lastError = "`\(path)` not found on \(ctx.displayName). Once Hermes is configured on this host, Settings will light up."
isLoading = false
return
}
rawYAML = text
config = HermesConfig(yaml: text)
isLoading = false
}
}
@@ -76,21 +76,16 @@ import Foundation
#expect(remoteDefault.paths.home == "~/.hermes") #expect(remoteDefault.paths.home == "~/.hermes")
} }
@Test func serverContextMakeTransportDispatches() { @Test func serverContextMakeTransportDispatchesLocal() {
// Only assert the .local path here. The .ssh SSHTransport
// default-factory assertion lives in the serialized
// M5FeatureVMTests suite because it depends on
// `ServerContext.sshTransportFactory` being nil, which races
// with any other parallel test installing a custom factory.
let local = ServerContext.local.makeTransport() let local = ServerContext.local.makeTransport()
#expect(local is LocalTransport) #expect(local is LocalTransport)
#expect(local.isRemote == false) #expect(local.isRemote == false)
#expect(local.contextID == ServerContext.local.id) #expect(local.contextID == ServerContext.local.id)
let remoteCtx = ServerContext(
id: UUID(),
displayName: "r",
kind: .ssh(SSHConfig(host: "h"))
)
let remote = remoteCtx.makeTransport()
#expect(remote is SSHTransport)
#expect(remote.isRemote == true)
#expect(remote.contextID == remoteCtx.id)
} }
@Test func fileStatMemberwise() { @Test func fileStatMemberwise() {
@@ -326,4 +326,201 @@ import Foundation
#expect(p.options[0].optionId == "allow") #expect(p.options[0].optionId == "allow")
} }
#endif #endif
// MARK: - M0b default SSH transport factory path
//
// Moved here from M0bTransportTests because it asserts the
// default-factory (nil) behavior which any other test in a
// parallel suite installing a custom factory would clobber.
// Living in a .serialized suite + explicitly resetting the
// factory makes the assertion race-free.
@Test @MainActor func defaultFactoryProducesSSHTransportForRemoteContext() {
let previous = ServerContext.sshTransportFactory
defer { ServerContext.sshTransportFactory = previous }
ServerContext.sshTransportFactory = nil
let remoteCtx = ServerContext(
id: UUID(),
displayName: "r",
kind: .ssh(SSHConfig(host: "h"))
)
let remote = remoteCtx.makeTransport()
#expect(remote is SSHTransport)
#expect(remote.isRemote == true)
#expect(remote.contextID == remoteCtx.id)
}
// MARK: - M6 Cron editing (write paths)
//
// Live in this suite (rather than M6ConfigCronTests) because they
// install the `ServerContext.sshTransportFactory` static same
// pattern as the Memory/Cron/Skills read-path tests above. Mixing
// factory-users across multiple `.serialized` suites races on
// the static, so M6's factory-touching tests merge here.
@Test @MainActor func cronUpsertCreatesFileFromScratch() async throws {
try await withLocalTransportFactory { [self] in
let (ctx, _) = try makeFakeHermes()
let vm = IOSCronViewModel(context: ctx)
await vm.load()
#expect(vm.jobs.isEmpty)
let job = HermesCronJob(
id: "job_abc",
name: "Morning brief",
prompt: "summarize my calendar",
skills: ["calendar"],
model: nil,
schedule: CronSchedule(kind: "cron", display: "9am", expression: "0 9 * * *"),
enabled: true,
state: "scheduled"
)
let ok = await vm.upsert(job)
#expect(ok)
#expect(vm.jobs.count == 1)
#expect(vm.jobs[0].name == "Morning brief")
let vm2 = IOSCronViewModel(context: ctx)
await vm2.load()
#expect(vm2.jobs.count == 1)
#expect(vm2.jobs[0].id == "job_abc")
#expect(vm2.jobs[0].prompt == "summarize my calendar")
#expect(vm2.jobs[0].skills == ["calendar"])
}
}
@Test @MainActor func cronToggleEnabledPersists() async throws {
try await withLocalTransportFactory { [self] in
let (ctx, _) = try makeFakeHermes()
let vm = IOSCronViewModel(context: ctx)
await vm.upsert(HermesCronJob(
id: "j1", name: "A", prompt: "p",
schedule: CronSchedule(kind: "cron"),
enabled: true, state: "scheduled"
))
#expect(vm.jobs[0].enabled)
let ok = await vm.toggleEnabled(id: "j1")
#expect(ok)
#expect(vm.jobs[0].enabled == false)
let vm2 = IOSCronViewModel(context: ctx)
await vm2.load()
#expect(vm2.jobs[0].enabled == false)
}
}
@Test @MainActor func cronDeleteRemovesJob() async throws {
try await withLocalTransportFactory { [self] in
let (ctx, _) = try makeFakeHermes()
let vm = IOSCronViewModel(context: ctx)
await vm.upsert(HermesCronJob(id: "a", name: "A", prompt: "p", schedule: CronSchedule(kind: "cron"), enabled: true, state: "scheduled"))
await vm.upsert(HermesCronJob(id: "b", name: "B", prompt: "q", schedule: CronSchedule(kind: "cron"), enabled: true, state: "scheduled"))
#expect(vm.jobs.count == 2)
let ok = await vm.delete(id: "a")
#expect(ok)
#expect(vm.jobs.count == 1)
#expect(vm.jobs[0].id == "b")
let vm2 = IOSCronViewModel(context: ctx)
await vm2.load()
#expect(vm2.jobs.count == 1)
#expect(vm2.jobs[0].id == "b")
}
}
@Test @MainActor func cronUpsertReplacesMatchingId() async throws {
try await withLocalTransportFactory { [self] in
let (ctx, _) = try makeFakeHermes()
let vm = IOSCronViewModel(context: ctx)
await vm.upsert(HermesCronJob(
id: "j1", name: "Original", prompt: "p1",
schedule: CronSchedule(kind: "cron"),
enabled: true, state: "scheduled"
))
await vm.upsert(HermesCronJob(
id: "j1", name: "Renamed", prompt: "p2",
schedule: CronSchedule(kind: "interval"),
enabled: false, state: "scheduled"
))
#expect(vm.jobs.count == 1)
#expect(vm.jobs[0].name == "Renamed")
#expect(vm.jobs[0].prompt == "p2")
#expect(vm.jobs[0].enabled == false)
}
}
@Test @MainActor func cronPreservesRuntimeFieldsAcrossReloads() async throws {
try await withLocalTransportFactory { [self] in
let (ctx, _) = try makeFakeHermes()
let vm = IOSCronViewModel(context: ctx)
await vm.upsert(HermesCronJob(
id: "j1", name: "Kept", prompt: "p",
skills: nil, model: "gpt-4",
schedule: CronSchedule(kind: "cron", display: "midnight"),
enabled: true,
state: "completed",
deliver: "discord:general",
nextRunAt: "2026-04-25T00:00:00Z",
lastRunAt: "2026-04-24T00:00:00Z",
deliveryFailures: 3,
lastDeliveryError: "rate limited",
timeoutType: "soft",
timeoutSeconds: 600,
silent: false
))
let vm2 = IOSCronViewModel(context: ctx)
await vm2.load()
let j = vm2.jobs[0]
#expect(j.nextRunAt == "2026-04-25T00:00:00Z")
#expect(j.lastRunAt == "2026-04-24T00:00:00Z")
#expect(j.deliveryFailures == 3)
#expect(j.lastDeliveryError == "rate limited")
#expect(j.timeoutSeconds == 600)
#expect(j.state == "completed")
}
}
// MARK: - M6 Settings
@Test @MainActor func settingsLoadsFromConfigYAML() async throws {
try await withLocalTransportFactory { [self] in
let (ctx, home) = try makeFakeHermes()
let yaml = """
model:
default: gpt-4o
provider: openai
display:
skin: solarized
compact: true
"""
try yaml.write(
to: home.appendingPathComponent("config.yaml"),
atomically: true,
encoding: .utf8
)
let vm = IOSSettingsViewModel(context: ctx)
await vm.load()
#expect(vm.isLoading == false)
#expect(vm.config.model == "gpt-4o")
#expect(vm.config.provider == "openai")
#expect(vm.config.display.skin == "solarized")
#expect(vm.config.display.compact == true)
#expect(vm.rawYAML.contains("gpt-4o"))
#expect(vm.lastError == nil)
}
}
@Test @MainActor func settingsSurfacesMissingFile() async throws {
try await withLocalTransportFactory { [self] in
let (ctx, _) = try makeFakeHermes()
let vm = IOSSettingsViewModel(context: ctx)
await vm.load()
#expect(vm.isLoading == false)
#expect(vm.lastError != nil)
#expect(vm.config.model == "unknown")
}
}
} }
@@ -0,0 +1,282 @@
import Testing
import Foundation
@testable import ScarfCore
/// M6: YAML parser port + HermesConfig loader. Pure functions no
/// `ServerContext.sshTransportFactory` races, so this suite can run
/// in parallel with everything else.
///
/// The write-path tests for Cron editing + Settings-from-yaml live
/// in `M5FeatureVMTests` (the serialized suite that already owns
/// the factory-install pattern) to avoid cross-suite parallel
/// collisions on the shared factory static.
@Suite struct M6ConfigCronTests {
// MARK: - YAML parser
@Test func parsesScalarKeyValues() {
let yaml = """
model:
default: gpt-4o
provider: openai
"""
let p = HermesYAML.parseNestedYAML(yaml)
#expect(p.values["model.default"] == "gpt-4o")
#expect(p.values["model.provider"] == "openai")
}
@Test func parsesBulletLists() {
let yaml = """
permanent_allowlist:
- ls
- pwd
- 'cat /etc/hostname'
"""
let p = HermesYAML.parseNestedYAML(yaml)
#expect(p.lists["permanent_allowlist"] == ["ls", "pwd", "cat /etc/hostname"])
}
@Test func parsesNestedMaps() {
let yaml = """
terminal:
docker_env:
PATH: /usr/local/bin
HOME: /home/hermes
"""
let p = HermesYAML.parseNestedYAML(yaml)
#expect(p.maps["terminal.docker_env"]?["PATH"] == "/usr/local/bin")
#expect(p.maps["terminal.docker_env"]?["HOME"] == "/home/hermes")
#expect(p.values["terminal.docker_env.PATH"] == "/usr/local/bin")
}
@Test func ignoresCommentsAndBlankLines() {
let yaml = """
# Top-level comment
model:
# inline comment
default: gpt-4o
provider: openai
"""
let p = HermesYAML.parseNestedYAML(yaml)
#expect(p.values["model.default"] == "gpt-4o")
#expect(p.values["model.provider"] == "openai")
}
@Test func stripsQuotes() {
#expect(HermesYAML.stripYAMLQuotes("'quoted'") == "quoted")
#expect(HermesYAML.stripYAMLQuotes("\"quoted\"") == "quoted")
#expect(HermesYAML.stripYAMLQuotes("plain") == "plain")
#expect(HermesYAML.stripYAMLQuotes("'unbalanced") == "'unbalanced")
#expect(HermesYAML.stripYAMLQuotes("") == "")
}
@Test func handlesInlineLiterals() {
let yaml = """
empty_map: {}
empty_list: []
"""
let p = HermesYAML.parseNestedYAML(yaml)
#expect(p.maps["empty_map"] != nil)
#expect(p.lists["empty_list"] != nil)
}
// MARK: - HermesConfig from YAML
@Test func emptyYAMLProducesDefaults() {
let c = HermesConfig(yaml: "")
#expect(c.model == "unknown")
#expect(c.provider == "unknown")
#expect(c.display.skin == "default")
#expect(c.streaming == true)
#expect(c.security.redactSecrets == true)
#expect(c.compression.enabled == true)
#expect(c.voice.ttsProvider == "edge")
}
@Test func parsesTopLevelModel() {
let yaml = """
model:
default: claude-4-opus
provider: anthropic
agent:
reasoning_effort: high
service_tier: pro
max_turns: 50
"""
let c = HermesConfig(yaml: yaml)
#expect(c.model == "claude-4-opus")
#expect(c.provider == "anthropic")
#expect(c.reasoningEffort == "high")
#expect(c.serviceTier == "pro")
#expect(c.maxTurns == 50)
}
@Test func parsesDisplaySection() {
let yaml = """
display:
skin: dark
compact: true
streaming: false
show_reasoning: true
show_cost: true
personality: professional
"""
let c = HermesConfig(yaml: yaml)
#expect(c.display.skin == "dark")
#expect(c.display.compact == true)
#expect(c.streaming == false)
#expect(c.showReasoning == true)
#expect(c.showCost == true)
#expect(c.personality == "professional")
}
@Test func parsesSecuritySection() {
let yaml = """
security:
redact_secrets: false
tirith_enabled: false
tirith_timeout: 15
website_blocklist:
enabled: true
domains:
- example.com
- evil.org
"""
let c = HermesConfig(yaml: yaml)
#expect(c.security.redactSecrets == false)
#expect(c.security.tirithEnabled == false)
#expect(c.security.tirithTimeout == 15)
#expect(c.security.blocklistEnabled == true)
#expect(c.security.blocklistDomains == ["example.com", "evil.org"])
}
@Test func parsesSlackWithLegacyAndNewerPaths() {
// Newer path wins when both present.
let newerWins = HermesConfig(yaml: """
platforms:
slack:
reply_to_mode: all
slack:
reply_to_mode: first
""")
#expect(newerWins.slack.replyToMode == "all")
// Legacy-only path used when newer is absent.
let legacyFallback = HermesConfig(yaml: """
slack:
reply_to_mode: first
""")
#expect(legacyFallback.slack.replyToMode == "first")
// Default when neither present.
let defaulted = HermesConfig(yaml: "")
#expect(defaulted.slack.replyToMode == "first")
}
@Test func parsesAuxiliarySection() {
let yaml = """
auxiliary:
vision:
provider: openai
model: gpt-4-vision
timeout: 60
compression:
provider: anthropic
model: claude-3-haiku
"""
let c = HermesConfig(yaml: yaml)
#expect(c.auxiliary.vision.provider == "openai")
#expect(c.auxiliary.vision.model == "gpt-4-vision")
#expect(c.auxiliary.vision.timeout == 60)
#expect(c.auxiliary.compression.provider == "anthropic")
// Not-configured aux blocks default to "auto" / empty.
#expect(c.auxiliary.sessionSearch.provider == "auto")
#expect(c.auxiliary.mcp.provider == "auto")
}
@Test func parsesPermanentAllowlist() {
let yaml = """
permanent_allowlist:
- ls
- pwd
- stat
"""
let c = HermesConfig(yaml: yaml)
#expect(c.commandAllowlist == ["ls", "pwd", "stat"])
}
@Test func parsesCommandAllowlistLegacyName() {
// Fall back to `command_allowlist` when `permanent_allowlist` absent.
let yaml = """
command_allowlist:
- whoami
- id
"""
let c = HermesConfig(yaml: yaml)
#expect(c.commandAllowlist == ["whoami", "id"])
}
@Test func preservesQuotedStrings() {
let yaml = """
model:
default: "gpt-4o with spaces"
timezone: 'America/New_York'
"""
let c = HermesConfig(yaml: yaml)
#expect(c.model == "gpt-4o with spaces")
#expect(c.timezone == "America/New_York")
}
@Test func cronScheduleMemberwise() {
let s = CronSchedule(
kind: "cron",
runAt: nil,
display: "9am weekdays",
expression: "0 9 * * 1-5"
)
#expect(s.kind == "cron")
#expect(s.display == "9am weekdays")
}
@Test func hermesCronJobMemberwiseAndWithEnabled() {
let job = HermesCronJob(
id: "j1",
name: "Brief",
prompt: "summarize",
skills: ["cal"],
schedule: CronSchedule(kind: "cron"),
enabled: true,
state: "scheduled",
deliver: "discord:general"
)
#expect(job.enabled)
let toggled = job.withEnabled(false)
#expect(toggled.enabled == false)
// Every other field round-trips.
#expect(toggled.id == job.id)
#expect(toggled.name == job.name)
#expect(toggled.prompt == job.prompt)
#expect(toggled.skills == job.skills)
#expect(toggled.deliver == job.deliver)
}
@Test func cronJobsFileMemberwise() {
let jobs = [
HermesCronJob(
id: "a", name: "A", prompt: "p",
schedule: CronSchedule(kind: "cron"),
enabled: true, state: "scheduled"
)
]
let file = CronJobsFile(jobs: jobs, updatedAt: "2026-04-23T00:00:00Z")
#expect(file.jobs.count == 1)
#expect(file.updatedAt == "2026-04-23T00:00:00Z")
// Codable round-trip should survive.
let data = try! JSONEncoder().encode(file)
let decoded = try! JSONDecoder().decode(CronJobsFile.self, from: data)
#expect(decoded.jobs.count == 1)
#expect(decoded.jobs[0].name == "A")
#expect(decoded.updatedAt == file.updatedAt)
}
}
+193 -73
View File
@@ -1,13 +1,14 @@
import SwiftUI import SwiftUI
import ScarfCore import ScarfCore
/// iOS Cron screen. Read-only list of scheduled jobs pulled from /// iOS Cron screen. M6 gained: toggle-enabled, swipe-to-delete,
/// `~/.hermes/cron/jobs.json`. Editing is deferred to a later phase /// "+" toolbar editor sheet, and row-tap edit existing job.
/// see `IOSCronViewModel`'s header for the scope rationale.
struct CronListView: View { struct CronListView: View {
let config: IOSServerConfig let config: IOSServerConfig
@State private var vm: IOSCronViewModel @State private var vm: IOSCronViewModel
@State private var editingJob: HermesCronJob?
@State private var showingNewJob = false
private static let sharedContextID: ServerID = ServerID( private static let sharedContextID: ServerID = ServerID(
uuidString: "00000000-0000-0000-0000-0000000000A1" uuidString: "00000000-0000-0000-0000-0000000000A1"
@@ -33,7 +34,7 @@ struct CronListView: View {
VStack(alignment: .leading, spacing: 6) { VStack(alignment: .leading, spacing: 6) {
Text("No cron jobs yet.") Text("No cron jobs yet.")
.font(.headline) .font(.headline)
Text("Create cron jobs from the Mac app or by editing `~/.hermes/cron/jobs.json` directly. iOS will display them here.") Text("Tap \(Image(systemName: "plus.circle.fill")) to create one, or manage them from the Mac app.")
.font(.caption) .font(.caption)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
} }
@@ -42,13 +43,34 @@ struct CronListView: View {
} else { } else {
Section { Section {
ForEach(vm.jobs) { job in ForEach(vm.jobs) { job in
CronRow(job: job) CronRow(job: job) {
Task { await vm.toggleEnabled(id: job.id) }
} onTap: {
editingJob = job
}
.swipeActions(edge: .trailing, allowsFullSwipe: false) {
Button(role: .destructive) {
Task { await vm.delete(id: job.id) }
} label: {
Label("Delete", systemImage: "trash")
}
}
} }
} }
} }
} }
.navigationTitle("Cron jobs") .navigationTitle("Cron jobs")
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button {
showingNewJob = true
} label: {
Image(systemName: "plus.circle.fill")
}
.disabled(vm.isSaving)
}
}
.overlay { .overlay {
if vm.isLoading && vm.jobs.isEmpty { if vm.isLoading && vm.jobs.isEmpty {
ProgressView("Loading jobs…") ProgressView("Loading jobs…")
@@ -59,29 +81,42 @@ struct CronListView: View {
} }
.refreshable { await vm.load() } .refreshable { await vm.load() }
.task { await vm.load() } .task { await vm.load() }
.sheet(item: $editingJob) { job in
CronEditorView(initial: job, title: "Edit cron job") { edited in
Task { await vm.upsert(edited) }
}
}
.sheet(isPresented: $showingNewJob) {
CronEditorView(initial: nil, title: "New cron job") { created in
Task { await vm.upsert(created) }
}
}
} }
} }
private struct CronRow: View { private struct CronRow: View {
let job: HermesCronJob let job: HermesCronJob
let onToggle: () -> Void
let onTap: () -> Void
var body: some View { var body: some View {
NavigationLink {
CronDetailView(job: job)
} label: {
HStack(alignment: .top, spacing: 12) { HStack(alignment: .top, spacing: 12) {
VStack { Button(action: onToggle) {
Image(systemName: job.stateIcon) Image(systemName: job.enabled
.foregroundStyle(stateColor) ? "checkmark.circle.fill"
.font(.body) : "circle")
.font(.title3)
.foregroundStyle(job.enabled ? Color.accentColor : Color.secondary)
} }
.frame(width: 22) .buttonStyle(.plain)
Button(action: onTap) {
VStack(alignment: .leading, spacing: 3) { VStack(alignment: .leading, spacing: 3) {
HStack { HStack {
Text(job.name) Text(job.name)
.font(.body) .font(.body)
.fontWeight(.medium) .fontWeight(.medium)
.foregroundStyle(.primary)
if !job.enabled { if !job.enabled {
Text("DISABLED") Text("DISABLED")
.font(.caption2) .font(.caption2)
@@ -108,86 +143,171 @@ private struct CronRow: View {
.foregroundStyle(.tertiary) .foregroundStyle(.tertiary)
} }
} }
.frame(maxWidth: .infinity, alignment: .leading)
}
.buttonStyle(.plain)
} }
.padding(.vertical, 2) .padding(.vertical, 2)
} }
} }
private var stateColor: Color { // MARK: - Editor
switch job.state {
case "running": return .blue
case "completed": return .green
case "failed": return .red
default: return .secondary
}
}
}
private struct CronDetailView: View { /// Sheet for creating or editing a single `HermesCronJob`. Scoped
let job: HermesCronJob /// to the fields a user typically sets; runtime state fields
/// (delivery_failures, last_run_at, etc.) pass through untouched
/// when editing an existing job.
struct CronEditorView: View {
let title: String
let onSave: (HermesCronJob) -> Void
@Environment(\.dismiss) private var dismiss
// Form-backing state.
@State private var id: String
@State private var name: String
@State private var prompt: String
@State private var model: String
@State private var skills: String // comma-separated
@State private var deliver: String
@State private var enabled: Bool
@State private var scheduleKind: String
@State private var scheduleDisplay: String
@State private var scheduleRunAt: String
@State private var scheduleExpression: String
private let existing: HermesCronJob?
init(
initial: HermesCronJob?,
title: String,
onSave: @escaping (HermesCronJob) -> Void
) {
self.title = title
self.onSave = onSave
self.existing = initial
_id = State(initialValue: initial?.id ?? "job_\(UUID().uuidString.prefix(8))")
_name = State(initialValue: initial?.name ?? "")
_prompt = State(initialValue: initial?.prompt ?? "")
_model = State(initialValue: initial?.model ?? "")
_skills = State(initialValue: (initial?.skills ?? []).joined(separator: ", "))
_deliver = State(initialValue: initial?.deliver ?? "")
_enabled = State(initialValue: initial?.enabled ?? true)
_scheduleKind = State(initialValue: initial?.schedule.kind ?? "cron")
_scheduleDisplay = State(initialValue: initial?.schedule.display ?? "")
_scheduleRunAt = State(initialValue: initial?.schedule.runAt ?? "")
_scheduleExpression = State(initialValue: initial?.schedule.expression ?? "")
}
var body: some View { var body: some View {
NavigationStack {
Form { Form {
Section("Job") {
TextField("Name", text: $name)
.autocorrectionDisabled()
Toggle("Enabled", isOn: $enabled)
}
Section("Prompt") { Section("Prompt") {
Text(job.prompt) TextEditor(text: $prompt)
.frame(minHeight: 120)
.font(.body) .font(.body)
.textSelection(.enabled) .autocorrectionDisabled()
.textInputAutocapitalization(.never)
} }
Section("Schedule") { Section("Schedule") {
LabeledContent("Kind", value: job.schedule.kind) Picker("Kind", selection: $scheduleKind) {
if let display = job.schedule.display { Text("cron").tag("cron")
LabeledContent("When", value: display) Text("interval").tag("interval")
Text("once").tag("once")
} }
if let expr = job.schedule.expression { TextField("Display (e.g. \"9am weekdays\")", text: $scheduleDisplay)
LabeledContent("Expression", value: expr) .autocorrectionDisabled()
if scheduleKind == "cron" {
TextField("Expression (e.g. \"0 9 * * 1-5\")", text: $scheduleExpression)
.autocorrectionDisabled()
.textInputAutocapitalization(.never)
}
if scheduleKind == "once" {
TextField("Run at (ISO8601)", text: $scheduleRunAt)
.autocorrectionDisabled()
.textInputAutocapitalization(.never)
} }
} }
Section("State") { Section("Optional") {
LabeledContent("Enabled", value: job.enabled ? "yes" : "no") TextField("Model (leave blank to use default)", text: $model)
LabeledContent("State", value: job.state) .autocorrectionDisabled()
if let last = job.lastRunAt { .textInputAutocapitalization(.never)
LabeledContent("Last run", value: last) TextField("Skills (comma-separated)", text: $skills)
} .autocorrectionDisabled()
if let next = job.nextRunAt { .textInputAutocapitalization(.never)
LabeledContent("Next run", value: next) TextField("Deliver (e.g. discord:channel)", text: $deliver)
} .autocorrectionDisabled()
if let err = job.lastError { .textInputAutocapitalization(.never)
VStack(alignment: .leading, spacing: 4) {
Text("Last error")
.font(.caption)
.foregroundStyle(.secondary)
Text(err)
.font(.caption.monospaced())
.foregroundStyle(.red)
.textSelection(.enabled)
} }
} }
} .navigationTitle(title)
if let delivery = job.deliveryDisplay {
Section("Delivery") {
LabeledContent("Route", value: delivery)
}
}
if let skills = job.skills, !skills.isEmpty {
Section("Skills") {
ForEach(skills, id: \.self) { s in
Text(s)
.font(.caption.monospaced())
}
}
}
if let model = job.model {
Section("Model") {
Text(model).font(.caption.monospaced())
}
}
}
.navigationTitle(job.name)
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .topBarLeading) {
Button("Cancel") { dismiss() }
}
ToolbarItem(placement: .topBarTrailing) {
Button("Save") {
onSave(buildJob())
dismiss()
}
.disabled(!isValid)
.bold()
}
}
}
}
private var isValid: Bool {
let n = name.trimmingCharacters(in: .whitespacesAndNewlines)
let p = prompt.trimmingCharacters(in: .whitespacesAndNewlines)
return !n.isEmpty && !p.isEmpty
}
private func buildJob() -> HermesCronJob {
let skillList = skills
.split(separator: ",")
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
.filter { !$0.isEmpty }
let emptyToNil: (String) -> String? = { s in
let t = s.trimmingCharacters(in: .whitespacesAndNewlines)
return t.isEmpty ? nil : t
}
let schedule = CronSchedule(
kind: scheduleKind,
runAt: emptyToNil(scheduleRunAt),
display: emptyToNil(scheduleDisplay),
expression: emptyToNil(scheduleExpression)
)
return HermesCronJob(
id: id,
name: name.trimmingCharacters(in: .whitespacesAndNewlines),
prompt: prompt.trimmingCharacters(in: .whitespacesAndNewlines),
skills: skillList.isEmpty ? nil : skillList,
model: emptyToNil(model),
schedule: schedule,
enabled: enabled,
state: existing?.state ?? "scheduled",
deliver: emptyToNil(deliver),
// Preserve runtime state fields from the existing job so
// an edit doesn't reset last_run_at, failure counts, etc.
nextRunAt: existing?.nextRunAt,
lastRunAt: existing?.lastRunAt,
lastError: existing?.lastError,
preRunScript: existing?.preRunScript,
deliveryFailures: existing?.deliveryFailures,
lastDeliveryError: existing?.lastDeliveryError,
timeoutType: existing?.timeoutType,
timeoutSeconds: existing?.timeoutSeconds,
silent: existing?.silent
)
} }
} }
@@ -110,6 +110,11 @@ struct DashboardView: View {
} label: { } label: {
Label("Skills", systemImage: "sparkles") Label("Skills", systemImage: "sparkles")
} }
NavigationLink {
SettingsView(config: config)
} label: {
Label("Settings", systemImage: "gearshape.fill")
}
} }
Section("Connected to") { Section("Connected to") {
+225
View File
@@ -0,0 +1,225 @@
import SwiftUI
import ScarfCore
/// iOS Settings screen. Read-only browser of `~/.hermes/config.yaml`
/// as it currently stands on the remote, grouped into sections that
/// mirror the Mac app's tabs. Source-of-truth toggle at the bottom
/// reveals the raw YAML for users who want to see what the parser
/// consumed.
struct SettingsView: View {
let config: IOSServerConfig
@State private var vm: IOSSettingsViewModel
@State private var showRawYAML = false
private static let sharedContextID: ServerID = ServerID(
uuidString: "00000000-0000-0000-0000-0000000000A1"
)!
init(config: IOSServerConfig) {
self.config = config
let ctx = config.toServerContext(id: Self.sharedContextID)
_vm = State(initialValue: IOSSettingsViewModel(context: ctx))
}
var body: some View {
List {
if let err = vm.lastError {
Section {
Label(err, systemImage: "exclamationmark.triangle.fill")
.foregroundStyle(.orange)
}
}
if !vm.isLoading || vm.config.model != "unknown" {
modelSection
agentSection
displaySection
terminalSection
memorySection
voiceSection
securitySection
compressionSection
loggingSection
platformsSection
rawYAMLToggleSection
}
}
.navigationTitle("Settings")
.navigationBarTitleDisplayMode(.inline)
.refreshable { await vm.load() }
.task { await vm.load() }
.overlay {
if vm.isLoading && vm.config.model == "unknown" {
ProgressView("Loading config.yaml…")
.padding()
.background(.regularMaterial)
.clipShape(RoundedRectangle(cornerRadius: 10))
}
}
}
// MARK: - Sections
@ViewBuilder
private var modelSection: some View {
Section("Model") {
LabeledContent("Default", value: vm.config.model)
if !vm.config.provider.isEmpty, vm.config.provider != "unknown" {
LabeledContent("Provider", value: vm.config.provider)
}
LabeledContent("Reasoning effort", value: vm.config.reasoningEffort)
if !vm.config.timezone.isEmpty {
LabeledContent("Timezone", value: vm.config.timezone)
}
}
}
@ViewBuilder
private var agentSection: some View {
Section("Agent") {
LabeledContent("Approval mode", value: vm.config.approvalMode)
LabeledContent("Max turns", value: "\(vm.config.maxTurns)")
LabeledContent("Service tier", value: vm.config.serviceTier)
yesNoRow("Verbose logging", vm.config.verbose)
LabeledContent("Tool use enforcement", value: vm.config.toolUseEnforcement)
}
}
@ViewBuilder
private var displaySection: some View {
Section("Display") {
yesNoRow("Streaming", vm.config.streaming)
yesNoRow("Show reasoning", vm.config.showReasoning)
yesNoRow("Show cost", vm.config.showCost)
LabeledContent("Skin", value: vm.config.display.skin)
yesNoRow("Compact", vm.config.display.compact)
yesNoRow("Inline diffs", vm.config.display.inlineDiffs)
LabeledContent("Personality", value: vm.config.personality)
}
}
@ViewBuilder
private var terminalSection: some View {
Section("Terminal") {
LabeledContent("Backend", value: vm.config.terminalBackend)
LabeledContent("Cwd", value: vm.config.terminal.cwd)
LabeledContent("Timeout", value: "\(vm.config.terminal.timeout)s")
yesNoRow("Persistent shell", vm.config.terminal.persistentShell)
if !vm.config.terminal.dockerImage.isEmpty {
LabeledContent("Docker image", value: vm.config.terminal.dockerImage)
}
}
}
@ViewBuilder
private var memorySection: some View {
Section("Memory") {
yesNoRow("Memory enabled", vm.config.memoryEnabled)
yesNoRow("User profile enabled", vm.config.userProfileEnabled)
if vm.config.memoryCharLimit > 0 {
LabeledContent("Char limit", value: "\(vm.config.memoryCharLimit)")
}
if !vm.config.memoryProfile.isEmpty {
LabeledContent("Profile", value: vm.config.memoryProfile)
}
if !vm.config.memoryProvider.isEmpty {
LabeledContent("Provider", value: vm.config.memoryProvider)
}
}
}
@ViewBuilder
private var voiceSection: some View {
Section("Voice") {
yesNoRow("Auto TTS", vm.config.autoTTS)
LabeledContent("TTS provider", value: vm.config.voice.ttsProvider)
yesNoRow("STT enabled", vm.config.voice.sttEnabled)
LabeledContent("STT provider", value: vm.config.voice.sttProvider)
}
}
@ViewBuilder
private var securitySection: some View {
Section("Security") {
yesNoRow("Redact secrets", vm.config.security.redactSecrets)
yesNoRow("Redact PII", vm.config.security.redactPII)
yesNoRow("Tirith enabled", vm.config.security.tirithEnabled)
yesNoRow("Website blocklist", vm.config.security.blocklistEnabled)
if !vm.config.security.blocklistDomains.isEmpty {
ForEach(vm.config.security.blocklistDomains.prefix(5), id: \.self) { domain in
Text(domain)
.font(.caption.monospaced())
.foregroundStyle(.secondary)
}
if vm.config.security.blocklistDomains.count > 5 {
Text("+ \(vm.config.security.blocklistDomains.count - 5) more")
.font(.caption)
.foregroundStyle(.tertiary)
}
}
}
}
@ViewBuilder
private var compressionSection: some View {
Section("Compression") {
yesNoRow("Enabled", vm.config.compression.enabled)
LabeledContent("Threshold", value: String(format: "%.2f", vm.config.compression.threshold))
LabeledContent("Target ratio", value: String(format: "%.2f", vm.config.compression.targetRatio))
LabeledContent("Protect last N", value: "\(vm.config.compression.protectLastN)")
}
}
@ViewBuilder
private var loggingSection: some View {
Section("Logging") {
LabeledContent("Level", value: vm.config.logging.level)
LabeledContent("Max size", value: "\(vm.config.logging.maxSizeMB) MB")
LabeledContent("Backup count", value: "\(vm.config.logging.backupCount)")
}
}
@ViewBuilder
private var platformsSection: some View {
Section("Platforms") {
yesNoRow("Discord: require mention", vm.config.discord.requireMention)
yesNoRow("Discord: auto-thread", vm.config.discord.autoThread)
yesNoRow("Telegram: require mention", vm.config.telegram.requireMention)
LabeledContent("Slack: reply mode", value: vm.config.slack.replyToMode)
yesNoRow("Matrix: require mention", vm.config.matrix.requireMention)
}
}
@ViewBuilder
private var rawYAMLToggleSection: some View {
Section {
DisclosureGroup("View source (config.yaml)", isExpanded: $showRawYAML) {
if vm.rawYAML.isEmpty {
Text("(empty)")
.font(.caption)
.foregroundStyle(.tertiary)
} else {
Text(vm.rawYAML)
.font(.caption2.monospaced())
.foregroundStyle(.secondary)
.textSelection(.enabled)
.frame(maxWidth: .infinity, alignment: .leading)
}
}
} footer: {
Text("M6 is read-only. Edit config.yaml on the Mac app or via a shell; iOS reflects the current remote state.")
.font(.caption)
}
}
// MARK: - Helpers
@ViewBuilder
private func yesNoRow(_ label: String, _ value: Bool) -> some View {
LabeledContent(label) {
Text(value ? "yes" : "no")
.foregroundStyle(value ? .primary : .secondary)
}
}
}
+54 -1
View File
@@ -696,4 +696,57 @@ Total **98 → 108 tests passing on Linux** via `docker run --rm -v $PWD/Package
- **Editing Cron, adding Skills** — both are deferred. Cron editing needs atomic JSON rewrites (doable). Skills install needs git-clone + schema validation (larger). - **Editing Cron, adding Skills** — both are deferred. Cron editing needs atomic JSON rewrites (doable). Skills install needs git-clone + schema validation (larger).
- **Settings tab on iOS is still missing** — requires a YAML parser in ScarfCore or porting `HermesFileService.loadConfig`. Next phase's job. - **Settings tab on iOS is still missing** — requires a YAML parser in ScarfCore or porting `HermesFileService.loadConfig`. Next phase's job.
### M6 — pending ### M6 — shipped (on `claude/ios-m6-settings-polish` branch, separate PR, stacked on M5)
Ports the Mac app's YAML parser into ScarfCore (unblocking iOS Settings), adds Settings browsing + Cron editing, consolidates the test-serialization story. App Store submission is deferred to a later task after real-device testing.
**Shipped — ScarfCore:**
- `Parsing/HermesYAML.swift``HermesYAML.parseNestedYAML(_:)` + `stripYAMLQuotes(_:)`. Lifted verbatim from `HermesFileService.parseNestedYAML` / `stripYAMLQuotes` but hoisted into a standalone enum for reuse. Scope unchanged: indent-based block nesting, scalar values, bullet lists, nested maps. Not full YAML-spec compliance; matches exactly what Hermes's `config.yaml` actually uses.
- `Parsing/HermesConfig+YAML.swift``HermesConfig.init(yaml:)`. Lifted from `HermesFileService.parseConfig` one-for-one. Every default, every key, every legacy fallback (e.g., `platforms.slack.*` vs `slack.*`) tracked to the Mac implementation. Forgiving: malformed YAML produces a partial-state `HermesConfig` rather than throwing.
- `ViewModels/IOSSettingsViewModel.swift``@Observable @MainActor` VM. Reads `~/.hermes/config.yaml` via transport, parses with the new loader, surfaces both the parsed `HermesConfig` and the raw text (so Settings view can offer a "View source" disclosure). M6 Settings is READ-ONLY — edit-path deferred until a round-trip-preserving YAML writer lands (commits, key order, whitespace would need preservation for a clean edit UX).
- `ViewModels/IOSCronViewModel.swift` — added write paths: `toggleEnabled(id:)`, `delete(id:)`, `upsert(_:)`. All funnel through `saveJobs(_:)` which re-encodes the full `CronJobsFile` (`.prettyPrinted + .sortedKeys`) and writes atomically via the transport (Data.write-atomic semantics from M5). Creates the `cron/` directory on fresh installs.
- Both `HermesCronJob` and `CronJobsFile` gained real memberwise inits (previously only hand-written `init(from:)` — Swift's synthesis was suppressed). Also `HermesCronJob.withEnabled(_:)` — clean field-passthrough instead of the JSON-roundtrip hack my first draft used.
**Shipped — iOS app:**
- `Scarf iOS/Settings/SettingsView.swift` — read-only browser grouped into sections that mirror the Mac app's tabs: Model, Agent, Display, Terminal, Memory, Voice, Security, Compression, Logging, Platforms. `DisclosureGroup` at the bottom reveals the raw YAML source for diagnostics.
- `Scarf iOS/Cron/CronListView.swift` rewritten: toggle-enabled circle (tap to flip), swipe-to-delete, "+" toolbar for new-job, row-tap opens the editor sheet. New `CronEditorView` form handles name / prompt / enabled / schedule (kind + display + expression + run_at) / optional model / comma-separated skills / delivery route. Preserves runtime state fields (nextRunAt, lastRunAt, deliveryFailures, etc.) when editing — no resetting the cron's observed history on a field edit.
- Dashboard's Surfaces section gets a 5th row: Settings.
**Test-suite reorganization:**
Discovered (and fixed) a cross-suite race: swift-testing's `.serialized` trait scopes to one @Suite, not globally. M5's serialized suite installed `ServerContext.sshTransportFactory`, M6's serialized suite did the same, and the M0b non-serialized `serverContextMakeTransportDispatches` test asserted the DEFAULT factory (nil) returned `SSHTransport` — all three raced on the shared static.
Fix: keep the YAML-parse + memberwise tests in a plain (non-serialized) `M6ConfigCronTests` suite since they're pure. **Move every factory-touching test into the single `.serialized` `M5FeatureVMTests`** — including M6's Cron write-path tests, Settings-load tests, AND the M0b default-factory test (with explicit `factory = nil` reset for race-freedom). Single serialization domain eliminates the race.
**Test counts:** 108 → **134 passing on Linux**.
| Suite | New in M6 | Total |
|---|--:|--:|
| `ScarfCoreSmokeTests` | 0 | 1 |
| `M0aPublicInitTests` | 0 | 15 |
| `M0bTransportTests` | 0 (1 split out + moved) | 18 |
| `M0cServicesTests` | 0 | 8 |
| `M0dViewModelsTests` | 0 | 9 |
| `M1ACPTests` | 0 | 10 |
| `M2OnboardingTests` | 0 | 26 |
| `M3TransportTests` | 0 | 5 |
| `M4ACPIOSTests` | 0 | 2 |
| `M5FeatureVMTests` | **+7** (cron write paths + settings load + default-factory guard) | 21 |
| `M6ConfigCronTests` | **+19** (YAML parsing + HermesConfig decode + memberwise inits) | 19 |
**Manual validation needed on Mac:**
1. Xcode compile clean.
2. Settings → confirm every section populates from your real `config.yaml`. Tap the "View source" disclosure to verify the raw text matches what's on the remote.
3. Cron: toggle a job's enabled flag, verify it survives a full refresh + relaunch. Swipe-to-delete a job. Tap "+" to create a new job; verify prompt + schedule + skills round-trip. Tap an existing job to edit; verify runtime fields (lastRunAt, deliveryFailures) aren't reset.
4. Skills: unchanged from M5, still browse-only.
**Rules next phases can rely on:**
- **Any test that touches `ServerContext.sshTransportFactory` or any other global mutable state MUST live in `M5FeatureVMTests`** (the single `.serialized` suite) — or introduce a new cross-suite synchronization primitive. Swift-testing's `.serialized` does NOT serialize across suites.
- **YAML parser in ScarfCore is a hard ceiling** — it handles the Hermes config subset, not arbitrary YAML. If a future Hermes version adds constructs the parser doesn't cover (flow-style `[...]`, anchors, `&` references, multi-line `|` blocks), port them on both sides simultaneously.
- **Settings writes stay deferred** until a round-trip-preserving YAML writer ships. Options: (a) hand-write one, (b) adopt a YAML lib (adds dependency), (c) delegate to `hermes config set` via ACP.
- **Cron editing on iOS is atomic per-save** — full jobs.json rewrites on every change. Fine for current cron sizes (dozens of jobs). If that grows into the thousands, consider partial updates via `hermes cron add/rm/toggle` over ACP.
- **Skills install (git-clone + validation over SSH)** remains deferred — it's its own project. The iOS Skills list is read-only; users install from the Mac app or by cloning directly to the remote.
### M7 — pending (post-testing App Store submission + any polish that surfaces)