@mmerterden/multi-agent-pipeline 12.1.1 → 12.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -14,6 +14,14 @@ Internal file-layout changes that don't affect the slash-command surface are sti
14
14
 
15
15
  ---
16
16
 
17
+ ## [12.2.0] - 2026-07-21
18
+
19
+ ### Added
20
+ - **Graylog log-fetch integration** — when a task's ticket/issue carries a transaction id (`trx`/`trxId`/`transaction id`) and/or a conversation id (`conversationId`/`convId`/`X-conversationId`), the pipeline fetches the matching Graylog log messages and injects them as advisory diagnostic context into Phase 1 analysis. Mirrors the Crashlytics analysis-context adapter (advisory only, no Phase-4 gate).
21
+ - New `lib/fetch-graylog.sh` adapter (mirrors the Fortify hosted-service shape): host from `prefs.global.hosts.graylog` (+ `GRAYLOG_HOST_OVERRIDE`), token via `prefs.global.keychainMapping.graylog` → `${USER}_Graylog_Access_Token` through the credential-store resolver. Graylog PAT auth is HTTP Basic `<token>:token` delivered only via a `curl -K` process-substitution config (never on argv). Universal/relative search by full-text OR of the ids, range + limit configurable via env.
22
+ - **Non-blocking by design:** any network/VPN failure degrades to an empty normalized result and exit 0 — a log fetch never blocks a run. Exit codes: 0 ok/degraded, 2 missing-token, 3 genuine auth rejection, 4 usage, 6 host-not-configured.
23
+ - Config surface: `graylog` added to `keychainMapping` + `hosts` in `schemas/prefs.schema.json` and `preferences-template.json`; keychain doc row (non-critical service). Extraction branch added to `lib/context-link-extractor.sh`. Phase-0 deep-fetch step (`state.graylogContext`) + Phase-1 dispatch row + external-context-injection dispatch row. `multi-agent:setup` prompts for the Graylog host (like the Jira host) and discovers the token key. Offline smoke coverage in `scripts/smoke-fetchers-offline.sh`.
24
+
17
25
  ## [12.1.1] - 2026-07-21
18
26
 
19
27
  ### Changed
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mmerterden/multi-agent-pipeline",
3
- "version": "12.1.1",
3
+ "version": "12.2.0",
4
4
  "description": "8-phase AI development pipeline with full orchestration on Claude Code and Copilot 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",
@@ -99,6 +99,7 @@ These are the RECOMMENDED key names. When creating NEW keys, use these. But exis
99
99
 
100
100
  > Multi-agent setup onboards **both** Figma tokens because the pipeline's 3-tier Figma access chain (see `pipeline/rules/figma-pipeline.md`) uses them in priority order: Tier 1 = MCP (`figma_mcp`), Tier 2 = REST PAT (`figma_pat`), Tier 3 = user screenshot fallback. Missing either token forces a downshift; missing both leaves Tier 3 as the only path.
101
101
  | `fortify` | `${USER}_Fortify_Access_Token` | `fortify` |
102
+ | `graylog` | `${USER}_Graylog_Access_Token` | `graylog` |
102
103
  | `firebase` | `${USER}_Firebase_Access_Json` | `firebase` (any variant: `sa`, `service`, `account`, `access`, `json`) |
103
104
  | `jenkins` | `${USER}_Jenkins_Access_Token` | `jenkins` |
104
105
 
@@ -180,6 +181,7 @@ Save the resolved mapping to preferences:
180
181
  "figma_pat": "${USER}_Figma_Access_Token",
181
182
  "figma_mcp": "${USER}_Figma_Mcp_Access_Token",
182
183
  "fortify": null,
184
+ "graylog": null,
183
185
  "firebase": "${USER}_Firebase_Access_Json",
184
186
  "jenkins": null
185
187
  }
@@ -331,7 +333,7 @@ Update `prefs.global.keychainMapping.{service}` with the key name.
331
333
  Update `identity.servicePatMap.{service}` with the key name.
332
334
  Update `serviceStatus.{service} = { ok: true, checkedAt: <now> }`.
333
335
 
334
- **→ Next: if `{service}` is `jira` / `confluence` / `bitbucket` / `fortify` AND the corresponding
336
+ **→ Next: if `{service}` is `jira` / `confluence` / `bitbucket` / `fortify` / `graylog` AND the corresponding
335
337
  `prefs.global.hosts.{service}` is unset, run Step 3.5 (Host Prompt) inline before
336
338
  the Auto-routing rule below. required - the pipeline cannot build API URLs without
337
339
  the host. Firebase is skipped here (host is always `console.firebase.google.com` /
@@ -352,7 +354,7 @@ This builds `platformIdentityRouting` incrementally - no separate Step 7 neede
352
354
 
353
355
  ### Step 3.5 - Host Prompt (embedded in Token Save Flow)
354
356
 
355
- **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) 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.
357
+ **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.
356
358
 
357
359
  Right after Step D (identity mapping confirmed), ask:
358
360
 
@@ -380,6 +382,7 @@ Resulting shape:
380
382
  "confluence": "confluence.example.com",
381
383
  "bitbucket": "bitbucket.example.com",
382
384
  "fortify": "ssc.example.com",
385
+ "graylog": "graylog.example.com",
383
386
  "corpDomain": "example.com"
384
387
  },
385
388
  "defaultJiraKey": "PROJ"
@@ -387,7 +390,9 @@ Resulting shape:
387
390
  }
388
391
  ```
389
392
 
390
- Placeholders `{JIRA_HOST}`, `{CONFLUENCE_HOST}`, `{BITBUCKET_HOST}`, `{FORTIFY_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.
393
+ 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.
394
+
395
+ 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.
391
396
 
392
397
  **Re-run / update**: `/multi-agent:setup hosts` re-opens the prompt to edit values without touching tokens.
393
398
 
@@ -706,6 +711,7 @@ Standard key names (used when creating NEW tokens). Discovery may find existing
706
711
  | `figma_pat` | `${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
712
  | `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
713
  | `fortify` | `${USER}_Fortify_Access_Token` | API Token | Fortify SSC -> Token Management (VPN required) |
714
+ | `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. |
709
715
  | `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. |
710
716
  | `jenkins` | `${USER}_Jenkins_Access_Token` | API Token | Jenkins -> User -> Configure -> API Token |
711
717
  | - | Git Identities | JSON (preferences) | Stored in `$HOME/.claude/multi-agent-preferences.json` - not Keychain |
@@ -11,6 +11,12 @@
11
11
  # - confluence : Confluence pages (host pattern + display/spaces/pageId)
12
12
  # - crashlytics : Firebase Crashlytics issue/session URLs
13
13
  # - fortify : Fortify SSC findings URLs
14
+ # - graylog : Graylog trx-id / conversation-id references. Unlike the
15
+ # others this is matched from free text (labels like "trx",
16
+ # "trxid", "transaction id", "conversationId", "convId",
17
+ # "X-conversationId" followed by an id) rather than a URL, so
18
+ # its entries carry "url": null. Advisory diagnostic context;
19
+ # pattern-based + tolerant, a project may refine the labels.
14
20
  # - figma : Figma design / make / FigJam / slides URLs
15
21
  # - generic-doc : http(s) URLs that didn't match a more specific type but
16
22
  # look like documentation (Notion, Google Docs, GitHub
@@ -26,6 +32,7 @@
26
32
  # { "type": "swagger", "url": "...", "metadata": { "format": "json|yaml|unknown" } },
27
33
  # { "type": "crashlytics","url": "...", "metadata": { "projectId": "...", "platform": "ios|android", "issueId": "...", "sessionId": "...|null" } },
28
34
  # { "type": "fortify", "url": "...", "metadata": { "host": "...", "projectId": "...|null" } },
35
+ # { "type": "graylog", "url": null, "metadata": { "idType": "trx|conversation", "id": "...", "label": "..." } },
29
36
  # { "type": "figma", "url": "...", "metadata": { "kind": "design|make|board|slides", "fileKey": "...", "nodeId": "...|null" } },
30
37
  # ...
31
38
  # ]
@@ -188,5 +195,38 @@ for url in RAW_URLS:
188
195
  seen.add(key)
189
196
  out.append({"type": typ, "url": url, "metadata": meta})
190
197
 
198
+ # --- Graylog trx / conversation ids (label-based, not URL) -----------------
199
+ # Recognise free-text references like "trxId: ABC123", "transaction id = ...",
200
+ # "conversationId ...", "X-conversationId: ...". Pattern-based + tolerant - a
201
+ # project can refine the label set. Longer/more-specific labels lead the
202
+ # alternation so "trxid" is not shadowed by "trx". The id is alphanumeric plus
203
+ # ._- and long enough (>= 6 chars) to avoid catching stray words.
204
+ GRAYLOG_RE = re.compile(
205
+ r"(?P<label>"
206
+ r"x[\s_-]*conversation[\s_-]*id"
207
+ r"|conversation[\s_-]*id"
208
+ r"|conv[\s_-]*id"
209
+ r"|transaction[\s_-]*id"
210
+ r"|trx[\s_-]*id"
211
+ r"|trx"
212
+ r")"
213
+ r"\s*[:=#]?\s*"
214
+ r"[\"']?(?P<id>[A-Za-z0-9][A-Za-z0-9._-]{5,})",
215
+ re.IGNORECASE,
216
+ )
217
+ for m in GRAYLOG_RE.finditer(text):
218
+ gid = m.group("id").rstrip(".,;:!?)")
219
+ label = m.group("label")
220
+ id_type = "conversation" if "conv" in label.lower() else "trx"
221
+ key = ("graylog", gid)
222
+ if not gid or key in seen:
223
+ continue
224
+ seen.add(key)
225
+ out.append({
226
+ "type": "graylog",
227
+ "url": None,
228
+ "metadata": {"idType": id_type, "id": gid, "label": label},
229
+ })
230
+
191
231
  print(json.dumps(out))
192
232
  PY
@@ -0,0 +1,233 @@
1
+ #!/bin/bash
2
+ #
3
+ # fetch-graylog.sh
4
+ # Query a Graylog instance for the log messages tied to a transaction or
5
+ # conversation id and emit a normalized JSON view the pipeline can attach as
6
+ # analysis context. A log fetch must never block a run: on any network failure
7
+ # (VPN down, host unreachable) the fetcher degrades to an empty result and
8
+ # exits 0.
9
+ #
10
+ # Input forms (at least one id is required):
11
+ # ./fetch-graylog.sh --trx <transaction-id>
12
+ # ./fetch-graylog.sh --conv <conversation-id>
13
+ # ./fetch-graylog.sh --trx <id> --conv <id>
14
+ #
15
+ # 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
18
+ #
19
+ # Optional env:
20
+ # GRAYLOG_TIMEOUT_SECONDS default 25
21
+ # GRAYLOG_HOST_OVERRIDE forces the host (used by tests and CI dry-runs)
22
+ # GRAYLOG_RANGE_SECONDS relative search window, default 86400 (24h)
23
+ # GRAYLOG_LIMIT max messages to return, default 200
24
+ #
25
+ # Auth: Graylog personal access tokens authenticate as HTTP Basic with the
26
+ # token as the username and the literal string "token" as the password. The
27
+ # credential travels through a curl config via process substitution so it
28
+ # never appears in argv (argv is visible to ps).
29
+ #
30
+ # Output (stdout, single JSON object):
31
+ # {
32
+ # "fetchedAt": "<ISO8601>",
33
+ # "source": { "host": "<host>", "transactionId": "..."|null,
34
+ # "conversationId": "..."|null, "query": "..." },
35
+ # "totalResults": <n>,
36
+ # "messages": [
37
+ # { "timestamp": "...", "source": "...", "level": <n>|null,
38
+ # "message": "...", "fields": { ... } }
39
+ # ],
40
+ # "degraded": <bool>, "degradeReason": "..."|null
41
+ # }
42
+ #
43
+ # Exit codes:
44
+ # 0 success, or degraded-empty on a network failure (never blocks a run)
45
+ # 2 missing/expired credential (orchestrator handles the Save Flow)
46
+ # 3 auth rejected on a reachable host (4xx)
47
+ # 4 bad usage
48
+ # 6 host not configured (prefs.global.hosts.graylog is empty)
49
+
50
+ set -eu
51
+
52
+ TRX_ID=""
53
+ CONV_ID=""
54
+ TIMEOUT="${GRAYLOG_TIMEOUT_SECONDS:-25}"
55
+ RANGE="${GRAYLOG_RANGE_SECONDS:-86400}"
56
+ LIMIT="${GRAYLOG_LIMIT:-200}"
57
+
58
+ while [ $# -gt 0 ]; do
59
+ case "$1" in
60
+ --trx) TRX_ID="$2"; shift 2 ;;
61
+ --conv) CONV_ID="$2"; shift 2 ;;
62
+ -h|--help)
63
+ echo "usage: $0 --trx <transaction-id> | --conv <conversation-id>" >&2
64
+ exit 4 ;;
65
+ *)
66
+ echo "ERR: unexpected arg $1" >&2; exit 4 ;;
67
+ esac
68
+ done
69
+
70
+ # Host comes from prefs (private config); skill templates only show {GRAYLOG_HOST}.
71
+ 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
76
+ try:
77
+ p = json.load(open('$PREFS'))
78
+ print(p.get('global', {}).get('hosts', {}).get('graylog') or '')
79
+ except Exception:
80
+ 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
88
+
89
+ if [ -z "$TRX_ID" ] && [ -z "$CONV_ID" ]; then
90
+ echo "ERR: no id (pass --trx <transaction-id> and/or --conv <conversation-id>)" >&2
91
+ exit 4
92
+ fi
93
+
94
+ # Build the Graylog query from whichever ids were supplied.
95
+ QUERY=""
96
+ if [ -n "$TRX_ID" ]; then
97
+ QUERY="transactionId:\"$TRX_ID\""
98
+ fi
99
+ if [ -n "$CONV_ID" ]; then
100
+ if [ -n "$QUERY" ]; then
101
+ QUERY="$QUERY OR conversationId:\"$CONV_ID\""
102
+ else
103
+ QUERY="conversationId:\"$CONV_ID\""
104
+ fi
105
+ fi
106
+
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
+ # Locate the credential helper via the resolver so Copilot-only installs work.
122
+ # shellcheck disable=SC1090,SC1091
123
+ . "$HOME/.claude/lib/credential-store-resolver.sh" 2>/dev/null \
124
+ || . "$HOME/.copilot/lib/credential-store-resolver.sh" 2>/dev/null \
125
+ || { printf '%s\n' '{"status":"blocked","reason":"missing-credential-helper","service":"graylog","expected_key":"'"$TOKEN_KEY"'"}' >&2; exit 2; }
126
+
127
+ TOKEN=$("$CRED_STORE" get "$TOKEN_KEY" 2>/dev/null || true)
128
+ if [ -z "$TOKEN" ]; then
129
+ printf '%s\n' '{"status":"blocked","reason":"missing-token","service":"graylog","expected_key":"'"$TOKEN_KEY"'"}' >&2
130
+ exit 2
131
+ fi
132
+
133
+ API_BASE="https://$HOST/api"
134
+
135
+ graylog_get() {
136
+ # Usage: graylog_get <path-and-query-relative-to-API_BASE>
137
+ # Prints "<body>\n<http_code>"; the caller splits the last line off.
138
+ local path="$1"
139
+ local url="$API_BASE/$path"
140
+ # PAT basic auth: username=<token>, password=literal "token". The credential
141
+ # goes through a curl config via process substitution so it never lands in
142
+ # argv (argv is visible to ps).
143
+ curl -sS --max-time "$TIMEOUT" --connect-timeout 5 -w "\n%{http_code}" \
144
+ -K <(printf 'user = "%s:token"\n' "$TOKEN") \
145
+ -H "Accept: application/json" \
146
+ -H "X-Requested-By: multi-agent-pipeline" \
147
+ "$url" 2>/dev/null || true
148
+ }
149
+
150
+ # Graylog relative universal search.
151
+ ENC_QUERY=$(QUERY_IN="$QUERY" python3 -c "
152
+ import os, urllib.parse as up
153
+ print(up.quote(os.environ['QUERY_IN']))
154
+ ")
155
+ SEARCH_PATH="search/universal/relative?query=$ENC_QUERY&range=$RANGE&limit=$LIMIT&sort=timestamp:desc"
156
+
157
+ RESP=$(graylog_get "$SEARCH_PATH")
158
+ HTTP=$(printf '%s' "$RESP" | tail -n1)
159
+ BODY=$(printf '%s' "$RESP" | sed '$d')
160
+
161
+ # A genuine auth rejection on a reachable host is the only hard failure.
162
+ case "$HTTP" in
163
+ 401|403)
164
+ echo "ERR: Graylog auth rejected (HTTP $HTTP)" >&2
165
+ exit 3 ;;
166
+ esac
167
+
168
+ # Any other non-200 (empty code on connection failure, 5xx, timeout) degrades
169
+ # to an empty result so a log fetch never blocks a run.
170
+ DEGRADED="false"
171
+ DEGRADE_REASON=""
172
+ if [ "$HTTP" != "200" ]; then
173
+ DEGRADED="true"
174
+ DEGRADE_REASON="graylog-unreachable"
175
+ BODY=""
176
+ fi
177
+
178
+ SRC_HOST="$HOST" SRC_TRX="$TRX_ID" SRC_CONV="$CONV_ID" SRC_QUERY="$QUERY" \
179
+ DEGRADED_IN="$DEGRADED" DEGRADE_REASON_IN="$DEGRADE_REASON" \
180
+ BODY_IN="$BODY" LIMIT_IN="$LIMIT" \
181
+ python3 - <<'PY'
182
+ import json, os, datetime
183
+
184
+ def safe_load(s):
185
+ try:
186
+ return json.loads(s) if s else None
187
+ except Exception:
188
+ return None
189
+
190
+ body = safe_load(os.environ.get("BODY_IN") or "") or {}
191
+ raw = body.get("messages") or []
192
+
193
+ try:
194
+ limit = int(os.environ.get("LIMIT_IN") or "200")
195
+ except Exception:
196
+ limit = 200
197
+
198
+ messages = []
199
+ for item in raw[:limit]:
200
+ m = item.get("message") if isinstance(item, dict) else None
201
+ if not isinstance(m, dict):
202
+ continue
203
+ reserved = ("timestamp", "source", "level", "message")
204
+ fields = {k: v for k, v in m.items() if k not in reserved}
205
+ messages.append({
206
+ "timestamp": m.get("timestamp") or "",
207
+ "source": m.get("source") or "",
208
+ "level": m.get("level") if isinstance(m.get("level"), (int, float)) else None,
209
+ "message": (m.get("message") or "")[:2000],
210
+ "fields": fields,
211
+ })
212
+
213
+ trx = os.environ.get("SRC_TRX") or None
214
+ conv = os.environ.get("SRC_CONV") or None
215
+ total = body.get("total_results")
216
+ if not isinstance(total, int):
217
+ total = len(messages)
218
+
219
+ result = {
220
+ "fetchedAt": datetime.datetime.utcnow().isoformat() + "Z",
221
+ "source": {
222
+ "host": os.environ["SRC_HOST"],
223
+ "transactionId": trx,
224
+ "conversationId": conv,
225
+ "query": os.environ.get("SRC_QUERY") or "",
226
+ },
227
+ "totalResults": total,
228
+ "messages": messages,
229
+ "degraded": os.environ.get("DEGRADED_IN") == "true",
230
+ "degradeReason": (os.environ.get("DEGRADE_REASON_IN") or None),
231
+ }
232
+ print(json.dumps(result, ensure_ascii=False))
233
+ PY
@@ -22,6 +22,7 @@ One fetcher per type. Each fetcher emits a normalized JSON view that the analysi
22
22
  |---|---|---|
23
23
  | `crashlytics` | `~/.claude/lib/fetch-crashlytics.sh <url>` (already invoked in Phase 0 Step 1b.1 for the legacy `state.crashContext` field; Phase 1 reads that field directly) | `state.crashContext` |
24
24
  | `fortify` | `~/.claude/lib/fetch-fortify.sh <url>` (already invoked in Phase 0 Step 1b.2 for the legacy `state.fortifyFinding` field; Phase 1 reads that field, Phase 4 reads the full payload for the security gate) | `state.fortifyFinding` |
25
+ | `graylog` | `~/.claude/lib/fetch-graylog.sh --trx <id>` / `--conv <id>` (already invoked in Phase 0 Step 1b.3 for the `state.graylogContext` field; Phase 1 reads that field directly). Diagnostic logs, **advisory only** - no Phase 4 gate, no generated task | `state.graylogContext` |
25
26
  | `swagger` | `~/.claude/lib/fetch-swagger.sh <url>` → endpoints[], request/response examples | `state.fetchedContext.swagger[]` |
26
27
  | `confluence` | `~/.claude/lib/fetch-confluence.sh <url>` → page body, code blocks, extracted API contracts | `state.fetchedContext.confluence[]` |
27
28
  | `figma` | no standalone fetcher - resolve via the Figma 3-tier chain (Tier 1 MCP tools, Tier 2 `~/.claude/lib/figma-screenshot.sh` + REST) when the task is a component; otherwise advisory only | `state.fetchedContext.figma[]` |
@@ -36,6 +37,8 @@ One fetcher per type. Each fetcher emits a normalized JSON view that the analysi
36
37
 
37
38
  Failures are never fatal at Phase 1 - the agent still runs analysis, just without the referenced source.
38
39
 
40
+ **Graylog specifics.** `fetch-graylog.sh` degrades to empty on any network/VPN failure: it emits a normalized empty object and exits `0`, so an unreachable host reads as `{status:"skipped", reason:"vpn-unreachable"}` and never blocks. Only a genuine auth rejection on a reachable host exits `3` (marked `failed`, still non-fatal here). `state.graylogContext` is prepended to the analysis prompt inside the **Referenced External Sources** section as diagnostic context that is **advisory only** - the agent may use it to orient on a reported error, but code remains ground truth, and there is no Phase 4 review gate for logs.
41
+
39
42
  ## Prompt injection shape
40
43
 
41
44
  Added immediately after the knowledge-injection block, before codebase exploration:
@@ -17,7 +17,7 @@ When a token resolves but the service rejects it (401 / 403), do not silently sk
17
17
 
18
18
  - **Regenerate** - the existing token is dead; open Save Flow Step B to replace `<KEY_NAME>` in place (same key name, clipboard path), then retry the operation.
19
19
  - **Use a different token** - map a new or existing Keychain entry for this service (Save Flow Step B + identity binding), then retry.
20
- - **Skip and continue** - drop this token's contribution. Non-critical services (Confluence enrichment, Fortify scan, Firebase events, Figma fallback tiers) continue with reduced evidence and a one-line warning. A token that is structurally required for the input (e.g. the Jira PAT for a Jira-ID input) halts instead, since the run cannot proceed without it.
20
+ - **Skip and continue** - drop this token's contribution. Non-critical services (Confluence enrichment, Fortify scan, Graylog log fetch, Firebase events, Figma fallback tiers) continue with reduced evidence and a one-line warning. A token that is structurally required for the input (e.g. the Jira PAT for a Jira-ID input) halts instead, since the run cannot proceed without it.
21
21
 
22
22
  The question asks a *choice*; the replacement value still flows through the clipboard, never chat. `smoke-no-token-prompt.sh` greps for value-prompts (`enter token`, `paste token`, `API key:`) - decision labels like `Regenerate` / `Skip and continue` do not trip it.
23
23
 
@@ -64,6 +64,7 @@ The shell driver auto-delegates to `~/.claude/scripts/keychain.py` on macOS / Li
64
64
  | `figma` | Figma | `${USER}_Figma_Access_Token` | PAT |
65
65
  | `figma_mcp` | Figma MCP | `${USER}_Figma_Mcp_Access_Token` | OAuth |
66
66
  | `fortify` | Fortify | `${USER}_Fortify_Access_Token` | API Token |
67
+ | `graylog` | Graylog | `${USER}_Graylog_Access_Token` | API Token |
67
68
  | `firebase` | Firebase | `${USER}_Firebase_Access_Json` | JSON (base64) |
68
69
  | `jenkins` | Jenkins CI | `${USER}_Jenkins_Access_Token` | API Token |
69
70
  | - | Git Identity | Stored in preferences JSON | Not Keychain |
@@ -82,6 +83,7 @@ The shell driver auto-delegates to `~/.claude/scripts/keychain.py` on macOS / Li
82
83
  "figma": "${USER}_Figma_Access_Token",
83
84
  "figma_mcp": "${USER}_Figma_Mcp_Access_Token",
84
85
  "fortify": "${USER}_Fortify_Access_Token",
86
+ "graylog": "${USER}_Graylog_Access_Token",
85
87
  "firebase": "${USER}_Firebase_Access_Json",
86
88
  "jenkins": "${USER}_Jenkins_Access_Token"
87
89
  }
@@ -53,7 +53,7 @@ Then STOP Phase 0 - do not proceed with a broken token state. This prevents th
53
53
 
54
54
  **Key design**: Every field is an **array of all historical values** (most recent first) → "recently used" list.
55
55
 
56
- **Token resolution rule**: ALWAYS check `prefs.global.keychainMapping.<service_id>` first for the key name - this holds the user's actual Keychain key name (which may differ from the standard convention). Fall back to standard key name ONLY when setup has never run (mapping absent). The standard fallback exists as a safety net, not as the primary path. Service IDs: `jira`, `bitbucket_token`, `bitbucket_user`, `github`, `confluence`, `figma`, `figma_mcp`, `fortify`, `firebase`, `jenkins`.
56
+ **Token resolution rule**: ALWAYS check `prefs.global.keychainMapping.<service_id>` first for the key name - this holds the user's actual Keychain key name (which may differ from the standard convention). Fall back to standard key name ONLY when setup has never run (mapping absent). The standard fallback exists as a safety net, not as the primary path. Service IDs: `jira`, `bitbucket_token`, `bitbucket_user`, `github`, `confluence`, `figma`, `figma_mcp`, `fortify`, `firebase`, `jenkins`, `graylog`.
57
57
 
58
58
  **Jira project key resolution** (first match wins):
59
59
 
@@ -196,7 +196,7 @@ Sequential prompts (standard UX pattern with Recent suggestion): Project Key →
196
196
 
197
197
  **Token pre-check** (after parsing): Jira input → resolve key via `prefs.global.keychainMapping.jira`, verify token with a lightweight API call (e.g. `GET /myself`). GitHub input → verify `gh auth status`. On failure (missing key, 401, 403) → run the **Token Save Flow** from `setup.md` inline. This is the same clipboard-based flow used during setup - token never appears in terminal. If user skips and the token is critical for the input type (e.g. Jira token for Jira input), halt Phase 0.
198
198
 
199
- **VPN connectivity check**: Test VPN-dependent services (Jira, Bitbucket, Confluence, Fortify) with `curl --connect-timeout 3`. If unreachable, warn and offer to continue. Fallback: Jira → manual input, Bitbucket → `git push` only, Confluence → skip Phase 7, Fortify → skip scan. Cache in `agent-state.json` → `"vpnServices": {"jira": true, ...}`.
199
+ **VPN connectivity check**: Test VPN-dependent services (Jira, Bitbucket, Confluence, Fortify, Graylog) with `curl --connect-timeout 3`. If unreachable, warn and offer to continue. Fallback: Jira → manual input, Bitbucket → `git push` only, Confluence → skip Phase 7, Fortify → skip scan, Graylog → skip log fetch (advisory only, never blocks). Cache in `agent-state.json` → `"vpnServices": {"jira": true, ...}`.
200
200
 
201
201
  #### Step 1b - URL Enrichment (catalogue + targeted deep fetches)
202
202
 
@@ -225,6 +225,7 @@ Output schema (one array entry per URL - full contract in `~/.claude/lib/conte
225
225
  { "type": "confluence", "url": "<url>", "metadata": { "host": "<host>", "pageId": "<id|null>", "spaceKey": "<key|null>" } },
226
226
  { "type": "crashlytics", "url": "<url>", "metadata": { "projectId": "...", "platform": "ios|android", "bundle": "...", "issueId": "...", "sessionId": "<id|null>" } },
227
227
  { "type": "fortify", "url": "<url>", "metadata": { "host": "<host>", "projectId": "<id|null>" } },
228
+ { "type": "graylog", "url": null, "metadata": { "idType": "trx|conversation", "id": "...", "label": "..." } },
228
229
  { "type": "figma", "url": "<url>", "metadata": { "kind": "design|make|board|slides", "fileKey": "...", "nodeId": "<id|null>" } },
229
230
  { "type": "generic-doc", "url": "<url>", "metadata": { "host": "<host>", "kind": "notion|google-docs|gist|github-doc" } }
230
231
  ]
@@ -258,7 +259,18 @@ URL pattern (caught by the extractor): `https://<prefs.global.hosts.fortify>/ssc
258
259
  - Phase 2 Planning injects `state.fortifyFinding` into the architectural-review prompt under a **Known Security Finding** section, and the generated task breakdown includes a task addressing the finding (naming convention: `sec(fortify-<issueId>): <issueName> at <file>:<line>`).
259
260
  - Soft-fail: 401 → re-run Token Save Flow for `fortify`; 403 → warn and skip; network timeout → respect VPN fallback (skip enrichment, mark `state.fortifyFinding = { skipped: "vpn-unreachable" }`).
260
261
 
261
- ##### Step 1b.3 - Other link types (catalogued only at Phase 0; fetched at Phase 1)
262
+ ##### Step 1b.3 - Graylog deep fetch (runs when `type == "graylog"` present in `state.contextLinks[]`)
263
+
264
+ 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.
265
+
266
+ - 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.
267
+ - 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.
268
+ - 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.
269
+ - Store as `state.graylogContext = { idType, id, messages[], fetchedAt }` (or an array keyed per id when several were extracted).
270
+ - 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).
271
+ - 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.
272
+
273
+ ##### Step 1b.4 - Other link types (catalogued only at Phase 0; fetched at Phase 1)
262
274
 
263
275
  For `swagger`, `confluence`, `figma`, `generic-doc` entries in `state.contextLinks[]`, no Phase 0 fetch runs. The entries pass through to Phase 1, which dispatches the matching fetcher (Block B work: `fetch-swagger.sh`, `fetch-confluence.sh`, etc.). Until those fetchers land, the entries are surfaced in Phase 1 context as "referenced but not fetched - agent should treat the URL as an authoritative external source and read it directly when relevant."
264
276
 
@@ -267,11 +279,11 @@ For `swagger`, `confluence`, `figma`, `generic-doc` entries in `state.contextLin
267
279
  **Log line shape** (progress contract):
268
280
 
269
281
  ```
270
- → context links: total=<N>, by-type={swagger:<n>, confluence:<n>, crashlytics:<n>, fortify:<n>, figma:<n>, generic-doc:<n>}
271
- → URL deep fetch: crashlytics=<hit|miss|skipped>, fortify=<hit|miss|skipped>
282
+ → context links: total=<N>, by-type={swagger:<n>, confluence:<n>, crashlytics:<n>, fortify:<n>, graylog:<n>, figma:<n>, generic-doc:<n>}
283
+ → URL deep fetch: crashlytics=<hit|miss|skipped>, fortify=<hit|miss|skipped>, graylog=<hit|miss|skipped>
272
284
  ```
273
285
 
274
- Two lines emit at the end of Step 1b. The first reports the catalogue from 1b.0 (always runs). The second reports the deep-fetch outcomes from 1b.1 / 1b.2 - `miss` = URL type not present in catalogue (normal), `skipped` = URL present but auth/VPN/mismatch skipped the fetch, `hit` = URL-derived context stored in state.
286
+ Two lines emit at the end of Step 1b. The first reports the catalogue from 1b.0 (always runs). The second reports the deep-fetch outcomes from 1b.1 / 1b.2 / 1b.3 - `miss` = type not present in catalogue (normal), `skipped` = present but auth/VPN/mismatch skipped the fetch (for graylog this includes the non-blocking VPN-unreachable degrade), `hit` = context stored in state.
275
287
 
276
288
  #### Step 2 - Project Selection
277
289
 
@@ -484,11 +496,12 @@ Single-repo mode (`projects.length === 1` or scalar-only) uses the legacy single
484
496
  "inputType": "github-issue-url|github-issue-number|jira-url|jira-id|free-text",
485
497
  "jiraId": "PROJ-XXXXX|null", "figmaUrl": "...|null",
486
498
  "contextLinks": [
487
- { "type": "swagger|confluence|crashlytics|fortify|figma|generic-doc",
488
- "url": "<full-url>", "metadata": { } }
499
+ { "type": "swagger|confluence|crashlytics|fortify|graylog|figma|generic-doc",
500
+ "url": "<full-url|null>", "metadata": { } }
489
501
  ],
490
502
  "crashContext": null,
491
503
  "fortifyFinding": null,
504
+ "graylogContext": null,
492
505
  "localMode": false, "instructionDriven": true|false, "instructionFiles": {},
493
506
  "identity": {"name": "...", "email": "...", "keychainKey": "..."},
494
507
  "currentPhase": 0, "status": "in_progress", "startedAt": "{ISO}", "shortId": "#N",
@@ -75,7 +75,7 @@ Progress lines:
75
75
 
76
76
  #### Step 1.5 - External Context Injection (`state.contextLinks[]`)
77
77
 
78
- Phase 0 Step 1b catalogued every typed external link from the task description into `state.contextLinks[]`. Phase 1 dispatches each entry to its matching fetcher (crashlytics, fortify, swagger, confluence, figma, generic-doc) and prepends results under a **Referenced External Sources** section in the analysis prompt - so the agent doesn't re-discover what the ticket already pointed at. Failures never fatal; pending refs are advisories. Full dispatch table, exit-code handling, prompt injection shape, log line shape: `$HOME/.claude/multi-agent-refs/features/external-context-injection.md`.
78
+ Phase 0 Step 1b catalogued every typed external link from the task description into `state.contextLinks[]`. Phase 1 dispatches each entry to its matching fetcher (crashlytics, fortify, graylog, swagger, confluence, figma, generic-doc) and prepends results under a **Referenced External Sources** section in the analysis prompt - so the agent doesn't re-discover what the ticket already pointed at. `state.graylogContext` is injected there too, as diagnostic context (advisory only). Failures never fatal (a non-zero fetcher exit is marked skipped and the analysis still runs, exactly as for crashlytics); pending refs are advisories. Full dispatch table, exit-code handling, prompt injection shape, log line shape: `$HOME/.claude/multi-agent-refs/features/external-context-injection.md`.
79
79
 
80
80
  **Log line shape** (progress contract):
81
81
 
@@ -11,6 +11,7 @@
11
11
  "figma": null,
12
12
  "figma_mcp": null,
13
13
  "fortify": null,
14
+ "graylog": null,
14
15
  "firebase": null,
15
16
  "jenkins": null
16
17
  },
@@ -157,6 +157,12 @@
157
157
  "null"
158
158
  ]
159
159
  },
160
+ "graylog": {
161
+ "type": [
162
+ "string",
163
+ "null"
164
+ ]
165
+ },
160
166
  "firebase": {
161
167
  "type": [
162
168
  "string",
@@ -482,6 +488,10 @@
482
488
  "type": "string",
483
489
  "description": "Fortify SSC host without scheme, e.g. ssc.example.com. Used by Phase 0 Step 1b URL enrichment to validate pasted Fortify URLs and for direct SSC REST API calls. Resolves {FORTIFY_HOST}."
484
490
  },
491
+ "graylog": {
492
+ "type": "string",
493
+ "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}."
494
+ },
485
495
  "corpDomain": {
486
496
  "type": "string",
487
497
  "description": "Corporate email / cookie domain, e.g. example.com. Resolves {CORP_DOMAIN}."
@@ -2,7 +2,7 @@
2
2
  .claude/CLAUDE.md 1
3
3
  .claude/agents 8
4
4
  .claude/commands 44
5
- .claude/lib 25
5
+ .claude/lib 26
6
6
  .claude/multi-agent-preferences.json 1
7
7
  .claude/multi-agent-refs 51
8
8
  .claude/rules 12
@@ -13,7 +13,7 @@
13
13
  .copilot/.pipeline-version 1
14
14
  .copilot/agents 8
15
15
  .copilot/copilot-instructions.md 1
16
- .copilot/lib 25
16
+ .copilot/lib 26
17
17
  .copilot/schemas 23
18
18
  .copilot/scripts 193
19
19
  .copilot/skills 467
@@ -261,6 +261,30 @@ expect_rc 0 "stub token + offline curl degrades to empty result (exit 0)" \
261
261
  "$SH" "$LIB/fetch-fortify.sh" "$FORTIFY_URL"
262
262
  assert_in_file '"gateOutcome"' "$OUT" "normalized JSON still emitted"
263
263
 
264
+ # -----------------------------------------------------------------------------
265
+ note "4b. fetch-graylog.sh"
266
+ # -----------------------------------------------------------------------------
267
+ expect_rc 6 "no host configured exits 6" \
268
+ env HOME="$FAKE_HOME" USER=smoke \
269
+ "$SH" "$LIB/fetch-graylog.sh" --trx T123
270
+ expect_rc 4 "host override + no ids exits 4 (usage)" \
271
+ env HOME="$FAKE_HOME" USER=smoke GRAYLOG_HOST_OVERRIDE=logs.example.com \
272
+ "$SH" "$LIB/fetch-graylog.sh"
273
+
274
+ expect_rc 2 "host override + id reaches credential stage" \
275
+ env HOME="$FAKE_HOME" USER=smoke GRAYLOG_HOST_OVERRIDE=logs.example.com \
276
+ "$SH" "$LIB/fetch-graylog.sh" --trx T123 --conv C456
277
+ assert_in_file "missing-token" "$ERR" "blocked JSON names missing-token"
278
+
279
+ # Non-blocking contract: with a stub token and a failing curl, the fetch must
280
+ # degrade to an empty normalized result (messages present) and exit 0 so a log
281
+ # fetch never blocks a run.
282
+ expect_rc 0 "stub token + offline curl degrades to empty result (exit 0)" \
283
+ env HOME="$FAKE_HOME" USER=smoke GRAYLOG_HOST_OVERRIDE=logs.example.com \
284
+ FAKE_CRED_VALUE=fake-token PATH="$BIN_CURL_FAIL:$PATH" \
285
+ "$SH" "$LIB/fetch-graylog.sh" --trx T123
286
+ assert_in_file '"messages"' "$OUT" "normalized JSON still emitted"
287
+
264
288
  # -----------------------------------------------------------------------------
265
289
  note "5. figma-screenshot.sh"
266
290
  # -----------------------------------------------------------------------------
@@ -338,6 +362,7 @@ OWNED_FILES=(
338
362
  "$LIB/fetch-crashlytics.sh"
339
363
  "$LIB/fetch-swagger.sh"
340
364
  "$LIB/fetch-fortify.sh"
365
+ "$LIB/fetch-graylog.sh"
341
366
  "$LIB/figma-mcp-refresh.sh"
342
367
  "$LIB/figma-screenshot.sh"
343
368
  "$LIB/credential-store.sh"