loki-mode 7.41.5 → 7.43.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/loki CHANGED
@@ -1057,6 +1057,7 @@ cmd_start() {
1057
1057
  echo "Options:"
1058
1058
  echo " --provider NAME AI provider: claude (default), codex, cline, aider"
1059
1059
  echo " --parallel Enable parallel mode with git worktrees"
1060
+ echo " --allow-haiku Enable Haiku model for the fast tier (default: disabled)"
1060
1061
  echo " --bg, --background Run in background mode"
1061
1062
  echo " --simple Force simple complexity tier (3 phases)"
1062
1063
  echo " --complex Force complex complexity tier (8 phases)"
@@ -1180,6 +1181,17 @@ cmd_start() {
1180
1181
  args+=("--parallel")
1181
1182
  shift
1182
1183
  ;;
1184
+ --allow-haiku)
1185
+ # Enable Haiku for the fast tier. Mirrors the LOKI_ALLOW_HAIKU=true
1186
+ # env var (consumed by providers/claude.sh and run.sh). Documented in
1187
+ # loki --help and run.sh; previously only the env var worked here, so
1188
+ # `loki start ./prd.md --allow-haiku` aborted with "Unknown option".
1189
+ # Export reaches the runner; also forward as an arg so the run.sh
1190
+ # parser (run.sh:15015) sees it on every route.
1191
+ export LOKI_ALLOW_HAIKU=true
1192
+ args+=("--allow-haiku")
1193
+ shift
1194
+ ;;
1183
1195
  --regen-prd|--regenerate-prd|--regen|--fresh-prd)
1184
1196
  # v7.8.1: force a fresh generated PRD on a no-PRD run, overriding
1185
1197
  # the staleness-aware reuse (decide_generated_prd_action in
@@ -13178,13 +13190,18 @@ FEOF
13178
13190
  ;;
13179
13191
  --disable)
13180
13192
  if [ -f "$failover_file" ]; then
13181
- python3 -c "
13182
- import json
13183
- with open('$failover_file') as f: d = json.load(f)
13193
+ if _FAILOVER_FILE="$failover_file" python3 -c "
13194
+ import json, os
13195
+ failover_file = os.environ['_FAILOVER_FILE']
13196
+ with open(failover_file) as f: d = json.load(f)
13184
13197
  d['enabled'] = False
13185
- with open('$failover_file', 'w') as f: json.dump(d, f, indent=2)
13186
- " 2>/dev/null
13187
- echo -e "${YELLOW}Failover disabled${NC}"
13198
+ with open(failover_file, 'w') as f: json.dump(d, f, indent=2)
13199
+ "; then
13200
+ echo -e "${YELLOW}Failover disabled${NC}"
13201
+ else
13202
+ echo -e "${RED}Error: failed to disable failover${NC}"
13203
+ return 1
13204
+ fi
13188
13205
  else
13189
13206
  echo "Failover not initialized."
13190
13207
  fi
@@ -13212,13 +13229,19 @@ with open('$failover_file', 'w') as f: json.dump(d, f, indent=2)
13212
13229
  return 1
13213
13230
  fi
13214
13231
 
13215
- python3 -c "
13216
- import json
13217
- with open('$failover_file') as f: d = json.load(f)
13218
- d['chain'] = '$new_chain'.split(',')
13219
- with open('$failover_file', 'w') as f: json.dump(d, f, indent=2)
13220
- " 2>/dev/null
13221
- echo "Failover chain updated: $new_chain"
13232
+ if _FAILOVER_FILE="$failover_file" _NEW_CHAIN="$new_chain" python3 -c "
13233
+ import json, os
13234
+ failover_file = os.environ['_FAILOVER_FILE']
13235
+ new_chain = os.environ['_NEW_CHAIN']
13236
+ with open(failover_file) as f: d = json.load(f)
13237
+ d['chain'] = new_chain.split(',')
13238
+ with open(failover_file, 'w') as f: json.dump(d, f, indent=2)
13239
+ "; then
13240
+ echo "Failover chain updated: $new_chain"
13241
+ else
13242
+ echo -e "${RED}Error: failed to update failover chain${NC}"
13243
+ return 1
13244
+ fi
13222
13245
  shift
13223
13246
  ;;
13224
13247
  --test)
@@ -18601,16 +18624,16 @@ else:
18601
18624
  exit 1
18602
18625
  fi
18603
18626
 
18604
- python3 -c "
18627
+ _REGISTRY_FILE="$registry_file" _PROJ_PATH="$path" _PROJ_NAME="$name" _PROJ_ALIAS="$alias" python3 -c "
18605
18628
  import json
18606
18629
  import os
18607
18630
  import hashlib
18608
18631
  from datetime import datetime, timezone
18609
18632
 
18610
- registry_file = '$registry_file'
18611
- path = '$path'
18612
- name = '$name' or os.path.basename(path)
18613
- alias = '$alias' or None
18633
+ registry_file = os.environ['_REGISTRY_FILE']
18634
+ path = os.environ['_PROJ_PATH']
18635
+ name = os.environ['_PROJ_NAME'] or os.path.basename(path)
18636
+ alias = os.environ['_PROJ_ALIAS'] or None
18614
18637
 
18615
18638
  # Generate project ID
18616
18639
  project_id = hashlib.md5(path.encode()).hexdigest()[:12]
@@ -18651,7 +18674,7 @@ with open(registry_file, 'w') as f:
18651
18674
  print(f' Path: {path}')
18652
18675
  if alias:
18653
18676
  print(f' Alias: {alias}')
18654
- " 2>/dev/null
18677
+ "
18655
18678
  ;;
18656
18679
 
18657
18680
  remove|rm)
@@ -18662,12 +18685,12 @@ if alias:
18662
18685
  exit 1
18663
18686
  fi
18664
18687
 
18665
- python3 -c "
18688
+ _REGISTRY_FILE="$registry_file" _IDENTIFIER="$identifier" python3 -c "
18666
18689
  import json
18667
18690
  import os
18668
18691
 
18669
- registry_file = '$registry_file'
18670
- identifier = '$identifier'
18692
+ registry_file = os.environ['_REGISTRY_FILE']
18693
+ identifier = os.environ['_IDENTIFIER']
18671
18694
 
18672
18695
  with open(registry_file, 'r') as f:
18673
18696
  data = json.load(f)
@@ -18690,7 +18713,7 @@ if found_id:
18690
18713
  else:
18691
18714
  print(f'Not found: {identifier}')
18692
18715
  exit(1)
18693
- " 2>/dev/null
18716
+ "
18694
18717
  ;;
18695
18718
 
18696
18719
  discover)
@@ -18842,12 +18865,12 @@ print(f'Added: {added}, Missing: {missing}, Total: {len(projects)}')
18842
18865
  health)
18843
18866
  local identifier="${2:-$(pwd)}"
18844
18867
 
18845
- python3 -c "
18868
+ _REGISTRY_FILE="$registry_file" _IDENTIFIER="$identifier" python3 -c "
18846
18869
  import json
18847
18870
  import os
18848
18871
 
18849
- registry_file = '$registry_file'
18850
- identifier = '$identifier'
18872
+ registry_file = os.environ['_REGISTRY_FILE']
18873
+ identifier = os.environ['_IDENTIFIER']
18851
18874
 
18852
18875
  # If it's a path, resolve it
18853
18876
  if os.path.isdir(identifier):
@@ -18886,7 +18909,7 @@ print('Health Checks:')
18886
18909
  for check, passed in checks.items():
18887
18910
  icon = '[OK]' if passed else '[FAIL]'
18888
18911
  print(f' {icon} {check}')
18889
- " 2>/dev/null
18912
+ "
18890
18913
  ;;
18891
18914
 
18892
18915
  --help|-h|help)
@@ -19040,17 +19063,17 @@ cmd_enterprise() {
19040
19063
  esac
19041
19064
  done
19042
19065
 
19043
- python3 -c "
19066
+ _TOKEN_FILE="$token_file" _TOKEN_NAME="$name" _TOKEN_SCOPES="$scopes" _TOKEN_EXPIRES="$expires" python3 -c "
19044
19067
  import json
19045
19068
  import secrets
19046
19069
  import hashlib
19047
19070
  from datetime import datetime, timezone, timedelta
19048
19071
  import os
19049
19072
 
19050
- token_file = '$token_file'
19051
- name = '$name'
19052
- scopes_str = '$scopes'
19053
- expires_str = '$expires'
19073
+ token_file = os.environ['_TOKEN_FILE']
19074
+ name = os.environ['_TOKEN_NAME']
19075
+ scopes_str = os.environ['_TOKEN_SCOPES']
19076
+ expires_str = os.environ['_TOKEN_EXPIRES']
19054
19077
 
19055
19078
  # Parse scopes
19056
19079
  scopes = scopes_str.split(',') if scopes_str else ['*']
@@ -19105,7 +19128,7 @@ if expires_at:
19105
19128
  print('')
19106
19129
  print('Token (save this - shown only once):')
19107
19130
  print(f' {raw_token}')
19108
- " 2>/dev/null
19131
+ "
19109
19132
  ;;
19110
19133
 
19111
19134
  list|ls)
@@ -19174,12 +19197,12 @@ else:
19174
19197
  exit 2
19175
19198
  fi
19176
19199
 
19177
- python3 -c "
19178
- import json
19200
+ _TOKEN_FILE="$token_file" _IDENTIFIER="$identifier" python3 -c "
19201
+ import json, os
19179
19202
  from datetime import datetime, timezone
19180
19203
 
19181
- token_file = '$token_file'
19182
- identifier = '$identifier'
19204
+ token_file = os.environ['_TOKEN_FILE']
19205
+ identifier = os.environ['_IDENTIFIER']
19183
19206
 
19184
19207
  with open(token_file, 'r') as f:
19185
19208
  data = json.load(f)
@@ -19202,7 +19225,7 @@ if found_id:
19202
19225
  else:
19203
19226
  print(f'Token not found: {identifier}')
19204
19227
  exit(1)
19205
- " 2>/dev/null
19228
+ "
19206
19229
  ;;
19207
19230
 
19208
19231
  delete)
@@ -19213,11 +19236,11 @@ else:
19213
19236
  exit 2
19214
19237
  fi
19215
19238
 
19216
- python3 -c "
19217
- import json
19239
+ _TOKEN_FILE="$token_file" _IDENTIFIER="$identifier" python3 -c "
19240
+ import json, os
19218
19241
 
19219
- token_file = '$token_file'
19220
- identifier = '$identifier'
19242
+ token_file = os.environ['_TOKEN_FILE']
19243
+ identifier = os.environ['_IDENTIFIER']
19221
19244
 
19222
19245
  with open(token_file, 'r') as f:
19223
19246
  data = json.load(f)
@@ -19241,7 +19264,7 @@ if found_id:
19241
19264
  else:
19242
19265
  print(f'Token not found: {identifier}')
19243
19266
  exit(1)
19244
- " 2>/dev/null
19267
+ "
19245
19268
  ;;
19246
19269
 
19247
19270
  *)
package/autonomy/run.sh CHANGED
@@ -7041,6 +7041,48 @@ enforce_test_coverage() {
7041
7041
  local output
7042
7042
  output=$(cd "${TARGET_DIR:-.}" && timeout "$gate_timeout" npx mocha 2>&1) || test_passed=false
7043
7043
  details="mocha: $(echo "$output" | tail -3 | tr '\n' ' ')"
7044
+ else
7045
+ # v7.41.x (test-coverage fail-open fix): a real "scripts.test" was
7046
+ # previously missed entirely. A greenfield project whose package.json
7047
+ # has {"scripts":{"test":"node --test"}} (or any non-placeholder test
7048
+ # script) actually runs a working suite via `npm test`, yet the gate
7049
+ # reported runner:none + pass:true -- so a project whose tests FAIL
7050
+ # green-lit identically. Detect a real test script (excluding the npm
7051
+ # placeholder "no test specified") with a JSON parser, not grep (grep
7052
+ # would false-positive on devDeps / unrelated keys), then run the
7053
+ # configured command. This MUST sit before the monorepo/python/go/rust
7054
+ # checks, all of which gate on test_runner=="none".
7055
+ local _pkg_test_script
7056
+ _pkg_test_script=$(_LOKI_PKG="${TARGET_DIR:-.}/package.json" python3 -c "
7057
+ import json, os, sys
7058
+ try:
7059
+ with open(os.environ['_LOKI_PKG']) as f:
7060
+ d = json.load(f)
7061
+ except Exception:
7062
+ sys.exit(0)
7063
+ t = (d.get('scripts') or {}).get('test') or ''
7064
+ # npm's default placeholder; treat as 'no test'.
7065
+ if 'no test specified' in t.lower():
7066
+ sys.exit(0)
7067
+ sys.stdout.write(t.strip())
7068
+ " 2>/dev/null || echo "")
7069
+ if [ -n "$_pkg_test_script" ]; then
7070
+ # LOKI_TEST_COMMAND lets an operator override the invocation; the
7071
+ # default is the project's own `npm test`.
7072
+ local _test_cmd="${LOKI_TEST_COMMAND:-npm test}"
7073
+ # Label the runner by what the script invokes so evidence is
7074
+ # honest (node --test, vitest, jest, etc. all surface here).
7075
+ case "$_pkg_test_script" in
7076
+ *"node --test"*|*"node:test"*) test_runner="node-test" ;;
7077
+ *vitest*) test_runner="vitest" ;;
7078
+ *jest*) test_runner="jest" ;;
7079
+ *mocha*) test_runner="mocha" ;;
7080
+ *) test_runner="npm-test" ;;
7081
+ esac
7082
+ local output
7083
+ output=$(cd "${TARGET_DIR:-.}" && timeout "$gate_timeout" sh -c "$_test_cmd" 2>&1) || test_passed=false
7084
+ details="$test_runner ($_test_cmd): $(echo "$output" | tail -5 | tr '\n' ' ')"
7085
+ fi
7044
7086
  fi
7045
7087
  fi
7046
7088
 
@@ -7165,10 +7207,23 @@ enforce_test_coverage() {
7165
7207
  fi
7166
7208
 
7167
7209
  if [ "$test_runner" = "none" ]; then
7168
- log_info "Test coverage: no test runner detected, skipping"
7210
+ log_info "Test coverage: no test runner detected, recording inconclusive (not pass)"
7211
+ # v7.41.x fail-open fix: previously this wrote pass:true, so a project
7212
+ # whose tests truly do not run was indistinguishable from one whose tests
7213
+ # passed. Record pass:"inconclusive" instead. The completion-council
7214
+ # evidence gate already treats runner=="none" as pass-through regardless
7215
+ # of the pass value (completion-council.sh: runner=='none' short-circuits
7216
+ # BEFORE the `passed is False` block), so genuinely-no-tests stays
7217
+ # non-blocking (no infinite hang), while the JSON record is now honest:
7218
+ # "no tests" never reads as "tests passed". A DETECTED runner that fails
7219
+ # still writes pass:false below and BLOCKS.
7220
+ #
7221
+ # unit-tests.pass is only read for the status-line display (run.sh ~2183,
7222
+ # PASS vs PENDING); keeping the touch preserves the historical
7223
+ # non-blocking behavior for legitimate no-test projects.
7169
7224
  touch "$quality_dir/unit-tests.pass"
7170
7225
  cat > "$quality_dir/test-results.json" << TREOF
7171
- {"timestamp":"$(date -u +%Y-%m-%dT%H:%M:%SZ)","runner":"none","pass":true,"summary":"No test runner detected"}
7226
+ {"timestamp":"$(date -u +%Y-%m-%dT%H:%M:%SZ)","runner":"none","pass":"inconclusive","summary":"No test runner detected"}
7172
7227
  TREOF
7173
7228
  # Finding #598: stamp the per-iteration freshness marker so a later
7174
7229
  # completion-route capture (ensure_completion_test_evidence) reuses this
@@ -14161,6 +14216,22 @@ if __name__ == "__main__":
14161
14216
  log_warn " Review details under .loki/quality/reviews/ ; gate_failures=${gate_failures}"
14162
14217
  _gate_block_for_completion=""
14163
14218
  # Fall through; the gate-failed loop continues normally
14219
+ # HIGH (trust-gate): the checklist hard gate must also guard the
14220
+ # DEFAULT completion-promise / loki_complete_task route, not only the
14221
+ # interval-gated council path (council_evaluate) and the dashboard
14222
+ # force-review path -- both of which already call this gate. Without
14223
+ # it, an agent that leaves a `priority: critical` checklist item
14224
+ # `failing` and claims done on a non-council-interval iteration would
14225
+ # ship, bypassing the checklist gate entirely. council_reverify_checklist
14226
+ # ran above (when a claim is present) so statuses are fresh here.
14227
+ # Mirrors the evidence/held-out gate arms below. No-op safe:
14228
+ # council_checklist_gate returns 0 (pass) when there is no checklist
14229
+ # results file or when no critical items are failing, so this branch
14230
+ # never fires on those projects. Gate output is written by the gate.
14231
+ elif [ "$_completion_claimed" = 1 ] && type council_checklist_gate &>/dev/null && ! council_checklist_gate; then
14232
+ log_warn "Completion claim rejected: critical checklist item(s) failing (hard gate)."
14233
+ log_warn " Details under .loki/council/gate-block.json"
14234
+ # Fall through; keep iterating until critical checklist items pass.
14164
14235
  # v7.19.1: the verified-completion evidence gate must also guard the
14165
14236
  # DEFAULT completion route (a completion claim via loki_complete_task
14166
14237
  # / the completion-promise text), not only the interval-gated council
@@ -7,7 +7,7 @@ Modules:
7
7
  control: Session control API (start/stop/pause/resume)
8
8
  """
9
9
 
10
- __version__ = "7.41.5"
10
+ __version__ = "7.43.0"
11
11
 
12
12
  # Expose the control app for easy import
13
13
  try:
@@ -7034,6 +7034,96 @@ def _pid_is_alive(pid):
7034
7034
  return None
7035
7035
 
7036
7036
 
7037
+ # Margin (seconds) added to the recorded reference time before a live pid is
7038
+ # judged to be a recycled (different) process. Must comfortably exceed clock
7039
+ # skew plus the launch-to-first-state-write gap so a genuine app is never
7040
+ # downgraded. A PID recycled after a crash typically belongs to a process that
7041
+ # started minutes or hours later, so a generous margin still catches recycles
7042
+ # while strongly biasing against the far worse false-positive of killing a live
7043
+ # app's status. See _reconcile_app_runner_liveness.
7044
+ _APP_RUNNER_PID_RECYCLE_MARGIN_SECONDS = 120
7045
+
7046
+
7047
+ def _pid_start_time(pid):
7048
+ """Best-effort wall-clock start time of pid, as epoch seconds, or None.
7049
+
7050
+ Reads `ps -o lstart= -p <pid>`, which is available on both macOS and Linux
7051
+ and prints the process start time in local time (e.g. "Sun Jun 14 18:39:15
7052
+ 2026"). The string is locale-dependent (%a/%b), so any parse failure, empty
7053
+ output, or missing process returns None and the caller degrades gracefully
7054
+ to its prior behavior. The returned epoch is timezone-correct because the
7055
+ naive local timestamp is interpreted in the system's local zone before
7056
+ conversion (ps reports local time; never mix it with a UTC value directly).
7057
+ """
7058
+ try:
7059
+ pid = int(pid)
7060
+ except (TypeError, ValueError):
7061
+ return None
7062
+ if pid <= 0:
7063
+ return None
7064
+ try:
7065
+ out = subprocess.run(["ps", "-o", "lstart=", "-p", str(pid)],
7066
+ capture_output=True, text=True, timeout=5)
7067
+ except (OSError, subprocess.SubprocessError):
7068
+ return None
7069
+ raw = (out.stdout or "").strip()
7070
+ if not raw:
7071
+ return None
7072
+ try:
7073
+ # lstart is local time without a zone; parse naive then attach the
7074
+ # local zone so .timestamp() yields a correct epoch regardless of TZ.
7075
+ naive = datetime.strptime(raw, "%a %b %d %H:%M:%S %Y")
7076
+ local = naive.replace(tzinfo=datetime.now().astimezone().tzinfo)
7077
+ return local.timestamp()
7078
+ except (ValueError, OverflowError, OSError):
7079
+ return None
7080
+
7081
+
7082
+ def _state_reference_epoch(state):
7083
+ """Epoch seconds for state.json's recorded reference time, or None.
7084
+
7085
+ Uses `started_at` (rewritten by the app-runner on every state write; it is
7086
+ the last-state-write time, not pure launch time). For a genuine process the
7087
+ real start time is always <= this value, so it is a safe upper bound to
7088
+ compare a live pid's start time against. The value is UTC (Z-suffixed).
7089
+ """
7090
+ if not isinstance(state, dict):
7091
+ return None
7092
+ started_at = state.get("started_at")
7093
+ if not started_at:
7094
+ return None
7095
+ try:
7096
+ ts = datetime.fromisoformat(str(started_at).replace("Z", "+00:00"))
7097
+ except (ValueError, TypeError):
7098
+ return None
7099
+ if ts.tzinfo is None:
7100
+ ts = ts.replace(tzinfo=timezone.utc)
7101
+ return ts.timestamp()
7102
+
7103
+
7104
+ def _pid_is_recycled(state):
7105
+ """True if the recorded main_pid is alive but is a DIFFERENT process now.
7106
+
7107
+ After the recorded app dies, the OS can recycle its numeric pid for an
7108
+ unrelated process; os.kill(pid, 0) then reports the stale pid "alive"
7109
+ forever and a dead run is never reconciled. We detect this by comparing the
7110
+ live pid's real start time against the recorded reference time: a genuine
7111
+ process started at or before the reference, so a live pid whose start time
7112
+ is comfortably AFTER the reference cannot be the original.
7113
+
7114
+ Returns True only with positive evidence of recycling. Any missing data
7115
+ (no recorded reference, start time unavailable) returns False so the caller
7116
+ keeps its prior behavior -- best-effort, biased against false positives.
7117
+ """
7118
+ reference = _state_reference_epoch(state)
7119
+ if reference is None:
7120
+ return False
7121
+ pid_start = _pid_start_time(state.get("main_pid"))
7122
+ if pid_start is None:
7123
+ return False
7124
+ return pid_start > reference + _APP_RUNNER_PID_RECYCLE_MARGIN_SECONDS
7125
+
7126
+
7037
7127
  def _health_checked_age_seconds(state):
7038
7128
  """Seconds since last_health.checked_at, or None if unparseable/absent."""
7039
7129
  health = state.get("last_health")
@@ -7059,6 +7149,9 @@ def _reconcile_app_runner_liveness(state):
7059
7149
  Here we cross-check the recorded main_pid against the real OS before
7060
7150
  returning, and only ever downgrade -- never upgrade -- the status:
7061
7151
  - recorded running/starting + pid genuinely gone -> "stopped"
7152
+ - recorded running/starting + pid "alive" but its real start time is
7153
+ after the recorded reference (the OS recycled a dead run's pid for an
7154
+ unrelated process) -> "stopped"
7062
7155
  - recorded running/starting + pid not verifiable +
7063
7156
  last_health.checked_at older than the threshold -> "stale"
7064
7157
  Any failure falls back to the raw recorded status (fail open to the writer's
@@ -7076,6 +7169,15 @@ def _reconcile_app_runner_liveness(state):
7076
7169
  state["status"] = "stopped"
7077
7170
  state["liveness"] = "pid_gone"
7078
7171
  return state
7172
+ if alive is True:
7173
+ # The numeric pid exists, but os.kill(pid, 0) cannot tell whether it
7174
+ # is still the SAME process. After a dead run the OS can recycle the
7175
+ # pid; detect that via the process start time so a recycled pid is
7176
+ # treated as gone rather than reported "running" forever.
7177
+ if _pid_is_recycled(state):
7178
+ state["status"] = "stopped"
7179
+ state["liveness"] = "pid_recycled"
7180
+ return state
7079
7181
  if alive is None:
7080
7182
  # Cannot verify via pid (e.g. compose subshell pid). Fall back to
7081
7183
  # the health-beat freshness with a generous threshold.
@@ -3910,7 +3910,7 @@ var LokiDashboard=(()=>{var Ee=Object.defineProperty;var rt=Object.getOwnPropert
3910
3910
  `:e.steps!==void 0?`
3911
3911
  <div class="detail-panel">
3912
3912
  <div class="detail-header">
3913
- <h3>Skill: ${e.name}</h3>
3913
+ <h3>Skill: ${this._escapeHtml(e.name)}</h3>
3914
3914
  <button class="close-btn" id="close-detail">&times;</button>
3915
3915
  </div>
3916
3916
  <div class="detail-body">
@@ -5518,7 +5518,7 @@ var LokiDashboard=(()=>{var Ee=Object.defineProperty;var rt=Object.getOwnPropert
5518
5518
  ${this._renderTabContent()}
5519
5519
  </div>
5520
5520
 
5521
- ${this._error?`<div class="error-banner">${this._error}</div>`:""}
5521
+ ${this._error?`<div class="error-banner">${this._escapeHtml(this._error)}</div>`:""}
5522
5522
  </div>
5523
5523
  `,this._attachEventListeners())}_attachEventListeners(){let e=this.shadowRoot;if(!e)return;let t=e.getElementById("force-review-btn");t&&t.addEventListener("click",()=>this._forceReview()),e.querySelectorAll(".tab[data-tab]").forEach(i=>{i.addEventListener("click",()=>this._setTab(i.dataset.tab))})}_renderTabContent(){switch(this._activeTab){case"overview":return this._renderOverview();case"decisions":return this._renderDecisions();case"convergence":return this._renderConvergence();case"agents":return this._renderAgents();default:return""}}_renderOverview(){let e=this._councilState||{},t=e.consecutive_no_change||0,i=e.done_signals||0,a=e.total_votes||0,s=e.approve_votes||0,r=this._verdicts.length>0?this._verdicts[this._verdicts.length-1]:null,o=this._agents.filter(n=>n.alive).length;return`
5524
5524
  <div class="overview-grid">
@@ -5631,27 +5631,27 @@ var LokiDashboard=(()=>{var Ee=Object.defineProperty;var rt=Object.getOwnPropert
5631
5631
  <div class="agent-card ${this._selectedAgent?.id===t.id?"agent-selected":""}"
5632
5632
  data-agent-index="${i}">
5633
5633
  <div class="agent-header">
5634
- <span class="agent-name">${t.name||t.id||"Unknown"}</span>
5634
+ <span class="agent-name">${this._escapeHtml(t.name||t.id||"Unknown")}</span>
5635
5635
  <span class="agent-status ${t.alive?"status-alive":"status-dead"}">
5636
5636
  ${t.alive?"Running":"Stopped"}
5637
5637
  </span>
5638
5638
  </div>
5639
5639
  <div class="agent-meta">
5640
- ${t.type?`<span class="agent-type">${t.type}</span>`:""}
5640
+ ${t.type?`<span class="agent-type">${this._escapeHtml(t.type)}</span>`:""}
5641
5641
  ${t.pid?`<span class="agent-pid">PID: ${t.pid}</span>`:""}
5642
- ${t.task?`<span class="agent-task">Task: ${t.task}</span>`:""}
5642
+ ${t.task?`<span class="agent-task">Task: ${this._escapeHtml(t.task)}</span>`:""}
5643
5643
  </div>
5644
5644
  ${this._selectedAgent?.id===t.id?`
5645
5645
  <div class="agent-actions">
5646
5646
  ${t.alive?`
5647
- <button class="btn btn-sm btn-warn" data-action="pause" data-agent-id="${t.id||t.name}">
5647
+ <button class="btn btn-sm btn-warn" data-action="pause" data-agent-id="${this._escapeHtml(t.id||t.name)}">
5648
5648
  Pause
5649
5649
  </button>
5650
- <button class="btn btn-sm btn-danger" data-action="kill" data-agent-id="${t.id||t.name}">
5650
+ <button class="btn btn-sm btn-danger" data-action="kill" data-agent-id="${this._escapeHtml(t.id||t.name)}">
5651
5651
  Kill
5652
5652
  </button>
5653
5653
  `:`
5654
- <button class="btn btn-sm btn-primary" data-action="resume" data-agent-id="${t.id||t.name}">
5654
+ <button class="btn btn-sm btn-primary" data-action="resume" data-agent-id="${this._escapeHtml(t.id||t.name)}">
5655
5655
  Resume
5656
5656
  </button>
5657
5657
  `}
@@ -5660,7 +5660,7 @@ var LokiDashboard=(()=>{var Ee=Object.defineProperty;var rt=Object.getOwnPropert
5660
5660
  </div>
5661
5661
  `).join("")}
5662
5662
  </div>
5663
- `;return this._pendingRaf=requestAnimationFrame(()=>{this._pendingRaf=null;let t=this.shadowRoot;t&&t.querySelectorAll(".agent-card[data-agent-index]").forEach(i=>{let a=parseInt(i.dataset.agentIndex,10),s=this._agents[a];s&&(i.addEventListener("click",()=>this._selectAgent(s)),i.querySelectorAll("[data-action]").forEach(r=>{r.addEventListener("click",o=>{o.stopPropagation();let n=r.dataset.action,l=r.dataset.agentId;n==="pause"?this._pauseAgent(l):n==="kill"?this._killAgent(l):n==="resume"&&this._resumeAgent(l)})}))})}),e}_formatTime(e){if(!e)return"";try{return new Date(e).toLocaleTimeString([],{hour:"2-digit",minute:"2-digit"})}catch{return e}}_getStyles(){return`
5663
+ `;return this._pendingRaf=requestAnimationFrame(()=>{this._pendingRaf=null;let t=this.shadowRoot;t&&t.querySelectorAll(".agent-card[data-agent-index]").forEach(i=>{let a=parseInt(i.dataset.agentIndex,10),s=this._agents[a];s&&(i.addEventListener("click",()=>this._selectAgent(s)),i.querySelectorAll("[data-action]").forEach(r=>{r.addEventListener("click",o=>{o.stopPropagation();let n=r.dataset.action,l=r.dataset.agentId;n==="pause"?this._pauseAgent(l):n==="kill"?this._killAgent(l):n==="resume"&&this._resumeAgent(l)})}))})}),e}_formatTime(e){if(!e)return"";try{return new Date(e).toLocaleTimeString([],{hour:"2-digit",minute:"2-digit"})}catch{return e}}_escapeHtml(e){return e?String(e).replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;"):""}_getStyles(){return`
5664
5664
  :host {
5665
5665
  display: block;
5666
5666
  font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
@@ -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.41.5
5
+ **Version:** v7.43.0
6
6
 
7
7
  ---
8
8
 
@@ -63,6 +63,7 @@ review verdict, evidence-related parses) so determinism is never affected.
63
63
  - [VS Code Extension (Deprecated)](#vs-code-extension-deprecated)
64
64
  - [Sandbox Mode](#sandbox-mode)
65
65
  - [Multi-Provider Support](#multi-provider-support)
66
+ - [Environment Variables](#environment-variables)
66
67
  - [Claude Code (CLI)](#claude-code-cli)
67
68
  - [Claude.ai (Web)](#claudeai-web)
68
69
  - [Anthropic API Console](#anthropic-api-console)
@@ -367,6 +368,74 @@ When using `codex`, `cline`, or `aider` providers, Loki Mode operates in **degra
367
368
 
368
369
  ---
369
370
 
371
+ ## Environment Variables
372
+
373
+ Loki Mode is designed to run with zero configuration: the trust-layer and
374
+ quality features below are default-on and decide intelligently by inspecting
375
+ the work. The environment variables here are opt-out escape hatches for power
376
+ users, not required setup. Set the documented value to disable a feature; leave
377
+ the variable unset to keep the intelligent default.
378
+
379
+ ### Trust-gate and completion knobs (default-on)
380
+
381
+ These are read by the orchestrator (`autonomy/run.sh`) on every run.
382
+
383
+ - `LOKI_REVIEW_INCONCLUSIVE_BLOCK` (default `1`) -- when a code-review cycle
384
+ returns zero usable verdicts (every reviewer produced empty output), the
385
+ review is treated as INCONCLUSIVE and the gate BLOCKS, because an all-empty
386
+ review proves nothing. A bounded one-shot retry runs first
387
+ (`LOKI_REVIEW_RETRY`, default `1`). Set `LOKI_REVIEW_INCONCLUSIVE_BLOCK=0` to
388
+ record the inconclusive result without blocking.
389
+
390
+ - `LOKI_COMPLETION_TEST_CAPTURE` (default `1`) -- before the verified-completion
391
+ evidence gate runs, Loki captures a fresh `test-results.json` so the gate
392
+ scores on real PASS/FAIL test results instead of a stale or missing file. It
393
+ reuses this iteration's results if already fresh, and never crashes the
394
+ completion path on red tests (the gate is the decider). Set
395
+ `LOKI_COMPLETION_TEST_CAPTURE=0` to opt out.
396
+
397
+ - `LOKI_AUTO_DOCS` (default `true`) -- auto-generates the `.loki/docs/` suite
398
+ before the documentation gate evaluates, so the gate scores on real generated
399
+ docs instead of nagging you to run `loki docs generate` by hand. Bounded:
400
+ runs at most once per run when docs are missing, and again only when existing
401
+ docs are substantially stale; best-effort, never fails the iteration loop.
402
+ Set `LOKI_AUTO_DOCS=false` to opt out.
403
+
404
+ ### Output-token compressor (caveman, Claude-only)
405
+
406
+ Loki integrates [caveman](https://github.com/JuliusBrussee/caveman), an optional
407
+ Claude Code skill that compresses the model's OUTPUT tokens only (keeping all
408
+ technical substance). It activates on free-form generation (the main RARV dev
409
+ loop) and is HARD-SUPPRESSED on every trust-gate subcall (council votes, code
410
+ review verdicts, evidence-related parses) so determinism is never affected. It
411
+ is Claude-provider-only; runs are byte-identical on Codex / Cline / Aider. These
412
+ variables are read in `autonomy/lib/claude-flags.sh`.
413
+
414
+ - `LOKI_CAVEMAN` (default on) -- set `LOKI_CAVEMAN=0` to disable the compressor.
415
+ Suppression on trust-gate subcalls is unconditional and applies even when
416
+ caveman is globally installed but `LOKI_CAVEMAN=0`, so trust gates are never
417
+ exposed to compression.
418
+
419
+ - `LOKI_CAVEMAN_LEVEL` (default `full`) -- the compression level for free-form
420
+ activation. When you do NOT set this, the level is inferred per-invocation
421
+ from the run's RARV tier (planning -> `lite`, development/fast -> `full`); the
422
+ auto path never selects `ultra`. Setting `LOKI_CAVEMAN_LEVEL` explicitly
423
+ overrides the inference entirely (the opt-out escape hatch).
424
+
425
+ - `LOKI_CAVEMAN_VERSION` (default `1.9.0`) -- the pinned caveman version used by
426
+ the one-time bootstrap. Bump only to upgrade the compressor.
427
+
428
+ ### RARV-C closure knobs (default-on)
429
+
430
+ The Phase 1 / RARV-C closure loop (findings injection, override council,
431
+ learnings writer, handoff doc) is default-on and documented in detail at the
432
+ top of this guide under [Phase 1 RARV-C closure](#phase-1-rarv-c-closure-shipped-v750-default-on-as-of-v753):
433
+ `LOKI_INJECT_FINDINGS`, `LOKI_OVERRIDE_COUNCIL`, `LOKI_AUTO_LEARNINGS`, and
434
+ `LOKI_HANDOFF_MD` (each opt out with `=0`). For the full schema and
435
+ reachability notes, see `skills/quality-gates.md`.
436
+
437
+ ---
438
+
370
439
  ## Claude Code (CLI)
371
440
 
372
441
  Loki Mode can be installed as a skill in three ways:
package/events/bus.py CHANGED
@@ -328,17 +328,20 @@ class EventBus:
328
328
  Events as they arrive
329
329
  """
330
330
  start_time = time.time()
331
- last_check = datetime.now(timezone.utc).isoformat()
332
331
 
333
332
  while True:
334
333
  if timeout and (time.time() - start_time) > timeout:
335
334
  break
336
335
 
337
- # Set last_check BEFORE fetching to avoid missing events that
338
- # arrive between fetch and timestamp update
339
- next_check = datetime.now(timezone.utc).isoformat()
340
- events = self.get_pending_events(types=types, since=last_check)
341
- last_check = next_check
336
+ # Dedup is driven solely by _processed_ids (maintained via
337
+ # mark_processed), NOT by a wall-clock `since` window. A local
338
+ # `since=now` filter silently drops any event whose timestamp is
339
+ # at or behind the subscriber's clock: cross-process clock skew
340
+ # (an emitter a few ms/s behind) or second-granularity timestamps
341
+ # (emit.sh's .000Z fallback) would lose events forever. This
342
+ # mirrors start_background_processing() and bus.ts, which both
343
+ # call get_pending_events with no `since` argument.
344
+ events = self.get_pending_events(types=types)
342
345
 
343
346
  for event in events:
344
347
  yield event