loki-mode 10.6.14 → 10.7.1

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 (123) hide show
  1. package/README.md +9 -5
  2. package/SKILL.md +2 -2
  3. package/VERSION +1 -1
  4. package/autonomy/acp-agent.py +128 -0
  5. package/autonomy/docker-run.sh +8 -0
  6. package/autonomy/issue-parser.sh +7 -7
  7. package/autonomy/issue-providers.sh +159 -1
  8. package/autonomy/lib/engine10-push.sh +4 -1
  9. package/autonomy/lib/review_risk.py +91 -0
  10. package/autonomy/loki +209 -19
  11. package/autonomy/mobile-verify.sh +66 -0
  12. package/autonomy/run.sh +44 -2
  13. package/completions/_loki +2 -0
  14. package/completions/loki.bash +1 -1
  15. package/dashboard/__init__.py +1 -1
  16. package/dashboard/api_runs_v1.py +80 -0
  17. package/dashboard/server.py +2 -0
  18. package/docs/INSTALLATION.md +5 -1
  19. package/docs/acp-editors.md +29 -0
  20. package/docs/api-runs-v1.md +12 -0
  21. package/docs/dev/architecture-reference.md +4 -0
  22. package/docs/merge-queue.md +13 -0
  23. package/docs/mobile-verify.md +11 -0
  24. package/docs/sentry-intake.md +13 -0
  25. package/docs/v10/BOARD.md +31 -6
  26. package/docs/v10/CONTROL-PLANE.md +7 -0
  27. package/docs/v10/CP-ENTERPRISE-UI.md +276 -0
  28. package/docs/v10/D61-SPEED.md +4 -0
  29. package/docs/v10/DECISIONS.md +115 -0
  30. package/docs/v10/ENGINE-LAWS.md +155 -0
  31. package/docs/v10/FAILURE-CLASSES.md +145 -0
  32. package/docs/v10/FOUNDER-QUEUE.md +1 -0
  33. package/docs/v10/GAP-RESEARCH-1003.md +32 -0
  34. package/docs/v10/GUIDE.md +4 -2
  35. package/docs/v10/L0-WAVE1.md +81 -0
  36. package/docs/v10/MORNING-BRIEF-1003.md +25 -0
  37. package/docs/v10/OPERATING-MODEL.md +1 -0
  38. package/docs/v10/PROGRESS.md +99 -0
  39. package/docs/v10/SWARM.md +32 -0
  40. package/docs/visual-evidence.md +5 -0
  41. package/loki-ts/dist/loki.js +805 -777
  42. package/loki-ts/package.json +1 -0
  43. package/mcp/__init__.py +1 -1
  44. package/package.json +1 -1
  45. package/packages/control-plane/dist/server.js +549 -21
  46. package/packages/control-plane/drizzle/0002_tiresome_fixer.sql +7 -0
  47. package/packages/control-plane/drizzle/0003_integrity.sql +4 -0
  48. package/packages/control-plane/drizzle/meta/0002_snapshot.json +409 -0
  49. package/packages/control-plane/drizzle/meta/0003_snapshot.json +437 -0
  50. package/packages/control-plane/drizzle/meta/_journal.json +14 -0
  51. package/packages/control-plane/src/db/migrate.ts +6 -2
  52. package/packages/control-plane/src/db/prune.ts +91 -0
  53. package/packages/control-plane/src/db/schema.ts +13 -0
  54. package/packages/control-plane/src/server/actions.ts +60 -0
  55. package/packages/control-plane/src/server/app.ts +59 -4
  56. package/packages/control-plane/src/server/integrity.ts +147 -0
  57. package/packages/control-plane/src/server/runs.ts +46 -4
  58. package/packages/control-plane/src/shipper/discover.ts +32 -3
  59. package/packages/control-plane/src/shipper/ship.ts +15 -0
  60. package/packages/control-plane/src/shipper/watch.ts +66 -0
  61. package/packages/control-plane/ui/dist/assets/index-DHDI_dHM.css +1 -0
  62. package/packages/control-plane/ui/dist/assets/index-DaQr0MPS.js +129 -0
  63. package/packages/control-plane/ui/dist/index.html +3 -3
  64. package/plugins/loki-mode/.claude-plugin/plugin.json +1 -1
  65. package/web-app/dist/assets/{AdminPage-oAFMNfKH.js → AdminPage-BJSU5hY1.js} +1 -1
  66. package/web-app/dist/assets/{Avatar-BA2gnzyf.js → Avatar-CSOSpw9c.js} +1 -1
  67. package/web-app/dist/assets/{Badge-Dsluv8n1.js → Badge-COr7vj0D.js} +1 -1
  68. package/web-app/dist/assets/{Button-WmW8A-iG.js → Button-DGoWFERp.js} +1 -1
  69. package/web-app/dist/assets/{CockpitPage-BR5o_RgL.js → CockpitPage-CBpWkNXY.js} +1 -1
  70. package/web-app/dist/assets/{ComparePage-3Eg-pKvO.js → ComparePage-BwT8vqrL.js} +1 -1
  71. package/web-app/dist/assets/{ErrorBoundary-DAfrnnsQ.js → ErrorBoundary-D7SicDDA.js} +1 -1
  72. package/web-app/dist/assets/{EvidenceReceiptPanel-wLZI4OTr.js → EvidenceReceiptPanel-bx-5liG2.js} +1 -1
  73. package/web-app/dist/assets/{GitHubIssuesPanel-BOuadisf.js → GitHubIssuesPanel-BAeeWLqY.js} +1 -1
  74. package/web-app/dist/assets/{GitHubPRsPanel-BTVoVpB3.js → GitHubPRsPanel-CssD8lGI.js} +1 -1
  75. package/web-app/dist/assets/{HomePage-s3GWdNEe.js → HomePage-SL5PT3m5.js} +2 -2
  76. package/web-app/dist/assets/{LoginPage-BfOZ566t.js → LoginPage-C8hFOZOM.js} +1 -1
  77. package/web-app/dist/assets/{MagicPage-9EZ83xOt.js → MagicPage-xHbHkrPf.js} +1 -1
  78. package/web-app/dist/assets/{MetricsPage-OJMOYhHP.js → MetricsPage-B_2CKFcC.js} +1 -1
  79. package/web-app/dist/assets/{NotFoundPage-_LoJ6KEv.js → NotFoundPage-CXwXMq35.js} +1 -1
  80. package/web-app/dist/assets/{ProjectPage-Duj5LSt6.js → ProjectPage-AaisMQI-.js} +3 -3
  81. package/web-app/dist/assets/{ProjectsPage-BBud3JrO.js → ProjectsPage-Cej5ResX.js} +1 -1
  82. package/web-app/dist/assets/{SettingsPage-C4zdgKno.js → SettingsPage-DKv4mn9H.js} +1 -1
  83. package/web-app/dist/assets/{ShowcasePage-Nb3ACjMR.js → ShowcasePage-Dkiyzne4.js} +1 -1
  84. package/web-app/dist/assets/{SystemSettingsPage-BDi49TDh.js → SystemSettingsPage-DaBLQ5dU.js} +1 -1
  85. package/web-app/dist/assets/{TeamsPage-HkcqkjLa.js → TeamsPage-B2yUr31k.js} +1 -1
  86. package/web-app/dist/assets/{TemplatesPage-CK796Y1F.js → TemplatesPage-CfYiANhU.js} +1 -1
  87. package/web-app/dist/assets/{TerminalOutput-BkvdNuT6.js → TerminalOutput-CMXoexLM.js} +1 -1
  88. package/web-app/dist/assets/{activity-DFgMgETR.js → activity-B0dzoZtY.js} +1 -1
  89. package/web-app/dist/assets/{bell-BInGGOMZ.js → bell-DFOTaSoo.js} +1 -1
  90. package/web-app/dist/assets/{bot-D43Yjsj4.js → bot-Dz-WSQyP.js} +1 -1
  91. package/web-app/dist/assets/{check-DFAGTLM9.js → check-L1QIRueJ.js} +1 -1
  92. package/web-app/dist/assets/{chevron-left-Drwin7n1.js → chevron-left-1TrnIEZS.js} +1 -1
  93. package/web-app/dist/assets/{circle-alert-DLWdb7V9.js → circle-alert-8Bf6xEDx.js} +1 -1
  94. package/web-app/dist/assets/{clock-Dsbi59u5.js → clock-DB8NE6P5.js} +1 -1
  95. package/web-app/dist/assets/{cloud-CUU-dXTP.js → cloud-DUSL3wZS.js} +1 -1
  96. package/web-app/dist/assets/{code-xml-CqIh7Wui.js → code-xml-DbjryWNl.js} +1 -1
  97. package/web-app/dist/assets/{database-DASN5Bfh.js → database-DrezrMSs.js} +1 -1
  98. package/web-app/dist/assets/{dollar-sign-ZvXzAihc.js → dollar-sign-CGbaLMzR.js} +1 -1
  99. package/web-app/dist/assets/{file-code-corner-ED_gN-N8.js → file-code-corner-Dfypa-r4.js} +1 -1
  100. package/web-app/dist/assets/{file-plus-BmBHndXt.js → file-plus-CzV5L2Gd.js} +1 -1
  101. package/web-app/dist/assets/{globe-Ck_iEzO4.js → globe-BoYjTwPQ.js} +1 -1
  102. package/web-app/dist/assets/{hammer-B8YKMard.js → hammer-WO-HuiyJ.js} +1 -1
  103. package/web-app/dist/assets/{index-D7SxmjVQ.js → index-CjIOUjnd.js} +3 -3
  104. package/web-app/dist/assets/{layers-CGVsj3wB.js → layers-Bn6jtKNk.js} +1 -1
  105. package/web-app/dist/assets/{loader-circle-FIH7UJDF.js → loader-circle-gERWRnmW.js} +1 -1
  106. package/web-app/dist/assets/{lock-vR8HoAnK.js → lock-BIiMLJq8.js} +1 -1
  107. package/web-app/dist/assets/{package-mZ6LFwXp.js → package-dLvDSVys.js} +1 -1
  108. package/web-app/dist/assets/{plus-CtnSVu0w.js → plus-BHM3ZLhZ.js} +1 -1
  109. package/web-app/dist/assets/{refresh-cw-BIapF6Gj.js → refresh-cw-BAp_alv6.js} +1 -1
  110. package/web-app/dist/assets/{rotate-ccw-LMkSsm21.js → rotate-ccw-CBhwq_MG.js} +1 -1
  111. package/web-app/dist/assets/{scroll-text-DQVlIhr9.js → scroll-text-DoS-6dfU.js} +1 -1
  112. package/web-app/dist/assets/{server-GqyhQxmz.js → server-5JgdDWQO.js} +1 -1
  113. package/web-app/dist/assets/{shield-alert-BrFTNJ-I.js → shield-alert-DjmlLT34.js} +1 -1
  114. package/web-app/dist/assets/{thumbs-up-lgwAEJFW.js → thumbs-up-BtiEXl3p.js} +1 -1
  115. package/web-app/dist/assets/{trash-2-C1cNysK4.js → trash-2-ClsWU_Vg.js} +1 -1
  116. package/web-app/dist/assets/{trending-down-DQFZ6j5s.js → trending-down-DPS4M7_X.js} +1 -1
  117. package/web-app/dist/assets/{trending-up-BJG_Zo8V.js → trending-up-D6OJ38-V.js} +1 -1
  118. package/web-app/dist/assets/{upload-BOY1aTpA.js → upload-DOCzLUpF.js} +1 -1
  119. package/web-app/dist/assets/{usePolling-DoAmsTNy.js → usePolling-CljQTWj_.js} +1 -1
  120. package/web-app/dist/assets/{user-iOfCe0_v.js → user-C0G82Weu.js} +1 -1
  121. package/web-app/dist/index.html +1 -1
  122. package/packages/control-plane/ui/dist/assets/index-CFfrqCx2.css +0 -1
  123. package/packages/control-plane/ui/dist/assets/index-Jt31nrK-.js +0 -114
package/README.md CHANGED
@@ -86,7 +86,7 @@ loki "fix the login redirect loop" --no-pr
86
86
  loki owner/repo#123
87
87
  ```
88
88
 
89
- The v10 engine accepts a quoted multi-word task, a GitHub, GitLab or Jira issue reference, `status`, `verify`, `dashboard`, and other subcommands. Flags (`loki-ts/src/engine10/cli.ts` USAGE): `--no-pr` builds and verifies without opening a pull request, `--deep` requests the deep verify pass and a longer implement budget, `--provider <name>` picks the coding provider, `--max-cost <usd>` sets the per-run cost cap (default $20.00, or `budgets.per_run` in `loki.yaml`; the flag wins; the start line prints it as `cap $20.00 (default)`; reaching it ends the run BUDGET_STOP, exit 3) (`--json` and `--verbose` are also parsed by the supervisor, see [Quiet output](#quiet-output)). The engine needs Bun; without it the command exits 1 with a message and installation instructions.
89
+ The v10 engine accepts a quoted multi-word task, a GitHub, GitLab or Jira issue reference, `status`, `verify`, `dashboard`, and other subcommands. Flags (`loki-ts/src/engine10/cli.ts` USAGE): `--no-pr` builds and verifies without opening a pull request, `--deep` requests the deep verify pass and a longer implement budget, `--provider <name>` picks the coding provider, `--max-cost <usd>` sets the per-run cost cap (default $100.00 when an API key is set, no dollar cap on a subscription run where the start line adds `subscription: no dollar cap; usage counts against your plan limits`; or `budgets.per_run` in `loki.yaml`; the flag wins; the start line prints it as `cap $100.00 (default)`; reaching it ends the run BUDGET_STOP, exit 3) (`--json` and `--verbose` are also parsed by the supervisor, see [Quiet output](#quiet-output)). The engine needs Bun; without it the command exits 1 with a message and installation instructions.
90
90
 
91
91
  **Legacy engine (being removed).** The previous engine still ships in 10.6.6 and is reachable with `LOKI_ENGINE=legacy` or `loki legacy <args>`. `loki start owner/repo#N`, `loki start <issue URL>` and `loki start "<multi-word task>"` run on Loki 10, the same as `loki owner/repo#N` and `loki "<task>"`; `loki start ./prd.md`, a flag-first call and a one-word start stay on the legacy engine. For many issues use `loki backlog owner/repo --all|--label X|--issues N,N` (many issues, N in parallel, each on a `loki/backlog-N` worktree branch) instead. Legacy removal is planned and resumes on 2026-10-07; see [docs/v10/LEGACY-REMOVAL.md](docs/v10/LEGACY-REMOVAL.md). Sections below marked "legacy" describe features that run only on that engine.
92
92
 
@@ -205,7 +205,7 @@ A v10 run ends in exactly one outcome. The mapping is `EXIT` in `loki-ts/src/eng
205
205
  | 0 | ALREADY_SATISFIED | The work was already done; evidence is recorded. |
206
206
  | 1 | FAILED | A check failed, the stage failed, or the event log was modified outside the engine. |
207
207
  | 2 | (usage) | No task given, not inside a git repository, or a preflight refusal. Nothing ran. |
208
- | 3 | BUDGET_STOP | The cost cap (`--max-cost`, `budgets.per_run`, default $20.00) or the time cap fired before the work finished. |
208
+ | 3 | BUDGET_STOP | The cost cap (`--max-cost`, `budgets.per_run`; default $100.00 with an API key, none on a subscription) or the time cap fired before the work finished. |
209
209
  | 4 | BLOCKED | Spec conflict: the contract cannot be satisfied as written. The summary names the conflict (`spec conflict: <reason>`); the run never guesses and calls it done. |
210
210
  | 5 | STALLED | The same failures three verifies in a row. |
211
211
 
@@ -303,6 +303,7 @@ A local control plane and UI for many runs, on by default. Turn it off with `LOK
303
303
  loki control serve # 127.0.0.1, default port 47821 (--port N, --db PATH)
304
304
  loki control backfill . # ship ./.loki/runs to the control plane
305
305
  loki control status # reachable? how many runs held?
306
+ loki control prune --repo acme/widget # remove those runs from the local control DB (--before DATE, --dry-run)
306
307
  ```
307
308
 
308
309
  The URL comes from `LOKI_CONTROL_URL`, else `http://127.0.0.1:${LOKI_CONTROL_PORT:-47821}`. While `loki control serve` is running, runs on this machine ship to it automatically (it publishes `~/.loki/control/instance.json`, mode 0600, removed on exit); a run never starts the server. Set `LOKI_CONTROL_URL` to ship elsewhere. See [docs/v10/CONTROL-PLANE.md](docs/v10/CONTROL-PLANE.md).
@@ -498,9 +499,12 @@ If you do not have Bun yet:
498
499
  curl -fsSL https://bun.sh/install | bash # macOS / Linux (or: brew install oven-sh/bun/bun)
499
500
  ```
500
501
 
501
- Docker without installing loki locally: `loki docker start prd.md` runs it in the
502
- published image with zero config, bind-mounting the current folder so `.loki`
503
- state and resume work exactly like local. See [DOCKER_README.md](DOCKER_README.md).
502
+ Docker without installing loki locally: `loki docker start owner/repo#N` (or
503
+ `loki docker start prd.md`) runs it in the published image with zero config,
504
+ bind-mounting the current folder so `.loki` state and resume work exactly like
505
+ local. On macOS, pass your GitHub login for PRs:
506
+ `GH_TOKEN=$(gh auth token) loki docker start owner/repo#17`.
507
+ See [DOCKER_README.md](DOCKER_README.md).
504
508
 
505
509
  Upgrading: `loki self-update` auto-detects which package manager installed loki
506
510
  and runs the right upgrade. `loki self-update --to bun` migrates an npm install
package/SKILL.md CHANGED
@@ -3,7 +3,7 @@ name: loki-mode
3
3
  description: An autonomous software factory that knows what it is supposed to deliver, and proves it did. Use when the user says Loki Mode or asks to build, fix or verify software autonomously.
4
4
  ---
5
5
 
6
- # Loki Mode v10.6.14
6
+ # Loki Mode v10.7.1
7
7
 
8
8
  **Current entry points (Loki 10 engine):** `loki "<task>"`, `loki owner/repo#N` and `loki quick "<task>"` run the Loki 10 engine and end with a signed receipt (`loki verify`). `loki backlog owner/repo --all` runs many issues in parallel. Guide: `docs/v10/GUIDE.md`. The skill instructions below, and `loki start`, describe the legacy engine, which still ships and is being removed (`loki start owner/repo#N` still routes to it; prefer `loki owner/repo#N`).
9
9
 
@@ -473,4 +473,4 @@ See `CHANGELOG.md` entries [7.5.7], [7.5.8], [7.5.13] for the per-fix list and r
473
473
 
474
474
  ---
475
475
 
476
- **v10.6.14 | [Autonomi](https://www.autonomi.dev/) flagship product | core skill**
476
+ **v10.7.1 | [Autonomi](https://www.autonomi.dev/) flagship product | core skill**
package/VERSION CHANGED
@@ -1 +1 @@
1
- 10.6.14
1
+ 10.7.1
@@ -0,0 +1,128 @@
1
+ #!/usr/bin/env python3
2
+ """Loki Mode as an ACP (Agent Client Protocol) agent over stdio.
3
+
4
+ Newline-delimited JSON-RPC 2.0. stdout carries protocol frames only; all
5
+ diagnostics go to stderr. Each session/prompt runs `loki quick "<prompt>"` in
6
+ the session cwd and streams its output as agent_message_chunk updates. A
7
+ non-zero exit is reported in the text as NOT VERIFIED, never as success.
8
+ """
9
+ import json
10
+ import os
11
+ import subprocess
12
+ import sys
13
+ import threading
14
+
15
+ PROTOCOL_VERSION = 1
16
+ LOKI_CMD = os.environ.get("LOKI_ACP_LOKI") or "loki"
17
+
18
+ out_lock = threading.Lock()
19
+ sessions = {} # sessionId -> {"cwd": str, "proc": Popen|None, "cancelled": bool}
20
+
21
+
22
+ def send(obj):
23
+ with out_lock:
24
+ sys.stdout.write(json.dumps(obj) + "\n")
25
+ sys.stdout.flush()
26
+
27
+
28
+ def reply(rid, result):
29
+ send({"jsonrpc": "2.0", "id": rid, "result": result})
30
+
31
+
32
+ def error(rid, code, msg):
33
+ send({"jsonrpc": "2.0", "id": rid, "error": {"code": code, "message": msg}})
34
+
35
+
36
+ def update(sid, text):
37
+ send({"jsonrpc": "2.0", "method": "session/update", "params": {
38
+ "sessionId": sid,
39
+ "update": {"sessionUpdate": "agent_message_chunk",
40
+ "content": {"type": "text", "text": text}}}})
41
+
42
+
43
+ def prompt_text(blocks):
44
+ parts = []
45
+ for b in blocks or []:
46
+ if b.get("type") == "text":
47
+ parts.append(b.get("text", ""))
48
+ elif b.get("type") == "resource_link":
49
+ parts.append(b.get("uri", ""))
50
+ elif b.get("type") == "resource":
51
+ r = b.get("resource", {})
52
+ parts.append(r.get("text") or r.get("uri", ""))
53
+ return "\n".join(p for p in parts if p).strip()
54
+
55
+
56
+ def run_prompt(rid, sid, text):
57
+ s = sessions[sid]
58
+ s["cancelled"] = False
59
+ if not text:
60
+ reply(rid, {"stopReason": "end_turn"})
61
+ return
62
+ env = dict(os.environ, LOKI_NO_BROWSER="1")
63
+ try:
64
+ proc = subprocess.Popen([LOKI_CMD, "quick", text], cwd=s["cwd"], env=env,
65
+ stdin=subprocess.DEVNULL, stdout=subprocess.PIPE,
66
+ stderr=subprocess.STDOUT, text=True, bufsize=1)
67
+ except OSError as exc:
68
+ update(sid, "Could not start loki: %s\nNOT VERIFIED: nothing ran.\n" % exc)
69
+ reply(rid, {"stopReason": "end_turn"})
70
+ return
71
+ s["proc"] = proc
72
+ for line in proc.stdout:
73
+ update(sid, line)
74
+ code = proc.wait()
75
+ s["proc"] = None
76
+ if s["cancelled"]:
77
+ reply(rid, {"stopReason": "cancelled"})
78
+ return
79
+ if code != 0:
80
+ update(sid, "\nloki exited with code %d. This run is NOT VERIFIED.\n" % code)
81
+ reply(rid, {"stopReason": "end_turn"})
82
+
83
+
84
+ def handle(msg):
85
+ method, rid, params = msg.get("method"), msg.get("id"), msg.get("params") or {}
86
+ if method == "initialize":
87
+ reply(rid, {"protocolVersion": PROTOCOL_VERSION,
88
+ "agentCapabilities": {"loadSession": False,
89
+ "promptCapabilities": {"embeddedContext": True}},
90
+ "agentInfo": {"name": "loki", "title": "Loki Mode"},
91
+ "authMethods": []})
92
+ elif method == "session/new":
93
+ sid = "loki-%d-%d" % (os.getpid(), len(sessions) + 1)
94
+ sessions[sid] = {"cwd": params.get("cwd") or os.getcwd(), "proc": None, "cancelled": False}
95
+ reply(rid, {"sessionId": sid})
96
+ elif method == "session/prompt":
97
+ sid = params.get("sessionId")
98
+ if sid not in sessions:
99
+ error(rid, -32602, "unknown session")
100
+ return
101
+ threading.Thread(target=run_prompt, daemon=True,
102
+ args=(rid, sid, prompt_text(params.get("prompt")))).start()
103
+ elif method == "session/cancel":
104
+ s = sessions.get(params.get("sessionId"))
105
+ if s and s["proc"]:
106
+ s["cancelled"] = True
107
+ s["proc"].terminate()
108
+ elif rid is not None:
109
+ error(rid, -32601, "method not found: %s" % method)
110
+
111
+
112
+ def main():
113
+ for line in sys.stdin:
114
+ line = line.strip()
115
+ if not line:
116
+ continue
117
+ try:
118
+ handle(json.loads(line))
119
+ except json.JSONDecodeError:
120
+ error(None, -32700, "parse error")
121
+ # Let in-flight prompts finish their replies before exiting.
122
+ for t in threading.enumerate():
123
+ if t is not threading.current_thread():
124
+ t.join(timeout=600)
125
+
126
+
127
+ if __name__ == "__main__":
128
+ main()
@@ -187,6 +187,14 @@ loki_docker_build_argv() {
187
187
  [ -n "${GH_TOKEN:-}" ] && argv+=(-e "GH_TOKEN=${GH_TOKEN}")
188
188
  # No desktop notifications inside a container.
189
189
  argv+=(-e "LOKI_NOTIFICATIONS=false")
190
+ # The container runs as uid 1000 but a macOS Docker Desktop bind mount can
191
+ # look root-owned, so the repo tool refuses /workspace ("dubious ownership").
192
+ # Trust it via env config, appending after any caller-set GIT_CONFIG_COUNT=N.
193
+ local _gc_n="${GIT_CONFIG_COUNT:-0}"
194
+ case "$_gc_n" in ''|*[!0-9]*) _gc_n=0 ;; esac
195
+ argv+=(-e "GIT_CONFIG_COUNT=$((_gc_n + 1))"
196
+ -e "GIT_CONFIG_KEY_${_gc_n}=safe.directory"
197
+ -e "GIT_CONFIG_VALUE_${_gc_n}=/workspace")
190
198
  # Multi-repo unified dashboard (Option B): the in-container run.sh must NOT
191
199
  # register into the project registry. A container only sees /workspace, not
192
200
  # the real host path, and registry.register_project() hard-fails on a path
@@ -136,7 +136,7 @@ extract_problem_statement() {
136
136
 
137
137
  # Try to find explicit problem/description section
138
138
  # Match: ## Problem, ### Problem, **Problem**, Problem:
139
- problem=$(echo "$body" | sed -n '/^#*\s*\*\{0,2\}[Pp]roblem\|^#*\s*\*\{0,2\}[Dd]escription\|^#*\s*\*\{0,2\}[Ss]ummary\|^#*\s*\*\{0,2\}[Bb]ackground/,/^#\|^---/p' | head -30 | tail -n +2)
139
+ problem=$(echo "$body" | sed -n -E '/^#*[[:space:]]*\*{0,2}([Pp]roblem|[Dd]escription|[Ss]ummary|[Bb]ackground)/,/^(#|---)/p' | head -30 | tail -n +2)
140
140
 
141
141
  # If no explicit section, use first paragraph
142
142
  if [ -z "$problem" ]; then
@@ -156,16 +156,16 @@ extract_acceptance_criteria() {
156
156
  local criteria=""
157
157
 
158
158
  # Look for Acceptance Criteria section
159
- criteria=$(echo "$body" | sed -n '/^#*\s*\*\{0,2\}[Aa]cceptance [Cc]riteria\|^#*\s*\*\{0,2\}[Rr]equirements\|^#*\s*\*\{0,2\}[Dd]efinition of [Dd]one/,/^#\|^---/p' | head -30)
159
+ criteria=$(echo "$body" | sed -n -E '/^#*[[:space:]]*\*{0,2}([Aa]cceptance [Cc]riteria|[Rr]equirements|[Dd]efinition of [Dd]one)/,/^(#|---)/p' | head -30)
160
160
 
161
161
  # If no section found, extract all checkboxes
162
162
  if [ -z "$criteria" ]; then
163
- criteria=$(echo "$body" | grep -E '^\s*[-*]\s*\[[ xX]\]' | head -20)
163
+ criteria=$(echo "$body" | grep -E '^[[:space:]]*[-*][[:space:]]*\[[ xX]\]' | head -20)
164
164
  fi
165
165
 
166
166
  # If still nothing, look for numbered or bulleted lists after "should" or "must"
167
167
  if [ -z "$criteria" ]; then
168
- criteria=$(echo "$body" | grep -E '^\s*[-*0-9.]+\s+.*(should|must|needs to|required)' | head -20)
168
+ criteria=$(echo "$body" | grep -E '^[[:space:]]*[-*0-9.]+[[:space:]]+.*(should|must|needs to|required)' | head -20)
169
169
  fi
170
170
 
171
171
  echo "$criteria"
@@ -178,7 +178,7 @@ extract_technical_requirements() {
178
178
  local technical=""
179
179
 
180
180
  # Look for Technical/Implementation section
181
- technical=$(echo "$body" | sed -n '/^#*\s*\*\{0,2\}[Tt]echnical\|^#*\s*\*\{0,2\}[Ii]mplementation\|^#*\s*\*\{0,2\}[Aa]rchitecture\|^#*\s*\*\{0,2\}[Tt]ech [Ss]pec/,/^#\|^---/p' | head -40)
181
+ technical=$(echo "$body" | sed -n -E '/^#*[[:space:]]*\*{0,2}([Tt]echnical|[Ii]mplementation|[Aa]rchitecture|[Tt]ech [Ss]pec)/,/^(#|---)/p' | head -40)
182
182
 
183
183
  # If no section, look for code-related mentions
184
184
  if [ -z "$technical" ]; then
@@ -544,7 +544,7 @@ prd:
544
544
  $(yaml_escape "$problem")
545
545
 
546
546
  goals:
547
- $(echo "$acceptance" | grep -E '^\s*[-*]\s*\[' | sed 's/^\s*[-*]\s*\[.\]/ -/' | head -10 || echo " - Complete implementation as described")
547
+ $(echo "$acceptance" | grep -E '^[[:space:]]*[-*][[:space:]]*\[' | sed 's/^[[:space:]]*[-*][[:space:]]*\[.\]/ -/' | head -10 || echo " - Complete implementation as described")
548
548
 
549
549
  scope:
550
550
  in_scope:
@@ -554,7 +554,7 @@ $(echo "$technical" | head -5 | sed 's/^/ - /' || true)
554
554
  - Changes unrelated to this issue
555
555
 
556
556
  success_criteria:
557
- $(echo "$acceptance" | grep -E '^\s*[-*]\s*\[' | sed 's/^\s*[-*]\s*\[.\]/ -/' | head -10 || echo " - Issue requirements satisfied")
557
+ $(echo "$acceptance" | grep -E '^[[:space:]]*[-*][[:space:]]*\[' | sed 's/^[[:space:]]*[-*][[:space:]]*\[.\]/ -/' | head -10 || echo " - Issue requirements satisfied")
558
558
  - All tests passing
559
559
  - Code review approved
560
560
 
@@ -35,7 +35,9 @@ ISSUE_PROVIDERS=("github" "gitlab" "jira" "azure_devops")
35
35
  detect_issue_provider() {
36
36
  local ref="$1"
37
37
 
38
- if [[ "$ref" =~ github\.com ]]; then
38
+ if sentry_ref_is_sentry "$ref"; then
39
+ echo "sentry"
40
+ elif [[ "$ref" =~ github\.com ]]; then
39
41
  echo "github"
40
42
  elif [[ "$ref" =~ gitlab\.com ]] || [[ "$ref" =~ gitlab\. ]]; then
41
43
  echo "gitlab"
@@ -57,6 +59,150 @@ detect_issue_provider() {
57
59
  fi
58
60
  }
59
61
 
62
+ # Sentry intake. A ref is Sentry when it is an https Sentry issue URL
63
+ # (sentry.io, <org>.sentry.io or a self-hosted .../issues/<id>/ with "sentry" in
64
+ # the host) or an exported Sentry JSON file on disk (works fully offline).
65
+ sentry_file_looks_like_export() {
66
+ local f="$1"
67
+ [ -f "$f" ] || return 1
68
+ case "$f" in *.json) ;; *) return 1 ;; esac
69
+ head -c 2000000 "$f" 2>/dev/null | grep -Eq '"(shortId|culprit)"[[:space:]]*:|"exception"[[:space:]]*:|"type"[[:space:]]*:[[:space:]]*"exception"'
70
+ }
71
+
72
+ sentry_ref_is_sentry() {
73
+ local ref="$1"
74
+ if [[ "$ref" =~ ^https://[^/]*sentry[^/]*/.*issues/[0-9]+ ]]; then
75
+ return 0
76
+ fi
77
+ sentry_file_looks_like_export "$ref"
78
+ }
79
+
80
+ # Normalize a Sentry issue/event JSON (stdin) to the common issue shape.
81
+ # Accepts an issue export, an event, {issue:..., event:...}, or an SDK payload.
82
+ # Env: _LOKI_SENTRY_URL (optional permalink fallback).
83
+ normalize_sentry_json() {
84
+ python3 -c "
85
+ import json, os, sys
86
+ raw = json.loads(sys.stdin.read())
87
+ if isinstance(raw, list) and raw:
88
+ raw = raw[0]
89
+ issue = raw.get('issue') if isinstance(raw.get('issue'), dict) else raw
90
+ ev = None
91
+ for k in ('latestEvent', 'event'):
92
+ if isinstance(raw.get(k), dict):
93
+ ev = raw[k]
94
+ break
95
+ if ev is None:
96
+ ev = raw
97
+ entries = {}
98
+ for e in ev.get('entries') or []:
99
+ if isinstance(e, dict) and e.get('type'):
100
+ entries[e['type']] = e.get('data') or {}
101
+ exc = entries.get('exception') or ev.get('exception') or {}
102
+ crumbs = entries.get('breadcrumbs') or ev.get('breadcrumbs') or {}
103
+ values = exc.get('values') or []
104
+ meta = issue.get('metadata') or {}
105
+ etype = (values[-1].get('type') if values else '') or meta.get('type') or ''
106
+ evalue = (values[-1].get('value') if values else '') or meta.get('value') or ''
107
+ title = issue.get('title') or ev.get('title') or (etype + ': ' + evalue).strip(': ') or 'Sentry issue'
108
+ number = str(issue.get('shortId') or issue.get('id') or ev.get('eventID') or ev.get('id') or 'sentry')
109
+ url = issue.get('permalink') or os.environ.get('_LOKI_SENTRY_URL', '')
110
+ lines = []
111
+ lines.append('## Error')
112
+ lines.append('')
113
+ lines.append('- Type: ' + (etype or 'unknown'))
114
+ lines.append('- Message: ' + (evalue or title))
115
+ if issue.get('culprit'):
116
+ lines.append('- Culprit: ' + str(issue['culprit']))
117
+ for k, label in (('level', 'Level'), ('status', 'Status'), ('count', 'Events'), ('firstSeen', 'First seen'), ('lastSeen', 'Last seen')):
118
+ if issue.get(k) not in (None, ''):
119
+ lines.append('- ' + label + ': ' + str(issue[k]))
120
+ lines.append('')
121
+ lines.append('## Stack trace')
122
+ lines.append('')
123
+ n_frames = 0
124
+ for v in values:
125
+ frames = (v.get('stacktrace') or {}).get('frames') or []
126
+ lines.append(str(v.get('type', '')) + ': ' + str(v.get('value', '')))
127
+ for f in reversed(frames[-30:]):
128
+ where = str(f.get('filename') or f.get('absPath') or f.get('module') or '?')
129
+ if f.get('lineNo') is not None:
130
+ where += ':' + str(f['lineNo'])
131
+ mark = '' if f.get('inApp', True) else ' (library)'
132
+ lines.append(' at ' + str(f.get('function') or '?') + ' (' + where + ')' + mark)
133
+ for ln in (f.get('context') or []):
134
+ if isinstance(ln, list) and len(ln) == 2 and ln[0] == f.get('lineNo'):
135
+ lines.append(' > ' + str(ln[1]).strip())
136
+ n_frames += 1
137
+ if n_frames == 0:
138
+ lines.append('(no stack trace in the export)')
139
+ lines.append('')
140
+ lines.append('## Breadcrumbs (oldest first, last 20)')
141
+ lines.append('')
142
+ cv = (crumbs.get('values') or [])[-20:]
143
+ for c in cv:
144
+ msg = c.get('message') or json.dumps(c.get('data') or {}, sort_keys=True)[:200]
145
+ lines.append('- ' + str(c.get('timestamp', '')) + ' [' + str(c.get('category', '')) + '/' + str(c.get('level', '')) + '] ' + str(msg))
146
+ if not cv:
147
+ lines.append('(no breadcrumbs in the export)')
148
+ lines.append('')
149
+ lines.append('## Acceptance criteria')
150
+ lines.append('')
151
+ lines.append('- [ ] The error above no longer occurs for the triggering input')
152
+ lines.append('- [ ] A regression test reproduces the failure and passes after the fix')
153
+ tags = [str(t.get('key')) + ':' + str(t.get('value')) for t in (ev.get('tags') or []) if isinstance(t, dict)][:10]
154
+ proj = issue.get('project')
155
+ slug = proj.get('slug', '') if isinstance(proj, dict) else ''
156
+ lbl = [x for x in [str(issue.get('level') or ''), str(slug)] if x] + tags
157
+ print(json.dumps({
158
+ 'provider': 'sentry', 'number': number, 'title': title,
159
+ 'body': chr(10).join(lines), 'labels': lbl, 'author': '',
160
+ 'url': url, 'created_at': str(issue.get('firstSeen') or ev.get('dateCreated') or ''),
161
+ 'repo': ''
162
+ }))
163
+ "
164
+ }
165
+
166
+ # Sentry API fetch (only when SENTRY_AUTH_TOKEN is set). The token travels in a
167
+ # curl config on stdin, never on argv, and is never echoed.
168
+ fetch_sentry_url() {
169
+ local ref="$1" host id base
170
+ if [ -z "${SENTRY_AUTH_TOKEN:-}" ]; then
171
+ echo -e "${RED}Error: SENTRY_AUTH_TOKEN is not set, so the Sentry API is not contacted.${NC}" >&2
172
+ echo "Export the issue as JSON and run: loki start ./issue.json" >&2
173
+ return 1
174
+ fi
175
+ if [[ "$ref" =~ ^https://([^/]+)/.*issues/([0-9]+) ]]; then
176
+ host="${BASH_REMATCH[1]}"; id="${BASH_REMATCH[2]}"
177
+ else
178
+ echo -e "${RED}Error: not a Sentry issue URL${NC}" >&2
179
+ return 1
180
+ fi
181
+ case "$host" in *.sentry.io) host="sentry.io" ;; esac
182
+ base="https://${host}/api/0/issues/${id}"
183
+ local issue ev
184
+ issue=$(printf 'header = "Authorization: Bearer %s"\n' "$SENTRY_AUTH_TOKEN" \
185
+ | curl -sS --fail --max-time 30 -K - "${base}/" 2>/dev/null) || {
186
+ echo -e "${RED}Error fetching Sentry issue ${id} (check SENTRY_AUTH_TOKEN scope and URL)${NC}" >&2
187
+ return 1
188
+ }
189
+ ev=$(printf 'header = "Authorization: Bearer %s"\n' "$SENTRY_AUTH_TOKEN" \
190
+ | curl -sS --fail --max-time 30 -K - "${base}/events/latest/" 2>/dev/null) || ev='{}'
191
+ printf '{"issue":%s,"event":%s}' "$issue" "$ev" | _LOKI_SENTRY_URL="$ref" normalize_sentry_json
192
+ }
193
+
194
+ fetch_sentry_issue() {
195
+ local ref="$ISSUE_SENTRY_SRC"
196
+ if [ -f "$ref" ]; then
197
+ normalize_sentry_json < "$ref" || {
198
+ echo -e "${RED}Error: could not parse Sentry export: $ref${NC}" >&2
199
+ return 1
200
+ }
201
+ else
202
+ fetch_sentry_url "$ref"
203
+ fi
204
+ }
205
+
60
206
  # Check if required CLI tools are available for a provider
61
207
  check_issue_provider_cli() {
62
208
  local provider="$1"
@@ -72,6 +218,9 @@ check_issue_provider_cli() {
72
218
  return 1
73
219
  fi
74
220
  ;;
221
+ sentry)
222
+ # Offline from an exported JSON; the API path checks its own token.
223
+ ;;
75
224
  gitlab)
76
225
  if ! command -v glab &>/dev/null; then
77
226
  echo -e "${RED}Error: glab CLI not found. Install with: brew install glab${NC}" >&2
@@ -118,6 +267,14 @@ parse_issue_reference() {
118
267
  ISSUE_ORG=""
119
268
 
120
269
  case "$ISSUE_PROVIDER" in
270
+ sentry)
271
+ ISSUE_SENTRY_SRC="$ref"
272
+ if [[ "$ref" =~ issues/([0-9]+) ]]; then
273
+ ISSUE_NUMBER="${BASH_REMATCH[1]}"
274
+ else
275
+ ISSUE_NUMBER="$(basename "$ref" .json)"
276
+ fi
277
+ ;;
121
278
  github)
122
279
  if [[ "$ref" =~ ^https?://github\.com/([^/]+)/([^/]+)/issues/([0-9]+) ]]; then
123
280
  ISSUE_OWNER="${BASH_REMATCH[1]}"
@@ -424,6 +581,7 @@ fetch_issue() {
424
581
  check_issue_provider_cli "$ISSUE_PROVIDER" || return 1
425
582
 
426
583
  case "$ISSUE_PROVIDER" in
584
+ sentry) fetch_sentry_issue ;;
427
585
  github) fetch_github_issue ;;
428
586
  gitlab) fetch_gitlab_issue ;;
429
587
  jira) fetch_jira_issue ;;
@@ -34,7 +34,10 @@ _e10_region="$(awk '
34
34
  END { if (!done) exit 3 }
35
35
  ' "$_e10_run_sh")" || die "run.sh trusted-push anchors not found; refusing to push" 3
36
36
  for _e10_fn in _loki_trusted_push _loki_with_github_tokens _loki_run_neutral _loki_github_repo_from_url; do
37
- printf '%s\n' "$_e10_region" | grep -q "^${_e10_fn}() {\$" \
37
+ # No pipe into grep -q: under pipefail, grep exiting on its first match
38
+ # SIGPIPEs the still-writing printf (rc 141), which read as "missing"
39
+ # intermittently under CPU load and silently skipped the push.
40
+ [[ $'\n'"$_e10_region"$'\n' == *$'\n'"${_e10_fn}() {"$'\n'* ]] \
38
41
  || die "run.sh region lacks ${_e10_fn}; refusing to push" 3
39
42
  done
40
43
  eval "$_e10_region" || die "run.sh region failed to load" 3
@@ -0,0 +1,91 @@
1
+ #!/usr/bin/env python3
2
+ """Deterministic diff risk score (0-100) for `loki review --risk`.
3
+
4
+ Reads a unified diff on stdin. Pure function of the diff text: no clock, no
5
+ network, no randomness. Same diff in, same score out.
6
+ """
7
+ import json
8
+ import re
9
+ import sys
10
+
11
+ SENSITIVE = re.compile(
12
+ r"(^|/)(\.github/workflows/|auth|security|secret|crypt|token|credential|password|"
13
+ r"migrations?/|Dockerfile|docker-compose|\.env|package\.json$|requirements.*\.txt$|"
14
+ r"VERSION$|autonomy/run\.sh$|providers/|moat/)", re.I)
15
+ TESTISH = re.compile(r"(^|/)(tests?|__tests__|spec)/|(^|/)test[-_][^/]*$|[._-](test|spec)\.[a-z]+$", re.I)
16
+
17
+
18
+ def parse(diff):
19
+ files = {}
20
+ cur = None
21
+ for line in diff.splitlines():
22
+ if line.startswith("diff --git "):
23
+ m = re.match(r"diff --git a/(.*) b/(.*)$", line)
24
+ cur = m.group(2) if m else line[11:]
25
+ files[cur] = {"add": 0, "del": 0, "deleted_file": False}
26
+ elif cur is None:
27
+ continue
28
+ elif line.startswith("deleted file mode"):
29
+ files[cur]["deleted_file"] = True
30
+ elif line.startswith("+++") or line.startswith("---"):
31
+ continue
32
+ elif line.startswith("+"):
33
+ files[cur]["add"] += 1
34
+ elif line.startswith("-"):
35
+ files[cur]["del"] += 1
36
+ return files
37
+
38
+
39
+ def score(diff):
40
+ files = parse(diff)
41
+ names = sorted(files)
42
+ test_files = [f for f in names if TESTISH.search(f)]
43
+ src_files = [f for f in names if f not in test_files]
44
+ sens = [f for f in names if SENSITIVE.search(f)]
45
+ total = sum(v["add"] + v["del"] for v in files.values())
46
+ src_lines = sum(files[f]["add"] + files[f]["del"] for f in src_files)
47
+ test_add = sum(files[f]["add"] for f in test_files)
48
+ test_del = sum(files[f]["del"] for f in test_files)
49
+ removed_test_files = [f for f in test_files if files[f]["deleted_file"]]
50
+
51
+ f_files = min(20, len(names) * 2)
52
+ f_sens = min(30, len(sens) * 10)
53
+ f_size = min(20, total // 25)
54
+ if src_lines == 0:
55
+ f_tests = 0
56
+ elif test_add == 0:
57
+ f_tests = 15
58
+ else:
59
+ f_tests = max(0, 15 - (15 * test_add) // src_lines)
60
+ f_deleted = min(15, test_del // 5 + 5 * len(removed_test_files))
61
+ factors = [
62
+ {"factor": "files_touched", "points": f_files, "max": 20, "detail": "%d files" % len(names)},
63
+ {"factor": "sensitive_paths", "points": f_sens, "max": 30, "detail": ", ".join(sens) or "none"},
64
+ {"factor": "size", "points": f_size, "max": 20, "detail": "%d changed lines" % total},
65
+ {"factor": "test_delta", "points": f_tests, "max": 15,
66
+ "detail": "%d test lines added vs %d source lines changed" % (test_add, src_lines)},
67
+ {"factor": "deleted_tests", "points": f_deleted, "max": 15,
68
+ "detail": "%d test lines removed, %d test files deleted" % (test_del, len(removed_test_files))},
69
+ ]
70
+ s = min(100, sum(x["points"] for x in factors))
71
+ level = "low" if s < 25 else "medium" if s < 50 else "high" if s < 75 else "critical"
72
+ return {"score": s, "level": level, "files": len(names), "factors": factors}
73
+
74
+
75
+ def main():
76
+ args = sys.argv[1:]
77
+ as_json = "--json" in args
78
+ rest = [a for a in args if a != "--json"]
79
+ src = rest[0] if rest else ""
80
+ r = score(sys.stdin.read())
81
+ r["source"] = src
82
+ if as_json:
83
+ print(json.dumps(r, sort_keys=True))
84
+ return
85
+ print("Risk score: %d/100 (%s)%s" % (r["score"], r["level"], " - " + src if src else ""))
86
+ for x in r["factors"]:
87
+ print(" %-16s %2d/%-2d %s" % (x["factor"], x["points"], x["max"], x["detail"]))
88
+
89
+
90
+ if __name__ == "__main__":
91
+ main()