@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 +14 -0
- package/package.json +1 -1
- package/pipeline/commands/multi-agent/complaint-analysis/SKILL.md +5 -1
- package/pipeline/commands/multi-agent/setup/SKILL.md +19 -5
- package/pipeline/lib/fetch-graylog.sh +166 -66
- package/pipeline/multi-agent-refs/features/url-enrichment.md +4 -3
- package/pipeline/multi-agent-refs/keychain.md +3 -1
- package/pipeline/preferences-template.json +1 -0
- package/pipeline/schemas/complaint-analysis-spec.schema.json +6 -1
- package/pipeline/schemas/prefs.schema.json +9 -1
- package/pipeline/scripts/README.md +1 -0
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.
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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)
|
|
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
|
|
17
|
-
# prefs.global.keychainMapping.graylog
|
|
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
|
-
#
|
|
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>", "
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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
|
-
|
|
78
|
-
|
|
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
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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":"'"$
|
|
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
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
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
|
-
|
|
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
|
-
|
|
158
|
-
# Usage:
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
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' "$
|
|
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
|
-
#
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
"
|
|
177
|
-
|
|
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
|
-
|
|
180
|
-
|
|
181
|
-
|
|
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
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
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"
|
|
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.
|
|
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
|
-
-
|
|
78
|
-
-
|
|
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
|
|
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
|
}
|
|
@@ -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
|