loki-mode 10.6.14 → 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.
- package/README.md +8 -5
- package/SKILL.md +2 -2
- package/VERSION +1 -1
- package/autonomy/acp-agent.py +128 -0
- package/autonomy/docker-run.sh +8 -0
- package/autonomy/issue-parser.sh +7 -7
- package/autonomy/issue-providers.sh +159 -1
- package/autonomy/lib/engine10-push.sh +4 -1
- package/autonomy/lib/review_risk.py +91 -0
- package/autonomy/loki +209 -19
- package/autonomy/mobile-verify.sh +66 -0
- package/autonomy/run.sh +44 -2
- package/completions/_loki +2 -0
- package/completions/loki.bash +1 -1
- package/dashboard/__init__.py +1 -1
- package/dashboard/api_runs_v1.py +80 -0
- package/dashboard/server.py +2 -0
- package/docs/INSTALLATION.md +5 -1
- package/docs/acp-editors.md +29 -0
- package/docs/api-runs-v1.md +12 -0
- package/docs/dev/architecture-reference.md +4 -0
- package/docs/merge-queue.md +13 -0
- package/docs/mobile-verify.md +11 -0
- package/docs/sentry-intake.md +13 -0
- package/docs/v10/BOARD.md +31 -6
- package/docs/v10/CP-ENTERPRISE-UI.md +276 -0
- package/docs/v10/D61-SPEED.md +4 -0
- package/docs/v10/DECISIONS.md +74 -0
- package/docs/v10/FOUNDER-QUEUE.md +1 -0
- package/docs/v10/GAP-RESEARCH-1003.md +32 -0
- package/docs/v10/MORNING-BRIEF-1003.md +39 -0
- package/docs/v10/PROGRESS.md +71 -0
- package/docs/v10/SWARM.md +32 -0
- package/docs/visual-evidence.md +5 -0
- package/loki-ts/dist/loki.js +618 -611
- package/mcp/__init__.py +1 -1
- package/package.json +1 -1
- package/packages/control-plane/dist/server.js +263 -9
- package/packages/control-plane/src/server/actions.ts +60 -0
- package/packages/control-plane/src/server/app.ts +39 -3
- package/packages/control-plane/src/shipper/watch.ts +64 -0
- package/packages/control-plane/ui/dist/assets/index-CUQ65vJY.css +1 -0
- package/packages/control-plane/ui/dist/assets/index-CwwGfp6E.js +129 -0
- package/packages/control-plane/ui/dist/index.html +3 -3
- package/plugins/loki-mode/.claude-plugin/plugin.json +1 -1
- package/web-app/dist/assets/{AdminPage-oAFMNfKH.js → AdminPage-BQPb5rc5.js} +1 -1
- package/web-app/dist/assets/{Avatar-BA2gnzyf.js → Avatar-Cttco6FC.js} +1 -1
- package/web-app/dist/assets/{Badge-Dsluv8n1.js → Badge-CPb6kpbH.js} +1 -1
- package/web-app/dist/assets/{Button-WmW8A-iG.js → Button-caTL28a4.js} +1 -1
- package/web-app/dist/assets/{CockpitPage-BR5o_RgL.js → CockpitPage-bEbLx6ph.js} +1 -1
- package/web-app/dist/assets/{ComparePage-3Eg-pKvO.js → ComparePage-DNtJSij-.js} +1 -1
- package/web-app/dist/assets/{ErrorBoundary-DAfrnnsQ.js → ErrorBoundary-BrgC3K44.js} +1 -1
- package/web-app/dist/assets/{EvidenceReceiptPanel-wLZI4OTr.js → EvidenceReceiptPanel-Q-mSrUUQ.js} +1 -1
- package/web-app/dist/assets/{GitHubIssuesPanel-BOuadisf.js → GitHubIssuesPanel-DbmYl_Er.js} +1 -1
- package/web-app/dist/assets/{GitHubPRsPanel-BTVoVpB3.js → GitHubPRsPanel-C6PCHM4B.js} +1 -1
- package/web-app/dist/assets/{HomePage-s3GWdNEe.js → HomePage-7zsL_k_e.js} +2 -2
- package/web-app/dist/assets/{LoginPage-BfOZ566t.js → LoginPage-Cl4ON8RQ.js} +1 -1
- package/web-app/dist/assets/{MagicPage-9EZ83xOt.js → MagicPage-BNEAhTqH.js} +1 -1
- package/web-app/dist/assets/{MetricsPage-OJMOYhHP.js → MetricsPage-DyBH-88q.js} +1 -1
- package/web-app/dist/assets/{NotFoundPage-_LoJ6KEv.js → NotFoundPage-CIVr21yL.js} +1 -1
- package/web-app/dist/assets/{ProjectPage-Duj5LSt6.js → ProjectPage-BMeVYocF.js} +3 -3
- package/web-app/dist/assets/{ProjectsPage-BBud3JrO.js → ProjectsPage-D-H2WRVU.js} +1 -1
- package/web-app/dist/assets/{SettingsPage-C4zdgKno.js → SettingsPage-o0kR9RAt.js} +1 -1
- package/web-app/dist/assets/{ShowcasePage-Nb3ACjMR.js → ShowcasePage-DtfMDMjp.js} +1 -1
- package/web-app/dist/assets/{SystemSettingsPage-BDi49TDh.js → SystemSettingsPage-CkXdpZ_T.js} +1 -1
- package/web-app/dist/assets/{TeamsPage-HkcqkjLa.js → TeamsPage-Cu5H9cQ0.js} +1 -1
- package/web-app/dist/assets/{TemplatesPage-CK796Y1F.js → TemplatesPage-3wdbVwil.js} +1 -1
- package/web-app/dist/assets/{TerminalOutput-BkvdNuT6.js → TerminalOutput-CWn301xh.js} +1 -1
- package/web-app/dist/assets/{activity-DFgMgETR.js → activity-YGaAt4zJ.js} +1 -1
- package/web-app/dist/assets/{bell-BInGGOMZ.js → bell-C28q0GO7.js} +1 -1
- package/web-app/dist/assets/{bot-D43Yjsj4.js → bot-DLcQGI92.js} +1 -1
- package/web-app/dist/assets/{check-DFAGTLM9.js → check-w0sBNlcm.js} +1 -1
- package/web-app/dist/assets/{chevron-left-Drwin7n1.js → chevron-left-W7O0vC_m.js} +1 -1
- package/web-app/dist/assets/{circle-alert-DLWdb7V9.js → circle-alert-Cxjq9fxB.js} +1 -1
- package/web-app/dist/assets/{clock-Dsbi59u5.js → clock-C8EqzmNl.js} +1 -1
- package/web-app/dist/assets/{cloud-CUU-dXTP.js → cloud-xNgp4g3Y.js} +1 -1
- package/web-app/dist/assets/{code-xml-CqIh7Wui.js → code-xml-B-lyNDLJ.js} +1 -1
- package/web-app/dist/assets/{database-DASN5Bfh.js → database-DoW5lmWc.js} +1 -1
- package/web-app/dist/assets/{dollar-sign-ZvXzAihc.js → dollar-sign-BcARA5sC.js} +1 -1
- package/web-app/dist/assets/{file-code-corner-ED_gN-N8.js → file-code-corner-CehcII0J.js} +1 -1
- package/web-app/dist/assets/{file-plus-BmBHndXt.js → file-plus-DI9Zo5hP.js} +1 -1
- package/web-app/dist/assets/{globe-Ck_iEzO4.js → globe-CiZWBk3H.js} +1 -1
- package/web-app/dist/assets/{hammer-B8YKMard.js → hammer-3qv_T2N-.js} +1 -1
- package/web-app/dist/assets/{index-D7SxmjVQ.js → index-gF9grAz-.js} +3 -3
- package/web-app/dist/assets/{layers-CGVsj3wB.js → layers-CN7d0By9.js} +1 -1
- package/web-app/dist/assets/{loader-circle-FIH7UJDF.js → loader-circle-DywcM-im.js} +1 -1
- package/web-app/dist/assets/{lock-vR8HoAnK.js → lock-_hdSmkU4.js} +1 -1
- package/web-app/dist/assets/{package-mZ6LFwXp.js → package-BV5zgqxJ.js} +1 -1
- package/web-app/dist/assets/{plus-CtnSVu0w.js → plus-CU_Hh4-x.js} +1 -1
- package/web-app/dist/assets/{refresh-cw-BIapF6Gj.js → refresh-cw-DMK-fNl-.js} +1 -1
- package/web-app/dist/assets/{rotate-ccw-LMkSsm21.js → rotate-ccw-CEB3_v5_.js} +1 -1
- package/web-app/dist/assets/{scroll-text-DQVlIhr9.js → scroll-text-BSdXxcRN.js} +1 -1
- package/web-app/dist/assets/{server-GqyhQxmz.js → server-CAMlP1yJ.js} +1 -1
- package/web-app/dist/assets/{shield-alert-BrFTNJ-I.js → shield-alert-B-r1Dt3h.js} +1 -1
- package/web-app/dist/assets/{thumbs-up-lgwAEJFW.js → thumbs-up-CYfxw9o4.js} +1 -1
- package/web-app/dist/assets/{trash-2-C1cNysK4.js → trash-2-DX3nGPS8.js} +1 -1
- package/web-app/dist/assets/{trending-down-DQFZ6j5s.js → trending-down-Bt-hSntd.js} +1 -1
- package/web-app/dist/assets/{trending-up-BJG_Zo8V.js → trending-up-DVSlvQp8.js} +1 -1
- package/web-app/dist/assets/{upload-BOY1aTpA.js → upload-D45RRHAP.js} +1 -1
- package/web-app/dist/assets/{usePolling-DoAmsTNy.js → usePolling-fq_Xj8iw.js} +1 -1
- package/web-app/dist/assets/{user-iOfCe0_v.js → user-BWcJkLlr.js} +1 -1
- package/web-app/dist/index.html +1 -1
- package/packages/control-plane/ui/dist/assets/index-CFfrqCx2.css +0 -1
- 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 $
|
|
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
|
|
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
|
|
502
|
-
|
|
503
|
-
state and resume work exactly like
|
|
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
|
+
# 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
|
|
|
@@ -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.
|
|
476
|
+
**v10.7.0 | [Autonomi](https://www.autonomi.dev/) flagship product | core skill**
|
package/VERSION
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
10.
|
|
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()
|
package/autonomy/docker-run.sh
CHANGED
|
@@ -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
|
package/autonomy/issue-parser.sh
CHANGED
|
@@ -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 '
|
|
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 '
|
|
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 '
|
|
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 '
|
|
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 '
|
|
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 '
|
|
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 '
|
|
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
|
|
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
|
-
|
|
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()
|