@mmerterden/multi-agent-pipeline 15.6.0 → 15.7.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 CHANGED
@@ -16,6 +16,31 @@ Internal file-layout changes that don't affect the slash-command surface are sti
16
16
 
17
17
  ## [Unreleased]
18
18
 
19
+ ## [15.7.0] - 2026-08-19
20
+
21
+ ### Added
22
+ - **Setup walks missing credentials one by one**: Step 3 gained a strict sequential onboarding loop - fixed service order, one Token Save Flow prompt per service (token -> author -> host), per-service skips that never abort the loop, and `figma` / `figma_mcp` as distinct passes. The discovery summary can no longer end setup.
23
+ - **Per-repo Jira project keys in setup**: the Jira pass closes with an optional multi-select mapping (repo -> project key) written to `prefs.projects[{slug}].jiraProjectKeys`; per-repo keys resolve before `global.defaultJiraKey` everywhere a key is needed. Re-open with `/multi-agent:setup jira-keys`.
24
+ - **Figma MCP mode question**: the `figma_mcp` pass opens with Remote / Local. Remote generates the `figu_` OAuth token for the current user (Dynamic Client Registration + PKCE; `prefs.global.figmaMcp.remoteGeneratorScript` drives the flow when set); Local wires the PAT-based `@anthropic-ai/figma-mcp` server (`localGeneratorScript` supported). Prefs schema: new `global.figmaMcp` block plus `supabase_access` / `supabase_service_role` keychainMapping keys.
25
+
26
+ ### Changed
27
+ - **Help spells out the --dev pipeline**: the Pipeline section now carries the dev chain (Phases 1-2 skipped, Review never skipped, Opus dev). Stale notes claiming Test or Review are skipped in --dev were corrected in both languages.
28
+ - **Setup Step 5 (Repo Discovery) split to a reference**: the full contract moved to `multi-agent-refs/setup/repo-discovery.md`; the SKILL keeps the opt-in prompt and a summary.
29
+
30
+ ### Fixed
31
+ - **build-stack-plugins.mjs dead import**: unused `APPLE_ONLY` import removed (superseded by the `STACK_ONLY` routing table).
32
+
33
+ ## [15.6.1] - 2026-08-19
34
+
35
+ ### Changed
36
+ - **`/multi-agent:update` installs from npm, not from a git clone**: the registry is the single update channel - latest published release resolved with a direct registry read (never `npm view`'s cache), downloaded via `npm pack` with the registry pinned, installed with `install.js --all`, changes rendered from the packaged CHANGELOG, smokes run from the tarball. A pipeline repo clone is now purely a maintainer workspace (synced by `/multi-agent:sync`); consumers need no git access at all, so collaborator grants on the private repo can stay read-only or be dropped.
37
+
38
+ ### Fixed
39
+ - **`node --test` runs stop pinging the live dashboard**: the tracker-entities suite calls `phase-tracker.sh init` outside run-smokes' `MULTI_AGENT_SMOKE` guard, so every test run left a phantom "probe" row on the timeline. The suite now sets the flag itself, and `usage-report.mjs` refuses to emit under it as the last line of defense for any caller.
40
+ - **Usage report reads the tracker as it is actually written**: `tracker-state.json` stores `phases` as an array, but the reporter iterated it with `Object.entries`, so dashboard phase ids were array indexes - every phase after a skipped one was mislabeled (Commit id "6" reported as Faz 5). Failed-phase error tags carried the same wrong ids.
41
+ - **Run duration and terminal timestamp resolve from the tracker**: nothing stamps `state.finishedAt`, so every run reported `du=null` and a terminal emit was stamped with the reporter's wall clock (wrong for backfills). Both now fall back to the tracker's phase span (earliest start to latest completion).
42
+ - **Version and user fields stop reporting null**: the reporter reads the installer's `~/.claude/.pipeline-version` marker (installed trees have no adjacent `package.json`) and falls back to `identity.name` when `identity.username` is absent.
43
+
19
44
  ## [15.6.0] - 2026-08-18
20
45
 
21
46
  ### Fixed
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mmerterden/multi-agent-pipeline",
3
- "version": "15.6.0",
3
+ "version": "15.7.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",
@@ -68,6 +68,10 @@ Pipeline (after Phase 0) - shown as visual cards in terminal:
68
68
 
69
69
  Autopilot always pauses at the Phase 7 channels menu (30-min timeout → session ends cleanly).
70
70
 
71
+ --dev pipeline (Phases 1-2 skipped; Review is NEVER skipped):
72
+ Init (same 8 steps), Dev with Opus, Review (same gates + triage),
73
+ Test (skipped in autopilot / --local), Commit + Report (menu still pauses).
74
+
71
75
  Every step is logged. Error in any phase -> pause -> resume to continue.
72
76
 
73
77
  ------------------------------------------------------------
@@ -80,7 +84,7 @@ Modes:
80
84
  autopilot Skip all confirmations INCLUDING plan gate, auto commit/PR
81
85
  (EXCEPT Phase 7 channels menu - always pauses)
82
86
  --dev autopilot Fastest path
83
- --local --dev Local + fast (no worktree, no analysis/review, no plan gate)
87
+ --local --dev Local + fast (no worktree, no analysis/plan gate - review still runs)
84
88
 
85
89
  Dedicated commands (same as flags above):
86
90
 
@@ -213,8 +217,7 @@ Design Check (mock-mode vs Figma, local-only - powered by dev-toolkit-mcp desi
213
217
  # "Requires a scenario / prefix / launch-arg" is not a reason; reaching those states is the run's job.
214
218
  # When a live screen has no confident Figma match, it SHOWS you the screenshot and asks for the node-id.
215
219
 
216
- dev-toolkit-mcp design_* tools it drives: design_mock_detect, design_scenario_inventory, design_mock_launch,
217
- design_ui_geometry, design_visual_compare, design_report (+ ios_* / android_* device control).
220
+ Drives the dev-toolkit-mcp design_* tool suite (+ ios_* / android_* device control).
218
221
 
219
222
  ------------------------------------------------------------
220
223
 
@@ -330,13 +333,18 @@ Pipeline (Phase 0'dan sonra) - terminalde görsel kart olarak görünür:
330
333
  Phase 3: Dev -> TDD: test -> kod -> build (Sonnet) + build queue
331
334
  Phase 4: Review -> Deterministik kapılar + paralel AI review + Fable triage
332
335
  (Claude Code: Fable + Sonnet · Copilot CLI: GPT-5.4 + Opus + Sonnet)
333
- Phase 5: Test -> Opsiyonel: branch'e geç, Xcode'da test (--dev / autopilot'ta skip)
336
+ Phase 5: Test -> Opsiyonel: branch'e geç, Xcode'da test
337
+ (dev + full'de koşar; her autopilot ve local varyantında skip)
334
338
  Phase 6: Commit -> Commit -> push -> PR + issue body güncelleme (hiç auto-close yok)
335
339
  Phase 7: Report -> Channels dispatcher (PR · Jira · Confluence · Wiki, multi-select)
336
340
  + internal capture (agent-log · knowledge · memory)
337
341
 
338
342
  Autopilot Phase 7'deki channels menüsünde HER ZAMAN durur (30 dk timeout → session temiz biter).
339
343
 
344
+ --dev pipeline'ı (Faz 1-2 atlanır; Review ASLA atlanmaz):
345
+ Init (aynı 8 adım), Dev Opus ile, Review (aynı kapılar + triage),
346
+ Test (autopilot / --local'de atlanır), Commit + Report (menü yine durur).
347
+
340
348
  Her adım loglanır. Herhangi bir fazdaki hata -> pause -> resume ile devam et.
341
349
 
342
350
  ------------------------------------------------------------
@@ -349,7 +357,7 @@ Modlar:
349
357
  autopilot Plan gate dahil tüm onayları atla, otomatik commit/PR
350
358
  (İSTİSNA: Phase 7 channels menüsü - her zaman durur)
351
359
  --dev autopilot En hızlı yol
352
- --local --dev Local + hızlı (worktree yok, analiz/review yok, plan gate yok)
360
+ --local --dev Local + hızlı (worktree yok, analiz/plan gate yok - review yine koşar)
353
361
 
354
362
  Dedicated komutlar (yukarıdaki flag'lerin eşdeğeri):
355
363
 
@@ -483,8 +491,7 @@ Design Check (mock-mod vs Figma, yalnızca lokal - dev-toolkit-mcp design_* to
483
491
  # "Senaryo / prefix / launch-arg gerektirir" gerekçe değildir; o state'lere ulaşmak koşunun işidir.
484
492
  # Canlı ekranın Figma karşılığı kesin bulunamazsa SANA screenshot'ı gösterip node-id sorar.
485
493
 
486
- Sürdüğü dev-toolkit-mcp design_* tool'ları: design_mock_detect, design_scenario_inventory, design_mock_launch,
487
- design_ui_geometry, design_visual_compare, design_report (+ ios_* / android_* cihaz kontrolü).
494
+ dev-toolkit-mcp design_* tool takımını sürer (+ ios_* / android_* cihaz kontrolü).
488
495
 
489
496
  ------------------------------------------------------------
490
497
 
@@ -247,7 +247,7 @@ Security notes to surface to the user: the token is **write-only** (append-only
247
247
 
248
248
  ### Step 3 - Interactive token onboarding (for missing tokens)
249
249
 
250
- Git identities are NOT asked as a separate step. They are learned when tokens are added in Step 4 (Token Save Flow). Each token save triggers an identity prompt:
250
+ Git identities are NOT asked as a separate step. They are learned when tokens are added via the Token Save Flow below. Each token save triggers an identity prompt:
251
251
 
252
252
  ```
253
253
  Token saved. Which git identity uses this token?
@@ -269,6 +269,30 @@ Token saved. Which git identity uses this token?
269
269
 
270
270
  For each MISSING service, run the **Token Save Flow** below. When creating new tokens, use the **standard key name** from Step 1b table.
271
271
 
272
+ #### Sequential onboarding loop (strict)
273
+
274
+ The Step 2 discovery summary is never the end of setup. Immediately after showing it, walk the missing services ONE BY ONE in this fixed order and run the Token Save Flow for each:
275
+
276
+ 1. `jira`
277
+ 2. `bitbucket_token` (then `bitbucket_user` right after, if missing)
278
+ 3. `github`
279
+ 4. `confluence`
280
+ 5. `figma` - Figma Personal Access Token (Tier 2, REST)
281
+ 6. `figma_mcp` - Figma MCP auth (Tier 1); its own prompt, never folded into the PAT prompt
282
+ 7. `fortify`
283
+ 8. `graylog`
284
+ 9. `firebase`
285
+ 10. `jenkins`
286
+ 11. App Store Connect block (Step 3b; only when the target repo is iOS)
287
+
288
+ Loop rules:
289
+
290
+ - **One prompt per service.** Never collapse the list into a single "add missing tokens now?" yes/no, and never offer one multi-select covering all services: each service has its own source URL, key name, identity binding and host prompt, so each gets its own Token Save Flow pass.
291
+ - **Each service pass asks, in order: token → author → host.** Step A/B collect the secret, Step C binds the git identity (author) - Bitbucket and GitHub included; every saved token gets an author - and Step 3.5 asks the host domain inline for hosted services (jira, confluence, bitbucket, fortify, graylog). GitHub and Figma skip the host question (fixed domains); `bitbucket_user` skips author binding (username, not a PAT). Never end a service pass with the token saved but the author or a required host unasked.
292
+ - **Skip is per-service.** A Skip answer leaves that service's `keychainMapping` at `null` and the loop continues with the next service. Skips never abort the loop.
293
+ - **`figma` and `figma_mcp` are distinct credentials for distinct tiers - ask both.** Skipping the PAT does not imply skipping MCP, and vice versa. The `figma_mcp` pass opens with a Remote / Local mode question instead of a clipboard paste (see "Figma MCP Setup" below): Remote generates a `figu_` OAuth token for the current user, Local wires the PAT-based local server.
294
+ - The loop ends only when every missing service has been asked exactly once; then continue to Step 4 (Verify all).
295
+
272
296
  ---
273
297
 
274
298
  ### Token Save Flow (reusable)
@@ -424,6 +448,23 @@ Default Jira project key (e.g. PROJ): ___
424
448
 
425
449
  Save to `prefs.global.defaultJiraKey`. From Jira host, derive `corpDomain` (e.g. `jira.example.com` → `example.com`) and pre-fill it so the Confluence / Bitbucket prompts only need the subdomain.
426
450
 
451
+ `defaultJiraKey` stays a single global fallback, but one Jira host usually serves several repos with DIFFERENT project keys, so the Jira pass continues with a per-repo key mapping (multi-select, optional):
452
+
453
+ ```
454
+ Per-repo Jira keys (optional) - repos whose project key differs from {defaultJiraKey}:
455
+
456
+ [ ] 1. my-ios-app (current: PROJA)
457
+ [ ] 2. my-other-app (current: - )
458
+ ...
459
+
460
+ Toggle repos, then enter the project key for each selected repo. Enter to skip.
461
+ ```
462
+
463
+ - The repo list comes from `prefs.projects` + `recentProjects` (when Step 5 already ran); a free-text repo path is accepted for repos not discovered yet.
464
+ - Each answer is PREPENDED to `prefs.projects[{slug}].jiraProjectKeys` (deduped, max 10 per schema). Example: `my-ios-app` → `PROJA`, `my-other-app` → `PROJB` - two repos on the same Jira host, two different project keys.
465
+ - Resolution order everywhere a Jira key is needed (placeholder replacement, branch names, new-issue creation): `prefs.projects[{slug}].jiraProjectKeys[0]` first, `global.defaultJiraKey` as fallback.
466
+ - **Re-run / update**: `/multi-agent:setup jira-keys` re-opens only this mapping without touching tokens or hosts.
467
+
427
468
  Resulting shape:
428
469
 
429
470
  ```json
@@ -512,11 +553,8 @@ If still missing, show which ones and let user skip (pipeline will re-ask for an
512
553
 
513
554
  ### Step 5 - Repo Discovery (optional)
514
555
 
515
- Pre-populates the Phase 0 project picker. Without this, every task types the repo path by hand; with this, the picker shows a curated list of repos the user has been active in.
516
-
517
- Three sources merge into one flat list. Each source is independent - any one failing does NOT block the others.
556
+ Pre-populates the Phase 0 project picker with repos the user is active in. Three independent sources merge into one flat list: local `$HOME` scan (depth 3, repo markers), Bitbucket (last 90 days, token-gated), GitHub (owner + collaborator, push in last 90 days, token-gated). Any source failing skips that source only, is reported (never swallowed), and discovery is never destructive and idempotent.
518
557
 
519
- **Prompt:**
520
558
  ```
521
559
  Step 5 - Repo Discovery (optional)
522
560
  Scan for recent repos now to pre-populate the Phase 0 picker?
@@ -526,103 +564,7 @@ Scan for recent repos now to pre-populate the Phase 0 picker?
526
564
  n - skip (Phase 0 will prompt for repo path manually)
527
565
  ```
528
566
 
529
- **7a. Local scan** - `$HOME` depth-3, repo markers
530
-
531
- ```bash
532
- find "$HOME" -maxdepth 3 -type d -name ".git" 2>/dev/null | while read gitdir; do
533
- repo_dir="$(dirname "$gitdir")"
534
- origin="$(git -C "$repo_dir" config --get remote.origin.url 2>/dev/null)"
535
- last="$(git -C "$repo_dir" log -1 --format=%cI 2>/dev/null)"
536
- printf '%s\t%s\t%s\n' "$repo_dir" "$origin" "$last"
537
- done
538
- ```
539
-
540
- Auxiliary markers (recorded but not required): `*.xcworkspace`, `*.xcodeproj`, `Podfile`, `Package.swift`, `package.json`, `pom.xml`, `build.gradle`. Their presence tags the repo as iOS/macOS, Cocoapods, SPM, Node, Maven, Gradle in the picker.
541
-
542
- **7b. Bitbucket scan** - last 90 days active
543
-
544
- Runs only if `keychainMapping.bitbucket_token` is non-null AND `serviceStatus.bitbucket.ok === true` (or cache older than `settings.serviceStatusCacheSeconds`, re-ping first).
545
-
546
- Bitbucket Data Center (self-hosted) - the host comes from `prefs.global.bitbucketHost` (or prompts once if unset; NEVER hardcoded):
547
- ```bash
548
- BB_HOST="$(jq -r '.global.bitbucketHost // empty' "$PREFS_FILE")"
549
- BB_USER="$(~/.claude/lib/credential-store.sh get "$(jq -r '.global.keychainMapping.bitbucket_user' "$PREFS_FILE")" 2>/dev/null)"
550
- BB_TOK="$(~/.claude/lib/credential-store.sh get "$(jq -r '.global.keychainMapping.bitbucket_token' "$PREFS_FILE")" 2>/dev/null)"
551
- CUTOFF_MS=$(( ( $(date +%s) - 7776000 ) * 1000 )) # 90 days
552
- curl -sfS -u "$BB_USER:$BB_TOK" \
553
- "https://${BB_HOST}/rest/api/1.0/repos?permission=REPO_WRITE&limit=200" \
554
- | jq -r --argjson cutoff "$CUTOFF_MS" '
555
- .values[] | select((.updatedDate // 0) >= $cutoff) |
556
- { slug: "\(.project.key)/\(.slug)", clone: (.links.clone[] | select(.name=="http") | .href) }'
557
- ```
558
-
559
- Bitbucket Cloud - swap to `https://api.bitbucket.org/2.0/repositories/{workspace}?role=member&sort=-updated_on`.
560
-
561
- **7c. GitHub scan** - owner + collaborator, push in last 90 days
562
-
563
- Runs only if `keychainMapping.github` is non-null AND `serviceStatus.github.ok === true`.
564
-
565
- ```bash
566
- GH_TOK="$(~/.claude/lib/credential-store.sh get "$(jq -r '.global.keychainMapping.github' "$PREFS_FILE")" 2>/dev/null)"
567
- CUTOFF_ISO="$(date -u -v-90d +%Y-%m-%dT%H:%M:%SZ 2>/dev/null || date -u -d '90 days ago' +%Y-%m-%dT%H:%M:%SZ)"
568
- # gh CLI path (preferred when available)
569
- gh api --paginate 'user/repos?affiliation=owner,collaborator&sort=pushed&per_page=100' \
570
- -q ".[] | select(.pushed_at > \"$CUTOFF_ISO\") | {full: .full_name, clone: .clone_url}"
571
- # Fallback without gh:
572
- curl -sfS -H "Authorization: Bearer $GH_TOK" \
573
- 'https://api.github.com/user/repos?affiliation=owner,collaborator&sort=pushed&per_page=100'
574
- ```
575
-
576
- **7d. Merge by origin URL**
577
-
578
- Normalize clone URLs (strip `git@`, `https://`, trailing `.git`) to a canonical `host/owner/repo` key. Deduplicate:
579
- - Local + remote match on canonical key → single entry with `path = <local>`, `platform = <remote source>`.
580
- - Remote-only match → entry with `path: null` (user clones on first use from Phase 0).
581
- - Local-only (no remote clone URL matches) → kept as-is, `platform: "local"`.
582
-
583
- **7e. Multi-select picker**
584
-
585
- ```
586
- Discovered 24 active repos (local + remote ≤ 90 days):
587
-
588
- [ ] 1. Booking ~/dev/Booking bitbucket iOS · SPM
589
- [ ] 2. UIComponents ~/dev/UIComponents bitbucket iOS · SPM
590
- [ ] 3. multi-agent-pipeline ~/multi-agent-pipeline github Node
591
- [ ] 4. dotfiles (not cloned) github -
592
- [ ] 5. backend-services ~/work/backend-services bitbucket Maven
593
- ...
594
-
595
- Toggle with numbers (e.g. "1 3 4"), 'all', 'none', or Enter to skip.
596
- Selected repos become defaults in the Phase 0 project picker.
597
- ```
598
-
599
- **7f. Save to preferences**
600
-
601
- ```json
602
- {
603
- "global": {
604
- "recentProjects": [
605
- {"path": "~/dev/Booking", "label": "Booking", "lastUsed": "2026-04-15T21:08:00Z"},
606
- {"path": "~/dev/UIComponents", "label": "UIComponents", "lastUsed": "2026-04-15T21:08:00Z"},
607
- {"path": "~/multi-agent-pipeline", "label": "multi-agent-pipeline","lastUsed": "2026-04-15T21:08:00Z"}
608
- ],
609
- "serviceStatus": {
610
- "bitbucket": {"ok": true, "checkedAt": "2026-04-15T21:08:00Z"},
611
- "github": {"ok": true, "checkedAt": "2026-04-15T21:08:00Z"}
612
- }
613
- }
614
- }
615
- ```
616
-
617
- Entries are objects (not bare strings). The schema `recentProjects.items` is a `oneOf` - both shapes validate, but v2.1.0 writes objects so `lastUsed` + `label` are first-class.
618
-
619
- **7g. Skip / degrade rules**
620
-
621
- - 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.
622
- - **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".
623
- - If all three sources yield zero hits, Step 5 is a no-op - the picker in Phase 0 falls back to manual path entry.
624
- - Discovery is **never destructive**: it never modifies git state, never writes outside `PREFS_FILE`.
625
- - Step 5 is idempotent. Re-running it refreshes `recentProjects` and `serviceStatus` but preserves user-curated entries unless explicitly deselected.
567
+ The scan commands, merge-by-origin dedup, the multi-select picker, the `recentProjects` / `serviceStatus` prefs shapes and the skip / degrade rules live in `$HOME/.claude/multi-agent-refs/setup/repo-discovery.md` - read that file when the user opts in.
626
568
 
627
569
  ### Step 6 - Remember as Multi-Repo Group (optional)
628
570
 
@@ -801,23 +743,33 @@ For maximum stdin hygiene (the secret never lands on argv / shell history), use
801
743
  pbpaste | ~/.claude/scripts/keychain.py set "<SERVICE_NAME>" -
802
744
  ```
803
745
 
804
- ### Figma MCP Setup (optional)
746
+ ### Figma MCP Setup (Remote / Local question)
747
+
748
+ The `figma_mcp` pass of the Step 3 loop opens with a mode question BEFORE any token is collected:
805
749
 
806
- Figma MCP works in two modes:
750
+ ```
751
+ Figma MCP mode?
807
752
 
808
- **Remote MCP** (Claude Code built-in):
809
- - Automatic - comes as `claude_ai_Figma` in Claude Code settings.json
810
- - If token renewal needed: re-auth from within Claude Code
753
+ [1] Remote - mcp.figma.com, OAuth token (figu_) (recommended)
754
+ [2] Local - @anthropic-ai/figma-mcp via npx, uses the Figma PAT
755
+ ```
811
756
 
812
- **Local MCP** (@anthropic-ai/figma-mcp package, with PAT):
757
+ **Remote** (`figu_` OAuth token - generate it FOR the current user, never just point at docs):
758
+ - No OAuth app and no client_id/secret needed: Dynamic Client Registration against `api.figma.com/v1/oauth/mcp/register` + PKCE, the browser opens, the user clicks Allow, and the resulting access + refresh token pair is saved to the credential store under a name the user confirms. Map that entry to `keychainMapping.figma_mcp` (Token Save Flow Step D applies as usual).
759
+ - If `prefs.global.figmaMcp.remoteGeneratorScript` is set, run that script - it drives the whole flow end to end (registration, browser, keychain save) for the current user. Otherwise guide the flow inline.
760
+ - Renewal is silent afterwards via `$HOME/.claude/lib/figma-mcp-refresh.sh` (refresh token). Never echo token values.
761
+
762
+ **Local** (PAT-based):
763
+ - Validates the Figma PAT against the REST API, saves it to the credential store (reusing the `figma` mapping when present), and writes the `@anthropic-ai/figma-mcp` server block into Claude Code `settings.json` `mcpServers`:
813
764
  ```bash
814
- # 1. Save PAT to keychain (stdin sentinel keeps the value out of shell history)
765
+ # stdin sentinel keeps the value out of shell history
815
766
  pbpaste | ~/.claude/scripts/keychain.py set "${USER}_Figma_Access_Token" -
816
767
  pbcopy < /dev/null
817
-
818
- # 2. Add MCP to Claude Code settings.json
819
- # ~/.claude/settings.json -> mcpServers -> figma
768
+ # then: ~/.claude/settings.json -> mcpServers -> figma
820
769
  ```
770
+ - If `prefs.global.figmaMcp.localGeneratorScript` is set, run that script instead.
771
+
772
+ Choosing Remote does NOT skip the `figma` PAT pass (Tier 2 still uses the PAT); choosing Local leaves `keychainMapping.figma_mcp` null and Tier 1 unavailable until Remote is onboarded.
821
773
 
822
774
  ### Security Rules
823
775
 
@@ -850,7 +802,7 @@ To set up multi-agent on a new machine:
850
802
  /multi-agent "bug description"
851
803
  ```
852
804
 
853
- All tokens are optional - pipeline will ask for any needed token at Phase 0. Setup only does pre-preparation.
805
+ All tokens are optional in the sense that every service can be answered with Skip - but the ASKING is not optional: the Step 3 sequential loop still walks every missing service one by one (token author → host). Phase 0 re-asks at runtime only for tokens the user skipped here.
854
806
 
855
807
  ### Step 8 - Enforcement hook (optional, Claude Code)
856
808
 
@@ -1,43 +1,49 @@
1
1
  ---
2
- description: "Update the pipeline to the latest version: git pull, install, migrate. Use when the installed pipeline is behind and should be brought to the latest version."
3
- description-tr: "Pipeline'ı son sürüme günceller: git pull, install, migrate."
2
+ description: "Update the pipeline to the latest published npm release: registry check, npm pack, install, migrate. Use when the installed pipeline is behind and should be brought to the latest version."
3
+ description-tr: "Pipeline'ı npm'deki son yayına günceller: registry kontrolü, npm pack, install, migrate."
4
4
  allowed-tools: Bash, Read, Write, AskUserQuestion
5
5
  ---
6
6
 
7
7
  # multi-agent update
8
8
 
9
- Update the pipeline in one command. Existing preferences are preserved; only skill / script / schema files are refreshed.
9
+ Update the pipeline in one command. The npm registry is the single update channel: the latest published release is downloaded and installed. Existing preferences are preserved; only skill / script / schema files are refreshed.
10
+
11
+ A git clone of the pipeline repo is a maintainer workspace, kept in sync by `/multi-agent:sync` - it is never consulted here. A fix that only exists on `main` reaches users when a release is published, not before.
10
12
 
11
13
  ## Steps
12
14
 
13
- 1. **Find the pipeline repo**:
15
+ 1. **Resolve the package and both versions** (run steps 1-3 in ONE shell block so the variables survive):
14
16
  ```bash
15
- PIPE_DIR=""
16
- for candidate in "$HOME/multi-agent-pipeline" "$HOME/dev/multi-agent-pipeline" "$HOME/projects/multi-agent-pipeline"; do
17
- [ -d "$candidate/.git" ] && PIPE_DIR="$candidate" && break
18
- done
19
- if [ -z "$PIPE_DIR" ]; then
20
- echo "Pipeline repo not found. Clone it:"
21
- echo " git clone git@github.com:{owner}/multi-agent-pipeline.git"
17
+ PKG="@{npm-scope}/multi-agent-pipeline"
18
+ REG="https://registry.npmjs.org"
19
+ CUR=$(tr -d '[:space:]' < "$HOME/.claude/.pipeline-version" 2>/dev/null)
20
+ [ -n "$CUR" ] || CUR="unknown"
21
+ # Read the registry directly - `npm view` can answer from a stale local cache.
22
+ LATEST=$(curl -fsS "$REG/$(printf '%s' "$PKG" | sed 's|/|%2F|')/latest" \
23
+ | node -pe 'JSON.parse(require("fs").readFileSync(0,"utf8")).version' 2>/dev/null)
24
+ if [ -z "$LATEST" ]; then
25
+ echo "Registry unreachable ($REG) - check your network and retry."
22
26
  exit 1
23
27
  fi
28
+ echo "Current: v$CUR Latest: v$LATEST"
24
29
  ```
25
30
 
26
- 2. **Capture the current version**:
31
+ 2. **Stop early when already current** (still refresh the marketplace, step 4b):
27
32
  ```bash
28
- OLD_VERSION=$(node -p "require('$PIPE_DIR/package.json').version" 2>/dev/null || echo "unknown")
29
- echo "Current: v$OLD_VERSION"
33
+ [ "$CUR" = "$LATEST" ] && echo "✓ Already up to date: v$LATEST"
30
34
  ```
31
35
 
32
- 3. **Pull latest**:
36
+ 3. **Download the release and install**:
33
37
  ```bash
34
- cd "$PIPE_DIR" && git pull origin main
38
+ UPD_DIR="${TMPDIR:-/tmp}/multi-agent-update"
39
+ rm -rf "$UPD_DIR" && mkdir -p "$UPD_DIR"
40
+ # --registry pinned: an ambient .npmrc must not reroute the scope elsewhere.
41
+ npm pack "$PKG@$LATEST" --registry "$REG" --pack-destination "$UPD_DIR" --silent >/dev/null
42
+ tar -xzf "$UPD_DIR"/*.tgz -C "$UPD_DIR"
43
+ node "$UPD_DIR/package/install.js" --all
35
44
  ```
36
45
 
37
- 4. **Install (copy the files)**:
38
- ```bash
39
- node "$PIPE_DIR/install.js" --all
40
- ```
46
+ The installer refreshes every configured CLI target (Claude Code, Copilot CLI, Codex CLI) and writes the new version to `$HOME/.claude/.pipeline-version`.
41
47
 
42
48
  4b. **Update the stack-plugin marketplace** (pulls the latest plugin versions):
43
49
  ```bash
@@ -98,44 +104,41 @@ Update the pipeline in one command. Existing preferences are preserved; only ski
98
104
  fi
99
105
  ```
100
106
 
101
- 6. **Show the new version**:
107
+ 6. **Show the new version and its changes** (from the packaged CHANGELOG - there is no git history on this channel):
102
108
  ```bash
103
- NEW_VERSION=$(node -p "require('$PIPE_DIR/package.json').version" 2>/dev/null || echo "unknown")
109
+ NEW=$(tr -d '[:space:]' < "$HOME/.claude/.pipeline-version" 2>/dev/null)
104
110
  echo ""
105
- if [ "$OLD_VERSION" = "$NEW_VERSION" ]; then
106
- echo "✓ Already up to date: v$NEW_VERSION"
107
- else
108
- echo " Updated: v$OLD_VERSION v$NEW_VERSION"
109
- echo ""
110
- echo "Changes:"
111
- cd "$PIPE_DIR" && git log --oneline "v$OLD_VERSION..v$NEW_VERSION" 2>/dev/null | head -15
112
- fi
111
+ echo "✓ Updated: v$CUR v$NEW"
112
+ echo ""
113
+ echo "Changes:"
114
+ awk -v new="## [$NEW]" -v cur="## [$CUR]" \
115
+ 'index($0,new){on=1} on&&index($0,cur){exit} on' \
116
+ "$UPD_DIR/package/CHANGELOG.md" | head -40
113
117
  ```
114
118
 
115
- 7. **Smoke test (optional)**:
119
+ 7. **Smoke test (optional)** - the release tarball ships exactly two smokes for this purpose:
116
120
  ```bash
117
121
  echo ""
118
122
  echo "Verification:"
119
- # Repo-relative on purpose: these are maintainer smokes, excluded from installs.
120
- # Both lines name $PIPE_DIR rather than relying on the cd from the line above,
121
- # so neither can be read - or copied - as an installed-tree invocation.
122
- bash "$PIPE_DIR/pipeline/scripts/smoke-schema-validation.sh" 2>&1 | tail -1
123
- bash "$PIPE_DIR/pipeline/scripts/smoke-cross-cli-behavior.sh" 2>&1 | tail -1
123
+ bash "$UPD_DIR/package/pipeline/scripts/smoke-schema-validation.sh" 2>&1 | tail -1
124
+ bash "$UPD_DIR/package/pipeline/scripts/smoke-cross-cli-behavior.sh" 2>&1 | tail -1
125
+ rm -rf "$UPD_DIR"
124
126
  ```
125
127
 
126
128
  ## Output
127
129
 
128
130
  ```
129
- Current: v14.2.2
130
- -> git pull origin main
131
+ Current: v15.6.0 Latest: v15.6.1
132
+ -> npm pack @{npm-scope}/multi-agent-pipeline@15.6.1
131
133
  -> node install.js --all (51 commands, 228 scripts, 205 skills)
132
134
  -> migrate-prefs.mjs (0 changes - already v2.6.0)
133
135
 
134
- ✓ Updated: v14.2.2 → v15.0.0
136
+ ✓ Updated: v15.6.0 → v15.6.1
135
137
 
136
138
  Changes:
137
- e3f883e fix(dev): Phase 6 must show local test prompt
138
- 59617aa fix(setup): first-run guard
139
+ ## [15.6.1] - 2026-08-19
140
+ ### Fixed
141
+ - Usage report reads the tracker as it is actually written
139
142
  ...
140
143
 
141
144
  Verification:
@@ -0,0 +1,116 @@
1
+ # Setup Step 5 - Repo Discovery (full contract)
2
+
3
+ > Split out of `setup/SKILL.md` (token budget). The SKILL keeps the opt-in prompt and a summary; this file carries the scan commands, merge rules, picker, prefs shapes and skip / degrade rules. Read it when the user opts into repo discovery.
4
+
5
+ Pre-populates the Phase 0 project picker. Without this, every task types the repo path by hand; with this, the picker shows a curated list of repos the user has been active in.
6
+
7
+ Three sources merge into one flat list. Each source is independent - any one failing does NOT block the others.
8
+
9
+ **Prompt:**
10
+ ```
11
+ Step 5 - Repo Discovery (optional)
12
+ Scan for recent repos now to pre-populate the Phase 0 picker?
13
+
14
+ y - all three (local + Bitbucket + GitHub, ~10-30s)
15
+ l - local scan only (fastest, no network)
16
+ n - skip (Phase 0 will prompt for repo path manually)
17
+ ```
18
+
19
+ **7a. Local scan** - `$HOME` depth-3, repo markers
20
+
21
+ ```bash
22
+ find "$HOME" -maxdepth 3 -type d -name ".git" 2>/dev/null | while read gitdir; do
23
+ repo_dir="$(dirname "$gitdir")"
24
+ origin="$(git -C "$repo_dir" config --get remote.origin.url 2>/dev/null)"
25
+ last="$(git -C "$repo_dir" log -1 --format=%cI 2>/dev/null)"
26
+ printf '%s\t%s\t%s\n' "$repo_dir" "$origin" "$last"
27
+ done
28
+ ```
29
+
30
+ Auxiliary markers (recorded but not required): `*.xcworkspace`, `*.xcodeproj`, `Podfile`, `Package.swift`, `package.json`, `pom.xml`, `build.gradle`. Their presence tags the repo as iOS/macOS, Cocoapods, SPM, Node, Maven, Gradle in the picker.
31
+
32
+ **7b. Bitbucket scan** - last 90 days active
33
+
34
+ Runs only if `keychainMapping.bitbucket_token` is non-null AND `serviceStatus.bitbucket.ok === true` (or cache older than `settings.serviceStatusCacheSeconds`, re-ping first).
35
+
36
+ Bitbucket Data Center (self-hosted) - the host comes from `prefs.global.bitbucketHost` (or prompts once if unset; NEVER hardcoded):
37
+ ```bash
38
+ BB_HOST="$(jq -r '.global.bitbucketHost // empty' "$PREFS_FILE")"
39
+ BB_USER="$(~/.claude/lib/credential-store.sh get "$(jq -r '.global.keychainMapping.bitbucket_user' "$PREFS_FILE")" 2>/dev/null)"
40
+ BB_TOK="$(~/.claude/lib/credential-store.sh get "$(jq -r '.global.keychainMapping.bitbucket_token' "$PREFS_FILE")" 2>/dev/null)"
41
+ CUTOFF_MS=$(( ( $(date +%s) - 7776000 ) * 1000 )) # 90 days
42
+ curl -sfS -u "$BB_USER:$BB_TOK" \
43
+ "https://${BB_HOST}/rest/api/1.0/repos?permission=REPO_WRITE&limit=200" \
44
+ | jq -r --argjson cutoff "$CUTOFF_MS" '
45
+ .values[] | select((.updatedDate // 0) >= $cutoff) |
46
+ { slug: "\(.project.key)/\(.slug)", clone: (.links.clone[] | select(.name=="http") | .href) }'
47
+ ```
48
+
49
+ Bitbucket Cloud - swap to `https://api.bitbucket.org/2.0/repositories/{workspace}?role=member&sort=-updated_on`.
50
+
51
+ **7c. GitHub scan** - owner + collaborator, push in last 90 days
52
+
53
+ Runs only if `keychainMapping.github` is non-null AND `serviceStatus.github.ok === true`.
54
+
55
+ ```bash
56
+ GH_TOK="$(~/.claude/lib/credential-store.sh get "$(jq -r '.global.keychainMapping.github' "$PREFS_FILE")" 2>/dev/null)"
57
+ CUTOFF_ISO="$(date -u -v-90d +%Y-%m-%dT%H:%M:%SZ 2>/dev/null || date -u -d '90 days ago' +%Y-%m-%dT%H:%M:%SZ)"
58
+ # gh CLI path (preferred when available)
59
+ gh api --paginate 'user/repos?affiliation=owner,collaborator&sort=pushed&per_page=100' \
60
+ -q ".[] | select(.pushed_at > \"$CUTOFF_ISO\") | {full: .full_name, clone: .clone_url}"
61
+ # Fallback without gh:
62
+ curl -sfS -H "Authorization: Bearer $GH_TOK" \
63
+ 'https://api.github.com/user/repos?affiliation=owner,collaborator&sort=pushed&per_page=100'
64
+ ```
65
+
66
+ **7d. Merge by origin URL**
67
+
68
+ Normalize clone URLs (strip `git@`, `https://`, trailing `.git`) to a canonical `host/owner/repo` key. Deduplicate:
69
+ - Local + remote match on canonical key → single entry with `path = <local>`, `platform = <remote source>`.
70
+ - Remote-only match → entry with `path: null` (user clones on first use from Phase 0).
71
+ - Local-only (no remote clone URL matches) → kept as-is, `platform: "local"`.
72
+
73
+ **7e. Multi-select picker**
74
+
75
+ ```
76
+ Discovered 24 active repos (local + remote ≤ 90 days):
77
+
78
+ [ ] 1. Booking ~/dev/Booking bitbucket iOS · SPM
79
+ [ ] 2. UIComponents ~/dev/UIComponents bitbucket iOS · SPM
80
+ [ ] 3. multi-agent-pipeline ~/multi-agent-pipeline github Node
81
+ [ ] 4. dotfiles (not cloned) github -
82
+ [ ] 5. backend-services ~/work/backend-services bitbucket Maven
83
+ ...
84
+
85
+ Toggle with numbers (e.g. "1 3 4"), 'all', 'none', or Enter to skip.
86
+ Selected repos become defaults in the Phase 0 project picker.
87
+ ```
88
+
89
+ **7f. Save to preferences**
90
+
91
+ ```json
92
+ {
93
+ "global": {
94
+ "recentProjects": [
95
+ {"path": "~/dev/Booking", "label": "Booking", "lastUsed": "2026-04-15T21:08:00Z"},
96
+ {"path": "~/dev/UIComponents", "label": "UIComponents", "lastUsed": "2026-04-15T21:08:00Z"},
97
+ {"path": "~/multi-agent-pipeline", "label": "multi-agent-pipeline","lastUsed": "2026-04-15T21:08:00Z"}
98
+ ],
99
+ "serviceStatus": {
100
+ "bitbucket": {"ok": true, "checkedAt": "2026-04-15T21:08:00Z"},
101
+ "github": {"ok": true, "checkedAt": "2026-04-15T21:08:00Z"}
102
+ }
103
+ }
104
+ }
105
+ ```
106
+
107
+ Entries are objects (not bare strings). The schema `recentProjects.items` is a `oneOf` - both shapes validate, but v2.1.0 writes objects so `lastUsed` + `label` are first-class.
108
+
109
+ **7g. Skip / degrade rules**
110
+
111
+ - 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.
112
+ - **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".
113
+ - If all three sources yield zero hits, Step 5 is a no-op - the picker in Phase 0 falls back to manual path entry.
114
+ - Discovery is **never destructive**: it never modifies git state, never writes outside `PREFS_FILE`.
115
+ - Step 5 is idempotent. Re-running it refreshes `recentProjects` and `serviceStatus` but preserves user-curated entries unless explicitly deselected.
116
+
@@ -123,7 +123,7 @@
123
123
  "keychainMapping": {
124
124
  "type": "object",
125
125
  "additionalProperties": false,
126
- "description": "Maps logical token names to actual Keychain item names. Pipeline looks up tokens via `security find-generic-password -s <mapped-name>` (or `pipeline/lib/credential-store.sh get` cross-platform). Covered keys: jira, bitbucket, bitbucket_token, bitbucket_user, github, confluence, figma, figma_mcp, fortify, graylog, firebase, jenkins, npm, claude_oauth_token, claude_oauth_token_fallback. Deprecated and accepted only for backward compatibility: figma_pat (superseded by figma), figma_user (unused).",
126
+ "description": "Maps logical token names to actual Keychain item names. Pipeline looks up tokens via `security find-generic-password -s <mapped-name>` (or `pipeline/lib/credential-store.sh get` cross-platform). Covered keys: jira, bitbucket, bitbucket_token, bitbucket_user, github, confluence, figma, figma_mcp, fortify, graylog, firebase, jenkins, npm, claude_oauth_token, claude_oauth_token_fallback, supabase_access, supabase_service_role. Deprecated and accepted only for backward compatibility: figma_pat (superseded by figma), figma_user (unused).",
127
127
  "properties": {
128
128
  "jira": {
129
129
  "type": [
@@ -188,6 +188,20 @@
188
188
  "null"
189
189
  ]
190
190
  },
191
+ "supabase_access": {
192
+ "type": [
193
+ "string",
194
+ "null"
195
+ ],
196
+ "description": "Supabase personal access token (Management API). Used by personal-site tooling; not consumed by pipeline phases."
197
+ },
198
+ "supabase_service_role": {
199
+ "type": [
200
+ "string",
201
+ "null"
202
+ ],
203
+ "description": "Supabase service_role key for the personal-site project. Server-side only; never shipped to a client."
204
+ },
191
205
  "usage_ingest": {
192
206
  "type": [
193
207
  "string",
@@ -621,6 +635,27 @@
621
635
  ],
622
636
  "description": "Default Jira project key used for placeholder replacement. null until /multi-agent:setup onboards a Jira project."
623
637
  },
638
+ "figmaMcp": {
639
+ "type": "object",
640
+ "additionalProperties": false,
641
+ "properties": {
642
+ "remoteGeneratorScript": {
643
+ "type": [
644
+ "string",
645
+ "null"
646
+ ],
647
+ "description": "Local-only path to a script that generates the remote MCP OAuth token (figu_) via Dynamic Client Registration + PKCE and saves it to the credential store. Run by the setup figma_mcp pass when the user picks Remote. Personal paths stay in prefs; never embedded in synced files."
648
+ },
649
+ "localGeneratorScript": {
650
+ "type": [
651
+ "string",
652
+ "null"
653
+ ],
654
+ "description": "Local-only path to a script that validates the Figma PAT and wires the PAT-based local MCP server (@anthropic-ai/figma-mcp) into Claude Code settings.json. Run by the setup figma_mcp pass when the user picks Local."
655
+ }
656
+ },
657
+ "description": "Figma MCP onboarding helpers for /multi-agent:setup. Both fields optional; when unset the setup flow guides the token generation inline."
658
+ },
624
659
  "modelFallback": {
625
660
  "type": "object",
626
661
  "additionalProperties": false,
@@ -55,7 +55,6 @@ const DRY = args.includes("--dry-run");
55
55
  import {
56
56
  COMMON_PLUGIN,
57
57
  COMMON_SKILLS,
58
- APPLE_ONLY,
59
58
  STACK_ONLY,
60
59
  STACK_PATTERNS,
61
60
  } from "./_stack-routing.mjs";
@@ -212,6 +212,12 @@ function resolveStatePath({ state, taskId }) {
212
212
  }
213
213
 
214
214
  function packageVersion() {
215
+ try {
216
+ const marker = readFileSync(join(homedir(), ".claude", ".pipeline-version"), "utf-8").trim();
217
+ if (marker) return marker;
218
+ } catch {
219
+ /* marker absent */
220
+ }
215
221
  const candidates = [
216
222
  join(__dirname, "..", "..", "package.json"),
217
223
  join(homedir(), ".claude", "multi-agent-pipeline", "package.json"),
@@ -260,10 +266,25 @@ function phaseDurationSec(p) {
260
266
  }
261
267
 
262
268
  function trackerSummary(state) {
269
+ const empty = {
270
+ tk: null,
271
+ cost: null,
272
+ phs: null,
273
+ failed: [],
274
+ models: [],
275
+ startedAt: null,
276
+ endedAt: null,
277
+ };
263
278
  const path = resolveTracker(state);
264
- if (!path) return { tk: null, cost: null, phs: null, failed: [], models: [] };
279
+ if (!path) return empty;
265
280
  const tracker = readJson(path);
266
- if (!tracker?.phases) return { tk: null, cost: null, phs: null, failed: [], models: [] };
281
+ const raw = tracker?.phases;
282
+ const list = Array.isArray(raw)
283
+ ? raw
284
+ : raw && typeof raw === "object"
285
+ ? Object.entries(raw).map(([id, p]) => ({ id, ...(p ?? {}) }))
286
+ : null;
287
+ if (!list || list.length === 0) return empty;
267
288
 
268
289
  let tin = 0;
269
290
  let tout = 0;
@@ -271,7 +292,10 @@ function trackerSummary(state) {
271
292
  const phs = [];
272
293
  const failed = [];
273
294
  const models = new Set();
274
- for (const [id, p] of Object.entries(tracker.phases)) {
295
+ let startedAt = typeof tracker?.started_at === "string" && tracker.started_at ? tracker.started_at : null;
296
+ let endedAt = null;
297
+ for (const p of list) {
298
+ const id = p?.id != null && String(p.id) !== "" ? String(p.id) : "?";
275
299
  if (p?.status === "failed") failed.push(id);
276
300
  if (p?.model) models.add(String(p.model));
277
301
  const pin = Number(p?.tokens_in || 0);
@@ -280,6 +304,10 @@ function trackerSummary(state) {
280
304
  tin += pin;
281
305
  tout += pout;
282
306
  tcache += pcache;
307
+ const ps = p?.started_at || p?.startedAt || null;
308
+ const pf = p?.completed_at || p?.finished_at || p?.finishedAt || null;
309
+ if (ps && (!startedAt || ps < startedAt)) startedAt = ps;
310
+ if (pf && (!endedAt || pf > endedAt)) endedAt = pf;
283
311
  phs.push({
284
312
  p: Number.isNaN(Number(id)) ? id : Number(id),
285
313
  st: p?.status || null,
@@ -300,6 +328,8 @@ function trackerSummary(state) {
300
328
  phs: phs.length ? phs : null,
301
329
  failed,
302
330
  models: Array.from(models).slice(0, 10),
331
+ startedAt,
332
+ endedAt,
303
333
  };
304
334
  }
305
335
 
@@ -312,11 +342,18 @@ function deriveErrors(state, failedPhases) {
312
342
  return Array.from(errs).slice(0, 20);
313
343
  }
314
344
 
315
- function durationSec(state) {
345
+ function durationSec(state, spend) {
316
346
  const s = state.startedAt ? Date.parse(state.startedAt) : NaN;
317
347
  const f = state.finishedAt ? Date.parse(state.finishedAt) : NaN;
318
- if (Number.isNaN(s) || Number.isNaN(f) || f < s) return null;
319
- return Math.round((f - s) / 1000);
348
+ if (!Number.isNaN(s) && !Number.isNaN(f) && f >= s) {
349
+ return Math.round((f - s) / 1000);
350
+ }
351
+ const ts = spend?.startedAt ? Date.parse(spend.startedAt) : NaN;
352
+ const tf = spend?.endedAt ? Date.parse(spend.endedAt) : NaN;
353
+ if (!Number.isNaN(ts) && !Number.isNaN(tf) && tf >= ts) {
354
+ return Math.round((tf - ts) / 1000);
355
+ }
356
+ return null;
320
357
  }
321
358
 
322
359
  function runId(state) {
@@ -324,8 +361,10 @@ function runId(state) {
324
361
  return base == null ? null : String(base);
325
362
  }
326
363
 
327
- function eventTimestamp(state) {
328
- return state.finishedAt || state.updatedAt || new Date().toISOString();
364
+ function eventTimestamp(state, spend) {
365
+ if (state.finishedAt) return state.finishedAt;
366
+ if (isTerminalStatus(state) && spend?.endedAt) return spend.endedAt;
367
+ return state.updatedAt || new Date().toISOString();
329
368
  }
330
369
 
331
370
  const TERMINAL_STATUSES = ["complete", "completed", "failed", "paused", "halted", "error"];
@@ -360,8 +399,8 @@ function buildEvent(state) {
360
399
  const ctx = deriveContext(state);
361
400
  return {
362
401
  id: runId(state),
363
- t: eventTimestamp(state),
364
- u: state.identity?.username || null,
402
+ t: eventTimestamp(state, spend),
403
+ u: state.identity?.username || state.identity?.name || null,
365
404
  c: commandOf(state),
366
405
  m: state.mode || null,
367
406
  ap: Boolean(state.autopilot),
@@ -376,7 +415,7 @@ function buildEvent(state) {
376
415
  ? state.reviewIterations.length
377
416
  : 0,
378
417
  pr: Boolean(state.pr && (state.pr.url || state.pr.number)),
379
- du: durationSec(state),
418
+ du: durationSec(state, spend),
380
419
  tk: spend.tk,
381
420
  cost: spend.cost,
382
421
  phs: spend.phs,
@@ -416,6 +455,7 @@ async function post(endpoint, token, event) {
416
455
 
417
456
  async function main() {
418
457
  const args = parseArgs(process.argv.slice(2));
458
+ if (process.env.MULTI_AGENT_SMOKE && !args.dryRun) return;
419
459
  const prefs = resolvePrefs();
420
460
  const token = resolveToken();
421
461
  const endpoint = prefs.endpoint || ENDPOINT_DEFAULT;