@mmerterden/multi-agent-pipeline 13.2.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.
@@ -5,17 +5,17 @@ argument-hint: "[module name or path]"
5
5
  allowed-tools: Skill, Bash, Read, Edit, Write, AskUserQuestion
6
6
  ---
7
7
 
8
- # multi-agent ios-coding-standardModule audit → plan → dev handoff
8
+ # multi-agent ios-coding-standard - Module audit → plan → dev handoff
9
9
 
10
- **Input**: $ARGUMENTSoptionally a module name or path. When absent, Phase 1 discovers and asks.
10
+ **Input**: $ARGUMENTS - optionally a module name or path. When absent, Phase 1 discovers and asks.
11
11
 
12
12
  This routine is the **procedure**. The rules live in the `ios-coding-standard` skill, whose registry is
13
13
  `references/rules.yml` and whose teaching doc is `references/STANDARD.md`. Load that
14
14
  skill first; it is installed on every host and carried by the iOS stack plugin, so
15
- there is one registry rather than a copy per CLI. Never restate a rule herecite its ID. A rule that is not in the
15
+ there is one registry rather than a copy per CLI. Never restate a rule here - cite its ID. A rule that is not in the
16
16
  registry is not a rule; if the audit needs one, propose it as `status: proposed` and say so.
17
17
 
18
- Read-only + planning. This routine never edits sourcethe dev pipeline does.
18
+ Read-only + planning. This routine never edits source - the dev pipeline does.
19
19
 
20
20
  **Goal.** A developer new to the repo can open any file in the module and understand it without a
21
21
  guided tour, and nothing sensitive leaks on the way. Rank every finding by "does fixing this
@@ -26,26 +26,26 @@ dev task) stay English.
26
26
 
27
27
  ---
28
28
 
29
- ## Phase 1Discover and pick the module
29
+ ## Phase 1 - Discover and pick the module
30
30
 
31
31
  1. Repo root via `git rev-parse --show-toplevel`.
32
- 2. **Discover module roots**any directory with a `Package.swift`, a `Sources/` subtree or an
32
+ 2. **Discover module roots** - any directory with a `Package.swift`, a `Sources/` subtree or an
33
33
  `.xcodeproj`. Sweep the container dirs that exist (`Domains/`, `Packages/`, `Modules/`,
34
34
  `Features/`, `Core/`, `Common/`, top-level module dirs) one level deep, then two if empty.
35
35
  3. Record per module: name · path · Swift files · lines · governance docs present · SPM targets.
36
- Keep this **module registry** for the whole run`MOD-*` greps every import against it.
36
+ Keep this **module registry** for the whole run - `MOD-*` greps every import against it.
37
37
  Classify each module's role, because the role decides which dependency edges are legal:
38
38
  **feature** · **core/common** · **seam** (cross-module contracts/bridges/navigation) ·
39
39
  **composition root** (legitimately knows every module).
40
40
  4. If `$ARGUMENTS` resolves to a module, skip the prompt. Otherwise `AskUserQuestion`
41
41
  (single-select, `outputLanguage`), documented modules first, each option showing file/line
42
42
  counts and `docs: yes/no`. If the list exceeds the question limit, group the smallest into an
43
- "other" option and ask againnever truncate silently.
43
+ "other" option and ask again - never truncate silently.
44
44
  5. Scope = the whole module's sources across all its targets, unless the user narrows it.
45
45
 
46
- ## Phase 2Resolve what applies to THIS module
46
+ ## Phase 2 - Resolve what applies to THIS module
47
47
 
48
- ### 2a. The registry is the standardin-module prose docs are NOT consulted
48
+ ### 2a. The registry is the standard - in-module prose docs are NOT consulted
49
49
 
50
50
  The skill's `references/rules.yml` (+ a project `modules/<Module>.yml` overlay) is the single source of truth. **Do not read, cite, or
51
51
  derive rules from a module's own `*-CLAUDE.md`, `docs/` set or any other in-repo prose.** They are
@@ -64,7 +64,7 @@ the specs the generator reads, and the module registry from Phase 1.
64
64
  be `blocking`.
65
65
  *Why prohibitions need to be written down:* inference reads dominant patterns, and a rule
66
66
  obeyed everywhere has no counter-example to infer from. A ban at 100% compliance is invisible
67
- to a scanit survives only if the overlay states it.
67
+ to a scan - it survives only if the overlay states it.
68
68
  2. **Repo-level rules** (root `CLAUDE.md`, contributing docs) apply to every module.
69
69
  3. **The module's own dominant pattern is its de-facto convention.** Count variants per dimension;
70
70
  the minority instances are the finding. Internal consistency beats conformity to a sibling.
@@ -74,14 +74,14 @@ Findings from step 3 are tagged `inferred` and capped at **suggestion**. Only re
74
74
  overlay-backed rules may be `blocking`.
75
75
 
76
76
  **When the overlay is missing**, generate a draft from code evidence, print it for confirmation,
77
- and mark every slot it could not derivea frozen UI target, a name lock, a carve-out and an
77
+ and mark every slot it could not derive - a frozen UI target, a name lock, a carve-out and an
78
78
  operational constraint all look like ordinary code from the outside. Do not guess them; list them
79
79
  as Open Questions.
80
80
 
81
81
  ### 2c. Vocabulary
82
82
 
83
83
  Bind each slot to a real symbol by grepping the module, then the reference module. **An unbound
84
- slot disables its rules**never invent a name, never import a sibling's.
84
+ slot disables its rules** - never invent a name, never import a sibling's.
85
85
 
86
86
  `HandlerName` · `EventParam` · `CoordinatorType` · `ResultType` / `ErrorType` / `ErrorFactory` ·
87
87
  `DIResolver` / `DIConfigurator` · `RequestModelSuffix` / `ResponseModelSuffix` / `TransportSuffix` ·
@@ -94,14 +94,14 @@ slot disables its rules** — never invent a name, never import a sibling's.
94
94
 
95
95
  Print the resolved table before scanning so a wrong binding is caught early.
96
96
 
97
- ### 2d. Sensitive-data inventoryrequired before any SEC rule runs
97
+ ### 2d. Sensitive-data inventory - required before any SEC rule runs
98
98
 
99
99
  The `SEC-*` rules are written against the **data classes** in `references/rules.yml →
100
100
  sensitive_data_classes`, never against one module's field names. Resolve the module's concrete
101
101
  instances of each class: grep entities, request/response models, storage calls and analytics
102
102
  events for the values the module actually handles, and map each to its class.
103
103
 
104
- Produce the inventory as a table**class · concrete symbols · where it enters · required
104
+ Produce the inventory as a table - **class · concrete symbols · where it enters · required
105
105
  lifetime (transient / survives-restart / survives-reinstall) · where it is actually stored ·
106
106
  where it is logged · where it leaves (network, analytics, pasteboard, another module)**.
107
107
 
@@ -117,13 +117,13 @@ whose required lifetime nobody can state, is an Open Question, not a silent pass
117
117
 
118
118
  This table is a deliverable in its own right, independent of whether any violation is found.
119
119
 
120
- ## Phase 3Scan
120
+ ## Phase 3 - Scan
121
121
 
122
122
  Apply the rules in `references/rules.yml` that survived Phase 2 binding, plus the module's own `validation`
123
123
  gate. Tag every finding: **rule ID · severity · source (`registry` / `doc` / `inferred`) ·
124
124
  onboarding impact (high/medium/low)**.
125
125
 
126
- ### Coverage gateon disk, not in memory
126
+ ### Coverage gate - on disk, not in memory
127
127
 
128
128
  Before scanning, write `.<module>-audit-coverage.tsv` to the scratch dir: one row per target
129
129
  (every screen under `ScreenRoot`, every shared component, every entity, every file in every
@@ -131,25 +131,25 @@ target) with columns `path · status · rules-applied`. Mark rows as you go.
131
131
 
132
132
  - Each target is **audited** or **explicitly skipped with a reason** (generated, fixture,
133
133
  vendored). No third state.
134
- - **The run is incomplete while any row is unmarked**and the file, not a claim, is the proof.
134
+ - **The run is incomplete while any row is unmarked** - and the file, not a claim, is the proof.
135
135
  Chunk the module if it is too large for one pass; the checklist survives a context reset.
136
136
  - No sampling. A partial audit reported as complete certifies the unscanned screens as clean.
137
137
 
138
138
  ### Grep hygiene
139
139
 
140
140
  Registry `mechanism` patterns are line-based and match doc comments. Filter
141
- `^[[:space:]]*//`BSD grep does not understand `\s`, so a `\s*//` filter silently lets `/// …`
142
- throughthen open each surviving hit and confirm it is real code. Report pre-filter and
141
+ `^[[:space:]]*//` - BSD grep does not understand `\s`, so a `\s*//` filter silently lets `/// ...`
142
+ through - then open each surviving hit and confirm it is real code. Report pre-filter and
143
143
  post-verification counts separately; a table padded with comment matches destroys trust.
144
144
 
145
145
  ### Judgement rules need evidence, not assertion
146
146
 
147
147
  For any rule marked `enforcement: judgement`, a finding must carry the measurement its `check`
148
- namesa reference count, a call-site count, a consumer count, a file list. **No count, no
148
+ names - a reference count, a call-site count, a consumer count, a file list. **No count, no
149
149
  finding.** This is what keeps a subjective rule from becoming an opinion.
150
150
 
151
151
  Every judgement rule has a worked ✗/✓ pair in `EXAMPLES.md`, keyed by ID. Cite it in the finding's
152
- proposed fix rather than re-describing the shapethe developer applying the fix should be
152
+ proposed fix rather than re-describing the shape - the developer applying the fix should be
153
153
  reading the same picture the audit used.
154
154
 
155
155
  ### Module-specific overrides
@@ -158,79 +158,79 @@ The module's own docs win over the registry, including where they contradict a s
158
158
  Never apply one module's dialect to another. List every override honoured, so the reader sees why
159
159
  a registry rule was not raised.
160
160
 
161
- ## Phase 4Produce the plan
161
+ ## Phase 4 - Produce the plan
162
162
 
163
163
  No findings → report compliant and stop. Otherwise write
164
164
  `<ModulePath>/ios-coding-standard-plan-<module>.md` (or the repo's scratch/docs dir if the module
165
165
  tree must stay clean), containing, in order:
166
166
 
167
- 1. **Coverage ledger**total targets · audited · skipped with reasons. Not 100% accounted for
167
+ 1. **Coverage ledger** - total targets · audited · skipped with reasons. Not 100% accounted for
168
168
  means the plan is not deliverable.
169
- 2. **Summary**counts by severity and source, resolved vocabulary, branch to create.
170
- 3. **Sensitive-data inventory** (Phase 2d)first, because it is the highest-risk content.
171
- 4. **Screen × role matrix** `[STRUCT-02]`screens as rows, manifest roles as columns, cells
169
+ 2. **Summary** - counts by severity and source, resolved vocabulary, branch to create.
170
+ 3. **Sensitive-data inventory** (Phase 2d) - first, because it is the highest-risk content.
171
+ 4. **Screen × role matrix** `[STRUCT-02]` - screens as rows, manifest roles as columns, cells
172
172
  `✓ / missing / misplaced`. The structural map a newcomer reads first.
173
- 5. **Type placement table** `[STRUCT-05]`type · location · consumer count · correct tier ·
173
+ 5. **Type placement table** `[STRUCT-05]` - type · location · consumer count · correct tier ·
174
174
  move required. Paired with the nested-type list `[STRUCT-01]`: owner · nested type · kind ·
175
175
  reference count · verdict.
176
- 6. **Visibility report** `[VIS-01, VIS-02]`over-exposed declarations per file, non-final
176
+ 6. **Visibility report** `[VIS-01, VIS-02]` - over-exposed declarations per file, non-final
177
177
  classes with no subclass.
178
- 7. **Boundary report** `[MOD-*]`import graph (allowed vs forbidden, with coupling depth) ·
178
+ 7. **Boundary report** `[MOD-*]` - import graph (allowed vs forbidden, with coupling depth) ·
179
179
  manifest graph (declared vs legal for the role, plus dead edges) · **removability delta**
180
- ("removing X touches N files: ") · inbound test (tests compile with no sibling present).
181
- 8. **Testability seam report** `[TEST-*]`types reaching for the environment, logic requiring a
180
+ ("removing X touches N files: ...") · inbound test (tests compile with no sibling present).
181
+ 8. **Testability seam report** `[TEST-*]` - types reaching for the environment, logic requiring a
182
182
  view to execute, doubles whose signatures have drifted.
183
- 9. **Change-cost probe** `[FLEX-*]`pick two plausible upcoming changes (a new variant of an
183
+ 9. **Change-cost probe** `[FLEX-*]` - pick two plausible upcoming changes (a new variant of an
184
184
  existing screen; a new field on a shared entity) and state what each costs in files today.
185
- 10. **Findings table**rule ID · `file:line` · current state · proposed fix · severity · source ·
185
+ 10. **Findings table** - rule ID · `file:line` · current state · proposed fix · severity · source ·
186
186
  onboarding impact.
187
- 11. **Per-file work**grouped per file, blocking → important → suggestion; within a severity,
187
+ 11. **Per-file work** - grouped per file, blocking → important → suggestion; within a severity,
188
188
  highest onboarding impact first.
189
- 12. **Behaviour-preservation guard**splitting files, adding MARKs, extracting extensions and
189
+ 12. **Behaviour-preservation guard** - splitting files, adding MARKs, extracting extensions and
190
190
  moving types must not change behaviour. Anything requiring a behaviour change (a missing
191
191
  analytics event, a wrong result type, a timezone fix, a storage migration) goes in a
192
192
  **separate group**, reviewed and tested on its own, never inside a mechanical split commit.
193
- 13. **Tooling delegation & the ratchet**the section that decides whether any of this survives.
193
+ 13. **Tooling delegation & the ratchet** - the section that decides whether any of this survives.
194
194
  - Classify each applied rule by its registry `enforcement`. For every `lint` / `format` /
195
195
  `scan` rule, state the concrete mechanism and whether that toolchain exists in the repo
196
196
  (`LintToolchain`). **When it does not exist, saying so is a finding**, ranked above most
197
197
  individual violations it would have caught.
198
198
  - Recommend **baseline-and-ratchet, never big-bang**: grandfather existing violations, surface
199
- only new ones, drain the backlog behind a green build. Order the plan the same waystop
199
+ only new ones, drain the backlog behind a green build. Order the plan the same way - stop
200
200
  the bleeding, then clean up.
201
201
  - Custom lint rule identifiers **are** the registry IDs (`sec_01_no_plaintext_persistence`,
202
202
  `mod_01_no_sibling_import`) so a violation points straight at the rule entry.
203
- - **Linting is local-only by decision**the skill's `references/lint-local.sh` with `references/swiftlint.draft.yml`, config
203
+ - **Linting is local-only by decision** - the skill's `references/lint-local.sh` with `references/swiftlint.draft.yml`, config
204
204
  and baseline kept outside the repository; no committed config, no build phase, no CI job.
205
205
  State this honestly in the plan rather than describing a gate that does not exist: a
206
206
  pre-PR habit catches less than a wall, so the residual risk is real and belongs in the
207
- report. Adopting it project-wide remains an open decisionrecord it as one so it stays
207
+ report. Adopting it project-wide remains an open decision - record it as one so it stays
208
208
  visible instead of quietly lapsing.
209
209
  - Never imply the audit substitutes for the linter, or the linter for a gate. The audit finds
210
210
  the debt once; the linter keeps a developer honest; only a gate stops it returning.
211
- 14. **Numeric exit criteria**target and today's measured value side by side: lint violations
211
+ 14. **Numeric exit criteria** - target and today's measured value side by side: lint violations
212
212
  per 1k lines (<1) · public-surface ratio, public declarations ÷ externally consumed symbols
213
213
  (≈1) · forbidden cross-module imports (0) · removability delta · files over threshold and
214
214
  files >120 lines with no sectioning (0) · screens missing a manifest role (0) · escape-hatch
215
215
  count `[CONC-03]` (flat or falling) · exception-marker count (flat or falling) · dead code (0)
216
216
  · screens with logic and no mirrored tests (0) · unclassified sensitive symbols (0).
217
- 15. **Exception ledger**every `// standard:exception(<ID>)` in the module: rule · reason ·
217
+ 15. **Exception ledger** - every `// standard:exception(<ID>)` in the module: rule · reason ·
218
218
  expiry · expired yes/no.
219
- 16. **Open Questions**Phase 2b dimensions with no majority, and unclassifiable symbols.
220
- 17. **Suggested commit slicing**one commit per file-group / concern.
221
- 18. **Scope guard**only this module is touched; definition of done is the module's own
219
+ 16. **Open Questions** - Phase 2b dimensions with no majority, and unclassifiable symbols.
220
+ 17. **Suggested commit slicing** - one commit per file-group / concern.
221
+ 18. **Scope guard** - only this module is touched; definition of done is the module's own
222
222
  verification path.
223
223
 
224
224
  Then write a **separate one-page onboarding summary** beside the plan, aimed at a developer
225
225
  joining next week rather than at whoever does the remediation: the ten always/never lines
226
226
  specialised to this module, the screen file manifest, the placement ladder, the sensitive-data
227
- classes in play, and the verification command. Nobody learns a codebase from a 40-finding audit
227
+ classes in play, and the verification command. Nobody learns a codebase from a 40-finding audit -
228
228
  this page is the actual deliverable, and the audit is how you earn the right to write it
229
- accurately. Derive it from `references/STANDARD.md § 0`, do not re-invent it.
229
+ accurately. Derive it from `references/STANDARD.md section 0`, do not re-invent it.
230
230
 
231
231
  Show a concise version of the plan to the user too.
232
232
 
233
- ## Phase 5Hand off
233
+ ## Phase 5 - Hand off
234
234
 
235
235
  `AskUserQuestion` (single-select, `outputLanguage`):
236
236
 
@@ -245,10 +245,10 @@ verifies, and commits only when asked. This routine ends at the handoff.
245
245
 
246
246
  ## Notes
247
247
 
248
- - **Verification reality is per-moduleestablish it, do not assume it.** Some targets cannot be
248
+ - **Verification reality is per-module - establish it, do not assume it.** Some targets cannot be
249
249
  compiled from the CLI (asset symbol generation, UIKit-dependent packages, a dependency failing
250
250
  earlier in the graph); there, verification is Xcode and a manifest dump validates the manifest
251
- only. Never trust a piped build's exit codea pipe reports the tail's status. Record the
251
+ only. Never trust a piped build's exit code - a pipe reports the tail's status. Record the
252
252
  module's actual verification command in the plan.
253
253
  - One module per run. Several modules means several runs and several plans, never one
254
254
  cross-module churn commit.
@@ -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 silently; the others still run. Failure is recorded in `serviceStatus.{service} = { ok: false, checkedAt: <now>, reason: <short> }` so Phase 0 can also read that cache.
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