@mmerterden/multi-agent-pipeline 15.14.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,33 @@ 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
+
32
+ ## [15.15.0] - 2026-08-22
33
+
34
+ ### Added
35
+ - **Graylog has two instances now, because it always did.** Test and production are separate Graylog deployments, and a trx id minted by a tester does not exist in production - so searching production alone answered "no logs" for a complaint that was fully logged one host over, and that answer was indistinguishable from a genuine miss. `hosts.graylogTest` and the optional `keychainMapping.graylog_test` (which falls back to the production key, correct for shared-token deployments) make the second instance addressable. `fetch-graylog.sh --env auto` is the new default: production first, test when production returns nothing or is unreachable. `--env prod` / `--env test` pin one.
36
+ - **The payload names the instance that answered** (`source.environment`, `source.searchedEnvironments[]`), and `/multi-agent:complaint-analysis` now has to cite it. A production complaint corroborated only by test logs is `insufficient-evidence`, not a confirmed `bff` fault, and the old payload gave the triage no way to tell those apart.
37
+ - `/multi-agent:setup` asks for the two things the previous release added a consumer for but no collector: the Graylog test host plus its optional separate token, and `fortify.versionIds`. `versionIds` shipped in 15.14.0 as the only way to resolve an instance-id-only Fortify ticket, and nothing asked for it, so that path silently no-opped for everyone.
38
+ - **`smoke-graylog-environments.sh`**, 15 assertions over the resolution rules that are easy to get subtly wrong: auto stops at production when production answers, falls back on empty AND on unreachable, degrades (never blocks) when both are down, treats a pinned environment with no host as exit 6 rather than silently searching the other one, and lets a 401 on one instance fall through instead of masking a working answer from the other.
39
+
40
+ ### Fixed
41
+ - A pinned `--env` with no configured host now exits 6 naming the exact pref, instead of falling back to whichever host happened to be set. Attaching test logs to a production complaint is worse than attaching none.
42
+
43
+ ### Changed
44
+ - `fetch-graylog.sh` resolves host and token per environment instead of once at the top, and reads prefs through one helper rather than three near-identical inline python blocks.
45
+
19
46
  ## [15.14.0] - 2026-08-22
20
47
 
21
48
  ### Added
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mmerterden/multi-agent-pipeline",
3
- "version": "15.14.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",
@@ -81,12 +81,16 @@ For each complaint with at least one id and `skipGraylog: false`:
81
81
  ~/.claude/lib/fetch-graylog.sh --trx <trxId> --conv <convId> # pass whichever exist
82
82
  ```
83
83
 
84
- Record per complaint: `graylog: {status: ok|degraded|skipped, totalResults, degradeReason}` plus the top messages (timestamp, source, level, message excerpt) kept in working context for Phase 2/3. Failure handling:
84
+ Environment defaults to `auto`: production first, the test instance (`hosts.graylogTest`) when production returns nothing or is unreachable. A customer complaint is normally a production event, but a tester-minted id only exists on test, and prod-only search reports "no logs" for both cases identically. When the intake itself says which environment a complaint came from, pin it with `--env prod` / `--env test` rather than letting the fallback decide.
85
+
86
+ Record per complaint: `graylog: {status: ok|degraded|skipped, environment, totalResults, degradeReason}` plus the top messages (timestamp, source, level, message excerpt) kept in working context for Phase 2/3. Failure handling:
85
87
 
86
88
  - Exit 0 with `degraded: true` -> `status: degraded`, keep the reason, continue.
87
89
  - Exit 2 / 3 / 6 (credential / auth / host) -> per `$HOME/.claude/multi-agent-refs/keychain.md` non-critical rule: warn ONCE (`WARN: Graylog unavailable (<reason>); remaining complaints proceed without log evidence.`), mark this and all remaining fetches `status: degraded`, continue. Never halt (Locked 1).
88
90
  - `skipGraylog: true` -> `status: skipped`.
89
91
 
92
+ **Always name the environment when citing log evidence.** `source.environment` in the fetcher payload says which instance answered; the same lines mean different things depending on whether a production complaint was corroborated by production logs or only by test ones. A complaint whose only evidence came from test is `insufficient-evidence` for a production claim, not `bff`.
93
+
90
94
  Set `phase: "correlating_repos"`.
91
95
 
92
96
  ### Phase 2 - Repo evidence correlation (read-only)
@@ -92,31 +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
- | `firebase` | `${USER}_Firebase_Access_Json` | `firebase` (any variant: `sa`, `service`, `account`, `access`, `json`) |
111
- | `jenkins` | `${USER}_Jenkins_Access_Token` | `jenkins` |
112
- | `appstore_connect_key_id` | `${USER}_AppStoreConnect_Key_Id` | (`appstore` or `asc` or `app_store`) + (`key` or `keyid`) |
113
- | `appstore_connect_issuer_id` | `${USER}_AppStoreConnect_Issuer_Id` | (`appstore` or `asc` or `app_store`) + `issuer` |
114
- | `appstore_connect_apple_id` | `${USER}_AppStoreConnect_Apple_Id` | (`appstore` or `asc`) + (`apple` or `account` or `user`) |
115
- | `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`) |
116
117
 
117
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.
118
119
 
119
- > 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`.
120
121
 
121
122
  **1c. Resolution logic (per service):**
122
123
 
@@ -183,27 +184,14 @@ Keychain Token Discovery (${USER}):
183
184
 
184
185
  Non-standard key names are perfectly valid - the mapping handles resolution. No rename or migration is offered.
185
186
 
186
- 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
+
187
189
  ```json
188
- {
189
- "global": {
190
- "keychainMapping": {
191
- "jira": "${USER}_Jira_Access_Token",
192
- "bitbucket_token": "${USER}_Bitbucket_Access_Token",
193
- "bitbucket_user": "${USER}_Bitbucket_Username",
194
- "github": "${USER}_Github",
195
- "confluence": null,
196
- "figma": "${USER}_Figma_Access_Token",
197
- "figma_mcp": "${USER}_Figma_Mcp_Access_Token",
198
- "fortify": null,
199
- "graylog": null,
200
- "firebase": "${USER}_Firebase_Access_Json",
201
- "jenkins": null
202
- }
203
- }
204
- }
190
+ { "jira": "${USER}_Jira_Access_Token", "github": "${USER}_Github", "confluence": null, "...": "..." }
205
191
  ```
206
192
 
193
+ Full key list and shape: `$HOME/.claude/multi-agent-refs/keychain.md`.
194
+
207
195
  `null` = not mapped (missing or skipped). Pipeline phases read this mapping to retrieve tokens dynamically - never hardcoded key names.
208
196
 
209
197
  ### Step 2.7 - Operational reporting token (optional, opt-in)
@@ -288,7 +276,7 @@ The Step 2 discovery summary is never the end of setup. Immediately after showin
288
276
  Loop rules:
289
277
 
290
278
  - **One prompt per service.** Never collapse the list into a single "add missing tokens now?" yes/no, and never offer one multi-select covering all services: each service has its own source URL, key name, identity binding and host prompt, so each gets its own Token Save Flow pass.
291
- - **Each service pass asks, in order: token → author → host.** Step A/B collect the secret, Step C binds the git identity (author) - Bitbucket and GitHub included; every saved token gets an author - and Step 3.5 asks the host domain inline for hosted services (jira, confluence, bitbucket, fortify, graylog). GitHub and Figma skip the host question (fixed domains); `bitbucket_user` skips author binding (username, not a PAT). Never end a service pass with the token saved but the author or a required host unasked.
279
+ - **Each service pass asks, in order: token → author → host.** Step A/B collect the secret, Step C binds the git identity (author) - Bitbucket and GitHub included; every saved token gets an author - and Step 3.5 asks the host domain inline for hosted services (jira, confluence, bitbucket, fortify, graylog) - plus the Fortify project versions and the optional Graylog test instance. GitHub and Figma skip the host question (fixed domains); `bitbucket_user` skips author binding (username, not a PAT). Never end a service pass with the token saved but the author or a required host unasked.
292
280
  - **Skip is per-service.** A Skip answer leaves that service's `keychainMapping` at `null` and the loop continues with the next service. Skips never abort the loop.
293
281
  - **`figma` and `figma_mcp` are distinct credentials for distinct tiers - ask both.** Skipping the PAT does not imply skipping MCP, and vice versa. The `figma_mcp` pass opens with a Remote / Local mode question instead of a clipboard paste (see "Figma MCP Setup" below): Remote generates a `figu_` OAuth token for the current user, Local wires the PAT-based local server.
294
282
  - The loop ends only when every missing service has been asked exactly once; then continue to Step 4 (Verify all).
@@ -369,6 +357,12 @@ pbcopy < /dev/null # clear clipboard
369
357
 
370
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`).
371
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
+
372
366
  For GitHub (special case):
373
367
  ```
374
368
  ! gh auth login
@@ -408,8 +402,7 @@ Update `serviceStatus.{service} = { ok: true, checkedAt: <now> }`.
408
402
  **→ Next: if `{service}` is `jira` / `confluence` / `bitbucket` / `fortify` / `graylog` AND the corresponding
409
403
  `prefs.global.hosts.{service}` is unset, run Step 3.5 (Host Prompt) inline before
410
404
  the Auto-routing rule below. required - the pipeline cannot build API URLs without
411
- the host. Firebase is skipped here (host is always `console.firebase.google.com` /
412
- `firebasecrashlytics.googleapis.com`); the SA JSON itself carries `project_id`.**
405
+ the host. Firebase is exempt (see Step 3.5).**
413
406
 
414
407
  **Auto-routing rule** (optional, on first token per platform):
415
408
 
@@ -430,7 +423,7 @@ Runs inside Step 3 alongside the other missing credentials. The four `appstore_c
430
423
 
431
424
  ### Step 3.5 - Host Prompt (embedded in Token Save Flow)
432
425
 
433
- **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.
434
427
 
435
428
  Right after Step D (identity mapping confirmed), ask:
436
429
 
@@ -440,27 +433,30 @@ Service host - needed to build API URLs for this token.
440
433
  {Service} host (e.g. jira.example.com): ___
441
434
  ```
442
435
 
443
- Save to `prefs.global.hosts.{service}`. For Jira specifically, also ask for the default project key (the token is useless without one for new-issue creation):
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.
444
437
 
445
- ```
446
- Default Jira project key (e.g. PROJ): ___
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:
439
+
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 | .[]'
447
444
  ```
448
445
 
449
- 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.
450
447
 
451
- `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:
452
449
 
453
450
  ```
454
451
  Per-repo Jira keys (optional) - repos whose project key differs from {defaultJiraKey}:
455
452
 
456
453
  [ ] 1. my-ios-app (current: PROJA)
457
454
  [ ] 2. my-other-app (current: - )
458
- ...
459
455
 
460
- 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.
461
457
  ```
462
458
 
463
- - 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.
464
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.
465
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.
466
462
  - **Re-run / update**: `/multi-agent:setup jira-keys` re-opens only this mapping without touching tokens or hosts.
@@ -476,6 +472,7 @@ Resulting shape:
476
472
  "bitbucket": "bitbucket.example.com",
477
473
  "fortify": "ssc.example.com",
478
474
  "graylog": "graylog.example.com",
475
+ "graylogTest": "graylog-test.example.com",
479
476
  "corpDomain": "example.com"
480
477
  },
481
478
  "defaultJiraKey": "PROJ"
@@ -483,9 +480,18 @@ Resulting shape:
483
480
  }
484
481
  ```
485
482
 
486
- Placeholders `{JIRA_HOST}`, `{CONFLUENCE_HOST}`, `{BITBUCKET_HOST}`, `{FORTIFY_HOST}`, `{GRAYLOG_HOST}`, `{CORP_DOMAIN}`, `{JIRA_KEY}` in skills/commands are resolved from this block at runtime. If a host is ever missing when a phase needs it, Phase 0 prompts the same Host Prompt inline.
483
+ Placeholders `{JIRA_HOST}`, `{CONFLUENCE_HOST}`, `{BITBUCKET_HOST}`, `{FORTIFY_HOST}`, `{GRAYLOG_HOST}`, `{GRAYLOG_TEST_HOST}`, `{CORP_DOMAIN}`, `{JIRA_KEY}` in skills/commands are resolved from this block at runtime. If a host is ever missing when a phase needs it, Phase 0 prompts the same Host Prompt inline.
484
+
485
+ Graylog uses a **PAT** whose API auth is HTTP Basic with the token as username and the literal `token` as password - no separate username entry. It is optional by the null-mapping convention: `keychainMapping.graylog` or `hosts.graylog` left `null` degrades the log fetch to empty and never blocks a run.
486
+
487
+ Graylog is asked **twice** - test and production are separate instances, and a tester-minted trx id does not exist in production. After the production host:
488
+
489
+ ```
490
+ Graylog test host (optional - Enter to skip): ___
491
+ Does the test instance use a different token? [y/N]
492
+ ```
487
493
 
488
- Graylog uses a **PAT (access token)** whose API auth is HTTP Basic with the token as the username and the literal `token` as the password (`<token>:token`) - no separate username entry. Its host prompt behaves exactly like the Jira host prompt (saved to `prefs.global.hosts.graylog`, resolved as `{GRAYLOG_HOST}`). Graylog is **automatically optional / non-blocking** via the null-mapping convention: leaving `keychainMapping.graylog` (and/or `hosts.graylog`) `null` simply means the log-fetch adapter degrades to empty and never blocks a run - no extra setup code.
494
+ Test host `hosts.graylogTest` (`{GRAYLOG_TEST_HOST}`); skipped means production-only search, which degrades nothing. Yes to the token question runs one more Token Save Flow pass for `graylog_test`; no (default) leaves it null and the fetcher reuses the production key. Run-time order is `--env auto`: production first, test on empty or unreachable, and the payload names which answered.
489
495
 
490
496
  **Re-run / update**: `/multi-agent:setup hosts` re-opens the prompt to edit values without touching tokens.
491
497
 
@@ -696,20 +702,23 @@ If no backend is available, setup falls back to a plain-text prompt + a warning
696
702
 
697
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.
698
704
 
699
- | Service ID | Standard Key Name | Type | Where to Get |
700
- |------------|------------------|------|---------------|
701
- | `jira` | `${USER}_Jira_Access_Token` | PAT | Jira -> Profile -> Personal Access Tokens (VPN required) |
702
- | `bitbucket_token` | `${USER}_Bitbucket_Access_Token` | App Password | Bitbucket -> Personal settings -> App passwords |
703
- | `bitbucket_user` | `${USER}_Bitbucket_Username` | Plain text | Bitbucket profile username |
704
- | `github` | `${USER}_Github_Access_Token` | PAT | GitHub Settings -> Tokens (scopes: repo, read:org, project) |
705
- | `confluence` | `${USER}_Confluence_Access_Token` | PAT | Confluence -> Profile -> Personal Access Tokens (VPN required) |
706
- | `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)". |
707
- | `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)". |
708
- | `fortify` | `${USER}_Fortify_Access_Token` | API Token | Fortify SSC -> Token Management (VPN required) |
709
- | `graylog` | `${USER}_Graylog_Access_Token` | PAT (API Basic `<token>:token`) | Graylog -> System -> Users and Teams -> Edit Tokens (VPN required). Auth is HTTP Basic with the token as username and literal `token` as password. |
710
- | `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. |
711
- | `jenkins` | `${USER}_Jenkins_Access_Token` | API Token | Jenkins -> User -> Configure -> API Token |
712
- | - | 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 |
713
722
 
714
723
  ### Inline Token Save Pattern
715
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
 
@@ -11,14 +11,29 @@
11
11
  # ./fetch-graylog.sh --trx <transaction-id>
12
12
  # ./fetch-graylog.sh --conv <conversation-id>
13
13
  # ./fetch-graylog.sh --trx <id> --conv <id>
14
+ # ./fetch-graylog.sh --trx <id> --env test # force one environment
15
+ #
16
+ # Environments. Test and production are separate Graylog instances, and a trx id
17
+ # minted by a tester simply does not exist in production - searching only prod
18
+ # returns "no logs" for a complaint that is fully logged one host over. The
19
+ # default `--env auto` searches production first and falls back to test when
20
+ # production returns nothing or is unreachable; the answer always names the
21
+ # environment that produced it, so a result is never silently from elsewhere.
22
+ # `--env prod` / `--env test` pin a single instance.
14
23
  #
15
24
  # Required configuration:
16
- # prefs.global.hosts.graylog - Graylog host (e.g. logs.example.com)
17
- # prefs.global.keychainMapping.graylog - keychain key holding the API token
25
+ # prefs.global.hosts.graylog - production host (e.g. logs.example.com)
26
+ # prefs.global.keychainMapping.graylog - keychain key holding the prod API token
27
+ # Optional (test environment):
28
+ # prefs.global.hosts.graylogTest - test host; without it, test is skipped
29
+ # prefs.global.keychainMapping.graylog_test - test token key; falls back to the prod
30
+ # key when unset (shared-token deployments)
18
31
  #
19
32
  # Optional env:
20
33
  # GRAYLOG_TIMEOUT_SECONDS default 25
21
- # GRAYLOG_HOST_OVERRIDE forces the host (used by tests and CI dry-runs)
34
+ # GRAYLOG_ENV auto|prod|test, same as --env (default auto)
35
+ # GRAYLOG_HOST_OVERRIDE forces one host, reported as environment "override"
36
+ # (used by tests and CI dry-runs)
22
37
  # GRAYLOG_RANGE_SECONDS relative search window, default 86400 (24h)
23
38
  # GRAYLOG_LIMIT max messages to return, default 200
24
39
  #
@@ -30,7 +45,9 @@
30
45
  # Output (stdout, single JSON object):
31
46
  # {
32
47
  # "fetchedAt": "<ISO8601>",
33
- # "source": { "host": "<host>", "transactionId": "..."|null,
48
+ # "source": { "host": "<host>", "environment": "prod|test|override",
49
+ # "searchedEnvironments": [ "prod", ... ],
50
+ # "transactionId": "..."|null,
34
51
  # "conversationId": "..."|null, "query": "..." },
35
52
  # "totalResults": <n>,
36
53
  # "messages": [
@@ -45,12 +62,13 @@
45
62
  # 2 missing/expired credential (orchestrator handles the Save Flow)
46
63
  # 3 auth rejected on a reachable host (4xx)
47
64
  # 4 bad usage
48
- # 6 host not configured (prefs.global.hosts.graylog is empty)
65
+ # 6 host not configured (the requested environment has no host in prefs)
49
66
 
50
67
  set -euo pipefail
51
68
 
52
69
  TRX_ID=""
53
70
  CONV_ID=""
71
+ ENVIRONMENT="${GRAYLOG_ENV:-auto}"
54
72
  TIMEOUT="${GRAYLOG_TIMEOUT_SECONDS:-25}"
55
73
  RANGE="${GRAYLOG_RANGE_SECONDS:-86400}"
56
74
  LIMIT="${GRAYLOG_LIMIT:-200}"
@@ -59,32 +77,50 @@ while [ $# -gt 0 ]; do
59
77
  case "$1" in
60
78
  --trx) TRX_ID="$2"; shift 2 ;;
61
79
  --conv) CONV_ID="$2"; shift 2 ;;
80
+ --env) ENVIRONMENT="$2"; shift 2 ;;
62
81
  -h|--help)
63
- echo "usage: $0 --trx <transaction-id> | --conv <conversation-id>" >&2
82
+ echo "usage: $0 --trx <transaction-id> | --conv <conversation-id> [--env auto|prod|test]" >&2
64
83
  exit 4 ;;
65
84
  *)
66
85
  echo "ERR: unexpected arg $1" >&2; exit 4 ;;
67
86
  esac
68
87
  done
69
88
 
70
- # Host comes from prefs (private config); skill templates only show {GRAYLOG_HOST}.
89
+ case "$ENVIRONMENT" in
90
+ auto|prod|test) ;;
91
+ *) echo "ERR: --env must be auto, prod or test (got '$ENVIRONMENT')" >&2; exit 4 ;;
92
+ esac
93
+
94
+ # Hosts and token keys come from prefs (private config); skill templates only
95
+ # show {GRAYLOG_HOST} / {GRAYLOG_TEST_HOST}.
71
96
  PREFS="$HOME/.claude/multi-agent-preferences.json"
72
- HOST="${GRAYLOG_HOST_OVERRIDE:-}"
73
- if [ -z "$HOST" ] && [ -f "$PREFS" ]; then
74
- HOST=$(python3 -c "
75
- import json
97
+
98
+ pref_str() {
99
+ # Usage: pref_str <dotted.path.under.global> -> value or empty
100
+ [ -f "$PREFS" ] || { printf ''; return 0; }
101
+ PREF_PATH="$1" python3 -c "
102
+ import json, os
76
103
  try:
77
- p = json.load(open('$PREFS'))
78
- print(p.get('global', {}).get('hosts', {}).get('graylog') or '')
104
+ node = json.load(open('$PREFS')).get('global', {})
105
+ for part in os.environ['PREF_PATH'].split('.'):
106
+ node = node.get(part, {}) if isinstance(node, dict) else {}
107
+ print(node if isinstance(node, str) else '')
79
108
  except Exception:
80
109
  print('')
81
- ")
82
- fi
83
- if [ -z "$HOST" ]; then
84
- printf '%s\n' '{"status":"blocked","reason":"host-not-configured","service":"graylog","expected_pref":"global.hosts.graylog"}' >&2
85
- exit 6
86
- fi
87
- HOST=${HOST%/} # strip trailing slash if any
110
+ " 2>/dev/null || printf ''
111
+ }
112
+
113
+ HOST_PROD=$(pref_str hosts.graylog)
114
+ HOST_TEST=$(pref_str hosts.graylogTest)
115
+ HOST_PROD=${HOST_PROD%/}
116
+ HOST_TEST=${HOST_TEST%/}
117
+
118
+ KEY_PROD=$(pref_str keychainMapping.graylog)
119
+ [ -z "$KEY_PROD" ] && KEY_PROD="${USER}_Graylog_Access_Token"
120
+ # A test instance usually issues its own token, but plenty of deployments share
121
+ # one; falling back to the prod key beats refusing to search test at all.
122
+ KEY_TEST=$(pref_str keychainMapping.graylog_test)
123
+ [ -z "$KEY_TEST" ] && KEY_TEST="$KEY_PROD"
88
124
 
89
125
  if [ -z "$TRX_ID" ] && [ -z "$CONV_ID" ]; then
90
126
  echo "ERR: no id (pass --trx <transaction-id> and/or --conv <conversation-id>)" >&2
@@ -104,20 +140,6 @@ if [ -n "$CONV_ID" ]; then
104
140
  fi
105
141
  fi
106
142
 
107
- # Token via keychain mapping.
108
- TOKEN_KEY=""
109
- if [ -f "$PREFS" ]; then
110
- TOKEN_KEY=$(python3 -c "
111
- import json
112
- try:
113
- p = json.load(open('$PREFS'))
114
- print(p.get('global', {}).get('keychainMapping', {}).get('graylog') or '')
115
- except Exception:
116
- print('')
117
- ")
118
- fi
119
- [ -z "$TOKEN_KEY" ] && TOKEN_KEY="${USER}_Graylog_Access_Token"
120
-
121
143
  # Locate the resolver with an existence check, not a `.`-chain.
122
144
  #
123
145
  # Sourcing a file that does not exist aborts the shell under `set -e` - `||` included -
@@ -142,50 +164,125 @@ for _cred_resolver in \
142
164
  done
143
165
  unset _cred_resolver
144
166
  if [ -z "${CRED_STORE:-}" ]; then
145
- printf '%s\n' '{"status":"blocked","reason":"missing-credential-helper","service":"graylog","expected_key":"'"$TOKEN_KEY"'"}' >&2
167
+ printf '%s\n' '{"status":"blocked","reason":"missing-credential-helper","service":"graylog","expected_key":"'"$KEY_PROD"'"}' >&2
146
168
  exit 2
147
169
  fi
148
170
 
149
- TOKEN=$("$CRED_STORE" get "$TOKEN_KEY" 2>/dev/null || true)
150
- if [ -z "$TOKEN" ]; then
151
- printf '%s\n' '{"status":"blocked","reason":"missing-token","service":"graylog","expected_key":"'"$TOKEN_KEY"'"}' >&2
152
- exit 2
153
- fi
171
+ # Graylog relative universal search, built once and reused per environment.
172
+ ENC_QUERY=$(QUERY_IN="$QUERY" python3 -c "
173
+ import os, urllib.parse as up
174
+ print(up.quote(os.environ['QUERY_IN']))
175
+ ")
176
+ SEARCH_PATH="search/universal/relative?query=$ENC_QUERY&range=$RANGE&limit=$LIMIT&sort=timestamp:desc"
154
177
 
155
- API_BASE="https://$HOST/api"
178
+ # Set by run_search: the outcome of the most recent environment probe.
179
+ RUN_HTTP=""
180
+ RUN_BODY=""
181
+ RUN_HITS=0
156
182
 
157
- graylog_get() {
158
- # Usage: graylog_get <path-and-query-relative-to-API_BASE>
159
- # Prints "<body>\n<http_code>"; the caller splits the last line off.
160
- local path="$1"
161
- local url="$API_BASE/$path"
183
+ run_search() {
184
+ # Usage: run_search <host> <token-key>
185
+ local host="$1" token_key="$2" token url resp
186
+ token=$("$CRED_STORE" get "$token_key" 2>/dev/null || true)
187
+ if [ -z "$token" ]; then
188
+ RUN_HTTP="no-token"; RUN_BODY=""; RUN_HITS=0
189
+ return 0
190
+ fi
191
+ url="https://$host/api/$SEARCH_PATH"
162
192
  # PAT basic auth: username=<token>, password=literal "token". The credential
163
193
  # goes through a curl config via process substitution so it never lands in
164
194
  # argv (argv is visible to ps).
165
- curl -sS --max-time "$TIMEOUT" --connect-timeout 5 -w "\n%{http_code}" \
166
- -K <(printf 'user = "%s:token"\n' "$TOKEN") \
195
+ resp=$(curl -sS --max-time "$TIMEOUT" --connect-timeout 5 -w "\n%{http_code}" \
196
+ -K <(printf 'user = "%s:token"\n' "$token") \
167
197
  -H "Accept: application/json" \
168
198
  -H "X-Requested-By: multi-agent-pipeline" \
169
- "$url" 2>/dev/null || true
199
+ "$url" 2>/dev/null || true)
200
+ RUN_HTTP=$(printf '%s' "$resp" | tail -n1)
201
+ RUN_BODY=$(printf '%s' "$resp" | sed '$d')
202
+ RUN_HITS=0
203
+ if [ "$RUN_HTTP" = "200" ]; then
204
+ RUN_HITS=$(BODY_IN="$RUN_BODY" python3 -c "
205
+ import json, os
206
+ try:
207
+ print(len(json.loads(os.environ['BODY_IN']).get('messages') or []))
208
+ except Exception:
209
+ print(0)
210
+ " 2>/dev/null || echo 0)
211
+ fi
212
+ # An empty RUN_HITS would make the caller's numeric test abort under `set -e`.
213
+ case "$RUN_HITS" in (*[!0-9]*|"") RUN_HITS=0 ;; esac
170
214
  }
171
215
 
172
- # Graylog relative universal search.
173
- ENC_QUERY=$(QUERY_IN="$QUERY" python3 -c "
174
- import os, urllib.parse as up
175
- print(up.quote(os.environ['QUERY_IN']))
176
- ")
177
- SEARCH_PATH="search/universal/relative?query=$ENC_QUERY&range=$RANGE&limit=$LIMIT&sort=timestamp:desc"
216
+ # Which instances to try, in order. `auto` reaches for test only when prod had
217
+ # nothing to say - either no hits or no answer at all.
218
+ CANDIDATES=""
219
+ if [ -n "${GRAYLOG_HOST_OVERRIDE:-}" ]; then
220
+ CANDIDATES="override"
221
+ else
222
+ case "$ENVIRONMENT" in
223
+ prod) CANDIDATES="prod" ;;
224
+ test) CANDIDATES="test" ;;
225
+ auto) if [ -n "$HOST_TEST" ]; then CANDIDATES="prod test"; else CANDIDATES="prod"; fi ;;
226
+ esac
227
+ fi
228
+
229
+ host_for() {
230
+ case "$1" in
231
+ override) printf '%s' "${GRAYLOG_HOST_OVERRIDE%/}" ;;
232
+ test) printf '%s' "$HOST_TEST" ;;
233
+ *) printf '%s' "$HOST_PROD" ;;
234
+ esac
235
+ }
236
+ key_for() {
237
+ case "$1" in
238
+ test) printf '%s' "$KEY_TEST" ;;
239
+ *) printf '%s' "$KEY_PROD" ;;
240
+ esac
241
+ }
242
+
243
+ # A pinned environment with no host is a configuration error, not a degrade:
244
+ # silently searching the other instance would attach the wrong logs.
245
+ for _env in $CANDIDATES; do
246
+ if [ -z "$(host_for "$_env")" ]; then
247
+ if [ "$_env" = "test" ]; then
248
+ printf '%s\n' '{"status":"blocked","reason":"host-not-configured","service":"graylog","environment":"test","expected_pref":"global.hosts.graylogTest"}' >&2
249
+ else
250
+ printf '%s\n' '{"status":"blocked","reason":"host-not-configured","service":"graylog","environment":"prod","expected_pref":"global.hosts.graylog"}' >&2
251
+ fi
252
+ exit 6
253
+ fi
254
+ done
178
255
 
179
- RESP=$(graylog_get "$SEARCH_PATH")
180
- HTTP=$(printf '%s' "$RESP" | tail -n1)
181
- BODY=$(printf '%s' "$RESP" | sed '$d')
256
+ HOST=""
257
+ ENV_USED=""
258
+ SEARCHED=""
259
+ HTTP=""
260
+ BODY=""
261
+ LAST_CANDIDATE="${CANDIDATES##* }"
262
+ for _env in $CANDIDATES; do
263
+ SEARCHED="${SEARCHED:+$SEARCHED }$_env"
264
+ run_search "$(host_for "$_env")" "$(key_for "$_env")"
265
+ HOST=$(host_for "$_env")
266
+ ENV_USED="$_env"
267
+ HTTP="$RUN_HTTP"
268
+ BODY="$RUN_BODY"
269
+ # An auth rejection on a reachable host is the only hard failure, and it is
270
+ # per-instance: a dead test token must not mask a working prod answer, so it
271
+ # aborts only when no candidate is left to try.
272
+ if [ "$HTTP" = "401" ] || [ "$HTTP" = "403" ]; then
273
+ if [ "$_env" = "$LAST_CANDIDATE" ]; then
274
+ echo "ERR: Graylog auth rejected on $_env (HTTP $HTTP)" >&2
275
+ exit 3
276
+ fi
277
+ continue
278
+ fi
279
+ if [ "$RUN_HITS" -gt 0 ]; then break; fi
280
+ done
182
281
 
183
- # A genuine auth rejection on a reachable host is the only hard failure.
184
- case "$HTTP" in
185
- 401|403)
186
- echo "ERR: Graylog auth rejected (HTTP $HTTP)" >&2
187
- exit 3 ;;
188
- esac
282
+ if [ "$HTTP" = "no-token" ]; then
283
+ printf '%s\n' '{"status":"blocked","reason":"missing-token","service":"graylog","expected_key":"'"$(key_for "$ENV_USED")"'"}' >&2
284
+ exit 2
285
+ fi
189
286
 
190
287
  # Any other non-200 (empty code on connection failure, 5xx, timeout) degrades
191
288
  # to an empty result so a log fetch never blocks a run.
@@ -197,7 +294,8 @@ if [ "$HTTP" != "200" ]; then
197
294
  BODY=""
198
295
  fi
199
296
 
200
- SRC_HOST="$HOST" SRC_TRX="$TRX_ID" SRC_CONV="$CONV_ID" SRC_QUERY="$QUERY" \
297
+ SRC_HOST="$HOST" SRC_ENV="$ENV_USED" SRC_SEARCHED="$SEARCHED" \
298
+ SRC_TRX="$TRX_ID" SRC_CONV="$CONV_ID" SRC_QUERY="$QUERY" \
201
299
  DEGRADED_IN="$DEGRADED" DEGRADE_REASON_IN="$DEGRADE_REASON" \
202
300
  BODY_IN="$BODY" LIMIT_IN="$LIMIT" \
203
301
  python3 - <<'PY'
@@ -239,9 +337,11 @@ if not isinstance(total, int):
239
337
  total = len(messages)
240
338
 
241
339
  result = {
242
- "fetchedAt": datetime.datetime.utcnow().isoformat() + "Z",
340
+ "fetchedAt": datetime.datetime.now(datetime.timezone.utc).isoformat().replace("+00:00", "Z"),
243
341
  "source": {
244
342
  "host": os.environ["SRC_HOST"],
343
+ "environment": os.environ.get("SRC_ENV") or "prod",
344
+ "searchedEnvironments": (os.environ.get("SRC_SEARCHED") or "").split(),
245
345
  "transactionId": trx,
246
346
  "conversationId": conv,
247
347
  "query": os.environ.get("SRC_QUERY") or "",
@@ -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.
@@ -74,10 +74,11 @@ Two entry shapes reach this step, and `metadata.source` says which:
74
74
 
75
75
  The extractor emits `graylog` entries from free-text trx / conversation ids (labels like `trx`, `trxId`, `transaction id`, `conversationId`, `convId`, `X-conversationId`), not from a URL - so these entries carry `"url": null`. When at least one is present, pull the matching diagnostic logs.
76
76
 
77
- - Host comes from `prefs.global.hosts.graylog` (`GRAYLOG_HOST_OVERRIDE` env forces it for tests/CI); no literal host lives in any source file. Missing host → the fetcher exits `6`; log a setup hint and skip.
78
- - Token resolution: `prefs.global.keychainMapping.graylog` `~/.claude/lib/credential-store.sh get "<key>"` Graylog PAT (fallback key `${USER}_Graylog_Access_Token`). Auth is HTTP Basic with the token as username and the literal `token` as password; the token is fed to `curl` only via a `-K` config file (process substitution), never on argv.
77
+ - Hosts come from `prefs.global.hosts.graylog` (production) and the optional `prefs.global.hosts.graylogTest` (`GRAYLOG_HOST_OVERRIDE` forces a single host for tests/CI); no literal host lives in any source file. Missing host for the requested environment → the fetcher exits `6`; log a setup hint and skip.
78
+ - **Environment**: the fetcher defaults to `--env auto`, which searches production and falls back to test when production returns nothing or is unreachable. A trx id minted by a tester does not exist in production, so prod-only search reports "no logs" for a complaint that is fully logged one host over. The result carries `source.environment` and `source.searchedEnvironments[]`; quote the environment whenever the logs are cited, because evidence from test says something different about a production complaint than the same lines from prod. Pin one instance with `--env prod` / `--env test` when the ticket already says which.
79
+ - Token resolution: `prefs.global.keychainMapping.graylog` → `~/.claude/lib/credential-store.sh get "<key>"` → Graylog PAT (fallback key `${USER}_Graylog_Access_Token`). The test instance uses `keychainMapping.graylog_test`, falling back to the production key when unset. Auth is HTTP Basic with the token as username and the literal `token` as password; the token is fed to `curl` only via a `-K` config file (process substitution), never on argv.
79
80
  - Fetch logs (VPN required; respect `vpnServices.graylog`): call `~/.claude/lib/fetch-graylog.sh --trx <id>` (or `--conv <id>` for conversation ids) once per extracted id. The fetcher emits a normalized JSON object of matching log messages.
80
- - Store as `state.graylogContext = { idType, id, messages[], fetchedAt }` (or an array keyed per id when several were extracted).
81
+ - Store as `state.graylogContext = { idType, id, environment, searchedEnvironments[], messages[], fetchedAt }` (or an array keyed per id when several were extracted).
81
82
  - Phase 1 Analysis prepends `state.graylogContext` to the agent prompt under the **Referenced External Sources** section as **diagnostic context, advisory only** (never contradicts code; no task is generated from it, no Phase 4 gate).
82
83
  - Soft-fail (non-blocking, mirrors Crashlytics): unreachable host / VPN down / network timeout → the fetcher emits a normalized empty object and exits `0`; mark `state.graylogContext = { skipped: "vpn-unreachable" }` and continue. Only a genuine auth rejection (4xx on a reachable host, fetcher exit `3`) is logged as `skipped` with the auth reason. A log fetch never blocks the run.
83
84
 
@@ -127,9 +127,14 @@ The shell driver auto-delegates to `~/.claude/scripts/keychain.py` on macOS / Li
127
127
  | `figma` | Figma | `${USER}_Figma_Access_Token` | PAT |
128
128
  | `figma_mcp` | Figma MCP | `${USER}_Figma_Mcp_Access_Token` | OAuth |
129
129
  | `fortify` | Fortify | `${USER}_Fortify_Access_Token` | API Token |
130
- | `graylog` | Graylog | `${USER}_Graylog_Access_Token` | API Token |
131
- | `firebase` | Firebase | `${USER}_Firebase_Access_Json` | JSON (base64) |
130
+ | `graylog` | Graylog (prod) | `${USER}_Graylog_Access_Token` | API Token |
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). One key per Firebase project; extras are named `..._Json_<projectId>` and listed in `global.firebase.accounts[]` |
132
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 |
133
138
  | - | Git Identity | Stored in preferences JSON | Not Keychain |
134
139
 
135
140
  **Key name mapping lives in preferences:**
@@ -147,6 +152,7 @@ The shell driver auto-delegates to `~/.claude/scripts/keychain.py` on macOS / Li
147
152
  "figma_mcp": "${USER}_Figma_Mcp_Access_Token",
148
153
  "fortify": "${USER}_Fortify_Access_Token",
149
154
  "graylog": "${USER}_Graylog_Access_Token",
155
+ "graylog_test": null,
150
156
  "firebase": "${USER}_Firebase_Access_Json",
151
157
  "jenkins": "${USER}_Jenkins_Access_Token"
152
158
  }
@@ -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:
@@ -12,6 +12,7 @@
12
12
  "figma_mcp": null,
13
13
  "fortify": null,
14
14
  "graylog": null,
15
+ "graylog_test": null,
15
16
  "usage_ingest": null,
16
17
  "firebase": null,
17
18
  "jenkins": null
@@ -114,7 +114,12 @@
114
114
  "properties": {
115
115
  "status": { "type": "string", "enum": ["pending", "ok", "degraded", "skipped"] },
116
116
  "totalResults": { "type": "integer", "minimum": 0 },
117
- "degradeReason": { "type": ["string", "null"] }
117
+ "degradeReason": { "type": ["string", "null"] },
118
+ "environment": {
119
+ "type": ["string", "null"],
120
+ "enum": ["prod", "test", "override", null],
121
+ "description": "v15.15+ - which Graylog instance answered. Test and production are separate instances; a production complaint corroborated only by test logs is insufficient-evidence, not a confirmed bff fault, so the verdict has to know where the lines came from."
122
+ }
118
123
  }
119
124
  },
120
125
  "repoEvidence": {
@@ -143,6 +143,10 @@
143
143
  "graylog": {
144
144
  "type": ["string", "null"]
145
145
  },
146
+ "graylog_test": {
147
+ "type": ["string", "null"],
148
+ "description": "v15.15+ - Keychain key for the TEST Graylog instance. Null or absent falls back to the `graylog` key, which is correct for deployments that share one token across both."
149
+ },
146
150
  "supabase_access": {
147
151
  "type": ["string", "null"],
148
152
  "description": "Supabase personal access token (Management API). Used by personal-site tooling; not consumed by pipeline phases."
@@ -574,7 +578,11 @@
574
578
  },
575
579
  "graylog": {
576
580
  "type": "string",
577
- "description": "Graylog host without scheme, e.g. logs.example.com. Used by the Graylog log-fetch adapter for direct REST API calls. Resolves {GRAYLOG_HOST}."
581
+ "description": "Graylog PRODUCTION host without scheme, e.g. logs.example.com. Used by the Graylog log-fetch adapter for direct REST API calls. Resolves {GRAYLOG_HOST}."
582
+ },
583
+ "graylogTest": {
584
+ "type": "string",
585
+ "description": "v15.15+ - Graylog TEST host without scheme. Optional; leaving it unset means fetch-graylog.sh only ever searches production. Test and production are separate instances, so a trx id minted by a tester does not exist in prod and searching prod alone answers 'no logs' for a complaint that is fully logged one host over. Resolves {GRAYLOG_TEST_HOST}."
578
586
  },
579
587
  "corpDomain": {
580
588
  "type": "string",
@@ -582,6 +590,36 @@
582
590
  }
583
591
  }
584
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
+ },
585
623
  "fortify": {
586
624
  "type": "object",
587
625
  "additionalProperties": false,
@@ -26,6 +26,7 @@ Validate contracts. Each emits `══ <name> smoke: N passed, M failed ══`
26
26
  - `smoke-handoff-contract.sh` - phase-boundary structured handoff + handoff-first resume (v10.8.0)
27
27
  - `smoke-update-check.sh` - Phase 0 Step 0.6 update-check + required-floor contract (v10.9.0, floor v15.14.0)
28
28
  - `smoke-context-links.sh` - context-link-extractor classification contract, all types (v15.14.0)
29
+ - `smoke-graylog-environments.sh` - Graylog prod / test instance resolution + auto fallback (v15.15.0)
29
30
 
30
31
  ### Schema + state
31
32
  - `smoke-schema-validation.sh` - all JSON schemas validate