Commit Graph

318 Commits

Author SHA1 Message Date
Alan Wizemann 585d035fe8 feat(projects): folder hierarchy + rename/archive/search in the sidebar
Second of three v2.3 commits. Replaces the flat projects sidebar
with a hierarchical view that honors the folder + archived fields
introduced in commit 1.

ProjectsView's inline 70-line `projectList` becomes a one-call
invocation of a new extracted `ProjectsSidebar` view. The parent
keeps all sheet state (add / rename / move / uninstall / remove-
from-list confirmation); the sidebar routes user intent up via
closures. That separation means future sidebar changes (drag-
and-drop, tags, color labels from the roadmap) don't need to
touch ProjectsView's sheet wiring.

ProjectsSidebar.swift renders, top to bottom:
- Search field (filters by name / path / folder label, live)
- Top-level projects (folder is nil or empty, not archived)
- One DisclosureGroup per folder, alphabetically sorted, expanded
  by default on first render; collapsed state persists per view
  instance. Newly-created folders auto-expand so moves are
  visibly reflected.
- An "Archived (N)" DisclosureGroup at the bottom, surfaced only
  when the Show Archived toggle in the bottom bar is on. Archived
  rows render at 0.7 opacity for a subtle visual cue.

Bottom bar gains a Show Archived toggle next to the existing +
button, using the archivebox SF Symbol (filled when on).

Context menu gets three new entries alongside the existing ones:
- Rename… — opens RenameProjectSheet with duplicate-name +
  empty-name validation.
- Move to Folder… — opens MoveToFolderSheet with current folder
  pre-selected; picker lists Top Level, existing folders, and a
  "New folder…" option that gates on a text field.
- Archive / Unarchive — flips the archived bit via the VM.

Both new sheets live as standalone files (RenameProjectSheet,
MoveToFolderSheet) for reuse — the wiki doesn't need updating; these
are pure UI refinements.

Selection binding round-trips through `viewModel.selectedProject`
unchanged, so the existing dashboard / Site tab routing is
unaffected. Sidebar matches use localizedCaseInsensitiveCompare
so folder labels and project names sort the way users expect in
non-English locales.

73/73 Swift tests still pass (no new tests in this commit — the
VM verbs already exercised in ProjectsViewModelTests; the UI is
visual and will be validated by the manual smoke test at the end
of the branch).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 23:00:21 +02:00
Alan Wizemann f1e8f3070f feat(projects): registry schema v2 — folder + archived fields
First of three v2.3 commits. Adds the data model + view-model plumbing
for folder grouping and soft-archive; no UI changes yet (sidebar still
renders a flat list).

ProjectEntry gains two optional fields:
- `folder: String?` — opaque single-level label for sidebar grouping;
  nil means top-level. Custom Codable decodeIfPresent so v2.2 registry
  files parse cleanly.
- `archived: Bool` — soft-delete flag; defaults to false via custom
  decoder. Archived projects stay on disk and in the registry; the
  v2.3 sidebar just hides them unless Show Archived is toggled on.

Custom encode(to:) omits both fields when they're at their default
values. Keeps registry files clean for the common (top-level,
unarchived) case and means v2.2 Scarf still loads a v2.3-written
registry of projects that never used the new features — forward +
backward compat by construction.

ProjectsViewModel grows four verbs:
- moveProject(_:toFolder:) — update the folder assignment
- renameProject(_:to:) — rename with duplicate-name + empty-name
  rejection; preserves selection across the rename so the user
  stays on the same project
- archiveProject(_:) — sets archived=true, clears selection if the
  archived project was selected (avoids lingering on a hidden view)
- unarchiveProject(_:) — sets archived=false; does NOT re-select
  (unhiding ≠ focusing)
- `folders: [String]` computed property — distinct folder labels,
  sorted, for the sidebar + move-to-folder sheet

Two new test suites:
- ProjectRegistryMigrationTests: round-trips v2.2 → v2.3 and back,
  asserts encoder cleanliness (defaults omitted), identity stability
  under folder / archive changes.
- ProjectsViewModelTests: verbs hit the real ~/.hermes/scarf/projects.json
  via TestRegistryLock for cross-suite serialization. Covers happy
  paths, duplicate / empty-name rename rejection, and folder dedup.

73/73 Swift tests pass (was 58; 15 new). No behavior change on v2.2
registry files yet — the sidebar UI lands in commit 2.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 22:57:02 +02:00
Alan Wizemann f366057cfd docs(roadmap): add Projects System Evolution section
Captures the backlog discussed during v2.3 planning so future
sessions can pick up items without re-deriving the terrain:

- v2.3 (planned, in this branch): folders + rename/archive/search
  + per-project Sessions tab via a sidecar attribution file.
- v2.4+: per-project activity feed, token rollup, cron filter,
  desktop notifications — all "filter existing data via the
  sidecar" work, unblocked once v2.3 ships.
- v2.5+: platform bets (Hermes upstream sessions.cwd column,
  per-project memory slice, per-project skills namespace,
  cross-project meta-dashboards, project backup/restore).
- Continuous polish: drag-and-drop, tags, favorites, recents,
  color labels, starter dashboards, opportunistic backfill.
- Known research gaps to chase when relevant.

No code change; pure docs. Commits to the feature branch
because the v2.3 planning context originated there; lands on
main with the merge.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 22:52:25 +02:00
Alan Wizemann fd0d923c0b chore(assets): switch AppIcon set to macOS-native filenames
Exported from Apple Configurator / Icon Composer with the macOS
naming template instead of the iOS one (rose from having the wrong
template selected in the asset-set's original export). The actual
PNG contents match the sizes the macOS AppIcon expects at every
1x/2x density; Contents.json reorders to reference the new names.

No visual change for users — the Finder / Dock / about-box icon
render identically because the rendered pixels are unchanged at
each size. File replacement is purely naming / organizational.
Uploaded as a prep commit on the v2.3-projects feature branch
since the icon tweak was sitting in the working tree and
shipping it separately from the feature work would require an
extra release cycle for no benefit.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 22:52:12 +02:00
Alan Wizemann 3c2d11470f chore: Bump version to 2.2.1 v2.2.1 2026-04-23 22:05:50 +02:00
Alan Wizemann dcd2f8f04b docs: v2.2.1 release notes
Covers the four commits landed since v2.2.0:

- New catalog template: awizemann/template-author (scaffolding skill)
- Config sheet fix: EnumControl always uses Menu picker, not Segmented
  (the long-option-label overflow that clipped the form)
- Config sheet fix: maxWidth constraint on inner VStacks so descriptions
  with unbreakable tokens wrap cleanly
- SKILL.md authoring guidance: prefer markdown link syntax over raw URLs
- Devops: scripts/catalog.sh accepts git worktrees

release.sh picks up this file as the GitHub release body.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 22:04:31 +02:00
Alan Wizemann ef3ddcdd7a fix(config-sheet): EnumControl always uses Menu picker, never Segmented
The Configuration sheet's clipping bug persisted after the earlier
VStack maxWidth fix (d616935) and the user's Part-C manifest
rewrite to use [label](url) markdown. Re-diagnosed: the actual
overflow source was EnumControl's `.pickerStyle(.segmented)` branch,
active when options.count ≤ 4.

Segmented pickers on macOS size to the intrinsic width of all their
labels concatenated. They refuse offered width constraints, refuse
to wrap, refuse to truncate. A schema with three long labels like
"Claude Opus 4 (Recommended - Most Capable)" produced a ~650pt
segmented picker that pushed the fieldRow past the sheet's 560pt
viewport. No amount of .frame(maxWidth: .infinity) on parent
containers can rein in a segmented picker — the picker ignores
them.

Fix: remove the segmented branch. Always use the default Menu
picker (dropdown). Dropdowns respect offered width and surface long
labels in the popup list, so the sheet can't overflow regardless of
label length or option count.

Loses the segmented look for short-enum cases like a 3-option
"Daily / Weekly / Monthly" picker — compactness traded for
correctness. If a future template author wants segmented rendering
for a specific short-label enum, we can add a manifest hint
(e.g., "uiHint": "segmented") that explicitly opts in; not worth
the machinery until there's demand.

58/58 Swift tests still pass. No schema changes, no migration.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 21:56:36 +02:00
Alan Wizemann 5e207f760d docs(skill): warn authors against raw URLs in field descriptions
Pairs with the config-sheet wrap fix in d616935. Even though the
Configuration sheet now renders raw URLs correctly, markdown link
syntax reads cleaner in the form — the visible text is the label,
not the URL. Teaching this in SKILL.md prevents the scaffolding
skill from generating schemas that look worse than they could.

Additions to SKILL.md:
- New "Writing good descriptions" subsection under Config Schema
  Design. Good/bad examples side by side; rule of thumb to wrap
  long unbreakable strings (URLs, paths) in markdown links or
  inline code.
- New item in the Common Pitfalls checklist: "No raw URLs in
  field descriptions."

Bundle rebuilt, catalog.json regenerated. 24/24 Python tests
still pass; Python validator treats descriptions as opaque strings
so no validator changes needed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 21:43:45 +02:00
Alan Wizemann d616935296 fix(config-sheet): wrap wide schema descriptions instead of clipping
The Configuration sheet rendered field labels chopped on the left
and description URLs spilling off the right whenever a schema
description contained a raw `https://…` URL. Root cause is layout:
SwiftUI's inline-markdown renderer turns the URL into an
unbreakable AttributedString link token, and without an explicit
maxWidth constraint on the sheet's inner VStack, width resolution
went bottom-up — the description's ideal width became the URL's
character length, the VStack matched it, the ScrollView's content
exceeded the sheet's `.frame(minWidth: 560)` viewport, the window
clipped the grown sheet, and the center-aligned result cut off
both sides.

Added `.frame(maxWidth: .infinity, alignment: .leading)` in two
places:
  - TemplateConfigSheet's inner VStack inside the ScrollView +
    the fieldRow VStack.
  - TemplateInstallSheet's main-preview VStack inside its
    ScrollView — same pattern, same failure mode for raw URLs in
    cron prompts or README blocks (the disclosure-group inner
    ScrollViews already had the modifier).

With the constraint, the description's
`.fixedSize(horizontal: false, vertical: true)` wraps at
whitespace boundaries as intended. The URL stays on its own line,
still clickable, still showing the full href. Long paths and
other unbreakable tokens render the same way.

Found while rendering a user-authored schema with two raw URLs
in descriptions. SKILL.md gets a paired update (separate commit)
teaching authors to prefer `[link text](https://…)` markdown
syntax so the visible description stays short even when the href
is long.

58/58 Swift tests pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 21:43:36 +02:00
Alan Wizemann ea4032766b feat(templates): ship awizemann/template-author skill bundle
A new .scarftemplate in the public catalog whose only content is
a Hermes skill that teaches an agent how to scaffold a new
Scarf-compatible project — dashboard, optional configuration
schema, optional cron job, AGENTS.md — from a short conversational
interview. Scaffolded projects are usable locally and cleanly
exportable as .scarftemplate bundles later.

The skill itself (~400 lines of structured markdown at
skills/scarf-template-author/SKILL.md) covers:

- When to invoke vs. when to answer inline
- The on-disk project shape Scarf expects
- A 5-question interview flow
- Full widget catalog (all 7 widget types) with JSON shapes
- Config schema design + hard invariants (no defaults on secrets,
  `contents.config` must match field count, etc.)
- Cron-job design including the {{PROJECT_DIR}} gotcha
- Step-by-step file writing (dashboard, manifest, AGENTS.md, README)
- Testing + catalog validation instructions
- Common pitfalls + source-of-truth references

Delivered as a .scarftemplate so the install flow's normal
safeguards apply: preview sheet shows one project + one skill
+ zero cron jobs + no config step, uninstall drops both the
project dir and the namespaced skill folder via the existing
lock-file mechanism.

Scope per user sign-off: blank-slate / fully conversational for
v1. Pre-baked archetypes (`monitor`, `dev-dashboard`, etc.) are
deferred to v1.1 pending real usage data on what shapes users
actually ask for.

New Swift test exercises the bundle through the installer's
plan builder — asserts manifest shape, that the skill lands at
~/.hermes/skills/templates/awizemann-template-author/scarf-template-author/SKILL.md,
and that no-config templates correctly skip the manifest cache.
58/58 Swift tests pass; 24/24 Python tests pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 19:41:50 +02:00
Claude 4132cb03e2 rebase: add import ScarfCore to templates feature Mac files
The v2.2.0 templates/config/catalog feature (introduced on main after
M0 branched) added 18 Mac-target files that reference types now living
in ScarfCore — ServerContext, ProjectEntry, ProjectDashboardService,
etc. After rebasing scarf-mobile-development onto main, those files
need `import ScarfCore` the same way the M0a/M0c/M0d extractions
added it to the ~100 pre-existing Mac files.

Unblocks Xcode compile of the scarf (Mac) target on this branch; no
behavior change.

https://claude.ai/code/session_019yMRP6mwZWfzVrPTqevx2y
2026-04-23 17:17:06 +00:00
Claude 44d2d6d6c6 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
2026-04-23 17:12:38 +00:00
Claude 6b731ddfb8 iOS port M5: Chat polish + Memory + Cron + Skills features
Fleshes out the iOS app from "Chat + placeholder Dashboard" into a
real on-the-go Hermes companion: Chat now renders tool calls + tool
results + permission sheets + markdown + chain-of-thought, and the
Dashboard gains three new feature surfaces.

## Chat polish

scarf/Scarf iOS/Chat/ChatView.swift — several new small SwiftUI
view types:

  - ToolCallCard: expandable card for each HermesToolCall on an
    assistant message. Tool-kind icon in the header (from
    HermesToolCall.toolKind.icon), arguments summary collapsed,
    full JSON on tap.
  - ToolResultRow: compact "Tool output" disclosure for messages
    with role == "tool", shown indented beneath the preceding
    assistant bubble.
  - PermissionSheet: SwiftUI .sheet(item:) presentation of
    RichChatViewModel.pendingPermission. Tapping an option
    dispatches ChatController.respondToPermission → ACPClient.
  - ReasoningDisclosure: DisclosureGroup for HermesMessage.reasoning,
    collapsed by default so chatty thinkers don't dominate scroll.

MessageBubble now renders assistant content through
AttributedString(markdown: options: .inlineOnlyPreservingWhitespace).
User messages stay plain Text (no reason to parse what the user
just typed). Unknown markdown falls through as literal text — worst
case, no formatting.

ChatController gains respondToPermission(requestId:optionId:) that
forwards to ACPClient and clears vm.pendingPermission on the
MainActor.

## New feature surfaces

### Memory (read + edit)

ScarfCore/ViewModels/IOSMemoryViewModel.swift:
  - Kind enum (.memory / .user) → maps to paths.memoryMD / .userMD
  - text (mutable) + originalText (pristine) + hasUnsavedChanges
  - load() / save() / revert()
  - async file I/O via ServerContext.readText / writeText — run on
    a detached task so the MainActor doesn't hang on remote SFTP

scarf/Scarf iOS/Memory/:
  - MemoryListView: two-row NavigationLink (MEMORY.md, USER.md)
  - MemoryEditorView: TextEditor bound to vm.text, toolbar Save +
    Revert, "Saved" bottom toast on success.

### Cron (read-only)

ScarfCore/ViewModels/IOSCronViewModel.swift:
  - Loads ~/.hermes/cron/jobs.json via transport.readFile + decodes
    into CronJobsFile (Codable, shipped in M0a)
  - Missing file = empty list (no error — common on fresh installs)
  - Sort: enabled-first, then nextRunAt ascending, disabled last
  - Surfaces decode errors via lastError

scarf/Scarf iOS/Cron/CronListView.swift:
  - Row: state-icon + name + schedule.display + next-run-at.
  - Detail: prompt, schedule, state, delivery route (via
    job.deliveryDisplay), skills, model.

Editing is deferred — needs atomic jobs.json rewrites. Shipped the
read path so users can at least audit their cron config on the go.

### Skills (read-only)

ScarfCore/ViewModels/IOSSkillsViewModel.swift:
  - Scans ~/.hermes/skills/<category>/<name>/ via transport.listDirectory
    + transport.stat for directory-ness
  - Filters dotfiles. Skips empty categories. Swallows per-category
    listing errors (permissions etc.) rather than failing the whole
    load.
  - requiredConfig stays empty — YAML frontmatter parsing deferred
    (would need a parser in ScarfCore; see M5 plan note).

scarf/Scarf iOS/Skills/SkillsListView.swift:
  - Grouped by category, tap → SkillDetailView (path + file list).

## Supporting tweaks

- RichChatViewModel.PendingPermission: fields + public init promoted
  from `let`/internal to `public let` / `public init(...)` so
  PermissionSheet can read title/kind/options and tests can construct
  one directly.

- LocalTransport.writeFile refactored to use Data.write(options: .atomic)
  instead of FileManager.replaceItemAt. replaceItemAt is Apple-only;
  Linux swift-corelibs doesn't fully implement it, which was breaking
  the M5 save-path tests on Linux CI. Data.write(atomic) is cross-
  platform and has identical semantics (temp-file + rename). Also
  auto-creates the parent directory if missing, folding in the one
  bit of the old logic that wasn't atomicity-related.

- DashboardView: single Chat Section → "Surfaces" Section with four
  NavigationLinks (Chat / Memory / Cron / Skills).

## Tests (ScarfCoreTests/M5FeatureVMTests, 10 new)

.serialized suite — tests install a `withLocalTransportFactory`
helper that swaps ServerContext.sshTransportFactory to produce a
LocalTransport against real tmp files (so .ssh contexts in the
test resolve to local FS paths). Restored in defer. Serialized
because the factory is a static.

  - memoryLoadsEmptyWhenFileMissing
  - memoryRoundTripsFileContent  — seed file → load → edit → save
    → reload via fresh VM → confirm persistence
  - memoryRevertRestoresOriginal
  - memoryKindPathRouting        — pin .memory → memoryMD etc.
  - cronEmptyWhenJobsFileMissing — missing file is not an error
  - cronLoadsAndSortsJobs        — 3-job fixture, verify sort:
                                   enabled-before-disabled and
                                   nextRunAt-ascending within
  - cronSurfacesDecodeErrors     — garbage jobs.json
  - skillsEmptyWhenDirMissing
  - skillsScansCategoryAndSkillStructure — 2 categories, dotfile
                                           filter check
  - skillsSkipsEmptyCategories
  - pendingPermissionMemberwise  — SQLite3-gated (RichChatViewModel
                                   is gated)

**108 / 108 passing on Linux** (98 → 108).

## Manual validation needed on Mac

1. Xcode compile clean against M5 source additions.
2. Chat: trigger a tool call + a permission request. Verify cards
   render, options dispatch, markdown looks right.
3. Memory: edit MEMORY.md on phone → save → confirm via `cat` on
   the remote.
4. Cron: existing jobs show sorted + detail view useful.
5. Skills: browse matches `ls ~/.hermes/skills/<cat>/<name>/`.

Updated scarf/docs/IOS_PORT_PLAN.md with M5's scope, rationale
for the LocalTransport.writeFile refactor (Linux CI), and the M6
Settings-blocker (needs YAML parser port).

https://claude.ai/code/session_019yMRP6mwZWfzVrPTqevx2y
2026-04-23 17:12:38 +00:00
Claude bd6e722029 iOS port M4: Chat via SSHExecACPChannel (Citadel exec bidirectional)
First real interactive iOS feature. Streams JSON-RPC over a
Citadel 8-bit-safe exec channel to a remote `hermes acp` process.
Reuses ScarfCore's `RichChatViewModel` state machine (from M0d)
+ `ACPClient` (from M1) unchanged — the only new code is the iOS-
specific channel + factory + SwiftUI view.

## SSHExecACPChannel

Packages/ScarfIOS/Sources/ScarfIOS/SSHExecACPChannel.swift
(iOS counterpart to Mac's ProcessACPChannel)

Uses Citadel's `SSHClient.withExec(_:perform:)`:
  - RFC 4254 exec channel, no PTY, binary-clean stdin/stdout for
    JSON-RPC bytes.
  - Bidirectional: `TTYStdinWriter` for our `send(_:)` writes,
    `TTYOutput` stream for stdout/stderr.
  - withExec's closure-scoped lifecycle handled by running it in
    a detached Task. A per-actor pending-waiters queue lets the
    first `send(_:)` block until the writer is handed over (one-
    time RTT); subsequent sends are instant.
  - `close()` cancels the Task, which drops the `withExec`
    closure, which triggers Citadel to close the SSH channel.
    Clean teardown.
  - Line framing via `Data` accumulators for stdout + stderr
    separately — Citadel yields bytes in arbitrary chunk sizes,
    we only push complete (newline-terminated) lines into the
    ACPChannel streams.

## ACPClient+iOS

Packages/ScarfIOS/Sources/ScarfIOS/ACPClient+iOS.swift
(Sibling to Mac's ACPClient+Mac.swift)

Exposes `ACPClient.forIOSApp(context:keyProvider:)`. Opens a
dedicated `SSHClient` per ACP session — NOT reusing the
`CitadelServerTransport` client. Rationale: ACP sessions can
run for minutes/hours of streaming chat, and OpenSSH caps
concurrent channels per connection at ~10. Two separate
connections (transport + ACP) stay well under.

SSH auth: ed25519 via the Keychain-stored bundle, same
`SSHAuthenticationMethod.ed25519(...)` path as
CitadelServerTransport.

## iOS Chat view

scarf/Scarf iOS/Chat/ChatView.swift + embedded ChatController
(@Observable @MainActor). Minimal v1 UX:

  - Three-state lifecycle: .connecting / .ready / .failed(reason)
  - Auto-scrolling message list
  - SwiftUI composer (multi-line TextField + Send button)
  - Toolbar "+" for a fresh session (stop → reset → start)
  - Message bubble (user: accent; agent: secondary background)

Deferred to M5: tool-call cards, permission request sheets,
markdown rendering, voice.

scarf/Scarf iOS/Dashboard/DashboardView.swift gains a
NavigationLink into Chat.

## Small public-API tweak

`RichChatViewModel.sessionId` promoted from `private(set)` to
`public private(set)` — ChatController reads it to route
`sendPrompt`. Same pattern as earlier M3 public-nits patches.

## Tests: 2 new in M4ACPIOSTests (now 98/98 on Linux)

Deliberately focused — M1's 10-test MockACPChannel suite already
covers the full ACPClient state machine. These two pin the
patterns iOS's new SSHExecACPChannel exercises:

  - streamingPromptDeliversChunksAndCompletes: full handshake +
    session/new + streamed agent_message_chunk notifications +
    session/prompt response. Verifies chunks arrive as
    .messageChunk events and prompt resolves with correct usage
    tokens.
  - permissionRequestYieldsEventAndRespondSends: remote
    session/request_permission request → .permissionRequest
    event → respondToPermission writes correct JSON back on the
    channel with matching id + outcome.

Running `docker run --rm -v $PWD/Packages/ScarfCore:/work
-w /work swift:6.0 swift test` now reports 98 / 98.

## Manual validation needed on Mac

1. Xcode compile of scarf mobile target against the merged
   pbxproj (target reconciliation shipped in the previous commit
   on this branch).
2. Chat end-to-end against a real Hermes host. From Dashboard,
   tap Chat → type "hello" → streaming response. Test "+" for
   new session. Verify no leaked SSH connections across
   Disconnect + re-onboard.
3. If your Hermes enables tools: verify tool_call_update
   notifications come through (won't render with fancy cards
   yet — that's M5 polish).

Updated scarf/docs/IOS_PORT_PLAN.md with M4's shipped state, the
"two separate SSH clients" rule, and the M5 polish backlog
(tool cards, permissions, markdown, voice).

https://claude.ai/code/session_019yMRP6mwZWfzVrPTqevx2y
2026-04-23 17:12:38 +00:00
Claude 110611549e iOS target reconciliation: integrate Xcode-created scarf mobile target
Three-way reconciliation of:
  - my M2/M3 source tree at scarf/scarf-ios/
  - Alan's Xcode-created target with folder scarf/Scarf iOS/ and
    target name `scarf mobile` (bundle com.scarf-mobile.app)
  - the Mac `scarf` target that already had ScarfCore wired in

Alan created the iOS target on the unrelated `template-configuration`
branch (commit b289a83). I pulled only the iOS-specific bits
(Scarf iOS/ folder, Scarf iOS{Tests,UITests}/, and the pbxproj
additions), leaving the template-config work alone.

## Source tree: one folder, no duplicates

Before:
  - scarf/scarf-ios/{App,Onboarding,Dashboard,Assets.xcassets,SETUP.md}
  - scarf/Scarf iOS/{ContentView,Item,Scarf_iOSApp}.swift  (Xcode defaults)
  - scarf/Scarf iOS/Assets.xcassets/                        (empty stubs)

After:
  - scarf/Scarf iOS/{App,Onboarding,Dashboard,Assets.xcassets,
                     Info.plist,Scarf_iOS.entitlements}
  - scarf/Scarf iOSTests/Scarf_iOSTests.swift               (placeholder)
  - scarf/Scarf iOSUITests/Scarf_iOSUITests*.swift          (placeholder)

Deleted:
  - scarf/Scarf iOS/ContentView.swift        (my App/ScarfIOSApp supersedes)
  - scarf/Scarf iOS/Item.swift               (Xcode's SwiftData boilerplate,
                                              unused)
  - scarf/Scarf iOS/Scarf_iOSApp.swift       (my App/ScarfIOSApp supersedes)
  - scarf/Scarf iOS/Assets.xcassets/         (replaced with pre-built
                                              1024 icon + Scarf-teal accent)
  - scarf/scarf-ios/                         (emptied + removed)

Moved:
  - scarf/scarf-ios/SETUP.md → scarf/docs/iOS-SETUP.md
    Docs belong in docs/ now that the target exists; the old
    walkthrough is rewritten as a "project layout reference"
    post-setup — the step-by-step target-creation instructions
    are moot.

## pbxproj: three-way merge, no conflicts

Used git merge-file between:
  base = main's pbxproj (before M0a + b289a83)
  ours = M3 branch's pbxproj (has ScarfCore wired to Mac target)
  theirs = b289a83 (has iOS target additions)

Merge produced zero conflict markers (658 → 1074 lines, +~416
from Alan's target additions and my M0a ScarfCore wiring). Hand-
added on top:

  - 53SCARFIOS0020: new XCLocalSwiftPackageReference pointing at
    Packages/ScarfIOS
  - 53SCARFIOS0001: XCSwiftPackageProductDependency for ScarfIOS
    product
  - 53SCARFCORE0002: second XCSwiftPackageProductDependency for
    ScarfCore (separate nav reference from the Mac target's so
    Xcode can track per-target memberships cleanly)
  - 53SCARFCORE0011 + 53SCARFIOS0010: PBXBuildFile entries linking
    both frameworks into the iOS target's Frameworks build phase
  - packageReferences on the project: added 53SCARFIOS0020
  - scarf mobile target's packageProductDependencies: added
    53SCARFCORE0002 + 53SCARFIOS0001
  - scarf mobile target's Frameworks build phase files: added
    53SCARFCORE0011 + 53SCARFIOS0010

Mac target (scarf) is unchanged structurally — still wires
53SCARFCORE0001 as before.

## docs/iOS-SETUP.md rewrite

Rewrote from "how to create the target" to "project layout
reference + troubleshooting". Key sections:

  - Target settings (name, bundle ID, deployment, team, Swift
    language version)
  - SPM dependency map (ScarfCore on both; ScarfIOS on iOS only)
  - Entitlements audit: called out that Xcode defaults set
    CloudKit + APNs entitlements that we don't actually use in
    M2-M4. Keeping them for now since they're no-ops; flagged
    for M6 polish.
  - Milestone coverage table
  - Troubleshooting entries for the 4 most likely build failures
    (Citadel resolution, `Cannot find Process`, stale auth variant
    name, Keychain access)

## Sanity check

Linux `swift test` on ScarfCore still 96/96 passing. The Xcode
build will need Mac verification.

https://claude.ai/code/session_019yMRP6mwZWfzVrPTqevx2y
2026-04-23 17:12:38 +00:00
Claude 92ac102f46 M3 follow-up: SETUP.md rewritten for single-project approach
Q: Should I just create an iOS target in the current scarf project?
   Would that be easier?
A: Yes — single scarf.xcodeproj with two targets (scarf + scarf-ios)
   is objectively easier than a separate scarf-ios.xcodeproj.

The original conservative recommendation (separate xcodeproj) was
rooted in my not wanting to hand-edit pbxproj. But you're the one
clicking through Xcode's UI to create the target, not me — Xcode
handles multi-target multi-platform projects natively, with zero
risk to the existing Mac target.

Rewrote SETUP.md to describe the single-project flow:

  - `File → New → Target` inside the existing project (not a new
    project file).
  - Both targets share the same SPM package references — ScarfCore
    is already there for Mac, you just add it + ScarfIOS to the
    scarf-ios target via General → Frameworks.
  - One Xcode window, one scheme switcher, unified signing/team
    settings.

Also threaded in M3-specific smoke-test steps (connect to a real
host → see Dashboard load via Citadel SFTP snapshot) and added a
post-M3 troubleshooting entry for the `Cannot find 'Process' in
scope` error — it should never appear now that makeProcess is
`#if !os(iOS)`-guarded, but if it does it's a leaked Mac-only file
in the scarf-ios target membership.

Milestone status table in SETUP.md updated to reflect M3 shipped.

https://claude.ai/code/session_019yMRP6mwZWfzVrPTqevx2y
2026-04-23 17:12:38 +00:00
Claude e85a7b170c iOS port M3: CitadelServerTransport + fix critical iOS compile blocker
Three things this phase ships:

## 1. Critical iOS-compile fix (latent from M0b)

`ServerTransport.makeProcess(...) -> Process` was iOS-unavailable at
compile time but my Linux CI didn't catch it (swift-corelibs-foundation
has Process; Apple iOS does not). Without this fix, the first ⌘B on
the iOS target would fail with "Cannot find 'Process' in scope".

Wrapped `makeProcess` with `#if !os(iOS)` on:
  - the ServerTransport protocol requirement
  - LocalTransport's impl
  - SSHTransport's impl

Every current caller of makeProcess is already Mac-target-only
(ACPClient+Mac.swift, OAuthFlowController.swift) so no code changes
needed outside ScarfCore.

## 2. New platform-neutral streamLines(_:args:)

`AsyncThrowingStream<String, Error>` on the protocol, one stdout
line per element, newline-framed. Stream finishes on EOF + throws
`TransportError.commandFailed` on non-zero exit.

Impls:
  - LocalTransport: Task.detached → Process + Pipe → line-framing
    loop → exit check. iOS returns an empty stream (iOS doesn't run
    LocalTransport at runtime).
  - SSHTransport: same pattern, wrapped in `ssh -T host -- sh -c`.
    iOS gets the empty-stream stub.
  - CitadelServerTransport: empty stream for M3; M4 wires it to
    Citadel's raw exec channel for iOS log tailing + chat.

HermesLogService refactored to use transport.streamLines() for the
remote tail path. The old `remoteTailProcess: Process?` +
`fileHandle: FileHandle?` state collapses into a single
`remoteTailTask: Task<Void, Never>?`. Parsed-line ring buffer is
drained synchronously by readNewLines() — semantically identical
on Mac, and newly works on iOS (when Citadel wires streamLines
in M4+).

## 3. CitadelServerTransport (the meat of M3)

Full `ServerTransport` conformance in ScarfIOS:

  - File I/O: SFTP via SSHClient.openSFTP()
  - runProcess: SSHClient.executeCommand(_:) with 2>&1 folding
  - snapshotSQLite: remote `sqlite3 .backup` then SFTP-download
    to <Caches>/scarf/snapshots/<id>/state.db
  - fileExists/stat: SFTPClient.getAttributes
  - listDirectory: SFTPClient.listDirectory with . / .. stripped
  - createDirectory: mkdir -p semantics (walks each component,
    ignores existing-dir errors)
  - removeFile: SFTPClient.remove, idempotent on missing
  - watchPaths: 3s polling on stat mtime (same shape as Mac
    SSHTransport's remote-watch fallback)
  - streamLines: empty stream for M3 (see above)

Maintains a single long-lived SSH + SFTP connection per transport
instance via a nested ConnectionHolder actor. Lazy-init on first
use, reconnect on failure. Blocks the caller thread via
DispatchSemaphore to bridge Citadel's async API to
ServerTransport's sync protocol — same pattern the Mac SSHTransport
uses.

## ScarfCore transport-factory injection

New `ServerContext.sshTransportFactory: SSHTransportFactory?`
static. When non-nil, `makeTransport()` routes `.ssh` contexts
through it instead of constructing SSHTransport directly.

scarfApp.init() on iOS wires this:
  ServerContext.sshTransportFactory = { id, cfg, name in
      CitadelServerTransport(
          contextID: id, config: cfg, displayName: name,
          keyProvider: { try await KeychainSSHKeyStore().load() ?? ... }
      )
  }

Mac leaves it nil; default SSHTransport path unchanged.

## iOS Dashboard — real data

New IOSDashboardViewModel in ScarfIOS. Unlike Mac's DashboardViewModel
(uses HermesFileService, still Mac-only), this reads session stats +
recent sessions only — enough for a real iOS Dashboard, none of the
config.yaml / gateway-state / pgrep checks the Mac dashboard shows.

DashboardView on iOS now renders actual data: session count, message
count, tool calls, token totals (input/output/reasoning with K/M
formatting), and the last 5 sessions with their source icons +
relative start times. Pull-to-refresh triggers vm.refresh(). Error
banner with Retry on snapshot/open failures.

## Public API nits (uncovered by the Dashboard work)

HermesDataService.SessionStats member fields + .empty static were
internal-by-default (nested in a public type, sed missed them).
Promoted to public. `lastOpenError` promoted to public private(set).

## Tests — 8 new in M3TransportTests, @Suite(.serialized)

- LocalTransport.streamLines yields one line per newline, drops
  partial trailing content, surfaces non-zero exit as
  TransportError.commandFailed.
- ServerContext.sshTransportFactory override applies for .ssh,
  ignored for .local, nil-falls-back-to-SSHTransport.
- HermesLogService remote-tail pumps scripted streamLines output
  through to readNewLines() ring buffer.
- HermesLogService.readLastLines uses runProcess one-shot, as
  documented.

Real bug caught in dev: first pass of this test suite had two tests
setting ServerContext.sshTransportFactory + defer-restoring. Swift-
Testing runs in parallel by default — the two tests raced, producing
"entries[2].message is 'z' not 'boom'". Fixed with
@Suite(.serialized) + a note in the suite header explaining why.

Running `docker run --rm -v $PWD/scarf/Packages/ScarfCore:/work -w /work
swift:6.0 swift test` now reports 96 / 96 passing (88 pre-M3 + 8 new).

## Manual validation needed on Mac

1. iOS build with the new protocol guards. ⌘B on iOS simulator —
   should compile cleanly. If `Cannot find 'Process' in scope`
   still appears anywhere, grep for any unguarded `Process\(\)`.
2. Dashboard end-to-end against a real Hermes host: iPhone
   simulator, public key in remote authorized_keys, onboarding →
   Dashboard → should see session stats fetched via Citadel SFTP +
   exec. Pull-to-refresh should re-snapshot.
3. SQLite snapshot file appears under `<Caches>/scarf/snapshots/
   <id>/state.db` and HermesDataService opens it read-only.

Updated scarf/docs/IOS_PORT_PLAN.md with M3's shipped scope, the
streamLines adoption rule, and the "CitadelServerTransport.streamLines
is a stub (M3)" / "M4 wires real streaming" cross-reference.

https://claude.ai/code/session_019yMRP6mwZWfzVrPTqevx2y
2026-04-23 17:12:38 +00:00
Claude 3420abae74 M2 follow-up: Citadel 0.12.1 (current), pre-built Assets.xcassets
Two follow-ups per review:

## Citadel: current stable

Citadel is at 0.12.1, not 0.9.x as I'd been writing against. Bumped
the pin from `from: "0.7.0"` to `.upToNextMinor(from: "0.12.0")`
— tight because Citadel's pre-1.0 authentication-method variants
have shifted between minor releases (0.7 → 0.9 → 0.12), so
explicit bump-and-review is safer than letting the version float.

Downloaded Citadel 0.12.1's source and verified every API call in
CitadelSSHService against it:
  - SSHAuthenticationMethod.ed25519(username:, privateKey:) ✓
  - SSHClientSettings(host:, authenticationMethod:, hostKeyValidator:) ✓
  - SSHHostKeyValidator.acceptAnything() ✓
  - SSHClient.connect(to: settings) ✓
  - client.executeCommand(_:) -> ByteBuffer ✓
  - client.close() async throws ✓

Dropped the "FIXME — may need adjustment" disclaimer in the file
header; replaced with a "verified against 0.12.1" note that says
re-verify if the pin bumps to 0.13+. Same change in SETUP.md
troubleshooting.

## Assets.xcassets (app icon + accent color)

scarf/scarf-ios/Assets.xcassets/ now exists with:

  - AppIcon.appiconset/
      AppIcon-1024.png    (1024×1024, copied from the Mac app's
                           icon set — same art)
      Contents.json       (idiom: universal, platform: ios,
                           size: 1024x1024 — iOS 14+ renders all
                           smaller sizes from this automatically)
  - AccentColor.colorset/
      Contents.json       (Scarf teal: sRGB 0.227/0.525/0.722
                           light, 0.400/0.690/0.902 dark)
  - Contents.json         (root, empty — just version metadata)

SETUP.md updated:
  - Instructs Alan to delete Xcode's scaffolded Assets.xcassets AND
    import ours, not the other way around.
  - Notes the accent color values so a different palette choice is
    a one-file edit.
  - Removes the obsolete "drop your icon asset" step.

No functional code changes; tests still 88/88 on Linux.

https://claude.ai/code/session_019yMRP6mwZWfzVrPTqevx2y
2026-04-23 17:12:37 +00:00
Claude ba368d2f6d iOS port M2: iOS app skeleton — onboarding, Citadel wrapper, Keychain, Dashboard
First iOS phase. Delivers all the code needed to build + TestFlight a
functional v1 iOS app (onboarding with SSH-key generate / import +
real Citadel-backed connection test; persistent Keychain key +
UserDefaults server config; placeholder Dashboard) — but NOT the
scarf-ios.xcodeproj. Creating that from scratch by hand is too risky
without an iOS SDK to build against, so Alan creates it in Xcode's UI
following scarf/scarf-ios/SETUP.md (~5 minutes, one-time).

## ScarfCore additions (all Linux-testable)

Packages/ScarfCore/Sources/ScarfCore/Security/:
  - SSHKey.swift         — SSHKeyBundle + SSHKeyStore protocol
                            + InMemorySSHKeyStore test actor
  - IOSServerConfig.swift — IOSServerConfig + store protocol + mock;
                            toServerContext(id:) bridges to the
                            existing ServerContext so all ScarfCore
                            services work against an iOS config
  - OnboardingState.swift — OnboardingStep enum + pure validators
                            (host, port, PEM shape, public-key parse)
  - SSHConnectionTester.swift — protocol + error enum + mock
  - OnboardingViewModel.swift — @Observable @MainActor state machine,
                            fully dependency-injected (key store /
                            config store / tester / generator closure)

## New Packages/ScarfIOS local SPM package

Depends on ScarfCore + Citadel (from: "0.7.0").

  - KeychainSSHKeyStore.swift    — real iOS Keychain storage
    (kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly, no iCloud
     sync). Gated on canImport(Security) for Linux skip.
  - UserDefaultsIOSServerConfigStore.swift — JSON-encoded single-key
    persistence of IOSServerConfig.
  - Ed25519KeyGenerator.swift    — CryptoKit-backed Ed25519 minting.
    Emits standard OpenSSH public-key lines (authorized_keys-ready).
    Stores the private half in a compact SCARF ED25519 PRIVATE KEY
    PEM shape that CitadelSSHService decodes back into a
    Curve25519.Signing.PrivateKey. Non-interop with OpenSSH's
    `BEGIN OPENSSH PRIVATE KEY` envelope — export flow for sharing
    keys is deferred to a later phase.
  - CitadelSSHService.swift      — SSHConnectionTester conformance +
    key-generation wrapper. Runs `echo scarf-ok` over a one-shot
    Citadel exec for the onboarding connection test. One FIXME on
    buildClientSettings because Citadel 0.7→0.9 shifted the
    `.ed25519(...)` authentication-method variant name; every other
    line is Citadel-version-independent. Gated on
    canImport(Citadel) && canImport(CryptoKit).

## scarf/scarf-ios/ app source tree

  - App/ScarfIOSApp.swift         — @main, RootModel routes to
                                    onboarding or dashboard based on
                                    stored state.
  - Onboarding/OnboardingRootView.swift — 8 sub-views, one per
                                    OnboardingStep. Validated
                                    server-details form, key-source
                                    picker, generate / show-public
                                    / import / test / retry /
                                    connected.
  - Dashboard/DashboardView.swift — M2 placeholder: connected host
                                    details + Disconnect button.
                                    M3 replaces with real data.

## scarf/scarf-ios/SETUP.md

Step-by-step Xcode project creation:
  - iOS 18 / iPhone-only / team 3Q6X2L86C4 / Bundle ID
    com.scarf.scarf-ios / Swift 5 language mode.
  - Wire Packages/ScarfCore + Packages/ScarfIOS (Citadel resolves
    transitively).
  - Replace Xcode's default scaffolded files with this source tree.
  - Smoke-test procedure (simulator → physical iPhone).
  - TestFlight upload steps.
  - Troubleshooting for the known Citadel-variant-name drift.

## Test coverage (Linux, `swift test`)

M2OnboardingTests, 26 new tests (ScarfCore):
  - SSHKeyBundle memberwise + display fingerprint
  - InMemorySSHKeyStore + InMemoryIOSServerConfigStore round-trips
  - IOSServerConfig.toServerContext bridging (with + without
    remoteHome override)
  - All OnboardingLogic validators (empty / whitespace / port range /
    legacy-RSA rejection / public-key line parser)
  - MockSSHConnectionTester scripting (success + failure)
  - 10 OnboardingViewModel end-to-end paths: happy-path
    save-and-test, invalid-host blocks advance, connection-failure
    routes to .testFailed (and crucially does NOT save config),
    retry-after-failure-works, import-happy, import-rejects-bad-PEM,
    reset clears all state

ScarfIOSSmokeTests, 3 tests (Apple-only, won't run on Linux):
  - Ed25519KeyGenerator bundle shape + base64 wire format
  - OpenSSH public-key line byte-length pinned at 51 bytes
  - Corrupted PEM rejection on round-trip decode

Running
  docker run --rm -v $PWD/scarf/Packages/ScarfCore:/work -w /work swift:6.0 swift test
reports **88 / 88 passing** (62 pre-M2 + 26 new).

## Real bug caught in development

First pass of OnboardingViewModel had `confirmPublicKeyAdded()` set
`isWorking=true`, then call `runConnectionTest()` which bailed on
`!isWorking` — meaning the connection probe never ran and the config
was never saved. Caught by the end-to-end test. Fixed by extracting
the shared probe body into `performConnectionTest()` and letting
both entry points own their own `isWorking` transition.

## Manual validation still needed on Mac

1. Xcode project creation per SETUP.md — confirm the resulting
   project builds cleanly.
2. Citadel 0.9.x authentication-method variant — verify the one
   FIXME line in buildClientSettings.
3. End-to-end onboarding: simulator against `localhost:22` (or a
   test host), then TestFlight → physical iPhone → real SSH host
   with the shown public key in authorized_keys.

Updated scarf/docs/IOS_PORT_PLAN.md with M2's shipped scope, the
scope decision about NOT generating the xcodeproj, and the list of
rules M3+ can rely on (Citadel transport dispatch, ChannelFactory
hook, single-server invariant).

https://claude.ai/code/session_019yMRP6mwZWfzVrPTqevx2y
2026-04-23 17:12:37 +00:00
Claude bdf31d6781 iOS port M1: decouple ACPClient from Process via ACPChannel protocol
Introduces the key architectural abstraction that lets iOS share the
ACP state machine with Mac in M4+. ACPClient no longer touches
`Process`, `Pipe`, file descriptors, or SSH sessions directly — it
reads / writes line-oriented JSON-RPC through an `ACPChannel`.

New in ScarfCore/ACP/:
  - ACPChannel.swift (protocol + ACPChannelError enum)
  - ProcessACPChannel.swift (Mac + Linux; `#if !os(iOS)` guard —
    iOS can't spawn subprocesses). Wraps the Process + Pipe +
    raw POSIX write(2) code that used to live inline inside
    ACPClient: SIGPIPE-ignore, partial-write loops, EPIPE →
    `.writeEndClosed`, graceful SIGINT + 2s SIGKILL watchdog.
    Uses `canImport(Darwin)` / `canImport(Glibc)` for the
    platform-specific `write(2)` binding.
  - ACPClient.swift (moved from scarf/Core/Services and refactored).
    Process/Pipe/stdinFd/Darwin.write state replaced with a single
    `channel: any ACPChannel` reference. Construction takes a
    `ChannelFactory = @Sendable (ServerContext) async throws -> any ACPChannel`
    closure — Mac wires ProcessACPChannel, iOS will wire a Citadel
    SSHExecACPChannel in M4.

Mac-side glue (stays in main target):
  - scarf/Core/Services/ACPClient+Mac.swift (new) carries the
    `ACPClient.forMacApp(context:)` factory. Internally spawns
    `hermes acp` locally or `ssh -T host -- hermes acp` remotely
    via SSHTransport.makeProcess, passing the enriched shell env
    (local: full PATH + credentials; remote: just SSH_AUTH_SOCK
    + SSH_AGENT_PID) with TERM stripped. Behaviour identical to
    pre-M1.
  - ChatViewModel updated at 3 sites from `ACPClient(context:)`
    to `ACPClient.forMacApp(context:)`.

Public API change callers need to know about:
  - `ACPClient.respondToPermission(requestId:optionId:)` is now
    `async`. ChatViewModel already `await`ed it, so that upgrade
    is a no-op; no other callers.

Also deleted scarf/Core/Services/ACPClient.swift (605 lines;
replaced by ScarfCore version).

Test coverage (M1ACPTests, 10 tests):
  Using a MockACPChannel actor to script JSON-RPC deterministically,
  not a real subprocess:
  - ACPChannel protocol (mock send/receive, write-after-close,
    error descriptions).
  - ACPClient initial state.
  - start() sends initialize and flips isConnected on reply.
  - RPC error reply surfaces as ACPClientError.rpcError.
  - Mid-flight channel close → pending request resolves with
    .processTerminated, isConnected flips false.
  - session/update notification routes into the `events` stream
    as .messageChunk.
  - Stderr lines feed the recentStderr ring buffer.
  - ACPErrorHint.classify across credential / missing-binary /
    rate-limit / unknown cases.

`swift test` on Linux now reports 62 / 62 passing.

Updated scarf/docs/IOS_PORT_PLAN.md with M1's shipped state, the
behavior-preservation rationale for the Mac factory, and the
iOS hook point M2–M4 will plug into.

https://claude.ai/code/session_019yMRP6mwZWfzVrPTqevx2y
2026-04-23 17:12:37 +00:00
Claude 920c86b4f8 M0 verification: fix two real regressions before starting M1
Two bugs caught by a post-M0d audit, both of which would have bitten
users before any test exercised them on Mac:

1. GatewayViewModel.swift lost its `import ScarfCore` during the
   M0d revert (when I moved it back to the Mac target after finding it
   wasn't portable). The file references ServerContext everywhere and
   wouldn't compile in Xcode without the import. Added back.

2. SSHTransport.sshSubprocessEnvironment() regressed in M0b.
   The original Mac code ran HermesFileService.enrichedEnvironment(),
   which tries `zsh -l -i` (login + interactive, with prompt-framework
   defangs) FIRST, then falls back to `zsh -l`. Most users with
   1Password / Secretive / manual ssh-add export SSH_AUTH_SOCK from
   their `.zshrc` (interactive shell init), NOT `.zprofile`. My M0b
   replacement used `zsh -l` only — so it would have silently failed
   to find their ssh-agent socket, and SSH auth would break with
   "Permission denied" (exit 255) for everyone who set up their
   agent the normal way.

   Fix is a dependency-inversion injection point instead of a local
   shell probe: SSHTransport.environmentEnricher is a `(@Sendable () ->
   [String: String])?` static that the Mac target wires at launch to
   HermesFileService.enrichedEnvironment(). Same exact code path
   executed as before M0b; no duplication; iOS leaves it `nil` and
   falls back to ProcessInfo.processInfo.environment (Citadel will
   own the SSH agent on iOS in M4+, not the login shell). Tests can
   set a stub closure.

   scarfApp.init() now sets `SSHTransport.environmentEnricher = {
   HermesFileService.enrichedEnvironment() }` right before the
   existing warm-up Task.

Test coverage: M0b suite gains `sshTransportEnvironmentEnricherInjection`,
which pins the injection-point shape so a future refactor can't
silently drop it.

Audit results (for confidence before M1):
  - Exhaustive grep of every moved type across main target → 0 files
    reference ScarfCore types without `import ScarfCore` (after the
    GatewayVM fix).
  - `scarf.xcodeproj/project.pbxproj` has no stale path references
    (PBXFileSystemSynchronizedRootGroup auto-discovers).
  - `xcshareddata/xcschemes/*.xcscheme` has no stale path references.
  - `.build/` correctly gitignored.
  - Zero leftover temp scripts / `.orig` / `.bak` files.

`swift test`: 52 / 52 passing on Linux.

https://claude.ai/code/session_019yMRP6mwZWfzVrPTqevx2y
2026-04-23 17:12:37 +00:00
Claude 8bd4b9282a iOS port M0d: extract 6 portable ViewModels to ScarfCore
Fourth and final M0 sub-PR. Wraps up the ScarfCore extraction with the
ViewModels that have no dependency on Mac-target services or AppKit.
Views deliberately stay in the Mac target — see plan for rationale.

Moved (6 VMs):
  ActivityViewModel.swift      — HermesDataService consumer, SQLite3-gated
  ConnectionStatusViewModel.swift — @MainActor heartbeat for remote SSH
  InsightsViewModel.swift      — HermesDataService aggregator, SQLite3-gated
                                  (+ InsightsPeriod, ModelUsage, PlatformUsage,
                                   ToolUsage, NotableSession types; exports
                                   free functions formatDuration/formatTokens)
  LogsViewModel.swift          — HermesLogService consumer, fully portable
                                  (+ nested LogFile / LogComponent enums)
  ProjectsViewModel.swift      — ProjectDashboardService wrapper, portable
  RichChatViewModel.swift      — ~700 lines of ACP-event + message-group
                                  handling, SQLite3-gated
                                  (+ ChatDisplayMode, MessageGroup types)

Reverted in-flight:
  GatewayViewModel.swift — my audit missed that it calls
  `context.runHermes(...)`, a Mac-target-only extension. Not portable
  without moving HermesFileService too. Left in the Mac target.

Platform guards applied:
  - `#if canImport(SQLite3)` wraps entire files for ActivityVM, InsightsVM,
    and RichChatVM (they transitively depend on HermesDataService).
  - `#if canImport(Darwin)` around LocalizedStringResource displayName
    in LogsViewModel's nested LogFile and LogComponent enums.
  - `#if canImport(os)` around the unused Logger in
    ConnectionStatusViewModel (kept the field for future use).

Swift 6 / Observation notes:
  - `import Observation` explicitly added to each @Observable file.
    Mac target gets Observation via SwiftUI; ScarfCore doesn't import
    SwiftUI, so it needs the explicit module import. Observation ships
    in the Swift 5.9+ standard library on every platform.
  - Nested enums' `var id: String { rawValue }` had to be manually
    promoted to `public var id` since my sed only touches 4-space-indent
    declarations and the nested enum's members are at 8-space indent.
  - Two accidentally-publicized function-local `let` variables in
    InsightsViewModel reverted back to internal.
  - Sed adjustment: an earlier pattern was producing `@Observable public`
    which is a Swift syntax error. Fixed post-hoc by stripping the
    stray trailing `public` after the attribute; noted in the plan file
    as a checklist item for M1+ sed work.

Consumer import sweeps:
  4 Mac-target files gained `import ScarfCore` for the moved VM types:
  ContentView.swift, ChatView.swift, RichChatView.swift, and
  ConnectionStatusPill.swift.

Test coverage (M0dViewModelsTests): 14 new tests.
  - ConnectionStatusViewModel: local-always-connected, remote idle-start,
    Status Equatable pinning.
  - LogsViewModel: init defaults, filteredEntries across level / search /
    component filters, nested enum Identifiable ids and loggerPrefix.
  - ProjectsViewModel: .local context binding.
  - (SQLite3-gated, Apple-only):
    ActivityVM construction, InsightsVM period defaults and sinceDate
    ordering, ChatDisplayMode case coverage, RichChatVM empty-state
    invariants, MessageGroup derived properties.

Running `docker run --rm -v $PWD/scarf/Packages/ScarfCore:/work -w /work
swift:6.0 swift test` now reports 51 / 51 passing on Linux
(M0a 16 + M0b 18 + M0c 8 + M0d 9 + smoke 1 − 5 SQLite3-gated).
Apple-target CI should see 56 / 56 with the 5 gated tests added in.

Updated scarf/docs/IOS_PORT_PLAN.md with M0d's shipped state, the
Views-stay-Mac-only scope decision, and the sed-gotcha checklist
future phases should watch for.

https://claude.ai/code/session_019yMRP6mwZWfzVrPTqevx2y
2026-04-23 17:12:37 +00:00
Claude 27dc694aeb iOS port M0c: extract portable Services to ScarfCore
Third of four M0 sub-PRs. Moves the four Services that have no dependency
on Mac-target code or AppKit into ScarfCore, so the Mac + (future) iOS
targets can share them.

Files moved (4):
  scarf/Core/Services/HermesDataService.swift  (658 lines, SQLite reader + SnapshotCoordinator actor)
  scarf/Core/Services/HermesLogService.swift   (log tail + parse, LogEntry + LogLevel)
  scarf/Core/Services/ModelCatalogService.swift (models.dev JSON reader, HermesModelInfo + HermesProviderInfo)
  scarf/Core/Services/ProjectDashboardService.swift (per-project dashboard I/O)

Not moved, with reason:
  HermesFileService.swift  — carries the big shell-enrichment logic; a
    later phase can port once iOS has a clearer env story for ACP spawns.
  HermesEnvService.swift   — depends on HermesFileService.
  HermesFileWatcher.swift  — depends on HermesFileService.
  ACPClient.swift          — M1's job (the ACPChannel refactor).
  UpdaterService.swift     — wraps Sparkle, stays Mac-only forever.

Platform guards:
  HermesDataService.swift is wrapped in `#if canImport(SQLite3) ... #endif`
  for the whole file. SQLite3 isn't a system module on Linux
  swift-corelibs-foundation. Apple platforms compile unchanged. Linux
  builds skip the file entirely; nothing in ScarfCore references
  HermesDataService from outside the file, so there's no downstream
  fallout.

  ModelCatalogService `import os` / Logger definition / call site all
  guarded with `#if canImport(os)`. Linux gets silent logging.

  HermesLogService + ProjectDashboardService use only Foundation —
  no guards needed.

Other fixes:
  - Features/Settings/Views/Components/ModelPickerSheet.swift (the one
    remaining consumer) gains `import ScarfCore`.
  - Self-referential `import ScarfCore` stripped from each moved file.

Test coverage: 8 new tests in ScarfCoreTests/M0cServicesTests.swift:
  - HermesLogService.parseLine exercised via readLastLines on a real
    tmp file with three formats — v0.9.0+ with session tag, older
    without, and garbage fallback. Pins CLAUDE.md's optional-session-tag
    invariant.
  - LogLevel SwiftUI colour strings pinned.
  - HermesModelInfo.contextDisplay across 1M / 200K / 500 / nil cases;
    costDisplay with and without costs.
  - ModelCatalogService load path end-to-end against a synthetic
    models_dev_cache.json lookalike — providers sorted, models
    filtered, provider(for:) resolves both full-scan and slash-prefixed
    IDs.
  - Malformed + missing catalog files return empty, no crash.
  - ProjectDashboardService round-trips ProjectRegistry + reads a
    synthetic .scarf/dashboard.json.

Running `docker run --rm -v $PWD/scarf/Packages/ScarfCore:/work -w /work
swift:6.0 swift test` now reports 42 / 42 passing (M0a 16 + M0b 18 +
M0c 8).

Updated scarf/docs/IOS_PORT_PLAN.md progress log with the shipped M0c
state and the SQLite3-gating pattern future phases should reuse.

https://claude.ai/code/session_019yMRP6mwZWfzVrPTqevx2y
2026-04-23 17:12:05 +00:00
Claude 0fd2ceb9fc iOS port M0b: extract Transport + ServerContext to ScarfCore
Second of four M0 sub-PRs. Moves the remaining cross-cutting
infrastructure — the ServerTransport protocol and its two implementations
(LocalTransport, SSHTransport), plus ServerContext and its helpers —
into ScarfCore so both Mac and (future) iOS targets share one codebase.

Files moved (5):
  - scarf/Core/Transport/ServerTransport.swift (+ FileStat, ProcessResult, WatchEvent)
  - scarf/Core/Transport/LocalTransport.swift
  - scarf/Core/Transport/SSHTransport.swift
  - scarf/Core/Transport/TransportErrors.swift
  - scarf/Core/Models/ServerContext.swift (+ SSHConfig, ServerKind, ServerID, UserHomeCache)

Split out of ServerContext.swift into a new Mac-target sibling file
scarf/Core/Models/ServerContext+Mac.swift:
  - runHermes(_:timeout:stdin:) — depends on HermesFileService
  - openInLocalEditor(_:) — depends on AppKit.NSWorkspace
These methods can't live in ScarfCore itself because ScarfCore must not
depend on main-target services or AppKit. iOS will provide a sibling
ServerContext+iOS.swift in M2+.

Removed: scarf/Core/Models/HermesPaths+Deprecated.swift.
  Zero callers in-tree; its only justification was that ServerContext
  used to be in the Mac target. With ServerContext in ScarfCore now,
  the deprecated forwarders are both unreachable AND dead code.

Breaking the ScarfCore → main-target circular dep in SSHTransport:
  The old SSHTransport.sshSubprocessEnvironment() called
  HermesFileService.enrichedEnvironment() to harvest SSH_AUTH_SOCK from
  the user's login shell. Replaced with a local #if os(macOS) helper
  SSHTransport.macLoginShellSSHAgent() that probes /bin/zsh for only
  the two SSH agent vars (no PATH/credentials — that's still in
  HermesFileService for ACP subprocess use). Behavior-identical on
  macOS; no-op on iOS/Linux.

Platform guards added in ScarfCore (runtime targets still macOS/iOS):
  - `#if canImport(os)` around os.Logger (definition + every call site,
    except the large Darwin-dependent ensureControlDir block).
  - `#if canImport(Darwin)` around LocalTransport.watchPaths (FSEvents)
    and SSHTransport.ensureControlDir (Darwin.stat/lstat). Linux gets
    a no-op empty stream and a best-effort FileManager.createDirectory
    fallback — neither is exercised at runtime on Linux, only compiled.
  - `#if canImport(SwiftUI)` around ServerContext's EnvironmentKey.
  - `#if canImport(AppKit)` inside the new ServerContext+Mac.swift
    extension.

Bug fixed: M0a's sed transform accidentally added `public` to protocol
requirements in ServerTransport.swift, e.g. `public nonisolated var
contextID: ServerID { get }`. Swift forbids access modifiers on
protocol requirements — stripped.

54 additional consumer files in the Mac target gained `import ScarfCore`.

Test coverage: 18 new tests in ScarfCoreTests/M0bTransportTests.swift.
Runs on Linux via
  docker run --rm -v $PWD/scarf/Packages/ScarfCore:/work -w /work swift:6.0 swift test
Total suite: 34 / 34 passing (M0a's 16 + M0b's 18).

Updated scarf/docs/IOS_PORT_PLAN.md progress log with the shipped M0b
state and the Platform-guard patterns future phases should reuse.

https://claude.ai/code/session_019yMRP6mwZWfzVrPTqevx2y
2026-04-23 17:10:59 +00:00
Claude f6f31cabe4 M0a fixup: unignore local Packages/, add missing files, make Linux CI pass
The initial M0a commit was incomplete: .gitignore's `Packages/` rule
(meant for the legacy pre-Xcode-14 SwiftPM checkout dir) silently
swallowed three new files that SHOULD have been committed:

  - scarf/Packages/ScarfCore/Package.swift
  - scarf/Packages/ScarfCore/Sources/ScarfCore/Models/HermesConstants.swift
  - scarf/Packages/ScarfCore/Tests/ScarfCoreTests/ScarfCoreSmokeTests.swift

The 12 moved models slipped through because `git mv` preserves tracking
across gitignored destinations, but new files in that tree did not.

Fix: add `!scarf/Packages/` override so our local SPM package is always
tracked; keep the top-level `Packages/` ignore for the historical case.

Also verified M0a builds + tests green on Linux via
`docker run --rm -v $PWD/scarf/Packages/ScarfCore:/work -w /work swift:6.0 swift test`.
To make that work, two small, Apple-platform-preserving guards:

  - `sqliteTransient` in HermesConstants.swift wrapped in
    `#if canImport(SQLite3)` — SQLite3 is not a system module on Linux
    swift-corelibs-foundation. Apple builds compile unchanged.
  - `ToolKind.displayName` and `MCPTransport.displayName` wrapped in
    `#if canImport(Darwin)` — `LocalizedStringResource` is Apple-only.
    Apple builds compile unchanged.

Additionally:

  - Package.swift pinned to Swift 5 language mode, matching the Mac app's
    `SWIFT_VERSION = 5.0`. Two types (`ACPEvent.availableCommands` and
    `ACPToolCallEvent.rawInput`) claim `Sendable` while carrying
    `[String: Any]` — strict Swift 6 rejects that. Comment in Package.swift
    flags this for a future typed-payloads cleanup + bump to `.v6`.
  - ScarfCoreSmokeTests now contains 16 tests exercising every M0a
    `public init` so parameter drift fails CI instead of a reviewer.
  - IOS_PORT_PLAN.md updated with what actually shipped, the Linux-CI
    guards + patterns future phases should reuse, and the Sendable
    follow-up flagged under "Rules next phases can rely on".

Test results (Linux, Swift 6.0.3):
  Suite M0aPublicInitTests: 15 tests passed
  Suite ScarfCoreSmokeTests: 1 test passed
  Total: 16 / 16 passed

https://claude.ai/code/session_019yMRP6mwZWfzVrPTqevx2y
2026-04-23 17:10:59 +00:00
Claude bb5045c10f iOS port M0a: extract 13 leaf Models to new ScarfCore local SPM package
First of four M0 sub-PRs that carve a platform-neutral ScarfCore package
out of the Mac app, in preparation for an iOS target. This PR is
Mac-only — no iOS target yet, no behavior changes expected.

What moves to ScarfCore:
  - 13 leaf model files (HermesSession, HermesMessage, HermesConfig and
    its 19 nested Settings structs, HermesCronJob, HermesMCPServer,
    HermesSkill, HermesSlashCommand, HermesTool + KnownPlatforms,
    HermesPathSet, MCPServerPreset, ProjectDashboard family, ACPMessages).
  - Portable half of HermesConstants.swift (sqliteTransient, QueryDefaults,
    FileSizeUnit). The deprecated HermesPaths enum stays in main target
    as HermesPaths+Deprecated.swift since it references ServerContext.

What stays in the Mac target:
  - ServerContext.swift (moves in M0b alongside Transport — depends on
    LocalTransport/SSHTransport + HermesFileService).
  - HermesPaths+Deprecated.swift (dead forwarders, zero callers in-tree;
    kept for safety until M0b can clean them up).

Mechanics:
  - New Packages/ScarfCore/Package.swift targeting macOS 14 / iOS 18,
    Swift 6 language mode.
  - Every moved type and member marked public; explicit public memberwise
    init added to every struct (Swift's synthesized memberwise init is
    internal and would break cross-module construction).
  - Xcode project references the package via XCLocalSwiftPackageReference
    and links ScarfCore into the scarf target.
  - 49 consumer files get `import ScarfCore` added.

See scarf/docs/IOS_PORT_PLAN.md for the full multi-phase plan, locked
decisions (iOS 18, iPhone only, no APNs v1), and the M0b–M6 roadmap.

Manual verification checklist:
  - Open scarf.xcodeproj in Xcode and build the scarf scheme — should
    resolve the local package and compile with no new errors.
  - Run scarfTests — should pass (tests don't touch moved types).
  - Smoke-run the app: Dashboard, Sessions, Chat, Memory should render
    with identical data to pre-PR.

https://claude.ai/code/session_019yMRP6mwZWfzVrPTqevx2y
2026-04-23 17:10:59 +00:00
Alan Wizemann 3e0d2db4c7 fix(catalog): accept git worktrees for gh-pages check
`need_ghpages` was testing `[[ -d "$GHPAGES_DIR/.git" ]]` — "is .git
a directory?". That's true for a regular clone but FALSE for a
`git worktree add` worktree, where `.git` is a pointer file (contains
`gitdir: …/main-repo/.git/worktrees/<name>`) rather than the
directory itself. `release.sh` creates the gh-pages worktree as
part of its flow; after release the worktree persists with a
`.git` file but `catalog.sh publish` would then refuse to run
because of the dir-only check.

Switched to `-e` (exists, either file or directory). Updated the
surrounding comment so the next poor soul doesn't delete the
worktree on the script's own (wrong) advice.

Caught when publishing the v2.2.0 template catalog — error told
the user to re-create a worktree that was already there and valid.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 18:37:31 +02:00
Alan Wizemann 2b25a9da71 chore: Bump version to 2.2.0 v2.2.0 2026-04-23 18:25:18 +02:00
Alan Wizemann 5fb9620631 Merge branch 'project-sharing': v2.2.0 — templates + configuration + catalog
Brings in 22 commits delivering the full v2.2.0 scope:

- Project Templates: .scarftemplate bundle format (install, uninstall,
  export, URL router) + install preview sheet + cross-agent AGENTS.md
- Template Configuration (schemaVersion 2): typed schema with 7 field
  types, Keychain-backed secrets, Configure step in install flow,
  post-install Configuration editor, model recommendations
- Template Catalog: gh-pages site generated from templates/<author>/<name>/,
  stdlib-only Python validator mirroring Swift invariants, PR CI gate,
  install-URL hosting from raw main
- Example template: awizemann/site-status-checker (config + cron + Site
  tab webview updates)
- Site tab: webview widget in any dashboard exposes a second tab
- UX: Remove from List vs. Uninstall Template clarification, preserved-
  files banner, Run Now no longer blocks on long agent runs, markdown
  in install sheet, install-time {{PROJECT_DIR}} token substitution

Release notes at releases/v2.2.0/RELEASE_NOTES.md (94 lines).
Wiki page at https://github.com/awizemann/scarf/wiki/Project-Templates.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 18:20:07 +02:00
Alan Wizemann de5b278da4 docs: expand v2.2.0 release notes + README for full 2.2 scope
The pre-existing release notes and README "What's New in 2.2" block
only covered the original Project Templates feature. This expands
both to reflect everything that's actually shipping in 2.2:

- Template Configuration (schemaVersion 2): typed schema, 7 field
  types, Keychain-backed secrets, configure step in install flow,
  post-install Configuration editor, model recommendations.
- Template Catalog: gh-pages site with live dashboard previews,
  stdlib-only Python validator mirroring Swift invariants, PR CI
  gate, install-URL hosting from raw main.
- Example template `awizemann/site-status-checker` exercising every
  v2.2 surface — config form, cron, Site tab webview, dashboard
  updates.
- Site tab — a webview widget in any dashboard exposes a second
  tab next to Dashboard, rendering a live URL.
- UX clarifications: Remove from List (keep files) vs. Uninstall
  Template (remove installed files), preserved-files banner on
  uninstall success, Run Now no longer blocks on long agent runs.
- Install-time {{PROJECT_DIR}} / {{TEMPLATE_ID}} / {{TEMPLATE_SLUG}}
  token substitution in cron prompts.

Release-notes link + wiki link surfaced at the bottom of the README
block so readers have a jump to full details.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 18:12:37 +02:00
Alan Wizemann fb7a80f191 fix: Run Now agent-run timing + non-404 webview placeholder
Two independent fixes that both blocked the "install → Run Now → see
the Site tab render" loop.

1. CronViewModel.runNow stopped blocking on `cron tick`. Previously
   the UI waited up to 60 s on the tick before deciding whether the
   job succeeded, so any agent run that did real work (an LLM call +
   a few HTTP GETs + a file write = easily 90 s+) surfaced a false
   "Run failed" toast while the job kept running in the background.
   Dashboard updates landed minutes later, confusing the user.

   New shape: show "Agent started — dashboard will update when it
   finishes" the instant `cron run` queues the job, then call `cron
   tick` with a 300 s timeout to force execution. Tick failures are
   logged but don't overwrite the started-toast — HermesFileWatcher
   picks up the dashboard.json rewrite automatically when the agent
   finishes.

2. site-status-checker's webview widget pointed at
   `github.com/awizemann/scarf/tree/main/templates/awizemann/...`.
   The templates/ path only exists on project-sharing, not main, so
   GitHub returned 404 in the Site tab until the first cron run
   replaced the URL with the user's configured site. Switched the
   placeholder to `awizemann.github.io/scarf/` which always renders.

Bundle + catalog rebuilt against the updated dashboard.json.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 17:14:29 +02:00
Alan Wizemann 18640293f7 fix(projects): clarify remove-vs-uninstall UX
Three UX changes addressing user feedback that "Remove from Scarf" and
"Uninstall Template…" looked interchangeable, and that users were
surprised when uninstall left the project folder behind.

- Rename sidebar menu entries:
  "Uninstall Template…"  → "Uninstall Template (remove installed files)…"
  "Remove from Scarf"    → "Remove from List (keep files)…"
  The expanded labels carry the scope difference at the point of click.

- Add a confirmation dialog for Remove from List. The sidebar's "-"
  button and the context-menu entry both route through it. Dialog copy
  explicitly spells out "Nothing on disk is touched — the folder, cron
  job, skills, and memory block all stay. To actually remove installed
  files, use 'Uninstall Template…' instead." Sidebar "-" also gains a
  help tooltip saying the same thing.

- Post-uninstall preserved-files banner. When the uninstaller keeps
  the project directory (because the cron wrote a status-log.md or the
  user dropped files in there), the success view now shows an orange
  banner listing up to 8 preserved paths with a "+N more…" tail, plus
  a one-line explanation and a pointer to delete the folder from
  Finder if the user doesn't want those files. VM captures the
  preservation shape before nil'ing `plan` on success.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 17:14:29 +02:00
Alan Wizemann 19750597cd feat(site-status-checker): add Live Site Preview webview for Site tab
A Scarf project dashboard that includes at least one webview widget
automatically exposes a Site tab next to the Dashboard tab. Adding a
"Live Site Preview" section with a webview widget gives this template
that tab out of the box.

The cron job + AGENTS.md now tell the agent to rewrite the webview's
`url` field to the first entry in `values.sites` on each run, so the
Site tab renders whatever the user actually configured instead of the
GitHub placeholder. If `values.sites` is empty, the webview URL is
left untouched.

Swift example test updated to assert 4 sections (was 3) plus the new
webview widget's presence + title; bundle + catalog rebuilt.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 17:14:29 +02:00
Alan Wizemann 69e9cc6c7b fix(cron): Run now now actually runs + markdown rendering in install sheet
Two fixes chained from manually testing site-status-checker v1.1.0.

---

Cron Run now was a no-op when the Hermes gateway scheduler wasn't
already running. `hermes cron run <id>` only marks a job as due on
the next scheduler tick — it doesn't execute. During dev or right
after install (gateway stopped, as the logs the user pasted showed),
the user's click resulted in nothing happening: job queued, tick
never comes, zero agent sessions, zero output, dashboard never
updates. Exactly the failure mode they hit.

Fix: CronViewModel.runNow now calls `hermes cron run <id>` followed
by `hermes cron tick` after a short delay. `tick` runs all due jobs
once and exits — so the just-queued job actually executes, and
exits cleanly whether the scheduler is running or not. Redundant
(not duplicative) when the gateway is live. The user sees a status
message whether it succeeded or failed instead of silent nothing.

---

Markdown rendering in install-sheet screens. Template READMEs,
manifest descriptions, field help text, and cron prompts all
reasonably contain markdown — but the install preview sheet was
rendering everything as plain text, so `[Create one](https://…)`
would appear verbatim instead of as a link, `# Site Status Checker`
as a literal pound sign, etc.

New Features/Templates/Views/TemplateMarkdown.swift — a tiny,
dependency-free markdown renderer scoped to what template authors
actually write:
- Headings (#..######) → larger bold Text with vertical spacing
- Bullet and numbered lists → hanging-indent rows with •/1. prefix
- Fenced code blocks (```) → monospaced with quaternary background
- Paragraphs → regular Text, with inline formatting via SwiftUI's
  built-in AttributedString(markdown:) so **bold**, *italic*,
  `code`, and [links](urls) work
- Blank lines separate blocks

Two entry points: `TemplateMarkdown.render(_ source:)` returns a
View for multi-block content (README preview), and
`TemplateMarkdown.inlineText(_ source:)` returns a Text for
one-line strings where block structure doesn't apply (field
descriptions, manifest tagline).

Wired into:
- TemplateInstallSheet.readmeSection — was plain Text(readme), now
  renders the full README with structure.
- TemplateInstallSheet.manifestHeader description — inline-only
  (taglines rarely have block structure).
- TemplateInstallSheet.cronSection — new DisclosureGroup per cron
  job exposes the full prompt with markdown rendering. Users can
  now verify what the installer will register with Hermes before
  clicking Install. {{PROJECT_DIR}} / {{TEMPLATE_ID}} tokens show
  unresolved here; they get substituted when the installer calls
  hermes cron create.
- TemplateConfigSheet field descriptions — inline markdown so
  `[Create a token](https://...)`-style links render as real links.

Not a full CommonMark implementation — no tables, no blockquotes,
no images, no HTML passthrough. Those can evolve as templates need
them. Safe with untrusted input: never executes scripts or renders
raw HTML.

Scope stays tight: 57/57 Swift tests + 24/24 Python tests still pass.
No new tests for the markdown helper itself — rendering is visual,
hard to unit-test meaningfully without snapshot-testing infra, and
the surface is small enough that changes would be caught by the
visual regression of any template install.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 17:14:29 +02:00
Alan Wizemann 03bf5262bb feat(templates): install-time {{PROJECT_DIR}} substitution in cron prompts
Hermes doesn't set a working directory when firing cron jobs, so any
relative path in a template's cron prompt (`.scarf/config.json`,
`status-log.md`, etc.) resolves against whatever dir Hermes happens
to be in — NOT the installed project. Practical effect: site-status-
checker's cron job fires, agent runs with relative paths, finds
nothing to read, silently bails. User sees "Run now" complete with
zero output and nothing updated on disk.

Fix: the installer now substitutes template-author placeholders in
cron prompts at install time, before calling `hermes cron create`.
The registered cron job carries a fully-qualified, CWD-independent
prompt.

Supported tokens (deliberately few — each is part of the template
format contract from now on):

- `{{PROJECT_DIR}}` — absolute path of the installed project dir.
  The one that was motivating this fix; required for any cron prompt
  that reads or writes project files.
- `{{TEMPLATE_ID}}` — the `owner/name` from the manifest, for
  templates that want to tag delivery payloads or log lines.
- `{{TEMPLATE_SLUG}}` — the sanitised slug used by the installer for
  dir name + skills namespace, for templates that want to reference
  their skills install path.

Implemented as a static `ProjectTemplateInstaller.substituteCronTokens`
so it's testable as a pure function. Unsupported placeholders pass
through verbatim — template authors notice in testing that their
token didn't get replaced and either use a supported one or file
a request.

Site Status Checker v1.1.0 updated to use the tokens:
- cron/jobs.json prompt now opens with "Run the site status check
  for the Scarf project at {{PROJECT_DIR}}" and references
  {{PROJECT_DIR}}/.scarf/config.json, {{PROJECT_DIR}}/status-log.md,
  and {{PROJECT_DIR}}/.scarf/dashboard.json explicitly.
- AGENTS.md gains a note explaining that the cron-registered prompt
  carries absolute paths (installer substitutes at install time),
  while interactive-chat agents can keep using relative paths.
- bundle rebuilt, catalog regenerated.

templates/CONTRIBUTING.md documents the three supported tokens under
the cron/jobs.json bullet so future authors don't have to discover
this by hitting the same CWD bug.

Tests:
- ProjectTemplateExampleTemplateTests.siteStatusCheckerParsesAndPlans
  extended to assert the bundled prompt contains {{PROJECT_DIR}}
  UNRESOLVED. If someone accidentally bakes an absolute path into
  the template (their install dir), every user of that template
  would get the wrong path — this test catches that.
- Four new substitution tests in ProjectTemplateInstallerTests:
  resolves PROJECT_DIR / resolves ID + SLUG / leaves unknown tokens
  untouched / substitutes repeated occurrences. All go through the
  static helper directly; no install round-trip needed.

57/57 Swift tests + 24/24 Python tests pass. Catalog check clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 17:14:29 +02:00
Alan Wizemann 3af99d9d9c fix(templates): site-status-checker dashboard no longer lies before first run
The template's dashboard shipped with two hardcoded example URLs
(https://example.com + https://example.org) baked into a "Configured
Sites" list widget, and the widget title still said "from sites.txt"
— stale from the v1.0.0 layout before we moved to config.json.

After the v1.1.0 configure-on-install flow lands, the user fills in a
real sites list through the Configure form (which correctly lands in
`.scarf/config.json` — the editor modal confirms that), but the
dashboard still rendered the baked-in example URLs. The agent would
overwrite them on the first cron run, but until then the dashboard
misrepresents reality.

Two orthogonal paths to fix this — populate the dashboard's items
from config.json at install time (requires Scarf-side template-value
interpolation, which is a v2.3.1 feature), or ship a dashboard that
clearly advertises "nothing has run yet." Taking the second path for
v1.1.0: replace the example URLs with a single placeholder row with
status "pending" pointing the user at running the check. The agent
replaces the row with real data on the first cron run.

Also: widget title fixed ("Watched Sites (populated after first run)"
instead of the stale sites.txt reference), top-of-dashboard description
updated, and the Quick Start text now mentions the Configuration
button as the way to set sites, not the long-gone sites.txt.

Bundle + catalog rebuilt; ProjectTemplateExampleTemplateTests still
passes (it asserts against cron prompt + schema shape, not dashboard
content, so the dashboard edit doesn't affect it).

---

Secondary fix: test deflake from the saveRegistry throw change.

Making saveRegistry throw exposed a pre-existing parallel-test race:
three suites (ProjectTemplateInstallerTests,
ProjectTemplateUninstallerTests, ProjectTemplateConfigInstallTests)
all write to the real `~/.hermes/scarf/projects.json`. Swift Testing's
`.serialized` trait only serializes within a single suite — multiple
suites still run in parallel. Before, writes silently failed on the
racing-loser side and tests passed by accident; now the loser's test
throws "couldn't be saved in the folder 'scarf'".

Added TestRegistryLock — a module-level NSLock that all three suites'
snapshotRegistry/restoreRegistry helpers share. acquireAndSnapshot()
locks + reads; restore(_:) writes + unlocks. The paired
snapshot-in-test-body / defer-restore pattern keeps acquire + release
balanced. Replaced the three per-suite copies of the helpers with
thin delegates to the shared lock.

Verified by running the full test suite 3 consecutive times: 53/53
tests pass each run, no flakes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 17:14:29 +02:00
Alan Wizemann 3bd95de8f4 fix(config): install sheet silently closed after Continue in config step
Two bugs chained into the observed "install completed but project
didn't show up" report. Either one would have been enough on its own;
both are here so both are fixed.

Primary bug: TemplateConfigSheet's Cancel + Continue buttons each
called `@Environment(\.dismiss)` after their state-update callbacks.
That was fine when the sheet is presented standalone (the post-install
Configuration button uses it this way and wants dismissal), but Phase C
also INLINED the same view inside TemplateInstallSheet.configureView
for the install flow's .awaitingConfig stage — there's no intermediate
.sheet() presenter there, so `dismiss()` resolved to the OUTER install
sheet. Clicking Continue → configure form's `onCommit` fired
`installerViewModel.submitConfig(values:)` which advanced stage to
.planned, then the dismiss() closed the whole install sheet before
the preview ever rendered. install() was never called.

Fix: remove both dismiss() calls from TemplateConfigSheet. Dismissal
is now the caller's responsibility. ConfigEditorSheet (standalone
mode) already calls `dismiss()` inside its own onCancel closure and
lets the .succeeded state's Done button handle commit-dismissal, so
nothing breaks there. The install flow's state machine advances to
the preview stage where the existing Install/Cancel buttons drive
everything from there.

Secondary bug (latent, same class): ProjectDashboardService.saveRegistry
swallowed both directory-creation and file-write errors with `try?`.
If the `~/.hermes/scarf/` dir creation or projects.json write ever
failed for any reason (permissions, readonly filesystem, sandbox),
the installer's registerProject returned a valid-looking ProjectEntry
while the registry on disk never received the row. Same symptom
surface as the primary bug: install "succeeds," project invisible.

Fix: saveRegistry now throws. Updated all four callers:
- ProjectTemplateInstaller.registerProject: `try` — a registry
  write failure aborts install with a user-visible failure screen.
  This is the critical path; silent success on a destructive op is
  the exact failure mode we want to eliminate.
- ProjectTemplateUninstaller: `do/catch` + logger.warning — we're at
  the final step of uninstall after every other side effect has
  already completed (files removed, skills removed, cron removed,
  memory stripped, Keychain cleared). Leaving a stale registry row
  pointing at a deleted project is cosmetic and easy to fix from
  the sidebar minus button.
- ProjectsViewModel.addProject + removeProject: `do/catch` +
  logger.error. The VM doesn't currently have a surface for
  user-visible errors (no toast/alert on this view), but the
  failure now at least lands in the unified log instead of
  disappearing. Proper in-UI error surface is tracked as follow-up.
- ProjectDashboardService.loadRegistry: switched its stale `print`
  to `logger.error` while I was in the file.

Tests: added TemplateInstallerViewModelTests suite (3 tests) covering
the install VM's configure-step state transitions:
- submitConfigStashesValuesAndTransitionsToPlanned — .awaitingConfig
  → .planned + configValues stash on the plan. The exact transition
  that the dismiss() bug tore down mid-flight.
- cancelConfigReturnsToAwaitingParentDirectory — back-button behaviour
  with plan preserved so re-entry doesn't re-run buildPlan.
- submitConfigNoOpWhenPlanIsNil — defensive guard.

These won't catch a view-level regression (Swift Testing doesn't do
UI tests in this project), but they lock in the VM state-machine
contract so the next refactor can't silently break submitConfig or
cancelConfig without failing CI.

53/53 Swift tests + 24/24 Python tests + catalog validator clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 17:14:29 +02:00
Alan Wizemann 81e8da91d6 feat(templates): upgrade site-status-checker to v1.1.0 with config schema
First real exercise of the v2.3 configuration feature. The template no
longer asks the agent to bootstrap sites.txt on first run — instead,
users enter their list of URLs through the Configure form during
install, and change them later via the dashboard's Configuration
button. This makes the template a complete round-trip test of the
new feature end-to-end.

Schema (manifest.config.schema):
- `sites` — list<string>, required, 1–25 items, default two example
  URLs. This is the list the cron job hits.
- `timeout_seconds` — number, 1–60, default 10. Per-URL HTTP timeout.
- `modelRecommendation.preferred = claude-haiku-4` — rationale: simple
  tool-use task, Haiku is cost-effective for daily cron.

Manifest bumped: schemaVersion 1 → 2, version 1.0.0 → 1.1.0,
minScarfVersion 2.2.0 → 2.3.0, contents.config = 2.

AGENTS.md rewritten for the config-driven flow:
- Reads values from `.scarf/config.json` at run time (values.sites +
  values.timeout_seconds). No more sites.txt bootstrap.
- "Add a site" / "Remove a site" no longer mean the agent edits a
  file — they mean "open the Configuration button on the dashboard."
  The agent points the user there rather than trying to mutate
  config.json itself. A future Scarf release may expose a tool for
  agents to write config programmatically; until then, config is
  strictly a user action.
- First-run bootstrap now only creates status-log.md (if absent).

README.md rewritten to walk users through the new form-based flow,
explain the Configuration button, and document the model
recommendation. Uninstall instructions point at the right-click
Uninstall Template action rather than manual steps.

Cron prompt updated to reference config.json (values.sites,
values.timeout_seconds) instead of sites.txt.

ProjectTemplateExampleTemplateTests.siteStatusCheckerParsesAndPlans
extended with v2-specific assertions: manifest.schemaVersion == 2,
contents.config == 2, schema.fields.count == 2, per-field
constraints (sites type/itemType/minItems/maxItems, timeout
min/max), modelRecommendation.preferred, plan.configSchema +
plan.manifestCachePath are populated, plan.projectFiles includes
both config.json + manifest.json destinations. Cron-prompt assertion
swapped from sites.txt to config.json/values.sites.

Three suites that touch ~/.hermes/scarf/projects.json now carry
.serialized — the new Phase B install-with-config tests stressed the
parallel-execution race in the snapshot/restore helpers. Serializing
within each suite deflakes without any architectural change.

Swift 50/50, Python 24/24, catalog validator accepts the upgraded
bundle. Site detail page now has manifest.json for renderConfigSchema
to pick up.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 17:14:29 +02:00
Alan Wizemann bb750e237e docs: CLAUDE.md — add Template Configuration section
Documents the v2.3 configuration feature for future agent sessions:
manifest schemaVersion 2 shape, supported field types, Keychain storage
conventions (service/account naming with project-path hash suffix), the
uninstaller's config-items cleanup path, exporter behaviour (schema
forwarded, values stripped), and the catalog site's schema display.

Includes the "Schema is Swift-primary" note so future edits to
TemplateConfigField.FieldType go through the right order of updates —
Swift first, then Python mirror, then widgets.js, then UI controls,
then tests on both sides. Schema drift between Swift + Python
validator would accept bundles the app later refuses at install
time, which is a catastrophic UX failure for the catalog.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 17:14:29 +02:00
Alan Wizemann 68f6b98fcf feat(catalog-config): mirror manifest v2 schema in validator + site
Phase D of v2.3 template configuration — closes the loop between the
Swift app and the catalog pipeline. Authors can now ship schemaful
bundles; the Python validator enforces the same invariants the Swift
installer does; the catalog site displays the schema so visitors see
what they'll need to configure before installing.

Python validator (tools/build-catalog.py):
- SUPPORTED_SCHEMA_VERSIONS accepts both 1 and 2 (v1 bundles are
  unchanged; v2 adds optional manifest.config).
- New _validate_config_schema function mirrors the Swift
  ProjectConfigService.validateSchema rules: unique keys, supported
  types, enum option presence + unique values, list itemType ==
  "string", secret-field cannot declare a default,
  modelRecommendation.preferred non-empty when present.
- _validate_contents_claim cross-checks contents.config (field count)
  against config.schema actual length — mismatch refused.
- TemplateRecord.to_catalog_entry exposes `config` in catalog.json so
  the site can render the schema.
- render_site copies each bundle's template.json to the detail dir as
  manifest.json (only when the manifest has a config block — keeps
  the served tree lean and makes "no manifest.json" a meaningful
  404 signal in the frontend).
- catalog.json's own schemaVersion stays at 1 (independent of per-
  template manifest schemaVersion).

Python tests (tools/test_build_catalog.py): 8 new cases in a new
ConfigSchemaValidationTests suite — accepts schemaful bundle, rejects
duplicate keys, rejects secret-with-default, rejects enum-without-
options, rejects unsupported field type, rejects contents.config
count mismatch, rejects unsupported list itemType, legacy v1
manifests pass unchanged. 24/24 Python tests total.

Site (site/widgets.js):
- New renderConfigSchema(container, config) — mirrors the display
  on the Scarf install preview. Renders each field as a <dt>/<dd>
  pair with type + required badges; enum shows choice labels; list
  fields show min/max bounds; string fields show pattern/length;
  secret fields get a "Stored in Keychain" reassurance. Optional
  modelRecommendation panel at the bottom with preferred + rationale
  + alternatives.
- The renderer is display-only — the site never collects values;
  that's the Scarf app's job.

template.html.tmpl adds a #config-schema <section>. The inline script
fetches manifest.json from the detail dir; on success hands the
config block to ScarfWidgets.renderConfigSchema; on 404 (schema-less
templates) silently leaves the section empty. CSS in styles.css
adds a config-schema panel matching the accent-green aesthetic.

24/24 Python + 50/50 Swift tests pass. site-status-checker still
renders correctly (schema-less; manifest.json isn't copied for it).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 17:14:29 +02:00
Alan Wizemann f8c086ee7a feat(config): configure-step UI + post-install Configuration editor
Adds the user-facing side of v2.3 template configuration. Install-time
flow: templates with a non-empty config.schema get a Configure step
between the parent-directory pick and the preview sheet. Post-install
flow: a Configuration button on the dashboard header + a context-menu
entry on the project list opens the same form pre-filled with current
values.

New files:
- Features/Templates/ViewModels/TemplateConfigViewModel.swift — drives
  the form. Keeps freshly-entered secret bytes in `pendingSecrets`
  in-memory until commit() succeeds, then calls
  ProjectConfigService.storeSecret for each one. Cancelling never
  leaves orphan Keychain entries — the form is transactional.
  Validates via ProjectConfigService.validateValues on commit and
  populates per-field `errors` the sheet surfaces inline. Two modes:
  .install (needs a project passed at commit time) and
  .edit(project:) (VM already holds the target).
- Features/Templates/Views/TemplateConfigSheet.swift — the form. One
  row per field with a control dispatched by type: TextField (string),
  TextEditor (text), number input, Toggle (bool), segmented/dropdown
  Picker (enum, picks form by option count), add/remove list editor,
  SecureField with show/hide toggle (secret). Required-field asterisk
  + per-field error display. Optional modelRecommendation panel at
  the bottom — informational badge; no auto-switch.
- Features/Templates/ViewModels/TemplateConfigEditorViewModel.swift —
  loads <project>/.scarf/manifest.json + config.json, hands a
  TemplateConfigViewModel to the sheet, writes edited values back on
  commit. Has a .notConfigurable stage for projects without a
  manifest cache (hand-added projects, schema-less templates).
- Features/Templates/Views/ConfigEditorSheet.swift — thin wrapper
  that owns the editor VM and routes its stages to loading / form /
  saving / success / error / not-configurable views.

Wiring:
- TemplateInstallerViewModel gains an .awaitingConfig stage between
  .awaitingParentDirectory and .planned. pickParentDirectory() now
  inspects plan.configSchema and either routes to .awaitingConfig
  (non-empty schema) or straight to .planned (schema-less). New
  submitConfig(values:) stashes finalized values in plan.configValues
  and advances; cancelConfig() returns to .awaitingParentDirectory.
- TemplateInstallSheet renders a new `configureView` that inlines
  TemplateConfigSheet into the install flow for .awaitingConfig.
  The existing preview (.planned) gains a new "Configuration" section
  listing each field + its display value (secrets shown as "••••••
  (Keychain)", lists shown as "first + N more", "(not set)" for
  missing values).
- ProjectsView adds an isConfigurable(_:) check (transport.fileExists
  on .scarf/manifest.json), a new @State configEditorProject for
  sheet presentation, a new "Configuration…" context-menu entry on
  project list rows (for configurable projects), and a new
  slider.horizontal.3 button on the dashboard header next to the
  existing Uninstall button.

50/50 tests still pass. This commit is UI-only — no new Phase C tests
(sheet behaviour is hard to unit-test without UI automation and the
underlying VM logic is exercised by Phase A/B's config-round-trip
tests).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 17:14:29 +02:00
Alan Wizemann eb34aec1f1 feat(config): template-config UI forms (configure sheet + editor)
Introduces the TemplateConfigSheet form and its view models, plus
the install-flow integration points: a new .awaitingConfig stage in
TemplateInstallerViewModel, the configureView step in the install
sheet, and the dashboard-header Configuration button wiring in
ProjectsView. This is the schemaful-template v2.3 UI that every
subsequent config commit builds on.

Originally landed alongside scaffolding for an iOS target in b289a83;
this is the split that keeps the template-config work and drops the
iOS scaffolding (the real iOS port is on scarf-mobile-development).
2026-04-23 17:14:22 +02:00
Alan Wizemann 97e9beea5f refactor(settings): remove unused providers list
The hardcoded `providers` array in SettingsViewModel was never referenced — no view reads `viewModel.providers`; the Model picker uses the models.dev catalog via `ModelCatalogService.loadProviders()` and Provider is shown as a `ReadOnlyRow` in the General tab. Leaving the dead list around makes issues like #33 look plausible (users reasonably guess a stale enum is normalising `openai-codex` → `openai` on save, which the code does not actually do).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 02:37:50 +02:00
Alan Wizemann 7a99547b22 fix: address code-review findings from Apr 22 commits
Three follow-ups from reviewing 1989fee (sidebar-width persist) and
4163595 (default server on launch):

- `SplitViewAutosaveFinder` hardcoded `"ScarfMainSidebar"` for every
  window. Since Scarf's `WindowGroup` spawns one window per `ServerID`,
  all windows shared the same `NSSplitView.autosaveName` — AppKit
  documents that name as required-unique, and in practice per-window
  widths collapsed onto a single UserDefaults key. Thread the window's
  `ServerContext` in through `@Environment(\.serverContext)` (already
  wired at `WindowGroup` construction) and suffix the name with the
  server UUID.
- `setDefaultServer` fired `onEntriesChanged`, whose sole consumer is
  `ServerLiveStatusRegistry.rebuild()` for menu-bar fanout. Flipping a
  default flag doesn't change the set of servers; the callback was
  semantic noise. Drop the call — SwiftUI views still redraw on the
  flag flip via `@Observable`'s tracking of `entries`.
- The filled-yellow star in `ManageServersView` had a no-op action
  inside `if !isDefault { ... }` but still animated its pressed state
  on click. Replace the conditional with `.disabled(isDefault)` so the
  row is visually inert when it already is the default.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 02:37:50 +02:00
Alan Wizemann 64b7d3beaf feat(config): manifest schemaVersion 2 + installer/uninstaller/exporter wiring
Extends the template format to schemaVersion 2 (schema-less bundles at
v1 keep working unchanged) and threads TemplateConfigSchema through
inspect → buildPlan → install → uninstall → export end-to-end.

Model additions (ProjectTemplate.swift):
- ProjectTemplateManifest gains optional `config: TemplateConfigSchema?`.
- TemplateContents gains optional `config: Int?` claim (field count)
  cross-checked against the schema by `verifyClaims` so a manifest
  can't hide its configuration from the preview sheet.
- TemplateInstallPlan gains `configSchema`, `configValues` (populated
  by the VM just before install()), and `manifestCachePath`. New
  fields also feed totalWriteCount so the preview footer is honest.
- TemplateLock gains optional `configKeychainItems: [String]?` and
  `configFields: [String]?`. Optional so pre-2.3 lock files still
  uninstall cleanly — Codable's default decoding skips missing fields.

Service changes:
- ProjectTemplateService.inspect now accepts schemaVersion 1 or 2.
  When the manifest declares a config block, the service validates it
  immediately via ProjectConfigService.validateSchema and fails the
  install with a manifestParseFailed before the preview sheet ever
  renders. verifyClaims cross-checks contents.config count against
  the actual schema length.
- ProjectTemplateService.buildPlan populates configSchema and queues
  two new entries in projectFiles: .scarf/config.json (synthesized by
  the installer from configValues at write time, using an empty
  sourceRelativePath sentinel) and .scarf/manifest.json (copy of the
  bundle's template.json so the post-install Configuration editor can
  render offline).
- ProjectTemplateInstaller.createProjectFiles now special-cases the
  empty-source sentinel: for .scarf/config.json, it encodes
  plan.configValues into a ProjectConfigFile on the fly. Secrets in
  that file are keychain:// refs — the raw bytes were routed into the
  Keychain by the VM before install() was called.
- ProjectTemplateInstaller.writeLockFile records every keychainRef
  URI from configValues in lock.configKeychainItems and the schema
  field keys in lock.configFields.
- ProjectTemplateUninstaller.uninstall adds a new step 4a: iterate
  lock.configKeychainItems, parse each URI into a TemplateKeychainRef,
  SecItemDelete each one. Absent items are no-ops (the Keychain
  wrapper already handles errSecItemNotFound silently).
- ProjectTemplateExporter now reads the source project's
  .scarf/manifest.json (if present) and forwards the SCHEMA through
  to the exported bundle while zeroing values. schemaVersion bumps to
  2 only when a schema is carried; schema-less exports stay at 1 for
  byte-compatibility with v2.2 catalog validators.

Tests (ProjectTemplateTests.swift): 5 new tests in 1 new suite.
- inspectAcceptsSchemaV2Bundle: v2 manifest unpacks cleanly.
- buildPlanSurfacesSchemaAndQueuesConfigFiles: plan carries the
  schema; projectFiles contains both config.json + manifest.json.
- verifyClaimsRejectsConfigCountMismatch: a manifest lying about
  contents.config vs. schema.fields.count is refused at inspect.
- installWritesConfigJsonAndManifestCache: install round-trip writes
  config.json (with non-secret values inline + secret as keychainRef),
  manifest.json cache, and lock with configKeychainItems +
  configFields. Real Keychain is exercised; the test cleans up the
  single item it creates.
- uninstallDeletesKeychainItemsViaLock: install + then uninstall,
  verify the Keychain entry is gone via SecItemCopyMatching.

sampleManifest test helper gains `configFieldCount` and `configSchema`
params so tests that want schemaful bundles don't need to rebuild the
whole manifest record. schemaVersion auto-bumps to 2 when a schema is
present so the fixture mirrors real bundle shape.

50/50 tests in 13 suites pass; pre-existing 45 from v2.2 unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 01:27:26 +02:00
Alan Wizemann 385c3a2e4d feat(config): template-config models + Keychain wrapper + ProjectConfigService
Groundwork for v2.3 template configuration. No user-visible behaviour
yet — this commit adds the data structures, storage layer, and
validation rules that the installer/uninstaller/UI will integrate with
in the next two commits.

Models (Core/Models/TemplateConfig.swift):
- TemplateConfigSchema + TemplateConfigField for the author-declared
  manifest.config block. 7 field types: string, text, number, bool,
  enum, list, secret. Type-specific constraints (pattern, min/max,
  min/maxLength, min/maxItems, enum options) are all optional and
  the validator enforces only those applicable to the field's type.
- TemplateModelRecommendation for the author's model-of-choice hint
  (preferred + rationale + alternatives). Purely advisory — Scarf
  never auto-switches the active model.
- TemplateConfigValue enum: string / number / bool / list / keychainRef.
  Custom Codable preserves keychain:// refs on round-trip — a round
  through save/load never demotes a secret ref to plaintext.
- ProjectConfigFile is the on-disk shape at <project>/.scarf/config.json.
- TemplateKeychainRef: derives (service, account) from templateSlug +
  fieldKey + project-path hash. The 32-bit FNV-1a suffix prevents two
  installs of the same template in different dirs from colliding in
  the login Keychain. uri <-> parse round-trips losslessly.

Keychain layer (Core/Services/ProjectConfigKeychain.swift):
- Thin wrapper over kSecClassGenericPassword. set() tries update-first
  then add-if-missing so we don't trip "already exists" on a race.
- kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly: no iCloud sync,
  but cron triggers can still read after the user's first unlock.
- testServiceSuffix lets unit tests route items under a distinct
  service prefix so nothing leaks into the user's real Keychain.

Service layer (Core/Services/ProjectConfigService.swift):
- load/save for <project>/.scarf/config.json through the ServerContext
  transport (so remote-ready for when installer goes remote).
- cacheManifest/loadCachedManifest: the installer copies template.json
  into <project>/.scarf/manifest.json so the post-install "Configuration"
  button can render the form offline.
- resolveSecret / storeSecret / deleteSecrets: the three Keychain paths
  any caller needs. Non-secret values never pass through these.
- validateSchema: author-facing invariants (unique keys, known types,
  enum opts present/unique, no defaults on secrets, non-empty model
  preferred). Called by ProjectTemplateService during inspect.
- validateValues: user-facing invariants (required, pattern, numeric
  range, list bounds, enum membership). Returns one error per problem
  so the UI can surface them inline with the offending field.

Tests (scarfTests/TemplateConfigTests.swift): 23 tests in 5 suites.
- Schema validation: happy path + every rejection rule.
- Value validation: required, pattern, numeric range, list bounds,
  enum membership, secret-via-keychain-ref acceptance.
- Keychain ref: uri round-trip, parse rejection of malformed input,
  path-hash differs across project dirs but is stable for same path.
- ProjectConfigFile round-trips non-secret values cleanly AND preserves
  keychain:// refs (the bug that would silently demote secrets to
  plaintext if the Codable were wrong).
- Real Keychain integration: store+resolve+delete, set overwrites,
  delete of missing item is a no-op, bulk delete clears all. Tests
  use unique testServiceSuffix per run so no cross-contamination.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 00:56:34 +02:00
Alan Wizemann e76fbf9937 chore: audit follow-ups from plan review
Four small fixes surfaced by a side-by-side plan-vs-shipped pass:

- README.md: adds the Template Catalog section the plan called out —
  links to the live site URL, the install flows (web / file / Finder),
  and templates/CONTRIBUTING.md for authors. Placed right before the
  existing Contributing section, with a catalog-specific cross-link at
  the end of that section too.
- CLAUDE.md: adds the Template Catalog section so future agent sessions
  know the regenerator pipeline exists, how it relates to release.sh +
  wiki.sh, and what the schema-sync rule is when DashboardWidget or
  ProjectTemplateManifest change.
- scarf/scarfTests/ProjectTemplateTests.swift: fixes the stale
  ProjectTemplateExampleTemplateTests docstring still referencing
  `examples/templates/` (the example moved to `templates/awizemann/`
  in 70f7cea).
- .github/workflows/validate-template-pr.yml: untangles the self-
  contradictory Python-version comment. The validator is 3.9+
  compatible; CI uses 3.11 for faster runner caching. Same stdlib
  surface, same code paths — just clearer about why.

All tests still green: 22 Swift tests in 7 suites, 16 Python tests,
catalog check passes on the site-status-checker example.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 00:35:46 +02:00
Alan Wizemann c9b8da9ec5 feat(ci): validate template submissions on PR + tailored checklist
Adds the CI gate that runs on every PR touching templates/, the catalog
validator, or its tests. The Action:
- runs tools/test_build_catalog.py (catches drift between validator +
  its own test suite on the same PR that introduces the drift)
- runs tools/build-catalog.py --check (validates every shipped .scarftemplate
  against the same invariants ProjectTemplateService.verifyClaims enforces
  at install time)
- posts a PR comment with the last 3 KB of the validator log on failure,
  so contributors see the specific mismatch without hunting through the
  Actions UI

.github/PULL_REQUEST_TEMPLATE/template-submission.md is the author-facing
checklist that mirrors templates/CONTRIBUTING.md. Opt-in via the
?template=template-submission.md compare URL (documented in the
contribution guide). CONTRIBUTING.md now links both the PR template and
the workflow file so authors know what to expect.

Phase 4 closes the community loop — from this commit on, a stranger can
fork the repo, follow templates/CONTRIBUTING.md, push a PR, and get
deterministic green/red feedback before a maintainer ever looks at it.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 00:35:46 +02:00
Alan Wizemann 6175bee27d feat(site): dogfood the Scarf dashboard format as the catalog website
Adds site/ with vanilla HTML + CSS + ~300 lines of JavaScript that
renders ProjectDashboard JSON directly in the browser. Each template's
detail page shows a live preview of the exact dashboard the user will
get post-install — the catalog IS the dogfood.

site/widgets.js mirrors the Swift widget dispatcher:
- stat (big number + colored icon + optional subtitle)
- progress (0..1 bar)
- text with inline markdown subset (headings, bold/italic, inline code,
  code fences, bullet + numbered lists, links)
- table (plain HTML)
- list (with up/down/unknown status badges)
- chart (SVG line + bar — no Chart.js dependency)
- webview (sandboxed iframe)
- unknown (placeholder so the page doesn't silently omit widgets)

Plus the renderMarkdown helper used by the template detail page to
display the bundle's README.

site/index.html.tmpl + site/template.html.tmpl are substitution-only —
the Python regenerator swaps {{CARDS}}, {{COUNT}}, {{COUNT_PLURAL}},
{{NAME}}, {{DESC}}, {{VERSION}}, {{AUTHOR_HTML}}, {{TAGS_HTML}},
{{INSTALL_URL_ENCODED}}, {{SCARF_INSTALL_URL}}. The detail page fetches
dashboard.json + README.md at page load and hands them to widgets.js.
No client-side framework, no bundler, no npm.

site/styles.css: minimal CSS with scarf green accent, prefers-color-
scheme dark support, responsive at 680px. One file, ~280 lines.

build-catalog.py extended to copy dashboard.json + README.md out of each
bundle into its detail dir so widgets.js can fetch them without
reaching across directories (and so gh-pages doesn't need to serve zip
contents at request time).

Two new Python tests: end-to-end site rendering (both cards, install
URL wiring, static asset copy, per-template dashboard + README copy)
and the {{COUNT_PLURAL}} singular-vs-plural flip. 16/16 Python tests
green.

Smoke-tested locally with python3 -m http.server: every endpoint
(index, catalog.json, detail HTML, per-template dashboard.json + README,
widgets.js) returns 200. The .gh-pages-worktree/appcast.xml +
.gh-pages-worktree/index.html are untouched — the catalog is purely
additive under /templates/.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 00:35:46 +02:00
Alan Wizemann 11732baa3c feat(catalog): stdlib-only Python validator + regenerator for templates/
Adds the catalog pipeline without introducing any external dependencies.
tools/build-catalog.py walks templates/<author>/<name>/, validates every
shipped .scarftemplate against its manifest (same invariants Swift's
ProjectTemplateService.verifyClaims enforces at install time), and emits
templates/catalog.json for the frontend to read.

Validator invariants:
- Required bundle files: template.json, README.md, AGENTS.md, dashboard.json
- contents claim cross-checked against actual zip entries (instructions,
  skills, cron count, memory appendix)
- dashboard.json widget types restricted to the vocabulary the Swift
  renderer knows
- Manifest id author component must match the template directory
- 5 MB bundle-size cap on submissions (installer's own cap is 50 MB)
- High-confidence secret patterns (private keys, GitHub PATs, Slack tokens,
  AWS access keys, OpenAI/Anthropic keys) block the bundle
- staging/ source tree must match the built bundle byte-for-byte — catches
  the common failure mode of editing staging/ but forgetting to rebuild

scripts/catalog.sh wraps the Python script with check/build/preview/serve/
publish subcommands, mirroring the scripts/wiki.sh shape. publish adds a
second-pass hard-pattern secret scan on the rendered gh-pages output so
template prose can't leak credentials even if the Python scan missed them.

tools/test_build_catalog.py has 14 unit tests covering the main validator
paths (minimal-valid, missing-AGENTS, content-claim mismatch, author
mismatch, oversized bundle, unknown widget type, secret detection,
staging-drift detection, missing bundle, catalog.json shape, and a real-
bundle end-to-end check against templates/awizemann/site-status-checker).
Python 3.9 compatible (Xcode's bundled python3), so no runtime needs
installing.

templates/catalog.json committed as the first generated aggregate index;
maintainers regenerate on merge by running `./scripts/catalog.sh build`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 00:35:46 +02:00