@mmerterden/multi-agent-pipeline 13.3.0 → 13.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +131 -0
- package/install/_mcp-register.mjs +125 -0
- package/install/codex.mjs +4 -38
- package/install/copilot.mjs +8 -0
- package/install/index.mjs +8 -3
- package/package.json +1 -1
- package/pipeline/commands/multi-agent/setup/SKILL.md +2 -1
- package/pipeline/lib/account-resolver.sh +15 -4
- package/pipeline/lib/credential-inventory.sh +374 -0
- package/pipeline/lib/credential-store-resolver.sh +34 -5
- package/pipeline/lib/fetch-confluence.sh +27 -5
- package/pipeline/lib/fetch-crashlytics.sh +27 -5
- package/pipeline/lib/fetch-figma-annotations.sh +18 -4
- package/pipeline/lib/fetch-fortify.sh +27 -5
- package/pipeline/lib/fetch-graylog.sh +27 -5
- package/pipeline/lib/figma-mcp-refresh.sh +35 -11
- package/pipeline/lib/figma-screenshot.sh +18 -7
- package/pipeline/lib/issue-fetcher.sh +19 -4
- package/pipeline/lib/post-pr-review.sh +27 -4
- package/pipeline/lib/repo-cache.sh +19 -4
- package/pipeline/lib/vercel-deploy.sh +15 -4
- package/pipeline/multi-agent-refs/features/external-context-injection.md +44 -3
- package/pipeline/multi-agent-refs/keychain.md +63 -0
- package/pipeline/multi-agent-refs/phases/phase-0-init.md +51 -18
- package/pipeline/multi-agent-refs/rules.md +3 -3
- package/pipeline/scripts/audit-log.sh +10 -4
- package/pipeline/scripts/keychain-save.sh +27 -5
- package/pipeline/scripts/phase-tracker.sh +26 -2
- package/pipeline/scripts/phase0-exit-gate.mjs +52 -0
- package/pipeline/scripts/uninstall.mjs +24 -5
- package/pipeline/skills/shared/core/multi-agent/SKILL.md +19 -0
|
@@ -20,7 +20,6 @@ set -euo pipefail
|
|
|
20
20
|
|
|
21
21
|
PREFS_FILE="${PREFS_FILE:-$HOME/.claude/multi-agent-preferences.json}"
|
|
22
22
|
TOKEN_ENDPOINT="https://api.figma.com/v1/oauth/token"
|
|
23
|
-
ACCOUNT="${USER:-$(whoami)}"
|
|
24
23
|
|
|
25
24
|
say() { printf '%s\n' "$*"; }
|
|
26
25
|
|
|
@@ -30,7 +29,36 @@ KEY_NAME=$(jq -r '.global.keychainMapping.figma_mcp // empty' "$PREFS_FILE")
|
|
|
30
29
|
[ -n "$KEY_NAME" ] || { say "figma-mcp-refresh: keychainMapping.figma_mcp not set"; exit 2; }
|
|
31
30
|
|
|
32
31
|
REFRESH_KEY="${KEY_NAME}_Refresh"
|
|
33
|
-
|
|
32
|
+
|
|
33
|
+
# Locate the resolver with an existence check, not a `.`-chain.
|
|
34
|
+
#
|
|
35
|
+
# Sourcing a file that does not exist aborts the shell under `set -e` - `||` included -
|
|
36
|
+
# so `. <candidate> || . <candidate> || { error }` reaches neither its later candidates
|
|
37
|
+
# nor its error branch. Every fetcher used that shape starting from `$HOME/.claude/...`,
|
|
38
|
+
# so on a Copilot-only or Codex-only install they all died with a bare exit 1 and no
|
|
39
|
+
# message. Reordering does not help: whichever candidate is absent aborts at that point.
|
|
40
|
+
# Checking for the file before sourcing it is the only safe form.
|
|
41
|
+
for _cred_resolver in \
|
|
42
|
+
"$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")" && pwd)/credential-store-resolver.sh" \
|
|
43
|
+
"$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")/../lib" 2>/dev/null && pwd)/credential-store-resolver.sh" \
|
|
44
|
+
"$HOME/.claude/lib/credential-store-resolver.sh" \
|
|
45
|
+
"$HOME/.copilot/lib/credential-store-resolver.sh" \
|
|
46
|
+
"$HOME/.codex/lib/credential-store-resolver.sh"; do
|
|
47
|
+
[ -f "$_cred_resolver" ] || continue
|
|
48
|
+
# shellcheck source=/dev/null
|
|
49
|
+
. "$_cred_resolver" 2>/dev/null || true
|
|
50
|
+
# `if`, not `[ ... ] && break`: the latter is the loop body's last command and returns
|
|
51
|
+
# 1 when CRED_STORE is still empty, which under `set -e` kills the loop on the first
|
|
52
|
+
# candidate that does not resolve - the very case the loop exists to survive.
|
|
53
|
+
if [ -n "${CRED_STORE:-}" ]; then break; fi
|
|
54
|
+
done
|
|
55
|
+
unset _cred_resolver
|
|
56
|
+
if [ -z "${CRED_STORE:-}" ]; then
|
|
57
|
+
say "figma-mcp-refresh: credential helper not found - run the pipeline installer"
|
|
58
|
+
exit 2
|
|
59
|
+
fi
|
|
60
|
+
|
|
61
|
+
REFRESH_TOKEN=$("$CRED_STORE" get "$REFRESH_KEY" 2>/dev/null || true)
|
|
34
62
|
[ -n "$REFRESH_TOKEN" ] || { say "figma-mcp-refresh: no refresh token at '$REFRESH_KEY'"; exit 2; }
|
|
35
63
|
|
|
36
64
|
SCRIPT_PATH=$(jq -r '.global.tokenScripts.figma_mcp // empty' "$PREFS_FILE")
|
|
@@ -69,17 +97,13 @@ if [ -z "$NEW_ACCESS" ]; then
|
|
|
69
97
|
exit 1
|
|
70
98
|
fi
|
|
71
99
|
|
|
72
|
-
# Keychain write
|
|
73
|
-
#
|
|
74
|
-
#
|
|
75
|
-
#
|
|
76
|
-
# previous direct call (-T "" -U). Figma tokens are URL-safe, so embedding in
|
|
77
|
-
# the quoted command string is safe.
|
|
100
|
+
# Keychain write through credential-store.sh with the `-` form, which reads the secret
|
|
101
|
+
# from stdin: the value never lands on any argv (visible to ps), which is the property
|
|
102
|
+
# the previous `security -i` call was carefully preserving. The store does the same thing
|
|
103
|
+
# per platform, so that property now holds on Linux and Windows as well.
|
|
78
104
|
save_keychain_secret() {
|
|
79
105
|
local service="$1" secret="$2"
|
|
80
|
-
|
|
81
|
-
printf 'add-generic-password -U -s "%s" -a "%s" -T "" -w "%s"\n' \
|
|
82
|
-
"$service" "$ACCOUNT" "$secret" | security -i >/dev/null
|
|
106
|
+
printf '%s' "$secret" | "$CRED_STORE" set "$service" - >/dev/null
|
|
83
107
|
}
|
|
84
108
|
|
|
85
109
|
# Save new access token first, then the rotated refresh token - order matters so a
|
|
@@ -42,13 +42,24 @@ set -euo pipefail
|
|
|
42
42
|
# --- Globals ----------------------------------------------------------------
|
|
43
43
|
|
|
44
44
|
PREFS="$HOME/.claude/multi-agent-preferences.json"
|
|
45
|
-
# Locate the
|
|
46
|
-
#
|
|
47
|
-
#
|
|
48
|
-
#
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
45
|
+
# Locate the resolver with an existence check, not a `.`-chain: sourcing a missing file
|
|
46
|
+
# aborts the shell under `set -e`, `||` included, so a chain skips both its later
|
|
47
|
+
# candidates and its trailing `|| true`. A missing store is tolerated here - CRED_STORE
|
|
48
|
+
# simply stays empty and the caller falls back to an env-supplied token.
|
|
49
|
+
for _cred_resolver in \
|
|
50
|
+
"$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")" && pwd)/credential-store-resolver.sh" \
|
|
51
|
+
"$HOME/.claude/lib/credential-store-resolver.sh" \
|
|
52
|
+
"$HOME/.copilot/lib/credential-store-resolver.sh" \
|
|
53
|
+
"$HOME/.codex/lib/credential-store-resolver.sh"; do
|
|
54
|
+
[ -f "$_cred_resolver" ] || continue
|
|
55
|
+
# shellcheck source=/dev/null
|
|
56
|
+
. "$_cred_resolver" 2>/dev/null || true
|
|
57
|
+
# `if`, not `[ ... ] && break`: the latter is the loop body's last command and returns
|
|
58
|
+
# 1 when CRED_STORE is still empty, which under `set -e` kills the loop on the first
|
|
59
|
+
# candidate that does not resolve - the very case the loop exists to survive.
|
|
60
|
+
if [ -n "${CRED_STORE:-}" ]; then break; fi
|
|
61
|
+
done
|
|
62
|
+
unset _cred_resolver
|
|
52
63
|
CRED_STORE="${CRED_STORE:-}"
|
|
53
64
|
FIGMA_API="https://api.figma.com/v1"
|
|
54
65
|
PARALLEL_DL=4
|
|
@@ -113,10 +113,25 @@ fetch_jira() {
|
|
|
113
113
|
# A pre-set CRED_STORE (tests, custom installs) is honored as-is.
|
|
114
114
|
if [ -z "${CRED_STORE:-}" ]; then
|
|
115
115
|
# shellcheck disable=SC1090,SC1091
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
116
|
+
# Existence check before sourcing: `. <missing>` aborts the shell under `set -e`,
|
|
117
|
+
# `||` included, so a `.`-chain reaches neither its later candidates nor its error
|
|
118
|
+
# branch. The loop also covers all three hosts - the chain it replaced knew only
|
|
119
|
+
# .claude and .copilot, so a Codex-only install could not resolve at all.
|
|
120
|
+
for _cred_resolver in \
|
|
121
|
+
"$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")" && pwd)/credential-store-resolver.sh" \
|
|
122
|
+
"$HOME/.claude/lib/credential-store-resolver.sh" \
|
|
123
|
+
"$HOME/.copilot/lib/credential-store-resolver.sh" \
|
|
124
|
+
"$HOME/.codex/lib/credential-store-resolver.sh"; do
|
|
125
|
+
[ -f "$_cred_resolver" ] || continue
|
|
126
|
+
# shellcheck source=/dev/null
|
|
127
|
+
. "$_cred_resolver" 2>/dev/null || true
|
|
128
|
+
if [ -n "${CRED_STORE:-}" ]; then break; fi
|
|
129
|
+
done
|
|
130
|
+
unset _cred_resolver
|
|
131
|
+
if [ -z "${CRED_STORE:-}" ]; then
|
|
132
|
+
echo "ERR: credential helper not found" >&2
|
|
133
|
+
return 1
|
|
134
|
+
fi
|
|
120
135
|
fi
|
|
121
136
|
local token
|
|
122
137
|
token=$("$CRED_STORE" get "$JIRA_TOKEN_KEY" 2>/dev/null || true)
|
|
@@ -104,7 +104,14 @@ echo "post-pr-review: decision=$DECISION (blocking=$ACCEPTED_BLOCKING, important
|
|
|
104
104
|
# when the suggested fix wording changes slightly between iterations.
|
|
105
105
|
finding_fingerprint() {
|
|
106
106
|
local path="$1" line="$2" issue="$3"
|
|
107
|
-
|
|
107
|
+
# sha256sum (coreutils) or shasum (perl), whichever this platform has. A bare
|
|
108
|
+
# `shasum` produced an empty fingerprint on slim Linux images, which made every
|
|
109
|
+
# finding look new and re-posted the whole review on each iteration.
|
|
110
|
+
if command -v sha256sum >/dev/null 2>&1; then
|
|
111
|
+
printf '%s|%s|%s' "$path" "$line" "$issue" | sha256sum | cut -c1-16
|
|
112
|
+
else
|
|
113
|
+
printf '%s|%s|%s' "$path" "$line" "$issue" | shasum -a 256 | cut -c1-16
|
|
114
|
+
fi
|
|
108
115
|
}
|
|
109
116
|
|
|
110
117
|
render_inline_body() {
|
|
@@ -307,9 +314,25 @@ post_bitbucket_server() {
|
|
|
307
314
|
# Credential Manager / Linux libsecret) - sourced lazily so this script keeps
|
|
308
315
|
# working when run from either ~/.claude/lib or ~/.copilot/lib.
|
|
309
316
|
# shellcheck disable=SC1090
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
317
|
+
# Existence check before sourcing: `. <missing>` aborts the shell under `set -e`,
|
|
318
|
+
# `||` included, so a `.`-chain reaches neither its later candidates nor its error
|
|
319
|
+
# branch. The loop also covers all three hosts - the chain it replaced knew only
|
|
320
|
+
# .claude and .copilot, so a Codex-only install could not resolve at all.
|
|
321
|
+
for _cred_resolver in \
|
|
322
|
+
"$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")" && pwd)/credential-store-resolver.sh" \
|
|
323
|
+
"$HOME/.claude/lib/credential-store-resolver.sh" \
|
|
324
|
+
"$HOME/.copilot/lib/credential-store-resolver.sh" \
|
|
325
|
+
"$HOME/.codex/lib/credential-store-resolver.sh"; do
|
|
326
|
+
[ -f "$_cred_resolver" ] || continue
|
|
327
|
+
# shellcheck source=/dev/null
|
|
328
|
+
. "$_cred_resolver" 2>/dev/null || true
|
|
329
|
+
if [ -n "${CRED_STORE:-}" ]; then break; fi
|
|
330
|
+
done
|
|
331
|
+
unset _cred_resolver
|
|
332
|
+
if [ -z "${CRED_STORE:-}" ]; then
|
|
333
|
+
echo "post-pr-review: credential helper not found" >&2
|
|
334
|
+
return 4
|
|
335
|
+
fi
|
|
313
336
|
resolve_credential_store || return 4
|
|
314
337
|
|
|
315
338
|
auth_user=$("$CRED_STORE" get "$user_key" 2>/dev/null || true)
|
|
@@ -142,10 +142,25 @@ print(json.dumps(out))
|
|
|
142
142
|
# A pre-set CRED_STORE (tests, custom installs) is honored as-is.
|
|
143
143
|
if [ -z "${CRED_STORE:-}" ]; then
|
|
144
144
|
# shellcheck disable=SC1090,SC1091
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
145
|
+
# Existence check before sourcing: `. <missing>` aborts the shell under `set -e`,
|
|
146
|
+
# `||` included, so a `.`-chain reaches neither its later candidates nor its error
|
|
147
|
+
# branch. The loop also covers all three hosts - the chain it replaced knew only
|
|
148
|
+
# .claude and .copilot, so a Codex-only install could not resolve at all.
|
|
149
|
+
for _cred_resolver in \
|
|
150
|
+
"$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")" && pwd)/credential-store-resolver.sh" \
|
|
151
|
+
"$HOME/.claude/lib/credential-store-resolver.sh" \
|
|
152
|
+
"$HOME/.copilot/lib/credential-store-resolver.sh" \
|
|
153
|
+
"$HOME/.codex/lib/credential-store-resolver.sh"; do
|
|
154
|
+
[ -f "$_cred_resolver" ] || continue
|
|
155
|
+
# shellcheck source=/dev/null
|
|
156
|
+
. "$_cred_resolver" 2>/dev/null || true
|
|
157
|
+
if [ -n "${CRED_STORE:-}" ]; then break; fi
|
|
158
|
+
done
|
|
159
|
+
unset _cred_resolver
|
|
160
|
+
if [ -z "${CRED_STORE:-}" ]; then
|
|
161
|
+
echo "ERR: credential helper not found" >&2
|
|
162
|
+
exit 3
|
|
163
|
+
fi
|
|
149
164
|
fi
|
|
150
165
|
BB_USER="${BB_USER:-${BB_USER_KEY:+$("$CRED_STORE" get "$BB_USER_KEY" 2>/dev/null || true)}}"
|
|
151
166
|
BB_TOKEN=$("$CRED_STORE" get "$BB_TOKEN_KEY" 2>/dev/null || true)
|
|
@@ -115,10 +115,21 @@ cmd_deploy() {
|
|
|
115
115
|
# The resolver ships alongside this wrapper in lib/; fall back to the
|
|
116
116
|
# per-CLI install locations when running from a different cwd layout.
|
|
117
117
|
# shellcheck source=/dev/null
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
118
|
+
# Existence check before sourcing: `. <missing>` aborts the shell under `set -e`,
|
|
119
|
+
# `||` included, so a `.`-chain reaches neither its later candidates nor its error
|
|
120
|
+
# branch. The loop also covers all three hosts - the chain it replaced knew only
|
|
121
|
+
# .claude and .copilot, so a Codex-only install could not resolve at all.
|
|
122
|
+
for _cred_resolver in \
|
|
123
|
+
"$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")" && pwd)/credential-store-resolver.sh" \
|
|
124
|
+
"$HOME/.claude/lib/credential-store-resolver.sh" \
|
|
125
|
+
"$HOME/.copilot/lib/credential-store-resolver.sh" \
|
|
126
|
+
"$HOME/.codex/lib/credential-store-resolver.sh"; do
|
|
127
|
+
[ -f "$_cred_resolver" ] || continue
|
|
128
|
+
# shellcheck source=/dev/null
|
|
129
|
+
. "$_cred_resolver" 2>/dev/null || true
|
|
130
|
+
if [ -n "${CRED_STORE:-}" ]; then break; fi
|
|
131
|
+
done
|
|
132
|
+
unset _cred_resolver
|
|
122
133
|
echo "ERROR: VERCEL_TOKEN not set. Resolve via env or keychain ('${CRED_STORE:-credential-store.sh} get <vercel-key>')." >&2
|
|
123
134
|
echo "Hint: vercel CLI accepts the token via env var; do NOT pass --token= on argv (leaks on retry)." >&2
|
|
124
135
|
exit 1
|
|
@@ -32,12 +32,53 @@ One fetcher per type. Each fetcher emits a normalized JSON view that the analysi
|
|
|
32
32
|
|
|
33
33
|
- `0` (success) → output appended to `state.fetchedContext.<type>[]` and injected into the prompt.
|
|
34
34
|
- `2` (blocked / missing token) → run the inline Token Save Flow per `setup.md`; on skip, mark the entry as `{status: "skipped", reason: "missing-token"}` and continue.
|
|
35
|
-
- `3` (network/auth) →
|
|
35
|
+
- `3` (network/auth) → **surface the Unreachable-source decision below.** Never silently downgrade.
|
|
36
36
|
- `4`/`5`/`6` (config errors) → mark as `{status: "failed", reason: "<exit-msg>"}`, continue and log a setup hint.
|
|
37
37
|
|
|
38
|
-
|
|
38
|
+
### MUST: an unreachable source is announced, not absorbed (BLOCKING)
|
|
39
39
|
|
|
40
|
-
|
|
40
|
+
A fetcher that fails is the pipeline losing the ground truth it was told to use. The
|
|
41
|
+
user is the only one who can fix it - the token is theirs to refresh, the VPN is theirs
|
|
42
|
+
to connect - and they cannot fix what they are not told about.
|
|
43
|
+
|
|
44
|
+
This used to read "mark as failed, continue". The observed result: an expired token and
|
|
45
|
+
a VPN-off remote both reached the analysis phase as *no data*, indistinguishable from a
|
|
46
|
+
ticket that referenced nothing. The run then produced a plan from a partial picture and
|
|
47
|
+
reported success, and the user found out by reading the output.
|
|
48
|
+
|
|
49
|
+
So on exit `3`, classify the stderr and surface an `AskUserQuestion` before continuing.
|
|
50
|
+
The classification is the same shape the Phase 6 remote gate uses, because the failures
|
|
51
|
+
are the same failures:
|
|
52
|
+
|
|
53
|
+
| stderr signal | Diagnosis | Question offers |
|
|
54
|
+
|---|---|---|
|
|
55
|
+
| `401`, `403`, `invalid_grant`, `token expired`, `unauthorized` | the credential is dead | **Refresh `<KEY_NAME>`** (Save Flow Step B, then retry) · **Continue without this source** · **Abort** |
|
|
56
|
+
| `could not resolve host`, `connection refused`, `timed out`, `network is unreachable`, corporate host in the URL | the host is unreachable, VPN almost certainly off | **Connect the VPN, then retry** (default) · **Continue without this source** · **Abort** |
|
|
57
|
+
| `404`, `no such issue`, `not found` | the reference is stale or the link points at something deleted | **Supply a current URL** · **Continue without this source** · **Abort** |
|
|
58
|
+
| anything else | unknown | verbatim stderr in the `description`, same three options |
|
|
59
|
+
|
|
60
|
+
Rules that make this actionable rather than decorative:
|
|
61
|
+
|
|
62
|
+
1. **Name the thing.** The question states which source (`crashlytics`), which URL, and
|
|
63
|
+
which credential key by its logical name (`firebase`, never the raw Keychain item).
|
|
64
|
+
2. **Say what is lost.** "Without it I plan from the exception message alone, with no
|
|
65
|
+
stack frames and no affected-version spread" beats "continuing without context".
|
|
66
|
+
3. **Retry is a real branch.** On "Connect the VPN, then retry" / "Refresh", re-run the
|
|
67
|
+
same fetcher and re-enter this table on a second failure. Do not fall through to
|
|
68
|
+
"continue" after one attempt.
|
|
69
|
+
4. **Record the choice.** `state.fetchedContext.<type>[].status` ∈ `ok | skipped | failed`
|
|
70
|
+
plus `userDecision` ∈ `retried-ok | continued-without | aborted`, so Phase 1 can put
|
|
71
|
+
"planned without Crashlytics, user's choice at <ts>" in the analysis doc's limitations
|
|
72
|
+
section instead of leaving a silent hole.
|
|
73
|
+
5. **Autopilot still reports.** Autopilot takes "continue without this source" without
|
|
74
|
+
asking, but the skipped source is logged and carried into the Phase 7 report. An
|
|
75
|
+
autopilot run that quietly planned from a partial picture is the same defect with a
|
|
76
|
+
flag on it.
|
|
77
|
+
|
|
78
|
+
Failures remain non-fatal at Phase 1 by default - the agent still runs analysis. What
|
|
79
|
+
changed is that the user learns about it while the choice is still theirs to make.
|
|
80
|
+
|
|
81
|
+
**Graylog specifics.** `fetch-graylog.sh` degrades to empty on any network/VPN failure: it emits a normalized empty object and exits `0`, so an unreachable host reads as `{status:"skipped", reason:"vpn-unreachable"}` and never blocks. **Exiting `0` is not permission to stay quiet.** The payload carries `degraded: true` and a `degradeReason`; when it does, log one line naming the host and the reason, and carry it into the Phase 7 report. Logs are advisory, so this never asks a question and never blocks - but "I searched the logs and found nothing" and "I never reached the log server" are different statements, and only one of them is true. Only a genuine auth rejection on a reachable host exits `3` (marked `failed`, still non-fatal here). `state.graylogContext` is prepended to the analysis prompt inside the **Referenced External Sources** section as diagnostic context that is **advisory only** - the agent may use it to orient on a reported error, but code remains ground truth, and there is no Phase 4 review gate for logs.
|
|
41
82
|
|
|
42
83
|
## Prompt injection shape
|
|
43
84
|
|
|
@@ -40,6 +40,69 @@ Reasons:
|
|
|
40
40
|
|
|
41
41
|
The smoke gate `pipeline/scripts/smoke-no-token-prompt.sh` greps the pipeline source for hardcoded "enter token" / "paste token" / "API key:" prompts and fails the build if any are introduced. Token I/O is exclusively `credential-store.sh` calls.
|
|
42
42
|
|
|
43
|
+
### Rule 2 - MUST: never ask the user for what a mapped credential can fetch (BLOCKING)
|
|
44
|
+
|
|
45
|
+
Before asking the user for data that an external system holds - a stack trace, a log
|
|
46
|
+
line, a page body, a finding, a design frame - inventory the credentials first:
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
bash "$HOME/.claude/lib/credential-inventory.sh" --json # what is configured
|
|
50
|
+
bash "$HOME/.claude/lib/credential-inventory.sh" --json --probe # and what actually answers
|
|
51
|
+
bash "$HOME/.claude/lib/credential-inventory.sh" --key firebase # exit 0 = usable
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
The inventory reads `prefs.global.keychainMapping`, probes each logical key through
|
|
55
|
+
`credential-store.sh get` with the value discarded, and reports `present` /
|
|
56
|
+
`mapped-but-missing` / `unmapped` alongside what each key unlocks. Values are never
|
|
57
|
+
printed.
|
|
58
|
+
|
|
59
|
+
The answer then decides the shape of the question:
|
|
60
|
+
|
|
61
|
+
| Inventory says | The question must be |
|
|
62
|
+
|---|---|
|
|
63
|
+
| `present` | ask for the **pointer**, not the payload, and say you will fetch it: "Give me the Crashlytics issue URL - I have the Firebase service account mapped and will pull the stack frames, affected versions and device spread myself." |
|
|
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
|
+
| `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
|
+
|
|
67
|
+
**Never** present "paste it yourself" as the first and most efficient option when a
|
|
68
|
+
credential is present. That is the defect this rule exists for: a run asked the user to
|
|
69
|
+
paste a Crashlytics stack trace, offering the manual path as "the fastest and most
|
|
70
|
+
certain route", while a valid Firebase service-account JSON sat in the Keychain mapped
|
|
71
|
+
as `firebase`. The user had configured that key precisely so the pipeline would not ask.
|
|
72
|
+
|
|
73
|
+
**State the limit honestly too.** A credential is not omniscience: the Crashlytics
|
|
74
|
+
fetcher resolves a specific issue and needs its URL, so "I have the key" does not mean
|
|
75
|
+
"I can find the crash from an exception message alone". Say which piece is missing and
|
|
76
|
+
why, rather than implying either more or less capability than exists.
|
|
77
|
+
|
|
78
|
+
#### Present is not the same as working
|
|
79
|
+
|
|
80
|
+
`--probe` makes one cheap authenticated request per configured credential, because
|
|
81
|
+
"the key is in the Keychain" and "the service answers" are different claims and the user
|
|
82
|
+
fixes them in different ways:
|
|
83
|
+
|
|
84
|
+
| Verdict | What it means | What to tell the user |
|
|
85
|
+
|---|---|---|
|
|
86
|
+
| `reachable` | the service answered and accepted the credential | nothing - proceed |
|
|
87
|
+
| `auth-rejected` | 401/403: the credential is dead | name the logical key, run the Expired-token decision (Rule 1) |
|
|
88
|
+
| `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
|
+
| `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) |
|
|
91
|
+
| `not-probeable` | no cheap probe exists for this credential type | do not claim it works, do not claim it fails |
|
|
92
|
+
|
|
93
|
+
Never collapse these into one "failed" bucket. A dead token, a closed VPN and a missing
|
|
94
|
+
host look identical in a `{status: "failed"}` field and lead the user to three different
|
|
95
|
+
wrong actions.
|
|
96
|
+
|
|
97
|
+
Probe only what is configured. An unmapped key is a capability the user chose not to
|
|
98
|
+
enable - reporting it as a problem trains them to ignore the report. Cache the result in
|
|
99
|
+
`prefs.global.serviceStatus.<service>` (`{ok, checkedAt, error?}`, TTL
|
|
100
|
+
`settings.serviceStatusCacheSeconds`) so a phase chain probes once, not per fetcher.
|
|
101
|
+
|
|
102
|
+
Record what the inventory found in `agent-state.json.credentialInventory`
|
|
103
|
+
(`{usable: [...], needsAttention: [...], at: <ts>}`) so a later phase can tell "the
|
|
104
|
+
source was unreachable" from "nobody looked".
|
|
105
|
+
|
|
43
106
|
**Retrieve pattern (always use mapping):**
|
|
44
107
|
|
|
45
108
|
```bash
|
|
@@ -136,6 +136,22 @@ Results are cached in `global.serviceStatus` (existing TTL contract, default 300
|
|
|
136
136
|
|
|
137
137
|
Probe failures caused by network (timeout, DNS) are NOT treated as expiry - log and continue; the mid-run 401 path still exists as the safety net.
|
|
138
138
|
|
|
139
|
+
**Record the inventory.** After the probes, persist what is reachable:
|
|
140
|
+
|
|
141
|
+
```bash
|
|
142
|
+
# --probe when the task references an external source (Jira ID, Crashlytics/Fortify URL,
|
|
143
|
+
# a remote to fetch): "the key is in the Keychain" and "the service answers" are
|
|
144
|
+
# different claims, and only the second one lets the run proceed.
|
|
145
|
+
bash "$HOME/.claude/lib/credential-inventory.sh" --json --probe > /tmp/cred-inventory-${TASK_ID}.json
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
Write `{usable, needsAttention, at}` into `agent-state.json.credentialInventory`. Later
|
|
149
|
+
phases read it instead of re-probing, and it is what makes `keychain.md` Rule 2
|
|
150
|
+
enforceable: any question that asks the user for data an external system holds must be
|
|
151
|
+
shaped by this file. A run that asks the user to paste a Crashlytics stack trace while
|
|
152
|
+
`firebase` is listed under `usable` is a Rule 2 violation, and the inventory is the
|
|
153
|
+
evidence.
|
|
154
|
+
|
|
139
155
|
#### Step 1 - Parse Input
|
|
140
156
|
|
|
141
157
|
**Branch from input**: If the user provided a branch name after the issue reference (space-separated), store as `baseBranch` and skip Step 3. Otherwise Step 3 asks interactively.
|
|
@@ -233,18 +249,30 @@ Scan `$HOME` (maxdepth 2) for project markers (`.xcodeproj`, `Package.swift`, `b
|
|
|
233
249
|
| grep -E '(develop|release|main|master)' \
|
|
234
250
|
| grep -v -E '(feature/|bugfix/|fix/|hotfix/|chore/)'
|
|
235
251
|
```
|
|
236
|
-
6. Sort: `develop*` first, then `release/*`, then `main`/`master`.
|
|
252
|
+
6. Sort: `develop*` first, then `release/*`, then `main`/`master`. Surface through the
|
|
253
|
+
**native picker** per `picker-contract.md` (`AskUserQuestion` on Claude Code,
|
|
254
|
+
`ask_choice.sh` on Copilot CLI) - `question` + `description` in `outputLanguage`,
|
|
255
|
+
`label` English, the recent branch first and marked `(Recommended)`. The ASCII sketch
|
|
256
|
+
below is what the options carry, not a menu to print:
|
|
257
|
+
|
|
237
258
|
```
|
|
238
|
-
Base branch
|
|
239
|
-
|
|
240
|
-
────────────────────
|
|
241
|
-
1. origin/develop
|
|
242
|
-
2. origin/main
|
|
243
|
-
...
|
|
244
|
-
Select [number] or enter for suggested:
|
|
259
|
+
header: "Base branch"
|
|
260
|
+
options: origin/develop (Recommended, reused from last run) | origin/main | release/8.4.0 | Other
|
|
245
261
|
```
|
|
246
262
|
7. User picks → store as `baseBranch`. Save to `prefs.projects[{project}].branches` (dedup, max 10).
|
|
247
263
|
|
|
264
|
+
**MUST: this step is not skippable (BLOCKING).** The only legitimate skip is rule 4
|
|
265
|
+
above - `baseBranch` already supplied in the input. Everything else asks. A run once
|
|
266
|
+
took a Jira ID and implemented straight onto whatever the local checkout was pointing
|
|
267
|
+
at, with neither the project nor the branch picker ever shown; nothing failed, so
|
|
268
|
+
nothing surfaced it. `phase0-exit-gate.mjs` now refuses to close Phase 0 unless
|
|
269
|
+
`agent-state.json` carries `baseBranch` and `baseFetchStatus`, so a skipped picker is a
|
|
270
|
+
gate failure rather than a silent default.
|
|
271
|
+
|
|
272
|
+
This holds in every mode. `--dev` skips the *LLM* phases (Analysis, Planning); it does
|
|
273
|
+
not skip Phase 0's pickers. Autopilot resolves them to their defaults without prompting,
|
|
274
|
+
which still writes the fields - it does not leave them unset.
|
|
275
|
+
|
|
248
276
|
**TTL filter for recent branches**:
|
|
249
277
|
|
|
250
278
|
- `prefs.global.recentBranches[{projectKey}][]` carries `{name, lastUsed}`. Filter to those whose `lastUsed` is within `settings.branchTtlDays` (default 15).
|
|
@@ -253,20 +281,25 @@ Scan `$HOME` (maxdepth 2) for project markers (`.xcodeproj`, `Package.swift`, `b
|
|
|
253
281
|
|
|
254
282
|
**Fetch-fail handling** (replaces silent `git fetch origin` failure):
|
|
255
283
|
|
|
256
|
-
The legacy `git -C $PROJECT_ROOT fetch origin` step (line 110 above) MUST not silently fall back to a stale cached ref. On non-zero exit,
|
|
284
|
+
The legacy `git -C $PROJECT_ROOT fetch origin` step (line 110 above) MUST not silently fall back to a stale cached ref. On non-zero exit, surface the **native picker** (per `picker-contract.md`) with 4 options:
|
|
257
285
|
|
|
258
286
|
```
|
|
259
|
-
git fetch
|
|
260
|
-
Likely
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
Select:
|
|
287
|
+
question: "git fetch failed for {project} - the base ref may be stale. How should I proceed?"
|
|
288
|
+
description: "exit {code}, last successful fetch {ts}. Likely: VPN closed, host unreachable, auth expired."
|
|
289
|
+
header: "Base ref"
|
|
290
|
+
options:
|
|
291
|
+
Connect VPN and retry (Recommended) re-run the fetch, then continue with a fresh ref
|
|
292
|
+
Use cached origin ref stale risk: base sha {sha}, fetched {since}
|
|
293
|
+
Use local branch as base only offered when the local branch exists; commit {sha}
|
|
294
|
+
Abort no worktree, no branch, no state file
|
|
268
295
|
```
|
|
269
296
|
|
|
297
|
+
**Say which is which.** The question must name the corporate host when the remote points
|
|
298
|
+
at one - a `{BITBUCKET_HOST}` remote failing to resolve is almost always the VPN, and
|
|
299
|
+
telling the user that is the difference between a five-second fix and a run built on a
|
|
300
|
+
month-old ref. "Connect VPN and retry" re-runs the fetch and re-enters this picker if it
|
|
301
|
+
fails again; it is a real retry, not a label.
|
|
302
|
+
|
|
270
303
|
Persist user choice in `agent-state.json.baseFetchStatus` ∈ `"fresh" | "cached-stale" | "local-branch" | "aborted"`. On any non-fresh choice, log:
|
|
271
304
|
```
|
|
272
305
|
⚠️ Base ref stale (fetch fail @ {ts}, choice: {cached-stale|local-branch})
|
|
@@ -163,10 +163,10 @@ Provider tools that print failed argv on retry leak credentials into the convers
|
|
|
163
163
|
# goes through the cross-platform credential helper so the same snippet works
|
|
164
164
|
# on macOS / Linux / Windows installs:
|
|
165
165
|
VERCEL_TOKEN="$(~/.claude/lib/credential-store.sh get mmerterden_Vercel_Access_Token)" \
|
|
166
|
-
bash $HOME/.claude/
|
|
166
|
+
bash $HOME/.claude/lib/vercel-deploy.sh deploy --prod
|
|
167
167
|
|
|
168
168
|
# Health check before deploy:
|
|
169
|
-
bash $HOME/.claude/
|
|
169
|
+
bash $HOME/.claude/lib/vercel-deploy.sh doctor
|
|
170
170
|
```
|
|
171
171
|
|
|
172
172
|
**Forbidden:**
|
|
@@ -177,7 +177,7 @@ vercel deploy --token=vcp_...
|
|
|
177
177
|
vercel deploy --token "$VERCEL_TOKEN"
|
|
178
178
|
```
|
|
179
179
|
|
|
180
|
-
The wrapper at `pipeline/lib/vercel-deploy.sh` (installed to `~/.claude/
|
|
180
|
+
The wrapper at `pipeline/lib/vercel-deploy.sh` (installed to `~/.claude/lib/`, `~/.copilot/lib/` and `~/.codex/lib/` with the rest of the shell libraries) refuses any `--token=` argv input, runs the CLI with `VERCEL_TOKEN` env, and pipes every stdout/stderr line through a redact filter that scrubs `vcp_...`, `Bearer ...`, and JSON-body token shapes. Regression gate: `smoke-vercel-deploy-redact.sh` (12 assertions).
|
|
181
181
|
|
|
182
182
|
Same rule applies to any future provider wrapper (e.g. `cloudflare-deploy.sh`, `npm-publish.sh`) - never pass tokens via argv when the underlying CLI may echo argv on failure.
|
|
183
183
|
|
|
@@ -48,12 +48,18 @@ json_escape() {
|
|
|
48
48
|
hash_url() {
|
|
49
49
|
local url="$1"
|
|
50
50
|
[ -z "$url" ] && { echo ""; return; }
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
51
|
+
# sha256sum first: it is the coreutils tool, present on Linux and in slim containers
|
|
52
|
+
# where `shasum` (a perl script) is not.
|
|
53
|
+
if command -v sha256sum >/dev/null 2>&1; then
|
|
54
54
|
printf '%s' "$url" | sha256sum | awk '{print $1}'
|
|
55
|
+
elif command -v shasum >/dev/null 2>&1; then
|
|
56
|
+
printf '%s' "$url" | shasum -a 256 | awk '{print $1}'
|
|
55
57
|
else
|
|
56
|
-
|
|
58
|
+
# The previous fallback printed `unhashed:<url>`, writing the plaintext remote into
|
|
59
|
+
# the audit log - the exact thing this hashing exists to prevent, and it would have
|
|
60
|
+
# carried any credentials embedded in the remote URL with it. Losing one field beats
|
|
61
|
+
# leaking one.
|
|
62
|
+
echo "unhashable-no-sha256-tool"
|
|
57
63
|
fi
|
|
58
64
|
}
|
|
59
65
|
|
|
@@ -53,11 +53,33 @@ case "$CHOICE" in
|
|
|
53
53
|
;;
|
|
54
54
|
esac
|
|
55
55
|
|
|
56
|
-
# Locate the
|
|
57
|
-
#
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
56
|
+
# Locate the resolver with an existence check, not a `.`-chain.
|
|
57
|
+
#
|
|
58
|
+
# Sourcing a file that does not exist aborts the shell under `set -e` - `||` included -
|
|
59
|
+
# so `. <candidate> || . <candidate> || { error }` reaches neither its later candidates
|
|
60
|
+
# nor its error branch. Every fetcher used that shape starting from `$HOME/.claude/...`,
|
|
61
|
+
# so on a Copilot-only or Codex-only install they all died with a bare exit 1 and no
|
|
62
|
+
# message. Reordering does not help: whichever candidate is absent aborts at that point.
|
|
63
|
+
# Checking for the file before sourcing it is the only safe form.
|
|
64
|
+
for _cred_resolver in \
|
|
65
|
+
"$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")" && pwd)/credential-store-resolver.sh" \
|
|
66
|
+
"$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")/../lib" 2>/dev/null && pwd)/credential-store-resolver.sh" \
|
|
67
|
+
"$HOME/.claude/lib/credential-store-resolver.sh" \
|
|
68
|
+
"$HOME/.copilot/lib/credential-store-resolver.sh" \
|
|
69
|
+
"$HOME/.codex/lib/credential-store-resolver.sh"; do
|
|
70
|
+
[ -f "$_cred_resolver" ] || continue
|
|
71
|
+
# shellcheck source=/dev/null
|
|
72
|
+
. "$_cred_resolver" 2>/dev/null || true
|
|
73
|
+
# `if`, not `[ ... ] && break`: the latter is the loop body's last command and returns
|
|
74
|
+
# 1 when CRED_STORE is still empty, which under `set -e` kills the loop on the first
|
|
75
|
+
# candidate that does not resolve - the very case the loop exists to survive.
|
|
76
|
+
if [ -n "${CRED_STORE:-}" ]; then break; fi
|
|
77
|
+
done
|
|
78
|
+
unset _cred_resolver
|
|
79
|
+
if [ -z "${CRED_STORE:-}" ]; then
|
|
80
|
+
echo "Hata: credential-store bulunamadi. Kurulum: npx @mmerterden/multi-agent-pipeline install"
|
|
81
|
+
exit 1
|
|
82
|
+
fi
|
|
61
83
|
CRED="$CRED_STORE"
|
|
62
84
|
|
|
63
85
|
"$CRED" delete "$SERVICE_NAME" >/dev/null 2>&1 || true
|
|
@@ -193,6 +193,25 @@ MA_DECODE_JS
|
|
|
193
193
|
printf '%s' "$out"
|
|
194
194
|
}
|
|
195
195
|
|
|
196
|
+
# Portable SHA-256 over stdin, printing the same "<hex> -" shape as either tool.
|
|
197
|
+
#
|
|
198
|
+
# GNU coreutils ships sha256sum; macOS ships shasum (a perl script). Neither is
|
|
199
|
+
# universal - Alpine and slim container images typically have sha256sum but no perl, and
|
|
200
|
+
# some hardened macOS setups have neither on a restricted PATH. Trying both keeps the
|
|
201
|
+
# tracker working on all three supported platforms, and the no-hasher branch emits an
|
|
202
|
+
# obviously-synthetic value rather than an empty string, so a broken environment shows
|
|
203
|
+
# up as a visibly fake id instead of a silently blank field.
|
|
204
|
+
sha256_hex() {
|
|
205
|
+
if command -v sha256sum >/dev/null 2>&1; then
|
|
206
|
+
sha256sum
|
|
207
|
+
elif command -v shasum >/dev/null 2>&1; then
|
|
208
|
+
shasum -a 256
|
|
209
|
+
else
|
|
210
|
+
cat >/dev/null
|
|
211
|
+
printf '%s -\n' "0000000000000000000000000000000000000000000000000000000000nohash"
|
|
212
|
+
fi
|
|
213
|
+
}
|
|
214
|
+
|
|
196
215
|
# OTel-compatible span emission - v6.1.0+, opt-in via $MULTI_AGENT_OTEL_SPANS.
|
|
197
216
|
# Appends one JSON line to otel-spans.jsonl. Never fatal on failure.
|
|
198
217
|
#
|
|
@@ -218,9 +237,14 @@ emit_otel_span() {
|
|
|
218
237
|
local spans_file="${TRACKER_DIR}/otel-spans.jsonl"
|
|
219
238
|
|
|
220
239
|
# Deterministic 128-bit trace_id from task_id, 64-bit span_id from event+phase+timestamp.
|
|
240
|
+
#
|
|
241
|
+
# sha256_hex, not `shasum` directly: this runs on every phase on every host, and
|
|
242
|
+
# `shasum` is a perl script that is absent from minimal Linux images and many
|
|
243
|
+
# containers. Without a fallback the ids came back empty and the span was written with
|
|
244
|
+
# blank trace/span - valid JSON, useless telemetry, and no error to notice.
|
|
221
245
|
local trace_id span_id
|
|
222
|
-
trace_id=$(printf "%s" "$task_id" |
|
|
223
|
-
span_id=$(printf "%s:%s:%s" "$event" "$phase_id" "$now_ns" |
|
|
246
|
+
trace_id=$(printf "%s" "$task_id" | sha256_hex | awk '{print substr($1,1,32)}')
|
|
247
|
+
span_id=$(printf "%s:%s:%s" "$event" "$phase_id" "$now_ns" | sha256_hex | awk '{print substr($1,1,16)}')
|
|
224
248
|
|
|
225
249
|
jq -nc \
|
|
226
250
|
--arg trace "$trace_id" --arg span "$span_id" \
|