loki-mode 7.94.0 → 7.96.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 +2 -2
- package/VERSION +1 -1
- package/autonomy/hooks/migration-hooks.sh +13 -0
- package/autonomy/run.sh +42 -1
- package/dashboard/__init__.py +1 -1
- package/dashboard/static/index.html +162 -156
- package/docs/INSTALLATION.md +1 -1
- package/events/bus.ts +7 -1
- package/loki-ts/dist/loki.js +2 -2
- package/mcp/__init__.py +1 -1
- package/mcp/learning_collector.py +18 -0
- package/mcp/server.py +18 -3
- package/memory/consolidation.py +55 -64
- package/memory/storage.py +69 -0
- package/package.json +1 -1
- package/plugins/loki-mode/.claude-plugin/plugin.json +1 -1
package/docs/INSTALLATION.md
CHANGED
|
@@ -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.
|
|
5
|
+
**Version:** v7.96.0
|
|
6
6
|
|
|
7
7
|
---
|
|
8
8
|
|
package/events/bus.ts
CHANGED
|
@@ -292,7 +292,13 @@ export class EventBus {
|
|
|
292
292
|
|
|
293
293
|
if (archive) {
|
|
294
294
|
try {
|
|
295
|
-
|
|
295
|
+
// Match the exact id at the filename boundary. Emitted files are named
|
|
296
|
+
// `${timestamp}_${id}.json`, so a substring match (f.includes(event.id))
|
|
297
|
+
// would also match a DIFFERENT event whose id contains this id as a
|
|
298
|
+
// substring, archiving/marking the wrong event file (event loss). The
|
|
299
|
+
// Python impl already uses the precise `*_${id}.json` glob; mirror it.
|
|
300
|
+
const suffix = `_${event.id}.json`;
|
|
301
|
+
const files = fs.readdirSync(this.pendingDir).filter((f) => f.endsWith(suffix));
|
|
296
302
|
for (const file of files) {
|
|
297
303
|
const src = path.join(this.pendingDir, file);
|
|
298
304
|
const dst = path.join(this.archiveDir, file);
|
package/loki-ts/dist/loki.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// @bun
|
|
2
|
-
var z8=Object.defineProperty;var X8=($)=>$;function K8($,Q){this[$]=X8.bind(null,Q)}var b=($,Q)=>{for(var Z in Q)z8($,Z,{get:Q[Z],enumerable:!0,configurable:!0,set:K8.bind(Q,Z)})};var L=($,Q)=>()=>($&&(Q=$($=0)),Q);var J$=import.meta.require;var y1={};b(y1,{lokiDir:()=>P,homeLokiDir:()=>T$,findRepoRootForVersion:()=>e$,REPO_ROOT:()=>h});import{resolve as t,dirname as i$}from"path";import{fileURLToPath as q8}from"url";import{existsSync as E$}from"fs";import{homedir as J8}from"os";function V8(){let $=h1;for(let Q=0;Q<6;Q++){if(E$(t($,"VERSION"))&&E$(t($,"autonomy/run.sh")))return $;let Z=i$($);if(Z===$)break;$=Z}return t(h1,"..","..","..")}function e$($){let Q=$;for(let Z=0;Z<6;Z++){if(E$(t(Q,"VERSION"))&&E$(t(Q,"autonomy/run.sh")))return Q;let z=i$(Q);if(z===Q)break;Q=z}return t($,"..","..","..")}function P(){return process.env.LOKI_DIR??t(process.cwd(),".loki")}function T$(){return t(J8(),".loki")}var h1,h;var C=L(()=>{h1=i$(q8(import.meta.url));h=V8()});import{readFileSync as W8}from"fs";import{resolve as U8,dirname as H8}from"path";import{fileURLToPath as G8}from"url";function N$(){if(Q$!==null)return Q$;let $="7.
|
|
2
|
+
var z8=Object.defineProperty;var X8=($)=>$;function K8($,Q){this[$]=X8.bind(null,Q)}var b=($,Q)=>{for(var Z in Q)z8($,Z,{get:Q[Z],enumerable:!0,configurable:!0,set:K8.bind(Q,Z)})};var L=($,Q)=>()=>($&&(Q=$($=0)),Q);var J$=import.meta.require;var y1={};b(y1,{lokiDir:()=>P,homeLokiDir:()=>T$,findRepoRootForVersion:()=>e$,REPO_ROOT:()=>h});import{resolve as t,dirname as i$}from"path";import{fileURLToPath as q8}from"url";import{existsSync as E$}from"fs";import{homedir as J8}from"os";function V8(){let $=h1;for(let Q=0;Q<6;Q++){if(E$(t($,"VERSION"))&&E$(t($,"autonomy/run.sh")))return $;let Z=i$($);if(Z===$)break;$=Z}return t(h1,"..","..","..")}function e$($){let Q=$;for(let Z=0;Z<6;Z++){if(E$(t(Q,"VERSION"))&&E$(t(Q,"autonomy/run.sh")))return Q;let z=i$(Q);if(z===Q)break;Q=z}return t($,"..","..","..")}function P(){return process.env.LOKI_DIR??t(process.cwd(),".loki")}function T$(){return t(J8(),".loki")}var h1,h;var C=L(()=>{h1=i$(q8(import.meta.url));h=V8()});import{readFileSync as W8}from"fs";import{resolve as U8,dirname as H8}from"path";import{fileURLToPath as G8}from"url";function N$(){if(Q$!==null)return Q$;let $="7.96.0";if(typeof $==="string"&&$.length>0)return Q$=$,Q$;try{let Q=H8(G8(import.meta.url)),Z=e$(Q);Q$=W8(U8(Z,"VERSION"),"utf-8").trim()}catch{Q$="unknown"}return Q$}var Q$=null;var $1=L(()=>{C()});var u1={};b(u1,{runOrThrow:()=>x8,run:()=>F,readStreamCapped:()=>f1,commandVersion:()=>N8,commandExists:()=>f,ShellError:()=>Q1,MAX_STDOUT_BYTES:()=>m1});async function f1($,Q=m1){let Z=$.getReader(),z=new TextDecoder,X="",q=0;try{while(q<Q){let{done:K,value:W}=await Z.read();if(K)break;if(!W)continue;if(q+=W.byteLength,q>Q){let V=W.byteLength-(q-Q);X+=z.decode(W.subarray(0,V),{stream:!0});break}X+=z.decode(W,{stream:!0})}X+=z.decode()}finally{try{await Z.cancel()}catch{}Z.releaseLock()}return X}async function F($,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([f1(Z.stdout),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 x8($,Q={}){let Z=await F($,Q);if(Z.exitCode!==0)throw new Q1(`command failed (${Z.exitCode}): ${$.join(" ")}`,Z.exitCode,Z.stdout,Z.stderr);return Z}async function f($){let Q=E8($),Z=await F(["sh","-c",`command -v ${Q}`],{timeoutMs:5000});if(Z.exitCode===0)return Z.stdout.trim()||null;return null}function E8($){if(!/^[A-Za-z0-9._/-]+$/.test($))throw Error(`refused to shell-escape suspect token: ${$}`);return $}async function N8($,Q="--version"){if(!await f($))return null;let z=await F([$,Q],{timeoutMs:5000});if(z.exitCode!==0)return null;return((z.stdout||z.stderr).split(/\r?\n/)[0]?.trim()??"")||null}var m1=16777216,Q1;var d=L(()=>{Q1=class Q1 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 r($){return S8?"":$}var S8,T,S,_,lZ,I,k,y,J;var c=L(()=>{S8=(process.env.NO_COLOR??"").length>0;T=r("\x1B[0;31m"),S=r("\x1B[0;32m"),_=r("\x1B[1;33m"),lZ=r("\x1B[0;34m"),I=r("\x1B[0;36m"),k=r("\x1B[1m"),y=r("\x1B[2m"),J=r("\x1B[0m")});import{existsSync as u8}from"fs";async function Z$(){if(A$!==void 0)return A$;let $="/opt/homebrew/bin/python3.12";if(u8($))return A$=$,$;let Q=await f("python3.12");if(Q)return A$=Q,Q;let Z=await f("python3");return A$=Z,Z}async function z$($,Q={}){let Z=await Z$();if(!Z)return{stdout:"",stderr:"python3 not found",exitCode:127};return F([Z,"-c",$],Q)}var A$;var V$=L(()=>{d()});var q0={};b(q0,{runStatus:()=>W3});import{existsSync as v,readFileSync as U$,readdirSync as i1,statSync as e1}from"fs";import{resolve as D,basename as $3}from"path";import{homedir as Q3}from"os";function $0($){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 Q0($,Q,Z){if(Q===0)return null;let z=Math.trunc($*100/Q),X=Math.trunc($*D$/Q);if(X>D$)X=D$;let q=D$-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)),V=$0($),U=$0(Q);return` ${k}${Z}${J} ${K}[${W}]${J} ${z}% (${V} / ${U})`}async function z3(){if(await f("jq"))return!0;return process.stdout.write(`${T}Error: jq is required but not installed.${J}
|
|
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)
|
|
@@ -802,4 +802,4 @@ Set LOKI_LEGACY_BASH=1 to force the bash CLI for every command.
|
|
|
802
802
|
`),2}default:return process.stderr.write(`Unknown command: ${Q}
|
|
803
803
|
`),process.stderr.write(Z8),2}}r1();process.on("SIGINT",()=>process.exit(130));process.on("SIGTERM",()=>process.exit(143));var FZ=await jZ(Bun.argv.slice(2));process.exit(FZ);
|
|
804
804
|
|
|
805
|
-
//# debugId=
|
|
805
|
+
//# debugId=E60A742D72C38FD764756E2164756E21
|
package/mcp/__init__.py
CHANGED
|
@@ -512,6 +512,24 @@ def get_mcp_learning_collector(
|
|
|
512
512
|
MCPLearningCollector instance
|
|
513
513
|
"""
|
|
514
514
|
global _collector
|
|
515
|
+
# Recreate when the requested loki_dir differs from the cached collector's
|
|
516
|
+
# directory. The module-level singleton would otherwise return a collector
|
|
517
|
+
# bound to a PREVIOUS project's .loki dir on cwd change, contaminating one
|
|
518
|
+
# project's learnings into another in a multi-project session. Compare by
|
|
519
|
+
# realpath so symlinked/relative paths to the same dir do not churn.
|
|
520
|
+
if _collector is not None and loki_dir is not None:
|
|
521
|
+
try:
|
|
522
|
+
cached = os.path.realpath(str(getattr(_collector, "loki_dir", "")))
|
|
523
|
+
requested = os.path.realpath(str(loki_dir))
|
|
524
|
+
if cached != requested:
|
|
525
|
+
close = getattr(_collector, "close", None)
|
|
526
|
+
if callable(close):
|
|
527
|
+
close()
|
|
528
|
+
_collector = None
|
|
529
|
+
except Exception:
|
|
530
|
+
# On any path-resolution error, fall through and reuse the cache
|
|
531
|
+
# rather than risk losing the collector.
|
|
532
|
+
pass
|
|
515
533
|
if _collector is None:
|
|
516
534
|
_collector = MCPLearningCollector(loki_dir=loki_dir)
|
|
517
535
|
return _collector
|
package/mcp/server.py
CHANGED
|
@@ -64,13 +64,28 @@ _learning_collector = None
|
|
|
64
64
|
|
|
65
65
|
|
|
66
66
|
def _get_learning_collector():
|
|
67
|
-
"""Get or create the LearningCollector instance for MCP server.
|
|
67
|
+
"""Get or create the LearningCollector instance for MCP server.
|
|
68
|
+
|
|
69
|
+
Revalidates the cached collector against the current cwd-derived .loki dir
|
|
70
|
+
(mirrors the StateManager realpath-compare-recreate guard in
|
|
71
|
+
_get_mcp_state_manager). A multi-project MCP session can change cwd between
|
|
72
|
+
calls; without this, the cached collector stays bound to a PREVIOUS
|
|
73
|
+
project's .loki dir and contaminates one project's learnings into another.
|
|
74
|
+
"""
|
|
68
75
|
global _learning_collector
|
|
69
76
|
if not LEARNING_COLLECTOR_AVAILABLE:
|
|
70
77
|
return None
|
|
78
|
+
from pathlib import Path
|
|
79
|
+
loki_dir = Path(os.getcwd()) / '.loki'
|
|
80
|
+
if _learning_collector is not None:
|
|
81
|
+
existing_dir = getattr(_learning_collector, 'loki_dir', None)
|
|
82
|
+
if existing_dir and os.path.realpath(str(existing_dir)) != os.path.realpath(str(loki_dir)):
|
|
83
|
+
# Project directory changed, recreate
|
|
84
|
+
close = getattr(_learning_collector, 'close', None)
|
|
85
|
+
if callable(close):
|
|
86
|
+
close()
|
|
87
|
+
_learning_collector = None
|
|
71
88
|
if _learning_collector is None:
|
|
72
|
-
from pathlib import Path
|
|
73
|
-
loki_dir = Path(os.getcwd()) / '.loki'
|
|
74
89
|
_learning_collector = get_mcp_learning_collector(loki_dir=loki_dir)
|
|
75
90
|
return _learning_collector
|
|
76
91
|
|
package/memory/consolidation.py
CHANGED
|
@@ -228,28 +228,34 @@ class ConsolidationPipeline:
|
|
|
228
228
|
merged = False
|
|
229
229
|
for idx, existing in enumerate(existing_patterns):
|
|
230
230
|
if self._patterns_similar(new_pattern, existing):
|
|
231
|
-
#
|
|
232
|
-
#
|
|
233
|
-
#
|
|
234
|
-
#
|
|
235
|
-
#
|
|
236
|
-
#
|
|
237
|
-
#
|
|
238
|
-
#
|
|
239
|
-
#
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
231
|
+
# Atomic read-merge-write (BUG-MEM C1, lost-update).
|
|
232
|
+
# The whole-run snapshot at step 4 can be stale by now:
|
|
233
|
+
# a concurrent storage.increment_pattern_usage()
|
|
234
|
+
# (atomic read-mutate-write under one exclusive lock)
|
|
235
|
+
# may have bumped usage_count/last_used AFTER the
|
|
236
|
+
# snapshot, and merge_with_existing() builds the merged
|
|
237
|
+
# record from the base's usage_count/last_used. The
|
|
238
|
+
# merge now runs INSIDE update_pattern_with_merge's
|
|
239
|
+
# single lock on patterns.json (the same path
|
|
240
|
+
# increment_pattern_usage locks), so the merge base is
|
|
241
|
+
# the live on-disk record and the bump cannot be lost.
|
|
242
|
+
merged_holder = {}
|
|
243
|
+
|
|
244
|
+
def _merge(current, _np=new_pattern, _holder=merged_holder):
|
|
245
|
+
base = SemanticPattern.from_dict(current)
|
|
246
|
+
m = self.merge_with_existing(_np, [base])
|
|
247
|
+
_holder["pattern"] = m
|
|
248
|
+
return m
|
|
249
|
+
|
|
250
|
+
if self.storage.update_pattern_with_merge(existing.id, _merge):
|
|
251
|
+
merged_pattern = merged_holder["pattern"]
|
|
252
|
+
# Refresh the in-memory copy so a later new pattern
|
|
253
|
+
# in this same run that also merges into this
|
|
254
|
+
# existing pattern builds on the just-merged state.
|
|
255
|
+
existing_patterns[idx] = merged_pattern
|
|
256
|
+
result.patterns_merged += 1
|
|
257
|
+
merged = True
|
|
258
|
+
break
|
|
253
259
|
|
|
254
260
|
if not merged:
|
|
255
261
|
self.storage.save_pattern(new_pattern)
|
|
@@ -277,20 +283,27 @@ class ConsolidationPipeline:
|
|
|
277
283
|
for idx, existing in enumerate(existing_patterns):
|
|
278
284
|
if (existing.incorrect_approach and
|
|
279
285
|
self._patterns_similar(anti_pattern, existing, threshold=0.6)):
|
|
280
|
-
#
|
|
281
|
-
# cluster merge loop above): merge from
|
|
282
|
-
#
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
286
|
+
# Atomic read-merge-write (same C1 lost-update guard as the
|
|
287
|
+
# cluster merge loop above): merge from the live on-disk record
|
|
288
|
+
# inside update_pattern_with_merge's single lock so a concurrent
|
|
289
|
+
# usage bump cannot be clobbered.
|
|
290
|
+
merged_holder = {}
|
|
291
|
+
|
|
292
|
+
def _merge(current, _ap=anti_pattern, _holder=merged_holder):
|
|
293
|
+
base = SemanticPattern.from_dict(current)
|
|
294
|
+
m = self.merge_with_existing(_ap, [base])
|
|
295
|
+
_holder["pattern"] = m
|
|
296
|
+
return m
|
|
297
|
+
|
|
298
|
+
if self.storage.update_pattern_with_merge(existing.id, _merge):
|
|
299
|
+
merged_pattern = merged_holder["pattern"]
|
|
300
|
+
# Refresh in-memory copy: a later anti-pattern merging into
|
|
301
|
+
# this same existing pattern must build on the just-merged
|
|
302
|
+
# state, not the stale pre-merge base.
|
|
303
|
+
existing_patterns[idx] = merged_pattern
|
|
304
|
+
result.patterns_merged += 1
|
|
305
|
+
merged = True
|
|
306
|
+
break
|
|
294
307
|
|
|
295
308
|
if not merged:
|
|
296
309
|
self.storage.save_pattern(anti_pattern)
|
|
@@ -324,34 +337,12 @@ class ConsolidationPipeline:
|
|
|
324
337
|
result.duration_seconds = time.time() - start_time
|
|
325
338
|
return result
|
|
326
339
|
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
the current on-disk record makes the merge build on live state.
|
|
334
|
-
|
|
335
|
-
Mirrors the snapshot's dict/object handling. If load_pattern returns
|
|
336
|
-
nothing (e.g. the record vanished), fall back to the in-memory copy so the
|
|
337
|
-
merge still proceeds rather than crashing.
|
|
338
|
-
|
|
339
|
-
Residual limitation (honest): load_pattern and update_pattern are SEPARATE
|
|
340
|
-
lock acquisitions, so a bump landing between this re-read and the write is
|
|
341
|
-
still lost. This narrows the race window from the whole run to a single
|
|
342
|
-
write; it is a mitigation, not cross-process atomicity. A full fix needs a
|
|
343
|
-
compare-and-set or merge-callback update in storage, which is out of scope
|
|
344
|
-
for this file (storage.py is frozen this batch).
|
|
345
|
-
"""
|
|
346
|
-
try:
|
|
347
|
-
fresh = self.storage.load_pattern(fallback.id)
|
|
348
|
-
except Exception:
|
|
349
|
-
return fallback
|
|
350
|
-
if not fresh:
|
|
351
|
-
return fallback
|
|
352
|
-
if isinstance(fresh, dict):
|
|
353
|
-
return SemanticPattern.from_dict(fresh)
|
|
354
|
-
return fresh
|
|
340
|
+
# NOTE: The former _reload_pattern() helper (a partial C1 lost-update
|
|
341
|
+
# mitigation that re-read the pattern in a SEPARATE lock from the write) was
|
|
342
|
+
# removed once the merge moved fully inside storage.update_pattern_with_merge,
|
|
343
|
+
# which performs the read, merge, and write under ONE exclusive lock on
|
|
344
|
+
# patterns.json. That closes the lost-update race cross-process; no residual
|
|
345
|
+
# narrow window remains.
|
|
355
346
|
|
|
356
347
|
# -------------------------------------------------------------------------
|
|
357
348
|
# Clustering Methods
|
package/memory/storage.py
CHANGED
|
@@ -857,6 +857,75 @@ class MemoryStorage:
|
|
|
857
857
|
|
|
858
858
|
return True
|
|
859
859
|
|
|
860
|
+
def update_pattern_with_merge(self, pattern_id: str, merge_fn) -> bool:
|
|
861
|
+
"""Atomically merge into an existing pattern under a single lock.
|
|
862
|
+
|
|
863
|
+
Closes the consolidation lost-update (BUG-MEM C1): the previous flow read
|
|
864
|
+
the pattern (load_pattern) and wrote the merged result (update_pattern) in
|
|
865
|
+
SEPARATE lock acquisitions, so a concurrent increment_pattern_usage() bump
|
|
866
|
+
landing between the read and the write was lost. Here the read of the
|
|
867
|
+
current on-disk record, the caller's merge, and the write all happen
|
|
868
|
+
inside ONE exclusive _file_lock on patterns.json -- the same path
|
|
869
|
+
increment_pattern_usage() and update_pattern() lock -- so they mutually
|
|
870
|
+
exclude and no bump is clobbered.
|
|
871
|
+
|
|
872
|
+
Args:
|
|
873
|
+
pattern_id: Id of the existing pattern to merge into.
|
|
874
|
+
merge_fn: Callable taking the current on-disk pattern dict and
|
|
875
|
+
returning the merged record (dict, or any object exposing
|
|
876
|
+
to_dict()/__dict__). It must preserve the id. The dict it
|
|
877
|
+
receives is a fresh read performed under the lock.
|
|
878
|
+
|
|
879
|
+
Returns:
|
|
880
|
+
True if the pattern was found and the merged record written, False if
|
|
881
|
+
the pattern id was not present (caller should fall back to a create).
|
|
882
|
+
"""
|
|
883
|
+
if not pattern_id:
|
|
884
|
+
return False
|
|
885
|
+
|
|
886
|
+
patterns_path = self.base_path / "semantic" / "patterns.json"
|
|
887
|
+
|
|
888
|
+
with self._file_lock(patterns_path, exclusive=True):
|
|
889
|
+
if not patterns_path.exists():
|
|
890
|
+
return False
|
|
891
|
+
|
|
892
|
+
with open(patterns_path, "r", encoding="utf-8") as f:
|
|
893
|
+
try:
|
|
894
|
+
patterns_file = json.load(f)
|
|
895
|
+
except (json.JSONDecodeError, UnicodeDecodeError):
|
|
896
|
+
return False
|
|
897
|
+
|
|
898
|
+
patterns = patterns_file.get("patterns", [])
|
|
899
|
+
target_idx = None
|
|
900
|
+
current = None
|
|
901
|
+
for i, p in enumerate(patterns):
|
|
902
|
+
if isinstance(p, dict) and p.get("id") == pattern_id:
|
|
903
|
+
target_idx = i
|
|
904
|
+
current = p
|
|
905
|
+
break
|
|
906
|
+
|
|
907
|
+
if target_idx is None:
|
|
908
|
+
return False
|
|
909
|
+
|
|
910
|
+
# Caller merges against the fresh, lock-protected current record.
|
|
911
|
+
merged = merge_fn(current)
|
|
912
|
+
if hasattr(merged, "to_dict"):
|
|
913
|
+
merged_data = merged.to_dict()
|
|
914
|
+
elif hasattr(merged, "__dict__"):
|
|
915
|
+
merged_data = merged.__dict__.copy()
|
|
916
|
+
else:
|
|
917
|
+
merged_data = dict(merged)
|
|
918
|
+
|
|
919
|
+
# Never let a merge orphan the record by changing its id.
|
|
920
|
+
merged_data["id"] = pattern_id
|
|
921
|
+
merged_data["updated_at"] = datetime.now(timezone.utc).isoformat()
|
|
922
|
+
patterns_file["patterns"][target_idx] = merged_data
|
|
923
|
+
patterns_file["last_updated"] = datetime.now(timezone.utc).isoformat()
|
|
924
|
+
|
|
925
|
+
self._atomic_write(patterns_path, patterns_file)
|
|
926
|
+
|
|
927
|
+
return True
|
|
928
|
+
|
|
860
929
|
# -------------------------------------------------------------------------
|
|
861
930
|
# Skill Storage
|
|
862
931
|
# -------------------------------------------------------------------------
|
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.96.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.
|
|
5
|
+
"version": "7.96.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",
|