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.
Files changed (113) hide show
  1. package/README.md +44 -9
  2. package/SKILL.md +2 -2
  3. package/VERSION +1 -1
  4. package/autonomy/lib/backlog.py +167 -36
  5. package/autonomy/lib/workspace.py +384 -0
  6. package/autonomy/lib/workspace_comment.py +91 -0
  7. package/autonomy/lib/worktree_prep.py +143 -0
  8. package/autonomy/loki +96 -3
  9. package/bin/loki +3 -3
  10. package/completions/_loki +2 -0
  11. package/completions/loki.bash +1 -2
  12. package/dashboard/__init__.py +1 -1
  13. package/dashboard/api_start.py +26 -1
  14. package/docs/INSTALLATION.md +1 -1
  15. package/docs/WANG-PRINCIPLES-PLAN.md +1 -1
  16. package/docs/WORKSPACES.md +36 -0
  17. package/docs/alternative-installations.md +3 -1
  18. package/docs/control-plane-container.md +50 -0
  19. package/docs/dashboard-guide.md +4 -0
  20. package/docs/dev/architecture-reference.md +4 -0
  21. package/docs/dev/project-structure.md +1 -1
  22. package/docs/exit-codes.md +1 -0
  23. package/docs/slack.md +34 -0
  24. package/docs/trackers.md +27 -0
  25. package/docs/v10/BOARD.md +74 -14
  26. package/docs/v10/CONTROL-PLANE.md +17 -6
  27. package/docs/v10/D51-PHASE-B.md +3 -1
  28. package/docs/v10/D61-SPEED.md +11 -2
  29. package/docs/v10/D63-CARDS.md +354 -0
  30. package/docs/v10/DECISIONS.md +63 -0
  31. package/docs/v10/DEPS.md +6 -2
  32. package/docs/v10/E2E-D65.md +87 -0
  33. package/docs/v10/HANDOFF-NEXT-WEEK.md +10 -4
  34. package/docs/v10/PROGRESS.md +63 -0
  35. package/docs/v10/ROADMAP-D63.md +35 -0
  36. package/loki-ts/dist/loki.js +612 -556
  37. package/mcp/__init__.py +1 -1
  38. package/mcp/server.py +76 -0
  39. package/mcp/tests/test_v10_tools.py +193 -0
  40. package/mcp/v10_tools.py +182 -0
  41. package/package.json +1 -1
  42. package/packages/control-plane/dist/server.js +131 -9
  43. package/packages/control-plane/drizzle/0001_group_id.sql +3 -0
  44. package/packages/control-plane/drizzle/meta/_journal.json +8 -1
  45. package/packages/control-plane/src/db/schema.ts +3 -0
  46. package/packages/control-plane/src/server/answer.ts +33 -0
  47. package/packages/control-plane/src/server/app.ts +22 -2
  48. package/packages/control-plane/src/server/auth.ts +51 -0
  49. package/packages/control-plane/src/server/runs.ts +6 -3
  50. package/packages/control-plane/src/server/serve.ts +13 -3
  51. package/packages/control-plane/src/shipper/discover.ts +29 -0
  52. package/packages/control-plane/ui/dist/assets/{index-BH3LtKcG.css → index-CFfrqCx2.css} +1 -1
  53. package/packages/control-plane/ui/dist/assets/index-Jt31nrK-.js +114 -0
  54. package/packages/control-plane/ui/dist/index.html +2 -2
  55. package/plugins/loki-mode/.claude-plugin/plugin.json +1 -1
  56. package/web-app/dist/assets/{AdminPage-DyjnUcaP.js → AdminPage-CZKZLuWU.js} +1 -1
  57. package/web-app/dist/assets/{Avatar-CY0sQNao.js → Avatar-Dcy9kO2E.js} +1 -1
  58. package/web-app/dist/assets/{Badge-BmwTRu72.js → Badge-DUotUSyx.js} +1 -1
  59. package/web-app/dist/assets/{Button-BRf5aTlb.js → Button-CFz_zfMZ.js} +1 -1
  60. package/web-app/dist/assets/{CockpitPage-RR94HuxS.js → CockpitPage-BhRw_oiM.js} +1 -1
  61. package/web-app/dist/assets/{ComparePage-CFWAuPRO.js → ComparePage-DfnsRTTC.js} +1 -1
  62. package/web-app/dist/assets/{ErrorBoundary-4cDADrG-.js → ErrorBoundary-Dn0gUu3D.js} +1 -1
  63. package/web-app/dist/assets/{EvidenceReceiptPanel-ChPFx2eM.js → EvidenceReceiptPanel-Co-xi282.js} +1 -1
  64. package/web-app/dist/assets/{GitHubIssuesPanel-B0TAjyeW.js → GitHubIssuesPanel-CoCpfexq.js} +1 -1
  65. package/web-app/dist/assets/{GitHubPRsPanel-BbszEwW9.js → GitHubPRsPanel-Dg0Eg6F_.js} +1 -1
  66. package/web-app/dist/assets/{HomePage-DrBgzs1d.js → HomePage-BysxL4mV.js} +2 -2
  67. package/web-app/dist/assets/{LoginPage-Bu7b2DGz.js → LoginPage-CQ366p_d.js} +1 -1
  68. package/web-app/dist/assets/{MagicPage-BFEYp631.js → MagicPage-DD7YcbNq.js} +1 -1
  69. package/web-app/dist/assets/{MetricsPage-eIe2naVR.js → MetricsPage-BYz49xQj.js} +1 -1
  70. package/web-app/dist/assets/{NotFoundPage-BVWYkNt9.js → NotFoundPage-CKs09MeO.js} +1 -1
  71. package/web-app/dist/assets/{ProjectPage-zmxyu732.js → ProjectPage-Ci2BIA_D.js} +3 -3
  72. package/web-app/dist/assets/{ProjectsPage-Dq5NdCak.js → ProjectsPage-ClwMH0eu.js} +1 -1
  73. package/web-app/dist/assets/{SettingsPage-CCrJOMe0.js → SettingsPage-Cm0rCX-y.js} +1 -1
  74. package/web-app/dist/assets/{ShowcasePage-n-Ipzp01.js → ShowcasePage-CxvESt8X.js} +1 -1
  75. package/web-app/dist/assets/{SystemSettingsPage-CP_rjYXK.js → SystemSettingsPage-W_CGYPOY.js} +1 -1
  76. package/web-app/dist/assets/{TeamsPage-B_P9DepW.js → TeamsPage-feVvRvys.js} +1 -1
  77. package/web-app/dist/assets/{TemplatesPage-h-3A6PQT.js → TemplatesPage-C-pUWMmF.js} +1 -1
  78. package/web-app/dist/assets/{TerminalOutput-pD0nJ1BU.js → TerminalOutput-j5ibjKY3.js} +1 -1
  79. package/web-app/dist/assets/{activity-ChFwzT-d.js → activity-TTdVYMa_.js} +1 -1
  80. package/web-app/dist/assets/{bell-BX-eK8Gu.js → bell-DeoTFwpv.js} +1 -1
  81. package/web-app/dist/assets/{bot-fj5PkPtR.js → bot-B5SghxqU.js} +1 -1
  82. package/web-app/dist/assets/{check-BGAbHlvD.js → check-k8SzsdbE.js} +1 -1
  83. package/web-app/dist/assets/{chevron-left-BzFUxHgb.js → chevron-left-DHnEllFz.js} +1 -1
  84. package/web-app/dist/assets/{circle-alert-BQrg5DWl.js → circle-alert-DW_SLdcB.js} +1 -1
  85. package/web-app/dist/assets/{clock-CTu9lkVo.js → clock-Bmoaxy09.js} +1 -1
  86. package/web-app/dist/assets/{cloud-DrIENRiE.js → cloud-eg3tzXTh.js} +1 -1
  87. package/web-app/dist/assets/{code-xml-PaoVpfur.js → code-xml-VAfXihhD.js} +1 -1
  88. package/web-app/dist/assets/{database-DMmkAotn.js → database-CY3_-5aK.js} +1 -1
  89. package/web-app/dist/assets/{dollar-sign-B2LQdBoX.js → dollar-sign-DYC0OLlg.js} +1 -1
  90. package/web-app/dist/assets/{file-code-corner-Dfv1XXeR.js → file-code-corner-0X3SveKz.js} +1 -1
  91. package/web-app/dist/assets/{file-plus-D2Wzf2wd.js → file-plus-B_oh_ukl.js} +1 -1
  92. package/web-app/dist/assets/{globe-DxotVWt8.js → globe-F0tLA8WJ.js} +1 -1
  93. package/web-app/dist/assets/{hammer-Brfy5ZrJ.js → hammer-DrlvBmfW.js} +1 -1
  94. package/web-app/dist/assets/{index-BfhHMwi8.js → index-YihVaZAj.js} +3 -3
  95. package/web-app/dist/assets/{layers-DfxSWDvp.js → layers-CbJfn5UC.js} +1 -1
  96. package/web-app/dist/assets/{loader-circle-CxIrzNwx.js → loader-circle-CpmOggou.js} +1 -1
  97. package/web-app/dist/assets/{lock-m6nzhtpU.js → lock-BGJ6QWrI.js} +1 -1
  98. package/web-app/dist/assets/{package-ri9oE9Jc.js → package-BTmH4Xto.js} +1 -1
  99. package/web-app/dist/assets/{plus-DWeTAMVV.js → plus-mKQ7hz-8.js} +1 -1
  100. package/web-app/dist/assets/{refresh-cw-BGMSpaMh.js → refresh-cw-vPetpLsm.js} +1 -1
  101. package/web-app/dist/assets/{rotate-ccw-DD1xBD_b.js → rotate-ccw-DiJ8GFhj.js} +1 -1
  102. package/web-app/dist/assets/{scroll-text-BCCR_AN9.js → scroll-text-DpPQIlQR.js} +1 -1
  103. package/web-app/dist/assets/{server-CmMWPIjS.js → server-CiaV4drx.js} +1 -1
  104. package/web-app/dist/assets/{shield-alert-BZZmBVEJ.js → shield-alert-BaaQqyco.js} +1 -1
  105. package/web-app/dist/assets/{thumbs-up-OaxbNRXu.js → thumbs-up-2zNCKp0Q.js} +1 -1
  106. package/web-app/dist/assets/{trash-2-DmcSzTfr.js → trash-2-CiVDx5Co.js} +1 -1
  107. package/web-app/dist/assets/{trending-down-BmfXLykz.js → trending-down-tcxvw5BH.js} +1 -1
  108. package/web-app/dist/assets/{trending-up-Djec3jIq.js → trending-up-C2bv2_v_.js} +1 -1
  109. package/web-app/dist/assets/{upload-BN8dXIlF.js → upload-DS4VPUAp.js} +1 -1
  110. package/web-app/dist/assets/{usePolling-Cu4yi04I.js → usePolling-JggBcVln.js} +1 -1
  111. package/web-app/dist/assets/{user-IBI-k10o.js → user-CvavvOxM.js} +1 -1
  112. package/web-app/dist/index.html +1 -1
  113. package/packages/control-plane/ui/dist/assets/index-B6EiyukW.js +0 -114
package/mcp/__init__.py CHANGED
@@ -75,4 +75,4 @@ try:
75
75
  except ImportError:
76
76
  __all__ = ['mcp']
77
77
 
78
- __version__ = '10.6.7'
78
+ __version__ = '10.6.9'
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()
@@ -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
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "loki-mode",
3
3
  "mcpName": "io.github.asklokesh/loki-mode",
4
- "version": "10.6.7",
4
+ "version": "10.6.9",
5
5
  "description": "An autonomous software factory that knows what it is supposed to deliver, and proves it did.",
6
6
  "keywords": [
7
7
  "agent",