97 Commits

Author SHA1 Message Date
Conor 037a37f135 Fix link formatting in README.md 2026-09-13 10:40:42 +01:00
Conor 8b812ac43f Fix XC-MCP link in README
Updated XC-MCP link to include NPM reference.
2026-09-13 10:40:14 +01:00
Conor 9210ee3ff0 docs: fix manual install, which put SKILL.md three levels too deep (#105)
The documented git clone could not work. A skill loads from SKILL.md at the
root of its directory (~/.claude/skills/<name>/SKILL.md), but this repo is a
plugin - the skill lives at ios-simulator-skill/skills/ios-simulator-skill/.
Cloning the whole repo into the skills directory left SKILL.md three levels
down, so the skill never loaded, and dumped tests/, site/ and pyproject.toml
in there as well.

Verified by reproducing it, and by running both replacement methods verbatim
into a scratch skills directory: SKILL.md lands at depth 1 and only
SKILL.md + scripts/ are installed.

- README: replace "Via Git Clone" with a "Manual install" section that
  explains the layout and gives two working methods - the release zip, and a
  clone plus a copy of the inner skill directory for tracking main
- release.yml: also publish an unversioned ios-simulator-skill.zip, so
  /releases/latest/download/ios-simulator-skill.zip is a stable URL the docs
  can point at. The old versioned name is kept, so existing links still work.
  (The previous docs URL 404'd: the only asset was version-stamped.)
- DEV.md: the download URL still had the unfilled YOUR_USERNAME placeholder
  and a hardcoded v1.0.0, and linked to a SKILL.md path that moved in #40.
  Replaced all five placeholder occurrences throughout the file.

The v1.5.0 release has been backfilled with the unversioned asset so the
documented URL works today rather than from the next release.
2026-09-12 08:42:37 +01:00
Conor ee3b42f456 fix(ci): repair release packaging for the plugin directory layout (#104)
Releases have been broken since April. #40 moved the skill into the plugin
layout (ios-simulator-skill/skills/<name>/) the day after v1.4.0 shipped, but
release.yml still validated ios-simulator-skill/SKILL.md and
ios-simulator-skill/scripts. Any tag pushed since would fail at "Validate skill
structure" — which is why v1.4.0 is still the latest release and 1.5.0 has been
sitting unreleased.

- point validation and packaging at the real skill directory, via a single
  SKILL_DIR env var so a future restructure breaks one line instead of four
- also assert plugin.json exists, since the plugin layout is now what ships
- keep the zip's shape identical to v1.4.0's: SKILL.md and scripts/ at the root
  of the archive, which is what the documented
  `unzip skill.zip -d ~/.claude/skills/ios-simulator-skill` expects. Packaging
  the plugin root instead would have nested them a level deeper and quietly
  broken that install path
- print the archive listing in CI so the shape is visible in the log
- exclude config.json, which is runtime state (last used simulator). It is
  untracked so CI was already clean, but a local build would have shipped it
- add a version to plugin.json, which had none, so pyproject.toml, SKILL.md and
  plugin.json now all read 1.5.0

Verified by running the validation and packaging steps locally: the archive
installs to the documented path with SKILL.md at its root, and carries no
xcode_compat shim.
v1.5.0
2026-09-12 08:35:54 +01:00
Conor 6d518e61fc Require idb-companion 1.5.1+ on Xcode 27, drop the shim, and document it (#103)
* fix(xcode27): require idb-companion 1.5.1+ instead of shimming DEVELOPER_DIR

Upstream fixed the Xcode 27 SimulatorKit path in idb-companion 1.5.1
(facebook/idb#953 and the equivalent that landed on main): it now prefers
Contents/SharedFrameworks and falls back to the legacy path. That is the same
thing our DEVELOPER_DIR shim faked from the outside, so the shim is deleted
rather than maintained.

Verified on Xcode 27 / iOS 27.0 with companion 1.5.7 and no shim: reads and HID
writes both work, importing `common` no longer mutates DEVELOPER_DIR, and xcrun
works from inside scripts that import it (the shim used to break every `xcrun
simctl` call in the same process).

The shim never shipped - it is on main but no tagged release contains it - so
this breaks no released user.

sim_health_check.sh now diagnoses the dependency instead:
- idb CLI missing is a failure, not a warning; every tap/swipe/keystroke needs it
- idb-companion is version-checked against a 1.5.1 floor, with the exact
  `brew tap facebook/fb` remediation (it is no longer in Homebrew core)
- new check for the Xcode 27 framework layout and for stale companion entries in
  /tmp/idb/state, which wedge every later idb call with "Connection refused"
- flags and offers to remove the obsolete shim directory
- adds --json, the one script that was missing it despite SKILL.md's claim

Refs #100, #101

* docs: document the real dependency floors and the Xcode 27 traps

The Xcode 27 breakage shipped with no documentation at all, so the 1200+ star
audience had nothing to find. Worse, the documented install was incomplete:
README told users to install idb-companion but never the `idb` CLI, so anyone
following it ended up with a companion and no CLI - and every navigator.py /
gesture.py / keyboard.py call fails.

README:
- prerequisites become a table with real versions (macOS 15+, Xcode 26+,
  Python 3.12+, idb 1.5.1+) instead of "Python 3" and "IDB (optional)"
- idb is stated as required for interactive scripts, installing BOTH the
  companion and the CLI from Meta's facebook/fb tap
- calls out that `brew install idb-companion` no longer works at all, since
  idb-companion was dropped from Homebrew core - the instruction every fork,
  blog post and stale README still carries
- new "Updating idb" and "Xcode 27 notes" sections: the 1.5.1 floor, the
  silent-tap symptom, no more Simulator.app (DeviceHub.app replaced it), and
  the dead-companion `idb disconnect` recovery

SKILL.md (the file installed users actually get) gains matching requirements
and an inline troubleshooting section, so the fix is reachable without the
repo - references/ is not part of the distributed package. Also drops the link
to examples/, which does not exist.

references/troubleshooting.md leads with the Xcode 27 failure modes, and the
pre-existing "IDB not connecting" entry now says its fix does not apply to
them. Replaced `open -a Simulator` and `killall Simulator`, both dead on
Xcode 27.

Also corrected long-standing drift found while auditing: script count was 27
(README) / 21+ (DEV.md) / 22 (site) against an actual 29, and the macOS floor
disagreed between docs.
2026-09-12 08:29:47 +01:00
Conor 4207f82fc2 fix: shim idb-companion's SimulatorKit path for Xcode 27 (#100)
* fix: shim idb-companion's SimulatorKit path for Xcode 27

Xcode 27 moved SimulatorKit.framework from
Contents/Developer/Library/PrivateFrameworks/ to Contents/SharedFrameworks/.
idb-companion <= 1.1.8 still hardcodes the old path, so every `idb ui *`
call (navigator.py --tap, gesture.py, keyboard.py) failed outright with
"SimulatorKit is required for HID interactions" — the accessibility tree
still worked, but nothing could tap, swipe, or type.

common/xcode_compat.py builds a persistent DEVELOPER_DIR shim under
~/.ios-simulator-skill/ (symlinks back into the real Xcode.app, plus one
extra symlink exposing SimulatorKit at the legacy path) and points
DEVELOPER_DIR at it for the current process, so every idb subprocess
inherits the fix. common/__init__.py calls it at import time, so every
script that does `from common import ...` gets it for free with zero
call-site changes.

Backward compatible by construction: the legacy-path existence check
short-circuits on any Xcode where idb-companion already works, so this
is a no-op there. Opt out entirely with IOS_SIM_SKIP_XCODE27_SHIM=1.

* style: apply black formatting
2026-09-11 10:55:51 +01:00
Conor 4a3e93f09f fix(privacy): align service names with simctl under Xcode 27 (#99)
`privacy_manager.py` validated services against its own map, then passed
the name straight to `simctl privacy`. Three entries were never accepted:
`health` and `keyboard` do not exist, and `mediaLibrary` is spelled
`media-library`. All three failed with a bare NSPOSIXErrorDomain code=1
that the caller swallowed, so callers saw a silent no-op.

- Catalogue now mirrors `xcrun simctl help privacy` (verified against
  Xcode 27 CLT); adds `all`, `contacts-limited`, `location-always`,
  `photos-add`
- Legacy camelCase spellings resolve via SERVICE_ALIASES so existing
  callers keep working
- grant/revoke/reset collapsed into one `apply_permission` returning
  (success, message) and surfacing simctl's stderr
- Failures now print to stderr and exit non-zero

All 15 services exercised against a booted iPhone 17 / iOS 27.0.
2026-09-11 10:21:22 +01:00
Conor e0ee87a884 Update README with MCP and XC-MCP information
Added a note about MCP and XC-MCP for users.
2026-06-18 16:29:19 +01:00
Conor d285110cf5 feat(app_launcher): support launch arguments and environment variables (#97)
Add --args (trailing simctl launch arguments) and --env KEY=VALUE
(injected into the app as SIMCTL_CHILD_*) to app_launcher.py. Both are
forwarded through launch() and restart_app(). app_launcher is the only
script that starts an app process, so the change is contained.

Includes unit tests for arg/env passthrough and SKILL.md doc update.
2026-06-14 22:23:51 +01:00
Conor ee346ec4db fix: parse Xcode 16 Swift Testing result counts (was always 0/0) (#95)
build_and_test.py read total/passed/failed from `xcresulttool get test-results
summary`, but Xcode 16 renamed those keys to totalTestCount/passedTests/
failedTests. Every Swift Testing run therefore reported "0/0 passed" regardless
of outcome — silently hiding real passes AND failures.

Now reads the Xcode 16 keys (with a fallback to the legacy names for older
Xcode) and derives duration from start/finishTime. Verified against a real
project: a 7-test suite now reports "Tests: PASS (7/7 passed, …s)" and a
686-test suite reports the true totals.
2026-06-01 18:39:14 +01:00
Conor 28ef1a4818 fix: warn when multiple simulators are booted (#94)
`get_booted_device_udid()` silently returned the first booted simulator.
When more than one is booted (or a stale idb companion lingers), gesture
and tap commands resolve to a device other than the one being watched —
idb reports success on the wrong device, so swipes/scrolls silently no-op
with no error.

Keep the first-match default but print a stderr warning on ambiguity
naming the selected device and advising an explicit --udid. Add
`get_booted_device_udids()` so callers can detect the multi-device case,
plus regression tests covering single/multiple/none/failure listings.
2026-05-28 21:23:53 +01:00
Conor 58e9815fdb feat: --symbolicate via xcrun atos (HangBuster PR 3/3) (#93)
Closes the stack-enrichment loop: with this PR, --get-details --cluster N
--symbolicate rewrites every [0x...] frame in a captured sample/spindump
stack into 'Foo.bar (Foo.swift:42)' notation via atos.

Pipeline (pure):
- extract_stack_addresses(stack) — pulls unique 0x... tokens in order
- symbolicate_stack(stack, resolver) — takes a resolver callable, rewrites
  inline. No I/O. Resolver-injected so it's trivially unit-testable.
  Addresses atos echoes back unchanged are left as-is, never bloated with
  a redundant arrow.

Watcher:
- _run_atos(binary, addrs) batches addresses in one subprocess; failures
  (timeout / non-zero / missing xcrun) return {} so the original stack
  survives untouched.
- _resolve_symbolication_target picks --dsym over --app-binary, falling
  back to IOS_SIM_HANG_DSYM then IOS_SIM_HANG_APP_BINARY env vars.
- _apply_symbolication walks cluster.auto_samples in place; sets
  symbolicated=True on each rewritten sample.

CLI:
- New --symbolicate flag on --get-details
- --app-binary <path> and --dsym <path> with env-var fallbacks
- No worker hot-path impact — symbolication is strictly post-capture

Tests: 11 new units covering address extraction, symbolicate happy path
+ no-op cases, atos subprocess wrapper (success/timeout/non-zero/empty
input), and the target-resolution precedence (flag > env > none).
2026-05-25 09:34:56 +01:00
Conor d46081b8c6 Merge pull request #92 from conorluddy/feat/hangbuster-auto-spindump
feat: --auto-spindump + multi-kind cluster sidecar (HangBuster PR 2/3)
2026-05-25 08:47:58 +01:00
Conor Luddy b3a8be9e54 feat: --auto-spindump + multi-kind cluster sidecar (HangBuster PR 2/3)
Adds spindump capture alongside --auto-sample, and fixes a latent gap
where auto-samples stashed during capture never made it into summary.json.

Storage: read_auto_samples returns dict[str, list[dict]] (write-order
preserved) so a fingerprint can carry both a simctl-sample and a spindump
record. Worker uses parallel dedup sets so each kind fires at most once
per fingerprint.

Pipeline: Cluster gains auto_samples: list[dict] | None alongside the
legacy auto_sample (kept for backward compat with old summary.json
files). SummaryBuilder.build() now takes auto_samples_by_fp and attaches
matching captures to clusters. SessionStore.build_summary passes it
through automatically. format_cluster_detail iterates and renders each
kind with its own label; also fixes a bug where the old format treated
stack as a list when PR 1 made it text — now splits by lines correctly.

CLI: new --auto-spindump flag, parallel to --auto-sample. Updated
--resample to use the new auto_samples list shape.

Tests: 8 new units covering spindump subprocess paths, multi-kind
storage round-trip, SummaryBuilder attachment by fingerprint, multi-
kind format rendering, and legacy auto_sample backward compat.
2026-05-25 08:46:15 +01:00
Conor 3cac6bbdd6 Merge pull request #91 from conorluddy/feat/hangbuster-auto-sample-simctl
feat: --auto-sample via xcrun simctl spawn sample (HangBuster PR 1/3)
2026-05-24 19:49:13 +01:00
Conor Luddy 0f108dc36e feat: implement --auto-sample via xcrun simctl spawn sample
Replaces the soft-imported main_thread_sampler stub (issue #62) with a
direct shell-out to the in-simulator sample binary. On each new hang
fingerprint, the worker captures a 1s main-thread stack and stashes it
on the cluster.

- _attempt_auto_sample now takes (udid, pid) and shells to
  xcrun simctl spawn <udid> sample <pid> 1 -mayDie -file -
- Return shape gains kind/captured_at_ms/symbolicated fields so future
  enrichments (spindump, atos) can share the auto_sample slot
- Threaded udid through _read_stream_into_events and the --resample
  path in get_details
- Help text updated to reflect the simctl backend

Tests: 7 new units mocking subprocess.run for success/timeout/non-zero/
missing-udid/missing-pid/empty-stdout/xcrun-missing paths.
2026-05-24 19:46:58 +01:00
Conor e8f55dc1f6 Merge pull request #90 from conorluddy/feat/hangbuster-review-followups
fix+feat+test: HangBuster review follow-ups + capture-path bugs + raw NDJSON mode (closes #79 #80 #81 #82 #83 #84 #85)
2026-05-23 11:37:14 +01:00
Conor Luddy 86cf938d91 docs: HangBuster — raw-capture quick start, auto-restart + cleanup env vars (#83 #84 #85)
Documents the three new capabilities landed in this PR across all three
contributor entry points:

SKILL.md:
- Expanded hang_watcher entry: raw-capture sub-section (size cap, gzip,
  jq exploration), resilience section (auto-restart + capture duration +
  list-sessions surface), automatic cleanup section (TTL + aggregate cap),
  bundle-id post-parse note.
- Two new env-var rows: IOS_SIM_HANG_MAX_RESTARTS, IOS_SIM_HANG_TOTAL_CAP_MB.
- Updated IOS_SIM_HANG_PREDICATE description to clarify why predicate stays
  simulator-global (so users don't try to AND a process clause).
- Two quick-start blocks: summarised mode (--start/--stop/--get-details) and
  raw-capture mode (jq cookbook with three production-tested queries).

README.md:
- hang_watcher row updated with raw-capture flags + auto-restart + cleanup.
- Predicate env-var description hardened to explain post-parse bundle filter.
- Two new env-var rows for restarts + total cap.

CLAUDE.md (project guidance for contributors):
- hang_watcher.py one-liner expanded with new modes + env vars.
- hang_sessions.py blurb: status state machine, mark_crashed timestamps,
  aggregate cap + raw_path/session_total_bytes accessors, file layout
  includes raw.ndjson.gz + auto_samples.jsonl.

No code changes. 138 tests still pass.
2026-05-23 11:34:44 +01:00
Conor Luddy 3c71ca938f feat: --raw-capture NDJSON mode + size cap + gzip + aggregate prune (closes #85)
The summarised pipeline (parse → bucket → cluster) is the right shape when
you trust the bucketing, but sometimes you want full-fidelity log data and
ad-hoc jq exploration. --raw-capture spawns 'log stream --style ndjson' and
dumps lines verbatim to raw.ndjson, bypassing the clustering pipeline.

Garbage collection is automatic and layered so a year of daily use ≈ a day:
- Per-session size cap (--max-size-mb, default 10) — worker stops cleanly on
  cap hit, writes size_cap_hit marker, sets extras.truncated.
- Gzip on stop (skippable with --no-gzip) — NDJSON compresses ~15-19x in
  practice. Verified live: 0.96 MB raw → 0.05 MB gzipped on Grapla today.
- TTL prune (existing IOS_SIM_HANG_SESSION_TTL_HOURS=24) runs on every
  --start. New aggregate cap IOS_SIM_HANG_TOTAL_CAP_MB (default 100) runs
  alongside, evicting oldest sessions first when total skill state exceeds.

Verified end-to-end on Grapla: a 12s session captured 737 raw NDJSON lines.
'jq -s group_by(.processImagePath)' shows runningboardd=544, SpringBoard=83,
mediaremoted=39, locationd=39, grapla=28. The grapla-process share (4%) is
exactly why the --bundle-id AND clause in #83 was wrong: it filtered out
96% of useful hang signal originating from system daemons.

Implementation:
- New args: --raw-capture, --max-size-mb, --no-gzip; new env var
  IOS_SIM_HANG_TOTAL_CAP_MB. Plumbed through start_args into meta.args.
- run_worker grew a raw-capture branch that reuses the #84 restart/crash
  machinery: spawn log stream --style ndjson, feed to _read_stream_into_raw,
  which dumps lines verbatim and bails on size cap.
- Non-JSON banners (log stream's 'Filtering the log data ...' first line,
  pwuid_r warnings) are skipped so consumers get strict NDJSON for jq.
- _stop_raw_session gzips raw.ndjson → raw.ndjson.gz (via stdlib gzip +
  shutil.copyfileobj), removes the uncompressed file, records bytes in
  meta.extras. No summary.json for raw sessions.
- _mark_truncated sets extras.truncated when the size cap fires.
- SessionStore.session_total_bytes + prune_to_aggregate_cap: sum sizes
  per session, evict oldest first until total ≤ cap.
- --list-sessions surfaces raw size, truncated flag, capture duration.
- --get-details on a raw session points to the file with a zcat | jq hint.

Tests: 7 new (in tests/test_worker_loop.py) — raw line writing, size-cap
trigger, gzip round-trip + content preservation, --no-gzip preserves uncompressed,
aggregate-cap eviction order, session_total_bytes. 131 → 138 passing.
2026-05-23 11:28:38 +01:00
Conor Luddy 073dc67a42 fix+feat: HangBuster worker EOF detect + bounded auto-restart + capture duration (closes #84)
Pre-fix: log stream EOF after 30s of silence broke the read loop, wrote a
stream_ended event, and exited. SessionStore.persist_worker_counters then
overwrote the status back to 'running' — sessions appeared live forever in
--list-sessions even though the worker had been dead for hours. Verified on
Grapla: a 19s session showed counters.total=1, status='running' indefinitely.

Fix split across three concerns:

1. Crash detection (run_worker rewrite):
   - Removed the 30s silence bail. A quiet log stream is not a dead one;
     bailing on silence was a false-positive death signal.
   - EOF on stdin or proc.poll() != None now triggers a stream_died event
     with exit_code and attempt number.
   - try/finally wrapping ensures mark_crashed runs whenever the worker
     exits involuntarily (subprocess died, restart budget exhausted).
   - Inner read loop extracted into _read_stream_into_events for clarity
     and direct testability.

2. Bounded auto-restart:
   - New env var IOS_SIM_HANG_MAX_RESTARTS (default 3). Each restart logs
     a stream_restart event with attempt number and a 2s backoff prevents
     tight respawn loops.
   - counters now tracks stream_restarts; surfaces in summary + --list-sessions.
   - SIGTERM during backoff or read loop exits cleanly without restart.

3. Capture-duration tracking (hang_sessions.py):
   - mark_crashed now also records stopped_at/stopped_at_ms — so crashed
     sessions have a real end-of-capture timestamp, not just a status flip.
   - persist_worker_counters preserves CRASHED status (alongside STOPPED)
     and no longer clobbers it back to RUNNING.
   - build_summary prefers meta.stopped_at_ms over now() so summaries
     reflect the capture window, not the time of inspection.
   - --list-sessions surfaces 'capture=Xs' and 'restarts=N' columns.

Tests: 8 new (5 in tests/test_worker_loop.py, 3 in tests/test_hang_sessions.py).
Fake subprocess.Popen drives EOF/restart/exhaustion scenarios deterministically.
123 → 131 passing.
2026-05-23 11:12:57 +01:00
Conor Luddy f4bcf88826 fix: --bundle-id no longer narrows hang predicate (closes #83)
Pre-fix: passing --bundle-id appended (process == <app>) AND to the os_log
predicate. The default hang predicate matches RunningBoard/SpringBoard/watchdog
events that originate outside the target app's process, so the AND silently
dropped the bulk of useful hang signal. Verified on Grapla: a 4-minute session
with --bundle-id com.conorluddy.grapla captured 0 events while the simulator
was actively logging hangs.

Fix: predicate stays simulator-global; bundle filtering applied post-parse
against the event's process field. Mirrors the existing _matches_bundle path
in the legacy --watch/--since modes; promoted to module-level matches_bundle()
so the HangBuster worker reuses it.

- _resolve_predicate signature simplified: drops bundle_id arg entirely.
  Adding it back would resurrect the bug — locked down by test.
- Worker (run_worker) applies matches_bundle() after parse, before matched++.
- --bundle-id help text updated to clarify it's a post-parse filter.
- HangWatcher._matches_bundle becomes a thin delegator (legacy callers).

Tests: 8 new in tests/test_predicate.py — predicate composition, env var
precedence, signature lockdown, post-parse match behaviour. 115 → 123 passing.
2026-05-23 11:00:29 +01:00
Conor Luddy 63d8ef2312 chore: doc sweep — bump script count to 29, add Device State + Simulator Discovery sections
Documents the new scripts that landed since the last doc pass: appearance.py,
location.py, model_inspector.py, container.py, localization_audit.py,
sim_list.py, simulator_selector.py.

- CLAUDE.md: script count 21/27 → 29; add Device State (2) and Simulator
  Discovery (2) categories; reorganise Testing & Analysis (6 → 9).
- SKILL.md: full 29-script listing with renumbered Lifecycle scripts (23-29).
- marketplace.json, plugin.json: description bumped to 29.
- validate-version.yml: fix SKILL.md path after directory restructure.
- .gitignore: fix local config.json path to match new directory layout.
2026-05-23 10:31:46 +01:00
Conor Luddy 00afc436d6 test: harden HangBuster coverage — crashed-worker, fixture, verdicts
Closes #82.

Three coverage gaps from the PR #78 review, now closed.

The crashed-worker recovery path had no test coverage. Adds:
- test_mark_crashed_sets_status — direct call after create
- test_mark_crashed_after_claim_overrides_running — overrides running state
- test_mark_crashed_silent_on_missing_session — no-op on dead session ID
- test_load_summary_returns_none_when_missing — crashed session has no summary.json

The 50-event fixture in tests/fixtures/sample_session.py only ever produced CRITICAL
events (all duration ≥ 600 ms). L1 hit ~36% of its 200-token budget; L2 only ~8%
of 2000. Token-budget tests trivially passed.

Hardened make_events():
- 12 clusters spanning all 4 Severity tiers (MINOR/WARN/CRITICAL/FROZEN)
- One hot cluster at 18 events (near-max count); others spread across cold tail
- Two processes (MyApp + Helper) → process_distribution populates
- First-cluster events packed tightly → detect_temporal_bursts populates
- 7s gap inserted mid-window → detect_quiet_periods populates
- Long symbol names → per-line formatter stress

After hardening: L0=28/30 (93%), L1=91/200 (46%), L2=298/2000 (15%). L1/L2 are
naturally sparse at default top_n=3; the real win is that all four L2 aggregate
branches (Severity / Bursts / Quiet periods / Processes) now actually render.

Adds qualitative assertions in test_token_budgets.py:
- test_fixture_covers_all_severity_tiers — guard against fixture regression
- test_fixture_has_at_least_ten_clusters
- test_l2_renders_all_aggregate_branches — every L2 branch must populate
- test_l1_floor_is_meaningful — floor at 60 tokens
- test_l2_floor_renders_full_summary — floor at 200 tokens

diff_sessions() produces 5 verdict strings; only 3 were tested. format_diff
was only rendered for 'no change' and version mismatch — the new/resolved/drift
render paths were never asserted.

Adds 6 new in tests/test_diff.py:
- test_diff_verdict_regression_new_minor
- test_diff_verdict_improvement_resolved
- test_diff_verdict_drift_mixed_signs (the bidirectional drift case)
- test_format_diff_renders_new_block
- test_format_diff_renders_resolved_block
- test_format_diff_renders_drift_block

Small helper _summary_with_clusters() builds SessionSummary instances with
controlled per-cluster max_duration_ms — keeps verdict tests isolated from the
event pipeline.

test_diff_detects_resolved_cluster filtered by an old symbol name from the
soft fixture ([ImageDecoder decode:]). Updated to use the new equivalent.

- ruff check: All checks passed
- black --check (scripts): clean
- python -m pytest tests/: 103 passed (was 88)
- pytest tests/: 103 passed
2026-05-23 10:24:44 +01:00
Conor Luddy c8368b11a9 feat: hash compute_fingerprint with sha256, bump FINGERPRINT_VERSION to 2
Closes #80.

compute_fingerprint() previously returned the raw `sym:<symbol>` or
`msg:<message_prefix>` string. Upstream normalisation (normalise_message
collapsing tokens to <addr>/<pid>/<n> placeholders) shrinks the effective
namespace dramatically — distinct hangs with overlapping normalised
prefixes silently clustered together.

Switch to sha256[:16]:

  key = f'sym:{symbol}' if symbol else f'msg:{message_prefix}'
  return f'fp:{hashlib.sha256(key.encode()).hexdigest()[:16]}'

Cluster.symbol_or_prefix already carries the human-readable label —
fingerprint is purely an identity key, so opacity is fine.

## Compat

FINGERPRINT_VERSION bumps from 1 → 2. `diff_sessions` already warns +
skips structural compare on version mismatch, so v1 summaries on disk
are gracefully handled.

## Tests

- test_compute_fingerprint_prefers_symbol: updated for new `fp:` prefix
- test_compute_fingerprint_falls_back_to_prefix: updated, asserts opacity
- test_compute_fingerprint_is_stable_across_calls: new (deterministic guard)
- test_compute_fingerprint_avoids_overlap_collision: new (the issue's
  motivating case — distinct messages sharing a 40-char head must not collide)
- test_build_normalised_event_full: `sym:` → `fp:` prefix update

Verification: ruff/black clean; 90 passed.
2026-05-23 10:23:08 +01:00
Conor Luddy 1f98d4c51c fix: handle zero-duration clusters in diff_sessions()
Closes #79.

The divide-by-zero guard in diff_sessions() used a bare `continue`,
silently dropping clusters whose A-side max_duration_ms was 0. A real
regression where a previously-silent cluster started hanging (0 → N ms)
was lost from the diff entirely.

Replace with explicit 4-way branching:

- both zero      → stable
- A=0, B>0       → drift, delta_pct = +inf (treated as worsened)
- A>0, B=0       → drift, delta_pct = -100.0 (fully improved)
- both nonzero   → existing percentage logic, unchanged

format_diff() renders the inf delta as `new` instead of `+inf%` for
readability.

Tests: 5 new in tests/test_diff.py covering each branch + a regression
guard that the standard nonzero path still uses the drift threshold,
plus format_diff render assertion for the inf case.

Verification: ruff/black clean; 93 passed (88 → 93).
2026-05-23 10:23:08 +01:00
Conor Luddy 03e092d543 fix: append-only JSONL for auto_samples to remove read-modify-write race
Closes #81.

The worker's auto-sample stash previously wrote auto_samples.json as a
read-modify-write of an aggregate dict, with no locking. Under
concurrent stashes (busy worker, many distinct fingerprints) one stash
could clobber another's writes.

Switch to auto_samples.jsonl: append-only, one record per line, fsync on
each write. Mirrors the events.jsonl discipline.

- SessionStore.stash_auto_sample(session_id, fingerprint, sample) — appends
- SessionStore.read_auto_samples(session_id) — reduces last-write-wins per fp
- HangBuster._stash_auto_sample now delegates to the store; no local I/O

Tests: 5 new in tests/test_hang_sessions.py covering basic append, last-write-wins,
missing-file, 50-thread concurrent stash, and corrupt-line skip.

Verification: ruff/black clean; 93 passed (88 → 93).
2026-05-23 10:23:08 +01:00
Conor c6c0ef8105 feat: HangBuster — session-scoped recorder with progressive disclosure (#77) (#78)
* feat: HangBuster session mode for hang_watcher (#77)

Evolves hang_watcher.py from a passive `tail -f`-style log stream into an
agent-native session recorder with progressive disclosure. Default --stop
output is ~80-120 tokens (vs ~3-5k for v1 raw streaming) with explicit
drill paths to per-cluster detail.

Architecture: pure-function filter pipeline in common/hang_pipeline.py
(parse → normalise → threshold → bucket → cluster → aggregate → rank →
format). Session storage in common/hang_sessions.py with its own dir
layout (~/.ios-simulator-skill/sessions/<id>/{meta.json, events.jsonl,
summary.json}); 4-hex session-id suffix avoids same-second collisions.

New CLI surface (HangBuster):
- --start: detached worker via Popen(start_new_session=True); worker writes
  its own pid into meta.json (no pidfile race), opens events.jsonl line-
  buffered, applies normalise+threshold+bucket inline, select-based
  readline timeout with stream_ended sentinel.
- --stop / --get-details [--cluster N | --raw] / --list-sessions /
  --clear-sessions [--older-than DURATION] / --diff A B
- --budget-tokens picks the densest level (L0/L1/L2) that fits via char/4
  estimator; --terse forces L0.
- --auto-sample soft-imports main_thread_sampler (#62) with debounce
  (one sample per cluster per session); graceful no-op if missing.
- --diff guards against fingerprint_version mismatch and emits a verdict
  line (regression / improvement / drift / no change) for CI.

Backward compat: HangWatcher class + --watch / --since unchanged. Stateless
parse helpers extracted to common/hang_pipeline.py; HangWatcher delegates.

New env vars (all via env_int): IOS_SIM_HANG_MIN_MS (default 250),
IOS_SIM_HANG_SESSION_TTL_HOURS (24), IOS_SIM_HANG_DEFAULT_TOP_N (3),
IOS_SIM_HANG_BUDGET_TOKENS (unset).

Tests: scaffolds pytest infrastructure (pyproject.toml + tests/conftest.py
+ pythonpath config) since the repo had none. 88 tests across pipeline
(51), sessions (23), token budgets (8), diff (6). Token-budget contract
asserted: 50-event session emits ≤200 tokens at L1, ≤2000 tokens at L2/L3.

Docs: SKILL.md and README updated with HangBuster section + env vars.

Closes #77

* docs: update CLAUDE.md with HangBuster + new shared utilities

- Add hang_watcher.py + common/hang_pipeline.py + common/hang_sessions.py
  to the project structure tree
- Add hang_watcher.py to Testing & Analysis category (now 6 scripts)
- Document hang_pipeline.py + hang_sessions.py under Shared Utilities
- Update Key Statistics: 7 shared utilities (was 6), reference 88 tests

* test: manual smoke script for HangBuster lifecycle

Walks through help / empty session / real session / drill (L2 + L3) /
budget collapse / JSON output / diff / list+clear / legacy --watch in
one script. Skips simulator-dependent steps when none is booted.

Verified on macOS 26 with a booted iPhone simulator: 21 checks pass.

Usage:
  tests/manual/hangbuster_smoke.sh                  # use booted simulator
  tests/manual/hangbuster_smoke.sh "iPhone 15"    # boots a device first
  IOS_SIM_HANG_MIN_MS=100 tests/manual/hangbuster_smoke.sh

* fix: address PR #78 review — blockers, atomicity, and code hygiene

Blockers:
- hang_watcher.py: `--top N` ternary was inverted; `--top 5` silently
  returned all clusters. Drop the ternary and always slice.
- pyproject.toml: add "." to pytest pythonpath so bare `pytest tests/`
  resolves `tests.fixtures.*` imports (previously only worked via
  `python -m pytest`).

Atomicity / boundary fixes:
- hang_sessions.py: fsync before os.replace in _write_meta and
  _write_summary — atomic rename does not imply durability.
- hang_sessions.py: new SessionStore.persist_worker_counters() public
  method; HangBuster.run_worker now calls it instead of reaching into
  the private _write_meta. The new method re-reads meta so a concurrent
  stop() that already set status=stopped is not clobbered to running.
- hang_sessions.py: parse JSON for stream_ended sentinel detection
  instead of substring match (avoids false-positives on any message
  containing the literal 'stream_ended').

Code hygiene:
- hang_pipeline.py: cluster_to_json / event_to_jsonl drop redundant
  Severity.value overwrites — asdict() leaves StrEnum members in place
  and json.dumps serialises them as strings (StrEnum is a str subclass).
- hang_pipeline.py: distinct CRITICAL icon (‼) so format_l1 no longer
  uses the same ⚠ for both WARN and CRITICAL.
- hang_watcher.py: delete duplicated HangWatcher._resolve_predicate
  method; route the two call sites to the module-level function that
  the worker already uses.

Verification:
- ruff check, black --check: clean
- pytest tests/ and python -m pytest tests/: 88 passed

* chore: simplify marketplace manifest

- Move description to top-level field (was nested under metadata)
- Drop pinned version from plugin.json so the git commit SHA
  becomes the version, giving users updates on every push
2026-05-23 10:08:32 +01:00
Conor 64fa06b9db chore: SKILL.md + README sweep for v2.0 wave 1 (#76)
* chore: SKILL.md TOC sweep for v2.0 wave 1 (#54 #55 #57 #60 #68)

* chore: README script tables + script count for v2.0 wave 1

- Bump '22 scripts' → '27 scripts' (3 references)
- Add new 'Device State' subsection: appearance.py, location.py
- Add container.py, hang_watcher.py, localization_audit.py under Testing & Analysis

Folds README updates into the same chore PR as the SKILL.md sweep.
Refs #54 #55 #57 #60 #68

* chore(#76): bump CLAUDE.md script count, align hang_watcher description

Review minor findings: CLAUDE.md still said '22 scripts' in two places; README
and SKILL.md had drifted descriptions for hang_watcher.py; normalised flag
style for location.py.

* chore: add IOS_SIM_HANG_PREDICATE row to Configuration tables

Deferred from the original wave-1 sweep because the Configuration tables
landed in v1.5 (PR #53). Adding now that #53 has merged so the env var
introduced by hang_watcher.py (#75) is documented in both README and SKILL.md.
2026-05-22 16:41:47 +01:00
Conor 45cf7e8811 feat: hang_watcher.py — os_log hang stream (#75)
* feat: hang_watcher.py — os_log hang stream (#60)

* fix(#75): explicit predicate parens, --since validation, bundle-id robustness

Two blockers: NSPredicate operator-precedence ambiguity (now explicitly parenthesised
to match intent) and unhandled ValueError on bad --since input. Plus bundle-id
matching narrowed via /.app/ path segment, subprocess teardown on duration exit,
suppress summary in --watch mode.
2026-05-22 16:39:36 +01:00
Conor 7f66f9d92b feat: container.py — app sandbox + UserDefaults + Core Data (#74)
* feat: container.py — app sandbox + UserDefaults + Core Data (#57)

* fix(#74): SECURITY — block path traversal in --cat/--ls + Decimal handling

Blocker: --cat/--ls accepted relative paths that escaped the app sandbox
(confirmed exploitable with ../../../etc/passwd). Adds is_relative_to guard.
Also fixes Decimal crash in _make_json_serializable, makes cache threshold
env-configurable, fixes --ls indent off-by-one.
2026-05-22 16:39:32 +01:00
Conor bf464bf3a9 feat: localization_audit.py — string catalog gaps (#73)
* feat: localization_audit.py — string catalog gaps (#68)

* fix(#73): handle 'stale' state, fix %lld regex, surface .strings parse errors

Review blockers: 'stale' xcstrings state was silently dropped; %lld and other
length-modified specifiers weren't matched. Plus minor doc/error cleanup.
2026-05-22 16:39:29 +01:00
Conor 5cb07a5bcc feat: location.py — GPS + GPX route playback (#72)
* feat: location.py — GPS + GPX route playback (#54)

* fix(#72): --lng/--lat pairing, list_scenarios error surfacing, add --distance

Review findings: --lng alone silently cleared location; list_scenarios swallowed
errors; simctl --distance was unexposed.
2026-05-22 16:39:26 +01:00
Conor 8c5140e55a feat: appearance.py — dark mode / dynamic type / locale (#71)
* feat: appearance.py — dark mode / dynamic type / locale (#55)

* fix(#71): drop dead booted-fallback, remove unused json-builder, guard --reset

Review findings: dead code in _device fallback and _build_json_output();
--reset was silently winning over explicit flags.
2026-05-22 16:39:23 +01:00
Conor e7829ba3ec feat: configurable limits and bumped defaults (v1.5) (#53)
* feat: configurable limits and bumped defaults for v1.5

Audit-driven pass over baked-in constants chosen when the project was
small. With 1000+ users now hitting slow CI runners, monorepo builds,
and accessibility audits on complex screens, several defaults were
silently truncating diagnostics or timing out.

Strategy: bump conservative defaults AND expose env-var overrides for
the highest-traffic knobs. No new CLI flags. 23 IOS_SIM_* variables
documented in SKILL.md and README.md.

Highlights
- Boot timeout 120s → 300s (slow CI was the top reported failure)
- Erase timeout 30s → 90s
- Log JSON cap 20 → 100 errors; log tail 50 → 200 lines
- Build verbose cap 10 → 100
- Navigator tappables 10 → 25; screen_mapper buttons 5 → 15
- Accessibility top issues 3 → 10; AXLabel truncation 30 → 80 chars
- ProgressiveCache: added max_entries (default 500) with LRU eviction
  to prevent unbounded cache growth in long-running CI

New: common/env_config.py — tiny env_int/env_float helpers with
parse-error fallback and minimum-value clamping.

Lint: black + ruff clean. All touched modules import cleanly. LRU
eviction verified with a 15-write, 10-entry temp cache.

* docs: full env var reference with tradeoffs in README

Replace the SKILL.md pointer with the complete table, grouped by
category (lifecycle, build, log, UI, a11y, cache) and annotated with
the practical tradeoff for each knob. Adds a session-export pattern
and notes the parse-error fallback behaviour.

* feat: env-overridable timeouts for xcodebuild build/test/introspect

Audit-followup. The original v1.5 pass missed xcodebuild itself — a
hung build/test subprocess would block indefinitely with no kill.

- IOS_SIM_BUILD_TIMEOUT=1800 (30 min) on xcodebuild build
- IOS_SIM_TEST_TIMEOUT=2700 (45 min) on xcodebuild test
- IOS_SIM_INTROSPECT_TIMEOUT=60 on xcodebuild -list and simctl list

Defaults are generous enough to survive clean builds of large monorepo
apps but bounded so hangs are recoverable. TimeoutExpired falls through
to the existing 'Error executing build/test' branch with the kill
message attached.

Docs updated in SKILL.md + README.md. Lint clean. Code↔README env-var
diff is empty.

* fix: use configured caps in 'more' tails; clamp poll interval floor

- app_launcher/navigator/screen_mapper: '... N more' counts now reflect
  the configured slice cap instead of stale literals (10/5/3).
- simctl_boot/erase: IOS_SIM_POLL_INTERVAL floored at 0.05s to prevent
  busy-loop on misconfig.
2026-05-22 16:37:59 +01:00
Conor fc59c88ccd chore: update dev tooling and fix stale lint path globs (#52)
- Bump Black 24.10.0 → 26.5.1
- Bump Ruff 0.7.4 → 0.15.14
- Pin CI to match pre-commit versions (was unpinned, causing drift)
- Fix pre-commit file glob (was ^skill/scripts/, matched zero files)
- Fix ruff include in pyproject.toml (same stale path)

Black reports no formatting changes needed. Ruff passes clean
on the (now-correctly-matched) script tree.
2026-05-22 12:51:42 +01:00
Conor 3acd0717a1 fix: remove invalid 'skills' field from plugin.json (#51)
Claude Code's plugin schema rejects a string value for 'skills'.
Skills are auto-discovered from the 'skills/' directory, so this
field can simply be removed.

Fixes #50
2026-05-07 18:51:41 +01:00
Conor cf583d1c35 fix: enforce Python 3.12+ in health check, bump to v1.4.2 (#47)
Fixes #42 — the minimum Python version was already declared in
pyproject.toml (requires-python = ">=3.12") but not surfaced to
users running scripts directly.

sim_health_check.sh now parses the installed Python version and fails
with an actionable message if < 3.12, rather than letting scripts
crash with a cryptic TypeError on PEP 604 union syntax.
2026-05-03 15:50:05 +01:00
Conor 2003d16e97 fix: add skills field to plugin.json and bump to v1.4.1 (#46)
Fixes #44 — Claude Code's path validator rejected the old `"skills": ["./"]`
declaration. The skills/ directory structure was already correct; the
plugin.json was simply missing the skills field.

Added `"skills": "skills"` following the pattern used by other working
plugins (e.g. vercel). The skill now loads as ios-simulator-skill:ios-simulator-skill.
2026-05-03 15:44:33 +01:00
Conor 8b686edc37 Add GitHub Pages landing site (#45)
Single-page landing at /site/ with TE-inspired aesthetic
(navy/yellow palette, Inter + Azeret Mono, numbered sections).

Adds .github/workflows/pages.yml — deploys site/ to GitHub Pages
on push to main using the official actions/configure-pages,
upload-pages-artifact, and deploy-pages actions.

After merge: enable Pages via Settings -> Pages -> Source:
GitHub Actions to trigger first deploy.
2026-04-30 07:57:30 +01:00
Conor 28a6e11b0f Fix plugin structure: move skills into skills/<name>/ directory (#40)
* Fix plugin structure: move skills into skills/<name>/ directory

The plugin.json had an invalid "skills": ["./"] field that caused a
"Path escapes plugin directory" error on load. The correct plugin
structure (matching the official plugin spec) requires skills to live
under skills/<skill-name>/ with SKILL.md at that path — no "skills"
field in plugin.json needed.

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

* Fix lint workflow paths after skills/ restructure

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-30 07:22:49 +01:00
Conor 21a386904e Bump version to 1.4.0 (#39)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
v1.4.0
2026-04-12 15:30:04 +01:00
Conor d6d57b41d8 Merge pull request #38 from conorluddy/feat/model-inspector
Add Core Data / SwiftData model inspector
2026-04-12 15:27:10 +01:00
Conor Luddy dbc7bcf0b8 Add script reference tables to README
Replaces the flat list with per-category tables showing each script's
purpose and key flags. Scannable at a glance, detailed enough to
know which script to reach for.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 15:26:32 +01:00
Conor Luddy aa7dcbf785 Add --raw flag for full model source dump
Progressive disclosure: summary first, then drill down with
--raw ModelName to get the complete Swift source or Core Data XML
for a specific model. Useful when the regex extraction isn't
sufficient and the agent needs the unprocessed source.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 15:23:01 +01:00
Conor Luddy cbfff2eae6 Fix SwiftData extraction: skip dotdirs, strip comments, deduplicate
- Skip all dot-prefixed directories (catches .archived, .build, .git)
  instead of hardcoded list
- Strip inline // comments from property type extraction
- Fix @Relationship lookbehind to check the previous line, preventing
  duplicate entries for relationship-annotated properties

Tested against Grapla (38→20 models) and AftersetApp (14 models).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 15:19:59 +01:00
Conor Luddy 4e2bdd86a1 Add Core Data / SwiftData model inspector (#33)
New script that parses .xcdatamodeld packages and @Model declarations
from project source files. Zero external deps — uses stdlib xml and
regex. Supports default/verbose/JSON output with progressive disclosure.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 15:14:14 +01:00
Conor a046111b2f Merge pull request #31 from conorluddy/feat/plugin-marketplace
Add plugin marketplace for streamlined installation
2026-04-12 13:34:06 +01:00
Conor Luddy 40fd0c6729 Rewrite README with clearer structure and updated install docs
Restructured around: intro, xcodebuild vs simulator distinction,
plugin marketplace install, and feature highlights (progressive
build disclosure, accessibility navigation, token optimization).
Removed stale content, hardcoded paths, and nonexistent references.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 13:31:53 +01:00
Conor Luddy 84c115654f Add skills path to plugin.json for SKILL.md discovery
The SKILL.md lives at the plugin root rather than in a skills/
subdirectory, so we need to explicitly declare "skills": ["./"]
for the plugin system to discover it.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 13:26:19 +01:00
Conor Luddy 04ae43e964 Add plugin marketplace for streamlined installation
Converts the repo into a Claude Code plugin marketplace so users can
install via `/plugin marketplace add` instead of manual git clone.
Adds marketplace.json and plugin.json, updates README with new
install instructions.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 13:24:13 +01:00