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,1113 @@
1
+ #!/usr/bin/env bash
2
+ # Repo-level configuration, shared by every stack. Run once, after the app
3
+ # scaffolds, with the app directory names as arguments.
4
+ #
5
+ # These files are what turn the conventions from advice into enforcement.
6
+ # Without them the rules are prose an agent may or may not follow.
7
+ set -euo pipefail
8
+
9
+ LIB="$(cd "$(dirname "${BASH_SOURCE[0]}")/../_lib" && pwd)"
10
+ # shellcheck source=../_lib/common.sh
11
+ source "$LIB/common.sh"
12
+
13
+ MODE="standalone"
14
+ case "${1:-}" in
15
+ --standalone) MODE="standalone"; shift ;;
16
+ --workspace) MODE="workspace"; shift ;;
17
+ esac
18
+ STANDALONE=0
19
+ [ "$MODE" = "standalone" ] && STANDALONE=1
20
+
21
+ # Everything below writes repo-relative paths, and `git config core.hooksPath`
22
+ # resolves from the repo root. Running from a subdirectory would scatter config
23
+ # where git never looks — hooks configured, hooks never fire, no error.
24
+ REPO_ROOT="$(git rev-parse --show-toplevel 2>/dev/null)" || die "not inside a git repository"
25
+ cd "$REPO_ROOT"
26
+
27
+ APPS=("$@")
28
+ [ ${#APPS[@]} -gt 0 ] || die "usage: setup.sh [--standalone|--workspace] <app-dir> [app-dir...]"
29
+
30
+ mkdir -p .github/workflows
31
+
32
+ # ── CI ────────────────────────────────────────────────────────────────────
33
+ # The verification gate only means something if it also runs on the PR.
34
+ # Locally it can be skipped; here it cannot.
35
+ #
36
+ # Not uniform: an app with verify.sh runs that (native toolchains), and iOS
37
+ # needs a macOS runner. A single npm matrix would leave native apps
38
+ # permanently red on the check we ask you to make required.
39
+ {
40
+ cat <<'YML'
41
+ name: CI
42
+
43
+ on:
44
+ pull_request:
45
+ push:
46
+ branches: [main]
47
+
48
+ concurrency:
49
+ group: ci-${{ github.ref }}
50
+ cancel-in-progress: true
51
+
52
+ jobs:
53
+ verify:
54
+ runs-on: ${{ matrix.os }}
55
+ strategy:
56
+ fail-fast: false
57
+ matrix:
58
+ include:
59
+ YML
60
+ for a in "${APPS[@]}"; do
61
+ os="ubuntu-latest"
62
+ kind="node"
63
+ browser="no"
64
+ if [ -f "$a/verify.sh" ]; then
65
+ kind="script"
66
+ case "$a" in *ios*|*Ios*|*iOS*) os="macos-latest" ;; esac
67
+ [ -f "$a/project.yml" ] && os="macos-latest"
68
+ elif [ -f "$a/package.json" ] && grep -q '@vitest/browser' "$a/package.json"; then
69
+ # Vitest browser mode launches a real browser, which the runner image
70
+ # does not ship.
71
+ browser="yes"
72
+ fi
73
+ printf ' - { app: %s, os: %s, kind: %s, browser: %s }\n' "$a" "$os" "$kind" "$browser"
74
+ done
75
+ cat <<'YML'
76
+ name: ${{ matrix.app }}
77
+ defaults:
78
+ run:
79
+ working-directory: ${{ matrix.app }}
80
+ env:
81
+ # Matches the app's docker-compose.yml. Harmless where there is no
82
+ # database — it is one unused variable.
83
+ DATABASE_URL: postgres://postgres:postgres@localhost:55432/app
84
+ steps:
85
+ - uses: actions/checkout@__CHECKOUT_REF__
86
+ with:
87
+ # The diff-coverage and mutation gates diff against the PR base.
88
+ # A shallow clone has no base commit to diff against. gitleaks
89
+ # needs the full history for the same reason — a secret committed
90
+ # and then removed is still in the history, and still leaked.
91
+ fetch-depth: 0
92
+
93
+ # corepack must come first: setup-node's yarn cache shells out to yarn,
94
+ # and without corepack that resolves to the runner's global yarn 1.x,
95
+ # which refuses a project pinned to yarn 4.
96
+ - name: Enable corepack
97
+ if: matrix.kind == 'node'
98
+ run: corepack enable
99
+
100
+ - uses: actions/setup-node@__SETUP_NODE_REF__
101
+ if: matrix.kind == 'node'
102
+ with:
103
+ node-version-file: ${{ matrix.app }}/.node-version
104
+ cache: yarn
105
+ # setup-node resolves this from the repo root, not from
106
+ # working-directory. Listing both covers a lockfile per app
107
+ # (standalone) and a single root lockfile (workspace); it only
108
+ # errors when neither matches.
109
+ cache-dependency-path: |
110
+ yarn.lock
111
+ ${{ matrix.app }}/yarn.lock
112
+
113
+ # --immutable is the point: CI fails rather than silently resolving a
114
+ # dependency the lockfile does not describe. A workspace keeps its one
115
+ # lockfile at the root, so install where it actually lives.
116
+ - name: Install
117
+ if: matrix.kind == 'node'
118
+ run: |
119
+ if [ -f yarn.lock ]; then yarn install --immutable
120
+ else (cd "$GITHUB_WORKSPACE" && yarn install --immutable); fi
121
+
122
+ - name: Install browser
123
+ if: matrix.browser == 'yes'
124
+ run: yarn playwright install --with-deps chromium
125
+
126
+ # The app's own compose file rather than a job-level `services:` block:
127
+ # services cannot be made conditional, and this job's matrix includes
128
+ # macOS runners for the native apps, where service containers do not
129
+ # exist. Reusing compose also means the postgres version is pinned in
130
+ # one place and CI runs the same path a developer does.
131
+ - name: Database
132
+ if: matrix.kind == 'node'
133
+ run: |
134
+ if [ -f docker-compose.yml ]; then
135
+ docker compose up -d --wait
136
+ else
137
+ echo "no docker-compose.yml in this app — skipped"
138
+ fi
139
+
140
+ - name: Verify
141
+ if: matrix.kind == 'node'
142
+ run: |
143
+ yarn typecheck
144
+ yarn test
145
+ yarn lint
146
+ yarn test:coverage
147
+ # A typecheck is not a build. tsc accepts an app that fails to
148
+ # bundle, and nothing else here loads the bundler.
149
+ if yarn run --silent 2>/dev/null | grep -qx 'build'; then yarn build; fi
150
+ # A known critical vulnerability blocks the merge.
151
+ yarn npm audit --severity critical
152
+
153
+ # The built app, driven in a real browser. Component tests mount one
154
+ # component in isolation and a typecheck never loads the bundler, so a
155
+ # broken route or a bundle that throws on load reaches this step and
156
+ # nothing before it. Guarded on the script being playwright's — an Expo
157
+ # app's test:e2e is maestro, and belongs in mobile-e2e.yml.
158
+ - name: End-to-end
159
+ if: matrix.kind == 'node'
160
+ run: |
161
+ if node -e 'const s=require("./package.json").scripts||{};process.exit(/playwright/.test(s["test:e2e"]||"")?0:1)'; then
162
+ yarn test:e2e
163
+ else
164
+ echo "no playwright e2e in this app — skipped"
165
+ fi
166
+
167
+ - uses: actions/upload-artifact@__UPLOAD_REF__
168
+ if: failure() && matrix.kind == 'node'
169
+ with:
170
+ name: playwright-${{ matrix.app }}
171
+ path: |
172
+ ${{ matrix.app }}/playwright-report/
173
+ ${{ matrix.app }}/test-results/
174
+ if-no-files-found: ignore
175
+
176
+ # Two commands that see what typecheck, lint and jest cannot: a native
177
+ # module off its SDK's compatibility table. Cheap, and the only thing in
178
+ # CI that catches the defect class that passes every JS test and then
179
+ # fails on a device.
180
+ - name: Native dependency check
181
+ if: matrix.kind == 'node'
182
+ run: |
183
+ if yarn run --silent 2>/dev/null | grep -qx 'doctor'; then
184
+ yarn doctor
185
+ else
186
+ echo "no doctor script in this app — skipped"
187
+ fi
188
+
189
+ # The gate that asks about this change rather than the repo's history:
190
+ # branch coverage of the lines this PR touched. Only meaningful on a PR,
191
+ # because only a PR has a base to diff against.
192
+ - name: Coverage of changed lines
193
+ if: matrix.kind == 'node' && github.event_name == 'pull_request'
194
+ env:
195
+ DIFF_COVERAGE_BASE: origin/${{ github.base_ref }}
196
+ run: |
197
+ if yarn run --silent 2>/dev/null | grep -q 'test:coverage:diff'; then
198
+ yarn test:coverage:diff
199
+ else
200
+ echo "no test:coverage:diff script in this app — skipped"
201
+ fi
202
+
203
+ # Stryker caches its results here; without the cache every PR pays for
204
+ # a cold run.
205
+ - name: Restore mutation cache
206
+ if: matrix.kind == 'node' && github.event_name == 'pull_request'
207
+ uses: actions/cache@__CACHE_REF__
208
+ with:
209
+ path: ${{ matrix.app }}/reports
210
+ key: stryker-${{ matrix.app }}-${{ github.sha }}
211
+ restore-keys: stryker-${{ matrix.app }}-
212
+
213
+ # Coverage says the line ran. This says it was checked.
214
+ - name: Mutation testing (changed files)
215
+ if: matrix.kind == 'node' && github.event_name == 'pull_request'
216
+ env:
217
+ MUTATION_BASE: origin/${{ github.base_ref }}
218
+ run: |
219
+ if yarn run --silent 2>/dev/null | grep -q 'test:mutation:diff'; then
220
+ yarn test:mutation:diff
221
+ else
222
+ echo "no test:mutation:diff script in this app — skipped"
223
+ fi
224
+
225
+ - name: Verify (native)
226
+ if: matrix.kind == 'script'
227
+ run: ./verify.sh
228
+ YML
229
+ } > .github/workflows/ci.yml
230
+
231
+ # Resolved now, not written from memory. The tokens above exist because the
232
+ # workflow heredoc is quoted — it has to be, ${{ ... }} is GitHub's syntax,
233
+ # not bash's — so the refs are substituted after the file is written.
234
+ CHECKOUT_REF="$(latest_action_ref actions/checkout v5)"
235
+ SETUP_NODE_REF="$(latest_action_ref actions/setup-node v5)"
236
+ CACHE_REF="$(latest_action_ref actions/cache v4)"
237
+ UPLOAD_ARTIFACT_REF="$(latest_action_ref actions/upload-artifact v4)"
238
+ sed -i.bak \
239
+ -e "s|@__CHECKOUT_REF__|@${CHECKOUT_REF}|" \
240
+ -e "s|@__SETUP_NODE_REF__|@${SETUP_NODE_REF}|" \
241
+ -e "s|@__CACHE_REF__|@${CACHE_REF}|" \
242
+ -e "s|@__UPLOAD_REF__|@${UPLOAD_ARTIFACT_REF}|" \
243
+ .github/workflows/ci.yml
244
+ rm -f .github/workflows/ci.yml.bak
245
+ log "ci workflow (${#APPS[@]} apps) — checkout@${CHECKOUT_REF}, setup-node@${SETUP_NODE_REF}, cache@${CACHE_REF}"
246
+
247
+ # ── Secret scanning ───────────────────────────────────────────────────────
248
+ # The one control on the list that nothing enforced. A committed secret is
249
+ # compromised the moment it is pushed — removing the commit is not the fix,
250
+ # rotating the credential is — so this has to fail before the merge, not
251
+ # after someone notices.
252
+ GITLEAKS_REF="$(latest_action_ref gitleaks/gitleaks-action v2)"
253
+ cat > .github/workflows/secrets.yml <<YML
254
+ name: secrets
255
+
256
+ on:
257
+ pull_request:
258
+ push:
259
+ branches: [main]
260
+
261
+ jobs:
262
+ gitleaks:
263
+ runs-on: ubuntu-latest
264
+ steps:
265
+ - uses: actions/checkout@${CHECKOUT_REF}
266
+ with:
267
+ # A secret that was committed and later deleted is still leaked.
268
+ # Scanning only the tip would miss every one of those.
269
+ fetch-depth: 0
270
+
271
+ - uses: gitleaks/gitleaks-action@${GITLEAKS_REF}
272
+ env:
273
+ GITHUB_TOKEN: \${{ secrets.GITHUB_TOKEN }}
274
+ YML
275
+ log "secret scanning workflow (gitleaks@${GITLEAKS_REF}, full history)"
276
+
277
+ # ── Dependabot ────────────────────────────────────────────────────────────
278
+ # This is the mechanism the whole testing rule exists to serve: a bump
279
+ # arrives as a PR, CI runs the suite, green means safe to merge.
280
+ {
281
+ cat <<'YML'
282
+ version: 2
283
+ updates:
284
+ - package-ecosystem: github-actions
285
+ directory: /
286
+ schedule:
287
+ interval: weekly
288
+ commit-message:
289
+ prefix: chore
290
+ YML
291
+ for a in "${APPS[@]}"; do
292
+ if [ -f "$a/package.json" ]; then
293
+ eco="npm"
294
+ elif [ -f "$a/build.gradle.kts" ] || [ -f "$a/settings.gradle.kts" ]; then
295
+ eco="gradle"
296
+ elif [ -f "$a/Package.swift" ]; then
297
+ # Only real SwiftPM manifests. An XcodeGen project.yml is not one, and
298
+ # dependabot's swift ecosystem errors on every run without it.
299
+ eco="swift"
300
+ else
301
+ warn "no recognised ecosystem in $a — skipping dependabot entry"
302
+ continue
303
+ fi
304
+ # "." must map to "/", not "/."
305
+ dir="/${a}"; [ "$a" = "." ] && dir="/"
306
+ cat <<YML
307
+
308
+ - package-ecosystem: ${eco}
309
+ directory: ${dir}
310
+ schedule:
311
+ interval: weekly
312
+ open-pull-requests-limit: 10
313
+ commit-message:
314
+ prefix: chore
315
+ include: scope
316
+ groups:
317
+ minor-and-patch:
318
+ update-types: [minor, patch]
319
+ YML
320
+ # Expo is the exception to "green CI means merge". The SDK pins react,
321
+ # react-native and every native module, and `expo install` resolves
322
+ # against that table — so a bump to the registry's latest installs a
323
+ # version the SDK rejects. Dependabot cannot know that, would file the
324
+ # same PR every week, and merging one breaks the build. The SDK upgrade
325
+ # itself is the event that moves these, via `expo install --fix`.
326
+ if [ "$eco" = "npm" ] && node -e 'const p=require(process.argv[1]);const d={...p.dependencies,...p.devDependencies};process.exit(d.expo?0:1)' "$PWD/$a/package.json" 2>/dev/null; then
327
+ cat <<'YML'
328
+ ignore:
329
+ # Managed by the Expo SDK — move these with `expo install --fix`.
330
+ - dependency-name: expo
331
+ - dependency-name: expo-*
332
+ - dependency-name: react
333
+ - dependency-name: react-dom
334
+ - dependency-name: react-native
335
+ - dependency-name: react-native-*
336
+ - dependency-name: "@react-native/*"
337
+ - dependency-name: "@react-native-*/*"
338
+ - dependency-name: "@sentry/react-native"
339
+ # jest-expo depends on the jest 29 packages throughout; jest 30 splits
340
+ # jest-runtime from jest-mock and every suite fails.
341
+ - dependency-name: jest
342
+ - dependency-name: jest-expo
343
+ - dependency-name: "@types/jest"
344
+ - dependency-name: typescript
345
+ YML
346
+ # This whole block redirects stdout into dependabot.yml — log to stderr.
347
+ log "dependabot: expo app in $a — SDK-managed packages ignored" >&2
348
+ fi
349
+ done
350
+ } > .github/dependabot.yml
351
+ log "dependabot config"
352
+
353
+ # ── Mobile e2e ────────────────────────────────────────────────────────────
354
+ # Separate from ci.yml on purpose. This one builds a native app and boots an
355
+ # emulator; it takes fifteen minutes where the rest of CI takes two, and it
356
+ # only has anything to say when the mobile app changed.
357
+ #
358
+ # It is the layer that is not optional on mobile: jest-expo renders components
359
+ # in Node against mocked native modules, so a missing native dependency or an
360
+ # SDK mismatch passes the entire JS suite and fails on a device.
361
+ EXPO_APP=""
362
+ for a in "${APPS[@]}"; do
363
+ if [ -f "$a/package.json" ] && node -e 'const p=require(process.argv[1]);const d={...p.dependencies,...p.devDependencies};process.exit(d.expo?0:1)' "$PWD/$a/package.json" 2>/dev/null; then
364
+ EXPO_APP="$a"
365
+ break
366
+ fi
367
+ done
368
+
369
+ if [ -n "$EXPO_APP" ]; then
370
+ SETUP_JAVA_REF="$(latest_action_ref actions/setup-java v5)"
371
+ EMULATOR_REF="$(latest_action_ref reactivecircus/android-emulator-runner v2)"
372
+ UPLOAD_REF="$(latest_action_ref actions/upload-artifact v4)"
373
+ {
374
+ cat <<YML
375
+ name: mobile e2e
376
+
377
+ # Android on every pull request that touches the app — an ubuntu runner is
378
+ # cheap. iOS on main, because a macOS runner costs roughly ten times as much
379
+ # per minute and the two rarely diverge on the same day.
380
+ on:
381
+ pull_request:
382
+ paths: ["${EXPO_APP}/**"]
383
+ push:
384
+ branches: [main]
385
+
386
+ concurrency:
387
+ group: mobile-e2e-\${{ github.ref }}
388
+ cancel-in-progress: true
389
+
390
+ jobs:
391
+ android:
392
+ runs-on: ubuntu-latest
393
+ timeout-minutes: 45
394
+ defaults:
395
+ run:
396
+ working-directory: ${EXPO_APP}
397
+ env:
398
+ # A debug e2e build has no Sentry credentials and must not fail for
399
+ # want of them. Sentry's Xcode phase gates on this exact variable and
400
+ # kills the build with exit 65 otherwise.
401
+ SENTRY_DISABLE_AUTO_UPLOAD: "true"
402
+ steps:
403
+ - uses: actions/checkout@${CHECKOUT_REF}
404
+
405
+ - run: corepack enable
406
+ working-directory: .
407
+
408
+ - uses: actions/setup-node@${SETUP_NODE_REF}
409
+ with:
410
+ node-version-file: ${EXPO_APP}/.node-version
411
+ cache: yarn
412
+ cache-dependency-path: |
413
+ yarn.lock
414
+ ${EXPO_APP}/yarn.lock
415
+
416
+ - uses: actions/setup-java@${SETUP_JAVA_REF}
417
+ with:
418
+ distribution: temurin
419
+ # 21, not AGP's minimum of 17: robolectric's stubbed jar for a
420
+ # recent API level refuses to start on 17 with "Android SDK 36
421
+ # requires Java 21". AGP accepts anything at or above 17.
422
+ java-version: "21"
423
+
424
+ - name: Install
425
+ run: |
426
+ if [ -f yarn.lock ]; then yarn install --immutable
427
+ else (cd "\$GITHUB_WORKSPACE" && yarn install --immutable); fi
428
+
429
+ # Cheap, and it fails before a fifteen-minute build when a native
430
+ # One script, not a second copy of these steps — the same reasoning as
431
+ # the ios job below. Two phases because the emulator is the action's to
432
+ # own: the build happens before it boots, the test inside it.
433
+ - name: Build
434
+ run: ./e2e-android.sh build
435
+
436
+ - name: Install maestro
437
+ run: |
438
+ curl -fsSL https://get.maestro.mobile.dev | bash
439
+ echo "\$HOME/.maestro/bin" >> "\$GITHUB_PATH"
440
+
441
+ - uses: reactivecircus/android-emulator-runner@${EMULATOR_REF}
442
+ with:
443
+ api-level: 34
444
+ arch: x86_64
445
+ working-directory: ${EXPO_APP}
446
+ script: ./e2e-android.sh test
447
+
448
+ # Maestro writes its failure output here, and reading it is the only
449
+ # way to tell a broken flow from a broken app.
450
+ - uses: actions/upload-artifact@${UPLOAD_REF}
451
+ if: failure()
452
+ with:
453
+ name: maestro-android
454
+ path: ~/.maestro/tests/
455
+ if-no-files-found: ignore
456
+
457
+ ios:
458
+ # macOS minutes are expensive; main only.
459
+ if: github.event_name == 'push'
460
+ runs-on: macos-latest
461
+ timeout-minutes: 60
462
+ defaults:
463
+ run:
464
+ working-directory: ${EXPO_APP}
465
+ env:
466
+ # A debug e2e build has no Sentry credentials and must not fail for
467
+ # want of them. Sentry's Xcode phase gates on this exact variable and
468
+ # kills the build with exit 65 otherwise.
469
+ SENTRY_DISABLE_AUTO_UPLOAD: "true"
470
+ # CocoaPods reads the Podfile through ruby's default external encoding.
471
+ # With no locale set that is US-ASCII, and `pod install` dies inside
472
+ # String#unicode_normalize with "Unicode Normalization not appropriate
473
+ # for ASCII-8BIT" — then xcodebuild fails with the far less helpful
474
+ # "sandbox is not in sync with the Podfile.lock". Reproduced on a real
475
+ # machine; a runner image that happens to set a locale is the image's
476
+ # choice, not ours.
477
+ LANG: en_US.UTF-8
478
+ steps:
479
+ - uses: actions/checkout@${CHECKOUT_REF}
480
+
481
+ - run: corepack enable
482
+ working-directory: .
483
+
484
+ - uses: actions/setup-node@${SETUP_NODE_REF}
485
+ with:
486
+ node-version-file: ${EXPO_APP}/.node-version
487
+ cache: yarn
488
+ cache-dependency-path: |
489
+ yarn.lock
490
+ ${EXPO_APP}/yarn.lock
491
+
492
+ # maestro is a JVM application. The android job gets a JDK incidentally,
493
+ # because gradle needs one; here nothing else does, so without this step
494
+ # `maestro test` dies on "Unable to locate a Java Runtime" after the
495
+ # build has already been paid for. A macOS runner image happens to ship
496
+ # a JDK today — that is the image's choice, not ours.
497
+ - uses: actions/setup-java@${SETUP_JAVA_REF}
498
+ with:
499
+ distribution: temurin
500
+ java-version: "21"
501
+
502
+ - name: Install
503
+ run: |
504
+ if [ -f yarn.lock ]; then yarn install --immutable
505
+ else (cd "\$GITHUB_WORKSPACE" && yarn install --immutable); fi
506
+
507
+ # One script, not a second copy of these steps. It carries the
508
+ # locale, the Sentry opt-out, the JDK check maestro needs and the
509
+ # Release/--no-bundler reasoning — see the file. Anyone can reproduce a
510
+ # red e2e by running it, and the agentic-team repo's own CI runs the same
511
+ # thing rather than a paraphrase of it.
512
+ - name: e2e
513
+ run: ./e2e-ios.sh
514
+
515
+
516
+ # Same reason as the android job: without the run output there is no
517
+ # way to tell a broken flow from a broken app.
518
+ - uses: actions/upload-artifact@${UPLOAD_REF}
519
+ if: failure()
520
+ with:
521
+ name: maestro-ios
522
+ path: ~/.maestro/tests/
523
+ if-no-files-found: ignore
524
+ YML
525
+ } > .github/workflows/mobile-e2e.yml
526
+ log "mobile e2e workflow (android per PR, ios on main) for ${EXPO_APP}"
527
+ warn "mobile-e2e.yml has never run here — it needs a real emulator and a"
528
+ warn "native build. Expect to tune signing and the emulator image on its"
529
+ warn "first run, and fill in .maestro/core-flow.yaml before trusting it."
530
+ fi
531
+
532
+ # ── Dependency policy, as automation ──────────────────────────────────────
533
+ # The rule says: minor and patch green is a merge, a major gets a human. That
534
+ # only holds if something does the merging — otherwise the PRs pile up and
535
+ # get merged in a batch without anyone reading the majors either.
536
+ #
537
+ # And the other half of the rule — never loosen a test to make a dependency
538
+ # PR green — is exactly what a bot cannot be trusted with, so it is checked
539
+ # rather than asked for.
540
+ # `|| true`: with pipefail, a missing config.yml makes the whole pipeline
541
+ # fail and set -e takes the script down with it. Absent config means the
542
+ # default, not an error.
543
+ # awk, not `grep -A<n>`: the shipped config.yml carries three comment lines
544
+ # between `dependencies:` and `automerge:`, so a fixed window missed the key
545
+ # entirely and every repo silently got "none" while its config said otherwise.
546
+ # This reads to the next top-level key instead, so comments cannot defeat it.
547
+ AUTOMERGE="$(awk '/^ dependencies:/{f=1;next} f && /^[a-z]/{exit} f && /^[[:space:]]*automerge:/{print $2;exit}' "$(git rev-parse --show-toplevel)/.orchestra/config.yml" 2>/dev/null || true)"
548
+ AUTOMERGE="${AUTOMERGE:-none}"
549
+ METADATA_REF="$(latest_action_ref dependabot/fetch-metadata v2)"
550
+
551
+ cat > .github/workflows/dependabot.yml <<YML
552
+ name: dependabot
553
+
554
+ on: pull_request
555
+
556
+ permissions:
557
+ contents: write
558
+ pull-requests: write
559
+
560
+ jobs:
561
+ # The half a bot must not decide for itself. A dependency bump that needed a
562
+ # test changed is a behaviour change wearing a version number — the test was
563
+ # doing its job, and the fix belongs in our code or the upgrade waits.
564
+ no-loosened-tests:
565
+ if: github.actor == 'dependabot[bot]'
566
+ runs-on: ubuntu-latest
567
+ steps:
568
+ - uses: actions/checkout@${CHECKOUT_REF}
569
+ with:
570
+ fetch-depth: 0
571
+
572
+ - name: A dependency PR must not touch tests
573
+ run: |
574
+ changed="\$(git diff --name-only "origin/\${{ github.base_ref }}...HEAD" \
575
+ | grep -E '(\.(test|spec)\.[jt]sx?|/__tests__/|/e2e/|Tests?/.*\.(swift|kt))' || true)"
576
+ if [ -n "\$changed" ]; then
577
+ echo "::error::this dependency PR changes tests:"
578
+ echo "\$changed"
579
+ echo "Fix our code to match the new behaviour, or hold the upgrade."
580
+ exit 1
581
+ fi
582
+ echo "no test files touched"
583
+
584
+ automerge:
585
+ if: github.actor == 'dependabot[bot]'
586
+ needs: no-loosened-tests
587
+ runs-on: ubuntu-latest
588
+ steps:
589
+ - id: meta
590
+ uses: dependabot/fetch-metadata@${METADATA_REF}
591
+
592
+ # A major version is the maintainer stating the contract changed, and
593
+ # some of what changed — default timeouts, retry behaviour, performance
594
+ # — is invisible to every test. Green means "nothing we assert broke".
595
+ - name: Flag a major for a human
596
+ if: steps.meta.outputs.update-type == 'version-update:semver-major'
597
+ run: |
598
+ gh pr edit "\$PR" --add-label "needs-human"
599
+ gh pr comment "\$PR" --body "Major version bump — a human reads the changelog before this merges. Green CI means nothing we assert broke, not that nothing broke."
600
+ env:
601
+ PR: \${{ github.event.pull_request.html_url }}
602
+ GH_TOKEN: \${{ secrets.GITHUB_TOKEN }}
603
+
604
+ # --auto, so it lands only once every required check has passed.
605
+ - name: Auto-merge minor and patch
606
+ if: |
607
+ $([ "$AUTOMERGE" = "minor-and-patch" ] && echo true || echo false) &&
608
+ (steps.meta.outputs.update-type == 'version-update:semver-minor' ||
609
+ steps.meta.outputs.update-type == 'version-update:semver-patch')
610
+ run: gh pr merge --auto --squash "\$PR"
611
+ env:
612
+ PR: \${{ github.event.pull_request.html_url }}
613
+ GH_TOKEN: \${{ secrets.GITHUB_TOKEN }}
614
+ YML
615
+ log "dependabot policy (automerge: ${AUTOMERGE}, tests protected)"
616
+ [ "$AUTOMERGE" = "none" ] || warn "auto-merge needs 'Allow auto-merge' enabled in repo settings"
617
+
618
+ # ── Deploy ────────────────────────────────────────────────────────────────
619
+ # Generated for any app that carries a railway.json.
620
+ #
621
+ # The honest shape of safety on Railway: there is no percentage canary. What
622
+ # protects a release is that the previous deployment keeps serving until the
623
+ # new one answers its healthcheck, plus a smoke test against the real URL
624
+ # afterwards, plus putting the last good commit back quickly when it fails.
625
+ #
626
+ # Confidence comes from being able to undo, not from being sure.
627
+ RAILWAY_APPS=()
628
+ for a in "${APPS[@]}"; do
629
+ [ -f "$a/railway.json" ] && RAILWAY_APPS+=("$a")
630
+ done
631
+
632
+ if [ ${#RAILWAY_APPS[@]} -gt 0 ]; then
633
+ DEPLOY_APP="${RAILWAY_APPS[0]}"
634
+ write_smoke_script
635
+ cat > .github/workflows/deploy.yml <<YML
636
+ name: deploy
637
+
638
+ # Only after CI has gone green on main. Deploying on push instead would ship
639
+ # the commit that broke the suite, which is the one moment it matters most.
640
+ on:
641
+ workflow_run:
642
+ workflows: [ci]
643
+ types: [completed]
644
+ branches: [main]
645
+
646
+ concurrency:
647
+ group: deploy
648
+ cancel-in-progress: false
649
+
650
+ jobs:
651
+ deploy:
652
+ if: \${{ github.event.workflow_run.conclusion == 'success' }}
653
+ runs-on: ubuntu-latest
654
+ environment: production
655
+ permissions:
656
+ contents: write
657
+ steps:
658
+ - uses: actions/checkout@${CHECKOUT_REF}
659
+ with:
660
+ ref: \${{ github.event.workflow_run.head_sha }}
661
+ fetch-depth: 0
662
+
663
+ # What to put back if this release turns out bad. Read before
664
+ # deploying, because afterwards the tag has already moved.
665
+ - name: Remember the last good deploy
666
+ id: last
667
+ run: |
668
+ git fetch --tags --force
669
+ prev="\$(git rev-parse -q --verify refs/tags/last-good-deploy || true)"
670
+ echo "sha=\$prev" >> "\$GITHUB_OUTPUT"
671
+ echo "previous good deploy: \${prev:-none yet}"
672
+
673
+ - run: npm i -g @railway/cli
674
+
675
+ - name: Deploy
676
+ working-directory: ${DEPLOY_APP}
677
+ run: railway up --service "\$RAILWAY_SERVICE" --ci
678
+ env:
679
+ RAILWAY_TOKEN: \${{ secrets.RAILWAY_TOKEN }}
680
+ RAILWAY_SERVICE: \${{ vars.RAILWAY_SERVICE }}
681
+
682
+ # Railway's healthcheck already gates promotion on /ready. This catches
683
+ # what a readiness probe cannot: a route answering 500, an asset that
684
+ # 404s, a migration that half-applied.
685
+ - name: Smoke the deployed URL
686
+ run: node scripts/smoke.mjs "\$DEPLOY_URL"
687
+ env:
688
+ DEPLOY_URL: \${{ vars.DEPLOY_URL }}
689
+
690
+ # Railway has no rollback command, so putting the previous commit back
691
+ # is the rollback. Same path as a normal deploy on purpose — an
692
+ # untested recovery path is not a recovery path.
693
+ - name: Roll back
694
+ if: failure() && steps.last.outputs.sha != ''
695
+ run: |
696
+ echo "::error::deploy or smoke failed — restoring \${{ steps.last.outputs.sha }}"
697
+ git checkout "\${{ steps.last.outputs.sha }}"
698
+ cd ${DEPLOY_APP} && railway up --service "\$RAILWAY_SERVICE" --ci
699
+ env:
700
+ RAILWAY_TOKEN: \${{ secrets.RAILWAY_TOKEN }}
701
+ RAILWAY_SERVICE: \${{ vars.RAILWAY_SERVICE }}
702
+
703
+ # Moved only after the smoke passed, so it always names a release that
704
+ # was observed working rather than one that merely deployed.
705
+ - name: Mark this deploy good
706
+ if: success()
707
+ run: |
708
+ git tag -f last-good-deploy "\${{ github.event.workflow_run.head_sha }}"
709
+ git push -f origin last-good-deploy
710
+ YML
711
+ log "deploy workflow for ${DEPLOY_APP} (railway, smoke-gated, rollback on failure)"
712
+ warn "deploy needs: secret RAILWAY_TOKEN, vars RAILWAY_SERVICE and DEPLOY_URL"
713
+ fi
714
+
715
+ # ── Review labels ─────────────────────────────────────────────────────────
716
+ #
717
+ # The reviewer and the engineer hand a pull request back and forth, and the
718
+ # verdict has to live somewhere GitHub lets the author write. It is not the
719
+ # review state: the same account opens the PR and reviews it, and GitHub
720
+ # refuses to let an author approve their own. A label it will allow, and
721
+ # everything else can read one — a filter, a board, a human scanning the list,
722
+ # and a run picking up after a crash.
723
+ #
724
+ # --force so a re-run updates rather than failing on "already exists". Not
725
+ # fatal without a remote: a repo that has not been pushed yet has nowhere to
726
+ # put a label, and that is not a reason to fail a scaffold.
727
+ if gh auth status >/dev/null 2>&1 && gh repo view >/dev/null 2>&1; then
728
+ gh label create review:changes-requested -c D93F0B \
729
+ -d "Reviewer found blocking issues" -f >/dev/null 2>&1 || true
730
+ gh label create review:approved -c 0E8A16 \
731
+ -d "Reviewer found nothing blocking" -f >/dev/null 2>&1 || true
732
+ log "review labels created"
733
+ else
734
+ warn "no gh remote yet — create the review labels after the first push:"
735
+ warn " gh label create review:changes-requested -c D93F0B -f"
736
+ warn " gh label create review:approved -c 0E8A16 -f"
737
+ fi
738
+
739
+ # ── PR template ───────────────────────────────────────────────────────────
740
+ cat > .github/pull_request_template.md <<'MD'
741
+ ## What
742
+
743
+ <!-- One or two sentences. What changed, not how. -->
744
+
745
+ ## Why
746
+
747
+ <!-- Link the milestone or task. What problem does this close? -->
748
+
749
+ ## Design
750
+
751
+ <!-- Required for any UI change. Both columns, side by side. -->
752
+
753
+ | Expected | Implemented |
754
+ |---|---|
755
+ | <!-- designs/<screen>.png --> | <!-- designs/.captures/<phase>.png --> |
756
+
757
+ <!-- gh cannot upload an image, so both files have to be committed in this
758
+ branch for the cells to render. If the screen could not be captured, put
759
+ the reason in the cell — "not captured: no simulator in the session". An
760
+ empty cell reads as a pass nobody observed. -->
761
+
762
+ <!-- Any deliberate difference from the design goes here, with the reason.
763
+ "Close enough" is not a reason. A spacing or a colour that is close is
764
+ not a difference, it is a defect — see
765
+ .claude/rules/design-parity.orchestra.md. -->
766
+
767
+ ## Verification
768
+
769
+ - [ ] typecheck, test, and lint pass locally
770
+ - [ ] tests cover the change, including the failure paths
771
+ - [ ] a11y checklist done for any new or changed component
772
+ - [ ] no hardcoded design values — tokens only
773
+ MD
774
+ log "pr template"
775
+
776
+ # ── Commit message enforcement ────────────────────────────────────────────
777
+ # Pure bash on purpose: the standalone strategy has no root package.json to
778
+ # hang commitlint off, and this needs to work in every strategy.
779
+ mkdir -p .githooks
780
+ cat > .githooks/commit-msg <<'HOOK'
781
+ #!/usr/bin/env sh
782
+ # Conventional commits. Matches .claude/rules/commit-format.orchestra.md.
783
+ msg_file="$1"
784
+ first_line=$(head -n1 "$msg_file")
785
+
786
+ case "$first_line" in
787
+ "Merge "*|"Revert "*|fixup!*|squash!*) exit 0 ;;
788
+ esac
789
+
790
+ # Accepts both `feat(scope)!:` (Conventional Commits spec, what release-please
791
+ # parses) and `feat!(scope):` (the form in commit-format.orchestra.md).
792
+ pattern='^(feat|fix|refactor|perf|test|chore|docs|style|ci|build|revert)!?(\([a-z0-9./-]+\))?!?: .{1,}$'
793
+
794
+ # A squash-merge takes its message from the pull request title, and a task PR
795
+ # is titled `#<task-id> - <subject>` so the tracker links it. Without this the
796
+ # repo would reject the commit its own delivery flow produces — see
797
+ # .claude/rules/pull-request.orchestra.md.
798
+ #
799
+ # Deliberately narrow: a task id and a subject, nothing else. It is not a way
800
+ # to opt an ordinary commit out of the convention.
801
+ squashed='^#[A-Za-z0-9_-]+ - .{1,}$'
802
+
803
+ if ! printf '%s' "$first_line" | grep -qE "$pattern" \
804
+ && ! printf '%s' "$first_line" | grep -qE "$squashed"; then
805
+ echo "commit rejected: subject must be <type>(<scope>): <description>" >&2
806
+ echo " got: $first_line" >&2
807
+ echo " types: feat fix refactor perf test chore docs style ci build revert" >&2
808
+ echo " (a squashed task PR may instead be '#<task-id> - <subject>')" >&2
809
+ exit 1
810
+ fi
811
+
812
+ if [ ${#first_line} -gt 72 ]; then
813
+ echo "commit rejected: subject is ${#first_line} chars, max 72" >&2
814
+ exit 1
815
+ fi
816
+
817
+ if printf '%s' "$(cat "$msg_file")" | grep -qi '^Co-Authored-By:'; then
818
+ echo "commit rejected: no Co-Authored-By trailers" >&2
819
+ exit 1
820
+ fi
821
+ HOOK
822
+ chmod +x .githooks/commit-msg
823
+
824
+ # Where the hook goes depends on what is already installed.
825
+ #
826
+ # husky owns `.husky/_` and regenerates it on every install (its own
827
+ # `prepare` script), and ships a `.gitignore` inside it — so a hook written
828
+ # there is neither committed nor durable. `.husky/` itself is the supported
829
+ # place for user hooks.
830
+ existing_hooks="$(git config --get core.hooksPath || true)"
831
+ case "$existing_hooks" in
832
+ ""|".githooks")
833
+ git config core.hooksPath .githooks
834
+ log "commit-msg hook installed (.githooks)"
835
+ ;;
836
+ .husky/_|.husky)
837
+ mkdir -p .husky
838
+ cp .githooks/commit-msg .husky/commit-msg
839
+ chmod +x .husky/commit-msg
840
+ rm -f .githooks/commit-msg
841
+ rmdir .githooks 2>/dev/null || true
842
+ log "commit-msg hook installed (.husky/commit-msg, husky present)"
843
+ ;;
844
+ *)
845
+ # Someone else's hooks directory. Add to it, never replace it, and never
846
+ # delete a directory this script did not create.
847
+ mkdir -p "$existing_hooks"
848
+ if [ -e "$existing_hooks/commit-msg" ]; then
849
+ warn "$existing_hooks/commit-msg already exists — left untouched, commit format NOT enforced"
850
+ else
851
+ cp .githooks/commit-msg "$existing_hooks/commit-msg"
852
+ chmod +x "$existing_hooks/commit-msg"
853
+ log "commit-msg hook added to existing $existing_hooks"
854
+ fi
855
+ rm -f .githooks/commit-msg
856
+ rmdir .githooks 2>/dev/null || true
857
+ ;;
858
+ esac
859
+
860
+ # Yarn 4 pins its own release under .yarn/releases, which must be committed or
861
+ # CI has no yarn binary. Everything else under .yarn is a cache.
862
+ if ! grep -q '^\.yarn/' .gitignore 2>/dev/null; then
863
+ cat >> .gitignore <<'YG'
864
+
865
+ # yarn — keep the pinned release, ignore the caches
866
+ .yarn/*
867
+ !.yarn/releases
868
+ .pnp.*
869
+ YG
870
+ log "gitignore: yarn caches ignored, pinned release kept"
871
+ fi
872
+
873
+ # node_modules at the repo root. Each create tool ignores it inside its own
874
+ # app directory, which is enough for `single` and `standalone` — but a yarn
875
+ # workspace hoists the whole tree to the root, where nothing covered it, and
876
+ # the first `git add -A` stages sixty thousand files.
877
+ if ! grep -q '^node_modules/' .gitignore 2>/dev/null; then
878
+ cat >> .gitignore <<'NMG'
879
+
880
+ # dependencies — hoisted here by a workspace, per-app otherwise
881
+ node_modules/
882
+ NMG
883
+ log "gitignore: node_modules ignored"
884
+ fi
885
+
886
+ # `.env` is gitignored in every project, always — the security rule says so
887
+ # and, until now, nothing did it. A create tool ignores .env inside its own
888
+ # app directory; nothing covered the repo root or a hand-made app.
889
+ #
890
+ # .env.example is the exception, and it is committed on purpose: it is the
891
+ # only record of what a new machine needs in order to boot.
892
+ if ! grep -q '^\.env$' .gitignore 2>/dev/null; then
893
+ cat >> .gitignore <<'ENVG'
894
+
895
+ # secrets never get committed; the example of what is needed always does
896
+ .env
897
+ .env.*
898
+ !.env.example
899
+ ENVG
900
+ log "gitignore: .env ignored, .env.example kept"
901
+ fi
902
+
903
+ cat > .editorconfig <<'EC'
904
+ root = true
905
+
906
+ [*]
907
+ charset = utf-8
908
+ end_of_line = lf
909
+ insert_final_newline = true
910
+ trim_trailing_whitespace = true
911
+ indent_style = space
912
+ indent_size = 2
913
+
914
+ [*.{kt,kts,swift}]
915
+ indent_size = 4
916
+
917
+ [*.md]
918
+ trim_trailing_whitespace = false
919
+ EC
920
+ log "editorconfig"
921
+
922
+
923
+ # ── Workspace (Turborepo) ─────────────────────────────────────────────────
924
+ # One lockfile at the root, turbo for task orchestration and caching. The
925
+ # per-app yarn setup each stack performs is undone here — a workspace has a
926
+ # single resolution root, and leaving per-app lockfiles behind produces two
927
+ # competing dependency trees.
928
+ if [ "$MODE" = "workspace" ]; then
929
+ for a in "${APPS[@]}"; do
930
+ rm -f "$a/yarn.lock" "$a/.yarnrc.yml"
931
+ rm -rf "$a/.yarn"
932
+ done
933
+
934
+ printf 'nodeLinker: node-modules\n' > .yarnrc.yml
935
+
936
+ node -e '
937
+ const fs = require("fs");
938
+ const apps = process.argv.slice(1);
939
+ const pkg = {
940
+ name: "workspace-root",
941
+ private: true,
942
+ workspaces: apps,
943
+ scripts: {
944
+ dev: "turbo run dev",
945
+ build: "turbo run build",
946
+ test: "turbo run test",
947
+ lint: "turbo run lint",
948
+ typecheck: "turbo run typecheck",
949
+ "test:coverage": "turbo run test:coverage",
950
+ verify: "turbo run typecheck test lint test:coverage",
951
+ },
952
+ };
953
+ fs.writeFileSync("package.json", JSON.stringify(pkg, null, 2) + "\n");
954
+ ' "${APPS[@]}"
955
+
956
+ cat > turbo.json <<'TURBO'
957
+ {
958
+ "$schema": "https://turbo.build/schema.json",
959
+ "tasks": {
960
+ "build": { "dependsOn": ["^build"], "outputs": ["dist/**", ".next/**", "!.next/cache/**"] },
961
+ "typecheck": { "dependsOn": ["^build"] },
962
+ "lint": {},
963
+ "test": { "dependsOn": ["^build"] },
964
+ "test:coverage": { "dependsOn": ["^build"], "outputs": ["coverage/**"] },
965
+ "dev": { "cache": false, "persistent": true }
966
+ }
967
+ }
968
+ TURBO
969
+
970
+ # turbo writes a cache and a per-task log into .turbo/, at the root and
971
+ # inside every package. Nothing ignored it, so the first commit carried
972
+ # turbo-lint.log and a tar.zst cache blob, and every run after it churned
973
+ # them.
974
+ if ! grep -q '^\.turbo/' .gitignore 2>/dev/null; then
975
+ printf '\n# turbo cache and task logs, root and per-package\n.turbo/\n' >> .gitignore
976
+ log "gitignore: .turbo ignored"
977
+ fi
978
+
979
+ corepack enable >/dev/null 2>&1 || true
980
+ yarn set version stable >/dev/null 2>&1 || warn "could not pin yarn at the root"
981
+ yarn add -D turbo@latest >/dev/null 2>&1 || warn "turbo install failed — run 'yarn add -D turbo' at the root"
982
+ log "workspace wired: yarn workspaces + turborepo (${#APPS[@]} apps)"
983
+ fi
984
+
985
+ # ── Standalone task runner ────────────────────────────────────────────────
986
+ # The standalone strategy has no root package.json on purpose, so this
987
+ # script takes the place of `npm run`.
988
+ if [ "$STANDALONE" -eq 1 ]; then
989
+ {
990
+ cat <<'RUNNER'
991
+ #!/usr/bin/env bash
992
+ # Root task runner. No root package.json by design — each app owns its own.
993
+ #
994
+ # ./run.sh install install every app
995
+ # ./run.sh dev <app> start one app
996
+ # ./run.sh test [app] test one app, or all
997
+ # ./run.sh lint [app]
998
+ # ./run.sh typecheck [app]
999
+ # ./run.sh build [app]
1000
+ # ./run.sh verify [app] typecheck + test + lint + coverage
1001
+ set -euo pipefail
1002
+
1003
+ RUNNER
1004
+ printf 'APPS=('
1005
+ for a in "${APPS[@]}"; do printf '"%s" ' "$a"; done
1006
+ printf ')\n'
1007
+ cat <<'RUNNER'
1008
+
1009
+ cmd="${1:-}"; shift || true
1010
+ target="${1:-}"
1011
+
1012
+ # Apps are not all npm. Native mobile forces this strategy, so dispatch on
1013
+ # what is actually in the directory. verify.sh is the normalised gate a
1014
+ # non-npm app exposes.
1015
+ __run() { # task, run from inside the app dir
1016
+ local task="$1"
1017
+ if [ -f verify.sh ]; then
1018
+ case "$task" in
1019
+ install|dev|build) echo "(skipped: $task not defined for a verify.sh app)" ;;
1020
+ *) ./verify.sh ;;
1021
+ esac
1022
+ elif [ -f package.json ]; then
1023
+ case "$task" in
1024
+ install) yarn install ;;
1025
+ *) yarn "$task" ;;
1026
+ esac
1027
+ else
1028
+ echo "no package.json and no verify.sh — cannot $task" >&2
1029
+ return 1
1030
+ fi
1031
+ }
1032
+
1033
+ run_in() { # dir, __run, task
1034
+ local dir="$1"; shift
1035
+ [ -d "$dir" ] || { echo "no such app: $dir" >&2; exit 1; }
1036
+ echo "──> $dir: $*"
1037
+ ( cd "$dir" && "$@" )
1038
+ }
1039
+
1040
+ each() { # script...
1041
+ local failed=()
1042
+ for a in "${APPS[@]}"; do
1043
+ run_in "$a" "$@" || failed+=("$a")
1044
+ done
1045
+ [ ${#failed[@]} -eq 0 ] || { echo "failed: ${failed[*]}" >&2; exit 1; }
1046
+ }
1047
+
1048
+ # `a && b || c` runs c when b FAILS, not only when a is false — which would
1049
+ # run every app's suite because one app's suite went red. Always if/else.
1050
+ one_or_all() { # script...
1051
+ if [ -n "$target" ]; then run_in "$target" "$@"; else each "$@"; fi
1052
+ }
1053
+
1054
+ case "$cmd" in
1055
+ install) each __run install ;;
1056
+ dev) [ -n "$target" ] || { echo "usage: ./run.sh dev <app>" >&2; exit 1; }
1057
+ run_in "$target" __run dev ;;
1058
+ test) one_or_all __run test ;;
1059
+ lint) one_or_all __run lint ;;
1060
+ typecheck) one_or_all __run typecheck ;;
1061
+ build) one_or_all __run build ;;
1062
+ verify)
1063
+ for s in typecheck test lint test:coverage; do
1064
+ one_or_all __run "$s"
1065
+ done ;;
1066
+ *)
1067
+ sed -n '2,10p' "$0" | sed 's/^# \{0,1\}//'
1068
+ exit 1 ;;
1069
+ esac
1070
+ RUNNER
1071
+ } > run.sh
1072
+ chmod +x run.sh
1073
+ log "root run.sh task runner (${#APPS[@]} apps)"
1074
+ fi
1075
+
1076
+ # Where a design lives, from the first commit rather than from the first
1077
+ # argument about it. A phase's `design:` field points in here, lead fetches a
1078
+ # URL design into .fetched/, and the capture the PR shows lands in .captures/.
1079
+ # Committed, both of them: gh cannot upload an image, so a screenshot that is
1080
+ # not in the branch cannot appear in a pull request.
1081
+ mkdir -p designs/.captures designs/.fetched designs/.reports
1082
+ # git does not track an empty directory, so without these the two folders
1083
+ # vanish on the next clone and the first phase with a design has to invent a
1084
+ # location — which is how two of them end up disagreeing.
1085
+ : > designs/.captures/.gitkeep
1086
+ : > designs/.fetched/.gitkeep
1087
+ # Machine output of a comparison is regenerated from the images and never
1088
+ # committed; one project committed ~100k lines of JSON reports here.
1089
+ printf '*\n!.gitignore\n' > designs/.reports/.gitignore
1090
+ cat > designs/README.md <<'MD'
1091
+ # designs
1092
+
1093
+ The source of truth for any screen this repo builds.
1094
+
1095
+ ```
1096
+ designs/
1097
+ ├── <screen>.png the design. mirrors the screen structure.
1098
+ ├── .fetched/ a design given as a URL, fetched once when the phase starts
1099
+ ├── .captures/ what was actually built, so a pull request can show both
1100
+ └── .reports/ a comparison tool's output — regenerated, never committed
1101
+ ```
1102
+
1103
+ A phase points at one with `design:` in its frontmatter — a path in here, or a
1104
+ URL. No field means no design, and nothing may infer one from a phase title: a
1105
+ check against a guessed design reports a pass nobody earned.
1106
+
1107
+ What counts as matching, and what blocks, is in
1108
+ `.claude/rules/design-parity.orchestra.md`.
1109
+ MD
1110
+ log "designs/ (source, fetched, captures) — a design has a home before it is needed"
1111
+
1112
+ log "repo config done — CI, dependabot, PR template, commit hook"
1113
+ warn "branch protection cannot be set from here. Require the CI check on main in repo settings."