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 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.14
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.14 | [Autonomi](https://www.autonomi.dev/) flagship product | ~260 lines core**
384
+ **v7.5.16 | [Autonomi](https://www.autonomi.dev/) flagship product | ~260 lines core**
package/VERSION CHANGED
@@ -1 +1 @@
1
- 7.5.14
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
- local target="${1:-.}"
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 [<path>] Save current architecture as baseline"
7083
- echo " loki sentrux gate [<path>] Compare current vs baseline (exit 1 on DEGRADED)"
7084
- echo " loki sentrux status [<path>] Show binary version + saved baseline quality"
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
- output=$(cd "${TARGET_DIR:-.}" && pytest --tb=short 2>&1) || test_passed=false
6042
- details="pytest: $(echo "$output" | tail -5 | tr '\n' ' ')"
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" \
@@ -7,7 +7,7 @@ Modules:
7
7
  control: Session control API (start/stop/pause/resume)
8
8
  """
9
9
 
10
- __version__ = "7.5.14"
10
+ __version__ = "7.5.16"
11
11
 
12
12
  # Expose the control app for easy import
13
13
  try:
@@ -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)."""