loki-mode 11.2.1 → 11.3.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 (73) hide show
  1. package/SKILL.md +2 -2
  2. package/VERSION +1 -1
  3. package/autonomy/lib/skill-link-heal.sh +78 -1
  4. package/autonomy/loki +96 -1195
  5. package/autonomy/provider-offer.sh +4 -4
  6. package/bin/loki +14 -0
  7. package/dashboard/__init__.py +1 -1
  8. package/docs/CLI-REFERENCE.md +1 -1
  9. package/docs/INSTALLATION.md +1 -1
  10. package/docs/KNOWN-ISSUES.md +0 -1
  11. package/docs/v10/ADOPTION-PLAN.md +2 -2
  12. package/docs/v10/FAILURE-CLASSES.md +21 -0
  13. package/loki-ts/dist/loki.js +724 -702
  14. package/mcp/__init__.py +1 -1
  15. package/package.json +1 -1
  16. package/plugins/loki-mode/.claude-plugin/plugin.json +1 -1
  17. package/web-app/dist/assets/{AdminPage-BZ1QNSju.js → AdminPage-D_e45uTw.js} +1 -1
  18. package/web-app/dist/assets/{Avatar-Dnzzsk2U.js → Avatar-T3nIpoaf.js} +1 -1
  19. package/web-app/dist/assets/{Badge-DWtsAw6M.js → Badge-BKqb59pP.js} +1 -1
  20. package/web-app/dist/assets/{Button-zEMFjJEr.js → Button-DjVlkh8_.js} +1 -1
  21. package/web-app/dist/assets/{CockpitPage-Ddo5Zhnz.js → CockpitPage-DXJgLGmh.js} +1 -1
  22. package/web-app/dist/assets/{ComparePage-kCkyRrrz.js → ComparePage-BRP_eVc_.js} +1 -1
  23. package/web-app/dist/assets/{ErrorBoundary-BZrVi8Ui.js → ErrorBoundary-CLjpGLjC.js} +1 -1
  24. package/web-app/dist/assets/{EvidenceReceiptPanel-B9eDIWUr.js → EvidenceReceiptPanel-DX-vb4Xy.js} +1 -1
  25. package/web-app/dist/assets/{GitHubIssuesPanel-DX5A2UkV.js → GitHubIssuesPanel-214HVHtX.js} +1 -1
  26. package/web-app/dist/assets/{GitHubPRsPanel-DKKxBe6-.js → GitHubPRsPanel-BEiZKc4t.js} +1 -1
  27. package/web-app/dist/assets/{HomePage-BSn_-fHq.js → HomePage-VvMHS5ga.js} +2 -2
  28. package/web-app/dist/assets/{LoginPage-C1BGwj0s.js → LoginPage-5WD-v7n3.js} +1 -1
  29. package/web-app/dist/assets/{MagicPage-B9JJw_BQ.js → MagicPage-DUQT_Jye.js} +1 -1
  30. package/web-app/dist/assets/{MetricsPage-B-lNQr8Q.js → MetricsPage-lga-z79v.js} +1 -1
  31. package/web-app/dist/assets/{NotFoundPage-DkktBNvI.js → NotFoundPage-CGhBX3G1.js} +1 -1
  32. package/web-app/dist/assets/{ProjectPage-DUAraQw9.js → ProjectPage-CdzF2GzZ.js} +3 -3
  33. package/web-app/dist/assets/{ProjectsPage-DgMo4QHm.js → ProjectsPage-DRG64N8s.js} +1 -1
  34. package/web-app/dist/assets/{SettingsPage-fnL3nqGC.js → SettingsPage-5fzW9Frb.js} +1 -1
  35. package/web-app/dist/assets/{ShowcasePage-Bn7rWTXn.js → ShowcasePage-DrTdxO4y.js} +1 -1
  36. package/web-app/dist/assets/{SystemSettingsPage-DxrytIXT.js → SystemSettingsPage-C9Mqs6Rd.js} +1 -1
  37. package/web-app/dist/assets/{TeamsPage-BQDC12Mn.js → TeamsPage-CUGPdSWo.js} +1 -1
  38. package/web-app/dist/assets/{TemplatesPage-C_JEygcZ.js → TemplatesPage-CNraDY0F.js} +1 -1
  39. package/web-app/dist/assets/{TerminalOutput-H0584wn0.js → TerminalOutput-BIu_6Pea.js} +1 -1
  40. package/web-app/dist/assets/{activity-Bse7FP2H.js → activity-Bk-kgqaf.js} +1 -1
  41. package/web-app/dist/assets/{bell-BMtgCGSW.js → bell-BXmUdmo8.js} +1 -1
  42. package/web-app/dist/assets/{bot-6SW_XTZN.js → bot-CiuzXxUR.js} +1 -1
  43. package/web-app/dist/assets/{check-C3f9aTHx.js → check-BEzjXppd.js} +1 -1
  44. package/web-app/dist/assets/{chevron-left-Bi60XjBc.js → chevron-left-AYWCXHf8.js} +1 -1
  45. package/web-app/dist/assets/{circle-alert-Bepfb6dn.js → circle-alert-BNg2uSRq.js} +1 -1
  46. package/web-app/dist/assets/{clock-jQIU5AuJ.js → clock-DTj0kkGT.js} +1 -1
  47. package/web-app/dist/assets/{cloud-BLZn6J9A.js → cloud-x4nTDBLT.js} +1 -1
  48. package/web-app/dist/assets/{code-xml-CRgKi92z.js → code-xml-BVh_MLrk.js} +1 -1
  49. package/web-app/dist/assets/{database-DWg9XT6Y.js → database-DdfBaowG.js} +1 -1
  50. package/web-app/dist/assets/{dollar-sign-Cfif5nlf.js → dollar-sign-XuYFmAOY.js} +1 -1
  51. package/web-app/dist/assets/{file-code-corner-DSiLXED8.js → file-code-corner-D4cRnmeL.js} +1 -1
  52. package/web-app/dist/assets/{file-plus-8lIzYfAS.js → file-plus-CyoVlHc_.js} +1 -1
  53. package/web-app/dist/assets/{globe-DZkvR077.js → globe-Y6IK2RPW.js} +1 -1
  54. package/web-app/dist/assets/{hammer-BtQyfpf_.js → hammer-BKzr9VZI.js} +1 -1
  55. package/web-app/dist/assets/{index-B-uQiPRR.js → index-B20cSx5G.js} +3 -3
  56. package/web-app/dist/assets/{layers-Dlfo3jwV.js → layers-DebNOhGS.js} +1 -1
  57. package/web-app/dist/assets/{loader-circle-BQ2hL7m5.js → loader-circle-DyGbrEgd.js} +1 -1
  58. package/web-app/dist/assets/{lock-LzpJKEAP.js → lock-BKPpyt20.js} +1 -1
  59. package/web-app/dist/assets/{package-zp-qZxpp.js → package-egF5Wi4y.js} +1 -1
  60. package/web-app/dist/assets/{plus-CuiDkGsm.js → plus-OiEgKIc-.js} +1 -1
  61. package/web-app/dist/assets/{refresh-cw-Dx57uk3-.js → refresh-cw-CzKvrONq.js} +1 -1
  62. package/web-app/dist/assets/{rotate-ccw-Bp1YOkM6.js → rotate-ccw-Bu2mII0p.js} +1 -1
  63. package/web-app/dist/assets/{scroll-text-Dc8LjRT5.js → scroll-text-CSkiyax8.js} +1 -1
  64. package/web-app/dist/assets/{server-CE0Mzyfz.js → server-DlPthJFw.js} +1 -1
  65. package/web-app/dist/assets/{shield-alert-DBj71EDP.js → shield-alert-CbpHWQQX.js} +1 -1
  66. package/web-app/dist/assets/{thumbs-up-C0q9J-0y.js → thumbs-up-DgGpZe-Q.js} +1 -1
  67. package/web-app/dist/assets/{trash-2-YNPSnz_h.js → trash-2-BV3GlCB-.js} +1 -1
  68. package/web-app/dist/assets/{trending-down-BlyQB_Gh.js → trending-down-Y4TJtXhD.js} +1 -1
  69. package/web-app/dist/assets/{trending-up-CStScgAm.js → trending-up-DsDNoHZY.js} +1 -1
  70. package/web-app/dist/assets/{upload-CLs-rKu9.js → upload-gkThrc_I.js} +1 -1
  71. package/web-app/dist/assets/{usePolling-BmFkptqQ.js → usePolling-jpNSeNAe.js} +1 -1
  72. package/web-app/dist/assets/{user-CV0TBz6A.js → user-3Zez1WOK.js} +1 -1
  73. package/web-app/dist/index.html +1 -1
package/autonomy/loki CHANGED
@@ -13945,1226 +13945,127 @@ print(json.dumps({
13945
13945
  return 0
13946
13946
  }
13947
13947
 
13948
- cmd_doctor() {
13949
- local json_output=false
13950
- local airgap_audit=false
13951
-
13952
- while [[ $# -gt 0 ]]; do
13953
- case "$1" in
13954
- --json)
13955
- json_output=true
13956
- shift
13957
- ;;
13958
- --airgap)
13959
- airgap_audit=true
13960
- shift
13961
- ;;
13962
- --help|-h)
13963
- echo -e "${BOLD}loki doctor${NC} - Check system prerequisites"
13964
- echo ""
13965
- echo "Usage: loki doctor [--json] [--airgap]"
13966
- echo ""
13967
- echo "Options:"
13968
- echo " --json Output machine-readable JSON"
13969
- echo " --airgap Audit network egress: model inference, telemetry and"
13970
- echo " the update check, whether each is REQUIRED or optional,"
13971
- echo " and how to disable it; names what it does not audit"
13972
- echo " (package installs, delegate PR). Exits non-zero if any"
13973
- echo " required egress remains."
13974
- echo ""
13975
- echo "Checks: node, python3, jq, git, curl, bash version,"
13976
- echo " provider CLIs, disk space, and cockpit render capability."
13977
- return 0
13978
- ;;
13979
- *)
13980
- echo -e "${RED}Unknown option: $1${NC}"
13981
- echo "Usage: loki doctor [--json] [--airgap]"
13982
- return 1
13983
- ;;
13984
- esac
13985
- done
13986
-
13987
- if [ "$airgap_audit" = true ]; then
13988
- _loki_airgap_audit "$json_output"
13989
- return $?
13990
- fi
13991
-
13992
- if [ "$json_output" = true ]; then
13993
- cmd_doctor_json
13994
- return $?
13995
- fi
13996
-
13997
- echo -e "${BOLD}Loki Mode Doctor${NC}"
13998
- echo ""
13999
- echo "Checking system prerequisites..."
14000
- echo ""
14001
-
14002
- local pass_count=0
14003
- local fail_count=0
14004
- # Set to 1 when the bundled SDK is the ONLY usable provider (no CLI on PATH).
14005
- # Declared here, not inside the branch that sets it, so the "Next:" block at
14006
- # the end of this function always reads an initialized value.
14007
- local _doctor_sdk_only=0
14008
- # FUNNEL FIX (v8.2.x): collect the NAME + FIX of every hard failure, not just
14009
- # a count. A first-time user on a bare machine sees 15 warnings (sentrux, GPG
14010
- # signing, bash 4, Bun, python3.12, truecolor, inline-image probe -- all
14011
- # OPTIONAL) around 2 real blockers, then a summary that says only "Some
14012
- # required prerequisites are missing" without naming them. That is the point
14013
- # where someone closes the terminal. Naming the blockers and their exact
14014
- # install command turns a wall of yellow into a two-line to-do.
14015
- local _doctor_blockers=""
14016
- _doctor_block() { _doctor_blockers="${_doctor_blockers}\n - $1"; }
14017
- local warn_count=0
14018
-
14019
- # Helper: check command exists and optionally check version
14020
- doctor_check() {
14021
- local name="$1"
14022
- local cmd="$2"
14023
- local required="$3" # required, recommended, optional
14024
- local min_version="${4:-}"
14025
-
14026
- if ! command -v "$cmd" &> /dev/null; then
14027
- if [ "$required" = "required" ]; then
14028
- echo -e " ${RED}FAIL${NC} $name - not found"
14029
- _doctor_block "${name%% (*} is not installed"
14030
- fail_count=$((fail_count + 1))
14031
- elif [ "$required" = "recommended" ]; then
14032
- echo -e " ${YELLOW}WARN${NC} $name - not found (recommended)"
14033
- warn_count=$((warn_count + 1))
14034
- else
14035
- echo -e " ${YELLOW}WARN${NC} $name - not found (optional)"
14036
- warn_count=$((warn_count + 1))
14037
- fi
14038
- return 1
14039
- fi
14040
-
14041
- local version=""
14042
- case "$cmd" in
14043
- node)
14044
- version=$(node --version 2>/dev/null | tr -d 'v')
14045
- ;;
14046
- python3)
14047
- version=$(python3 --version 2>/dev/null | awk '{print $2}')
14048
- ;;
14049
- git)
14050
- version=$(git --version 2>/dev/null | awk '{print $3}')
14051
- ;;
14052
- bash)
14053
- version=$("$cmd" --version 2>/dev/null | head -1 | sed 's/.*version \([0-9.]*\).*/\1/')
14054
- ;;
14055
- bun)
14056
- version=$(bun --version 2>/dev/null | head -1 | tr -d 'v ')
14057
- ;;
14058
- jq)
14059
- version=$(jq --version 2>/dev/null | tr -d 'jq-')
14060
- ;;
14061
- curl)
14062
- version=$(curl --version 2>/dev/null | head -1 | awk '{print $2}')
14063
- ;;
14064
- claude)
14065
- version=$(claude --version 2>/dev/null | head -1 | sed 's/[^0-9.]//g' | head -1)
14066
- ;;
14067
- codex)
14068
- version=$(codex --version 2>/dev/null | head -1 | sed 's/[^0-9.]//g' | head -1)
14069
- ;;
14070
- cline)
14071
- version=$(cline --version 2>/dev/null | head -1 | sed 's/[^0-9.]//g' | head -1)
14072
- ;;
14073
- aider)
14074
- version=$(aider --version 2>/dev/null | head -1 | sed 's/[^0-9.]//g' | head -1)
14075
- ;;
14076
- opencode)
14077
- version=$(opencode --version 2>/dev/null | head -1 | sed 's/[^0-9.]//g' | head -1)
14078
- ;;
14079
- esac
14080
-
14081
- local version_display=""
14082
- if [ -n "$version" ]; then
14083
- version_display=" (v$version)"
14084
- fi
14085
-
14086
- # Simple major version check if min_version is specified
14087
- if [ -n "$min_version" ] && [ -n "$version" ]; then
14088
- local cur_major cur_minor min_major min_minor
14089
- cur_major=$(echo "$version" | cut -d. -f1)
14090
- min_major=$(echo "$min_version" | cut -d. -f1)
14091
- cur_minor=$(echo "$version" | cut -d. -f2)
14092
- min_minor=$(echo "$min_version" | cut -d. -f2)
14093
-
14094
- if [ "$cur_major" -lt "$min_major" ] 2>/dev/null || \
14095
- { [ "$cur_major" -eq "$min_major" ] 2>/dev/null && [ "$cur_minor" -lt "$min_minor" ] 2>/dev/null; }; then
14096
- if [ "$required" = "required" ]; then
14097
- echo -e " ${RED}FAIL${NC} $name$version_display - requires >= $min_version"
14098
- _doctor_block "${name%% (*} must be >= $min_version"
14099
- fail_count=$((fail_count + 1))
14100
- else
14101
- echo -e " ${YELLOW}WARN${NC} $name$version_display - recommended >= $min_version"
14102
- warn_count=$((warn_count + 1))
14103
- fi
14104
- return 1
14105
- fi
14106
- fi
14107
-
14108
- echo -e " ${GREEN}PASS${NC} $name$version_display"
14109
- pass_count=$((pass_count + 1))
14110
- return 0
14111
- }
14112
-
14113
- echo -e "${CYAN}Required:${NC}"
14114
- doctor_check "Node.js (>= 18)" node required 18.0 || true
14115
- doctor_check "Python 3 (>= 3.8)" python3 required 3.8 || true
14116
- doctor_check "jq" jq required || true
14117
- doctor_check "git" git required || true
14118
- doctor_check "curl" curl required || true
14119
- echo ""
14120
-
14121
- echo -e "${CYAN}AI Providers:${NC}"
14122
- # C1 (doctor UX): when an individual provider CLI is missing, print the
14123
- # EXACT install command right under the WARN line so the user never has to
14124
- # look it up. doctor_check returns 1 when the CLI is absent; we capture that
14125
- # and emit the canonical install command for that provider. Honest: this
14126
- # only PRINTS the command -- the offer-to-run path lives below, gated on a
14127
- # TTY, and only when NO provider at all is installed.
14128
- doctor_provider_install_cmd() {
14129
- case "$1" in
14130
- claude) echo "npm install -g @anthropic-ai/claude-code" ;;
14131
- codex) echo "npm install -g @openai/codex" ;;
14132
- cline) echo "npm install -g cline" ;;
14133
- aider) echo "pip install aider-chat" ;;
14134
- opencode) echo "npm install -g opencode-ai" ;;
14135
- *) echo "" ;;
14136
- esac
14137
- }
14138
- doctor_check_provider() {
14139
- local _label="$1" _cmd="$2"
14140
- if ! doctor_check "$_label" "$_cmd" optional; then
14141
- local _install
14142
- _install=$(doctor_provider_install_cmd "$_cmd")
14143
- # Route the per-provider install hint to STDERR (fd 2), mirroring the
14144
- # doctor_probe_note stderr-only pattern above. The Bun route emits the
14145
- # same hint on STDOUT immediately after the provider WARN line; under
14146
- # the parity harness's 2>&1 capture both land adjacent to the WARN, so
14147
- # the two routes stay byte-identical when an optional provider is absent
14148
- # (v7.63.0: closed the cline-absent parity gap by adding the Bun hint).
14149
- [ -n "$_install" ] && echo -e " ${YELLOW}Install: ${_install}${NC}" >&2
14150
- fi
14151
- }
14152
- doctor_check_provider "Claude CLI" claude || true
14153
- doctor_check_provider "Codex CLI" codex || true
14154
- doctor_check_provider "Cline CLI" cline || true
14155
- doctor_check_provider "Aider CLI" aider || true
14156
- doctor_check_provider "opencode CLI" opencode || true
14157
-
14158
- # Check if at least one provider is installed (detect_any_provider is the
14159
- # shared helper from provider-offer.sh, extracted from this exact loop).
14160
- if ! detect_any_provider; then
14161
- # The bundled Claude Agent SDK runs the main loop with no separate CLI,
14162
- # but ONLY when it is genuinely usable: binary extracted, credentials
14163
- # present, and the SDK loop actually active. detect_bundled_sdk_provider
14164
- # fails closed on any doubt, so when it returns false we emit today's
14165
- # blocker verbatim. Mirrored in loki-ts/src/commands/doctor.ts.
14166
- if declare -f detect_bundled_sdk_provider >/dev/null 2>&1 && detect_bundled_sdk_provider; then
14167
- # Say exactly which commands this covers. "No separate CLI needed" was
14168
- # true for `loki start` and FALSE for demo/quick/quickstart, which stay
14169
- # on the bash route and require a binary on PATH (provider-offer.sh:44
14170
- # documents why folding the SDK predicate into detect_any_provider
14171
- # would be a fail-open). The old wording produced the worst first-run
14172
- # outcome we have: a green doctor ending in "Next: loki quickstart",
14173
- # followed by quickstart, demo, quick and start-on-bash all exiting 2.
14174
- echo -e " ${GREEN}PASS${NC} Bundled Claude Agent SDK is usable -- 'loki start' needs no separate CLI"
14175
- echo -e " ${YELLOW}Note: loki demo/quick/quickstart still need a provider CLI on PATH${NC}"
14176
- echo -e " ${YELLOW} Install: npm install -g @anthropic-ai/claude-code${NC}"
14177
- # The note above was only half the fix. The final "Next:" line at the
14178
- # bottom of this function recommended quickstart unconditionally, so a
14179
- # user reading a GREEN doctor followed its LAST line straight into
14180
- # quickstart.sh:476 exit 2. Record the state here (where the predicate
14181
- # is already evaluated) and let the recommendation read it.
14182
- _doctor_sdk_only=1
14183
- pass_count=$((pass_count + 1))
14184
- else
14185
- echo -e " ${RED}FAIL${NC} No AI provider CLI installed -- at least one is required"
14186
- echo -e " ${YELLOW}Install: npm install -g @anthropic-ai/claude-code${NC}"
14187
- _doctor_block "No AI provider CLI. Fix: npm install -g @anthropic-ai/claude-code"
14188
- fail_count=$((fail_count + 1))
14189
- # v7.29.0: on a TTY (non-json), append the consent-gated install offer.
14190
- # In report mode the helper is a no-op on non-TTY/CI, so the doctor
14191
- # output stays byte-identical for the bun-parity matrix. --json never
14192
- # reaches here (cmd_doctor dispatches to cmd_doctor_json earlier).
14193
- if declare -f offer_provider_install >/dev/null 2>&1; then
14194
- offer_provider_install report || true
14195
- fi
13948
+ # _loki_bun_delegate <cmd> [args...]
13949
+ # Hand a command to its single Bun implementation (FC-DUP, docs/v10/FAILURE-CLASSES.md).
13950
+ # Resolves bun the way bin/loki's _loki_bun does (PATH first, then an npm-installed
13951
+ # @oven/bun-* package, including bun.exe on Windows), resolves the CLI (dist, then
13952
+ # src), preflights `bun <cli> version` under a 5s timeout (when `timeout` exists),
13953
+ # then execs. On success it never returns. On failure it returns 1 and sets
13954
+ # _LOKI_DELEGATE_WHY (the single reason) and _LOKI_DELEGATE_FIX (one fix line) so
13955
+ # the caller can print a minimal diagnostic.
13956
+ _loki_bun_delegate() {
13957
+ local cmd="$1"
13958
+ shift
13959
+ local root="${_LOKI_SCRIPT_DIR}/.."
13960
+ local c bun_bin="" cli="" t="" rc=0 found=0
13961
+ _LOKI_DELEGATE_WHY=""
13962
+ _LOKI_DELEGATE_FIX=""
13963
+
13964
+ for c in "$(command -v bun 2>/dev/null || true)" \
13965
+ "$root"/node_modules/@oven/bun-*/bin/bun \
13966
+ "$root"/node_modules/@oven/bun-*/bin/bun.exe \
13967
+ "$root/node_modules/bun/bin/bun.exe"; do
13968
+ [ -n "$c" ] && [ -x "$c" ] || continue
13969
+ found=1
13970
+ if "$c" --version >/dev/null 2>&1; then
13971
+ bun_bin="$c"
13972
+ break
14196
13973
  fi
14197
- fi
14198
- echo ""
14199
-
14200
- # A-123: an EXPLICIT LOKI_PROVIDER names the CLI a build will launch, so that
14201
- # CLI is required even when another provider is installed. Auto-detection
14202
- # never reaches here: it only picks an installed provider. Bundled-SDK
14203
- # claude stays allowed. Mirrored in loki-ts/src/commands/doctor.ts.
14204
- if [ -n "${LOKI_PROVIDER:-}" ] && [ "$fail_count" -eq 0 ] \
14205
- && ! command -v "$LOKI_PROVIDER" >/dev/null 2>&1 \
14206
- && ! { [ "$LOKI_PROVIDER" = "claude" ] && [ "$_doctor_sdk_only" = "1" ]; }; then
14207
- _sel_install=$(doctor_provider_install_cmd "$LOKI_PROVIDER")
14208
- echo -e " ${RED}FAIL${NC} Selected provider '${LOKI_PROVIDER}' CLI not found"
14209
- _doctor_block "Selected provider ${LOKI_PROVIDER} CLI not found. Fix: ${_sel_install:-install ${LOKI_PROVIDER}, or unset LOKI_PROVIDER}"
14210
- fail_count=$((fail_count + 1))
14211
- fi
14212
-
14213
- # Provider Availability. Which provider a build would ACTUALLY pick, which
14214
- # is decided by providers/loader.sh auto_detect_provider, not by PATH order
14215
- # and not by the section above. Rendered by the shared helper in
14216
- # provider-offer.sh so the Bun route prints the identical bytes (doctor
14217
- # stdout is byte-compared -- tests/test-doctor-blocker-parity.sh). Skipped
14218
- # silently, with no error, when loader.sh is missing or unsourceable.
14219
- if declare -f render_provider_availability >/dev/null 2>&1; then
14220
- if render_provider_availability; then
14221
- echo ""
14222
- fi
14223
- fi
14224
-
14225
- echo -e "${CYAN}API Keys:${NC}"
14226
- # Note: CLI tools use their own login sessions outside Docker/K8s.
14227
- # This section helps first-time users verify they can authenticate.
14228
- if [ -n "${ANTHROPIC_API_KEY:-}" ]; then
14229
- echo -e " ${GREEN}PASS${NC} ANTHROPIC_API_KEY is set"
14230
- pass_count=$((pass_count + 1))
14231
- elif command -v claude &>/dev/null; then
14232
- # Zero-network login check. v7.104.2: prefer the CLI's own local
14233
- # auth-status (JSON with "loggedIn"), which is authoritative for BOTH the
14234
- # file-based and the native/Keychain login. This fixes doctor missing a
14235
- # genuine Keychain login (native install writes NO ~/.claude/.credentials
14236
- # .json). Fall back to the expiry stamp in the file for older CLIs.
14237
- _claude_login="$(claude auth status 2>/dev/null | python3 -c "
14238
- import json, sys
14239
- try:
14240
- v = json.load(sys.stdin).get('loggedIn')
14241
- # Only the explicit booleans are trusted; a missing/renamed field is
14242
- # inconclusive and falls through to the file-expiry check (never a spurious
14243
- # 'not logged in' warning). Matches the run.sh helper + bun doctor.
14244
- if v is True:
14245
- print('yes')
14246
- elif v is False:
14247
- print('no')
14248
- except Exception:
14249
- pass
14250
- " 2>/dev/null)"
14251
- _claude_creds="${CLAUDE_CONFIG_DIR:-$HOME/.claude}/.credentials.json"
14252
- _claude_expired="$( [ -s "$_claude_creds" ] && python3 -c "
14253
- import json, sys, time
14254
- try:
14255
- d = json.load(open(sys.argv[1]))
14256
- exp = d.get('claudeAiOauth', {}).get('expiresAt')
14257
- print('expired' if isinstance(exp,(int,float)) and exp>0 and (exp/1000.0)<=(time.time()+60) else 'ok')
14258
- except Exception:
14259
- print('ok')
14260
- " "$_claude_creds" 2>/dev/null || echo ok )"
14261
- if [ "$_claude_login" = "yes" ]; then
14262
- echo -e " ${GREEN}PASS${NC} Claude CLI is logged in (subscription/OAuth login)"
14263
- pass_count=$((pass_count + 1))
14264
- elif [ "$_claude_login" = "no" ]; then
14265
- # BLOCKER, not a warning. A build cannot run without a login, and the
14266
- # warning form let a user pass doctor, answer every quickstart prompt,
14267
- # pick a template and CONFIRM THE SPEND before hitting the refusal at
14268
- # run.sh's auth preflight. Discovering a missing login after the
14269
- # consent screen is the worst placement available. Doctor is where a
14270
- # missing credential belongs. (An ANTHROPIC_API_KEY short-circuits
14271
- # that preflight, which is why this branch is reached only when the
14272
- # key is absent too.)
14273
- echo -e " ${RED}FAIL${NC} Claude CLI is NOT logged in -- a build would stall instead of running"
14274
- echo -e " ${YELLOW}Fix: claude login${NC} (or set ANTHROPIC_API_KEY)"
14275
- _doctor_block "Claude CLI is not logged in. Fix: claude login (or set ANTHROPIC_API_KEY)"
14276
- fail_count=$((fail_count + 1))
14277
- elif [ "$_claude_expired" = "expired" ]; then
14278
- echo -e " ${RED}FAIL${NC} Claude login has EXPIRED -- a build would stall instead of running"
14279
- echo -e " ${YELLOW}Fix: claude login${NC} (or set ANTHROPIC_API_KEY)"
14280
- _doctor_block "Claude login has expired. Fix: claude login (or set ANTHROPIC_API_KEY)"
14281
- fail_count=$((fail_count + 1))
14282
- else
14283
- echo -e " ${DIM} -- ${NC} ANTHROPIC_API_KEY not set (Claude CLI uses its own login)"
14284
- fi
14285
- fi
14286
- if [ -n "${OPENAI_API_KEY:-}" ]; then
14287
- echo -e " ${GREEN}PASS${NC} OPENAI_API_KEY is set"
14288
- pass_count=$((pass_count + 1))
14289
- elif command -v codex &>/dev/null; then
14290
- echo -e " ${DIM} -- ${NC} OPENAI_API_KEY not set (Codex CLI uses its own login)"
14291
- fi
14292
- # Phase I (v7.5.25): detect ANTHROPIC_BASE_URL alt-provider routing
14293
- # (OpenRouter, Ollama, LiteLLM, self-hosted). Claude Code reads this env
14294
- # var natively; Loki passes it through unchanged. Warn when the user sets
14295
- # an alt endpoint without LOKI_MODEL_OVERRIDE -- the default opus/sonnet/
14296
- # haiku aliases may not resolve on the alt-provider.
14297
- if [ -n "${ANTHROPIC_BASE_URL:-}" ]; then
14298
- echo -e " ${GREEN}PASS${NC} ANTHROPIC_BASE_URL: ${ANTHROPIC_BASE_URL}"
14299
- pass_count=$((pass_count + 1))
14300
- if [ -z "${LOKI_MODEL_OVERRIDE:-}" ]; then
14301
- echo -e " ${YELLOW}WARN${NC} LOKI_MODEL_OVERRIDE not set -- opus/sonnet/haiku aliases may not resolve on alt-provider"
14302
- warn_count=$((warn_count + 1))
13974
+ done
13975
+ if [ -z "$bun_bin" ]; then
13976
+ if [ "$found" = "1" ]; then
13977
+ _LOKI_DELEGATE_WHY="bun failed to start (found but 'bun --version' exited non-zero)"
13978
+ _LOKI_DELEGATE_FIX="Reinstall bun: curl -fsSL https://bun.sh/install | bash"
14303
13979
  else
14304
- echo -e " ${GREEN}PASS${NC} LOKI_MODEL_OVERRIDE: ${LOKI_MODEL_OVERRIDE}"
14305
- pass_count=$((pass_count + 1))
13980
+ _LOKI_DELEGATE_WHY="bun missing (not on PATH and no @oven/bun-* package under ${root})"
13981
+ _LOKI_DELEGATE_FIX="Install bun: curl -fsSL https://bun.sh/install | bash (or npm install -g bun)"
14306
13982
  fi
13983
+ return 1
14307
13984
  fi
14308
- echo ""
14309
13985
 
14310
- echo -e "${CYAN}Skills:${NC}"
14311
- local skill_dirs=(
14312
- "$HOME/.claude/skills/loki-mode:Claude Code"
14313
- "$HOME/.codex/skills/loki-mode:Codex CLI"
14314
- "$HOME/.cline/skills/loki-mode:Cline CLI"
14315
- "$HOME/.aider/skills/loki-mode:Aider CLI"
14316
- )
14317
- # SEVERITY DEPENDS ON WHOSE SKILL IT IS (v9.48.x). A dangling skill symlink
14318
- # used to block unconditionally, so a stale link left by an old checkout
14319
- # path exited 1 on a HEALTHY machine -- while the Provider Availability
14320
- # section three lines above said that same provider was "not installed".
14321
- # Doctor contradicted itself on one screen and told the user to fix a tool
14322
- # they do not have.
14323
- #
14324
- # The link only blocks when it belongs to the provider a build would
14325
- # actually launch. Anything else is inert state: still reported, with the
14326
- # dangling target and the fix, but not a reason to refuse to start.
14327
- #
14328
- # `|| true` is mandatory under `set -euo pipefail`: the accessor returns
14329
- # non-zero when no provider resolves (a bare call would abort doctor), and
14330
- # an empty id then makes every entry non-blocking -- the same direction the
14331
- # No-AI-provider blocker above already covers.
14332
- local _selected_provider=""
14333
- if declare -f effective_provider_id >/dev/null 2>&1; then
14334
- _selected_provider="$(effective_provider_id 2>/dev/null || true)"
14335
- fi
14336
- for entry in "${skill_dirs[@]}"; do
14337
- local sdir="${entry%%:*}"
14338
- local sname="${entry#*:}"
14339
- local short_path="${sdir/$HOME/~}"
14340
- # Provider id from the directory, never a parallel list: `.claude` ->
14341
- # `claude`. A fifth hand-maintained list is how the lists in this repo
14342
- # have drifted from auto_detect_provider before (v8.76.0, v8.82.0).
14343
- local _sid="${sdir#"$HOME"/.}"
14344
- _sid="${_sid%%/*}"
14345
- if [ -f "$sdir/SKILL.md" ]; then
14346
- echo -e " ${GREEN}PASS${NC} $sname ${DIM}$short_path${NC}"
14347
- pass_count=$((pass_count + 1))
14348
- elif [ -L "$sdir" ]; then
14349
- local _target
14350
- # readlink is NOT in POSIX and is absent from minimal images and
14351
- # from constrained PATHs (the doctor parity harness builds one).
14352
- # When it is missing this reported the literal string "unknown"
14353
- # while the Bun route printed the real target, so the two routes
14354
- # diverged on the same host and bun-parity went red. ls -ld is
14355
- # POSIX and always present; strip through the FIRST " -> " only,
14356
- # since a link target may itself contain that sequence.
14357
- if command -v readlink >/dev/null 2>&1; then
14358
- _target=$(readlink "$sdir" 2>/dev/null || echo "unknown")
14359
- else
14360
- _target=$(ls -ld "$sdir" 2>/dev/null | sed -e 's/^[^>]*-> //')
14361
- [ -n "$_target" ] || _target="unknown"
14362
- fi
14363
- # Blocking ONLY for the provider a build would actually use. The
14364
- # non-blocking arm is a WARN rather than a non-counted FAIL on
14365
- # purpose: a red FAIL badge that never reaches `N failed` or the
14366
- # `Blocking (N)` list makes the summary disagree with the screen.
14367
- # Either way the dangling target and the fix are still printed --
14368
- # the stale link is reported, never silently swallowed.
14369
- if [ -n "$_selected_provider" ] && [ "$_sid" = "$_selected_provider" ]; then
14370
- echo -e " ${RED}FAIL${NC} $sname ${DIM}(broken symlink -> $_target)${NC}"
14371
- echo -e " ${YELLOW}Fix: loki setup-skill${NC}"
14372
- _doctor_block "$sname is a broken symlink. Fix: loki setup-skill"
14373
- fail_count=$((fail_count + 1))
14374
- else
14375
- echo -e " ${YELLOW}WARN${NC} $sname ${DIM}(broken symlink -> $_target)${NC}"
14376
- echo -e " ${YELLOW}Fix: loki setup-skill${NC}"
14377
- warn_count=$((warn_count + 1))
14378
- fi
14379
- else
14380
- echo -e " ${YELLOW}WARN${NC} $sname ${DIM}(not found - run 'loki setup-skill')${NC}"
14381
- warn_count=$((warn_count + 1))
13986
+ if [ -n "${LOKI_TS_ENTRY:-}" ]; then
13987
+ if [ ! -f "$LOKI_TS_ENTRY" ]; then
13988
+ _LOKI_DELEGATE_WHY="LOKI_TS_ENTRY invalid (${LOKI_TS_ENTRY} does not exist)"
13989
+ _LOKI_DELEGATE_FIX="Unset LOKI_TS_ENTRY or point it at a built loki.js"
13990
+ return 1
14382
13991
  fi
14383
- done
14384
- echo ""
14385
-
14386
- echo -e "${CYAN}Integrations:${NC}"
14387
- # MCP SDK check
14388
- if python3 -c "import mcp" 2>/dev/null; then
14389
- echo -e " ${GREEN}PASS${NC} MCP SDK (Python)"
14390
- pass_count=$((pass_count + 1))
14391
- else
14392
- echo -e " ${YELLOW}WARN${NC} MCP SDK - not installed (pip3 install mcp)"
14393
- warn_count=$((warn_count + 1))
14394
- fi
14395
- # Detect best Python for ML packages (3.12 preferred, 3.14+ has compat issues)
14396
- local _py_ml="python3"
14397
- if command -v /opt/homebrew/bin/python3.12 &>/dev/null; then
14398
- _py_ml="/opt/homebrew/bin/python3.12"
14399
- elif command -v python3.12 &>/dev/null; then
14400
- _py_ml="python3.12"
14401
- fi
14402
- # Vector search dependencies
14403
- if $_py_ml -c "import numpy" 2>/dev/null; then
14404
- echo -e " ${GREEN}PASS${NC} numpy (vector search)"
14405
- pass_count=$((pass_count + 1))
14406
- else
14407
- echo -e " ${YELLOW}WARN${NC} numpy - not installed (pip3 install numpy)"
14408
- warn_count=$((warn_count + 1))
14409
- fi
14410
- if $_py_ml -c "import sentence_transformers" 2>/dev/null; then
14411
- echo -e " ${GREEN}PASS${NC} sentence-transformers (embeddings)"
14412
- pass_count=$((pass_count + 1))
14413
- else
14414
- echo -e " ${YELLOW}WARN${NC} sentence-transformers - not installed (loki memory vectors setup)"
14415
- warn_count=$((warn_count + 1))
14416
- fi
14417
- # C3 (doctor UX): the next checks are bounded network probes, not instant
14418
- # local lookups. They are the slowest part of doctor (a stale/unreachable
14419
- # host could otherwise hang on curl's default ~2min connect timeout). We
14420
- # (a) bound every probe with --connect-timeout/--max-time so a dead host
14421
- # fails fast, and (b) print a transient progress note to stderr on a TTY so
14422
- # the user knows doctor is doing network work, not stuck. The note goes to
14423
- # stderr only -- the parity-captured stdout stays byte-identical across the
14424
- # bash and Bun routes.
14425
- doctor_probe_note() {
14426
- # return 0 is mandatory: [ -t 2 ] returns 1 off a TTY, and under
14427
- # set -euo pipefail a bare call here aborts the whole doctor run on
14428
- # CI/headless (the v7.58.1 Bun-Parity crash).
14429
- [ -t 2 ] && printf ' %s...\r' "$1" >&2
14430
- return 0
14431
- }
14432
- doctor_probe_clear() {
14433
- # Erase the transient progress line so it does not linger above the
14434
- # PASS/WARN result. Only on a TTY (where the note was printed).
14435
- # return 0: same set -e safety as doctor_probe_note.
14436
- [ -t 2 ] && printf '\r\033[K' >&2
14437
- return 0
14438
- }
14439
- # ChromaDB check (bounded network probe)
14440
- doctor_probe_note "Probing ChromaDB (port 8100)"
14441
- if curl -sf --connect-timeout 2 --max-time 5 http://localhost:8100/api/v2/heartbeat >/dev/null 2>&1; then
14442
- doctor_probe_clear
14443
- echo -e " ${GREEN}PASS${NC} ChromaDB server (port 8100)"
14444
- pass_count=$((pass_count + 1))
14445
- else
14446
- doctor_probe_clear
14447
- echo -e " ${YELLOW}WARN${NC} ChromaDB - not running (docker start loki-chroma)"
14448
- warn_count=$((warn_count + 1))
14449
- fi
14450
- # LSP servers check (v7.7.0): auto-detected by mcp.lsp_proxy; reported
14451
- # here so users know which languages get agent-side symbol grounding.
14452
- local _lsp_found=0 _lsp_bin
14453
- local _lsp_list=""
14454
- for _lsp_bin in pyright-langserver pylsp typescript-language-server gopls rust-analyzer jdtls; do
14455
- if command -v "$_lsp_bin" >/dev/null 2>&1; then
14456
- _lsp_found=$((_lsp_found + 1))
14457
- _lsp_list="${_lsp_list:+$_lsp_list, }${_lsp_bin}"
13992
+ cli="$LOKI_TS_ENTRY"
13993
+ elif [ -f "$root/loki-ts/dist/loki.js" ]; then
13994
+ cli="$root/loki-ts/dist/loki.js"
13995
+ # Dev checkout: a gitignored dist older than src is stale (same rule as bin/loki).
13996
+ if [ -f "$root/loki-ts/src/cli.ts" ] && [ "$root/loki-ts/src/cli.ts" -nt "$cli" ]; then
13997
+ cli="$root/loki-ts/src/cli.ts"
14458
13998
  fi
14459
- done
14460
- if [ "$_lsp_found" -gt 0 ]; then
14461
- echo -e " ${GREEN}PASS${NC} LSP servers detected ($_lsp_found): $_lsp_list"
14462
- pass_count=$((pass_count + 1))
13999
+ elif [ -f "$root/loki-ts/src/cli.ts" ]; then
14000
+ cli="$root/loki-ts/src/cli.ts"
14463
14001
  else
14464
- echo -e " ${YELLOW}WARN${NC} LSP servers - none on PATH (install for symbol grounding: npm i -g pyright typescript-language-server; brew install gopls)"
14465
- warn_count=$((warn_count + 1))
14466
- fi
14467
- # MiroFish check (optional service)
14468
- local _mf_url="${LOKI_MIROFISH_URL:-http://localhost:5001}"
14469
- if docker inspect loki-mirofish &>/dev/null 2>&1 || [[ -n "${LOKI_MIROFISH_URL:-}" ]]; then
14470
- doctor_probe_note "Probing MiroFish ($_mf_url)"
14471
- if curl -sf --connect-timeout 2 --max-time 5 "$_mf_url/health" >/dev/null 2>&1; then
14472
- doctor_probe_clear
14473
- echo -e " ${GREEN}PASS${NC} MiroFish server ($_mf_url)"
14474
- pass_count=$((pass_count + 1))
14475
- else
14476
- doctor_probe_clear
14477
- echo -e " ${YELLOW}WARN${NC} MiroFish - not running (loki start --mirofish-docker <image>)"
14478
- warn_count=$((warn_count + 1))
14479
- fi
14480
- fi
14481
- # OTEL status
14482
- if [ -n "${LOKI_OTEL_ENDPOINT:-}" ]; then
14483
- echo -e " ${GREEN}PASS${NC} OTEL endpoint: $LOKI_OTEL_ENDPOINT"
14484
- pass_count=$((pass_count + 1))
14485
- else
14486
- echo -e " ${YELLOW}WARN${NC} OTEL - not configured (set LOKI_OTEL_ENDPOINT)"
14487
- warn_count=$((warn_count + 1))
14488
- fi
14489
- # sentrux check (v7.5.14, optional architectural-drift gate)
14490
- if command -v sentrux &>/dev/null; then
14491
- local _sentrux_ver
14492
- _sentrux_ver=$(sentrux --version 2>/dev/null | head -1 | awk '{print $NF}')
14493
- echo -e " ${GREEN}PASS${NC} sentrux ${_sentrux_ver:-unknown} (architectural drift gate: loki sentrux help)"
14494
- pass_count=$((pass_count + 1))
14495
- else
14496
- echo -e " ${YELLOW}WARN${NC} sentrux - not installed (optional, brew install sentrux/tap/sentrux)"
14497
- warn_count=$((warn_count + 1))
14498
- fi
14499
- # Receipt signing: Ed25519, auto-generated on first run. WARN only, and
14500
- # only when something needs attention. Byte-mirrored in doctor.ts.
14501
- if [ -n "${LOKI_PROOF_GPG_KEY:-}" ]; then
14502
- echo -e " ${YELLOW}WARN${NC} Receipt signing: LOKI_PROOF_GPG_KEY is no longer supported and is ignored; use LOKI_RECEIPT_SIGNING_KEY or LOKI_RECEIPT_SIGNING_KEY_FILE (docs/SIGNED-RECEIPTS.md)"
14503
- warn_count=$((warn_count + 1))
14504
- fi
14505
- if ! python3 -c 'import cryptography' >/dev/null 2>&1; then
14506
- echo -e " ${YELLOW}WARN${NC} Receipt signing: python3 'cryptography' package missing, receipts will be UNSIGNED (pip install cryptography)"
14507
- warn_count=$((warn_count + 1))
14508
- fi
14509
- echo ""
14510
-
14511
- echo -e "${CYAN}System:${NC}"
14512
- doctor_check "bash (>= 4.0)" bash recommended 4.0 || true
14513
- # v7.4.9: Bun powers ported commands; warn if missing so users notice the
14514
- # speedup is unavailable (bin/loki falls through to bash silently).
14515
- doctor_check "Bun (>= 1.3)" bun recommended 1.3 || true
14516
-
14517
- # Disk space check
14518
- local disk_avail
14519
- if command -v df &> /dev/null; then
14520
- # Get available space in GB (works on macOS and Linux).
14521
- # v7.4.12: || true on the pipes so set -euo pipefail doesn't exit
14522
- # the whole doctor when df -g fails on Linux (which doesn't support
14523
- # the -g flag). Pre-v7.4.12 bash route truncated after the System
14524
- # section on Linux because of this exact early exit.
14525
- disk_avail=$(df -g "$HOME" 2>/dev/null | tail -1 | awk '{print $4}' || true)
14526
- if [ -z "$disk_avail" ]; then
14527
- # Linux fallback (df -g may not work)
14528
- disk_avail=$(df -BG "$HOME" 2>/dev/null | tail -1 | awk '{print $4}' | tr -d 'G' || true)
14529
- fi
14530
- if [ -n "$disk_avail" ] && [ "$disk_avail" -gt 0 ] 2>/dev/null; then
14531
- if [ "$disk_avail" -lt 1 ]; then
14532
- echo -e " ${RED}FAIL${NC} Disk space: ${disk_avail}GB available (need >= 1GB)"
14533
- _doctor_block "Free up disk: ${disk_avail}GB available, need >= 1GB"
14534
- fail_count=$((fail_count + 1))
14535
- elif [ "$disk_avail" -lt 5 ]; then
14536
- echo -e " ${YELLOW}WARN${NC} Disk space: ${disk_avail}GB available (low)"
14537
- warn_count=$((warn_count + 1))
14538
- else
14539
- echo -e " ${GREEN}PASS${NC} Disk space: ${disk_avail}GB available"
14540
- pass_count=$((pass_count + 1))
14541
- fi
14542
- else
14543
- echo -e " ${YELLOW}WARN${NC} Disk space: unable to determine"
14544
- warn_count=$((warn_count + 1))
14545
- fi
14002
+ _LOKI_DELEGATE_WHY="dist missing (no loki-ts/dist/loki.js or loki-ts/src/cli.ts under ${root})"
14003
+ _LOKI_DELEGATE_FIX="Reinstall loki-mode (npm install -g loki-mode) or run: cd loki-ts && bun run build"
14004
+ return 1
14546
14005
  fi
14547
- echo ""
14548
14006
 
14549
- # v7.5.1 fix B23: Runtime route -- mirror loki-ts/src/commands/doctor.ts.
14550
- # Informational only; does NOT contribute to pass/fail/warn counts so
14551
- # the bun-parity matrix can normalize it without reconciling summary
14552
- # totals across routes.
14553
- echo -e "${CYAN}Runtime route:${NC}"
14554
- echo -e " ${GREEN}PASS${NC} Active runtime: Bash (autonomy/loki)"
14555
- if [ "${LOKI_LEGACY_BASH:-}" = "1" ] || [ "${LOKI_LEGACY_BASH:-}" = "true" ]; then
14556
- echo -e " ${YELLOW}WARN${NC} LOKI_LEGACY_BASH set: shim routes every command to autonomy/loki (bash)"
14557
- fi
14558
- if [ -n "${LOKI_TS_ENTRY:-}" ]; then
14559
- echo -e " ${GREEN}PASS${NC} LOKI_TS_ENTRY override: ${LOKI_TS_ENTRY}"
14560
- fi
14561
- if [ "${BUN_FROM_SOURCE:-}" = "1" ] || [ "${BUN_FROM_SOURCE:-}" = "true" ]; then
14562
- echo -e " ${GREEN}PASS${NC} BUN_FROM_SOURCE set: shim prefers loki-ts/src/ over dist/"
14563
- fi
14564
- # v7.5.2: explicit python3.12 probe -- chromadb + sentence-transformers
14565
- # require it; the generic Python 3 check passes Python 3.13/3.14 and
14566
- # silently fails at runtime. Informational only.
14567
- py312_path=$(command -v python3.12 2>/dev/null || true)
14568
- if [ -n "$py312_path" ]; then
14569
- py312_ver=$("$py312_path" -c 'import sys; print(f"{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}")' 2>/dev/null)
14570
- echo -e " ${GREEN}PASS${NC} Python 3.12 (chromadb / sentence-transformers): ${py312_ver} at ${py312_path}"
14007
+ t="$(command -v timeout 2>/dev/null || command -v gtimeout 2>/dev/null || true)"
14008
+ if [ -n "$t" ]; then
14009
+ "$t" -k 1 5 "$bun_bin" "$cli" version >/dev/null 2>&1
14571
14010
  else
14572
- echo -e " ${YELLOW}WARN${NC} Python 3.12 NOT on PATH -- chromadb / sentence-transformers may fail. Install via brew install python@3.12 or apt install python3.12."
14011
+ "$bun_bin" "$cli" version >/dev/null 2>&1
14573
14012
  fi
14574
- echo ""
14575
-
14576
- # Model catalog freshness. Providers ship models constantly and this catalog
14577
- # is hand-maintained, so it rots silently -- nothing else in the system ever
14578
- # reports its age. Reads ONLY the local file's "updated" field: zero network
14579
- # I/O, so `loki doctor` stays air-gapped-safe (docs/air-gapped.md). We do not
14580
- # and will not auto-fetch or guess model IDs; inventing a model that does not
14581
- # exist is worse than being stale, so refresh stays a human-verified step.
14582
- #
14583
- # Informational only -- like Runtime route and Cockpit above, it does NOT
14584
- # touch pass/warn/fail counts and never calls _doctor_block, so a stale
14585
- # catalog can never flip doctor's exit code or block a build. Advisory by
14586
- # construction, not by convention.
14587
- #
14588
- # Mirrored byte-for-byte in loki-ts/src/commands/doctor.ts -- the bun-parity
14589
- # matrix diffs this section (it is NOT in the normalizer's strip list), so
14590
- # edit BOTH routes or parity fails.
14591
- echo -e "${CYAN}Model catalog:${NC}"
14592
- _catalog_path="${LOKI_MODEL_CATALOG:-${_LOKI_SCRIPT_DIR}/../providers/model_catalog.json}"
14593
- _catalog_line=$(LOKI_CATALOG_PATH="$_catalog_path" python3 -c "
14594
- import json, os, datetime
14595
- # Threshold: 90 days. The upstream probe workflow runs weekly, so a catalog
14596
- # untouched for a quarter means the probe PRs are going unread.
14597
- STALE_DAYS = 90
14598
- p = os.environ['LOKI_CATALOG_PATH']
14599
- try:
14600
- updated = json.load(open(p))['updated']
14601
- age = (datetime.datetime.now(datetime.timezone.utc).date() - datetime.date.fromisoformat(updated)).days
14602
- except Exception:
14603
- print('warn|Catalog unreadable or missing an ISO \"updated\" date -- cannot determine age')
14604
- else:
14605
- if age > STALE_DAYS:
14606
- print(f'warn|Last updated {updated} ({age} days ago) -- may be missing newer models. Refresh: python3 tools/probe-model-catalog.py (reports new model IDs from provider docs; you verify and edit the catalog by hand -- never auto-applied)')
14607
- else:
14608
- print(f'pass|Last updated {updated} ({age} days ago)')
14609
- " 2>/dev/null)
14610
- # Fail-open: if python3 is missing or the probe dies, say so and move on.
14611
- [ -n "$_catalog_line" ] || _catalog_line="warn|Could not probe catalog age"
14612
- if [ "${_catalog_line%%|*}" = "pass" ]; then
14613
- echo -e " ${GREEN}PASS${NC} ${_catalog_line#*|}"
14614
- else
14615
- echo -e " ${YELLOW}WARN${NC} ${_catalog_line#*|}"
14013
+ rc=$?
14014
+ if [ "$rc" -ne 0 ]; then
14015
+ _LOKI_DELEGATE_WHY="bun failed exit ${rc} running ${cli} version"
14016
+ _LOKI_DELEGATE_FIX="Reinstall loki-mode (npm install -g loki-mode) or unset LOKI_TS_ENTRY"
14017
+ return 1
14616
14018
  fi
14617
- echo ""
14618
14019
 
14619
- # Cockpit capability (E5): report what `loki cockpit` will actually do in
14620
- # this terminal. Informational only (does NOT touch pass/warn/fail counts,
14621
- # like Runtime route above), so the bun-parity matrix stays reconcilable.
14622
- # The protocol + resvg + path come from a bun probe that reuses the real
14623
- # detection code (loki-ts) so this report can never drift from behavior;
14624
- # with no bun we honestly report the text-only path.
14625
- echo -e "${CYAN}Cockpit:${NC}"
14626
- # Truecolor: purely from COLORTERM, same signal the renderer relies on.
14627
- case "${COLORTERM:-}" in
14628
- truecolor|24bit)
14629
- echo -e " ${GREEN}PASS${NC} Truecolor: yes (COLORTERM=${COLORTERM})" ;;
14630
- *)
14631
- echo -e " ${DIM} -- ${NC} Truecolor: not signalled (COLORTERM unset) -- colors may be approximate" ;;
14632
- esac
14633
- local _ck_ts_dir="${_LOKI_SCRIPT_DIR}/../loki-ts"
14634
- local _ck_entry="$_ck_ts_dir/dist/cockpit.js"
14635
- [ -f "$_ck_entry" ] || _ck_entry="$_ck_ts_dir/src/cockpit/cli.ts"
14636
- if command -v bun >/dev/null 2>&1 && [ -f "$_ck_entry" ]; then
14637
- echo -e " ${GREEN}PASS${NC} Bun present -- cockpit renderer available"
14638
- local _ck_probe _ck_proto _ck_resvg _ck_path
14639
- _ck_probe="$(bun "$_ck_entry" --probe 2>/dev/null || true)"
14640
- _ck_proto="$(printf '%s\n' "$_ck_probe" | sed -n 's/^protocol=//p' | head -1)"
14641
- _ck_resvg="$(printf '%s\n' "$_ck_probe" | sed -n 's/^resvg=//p' | head -1)"
14642
- _ck_path="$(printf '%s\n' "$_ck_probe" | sed -n 's/^path=//p' | head -1)"
14643
- [ -n "$_ck_proto" ] || _ck_proto="none"
14644
- [ -n "$_ck_resvg" ] || _ck_resvg="no"
14645
- [ -n "$_ck_path" ] || _ck_path="text+dashboard"
14646
- if [ "$_ck_proto" = "none" ]; then
14647
- echo -e " ${DIM} -- ${NC} Inline-image protocol: none (this terminal has no iTerm2/Kitty graphics)"
14648
- else
14649
- echo -e " ${GREEN}PASS${NC} Inline-image protocol: ${_ck_proto}"
14650
- fi
14651
- if [ "$_ck_resvg" = "yes" ]; then
14652
- echo -e " ${GREEN}PASS${NC} SVG rasterizer: ready (bundled wasm; renders the frame to an image)"
14653
- else
14654
- echo -e " ${DIM} -- ${NC} SVG rasterizer: unavailable (cockpit uses text+dashboard)"
14655
- fi
14656
- if [ "$_ck_path" = "image" ]; then
14657
- echo -e " ${GREEN}PASS${NC} Render path: inline image ('loki cockpit' draws the frame in-terminal)"
14658
- else
14659
- echo -e " ${DIM} -- ${NC} Render path: text + browser dashboard ('loki cockpit' prints a summary; open 'loki dashboard')"
14660
- fi
14661
- else
14662
- echo -e " ${YELLOW}WARN${NC} Bun not found -- 'loki cockpit' uses the text summary + browser dashboard (install bun for the inline-image frame)"
14663
- # Report the protocol line here too. Without it doctor's Cockpit section
14664
- # is asymmetric: the bun branch prints BOTH "Inline-image protocol" and
14665
- # "Render path", the no-bun branch printed only "Render path". A user
14666
- # reading doctor on a machine without bun could not tell whether the
14667
- # inline-image path was unsupported by their terminal or simply never
14668
- # probed. Naming the reason keeps the section self-explanatory.
14669
- echo -e " ${DIM} -- ${NC} Inline-image protocol: not probed (needs bun to run the cockpit renderer)"
14670
- echo -e " ${DIM} -- ${NC} Render path: text + browser dashboard"
14671
- fi
14672
- echo ""
14020
+ exec "$bun_bin" "$cli" "$cmd" "$@"
14021
+ }
14673
14022
 
14674
- # INSTALL INTEGRITY: the quality gates shell out to detector scripts. If the
14675
- # package shipped without them, four gates fail-CLOSED on every iteration --
14676
- # correct behaviour (a missing detector must never read as "nothing found"),
14677
- # but it means the build can never pass and first-pass completion is
14678
- # impossible no matter how good the output is.
14679
- #
14680
- # That shipped: package.json's files[] had no tests/ entry until v8.38.0, so
14681
- # every npm install had zero detectors. `doctor` reported a healthy system
14682
- # the whole time, because it only ever checked for external COMMANDS -- not
14683
- # for the files this package is supposed to contain. A broken install and a
14684
- # healthy one were indistinguishable from the one command meant to tell them
14685
- # apart.
14686
- echo -e "${BOLD}Install integrity:${NC}"
14687
- local _det_missing="" _det
14688
- for _det in detect-test-mutations detect-mock-problems \
14689
- detect-semantic-test-problems detect-invariant-violations; do
14690
- if [ -f "${_LOKI_SCRIPT_DIR}/../tests/${_det}.sh" ]; then
14691
- pass_count=$((pass_count + 1))
14692
- else
14693
- _det_missing="${_det_missing} ${_det}.sh"
14694
- fi
14023
+ # loki doctor. The one implementation is loki-ts/src/commands/doctor.ts; this is a
14024
+ # delegator. Only --airgap stays in bash (_loki_airgap_audit). If the Bun route is
14025
+ # unavailable we print a minimal diagnostic (never a second copy of the checks).
14026
+ cmd_doctor() {
14027
+ local json_output=false
14028
+ local airgap_audit=false
14029
+ local a
14030
+ for a in "$@"; do
14031
+ case "$a" in
14032
+ --json) json_output=true ;;
14033
+ --airgap) airgap_audit=true ;;
14034
+ esac
14695
14035
  done
14696
- if [ -z "$_det_missing" ]; then
14697
- echo -e " ${GREEN}OK${NC} Quality-gate detectors present (4/4)"
14698
- else
14699
- echo -e " ${RED}FAIL${NC} Quality-gate detectors MISSING:${_det_missing}"
14700
- echo -e " ${DIM} These gates fail-closed, so every iteration will be blocked.${NC}"
14701
- _doctor_block "Incomplete install: quality-gate detectors are missing. Reinstall: bun install -g loki-mode"
14702
- fail_count=$((fail_count + 1))
14703
- fi
14704
- echo ""
14705
14036
 
14706
- # PATH SHADOWING. Reported from the field twice, and it defeats upgrading
14707
- # entirely: `bun install -g loki-mode` reports installing 9.39.0, then
14708
- # `loki --version` prints 9.22.3, because an OLDER copy sits earlier on
14709
- # PATH (typically ~/.local/bin -> a Homebrew node prefix, ahead of
14710
- # ~/.bun/bin). Reinstalling cannot fix it; every reinstall updates the copy
14711
- # that is NOT winning, so the user loops forever on advice that cannot work.
14712
- #
14713
- # This is a blocker, not a warning: the user is running code they did not
14714
- # install and cannot upgrade by the documented route.
14715
- #
14716
- # Resolved by realpath so two PATH entries pointing at the SAME install are
14717
- # not reported as shadowing each other.
14718
- # Resolve symlinks with whatever the host has. macOS ships no `realpath`
14719
- # on older versions, so fall back to readlink and finally to the path
14720
- # itself -- an unresolved path is still comparable, just less precise.
14721
- _doctor_resolve() {
14722
- if command -v realpath >/dev/null 2>&1; then
14723
- realpath "$1" 2>/dev/null || printf '%s' "$1"
14724
- elif command -v readlink >/dev/null 2>&1; then
14725
- readlink -f "$1" 2>/dev/null || printf '%s' "$1"
14726
- else
14727
- printf '%s' "$1"
14728
- fi
14729
- }
14730
- _running_real="$(command -v loki 2>/dev/null || true)"
14731
- if [ -n "$_running_real" ]; then
14732
- _running_real="$(_doctor_resolve "$_running_real")"
14733
- _shadow_found=""
14734
- _shadow_seen=""
14735
- _oldIFS="$IFS"; IFS=':'
14736
- for _d in $PATH; do
14737
- [ -n "$_d" ] || continue
14738
- [ -x "$_d/loki" ] || continue
14739
- _r="$(_doctor_resolve "$_d/loki")"
14740
- case "$_shadow_seen" in *"|$_r|"*) continue ;; esac
14741
- _shadow_seen="${_shadow_seen}|$_r|"
14742
- [ "$_r" = "$_running_real" ] && continue
14743
- # <pkg>/bin/loki -> package.json two levels up
14744
- _pj="$(dirname "$(dirname "$_r")")/package.json"
14745
- [ -f "$_pj" ] || continue
14746
- _v="$(python3 -c "import json,sys;print(json.load(open(sys.argv[1])).get('version',''))" "$_pj" 2>/dev/null || true)"
14747
- [ -n "$_v" ] || continue
14748
- _shadow_found="${_shadow_found}\n $_v at $_d/loki"
14749
- done
14750
- IFS="$_oldIFS"
14751
- if [ -n "$_shadow_found" ]; then
14752
- echo -e " ${RED}FAIL${NC} Multiple loki installs on PATH; you are running $(get_version 2>/dev/null || echo unknown)"
14753
- echo -e " ${DIM} Running: $_running_real${NC}"
14754
- printf " ${DIM} Others:%b${NC}\n" "$_shadow_found"
14755
- echo -e " ${DIM} Reinstalling updates a copy that is not winning on PATH.${NC}"
14756
- _doctor_block "Multiple loki installs on PATH. Run 'which -a loki', then remove or re-point every entry EARLIER than the one you want. Reinstalling alone will not fix this."
14757
- fail_count=$((fail_count + 1))
14758
- else
14759
- echo -e " ${GREEN}OK${NC} Single loki install on PATH"
14760
- fi
14761
- echo ""
14037
+ if [ "$airgap_audit" = true ]; then
14038
+ _loki_airgap_audit "$json_output"
14039
+ return $?
14762
14040
  fi
14763
14041
 
14764
- # Summary
14765
- echo -e "${BOLD}Summary:${NC} ${GREEN}$pass_count passed${NC}, ${RED}$fail_count failed${NC}, ${YELLOW}$warn_count warnings${NC}"
14766
- echo ""
14042
+ # LOKI_LEGACY_BASH stays set: doctor.ts reads it to report the bash runtime
14043
+ # and print the warning; it never re-routes on it.
14044
+ _loki_bun_delegate doctor "$@" || true
14767
14045
 
14768
- if [ "$fail_count" -gt 0 ]; then
14769
- echo -e "${RED}Blocking ($fail_count). Everything else above is optional.${NC}"
14770
- printf '%b\n' "$_doctor_blockers"
14771
- echo ""
14772
- echo "Then re-run: loki doctor"
14773
- echo "Meanwhile 'loki tour' works right now -- no provider, no key, no spend."
14774
- # Adoption signal: which CLASS of dependency stopped this first run.
14775
- # Mapped to an enum here rather than forwarding the blocker TEXT, so
14776
- # the payload can never carry a path or a version. Best-effort and
14777
- # backgrounded: telemetry must never delay or fail a doctor run, and
14778
- # every existing opt-out still gates it downstream.
14779
- if declare -f loki_emit_first_run_blocked >/dev/null 2>&1; then
14780
- local _blk_key="other"
14781
- case "$_doctor_blockers" in
14782
- *"No AI provider CLI"*) _blk_key="no_provider" ;;
14783
- *"Node.js is not installed"*|*"Node.js must be"*) _blk_key="node" ;;
14784
- *"Python 3 is not installed"*|*"Python 3 must be"*) _blk_key="python3" ;;
14785
- *"jq is not installed"*) _blk_key="jq" ;;
14786
- *"git is not installed"*) _blk_key="git" ;;
14787
- *"curl is not installed"*) _blk_key="curl" ;;
14788
- *"Free up disk"*) _blk_key="disk" ;;
14789
- *"broken symlink"*) _blk_key="skill_symlink" ;;
14790
- esac
14791
- ( loki_emit_first_run_blocked "$_blk_key" >/dev/null 2>&1 </dev/null & ) 2>/dev/null || true
14792
- fi
14793
- # A-123: the LAST line is the one blocking reason, exactly.
14794
- printf '%b' "$_doctor_blockers" | awk '/^ - /{sub(/^ - /,""); print; exit}'
14795
- return 1
14796
- elif [ "$warn_count" -gt 0 ]; then
14797
- echo -e "${YELLOW}All required checks passed with some warnings.${NC}"
14046
+ # Reached only when the delegate could not exec.
14047
+ if [ "$json_output" = true ]; then
14048
+ cmd_doctor_json
14798
14049
  else
14799
- echo -e "${GREEN}All checks passed. System is ready for Loki Mode.${NC}"
14050
+ echo "loki doctor: the Bun doctor is unavailable, so only this minimal report can run."
14051
+ echo "Fix: ${_LOKI_DELEGATE_FIX}"
14052
+ echo "${_LOKI_DELEGATE_WHY}"
14800
14053
  fi
14801
- # Setup verified (no required check failed): hand the user straight to a
14802
- # first build with a copy-paste command, so they never dead-end here. Gated
14803
- # on fail_count==0 above, so a failing setup is never told to build. The
14804
- # --json path returns long before this code, so machine output is untouched.
14805
- echo ""
14806
- if [ "$_doctor_sdk_only" = "1" ]; then
14807
- # SDK-only host: quickstart and demo stay on the bash route and need a
14808
- # binary on PATH, so recommending them here is a green doctor pointing at
14809
- # an exit 2. Recommend only what actually runs, plus the one command that
14810
- # unlocks the rest.
14811
- echo "Next: loki start ./prd.md (runs on the bundled SDK, no CLI install needed)"
14812
- echo " For loki quickstart/demo: npm install -g @anthropic-ai/claude-code"
14813
- else
14814
- echo "Next: loki quickstart (guided first build from your idea, no PRD needed)"
14815
- echo " or loki demo (builds a sample todo app end to end) or loki start ./prd.md"
14816
- fi
14817
- # A-123: the last stdout line. Provider and model come from the selected
14818
- # provider; the key state is read WITHOUT creating a key (auto_generate=False).
14819
- # Byte-mirrored in loki-ts/src/commands/doctor.ts.
14820
- local _rd_id _rd_model _rd_key _rd_state
14821
- _rd_id="${LOKI_PROVIDER:-}"
14822
- [ -n "$_rd_id" ] || _rd_id="$(effective_provider_id 2>/dev/null || true)"
14823
- [ -n "$_rd_id" ] || _rd_id="claude"
14824
- _rd_model="$(bash -c 'source "$1" >/dev/null 2>&1; printf %s "${PROVIDER_MODEL_DEVELOPMENT:-}"' _ "${_LOKI_SCRIPT_DIR}/../providers/${_rd_id}.sh" 2>/dev/null || true)"
14825
- [ -n "$_rd_model" ] || _rd_model="default"
14826
- _rd_key="$(python3 -E - "${_LOKI_SCRIPT_DIR}" 2>/dev/null <<'PY' || true
14827
- import os, sys
14828
- sys.path[:] = [p for p in sys.path if p not in ("", ".")]
14829
- sys.path.insert(0, sys.argv[1])
14830
- from receipt_jwt import load_signing_key, _CRYPTO_AVAILABLE, RECEIPT_SIGNER_BASENAME
14831
- if not _CRYPTO_AVAILABLE:
14832
- print("nocrypto")
14833
- else:
14834
- k, kid = load_signing_key(auto_generate=False)
14835
- if kid:
14836
- print("kid " + kid[:8])
14837
- elif os.environ.get("LOKI_RECEIPT_SIGNING_KEY", "").strip():
14838
- print("bad")
14839
- else:
14840
- # A-122: a missing key FILE is auto-generated on first use when its nearest
14841
- # existing ancestor directory is writable; an existing file that did not
14842
- # load (corrupt/unreadable) or an unwritable location is unusable.
14843
- f = os.environ.get("LOKI_RECEIPT_SIGNING_KEY_FILE", "").strip() or os.path.expanduser("~/.loki/keys/receipt-ed25519.pem")
14844
- d = os.path.dirname(os.path.abspath(f))
14845
- while not os.path.isdir(d) and os.path.dirname(d) != d:
14846
- d = os.path.dirname(d)
14847
- print("bad" if os.path.lexists(f) or not os.access(d, os.W_OK | os.X_OK) else "none")
14848
- PY
14849
- )"
14850
- case "$_rd_key" in
14851
- "kid "*) _rd_state="receipts signed (${_rd_key})" ;;
14852
- none) _rd_state="receipts will be signed on first run" ;;
14853
- bad) _rd_state="receipts unsigned: signing key could not be loaded" ;;
14854
- *) _rd_state="receipts unsigned: python3 cryptography package missing" ;;
14855
- esac
14856
- echo "Ready: ${_rd_id} (${_rd_model}), ${_rd_state}"
14857
- # Best-effort stale-install nudge (stderr only; never blocks; off on
14858
- # non-TTY/CI). Reached only on the human-readable path (--json returns above).
14859
- maybe_print_update_hint
14860
- return 0
14054
+ return 1
14861
14055
  }
14862
14056
 
14863
- # JSON output for loki doctor --json
14057
+ # Minimal --json fallback when the Bun doctor is unavailable. Same top-level keys as
14058
+ # DoctorJson (loki-ts/src/commands/doctor.ts) with summary.ok=false, failed=1, plus
14059
+ # route:"unavailable" and the reason.
14864
14060
  cmd_doctor_json() {
14865
- local _loki_version
14866
- _loki_version=$(get_version)
14867
- # Provider availability, computed in bash and handed to the python program
14868
- # as an env var (same pattern as LOKI_VERSION). Computed here rather than
14869
- # inside the program because the answer comes from sourcing loader.sh.
14870
- # Empty when loader.sh is unavailable, and the key is then null: the same
14871
- # graceful skip the text section performs.
14872
- local _provider_availability=""
14873
- if declare -f provider_availability_json >/dev/null 2>&1; then
14874
- _provider_availability="$(provider_availability_json 2>/dev/null || true)"
14875
- fi
14876
- LOKI_PROVIDER_AVAILABILITY="$_provider_availability" \
14877
- LOKI_VERSION="$_loki_version" \
14878
- LOKI_CATALOG_PATH="${LOKI_MODEL_CATALOG:-${_LOKI_SCRIPT_DIR}/../providers/model_catalog.json}" \
14879
- python3 -c "
14880
- import json, os, subprocess, sys, shutil
14881
-
14882
- def get_version(cmd, args=None):
14883
- try:
14884
- if args is None:
14885
- args = ['--version']
14886
- result = subprocess.run([cmd] + args, capture_output=True, text=True, timeout=5)
14887
- output = result.stdout.strip() or result.stderr.strip()
14888
- # Extract version number
14889
- import re
14890
- match = re.search(r'(\d+\.\d+[\.\d]*)', output)
14891
- return match.group(1) if match else None
14892
- except (FileNotFoundError, subprocess.TimeoutExpired, Exception):
14893
- return None
14894
-
14895
- def check_tool(name, cmd, required, min_version=None):
14896
- found = shutil.which(cmd) is not None
14897
- version = get_version(cmd) if found else None
14898
- status = 'pass'
14899
-
14900
- if not found:
14901
- status = 'fail' if required == 'required' else 'warn'
14902
- elif min_version and version:
14903
- cur_parts = [int(x) for x in version.split('.')[:2]]
14904
- min_parts = [int(x) for x in min_version.split('.')[:2]]
14905
- while len(cur_parts) < 2: cur_parts.append(0)
14906
- while len(min_parts) < 2: min_parts.append(0)
14907
- if cur_parts < min_parts:
14908
- status = 'fail' if required == 'required' else 'warn'
14909
-
14910
- return {
14911
- 'name': name,
14912
- 'command': cmd,
14913
- 'found': found,
14914
- 'version': version,
14915
- 'required': required,
14916
- 'min_version': min_version,
14917
- 'status': status,
14918
- 'path': shutil.which(cmd)
14919
- }
14920
-
14921
- checks = []
14922
- checks.append(check_tool('Node.js', 'node', 'required', '18.0'))
14923
- checks.append(check_tool('Python 3', 'python3', 'required', '3.8'))
14924
- checks.append(check_tool('jq', 'jq', 'required'))
14925
- checks.append(check_tool('git', 'git', 'required'))
14926
- checks.append(check_tool('curl', 'curl', 'required'))
14927
- checks.append(check_tool('bash', 'bash', 'recommended', '4.0'))
14928
- checks.append(check_tool('Bun', 'bun', 'recommended', '1.3'))
14929
- checks.append(check_tool('Claude CLI', 'claude', 'optional'))
14930
- checks.append(check_tool('Codex CLI', 'codex', 'optional'))
14931
- checks.append(check_tool('Cline CLI', 'cline', 'optional'))
14932
- checks.append(check_tool('Aider CLI', 'aider', 'optional'))
14933
- checks.append(check_tool('opencode CLI', 'opencode', 'optional'))
14934
-
14935
- # Disk space
14936
- disk_gb = None
14937
- try:
14938
- stat = os.statvfs(os.path.expanduser('~'))
14939
- disk_gb = round((stat.f_bavail * stat.f_frsize) / (1024**3), 1)
14940
- except Exception:
14941
- pass
14942
-
14943
- disk_status = 'pass'
14944
- if disk_gb is not None:
14945
- if disk_gb < 1:
14946
- disk_status = 'fail'
14947
- elif disk_gb < 5:
14948
- disk_status = 'warn'
14949
-
14950
- # v7.5.15: expose the sentrux architectural-drift gate state in --json so
14951
- # dashboards/automation can surface it the same way the text-mode block does
14952
- # (added in v7.5.14). Sibling of checks/disk -- intentionally not counted in
14953
- # the summary tally to keep summary numbers backwards-compatible.
14954
- sentrux_found = shutil.which('sentrux') is not None
14955
- sentrux_version = get_version('sentrux') if sentrux_found else None
14956
- sentrux_status = 'pass' if sentrux_found else 'warn'
14957
- sentrux = {
14958
- 'found': sentrux_found,
14959
- 'version': sentrux_version,
14960
- 'status': sentrux_status,
14961
- 'required': 'optional'
14962
- }
14963
-
14964
- # v7.7.17: memory subsystem health surface. Reports the latest entries
14965
- # from .loki/memory/.errors.log (rotated by memory/error_log.py) so
14966
- # developers see regressions in the previously-silent-fail call sites
14967
- # at run.sh:8724, 9087, 9136. Sibling of checks/disk/sentrux; not
14968
- # counted in the summary tally to keep backwards-compatible numbers.
14969
- # Inline file read (rather than importing memory.error_log) so this
14970
- # block works regardless of the doctor's invocation cwd / sys.path.
14971
- # v7.7.17 council fix (Opus 2): tail-only read (last 64 KB) so a
14972
- # pathological oversize log cannot OOM the doctor command. Bash + Bun
14973
- # emit the SAME relative path string for parity.
14974
- memory_base = os.path.join(os.environ.get('LOKI_DIR', '.loki'), 'memory')
14975
- _memory_log_path = os.path.join(memory_base, '.errors.log')
14976
- _TAIL_BYTES = 64 * 1024
14977
- memory_recent_errors = []
14978
- if os.path.isfile(_memory_log_path):
14979
- try:
14980
- with open(_memory_log_path, 'rb') as _f:
14981
- _f.seek(0, 2)
14982
- _size = _f.tell()
14983
- _offset = max(0, _size - _TAIL_BYTES)
14984
- _f.seek(_offset)
14985
- _chunk = _f.read()
14986
- _text = _chunk.decode('utf-8', errors='replace')
14987
- _lines = _text.split('\n')
14988
- if _offset > 0 and _lines:
14989
- _lines = _lines[1:] # drop possibly-partial first line
14990
- _lines = [ln.strip() for ln in _lines if ln.strip()]
14991
- memory_recent_errors = _lines[-5:]
14992
- except OSError:
14993
- memory_recent_errors = []
14994
- memory = {
14995
- 'errors_log_path': _memory_log_path if os.path.isfile(_memory_log_path) else None,
14996
- 'recent_errors': memory_recent_errors,
14997
- 'recent_error_count': len(memory_recent_errors),
14998
- 'status': 'pass' if not memory_recent_errors else 'warn',
14999
- }
15000
-
15001
- # SKILL LINK INTEGRITY. The text path has always failed closed on a broken
15002
- # skill symlink (the Skills section, _doctor_block, exit 1), but --json
15003
- # omitted skills entirely -- so on a host with a dangling ~/.claude/skills/
15004
- # loki-mode the two outputs gave OPPOSITE verdicts: text exited 1 while
15005
- # --json reported failed 0 and ok true. An operator gating on the JSON got a
15006
- # green on a host where the skill cannot load. Same class of fake-green as
15007
- # the ai_provider block below, and fixed the same way: counted in the tally.
15008
- #
15009
- # Per-entry counting matches the text path, which tallies each of the four
15010
- # entries separately. Paths are the same ones the text path already prints,
15011
- # so this exposes nothing new.
15012
- #
15013
- # CAUTION: this block lives inside a DOUBLE-QUOTED python3 -c program, so no
15014
- # apostrophes and no double quotes anywhere here, comments included.
15015
- #
15016
- # SEVERITY MIRRORS THE TEXT PATH: a dangling link is a failure only for the
15017
- # provider a build would actually use. Read from the SAME payload the text
15018
- # route uses (LOKI_PROVIDER_AVAILABILITY, set by cmd_doctor_json), parsed here
15019
- # rather than via a second env var so the two cannot disagree. LOKI_PROVIDER
15020
- # wins for the same reason effective_provider_id honors it.
15021
- _SKILL_ENTRIES = (
15022
- ('Claude Code', '.claude/skills/loki-mode', 'claude'),
15023
- ('Codex CLI', '.codex/skills/loki-mode', 'codex'),
15024
- ('Cline CLI', '.cline/skills/loki-mode', 'cline'),
15025
- ('Aider CLI', '.aider/skills/loki-mode', 'aider'),
15026
- )
15027
- try:
15028
- _sk_pa = os.environ.get('LOKI_PROVIDER_AVAILABILITY', '').strip()
15029
- _sk_selected = (json.loads(_sk_pa).get('selected') or '') if _sk_pa else ''
15030
- except Exception:
15031
- _sk_selected = ''
15032
- _sk_selected = os.environ.get('LOKI_PROVIDER', '') or _sk_selected
15033
- skills = []
15034
- for _sk_name, _sk_rel, _sk_id in _SKILL_ENTRIES:
15035
- _sk_dir = os.path.join(os.path.expanduser('~'), _sk_rel)
15036
- # JSON is commonly archived or forwarded to automation. Keep the useful
15037
- # user-relative location without exposing the machine-specific home path.
15038
- _sk_json_path = '~/' + _sk_rel
15039
- if os.path.isfile(os.path.join(_sk_dir, 'SKILL.md')):
15040
- skills.append({'name': _sk_name, 'path': _sk_json_path, 'status': 'pass',
15041
- 'detail': None, 'required': 'required'})
15042
- elif os.path.islink(_sk_dir):
15043
- # islink is true for a DANGLING link (lstat-based), which is exactly
15044
- # the broken case the text path reports. fail only for the effective
15045
- # provider; otherwise warn, matching the text path badge for badge.
15046
- # 'required' stays 'required' in both arms: an absent link has always
15047
- # been warn+required, so the warn arm introduces no new shape, and a
15048
- # consumer keys on 'status'.
15049
- _sk_blocking = bool(_sk_selected) and _sk_id == _sk_selected
15050
- skills.append({'name': _sk_name, 'path': _sk_json_path,
15051
- 'status': 'fail' if _sk_blocking else 'warn',
15052
- 'detail': 'broken symlink. Fix: loki setup-skill',
15053
- 'required': 'required'})
15054
- else:
15055
- skills.append({'name': _sk_name, 'path': _sk_json_path, 'status': 'warn',
15056
- 'detail': 'not found - run loki setup-skill',
15057
- 'required': 'required'})
15058
-
15059
- pass_count = sum(1 for c in checks if c['status'] == 'pass')
15060
- fail_count = sum(1 for c in checks if c['status'] == 'fail')
15061
- warn_count = sum(1 for c in checks if c['status'] == 'warn')
15062
-
15063
- for _sk in skills:
15064
- if _sk['status'] == 'pass':
15065
- pass_count += 1
15066
- elif _sk['status'] == 'fail':
15067
- fail_count += 1
15068
- else:
15069
- warn_count += 1
15070
-
15071
- if disk_status == 'pass': pass_count += 1
15072
- elif disk_status == 'fail': fail_count += 1
15073
- elif disk_status == 'warn': warn_count += 1
15074
-
15075
- # AGGREGATE PROVIDER CHECK. Each provider CLI is individually optional --
15076
- # you need one of Claude, Codex, Cline, Aider, or opencode -- so no single
15077
- # one can be marked required. But having NONE is a blocker, and the text
15078
- # path reports it as one (the No-AI-provider branch near autonomy/loki:11000).
15079
- #
15080
- # --json omitted this entirely, so on a host with no provider the two
15081
- # outputs gave OPPOSITE verdicts: the text path exited 1 while --json
15082
- # reported zero failures and ok true. An operator gating a pipeline on the
15083
- # JSON got a pass on a machine that cannot run a build -- the fake-green
15084
- # this project exists to refuse.
15085
- #
15086
- # CAUTION: this block lives inside python3 -c with a DOUBLE-QUOTED program,
15087
- # so an apostrophe or a double quote in a comment terminates the shell
15088
- # string and mangles the program. First attempt did exactly that: doctor
15089
- # --json printed nothing and exited 0. Keep this comment quote-free.
15090
- # Must match auto_detect_provider() in providers/loader.sh, in ITS order.
15091
- # Omitting opencode made doctor report 'No AI provider CLI' on a machine
15092
- # where the runner would happily select opencode -- the fifth list in this
15093
- # repo to drift from that authority (see v8.76.0, v8.82.0).
15094
- _provider_cmds = ('claude', 'cline', 'codex', 'aider', 'opencode')
15095
- _any_provider = any(shutil.which(_p) is not None for _p in _provider_cmds)
15096
- ai_provider = {
15097
- 'found': _any_provider,
15098
- 'status': 'pass' if _any_provider else 'fail',
15099
- 'required': 'required',
15100
- 'detail': None if _any_provider
15101
- else 'No AI provider CLI. Fix: npm install -g @anthropic-ai/claude-code',
15102
- }
15103
- if _any_provider:
15104
- pass_count += 1
15105
- else:
15106
- fail_count += 1
15107
-
15108
- # Model catalog freshness. Local read only -- no network. Advisory: deliberately
15109
- # excluded from pass/fail/warn counts and from 'ok', so a stale catalog can never
15110
- # flip doctor's exit code. Mirrors describeCatalogFreshness() in
15111
- # loki-ts/src/commands/doctor.ts.
15112
- CATALOG_STALE_DAYS = 90
15113
- _cat_path = os.environ['LOKI_CATALOG_PATH']
15114
- try:
15115
- import datetime as _dt
15116
- _cat_updated = json.load(open(_cat_path))['updated']
15117
- _cat_age = (_dt.datetime.now(_dt.timezone.utc).date() - _dt.date.fromisoformat(_cat_updated)).days
15118
- if _cat_age > CATALOG_STALE_DAYS:
15119
- # Deliberately NOT counted. The block comment above states catalog age is
15120
- # excluded from pass/fail/warn and from 'ok' so a stale catalog can never
15121
- # fail a build -- this increment contradicted that contract, and it also
15122
- # broke route parity: the Bun route (doctor.ts) never counted it, so the
15123
- # same host reported warnings: 2 on bash and 1 on Bun. bun-parity
15124
- # compares these byte for byte, so it would have gone red on day 91.
15125
- model_catalog = {'status': 'warn', 'updated': _cat_updated, 'age_days': _cat_age,
15126
- 'detail': f'Last updated {_cat_updated} ({_cat_age} days ago) -- may be missing newer models'}
15127
- else:
15128
- model_catalog = {'status': 'pass', 'updated': _cat_updated, 'age_days': _cat_age,
15129
- 'detail': f'Last updated {_cat_updated} ({_cat_age} days ago)'}
15130
- except Exception:
15131
- model_catalog = {'status': 'warn', 'updated': None, 'age_days': None,
15132
- 'detail': 'Catalog unreadable or missing an ISO \"updated\" date -- cannot determine age'}
15133
-
15134
- # Provider availability, handed in from bash (see cmd_doctor_json). Null when
15135
- # providers/loader.sh could not be sourced, mirroring the text sections skip.
15136
- # CAUTION: this block lives inside a DOUBLE-QUOTED python3 -c program, so no
15137
- # apostrophes and no double quotes anywhere here, comments included.
15138
- try:
15139
- _pa_raw = os.environ.get('LOKI_PROVIDER_AVAILABILITY', '').strip()
15140
- provider_availability = json.loads(_pa_raw) if _pa_raw else None
15141
- except Exception:
15142
- provider_availability = None
15143
-
15144
- result = {
15145
- 'loki_mode_version': os.environ.get('LOKI_VERSION', 'unknown'),
15146
- 'checks': checks,
15147
- 'provider_availability': provider_availability,
15148
- 'disk': {
15149
- 'available_gb': disk_gb,
15150
- 'status': disk_status
15151
- },
15152
- 'ai_provider': ai_provider,
15153
- 'skills': skills,
15154
- 'sentrux': sentrux,
15155
- 'memory': memory,
15156
- 'model_catalog': model_catalog,
15157
- 'summary': {
15158
- 'passed': pass_count,
15159
- 'failed': fail_count,
15160
- 'warnings': warn_count,
15161
- 'ok': fail_count == 0
15162
- }
15163
- }
15164
-
15165
- print(json.dumps(result, indent=2))
15166
- sys.exit(0 if result['summary']['ok'] else 1)
15167
- "
14061
+ local ver why fix
14062
+ ver="$(get_version 2>/dev/null || echo unknown)"
14063
+ why="${_LOKI_DELEGATE_WHY:-bun route unavailable}"
14064
+ fix="${_LOKI_DELEGATE_FIX:-}"
14065
+ why="${why//\\/\\\\}"; why="${why//\"/\\\"}"
14066
+ fix="${fix//\\/\\\\}"; fix="${fix//\"/\\\"}"
14067
+ ver="${ver//\\/\\\\}"; ver="${ver//\"/\\\"}"
14068
+ printf '{"loki_mode_version":"%s","checks":[],"provider_availability":null,"disk":null,"ai_provider":null,"skills":[],"sentrux":null,"memory":null,"model_catalog":null,"summary":{"passed":0,"failed":1,"warnings":0,"ok":false},"route":"unavailable","reason":"%s","fix":"%s"}\n' "$ver" "$why" "$fix"
15168
14069
  }
15169
14070
 
15170
14071
  # Architectural-drift gate (v7.5.14, opt-in).