@mmerterden/multi-agent-pipeline 16.31.1 → 17.1.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 (69) hide show
  1. package/CHANGELOG.md +110 -0
  2. package/README.md +148 -103
  3. package/README.tr.md +149 -103
  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/architecture.md +3 -3
  8. package/docs/ecosystem.md +5 -5
  9. package/docs/engineering.md +1 -1
  10. package/index.js +26 -0
  11. package/install/_dev-only-files.mjs +0 -1
  12. package/install/index.mjs +10 -0
  13. package/install/templates/multi-agent-autopilot.plist.template +79 -0
  14. package/package.json +5 -3
  15. package/pipeline/commands/multi-agent/autopilot-off/SKILL.md +64 -0
  16. package/pipeline/commands/multi-agent/autopilot-on/SKILL.md +173 -0
  17. package/pipeline/commands/multi-agent/autopilot-status/SKILL.md +74 -0
  18. package/pipeline/commands/multi-agent/channels/SKILL.md +41 -12
  19. package/pipeline/commands/multi-agent/garbage-collect/SKILL.md +40 -3
  20. package/pipeline/commands/multi-agent/help/SKILL.md +43 -37
  21. package/pipeline/commands/multi-agent/manual-test/SKILL.md +1 -1
  22. package/pipeline/commands/multi-agent/setup/SKILL.md +15 -7
  23. package/pipeline/commands/multi-agent/stack/SKILL.md +31 -32
  24. package/pipeline/commands/multi-agent/status/SKILL.md +17 -1
  25. package/pipeline/commands/multi-agent/sync/SKILL.md +34 -28
  26. package/pipeline/commands/multi-agent/update/SKILL.md +1 -1
  27. package/pipeline/lib/autopilot-activation.sh +117 -0
  28. package/pipeline/lib/autopilot-state.sh +150 -0
  29. package/pipeline/lib/issue-fetcher.sh +18 -1
  30. package/pipeline/lib/plan-todos.sh +18 -0
  31. package/pipeline/lib/stack-detect.sh +200 -0
  32. package/pipeline/multi-agent-refs/channels/jira.md +80 -20
  33. package/pipeline/multi-agent-refs/channels/pr.md +65 -19
  34. package/pipeline/multi-agent-refs/cross-cli-contract.md +35 -15
  35. package/pipeline/multi-agent-refs/features/doctor.md +15 -3
  36. package/pipeline/multi-agent-refs/features/visual-evidence.md +61 -1
  37. package/pipeline/multi-agent-refs/phases/phase-0-init.md +14 -5
  38. package/pipeline/multi-agent-refs/phases/phase-1-analysis.md +26 -12
  39. package/pipeline/multi-agent-refs/phases/phase-2-planning.md +17 -15
  40. package/pipeline/multi-agent-refs/phases/phase-3-dev.md +7 -5
  41. package/pipeline/multi-agent-refs/phases/phase-6-commit.md +1 -1
  42. package/pipeline/multi-agent-refs/phases/phase-7-report.md +2 -3
  43. package/pipeline/multi-agent-refs/readiness-review.md +7 -1
  44. package/pipeline/multi-agent-refs/rules.md +3 -11
  45. package/pipeline/multi-agent-refs/tracker-contract.md +32 -0
  46. package/pipeline/schemas/agent-state.schema.json +99 -25
  47. package/pipeline/schemas/autopilot-config.schema.json +149 -0
  48. package/pipeline/schemas/token-budget.json +4 -4
  49. package/pipeline/scripts/_stack-routing.mjs +91 -0
  50. package/pipeline/scripts/autopilot-arming.mjs +147 -0
  51. package/pipeline/scripts/autopilot-intake.mjs +383 -0
  52. package/pipeline/scripts/autopilot-menubar.swift +361 -0
  53. package/pipeline/scripts/autopilot-runner.mjs +349 -0
  54. package/pipeline/scripts/autopilot-status.sh +212 -0
  55. package/pipeline/scripts/capture-resume.sh +76 -14
  56. package/pipeline/scripts/doctor.mjs +26 -3
  57. package/pipeline/scripts/gc-abandoned.sh +352 -0
  58. package/pipeline/scripts/jira-search.sh +70 -0
  59. package/pipeline/scripts/phase-tracker.sh +134 -12
  60. package/pipeline/scripts/probe-evidence-capability.sh +27 -3
  61. package/pipeline/scripts/run-ui-tests.sh +113 -4
  62. package/pipeline/scripts/usage-report.mjs +5 -5
  63. package/pipeline/skills/.skill-manifest.json +16 -4
  64. package/pipeline/skills/shared/core/multi-agent-autopilot-off/SKILL.md +67 -0
  65. package/pipeline/skills/shared/core/multi-agent-autopilot-on/SKILL.md +146 -0
  66. package/pipeline/skills/shared/core/multi-agent-autopilot-status/SKILL.md +64 -0
  67. package/pipeline/skills/shared/core/multi-agent-channels/SKILL.md +62 -11
  68. package/pipeline/skills/shared/core/multi-agent-sync/SKILL.md +9 -8
  69. package/pipeline/scripts/gate-linux.sh +0 -62
@@ -0,0 +1,117 @@
1
+ #!/usr/bin/env bash
2
+ # autopilot-activation.sh - which repos may this machine pick work up from.
3
+ #
4
+ # The answer is NEVER "all of them" and never "whatever has the label". Measured
5
+ # on one machine: 66 repos grant push, and a good half belong to other people -
6
+ # a colleague's backend, someone's side project. A label is a filter, not a gate:
7
+ # anyone able to open an issue in a repo you happen to have push on could put
8
+ # `agent-queue` on it. The gate is this picker, and the picker is per machine.
9
+ #
10
+ # Two lists come out, and the difference matters:
11
+ #
12
+ # eligible push rights AND a checkout on this machine
13
+ # unavailable push rights, no checkout - LISTED, with that as the reason
14
+ #
15
+ # The second list is printed rather than dropped. Silently hiding 40 repos from a
16
+ # 66-repo answer looks exactly like a permissions problem, and the user then goes
17
+ # looking for a token fault that does not exist.
18
+ #
19
+ # Usage:
20
+ # bash autopilot-activation.sh list # TSV: state, nameWithOwner, localPath
21
+ # bash autopilot-activation.sh list --json
22
+ # . autopilot-activation.sh; ma_ap_local_path <owner/repo>
23
+ #
24
+ # Exit: 0 on success (including an empty list), 2 on usage, 3 when gh cannot answer.
25
+
26
+ set -uo pipefail
27
+
28
+ # Repo roots to search for checkouts. $HOME's immediate children covers the
29
+ # normal layout; MA_AUTOPILOT_REPO_ROOTS overrides it for tests and for anyone
30
+ # who keeps checkouts somewhere else.
31
+ MA_AP_ROOTS="${MA_AUTOPILOT_REPO_ROOTS:-$HOME}"
32
+
33
+ # owner/repo for a checkout, read from its origin remote. Both URL forms, and a
34
+ # trailing .git is stripped - `git@github.com:o/r.git` and
35
+ # `https://github.com/o/r` are the same repo and a picker that treats them as
36
+ # two shows the user a duplicate they cannot explain.
37
+ ma_ap_remote_slug() {
38
+ local dir="$1" url
39
+ url=$(git -C "$dir" remote get-url origin 2>/dev/null) || return 1
40
+ url="${url%.git}"
41
+ case "$url" in
42
+ *github.com[:/]*) printf '%s\n' "${url##*github.com[:/]}" ;;
43
+ *) return 1 ;;
44
+ esac
45
+ }
46
+
47
+ # Every checkout under the roots, as `slug<TAB>path`. One pass, so the lookup
48
+ # below is a grep rather than a walk per repo.
49
+ ma_ap_checkout_index() {
50
+ local root d slug
51
+ for root in $MA_AP_ROOTS; do
52
+ [ -d "$root" ] || continue
53
+ for d in "$root"/*/; do
54
+ [ -d "$d/.git" ] || continue
55
+ slug=$(ma_ap_remote_slug "${d%/}") || continue
56
+ printf '%s\t%s\n' "$slug" "${d%/}"
57
+ done
58
+ done
59
+ }
60
+
61
+ ma_ap_local_path() { # $1 = owner/repo
62
+ ma_ap_checkout_index | awk -F'\t' -v s="$1" 'tolower($1)==tolower(s){print $2; exit}'
63
+ }
64
+
65
+ # Repos the authenticated account may push to.
66
+ #
67
+ # The query string form is deliberate: `gh api user/repos --field affiliation=...`
68
+ # sends the value as a BODY field on a GET, which this endpoint ignores - it
69
+ # answers with the default affiliation and the caller never learns the filter did
70
+ # not apply. Measured: the --field form returned 0 rows here, the query string
71
+ # form returned 66.
72
+ ma_ap_writable_repos() {
73
+ gh api "user/repos?affiliation=owner,collaborator,organization_member&per_page=100" \
74
+ --paginate -q '.[] | select(.permissions.push == true) | .full_name' 2>/dev/null
75
+ }
76
+
77
+ ma_ap_list() {
78
+ local json="${1:-}" slug path index
79
+ index=$(ma_ap_checkout_index)
80
+ local repos
81
+ repos=$(ma_ap_writable_repos)
82
+ if [ -z "$repos" ]; then
83
+ echo "autopilot-activation: gh returned no writable repos - check 'gh auth status'" >&2
84
+ return 3
85
+ fi
86
+
87
+ local first=1
88
+ [ "$json" = "--json" ] && printf '['
89
+ while IFS= read -r slug; do
90
+ [ -n "$slug" ] || continue
91
+ path=$(printf '%s\n' "$index" | awk -F'\t' -v s="$slug" 'tolower($1)==tolower(s){print $2; exit}')
92
+ local state="eligible"
93
+ [ -z "$path" ] && state="unavailable"
94
+ if [ "$json" = "--json" ]; then
95
+ [ "$first" -eq 0 ] && printf ','
96
+ first=0
97
+ printf '{"state":"%s","nameWithOwner":"%s","localPath":"%s"}' "$state" "$slug" "$path"
98
+ else
99
+ printf '%s\t%s\t%s\n' "$state" "$slug" "$path"
100
+ fi
101
+ done <<EOF
102
+ $repos
103
+ EOF
104
+ [ "$json" = "--json" ] && printf ']\n'
105
+ return 0
106
+ }
107
+
108
+ if [ "${BASH_SOURCE[0]:-$0}" = "$0" ]; then
109
+ case "${1:-}" in
110
+ list) ma_ap_list "${2:-}" ;;
111
+ "" | -h | --help) grep -E '^#( |$)' "$0" | sed -E 's/^# ?//' ;;
112
+ *)
113
+ echo "autopilot-activation: unknown command ${1}" >&2
114
+ exit 2
115
+ ;;
116
+ esac
117
+ fi
@@ -0,0 +1,150 @@
1
+ #!/usr/bin/env bash
2
+ # autopilot-state.sh - where continuous mode keeps its state, and who may read it.
3
+ #
4
+ # Everything lives under ~/.claude/autopilot/ and NOT in
5
+ # multi-agent-preferences.json. That file has `additionalProperties: false` and a
6
+ # migration chain, so a block there would push keys into every local user's
7
+ # preferences forever - including everyone who never turns this on. Separation
8
+ # also gives the off state a definition that cannot be got wrong: the directory
9
+ # is absent.
10
+ #
11
+ # config.json the repo selection. Survives autopilot-off on purpose, so
12
+ # turning the mode back on does not re-ask which repos.
13
+ # queue.json the current ordered list plus whatever is in flight
14
+ # status.json what the menu bar renders. Written by the runner, read-only
15
+ # to everything else
16
+ # attempted.jsonl append-only: {source,id,taskId,outcome,prUrl,at}
17
+ # runner.pid pid + the in-flight sessionId + kern.boottime
18
+ # runner.log
19
+ # bin/menubar built on demand from scripts/autopilot-menubar.swift
20
+ #
21
+ # 0700 throughout. The queue names real tickets and real repos, and on a shared
22
+ # machine that is somebody's roadmap.
23
+ #
24
+ # Usage:
25
+ # . autopilot-state.sh
26
+ # ma_ap_root # prints the dir, creating it 0700 only when asked
27
+ # ma_ap_is_on # exit 0 when configured, 1 otherwise. NO side effects
28
+ # ma_ap_write <file> <- # atomic write from stdin, 0600
29
+
30
+ set -uo pipefail
31
+
32
+ MA_AP_ROOT="${MA_AUTOPILOT_ROOT:-$HOME/.claude/autopilot}"
33
+
34
+ ma_ap_root() { printf '%s\n' "$MA_AP_ROOT"; }
35
+
36
+ MA_AP_LABEL="${MA_AUTOPILOT_LABEL:-com.multi-agent.autopilot}"
37
+ MA_AP_PLIST="${MA_AUTOPILOT_PLIST:-$HOME/Library/LaunchAgents/$MA_AP_LABEL.plist}"
38
+
39
+ # Two different questions, and conflating them was a real bug in the first draft
40
+ # of this file: `autopilot-off` deliberately KEEPS config.json so turning the
41
+ # mode back on does not re-ask which repos, so a predicate reading the config
42
+ # would still answer "on" after you turned it off.
43
+ #
44
+ # configured = a repo selection exists
45
+ # on = launchd holds the job
46
+ #
47
+ # On is the launchd job because that is the thing that actually makes work
48
+ # happen; anything else is a claim about a file.
49
+ ma_ap_is_configured() { [ -f "$MA_AP_ROOT/config.json" ]; }
50
+
51
+ ma_ap_is_on() { [ -f "$MA_AP_PLIST" ] && launchctl list 2>/dev/null | grep -q "$MA_AP_LABEL"; }
52
+
53
+ # The failure mode a `resume` command would have papered over: configured and
54
+ # meant to be running, but launchd does not hold the job - an OS update dropped
55
+ # the plist, or it was booted out by hand. doctor reports this; there is no
56
+ # command to remember.
57
+ ma_ap_is_orphaned() { ma_ap_is_configured && ! ma_ap_is_on && [ -f "$MA_AP_PLIST" ]; }
58
+
59
+ # Deliberately no side effects in any of the three. A predicate that creates its
60
+ # own directory turns "is autopilot on?" into "autopilot is now half on", and
61
+ # every status command, hook and doctor check calls these.
62
+
63
+ ma_ap_ensure_root() {
64
+ [ -d "$MA_AP_ROOT" ] || mkdir -p "$MA_AP_ROOT" || return 1
65
+ chmod 700 "$MA_AP_ROOT" 2>/dev/null
66
+ [ -d "$MA_AP_ROOT/bin" ] || mkdir -p "$MA_AP_ROOT/bin" 2>/dev/null
67
+ chmod 700 "$MA_AP_ROOT/bin" 2>/dev/null
68
+ return 0
69
+ }
70
+
71
+ # Write via a temp file in the SAME directory then rename. The menu bar polls
72
+ # status.json every few seconds, and a reader that catches a half-written file
73
+ # shows an empty menu; rename is atomic on the same filesystem, so it never sees
74
+ # a partial one.
75
+ ma_ap_write() { # $1 = filename under the root; content on stdin
76
+ ma_ap_ensure_root || return 1
77
+ local dst="$MA_AP_ROOT/$1" tmp="$MA_AP_ROOT/.$1.$$"
78
+ cat > "$tmp" || {
79
+ rm -f "$tmp"
80
+ return 1
81
+ }
82
+ chmod 600 "$tmp" 2>/dev/null
83
+ mv -f "$tmp" "$dst"
84
+ }
85
+
86
+ ma_ap_append() { # $1 = filename, content on stdin - for the jsonl
87
+ ma_ap_ensure_root || return 1
88
+ local dst="$MA_AP_ROOT/$1"
89
+ cat >> "$dst" || return 1
90
+ chmod 600 "$dst" 2>/dev/null
91
+ }
92
+
93
+ ma_ap_read() { # $1 = filename; empty and exit 1 when absent
94
+ local src="$MA_AP_ROOT/$1"
95
+ [ -f "$src" ] || return 1
96
+ cat "$src"
97
+ }
98
+
99
+ # jq with a default, so a caller never has to distinguish "key absent" from
100
+ # "file absent" from "file unparseable" - all three mean "use the default".
101
+ ma_ap_cfg() { # $1 = jq path, $2 = default
102
+ local v
103
+ v=$(ma_ap_read config.json 2>/dev/null | jq -r "$1 // empty" 2>/dev/null)
104
+ [ -n "$v" ] && printf '%s\n' "$v" || printf '%s\n' "$2"
105
+ }
106
+
107
+ # AC or battery. The mode holds a sleep assertion only on AC: a queue that keeps
108
+ # a laptop awake on battery is a bug, and `StartInterval` does not wake a
109
+ # sleeping Mac anyway, so on battery the work resumes when you plug in.
110
+ ma_ap_power() {
111
+ case "$(pmset -g ps 2>/dev/null | head -1)" in
112
+ *"AC Power"*) printf 'ac\n' ;;
113
+ *) printf 'battery\n' ;;
114
+ esac
115
+ }
116
+
117
+ # Boot time, so a stale runner.pid cannot be mistaken for a live runner. After a
118
+ # restart pids start low and the recorded 4711 may belong to something unrelated;
119
+ # a pid recorded BEFORE the current boot is stale by definition, with no probing.
120
+ # The pattern is ANCHORED on purpose. `kern.boottime` prints
121
+ # `{ sec = 1788181644, usec = 618574 } Mon Aug 31 ...`, and `.*sec = ` is greedy:
122
+ # it walks past `sec` to `usec` and captures 618574, the microseconds. The
123
+ # original form here did exactly that, so this returned a six-digit number that
124
+ # looked plausible and never matched the same fact read anywhere else - which is
125
+ # how the runner's staleness check compared two different numbers and called a
126
+ # live runner dead.
127
+ ma_ap_boottime() {
128
+ sysctl -n kern.boottime 2>/dev/null | sed -n 's/^{ *sec = \([0-9]*\).*/\1/p'
129
+ }
130
+
131
+ # The machine's honest ceiling, so raising `slots` is a measured decision. RAM
132
+ # and cores bound the concurrent Claude sessions; disk bounds the worktrees
133
+ # (0.75 GB each, measured at 11 GB across 15). The hard cap is 4 because beyond
134
+ # that the bottleneck stops being this machine.
135
+ ma_ap_slot_ceiling() {
136
+ local ram_gb cores free_gb a b c
137
+ ram_gb=$(( $(sysctl -n hw.memsize 2>/dev/null || echo 0) / 1073741824 ))
138
+ cores=$(sysctl -n hw.ncpu 2>/dev/null || echo 2)
139
+ free_gb=$(df -g "$HOME" 2>/dev/null | awk 'NR==2{print $4}')
140
+ [ -n "$free_gb" ] || free_gb=0
141
+ a=$(((ram_gb - 4) * 10 / 25))
142
+ b=$((cores / 2))
143
+ c=$(((free_gb - 20) * 100 / 75))
144
+ local m=$a
145
+ [ "$b" -lt "$m" ] && m=$b
146
+ [ "$c" -lt "$m" ] && m=$c
147
+ [ "$m" -gt 4 ] && m=4
148
+ [ "$m" -lt 1 ] && m=1
149
+ printf '%s\n' "$m"
150
+ }
@@ -49,8 +49,23 @@
49
49
 
50
50
  set -euo pipefail
51
51
 
52
+ # Sourcing this file hands a caller the provider helpers - `jira_search`,
53
+ # `fetch_jira`, `fetch_github` and the `jira_curl` underneath them - without
54
+ # resolving anything. Executing it resolves one input, exactly as before.
55
+ #
56
+ # The guard exists because the alternative is worse than it looks: a second
57
+ # consumer of `jira_search` with no way to source would have to copy
58
+ # `jira_curl`, and that function is not a URL - it is the credential resolution,
59
+ # the host lookup and the rule that keeps a token off argv. Three copies of that
60
+ # is three places for a token to leak.
61
+ MA_IF_EXECUTED=0
62
+ [ "${BASH_SOURCE[0]}" = "$0" ] && MA_IF_EXECUTED=1
63
+
52
64
  INPUT="${1:-}"
53
- [ -z "$INPUT" ] && { echo '{"error":"input required"}'; exit 1; }
65
+ if [ "$MA_IF_EXECUTED" = 1 ] && [ -z "$INPUT" ]; then
66
+ echo '{"error":"input required"}'
67
+ exit 1
68
+ fi
54
69
 
55
70
  JIRA_TOKEN_KEY="${ACCOUNT_JIRA_TOKEN_KEY:-}"
56
71
  JIRA_HOST="${ACCOUNT_JIRA_HOST:-}"
@@ -361,6 +376,7 @@ PY
361
376
  }
362
377
 
363
378
  # --- Dispatch -----------------------------------------------------------------
379
+ if [ "$MA_IF_EXECUTED" = 1 ]; then
364
380
  case "$KIND" in
365
381
  jira-id)
366
382
  KEY="$INPUT"
@@ -592,3 +608,4 @@ sys.stdout.write("\x1f".join(parts))
592
608
  "description=" "branchHint=$branch"
593
609
  ;;
594
610
  esac
611
+ fi
@@ -94,6 +94,24 @@ do_set() {
94
94
  if [ -z "$plan_blob" ] || [ "$plan_blob" = "-" ]; then
95
95
  plan_blob=$(cat)
96
96
  fi
97
+ # A planning-output document is accepted directly and converted here.
98
+ #
99
+ # The conversion used to live as a jq blob inside phase-2-planning.md, which
100
+ # made the mapping from `tasks[]` to `todos[]` a thing two files defined - and
101
+ # the phase doc was the copy nothing tested. Accepting both shapes costs four
102
+ # lines and removes the second definition.
103
+ if jq -e '.tasks and (.todos | not)' <<<"$plan_blob" >/dev/null 2>&1; then
104
+ plan_blob=$(jq '{
105
+ title: (.summary // .title // "plan"),
106
+ todos: [ .tasks[] | {
107
+ id: .id,
108
+ task: (.title // .subject // ""),
109
+ status: "pending",
110
+ deps: (.dependsOn // .blockedBy // [])
111
+ } ]
112
+ }' <<<"$plan_blob")
113
+ fi
114
+
97
115
  # Validate against schema (best-effort - jq syntax check, then required-field probe).
98
116
  if ! jq -e '.title and (.todos | type == "array")' <<<"$plan_blob" >/dev/null 2>&1; then
99
117
  echo "plan-todos: input must be an object with .title (string) and .todos (array)" >&2
@@ -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