loki-mode 7.41.4 → 7.42.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 +18 -1
- package/SKILL.md +2 -2
- package/VERSION +1 -1
- package/autonomy/completion-council.sh +143 -37
- package/autonomy/hooks/migration-hooks.sh +131 -7
- package/autonomy/loki +54 -43
- package/autonomy/run.sh +1 -1
- package/dashboard/__init__.py +1 -1
- package/dashboard/server.py +102 -0
- package/docs/INSTALLATION.md +70 -1
- package/loki-ts/dist/loki.js +2 -2
- package/mcp/__init__.py +1 -1
- package/mcp/lsp_proxy.py +274 -89
- package/memory/engine.py +15 -3
- package/memory/storage.py +6 -0
- package/package.json +1 -1
- package/plugins/loki-mode/.claude-plugin/plugin.json +1 -1
- package/references/core-workflow.md +7 -0
- package/references/quality-control.md +6 -0
- package/skills/agents.md +1 -0
package/mcp/lsp_proxy.py
CHANGED
|
@@ -9,8 +9,12 @@ language's LSP is not detected, tools targeting that language return a
|
|
|
9
9
|
structured `{"error": ...}` payload but the server itself stays up.
|
|
10
10
|
|
|
11
11
|
Architecture:
|
|
12
|
-
- Stdlib
|
|
13
|
-
atexit, signal, time, urllib).
|
|
12
|
+
- Stdlib for the LSP wire + lifecycle (json, subprocess, shutil,
|
|
13
|
+
threading, os, pathlib, atexit, signal, time, urllib). The only
|
|
14
|
+
non-stdlib import is anyio (a transitive MCP-SDK dependency), used
|
|
15
|
+
solely for the threading bridge that keeps the async tool handlers
|
|
16
|
+
off the event loop; it is guarded so the module still imports on the
|
|
17
|
+
no-SDK degrade path.
|
|
14
18
|
- Lazy lifecycle: an `LSPClient` is spawned on the first tool call
|
|
15
19
|
that needs a given language. Spawn does the LSP `initialize`
|
|
16
20
|
handshake and caches `didOpen` per file URI.
|
|
@@ -43,6 +47,7 @@ Usage:
|
|
|
43
47
|
from __future__ import annotations
|
|
44
48
|
|
|
45
49
|
import atexit
|
|
50
|
+
import functools
|
|
46
51
|
import json
|
|
47
52
|
import logging
|
|
48
53
|
import os
|
|
@@ -54,9 +59,43 @@ import sys
|
|
|
54
59
|
import threading
|
|
55
60
|
import time
|
|
56
61
|
from pathlib import Path
|
|
57
|
-
from typing import Any, Dict, List, Optional, Tuple
|
|
62
|
+
from typing import Any, Callable, Dict, List, Optional, Tuple
|
|
58
63
|
from urllib.parse import quote
|
|
59
64
|
|
|
65
|
+
# v7.x async-safety fix: the MCP SDK dispatches @mcp.tool() handlers
|
|
66
|
+
# concurrently on its anyio event loop via start_soon. Every handler in this
|
|
67
|
+
# module does blocking I/O (LSP handshake up to 10s, request waits up to 5s,
|
|
68
|
+
# diagnostics poll sleeps) with no await, so each one stalls the whole loop.
|
|
69
|
+
# We offload the blocking body to a worker thread via anyio.to_thread.run_sync.
|
|
70
|
+
#
|
|
71
|
+
# anyio is a transitive dependency of the MCP SDK, so it is present whenever
|
|
72
|
+
# the proxy is actually runnable. But this module must still IMPORT cleanly on
|
|
73
|
+
# the _NoopFastMCP degrade path (no SDK, possibly no anyio), so the import is
|
|
74
|
+
# guarded and `_to_thread` falls back to an inline call when anyio is absent.
|
|
75
|
+
# The inline fallback only runs in degrade/test envs that have no event loop
|
|
76
|
+
# to stall, so it is safe there.
|
|
77
|
+
try:
|
|
78
|
+
import anyio.to_thread as _anyio_to_thread # noqa: E402
|
|
79
|
+
|
|
80
|
+
_HAS_ANYIO = True
|
|
81
|
+
except ImportError: # pragma: no cover - degrade path without anyio
|
|
82
|
+
_anyio_to_thread = None # type: ignore[assignment]
|
|
83
|
+
_HAS_ANYIO = False
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
async def _to_thread(func: Callable[..., Any], *args: Any, **kwargs: Any) -> Any:
|
|
87
|
+
"""Run a blocking callable off the event loop.
|
|
88
|
+
|
|
89
|
+
Uses anyio.to_thread.run_sync (the MCP SDK's own threading bridge) when
|
|
90
|
+
anyio is available; otherwise calls inline (degrade/test path, no loop to
|
|
91
|
+
stall). anyio's run_sync is positional-only, so kwargs are bound via
|
|
92
|
+
functools.partial before handing the callable to the worker thread."""
|
|
93
|
+
if _HAS_ANYIO and _anyio_to_thread is not None:
|
|
94
|
+
if kwargs:
|
|
95
|
+
func = functools.partial(func, **kwargs)
|
|
96
|
+
return await _anyio_to_thread.run_sync(func, *args)
|
|
97
|
+
return func(*args, **kwargs)
|
|
98
|
+
|
|
60
99
|
|
|
61
100
|
# ============================================================
|
|
62
101
|
# LOGGING (stderr; stdio transport reserves stdout for JSON-RPC)
|
|
@@ -249,8 +288,38 @@ class LSPClient:
|
|
|
249
288
|
self.extra_args = list(extra_args)
|
|
250
289
|
self.proc: Optional[subprocess.Popen] = None
|
|
251
290
|
self._next_id = 1
|
|
252
|
-
|
|
291
|
+
# v7.x staleness fix: track per-URI document version + on-disk mtime
|
|
292
|
+
# (st_mtime_ns) instead of a plain "opened" set. On a second open of a
|
|
293
|
+
# file whose mtime changed, we send textDocument/didChange with an
|
|
294
|
+
# incremented version (full-sync) so the LSP re-analyzes the new
|
|
295
|
+
# content; without this, did_open early-returned and every query (and
|
|
296
|
+
# diagnostics) saw the first-open snapshot forever. mtime is used
|
|
297
|
+
# (not a content hash) because it is content-independent: it always
|
|
298
|
+
# bumps on save, sidestepping the same-size-edit blind spot that bit
|
|
299
|
+
# the non-git codebase signature (see task #569).
|
|
300
|
+
# _doc_versions: uri -> last LSP document version we sent
|
|
301
|
+
# _doc_mtimes: uri -> st_mtime_ns at the time we last (re)opened
|
|
302
|
+
self._doc_versions: Dict[str, int] = {}
|
|
303
|
+
self._doc_mtimes: Dict[str, int] = {}
|
|
253
304
|
self._lock = threading.Lock()
|
|
305
|
+
# v7.x async-safety fix: with handlers now offloaded to worker threads
|
|
306
|
+
# (anyio.to_thread), two tool calls can run did_open()/request() in
|
|
307
|
+
# parallel. _write_lsp() does write(header)+write(body)+flush(); if two
|
|
308
|
+
# threads interleave there, the Content-Length framing on the LSP's
|
|
309
|
+
# stdin is corrupted. _write_lock serializes ONLY the stdin write
|
|
310
|
+
# sequences (and the did_open check-and-record), never the response
|
|
311
|
+
# wait or the diagnostics poll, so concurrency is preserved everywhere
|
|
312
|
+
# that matters.
|
|
313
|
+
self._write_lock = threading.Lock()
|
|
314
|
+
# v7.x async-safety fix: _next_request_id() is a read-modify-write on
|
|
315
|
+
# _next_id. With handlers offloaded to worker threads, two concurrent
|
|
316
|
+
# request() calls on the same-language client can allocate the SAME id
|
|
317
|
+
# (the GIL can switch between the load and the store), which collides
|
|
318
|
+
# in _response_queues -- one waiter is overwritten and hangs, or a
|
|
319
|
+
# response is routed to the wrong caller. This leaf lock (it never
|
|
320
|
+
# calls out while held, so no ordering concern with the other locks)
|
|
321
|
+
# makes id allocation atomic.
|
|
322
|
+
self._id_lock = threading.Lock()
|
|
254
323
|
self._initialized = False
|
|
255
324
|
# v7.7.14 LSP regression fix (was broken since v7.7.0):
|
|
256
325
|
# publishDiagnostics notifications were dropped by request()'s
|
|
@@ -298,7 +367,9 @@ class LSPClient:
|
|
|
298
367
|
self._response_queues.clear()
|
|
299
368
|
with self._lock:
|
|
300
369
|
self.pending_diagnostics.clear()
|
|
301
|
-
self.
|
|
370
|
+
with self._write_lock:
|
|
371
|
+
self._doc_versions.clear()
|
|
372
|
+
self._doc_mtimes.clear()
|
|
302
373
|
self._initialized = False
|
|
303
374
|
cmd = [self.binary_path] + self.extra_args
|
|
304
375
|
self.proc = subprocess.Popen(
|
|
@@ -427,37 +498,93 @@ class LSPClient:
|
|
|
427
498
|
pass
|
|
428
499
|
|
|
429
500
|
def _next_request_id(self) -> int:
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
501
|
+
# Atomic id allocation: see _id_lock note in __init__. Called from
|
|
502
|
+
# request() and shutdown(), which may run concurrently once handlers
|
|
503
|
+
# are offloaded to worker threads.
|
|
504
|
+
with self._id_lock:
|
|
505
|
+
rid = self._next_id
|
|
506
|
+
self._next_id += 1
|
|
507
|
+
return rid
|
|
433
508
|
|
|
434
509
|
def did_open(self, file_path: str) -> None:
|
|
435
|
-
"""
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
510
|
+
"""Ensure the LSP has the CURRENT content of `file_path` open.
|
|
511
|
+
|
|
512
|
+
First call for a URI: sends `textDocument/didOpen` at version 1 and
|
|
513
|
+
records the file's on-disk mtime. Subsequent call for an already-open
|
|
514
|
+
URI whose on-disk mtime CHANGED since the last (re)open: sends
|
|
515
|
+
`textDocument/didChange` (full document sync) with an incremented
|
|
516
|
+
version so the server re-analyzes the edited file, and clears any
|
|
517
|
+
stale published diagnostics for that URI so the next poll waits for a
|
|
518
|
+
fresh publish instead of returning the pre-edit snapshot. Unchanged
|
|
519
|
+
files early-return (no redundant didChange), preserving the original
|
|
520
|
+
no-op optimization.
|
|
521
|
+
|
|
522
|
+
Reads file contents from disk; silently no-ops if the file is
|
|
523
|
+
unreadable (a subsequent request will fail with a clean LSP error
|
|
524
|
+
rather than crashing the proxy)."""
|
|
439
525
|
uri = _path_to_uri(file_path)
|
|
440
|
-
|
|
526
|
+
try:
|
|
527
|
+
mtime = os.stat(file_path).st_mtime_ns
|
|
528
|
+
except OSError:
|
|
441
529
|
return
|
|
530
|
+
# Fast path: already open and on-disk content unchanged since last
|
|
531
|
+
# (re)open. No write, no lock contention beyond the dict read.
|
|
532
|
+
with self._write_lock:
|
|
533
|
+
prev_mtime = self._doc_mtimes.get(uri)
|
|
534
|
+
already_open = uri in self._doc_versions
|
|
535
|
+
if already_open and prev_mtime == mtime:
|
|
536
|
+
return
|
|
442
537
|
try:
|
|
443
538
|
with open(file_path, 'r', encoding='utf-8', errors='replace') as fh:
|
|
444
539
|
text = fh.read()
|
|
445
540
|
except OSError:
|
|
446
541
|
return
|
|
447
542
|
lsp_id, _suffixes, _bin, _args = LANG_MAP[self.language]
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
'
|
|
455
|
-
'
|
|
456
|
-
'
|
|
543
|
+
with self._write_lock:
|
|
544
|
+
# Re-read under the lock so two racing threads agree on state.
|
|
545
|
+
prev_version = self._doc_versions.get(uri)
|
|
546
|
+
if prev_version is None:
|
|
547
|
+
# First open of this URI.
|
|
548
|
+
_write_lsp(self.proc.stdin, {
|
|
549
|
+
'jsonrpc': '2.0',
|
|
550
|
+
'method': 'textDocument/didOpen',
|
|
551
|
+
'params': {
|
|
552
|
+
'textDocument': {
|
|
553
|
+
'uri': uri,
|
|
554
|
+
'languageId': lsp_id,
|
|
555
|
+
'version': 1,
|
|
556
|
+
'text': text,
|
|
557
|
+
},
|
|
558
|
+
},
|
|
559
|
+
})
|
|
560
|
+
self._doc_versions[uri] = 1
|
|
561
|
+
self._doc_mtimes[uri] = mtime
|
|
562
|
+
return
|
|
563
|
+
if self._doc_mtimes.get(uri) == mtime:
|
|
564
|
+
# Another thread already refreshed to this mtime; nothing to do.
|
|
565
|
+
return
|
|
566
|
+
# File changed on disk since we last opened it: full-sync
|
|
567
|
+
# didChange with an incremented version.
|
|
568
|
+
new_version = prev_version + 1
|
|
569
|
+
_write_lsp(self.proc.stdin, {
|
|
570
|
+
'jsonrpc': '2.0',
|
|
571
|
+
'method': 'textDocument/didChange',
|
|
572
|
+
'params': {
|
|
573
|
+
'textDocument': {
|
|
574
|
+
'uri': uri,
|
|
575
|
+
'version': new_version,
|
|
576
|
+
},
|
|
577
|
+
'contentChanges': [{'text': text}],
|
|
457
578
|
},
|
|
458
|
-
}
|
|
459
|
-
|
|
460
|
-
|
|
579
|
+
})
|
|
580
|
+
self._doc_versions[uri] = new_version
|
|
581
|
+
self._doc_mtimes[uri] = mtime
|
|
582
|
+
# Drop the stale published diagnostics for this URI (outside the
|
|
583
|
+
# write lock, under _lock which guards pending_diagnostics) so that
|
|
584
|
+
# lsp_get_diagnostics polls for the re-published set instead of
|
|
585
|
+
# immediately returning the pre-edit snapshot.
|
|
586
|
+
with self._lock:
|
|
587
|
+
self.pending_diagnostics.pop(uri, None)
|
|
461
588
|
|
|
462
589
|
def request(self, method: str, params: Dict[str, Any], timeout: float = 5.0) -> Dict[str, Any]:
|
|
463
590
|
"""Send a JSON-RPC request and block until its response arrives.
|
|
@@ -472,12 +599,17 @@ class LSPClient:
|
|
|
472
599
|
self._response_queues[rid] = wait_q
|
|
473
600
|
try:
|
|
474
601
|
try:
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
602
|
+
# Serialize the stdin write sequence against did_open() and
|
|
603
|
+
# other concurrent request() calls so the Content-Length
|
|
604
|
+
# framing is never interleaved. The lock is held ONLY for the
|
|
605
|
+
# write, never across the wait below.
|
|
606
|
+
with self._write_lock:
|
|
607
|
+
_write_lsp(self.proc.stdin, {
|
|
608
|
+
'jsonrpc': '2.0',
|
|
609
|
+
'id': rid,
|
|
610
|
+
'method': method,
|
|
611
|
+
'params': params,
|
|
612
|
+
})
|
|
481
613
|
except (BrokenPipeError, OSError) as exc:
|
|
482
614
|
return {'error': {'message': f'LSP I/O failure on write: {exc}'}}
|
|
483
615
|
try:
|
|
@@ -498,7 +630,11 @@ class LSPClient:
|
|
|
498
630
|
if not self.proc or self.proc.poll() is not None:
|
|
499
631
|
return
|
|
500
632
|
try:
|
|
501
|
-
|
|
633
|
+
# Serialize against concurrent request()/did_open() stdin writes
|
|
634
|
+
# via _write_lock so the shutdown + exit frames are not interleaved
|
|
635
|
+
# with another in-flight write. (Switched from _lock, which guards
|
|
636
|
+
# pending_diagnostics, to the dedicated stdin write lock.)
|
|
637
|
+
with self._write_lock:
|
|
502
638
|
_write_lsp(self.proc.stdin, {
|
|
503
639
|
'jsonrpc': '2.0',
|
|
504
640
|
'id': self._next_request_id(),
|
|
@@ -560,7 +696,23 @@ def _get_or_spawn_client(language: str) -> Optional[LSPClient]:
|
|
|
560
696
|
logger.warning("LSP spawn failed for %s: %s", language, exc)
|
|
561
697
|
return None
|
|
562
698
|
with _clients_lock:
|
|
563
|
-
|
|
699
|
+
# Double-check after re-acquiring: a concurrent first-call for the same
|
|
700
|
+
# language (now reachable because handlers run in worker threads via
|
|
701
|
+
# _to_thread) may have spawned and registered a live client while we
|
|
702
|
+
# were handshaking. If so, keep the cached one and shut down our
|
|
703
|
+
# duplicate so we never leak an orphaned LSP subprocess.
|
|
704
|
+
existing = _clients.get(language)
|
|
705
|
+
if existing is not None and existing.proc and existing.proc.poll() is None:
|
|
706
|
+
losing = client
|
|
707
|
+
client = existing
|
|
708
|
+
else:
|
|
709
|
+
losing = None
|
|
710
|
+
_clients[language] = client
|
|
711
|
+
if losing is not None:
|
|
712
|
+
try:
|
|
713
|
+
losing.shutdown()
|
|
714
|
+
except Exception as exc: # best-effort reap; never fail the caller
|
|
715
|
+
logger.warning("LSP duplicate-client shutdown failed for %s: %s", language, exc)
|
|
564
716
|
return client
|
|
565
717
|
|
|
566
718
|
|
|
@@ -776,7 +928,8 @@ async def lsp_find_references(file: str, line: int, character: int,
|
|
|
776
928
|
JSON-encoded string. Success: {"result": [...], "language": ...}.
|
|
777
929
|
Error: {"error": "..."}.
|
|
778
930
|
"""
|
|
779
|
-
result =
|
|
931
|
+
result = await _to_thread(
|
|
932
|
+
_dispatch_lsp,
|
|
780
933
|
file, line, character, 'textDocument/references',
|
|
781
934
|
extra_params={'context': {'includeDeclaration': bool(include_declaration)}},
|
|
782
935
|
)
|
|
@@ -797,7 +950,8 @@ async def lsp_go_to_definition(file: str, line: int, character: int) -> str:
|
|
|
797
950
|
JSON-encoded string with `result` (LSP Location | Location[] |
|
|
798
951
|
LocationLink[]) on success or `error` on failure.
|
|
799
952
|
"""
|
|
800
|
-
result =
|
|
953
|
+
result = await _to_thread(
|
|
954
|
+
_dispatch_lsp,
|
|
801
955
|
file, line, character, 'textDocument/definition',
|
|
802
956
|
)
|
|
803
957
|
return json.dumps(result)
|
|
@@ -818,7 +972,8 @@ async def lsp_symbol_at_position(file: str, line: int, character: int) -> str:
|
|
|
818
972
|
JSON-encoded string with `result` (LSP Hover) on success or
|
|
819
973
|
`error` on failure.
|
|
820
974
|
"""
|
|
821
|
-
result =
|
|
975
|
+
result = await _to_thread(
|
|
976
|
+
_dispatch_lsp,
|
|
822
977
|
file, line, character, 'textDocument/hover',
|
|
823
978
|
)
|
|
824
979
|
return json.dumps(result)
|
|
@@ -894,30 +1049,11 @@ def _workspace_symbol_request(language: str, query: str, limit: int = 50) -> Dic
|
|
|
894
1049
|
return {'result': result[:limit], 'language': language}
|
|
895
1050
|
|
|
896
1051
|
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
The single most useful grounding primitive: an agent about to write
|
|
903
|
-
`flightApi.getStatus()` should call `lsp_check_exists("getStatus")`
|
|
904
|
-
first. If false, it means LSP could not find that name anywhere in
|
|
905
|
-
the workspace; the agent should resolve via find / grep / read
|
|
906
|
-
before writing the call.
|
|
907
|
-
|
|
908
|
-
Args:
|
|
909
|
-
symbol: Symbol name to look for (substring match per LSP spec).
|
|
910
|
-
kind: Optional filter: 'function', 'class', 'method', 'variable',
|
|
911
|
-
etc. If provided, only symbols whose LSP SymbolKind matches
|
|
912
|
-
are counted.
|
|
913
|
-
language: Optional language override. If None, auto-detected
|
|
914
|
-
from workspace markers (package.json, requirements.txt, etc.).
|
|
915
|
-
|
|
916
|
-
Returns:
|
|
917
|
-
JSON-encoded string: {"exists": bool, "matches": N, "samples": [...],
|
|
918
|
-
"language": "...", "elapsed_ms": float}. On no-LSP-available:
|
|
919
|
-
{"error": "...", "exists": null}.
|
|
920
|
-
"""
|
|
1052
|
+
def _lsp_check_exists_blocking(symbol: str, kind: Optional[str] = None,
|
|
1053
|
+
language: Optional[str] = None) -> str:
|
|
1054
|
+
"""Blocking implementation of lsp_check_exists. Runs on a worker thread
|
|
1055
|
+
via _to_thread so the spawn handshake + workspace/symbol request never
|
|
1056
|
+
stall the MCP event loop."""
|
|
921
1057
|
import time as _t
|
|
922
1058
|
t0 = _t.perf_counter()
|
|
923
1059
|
if not symbol or not isinstance(symbol, str):
|
|
@@ -960,22 +1096,36 @@ async def lsp_check_exists(symbol: str, kind: Optional[str] = None,
|
|
|
960
1096
|
|
|
961
1097
|
|
|
962
1098
|
@mcp.tool()
|
|
963
|
-
async def
|
|
964
|
-
|
|
1099
|
+
async def lsp_check_exists(symbol: str, kind: Optional[str] = None,
|
|
1100
|
+
language: Optional[str] = None) -> str:
|
|
1101
|
+
"""Cheap existence check for a symbol in the current workspace.
|
|
965
1102
|
|
|
966
|
-
|
|
967
|
-
`
|
|
968
|
-
|
|
969
|
-
|
|
1103
|
+
The single most useful grounding primitive: an agent about to write
|
|
1104
|
+
`flightApi.getStatus()` should call `lsp_check_exists("getStatus")`
|
|
1105
|
+
first. If false, it means LSP could not find that name anywhere in
|
|
1106
|
+
the workspace; the agent should resolve via find / grep / read
|
|
1107
|
+
before writing the call.
|
|
970
1108
|
|
|
971
1109
|
Args:
|
|
972
|
-
|
|
1110
|
+
symbol: Symbol name to look for (substring match per LSP spec).
|
|
1111
|
+
kind: Optional filter: 'function', 'class', 'method', 'variable',
|
|
1112
|
+
etc. If provided, only symbols whose LSP SymbolKind matches
|
|
1113
|
+
are counted.
|
|
1114
|
+
language: Optional language override. If None, auto-detected
|
|
1115
|
+
from workspace markers (package.json, requirements.txt, etc.).
|
|
973
1116
|
|
|
974
1117
|
Returns:
|
|
975
|
-
JSON: {"
|
|
976
|
-
"
|
|
977
|
-
"
|
|
1118
|
+
JSON-encoded string: {"exists": bool, "matches": N, "samples": [...],
|
|
1119
|
+
"language": "...", "elapsed_ms": float}. On no-LSP-available:
|
|
1120
|
+
{"error": "...", "exists": null}.
|
|
978
1121
|
"""
|
|
1122
|
+
return await _to_thread(_lsp_check_exists_blocking, symbol, kind, language)
|
|
1123
|
+
|
|
1124
|
+
|
|
1125
|
+
def _lsp_get_diagnostics_blocking(file: str) -> str:
|
|
1126
|
+
"""Blocking implementation of lsp_get_diagnostics. Runs on a worker
|
|
1127
|
+
thread via _to_thread; the spawn handshake, didOpen/didChange and the
|
|
1128
|
+
~1s diagnostics poll (time.sleep loop) must not stall the event loop."""
|
|
979
1129
|
import time as _t
|
|
980
1130
|
t0 = _t.perf_counter()
|
|
981
1131
|
abs_file = os.path.abspath(file)
|
|
@@ -1020,24 +1170,30 @@ async def lsp_get_diagnostics(file: str) -> str:
|
|
|
1020
1170
|
|
|
1021
1171
|
|
|
1022
1172
|
@mcp.tool()
|
|
1023
|
-
async def
|
|
1024
|
-
|
|
1025
|
-
"""Fuzzy-search symbols across the entire workspace.
|
|
1173
|
+
async def lsp_get_diagnostics(file: str) -> str:
|
|
1174
|
+
"""Return current LSP diagnostics (errors + warnings) for a file.
|
|
1026
1175
|
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1176
|
+
Diagnostics are published asynchronously by LSP servers via
|
|
1177
|
+
`textDocument/publishDiagnostics`. This tool opens the file (if not
|
|
1178
|
+
already open, or re-syncs it via didChange if edited since first open)
|
|
1179
|
+
and waits up to 1 second for diagnostics to arrive, then returns
|
|
1180
|
+
whatever has been published.
|
|
1031
1181
|
|
|
1032
1182
|
Args:
|
|
1033
|
-
|
|
1034
|
-
limit: Max results to return (default 20, hard cap 100).
|
|
1035
|
-
language: Optional language override.
|
|
1183
|
+
file: Absolute or cwd-relative path to the source file.
|
|
1036
1184
|
|
|
1037
1185
|
Returns:
|
|
1038
|
-
JSON: {"
|
|
1186
|
+
JSON: {"diagnostics": [{severity, message, range, source}, ...],
|
|
1187
|
+
"count_errors": N, "count_warnings": M, "language": "...",
|
|
1039
1188
|
"elapsed_ms": float}.
|
|
1040
1189
|
"""
|
|
1190
|
+
return await _to_thread(_lsp_get_diagnostics_blocking, file)
|
|
1191
|
+
|
|
1192
|
+
|
|
1193
|
+
def _lsp_workspace_symbols_blocking(query: str, limit: int = 20,
|
|
1194
|
+
language: Optional[str] = None) -> str:
|
|
1195
|
+
"""Blocking implementation of lsp_workspace_symbols. Runs on a worker
|
|
1196
|
+
thread via _to_thread (spawn handshake + workspace/symbol request)."""
|
|
1041
1197
|
import time as _t
|
|
1042
1198
|
t0 = _t.perf_counter()
|
|
1043
1199
|
if not isinstance(query, str):
|
|
@@ -1070,20 +1226,31 @@ async def lsp_workspace_symbols(query: str, limit: int = 20,
|
|
|
1070
1226
|
|
|
1071
1227
|
|
|
1072
1228
|
@mcp.tool()
|
|
1073
|
-
async def
|
|
1074
|
-
|
|
1075
|
-
"""
|
|
1076
|
-
|
|
1077
|
-
|
|
1229
|
+
async def lsp_workspace_symbols(query: str, limit: int = 20,
|
|
1230
|
+
language: Optional[str] = None) -> str:
|
|
1231
|
+
"""Fuzzy-search symbols across the entire workspace.
|
|
1232
|
+
|
|
1233
|
+
Use when an agent is hunting for the right name (knows the
|
|
1234
|
+
function/class is about "config loading" but isn't sure of the
|
|
1235
|
+
actual identifier). Returns LSP workspace/symbol results scoped to
|
|
1236
|
+
the detected language (or the language override).
|
|
1078
1237
|
|
|
1079
1238
|
Args:
|
|
1080
|
-
|
|
1239
|
+
query: Symbol query (substring or fuzzy per LSP server impl).
|
|
1240
|
+
limit: Max results to return (default 20, hard cap 100).
|
|
1081
1241
|
language: Optional language override.
|
|
1082
1242
|
|
|
1083
1243
|
Returns:
|
|
1084
|
-
JSON: {"
|
|
1085
|
-
"
|
|
1244
|
+
JSON: {"matches": [...], "count": N, "language": "...",
|
|
1245
|
+
"elapsed_ms": float}.
|
|
1086
1246
|
"""
|
|
1247
|
+
return await _to_thread(_lsp_workspace_symbols_blocking, query, limit, language)
|
|
1248
|
+
|
|
1249
|
+
|
|
1250
|
+
def _lsp_find_definition_by_name_blocking(symbol: str,
|
|
1251
|
+
language: Optional[str] = None) -> str:
|
|
1252
|
+
"""Blocking implementation of lsp_find_definition_by_name. Runs on a
|
|
1253
|
+
worker thread via _to_thread (spawn handshake + workspace/symbol)."""
|
|
1087
1254
|
import time as _t
|
|
1088
1255
|
t0 = _t.perf_counter()
|
|
1089
1256
|
root = _resolve_workspace_root()
|
|
@@ -1115,6 +1282,24 @@ async def lsp_find_definition_by_name(symbol: str,
|
|
|
1115
1282
|
})
|
|
1116
1283
|
|
|
1117
1284
|
|
|
1285
|
+
@mcp.tool()
|
|
1286
|
+
async def lsp_find_definition_by_name(symbol: str,
|
|
1287
|
+
language: Optional[str] = None) -> str:
|
|
1288
|
+
"""Find where a named symbol is defined, without needing a file
|
|
1289
|
+
position upfront. Convenience wrapper: runs workspace/symbol then
|
|
1290
|
+
returns the first result's location.
|
|
1291
|
+
|
|
1292
|
+
Args:
|
|
1293
|
+
symbol: Symbol name to find.
|
|
1294
|
+
language: Optional language override.
|
|
1295
|
+
|
|
1296
|
+
Returns:
|
|
1297
|
+
JSON: {"location": {uri, range} | null, "name": str | null,
|
|
1298
|
+
"language": "...", "elapsed_ms": float}.
|
|
1299
|
+
"""
|
|
1300
|
+
return await _to_thread(_lsp_find_definition_by_name_blocking, symbol, language)
|
|
1301
|
+
|
|
1302
|
+
|
|
1118
1303
|
# ============================================================
|
|
1119
1304
|
# MAIN
|
|
1120
1305
|
# ============================================================
|
package/memory/engine.py
CHANGED
|
@@ -242,6 +242,8 @@ class MemoryEngine:
|
|
|
242
242
|
patterns_data = self.storage.read_json("semantic/patterns.json") or {}
|
|
243
243
|
referenced_ids: set = set()
|
|
244
244
|
for pattern in patterns_data.get("patterns", []):
|
|
245
|
+
if not isinstance(pattern, dict):
|
|
246
|
+
continue
|
|
245
247
|
referenced_ids.update(pattern.get("source_episodes", []))
|
|
246
248
|
|
|
247
249
|
# Scan episodic directories
|
|
@@ -366,11 +368,15 @@ class MemoryEngine:
|
|
|
366
368
|
})
|
|
367
369
|
index["total_memories"] = index.get("total_memories", 0) + 1
|
|
368
370
|
else:
|
|
371
|
+
# Only count a given episode once. On resume/checkpoint the same
|
|
372
|
+
# trace id can be re-saved; without this guard episode_count,
|
|
373
|
+
# total_cost_usd, and total_tokens would inflate on every re-save
|
|
374
|
+
# even though episode_ids is already de-duplicated.
|
|
369
375
|
if episode_id and episode_id not in found.get("episode_ids", []):
|
|
370
376
|
found.setdefault("episode_ids", []).append(episode_id)
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
377
|
+
found["episode_count"] = found.get("episode_count", 0) + 1
|
|
378
|
+
found["total_cost_usd"] = float(found.get("total_cost_usd", 0) or 0) + cost
|
|
379
|
+
found["total_tokens"] = int(found.get("total_tokens", 0) or 0) + tokens
|
|
374
380
|
merged = set(found.get("files_touched", []) or []) | set(files[:20])
|
|
375
381
|
found["files_touched"] = sorted(merged)[:50]
|
|
376
382
|
found["last_accessed"] = now
|
|
@@ -489,6 +495,8 @@ class MemoryEngine:
|
|
|
489
495
|
"""
|
|
490
496
|
patterns_data = self.storage.read_json("semantic/patterns.json") or {}
|
|
491
497
|
for pattern in patterns_data.get("patterns", []):
|
|
498
|
+
if not isinstance(pattern, dict):
|
|
499
|
+
continue
|
|
492
500
|
if pattern.get("id") == pattern_id:
|
|
493
501
|
return self._dict_to_pattern(pattern)
|
|
494
502
|
return None
|
|
@@ -512,6 +520,8 @@ class MemoryEngine:
|
|
|
512
520
|
results: List[SemanticPattern] = []
|
|
513
521
|
|
|
514
522
|
for pattern in patterns_data.get("patterns", []):
|
|
523
|
+
if not isinstance(pattern, dict):
|
|
524
|
+
continue
|
|
515
525
|
# Filter by confidence
|
|
516
526
|
if pattern.get("confidence", 0) < min_confidence:
|
|
517
527
|
continue
|
|
@@ -837,6 +847,8 @@ class MemoryEngine:
|
|
|
837
847
|
# Index semantic patterns
|
|
838
848
|
patterns_data = self.storage.read_json("semantic/patterns.json") or {}
|
|
839
849
|
for pattern in patterns_data.get("patterns", []):
|
|
850
|
+
if not isinstance(pattern, dict):
|
|
851
|
+
continue
|
|
840
852
|
total_memories += 1
|
|
841
853
|
category = pattern.get("category", "general")
|
|
842
854
|
|
package/memory/storage.py
CHANGED
|
@@ -617,6 +617,12 @@ class MemoryStorage:
|
|
|
617
617
|
"patterns": []
|
|
618
618
|
}
|
|
619
619
|
|
|
620
|
+
# Defensive: a pre-existing patterns.json that is valid JSON but
|
|
621
|
+
# lacks the "patterns" key (partial/external write, alternate
|
|
622
|
+
# schema, or a {"version": ...}-only file) would otherwise raise
|
|
623
|
+
# KeyError below and silently lose the save. Ensure the list exists.
|
|
624
|
+
patterns_file.setdefault("patterns", [])
|
|
625
|
+
|
|
620
626
|
# Upsert: update existing pattern or append new
|
|
621
627
|
existing_idx = None
|
|
622
628
|
for i, p in enumerate(patterns_file["patterns"]):
|
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": "7.
|
|
4
|
+
"version": "7.42.0",
|
|
5
5
|
"description": "Loki Mode by Autonomi. Autonomous spec-to-product system: takes a PRD, GitHub issue, OpenAPI/JSON/YAML, or one-line brief to a deployed app via the RARV-C closure loop with 11 quality gates. Provider-agnostic (Claude Code, OpenAI Codex, Cline, Aider).",
|
|
6
6
|
"keywords": [
|
|
7
7
|
"agent",
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json",
|
|
3
3
|
"name": "loki-mode",
|
|
4
4
|
"displayName": "Loki Mode",
|
|
5
|
-
"version": "7.
|
|
5
|
+
"version": "7.42.0",
|
|
6
6
|
"description": "Autonomous spec-to-product build system with a built-in trust layer (RARV-C closure loop, 11 quality gates, completion council). Ships Loki's spec-hardening, drift-detection, and deterministic PR verification commands plus the Loki MCP server.",
|
|
7
7
|
"author": {
|
|
8
8
|
"name": "Autonomi",
|
|
@@ -74,6 +74,13 @@ Every iteration follows this cycle:
|
|
|
74
74
|
|
|
75
75
|
The RARV cycle now closes with an explicit Critique step (RARV-C). After VERIFY, an override council of real provider judges (v7.5.4) issues a binding decision before the iteration is marked complete. See `references/quality-control.md` for the override council protocol.
|
|
76
76
|
|
|
77
|
+
### Verified Completion: Evidence Required (v7.41.1, v7.41.5)
|
|
78
|
+
|
|
79
|
+
Completion is gated on affirmative test evidence, not the absence of a detected failure.
|
|
80
|
+
|
|
81
|
+
- **Test evidence captured before the gate reads it (v7.41.1).** Loki runs the project's own tests and persists `.loki/quality/test-results.json` before the completion evidence gate evaluates it, so absent test evidence can no longer silently pass the test axis. Default-on; opt out with `LOKI_COMPLETION_TEST_CAPTURE=0`. It reuses the quality-ladder run (no double test execution per iteration) and a project with no runner records `{"runner":"none","pass":true}`. Source: `autonomy/run.sh` (`ensure_completion_test_evidence`, `:7236`).
|
|
82
|
+
- **Completion council heuristic fallback defaults to CONTINUE (v7.41.5).** When no AI provider is available for the council, the heuristic member evaluation starts each vote at CONTINUE and flips to COMPLETE only when no failure is detected AND affirmative positive evidence is present (the same non-red `test-results.json` signal the evidence hard gate uses). An empty `.loki/` with no test evidence no longer clears the threshold on "absence of failure". Legitimate finished projects (passing or genuinely no-test) still vote COMPLETE. Source: `autonomy/completion-council.sh` (`council_evaluate_member`, `:2044`-`:2063`, `:2127`-`:2140`).
|
|
83
|
+
|
|
77
84
|
---
|
|
78
85
|
|
|
79
86
|
## CONTINUITY.md - Working Memory Protocol
|
|
@@ -287,6 +287,12 @@ Task(subagent_type="general-purpose", model="opus",
|
|
|
287
287
|
- ALWAYS re-run ALL 3 reviewers after fixes (not just the one that found the issue)
|
|
288
288
|
- Wait for all reviews to complete before aggregating results
|
|
289
289
|
|
|
290
|
+
### Inconclusive Reviews Block (v7.41.1)
|
|
291
|
+
|
|
292
|
+
A code-review round must produce real verdicts to pass. `run_code_review` counts only reviewer outputs that exist, are non-empty, and carry a recognized `VERDICT:` line. If every reviewer returns no usable verdict (all NO_OUTPUT or unparseable), the round is treated as INCONCLUSIVE and BLOCKS rather than silently passing with zero findings. A bounded one-shot retry runs first; the block is opt-out via `LOKI_REVIEW_INCONCLUSIVE_BLOCK=0` (records, never blocks). APPROVE / PASS-with-concerns still pass.
|
|
293
|
+
|
|
294
|
+
The reviewer-prompt diff excludes `.loki/` and `.git/` via git pathspec (`git diff <sha>..HEAD -- . ':(exclude).loki/'`). This mirrors the completion evidence gate and prevents a `.loki/`-tracked repo from ballooning the diff to the point that the reviewer model overflows and returns empty (the original NO_OUTPUT cause). Source: `autonomy/run.sh` (`run_code_review`, diff at `:2578`, inconclusive handling at `:8134`-`:8270`).
|
|
295
|
+
|
|
290
296
|
---
|
|
291
297
|
|
|
292
298
|
## Structured Prompting for Subagents
|
package/skills/agents.md
CHANGED
|
@@ -135,6 +135,7 @@ Task(
|
|
|
135
135
|
- WAIT for all 3 before aggregating
|
|
136
136
|
- IF unanimous PASS: run Devil's Advocate reviewer (anti-sycophancy)
|
|
137
137
|
- Critical/High = BLOCK, Medium = TODO, Low = informational
|
|
138
|
+
- IF every reviewer returns no usable verdict (all NO_OUTPUT / unparseable): the round is INCONCLUSIVE and BLOCKS, never silently passes (v7.41.1, bounded retry first; opt out `LOKI_REVIEW_INCONCLUSIVE_BLOCK=0`). The reviewer diff excludes `.loki/` and `.git/` so a tracked `.loki/` cannot overflow the prompt into the empty-output that caused the original silent pass. See `skills/quality-gates.md` for the env knobs.
|
|
138
139
|
|
|
139
140
|
---
|
|
140
141
|
|