loki-mode 5.46.0 → 5.47.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 +1 -1
- package/SKILL.md +2 -2
- package/VERSION +1 -1
- package/autonomy/app-runner.sh +11 -6
- package/autonomy/checklist-verify.py +3 -2
- package/autonomy/completion-council.sh +129 -2
- package/autonomy/playwright-verify.sh +0 -0
- package/autonomy/prd-analyzer.py +0 -0
- package/autonomy/prd-checklist.sh +163 -2
- package/autonomy/run.sh +6 -1
- package/dashboard/__init__.py +1 -1
- package/dashboard/server.py +130 -4
- package/dashboard/static/index.html +161 -65
- package/docs/INSTALLATION.md +1 -1
- package/mcp/__init__.py +1 -1
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
[](benchmarks/results/)
|
|
14
14
|
[](benchmarks/results/)
|
|
15
15
|
|
|
16
|
-
**Current Version: v5.
|
|
16
|
+
**Current Version: v5.47.0**
|
|
17
17
|
|
|
18
18
|
**[Autonomi](https://www.autonomi.dev/)** | **[Documentation](https://www.autonomi.dev/docs)** | **[GitHub](https://github.com/asklokesh/loki-mode)**
|
|
19
19
|
|
package/SKILL.md
CHANGED
|
@@ -3,7 +3,7 @@ name: loki-mode
|
|
|
3
3
|
description: Multi-agent autonomous startup system. Triggers on "Loki Mode". Takes PRD to deployed product with zero human intervention. Requires --dangerously-skip-permissions flag.
|
|
4
4
|
---
|
|
5
5
|
|
|
6
|
-
# Loki Mode v5.
|
|
6
|
+
# Loki Mode v5.47.0
|
|
7
7
|
|
|
8
8
|
**You are an autonomous agent. You make decisions. You do not ask questions. You do not stop.**
|
|
9
9
|
|
|
@@ -262,4 +262,4 @@ The following features are documented in skill modules but not yet fully automat
|
|
|
262
262
|
| Quality gates 3-reviewer system | Implemented (v5.35.0) | 5 specialist reviewers in `skills/quality-gates.md`; execution in run.sh |
|
|
263
263
|
| Benchmarks (HumanEval, SWE-bench) | Infrastructure only | Runner scripts and datasets exist in `benchmarks/`; no published results |
|
|
264
264
|
|
|
265
|
-
**v5.
|
|
265
|
+
**v5.47.0 | [Autonomi](https://www.autonomi.dev/) flagship product | ~260 lines core**
|
package/VERSION
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
5.
|
|
1
|
+
5.47.0
|
package/autonomy/app-runner.sh
CHANGED
|
@@ -395,8 +395,9 @@ _install_node_deps() {
|
|
|
395
395
|
_install_python_deps() {
|
|
396
396
|
local dir="$1"
|
|
397
397
|
if [ -f "$dir/requirements.txt" ]; then
|
|
398
|
-
log_step "App Runner: installing Python dependencies
|
|
399
|
-
(cd "$dir" && pip install -r requirements.txt >> "$_APP_RUNNER_DIR/app.log" 2>&1)
|
|
398
|
+
log_step "App Runner: installing Python dependencies..."
|
|
399
|
+
(cd "$dir" && pip install -r requirements.txt >> "$_APP_RUNNER_DIR/app.log" 2>&1) || \
|
|
400
|
+
log_warn "App Runner: pip install failed, app may not start"
|
|
400
401
|
fi
|
|
401
402
|
}
|
|
402
403
|
|
|
@@ -425,14 +426,18 @@ app_runner_start() {
|
|
|
425
426
|
_rotate_app_log
|
|
426
427
|
|
|
427
428
|
# Start the process in a new process group
|
|
428
|
-
|
|
429
|
+
if command -v setsid >/dev/null 2>&1; then
|
|
430
|
+
(cd "$dir" && setsid bash -c "$_APP_RUNNER_METHOD" >> "$_APP_RUNNER_DIR/app.log" 2>&1) &
|
|
431
|
+
else
|
|
432
|
+
(cd "$dir" && bash -c "$_APP_RUNNER_METHOD" >> "$_APP_RUNNER_DIR/app.log" 2>&1) &
|
|
433
|
+
fi
|
|
429
434
|
_APP_RUNNER_PID=$!
|
|
430
435
|
|
|
431
436
|
# Write PID file
|
|
432
437
|
echo "$_APP_RUNNER_PID" > "$_APP_RUNNER_DIR/app.pid"
|
|
433
438
|
|
|
434
439
|
# Capture initial git diff hash for change detection
|
|
435
|
-
_GIT_DIFF_HASH=$(cd "$dir" && git diff --stat 2>/dev/null | md5sum 2>/dev/null | awk '{print $1}' || echo "none")
|
|
440
|
+
_GIT_DIFF_HASH=$(cd "$dir" && git diff --stat 2>/dev/null | (md5sum 2>/dev/null || md5 -r 2>/dev/null) | awk '{print $1}' || echo "none")
|
|
436
441
|
|
|
437
442
|
# Brief pause for process to initialize
|
|
438
443
|
sleep 2
|
|
@@ -557,7 +562,7 @@ app_runner_should_restart() {
|
|
|
557
562
|
|
|
558
563
|
# Get current git diff hash
|
|
559
564
|
local current_hash
|
|
560
|
-
current_hash=$(cd "$dir" && git diff --stat 2>/dev/null | md5sum 2>/dev/null | awk '{print $1}' || echo "none")
|
|
565
|
+
current_hash=$(cd "$dir" && git diff --stat 2>/dev/null | (md5sum 2>/dev/null || md5 -r 2>/dev/null) | awk '{print $1}' || echo "none")
|
|
561
566
|
|
|
562
567
|
# No change
|
|
563
568
|
if [ "$current_hash" = "$_GIT_DIFF_HASH" ]; then
|
|
@@ -631,7 +636,7 @@ app_runner_watchdog() {
|
|
|
631
636
|
# Clear PID and restart
|
|
632
637
|
rm -f "$_APP_RUNNER_DIR/app.pid"
|
|
633
638
|
_APP_RUNNER_PID=""
|
|
634
|
-
app_runner_start
|
|
639
|
+
app_runner_start || log_warn "App Runner: auto-restart failed"
|
|
635
640
|
}
|
|
636
641
|
|
|
637
642
|
#===============================================================================
|
|
@@ -32,7 +32,7 @@ from pathlib import Path
|
|
|
32
32
|
|
|
33
33
|
# Allowed characters in check paths and patterns (security: prevent injection)
|
|
34
34
|
_SAFE_PATH_RE = re.compile(r'^[a-zA-Z0-9_\-./\*\[\]{}?]+$')
|
|
35
|
-
_SAFE_PATTERN_RE = re.compile(r'^[a-zA-Z0-9_\-./\*\[\]{}?|\\()+^$\s]+$')
|
|
35
|
+
_SAFE_PATTERN_RE = re.compile(r'^[a-zA-Z0-9_\-./\*\[\]{}?|\\()+^$\s:=<>@#"\'`,;!&%]+$')
|
|
36
36
|
|
|
37
37
|
|
|
38
38
|
def _validate_path(path: str, project_dir: str) -> str:
|
|
@@ -170,7 +170,8 @@ def run_check(check: dict, project_dir: str, timeout: int) -> dict:
|
|
|
170
170
|
elif check_type == "http_check":
|
|
171
171
|
path = check.get("path", "/")
|
|
172
172
|
# Validate path is safe
|
|
173
|
-
|
|
173
|
+
stripped = path.lstrip("/")
|
|
174
|
+
if stripped and not _SAFE_PATH_RE.match(stripped):
|
|
174
175
|
result["passed"] = None
|
|
175
176
|
result["output"] = f"Unsafe path rejected: {path!r}"
|
|
176
177
|
else:
|
|
@@ -461,6 +461,124 @@ try:
|
|
|
461
461
|
except: print('Results unavailable')
|
|
462
462
|
" >> "$evidence_file" 2>/dev/null || echo "Playwright data unavailable" >> "$evidence_file"
|
|
463
463
|
fi
|
|
464
|
+
|
|
465
|
+
# Add hard gate status
|
|
466
|
+
if [ -f "$COUNCIL_STATE_DIR/gate-block.json" ]; then
|
|
467
|
+
echo "" >> "$evidence_file"
|
|
468
|
+
echo "## Hard Gate Status: BLOCKED" >> "$evidence_file"
|
|
469
|
+
echo "Critical checklist items are failing. Completion is blocked until resolved." >> "$evidence_file"
|
|
470
|
+
cat "$COUNCIL_STATE_DIR/gate-block.json" >> "$evidence_file"
|
|
471
|
+
fi
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
#===============================================================================
|
|
475
|
+
# Council Reverify Checklist - Re-run checklist before evaluation
|
|
476
|
+
#===============================================================================
|
|
477
|
+
|
|
478
|
+
# Re-verify checklist before council evaluation to ensure fresh data
|
|
479
|
+
council_reverify_checklist() {
|
|
480
|
+
if type checklist_verify &>/dev/null && [ -f ".loki/checklist/checklist.json" ]; then
|
|
481
|
+
log_info "[Council] Re-verifying checklist before evaluation..."
|
|
482
|
+
checklist_verify 2>/dev/null || true
|
|
483
|
+
fi
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
#===============================================================================
|
|
487
|
+
# Council Checklist Hard Gate - Block completion on critical failures
|
|
488
|
+
#===============================================================================
|
|
489
|
+
|
|
490
|
+
# Council hard gate: blocks completion if critical checklist items are failing
|
|
491
|
+
# Returns 0 if gate passes (ok to complete), 1 if gate blocks (critical failures exist)
|
|
492
|
+
council_checklist_gate() {
|
|
493
|
+
local results_file=".loki/checklist/verification-results.json"
|
|
494
|
+
local waivers_file=".loki/checklist/waivers.json"
|
|
495
|
+
|
|
496
|
+
# No checklist = no gate (backwards compatible)
|
|
497
|
+
if [ ! -f "$results_file" ]; then
|
|
498
|
+
return 0
|
|
499
|
+
fi
|
|
500
|
+
|
|
501
|
+
# Check for critical failures, excluding waived items
|
|
502
|
+
local gate_result
|
|
503
|
+
gate_result=$(_RESULTS_FILE="$results_file" _WAIVERS_FILE="$waivers_file" python3 -c "
|
|
504
|
+
import json, sys, os
|
|
505
|
+
|
|
506
|
+
results_file = os.environ['_RESULTS_FILE']
|
|
507
|
+
waivers_file = os.environ.get('_WAIVERS_FILE', '')
|
|
508
|
+
|
|
509
|
+
try:
|
|
510
|
+
with open(results_file) as f:
|
|
511
|
+
results = json.load(f)
|
|
512
|
+
except (json.JSONDecodeError, IOError, KeyError):
|
|
513
|
+
print('PASS')
|
|
514
|
+
sys.exit(0)
|
|
515
|
+
|
|
516
|
+
# Load waivers
|
|
517
|
+
waived_ids = set()
|
|
518
|
+
if waivers_file and os.path.exists(waivers_file):
|
|
519
|
+
try:
|
|
520
|
+
with open(waivers_file) as f:
|
|
521
|
+
waivers = json.load(f)
|
|
522
|
+
waived_ids = {w['item_id'] for w in waivers.get('waivers', []) if w.get('active', True)}
|
|
523
|
+
except (json.JSONDecodeError, KeyError):
|
|
524
|
+
pass
|
|
525
|
+
|
|
526
|
+
# Find critical failures not waived
|
|
527
|
+
critical_failures = []
|
|
528
|
+
for cat in results.get('categories', []):
|
|
529
|
+
for item in cat.get('items', []):
|
|
530
|
+
if item.get('priority') == 'critical' and item.get('status') == 'failing':
|
|
531
|
+
if item.get('id') not in waived_ids:
|
|
532
|
+
critical_failures.append(item.get('title', item.get('id', 'unknown')))
|
|
533
|
+
|
|
534
|
+
if critical_failures:
|
|
535
|
+
print('BLOCK:' + '|'.join(critical_failures[:5]))
|
|
536
|
+
sys.exit(0)
|
|
537
|
+
else:
|
|
538
|
+
print('PASS')
|
|
539
|
+
sys.exit(0)
|
|
540
|
+
" 2>/dev/null || echo "PASS")
|
|
541
|
+
|
|
542
|
+
if [[ "$gate_result" == BLOCK:* ]]; then
|
|
543
|
+
local failures="${gate_result#BLOCK:}"
|
|
544
|
+
log_warn "[Council] Hard gate BLOCKED: critical checklist failures: ${failures//|/, }"
|
|
545
|
+
|
|
546
|
+
# Write gate block to council state (atomic write via temp file)
|
|
547
|
+
local gate_file="$COUNCIL_STATE_DIR/gate-block.json"
|
|
548
|
+
local gate_tmp="${gate_file}.tmp"
|
|
549
|
+
local timestamp
|
|
550
|
+
timestamp=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
|
|
551
|
+
local failures_json
|
|
552
|
+
failures_json=$(_FAILURES="$failures" python3 -c "
|
|
553
|
+
import json, os
|
|
554
|
+
items = os.environ['_FAILURES'].split('|')
|
|
555
|
+
print(json.dumps(items))
|
|
556
|
+
" 2>/dev/null || echo '[]')
|
|
557
|
+
local critical_count
|
|
558
|
+
critical_count=$(_FAILURES="$failures" python3 -c "
|
|
559
|
+
import os
|
|
560
|
+
print(len(os.environ['_FAILURES'].split('|')))
|
|
561
|
+
" 2>/dev/null || echo '0')
|
|
562
|
+
cat > "$gate_tmp" << GATE_EOF
|
|
563
|
+
{
|
|
564
|
+
"status": "blocked",
|
|
565
|
+
"blocked": true,
|
|
566
|
+
"blocked_at": "$timestamp",
|
|
567
|
+
"iteration": ${ITERATION_COUNT:-0},
|
|
568
|
+
"reason": "critical_checklist_failures",
|
|
569
|
+
"critical_failures": $critical_count,
|
|
570
|
+
"failures": $failures_json
|
|
571
|
+
}
|
|
572
|
+
GATE_EOF
|
|
573
|
+
mv "$gate_tmp" "$gate_file"
|
|
574
|
+
return 1
|
|
575
|
+
fi
|
|
576
|
+
|
|
577
|
+
# Gate passes
|
|
578
|
+
if [ -f "$COUNCIL_STATE_DIR/gate-block.json" ]; then
|
|
579
|
+
rm -f "$COUNCIL_STATE_DIR/gate-block.json"
|
|
580
|
+
fi
|
|
581
|
+
return 0
|
|
464
582
|
}
|
|
465
583
|
|
|
466
584
|
#===============================================================================
|
|
@@ -1019,6 +1137,15 @@ council_evaluate() {
|
|
|
1019
1137
|
|
|
1020
1138
|
log_info "Running council evaluation pipeline (round $ITERATION_COUNT)..."
|
|
1021
1139
|
|
|
1140
|
+
# Phase 4: Re-verify checklist for fresh data
|
|
1141
|
+
council_reverify_checklist
|
|
1142
|
+
|
|
1143
|
+
# Phase 4: Hard gate check - block if critical checklist items failing
|
|
1144
|
+
if ! council_checklist_gate; then
|
|
1145
|
+
log_info "[Council] Completion blocked by checklist hard gate"
|
|
1146
|
+
return 1 # CONTINUE - can't complete with critical failures
|
|
1147
|
+
fi
|
|
1148
|
+
|
|
1022
1149
|
# Step 1: Aggregate votes from all members
|
|
1023
1150
|
local aggregate_result
|
|
1024
1151
|
aggregate_result=$(council_aggregate_votes)
|
|
@@ -1082,8 +1209,8 @@ council_should_stop() {
|
|
|
1082
1209
|
return 1 # Not time to check yet
|
|
1083
1210
|
fi
|
|
1084
1211
|
|
|
1085
|
-
# Run the council
|
|
1086
|
-
if
|
|
1212
|
+
# Run the council evaluation (includes hard gate + aggregate votes + devil's advocate)
|
|
1213
|
+
if council_evaluate; then
|
|
1087
1214
|
log_header "COMPLETION COUNCIL: PROJECT APPROVED"
|
|
1088
1215
|
log_info "The council has determined this project is complete."
|
|
1089
1216
|
|
|
File without changes
|
package/autonomy/prd-analyzer.py
CHANGED
|
File without changes
|
|
@@ -158,7 +158,9 @@ checklist_summary() {
|
|
|
158
158
|
return 0
|
|
159
159
|
fi
|
|
160
160
|
|
|
161
|
-
_CHECKLIST_RESULTS="$CHECKLIST_RESULTS_FILE"
|
|
161
|
+
_CHECKLIST_RESULTS="$CHECKLIST_RESULTS_FILE" \
|
|
162
|
+
_CHECKLIST_WAIVERS="${CHECKLIST_DIR:-".loki/checklist"}/waivers.json" \
|
|
163
|
+
python3 -c "
|
|
162
164
|
import json, sys, os
|
|
163
165
|
try:
|
|
164
166
|
fpath = os.environ.get('_CHECKLIST_RESULTS', '')
|
|
@@ -168,18 +170,39 @@ try:
|
|
|
168
170
|
verified = s.get('verified', 0)
|
|
169
171
|
failing = s.get('failing', 0)
|
|
170
172
|
pending = s.get('pending', 0)
|
|
173
|
+
|
|
174
|
+
# Load waivers
|
|
175
|
+
waived_ids = set()
|
|
176
|
+
waivers_path = os.environ.get('_CHECKLIST_WAIVERS', '')
|
|
177
|
+
if waivers_path and os.path.exists(waivers_path):
|
|
178
|
+
try:
|
|
179
|
+
with open(waivers_path) as wf:
|
|
180
|
+
wdata = json.load(wf)
|
|
181
|
+
for w in wdata.get('waivers', []):
|
|
182
|
+
if w.get('active', True):
|
|
183
|
+
waived_ids.add(w['item_id'])
|
|
184
|
+
except Exception:
|
|
185
|
+
pass
|
|
186
|
+
|
|
187
|
+
# Count waived items and adjust failing list
|
|
188
|
+
waived_count = 0
|
|
171
189
|
if total == 0:
|
|
172
190
|
print('')
|
|
173
191
|
else:
|
|
174
192
|
failing_items = []
|
|
175
193
|
for cat in data.get('categories', []):
|
|
176
194
|
for item in cat.get('items', []):
|
|
195
|
+
item_id = item.get('id', '')
|
|
196
|
+
if item_id in waived_ids:
|
|
197
|
+
waived_count += 1
|
|
198
|
+
continue
|
|
177
199
|
if item.get('status') == 'failing' and item.get('priority') in ('critical', 'major'):
|
|
178
200
|
failing_items.append(item.get('title', item.get('id', '?')))
|
|
179
201
|
detail = ''
|
|
180
202
|
if failing_items:
|
|
181
203
|
detail = ' FAILING: ' + ', '.join(failing_items[:5])
|
|
182
|
-
|
|
204
|
+
waived_str = f', {waived_count} waived' if waived_count > 0 else ''
|
|
205
|
+
print(f'{verified}/{total} verified, {failing} failing{waived_str}, {pending} pending.{detail}')
|
|
183
206
|
except Exception:
|
|
184
207
|
print('', file=sys.stderr)
|
|
185
208
|
" 2>/dev/null || echo ""
|
|
@@ -221,3 +244,141 @@ except Exception:
|
|
|
221
244
|
" 2>/dev/null || echo "Checklist data unavailable"
|
|
222
245
|
} >> "${evidence_file:-/dev/stdout}"
|
|
223
246
|
}
|
|
247
|
+
|
|
248
|
+
#===============================================================================
|
|
249
|
+
# Waiver Support (Phase 4)
|
|
250
|
+
#===============================================================================
|
|
251
|
+
|
|
252
|
+
# Load waivers from .loki/checklist/waivers.json
|
|
253
|
+
# Returns waived item IDs (one per line) to stdout
|
|
254
|
+
checklist_waiver_load() {
|
|
255
|
+
local waivers_file="${CHECKLIST_DIR:-".loki/checklist"}/waivers.json"
|
|
256
|
+
if [ ! -f "$waivers_file" ]; then
|
|
257
|
+
return 0
|
|
258
|
+
fi
|
|
259
|
+
_WAIVERS_FILE="$waivers_file" python3 -c "
|
|
260
|
+
import json, sys, os
|
|
261
|
+
try:
|
|
262
|
+
waivers_file = os.environ['_WAIVERS_FILE']
|
|
263
|
+
with open(waivers_file) as f:
|
|
264
|
+
waivers = json.load(f)
|
|
265
|
+
for w in waivers.get('waivers', []):
|
|
266
|
+
if w.get('active', True):
|
|
267
|
+
print(w['item_id'])
|
|
268
|
+
except Exception:
|
|
269
|
+
pass
|
|
270
|
+
" 2>/dev/null || true
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
# Add a waiver for a checklist item
|
|
274
|
+
# Usage: checklist_waiver_add <item_id> <reason> [waived_by]
|
|
275
|
+
checklist_waiver_add() {
|
|
276
|
+
local item_id="${1:?item_id required}"
|
|
277
|
+
local reason="${2:?reason required}"
|
|
278
|
+
local waived_by="${3:-manual}"
|
|
279
|
+
local waivers_file="${CHECKLIST_DIR:-".loki/checklist"}/waivers.json"
|
|
280
|
+
local timestamp
|
|
281
|
+
timestamp=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
|
|
282
|
+
|
|
283
|
+
_WAIVERS_FILE="$waivers_file" python3 -c "
|
|
284
|
+
import json, os, sys
|
|
285
|
+
|
|
286
|
+
waivers_file = os.environ['_WAIVERS_FILE']
|
|
287
|
+
item_id = sys.argv[1]
|
|
288
|
+
reason = sys.argv[2]
|
|
289
|
+
waived_by = sys.argv[3]
|
|
290
|
+
timestamp = sys.argv[4]
|
|
291
|
+
|
|
292
|
+
# Load existing or create new
|
|
293
|
+
waivers = {'waivers': []}
|
|
294
|
+
if os.path.exists(waivers_file):
|
|
295
|
+
try:
|
|
296
|
+
with open(waivers_file) as f:
|
|
297
|
+
waivers = json.load(f)
|
|
298
|
+
except (json.JSONDecodeError, IOError):
|
|
299
|
+
pass
|
|
300
|
+
|
|
301
|
+
# Check for duplicate
|
|
302
|
+
for w in waivers.get('waivers', []):
|
|
303
|
+
if w.get('item_id') == item_id and w.get('active', True):
|
|
304
|
+
print(f'Waiver already exists for {item_id}')
|
|
305
|
+
sys.exit(0)
|
|
306
|
+
|
|
307
|
+
# Add new waiver
|
|
308
|
+
waivers.setdefault('waivers', []).append({
|
|
309
|
+
'item_id': item_id,
|
|
310
|
+
'reason': reason,
|
|
311
|
+
'waived_by': waived_by,
|
|
312
|
+
'waived_at': timestamp,
|
|
313
|
+
'active': True
|
|
314
|
+
})
|
|
315
|
+
|
|
316
|
+
# Atomic write
|
|
317
|
+
tmp = waivers_file + '.tmp'
|
|
318
|
+
with open(tmp, 'w') as f:
|
|
319
|
+
json.dump(waivers, f, indent=2)
|
|
320
|
+
os.replace(tmp, waivers_file)
|
|
321
|
+
print(f'Waiver added for {item_id}')
|
|
322
|
+
" "$item_id" "$reason" "$waived_by" "$timestamp" 2>/dev/null
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
# Remove (deactivate) a waiver for a checklist item
|
|
326
|
+
# Usage: checklist_waiver_remove <item_id>
|
|
327
|
+
checklist_waiver_remove() {
|
|
328
|
+
local item_id="${1:?item_id required}"
|
|
329
|
+
local waivers_file="${CHECKLIST_DIR:-".loki/checklist"}/waivers.json"
|
|
330
|
+
|
|
331
|
+
if [ ! -f "$waivers_file" ]; then
|
|
332
|
+
echo "No waivers file found"
|
|
333
|
+
return 1
|
|
334
|
+
fi
|
|
335
|
+
|
|
336
|
+
_WAIVERS_FILE="$waivers_file" python3 -c "
|
|
337
|
+
import json, os, sys
|
|
338
|
+
|
|
339
|
+
waivers_file = os.environ['_WAIVERS_FILE']
|
|
340
|
+
item_id = sys.argv[1]
|
|
341
|
+
|
|
342
|
+
with open(waivers_file) as f:
|
|
343
|
+
waivers = json.load(f)
|
|
344
|
+
|
|
345
|
+
found = False
|
|
346
|
+
for w in waivers.get('waivers', []):
|
|
347
|
+
if w.get('item_id') == item_id and w.get('active', True):
|
|
348
|
+
w['active'] = False
|
|
349
|
+
found = True
|
|
350
|
+
|
|
351
|
+
if not found:
|
|
352
|
+
print(f'No active waiver found for {item_id}')
|
|
353
|
+
sys.exit(1)
|
|
354
|
+
|
|
355
|
+
tmp = waivers_file + '.tmp'
|
|
356
|
+
with open(tmp, 'w') as f:
|
|
357
|
+
json.dump(waivers, f, indent=2)
|
|
358
|
+
os.replace(tmp, waivers_file)
|
|
359
|
+
print(f'Waiver removed for {item_id}')
|
|
360
|
+
" "$item_id" 2>/dev/null
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
# List all active waivers
|
|
364
|
+
checklist_waiver_list() {
|
|
365
|
+
local waivers_file="${CHECKLIST_DIR:-".loki/checklist"}/waivers.json"
|
|
366
|
+
|
|
367
|
+
if [ ! -f "$waivers_file" ]; then
|
|
368
|
+
echo "No waivers configured"
|
|
369
|
+
return 0
|
|
370
|
+
fi
|
|
371
|
+
|
|
372
|
+
_WAIVERS_FILE="$waivers_file" python3 -c "
|
|
373
|
+
import json, os
|
|
374
|
+
waivers_file = os.environ['_WAIVERS_FILE']
|
|
375
|
+
with open(waivers_file) as f:
|
|
376
|
+
waivers = json.load(f)
|
|
377
|
+
active = [w for w in waivers.get('waivers', []) if w.get('active', True)]
|
|
378
|
+
if not active:
|
|
379
|
+
print('No active waivers')
|
|
380
|
+
else:
|
|
381
|
+
for w in active:
|
|
382
|
+
print(f\" {w['item_id']}: {w.get('reason', 'no reason')} (by {w.get('waived_by', 'unknown')} at {w.get('waived_at', '?')})\")
|
|
383
|
+
" 2>/dev/null
|
|
384
|
+
}
|
package/autonomy/run.sh
CHANGED
|
@@ -6843,7 +6843,9 @@ check_human_intervention() {
|
|
|
6843
6843
|
if [ -f "$loki_dir/signals/COUNCIL_REVIEW_REQUESTED" ]; then
|
|
6844
6844
|
log_info "Council force-review requested from dashboard"
|
|
6845
6845
|
rm -f "$loki_dir/signals/COUNCIL_REVIEW_REQUESTED"
|
|
6846
|
-
if type
|
|
6846
|
+
if type council_checklist_gate &>/dev/null && ! council_checklist_gate; then
|
|
6847
|
+
log_info "Council force-review: blocked by checklist hard gate"
|
|
6848
|
+
elif type council_vote &>/dev/null && council_vote; then
|
|
6847
6849
|
log_header "COMPLETION COUNCIL: FORCE REVIEW - PROJECT COMPLETE"
|
|
6848
6850
|
# BUG #17 fix: Write COMPLETED marker, generate council report, and
|
|
6849
6851
|
# run memory consolidation (matching the normal council approval path
|
|
@@ -7452,6 +7454,9 @@ main() {
|
|
|
7452
7454
|
audit_agent_action "session_stop" "Session ended" "result=$result,iterations=$ITERATION_COUNT"
|
|
7453
7455
|
|
|
7454
7456
|
# Cleanup
|
|
7457
|
+
if type app_runner_cleanup &>/dev/null; then
|
|
7458
|
+
app_runner_cleanup
|
|
7459
|
+
fi
|
|
7455
7460
|
stop_dashboard
|
|
7456
7461
|
stop_status_monitor
|
|
7457
7462
|
rm -f .loki/loki.pid 2>/dev/null
|
package/dashboard/__init__.py
CHANGED
package/dashboard/server.py
CHANGED
|
@@ -26,7 +26,7 @@ from fastapi import (
|
|
|
26
26
|
WebSocketDisconnect,
|
|
27
27
|
)
|
|
28
28
|
from fastapi.middleware.cors import CORSMiddleware
|
|
29
|
-
from fastapi.responses import PlainTextResponse
|
|
29
|
+
from fastapi.responses import JSONResponse, PlainTextResponse
|
|
30
30
|
from pydantic import BaseModel, Field
|
|
31
31
|
from sqlalchemy import select, update, delete
|
|
32
32
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
@@ -3402,6 +3402,132 @@ async def get_prd_observations():
|
|
|
3402
3402
|
return PlainTextResponse("Error reading PRD observations.", status_code=500)
|
|
3403
3403
|
|
|
3404
3404
|
|
|
3405
|
+
# =============================================================================
|
|
3406
|
+
# Checklist Waiver Management Endpoints (Phase 4)
|
|
3407
|
+
# =============================================================================
|
|
3408
|
+
|
|
3409
|
+
@app.get("/api/checklist/waivers")
|
|
3410
|
+
async def get_checklist_waivers():
|
|
3411
|
+
"""Get all checklist waivers."""
|
|
3412
|
+
waivers_file = _get_loki_dir() / "checklist" / "waivers.json"
|
|
3413
|
+
if not waivers_file.exists():
|
|
3414
|
+
return {"waivers": []}
|
|
3415
|
+
try:
|
|
3416
|
+
return json.loads(waivers_file.read_text())
|
|
3417
|
+
except (json.JSONDecodeError, IOError):
|
|
3418
|
+
return {"waivers": [], "error": "Failed to read waivers file"}
|
|
3419
|
+
|
|
3420
|
+
|
|
3421
|
+
@app.post("/api/checklist/waivers", dependencies=[Depends(auth.require_scope("control"))])
|
|
3422
|
+
async def add_checklist_waiver(request: Request):
|
|
3423
|
+
"""Add a waiver for a checklist item."""
|
|
3424
|
+
if not _control_limiter.check("control"):
|
|
3425
|
+
raise HTTPException(status_code=429, detail="Rate limit exceeded")
|
|
3426
|
+
try:
|
|
3427
|
+
body = await request.json()
|
|
3428
|
+
except Exception:
|
|
3429
|
+
return JSONResponse(status_code=400, content={"error": "Invalid JSON"})
|
|
3430
|
+
|
|
3431
|
+
item_id = body.get("item_id")
|
|
3432
|
+
reason = body.get("reason")
|
|
3433
|
+
if not item_id or not reason:
|
|
3434
|
+
return JSONResponse(status_code=400, content={"error": "item_id and reason required"})
|
|
3435
|
+
|
|
3436
|
+
if not isinstance(reason, str) or len(reason) > 1024:
|
|
3437
|
+
return JSONResponse(status_code=400, content={"error": "reason must be a string (max 1024 chars)"})
|
|
3438
|
+
|
|
3439
|
+
# Sanitize item_id: non-empty, max 256 chars, no path traversal
|
|
3440
|
+
if not isinstance(item_id, str) or len(item_id) > 256 or ".." in item_id or "/" in item_id or "\\" in item_id:
|
|
3441
|
+
return JSONResponse(status_code=400, content={"error": "Invalid item_id: must be 1-256 chars, no path traversal characters"})
|
|
3442
|
+
|
|
3443
|
+
waivers_file = _get_loki_dir() / "checklist" / "waivers.json"
|
|
3444
|
+
|
|
3445
|
+
# Load existing
|
|
3446
|
+
waivers = {"waivers": []}
|
|
3447
|
+
if waivers_file.exists():
|
|
3448
|
+
try:
|
|
3449
|
+
waivers = json.loads(waivers_file.read_text())
|
|
3450
|
+
except (json.JSONDecodeError, IOError):
|
|
3451
|
+
pass
|
|
3452
|
+
|
|
3453
|
+
# Check duplicate
|
|
3454
|
+
for w in waivers.get("waivers", []):
|
|
3455
|
+
if w.get("item_id") == item_id and w.get("active", True):
|
|
3456
|
+
return JSONResponse(status_code=409, content={"status": "already_exists", "item_id": item_id})
|
|
3457
|
+
|
|
3458
|
+
# Add waiver
|
|
3459
|
+
waiver = {
|
|
3460
|
+
"item_id": item_id,
|
|
3461
|
+
"reason": reason,
|
|
3462
|
+
"waived_by": body.get("waived_by", "dashboard"),
|
|
3463
|
+
"waived_at": datetime.now(timezone.utc).isoformat(),
|
|
3464
|
+
"active": True
|
|
3465
|
+
}
|
|
3466
|
+
waivers.setdefault("waivers", []).append(waiver)
|
|
3467
|
+
|
|
3468
|
+
# Ensure directory exists
|
|
3469
|
+
waivers_file.parent.mkdir(parents=True, exist_ok=True)
|
|
3470
|
+
|
|
3471
|
+
# Atomic write
|
|
3472
|
+
tmp_file = waivers_file.with_suffix(".tmp")
|
|
3473
|
+
tmp_file.write_text(json.dumps(waivers, indent=2))
|
|
3474
|
+
tmp_file.replace(waivers_file)
|
|
3475
|
+
|
|
3476
|
+
return {"status": "added", "waiver": waiver}
|
|
3477
|
+
|
|
3478
|
+
|
|
3479
|
+
@app.delete("/api/checklist/waivers/{item_id}", dependencies=[Depends(auth.require_scope("control"))])
|
|
3480
|
+
async def remove_checklist_waiver(item_id: str):
|
|
3481
|
+
"""Deactivate a waiver for a checklist item."""
|
|
3482
|
+
if not _control_limiter.check("control"):
|
|
3483
|
+
raise HTTPException(status_code=429, detail="Rate limit exceeded")
|
|
3484
|
+
|
|
3485
|
+
# Sanitize item_id: non-empty, max 256 chars, no path traversal
|
|
3486
|
+
if not item_id or len(item_id) > 256 or ".." in item_id or "/" in item_id or "\\" in item_id:
|
|
3487
|
+
raise HTTPException(status_code=400, detail="Invalid item_id: must be 1-256 chars, no path traversal characters")
|
|
3488
|
+
|
|
3489
|
+
waivers_file = _get_loki_dir() / "checklist" / "waivers.json"
|
|
3490
|
+
if not waivers_file.exists():
|
|
3491
|
+
return JSONResponse(status_code=404, content={"error": "No waivers file"})
|
|
3492
|
+
|
|
3493
|
+
try:
|
|
3494
|
+
waivers = json.loads(waivers_file.read_text())
|
|
3495
|
+
except (json.JSONDecodeError, IOError):
|
|
3496
|
+
return JSONResponse(status_code=500, content={"error": "Failed to read waivers"})
|
|
3497
|
+
|
|
3498
|
+
found = False
|
|
3499
|
+
for w in waivers.get("waivers", []):
|
|
3500
|
+
if w.get("item_id") == item_id and w.get("active", True):
|
|
3501
|
+
w["active"] = False
|
|
3502
|
+
found = True
|
|
3503
|
+
|
|
3504
|
+
if not found:
|
|
3505
|
+
return JSONResponse(status_code=404, content={"error": f"No active waiver for {item_id}"})
|
|
3506
|
+
|
|
3507
|
+
# Atomic write
|
|
3508
|
+
tmp_file = waivers_file.with_suffix(".tmp")
|
|
3509
|
+
tmp_file.write_text(json.dumps(waivers, indent=2))
|
|
3510
|
+
tmp_file.replace(waivers_file)
|
|
3511
|
+
|
|
3512
|
+
return {"status": "removed", "item_id": item_id}
|
|
3513
|
+
|
|
3514
|
+
|
|
3515
|
+
# =============================================================================
|
|
3516
|
+
# Council Hard Gate Endpoint (Phase 4)
|
|
3517
|
+
# =============================================================================
|
|
3518
|
+
|
|
3519
|
+
@app.get("/api/council/gate")
|
|
3520
|
+
async def get_council_gate():
|
|
3521
|
+
"""Get council hard gate status."""
|
|
3522
|
+
gate_file = _get_loki_dir() / "council" / "gate-block.json"
|
|
3523
|
+
if not gate_file.exists():
|
|
3524
|
+
return {"blocked": False}
|
|
3525
|
+
try:
|
|
3526
|
+
return json.loads(gate_file.read_text())
|
|
3527
|
+
except (json.JSONDecodeError, IOError):
|
|
3528
|
+
return {"blocked": False, "error": "Failed to read gate file"}
|
|
3529
|
+
|
|
3530
|
+
|
|
3405
3531
|
# =============================================================================
|
|
3406
3532
|
# App Runner Endpoints (v5.45.0)
|
|
3407
3533
|
# =============================================================================
|
|
@@ -3436,7 +3562,7 @@ async def get_app_runner_logs(lines: int = Query(default=100, ge=1, le=1000)):
|
|
|
3436
3562
|
@app.post("/api/control/app-restart", dependencies=[Depends(auth.require_scope("control"))])
|
|
3437
3563
|
async def control_app_restart(request: Request):
|
|
3438
3564
|
"""Signal app runner to restart the application."""
|
|
3439
|
-
if not _control_limiter.check(
|
|
3565
|
+
if not _control_limiter.check(request.client.host if request.client else "unknown"):
|
|
3440
3566
|
raise HTTPException(status_code=429, detail="Rate limit exceeded")
|
|
3441
3567
|
loki_dir = _get_loki_dir()
|
|
3442
3568
|
signal_dir = loki_dir / "app-runner"
|
|
@@ -3449,7 +3575,7 @@ async def control_app_restart(request: Request):
|
|
|
3449
3575
|
@app.post("/api/control/app-stop", dependencies=[Depends(auth.require_scope("control"))])
|
|
3450
3576
|
async def control_app_stop(request: Request):
|
|
3451
3577
|
"""Signal app runner to stop the application."""
|
|
3452
|
-
if not _control_limiter.check(
|
|
3578
|
+
if not _control_limiter.check(request.client.host if request.client else "unknown"):
|
|
3453
3579
|
raise HTTPException(status_code=429, detail="Rate limit exceeded")
|
|
3454
3580
|
loki_dir = _get_loki_dir()
|
|
3455
3581
|
signal_dir = loki_dir / "app-runner"
|
|
@@ -3487,7 +3613,7 @@ async def get_playwright_screenshot():
|
|
|
3487
3613
|
screenshots = sorted(screenshots_dir.glob("*.png"), key=lambda p: p.stat().st_mtime, reverse=True)
|
|
3488
3614
|
if not screenshots:
|
|
3489
3615
|
return {"screenshot": None}
|
|
3490
|
-
return
|
|
3616
|
+
return FileResponse(str(screenshots[0]), media_type="image/png")
|
|
3491
3617
|
|
|
3492
3618
|
|
|
3493
3619
|
# =============================================================================
|