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/README.md +18 -1
- package/SKILL.md +2 -2
- package/VERSION +1 -1
- package/autonomy/app-runner.sh +174 -8
- package/autonomy/completion-council.sh +38 -16
- package/autonomy/hooks/migration-hooks.sh +131 -7
- package/autonomy/loki +66 -43
- package/autonomy/run.sh +73 -2
- package/dashboard/__init__.py +1 -1
- package/dashboard/server.py +102 -0
- package/dashboard/static/index.html +9 -9
- package/docs/INSTALLATION.md +70 -1
- package/events/bus.py +9 -6
- package/loki-ts/dist/loki.js +2 -2
- package/mcp/__init__.py +1 -1
- package/mcp/lsp_proxy.py +274 -89
- package/mcp/server.py +26 -2
- package/memory/vector_index.py +6 -1
- package/package.json +1 -1
- package/plugins/loki-mode/.claude-plugin/plugin.json +1 -1
- package/providers/codex.sh +21 -1
- package/references/core-workflow.md +7 -0
- package/references/quality-control.md +6 -0
- package/skills/agents.md +1 -0
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
|
-
|
|
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(
|
|
13186
|
-
"
|
|
13187
|
-
|
|
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
|
-
|
|
13218
|
-
|
|
13219
|
-
with open(
|
|
13220
|
-
|
|
13221
|
-
|
|
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 = '
|
|
18611
|
-
path = '
|
|
18612
|
-
name = '
|
|
18613
|
-
alias = '
|
|
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
|
-
"
|
|
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 = '
|
|
18670
|
-
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
|
-
"
|
|
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 = '
|
|
18850
|
-
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
|
-
"
|
|
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 = '
|
|
19051
|
-
name = '
|
|
19052
|
-
scopes_str = '
|
|
19053
|
-
expires_str = '
|
|
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
|
-
"
|
|
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 = '
|
|
19182
|
-
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
|
-
"
|
|
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 = '
|
|
19220
|
-
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
|
-
"
|
|
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,
|
|
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":
|
|
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
|
package/dashboard/__init__.py
CHANGED
package/dashboard/server.py
CHANGED
|
@@ -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">×</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,"&").replace(/</g,"<").replace(/>/g,">").replace(/"/g,"""):""}_getStyles(){return`
|
|
5664
5664
|
:host {
|
|
5665
5665
|
display: block;
|
|
5666
5666
|
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
|
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.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
|
-
#
|
|
338
|
-
#
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
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
|