loki-mode 11.76.0 → 11.78.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (103) hide show
  1. package/SKILL.md +2 -2
  2. package/VERSION +1 -1
  3. package/autonomy/lib/check_author.py +146 -0
  4. package/autonomy/lib/claude-flags.sh +5 -1
  5. package/autonomy/lib/mcp-config.sh +41 -1
  6. package/autonomy/lib/proof-analytics-props.py +115 -6
  7. package/autonomy/lib/proof-verify.py +22 -0
  8. package/autonomy/lib/telemetry-schema.json +52 -0
  9. package/autonomy/loki +398 -7
  10. package/autonomy/telemetry.sh +70 -31
  11. package/completions/_loki +19 -0
  12. package/completions/loki.bash +17 -1
  13. package/dashboard/__init__.py +1 -1
  14. package/dashboard/api_start.py +2 -10
  15. package/dashboard/secure_tmp.py +92 -0
  16. package/dashboard/telemetry.py +81 -15
  17. package/docs/ARCHITECTURE.md +6 -4
  18. package/docs/{v10/CLI-MODERN.md → CLI-INVENTORY.md} +6 -6
  19. package/docs/CLI-REFERENCE.md +3 -2
  20. package/docs/COMPARISON.md +6 -8
  21. package/docs/COMPONENTS.md +1 -1
  22. package/docs/INSTALLATION.md +1 -1
  23. package/docs/PRIVACY.md +47 -11
  24. package/docs/V10-RESEARCH.md +1 -1
  25. package/docs/architecture/ADR-001-runtime-migration.md +7 -7
  26. package/docs/cursor-comparison.md +1 -1
  27. package/docs/dev/architecture-reference.md +7 -2
  28. package/docs/dev/project-structure.md +2 -1
  29. package/docs/image-provenance.md +32 -7
  30. package/docs/slack.md +2 -0
  31. package/docs/v11/COST-HALF.md +2 -1
  32. package/loki-ts/dist/loki.js +1744 -1604
  33. package/loki-ts/package.json +2 -2
  34. package/mcp/__init__.py +1 -1
  35. package/mcp/requirements.txt +1 -1
  36. package/package.json +3 -3
  37. package/packages/control-plane/dist/server.js +20045 -19119
  38. package/plugins/loki-mode/.claude-plugin/plugin.json +1 -1
  39. package/providers/aider.sh +7 -0
  40. package/providers/claude.sh +8 -1
  41. package/providers/cline.sh +3 -0
  42. package/providers/loader.sh +5 -0
  43. package/providers/model_catalog.json +3 -0
  44. package/providers/models.sh +59 -0
  45. package/providers/opencode.sh +9 -3
  46. package/skills/providers.md +1 -0
  47. package/web-app/dist/assets/{AdminPage-DoGPqZIa.js → AdminPage-BPF2Xehf.js} +1 -1
  48. package/web-app/dist/assets/{Avatar-Dyf7xJsz.js → Avatar-CSW9BhBU.js} +1 -1
  49. package/web-app/dist/assets/{Badge-Cm4dADew.js → Badge-wgm79wim.js} +1 -1
  50. package/web-app/dist/assets/{Button-LGxjldY5.js → Button-BcIAcPKr.js} +1 -1
  51. package/web-app/dist/assets/{CockpitPage-DuC9g48J.js → CockpitPage-BFi-0Kwj.js} +1 -1
  52. package/web-app/dist/assets/{ComparePage-DuTrmedK.js → ComparePage-B7Wibrfn.js} +1 -1
  53. package/web-app/dist/assets/{ErrorBoundary-B0f7NEiT.js → ErrorBoundary-BYyOVh2c.js} +1 -1
  54. package/web-app/dist/assets/{EvidenceReceiptPanel-DsE8B9Hw.js → EvidenceReceiptPanel-C9QcrjNl.js} +1 -1
  55. package/web-app/dist/assets/{GitHubIssuesPanel-aYflOqd4.js → GitHubIssuesPanel-CWbuBxVZ.js} +1 -1
  56. package/web-app/dist/assets/{GitHubPRsPanel-CCJGck6c.js → GitHubPRsPanel-qcBHK615.js} +1 -1
  57. package/web-app/dist/assets/{HomePage-BJU3PVkJ.js → HomePage-B1mYFU4t.js} +2 -2
  58. package/web-app/dist/assets/{LoginPage-UzYoF5CJ.js → LoginPage-CmIO1xL7.js} +1 -1
  59. package/web-app/dist/assets/{MagicPage-5X3hQ7lt.js → MagicPage-L7UP4qKP.js} +1 -1
  60. package/web-app/dist/assets/{MetricsPage-CFsTNyOu.js → MetricsPage-DVPCjN1U.js} +1 -1
  61. package/web-app/dist/assets/{NotFoundPage-Ba0nMlqG.js → NotFoundPage-D3XZmCH5.js} +1 -1
  62. package/web-app/dist/assets/{ProjectPage-fvU-EYZb.js → ProjectPage-BlaMzlVi.js} +3 -3
  63. package/web-app/dist/assets/{ProjectsPage-CVEKCorG.js → ProjectsPage-DN7W28Zq.js} +1 -1
  64. package/web-app/dist/assets/{SettingsPage-JGbsNmqD.js → SettingsPage-2u9ODYB5.js} +1 -1
  65. package/web-app/dist/assets/{ShowcasePage-Dr2c_V-y.js → ShowcasePage-CoKuTfab.js} +1 -1
  66. package/web-app/dist/assets/{SystemSettingsPage-D9dCc4Ir.js → SystemSettingsPage-Crhj2XAU.js} +1 -1
  67. package/web-app/dist/assets/{TeamsPage-B56u-bod.js → TeamsPage-DehMgnKh.js} +1 -1
  68. package/web-app/dist/assets/{TemplatesPage-CUwqj6ge.js → TemplatesPage-DGtwfZgU.js} +1 -1
  69. package/web-app/dist/assets/{TerminalOutput-Cm920SDi.js → TerminalOutput-7HZRtXoC.js} +1 -1
  70. package/web-app/dist/assets/{activity-DDF6juD-.js → activity-DAp5wswo.js} +1 -1
  71. package/web-app/dist/assets/{bell-7MW_oAcb.js → bell-Ncvnrz7d.js} +1 -1
  72. package/web-app/dist/assets/{bot-b63uz2Ov.js → bot-BWob3K3E.js} +1 -1
  73. package/web-app/dist/assets/{check-2oBMAALa.js → check-DReWA2vm.js} +1 -1
  74. package/web-app/dist/assets/{chevron-left-UuhI6pbU.js → chevron-left-CBJpzVU1.js} +1 -1
  75. package/web-app/dist/assets/{circle-alert-BikRzQA7.js → circle-alert-2c_vs5PW.js} +1 -1
  76. package/web-app/dist/assets/{clock-BdkTaAgO.js → clock-BrS_ZKKi.js} +1 -1
  77. package/web-app/dist/assets/{cloud-oKg24kTO.js → cloud-Czvuo51R.js} +1 -1
  78. package/web-app/dist/assets/{code-xml-CpDmdnYQ.js → code-xml-BWEqJ2I8.js} +1 -1
  79. package/web-app/dist/assets/{database-BT133u2w.js → database-CJ6M-oK-.js} +1 -1
  80. package/web-app/dist/assets/{dollar-sign-khUEgmXm.js → dollar-sign-DSObKmVv.js} +1 -1
  81. package/web-app/dist/assets/{file-code-corner-_TlPqN5g.js → file-code-corner-CVEW-gF5.js} +1 -1
  82. package/web-app/dist/assets/{file-plus-B5UT0SZU.js → file-plus-CAK0I0tE.js} +1 -1
  83. package/web-app/dist/assets/{globe-Chhrgg2K.js → globe-DeUgcsSZ.js} +1 -1
  84. package/web-app/dist/assets/{hammer-DJ6Z4tiA.js → hammer-B74PyjS0.js} +1 -1
  85. package/web-app/dist/assets/{index-BprNVRFx.js → index-DW3_HRqp.js} +3 -3
  86. package/web-app/dist/assets/{layers-CtYWuenz.js → layers-Dw7kaGyJ.js} +1 -1
  87. package/web-app/dist/assets/{loader-circle-BRxnh_Va.js → loader-circle-B6xzlAjw.js} +1 -1
  88. package/web-app/dist/assets/{lock-btaZm2bN.js → lock-5ncCPzIG.js} +1 -1
  89. package/web-app/dist/assets/{package-BFU4d6gL.js → package-x59_dTx0.js} +1 -1
  90. package/web-app/dist/assets/{plus-BogXrZQi.js → plus-BcVBzjpS.js} +1 -1
  91. package/web-app/dist/assets/{refresh-cw-CsdQkR5n.js → refresh-cw-CNrJhks6.js} +1 -1
  92. package/web-app/dist/assets/{rotate-ccw-BeLxChlE.js → rotate-ccw-BpmS_ezI.js} +1 -1
  93. package/web-app/dist/assets/{scroll-text-DAXSJc0i.js → scroll-text-CwBYNyoj.js} +1 -1
  94. package/web-app/dist/assets/{server-Cf_yJUqU.js → server-C4hnxkk6.js} +1 -1
  95. package/web-app/dist/assets/{shield-alert-B2gh1hM3.js → shield-alert-CSVUyWCM.js} +1 -1
  96. package/web-app/dist/assets/{thumbs-up-DxYjJ1v8.js → thumbs-up-DEU9fmDX.js} +1 -1
  97. package/web-app/dist/assets/{trash-2-BaAsSOYk.js → trash-2-DJKMevAm.js} +1 -1
  98. package/web-app/dist/assets/{trending-down-Kc-Tady7.js → trending-down-Dq3wAM59.js} +1 -1
  99. package/web-app/dist/assets/{trending-up-BDsns895.js → trending-up-DribptN4.js} +1 -1
  100. package/web-app/dist/assets/{upload-CCJzLwO9.js → upload-DUlZxsH1.js} +1 -1
  101. package/web-app/dist/assets/{usePolling-4aqc_A7F.js → usePolling-PecqVZ0k.js} +1 -1
  102. package/web-app/dist/assets/{user-BzoHXLDe.js → user-BcLYLDVU.js} +1 -1
  103. package/web-app/dist/index.html +1 -1
package/autonomy/loki CHANGED
@@ -1318,7 +1318,7 @@ loki_remote_verify_receipt() {
1318
1318
  _attest="$(loki_remote_attestation_status "$pj" "$_attest_url")"
1319
1319
  local _vroot
1320
1320
  _vroot="$(loki_verify_root)" || { echo " NOT VERIFIED: no project tree to check against" >&2; return 0; }
1321
- out="$(python3 -E "$verifier" "$pj" "$_vroot" 2>/dev/null)"
1321
+ out="$(python3 -I -S "$verifier" "$pj" "$_vroot" 2>/dev/null)"
1322
1322
  hash_ok="$(printf '%s' "$out" | jq -r '.hash_ok // false' 2>/dev/null)"
1323
1323
  gpg_ok="$(printf '%s' "$out" | jq -r '.gpg_ok | tostring' 2>/dev/null)"
1324
1324
 
@@ -5832,6 +5832,112 @@ _loki_next_action() {
5832
5832
  esac
5833
5833
  }
5834
5834
 
5835
+ # loki next --suggest: with no active run, propose up to 3 small tasks drawn
5836
+ # from open GitHub issues (only when gh is authenticated), TODO/FIXME lines and
5837
+ # the last recorded failing test run. est_usd comes from the one estimator
5838
+ # ('loki estimate --json', cost_preview.ts) and is null / NOT AVAILABLE when it
5839
+ # has no usable history (Engine Law L0: never a number from task text).
5840
+ # Sources are interleaved so one noisy source cannot fill all 3.
5841
+ _loki_next_suggest() {
5842
+ local as_json=false
5843
+ case "${1:-}" in
5844
+ --json) as_json=true ;;
5845
+ "") : ;;
5846
+ *) echo "Usage: loki next --suggest [--json]" >&2; return 1 ;;
5847
+ esac
5848
+ local status
5849
+ status="$(_loki_resolve_run_status)"
5850
+ if [ "$status" = "running" ]; then
5851
+ echo "loki next --suggest: a run is active here; use 'loki next' instead." >&2
5852
+ return 1
5853
+ fi
5854
+ local tmp note=""
5855
+ tmp="$(mktemp -d "${TMPDIR:-/tmp}/loki-run.XXXXXXXX")" || return 1
5856
+ : > "$tmp/issues"; : > "$tmp/todos"; : > "$tmp/tests"
5857
+ # gh runs under a python timeout so a hung network call cannot hang the command.
5858
+ python3 - > "$tmp/issues" 2>/dev/null <<'PYEOF' || note="issues skipped: gh not installed, not authenticated, or timed out"
5859
+ import json, subprocess, sys
5860
+ try:
5861
+ if subprocess.run(["gh", "auth", "status"], capture_output=True, timeout=10).returncode != 0:
5862
+ sys.exit(1)
5863
+ r = subprocess.run(["gh", "issue", "list", "--state", "open", "--limit", "10", "--json", "number,title"],
5864
+ capture_output=True, text=True, timeout=15)
5865
+ if r.returncode != 0:
5866
+ sys.exit(1)
5867
+ for i in json.loads(r.stdout):
5868
+ print("#%s %s" % (i["number"], str(i["title"]).replace("\t", " ")))
5869
+ except Exception:
5870
+ sys.exit(1)
5871
+ PYEOF
5872
+ local est_json="" est_pid="" est_ticks=0 est_max est_kid
5873
+ est_max=$(( ${LOKI_NEXT_ESTIMATE_TIMEOUT:-20} * 4 ))
5874
+ # The estimate runs in the background and is polled, so a slow or hung
5875
+ # estimator cannot hang the command (no timeout binary is assumed: macOS
5876
+ # bash 3.2 may lack one). Only the PID started here, and its direct
5877
+ # children, are ever signalled.
5878
+ ( cmd_estimate --json > "$tmp/est" 2>/dev/null ) &
5879
+ est_pid=$!
5880
+ while kill -0 "$est_pid" 2>/dev/null; do
5881
+ if [ "$est_ticks" -ge "$est_max" ]; then
5882
+ for est_kid in $(pgrep -P "$est_pid" 2>/dev/null); do kill "$est_kid" 2>/dev/null || true; done
5883
+ kill "$est_pid" 2>/dev/null || true
5884
+ note="${note:+$note; }estimate unavailable: timed out after ${LOKI_NEXT_ESTIMATE_TIMEOUT:-20}s"
5885
+ : > "$tmp/est"
5886
+ break
5887
+ fi
5888
+ sleep 0.25
5889
+ est_ticks=$((est_ticks + 1))
5890
+ done
5891
+ wait "$est_pid" 2>/dev/null || true
5892
+ est_json="$(cat "$tmp/est" 2>/dev/null)" || est_json=""
5893
+ { git grep -nIE 'TODO|FIXME' -- . ':!.loki' 2>/dev/null \
5894
+ || grep -rnIE 'TODO|FIXME' --exclude-dir=.git --exclude-dir=.loki --exclude-dir=node_modules . 2>/dev/null; } \
5895
+ | head -20 > "$tmp/todos" || true
5896
+ python3 - "${LOKI_DIR:-.loki}/quality/test-results.json" > "$tmp/tests" 2>/dev/null <<'PYEOF' || true
5897
+ import json, sys
5898
+ try:
5899
+ d = json.load(open(sys.argv[1]))
5900
+ if d.get("pass") in (False, "false", "False"):
5901
+ print("failing tests (%s): %s" % (d.get("runner", "unknown"), str(d.get("summary", ""))[:120].replace("\n", " ")))
5902
+ except Exception:
5903
+ pass
5904
+ PYEOF
5905
+ SUGGEST_JSON=$as_json SUGGEST_NOTE="$note" SUGGEST_EST="$est_json" python3 - "$tmp" <<'PYEOF'
5906
+ import json, os, sys
5907
+ d = sys.argv[1]
5908
+ def rd(n):
5909
+ return [l.rstrip("\n") for l in open(os.path.join(d, n)) if l.strip()]
5910
+ est, na = None, "estimator returned nothing"
5911
+ try:
5912
+ r = json.loads(os.environ.get("SUGGEST_EST", ""))
5913
+ if r.get("ok") is True and isinstance(r.get("est", {}).get("usd"), list):
5914
+ est = r["est"]["usd"]
5915
+ else:
5916
+ na = str(r.get("reason", "no usable run history"))
5917
+ except Exception:
5918
+ pass
5919
+ src = [("failing_test", "small", rd("tests")), ("issue", "medium", rd("issues")), ("todo", "small", rd("todos"))]
5920
+ out = []
5921
+ while len(out) < 3 and any(x[2] for x in src):
5922
+ for name, size, items in src:
5923
+ if items and len(out) < 3:
5924
+ out.append({"source": name, "task": items.pop(0), "size": size, "est_usd": est})
5925
+ note = os.environ.get("SUGGEST_NOTE", "")
5926
+ if os.environ.get("SUGGEST_JSON") == "true":
5927
+ print(json.dumps({"suggestions": out, "notes": [note] if note else [], "est_usd_reason": None if est else na}))
5928
+ else:
5929
+ if note:
5930
+ print("note: " + note)
5931
+ if not out:
5932
+ print("No suggestions found (no open issues, TODO/FIXME lines or failing tests).")
5933
+ for i, o in enumerate(out, 1):
5934
+ e = ("est_usd %.2f-%.2f" % tuple(o["est_usd"])) if o["est_usd"] else "est_usd NOT AVAILABLE (%s)" % na
5935
+ print("%d. [%s] %s (%s)" % (i, o["source"], o["task"], e))
5936
+ PYEOF
5937
+ rm -rf "$tmp"
5938
+ return 0
5939
+ }
5940
+
5835
5941
  # loki next -- one command that runs the right next step for you.
5836
5942
  # Reads the SAME already-captured state as cmd_why (no new state, no spend),
5837
5943
  # resolves the terminal status, and proposes the single mapped next step, then
@@ -5846,7 +5952,7 @@ cmd_next() {
5846
5952
  --help|-h|help)
5847
5953
  echo -e "${BOLD}Loki Mode -- run the right next step for you${NC}"
5848
5954
  echo ""
5849
- echo "Usage: loki next [--yes] [--dry-run]"
5955
+ echo "Usage: loki next [--yes] [--dry-run] [--suggest [--json]]"
5850
5956
  echo ""
5851
5957
  echo "Reads the last build's recorded outcome (the same state 'loki why'"
5852
5958
  echo "reads -- no new run, no cost) and carries you forward by running the"
@@ -5860,13 +5966,15 @@ cmd_next() {
5860
5966
  echo "Options:"
5861
5967
  echo " --yes Run the proposed step without asking"
5862
5968
  echo " --dry-run Print the proposed step only; do not run it"
5969
+ echo " --suggest No active run: propose up to 3 small tasks (issues, TODO/FIXME, failing tests)"
5863
5970
  echo " --help, -h Show this help and exit"
5864
5971
  return 0
5865
5972
  ;;
5973
+ --suggest) shift; _loki_next_suggest "$@"; return $? ;;
5866
5974
  --yes|-y) assume_yes=true ;;
5867
5975
  --dry-run) dry_run=true ;;
5868
5976
  "") : ;;
5869
- *) echo -e "${RED}Unknown flag: $1${NC}" >&2; echo "Usage: loki next [--yes] [--dry-run]" >&2; return 1 ;;
5977
+ *) echo -e "${RED}Unknown flag: $1${NC}" >&2; echo "Usage: loki next [--yes] [--dry-run] [--suggest [--json]]" >&2; return 1 ;;
5870
5978
  esac
5871
5979
  # Allow a second flag (e.g. --dry-run --yes order-agnostic).
5872
5980
  case "${2:-}" in
@@ -10001,7 +10109,7 @@ _deploy_receipt_verdict() {
10001
10109
 
10002
10110
  local out hash_ok _vroot
10003
10111
  _vroot="$(loki_verify_root 2>/dev/null)" || { printf '%s' "UNCHECKED"; return 0; }
10004
- out="$(python3 -E "$verifier" "$pj" "$_vroot" 2>/dev/null || true)"
10112
+ out="$(python3 -I -S "$verifier" "$pj" "$_vroot" 2>/dev/null || true)"
10005
10113
 
10006
10114
  # "The verifier did not RUN" and "the verifier says the hash is bad" are
10007
10115
  # different facts and must not collapse. If they do, a broken python3
@@ -11581,6 +11689,80 @@ cmd_import() {
11581
11689
  }
11582
11690
 
11583
11691
  # GitHub integration management (v5.41.0)
11692
+ # _gh_protect_py: JSON/URL helper for `loki github protect`.
11693
+ # enc BRANCH | put GETJSON ADD | has GETJSON | same GETJSON WRITTEN
11694
+ # mkstate SLUG BRANCH MODE PRIOR WRITTEN | sget STATEJSON KEY
11695
+ # `put` turns a GET branch-protection body into the PUT body, carrying every
11696
+ # setting (reviews, admins, restrictions, linear history, check app_ids)
11697
+ # unchanged; ADD=1 appends the loki/receipt check.
11698
+ _gh_protect_py() {
11699
+ python3 -c '
11700
+ import json, sys, urllib.parse
11701
+ op = sys.argv[1]
11702
+ def names(lst, key):
11703
+ return [x if isinstance(x, str) else x.get(key) for x in (lst or [])]
11704
+ def actors(d):
11705
+ if d is None:
11706
+ return None
11707
+ return {"users": names(d.get("users"), "login"), "teams": names(d.get("teams"), "slug"), "apps": names(d.get("apps"), "slug")}
11708
+ def put(p, add):
11709
+ rsc = p.get("required_status_checks")
11710
+ rs = None
11711
+ if rsc is not None or add:
11712
+ rsc = rsc or {}
11713
+ checks = rsc.get("checks")
11714
+ if checks is None:
11715
+ checks = [{"context": c} for c in rsc.get("contexts", [])]
11716
+ checks = [{k: v for k, v in c.items() if not (k == "app_id" and v is None)} for c in checks]
11717
+ if add and not any(c.get("context") == "loki/receipt" for c in checks):
11718
+ checks.append({"context": "loki/receipt"})
11719
+ rs = {"strict": bool(rsc.get("strict")), "checks": checks}
11720
+ rv = p.get("required_pull_request_reviews")
11721
+ if rv is not None:
11722
+ r = {"dismiss_stale_reviews": bool(rv.get("dismiss_stale_reviews")),
11723
+ "require_code_owner_reviews": bool(rv.get("require_code_owner_reviews")),
11724
+ "required_approving_review_count": rv.get("required_approving_review_count", 0),
11725
+ "require_last_push_approval": bool(rv.get("require_last_push_approval"))}
11726
+ for k in ("dismissal_restrictions", "bypass_pull_request_allowances"):
11727
+ if rv.get(k) is not None:
11728
+ r[k] = actors(rv[k])
11729
+ rv = r
11730
+ def en(k):
11731
+ return bool((p.get(k) or {}).get("enabled"))
11732
+ body = {"required_status_checks": rs, "enforce_admins": en("enforce_admins"),
11733
+ "required_pull_request_reviews": rv, "restrictions": actors(p.get("restrictions"))}
11734
+ for k in ("required_linear_history", "allow_force_pushes", "allow_deletions", "block_creations",
11735
+ "required_conversation_resolution", "lock_branch", "allow_fork_syncing"):
11736
+ body[k] = en(k)
11737
+ return body
11738
+ def norm(b):
11739
+ b = json.loads(json.dumps(b))
11740
+ if b.get("required_status_checks"):
11741
+ for c in b["required_status_checks"]["checks"]:
11742
+ c.pop("app_id", None)
11743
+ return b
11744
+ def dump(o):
11745
+ return json.dumps(o, sort_keys=True, separators=(",", ":"))
11746
+ if op == "enc":
11747
+ print(urllib.parse.quote(sys.argv[2], safe="/"))
11748
+ elif op == "put":
11749
+ print(dump(put(json.loads(sys.argv[2]), sys.argv[3] == "1")))
11750
+ elif op == "has":
11751
+ p = json.loads(sys.argv[2]); r = p.get("required_status_checks") or {}
11752
+ have = [c.get("context") for c in r.get("checks", [])] + list(r.get("contexts", []))
11753
+ print("1" if "loki/receipt" in have else "0")
11754
+ elif op == "same":
11755
+ print("1" if norm(put(json.loads(sys.argv[2]), False)) == norm(json.loads(sys.argv[3])) else "0")
11756
+ elif op == "mkstate":
11757
+ print(dump({"slug": sys.argv[2], "branch": sys.argv[3], "mode": sys.argv[4],
11758
+ "prior": json.loads(sys.argv[5]) if sys.argv[5] else None,
11759
+ "written": json.loads(sys.argv[6])}))
11760
+ elif op == "sget":
11761
+ v = json.loads(sys.argv[2])[sys.argv[3]]
11762
+ print(v if isinstance(v, str) else dump(v))
11763
+ ' "$@"
11764
+ }
11765
+
11584
11766
  cmd_github() {
11585
11767
  local subcmd="${1:-help}"
11586
11768
  shift 2>/dev/null || true
@@ -11819,6 +12001,128 @@ except: print(0)
11819
12001
  fi
11820
12002
  ;;
11821
12003
 
12004
+ protect)
12005
+ # Make loki/receipt a required status check on the default branch.
12006
+ # The FULL prior protection is saved (with slug and branch) and the
12007
+ # PUT carries every other setting back unchanged, so --off restores
12008
+ # the exact prior protection. --dry-run performs one read only.
12009
+ local _pr_dry=0 _pr_off=0 _pr_usage=0
12010
+ while [ $# -gt 0 ]; do
12011
+ case "$1" in
12012
+ --dry-run) _pr_dry=1; shift ;;
12013
+ --off) _pr_off=1; shift ;;
12014
+ *) _pr_usage=1; break ;;
12015
+ esac
12016
+ done
12017
+ if [ "$_pr_usage" -ne 0 ]; then
12018
+ echo "Usage: loki github protect [--dry-run] [--off]" >&2
12019
+ exit 2
12020
+ fi
12021
+ if ! command -v gh &>/dev/null; then
12022
+ echo -e "${RED}Error: gh CLI not found. Install with: brew install gh${NC}" >&2
12023
+ exit 1
12024
+ fi
12025
+ if ! gh auth status &>/dev/null; then
12026
+ echo -e "${RED}Error: gh CLI not authenticated. Run: gh auth login${NC}" >&2
12027
+ exit 1
12028
+ fi
12029
+ local _pr_url _pr_slug _pr_branch _pr_enc
12030
+ _pr_url="$(git remote get-url origin 2>/dev/null || true)"
12031
+ _pr_slug="$(printf '%s' "$_pr_url" | sed -E 's|^.*github\.com[:/]||; s|\.git$||')"
12032
+ if [ -z "$_pr_slug" ] || [ "$_pr_slug" = "$_pr_url" ]; then
12033
+ echo -e "${RED}Error: no GitHub origin remote to protect.${NC}" >&2
12034
+ exit 1
12035
+ fi
12036
+ _pr_branch="$(git symbolic-ref --short refs/remotes/origin/HEAD 2>/dev/null | sed 's|^origin/||' || true)"
12037
+ [ -n "$_pr_branch" ] || _pr_branch="$(git branch --show-current 2>/dev/null)"
12038
+ if [ -z "$_pr_branch" ]; then
12039
+ echo -e "${RED}Error: could not determine the default branch.${NC}" >&2
12040
+ exit 1
12041
+ fi
12042
+ _pr_enc="$(_gh_protect_py enc "$_pr_branch")" || exit 1
12043
+ local _pr_api="repos/${_pr_slug}/branches/${_pr_enc}/protection"
12044
+ local _pr_state="${LOKI_DIR}/state/github-protect.json"
12045
+
12046
+ # Read the full current protection: mode is "none" (unprotected)
12047
+ # or "protected" (cur holds the JSON).
12048
+ local _pr_out _pr_rc=0 _pr_mode="protected" _pr_cur=""
12049
+ _pr_out="$(gh api "$_pr_api" 2>&1)" || _pr_rc=$?
12050
+ if [ "$_pr_rc" -eq 0 ]; then
12051
+ _pr_cur="$_pr_out"
12052
+ elif printf '%s' "$_pr_out" | grep -qi "Branch not protected"; then
12053
+ _pr_mode="none"
12054
+ else
12055
+ echo "$_pr_out" >&2
12056
+ echo -e "${RED}Error: could not read branch protection for ${_pr_slug}@${_pr_branch}.${NC}" >&2
12057
+ exit 1
12058
+ fi
12059
+
12060
+ if [ "$_pr_off" -eq 1 ]; then
12061
+ if [ ! -f "$_pr_state" ]; then
12062
+ echo -e "${RED}Error: nothing to restore (no saved state at ${_pr_state}).${NC}" >&2
12063
+ exit 1
12064
+ fi
12065
+ local _pr_st _pr_ssl _pr_sbr _pr_smode _pr_prior _pr_written _pr_body
12066
+ _pr_st="$(cat "$_pr_state")"
12067
+ _pr_ssl="$(_gh_protect_py sget "$_pr_st" slug)" || { echo -e "${RED}Error: unreadable saved state ${_pr_state}.${NC}" >&2; exit 1; }
12068
+ _pr_sbr="$(_gh_protect_py sget "$_pr_st" branch)" || exit 1
12069
+ _pr_smode="$(_gh_protect_py sget "$_pr_st" mode)" || exit 1
12070
+ if [ "$_pr_ssl" != "$_pr_slug" ] || [ "$_pr_sbr" != "$_pr_branch" ]; then
12071
+ echo -e "${RED}Refused: saved state is for ${_pr_ssl}@${_pr_sbr}, but this checkout is ${_pr_slug}@${_pr_branch}. Nothing changed.${NC}" >&2
12072
+ exit 1
12073
+ fi
12074
+ if [ "$_pr_mode" = "none" ]; then
12075
+ echo -e "${RED}Refused: ${_pr_slug}@${_pr_branch} has no protection now, so loki/receipt is gone. Nothing changed.${NC}" >&2
12076
+ exit 1
12077
+ fi
12078
+ _pr_written="$(_gh_protect_py sget "$_pr_st" written)" || exit 1
12079
+ if [ "$(_gh_protect_py has "$_pr_cur")" != "1" ]; then
12080
+ echo -e "${RED}Refused: loki/receipt is no longer a required check on ${_pr_slug}@${_pr_branch}; protection was changed since protect. Nothing changed.${NC}" >&2
12081
+ exit 1
12082
+ fi
12083
+ if [ "$(_gh_protect_py same "$_pr_cur" "$_pr_written")" != "1" ]; then
12084
+ echo -e "${RED}Refused: protection on ${_pr_slug}@${_pr_branch} differs from what protect wrote; restoring would overwrite later changes. Nothing changed.${NC}" >&2
12085
+ exit 1
12086
+ fi
12087
+ if [ "$_pr_smode" = "none" ]; then
12088
+ gh api -X DELETE "$_pr_api" >/dev/null || { echo -e "${RED}Error: could not remove branch protection.${NC}" >&2; exit 1; }
12089
+ else
12090
+ _pr_prior="$(_gh_protect_py sget "$_pr_st" prior)" || exit 1
12091
+ _pr_body="$(_gh_protect_py put "$_pr_prior" 0)" || exit 1
12092
+ printf '%s' "$_pr_body" | gh api -X PUT "$_pr_api" --input - >/dev/null \
12093
+ || { echo -e "${RED}Error: could not restore branch protection.${NC}" >&2; exit 1; }
12094
+ fi
12095
+ rm -f "$_pr_state"
12096
+ echo "Restored branch protection on ${_pr_slug}@${_pr_branch} (was: ${_pr_smode})"
12097
+ exit 0
12098
+ fi
12099
+
12100
+ local _pr_has=0
12101
+ if [ "$_pr_mode" = "protected" ] && [ "$(_gh_protect_py has "$_pr_cur")" = "1" ]; then
12102
+ _pr_has=1
12103
+ fi
12104
+ if [ "$_pr_dry" -eq 1 ]; then
12105
+ echo "Dry run for ${_pr_slug}@${_pr_branch}: protection is '${_pr_mode}'; would $([ "$_pr_has" -eq 1 ] && echo 'change nothing (loki/receipt already required)' || echo 'require loki/receipt'). Nothing changed."
12106
+ exit 0
12107
+ fi
12108
+ if [ "$_pr_has" -eq 1 ]; then
12109
+ echo "loki/receipt is already a required check on ${_pr_slug}@${_pr_branch}; nothing to do."
12110
+ exit 0
12111
+ fi
12112
+ local _pr_new _pr_prior_in="" _pr_src="{}" _pr_stjson
12113
+ if [ "$_pr_mode" = "protected" ]; then _pr_prior_in="$_pr_cur"; _pr_src="$_pr_cur"; fi
12114
+ _pr_new="$(_gh_protect_py put "$_pr_src" 1)" || exit 1
12115
+ _pr_stjson="$(_gh_protect_py mkstate "$_pr_slug" "$_pr_branch" "$_pr_mode" "$_pr_prior_in" "$_pr_new")" || exit 1
12116
+ mkdir -p "${LOKI_DIR}/state"
12117
+ printf '%s\n' "$_pr_stjson" > "$_pr_state"
12118
+ if ! printf '%s' "$_pr_new" | gh api -X PUT "$_pr_api" --input - >/dev/null; then
12119
+ rm -f "$_pr_state"
12120
+ echo -e "${RED}Error: could not update branch protection; nothing changed.${NC}" >&2
12121
+ exit 1
12122
+ fi
12123
+ echo "loki/receipt is now a required check on ${_pr_slug}@${_pr_branch} (was: ${_pr_mode}). Undo with: loki github protect --off"
12124
+ ;;
12125
+
11822
12126
  help|*)
11823
12127
  echo -e "${BOLD}loki github${NC} - GitHub integration management"
11824
12128
  echo ""
@@ -11828,6 +12132,7 @@ except: print(0)
11828
12132
  echo " export Export local tasks as new GitHub issues"
11829
12133
  echo " pr [name] Create pull request from completed work"
11830
12134
  echo " publish [name] [--public] Push to GitHub and set a loki/receipt commit status"
12135
+ echo " protect [--dry-run] [--off] Require loki/receipt on the default branch (--off restores)"
11831
12136
  echo ""
11832
12137
  echo "Environment Variables:"
11833
12138
  echo " LOKI_GITHUB_IMPORT=true Import open issues as tasks on start"
@@ -16279,6 +16584,9 @@ cmd_notify() {
16279
16584
  webhook)
16280
16585
  cmd_notify_send "webhook" "$@"
16281
16586
  ;;
16587
+ desktop)
16588
+ cmd_notify_send "desktop" "$@"
16589
+ ;;
16282
16590
  status)
16283
16591
  cmd_notify_status
16284
16592
  ;;
@@ -16308,6 +16616,8 @@ cmd_notify_help() {
16308
16616
  echo " slack <message> Send notification to Slack only"
16309
16617
  echo " discord <message> Send notification to Discord only"
16310
16618
  echo " webhook <message> Send notification to webhook only"
16619
+ echo " desktop <message> Send a desktop notification (macOS or Linux notify-send)"
16620
+ echo " test --channel desktop [message] Test only the desktop channel"
16311
16621
  echo " status Show which notification channels are configured"
16312
16622
  echo " help Show this help"
16313
16623
  echo ""
@@ -16315,7 +16625,7 @@ cmd_notify_help() {
16315
16625
  echo " LOKI_SLACK_WEBHOOK Slack incoming webhook URL"
16316
16626
  echo " LOKI_DISCORD_WEBHOOK Discord webhook URL"
16317
16627
  echo " LOKI_WEBHOOK_URL Custom webhook URL (POST JSON)"
16318
- echo " LOKI_NOTIFY_CHANNELS Channels to use (comma-separated: slack,discord,webhook)"
16628
+ echo " LOKI_NOTIFY_CHANNELS Channels to use (comma-separated: slack,discord,webhook,desktop)"
16319
16629
  echo " Default: all configured channels"
16320
16630
  echo ""
16321
16631
  echo "Examples:"
@@ -16381,9 +16691,35 @@ cmd_notify_status() {
16381
16691
  fi
16382
16692
  }
16383
16693
 
16694
+ # Desktop channel: reuse run.sh send_notification (the same function the run
16695
+ # loop fires on blocked, done and failed) by extracting it, so the macOS and
16696
+ # Linux logic lives in one place. Returns 1 when no desktop notifier exists.
16697
+ send_desktop_notification() {
16698
+ local message="$1" title="${2:-Notification}"
16699
+ command -v osascript >/dev/null 2>&1 || command -v notify-send >/dev/null 2>&1 || return 1
16700
+ [ -f "$RUN_SH" ] || return 1
16701
+ local fn
16702
+ fn="$(sed -n '/^send_notification() {/,/^}/p' "$RUN_SH")"
16703
+ [ -n "$fn" ] || return 1
16704
+ (
16705
+ # shellcheck disable=SC2034
16706
+ NOTIFICATIONS_ENABLED=true
16707
+ # shellcheck disable=SC2034
16708
+ NOTIFICATION_SOUND=false
16709
+ eval "$fn"
16710
+ send_notification "$title" "$message" "normal"
16711
+ )
16712
+ }
16713
+
16384
16714
  cmd_notify_test() {
16715
+ local channel_arg=""
16716
+ if [ "${1:-}" = "--channel" ]; then
16717
+ channel_arg="${2:-}"
16718
+ shift 2 2>/dev/null || shift $#
16719
+ fi
16385
16720
  local message="${1:-Loki Mode test notification}"
16386
16721
  local channels_notified=0
16722
+ [ -n "$channel_arg" ] && LOKI_NOTIFY_CHANNELS="$channel_arg"
16387
16723
 
16388
16724
  echo -e "${BOLD}Testing Notification Channels${NC}"
16389
16725
  echo ""
@@ -16430,6 +16766,17 @@ cmd_notify_test() {
16430
16766
  fi
16431
16767
  fi
16432
16768
 
16769
+ # Desktop is opt-in (explicit channel) so default runs never pop a banner
16770
+ if [[ "${channels}" == *"desktop"* ]]; then
16771
+ echo -n " Desktop... "
16772
+ if send_desktop_notification "$message" "Test"; then
16773
+ echo -e "${GREEN}OK${NC}"
16774
+ channels_notified=$((channels_notified + 1))
16775
+ else
16776
+ echo -e "${RED}FAILED (no osascript or notify-send)${NC}"
16777
+ fi
16778
+ fi
16779
+
16433
16780
  echo ""
16434
16781
 
16435
16782
  if [ $channels_notified -eq 0 ]; then
@@ -16489,6 +16836,14 @@ cmd_notify_send() {
16489
16836
  exit 1
16490
16837
  fi
16491
16838
  ;;
16839
+ desktop)
16840
+ if send_desktop_notification "$message" "Manual"; then
16841
+ echo -e "${GREEN}Sent desktop notification${NC}"
16842
+ else
16843
+ echo -e "${RED}No desktop notifier (osascript or notify-send) available${NC}"
16844
+ exit 1
16845
+ fi
16846
+ ;;
16492
16847
  esac
16493
16848
  }
16494
16849
 
@@ -30153,6 +30508,7 @@ cmd_cost() {
30153
30508
  local show_json=false
30154
30509
  local last_n=0
30155
30510
  local detail=false
30511
+ local month="" csv=false
30156
30512
 
30157
30513
  while [[ $# -gt 0 ]]; do
30158
30514
  case "$1" in
@@ -30168,6 +30524,9 @@ cmd_cost() {
30168
30524
  echo "Options:"
30169
30525
  echo " --json Machine-readable JSON output"
30170
30526
  echo " --last N Show only the last N runs in history (default: all)"
30527
+ echo " --month YYYY-MM Only runs generated in that month"
30528
+ echo " --csv One CSV row per run: repo,user,usd,verdict,run_id,date"
30529
+ echo " (unmeasured cost prints \"unmeasured\", never 0)"
30171
30530
  echo " --detail Per-iteration breakdown: cache hit ratio, cost trend,"
30172
30531
  echo " and the measured/found ratio (autonomy/lib/cost-summary.py)"
30173
30532
  echo " --help, -h Show this help"
@@ -30188,12 +30547,24 @@ cmd_cost() {
30188
30547
  ;;
30189
30548
  --json) show_json=true; shift ;;
30190
30549
  --detail) detail=true; shift ;;
30550
+ --csv) csv=true; shift ;;
30551
+ --month) month="${2:-}"; shift 2 || shift ;;
30552
+ --month=*) month="${1#*=}"; shift ;;
30191
30553
  --last) last_n="${2:-0}"; shift 2 ;;
30192
30554
  --last=*) last_n="${1#*=}"; shift ;;
30193
30555
  *) echo -e "${RED}Unknown option: $1${NC}" >&2; echo "Run 'loki cost --help' for usage." >&2; exit 1 ;;
30194
30556
  esac
30195
30557
  done
30196
30558
 
30559
+ if [ -n "$month" ] && ! [[ "$month" =~ ^[0-9]{4}-(0[1-9]|1[0-2])$ ]]; then
30560
+ echo -e "${RED}--month must be YYYY-MM (got: ${month})${NC}" >&2
30561
+ return 1
30562
+ fi
30563
+ if { [ -n "$month" ] || [ "$csv" = "true" ]; } && [ "$detail" = "true" ]; then
30564
+ echo -e "${RED}--month and --csv are not supported with --detail${NC}" >&2
30565
+ return 1
30566
+ fi
30567
+
30197
30568
  # --detail routes to the per-run summarizer, which reports what the budget
30198
30569
  # view cannot: cache hit ratio, cost trend, and the measured/found ratio.
30199
30570
  # It reuses the SAME honesty predicate (efficiency_cost.record_is_measured),
@@ -30240,6 +30611,8 @@ cmd_cost() {
30240
30611
  LOKI_SKILL_DIR="$SKILL_DIR" \
30241
30612
  COST_JSON="$show_json" \
30242
30613
  COST_LAST_N="$last_n" \
30614
+ COST_MONTH="$month" \
30615
+ COST_CSV="$csv" \
30243
30616
  COST_BUDGET_LIMIT="${LOKI_BUDGET_LIMIT:-}" \
30244
30617
  python3 << 'COST_SCRIPT'
30245
30618
  import json
@@ -30254,6 +30627,8 @@ try:
30254
30627
  except ValueError:
30255
30628
  last_n = 0
30256
30629
  budget_limit_env = os.environ.get("COST_BUDGET_LIMIT", "").strip()
30630
+ cost_month = os.environ.get("COST_MONTH", "")
30631
+ cost_csv = os.environ.get("COST_CSV", "false") == "true"
30257
30632
 
30258
30633
  # ANSI (suppressed under --json / non-tty)
30259
30634
  use_color = (not show_json) and sys.stdout.isatty()
@@ -30361,6 +30736,9 @@ if os.path.isdir(proofs_dir):
30361
30736
  "files_changed": (d.get("files_changed") or {}).get("count"),
30362
30737
  "final_verdict": (d.get("council") or {}).get("final_verdict"),
30363
30738
  })
30739
+ if cost_month:
30740
+ runs = [r for r in runs if (r.get("generated_at") or "").startswith(cost_month)]
30741
+ project_total = sum(r["cost_usd"] for r in runs if r.get("cost_usd") is not None)
30364
30742
  runs.sort(key=lambda x: (x.get("generated_at") or ""), reverse=True)
30365
30743
  if last_n > 0:
30366
30744
  runs = runs[:last_n]
@@ -30435,6 +30813,19 @@ for r in runs:
30435
30813
  m = r.get("model") or "unknown"
30436
30814
  by_model[m] = by_model.get(m, 0.0) + c
30437
30815
 
30816
+ if cost_csv:
30817
+ import csv
30818
+ ws = os.path.basename(os.path.dirname(os.path.abspath(loki_dir))) or "unknown"
30819
+ who = os.environ.get("USER") or os.environ.get("LOGNAME") or "unknown"
30820
+ w = csv.writer(sys.stdout, lineterminator="\n")
30821
+ w.writerow(["repo", "user", "usd", "verdict", "run_id", "date"])
30822
+ for r in sorted(runs, key=lambda x: (x.get("generated_at") or "", str(x.get("run_id")))):
30823
+ c = r.get("cost_usd")
30824
+ w.writerow([ws, who, "unmeasured" if c is None else "%.6f" % c,
30825
+ r.get("final_verdict") or "unknown", r.get("run_id"),
30826
+ (r.get("generated_at") or "")[:10]])
30827
+ sys.exit(0)
30828
+
30438
30829
  if show_json:
30439
30830
  out = {
30440
30831
  "current_run": {
@@ -39236,9 +39627,9 @@ print(json.dumps(json.load(open(os.environ['LOKI_PROOF_JSON'])), indent=2))
39236
39627
  # route (proof.ts verifyProof) -- ok already agrees with the exit
39237
39628
  # code there, so there is nothing to patch.
39238
39629
  if [ "$_pv_human" -eq 1 ]; then
39239
- _pv_out="$(python3 -E "$verifier" --human "$pj" "$_pv_root")" || _pv_rc=$?
39630
+ _pv_out="$(python3 -I -S "$verifier" --human "$pj" "$_pv_root")" || _pv_rc=$?
39240
39631
  else
39241
- _pv_out="$(python3 -E "$verifier" "$pj" "$_pv_root")" || _pv_rc=$?
39632
+ _pv_out="$(python3 -I -S "$verifier" "$pj" "$_pv_root")" || _pv_rc=$?
39242
39633
  fi
39243
39634
  _pv_base_rc="$_pv_rc"
39244
39635