@mmerterden/multi-agent-pipeline 20.19.0 → 20.21.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 (46) hide show
  1. package/CHANGELOG.md +44 -0
  2. package/README.md +25 -14
  3. package/README.tr.md +22 -11
  4. package/docs/ecosystem.md +14 -8
  5. package/docs/facts.json +7 -6
  6. package/docs/features.md +2 -1
  7. package/install/catalog-history.json +1 -1
  8. package/manifest.json +49 -46
  9. package/package.json +1 -1
  10. package/pipeline/commands/multi-agent/SKILL.md +1 -1
  11. package/pipeline/commands/multi-agent/route-on/SKILL.md +32 -8
  12. package/pipeline/commands/multi-agent/route-status/SKILL.md +12 -2
  13. package/pipeline/commands/multi-agent/setup/SKILL.md +2 -1
  14. package/pipeline/lib/model-dispatch.sh +72 -24
  15. package/pipeline/lib/outbound-gate.mjs +2 -0
  16. package/pipeline/lib/route-state.sh +118 -10
  17. package/pipeline/lib/toolkit-llm.sh +30 -0
  18. package/pipeline/lib/unattended-profile-check.mjs +23 -2
  19. package/pipeline/multi-agent-refs/analysis/evidence.md +3 -1
  20. package/pipeline/multi-agent-refs/cross-cli-contract.md +12 -3
  21. package/pipeline/multi-agent-refs/features/doctor.md +11 -0
  22. package/pipeline/multi-agent-refs/features/exploratory-testing.md +7 -0
  23. package/pipeline/multi-agent-refs/features/external-context-injection.md +1 -1
  24. package/pipeline/multi-agent-refs/features/free-models.md +111 -0
  25. package/pipeline/multi-agent-refs/features/model-fallback.md +21 -15
  26. package/pipeline/multi-agent-refs/features/unattended-security.md +7 -4
  27. package/pipeline/multi-agent-refs/refactor/toolkit-research.md +3 -1
  28. package/pipeline/multi-agent-refs/setup/free-models.md +22 -0
  29. package/pipeline/preferences-template.json +1 -0
  30. package/pipeline/schemas/prefs.schema.json +10 -1
  31. package/pipeline/schemas/route-config.schema.json +33 -8
  32. package/pipeline/schemas/secret-patterns.json +14 -0
  33. package/pipeline/schemas/unattended-profile.json +1 -1
  34. package/pipeline/scripts/bulk-read.sh +98 -12
  35. package/pipeline/scripts/cost-table.json +9 -1
  36. package/pipeline/scripts/doctor.mjs +100 -0
  37. package/pipeline/scripts/pre-commit-check.sh +1 -1
  38. package/pipeline/scripts/unattended_policy.py +28 -0
  39. package/pipeline/skills/.skill-manifest.json +8 -8
  40. package/pipeline/skills/shared/core/multi-agent/SKILL.md +2 -0
  41. package/pipeline/skills/shared/core/multi-agent-route-on/SKILL.md +31 -7
  42. package/pipeline/skills/shared/core/multi-agent-route-status/SKILL.md +12 -2
  43. package/pipeline/skills/shared/core/multi-agent-setup/SKILL.md +1 -0
  44. package/pipeline/skills/shared/external/e2e-testing/SKILL.md +39 -7
  45. package/pipeline/skills/shared/external/signal-community/SKILL.md +7 -1
  46. package/pipeline/skills/shared/external/web-testing/SKILL.md +57 -0
@@ -9,11 +9,11 @@
9
9
  # model-dispatch.sh <call-site> [--persona P] [--phase N] [--task-kind K]
10
10
  # [--default RUNG] [--task-id ID]
11
11
  #
12
- # <call-site> is one of the `scope` members: subagent | bulk-read | research.
13
- # stdout is a single rung NAME. Rung names are the contract; the model id behind
14
- # one lives in cost-table.json and moves without a config edit.
12
+ # <call-site> is one of the `scope` members: subagent | bulk-read | delegate |
13
+ # research. stdout is a single rung NAME. Rung names are the contract; the model
14
+ # id behind one lives in cost-table.json and moves without a config edit.
15
15
  #
16
- # Exit is always 0 and stdout is always a usable rung. A router that can fail
16
+ # Exit is always 0 and stdout is always a usable answer. A router that can fail
17
17
  # turns every call site into a place the run can die, for a feature that ships
18
18
  # disabled; when anything is missing, unparseable or out of scope, the caller's
19
19
  # default comes back unchanged.
@@ -22,16 +22,18 @@
22
22
  #
23
23
  # Layer 1 an Anthropic rung. Reachable from every call site, because it
24
24
  # writes to a seam that already exists and opens no new network path.
25
- # Layer 2 a non-Anthropic rung. NO call site can dispatch to one today, and
26
- # the router refuses it rather than returning a rung its caller will
27
- # hand to a CLI that does not know the name:
28
- # - a subagent is dispatched by the HOST;
29
- # - bulk-read shells out to `claude -p --model <rung>`;
30
- # - research_ask runs in the MCP server and picks its own model.
31
- # `provider` in cost-table.json is what makes this checkable, and it
32
- # is the seam a future call site would use. Until one exists, the
33
- # refusal is the honest answer: an allowed rung nothing can honour is
34
- # a rule the user believes worked.
25
+ # Layer 2 the `free` rung: the multi-agent-toolkit's verified free-model
26
+ # chain. Reachable only where the pipeline makes the call itself -
27
+ # bulk-read and delegate - and only with modelRouting.free.enabled.
28
+ # Its caller must keep its default as the fallback, because the chain
29
+ # answers ESCALATE_TO_CLAUDE whenever no free answer survives
30
+ # verification. A subagent is dispatched by the HOST, so `free` and
31
+ # every other non-Anthropic rung is refused there and the rule's next
32
+ # rung answers instead.
33
+ #
34
+ # Strategies: manual keeps the rule's order; task-fit tries the cheapest rung
35
+ # first; cost-ceiling keeps the order until cost-budget-check.mjs reports the run
36
+ # at its warn threshold of budgetCeilingUsd, then tries the cheapest first.
35
37
 
36
38
  set -uo pipefail
37
39
 
@@ -74,7 +76,7 @@ fi
74
76
  [ "$DEFAULT_RUNG" = "fable" ] && [ "$FABLE_ON" != "true" ] && DEFAULT_RUNG="opus"
75
77
 
76
78
  case "$CALL_SITE" in
77
- subagent|bulk-read|research) ;;
79
+ subagent|bulk-read|delegate|research) ;;
78
80
  *) emit "$DEFAULT_RUNG" ;;
79
81
  esac
80
82
 
@@ -103,11 +105,45 @@ MATCH=$(jq -r \
103
105
  and ((.when.taskKind // null) as $k | $k == null or $k == $kind)
104
106
  )
105
107
  ] | first | (.prefer // []) | join(" ")' "$PREFS" 2>/dev/null) || emit "$DEFAULT_RUNG"
106
- [ -n "$MATCH" ] && [ "$MATCH" != "null" ] || emit "$DEFAULT_RUNG"
108
+ [ "$MATCH" = "null" ] && MATCH=""
109
+
110
+ FREE_ON=$(jq -r '.global.modelRouting.free.enabled // false' "$PREFS" 2>/dev/null)
111
+ CANDIDATES="$MATCH"
112
+ case "$CALL_SITE" in
113
+ bulk-read|delegate) [ "$FREE_ON" = "true" ] && CANDIDATES="free $MATCH" ;;
114
+ esac
115
+ [ -n "${CANDIDATES// /}" ] || emit "$DEFAULT_RUNG"
107
116
 
108
117
  HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
109
118
  COST_TABLE="$HERE/../scripts/cost-table.json"
110
119
 
120
+ STRATEGY=$(jq -r '.global.modelRouting.strategy // "manual"' "$PREFS" 2>/dev/null)
121
+ CHEAPEST_FIRST=false
122
+ STRATEGY_NOTE=""
123
+ case "$STRATEGY" in
124
+ task-fit) CHEAPEST_FIRST=true; STRATEGY_NOTE="task-fit" ;;
125
+ cost-ceiling)
126
+ CEILING=$(jq -r '.global.modelRouting.budgetCeilingUsd // empty' "$PREFS" 2>/dev/null)
127
+ if [ -n "$CEILING" ] && [ "$TASK_ID" != "unknown" ] && command -v node >/dev/null 2>&1 \
128
+ && [ -f "$HERE/../scripts/cost-budget-check.mjs" ]; then
129
+ node "$HERE/../scripts/cost-budget-check.mjs" --task-id "$TASK_ID" --max-usd "$CEILING" --enabled >/dev/null 2>&1
130
+ case $? in
131
+ 10|11) CHEAPEST_FIRST=true; STRATEGY_NOTE="cost-ceiling reached" ;;
132
+ esac
133
+ fi
134
+ ;;
135
+ esac
136
+
137
+ if [ "$CHEAPEST_FIRST" = "true" ] && [ -f "$COST_TABLE" ]; then
138
+ CANDIDATES=$(
139
+ jq -r --arg c "$CANDIDATES" '
140
+ .prices as $p
141
+ | ($c | split(" ") | map(select(length > 0)))
142
+ | to_entries
143
+ | sort_by([($p[.value].inPerMtok // 1e9), .key])
144
+ | map(.value) | join(" ")' "$COST_TABLE" 2>/dev/null || printf '%s' "$CANDIDATES")
145
+ fi
146
+
111
147
  # The fable rung answers only when the fable switch is on (FABLE_ON, read
112
148
  # above). Routing is a policy over what is available; it is not a second way to
113
149
  # turn a rung on, or `/multi-agent:model off` would stop meaning anything the
@@ -116,13 +152,13 @@ COST_TABLE="$HERE/../scripts/cost-table.json"
116
152
  record() {
117
153
  [ -x "$HERE/../scripts/log-metric.sh" ] || return 0
118
154
  local rec
119
- rec=$(jq -r '.global.modelRouting.recordDecisions // true' "$PREFS" 2>/dev/null)
155
+ rec=$(jq -r 'if .global.modelRouting.recordDecisions == false then "false" else "true" end' "$PREFS" 2>/dev/null)
120
156
  [ "$rec" = "false" ] && return 0
121
157
  "$HERE/../scripts/log-metric.sh" "$TASK_ID" "${PHASE:-0}" model_routing.decision \
122
158
  call_site="$CALL_SITE" rung="$1" reason="$2" >/dev/null 2>&1 || true
123
159
  }
124
160
 
125
- for rung in $MATCH; do
161
+ for rung in $CANDIDATES; do
126
162
  if [ ! -f "$COST_TABLE" ]; then
127
163
  record "$rung" "no cost table; rung taken on trust"
128
164
  emit "$rung"
@@ -139,15 +175,27 @@ for rung in $MATCH; do
139
175
  echo "model-dispatch: rule prefers 'fable' but modelFallback.fableEnabled is false; skipping it" >&2
140
176
  continue
141
177
  fi
178
+ if [ "$PROVIDER" = "free-chain" ]; then
179
+ if [ "$FREE_ON" != "true" ]; then
180
+ echo "model-dispatch: rule prefers 'free' but modelRouting.free.enabled is false (route-on --free); skipping it" >&2
181
+ continue
182
+ fi
183
+ case "$CALL_SITE" in
184
+ bulk-read|delegate)
185
+ record "$rung" "free chain${STRATEGY_NOTE:+, $STRATEGY_NOTE}"
186
+ emit "$rung" ;;
187
+ esac
188
+ echo "model-dispatch: rung 'free' is reachable only at bulk-read and delegate; a $CALL_SITE is dispatched by the host, skipping it" >&2
189
+ continue
190
+ fi
142
191
  if [ "$PROVIDER" != "anthropic" ]; then
143
- # The honest limit, said out loud at the moment it bites. Every call site
144
- # that asks here hands the rung to something that only speaks the Anthropic
145
- # ladder, so returning an external rung would produce a failed call rather
146
- # than a cheaper one.
147
- echo "model-dispatch: rung '$rung' is $PROVIDER and no call site can dispatch to one yet; skipping it" >&2
192
+ # Every call site that asks here hands any other rung to something that
193
+ # speaks the Anthropic ladder or the free chain, so returning an external
194
+ # rung would produce a failed call rather than a cheaper one.
195
+ echo "model-dispatch: rung '$rung' is $PROVIDER and no call site dispatches to it; skipping it" >&2
148
196
  continue
149
197
  fi
150
- record "$rung" "rule matched"
198
+ record "$rung" "rule matched${STRATEGY_NOTE:+, $STRATEGY_NOTE}"
151
199
  emit "$rung"
152
200
  done
153
201
 
@@ -82,6 +82,8 @@ export const RULES = [
82
82
  { name: "stripe-key", certain: true, re: /\b[sr]k_live_[A-Za-z0-9]{20,}\b/g },
83
83
  { name: "perplexity-key", certain: true, re: /\bpplx-[A-Za-z0-9]{32,}\b/g },
84
84
  { name: "huggingface-token", certain: true, re: /\bhf_[A-Za-z0-9]{30,}\b/g },
85
+ { name: "groq-key", certain: true, re: /\bgsk_[A-Za-z0-9]{40,}\b/g },
86
+ { name: "openrouter-key", certain: true, re: /\bsk-or-v1-[A-Za-z0-9]{40,}\b/g },
85
87
  {
86
88
  name: "service-account-json",
87
89
  certain: true,
@@ -10,10 +10,11 @@
10
10
  # for a configuration the user already gave.
11
11
  #
12
12
  # Usage:
13
- # route-state.sh status print the current policy
14
- # route-state.sh on [--strategy=S] [--scope=a,b]
13
+ # route-state.sh status print the current policy and savings
14
+ # route-state.sh on [--strategy=S] [--scope=a,b] [--free [--allow-training]] [--no-free]
15
15
  # route-state.sh off
16
- # route-state.sh set-rules <file.json> replace rules[] from a JSON array
16
+ # route-state.sh set-rules <file.json> replace rules[] from a JSON array,
17
+ # checked against the schema and cost table
17
18
  #
18
19
  # Exit codes:
19
20
  # 0 - done
@@ -34,7 +35,7 @@ if [ -f "$_MA_RS_HERE/user-facing.sh" ]; then . "$_MA_RS_HERE/user-facing.sh"; e
34
35
  # included - through a third layer, breaks the subscription's auth model, and
35
36
  # silently changes which model answers. The schema encodes the same list so a
36
37
  # hand-edited preferences file is rejected too, rather than only this entry point.
37
- ALLOWED_SCOPE="subagent bulk-read research"
38
+ ALLOWED_SCOPE="subagent bulk-read delegate research"
38
39
 
39
40
  die() { echo "route-state: $1" >&2; exit "${2:-3}"; }
40
41
 
@@ -47,6 +48,9 @@ jq -e . "$PREFS" >/dev/null 2>&1 || die "$PREFS is not valid JSON - refusing t
47
48
 
48
49
  ACTION="${1:-status}"; shift 2>/dev/null || true
49
50
 
51
+ COST_TABLE="$_MA_RS_HERE/../scripts/cost-table.json"
52
+ USAGE_LOG="${MCP_TOOLKIT_INDEX_DIR:-$HOME/.claude/logs/multi-agent-toolkit/.index}/llm-usage.jsonl"
53
+
50
54
  write_prefs() {
51
55
  local filter="$1"; shift
52
56
  local tmp
@@ -56,6 +60,57 @@ write_prefs() {
56
60
  mv "$tmp" "$PREFS"
57
61
  }
58
62
 
63
+ # Last seven days of the toolkit's usage logs (attended, and an unattended run's
64
+ # own under ~/.multi-agent-unattended): verified answers, cache hits and
65
+ # escalations, and what those tokens would have cost on haiku, the rung bulk-read
66
+ # and simple tasks fall back to. An estimate, and labelled as one.
67
+ free_savings() {
68
+ local logs=() f
69
+ for f in "$USAGE_LOG" "$HOME/.multi-agent-unattended/toolkit-index/llm-usage.jsonl"; do
70
+ [ -f "$f" ] && logs+=("$f")
71
+ done
72
+ if [ "${#logs[@]}" -eq 0 ]; then
73
+ echo "savings: no free-model task recorded yet ($USAGE_LOG)"
74
+ return 0
75
+ fi
76
+ local since
77
+ since=$(( $(date +%s) - 7 * 86400 ))
78
+ jq -rs --argjson since "$since" --slurpfile ct "$COST_TABLE" '
79
+ map(select((.ts | sub("\\.[0-9]+Z$"; "Z") | fromdateiso8601? // 0) >= $since))
80
+ | ($ct[0].prices.haiku // {inPerMtok: 1, outPerMtok: 5}) as $h
81
+ | {
82
+ n: length,
83
+ ok: (map(select(.ok and (.cached | not))) | length),
84
+ cached: (map(select(.ok and .cached)) | length),
85
+ esc: (map(select(.ok | not)) | length),
86
+ pin: (map(.prompt_tokens // 0) | add // 0),
87
+ pout: (map(.completion_tokens // 0) | add // 0),
88
+ by: (map(select(.ok and .provider)) | group_by(.provider) | map("\(.[0].provider) \(length)") | join(", "))
89
+ }
90
+ | . + {usd: ((.pin * $h.inPerMtok + .pout * $h.outPerMtok) / 1000000)}
91
+ | "savings (7 days): \(.n) task(s), \(.ok) answered free and verified, \(.cached) from cache, \(.esc) handed back to Claude\n"
92
+ + " tokens kept off Claude: \(.pin) in + \(.pout) out (~$\(.usd * 100 | round / 100) at haiku rates, an estimate)"
93
+ + (if .by != "" then "\n by provider: \(.by)" else "" end)
94
+ ' "${logs[@]}" 2>/dev/null || echo "savings: the usage log could not be read"
95
+ }
96
+
97
+ # What the toolkit can reach, without a single network call: configured
98
+ # providers and where their keys come from. Names only, never values.
99
+ free_providers() {
100
+ local tl="$_MA_RS_HERE/toolkit-llm.sh"
101
+ [ -f "$tl" ] || tl="$HOME/.claude/lib/toolkit-llm.sh"
102
+ [ -f "$tl" ] || { echo "free chain: toolkit-llm.sh is not installed"; return 1; }
103
+ # shellcheck source=/dev/null
104
+ . "$tl"
105
+ local out
106
+ out=$(ma_toolkit_llm providers ${1:+--allow-training} 2>/dev/null) || { echo "free chain: the multi-agent-toolkit CLI could not be run (node and npx are needed)"; return 1; }
107
+ jq -r '
108
+ "free chain: \(.configured) provider(s) usable under the \(.data_policy) data policy",
109
+ (.providers[] | " \(if .configured and .allowed_by_policy then "ready " elif .configured then "policy " else "missing" end) \(.id)\(if .key_source then " (" + .key_source + ")" else "" end)\(if (.configured | not) then " - " + .setup else "" end)")
110
+ ' <<< "$out"
111
+ [ "$(jq -r '.configured' <<< "$out")" -gt 0 ]
112
+ }
113
+
59
114
  case "$ACTION" in
60
115
  status)
61
116
  ENABLED=$(jq -r '.global.modelRouting.enabled // false' "$PREFS")
@@ -63,12 +118,16 @@ case "$ACTION" in
63
118
  SCOPE=$(jq -r '(.global.modelRouting.scope // ["subagent"]) | join(", ")' "$PREFS")
64
119
  NRULES=$(jq -r '(.global.modelRouting.rules // []) | length' "$PREFS")
65
120
  CEIL=$(jq -r '.global.modelRouting.budgetCeilingUsd // "none"' "$PREFS")
66
- REC=$(jq -r '.global.modelRouting.recordDecisions // true' "$PREFS")
121
+ REC=$(jq -r 'if .global.modelRouting.recordDecisions == false then "false" else "true" end' "$PREFS")
122
+
123
+ FREE=$(jq -r '.global.modelRouting.free.enabled // false' "$PREFS")
124
+ TRAIN=$(jq -r '.global.modelRouting.free.allowTraining // false' "$PREFS")
67
125
 
68
126
  echo "routing: $ENABLED"
69
127
  echo "strategy: $STRATEGY"
70
128
  echo "scope: $SCOPE"
71
129
  echo "rules: $NRULES"
130
+ echo "free: $FREE$([ "$FREE" = "true" ] && echo " (data policy: $([ "$TRAIN" = "true" ] && echo any || echo no-training))")"
72
131
  echo "ceiling: $CEIL"
73
132
  echo "decisions: $REC"
74
133
  echo ""
@@ -79,34 +138,46 @@ case "$ACTION" in
79
138
  exit 0
80
139
  fi
81
140
 
82
- if [ "$NRULES" -eq 0 ]; then
141
+ if [ "$NRULES" -eq 0 ] && [ "$FREE" != "true" ]; then
83
142
  echo "Armed with no rules. Nothing matches, so nothing is routed - this is a"
84
143
  echo "configuration state, not an error."
85
- else
144
+ elif [ "$NRULES" -gt 0 ]; then
86
145
  jq -r '(.global.modelRouting.rules // [])[] |
87
146
  " when " + ([.when | to_entries[] | "\(.key)=\(.value)"] | join(" ")) +
88
147
  " -> " + (.prefer | join(" > "))' "$PREFS"
89
148
  fi
90
149
 
150
+ if [ "$FREE" = "true" ]; then
151
+ echo " free chain first at: bulk-read, delegate (Anthropic default when it escalates)"
152
+ echo ""
153
+ free_savings
154
+ fi
155
+
91
156
  echo ""
92
157
  # The limit is printed every time rather than documented once, because the
93
158
  # question it answers ("routing is on, why is the reviewer still on Opus")
94
159
  # otherwise arrives days later as a bug report.
95
160
  echo "Honest limit: on Claude Code a subagent cannot be sent to a non-Anthropic"
96
161
  echo "model - subagent dispatch belongs to the host. Phase 1/2/3 personas stay"
97
- echo "inside the Anthropic ladder whatever the rules say. External providers apply"
98
- echo "only where this pipeline makes the call itself (bulk-read, research)."
162
+ echo "inside the Anthropic ladder whatever the rules say. Free models answer only"
163
+ echo "where this pipeline makes the call itself (bulk-read, delegate), only after"
164
+ echo "the toolkit verified the answer against its source, and Claude takes over"
165
+ echo "whenever none passes."
99
166
  ;;
100
167
 
101
168
  on)
102
- STRATEGY="manual"; SCOPE_ARG=""
169
+ STRATEGY="manual"; SCOPE_ARG=""; FREE_ARG=""; TRAIN_ARG=false
103
170
  for arg in "$@"; do
104
171
  case "$arg" in
105
172
  --strategy=*) STRATEGY="${arg#*=}" ;;
106
173
  --scope=*) SCOPE_ARG="${arg#*=}" ;;
174
+ --free) FREE_ARG=on ;;
175
+ --no-free) FREE_ARG=off ;;
176
+ --allow-training) TRAIN_ARG=true ;;
107
177
  *) die "unknown option: $arg" 2 ;;
108
178
  esac
109
179
  done
180
+ [ "$TRAIN_ARG" = "true" ] && [ "$FREE_ARG" != "on" ] && die "--allow-training only goes with --free" 2
110
181
  case "$STRATEGY" in
111
182
  manual|task-fit|cost-ceiling) ;;
112
183
  *) die "strategy must be manual, task-fit or cost-ceiling (got '$STRATEGY')" 2 ;;
@@ -138,6 +209,26 @@ case "$ACTION" in
138
209
  | .global.modelRouting.scope //= ["subagent"]
139
210
  ' --arg st "$STRATEGY"
140
211
  fi
212
+ if [ "$FREE_ARG" = "on" ]; then
213
+ write_prefs '
214
+ .global.modelRouting.free = {enabled: true, allowTraining: $tr}
215
+ | .global.modelRouting.scope = (((.global.modelRouting.scope // ["subagent"]) + ["bulk-read", "delegate"]) | unique)
216
+ ' --argjson tr "$TRAIN_ARG"
217
+ echo "free chain enabled at bulk-read and delegate (data policy: $([ "$TRAIN_ARG" = "true" ] && echo any || echo no-training))"
218
+ TRAIN_FLAG=""
219
+ [ "$TRAIN_ARG" = "true" ] && TRAIN_FLAG=1
220
+ if ! free_providers "$TRAIN_FLAG"; then
221
+ echo ""
222
+ echo "No free provider is usable yet, so every task still runs on Claude. Add one key"
223
+ echo "(free at https://console.groq.com/keys) with $(ma_command setup), or from a shell:"
224
+ echo " bash \"\$HOME/.claude/lib/credential-store.sh\" set llm-groq - (paste the key, then Ctrl-D)"
225
+ echo "or export GROQ_API_KEY for the server, then run $(ma_command route-status)."
226
+ fi
227
+ echo ""
228
+ elif [ "$FREE_ARG" = "off" ]; then
229
+ write_prefs '.global.modelRouting.free.enabled = false'
230
+ echo "free chain disabled; Anthropic rungs only"
231
+ fi
141
232
  echo "routing enabled (strategy: $STRATEGY)"
142
233
  exec "$0" status
143
234
  ;;
@@ -154,6 +245,23 @@ case "$ACTION" in
154
245
  [ -n "$FILE" ] || die "set-rules needs a JSON file containing an array of rules" 2
155
246
  [ -f "$FILE" ] || die "no such file: $FILE" 2
156
247
  jq -e 'type == "array"' "$FILE" >/dev/null 2>&1 || die "$FILE must contain a JSON ARRAY of rules" 2
248
+ # The schema's rule shape, enforced here so a hand-written file cannot store
249
+ # a rule that dispatch silently skips: when has 1-3 known keys with valid
250
+ # values, prefer is a non-empty list of rungs the cost table prices.
251
+ RUNGS=$(jq -c '.prices | keys' "$COST_TABLE" 2>/dev/null || echo '[]')
252
+ BAD=$(jq -r --argjson rungs "$RUNGS" '
253
+ to_entries[] | .key as $i | .value as $r
254
+ | if ($r | type) != "object" then "rule \($i): not an object"
255
+ elif (($r | keys) - ["when", "prefer"]) != [] then "rule \($i): unknown key(s) \((($r | keys) - ["when", "prefer"]) | join(", "))"
256
+ elif ($r.when | type) != "object" or ($r.when | length) == 0 then "rule \($i): when needs persona, phase or taskKind"
257
+ elif (($r.when | keys) - ["persona", "phase", "taskKind"]) != [] then "rule \($i): when has unknown key(s) \((($r.when | keys) - ["persona", "phase", "taskKind"]) | join(", "))"
258
+ elif ($r.when.persona != null and ($r.when.persona | type) != "string") then "rule \($i): persona must be a string"
259
+ elif ($r.when.phase != null and (($r.when.phase | type) != "number" or $r.when.phase < 0 or $r.when.phase > 5 or ($r.when.phase | floor) != $r.when.phase)) then "rule \($i): phase must be an integer 0-5"
260
+ elif ($r.when.taskKind != null and ([$r.when.taskKind] | inside(["bugfix", "feature", "refactor", "chore", "component"]) | not)) then "rule \($i): taskKind must be bugfix, feature, refactor, chore or component"
261
+ elif ($r.prefer | type) != "array" or ($r.prefer | length) == 0 then "rule \($i): prefer needs at least one rung"
262
+ elif ([$r.prefer[] | select((type != "string") or (. as $x | $rungs | index($x) | not))] | length) > 0 then "rule \($i): unknown rung(s) \([$r.prefer[] | select((type != "string") or (. as $x | $rungs | index($x) | not))] | map(tostring) | join(", ")) (known: \($rungs | join(", ")))"
263
+ else empty end' "$FILE" 2>/dev/null)
264
+ [ -z "$BAD" ] || die "$(printf 'rules rejected, nothing written:\n%s' "$BAD")" 2
157
265
  write_prefs '.global.modelRouting //= {} | .global.modelRouting.rules = $r' --slurpfile _ignore /dev/null --argjson r "$(cat "$FILE")"
158
266
  echo "rules replaced: $(jq -r 'length' "$FILE")"
159
267
  ;;
@@ -0,0 +1,30 @@
1
+ #!/usr/bin/env bash
2
+ # toolkit-llm.sh - run the multi-agent-toolkit's free-model CLI.
3
+ #
4
+ # Sourced by bulk-read.sh and route-state.sh. `ma_toolkit_llm <args>` runs
5
+ # `multi-agent-toolkit-llm <args>` from the first place it exists:
6
+ #
7
+ # 1. MULTI_AGENT_TOOLKIT_LLM a path to the CLI script
8
+ # 2. multi-agent-toolkit-llm on PATH a global install
9
+ # 3. npx -p @mmerterden/multi-agent-toolkit-mcp@latest, the same package
10
+ # the MCP registration runs
11
+ #
12
+ # A maintainer's working copy (prefs.global.devToolkit) is deliberately not a
13
+ # source: it can sit on an unreleased branch, and a run should use what ships.
14
+ #
15
+ # The CLI prints one JSON object and exits 0 on a verified answer, 3 on
16
+ # ESCALATE_TO_CLAUDE, 2 on a usage error. Any other status (no node, no network
17
+ # for npx) is the caller's cue to keep its own default.
18
+
19
+ ma_toolkit_llm() {
20
+ if [ -n "${MULTI_AGENT_TOOLKIT_LLM:-}" ] && [ -f "$MULTI_AGENT_TOOLKIT_LLM" ]; then
21
+ node "$MULTI_AGENT_TOOLKIT_LLM" "$@"
22
+ return $?
23
+ fi
24
+ if command -v multi-agent-toolkit-llm >/dev/null 2>&1; then
25
+ multi-agent-toolkit-llm "$@"
26
+ return $?
27
+ fi
28
+ command -v npx >/dev/null 2>&1 || return 127
29
+ npx -y --@mmerterden:registry=https://registry.npmjs.org -p @mmerterden/multi-agent-toolkit-mcp@latest multi-agent-toolkit-llm "$@"
30
+ }
@@ -22,9 +22,22 @@ import { join } from "node:path";
22
22
 
23
23
  const PREFS_HOST_KEYS = ["jira", "confluence", "bitbucket"];
24
24
 
25
+ // The free-model hosts a sandboxed bulk-read may reach once route-on --free is
26
+ // on: Groq alone under the default no-training policy, all five with
27
+ // allowTraining. A local Ollama needs no host; allowLocalBinding covers it.
28
+ const FREE_HOSTS_NO_TRAINING = ["api.groq.com"];
29
+ const FREE_HOSTS_ANY = [
30
+ "api.groq.com",
31
+ "api.mistral.ai",
32
+ "generativelanguage.googleapis.com",
33
+ "openrouter.ai",
34
+ "api.z.ai",
35
+ ];
36
+
25
37
  /**
26
38
  * Hosts from prefs.global.hosts, so the sandbox's network list covers this
27
- * machine's own Jira / Confluence / Bitbucket.
39
+ * machine's own Jira / Confluence / Bitbucket, plus the free-model providers
40
+ * modelRouting.free allows.
28
41
  *
29
42
  * @param {string} home
30
43
  * @returns {string[]}
@@ -35,7 +48,7 @@ export function prefsHosts(home) {
35
48
  readFileSync(join(home, ".claude", "multi-agent-preferences.json"), "utf-8"),
36
49
  );
37
50
  const hosts = prefs?.global?.hosts || {};
38
- return PREFS_HOST_KEYS.map((k) => hosts[k])
51
+ const own = PREFS_HOST_KEYS.map((k) => hosts[k])
39
52
  .filter((v) => typeof v === "string" && v.trim())
40
53
  .map(
41
54
  (v) =>
@@ -44,6 +57,14 @@ export function prefsHosts(home) {
44
57
  .replace(/^[a-z]+:\/\//i, "")
45
58
  .split("/")[0],
46
59
  );
60
+ const routing = prefs?.global?.modelRouting;
61
+ const free =
62
+ routing?.enabled === true && routing?.free?.enabled === true
63
+ ? routing.free.allowTraining === true
64
+ ? FREE_HOSTS_ANY
65
+ : FREE_HOSTS_NO_TRAINING
66
+ : [];
67
+ return [...own, ...free];
47
68
  } catch {
48
69
  return [];
49
70
  }
@@ -27,6 +27,8 @@ For each entry in `state.analysisSpec.contextLinks[]`, fan out by `type`. Entrie
27
27
  2. Surface a one-line warning to the user during Phase 1 progress output: `WARN: Confluence requires browser auth for <url>. Falling back to repo evidence; add the page text manually to /tmp/analysis-paste.md and re-run if needed.`
28
28
  3. Continue Phase 1 with remaining evidence sources rather than aborting.
29
29
 
30
+ **Long sources:** with the free chain on, summarize long confluence/document/jira/generic-doc texts via `llm_delegate` first (`features/free-models.md`).
31
+
30
32
  For each repo in `state.analysisSpec.repos[]`, run parallel greps (lightweight surface scan; the deeper 13-bucket extraction happens in Phase 1b):
31
33
 
32
34
  ```bash
@@ -204,7 +206,7 @@ Phase 2 Section 20 Risks reads this list and emits one open question per entry.
204
206
 
205
207
  **Caching (Locked 26)**: compute `evidence_digest = sha256(featureName || sorted(platforms) || options.redesign || hash(evidence.repoEvidence) || hash(evidence.conventions))`. `options.redesign` is a digest input: without it a redesign within a day of a normal run on the same feature reuses that cache, skips Phase 1b, and renders an empty current-behaviour table every redesign check then passes over. Cache key on disk: `/tmp/multi-agent-analysis-cache/<digest>.json` with mtime <= 24h. Cache hit skips Phase 1b and Phase 1c. `--no-cache` flag forces re-run.
206
208
 
207
- Phase 1d is deliberately absent from the digest inputs. Community signal changes by the hour, so folding it in would produce a new digest on every run, invalidate the cache every time, and re-run the two expensive repo phases the cache exists to skip. The consequence is worth stating plainly: a cache hit reuses yesterday's signal rows. That is the correct trade for an advisory tier, and `--no-cache` is the way to refresh them.
209
+ Phase 1d is not a digest input: community signal changes hourly, so it would invalidate the cache every run and re-run the two costly repo phases the cache skips. A cache hit reuses yesterday's signal rows, the right trade for an advisory tier; `--no-cache` refreshes them.
208
210
 
209
211
  ### Phase 1d - Outside facts (optional, analyst toolkit)
210
212
 
@@ -111,6 +111,11 @@ it does not. Contracts: `features/exploratory-testing.md`,
111
111
  | Secrets by reference | `secret_ref` on the type tools | ≥ v3.19.0 |
112
112
  | Parallel web sessions, replay | `session` on the web tools; `save_as` / `replay` | ≥ v3.19.0 |
113
113
  | Travel document fixtures | `document_generate`, `document_mrz_build` | ≥ v3.19.0 |
114
+ | ARIA assertions | `expect_aria` step, `web_assert_aria` | ≥ v3.20.0 |
115
+ | Web states and locators | `web_route`, `web_network_state`, `web_clock`, `web_dialog`, `web_upload`, `web_drag`, `web_hover`, `web_fill_form`, `web_find`, `web_generate_locator`, `web_trace`; `request_id` on `web_network`; `locator` on `web_click` / `web_type` | ≥ v3.20.0 |
116
+ | Document fetch and crawl | `web_scrape` (formats, cache, `change_tracking`), `url` / `sitemap` / `include_paths` / `llms_txt` on `web_crawl` and `web_map` | ≥ v3.20.0 |
117
+ | Research modes and threads | `mode`, `recency`, `domains`, `sources`, `thread_id` on `research_ask`; `arxiv` and `fetch_content` on `research_search` | ≥ v3.20.0 |
118
+ | Free-model delegation | `llm_delegate`, `llm_providers`; `ESCALATE_TO_CLAUDE`; the `multi-agent-toolkit-llm` CLI (`delegate`, `providers`, `probe`, `usage`) | ≥ v3.21.0 |
114
119
 
115
120
  ---
116
121
 
@@ -266,9 +271,13 @@ happen.
266
271
  The routing trio (`route-on`, `route-off`, `route-status`) has no such split -
267
272
  it writes `prefs.global.modelRouting` identically everywhere. Its honest limit is
268
273
  different and applies on every host: a subagent cannot be dispatched to a
269
- non-Anthropic model, because subagent dispatch belongs to the host. External
270
- providers are reachable only where the pipeline makes the HTTP call itself
271
- (`bulk-read.sh`, `research_ask`). `route-status` prints that on every run.
274
+ non-Anthropic model, because subagent dispatch belongs to the host. The `free`
275
+ rung (`route-on --free`) is reachable only where the pipeline makes the call
276
+ itself: `bulk-read.sh` and the `delegate` call site, where a skill asks
277
+ `model-dispatch.sh delegate --default host` and calls `llm_delegate` on `free`.
278
+ The toolkit and its CLI are the same on all three hosts, so the free chain
279
+ behaves identically everywhere. `route-status` prints the limit on every run.
280
+ Contract: `features/free-models.md`.
272
281
 
273
282
  ## 3. Frontmatter Transform Rules (Claude ↔ Copilot)
274
283
 
@@ -303,6 +303,17 @@ other outcome (`redirected`, `rejected`, `not-accepted`, `failed`, `skipped`),
303
303
  with the status code or reason and the step that fixes it. The emitter runs
304
304
  detached from phase-tracker with its output discarded, so this record is the
305
305
  only surface on which a reporting path that stopped working shows up.
306
+ ### free-models
307
+
308
+ Can `route-on --free` actually leave Claude. SKIP while routing or its free
309
+ chain is off. Otherwise it runs the toolkit's `multi-agent-toolkit-llm
310
+ providers` through `lib/toolkit-llm.sh`: WARN when the CLI cannot run, or when
311
+ no provider is both configured and allowed by the data policy
312
+ (`modelRouting.free.allowTraining`), because every free task then escalates to
313
+ Claude while the user believes it does not. `--probe` also sends each
314
+ configured provider one tiny request and WARNs when every usable one fails it
315
+ (a revoked key, an exhausted quota). `--explain` lists each provider with its
316
+ key source, never a value.
306
317
  ### xcode-skills
307
318
 
308
319
  Is Apple's `xcode-integration` plugin, which Xcode 27.1 and later materialize
@@ -123,6 +123,13 @@ taps as before and say so in the report:
123
123
  value; the value never enters the transcript, the run file or a finding.
124
124
  - **Traces.** A failed `agent_run_steps` writes a `trace.md`; put its path in
125
125
  the step's `trace` and in the finding's `evidence` as `kind: "trace"`.
126
+ - **States the happy path skips (web, ≥ v3.20.0).** Reach error, empty, slow
127
+ and offline states on purpose: `web_route` fulfills an endpoint with an error
128
+ status or an empty body, aborts it or delays it; `web_network_state` goes
129
+ offline; `web_clock` moves time for expiry and countdowns; `web_dialog`
130
+ answers a confirm before the click that opens it. Clear the routes when the
131
+ state is done. `expect_aria` checks a screen's structure against a template
132
+ without a judge.
126
133
  - **Replay.** `save_as` stores a step sequence that reached a finding, and
127
134
  `replay` runs it again. `REPLAY_DIVERGED` means the app no longer follows the
128
135
  recorded path: an automation block, and a hint that the screen changed.
@@ -35,7 +35,7 @@ One fetcher per type. Each fetcher emits a normalized JSON view that the analysi
35
35
  | `swagger` | `~/.claude/lib/fetch-swagger.sh <url>` → endpoints[], request/response examples | `state.fetchedContext.swagger[]` |
36
36
  | `confluence` | `~/.claude/lib/fetch-confluence.sh <url>` → page body, code blocks, extracted API contracts | `state.fetchedContext.confluence[]` |
37
37
  | `figma` | no standalone fetcher - resolve via the Figma 3-tier chain (Tier 1 MCP tools, Tier 2 `~/.claude/lib/figma-screenshot.sh` + REST) when the task is a component; otherwise advisory only | `state.fetchedContext.figma[]` |
38
- | `generic-doc` | no automatic fetcher - surfaced as advisory; the agent uses WebFetch on demand when the URL turns out to be load-bearing | n/a |
38
+ | `generic-doc` | no automatic fetcher - surfaced as advisory. When the URL turns out to be load-bearing, the agent reads it with the toolkit's `web_scrape` (`formats: ["markdown"]`; ≥ v3.20.0) when the server is registered, else with WebFetch. `web_scrape` returns main content as Markdown fenced as untrusted with prompt-injection flags, and serves a repeat from its cache | n/a (read into the prompt, not stored) |
39
39
 
40
40
  ## Exit code handling
41
41