@mmerterden/multi-agent-pipeline 11.3.2 → 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.
Files changed (73) hide show
  1. package/CHANGELOG.md +141 -0
  2. package/README.md +1 -0
  3. package/index.js +9 -2
  4. package/install/_common.mjs +107 -5
  5. package/install/_telemetry.mjs +5 -23
  6. package/install/claude.mjs +113 -11
  7. package/install/copilot.mjs +75 -6
  8. package/package.json +4 -10
  9. package/pipeline/commands/multi-agent/SKILL.md +5 -1
  10. package/pipeline/commands/multi-agent/dev-local/SKILL.md +1 -1
  11. package/pipeline/commands/multi-agent/help/SKILL.md +6 -2
  12. package/pipeline/commands/multi-agent/review/SKILL.md +36 -4
  13. package/pipeline/commands/multi-agent/review-issue/SKILL.md +22 -0
  14. package/pipeline/commands/multi-agent/review-jira/SKILL.md +22 -0
  15. package/pipeline/commands/multi-agent/sync/SKILL.md +4 -4
  16. package/pipeline/lib/account-resolver.sh +61 -23
  17. package/pipeline/lib/channels-multi-repo.sh +7 -2
  18. package/pipeline/lib/count-lib.sh +27 -0
  19. package/pipeline/lib/credential-store.sh +23 -6
  20. package/pipeline/lib/extract-conventions.sh +27 -21
  21. package/pipeline/lib/fetch-confluence.sh +25 -13
  22. package/pipeline/lib/fetch-crashlytics.sh +30 -8
  23. package/pipeline/lib/fetch-fortify.sh +11 -2
  24. package/pipeline/lib/fetch-swagger.sh +18 -7
  25. package/pipeline/lib/figma-mcp-refresh.sh +26 -11
  26. package/pipeline/lib/figma-screenshot.sh +11 -2
  27. package/pipeline/lib/issue-fetcher.sh +31 -6
  28. package/pipeline/lib/multi-repo-pipeline.sh +84 -20
  29. package/pipeline/lib/plan-todos.sh +12 -3
  30. package/pipeline/lib/post-pr-review.sh +5 -3
  31. package/pipeline/lib/repo-cache.sh +53 -9
  32. package/pipeline/lib/review-watch.sh +26 -6
  33. package/pipeline/lib/shadow-git.sh +45 -4
  34. package/pipeline/lib/vercel-deploy.sh +10 -1
  35. package/pipeline/multi-agent-refs/cross-cli-contract.md +4 -4
  36. package/pipeline/multi-agent-refs/readiness-review.md +65 -0
  37. package/pipeline/scripts/audit-log-rotate.sh +38 -10
  38. package/pipeline/scripts/check-md-links.mjs +34 -9
  39. package/pipeline/scripts/diff-risk-score.mjs +4 -1
  40. package/pipeline/scripts/evidence-gate.mjs +10 -1
  41. package/pipeline/scripts/fixtures/install-layout.tsv +7 -7
  42. package/pipeline/scripts/fixtures/pack-expected-count.txt +1 -0
  43. package/pipeline/scripts/keychain-save.sh +13 -9
  44. package/pipeline/scripts/lint-skills.mjs +40 -2
  45. package/pipeline/scripts/migrate-prefs.mjs +26 -7
  46. package/pipeline/scripts/phase-tracker.sh +71 -0
  47. package/pipeline/scripts/pre-commit-check.sh +39 -0
  48. package/pipeline/scripts/scan-skills.sh +217 -127
  49. package/pipeline/scripts/smoke-bitbucket-contract.sh +21 -5
  50. package/pipeline/scripts/smoke-fetchers-offline.sh +382 -0
  51. package/pipeline/scripts/smoke-generate-issue.sh +3 -3
  52. package/pipeline/scripts/smoke-install-layout.sh +11 -3
  53. package/pipeline/scripts/smoke-lib-scripts.sh +54 -1
  54. package/pipeline/scripts/smoke-pack-contents.sh +140 -0
  55. package/pipeline/scripts/smoke-plugin-validate.sh +64 -0
  56. package/pipeline/scripts/smoke-review-readiness.sh +91 -0
  57. package/pipeline/scripts/smoke-shadow-git.sh +48 -1
  58. package/pipeline/scripts/smoke-skill-authoring.sh +1 -1
  59. package/pipeline/scripts/smoke-workflow-audit.sh +69 -0
  60. package/pipeline/scripts/smoke-wrapper-preservation.sh +68 -0
  61. package/pipeline/scripts/test-gap-scan.mjs +12 -1
  62. package/pipeline/scripts/triage-memory.mjs +10 -1
  63. package/pipeline/scripts/uninstall.mjs +105 -36
  64. package/pipeline/scripts/update-issue-progress.sh +60 -41
  65. package/pipeline/scripts/write-state.mjs +14 -4
  66. package/pipeline/skills/.skill-manifest.json +13 -5
  67. package/pipeline/skills/.skills-index.json +20 -2
  68. package/pipeline/skills/shared/core/multi-agent-help/SKILL.md +6 -2
  69. package/pipeline/skills/shared/core/multi-agent-review/SKILL.md +19 -10
  70. package/pipeline/skills/shared/core/multi-agent-review-issue/SKILL.md +25 -0
  71. package/pipeline/skills/shared/core/multi-agent-review-jira/SKILL.md +25 -0
  72. package/pipeline/skills/shared/core/multi-agent-sync/SKILL.md +3 -3
  73. package/pipeline/skills/skills-index.md +4 -2
@@ -40,6 +40,12 @@ if ! command -v jq >/dev/null 2>&1; then
40
40
  exit 1
41
41
  fi
42
42
 
43
+ # count_matches: single-line numeric grep -c. The bare `grep -c || echo 0`
44
+ # idiom emits "0" twice on zero matches, which turns the $((...)) counter
45
+ # arithmetic below into a silent syntax error that zeroes the bucket.
46
+ # shellcheck source=count-lib.sh disable=SC1091
47
+ . "$(cd "$(dirname "$0")" && pwd)/count-lib.sh"
48
+
43
49
  # ---- global timing budget -----------------------------------------------------
44
50
  SCRIPT_START_EPOCH=$(date +%s)
45
51
  GLOBAL_BUDGET_SEC=180
@@ -672,16 +678,16 @@ bucket_test_method_naming() {
672
678
  [ -z "$f" ] && continue
673
679
  case "$PLATFORM" in
674
680
  ios|android)
675
- underscore_count=$((underscore_count + $(grep -cE 'func +test[A-Za-z0-9]+_[A-Za-z0-9_]+' "$f" 2>/dev/null || echo 0)))
676
- underscore_count=$((underscore_count + $(grep -cE 'fun +[A-Za-z0-9]+_[A-Za-z0-9_]+_[A-Za-z0-9_]+ *\(' "$f" 2>/dev/null || echo 0)))
677
- camel_count=$((camel_count + $(grep -cE 'func +test[A-Z][A-Za-z0-9]+ *\(' "$f" 2>/dev/null || echo 0)))
678
- backtick_count=$((backtick_count + $(grep -cE '`[^`]*`\s*\(' "$f" 2>/dev/null || echo 0)))
681
+ underscore_count=$((underscore_count + $(count_matches -E 'func +test[A-Za-z0-9]+_[A-Za-z0-9_]+' "$f")))
682
+ underscore_count=$((underscore_count + $(count_matches -E 'fun +[A-Za-z0-9]+_[A-Za-z0-9_]+_[A-Za-z0-9_]+ *\(' "$f")))
683
+ camel_count=$((camel_count + $(count_matches -E 'func +test[A-Z][A-Za-z0-9]+ *\(' "$f")))
684
+ backtick_count=$((backtick_count + $(count_matches -E '`[^`]*`\s*\(' "$f")))
679
685
  ;;
680
686
  backend)
681
- underscore_count=$((underscore_count + $(grep -cE '^def +test_[a-z0-9_]+' "$f" 2>/dev/null || echo 0)))
687
+ underscore_count=$((underscore_count + $(count_matches -E '^def +test_[a-z0-9_]+' "$f")))
682
688
  ;;
683
689
  frontend)
684
- it_count=$((it_count + $(grep -cE "(it|test)\\(['\"\`]" "$f" 2>/dev/null || echo 0)))
690
+ it_count=$((it_count + $(count_matches -E "(it|test)\\(['\"\`]" "$f")))
685
691
  ;;
686
692
  esac
687
693
  done <<< "$test_files"
@@ -745,9 +751,9 @@ bucket_accessibility_identifier() {
745
751
  if [ -z "$sample_ids" ]; then empty_bucket; return; fi
746
752
 
747
753
  local dot_count snake_count camel_count
748
- dot_count=$(printf '%s\n' "$sample_ids" | grep -cE '"[a-z]+(\.[a-zA-Z]+)+' 2>/dev/null || echo 0)
749
- snake_count=$(printf '%s\n' "$sample_ids" | grep -cE '"[a-z]+_[a-z_]+' 2>/dev/null || echo 0)
750
- camel_count=$(printf '%s\n' "$sample_ids" | grep -cE '"[a-z]+[A-Z][a-zA-Z]+' 2>/dev/null || echo 0)
754
+ dot_count=$(printf '%s\n' "$sample_ids" | count_matches -E '"[a-z]+(\.[a-zA-Z]+)+')
755
+ snake_count=$(printf '%s\n' "$sample_ids" | count_matches -E '"[a-z]+_[a-z_]+')
756
+ camel_count=$(printf '%s\n' "$sample_ids" | count_matches -E '"[a-z]+[A-Z][a-zA-Z]+')
751
757
  dot_count=${dot_count:-0}; snake_count=${snake_count:-0}; camel_count=${camel_count:-0}
752
758
 
753
759
  local evidence_json
@@ -772,9 +778,9 @@ bucket_accessibility_identifier() {
772
778
  done | head -30 || true)
773
779
  if [ -z "$ids" ]; then empty_bucket; return; fi
774
780
  local snake_count kebab_count camel_count
775
- snake_count=$(printf '%s\n' "$ids" | grep -cE '"[a-z]+_[a-z_]+' || echo 0)
776
- kebab_count=$(printf '%s\n' "$ids" | grep -cE '"[a-z]+-[a-z\-]+' || echo 0)
777
- camel_count=$(printf '%s\n' "$ids" | grep -cE '"[a-z]+[A-Z][a-zA-Z]+' || echo 0)
781
+ snake_count=$(printf '%s\n' "$ids" | count_matches -E '"[a-z]+_[a-z_]+')
782
+ kebab_count=$(printf '%s\n' "$ids" | count_matches -E '"[a-z]+-[a-z\-]+')
783
+ camel_count=$(printf '%s\n' "$ids" | count_matches -E '"[a-z]+[A-Z][a-zA-Z]+')
778
784
  snake_count=${snake_count:-0}; kebab_count=${kebab_count:-0}; camel_count=${camel_count:-0}
779
785
  if [ "$snake_count" -ge "$kebab_count" ] && [ "$snake_count" -ge "$camel_count" ] && [ "$snake_count" -gt 0 ]; then
780
786
  emit_bucket "snake_case testTag" "checkin_continue_button" "$(confidence_for "$snake_count")" "[]" '["kebab","camelCase"]'
@@ -797,8 +803,8 @@ bucket_accessibility_identifier() {
797
803
  done | head -50 || true)
798
804
  if [ -z "$op_ids" ]; then empty_bucket; return; fi
799
805
  local camel_count snake_count
800
- camel_count=$(printf '%s\n' "$op_ids" | grep -cE '[a-z]+[A-Z][A-Za-z]+' || echo 0)
801
- snake_count=$(printf '%s\n' "$op_ids" | grep -cE '[a-z]+_[a-z_]+' || echo 0)
806
+ camel_count=$(printf '%s\n' "$op_ids" | count_matches -E '[a-z]+[A-Z][A-Za-z]+')
807
+ snake_count=$(printf '%s\n' "$op_ids" | count_matches -E '[a-z]+_[a-z_]+')
802
808
  camel_count=${camel_count:-0}; snake_count=${snake_count:-0}
803
809
  if [ "$camel_count" -ge "$snake_count" ] && [ "$camel_count" -gt 0 ]; then
804
810
  emit_bucket "camelCase operationId" "createFoo" "$(confidence_for "$camel_count")" "[]" '["snake_case"]'
@@ -817,9 +823,9 @@ bucket_accessibility_identifier() {
817
823
  done | head -30 || true)
818
824
  if [ -z "$ids" ]; then empty_bucket; return; fi
819
825
  local kebab_count camel_count snake_count
820
- kebab_count=$(printf '%s\n' "$ids" | grep -cE '"[a-z]+-[a-z\-]+' || echo 0)
821
- camel_count=$(printf '%s\n' "$ids" | grep -cE '"[a-z]+[A-Z][a-zA-Z]+' || echo 0)
822
- snake_count=$(printf '%s\n' "$ids" | grep -cE '"[a-z]+_[a-z_]+' || echo 0)
826
+ kebab_count=$(printf '%s\n' "$ids" | count_matches -E '"[a-z]+-[a-z\-]+')
827
+ camel_count=$(printf '%s\n' "$ids" | count_matches -E '"[a-z]+[A-Z][a-zA-Z]+')
828
+ snake_count=$(printf '%s\n' "$ids" | count_matches -E '"[a-z]+_[a-z_]+')
823
829
  kebab_count=${kebab_count:-0}; camel_count=${camel_count:-0}; snake_count=${snake_count:-0}
824
830
  if [ "$kebab_count" -ge "$camel_count" ] && [ "$kebab_count" -ge "$snake_count" ] && [ "$kebab_count" -gt 0 ]; then
825
831
  emit_bucket "kebab-case data-testid" 'data-testid="checkin-continue-button"' "$(confidence_for "$kebab_count")" "[]" '["camelCase","snake"]'
@@ -879,9 +885,9 @@ bucket_localization_key() {
879
885
  if [ -z "$keys" ]; then empty_bucket; return; fi
880
886
 
881
887
  local dot_count snake_count camel_count
882
- dot_count=$(printf '%s\n' "$keys" | grep -cE '^[A-Za-z][A-Za-z0-9]*(\.[A-Za-z][A-Za-z0-9]*)+' || echo 0)
883
- snake_count=$(printf '%s\n' "$keys" | grep -cE '^[a-z][a-z0-9]*(_[a-z0-9]+)+' || echo 0)
884
- camel_count=$(printf '%s\n' "$keys" | grep -cE '^[a-z][a-zA-Z0-9]*[A-Z][a-zA-Z0-9]*' || echo 0)
888
+ dot_count=$(printf '%s\n' "$keys" | count_matches -E '^[A-Za-z][A-Za-z0-9]*(\.[A-Za-z][A-Za-z0-9]*)+')
889
+ snake_count=$(printf '%s\n' "$keys" | count_matches -E '^[a-z][a-z0-9]*(_[a-z0-9]+)+')
890
+ camel_count=$(printf '%s\n' "$keys" | count_matches -E '^[a-z][a-zA-Z0-9]*[A-Z][a-zA-Z0-9]*')
885
891
  dot_count=${dot_count:-0}; snake_count=${snake_count:-0}; camel_count=${camel_count:-0}
886
892
 
887
893
  if [ "$dot_count" -ge "$snake_count" ] && [ "$dot_count" -ge "$camel_count" ] && [ "$dot_count" -gt 0 ]; then
@@ -907,7 +913,7 @@ bucket_di_registration() {
907
913
  local resolver_count factory_count manual_count swinject_count
908
914
  manual_count=0; resolver_count=0; factory_count=0; swinject_count=0
909
915
  if [ -n "$configurators" ]; then
910
- manual_count=$(printf '%s\n' "$configurators" | grep -c 'DependencyConfigurator' || echo 0)
916
+ manual_count=$(printf '%s\n' "$configurators" | count_matches 'DependencyConfigurator')
911
917
  fi
912
918
  files=$(run_find -type f -name '*.swift' 2>/dev/null || true)
913
919
  if [ -n "$files" ]; then
@@ -67,15 +67,17 @@ if [ -z "$URL" ] && [ -z "$PAGE_ID" ]; then
67
67
  exit 4
68
68
  fi
69
69
 
70
- # Resolve host + pageId from URL when not given explicitly.
71
- URL_PARSE_INPUT="$URL" URL_HOST_OVERRIDE="$HOST_OVERRIDE" URL_PAGE_ID="$PAGE_ID" \
72
- PARSED=$(python3 - <<'PY'
73
- import os, sys, re
70
+ # Resolve host + pageId from URL when not given explicitly. Inputs are passed
71
+ # as argv: an env prefix on a plain assignment (VAR=x PARSED=$(...)) never
72
+ # reaches the child process, so os.environ would always come back empty.
73
+ PARSED=$(python3 - "$URL" "$HOST_OVERRIDE" "$PAGE_ID" <<'PY'
74
+ import sys, re
74
75
  import urllib.parse as up
75
76
 
76
- url = os.environ.get("URL_PARSE_INPUT", "")
77
- host_override = os.environ.get("URL_HOST_OVERRIDE", "")
78
- explicit_id = os.environ.get("URL_PAGE_ID", "")
77
+ argv = sys.argv[1:] + ["", "", ""]
78
+ url = argv[0]
79
+ host_override = argv[1]
80
+ explicit_id = argv[2]
79
81
 
80
82
  host = host_override
81
83
  page_id = explicit_id
@@ -124,12 +126,22 @@ except Exception:
124
126
  fi
125
127
  [ -z "$TOKEN_KEY" ] && TOKEN_KEY="${USER}_Confluence_Access_Token"
126
128
 
127
- TOKEN=$("$HOME/.claude/lib/credential-store.sh" get "$TOKEN_KEY" 2>/dev/null || true)
129
+ # Locate the credential helper via the resolver so Copilot-only installs work.
130
+ # shellcheck disable=SC1090,SC1091
131
+ . "$HOME/.claude/lib/credential-store-resolver.sh" 2>/dev/null \
132
+ || . "$HOME/.copilot/lib/credential-store-resolver.sh" 2>/dev/null \
133
+ || { printf '%s\n' '{"status":"blocked","reason":"missing-credential-helper","service":"confluence","expected_key":"'"$TOKEN_KEY"'"}' >&2; exit 2; }
134
+
135
+ TOKEN=$("$CRED_STORE" get "$TOKEN_KEY" 2>/dev/null || true)
128
136
  if [ -z "$TOKEN" ]; then
129
137
  printf '%s\n' '{"status":"blocked","reason":"missing-token","service":"confluence","expected_key":"'"$TOKEN_KEY"'"}' >&2
130
138
  exit 2
131
139
  fi
132
140
 
141
+ # Bearer auth via a curl config fed through process substitution so the token
142
+ # never appears in argv (argv is visible to ps).
143
+ confluence_auth_cfg() { printf 'header = "Authorization: Bearer %s"\n' "$TOKEN"; }
144
+
133
145
  # Endpoint resolution: Confluence Cloud (<tenant>.atlassian.net/wiki/rest/api)
134
146
  # vs Server/Data Center (<host>/rest/api). The same paths exist on both.
135
147
  case "$HOST" in
@@ -141,14 +153,14 @@ esac
141
153
  if [ -z "$PAGE_ID_RESOLVED" ] && [ -n "$SPACE_KEY" ] && [ -n "$URL" ]; then
142
154
  TITLE_RAW=$(python3 -c "
143
155
  import urllib.parse as up
144
- import os, re
145
- p = up.urlparse(os.environ['URL_PARSE_INPUT'])
156
+ import sys, re
157
+ p = up.urlparse(sys.argv[1])
146
158
  m = re.match(r'/display/[^/]+/(.+)', p.path)
147
159
  print(up.unquote(m.group(1)) if m else '')
148
- " URL_PARSE_INPUT="$URL")
160
+ " "$URL")
149
161
  if [ -n "$TITLE_RAW" ]; then
150
162
  LOOKUP=$(curl -sS --fail --max-time "$TIMEOUT" --connect-timeout 5 \
151
- -H "Authorization: Bearer $TOKEN" -H "Accept: application/json" \
163
+ -K <(confluence_auth_cfg) -H "Accept: application/json" \
152
164
  --data-urlencode "spaceKey=$SPACE_KEY" \
153
165
  --data-urlencode "title=$TITLE_RAW" \
154
166
  --data-urlencode "expand=" \
@@ -172,7 +184,7 @@ fi
172
184
 
173
185
  # Fetch the page body in storage format.
174
186
  PAGE_JSON=$(curl -sS --fail --max-time "$TIMEOUT" --connect-timeout 5 \
175
- -H "Authorization: Bearer $TOKEN" -H "Accept: application/json" \
187
+ -K <(confluence_auth_cfg) -H "Accept: application/json" \
176
188
  "$API_BASE/content/$PAGE_ID_RESOLVED?expand=body.storage,space" 2>/dev/null || true)
177
189
 
178
190
  if [ -z "$PAGE_JSON" ]; then
@@ -124,22 +124,40 @@ except Exception:
124
124
  fi
125
125
  [ -z "$TOKEN_KEY" ] && TOKEN_KEY="${USER}_Firebase_Access_Json"
126
126
 
127
- SA_JSON_B64=$("$HOME/.claude/lib/credential-store.sh" get "$TOKEN_KEY" 2>/dev/null || true)
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 > /tmp/.fc-sa-$$.json 2>/dev/null \
137
- && python3 -c "import json; json.load(open('/tmp/.fc-sa-$$.json'))" 2>/dev/null; then
138
- SA_JSON=$(cat /tmp/.fc-sa-$$.json)
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 /tmp/.fc-sa-$$.json
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
- -H "Authorization: Bearer $ACCESS_TOKEN" -H "Accept: application/json" \
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
- -H "Authorization: Bearer $ACCESS_TOKEN" -H "Accept: application/json" \
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
- TOKEN=$("$HOME/.claude/lib/credential-store.sh" get "$TOKEN_KEY" 2>/dev/null || true)
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
- -H "$hdr" -H "Accept: application/json" "$url" 2>/dev/null || true)
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
- if ! META=$(curl "${CURL_ARGS[@]}" "$URL" 2>/dev/null); then
65
- echo "ERR: swagger fetch failed for $URL" >&2
66
- exit 2
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 "${URL,,}" in
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
- CURL_ARGS=(
48
- --data-urlencode "grant_type=refresh_token"
49
- --data-urlencode "refresh_token=${REFRESH_TOKEN}"
50
- --data-urlencode "client_id=${CLIENT_ID}"
51
- )
52
- [ -n "$CLIENT_SECRET" ] && CURL_ARGS+=( --data-urlencode "client_secret=${CLIENT_SECRET}" )
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
- "${CURL_ARGS[@]}")
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
- security delete-generic-password -s "$KEY_NAME" -a "$ACCOUNT" >/dev/null 2>&1 || true
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
- security delete-generic-password -s "$REFRESH_KEY" -a "$ACCOUNT" >/dev/null 2>&1 || true
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
- CRED_STORE="$HOME/.claude/lib/credential-store.sh"
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
- -H "X-Figma-Token: $FIGMA_TOKEN" \
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=$("$HOME/.claude/lib/credential-store.sh" get "$JIRA_TOKEN_KEY" 2>/dev/null || true)
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 -H "Authorization: Bearer $token" \
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
- list_repos_jq | while IFS=$'\t' read -r name url provider; do
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" || true
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
- echo "[$name] committed: $subject"
104
- done
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
- list_repos_jq | while IFS=$'\t' read -r name url provider; do
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" || echo "[$name] push failed"
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
- echo "[$name] pushed $BRANCH"
115
- done
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 primary_owner_repo
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 pr create --title "$COMMIT_TYPE($name): $ISSUE_TITLE" --body "$body" --fill 2>/dev/null \
134
- | tee -a "$WORKTREE_ROOT/_prs.log" || echo "[$name] gh pr create failed"
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
- || echo "[$primary_name] gh pr create failed"
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] removing $WORKTREE_ROOT (logs preserved at ~/.claude/logs/multi-agent/$TASK_ID/)"
159
- rm -rf "$WORKTREE_ROOT"
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.