@mmerterden/multi-agent-pipeline 16.25.1 → 16.27.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 (44) hide show
  1. package/CHANGELOG.md +67 -0
  2. package/README.md +1 -1
  3. package/README.tr.md +1 -1
  4. package/install/templates/claude-hooks.json +32 -1
  5. package/package.json +1 -1
  6. package/pipeline/commands/multi-agent/help/SKILL.md +2 -0
  7. package/pipeline/commands/multi-agent/refactor/SKILL.md +23 -1
  8. package/pipeline/commands/multi-agent/search/SKILL.md +28 -0
  9. package/pipeline/commands/multi-agent/setup/SKILL.md +18 -44
  10. package/pipeline/commands/multi-agent/status/SKILL.md +9 -0
  11. package/pipeline/lib/credential-inventory.sh +142 -18
  12. package/pipeline/lib/fetch-crashlytics.sh +123 -28
  13. package/pipeline/multi-agent-refs/features/url-enrichment.md +1 -1
  14. package/pipeline/multi-agent-refs/keychain.md +65 -20
  15. package/pipeline/multi-agent-refs/knowledge.md +27 -0
  16. package/pipeline/multi-agent-refs/phases/operations.md +7 -1
  17. package/pipeline/multi-agent-refs/phases/phase-1-analysis.md +3 -1
  18. package/pipeline/multi-agent-refs/phases/phase-7-report.md +11 -21
  19. package/pipeline/multi-agent-refs/picker-contract.md +1 -1
  20. package/pipeline/multi-agent-refs/refactor/observations.md +81 -0
  21. package/pipeline/multi-agent-refs/setup/firebase.md +151 -0
  22. package/pipeline/schemas/learnings-ledger.schema.json +5 -0
  23. package/pipeline/schemas/prefs.schema.json +31 -3
  24. package/pipeline/schemas/skill-observation.schema.json +73 -0
  25. package/pipeline/scripts/capture-flush.sh +158 -0
  26. package/pipeline/scripts/capture-resume.sh +87 -0
  27. package/pipeline/scripts/crush-json.mjs +283 -0
  28. package/pipeline/scripts/firebase-app-discovery.sh +114 -0
  29. package/pipeline/scripts/keychain-save.sh +5 -8
  30. package/pipeline/scripts/keychain.py +76 -14
  31. package/pipeline/scripts/learn-from-transcripts.mjs +625 -0
  32. package/pipeline/scripts/learning-curve.mjs +22 -4
  33. package/pipeline/scripts/learnings-ledger.mjs +86 -12
  34. package/pipeline/scripts/note-session.sh +187 -0
  35. package/pipeline/scripts/observations.mjs +347 -0
  36. package/pipeline/scripts/offload-ref.sh +45 -2
  37. package/pipeline/scripts/pre-commit-check.sh +31 -1
  38. package/pipeline/scripts/scan-agent-config.sh +12 -3
  39. package/pipeline/scripts/skill-siblings.mjs +187 -0
  40. package/pipeline/scripts/triage-memory.mjs +73 -9
  41. package/pipeline/skills/shared/core/multi-agent-refactor/SKILL.md +23 -1
  42. package/pipeline/skills/shared/core/multi-agent-search/SKILL.md +28 -0
  43. package/pipeline/skills/shared/core/multi-agent-setup/SKILL.md +37 -6
  44. package/pipeline/skills/shared/core/multi-agent-status/SKILL.md +9 -0
@@ -148,6 +148,82 @@ classify_status() {
148
148
  esac
149
149
  }
150
150
 
151
+ # Which Crashlytics tier can actually serve a request right now.
152
+ #
153
+ # Tier 1 (service account) is measured by the fetcher's own --probe, so the JWT
154
+ # exchange has exactly one implementation; a copy here would be the one that
155
+ # drifts. Tier 2 (interactive Firebase CLI session + the Firebase MCP server) is
156
+ # measured here, because it is a property of the host, not of the credential.
157
+ #
158
+ # Tier 1 is preferred even when tier 2 is available: url-enrichment and the
159
+ # autopilot flows run with nobody at the keyboard, and a browser login is not an
160
+ # option there.
161
+ firebase_tier() {
162
+ local t1="" fetcher=""
163
+ # Rule out before measuring. A stored value that is not service-account JSON
164
+ # cannot be fixed by any grant or any session, and saying so here means the
165
+ # verdict survives an install where the fetcher is absent - otherwise a broken
166
+ # credential and a missing fetcher both read as unreachable, which sends the
167
+ # user to debug a network over a re-onboarding.
168
+ #
169
+ # This is not the shape check that was removed. That one graded shape and
170
+ # concluded the credential WORKED; this one grades shape and concludes only
171
+ # that it CANNOT, which is the half shape can actually answer.
172
+ if ! bash "$STORE" get firebase 2>/dev/null | python3 -c \
173
+ 'import json,sys; d=json.load(sys.stdin); sys.exit(0 if d.get("project_id") and d.get("private_key") else 1)' 2>/dev/null; then
174
+ echo "malformed"; return
175
+ fi
176
+ for c in "$(dirname "${BASH_SOURCE[0]:-$0}")/fetch-crashlytics.sh" \
177
+ "$HOME/.claude/lib/fetch-crashlytics.sh"; do
178
+ [ -f "$c" ] && { fetcher="$c"; break; }
179
+ done
180
+ if [ -n "$fetcher" ]; then
181
+ t1=$(bash "$fetcher" --probe 2>/dev/null \
182
+ | python3 -c 'import json,sys; print((json.load(sys.stdin) or {}).get("tier",""))' 2>/dev/null || true)
183
+ fi
184
+ case "$t1" in
185
+ tier-1-ready) echo "tier-1-ready"; return ;;
186
+ malformed) echo "malformed"; return ;;
187
+ esac
188
+
189
+ # Tier 2 needs both halves: a logged-in CLI session AND a registered MCP
190
+ # server. Either alone reaches nothing, and reporting ready on half of it
191
+ # would repeat the mistake this function exists to end.
192
+ local logged_in=0 mcp=0
193
+ if command -v firebase >/dev/null 2>&1 \
194
+ && firebase login:list 2>/dev/null | grep -qi "logged in as"; then
195
+ logged_in=1
196
+ fi
197
+ if python3 - "$HOME" <<'MCPCHK' 2>/dev/null
198
+ import json, os, sys
199
+ home = sys.argv[1]
200
+ def named(d):
201
+ return any("firebase" in k.lower() for k in (d or {}))
202
+ for f in (os.path.join(home, ".claude.json"), os.path.join(home, ".claude", "settings.json")):
203
+ try:
204
+ d = json.load(open(f))
205
+ except Exception:
206
+ continue
207
+ if named(d.get("mcpServers")):
208
+ sys.exit(0)
209
+ for pv in (d.get("projects") or {}).values():
210
+ if named((pv or {}).get("mcpServers")):
211
+ sys.exit(0)
212
+ sys.exit(1)
213
+ MCPCHK
214
+ then
215
+ mcp=1
216
+ fi
217
+ if [ "$logged_in" -eq 1 ] && [ "$mcp" -eq 1 ]; then
218
+ echo "tier-2-ready"; return
219
+ fi
220
+
221
+ case "$t1" in
222
+ tier-1-no-grant) echo "tier-1-no-grant" ;;
223
+ *) echo "unreachable" ;;
224
+ esac
225
+ }
226
+
151
227
  # Reachability for one logical key. Never prints a credential; the value is piped
152
228
  # straight into curl's stdin config and discarded.
153
229
  #
@@ -207,8 +283,14 @@ probe_one() {
207
283
  status=$(probe_http "https://${host}/ssc/api/v1/projects?limit=1" \
208
284
  "Authorization" "FortifyToken $(printf '%s' "$tok" | base64 | tr -d '\n')")
209
285
  fi ;;
210
- graylog)
211
- host=$(pref global.hosts.graylog)
286
+ graylog|graylog_test)
287
+ # The test instance is a separate deployment with its own host key; probing it
288
+ # against the prod host would report a healthy token as broken.
289
+ if [ "$key" = "graylog_test" ]; then
290
+ host=$(pref global.hosts.graylogTest)
291
+ else
292
+ host=$(pref global.hosts.graylog)
293
+ fi
212
294
  [ -n "$host" ] || { echo "no-host-configured"; return; }
213
295
  # Graylog PATs authenticate as basic auth with the literal password "token", and
214
296
  # the API rejects requests without X-Requested-By. Both per fetch-graylog.sh.
@@ -219,16 +301,19 @@ probe_one() {
219
301
  [ -n "$host" ] || { echo "no-host-configured"; return; }
220
302
  status=$(probe_http "https://${host}/api/json" "Authorization" "Bearer $tok") ;;
221
303
  firebase)
222
- # A service-account JSON, not a bearer token: an OAuth exchange would be needed
223
- # to reach Crashlytics, and the fetcher does that per issue. Verifying the shape
224
- # is honest about what is known - well-formed, not proven reachable.
225
- if printf '%s' "$tok" | python3 -c 'import json,sys; d=json.load(sys.stdin); sys.exit(0 if d.get("project_id") and d.get("private_key") else 1)' 2>/dev/null; then
226
- echo "well-formed"; return
227
- fi
228
- echo "malformed"; return ;;
229
- appstore_connect_private_key)
230
- printf '%s' "$tok" | grep -q "BEGIN PRIVATE KEY" && echo "well-formed" || echo "malformed"
231
- return ;;
304
+ # Crashlytics has three ways in and they fail differently, so a single
305
+ # yes/no was never an answer. This used to check the JSON's shape and call
306
+ # it "well-formed", on which capability_of() declared it would pull stack
307
+ # frames - a promise the credential could not keep: the account was sound
308
+ # and simply had no Crashlytics role, which shape can never reveal.
309
+ #
310
+ # Verdicts, best usable tier first:
311
+ # tier-1-ready the SA can read Crashlytics headlessly
312
+ # tier-2-ready tier 1 is a role away, but an interactive session works
313
+ # tier-1-no-grant the SA is sound, no role, and no interactive fallback
314
+ # malformed the stored value is not service-account JSON
315
+ # unreachable nothing answers
316
+ echo "$(firebase_tier)"; return ;;
232
317
  *)
233
318
  echo "not-probeable"; return ;;
234
319
  esac
@@ -240,22 +325,51 @@ probe_one() {
240
325
  # issue URL and I will pull the stack trace" is only sayable if the capability is
241
326
  # written down somewhere.
242
327
  capability_of() {
328
+ # $2 is the reachability verdict when the caller has one. Only Firebase reads
329
+ # it today, and it has to: the same stored credential buys a headless fetch, an
330
+ # interactive lookup, or nothing at all, depending on a role the JSON cannot
331
+ # show. Announcing the best of those unconditionally is what made the inventory
332
+ # promise stack frames from an account that could not read them.
333
+ if [ "$1" = "firebase" ]; then
334
+ case "${2:-}" in
335
+ tier-1-ready)
336
+ echo "pull a Crashlytics issue headlessly: stack frames, affected versions, device spread (needs the issue URL)"; return ;;
337
+ tier-2-ready)
338
+ echo "pull a Crashlytics issue through the signed-in Firebase MCP session; headless runs still need firebasecrashlytics.viewer on the service account"; return ;;
339
+ tier-1-no-grant)
340
+ echo "nothing yet - the service account authenticates but holds no Crashlytics role; ask an Owner for roles/firebasecrashlytics.viewer"; return ;;
341
+ malformed)
342
+ echo "nothing - the stored value is not service-account JSON; re-onboard the key issued by Firebase Console"; return ;;
343
+ unreachable)
344
+ echo "nothing reachable - neither the service account nor an interactive Firebase session answers"; return ;;
345
+ esac
346
+ fi
243
347
  case "$1" in
244
348
  jira) echo "read the ticket, its comments and its linked issues; post the Phase 7 comment" ;;
245
349
  bitbucket_token) echo "read the repo, open and update pull requests" ;;
246
350
  bitbucket_user) echo "identify the PR author (paired with bitbucket_token)" ;;
247
351
  github) echo "read issues, open pull requests, read Actions runs" ;;
248
352
  confluence) echo "read linked pages and publish the analysis document" ;;
249
- firebase) echo "pull a Crashlytics issue: stack frames, affected versions, device spread (needs the issue URL)" ;;
353
+ firebase) echo "pull a Crashlytics issue: stack frames, affected versions, device spread (needs the issue URL and a readable tier)" ;;
250
354
  fortify) echo "pull a static-analysis finding and its remediation guidance" ;;
251
355
  graylog) echo "pull request logs by transaction or conversation id (advisory)" ;;
356
+ graylog_test) echo "pull the same logs from the test environment (falls back to the graylog token when unset)" ;;
357
+ usage_ingest) echo "report pipeline usage counters and send /multi-agent:feedback" ;;
252
358
  figma|figma_mcp) echo "fetch design context, screenshots and Code Connect mappings" ;;
253
359
  jenkins) echo "read build results" ;;
254
360
  npm) echo "publish to the npm registry" ;;
255
- appstore_connect_key_id|appstore_connect_issuer_id|appstore_connect_private_key)
256
- echo "validate an archive against App Store rules before submission" ;;
361
+ appstore_connect_key_id|appstore_connect_issuer_id)
362
+ echo "validate an archive against App Store rules before submission (the .p8 itself is a file at ~/.appstoreconnect/private_keys/, never a mapping)" ;;
363
+ appstore_connect_apple_id|appstore_connect_password_item)
364
+ echo "upload a build with an app-specific password (Tier 2 of the App Store Connect chain)" ;;
257
365
  claude_oauth_token|claude_oauth_token_fallback)
258
366
  echo "run headless review and analysis passes" ;;
367
+ supabase_access) echo "administer the personal-site Supabase project (Management API); no pipeline phase reads it" ;;
368
+ supabase_service_role)
369
+ echo "read and write the personal-site database server-side; no pipeline phase reads it" ;;
370
+ bitbucket) echo "legacy alias for bitbucket_token; migrate-prefs.mjs folds it into that key" ;;
371
+ figma_pat|figma_user|firebase_project|firebase_sa)
372
+ echo "deprecated mapping kept only so an un-migrated preferences file validates; migrate-prefs.mjs removes it" ;;
259
373
  *) echo "(no capability recorded for this key)" ;;
260
374
  esac
261
375
  }
@@ -286,7 +400,8 @@ if [ -n "$QUERY" ]; then
286
400
  case "$rc" in
287
401
  0)
288
402
  if [ "$PROBE" = "1" ]; then
289
- echo "$QUERY: present, $(probe_one "$QUERY") - can $(capability_of "$QUERY")"
403
+ _reach=$(probe_one "$QUERY")
404
+ echo "$QUERY: present, $_reach - can $(capability_of "$QUERY" "$_reach")"
290
405
  else
291
406
  echo "$QUERY: present - can $(capability_of "$QUERY")"
292
407
  fi
@@ -323,7 +438,7 @@ while IFS=$'\t' read -r key mapped; do
323
438
  state="mapped-but-missing"
324
439
  reach="not-probed"
325
440
  fi
326
- ROWS="${ROWS}${key}\t${state}\t${reach}\t$(capability_of "$key")\n"
441
+ ROWS="${ROWS}${key}\t${state}\t${reach}\t$(capability_of "$key" "$reach")\n"
327
442
  done < <(mapping_keys)
328
443
 
329
444
  if [ -z "$ROWS" ]; then
@@ -362,8 +477,16 @@ with open(sys.argv[1]) as fh:
362
477
 
363
478
  # `usable` is presence only, so a caller that never probed still gets a meaningful
364
479
  # answer. `reachable` is the stronger claim and exists only after --probe.
365
- OK_REACH = {"reachable", "well-formed"}
480
+ # A tier that can serve a request is reachable, whichever tier it is. "well-formed"
481
+ # used to sit in this set and was the reason a shape check could report a
482
+ # credential as reachable without anything having been reached.
483
+ OK_REACH = {"reachable", "tier-1-ready", "tier-2-ready"}
366
484
  BLOCKED = {"auth-rejected", "malformed"}
485
+ # Neither reachable nor broken: the credential is sound and one grant short. It
486
+ # gets its own bucket for the reason the others do - the user action is specific
487
+ # (ask an Owner for a role), and folding it into any existing bucket sends them
488
+ # to refresh a token that is fine or debug a VPN that is up.
489
+ NEEDS_GRANT = {"tier-1-no-grant"}
367
490
  probed = [r for r in rows if r["reachability"] != "not-probed"]
368
491
  print(json.dumps({
369
492
  "status": "ok",
@@ -377,6 +500,7 @@ print(json.dumps({
377
500
  "authRejected": [r["logical"] for r in probed if r["reachability"] in BLOCKED],
378
501
  "unreachable": [r["logical"] for r in probed if r["reachability"] == "unreachable"],
379
502
  "noHostConfigured": [r["logical"] for r in probed if r["reachability"] == "no-host-configured"],
503
+ "needsGrant": [r["logical"] for r in probed if r["reachability"] in NEEDS_GRANT],
380
504
  "credentials": rows,
381
505
  }, indent=2))
382
506
  PYJSON
@@ -10,6 +10,7 @@
10
10
  # ./fetch-crashlytics.sh <issue-url>
11
11
  # ./fetch-crashlytics.sh --project <id> --platform <ios|android> \
12
12
  # --bundle <id> --issue-id <id> [--session-id <id>]
13
+ # ./fetch-crashlytics.sh --probe [--project <id>]
13
14
  #
14
15
  # Optional env:
15
16
  # FIREBASE_TIMEOUT_SECONDS default 20
@@ -48,6 +49,17 @@
48
49
  # 4 bad usage
49
50
  # 5 project mismatch - SA JSON project_id != URL project (configuration bug)
50
51
  #
52
+ # --probe answers one question - can this service account read Crashlytics right
53
+ # now - and answers it on stdout as {"tier":...,"reason":...} with exit 0 on every
54
+ # outcome, because "no" is an answer, not a failure. It lives here rather than in
55
+ # credential-inventory.sh so the JWT exchange has exactly one implementation; a
56
+ # second copy would be the one that drifts. Verdicts:
57
+ #
58
+ # tier-1-ready the SA holds firebasecrashlytics.issues.get
59
+ # tier-1-no-grant the SA authenticates but lacks that permission - a role away
60
+ # malformed the stored value is not service-account JSON
61
+ # unreachable no credential, no helper, or the exchange never answered
62
+ #
51
63
  # Notes:
52
64
  # Two v1alpha calls back this, because there is no get-issue-by-id endpoint:
53
65
  # `reports/topIssues` for the summary and metrics, and `events?filter.issue.id`
@@ -65,8 +77,16 @@ PLATFORM=""
65
77
  BUNDLE=""
66
78
  ISSUE_ID=""
67
79
  SESSION_ID=""
80
+ PROBE=0
68
81
  TIMEOUT="${FIREBASE_TIMEOUT_SECONDS:-20}"
69
82
 
83
+ # Probe verdicts go to stdout and exit 0 - the caller reads the tier, it does not
84
+ # read an exit status. Outside probe mode this is never called.
85
+ probe_out() {
86
+ printf '{"tier":"%s","reason":"%s","projectId":"%s"}\n' "$1" "$2" "${3:-}"
87
+ exit 0
88
+ }
89
+
70
90
  need_value() { [ $# -ge 2 ] || { echo "ERR: $1 needs a value" >&2; exit 4; }; }
71
91
 
72
92
  while [ $# -gt 0 ]; do
@@ -76,8 +96,9 @@ while [ $# -gt 0 ]; do
76
96
  --bundle) need_value "$@"; BUNDLE="$2"; shift 2 ;;
77
97
  --issue-id) need_value "$@"; ISSUE_ID="$2"; shift 2 ;;
78
98
  --session-id) need_value "$@"; SESSION_ID="$2"; shift 2 ;;
99
+ --probe) PROBE=1; shift ;;
79
100
  -h|--help)
80
- echo "usage: $0 <issue-url> | $0 --project ... --platform ... --bundle ... --issue-id ... [--session-id ...]" >&2
101
+ echo "usage: $0 <issue-url> | $0 --project ... --platform ... --bundle ... --issue-id ... [--session-id ...] | $0 --probe [--project <id>]" >&2
81
102
  exit 4 ;;
82
103
  *)
83
104
  if [ -z "$URL" ]; then URL="$1"; shift; else
@@ -118,7 +139,8 @@ PY
118
139
  SESSION_ID=$(printf '%s' "$PARSED" | cut -f5)
119
140
  fi
120
141
 
121
- if [ -z "$PROJECT_ID" ] || [ -z "$PLATFORM" ] || [ -z "$BUNDLE" ] || [ -z "$ISSUE_ID" ]; then
142
+ if [ "$PROBE" -eq 0 ] \
143
+ && { [ -z "$PROJECT_ID" ] || [ -z "$PLATFORM" ] || [ -z "$BUNDLE" ] || [ -z "$ISSUE_ID" ]; }; then
122
144
  echo "ERR: missing required components - need project, platform, bundle, issue-id" >&2
123
145
  exit 4
124
146
  fi
@@ -179,38 +201,29 @@ for _cred_resolver in \
179
201
  done
180
202
  unset _cred_resolver
181
203
  if [ -z "${CRED_STORE:-}" ]; then
204
+ [ "$PROBE" -eq 1 ] && probe_out unreachable no-credential-helper "$PROJECT_ID"
182
205
  printf '%s\n' '{"status":"blocked","reason":"missing-credential-helper","service":"firebase","expected_key":"'"$TOKEN_KEY"'"}' >&2
183
206
  exit 2
184
207
  fi
185
208
 
186
- SA_JSON_B64=$("$CRED_STORE" get "$TOKEN_KEY" 2>/dev/null || true)
187
- if [ -z "$SA_JSON_B64" ]; then
209
+ SA_JSON_RAW=$("$CRED_STORE" get "$TOKEN_KEY" 2>/dev/null || true)
210
+ if [ -z "$SA_JSON_RAW" ]; then
211
+ [ "$PROBE" -eq 1 ] && probe_out unreachable no-credential "$PROJECT_ID"
188
212
  printf '%s\n' '{"status":"blocked","reason":"missing-token","service":"firebase","expected_key":"'"$TOKEN_KEY"'"}' >&2
189
213
  exit 2
190
214
  fi
191
215
 
192
- # The credential store may return raw JSON or base64-encoded JSON (the user's
193
- # setup convention). Try base64-decode first, fall back to raw. The decoded
194
- # payload contains a GCP private key, so it goes into a 0600 mktemp file with
195
- # cleanup traps registered BEFORE the secret is written.
196
- SA_TMP=""
197
- cleanup_sa_tmp() {
198
- if [ -n "$SA_TMP" ]; then rm -f "$SA_TMP"; fi
199
- }
200
- trap cleanup_sa_tmp EXIT
201
- trap 'cleanup_sa_tmp; exit 130' INT
202
- trap 'cleanup_sa_tmp; exit 143' TERM
203
- SA_TMP=$(umask 077; mktemp "${TMPDIR:-/tmp}/fc-sa.XXXXXX")
204
-
205
- SA_JSON=""
206
- if printf '%s' "$SA_JSON_B64" | base64 -d > "$SA_TMP" 2>/dev/null \
207
- && python3 -c "import json, sys; json.load(open(sys.argv[1]))" "$SA_TMP" 2>/dev/null; then
208
- SA_JSON=$(cat "$SA_TMP")
209
- else
210
- SA_JSON="$SA_JSON_B64"
211
- fi
212
- rm -f "$SA_TMP"
213
- SA_TMP=""
216
+ # The credential store returns the JSON as stored - encoding is its internal
217
+ # business, not this script's. The base64 attempt that used to live here was
218
+ # guesswork that never fired: what actually broke the fetcher was the store
219
+ # returning bare hex for any multi-line value, which is neither base64-of-JSON
220
+ # nor JSON, so both branches failed and the failure surfaced three layers later
221
+ # as a token-exchange error.
222
+ #
223
+ # With no decode step there is nothing to stage on disk, so the temp file and its
224
+ # cleanup traps went with it: the service-account JSON now only ever exists in a
225
+ # shell variable, which is one fewer place a GCP private key can be left behind.
226
+ SA_JSON="$SA_JSON_RAW"
214
227
 
215
228
  # Verify project_id from SA JSON matches the URL - protects against pasting a
216
229
  # crash URL from another GCP project than the keychain's SA covers.
@@ -222,6 +235,17 @@ try:
222
235
  except Exception:
223
236
  print('')
224
237
  ")
238
+ if [ "$PROBE" -eq 1 ]; then
239
+ # A stored value that is not service-account JSON has no project_id, and no
240
+ # amount of network access makes it usable. Say malformed here rather than
241
+ # letting the exchange fail and reporting it as unreachable - the two need
242
+ # different fixes, and conflating them is what sent the last diagnosis three
243
+ # layers away from the defect.
244
+ if [ -z "$SA_PROJECT" ]; then
245
+ probe_out malformed not-service-account-json "$PROJECT_ID"
246
+ fi
247
+ [ -z "$PROJECT_ID" ] && PROJECT_ID="$SA_PROJECT"
248
+ fi
225
249
  if [ -n "$SA_PROJECT" ] && [ "$SA_PROJECT" != "$PROJECT_ID" ]; then
226
250
  # Still a hard error, but say which key was used and how to map the right one:
227
251
  # with several Firebase projects in play, "project mismatch" alone does not
@@ -308,10 +332,50 @@ PY
308
332
  ) || TOKEN_RC=$?
309
333
 
310
334
  if [ "$TOKEN_RC" -ne 0 ] || [ -z "$ACCESS_TOKEN" ]; then
335
+ [ "$PROBE" -eq 1 ] && probe_out unreachable token-exchange-failed "$PROJECT_ID"
311
336
  echo "ERR: Firebase token exchange failed (exchange rc=$TOKEN_RC)" >&2
312
337
  exit 3
313
338
  fi
314
339
 
340
+ if [ "$PROBE" -eq 1 ]; then
341
+ # Ask IAM what this service account may do rather than calling Crashlytics and
342
+ # reading the error: a 403 from topIssues means "no permission", but so does a
343
+ # 403 from a disabled API or a project holding no crash data, and those need
344
+ # different fixes. testIamPermissions answers only the question asked, and
345
+ # answers it without touching crash data.
346
+ PERM_VERDICT=$(PROBE_TOKEN="$ACCESS_TOKEN" PROBE_PROJECT="$PROJECT_ID" python3 - <<'IAM'
347
+ import json, os, sys, urllib.error, urllib.request
348
+
349
+ WANT = "firebasecrashlytics.issues.get"
350
+ body = json.dumps({"permissions": [WANT, "firebasecrashlytics.issues.list"]}).encode()
351
+ req = urllib.request.Request(
352
+ "https://cloudresourcemanager.googleapis.com/v1/projects/%s:testIamPermissions"
353
+ % os.environ["PROBE_PROJECT"],
354
+ data=body,
355
+ headers={"Authorization": "Bearer %s" % os.environ["PROBE_TOKEN"],
356
+ "Content-Type": "application/json"},
357
+ )
358
+ try:
359
+ with urllib.request.urlopen(req, timeout=int(os.environ.get("FIREBASE_TIMEOUT_SECONDS", "20"))) as r:
360
+ held = json.loads(r.read().decode()).get("permissions") or []
361
+ except urllib.error.HTTPError as e:
362
+ # The token was minted, so the account is real; a refusal here is still a
363
+ # grant question, not a reachability one.
364
+ print("tier-1-no-grant" if e.code in (401, 403) else "unreachable")
365
+ sys.exit(0)
366
+ except Exception:
367
+ print("unreachable")
368
+ sys.exit(0)
369
+ print("tier-1-ready" if WANT in held else "tier-1-no-grant")
370
+ IAM
371
+ ) || PERM_VERDICT="unreachable"
372
+ case "$PERM_VERDICT" in
373
+ tier-1-ready) probe_out tier-1-ready crashlytics-readable "$PROJECT_ID" ;;
374
+ tier-1-no-grant) probe_out tier-1-no-grant missing-crashlytics-role "$PROJECT_ID" ;;
375
+ *) probe_out unreachable iam-check-failed "$PROJECT_ID" ;;
376
+ esac
377
+ fi
378
+
315
379
  # Bearer auth via a curl config fed through process substitution so the token
316
380
  # never appears in argv (argv is visible to ps).
317
381
  crashlytics_auth_cfg() { printf 'header = "Authorization: Bearer %s"\n' "$ACCESS_TOKEN"; }
@@ -322,9 +386,39 @@ fb_get() {
322
386
  }
323
387
 
324
388
  # The appId is opaque (1:1234567890:ios:abcdef) and cannot be derived from the
325
- # bundle id. Ask the Firebase Management API which app owns this bundle; the
326
- # console URL only ever carries the bundle.
389
+ # bundle id, so it has to come from somewhere. Preferences first: the repo's own
390
+ # GoogleService-Info*.plist / google-services.json already name the pair, and
391
+ # firebase-app-discovery.sh writes them into
392
+ # prefs.global.firebase.accounts[].apps[] at setup. A hit there costs nothing; a
393
+ # miss falls through to the Management API exactly as before, so an install that
394
+ # never ran discovery behaves identically.
395
+ APP_ID=""
396
+ if [ -f "$PREFS" ]; then
397
+ APP_ID=$(WANT_PROJECT="$PROJECT_ID" WANT_BUNDLE="$BUNDLE" WANT_PLATFORM="$PLATFORM" \
398
+ python3 -c "
399
+ import json, os
400
+ try:
401
+ g = json.load(open('$PREFS')).get('global', {})
402
+ except Exception:
403
+ g = {}
404
+ want_p = os.environ.get('WANT_PROJECT') or ''
405
+ want_b = os.environ.get('WANT_BUNDLE') or ''
406
+ want_pl = os.environ.get('WANT_PLATFORM') or ''
407
+ for a in (g.get('firebase', {}) or {}).get('accounts') or []:
408
+ if not isinstance(a, dict) or str(a.get('projectId') or '') != want_p:
409
+ continue
410
+ for app in a.get('apps') or []:
411
+ if not isinstance(app, dict):
412
+ continue
413
+ if str(app.get('bundleId') or '') == want_b and str(app.get('platform') or '') == want_pl:
414
+ print(app.get('appId') or '')
415
+ raise SystemExit(0)
416
+ print('')
417
+ " 2>/dev/null || printf '')
418
+ fi
419
+
327
420
  FB_MGMT="https://firebase.googleapis.com/v1beta1/projects/$PROJECT_ID"
421
+ if [ -z "$APP_ID" ]; then
328
422
  if [ "$PLATFORM" = "ios" ]; then
329
423
  APPS_JSON=$(fb_get "$FB_MGMT/iosApps?pageSize=200")
330
424
  MATCH_FIELD="bundleId"
@@ -350,6 +444,7 @@ else:
350
444
  # rather than guessing at the wrong app.
351
445
  print(apps[0].get('appId') or '' if len(apps) == 1 else '')
352
446
  " 2>/dev/null || true)
447
+ fi
353
448
 
354
449
  if [ -z "$APP_ID" ]; then
355
450
  printf '{"status":"failed","reason":"app-not-found","platform":"%s","bundle":"%s","projectId":"%s"}\n' \
@@ -46,7 +46,7 @@ The catalogue is the single source of truth for downstream phases - they read
46
46
  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>)?`
47
47
 
48
48
  - Host is fixed (`console.firebase.google.com`); no `hosts.firebase` pref needed.
49
- - Token resolution, in order: env `FIREBASE_TOKEN_KEY`; the `prefs.global.firebase.accounts[]` entry whose `projectId` equals the URL's project; `prefs.global.keychainMapping.firebase` (single-project setups). Then `~/.claude/lib/credential-store.sh get "<key>" | base64 -d` → Service Account JSON. `project_id` read from the decoded JSON - verify it matches the `<projectId>` from the URL; mismatch → the fetcher exits `5` naming the key it used and the `accounts[]` entry to add, and enrichment is skipped. Several Firebase projects per team is the normal case (legacy plus redesign, staging plus prod), which is what `accounts[]` exists for.
49
+ - Token resolution, in order: env `FIREBASE_TOKEN_KEY`; the `prefs.global.firebase.accounts[]` entry whose `projectId` equals the URL's project; `prefs.global.keychainMapping.firebase` (single-project setups). Then `~/.claude/lib/credential-store.sh get "<key>"` → Service Account JSON. `project_id` read from that JSON - verify it matches the `<projectId>` from the URL; mismatch → the fetcher exits `5` naming the key it used and the `accounts[]` entry to add, and enrichment is skipped. Several Firebase projects per team is the normal case (legacy plus redesign, staging plus prod), which is what `accounts[]` exists for.
50
50
  - 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.
51
51
  - Resolve the opaque `appId` first: `GET https://firebase.googleapis.com/v1beta1/projects/<projectId>/(iosApps|androidApps)?pageSize=200` and match `bundleId` (iOS) / `packageName` (Android) against the bundle from the URL. The console URL only carries the bundle, and the Crashlytics API only accepts the appId (`1:1234567890:ios:abcdef`). No match in a multi-app project → exit `3`, reason `app-not-found`; never guess at another app.
52
52
  - Fetch crash detail with two calls - v1alpha has no get-issue-by-id endpoint: `GET .../v1alpha/projects/<projectId>/apps/<appId>/reports/topIssues?pageSize=200` for the summary and metrics (filter to the issue id), and `GET .../apps/<appId>/events?filter.issue.id=<issueId>&pageSize=1` for the newest event, which carries the stack frames, device, OS and breadcrumbs.
@@ -64,6 +64,24 @@ The answer then decides the shape of the question:
64
64
  | `mapped-but-missing` | say the credential is configured but not resolving, name the logical key, and offer the Save Flow: "`firebase` is mapped but the Keychain item is not resolving - refresh it, or paste the trace and I continue without it." |
65
65
  | `unmapped` | say the capability is not configured and offer setup: "No `firebase` key is mapped, so I cannot reach Crashlytics. Onboard it via `/multi-agent:setup`, or paste the trace." |
66
66
 
67
+ **`present` is not the same as usable, and Firebase is where that bites.** A
68
+ service-account JSON that resolves perfectly still reads nothing until an Owner
69
+ grants it a Crashlytics role, so the mapping-state column above is too coarse for
70
+ this one credential: run `--probe` and let the tier decide the question.
71
+
72
+ | Probe says | Ask this |
73
+ |---|---|
74
+ | `tier-1-ready` | ask nothing about access - ask for the issue URL and fetch |
75
+ | `tier-2-ready` | fetch through the signed-in session, and say once that headless runs (autopilot, url-enrichment) still need the role |
76
+ | `tier-1-no-grant` | offer both, in this order: **the durable fix** - one line to an Owner asking for `roles/firebasecrashlytics.viewer` on that service account, which also fixes headless runs; **the fast fix** - `firebase login` plus the MCP server, about two minutes, this machine only, expires |
77
+ | `malformed` | the stored value is not service-account JSON; offer the Save Flow, do not blame Crashlytics |
78
+ | `unreachable` | now the trace is worth asking for - and say it is the third option, and why the other two are unavailable |
79
+
80
+ Offering the paste before that measurement is the same defect in a new place: the
81
+ user is asked to do by hand what a credential they configured was supposed to do,
82
+ and this time the credential really was one grant away. Setup mechanics for both
83
+ tiers: `refs/setup/firebase.md`.
84
+
67
85
  **Never** present "paste it yourself" as the first and most efficient option when a
68
86
  credential is present. That is the defect this rule exists for: a run asked the user to
69
87
  paste a Crashlytics stack trace, offering the manual path as "the fastest and most
@@ -87,9 +105,18 @@ fixes them in different ways:
87
105
  | `auth-rejected` | 401/403: the credential is dead | name the logical key, run the Expired-token decision (Rule 1) |
88
106
  | `unreachable` | no response at all | on a corporate host this is almost always the VPN: "`{FORTIFY_HOST}` is not answering - connect the VPN and I will retry, or I continue without the finding" |
89
107
  | `no-host-configured` | a token exists but `global.hosts.<service>` was never recorded | "I hold the `jira` token but no Jira host is configured, so I cannot build a request URL - set it via `/multi-agent:setup`" |
90
- | `well-formed` | structurally valid, liveness not checkable yet | say what is still needed (the Crashlytics issue URL) |
108
+ | `tier-1-ready` | the Firebase service account can read Crashlytics headlessly | nothing - proceed, autopilot and url-enrichment work too |
109
+ | `tier-2-ready` | tier 1 is a role away, but a signed-in Firebase MCP session answers | proceed interactively; say that headless runs still need the role |
110
+ | `tier-1-no-grant` | the service account authenticates and holds no Crashlytics role | one line to an Owner: `roles/firebasecrashlytics.viewer` on that service account |
111
+ | `malformed` | the stored value is not the credential type this key expects | re-onboard the key; do not blame the service |
91
112
  | `not-probeable` | no cheap probe exists for this credential type | do not claim it works, do not claim it fails |
92
113
 
114
+ `well-formed` used to sit in this table for Firebase, and it was the wrong kind of
115
+ answer: it graded the JSON's shape, which is the one thing that was never in doubt.
116
+ The account was sound and simply held no Crashlytics role - invisible to any shape
117
+ check, and the reason the inventory promised stack frames it could not deliver.
118
+ Shape is not access; measure the access.
119
+
93
120
  Never collapse these into one "failed" bucket. A dead token, a closed VPN and a missing
94
121
  host look identical in a `{status: "failed"}` field and lead the user to three different
95
122
  wrong actions.
@@ -117,25 +144,25 @@ The shell driver auto-delegates to `~/.claude/scripts/keychain.py` on macOS / Li
117
144
 
118
145
  **Standard key names (convention for new tokens):**
119
146
 
120
- | Service ID | Context | Standard Key Name | Type |
121
- | ------------------ | ------------------ | ---------------------------------- | ----------------- |
122
- | `jira` | Jira issue fetch | `${USER}_Jira_Access_Token` | PAT |
123
- | `bitbucket_token` | Bitbucket PR/push | `${USER}_Bitbucket_Access_Token` | App Password |
124
- | `bitbucket_user` | Bitbucket username | `${USER}_Bitbucket_Username` | Plain text |
125
- | `github` | GitHub issue/PR | `${USER}_Github_Access_Token` | PAT (+ `gh auth`) |
126
- | `confluence` | Confluence docs | `${USER}_Confluence_Access_Token` | PAT |
127
- | `figma` | Figma | `${USER}_Figma_Access_Token` | PAT |
128
- | `figma_mcp` | Figma MCP | `${USER}_Figma_Mcp_Access_Token` | OAuth |
129
- | `fortify` | Fortify | `${USER}_Fortify_Access_Token` | API Token |
130
- | `graylog` | Graylog (prod) | `${USER}_Graylog_Access_Token` | API Token |
131
- | `graylog_test` | Graylog (test) | `${USER}_Graylog_Test_Access_Token` | API Token, optional - unset falls back to `graylog` |
132
- | `firebase` | Firebase | `${USER}_Firebase_Access_Json` | JSON (base64). One key per Firebase project; extras are named `..._Json_<projectId>` and listed in `global.firebase.accounts[]` |
133
- | `jenkins` | Jenkins CI | `${USER}_Jenkins_Access_Token` | API Token |
134
- | `appstore_connect_key_id` | App Store Connect | `${USER}_AppStoreConnect_Key_Id` | Identifier, not a secret |
135
- | `appstore_connect_issuer_id` | App Store Connect | `${USER}_AppStoreConnect_Issuer_Id` | Identifier, not a secret |
136
- | `appstore_connect_apple_id` | App Store Connect | `${USER}_AppStoreConnect_Apple_Id` | Email address |
137
- | `appstore_connect_password_item` | App Store Connect | `${USER}_AppStoreConnect_Password_Item` | Keychain ITEM NAME, not a password |
138
- | - | Git Identity | Stored in preferences JSON | Not Keychain |
147
+ | Service ID | Context | Standard Key Name | Type | Where to Get |
148
+ | ------------------ | ------------------ | ---------------------------------- | ----------------- | ------------ |
149
+ | `jira` | Jira issue fetch | `${USER}_Jira_Access_Token` | PAT | Jira -> Profile -> Personal Access Tokens (VPN) |
150
+ | `bitbucket_token` | Bitbucket PR/push | `${USER}_Bitbucket_Access_Token` | App Password | Bitbucket -> Personal settings -> App passwords |
151
+ | `bitbucket_user` | Bitbucket username | `${USER}_Bitbucket_Username` | Plain text | Bitbucket profile username (plain text, not a PAT) |
152
+ | `github` | GitHub issue/PR | `${USER}_Github_Access_Token` | PAT (+ `gh auth`) | GitHub Settings -> Tokens (scopes: repo, read:org, project) |
153
+ | `confluence` | Confluence docs | `${USER}_Confluence_Access_Token` | PAT | Confluence -> Profile -> Personal Access Tokens (VPN) |
154
+ | `figma` | Figma | `${USER}_Figma_Access_Token` | PAT | Figma Developer Settings (max 90 days). Tier 2 / REST |
155
+ | `figma_mcp` | Figma MCP | `${USER}_Figma_Mcp_Access_Token` | OAuth | Automatic via Claude Code Figma MCP remote auth. Tier 1 |
156
+ | `fortify` | Fortify | `${USER}_Fortify_Access_Token` | API Token | Fortify SSC -> Token Management (VPN) |
157
+ | `graylog` | Graylog (prod) | `${USER}_Graylog_Access_Token` | API Token | Graylog -> System -> Users and Teams -> Edit Tokens (VPN) |
158
+ | `graylog_test` | Graylog (test) | `${USER}_Graylog_Test_Access_Token` | API Token, optional - unset falls back to `graylog` | Same page on the TEST instance; optional |
159
+ | `firebase` | Firebase | `${USER}_Firebase_Access_Json` | Service-account JSON, as issued. One key per Firebase project; extras are named `..._Json_<projectId>` and listed in `global.firebase.accounts[]` | Firebase Console -> Project settings -> Service accounts -> Generate new private key |
160
+ | `jenkins` | Jenkins CI | `${USER}_Jenkins_Access_Token` | API Token | Jenkins -> User -> Configure -> API Token |
161
+ | `appstore_connect_key_id` | App Store Connect | `${USER}_AppStoreConnect_Key_Id` | Identifier, not a secret | App Store Connect -> Users and Access -> Integrations -> App Store Connect API |
162
+ | `appstore_connect_issuer_id` | App Store Connect | `${USER}_AppStoreConnect_Issuer_Id` | Identifier, not a secret | Same page as the key id; one issuer id per team |
163
+ | `appstore_connect_apple_id` | App Store Connect | `${USER}_AppStoreConnect_Apple_Id` | Email address | The Apple ID itself; no generation step |
164
+ | `appstore_connect_password_item` | App Store Connect | `${USER}_AppStoreConnect_Password_Item` | Keychain ITEM NAME, not a password | `xcrun altool --store-password-in-keychain-item`; map only the item NAME |
165
+ | - | Git Identity | Stored in preferences JSON | Not Keychain | Written to preferences by setup Step 4 |
139
166
 
140
167
  **Key name mapping lives in preferences:**
141
168
 
@@ -171,3 +198,21 @@ The shell driver auto-delegates to `~/.claude/scripts/keychain.py` on macOS / Li
171
198
  ```bash
172
199
  bash "$HOME/.claude/scripts/keychain-save.sh" <service-id>
173
200
  ```
201
+
202
+ ## Cross-platform mechanics
203
+
204
+ Moved out of `setup/SKILL.md`: it is reference detail every credential path shares,
205
+ and the setup skill only needs to route to it.
206
+
207
+ The credential store is platform-agnostic - every read/write goes through `~/.claude/lib/credential-store.sh`, which dispatches to the right backend (`security` on macOS, `secret-tool` on Linux, PowerShell `CredentialManager` on Windows). On macOS / Linux the shell driver delegates to `~/.claude/scripts/keychain.py` for deterministic behaviour. You almost never need the platform-native commands directly.
208
+
209
+ Clipboard helpers (`pbpaste`, `pbcopy`) are macOS-specific - for Copilot CLI on Linux substitute:
210
+
211
+ | macOS command | Linux equivalent | Notes |
212
+ |---|---|---|
213
+ | `pbpaste` | `xclip -selection clipboard -o` (X11) or `wl-paste` (Wayland) | Install with `apt install xclip` or `apt install wl-clipboard` |
214
+ | `pbcopy < /dev/null` | `xclip -selection clipboard < /dev/null` or `wl-copy --clear` | Same intent: clear clipboard after token paste |
215
+
216
+ If you need to bypass the helper (debugging, raw inspection), the underlying platform commands are documented at the top of `$HOME/.claude/lib/credential-store.sh`. Otherwise stay on the helper - it keeps secrets off argv (stdin sentinel `-`) and writes both `-l` and `-s` attributes on macOS.
217
+
218
+ If no backend is available, setup falls back to a plain-text prompt + a warning that the token is **not persisted** - pipeline phases will re-ask each session.
@@ -67,6 +67,33 @@ Knowledge files grow over time. Maintenance rules:
67
67
 
68
68
  ---
69
69
 
70
+ ## Retrieval order, and what each layer costs
71
+
72
+ Every durable store here answers in layers, and the order costs more than the
73
+ query does. Filter first, fetch second.
74
+
75
+ | # | Call | Returns | Rough cost per hit |
76
+ |---|---|---|---|
77
+ | 1 | `query` / `brief` / `search --semantic` | pointers: id, file, one line | ~50-100 tokens |
78
+ | 2 | `timeline --anchor <id>` | the rows recorded around one hit | ~50-100 tokens each |
79
+ | 3 | `show --id <id>` | one full row | ~500-1000 tokens |
80
+ | 4 | the artefact behind a `[[ref:<id>]]` | the raw payload | thousands |
81
+
82
+ Starting at layer 3 is the mistake worth naming: a `show` on twenty pointers
83
+ costs more than the search that produced them, and answers a question nobody
84
+ asked. Narrow at 1, widen at 2 only around what survived, and open 3 for the
85
+ handful that will actually be read.
86
+
87
+ Layer 2 is the one that gets skipped. A finding rarely stands alone - the same
88
+ review pass produced its neighbours, and those are usually what makes an old row
89
+ legible a year later. Both JSONL stores are append-only, so line adjacency is
90
+ chronological adjacency and the neighbourhood needs no index to answer:
91
+
92
+ ```bash
93
+ node "$HOME/.claude/scripts/triage-memory.mjs" timeline --anchor T:<id> --before 3 --after 3
94
+ node "$HOME/.claude/scripts/learnings-ledger.mjs" timeline --anchor L:<id> --before 3 --after 3
95
+ ```
96
+
70
97
  ## Skill Injection Strategy
71
98
 
72
99
  When calling sub-agents, inject task-specific context from previous phases: