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
+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).
- **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)