agentic-team 0.0.0-stage → 6.1.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.
- package/LICENSE +21 -0
- package/README.md +184 -2
- package/bin/build-template.js +208 -0
- package/bin/index.js +1139 -0
- package/package.json +41 -4
- package/template/.claude-plugin/plugin.json +10 -0
- package/template/.orchestra/README.md +401 -0
- package/template/.orchestra/blueprints/README.md +87 -0
- package/template/.orchestra/blueprints/api-only.md +46 -0
- package/template/.orchestra/blueprints/component-crud-resource.md +25 -0
- package/template/.orchestra/blueprints/saas-starter.md +69 -0
- package/template/.orchestra/config.yml +277 -0
- package/template/.orchestra/roles/orchestrator.md +105 -0
- package/template/.orchestra/roles/product-manager.md +438 -0
- package/template/.orchestra/scripts/compact-diff.awk +45 -0
- package/template/.orchestra/stacks/README.md +81 -0
- package/template/.orchestra/stacks/_lib/common.sh +3095 -0
- package/template/.orchestra/stacks/_repo/setup.sh +1109 -0
- package/template/.orchestra/stacks/backend-hono/claude/commands/endpoint.md +13 -0
- package/template/.orchestra/stacks/backend-hono/claude/skills/api-endpoint/SKILL.md +111 -0
- package/template/.orchestra/stacks/backend-hono/claude/skills/testing/SKILL.md +75 -0
- package/template/.orchestra/stacks/backend-hono/setup.sh +5945 -0
- package/template/.orchestra/stacks/backend-hono/stack.md +195 -0
- package/template/.orchestra/stacks/mobile-android-native/claude/commands/component.md +13 -0
- package/template/.orchestra/stacks/mobile-android-native/claude/skills/design-component/SKILL.md +58 -0
- package/template/.orchestra/stacks/mobile-android-native/setup.sh +607 -0
- package/template/.orchestra/stacks/mobile-android-native/stack.md +138 -0
- package/template/.orchestra/stacks/mobile-expo/claude/commands/component.md +12 -0
- package/template/.orchestra/stacks/mobile-expo/claude/skills/design-component/SKILL.md +92 -0
- package/template/.orchestra/stacks/mobile-expo/setup.sh +470 -0
- package/template/.orchestra/stacks/mobile-expo/stack.md +141 -0
- package/template/.orchestra/stacks/mobile-ios-native/claude/commands/component.md +12 -0
- package/template/.orchestra/stacks/mobile-ios-native/claude/skills/design-component/SKILL.md +58 -0
- package/template/.orchestra/stacks/mobile-ios-native/setup.sh +468 -0
- package/template/.orchestra/stacks/mobile-ios-native/stack.md +130 -0
- package/template/.orchestra/stacks/web-nextjs/claude/commands/component.md +12 -0
- package/template/.orchestra/stacks/web-nextjs/claude/skills/design-component/SKILL.md +100 -0
- package/template/.orchestra/stacks/web-nextjs/claude/skills/design-tokens/SKILL.md +61 -0
- package/template/.orchestra/stacks/web-nextjs/setup.sh +128 -0
- package/template/.orchestra/stacks/web-nextjs/stack.md +170 -0
- package/template/.orchestra/stacks/web-vite-react/claude/commands/component.md +12 -0
- package/template/.orchestra/stacks/web-vite-react/claude/skills/design-component/SKILL.md +100 -0
- package/template/.orchestra/stacks/web-vite-react/claude/skills/design-tokens/SKILL.md +61 -0
- package/template/.orchestra/stacks/web-vite-react/setup.sh +185 -0
- package/template/.orchestra/stacks/web-vite-react/stack.md +177 -0
- package/template/.orchestra/trackers/github.sh +467 -0
- package/template/CLAUDE.md +54 -0
- package/template/agents/architect.md +83 -0
- package/template/agents/engineer-high.md +17 -0
- package/template/agents/engineer-low.md +17 -0
- package/template/agents/engineer-max.md +17 -0
- package/template/agents/engineer-medium.md +17 -0
- package/template/agents/engineer-xhigh.md +17 -0
- package/template/agents/lead.md +870 -0
- package/template/agents/milestone-reviewer.md +63 -0
- package/template/agents/orchestrator.md +105 -0
- package/template/agents/product-manager.md +438 -0
- package/template/agents/reviewer.md +301 -0
- package/template/agents/scaffolder.md +180 -0
- package/template/agents/tracker.md +202 -0
- package/template/commands/blueprint.md +23 -0
- package/template/commands/create-role.md +64 -0
- package/template/commands/export.md +45 -0
- package/template/commands/help.md +55 -0
- package/template/commands/hotfix.md +12 -0
- package/template/commands/orchestrator.md +12 -0
- package/template/commands/pm.md +18 -0
- package/template/commands/rewind.md +61 -0
- package/template/commands/setup.md +14 -0
- package/template/commands/start.md +29 -0
- package/template/commands/status.md +11 -0
- package/template/commands/sync.md +73 -0
- package/template/commands/verifier.md +53 -0
- package/template/hooks/audit-context.sh +153 -0
- package/template/hooks/enforce-role.sh +175 -0
- package/template/hooks/protect-system-files.sh +123 -0
- package/template/hooks/review-rounds.sh +172 -0
- package/template/rules/acceptance-check.orchestra.md +11 -0
- package/template/rules/analytics-kpi.orchestra.md +56 -0
- package/template/rules/code-standards.orchestra.md +34 -0
- package/template/rules/comments.orchestra.md +77 -0
- package/template/rules/commit-format.orchestra.md +20 -0
- package/template/rules/dependency-upgrade.orchestra.md +112 -0
- package/template/rules/design-parity.orchestra.md +170 -0
- package/template/rules/observability.orchestra.md +118 -0
- package/template/rules/performance.orchestra.md +186 -0
- package/template/rules/phase-limits.orchestra.md +3 -0
- package/template/rules/pull-request.orchestra.md +213 -0
- package/template/rules/role-boundaries.orchestra.md +117 -0
- package/template/rules/security.orchestra.md +106 -0
- package/template/rules/stuck-detection.orchestra.md +25 -0
- package/template/rules/testing-standards.orchestra.md +214 -0
- package/template/rules/tracker-authority.orchestra.md +190 -0
- package/template/rules/verification-gate.orchestra.md +56 -0
- package/template/skills/accessibility/SKILL.md +31 -0
- package/template/skills/android-testing/SKILL.md +112 -0
- package/template/skills/architecture/SKILL.md +175 -0
- package/template/skills/auth-setup/SKILL.md +105 -0
- package/template/skills/best-practices/SKILL.md +29 -0
- package/template/skills/code-optimizer/SKILL.md +27 -0
- package/template/skills/core-web-vitals/SKILL.md +32 -0
- package/template/skills/crud-api/SKILL.md +32 -0
- package/template/skills/debug/SKILL.md +28 -0
- package/template/skills/deployment/SKILL.md +104 -0
- package/template/skills/error-handling/SKILL.md +154 -0
- package/template/skills/frontend-design/SKILL.md +30 -0
- package/template/skills/fullstack-infrastructure/SKILL.md +108 -0
- package/template/skills/ios-testing/SKILL.md +100 -0
- package/template/skills/mobile-testing/SKILL.md +102 -0
- package/template/skills/observability/SKILL.md +91 -0
- package/template/skills/project-structure/SKILL.md +114 -0
- package/template/skills/push-notifications/SKILL.md +146 -0
- package/template/skills/react-best-practices/SKILL.md +31 -0
- package/template/skills/realtime/SKILL.md +146 -0
- package/template/skills/review/SKILL.md +82 -0
- package/template/skills/testing/SKILL.md +44 -0
- package/template/skills/web-quality-audit/SKILL.md +47 -0
- 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."
|