@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 +35 -0
- package/docs/features.md +5 -1
- 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/commands/multi-agent/sync/SKILL.md +2 -9
- package/pipeline/commands/multi-agent/update/SKILL.md +9 -1
- package/pipeline/lib/context-link-extractor.sh +55 -7
- package/pipeline/lib/fetch-crashlytics.sh +171 -61
- package/pipeline/lib/fetch-fortify.sh +58 -5
- package/pipeline/lib/fetch-graylog.sh +166 -66
- package/pipeline/multi-agent-refs/features/url-enrichment.md +13 -8
- package/pipeline/multi-agent-refs/keychain.md +3 -1
- package/pipeline/multi-agent-refs/phases/phase-0-init.md +17 -3
- package/pipeline/multi-agent-refs/phases/phase-4-review.md +1 -1
- package/pipeline/multi-agent-refs/rules.md +11 -0
- package/pipeline/preferences-template.json +1 -0
- package/pipeline/schemas/complaint-analysis-spec.schema.json +6 -1
- package/pipeline/schemas/prefs.schema.json +29 -3
- package/pipeline/schemas/token-budget.json +2 -2
- package/pipeline/scripts/README.md +5 -1
- package/pipeline/scripts/require-supported-version.sh +70 -0
- package/pipeline/scripts/update-check.sh +64 -13
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
|
|
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.
|
|
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 |
|
|
@@ -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
|
-
|
|
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
|
|
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": "
|
|
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
|
-
|
|
159
|
-
|
|
160
|
-
if
|
|
161
|
-
|
|
162
|
-
|
|
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": "...", "
|
|
23
|
-
# "
|
|
24
|
-
#
|
|
25
|
-
#
|
|
26
|
-
# "
|
|
27
|
-
# "
|
|
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": "
|
|
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
|
-
#
|
|
43
|
-
#
|
|
44
|
-
#
|
|
45
|
-
#
|
|
46
|
-
#
|
|
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
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
#
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
"
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
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
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
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
|
-
|
|
315
|
-
|
|
316
|
-
|
|
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
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
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.
|
|
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":
|
|
437
|
+
"issueId": issue_id,
|
|
333
438
|
"sessionId": os.environ.get("SRC_SESSION") or None,
|
|
334
439
|
},
|
|
335
440
|
"issue": {
|
|
336
|
-
"title":
|
|
337
|
-
"subtitle":
|
|
338
|
-
"type":
|
|
339
|
-
"
|
|
340
|
-
"
|
|
341
|
-
"
|
|
342
|
-
"
|
|
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":
|
|
345
|
-
"line":
|
|
346
|
-
"symbol":
|
|
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
|
-
"
|
|
349
|
-
"
|
|
350
|
-
"
|
|
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
|
-
"
|
|
355
|
-
|
|
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
|