loki-mode 8.10.0 → 8.12.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/SKILL.md CHANGED
@@ -3,7 +3,7 @@ name: loki-mode
3
3
  description: Autonomous spec-driven build system with a built-in trust layer. It does not call work done until it is verified (RARV-C closure loop, 8 quality gates, completion council, verified-completion evidence gate). Triggers on "Loki Mode". Takes a spec (PRD, GitHub issue, OpenAPI doc, etc.) to deployed product with minimal human intervention. Provider-agnostic. Requires --dangerously-skip-permissions flag.
4
4
  ---
5
5
 
6
- # Loki Mode v8.10.0
6
+ # Loki Mode v8.12.0
7
7
 
8
8
  **You are an autonomous agent. You make decisions. You do not ask questions. You do not stop.**
9
9
 
@@ -469,4 +469,4 @@ See `CHANGELOG.md` entries [7.5.7], [7.5.8], [7.5.13] for the per-fix list and r
469
469
 
470
470
  ---
471
471
 
472
- **v8.10.0 | [Autonomi](https://www.autonomi.dev/) flagship product | ~410 lines core**
472
+ **v8.12.0 | [Autonomi](https://www.autonomi.dev/) flagship product | ~410 lines core**
package/VERSION CHANGED
@@ -1 +1 @@
1
- 8.10.0
1
+ 8.12.0
@@ -31,6 +31,18 @@ SECURITY MODEL
31
31
  ignored, never run, and their presence is reported)
32
32
  - manifests that are not the declared kind
33
33
 
34
+ WHAT AN INSTALLED AGENT DOES
35
+ An installed agent is not inert metadata. Beyond `loki agent
36
+ list/info/run`, it joins the code-review reviewer pool: its `focus`
37
+ entries act as trigger keywords scored against the diff, and on a match
38
+ it is dispatched as a real reviewer whose findings feed the same
39
+ Critical/High = BLOCK mechanism as a built-in. Selection is strictly
40
+ ADDITIVE -- installed agents are appended after the built-in battery and
41
+ can never displace a built-in reviewer, and at most 2 fire per review.
42
+ Consistent with the security model above, only manifest TEXT (persona /
43
+ focus / capabilities) reaches the reviewer prompt; nothing is executed.
44
+ See skills/quality-gates.md "Adding Your Own Reviewer".
45
+
34
46
  STORE LAYOUT (project-local, under .loki/)
35
47
  .loki/agents/installed.json list of installed agent manifests
36
48
  .loki/templates/<name>.md installed template body
package/autonomy/run.sh CHANGED
@@ -657,16 +657,29 @@ loki_detect_scoped_change() {
657
657
 
658
658
  # Greenfield is not a scoped change: no repo, or a repo with almost no
659
659
  # history, means we are building something new.
660
+ #
661
+ # Ask git whether this is a work tree rather than testing for a .git
662
+ # DIRECTORY: in a git worktree (and in a submodule) .git is a FILE, so the
663
+ # old -d test rejected every worktree-based run -- including the parallel
664
+ # workflow streams this project runs by default. rev-parse is true for a
665
+ # plain clone, a worktree, and a submodule alike, and replaces two
666
+ # subprocesses' worth of checking with one.
660
667
  local target="${TARGET_DIR:-.}"
661
- [ -d "$target/.git" ] || return 1
668
+ git -C "$target" rev-parse --is-inside-work-tree >/dev/null 2>&1 || return 1
662
669
  local commits
663
670
  commits="$(git -C "$target" rev-list --count HEAD 2>/dev/null || echo 0)"
664
671
  [ "${commits:-0}" -ge 5 ] || return 1
665
672
 
666
673
  # An issue-sourced spec is the canonical scoped change: someone filed a
667
674
  # discrete request against code that already exists.
675
+ #
676
+ # The spec path is passed in by the caller ($1). It used to be read only
677
+ # from LOKI_PRD_FILE / LOKI_ISSUE_REF, but `loki start <issue>` writes
678
+ # .loki/prd-issue-N.md and hands run.sh that path as a POSITIONAL argument,
679
+ # so neither variable was ever set and this check could not fire.
680
+ local spec="${1:-${LOKI_PRD_FILE:-}}"
668
681
  [ -n "${LOKI_ISSUE_REF:-}" ] && return 0
669
- case "${LOKI_PRD_FILE:-}" in
682
+ case "$spec" in
670
683
  *prd-issue-*) return 0 ;;
671
684
  esac
672
685
 
@@ -674,7 +687,7 @@ loki_detect_scoped_change() {
674
687
  }
675
688
 
676
689
  loki_apply_scoped_change_profile() {
677
- loki_detect_scoped_change || return 0
690
+ loki_detect_scoped_change "${1:-}" || return 0
678
691
 
679
692
  # Off: cannot affect the correctness of a scoped change to existing code.
680
693
  : "${LOKI_PHASE_WEB_RESEARCH:=false}"
@@ -10597,17 +10610,38 @@ print("REPEATED_GATE_BLOCKER (PRIORITY): action=escalate gate=%s count=%d thresh
10597
10610
  # Usage: _loki_run_pytest_with_timeout <target_dir> [pytest_args...]
10598
10611
  # Stdout: combined pytest output
10599
10612
  # Exit: 0 on pass, non-zero on fail. Exit 124 indicates the timeout fired.
10600
- _loki_run_pytest_with_timeout() {
10601
- local target_dir="$1"; shift
10602
- local pytest_timeout="${LOKI_PYTEST_TIMEOUT:-${LOKI_GATE_TIMEOUT:-300}}"
10603
- local _to_cmd=()
10613
+ # Portable timeout-prefix probe, shared by every gate that must be wall-clock
10614
+ # bounded. Stock macOS ships NEITHER `timeout` NOR `gtimeout` (gtimeout arrives
10615
+ # with coreutils), so a bare `timeout` would resolve to "command not found"
10616
+ # (exit 127) and flip test_passed=false on every macOS run -- turning a rare
10617
+ # hang into a universal false RED. When no timeout binary exists we emit an
10618
+ # EMPTY prefix and run unbounded, which is the pre-existing behaviour.
10619
+ #
10620
+ # Usage: _loki_timeout_prefix <seconds> <gate-label> (writes words to stdout)
10621
+ # local _cmd=(); read -r -a _cmd <<< "$(_loki_timeout_prefix 300 'go test')"
10622
+ # "${_cmd[@]}" go test ./...
10623
+ _loki_timeout_prefix() {
10624
+ local secs="$1" label="${2:-gate}"
10604
10625
  if command -v gtimeout >/dev/null 2>&1; then
10605
- _to_cmd=(gtimeout "${pytest_timeout}s")
10626
+ printf 'gtimeout %ss' "$secs"
10606
10627
  elif command -v timeout >/dev/null 2>&1; then
10607
- _to_cmd=(timeout "${pytest_timeout}s")
10628
+ printf 'timeout %ss' "$secs"
10608
10629
  else
10609
- log_warn "Neither gtimeout nor timeout available; pytest gate will run unbounded (install coreutils on macOS)"
10630
+ # >&2 is LOAD-BEARING: this function's STDOUT becomes the command-prefix
10631
+ # array at every call site. log_warn writes to stdout (run.sh:1684), so
10632
+ # without this redirect the warning text itself would be executed as the
10633
+ # command -> exit 127 -> test_passed=false on every box lacking a timeout
10634
+ # binary (stock macOS). That would be a universal false RED, strictly
10635
+ # worse than the unbounded hang this helper exists to prevent.
10636
+ log_warn "Neither gtimeout nor timeout available; ${label} will run unbounded (install coreutils on macOS)" >&2
10610
10637
  fi
10638
+ }
10639
+
10640
+ _loki_run_pytest_with_timeout() {
10641
+ local target_dir="$1"; shift
10642
+ local pytest_timeout="${LOKI_PYTEST_TIMEOUT:-${LOKI_GATE_TIMEOUT:-300}}"
10643
+ local _to_cmd=()
10644
+ read -r -a _to_cmd <<< "$(_loki_timeout_prefix "$pytest_timeout" 'pytest gate')"
10611
10645
  (cd "$target_dir" && "${_to_cmd[@]}" pytest "$@" 2>&1)
10612
10646
  }
10613
10647
 
@@ -11081,9 +11115,8 @@ sys.stdout.write(t.strip())
11081
11115
  test_runner="unittest"
11082
11116
  local output unittest_exit _ut_to
11083
11117
  _ut_to="${LOKI_PYTEST_TIMEOUT:-${LOKI_GATE_TIMEOUT:-300}}"
11084
- local _ut_cmd=(timeout "${_ut_to}s")
11085
- command -v gtimeout &>/dev/null && _ut_cmd=(gtimeout "${_ut_to}s")
11086
- command -v timeout &>/dev/null || command -v gtimeout &>/dev/null || _ut_cmd=()
11118
+ local _ut_cmd=()
11119
+ read -r -a _ut_cmd <<< "$(_loki_timeout_prefix "$_ut_to" 'unittest gate')"
11087
11120
  output=$(cd "${TARGET_DIR:-.}" && "${_ut_cmd[@]}" python3 -m unittest discover -p 'test_*.py' 2>&1)
11088
11121
  unittest_exit=$?
11089
11122
  if [ "$unittest_exit" -eq 124 ]; then
@@ -11098,19 +11131,46 @@ sys.stdout.write(t.strip())
11098
11131
  fi
11099
11132
 
11100
11133
  # Go
11134
+ # Wall-clock bounded like every other runner above. `go test` self-imposes
11135
+ # -timeout 10m PER TEST BINARY, but `./...` runs one binary per package, so
11136
+ # the AGGREGATE is unbounded -- and a test blocked in a cgo call or a syscall
11137
+ # can outlive that panic. `cargo test` has no default timeout at all. Without
11138
+ # this the gate hangs the whole iteration with no verdict.
11139
+ # $gate_timeout is NOT in scope here (it is declared inside the package.json
11140
+ # block), so read LOKI_GATE_TIMEOUT directly.
11101
11141
  if [ "$test_runner" = "none" ] && [ -f "${TARGET_DIR:-.}/go.mod" ] && command -v go &>/dev/null; then
11102
11142
  test_runner="go-test"
11103
- local output
11104
- output=$(cd "${TARGET_DIR:-.}" && go test ./... 2>&1) || test_passed=false
11105
- details="go test: $(echo "$output" | tail -3 | tr '\n' ' ')"
11143
+ local output go_exit _go_to _go_cmd=()
11144
+ _go_to="${LOKI_GATE_TIMEOUT:-300}"
11145
+ read -r -a _go_cmd <<< "$(_loki_timeout_prefix "$_go_to" 'go test gate')"
11146
+ output=$(cd "${TARGET_DIR:-.}" && "${_go_cmd[@]}" go test ./... 2>&1)
11147
+ go_exit=$?
11148
+ if [ "$go_exit" -eq 124 ]; then
11149
+ test_passed=false
11150
+ log_warn "go test gate timed out after ${_go_to}s (exit 124)"
11151
+ details="go test: TIMED OUT after ${_go_to}s -- $(echo "$output" | tail -3 | tr '\n' ' ')"
11152
+ else
11153
+ [ "$go_exit" -ne 0 ] && test_passed=false
11154
+ details="go test: $(echo "$output" | tail -3 | tr '\n' ' ')"
11155
+ fi
11106
11156
  fi
11107
11157
 
11108
11158
  # Rust
11109
11159
  if [ "$test_runner" = "none" ] && [ -f "${TARGET_DIR:-.}/Cargo.toml" ] && command -v cargo &>/dev/null; then
11110
11160
  test_runner="cargo-test"
11111
- local output
11112
- output=$(cd "${TARGET_DIR:-.}" && cargo test 2>&1) || test_passed=false
11113
- details="cargo test: $(echo "$output" | tail -3 | tr '\n' ' ')"
11161
+ local output cargo_exit _cargo_to _cargo_cmd=()
11162
+ _cargo_to="${LOKI_GATE_TIMEOUT:-300}"
11163
+ read -r -a _cargo_cmd <<< "$(_loki_timeout_prefix "$_cargo_to" 'cargo test gate')"
11164
+ output=$(cd "${TARGET_DIR:-.}" && "${_cargo_cmd[@]}" cargo test 2>&1)
11165
+ cargo_exit=$?
11166
+ if [ "$cargo_exit" -eq 124 ]; then
11167
+ test_passed=false
11168
+ log_warn "cargo test gate timed out after ${_cargo_to}s (exit 124)"
11169
+ details="cargo test: TIMED OUT after ${_cargo_to}s -- $(echo "$output" | tail -3 | tr '\n' ' ')"
11170
+ else
11171
+ [ "$cargo_exit" -ne 0 ] && test_passed=false
11172
+ details="cargo test: $(echo "$output" | tail -3 | tr '\n' ' ')"
11173
+ fi
11114
11174
  fi
11115
11175
 
11116
11176
  # node --test (built-in Node test runner) -- config-less fallback (task #79).
@@ -16936,8 +16996,64 @@ check_completion_promise() {
16936
16996
  }
16937
16997
 
16938
16998
  # Check if max iterations reached
16999
+ # EVIDENCE-AWARE ITERATION CAP.
17000
+ #
17001
+ # The cap used to be a bare counter: it consulted no gate, no council, and no
17002
+ # evidence. A run one step from finishing was cut off identically to a run
17003
+ # thrashing in circles, and both reported the same terminal.
17004
+ #
17005
+ # An iteration count is a PROXY for "is this converging". Where real evidence
17006
+ # exists, prefer the evidence. Two signals are already on disk at this point:
17007
+ #
17008
+ # 1. the model's own completion request (.loki/signals/COMPLETION_REQUESTED),
17009
+ # which the agent writes when it believes the work is done
17010
+ # 2. gate state (.loki/quality/gate-failures.txt), which says whether the
17011
+ # last verification pass actually found anything
17012
+ #
17013
+ # When the model says it is done AND no gate is failing, the run gets ONE extra
17014
+ # iteration to land it. That is the difference between a finished product and a
17015
+ # terminal failure at the buzzer.
17016
+ #
17017
+ # WHY THIS CANNOT LOOP FOREVER, which is the only thing that matters here:
17018
+ # the grace is granted at most once per run (a marker file, checked before it
17019
+ # is written), it requires POSITIVE evidence rather than the absence of a
17020
+ # signal, and it extends by exactly one iteration. A run that keeps claiming
17021
+ # done without finishing gets the cap, once, and then stops. Published
17022
+ # measurements put automated-verifier false-negative rates near 24%, so an
17023
+ # unbounded verifier-driven loop would burn real money on already-correct work.
17024
+ # This is deliberately a bounded nudge, not a verifier-driven terminal.
17025
+ #
17026
+ # LOKI_ITERATION_GRACE=0 restores the pure counter.
17027
+ _iteration_grace_available() {
17028
+ [ "${LOKI_ITERATION_GRACE:-1}" != "0" ] || return 1
17029
+
17030
+ local _loki_root="${TARGET_DIR:-.}/.loki"
17031
+ local _marker="$_loki_root/state/iteration-grace-used"
17032
+ [ -f "$_marker" ] && return 1
17033
+
17034
+ # POSITIVE evidence the model believes it is done. Absence is not evidence.
17035
+ [ -f "$_loki_root/signals/COMPLETION_REQUESTED" ] || return 1
17036
+
17037
+ # ...and nothing is currently failing. A non-empty gate-failures.txt means
17038
+ # the last verification pass found real problems, so a "done" claim on top
17039
+ # of it is exactly the case the cap should still stop.
17040
+ local _gf="$_loki_root/quality/gate-failures.txt"
17041
+ if [ -s "$_gf" ]; then
17042
+ return 1
17043
+ fi
17044
+
17045
+ mkdir -p "$_loki_root/state" 2>/dev/null || true
17046
+ printf 'granted at iteration %s\n' "${ITERATION_COUNT:-0}" > "$_marker" 2>/dev/null || true
17047
+ return 0
17048
+ }
17049
+
16939
17050
  check_max_iterations() {
16940
17051
  if [ $ITERATION_COUNT -ge $MAX_ITERATIONS ]; then
17052
+ if _iteration_grace_available; then
17053
+ MAX_ITERATIONS=$((MAX_ITERATIONS + 1))
17054
+ log_info "Iteration cap reached, but the agent reports done with no failing gate -- granting ONE final iteration to land it (once per run; LOKI_ITERATION_GRACE=0 to disable)."
17055
+ return 1
17056
+ fi
16941
17057
  log_warn "Max iterations ($MAX_ITERATIONS) reached. Stopping."
16942
17058
  return 0
16943
17059
  fi
@@ -24182,6 +24298,15 @@ main() {
24182
24298
  set --
24183
24299
  fi
24184
24300
 
24301
+ # Re-apply the scoped-change profile now that PRD_PATH is known.
24302
+ #
24303
+ # The module-scope call runs at source time, BEFORE this argument parsing,
24304
+ # so the spec path was always empty there and an issue-sourced build could
24305
+ # never be recognised. Re-running it here is idempotent: the profile assigns
24306
+ # with := so anything already set (including an explicit operator override)
24307
+ # is left untouched, and a non-scoped run still returns immediately.
24308
+ loki_apply_scoped_change_profile "$PRD_PATH"
24309
+
24185
24310
  # Validate PRD if provided
24186
24311
  if [ -n "$PRD_PATH" ] && [ ! -f "$PRD_PATH" ]; then
24187
24312
  log_error "PRD file not found: $PRD_PATH"
@@ -7,7 +7,7 @@ Modules:
7
7
  control: Session control API (start/stop/pause/resume)
8
8
  """
9
9
 
10
- __version__ = "8.10.0"
10
+ __version__ = "8.12.0"
11
11
 
12
12
  # Expose the control app for easy import
13
13
  try:
@@ -1,5 +1,5 @@
1
1
  // @bun
2
- var h_=Object.create;var{getPrototypeOf:v_,defineProperty:rK,getOwnPropertyNames:g_}=Object;var m_=Object.prototype.hasOwnProperty;function u_(Z){return this[Z]}var p_,d_,c_=(Z,X,Q)=>{var Y=Z!=null&&typeof Z==="object";if(Y){var J=X?p_??=new WeakMap:d_??=new WeakMap,z=J.get(Z);if(z)return z}Q=Z!=null?h_(v_(Z)):{};let K=X||!Z||!Z.__esModule?rK(Q,"default",{value:Z,enumerable:!0}):Q;for(let $ of g_(Z))if(!m_.call(K,$))rK(K,$,{get:u_.bind(Z,$),enumerable:!0});if(Y)J.set(Z,K);return K};var qQ=(Z,X)=>()=>(X||Z((X={exports:{}}).exports,X),X.exports);var l_=(Z)=>Z;function i_(Z,X){this[Z]=l_.bind(null,X)}var l0=(Z,X)=>{for(var Q in X)rK(Z,Q,{get:X[Q],enumerable:!0,configurable:!0,set:i_.bind(X,Q)})};var p=(Z,X)=>()=>(Z&&(X=Z(Z=0)),X);var e0=import.meta.require;var kO={};l0(kO,{lokiDir:()=>j0,homeLokiDir:()=>P4,findRepoRootForVersion:()=>eK,REPO_ROOT:()=>r0});import{resolve as n7,dirname as tK}from"path";import{fileURLToPath as a_}from"url";import{existsSync as GQ}from"fs";import{homedir as s_}from"os";function n_(){let Z=RO;for(let X=0;X<6;X++){if(GQ(n7(Z,"VERSION"))&&GQ(n7(Z,"autonomy/run.sh")))return Z;let Q=tK(Z);if(Q===Z)break;Z=Q}return n7(RO,"..","..","..")}function eK(Z){let X=Z;for(let Q=0;Q<6;Q++){if(GQ(n7(X,"VERSION"))&&GQ(n7(X,"autonomy/run.sh")))return X;let Y=tK(X);if(Y===X)break;X=Y}return n7(Z,"..","..","..")}function j0(){return process.env.LOKI_DIR??n7(process.cwd(),".loki")}function P4(){return n7(s_(),".loki")}var RO,r0;var G8=p(()=>{RO=tK(a_(import.meta.url));r0=n_()});import{readFileSync as o_}from"fs";import{resolve as r_,dirname as t_}from"path";import{fileURLToPath as e_}from"url";function f3(){if(h5!==null)return h5;let Z="8.10.0";if(typeof Z==="string"&&Z.length>0)return h5=Z,h5;try{let X=t_(e_(import.meta.url)),Q=eK(X);h5=o_(r_(Q,"VERSION"),"utf-8").trim()}catch{h5="unknown"}return h5}var h5=null;var HQ=p(()=>{G8()});var bO={};l0(bO,{runOrThrow:()=>Of,run:()=>E0,readStreamCapped:()=>UQ,commandVersion:()=>Af,commandExists:()=>X9,ShellError:()=>Z$,MAX_STDOUT_BYTES:()=>yO});async function UQ(Z,X=yO){let Q=Z.getReader(),Y=new TextDecoder,J="",z=0;try{while(z<X){let{done:K,value:$}=await Q.read();if(K)break;if(!$)continue;if(z+=$.byteLength,z>X){let W=$.byteLength-(z-X);J+=Y.decode($.subarray(0,W),{stream:!0});break}J+=Y.decode($,{stream:!0})}J+=Y.decode()}finally{try{await Q.cancel()}catch{}Q.releaseLock()}return J}async function E0(Z,X={}){let Q=Bun.spawn({cmd:[...Z],stdout:"pipe",stderr:"pipe",env:X.env?{...process.env,...X.env}:process.env,cwd:X.cwd}),Y,J;if(X.timeoutMs&&X.timeoutMs>0)Y=setTimeout(()=>{try{Q.kill("SIGTERM")}catch{}J=setTimeout(()=>{try{Q.kill("SIGKILL")}catch{}},2000)},X.timeoutMs);try{let[z,K,$]=await Promise.all([UQ(Q.stdout),new Response(Q.stderr).text(),Q.exited]);return{stdout:z,stderr:K,exitCode:$}}finally{if(Y)clearTimeout(Y);if(J)clearTimeout(J)}}async function Of(Z,X={}){let Q=await E0(Z,X);if(Q.exitCode!==0)throw new Z$(`command failed (${Q.exitCode}): ${Z.join(" ")}`,Q.exitCode,Q.stdout,Q.stderr);return Q}async function X9(Z){let X=Lf(Z),Q=await E0(["sh","-c",`command -v ${X}`],{timeoutMs:5000});if(Q.exitCode===0)return Q.stdout.trim()||null;return null}function Lf(Z){if(!/^[A-Za-z0-9._/-]+$/.test(Z))throw Error(`refused to shell-escape suspect token: ${Z}`);return Z}async function Af(Z,X="--version"){if(!await X9(Z))return null;let Y=await E0([Z,X],{timeoutMs:5000});if(Y.exitCode!==0)return null;return((Y.stdout||Y.stderr).split(/\r?\n/)[0]?.trim()??"")||null}var yO=16777216,Z$;var x9=p(()=>{Z$=class Z$ extends Error{message;exitCode;stdout;stderr;constructor(Z,X,Q,Y){super(Z);this.message=Z;this.exitCode=X;this.stdout=Q;this.stderr=Y;this.name="ShellError"}}});function o7(Z){return jf?"":Z}var jf,L0,k8,p0,ZV0,i0,H8,Q9,v;var S6=p(()=>{jf=(process.env.NO_COLOR??"").length>0;L0=o7("\x1B[0;31m"),k8=o7("\x1B[0;32m"),p0=o7("\x1B[1;33m"),ZV0=o7("\x1B[0;34m"),i0=o7("\x1B[0;36m"),H8=o7("\x1B[1m"),Q9=o7("\x1B[2m"),v=o7("\x1B[0m")});import{existsSync as xf}from"fs";async function E7(){if(k4!==void 0)return k4;let Z="/opt/homebrew/bin/python3.12";if(xf(Z))return k4=Z,Z;let X=await X9("python3.12");if(X)return k4=X,X;let Q=await X9("python3");return k4=Q,Q}async function Y7(Z,X={}){let Q=await E7();if(!Q)return{stdout:"",stderr:"python3 not found",exitCode:127};return E0([Q,"-c",Z],X)}var k4;var r7=p(()=>{x9()});var ZL={};l0(ZL,{runStatus:()=>Yh});import{existsSync as Y9,readFileSync as v3,readdirSync as iO,statSync as aO}from"fs";import{resolve as h8,basename as sf}from"path";import{homedir as nf}from"os";function sO(Z){let X=Math.trunc(Z);if(X>=1e6)return`${(Math.trunc(X/1e6*10)/10).toFixed(1)}M`;if(X>=1000)return`${(Math.trunc(X/1000*10)/10).toFixed(1)}K`;return String(X)}function nO(Z,X,Q){if(X===0)return null;let Y=Math.trunc(Z*100/X),J=Math.trunc(Z*NQ/X);if(J>NQ)J=NQ;let z=NQ-J,K=k8;if(Y>=80)K=L0;else if(Y>=50)K=p0;let $="=".repeat(Math.max(0,J))+" ".repeat(Math.max(0,z)),W=sO(Z),V=sO(X);return` ${H8}${Q}${v} ${K}[${$}]${v} ${Y}% (${W} / ${V})`}async function rf(){if(await X9("jq"))return!0;return process.stdout.write(`${L0}Error: jq is required but not installed.${v}
2
+ var h_=Object.create;var{getPrototypeOf:v_,defineProperty:rK,getOwnPropertyNames:g_}=Object;var m_=Object.prototype.hasOwnProperty;function u_(Z){return this[Z]}var p_,d_,c_=(Z,X,Q)=>{var Y=Z!=null&&typeof Z==="object";if(Y){var J=X?p_??=new WeakMap:d_??=new WeakMap,z=J.get(Z);if(z)return z}Q=Z!=null?h_(v_(Z)):{};let K=X||!Z||!Z.__esModule?rK(Q,"default",{value:Z,enumerable:!0}):Q;for(let $ of g_(Z))if(!m_.call(K,$))rK(K,$,{get:u_.bind(Z,$),enumerable:!0});if(Y)J.set(Z,K);return K};var qQ=(Z,X)=>()=>(X||Z((X={exports:{}}).exports,X),X.exports);var l_=(Z)=>Z;function i_(Z,X){this[Z]=l_.bind(null,X)}var l0=(Z,X)=>{for(var Q in X)rK(Z,Q,{get:X[Q],enumerable:!0,configurable:!0,set:i_.bind(X,Q)})};var p=(Z,X)=>()=>(Z&&(X=Z(Z=0)),X);var e0=import.meta.require;var kO={};l0(kO,{lokiDir:()=>j0,homeLokiDir:()=>P4,findRepoRootForVersion:()=>eK,REPO_ROOT:()=>r0});import{resolve as n7,dirname as tK}from"path";import{fileURLToPath as a_}from"url";import{existsSync as GQ}from"fs";import{homedir as s_}from"os";function n_(){let Z=RO;for(let X=0;X<6;X++){if(GQ(n7(Z,"VERSION"))&&GQ(n7(Z,"autonomy/run.sh")))return Z;let Q=tK(Z);if(Q===Z)break;Z=Q}return n7(RO,"..","..","..")}function eK(Z){let X=Z;for(let Q=0;Q<6;Q++){if(GQ(n7(X,"VERSION"))&&GQ(n7(X,"autonomy/run.sh")))return X;let Y=tK(X);if(Y===X)break;X=Y}return n7(Z,"..","..","..")}function j0(){return process.env.LOKI_DIR??n7(process.cwd(),".loki")}function P4(){return n7(s_(),".loki")}var RO,r0;var G8=p(()=>{RO=tK(a_(import.meta.url));r0=n_()});import{readFileSync as o_}from"fs";import{resolve as r_,dirname as t_}from"path";import{fileURLToPath as e_}from"url";function f3(){if(h5!==null)return h5;let Z="8.12.0";if(typeof Z==="string"&&Z.length>0)return h5=Z,h5;try{let X=t_(e_(import.meta.url)),Q=eK(X);h5=o_(r_(Q,"VERSION"),"utf-8").trim()}catch{h5="unknown"}return h5}var h5=null;var HQ=p(()=>{G8()});var bO={};l0(bO,{runOrThrow:()=>Of,run:()=>E0,readStreamCapped:()=>UQ,commandVersion:()=>Af,commandExists:()=>X9,ShellError:()=>Z$,MAX_STDOUT_BYTES:()=>yO});async function UQ(Z,X=yO){let Q=Z.getReader(),Y=new TextDecoder,J="",z=0;try{while(z<X){let{done:K,value:$}=await Q.read();if(K)break;if(!$)continue;if(z+=$.byteLength,z>X){let W=$.byteLength-(z-X);J+=Y.decode($.subarray(0,W),{stream:!0});break}J+=Y.decode($,{stream:!0})}J+=Y.decode()}finally{try{await Q.cancel()}catch{}Q.releaseLock()}return J}async function E0(Z,X={}){let Q=Bun.spawn({cmd:[...Z],stdout:"pipe",stderr:"pipe",env:X.env?{...process.env,...X.env}:process.env,cwd:X.cwd}),Y,J;if(X.timeoutMs&&X.timeoutMs>0)Y=setTimeout(()=>{try{Q.kill("SIGTERM")}catch{}J=setTimeout(()=>{try{Q.kill("SIGKILL")}catch{}},2000)},X.timeoutMs);try{let[z,K,$]=await Promise.all([UQ(Q.stdout),new Response(Q.stderr).text(),Q.exited]);return{stdout:z,stderr:K,exitCode:$}}finally{if(Y)clearTimeout(Y);if(J)clearTimeout(J)}}async function Of(Z,X={}){let Q=await E0(Z,X);if(Q.exitCode!==0)throw new Z$(`command failed (${Q.exitCode}): ${Z.join(" ")}`,Q.exitCode,Q.stdout,Q.stderr);return Q}async function X9(Z){let X=Lf(Z),Q=await E0(["sh","-c",`command -v ${X}`],{timeoutMs:5000});if(Q.exitCode===0)return Q.stdout.trim()||null;return null}function Lf(Z){if(!/^[A-Za-z0-9._/-]+$/.test(Z))throw Error(`refused to shell-escape suspect token: ${Z}`);return Z}async function Af(Z,X="--version"){if(!await X9(Z))return null;let Y=await E0([Z,X],{timeoutMs:5000});if(Y.exitCode!==0)return null;return((Y.stdout||Y.stderr).split(/\r?\n/)[0]?.trim()??"")||null}var yO=16777216,Z$;var x9=p(()=>{Z$=class Z$ extends Error{message;exitCode;stdout;stderr;constructor(Z,X,Q,Y){super(Z);this.message=Z;this.exitCode=X;this.stdout=Q;this.stderr=Y;this.name="ShellError"}}});function o7(Z){return jf?"":Z}var jf,L0,k8,p0,ZV0,i0,H8,Q9,v;var S6=p(()=>{jf=(process.env.NO_COLOR??"").length>0;L0=o7("\x1B[0;31m"),k8=o7("\x1B[0;32m"),p0=o7("\x1B[1;33m"),ZV0=o7("\x1B[0;34m"),i0=o7("\x1B[0;36m"),H8=o7("\x1B[1m"),Q9=o7("\x1B[2m"),v=o7("\x1B[0m")});import{existsSync as xf}from"fs";async function E7(){if(k4!==void 0)return k4;let Z="/opt/homebrew/bin/python3.12";if(xf(Z))return k4=Z,Z;let X=await X9("python3.12");if(X)return k4=X,X;let Q=await X9("python3");return k4=Q,Q}async function Y7(Z,X={}){let Q=await E7();if(!Q)return{stdout:"",stderr:"python3 not found",exitCode:127};return E0([Q,"-c",Z],X)}var k4;var r7=p(()=>{x9()});var ZL={};l0(ZL,{runStatus:()=>Yh});import{existsSync as Y9,readFileSync as v3,readdirSync as iO,statSync as aO}from"fs";import{resolve as h8,basename as sf}from"path";import{homedir as nf}from"os";function sO(Z){let X=Math.trunc(Z);if(X>=1e6)return`${(Math.trunc(X/1e6*10)/10).toFixed(1)}M`;if(X>=1000)return`${(Math.trunc(X/1000*10)/10).toFixed(1)}K`;return String(X)}function nO(Z,X,Q){if(X===0)return null;let Y=Math.trunc(Z*100/X),J=Math.trunc(Z*NQ/X);if(J>NQ)J=NQ;let z=NQ-J,K=k8;if(Y>=80)K=L0;else if(Y>=50)K=p0;let $="=".repeat(Math.max(0,J))+" ".repeat(Math.max(0,z)),W=sO(Z),V=sO(X);return` ${H8}${Q}${v} ${K}[${$}]${v} ${Y}% (${W} / ${V})`}async function rf(){if(await X9("jq"))return!0;return process.stdout.write(`${L0}Error: jq is required but not installed.${v}
3
3
  `),process.stdout.write(`Install with:
4
4
  `),process.stdout.write(` brew install jq (macOS)
5
5
  `),process.stdout.write(` apt install jq (Debian/Ubuntu)
@@ -1222,4 +1222,4 @@ Set LOKI_LEGACY_BASH=1 to force the bash CLI for every command.
1222
1222
  `),2}case"start":{let{runStart:Y}=await Promise.resolve().then(() => (b_(),y_));return Y(Q)}default:return process.stderr.write(`Unknown command: ${X}
1223
1223
  `),process.stderr.write(__),2}}cO();process.on("SIGINT",()=>process.exit(130));process.on("SIGTERM",()=>process.exit(143));var fW0=await _W0(Bun.argv.slice(2));process.exit(fW0);
1224
1224
 
1225
- //# debugId=FFC5E67020401A2F64756E2164756E21
1225
+ //# debugId=12231098D894234164756E2164756E21
package/mcp/__init__.py CHANGED
@@ -75,4 +75,4 @@ try:
75
75
  except ImportError:
76
76
  __all__ = ['mcp']
77
77
 
78
- __version__ = '8.10.0'
78
+ __version__ = '8.12.0'
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "loki-mode",
3
3
  "mcpName": "io.github.asklokesh/loki-mode",
4
- "version": "8.10.0",
4
+ "version": "8.12.0",
5
5
  "description": "Loki Mode by Autonomi. Autonomous spec-to-product system: takes a PRD, GitHub issue, OpenAPI/JSON/YAML, or one-line brief to a deployed app via the RARV-C closure loop with 8 quality gates. Provider-agnostic (Claude Code, OpenAI Codex, Cline, Aider).",
6
6
  "keywords": [
7
7
  "agent",
@@ -2,7 +2,7 @@
2
2
  "$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json",
3
3
  "name": "loki-mode",
4
4
  "displayName": "Loki Mode",
5
- "version": "8.10.0",
5
+ "version": "8.12.0",
6
6
  "description": "Autonomous spec-to-product build system with a built-in trust layer (RARV-C closure loop, 8 quality gates, completion council). Ships Loki's spec-hardening, drift-detection, and deterministic PR verification commands plus the Loki MCP server.",
7
7
  "author": {
8
8
  "name": "Autonomi",
@@ -35,6 +35,8 @@
35
35
  | Legacy healing, modernization, archaeology | `healing.md` |
36
36
  | Plan deepening, knowledge extraction | `compound-learning.md` |
37
37
  | Managed Agents memory, multiagent council, flag hierarchy | `memory.md` |
38
+ | Non-trivial change (>3 files, agent runtime, MINOR/MAJOR release) | `sdlc-fleet.md` |
39
+ | Adding your own gate/reviewer/agent to the loop | `extending.md` |
38
40
 
39
41
  ## Module Descriptions
40
42
 
@@ -159,6 +161,20 @@
159
161
  - Language-specific guides (COBOL, legacy Java, PHP, Python 2)
160
162
  - Full reference: `references/legacy-healing-patterns.md`
161
163
 
164
+ ### sdlc-fleet.md
165
+ **When:** Any non-trivial change (feature touching >3 files, bug fix in agent
166
+ runtime / council / memory / auto-spawn, MINOR or MAJOR release, cross-route
167
+ parity change). Binding per CLAUDE.md, so this index must be able to route to it.
168
+ - Six roles: Architect, Product Owner, Dev Fleet, SDET, Council Reviewers, Real-User QA
169
+ - Unanimous 3-of-3 APPROVE required from the review council
170
+ - Skip rules: typo fixes, docs-only edits, reverts, emergency hotfixes
171
+
172
+ ### extending.md
173
+ **When:** Adding a custom reviewer/agent to the loop without editing engine files
174
+ - The live seam: `loki agent install` -> `.loki/agents/installed.json`
175
+ - What is strictly additive vs what can never displace a built-in gate
176
+ - Honest status of `src/plugins/` (built, tested, not wired into the loop)
177
+
162
178
  ### providers.md (v5.0.0)
163
179
  **When:** Using non-Claude providers (Codex, Cline, Aider), understanding degraded mode
164
180
  - Provider comparison matrix
@@ -0,0 +1,131 @@
1
+ # Extending the Loop
2
+
3
+ **When to load:** you want to add your own reviewer, agent, or gate to the RARV
4
+ loop without editing engine files.
5
+
6
+ Read this before writing an extension. Every claim below is cited to source, and
7
+ the one live seam is exercised by `tests/test-installed-agent-reviewer.sh`
8
+ (10 tests, wired into `tests/run-all-tests.sh:732`).
9
+
10
+ ## What is actually extensible today
11
+
12
+ | Surface | Extend without editing engine files? | Mechanism |
13
+ |---|---|---|
14
+ | Code-review reviewer pool | **Yes** | `loki agent install` -> `.loki/agents/installed.json` |
15
+ | Numbered quality gates 1-8 | No | Compiled into `autonomy/run.sh` phases; opt-out flags only |
16
+ | RARV phases | No | Phase sequence lives in `run_autonomous()` (`autonomy/run.sh`) |
17
+ | MCP tools | Yes | Standard MCP server config (see `references/mcp-integration.md`) |
18
+
19
+ Only the reviewer pool has a supported, tested, data-only extension seam. The
20
+ rest are opt-out (disable) surfaces, not opt-in (add) surfaces. If you need a new
21
+ blocking gate, that is an engine change, not an extension.
22
+
23
+ ## The reviewer seam (R10)
24
+
25
+ `autonomy/run.sh:14002-14036` loads user-installed agents into a dict named
26
+ `INSTALLED_SPECIALISTS` (declared at `:14013`), separate from the built-in
27
+ `SPECIALISTS` pool.
28
+
29
+ Line numbers drift. Re-anchor with
30
+ `grep -n "R10 extension seam" autonomy/run.sh` before relying on them.
31
+
32
+ ### Install
33
+
34
+ Write a manifest and install it. The source may be a local path, a git repo URL,
35
+ or a raw manifest URL (`autonomy/loki:26774`).
36
+
37
+ ```json
38
+ {
39
+ "type": "a11y-auditor",
40
+ "name": "Accessibility Auditor",
41
+ "swarm": "review",
42
+ "capabilities": "WCAG and screen-reader review",
43
+ "focus": ["aria", "accessibility", "contrast", "keyboard"],
44
+ "persona": "You audit for WCAG 2.2 AA compliance."
45
+ }
46
+ ```
47
+
48
+ ```bash
49
+ loki agent install ./my-agent/manifest.json
50
+ # Installed agent: a11y-auditor (Accessibility Auditor)
51
+ # Stored in .loki/agents/installed.json
52
+ ```
53
+
54
+ `focus` is load-bearing: those strings become the keywords that decide whether
55
+ your reviewer fires on a given diff. An agent with an empty `focus` is skipped
56
+ rather than made always-on (`autonomy/run.sh:14027`).
57
+
58
+ ### When it fires
59
+
60
+ Your reviewer only fires when one of its `focus` keywords appears in the diff or
61
+ changed-file list, so a backend-only change does not pay for an a11y auditor.
62
+ Verified by running the real selector block extracted from `run.sh` against a
63
+ diff containing `aria-label`, `contrast`, `keyboard`:
64
+
65
+ ```
66
+ REVIEWERS: ['architecture-strategist', 'maintainer-mergeability',
67
+ 'eng-frontend', 'security-sentinel', 'a11y-auditor']
68
+ ```
69
+
70
+ The silent case (a diff matching none of the agent's keywords leaves it out of
71
+ the pool) is covered by the `stays silent on a diff its keywords do not match`
72
+ case in `tests/test-installed-agent-reviewer.sh`.
73
+
74
+ ## Three invariants you cannot override
75
+
76
+ These are deliberate. An extension can only ADD scrutiny.
77
+
78
+ 1. **Never displaces a built-in reviewer.** Installed agents are kept in a
79
+ separate dict and APPENDED after the built-in `ranked[:want]` selection
80
+ (`autonomy/run.sh:14011` records that an earlier version displaced
81
+ `security-sentinel`, which is why the dicts are split). In the run above,
82
+ `security-sentinel` is still present alongside the installed agent.
83
+ 2. **Never shadows a built-in type.** An installed agent whose `type` collides
84
+ with a built-in is skipped (`autonomy/run.sh:14022-14024`).
85
+ 3. **Capped at 2 installed reviewers per run.** `_MAX_INSTALLED_REVIEWERS = 2`
86
+ (`autonomy/run.sh:14104`). Each appended agent costs one more LLM reviewer
87
+ call every iteration. Verified: three installed agents whose keywords all
88
+ match one diff yield exactly two appended reviewers
89
+ (`['a11y-one', 'a11y-two']`, the third dropped).
90
+
91
+ A corrupt or absent `installed.json` is swallowed and leaves the normal battery
92
+ intact (`autonomy/run.sh:14035-14036`) -- extensions must never be able to break
93
+ code review.
94
+
95
+ ## Manifests are data, never code
96
+
97
+ `hub_install.py` stores manifest fields; it never executes anything from them.
98
+ Executable-looking fields are stripped and reported. Verified by installing a
99
+ manifest carrying a `postinstall` field:
100
+
101
+ ```
102
+ {"type": "a11y-auditor", ..., "_ignored_executable_fields": ["postinstall"]}
103
+ ```
104
+
105
+ The command in that field did not run. Covered by the test
106
+ `manifest postinstall field is not stored and never runs (data-only)`.
107
+
108
+ ## `src/plugins/` is NOT a seam (status: unwired)
109
+
110
+ `src/plugins/` contains `GatePlugin`, `AgentPlugin`, `MCPPlugin`,
111
+ `IntegrationPlugin`, a loader and a validator, with 84 passing tests in
112
+ `tests/plugins/`.
113
+
114
+ **Status: not wired.** No engine file calls any of it, and the loop will never
115
+ call it. There is no `require` of `src/plugins` in
116
+ `autonomy/run.sh`, `autonomy/loki`, or anywhere outside its own tests, and
117
+ `tests/plugins/` is absent from the `test` script in `package.json`, from
118
+ `tests/run-all-tests.sh`, and from `scripts/local-ci.sh` -- so those 84 tests do
119
+ not run in CI.
120
+
121
+ Do not register a gate through `GatePlugin` expecting the loop to execute it.
122
+ Registration succeeds and nothing ever calls the gate. It is documented here so
123
+ the next reader does not mistake a green test suite for a working feature.
124
+
125
+ ## Adding a real gate
126
+
127
+ If you need a new blocking gate, it is an engine change: add the phase to
128
+ `autonomy/run.sh`, give it an opt-out flag matching the `LOKI_GATE_*` convention
129
+ in `skills/quality-gates.md`, and mirror it on the Bun route. Follow
130
+ `skills/sdlc-fleet.md` -- a new gate is a non-trivial change and needs the
131
+ council.
@@ -32,7 +32,7 @@ Claude Fable 5 (alias `fable`, full id `claude-fable-5`) is Anthropic's most cap
32
32
 
33
33
  ### Mid-flight model switching
34
34
 
35
- You can change the model a **live run** uses, from the dashboard or by writing a state file. The switch applies at the **next iteration boundary** (each iteration spawns a fresh `claude -p`, which fixes the model per invocation, so it never changes mid-invocation). The override applies to the **current run only**: the runner clears a leftover override at the start of a fresh run, so a switch never silently carries into future runs. The override is also clamped by `LOKI_MAX_TIER`: if the operator set a cost ceiling, an override above it is clamped down with one honest log line, and the dashboard reports the clamped effective model. The clamp is scoped, not blanket: on the override path a `sonnet` ceiling downgrades only `fable` (to `PROVIDER_MODEL_DEVELOPMENT`, opus by default), while a plain `opus` override stays opus; a `haiku` ceiling pins to `PROVIDER_MODEL_FAST`; an `opus` ceiling caps only `fable` back to opus. (The clamp is enforced on the bash runner, which is the live `start` route; the experimental Bun runner does not yet port it.)
35
+ You can change the model a **live run** uses, from the dashboard or by writing a state file. The switch applies at the **next iteration boundary** (each iteration spawns a fresh `claude -p`, which fixes the model per invocation, so it never changes mid-invocation). The override applies to the **current run only**: the runner clears a leftover override at the start of a fresh run, so a switch never silently carries into future runs. The override is also clamped by `LOKI_MAX_TIER`: if the operator set a cost ceiling, an override above it is clamped down with one honest log line, and the dashboard reports the clamped effective model. The clamp is scoped, not blanket: on the override path a `sonnet` ceiling downgrades only `fable` (to `PROVIDER_MODEL_DEVELOPMENT`, sonnet by default per `providers/claude.sh:61`), while a plain `opus` override stays opus; a `haiku` ceiling pins to `PROVIDER_MODEL_FAST`; an `opus` ceiling caps only `fable` back to opus. (The clamp is enforced on the bash runner, which is the live `start` route; the experimental Bun runner does not yet port it.)
36
36
 
37
37
  - Dashboard: the Model selector in the session-control panel. The Fable option shows its 2x-Opus cost; an inline notice discloses the iteration-boundary timing. It calls `POST /api/session/model`.
38
38
  - File / CLI: write an allowlisted alias (`haiku`, `sonnet`, `opus`, `fable`) to `.loki/state/model-override`. Empty or absent file reverts to the tier mapping. Invalid content is ignored (the runtime warns once). The value is allowlist-validated because it is fed straight into `claude --model`.
@@ -58,7 +58,7 @@ For diffs that move money or mutate infrastructure (payments, billing, spend aut
58
58
 
59
59
  ### Cost estimate honesty
60
60
 
61
- `loki plan` quotes the model the runner actually dispatches, on **every** path, for the levers the runner honors. This includes the stock no-lever default: the `sonnet` session pin resolves through the development tier to opus, so the quote names Opus (not Sonnet) on the default path. Set `LOKI_SESSION_MODEL=fable`, or have a pending `.loki/state/model-override` of `fable`, and the estimate uses Fable's $10/$50 pricing with a 2x-Opus note. The quote, the dashboard `effective` model, and the actual `claude --model` argument always agree on both routes: the session-pin tier route (no override) and the override-path clamp (override present) are each mirrored exactly in the estimator and the dashboard, so the same lever that changes the quote changes the run. `LOKI_MAX_TIER` is applied to the quote too, so the estimate never quotes a model above the operator's cost ceiling. (`LOKI_MODEL` is not a session lever and does not affect the run; use `LOKI_SESSION_MODEL`.)
61
+ `loki plan` quotes the model the runner actually dispatches, on **every** path, for the levers the runner honors. This includes the stock no-lever default: the `sonnet` session pin resolves through the development tier to sonnet, so the quote names Sonnet on the default path (`providers/claude.sh:61` sets `CLAUDE_DEFAULT_DEVELOPMENT="sonnet"`, and the estimator mirrors it at `autonomy/loki:17378-17382`; a stock `loki plan` prints `Model distribution: Sonnet x1`). Set `LOKI_SESSION_MODEL=fable`, or have a pending `.loki/state/model-override` of `fable`, and the estimate uses Fable's $10/$50 pricing with a 2x-Opus note. The quote, the dashboard `effective` model, and the actual `claude --model` argument always agree on both routes: the session-pin tier route (no override) and the override-path clamp (override present) are each mirrored exactly in the estimator and the dashboard, so the same lever that changes the quote changes the run. `LOKI_MAX_TIER` is applied to the quote too, so the estimate never quotes a model above the operator's cost ceiling. (`LOKI_MODEL` is not a session lever and does not affect the run; use `LOKI_SESSION_MODEL`.)
62
62
 
63
63
  **Where the dispatched model is disclosed (precisely):**
64
64
  - Override path (a `.loki/state/model-override` alias): the runner logs the override and the clamp line (`model override: <model>`, plus the one honest clamp-down log line when `LOKI_MAX_TIER` reduces it); the dashboard `GET /api/session/model` `effective` field reports the clamped model.
@@ -68,14 +68,29 @@ For diffs that move money or mutate infrastructure (payments, billing, spend aut
68
68
 
69
69
  ## Multi-Provider Support (v5.0.0)
70
70
 
71
- Loki Mode supports five AI providers. Claude has full features; all others run in **degraded mode** (sequential execution only, no Task tool, no parallel agents).
72
-
73
- | Provider | Full Features | Degraded | CLI Flag |
74
- |----------|---------------|----------|----------|
75
- | **Claude Code** | Yes | No | `--provider claude` (default) |
76
- | **OpenAI Codex CLI** | No | Yes | `--provider codex` |
77
- | **Cline CLI** | No | Yes | `--provider cline` |
78
- | **Aider** | No | Yes | `--provider aider` |
71
+ Loki Mode supports five AI providers. The authoritative list is
72
+ `SUPPORTED_PROVIDERS` in `providers/loader.sh:8`; each provider's capability
73
+ flags live in its own `providers/<name>.sh`. Claude has full features. Every
74
+ other provider runs sequentially (no Task tool, no parallel agents), but only
75
+ those with `PROVIDER_DEGRADED=true` are flagged degraded.
76
+
77
+ | Provider | Full Features | `PROVIDER_DEGRADED` | CLI Flag |
78
+ |----------|---------------|---------------------|----------|
79
+ | **Claude Code** | Yes | false (`providers/claude.sh:104`) | `--provider claude` (default) |
80
+ | **OpenAI Codex CLI** | No | true (`providers/codex.sh:180`) | `--provider codex` |
81
+ | **Cline CLI** | No | false (`providers/cline.sh:97`) | `--provider cline` |
82
+ | **Aider** | No | true (`providers/aider.sh:95`) | `--provider aider` |
83
+ | **opencode** | No | false (`providers/opencode.sh:66`) | `--provider opencode` |
84
+
85
+ Only **codex** and **aider** are flagged degraded. Cline and opencode are
86
+ sequential but not degraded, so do not describe them as degraded-mode providers.
87
+
88
+ **opencode** is the model-agnostic route: it reaches 75+ model providers, so it
89
+ is the path to cheap/open models. It is sequential like the others
90
+ (`PROVIDER_HAS_SUBAGENTS=false`, `PROVIDER_HAS_PARALLEL=false`,
91
+ `PROVIDER_HAS_TASK_TOOL=false` at `providers/opencode.sh:61-63`) but it does
92
+ support MCP (`PROVIDER_HAS_MCP=true`, `:64`), which is why it is not marked
93
+ degraded.
79
94
 
80
95
  **Degraded mode limitations:**
81
96
  - No Task tool (cannot spawn subagents)