loki-mode 7.92.0 → 7.94.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/lib/done-recognition.sh +823 -0
- package/autonomy/run.sh +74 -1
- package/dashboard/__init__.py +1 -1
- package/dashboard/server.py +179 -20
- package/dashboard/static/index.html +423 -104
- package/docs/INSTALLATION.md +2 -2
- package/docs/PRD-REUSE-DONE-RECOGNITION-PLAN.md +536 -0
- package/loki-ts/dist/loki.js +2 -2
- package/mcp/__init__.py +1 -1
- package/package.json +1 -1
- package/plugins/loki-mode/.claude-plugin/plugin.json +1 -1
package/autonomy/run.sh
CHANGED
|
@@ -13383,7 +13383,7 @@ except (json.JSONDecodeError, KeyError, TypeError, OSError):
|
|
|
13383
13383
|
ITERATION_COUNT=0
|
|
13384
13384
|
fi
|
|
13385
13385
|
;;
|
|
13386
|
-
failed|max_iterations_reached|max_retries_exceeded|exited|council_approved|council_force_approved|completion_promise_fulfilled)
|
|
13386
|
+
failed|max_iterations_reached|max_retries_exceeded|exited|council_approved|council_force_approved|completion_promise_fulfilled|reuse_already_satisfied)
|
|
13387
13387
|
log_info "Previous session ended with status: $prev_status. Resetting for new session."
|
|
13388
13388
|
RETRY_COUNT=0
|
|
13389
13389
|
ITERATION_COUNT=0
|
|
@@ -14859,6 +14859,40 @@ if os.path.exists(pending_path):
|
|
|
14859
14859
|
existing_ids = {t.get("id") for t in existing if isinstance(t, dict)}
|
|
14860
14860
|
added = 0
|
|
14861
14861
|
|
|
14862
|
+
# Reuse done-recognition (v7.94.0): if a satisfied-requirements manifest exists
|
|
14863
|
+
# AND its prd_sha matches THIS PRD's hash, skip any feature whose title is
|
|
14864
|
+
# already satisfied, so an incremental reuse run rebuilds ONLY the gap. A stale
|
|
14865
|
+
# or absent manifest is ignored (full build -- the safe default). The sha is
|
|
14866
|
+
# computed over the SAME PRD file bytes the gate hashed (hashlib.sha256), so the
|
|
14867
|
+
# guard matches byte-for-byte. Title match is normalized (case-insensitive, with
|
|
14868
|
+
# a leading "feature:/requirement:/epic:/story:" heading prefix stripped) so a
|
|
14869
|
+
# model-returned title like "User login" matches the parser's heading title
|
|
14870
|
+
# "Feature: User login". bash 3.2 safe: all normalization happens in python.
|
|
14871
|
+
def _dr_norm_title(s):
|
|
14872
|
+
s = (s or "").strip().lower()
|
|
14873
|
+
for _pfx in ("feature:", "requirement:", "epic:", "story:", "user story:"):
|
|
14874
|
+
if s.startswith(_pfx):
|
|
14875
|
+
s = s[len(_pfx):].strip()
|
|
14876
|
+
break
|
|
14877
|
+
return s
|
|
14878
|
+
|
|
14879
|
+
_dr_satisfied = set()
|
|
14880
|
+
try:
|
|
14881
|
+
import hashlib
|
|
14882
|
+
_dr_manifest = ".loki/state/satisfied-requirements.json"
|
|
14883
|
+
if os.path.isfile(_dr_manifest):
|
|
14884
|
+
with open(_dr_manifest, "r") as _mf:
|
|
14885
|
+
_dr_data = json.load(_mf)
|
|
14886
|
+
_dr_manifest_sha = (_dr_data.get("prd_sha") or "").strip()
|
|
14887
|
+
with open(prd_path, "rb") as _pf:
|
|
14888
|
+
_dr_cur_sha = hashlib.sha256(_pf.read()).hexdigest()
|
|
14889
|
+
if _dr_manifest_sha and _dr_manifest_sha == _dr_cur_sha:
|
|
14890
|
+
for _t in _dr_data.get("satisfied", []):
|
|
14891
|
+
if isinstance(_t, str) and _t.strip():
|
|
14892
|
+
_dr_satisfied.add(_dr_norm_title(_t))
|
|
14893
|
+
except Exception:
|
|
14894
|
+
_dr_satisfied = set()
|
|
14895
|
+
|
|
14862
14896
|
# BUG-V63-001 fix: extract audience once with flag to break both loops
|
|
14863
14897
|
audience = "a user"
|
|
14864
14898
|
audience_found = False
|
|
@@ -14878,6 +14912,12 @@ for i, feat in enumerate(features):
|
|
|
14878
14912
|
if task_id in existing_ids:
|
|
14879
14913
|
continue
|
|
14880
14914
|
|
|
14915
|
+
# Skip features the done-recognition gate verified as already satisfied
|
|
14916
|
+
# (manifest-driven, title-keyed, normalized match). Only unmet requirements
|
|
14917
|
+
# become tasks, so the RARV loop works only the gap.
|
|
14918
|
+
if _dr_satisfied and _dr_norm_title(feat.get("title")) in _dr_satisfied:
|
|
14919
|
+
continue
|
|
14920
|
+
|
|
14881
14921
|
criteria = extract_acceptance_criteria(feat["section"], sections)
|
|
14882
14922
|
|
|
14883
14923
|
# Build a rich description
|
|
@@ -15238,6 +15278,39 @@ except Exception:
|
|
|
15238
15278
|
load_state
|
|
15239
15279
|
local retry=$RETRY_COUNT
|
|
15240
15280
|
|
|
15281
|
+
# Reuse done-recognition gate (v7.94.0). On a no-PRD run that is REUSING an
|
|
15282
|
+
# already-generated PRD, model-verify whether the codebase already satisfies
|
|
15283
|
+
# that spec BEFORE rebuilding a task queue and re-running the RARV loop.
|
|
15284
|
+
# Routes to one of three outcomes (the verdict is the model's, grounded in
|
|
15285
|
+
# re-run tests + code; the only deterministic shortcut is NEGATIVE -> build):
|
|
15286
|
+
# done -> refresh the verified-completion record, finalize, return 0
|
|
15287
|
+
# so the queue/loop is skipped and main()'s terminal block
|
|
15288
|
+
# finishes the run (no wasted iterations, no stray delegate
|
|
15289
|
+
# branch -- this runs BEFORE the start-sha/delegate block).
|
|
15290
|
+
# incomplete -> write .loki/state/satisfied-requirements.json so
|
|
15291
|
+
# populate_prd_queue builds ONLY the unsatisfied items, then
|
|
15292
|
+
# fall through to the (now incremental) build.
|
|
15293
|
+
# inconclusive-> fall through to the normal full build (safe default).
|
|
15294
|
+
# Default-on; LOKI_DONE_RECOGNITION=0 disables it. Armed only on a reuse of
|
|
15295
|
+
# an existing generated PRD; `update` (stale PRD) may never fast-stop as done.
|
|
15296
|
+
case "${GENERATED_PRD_ACTION:-}" in
|
|
15297
|
+
reuse|user_owned|update)
|
|
15298
|
+
local _done_recog_lib="$SCRIPT_DIR/lib/done-recognition.sh"
|
|
15299
|
+
if [ -f "$_done_recog_lib" ]; then
|
|
15300
|
+
# shellcheck source=lib/done-recognition.sh
|
|
15301
|
+
source "$_done_recog_lib" 2>/dev/null || true
|
|
15302
|
+
if declare -f reuse_done_recognition_gate >/dev/null 2>&1; then
|
|
15303
|
+
if reuse_done_recognition_gate "$prd_path"; then
|
|
15304
|
+
# done verdict: the gate finalized; main()'s terminal
|
|
15305
|
+
# block (run_autonomous's caller) runs the COMPLETED
|
|
15306
|
+
# marker, proof-of-run, and HANDOFF.md.
|
|
15307
|
+
return 0
|
|
15308
|
+
fi
|
|
15309
|
+
fi
|
|
15310
|
+
fi
|
|
15311
|
+
;;
|
|
15312
|
+
esac
|
|
15313
|
+
|
|
15241
15314
|
# Capture run-start SHA for the evidence hard gate (v7.19.1).
|
|
15242
15315
|
# Fresh-run-aware: recapture HEAD when ITERATION_COUNT==0 (fresh invocation,
|
|
15243
15316
|
# reset, or corrupted/missing baseline); preserve only on a genuine resume
|
package/dashboard/__init__.py
CHANGED
package/dashboard/server.py
CHANGED
|
@@ -667,7 +667,14 @@ async def _push_loki_state_loop() -> None:
|
|
|
667
667
|
"running_agents": running_agents,
|
|
668
668
|
"pending_tasks": len(pending) if isinstance(pending, list) else 0,
|
|
669
669
|
"current_task": in_prog[0].get("payload", {}).get("action", "") if isinstance(in_prog, list) and in_prog else "",
|
|
670
|
-
|
|
670
|
+
# Version reflects the RUNNING engine, not the project's
|
|
671
|
+
# stored state. raw.get("version") is whatever engine
|
|
672
|
+
# last wrote dashboard-state.json for THIS project (can
|
|
673
|
+
# be an old version, e.g. a project first built under
|
|
674
|
+
# 7.7.29), which made the displayed version flip between
|
|
675
|
+
# this stale value and the live one from the fallback
|
|
676
|
+
# path below. Always use the live engine version.
|
|
677
|
+
"version": _version,
|
|
671
678
|
}
|
|
672
679
|
await manager.broadcast({
|
|
673
680
|
"type": "status_update",
|
|
@@ -705,14 +712,10 @@ async def _push_loki_state_loop() -> None:
|
|
|
705
712
|
pass
|
|
706
713
|
|
|
707
714
|
if _sk_fresh:
|
|
708
|
-
#
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
try:
|
|
713
|
-
_sk_version = _vf.read_text().strip()
|
|
714
|
-
except OSError:
|
|
715
|
-
pass
|
|
715
|
+
# Version reflects the running engine (single source of
|
|
716
|
+
# truth), the same value the dashboard-state path uses
|
|
717
|
+
# above, so the displayed version never flips.
|
|
718
|
+
_sk_version = _version
|
|
716
719
|
|
|
717
720
|
# Read orchestrator state
|
|
718
721
|
_sk_phase = ""
|
|
@@ -1106,17 +1109,10 @@ async def get_status() -> StatusResponse:
|
|
|
1106
1109
|
loki_dir = _get_loki_dir()
|
|
1107
1110
|
uptime = (datetime.now(timezone.utc) - start_time).total_seconds()
|
|
1108
1111
|
|
|
1109
|
-
#
|
|
1110
|
-
version
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
version_file = os.path.join(project_root, "VERSION")
|
|
1114
|
-
if os.path.isfile(version_file):
|
|
1115
|
-
try:
|
|
1116
|
-
with open(version_file) as vf:
|
|
1117
|
-
version = vf.read().strip()
|
|
1118
|
-
except (OSError, IOError) as e:
|
|
1119
|
-
logger.warning(f"Failed to read VERSION file: {e}")
|
|
1112
|
+
# Version reflects the running engine (single source of truth: the package
|
|
1113
|
+
# __version__, same value every status path uses) so the displayed version is
|
|
1114
|
+
# always the live engine, never a stale per-project value.
|
|
1115
|
+
version = _version
|
|
1120
1116
|
|
|
1121
1117
|
# If .loki/ directory doesn't exist, return idle status immediately
|
|
1122
1118
|
if not loki_dir.is_dir():
|
|
@@ -8784,6 +8780,157 @@ def _reconcile_app_runner_liveness(state):
|
|
|
8784
8780
|
return state
|
|
8785
8781
|
|
|
8786
8782
|
|
|
8783
|
+
# Per-probe TCP connect timeout (seconds) for the single-process port probe.
|
|
8784
|
+
# Kept short so a stopped/firewalled port fails fast and the status endpoint
|
|
8785
|
+
# stays responsive for the dashboard's 3-5s pollers.
|
|
8786
|
+
_APP_RUNNER_PORT_PROBE_TIMEOUT = 1.0
|
|
8787
|
+
|
|
8788
|
+
|
|
8789
|
+
def _port_is_serving(port):
|
|
8790
|
+
"""True only if a TCP connection to 127.0.0.1:<port> genuinely succeeds.
|
|
8791
|
+
|
|
8792
|
+
Honest by construction: this proves *something* is accepting connections on
|
|
8793
|
+
the recorded port right now, never fabricates a result. Any failure (refused,
|
|
8794
|
+
timeout, bad port, OS error) returns False so the caller degrades to an
|
|
8795
|
+
honest non-running state. Synchronous; the caller offloads it to a worker
|
|
8796
|
+
thread so the event loop is never blocked by the connect.
|
|
8797
|
+
"""
|
|
8798
|
+
try:
|
|
8799
|
+
port = int(port)
|
|
8800
|
+
except (TypeError, ValueError):
|
|
8801
|
+
return False
|
|
8802
|
+
if port <= 0 or port > 65535:
|
|
8803
|
+
return False
|
|
8804
|
+
import socket
|
|
8805
|
+
for host in ("127.0.0.1", "::1"):
|
|
8806
|
+
sock = None
|
|
8807
|
+
try:
|
|
8808
|
+
family = socket.AF_INET6 if ":" in host else socket.AF_INET
|
|
8809
|
+
sock = socket.socket(family, socket.SOCK_STREAM)
|
|
8810
|
+
sock.settimeout(_APP_RUNNER_PORT_PROBE_TIMEOUT)
|
|
8811
|
+
if sock.connect_ex((host, port)) == 0:
|
|
8812
|
+
return True
|
|
8813
|
+
except OSError:
|
|
8814
|
+
continue
|
|
8815
|
+
finally:
|
|
8816
|
+
if sock is not None:
|
|
8817
|
+
try:
|
|
8818
|
+
sock.close()
|
|
8819
|
+
except OSError:
|
|
8820
|
+
pass
|
|
8821
|
+
return False
|
|
8822
|
+
|
|
8823
|
+
|
|
8824
|
+
def _dashboard_self_port():
|
|
8825
|
+
"""The TCP port this dashboard process itself is bound to, or None.
|
|
8826
|
+
|
|
8827
|
+
The user's app can never listen on the port the dashboard already occupies,
|
|
8828
|
+
so this is used to exclude a self-hit from the single-process probe (a stale
|
|
8829
|
+
recorded port that happens to equal the dashboard's port would otherwise
|
|
8830
|
+
probe-succeed against the dashboard and be misreported as the app running).
|
|
8831
|
+
Reads LOKI_DASHBOARD_PORT (the same env run_server binds), defaulting to the
|
|
8832
|
+
well-known 57374. Returns an int or None.
|
|
8833
|
+
"""
|
|
8834
|
+
try:
|
|
8835
|
+
return int(os.environ.get("LOKI_DASHBOARD_PORT", "57374"))
|
|
8836
|
+
except (TypeError, ValueError):
|
|
8837
|
+
return 57374
|
|
8838
|
+
|
|
8839
|
+
|
|
8840
|
+
def _recorded_app_port(state):
|
|
8841
|
+
"""Best-effort recorded port for the single-process app, or None.
|
|
8842
|
+
|
|
8843
|
+
Reads the port the app-runner/CLI already recorded for THIS project, never a
|
|
8844
|
+
guessed value: first state.json's own `port`, then the app-runner
|
|
8845
|
+
detection.json the engine writes (`.loki/app-runner/detection.json`). Returns
|
|
8846
|
+
an int in the valid range or None. Pairing the probe to a *recorded* port is
|
|
8847
|
+
what keeps the result honest -- we never sweep arbitrary common ports.
|
|
8848
|
+
|
|
8849
|
+
Honesty guards:
|
|
8850
|
+
- A docker-compose detection.json is ignored here; a compose stack is the
|
|
8851
|
+
compose-discovery path's domain (which verifies the container belongs to
|
|
8852
|
+
the project), so feeding its port into the single-process probe would
|
|
8853
|
+
risk a false positive against an unrelated local service.
|
|
8854
|
+
- The dashboard's own port is never returned, since the user's app cannot
|
|
8855
|
+
bind it and a stale recorded value equal to it would self-hit the probe.
|
|
8856
|
+
"""
|
|
8857
|
+
self_port = _dashboard_self_port()
|
|
8858
|
+
|
|
8859
|
+
def _ok(p):
|
|
8860
|
+
try:
|
|
8861
|
+
p = int(p)
|
|
8862
|
+
except (TypeError, ValueError):
|
|
8863
|
+
return None
|
|
8864
|
+
if not (0 < p <= 65535):
|
|
8865
|
+
return None
|
|
8866
|
+
if self_port is not None and p == self_port:
|
|
8867
|
+
return None
|
|
8868
|
+
return p
|
|
8869
|
+
|
|
8870
|
+
if isinstance(state, dict):
|
|
8871
|
+
p = _ok(state.get("port"))
|
|
8872
|
+
if p is not None:
|
|
8873
|
+
return p
|
|
8874
|
+
try:
|
|
8875
|
+
det_file = _get_loki_dir() / "app-runner" / "detection.json"
|
|
8876
|
+
if det_file.is_file():
|
|
8877
|
+
det = json.loads(det_file.read_text())
|
|
8878
|
+
if isinstance(det, dict) and not det.get("is_docker"):
|
|
8879
|
+
p = _ok(det.get("port"))
|
|
8880
|
+
if p is not None:
|
|
8881
|
+
return p
|
|
8882
|
+
except (OSError, ValueError, TypeError, json.JSONDecodeError):
|
|
8883
|
+
pass
|
|
8884
|
+
return None
|
|
8885
|
+
|
|
8886
|
+
|
|
8887
|
+
def _discover_single_process_app_runner_state(state):
|
|
8888
|
+
"""Detect a genuinely-running single-process app via a recorded-port probe.
|
|
8889
|
+
|
|
8890
|
+
A SKILL/CLI-built project (e.g. one started with `npm run dev` outside
|
|
8891
|
+
app-runner.sh, or whose orchestrator has since exited) leaves a state.json
|
|
8892
|
+
with status "stopped"/"stale" and main_pid 0, even while the app itself is
|
|
8893
|
+
still serving. The dashboard would then report "not running" for a live app.
|
|
8894
|
+
|
|
8895
|
+
Resolution (all probe-verified, never fabricated):
|
|
8896
|
+
- Find the RECORDED port (state.json.port or non-docker detection.json) --
|
|
8897
|
+
never a guessed port, and never the dashboard's own port.
|
|
8898
|
+
- Probe 127.0.0.1:<port>. Only if the connection genuinely succeeds do we
|
|
8899
|
+
synthesize a "running" state using the recorded url/port.
|
|
8900
|
+
- Otherwise (no recorded port, or recorded port not reachable) return None;
|
|
8901
|
+
the caller keeps the honest reconciled state, which already carries
|
|
8902
|
+
positive evidence (the writer's settled "stopped", or a pid_gone /
|
|
8903
|
+
recycled / health_stale downgrade). "Unknown" would be less honest than
|
|
8904
|
+
that evidence, so we never override it here.
|
|
8905
|
+
|
|
8906
|
+
Synchronous and self-contained; the caller offloads it onto a worker thread.
|
|
8907
|
+
Never raises.
|
|
8908
|
+
"""
|
|
8909
|
+
try:
|
|
8910
|
+
port = _recorded_app_port(state)
|
|
8911
|
+
if not port:
|
|
8912
|
+
return None
|
|
8913
|
+
if not _port_is_serving(port):
|
|
8914
|
+
return None
|
|
8915
|
+
url = ""
|
|
8916
|
+
if isinstance(state, dict):
|
|
8917
|
+
url = state.get("url") or ""
|
|
8918
|
+
if not url:
|
|
8919
|
+
url = "http://localhost:{}".format(port)
|
|
8920
|
+
return {
|
|
8921
|
+
"status": "running",
|
|
8922
|
+
"url": url,
|
|
8923
|
+
"port": int(port),
|
|
8924
|
+
"method": (state.get("method") if isinstance(state, dict) else "") or "",
|
|
8925
|
+
"source": "probe",
|
|
8926
|
+
"externally_managed": True,
|
|
8927
|
+
"last_health": {"ok": True},
|
|
8928
|
+
}
|
|
8929
|
+
except Exception:
|
|
8930
|
+
# Fail open: never let the probe break the status endpoint.
|
|
8931
|
+
return None
|
|
8932
|
+
|
|
8933
|
+
|
|
8787
8934
|
# =============================================================================
|
|
8788
8935
|
# Docker-compose app-runner discovery
|
|
8789
8936
|
#
|
|
@@ -9191,6 +9338,10 @@ async def get_app_runner_status():
|
|
|
9191
9338
|
discovered = await asyncio.to_thread(_discover_compose_app_runner_state)
|
|
9192
9339
|
if discovered is not None:
|
|
9193
9340
|
return discovered
|
|
9341
|
+
# No state.json at all and no compose stack: there is no recorded port to
|
|
9342
|
+
# probe, so the single-process probe can only ever return an honest
|
|
9343
|
+
# "unknown" here. Keep returning not_initialized (the project has never
|
|
9344
|
+
# had an app-runner record) rather than overstating with "unknown".
|
|
9194
9345
|
return {"status": "not_initialized"}
|
|
9195
9346
|
|
|
9196
9347
|
try:
|
|
@@ -9208,6 +9359,14 @@ async def get_app_runner_status():
|
|
|
9208
9359
|
discovered = await asyncio.to_thread(_discover_compose_app_runner_state)
|
|
9209
9360
|
if discovered is not None:
|
|
9210
9361
|
return discovered
|
|
9362
|
+
|
|
9363
|
+
# Still nothing from compose: a SKILL/CLI-built project may be serving on its
|
|
9364
|
+
# recorded port even though no live app-runner.sh process owns it (main_pid 0
|
|
9365
|
+
# / orchestrator exited). Probe the RECORDED port and only report running when
|
|
9366
|
+
# the port genuinely answers; otherwise keep the honest reconciled state.
|
|
9367
|
+
probed = await asyncio.to_thread(_discover_single_process_app_runner_state, state)
|
|
9368
|
+
if isinstance(probed, dict) and probed.get("status") == "running":
|
|
9369
|
+
return probed
|
|
9211
9370
|
return reconciled
|
|
9212
9371
|
|
|
9213
9372
|
|