loki-mode 7.5.14 → 7.5.16
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/SKILL.md +2 -2
- package/VERSION +1 -1
- package/autonomy/completion-council.sh +168 -0
- package/autonomy/loki +102 -4
- package/autonomy/run.sh +110 -3
- package/dashboard/__init__.py +1 -1
- package/dashboard/server.py +136 -0
- package/dashboard/static/index.html +369 -108
- package/docs/INSTALLATION.md +1 -1
- package/loki-ts/dist/loki.js +80 -80
- package/mcp/__init__.py +1 -1
- package/memory/storage.py +35 -7
- package/package.json +1 -1
package/SKILL.md
CHANGED
|
@@ -3,7 +3,7 @@ name: loki-mode
|
|
|
3
3
|
description: Multi-agent autonomous startup system. Triggers on "Loki Mode". Takes a spec (PRD, GitHub issue, OpenAPI doc, etc.) to deployed product with minimal human intervention. Requires --dangerously-skip-permissions flag.
|
|
4
4
|
---
|
|
5
5
|
|
|
6
|
-
# Loki Mode v7.5.
|
|
6
|
+
# Loki Mode v7.5.16
|
|
7
7
|
|
|
8
8
|
**You are an autonomous agent. You make decisions. You do not ask questions. You do not stop.**
|
|
9
9
|
|
|
@@ -381,4 +381,4 @@ See `CHANGELOG.md` entries [7.5.7], [7.5.8], [7.5.13] for the per-fix list and r
|
|
|
381
381
|
|
|
382
382
|
---
|
|
383
383
|
|
|
384
|
-
**v7.5.
|
|
384
|
+
**v7.5.16 | [Autonomi](https://www.autonomi.dev/) flagship product | ~260 lines core**
|
package/VERSION
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
7.5.
|
|
1
|
+
7.5.16
|
|
@@ -470,12 +470,171 @@ with open(state_file, 'w') as f:
|
|
|
470
470
|
"threshold=$effective_threshold" \
|
|
471
471
|
"result=$([ $approve_count -ge $effective_threshold ] && echo 'APPROVED' || echo 'REJECTED')" 2>/dev/null || true
|
|
472
472
|
|
|
473
|
+
# Write transcript for this council round (Path A: council_vote path)
|
|
474
|
+
local _ct_outcome
|
|
475
|
+
_ct_outcome=$([ $approve_count -ge $effective_threshold ] && echo "APPROVED" || echo "REJECTED")
|
|
476
|
+
local _ct_triggered="false"
|
|
477
|
+
local _ct_flipped="false"
|
|
478
|
+
if [ $approve_count -eq $COUNCIL_SIZE ] && [ $COUNCIL_SIZE -ge 2 ]; then
|
|
479
|
+
_ct_triggered="true"
|
|
480
|
+
fi
|
|
481
|
+
# contrarian_flipped: DA voted REJECT/CANNOT_VALIDATE causing approve_count drop
|
|
482
|
+
# Detect by checking if approve dropped from unanimous (COUNCIL_SIZE) to less
|
|
483
|
+
# We infer flip if triggered AND final approve < COUNCIL_SIZE
|
|
484
|
+
if [ "$_ct_triggered" = "true" ] && [ $approve_count -lt $COUNCIL_SIZE ]; then
|
|
485
|
+
_ct_flipped="true"
|
|
486
|
+
fi
|
|
487
|
+
council_write_transcript "${ITERATION_COUNT:-0}" "$_ct_outcome" "$_ct_triggered" "$_ct_flipped"
|
|
488
|
+
|
|
473
489
|
if [ $approve_count -ge $effective_threshold ]; then
|
|
474
490
|
return 0 # Council says DONE
|
|
475
491
|
fi
|
|
476
492
|
return 1 # Council says CONTINUE
|
|
477
493
|
}
|
|
478
494
|
|
|
495
|
+
#===============================================================================
|
|
496
|
+
# Council Transcript Writer - persists per-iteration council round as JSON
|
|
497
|
+
#
|
|
498
|
+
# Arguments:
|
|
499
|
+
# $1 - iteration number
|
|
500
|
+
# $2 - outcome: APPROVED | REJECTED | BLOCKED_BY_GATE
|
|
501
|
+
# $3 - contrarian_triggered: true | false
|
|
502
|
+
# $4 - contrarian_flipped: true | false
|
|
503
|
+
#
|
|
504
|
+
# Output: .loki/council/transcripts/iter-<N>-<TIMESTAMP>.json
|
|
505
|
+
#===============================================================================
|
|
506
|
+
|
|
507
|
+
council_write_transcript() {
|
|
508
|
+
local iteration="${1:-${ITERATION_COUNT:-0}}"
|
|
509
|
+
local outcome="${2:-REJECTED}"
|
|
510
|
+
local contrarian_triggered="${3:-false}"
|
|
511
|
+
local contrarian_flipped="${4:-false}"
|
|
512
|
+
local timestamp
|
|
513
|
+
timestamp=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
|
|
514
|
+
# Remove colons and hyphens from timestamp for filename safety
|
|
515
|
+
local ts_safe="${timestamp//[:\-]/}"
|
|
516
|
+
local iteration_id="iter-${iteration}-${ts_safe}"
|
|
517
|
+
local transcript_dir="$COUNCIL_STATE_DIR/transcripts"
|
|
518
|
+
mkdir -p "$transcript_dir"
|
|
519
|
+
local transcript_file="$transcript_dir/${iteration_id}.json"
|
|
520
|
+
|
|
521
|
+
# Read prd preview from state or prd file
|
|
522
|
+
local task_or_prd=""
|
|
523
|
+
if [ -n "$COUNCIL_PRD_PATH" ] && [ -f "$COUNCIL_PRD_PATH" ]; then
|
|
524
|
+
task_or_prd=$(head -5 "$COUNCIL_PRD_PATH" | tr '\n' ' ' | cut -c1-200)
|
|
525
|
+
fi
|
|
526
|
+
|
|
527
|
+
local round_file="$COUNCIL_STATE_DIR/votes/round-${iteration}.json"
|
|
528
|
+
local da_file="$COUNCIL_STATE_DIR/votes/devils-advocate-round-${iteration}.json"
|
|
529
|
+
|
|
530
|
+
_IT="$iteration" _TS="$timestamp" _IID="$iteration_id" \
|
|
531
|
+
_OUTCOME="$outcome" _CT="$contrarian_triggered" _CF="$contrarian_flipped" \
|
|
532
|
+
_TASK="$task_or_prd" _PRD="${COUNCIL_PRD_PATH:-}" \
|
|
533
|
+
_ROUND_FILE="${round_file}" _DA_FILE="${da_file}" \
|
|
534
|
+
_MEMBERS_DIR="$COUNCIL_STATE_DIR/votes/iteration-${iteration}" \
|
|
535
|
+
_OUT="$transcript_file" \
|
|
536
|
+
python3 -c "
|
|
537
|
+
import json, os, pathlib, re
|
|
538
|
+
|
|
539
|
+
iteration_id = os.environ['_IID']
|
|
540
|
+
voters = []
|
|
541
|
+
|
|
542
|
+
# Priority 1: structured round file (Path B -- council_aggregate_votes)
|
|
543
|
+
rfile = pathlib.Path(os.environ['_ROUND_FILE'])
|
|
544
|
+
if rfile.exists():
|
|
545
|
+
try:
|
|
546
|
+
rd = json.loads(rfile.read_text())
|
|
547
|
+
for v in rd.get('votes', []):
|
|
548
|
+
voters.append({
|
|
549
|
+
'name': v.get('role', 'unknown'),
|
|
550
|
+
'role_index': v.get('member', 0),
|
|
551
|
+
'verdict': 'APPROVE' if v.get('vote') == 'COMPLETE' else 'REJECT',
|
|
552
|
+
'reasoning': v.get('reason', ''),
|
|
553
|
+
'issues': [],
|
|
554
|
+
'is_contrarian': False,
|
|
555
|
+
})
|
|
556
|
+
except Exception:
|
|
557
|
+
pass
|
|
558
|
+
|
|
559
|
+
# Priority 2: member txt files (Path A -- council_vote)
|
|
560
|
+
if not voters:
|
|
561
|
+
mdir = pathlib.Path(os.environ['_MEMBERS_DIR'])
|
|
562
|
+
roles = ['requirements_verifier', 'test_auditor', 'devils_advocate']
|
|
563
|
+
if mdir.exists():
|
|
564
|
+
for mf in sorted(mdir.glob('member-*.txt')):
|
|
565
|
+
content = mf.read_text(errors='replace').strip()
|
|
566
|
+
vote_match = re.search(r'VOTE\s*:\s*(APPROVE|REJECT|CANNOT_VALIDATE)', content)
|
|
567
|
+
reason_match = re.search(r'REASON\s*:\s*(.+?)(?:\n|\$)', content)
|
|
568
|
+
issues = []
|
|
569
|
+
for im in re.finditer(r'ISSUES\s*:\s*(CRITICAL|HIGH|MEDIUM|LOW)\s*:\s*(.+?)(?:\n|\$)', content):
|
|
570
|
+
issues.append({'severity': im.group(1), 'description': im.group(2).strip()})
|
|
571
|
+
idx = int(re.sub(r'\D', '', mf.stem) or '0') - 1
|
|
572
|
+
role = roles[idx % len(roles)] if idx >= 0 else 'unknown'
|
|
573
|
+
voters.append({
|
|
574
|
+
'name': role,
|
|
575
|
+
'role_index': idx + 1,
|
|
576
|
+
'verdict': vote_match.group(1) if vote_match else 'REJECT',
|
|
577
|
+
'reasoning': reason_match.group(1).strip() if reason_match else '',
|
|
578
|
+
'issues': issues,
|
|
579
|
+
'is_contrarian': False,
|
|
580
|
+
})
|
|
581
|
+
|
|
582
|
+
# Add DA voter if triggered
|
|
583
|
+
ct = os.environ['_CT'] == 'true'
|
|
584
|
+
cf = os.environ['_CF'] == 'true'
|
|
585
|
+
if ct:
|
|
586
|
+
da_challenges = []
|
|
587
|
+
dafile = pathlib.Path(os.environ['_DA_FILE'])
|
|
588
|
+
if dafile.exists():
|
|
589
|
+
try:
|
|
590
|
+
da = json.loads(dafile.read_text())
|
|
591
|
+
details = da.get('details', '')
|
|
592
|
+
if details and details != 'none':
|
|
593
|
+
da_challenges = [d.strip() for d in details.split(';') if d.strip()]
|
|
594
|
+
except Exception:
|
|
595
|
+
pass
|
|
596
|
+
# Also check contrarian.txt for reasoning
|
|
597
|
+
cfile = pathlib.Path(os.environ['_MEMBERS_DIR']) / 'contrarian.txt'
|
|
598
|
+
da_reasoning = ''
|
|
599
|
+
da_verdict = 'REJECT' if cf else 'APPROVE'
|
|
600
|
+
if cfile.exists():
|
|
601
|
+
content = cfile.read_text(errors='replace').strip()
|
|
602
|
+
reason_match = re.search(r'REASON\s*:\s*(.+?)(?:\n|\$)', content)
|
|
603
|
+
if reason_match:
|
|
604
|
+
da_reasoning = reason_match.group(1).strip()
|
|
605
|
+
voters.append({
|
|
606
|
+
'name': 'devils_advocate',
|
|
607
|
+
'role_index': len(voters) + 1,
|
|
608
|
+
'verdict': da_verdict,
|
|
609
|
+
'reasoning': da_reasoning,
|
|
610
|
+
'issues': [],
|
|
611
|
+
'challenges': da_challenges,
|
|
612
|
+
'is_contrarian': True,
|
|
613
|
+
'triggered': True,
|
|
614
|
+
})
|
|
615
|
+
|
|
616
|
+
task_or_prd = os.environ.get('_TASK', '')[:200]
|
|
617
|
+
non_contrarian = [v for v in voters if not v.get('is_contrarian')]
|
|
618
|
+
transcript = {
|
|
619
|
+
'iteration_id': iteration_id,
|
|
620
|
+
'iteration': int(os.environ['_IT']),
|
|
621
|
+
'timestamp': os.environ['_TS'],
|
|
622
|
+
'task_or_prd': task_or_prd,
|
|
623
|
+
'prd_path': os.environ.get('_PRD', ''),
|
|
624
|
+
'voters': voters,
|
|
625
|
+
'outcome': os.environ['_OUTCOME'],
|
|
626
|
+
'contrarian_triggered': ct,
|
|
627
|
+
'contrarian_flipped': cf,
|
|
628
|
+
'approve_count': sum(1 for v in non_contrarian if v.get('verdict') == 'APPROVE'),
|
|
629
|
+
'reject_count': sum(1 for v in non_contrarian if v.get('verdict') in ('REJECT', 'CANNOT_VALIDATE')),
|
|
630
|
+
'threshold': 2,
|
|
631
|
+
'total_members': len(non_contrarian),
|
|
632
|
+
}
|
|
633
|
+
with open(os.environ['_OUT'], 'w') as f:
|
|
634
|
+
json.dump(transcript, f, indent=2)
|
|
635
|
+
" || log_warn "Failed to write council transcript"
|
|
636
|
+
}
|
|
637
|
+
|
|
479
638
|
#===============================================================================
|
|
480
639
|
# Evidence Gathering - Collect data for council review
|
|
481
640
|
#===============================================================================
|
|
@@ -1372,14 +1531,23 @@ council_evaluate() {
|
|
|
1372
1531
|
da_result=$(council_devils_advocate_review "$ITERATION_COUNT")
|
|
1373
1532
|
if [ "$da_result" = "OVERRIDE_CONTINUE" ]; then
|
|
1374
1533
|
log_warn "Council evaluate: devil's advocate overrode unanimous COMPLETE"
|
|
1534
|
+
# Write transcript: DA triggered and flipped the outcome (Path B)
|
|
1535
|
+
council_write_transcript "${ITERATION_COUNT:-0}" "REJECTED" "true" "true"
|
|
1375
1536
|
return 1 # CONTINUE
|
|
1376
1537
|
fi
|
|
1538
|
+
# Write transcript: DA triggered but did NOT flip (Path B, unanimous COMPLETE confirmed)
|
|
1539
|
+
council_write_transcript "${ITERATION_COUNT:-0}" "APPROVED" "true" "false"
|
|
1540
|
+
else
|
|
1541
|
+
# Write transcript: not unanimous, DA not triggered (Path B)
|
|
1542
|
+
council_write_transcript "${ITERATION_COUNT:-0}" "APPROVED" "false" "false"
|
|
1377
1543
|
fi
|
|
1378
1544
|
|
|
1379
1545
|
log_info "Council evaluate: verdict is COMPLETE"
|
|
1380
1546
|
return 0 # COMPLETE (should stop)
|
|
1381
1547
|
fi
|
|
1382
1548
|
|
|
1549
|
+
# Write transcript: aggregate voted CONTINUE (Path B)
|
|
1550
|
+
council_write_transcript "${ITERATION_COUNT:-0}" "REJECTED" "false" "false"
|
|
1383
1551
|
log_info "Council evaluate: verdict is CONTINUE"
|
|
1384
1552
|
return 1 # CONTINUE
|
|
1385
1553
|
}
|
package/autonomy/loki
CHANGED
|
@@ -3096,6 +3096,11 @@ cmd_dashboard_help() {
|
|
|
3096
3096
|
echo ""
|
|
3097
3097
|
echo "Usage: loki dashboard <command> [options]"
|
|
3098
3098
|
echo ""
|
|
3099
|
+
echo "Note: 'loki dashboard' is the operations/observability UI (port ${DASHBOARD_DEFAULT_PORT})."
|
|
3100
|
+
echo " It is NOT the same as 'loki web' (Purple Lab, port ${PURPLE_LAB_DEFAULT_PORT}, where you input PRDs)."
|
|
3101
|
+
echo " Use 'loki dashboard' to monitor agents, tasks, costs, council, escalations."
|
|
3102
|
+
echo " Use 'loki web' to submit a PRD and watch agents build."
|
|
3103
|
+
echo ""
|
|
3099
3104
|
echo "Commands:"
|
|
3100
3105
|
echo " start Start the dashboard server"
|
|
3101
3106
|
echo " stop Stop the dashboard server"
|
|
@@ -3644,6 +3649,11 @@ cmd_web_help() {
|
|
|
3644
3649
|
echo ""
|
|
3645
3650
|
echo "Usage: loki web [command] [options]"
|
|
3646
3651
|
echo ""
|
|
3652
|
+
echo "Note: 'loki web' is Purple Lab (the PRD-input/build-watch UI, port ${PURPLE_LAB_DEFAULT_PORT})."
|
|
3653
|
+
echo " It is NOT the same as 'loki dashboard' (operations UI, port ${DASHBOARD_DEFAULT_PORT})."
|
|
3654
|
+
echo " Use 'loki web' to submit a PRD and watch agents build it."
|
|
3655
|
+
echo " Use 'loki dashboard' to monitor running agents, tasks, costs, council, escalations."
|
|
3656
|
+
echo ""
|
|
3647
3657
|
echo "Commands:"
|
|
3648
3658
|
echo " start Start Purple Lab (default)"
|
|
3649
3659
|
echo " stop Stop Purple Lab server"
|
|
@@ -6961,6 +6971,20 @@ if disk_gb is not None:
|
|
|
6961
6971
|
elif disk_gb < 5:
|
|
6962
6972
|
disk_status = 'warn'
|
|
6963
6973
|
|
|
6974
|
+
# v7.5.15: expose the sentrux architectural-drift gate state in --json so
|
|
6975
|
+
# dashboards/automation can surface it the same way the text-mode block does
|
|
6976
|
+
# (added in v7.5.14). Sibling of checks/disk -- intentionally not counted in
|
|
6977
|
+
# the summary tally to keep summary numbers backwards-compatible.
|
|
6978
|
+
sentrux_found = shutil.which('sentrux') is not None
|
|
6979
|
+
sentrux_version = get_version('sentrux') if sentrux_found else None
|
|
6980
|
+
sentrux_status = 'pass' if sentrux_found else 'warn'
|
|
6981
|
+
sentrux = {
|
|
6982
|
+
'found': sentrux_found,
|
|
6983
|
+
'version': sentrux_version,
|
|
6984
|
+
'status': sentrux_status,
|
|
6985
|
+
'required': 'optional'
|
|
6986
|
+
}
|
|
6987
|
+
|
|
6964
6988
|
pass_count = sum(1 for c in checks if c['status'] == 'pass')
|
|
6965
6989
|
fail_count = sum(1 for c in checks if c['status'] == 'fail')
|
|
6966
6990
|
warn_count = sum(1 for c in checks if c['status'] == 'warn')
|
|
@@ -6975,6 +6999,7 @@ result = {
|
|
|
6975
6999
|
'available_gb': disk_gb,
|
|
6976
7000
|
'status': disk_status
|
|
6977
7001
|
},
|
|
7002
|
+
'sentrux': sentrux,
|
|
6978
7003
|
'summary': {
|
|
6979
7004
|
'passed': pass_count,
|
|
6980
7005
|
'failed': fail_count,
|
|
@@ -7011,7 +7036,18 @@ cmd_sentrux() {
|
|
|
7011
7036
|
|
|
7012
7037
|
local sub="${1:-help}"
|
|
7013
7038
|
if [ "$#" -gt 0 ]; then shift; fi
|
|
7014
|
-
|
|
7039
|
+
|
|
7040
|
+
# Parse --force flag (used by init-rules); collect remaining args into target.
|
|
7041
|
+
local force=0
|
|
7042
|
+
local positional=()
|
|
7043
|
+
while [ "$#" -gt 0 ]; do
|
|
7044
|
+
case "$1" in
|
|
7045
|
+
--force|-f) force=1; shift ;;
|
|
7046
|
+
--) shift; while [ "$#" -gt 0 ]; do positional+=("$1"); shift; done ;;
|
|
7047
|
+
*) positional+=("$1"); shift ;;
|
|
7048
|
+
esac
|
|
7049
|
+
done
|
|
7050
|
+
local target="${positional[0]:-.}"
|
|
7015
7051
|
|
|
7016
7052
|
case "$sub" in
|
|
7017
7053
|
baseline)
|
|
@@ -7075,13 +7111,75 @@ cmd_sentrux() {
|
|
|
7075
7111
|
fi
|
|
7076
7112
|
return 0
|
|
7077
7113
|
;;
|
|
7114
|
+
init-rules)
|
|
7115
|
+
# Scaffold a conservative default .sentrux/rules.toml in <target>/.sentrux/.
|
|
7116
|
+
# Does not require sentrux binary -- the file is plain text.
|
|
7117
|
+
local rules_dir="$target/.sentrux"
|
|
7118
|
+
local rules_file="$rules_dir/rules.toml"
|
|
7119
|
+
local abs_path
|
|
7120
|
+
if [ -e "$rules_file" ] && [ "$force" -ne 1 ]; then
|
|
7121
|
+
echo -e "${YELLOW}Refusing to overwrite existing $rules_file${NC}" >&2
|
|
7122
|
+
echo " Re-run with --force to replace it." >&2
|
|
7123
|
+
return 1
|
|
7124
|
+
fi
|
|
7125
|
+
if ! mkdir -p "$rules_dir" 2>/dev/null; then
|
|
7126
|
+
echo -e "${RED}Failed to create $rules_dir${NC}" >&2
|
|
7127
|
+
return 2
|
|
7128
|
+
fi
|
|
7129
|
+
if ! cat > "$rules_file" <<'SENTRUX_RULES_EOF'
|
|
7130
|
+
# Sentrux architectural rules scaffolded by `loki sentrux init-rules` (Loki Mode v7.5.15).
|
|
7131
|
+
# Conservative defaults -- tighten per-project as needed.
|
|
7132
|
+
# See https://github.com/sentrux/sentrux for full spec.
|
|
7133
|
+
|
|
7134
|
+
[constraints]
|
|
7135
|
+
# Block any iteration that introduces an import cycle.
|
|
7136
|
+
max_cycles = 0
|
|
7137
|
+
# Block files that grow into "god files" (very high churn + many dependents).
|
|
7138
|
+
no_god_files = true
|
|
7139
|
+
# Cap cyclomatic complexity per function (sentrux default is liberal).
|
|
7140
|
+
max_cc = 30
|
|
7141
|
+
|
|
7142
|
+
# Layer enforcement is project-specific. Uncomment + edit when you know the
|
|
7143
|
+
# layout you want enforced. Example for a typical app:
|
|
7144
|
+
#
|
|
7145
|
+
# [[layers]]
|
|
7146
|
+
# name = "core"
|
|
7147
|
+
# paths = ["src/core/*"]
|
|
7148
|
+
# order = 0
|
|
7149
|
+
#
|
|
7150
|
+
# [[layers]]
|
|
7151
|
+
# name = "app"
|
|
7152
|
+
# paths = ["src/app/*"]
|
|
7153
|
+
# order = 2
|
|
7154
|
+
#
|
|
7155
|
+
# [[boundaries]]
|
|
7156
|
+
# from = "src/app/*"
|
|
7157
|
+
# to = "src/core/internal/*"
|
|
7158
|
+
# reason = "app must not depend on core internals"
|
|
7159
|
+
SENTRUX_RULES_EOF
|
|
7160
|
+
then
|
|
7161
|
+
echo -e "${RED}Failed to write $rules_file${NC}" >&2
|
|
7162
|
+
return 2
|
|
7163
|
+
fi
|
|
7164
|
+
# Resolve absolute path for friendly output (portable across macOS/Linux).
|
|
7165
|
+
if command -v python3 >/dev/null 2>&1; then
|
|
7166
|
+
abs_path=$(python3 -c "import os,sys; print(os.path.abspath(sys.argv[1]))" "$rules_file" 2>/dev/null || echo "$rules_file")
|
|
7167
|
+
else
|
|
7168
|
+
abs_path=$(cd "$(dirname "$rules_file")" 2>/dev/null && pwd)/$(basename "$rules_file")
|
|
7169
|
+
fi
|
|
7170
|
+
echo -e "${GREEN}Wrote $abs_path${NC}"
|
|
7171
|
+
echo "Edit it to add layer/boundary rules, then run: loki sentrux baseline $target"
|
|
7172
|
+
return 0
|
|
7173
|
+
;;
|
|
7078
7174
|
help|--help|-h|"")
|
|
7079
7175
|
echo -e "${BOLD}loki sentrux${NC} - Architectural drift gate (opt-in, requires sentrux binary)"
|
|
7080
7176
|
echo ""
|
|
7081
7177
|
echo "Usage:"
|
|
7082
|
-
echo " loki sentrux baseline
|
|
7083
|
-
echo " loki sentrux gate
|
|
7084
|
-
echo " loki sentrux status
|
|
7178
|
+
echo " loki sentrux baseline [<path>] Save current architecture as baseline"
|
|
7179
|
+
echo " loki sentrux gate [<path>] Compare current vs baseline (exit 1 on DEGRADED)"
|
|
7180
|
+
echo " loki sentrux status [<path>] Show binary version + saved baseline quality"
|
|
7181
|
+
echo " loki sentrux init-rules [<path>] [--force]"
|
|
7182
|
+
echo " Scaffold a default .sentrux/rules.toml"
|
|
7085
7183
|
echo ""
|
|
7086
7184
|
echo "Default path is the current directory."
|
|
7087
7185
|
echo ""
|
package/autonomy/run.sh
CHANGED
|
@@ -5916,6 +5916,29 @@ except (json.JSONDecodeError, FileNotFoundError, OSError):
|
|
|
5916
5916
|
# Results stored in .loki/quality/test-results.json
|
|
5917
5917
|
# ============================================================================
|
|
5918
5918
|
|
|
5919
|
+
# v7.5.15 (Triage #14): wrap pytest with a configurable timeout so a
|
|
5920
|
+
# deadlocked or infinite-loop test under /test cannot hang the gate
|
|
5921
|
+
# indefinitely. Uses `timeout` on Linux, `gtimeout` (coreutils) on macOS,
|
|
5922
|
+
# and degrades gracefully if neither is available (logs a warning, runs
|
|
5923
|
+
# unbounded). Configurable via LOKI_PYTEST_TIMEOUT (default 300s).
|
|
5924
|
+
#
|
|
5925
|
+
# Usage: _loki_run_pytest_with_timeout <target_dir> [pytest_args...]
|
|
5926
|
+
# Stdout: combined pytest output
|
|
5927
|
+
# Exit: 0 on pass, non-zero on fail. Exit 124 indicates the timeout fired.
|
|
5928
|
+
_loki_run_pytest_with_timeout() {
|
|
5929
|
+
local target_dir="$1"; shift
|
|
5930
|
+
local pytest_timeout="${LOKI_PYTEST_TIMEOUT:-${LOKI_GATE_TIMEOUT:-300}}"
|
|
5931
|
+
local _to_cmd=()
|
|
5932
|
+
if command -v gtimeout >/dev/null 2>&1; then
|
|
5933
|
+
_to_cmd=(gtimeout "${pytest_timeout}s")
|
|
5934
|
+
elif command -v timeout >/dev/null 2>&1; then
|
|
5935
|
+
_to_cmd=(timeout "${pytest_timeout}s")
|
|
5936
|
+
else
|
|
5937
|
+
log_warn "Neither gtimeout nor timeout available; pytest gate will run unbounded (install coreutils on macOS)"
|
|
5938
|
+
fi
|
|
5939
|
+
(cd "$target_dir" && "${_to_cmd[@]}" pytest "$@" 2>&1)
|
|
5940
|
+
}
|
|
5941
|
+
|
|
5919
5942
|
enforce_test_coverage() {
|
|
5920
5943
|
local loki_dir="${TARGET_DIR:-.}/.loki"
|
|
5921
5944
|
local quality_dir="$loki_dir/quality"
|
|
@@ -6037,9 +6060,19 @@ enforce_test_coverage() {
|
|
|
6037
6060
|
fi
|
|
6038
6061
|
if [ "$has_python_project" = "true" ] && command -v pytest &>/dev/null; then
|
|
6039
6062
|
test_runner="pytest"
|
|
6040
|
-
local output
|
|
6041
|
-
|
|
6042
|
-
|
|
6063
|
+
local output pytest_exit
|
|
6064
|
+
# v7.5.15 (Triage #14): wrapped with configurable timeout via helper.
|
|
6065
|
+
output=$(_loki_run_pytest_with_timeout "${TARGET_DIR:-.}" --tb=short)
|
|
6066
|
+
pytest_exit=$?
|
|
6067
|
+
if [ "$pytest_exit" -eq 124 ]; then
|
|
6068
|
+
local _pt_to="${LOKI_PYTEST_TIMEOUT:-${LOKI_GATE_TIMEOUT:-300}}"
|
|
6069
|
+
test_passed=false
|
|
6070
|
+
log_warn "pytest gate timed out after ${_pt_to}s (exit 124)"
|
|
6071
|
+
details="pytest: TIMED OUT after ${_pt_to}s -- $(echo "$output" | tail -3 | tr '\n' ' ')"
|
|
6072
|
+
else
|
|
6073
|
+
[ "$pytest_exit" -ne 0 ] && test_passed=false
|
|
6074
|
+
details="pytest: $(echo "$output" | tail -5 | tr '\n' ' ')"
|
|
6075
|
+
fi
|
|
6043
6076
|
fi
|
|
6044
6077
|
fi
|
|
6045
6078
|
|
|
@@ -10487,11 +10520,79 @@ PRD_PARSE_EOF
|
|
|
10487
10520
|
# Main Autonomous Loop
|
|
10488
10521
|
#===============================================================================
|
|
10489
10522
|
|
|
10523
|
+
#-------------------------------------------------------------------------------
|
|
10524
|
+
# Sentrux architectural-drift gate hooks (v7.5.15).
|
|
10525
|
+
#
|
|
10526
|
+
# Opt-in via LOKI_SENTRUX_GATE=1. Default OFF -- zero behavior change for users
|
|
10527
|
+
# who don't opt in. The helper at autonomy/lib/sentrux-gate.sh is sourced inside
|
|
10528
|
+
# run_autonomous() under the same guard. Both hook functions no-op silently if
|
|
10529
|
+
# the helper is not loaded or the sentrux binary is not on PATH.
|
|
10530
|
+
#-------------------------------------------------------------------------------
|
|
10531
|
+
_loki_sentrux_iteration_start() {
|
|
10532
|
+
local target="${1:-${TARGET_DIR:-.}}"
|
|
10533
|
+
if [ "${LOKI_SENTRUX_GATE:-0}" != "1" ]; then
|
|
10534
|
+
return 0
|
|
10535
|
+
fi
|
|
10536
|
+
if ! type sentrux_available >/dev/null 2>&1 || ! sentrux_available; then
|
|
10537
|
+
return 0
|
|
10538
|
+
fi
|
|
10539
|
+
sentrux_baseline_save "$target" >/dev/null 2>&1 || true
|
|
10540
|
+
return 0
|
|
10541
|
+
}
|
|
10542
|
+
|
|
10543
|
+
_loki_sentrux_iteration_end() {
|
|
10544
|
+
local iter="${1:-0}"
|
|
10545
|
+
local target="${2:-${TARGET_DIR:-.}}"
|
|
10546
|
+
if [ "${LOKI_SENTRUX_GATE:-0}" != "1" ]; then
|
|
10547
|
+
return 0
|
|
10548
|
+
fi
|
|
10549
|
+
if ! type sentrux_available >/dev/null 2>&1 || ! sentrux_available; then
|
|
10550
|
+
return 0
|
|
10551
|
+
fi
|
|
10552
|
+
local diff before after verdict
|
|
10553
|
+
diff=$(sentrux_gate_diff "$target" 2>/dev/null || true)
|
|
10554
|
+
if [ -z "$diff" ]; then
|
|
10555
|
+
return 0
|
|
10556
|
+
fi
|
|
10557
|
+
before="${diff%%|*}"
|
|
10558
|
+
local rest="${diff#*|}"
|
|
10559
|
+
after="${rest%%|*}"
|
|
10560
|
+
verdict="${rest#*|}"
|
|
10561
|
+
if type log_info >/dev/null 2>&1; then
|
|
10562
|
+
log_info "sentrux gate iter=$iter verdict=$verdict before=${before:-?} after=${after:-?}"
|
|
10563
|
+
fi
|
|
10564
|
+
if [ "$verdict" = "DEGRADED" ]; then
|
|
10565
|
+
local state_dir="$target/.loki/state"
|
|
10566
|
+
mkdir -p "$state_dir" 2>/dev/null || true
|
|
10567
|
+
local finding_path="$state_dir/findings-sentrux-${iter}.json"
|
|
10568
|
+
local ts
|
|
10569
|
+
ts=$(date -u +"%Y-%m-%dT%H:%M:%SZ" 2>/dev/null || echo "")
|
|
10570
|
+
local before_json="${before:-0}"
|
|
10571
|
+
local after_json="${after:-0}"
|
|
10572
|
+
# Guard against non-numeric values when serializing to JSON.
|
|
10573
|
+
if ! [[ "$before_json" =~ ^[0-9]+$ ]]; then before_json=0; fi
|
|
10574
|
+
if ! [[ "$after_json" =~ ^[0-9]+$ ]]; then after_json=0; fi
|
|
10575
|
+
printf '{"type":"architectural-drift","iteration":%s,"before":%s,"after":%s,"verdict":"DEGRADED","timestamp":"%s","source":"sentrux"}\n' \
|
|
10576
|
+
"$iter" "$before_json" "$after_json" "$ts" \
|
|
10577
|
+
> "$finding_path" 2>/dev/null || true
|
|
10578
|
+
fi
|
|
10579
|
+
return 0
|
|
10580
|
+
}
|
|
10581
|
+
|
|
10490
10582
|
run_autonomous() {
|
|
10491
10583
|
local prd_path="$1"
|
|
10492
10584
|
|
|
10493
10585
|
log_header "Starting Autonomous Execution"
|
|
10494
10586
|
|
|
10587
|
+
# Sentrux architectural-drift gate (opt-in via LOKI_SENTRUX_GATE=1, v7.5.15).
|
|
10588
|
+
# Source the helper only when the gate is enabled to avoid hot-path overhead
|
|
10589
|
+
# for the default-off case. Failure to source is non-fatal -- the wrapper
|
|
10590
|
+
# functions degrade to no-ops via type checks.
|
|
10591
|
+
if [ "${LOKI_SENTRUX_GATE:-0}" = "1" ]; then
|
|
10592
|
+
# shellcheck disable=SC1090,SC1091
|
|
10593
|
+
source "${SCRIPT_DIR}/lib/sentrux-gate.sh" 2>/dev/null || true
|
|
10594
|
+
fi
|
|
10595
|
+
|
|
10495
10596
|
# Auto-detect PRD if not provided
|
|
10496
10597
|
if [ -z "$prd_path" ]; then
|
|
10497
10598
|
log_step "No PRD provided, searching for existing PRD files..."
|
|
@@ -10670,6 +10771,9 @@ except Exception as exc:
|
|
|
10670
10771
|
# Auto-track iteration start (for dashboard task queue)
|
|
10671
10772
|
track_iteration_start "$ITERATION_COUNT" "$prd_path"
|
|
10672
10773
|
|
|
10774
|
+
# Sentrux architectural-drift baseline snapshot (opt-in, v7.5.15).
|
|
10775
|
+
_loki_sentrux_iteration_start "${TARGET_DIR:-.}"
|
|
10776
|
+
|
|
10673
10777
|
local prompt
|
|
10674
10778
|
prompt=$(build_prompt "$retry" "$prd_path" "$ITERATION_COUNT")
|
|
10675
10779
|
|
|
@@ -11149,6 +11253,9 @@ if __name__ == "__main__":
|
|
|
11149
11253
|
# Auto-track iteration completion (for dashboard task queue)
|
|
11150
11254
|
track_iteration_complete "$ITERATION_COUNT" "$exit_code"
|
|
11151
11255
|
|
|
11256
|
+
# Sentrux architectural-drift gate diff + finding emission (opt-in, v7.5.15).
|
|
11257
|
+
_loki_sentrux_iteration_end "$ITERATION_COUNT" "${TARGET_DIR:-.}"
|
|
11258
|
+
|
|
11152
11259
|
# End OTEL phase span (if OTEL is enabled)
|
|
11153
11260
|
if [ -n "${LOKI_OTEL_ENDPOINT:-}" ]; then
|
|
11154
11261
|
emit_event_pending "otel_span_end" \
|
package/dashboard/__init__.py
CHANGED
package/dashboard/server.py
CHANGED
|
@@ -3782,6 +3782,80 @@ async def force_council_review():
|
|
|
3782
3782
|
return {"success": True, "message": "Council review requested"}
|
|
3783
3783
|
|
|
3784
3784
|
|
|
3785
|
+
@app.get("/api/council/transcripts")
|
|
3786
|
+
async def get_council_transcripts(
|
|
3787
|
+
limit: int = Query(default=20, ge=1, le=200),
|
|
3788
|
+
since: Optional[str] = Query(default=None),
|
|
3789
|
+
iter_min: Optional[int] = Query(default=None),
|
|
3790
|
+
):
|
|
3791
|
+
"""List council transcript records, sorted descending by iteration number.
|
|
3792
|
+
|
|
3793
|
+
Query params:
|
|
3794
|
+
limit int, default=20, max=200
|
|
3795
|
+
since ISO8601 string (optional), filter to transcripts after this time
|
|
3796
|
+
iter_min int (optional), filter to iteration >= N
|
|
3797
|
+
"""
|
|
3798
|
+
transcripts_dir = _get_loki_dir() / "council" / "transcripts"
|
|
3799
|
+
if not transcripts_dir.exists():
|
|
3800
|
+
return {"transcripts": [], "total": 0, "latest_id": None}
|
|
3801
|
+
|
|
3802
|
+
since_dt = None
|
|
3803
|
+
if since:
|
|
3804
|
+
try:
|
|
3805
|
+
since_dt = datetime.fromisoformat(since.replace("Z", "+00:00"))
|
|
3806
|
+
except ValueError:
|
|
3807
|
+
raise HTTPException(status_code=400, detail="Invalid 'since' timestamp format; expected ISO8601")
|
|
3808
|
+
|
|
3809
|
+
records = []
|
|
3810
|
+
for f in sorted(transcripts_dir.glob("iter-*.json"), reverse=True):
|
|
3811
|
+
try:
|
|
3812
|
+
rec = json.loads(f.read_text())
|
|
3813
|
+
except Exception:
|
|
3814
|
+
logger.warning("Skipping corrupt council transcript file: %s", f.name)
|
|
3815
|
+
continue
|
|
3816
|
+
if not isinstance(rec, dict):
|
|
3817
|
+
logger.warning("Skipping non-object council transcript file: %s", f.name)
|
|
3818
|
+
continue
|
|
3819
|
+
if since_dt is not None:
|
|
3820
|
+
ts_str = rec.get("timestamp", "")
|
|
3821
|
+
try:
|
|
3822
|
+
ts = datetime.fromisoformat(ts_str.replace("Z", "+00:00"))
|
|
3823
|
+
except (ValueError, AttributeError):
|
|
3824
|
+
continue
|
|
3825
|
+
if ts <= since_dt:
|
|
3826
|
+
continue
|
|
3827
|
+
if iter_min is not None and rec.get("iteration", 0) < iter_min:
|
|
3828
|
+
continue
|
|
3829
|
+
records.append(rec)
|
|
3830
|
+
if len(records) >= limit:
|
|
3831
|
+
break
|
|
3832
|
+
|
|
3833
|
+
return {
|
|
3834
|
+
"transcripts": records,
|
|
3835
|
+
"total": len(records),
|
|
3836
|
+
"latest_id": records[0]["iteration_id"] if records else None,
|
|
3837
|
+
}
|
|
3838
|
+
|
|
3839
|
+
|
|
3840
|
+
@app.get("/api/council/transcripts/{iteration_id}")
|
|
3841
|
+
async def get_council_transcript(iteration_id: str):
|
|
3842
|
+
"""Fetch a single council transcript by iteration_id.
|
|
3843
|
+
|
|
3844
|
+
Returns the record body or 404 if not found.
|
|
3845
|
+
Path traversal attempts (containing '/' or '..') are rejected with 404.
|
|
3846
|
+
"""
|
|
3847
|
+
# Reject path traversal: iteration_id must be a plain filename component.
|
|
3848
|
+
if "/" in iteration_id or "\\" in iteration_id or ".." in iteration_id:
|
|
3849
|
+
raise HTTPException(status_code=404, detail="Transcript not found")
|
|
3850
|
+
transcript_file = _get_loki_dir() / "council" / "transcripts" / f"{iteration_id}.json"
|
|
3851
|
+
if not transcript_file.exists():
|
|
3852
|
+
raise HTTPException(status_code=404, detail="Transcript not found")
|
|
3853
|
+
try:
|
|
3854
|
+
return json.loads(transcript_file.read_text())
|
|
3855
|
+
except Exception:
|
|
3856
|
+
raise HTTPException(status_code=500, detail="Corrupt transcript file")
|
|
3857
|
+
|
|
3858
|
+
|
|
3785
3859
|
# =============================================================================
|
|
3786
3860
|
# Context Window Tracking API (v5.40.0)
|
|
3787
3861
|
# =============================================================================
|
|
@@ -5955,6 +6029,68 @@ async def get_findings(iteration: int):
|
|
|
5955
6029
|
detail=f"No findings for iteration {iteration}")
|
|
5956
6030
|
|
|
5957
6031
|
|
|
6032
|
+
@app.get("/api/quality/architecture")
|
|
6033
|
+
async def get_quality_architecture():
|
|
6034
|
+
"""Return the sentrux architectural-drift series.
|
|
6035
|
+
|
|
6036
|
+
Globs `.loki/state/findings-sentrux-*.json` (written by the iteration
|
|
6037
|
+
loop when LOKI_SENTRUX_GATE=1), sorts by iteration ascending, and
|
|
6038
|
+
returns a series suitable for plotting drift over time.
|
|
6039
|
+
|
|
6040
|
+
Per-file JSON parse errors are logged and skipped; the endpoint stays
|
|
6041
|
+
200 OK even when no files exist or every file is corrupt.
|
|
6042
|
+
"""
|
|
6043
|
+
base = _get_loki_dir()
|
|
6044
|
+
state_dir = base / "state"
|
|
6045
|
+
series: list[dict[str, Any]] = []
|
|
6046
|
+
if state_dir.exists():
|
|
6047
|
+
try:
|
|
6048
|
+
paths = list(state_dir.glob("findings-sentrux-*.json"))
|
|
6049
|
+
except OSError as exc:
|
|
6050
|
+
logger.warning("sentrux: failed to glob %s: %s", state_dir, exc)
|
|
6051
|
+
paths = []
|
|
6052
|
+
for path in paths:
|
|
6053
|
+
try:
|
|
6054
|
+
text = path.read_text(encoding="utf-8", errors="replace")
|
|
6055
|
+
data = json.loads(text)
|
|
6056
|
+
except (OSError, IOError) as exc:
|
|
6057
|
+
logger.warning("sentrux: skipping unreadable %s: %s",
|
|
6058
|
+
path.name, exc)
|
|
6059
|
+
continue
|
|
6060
|
+
except json.JSONDecodeError as exc:
|
|
6061
|
+
logger.warning("sentrux: skipping corrupt JSON %s: %s",
|
|
6062
|
+
path.name, exc)
|
|
6063
|
+
continue
|
|
6064
|
+
if not isinstance(data, dict):
|
|
6065
|
+
logger.warning("sentrux: skipping non-object payload in %s",
|
|
6066
|
+
path.name)
|
|
6067
|
+
continue
|
|
6068
|
+
try:
|
|
6069
|
+
iteration = int(data.get("iteration"))
|
|
6070
|
+
before = int(data.get("before"))
|
|
6071
|
+
after = int(data.get("after"))
|
|
6072
|
+
except (TypeError, ValueError) as exc:
|
|
6073
|
+
logger.warning("sentrux: skipping %s, bad ints: %s",
|
|
6074
|
+
path.name, exc)
|
|
6075
|
+
continue
|
|
6076
|
+
verdict = data.get("verdict")
|
|
6077
|
+
if verdict not in ("DEGRADED", "OK", "UNKNOWN"):
|
|
6078
|
+
verdict = "UNKNOWN"
|
|
6079
|
+
timestamp = data.get("timestamp")
|
|
6080
|
+
if not isinstance(timestamp, str):
|
|
6081
|
+
timestamp = ""
|
|
6082
|
+
series.append({
|
|
6083
|
+
"iteration": iteration,
|
|
6084
|
+
"before": before,
|
|
6085
|
+
"after": after,
|
|
6086
|
+
"verdict": verdict,
|
|
6087
|
+
"timestamp": timestamp,
|
|
6088
|
+
})
|
|
6089
|
+
series.sort(key=lambda e: e["iteration"])
|
|
6090
|
+
current = series[-1]["after"] if series else None
|
|
6091
|
+
return {"series": series, "current": current, "samples": len(series)}
|
|
6092
|
+
|
|
6093
|
+
|
|
5958
6094
|
@app.get("/api/learnings")
|
|
5959
6095
|
async def get_learnings(limit: int = 50):
|
|
5960
6096
|
"""Read recent learnings (newest first)."""
|