@mmerterden/multi-agent-pipeline 16.29.0 → 16.31.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/CHANGELOG.md +82 -0
  2. package/docs/features.md +14 -0
  3. package/install/_common.mjs +45 -4
  4. package/package.json +1 -1
  5. package/pipeline/commands/multi-agent/design-check/SKILL.md +6 -5
  6. package/pipeline/commands/multi-agent/help/SKILL.md +13 -12
  7. package/pipeline/commands/multi-agent/manual-test/SKILL.md +1 -1
  8. package/pipeline/commands/multi-agent/sync/SKILL.md +3 -4
  9. package/pipeline/lib/credential-inventory.sh +1 -0
  10. package/pipeline/lib/repo-hygiene.sh +164 -0
  11. package/pipeline/lib/vercel-deploy.sh +41 -22
  12. package/pipeline/multi-agent-refs/channels/pr.md +37 -1
  13. package/pipeline/multi-agent-refs/features/doctor.md +12 -0
  14. package/pipeline/multi-agent-refs/features/model-fallback.md +2 -2
  15. package/pipeline/multi-agent-refs/features/visual-evidence.md +103 -20
  16. package/pipeline/multi-agent-refs/keychain.md +1 -0
  17. package/pipeline/multi-agent-refs/knowledge.md +1 -1
  18. package/pipeline/multi-agent-refs/phases/phase-0-init.md +36 -7
  19. package/pipeline/multi-agent-refs/phases/phase-2-planning.md +1 -1
  20. package/pipeline/multi-agent-refs/phases/phase-3-dev.md +14 -2
  21. package/pipeline/multi-agent-refs/phases/phase-5-test.md +12 -2
  22. package/pipeline/multi-agent-refs/phases/phase-6-commit.md +23 -0
  23. package/pipeline/preferences-template.json +2 -1
  24. package/pipeline/schemas/agent-state.schema.json +79 -1
  25. package/pipeline/schemas/prefs.schema.json +24 -1
  26. package/pipeline/schemas/token-budget.json +10 -10
  27. package/pipeline/scripts/bulk-read.sh +13 -5
  28. package/pipeline/scripts/capture-evidence.sh +170 -5
  29. package/pipeline/scripts/doctor.mjs +56 -1
  30. package/pipeline/scripts/evidence-gate.mjs +31 -2
  31. package/pipeline/scripts/gc-tmp.sh +30 -0
  32. package/pipeline/scripts/gc-worktrees.sh +32 -10
  33. package/pipeline/scripts/offload-ref.sh +13 -5
  34. package/pipeline/scripts/probe-evidence-capability.sh +250 -0
  35. package/pipeline/scripts/purge.sh +11 -0
  36. package/pipeline/scripts/run-ui-tests.sh +380 -0
  37. package/pipeline/scripts/worktree-finalize.sh +9 -0
  38. package/pipeline/skills/.skill-manifest.json +19 -11
  39. package/pipeline/skills/shared/core/multi-agent-manual-test/SKILL.md +10 -1
  40. package/pipeline/skills/shared/core/multi-agent-sync/SKILL.md +2 -1
package/CHANGELOG.md CHANGED
@@ -14,6 +14,88 @@ Internal file-layout changes that don't affect the slash-command surface are sti
14
14
 
15
15
  ---
16
16
 
17
+ ## [16.31.0] - 2026-09-13
18
+
19
+ A pipeline that leaves worktrees, artefacts and exclude lines behind is not generic tooling, it is a guest that rearranges the furniture. This release makes a run leave the repo exactly as it found it, and fixes two credentials-and-integrity gaps found while proving it.
20
+
21
+ ### Added
22
+
23
+ - **`pipeline/lib/repo-hygiene.sh`** - one owner for everything the pipeline writes into somebody else's repo. `ensure_exclusions` writes a marked block into `.git/info/exclude` covering `.worktrees/`, `.pipeline/`, `.multi-agent/`, `triage-output.json`, `.review-diff.txt`, `.build.log` and `.test.log`; `release_exclusions` removes exactly that block and nothing around it; `prune_empty` removes directories it can `rmdir` and never touches `memory/`. The single-line residue guard that lived inline in Phase 0 moved here, so the write and the removal are now written next to each other rather than one existing and the other not.
24
+
25
+ The exclude path resolves through `--git-common-dir`, not `--git-dir`: inside a worktree the latter points at `.git/worktrees/<name>/`, where an exclude file is read by that worktree alone and disappears with it.
26
+
27
+ **`safe_root` refuses `/`, `$HOME`, an empty argument and anything that does not resolve.** Every sibling GC script already had that guard; this one wrote into `$HOME/.multi-agent/` without it, which is a real path on a real machine.
28
+
29
+ - **`doctor`: a `worktree-residue` check** (the 15th). Reports per-repo `.worktrees` count and total size and points at `/multi-agent:garbage-collect` above the threshold. Written because the leak was invisible: one reference repo had accumulated 15 worktrees and 11.4 GB, and nothing reported it.
30
+
31
+ - **`smoke-repo-residue.sh`** (26 assertions, 10 sections) - simulates a full run's artefacts in a temporary repo and proves `git status --porcelain` is empty afterwards. There was no gate of this shape before, which is why the leak survived.
32
+
33
+ ### Fixed
34
+
35
+ - **`vercel-deploy.sh` never read the credential store it advertised.** The header has promised "resolved from prefs keychainMapping if unset" since the wrapper was written; the block under that promise sourced the resolver only to name it in an error message. A machine with the token onboarded was still told to set an environment variable. Resolution now lives in one function that both `doctor` and `deploy` call, so the diagnostic can no longer report a token missing that a deploy would have found.
36
+
37
+ The key was also **impossible to map**: `keychainMapping` is `additionalProperties: false` and carried no `vercel` slot, so the remediation the error printed could not be followed. The slot now exists in the schema, the preferences template, `credential-inventory.sh`'s capability table and the `keychain.md` key table - the four places `smoke-credential-key-alignment.sh` requires a runtime key to appear, and that gate caught three of them being missing.
38
+
39
+ - **`.skill-manifest.json` had been stale since 09-09.** Four `SKILL.md` files changed under it and two more were never in it at all, so every install printed drift warnings nobody could act on, and a signed manifest that is allowed to rot is decorative. `smoke-gate-wiring.sh` exempted `sign-skills.sh` as "release-time signing" for a release step that did not exist; the release flow now names it, and the manifest smoke verifies the **shipped tree** rather than only its fixtures.
40
+
41
+ - **An exclude file with no trailing newline was corrupted.** The block marker was appended directly onto the user's last line, which both mangled that line and made the block unremovable, since the removal matches on the marker being at the start of a line.
42
+
43
+ - **`gc-tmp.sh` swept four temp prefixes it never knew about** (`complaint-analysis-*`, `generate-issue-*`, `cred-inventory-*`, `pr-body-*`) and `*-wiki` clones. The `.git` requirement that guards wiki clones applies to those alone - applied to the prefix list it would have narrowed an unconditional sweep.
44
+
45
+ - **`gc-worktrees.sh` failed silently when the hygiene lib was absent**, doing nothing where it was meant to write the guard. It now says so loudly.
46
+
47
+ - **Two legacy data roots** (`~/.claude/multi-agent-tasks/`, `~/.claude/.multi-agent-state/`, both with zero code references) are recognised by `pruneAbandonedTrees()` through an entry marker, so one content-bearing entry without the marker keeps the whole tree rather than the installer deleting somebody's data.
48
+
49
+ ### Changed
50
+
51
+ - `worktree-finalize.sh` prunes through the resolved real path rather than `$PROJECT_ROOT`, which differs whenever the repo is reached through a symlink.
52
+ - `purge` is the only path that releases the managed exclude block - a garbage collection run is not a reason to stop ignoring the pipeline's own artefacts.
53
+ - `smoke-offload-ref.sh` and `smoke-gc-worktrees.sh` now assert through `git check-ignore` on the real leak paths instead of matching our own printed strings.
54
+
55
+ ## [16.30.0] - 2026-09-12
56
+
57
+ The flow video existed as a contract with no recorder, no UI test ever ran, and the PR carried filenames where a reviewer needed a picture. This release makes the evidence real and asks the user what it is worth paying for.
58
+
59
+ ### Added
60
+
61
+ - **`capture-evidence.sh video start|stop`** - the recorder the contract has described since 16.24.0 and nothing implemented. iOS records through `simctl io recordVideo --codec h264` (hevc does not play in the Jira attachment preview or several browsers) and stops on SIGINT, which is the only signal that makes simctl close the container; a TERM leaves an mp4 every player refuses. Android uses `screenrecord` with its own 180s ceiling, which the script clamps to and reports, because one preference honoured on one platform and silently halved on the other is worse than a stated limit. Shell rather than MCP, like the still capture beside it: a host with no toolkit MCP registered still produces evidence.
62
+
63
+ **Both recorders encode on change**, so a flow over a screen that never moved is a valid two-frame file a fraction of a second long. That file is kept - it is not broken - but `stop` says on stderr that nothing moved, and the caller records it as a gap rather than presenting a still as a flow. Nothing asserts the duration against wall clock anywhere, because that check fails a correct capture of a static screen.
64
+
65
+ - **`run-ui-tests.sh`** - finds the repo's own UI test target, picks the tests covering the changed files, and runs them. Exit 3 (target present, nothing matches) and exit 4 (no target) are reasons to fall to the next video tier; only exit 1 is a red test, and the log is the evidence, since a runner that died before reaching the tests also exits non-zero.
66
+
67
+ **The signal is `XCUIApplication`, not a folder named `*UITests`.** In the reference iOS app 477 files sit under such a path and exactly 2 drive the UI; the other 475 are snapshot tests that render a view and compare pixels without launching the app. Recording video around one of those produces a still frame and files it as a flow. And a real app has many candidates - 17 UI test directories there, 8 instrumentation modules in the Android app - so detection reports the whole set and lets the changed files choose. Taking the first off a `find` is a guess wearing a measurement's clothes.
68
+
69
+ Detection reads the filesystem and never calls `xcodebuild -list`, which resolves the SPM graph first and took 72 seconds on the reference workspace. It also found a target the `xcodebuild` path had missed entirely. The scheme, which only a run needs, is resolved in `run` behind a timeout.
70
+
71
+ - **`probe-evidence-capability.sh`** - measures before the user is asked. Offering "unit + UI test with a screen recording" and discovering afterwards that there is no target or no booted device spends the user's answer on something that was never going to happen. Every absent value carries its reason, because `no booted simulator, but one is available to boot` and `no iOS simulator available on this machine` close the same menu row and ask for completely different things. A value it could not measure is `null`, never `false`: with no `adb` on the PATH, "no device attached" is a negative nobody looked for and reads exactly like one somebody checked.
72
+
73
+ - **Test depth is a question** (Phase 0 Step 7.7): unit only, unit plus the UI test with a recording, or unit plus an MCP-driven flow. The options are built from the probe; a closed one keeps its row and states why, and when every option but the first is closed nothing is asked at all and `testDepthSource` records `forced` - a one-option question has no answer in it. Asked at intake rather than in Phase 5 because Phase 5 is absent from four of the eight modes, and a question asked where it cannot be reached is a question nobody answers. `prefs.global.testDepth.default` serves both autopilot entries.
74
+
75
+ - **The tier is re-checked at capture time.** A simulator booted at intake can be gone by the time the build goes green, so Phase 3 re-measures the device row alone and records the transition it came down from.
76
+
77
+ - **`state.visualEvidence.host`, and a PR that shows the picture.** Resolved in Phase 6: Jira takes both stills and video as attachments; with no Jira the stills go to an orphan `evidence/<task-id>` branch and the PR body embeds them. There is no GitHub API that attaches a file to an issue or a PR - the web uploader needs a browser session - so a branch is the only mechanism a script has that neither touches the PR diff nor publishes a release. **A private repo gets a blob permalink rather than an embed**: GitHub renders markdown images through a proxy that has no credentials for a private repo, and a broken image reads as missing evidence. Video stays Jira-only, and on a GitHub run none is recorded at all rather than paying UI-test minutes for a download nobody opens.
78
+
79
+ - **The probe's output contract**, settled by its own review. `--json-out <path>` writes the JSON the state records while stdout stays `KEY=VALUE`, so one run serves both the shell that builds the menu and the state that keeps the measurement; two runs meant two repo scans and two chances to disagree. Every value is **shell-quoted**, because Phase 0 evals that output and the reasons are prose: unquoted, `EVIDENCE_DEVICE_REASON=no booted simulator, but one is available to boot` makes `eval` run `booted` as a command and keep only the first word. That bug is invisible on any machine where the reasons happen to come back empty, which is the machine it was first tested on. `--only device` skips the detection scan for the Phase 3 re-check, and reports tier 1 as **unknown** rather than closed there - a tier that was never probed is not a tier that was ruled out, which is the same rule the probe states about every other field.
80
+
81
+ - **`smoke-ui-test-runner.sh`** (24 assertions) and **`smoke-evidence-probe.sh`** (33). The first caught a real bug while being written: `run` keyed its "no target" exit off the single chosen target, which is deliberately empty while several candidates exist, so "this repo has nothing to record" and "nothing covers what changed" collapsed into one code.
82
+
83
+ ### Fixed
84
+
85
+ - **`smoke-no-mcp-in-dev-phases.sh` failed the pipeline's own prescribed calls.** It rejected every `mcpCalls[]` entry at phase >= 2 regardless of tool, while the rule it enforces (Locked 30, `rules/figma-pipeline.md`, `phase-3-dev.md` item 7) names `mcp__claude_ai_Figma__*` and nothing else, because what it protects is a single source of design truth, not MCP as a transport. `phase-3-dev.md` builds with `ios_xcodebuild` and `phase-5-test.md` audits with the accessibility tools, both at phase >= 2 and neither reading a design; the first person to record telemetry honestly is the one the gate would have failed. Now filtered on the tool name, with three assertions covering both directions.
86
+
87
+ - **The design-check MCP probe had never run.** `design-check/SKILL.md` read `mcpServers[...].args[0]` as a script path and `cd`'d to its parent. The registration is the npx form, so `args[0]` is `-y`, the `cd` failed every time, and a gate that exists to catch a stale tool list was inert. It now launches the server exactly as it is registered, which is what `doctor.mjs` already did.
88
+
89
+ - **`evidence-gate.mjs` never read the `screenshot` field** it has carried in the manual-test document shape since the gate shipped, so a criterion with `"screenshot": null` passed as a verified manual test on a UI change - the one case the picture was added for. New `--require-screenshot` flag, set by Phase 5 when visual evidence is required: a passing criterion must name a file that is actually on disk, since a path pointing nowhere is not evidence.
90
+
91
+ - **The derived `multi-agent-manual-test` skill had no evidence gate at all**, while its command sibling had one. Two copies of a command that disagree about whether a claim needs proof is the drift `skill-siblings.mjs` exists to surface.
92
+
93
+ ### Changed
94
+
95
+ - `features/visual-evidence.md` section 4 rewritten around probe -> question -> run, plus a new host section carrying the two GitHub constraints that shape the PR rendering.
96
+ - Four phase docs gained a step, so `token-budget.json` moves with them: phase 0 (probe + depth question), 3 and 5 (recording), 6 (host resolution). The docs were compressed first and the new numbers are the measured residual, not headroom - these files are read into context on every run, so the budget is the place that cost is recorded rather than absorbed.
97
+ - `smoke-visual-evidence.sh` now records two seconds on a booted device and verifies the h264 file instead of grepping this repo's own markdown for the words "Tier 1" and `agent_run_steps`. A gate that asserts what we print proves nothing. Where no device is present the skip is printed as its own assertion, because `run-smokes.mjs` counts a silent suite as zero assertions and treats it as inert. It also holds the host to its two constraints: the public shape must embed an image, and the private shape must **not** - an embedded raw URL from a private repo renders broken for every reader, which looks like missing evidence rather than a link nobody clicked.
98
+
17
99
  ## [16.29.0] - 2026-09-11
18
100
 
19
101
  ### Added
package/docs/features.md CHANGED
@@ -271,6 +271,20 @@ Signals + weights: `security_path` ×3, `migration` ×4, `public_api` ×2, `no_t
271
271
 
272
272
  `pipeline/scripts/test-gap-scan.mjs` runs at Phase 5 Step 0. Walks the diff for newly added public symbols and reports those with no paired test. Stack-specific rules ship for iOS, Android, Python, Node.js. iOS Views and Android `@Composable` symbols default to `important`; other public API additions to `suggestion`. Optional gating via `prefs.testGap.blockingThreshold` - when set, the report becomes a Phase 4 rework finding once `important + blocking` count exceeds the threshold.
273
273
 
274
+ ### Visual Evidence (UI changes)
275
+
276
+ A UI change carries its own picture. `state.visualEvidence.required` is decided mechanically from `taskType` plus the changed-file list, never from a reading of the task.
277
+
278
+ **Stills.** The "before" is the reporter's own ticket attachment, harvested in Phase 0; the pipeline never rebuilds the old state to photograph it. The "after" is captured in Phase 3 right after the build goes green, not Phase 5, which autopilot and both local modes drop. `capture-evidence.sh` cleans the status bar and downscales to 1242px so two captures of one screen differ by the change and not by the clock.
279
+
280
+ **The flow video rides on a test run.** `probe-evidence-capability.sh` measures the UI test target, the tests matching this change, the device, the recorder and the MCP registration; Phase 0 Step 7.7 then asks the depth with the options built from that measurement, and a closed option keeps its row and states why. Tier 1 runs the repo's own UI test and records around it, tier 2 drives the flow through `agent_run_steps`, tier 3 records nothing and says so. The tier is re-checked before the recording starts, because a simulator booted at intake can be gone by Phase 3.
281
+
282
+ UI test detection keys on `XCUIApplication` rather than on a folder named `*UITests`: in a real app the overwhelming majority of files under such a path are snapshot tests, which never launch the app and would produce a still frame filed as a flow.
283
+
284
+ **Where it lands.** Jira takes both stills and video as attachments. With no Jira the stills go to an orphan `evidence/<task-id>` branch and the PR body embeds them, or links them with a blob permalink when the repo is private (GitHub's image proxy has no credentials for a private repo, and a broken image reads as missing evidence). Phase 6 blocks when a required artefact is neither published nor explained; the gate is against silence, not against an honest "the ticket carries no image".
285
+
286
+ Toggle via `prefs.global.visualEvidence.enabled` (default ON), `visualEvidence.githubHost`, `visualEvidence.maxAttachmentMb`, `visualEvidence.maxVideoSeconds`, `prefs.global.testDepth.default`.
287
+
274
288
  ### Triage Memory
275
289
 
276
290
  Per-repo append-only JSONL corpus at `~/.claude/memory/multi-agent/<repo-slug>/triage-corpus.jsonl`. Phase 7 ingests every triage output (idempotent), Phase 1 enriches the analysis with similar past tasks, Phase 4 triage attaches prior-art hits to each raw finding with an explicit bias hedge. Token-overlap recall, zero deps, Node-18-compatible. `/multi-agent:search "<text>" --semantic` routes the query to the corpus instead of agent-log grep. Toggle via `prefs.global.priorArtEnrichment.enabled` (default ON).
@@ -268,13 +268,30 @@ export function pruneLegacyMultiAgentSkills(skillsDir) {
268
268
  *
269
269
  * `root: "home"` entries sit beside `~/.claude`, not inside it.
270
270
  *
271
- * @type {ReadonlyArray<{dir: string, root?: "claude"|"home", reason: string}>}
271
+ * @type {ReadonlyArray<{dir: string, root?: "claude"|"home", reason: string, knownEntries?: string[], entryMarker?: string}>}
272
272
  */
273
273
  const ABANDONED_TREES = Object.freeze([
274
274
  {
275
275
  dir: "eval",
276
276
  reason: "eval corpora; the harnesses that read them are maintainer-only and no longer ship",
277
277
  },
278
+ {
279
+ // Two roots the pipeline wrote task state into before the log root settled
280
+ // on ~/.claude/logs/multi-agent/. Nothing in the tree references either any
281
+ // more - no script, no doc, no schema - so they are orphaned copies of
282
+ // state nothing can read. `entryMarker` is the guard: every entry must be a
283
+ // directory holding an agent-state.json, which is the shape only this
284
+ // pipeline wrote. A folder someone else put there fails that test and the
285
+ // tree is kept.
286
+ dir: "multi-agent-tasks",
287
+ entryMarker: "agent-state.json",
288
+ reason: "pre-log-root task state; the current root is ~/.claude/logs/multi-agent/",
289
+ },
290
+ {
291
+ dir: ".multi-agent-state",
292
+ entryMarker: "agent-state.json",
293
+ reason: "pre-log-root task state; the current root is ~/.claude/logs/multi-agent/",
294
+ },
278
295
  {
279
296
  dir: ".multi-agent",
280
297
  root: "home",
@@ -290,9 +307,33 @@ const ABANDONED_TREES = Object.freeze([
290
307
  /**
291
308
  * @param {string} target
292
309
  * @param {ReadonlyArray<string>|undefined} knownEntries
310
+ * @param {string|undefined} entryMarker file every entry must contain, for trees keyed by task id
293
311
  * @returns {boolean} true when every top-level entry is one the abandoned tree is known to contain
294
312
  */
295
- function isKnownLegacyTree(target, knownEntries) {
313
+ function isKnownLegacyTree(target, knownEntries, entryMarker) {
314
+ if (entryMarker) {
315
+ // Shape check rather than a name list, for trees whose entries are task ids
316
+ // and therefore unknowable in advance. An entry qualifies when it carries
317
+ // the marker, or when it is an empty directory - an abandoned run that was
318
+ // torn down leaves one of those, and nothing that is empty can belong to
319
+ // anyone else. A directory with content but no marker is somebody's, and
320
+ // one such entry keeps the whole tree.
321
+ try {
322
+ return readdirSync(target)
323
+ .filter((name) => name !== ".DS_Store")
324
+ .every((name) => {
325
+ const entry = join(target, name);
326
+ if (existsSync(join(entry, entryMarker))) return true;
327
+ try {
328
+ return readdirSync(entry).filter((n) => n !== ".DS_Store").length === 0;
329
+ } catch {
330
+ return false;
331
+ }
332
+ });
333
+ } catch {
334
+ return false;
335
+ }
336
+ }
296
337
  if (!knownEntries) return true;
297
338
  try {
298
339
  return readdirSync(target).every((name) => knownEntries.includes(name));
@@ -329,11 +370,11 @@ export const COMMAND_RENAMES = Object.freeze({
329
370
  */
330
371
  export function pruneAbandonedTrees(claudeDir, home = dirname(claudeDir)) {
331
372
  let removed = 0;
332
- for (const { dir, root, reason, knownEntries } of ABANDONED_TREES) {
373
+ for (const { dir, root, reason, knownEntries, entryMarker } of ABANDONED_TREES) {
333
374
  const base = root === "home" ? home : claudeDir;
334
375
  const target = join(base, dir);
335
376
  if (!existsSync(target)) continue;
336
- if (!isKnownLegacyTree(target, knownEntries)) {
377
+ if (!isKnownLegacyTree(target, knownEntries, entryMarker)) {
337
378
  console.log(
338
379
  ` kept ${target}: holds entries no abandoned install wrote, so it is not ours to remove`,
339
380
  );
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mmerterden/multi-agent-pipeline",
3
- "version": "16.29.0",
3
+ "version": "16.31.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",
@@ -79,23 +79,24 @@ Every Phase 0 / Phase 2 decision uses a native `AskUserQuestion` picker (numbere
79
79
 
80
80
  a. **Session** - `mcp__multi-agent-toolkit__design_scenario_inventory` must be present in the tools available to you, and `mcp__multi-agent-toolkit__design_visual_compare` must accept `live_region`. Absent → the session is bound to a stale process.
81
81
 
82
- b. **Disk** - probe the configured server directly, which reports what a fresh connection WOULD serve:
82
+ b. **Disk** - probe what a fresh connection WOULD serve. Launch it as registered; under npx `args[0]` is `-y`, not a path.
83
83
  ```bash
84
- cd "$(python3 -c "import json,os;print(json.load(open(os.path.expanduser('~/.claude.json')))['mcpServers']['multi-agent-toolkit']['args'][0].rsplit('/',1)[0])")" && \
84
+ MA_MCP_CMD=$(python3 -c "import json,os,shlex;e=json.load(open(os.path.expanduser('~/.claude.json')))['mcpServers']['multi-agent-toolkit'];print(shlex.join([e['command']]+e.get('args',[])))")
85
85
  printf '%s\n%s\n' \
86
86
  '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"probe","version":"1"}}}' \
87
87
  '{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}' \
88
- | node index.js 2>/dev/null | python3 -c "
88
+ | eval "$MA_MCP_CMD" 2>/dev/null | python3 -c "
89
89
  import sys,json
90
90
  for l in sys.stdin:
91
91
  try: m=json.loads(l)
92
92
  except: continue
93
- if m.get('id')==1: print('version', m['result']['serverInfo']['version'])
93
+ if m.get('id')==1: print('version',m['result']['serverInfo']['version'])
94
94
  if m.get('id')==2:
95
95
  n=[t['name'] for t in m['result']['tools']]
96
- print('tools', len(n), 'inventory', 'design_scenario_inventory' in n)"
96
+ print('tools',len(n),'inventory','design_scenario_inventory' in n)"
97
97
  ```
98
98
 
99
+
99
100
  Branch on the two results:
100
101
  - **disk OK, session missing the tool** → **HALT** and tell the user to reconnect: `/mcp` → `multi-agent-toolkit` → Reconnect. Note the tool-count tell (a stale build advertises one fewer `design_*` tool). If a reconnect does not take, stale server processes may be lingering - `pgrep -f multi-agent-toolkit-mcp/index.js` with `ps -o lstart=` shows their start times, and any that predate the code's mtime cannot serve the current tools.
101
102
  - **disk itself stale** (older version, or `inventory False`) → **HALT**: the checkout needs updating before a reconnect can help.
@@ -243,24 +243,25 @@ Key Features:
243
243
  Multi-Platform iOS, Android, Backend, Web - auto-detected
244
244
  Build Queue xcodebuild/Gradle serialized with lock file - parallel safe
245
245
  Stack Aware Auto-detects iOS/Android/Python/Node/Go/Docker, uses the enabled stack plugin's skills
246
- Det. Gates Build + lint + test + secret scan BEFORE AI review
247
- Multi-Repo Per-repo worktrees, per-repo identity, integration build before commit
248
- Identity Routing Git identity auto-picked from repo origin URL (corporate vs personal)
249
- Issue Safety Never auto-closes issues (4 approvals required, GitHub + Jira)
250
- Store Compliance /multi-agent:test "store-ready" runs 18-rule iOS audit (ITMS / Privacy Manifest /
251
- code signing / debug-tool leak / IPv6 / SDK list / etc.) and 21-rule Android audit
246
+ Det. Gates Build + lint + test + secret scan before AI review
247
+ Multi-Repo Per-repo worktrees + identity, integration build before commit
248
+ Identity Routing Git identity picked from the repo origin URL (corporate vs personal)
249
+ Issue Safety Never auto-closes issues (4 approvals, GitHub + Jira)
250
+ Store Compliance /multi-agent:test "store-ready" - 18-rule iOS audit (ITMS, Privacy Manifest,
251
+ signing, debug leaks, IPv6, SDK list) + 21-rule Android audit
252
252
  Bilingual EN + TR - outputLanguage toggles assistant explanations; promptLanguage is locked en
253
253
 
254
254
  Quality & Telemetry (advisory, on by default - flip prefs.global.* to disable):
255
255
 
256
256
  Diff Risk Score Phase 4 Step 1.75 ranks files before reviewer dispatch (security paths,
257
- schema migrations, no-test-change, complexity delta) - heuristic, sub-second
257
+ migrations, no-test-change, complexity) - heuristic, sub-second
258
258
  Test Gap Report Phase 5 Step 0 surfaces public symbols added in this branch with no paired test
259
- Cost Breakdown Phase 7 appends per-phase tokens (in/out) + estimated USD to agent-log.md
260
- Triage Memory Phase 7 ingests accepted/deferred/rejected findings into a per-repo corpus
261
- Prior-Art Lookup Phase 1 + Phase 4 query the corpus for similar past findings, inject as context
262
- Per-Persona Reviewer/agent dispatch reads `preferredModel` from persona file; per-call override
263
- via PHASE_MODEL_OVERRIDE; ladder fable -> opus -> sonnet -> haiku
259
+ Visual Evidence before/after stills + flow video on UI changes; Step 7.7 asks the depth
260
+ Cost Breakdown Phase 7 appends per-phase tokens + estimated USD to agent-log.md
261
+ Triage Memory Phase 7 ingests accepted/deferred/rejected findings into a repo corpus
262
+ Prior-Art Lookup Phase 1 + Phase 4 query the corpus for similar findings, inject as context
263
+ Per-Persona Dispatch reads `preferredModel` from the persona file; override per call via
264
+ PHASE_MODEL_OVERRIDE; ladder fable -> opus -> sonnet -> haiku
264
265
 
265
266
  ------------------------------------------------------------
266
267
 
@@ -51,5 +51,5 @@ Lets you switch to the task branch for manual testing in Xcode before the PR is
51
51
  ```json
52
52
  {"criteria":[{"spec":"<quote>","source":"analysis 15.2 | plan task 3 | user","observed":"<what was seen>","verdict":"pass|fail|not-tested","reason":"<required when not-tested>","screenshot":"<path or null>"}],"verdict":"passed|failed"}
53
53
  ```
54
- then run `node $HOME/.claude/scripts/evidence-gate.mjs --claim manual --status passed --evidence "$WORKTREE/.pipeline/manual-test.json"`. Exit 1 means the "ok" is not accepted: name the criterion that is missing evidence and wait for the next reply. Exit 0 → `phase-tracker.sh update 5 completed` + `phase-tracker.sh meta 5 Result "local test passed (user)"`, recreate the worktree, continue to Phase 6. Full contract: `$HOME/.claude/multi-agent-refs/phases/phase-5-test.md` step 5.
54
+ then run `node $HOME/.claude/scripts/evidence-gate.mjs --claim manual --status passed --evidence "$WORKTREE/.pipeline/manual-test.json"`, adding `--require-screenshot` when `state.visualEvidence.required` is true (a passing criterion then has to name a screenshot that is actually on disk). Exit 1 means the "ok" is not accepted: name the criterion that is missing evidence and wait for the next reply. Exit 0 → `phase-tracker.sh update 5 completed` + `phase-tracker.sh meta 5 Result "local test passed (user)"`, recreate the worktree, continue to Phase 6. Full contract: `$HOME/.claude/multi-agent-refs/phases/phase-5-test.md` step 5.
55
55
  - **Fix needed** → `phase-tracker.sh now 5 "applying fix: <summary>"`, recreate the worktree, apply the fix
@@ -120,9 +120,8 @@ Step 0 gate rules and why: `features/doctor.md`.
120
120
  - `~/.claude/CLAUDE.md`, `~/.claude/rules/`, `~/.claude/knowledge/`
121
121
  - `~/.claude/scripts/` - EXCEPT `pre-commit-check.sh`, `agent-guard.sh`, `agent-guard.py`, and `build-stack-plugins.mjs` (generic, synced)
122
122
  - `~/.claude/settings.json`
123
- - **Any `~/.claude/commands/multi-agent/*/SKILL.md` whose frontmatter has `local-only: true`** - these are user/repo-specific alias wrappers that delegate to a private marketplace plugin's skills exposed as `multi-agent:<name>`. Syncing them would leak the private plugin/skill names into the public pipeline. Filter before copy: skip every source file containing `local-only: true`, and after copy assert none reached `pipeline/commands/`.
123
+ - **Any `~/.claude/commands/multi-agent/*/SKILL.md` whose frontmatter has `local-only: true`** - these are user/repo-specific alias wrappers that delegate to a private marketplace plugin's skills exposed as `multi-agent:<name>`. Syncing them would leak the private plugin/skill names into the public pipeline. Skip them at copy time; the backstop below proves none reached `pipeline/commands/`.
124
124
  ```bash
125
- # backstop: no local-only wrapper may exist in the synced target
126
125
  grep -rl "^local-only: true" ~/multi-agent-pipeline/pipeline/commands/ 2>/dev/null \
127
126
  && { echo "ABORT: local-only wrapper leaked into pipeline/commands"; exit 1; } || true
128
127
  # backstop: corporate refs only (generic ai-ios-toolkit is fine)
@@ -140,7 +139,6 @@ Step 0 gate rules and why: `features/doctor.md`.
140
139
  # normalize: restore English descriptions, strip description-en sidecars
141
140
  node ~/multi-agent-pipeline/pipeline/scripts/localize-commands.mjs restore \
142
141
  --dir ~/multi-agent-pipeline/pipeline/commands/multi-agent
143
- # backstop: no sidecar may survive into the repo
144
142
  grep -rl "^description-en:" ~/multi-agent-pipeline/pipeline/commands/ 2>/dev/null \
145
143
  && { echo "ABORT: description-en sidecar leaked into pipeline/commands"; exit 1; } || true
146
144
  # backstop: repo description lines must be English (Turkish-diacritics proxy)
@@ -467,7 +465,8 @@ When invoked with the `release` argument:
467
465
  ```
468
466
  1. REPO Claude Code -> pipeline repo (genericized)
469
467
  2. VERIFY Personal data scan -> 0 results; config-hygiene scan (scan-agent-config.sh) -> 0 HIGH
470
- 3. LINT + TEST ESLint + smoke tests
468
+ 3. LINT + TEST ESLint + smoke tests (stale .skill-manifest.json fails here; re-sign
469
+ it with sign-skills.sh whenever a SKILL.md moved)
471
470
  4. VERSION package.json version bump (patch/minor/major)
472
471
  5. Commit + TAG git commit + git tag v{VERSION}
473
472
  6. PUSH git push --tags; publish is local (release.yml off)
@@ -358,6 +358,7 @@ capability_of() {
358
358
  figma|figma_mcp) echo "fetch design context, screenshots and Code Connect mappings" ;;
359
359
  jenkins) echo "read build results" ;;
360
360
  npm) echo "publish to the npm registry" ;;
361
+ vercel) echo "deploy the website to Vercel and read its deployment status" ;;
361
362
  appstore_connect_key_id|appstore_connect_issuer_id)
362
363
  echo "validate an archive against App Store rules before submission (the .p8 itself is a file at ~/.appstoreconnect/private_keys/, never a mapping)" ;;
363
364
  appstore_connect_apple_id|appstore_connect_password_item)
@@ -0,0 +1,164 @@
1
+ #!/usr/bin/env bash
2
+ #
3
+ # repo-hygiene.sh
4
+ # Keeps pipeline artefacts out of the user's repository. Sourceable library.
5
+ #
6
+ # The pipeline writes into a checkout in three ways: the worktree parent
7
+ # (.worktrees/), the per-run artefact dir (.pipeline/), and the offload tree
8
+ # (.multi-agent/). Only the first was ever excluded, and only by a one-line
9
+ # guard duplicated across two phase docs and gc-worktrees.sh. The other two
10
+ # were never excluded anywhere, which is invisible in worktree mode (the
11
+ # worktree is deleted) and permanent in --local mode (nothing deletes it).
12
+ #
13
+ # Functions:
14
+ # ma_hygiene_ensure_exclusions <repo-root> Write the managed block into
15
+ # .git/info/exclude. Idempotent;
16
+ # rewrites the block on upgrade so
17
+ # new entries reach old checkouts.
18
+ # ma_hygiene_release_exclusions <repo-root> Remove the managed block, and
19
+ # nothing else. Lines a human put
20
+ # there are never touched.
21
+ # ma_hygiene_local_gitignore <repo-root> Write .multi-agent/.gitignore.
22
+ # ma_hygiene_prune_empty <repo-root> rmdir the artefact parents when
23
+ # their last child is gone.
24
+ #
25
+ # Every function is best-effort: a read-only or non-git directory is not an
26
+ # error, because refusing to run a pipeline over a repo we cannot tidy is worse
27
+ # than leaving it untidy. Callers do not check the return value.
28
+ #
29
+ # bash 3.2 compatible (macOS ships 3.2 and has no flock, no mapfile).
30
+
31
+ MA_HYGIENE_BEGIN='# >>> multi-agent pipeline (managed) >>>'
32
+ MA_HYGIENE_END='# <<< multi-agent pipeline (managed) <<<'
33
+
34
+ # Paths the pipeline writes inside a checkout. `.worktrees/` predates this file
35
+ # and may already be present as a bare line; git treats a duplicated pattern as
36
+ # one, so the managed block restates it rather than trying to adopt it.
37
+ ma_hygiene_patterns() {
38
+ cat <<'PATTERNS'
39
+ .worktrees/
40
+ .pipeline/
41
+ .multi-agent/
42
+ triage-output.json
43
+ .review-diff.txt
44
+ .build.log
45
+ .test.log
46
+ PATTERNS
47
+ }
48
+
49
+ # Same contract every gc-*.sh in this tree carries: refuse to operate on `/`,
50
+ # on $HOME, or on a path that does not resolve. $HOME matters specifically -
51
+ # `$HOME/.multi-agent/` is a real directory the installer knows about, so a
52
+ # caller that passed the wrong root would have had `prune_empty` delete a file
53
+ # out of it. Callers never check the return value; refusing is silent and safe.
54
+ #
55
+ # One consequence worth knowing: offload-ref.sh and bulk-read.sh fall back to
56
+ # $PWD when they are not inside a repo, so offloading from $HOME itself now
57
+ # skips writing the ignore file. The payload is still written; only the ignore
58
+ # is skipped, and $HOME/.multi-agent/ is a tree the installer prunes anyway.
59
+ ma_hygiene_safe_root() {
60
+ local root="${1:-}" resolved
61
+ [ -n "$root" ] || return 1
62
+ resolved=$(cd "$root" 2>/dev/null && pwd -P) || return 1
63
+ [ -n "$resolved" ] || return 1
64
+ [ "$resolved" = "/" ] && return 1
65
+ [ "$resolved" = "$(cd "$HOME" 2>/dev/null && pwd -P)" ] && return 1
66
+ printf '%s\n' "$resolved"
67
+ }
68
+
69
+ # Resolve the exclude file through --git-common-dir, not --git-dir: inside a
70
+ # worktree the latter points at .git/worktrees/<name>, whose info/exclude is
71
+ # per-worktree and dies with it.
72
+ ma_hygiene_exclude_path() {
73
+ local root="$1" common
74
+ common=$(git -C "$root" rev-parse --path-format=absolute --git-common-dir 2>/dev/null) || return 1
75
+ [ -n "$common" ] || return 1
76
+ printf '%s/info/exclude\n' "$common"
77
+ }
78
+
79
+ ma_hygiene_ensure_exclusions() {
80
+ local root ex tmp
81
+ root=$(ma_hygiene_safe_root "${1:-}") || return 0
82
+ ex=$(ma_hygiene_exclude_path "$root") || return 0
83
+ mkdir -p "$(dirname "$ex")" 2>/dev/null || return 0
84
+ [ -f "$ex" ] || : > "$ex" 2>/dev/null || return 0
85
+
86
+ tmp="${ex}.ma-tmp.$$"
87
+ # Drop any previous managed block, keep everything else verbatim, then append
88
+ # the current one. sed is the portable way to delete an inclusive range.
89
+ if grep -qF "$MA_HYGIENE_BEGIN" "$ex" 2>/dev/null; then
90
+ sed "/^$(printf '%s' "$MA_HYGIENE_BEGIN" | sed 's/[][\.*^$\/]/\\&/g')$/,/^$(printf '%s' "$MA_HYGIENE_END" | sed 's/[][\.*^$\/]/\\&/g')$/d" "$ex" > "$tmp" 2>/dev/null || { rm -f "$tmp"; return 0; }
91
+ else
92
+ cat "$ex" > "$tmp" 2>/dev/null || { rm -f "$tmp"; return 0; }
93
+ fi
94
+
95
+ # A file whose last line has no newline would take the BEGIN marker onto the
96
+ # end of it. That corrupts the user's line AND leaves the marker unanchored,
97
+ # so `release_exclusions` can never match it again and the block is stuck in
98
+ # their file for good. Close the line first.
99
+ if [ -s "$tmp" ] && [ "$(tail -c 1 "$tmp" | wc -l | tr -d ' ')" = "0" ]; then
100
+ printf '\n' >> "$tmp" 2>/dev/null || { rm -f "$tmp"; return 0; }
101
+ fi
102
+
103
+ {
104
+ printf '%s\n' "$MA_HYGIENE_BEGIN"
105
+ ma_hygiene_patterns
106
+ printf '%s\n' "$MA_HYGIENE_END"
107
+ } >> "$tmp" 2>/dev/null || { rm -f "$tmp"; return 0; }
108
+
109
+ mv "$tmp" "$ex" 2>/dev/null || rm -f "$tmp"
110
+ return 0
111
+ }
112
+
113
+ ma_hygiene_release_exclusions() {
114
+ local root ex tmp
115
+ root=$(ma_hygiene_safe_root "${1:-}") || return 0
116
+ ex=$(ma_hygiene_exclude_path "$root") || return 0
117
+ [ -f "$ex" ] || return 0
118
+ grep -qF "$MA_HYGIENE_BEGIN" "$ex" 2>/dev/null || return 0
119
+
120
+ tmp="${ex}.ma-tmp.$$"
121
+ sed "/^$(printf '%s' "$MA_HYGIENE_BEGIN" | sed 's/[][\.*^$\/]/\\&/g')$/,/^$(printf '%s' "$MA_HYGIENE_END" | sed 's/[][\.*^$\/]/\\&/g')$/d" "$ex" > "$tmp" 2>/dev/null || { rm -f "$tmp"; return 0; }
122
+ mv "$tmp" "$ex" 2>/dev/null || rm -f "$tmp"
123
+ return 0
124
+ }
125
+
126
+ # `*` and not a list of children: an ignore file that names its siblings does
127
+ # not name itself, so .multi-agent/.gitignore stayed permanently untracked -
128
+ # the one piece of residue the guard existed to prevent. Git reads an ignore
129
+ # file whether or not it is itself ignored, so self-exclusion is safe.
130
+ ma_hygiene_local_gitignore() {
131
+ local root gi
132
+ root=$(ma_hygiene_safe_root "${1:-}") || return 0
133
+ gi="$root/.multi-agent/.gitignore"
134
+ mkdir -p "$root/.multi-agent" 2>/dev/null || return 0
135
+ if [ ! -f "$gi" ] || ! grep -q '^\*$' "$gi" 2>/dev/null; then
136
+ printf '# Local run artefacts - never commit. Ignores this file too.\n*\n' > "$gi" 2>/dev/null || return 0
137
+ fi
138
+ return 0
139
+ }
140
+
141
+ # rmdir, never rm -rf: it fails harmlessly when anything is left, which is the
142
+ # behaviour we want. An empty .worktrees/ and .pipeline/evidence/ survive a
143
+ # clean run today and read as leftovers to anyone looking at the checkout.
144
+ ma_hygiene_prune_empty() {
145
+ local root d
146
+ root=$(ma_hygiene_safe_root "${1:-}") || return 0
147
+ for d in "$root/.pipeline/evidence" "$root/.pipeline" "$root/.worktrees/.archive" "$root/.worktrees" "$root/.multi-agent/refs"; do
148
+ [ -d "$d" ] && rmdir "$d" 2>/dev/null
149
+ done
150
+
151
+ # .multi-agent survives the loop above because it still holds the .gitignore
152
+ # this library wrote. That file is ours, so a directory holding nothing else
153
+ # is residue, not user data - but `memory/` there IS user data and is never
154
+ # touched (see commands/multi-agent/uninstall/SKILL.md).
155
+ if [ -d "$root/.multi-agent" ]; then
156
+ local leftover
157
+ leftover=$(ls -A "$root/.multi-agent" 2>/dev/null)
158
+ if [ "$leftover" = ".gitignore" ]; then
159
+ rm -f "$root/.multi-agent/.gitignore" 2>/dev/null
160
+ rmdir "$root/.multi-agent" 2>/dev/null
161
+ fi
162
+ fi
163
+ return 0
164
+ }
@@ -23,7 +23,8 @@
23
23
  # pipeline/lib/vercel-deploy.sh doctor # env + CLI check
24
24
  #
25
25
  # Environment:
26
- # VERCEL_TOKEN - required for deploy (resolved from prefs keychainMapping if unset)
26
+ # VERCEL_TOKEN - required for deploy; when unset it is resolved from
27
+ # prefs keychainMapping.vercel via credential-store.sh
27
28
  # VERCEL_ORG_ID - optional, scopes deploys to a team
28
29
  # VERCEL_PROJECT_ID - optional, links to a specific project
29
30
  # VERCEL_DEPLOY_QUIET - when set, suppresses progress lines (raw CLI output stays redacted)
@@ -63,8 +64,44 @@ cmd_redact() {
63
64
  redact_filter
64
65
  }
65
66
 
67
+ # Resolve VERCEL_TOKEN from the credential store when the environment has none.
68
+ # The header has promised "resolved from prefs keychainMapping if unset" since
69
+ # this wrapper was written, but nothing ever read the store: the block below
70
+ # sourced the resolver only to name it in an error message. Both `doctor` and
71
+ # `deploy` call this now, so the diagnostic can no longer report "unset" for a
72
+ # token a deploy would have found.
73
+ #
74
+ # credential-store.sh takes the LOGICAL key and maps it through
75
+ # prefs.global.keychainMapping itself, so `get vercel` is the whole mechanism.
76
+ # Command substitution, never argv: the CLI echoes its own arguments on retry.
77
+ ma_vercel_resolve_token() {
78
+ [ -z "${VERCEL_TOKEN:-}" ] || return 0
79
+ local _r
80
+ for _r in \
81
+ "$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")" && pwd)/credential-store-resolver.sh" \
82
+ "$HOME/.claude/lib/credential-store-resolver.sh" \
83
+ "$HOME/.copilot/lib/credential-store-resolver.sh" \
84
+ "$HOME/.codex/lib/credential-store-resolver.sh"; do
85
+ [ -f "$_r" ] || continue
86
+ # shellcheck source=/dev/null
87
+ . "$_r" 2>/dev/null || true
88
+ if [ -n "${CRED_STORE:-}" ]; then break; fi
89
+ done
90
+ [ -n "${CRED_STORE:-}" ] || return 0
91
+ local _tok
92
+ _tok=$("$CRED_STORE" get vercel 2>/dev/null || true)
93
+ # Only export a real value. Exporting an empty VERCEL_TOKEN would hand every
94
+ # child process a defined-but-blank variable where it previously saw none,
95
+ # and "set to nothing" reads differently from "unset" to the Vercel CLI.
96
+ [ -n "$_tok" ] || return 0
97
+ VERCEL_TOKEN="$_tok"
98
+ export VERCEL_TOKEN
99
+ return 0
100
+ }
101
+
66
102
  cmd_doctor() {
67
103
  local rc=0
104
+ ma_vercel_resolve_token
68
105
  if command -v vercel >/dev/null 2>&1; then
69
106
  echo "vercel: $(vercel --version 2>&1 | redact_filter | head -1)"
70
107
  else
@@ -109,28 +146,10 @@ cmd_deploy() {
109
146
  exit 1
110
147
  fi
111
148
 
149
+ ma_vercel_resolve_token
112
150
  if [ -z "${VERCEL_TOKEN:-}" ]; then
113
- # Route the credential-helper hint through the resolver instead of a
114
- # hardcoded ~/.claude path so Copilot-only installs get a usable command.
115
- # The resolver ships alongside this wrapper in lib/; fall back to the
116
- # per-CLI install locations when running from a different cwd layout.
117
- # shellcheck source=/dev/null
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
133
- echo "ERROR: VERCEL_TOKEN not set. Resolve via env or keychain ('${CRED_STORE:-credential-store.sh} get <vercel-key>')." >&2
151
+ echo "ERROR: VERCEL_TOKEN not set and keychainMapping.vercel resolved nothing." >&2
152
+ echo "Map it once: point prefs.global.keychainMapping.vercel at the keychain item holding the token." >&2
134
153
  echo "Hint: vercel CLI accepts the token via env var; do NOT pass --token= on argv (leaks on retry)." >&2
135
154
  exit 1
136
155
  fi