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