loki-mode 10.6.7 → 10.6.9
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 +44 -9
- package/SKILL.md +2 -2
- package/VERSION +1 -1
- package/autonomy/lib/backlog.py +167 -36
- package/autonomy/lib/workspace.py +384 -0
- package/autonomy/lib/workspace_comment.py +91 -0
- package/autonomy/lib/worktree_prep.py +143 -0
- package/autonomy/loki +96 -3
- package/bin/loki +3 -3
- package/completions/_loki +2 -0
- package/completions/loki.bash +1 -2
- package/dashboard/__init__.py +1 -1
- package/dashboard/api_start.py +26 -1
- package/docs/INSTALLATION.md +1 -1
- package/docs/WANG-PRINCIPLES-PLAN.md +1 -1
- package/docs/WORKSPACES.md +36 -0
- package/docs/alternative-installations.md +3 -1
- package/docs/control-plane-container.md +50 -0
- package/docs/dashboard-guide.md +4 -0
- package/docs/dev/architecture-reference.md +4 -0
- package/docs/dev/project-structure.md +1 -1
- package/docs/exit-codes.md +1 -0
- package/docs/slack.md +34 -0
- package/docs/trackers.md +27 -0
- package/docs/v10/BOARD.md +74 -14
- package/docs/v10/CONTROL-PLANE.md +17 -6
- package/docs/v10/D51-PHASE-B.md +3 -1
- package/docs/v10/D61-SPEED.md +11 -2
- package/docs/v10/D63-CARDS.md +354 -0
- package/docs/v10/DECISIONS.md +63 -0
- package/docs/v10/DEPS.md +6 -2
- package/docs/v10/E2E-D65.md +87 -0
- package/docs/v10/HANDOFF-NEXT-WEEK.md +10 -4
- package/docs/v10/PROGRESS.md +63 -0
- package/docs/v10/ROADMAP-D63.md +35 -0
- package/loki-ts/dist/loki.js +612 -556
- package/mcp/__init__.py +1 -1
- package/mcp/server.py +76 -0
- package/mcp/tests/test_v10_tools.py +193 -0
- package/mcp/v10_tools.py +182 -0
- package/package.json +1 -1
- package/packages/control-plane/dist/server.js +131 -9
- package/packages/control-plane/drizzle/0001_group_id.sql +3 -0
- package/packages/control-plane/drizzle/meta/_journal.json +8 -1
- package/packages/control-plane/src/db/schema.ts +3 -0
- package/packages/control-plane/src/server/answer.ts +33 -0
- package/packages/control-plane/src/server/app.ts +22 -2
- package/packages/control-plane/src/server/auth.ts +51 -0
- package/packages/control-plane/src/server/runs.ts +6 -3
- package/packages/control-plane/src/server/serve.ts +13 -3
- package/packages/control-plane/src/shipper/discover.ts +29 -0
- package/packages/control-plane/ui/dist/assets/{index-BH3LtKcG.css → index-CFfrqCx2.css} +1 -1
- package/packages/control-plane/ui/dist/assets/index-Jt31nrK-.js +114 -0
- package/packages/control-plane/ui/dist/index.html +2 -2
- package/plugins/loki-mode/.claude-plugin/plugin.json +1 -1
- package/web-app/dist/assets/{AdminPage-DyjnUcaP.js → AdminPage-CZKZLuWU.js} +1 -1
- package/web-app/dist/assets/{Avatar-CY0sQNao.js → Avatar-Dcy9kO2E.js} +1 -1
- package/web-app/dist/assets/{Badge-BmwTRu72.js → Badge-DUotUSyx.js} +1 -1
- package/web-app/dist/assets/{Button-BRf5aTlb.js → Button-CFz_zfMZ.js} +1 -1
- package/web-app/dist/assets/{CockpitPage-RR94HuxS.js → CockpitPage-BhRw_oiM.js} +1 -1
- package/web-app/dist/assets/{ComparePage-CFWAuPRO.js → ComparePage-DfnsRTTC.js} +1 -1
- package/web-app/dist/assets/{ErrorBoundary-4cDADrG-.js → ErrorBoundary-Dn0gUu3D.js} +1 -1
- package/web-app/dist/assets/{EvidenceReceiptPanel-ChPFx2eM.js → EvidenceReceiptPanel-Co-xi282.js} +1 -1
- package/web-app/dist/assets/{GitHubIssuesPanel-B0TAjyeW.js → GitHubIssuesPanel-CoCpfexq.js} +1 -1
- package/web-app/dist/assets/{GitHubPRsPanel-BbszEwW9.js → GitHubPRsPanel-Dg0Eg6F_.js} +1 -1
- package/web-app/dist/assets/{HomePage-DrBgzs1d.js → HomePage-BysxL4mV.js} +2 -2
- package/web-app/dist/assets/{LoginPage-Bu7b2DGz.js → LoginPage-CQ366p_d.js} +1 -1
- package/web-app/dist/assets/{MagicPage-BFEYp631.js → MagicPage-DD7YcbNq.js} +1 -1
- package/web-app/dist/assets/{MetricsPage-eIe2naVR.js → MetricsPage-BYz49xQj.js} +1 -1
- package/web-app/dist/assets/{NotFoundPage-BVWYkNt9.js → NotFoundPage-CKs09MeO.js} +1 -1
- package/web-app/dist/assets/{ProjectPage-zmxyu732.js → ProjectPage-Ci2BIA_D.js} +3 -3
- package/web-app/dist/assets/{ProjectsPage-Dq5NdCak.js → ProjectsPage-ClwMH0eu.js} +1 -1
- package/web-app/dist/assets/{SettingsPage-CCrJOMe0.js → SettingsPage-Cm0rCX-y.js} +1 -1
- package/web-app/dist/assets/{ShowcasePage-n-Ipzp01.js → ShowcasePage-CxvESt8X.js} +1 -1
- package/web-app/dist/assets/{SystemSettingsPage-CP_rjYXK.js → SystemSettingsPage-W_CGYPOY.js} +1 -1
- package/web-app/dist/assets/{TeamsPage-B_P9DepW.js → TeamsPage-feVvRvys.js} +1 -1
- package/web-app/dist/assets/{TemplatesPage-h-3A6PQT.js → TemplatesPage-C-pUWMmF.js} +1 -1
- package/web-app/dist/assets/{TerminalOutput-pD0nJ1BU.js → TerminalOutput-j5ibjKY3.js} +1 -1
- package/web-app/dist/assets/{activity-ChFwzT-d.js → activity-TTdVYMa_.js} +1 -1
- package/web-app/dist/assets/{bell-BX-eK8Gu.js → bell-DeoTFwpv.js} +1 -1
- package/web-app/dist/assets/{bot-fj5PkPtR.js → bot-B5SghxqU.js} +1 -1
- package/web-app/dist/assets/{check-BGAbHlvD.js → check-k8SzsdbE.js} +1 -1
- package/web-app/dist/assets/{chevron-left-BzFUxHgb.js → chevron-left-DHnEllFz.js} +1 -1
- package/web-app/dist/assets/{circle-alert-BQrg5DWl.js → circle-alert-DW_SLdcB.js} +1 -1
- package/web-app/dist/assets/{clock-CTu9lkVo.js → clock-Bmoaxy09.js} +1 -1
- package/web-app/dist/assets/{cloud-DrIENRiE.js → cloud-eg3tzXTh.js} +1 -1
- package/web-app/dist/assets/{code-xml-PaoVpfur.js → code-xml-VAfXihhD.js} +1 -1
- package/web-app/dist/assets/{database-DMmkAotn.js → database-CY3_-5aK.js} +1 -1
- package/web-app/dist/assets/{dollar-sign-B2LQdBoX.js → dollar-sign-DYC0OLlg.js} +1 -1
- package/web-app/dist/assets/{file-code-corner-Dfv1XXeR.js → file-code-corner-0X3SveKz.js} +1 -1
- package/web-app/dist/assets/{file-plus-D2Wzf2wd.js → file-plus-B_oh_ukl.js} +1 -1
- package/web-app/dist/assets/{globe-DxotVWt8.js → globe-F0tLA8WJ.js} +1 -1
- package/web-app/dist/assets/{hammer-Brfy5ZrJ.js → hammer-DrlvBmfW.js} +1 -1
- package/web-app/dist/assets/{index-BfhHMwi8.js → index-YihVaZAj.js} +3 -3
- package/web-app/dist/assets/{layers-DfxSWDvp.js → layers-CbJfn5UC.js} +1 -1
- package/web-app/dist/assets/{loader-circle-CxIrzNwx.js → loader-circle-CpmOggou.js} +1 -1
- package/web-app/dist/assets/{lock-m6nzhtpU.js → lock-BGJ6QWrI.js} +1 -1
- package/web-app/dist/assets/{package-ri9oE9Jc.js → package-BTmH4Xto.js} +1 -1
- package/web-app/dist/assets/{plus-DWeTAMVV.js → plus-mKQ7hz-8.js} +1 -1
- package/web-app/dist/assets/{refresh-cw-BGMSpaMh.js → refresh-cw-vPetpLsm.js} +1 -1
- package/web-app/dist/assets/{rotate-ccw-DD1xBD_b.js → rotate-ccw-DiJ8GFhj.js} +1 -1
- package/web-app/dist/assets/{scroll-text-BCCR_AN9.js → scroll-text-DpPQIlQR.js} +1 -1
- package/web-app/dist/assets/{server-CmMWPIjS.js → server-CiaV4drx.js} +1 -1
- package/web-app/dist/assets/{shield-alert-BZZmBVEJ.js → shield-alert-BaaQqyco.js} +1 -1
- package/web-app/dist/assets/{thumbs-up-OaxbNRXu.js → thumbs-up-2zNCKp0Q.js} +1 -1
- package/web-app/dist/assets/{trash-2-DmcSzTfr.js → trash-2-CiVDx5Co.js} +1 -1
- package/web-app/dist/assets/{trending-down-BmfXLykz.js → trending-down-tcxvw5BH.js} +1 -1
- package/web-app/dist/assets/{trending-up-Djec3jIq.js → trending-up-C2bv2_v_.js} +1 -1
- package/web-app/dist/assets/{upload-BN8dXIlF.js → upload-DS4VPUAp.js} +1 -1
- package/web-app/dist/assets/{usePolling-Cu4yi04I.js → usePolling-JggBcVln.js} +1 -1
- package/web-app/dist/assets/{user-IBI-k10o.js → user-CvavvOxM.js} +1 -1
- package/web-app/dist/index.html +1 -1
- package/packages/control-plane/ui/dist/assets/index-B6EiyukW.js +0 -114
package/mcp/__init__.py
CHANGED
package/mcp/server.py
CHANGED
|
@@ -1368,6 +1368,82 @@ async def get_pending_tasks() -> str:
|
|
|
1368
1368
|
# ENTERPRISE TOOLS (P0-1)
|
|
1369
1369
|
# ============================================================
|
|
1370
1370
|
|
|
1371
|
+
@mcp.tool()
|
|
1372
|
+
async def loki_v10_run(ref: str, repo_path: str) -> str:
|
|
1373
|
+
"""
|
|
1374
|
+
Start a Loki 10 run in the background (same engine as `loki <ref>`).
|
|
1375
|
+
|
|
1376
|
+
Args:
|
|
1377
|
+
ref: Task text or issue reference (owner/repo#N or issue URL)
|
|
1378
|
+
repo_path: Repository directory (must be inside the project root)
|
|
1379
|
+
|
|
1380
|
+
Returns:
|
|
1381
|
+
JSON with run_id (null if not yet created), pid and log_path. Never blocks.
|
|
1382
|
+
"""
|
|
1383
|
+
_emit_tool_event_async('loki_v10_run', 'start', parameters={'repo_path': repo_path})
|
|
1384
|
+
try:
|
|
1385
|
+
from mcp import v10_tools
|
|
1386
|
+
result = await asyncio.to_thread(
|
|
1387
|
+
v10_tools.v10_run, ref, repo_path, lambda p: validate_path(p, allowed_dirs=['.']))
|
|
1388
|
+
_emit_tool_event_async('loki_v10_run', 'complete', result_status='error' if isinstance(result, dict) and result.get('error') else 'success')
|
|
1389
|
+
return json.dumps(result)
|
|
1390
|
+
except Exception as e:
|
|
1391
|
+
logger.error(f"loki_v10_run failed: {e}")
|
|
1392
|
+
_emit_tool_event_async('loki_v10_run', 'complete', result_status='error', error=str(e))
|
|
1393
|
+
return json.dumps({"error": str(e)})
|
|
1394
|
+
|
|
1395
|
+
|
|
1396
|
+
@mcp.tool()
|
|
1397
|
+
async def loki_v10_status(repo_path: str, run_id: str = "") -> str:
|
|
1398
|
+
"""
|
|
1399
|
+
Read the state of a Loki 10 run from its events log.
|
|
1400
|
+
|
|
1401
|
+
Args:
|
|
1402
|
+
repo_path: Repository directory containing .loki/runs
|
|
1403
|
+
run_id: Run id (e10-...); defaults to the newest run
|
|
1404
|
+
|
|
1405
|
+
Returns:
|
|
1406
|
+
JSON with phase, done, verdict (once done) and cost_usd (null if unmeasured)
|
|
1407
|
+
"""
|
|
1408
|
+
_emit_tool_event_async('loki_v10_status', 'start', parameters={'repo_path': repo_path, 'run_id': run_id})
|
|
1409
|
+
try:
|
|
1410
|
+
from mcp import v10_tools
|
|
1411
|
+
result = v10_tools.v10_status(
|
|
1412
|
+
run_id, repo_path, lambda p: validate_path(p, allowed_dirs=['.']))
|
|
1413
|
+
_emit_tool_event_async('loki_v10_status', 'complete', result_status='error' if isinstance(result, dict) and result.get('error') else 'success')
|
|
1414
|
+
return json.dumps(result)
|
|
1415
|
+
except Exception as e:
|
|
1416
|
+
logger.error(f"loki_v10_status failed: {e}")
|
|
1417
|
+
_emit_tool_event_async('loki_v10_status', 'complete', result_status='error', error=str(e))
|
|
1418
|
+
return json.dumps({"error": str(e)})
|
|
1419
|
+
|
|
1420
|
+
|
|
1421
|
+
@mcp.tool()
|
|
1422
|
+
async def loki_v10_verify(receipt_path: str = "", repo_path: str = "") -> str:
|
|
1423
|
+
"""
|
|
1424
|
+
Run `loki verify` on a receipt (or the newest run of a repo).
|
|
1425
|
+
|
|
1426
|
+
Args:
|
|
1427
|
+
receipt_path: Path to a receipt.json (takes priority)
|
|
1428
|
+
repo_path: Repository directory; verifies its newest run
|
|
1429
|
+
|
|
1430
|
+
Returns:
|
|
1431
|
+
JSON with exit_code, verified and an output summary
|
|
1432
|
+
"""
|
|
1433
|
+
_emit_tool_event_async('loki_v10_verify', 'start', parameters={'receipt_path': receipt_path, 'repo_path': repo_path})
|
|
1434
|
+
try:
|
|
1435
|
+
from mcp import v10_tools
|
|
1436
|
+
result = await asyncio.to_thread(
|
|
1437
|
+
v10_tools.v10_verify, receipt_path, repo_path,
|
|
1438
|
+
lambda p: validate_path(p, allowed_dirs=['.']))
|
|
1439
|
+
_emit_tool_event_async('loki_v10_verify', 'complete', result_status='error' if isinstance(result, dict) and result.get('error') else 'success')
|
|
1440
|
+
return json.dumps(result)
|
|
1441
|
+
except Exception as e:
|
|
1442
|
+
logger.error(f"loki_v10_verify failed: {e}")
|
|
1443
|
+
_emit_tool_event_async('loki_v10_verify', 'complete', result_status='error', error=str(e))
|
|
1444
|
+
return json.dumps({"error": str(e)})
|
|
1445
|
+
|
|
1446
|
+
|
|
1371
1447
|
@mcp.tool()
|
|
1372
1448
|
async def loki_start_project(prd_content: str = "", prd_path: str = "") -> str:
|
|
1373
1449
|
"""
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
"""Unit tests for mcp/v10_tools.py with subprocess mocked."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import subprocess
|
|
6
|
+
import sys
|
|
7
|
+
import tempfile
|
|
8
|
+
import unittest
|
|
9
|
+
from unittest import mock
|
|
10
|
+
|
|
11
|
+
_REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..'))
|
|
12
|
+
if _REPO_ROOT not in sys.path:
|
|
13
|
+
sys.path.insert(0, _REPO_ROOT)
|
|
14
|
+
|
|
15
|
+
from mcp import v10_tools # noqa: E402
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _ok(p):
|
|
19
|
+
return os.path.realpath(p)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _deny(p):
|
|
23
|
+
raise PermissionError("Access denied: " + p)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _write_events(repo, run_id, events):
|
|
27
|
+
d = os.path.join(repo, ".loki", "runs", run_id)
|
|
28
|
+
os.makedirs(d)
|
|
29
|
+
with open(os.path.join(d, "events.jsonl"), "w") as f:
|
|
30
|
+
for e in events:
|
|
31
|
+
f.write(json.dumps(e) + "\n")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class RunTests(unittest.TestCase):
|
|
35
|
+
def test_rejects_flag_like_ref(self):
|
|
36
|
+
with mock.patch("subprocess.Popen") as popen:
|
|
37
|
+
r = v10_tools.v10_run("--help", "/x", _ok)
|
|
38
|
+
self.assertIn("error", r)
|
|
39
|
+
popen.assert_not_called()
|
|
40
|
+
|
|
41
|
+
def test_path_validation_failure(self):
|
|
42
|
+
with mock.patch("subprocess.Popen") as popen:
|
|
43
|
+
r = v10_tools.v10_run("fix the bug", "/etc", _deny)
|
|
44
|
+
self.assertIn("Access denied", r["error"])
|
|
45
|
+
popen.assert_not_called()
|
|
46
|
+
|
|
47
|
+
def test_starts_in_background_with_arg_list(self):
|
|
48
|
+
with tempfile.TemporaryDirectory() as repo, \
|
|
49
|
+
mock.patch.object(v10_tools, "V10_RUN_ID_WAIT_S", 0), \
|
|
50
|
+
mock.patch("subprocess.Popen") as popen:
|
|
51
|
+
popen.return_value.pid = 4242
|
|
52
|
+
r = v10_tools.v10_run("fix the bug", repo, _ok)
|
|
53
|
+
args, kwargs = popen.call_args
|
|
54
|
+
self.assertEqual(args[0][1], "fix the bug")
|
|
55
|
+
self.assertIsInstance(args[0], list)
|
|
56
|
+
self.assertNotIn("shell", kwargs)
|
|
57
|
+
self.assertTrue(kwargs["start_new_session"])
|
|
58
|
+
self.assertEqual(kwargs["env"]["LOKI_ENGINE"], "v10")
|
|
59
|
+
self.assertEqual(r["pid"], 4242)
|
|
60
|
+
self.assertIsNone(r["run_id"])
|
|
61
|
+
self.assertTrue(r["log_path"].startswith(os.path.realpath(repo)))
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class EnvTests(unittest.TestCase):
|
|
65
|
+
def test_env_forces_headless_and_drops_secrets(self):
|
|
66
|
+
secrets = {"LOKI_CONTROL_TOKEN": "t", "SLACK_BOT_TOKEN": "xoxb-1", "SLACK_SIGNING_SECRET": "s",
|
|
67
|
+
"SLACK_WEBHOOK_URL": "https://hooks.slack.com/x", "LOKI_NO_BROWSER": "0"}
|
|
68
|
+
with mock.patch.dict(os.environ, secrets):
|
|
69
|
+
env = v10_tools._env()
|
|
70
|
+
self.assertEqual(env["LOKI_NO_BROWSER"], "1")
|
|
71
|
+
self.assertEqual(env["LOKI_ENGINE"], "v10")
|
|
72
|
+
for k in ("LOKI_CONTROL_TOKEN", "SLACK_BOT_TOKEN", "SLACK_SIGNING_SECRET", "SLACK_WEBHOOK_URL"):
|
|
73
|
+
self.assertNotIn(k, env)
|
|
74
|
+
|
|
75
|
+
def test_run_child_env_has_no_secrets(self):
|
|
76
|
+
with tempfile.TemporaryDirectory() as repo, \
|
|
77
|
+
mock.patch.dict(os.environ, {"SLACK_BOT_TOKEN": "xoxb-1"}), \
|
|
78
|
+
mock.patch.object(v10_tools, "V10_RUN_ID_WAIT_S", 0), \
|
|
79
|
+
mock.patch("subprocess.Popen") as popen:
|
|
80
|
+
popen.return_value.pid = 1
|
|
81
|
+
v10_tools.v10_run("fix it", repo, _ok)
|
|
82
|
+
self.assertNotIn("SLACK_BOT_TOKEN", popen.call_args[1]["env"])
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class RefPassThroughTests(unittest.TestCase):
|
|
86
|
+
def test_jira_and_linear_refs_pass_through(self):
|
|
87
|
+
for ref in ("jira:PROJ-123", "linear:ENG-45"):
|
|
88
|
+
with tempfile.TemporaryDirectory() as repo, \
|
|
89
|
+
mock.patch.object(v10_tools, "V10_RUN_ID_WAIT_S", 0), \
|
|
90
|
+
mock.patch("subprocess.Popen") as popen:
|
|
91
|
+
popen.return_value.pid = 7
|
|
92
|
+
r = v10_tools.v10_run(ref, repo, _ok)
|
|
93
|
+
self.assertNotIn("error", r)
|
|
94
|
+
self.assertEqual(popen.call_args[0][0][1], ref)
|
|
95
|
+
|
|
96
|
+
def test_leading_dash_still_rejected(self):
|
|
97
|
+
with mock.patch("subprocess.Popen") as popen:
|
|
98
|
+
self.assertIn("error", v10_tools.v10_run("-jira:X", "/x", _ok))
|
|
99
|
+
popen.assert_not_called()
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
class StatusTests(unittest.TestCase):
|
|
103
|
+
def test_blocked_returns_question(self):
|
|
104
|
+
ev = [
|
|
105
|
+
{"seq": 0, "type": "stage.completed", "stage": "implement",
|
|
106
|
+
"data": {"spec_conflict_reason": "spec says A\nbut tests say B"}},
|
|
107
|
+
{"seq": 1, "type": "run.completed", "stage": None, "data": {"verdict": "BLOCKED"}},
|
|
108
|
+
]
|
|
109
|
+
with tempfile.TemporaryDirectory() as repo:
|
|
110
|
+
_write_events(repo, "e10-blk", ev)
|
|
111
|
+
r = v10_tools.v10_status("e10-blk", repo, _ok)
|
|
112
|
+
self.assertEqual(r["verdict"], "BLOCKED")
|
|
113
|
+
self.assertEqual(r["blocked_question"], "spec says A but tests say B")
|
|
114
|
+
|
|
115
|
+
def test_verified_has_no_blocked_question(self):
|
|
116
|
+
ev = [{"seq": 0, "type": "run.completed", "stage": None, "data": {"verdict": "VERIFIED"}}]
|
|
117
|
+
with tempfile.TemporaryDirectory() as repo:
|
|
118
|
+
_write_events(repo, "e10-ok", ev)
|
|
119
|
+
self.assertNotIn("blocked_question", v10_tools.v10_status("e10-ok", repo, _ok))
|
|
120
|
+
|
|
121
|
+
def test_done_run(self):
|
|
122
|
+
ev = [
|
|
123
|
+
{"seq": 0, "type": "run.started", "stage": None, "data": {}},
|
|
124
|
+
{"seq": 1, "type": "stage.started", "stage": "plan", "data": {}},
|
|
125
|
+
{"seq": 2, "type": "cost", "stage": "plan", "data": {"usd": 0.5}},
|
|
126
|
+
{"seq": 3, "type": "stage.completed", "stage": "plan", "data": {}},
|
|
127
|
+
{"seq": 4, "type": "run.completed", "stage": None, "data": {"verdict": "VERIFIED"}},
|
|
128
|
+
]
|
|
129
|
+
with tempfile.TemporaryDirectory() as repo:
|
|
130
|
+
_write_events(repo, "e10-a", ev)
|
|
131
|
+
r = v10_tools.v10_status("", repo, _ok)
|
|
132
|
+
self.assertEqual(r["run_id"], "e10-a")
|
|
133
|
+
self.assertTrue(r["done"])
|
|
134
|
+
self.assertEqual(r["verdict"], "VERIFIED")
|
|
135
|
+
self.assertEqual(r["phase"], "done")
|
|
136
|
+
self.assertAlmostEqual(r["cost_usd"], 0.5)
|
|
137
|
+
|
|
138
|
+
def test_running_and_unmeasured_cost(self):
|
|
139
|
+
ev = [
|
|
140
|
+
{"seq": 0, "type": "stage.started", "stage": "build", "data": {}},
|
|
141
|
+
{"seq": 1, "type": "cost", "stage": "build", "data": {}},
|
|
142
|
+
]
|
|
143
|
+
with tempfile.TemporaryDirectory() as repo:
|
|
144
|
+
_write_events(repo, "e10-b", ev)
|
|
145
|
+
r = v10_tools.v10_status("e10-b", repo, _ok)
|
|
146
|
+
self.assertFalse(r["done"])
|
|
147
|
+
self.assertEqual(r["phase"], "build")
|
|
148
|
+
self.assertIsNone(r["cost_usd"])
|
|
149
|
+
self.assertIsNone(r["verdict"])
|
|
150
|
+
|
|
151
|
+
def test_bad_run_id_and_missing(self):
|
|
152
|
+
with tempfile.TemporaryDirectory() as repo:
|
|
153
|
+
self.assertIn("error", v10_tools.v10_status("../x", repo, _ok))
|
|
154
|
+
self.assertIn("error", v10_tools.v10_status("", repo, _ok))
|
|
155
|
+
self.assertIn("error", v10_tools.v10_status("", "", _ok))
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
class VerifyTests(unittest.TestCase):
|
|
159
|
+
def test_receipt_path_runs_verify(self):
|
|
160
|
+
with tempfile.TemporaryDirectory() as repo:
|
|
161
|
+
receipt = os.path.join(repo, "receipt.json")
|
|
162
|
+
open(receipt, "w").write("{}")
|
|
163
|
+
cp = subprocess.CompletedProcess([], 0, stdout="VERIFIED\n", stderr="")
|
|
164
|
+
with mock.patch("subprocess.run", return_value=cp) as run:
|
|
165
|
+
r = v10_tools.v10_verify(receipt, "", _ok)
|
|
166
|
+
args, kwargs = run.call_args
|
|
167
|
+
self.assertEqual(args[0][1:], ["verify", os.path.realpath(receipt)])
|
|
168
|
+
self.assertEqual(kwargs["timeout"], v10_tools.V10_VERIFY_TIMEOUT_S)
|
|
169
|
+
self.assertNotIn("shell", kwargs)
|
|
170
|
+
self.assertEqual(r["exit_code"], 0)
|
|
171
|
+
self.assertTrue(r["verified"])
|
|
172
|
+
self.assertIn("VERIFIED", r["output"])
|
|
173
|
+
|
|
174
|
+
def test_repo_path_nonzero_exit(self):
|
|
175
|
+
with tempfile.TemporaryDirectory() as repo:
|
|
176
|
+
cp = subprocess.CompletedProcess([], 1, stdout="", stderr="hash mismatch")
|
|
177
|
+
with mock.patch("subprocess.run", return_value=cp):
|
|
178
|
+
r = v10_tools.v10_verify("", repo, _ok)
|
|
179
|
+
self.assertEqual(r["exit_code"], 1)
|
|
180
|
+
self.assertFalse(r["verified"])
|
|
181
|
+
|
|
182
|
+
def test_timeout_and_validation(self):
|
|
183
|
+
with tempfile.TemporaryDirectory() as repo:
|
|
184
|
+
with mock.patch("subprocess.run", side_effect=subprocess.TimeoutExpired("loki", 1)):
|
|
185
|
+
self.assertIn("timed out", v10_tools.v10_verify("", repo, _ok)["error"])
|
|
186
|
+
with mock.patch("subprocess.run") as run:
|
|
187
|
+
self.assertIn("error", v10_tools.v10_verify("/etc/passwd", "", _deny))
|
|
188
|
+
self.assertIn("error", v10_tools.v10_verify("", "", _ok))
|
|
189
|
+
run.assert_not_called()
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
if __name__ == "__main__":
|
|
193
|
+
unittest.main()
|
package/mcp/v10_tools.py
ADDED
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
"""Loki 10 MCP tool helpers: start a v10 run, read its status, verify a receipt.
|
|
2
|
+
|
|
3
|
+
Plain functions returning dicts so they are testable without the MCP SDK; the
|
|
4
|
+
thin @mcp.tool wrappers live in mcp/server.py. Subprocesses use argument lists
|
|
5
|
+
only (never a shell), and every path is checked by the caller-supplied
|
|
6
|
+
validator before use.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import os
|
|
11
|
+
import re
|
|
12
|
+
import subprocess
|
|
13
|
+
import time
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Callable, Dict, List, Optional
|
|
16
|
+
|
|
17
|
+
LOKI_BIN = str(Path(__file__).resolve().parent.parent / "bin" / "loki")
|
|
18
|
+
RUNS_REL = os.path.join(".loki", "runs")
|
|
19
|
+
V10_RUN_ID_WAIT_S = 2.0
|
|
20
|
+
V10_VERIFY_TIMEOUT_S = 120
|
|
21
|
+
_TERMINAL = {"stage.completed", "stage.failed", "stage.skipped"}
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
_DROPPED_ENV = ("LOKI_CONTROL_TOKEN", "SLACK_BOT_TOKEN", "SLACK_SIGNING_SECRET", "SLACK_WEBHOOK_URL")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _env() -> Dict[str, str]:
|
|
28
|
+
env = {k: v for k, v in os.environ.items() if k not in _DROPPED_ENV}
|
|
29
|
+
env["LOKI_ENGINE"] = "v10"
|
|
30
|
+
env["LOKI_NO_BROWSER"] = "1"
|
|
31
|
+
return env
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _run_ids(repo: str) -> List[str]:
|
|
35
|
+
d = os.path.join(repo, RUNS_REL)
|
|
36
|
+
if not os.path.isdir(d):
|
|
37
|
+
return []
|
|
38
|
+
return sorted(n for n in os.listdir(d) if os.path.isdir(os.path.join(d, n)))
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def v10_run(ref: str, repo_path: str, validate: Callable[[str], str]) -> dict:
|
|
42
|
+
if not isinstance(ref, str) or not ref.strip() or ref.startswith("-") or "\x00" in ref:
|
|
43
|
+
return {"error": "invalid ref: must be a non-empty task or issue reference that does not start with '-'"}
|
|
44
|
+
try:
|
|
45
|
+
repo = validate(repo_path)
|
|
46
|
+
except Exception as e: # PathTraversalError from the server validator
|
|
47
|
+
return {"error": str(e)}
|
|
48
|
+
if not os.path.isdir(repo):
|
|
49
|
+
return {"error": f"repo_path is not a directory: {repo_path}"}
|
|
50
|
+
before = set(_run_ids(repo))
|
|
51
|
+
log_dir = os.path.join(repo, ".loki")
|
|
52
|
+
os.makedirs(log_dir, exist_ok=True)
|
|
53
|
+
log_path = os.path.join(log_dir, f"v10-mcp-{int(time.time() * 1000)}.log")
|
|
54
|
+
try:
|
|
55
|
+
with open(log_path, "ab") as log:
|
|
56
|
+
proc = subprocess.Popen(
|
|
57
|
+
[LOKI_BIN, ref],
|
|
58
|
+
cwd=repo,
|
|
59
|
+
env=_env(),
|
|
60
|
+
stdin=subprocess.DEVNULL,
|
|
61
|
+
stdout=log,
|
|
62
|
+
stderr=log,
|
|
63
|
+
start_new_session=True,
|
|
64
|
+
)
|
|
65
|
+
except OSError as e:
|
|
66
|
+
return {"error": f"could not start loki: {e}"}
|
|
67
|
+
run_id: Optional[str] = None
|
|
68
|
+
deadline = time.time() + V10_RUN_ID_WAIT_S
|
|
69
|
+
while True:
|
|
70
|
+
new = [r for r in _run_ids(repo) if r not in before]
|
|
71
|
+
if new:
|
|
72
|
+
run_id = new[-1]
|
|
73
|
+
break
|
|
74
|
+
if time.time() >= deadline:
|
|
75
|
+
break
|
|
76
|
+
time.sleep(0.1)
|
|
77
|
+
return {"run_id": run_id, "pid": proc.pid, "log_path": log_path}
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _read_events(path: str) -> List[dict]:
|
|
81
|
+
events = []
|
|
82
|
+
with open(path, "r", encoding="utf-8") as f:
|
|
83
|
+
for line in f:
|
|
84
|
+
line = line.strip()
|
|
85
|
+
if not line:
|
|
86
|
+
continue
|
|
87
|
+
try:
|
|
88
|
+
e = json.loads(line)
|
|
89
|
+
except ValueError:
|
|
90
|
+
continue
|
|
91
|
+
if isinstance(e, dict):
|
|
92
|
+
events.append(e)
|
|
93
|
+
return events
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def v10_status(run_id: str, repo_path: str, validate: Callable[[str], str]) -> dict:
|
|
97
|
+
if not repo_path:
|
|
98
|
+
return {"error": "repo_path is required"}
|
|
99
|
+
try:
|
|
100
|
+
repo = validate(repo_path)
|
|
101
|
+
except Exception as e:
|
|
102
|
+
return {"error": str(e)}
|
|
103
|
+
if run_id:
|
|
104
|
+
if "/" in run_id or "\\" in run_id or run_id.startswith(".") or "\x00" in run_id:
|
|
105
|
+
return {"error": "invalid run_id"}
|
|
106
|
+
else:
|
|
107
|
+
ids = _run_ids(repo)
|
|
108
|
+
if not ids:
|
|
109
|
+
return {"error": "no v10 runs found under .loki/runs"}
|
|
110
|
+
run_id = ids[-1]
|
|
111
|
+
events_path = os.path.join(repo, RUNS_REL, run_id, "events.jsonl")
|
|
112
|
+
if not os.path.isfile(events_path):
|
|
113
|
+
return {"error": f"no events.jsonl for run {run_id}"}
|
|
114
|
+
events = _read_events(events_path)
|
|
115
|
+
open_stages: Dict[str, bool] = {}
|
|
116
|
+
verdict = None
|
|
117
|
+
question = None
|
|
118
|
+
done = False
|
|
119
|
+
usd = 0.0
|
|
120
|
+
saw_cost = False
|
|
121
|
+
unmeasured = False
|
|
122
|
+
for e in events:
|
|
123
|
+
t, stage = e.get("type"), e.get("stage")
|
|
124
|
+
data = e.get("data") if isinstance(e.get("data"), dict) else {}
|
|
125
|
+
if stage:
|
|
126
|
+
if t == "stage.started":
|
|
127
|
+
open_stages[stage] = True
|
|
128
|
+
elif t in _TERMINAL:
|
|
129
|
+
open_stages.pop(stage, None)
|
|
130
|
+
if t == "stage.completed" and stage == "implement" and isinstance(data.get("spec_conflict_reason"), str):
|
|
131
|
+
question = re.sub(r"[\x00-\x1f\x7f]+", " ", data["spec_conflict_reason"])[:500]
|
|
132
|
+
if t == "run.completed":
|
|
133
|
+
done = True
|
|
134
|
+
verdict = data.get("verdict") if isinstance(data.get("verdict"), str) else None
|
|
135
|
+
elif t == "cost":
|
|
136
|
+
saw_cost = True
|
|
137
|
+
if isinstance(data.get("usd"), (int, float)):
|
|
138
|
+
usd += data["usd"]
|
|
139
|
+
else:
|
|
140
|
+
unmeasured = True
|
|
141
|
+
phase = "done" if done else ("+".join(sorted(open_stages)) or "starting")
|
|
142
|
+
result = {
|
|
143
|
+
"run_id": run_id,
|
|
144
|
+
"phase": phase,
|
|
145
|
+
"done": done,
|
|
146
|
+
"verdict": verdict,
|
|
147
|
+
"cost_usd": usd if saw_cost and not unmeasured else None,
|
|
148
|
+
"events": len(events),
|
|
149
|
+
}
|
|
150
|
+
if verdict == "BLOCKED":
|
|
151
|
+
result["blocked_question"] = question or "see the receipt"
|
|
152
|
+
return result
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def v10_verify(receipt_path: str, repo_path: str, validate: Callable[[str], str]) -> dict:
|
|
156
|
+
args = [LOKI_BIN, "verify"]
|
|
157
|
+
try:
|
|
158
|
+
if receipt_path:
|
|
159
|
+
receipt = validate(receipt_path)
|
|
160
|
+
if not os.path.isfile(receipt):
|
|
161
|
+
return {"error": f"receipt not found: {receipt_path}"}
|
|
162
|
+
args.append(receipt)
|
|
163
|
+
cwd = os.path.dirname(receipt)
|
|
164
|
+
elif repo_path:
|
|
165
|
+
cwd = validate(repo_path)
|
|
166
|
+
if not os.path.isdir(cwd):
|
|
167
|
+
return {"error": f"repo_path is not a directory: {repo_path}"}
|
|
168
|
+
else:
|
|
169
|
+
return {"error": "receipt_path or repo_path is required"}
|
|
170
|
+
except Exception as e:
|
|
171
|
+
return {"error": str(e)}
|
|
172
|
+
try:
|
|
173
|
+
p = subprocess.run(
|
|
174
|
+
args, cwd=cwd, env=_env(), stdin=subprocess.DEVNULL,
|
|
175
|
+
capture_output=True, text=True, timeout=V10_VERIFY_TIMEOUT_S,
|
|
176
|
+
)
|
|
177
|
+
except subprocess.TimeoutExpired:
|
|
178
|
+
return {"error": f"loki verify timed out after {V10_VERIFY_TIMEOUT_S}s"}
|
|
179
|
+
except OSError as e:
|
|
180
|
+
return {"error": f"could not run loki verify: {e}"}
|
|
181
|
+
out = ((p.stdout or "") + (p.stderr or "")).strip()
|
|
182
|
+
return {"exit_code": p.returncode, "verified": p.returncode == 0, "output": out[-2000:]}
|
package/package.json
CHANGED