loki-mode 7.5.15 → 7.5.17

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.15
6
+ # Loki Mode v7.5.17
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.15 | [Autonomi](https://www.autonomi.dev/) flagship product | ~260 lines core**
384
+ **v7.5.17 | [Autonomi](https://www.autonomi.dev/) flagship product | ~260 lines core**
package/VERSION CHANGED
@@ -1 +1 @@
1
- 7.5.15
1
+ 7.5.17
@@ -470,12 +470,174 @@ 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" "$effective_threshold"
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
+ # $5 - effective_threshold: votes needed for approval (0 = unknown/sentinel)
504
+ #
505
+ # Output: .loki/council/transcripts/iter-<N>-<TIMESTAMP>.json
506
+ #===============================================================================
507
+
508
+ council_write_transcript() {
509
+ local iteration="${1:-${ITERATION_COUNT:-0}}"
510
+ local outcome="${2:-REJECTED}"
511
+ local contrarian_triggered="${3:-false}"
512
+ local contrarian_flipped="${4:-false}"
513
+ local effective_threshold="${5:-0}"
514
+ local timestamp
515
+ timestamp=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
516
+ # Remove colons and hyphens from timestamp for filename safety
517
+ local ts_safe="${timestamp//[:\-]/}"
518
+ local iteration_id="iter-${iteration}-${ts_safe}"
519
+ local transcript_dir="$COUNCIL_STATE_DIR/transcripts"
520
+ mkdir -p "$transcript_dir"
521
+ local transcript_file="$transcript_dir/${iteration_id}.json"
522
+
523
+ # Read prd preview from state or prd file
524
+ local task_or_prd=""
525
+ if [ -n "$COUNCIL_PRD_PATH" ] && [ -f "$COUNCIL_PRD_PATH" ]; then
526
+ task_or_prd=$(head -5 "$COUNCIL_PRD_PATH" | tr '\n' ' ' | cut -c1-200)
527
+ fi
528
+
529
+ local round_file="$COUNCIL_STATE_DIR/votes/round-${iteration}.json"
530
+ local da_file="$COUNCIL_STATE_DIR/votes/devils-advocate-round-${iteration}.json"
531
+
532
+ _IT="$iteration" _TS="$timestamp" _IID="$iteration_id" \
533
+ _OUTCOME="$outcome" _CT="$contrarian_triggered" _CF="$contrarian_flipped" \
534
+ _TASK="$task_or_prd" _PRD="${COUNCIL_PRD_PATH:-}" \
535
+ _ROUND_FILE="${round_file}" _DA_FILE="${da_file}" \
536
+ _MEMBERS_DIR="$COUNCIL_STATE_DIR/votes/iteration-${iteration}" \
537
+ _THRESHOLD="$effective_threshold" \
538
+ _OUT="$transcript_file" \
539
+ python3 -c "
540
+ import json, os, pathlib, re
541
+
542
+ iteration_id = os.environ['_IID']
543
+ voters = []
544
+
545
+ # Priority 1: structured round file (Path B -- council_aggregate_votes)
546
+ rfile = pathlib.Path(os.environ['_ROUND_FILE'])
547
+ if rfile.exists():
548
+ try:
549
+ rd = json.loads(rfile.read_text())
550
+ for v in rd.get('votes', []):
551
+ voters.append({
552
+ 'name': v.get('role', 'unknown'),
553
+ 'role_index': v.get('member', 0),
554
+ 'verdict': 'APPROVE' if v.get('vote') == 'COMPLETE' else 'REJECT',
555
+ 'reasoning': v.get('reason', ''),
556
+ 'issues': [],
557
+ 'is_contrarian': False,
558
+ })
559
+ except Exception:
560
+ pass
561
+
562
+ # Priority 2: member txt files (Path A -- council_vote)
563
+ if not voters:
564
+ mdir = pathlib.Path(os.environ['_MEMBERS_DIR'])
565
+ roles = ['requirements_verifier', 'test_auditor', 'devils_advocate']
566
+ if mdir.exists():
567
+ for mf in sorted(mdir.glob('member-*.txt')):
568
+ content = mf.read_text(errors='replace').strip()
569
+ vote_match = re.search(r'VOTE\s*:\s*(APPROVE|REJECT|CANNOT_VALIDATE)', content)
570
+ reason_match = re.search(r'REASON\s*:\s*(.+?)(?:\n|\$)', content)
571
+ issues = []
572
+ for im in re.finditer(r'ISSUES\s*:\s*(CRITICAL|HIGH|MEDIUM|LOW)\s*:\s*(.+?)(?:\n|\$)', content):
573
+ issues.append({'severity': im.group(1), 'description': im.group(2).strip()})
574
+ idx = int(re.sub(r'\D', '', mf.stem) or '0') - 1
575
+ role = roles[idx % len(roles)] if idx >= 0 else 'unknown'
576
+ voters.append({
577
+ 'name': role,
578
+ 'role_index': idx + 1,
579
+ 'verdict': vote_match.group(1) if vote_match else 'REJECT',
580
+ 'reasoning': reason_match.group(1).strip() if reason_match else '',
581
+ 'issues': issues,
582
+ 'is_contrarian': False,
583
+ })
584
+
585
+ # Add DA voter if triggered
586
+ ct = os.environ['_CT'] == 'true'
587
+ cf = os.environ['_CF'] == 'true'
588
+ if ct:
589
+ da_challenges = []
590
+ dafile = pathlib.Path(os.environ['_DA_FILE'])
591
+ if dafile.exists():
592
+ try:
593
+ da = json.loads(dafile.read_text())
594
+ details = da.get('details', '')
595
+ if details and details != 'none':
596
+ da_challenges = [d.strip() for d in details.split(';') if d.strip()]
597
+ except Exception:
598
+ pass
599
+ # Also check contrarian.txt for reasoning
600
+ cfile = pathlib.Path(os.environ['_MEMBERS_DIR']) / 'contrarian.txt'
601
+ da_reasoning = ''
602
+ da_verdict = 'REJECT' if cf else 'APPROVE'
603
+ if cfile.exists():
604
+ content = cfile.read_text(errors='replace').strip()
605
+ reason_match = re.search(r'REASON\s*:\s*(.+?)(?:\n|\$)', content)
606
+ if reason_match:
607
+ da_reasoning = reason_match.group(1).strip()
608
+ voters.append({
609
+ 'name': 'devils_advocate',
610
+ 'role_index': len(voters) + 1,
611
+ 'verdict': da_verdict,
612
+ 'reasoning': da_reasoning,
613
+ 'issues': [],
614
+ 'challenges': da_challenges,
615
+ 'is_contrarian': True,
616
+ 'triggered': True,
617
+ })
618
+
619
+ task_or_prd = os.environ.get('_TASK', '')[:200]
620
+ non_contrarian = [v for v in voters if not v.get('is_contrarian')]
621
+ transcript = {
622
+ 'iteration_id': iteration_id,
623
+ 'iteration': int(os.environ['_IT']),
624
+ 'timestamp': os.environ['_TS'],
625
+ 'task_or_prd': task_or_prd,
626
+ 'prd_path': os.environ.get('_PRD', ''),
627
+ 'voters': voters,
628
+ 'outcome': os.environ['_OUTCOME'],
629
+ 'contrarian_triggered': ct,
630
+ 'contrarian_flipped': cf,
631
+ 'approve_count': sum(1 for v in non_contrarian if v.get('verdict') == 'APPROVE'),
632
+ 'reject_count': sum(1 for v in non_contrarian if v.get('verdict') in ('REJECT', 'CANNOT_VALIDATE')),
633
+ 'threshold': int(os.environ.get('_THRESHOLD', '0')),
634
+ 'total_members': len(non_contrarian),
635
+ }
636
+ with open(os.environ['_OUT'], 'w') as f:
637
+ json.dump(transcript, f, indent=2)
638
+ " || log_warn "Failed to write council transcript"
639
+ }
640
+
479
641
  #===============================================================================
480
642
  # Evidence Gathering - Collect data for council review
481
643
  #===============================================================================
@@ -1353,6 +1515,9 @@ council_evaluate() {
1353
1515
  return 1 # CONTINUE - can't complete with critical failures
1354
1516
  fi
1355
1517
 
1518
+ # Compute threshold using the same ceiling(2/3) formula as council_vote and council_aggregate_votes
1519
+ local _eval_threshold=$(( (COUNCIL_SIZE * 2 + 2) / 3 ))
1520
+
1356
1521
  # Step 1: Aggregate votes from all members
1357
1522
  local aggregate_result
1358
1523
  aggregate_result=$(council_aggregate_votes)
@@ -1372,14 +1537,23 @@ council_evaluate() {
1372
1537
  da_result=$(council_devils_advocate_review "$ITERATION_COUNT")
1373
1538
  if [ "$da_result" = "OVERRIDE_CONTINUE" ]; then
1374
1539
  log_warn "Council evaluate: devil's advocate overrode unanimous COMPLETE"
1540
+ # Write transcript: DA triggered and flipped the outcome (Path B)
1541
+ council_write_transcript "${ITERATION_COUNT:-0}" "REJECTED" "true" "true" "$_eval_threshold"
1375
1542
  return 1 # CONTINUE
1376
1543
  fi
1544
+ # Write transcript: DA triggered but did NOT flip (Path B, unanimous COMPLETE confirmed)
1545
+ council_write_transcript "${ITERATION_COUNT:-0}" "APPROVED" "true" "false" "$_eval_threshold"
1546
+ else
1547
+ # Write transcript: not unanimous, DA not triggered (Path B)
1548
+ council_write_transcript "${ITERATION_COUNT:-0}" "APPROVED" "false" "false" "$_eval_threshold"
1377
1549
  fi
1378
1550
 
1379
1551
  log_info "Council evaluate: verdict is COMPLETE"
1380
1552
  return 0 # COMPLETE (should stop)
1381
1553
  fi
1382
1554
 
1555
+ # Write transcript: aggregate voted CONTINUE (Path B)
1556
+ council_write_transcript "${ITERATION_COUNT:-0}" "REJECTED" "false" "false" "$_eval_threshold"
1383
1557
  log_info "Council evaluate: verdict is CONTINUE"
1384
1558
  return 1 # CONTINUE
1385
1559
  }
package/autonomy/loki CHANGED
@@ -7052,9 +7052,10 @@ cmd_sentrux() {
7052
7052
  case "$sub" in
7053
7053
  baseline)
7054
7054
  if ! sentrux_available; then
7055
- echo -e "${YELLOW}sentrux not installed.${NC} Install via:" >&2
7055
+ echo -e "${YELLOW}sentrux not installed.${NC} Install with one of:" >&2
7056
7056
  echo " brew install sentrux/tap/sentrux" >&2
7057
- echo " or download from https://github.com/sentrux/sentrux/releases" >&2
7057
+ echo " curl -fsSL https://raw.githubusercontent.com/sentrux/sentrux/main/install.sh | sh" >&2
7058
+ echo " See also: https://github.com/sentrux/sentrux" >&2
7058
7059
  return 2
7059
7060
  fi
7060
7061
  if sentrux_baseline_save "$target"; then
@@ -7068,7 +7069,10 @@ cmd_sentrux() {
7068
7069
  ;;
7069
7070
  gate)
7070
7071
  if ! sentrux_available; then
7071
- echo -e "${YELLOW}sentrux not installed.${NC} Run 'loki sentrux baseline' for setup hints." >&2
7072
+ echo -e "${YELLOW}sentrux not installed.${NC} Install with one of:" >&2
7073
+ echo " brew install sentrux/tap/sentrux" >&2
7074
+ echo " curl -fsSL https://raw.githubusercontent.com/sentrux/sentrux/main/install.sh | sh" >&2
7075
+ echo " See also: https://github.com/sentrux/sentrux" >&2
7072
7076
  return 2
7073
7077
  fi
7074
7078
  local diff verdict before after
@@ -7097,7 +7101,10 @@ cmd_sentrux() {
7097
7101
  status)
7098
7102
  if ! sentrux_available; then
7099
7103
  echo "sentrux: not installed (optional)"
7100
- echo "install: brew install sentrux/tap/sentrux"
7104
+ echo "install with one of:"
7105
+ echo " brew install sentrux/tap/sentrux"
7106
+ echo " curl -fsSL https://raw.githubusercontent.com/sentrux/sentrux/main/install.sh | sh"
7107
+ echo " See also: https://github.com/sentrux/sentrux"
7101
7108
  return 0
7102
7109
  fi
7103
7110
  local v
@@ -7,7 +7,7 @@ Modules:
7
7
  control: Session control API (start/stop/pause/resume)
8
8
  """
9
9
 
10
- __version__ = "7.5.15"
10
+ __version__ = "7.5.17"
11
11
 
12
12
  # Expose the control app for easy import
13
13
  try:
@@ -3782,6 +3782,93 @@ 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, ge=0),
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
+ # Validate query params before any early-return so invalid inputs always get 400.
3799
+ since_dt = None
3800
+ if since:
3801
+ try:
3802
+ since_dt = datetime.fromisoformat(since.replace("Z", "+00:00"))
3803
+ except ValueError:
3804
+ raise HTTPException(status_code=400, detail="Invalid 'since' timestamp format; expected ISO8601")
3805
+
3806
+ transcripts_dir = _get_loki_dir() / "council" / "transcripts"
3807
+ if not transcripts_dir.exists():
3808
+ return {"transcripts": [], "total": 0, "latest_id": None}
3809
+
3810
+ records = []
3811
+ for f in sorted(transcripts_dir.glob("iter-*.json"), reverse=True):
3812
+ try:
3813
+ rec = json.loads(f.read_text())
3814
+ except Exception:
3815
+ logger.warning("Skipping corrupt council transcript file: %s", f.name)
3816
+ continue
3817
+ if not isinstance(rec, dict):
3818
+ logger.warning("Skipping non-object council transcript file: %s", f.name)
3819
+ continue
3820
+ if not isinstance(rec.get("iteration_id"), str):
3821
+ logger.warning("Skipping transcript missing iteration_id field: %s", f.name)
3822
+ continue
3823
+ if since_dt is not None:
3824
+ ts_str = rec.get("timestamp", "")
3825
+ try:
3826
+ ts = datetime.fromisoformat(ts_str.replace("Z", "+00:00"))
3827
+ except (ValueError, AttributeError):
3828
+ continue
3829
+ if ts <= since_dt:
3830
+ continue
3831
+ if iter_min is not None and rec.get("iteration", 0) < iter_min:
3832
+ continue
3833
+ records.append(rec)
3834
+ if len(records) >= limit:
3835
+ break
3836
+
3837
+ return {
3838
+ "transcripts": records,
3839
+ "total": len(records),
3840
+ "latest_id": records[0].get("iteration_id") if records else None,
3841
+ }
3842
+
3843
+
3844
+ @app.get("/api/council/transcripts/{iteration_id}")
3845
+ async def get_council_transcript(iteration_id: str):
3846
+ """Fetch a single council transcript by iteration_id.
3847
+
3848
+ Returns the record body or 404 if not found.
3849
+ Path traversal attempts (containing '/' or '..') are rejected with 404.
3850
+ """
3851
+ # Reject path traversal: iteration_id must be a plain filename component.
3852
+ if "/" in iteration_id or "\\" in iteration_id or ".." in iteration_id:
3853
+ raise HTTPException(status_code=404, detail="Transcript not found")
3854
+ transcript_file = _get_loki_dir() / "council" / "transcripts" / f"{iteration_id}.json"
3855
+ if not transcript_file.exists():
3856
+ raise HTTPException(status_code=404, detail="Transcript not found")
3857
+ try:
3858
+ rec = json.loads(transcript_file.read_text())
3859
+ except Exception:
3860
+ raise HTTPException(
3861
+ status_code=410,
3862
+ detail=f"Transcript file for {iteration_id} is corrupt; admin should inspect or remove it",
3863
+ )
3864
+ if not isinstance(rec, dict):
3865
+ raise HTTPException(
3866
+ status_code=410,
3867
+ detail=f"Transcript file for {iteration_id} is corrupt; admin should inspect or remove it",
3868
+ )
3869
+ return rec
3870
+
3871
+
3785
3872
  # =============================================================================
3786
3873
  # Context Window Tracking API (v5.40.0)
3787
3874
  # =============================================================================