loki-mode 7.50.0 → 7.51.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/SKILL.md CHANGED
@@ -3,7 +3,7 @@ name: loki-mode
3
3
  description: Autonomous spec-driven build system with a built-in trust layer. It does not call work done until it is verified (RARV-C closure loop, 8 quality gates, completion council, verified-completion evidence gate). Triggers on "Loki Mode". Takes a spec (PRD, GitHub issue, OpenAPI doc, etc.) to deployed product with minimal human intervention. Provider-agnostic. Requires --dangerously-skip-permissions flag.
4
4
  ---
5
5
 
6
- # Loki Mode v7.50.0
6
+ # Loki Mode v7.51.0
7
7
 
8
8
  **You are an autonomous agent. You make decisions. You do not ask questions. You do not stop.**
9
9
 
@@ -407,4 +407,4 @@ See `CHANGELOG.md` entries [7.5.7], [7.5.8], [7.5.13] for the per-fix list and r
407
407
 
408
408
  ---
409
409
 
410
- **v7.50.0 | [Autonomi](https://www.autonomi.dev/) flagship product | ~260 lines core**
410
+ **v7.51.0 | [Autonomi](https://www.autonomi.dev/) flagship product | ~260 lines core**
package/VERSION CHANGED
@@ -1 +1 @@
1
- 7.50.0
1
+ 7.51.0
package/autonomy/run.sh CHANGED
@@ -1480,7 +1480,40 @@ check_policy() {
1480
1480
  elif [ $exit_code -eq 2 ]; then
1481
1481
  log_warn "Policy requires APPROVAL: $result"
1482
1482
  audit_agent_action "policy_approval_required" "Policy requires approval" "enforcement=$enforcement_point"
1483
- # Log but proceed (full approval flow is P1-3 scope)
1483
+ # P3-3 (v7.51.0): honor the approval requirement when the operator has
1484
+ # opted into enforcement. This is OPT-IN and changes NOTHING for existing
1485
+ # users: the wait fires only when staged autonomy is on
1486
+ # (LOKI_STAGED_AUTONOMY=true) or the explicit
1487
+ # LOKI_POLICY_APPROVAL_ENFORCE=1 knob is set. Otherwise it stays advisory
1488
+ # (log + proceed), preserving the historical default behavior. The wait
1489
+ # reuses the same .loki/signals/ file-signal mechanism as staged-autonomy
1490
+ # plan approval (check_staged_autonomy), extended with a reject arm so an
1491
+ # operator can deny (deny == policy DENIED == return 1).
1492
+ if [ "$STAGED_AUTONOMY" = "true" ] || [ "${LOKI_POLICY_APPROVAL_ENFORCE:-0}" = "1" ]; then
1493
+ local _approve_sig=".loki/signals/POLICY_APPROVED"
1494
+ local _reject_sig=".loki/signals/POLICY_REJECTED"
1495
+ log_warn "Policy enforcement: waiting for approval at enforcement point '$enforcement_point'."
1496
+ log_warn " Approve: create $_approve_sig | Reject: create $_reject_sig"
1497
+ audit_agent_action "policy_approval_wait" "Waiting for policy approval signal" "enforcement=$enforcement_point"
1498
+ while [ ! -f "$_approve_sig" ] && [ ! -f "$_reject_sig" ]; do
1499
+ sleep 5
1500
+ done
1501
+ if [ -f "$_reject_sig" ]; then
1502
+ rm -f "$_reject_sig" "$_approve_sig" 2>/dev/null || true
1503
+ log_error "Policy REJECTED by operator at enforcement point '$enforcement_point'"
1504
+ audit_agent_action "policy_approval_rejected" "Operator rejected policy approval" "enforcement=$enforcement_point"
1505
+ emit_event_json "policy_denied" \
1506
+ "enforcement=$enforcement_point" \
1507
+ "result=operator_rejected"
1508
+ return 1
1509
+ fi
1510
+ rm -f "$_approve_sig" 2>/dev/null || true
1511
+ log_info "Policy approved by operator at enforcement point '$enforcement_point'; continuing."
1512
+ audit_agent_action "policy_approval_granted" "Operator approved policy" "enforcement=$enforcement_point"
1513
+ return 0
1514
+ fi
1515
+ # Default (no staged autonomy, no enforce knob): advisory only -- log and
1516
+ # proceed. This preserves the historical behavior for existing users.
1484
1517
  return 0
1485
1518
  fi
1486
1519
  return 0
@@ -7757,6 +7790,55 @@ os.replace(tmp, out)
7757
7790
  else
7758
7791
  log_info "Coverage: not measured (${COVERAGE_REASON:-unknown}); pass-through, not blocking"
7759
7792
  fi
7793
+ else
7794
+ # P3-5/coverage-honesty (v7.51.0): measurement is OPT-IN (it re-runs the
7795
+ # suite instrumented, which would double every test run -- a UX
7796
+ # regression for an autonomous loop). At default-off we deliberately do
7797
+ # NOT measure, but we STILL write a coverage fact so the run manifest /
7798
+ # reproducibility record always has one honest coverage shape. This is
7799
+ # the "missing-artifact" fix, not a hollow gate: measured=false, pct=null,
7800
+ # blocked=false, with an explicit reason. ZERO runtime (no instrumented
7801
+ # re-run). Reuses the EXACT python3 writer + schema used at default-on so
7802
+ # consumers see a single shape. Single-pass, never blocks.
7803
+ _LOKI_COV_MEASURED="false" \
7804
+ _LOKI_COV_PCT="" \
7805
+ _LOKI_COV_TOOL="none" \
7806
+ _LOKI_COV_REASON="not requested (set LOKI_COVERAGE_GATE=1 to measure)" \
7807
+ _LOKI_COV_MIN="$min_coverage" \
7808
+ _LOKI_COV_ENFORCED="0" \
7809
+ _LOKI_COV_BLOCKED="false" \
7810
+ _LOKI_COV_RUNNER="$test_runner" \
7811
+ _LOKI_COV_OUT="$quality_dir/coverage.json" \
7812
+ python3 -c "
7813
+ import json, os, tempfile
7814
+ out=os.environ['_LOKI_COV_OUT']
7815
+ measured = os.environ.get('_LOKI_COV_MEASURED','false') == 'true'
7816
+ pct_raw = os.environ.get('_LOKI_COV_PCT','')
7817
+ try:
7818
+ pct = float(pct_raw) if (measured and pct_raw != '') else None
7819
+ except ValueError:
7820
+ pct = None
7821
+ def b(v): return os.environ.get(v,'false') == 'true'
7822
+ def i(v):
7823
+ try: return int(float(os.environ.get(v,'0')))
7824
+ except (TypeError, ValueError): return 0
7825
+ rec = {
7826
+ 'measured': measured,
7827
+ 'pct': pct,
7828
+ 'tool': os.environ.get('_LOKI_COV_TOOL','none'),
7829
+ 'runner': os.environ.get('_LOKI_COV_RUNNER','none'),
7830
+ 'threshold': i('_LOKI_COV_MIN'),
7831
+ 'enforced': os.environ.get('_LOKI_COV_ENFORCED','0') == '1',
7832
+ 'blocked': b('_LOKI_COV_BLOCKED'),
7833
+ 'reason': os.environ.get('_LOKI_COV_REASON','') if not measured else '',
7834
+ 'timestamp': __import__('datetime').datetime.utcnow().strftime('%Y-%m-%dT%H:%M:%SZ'),
7835
+ }
7836
+ d=os.path.dirname(out)
7837
+ fd, tmp=tempfile.mkstemp(dir=d, suffix='.json')
7838
+ with os.fdopen(fd,'w') as f:
7839
+ json.dump(rec, f, indent=2)
7840
+ os.replace(tmp, out)
7841
+ " 2>/dev/null || true
7760
7842
  fi
7761
7843
 
7762
7844
  if [ "$test_passed" = "true" ]; then
@@ -7854,6 +7936,65 @@ ensure_completion_test_evidence() {
7854
7936
  return 0
7855
7937
  }
7856
7938
 
7939
+ # P1-1 (v7.51.0): ADVISORY consumer for the evidence-gate detail record that
7940
+ # completion-council.sh:_write_evidence_details writes on EVERY evidence-gate run
7941
+ # (pass and block) to .loki/council/evidence-gate-details.json. Until now run.sh
7942
+ # had ZERO consumers of that file -- the audit record was durable but invisible
7943
+ # to the operator and to the next-iteration prompt. This surfaces a one-line
7944
+ # advisory summary (verdict + diff axis + tests axis). It NEVER blocks and NEVER
7945
+ # introduces a new gate (the evidence gate itself already blocks; this is purely
7946
+ # visibility). Absent or malformed file -> degrade silently (no error, no block).
7947
+ surface_evidence_gate_details() {
7948
+ local _det_file="${TARGET_DIR:-.}/.loki/council/evidence-gate-details.json"
7949
+ [ -f "$_det_file" ] || return 0
7950
+ local _summary
7951
+ _summary=$(_LOKI_EGD_FILE="$_det_file" python3 -c "
7952
+ import json, os, sys
7953
+ try:
7954
+ with open(os.environ['_LOKI_EGD_FILE']) as f:
7955
+ d = json.load(f)
7956
+ except Exception:
7957
+ sys.exit(0)
7958
+ if not isinstance(d, dict):
7959
+ sys.exit(0)
7960
+ verdict = d.get('verdict', 'unknown')
7961
+ diff = d.get('diff', {}) if isinstance(d.get('diff'), dict) else {}
7962
+ tests = d.get('tests', {}) if isinstance(d.get('tests'), dict) else {}
7963
+ diff_ok = diff.get('ok')
7964
+ tests_ok = tests.get('ok')
7965
+ runner = tests.get('runner', 'none')
7966
+ parts = ['verdict=%s' % verdict]
7967
+ parts.append('diff_ok=%s' % diff_ok)
7968
+ parts.append('tests_ok=%s (runner=%s)' % (tests_ok, runner))
7969
+ if diff.get('inconclusive'):
7970
+ parts.append('diff_inconclusive=%s' % (diff.get('inconclusive_reason') or 'yes'))
7971
+ if tests.get('inconclusive'):
7972
+ parts.append('tests_inconclusive=%s' % (tests.get('inconclusive_reason') or 'yes'))
7973
+ print(' '.join(str(p) for p in parts))
7974
+ " 2>/dev/null) || return 0
7975
+ [ -n "$_summary" ] || return 0
7976
+ if printf '%s' "$_summary" | grep -q "verdict=block"; then
7977
+ log_warn "[Council] Evidence-gate details: $_summary"
7978
+ else
7979
+ log_info "[Council] Evidence-gate details: $_summary"
7980
+ fi
7981
+ return 0
7982
+ }
7983
+
7984
+ # P1-1 (v7.51.0): wrapper that runs the evidence gate, then surfaces its detail
7985
+ # record on BOTH the pass and block paths, and returns the gate's exact rc. This
7986
+ # preserves the elif chain's `! council_evidence_gate` semantics byte-for-byte
7987
+ # (fall-through on pass so the held-out and assumption gates downstream still
7988
+ # evaluate; block on a 1). The surface call is advisory-only and never affects
7989
+ # the returned rc. The detail file is fresh here -- _write_evidence_details ran
7990
+ # inside council_evidence_gate just above on this same iteration.
7991
+ _evidence_gate_and_surface() {
7992
+ local _rc=0
7993
+ council_evidence_gate || _rc=$?
7994
+ surface_evidence_gate_details || true
7995
+ return $_rc
7996
+ }
7997
+
7857
7998
  # ============================================================================
7858
7999
  # Documentation Staleness Check (v6.75.0)
7859
8000
  # Checks if generated documentation is stale relative to HEAD
@@ -14818,6 +14959,88 @@ if __name__ == "__main__":
14818
14959
  log_warn "Mutation integrity gate FAILED ($mt_count consecutive) - HIGH test-fitting detected"
14819
14960
  fi
14820
14961
  fi
14962
+ # LSP diagnostics gate (P1-5 bash-route parity, v7.51.0). Closes the
14963
+ # parity gap: the Bun route ships runLSPDiagnostics
14964
+ # (loki-ts/src/runner/quality_gates.ts) with a route-neutral Python
14965
+ # writer (mcp/lsp_proxy.py); the bash route had NO writer/reader.
14966
+ # This block runs the SAME writer and mirrors the TS blocking
14967
+ # semantics byte-for-byte:
14968
+ # - Gate is OPT-IN: default OFF. Enabled by LOKI_GATE_LSP_DIAGNOSTICS=true
14969
+ # (the single toggle; mirrors flag("LOKI_GATE_LSP_DIAGNOSTICS", false)
14970
+ # at quality_gates.ts:1717). No second knob.
14971
+ # - When enabled, count_errors > 0 -> BLOCK (mirrors
14972
+ # "if (errorCount > 0) { passed: false }" at quality_gates.ts:1667).
14973
+ # - warnings only -> advisory PASS (quality_gates.ts:1673).
14974
+ # - artifact absent/malformed -> honest pass-through, NEVER block
14975
+ # (quality_gates.ts:1646 returns passed:true on null artifact).
14976
+ # The writer is OPT-OUT-able with LOKI_GATE_LSP_WRITER=0 (operator can
14977
+ # supply a pre-built artifact), matching the TS escape hatch
14978
+ # (quality_gates.ts:1630). cwd must be the install dir (PROJECT_DIR =
14979
+ # $SCRIPT_DIR/.. ) so `-m mcp.lsp_proxy` imports, while --root points
14980
+ # at the TARGET project the loop is building (mirrors
14981
+ # runLSPDiagnosticsWriter: cwd=REPO_ROOT, --root=ctx.cwd).
14982
+ if [ "${LOKI_GATE_LSP_DIAGNOSTICS:-false}" = "true" ] && [ "$ITERATION_COUNT" -gt 0 ]; then
14983
+ log_info "Quality gate: LSP diagnostics..."
14984
+ # WRITER: route-neutral Python, same program as the Bun route.
14985
+ if [ "${LOKI_GATE_LSP_WRITER:-1}" != "0" ]; then
14986
+ ( cd "$PROJECT_DIR" && LOKI_DIR="${TARGET_DIR:-.}/.loki" python3 -m mcp.lsp_proxy --write-diagnostics --root "${TARGET_DIR:-.}" ) >/dev/null 2>&1 || true
14987
+ fi
14988
+ # READER: read counts, mirror TS block policy.
14989
+ local _lsp_file="${TARGET_DIR:-.}/.loki/quality/lsp-diagnostics.json"
14990
+ local _lsp_verdict="absent"
14991
+ if [ -f "$_lsp_file" ]; then
14992
+ _lsp_verdict=$(_LOKI_LSP_FILE="$_lsp_file" python3 -c "
14993
+ import json, os, sys
14994
+ try:
14995
+ with open(os.environ['_LOKI_LSP_FILE']) as f:
14996
+ d = json.load(f)
14997
+ except Exception:
14998
+ print('absent'); sys.exit(0)
14999
+ if not isinstance(d, dict):
15000
+ print('absent'); sys.exit(0)
15001
+ diags = d.get('diagnostics') if isinstance(d.get('diagnostics'), list) else []
15002
+ ce = d.get('count_errors')
15003
+ cw = d.get('count_warnings')
15004
+ errors = ce if isinstance(ce, int) else sum(1 for x in diags if isinstance(x, dict) and x.get('severity') == 1)
15005
+ warns = cw if isinstance(cw, int) else sum(1 for x in diags if isinstance(x, dict) and x.get('severity') == 2)
15006
+ if errors > 0:
15007
+ print('block %d %d' % (errors, warns))
15008
+ elif warns > 0:
15009
+ print('warn %d %d' % (errors, warns))
15010
+ else:
15011
+ print('clean 0 0')
15012
+ " 2>/dev/null) || _lsp_verdict="absent"
15013
+ [ -n "$_lsp_verdict" ] || _lsp_verdict="absent"
15014
+ fi
15015
+ case "$_lsp_verdict" in
15016
+ block*)
15017
+ local _lsp_e _lsp_w
15018
+ _lsp_e=$(printf '%s' "$_lsp_verdict" | awk '{print $2}')
15019
+ _lsp_w=$(printf '%s' "$_lsp_verdict" | awk '{print $3}')
15020
+ local lsp_count
15021
+ lsp_count=$(track_gate_failure "lsp_diagnostics")
15022
+ gate_failures="${gate_failures}lsp_diagnostics,"
15023
+ log_warn "LSP diagnostics gate FAILED ($lsp_count consecutive) - ${_lsp_e} error(s), ${_lsp_w} warning(s); LSP reports compiler/type errors"
15024
+ ;;
15025
+ warn*)
15026
+ local _lsp_w2
15027
+ _lsp_w2=$(printf '%s' "$_lsp_verdict" | awk '{print $3}')
15028
+ clear_gate_failure "lsp_diagnostics"
15029
+ log_info "LSP diagnostics: 0 errors, ${_lsp_w2} warning(s) (advisory)"
15030
+ ;;
15031
+ clean*)
15032
+ clear_gate_failure "lsp_diagnostics"
15033
+ log_info "LSP diagnostics: 0 errors, 0 warnings"
15034
+ ;;
15035
+ *)
15036
+ # Absent or malformed artifact: honest pass-through, never
15037
+ # block (mirrors quality_gates.ts:1646). Do not fabricate
15038
+ # a clean verdict from absence.
15039
+ clear_gate_failure "lsp_diagnostics"
15040
+ log_info "LSP diagnostics: no lsp-diagnostics.json artifact (lsp not available) -- gate did not run"
15041
+ ;;
15042
+ esac
15043
+ fi
14821
15044
  # Code review gate (upgraded from advisory, with escalation)
14822
15045
  if [ "$PHASE_CODE_REVIEW" = "true" ] && [ "$ITERATION_COUNT" -gt 0 ]; then
14823
15046
  log_info "Quality gate: code review..."
@@ -15096,7 +15319,7 @@ if __name__ == "__main__":
15096
15319
  # LOKI_EVIDENCE_GATE=0 (council_evidence_gate returns 0 immediately
15097
15320
  # when disabled, so this branch never fires). Gate output (reason +
15098
15321
  # opt-out hint) is printed by council_evidence_gate itself.
15099
- elif [ "$_completion_claimed" = 1 ] && type council_evidence_gate &>/dev/null && ! council_evidence_gate; then
15322
+ elif [ "$_completion_claimed" = 1 ] && type council_evidence_gate &>/dev/null && ! _evidence_gate_and_surface; then
15100
15323
  log_warn "Completion claim rejected: evidence gate found no proof of completion (empty diff vs run-start SHA, or red tests)."
15101
15324
  log_warn " Details under .loki/council/evidence-block.json ; opt out with LOKI_EVIDENCE_GATE=0"
15102
15325
  # Fall through; keep iterating until there is real evidence.
@@ -15595,7 +15818,7 @@ check_human_intervention() {
15595
15818
  fi
15596
15819
  if type council_checklist_gate &>/dev/null && ! council_checklist_gate; then
15597
15820
  log_info "Council force-review: blocked by checklist hard gate"
15598
- elif type council_evidence_gate &>/dev/null && ! council_evidence_gate; then
15821
+ elif type council_evidence_gate &>/dev/null && ! _evidence_gate_and_surface; then
15599
15822
  log_info "Council force-review: blocked by evidence hard gate"
15600
15823
  elif type council_heldout_gate &>/dev/null && ! council_heldout_gate; then
15601
15824
  log_info "Council force-review: blocked by held-out spec-eval hard gate"
@@ -7,7 +7,7 @@ Modules:
7
7
  control: Session control API (start/stop/pause/resume)
8
8
  """
9
9
 
10
- __version__ = "7.50.0"
10
+ __version__ = "7.51.0"
11
11
 
12
12
  # Expose the control app for easy import
13
13
  try:
@@ -2,7 +2,7 @@
2
2
 
3
3
  The flagship product of [Autonomi](https://www.autonomi.dev/). Loki Mode is a spec-driven autonomous builder with a built-in trust layer that takes any spec to a deployed product and verifies completion with evidence (quality gates plus a completion council), not just a "done" claim. Complete installation instructions for all platforms and use cases.
4
4
 
5
- **Version:** v7.50.0
5
+ **Version:** v7.51.0
6
6
 
7
7
  ---
8
8
 
@@ -396,7 +396,7 @@ provider works inside the container. Provide auth with your Anthropic API key:
396
396
  # Run Loki Mode in Docker (Claude provider, API-key auth)
397
397
  docker run --rm -e ANTHROPIC_API_KEY="$ANTHROPIC_API_KEY" \
398
398
  -v $(pwd):/workspace -w /workspace \
399
- asklokesh/loki-mode:7.50.0 start ./my-spec.md
399
+ asklokesh/loki-mode:7.51.0 start ./my-spec.md
400
400
  ```
401
401
 
402
402
  ##### docker compose + .env (no host install)
@@ -1,5 +1,5 @@
1
1
  // @bun
2
- var r6=Object.defineProperty;var t6=($)=>$;function i6($,Q){this[$]=t6.bind(null,Q)}var h=($,Q)=>{for(var Z in Q)r6($,Z,{get:Q[Z],enumerable:!0,configurable:!0,set:i6.bind(Q,Z)})};var L=($,Q)=>()=>($&&(Q=$($=0)),Q);var K$=import.meta.require;var D1={};h(D1,{lokiDir:()=>P,homeLokiDir:()=>n$,findRepoRootForVersion:()=>o$,REPO_ROOT:()=>g});import{resolve as n,dirname as d$}from"path";import{fileURLToPath as e6}from"url";import{existsSync as P$}from"fs";import{homedir as $Q}from"os";function QQ(){let $=S1;for(let Q=0;Q<6;Q++){if(P$(n($,"VERSION"))&&P$(n($,"autonomy/run.sh")))return $;let Z=d$($);if(Z===$)break;$=Z}return n(S1,"..","..","..")}function o$($){let Q=$;for(let Z=0;Z<6;Z++){if(P$(n(Q,"VERSION"))&&P$(n(Q,"autonomy/run.sh")))return Q;let z=d$(Q);if(z===Q)break;Q=z}return n($,"..","..","..")}function P(){return process.env.LOKI_DIR??n(process.cwd(),".loki")}function n$(){return n($Q(),".loki")}var S1,g;var b=L(()=>{S1=d$(e6(import.meta.url));g=QQ()});import{readFileSync as ZQ}from"fs";import{resolve as zQ,dirname as XQ}from"path";import{fileURLToPath as KQ}from"url";function j$(){if($$!==null)return $$;let $="7.50.0";if(typeof $==="string"&&$.length>0)return $$=$,$$;try{let Q=XQ(KQ(import.meta.url)),Z=o$(Q);$$=ZQ(zQ(Z,"VERSION"),"utf-8").trim()}catch{$$="unknown"}return $$}var $$=null;var a$=L(()=>{b()});var b1={};h(b1,{runOrThrow:()=>qQ,run:()=>k,commandVersion:()=>WQ,commandExists:()=>f,ShellError:()=>s$});async function k($,Q={}){let Z=Bun.spawn({cmd:[...$],stdout:"pipe",stderr:"pipe",env:Q.env?{...process.env,...Q.env}:process.env,cwd:Q.cwd}),z,X;if(Q.timeoutMs&&Q.timeoutMs>0)z=setTimeout(()=>{try{Z.kill("SIGTERM")}catch{}X=setTimeout(()=>{try{Z.kill("SIGKILL")}catch{}},2000)},Q.timeoutMs);try{let[q,K,W]=await Promise.all([new Response(Z.stdout).text(),new Response(Z.stderr).text(),Z.exited]);return{stdout:q,stderr:K,exitCode:W}}finally{if(z)clearTimeout(z);if(X)clearTimeout(X)}}async function qQ($,Q={}){let Z=await k($,Q);if(Z.exitCode!==0)throw new s$(`command failed (${Z.exitCode}): ${$.join(" ")}`,Z.exitCode,Z.stdout,Z.stderr);return Z}async function f($){let Q=VQ($),Z=await k(["sh","-c",`command -v ${Q}`],{timeoutMs:5000});if(Z.exitCode===0)return Z.stdout.trim()||null;return null}function VQ($){if(!/^[A-Za-z0-9._/-]+$/.test($))throw Error(`refused to shell-escape suspect token: ${$}`);return $}async function WQ($,Q="--version"){if(!await f($))return null;let z=await k([$,Q],{timeoutMs:5000});if(z.exitCode!==0)return null;return((z.stdout||z.stderr).split(/\r?\n/)[0]?.trim()??"")||null}var s$;var d=L(()=>{s$=class s$ extends Error{message;exitCode;stdout;stderr;constructor($,Q,Z,z){super($);this.message=$;this.exitCode=Q;this.stdout=Z;this.stderr=z;this.name="ShellError"}}});function a($){return JQ?"":$}var JQ,T,S,_,wZ,I,R,y,V;var c=L(()=>{JQ=(process.env.NO_COLOR??"").length>0;T=a("\x1B[0;31m"),S=a("\x1B[0;32m"),_=a("\x1B[1;33m"),wZ=a("\x1B[0;34m"),I=a("\x1B[0;36m"),R=a("\x1B[1m"),y=a("\x1B[2m"),V=a("\x1B[0m")});import{existsSync as wQ}from"fs";async function Q$(){if(G$!==void 0)return G$;let $="/opt/homebrew/bin/python3.12";if(wQ($))return G$=$,$;let Q=await f("python3.12");if(Q)return G$=Q,Q;let Z=await f("python3");return G$=Z,Z}async function Z$($,Q={}){let Z=await Q$();if(!Z)return{stdout:"",stderr:"python3 not found",exitCode:127};return k([Z,"-c",$],Q)}var G$;var q$=L(()=>{d()});var e1={};h(e1,{runStatus:()=>uQ});import{existsSync as v,readFileSync as W$,readdirSync as d1,statSync as o1}from"fs";import{resolve as C,basename as DQ}from"path";import{homedir as CQ}from"os";function n1($){let Q=Math.trunc($);if(Q>=1e6)return`${(Math.trunc(Q/1e6*10)/10).toFixed(1)}M`;if(Q>=1000)return`${(Math.trunc(Q/1000*10)/10).toFixed(1)}K`;return String(Q)}function a1($,Q,Z){if(Q===0)return null;let z=Math.trunc($*100/Q),X=Math.trunc($*k$/Q);if(X>k$)X=k$;let q=k$-X,K=S;if(z>=80)K=T;else if(z>=50)K=_;let W="=".repeat(Math.max(0,X))+" ".repeat(Math.max(0,q)),J=n1($),U=n1(Q);return` ${R}${Z}${V} ${K}[${W}]${V} ${z}% (${J} / ${U})`}async function hQ(){if(await f("jq"))return!0;return process.stdout.write(`${T}Error: jq is required but not installed.${V}
2
+ var r6=Object.defineProperty;var t6=($)=>$;function i6($,Q){this[$]=t6.bind(null,Q)}var h=($,Q)=>{for(var Z in Q)r6($,Z,{get:Q[Z],enumerable:!0,configurable:!0,set:i6.bind(Q,Z)})};var L=($,Q)=>()=>($&&(Q=$($=0)),Q);var K$=import.meta.require;var D1={};h(D1,{lokiDir:()=>P,homeLokiDir:()=>n$,findRepoRootForVersion:()=>o$,REPO_ROOT:()=>g});import{resolve as n,dirname as d$}from"path";import{fileURLToPath as e6}from"url";import{existsSync as P$}from"fs";import{homedir as $Q}from"os";function QQ(){let $=S1;for(let Q=0;Q<6;Q++){if(P$(n($,"VERSION"))&&P$(n($,"autonomy/run.sh")))return $;let Z=d$($);if(Z===$)break;$=Z}return n(S1,"..","..","..")}function o$($){let Q=$;for(let Z=0;Z<6;Z++){if(P$(n(Q,"VERSION"))&&P$(n(Q,"autonomy/run.sh")))return Q;let z=d$(Q);if(z===Q)break;Q=z}return n($,"..","..","..")}function P(){return process.env.LOKI_DIR??n(process.cwd(),".loki")}function n$(){return n($Q(),".loki")}var S1,g;var b=L(()=>{S1=d$(e6(import.meta.url));g=QQ()});import{readFileSync as ZQ}from"fs";import{resolve as zQ,dirname as XQ}from"path";import{fileURLToPath as KQ}from"url";function j$(){if($$!==null)return $$;let $="7.51.0";if(typeof $==="string"&&$.length>0)return $$=$,$$;try{let Q=XQ(KQ(import.meta.url)),Z=o$(Q);$$=ZQ(zQ(Z,"VERSION"),"utf-8").trim()}catch{$$="unknown"}return $$}var $$=null;var a$=L(()=>{b()});var b1={};h(b1,{runOrThrow:()=>qQ,run:()=>k,commandVersion:()=>WQ,commandExists:()=>f,ShellError:()=>s$});async function k($,Q={}){let Z=Bun.spawn({cmd:[...$],stdout:"pipe",stderr:"pipe",env:Q.env?{...process.env,...Q.env}:process.env,cwd:Q.cwd}),z,X;if(Q.timeoutMs&&Q.timeoutMs>0)z=setTimeout(()=>{try{Z.kill("SIGTERM")}catch{}X=setTimeout(()=>{try{Z.kill("SIGKILL")}catch{}},2000)},Q.timeoutMs);try{let[q,K,W]=await Promise.all([new Response(Z.stdout).text(),new Response(Z.stderr).text(),Z.exited]);return{stdout:q,stderr:K,exitCode:W}}finally{if(z)clearTimeout(z);if(X)clearTimeout(X)}}async function qQ($,Q={}){let Z=await k($,Q);if(Z.exitCode!==0)throw new s$(`command failed (${Z.exitCode}): ${$.join(" ")}`,Z.exitCode,Z.stdout,Z.stderr);return Z}async function f($){let Q=VQ($),Z=await k(["sh","-c",`command -v ${Q}`],{timeoutMs:5000});if(Z.exitCode===0)return Z.stdout.trim()||null;return null}function VQ($){if(!/^[A-Za-z0-9._/-]+$/.test($))throw Error(`refused to shell-escape suspect token: ${$}`);return $}async function WQ($,Q="--version"){if(!await f($))return null;let z=await k([$,Q],{timeoutMs:5000});if(z.exitCode!==0)return null;return((z.stdout||z.stderr).split(/\r?\n/)[0]?.trim()??"")||null}var s$;var d=L(()=>{s$=class s$ extends Error{message;exitCode;stdout;stderr;constructor($,Q,Z,z){super($);this.message=$;this.exitCode=Q;this.stdout=Z;this.stderr=z;this.name="ShellError"}}});function a($){return JQ?"":$}var JQ,T,S,_,wZ,I,R,y,V;var c=L(()=>{JQ=(process.env.NO_COLOR??"").length>0;T=a("\x1B[0;31m"),S=a("\x1B[0;32m"),_=a("\x1B[1;33m"),wZ=a("\x1B[0;34m"),I=a("\x1B[0;36m"),R=a("\x1B[1m"),y=a("\x1B[2m"),V=a("\x1B[0m")});import{existsSync as wQ}from"fs";async function Q$(){if(G$!==void 0)return G$;let $="/opt/homebrew/bin/python3.12";if(wQ($))return G$=$,$;let Q=await f("python3.12");if(Q)return G$=Q,Q;let Z=await f("python3");return G$=Z,Z}async function Z$($,Q={}){let Z=await Q$();if(!Z)return{stdout:"",stderr:"python3 not found",exitCode:127};return k([Z,"-c",$],Q)}var G$;var q$=L(()=>{d()});var e1={};h(e1,{runStatus:()=>uQ});import{existsSync as v,readFileSync as W$,readdirSync as d1,statSync as o1}from"fs";import{resolve as C,basename as DQ}from"path";import{homedir as CQ}from"os";function n1($){let Q=Math.trunc($);if(Q>=1e6)return`${(Math.trunc(Q/1e6*10)/10).toFixed(1)}M`;if(Q>=1000)return`${(Math.trunc(Q/1000*10)/10).toFixed(1)}K`;return String(Q)}function a1($,Q,Z){if(Q===0)return null;let z=Math.trunc($*100/Q),X=Math.trunc($*k$/Q);if(X>k$)X=k$;let q=k$-X,K=S;if(z>=80)K=T;else if(z>=50)K=_;let W="=".repeat(Math.max(0,X))+" ".repeat(Math.max(0,q)),J=n1($),U=n1(Q);return` ${R}${Z}${V} ${K}[${W}]${V} ${z}% (${J} / ${U})`}async function hQ(){if(await f("jq"))return!0;return process.stdout.write(`${T}Error: jq is required but not installed.${V}
3
3
  `),process.stdout.write(`Install with:
4
4
  `),process.stdout.write(` brew install jq (macOS)
5
5
  `),process.stdout.write(` apt install jq (Debian/Ubuntu)
@@ -790,4 +790,4 @@ Set LOKI_LEGACY_BASH=1 to force the bash CLI for every command.
790
790
  `),2}default:return process.stderr.write(`Unknown command: ${Q}
791
791
  `),process.stderr.write(s6),2}}l1();process.on("SIGINT",()=>process.exit(130));process.on("SIGTERM",()=>process.exit(143));var KZ=await XZ(Bun.argv.slice(2));process.exit(KZ);
792
792
 
793
- //# debugId=A55FF1A1CB2393EF64756E2164756E21
793
+ //# debugId=8015709BAB9E625464756E2164756E21
package/mcp/__init__.py CHANGED
@@ -57,4 +57,4 @@ try:
57
57
  except ImportError:
58
58
  __all__ = ['mcp']
59
59
 
60
- __version__ = '7.50.0'
60
+ __version__ = '7.51.0'
package/mcp/lsp_proxy.py CHANGED
@@ -1304,6 +1304,180 @@ async def lsp_find_definition_by_name(symbol: str,
1304
1304
  # MAIN
1305
1305
  # ============================================================
1306
1306
 
1307
+ # ============================================================
1308
+ # DIAGNOSTICS WRITER (P1-5 quality gate)
1309
+ # ============================================================
1310
+ #
1311
+ # The LSP-diagnostics quality gate (loki-ts/src/runner/quality_gates.ts,
1312
+ # runLSPDiagnostics) READS <lokiDir>/quality/lsp-diagnostics.json but nothing
1313
+ # wrote it -- the gate was inert. This is the WRITER, invoked the same way on
1314
+ # both routes (Bun gate calls `python3 -m mcp.lsp_proxy --write-diagnostics`;
1315
+ # the bash route, when wired by the run.sh owner, calls the identical command),
1316
+ # so a single program produces byte-identical output for both. NO TS/bash
1317
+ # re-implementation of the aggregation -- that is the whole point of putting it
1318
+ # here.
1319
+ #
1320
+ # It enumerates the changed files itself (HEAD~1 -> --cached -> ls-files,
1321
+ # mirroring runStaticAnalysis's chain in quality_gates.ts so file selection
1322
+ # cannot diverge from the sibling static-analysis gate), queries each
1323
+ # supported file via the SAME in-process LSP client cache (_get_or_spawn_client,
1324
+ # one process for all files -- not python3-per-file), aggregates, and writes
1325
+ # the minimal deterministic shape the gate consumes.
1326
+ #
1327
+ # HONESTY (never fabricate a clean verdict from absence): when NO supported
1328
+ # language server is on PATH, or NO changed file maps to an available server,
1329
+ # the writer writes NO artifact and removes any stale one. The gate's existing
1330
+ # absence path then fires ("gate did not run") instead of a manufactured
1331
+ # "0 errors, 0 warnings" clean verdict. Likewise on any unrecoverable error
1332
+ # the writer leaves no artifact.
1333
+ #
1334
+ # DETERMINISM: the artifact carries ONLY the fields the gate reads
1335
+ # (count_errors, count_warnings, diagnostics[].severity/.message/.file).
1336
+ # Non-deterministic proxy fields (elapsed_ms, ranges, source) are dropped, and
1337
+ # diagnostics are sorted stably (file, severity, message) so the same inputs
1338
+ # always serialize byte-identically across runs and routes.
1339
+
1340
+ _DIAG_ARTIFACT_REL = os.path.join('quality', 'lsp-diagnostics.json')
1341
+
1342
+
1343
+ def _writer_loki_dir() -> str:
1344
+ """Resolve the .loki dir the artifact is written under. Honors LOKI_DIR
1345
+ (matching loki-ts/src/util/paths.ts lokiDir()), else <cwd>/.loki."""
1346
+ env = os.environ.get('LOKI_DIR')
1347
+ if env:
1348
+ return env
1349
+ return os.path.join(os.getcwd(), '.loki')
1350
+
1351
+
1352
+ def _writer_changed_files(root: str) -> List[str]:
1353
+ """Return changed files relative to `root`, mirroring the HEAD~1 ->
1354
+ --cached -> ls-files chain in runStaticAnalysis (quality_gates.ts). An
1355
+ empty-but-successful `git diff HEAD~1 HEAD` is honored as "no changes
1356
+ this iteration" and does NOT fall through to ls-files (parity with the
1357
+ tryGit null-vs-empty distinction in the TS gate)."""
1358
+ def _try_git(git_args: List[str]) -> Optional[str]:
1359
+ try:
1360
+ proc = subprocess.run(
1361
+ ['git', '-C', root, *git_args],
1362
+ capture_output=True, text=True, timeout=30,
1363
+ )
1364
+ except (OSError, subprocess.SubprocessError):
1365
+ return None
1366
+ if proc.returncode != 0:
1367
+ return None
1368
+ return proc.stdout
1369
+
1370
+ raw: str
1371
+ head_tilde = _try_git(['diff', '--name-only', 'HEAD~1', 'HEAD'])
1372
+ if head_tilde is not None:
1373
+ raw = head_tilde
1374
+ else:
1375
+ cached = _try_git(['diff', '--name-only', '--cached'])
1376
+ if cached is not None and cached.strip():
1377
+ raw = cached
1378
+ else:
1379
+ ls_files = _try_git(['ls-files'])
1380
+ raw = ls_files if (ls_files is not None and ls_files.strip()) else ''
1381
+ return [line.strip() for line in raw.splitlines() if line.strip()]
1382
+
1383
+
1384
+ def write_diagnostics_artifact(root: Optional[str] = None,
1385
+ loki_dir: Optional[str] = None) -> Dict[str, Any]:
1386
+ """Enumerate changed files, query LSP diagnostics per supported file via
1387
+ the shared in-process client cache, aggregate, and atomically write the
1388
+ gate artifact. Returns a status dict (also printed by --write-diagnostics).
1389
+
1390
+ Writes NO artifact (and removes a stale one) when there is nothing real to
1391
+ measure -- no detected server, or no changed file maps to a detected
1392
+ server -- so the gate's absence path fires honestly instead of a fabricated
1393
+ clean verdict."""
1394
+ root = root or os.getcwd()
1395
+ loki_dir = loki_dir or _writer_loki_dir()
1396
+ artifact_path = os.path.join(loki_dir, _DIAG_ARTIFACT_REL)
1397
+
1398
+ def _remove_stale() -> None:
1399
+ try:
1400
+ if os.path.isfile(artifact_path):
1401
+ os.remove(artifact_path)
1402
+ except OSError:
1403
+ pass
1404
+
1405
+ detected = _detect_lsps()
1406
+ if not detected:
1407
+ _remove_stale()
1408
+ return {'measured': False, 'reason': 'no-language-server-on-path',
1409
+ 'wrote_artifact': False}
1410
+
1411
+ changed_rel = _writer_changed_files(root)
1412
+ # Keep only files whose language has a detected server AND that exist on
1413
+ # disk (skip deleted/renamed diff entries).
1414
+ targets: List[str] = []
1415
+ for rel in changed_rel:
1416
+ abs_path = rel if os.path.isabs(rel) else os.path.join(root, rel)
1417
+ if not os.path.isfile(abs_path):
1418
+ continue
1419
+ lang = _suffix_to_language(abs_path)
1420
+ if lang is None or lang not in detected:
1421
+ continue
1422
+ targets.append(abs_path)
1423
+
1424
+ if not targets:
1425
+ _remove_stale()
1426
+ return {'measured': False, 'reason': 'no-changed-file-with-detected-server',
1427
+ 'wrote_artifact': False,
1428
+ 'detected': sorted(detected.keys())}
1429
+
1430
+ all_diags: List[Dict[str, Any]] = []
1431
+ queried = 0
1432
+ for abs_path in targets:
1433
+ try:
1434
+ raw = _lsp_get_diagnostics_blocking(abs_path)
1435
+ parsed = json.loads(raw)
1436
+ except (OSError, ValueError):
1437
+ continue
1438
+ if 'error' in parsed:
1439
+ # Server present in detection but failed for this file (spawn
1440
+ # failure, unsupported, etc.). Skip it -- do NOT count it clean.
1441
+ continue
1442
+ queried += 1
1443
+ for d in parsed.get('diagnostics', []) or []:
1444
+ sev = d.get('severity')
1445
+ if not isinstance(sev, int):
1446
+ continue
1447
+ all_diags.append({
1448
+ 'file': abs_path,
1449
+ 'severity': sev,
1450
+ 'message': str(d.get('message', '')),
1451
+ })
1452
+
1453
+ if queried == 0:
1454
+ # Every detected-server file failed to produce a usable result. We
1455
+ # measured nothing real -- do not fabricate a clean artifact.
1456
+ _remove_stale()
1457
+ return {'measured': False, 'reason': 'no-file-yielded-diagnostics',
1458
+ 'wrote_artifact': False, 'detected': sorted(detected.keys())}
1459
+
1460
+ # Stable sort so the same inputs serialize identically across runs/routes.
1461
+ all_diags.sort(key=lambda d: (d['file'], d['severity'], d['message']))
1462
+ count_errors = sum(1 for d in all_diags if d['severity'] == 1)
1463
+ count_warnings = sum(1 for d in all_diags if d['severity'] == 2)
1464
+ artifact = {
1465
+ 'count_errors': count_errors,
1466
+ 'count_warnings': count_warnings,
1467
+ 'diagnostics': all_diags,
1468
+ }
1469
+
1470
+ os.makedirs(os.path.dirname(artifact_path), exist_ok=True)
1471
+ body = json.dumps(artifact, indent=2, sort_keys=True) + '\n'
1472
+ tmp_path = f'{artifact_path}.tmp.{os.getpid()}'
1473
+ with open(tmp_path, 'w', encoding='utf-8') as fh:
1474
+ fh.write(body)
1475
+ os.replace(tmp_path, artifact_path)
1476
+ return {'measured': True, 'wrote_artifact': True, 'path': artifact_path,
1477
+ 'files_queried': queried, 'count_errors': count_errors,
1478
+ 'count_warnings': count_warnings}
1479
+
1480
+
1307
1481
  def main() -> None:
1308
1482
  import argparse
1309
1483
  parser = argparse.ArgumentParser(
@@ -1317,7 +1491,36 @@ def main() -> None:
1317
1491
  '--port', type=int, default=8422,
1318
1492
  help='Port for HTTP transport (default: 8422).',
1319
1493
  )
1494
+ parser.add_argument(
1495
+ '--write-diagnostics', action='store_true',
1496
+ help='One-shot: enumerate changed files, query LSP diagnostics for '
1497
+ 'each supported file, and write the quality-gate artifact at '
1498
+ '<LOKI_DIR>/quality/lsp-diagnostics.json. Writes nothing when no '
1499
+ 'language server is available (the gate then reports "did not '
1500
+ 'run"). Used by the LSP-diagnostics quality gate on both routes.',
1501
+ )
1502
+ parser.add_argument(
1503
+ '--root', default=None,
1504
+ help='Project root to enumerate changed files in (default: cwd). The '
1505
+ 'caller MUST pass the TARGET project dir here, not the loki '
1506
+ 'install dir -- the install dir is only the import path for '
1507
+ '`-m mcp.lsp_proxy`.',
1508
+ )
1320
1509
  args = parser.parse_args()
1510
+
1511
+ if args.write_diagnostics:
1512
+ # One-shot writer mode -- no MCP server, no event loop. Always cleans
1513
+ # up any spawned LSP clients before exit. `--root` is the TARGET
1514
+ # project (where the diff lives); it is independent of the process cwd
1515
+ # (which the caller may set to the install dir so `-m mcp.lsp_proxy`
1516
+ # imports).
1517
+ try:
1518
+ status = write_diagnostics_artifact(root=args.root)
1519
+ finally:
1520
+ _cleanup_all_clients()
1521
+ print(json.dumps(status))
1522
+ return
1523
+
1321
1524
  # SIGTERM handler so docker stop / supervisord stop triggers cleanup
1322
1525
  # symmetrically to atexit. Re-raises via default handler so the
1323
1526
  # process actually exits.
@@ -373,5 +373,174 @@ class LSPClientShutdownTests(unittest.TestCase):
373
373
  fake_proc.kill.assert_called()
374
374
 
375
375
 
376
+ class DiagnosticsWriterTests(unittest.TestCase):
377
+ """P1-5: the diagnostics WRITER that feeds the LSP-diagnostics quality
378
+ gate (loki-ts/src/runner/quality_gates.ts runLSPDiagnostics). Proves
379
+ non-vacuity (real severity-1 diagnostics -> blocking artifact) at the
380
+ recording/aggregation layer, and the no-false-fire honesty paths (no
381
+ server / no measurable file -> NO artifact, so the gate's absence path
382
+ fires instead of a fabricated clean verdict)."""
383
+
384
+ def setUp(self):
385
+ self.lp = _import_lsp_proxy()
386
+ self.lp._reset_detection_cache()
387
+
388
+ def _make_repo(self, files):
389
+ """Create a tmp dir with the given {relpath: contents} and return it."""
390
+ d = tempfile.mkdtemp(prefix='loki-lsp-writer-')
391
+ for rel, body in files.items():
392
+ p = os.path.join(d, rel)
393
+ os.makedirs(os.path.dirname(p), exist_ok=True)
394
+ with open(p, 'w', encoding='utf-8') as fh:
395
+ fh.write(body)
396
+ return d
397
+
398
+ def test_no_server_writes_no_artifact(self):
399
+ """No detected language server -> measured:false, no artifact. The
400
+ gate must then report 'did not run', never a clean verdict."""
401
+ root = self._make_repo({'a.ts': 'const x = 1;\n'})
402
+ loki = tempfile.mkdtemp(prefix='loki-lsp-out-')
403
+ with mock.patch.object(self.lp, '_detect_lsps', return_value={}):
404
+ status = self.lp.write_diagnostics_artifact(root=root, loki_dir=loki)
405
+ self.assertFalse(status['measured'])
406
+ self.assertFalse(status['wrote_artifact'])
407
+ self.assertFalse(os.path.isfile(
408
+ os.path.join(loki, 'quality', 'lsp-diagnostics.json')))
409
+
410
+ def test_server_present_but_no_matching_changed_file(self):
411
+ """Server detected for rust, but the changed file is .ts -> nothing
412
+ real to measure -> no artifact."""
413
+ root = self._make_repo({'a.ts': 'const x = 1;\n'})
414
+ loki = tempfile.mkdtemp(prefix='loki-lsp-out-')
415
+ with mock.patch.object(self.lp, '_detect_lsps',
416
+ return_value={'rust': '/usr/bin/rust-analyzer'}), \
417
+ mock.patch.object(self.lp, '_writer_changed_files',
418
+ return_value=['a.ts']):
419
+ status = self.lp.write_diagnostics_artifact(root=root, loki_dir=loki)
420
+ self.assertFalse(status['measured'])
421
+ self.assertEqual(status['reason'], 'no-changed-file-with-detected-server')
422
+ self.assertFalse(os.path.isfile(
423
+ os.path.join(loki, 'quality', 'lsp-diagnostics.json')))
424
+
425
+ def test_real_error_recorded_and_blocks(self):
426
+ """NON-VACUITY: a severity-1 diagnostic from the per-file LSP query is
427
+ recorded into the artifact with count_errors>0 -- the exact shape the
428
+ gate blocks on. Diagnostics source is mocked at the proxy boundary so
429
+ this exercises the writer's enumeration + aggregation + serialization
430
+ WITHOUT fabricating a verdict (the gate still independently reads the
431
+ file)."""
432
+ root = self._make_repo({'src/main.py': 'x: int = "nope"\n'})
433
+ loki = tempfile.mkdtemp(prefix='loki-lsp-out-')
434
+ fake = json.dumps({
435
+ 'diagnostics': [
436
+ {'severity': 1, 'message': 'Type error', 'range': {}, 'source': 'x'},
437
+ {'severity': 2, 'message': 'Unused', 'range': {}},
438
+ {'severity': 3, 'message': 'Info note'},
439
+ ],
440
+ 'count_errors': 1, 'count_warnings': 1,
441
+ 'language': 'python', 'elapsed_ms': 123.4,
442
+ })
443
+ with mock.patch.object(self.lp, '_detect_lsps',
444
+ return_value={'python': '/usr/bin/pyright'}), \
445
+ mock.patch.object(self.lp, '_writer_changed_files',
446
+ return_value=['src/main.py']), \
447
+ mock.patch.object(self.lp, '_lsp_get_diagnostics_blocking',
448
+ return_value=fake):
449
+ status = self.lp.write_diagnostics_artifact(root=root, loki_dir=loki)
450
+ self.assertTrue(status['measured'])
451
+ self.assertTrue(status['wrote_artifact'])
452
+ self.assertEqual(status['count_errors'], 1)
453
+ path = os.path.join(loki, 'quality', 'lsp-diagnostics.json')
454
+ self.assertTrue(os.path.isfile(path))
455
+ with open(path, encoding='utf-8') as fh:
456
+ artifact = json.load(fh)
457
+ self.assertEqual(artifact['count_errors'], 1)
458
+ self.assertEqual(artifact['count_warnings'], 1)
459
+ # Minimal deterministic shape: only severity/message/file survive;
460
+ # elapsed_ms / range / source are stripped.
461
+ self.assertEqual(len(artifact['diagnostics']), 3)
462
+ for d in artifact['diagnostics']:
463
+ self.assertEqual(set(d.keys()), {'file', 'severity', 'message'})
464
+ self.assertNotIn('elapsed_ms', artifact)
465
+
466
+ def test_per_file_lsp_error_does_not_count_clean(self):
467
+ """A detected server that returns {'error':...} for the only changed
468
+ file means we measured nothing -> no artifact (NOT a fabricated
469
+ clean verdict)."""
470
+ root = self._make_repo({'src/main.py': 'x = 1\n'})
471
+ loki = tempfile.mkdtemp(prefix='loki-lsp-out-')
472
+ with mock.patch.object(self.lp, '_detect_lsps',
473
+ return_value={'python': '/usr/bin/pyright'}), \
474
+ mock.patch.object(self.lp, '_writer_changed_files',
475
+ return_value=['src/main.py']), \
476
+ mock.patch.object(self.lp, '_lsp_get_diagnostics_blocking',
477
+ return_value=json.dumps({'error': 'spawn failed'})):
478
+ status = self.lp.write_diagnostics_artifact(root=root, loki_dir=loki)
479
+ self.assertFalse(status['measured'])
480
+ self.assertEqual(status['reason'], 'no-file-yielded-diagnostics')
481
+ self.assertFalse(os.path.isfile(
482
+ os.path.join(loki, 'quality', 'lsp-diagnostics.json')))
483
+
484
+ def test_clean_file_writes_zero_artifact(self):
485
+ """A measured file with no diagnostics writes a real 0/0 artifact --
486
+ this is a MEASURED clean result (we queried a live server), distinct
487
+ from the absence path."""
488
+ root = self._make_repo({'src/main.py': 'x = 1\n'})
489
+ loki = tempfile.mkdtemp(prefix='loki-lsp-out-')
490
+ with mock.patch.object(self.lp, '_detect_lsps',
491
+ return_value={'python': '/usr/bin/pyright'}), \
492
+ mock.patch.object(self.lp, '_writer_changed_files',
493
+ return_value=['src/main.py']), \
494
+ mock.patch.object(self.lp, '_lsp_get_diagnostics_blocking',
495
+ return_value=json.dumps({
496
+ 'diagnostics': [], 'count_errors': 0,
497
+ 'count_warnings': 0})):
498
+ status = self.lp.write_diagnostics_artifact(root=root, loki_dir=loki)
499
+ self.assertTrue(status['measured'])
500
+ path = os.path.join(loki, 'quality', 'lsp-diagnostics.json')
501
+ with open(path, encoding='utf-8') as fh:
502
+ artifact = json.load(fh)
503
+ self.assertEqual(artifact['count_errors'], 0)
504
+ self.assertEqual(artifact['count_warnings'], 0)
505
+ self.assertEqual(artifact['diagnostics'], [])
506
+
507
+ def test_stale_artifact_removed_when_unmeasured(self):
508
+ """A previously-written artifact must be removed when a later run
509
+ measures nothing, so last iteration's errors cannot block forever."""
510
+ root = self._make_repo({'a.ts': 'const x = 1;\n'})
511
+ loki = tempfile.mkdtemp(prefix='loki-lsp-out-')
512
+ qdir = os.path.join(loki, 'quality')
513
+ os.makedirs(qdir, exist_ok=True)
514
+ stale = os.path.join(qdir, 'lsp-diagnostics.json')
515
+ with open(stale, 'w', encoding='utf-8') as fh:
516
+ fh.write('{"count_errors": 5, "count_warnings": 0, "diagnostics": []}')
517
+ with mock.patch.object(self.lp, '_detect_lsps', return_value={}):
518
+ self.lp.write_diagnostics_artifact(root=root, loki_dir=loki)
519
+ self.assertFalse(os.path.isfile(stale))
520
+
521
+ def test_deterministic_serialization(self):
522
+ """Same inputs -> byte-identical artifact (route + run parity)."""
523
+ root = self._make_repo({'src/main.py': 'x = 1\n'})
524
+ loki1 = tempfile.mkdtemp(prefix='loki-lsp-out-')
525
+ loki2 = tempfile.mkdtemp(prefix='loki-lsp-out-')
526
+ fake = json.dumps({'diagnostics': [
527
+ {'severity': 2, 'message': 'b'},
528
+ {'severity': 1, 'message': 'a'},
529
+ ]})
530
+ for lk in (loki1, loki2):
531
+ with mock.patch.object(self.lp, '_detect_lsps',
532
+ return_value={'python': '/usr/bin/pyright'}), \
533
+ mock.patch.object(self.lp, '_writer_changed_files',
534
+ return_value=['src/main.py']), \
535
+ mock.patch.object(self.lp, '_lsp_get_diagnostics_blocking',
536
+ return_value=fake):
537
+ self.lp.write_diagnostics_artifact(root=root, loki_dir=lk)
538
+ with open(os.path.join(loki1, 'quality', 'lsp-diagnostics.json'), 'rb') as fh:
539
+ b1 = fh.read()
540
+ with open(os.path.join(loki2, 'quality', 'lsp-diagnostics.json'), 'rb') as fh:
541
+ b2 = fh.read()
542
+ self.assertEqual(b1, b2)
543
+
544
+
376
545
  if __name__ == '__main__':
377
546
  unittest.main()
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.50.0",
4
+ "version": "7.51.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 8 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.50.0",
5
+ "version": "7.51.0",
6
6
  "description": "Autonomous spec-to-product build system with a built-in trust layer (RARV-C closure loop, 8 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",