@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.
- package/CHANGELOG.md +110 -0
- package/README.md +148 -103
- package/README.tr.md +149 -103
- package/docs/adr/0011-dormant-ci.md +10 -1
- package/docs/adr/0012-macos-only.md +98 -0
- package/docs/adr/README.md +1 -0
- package/docs/architecture.md +3 -3
- package/docs/ecosystem.md +5 -5
- package/docs/engineering.md +1 -1
- package/index.js +26 -0
- package/install/_dev-only-files.mjs +0 -1
- package/install/index.mjs +10 -0
- package/install/templates/multi-agent-autopilot.plist.template +79 -0
- package/package.json +5 -3
- package/pipeline/commands/multi-agent/autopilot-off/SKILL.md +64 -0
- package/pipeline/commands/multi-agent/autopilot-on/SKILL.md +173 -0
- package/pipeline/commands/multi-agent/autopilot-status/SKILL.md +74 -0
- package/pipeline/commands/multi-agent/channels/SKILL.md +41 -12
- package/pipeline/commands/multi-agent/garbage-collect/SKILL.md +40 -3
- package/pipeline/commands/multi-agent/help/SKILL.md +43 -37
- package/pipeline/commands/multi-agent/manual-test/SKILL.md +1 -1
- package/pipeline/commands/multi-agent/setup/SKILL.md +15 -7
- package/pipeline/commands/multi-agent/stack/SKILL.md +31 -32
- package/pipeline/commands/multi-agent/status/SKILL.md +17 -1
- package/pipeline/commands/multi-agent/sync/SKILL.md +34 -28
- package/pipeline/commands/multi-agent/update/SKILL.md +1 -1
- package/pipeline/lib/autopilot-activation.sh +117 -0
- package/pipeline/lib/autopilot-state.sh +150 -0
- package/pipeline/lib/issue-fetcher.sh +18 -1
- package/pipeline/lib/plan-todos.sh +18 -0
- package/pipeline/lib/stack-detect.sh +200 -0
- package/pipeline/multi-agent-refs/channels/jira.md +80 -20
- package/pipeline/multi-agent-refs/channels/pr.md +65 -19
- package/pipeline/multi-agent-refs/cross-cli-contract.md +35 -15
- package/pipeline/multi-agent-refs/features/doctor.md +15 -3
- package/pipeline/multi-agent-refs/features/visual-evidence.md +61 -1
- package/pipeline/multi-agent-refs/phases/phase-0-init.md +14 -5
- package/pipeline/multi-agent-refs/phases/phase-1-analysis.md +26 -12
- package/pipeline/multi-agent-refs/phases/phase-2-planning.md +17 -15
- package/pipeline/multi-agent-refs/phases/phase-3-dev.md +7 -5
- package/pipeline/multi-agent-refs/phases/phase-6-commit.md +1 -1
- package/pipeline/multi-agent-refs/phases/phase-7-report.md +2 -3
- package/pipeline/multi-agent-refs/readiness-review.md +7 -1
- package/pipeline/multi-agent-refs/rules.md +3 -11
- package/pipeline/multi-agent-refs/tracker-contract.md +32 -0
- package/pipeline/schemas/agent-state.schema.json +99 -25
- package/pipeline/schemas/autopilot-config.schema.json +149 -0
- package/pipeline/schemas/token-budget.json +4 -4
- package/pipeline/scripts/_stack-routing.mjs +91 -0
- package/pipeline/scripts/autopilot-arming.mjs +147 -0
- package/pipeline/scripts/autopilot-intake.mjs +383 -0
- package/pipeline/scripts/autopilot-menubar.swift +361 -0
- package/pipeline/scripts/autopilot-runner.mjs +349 -0
- package/pipeline/scripts/autopilot-status.sh +212 -0
- package/pipeline/scripts/capture-resume.sh +76 -14
- package/pipeline/scripts/doctor.mjs +26 -3
- package/pipeline/scripts/gc-abandoned.sh +352 -0
- package/pipeline/scripts/jira-search.sh +70 -0
- package/pipeline/scripts/phase-tracker.sh +134 -12
- package/pipeline/scripts/probe-evidence-capability.sh +27 -3
- package/pipeline/scripts/run-ui-tests.sh +113 -4
- package/pipeline/scripts/usage-report.mjs +5 -5
- package/pipeline/skills/.skill-manifest.json +16 -4
- package/pipeline/skills/shared/core/multi-agent-autopilot-off/SKILL.md +67 -0
- package/pipeline/skills/shared/core/multi-agent-autopilot-on/SKILL.md +146 -0
- package/pipeline/skills/shared/core/multi-agent-autopilot-status/SKILL.md +64 -0
- package/pipeline/skills/shared/core/multi-agent-channels/SKILL.md +62 -11
- package/pipeline/skills/shared/core/multi-agent-sync/SKILL.md +9 -8
- 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
|
-
[
|
|
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
|