@mmerterden/multi-agent-pipeline 13.3.0 → 13.4.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 +75 -0
- package/package.json +1 -1
- package/pipeline/commands/multi-agent/setup/SKILL.md +2 -1
- package/pipeline/lib/credential-inventory.sh +351 -0
- package/pipeline/multi-agent-refs/features/external-context-injection.md +43 -2
- 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/phase0-exit-gate.mjs +52 -0
- package/pipeline/skills/shared/core/multi-agent/SKILL.md +19 -0
package/CHANGELOG.md
CHANGED
|
@@ -16,6 +16,81 @@ Internal file-layout changes that don't affect the slash-command surface are sti
|
|
|
16
16
|
|
|
17
17
|
## [Unreleased]
|
|
18
18
|
|
|
19
|
+
## [13.4.0] - 2026-07-28
|
|
20
|
+
|
|
21
|
+
### Added
|
|
22
|
+
|
|
23
|
+
- **`credential-inventory.sh`** - one command that answers "what can I reach right now?"
|
|
24
|
+
A run asked the user to paste a Crashlytics stack trace by hand, offering the manual
|
|
25
|
+
path as the fastest route, while a valid Firebase service-account JSON sat in the
|
|
26
|
+
Keychain mapped as `firebase` and resolving fine. Nothing had failed: the pipeline
|
|
27
|
+
simply never asked itself whether it already held a credential that answered the
|
|
28
|
+
question. The tool reports `present` / `mapped-but-missing` / `unmapped` per logical
|
|
29
|
+
key plus what each one unlocks, and `keychain.md` Rule 2 now binds every question that
|
|
30
|
+
requests external data to its output - ask for the pointer (the issue URL), never for
|
|
31
|
+
the payload. Values are never printed.
|
|
32
|
+
- `--probe` verifies that configured credentials actually answer, because "the key is in
|
|
33
|
+
the Keychain" and "the service replies" are different claims that the user fixes in
|
|
34
|
+
different ways. The verdicts stay separate on purpose: `auth-rejected` (refresh the
|
|
35
|
+
token), `unreachable` (on a corporate host, almost always the VPN),
|
|
36
|
+
`no-host-configured` (a token with nowhere to point), `well-formed` (valid shape,
|
|
37
|
+
liveness needs more input), `not-probeable`. Collapsing them into one "failed" bucket
|
|
38
|
+
is what led the user to three different wrong actions. Only configured credentials are
|
|
39
|
+
probed - an unmapped key is a capability the user chose not to enable.
|
|
40
|
+
- `smoke-credential-awareness.sh` (32 assertions) and `test/phase0-exit-gate.test.mjs`
|
|
41
|
+
(20 cases) lock all of the above, including two secret-leak checks and the rule that
|
|
42
|
+
the credential reaches curl through a stdin config rather than argv, where any `ps`
|
|
43
|
+
could read it.
|
|
44
|
+
- `smoke-install-layout.sh --update-fixture` regenerates the layout fixture from the
|
|
45
|
+
tree the gate itself installs. The documented hand-rolled recipe omitted the seeded
|
|
46
|
+
probe plugin, so following it baked a three-file shortfall into the fixture and the
|
|
47
|
+
next run failed on a drift that was really a bad regen.
|
|
48
|
+
|
|
49
|
+
### Fixed
|
|
50
|
+
|
|
51
|
+
- **The Phase 0 exit gate now asserts that the pickers ran.** It checked `taskType` and
|
|
52
|
+
the Figma access tier only, so a run that skipped the project and branch pickers and
|
|
53
|
+
developed straight on the local checkout passed cleanly - which is exactly what a
|
|
54
|
+
reported run did with a Jira ID. The gate now also requires `baseBranch`,
|
|
55
|
+
a `baseFetchStatus` from the known set, and a worktree path distinct from the project
|
|
56
|
+
root. Branch selection is marked non-skippable in `phase-0-init.md`, in every mode:
|
|
57
|
+
`--dev` skips the LLM phases, not Phase 0's pickers.
|
|
58
|
+
- **An unreachable external source is announced instead of absorbed.** Exit code `3`
|
|
59
|
+
used to mean "mark it failed and continue", so an expired token and a VPN-off remote
|
|
60
|
+
both reached the analysis phase as *no data*, indistinguishable from a ticket that
|
|
61
|
+
referenced nothing. The run then planned from a partial picture and reported success.
|
|
62
|
+
`external-context-injection.md` now classifies the stderr and surfaces a decision -
|
|
63
|
+
refresh the credential, connect the VPN and retry, supply a current URL, or continue
|
|
64
|
+
without - records `userDecision` in state, and requires autopilot to report what it
|
|
65
|
+
skipped rather than hide it. Retry is a real branch, not a label.
|
|
66
|
+
- The base-branch and fetch-failure pickers were still written as ASCII numbered menus
|
|
67
|
+
in `phase-0-init.md`, against the picker contract's native-widget rule. Both are now
|
|
68
|
+
expressed as picker options, and the fetch-failure question names the corporate host
|
|
69
|
+
when the remote points at one - a host that will not resolve is almost always the VPN,
|
|
70
|
+
and saying so is the difference between a five-second fix and a run built on a stale ref.
|
|
71
|
+
- `credential-inventory.sh --json` returned empty arrays while the table mode looked
|
|
72
|
+
correct: `python3 - <<EOF` takes its script from stdin, so the piped payload never
|
|
73
|
+
reached `sys.stdin`. Caught by the new gate on its first run.
|
|
74
|
+
- The probe read hosts from `global.<service>Host`; the real path is
|
|
75
|
+
`global.hosts.<service>`, so every self-hosted service reported
|
|
76
|
+
`no-host-configured`. Found by running the probe against the real preferences instead
|
|
77
|
+
of trusting the field name.
|
|
78
|
+
- `figma_mcp` is an OAuth token for the MCP server, not a REST PAT: probing it against
|
|
79
|
+
`api.figma.com` returned 403 for a healthy token and would have sent the user to
|
|
80
|
+
regenerate something that worked. It is now reported as not-probeable, with liveness
|
|
81
|
+
left to `figma-mcp-refresh.sh`, which owns the grant.
|
|
82
|
+
- A failed `curl` wrote `000` through `write-out` *and* triggered the `|| echo "000"`
|
|
83
|
+
fallback, producing `000000` and a bogus verdict for what was simply a closed VPN.
|
|
84
|
+
Any non-three-digit status now reads as `probe-error` rather than being dressed up as
|
|
85
|
+
a service verdict.
|
|
86
|
+
- `multi-agent-refs/rules.md` pointed at `~/.claude/scripts/vercel-deploy.sh`; the
|
|
87
|
+
wrapper installs to `lib/` with the other shell libraries. It had been reported as a
|
|
88
|
+
dangling reference for several releases.
|
|
89
|
+
- `/multi-agent:setup` no longer describes an unreachable discovery source as skipped
|
|
90
|
+
"silently". Setup is where the user is configuring things, so a source that could not
|
|
91
|
+
be reached is precisely what they need told, with the classification that decides
|
|
92
|
+
their next move.
|
|
93
|
+
|
|
19
94
|
## [13.3.0] - 2026-07-28
|
|
20
95
|
|
|
21
96
|
### Added
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mmerterden/multi-agent-pipeline",
|
|
3
|
-
"version": "13.
|
|
3
|
+
"version": "13.4.0",
|
|
4
4
|
"description": "8-phase AI development pipeline with full orchestration on Claude Code, Copilot CLI and Codex CLI. Analysis, planning, TDD, CLI-aware parallel review with consensus surfacing + Fable triage, default-FAIL evidence gates, secret + intent guards, per-phase cost ledger, persistent learnings memory, wiki generation, commit automation. Token-preserving uninstall.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "index.js",
|
|
@@ -633,7 +633,8 @@ Entries are objects (not bare strings). The schema `recentProjects.items` is a `
|
|
|
633
633
|
|
|
634
634
|
**7g. Skip / degrade rules**
|
|
635
635
|
|
|
636
|
-
- Source failure (network, VPN, 401, rate-limit) skips that source
|
|
636
|
+
- Source failure (network, VPN, 401, rate-limit) skips that source; the others still run. Failure is recorded in `serviceStatus.{service} = { ok: false, checkedAt: <now>, reason: <short> }` so Phase 0 can also read that cache.
|
|
637
|
+
- **Report the skip, do not swallow it.** Setup is where the user is actively configuring things, so a source that could not be reached is exactly what they need to hear about, and the reason decides their next move: a 401 means refresh that token, no response at all on a corporate host means connect the VPN, and a missing `global.hosts.<service>` means the token has nowhere to point. Print one line per skipped source with its classification (see `refs/keychain.md` Rule 2) before moving on. A discovery pass that quietly returned three repos out of forty reads as "you only have three repos".
|
|
637
638
|
- If all three sources yield zero hits, Step 5 is a no-op - the picker in Phase 0 falls back to manual path entry.
|
|
638
639
|
- Discovery is **never destructive**: it never modifies git state, never writes outside `PREFS_FILE`.
|
|
639
640
|
- Step 5 is idempotent. Re-running it refreshes `recentProjects` and `serviceStatus` but preserves user-curated entries unless explicitly deselected.
|
|
@@ -0,0 +1,351 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# credential-inventory.sh - what the pipeline can reach right now, and what it cannot.
|
|
3
|
+
#
|
|
4
|
+
# WHY THIS EXISTS
|
|
5
|
+
#
|
|
6
|
+
# A run asked the user to paste a Crashlytics stack trace by hand while a Firebase
|
|
7
|
+
# service-account JSON sat in the Keychain, mapped as `firebase`, fully valid. Nothing
|
|
8
|
+
# was broken - the pipeline simply never asked itself "do I already hold a credential
|
|
9
|
+
# that answers this?" before asking the user. From the user's side that is worse than a
|
|
10
|
+
# failure: they had supplied the key precisely so this would not happen.
|
|
11
|
+
#
|
|
12
|
+
# One command, so there is no excuse for asking blind. Run it before any question that
|
|
13
|
+
# requests data an external system holds, and let the answer shape the question:
|
|
14
|
+
#
|
|
15
|
+
# - credential present -> ask for the *pointer* (the issue URL), not the payload,
|
|
16
|
+
# and say you will fetch it
|
|
17
|
+
# - credential missing -> say which logical key is unmapped and offer the Save Flow
|
|
18
|
+
# - credential dead -> say it is expired and offer to refresh it
|
|
19
|
+
#
|
|
20
|
+
# SAFETY
|
|
21
|
+
#
|
|
22
|
+
# Values are never printed, never stored in a variable that reaches stdout, and never
|
|
23
|
+
# logged. Presence is probed through `credential-store.sh get` with stdout discarded, so
|
|
24
|
+
# the secret goes to /dev/null and only the exit status is read.
|
|
25
|
+
#
|
|
26
|
+
# Usage:
|
|
27
|
+
# credential-inventory.sh # human-readable table
|
|
28
|
+
# credential-inventory.sh --json # machine-readable, for state files
|
|
29
|
+
# credential-inventory.sh --probe # also verify each configured service answers
|
|
30
|
+
# credential-inventory.sh --key firebase # single logical key, exit 0 present / 1 not
|
|
31
|
+
#
|
|
32
|
+
# PRESENT IS NOT THE SAME AS WORKING
|
|
33
|
+
#
|
|
34
|
+
# Without --probe this reports what is configured. With --probe each configured
|
|
35
|
+
# credential makes one cheap authenticated request, and the verdict distinguishes the
|
|
36
|
+
# three failures that need three different user actions:
|
|
37
|
+
#
|
|
38
|
+
# reachable the service answered and accepted the credential
|
|
39
|
+
# auth-rejected 401/403 - the credential is dead, the user must refresh it
|
|
40
|
+
# unreachable no response at all - on a corporate host, almost always the VPN
|
|
41
|
+
# no-host-configured a token exists but its host was never recorded in preferences
|
|
42
|
+
# well-formed structurally valid, liveness not checkable without more input
|
|
43
|
+
# not-probeable no cheap probe exists for this credential type
|
|
44
|
+
#
|
|
45
|
+
# Only configured credentials are probed. An unmapped key is a capability the user chose
|
|
46
|
+
# not to enable, not a problem to report.
|
|
47
|
+
#
|
|
48
|
+
# Exit codes: 0 = inventory produced (or the queried key is present), 1 = queried key
|
|
49
|
+
# absent, 3 = usage error.
|
|
50
|
+
|
|
51
|
+
set -uo pipefail
|
|
52
|
+
|
|
53
|
+
PREFS="${MULTI_AGENT_PREFS:-$HOME/.claude/multi-agent-preferences.json}"
|
|
54
|
+
|
|
55
|
+
STORE=""
|
|
56
|
+
for c in "$HOME/.claude/lib/credential-store.sh" "$HOME/.copilot/lib/credential-store.sh" \
|
|
57
|
+
"$HOME/.codex/lib/credential-store.sh" "$(dirname "$0")/credential-store.sh"; do
|
|
58
|
+
[ -f "$c" ] && { STORE="$c"; break; }
|
|
59
|
+
done
|
|
60
|
+
|
|
61
|
+
MODE="table"
|
|
62
|
+
QUERY=""
|
|
63
|
+
PROBE=0
|
|
64
|
+
TIMEOUT="${MULTI_AGENT_PROBE_TIMEOUT:-6}"
|
|
65
|
+
while [ $# -gt 0 ]; do
|
|
66
|
+
case "$1" in
|
|
67
|
+
--json) MODE="json"; shift ;;
|
|
68
|
+
--probe) PROBE=1; shift ;;
|
|
69
|
+
--key) QUERY="${2:-}"; shift 2 || shift ;;
|
|
70
|
+
-h|--help)
|
|
71
|
+
echo "usage: $0 [--json] [--probe] [--key <logical-key>]" >&2; exit 3 ;;
|
|
72
|
+
*) echo "ERR: unexpected arg $1" >&2; exit 3 ;;
|
|
73
|
+
esac
|
|
74
|
+
done
|
|
75
|
+
|
|
76
|
+
pref() { # pref <dotted.path> -> value or empty
|
|
77
|
+
[ -f "$PREFS" ] || return 0
|
|
78
|
+
python3 - "$PREFS" "$1" <<'PYPREF' 2>/dev/null
|
|
79
|
+
import json, sys
|
|
80
|
+
try:
|
|
81
|
+
d = json.load(open(sys.argv[1]))
|
|
82
|
+
except Exception:
|
|
83
|
+
sys.exit(0)
|
|
84
|
+
for k in sys.argv[2].split("."):
|
|
85
|
+
if not isinstance(d, dict):
|
|
86
|
+
sys.exit(0)
|
|
87
|
+
d = d.get(k)
|
|
88
|
+
if d is None:
|
|
89
|
+
sys.exit(0)
|
|
90
|
+
print(d if not isinstance(d, (dict, list)) else "")
|
|
91
|
+
PYPREF
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
# An authenticated request whose credential never appears in argv.
|
|
95
|
+
#
|
|
96
|
+
# `curl -H "Authorization: Bearer $TOK"` puts the secret in the process command line,
|
|
97
|
+
# where any `ps` on the machine can read it. `--config -` takes both the header and the
|
|
98
|
+
# URL from stdin instead, so the token exists only in the pipe. Same reason the Vercel
|
|
99
|
+
# wrapper refuses a `--token=` argv.
|
|
100
|
+
#
|
|
101
|
+
# probe_http <url> <header-name> <header-value> -> prints the HTTP status
|
|
102
|
+
probe_http() {
|
|
103
|
+
local url="$1" hname="$2" hval="$3" out
|
|
104
|
+
# No `|| echo 000` here: curl already writes `000` through write-out when it never
|
|
105
|
+
# got a response, and exits non-zero as well. Appending produced `000000`, which fell
|
|
106
|
+
# through classify_status into a bogus `unexpected-000000` verdict for what was simply
|
|
107
|
+
# a closed VPN.
|
|
108
|
+
out=$(printf 'url = "%s"\nheader = "%s: %s"\nsilent\noutput = "/dev/null"\nwrite-out = "%%{http_code}"\nmax-time = %s\n' \
|
|
109
|
+
"$url" "$hname" "$hval" "$TIMEOUT" | curl --config - 2>/dev/null)
|
|
110
|
+
printf '%s' "${out:-000}"
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
# probe_basic <url> <user> <pass> -> prints the HTTP status
|
|
114
|
+
probe_basic() {
|
|
115
|
+
local url="$1" u="$2" p="$3" out
|
|
116
|
+
out=$(printf 'url = "%s"\nuser = "%s:%s"\nsilent\noutput = "/dev/null"\nwrite-out = "%%{http_code}"\nmax-time = %s\n' \
|
|
117
|
+
"$url" "$u" "$p" "$TIMEOUT" | curl --config - 2>/dev/null)
|
|
118
|
+
printf '%s' "${out:-000}"
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
# Map an HTTP status onto the vocabulary the pipeline reasons about. `000` is curl's
|
|
122
|
+
# "never got a response" - DNS failure, refused connection, timeout - which on a
|
|
123
|
+
# corporate host is almost always a closed VPN, and is a completely different user
|
|
124
|
+
# action from a rejected credential.
|
|
125
|
+
classify_status() {
|
|
126
|
+
case "$1" in
|
|
127
|
+
2??) echo "reachable" ;;
|
|
128
|
+
401|403) echo "auth-rejected" ;;
|
|
129
|
+
404) echo "reachable" ;; # endpoint answered; the probe path may just not exist
|
|
130
|
+
000|"") echo "unreachable" ;;
|
|
131
|
+
5??) echo "server-error" ;;
|
|
132
|
+
# Anything that is not a three-digit status means the probe itself misbehaved.
|
|
133
|
+
# Saying so beats inventing a service verdict from a malformed value.
|
|
134
|
+
*) echo "probe-error" ;;
|
|
135
|
+
esac
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
# Reachability for one logical key. Never prints a credential; the value is piped
|
|
139
|
+
# straight into curl's stdin config and discarded.
|
|
140
|
+
#
|
|
141
|
+
# Hosts always come from preferences - a self-hosted Jira / Bitbucket / Confluence /
|
|
142
|
+
# Fortify / Graylog address is deployment-specific and must never be baked in. Only
|
|
143
|
+
# genuinely global public APIs are named literally. Host keys live at
|
|
144
|
+
# `global.hosts.<service>` per prefs.schema.json - an earlier version of this probe
|
|
145
|
+
# guessed `global.<service>Host` and reported no-host-configured for every
|
|
146
|
+
# self-hosted service the user had actually configured.
|
|
147
|
+
probe_one() {
|
|
148
|
+
local key="$1" tok host status
|
|
149
|
+
tok=$(bash "$STORE" get "$key" 2>/dev/null) || { echo "no-credential"; return; }
|
|
150
|
+
[ -n "$tok" ] || { echo "no-credential"; return; }
|
|
151
|
+
|
|
152
|
+
case "$key" in
|
|
153
|
+
jira)
|
|
154
|
+
host=$(pref global.hosts.jira)
|
|
155
|
+
[ -n "$host" ] || { echo "no-host-configured"; return; }
|
|
156
|
+
status=$(probe_http "https://${host}/rest/api/2/myself" "Authorization" "Bearer $tok") ;;
|
|
157
|
+
confluence)
|
|
158
|
+
host=$(pref global.hosts.confluence)
|
|
159
|
+
[ -n "$host" ] || { echo "no-host-configured"; return; }
|
|
160
|
+
status=$(probe_http "https://${host}/rest/api/user/current" "Authorization" "Bearer $tok") ;;
|
|
161
|
+
bitbucket_token)
|
|
162
|
+
host=$(pref global.hosts.bitbucket)
|
|
163
|
+
[ -n "$host" ] || { echo "no-host-configured"; return; }
|
|
164
|
+
local bbuser bbkey
|
|
165
|
+
bbkey=$(pref global.keychainMapping.bitbucket_user)
|
|
166
|
+
bbuser=$([ -n "$bbkey" ] && bash "$STORE" get bitbucket_user 2>/dev/null || echo "")
|
|
167
|
+
if [ -n "$bbuser" ]; then
|
|
168
|
+
status=$(probe_basic "https://${host}/rest/api/1.0/repos?limit=1" "$bbuser" "$tok")
|
|
169
|
+
else
|
|
170
|
+
status=$(probe_http "https://${host}/rest/api/1.0/repos?limit=1" "Authorization" "Bearer $tok")
|
|
171
|
+
fi ;;
|
|
172
|
+
github)
|
|
173
|
+
status=$(probe_http "https://api.github.com/user" "Authorization" "Bearer $tok") ;;
|
|
174
|
+
figma)
|
|
175
|
+
status=$(probe_http "https://api.figma.com/v1/me" "X-Figma-Token" "$tok") ;;
|
|
176
|
+
figma_mcp)
|
|
177
|
+
# An OAuth token for the Figma MCP server, not a REST PAT. Sending it to
|
|
178
|
+
# api.figma.com returns 403 for a perfectly healthy token, which is worse than
|
|
179
|
+
# not probing: it would send the user to regenerate something that works.
|
|
180
|
+
# Liveness for this one belongs to figma-mcp-refresh.sh, which owns the grant.
|
|
181
|
+
printf '%s' "$tok" | grep -q . && echo "not-probeable" || echo "malformed"
|
|
182
|
+
return ;;
|
|
183
|
+
npm)
|
|
184
|
+
status=$(probe_http "https://registry.npmjs.org/-/whoami" "Authorization" "Bearer $tok") ;;
|
|
185
|
+
fortify)
|
|
186
|
+
host=$(pref global.hosts.fortify)
|
|
187
|
+
[ -n "$host" ] || { echo "no-host-configured"; return; }
|
|
188
|
+
status=$(probe_http "https://${host}/api/v1/projects?limit=1" "Authorization" "Bearer $tok") ;;
|
|
189
|
+
graylog)
|
|
190
|
+
host=$(pref global.hosts.graylog)
|
|
191
|
+
[ -n "$host" ] || { echo "no-host-configured"; return; }
|
|
192
|
+
status=$(probe_http "https://${host}/api/system" "Authorization" "Bearer $tok") ;;
|
|
193
|
+
jenkins)
|
|
194
|
+
host=$(pref global.hosts.jenkins)
|
|
195
|
+
[ -n "$host" ] || { echo "no-host-configured"; return; }
|
|
196
|
+
status=$(probe_http "https://${host}/api/json" "Authorization" "Bearer $tok") ;;
|
|
197
|
+
firebase)
|
|
198
|
+
# A service-account JSON, not a bearer token: an OAuth exchange would be needed
|
|
199
|
+
# to reach Crashlytics, and the fetcher does that per issue. Verifying the shape
|
|
200
|
+
# is honest about what is known - well-formed, not proven reachable.
|
|
201
|
+
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
|
|
202
|
+
echo "well-formed"; return
|
|
203
|
+
fi
|
|
204
|
+
echo "malformed"; return ;;
|
|
205
|
+
appstore_connect_private_key)
|
|
206
|
+
printf '%s' "$tok" | grep -q "BEGIN PRIVATE KEY" && echo "well-formed" || echo "malformed"
|
|
207
|
+
return ;;
|
|
208
|
+
*)
|
|
209
|
+
echo "not-probeable"; return ;;
|
|
210
|
+
esac
|
|
211
|
+
classify_status "$status"
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
# What each logical key unlocks, in the pipeline's own terms. This is the column that
|
|
215
|
+
# turns an inventory into an actionable question: "I hold `firebase`, so give me the
|
|
216
|
+
# issue URL and I will pull the stack trace" is only sayable if the capability is
|
|
217
|
+
# written down somewhere.
|
|
218
|
+
capability_of() {
|
|
219
|
+
case "$1" in
|
|
220
|
+
jira) echo "read the ticket, its comments and its linked issues; post the Phase 7 comment" ;;
|
|
221
|
+
bitbucket_token) echo "read the repo, open and update pull requests" ;;
|
|
222
|
+
bitbucket_user) echo "identify the PR author (paired with bitbucket_token)" ;;
|
|
223
|
+
github) echo "read issues, open pull requests, read Actions runs" ;;
|
|
224
|
+
confluence) echo "read linked pages and publish the analysis document" ;;
|
|
225
|
+
firebase) echo "pull a Crashlytics issue: stack frames, affected versions, device spread (needs the issue URL)" ;;
|
|
226
|
+
fortify) echo "pull a static-analysis finding and its remediation guidance" ;;
|
|
227
|
+
graylog) echo "pull request logs by transaction or conversation id (advisory)" ;;
|
|
228
|
+
figma|figma_mcp) echo "fetch design context, screenshots and Code Connect mappings" ;;
|
|
229
|
+
jenkins) echo "read build results" ;;
|
|
230
|
+
npm) echo "publish to the npm registry" ;;
|
|
231
|
+
appstore_connect_key_id|appstore_connect_issuer_id|appstore_connect_private_key)
|
|
232
|
+
echo "validate an archive against App Store rules before submission" ;;
|
|
233
|
+
claude_oauth_token|claude_oauth_token_fallback)
|
|
234
|
+
echo "run headless review and analysis passes" ;;
|
|
235
|
+
*) echo "(no capability recorded for this key)" ;;
|
|
236
|
+
esac
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
mapping_keys() {
|
|
240
|
+
[ -f "$PREFS" ] || return 0
|
|
241
|
+
python3 - "$PREFS" <<'PY' 2>/dev/null
|
|
242
|
+
import json, sys
|
|
243
|
+
try:
|
|
244
|
+
p = json.load(open(sys.argv[1]))
|
|
245
|
+
except Exception:
|
|
246
|
+
sys.exit(0)
|
|
247
|
+
m = (p.get("global") or {}).get("keychainMapping") or {}
|
|
248
|
+
for k, v in m.items():
|
|
249
|
+
print(f"{k}\t{'1' if v else '0'}")
|
|
250
|
+
PY
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
# Presence probe. The value lands in /dev/null; only the exit status is observed.
|
|
254
|
+
probe() {
|
|
255
|
+
[ -n "$STORE" ] || return 2
|
|
256
|
+
bash "$STORE" get "$1" >/dev/null 2>&1
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
if [ -n "$QUERY" ]; then
|
|
260
|
+
probe "$QUERY"
|
|
261
|
+
rc=$?
|
|
262
|
+
case "$rc" in
|
|
263
|
+
0)
|
|
264
|
+
if [ "$PROBE" = "1" ]; then
|
|
265
|
+
echo "$QUERY: present, $(probe_one "$QUERY") - can $(capability_of "$QUERY")"
|
|
266
|
+
else
|
|
267
|
+
echo "$QUERY: present - can $(capability_of "$QUERY")"
|
|
268
|
+
fi
|
|
269
|
+
exit 0 ;;
|
|
270
|
+
2) echo "$QUERY: no credential helper on this host" >&2; exit 1 ;;
|
|
271
|
+
*) echo "$QUERY: NOT AVAILABLE - onboard it via /multi-agent:setup before relying on it" >&2; exit 1 ;;
|
|
272
|
+
esac
|
|
273
|
+
fi
|
|
274
|
+
|
|
275
|
+
ROWS=""
|
|
276
|
+
while IFS=$'\t' read -r key mapped; do
|
|
277
|
+
[ -z "$key" ] && continue
|
|
278
|
+
if [ "$mapped" != "1" ]; then
|
|
279
|
+
state="unmapped"
|
|
280
|
+
reach="not-probed"
|
|
281
|
+
elif probe "$key"; then
|
|
282
|
+
state="present"
|
|
283
|
+
# Only configured credentials are probed. A key the user never onboarded is not a
|
|
284
|
+
# problem to report - it is a capability they chose not to enable.
|
|
285
|
+
reach=$([ "$PROBE" = "1" ] && probe_one "$key" || echo "not-probed")
|
|
286
|
+
else
|
|
287
|
+
state="mapped-but-missing"
|
|
288
|
+
reach="not-probed"
|
|
289
|
+
fi
|
|
290
|
+
ROWS="${ROWS}${key}\t${state}\t${reach}\t$(capability_of "$key")\n"
|
|
291
|
+
done < <(mapping_keys)
|
|
292
|
+
|
|
293
|
+
if [ -z "$ROWS" ]; then
|
|
294
|
+
if [ "$MODE" = "json" ]; then
|
|
295
|
+
echo '{"status":"empty","reason":"no keychainMapping in preferences","credentials":[]}'
|
|
296
|
+
else
|
|
297
|
+
echo "no keychainMapping found in $PREFS - run /multi-agent:setup" >&2
|
|
298
|
+
fi
|
|
299
|
+
exit 0
|
|
300
|
+
fi
|
|
301
|
+
|
|
302
|
+
if [ "$MODE" = "json" ]; then
|
|
303
|
+
# The rows travel through a temp file, not a pipe. `python3 - <<EOF` takes its SCRIPT
|
|
304
|
+
# from stdin, so a piped payload never reaches sys.stdin: --json came back with empty
|
|
305
|
+
# arrays while the table mode looked correct. Caught by smoke-credential-awareness.
|
|
306
|
+
ROWS_FILE="$(mktemp)"
|
|
307
|
+
trap 'rm -f "$ROWS_FILE"' EXIT
|
|
308
|
+
printf '%b' "$ROWS" > "$ROWS_FILE"
|
|
309
|
+
python3 - "$ROWS_FILE" <<'PYJSON'
|
|
310
|
+
import json, sys
|
|
311
|
+
rows = []
|
|
312
|
+
with open(sys.argv[1]) as fh:
|
|
313
|
+
for line in fh:
|
|
314
|
+
line = line.rstrip("\n")
|
|
315
|
+
if not line:
|
|
316
|
+
continue
|
|
317
|
+
parts = line.split("\t")
|
|
318
|
+
if len(parts) < 4:
|
|
319
|
+
continue
|
|
320
|
+
rows.append({
|
|
321
|
+
"logical": parts[0],
|
|
322
|
+
"state": parts[1],
|
|
323
|
+
"reachability": parts[2],
|
|
324
|
+
"capability": parts[3],
|
|
325
|
+
})
|
|
326
|
+
|
|
327
|
+
# `usable` is presence only, so a caller that never probed still gets a meaningful
|
|
328
|
+
# answer. `reachable` is the stronger claim and exists only after --probe.
|
|
329
|
+
OK_REACH = {"reachable", "well-formed"}
|
|
330
|
+
BLOCKED = {"auth-rejected", "malformed"}
|
|
331
|
+
probed = [r for r in rows if r["reachability"] != "not-probed"]
|
|
332
|
+
print(json.dumps({
|
|
333
|
+
"status": "ok",
|
|
334
|
+
"probed": bool(probed),
|
|
335
|
+
"usable": [r["logical"] for r in rows if r["state"] == "present"],
|
|
336
|
+
"needsAttention": [r["logical"] for r in rows if r["state"] != "present"],
|
|
337
|
+
# Separated on purpose: a dead token is the user's to refresh, an unreachable host
|
|
338
|
+
# is usually a closed VPN, and a missing host is a setup gap. Collapsing them into
|
|
339
|
+
# one "failed" bucket is what made the pipeline ask blind questions.
|
|
340
|
+
"reachable": [r["logical"] for r in probed if r["reachability"] in OK_REACH],
|
|
341
|
+
"authRejected": [r["logical"] for r in probed if r["reachability"] in BLOCKED],
|
|
342
|
+
"unreachable": [r["logical"] for r in probed if r["reachability"] == "unreachable"],
|
|
343
|
+
"noHostConfigured": [r["logical"] for r in probed if r["reachability"] == "no-host-configured"],
|
|
344
|
+
"credentials": rows,
|
|
345
|
+
}, indent=2))
|
|
346
|
+
PYJSON
|
|
347
|
+
else
|
|
348
|
+
printf '%b' "$ROWS" | awk -F'\t' '
|
|
349
|
+
BEGIN { printf "%-28s %-20s %-20s %s\n", "LOGICAL KEY", "STATE", "REACHABILITY", "UNLOCKS" }
|
|
350
|
+
{ printf "%-28s %-20s %-20s %s\n", $1, $2, $3, $4 }'
|
|
351
|
+
fi
|
|
@@ -32,10 +32,51 @@ 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
|
+
|
|
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.
|
|
39
80
|
|
|
40
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. 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
|
|
|
@@ -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
|
|
|
@@ -14,6 +14,12 @@
|
|
|
14
14
|
* and spacing guessed at 16 where the frame said `Spacing/12`. Half the commits on
|
|
15
15
|
* that branch were rework.
|
|
16
16
|
*
|
|
17
|
+
* A second run then showed the other half of the same failure: a Jira task was
|
|
18
|
+
* implemented without the repo or branch picker ever appearing, straight on whatever
|
|
19
|
+
* the local checkout was pointing at. Step 3 and Step 8 own those decisions, and
|
|
20
|
+
* `agent-state.json` is the only durable evidence they happened - so the gate asserts
|
|
21
|
+
* their output too, not just `taskType`.
|
|
22
|
+
*
|
|
17
23
|
* A phase that reports success without its output is worse than one that fails:
|
|
18
24
|
* every later phase then reasons from a field that is not there. So this is a
|
|
19
25
|
* gate, not a lint - the spec already said what to write, and prose alone did
|
|
@@ -120,6 +126,52 @@ export function evaluate(state, extraInput = "") {
|
|
|
120
126
|
);
|
|
121
127
|
}
|
|
122
128
|
|
|
129
|
+
// Step 3 picks the base branch and Step 8 creates the worktree. A run that skipped
|
|
130
|
+
// both develops on whatever the checkout happened to have, which is how a Jira task
|
|
131
|
+
// got implemented on a stale local branch with no picker ever shown. The state file
|
|
132
|
+
// is the only durable evidence those steps ran, so it has to carry their output.
|
|
133
|
+
//
|
|
134
|
+
// `local` provider runs still choose a base branch; what they skip is push and PR.
|
|
135
|
+
// So this applies to every provider.
|
|
136
|
+
const baseBranch = typeof state.baseBranch === "string" ? state.baseBranch.trim() : "";
|
|
137
|
+
if (!baseBranch) {
|
|
138
|
+
failures.push(
|
|
139
|
+
"agent-state.json has no baseBranch. Step 3 asks for it (or takes it from the " +
|
|
140
|
+
"input); an unset value means the branch picker never ran and development " +
|
|
141
|
+
"happens on whatever the checkout was already pointing at.",
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Which ref the base actually came from. Without it, "built on a fresh origin ref"
|
|
146
|
+
// and "built on a month-old local branch" are indistinguishable after the fact -
|
|
147
|
+
// and the fetch-failure picker exists precisely to make that an explicit choice.
|
|
148
|
+
const fetchStatus = typeof state.baseFetchStatus === "string" ? state.baseFetchStatus.trim() : "";
|
|
149
|
+
const FETCH_STATES = ["fresh", "cached-stale", "local-branch", "aborted"];
|
|
150
|
+
if (baseBranch && !FETCH_STATES.includes(fetchStatus)) {
|
|
151
|
+
failures.push(
|
|
152
|
+
`agent-state.json has baseFetchStatus="${fetchStatus || "<unset>"}"; Step 3 must ` +
|
|
153
|
+
`record one of ${FETCH_STATES.join(" | ")}. An unset value means nothing ` +
|
|
154
|
+
`established whether the base ref was fetched or reused from a stale cache.`,
|
|
155
|
+
);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Worktree isolation is a standing rule: never develop in the primary checkout.
|
|
159
|
+
const worktree = typeof state.worktreePath === "string" ? state.worktreePath.trim() : "";
|
|
160
|
+
const projectRoot = typeof state.projectRoot === "string" ? state.projectRoot.trim() : "";
|
|
161
|
+
if (!worktree && !projectRoot) {
|
|
162
|
+
failures.push(
|
|
163
|
+
"agent-state.json records neither worktreePath nor projectRoot. Step 8 creates " +
|
|
164
|
+
"the worktree; with no recorded path, later phases cannot tell which tree they " +
|
|
165
|
+
"are meant to edit and default to the current directory.",
|
|
166
|
+
);
|
|
167
|
+
}
|
|
168
|
+
if (worktree && projectRoot && worktree === projectRoot) {
|
|
169
|
+
failures.push(
|
|
170
|
+
`worktreePath equals projectRoot (${worktree}). Development must happen in a ` +
|
|
171
|
+
`separate worktree, never in the primary checkout.`,
|
|
172
|
+
);
|
|
173
|
+
}
|
|
174
|
+
|
|
123
175
|
// The Figma access chain records which tier answered. A component task with no
|
|
124
176
|
// recorded tier means nothing verified that the design was actually reachable,
|
|
125
177
|
// which is how a run ends up guessing spacing.
|
|
@@ -336,6 +336,25 @@ Full contract: `refs/tracker-contract.md` section "TaskCreate ordering (strict)"
|
|
|
336
336
|
> 6. **Branch name confirm** - `feature/PROJ-{id}-{kebab}` or `bugfix/...` - user confirms or edits. In multi-repo mode the branch name is shared across all selected repos (collision check runs per-repo; any collision applies the suffix to all). (Step 4)
|
|
337
337
|
> 7. **Git identity - per repo** - route via `prefs.global.platformIdentityRouting`; ask if ambiguous. Multi-repo mode resolves identity **per repo independently** (phase-0-init.md L283). (Step 6)
|
|
338
338
|
> 8. **Instruction files + workspace creation - serially per repo** - detect `.instructions/figma/` etc. In multi-repo mode, worktree creation loops per repo serially (phase-0-init.md L308); any failure rolls back previously-created worktrees. Write `agent-state.json` with `state.projects[]` array (scalar `project`/`projectRoot`/`branch` mirror `projects[0]` for back-compat). (Steps 7-8)
|
|
339
|
+
>
|
|
340
|
+
> **Exit gate (BLOCKING).** The contract above was prose only, and a run still took a
|
|
341
|
+
> Jira ID and implemented straight onto the local checkout with no picker shown. Before
|
|
342
|
+
> marking Phase 0 completed:
|
|
343
|
+
>
|
|
344
|
+
> ```bash
|
|
345
|
+
> node "$HOME/.copilot/scripts/phase0-exit-gate.mjs" "$TASK_ID" --input "$USER_INPUT"
|
|
346
|
+
> ```
|
|
347
|
+
>
|
|
348
|
+
> Non-zero means Phase 0 did not produce its own output - `taskType`, `baseBranch`,
|
|
349
|
+
> `baseFetchStatus`, a worktree path distinct from the project root, and a recorded Figma
|
|
350
|
+
> access tier when the input carries a Figma URL. Fix the missing step; do not proceed.
|
|
351
|
+
>
|
|
352
|
+
> **Credential inventory (Step 0).** Run
|
|
353
|
+
> `bash "$HOME/.copilot/lib/credential-inventory.sh" --json` and store the result in
|
|
354
|
+
> `agent-state.json.credentialInventory`. Per `refs/keychain.md` Rule 2, never ask the
|
|
355
|
+
> user for data a mapped credential can fetch: a run asked for a hand-pasted Crashlytics
|
|
356
|
+
> stack trace while a valid Firebase service account was mapped and resolving. Ask for
|
|
357
|
+
> the pointer (the issue URL) instead, and say what you will do with it.
|
|
339
358
|
|
|
340
359
|
**Step 1 - Project detection + input parsing:**
|
|
341
360
|
|