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/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
@@ -7,7 +7,7 @@ Modules:
7
7
  control: Session control API (start/stop/pause/resume)
8
8
  """
9
9
 
10
- __version__ = "7.92.0"
10
+ __version__ = "7.94.0"
11
11
 
12
12
  # Expose the control app for easy import
13
13
  try:
@@ -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
- "version": raw.get("version", ""),
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
- # Read version from VERSION file
709
- _sk_version = ""
710
- _vf = _Path(os.path.dirname(os.path.dirname(__file__))) / "VERSION"
711
- if _vf.is_file():
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
- # Read VERSION file early (independent of .loki/ dir)
1110
- version = "unknown"
1111
- dashboard_dir = os.path.dirname(__file__)
1112
- project_root = os.path.dirname(dashboard_dir)
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