@mmerterden/multi-agent-pipeline 11.4.0 → 11.5.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 +118 -0
- package/README.md +1 -0
- package/index.js +9 -2
- package/install/_common.mjs +107 -5
- package/install/_telemetry.mjs +5 -23
- package/install/claude.mjs +70 -10
- package/install/copilot.mjs +75 -6
- package/package.json +4 -10
- package/pipeline/commands/multi-agent/dev-local/SKILL.md +1 -1
- package/pipeline/lib/account-resolver.sh +61 -23
- package/pipeline/lib/channels-multi-repo.sh +7 -2
- package/pipeline/lib/count-lib.sh +27 -0
- package/pipeline/lib/credential-store.sh +23 -6
- package/pipeline/lib/extract-conventions.sh +27 -21
- package/pipeline/lib/fetch-confluence.sh +25 -13
- package/pipeline/lib/fetch-crashlytics.sh +30 -8
- package/pipeline/lib/fetch-fortify.sh +11 -2
- package/pipeline/lib/fetch-swagger.sh +18 -7
- package/pipeline/lib/figma-mcp-refresh.sh +26 -11
- package/pipeline/lib/figma-screenshot.sh +11 -2
- package/pipeline/lib/issue-fetcher.sh +31 -6
- package/pipeline/lib/multi-repo-pipeline.sh +84 -20
- package/pipeline/lib/plan-todos.sh +12 -3
- package/pipeline/lib/post-pr-review.sh +5 -3
- package/pipeline/lib/repo-cache.sh +53 -9
- package/pipeline/lib/review-watch.sh +26 -6
- package/pipeline/lib/shadow-git.sh +45 -4
- package/pipeline/lib/vercel-deploy.sh +10 -1
- package/pipeline/scripts/audit-log-rotate.sh +38 -10
- package/pipeline/scripts/check-md-links.mjs +34 -9
- package/pipeline/scripts/diff-risk-score.mjs +4 -1
- package/pipeline/scripts/evidence-gate.mjs +10 -1
- package/pipeline/scripts/fixtures/install-layout.tsv +4 -4
- package/pipeline/scripts/fixtures/pack-expected-count.txt +1 -0
- package/pipeline/scripts/keychain-save.sh +13 -9
- package/pipeline/scripts/lint-skills.mjs +40 -2
- package/pipeline/scripts/migrate-prefs.mjs +26 -7
- package/pipeline/scripts/phase-tracker.sh +71 -0
- package/pipeline/scripts/pre-commit-check.sh +39 -0
- package/pipeline/scripts/scan-skills.sh +217 -127
- package/pipeline/scripts/smoke-bitbucket-contract.sh +21 -5
- package/pipeline/scripts/smoke-fetchers-offline.sh +382 -0
- package/pipeline/scripts/smoke-install-layout.sh +11 -3
- package/pipeline/scripts/smoke-lib-scripts.sh +54 -1
- package/pipeline/scripts/smoke-pack-contents.sh +140 -0
- package/pipeline/scripts/smoke-plugin-validate.sh +64 -0
- package/pipeline/scripts/smoke-shadow-git.sh +48 -1
- package/pipeline/scripts/smoke-skill-authoring.sh +1 -1
- package/pipeline/scripts/smoke-workflow-audit.sh +69 -0
- package/pipeline/scripts/test-gap-scan.mjs +12 -1
- package/pipeline/scripts/triage-memory.mjs +10 -1
- package/pipeline/scripts/uninstall.mjs +105 -36
- package/pipeline/scripts/update-issue-progress.sh +60 -41
- package/pipeline/scripts/write-state.mjs +14 -4
|
@@ -124,22 +124,40 @@ except Exception:
|
|
|
124
124
|
fi
|
|
125
125
|
[ -z "$TOKEN_KEY" ] && TOKEN_KEY="${USER}_Firebase_Access_Json"
|
|
126
126
|
|
|
127
|
-
|
|
127
|
+
# Locate the credential helper via the resolver so Copilot-only installs work.
|
|
128
|
+
# shellcheck disable=SC1090,SC1091
|
|
129
|
+
. "$HOME/.claude/lib/credential-store-resolver.sh" 2>/dev/null \
|
|
130
|
+
|| . "$HOME/.copilot/lib/credential-store-resolver.sh" 2>/dev/null \
|
|
131
|
+
|| { printf '%s\n' '{"status":"blocked","reason":"missing-credential-helper","service":"firebase","expected_key":"'"$TOKEN_KEY"'"}' >&2; exit 2; }
|
|
132
|
+
|
|
133
|
+
SA_JSON_B64=$("$CRED_STORE" get "$TOKEN_KEY" 2>/dev/null || true)
|
|
128
134
|
if [ -z "$SA_JSON_B64" ]; then
|
|
129
135
|
printf '%s\n' '{"status":"blocked","reason":"missing-token","service":"firebase","expected_key":"'"$TOKEN_KEY"'"}' >&2
|
|
130
136
|
exit 2
|
|
131
137
|
fi
|
|
132
138
|
|
|
133
139
|
# The credential store may return raw JSON or base64-encoded JSON (the user's
|
|
134
|
-
# setup convention). Try base64-decode first, fall back to raw.
|
|
140
|
+
# setup convention). Try base64-decode first, fall back to raw. The decoded
|
|
141
|
+
# payload contains a GCP private key, so it goes into a 0600 mktemp file with
|
|
142
|
+
# cleanup traps registered BEFORE the secret is written.
|
|
143
|
+
SA_TMP=""
|
|
144
|
+
cleanup_sa_tmp() {
|
|
145
|
+
if [ -n "$SA_TMP" ]; then rm -f "$SA_TMP"; fi
|
|
146
|
+
}
|
|
147
|
+
trap cleanup_sa_tmp EXIT
|
|
148
|
+
trap 'cleanup_sa_tmp; exit 130' INT
|
|
149
|
+
trap 'cleanup_sa_tmp; exit 143' TERM
|
|
150
|
+
SA_TMP=$(umask 077; mktemp "${TMPDIR:-/tmp}/fc-sa.XXXXXX")
|
|
151
|
+
|
|
135
152
|
SA_JSON=""
|
|
136
|
-
if printf '%s' "$SA_JSON_B64" | base64 -d >
|
|
137
|
-
&& python3 -c "import json; json.load(open(
|
|
138
|
-
SA_JSON=$(cat
|
|
153
|
+
if printf '%s' "$SA_JSON_B64" | base64 -d > "$SA_TMP" 2>/dev/null \
|
|
154
|
+
&& python3 -c "import json, sys; json.load(open(sys.argv[1]))" "$SA_TMP" 2>/dev/null; then
|
|
155
|
+
SA_JSON=$(cat "$SA_TMP")
|
|
139
156
|
else
|
|
140
157
|
SA_JSON="$SA_JSON_B64"
|
|
141
158
|
fi
|
|
142
|
-
rm -f
|
|
159
|
+
rm -f "$SA_TMP"
|
|
160
|
+
SA_TMP=""
|
|
143
161
|
|
|
144
162
|
# Verify project_id from SA JSON matches the URL - protects against pasting a
|
|
145
163
|
# crash URL from another GCP project than the keychain's SA covers.
|
|
@@ -235,6 +253,10 @@ if [ -z "$ACCESS_TOKEN" ]; then
|
|
|
235
253
|
exit 3
|
|
236
254
|
fi
|
|
237
255
|
|
|
256
|
+
# Bearer auth via a curl config fed through process substitution so the token
|
|
257
|
+
# never appears in argv (argv is visible to ps).
|
|
258
|
+
crashlytics_auth_cfg() { printf 'header = "Authorization: Bearer %s"\n' "$ACCESS_TOKEN"; }
|
|
259
|
+
|
|
238
260
|
# Construct the appId in the form Firebase expects:
|
|
239
261
|
# ios: ios:<bundleId> → api: /projects/<n>/apps/<bundleId> (looked up via list)
|
|
240
262
|
# android: android:<package>
|
|
@@ -245,7 +267,7 @@ APP_REF="$PLATFORM:$BUNDLE"
|
|
|
245
267
|
ISSUE_URL_API="https://firebasecrashlytics.googleapis.com/v1alpha/projects/$PROJECT_ID/apps/$APP_REF/issues/$ISSUE_ID"
|
|
246
268
|
|
|
247
269
|
ISSUE_JSON=$(curl -sS --fail --max-time "$TIMEOUT" --connect-timeout 5 \
|
|
248
|
-
-
|
|
270
|
+
-K <(crashlytics_auth_cfg) -H "Accept: application/json" \
|
|
249
271
|
"$ISSUE_URL_API" 2>/dev/null || true)
|
|
250
272
|
|
|
251
273
|
if [ -z "$ISSUE_JSON" ]; then
|
|
@@ -257,7 +279,7 @@ SESSION_JSON=""
|
|
|
257
279
|
if [ -n "$SESSION_ID" ]; then
|
|
258
280
|
SESSION_URL_API="$ISSUE_URL_API/sessions/$SESSION_ID"
|
|
259
281
|
SESSION_JSON=$(curl -sS --fail --max-time "$TIMEOUT" --connect-timeout 5 \
|
|
260
|
-
-
|
|
282
|
+
-K <(crashlytics_auth_cfg) -H "Accept: application/json" \
|
|
261
283
|
"$SESSION_URL_API" 2>/dev/null || true)
|
|
262
284
|
fi
|
|
263
285
|
|
|
@@ -128,7 +128,13 @@ except Exception:
|
|
|
128
128
|
fi
|
|
129
129
|
[ -z "$TOKEN_KEY" ] && TOKEN_KEY="${USER}_Fortify_Access_Token"
|
|
130
130
|
|
|
131
|
-
|
|
131
|
+
# Locate the credential helper via the resolver so Copilot-only installs work.
|
|
132
|
+
# shellcheck disable=SC1090,SC1091
|
|
133
|
+
. "$HOME/.claude/lib/credential-store-resolver.sh" 2>/dev/null \
|
|
134
|
+
|| . "$HOME/.copilot/lib/credential-store-resolver.sh" 2>/dev/null \
|
|
135
|
+
|| { printf '%s\n' '{"status":"blocked","reason":"missing-credential-helper","service":"fortify","expected_key":"'"$TOKEN_KEY"'"}' >&2; exit 2; }
|
|
136
|
+
|
|
137
|
+
TOKEN=$("$CRED_STORE" get "$TOKEN_KEY" 2>/dev/null || true)
|
|
132
138
|
if [ -z "$TOKEN" ]; then
|
|
133
139
|
printf '%s\n' '{"status":"blocked","reason":"missing-token","service":"fortify","expected_key":"'"$TOKEN_KEY"'"}' >&2
|
|
134
140
|
exit 2
|
|
@@ -153,8 +159,11 @@ fortify_get() {
|
|
|
153
159
|
else
|
|
154
160
|
hdr="Authorization: FortifyToken $(printf '%s' "$TOKEN" | base64 | tr -d '\n')"
|
|
155
161
|
fi
|
|
162
|
+
# Auth header goes through a curl config via process substitution so the
|
|
163
|
+
# token never appears in argv (argv is visible to ps).
|
|
156
164
|
resp=$(curl -sS --max-time "$TIMEOUT" --connect-timeout 5 -w "\n%{http_code}" \
|
|
157
|
-
-
|
|
165
|
+
-K <(printf 'header = "%s"\n' "$hdr") \
|
|
166
|
+
-H "Accept: application/json" "$url" 2>/dev/null || true)
|
|
158
167
|
http=$(printf '%s' "$resp" | tail -n1)
|
|
159
168
|
if [ "$http" = "200" ]; then
|
|
160
169
|
printf '%s' "$resp" | sed '$d'
|
|
@@ -57,13 +57,21 @@ trap 'rm -f "$TMP_RAW"' EXIT
|
|
|
57
57
|
# platform; we let curl run with --output then check the size).
|
|
58
58
|
CURL_ARGS=(-sS --fail --max-time "$TIMEOUT" --connect-timeout 5 --location --output "$TMP_RAW" \
|
|
59
59
|
--write-out "%{http_code}\n%{content_type}\n%{size_download}\n")
|
|
60
|
-
if [ -n "${SWAGGER_AUTH_HEADER:-}" ]; then
|
|
61
|
-
CURL_ARGS+=(-H "$SWAGGER_AUTH_HEADER")
|
|
62
|
-
fi
|
|
63
60
|
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
61
|
+
# The optional auth header may carry a token, so it is fed through a curl
|
|
62
|
+
# config via process substitution instead of argv (argv is visible to ps).
|
|
63
|
+
swagger_auth_cfg() { printf 'header = "%s"\n' "$SWAGGER_AUTH_HEADER"; }
|
|
64
|
+
|
|
65
|
+
if [ -n "${SWAGGER_AUTH_HEADER:-}" ]; then
|
|
66
|
+
META=$(curl "${CURL_ARGS[@]}" -K <(swagger_auth_cfg) "$URL" 2>/dev/null) || {
|
|
67
|
+
echo "ERR: swagger fetch failed for $URL" >&2
|
|
68
|
+
exit 2
|
|
69
|
+
}
|
|
70
|
+
else
|
|
71
|
+
META=$(curl "${CURL_ARGS[@]}" "$URL" 2>/dev/null) || {
|
|
72
|
+
echo "ERR: swagger fetch failed for $URL" >&2
|
|
73
|
+
exit 2
|
|
74
|
+
}
|
|
67
75
|
fi
|
|
68
76
|
|
|
69
77
|
HTTP_CODE=$(printf '%s\n' "$META" | sed -n '1p')
|
|
@@ -88,7 +96,10 @@ case "$LOWER_CT" in
|
|
|
88
96
|
*yaml*|*yml*) FORMAT="yaml" ;;
|
|
89
97
|
*json*) FORMAT="json" ;;
|
|
90
98
|
*)
|
|
91
|
-
case
|
|
99
|
+
# Lowercase via tr: the bash-4 case-conversion expansion is not
|
|
100
|
+
# available on macOS /bin/bash 3.2.
|
|
101
|
+
LOWER_URL=$(printf '%s' "$URL" | tr '[:upper:]' '[:lower:]')
|
|
102
|
+
case "$LOWER_URL" in
|
|
92
103
|
*.yaml|*.yml) FORMAT="yaml" ;;
|
|
93
104
|
*) FORMAT="json" ;;
|
|
94
105
|
esac
|
|
@@ -44,16 +44,20 @@ CLIENT_ID=$(jq -r '.client_id // empty' "$OAUTH_CONFIG")
|
|
|
44
44
|
CLIENT_SECRET=$(jq -r '.client_secret // empty' "$OAUTH_CONFIG")
|
|
45
45
|
[ -n "$CLIENT_ID" ] || { say "figma-mcp-refresh: client_id missing in .figma-oauth.json"; exit 2; }
|
|
46
46
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
47
|
+
# Refresh token + client secret go through a curl config fed via process
|
|
48
|
+
# substitution so they never appear in argv (argv is visible to ps).
|
|
49
|
+
refresh_grant_cfg() {
|
|
50
|
+
printf 'data-urlencode = "grant_type=refresh_token"\n'
|
|
51
|
+
printf 'data-urlencode = "refresh_token=%s"\n' "$REFRESH_TOKEN"
|
|
52
|
+
printf 'data-urlencode = "client_id=%s"\n' "$CLIENT_ID"
|
|
53
|
+
if [ -n "$CLIENT_SECRET" ]; then
|
|
54
|
+
printf 'data-urlencode = "client_secret=%s"\n' "$CLIENT_SECRET"
|
|
55
|
+
fi
|
|
56
|
+
}
|
|
53
57
|
|
|
54
58
|
RESPONSE=$(curl -s --max-time 20 -X POST "$TOKEN_ENDPOINT" \
|
|
55
59
|
-H "Content-Type: application/x-www-form-urlencoded" \
|
|
56
|
-
|
|
60
|
+
-K <(refresh_grant_cfg))
|
|
57
61
|
|
|
58
62
|
NEW_ACCESS=$(printf '%s' "$RESPONSE" | jq -r '.access_token // empty' 2>/dev/null || true)
|
|
59
63
|
NEW_REFRESH=$(printf '%s' "$RESPONSE" | jq -r '.refresh_token // empty' 2>/dev/null || true)
|
|
@@ -65,14 +69,25 @@ if [ -z "$NEW_ACCESS" ]; then
|
|
|
65
69
|
exit 1
|
|
66
70
|
fi
|
|
67
71
|
|
|
72
|
+
# Keychain write: the add-generic-password command is fed to `security -i`
|
|
73
|
+
# over stdin instead of the direct `security add-generic-password -w <secret>`
|
|
74
|
+
# argv form, so the secret never shows up in process listings. printf is a
|
|
75
|
+
# bash builtin, so no argv exposure there either. Attributes match the
|
|
76
|
+
# previous direct call (-T "" -U). Figma tokens are URL-safe, so embedding in
|
|
77
|
+
# the quoted command string is safe.
|
|
78
|
+
save_keychain_secret() {
|
|
79
|
+
local service="$1" secret="$2"
|
|
80
|
+
security delete-generic-password -s "$service" -a "$ACCOUNT" >/dev/null 2>&1 || true
|
|
81
|
+
printf 'add-generic-password -U -s "%s" -a "%s" -T "" -w "%s"\n' \
|
|
82
|
+
"$service" "$ACCOUNT" "$secret" | security -i >/dev/null
|
|
83
|
+
}
|
|
84
|
+
|
|
68
85
|
# Save new access token first, then the rotated refresh token - order matters so a
|
|
69
86
|
# mid-write failure never leaves us with a lost refresh token AND a stale access token.
|
|
70
|
-
|
|
71
|
-
security add-generic-password -s "$KEY_NAME" -a "$ACCOUNT" -w "$NEW_ACCESS" -T "" -U
|
|
87
|
+
save_keychain_secret "$KEY_NAME" "$NEW_ACCESS"
|
|
72
88
|
|
|
73
89
|
if [ -n "$NEW_REFRESH" ]; then
|
|
74
|
-
|
|
75
|
-
security add-generic-password -s "$REFRESH_KEY" -a "$ACCOUNT" -w "$NEW_REFRESH" -T "" -U
|
|
90
|
+
save_keychain_secret "$REFRESH_KEY" "$NEW_REFRESH"
|
|
76
91
|
fi
|
|
77
92
|
|
|
78
93
|
say "figma-mcp-refresh: renewed '$KEY_NAME' (expires_in=${EXPIRES_IN}s, refresh $( [ -n "$NEW_REFRESH" ] && echo rotated || echo unchanged ))"
|
|
@@ -42,7 +42,14 @@ set -euo pipefail
|
|
|
42
42
|
# --- Globals ----------------------------------------------------------------
|
|
43
43
|
|
|
44
44
|
PREFS="$HOME/.claude/multi-agent-preferences.json"
|
|
45
|
-
|
|
45
|
+
# Locate the credential helper via the resolver so Copilot-only installs work.
|
|
46
|
+
# The helper is optional here: the FIGMA_PAT env fallback still applies when
|
|
47
|
+
# no helper is installed, so a resolver miss must not abort the script.
|
|
48
|
+
# shellcheck disable=SC1090,SC1091
|
|
49
|
+
. "$HOME/.claude/lib/credential-store-resolver.sh" 2>/dev/null \
|
|
50
|
+
|| . "$HOME/.copilot/lib/credential-store-resolver.sh" 2>/dev/null \
|
|
51
|
+
|| true
|
|
52
|
+
CRED_STORE="${CRED_STORE:-}"
|
|
46
53
|
FIGMA_API="https://api.figma.com/v1"
|
|
47
54
|
PARALLEL_DL=4
|
|
48
55
|
HTTP_TIMEOUT=60
|
|
@@ -170,9 +177,11 @@ curl_figma() {
|
|
|
170
177
|
local out="$1" url="$2"
|
|
171
178
|
local code attempt sleep_s
|
|
172
179
|
for attempt in 1 2 3; do
|
|
180
|
+
# Token header goes through a curl config via process substitution so it
|
|
181
|
+
# never appears in argv (argv is visible to ps).
|
|
173
182
|
code=$(curl -sS -o "$out" -w '%{http_code}' \
|
|
174
183
|
--max-time "$HTTP_TIMEOUT" --connect-timeout 5 \
|
|
175
|
-
-
|
|
184
|
+
-K <(printf 'header = "X-Figma-Token: %s"\n' "$FIGMA_TOKEN") \
|
|
176
185
|
"$url" || echo "000")
|
|
177
186
|
case "$code" in
|
|
178
187
|
2*) printf '%s' "$code"; return 0 ;;
|
|
@@ -49,11 +49,21 @@ PROMPT_LANG="${PROMPT_LANG:-tr}"
|
|
|
49
49
|
detect_kind() {
|
|
50
50
|
local in="$1"
|
|
51
51
|
case "$in" in
|
|
52
|
-
*atlassian.net/browse/*|*jira*/browse/*|http*://*/browse/*) echo "jira-url" ;;
|
|
53
|
-
*github.com/*/issues/*) echo "gh-url" ;;
|
|
54
|
-
[A-Z]*-[0-9]*) echo "jira-id" ;;
|
|
52
|
+
*atlassian.net/browse/*|*jira*/browse/*|http*://*/browse/*) echo "jira-url"; return ;;
|
|
53
|
+
*github.com/*/issues/*) echo "gh-url"; return ;;
|
|
54
|
+
[A-Z]*-[0-9]*) echo "jira-id"; return ;;
|
|
55
|
+
esac
|
|
56
|
+
# Bare issue number ("316" or "#316") must resolve BEFORE the repo#N glob:
|
|
57
|
+
# `*\#[0-9]*` also matches "#316" with an empty repo prefix, which used to
|
|
58
|
+
# misroute it to gh-short and build https://github.com/<owner>//issues/316.
|
|
59
|
+
local num="${in#\#}"
|
|
60
|
+
case "$num" in
|
|
61
|
+
"") ;;
|
|
62
|
+
*[!0-9]*) ;;
|
|
63
|
+
*) echo "gh-num"; return ;;
|
|
64
|
+
esac
|
|
65
|
+
case "$in" in
|
|
55
66
|
*\#[0-9]*) echo "gh-short" ;;
|
|
56
|
-
[0-9]*) echo "gh-num" ;;
|
|
57
67
|
*) echo "freetext" ;;
|
|
58
68
|
esac
|
|
59
69
|
}
|
|
@@ -87,19 +97,34 @@ branch_for() {
|
|
|
87
97
|
}
|
|
88
98
|
|
|
89
99
|
# --- Provider fetchers (return raw JSON or empty) -----------------------------
|
|
100
|
+
|
|
101
|
+
# Bearer-auth via a curl config fed through process substitution so the
|
|
102
|
+
# token never appears in argv (argv is visible to `ps` / process audit).
|
|
103
|
+
jira_auth_cfg() { printf 'header = "Authorization: Bearer %s"\n' "$1"; }
|
|
104
|
+
|
|
90
105
|
fetch_jira() {
|
|
91
106
|
local key="$1"
|
|
92
107
|
if [ -z "$JIRA_HOST" ] || [ -z "$JIRA_TOKEN_KEY" ]; then
|
|
93
108
|
echo "ERR: ACCOUNT_JIRA_HOST and ACCOUNT_JIRA_TOKEN_KEY must be set" >&2
|
|
94
109
|
return 1
|
|
95
110
|
fi
|
|
111
|
+
# Resolve the credential helper via the shared resolver (works from the
|
|
112
|
+
# repo checkout and from both install trees) - never hardcode the path.
|
|
113
|
+
# A pre-set CRED_STORE (tests, custom installs) is honored as-is.
|
|
114
|
+
if [ -z "${CRED_STORE:-}" ]; then
|
|
115
|
+
# shellcheck disable=SC1090,SC1091
|
|
116
|
+
. "$(cd "$(dirname "$0")" && pwd)/credential-store-resolver.sh" 2>/dev/null \
|
|
117
|
+
|| . "$HOME/.claude/lib/credential-store-resolver.sh" 2>/dev/null \
|
|
118
|
+
|| . "$HOME/.copilot/lib/credential-store-resolver.sh" 2>/dev/null \
|
|
119
|
+
|| { echo "ERR: credential helper not found" >&2; return 1; }
|
|
120
|
+
fi
|
|
96
121
|
local token
|
|
97
|
-
token=$("$
|
|
122
|
+
token=$("$CRED_STORE" get "$JIRA_TOKEN_KEY" 2>/dev/null || true)
|
|
98
123
|
if [ -z "$token" ]; then
|
|
99
124
|
echo "ERR: Jira token not found in credential store ($JIRA_TOKEN_KEY)" >&2
|
|
100
125
|
return 1
|
|
101
126
|
fi
|
|
102
|
-
curl -sf -
|
|
127
|
+
curl -sf -K <(jira_auth_cfg "$token") \
|
|
103
128
|
"https://$JIRA_HOST/rest/api/2/issue/$key?fields=summary,status,issuetype,description,priority,resolution,fixVersions" \
|
|
104
129
|
|| { echo "ERR: Jira fetch failed for $key" >&2; return 1; }
|
|
105
130
|
}
|
|
@@ -81,11 +81,15 @@ cmd_prepare() {
|
|
|
81
81
|
}
|
|
82
82
|
|
|
83
83
|
cmd_commit() {
|
|
84
|
-
|
|
84
|
+
# Process substitution (not a pipe) so the `failed` counter survives the
|
|
85
|
+
# loop - a piped `while` runs in a subshell and the summary exit would
|
|
86
|
+
# always be 0 regardless of per-repo failures.
|
|
87
|
+
local failed=0
|
|
88
|
+
while IFS=$'\t' read -r name url provider; do
|
|
85
89
|
local target="$WORKTREE_ROOT/$name"
|
|
86
90
|
[ ! -d "$target" ] && continue
|
|
87
91
|
cd "$target"
|
|
88
|
-
# Use status --porcelain so untracked files count as "changes" too -
|
|
92
|
+
# Use status --porcelain so untracked files count as "changes" too -
|
|
89
93
|
# dev sessions usually add brand-new files alongside edits, and `git diff`
|
|
90
94
|
# alone misses them.
|
|
91
95
|
if [ -z "$(git status --porcelain 2>/dev/null)" ]; then
|
|
@@ -98,30 +102,45 @@ cmd_commit() {
|
|
|
98
102
|
[ -n "$ISSUE_KEY" ] && subject="$subject [$ISSUE_KEY]"
|
|
99
103
|
# Trim subject to 72 chars
|
|
100
104
|
subject=$(printf '%s' "$subject" | cut -c1-72)
|
|
101
|
-
git commit --quiet -m "$subject"
|
|
105
|
+
if git commit --quiet -m "$subject"; then
|
|
106
|
+
echo "[$name] committed: $subject"
|
|
107
|
+
else
|
|
108
|
+
echo "ERR: [$name] commit failed" >&2
|
|
109
|
+
failed=$((failed + 1))
|
|
110
|
+
fi
|
|
102
111
|
cd - >/dev/null
|
|
103
|
-
|
|
104
|
-
|
|
112
|
+
done < <(list_repos_jq)
|
|
113
|
+
if [ "$failed" -gt 0 ]; then
|
|
114
|
+
echo "ERR: commit failed in $failed repo(s)" >&2
|
|
115
|
+
return 3
|
|
116
|
+
fi
|
|
105
117
|
}
|
|
106
118
|
|
|
107
119
|
cmd_push() {
|
|
108
|
-
|
|
120
|
+
local failed=0
|
|
121
|
+
while IFS=$'\t' read -r name url provider; do
|
|
109
122
|
local target="$WORKTREE_ROOT/$name"
|
|
110
123
|
[ ! -d "$target" ] && continue
|
|
111
124
|
cd "$target"
|
|
112
|
-
git push --quiet -u origin "$BRANCH"
|
|
125
|
+
if git push --quiet -u origin "$BRANCH"; then
|
|
126
|
+
echo "[$name] pushed $BRANCH"
|
|
127
|
+
else
|
|
128
|
+
echo "ERR: [$name] push failed" >&2
|
|
129
|
+
failed=$((failed + 1))
|
|
130
|
+
fi
|
|
113
131
|
cd - >/dev/null
|
|
114
|
-
|
|
115
|
-
|
|
132
|
+
done < <(list_repos_jq)
|
|
133
|
+
if [ "$failed" -gt 0 ]; then
|
|
134
|
+
echo "ERR: push failed in $failed repo(s)" >&2
|
|
135
|
+
return 3
|
|
136
|
+
fi
|
|
116
137
|
}
|
|
117
138
|
|
|
118
139
|
cmd_pr() {
|
|
119
140
|
# Open PR for extras first, then primary with cross-links.
|
|
120
|
-
local primary_name
|
|
141
|
+
local primary_name failed=0
|
|
121
142
|
primary_name=$(python3 -c "import json; d=json.load(open('$STATE')); print(d['primary']['name'])")
|
|
122
|
-
# Extra-repo PR URLs are passed to the primary PR via the _prs.log file below
|
|
123
|
-
# not a shell var - the loop runs in a pipe subshell, so any accumulated var
|
|
124
|
-
# would be lost. The file-based handoff is intentional.
|
|
143
|
+
# Extra-repo PR URLs are passed to the primary PR via the _prs.log file below.
|
|
125
144
|
while IFS=$'\t' read -r name url provider; do
|
|
126
145
|
if [ "$name" = "$primary_name" ]; then continue; fi
|
|
127
146
|
local target="$WORKTREE_ROOT/$name"
|
|
@@ -130,8 +149,16 @@ cmd_pr() {
|
|
|
130
149
|
if [ "$provider" = "github" ] && command -v gh >/dev/null 2>&1; then
|
|
131
150
|
local body="Ref: $ISSUE_KEY
|
|
132
151
|
Part of multi-repo task $TASK_ID."
|
|
133
|
-
gh
|
|
134
|
-
|
|
152
|
+
# Capture output first: in `gh ... | tee ... || echo failed` the `||`
|
|
153
|
+
# binds to tee, so a gh failure was silently reported as success.
|
|
154
|
+
local pr_out
|
|
155
|
+
if pr_out=$(gh pr create --title "$COMMIT_TYPE($name): $ISSUE_TITLE" --body "$body" --fill 2>/dev/null); then
|
|
156
|
+
printf '%s\n' "$pr_out" | tee -a "$WORKTREE_ROOT/_prs.log"
|
|
157
|
+
echo "[$name] PR created"
|
|
158
|
+
else
|
|
159
|
+
echo "ERR: [$name] gh pr create failed" >&2
|
|
160
|
+
failed=$((failed + 1))
|
|
161
|
+
fi
|
|
135
162
|
else
|
|
136
163
|
echo "[$name] PR creation skipped (provider=$provider)"
|
|
137
164
|
fi
|
|
@@ -147,17 +174,54 @@ Part of multi-repo task $TASK_ID."
|
|
|
147
174
|
local body="Ref: $ISSUE_KEY
|
|
148
175
|
$extras_links"
|
|
149
176
|
if command -v gh >/dev/null 2>&1; then
|
|
150
|
-
gh pr create --title "$COMMIT_TYPE($primary_name): $ISSUE_TITLE" --body "$body" --fill 2>/dev/null
|
|
151
|
-
|
|
177
|
+
if gh pr create --title "$COMMIT_TYPE($primary_name): $ISSUE_TITLE" --body "$body" --fill 2>/dev/null; then
|
|
178
|
+
echo "[$primary_name] PR created"
|
|
179
|
+
else
|
|
180
|
+
echo "ERR: [$primary_name] gh pr create failed" >&2
|
|
181
|
+
failed=$((failed + 1))
|
|
182
|
+
fi
|
|
152
183
|
fi
|
|
153
184
|
cd - >/dev/null
|
|
185
|
+
if [ "$failed" -gt 0 ]; then
|
|
186
|
+
echo "ERR: PR creation failed for $failed repo(s)" >&2
|
|
187
|
+
return 3
|
|
188
|
+
fi
|
|
154
189
|
}
|
|
155
190
|
|
|
191
|
+
# Resolve a directory to its physical path ("" when it does not resolve).
|
|
192
|
+
resolve_dir() { (cd "$1" 2>/dev/null && pwd -P); }
|
|
193
|
+
|
|
156
194
|
cmd_teardown() {
|
|
157
|
-
if [ -d "$WORKTREE_ROOT" ]; then
|
|
158
|
-
echo "[teardown]
|
|
159
|
-
|
|
195
|
+
if [ ! -d "$WORKTREE_ROOT" ]; then
|
|
196
|
+
echo "[teardown] nothing to remove ($WORKTREE_ROOT not a directory)"
|
|
197
|
+
return 0
|
|
198
|
+
fi
|
|
199
|
+
# Guard the rm -rf: worktreeRoot comes from state.json, so a corrupted or
|
|
200
|
+
# malicious value ("." / ".." / "~") must never delete the project, the
|
|
201
|
+
# home directory, or the filesystem root. Resolve the physical path and
|
|
202
|
+
# refuse when it equals or contains the current project root or $HOME.
|
|
203
|
+
local rp cwd home_rp
|
|
204
|
+
rp=$(resolve_dir "$WORKTREE_ROOT") || rp=""
|
|
205
|
+
cwd=$(pwd -P)
|
|
206
|
+
home_rp=$(resolve_dir "$HOME") || home_rp="$HOME"
|
|
207
|
+
if [ -z "$rp" ] || [ "$rp" = "/" ] || [ "$rp" = "$home_rp" ] || [ "$rp" = "$cwd" ]; then
|
|
208
|
+
echo "ERR: refusing teardown - unsafe worktreeRoot '$WORKTREE_ROOT' (resolved: '${rp:-unresolvable}')" >&2
|
|
209
|
+
return 4
|
|
160
210
|
fi
|
|
211
|
+
case "$cwd/" in
|
|
212
|
+
"$rp"/*)
|
|
213
|
+
echo "ERR: refusing teardown - '$rp' contains the current project root" >&2
|
|
214
|
+
return 4
|
|
215
|
+
;;
|
|
216
|
+
esac
|
|
217
|
+
case "$home_rp/" in
|
|
218
|
+
"$rp"/*)
|
|
219
|
+
echo "ERR: refusing teardown - '$rp' contains the home directory" >&2
|
|
220
|
+
return 4
|
|
221
|
+
;;
|
|
222
|
+
esac
|
|
223
|
+
echo "[teardown] removing $rp (logs preserved at ~/.claude/logs/multi-agent/$TASK_ID/)"
|
|
224
|
+
rm -rf "$rp"
|
|
161
225
|
}
|
|
162
226
|
|
|
163
227
|
# Emit agent-state.json v2.1.0 from picker state.json so Phase 0 can consume it directly.
|
|
@@ -61,7 +61,10 @@ resolve_state() {
|
|
|
61
61
|
|
|
62
62
|
write_state() {
|
|
63
63
|
local path="$1" content="$2"
|
|
64
|
-
|
|
64
|
+
# mktemp NEXT TO the target: a $TMPDIR temp file can live on a different
|
|
65
|
+
# filesystem than $HOME, where mv degrades to copy+unlink and loses the
|
|
66
|
+
# atomic-rename guarantee.
|
|
67
|
+
local tmp; tmp=$(mktemp "${path}.XXXXXX")
|
|
65
68
|
printf '%s\n' "$content" > "$tmp"
|
|
66
69
|
mv "$tmp" "$path"
|
|
67
70
|
}
|
|
@@ -93,6 +96,12 @@ do_set() {
|
|
|
93
96
|
echo "plan-todos: input must be an object with .title (string) and .todos (array)" >&2
|
|
94
97
|
exit 2
|
|
95
98
|
fi
|
|
99
|
+
# Per-item validation: every todo needs a string .id and .task, otherwise
|
|
100
|
+
# downstream jq like `.id + "\t" + .task` crashes on (null + string).
|
|
101
|
+
if ! jq -e '.todos | all(type == "object" and (.id | type == "string") and (.task | type == "string"))' <<<"$plan_blob" >/dev/null 2>&1; then
|
|
102
|
+
echo "plan-todos: every todo must be an object with string .id and .task fields" >&2
|
|
103
|
+
exit 2
|
|
104
|
+
fi
|
|
96
105
|
local state_path; state_path=$(resolve_state "$task_id")
|
|
97
106
|
local now; now=$(iso_now)
|
|
98
107
|
local new
|
|
@@ -209,7 +218,7 @@ do_next() {
|
|
|
209
218
|
)
|
|
210
219
|
)
|
|
211
220
|
| (.[0] // empty)
|
|
212
|
-
| if . == null or . == "" then "" else .id + "\t" + .task end
|
|
221
|
+
| if . == null or . == "" then "" else (.id // "") + "\t" + (.task // "") end
|
|
213
222
|
' "$state_path"
|
|
214
223
|
}
|
|
215
224
|
|
|
@@ -229,7 +238,7 @@ do_list() {
|
|
|
229
238
|
skipped: "- [/]",
|
|
230
239
|
failed: "- [!]"
|
|
231
240
|
} as $marks
|
|
232
|
-
| ($marks[.status] // "- [ ]") + " **" + .id + "** " + .task
|
|
241
|
+
| ($marks[.status] // "- [ ]") + " **" + (.id // "") + "** " + (.task // "")
|
|
233
242
|
+ (if (.notes // "") != "" then " _(" + .notes + ")_" else "" end)
|
|
234
243
|
+ (if (.failureReason // "") != "" then " _**failed:** " + .failureReason + "_" else "" end)
|
|
235
244
|
+ (if (.skipReason // "") != "" then " _**skipped:** " + .skipReason + "_" else "" end))
|
|
@@ -75,9 +75,11 @@ ITERATION=$(jq -r '.review.iterationNumber // 1' "$AGENT_STATE")
|
|
|
75
75
|
# --- Decision ---------------------------------------------------------------
|
|
76
76
|
|
|
77
77
|
# Single jq pass - group accepted findings by severity into tab-separated counts.
|
|
78
|
+
# `.review.findings // []` so a null/absent findings array (clean review)
|
|
79
|
+
# cannot raise "Cannot iterate over null" under set -e.
|
|
78
80
|
read -r ACCEPTED_BLOCKING ACCEPTED_IMPORTANT < <(
|
|
79
81
|
jq -r '
|
|
80
|
-
[.review.findings[] | select(.status == "accepted")] as $a
|
|
82
|
+
[(.review.findings // [])[] | select(.status == "accepted")] as $a
|
|
81
83
|
| [($a | map(select(.severity == "blocking")) | length),
|
|
82
84
|
($a | map(select(.severity == "important")) | length)]
|
|
83
85
|
| @tsv
|
|
@@ -242,7 +244,7 @@ post_github() {
|
|
|
242
244
|
fi
|
|
243
245
|
|
|
244
246
|
if [ "$DECISION" = "needs_work" ]; then
|
|
245
|
-
jq -c '.review.findings[] | select(.status == "accepted" and (.severity == "blocking" or .severity == "important"))' "$AGENT_STATE" \
|
|
247
|
+
jq -c '(.review.findings // [])[] | select(.status == "accepted" and (.severity == "blocking" or .severity == "important"))' "$AGENT_STATE" \
|
|
246
248
|
| while read -r finding; do
|
|
247
249
|
local sev path line issue_b64 fix_b64 rule_id_b64 issue fix rule_id body fingerprint
|
|
248
250
|
IFS=$'\t' read -r sev path line issue_b64 fix_b64 rule_id_b64 < <(
|
|
@@ -333,7 +335,7 @@ post_bitbucket_server() {
|
|
|
333
335
|
fi
|
|
334
336
|
|
|
335
337
|
if [ "$DECISION" = "needs_work" ]; then
|
|
336
|
-
jq -c '.review.findings[] | select(.status == "accepted" and (.severity == "blocking" or .severity == "important"))' "$AGENT_STATE" \
|
|
338
|
+
jq -c '(.review.findings // [])[] | select(.status == "accepted" and (.severity == "blocking" or .severity == "important"))' "$AGENT_STATE" \
|
|
337
339
|
| while read -r finding; do
|
|
338
340
|
local sev path line issue_b64 fix_b64 rule_id_b64 issue fix rule_id body payload http fingerprint
|
|
339
341
|
IFS=$'\t' read -r sev path line issue_b64 fix_b64 rule_id_b64 < <(
|
|
@@ -59,6 +59,36 @@ if [ $REFRESH -eq 0 ] && cache_fresh; then
|
|
|
59
59
|
exit 0
|
|
60
60
|
fi
|
|
61
61
|
|
|
62
|
+
# Refresh staging: providers write to $CACHE_TMP, then commit_cache validates
|
|
63
|
+
# and mv's it into place. Writing straight to $CACHE_FILE truncated the cache
|
|
64
|
+
# BEFORE the fetch ran, so any failure left an empty file that cache_fresh
|
|
65
|
+
# then served for the whole TTL.
|
|
66
|
+
CACHE_TMP="${CACHE_FILE}.tmp.$$"
|
|
67
|
+
cleanup_tmp() { rm -f "$CACHE_TMP" "$CACHE_TMP.list" 2>/dev/null || true; }
|
|
68
|
+
trap cleanup_tmp EXIT
|
|
69
|
+
|
|
70
|
+
# Validate the staged file (non-empty valid JSON) and move it into place.
|
|
71
|
+
# On failure keep the previous cache and serve it with a warning; hard-fail
|
|
72
|
+
# only when there is no previous cache to fall back to.
|
|
73
|
+
commit_cache() {
|
|
74
|
+
if [ -s "$CACHE_TMP" ] \
|
|
75
|
+
&& python3 -c 'import json,sys; json.load(open(sys.argv[1]))' "$CACHE_TMP" 2>/dev/null; then
|
|
76
|
+
mv "$CACHE_TMP" "$CACHE_FILE"
|
|
77
|
+
return 0
|
|
78
|
+
fi
|
|
79
|
+
rm -f "$CACHE_TMP"
|
|
80
|
+
if [ -f "$CACHE_FILE" ]; then
|
|
81
|
+
echo "WARN: repo-cache refresh failed for $PROVIDER/$SCOPE; serving previous cache" >&2
|
|
82
|
+
return 0
|
|
83
|
+
fi
|
|
84
|
+
echo "ERR: repo-cache refresh failed for $PROVIDER/$SCOPE and no previous cache exists" >&2
|
|
85
|
+
exit 3
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
# Basic-auth via a curl config fed through process substitution so the
|
|
89
|
+
# credential never appears in argv (argv is visible to `ps`).
|
|
90
|
+
bb_auth_cfg() { printf 'user = "%s:%s"\n' "$1" "$2"; }
|
|
91
|
+
|
|
62
92
|
case "$PROVIDER" in
|
|
63
93
|
github)
|
|
64
94
|
if ! command -v gh >/dev/null 2>&1; then
|
|
@@ -100,20 +130,32 @@ for r in data:
|
|
|
100
130
|
"provider": "github"
|
|
101
131
|
})
|
|
102
132
|
print(json.dumps(out))
|
|
103
|
-
' > "$
|
|
133
|
+
' > "$CACHE_TMP" || true
|
|
134
|
+
commit_cache
|
|
104
135
|
;;
|
|
105
136
|
|
|
106
137
|
bitbucket)
|
|
107
138
|
: "${BB_TOKEN_KEY:?BB_TOKEN_KEY required for bitbucket}"
|
|
108
139
|
: "${BB_HOST:?BB_HOST required (e.g. bitbucket.example.com)}"
|
|
109
|
-
|
|
110
|
-
|
|
140
|
+
# Resolve the credential helper via the shared resolver (works from the
|
|
141
|
+
# repo checkout and from both install trees) - never hardcode the path.
|
|
142
|
+
# A pre-set CRED_STORE (tests, custom installs) is honored as-is.
|
|
143
|
+
if [ -z "${CRED_STORE:-}" ]; then
|
|
144
|
+
# shellcheck disable=SC1090,SC1091
|
|
145
|
+
. "$(cd "$(dirname "$0")" && pwd)/credential-store-resolver.sh" 2>/dev/null \
|
|
146
|
+
|| . "$HOME/.claude/lib/credential-store-resolver.sh" 2>/dev/null \
|
|
147
|
+
|| . "$HOME/.copilot/lib/credential-store-resolver.sh" 2>/dev/null \
|
|
148
|
+
|| { echo "ERR: credential helper not found" >&2; exit 3; }
|
|
149
|
+
fi
|
|
150
|
+
BB_USER="${BB_USER:-${BB_USER_KEY:+$("$CRED_STORE" get "$BB_USER_KEY" 2>/dev/null || true)}}"
|
|
151
|
+
BB_TOKEN=$("$CRED_STORE" get "$BB_TOKEN_KEY" 2>/dev/null || true)
|
|
111
152
|
if [ -z "$BB_TOKEN" ]; then
|
|
112
153
|
echo "ERR: bitbucket token not found in credential store ($BB_TOKEN_KEY)" >&2
|
|
113
154
|
exit 3
|
|
114
155
|
fi
|
|
115
156
|
# Bitbucket Server REST: /rest/api/1.0/projects/{key}/repos?limit=200
|
|
116
|
-
curl
|
|
157
|
+
# Auth via curl config file (-K) so the token never appears on argv.
|
|
158
|
+
curl -sf -K <(bb_auth_cfg "${BB_USER:-bot}" "$BB_TOKEN") \
|
|
117
159
|
"https://${BB_HOST}/rest/api/1.0/projects/${SCOPE}/repos?limit=200" \
|
|
118
160
|
| python3 -c '
|
|
119
161
|
import json,sys
|
|
@@ -147,7 +189,8 @@ for r in vals:
|
|
|
147
189
|
"provider": "bitbucket"
|
|
148
190
|
})
|
|
149
191
|
print(json.dumps(out))
|
|
150
|
-
' > "$
|
|
192
|
+
' > "$CACHE_TMP" || true
|
|
193
|
+
commit_cache
|
|
151
194
|
;;
|
|
152
195
|
|
|
153
196
|
local)
|
|
@@ -161,7 +204,7 @@ print(json.dumps(out))
|
|
|
161
204
|
if [ -d "$SCOPE" ]; then
|
|
162
205
|
ROOTS="$SCOPE"
|
|
163
206
|
fi
|
|
164
|
-
: > "$
|
|
207
|
+
: > "$CACHE_TMP.list"
|
|
165
208
|
for root in $ROOTS; do
|
|
166
209
|
[ -d "$root" ] || continue
|
|
167
210
|
# -prune avoids descending into already-found .git trees (nested submodules
|
|
@@ -178,7 +221,7 @@ print(json.dumps(out))
|
|
|
178
221
|
# truly offline-only or a clone with an upstream we just chose
|
|
179
222
|
# to treat as local.
|
|
180
223
|
remote=$(git -C "$repo_dir" remote get-url origin 2>/dev/null || true)
|
|
181
|
-
printf '%s\t%s\t%s\n' "$name" "$repo_dir" "$remote" >> "$
|
|
224
|
+
printf '%s\t%s\t%s\n' "$name" "$repo_dir" "$remote" >> "$CACHE_TMP.list"
|
|
182
225
|
done
|
|
183
226
|
done
|
|
184
227
|
python3 -c '
|
|
@@ -218,8 +261,9 @@ for raw in open(sys.argv[1]):
|
|
|
218
261
|
"provider": "local"
|
|
219
262
|
})
|
|
220
263
|
print(json.dumps(out))
|
|
221
|
-
' "$
|
|
222
|
-
rm -f "$
|
|
264
|
+
' "$CACHE_TMP.list" > "$CACHE_TMP" || true
|
|
265
|
+
rm -f "$CACHE_TMP.list"
|
|
266
|
+
commit_cache
|
|
223
267
|
;;
|
|
224
268
|
|
|
225
269
|
*)
|