@mmerterden/multi-agent-pipeline 15.15.0 → 15.16.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/CHANGELOG.md CHANGED
@@ -16,6 +16,19 @@ Internal file-layout changes that don't affect the slash-command surface are sti
16
16
 
17
17
  ## [Unreleased]
18
18
 
19
+ ## [15.16.0] - 2026-08-22
20
+
21
+ ### Added
22
+ - **More than one Firebase project per team.** `keychainMapping.firebase` held exactly one service-account key, which is wrong for the normal case: a legacy app next to its redesign, or staging next to production, each with its own key. A crash URL from the project you did not pick failed the `project_id` check and reported it as a configuration error, which it was, but not the one the message suggested. `global.firebase.accounts[]` maps `projectId` to a keychain key, `fetch-crashlytics.sh` reads the projectId out of the console URL and picks the matching account, and the single slot stays the fallback so a one-project setup needs no config at all. `/multi-agent:setup` now loops the Firebase pass (`Add another Firebase project? [y/N]`), reading `project_id` from each decoded JSON rather than asking for it.
23
+ - A `project_id` mismatch now names the key it used and prints the `accounts[]` entry to add. With several projects in play, "project mismatch" alone does not say whether the URL is wrong or the mapping is incomplete.
24
+
25
+ - **Jira project keys are discovered instead of recalled.** The token is saved and the host is known by the time setup asks for a project key, so it now asks Jira: one search for issues the person assigned or reported, most-recently-updated first, and the distinct project keys become a picker. A corporate instance has thousands of projects and a typed key is a typo that routes branches and new issues at the wrong board. The free-text prompt stays as the fallback for no-VPN and fresh accounts, and the per-repo mapping offers the discovered keys rather than asking for them again.
26
+
27
+ ### Changed
28
+ - `setup/SKILL.md` lost its third copy of the service-ID table and its second copy of the `keychainMapping` shape; both live in `refs/keychain.md`, which the flow already cites. What stayed is the column nothing else had: where to generate each token. The Firebase host-exemption note also stopped being stated twice, two paragraphs apart.
29
+ - The App Store Connect tier reasoning moved into the ref that owns that flow, and `refs/keychain.md` gained the four App Store Connect standard key names so it is now the complete answer for every service setup points at.
30
+ - `smoke-url-enrichment.sh` follows the type label to where it now lives and adds eight assertions: five for the multi-account contract (including that the single-slot fallback survives) and three for Jira discovery and the completeness of the key-name reference.
31
+
19
32
  ## [15.15.0] - 2026-08-22
20
33
 
21
34
  ### Added
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mmerterden/multi-agent-pipeline",
3
- "version": "15.15.0",
3
+ "version": "15.16.0",
4
4
  "description": "8-phase AI development pipeline with full orchestration on Claude Code, Copilot CLI and Codex CLI. Analysis, planning, TDD, CLI-aware parallel review with consensus surfacing + Fable triage, default-FAIL evidence gates, secret + intent guards, per-phase cost ledger, persistent learnings memory, wiki generation, commit automation. Token-preserving uninstall.",
5
5
  "type": "module",
6
6
  "main": "index.js",
@@ -92,32 +92,32 @@ DISCOVERED=$(~/.claude/lib/credential-store.sh list 2>/dev/null | grep -i "^${US
92
92
  echo "$DISCOVERED"
93
93
  ```
94
94
 
95
- **1b. Standard key names (convention):**
96
-
97
- These are the RECOMMENDED key names. When creating NEW keys, use these. But existing keys under different names are perfectly valid - the discovery step will find them.
98
-
99
- | Service ID | Standard Key Name | Search Keywords |
100
- |------------|------------------|-----------------|
101
- | `jira` | `${USER}_Jira_Access_Token` | `jira` |
102
- | `bitbucket_token` | `${USER}_Bitbucket_Access_Token` | `bitbucket` + (`token` or `access` or `pass`) |
103
- | `bitbucket_user` | `${USER}_Bitbucket_Username` | `bitbucket` + (`user` or `name`) |
104
- | `github` | `${USER}_Github_Access_Token` | `github` or `gh` (exclude `ssh`) |
105
- | `confluence` | `${USER}_Confluence_Access_Token` | `confluence` |
106
- | `figma` | `${USER}_Figma_Access_Token` | `figma` + (`token` or `pat` or `access`) + NOT `mcp` |
107
- | `figma_mcp` | `${USER}_Figma_Mcp_Access_Token` | `figma` + `mcp` |
108
- | `fortify` | `${USER}_Fortify_Access_Token` | `fortify` |
109
- | `graylog` | `${USER}_Graylog_Access_Token` | `graylog` |
110
- | `graylog_test` | `${USER}_Graylog_Test_Access_Token` | `graylogTest` |
111
- | `firebase` | `${USER}_Firebase_Access_Json` | `firebase` (any variant: `sa`, `service`, `account`, `access`, `json`) |
112
- | `jenkins` | `${USER}_Jenkins_Access_Token` | `jenkins` |
113
- | `appstore_connect_key_id` | `${USER}_AppStoreConnect_Key_Id` | (`appstore` or `asc` or `app_store`) + (`key` or `keyid`) |
114
- | `appstore_connect_issuer_id` | `${USER}_AppStoreConnect_Issuer_Id` | (`appstore` or `asc` or `app_store`) + `issuer` |
115
- | `appstore_connect_apple_id` | `${USER}_AppStoreConnect_Apple_Id` | (`appstore` or `asc`) + (`apple` or `account` or `user`) |
116
- | `appstore_connect_password_item` | `${USER}_AppStoreConnect_Password_Item` | (`appstore` or `asc` or `altool`) + (`password` or `app_specific`) |
95
+ **1b. Search keywords (per service):**
96
+
97
+ Standard key names - the ones to use when creating a NEW key - live in `$HOME/.claude/multi-agent-refs/keychain.md` "Standard key names"; read the name for a service from there. Existing keys under other names are perfectly valid, which is what these keywords are for.
98
+
99
+ | Service ID | Search Keywords |
100
+ |------------|-----------------|
101
+ | `jira` | `jira` |
102
+ | `bitbucket_token` | `bitbucket` + (`token` or `access` or `pass`) |
103
+ | `bitbucket_user` | `bitbucket` + (`user` or `name`) |
104
+ | `github` | `github` or `gh` (exclude `ssh`) |
105
+ | `confluence` | `confluence` |
106
+ | `figma` | `figma` + (`token` or `pat` or `access`) + NOT `mcp` |
107
+ | `figma_mcp` | `figma` + `mcp` |
108
+ | `fortify` | `fortify` |
109
+ | `graylog` | `graylog` |
110
+ | `graylog_test` | `graylog` + `test` |
111
+ | `firebase` | `firebase` (any variant: `sa`, `service`, `account`, `access`, `json`) |
112
+ | `jenkins` | `jenkins` |
113
+ | `appstore_connect_key_id` | (`appstore` or `asc` or `app_store`) + (`key` or `keyid`) |
114
+ | `appstore_connect_issuer_id` | (`appstore` or `asc` or `app_store`) + `issuer` |
115
+ | `appstore_connect_apple_id` | (`appstore` or `asc`) + (`apple` or `account` or `user`) |
116
+ | `appstore_connect_password_item` | (`appstore` or `asc` or `altool`) + (`password` or `app_specific`) |
117
117
 
118
118
  > Multi-agent setup onboards **both** Figma tokens because the pipeline's 3-tier Figma access chain (see `$HOME/.claude/rules/figma-pipeline.md`) uses them in priority order: Tier 1 = MCP (`figma_mcp`), Tier 2 = REST PAT (`figma`), Tier 3 = user screenshot fallback. Missing either token forces a downshift; missing both leaves Tier 3 as the only path. The Tier 2 logical key is `figma`, not `figma_pat`: `figma_pat` is the pre-v13.6 name that `migrate-prefs.mjs` consolidates away, and writing a new mapping under it means the next migration deletes it.
119
119
 
120
- > The four App Store Connect entries are **iOS-only and optional**: skip them all and the pipeline still works, it just reports Gate 2 of `/multi-agent:store-ready` as `SKIPPED` (never as a pass). They mirror the Figma 3-tier shape - Tier 1 = API key (`appstore_connect_key_id` + `appstore_connect_issuer_id`), Tier 2 = Apple ID + app-specific password (`appstore_connect_apple_id` + `appstore_connect_password_item`), Tier 3 = nothing configured. **Offer Tier 2 first when the user says they cannot create an API key**: creating one needs an Admin or App Manager role in App Store Connect, while an app-specific password is generated by the account holder at `appleid.apple.com` with no team permission at all. Two of these hold identifiers rather than secrets (key id, issuer id) and one holds a keychain ITEM NAME, not a password - they still go through the mapping layer so every credential is read the same way. Onboarding mechanics in Step 3b.
120
+ > The four App Store Connect entries are **iOS-only and optional** - skipping them all reports Gate 2 of `/multi-agent:store-ready` as `SKIPPED`, never as a pass. Two hold identifiers rather than secrets and one holds a keychain ITEM NAME, yet all four go through the mapping layer so every credential is read the same way. Tiers, the role constraint that decides which to offer first, and the onboarding mechanics: Step 3b and `$HOME/.claude/multi-agent-refs/setup/app-store-connect.md`.
121
121
 
122
122
  **1c. Resolution logic (per service):**
123
123
 
@@ -184,27 +184,14 @@ Keychain Token Discovery (${USER}):
184
184
 
185
185
  Non-standard key names are perfectly valid - the mapping handles resolution. No rename or migration is offered.
186
186
 
187
- Save the resolved mapping to preferences:
187
+ Save the resolved mapping to `prefs.global.keychainMapping` - every service ID gets an entry, the discovered key name verbatim or `null` when nothing was found:
188
+
188
189
  ```json
189
- {
190
- "global": {
191
- "keychainMapping": {
192
- "jira": "${USER}_Jira_Access_Token",
193
- "bitbucket_token": "${USER}_Bitbucket_Access_Token",
194
- "bitbucket_user": "${USER}_Bitbucket_Username",
195
- "github": "${USER}_Github",
196
- "confluence": null,
197
- "figma": "${USER}_Figma_Access_Token",
198
- "figma_mcp": "${USER}_Figma_Mcp_Access_Token",
199
- "fortify": null,
200
- "graylog": null,
201
- "firebase": "${USER}_Firebase_Access_Json",
202
- "jenkins": null
203
- }
204
- }
205
- }
190
+ { "jira": "${USER}_Jira_Access_Token", "github": "${USER}_Github", "confluence": null, "...": "..." }
206
191
  ```
207
192
 
193
+ Full key list and shape: `$HOME/.claude/multi-agent-refs/keychain.md`.
194
+
208
195
  `null` = not mapped (missing or skipped). Pipeline phases read this mapping to retrieve tokens dynamically - never hardcoded key names.
209
196
 
210
197
  ### Step 2.7 - Operational reporting token (optional, opt-in)
@@ -370,6 +357,12 @@ pbcopy < /dev/null # clear clipboard
370
357
 
371
358
  Linux substitutes: `xclip -selection clipboard -o` or `wl-paste` for `pbpaste`; matching clear command for `pbcopy < /dev/null`. The shell driver detects platform and routes accordingly (see `~/.claude/lib/credential-store.sh`).
372
359
 
360
+ **Firebase repeats per project.** Teams own several Firebase projects (legacy plus redesign, staging plus prod), each with its own key, and a crash URL from the second one used to fail the `project_id` check as if it were misconfigured. After each saved key, read `project_id` from the decoded JSON (never ask) and offer another round: `Saved: <project_id>. Add another Firebase project? [y/N]`.
361
+
362
+ - Every key appends `{projectId, keychainKey, label?}` to `prefs.global.firebase.accounts`; the first one also fills `keychainMapping.firebase`, so a one-project setup is unchanged.
363
+ - Keys after the first are named `${USER}_Firebase_Access_Json_<projectId>` so they cannot collide.
364
+ - At run time `fetch-crashlytics.sh` picks the account matching the console URL's projectId, falling back to `keychainMapping.firebase`.
365
+
373
366
  For GitHub (special case):
374
367
  ```
375
368
  ! gh auth login
@@ -409,8 +402,7 @@ Update `serviceStatus.{service} = { ok: true, checkedAt: <now> }`.
409
402
  **→ Next: if `{service}` is `jira` / `confluence` / `bitbucket` / `fortify` / `graylog` AND the corresponding
410
403
  `prefs.global.hosts.{service}` is unset, run Step 3.5 (Host Prompt) inline before
411
404
  the Auto-routing rule below. required - the pipeline cannot build API URLs without
412
- the host. Firebase is skipped here (host is always `console.firebase.google.com` /
413
- `firebasecrashlytics.googleapis.com`); the SA JSON itself carries `project_id`.**
405
+ the host. Firebase is exempt (see Step 3.5).**
414
406
 
415
407
  **Auto-routing rule** (optional, on first token per platform):
416
408
 
@@ -431,7 +423,7 @@ Runs inside Step 3 alongside the other missing credentials. The four `appstore_c
431
423
 
432
424
  ### Step 3.5 - Host Prompt (embedded in Token Save Flow)
433
425
 
434
- **Not a standalone step** - runs inline at the end of the Token Save Flow whenever the saved token belongs to a **hosted service** (Jira, Confluence, Bitbucket, Fortify, Graylog) AND the host is not yet in `prefs.global.hosts`. Firebase tokens skip this step - Crashlytics is always on Google's fixed domains and `project_id` is embedded in the SA JSON.
426
+ **Not a standalone step** - runs inline at the end of the Token Save Flow whenever the saved token belongs to a **hosted service** (Jira, Confluence, Bitbucket, Fortify, Graylog) AND the host is not yet in `prefs.global.hosts`. Firebase tokens skip this step - Crashlytics is always on Google's fixed domains and `project_id` is embedded in the service-account JSON.
435
427
 
436
428
  Right after Step D (identity mapping confirmed), ask:
437
429
 
@@ -443,27 +435,28 @@ Service host - needed to build API URLs for this token.
443
435
 
444
436
  Save to `prefs.global.hosts.{service}`. For Fortify, also ask `Fortify project version ids (optional, comma-separated - e.g. 1234,5678)` → `prefs.global.fortify.versionIds`. A ticket naming only an instance id carries no version, so the lookup silently no-ops without these; URL-referenced findings resolve either way.
445
437
 
446
- For Jira specifically, also ask for the default project key (the token is useless without one for new-issue creation):
438
+ For Jira, **discover the project keys instead of asking the user to recall them.** The token is already saved and the host is already known, so ask Jira which projects this person actually works in - a corporate instance has thousands of projects, and a typed key is a typo waiting to route branches and new issues at the wrong board:
447
439
 
448
- ```
449
- Default Jira project key (e.g. PROJ): ___
440
+ ```bash
441
+ curl -s -H "Authorization: Bearer $TOKEN" \
442
+ "https://{JIRA_HOST}/rest/api/2/search?jql=assignee%3DcurrentUser()%20OR%20reporter%3DcurrentUser()%20ORDER%20BY%20updated%20DESC&fields=project&maxResults=100" \
443
+ | jq -r '[.issues[].fields.project | "\(.key)\t\(.name)"] | unique | .[]'
450
444
  ```
451
445
 
452
- Save to `prefs.global.defaultJiraKey`. From Jira host, derive `corpDomain` (e.g. `jira.example.com` → `example.com`) and pre-fill it so the Confluence / Bitbucket prompts only need the subdomain.
446
+ Present the distinct keys, most-recently-worked first, as a picker; fall back to the free-text prompt `Default Jira project key (e.g. PROJ)` when the call fails or returns nothing (no VPN, fresh account). The picked key → `prefs.global.defaultJiraKey`. From the Jira host, derive `corpDomain` (`jira.example.com` → `example.com`) and pre-fill it so the Confluence / Bitbucket prompts only need the subdomain.
453
447
 
454
- `defaultJiraKey` stays a single global fallback, but one Jira host usually serves several repos with DIFFERENT project keys, so the Jira pass continues with a per-repo key mapping (multi-select, optional):
448
+ `defaultJiraKey` is a single global fallback, but one Jira host usually serves several repos with DIFFERENT keys, so the pass continues with a per-repo mapping. Show the repos, and let the key come from the discovered list rather than being typed again:
455
449
 
456
450
  ```
457
451
  Per-repo Jira keys (optional) - repos whose project key differs from {defaultJiraKey}:
458
452
 
459
453
  [ ] 1. my-ios-app (current: PROJA)
460
454
  [ ] 2. my-other-app (current: - )
461
- ...
462
455
 
463
- Toggle repos, then enter the project key for each selected repo. Enter to skip.
456
+ Toggle repos, then pick a project for each - discovered: PROJA, PROJB, PROJC. Enter to skip.
464
457
  ```
465
458
 
466
- - The repo list comes from `prefs.projects` + `recentProjects` (when Step 5 already ran); a free-text repo path is accepted for repos not discovered yet.
459
+ - The repo list comes from `prefs.projects` + `recentProjects` (when Step 5 already ran); a free-text repo path is accepted for repos not discovered yet, and a free-text key for a project the search did not surface.
467
460
  - Each answer is PREPENDED to `prefs.projects[{slug}].jiraProjectKeys` (deduped, max 10 per schema). Example: `my-ios-app` → `PROJA`, `my-other-app` → `PROJB` - two repos on the same Jira host, two different project keys.
468
461
  - Resolution order everywhere a Jira key is needed (placeholder replacement, branch names, new-issue creation): `prefs.projects[{slug}].jiraProjectKeys[0]` first, `global.defaultJiraKey` as fallback.
469
462
  - **Re-run / update**: `/multi-agent:setup jira-keys` re-opens only this mapping without touching tokens or hosts.
@@ -709,21 +702,23 @@ If no backend is available, setup falls back to a plain-text prompt + a warning
709
702
 
710
703
  Standard key names (used when creating NEW tokens). Discovery may find existing keys under different names - that's fine, the mapping in preferences handles it.
711
704
 
712
- | Service ID | Standard Key Name | Type | Where to Get |
713
- |------------|------------------|------|---------------|
714
- | `jira` | `${USER}_Jira_Access_Token` | PAT | Jira -> Profile -> Personal Access Tokens (VPN required) |
715
- | `bitbucket_token` | `${USER}_Bitbucket_Access_Token` | App Password | Bitbucket -> Personal settings -> App passwords |
716
- | `bitbucket_user` | `${USER}_Bitbucket_Username` | Plain text | Bitbucket profile username |
717
- | `github` | `${USER}_Github_Access_Token` | PAT | GitHub Settings -> Tokens (scopes: repo, read:org, project) |
718
- | `confluence` | `${USER}_Confluence_Access_Token` | PAT | Confluence -> Profile -> Personal Access Tokens (VPN required) |
719
- | `figma` | `${USER}_Figma_Access_Token` | PAT (Tier 2, REST API) | Figma Developer Settings (max 90 days). Token Save Flow prompt copy: "Figma Personal Access Token (for Tier 2)". |
720
- | `figma_mcp` | `${USER}_Figma_Mcp_Access_Token` | OAuth (Tier 1, MCP) | Automatic via Claude Code Figma MCP remote auth. Token Save Flow prompt copy: "Figma MCP token (for Tier 1)". |
721
- | `fortify` | `${USER}_Fortify_Access_Token` | API Token | Fortify SSC -> Token Management (VPN required) |
722
- | `graylog` | `${USER}_Graylog_Access_Token` | PAT (API Basic `<token>:token`) | Graylog -> System -> Users and Teams -> Edit Tokens (VPN required) |
723
- | `graylog_test` | `${USER}_Graylog_Test_Access_Token` | same | Same page on the TEST instance; optional |
724
- | `firebase` | `${USER}_Firebase_Access_Json` | Firebase JSON (base64) | Firebase Console -> Project settings -> Service accounts -> Generate new private key. Project id is parsed from the decoded JSON - no separate entry. |
725
- | `jenkins` | `${USER}_Jenkins_Access_Token` | API Token | Jenkins -> User -> Configure -> API Token |
726
- | - | Git Identities | JSON (preferences) | Stored in `$HOME/.claude/multi-agent-preferences.json` - not Keychain |
705
+ Standard key names and token types: `$HOME/.claude/multi-agent-refs/keychain.md` "Standard key names". Where to generate each one:
706
+
707
+ | Service ID | Where to Get |
708
+ |------------|---------------|
709
+ | `jira` | Jira -> Profile -> Personal Access Tokens (VPN required) |
710
+ | `bitbucket_token` | Bitbucket -> Personal settings -> App passwords |
711
+ | `bitbucket_user` | Bitbucket profile username (plain text, not a PAT) |
712
+ | `github` | GitHub Settings -> Tokens (scopes: repo, read:org, project) |
713
+ | `confluence` | Confluence -> Profile -> Personal Access Tokens (VPN required) |
714
+ | `figma` | Figma Developer Settings (max 90 days). Tier 2 / REST. Prompt copy: "Figma Personal Access Token (for Tier 2)". |
715
+ | `figma_mcp` | Automatic via Claude Code Figma MCP remote auth. Tier 1 / MCP. Prompt copy: "Figma MCP token (for Tier 1)". |
716
+ | `fortify` | Fortify SSC -> Token Management (VPN required) |
717
+ | `graylog` | Graylog -> System -> Users and Teams -> Edit Tokens (VPN required) |
718
+ | `graylog_test` | Same page on the TEST instance; optional |
719
+ | `firebase` | Firebase Console -> Project settings -> Service accounts -> Generate new private key. One key per Firebase project; `project_id` is parsed from the decoded JSON, never asked. |
720
+ | `jenkins` | Jenkins -> User -> Configure -> API Token |
721
+ | Git Identities | Stored in `$HOME/.claude/multi-agent-preferences.json`, not Keychain |
727
722
 
728
723
  ### Inline Token Save Pattern
729
724
 
@@ -13,8 +13,13 @@
13
13
  #
14
14
  # Optional env:
15
15
  # FIREBASE_TIMEOUT_SECONDS default 20
16
- # FIREBASE_TOKEN_KEY default prefs.global.keychainMapping.firebase
17
- # fallback "${USER}_Firebase_Access_Json"
16
+ # FIREBASE_TOKEN_KEY forces one keychain key, skipping account lookup
17
+ #
18
+ # Credential resolution, in order:
19
+ # 1. FIREBASE_TOKEN_KEY (env override)
20
+ # 2. prefs.global.firebase.accounts[] entry whose projectId matches the URL
21
+ # 3. prefs.global.keychainMapping.firebase (single-project setups)
22
+ # 4. "${USER}_Firebase_Access_Json"
18
23
  #
19
24
  # Output (stdout, single JSON object):
20
25
  # {
@@ -116,18 +121,35 @@ if [ -z "$PROJECT_ID" ] || [ -z "$PLATFORM" ] || [ -z "$BUNDLE" ] || [ -z "$ISSU
116
121
  exit 4
117
122
  fi
118
123
 
119
- # Resolve Firebase SA JSON via keychain mapping (matches Phase 0 contract).
124
+ # Resolve the Firebase SA JSON via keychain mapping (matches Phase 0 contract).
125
+ #
126
+ # One team routinely owns several Firebase projects - a legacy app and its
127
+ # redesign, or staging next to production - and each has its own service-account
128
+ # key. A single `keychainMapping.firebase` slot forced a choice, and a crash URL
129
+ # from the other project then failed the project_id check as if it were a
130
+ # configuration error. `global.firebase.accounts[]` maps projectId -> keychain
131
+ # key, and the URL's projectId picks the key. The single slot stays the fallback,
132
+ # so a one-project setup needs no config at all.
120
133
  PREFS="$HOME/.claude/multi-agent-preferences.json"
121
134
  TOKEN_KEY="${FIREBASE_TOKEN_KEY:-}"
135
+ FIREBASE_ACCOUNT_LABEL=""
122
136
  if [ -z "$TOKEN_KEY" ] && [ -f "$PREFS" ]; then
123
- TOKEN_KEY=$(python3 -c "
124
- import json
137
+ ACCOUNT_HIT=$(WANT_PROJECT="$PROJECT_ID" python3 -c "
138
+ import json, os
139
+ want = os.environ.get('WANT_PROJECT') or ''
125
140
  try:
126
- p = json.load(open('$PREFS'))
127
- print(p.get('global', {}).get('keychainMapping', {}).get('firebase') or '')
141
+ g = json.load(open('$PREFS')).get('global', {})
128
142
  except Exception:
129
- print('')
130
- ")
143
+ g = {}
144
+ for a in (g.get('firebase', {}) or {}).get('accounts') or []:
145
+ if isinstance(a, dict) and str(a.get('projectId') or '') == want and a.get('keychainKey'):
146
+ print('%s\t%s' % (a['keychainKey'], a.get('label') or a['projectId']))
147
+ break
148
+ else:
149
+ print('%s\t' % ((g.get('keychainMapping', {}) or {}).get('firebase') or ''))
150
+ " 2>/dev/null || printf '\t')
151
+ TOKEN_KEY=$(printf '%s' "$ACCOUNT_HIT" | cut -f1)
152
+ FIREBASE_ACCOUNT_LABEL=$(printf '%s' "$ACCOUNT_HIT" | cut -f2)
131
153
  fi
132
154
  [ -z "$TOKEN_KEY" ] && TOKEN_KEY="${USER}_Firebase_Access_Json"
133
155
 
@@ -199,7 +221,12 @@ except Exception:
199
221
  print('')
200
222
  ")
201
223
  if [ -n "$SA_PROJECT" ] && [ "$SA_PROJECT" != "$PROJECT_ID" ]; then
224
+ # Still a hard error, but say which key was used and how to map the right one:
225
+ # with several Firebase projects in play, "project mismatch" alone does not
226
+ # tell you whether the URL is wrong or the mapping is missing an account.
202
227
  echo "ERR: SA project_id ($SA_PROJECT) != URL project ($PROJECT_ID)" >&2
228
+ echo " key used: $TOKEN_KEY${FIREBASE_ACCOUNT_LABEL:+ (account: $FIREBASE_ACCOUNT_LABEL)}" >&2
229
+ echo " map this project: prefs.global.firebase.accounts[] += {\"projectId\":\"$PROJECT_ID\",\"keychainKey\":\"<keychain-key>\"}" >&2
203
230
  exit 5
204
231
  fi
205
232
 
@@ -45,7 +45,7 @@ The catalogue is the single source of truth for downstream phases - they read
45
45
  URL pattern (caught by the extractor): `console.firebase.google.com/(u/[0-9]+/)?project/<projectId>/crashlytics/app/(ios|android)(:|%3A)<bundleOrPackage>/issues/<issueId>(/sessions/<sessionId>)?`
46
46
 
47
47
  - Host is fixed (`console.firebase.google.com`); no `hosts.firebase` pref needed.
48
- - Token resolution: `prefs.global.keychainMapping.firebase` `~/.claude/lib/credential-store.sh get "<key>" | base64 -d` → Service Account JSON. `project_id` read from decoded JSON - verify it matches the `<projectId>` from the URL; mismatch → warn, skip enrichment.
48
+ - Token resolution, in order: env `FIREBASE_TOKEN_KEY`; the `prefs.global.firebase.accounts[]` entry whose `projectId` equals the URL's project; `prefs.global.keychainMapping.firebase` (single-project setups). Then `~/.claude/lib/credential-store.sh get "<key>" | base64 -d` → Service Account JSON. `project_id` read from the decoded JSON - verify it matches the `<projectId>` from the URL; mismatch → the fetcher exits `5` naming the key it used and the `accounts[]` entry to add, and enrichment is skipped. Several Firebase projects per team is the normal case (legacy plus redesign, staging plus prod), which is what `accounts[]` exists for.
49
49
  - Exchange the SA JSON for a short-lived GCP access token (scope `https://www.googleapis.com/auth/firebase https://www.googleapis.com/auth/cloud-platform`) using `google-auth` (Python) or an inline JWT exchange (OpenSSL sign → `https://oauth2.googleapis.com/token`). Cache in-memory for the pipeline run only - never persist.
50
50
  - Resolve the opaque `appId` first: `GET https://firebase.googleapis.com/v1beta1/projects/<projectId>/(iosApps|androidApps)?pageSize=200` and match `bundleId` (iOS) / `packageName` (Android) against the bundle from the URL. The console URL only carries the bundle, and the Crashlytics API only accepts the appId (`1:1234567890:ios:abcdef`). No match in a multi-app project → exit `3`, reason `app-not-found`; never guess at another app.
51
51
  - Fetch crash detail with two calls - v1alpha has no get-issue-by-id endpoint: `GET .../v1alpha/projects/<projectId>/apps/<appId>/reports/topIssues?pageSize=200` for the summary and metrics (filter to the issue id), and `GET .../apps/<appId>/events?filter.issue.id=<issueId>&pageSize=1` for the newest event, which carries the stack frames, device, OS and breadcrumbs.
@@ -129,8 +129,12 @@ The shell driver auto-delegates to `~/.claude/scripts/keychain.py` on macOS / Li
129
129
  | `fortify` | Fortify | `${USER}_Fortify_Access_Token` | API Token |
130
130
  | `graylog` | Graylog (prod) | `${USER}_Graylog_Access_Token` | API Token |
131
131
  | `graylog_test` | Graylog (test) | `${USER}_Graylog_Test_Access_Token` | API Token, optional - unset falls back to `graylog` |
132
- | `firebase` | Firebase | `${USER}_Firebase_Access_Json` | JSON (base64) |
132
+ | `firebase` | Firebase | `${USER}_Firebase_Access_Json` | JSON (base64). One key per Firebase project; extras are named `..._Json_<projectId>` and listed in `global.firebase.accounts[]` |
133
133
  | `jenkins` | Jenkins CI | `${USER}_Jenkins_Access_Token` | API Token |
134
+ | `appstore_connect_key_id` | App Store Connect | `${USER}_AppStoreConnect_Key_Id` | Identifier, not a secret |
135
+ | `appstore_connect_issuer_id` | App Store Connect | `${USER}_AppStoreConnect_Issuer_Id` | Identifier, not a secret |
136
+ | `appstore_connect_apple_id` | App Store Connect | `${USER}_AppStoreConnect_Apple_Id` | Email address |
137
+ | `appstore_connect_password_item` | App Store Connect | `${USER}_AppStoreConnect_Password_Item` | Keychain ITEM NAME, not a password |
134
138
  | - | Git Identity | Stored in preferences JSON | Not Keychain |
135
139
 
136
140
  **Key name mapping lives in preferences:**
@@ -17,9 +17,20 @@ what they hold is not a pasteable secret:
17
17
  | `appstore_connect_apple_id` | an email address | same |
18
18
  | `appstore_connect_password_item` | a keychain ITEM NAME | the password lives in Apple's own keychain item, referenced as `-p @keychain:<item>` and never read by the pipeline |
19
19
 
20
+ All four entries are **iOS-only and optional**: skip them all and the pipeline
21
+ still works, it just reports Gate 2 of `/multi-agent:store-ready` as `SKIPPED`,
22
+ never as a pass.
23
+
24
+ The tiers mirror the Figma 3-tier shape: Tier 1 = API key (`appstore_connect_key_id`
25
+ + `appstore_connect_issuer_id`), Tier 2 = Apple ID + app-specific password
26
+ (`appstore_connect_apple_id` + `appstore_connect_password_item`), Tier 3 = nothing
27
+ configured.
28
+
20
29
  Ask which tier to configure (picker): **API key** / **Apple ID + app-specific
21
30
  password** / **Skip**. Lead with the second when the user says they cannot create
22
- an API key.
31
+ an API key: creating one needs an Admin or App Manager role in App Store Connect,
32
+ while an app-specific password is generated by the account holder at
33
+ `appleid.apple.com` with no team permission at all.
23
34
 
24
35
  **API key.** The private key is a FILE and is never copied into the credential
25
36
  store. It must sit in a directory `altool` already searches:
@@ -590,6 +590,36 @@
590
590
  }
591
591
  }
592
592
  },
593
+ "firebase": {
594
+ "type": "object",
595
+ "additionalProperties": false,
596
+ "description": "v15.16+ - Firebase service-account keys. One team routinely owns several Firebase projects (a legacy app and its redesign, staging next to production), each with its own key, and the single keychainMapping.firebase slot forced a choice: a crash URL from the other project then failed the project_id check as if it were misconfigured. Leave this out entirely for a one-project setup - the single slot stays the fallback.",
597
+ "properties": {
598
+ "accounts": {
599
+ "type": "array",
600
+ "description": "Maps a Firebase projectId to the keychain key holding its service-account JSON. fetch-crashlytics.sh reads the projectId out of the console URL and picks the matching entry; no match falls back to keychainMapping.firebase.",
601
+ "items": {
602
+ "type": "object",
603
+ "additionalProperties": false,
604
+ "required": ["projectId", "keychainKey"],
605
+ "properties": {
606
+ "projectId": {
607
+ "type": "string",
608
+ "description": "Firebase project id exactly as it appears in a console URL, e.g. my-app-prod-1234."
609
+ },
610
+ "keychainKey": {
611
+ "type": "string",
612
+ "description": "Keychain key holding this project's service-account JSON (base64 or raw)."
613
+ },
614
+ "label": {
615
+ "type": "string",
616
+ "description": "Human label used in error output, e.g. \"redesign prod\". Defaults to the projectId."
617
+ }
618
+ }
619
+ }
620
+ }
621
+ }
622
+ },
593
623
  "fortify": {
594
624
  "type": "object",
595
625
  "additionalProperties": false,