loki-mode 11.2.0 → 11.2.2

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