@leejungkiin/awkit 1.7.0 → 1.7.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (241) hide show
  1. package/bin/awk.js +576 -84
  2. package/core/CLAUDE.md +1 -1
  3. package/core/GEMINI.md +148 -167
  4. package/core/GEMINI.md.bak +149 -116
  5. package/core/skill-runtime-manifest.json +3 -0
  6. package/docs/Claude Fable 5.md +3826 -0
  7. package/docs/android_kotlin_system_instruction.md +210 -0
  8. package/docs/brainstorm_ponytail_integration.md +146 -0
  9. package/docs/brainstorm_smart_setup.md +113 -0
  10. package/docs/deep-research-report (1).md +293 -0
  11. package/docs/history/GEMINI.v1.md +135 -0
  12. package/docs/history/brainstorm_antigravity_unified_architecture.v1.md +105 -0
  13. package/docs/history/implementation_plan.v1.md +58 -0
  14. package/package.json +4 -1
  15. package/scripts/artifact-storage.js +130 -0
  16. package/scripts/automation-gate.js +40 -7
  17. package/scripts/claude-plan.js +76 -0
  18. package/scripts/dependency-manager.js +210 -0
  19. package/scripts/exec-rtk.js +11 -5
  20. package/scripts/i18n-helper.js +381 -0
  21. package/scripts/multi-model-pipeline.js +144 -0
  22. package/skill-packs/mobile-ios/pack.json +4 -2
  23. package/skill-packs/reverse-engineering/pack.json +1 -0
  24. package/skills/CATALOG.md +20 -0
  25. package/skills/GEMINI.md +9 -1
  26. package/skills/TRIGGER_INDEX.md +10 -0
  27. package/skills/ai-music/SKILL.md +275 -0
  28. package/skills/android-re-analyzer/SKILL.md +238 -0
  29. package/skills/android-re-analyzer/references/api-extraction-patterns.md +119 -0
  30. package/skills/android-re-analyzer/references/call-flow-analysis.md +176 -0
  31. package/skills/android-re-analyzer/references/fernflower-usage.md +115 -0
  32. package/skills/android-re-analyzer/references/jadx-usage.md +116 -0
  33. package/skills/android-re-analyzer/references/setup-guide.md +221 -0
  34. package/skills/android-re-analyzer/scripts/check-deps.sh +129 -0
  35. package/skills/android-re-analyzer/scripts/decompile.sh +375 -0
  36. package/skills/android-re-analyzer/scripts/find-api-calls.sh +118 -0
  37. package/skills/android-re-analyzer/scripts/install-dep.sh +448 -0
  38. package/skills/animal-island-ui-style/SKILL.md +1450 -0
  39. package/skills/app-store-review-agent/SKILL.md +164 -0
  40. package/skills/app-store-review-agent/references/guidelines/README.md +154 -0
  41. package/skills/app-store-review-agent/references/guidelines/by-app-type/ai_apps.md +37 -0
  42. package/skills/app-store-review-agent/references/guidelines/by-app-type/all_apps.md +50 -0
  43. package/skills/app-store-review-agent/references/guidelines/by-app-type/crypto_finance.md +31 -0
  44. package/skills/app-store-review-agent/references/guidelines/by-app-type/games.md +31 -0
  45. package/skills/app-store-review-agent/references/guidelines/by-app-type/health_fitness.md +31 -0
  46. package/skills/app-store-review-agent/references/guidelines/by-app-type/kids.md +27 -0
  47. package/skills/app-store-review-agent/references/guidelines/by-app-type/macos.md +38 -0
  48. package/skills/app-store-review-agent/references/guidelines/by-app-type/social_ugc.md +32 -0
  49. package/skills/app-store-review-agent/references/guidelines/by-app-type/subscription_iap.md +34 -0
  50. package/skills/app-store-review-agent/references/guidelines/by-app-type/vpn.md +18 -0
  51. package/skills/app-store-review-agent/references/rules/design/minimum_functionality.md +96 -0
  52. package/skills/app-store-review-agent/references/rules/design/sign_in_with_apple.md +54 -0
  53. package/skills/app-store-review-agent/references/rules/entitlements/unused_entitlements.md +83 -0
  54. package/skills/app-store-review-agent/references/rules/metadata/accurate_metadata.md +54 -0
  55. package/skills/app-store-review-agent/references/rules/metadata/apple_trademark.md +99 -0
  56. package/skills/app-store-review-agent/references/rules/metadata/china_storefront.md +72 -0
  57. package/skills/app-store-review-agent/references/rules/metadata/competitor_terms.md +56 -0
  58. package/skills/app-store-review-agent/references/rules/metadata/subscription_metadata.md +81 -0
  59. package/skills/app-store-review-agent/references/rules/privacy/privacy_manifest.md +84 -0
  60. package/skills/app-store-review-agent/references/rules/privacy/unnecessary_data.md +60 -0
  61. package/skills/app-store-review-agent/references/rules/subscription/misleading_pricing.md +63 -0
  62. package/skills/app-store-review-agent/references/rules/subscription/missing_tos_pp.md +54 -0
  63. package/skills/awf-ponytail/SKILL.md +91 -0
  64. package/skills/awf-ponytail-review/SKILL.md +67 -0
  65. package/skills/awf-session-restore/SKILL.md +3 -3
  66. package/skills/brainstorm-agent/SKILL.md +11 -2
  67. package/skills/brainstorm-agent/templates/brief-template.md +8 -0
  68. package/skills/claude-planner/SKILL.md +47 -0
  69. package/skills/code-review/SKILL.md +87 -0
  70. package/skills/expo-game-development/SKILL.md +163 -0
  71. package/skills/flutter/LICENSE.txt +202 -0
  72. package/skills/flutter/SKILL.md +127 -0
  73. package/skills/flutter-project-creater/LICENSE.txt +202 -0
  74. package/skills/flutter-project-creater/SKILL.md +106 -0
  75. package/skills/game-developer/SKILL.md +163 -0
  76. package/skills/game-developer/references/ecs-patterns.md +501 -0
  77. package/skills/game-developer/references/multiplayer-networking.md +475 -0
  78. package/skills/game-developer/references/performance-optimization.md +422 -0
  79. package/skills/game-developer/references/unity-patterns.md +271 -0
  80. package/skills/game-developer/references/unreal-cpp.md +352 -0
  81. package/skills/generate-gui-assets/SKILL.md +305 -0
  82. package/skills/generate-gui-assets/agents/openai.yaml +4 -0
  83. package/skills/generate-gui-assets/references/catalog-schema.md +58 -0
  84. package/skills/generate-gui-assets/references/extraction-techniques.md +21 -0
  85. package/skills/generate-gui-assets/references/prompt-patterns.md +58 -0
  86. package/skills/generate-gui-assets/scripts/__pycache__/clean_chroma_edges.cpython-311.pyc +0 -0
  87. package/skills/generate-gui-assets/scripts/build_gui_contact_sheet.py +51 -0
  88. package/skills/generate-gui-assets/scripts/clean_chroma_edges.py +262 -0
  89. package/skills/generate-gui-assets/scripts/copy_approved_icons.py +64 -0
  90. package/skills/generate-gui-assets/scripts/prepare_gui_asset_run.py +91 -0
  91. package/skills/generate-gui-assets/scripts/suggest_grid_options.py +63 -0
  92. package/skills/generate-gui-assets/scripts/validate_gui_catalog.py +50 -0
  93. package/skills/godot-game-development/SKILL.md +142 -0
  94. package/skills/hatch-pet/LICENSE.txt +201 -0
  95. package/skills/hatch-pet/SKILL.md +420 -0
  96. package/skills/hatch-pet/agents/openai.yaml +4 -0
  97. package/skills/hatch-pet/references/animation-rows.md +29 -0
  98. package/skills/hatch-pet/references/codex-pet-contract.md +35 -0
  99. package/skills/hatch-pet/references/qa-rubric.md +60 -0
  100. package/skills/hatch-pet/scripts/__pycache__/clean_chroma_edges.cpython-311.pyc +0 -0
  101. package/skills/hatch-pet/scripts/clean_chroma_edges.py +262 -0
  102. package/skills/hatch-pet/scripts/compose_atlas.py +150 -0
  103. package/skills/hatch-pet/scripts/derive_running_left_from_running_right.py +143 -0
  104. package/skills/hatch-pet/scripts/extract_strip_frames.py +323 -0
  105. package/skills/hatch-pet/scripts/finalize_pet_run.py +382 -0
  106. package/skills/hatch-pet/scripts/generate_pet_images.py +287 -0
  107. package/skills/hatch-pet/scripts/inspect_frames.py +246 -0
  108. package/skills/hatch-pet/scripts/make_contact_sheet.py +96 -0
  109. package/skills/hatch-pet/scripts/package_custom_pet.py +108 -0
  110. package/skills/hatch-pet/scripts/pet_job_status.py +117 -0
  111. package/skills/hatch-pet/scripts/prepare_pet_run.py +673 -0
  112. package/skills/hatch-pet/scripts/queue_pet_repairs.py +172 -0
  113. package/skills/hatch-pet/scripts/record_imagegen_result.py +250 -0
  114. package/skills/hatch-pet/scripts/render_animation_videos.py +134 -0
  115. package/skills/hatch-pet/scripts/render_animation_videos.sh +5 -0
  116. package/skills/hatch-pet/scripts/validate_atlas.py +139 -0
  117. package/skills/i18n-orchestrator/SKILL.md +37 -0
  118. package/skills/ios-simulator-skill/SKILL.md +390 -0
  119. package/skills/ios-simulator-skill/scripts/accessibility_audit.py +300 -0
  120. package/skills/ios-simulator-skill/scripts/app_launcher.py +326 -0
  121. package/skills/ios-simulator-skill/scripts/app_state_capture.py +400 -0
  122. package/skills/ios-simulator-skill/scripts/appearance.py +385 -0
  123. package/skills/ios-simulator-skill/scripts/build_and_test.py +348 -0
  124. package/skills/ios-simulator-skill/scripts/clipboard.py +103 -0
  125. package/skills/ios-simulator-skill/scripts/common/__init__.py +61 -0
  126. package/skills/ios-simulator-skill/scripts/common/cache_utils.py +289 -0
  127. package/skills/ios-simulator-skill/scripts/common/device_utils.py +462 -0
  128. package/skills/ios-simulator-skill/scripts/common/env_config.py +35 -0
  129. package/skills/ios-simulator-skill/scripts/common/hang_pipeline.py +862 -0
  130. package/skills/ios-simulator-skill/scripts/common/hang_sessions.py +490 -0
  131. package/skills/ios-simulator-skill/scripts/common/idb_utils.py +180 -0
  132. package/skills/ios-simulator-skill/scripts/common/screenshot_utils.py +338 -0
  133. package/skills/ios-simulator-skill/scripts/container.py +668 -0
  134. package/skills/ios-simulator-skill/scripts/gesture.py +394 -0
  135. package/skills/ios-simulator-skill/scripts/hang_watcher.py +1533 -0
  136. package/skills/ios-simulator-skill/scripts/keyboard.py +391 -0
  137. package/skills/ios-simulator-skill/scripts/localization_audit.py +483 -0
  138. package/skills/ios-simulator-skill/scripts/location.py +467 -0
  139. package/skills/ios-simulator-skill/scripts/log_monitor.py +493 -0
  140. package/skills/ios-simulator-skill/scripts/model_inspector.py +645 -0
  141. package/skills/ios-simulator-skill/scripts/navigator.py +461 -0
  142. package/skills/ios-simulator-skill/scripts/privacy_manager.py +310 -0
  143. package/skills/ios-simulator-skill/scripts/push_notification.py +240 -0
  144. package/skills/ios-simulator-skill/scripts/screen_mapper.py +296 -0
  145. package/skills/ios-simulator-skill/scripts/sim_health_check.sh +245 -0
  146. package/skills/ios-simulator-skill/scripts/sim_list.py +299 -0
  147. package/skills/ios-simulator-skill/scripts/simctl_boot.py +312 -0
  148. package/skills/ios-simulator-skill/scripts/simctl_create.py +316 -0
  149. package/skills/ios-simulator-skill/scripts/simctl_delete.py +357 -0
  150. package/skills/ios-simulator-skill/scripts/simctl_erase.py +351 -0
  151. package/skills/ios-simulator-skill/scripts/simctl_shutdown.py +290 -0
  152. package/skills/ios-simulator-skill/scripts/simulator_selector.py +375 -0
  153. package/skills/ios-simulator-skill/scripts/status_bar.py +250 -0
  154. package/skills/ios-simulator-skill/scripts/test_recorder.py +323 -0
  155. package/skills/ios-simulator-skill/scripts/visual_diff.py +235 -0
  156. package/skills/ios-simulator-skill/scripts/xcode/__init__.py +13 -0
  157. package/skills/ios-simulator-skill/scripts/xcode/builder.py +397 -0
  158. package/skills/ios-simulator-skill/scripts/xcode/cache.py +204 -0
  159. package/skills/ios-simulator-skill/scripts/xcode/config.py +178 -0
  160. package/skills/ios-simulator-skill/scripts/xcode/reporter.py +343 -0
  161. package/skills/ios-simulator-skill/scripts/xcode/xcresult.py +451 -0
  162. package/skills/ios-visual-qa-strategist/SKILL.md +111 -0
  163. package/skills/ios-visual-qa-strategist/agents/openai.yaml +4 -0
  164. package/skills/ios-visual-qa-strategist/references/ios-tool-selection.md +61 -0
  165. package/skills/ios-visual-qa-strategist/references/minimal-capture-policy.md +56 -0
  166. package/skills/ios-visual-qa-strategist/references/visual-reasoning-heuristics.md +53 -0
  167. package/skills/orchestrator/SKILL.md +0 -20
  168. package/skills/persistent-storage/SKILL.md +55 -0
  169. package/skills/short-maker/SKILL.md +23 -0
  170. package/skills/short-maker/scripts/effects.js +56 -0
  171. package/skills/short-maker/scripts/shortmaker-bridge.js +332 -0
  172. package/skills/short-maker/scripts/videomix.js +601 -0
  173. package/skills/short-maker/templates/hyperframes/cinematic-character.template.html +172 -0
  174. package/skills/short-maker/templates/hyperframes/index.template.html +194 -0
  175. package/skills/smali-to-kotlin/SKILL.md +128 -0
  176. package/skills/smali-to-kotlin/examples/getting-started/tech-stack.md +58 -0
  177. package/skills/smali-to-kotlin/examples/pipeline/data-ui-parity.md +118 -0
  178. package/skills/smali-to-kotlin/examples/pipeline/scanner-and-bootstrap.md +106 -0
  179. package/skills/smali-to-kotlin/library-patterns.md +189 -0
  180. package/skills/smali-to-kotlin/phase-0-discovery.md +128 -0
  181. package/skills/smali-to-kotlin/phase-1-architecture.md +166 -0
  182. package/skills/smali-to-kotlin/phase-2-blueprint-ui.md +347 -0
  183. package/skills/smali-to-kotlin/phase-2-blueprint.md +228 -0
  184. package/skills/smali-to-kotlin/phase-3-build.md +248 -0
  185. package/skills/smali-to-kotlin/phase-3-logic-build.md +268 -0
  186. package/skills/smali-to-kotlin/smali-reading-guide.md +310 -0
  187. package/skills/smali-to-kotlin/templates/app-map.md +101 -0
  188. package/skills/smali-to-kotlin/templates/architecture.md +142 -0
  189. package/skills/smali-to-kotlin/templates/blueprint.md +145 -0
  190. package/skills/spec-gate/SKILL.md +6 -2
  191. package/skills/symphony-enforcer/SKILL.md +8 -0
  192. package/skills/symphony-enforcer/examples/mindful-stop.md +2 -0
  193. package/skills/symphony-enforcer/examples/three-phase.md +16 -0
  194. package/skills/symphony-enforcer/examples/trigger-points.md +7 -1
  195. package/skills/unity-game-development/SKILL.md +231 -0
  196. package/skills/verification-gate/SKILL.md +4 -2
  197. package/skills/video-edit/SKILL.md +36 -0
  198. package/skills/video-edit/scripts/video_edit.py +324 -0
  199. package/templates/setup-mapping.json +48 -0
  200. package/templates/specs/design-template.md +161 -71
  201. package/templates/specs/requirements-template.md +65 -133
  202. package/templates/specs/task-spec-template.xml +3 -0
  203. package/workflows/_uncategorized/critic.md +40 -0
  204. package/workflows/_uncategorized/git-rebase-flow.md +81 -0
  205. package/workflows/_uncategorized/image-gen.md +118 -0
  206. package/workflows/_uncategorized/multi-model-pipeline.md +60 -0
  207. package/workflows/_uncategorized/pixel-gen.md +86 -0
  208. package/workflows/_uncategorized/pixel-setup.md +90 -0
  209. package/workflows/_uncategorized/ponytail-review.md +59 -0
  210. package/workflows/_uncategorized/reverse-android-build.md +222 -0
  211. package/workflows/_uncategorized/reverse-android-design.md +139 -0
  212. package/workflows/_uncategorized/reverse-android-discover.md +150 -0
  213. package/workflows/_uncategorized/reverse-android-scan.md +158 -0
  214. package/workflows/_uncategorized/reverse-android.md +143 -0
  215. package/workflows/_uncategorized/reverse-ios-build.md +240 -0
  216. package/workflows/_uncategorized/reverse-ios-design.md +112 -0
  217. package/workflows/_uncategorized/reverse-ios-discover.md +120 -0
  218. package/workflows/_uncategorized/reverse-ios-scan.md +155 -0
  219. package/workflows/_uncategorized/reverse-ios.md +152 -0
  220. package/workflows/_uncategorized/safety-router.md +34 -0
  221. package/workflows/_uncategorized/teach.md +89 -0
  222. package/workflows/_uncategorized/verify-ui.md +53 -0
  223. package/workflows/_uncategorized/visualize-screenshots.md +34 -0
  224. package/workflows/ads/ads-analyst.md +201 -0
  225. package/workflows/ads/ads-audit.md +106 -0
  226. package/workflows/ads/ads-optimize.md +97 -0
  227. package/workflows/ads/ads-targeting.md +241 -0
  228. package/workflows/ads/adsExpert.md +160 -0
  229. package/workflows/ads/smali-ads-config.md +400 -0
  230. package/workflows/ads/smali-ads-flow.md +331 -0
  231. package/workflows/ads/smali-ads-interstitial.md +377 -0
  232. package/workflows/ads/smali-ads-native.md +382 -0
  233. package/workflows/context/teach.md +89 -0
  234. package/workflows/gitnexus.md +8 -8
  235. package/workflows/lifecycle/brainstorm.md +43 -0
  236. package/workflows/lifecycle/code.md +5 -0
  237. package/workflows/lifecycle/init.md +23 -5
  238. package/workflows/lifecycle/multi-model-pipeline.md +60 -0
  239. package/workflows/quality/ponytail-review.md +59 -0
  240. package/workflows/roles/critic.md +40 -0
  241. package/workflows/roles/safety-router.md +34 -0
@@ -0,0 +1,490 @@
1
+ #!/usr/bin/env python3
2
+ """HangBuster session storage — own dir layout, no ProgressiveCache reuse.
3
+
4
+ Each session is a directory under ``~/.ios-simulator-skill/sessions/<id>/``
5
+ containing ``meta.json`` (config + pid + status), ``events.jsonl``
6
+ (append-only normalised events), and ``summary.json`` (post-stop).
7
+
8
+ The parent creates the directory and writes initial meta. The detached
9
+ worker updates meta with its own pid (avoids pidfile race) and appends to
10
+ events.jsonl. ``--stop`` SIGTERMs the worker, drains events, builds a
11
+ ``SessionSummary``, and writes ``summary.json``.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import contextlib
17
+ import json
18
+ import os
19
+ import re
20
+ import secrets
21
+ import signal
22
+ import time
23
+ from dataclasses import dataclass, field
24
+ from datetime import datetime, timedelta
25
+ from pathlib import Path
26
+
27
+ from common.env_config import env_int
28
+ from common.hang_pipeline import (
29
+ SessionSummary,
30
+ SummaryBuilder,
31
+ event_from_jsonl,
32
+ summary_from_json,
33
+ summary_to_json,
34
+ )
35
+
36
+ # === CONSTANTS ===
37
+
38
+ DEFAULT_SESSIONS_DIR = Path("~/.ios-simulator-skill/sessions").expanduser()
39
+ DEFAULT_TTL_HOURS = env_int("IOS_SIM_HANG_SESSION_TTL_HOURS", 24)
40
+
41
+ _STATUS_PENDING = "pending"
42
+ _STATUS_RUNNING = "running"
43
+ _STATUS_STOPPED = "stopped"
44
+ _STATUS_CRASHED = "crashed"
45
+
46
+ _DURATION_RE = re.compile(r"(\d+)([smhd])$")
47
+
48
+
49
+ # === TYPES ===
50
+
51
+
52
+ @dataclass
53
+ class SessionMeta:
54
+ """Parent + worker writes to meta.json."""
55
+
56
+ session_id: str
57
+ started_at: str
58
+ started_at_ms: int
59
+ args: dict
60
+ pid: int | None = None
61
+ status: str = _STATUS_PENDING
62
+ stopped_at: str | None = None
63
+ stopped_at_ms: int | None = None
64
+ extras: dict = field(default_factory=dict)
65
+
66
+ def to_json(self) -> dict:
67
+ return {
68
+ "session_id": self.session_id,
69
+ "started_at": self.started_at,
70
+ "started_at_ms": self.started_at_ms,
71
+ "args": self.args,
72
+ "pid": self.pid,
73
+ "status": self.status,
74
+ "stopped_at": self.stopped_at,
75
+ "stopped_at_ms": self.stopped_at_ms,
76
+ "extras": self.extras,
77
+ }
78
+
79
+ @classmethod
80
+ def from_json(cls, payload: dict) -> SessionMeta:
81
+ return cls(
82
+ session_id=payload["session_id"],
83
+ started_at=payload["started_at"],
84
+ started_at_ms=payload["started_at_ms"],
85
+ args=payload.get("args", {}),
86
+ pid=payload.get("pid"),
87
+ status=payload.get("status", _STATUS_PENDING),
88
+ stopped_at=payload.get("stopped_at"),
89
+ stopped_at_ms=payload.get("stopped_at_ms"),
90
+ extras=payload.get("extras", {}),
91
+ )
92
+
93
+
94
+ # === SESSION STORE ===
95
+
96
+
97
+ class SessionStore:
98
+ """Filesystem-backed session repository."""
99
+
100
+ def __init__(self, base_dir: Path | None = None):
101
+ self.base_dir = Path(base_dir).expanduser() if base_dir else DEFAULT_SESSIONS_DIR
102
+ self.base_dir.mkdir(parents=True, exist_ok=True)
103
+
104
+ # === PUBLIC API ===
105
+
106
+ def create(self, args: dict) -> SessionMeta:
107
+ """Generate id + dir + initial meta.json. Caller detaches the worker next."""
108
+ session_id = _generate_session_id()
109
+ session_dir = self.base_dir / session_id
110
+ session_dir.mkdir(parents=True, exist_ok=False)
111
+ # Empty events file so the worker can `open(..., 'a')` cleanly.
112
+ (session_dir / "events.jsonl").touch()
113
+ now = datetime.now()
114
+ meta = SessionMeta(
115
+ session_id=session_id,
116
+ started_at=now.isoformat(),
117
+ started_at_ms=int(now.timestamp() * 1000),
118
+ args=args,
119
+ status=_STATUS_PENDING,
120
+ )
121
+ self._write_meta(meta)
122
+ return meta
123
+
124
+ def wait_for_worker(self, session_id: str, timeout_seconds: float = 2.0) -> SessionMeta:
125
+ """Poll meta.json until status=running or timeout. Raises on timeout."""
126
+ deadline = time.time() + timeout_seconds
127
+ while time.time() < deadline:
128
+ meta = self.load_meta(session_id)
129
+ if meta.status == _STATUS_RUNNING and meta.pid:
130
+ return meta
131
+ time.sleep(0.05)
132
+ raise TimeoutError(f"Worker for {session_id} did not register within {timeout_seconds}s")
133
+
134
+ def claim_worker(self, session_id: str, pid: int) -> SessionMeta:
135
+ """Called by worker on startup. Writes pid + status=running into meta."""
136
+ meta = self.load_meta(session_id)
137
+ meta.pid = pid
138
+ meta.status = _STATUS_RUNNING
139
+ self._write_meta(meta)
140
+ return meta
141
+
142
+ def persist_worker_counters(self, session_id: str, counters: dict) -> None:
143
+ """Worker calls this at shutdown to flush its line counters into meta.
144
+
145
+ Re-reads meta from disk so a concurrent terminal status — ``stopped``
146
+ from the parent's ``stop()`` or ``crashed`` from this worker's own
147
+ ``mark_crashed`` — is not clobbered back to ``running``.
148
+ """
149
+ meta = self.load_meta(session_id)
150
+ meta.extras["line_counters"] = counters
151
+ if meta.status not in (_STATUS_STOPPED, _STATUS_CRASHED):
152
+ meta.status = _STATUS_RUNNING
153
+ self._write_meta(meta)
154
+
155
+ def stop(self, session_id: str, summary: SessionSummary) -> SessionMeta:
156
+ """Mark session stopped and persist the computed summary."""
157
+ meta = self.load_meta(session_id)
158
+ meta.status = _STATUS_STOPPED
159
+ now = datetime.now()
160
+ meta.stopped_at = now.isoformat()
161
+ meta.stopped_at_ms = int(now.timestamp() * 1000)
162
+ self._write_meta(meta)
163
+ self._write_summary(session_id, summary)
164
+ return meta
165
+
166
+ def mark_crashed(self, session_id: str) -> None:
167
+ """Best-effort: tag a session whose worker exited without a summary.
168
+
169
+ Records ``stopped_at`` / ``stopped_at_ms`` so capture-duration math in
170
+ ``build_summary`` and ``--list-sessions`` reflects when the worker
171
+ actually died, not when the session was finally inspected.
172
+ """
173
+ try:
174
+ meta = self.load_meta(session_id)
175
+ except FileNotFoundError:
176
+ return
177
+ meta.status = _STATUS_CRASHED
178
+ now = datetime.now()
179
+ meta.stopped_at = now.isoformat()
180
+ meta.stopped_at_ms = int(now.timestamp() * 1000)
181
+ self._write_meta(meta)
182
+
183
+ def signal_worker(self, session_id: str, sig: int = signal.SIGTERM) -> bool:
184
+ """Send ``sig`` to the worker pid recorded in meta.json. Returns True if delivered."""
185
+ meta = self.load_meta(session_id)
186
+ if not meta.pid:
187
+ return False
188
+ try:
189
+ os.kill(meta.pid, sig)
190
+ return True
191
+ except ProcessLookupError:
192
+ return False
193
+ except PermissionError:
194
+ return False
195
+
196
+ def load_meta(self, session_id: str) -> SessionMeta:
197
+ path = self._meta_path(session_id)
198
+ if not path.exists():
199
+ raise FileNotFoundError(f"No meta.json for session {session_id}")
200
+ with open(path) as handle:
201
+ return SessionMeta.from_json(json.load(handle))
202
+
203
+ def load_summary(self, session_id: str) -> SessionSummary | None:
204
+ path = self._summary_path(session_id)
205
+ if not path.exists():
206
+ return None
207
+ with open(path) as handle:
208
+ return summary_from_json(json.load(handle))
209
+
210
+ def stash_auto_sample(self, session_id: str, fingerprint: str, sample: dict) -> None:
211
+ """Append an auto-sample record to ``<session>/auto_samples.jsonl``.
212
+
213
+ Append-only JSONL avoids the read-modify-write race that an aggregate
214
+ JSON dict would have under concurrent worker stashes. Readers reduce
215
+ last-write-wins per fingerprint.
216
+ """
217
+ path = self._auto_samples_path(session_id)
218
+ line = json.dumps({"fingerprint": fingerprint, "sample": sample}, separators=(",", ":"))
219
+ with open(path, "a") as handle:
220
+ handle.write(line + "\n")
221
+ handle.flush()
222
+ os.fsync(handle.fileno())
223
+
224
+ def read_auto_samples(self, session_id: str) -> dict[str, list[dict]]:
225
+ """Return ``{fingerprint: [sample, ...]}`` preserving write order.
226
+
227
+ Multiple capture mechanisms (e.g. ``--auto-sample`` + ``--auto-spindump``)
228
+ can stash distinct records under one fingerprint; callers disambiguate
229
+ via the ``kind`` field on each sample payload.
230
+ """
231
+ path = self._auto_samples_path(session_id)
232
+ if not path.exists():
233
+ return {}
234
+ samples: dict[str, list[dict]] = {}
235
+ with open(path) as handle:
236
+ for raw in handle:
237
+ line = raw.strip()
238
+ if not line:
239
+ continue
240
+ try:
241
+ payload = json.loads(line)
242
+ except json.JSONDecodeError:
243
+ continue
244
+ fingerprint = payload.get("fingerprint")
245
+ if fingerprint is None:
246
+ continue
247
+ samples.setdefault(fingerprint, []).append(payload.get("sample"))
248
+ return samples
249
+
250
+ def read_events(self, session_id: str) -> list:
251
+ """Read all events.jsonl lines, returning NormalisedEvent instances.
252
+
253
+ Skips non-event sentinel lines (e.g. ``{"event": "stream_ended"}``).
254
+ """
255
+ path = self._events_path(session_id)
256
+ if not path.exists():
257
+ return []
258
+ events = []
259
+ with open(path) as handle:
260
+ for raw in handle:
261
+ line = raw.strip()
262
+ if not line:
263
+ continue
264
+ try:
265
+ payload = json.loads(line)
266
+ except json.JSONDecodeError:
267
+ continue
268
+ # Skip non-event sentinel lines (e.g. {"event": "stream_ended"}).
269
+ if payload.get("event") == "stream_ended":
270
+ continue
271
+ try:
272
+ events.append(event_from_jsonl(line))
273
+ except (json.JSONDecodeError, KeyError):
274
+ continue
275
+ return events
276
+
277
+ def events_path(self, session_id: str) -> Path:
278
+ """Worker writes here. Public so the worker can open it line-buffered."""
279
+ return self._events_path(session_id)
280
+
281
+ def raw_path(self, session_id: str, gzipped: bool = False) -> Path:
282
+ """Raw-capture NDJSON path. ``gzipped=True`` returns the post-stop path."""
283
+ name = "raw.ndjson.gz" if gzipped else "raw.ndjson"
284
+ return self.base_dir / session_id / name
285
+
286
+ def session_dir(self, session_id: str) -> Path:
287
+ return self.base_dir / session_id
288
+
289
+ def session_total_bytes(self, session_id: str) -> int:
290
+ """Sum of all files under a session dir. Used by aggregate-cap pruning."""
291
+ total = 0
292
+ session_path = self.session_dir(session_id)
293
+ if not session_path.exists():
294
+ return 0
295
+ for path in session_path.rglob("*"):
296
+ if path.is_file():
297
+ with contextlib.suppress(OSError):
298
+ total += path.stat().st_size
299
+ return total
300
+
301
+ def prune_to_aggregate_cap(self, max_bytes: int) -> int:
302
+ """Drop oldest sessions until total bytes ≤ max_bytes. Returns deletions.
303
+
304
+ Pairs with ``prune_expired``: TTL handles age, this handles disk usage
305
+ when activity outpaces TTL. Both are called automatically on every
306
+ ``create`` so the user never has to clean up manually.
307
+ """
308
+ if max_bytes <= 0:
309
+ return 0
310
+ # Oldest first — deletion order.
311
+ entries: list[tuple[int, str, int]] = [] # (started_at_ms, session_id, bytes)
312
+ total = 0
313
+ for entry in self.base_dir.iterdir():
314
+ if not entry.is_dir():
315
+ continue
316
+ try:
317
+ meta = self.load_meta(entry.name)
318
+ except (FileNotFoundError, json.JSONDecodeError):
319
+ continue
320
+ size = self.session_total_bytes(entry.name)
321
+ total += size
322
+ entries.append((meta.started_at_ms, entry.name, size))
323
+ if total <= max_bytes:
324
+ return 0
325
+ entries.sort(key=lambda e: e[0]) # oldest first
326
+ deleted = 0
327
+ for _, session_id, size in entries:
328
+ if total <= max_bytes:
329
+ break
330
+ _remove_tree(self.session_dir(session_id))
331
+ total -= size
332
+ deleted += 1
333
+ return deleted
334
+
335
+ def list_sessions(self) -> list[SessionMeta]:
336
+ """All non-expired session metas, newest first."""
337
+ metas: list[SessionMeta] = []
338
+ for entry in self.base_dir.iterdir():
339
+ if not entry.is_dir():
340
+ continue
341
+ try:
342
+ metas.append(self.load_meta(entry.name))
343
+ except (FileNotFoundError, json.JSONDecodeError):
344
+ continue
345
+ metas.sort(key=lambda m: m.started_at_ms, reverse=True)
346
+ return metas
347
+
348
+ def clear(self, older_than: str | None = None) -> int:
349
+ """Delete session dirs. ``older_than`` is a duration string like ``24h``."""
350
+ cutoff_ms = _resolve_cutoff_ms(older_than) if older_than else None
351
+ deleted = 0
352
+ for entry in self.base_dir.iterdir():
353
+ if not entry.is_dir():
354
+ continue
355
+ try:
356
+ meta = self.load_meta(entry.name)
357
+ except (FileNotFoundError, json.JSONDecodeError):
358
+ _remove_tree(entry)
359
+ deleted += 1
360
+ continue
361
+ if cutoff_ms is None or meta.started_at_ms <= cutoff_ms:
362
+ _remove_tree(entry)
363
+ deleted += 1
364
+ return deleted
365
+
366
+ def prune_expired(self, ttl_hours: int | None = None) -> int:
367
+ """Remove sessions older than ttl. Called on every ``create``."""
368
+ ttl = ttl_hours if ttl_hours is not None else DEFAULT_TTL_HOURS
369
+ cutoff = int((datetime.now() - timedelta(hours=ttl)).timestamp() * 1000)
370
+ return self._clear_older_than_ms(cutoff)
371
+
372
+ # === SUMMARY HELPERS ===
373
+
374
+ def build_summary(
375
+ self,
376
+ session_id: str,
377
+ matched_lines: int = 0,
378
+ total_lines: int = 0,
379
+ dropped_below_threshold: int = 0,
380
+ extras: dict | None = None,
381
+ top_n: int | None = None,
382
+ ) -> SessionSummary:
383
+ """Convenience: read events.jsonl and run the pipeline through SummaryBuilder.
384
+
385
+ Duration prefers ``meta.stopped_at_ms`` (set on both ``stop()`` and
386
+ ``mark_crashed()``) so summaries for crashed/stopped sessions reflect
387
+ the actual capture window, not the time of inspection. Live sessions
388
+ without ``stopped_at_ms`` fall back to ``now`` as before.
389
+ """
390
+ meta = self.load_meta(session_id)
391
+ events = self.read_events(session_id)
392
+ end_ms = meta.stopped_at_ms or int(datetime.now().timestamp() * 1000)
393
+ duration_ms = end_ms - meta.started_at_ms
394
+ builder = SummaryBuilder(
395
+ session_id=session_id,
396
+ started_at=meta.started_at,
397
+ duration_ms=max(0, duration_ms),
398
+ matched_lines=matched_lines,
399
+ total_lines=total_lines,
400
+ dropped_below_threshold=dropped_below_threshold,
401
+ extras=extras or {},
402
+ )
403
+ return builder.build(
404
+ events,
405
+ top_n=top_n,
406
+ auto_samples_by_fp=self.read_auto_samples(session_id),
407
+ )
408
+
409
+ # === PRIVATE ===
410
+
411
+ def _meta_path(self, session_id: str) -> Path:
412
+ return self.base_dir / session_id / "meta.json"
413
+
414
+ def _events_path(self, session_id: str) -> Path:
415
+ return self.base_dir / session_id / "events.jsonl"
416
+
417
+ def _summary_path(self, session_id: str) -> Path:
418
+ return self.base_dir / session_id / "summary.json"
419
+
420
+ def _auto_samples_path(self, session_id: str) -> Path:
421
+ return self.base_dir / session_id / "auto_samples.jsonl"
422
+
423
+ def _write_meta(self, meta: SessionMeta) -> None:
424
+ path = self._meta_path(meta.session_id)
425
+ tmp = path.with_suffix(".json.tmp")
426
+ # Atomic write — concurrent reads (e.g. the parent polling) never see a half-file.
427
+ # fsync before replace makes the new contents durable, not just atomically renamed.
428
+ with open(tmp, "w") as handle:
429
+ json.dump(meta.to_json(), handle, indent=2)
430
+ handle.flush()
431
+ os.fsync(handle.fileno())
432
+ tmp.replace(path)
433
+
434
+ def _write_summary(self, session_id: str, summary: SessionSummary) -> None:
435
+ path = self._summary_path(session_id)
436
+ tmp = path.with_suffix(".json.tmp")
437
+ with open(tmp, "w") as handle:
438
+ json.dump(summary_to_json(summary), handle, indent=2)
439
+ handle.flush()
440
+ os.fsync(handle.fileno())
441
+ tmp.replace(path)
442
+
443
+ def _clear_older_than_ms(self, cutoff_ms: int) -> int:
444
+ deleted = 0
445
+ for entry in self.base_dir.iterdir():
446
+ if not entry.is_dir():
447
+ continue
448
+ try:
449
+ meta = self.load_meta(entry.name)
450
+ except (FileNotFoundError, json.JSONDecodeError):
451
+ _remove_tree(entry)
452
+ deleted += 1
453
+ continue
454
+ if meta.started_at_ms <= cutoff_ms:
455
+ _remove_tree(entry)
456
+ deleted += 1
457
+ return deleted
458
+
459
+
460
+ # === MODULE-LEVEL HELPERS ===
461
+
462
+
463
+ def _generate_session_id() -> str:
464
+ """``hang-YYYYMMDD-HHmmss-XXXX`` — random hex suffix avoids same-second collisions."""
465
+ timestamp = datetime.now().strftime("%Y%m%d-%H%M%S")
466
+ suffix = secrets.token_hex(2)
467
+ return f"hang-{timestamp}-{suffix}"
468
+
469
+
470
+ def _resolve_cutoff_ms(duration_str: str) -> int:
471
+ """Parse e.g. ``24h``, ``30m`` and return the epoch-ms threshold."""
472
+ match = _DURATION_RE.match(duration_str.strip().lower())
473
+ if not match:
474
+ raise ValueError(f"Invalid duration: {duration_str!r}. Use 30s/5m/24h/7d.")
475
+ value, unit = int(match.group(1)), match.group(2)
476
+ seconds = value * {"s": 1, "m": 60, "h": 3600, "d": 86400}[unit]
477
+ cutoff = datetime.now() - timedelta(seconds=seconds)
478
+ return int(cutoff.timestamp() * 1000)
479
+
480
+
481
+ def _remove_tree(path: Path) -> None:
482
+ """rm -rf path. Used for session-dir cleanup."""
483
+ for child in path.iterdir():
484
+ if child.is_dir():
485
+ _remove_tree(child)
486
+ else:
487
+ with contextlib.suppress(FileNotFoundError):
488
+ child.unlink()
489
+ with contextlib.suppress(OSError):
490
+ path.rmdir()
@@ -0,0 +1,180 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Shared IDB utility functions.
4
+
5
+ This module provides common IDB operations used across multiple scripts.
6
+ Follows Jackson's Law - only shared code that's truly reused, not speculative.
7
+
8
+ Used by:
9
+ - navigator.py - Accessibility tree navigation
10
+ - screen_mapper.py - UI element analysis
11
+ - accessibility_audit.py - WCAG compliance checking
12
+ - test_recorder.py - Test documentation
13
+ - app_state_capture.py - State snapshots
14
+ - gesture.py - Touch gesture operations
15
+ """
16
+
17
+ import json
18
+ import subprocess
19
+ import sys
20
+
21
+
22
+ def get_accessibility_tree(udid: str | None = None, nested: bool = True) -> dict:
23
+ """
24
+ Fetch accessibility tree from IDB.
25
+
26
+ The accessibility tree represents the complete UI hierarchy of the current
27
+ screen, with all element properties needed for semantic navigation.
28
+
29
+ Args:
30
+ udid: Device UDID (uses booted simulator if None)
31
+ nested: Include nested structure (default True). If False, returns flat array.
32
+
33
+ Returns:
34
+ Root element of accessibility tree as dict.
35
+ Structure: {
36
+ "type": "Window",
37
+ "AXLabel": "App Name",
38
+ "frame": {"x": 0, "y": 0, "width": 390, "height": 844},
39
+ "children": [...]
40
+ }
41
+
42
+ Raises:
43
+ SystemExit: If IDB command fails or returns invalid JSON
44
+
45
+ Example:
46
+ tree = get_accessibility_tree("UDID123")
47
+ # Root is Window element with all children nested
48
+ """
49
+ cmd = ["idb", "ui", "describe-all", "--json"]
50
+ if nested:
51
+ cmd.append("--nested")
52
+ if udid:
53
+ cmd.extend(["--udid", udid])
54
+
55
+ try:
56
+ result = subprocess.run(cmd, capture_output=True, text=True, check=True)
57
+ tree_data = json.loads(result.stdout)
58
+
59
+ # IDB returns array format, extract first element (root)
60
+ if isinstance(tree_data, list) and len(tree_data) > 0:
61
+ return tree_data[0]
62
+ return tree_data
63
+ except subprocess.CalledProcessError as e:
64
+ print(f"Error: Failed to get accessibility tree: {e.stderr}", file=sys.stderr)
65
+ sys.exit(1)
66
+ except json.JSONDecodeError:
67
+ print("Error: Invalid JSON from idb", file=sys.stderr)
68
+ sys.exit(1)
69
+
70
+
71
+ def flatten_tree(node: dict, depth: int = 0, elements: list[dict] | None = None) -> list[dict]:
72
+ """
73
+ Flatten nested accessibility tree into list of elements.
74
+
75
+ Converts the hierarchical accessibility tree into a flat list where each
76
+ element includes its depth for context.
77
+
78
+ Used by:
79
+ - navigator.py - Element finding
80
+ - screen_mapper.py - Element analysis
81
+ - accessibility_audit.py - Audit scanning
82
+
83
+ Args:
84
+ node: Root node of tree (typically from get_accessibility_tree)
85
+ depth: Current depth (used internally, start at 0)
86
+ elements: Accumulator list (used internally, start as None)
87
+
88
+ Returns:
89
+ Flat list of elements, each with "depth" key indicating nesting level.
90
+ Structure of each element: {
91
+ "type": "Button",
92
+ "AXLabel": "Login",
93
+ "frame": {...},
94
+ "depth": 2,
95
+ ...
96
+ }
97
+
98
+ Example:
99
+ tree = get_accessibility_tree()
100
+ flat = flatten_tree(tree)
101
+ for elem in flat:
102
+ print(f"{' ' * elem['depth']}{elem.get('type')}: {elem.get('AXLabel')}")
103
+ """
104
+ if elements is None:
105
+ elements = []
106
+
107
+ # Add current node with depth tracking
108
+ node_copy = node.copy()
109
+ node_copy["depth"] = depth
110
+ elements.append(node_copy)
111
+
112
+ # Process children recursively
113
+ for child in node.get("children", []):
114
+ flatten_tree(child, depth + 1, elements)
115
+
116
+ return elements
117
+
118
+
119
+ def count_elements(node: dict) -> int:
120
+ """
121
+ Count total elements in tree (recursive).
122
+
123
+ Traverses entire tree counting all elements for reporting purposes.
124
+
125
+ Used by:
126
+ - test_recorder.py - Element counting per step
127
+ - screen_mapper.py - Summary statistics
128
+
129
+ Args:
130
+ node: Root node of tree
131
+
132
+ Returns:
133
+ Total element count including root and all descendants
134
+
135
+ Example:
136
+ tree = get_accessibility_tree()
137
+ total = count_elements(tree)
138
+ print(f"Screen has {total} elements")
139
+ """
140
+ count = 1
141
+ for child in node.get("children", []):
142
+ count += count_elements(child)
143
+ return count
144
+
145
+
146
+ def get_screen_size(udid: str | None = None) -> tuple[int, int]:
147
+ """
148
+ Get screen dimensions from accessibility tree.
149
+
150
+ Extracts the screen size from the root element's frame. Useful for
151
+ gesture calculations and coordinate normalization.
152
+
153
+ Used by:
154
+ - gesture.py - Gesture positioning
155
+ - Potentially: screenshot positioning, screen-aware scaling
156
+
157
+ Args:
158
+ udid: Device UDID (uses booted if None)
159
+
160
+ Returns:
161
+ (width, height) tuple. Defaults to (390, 844) if detection fails
162
+ or tree cannot be accessed.
163
+
164
+ Example:
165
+ width, height = get_screen_size()
166
+ center_x = width // 2
167
+ center_y = height // 2
168
+ """
169
+ DEFAULT_WIDTH = 390 # iPhone 14
170
+ DEFAULT_HEIGHT = 844
171
+
172
+ try:
173
+ tree = get_accessibility_tree(udid, nested=False)
174
+ frame = tree.get("frame", {})
175
+ width = int(frame.get("width", DEFAULT_WIDTH))
176
+ height = int(frame.get("height", DEFAULT_HEIGHT))
177
+ return (width, height)
178
+ except Exception:
179
+ # Silently fall back to defaults if tree access fails
180
+ return (DEFAULT_WIDTH, DEFAULT_HEIGHT)