loki-mode 10.6.13 → 10.7.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 (110) hide show
  1. package/README.md +8 -5
  2. package/SKILL.md +3 -3
  3. package/VERSION +1 -1
  4. package/autonomy/acp-agent.py +128 -0
  5. package/autonomy/completion-council.sh +3 -3
  6. package/autonomy/docker-run.sh +8 -0
  7. package/autonomy/issue-parser.sh +8 -7
  8. package/autonomy/issue-providers.sh +159 -1
  9. package/autonomy/lib/engine10-push.sh +4 -1
  10. package/autonomy/lib/review_risk.py +91 -0
  11. package/autonomy/loki +225 -31
  12. package/autonomy/mobile-verify.sh +66 -0
  13. package/autonomy/quickstart.sh +44 -4
  14. package/autonomy/run.sh +44 -2
  15. package/completions/_loki +2 -0
  16. package/completions/loki.bash +1 -1
  17. package/dashboard/__init__.py +1 -1
  18. package/dashboard/api_runs_v1.py +80 -0
  19. package/dashboard/server.py +2 -0
  20. package/docs/COMPETITOR-DEPLOYMENT-MODELS.md +3 -3
  21. package/docs/INSTALLATION.md +5 -1
  22. package/docs/acp-editors.md +29 -0
  23. package/docs/api-runs-v1.md +12 -0
  24. package/docs/architecture/ADR-001-runtime-migration.md +13 -13
  25. package/docs/dev/architecture-reference.md +6 -2
  26. package/docs/merge-queue.md +13 -0
  27. package/docs/mobile-verify.md +11 -0
  28. package/docs/sentry-intake.md +13 -0
  29. package/docs/v10/BOARD.md +66 -12
  30. package/docs/v10/CP-ENTERPRISE-UI.md +276 -0
  31. package/docs/v10/D61-SPEED.md +4 -0
  32. package/docs/v10/DECISIONS.md +85 -0
  33. package/docs/v10/FOUNDER-QUEUE.md +1 -0
  34. package/docs/v10/GAP-RESEARCH-1003.md +32 -0
  35. package/docs/v10/MORNING-BRIEF-1003.md +39 -0
  36. package/docs/v10/PROGRESS.md +107 -0
  37. package/docs/v10/SWARM.md +32 -0
  38. package/docs/visual-evidence.md +5 -0
  39. package/loki-ts/dist/loki.js +766 -624
  40. package/mcp/__init__.py +1 -1
  41. package/package.json +1 -1
  42. package/packages/control-plane/dist/server.js +263 -9
  43. package/packages/control-plane/src/server/actions.ts +60 -0
  44. package/packages/control-plane/src/server/app.ts +39 -3
  45. package/packages/control-plane/src/shipper/watch.ts +64 -0
  46. package/packages/control-plane/ui/dist/assets/index-CUQ65vJY.css +1 -0
  47. package/packages/control-plane/ui/dist/assets/index-CwwGfp6E.js +129 -0
  48. package/packages/control-plane/ui/dist/index.html +3 -3
  49. package/plugins/loki-mode/.claude-plugin/plugin.json +1 -1
  50. package/skills/healing.md +2 -2
  51. package/tools/proof-passport.py +1 -1
  52. package/web-app/dist/assets/{AdminPage-CVEdde3t.js → AdminPage-BQPb5rc5.js} +1 -1
  53. package/web-app/dist/assets/{Avatar-D1r8n_Da.js → Avatar-Cttco6FC.js} +1 -1
  54. package/web-app/dist/assets/{Badge-BAPyJ97M.js → Badge-CPb6kpbH.js} +1 -1
  55. package/web-app/dist/assets/{Button-C-GCctTq.js → Button-caTL28a4.js} +1 -1
  56. package/web-app/dist/assets/{CockpitPage-CV9MhyuV.js → CockpitPage-bEbLx6ph.js} +1 -1
  57. package/web-app/dist/assets/{ComparePage-D1R5Lxmy.js → ComparePage-DNtJSij-.js} +1 -1
  58. package/web-app/dist/assets/{ErrorBoundary-CFvJx4vV.js → ErrorBoundary-BrgC3K44.js} +1 -1
  59. package/web-app/dist/assets/{EvidenceReceiptPanel-9I7dfPz0.js → EvidenceReceiptPanel-Q-mSrUUQ.js} +1 -1
  60. package/web-app/dist/assets/{GitHubIssuesPanel-8YH6WkTh.js → GitHubIssuesPanel-DbmYl_Er.js} +1 -1
  61. package/web-app/dist/assets/{GitHubPRsPanel-BZvEHBTF.js → GitHubPRsPanel-C6PCHM4B.js} +1 -1
  62. package/web-app/dist/assets/{HomePage-DhjWTo8R.js → HomePage-7zsL_k_e.js} +2 -2
  63. package/web-app/dist/assets/{LoginPage-BwNGyWxP.js → LoginPage-Cl4ON8RQ.js} +1 -1
  64. package/web-app/dist/assets/{MagicPage-Dg9z7deq.js → MagicPage-BNEAhTqH.js} +1 -1
  65. package/web-app/dist/assets/{MetricsPage-DLF1F6XC.js → MetricsPage-DyBH-88q.js} +1 -1
  66. package/web-app/dist/assets/{NotFoundPage-CZClMI3s.js → NotFoundPage-CIVr21yL.js} +1 -1
  67. package/web-app/dist/assets/{ProjectPage-CnAvyHDJ.js → ProjectPage-BMeVYocF.js} +3 -3
  68. package/web-app/dist/assets/{ProjectsPage-D33VGyVL.js → ProjectsPage-D-H2WRVU.js} +1 -1
  69. package/web-app/dist/assets/{SettingsPage-DQtlpXPe.js → SettingsPage-o0kR9RAt.js} +1 -1
  70. package/web-app/dist/assets/{ShowcasePage-CAitzJlY.js → ShowcasePage-DtfMDMjp.js} +1 -1
  71. package/web-app/dist/assets/{SystemSettingsPage-CV49UjwU.js → SystemSettingsPage-CkXdpZ_T.js} +1 -1
  72. package/web-app/dist/assets/{TeamsPage-5EKrbkN3.js → TeamsPage-Cu5H9cQ0.js} +1 -1
  73. package/web-app/dist/assets/{TemplatesPage-BRsrm00y.js → TemplatesPage-3wdbVwil.js} +1 -1
  74. package/web-app/dist/assets/{TerminalOutput-DgEnMwnQ.js → TerminalOutput-CWn301xh.js} +1 -1
  75. package/web-app/dist/assets/{activity-hXTYu4Zb.js → activity-YGaAt4zJ.js} +1 -1
  76. package/web-app/dist/assets/{bell-D5QkmMjg.js → bell-C28q0GO7.js} +1 -1
  77. package/web-app/dist/assets/{bot-CGOzPami.js → bot-DLcQGI92.js} +1 -1
  78. package/web-app/dist/assets/{check-HxPvzPXm.js → check-w0sBNlcm.js} +1 -1
  79. package/web-app/dist/assets/{chevron-left-D7HYUp4T.js → chevron-left-W7O0vC_m.js} +1 -1
  80. package/web-app/dist/assets/{circle-alert-BdufMh8P.js → circle-alert-Cxjq9fxB.js} +1 -1
  81. package/web-app/dist/assets/{clock-DMcS0xjR.js → clock-C8EqzmNl.js} +1 -1
  82. package/web-app/dist/assets/{cloud-Bt-0bh9m.js → cloud-xNgp4g3Y.js} +1 -1
  83. package/web-app/dist/assets/{code-xml-DRtDKt4_.js → code-xml-B-lyNDLJ.js} +1 -1
  84. package/web-app/dist/assets/{database-B0ChKZQ4.js → database-DoW5lmWc.js} +1 -1
  85. package/web-app/dist/assets/{dollar-sign-st_8sF_M.js → dollar-sign-BcARA5sC.js} +1 -1
  86. package/web-app/dist/assets/{file-code-corner-UdiLz5N6.js → file-code-corner-CehcII0J.js} +1 -1
  87. package/web-app/dist/assets/{file-plus-De1dimur.js → file-plus-DI9Zo5hP.js} +1 -1
  88. package/web-app/dist/assets/{globe-rrbXmytb.js → globe-CiZWBk3H.js} +1 -1
  89. package/web-app/dist/assets/{hammer-CBtNZi-S.js → hammer-3qv_T2N-.js} +1 -1
  90. package/web-app/dist/assets/{index-D3YvZaMV.js → index-gF9grAz-.js} +3 -3
  91. package/web-app/dist/assets/{layers-BmthCdkO.js → layers-CN7d0By9.js} +1 -1
  92. package/web-app/dist/assets/{loader-circle-C_sXFJUa.js → loader-circle-DywcM-im.js} +1 -1
  93. package/web-app/dist/assets/{lock-DT6V3D97.js → lock-_hdSmkU4.js} +1 -1
  94. package/web-app/dist/assets/{package-D86K1oU6.js → package-BV5zgqxJ.js} +1 -1
  95. package/web-app/dist/assets/{plus-Dwkssum8.js → plus-CU_Hh4-x.js} +1 -1
  96. package/web-app/dist/assets/{refresh-cw-DhRlqf1F.js → refresh-cw-DMK-fNl-.js} +1 -1
  97. package/web-app/dist/assets/{rotate-ccw-EvA5ZJVL.js → rotate-ccw-CEB3_v5_.js} +1 -1
  98. package/web-app/dist/assets/{scroll-text-D4ABo43E.js → scroll-text-BSdXxcRN.js} +1 -1
  99. package/web-app/dist/assets/{server-BAAdr_fD.js → server-CAMlP1yJ.js} +1 -1
  100. package/web-app/dist/assets/{shield-alert-DJPLy9t8.js → shield-alert-B-r1Dt3h.js} +1 -1
  101. package/web-app/dist/assets/{thumbs-up-D07JqmoS.js → thumbs-up-CYfxw9o4.js} +1 -1
  102. package/web-app/dist/assets/{trash-2-BJYwlHzl.js → trash-2-DX3nGPS8.js} +1 -1
  103. package/web-app/dist/assets/{trending-down-Bo8txoOw.js → trending-down-Bt-hSntd.js} +1 -1
  104. package/web-app/dist/assets/{trending-up-Bb13tkln.js → trending-up-DVSlvQp8.js} +1 -1
  105. package/web-app/dist/assets/{upload-D60DiVNZ.js → upload-D45RRHAP.js} +1 -1
  106. package/web-app/dist/assets/{usePolling-DJmgDiio.js → usePolling-fq_Xj8iw.js} +1 -1
  107. package/web-app/dist/assets/{user-BmXCbSJm.js → user-BWcJkLlr.js} +1 -1
  108. package/web-app/dist/index.html +1 -1
  109. package/packages/control-plane/ui/dist/assets/index-CFfrqCx2.css +0 -1
  110. 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
 
@@ -498,9 +498,12 @@ If you do not have Bun yet:
498
498
  curl -fsSL https://bun.sh/install | bash # macOS / Linux (or: brew install oven-sh/bun/bun)
499
499
  ```
500
500
 
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).
501
+ Docker without installing loki locally: `loki docker start owner/repo#N` (or
502
+ `loki docker start prd.md`) runs it in the published image with zero config,
503
+ bind-mounting the current folder so `.loki` state and resume work exactly like
504
+ local. On macOS, pass your GitHub login for PRs:
505
+ `GH_TOKEN=$(gh auth token) loki docker start owner/repo#17`.
506
+ See [DOCKER_README.md](DOCKER_README.md).
504
507
 
505
508
  Upgrading: `loki self-update` auto-detects which package manager installed loki
506
509
  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.13
6
+ # Loki Mode v10.7.0
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
 
@@ -15,7 +15,7 @@ description: An autonomous software factory that knows what it is supposed to de
15
15
 
16
16
  **Provider-agnostic (stable since v5.0.0):** runs on Claude/Codex/Cline/Aider with abstract model tiers and degraded mode for non-Claude providers; no vendor lock-in. Gemini deprecated v7.5.18. See `skills/providers.md`. **Legacy track (v8.0.0):** the Anthropic Agent SDK route (see below), spec-mode expansion for OpenAPI/GraphQL/Postman contracts, the runtime-boot and secret-leak evidence axes, and `loki steer` / `loki why` for mid-run control. Earlier tracks: LSP grounding as a first-class agent tool (v7.7.x) and Phase 1 RARV-C closure (real provider judges, gate-failure flock, synthetic PRD e2e, status `--json`).
17
17
 
18
- **Runtime migration:** Bash-to-Bun migration. Read-only commands (`version`, `status`, `stats`, `doctor`, `provider show/list`, `memory list/index`) flow through Bun runtime via `bin/loki` since v7.3.0. Every other command remains on the Bash runtime (`autonomy/loki`). Rollback: `LOKI_LEGACY_BASH=1`. See `UPGRADING.md` and `docs/architecture/ADR-001-runtime-migration.md`.
18
+ **Runtime migration:** Bash-to-Bun migration. `bin/loki` is the router: the commands in its Bun-route `case` (read-only ones such as `version`, `status`, `stats`, `doctor`, `provider`, `memory`, plus stateful ones such as `rollback`, `trust`, `proof`/`receipt`, `control`, `answer`) run through `loki-ts/src/cli.ts` on Bun; the rest, and some flag forms (for example `doctor --airgap`, `trust detail`), run on Bash (`autonomy/loki`). `start` is Bash by default and goes to Bun only for the SDK loop (`LOKI_SDK_LOOP=1`) or Loki 10 engine inputs (issue refs, multi-word tasks). Read `bin/loki` and `cli.ts` for the exact set rather than trusting a list here. Rollback: `LOKI_LEGACY_BASH=1`. See `UPGRADING.md` and `docs/architecture/ADR-001-runtime-migration.md`.
19
19
 
20
20
  **Anthropic Agent SDK route (v8.0.0, opt-in, default-off):** a claude-binary-free path where the RARV loop runs on `@anthropic-ai/claude-agent-sdk` `query()` and judges run on the raw `@anthropic-ai/sdk`. One operator switch `LOKI_SDK_MODE` (`off` default / `judges` / `full`), mirrored byte-for-byte in bash (`autonomy/lib/sdk-mode.sh`) and TypeScript (`loki-ts/src/runner/sdk_mode.ts`). Unset = byte-identical to the claude-CLI route. See `references/sdk-mode.md`.
21
21
 
@@ -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.13 | [Autonomi](https://www.autonomi.dev/) flagship product | core skill**
476
+ **v10.7.0 | [Autonomi](https://www.autonomi.dev/) flagship product | core skill**
package/VERSION CHANGED
@@ -1 +1 @@
1
- 10.6.13
1
+ 10.7.0
@@ -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()
@@ -3606,7 +3606,7 @@ print(1 if runner not in ('none', '', 'None') and p is True else 0)
3606
3606
  devils_advocate)
3607
3607
  # Check for TODO/FIXME
3608
3608
  local todo_count
3609
- todo_count=$(grep -rl "TODO\|FIXME\|HACK\|XXX" . --include="*.ts" --include="*.js" --include="*.py" --include="*.sh" 2>/dev/null | wc -l | tr -d ' ')
3609
+ todo_count=$(grep -rlE "\b(TODO|FIXME|HACK|XXX)s?\b" --exclude-dir=node_modules --exclude-dir=.git --exclude-dir=.loki --exclude-dir=dist --exclude-dir=build --exclude-dir=vendor . --include="*.ts" --include="*.js" --include="*.py" --include="*.sh" 2>/dev/null | wc -l | tr -d ' ')
3610
3610
  if [ "$todo_count" -gt 5 ]; then
3611
3611
  ((issues++))
3612
3612
  fi
@@ -3883,7 +3883,7 @@ print(len(d.get('tasks', d) if isinstance(d, dict) else d))" 2>/dev/null)
3883
3883
  # count is a hard "not done"; a missing/absent test base means no
3884
3884
  # positive evidence even when TODOs are low.
3885
3885
  local todo_count
3886
- todo_count=$(grep -rl "TODO\|FIXME\|HACK\|XXX" . --include="*.ts" --include="*.js" --include="*.py" --include="*.sh" 2>/dev/null | wc -l | tr -d ' ')
3886
+ todo_count=$(grep -rlE "\b(TODO|FIXME|HACK|XXX)s?\b" --exclude-dir=node_modules --exclude-dir=.git --exclude-dir=.loki --exclude-dir=dist --exclude-dir=build --exclude-dir=vendor . --include="*.ts" --include="*.js" --include="*.py" --include="*.sh" 2>/dev/null | wc -l | tr -d ' ')
3887
3887
  if [ "$todo_count" -gt 5 ]; then
3888
3888
  blocked="true"
3889
3889
  reasons="${reasons}$todo_count files with TODO/FIXME markers; "
@@ -4149,7 +4149,7 @@ else:
4149
4149
 
4150
4150
  # Skeptical check 3: TODO/FIXME/HACK density
4151
4151
  local todo_count
4152
- todo_count=$(grep -rl "TODO\|FIXME\|HACK\|XXX" . --include="*.ts" --include="*.js" --include="*.py" --include="*.sh" 2>/dev/null | wc -l | tr -d ' ')
4152
+ todo_count=$(grep -rlE "\b(TODO|FIXME|HACK|XXX)s?\b" --exclude-dir=node_modules --exclude-dir=.git --exclude-dir=.loki --exclude-dir=dist --exclude-dir=build --exclude-dir=vendor . --include="*.ts" --include="*.js" --include="*.py" --include="*.sh" 2>/dev/null | wc -l | tr -d ' ')
4153
4153
  if [ "$todo_count" -gt 3 ]; then
4154
4154
  ((issues_found++))
4155
4155
  issue_details="${issue_details}$todo_count files still contain TODO/FIXME markers; "
@@ -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
@@ -259,6 +259,7 @@ extract_type_from_labels() {
259
259
  # claim.
260
260
  _gp_criteria_lines() {
261
261
  printf '%s\n' "${1:-}" \
262
+ | grep -E '^[[:space:]]*([-*][[:space:]]+|[0-9]+[.)][[:space:]]+)' \
262
263
  | sed -E 's/^[[:space:]]*[-*][[:space:]]*\[[ xX]\][[:space:]]*//; s/^[[:space:]]*[-*][[:space:]]+//; s/^[[:space:]]*[0-9]+[.)][[:space:]]+//' \
263
264
  | grep -vE '^[[:space:]]*$' \
264
265
  | grep -vE '^[[:space:]]*-{3,}[[:space:]]*$'
@@ -543,7 +544,7 @@ prd:
543
544
  $(yaml_escape "$problem")
544
545
 
545
546
  goals:
546
- $(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")
547
548
 
548
549
  scope:
549
550
  in_scope:
@@ -553,7 +554,7 @@ $(echo "$technical" | head -5 | sed 's/^/ - /' || true)
553
554
  - Changes unrelated to this issue
554
555
 
555
556
  success_criteria:
556
- $(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")
557
558
  - All tests passing
558
559
  - Code review approved
559
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()