@mmerterden/multi-agent-pipeline 12.8.0 → 12.9.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,87 @@ Internal file-layout changes that don't affect the slash-command surface are sti
16
16
 
17
17
  ## [Unreleased]
18
18
 
19
+ ## [12.9.0] - 2026-07-26
20
+
21
+ Context engineering follow-through, and a guard that was blocking safe commands.
22
+
23
+ ### Phase docs load their conditional sections on demand
24
+
25
+ The two biggest phase docs carried the UNION of every branch, so a run paid for
26
+ paths it never took. Both were over their own warn thresholds.
27
+
28
+ - `phase-0-init.md` Step 1b (URL Enrichment, 9.6 kB) applies only when the task
29
+ input carries URLs. A bare Jira ID, a GitHub issue number or a free-text task
30
+ never needs it. Moved to `multi-agent-refs/features/url-enrichment.md` behind a
31
+ 761-byte stub that states the run condition. Phase 0: 11701 -> 9469 tokens.
32
+ - `phase-4-review.md` Multi-Repo Mode (3.0 kB) applies only when
33
+ `state.projects[].length > 1`. Moved to `features/review-multi-repo.md`.
34
+ Phase 4: 12070 -> 11048 tokens.
35
+
36
+ Total phase-doc budget 50955 -> 48047 tokens, and both docs are now under warn
37
+ rather than over. `smoke-url-enrichment.sh` follows the content to the ref and
38
+ additionally asserts the phase doc still POINTS at it: a JIT reference nothing
39
+ loads is worse than the inline copy it replaced.
40
+
41
+ ### The always-on description surface is budgeted
42
+
43
+ Progressive disclosure defers reference BODIES, but a skill or command
44
+ `description` sits in the host's listing for the whole session whether or not the
45
+ skill is ever invoked. That is a second fixed load, it was LARGER than the
46
+ budgeted one (76918 bytes across 236 descriptions), and nothing capped it.
47
+ `smoke-context-budget.sh` now gates the total and the per-description average
48
+ (the total can stay under budget while every description bloats), reports the
49
+ whole pre-task cost as one number, and `test/context-budget-gate.test.mjs` pins
50
+ all three ceilings as literals.
51
+
52
+ ### No prose block loads twice
53
+
54
+ `smoke-context-duplication.sh` hashes normalised prose blocks across the files
55
+ that can co-load in one run. 12.7.0 removed three sections the orchestrator had
56
+ inlined from `multi-agent-refs/` - one already stale, naming a path the code no
57
+ longer used - found by hand with nothing to prevent a recurrence.
58
+
59
+ Scope mattered more than the algorithm. A first draft scanned the command and
60
+ skill trees too and reported 10+ duplicates, every one legitimate: the
61
+ `commands/multi-agent/X` vs `skills/shared/core/multi-agent-X` pairs are the
62
+ cross-CLI parity contract, which `cross-cli-contract.md` REQUIRES byte-identical,
63
+ and the mode commands repeat prose but only one loads per run. A gate that fires
64
+ on deliberate design trains people to ignore it. Narrowed to the ref tree plus the
65
+ orchestrator, it found one real case: three pickers each restating the Language
66
+ matrix that `rules.md` already carries on every run - and the copies had already
67
+ drifted, two saying "Commit/PR/Jira/Wiki" and one "Commit/PR/Jira". Replaced with
68
+ a one-line pointer, 678 bytes reclaimed.
69
+
70
+ ### agent-guard stopped blocking safe pushes
71
+
72
+ The force-push detector ended in `-\S*f\S*`, matching any flag containing an `f`:
73
+
74
+ ```
75
+ git pull --ff-only && git push origin main -> BLOCKED
76
+ git push --follow-tags origin main -> BLOCKED
77
+ ```
78
+
79
+ Neither rewrites history, and `--follow-tags` is the normal way to push a release
80
+ tag with its commit, so the guard blocked a routine release step. False positives
81
+ are not free: people learn to route around the hook, which loses more safety than
82
+ the guard buys. The short-flag arm is now `-[A-Za-z]*f[A-Za-z]*` (a single dash
83
+ followed only by letters, so `-f`/`-fq` still match and no `--long-flag` can), and
84
+ force detection is scoped to the `git push` segment so a neighbouring command's
85
+ `-f` no longer implicates it (`rm -f stale.log && git push origin main`).
86
+
87
+ Fail-closed behaviour is unchanged and now asserted: an untokenizable command, and
88
+ a bare force-push whose target branch cannot be determined, still block.
89
+ `test/agent-guard.test.mjs` covers 30 cases across both directions.
90
+
91
+ ### Also
92
+
93
+ - Trees an older installer created and no current one manages are pruned on
94
+ install (`pruneAbandonedTrees`). `~/.claude/eval/` held 31 files that no
95
+ installer wrote and no uninstall removed - wipe-before-copy only protects trees
96
+ still being written, so an abandoned one persists forever.
97
+ - shellcheck in CI scans `git ls-files '*.sh'` instead of `find pipeline`, so a
98
+ shell script added outside `pipeline/` is no longer silently unchecked.
99
+
19
100
  ## [12.8.0] - 2026-07-26
20
101
 
21
102
  12.7.0 fixed nine gates that reported success without checking anything. This
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mmerterden/multi-agent-pipeline",
3
- "version": "12.8.0",
3
+ "version": "12.9.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",
@@ -0,0 +1,65 @@
1
+ # Phase 4 Review - Multi-Repo Mode
2
+
3
+ > Loaded on demand from `phases/phase-4-review.md`. Conditional on
4
+ > `state.projects[].length > 1`; a single-repo run (the common case) never needs
5
+ > it and used to pay for it on every review.
6
+
7
+
8
+ Active when `state.projects[].length > 1`. The 3-reviewer/triage architecture is unchanged - what changes is the diff that reviewers see.
9
+
10
+ **Combined diff assembly** - reviewers get **one combined diff** covering all repos, with explicit per-repo headers so they can flag cross-repo impact:
11
+
12
+ ```bash
13
+ COMBINED_DIFF=$(mktemp)
14
+ for proj in $(jq -r '.projects[] | "\(.name)\t\(.worktreePath)\t\(.baseBranch)"' "$STATE_FILE"); do
15
+ IFS=$'\t' read -r name wt base <<< "$proj"
16
+ ORIGIN=$(git -C "$wt" config --get remote.origin.url)
17
+ printf '\n=== repo: %s (origin: %s) ===\n' "$name" "$ORIGIN" >> "$COMBINED_DIFF"
18
+ printf 'Base: %s ... HEAD\n\n' "$base" >> "$COMBINED_DIFF"
19
+ git -C "$wt" diff "origin/$base...HEAD" >> "$COMBINED_DIFF"
20
+ done
21
+ ```
22
+
23
+ Same reviewer set (Fable-or-Opus / GPT-5.4 / Sonnet) receive `COMBINED_DIFF` with a multi-repo prefix in the system prompt:
24
+
25
+ ```
26
+ This is a multi-repo task spanning {N} repos: {repo names}.
27
+ Each repo's diff is prefixed with `=== repo: <name> ===`.
28
+ A finding may span repos (e.g. "common adds API X, but uicomponents calls X with wrong type").
29
+ For cross-repo findings, set `crossRepo: true` and list all impacted repos in `affectedRepos: []`.
30
+ ```
31
+
32
+ **Schema extension for findings** (additive, single-repo `crossRepo` defaults to false):
33
+
34
+ ```json
35
+ {
36
+ "severity": "blocking|important|suggestion",
37
+ "file": "uicomponents/Sources/Foo.swift",
38
+ "line": 42,
39
+ "issue": "...",
40
+ "fix": "...",
41
+ "crossRepo": false,
42
+ "affectedRepos": []
43
+ }
44
+ ```
45
+
46
+ `file` paths are repo-name-prefixed (`<repo-name>/<path-relative-to-repo>`) so triage and rework can resolve them back to the right worktree. The triage prompt receives `state.projects[]` so it can validate `affectedRepos` entries.
47
+
48
+ **Triage cross-check**: Cross-repo findings are NEVER auto-rejected - the cross-cutting nature is exactly what reviewers are best at catching. Triage may still defer (out-of-scope) but rejection requires explicit reasoning.
49
+
50
+ **Token-budget guard** (per `token-budget.json` Phase 4 allowance): if `COMBINED_DIFF` exceeds 80% of the budget, truncate the largest repo's diff with a footer header:
51
+ ```
52
+ [truncated - full diff in file://$WORKTREE/.review-diff.txt]
53
+ ```
54
+ And log `review.diff_truncated repo=<name> bytes_dropped=<N>`. Triage receives the same truncated view + the truncation marker so it can flag suggestions to "review the full diff manually."
55
+
56
+ **Deterministic gates per repo**: Step 1 gates (build/lint/test/secrets) run **per repo** - failure in any one repo blocks AI review for the whole task. Build queue lock applies as in Phase 3 (multi-repo doesn't change Xcode serialization).
57
+
58
+ **Telemetry**: Per-repo build/test gate timings + a single combined review/triage call set:
59
+ ```bash
60
+ pipeline/scripts/log-metric.sh "$TASK_ID" 4 gate.build repo=common status=pass duration_ms=$D
61
+ pipeline/scripts/log-metric.sh "$TASK_ID" 4 gate.build repo=uicomponents status=pass duration_ms=$D
62
+ pipeline/scripts/log-metric.sh "$TASK_ID" 4 review.combined_diff repos=2 bytes=$BYTES truncated=false
63
+ ```
64
+
65
+ ---
@@ -0,0 +1,93 @@
1
+ # Step 1b - URL Enrichment (Phase 0)
2
+
3
+ > Loaded on demand from `phases/phase-0-init.md` Step 1b. It lives here rather
4
+ > than inline because it applies only to a task that carries URLs: a bare Jira
5
+ > ID, a GitHub issue number, or a free-text task never needs any of it, yet every
6
+ > run used to pay for all 9.6 kB of it. Deep-fetch sub-steps 1b.1-1b.3 are
7
+ > themselves conditional on `state.contextLinks[]` carrying that link type.
8
+
9
+
10
+ After Step 1 fetches the Jira description (or free-text input), every URL in the text gets classified and catalogued. Targeted deep fetches (Crashlytics, Fortify) run when the matching link type is present; the rest (Swagger, Confluence, Figma, generic doc) are catalogued so Phase 1 (Analysis) can consume them via the typed fetchers documented in `$HOME/.claude/multi-agent-refs/channels/*.md`.
11
+
12
+ ##### Step 1b.0 - Extract context links (always runs)
13
+
14
+ Pipe the description text through `~/.claude/lib/context-link-extractor.sh` and store the result on the state:
15
+
16
+ ```bash
17
+ printf '%s' "$DESCRIPTION_TEXT" \
18
+ | ~/.claude/lib/context-link-extractor.sh \
19
+ > /tmp/context-links-${TASK_ID}.json
20
+
21
+ # Persist into agent-state.json under state.contextLinks
22
+ jq --slurpfile links /tmp/context-links-${TASK_ID}.json \
23
+ '.contextLinks = $links[0]' \
24
+ "$STATE_FILE" > "$STATE_FILE.tmp" && mv "$STATE_FILE.tmp" "$STATE_FILE"
25
+ ```
26
+
27
+ Output schema (one array entry per URL - full contract in `~/.claude/lib/context-link-extractor.sh`):
28
+
29
+ ```jsonc
30
+ "contextLinks": [
31
+ { "type": "swagger", "url": "<url>", "metadata": { "format": "json|yaml|unknown" } },
32
+ { "type": "confluence", "url": "<url>", "metadata": { "host": "<host>", "pageId": "<id|null>", "spaceKey": "<key|null>" } },
33
+ { "type": "crashlytics", "url": "<url>", "metadata": { "projectId": "...", "platform": "ios|android", "bundle": "...", "issueId": "...", "sessionId": "<id|null>" } },
34
+ { "type": "fortify", "url": "<url>", "metadata": { "host": "<host>", "projectId": "<id|null>" } },
35
+ { "type": "graylog", "url": null, "metadata": { "idType": "trx|conversation", "id": "...", "label": "..." } },
36
+ { "type": "figma", "url": "<url>", "metadata": { "kind": "design|make|board|slides", "fileKey": "...", "nodeId": "<id|null>" } },
37
+ { "type": "generic-doc", "url": "<url>", "metadata": { "host": "<host>", "kind": "notion|google-docs|gist|github-doc" } }
38
+ ]
39
+ ```
40
+
41
+ The catalogue is the single source of truth for downstream phases - they read `state.contextLinks[]` and dispatch per `type`. The extractor is deterministic: same input → same output, no network calls, no auth.
42
+
43
+ ##### Step 1b.1 - Firebase Crashlytics deep fetch (runs when `type == "crashlytics"` present in `state.contextLinks[]`)
44
+
45
+ URL pattern (caught by the extractor): `console.firebase.google.com/(u/[0-9]+/)?project/<projectId>/crashlytics/app/(ios|android)(:|%3A)<bundleOrPackage>/issues/<issueId>(/sessions/<sessionId>)?`
46
+
47
+ - Host is fixed (`console.firebase.google.com`); no `hosts.firebase` pref needed.
48
+ - Token resolution: `prefs.global.keychainMapping.firebase` → `~/.claude/lib/credential-store.sh get "<key>" | base64 -d` → Service Account JSON. `project_id` read from decoded JSON - verify it matches the `<projectId>` from the URL; mismatch → warn, skip enrichment.
49
+ - Exchange the SA JSON for a short-lived GCP access token (scope `https://www.googleapis.com/auth/firebase https://www.googleapis.com/auth/cloud-platform`) using `google-auth` (Python) or an inline JWT exchange (OpenSSL sign → `https://oauth2.googleapis.com/token`). Cache in-memory for the pipeline run only - never persist.
50
+ - Fetch crash detail: `GET https://firebasecrashlytics.googleapis.com/v1alpha/projects/<projectId>/apps/<appId>/issues/<issueId>` (issue summary + top device + OS + impacted users + top stack frame). If `sessionId` present, also fetch `/sessions/<sessionId>` for the full session timeline.
51
+ - Store as `state.crashContext = { projectId, appId, issueId, title, subtitle, impactedUsers, topDevices[], topOS[], topStackFrame, sessionTimeline?, fetchedAt }`.
52
+ - Phase 1 Analysis prepends `state.crashContext` to the agent prompt under a **Known Crash Context** section (do not rediscover, use as ground truth).
53
+ - Soft-fail: any network/auth error → log warning, continue pipeline without enrichment (ticket still gets manually analysed).
54
+
55
+ ##### Step 1b.2 - Fortify SSC deep fetch (runs when `type == "fortify"` present in `state.contextLinks[]`)
56
+
57
+ URL pattern (caught by the extractor): `https://<prefs.global.hosts.fortify>/ssc/html/(ssc/)?version/<versionId>(/.*)?(#/version/<versionId>)?(/audit|/issues|/artifacts)?(\?issue=<issueId>)?` (plus the short-link variant `/ssc/html/ssc/version/<versionId>/fix/<issueInstanceId>`).
58
+
59
+ - Host comes from `prefs.global.hosts.fortify` (collected during Token Save Flow Step 3.5). URL host must match, else warn and skip (protects against pasting another company's Fortify URL by accident).
60
+ - Token resolution: `prefs.global.keychainMapping.fortify` → `~/.claude/lib/credential-store.sh get "<key>"` → API token. Auth header: `Authorization: FortifyToken <base64(token)>` (legacy tokens) or `Bearer <token>` (scoped tokens) - try Bearer first, fall back to FortifyToken on 401.
61
+ - Fetch finding detail (VPN required; respect `vpnServices.fortify`):
62
+ - Version: `GET /ssc/api/v1/projectVersions/<versionId>` → name, project name, current issue count.
63
+ - Issue list / single issue: if `issueInstanceId` in URL → `GET /ssc/api/v1/projectVersions/<versionId>/issues?q=issueInstanceId:"<id>"&fields=id,issueName,friority,fullFileName,lineNumber,analyzer,kingdom`. Otherwise fetch top open issue of the version.
64
+ - Issue details + recommendation: `GET /ssc/api/v1/issueDetails/<issueId>` → description, recommendation, code snippet (if the artifact is retained).
65
+ - Store as `state.fortifyFinding = { host, versionId, versionName, projectName, issueId?, issueName?, severity, friority, kingdom, analyzer, file, line, description, recommendation, codeSnippet?, fetchedAt }`.
66
+ - 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>`).
67
+ - 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" }`).
68
+
69
+ ##### Step 1b.3 - Graylog deep fetch (runs when `type == "graylog"` present in `state.contextLinks[]`)
70
+
71
+ 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.
72
+
73
+ - 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.
74
+ - 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.
75
+ - 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.
76
+ - Store as `state.graylogContext = { idType, id, messages[], fetchedAt }` (or an array keyed per id when several were extracted).
77
+ - 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).
78
+ - 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.
79
+
80
+ ##### Step 1b.4 - Other link types (catalogued only at Phase 0; fetched at Phase 1)
81
+
82
+ 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."
83
+
84
+ **Jira project key addendum**: Support multi-tenant Jira - `prefs.projects[{project}].jiraProjectKeys[]` is already a list. When Step 1 extracts a Jira key from input, `upsert` it at the head (dedup, cap 10). Sibling keys for the same project (e.g. one key per team or tracker board) coexist; `prefs.global.defaultJiraKey` is the fallback for ambiguous free-text input only.
85
+
86
+ **Log line shape** (progress contract):
87
+
88
+ ```
89
+ → context links: total=<N>, by-type={swagger:<n>, confluence:<n>, crashlytics:<n>, fortify:<n>, graylog:<n>, figma:<n>, generic-doc:<n>}
90
+ → URL deep fetch: crashlytics=<hit|miss|skipped>, fortify=<hit|miss|skipped>, graylog=<hit|miss|skipped>
91
+ ```
92
+
93
+ 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.
@@ -200,90 +200,9 @@ Sequential prompts (standard UX pattern with Recent suggestion): Project Key →
200
200
 
201
201
  #### Step 1b - URL Enrichment (catalogue + targeted deep fetches)
202
202
 
203
- After Step 1 fetches the Jira description (or free-text input), every URL in the text gets classified and catalogued. Targeted deep fetches (Crashlytics, Fortify) run when the matching link type is present; the rest (Swagger, Confluence, Figma, generic doc) are catalogued so Phase 1 (Analysis) can consume them via the typed fetchers documented in `$HOME/.claude/multi-agent-refs/channels/*.md`.
203
+ **Runs only when Step 1 found at least one URL in the task input** (Jira/Confluence/Figma/Swagger/Crashlytics/Fortify/Graylog links). Otherwise skip straight to Step 2.
204
204
 
205
- ##### Step 1b.0 - Extract context links (always runs)
206
-
207
- Pipe the description text through `~/.claude/lib/context-link-extractor.sh` and store the result on the state:
208
-
209
- ```bash
210
- printf '%s' "$DESCRIPTION_TEXT" \
211
- | ~/.claude/lib/context-link-extractor.sh \
212
- > /tmp/context-links-${TASK_ID}.json
213
-
214
- # Persist into agent-state.json under state.contextLinks
215
- jq --slurpfile links /tmp/context-links-${TASK_ID}.json \
216
- '.contextLinks = $links[0]' \
217
- "$STATE_FILE" > "$STATE_FILE.tmp" && mv "$STATE_FILE.tmp" "$STATE_FILE"
218
- ```
219
-
220
- Output schema (one array entry per URL - full contract in `~/.claude/lib/context-link-extractor.sh`):
221
-
222
- ```jsonc
223
- "contextLinks": [
224
- { "type": "swagger", "url": "<url>", "metadata": { "format": "json|yaml|unknown" } },
225
- { "type": "confluence", "url": "<url>", "metadata": { "host": "<host>", "pageId": "<id|null>", "spaceKey": "<key|null>" } },
226
- { "type": "crashlytics", "url": "<url>", "metadata": { "projectId": "...", "platform": "ios|android", "bundle": "...", "issueId": "...", "sessionId": "<id|null>" } },
227
- { "type": "fortify", "url": "<url>", "metadata": { "host": "<host>", "projectId": "<id|null>" } },
228
- { "type": "graylog", "url": null, "metadata": { "idType": "trx|conversation", "id": "...", "label": "..." } },
229
- { "type": "figma", "url": "<url>", "metadata": { "kind": "design|make|board|slides", "fileKey": "...", "nodeId": "<id|null>" } },
230
- { "type": "generic-doc", "url": "<url>", "metadata": { "host": "<host>", "kind": "notion|google-docs|gist|github-doc" } }
231
- ]
232
- ```
233
-
234
- The catalogue is the single source of truth for downstream phases - they read `state.contextLinks[]` and dispatch per `type`. The extractor is deterministic: same input → same output, no network calls, no auth.
235
-
236
- ##### Step 1b.1 - Firebase Crashlytics deep fetch (runs when `type == "crashlytics"` present in `state.contextLinks[]`)
237
-
238
- URL pattern (caught by the extractor): `console.firebase.google.com/(u/[0-9]+/)?project/<projectId>/crashlytics/app/(ios|android)(:|%3A)<bundleOrPackage>/issues/<issueId>(/sessions/<sessionId>)?`
239
-
240
- - Host is fixed (`console.firebase.google.com`); no `hosts.firebase` pref needed.
241
- - Token resolution: `prefs.global.keychainMapping.firebase` → `~/.claude/lib/credential-store.sh get "<key>" | base64 -d` → Service Account JSON. `project_id` read from decoded JSON - verify it matches the `<projectId>` from the URL; mismatch → warn, skip enrichment.
242
- - Exchange the SA JSON for a short-lived GCP access token (scope `https://www.googleapis.com/auth/firebase https://www.googleapis.com/auth/cloud-platform`) using `google-auth` (Python) or an inline JWT exchange (OpenSSL sign → `https://oauth2.googleapis.com/token`). Cache in-memory for the pipeline run only - never persist.
243
- - Fetch crash detail: `GET https://firebasecrashlytics.googleapis.com/v1alpha/projects/<projectId>/apps/<appId>/issues/<issueId>` (issue summary + top device + OS + impacted users + top stack frame). If `sessionId` present, also fetch `/sessions/<sessionId>` for the full session timeline.
244
- - Store as `state.crashContext = { projectId, appId, issueId, title, subtitle, impactedUsers, topDevices[], topOS[], topStackFrame, sessionTimeline?, fetchedAt }`.
245
- - Phase 1 Analysis prepends `state.crashContext` to the agent prompt under a **Known Crash Context** section (do not rediscover, use as ground truth).
246
- - Soft-fail: any network/auth error → log warning, continue pipeline without enrichment (ticket still gets manually analysed).
247
-
248
- ##### Step 1b.2 - Fortify SSC deep fetch (runs when `type == "fortify"` present in `state.contextLinks[]`)
249
-
250
- URL pattern (caught by the extractor): `https://<prefs.global.hosts.fortify>/ssc/html/(ssc/)?version/<versionId>(/.*)?(#/version/<versionId>)?(/audit|/issues|/artifacts)?(\?issue=<issueId>)?` (plus the short-link variant `/ssc/html/ssc/version/<versionId>/fix/<issueInstanceId>`).
251
-
252
- - Host comes from `prefs.global.hosts.fortify` (collected during Token Save Flow Step 3.5). URL host must match, else warn and skip (protects against pasting another company's Fortify URL by accident).
253
- - Token resolution: `prefs.global.keychainMapping.fortify` → `~/.claude/lib/credential-store.sh get "<key>"` → API token. Auth header: `Authorization: FortifyToken <base64(token)>` (legacy tokens) or `Bearer <token>` (scoped tokens) - try Bearer first, fall back to FortifyToken on 401.
254
- - Fetch finding detail (VPN required; respect `vpnServices.fortify`):
255
- - Version: `GET /ssc/api/v1/projectVersions/<versionId>` → name, project name, current issue count.
256
- - Issue list / single issue: if `issueInstanceId` in URL → `GET /ssc/api/v1/projectVersions/<versionId>/issues?q=issueInstanceId:"<id>"&fields=id,issueName,friority,fullFileName,lineNumber,analyzer,kingdom`. Otherwise fetch top open issue of the version.
257
- - Issue details + recommendation: `GET /ssc/api/v1/issueDetails/<issueId>` → description, recommendation, code snippet (if the artifact is retained).
258
- - Store as `state.fortifyFinding = { host, versionId, versionName, projectName, issueId?, issueName?, severity, friority, kingdom, analyzer, file, line, description, recommendation, codeSnippet?, fetchedAt }`.
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>`).
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" }`).
261
-
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)
274
-
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."
276
-
277
- **Jira project key addendum**: Support multi-tenant Jira - `prefs.projects[{project}].jiraProjectKeys[]` is already a list. When Step 1 extracts a Jira key from input, `upsert` it at the head (dedup, cap 10). Sibling keys for the same project (e.g. one key per team or tracker board) coexist; `prefs.global.defaultJiraKey` is the fallback for ambiguous free-text input only.
278
-
279
- **Log line shape** (progress contract):
280
-
281
- ```
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>
284
- ```
285
-
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.
205
+ When it runs, load `$HOME/.claude/multi-agent-refs/features/url-enrichment.md` and follow it. It covers: 1b.0 extract + catalogue every link into `state.contextLinks[]` (always runs when this step runs), then the deep fetches that are each conditional on their link type being present - 1b.1 Crashlytics -> `state.crashContext`, 1b.2 Fortify SSC -> `state.fortifyFinding`, 1b.3 Graylog, 1b.4 catalogue-only types (fetched at Phase 1). It also owns the two closing log lines and the Phase 1 / Phase 2 prepend contracts.
287
206
 
288
207
  #### Step 2 - Project Selection
289
208
 
@@ -474,64 +474,7 @@ Log: "Phase 4: Review - raw={N1+N2} accepted={Na} deferred={Nd} rejected={Nr}
474
474
 
475
475
  #### Multi-Repo Mode
476
476
 
477
- Active when `state.projects[].length > 1`. The 3-reviewer/triage architecture is unchanged - what changes is the diff that reviewers see.
478
-
479
- **Combined diff assembly** - reviewers get **one combined diff** covering all repos, with explicit per-repo headers so they can flag cross-repo impact:
480
-
481
- ```bash
482
- COMBINED_DIFF=$(mktemp)
483
- for proj in $(jq -r '.projects[] | "\(.name)\t\(.worktreePath)\t\(.baseBranch)"' "$STATE_FILE"); do
484
- IFS=$'\t' read -r name wt base <<< "$proj"
485
- ORIGIN=$(git -C "$wt" config --get remote.origin.url)
486
- printf '\n=== repo: %s (origin: %s) ===\n' "$name" "$ORIGIN" >> "$COMBINED_DIFF"
487
- printf 'Base: %s ... HEAD\n\n' "$base" >> "$COMBINED_DIFF"
488
- git -C "$wt" diff "origin/$base...HEAD" >> "$COMBINED_DIFF"
489
- done
490
- ```
491
-
492
- Same reviewer set (Fable-or-Opus / GPT-5.4 / Sonnet) receive `COMBINED_DIFF` with a multi-repo prefix in the system prompt:
493
-
494
- ```
495
- This is a multi-repo task spanning {N} repos: {repo names}.
496
- Each repo's diff is prefixed with `=== repo: <name> ===`.
497
- A finding may span repos (e.g. "common adds API X, but uicomponents calls X with wrong type").
498
- For cross-repo findings, set `crossRepo: true` and list all impacted repos in `affectedRepos: []`.
499
- ```
500
-
501
- **Schema extension for findings** (additive, single-repo `crossRepo` defaults to false):
502
-
503
- ```json
504
- {
505
- "severity": "blocking|important|suggestion",
506
- "file": "uicomponents/Sources/Foo.swift",
507
- "line": 42,
508
- "issue": "...",
509
- "fix": "...",
510
- "crossRepo": false,
511
- "affectedRepos": []
512
- }
513
- ```
514
-
515
- `file` paths are repo-name-prefixed (`<repo-name>/<path-relative-to-repo>`) so triage and rework can resolve them back to the right worktree. The triage prompt receives `state.projects[]` so it can validate `affectedRepos` entries.
516
-
517
- **Triage cross-check**: Cross-repo findings are NEVER auto-rejected - the cross-cutting nature is exactly what reviewers are best at catching. Triage may still defer (out-of-scope) but rejection requires explicit reasoning.
518
-
519
- **Token-budget guard** (per `token-budget.json` Phase 4 allowance): if `COMBINED_DIFF` exceeds 80% of the budget, truncate the largest repo's diff with a footer header:
520
- ```
521
- [truncated - full diff in file://$WORKTREE/.review-diff.txt]
522
- ```
523
- And log `review.diff_truncated repo=<name> bytes_dropped=<N>`. Triage receives the same truncated view + the truncation marker so it can flag suggestions to "review the full diff manually."
524
-
525
- **Deterministic gates per repo**: Step 1 gates (build/lint/test/secrets) run **per repo** - failure in any one repo blocks AI review for the whole task. Build queue lock applies as in Phase 3 (multi-repo doesn't change Xcode serialization).
526
-
527
- **Telemetry**: Per-repo build/test gate timings + a single combined review/triage call set:
528
- ```bash
529
- pipeline/scripts/log-metric.sh "$TASK_ID" 4 gate.build repo=common status=pass duration_ms=$D
530
- pipeline/scripts/log-metric.sh "$TASK_ID" 4 gate.build repo=uicomponents status=pass duration_ms=$D
531
- pipeline/scripts/log-metric.sh "$TASK_ID" 4 review.combined_diff repos=2 bytes=$BYTES truncated=false
532
- ```
533
-
534
- ---
477
+ **Only when `state.projects[].length > 1`.** Load `$HOME/.claude/multi-agent-refs/features/review-multi-repo.md` and follow it: per-repo diff scoping, how one merged finding set is attributed back to the repo that owns each file, and the per-repo `buildStatus` contract. A single-repo run skips this section entirely.
535
478
 
536
479
  ## Token telemetry - invoke after every LLM call
537
480
 
@@ -25,7 +25,50 @@ ATTRIBUTION = re.compile(
25
25
  r"|claude code <",
26
26
  re.IGNORECASE,
27
27
  )
28
- FORCE = re.compile(r"(^|\s)(--force(\b|$)|--force-with-lease|-\S*f\S*)(\s|$)")
28
+ # Force flags, and ONLY force flags.
29
+ #
30
+ # The previous pattern ended in `-\S*f\S*`, which matches any flag containing an
31
+ # `f` anywhere - so it fired on `--ff-only`, `--follow-tags`, and `--file=...`.
32
+ # Two real false positives followed:
33
+ #
34
+ # git pull --ff-only && git push origin main -> BLOCKED
35
+ # git push --follow-tags origin main -> BLOCKED
36
+ #
37
+ # Neither rewrites history. `--follow-tags` is the normal way to push a release
38
+ # tag with its commit, so the guard blocked a routine release step. Being
39
+ # fail-closed is right for a data-loss guard, but only against the thing it
40
+ # guards; blocking safe commands trains people to work around the hook, which
41
+ # costs more safety than it buys.
42
+ #
43
+ # The short-flag arm is now `-[A-Za-z]*f[A-Za-z]*`: a SINGLE dash followed only by
44
+ # letters, so it still catches `-f`, `-fq`, `-qf`, and never a `--long-flag`
45
+ # (whose second character is `-`, not a letter).
46
+ FORCE = re.compile(
47
+ r"(^|\s)("
48
+ r"--force(-with-lease|-if-includes)?(=\S+)?" # --force, --force-with-lease[=ref], --force-if-includes
49
+ r"|-[A-Za-z]*f[A-Za-z]*" # -f and short clusters containing f
50
+ r")(\s|$)"
51
+ )
52
+
53
+
54
+ # Shell separators that end one command and start the next. Used to attribute a
55
+ # flag to the command it actually belongs to.
56
+ SEPARATORS = re.compile(r"&&|\|\||;|\||\n")
57
+
58
+
59
+ def push_segments(cmd: str) -> list:
60
+ """The sub-commands that invoke `git push`.
61
+
62
+ A flag must be attributed to its own command. Scanning the whole string made
63
+ `git commit -f ... && git push origin main` read as a force-push, because the
64
+ `-f` belonged to the commit. Splitting on shell separators first keeps each
65
+ command's flags with that command.
66
+
67
+ Deliberately coarse: a separator inside a quoted string splits too, which can
68
+ only ever produce MORE segments to inspect, never fewer - the safe direction
69
+ for a guard.
70
+ """
71
+ return [seg for seg in SEPARATORS.split(cmd) if re.search(r"git\s+push", seg.lower())]
29
72
 
30
73
 
31
74
  def decide(cmd: str) -> str:
@@ -35,8 +78,19 @@ def decide(cmd: str) -> str:
35
78
  if re.search(r"git\s+commit", low) and ATTRIBUTION.search(cmd):
36
79
  return "BLOCK_ATTRIB"
37
80
 
38
- # Rule 2: force-push to a protected branch.
39
- if re.search(r"git\s+push", low) and FORCE.search(cmd):
81
+ # Rule 2: force-push to a protected branch. Evaluate each push segment on its
82
+ # own so a neighbouring command's flags cannot implicate it.
83
+ for segment in push_segments(cmd):
84
+ verdict = _decide_push(segment)
85
+ if verdict != "OK":
86
+ return verdict
87
+
88
+ return "OK"
89
+
90
+
91
+ def _decide_push(cmd: str) -> str:
92
+ """Verdict for a single `git push ...` command."""
93
+ if FORCE.search(cmd):
40
94
  try:
41
95
  toks = shlex.split(cmd)
42
96
  except Exception: