@mmerterden/multi-agent-pipeline 16.31.0 → 17.0.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 (36) hide show
  1. package/CHANGELOG.md +88 -0
  2. package/README.md +101 -101
  3. package/README.tr.md +101 -101
  4. package/docs/adr/0011-dormant-ci.md +10 -1
  5. package/docs/adr/0012-macos-only.md +98 -0
  6. package/docs/adr/README.md +1 -0
  7. package/docs/engineering.md +1 -1
  8. package/index.js +26 -0
  9. package/install/_dev-only-files.mjs +0 -1
  10. package/install/index.mjs +10 -0
  11. package/package.json +5 -3
  12. package/pipeline/commands/multi-agent/garbage-collect/SKILL.md +40 -3
  13. package/pipeline/commands/multi-agent/help/SKILL.md +2 -2
  14. package/pipeline/commands/multi-agent/setup/SKILL.md +15 -7
  15. package/pipeline/commands/multi-agent/stack/SKILL.md +31 -32
  16. package/pipeline/commands/multi-agent/status/SKILL.md +17 -1
  17. package/pipeline/commands/multi-agent/sync/SKILL.md +26 -21
  18. package/pipeline/lib/account-resolver.sh +1 -1
  19. package/pipeline/lib/stack-detect.sh +200 -0
  20. package/pipeline/multi-agent-refs/cross-cli-contract.md +29 -10
  21. package/pipeline/multi-agent-refs/features/doctor.md +15 -3
  22. package/pipeline/multi-agent-refs/features/visual-evidence.md +49 -1
  23. package/pipeline/multi-agent-refs/phases/phase-0-init.md +14 -5
  24. package/pipeline/multi-agent-refs/phases/phase-1-analysis.md +26 -12
  25. package/pipeline/multi-agent-refs/phases/phase-3-dev.md +6 -4
  26. package/pipeline/multi-agent-refs/phases/phase-7-report.md +2 -3
  27. package/pipeline/multi-agent-refs/rules.md +4 -5
  28. package/pipeline/schemas/agent-state.schema.json +99 -25
  29. package/pipeline/schemas/token-budget.json +4 -4
  30. package/pipeline/scripts/_stack-routing.mjs +91 -0
  31. package/pipeline/scripts/capture-resume.sh +76 -14
  32. package/pipeline/scripts/doctor.mjs +26 -3
  33. package/pipeline/scripts/gc-abandoned.sh +352 -0
  34. package/pipeline/scripts/keychain.py +1 -1
  35. package/pipeline/scripts/usage-report.mjs +5 -5
  36. package/pipeline/scripts/gate-linux.sh +0 -62
@@ -817,13 +817,21 @@ Stack skills ship as versioned plugins in the `{owner}/multi-agent-plugins` mark
817
817
  ```bash
818
818
  claude marketplace add {owner}/multi-agent-plugins 2>/dev/null || true
819
819
  ```
820
- 2. Detect the project stack from markers and enable the matching plugin(s) plus the two always-on plugins in the project's `.claude/settings.json` `enabledPlugins`:
821
- - `.xcodeproj` / `Package.swift` / `*.xcworkspace` → `ai-ios-toolkit`
822
- - `build.gradle` / `settings.gradle` → `ai-android-toolkit`
823
- - `package.json` with `react`/`next` → `ai-frontend-toolkit`
824
- - `requirements.txt` / `pyproject.toml` / server `package.json` → `ai-backend-toolkit`
825
- - **no clear marker → default `ai-ios-toolkit`**
826
- `ai-common-toolkit@multi-agent-plugins` and `ai-analyst-toolkit@multi-agent-plugins` are always set `true` alongside the stack plugin: neither is stack-specific.
820
+ 2. Ask the detector, then derive. No marker list here: a second list is a second
821
+ answer, and this one defaulted to `ai-ios-toolkit` when nothing matched - how
822
+ a Next.js site and several Node CLIs ended up with the iOS toolkit.
823
+
824
+ ```bash
825
+ eval "$(bash $HOME/.claude/lib/stack-detect.sh "$PROJECT_ROOT")" # MA_STACKS, MA_STACK_WHY
826
+ node -e 'import(process.env.HOME+"/.claude/scripts/_stack-routing.mjs").then(m=>
827
+ console.log(m.pluginsForStacks(process.argv[1].split(" ").filter(Boolean)).plugins.join(" ")))' "$MA_STACKS"
828
+ ```
829
+
830
+ Report `MA_STACK_WHY` with the result. `ai-common-toolkit` and
831
+ `ai-analyst-toolkit` are in every answer, the empty one included: neither is
832
+ stack-specific. **No marker matched is an answer** - those two, not a guess.
833
+ A name the marketplace does not carry is dropped with `dropped[]` naming it,
834
+ so a toolkit that was never shipped is caught before it fails to load.
827
835
  3. Report the enabled set. To change later, run `/multi-agent:stack <ios|android|web|backend|...>` in the repo. Pipeline Phase 1 auto-detects the stack for its own routing regardless of enablement.
828
836
 
829
837
  The marketplace repo name (`multi-agent-plugins`) is generic; a different org points `{owner}` at its own fork - nothing in the pipeline is coupled to a specific account.
@@ -29,17 +29,26 @@ This replaces the old `stack-swap.sh` mechanic that physically moved skill direc
29
29
 
30
30
  ## Stack → plugin map
31
31
 
32
- | Stack | Plugins enabled (all `@multi-agent-plugins`) |
33
- |---|---|
34
- | `ios` | `ai-common-toolkit`, `ai-analyst-toolkit`, `ai-ios-toolkit` |
35
- | `android` | `ai-common-toolkit`, `ai-analyst-toolkit`, `ai-android-toolkit` |
36
- | `web` / `frontend` | `ai-common-toolkit`, `ai-analyst-toolkit`, `ai-frontend-toolkit` |
37
- | `backend` | `ai-common-toolkit`, `ai-analyst-toolkit`, `ai-backend-toolkit` |
38
- | `mobile` | common + `ai-ios-toolkit` + `ai-android-toolkit` |
39
- | `fullstack` | common + `ai-frontend-toolkit` + `ai-backend-toolkit` |
40
- | `all` | common + all four stack toolkits |
41
-
42
- Multiple args union their plugin sets: `ios backend` → common + iOS + backend.
32
+ Derived, not listed. `pluginsForStacks()` in `scripts/_stack-routing.mjs` owns
33
+ it, and this file deliberately carries no copy: the mapping was written out by
34
+ hand in four places, and every copy is a chance to get the one asymmetry wrong -
35
+ the detector says `web`, the marketplace ships `ai-frontend-toolkit`.
36
+
37
+ ```bash
38
+ node -e 'import(process.env.HOME+"/.claude/scripts/_stack-routing.mjs").then(m=>{
39
+ const st=process.argv.slice(1).flatMap(a=>m.expandStackArg(a));
40
+ if(!st.length){console.error("Unknown stack. One of: "+m.KNOWN_STACKS.join(" ")+" mobile fullstack all");process.exit(1);}
41
+ const r=m.pluginsForStacks(st);
42
+ if(r.dropped.length) console.error("not in the marketplace, dropped: "+r.dropped.join(" "));
43
+ console.log(r.plugins.join(" "));
44
+ })' "$@"
45
+ ```
46
+
47
+ `ai-common-toolkit` and `ai-analyst-toolkit` are in every answer, including the
48
+ empty one: neither is stack-specific. Multiple args union their sets, and the
49
+ aliases (`frontend`, `mobile`, `fullstack`, `all`) expand in the module so
50
+ `/multi-agent:stack mobile` cannot mean one thing here and another in the
51
+ installer.
43
52
 
44
53
  ## Behaviour
45
54
 
@@ -70,13 +79,6 @@ if ! claude marketplace list 2>/dev/null | grep -q "$MP"; then
70
79
  || echo "note: add the marketplace once with: claude marketplace add {owner}/multi-agent-plugins"
71
80
  fi
72
81
 
73
- COMMON="ai-common-toolkit@${MP}"
74
- ANALYST="ai-analyst-toolkit@${MP}"
75
- IOS="ai-ios-toolkit@${MP}"
76
- ANDROID="ai-android-toolkit@${MP}"
77
- WEB="ai-frontend-toolkit@${MP}"
78
- BACKEND="ai-backend-toolkit@${MP}"
79
-
80
82
  # Enable-mode only: with zero args the write rules below would set every stack
81
83
  # toolkit to false (nothing but the always-on pair is in $ON), silently wiping the repo's
82
84
  # selection. No args belongs to the picker / status path (Behaviour step 1).
@@ -85,20 +87,17 @@ if [ "$#" -eq 0 ]; then
85
87
  exit 0
86
88
  fi
87
89
 
88
- # resolve every arg through the alias table, union the ON set
89
- ON="$COMMON $ANALYST"
90
- for ARG in "$@"; do
91
- case "$ARG" in
92
- ios) ON="$ON $IOS" ;;
93
- android) ON="$ON $ANDROID" ;;
94
- web|frontend) ON="$ON $WEB" ;;
95
- backend) ON="$ON $BACKEND" ;;
96
- mobile) ON="$ON $IOS $ANDROID" ;;
97
- fullstack) ON="$ON $WEB $BACKEND" ;;
98
- all) ON="$ON $IOS $ANDROID $WEB $BACKEND" ;;
99
- *) echo "Unknown stack '$ARG'. One of: ios android mobile backend web fullstack all"; exit 1 ;;
100
- esac
101
- done
90
+ # resolve every arg and derive the ON set - one owner, see the map above.
91
+ # The marketplace suffix is appended INSIDE node: `$ON` is a single string, and
92
+ # re-splitting it in the shell to suffix each name is the zsh word-splitting trap
93
+ # (`printf %s@x $ON` suffixes the whole string, not each word).
94
+ ON=$(MP="$MP" node -e 'import(process.env.HOME+"/.claude/scripts/_stack-routing.mjs").then(m=>{
95
+ const st=process.argv.slice(1).flatMap(a=>m.expandStackArg(a));
96
+ if(!st.length){console.error("Unknown stack. One of: "+m.KNOWN_STACKS.join(" ")+" mobile fullstack all");process.exit(1);}
97
+ const r=m.pluginsForStacks(st);
98
+ if(r.dropped.length) console.error("not in the marketplace, dropped: "+r.dropped.join(" "));
99
+ console.log(r.plugins.map(p=>p+"@"+process.env.MP).join(" "));
100
+ })' "$@") || exit 1
102
101
  ```
103
102
 
104
103
  After resolving `$ON`, edit `.claude/settings.json` (create `{ "enabledPlugins": {} }` if absent) so that:
@@ -22,7 +22,23 @@ Show every active and completed task as a table.
22
22
  3. **Parse state** - for each task:
23
23
  - `taskId`, `branch`, `currentPhase`, `status`, `startedAt`, `autopilot`
24
24
 
25
- 4. **Render as a table**:
25
+ 3b. **Sort into three groups, and label them.** `in_progress` alone cannot tell
26
+ a run that is waiting for you from one that died, and on this machine that
27
+ difference covered 20 runs: 3 were holding at Phase 6/7 with their PR already
28
+ open, 11 were left at a Phase 0 question with nothing built, 6 stopped
29
+ mid-development. One "in progress" label for all three is what made a
30
+ finished job and a crashed one share a row.
31
+
32
+ | Group | Test | Action offered |
33
+ |---|---|---|
34
+ | Waiting on you | `status == "awaiting_input"`, or a `pr.url`, or phase 6/7 | `resume #N` - the work landed, it needs your answer |
35
+ | Stopped mid-development | anything else past phase 0 | `resume #N` or `kill #N` |
36
+ | Left at a question | phase 0 | `garbage-collect --abandoned` - nothing was built |
37
+
38
+ A run with no `status` is **not** placed in any group. Unknown is not a
39
+ finding, and calling it dead is the same false claim in the other direction.
40
+
41
+ 4. **Render as a table**, grouped per 3b, with the group as a section heading:
26
42
  ```
27
43
  🤖 Multi-Agent Tasks
28
44
 
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  description: "One-shot sync of the entire multi-agent ecosystem: Claude Code, Copilot CLI, pipeline repo, website, and the multi-agent-toolkit MCP server. Use when work is finished and should be propagated across Claude Code, Copilot CLI, the repos, the website and the multi-agent-toolkit server."
3
3
  description-tr: "Tüm multi-agent ekosisteminin tek atımlık senkronu: Claude Code, Copilot CLI, pipeline repo, website ve multi-agent-toolkit MCP sunucusu."
4
- argument-hint: "[release] [multi-agent-toolkit] [--platform=<macos|linux|windows>]"
4
+ argument-hint: "[release] [multi-agent-toolkit]"
5
5
  allowed-tools: Read, Write, Edit, Bash, Glob, Grep, AskUserQuestion, WebFetch
6
6
  ---
7
7
 
@@ -9,7 +9,7 @@ allowed-tools: Read, Write, Edit, Bash, Glob, Grep, AskUserQuestion, WebFetch
9
9
 
10
10
  **One command. Full ecosystem sync. No arguments needed.**
11
11
 
12
- When invoked, it synchronizes all targets in order. It detects what changed, updates only the stale ones, and returns a result report. The host platform (macOS / Linux / Windows) is auto-detected; all dispatch commands stay platform-agnostic through `~/.claude/lib/credential-store.sh`.
12
+ When invoked, it synchronizes all targets in order. It detects what changed, updates only the stale ones, and returns a result report. macOS only (ADR-0012); credential access goes through `~/.claude/lib/credential-store.sh` so no call site names the keychain backend.
13
13
 
14
14
  **Input**: $ARGUMENTS (optional)
15
15
 
@@ -18,7 +18,6 @@ When invoked, it synchronizes all targets in order. It detects what changed, upd
18
18
  | (none) | Full ecosystem sync: Claude Code, Copilot CLI, pipeline repo, website, multi-agent-toolkit MCP server. |
19
19
  | `release` | Full sync + version bump + tag + npm publish + website deploy (plus the multi-agent-toolkit ship path) |
20
20
  | `multi-agent-toolkit` | Run Step 3d only: gate, commit and publish the companion MCP server |
21
- | `--platform=<macos\|linux\|windows>` | Override automatic platform detection. For CI / cross-platform smoke. |
22
21
  | `"change description"` | Apply the description to every target at once |
23
22
 
24
23
  ## Targets
@@ -32,25 +31,32 @@ When invoked, it synchronizes all targets in order. It detects what changed, upd
32
31
  | 4 | Website | `{owner}/{website-host}` | <- version + features |
33
32
  | 5 | multi-agent-toolkit MCP server | resolved from `prefs.global.devToolkit` or the `mcpServers` registration | own repo: gate, commit, publish |
34
33
 
35
- ## Platform Support (macOS / Linux / Windows)
34
+ ## Platform Support (macOS only)
36
35
 
37
- The sync skill must run on all three platforms. Commands go through the platform-agnostic shell layer; the following compatibility rules are preserved for every target:
36
+ v17.0.0 removed Linux and Windows (ADR-0012). `package.json` declares
37
+ `os: ["darwin"]`, which npm enforces on the root package, so this tree cannot be
38
+ installed elsewhere - a detection table for hosts that cannot run the installer
39
+ would be a table the agent executes against a case that no longer exists.
38
40
 
39
- | Platform | Detection | Implication |
40
- |---|---|---|
41
- | macOS | `[[ "$OSTYPE" == "darwin"* ]]` | Default. BSD `stat -f %m`, `security` for Keychain. |
42
- | Linux | `[[ "$OSTYPE" == "linux"* ]]` | GNU `stat -c %Y`, `secret-tool` for Keychain (libsecret). |
43
- | Windows (Git Bash / MSYS2) | `[[ "$OSTYPE" == "msys" \|\| "$OSTYPE" == "cygwin" ]]` | `~` resolves to `/c/Users/<name>`. Powershell `CredentialManager` for Keychain. POSIX paths inside Git Bash. |
44
- | Windows (WSL) | `grep -qi microsoft /proc/version` | Linux toolchain inside WSL. Hostname format `WSL2-...`. |
45
-
46
- **Cross-platform contract for synced shell scripts:**
41
+ **Contract for synced shell scripts.** The runtime is BSD userland and bash 3.2,
42
+ and that is a stricter constraint than "cross-platform", not a looser one:
47
43
 
48
44
  1. Path handling: always quote (`"$f"`); use `$HOME`, never `/Users/...`.
49
- 2. Timestamps: try BSD `stat -f` first, fall back to GNU `stat -c` - both already covered by `~/.claude/lib/repo-cache.sh` pattern.
50
- 3. `find` traversal: include `-prune` for `node_modules`, `Pods`, `.build`, `DerivedData`, `.next` - same on every platform.
51
- 4. Sed / awk: prefer POSIX-portable invocations. Avoid GNU-only `-i` without `''` on macOS or `-r` (use `-E` instead).
52
- 5. Keychain I/O: always via `~/.claude/lib/credential-store.sh get|set` - never call `security` / `secret-tool` / `gcm` directly.
53
- 6. Verification: after Step 3 (REPO), run `bash -n` on every shell script in `pipeline/lib/` and `pipeline/scripts/` to surface portability regressions early.
45
+ 2. `grep -P` is banned. BSD grep has no `-P`: it exits 2, and with `2>/dev/null`
46
+ that reads as "found nothing". Two gates have already shipped green for
47
+ exactly this reason. Use `-E`.
48
+ 3. Timestamps: `stat -c %Y` FIRST, then `stat -f %m`. GNU-first ordering is
49
+ required because `stat -f` is a valid GNU flag (`--file-system`) that
50
+ succeeds and prints something that is not a timestamp.
51
+ 4. Hashes: `sha256sum || shasum`, in that order. The gate fails a file that
52
+ names either alone.
53
+ 5. `find` traversal: `-prune` for `node_modules`, `Pods`, `.build`,
54
+ `DerivedData`, `.next`.
55
+ 6. Sed / awk: POSIX-portable. `-E`, never `-r`; `sed -i ''` on BSD.
56
+ 7. Keychain I/O: always via `~/.claude/lib/credential-store.sh get|set`, never
57
+ `security` directly - the wrapper is what keeps a secret off argv.
58
+ 8. Verification: after Step 3 (REPO), `bash -n` every shell script in
59
+ `pipeline/lib/` and `pipeline/scripts/`.
54
60
 
55
61
  ## Default Behavior (no arguments)
56
62
 
@@ -58,7 +64,6 @@ Run every step automatically:
58
64
 
59
65
  ```
60
66
  Step 0: DOCTOR doctor.mjs - exit 2 or 4 stops the sync
61
- Step 1: PLATFORM Detect macOS / Linux / Windows (Git Bash / WSL); export PLATFORM env
62
67
  Step 1.5: DETECT Compare timestamps, find stale targets
63
68
  Step 2: COPILOT Claude Code -> Copilot CLI (instructions + 53 sub-command skills)
64
69
  Step 2b: CODEX Claude Code -> Codex CLI (1 router skill + 53 specs as refs + 8 agent TOML)
@@ -384,8 +389,8 @@ Measured on this machine, which is why the order is what it is:
384
389
  | Candidate | login | scopes |
385
390
  |---|---|---|
386
391
  | `keychainMapping.github` | corporate EMU account | cannot publish to a personal scope under any grant |
387
- | `mmerterden_Github_Auth_Token` | personal | `admin:public_key, gist, read:org, repo` - no `write:packages` |
388
- | `gh auth token -u mmerterden` | personal | `gist, read:org, repo, workflow, write:packages` ✓ |
392
+ | the personal PAT in the store | personal | `admin:public_key, gist, read:org, repo` - no `write:packages` |
393
+ | `gh auth token -u {owner}` | personal | `gist, read:org, repo, workflow, write:packages` ✓ |
389
394
 
390
395
  **Two 403s mean two different things, and neither says "wrong token" plainly:**
391
396
 
@@ -8,7 +8,7 @@
8
8
  # that precedes the provider segment. Examples:
9
9
  # ${USER}_Github_Access_Token -> prefix=${USER}
10
10
  # ${USER}_Tktech_Github_Access_Token -> prefix=${USER}_Tktech
11
- # personal_Github_Auth_Token -> prefix=personal
11
+ # {prefix}_Github_Auth_Token -> prefix={prefix}
12
12
  #
13
13
  # Usage:
14
14
  # ./account-resolver.sh # JSON array
@@ -0,0 +1,200 @@
1
+ #!/usr/bin/env bash
2
+ # stack-detect.sh - answer "what is this repo built with" from the repo itself.
3
+ #
4
+ # Why this exists: `/multi-agent:stack` writes `enabledPlugins` and a human picks
5
+ # the stack. When that conversation never happens the repo inherits the global
6
+ # setting, and on the machine this was written against only 3 of 25 checkouts had
7
+ # ever had it - so a Next.js site and several Node CLIs were all routed to the iOS
8
+ # toolkit. An unsupervised run has nobody to notice.
9
+ #
10
+ # So: infer, deterministically, from file markers - never a model call - so the
11
+ # same repo always answers the same way and the answer can be shown with its
12
+ # reason.
13
+ #
14
+ # This file answers ONE question: which of `ios android web backend` describes
15
+ # this repo. It deliberately does not know plugin names. "Which plugin carries a
16
+ # stack" is a different question with a different owner
17
+ # (`pipeline/scripts/_stack-routing.mjs`), and the two were one table here until
18
+ # that put a tenth copy of the stack-to-plugin mapping in the tree - the exact
19
+ # duplication `multi-agent-refs/stack-skill-routing.md` exists to prevent.
20
+ #
21
+ # Usage:
22
+ # . stack-detect.sh; ma_stack_detect <repo-root> # sets MA_STACKS, MA_STACK_WHY
23
+ # bash stack-detect.sh <repo-root> # prints KEY=VALUE
24
+ # bash stack-detect.sh --json <repo-root>
25
+ #
26
+ # Output: MA_STACKS is a space-separated subset of `ios android web backend`, in
27
+ # that fixed order so two runs on the same repo produce the same string. Empty is
28
+ # a real answer and means "no marker matched", NOT "not looked" - MA_STACK_WHY
29
+ # distinguishes them, because a caller that cannot tell those apart will treat an
30
+ # unreadable directory as a language-free repo.
31
+
32
+ # Markers whose mere presence decides a stack, as data rather than a `case`
33
+ # cascade: `stack:glob:maxdepth`. Adding a language is one line here, which is
34
+ # the point - a contributor who has to edit control flow to add Rust will
35
+ # instead widen somebody else's regex.
36
+ #
37
+ # Not every rule fits this shape, and forcing the two that do not would be worse
38
+ # than keeping them in code: Android is decided by FILE CONTENT (Gradle alone is
39
+ # a JVM service), and one package.json can be web, backend or both. Those two
40
+ # run after the table, with their reasoning at the point of decision.
41
+ MA_STACK_MARKERS="\
42
+ ios:Package.swift:3
43
+ ios:*.xcodeproj:3
44
+ ios:*.xcworkspace:3
45
+ ios:Podfile:3
46
+ web:vite.config.*:3
47
+ web:nuxt.config.*:3
48
+ web:angular.json:3
49
+ web:svelte.config.*:3
50
+ backend:requirements.txt:3
51
+ backend:pyproject.toml:3
52
+ backend:go.mod:3
53
+ backend:Cargo.toml:3
54
+ backend:pom.xml:3"
55
+
56
+ # Dependency names that decide which side of one package.json a repo is on. A
57
+ # Next app with API routes is honestly both, so both may be recorded.
58
+ MA_STACK_WEB_DEPS='"(react|next|vue|svelte|@angular/core|solid-js|astro|preact|remix)"[[:space:]]*:'
59
+ MA_STACK_BACKEND_DEPS='"(express|fastify|@nestjs/core|koa|hapi|@trpc/server|apollo-server)"[[:space:]]*:'
60
+
61
+ ma_stack_detect() {
62
+ local root="${1:-$PWD}"
63
+ MA_STACKS=""
64
+ MA_STACK_WHY=""
65
+
66
+ if [ -z "$root" ] || [ ! -d "$root" ]; then
67
+ MA_STACK_WHY="unreadable: $root"
68
+ return 1
69
+ fi
70
+ # Strip trailing slashes before anything builds a path from $root. `find`
71
+ # prints `<root>/x`, so a root ending in `/` makes every `-path "$root/$sub"`
72
+ # prune carry a double slash and match nothing - the submodule prune then
73
+ # silently does not fire, and a vendored Package.swift reports a Compose app
74
+ # as iOS. A caller passing a directory with a trailing slash is normal.
75
+ while [ "${root%/}" != "$root" ] && [ "$root" != "/" ]; do root="${root%/}"; done
76
+
77
+ # Submodule paths are pruned: a vendored checkout is somebody else's repo and
78
+ # its markers are not this repo's stack. Measured, not theorised - the Android
79
+ # app vendors the shared configuration repo, which ships a Package.swift, and a
80
+ # depth-first scan reported that Compose app as an iOS repo.
81
+ local _prunes=(
82
+ -name node_modules -o -name .build -o -name Pods -o -name build -o -name dist
83
+ -o -name .next -o -name .gradle -o -name DerivedData -o -name .worktrees
84
+ -o -name .git -o -name vendor -o -name Carthage
85
+ )
86
+ local _sub _subs=()
87
+ if [ -f "$root/.gitmodules" ]; then
88
+ while IFS= read -r _sub; do
89
+ [ -n "$_sub" ] && _subs+=(-o -path "$root/$_sub")
90
+ done <<< "$(sed -n 's/^[[:space:]]*path[[:space:]]*=[[:space:]]*//p' "$root/.gitmodules" 2>/dev/null)"
91
+ fi
92
+
93
+ # The repo ROOT is checked before anything deeper, and that ordering is the
94
+ # whole correctness argument: the root manifest is the repo's own declaration,
95
+ # while every deeper hit belongs to a submodule, a build output or a vendored
96
+ # dependency. Without it the answer is decided by `find` traversal order, which
97
+ # is arbitrary - it read a generated .next/package.json as a Next.js app's
98
+ # manifest and found no framework in it.
99
+ _ma_has() { # $1 = -name pattern; prints the first hit, empty when none
100
+ local _g
101
+ for _g in "$root"/$1; do
102
+ [ -e "$_g" ] && {
103
+ printf '%s\n' "$_g"
104
+ return 0
105
+ }
106
+ done
107
+ # No -mindepth here, deliberately. -mindepth suppresses predicate evaluation
108
+ # for shallower entries, so with -mindepth 2 the prune never fired on a
109
+ # depth-1 submodule directory and find walked straight into it. The root glob
110
+ # above already returned any root-level hit, so re-visiting depth 1 is free.
111
+ find "$root" -maxdepth "${2:-3}" \
112
+ -type d \( "${_prunes[@]}" "${_subs[@]}" \) -prune -o \
113
+ -name "$1" -print 2>/dev/null | head -1
114
+ }
115
+
116
+ _ma_add() { # $1 = stack, $2 = why-suffix
117
+ case " $MA_STACKS " in *" $1 "*) return 0 ;; esac
118
+ MA_STACKS="${MA_STACKS:+$MA_STACKS }$1"
119
+ _why="${_why:+$_why }$1<-$2"
120
+ }
121
+
122
+ local _why="" hit row stack pat depth
123
+
124
+ # --- table markers ----------------------------------------------------
125
+ while IFS=: read -r stack pat depth; do
126
+ [ -n "$stack" ] || continue
127
+ case " $MA_STACKS " in *" $stack "*) continue ;; esac
128
+ hit=$(_ma_has "$pat" "$depth")
129
+ [ -n "$hit" ] && _ma_add "$stack" "${hit##*/}"
130
+ done <<EOF
131
+ $MA_STACK_MARKERS
132
+ EOF
133
+
134
+ # --- android, by content ----------------------------------------------
135
+ # Gradle alone does NOT mean Android - a JVM service builds with Gradle too.
136
+ # AndroidManifest.xml or the Android Gradle plugin is what separates them, and
137
+ # getting this wrong loads the Compose toolkit onto a Spring repo.
138
+ # AndroidManifest.xml lives at <module>/src/main/, which is depth 4 in every
139
+ # multi-module app, so this one search goes deeper than the rest.
140
+ hit=$(_ma_has "AndroidManifest.xml" 5)
141
+ if [ -z "$hit" ]; then
142
+ # A version catalogue is where a modern build declares the Android plugin;
143
+ # the root build.gradle.kts of the reference app names it nowhere.
144
+ local g
145
+ for g in "$root/gradle/libs.versions.toml" "$(_ma_has 'build.gradle*')" "$(_ma_has 'settings.gradle*')"; do
146
+ [ -n "$g" ] && [ -f "$g" ] || continue
147
+ if grep -qE "com\.android\.(application|library)|androidx|\bagp\b" "$g" 2>/dev/null; then
148
+ hit="$g"
149
+ break
150
+ fi
151
+ done
152
+ fi
153
+ [ -n "$hit" ] && _ma_add "android" "${hit##*/}"
154
+
155
+ # --- web / backend, by dependency -------------------------------------
156
+ local pkg
157
+ pkg=$(_ma_has "package.json")
158
+ if [ -n "$pkg" ]; then
159
+ local is_web=0
160
+ grep -qE "$MA_STACK_WEB_DEPS" "$pkg" 2>/dev/null && is_web=1
161
+ [ "$is_web" -eq 1 ] && _ma_add "web" "package.json"
162
+ if grep -qE "$MA_STACK_BACKEND_DEPS" "$pkg" 2>/dev/null; then
163
+ _ma_add "backend" "package.json"
164
+ elif [ "$is_web" -eq 0 ]; then
165
+ # A package.json with no web framework is still a Node project, and the
166
+ # backend toolkit is the one that covers Node. Reporting nothing here would
167
+ # send the caller to its no-marker fallback for a repo whose language is
168
+ # not in doubt.
169
+ _ma_add "backend" "package.json(node)"
170
+ fi
171
+ fi
172
+
173
+ # Fixed order, so the same repo always yields the same string.
174
+ local ordered="" s
175
+ for s in ios android web backend; do
176
+ case " $MA_STACKS " in *" $s "*) ordered="${ordered:+$ordered }$s" ;; esac
177
+ done
178
+ MA_STACKS="$ordered"
179
+ MA_STACK_WHY="${_why:-no marker matched}"
180
+ unset -f _ma_has _ma_add
181
+ return 0
182
+ }
183
+
184
+ if [ "${BASH_SOURCE[0]:-$0}" = "$0" ]; then
185
+ _json=0
186
+ case "${1:-}" in --json)
187
+ _json=1
188
+ shift
189
+ ;;
190
+ esac
191
+ ma_stack_detect "${1:-$PWD}" || true
192
+ if [ "$_json" -eq 1 ]; then
193
+ _arr=""
194
+ for _s in $MA_STACKS; do _arr="${_arr:+$_arr,}\"$_s\""; done
195
+ printf '{"stacks":[%s],"why":"%s"}\n' "$_arr" "$MA_STACK_WHY"
196
+ else
197
+ printf 'MA_STACKS=%s\n' "$(printf '%q' "$MA_STACKS")"
198
+ printf 'MA_STACK_WHY=%s\n' "$(printf '%q' "$MA_STACK_WHY")"
199
+ fi
200
+ fi
@@ -297,20 +297,39 @@ Modifier flags are orthogonal and compose:
297
297
 
298
298
  ---
299
299
 
300
- ## 7. Platform Guards (macOS / Linux / WSL)
300
+ ## 7. Platform Guards (macOS)
301
301
 
302
- All three CLIs run on macOS, Linux and Windows, so any command may execute on any of them. (This line used to say Claude Code was macOS-only; it has not been true for some time, and the assumption it invited - that a Windows user necessarily arrives through Copilot or Codex - produces wrong conclusions about which paths need to be portable.) Shell code in `pipeline/skills/shared/core/` command files and in `pipeline/lib` / `pipeline/scripts` must be portable.
302
+ All three CLIs are installed from this package, and `package.json` declares
303
+ `os: ["darwin"]` (ADR-0012), so every command executes on macOS. That is not a
304
+ licence to stop being careful about shell: the runtime is **BSD userland and
305
+ bash 3.2**, which is a narrower target than "portable", and three constructs
306
+ below exist because the BSD form silently differs rather than failing.
303
307
 
304
- For Keychain I/O the canonical path is **`~/.claude/lib/credential-store.sh`** (or `~/.copilot/lib/credential-store.sh` / `~/.codex/lib/credential-store.sh` on those installs). The shell driver detects platform internally and auto-delegates to `keychain.py` (Python helper, macOS / Linux) or PowerShell `CredentialManager` (Windows). Call sites stay platform-agnostic - no per-OS branching needed.
308
+ For Keychain I/O the canonical path is **`~/.claude/lib/credential-store.sh`**
309
+ (or `~/.copilot/lib/credential-store.sh` / `~/.codex/lib/credential-store.sh` on
310
+ those installs). Call sites never invoke `security` themselves - the wrapper is
311
+ what keeps a secret off argv and what writes the audit entry.
305
312
 
306
- | Purpose | Canonical (cross-platform) | Underlying backend (for reference / debugging) |
313
+ | Purpose | Canonical | Underlying backend (for debugging only) |
307
314
  |---|---|---|
308
- | Keychain read | `~/.claude/lib/credential-store.sh get <key>` | macOS: `security find-generic-password -a "$USER" -s <key> -w` · Linux: `secret-tool lookup account "$USER" service <key>` · Windows: PowerShell `Get-StoredCredential` |
309
- | Keychain write | `~/.claude/lib/credential-store.sh set <key> <value>` (or `keychain.py set <key> -` to stream the secret over stdin) | macOS: `security add-generic-password -a "$USER" -s <key> -w "$VAL"` · Linux: `secret-tool store --label=<key> account "$USER" service <key>` · Windows: `New-StoredCredential` |
310
- | Clipboard read | - (still needs a per-platform branch - there is no helper) | `pbpaste` (macOS) · `wl-paste` (Wayland) · `xclip -selection clipboard -o` (X11) |
311
- | Clipboard clear | - | `pbcopy < /dev/null` (macOS) · `printf '' \| wl-copy` (Wayland) · `printf '' \| xclip -selection clipboard` (X11) |
312
-
313
- For clipboard ops, callers still gate with `if command -v pbpaste >/dev/null; then ...; elif command -v wl-paste ...; elif command -v xclip ...; fi`. A future `pipeline/lib/clipboard.sh` could absorb that pattern; until it exists, keep the inline guard in skill bash blocks.
315
+ | Keychain read | `~/.claude/lib/credential-store.sh get <key>` | `security find-generic-password -a "$USER" -s <key> -w` |
316
+ | Keychain write | `~/.claude/lib/credential-store.sh set <key> <value>` (or `keychain.py set <key> -` to stream the secret over stdin, keeping it off argv) | `security add-generic-password -a "$USER" -s <key> -w` |
317
+ | Clipboard read | `pbpaste` | - |
318
+ | Clipboard clear | `pbcopy < /dev/null` | - |
319
+
320
+ **The three that bite on BSD**, and why each is a rule rather than a preference:
321
+
322
+ - PCRE mode is absent from BSD grep, and is banned here. It exits 2, which under
323
+ `2>/dev/null` reads as "found nothing" - two gates shipped green for exactly
324
+ that reason. Use `-E`.
325
+ - `stat -c %Y` FIRST, then `stat -f %m`. GNU-first ordering is required because
326
+ `stat -f` is a valid GNU flag (`--file-system`) that succeeds and prints
327
+ something that is not a timestamp.
328
+ - `sed -E`, never `-r`; `sed -i ''` when editing in place.
329
+
330
+ Clipboard callers may use `pbpaste` / `pbcopy` directly; the three-way
331
+ `command -v` guard they used to carry was for hosts this package no longer
332
+ installs on.
314
333
 
315
334
  ---
316
335
 
@@ -25,9 +25,9 @@ resolver as a clean bill.
25
25
 
26
26
  **A state in which a run will fail or leak. Not a state in which it will merely
27
27
  be worse.** Without a closed definition "blocked" grows until nobody respects
28
- exit 2, so only five checks can produce it: `install-present`, `script-surface`,
29
- `state-writable`, `prefs-valid` and `embedded-credentials`. Every other check
30
- tops out at WARN however bad it looks.
28
+ exit 2, so only six checks can produce it: `host-platform`, `install-present`,
29
+ `script-surface`, `state-writable`, `prefs-valid` and `embedded-credentials`.
30
+ Every other check tops out at WARN however bad it looks.
31
31
 
32
32
  ## Who calls it, and what they do with the code
33
33
 
@@ -91,6 +91,18 @@ its host; everything after that is behind `--explain`.
91
91
 
92
92
  ## Checks
93
93
 
94
+ ### host-platform
95
+
96
+ The host is macOS. BLOCK otherwise: every credential read shells `security`,
97
+ every iOS build shells `xcodebuild`, every piece of visual evidence shells
98
+ `simctl`, so a run on another platform does not degrade, it fails partway
99
+ through with a worktree and a branch already created (ADR-0012).
100
+
101
+ It runs **first**, before `install-present`, so an unsupported host reads one
102
+ line naming the cause instead of a cascade of consequences. The step names the
103
+ `MULTI_AGENT_ALLOW_NON_DARWIN=1` escape, which the entry points honour and
104
+ nothing tests.
105
+
94
106
  ### install-present
95
107
 
96
108
  The installed tree exists and carries the subtrees a run reads: `commands/`,
@@ -33,6 +33,54 @@ model's reading of the task. A file counts as UI by stack:
33
33
  Nothing about this section is optional-by-omission: when it is required and an
34
34
  artefact is absent, the absence is written down with its reason (section 5).
35
35
 
36
+ ### 1a. Who writes the verdict, and when
37
+
38
+ **Phase 0 Step 7.7 writes `required`, `requiredBy` and `platform`**, before it
39
+ probes. Naming the writer is not a formality: five phases read these three fields
40
+ and nothing set them, so `required` was never true, Step 7.7 never ran, Phase 3
41
+ never captured, and Phase 6's blocker never blocked. A contract with readers and
42
+ no writer reads exactly like a contract that is satisfied.
43
+
44
+ Phase 0 has no diff, so it decides on what it actually knows - `taskType` and
45
+ whether the intake carried a Figma reference - and that is enough for the whole
46
+ design-work row. The `bugfix` row needs a changed-file list, so Phase 0 records
47
+ it as provisional (`requiredBy: "bugfix (pending diff)"`) and **Phase 3 re-decides
48
+ from the real diff** before capturing. A provisional `false` never suppresses the
49
+ Phase 3 decision.
50
+
51
+ ### 1b. The platform, and when there is not one
52
+
53
+ `platform` comes from the stack, not from the diff: it selects which device
54
+ tooling is probed, and that is a property of the repo.
55
+
56
+ ```bash
57
+ eval "$(bash $HOME/.claude/lib/stack-detect.sh "$PROJECT_ROOT")" # MA_STACKS
58
+ EVIDENCE_PLATFORM=""
59
+ case " $MA_STACKS " in
60
+ *" ios "*) EVIDENCE_PLATFORM=ios ;;
61
+ *" android "*) EVIDENCE_PLATFORM=android ;;
62
+ esac
63
+ ```
64
+
65
+ iOS wins a tie, and the tie is recorded in `stackWhy`: `simctl` discovery is the
66
+ cheaper of the two, and a repo that is honestly both gets the same answer on
67
+ every run rather than one that depends on ordering.
68
+
69
+ **An empty value is a real outcome, not an error.** A web or backend repo has no
70
+ simulator to probe, and `probe-evidence-capability.sh` rejects anything but
71
+ `ios|android` with exit 2. So when `EVIDENCE_PLATFORM` is empty the probe **does
72
+ not run**, `platform` is written as `web` or `other` from `MA_STACKS`, and the
73
+ reason goes to `state.evidenceCapability.skippedReason` in the probe's own
74
+ `*_REASON` idiom - "no device platform in stacks [web backend]". Passing an empty
75
+ string instead is what the probe's exit 2 exists to refuse, and swallowing that
76
+ exit would turn a missing value into a silent skip.
77
+
78
+ Every `--platform` argument in this pipeline is `$EVIDENCE_PLATFORM`, read from
79
+ `state.visualEvidence.platform` in later phases. `$PLATFORM` is not a pipeline
80
+ variable: nothing ever assigned it, in any phase document, which is why
81
+ `smoke-evidence-probe.sh` now fails a `--platform` argument whose variable is not
82
+ assigned earlier in the same file.
83
+
36
84
  ## 2. Before - the reporter's screenshot, or nothing
37
85
 
38
86
  **The pipeline does not build the old state to photograph it.** Reproducing a
@@ -61,7 +109,7 @@ never the only source.
61
109
 
62
110
  ```bash
63
111
  bash $HOME/.claude/scripts/capture-evidence.sh after \
64
- --task "$TASK_ID" --platform "$PLATFORM" --label "<screen-slug>"
112
+ --task "$TASK_ID" --platform "$EVIDENCE_PLATFORM" --label "<screen-slug>"
65
113
  ```
66
114
 
67
115
  What the capture is worth checking against - the per-component catalog, the
@@ -591,21 +591,30 @@ Log: `Phase 0 Step 7.6: test baseline = {green|red|unknown} ({N} pre-existing fa
591
591
 
592
592
  #### Step 7.7 - Evidence capability, then test depth
593
593
 
594
- Probe, then ask, then run. Skipped unless `state.visualEvidence.required` and `visualEvidence.enabled` is not `false`. Reasoning: `features/visual-evidence.md` section 4.
594
+ Decide, then probe, then ask, then run. Skipped only when `visualEvidence.enabled` is `false`. Contract: `features/visual-evidence.md` sections 1a, 1b, 4.
595
+
596
+ **This step is the only writer of `visualEvidence.required`, `requiredBy` and `platform`** (section 1a). No diff exists yet, so the `bugfix` row is provisional and Phase 3 re-decides.
595
597
 
596
598
  ```bash
597
- eval "$(bash $HOME/.claude/scripts/probe-evidence-capability.sh \
598
- --platform "$PLATFORM" --repo "$WORKTREE" --changed "$CHANGED_CSV" \
599
- --json-out "$WORKTREE/.pipeline/evidence-capability.json")"
599
+ eval "$(bash $HOME/.claude/lib/stack-detect.sh "$PROJECT_ROOT")"
600
+ EVIDENCE_PLATFORM=""
601
+ case " $MA_STACKS " in *" ios "*) EVIDENCE_PLATFORM=ios ;; *" android "*) EVIDENCE_PLATFORM=android ;; esac
602
+ if [ -n "$EVIDENCE_PLATFORM" ]; then
603
+ eval "$(bash $HOME/.claude/scripts/probe-evidence-capability.sh \
604
+ --platform "$EVIDENCE_PLATFORM" --repo "$WORKTREE" \
605
+ --json-out "$WORKTREE/.pipeline/evidence-capability.json")"
606
+ fi
600
607
  ```
601
608
 
609
+ Empty is an outcome, not a failure: no simulator on web or backend, so the probe does not run and `evidenceCapability.skippedReason` says so. No `--changed` yet, by the same token.
610
+
602
611
  One run, both forms: stdout is `EVIDENCE_*` (shell-quoted, so the eval is safe), and the same measurement lands as JSON.
603
612
 
604
613
  Persist that file as `state.evidenceCapability`, then build the menu from it, never from a reading of the repo: `1. Sadece unit test` / `2. Unit + UI test, ekran kaydiyla` (tier 1) / `3. Unit + MCP ile akis kaydi` (tier 2).
605
614
 
606
615
  A closed option keeps its row and prints the probe's reason verbatim. No `uiTestTargets` closes 2; `mcp` false closes 3; a missing `device` or `recorder` closes both. Targets present with no `matchingTests` leaves 2 open, warning that the whole UI suite will run. **When only option 1 is open, do not ask**: `testDepth = unit`, `testDepthSource = forced`, and the tier 3 gap is written.
607
616
 
608
- Pass the default as the **1-based index**, never the label (`rules.md`: labels render in `outputLanguage`, so a label default matches nothing on a `tr` run and `ask-choice.sh` takes option 1 on a non-TTY):
617
+ Default as a 1-based index, never a label - same rule and same reason as Step 7.5:
609
618
 
610
619
  ```bash
611
620
  DEPTH_DEFAULT_INDEX=1