@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.
- package/CHANGELOG.md +67 -0
- package/README.md +1 -1
- package/README.tr.md +1 -1
- package/install/templates/claude-hooks.json +32 -1
- package/package.json +1 -1
- package/pipeline/commands/multi-agent/help/SKILL.md +2 -0
- package/pipeline/commands/multi-agent/refactor/SKILL.md +23 -1
- package/pipeline/commands/multi-agent/search/SKILL.md +28 -0
- package/pipeline/commands/multi-agent/setup/SKILL.md +18 -44
- package/pipeline/commands/multi-agent/status/SKILL.md +9 -0
- package/pipeline/lib/credential-inventory.sh +142 -18
- package/pipeline/lib/fetch-crashlytics.sh +123 -28
- package/pipeline/multi-agent-refs/features/url-enrichment.md +1 -1
- package/pipeline/multi-agent-refs/keychain.md +65 -20
- package/pipeline/multi-agent-refs/knowledge.md +27 -0
- package/pipeline/multi-agent-refs/phases/operations.md +7 -1
- package/pipeline/multi-agent-refs/phases/phase-1-analysis.md +3 -1
- package/pipeline/multi-agent-refs/phases/phase-7-report.md +11 -21
- package/pipeline/multi-agent-refs/picker-contract.md +1 -1
- package/pipeline/multi-agent-refs/refactor/observations.md +81 -0
- package/pipeline/multi-agent-refs/setup/firebase.md +151 -0
- package/pipeline/schemas/learnings-ledger.schema.json +5 -0
- package/pipeline/schemas/prefs.schema.json +31 -3
- package/pipeline/schemas/skill-observation.schema.json +73 -0
- package/pipeline/scripts/capture-flush.sh +158 -0
- package/pipeline/scripts/capture-resume.sh +87 -0
- package/pipeline/scripts/crush-json.mjs +283 -0
- package/pipeline/scripts/firebase-app-discovery.sh +114 -0
- package/pipeline/scripts/keychain-save.sh +5 -8
- package/pipeline/scripts/keychain.py +76 -14
- package/pipeline/scripts/learn-from-transcripts.mjs +625 -0
- package/pipeline/scripts/learning-curve.mjs +22 -4
- package/pipeline/scripts/learnings-ledger.mjs +86 -12
- package/pipeline/scripts/note-session.sh +187 -0
- package/pipeline/scripts/observations.mjs +347 -0
- package/pipeline/scripts/offload-ref.sh +45 -2
- package/pipeline/scripts/pre-commit-check.sh +31 -1
- package/pipeline/scripts/scan-agent-config.sh +12 -3
- package/pipeline/scripts/skill-siblings.mjs +187 -0
- package/pipeline/scripts/triage-memory.mjs +73 -9
- package/pipeline/skills/shared/core/multi-agent-refactor/SKILL.md +23 -1
- package/pipeline/skills/shared/core/multi-agent-search/SKILL.md +28 -0
- package/pipeline/skills/shared/core/multi-agent-setup/SKILL.md +37 -6
- 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
|
|
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
|
-
#
|
|
223
|
-
#
|
|
224
|
-
#
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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 [
|
|
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
|
-
|
|
187
|
-
if [ -z "$
|
|
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
|
|
193
|
-
#
|
|
194
|
-
#
|
|
195
|
-
#
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
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
|
|
326
|
-
#
|
|
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>"
|
|
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
|
-
| `
|
|
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
|
|
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:
|