@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 +81 -0
- package/package.json +1 -1
- package/pipeline/multi-agent-refs/features/review-multi-repo.md +65 -0
- package/pipeline/multi-agent-refs/features/url-enrichment.md +93 -0
- package/pipeline/multi-agent-refs/phases/phase-0-init.md +2 -83
- package/pipeline/multi-agent-refs/phases/phase-4-review.md +1 -58
- package/pipeline/scripts/agent-guard.py +57 -3
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|