@mmerterden/multi-agent-pipeline 12.8.0 → 12.10.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 +134 -0
- package/install/_common.mjs +27 -6
- 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/pipeline/scripts/smoke-schema-validation.sh +33 -7
package/CHANGELOG.md
CHANGED
|
@@ -16,6 +16,140 @@ Internal file-layout changes that don't affect the slash-command surface are sti
|
|
|
16
16
|
|
|
17
17
|
## [Unreleased]
|
|
18
18
|
|
|
19
|
+
## [12.10.0] - 2026-07-26
|
|
20
|
+
|
|
21
|
+
Two pieces of abandoned residue, and a gate that had inverted.
|
|
22
|
+
|
|
23
|
+
### `~/.multi-agent/` is pruned on install
|
|
24
|
+
|
|
25
|
+
The "shared runtime" the Cursor / Antigravity / VS Code Copilot Chat adapters
|
|
26
|
+
needed, so their emitted agents could reach the gate scripts by absolute path.
|
|
27
|
+
Those adapters were deleted in v10.7.0 along with `installSharedRuntime`,
|
|
28
|
+
`_base.mjs`, `rewriteScriptRefs` and `smoke-shared-runtime.sh` - but not the tree
|
|
29
|
+
they had written. 222 files (174 scripts, 23 lib, 24 schemas) sat frozen at
|
|
30
|
+
whatever the last adapter-era install produced.
|
|
31
|
+
|
|
32
|
+
It is worse than dead weight because it is indistinguishable from a live install
|
|
33
|
+
when you look at it: same directory names, same file names. Its
|
|
34
|
+
`schemas/migrations/` is missing `prefs-2.3.0-to-2.4.0.mjs`, so reading it gives a
|
|
35
|
+
migration chain three versions short and a reasonable conclusion that the chain
|
|
36
|
+
itself is stale.
|
|
37
|
+
|
|
38
|
+
`ABANDONED_TREES` now takes `root: "home"` entries for trees beside `~/.claude`
|
|
39
|
+
rather than inside it, and 253 files (this plus the 31 in `~/.claude/eval`) are
|
|
40
|
+
removed on the next install.
|
|
41
|
+
|
|
42
|
+
### The live-prefs gate was rejecting the only correct state
|
|
43
|
+
|
|
44
|
+
`smoke-schema-validation.sh` step 7 read:
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
if [ "$LVER" = "2.1.0" ]; then pass "live prefs already v2.1.0"
|
|
48
|
+
elif ... else fail "live prefs has unknown schemaVersion: $LVER"
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
`migrate-prefs.mjs` `TARGET_VERSION` had since moved to 2.4.0, which inverted the
|
|
52
|
+
gate: a prefs file left behind at 2.1.0 passed as "already current", and a file
|
|
53
|
+
correctly migrated to 2.4.0 fell through to the else arm and FAILED as "unknown".
|
|
54
|
+
The gate rejected the fully-migrated state it exists to encourage.
|
|
55
|
+
|
|
56
|
+
It now reads the target from `migrate-prefs.mjs` and the accepted set from the
|
|
57
|
+
schema's own `schemaVersion` enum, so it cannot disagree with either again, and a
|
|
58
|
+
known-but-older version reports how far behind it is (`v2.1.0 -> v2.4.0`) instead
|
|
59
|
+
of reading as current. This is a CONSUMER smoke (`/multi-agent:update` runs it), so
|
|
60
|
+
it resolves both paths through `SMOKE_DIR`, which is `pipeline/scripts` in the repo
|
|
61
|
+
and `~/.claude/scripts` in an install.
|
|
62
|
+
|
|
63
|
+
### Dead-file sweep
|
|
64
|
+
|
|
65
|
+
Swept refs, schemas, agents, libs and scripts for files nothing references. No
|
|
66
|
+
orphans. The two candidates were both false positives from the sweep's own exclude
|
|
67
|
+
flags: `design-check-config.schema.json` is referenced as the instance filename by
|
|
68
|
+
the design-check command, and `count-lib.sh` is sourced by two siblings inside
|
|
69
|
+
`pipeline/lib/`, which the sweep had excluded. Recorded here so the next sweep does
|
|
70
|
+
not re-flag them.
|
|
71
|
+
|
|
72
|
+
## [12.9.0] - 2026-07-26
|
|
73
|
+
|
|
74
|
+
Context engineering follow-through, and a guard that was blocking safe commands.
|
|
75
|
+
|
|
76
|
+
### Phase docs load their conditional sections on demand
|
|
77
|
+
|
|
78
|
+
The two biggest phase docs carried the UNION of every branch, so a run paid for
|
|
79
|
+
paths it never took. Both were over their own warn thresholds.
|
|
80
|
+
|
|
81
|
+
- `phase-0-init.md` Step 1b (URL Enrichment, 9.6 kB) applies only when the task
|
|
82
|
+
input carries URLs. A bare Jira ID, a GitHub issue number or a free-text task
|
|
83
|
+
never needs it. Moved to `multi-agent-refs/features/url-enrichment.md` behind a
|
|
84
|
+
761-byte stub that states the run condition. Phase 0: 11701 -> 9469 tokens.
|
|
85
|
+
- `phase-4-review.md` Multi-Repo Mode (3.0 kB) applies only when
|
|
86
|
+
`state.projects[].length > 1`. Moved to `features/review-multi-repo.md`.
|
|
87
|
+
Phase 4: 12070 -> 11048 tokens.
|
|
88
|
+
|
|
89
|
+
Total phase-doc budget 50955 -> 48047 tokens, and both docs are now under warn
|
|
90
|
+
rather than over. `smoke-url-enrichment.sh` follows the content to the ref and
|
|
91
|
+
additionally asserts the phase doc still POINTS at it: a JIT reference nothing
|
|
92
|
+
loads is worse than the inline copy it replaced.
|
|
93
|
+
|
|
94
|
+
### The always-on description surface is budgeted
|
|
95
|
+
|
|
96
|
+
Progressive disclosure defers reference BODIES, but a skill or command
|
|
97
|
+
`description` sits in the host's listing for the whole session whether or not the
|
|
98
|
+
skill is ever invoked. That is a second fixed load, it was LARGER than the
|
|
99
|
+
budgeted one (76918 bytes across 236 descriptions), and nothing capped it.
|
|
100
|
+
`smoke-context-budget.sh` now gates the total and the per-description average
|
|
101
|
+
(the total can stay under budget while every description bloats), reports the
|
|
102
|
+
whole pre-task cost as one number, and `test/context-budget-gate.test.mjs` pins
|
|
103
|
+
all three ceilings as literals.
|
|
104
|
+
|
|
105
|
+
### No prose block loads twice
|
|
106
|
+
|
|
107
|
+
`smoke-context-duplication.sh` hashes normalised prose blocks across the files
|
|
108
|
+
that can co-load in one run. 12.7.0 removed three sections the orchestrator had
|
|
109
|
+
inlined from `multi-agent-refs/` - one already stale, naming a path the code no
|
|
110
|
+
longer used - found by hand with nothing to prevent a recurrence.
|
|
111
|
+
|
|
112
|
+
Scope mattered more than the algorithm. A first draft scanned the command and
|
|
113
|
+
skill trees too and reported 10+ duplicates, every one legitimate: the
|
|
114
|
+
`commands/multi-agent/X` vs `skills/shared/core/multi-agent-X` pairs are the
|
|
115
|
+
cross-CLI parity contract, which `cross-cli-contract.md` REQUIRES byte-identical,
|
|
116
|
+
and the mode commands repeat prose but only one loads per run. A gate that fires
|
|
117
|
+
on deliberate design trains people to ignore it. Narrowed to the ref tree plus the
|
|
118
|
+
orchestrator, it found one real case: three pickers each restating the Language
|
|
119
|
+
matrix that `rules.md` already carries on every run - and the copies had already
|
|
120
|
+
drifted, two saying "Commit/PR/Jira/Wiki" and one "Commit/PR/Jira". Replaced with
|
|
121
|
+
a one-line pointer, 678 bytes reclaimed.
|
|
122
|
+
|
|
123
|
+
### agent-guard stopped blocking safe pushes
|
|
124
|
+
|
|
125
|
+
The force-push detector ended in `-\S*f\S*`, matching any flag containing an `f`:
|
|
126
|
+
|
|
127
|
+
```
|
|
128
|
+
git pull --ff-only && git push origin main -> BLOCKED
|
|
129
|
+
git push --follow-tags origin main -> BLOCKED
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
Neither rewrites history, and `--follow-tags` is the normal way to push a release
|
|
133
|
+
tag with its commit, so the guard blocked a routine release step. False positives
|
|
134
|
+
are not free: people learn to route around the hook, which loses more safety than
|
|
135
|
+
the guard buys. The short-flag arm is now `-[A-Za-z]*f[A-Za-z]*` (a single dash
|
|
136
|
+
followed only by letters, so `-f`/`-fq` still match and no `--long-flag` can), and
|
|
137
|
+
force detection is scoped to the `git push` segment so a neighbouring command's
|
|
138
|
+
`-f` no longer implicates it (`rm -f stale.log && git push origin main`).
|
|
139
|
+
|
|
140
|
+
Fail-closed behaviour is unchanged and now asserted: an untokenizable command, and
|
|
141
|
+
a bare force-push whose target branch cannot be determined, still block.
|
|
142
|
+
`test/agent-guard.test.mjs` covers 30 cases across both directions.
|
|
143
|
+
|
|
144
|
+
### Also
|
|
145
|
+
|
|
146
|
+
- Trees an older installer created and no current one manages are pruned on
|
|
147
|
+
install (`pruneAbandonedTrees`). `~/.claude/eval/` held 31 files that no
|
|
148
|
+
installer wrote and no uninstall removed - wipe-before-copy only protects trees
|
|
149
|
+
still being written, so an abandoned one persists forever.
|
|
150
|
+
- shellcheck in CI scans `git ls-files '*.sh'` instead of `find pipeline`, so a
|
|
151
|
+
shell script added outside `pipeline/` is no longer silently unchecked.
|
|
152
|
+
|
|
19
153
|
## [12.8.0] - 2026-07-26
|
|
20
154
|
|
|
21
155
|
12.7.0 fixed nine gates that reported success without checking anything. This
|
package/install/_common.mjs
CHANGED
|
@@ -252,25 +252,46 @@ export function pruneLegacyMultiAgentSkills(skillsDir) {
|
|
|
252
252
|
* neither did uninstall - 31 stale files sat there with nothing left that could
|
|
253
253
|
* ever read or remove them.
|
|
254
254
|
*
|
|
255
|
-
*
|
|
255
|
+
* `~/.multi-agent/` is the larger case: the "shared runtime" the Cursor /
|
|
256
|
+
* Antigravity / VS Code Copilot Chat adapters needed, so their emitted agents
|
|
257
|
+
* could reach the gate scripts by absolute path. Those adapters were deleted in
|
|
258
|
+
* v10.7.0 along with `installSharedRuntime`, `_base.mjs`, `rewriteScriptRefs` and
|
|
259
|
+
* `smoke-shared-runtime.sh` - but not the tree they wrote. 221 files (174 scripts,
|
|
260
|
+
* 23 lib, 24 schemas) frozen at whatever the last adapter-era install produced,
|
|
261
|
+
* including a migrations directory missing `prefs-2.3.0-to-2.4.0.mjs`. Browsing it
|
|
262
|
+
* looks exactly like browsing a current install, which is how it comes to be
|
|
263
|
+
* mistaken for one.
|
|
264
|
+
*
|
|
265
|
+
* `root: "home"` entries sit beside `~/.claude`, not inside it.
|
|
266
|
+
*
|
|
267
|
+
* @type {ReadonlyArray<{dir: string, root?: "claude"|"home", reason: string}>}
|
|
256
268
|
*/
|
|
257
269
|
export const ABANDONED_TREES = Object.freeze([
|
|
258
270
|
{
|
|
259
271
|
dir: "eval",
|
|
260
272
|
reason: "eval corpora; the harnesses that read them are maintainer-only and no longer ship",
|
|
261
273
|
},
|
|
274
|
+
{
|
|
275
|
+
dir: ".multi-agent",
|
|
276
|
+
root: "home",
|
|
277
|
+
reason:
|
|
278
|
+
"shared runtime for the Cursor / Antigravity / Copilot Chat adapters, all deleted in v10.7.0",
|
|
279
|
+
},
|
|
262
280
|
]);
|
|
263
281
|
|
|
264
282
|
/**
|
|
265
|
-
* Remove trees an older install left behind
|
|
283
|
+
* Remove trees an older install left behind.
|
|
266
284
|
*
|
|
267
|
-
* @param {string}
|
|
285
|
+
* @param {string} claudeDir e.g. `$HOME/.claude`
|
|
286
|
+
* @param {string} [home] `$HOME`, for entries marked `root: "home"`. Defaults to
|
|
287
|
+
* the parent of `claudeDir`, which is the layout every installer uses.
|
|
268
288
|
* @returns {number} directories removed
|
|
269
289
|
*/
|
|
270
|
-
export function pruneAbandonedTrees(
|
|
290
|
+
export function pruneAbandonedTrees(claudeDir, home = dirname(claudeDir)) {
|
|
271
291
|
let removed = 0;
|
|
272
|
-
for (const { dir, reason } of ABANDONED_TREES) {
|
|
273
|
-
const
|
|
292
|
+
for (const { dir, root, reason } of ABANDONED_TREES) {
|
|
293
|
+
const base = root === "home" ? home : claudeDir;
|
|
294
|
+
const target = join(base, dir);
|
|
274
295
|
if (!existsSync(target)) continue;
|
|
275
296
|
if (dryRun) {
|
|
276
297
|
console.log(` [dry-run] would remove abandoned tree ${target} (${reason})`);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mmerterden/multi-agent-pipeline",
|
|
3
|
-
"version": "12.
|
|
3
|
+
"version": "12.10.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:
|
|
@@ -174,14 +174,40 @@ echo ""
|
|
|
174
174
|
echo "→ 7. Live preferences (if exists) - post-migration check"
|
|
175
175
|
if [ -f "$LIVE_PREFS" ]; then
|
|
176
176
|
LVER=$(node -e "console.log(JSON.parse(require('fs').readFileSync('$LIVE_PREFS','utf8')).schemaVersion || 'none')" 2>/dev/null)
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
177
|
+
|
|
178
|
+
# The target is read from migrate-prefs.mjs, never hardcoded here.
|
|
179
|
+
#
|
|
180
|
+
# This block used to say `if [ "$LVER" = "2.1.0" ]; then pass "already v2.1.0"`
|
|
181
|
+
# with everything else falling through to `fail "unknown schemaVersion"`. The
|
|
182
|
+
# migration target had since moved to 2.4.0, which inverted the gate: a prefs
|
|
183
|
+
# file left behind at 2.1.0 reported PASS as "already current", while a file
|
|
184
|
+
# correctly migrated to 2.4.0 hit the else arm and FAILED as "unknown". The
|
|
185
|
+
# gate was rejecting the only fully-migrated state it exists to encourage.
|
|
186
|
+
#
|
|
187
|
+
# Reading the target from the migrator, and the accepted set from the schema
|
|
188
|
+
# enum, means this can never disagree with them again.
|
|
189
|
+
MIGRATOR="$SMOKE_DIR/migrate-prefs.mjs"
|
|
190
|
+
TARGET=$(grep -oE 'TARGET_VERSION = "[0-9.]+"' "$MIGRATOR" 2>/dev/null | grep -oE '[0-9]+\.[0-9]+\.[0-9]+')
|
|
191
|
+
KNOWN=$(node -e "
|
|
192
|
+
const s = require('$PREFS_SCHEMA');
|
|
193
|
+
process.stdout.write((s.properties.schemaVersion.enum || []).join(' '));
|
|
194
|
+
" 2>/dev/null)
|
|
195
|
+
|
|
196
|
+
if [ -z "$TARGET" ]; then
|
|
197
|
+
fail "cannot read TARGET_VERSION from migrate-prefs.mjs - the check has no reference point"
|
|
198
|
+
elif [ "$LVER" = "$TARGET" ]; then
|
|
199
|
+
pass "live prefs at the migration target (v$TARGET)"
|
|
200
|
+
elif [ "$LVER" = "none" ]; then
|
|
201
|
+
echo " ⓘ live prefs carries no schemaVersion - migration pending"
|
|
202
|
+
pass "live prefs present, needs migration (schemaVersion: none)"
|
|
203
|
+
elif printf '%s\n' $KNOWN | grep -qxF "$LVER"; then
|
|
204
|
+
# A known-but-older version is legitimately "migration pending", not a
|
|
205
|
+
# failure: migration is its own install step. Say how far behind it is, so a
|
|
206
|
+
# file sitting three versions back is visible instead of reading as current.
|
|
207
|
+
echo " ⓘ live prefs at v$LVER, target v$TARGET - migration pending"
|
|
208
|
+
pass "live prefs present, needs migration (v$LVER -> v$TARGET)"
|
|
183
209
|
else
|
|
184
|
-
fail "live prefs has
|
|
210
|
+
fail "live prefs has a schemaVersion the schema does not know: $LVER (known: $KNOWN)"
|
|
185
211
|
fi
|
|
186
212
|
else
|
|
187
213
|
echo " ⓘ no live prefs at $LIVE_PREFS - ok for fresh install"
|