@mmerterden/multi-agent-pipeline 15.14.0 → 15.15.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,20 @@ Internal file-layout changes that don't affect the slash-command surface are sti
16
16
 
17
17
  ## [Unreleased]
18
18
 
19
+ ## [15.15.0] - 2026-08-22
20
+
21
+ ### Added
22
+ - **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.
23
+ - **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.
24
+ - `/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.
25
+ - **`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.
26
+
27
+ ### Fixed
28
+ - 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.
29
+
30
+ ### Changed
31
+ - `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.
32
+
19
33
  ## [15.14.0] - 2026-08-22
20
34
 
21
35
  ### 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.15.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)
@@ -107,6 +107,7 @@ These are the RECOMMENDED key names. When creating NEW keys, use these. But exis
107
107
  | `figma_mcp` | `${USER}_Figma_Mcp_Access_Token` | `figma` + `mcp` |
108
108
  | `fortify` | `${USER}_Fortify_Access_Token` | `fortify` |
109
109
  | `graylog` | `${USER}_Graylog_Access_Token` | `graylog` |
110
+ | `graylog_test` | `${USER}_Graylog_Test_Access_Token` | `graylogTest` |
110
111
  | `firebase` | `${USER}_Firebase_Access_Json` | `firebase` (any variant: `sa`, `service`, `account`, `access`, `json`) |
111
112
  | `jenkins` | `${USER}_Jenkins_Access_Token` | `jenkins` |
112
113
  | `appstore_connect_key_id` | `${USER}_AppStoreConnect_Key_Id` | (`appstore` or `asc` or `app_store`) + (`key` or `keyid`) |
@@ -288,7 +289,7 @@ The Step 2 discovery summary is never the end of setup. Immediately after showin
288
289
  Loop rules:
289
290
 
290
291
  - **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.
292
+ - **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
293
  - **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
294
  - **`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
295
  - The loop ends only when every missing service has been asked exactly once; then continue to Step 4 (Verify all).
@@ -440,7 +441,9 @@ Service host - needed to build API URLs for this token.
440
441
  {Service} host (e.g. jira.example.com): ___
441
442
  ```
442
443
 
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):
444
+ 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
+
446
+ For Jira specifically, also ask for the default project key (the token is useless without one for new-issue creation):
444
447
 
445
448
  ```
446
449
  Default Jira project key (e.g. PROJ): ___
@@ -476,6 +479,7 @@ Resulting shape:
476
479
  "bitbucket": "bitbucket.example.com",
477
480
  "fortify": "ssc.example.com",
478
481
  "graylog": "graylog.example.com",
482
+ "graylogTest": "graylog-test.example.com",
479
483
  "corpDomain": "example.com"
480
484
  },
481
485
  "defaultJiraKey": "PROJ"
@@ -483,9 +487,18 @@ Resulting shape:
483
487
  }
484
488
  ```
485
489
 
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.
490
+ 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.
491
+
492
+ 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.
493
+
494
+ 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:
495
+
496
+ ```
497
+ Graylog test host (optional - Enter to skip): ___
498
+ Does the test instance use a different token? [y/N]
499
+ ```
487
500
 
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.
501
+ 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
502
 
490
503
  **Re-run / update**: `/multi-agent:setup hosts` re-opens the prompt to edit values without touching tokens.
491
504
 
@@ -706,7 +719,8 @@ Standard key names (used when creating NEW tokens). Discovery may find existing
706
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)". |
707
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)". |
708
721
  | `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. |
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 |
710
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. |
711
725
  | `jenkins` | `${USER}_Jenkins_Access_Token` | API Token | Jenkins -> User -> Configure -> API Token |
712
726
  | - | Git Identities | JSON (preferences) | Stored in `$HOME/.claude/multi-agent-preferences.json` - not Keychain |
@@ -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 "",
@@ -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,7 +127,8 @@ 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 |
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` |
131
132
  | `firebase` | Firebase | `${USER}_Firebase_Access_Json` | JSON (base64) |
132
133
  | `jenkins` | Jenkins CI | `${USER}_Jenkins_Access_Token` | API Token |
133
134
  | - | Git Identity | Stored in preferences JSON | Not Keychain |
@@ -147,6 +148,7 @@ The shell driver auto-delegates to `~/.claude/scripts/keychain.py` on macOS / Li
147
148
  "figma_mcp": "${USER}_Figma_Mcp_Access_Token",
148
149
  "fortify": "${USER}_Fortify_Access_Token",
149
150
  "graylog": "${USER}_Graylog_Access_Token",
151
+ "graylog_test": null,
150
152
  "firebase": "${USER}_Firebase_Access_Json",
151
153
  "jenkins": "${USER}_Jenkins_Access_Token"
152
154
  }
@@ -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",
@@ -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