Skip to content

release: TermQ v0.10.0#306

Merged
eyelock merged 42 commits into
mainfrom
release/v0.10.0
May 11, 2026
Merged

release: TermQ v0.10.0#306
eyelock merged 42 commits into
mainfrom
release/v0.10.0

Conversation

@eyelock
Copy link
Copy Markdown
Owner

@eyelock eyelock commented May 11, 2026

Promotes release/v0.10.0 to main for stable release.

eyelock and others added 30 commits April 28, 2026 06:51
chore: sync main into develop post v0.9.0
Three bugs prevented v0.9.0 from appearing in appcast.xml:

1. Race condition: the `release: published` and explicit `workflow_dispatch`
   triggers both fired within 2 seconds of the release being published, before
   the GitHub API had indexed the new release. The appcast workflow got the
   pre-release cached list of exactly 30 items and silently skipped v0.9.0
   (no zip asset visible yet → jq returned empty → no warning logged → diff
   showed no changes → no PR created).

2. Pagination: generate-appcast.sh fetched a single page with GitHub's default
   of 30 releases. With 50+ releases in the repo, any stable release beyond
   page 1 would be invisible to the generator.

Fixes:
- Replace `release: published` + explicit `workflow_dispatch` trigger with
  `workflow_run` on Release workflow completion. The Release workflow takes
  30-60 minutes to build/sign/notarize; by the time it completes the API
  has long indexed the new release.
- Add job-level guard to skip appcast update when the Release workflow failed.
- Implement page-looping in fetch_releases() (per_page=100&page=N until empty)
  so all releases are fetched regardless of count.
- Add GH_TOKEN auth header to bypass the API response cache.
- Fix stale github.event.release.tag_name reference (removed with the
  release: published trigger) → github.event.workflow_run.head_branch.

Co-authored-by: David Collie <support@eyelock.net>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
… record (#234)

Harnesses that exist on disk but were never registered via `ynh install`
caused `ynh uninstall` to fail with "harness not installed". The fix:

- `uninstallHarness(name:)` now detects `installedFrom == nil` and
  deletes the harness directory via FileManager directly, bypassing the
  ynh terminal entirely, then cleans associations and refreshes the list
- Removed a redundant `FileManager.removeItem` from
  `performDeleteLocalHarness` in the sidebar (now owned by
  `uninstallHarness`)
- Uninstall confirmation dialogs now show context-aware messages for all
  three harness provenance states (untracked / ynh-local / registry+git),
  with the selection logic extracted into a single
  `Strings.Harnesses.uninstallBaseMessage(for:)` function used by both
  HarnessDetailView and HarnessesSidebarTab

Co-authored-by: David Collie <support@eyelock.net>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
* docs(skills): harden hotfix procedure post v0.9.1 learnings

- Merge PR to main before tagging (tag must point to merge commit on main)
- Skill/docs updates belong on the hotfix branch for clean cherry-picks
- Consolidate forward-port into a single PR to reduce public PR noise
- Add "NEVER open multiple forward-port PRs" to What NOT to Do

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* chore: Update appcast for release v0.9.1

Auto-generated appcast files for Sparkle auto-updates.

Co-Authored-By: github-actions[bot] <github-actions[bot]@users.noreply.github.com>

---------

Co-authored-by: David Collie <support@eyelock.net>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
#239)

SwiftUI registers its own kAEGetURL handler during scene initialisation,
which runs after App.init — overriding the registration we placed there.
This caused SwiftUI's AppWindowsController.activateWindowForExternalEvent
to close the main window on every URL open.

Moving the NSAppleEventManager registration to applicationDidFinishLaunching
ensures TermQ's handler is set last and wins, so SwiftUI never sees the URL
Apple Event and cannot hide the window.

Co-authored-by: David Collie <support@eyelock.net>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
…ng (#240)

Cmd+clicking a path in the terminal produced the macOS "-50" dialog because
SwiftTerm's default requestOpenLink implementation calls URL(string:) on bare
paths, which produces a schemeless URL that LaunchServices rejects.

Root cause: LocalProcessTerminalView satisfies requestOpenLink via a protocol
extension default baked into the SwiftTerm binary. Subclass overrides in our
module land in a separate vtable slot that the inherited witness table never
consults. Per SwiftTerm's own docs, the fix is to replace terminalDelegate
with a proxy and forward all values.

- Add TermQLinkDelegate: full-proxy TerminalViewDelegate installed in
  TermQTerminalView.init; intercepts requestOpenLink, forwards everything
  else to LocalProcessTerminalView's concrete implementations
- Add TerminalLinkResolver: pure resolution of a link string into
  .openURL / .openFile / .revealInFinder / .fallbackString / .noop
- Add TermQTerminalLink.open: single entry point for all link clicks;
  pre-flight checks for registered handler, surfaces friendly alert
  instead of -50 dialog, opens directories directly in Finder
- Add TerminalLinkRoutingTests: static guardrail that scans all
  requestOpenLink definitions and asserts each routes through
  TermQTerminalLink.open
- Add TerminalLinkResolverTests: 20 unit tests for sanitize + resolve
- Wire ControlModePaneDelegate.requestOpenLink through the same entry point
- Localize two new alert strings (no-handler, launch-failed) into 40 languages

Co-authored-by: David Collie <support@eyelock.net>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
* chore: update CHANGELOG for v0.9.2

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* chore: Update appcast for release v0.9.2

Auto-generated appcast files for Sparkle auto-updates.

Co-Authored-By: github-actions[bot] <github-actions[bot]@users.noreply.github.com>

---------

Co-authored-by: David Collie <support@eyelock.net>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
* fix(concurrency): add @sendable to system-dispatched closures to prevent MainActor isolation crash

TerminalLinkResolver and TmuxControlModeSession both had closures passed to
system APIs (LaunchServices completion handler and FileHandle.readabilityHandler)
that were inheriting @mainactor isolation from their enclosing context. When
called on a background queue by the system this triggers EXC_BREAKPOINT via
_swift_task_checkIsolatedSwift. Mark both closures @sendable to opt them out
of actor isolation inheritance.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* chore: update CHANGELOG for v0.9.3

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* chore: Update appcast for release v0.9.3

Auto-generated appcast files for Sparkle auto-updates.

Co-Authored-By: github-actions[bot] <github-actions[bot]@users.noreply.github.com>

---------

Co-authored-by: David Collie <support@eyelock.net>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
…ft detection (#249)

* feat(harness): Phase 1 management — foundation, detail pane, fork & duplicate, drift detection

Scope:

CommandRunner foundation. Streamed-output shell-out with cancellation;
migrate ynh callers off ad-hoc Process/Pipe to a single seam.

YNH 0.3+ envelope decoding. HarnessListResponse / HarnessInfoResponse
parse the structured-output envelope with capabilities and ynh_version;
YnhVersionProbe gates Phase 1 affordances on capabilities ≥ 0.3.0.

Detail pane refactor. Extract HarnessDetailViewModel from the view; pure
formatters for source classification (HarnessSourceBadgeViewModel),
editability (HarnessEditabilityResolver), and update signal
(HarnessUpdateBadgeStore). Old feature-flagged badge retired.

Source badges and read-only model. Sidebar rows and detail header show
provenance: registry name, short Git URL, "Local", or "Forked from <X>".
Registry harnesses display a Read-only pill — surfaces that direct edits
will be overwritten by the next ynh update.

Update detection. UpdateAvailabilityService seam +
LiveUpdateAvailabilityService impl wraps ynh ls --check-updates and ynh
info --check-updates. Three-state HarnessUpdateSignal classifies as
versioned (manifest version bumped — orange dot, info banner),
unversionedDrift (content drift without a version bump — amber warning
triangle, warning banner, confirmation step listing each drifted include
SHA), or none. Sidebar dots, header global-probe spinner, dependency-view
warning triangle render the signal.

Fork to local (single-call). ForkHarnessSheet runs ynh fork --to <path>
against the pointer-model YNH; one editable working tree, no copy under
~/.ynh/harnesses/. installed_from.forked_from preserved; ghost origin
shown in detail pane.

Duplicate (single-call with --name). DuplicateHarnessSheet runs ynh fork
--to <path> --name <newname> for local-only renamed copies. Hidden for
registry harnesses (Fork covers that intent).

Action menu parity. Sidebar context menu and detail action menu share a
canonical five-group layout (Run, Location, Actions, Help, Destructive).
"Open in…" submenu, Open in browser (URL sources), Reveal in Terminal,
Copy as Pathname now consistent across both surfaces. Update menu hidden
for forks (YNH refuses).

Editable-path resolution. For forked-locals, Reveal/Open/Copy actions
target installed_from.source (the editable working tree), not the YNH
install slot. Single canonical "where this lives on disk" location.

Tolerant Harness decoder. Custom init(from:) accepts null for includes
and delegates_to (which YNH emits for broken-install error rows). One
bad row no longer collapses the whole sidebar.

Sheet first-paint fix. Fork, Update, Duplicate, Add Marketplace, Session
Recovery, and Install sheets apply their .frame at the .sheet content
closure rather than inside the sheet body. Eliminates the
rounded-rect placeholder flash before content resolves.

Vendor override picker. Per-harness vendor override (claude/codex/cursor)
persists via YNHPersistence; surfaces as a picker badge in the detail
header.

Tutorial 13 updated for fork/duplicate/menu reorganization.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(localization): Translate Phase 1 harness management strings (40 languages)

Adds and syncs localized strings for the Phase 1 harness management
surface across all 40 supported locales:

- Source badges (Local, Forked from <X>, Read-only pill)
- Update banner copy (versioned vs unversioned-drift wording)
- Update sheet phases (confirm, running, succeeded, failed)
- Unversioned-drift warning banner and confirmation step
- Fork sheet (title, explanation, fork button, progress)
- Duplicate sheet (title, name field, duplicate button)
- Action menu items new in this PR (Reveal in Terminal, Open in…,
  Open in browser, Copy as Pathname, Delete Harness)
- Sidebar tooltips for the global-probe spinner and dependency-view
  warning triangle
- Common error/loading copy used by CommandRunnerSheet

No source-only NEEDS TRANSLATION markers remain.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* test(harness): Coverage for Phase 1 — fork, badges, decoder, drift signals

Adds and updates test coverage to match the Phase 1 implementation:

- HarnessUpdateBadgeStoreTests — full coverage of the three-state signal
  classification (none / versioned / unversionedDrift), include-level
  drift, harness-source drift (self-contained plugins), and version-bump
  precedence over drift. Regression test for nil-ref include data shape.
- HarnessSourceBadgeViewModelTests — registry / git / local / forked
  classification from installed_from variants.
- HarnessEditabilityResolverTests — editability decision per source kind
  and capability gate.
- ForkServiceTests — single-call ynh fork --to flow, error mapping,
  invalidate-on-success.
- YnhVersionProbeTests — semver comparison, capability gating.
- YNHDecodingTests — extended for tolerant Harness decoder (null
  includes/delegates_to from broken-install error rows), envelope shape,
  --check-updates fields (version_available, ref_available, sha_available),
  forked_from provenance.
- UpdateCheckStateTests — lifecycle transitions.
- YNHPersistenceProtocolTests — vendor override persistence contract.
- HarnessRepositoryTests — capability surface + selectedDetail wiring.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: David Collie <support@eyelock.net>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
#250)

* feat(harness): inline include editing in detail pane

Adds inline add/edit/remove of includes directly from the harness detail
pane, gated to non-registry harnesses. Builds on Phase 1's foundation;
no changes to the existing marketplace-driven flow.

- IncludeMutator service wraps ynh include remove/update with the same
  streaming pattern as IncludeApplier
- HarnessIncludeEditor coordinator drives the per-include affordances
  (edit sheet, remove confirmation) and the inline add panel state
- AddIncludeFlow embeds the source picker (marketplace browse or git
  URL), reusable picks selector, and review-and-apply step inline below
  the dependencies section
- EditIncludeSheet edits ref/path/picks with a command preview and
  honors ynh's "empty picks = include all" semantics
- Already-installed plugins in the source picker route to Edit instead
  of attempting a duplicate add

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(harness): manifest field editor for description, vendor, version

Adds an "Edit Manifest…" item to the detail pane action menu (gated to
fully-editable harnesses) that opens a sheet for the safe subset of
plugin.json fields. Includes/delegates remain managed by ynh commands.

- HarnessManifestEditor service reads/writes .ynh-plugin/plugin.json,
  preserving every key (\$schema, name, includes, etc.) by round-tripping
  through a JSON dictionary
- SemverValidator gives the version field a shape check with inline hint
- VendorService drives the default-vendor picker
- Save invalidates detail and re-fetches so the header reflects the
  change immediately

Also picks up a swift-format whitespace fix on AddIncludeFlow.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(harness): polish manifest editor and drift indicator

- Manifest save now refreshes the harness list so the sidebar reflects
  new version and description (was only re-fetching detail)
- "Default Vendor" label disambiguates the manifest field from the
  per-user override badge in the header
- Drift triangle on dependency rows now matches by git URL when an
  include has no subpath, so includes spanning the whole repo light up
- Drift tooltip names the SHA delta ("Upstream commit changed: abc1234
  → def5678") instead of the generic banner copy

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore(localization): translate harness detail editing strings

Brings 39 non-English locales up to 852/852 keys. Covers the 50 new
strings added by this branch (include add/edit/remove + manifest
editor + drift tooltip) plus 12 pre-existing gaps that the localizer
audit surfaced (sidebar checkout-branch sheet, harness loading help,
unversioned drift help/banner/confirmation copy).

Validated with zero NEEDS TRANSLATION markers, zero missing keys, and
zero format-specifier mismatches across all 40 languages.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: David Collie <support@eyelock.net>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds the row-level affordances for delegates that mirror what we just
shipped for includes — ••• menu with Edit / Remove on each delegate
card, gated to non-registry harnesses, plus the underlying YNH wrapper.

The "Add Delegate" entry point is intentionally absent. The first
attempt landed an inline embed that didn't fit how users actually
think about picking a delegate target (a harness already in their
library, not a path to type or browse to). That flow will be
reintroduced once we agree on a unified source-picker pattern that
covers Add Include, Add Delegate, Add Marketplace, and Install
Harness consistently.

What's in:
- DelegateMutator service (ynh delegate add / remove / update)
- HarnessDelegateEditor coordinator for per-row Edit and Remove
- EditDelegateSheet (ref + path text fields + command preview)
- Per-row ••• menu on delegate cards in HarnessDetailDependencyView
- Remove confirmation dialog
- Strings translated across all 40 supported languages

What's deliberately missing (deferred):
- "Add Delegate" entry point — depends on unified picker design

Co-authored-by: David Collie <support@eyelock.net>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ss (#252)

`Harness.id` is `"namespace/name"` for namespaced installs but
`YNHPersistence` keys associations by bare `name`. The Launch <harness>
flow on worktree rows passes the persisted name through and sets
`selectedHarnessName = name`, but `HarnessRepository.selectedHarness`
matched on `id` only — for any namespaced install the lookup missed,
the launch sheet's content closure returned nothing, and the sheet
rendered as a blank rounded rectangle that never populated and could
only be dismissed with Esc.

Make `selectedHarness` match by `id` then fall back to `name`. Apply
the same rule to the stale-selection eviction inside `refresh()` so
the next list refresh doesn't immediately clear a name-keyed selection.

Affects v0.9.3 — also forward-ported via hotfix release.

Co-authored-by: David Collie <support@eyelock.net>
…name (#258)

Three breaking changes in ynh 0.3.0's structured-output format combined
to leave TermQ unable to load any harness data against the new YNH:

  1. `ynh ls --format json` now returns an envelope object
     `{capabilities, harnesses, ynh_version}` instead of a bare array.
  2. `ynh info <name> --format json` likewise wraps in
     `{capabilities, harness, ynh_version}`.
  3. The harness `version` field was renamed to `version_installed`
     in both `ynh ls` and `ynh info` payloads.

Update HarnessRepository to decode through YNHListEnvelope /
YNHInfoEnvelope wrappers, and remap the `version` CodingKey on
Harness and HarnessInfo to `version_installed`. ynd compose still
emits `version` so HarnessComposition is unchanged.

User-visible symptom on v0.9.4: the Harnesses sidebar tab was empty,
harness detail showed nothing, and Launch from a worktree row
presented a blank rounded sheet (or did nothing at all). The v0.9.4
identifier-fallback fix at HarnessRepository:40 was a downstream
patch on the same bug class but couldn't help while the list itself
was empty.

YNH-side schema changes are documented separately in a YNH bug
report (envelope shape, version rename, Harness.namespace not
populated for registry installs). All three are 0.x churn and
explicitly not backward-compatible.

Co-authored-by: David Collie <support@eyelock.net>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…#255)

Land the three Lane A structural fixes from the post-0.9.4 audit. All three
are pattern adoptions, not rewrites, but together they close the
load-bearing failure modes that produced the white-pill launch bug and the
0.9.3 actor-isolation crash class.

1. LoadState readiness primitive on HarnessRepository
   - New `LoadState<Value>` enum (.idle/.loading/.loaded(value)/.error)
   - HarnessRepository exposes `listState` as the canonical source; the
     legacy `harnesses`/`isLoading` are computed accessors over it.
   - Launch sheet migrated from `.sheet(isPresented:)` with a conditionally
     empty content closure (the SwiftUI footgun behind the white-pill bug)
     to `.sheet(item: $launchSheetTarget)` against an Identifiable target
     that is set only when both `listState.isLoaded` AND the requested
     harness id resolves to a `Harness`. Cold-start clicks against an
     unloaded list are queued in `pendingLaunch` and resolve via
     `.onChange(of: listState)` once data lands.

2. Canonical Harness identity = `Harness.id`
   - Renamed `selectedHarnessName` → `selectedHarnessId` (every site).
   - Sidebar `.tag(harness.name)` → `.tag(harness.id)`; both sidebars
     write `harness.id` into YNHPersistence rather than `harness.name`.
   - YNHPersistence stores ids; new `migrateLegacyHarnessKeys(using:)`
     pass rewrites pre-existing bare-name values to canonical ids on
     first successful refresh (idempotent, gated per session).
   - `removeAllAssociations` accepts an id and tolerates legacy
     bare-name/`*/<name>` matches as a transition safety net.
   - The id||name fallback inside `selectedHarness` is gone; refresh
     normalises a legacy bare-name selection to its canonical id rather
     than dropping it.

3. -strict-concurrency=complete enforced explicitly
   - Applied as a swiftSetting to every target in Package.swift so the
     gate survives any future relaxation of the package language mode.
   - TmuxControlModeSession's process/pipe `nonisolated(unsafe)` storage
     replaced with a `TmuxProcessHolder: @unchecked Sendable` indirection.
     The session is now @MainActor-clean; cleanup runs from the holder's
     deinit on whichever queue ARC releases on, not from the session.

Verification:
- `swift package clean && make check` clean (1658 tests pass, no
  warnings).
- White-pill repro requires interactive cold-launch testing on the user's
  machine — the structural fix prevents the bug class by construction
  but should be confirmed against the four launch entry points.

Co-authored-by: David Collie <support@eyelock.net>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…vider, UpdaterProvider) (#259)

* refactor: introduce YNHCommandRunner seam for YNH-touching services

Protocol-and-DI extraction around CommandRunner.run for HarnessRepository,
VendorService, SourcesService, HarnessSearchService, and the
LiveUpdateAvailabilityService.production wiring. Lets tests stub the
subprocess layer instead of shelling out to a real ynh/ynd binary,
unblocking coverage of the success and error branches in each service.

Production callers are unchanged — LiveYNHCommandRunner is the default
init argument and is a thin pass-through to CommandRunner.run.

* refactor: introduce WorkspaceProvider seam for NSWorkspace consumers

Protocol-and-DI extraction around NSWorkspace.shared for EditorRegistry
and TermQTerminalLink. Lets tests stub LaunchServices queries, URL
opens, and Finder reveal calls without launching real apps.

TermQTerminalLink moves from a stateless enum to a struct with an
injectable workspace + filesystem predicates; the static `open(link:cwd:)`
facade is preserved unchanged for the existing TerminalViewDelegate
call sites.

Production callers are unchanged — LiveWorkspaceProvider is the default.

* refactor: introduce UpdaterProviding seam for Sparkle integration

Protocol-and-DI extraction around SPUUpdater for UpdaterViewModel. Tests
can now inject a stub that records checkForUpdates() calls and emits
canCheckForUpdates changes via a PassthroughSubject — letting the view
model's state plumbing be exercised without Sparkle reaching the network
or talking to LaunchServices.

UpdaterViewModel keeps its existing init(updater:controller:) as a
convenience that wraps LiveUpdaterProvider, so production wiring in
TermQAppDelegate is unchanged.

---------

Co-authored-by: David Collie <support@eyelock.net>
Extracts a generic two-tab picker (Library | Git URL) from the prior
HarnessInstallSheet, driven by a SourcePickerContext protocol. Install
Harness becomes the first context; Add Delegate and Add Include will
follow in later phases per .claude/plans/feat-unified-source-picker.md.

UX changes
- Sources tab folds into Library as a gear-button affordance opening a
  nested Manage Sources sheet (popover would crash inside macOS sheets).
- Library search filters Installed / Available Locally / Available from
  Registries in place; no mode switch.
- Empty state: centered "No results" overlay when a filter clears every
  section. Registry section hides entirely when filtered to nothing.
- "No marketplaces configured" inline row for the genuine empty state.

Implementation
- Sources/TermQ/Views/SourcePicker/SourcePickerContext.swift — protocol.
- Sources/TermQ/Views/SourcePicker/SourcePicker.swift — generic shell.
- Sources/TermQ/Views/SourcePicker/HarnessInstallContext.swift — context
  + Library, Git URL, and Manage Sources views.
- HarnessInstallSheet reduced to a thin host preserving its public init
  and onInstall callback so call sites (ContentView.swift:257) need no
  edits.

Localization
- Replaces tab keys (.search, .sources removed; .library added).
- Adds .manage.sources, .manage.sources.help, .manage.sources.done,
  .section.available.empty across all 40 locales.

Co-authored-by: David Collie <support@eyelock.net>
…lures (#264)

Removing a marketplace from Settings → External Sources didn't survive
relaunch. Three concurrent issues:

- Confirmation dialog read marketplaceToRemove after dismissal (racy);
  switched to the presenting: form so the action captures by value,
  matching the pattern already used in HarnessDetailDependencyView.
- save() swallowed all errors with try?; now logs via TermQLogger.io
  and exposes lastPersistenceError on the store.
- A re-seed (after a defaults reset or version bump) could re-add a
  default the user had explicitly removed. Added tombstone tracking
  (marketplaces.removedDefaultURLs.v1) honoured on seed; the explicit
  Restore Defaults button bypasses tombstones via force: true.

MarketplaceStore.init now accepts optional fileURL and UserDefaults
for isolated tests; new MarketplaceStoreTests.swift covers seeding,
removal persistence, tombstone behaviour, and dedup.

Fixes #260

Co-authored-by: David Collie <support@eyelock.net>
#265)

Models the layering the codebase has been implementing informally
(built-in defaults → user prefs → per-card overrides) as an explicit
@observable owner. Migrates the four audit-named drift fields
(safePaste, fontSize, themeId, backend) from snapshot-on-create to
Optional per-card overrides that resolve through SettingsStore at
use time, plus the seven other globals it now owns
(enableTerminalAutorun, allowOscClipboard, confirmExternalLLMModifications,
tmuxEnabled, tmuxAutoReattach, binRetentionDays, terminalScrollbackLines).

Drift fix
- TerminalCard.{safePasteEnabled, fontSize, themeId, backend} are now
  Optional. Custom Codable round-trips concrete pre-upgrade values as
  explicit overrides so existing cards keep their behavior; missing
  or sentinel ("", 0) values decode to nil (inherit).
- Card-create sites (BoardViewModel.newTerminal/quickNewTerminal,
  ContentView.handlePendingTerminal/install/uninstall/export/launchHarness)
  no longer snapshot the four fields from UserDefaults — they pass nil.
- TerminalSessionManager resolves at session-create time via
  settings.effective{SafePaste,FontSize,ThemeId,Backend}(card:).
- CardEditorView gains an "Override default" toggle per field; the
  inherited value is shown read-only when the toggle is off.

Tier A/B migration
- SettingsView writes the seven Tier B globals through @bindable
  SettingsStore. Theme picker stays bound to TerminalSessionManager
  to preserve live theme application on running terminals.
- BinView, ToolsTabContent, CardEditorView (read-only globals),
  HarnessLaunchSheet, URLHandler, BoardViewModel+TmuxRecovery,
  BoardViewModel+HeadlessMode, BoardViewModel.purgeExpiredCards,
  TerminalSessionManager (scrollback + autorun gate) all route
  through SettingsStore.

External-write bridge
- SettingsStore observes UserDefaults.didChangeNotification and
  reconciles its in-memory state on external writes (CLI, MCP,
  Sparkle, future code). The didSet write-back is suppressed during
  sync to avoid loops.

Upgrade behaviour
- Existing cards retain their concrete values as explicit overrides
  and will not track future changes to the matching global default
  until the user toggles "Override default" off. CHANGELOG documents
  this. New cards inherit by default.

Tests
- SettingsStoreTests (19) cover defaults, layering, override
  resolution, write-through, external-sync, and the propagation
  contract (inheriting cards see global changes; overriding cards
  don't).
- TerminalCardTests, CardEditorViewModelTests, BoardTests,
  BoardViewModelDefaultsTests, TmuxMetadataTests updated to assert
  the new Optional contract.

Follow-up audit filed as #263 — disposition for the remaining
~70 @AppStorage / UserDefaults sites (16 distinct keys), classified
as migrate-to-store (3), move-to-other-owner (4), or stays (12).

Co-authored-by: David Collie <support@eyelock.net>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…#266)

Closes the three "candidate-for-store" items from #263:

- copyOnSelect: SettingsView writes through `$settings.copyOnSelect`;
  TerminalHostView reads `SettingsStore.shared.copyOnSelect`.
- defaultWorkingDirectory: SettingsView binds to `$settings.defaultWorkingDirectory`;
  BoardViewModel.newTerminalDefaults reads through the store. Empty-string
  decode falls back to `NSHomeDirectory()`.
- diagnosticsVerboseMode: TermQLogBuffer's UI-bound setter routes through
  `SettingsStore.shared` (with a MainActor hop). The init read and the
  per-message gate in `append()` keep the nonisolated UserDefaults read,
  since `append()` is called from arbitrary actor contexts and
  `SettingsStore.shared` is `@MainActor`. Both reads share the same
  persisted key so they stay consistent.

Adds six SettingsStoreTests covering defaults, persisted reads, write-through,
and the empty-string-string fallback for `defaultWorkingDirectory`.

After this lands, 16 of #263's bullets remain (4 move-to-other-owner +
12 stays-as-is). The OSC 52 default-mismatch is still a separate followup.

Co-authored-by: David Collie <support@eyelock.net>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
applicationShouldHandleReopen fired on every MCP-driven termq:// URL
delivery (NSWorkspace.open with activates:false does not suppress the
underlying AE Reopen). The handler unconditionally called
makeKeyAndOrderFront, so each background MCP op stole focus from
whatever app the user was working in.

Gate the activation: unhide on Cmd+H, deminiaturize on Cmd+M, bring the
window forward only when no windows are visible. When the window is
already visible and the app is not hidden, no-op — AppKit handles
genuine Dock-click activation independently of this delegate method.

Co-authored-by: David Collie <support@eyelock.net>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…270)

`TerminalHostView`'s runtime gate for OSC 52 clipboard access defaulted
to `true` when the preference was unset, while `SettingsView` displayed
`false`. So a never-touched user saw "Off" in Settings → Data & Security
but terminal programs could silently copy to the clipboard. Aligning to
`SettingsStore.shared.allowOscClipboard` (default `false`) closes the
mismatch and matches what users see in Settings.

Behavior change: existing users who had relied on the implicit-on
default will need to enable OSC 52 explicitly. CHANGELOG documents this.

Filed as the OSC 52 follow-up from #263 (now closed).

Co-authored-by: David Collie <support@eyelock.net>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds Add Delegate as the second SourcePickerContext. Users can attach a
delegate to an editable harness from the detail pane via a new "+ Add
Delegate…" entry point, choosing from installed harnesses or pasting a
git URL — same shape as Install Harness.

Library / Git URL contexts
- Library lists installed harnesses (excluding self) with search; pick a
  row to enter a Configure stage with optional ref + path inputs and a
  command preview, then Apply runs `ynh delegate add` via DelegateMutator.
- Git URL tab takes URL + ref + path with the same Apply path.
- sourceURL for installed-harness picks comes from
  `Harness.installedFrom.source` — the registry URL, git URL, or local
  path the harness was originally installed from. Works across all three
  install types without assuming a specific source kind.

Cross-context unification
- Both Install Harness and Add Delegate now share the same Library shape:
  search field + gear icon (Manage Sources) at the top, "Browse Local…"
  button at the bottom. Same icon, same label, same position.
- New `SourcePickerManageSourcesSheet` is the shared view behind the gear
  in both contexts; it lists registered ynh sources with Remove.
- Install Harness's "Add Source…" (previously buried in the gear popup)
  is now the visible "Browse Local…" button at the bottom of Library;
  Add Delegate's bottom button is identical, with context-specific
  follow-up (registers as ynh source vs. uses the path as ad-hoc
  delegate sourceURL).

Editor lifecycle
- HarnessDelegateEditor gains `requestAdd()`, `isAddingDelegate` flag,
  and `reloadAfterAdd(harnessName:)`. The unified picker owns the
  mutation; the editor owns the post-apply detail reload.
- HarnessDetailDependencyView renders the "+ Add Delegate…" button
  below the delegates list (mirrors the existing Add Include
  affordance) and presents the SourcePicker sheet via a thin
  `AddDelegateSheetHost`.

Localization
- Adds `harnesses.add.delegate.*` and `source.picker.browse.local*`
  keys; renames the old `harnesses.add.delegate.browse.local*` keys to
  the shared `source.picker.*` namespace.
- All 40 locales synced.

Co-authored-by: David Collie <support@eyelock.net>
…rs (#271)

Splits the launch and lifecycle flows out of ContentView (974 lines
before, the highest-churn file in the audit's god-object list at §2G)
into two @observable coordinators held via @State.

- HarnessLaunchCoordinator owns: pendingLaunch, launchSheetTarget,
  launchWorkingDirectory, launchWorktreeBranch, cardBeforeHarness state,
  the PendingLaunch + LaunchSheetTarget types, and the requestLaunch /
  tryResolvePendingLaunch / launchHarness / dismiss / clearAllSelection
  / captureCardBeforeHarness functions.
- HarnessLifecycleCoordinator owns: installCardIDs, uninstallCardNames,
  harnessNameToFork/Update, showForkSheet/UpdateSheet/InstallSheet
  state, and the installHarness / uninstallHarness / updateHarness /
  exportHarness / forkHarness / handleTransientSessionExit /
  handleForkCompleted functions.

Both take their service dependencies via constructor injection (default
to the existing .shared instances) so tests can drive them directly.
Pattern follows the SettingsStore + Lane A approach: @observable +
@State, not new singletons. The audit (§2A) flagged singleton-style
services as load-bearing bug class #1; adding more would regress.

ContentView is now 792 lines (-182). Every reference to the extracted
state goes through a coordinator — no half-migration. Sub-views
participate via the @State-held coordinator instances. The
ContentView+HarnessLaunch.swift and ContentView+HarnessFork.swift
extension files are deleted; their content moved into the coordinators
or inlined into ContentView's harness sub-view extension.

handlePendingTerminal (URL-driven) stays on ContentView — different
entry point from the coordinator-driven flows.

Adds 13 tests covering both coordinators (initial state, fork/update
sheet state, transient-session-exit dispatch, dismiss clears state,
captureCardBeforeHarness, clearAllSelection).

Tests: 1705 (was 1692). Lint, format, build all clean.

Co-authored-by: David Collie <support@eyelock.net>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…a injected runner (#280)

`HarnessSearchService` already accepts a `YNHCommandRunner` and
`YNHDetectorProtocol` via init, but the existing tests all fall through
the `.missing` detector guard — leaving the entire `search()` body
(arg construction, env override, command result decode, error paths)
uncovered.

Adds a `StubYNHCommandRunner` test double and nine tests covering:
- result decoding from canned JSON
- empty-query "browse all" arg shape
- whitespace-only query treated as browse
- non-zero exit code → error
- malformed JSON → error
- runner throws → error
- `ynhHomeOverride` propagates to environment
- subsequent search call cancels the previous (debounce)

Coverage moves from "init + reset + .missing-guard only" to the full
success path. Closes the easy-win line item from
`.claude/plans/2026-04-22-test-integration-initial-handover.md`
(plan-2 table at line 172).

Tests: 1705 → 1713 (+8 net new; the existing 9 still pass).

Co-authored-by: David Collie <support@eyelock.net>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* chore: Update appcast for release v0.9.6

Auto-generated appcast files for Sparkle auto-updates.

Co-Authored-By: github-actions[bot] <github-actions[bot]@users.noreply.github.com>

* chore: forward-port v0.9.6 hotfix CHANGELOG

The three fix commits backported on the v0.9.6 hotfix line are already
present on develop (#264, #268 directly; #270 via the SettingsStore
route). This commit folds the [Unreleased] OSC 52 entry into a new
[0.9.6] section so develop's CHANGELOG reflects what shipped, alongside
the appcast files cherry-picked from main.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
Co-authored-by: David Collie <support@eyelock.net>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ceTests flake (#279, #285) (#284)

* test(coverage): backfill HeadlessWriter and CLI+Mutations branches (#279)

Add targeted tests for previously uncovered branches:

- HeadlessWriter: tag-merge replacement when key already exists,
  replaceTags with nil to clear all tags, and permanent delete
  paths (exact name, partial name, unknown identifier).
- CLI+Mutations: --init-command warning in headless mode,
  --replace-tags clearing existing tags, and invalid column
  surfaces a failure.

Coverage:
- HeadlessWriter: 87% -> 98.8% lines
- CLI+Mutations:  82% -> 86% lines, 100% regions

RepoConfigLoader unchanged at 94% lines / 100% regions; remaining
gaps are NSFileCoordinator and encoder-failure paths that aren't
reachable without a mockable seam.

Closes #279

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* test(harness-search): replace fixed-sleep wait with deterministic poll (#285)

The 450ms fixed sleep in waitForDebouncedSearch left only ~100ms
of slack after the 350ms debounce — not enough on loaded CI runners
where parallel test bundles compete for scheduling. This produced
non-deterministic failures across multiple test cases in the file
(test_search_withResults_decodesAndPublishesThem,
test_search_commandNonZeroExit_setsError, etc.).

Replace the helper with a generic poll-until-predicate awaiter
(20ms tick, 3s ceiling). Each callsite now waits on the actual
terminal state it asserts on, so timing variance no longer matters.

Closes #285

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: David Collie <support@eyelock.net>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…range (#283)

The previous generator ranged over `${PREV_TAG}..origin/develop` and
filtered by `feat:`/`fix:` commit prefixes, on the assumption that all
features land on develop. That assumption breaks for hotfixes: a
hotfix is tagged off `main`, and `develop` contains every commit that
wasn't backported. v0.9.6's release notes ended up listing SourcePicker,
harness management, ynh 0.3 envelope, and other develop-only commits
that aren't actually in the v0.9.6 tag.

Switch to extracting the section header `## [$VERSION]` from
CHANGELOG.md. CHANGELOG is already the human-curated source of truth
for what shipped — using it directly is correct for every release type
(stable, beta, hotfix) and gives release-author control over wording.

If the section is empty (CHANGELOG not maintained for that version),
fall back to a stub pointer.

Co-authored-by: David Collie <support@eyelock.net>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ction (#276) (#286)

BackupManager previously hard-coded its primary (Application Support)
and backup directories at static-property level, which made every
file-based test either skip when a real file existed on disk or
operate on the user's actual data. Introduce a `BackupRoots`
Sendable struct with `primaryDir` + `backupDir`, and a single static
`rootsOverride` hook (nonisolated(unsafe), test-only) that flips the
resolution. All path getters now route through `roots`, so call sites
remain unchanged.

Add a dedicated test suite (`BackupManagerRootsInjectionTests`)
covering backup/restore/backupInfo/hasBackup/checkAndOfferRestore
against fresh temp directories per test — no more skips.

`MarketplaceStore` was already DI-ready (`init(fileURL:defaults:)`)
with isolated tests, so this PR scopes to BackupManager only.
SecureStorage interaction in backup/restoreSecrets() left as-is —
that's the Keychain abstraction tracked by #274.

Closes #276

Co-authored-by: David Collie <support@eyelock.net>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
… (#287)

SecureStorage previously hardcoded its dependencies — Keychain (or a
file-based debug fallback) for the AES key, and DataDirectoryManager
for the secrets file location. Tests had no way in, leaving 0%
coverage on launch-path code that resolves env vars on every terminal
open.

Extract an `EncryptionKeyStore` protocol with three implementations:
- `LiveEncryptionKeyStore` — encapsulates the existing Data Protection
  Keychain + legacy migration in RELEASE, file-based key in
  TERMQ_DEBUG_BUILD. Same behaviour as before.
- `InMemoryEncryptionKeyStore` — test-only, holds the key in memory.

`SecureStorage` now takes `keyStore` + `configDirectory` at init;
production code keeps using `.shared` (defaults preserved). Tests
construct fresh actors over temp directories.

`GlobalEnvironmentManager` gets matching DI: `init(secureStorage:
userDefaults: autoLoad:)` lets tests pass an isolated storage actor
and a UserDefaults suite, with `autoLoad: false` to skip the init-time
Task that previously made deterministic tests impossible.

Two new test suites:
- `SecureStorageTests` (15 tests) — round-trip, persistence,
  hasEncryptionKey, reset, export/import, clearCache.
- `InMemoryEncryptionKeyStoreTests` (4 tests) — load/store/delete
  semantics for the test double.
- `GlobalEnvironmentManagerTests` (16 tests) — add/update/delete,
  secret/non-secret transitions, persistence round-trip, resolution,
  duplicate-key detection.

Out of scope per #274: key rotation; migrating other Keychain
consumers.

Closes #274

Co-authored-by: David Collie <support@eyelock.net>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…wners (#269) (#288)

Each move replaces a @AppStorage key duplicated across several Views
(or read raw via UserDefaults) with a small @published owner that
persists to UserDefaults itself. Same on-disk shape — existing values
keep loading.

- `marketplaceAutoRefresh` → `MarketplaceStore.autoRefresh`. Settings
  binds via `$store.autoRefresh`. One reader.
- `defaultHarnessAuthorDirectory` → new `HarnessAuthorPreferences`
  ObservableObject. Replaces five sites:
  HarnessWizardSheet, ForkHarnessSheet, DuplicateHarnessSheet,
  SettingsMarketplacesView, HarnessesSidebarTab (the latter previously
  read raw UserDefaults).
- `sidebar.selectedTab` → new `SidebarState` ObservableObject. The
  `SidebarTab` enum is promoted out of `SidebarView` to module scope
  so cross-view writers (HarnessWizardSheet, HarnessDetailView) can
  set tabs by enum case instead of fragile raw strings like
  "marketplaces".
- `protectedBranches` → new `GitConfigStore`. SettingsView binds via
  `$gitConfig.globalProtectedBranches`; WorktreeSidebarViewModel
  reads from the store instead of raw UserDefaults at line 392.

10 new tests in `AppStorageOwnerTests` exercise each owner's default,
persistence, and (for SidebarState) malformed-value fallback against
isolated UserDefaults suites.

Closes #269

Co-authored-by: David Collie <support@eyelock.net>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
eyelock and others added 12 commits May 5, 2026 11:28
…inalSessionManager (#273) (#290)

Two of the three direct `Process()` sites flagged in #273 fit the
existing `YNHCommandRunner` shape — single-shot fire-and-forget
subprocesses with no streaming, lifecycle, or pipe semantics:

- `EditorRegistry.which(_:)` — `/usr/bin/which <bin>`. `start()` and
  `detect()` become async; `start()` schedules the work on a Task so
  the AppDelegate caller stays sync.
- `TerminalSessionManager.sendTmuxCommand(_:to:)` — fire-and-forget
  `tmux <command> -t <session>`.

Both sites now take `commandRunner: any YNHCommandRunner` at init,
defaulting to `LiveYNHCommandRunner`. The protocol is mildly
misnamed for non-YNH callers but reusing it avoids cosmetic churn
across six existing files; renaming can come later if it grates.

The third site, `TmuxControlModeSession.connect()` (line 96), is
explicitly out of scope here — it needs the full Process lifecycle
plus Pipe/FileHandle streaming abstraction that #273 itself flagged
as a separate follow-on. Filed as #289.

Tests:
- New `EditorRegistryTests` (5 tests): bundle-id hit short-circuits
  `which`, success/empty/failure paths for the runner, exact args
  asserted on the captured invocations.
- `TerminalSessionManagerTests`: smoke-test that
  `sendTmuxCommand` no-ops cleanly when no session exists for the
  card. End-to-end happy-path for the runner is hard to drive
  without real session state and is left for #289's follow-up.

Closes #273

Co-authored-by: David Collie <support@eyelock.net>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…277) (#291)

`HarnessAuthor.swift` carried six classes (HarnessAuthor,
MarketplaceAddRunner, IncludeMutator, DelegateMutator,
YNHMarketplaceService, IncludeApplier) each with its own
~60-line copy-pasted streamProcess implementation spawning
Process directly. ~300 lines of duplication, near-zero coverage,
launch-path adjacent.

Each class now takes `commandRunner: any YNHCommandRunner =
LiveYNHCommandRunner()` at init and delegates streaming to
`CommandRunner.run`'s onStdoutLine/onStderrLine callbacks.
Stdout and stderr are merged into outputLines (matching prior
behaviour where both pipes were combined). The empty-line filter
is preserved by guarding the line callback. The `LineBuffer`
helper at the top of the file is removed; CommandRunner has its
own line splitter.

`YNHMarketplaceService.fetch` and `runSilent` use the runner's
buffered Result.stdout / didSucceed surface — no streaming needed
for the JSON-listing and silent-remove paths.

File shrinks from 796 to 616 lines (-180). All six classes are
now testable without spawning real ynh/ynd subprocesses.

19 new tests in `HarnessAuthorRunnerTests`:
- HarnessAuthor: success, create-fail-skips-install, arg-passing.
- MarketplaceAddRunner: success and failure surfaces.
- IncludeMutator: pure arg builders + remove/update success/fail.
- DelegateMutator: pure arg builders + add success.
- YNHMarketplaceService: JSON refresh decode, non-zero exit
  leaves state empty, remove invokes remove+refresh in order.
- IncludeApplier: pure arg builders + apply success/fail.

Scope notes against the original audit (#277):
- The audit assumed manifest editing, fork/duplicate orchestration,
  filesystem operations, and persistence updates were all in
  HarnessAuthor. They aren't — those live in HarnessManifestEditor,
  Authoring/{Fork,Duplicate}HarnessSheet.swift, and HarnessRepository
  respectively. The actual problem was streamProcess duplication.
- A `HarnessAuthorPreferences` type already shipped in #269. The
  `HarnessFsOperations` extraction the audit suggested has no caller
  in this file.
- File-split per class (one file each) is good hygiene but cosmetic;
  filing as a separate follow-up so this PR stays focused on the
  testability win.

Closes #277

Co-authored-by: David Collie <support@eyelock.net>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…les (#292) (#293)

Pure cosmetic reorg following #277. The six classes that previously
shared HarnessAuthor.swift have no shared state, no inheritance, and
no helper code (since #277 collapsed the duplicated streamProcess
into YNHCommandRunner calls). One file per class makes each
findable by name.

| File                          | Class                  |
|-------------------------------|------------------------|
| HarnessAuthor.swift (slimmed) | HarnessAuthor          |
| MarketplaceAddRunner.swift    | MarketplaceAddRunner   |
| IncludeMutator.swift          | IncludeMutator         |
| DelegateMutator.swift         | DelegateMutator        |
| YNHMarketplaceService.swift   | YNHMarketplaceService  |
| IncludeApplier.swift          | IncludeApplier         |

Option structs collocate with the class that consumes them
(IncludeRemove/UpdateOptions move with IncludeMutator,
DelegateAdd/Remove/UpdateOptions with DelegateMutator,
IncludeApplicationOptions with IncludeApplier, YNHMarketplace
with YNHMarketplaceService). Shared types still consumed by
HarnessAuthor itself (AuthorStepStatus, AuthorStep,
HarnessCreationOptions, YNHBinaries) stay in HarnessAuthor.swift.

No behaviour change. All 19 HarnessAuthorRunnerTests still pass.

Closes #292

Co-authored-by: David Collie <support@eyelock.net>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Migrates the bespoke Add Include flow onto the unified SourcePicker,
completing the picker plan for the three "add a thing" surfaces:
Install Harness (Phase 1), Add Delegate (Phase 2), and now Add Include.

Layout
- Library tab lists plugins from configured marketplaces with search
  and "already installed" recognition. Picking a fresh plugin enters a
  Configure stage with the picks tree (IncludePicksSelector) and a
  command preview; clicking a row matching an existing include routes
  through to the editor's Edit sheet via switchToEditing().
- Git URL tab takes URL + ref + path with command preview (no picks —
  raw URLs aren't introspected; user can narrow with Edit afterwards).
- Apply runs `ynh include add` via IncludeApplier; on success the
  editor's didFinishAddingInclude() dismisses the sheet and reloads
  detail.

Sheet replaces the inline expansion. The "+ Add Include…" button now
toggles editor.isAddingInclude which the IncludeEditorOverlay presents
as a sheet (mirroring AddDelegateSheetHost).

File layout
- New: Sources/TermQ/Views/SourcePicker/AddIncludeContext.swift
- New: Sources/TermQ/Views/SourcePicker/IncludePicksSelector.swift
  (extracted from the old flow file; reusable view)
- New: Sources/TermQ/Marketplace/IncludeMatching.swift
  (IncludeKey + IncludePluginLookup; non-View utilities)
- Deleted: Sources/TermQ/Views/AddIncludeFlow.swift (790 lines)
- HarnessDetailDependencyView: drops AddIncludeSectionView; entry-point
  button stays; sheet host modifier added.

Tests
- AddIncludeStoreTests → AddIncludeContextTests, rewritten against the
  new context API. Covers library-stage resolution, picks helpers, and
  command-preview behaviour for both Library and Git URL paths.

Localization
- Retires step-title and source-mode strings (no longer needed —
  picker shell handles tab labels and step dots are dropped).
- Adds harnesses.include.add.edit.existing.
- All 40 locales synced.

Co-authored-by: David Collie <support@eyelock.net>
* hotfix(v0.9.7): tolerate YNH 0.2.x list/info shapes (#296)

v0.9.5/0.9.6 hard-coded the YNH 0.3 structured-output shape, but
YNH 0.3 was never published to the Homebrew tap. Every user on
`brew install ynh` is on 0.2.3, so harness loading failed entirely:
empty Harnesses sidebar, blank Launch card on worktree rows that
already had a harness associated.

Decoding is now tolerant of both shapes:

- `YNHListEnvelope` / `YNHInfoEnvelope` accept either the 0.3
  envelope (`{harnesses: [...]}` / `{harness: {...}}`) or a bare
  `[Harness]` / `HarnessInfo` payload.
- `Harness` / `HarnessInfo` accept either `version_installed` (0.3)
  or `version` (0.2.x).

Tests cover both shapes for both call sites.

The compat layer is intentional and sticks around past 0.10. Removal
plan: once YNH 0.3 ships to the tap, gate behavior on
`YNHDetector.capabilityMeets("0.3.0")` for one release, then delete
the legacy branches.

Co-authored-by: David Collie <support@eyelock.net>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore: Update appcast for release v0.9.7

Auto-generated appcast files for Sparkle auto-updates.

Co-Authored-By: github-actions[bot] <github-actions[bot]@users.noreply.github.com>

* fix: complete forward-port of v0.9.7 hotfix

The hotfix added isPinned to HarnessInfo but the custom init(from:) and
encode(to:) didn't handle the new property — develop's build broke on
merge. Decode/encode it like the other optional fields.

Tests for YNH 0.2.x/0.3 envelope tolerance referenced YNHListEnvelope/
YNHInfoEnvelope, but the actual types are HarnessListResponse/
HarnessInfoResponse. Updated test references.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: David Collie <support@eyelock.net>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
The local-branch context menu previously offered only "New Worktree
from Branch", which creates a new branch off the selected one. The more
common operation is converting an existing branch into a worktree
without creating a new branch — this adds that as a distinct action.

The Convert sheet exposes an editable Branch Name (default = current)
so a loose name can be normalised to the feature-branch convention as
part of the conversion; if changed, git branch -m runs before
git worktree add.

Co-authored-by: David Collie <support@eyelock.net>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(harness): canonical-id cutover at the YNH CLI boundary

Treat each harness's canonical id (`<host>/<org>/<repo>/<name>` or
`local/<name>`) as the only identifier across every TermQ→YNH
interaction. Eliminates the duplicate-name bug class where a registry
install and a local fork sharing a name could resolve to the wrong
target.

Steps consolidated into this commit:

- **Drop bare-name fallbacks in HarnessRepository.** The `id || name`
  mixed-key fallback was the original source of duplicate-name bugs;
  every read path now keys exclusively on `Harness.id`.
- **Canonical-id at the YNH CLI boundary.** Every shell-out (`ynh
  install`, `ynh info`, `ynh include`, `ynh delegate`, `ynh fork`,
  `ynh uninstall`, `ynh update`, `ynh export`) passes `harness.id`
  rather than `harness.name`.
- **Fork UX, migration coordinator, and quarantine surface** (steps
  4–8 of the plan):
  - Fork sheet: `--name <new-name>` flow matching what YNH actually
    exposes; canonical id is always `local/<name>`.
  - `HarnessMigrationCoordinator` reads `~/.ynh/.migration-manifest.json`
    on startup and rewrites TermQ-side persisted ids in `YNHPersistence`
    from old shape to canonical. Idempotent via
    `TermQ.harnessMigration.lastAppliedSchema` UserDefault. Includes a
    leaf-name fallback so `<old-namespace>/<name>` values rewrite
    against bare-name manifest entries (the persistence-shape mismatch
    we hit on first cutover migration). Always applies the on-disk
    manifest before deciding whether to call `ynh migrate`, so a fresh
    TermQ against an already-migrated YNH home still rewrites
    persistence cleanly.
  - Quarantine sidebar group surfaces entries from
    `~/.ynh/.quarantine/broken/` with Restore + Drop actions.

Robustness fixes folded in:

- `Harness.id` is decoded verbatim from the YNH 0.4 envelope's `id`
  field, with a `namespace + "/" + name` fallback for older envelopes.
- `HarnessListResponse` initializes `schemaVersion` in both decoder
  branches.
- Fork-completion lookup matches by `path` rather than synthesized id,
  robust against future fork id stamping changes.

Tests:
- Envelope `id` decode primary path (was previously only fallback path).
- `HarnessListResponse.schemaVersion` round-trip.
- `MigrationManifest` and `QuarantineEntry` decode contracts.

Docs:
- CHANGELOG sweep covering Phase 1 follow-ups: include/manifest editing,
  delegate management, unified Source Picker, schema-1→2 migration,
  quarantine sidebar, canonical-id at YNH CLI, fork sheet alignment,
  YNHCommandRunner injectable seam.
- Tutorial 13 expanded with sections on inline editing, quarantine UX,
  and automatic schema migration; fork section rewritten to describe
  the `--name` flow.
- Three 4×4 placeholder PNGs for the tutorial — real screenshots TBD.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(harness): stale-while-revalidate refresh, no flash on focus

`HarnessRepository.refresh()` set `listState = .loading` at the top of
every refresh, which made `harnesses` (which reads `listState.value`)
return `[]` until the new `ynh ls` response arrived. The
`didBecomeActiveNotification` handler in SidebarView calls refresh on
every focus, so each refocus produced a ~100ms window where the harness
list was empty:

- The detail pane unmounted (its survival check sees no harness)
- Open Add Include / Add Delegate sheets dismissed (parent unmounted →
  @State reset)
- Layout flashed to the default Kanban view, then back to detail when
  the load completed

Stale-while-revalidate semantics: keep `listState` at `.loaded` across
refreshes once we have data. A separate `isRefreshing` @published flag
exposes the in-flight signal to consumers that want a spinner. The
header spinner and refresh-button-disabled gate continue to work via
`isLoading`, which is now `isRefreshing || listState.isLoading`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(harness-source-picker): delegate add — pre-fill, SHA pin toggle, local restriction

Aligns Add Delegate's Library tab with YNH's marketplace pinning model
and YNH's actual `delegate add` surface. Final state collapses several
in-flight iterations as the YNH-side semantics were pinned down.

Form behaviour:
- Pre-fill `path` from the picked harness's `installedFrom.path` so
  YNH receives a fully-qualified delegate target. Without this,
  `ynh delegate add <target> <source-url>` with no `--path` produces
  a silently-ambiguous entry (the bug class YNH PR #134's validator
  now rejects).
- Pre-fill `ref` from `installedFrom.ref` (the user's stated install-
  time intent — could be a tag, branch, or SHA). Per YNH's marketplace
  model (docs/marketplace.md#pinning-refs-and-shas), ref is the primary
  identifier and SHA is an optional integrity pin layered on top.
  Defaulting to the symbolic ref preserves the user's tracking intent
  ("give me 1.0, including future patches").
- Surface the resolved SHA below the ref field as a caption, and add
  a "Pin to exact commit (integrity check)" toggle. When ticked, the
  form submits the SHA as `--ref` (YNH's resolver supports SHA-as-ref).
- Configure form's `@State` initializers seed from the picked source
  so the pre-filled ref + path actually appear in the UI rather than
  being silently overridden by empty defaults.

Local-as-delegate restriction:
- Local-only harnesses cannot be valid delegate targets — there's no
  shareable identity (no remote URL or canonical id YNH can resolve
  across machines), and persisting a filesystem path in plugin.json
  bakes one user's directory layout into the harness manifest.
  Filter local-sourceType harnesses out of the Add Delegate library
  list and remove the Browse-Local affordance entirely.
- Tutorial 13's Delegates section gets a callout describing the
  restriction and pointing users at the Git URL alternative.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(harness-source-picker): include — ref plumbing, picks step, URL normalization

Three independent gaps in Add Include surfaced during canonical-id
testing. All collapse here:

**`--ref` was silently dropped on every include add.**
`IncludeApplicationOptions` had no `ref` field and `buildIncludeAddArgs`
never emitted `--ref`. Every include floated to HEAD regardless of
the user's pinning intent — Library tab dropped `marketplace.ref`
that was already fetched as `libraryResolvedRef`; Git URL tab dropped
the user-typed `gitRef`. Plumb `ref: String?` through
`IncludeApplicationOptions`, `buildIncludeAddArgs`, `applyLibrary()`,
`applyGitURL()`, and `commandPreview()`. The Library Configure view
surfaces the inherited marketplace ref as a "Pinned to X (inherited
from marketplace)" / "Tracks marketplace HEAD (unpinned)" caption.

**Picks step buried in the configure view.**
The unification work (Library / Git / Path tabs under one SourcePicker)
didn't carry over the multi-step Artifacts → Apply UX from the older
HarnessIncludePicker. Result: picks selector compressed alongside the
plugin header card, command preview, and Apply button on a single
screen — easy to miss and dismiss with the all-pre-selected default.
Restructure `AddIncludeLibraryConfigureView` as a two-step wizard
mirroring the marketplace picker: numbered step indicator, Artifacts
step with full-bleed picks selector + Select All / None affordances,
Apply step with command preview and Back/Apply controls.

**`IncludePluginLookup` only normalized `.git` and case.**
YNH stores include source URLs as `github.com/eyelock/assistants`
(host-prefixed, no scheme) while marketplaces typically store the
canonical `https://github.com/eyelock/assistants` form. They never
matched, so Edit Include's "Picks" section showed "0 of 0 selected —
No artifacts enumerated for this plugin — This source isn't in your
marketplaces" even when the plugin clearly was. Extract a shared
`GitURLNormalizer` that strips scheme, well-known host prefix,
trailing `.git`, trailing slash, and SSH alt form. The same helper
now backs both `IncludeKey.matches` (already-installed detection in
Add Include) and `IncludePluginLookup.find` (picks discovery in Edit
Include). Nine unit tests cover the normalization paths.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(harness): install search spinner + settings ref pin display

Two small UX polish items surfaced during canonical-id testing.

**Install Harness's "Available from Registries" flicker.**
The section flashed "No marketplaces configured. Add one in
Settings → External Sources." before the initial search task fired,
then switched to the spinner, then to the actual list. The empty-
state branch was reached during the brief window where `isSearching`
was still false (search hadn't been kicked off yet) and `results`
was empty. Add a `hasSearched` flag on `HarnessSearchService` that
flips true after the first search task runs. The registry-rows view
now treats "never searched" the same as "actively searching" — the
spinner sticks until we have a real result or error to render.

**Settings → External Sources ref pin display.**
Surface the configured git ref (or "latest (unpinned)") on each
marketplace row. Useful debug context for verifying which version
of a registry the app is reading from when investigating
include/delegate resolution issues. For TermQ-side `Marketplace`
rows the ref comes from the user-supplied pin. For YNH-side
`YNHMarketplace` rows the ref is decoded from `YNHMarketplace.ref`;
displays "latest (unpinned)" until YNH starts emitting that field
in `registry list --format json`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(harness-sidebar): grouping, fork badge, broken-fork display, safe Delete

Four small but visible sidebar polish items, each fixing a UX gap that
canonical-id testing surfaced.

**DEFAULT/LOCAL grouping respects source type.**
The DEFAULT group filter matched solely on `harness.name` against
`KnownHarnesses.defaultNames`. Once a user forked a default-named
harness (e.g. `ynh-dev`), both the original registry install and the
fork share `name == "ynh-dev"`, so both filtered into DEFAULT and the
fork never appeared in LOCAL. Add a source-type gate: a harness only
counts as DEFAULT when its `installedFrom.sourceType` is not "local".
Forks and other locally-sourced harnesses now reach LOCAL even when
they share a name with a known default.

**Forked-to-local rows render distinctly.**
The LOCAL group rendered every entry with a generic "Local" source
badge, so a hand-built local harness and a fork of a registry harness
were visually identical. Forks now render with an `arrow.triangle.branch`
icon and a "Fork of <upstream>" label using the upstream's registry
name (or short git URL). Tooltip carries the full origin description
— registry name, short URL, and version when available.

**Broken-fork state surfaced from the YNH envelope.**
YNH tags pointer-installed forks whose on-disk `.ynh-plugin/plugin.json`
is missing or unreadable with `kind: "local-fork-broken"` and a
`broken_reason`. Decode both fields on `Harness` and surface broken
entries with a red `exclamationmark.octagon.fill` indicator next to
the name, a red "Broken" badge in the row footer, and the
`broken_reason` rendered inline as the caption. Tooltip on the
indicator falls back to a help message pointing the user at right-
click → Uninstall.

**Delete actually deletes.**
The destructive "Delete Harness" action called the same `onUninstall`
path as plain Uninstall, which leaves the source tree in place per
`ynh uninstall`'s contract. The dialog copy explicitly promised
"permanently delete its files. This action cannot be undone." — but
no file deletion ever happened.

Wire a separate `deleteLocalHarness(id:)` lifecycle method that chains
`ynh uninstall <id> && rm -rf <editable-path> && exit` in the
transient terminal. Three layers of defence:
- The sidebar context menu only shows Delete for local-source harnesses
- The coordinator independently refuses if `sourceType != "local"`
- `shellQuote` survives paths with spaces or single quotes

Pure command-builder is exposed for testing — chain order, quoting,
and exit semantics are unit-covered.

Localized to all 39 non-English locales.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* style(harness-source-picker): swiftlint for-where + identifier_name fixes

Trivial style cleanup surfaced by the quality gate after squashing:

- `GitURLNormalizer.normalize` now uses `for x in xs where ...` form
  instead of `for x in xs { if ... { break } }`. Same semantics, one
  less indent level.
- Identifier `s` renamed to `normalized` (swiftlint identifier_name
  minimum-length rule).
- swift-format whitespace fix in AddIncludeContext.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(harness): gate ynh ls error description behind fileLoggingEnabled

The catch arm at HarnessRepository.swift:174 logs the full error
description via `String(describing: error)`. When the error is
`YNHDetectionError.commandFailed`, the description includes stderr —
which is terminal output, classified as user data per logging-rules.
Sending it unconditionally to os.Logger violates the privacy boundary.

Default to type-only logging (no stderr leakage) and gate the full
description behind `TermQLogger.fileLoggingEnabled`, mirroring the
pattern already used in `fetchDetail` at lines 210-214 and 225-229
of the same file.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(harness-migration): refresh quarantine list even when migration applied

ContentView's `runMigrationIfNeeded` short-circuited when the
migration flag was already set, returning before delegating to the
coordinator. The coordinator's own "already applied → just refresh
quarantine" branch was therefore unreachable — the QUARANTINED
sidebar group only updated on the first launch that triggered a
migration, and then went stale forever.

Drop the outer hasApplied check; let the coordinator decide. Its
`runIfNeeded` does the right thing in all branches (calls
`refreshQuarantineList` either standalone when migration is current,
or as the final step after running migrate).

Surface symptom: planting a quarantined entry while TermQ was
running never made the QUARANTINED group appear, even after relaunch
— because the migration flag was already at schema 2 from prior
sessions and the coordinator was never given a chance to re-probe
`ynh quarantine list`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: David Collie <support@eyelock.net>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…umeric filename prefixes, real screenshots (#302)

* docs(help): real screenshots replace placeholders, drop orphans

Replaces the 4×4 placeholder PNGs that shipped with the canonical-id
cutover with real screenshots, and adds the screenshots that tutorials
12 and 14 referenced but never had files for.

**Replaced** (placeholder → real, all compressed via pngquant):
- harness-delegate-editor, harness-fork-button, harness-fork-sheet,
  harness-include-editor, harness-quarantine-group, harness-update-dot

**Added** (was referenced in markdown, no file existed):
- harness-install-browse, harness-install-search-results
- harness-wizard-identity, harness-wizard-progress
- harnesses-author-directory
- marketplace-browser, marketplace-include-picker
- settings-protected-branches

**Removed** (existed but never referenced):
- harness-add-registry, harness-install-sheet-search
- harness-source-badges, harness-worktree-context-menu

All new and replaced PNGs run through `make compress-images` so every
file in `Docs/Help/Images/` is now under 300KB (was up to ~1MB raw).

Image audit after this commit: 71 referenced, 71 on disk, zero broken,
zero orphans.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs(help): drop numeric prefixes from tutorial filenames

Tutorial display order has lived in `_sidebar.md` for a while —
sidebar already shows "8. Git Worktrees" mapped to `12-worktree-sidebar.md`,
"9. Harnesses" mapped to `13-harness-sidebar.md`, etc. The filename
numbers carried no semantic value beyond confusion when reordering.

Drop the `NN-` prefix from every tutorial filename. Display order
continues to live in `_sidebar.md` (and `index.json`), making future
reorganisation a one-file edit instead of a filesystem rename pass
plus 60+ cross-reference updates.

Updated all references across `_sidebar.md`, `index.json`,
`README.md`, `why.md`, `reference/*.md`, and inter-tutorial links.
Bundled help in `Sources/TermQ/Resources/Help/` will sync via
`make copy-help` in a later commit.

No content changes — pure rename + reference update.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs(help): split Marketplace Browser and Harnesses tutorials, with Marketplace as prerequisite

Reorganises the help tutorials so each one focuses on a single
mental model.

**Old shape**: tutorial 13 was "Harnesses" (install / lifecycle / fork)
and tutorial 14 was "Marketplaces & Authoring" — bundling marketplace
browsing with the harness-creation wizard, the include-picker, and
the typical authoring loop. Half of T14 was actually harness-side
content. The narrative had to bounce between two concepts and the
order put the dependent (harnesses) before its dependency
(marketplaces).

**New shape**:
- T9 (was T14, slimmed): **Marketplace Browser** — what marketplaces
  are, default and custom marketplaces, browsing the catalogue,
  marketplaces sidebar context menus.
- T10 (was T13, expanded): **Harnesses** — the entire harness
  lifecycle, including the harness-authoring sections that used to
  live under the marketplace tutorial: adding marketplace plugins to
  a harness, default author directory, the wizard, and the typical
  authoring loop.

Also folded into this commit:

- **Stale Picker text** in the old §13.4 (Installing a harness):
  "three tabs: Search, From Git, Sources" rewritten as the unified
  two-tab Source Picker (Library + Git URL). The Sources subsection
  is removed entirely — sources management moved to Settings →
  External Sources, accessible from the Library tab's gear icon.
- **Library / Git / Path** in §12 corrected to **Library / Git URL**.
- **Settings → Marketplaces** corrected to **Settings → External
  Sources** throughout.
- Section numbering inside each tutorial dropped the leading tutorial
  number (§13.X → §X) to decouple body refs from tutorial position.
- Tutorial titles drop the "Tutorial NN:" prefix — display order
  lives in `_sidebar.md` and `index.json`, and self-contained titles
  survive future reordering without a body-edit pass.

Other touched files:
- `_sidebar.md` and `index.json`: swap order, update display titles
  (Harnesses → Marketplace Browser → Harnesses).
- `README.md`: the Power Workflows section now lists Marketplace
  Browser (T9) and Harnesses (T10) — they were missing entirely.

`Sources/TermQ/Resources/Help/` will sync via `make copy-help` in the
next commit.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: David Collie <support@eyelock.net>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…detail, Run with Focus polish (#304)

* feat(sidebar): Remote PRs panel with Local/Remote toggle and Run with Focus

Adds a Remote tab to the Repositories sidebar panel. When the `gh` CLI
is present and authenticated, the tab lists open GitHub PRs for each repo
with role badges (author / review-requested / assigned / draft / checked-out).

Key capabilities:
- GhCliProbe detects gh presence + auth on startup and app-foreground
- GitHubPRService fetches open PRs via `gh pr list --json` per repo
- SHA-primary + branch-name-fallback PR↔worktree matching
- Checkout PR as new worktree from the Remote tab (gh pr checkout)
- Local tab gains PR-link badge on matched worktrees
- Worktree context menu adds "Run with Focus…", "Open PR on Remote",
  and "Show in Remote" when the worktree is PR-linked
- Remote PR row context menu: Open/Copy URL, Run with Focus (checked out),
  Show in Local, Checkout as Worktree (not checked out)
- Force-push indicator + re-routed "Update from Origin" for PR-linked worktrees
- ReviewWithFocusSheet with harness/profile/focus pickers and command preview
- PruneClosedPRsSheet for worktrees whose PR number is no longer open
- WorktreeSidebarView split into +RemotePRs.swift (file_length limit)

Note: the focus sheet end-to-end depends on feat/harness-detail-editing
landing first (canonical ID fix for ynh run / ynh info).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* refactor(sidebar): rename ReviewWithFocus → RunWithFocus throughout

The sheet label was already "Run with Focus…" but the code still used
Review* naming. Aligns all type names, protocol methods, string keys,
persistence fields, and the file name with the UI copy:

- ReviewWithFocusSheet → RunWithFocusSheet (file renamed)
- ReviewWithFocusContext → RunWithFocusContext
- onReviewWithFocus → onRunWithFocus (callback props)
- reviewWithFocusContext → runWithFocusContext (state var)
- reviewHarness/setReviewHarness → runHarness/setRunHarness
- setReviewFocus → setRunFocus, reviewFocus → runFocus
- repoReviewHarness/repoReviewFocus → repoRunHarness/repoRunFocus
- remote.prs.review.* string keys → remote.prs.run.*

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(run-with-focus): always show Profile row; rename Prompt → Focus prompt

Profile picker now appears even when the harness declares no profiles,
showing a single disabled "Default" entry as a signal that YNH supports
the --profile flag. When profiles exist, the picker shows them (read-only,
driven by the selected focus).

Also renames the prompt section header from "Prompt" to "Focus prompt"
for clarity.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(run-with-focus): always invalidate detail cache on sheet open

HarnessRepository caches ynd compose results per session. The focus
sheet was hitting a stale cache entry when the harness's plugin.json
had been edited since the last fetch — focuses and profiles would not
appear until the app restarted.

Invalidate the specific harness entry before each fetchDetail call in
RunWithFocusSheet so the sheet always reflects the current on-disk
composition. The detail pane's existing cache-on-mutation contract is
unaffected.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(run-with-focus): interactive Profile picker in ad-hoc mode

When no focus is selected the Profile row is now a live picker — the
user can choose any profile declared by the harness, or leave it on
"(harness defaults)". The chosen profile is passed as --profile to
ynh run and shown in the command preview.

When a focus is selected the profile is still locked (derived from the
focus definition) because --focus and --profile are mutually exclusive
in YNH.

Also adds profile: String? to HarnessLaunchConfig and updates
command() to emit --profile when set (and no focus flag is present).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(run-with-focus): show loading spinner while fetching harness detail

Focus and Profile pickers are replaced by a spinner + label while
ynd compose runs, preventing the sheet from appearing in a bare
default state before the data arrives.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(remote-prs): priority feed, per-host login, cap setting, sidebar polish

- Add `updatedAt` to `GitHubPR` for recency ordering
- `GitHubPRService`: per-repo login via `gh api user` (scoped to remote
  host via currentDirectory, cached per repo); `prioritisedFeed` method
  orders PRs in four tiers: checked-out → review-requested → unreviewed
  non-draft → rest, `updatedAt` desc within each tier
- `SettingsStore`: `remotePRFeedCap` (default 20); persisted via UserDefaults
- `LocalYNHConfig`: `repoRemotePRFeedCap` per-repo override
- `YNHPersistence`: `remotePRFeedCap(for:)` / `setRemotePRFeedCap(_:for:)`
- New GitHub Settings tab (`SettingsGitHubView`) with stepper for feed cap
- Remote sidebar: applies priority feed + cap, shows "+N more" footer when
  truncated, hides "Prune Closed PRs" when no closed-PR worktrees exist
- Local sidebar: PR badge moves to trailing edge, is now a Button that
  opens Run with Focus sheet; shows PR title in tooltip
- Branch name button gets `.help()` tooltip showing full branch name

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(run-with-focus): vendor picker + --interactive toggle

- `Vendor`: add `supportsInitialPrompt` decoded from `supports_initial_prompt`
  in `ynh vendors --format json` (defaults false for older YNH)
- `HarnessLaunchConfig`: add `interactive: Bool`; `command()` appends
  `--interactive` when true
- `RunWithFocusSheet`: add vendor picker (same defaultVendorTag pattern as
  HarnessLaunchSheet); add "Stay interactive after run" toggle, shown only
  when the resolved vendor reports `supportsInitialPrompt` and there is a
  prompt or focus to send; toggle auto-clears when switching to a vendor
  that doesn't support it

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(run-with-focus): refresh VendorService on sheet open if vendors not yet loaded

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(remote-prs): context menu restructure, card titles, cache-first detail loading

Restructures the Remote PRs context menu to match the Local panel layout:
Run with Focus at top, Quick Launch Focus submenu pre-populated from cached
harness detail, then terminal/reveal/copy actions, then PR-specific actions,
then Set Default Focus submenu.

Adds card title override (focus: org/repo#N) with 40-char middle-truncation
for long org/repo slugs, built at launch time and surfaced as the terminal
card header rather than the bare branch name.

Harness detail is now cache-first: RunWithFocusSheet reads the in-memory
cache immediately (no fetch), with a manual ⟳ button to force-invalidate.
PR rows pre-warm the cache via .task(id:) so the sheet opens instantly in
the common case. Default focus restore defers via Task { @mainactor } to
survive the onChange(of: selectedHarnessId) clear.

Adds 33 unit tests covering prioritised PR feed ordering/overflow, card
title truncation, and HarnessLaunchConfig command composition.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* docs(remote-prs): Tutorial 15 with compressed screenshots

Adds Help tutorial covering the full Remote PRs feature set: feed overview,
priority ordering, PR rows, context menu, Run with Focus sheet, Quick Launch,
Set Default Focus, and GitHub Settings. Twelve pngquant-compressed screenshots
(~78% size reduction each).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* docs(remote-prs): align tutorial with new slug naming convention

Renames 15-remote-prs.md → remote-prs.md to match the slug-first
convention introduced in #302. Registers the tutorial in _sidebar.md
(Power Workflows section, numbered 11) and index.json.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: David Collie <support@eyelock.net>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
# Conflicts:
#	.claude/skills/release/references/hotfix.md
#	CHANGELOG.md
#	Docs/appcast-beta.xml
#	Sources/TermQ/Services/HarnessRepository.swift
#	Sources/TermQ/Services/MarketplaceStore.swift
#	Sources/TermQ/Services/TerminalLinkResolver.swift
#	Sources/TermQ/Views/ContentView.swift
#	Sources/TermQ/Views/HarnessDetailView.swift
#	Sources/TermQ/Views/Sidebar/HarnessesSidebarTab.swift
#	Sources/TermQ/Views/TerminalHostView.swift
#	Sources/TermQShared/Harness.swift
#	Tests/TermQTests/HarnessRepositoryTests.swift
#	Tests/TermQTests/YNHDecodingTests.swift
@eyelock eyelock merged commit 5b7879f into main May 11, 2026
8 checks passed
@eyelock eyelock deleted the release/v0.10.0 branch May 11, 2026 06:16
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant