loki-mode 9.17.2 → 9.18.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/SKILL.md CHANGED
@@ -3,7 +3,7 @@ name: loki-mode
3
3
  description: Autonomous spec-driven build system with a built-in trust layer. It does not call work done until it is verified (RARV-C closure loop, 8 quality gates, completion council, verified-completion evidence gate). Triggers on "Loki Mode". Takes a spec (PRD, GitHub issue, OpenAPI doc, etc.) to deployed product with minimal human intervention. Provider-agnostic. Requires --dangerously-skip-permissions flag.
4
4
  ---
5
5
 
6
- # Loki Mode v9.17.2
6
+ # Loki Mode v9.18.4
7
7
 
8
8
  **You are an autonomous agent. You make decisions. You do not ask questions. You do not stop.**
9
9
 
@@ -470,4 +470,4 @@ See `CHANGELOG.md` entries [7.5.7], [7.5.8], [7.5.13] for the per-fix list and r
470
470
 
471
471
  ---
472
472
 
473
- **v9.17.2 | [Autonomi](https://www.autonomi.dev/) flagship product | ~410 lines core**
473
+ **v9.18.4 | [Autonomi](https://www.autonomi.dev/) flagship product | ~410 lines core**
package/VERSION CHANGED
@@ -1 +1 @@
1
- 9.17.2
1
+ 9.18.4
package/autonomy/loki CHANGED
@@ -556,6 +556,355 @@ require_jq() {
556
556
  fi
557
557
  }
558
558
 
559
+ # --- remote submit (loki start --remote <url>) --------------------------------
560
+ #
561
+ # Submits a spec to a deployed trigger-server's POST /jobs instead of building
562
+ # locally, then polls GET /jobs/<id> until the job reaches a terminal state.
563
+ #
564
+ # The bearer token is read from LOKI_REMOTE_TOKEN or LOKI_REMOTE_TOKEN_FILE and
565
+ # is NEVER placed in argv (the process list is world-readable) and never logged.
566
+ # It reaches curl through `--config -` on stdin.
567
+
568
+ # Extract the host from a URL: strip scheme, userinfo, then cut at the first
569
+ # '/', ':' or '?'. Compared EXACTLY, never by prefix -- "http://localhost*"
570
+ # would happily accept http://localhost.evil.com and leak a bearer token.
571
+ loki_remote_host() {
572
+ local url="$1"
573
+ local rest="${url#*://}"
574
+ rest="${rest##*@}"
575
+ rest="${rest%%/*}"
576
+ rest="${rest%%\?*}"
577
+ # Strip a :port, but keep an [::1] literal intact.
578
+ case "$rest" in
579
+ \[*\]*) rest="${rest%%\]*}]" ;;
580
+ *) rest="${rest%%:*}" ;;
581
+ esac
582
+ printf '%s' "$rest"
583
+ }
584
+
585
+ loki_remote_is_local_host() {
586
+ case "$(loki_remote_host "$1")" in
587
+ localhost|127.0.0.1|\[::1\]|::1) return 0 ;;
588
+ *) return 1 ;;
589
+ esac
590
+ }
591
+
592
+ # Read the token from env or a file. Never echoed, never passed as an argument.
593
+ loki_remote_token() {
594
+ if [ -n "${LOKI_REMOTE_TOKEN:-}" ]; then
595
+ printf '%s' "$LOKI_REMOTE_TOKEN"
596
+ return 0
597
+ fi
598
+ if [ -n "${LOKI_REMOTE_TOKEN_FILE:-}" ] && [ -f "${LOKI_REMOTE_TOKEN_FILE}" ]; then
599
+ # Mounted secrets end with a newline; strip it or every compare fails.
600
+ tr -d '\r\n' < "${LOKI_REMOTE_TOKEN_FILE}"
601
+ return 0
602
+ fi
603
+ return 1
604
+ }
605
+
606
+ # curl with the Authorization header fed on stdin via --config, so the token
607
+ # never appears in argv. Usage: loki_remote_curl <token> <curl-args...>
608
+ loki_remote_curl() {
609
+ local _tok="$1"; shift
610
+ printf 'header = "Authorization: Bearer %s"\n' "$_tok" \
611
+ | curl --config - "$@"
612
+ }
613
+
614
+ loki_remote_submit() {
615
+ local url="$1"
616
+ local spec="$2"
617
+
618
+ url="${url%/}"
619
+
620
+ if [ -z "$spec" ]; then
621
+ echo -e "${RED}loki: --remote needs a spec to submit${NC}" >&2
622
+ echo " e.g. loki start ./prd.md --remote $url" >&2
623
+ return 1
624
+ fi
625
+
626
+ # A bearer token over plaintext to a remote host is a credential leak.
627
+ # Loopback is exempt (it never leaves the machine); anything else needs an
628
+ # explicit opt-in so the leak is a decision, not an accident.
629
+ case "$url" in
630
+ http://*)
631
+ if ! loki_remote_is_local_host "$url" && [ "${LOKI_REMOTE_INSECURE:-}" != "1" ]; then
632
+ echo -e "${RED}loki: refusing to send a bearer token over plaintext http to $(loki_remote_host "$url")${NC}" >&2
633
+ echo " Use https://, or set LOKI_REMOTE_INSECURE=1 to override." >&2
634
+ return 1
635
+ fi
636
+ ;;
637
+ esac
638
+
639
+ local token
640
+ if ! token="$(loki_remote_token)" || [ -z "$token" ]; then
641
+ echo -e "${RED}loki: no remote token configured${NC}" >&2
642
+ echo " Set LOKI_REMOTE_TOKEN or LOKI_REMOTE_TOKEN_FILE (never pass it as an argument)." >&2
643
+ return 1
644
+ fi
645
+
646
+ require_jq || return 1
647
+
648
+ local body resp
649
+ body="$(jq -nc --arg s "$spec" '{spec:$s}')"
650
+
651
+ echo "Submitting to $url ..."
652
+ if ! resp="$(loki_remote_curl "$token" -fsS --max-time 30 \
653
+ -H "Content-Type: application/json" \
654
+ -X POST --data "$body" "$url/jobs" 2>&1)"; then
655
+ # NEVER fall back to a local run: a user who asked for remote and
656
+ # silently got a local build would pay local spend they did not ask for.
657
+ echo -e "${RED}loki: remote submit failed: $url/jobs${NC}" >&2
658
+ echo " Is the server reachable, and is LOKI_REMOTE_TOKEN correct?" >&2
659
+ echo " Not falling back to a local build (you asked for --remote)." >&2
660
+ return 1
661
+ fi
662
+
663
+ local job_id
664
+ job_id="$(printf '%s' "$resp" | jq -r '.id // empty' 2>/dev/null)"
665
+ if [ -z "$job_id" ]; then
666
+ echo -e "${RED}loki: remote accepted the request but returned no job id${NC}" >&2
667
+ return 1
668
+ fi
669
+ echo "Job $job_id queued on $url"
670
+
671
+ local poll_sec="${LOKI_REMOTE_POLL_SEC:-5}"
672
+ local max_polls="${LOKI_REMOTE_MAX_POLLS:-720}"
673
+ local status="" last="" i=0
674
+ while [ "$i" -lt "$max_polls" ]; do
675
+ i=$((i+1))
676
+ if ! resp="$(loki_remote_curl "$token" -fsS --max-time 30 \
677
+ "$url/jobs/$job_id" 2>&1)"; then
678
+ echo -e "${RED}loki: lost contact with $url while polling job $job_id${NC}" >&2
679
+ return 1
680
+ fi
681
+ status="$(printf '%s' "$resp" | jq -r '.status // empty' 2>/dev/null)"
682
+ if [ "$status" != "$last" ]; then
683
+ echo " [$job_id] $status"
684
+ last="$status"
685
+ fi
686
+ case "$status" in
687
+ passed|failed|unknown|error|rejected*)
688
+ break
689
+ ;;
690
+ esac
691
+ sleep "$poll_sec"
692
+ done
693
+
694
+ # A TAMPERED receipt fails the whole submit regardless of the job status:
695
+ # reporting a build as successful while holding proof that its own receipt
696
+ # was altered would make the receipt decorative.
697
+ local _receipt_rc=0
698
+ loki_remote_fetch_receipt "$url" "$token" "$job_id" || _receipt_rc=$?
699
+ if [ "$_receipt_rc" -ne 0 ]; then
700
+ echo -e "${RED}loki: remote job $job_id returned a receipt that failed verification${NC}" >&2
701
+ return 1
702
+ fi
703
+
704
+ # Gate on the BUILD's outcome, not on "a build was launched". The server
705
+ # reports passed / failed / unknown as distinct terminal statuses (see
706
+ # JOB_TERMINAL_STATUSES in trigger-server.py). Anything that is not
707
+ # "passed" exits non-zero so a CI pipeline cannot go green on a failed
708
+ # build. "unknown" -- the build detached and its exit code was never
709
+ # observed -- fails CLOSED: an outcome nobody observed is not a pass.
710
+ case "$status" in
711
+ passed) return 0 ;;
712
+ unknown)
713
+ echo -e "${RED}loki: remote job $job_id outcome UNKNOWN${NC}" >&2
714
+ echo " The build detached and its exit code was never observed." >&2
715
+ echo " An unobserved outcome is not a pass; treating it as a failure." >&2
716
+ return 1
717
+ ;;
718
+ "") echo -e "${RED}loki: job $job_id did not reach a terminal status${NC}" >&2; return 1 ;;
719
+ *) echo -e "${RED}loki: remote job $job_id failed: $status${NC}" >&2; return 1 ;;
720
+ esac
721
+ }
722
+
723
+ # Fetch the Evidence Receipt and write it where a local run puts it, so
724
+ # `loki proof` works on a remote build, then VERIFY it here -- on the machine
725
+ # that did NOT produce it. That is the entire point: a receipt you can only
726
+ # check by asking the builder whether it is good proves nothing.
727
+ #
728
+ # Returns 0 when the receipt verified or is honestly reported as unverifiable,
729
+ # and NON-ZERO when the receipt is TAMPERED. Absence of a receipt is not a
730
+ # failure (a build may legitimately produce none); a receipt that fails its
731
+ # own integrity check is.
732
+ loki_remote_fetch_receipt() {
733
+ local url="$1" token="$2" job_id="$3"
734
+ local dir="${LOKI_DIR:-.loki}/proofs/$job_id"
735
+ local tmp resp
736
+
737
+ if ! resp="$(loki_remote_curl "$token" -fsS --max-time 30 \
738
+ "$url/jobs/$job_id/proof" 2>/dev/null)"; then
739
+ echo " (no evidence receipt available from $url for $job_id)" >&2
740
+ return 0
741
+ fi
742
+ # Only write something that parses as JSON: a proxy error page must not
743
+ # land in .loki/proofs/ looking like a receipt.
744
+ if ! printf '%s' "$resp" | jq -e . >/dev/null 2>&1; then
745
+ echo " (remote returned a non-JSON receipt for $job_id; not written)" >&2
746
+ return 0
747
+ fi
748
+ mkdir -p "$dir"
749
+ tmp="$dir/.proof.json.$$"
750
+ printf '%s' "$resp" > "$tmp" && mv "$tmp" "$dir/proof.json"
751
+ echo " Evidence Receipt: $dir/proof.json"
752
+
753
+ loki_remote_verify_receipt "$dir/proof.json"
754
+ }
755
+
756
+ # Verify a fetched receipt locally and print exactly what was and was not
757
+ # proven. Exit 1 ONLY on tamper.
758
+ #
759
+ # THREE OUTCOMES, NEVER COLLAPSED INTO TWO:
760
+ # VERIFIED a gpg signature is present and valid -> integrity AND provenance
761
+ # UNSIGNED the hash matches, no signature -> integrity ONLY
762
+ # TAMPERED hash mismatch or bad signature -> exit 1
763
+ #
764
+ # UNSIGNED is not a weaker VERIFIED, it is a different claim. docs/VERIFICATION-COST.md
765
+ # records that the unsigned path is FORGEABLE: whoever rewrites the facts and
766
+ # the headline into a mutually consistent lie and recomputes the hash passes
767
+ # every check here. A user who trusts a "verified" label meaning only "these
768
+ # bytes are self-consistent" is worse off than one who knows they hold no proof,
769
+ # because they will stop looking. So the word "verified" is never printed for an
770
+ # unsigned receipt.
771
+ #
772
+ # There is a FOURTH state and it must not be folded into UNSIGNED: a signature
773
+ # is present but gpg is not installed here, so it could not be checked. Saying
774
+ # UNSIGNED there would be a false statement about the receipt (it IS signed),
775
+ # and saying VERIFIED would be a false statement about this machine (it checked
776
+ # nothing). It gets its own line and never claims verification.
777
+ # Ask gpg WHY a signature failed. Echoes "nopubkey" when the signing key is
778
+ # absent (uncheckable), "" otherwise (a genuinely bad signature, or we could
779
+ # not tell). Fails toward "" so an unclear answer keeps the loud TAMPERED
780
+ # verdict rather than silently downgrading a real forgery to a soft warning.
781
+ loki_remote_gpg_status() {
782
+ local pj="$1"
783
+ local d="${TMPDIR:-/tmp}/loki-sigcheck.$$"
784
+ mkdir -p "$d" || return 0
785
+ python3 - "$pj" "$d" <<'PY' 2>/dev/null || { rm -rf "$d"; return 0; }
786
+ import json, sys, os
787
+ proof = json.load(open(sys.argv[1]))
788
+ d = sys.argv[2]
789
+ sig = (proof.get("verification") or {}).get("gpg_signature") or ""
790
+ proof.pop("verification", None)
791
+ open(os.path.join(d, "canon.bin"), "wb").write(
792
+ json.dumps(proof, sort_keys=True, separators=(",", ":")).encode())
793
+ open(os.path.join(d, "sig.asc"), "w").write(sig)
794
+ PY
795
+ local st
796
+ st="$(gpg --status-fd 1 --verify "$d/sig.asc" "$d/canon.bin" 2>/dev/null)"
797
+ rm -rf "$d"
798
+ case "$st" in
799
+ *NO_PUBKEY*) printf 'nopubkey' ;;
800
+ *) printf '' ;;
801
+ esac
802
+ }
803
+
804
+ loki_remote_verify_receipt() {
805
+ local pj="$1"
806
+ local verifier="${_LOKI_SCRIPT_DIR}/lib/proof-verify.py"
807
+
808
+ if [ ! -f "$verifier" ] || ! command -v python3 >/dev/null 2>&1; then
809
+ echo " NOT VERIFIED: no local verifier available (need python3 and lib/proof-verify.py)" >&2
810
+ return 0
811
+ fi
812
+
813
+ # Whether the receipt CARRIES a signature is read from the receipt itself,
814
+ # not inferred from the verifier's gpg verdict. proof-verify.py reports
815
+ # gpg_ok="n/a" for two distinct situations -- no signature present, and gpg
816
+ # unavailable on this machine -- so gpg_ok alone cannot tell them apart, and
817
+ # collapsing them is exactly how a signed receipt gets mislabelled UNSIGNED.
818
+ local signed=0
819
+ if jq -e '.verification.gpg_signature // empty' "$pj" >/dev/null 2>&1; then
820
+ signed=1
821
+ fi
822
+
823
+ # Deliberately NOT gated on the verifier's exit code or its `ok` field.
824
+ # `ok` includes diff_drift, re-derived against the LOCAL repo -- which is
825
+ # not the machine that produced this build, so an honest remote receipt
826
+ # drifts by construction and would report TAMPERED on every single run.
827
+ # A false BLOCK is the more damaging direction (docs/VERIFICATION-COST.md):
828
+ # a gate that cries wolf every time trains users to ignore the verdict.
829
+ # Only hash_ok and gpg_ok are load-bearing here; drift is not our verdict.
830
+ local out hash_ok gpg_ok
831
+ out="$(python3 "$verifier" "$pj" "${TARGET_DIR:-.}" 2>/dev/null)"
832
+ hash_ok="$(printf '%s' "$out" | jq -r '.hash_ok // false' 2>/dev/null)"
833
+ gpg_ok="$(printf '%s' "$out" | jq -r '.gpg_ok | tostring' 2>/dev/null)"
834
+
835
+ if [ "$hash_ok" != "true" ]; then
836
+ echo -e " ${RED}TAMPERED: the receipt's integrity hash does not match its contents.${NC}" >&2
837
+ echo " proof.json was edited after it was written. Do not trust this build." >&2
838
+ return 1
839
+ fi
840
+
841
+ # Drift is REPORTED, never a verdict. This receipt was produced in a cluster
842
+ # pod and is being checked against a different tree, so drift is the normal
843
+ # result and says nothing about tampering -- docs/VERIFICATION-COST.md:
844
+ # hash_ok is integrity, `ok` folds in tree_drift and false-alarms here.
845
+ # Stated rather than suppressed: silently dropping a true fact because it is
846
+ # inconvenient is the same error as folding UNCHECKED into UNSIGNED.
847
+ if [ "$(printf '%s' "$out" | jq -r '[.diff_drift,.tree_drift]|index(true)!=null' 2>/dev/null)" = "true" ]; then
848
+ echo " Drift: this tree differs from the one the receipt records. Expected --"
849
+ echo " the build ran elsewhere. Not tampering, and not part of the verdict."
850
+ fi
851
+
852
+ # gpg_ok=false covers TWO facts that must never be conflated: the signature
853
+ # is cryptographically bad over this content (the receipt was ALTERED), and
854
+ # gpg could not evaluate it at all (we do not hold the public key). Only the
855
+ # first is an accusation. Reporting a missing key as TAMPERED would send a
856
+ # user hunting a forgery that never happened, and would train them to shrug
857
+ # at a real TAMPERED -- the same error as calling an unsigned receipt
858
+ # verified, just pointed the other way.
859
+ #
860
+ # proof-verify.py returns a bare boolean, so the distinction is drawn here
861
+ # from gpg's own status codes: BADSIG = bad over this content, NO_PUBKEY /
862
+ # ERRSIG = could not be checked.
863
+ if [ "$signed" -eq 1 ] && [ "$gpg_ok" = "false" ]; then
864
+ local _why=""
865
+ if command -v gpg >/dev/null 2>&1; then
866
+ _why="$(loki_remote_gpg_status "$pj")"
867
+ fi
868
+ if [ "$_why" = "nopubkey" ]; then
869
+ echo -e " ${YELLOW}NOT CHECKED${NC}: the receipt is signed, but the signing key is not in your keyring." >&2
870
+ echo " Nothing is wrong with the contents: the integrity hash matches. The" >&2
871
+ echo " signature could not be evaluated, so PROVENANCE is unproven here." >&2
872
+ echo " Import the publisher's public key and re-run: loki proof verify $(basename "$(dirname "$pj")")" >&2
873
+ # Non-zero: a caller who asked for a signed receipt did not get
874
+ # proof, and in CI that must not pass silently. The message states
875
+ # plainly that no fault was found in the content, so this is a
876
+ # missing-proof exit, not an accusation of tampering.
877
+ return 1
878
+ fi
879
+ echo -e " ${RED}TAMPERED: the receipt carries a gpg signature that does not verify.${NC}" >&2
880
+ echo " Either the contents or the signature was altered. Do not trust this build." >&2
881
+ return 1
882
+ fi
883
+
884
+ if [ "$signed" -eq 1 ] && [ "$gpg_ok" = "true" ]; then
885
+ echo -e " ${GREEN}VERIFIED${NC}: integrity hash matches and the gpg signature is valid."
886
+ echo " Proven: these receipt bytes are unaltered, and they were signed by the"
887
+ echo " holder of the signing key. Verify that key is one you trust."
888
+ echo " NOT proven: that the code is correct. A receipt records which checks ran."
889
+ return 0
890
+ fi
891
+
892
+ if [ "$signed" -eq 1 ]; then
893
+ # gpg_ok is "n/a" with a signature present: gpg is missing here.
894
+ echo -e " ${YELLOW}NOT CHECKED${NC}: the receipt is signed, but gpg is unavailable on this machine."
895
+ echo " Its integrity hash matches. The signature was NOT checked, so provenance"
896
+ echo " is unproven here. Install gpg and re-run: loki proof verify $(basename "$(dirname "$pj")")"
897
+ return 0
898
+ fi
899
+
900
+ echo -e " ${YELLOW}UNSIGNED${NC}: integrity hash matches. This is NOT proof of provenance."
901
+ echo " Proven: the receipt is internally consistent with its own hash."
902
+ echo " NOT proven: who produced it. An unsigned receipt is forgeable -- whoever"
903
+ echo " controls the builder can rewrite the facts and recompute the hash."
904
+ echo " For provenance, the build must set LOKI_PROOF_GPG_KEY (docs/SIGNED-RECEIPTS.md)."
905
+ return 0
906
+ }
907
+
559
908
  # Emit event (non-blocking)
560
909
  # Usage: emit_event <type> <source> <action> [key=value ...]
561
910
  emit_event() {
@@ -1495,6 +1844,7 @@ cmd_start() {
1495
1844
  local mirofish_bg=false
1496
1845
  local mirofish_disabled=false
1497
1846
  local no_plan=false # v6.81.1: --no-plan opts out of auto-plan display
1847
+ local remote_url="${LOKI_REMOTE_URL:-}" # --remote: submit to a deployed cluster
1498
1848
 
1499
1849
  # v6.84.0: unified entry point -- explicit mode overrides + issue-mode args
1500
1850
  local explicit_mode="" # "prd" | "issue" | "" (auto-detect)
@@ -1532,6 +1882,10 @@ cmd_start() {
1532
1882
  echo " --brief \"TEXT\" Force zero-config brief mode (fast first run)"
1533
1883
  echo ""
1534
1884
  echo "Options:"
1885
+ echo " --remote URL Submit to a deployed Loki (POST /jobs) instead of"
1886
+ echo " building locally; polls until the job finishes."
1887
+ echo " Token from LOKI_REMOTE_TOKEN / LOKI_REMOTE_TOKEN_FILE"
1888
+ echo " (never an argument). Env: LOKI_REMOTE_URL."
1535
1889
  echo " --provider NAME AI provider: claude (default), codex, cline, aider"
1536
1890
  echo " --parallel Enable parallel mode with git worktrees"
1537
1891
  echo " --allow-haiku Enable Haiku model for the fast tier (default: disabled)"
@@ -2058,6 +2412,19 @@ cmd_start() {
2058
2412
  --config=*|--vars=*|--env-file=*)
2059
2413
  shift
2060
2414
  ;;
2415
+ --remote)
2416
+ if [ "$#" -ge 2 ]; then
2417
+ remote_url="$2"
2418
+ shift 2
2419
+ else
2420
+ echo "loki: --remote requires a URL (e.g. --remote https://loki.example.com)" >&2
2421
+ exit 1
2422
+ fi
2423
+ ;;
2424
+ --remote=*)
2425
+ remote_url="${1#--remote=}"
2426
+ shift
2427
+ ;;
2061
2428
  --)
2062
2429
  # End-of-options: everything after is positional. Lets a caller
2063
2430
  # pass an untrusted spec that might start with '-' safely, e.g.
@@ -2084,6 +2451,18 @@ cmd_start() {
2084
2451
  esac
2085
2452
  done
2086
2453
 
2454
+ # --remote: submit to a deployed cluster instead of building locally.
2455
+ #
2456
+ # Branches HERE, after arg parsing but BEFORE the provider pre-flight and
2457
+ # provider_offer_gate below: those require a provider CLI on PATH, and the
2458
+ # whole point of a remote submit is that this machine is not doing the
2459
+ # work. Gating a remote submit on a local provider install would defeat it.
2460
+ if [ -n "$remote_url" ]; then
2461
+ local _remote_spec="${brief_text:-${prd_file:-$positional_arg}}"
2462
+ loki_remote_submit "$remote_url" "$_remote_spec"
2463
+ return $?
2464
+ fi
2465
+
2087
2466
  # Explicit-provider pre-flight. Runs BEFORE provider_offer_gate below, and
2088
2467
  # is deliberately narrower than it.
2089
2468
  #
@@ -11364,6 +11743,10 @@ cmd_doctor() {
11364
11743
 
11365
11744
  local pass_count=0
11366
11745
  local fail_count=0
11746
+ # Set to 1 when the bundled SDK is the ONLY usable provider (no CLI on PATH).
11747
+ # Declared here, not inside the branch that sets it, so the "Next:" block at
11748
+ # the end of this function always reads an initialized value.
11749
+ local _doctor_sdk_only=0
11367
11750
  # FUNNEL FIX (v8.2.x): collect the NAME + FIX of every hard failure, not just
11368
11751
  # a count. A first-time user on a bare machine sees 15 warnings (sentrux, GPG
11369
11752
  # signing, bash 4, Bun, python3.12, truecolor, inline-image probe -- all
@@ -11528,6 +11911,12 @@ cmd_doctor() {
11528
11911
  echo -e " ${GREEN}PASS${NC} Bundled Claude Agent SDK is usable -- 'loki start' needs no separate CLI"
11529
11912
  echo -e " ${YELLOW}Note: loki demo/quick/quickstart still need a provider CLI on PATH${NC}"
11530
11913
  echo -e " ${YELLOW} Install: npm install -g @anthropic-ai/claude-code${NC}"
11914
+ # The note above was only half the fix. The final "Next:" line at the
11915
+ # bottom of this function recommended quickstart unconditionally, so a
11916
+ # user reading a GREEN doctor followed its LAST line straight into
11917
+ # quickstart.sh:476 exit 2. Record the state here (where the predicate
11918
+ # is already evaluated) and let the recommendation read it.
11919
+ _doctor_sdk_only=1
11531
11920
  pass_count=$((pass_count + 1))
11532
11921
  else
11533
11922
  echo -e " ${RED}FAIL${NC} No AI provider CLI installed -- at least one is required"
@@ -12050,8 +12439,17 @@ else:
12050
12439
  # on fail_count==0 above, so a failing setup is never told to build. The
12051
12440
  # --json path returns long before this code, so machine output is untouched.
12052
12441
  echo ""
12053
- echo "Next: loki quickstart (guided first build from your idea, no PRD needed)"
12054
- echo " or loki demo (builds a sample todo app end to end) or loki start ./prd.md"
12442
+ if [ "$_doctor_sdk_only" = "1" ]; then
12443
+ # SDK-only host: quickstart and demo stay on the bash route and need a
12444
+ # binary on PATH, so recommending them here is a green doctor pointing at
12445
+ # an exit 2. Recommend only what actually runs, plus the one command that
12446
+ # unlocks the rest.
12447
+ echo "Next: loki start ./prd.md (runs on the bundled SDK, no CLI install needed)"
12448
+ echo " For loki quickstart/demo: npm install -g @anthropic-ai/claude-code"
12449
+ else
12450
+ echo "Next: loki quickstart (guided first build from your idea, no PRD needed)"
12451
+ echo " or loki demo (builds a sample todo app end to end) or loki start ./prd.md"
12452
+ fi
12055
12453
  # Best-effort stale-install nudge (stderr only; never blocks; off on
12056
12454
  # non-TTY/CI). Reached only on the human-readable path (--json returns above).
12057
12455
  maybe_print_update_hint
@@ -25040,6 +25438,60 @@ TELEM_DISABLE_PY
25040
25438
  echo " OTEL tracing can be configured with 'loki telemetry enable [endpoint]'."
25041
25439
  ;;
25042
25440
 
25441
+ analytics)
25442
+ # Opt-in gate for build-outcome analytics (telemetry.sh
25443
+ # _loki_analytics_enabled). That gate reads ANALYTICS_ENABLED=true from
25444
+ # ~/.loki/config, and until now NOTHING in this repo ever wrote that
25445
+ # key -- docs/PRIVACY.md told users to hand-edit the file. So the
25446
+ # first-run funnel (first_start_attempted / first_run_blocked /
25447
+ # build_verified) could not fire for anyone, and drop-off was
25448
+ # structurally unmeasurable.
25449
+ #
25450
+ # Stays STRICTLY OPT-IN and default OFF: this only gives the existing
25451
+ # consent a way to be expressed. Same filter-then-append shape as
25452
+ # start|on above, so it is idempotent and never clobbers other keys.
25453
+ local global_config="${HOME}/.loki/config"
25454
+ local _an_action="${1:-status}"
25455
+ case "$_an_action" in
25456
+ on)
25457
+ mkdir -p "${HOME}/.loki"
25458
+ if [ -f "$global_config" ]; then
25459
+ grep -v "^ANALYTICS_ENABLED=" "$global_config" > "${global_config}.tmp" 2>/dev/null || true
25460
+ mv "${global_config}.tmp" "$global_config"
25461
+ fi
25462
+ echo "ANALYTICS_ENABLED=true" >> "$global_config"
25463
+ echo -e "${BOLD}Build analytics enabled${NC}"
25464
+ echo ""
25465
+ echo " Opt-in saved to: $global_config"
25466
+ echo " Only already-computed scalars are sent -- never code, spec text, or paths."
25467
+ echo " Requires telemetry to be on as well (loki telemetry status)."
25468
+ echo " Run 'loki telemetry analytics off' to opt back out."
25469
+ ;;
25470
+ off)
25471
+ if [ -f "$global_config" ]; then
25472
+ grep -v "^ANALYTICS_ENABLED=" "$global_config" > "${global_config}.tmp" 2>/dev/null || true
25473
+ mv "${global_config}.tmp" "$global_config"
25474
+ fi
25475
+ echo -e "${BOLD}Build analytics disabled${NC}"
25476
+ echo ""
25477
+ echo " ANALYTICS_ENABLED removed from: $global_config"
25478
+ echo " Nothing build-related is sent. This is the default state."
25479
+ ;;
25480
+ status)
25481
+ if [ -f "$global_config" ] && grep -q "^ANALYTICS_ENABLED=true" "$global_config" 2>/dev/null; then
25482
+ echo -e " Build analytics: ${GREEN}enabled${NC} (you opted in; opt out with: loki telemetry analytics off)"
25483
+ else
25484
+ echo -e " Build analytics: ${YELLOW}off${NC} (the default; opt in with: loki telemetry analytics on)"
25485
+ fi
25486
+ ;;
25487
+ *)
25488
+ echo -e "${RED}Unknown analytics action: $_an_action${NC}"
25489
+ echo "Usage: loki telemetry analytics on|off|status"
25490
+ return 1
25491
+ ;;
25492
+ esac
25493
+ ;;
25494
+
25043
25495
  --help|-h|help)
25044
25496
  echo -e "${BOLD}loki telemetry${NC} - OpenTelemetry management"
25045
25497
  echo ""
@@ -25053,6 +25505,8 @@ TELEM_DISABLE_PY
25053
25505
  echo " off Opt out of all anonymous diagnostics (telemetry + crash)"
25054
25506
  echo " start Alias for 'on' (persistent opt-in across all sessions)"
25055
25507
  echo " stop Alias for 'off' (persistent opt-out across all sessions)"
25508
+ echo " analytics on|off|status"
25509
+ echo " Opt in/out of build-outcome analytics (off by default)"
25056
25510
  echo ""
25057
25511
  echo "Anonymous diagnostics are OFF by default. Nothing is collected or"
25058
25512
  echo "sent unless you opt in. See docs/PRIVACY.md for the full disclosure."