loki-mode 7.5.15 → 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.15
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.15 | [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.15
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
  }
@@ -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.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
  # =============================================================================