@mmerterden/multi-agent-pipeline 15.13.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,41 @@ 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
+
33
+ ## [15.14.0] - 2026-08-22
34
+
35
+ ### Added
36
+ - **A release can now be required, not just available.** `/multi-agent:update` stays exactly what it was, and most releases change nothing: the run-start check keeps reading `dist-tags.latest` and keeps asking politely. What is new is a second tag. `npm dist-tag add <pkg>@<version> required` names the oldest version a user may run, and an install below that floor is not behind, it is wrong - it would produce work against a contract that no longer holds, which then has to be redone. Below the floor, Phase 0 Step 0.6 halts: it runs the update flow and stops, and the user re-issues the command on the new version. It does not continue on the freshly updated install, because this run's phase docs, refs and scripts were already loaded from the old one, and that is the drift the floor exists to prevent. Interactive and autopilot behave identically - there is nothing to decide.
37
+ - **`require-supported-version.sh`** turns the signal into an exit code for shell callers: 0 = proceed, 3 = halt, with `force|<local>|<latest>|<required>` on stdout and a human block on stderr. It shares `update-check.sh`'s cache, so a second command inside the TTL window costs no network call.
38
+
39
+ ### Fixed
40
+ - **The Firebase Crashlytics fetcher called an endpoint that does not exist.** It built the app reference by hand as `<platform>:<bundle>` and asked for `/v1alpha/projects/<p>/apps/<ref>/issues/<issueId>`. The appId is opaque (`1:1234567890:ios:abcdef`) and cannot be derived from a bundle, and v1alpha has no get-issue-by-id route, so every fetch failed - and the failure was reported as `api-not-enabled`, which sent anyone debugging it to look at Google's API allowlist instead of at the URL. It now resolves the real appId through the Firebase Management API (`iosApps` / `androidApps`, matching `bundleId` / `packageName`), then reads `reports/topIssues` for the summary and metrics and `events?filter.issue.id=<id>` for the newest event. The payload gains what that event carries and the old shape could not: the full `stackTrace[]`, the breadcrumb and log timeline with screen names, session and occurrence counts, and the console URI. A multi-app project with no bundle match exits 3 as `app-not-found` rather than picking a neighbouring app.
41
+ - **Fortify findings that arrive without a URL are no longer invisible.** A scanner-to-tracker bridge writes the instance id and the `file:line` into the ticket and never writes the SSC link - it knows the id, and the person reading the ticket never needed the URL. The extractor was URL-only, so those tickets produced an empty `contextLinks[]`, Phase 0 skipped the deep fetch, and Phase 4's Fortify gate reported `n/a` on a ticket that exists *because of* a security finding. It now also matches a labelled `Fortify Instance ID` / `issue instance id` / `fortify id` and emits a URL-less entry, the same shape graylog trx ids already used. `fetch-fortify.sh --instance-id <id>` resolves the project version by asking each id in `prefs.global.fortify.versionIds` in order. To stay off prose, the id must be at least 16 characters and contain a digit, and the bare label `instance id` is deliberately not matched.
42
+ - **`prefs.global.fortify` did not exist.** Phase 4 Gate 5 documented `fortify.alwaysCheck` as its opt-in from the day it shipped, but `global` is closed to additional properties and the schema had no `fortify` object, so setting it failed validation - the gate could only ever run off a referenced URL. The object now exists with `alwaysCheck` and `versionIds`.
43
+ - Fortify URLs of the form `/ssc/html/ssc/version/<id>` and `#/version/<id>` now yield a version id. Only the API shape `/projectVersions/<id>` was matched before, so the UI links people actually paste parsed to `projectId: null`.
44
+
45
+ ### Changed
46
+ - `update-check.sh` reads both tags in one call and now uses the abbreviated packument (60 kB instead of 250 kB for the same answer). Its own contract is unchanged and deliberately so: it still always exits 0, still says nothing when the registry is unreachable, and still emits `<local>|<latest>` for a plain update. The floor appends a third field, `force`, which a `cut -f1`/`-f2` reader ignores. The cache file grows a third field too; a two-field cache written by an older install still reads, and its missing floor means "unknown", never "none".
47
+ - **Fail-open, on purpose.** Offline, a blocked registry, an undeterminable local version, a `required` tag published above `latest`, or no tag at all: every one of these exits 0. A version gate that bricks the pipeline on a flaky network is worse than the drift it guards against.
48
+ - **Not opt-out.** `updateCheck.enabled: false` silences the advisory "update available" prompt, which is what it always meant; it does not lift a floor. The single override is the env var `MULTI_AGENT_ALLOW_OUTDATED=1`, which exits 0 with a warning and is logged in the run record, so a broken release cannot strand someone mid-incident. Exempt commands - `update`, `setup`, `uninstall`, `help`, `status`, `log`, `search`, `routines`, `forget`, `language` - are the remedy or cannot depend on a contract.
49
+ - Phase-doc token budget: total 54050 -> 54400. Compression came first and took the new Step 0.6 prose from 469 tokens to 337, by moving the rationale, the exemption list and the `npm dist-tag add` recipe into `multi-agent-refs/rules.md` "Supported Version Gate" (loaded by 25 commands, outside this budget) and into the script header, leaving the phase doc with the call, the decision table and the halt.
50
+ - `smoke-update-check.sh` grew from 12 to 27 assertions, covering the floor, the clamp, the legacy cache, the wrapper's three exit paths and the doc wiring on both sides.
51
+ - **`smoke-context-links.sh`: the extractor has a behaviour test now.** A 250-line deterministic classifier that every Phase 0 deep fetch dispatches on had no test of its own, so a regression in any pattern would only ever surface as "the ticket had a link and nothing was enriched", with nothing red to point at. 32 assertions over every emitted type, both Fortify entry shapes, dedup, and the two things that must NOT match: prose containing the words, and a bare non-doc URL.
52
+ - `sync/SKILL.md` lost a "Special inputs" table that repeated the argument table from the top of the same file, minus one row.
53
+
19
54
  ## [15.13.0] - 2026-08-22
20
55
 
21
56
  ### Fixed
package/docs/features.md CHANGED
@@ -162,7 +162,11 @@ Existing tests are immutable during a task: deleting, renaming, or weakening an
162
162
 
163
163
  ### Update Check at Run Start
164
164
 
165
- Phase 0 Step 0.6, opt-out via `prefs.global.updateCheck`. Once per `ttlHours` window (cached, 3s-bounded curl to the npm registry), the installed version is compared against `dist-tags.latest`. Newer version: interactive modes ask "Update now?" (yes runs the `/multi-agent:update` flow, then the run continues); autopilot logs one line and never asks. `autoUpdate: true` updates silently before the worktree exists. Offline and failed checks are silent - never blocks.
165
+ Phase 0 Step 0.6. Once per `ttlHours` window (cached, 3s-bounded curl to the npm registry), the installed version is compared against two dist-tags.
166
+
167
+ **`latest` - advisory**, opt-out via `prefs.global.updateCheck.enabled`. Newer version: interactive modes ask "Update now?" (yes runs the `/multi-agent:update` flow, then the run continues); autopilot logs one line and never asks. `autoUpdate: true` updates silently before the worktree exists. Offline and failed checks are silent - never blocks.
168
+
169
+ **`required` - blocking** (v15.14.0+). Most releases do not publish this tag and nothing changes for them. A release that changed a contract a run depends on is promoted with `npm dist-tag add <pkg>@<version> required`, and an install below that floor is not behind, it is wrong: `require-supported-version.sh` exits 3, the run halts, `/multi-agent:update` runs, and the user re-issues the command on the new version rather than continuing on docs already loaded from the old one. Interactive and autopilot behave identically. The gate fails open on every undeterminable answer (offline, blocked registry, no tag), `updateCheck.enabled: false` does not disable it, and the single override is the env var `MULTI_AGENT_ALLOW_OUTDATED=1`, which is logged in the run record. Exemptions: `update`, `setup`, `uninstall`, `help`, `status`, `log`, `search`, `routines`, `forget`, `language`.
166
170
 
167
171
  ### Structured Handoff Blocks
168
172
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mmerterden/multi-agent-pipeline",
3
- "version": "15.13.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 |
@@ -72,15 +72,6 @@ Step 6: Report Summary: synced targets, platform, changed files, deploy sta
72
72
 
73
73
  If nothing is stale → report "All targets up to date" and stop.
74
74
 
75
- ## Special inputs
76
-
77
- | Input | Behaviour |
78
- |-------|----------|
79
- | (no args) | Full ecosystem sync (default) |
80
- | `release` | Full sync + version bump + tag + npm publish + website deploy |
81
- | `multi-agent-toolkit` | Run Step 3d only: gate, commit and publish the companion MCP server |
82
- | `"change description"` | Apply the description to EVERY target at once |
83
-
84
75
  ---
85
76
 
86
77
  ## Sync rules
@@ -427,6 +418,8 @@ npm publish --userconfig "$NPMRC"
427
418
 
428
419
  Verify the publish landed: on GitHub Packages `npm view <package> version --userconfig "$NPMRC"`; on npmjs read the registry directly (`curl -s https://registry.npmjs.org/<package>/latest`) rather than `npm view`, which can answer from a stale cache.
429
420
 
421
+ **Required release** (optional; ask first even in `release` mode, it halts every older install): `npm dist-tag add "$(node -p "require('./package.json').name")@$VER" required --userconfig "$NPMRC"`. Out of band, so promote or demote any time. Contract: `multi-agent-refs/rules.md` "Supported Version Gate".
422
+
430
423
  **Approval**: outside autopilot and outside `release`, ask before shipping.
431
424
 
432
425
  > "multi-agent-toolkit moved (N changed files, M unpushed commits, v$VER). What should I do?"
@@ -25,9 +25,17 @@ A git clone of the pipeline repo is a maintainer workspace, kept in sync by `/mu
25
25
  echo "Registry unreachable ($REG) - check your network and retry."
26
26
  exit 1
27
27
  fi
28
- echo "Current: v$CUR Latest: v$LATEST"
28
+ # dist-tags.required is the supported-version floor (absent on most releases).
29
+ REQUIRED=$(curl -fsS -H 'Accept: application/vnd.npm.install-v1+json' \
30
+ "$REG/$(printf '%s' "$PKG" | sed 's|/|%2F|')" \
31
+ | node -pe 'JSON.parse(require("fs").readFileSync(0,"utf8"))["dist-tags"].required || ""' 2>/dev/null)
32
+ echo "Current: v$CUR Latest: v$LATEST${REQUIRED:+ Required floor: v$REQUIRED}"
29
33
  ```
30
34
 
35
+ This command is never blocked by the floor - it is the remedy for it. The
36
+ line above is where the floor is reported, so the user sees why a run halted;
37
+ `$REQUIRED` does not survive into the later shell blocks.
38
+
31
39
  2. **Stop early when already current** (still refresh the marketplace, step 4b):
32
40
  ```bash
33
41
  [ "$CUR" = "$LATEST" ] && echo "✓ Already up to date: v$LATEST"
@@ -10,7 +10,8 @@
10
10
  # - swagger : OpenAPI / Swagger JSON or YAML spec URLs
11
11
  # - confluence : Confluence pages (host pattern + display/spaces/pageId)
12
12
  # - crashlytics : Firebase Crashlytics issue/session URLs
13
- # - fortify : Fortify SSC findings URLs
13
+ # - fortify : Fortify SSC finding URLs, and (URL-less, like graylog)
14
+ # labelled issue instance ids - "Fortify Instance ID: <id>"
14
15
  # - graylog : Graylog trx-id / conversation-id references. Unlike the
15
16
  # others this is matched from free text (labels like "trx",
16
17
  # "trxid", "transaction id", "conversationId", "convId",
@@ -31,7 +32,7 @@
31
32
  # { "type": "confluence", "url": "...", "metadata": { "host": "...", "pageId": "...|null", "spaceKey": "...|null" } },
32
33
  # { "type": "swagger", "url": "...", "metadata": { "format": "json|yaml|unknown" } },
33
34
  # { "type": "crashlytics","url": "...", "metadata": { "projectId": "...", "platform": "ios|android", "issueId": "...", "sessionId": "...|null" } },
34
- # { "type": "fortify", "url": "...", "metadata": { "host": "...", "projectId": "...|null" } },
35
+ # { "type": "fortify", "url": "...|null", "metadata": { "host": "...|null", "versionId": "...|null", "instanceId": "...|null", "source": "url|label" } },
35
36
  # { "type": "graylog", "url": null, "metadata": { "idType": "trx|conversation", "id": "...", "label": "..." } },
36
37
  # { "type": "figma", "url": "...", "metadata": { "kind": "design|make|board|slides", "fileKey": "...", "nodeId": "...|null" } },
37
38
  # ...
@@ -155,11 +156,18 @@ def classify(url: str):
155
156
  # https://<ssc-host>/ssc/html/ssc/...
156
157
  # https://<ssc-host>/ssc/api/v1/projectVersions/<id>/...
157
158
  if "fortify" in host or "/ssc/" in path or "/api/v1/projectVersions/" in path:
158
- project_id = None
159
- m = re.search(r"/projectVersions/(?P<id>\d+)", path)
160
- if m:
161
- project_id = m.group("id")
162
- return ("fortify", {"host": host, "projectId": project_id})
159
+ # The SSC UI keeps the version id in the fragment on some deployments
160
+ # and in the path on others; search both.
161
+ where = path + ("#" + parsed.fragment if parsed.fragment else "")
162
+ m = re.search(r"/(?:projectVersions|version)/(?P<id>\d+)", where)
163
+ version_id = m.group("id") if m else None
164
+ m2 = re.search(r"/fix/(?P<inst>[A-Za-z0-9_-]{8,})", where)
165
+ return ("fortify", {
166
+ "host": host,
167
+ "versionId": version_id,
168
+ "instanceId": m2.group("inst") if m2 else None,
169
+ "source": "url",
170
+ })
163
171
 
164
172
  # --- Generic doc fallbacks ---------------------------------------------
165
173
  # Notion, Google Docs, GitHub markdown blob/wiki, gists. Useful as
@@ -228,5 +236,45 @@ for m in GRAYLOG_RE.finditer(text):
228
236
  "metadata": {"idType": id_type, "id": gid, "label": label},
229
237
  })
230
238
 
239
+ # --- Fortify issue instance ids (label-based, not URL) ---------------------
240
+ # A finding often arrives as a ticket written by a scanner-to-tracker bridge,
241
+ # which quotes the instance id and the file:line but no SSC deep link - the
242
+ # bridge knows the id, the reader does not need the URL, and the pipeline then
243
+ # saw nothing to enrich. Same shape as the graylog entries: "url": null, the id
244
+ # in metadata. Resolution to a project version happens in fetch-fortify.sh,
245
+ # which is where the prefs live.
246
+ FORTIFY_ID_RE = re.compile(
247
+ r"(?P<label>"
248
+ r"fortify[\s_-]*instance[\s_-]*id"
249
+ r"|issue[\s_-]*instance[\s_-]*id"
250
+ r"|fortify[\s_-]*id"
251
+ r")"
252
+ r"[\s*_~`]*[:=#]?[\s*_~`]*"
253
+ r"[\"']?(?P<id>(?=[A-Za-z0-9._-]*[0-9])[A-Za-z0-9][A-Za-z0-9._-]{15,})",
254
+ re.IGNORECASE,
255
+ )
256
+ already_linked = {
257
+ e["metadata"].get("instanceId")
258
+ for e in out
259
+ if e["type"] == "fortify" and e["metadata"].get("instanceId")
260
+ }
261
+ for m in FORTIFY_ID_RE.finditer(text):
262
+ fid = m.group("id").rstrip(".,;:!?)")
263
+ key = ("fortify", fid)
264
+ if not fid or key in seen or fid in already_linked:
265
+ continue
266
+ seen.add(key)
267
+ out.append({
268
+ "type": "fortify",
269
+ "url": None,
270
+ "metadata": {
271
+ "host": None,
272
+ "versionId": None,
273
+ "instanceId": fid,
274
+ "source": "label",
275
+ "label": m.group("label"),
276
+ },
277
+ })
278
+
231
279
  print(json.dumps(out))
232
280
  PY
@@ -19,16 +19,21 @@
19
19
  # Output (stdout, single JSON object):
20
20
  # {
21
21
  # "fetchedAt": "<ISO8601>",
22
- # "source": { "url": "<url|null>", "projectId": "...", "platform": "ios|android",
23
- # "bundle": "...", "issueId": "...", "sessionId": "<id|null>" },
24
- # "issue": { "title": "...", "subtitle": "...", "type": "fatal|nonFatal|ANR",
25
- # "occurrences": <n>, "impactedUsers": <n>,
26
- # "firstSeen": "<ISO8601>", "lastSeen": "<ISO8601>",
27
- # "topStackFrame": { "file": "...", "line": <n>, "symbol": "..." },
22
+ # "source": { "url": "<url|null>", "projectId": "...", "appId": "<resolved|null>",
23
+ # "platform": "ios|android", "bundle": "...", "issueId": "...",
24
+ # "sessionId": "<id|null>" },
25
+ # "issue": { "title": "...", "subtitle": "...", "type": "FATAL|NON_FATAL|ANR",
26
+ # "state": "...", "consoleUri": "<url|null>",
27
+ # "occurrences": <n>, "impactedUsers": <n>, "sessions": <n>,
28
+ # "firstSeenVersion": "<v|null>", "lastSeenVersion": "<v|null>",
29
+ # "topStackFrame": { "file": "<library>", "line": 0, "symbol": "..." },
30
+ # "stackTrace": [ { "library": "...", "symbol": "..." }, ... ],
28
31
  # "topDevices": [ "<name>", ... ],
29
32
  # "topOS": [ "<version>", ... ],
30
33
  # "affectedAppVersions": [ "<version>", ... ] },
31
- # "session": null | { "id": "...", "events": [ { "type": "...", "time": "...", "summary": "..." } ] }
34
+ # "session": null | { "id": "<eventId>", "time": "<ISO8601>",
35
+ # "events": [ { "type": "breadcrumb|log", "time": "...",
36
+ # "summary": "...", "screen": "<name|null>" } ] }
32
37
  # }
33
38
  #
34
39
  # Exit codes:
@@ -39,11 +44,13 @@
39
44
  # 5 project mismatch - SA JSON project_id != URL project (configuration bug)
40
45
  #
41
46
  # Notes:
42
- # The Firebase Crashlytics REST surface (`firebasecrashlytics.googleapis.com`)
43
- # is currently invitation-only in some regions. When the API returns 404 for
44
- # a valid project the script exits 3 with reason "api-not-enabled"; the
45
- # orchestrator surfaces this as "fetcher unavailable, advisory only" to
46
- # keep the pipeline moving.
47
+ # Two v1alpha calls back this, because there is no get-issue-by-id endpoint:
48
+ # `reports/topIssues` for the summary and metrics, and `events?filter.issue.id`
49
+ # for the stack trace, device, OS and breadcrumbs. The opaque appId is resolved
50
+ # first through the Firebase Management API (`iosApps` / `androidApps`), since a
51
+ # console URL only ever carries the bundle id. Any failure exits 3 with a reason
52
+ # ("app-not-found", "crashlytics-unreachable"); the orchestrator surfaces that as
53
+ # "fetcher unavailable, advisory only" and keeps the pipeline moving.
47
54
 
48
55
  set -euo pipefail
49
56
 
@@ -279,80 +286,183 @@ fi
279
286
  # never appears in argv (argv is visible to ps).
280
287
  crashlytics_auth_cfg() { printf 'header = "Authorization: Bearer %s"\n' "$ACCESS_TOKEN"; }
281
288
 
282
- # Construct the appId in the form Firebase expects:
283
- # ios: ios:<bundleId> → api: /projects/<n>/apps/<bundleId> (looked up via list)
284
- # android: android:<package>
285
- # In practice the appId is opaque (1:1234567890:ios:abcdef). We try the
286
- # Crashlytics v1alpha endpoint with the bundle as a fallback "app" - the API
287
- # accepts the package/bundle when scoped to the project.
288
- APP_REF="$PLATFORM:$BUNDLE"
289
- ISSUE_URL_API="https://firebasecrashlytics.googleapis.com/v1alpha/projects/$PROJECT_ID/apps/$APP_REF/issues/$ISSUE_ID"
290
-
291
- ISSUE_JSON=$(curl -sS --fail --max-time "$TIMEOUT" --connect-timeout 5 \
292
- -K <(crashlytics_auth_cfg) -H "Accept: application/json" \
293
- "$ISSUE_URL_API" 2>/dev/null || true)
294
-
295
- if [ -z "$ISSUE_JSON" ]; then
296
- printf '{"status":"failed","reason":"api-not-enabled-or-app-mismatch","appRef":"%s","issueId":"%s"}\n' "$APP_REF" "$ISSUE_ID" >&2
289
+ fb_get() {
290
+ curl -sS --fail --max-time "$TIMEOUT" --connect-timeout 5 \
291
+ -K <(crashlytics_auth_cfg) -H "Accept: application/json" "$1" 2>/dev/null || true
292
+ }
293
+
294
+ # The appId is opaque (1:1234567890:ios:abcdef) and cannot be derived from the
295
+ # bundle id. Ask the Firebase Management API which app owns this bundle; the
296
+ # console URL only ever carries the bundle.
297
+ FB_MGMT="https://firebase.googleapis.com/v1beta1/projects/$PROJECT_ID"
298
+ if [ "$PLATFORM" = "ios" ]; then
299
+ APPS_JSON=$(fb_get "$FB_MGMT/iosApps?pageSize=200")
300
+ MATCH_FIELD="bundleId"
301
+ else
302
+ APPS_JSON=$(fb_get "$FB_MGMT/androidApps?pageSize=200")
303
+ MATCH_FIELD="packageName"
304
+ fi
305
+
306
+ APP_ID=$(APPS_IN="$APPS_JSON" WANT="$BUNDLE" FIELD="$MATCH_FIELD" python3 -c "
307
+ import json, os
308
+ try:
309
+ apps = (json.loads(os.environ['APPS_IN'] or '{}').get('apps') or [])
310
+ except Exception:
311
+ apps = []
312
+ want = (os.environ['WANT'] or '').lower()
313
+ field = os.environ['FIELD']
314
+ for a in apps:
315
+ if str(a.get(field) or '').lower() == want:
316
+ print(a.get('appId') or '')
317
+ break
318
+ else:
319
+ # A single-app project needs no disambiguation; anything else stays unresolved
320
+ # rather than guessing at the wrong app.
321
+ print(apps[0].get('appId') or '' if len(apps) == 1 else '')
322
+ " 2>/dev/null || true)
323
+
324
+ if [ -z "$APP_ID" ]; then
325
+ printf '{"status":"failed","reason":"app-not-found","platform":"%s","bundle":"%s","projectId":"%s"}\n' \
326
+ "$PLATFORM" "$BUNDLE" "$PROJECT_ID" >&2
297
327
  exit 3
298
328
  fi
299
329
 
300
- SESSION_JSON=""
301
- if [ -n "$SESSION_ID" ]; then
302
- SESSION_URL_API="$ISSUE_URL_API/sessions/$SESSION_ID"
303
- SESSION_JSON=$(curl -sS --fail --max-time "$TIMEOUT" --connect-timeout 5 \
304
- -K <(crashlytics_auth_cfg) -H "Accept: application/json" \
305
- "$SESSION_URL_API" 2>/dev/null || true)
330
+ CRASHLYTICS_APP="https://firebasecrashlytics.googleapis.com/v1alpha/projects/$PROJECT_ID/apps/$APP_ID"
331
+
332
+ # There is no get-issue-by-id endpoint on v1alpha. The issue summary comes from
333
+ # the topIssues report (filtered to the id we want) and the stack trace, device,
334
+ # OS and breadcrumbs come from the issue's most recent event. An earlier version
335
+ # of this script called /issues/<id> with a guessed "<platform>:<bundle>" app
336
+ # reference; neither the path nor the reference exists, so every fetch failed and
337
+ # reported it as "api-not-enabled".
338
+ #
339
+ # Two partial answers are treated as answers, not failures. A long-tail issue
340
+ # outside the top 200 leaves the summary empty and the event still carries the
341
+ # stack trace, and an event fetch that comes back empty still leaves the summary.
342
+ # Only both being empty is a failure. A URL naming a specific session gets that
343
+ # issue's most recent event instead: v1alpha exposes no per-session read, and
344
+ # `source.sessionId` is passed through so the caller can see what was asked for.
345
+ ISSUE_ID_ENC=$(ISSUE_IN="$ISSUE_ID" python3 -c "
346
+ import os, urllib.parse
347
+ print(urllib.parse.quote(os.environ['ISSUE_IN'], safe=''))
348
+ " 2>/dev/null || printf '%s' "$ISSUE_ID")
349
+ TOP_ISSUES_JSON=$(fb_get "$CRASHLYTICS_APP/reports/topIssues?pageSize=200")
350
+ EVENT_JSON=$(fb_get "$CRASHLYTICS_APP/events?filter.issue.id=$ISSUE_ID_ENC&pageSize=1")
351
+
352
+ if [ -z "$TOP_ISSUES_JSON" ] && [ -z "$EVENT_JSON" ]; then
353
+ printf '{"status":"failed","reason":"crashlytics-unreachable","appId":"%s","issueId":"%s"}\n' "$APP_ID" "$ISSUE_ID" >&2
354
+ exit 3
306
355
  fi
307
356
 
357
+ ISSUE_JSON="$TOP_ISSUES_JSON"
358
+ SESSION_JSON="$EVENT_JSON"
359
+
308
360
  SRC_URL="$URL" SRC_PROJECT="$PROJECT_ID" SRC_PLATFORM="$PLATFORM" SRC_BUNDLE="$BUNDLE" \
309
- SRC_ISSUE="$ISSUE_ID" SRC_SESSION="$SESSION_ID" \
361
+ SRC_ISSUE="$ISSUE_ID" SRC_SESSION="$SESSION_ID" SRC_APPID="$APP_ID" \
310
362
  ISSUE_JSON_IN="$ISSUE_JSON" SESSION_JSON_IN="$SESSION_JSON" \
311
363
  python3 - <<'PY'
312
364
  import json, os, datetime
313
365
 
314
- issue = json.loads(os.environ["ISSUE_JSON_IN"] or "{}")
315
- session_raw = os.environ.get("SESSION_JSON_IN") or ""
316
- session = json.loads(session_raw) if session_raw else None
366
+ def load(name):
367
+ raw = os.environ.get(name) or ""
368
+ try:
369
+ return json.loads(raw) if raw else {}
370
+ except Exception:
371
+ return {}
372
+
373
+ issue_id = os.environ["SRC_ISSUE"]
374
+
375
+ # topIssues returns groups: {"issue": {...}, "metrics": [{...}]}. Find ours.
376
+ groups = load("ISSUE_JSON_IN").get("topIssues") or load("ISSUE_JSON_IN").get("issues") or []
377
+ group = {}
378
+ for g in groups:
379
+ if str(((g or {}).get("issue") or {}).get("id") or "") == issue_id:
380
+ group = g
381
+ break
382
+ issue = (group.get("issue") or {}) if isinstance(group, dict) else {}
383
+ metrics_list = (group.get("metrics") or []) if isinstance(group, dict) else []
384
+ metrics = metrics_list[0] if metrics_list else {}
385
+
386
+ def count(value):
387
+ try:
388
+ return int(value)
389
+ except (TypeError, ValueError):
390
+ return 0
391
+
392
+ events_body = load("SESSION_JSON_IN")
393
+ events = events_body.get("events") or events_body.get("errorEvents") or []
394
+ event = events[0] if events else {}
395
+
396
+ exceptions = event.get("exceptions") or []
397
+ top_exception = exceptions[0] if exceptions else {}
398
+ frames = top_exception.get("frames") or []
399
+ top_frame = frames[0] if frames else {}
400
+
401
+ # v1alpha frames carry library + symbol, not file + line. Keeping the documented
402
+ # key names with the library in "file" beats inventing a second shape for the
403
+ # one consumer (Phase 1 prompt injection) that reads them.
404
+ stack = [
405
+ {"library": f.get("library") or "", "symbol": f.get("symbol") or f.get("rawSymbol") or ""}
406
+ for f in frames[:40]
407
+ ]
408
+
409
+ versions = []
410
+ for v in (issue.get("firstSeenVersion"), issue.get("lastSeenVersion"),
411
+ ((event.get("version") or {}).get("displayName"))):
412
+ if v and v not in versions:
413
+ versions.append(v)
317
414
 
318
- def first(d, *keys, default=None):
319
- for k in keys:
320
- if isinstance(d, dict) and k in d:
321
- return d[k]
322
- return default
415
+ timeline = []
416
+ for crumb in (event.get("breadcrumbs") or [])[:25]:
417
+ screen = ((crumb.get("params") or {}).get("firebase_screen_class")) or None
418
+ timeline.append({
419
+ "type": "breadcrumb",
420
+ "time": crumb.get("eventTime") or "",
421
+ "summary": crumb.get("title") or "",
422
+ "screen": screen,
423
+ })
424
+ for log in (event.get("logs") or [])[:25]:
425
+ message = log.get("message") or ""
426
+ if message:
427
+ timeline.append({"type": "log", "time": "", "summary": message, "screen": None})
323
428
 
324
- top_frame_raw = first(issue, "topFrame", "frame", default={}) or {}
325
429
  result = {
326
- "fetchedAt": datetime.datetime.utcnow().isoformat() + "Z",
430
+ "fetchedAt": datetime.datetime.now(datetime.timezone.utc).isoformat().replace("+00:00", "Z"),
327
431
  "source": {
328
432
  "url": os.environ.get("SRC_URL") or None,
329
433
  "projectId": os.environ["SRC_PROJECT"],
434
+ "appId": os.environ.get("SRC_APPID") or None,
330
435
  "platform": os.environ["SRC_PLATFORM"],
331
436
  "bundle": os.environ["SRC_BUNDLE"],
332
- "issueId": os.environ["SRC_ISSUE"],
437
+ "issueId": issue_id,
333
438
  "sessionId": os.environ.get("SRC_SESSION") or None,
334
439
  },
335
440
  "issue": {
336
- "title": first(issue, "title", "errorTitle", "subtitle", default=""),
337
- "subtitle": first(issue, "subtitle", "errorMessage", default=""),
338
- "type": first(issue, "type", "errorType", default="unknown"),
339
- "occurrences": first(issue, "occurrences", "eventCount", default=0),
340
- "impactedUsers": first(issue, "impactedUsers", "userCount", default=0),
341
- "firstSeen": first(issue, "firstSeen", "createTime", default=None),
342
- "lastSeen": first(issue, "lastSeen", "updateTime", default=None),
441
+ "title": issue.get("title") or top_exception.get("title") or "",
442
+ "subtitle": issue.get("subtitle") or "",
443
+ "type": issue.get("errorType") or "unknown",
444
+ "state": issue.get("state") or "",
445
+ "consoleUri": issue.get("uri") or None,
446
+ "occurrences": count(metrics.get("eventsCount")),
447
+ "impactedUsers": count(metrics.get("impactedUsersCount")),
448
+ "sessions": count(metrics.get("sessionsCount")),
449
+ "firstSeenVersion": issue.get("firstSeenVersion") or None,
450
+ "lastSeenVersion": issue.get("lastSeenVersion") or None,
343
451
  "topStackFrame": {
344
- "file": first(top_frame_raw, "fileName", "file", default=""),
345
- "line": first(top_frame_raw, "lineNumber", "line", default=0),
346
- "symbol": first(top_frame_raw, "symbol", "function", default=""),
452
+ "file": top_frame.get("library") or "",
453
+ "line": 0,
454
+ "symbol": top_frame.get("symbol") or top_frame.get("rawSymbol") or "",
347
455
  },
348
- "topDevices": (issue.get("topDevices") or [])[:5],
349
- "topOS": (issue.get("topOperatingSystems") or issue.get("topOS") or [])[:5],
350
- "affectedAppVersions": (issue.get("affectedVersions") or issue.get("appVersions") or [])[:5],
456
+ "stackTrace": stack,
457
+ "topDevices": [d for d in [((event.get("device") or {}).get("displayName"))] if d],
458
+ "topOS": [o for o in [((event.get("operatingSystem") or {}).get("displayName"))] if o],
459
+ "affectedAppVersions": versions,
351
460
  },
352
461
  "session": ({
353
- "id": os.environ.get("SRC_SESSION") or "",
354
- "events": (session.get("events") if isinstance(session, dict) else None) or [],
355
- } if session else None),
462
+ "id": event.get("eventId") or os.environ.get("SRC_SESSION") or "",
463
+ "time": event.get("eventTime") or "",
464
+ "events": timeline,
465
+ } if event else None),
356
466
  }
357
467
  print(json.dumps(result, ensure_ascii=False))
358
468
  PY