agentic-team 0.0.0-stage → 6.2.0

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 (121) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +189 -2
  3. package/bin/build-template.js +208 -0
  4. package/bin/index.js +1334 -0
  5. package/bin/platforms.js +510 -0
  6. package/package.json +41 -4
  7. package/template/.claude-plugin/plugin.json +10 -0
  8. package/template/.orchestra/README.md +391 -0
  9. package/template/.orchestra/blueprints/README.md +87 -0
  10. package/template/.orchestra/blueprints/api-only.md +46 -0
  11. package/template/.orchestra/blueprints/component-crud-resource.md +25 -0
  12. package/template/.orchestra/blueprints/saas-starter.md +69 -0
  13. package/template/.orchestra/config.yml +293 -0
  14. package/template/.orchestra/roles/orchestrator.md +105 -0
  15. package/template/.orchestra/roles/product-manager.md +439 -0
  16. package/template/.orchestra/scripts/compact-diff.awk +45 -0
  17. package/template/.orchestra/scripts/context.mjs +373 -0
  18. package/template/.orchestra/stacks/README.md +81 -0
  19. package/template/.orchestra/stacks/_lib/common.sh +3095 -0
  20. package/template/.orchestra/stacks/_repo/setup.sh +1113 -0
  21. package/template/.orchestra/stacks/backend-hono/claude/commands/endpoint.md +13 -0
  22. package/template/.orchestra/stacks/backend-hono/claude/skills/api-endpoint/SKILL.md +111 -0
  23. package/template/.orchestra/stacks/backend-hono/claude/skills/testing/SKILL.md +75 -0
  24. package/template/.orchestra/stacks/backend-hono/setup.sh +5945 -0
  25. package/template/.orchestra/stacks/backend-hono/stack.md +195 -0
  26. package/template/.orchestra/stacks/mobile-android-native/claude/commands/component.md +13 -0
  27. package/template/.orchestra/stacks/mobile-android-native/claude/skills/design-component/SKILL.md +58 -0
  28. package/template/.orchestra/stacks/mobile-android-native/setup.sh +607 -0
  29. package/template/.orchestra/stacks/mobile-android-native/stack.md +138 -0
  30. package/template/.orchestra/stacks/mobile-expo/claude/commands/component.md +12 -0
  31. package/template/.orchestra/stacks/mobile-expo/claude/skills/design-component/SKILL.md +92 -0
  32. package/template/.orchestra/stacks/mobile-expo/setup.sh +470 -0
  33. package/template/.orchestra/stacks/mobile-expo/stack.md +141 -0
  34. package/template/.orchestra/stacks/mobile-ios-native/claude/commands/component.md +12 -0
  35. package/template/.orchestra/stacks/mobile-ios-native/claude/skills/design-component/SKILL.md +58 -0
  36. package/template/.orchestra/stacks/mobile-ios-native/setup.sh +468 -0
  37. package/template/.orchestra/stacks/mobile-ios-native/stack.md +130 -0
  38. package/template/.orchestra/stacks/web-nextjs/claude/commands/component.md +12 -0
  39. package/template/.orchestra/stacks/web-nextjs/claude/skills/design-component/SKILL.md +100 -0
  40. package/template/.orchestra/stacks/web-nextjs/claude/skills/design-tokens/SKILL.md +61 -0
  41. package/template/.orchestra/stacks/web-nextjs/setup.sh +128 -0
  42. package/template/.orchestra/stacks/web-nextjs/stack.md +170 -0
  43. package/template/.orchestra/stacks/web-vite-react/claude/commands/component.md +12 -0
  44. package/template/.orchestra/stacks/web-vite-react/claude/skills/design-component/SKILL.md +100 -0
  45. package/template/.orchestra/stacks/web-vite-react/claude/skills/design-tokens/SKILL.md +61 -0
  46. package/template/.orchestra/stacks/web-vite-react/setup.sh +185 -0
  47. package/template/.orchestra/stacks/web-vite-react/stack.md +177 -0
  48. package/template/.orchestra/trackers/github.sh +467 -0
  49. package/template/CLAUDE.md +54 -0
  50. package/template/agents/architect.md +83 -0
  51. package/template/agents/engineer-high.md +17 -0
  52. package/template/agents/engineer-low.md +17 -0
  53. package/template/agents/engineer-max.md +17 -0
  54. package/template/agents/engineer-medium.md +17 -0
  55. package/template/agents/engineer-xhigh.md +17 -0
  56. package/template/agents/lead.md +925 -0
  57. package/template/agents/milestone-reviewer.md +65 -0
  58. package/template/agents/orchestrator.md +105 -0
  59. package/template/agents/product-manager.md +439 -0
  60. package/template/agents/re-reviewer.md +25 -0
  61. package/template/agents/reviewer.md +303 -0
  62. package/template/agents/scaffolder.md +206 -0
  63. package/template/agents/tracker.md +202 -0
  64. package/template/commands/blueprint.md +23 -0
  65. package/template/commands/create-role.md +64 -0
  66. package/template/commands/export.md +45 -0
  67. package/template/commands/help.md +56 -0
  68. package/template/commands/hotfix.md +14 -0
  69. package/template/commands/orchestrator.md +12 -0
  70. package/template/commands/pm.md +18 -0
  71. package/template/commands/rewind.md +63 -0
  72. package/template/commands/setup.md +15 -0
  73. package/template/commands/start.md +29 -0
  74. package/template/commands/status.md +11 -0
  75. package/template/commands/sync.md +76 -0
  76. package/template/commands/verifier.md +53 -0
  77. package/template/hooks/audit-context.sh +166 -0
  78. package/template/hooks/enforce-role.sh +193 -0
  79. package/template/hooks/protect-system-files.sh +123 -0
  80. package/template/hooks/review-rounds.sh +172 -0
  81. package/template/rules/acceptance-check.orchestra.md +11 -0
  82. package/template/rules/analytics-kpi.orchestra.md +56 -0
  83. package/template/rules/code-standards.orchestra.md +35 -0
  84. package/template/rules/comments.orchestra.md +77 -0
  85. package/template/rules/commit-format.orchestra.md +20 -0
  86. package/template/rules/dependency-upgrade.orchestra.md +112 -0
  87. package/template/rules/design-parity.orchestra.md +181 -0
  88. package/template/rules/observability.orchestra.md +121 -0
  89. package/template/rules/performance.orchestra.md +190 -0
  90. package/template/rules/phase-limits.orchestra.md +3 -0
  91. package/template/rules/pull-request.orchestra.md +218 -0
  92. package/template/rules/role-boundaries.orchestra.md +132 -0
  93. package/template/rules/security.orchestra.md +106 -0
  94. package/template/rules/stuck-detection.orchestra.md +25 -0
  95. package/template/rules/testing-standards.orchestra.md +219 -0
  96. package/template/rules/tracker-authority.orchestra.md +198 -0
  97. package/template/rules/verification-gate.orchestra.md +56 -0
  98. package/template/skills/accessibility/SKILL.md +31 -0
  99. package/template/skills/android-testing/SKILL.md +112 -0
  100. package/template/skills/architecture/SKILL.md +175 -0
  101. package/template/skills/auth-setup/SKILL.md +105 -0
  102. package/template/skills/best-practices/SKILL.md +29 -0
  103. package/template/skills/code-optimizer/SKILL.md +27 -0
  104. package/template/skills/core-web-vitals/SKILL.md +32 -0
  105. package/template/skills/crud-api/SKILL.md +32 -0
  106. package/template/skills/debug/SKILL.md +28 -0
  107. package/template/skills/deployment/SKILL.md +104 -0
  108. package/template/skills/error-handling/SKILL.md +154 -0
  109. package/template/skills/frontend-design/SKILL.md +30 -0
  110. package/template/skills/fullstack-infrastructure/SKILL.md +108 -0
  111. package/template/skills/ios-testing/SKILL.md +100 -0
  112. package/template/skills/mobile-testing/SKILL.md +102 -0
  113. package/template/skills/observability/SKILL.md +91 -0
  114. package/template/skills/project-structure/SKILL.md +114 -0
  115. package/template/skills/push-notifications/SKILL.md +146 -0
  116. package/template/skills/react-best-practices/SKILL.md +31 -0
  117. package/template/skills/realtime/SKILL.md +146 -0
  118. package/template/skills/review/SKILL.md +82 -0
  119. package/template/skills/testing/SKILL.md +45 -0
  120. package/template/skills/web-quality-audit/SKILL.md +47 -0
  121. package/template/skills/web-testing/SKILL.md +120 -0
@@ -0,0 +1,607 @@
1
+ #!/usr/bin/env bash
2
+ # Native Android — Kotlin, Jetpack Compose, Gradle Kotlin DSL.
3
+ # Everything is text and reviewable in a diff. The version catalog is the
4
+ # single source of truth for dependency versions, and what Dependabot edits.
5
+ set -euo pipefail
6
+
7
+ DIR="${1:?usage: setup.sh <target-dir>}"
8
+ LIB="$(cd "$(dirname "${BASH_SOURCE[0]}")/../_lib" && pwd)"
9
+ # shellcheck source=../_lib/common.sh
10
+ source "$LIB/common.sh"
11
+
12
+ command -v gradle >/dev/null 2>&1 || die "gradle not found. Install it first: brew install gradle"
13
+
14
+ SDK="${ANDROID_HOME:-${ANDROID_SDK_ROOT:-}}"
15
+ if [ -z "$SDK" ] && [ -d /opt/homebrew/share/android-commandlinetools ]; then
16
+ SDK=/opt/homebrew/share/android-commandlinetools
17
+ fi
18
+ [ -n "$SDK" ] || die "Android SDK not found. Install it (brew install --cask android-commandlinetools) and set ANDROID_HOME."
19
+
20
+ log "scaffolding native Android into $DIR"
21
+ mkdir -p "$DIR"/gradle \
22
+ "$DIR"/app/src/main/kotlin/com/example/app \
23
+ "$DIR"/app/src/test/kotlin \
24
+ "$DIR"/designsystem/src/main/kotlin/designsystem \
25
+ "$DIR"/designsystem/src/test/kotlin/designsystem
26
+ cd "$DIR"
27
+
28
+ # local.properties points Gradle at the SDK. It is machine-specific — never
29
+ # commit it.
30
+ printf 'sdk.dir=%s\n' "$SDK" > local.properties
31
+ printf 'local.properties\n.gradle/\nbuild/\n' > .gitignore
32
+
33
+ # Gradle has no safe "@latest": a dynamic version destroys build
34
+ # reproducibility, so the catalog pins explicitly. Explicit is not the same
35
+ # as remembered — every one of these is read from the repository that serves
36
+ # it, then written down. Dependabot moves them afterwards.
37
+ log "resolving gradle versions"
38
+ AGP_V="$(maven_latest "$GOOGLE_MAVEN" com.android.tools.build gradle 9.3.1)"
39
+ KOTLIN_V="$(maven_latest "$MAVEN_CENTRAL" org.jetbrains.kotlin kotlin-gradle-plugin 2.4.10)"
40
+ COMPOSE_BOM_V="$(maven_latest "$GOOGLE_MAVEN" androidx.compose compose-bom 2026.08.00)"
41
+ ACTIVITY_COMPOSE_V="$(maven_latest "$GOOGLE_MAVEN" androidx.activity activity-compose 1.13.0)"
42
+ JUNIT_V="$(maven_latest "$MAVEN_CENTRAL" junit junit 4.13.2)"
43
+ # Compose UI tests run on the JVM through robolectric — seconds instead of the
44
+ # minutes an emulator costs, which is what makes it realistic to test every
45
+ # component rather than a token few.
46
+ ROBOLECTRIC_V="$(maven_latest "$MAVEN_CENTRAL" org.robolectric robolectric 4.16)"
47
+ # Observability is not a launch task — it goes in before the first release.
48
+ SENTRY_V="$(maven_latest "$MAVEN_CENTRAL" io.sentry sentry-android 8.16.0)"
49
+ POSTHOG_V="$(maven_latest "$MAVEN_CENTRAL" com.posthog posthog-android 3.19.0)"
50
+ LEAKCANARY_V="$(maven_latest "$MAVEN_CENTRAL" com.squareup.leakcanary leakcanary-android 2.14)"
51
+ log " agp $AGP_V · kotlin $KOTLIN_V · compose-bom $COMPOSE_BOM_V · activity-compose $ACTIVITY_COMPOSE_V · junit $JUNIT_V · robolectric $ROBOLECTRIC_V · sentry $SENTRY_V · posthog $POSTHOG_V"
52
+
53
+ # Unquoted heredoc on purpose — the versions above are substituted in. There
54
+ # is no $ or backtick in the catalog itself, so nothing else expands.
55
+ cat > gradle/libs.versions.toml <<TOML
56
+ [versions]
57
+ agp = "$AGP_V"
58
+ kotlin = "$KOTLIN_V"
59
+ compose-bom = "$COMPOSE_BOM_V"
60
+ activity-compose = "$ACTIVITY_COMPOSE_V"
61
+ junit = "$JUNIT_V"
62
+ robolectric = "$ROBOLECTRIC_V"
63
+ sentry = "$SENTRY_V"
64
+ posthog = "$POSTHOG_V"
65
+ leakcanary = "$LEAKCANARY_V"
66
+
67
+ [libraries]
68
+ compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "compose-bom" }
69
+ compose-ui = { group = "androidx.compose.ui", name = "ui" }
70
+ compose-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" }
71
+ compose-material3 = { group = "androidx.compose.material3", name = "material3" }
72
+ activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activity-compose" }
73
+ junit = { group = "junit", name = "junit", version.ref = "junit" }
74
+ robolectric = { group = "org.robolectric", name = "robolectric", version.ref = "robolectric" }
75
+ sentry-android = { group = "io.sentry", name = "sentry-android", version.ref = "sentry" }
76
+ posthog-android = { group = "com.posthog", name = "posthog-android", version.ref = "posthog" }
77
+ leakcanary-android = { group = "com.squareup.leakcanary", name = "leakcanary-android", version.ref = "leakcanary" }
78
+ # Versioned by the compose BOM, so no version.ref here.
79
+ compose-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" }
80
+ compose-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" }
81
+
82
+ [plugins]
83
+ android-application = { id = "com.android.application", version.ref = "agp" }
84
+ android-library = { id = "com.android.library", version.ref = "agp" }
85
+ kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
86
+ TOML
87
+
88
+ cat > settings.gradle.kts <<'KTS'
89
+ pluginManagement { repositories { google(); mavenCentral(); gradlePluginPortal() } }
90
+ dependencyResolutionManagement { repositories { google(); mavenCentral() } }
91
+ rootProject.name = "app"
92
+ include(":app", ":designsystem")
93
+ KTS
94
+
95
+ # NOTE: there is deliberately no org.jetbrains.kotlin.android plugin here.
96
+ # AGP 9 has built-in Kotlin support and fails the build if you apply it.
97
+ cat > build.gradle.kts <<'KTS'
98
+ plugins {
99
+ alias(libs.plugins.android.application) apply false
100
+ alias(libs.plugins.android.library) apply false
101
+ alias(libs.plugins.kotlin.compose) apply false
102
+ }
103
+ KTS
104
+
105
+ cat > designsystem/build.gradle.kts <<'KTS'
106
+ plugins {
107
+ alias(libs.plugins.android.library)
108
+ alias(libs.plugins.kotlin.compose)
109
+ }
110
+
111
+ android {
112
+ namespace = "com.example.designsystem"
113
+ compileSdk = 37
114
+ defaultConfig { minSdk = 26 }
115
+ buildFeatures { compose = true }
116
+ }
117
+
118
+ dependencies {
119
+ implementation(platform(libs.compose.bom))
120
+ implementation(libs.compose.ui)
121
+ implementation(libs.compose.material3)
122
+ testImplementation(libs.junit)
123
+ // Compose UI tests on the JVM: the real Compose runtime under robolectric,
124
+ // seconds rather than the minutes an emulator costs. That speed is what
125
+ // makes it realistic to test every component instead of a token few.
126
+ testImplementation(libs.robolectric)
127
+ testImplementation(platform(libs.compose.bom))
128
+ testImplementation(libs.compose.ui.test.junit4)
129
+ debugImplementation(libs.compose.ui.test.manifest)
130
+ }
131
+
132
+ // Robolectric needs android resources on the unit-test classpath.
133
+ android.testOptions.unitTests.isIncludeAndroidResources = true
134
+ KTS
135
+
136
+ cat > app/build.gradle.kts <<'KTS'
137
+ plugins {
138
+ alias(libs.plugins.android.application)
139
+ alias(libs.plugins.kotlin.compose)
140
+ }
141
+
142
+ android {
143
+ namespace = "com.example.app"
144
+ compileSdk = 37
145
+ defaultConfig {
146
+ applicationId = "com.example.app"
147
+ minSdk = 26
148
+ targetSdk = 37
149
+ versionCode = 1
150
+ versionName = "1.0"
151
+ // Credentials arrive from the environment at build time, never from a
152
+ // committed constant. Absent in a checkout with no secrets, and the
153
+ // app says it is off rather than reporting nowhere in silence.
154
+ buildConfigField("String", "SENTRY_DSN", "\"${System.getenv("SENTRY_DSN") ?: ""}\"")
155
+ buildConfigField("String", "POSTHOG_API_KEY", "\"${System.getenv("POSTHOG_API_KEY") ?: ""}\"")
156
+ buildConfigField(
157
+ "String",
158
+ "POSTHOG_HOST",
159
+ "\"${System.getenv("POSTHOG_HOST") ?: "https://eu.i.posthog.com"}\"",
160
+ )
161
+ }
162
+ buildFeatures {
163
+ compose = true
164
+ buildConfig = true
165
+ }
166
+ }
167
+
168
+ dependencies {
169
+ implementation(project(":designsystem"))
170
+ implementation(libs.sentry.android)
171
+ implementation(libs.posthog.android)
172
+ implementation(platform(libs.compose.bom))
173
+ implementation(libs.compose.ui)
174
+ implementation(libs.compose.material3)
175
+ implementation(libs.activity.compose)
176
+ testImplementation(libs.junit)
177
+ // Compose UI tests on the JVM: the real Compose runtime under robolectric,
178
+ // seconds rather than the minutes an emulator costs. That speed is what
179
+ // makes it realistic to test every component instead of a token few.
180
+ testImplementation(libs.robolectric)
181
+ testImplementation(platform(libs.compose.bom))
182
+ testImplementation(libs.compose.ui.test.junit4)
183
+ debugImplementation(libs.compose.ui.test.manifest)
184
+ // A leaked activity, fragment, view model or view is reported with the
185
+ // chain that holds it, while the screen is being built — not as an OOM
186
+ // in a crash report months later. Debug only; it ships nothing.
187
+ debugImplementation(libs.leakcanary.android)
188
+ }
189
+
190
+ // Robolectric needs android resources on the unit-test classpath.
191
+ android.testOptions.unitTests.isIncludeAndroidResources = true
192
+ KTS
193
+
194
+ # A Compose-only app has no AppCompat dependency, so an @style/Theme.AppCompat
195
+ # reference fails resource linking. Use a platform theme.
196
+ cat > app/src/main/AndroidManifest.xml <<'XML'
197
+ <?xml version="1.0" encoding="utf-8"?>
198
+ <manifest xmlns:android="http://schemas.android.com/apk/res/android">
199
+ <!-- android:name is what makes App.onCreate run. Without it the class
200
+ exists, compiles, and never executes — so observability is wired and
201
+ silently off. -->
202
+ <application
203
+ android:name=".App"
204
+ android:label="App"
205
+ android:theme="@android:style/Theme.Material.Light.NoActionBar">
206
+ <activity android:name=".MainActivity" android:exported="true">
207
+ <intent-filter>
208
+ <action android:name="android.intent.action.MAIN" />
209
+ <category android:name="android.intent.category.LAUNCHER" />
210
+ </intent-filter>
211
+ </activity>
212
+ </application>
213
+ </manifest>
214
+ XML
215
+
216
+ cat > app/src/main/kotlin/com/example/app/MainActivity.kt <<'KT'
217
+ package com.example.app
218
+
219
+ import android.os.Bundle
220
+ import androidx.activity.ComponentActivity
221
+ import androidx.activity.compose.setContent
222
+ import androidx.compose.foundation.layout.padding
223
+ import androidx.compose.material3.Text
224
+ import androidx.compose.ui.Modifier
225
+ import com.example.app.observability.AnalyticsEvent
226
+ import com.example.app.observability.AnalyticsSink
227
+ import com.example.app.observability.PostHogSink
228
+ import designsystem.Tokens
229
+
230
+ class MainActivity : ComponentActivity() {
231
+ private val analytics: AnalyticsSink = PostHogSink()
232
+
233
+ override fun onCreate(savedInstanceState: Bundle?) {
234
+ super.onCreate(savedInstanceState)
235
+ // At the screen, not in a wrapper three layers away that eventually
236
+ // fires for the wrong reason.
237
+ analytics.capture(AnalyticsEvent.ScreenViewed("home"))
238
+ setContent {
239
+ Text("App", Modifier.padding(Tokens.Spacing.gutter))
240
+ }
241
+ }
242
+ }
243
+ KT
244
+
245
+ cat > app/src/main/kotlin/com/example/app/App.kt <<'KT'
246
+ package com.example.app
247
+
248
+ import android.app.Application
249
+ import com.example.app.observability.Observability
250
+
251
+ /**
252
+ * Started before anything else, so a crash during startup is still reported.
253
+ */
254
+ class App : Application() {
255
+ override fun onCreate() {
256
+ super.onCreate()
257
+ Observability.start(this)
258
+ }
259
+ }
260
+ KT
261
+
262
+ mkdir -p app/src/main/kotlin/com/example/app/observability \
263
+ app/src/test/kotlin/com/example/app/observability
264
+
265
+ cat > app/src/main/kotlin/com/example/app/observability/Events.kt <<'KT'
266
+ package com.example.app.observability
267
+
268
+ /**
269
+ * Every analytics event this app can send, and its payload.
270
+ *
271
+ * One place, and a sealed type rather than strings at call sites. A string
272
+ * literal is how analytics quietly rots: the name changes here, the call site
273
+ * still compiles, and the funnel goes silent with nobody the wiser.
274
+ *
275
+ * Naming is `object_action` and never mixes styles. See
276
+ * .claude/rules/analytics-kpi.orchestra.md.
277
+ */
278
+ sealed class AnalyticsEvent(val name: String, val properties: Map<String, String>) {
279
+ /** Every screen reports a view. The screen, not the file. */
280
+ class ScreenViewed(screen: String) :
281
+ AnalyticsEvent("screen_viewed", mapOf("screen" to screen))
282
+
283
+ /**
284
+ * A critical flow start, success, and failure — the last with a reason
285
+ * code. A funnel that only records success cannot tell you why people
286
+ * left, which is the only thing you wanted to know.
287
+ */
288
+ class FlowStarted(flow: String) : AnalyticsEvent("flow_started", mapOf("flow" to flow))
289
+
290
+ class FlowCompleted(flow: String) : AnalyticsEvent("flow_completed", mapOf("flow" to flow))
291
+
292
+ class FlowFailed(flow: String, reason: String) :
293
+ AnalyticsEvent("flow_failed", mapOf("flow" to flow, "reason" to reason))
294
+ }
295
+
296
+ /** The seam. Production sends to PostHog; a test captures instead. */
297
+ interface AnalyticsSink {
298
+ fun capture(event: AnalyticsEvent)
299
+ }
300
+ KT
301
+
302
+ cat > app/src/main/kotlin/com/example/app/observability/Observability.kt <<'KT'
303
+ package com.example.app.observability
304
+
305
+ import android.content.Context
306
+ import android.util.Log
307
+ import com.example.app.BuildConfig
308
+ import com.posthog.android.PostHogAndroid
309
+ import com.posthog.android.PostHogAndroidConfig
310
+ import com.posthog.PostHog
311
+ import io.sentry.android.core.SentryAndroid
312
+
313
+ /**
314
+ * Error monitoring and analytics, started once in Application.onCreate.
315
+ *
316
+ * Both are skipped when their credential is absent — a checkout with no
317
+ * secrets has to build, run and test, or nobody can work on the app without
318
+ * production keys. Absent is logged, never silent: an app reporting nowhere
319
+ * while appearing to report is the failure this guards against.
320
+ */
321
+ object Observability {
322
+ private const val TAG = "observability"
323
+
324
+ fun start(context: Context) {
325
+ if (BuildConfig.SENTRY_DSN.isNotEmpty()) {
326
+ SentryAndroid.init(context) { options ->
327
+ options.dsn = BuildConfig.SENTRY_DSN
328
+ // Release tagging is required: an error you cannot tie to a
329
+ // build is an error you cannot fix.
330
+ options.release = "${BuildConfig.APPLICATION_ID}@${BuildConfig.VERSION_NAME}+${BuildConfig.VERSION_CODE}"
331
+ options.environment = if (BuildConfig.DEBUG) "development" else "production"
332
+ // Scrub before send, not after.
333
+ options.isSendDefaultPii = false
334
+ }
335
+ } else {
336
+ Log.w(TAG, "no SENTRY_DSN in this build — crash reporting is off")
337
+ }
338
+
339
+ if (BuildConfig.POSTHOG_API_KEY.isNotEmpty()) {
340
+ PostHogAndroid.setup(
341
+ context,
342
+ PostHogAndroidConfig(
343
+ apiKey = BuildConfig.POSTHOG_API_KEY,
344
+ host = BuildConfig.POSTHOG_HOST,
345
+ ),
346
+ )
347
+ } else {
348
+ Log.w(TAG, "no POSTHOG_API_KEY in this build — analytics is off")
349
+ }
350
+ }
351
+ }
352
+
353
+ /** Sends to PostHog when it is configured, and nowhere when it is not. */
354
+ class PostHogSink : AnalyticsSink {
355
+ override fun capture(event: AnalyticsEvent) {
356
+ PostHog.capture(event.name, properties = event.properties)
357
+ }
358
+ }
359
+ KT
360
+
361
+ cat > app/src/test/kotlin/com/example/app/observability/EventsTest.kt <<'KT'
362
+ package com.example.app.observability
363
+
364
+ import org.junit.Assert.assertEquals
365
+ import org.junit.Assert.assertFalse
366
+ import org.junit.Assert.assertTrue
367
+ import org.junit.Test
368
+
369
+ /**
370
+ * Instrumentation is code, so it is tested like code.
371
+ *
372
+ * The sink is the seam: these drive the real event types through a capturing
373
+ * implementation, so a renamed event or a changed payload fails here rather
374
+ * than in a dashboard three weeks later.
375
+ */
376
+ private class CapturingSink : AnalyticsSink {
377
+ val captured = mutableListOf<AnalyticsEvent>()
378
+ override fun capture(event: AnalyticsEvent) {
379
+ captured += event
380
+ }
381
+ }
382
+
383
+ class EventsTest {
384
+ @Test
385
+ fun `a screen view fires once with the screen name`() {
386
+ val sink = CapturingSink()
387
+ sink.capture(AnalyticsEvent.ScreenViewed("home"))
388
+
389
+ assertEquals(1, sink.captured.size)
390
+ assertEquals("screen_viewed", sink.captured.first().name)
391
+ assertEquals(mapOf("screen" to "home"), sink.captured.first().properties)
392
+ }
393
+
394
+ @Test
395
+ fun `every event name is object_action and lower case`() {
396
+ // Boring and consistent, decided once. A mixed convention is
397
+ // unqueryable six months later.
398
+ for (event in allEvents()) {
399
+ assertTrue("${event.name} is not object_action", event.name.contains("_"))
400
+ assertEquals(event.name, event.name.lowercase())
401
+ }
402
+ }
403
+
404
+ @Test
405
+ fun `a failure carries a reason code`() {
406
+ val event = AnalyticsEvent.FlowFailed("checkout", "card_declined")
407
+ assertEquals("card_declined", event.properties["reason"])
408
+ }
409
+
410
+ @Test
411
+ fun `no event carries free text the user typed`() {
412
+ // Property values are identifiers, not sentences. Crude on purpose: a
413
+ // space is the cheapest signal that something typed has leaked in.
414
+ for (event in allEvents()) {
415
+ for ((key, value) in event.properties) {
416
+ assertFalse("$key looks like free text: $value", value.contains(" "))
417
+ }
418
+ }
419
+ }
420
+
421
+ private fun allEvents() = listOf(
422
+ AnalyticsEvent.ScreenViewed("home"),
423
+ AnalyticsEvent.FlowStarted("signup"),
424
+ AnalyticsEvent.FlowCompleted("signup"),
425
+ AnalyticsEvent.FlowFailed("signup", "network"),
426
+ )
427
+ }
428
+ KT
429
+
430
+ cat > designsystem/src/main/kotlin/designsystem/Tokens.kt <<'KT'
431
+ package designsystem
432
+
433
+ import androidx.compose.ui.unit.dp
434
+
435
+ /**
436
+ * Design tokens. Populated from the Claude Design source before any
437
+ * composable is written. Color is one axis of several — do not stop there.
438
+ */
439
+ object Tokens {
440
+ object Spacing {
441
+ val gutter = 16.dp
442
+ }
443
+
444
+ object Radius {
445
+ val control = 8.dp
446
+ val surface = 12.dp
447
+ }
448
+
449
+ object Motion {
450
+ const val FAST_MS = 120
451
+ }
452
+ }
453
+ KT
454
+
455
+ cat > designsystem/src/test/kotlin/designsystem/TokensTest.kt <<'KT'
456
+ package designsystem
457
+
458
+ import org.junit.Assert.assertTrue
459
+ import org.junit.Test
460
+
461
+ class TokensTest {
462
+ @Test
463
+ fun controlRadiusIsSmallerThanSurface() {
464
+ assertTrue(Tokens.Radius.control < Tokens.Radius.surface)
465
+ }
466
+
467
+ @Test
468
+ fun gutterIsPositive() {
469
+ assertTrue(Tokens.Spacing.gutter.value > 0f)
470
+ }
471
+ }
472
+ KT
473
+
474
+ # Robolectric ships a stubbed android-all jar per API level, and it always
475
+ # lags the newest platform — here compileSdk 37 against a Robolectric that
476
+ # supports at most 36, which fails as "targetSdkVersion=37 > maxSdkVersion=36"
477
+ # before a single test runs.
478
+ #
479
+ # This is a deliberate constant, like compileSdk itself, not a resolved
480
+ # version: it must never exceed what the installed Robolectric supports.
481
+ # Raise it when Robolectric adds the platform, and check with
482
+ # ./gradlew :designsystem:testDebugUnitTest --info | grep -i sdk
483
+ mkdir -p designsystem/src/test/resources app/src/test/resources
484
+ for m in designsystem app; do
485
+ cat > "$m/src/test/resources/robolectric.properties" <<'PROPS'
486
+ # The newest API level this Robolectric supports. Not the app's targetSdk —
487
+ # Robolectric lags the platform, and inheriting targetSdk fails every test.
488
+ sdk=36
489
+ PROPS
490
+ done
491
+ log "robolectric pinned to sdk 36 (it lags compileSdk 37)"
492
+
493
+ # A Compose UI test, on the JVM. Queries the semantics tree — which is the
494
+ # tree TalkBack reads — so a control with no text or content description
495
+ # fails the test, and accessibility is enforced rather than reviewed.
496
+ cat > designsystem/src/test/kotlin/designsystem/AccessibleButtonTest.kt <<'KT'
497
+ package designsystem
498
+
499
+ import androidx.compose.material3.Button
500
+ import androidx.compose.material3.Text
501
+ import androidx.compose.ui.test.assertHasClickAction
502
+ import androidx.compose.ui.test.junit4.createComposeRule
503
+ import androidx.compose.ui.test.onNodeWithText
504
+ import androidx.compose.ui.test.performClick
505
+ import org.junit.Assert.assertTrue
506
+ import org.junit.Rule
507
+ import org.junit.Test
508
+ import org.junit.runner.RunWith
509
+ import org.robolectric.RobolectricTestRunner
510
+
511
+ @RunWith(RobolectricTestRunner::class)
512
+ class AccessibleButtonTest {
513
+ @get:Rule val compose = createComposeRule()
514
+
515
+ @Test
516
+ fun `a labelled button is reachable and clickable`() {
517
+ var clicked = false
518
+ compose.setContent {
519
+ Button(onClick = { clicked = true }) { Text("Add water") }
520
+ }
521
+
522
+ // By text, never by test tag: this is the assertion that fails when
523
+ // the control has no accessible name.
524
+ compose.onNodeWithText("Add water").assertHasClickAction().performClick()
525
+
526
+ assertTrue("the click handler fired", clicked)
527
+ }
528
+ }
529
+ KT
530
+
531
+ cat > verify.sh <<'V'
532
+ #!/usr/bin/env bash
533
+ # Normalised verification gate. Orchestra runs this instead of the npm
534
+ # commands in config.yml, because this app is not npm-based.
535
+ set -euo pipefail
536
+
537
+ # gradlew needs a JVM on PATH. Homebrew's gradle bundles one that the wrapper
538
+ # does not see, so resolve it rather than failing with "Unable to locate a
539
+ # Java Runtime".
540
+ # 21 specifically, and the newest is the wrong default. Robolectric
541
+ # instruments bytecode with a bundled ASM, and ASM refuses a class file newer
542
+ # than it knows: a JDK 26 on this PATH produced
543
+ #
544
+ # java.lang.IllegalArgumentException: Unsupported class file major version 70
545
+ # at org.robolectric.internal.bytecode.ClassNodeProvider.createClassNode
546
+ #
547
+ # after a full compile, naming a class file version rather than the JDK.
548
+ if [ -z "${JAVA_HOME:-}" ]; then
549
+ for candidate in "$(/usr/libexec/java_home -v 21 2>/dev/null || true)" \
550
+ /opt/homebrew/opt/openjdk@21/libexec/openjdk.jdk/Contents/Home \
551
+ /usr/lib/jvm/temurin-21-jdk-amd64 \
552
+ /usr/lib/jvm/java-21-openjdk-amd64; do
553
+ [ -n "$candidate" ] && [ -x "$candidate/bin/java" ] && { export JAVA_HOME="$candidate"; break; }
554
+ done
555
+ fi
556
+ [ -n "${JAVA_HOME:-}" ] || {
557
+ echo "no JDK 21 found — robolectric cannot instrument bytecode from a newer one" >&2
558
+ echo " brew install openjdk@21" >&2
559
+ exit 1
560
+ }
561
+
562
+ # AGP's minimum is 17, but robolectric's stubbed jar for a recent API level
563
+ # refuses to start below 21 — "Android SDK 36 requires Java 21 (have Java
564
+ # 17)" — and it says so only after a full compile. Fail here instead, where
565
+ # the message is actionable.
566
+ java_major="$("$JAVA_HOME/bin/java" -version 2>&1 | head -1 | sed -E 's/.*"([0-9]+).*/\1/')"
567
+ case "$java_major" in
568
+ ''|*[!0-9]*) echo "could not read the java version from $JAVA_HOME" >&2; exit 1 ;;
569
+ esac
570
+ # A floor with no ceiling is not a check: it selected the newest JDK
571
+ # installed and called that satisfied.
572
+ [ "$java_major" -eq 21 ] || {
573
+ echo "JAVA_HOME is JDK ${java_major}; this build needs 21 exactly." >&2
574
+ echo " Below it, robolectric's stubbed jar for API 36 refuses to start." >&2
575
+ echo " Above it, robolectric's ASM cannot read the class files produced." >&2
576
+ echo " brew install openjdk@21" >&2
577
+ exit 1
578
+ }
579
+
580
+ echo "── compile"
581
+ ./gradlew :app:assembleDebug --no-daemon
582
+
583
+ echo "── release build"
584
+ # R8 shrinks, optimises and obfuscates. Reflection stops resolving, a
585
+ # serialisation library loses the field names it needed, a missing keep rule
586
+ # removes a class loaded by name. Every test is green, assembleDebug is fine,
587
+ # and the release APK crashes on launch. This is the only step that sees it.
588
+ ./gradlew :app:assembleRelease --no-daemon
589
+
590
+ echo "── test"
591
+ ./gradlew :designsystem:test :app:test --no-daemon
592
+
593
+ echo "── lint"
594
+ ./gradlew lint --no-daemon || echo "lint task unavailable — skipped"
595
+ V
596
+ chmod +x verify.sh
597
+
598
+ log "generating gradle wrapper"
599
+ GRADLE_V="$(gradle_latest 9.7.1)"
600
+ gradle wrapper --gradle-version "$GRADLE_V" >/dev/null 2>&1 || warn "wrapper generation failed — run 'gradle wrapper' manually"
601
+ log " gradle wrapper $GRADLE_V"
602
+
603
+ log "mobile-android-native ready in $DIR"
604
+ warn "verification runs through ./verify.sh, not npm — config.yml reflects that"
605
+ warn "gradle/libs.versions.toml pins explicitly because gradle has no safe @latest,"
606
+ warn "but every version in it was resolved from its repository just now, not remembered."
607
+ warn "compileSdk 37 is the one deliberate constant — it is an API level, not a dependency."