loki-mode 6.16.1 → 6.17.1

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 PRD to deployed product with minimal human intervention. Requires --dangerously-skip-permissions flag.
4
4
  ---
5
5
 
6
- # Loki Mode v6.16.1
6
+ # Loki Mode v6.17.1
7
7
 
8
8
  **You are an autonomous agent. You make decisions. You do not ask questions. You do not stop.**
9
9
 
@@ -267,4 +267,4 @@ The following features are documented in skill modules but not yet fully automat
267
267
  | Quality gates 3-reviewer system | Implemented (v5.35.0) | 5 specialist reviewers in `skills/quality-gates.md`; execution in run.sh |
268
268
  | Benchmarks (HumanEval, SWE-bench) | Infrastructure only | Runner scripts and datasets exist in `benchmarks/`; no published results |
269
269
 
270
- **v6.16.1 | [Autonomi](https://www.autonomi.dev/) flagship product | ~260 lines core**
270
+ **v6.17.1 | [Autonomi](https://www.autonomi.dev/) flagship product | ~260 lines core**
package/VERSION CHANGED
@@ -1 +1 @@
1
- 6.16.1
1
+ 6.17.1
package/autonomy/loki CHANGED
@@ -433,6 +433,7 @@ show_help() {
433
433
  echo " worktree [cmd] Parallel worktree management (list|merge|clean|status)"
434
434
  echo " agent [cmd] Agent type dispatch (list|info|run|start|review)"
435
435
  echo " remote [PRD] Start remote session (connect from phone/browser, Claude Pro/Max)"
436
+ echo " trigger Event-driven autonomous execution (schedules, webhooks)"
436
437
  echo " version Show version"
437
438
  echo " help Show this help"
438
439
  echo ""
@@ -7725,6 +7726,290 @@ print('Template validated. Lifecycle hooks active.')
7725
7726
  esac
7726
7727
  }
7727
7728
 
7729
+ # Event-driven trigger management (v6.17.0)
7730
+ cmd_trigger() {
7731
+ local subcmd="${1:-help}"
7732
+ shift || true
7733
+
7734
+ local trigger_server_script
7735
+ trigger_server_script="$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")/trigger-server.py"
7736
+ local trigger_schedule_script
7737
+ trigger_schedule_script="$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")/trigger-schedule.py"
7738
+ local pid_file=".loki/triggers/server.pid"
7739
+
7740
+ case "$subcmd" in
7741
+ start)
7742
+ local port=""
7743
+ local secret=""
7744
+ local dry_run_flag=""
7745
+ while [[ $# -gt 0 ]]; do
7746
+ case "$1" in
7747
+ --port) port="$2"; shift 2 ;;
7748
+ --port=*) port="${1#--port=}"; shift ;;
7749
+ --secret) secret="$2"; shift 2 ;;
7750
+ --secret=*) secret="${1#--secret=}"; shift ;;
7751
+ --dry-run) dry_run_flag="--dry-run"; shift ;;
7752
+ *) shift ;;
7753
+ esac
7754
+ done
7755
+ if [[ -f "$pid_file" ]]; then
7756
+ local existing_pid
7757
+ existing_pid=$(cat "$pid_file" 2>/dev/null)
7758
+ if kill -0 "$existing_pid" 2>/dev/null; then
7759
+ echo "Trigger server already running (pid=$existing_pid)"
7760
+ return 0
7761
+ fi
7762
+ fi
7763
+ local args=()
7764
+ [[ -n "$port" ]] && args+=("--port" "$port")
7765
+ [[ -n "$secret" ]] && args+=("--secret" "$secret")
7766
+ [[ -n "$dry_run_flag" ]] && args+=("--dry-run")
7767
+ mkdir -p .loki/triggers
7768
+ nohup python3 "$trigger_server_script" "${args[@]}" \
7769
+ > .loki/triggers/server.log 2>&1 &
7770
+ local server_pid=$!
7771
+ echo "$server_pid" > "$pid_file"
7772
+ sleep 0.5
7773
+ if kill -0 "$server_pid" 2>/dev/null; then
7774
+ echo "Trigger server started (pid=$server_pid)"
7775
+ local p="${port:-7373}"
7776
+ echo "Webhook endpoint: POST http://localhost:$p/webhook"
7777
+ else
7778
+ echo -e "${RED}Trigger server failed to start. Check .loki/triggers/server.log${NC}"
7779
+ return 1
7780
+ fi
7781
+ ;;
7782
+ stop)
7783
+ if [[ ! -f "$pid_file" ]]; then
7784
+ echo "Trigger server not running (no pid file)"
7785
+ return 0
7786
+ fi
7787
+ local pid
7788
+ pid=$(cat "$pid_file" 2>/dev/null)
7789
+ if [[ -z "$pid" ]]; then
7790
+ echo "Trigger server not running"
7791
+ rm -f "$pid_file"
7792
+ return 0
7793
+ fi
7794
+ if kill "$pid" 2>/dev/null; then
7795
+ echo "Trigger server stopped (pid=$pid)"
7796
+ rm -f "$pid_file"
7797
+ else
7798
+ echo "Trigger server was not running (pid=$pid)"
7799
+ rm -f "$pid_file"
7800
+ fi
7801
+ ;;
7802
+ status)
7803
+ if [[ -f "$pid_file" ]]; then
7804
+ local pid
7805
+ pid=$(cat "$pid_file" 2>/dev/null)
7806
+ if kill -0 "$pid" 2>/dev/null; then
7807
+ echo "Trigger server: running (pid=$pid)"
7808
+ if command -v curl >/dev/null 2>&1; then
7809
+ local config_port
7810
+ config_port=$(python3 -c "
7811
+ import json, pathlib
7812
+ p = pathlib.Path('.loki/triggers/config.json')
7813
+ print(json.loads(p.read_text()).get('port', 7373) if p.exists() else 7373)
7814
+ " 2>/dev/null || echo "7373")
7815
+ curl -sf "http://localhost:$config_port/status" 2>/dev/null | \
7816
+ python3 -m json.tool 2>/dev/null || true
7817
+ fi
7818
+ else
7819
+ echo "Trigger server: stopped (stale pid=$pid)"
7820
+ rm -f "$pid_file"
7821
+ fi
7822
+ else
7823
+ echo "Trigger server: not running"
7824
+ fi
7825
+ local sched_file=".loki/triggers/schedules.json"
7826
+ if [[ -f "$sched_file" ]]; then
7827
+ local count
7828
+ count=$(python3 -c "import json; d=json.load(open('$sched_file')); print(len(d) if isinstance(d,list) else len(d.get('schedules',[])))" 2>/dev/null || echo "?")
7829
+ echo "Schedules configured: $count"
7830
+ else
7831
+ echo "Schedules configured: 0"
7832
+ fi
7833
+ ;;
7834
+ list)
7835
+ python3 "$trigger_schedule_script" list
7836
+ ;;
7837
+ add)
7838
+ local add_type="${1:-}"
7839
+ shift || true
7840
+ if [[ "$add_type" == "schedule" ]]; then
7841
+ local name="${1:-}"
7842
+ local cron_expr="${2:-}"
7843
+ local action="${3:-}"
7844
+ shift 3 || true
7845
+ if [[ -z "$name" || -z "$cron_expr" || -z "$action" ]]; then
7846
+ echo "Usage: loki trigger add schedule <name> <cron_expr> <action> [args...]"
7847
+ echo ""
7848
+ echo "Example:"
7849
+ echo " loki trigger add schedule daily-review '0 9 * * 1-5' quality-review"
7850
+ echo " loki trigger add schedule auto-issue '*/30 * * * *' run 42"
7851
+ return 1
7852
+ fi
7853
+ python3 "$trigger_schedule_script" add "$name" "$cron_expr" "$action" "$@"
7854
+ else
7855
+ echo "Usage: loki trigger add schedule <name> <cron_expr> <action> [args...]"
7856
+ return 1
7857
+ fi
7858
+ ;;
7859
+ remove)
7860
+ local name="${1:-}"
7861
+ if [[ -z "$name" ]]; then
7862
+ echo "Usage: loki trigger remove <name>"
7863
+ return 1
7864
+ fi
7865
+ python3 "$trigger_schedule_script" remove "$name"
7866
+ ;;
7867
+ daemon)
7868
+ local dry_run_flag=""
7869
+ [[ "${1:-}" == "--dry-run" ]] && dry_run_flag="--dry-run"
7870
+ python3 "$trigger_schedule_script" daemon $dry_run_flag
7871
+ ;;
7872
+ test)
7873
+ local event_type="${1:-issues}"
7874
+ shift || true
7875
+ local repo=""
7876
+ local issue_num=""
7877
+ while [[ $# -gt 0 ]]; do
7878
+ case "$1" in
7879
+ --repo) repo="$2"; shift 2 ;;
7880
+ --repo=*) repo="${1#--repo=}"; shift ;;
7881
+ --issue) issue_num="$2"; shift 2 ;;
7882
+ --issue=*) issue_num="${1#--issue=}"; shift ;;
7883
+ *) shift ;;
7884
+ esac
7885
+ done
7886
+ local config_port
7887
+ config_port=$(python3 -c "
7888
+ import json, pathlib
7889
+ p = pathlib.Path('.loki/triggers/config.json')
7890
+ print(json.loads(p.read_text()).get('port', 7373) if p.exists() else 7373)
7891
+ " 2>/dev/null || echo "7373")
7892
+
7893
+ local payload
7894
+ case "$event_type" in
7895
+ issues)
7896
+ local num="${issue_num:-1}"
7897
+ local r="${repo:-owner/repo}"
7898
+ payload="{\"action\":\"opened\",\"issue\":{\"number\":$num,\"title\":\"Test issue\"},\"repository\":{\"full_name\":\"$r\"}}"
7899
+ ;;
7900
+ pull_request)
7901
+ local num="${issue_num:-1}"
7902
+ local r="${repo:-owner/repo}"
7903
+ payload="{\"action\":\"synchronize\",\"pull_request\":{\"number\":$num,\"title\":\"Test PR\"},\"repository\":{\"full_name\":\"$r\"}}"
7904
+ ;;
7905
+ workflow_run)
7906
+ local r="${repo:-owner/repo}"
7907
+ payload="{\"action\":\"completed\",\"workflow_run\":{\"name\":\"CI\",\"conclusion\":\"failure\"},\"repository\":{\"full_name\":\"$r\"}}"
7908
+ ;;
7909
+ *)
7910
+ echo "Supported event types: issues, pull_request, workflow_run"
7911
+ return 1
7912
+ ;;
7913
+ esac
7914
+
7915
+ if command -v curl >/dev/null 2>&1; then
7916
+ echo "Sending test $event_type event to http://localhost:$config_port/webhook"
7917
+ curl -sf -X POST \
7918
+ -H "Content-Type: application/json" \
7919
+ -H "X-GitHub-Event: $event_type" \
7920
+ -d "$payload" \
7921
+ "http://localhost:$config_port/webhook" | python3 -m json.tool 2>/dev/null || \
7922
+ echo "Server not running or request failed. Start with: loki trigger start"
7923
+ else
7924
+ echo "curl not available. Cannot send test event."
7925
+ return 1
7926
+ fi
7927
+ ;;
7928
+ logs)
7929
+ local tail_n="50"
7930
+ while [[ $# -gt 0 ]]; do
7931
+ case "$1" in
7932
+ --tail) tail_n="$2"; shift 2 ;;
7933
+ --tail=*) tail_n="${1#--tail=}"; shift ;;
7934
+ *) shift ;;
7935
+ esac
7936
+ done
7937
+ local log_file=".loki/triggers/events.log"
7938
+ if [[ -f "$log_file" ]]; then
7939
+ tail -n "$tail_n" "$log_file" | python3 -c "
7940
+ import sys, json
7941
+ for line in sys.stdin:
7942
+ line = line.strip()
7943
+ if not line:
7944
+ continue
7945
+ try:
7946
+ e = json.loads(line)
7947
+ print('[%s] %-15s %-12s %-8s %s' % (
7948
+ e.get('timestamp','?'),
7949
+ e.get('event','?'),
7950
+ e.get('action','?'),
7951
+ e.get('status','?'),
7952
+ e.get('summary',''),
7953
+ ))
7954
+ except Exception:
7955
+ print(line)
7956
+ " 2>/dev/null || cat "$log_file" | tail -n "$tail_n"
7957
+ else
7958
+ echo "No events logged yet. (.loki/triggers/events.log does not exist)"
7959
+ fi
7960
+ local server_log=".loki/triggers/server.log"
7961
+ if [[ -f "$server_log" ]]; then
7962
+ echo ""
7963
+ echo "--- Server log (last $tail_n lines) ---"
7964
+ tail -n "$tail_n" "$server_log"
7965
+ fi
7966
+ ;;
7967
+ help|--help|-h)
7968
+ echo -e "${BOLD}loki trigger${NC} - Event-driven autonomous execution"
7969
+ echo ""
7970
+ echo "Usage: loki trigger <subcommand> [options]"
7971
+ echo ""
7972
+ echo "Subcommands:"
7973
+ echo " start [--port PORT] [--secret SECRET] [--dry-run]"
7974
+ echo " Start the webhook receiver server (default port: 7373)"
7975
+ echo " stop Stop the webhook receiver server"
7976
+ echo " status Show server and schedule status"
7977
+ echo " list List configured schedules"
7978
+ echo " add schedule <name> <cron_expr> <action> [args...]"
7979
+ echo " Add a cron-based schedule trigger"
7980
+ echo " remove <name> Remove a schedule trigger"
7981
+ echo " daemon [--dry-run] Run one pass of schedule checks (call from cron)"
7982
+ echo " test <event-type> [--repo owner/repo] [--issue NUM]"
7983
+ echo " Send a test webhook event (event-types: issues, pull_request, workflow_run)"
7984
+ echo " logs [--tail N] Show recent trigger event log (default: last 50)"
7985
+ echo " help Show this help"
7986
+ echo ""
7987
+ echo "Webhook events handled:"
7988
+ echo " issues (opened) -> loki run <issue_number> --pr --detach"
7989
+ echo " pull_request (sync) -> loki run <pr_number> --detach"
7990
+ echo " workflow_run (failure) -> loki run --detach"
7991
+ echo ""
7992
+ echo "Schedule cron format: minute hour day-of-month month day-of-week"
7993
+ echo " Examples:"
7994
+ echo " '0 9 * * 1-5' - 9am Mon-Fri"
7995
+ echo " '*/30 * * * *' - Every 30 minutes"
7996
+ echo " '0 0 * * 0' - Midnight Sunday"
7997
+ echo ""
7998
+ echo "Examples:"
7999
+ echo " loki trigger start --port 7373 --secret mywebhooksecret"
8000
+ echo " loki trigger add schedule daily-review '0 9 * * 1-5' quality-review"
8001
+ echo " loki trigger test issues --repo owner/repo --issue 42"
8002
+ echo " loki trigger logs --tail 20"
8003
+ echo " loki trigger stop"
8004
+ ;;
8005
+ *)
8006
+ echo -e "${RED}Unknown trigger command: $subcmd${NC}"
8007
+ echo "Run 'loki trigger help' for usage."
8008
+ return 1
8009
+ ;;
8010
+ esac
8011
+ }
8012
+
7728
8013
  # Main command dispatcher
7729
8014
  main() {
7730
8015
  if [ $# -eq 0 ]; then
@@ -7882,6 +8167,9 @@ main() {
7882
8167
  remote|rc)
7883
8168
  cmd_remote "$@"
7884
8169
  ;;
8170
+ trigger)
8171
+ cmd_trigger "$@"
8172
+ ;;
7885
8173
  version|--version|-v)
7886
8174
  cmd_version
7887
8175
  ;;
package/autonomy/run.sh CHANGED
@@ -8617,6 +8617,8 @@ if __name__ == "__main__":
8617
8617
  fi
8618
8618
  fi
8619
8619
 
8620
+ log_step "Post-iteration: running inter-iteration checks..."
8621
+
8620
8622
  # App Runner: restart on code changes (v5.45.0)
8621
8623
  if [ "${APP_RUNNER_INITIALIZED:-}" = "true" ] && type app_runner_should_restart &>/dev/null; then
8622
8624
  if app_runner_should_restart; then
@@ -8661,10 +8663,12 @@ if __name__ == "__main__":
8661
8663
  create_checkpoint "iteration-${ITERATION_COUNT} complete" "iteration-${ITERATION_COUNT}"
8662
8664
 
8663
8665
  # Quality gates (v6.10.0 - escalation ladder)
8666
+ log_step "Post-iteration: running quality gates..."
8664
8667
  local gate_failures=""
8665
8668
  if [ "${LOKI_HARD_GATES:-true}" = "true" ]; then
8666
8669
  # Static analysis gate
8667
8670
  if [ "${PHASE_STATIC_ANALYSIS:-true}" = "true" ]; then
8671
+ log_info "Quality gate: static analysis..."
8668
8672
  if enforce_static_analysis; then
8669
8673
  clear_gate_failure "static_analysis"
8670
8674
  else
@@ -8676,6 +8680,7 @@ if __name__ == "__main__":
8676
8680
  fi
8677
8681
  # Test coverage gate
8678
8682
  if [ "${PHASE_UNIT_TESTS:-true}" = "true" ]; then
8683
+ log_info "Quality gate: test coverage..."
8679
8684
  if enforce_test_coverage; then
8680
8685
  clear_gate_failure "test_coverage"
8681
8686
  else
@@ -8687,6 +8692,7 @@ if __name__ == "__main__":
8687
8692
  fi
8688
8693
  # Code review gate (upgraded from advisory, with escalation)
8689
8694
  if [ "$PHASE_CODE_REVIEW" = "true" ] && [ "$ITERATION_COUNT" -gt 0 ]; then
8695
+ log_info "Quality gate: code review..."
8690
8696
  if run_code_review; then
8691
8697
  clear_gate_failure "code_review"
8692
8698
  else
@@ -8717,9 +8723,11 @@ if __name__ == "__main__":
8717
8723
  fi
8718
8724
  else
8719
8725
  if [ "$PHASE_CODE_REVIEW" = "true" ] && [ "$ITERATION_COUNT" -gt 0 ]; then
8726
+ log_info "Quality gate: code review (advisory)..."
8720
8727
  run_code_review || log_warn "Code review found issues - check .loki/quality/reviews/"
8721
8728
  fi
8722
8729
  fi
8730
+ log_info "Quality gates complete."
8723
8731
 
8724
8732
  # Automatic episode capture after every RARV iteration (v6.15.0)
8725
8733
  # Captures RARV phase, git changes, and iteration context automatically
@@ -8745,6 +8753,7 @@ if __name__ == "__main__":
8745
8753
 
8746
8754
  # Completion Council check (v5.25.0) - multi-agent voting on completion
8747
8755
  # Runs before completion promise check since council is more comprehensive
8756
+ log_step "Post-iteration: checking completion council..."
8748
8757
  if type council_should_stop &>/dev/null && council_should_stop; then
8749
8758
  echo ""
8750
8759
  log_header "COMPLETION COUNCIL: PROJECT COMPLETE"
@@ -8776,7 +8785,7 @@ if __name__ == "__main__":
8776
8785
  fi
8777
8786
 
8778
8787
  # SUCCESS exit - continue IMMEDIATELY to next iteration (no wait!)
8779
- log_info "Iteration complete. Continuing to next iteration..."
8788
+ log_step "Starting next iteration..."
8780
8789
  ((retry++))
8781
8790
  continue # Immediately start next iteration, no exponential backoff
8782
8791
  fi
@@ -0,0 +1,367 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ trigger-schedule.py - Schedule-based trigger daemon for loki-mode.
4
+
5
+ Reads .loki/triggers/schedules.json and runs loki commands on cron-like schedules.
6
+ Called periodically via `loki trigger daemon`.
7
+
8
+ Schedule entry format:
9
+ {
10
+ "name": "daily-quality-review",
11
+ "cron_expr": "0 9 * * 1-5",
12
+ "action": "quality-review",
13
+ "args": [],
14
+ "enabled": true,
15
+ "last_run": null
16
+ }
17
+
18
+ Supported actions:
19
+ run <issue-ref> - Run loki run on an issue/ref
20
+ status - Run loki status
21
+ quality-review - Run loki review
22
+
23
+ Usage:
24
+ python3 autonomy/trigger-schedule.py [--once] [--dry-run]
25
+ """
26
+
27
+ import argparse
28
+ import json
29
+ import logging
30
+ import os
31
+ import subprocess
32
+ import sys
33
+ from datetime import datetime, timezone
34
+ from pathlib import Path
35
+
36
+
37
+ def get_loki_dir():
38
+ """Return .loki/triggers directory, creating it if needed."""
39
+ loki_dir = Path(".loki") / "triggers"
40
+ loki_dir.mkdir(parents=True, exist_ok=True)
41
+ return loki_dir
42
+
43
+
44
+ def load_schedules():
45
+ """Load schedules from .loki/triggers/schedules.json."""
46
+ schedules_path = get_loki_dir() / "schedules.json"
47
+ if not schedules_path.exists():
48
+ return []
49
+ try:
50
+ with open(schedules_path) as f:
51
+ data = json.load(f)
52
+ if isinstance(data, list):
53
+ return data
54
+ return data.get("schedules", [])
55
+ except (json.JSONDecodeError, OSError) as e:
56
+ logging.error("Failed to load schedules: %s", e)
57
+ return []
58
+
59
+
60
+ def save_schedules(schedules):
61
+ """Save schedules back to .loki/triggers/schedules.json."""
62
+ schedules_path = get_loki_dir() / "schedules.json"
63
+ with open(schedules_path, "w") as f:
64
+ json.dump(schedules, f, indent=2)
65
+
66
+
67
+ def parse_cron_field(field, min_val, max_val):
68
+ """
69
+ Parse a single cron field.
70
+ Returns a set of matching integers.
71
+ Supports: * (any), N (exact), N-M (range), */N (step), N,M,... (list)
72
+ """
73
+ result = set()
74
+ if field == "*":
75
+ return set(range(min_val, max_val + 1))
76
+
77
+ for part in field.split(","):
78
+ part = part.strip()
79
+ if "/" in part:
80
+ range_part, step_str = part.split("/", 1)
81
+ step = int(step_str)
82
+ if range_part == "*":
83
+ start, end = min_val, max_val
84
+ elif "-" in range_part:
85
+ s, e = range_part.split("-", 1)
86
+ start, end = int(s), int(e)
87
+ else:
88
+ start = int(range_part)
89
+ end = max_val
90
+ result.update(range(start, end + 1, step))
91
+ elif "-" in part:
92
+ s, e = part.split("-", 1)
93
+ result.update(range(int(s), int(e) + 1))
94
+ else:
95
+ result.add(int(part))
96
+
97
+ return result
98
+
99
+
100
+ def cron_matches(cron_expr, dt):
101
+ """
102
+ Check if a datetime matches a cron expression.
103
+ Format: minute hour day-of-month month day-of-week
104
+ Day-of-week: 0=Sunday, 1=Monday, ..., 6=Saturday
105
+ """
106
+ parts = cron_expr.strip().split()
107
+ if len(parts) != 5:
108
+ raise ValueError("Invalid cron expression (expected 5 fields): %s" % cron_expr)
109
+
110
+ minute_field, hour_field, dom_field, month_field, dow_field = parts
111
+
112
+ minutes = parse_cron_field(minute_field, 0, 59)
113
+ hours = parse_cron_field(hour_field, 0, 23)
114
+ doms = parse_cron_field(dom_field, 1, 31)
115
+ months = parse_cron_field(month_field, 1, 12)
116
+ # Python weekday: 0=Monday, ..., 6=Sunday
117
+ # Cron weekday: 0=Sunday, 1=Monday, ..., 6=Saturday
118
+ # Convert python weekday to cron dow
119
+ python_dow = dt.weekday() # 0=Mon
120
+ cron_dow = (python_dow + 1) % 7 # 0=Sun, 1=Mon, ...
121
+ dows = parse_cron_field(dow_field, 0, 6)
122
+
123
+ return (
124
+ dt.minute in minutes
125
+ and dt.hour in hours
126
+ and dt.day in doms
127
+ and dt.month in months
128
+ and cron_dow in dows
129
+ )
130
+
131
+
132
+ def should_run(schedule, now):
133
+ """
134
+ Determine if a schedule should fire now.
135
+ Compares current minute-resolution timestamp to last_run.
136
+ """
137
+ if not schedule.get("enabled", True):
138
+ return False
139
+
140
+ try:
141
+ matches = cron_matches(schedule["cron_expr"], now)
142
+ except (ValueError, KeyError) as e:
143
+ logging.warning("Invalid cron for '%s': %s", schedule.get("name", "?"), e)
144
+ return False
145
+
146
+ if not matches:
147
+ return False
148
+
149
+ last_run = schedule.get("last_run")
150
+ if last_run:
151
+ try:
152
+ # Check if we already ran this minute
153
+ last_dt = datetime.fromisoformat(last_run.replace("Z", "+00:00"))
154
+ if last_dt.year == now.year and last_dt.month == now.month and \
155
+ last_dt.day == now.day and last_dt.hour == now.hour and \
156
+ last_dt.minute == now.minute:
157
+ return False
158
+ except (ValueError, AttributeError):
159
+ pass
160
+
161
+ return True
162
+
163
+
164
+ def build_loki_args(schedule):
165
+ """Build the loki CLI argument list from a schedule entry."""
166
+ action = schedule.get("action", "")
167
+ args = schedule.get("args", [])
168
+
169
+ if action == "status":
170
+ return ["status"]
171
+ elif action == "quality-review":
172
+ return ["review"] + list(args)
173
+ elif action.startswith("run"):
174
+ # action is "run" with args, or "run <issue-ref>"
175
+ extra = list(args)
176
+ if not extra and " " in action:
177
+ # action = "run 123"
178
+ parts = action.split(None, 1)
179
+ extra = [parts[1]]
180
+ return ["run"] + extra
181
+ else:
182
+ # Generic: split action and prepend
183
+ return action.split() + list(args)
184
+
185
+
186
+ def run_schedule(schedule, dry_run=False):
187
+ """Execute a scheduled loki command."""
188
+ loki_args = build_loki_args(schedule)
189
+ cmd = ["loki"] + loki_args
190
+ name = schedule.get("name", "unnamed")
191
+
192
+ if dry_run:
193
+ logging.info("[DRY-RUN] Schedule '%s' would run: %s", name, " ".join(cmd))
194
+ return True
195
+
196
+ logging.info("Schedule '%s' firing: %s", name, " ".join(cmd))
197
+ try:
198
+ proc = subprocess.Popen(
199
+ cmd,
200
+ stdout=subprocess.PIPE,
201
+ stderr=subprocess.PIPE,
202
+ )
203
+ logging.info("Schedule '%s' started pid=%d", name, proc.pid)
204
+ return True
205
+ except (FileNotFoundError, OSError) as e:
206
+ logging.error("Schedule '%s' failed: %s", name, e)
207
+ return False
208
+
209
+
210
+ def log_event(name, action_desc, status):
211
+ """Log schedule fire to events.log."""
212
+ log_path = get_loki_dir() / "events.log"
213
+ timestamp = datetime.utcnow().strftime("%Y-%m-%dT%H:%M:%SZ")
214
+ entry = {
215
+ "timestamp": timestamp,
216
+ "event": "schedule",
217
+ "action": action_desc,
218
+ "summary": name,
219
+ "status": status,
220
+ }
221
+ with open(log_path, "a") as f:
222
+ f.write(json.dumps(entry) + "\n")
223
+
224
+
225
+ def run_daemon(dry_run=False, once=False):
226
+ """
227
+ Check all schedules and fire those that match the current minute.
228
+ If once=True, run one pass and exit (used for cron job invocation).
229
+ """
230
+ now = datetime.now()
231
+ schedules = load_schedules()
232
+ changed = False
233
+
234
+ for i, schedule in enumerate(schedules):
235
+ if should_run(schedule, now):
236
+ success = run_schedule(schedule, dry_run=dry_run)
237
+ status = "fired" if success else "error"
238
+ log_event(schedule.get("name", "?"), schedule.get("action", "?"), status)
239
+ if not dry_run:
240
+ schedules[i]["last_run"] = now.strftime("%Y-%m-%dT%H:%M:%SZ")
241
+ changed = True
242
+
243
+ if changed:
244
+ save_schedules(schedules)
245
+
246
+ if not schedules:
247
+ logging.debug("No schedules configured.")
248
+
249
+ return True
250
+
251
+
252
+ def list_schedules():
253
+ """Print all configured schedules."""
254
+ schedules = load_schedules()
255
+ if not schedules:
256
+ print("No schedules configured.")
257
+ print("Add schedules to .loki/triggers/schedules.json")
258
+ return
259
+
260
+ print("Configured schedules:")
261
+ for s in schedules:
262
+ enabled = "enabled" if s.get("enabled", True) else "disabled"
263
+ last_run = s.get("last_run") or "never"
264
+ print(" %-30s %-20s %-12s %s (last: %s)" % (
265
+ s.get("name", "?"),
266
+ s.get("cron_expr", "?"),
267
+ s.get("action", "?"),
268
+ enabled,
269
+ last_run,
270
+ ))
271
+
272
+
273
+ def add_schedule(name, cron_expr, action, extra_args, enabled=True):
274
+ """Add or update a schedule entry."""
275
+ schedules = load_schedules()
276
+ # Check for existing
277
+ for i, s in enumerate(schedules):
278
+ if s.get("name") == name:
279
+ schedules[i] = {
280
+ "name": name,
281
+ "cron_expr": cron_expr,
282
+ "action": action,
283
+ "args": list(extra_args),
284
+ "enabled": enabled,
285
+ "last_run": s.get("last_run"),
286
+ }
287
+ save_schedules(schedules)
288
+ print("Updated schedule: %s" % name)
289
+ return
290
+ schedules.append({
291
+ "name": name,
292
+ "cron_expr": cron_expr,
293
+ "action": action,
294
+ "args": list(extra_args),
295
+ "enabled": enabled,
296
+ "last_run": None,
297
+ })
298
+ save_schedules(schedules)
299
+ print("Added schedule: %s" % name)
300
+
301
+
302
+ def remove_schedule(name):
303
+ """Remove a schedule by name."""
304
+ schedules = load_schedules()
305
+ before = len(schedules)
306
+ schedules = [s for s in schedules if s.get("name") != name]
307
+ if len(schedules) == before:
308
+ print("Schedule not found: %s" % name)
309
+ return False
310
+ save_schedules(schedules)
311
+ print("Removed schedule: %s" % name)
312
+ return True
313
+
314
+
315
+ def main():
316
+ parser = argparse.ArgumentParser(
317
+ description="loki-mode schedule-based trigger daemon"
318
+ )
319
+ subparsers = parser.add_subparsers(dest="command")
320
+
321
+ # daemon - run one pass
322
+ daemon_parser = subparsers.add_parser("daemon", help="Run one pass of schedule checks")
323
+ daemon_parser.add_argument("--dry-run", action="store_true", help="Preview without running")
324
+
325
+ # list
326
+ subparsers.add_parser("list", help="List configured schedules")
327
+
328
+ # add
329
+ add_parser = subparsers.add_parser("add", help="Add a schedule")
330
+ add_parser.add_argument("name")
331
+ add_parser.add_argument("cron_expr")
332
+ add_parser.add_argument("action")
333
+ add_parser.add_argument("args", nargs="*")
334
+ add_parser.add_argument("--disabled", action="store_true")
335
+
336
+ # remove
337
+ remove_parser = subparsers.add_parser("remove", help="Remove a schedule")
338
+ remove_parser.add_argument("name")
339
+
340
+ args = parser.parse_args()
341
+
342
+ logging.basicConfig(
343
+ level=logging.INFO,
344
+ format="%(asctime)s [%(levelname)s] %(message)s",
345
+ datefmt="%Y-%m-%dT%H:%M:%SZ",
346
+ )
347
+
348
+ if args.command == "daemon" or args.command is None:
349
+ dry_run = getattr(args, "dry_run", False)
350
+ run_daemon(dry_run=dry_run, once=True)
351
+ elif args.command == "list":
352
+ list_schedules()
353
+ elif args.command == "add":
354
+ add_schedule(
355
+ args.name,
356
+ args.cron_expr,
357
+ args.action,
358
+ args.args,
359
+ enabled=not args.disabled,
360
+ )
361
+ elif args.command == "remove":
362
+ success = remove_schedule(args.name)
363
+ sys.exit(0 if success else 1)
364
+
365
+
366
+ if __name__ == "__main__":
367
+ main()
@@ -0,0 +1,309 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ trigger-server.py - GitHub webhook receiver for loki-mode event-driven execution.
4
+
5
+ Listens for GitHub webhook events and automatically runs `loki run` in response.
6
+ Supports signature validation, dry-run mode, and event logging.
7
+
8
+ Usage:
9
+ python3 autonomy/trigger-server.py [--port PORT] [--secret SECRET] [--dry-run]
10
+ """
11
+
12
+ import argparse
13
+ import hashlib
14
+ import hmac
15
+ import http.server
16
+ import json
17
+ import logging
18
+ import os
19
+ import subprocess
20
+ import sys
21
+ import threading
22
+ import time
23
+ from datetime import datetime
24
+ from pathlib import Path
25
+
26
+
27
+ def get_loki_dir():
28
+ """Return .loki/triggers directory, creating it if needed."""
29
+ loki_dir = Path(".loki") / "triggers"
30
+ loki_dir.mkdir(parents=True, exist_ok=True)
31
+ return loki_dir
32
+
33
+
34
+ def load_config():
35
+ """Load trigger config from .loki/triggers/config.json."""
36
+ config_path = get_loki_dir() / "config.json"
37
+ defaults = {
38
+ "port": 7373,
39
+ "secret": "",
40
+ "dry_run": False,
41
+ "enabled_events": ["issues", "pull_request", "workflow_run"],
42
+ }
43
+ if config_path.exists():
44
+ try:
45
+ with open(config_path) as f:
46
+ stored = json.load(f)
47
+ defaults.update(stored)
48
+ except (json.JSONDecodeError, OSError):
49
+ pass
50
+ return defaults
51
+
52
+
53
+ def save_config(config):
54
+ """Save trigger config to .loki/triggers/config.json."""
55
+ config_path = get_loki_dir() / "config.json"
56
+ with open(config_path, "w") as f:
57
+ json.dump(config, f, indent=2)
58
+
59
+
60
+ def log_event(event_type, action, payload_summary, status):
61
+ """Append event to .loki/triggers/events.log."""
62
+ log_path = get_loki_dir() / "events.log"
63
+ timestamp = datetime.utcnow().strftime("%Y-%m-%dT%H:%M:%SZ")
64
+ entry = {
65
+ "timestamp": timestamp,
66
+ "event": event_type,
67
+ "action": action,
68
+ "summary": payload_summary,
69
+ "status": status,
70
+ }
71
+ with open(log_path, "a") as f:
72
+ f.write(json.dumps(entry) + "\n")
73
+
74
+
75
+ def validate_signature(secret, body, signature_header):
76
+ """Validate GitHub HMAC-SHA256 webhook signature."""
77
+ if not secret:
78
+ return True # No secret configured - accept all
79
+ if not signature_header:
80
+ return False
81
+ expected = "sha256=" + hmac.new(
82
+ secret.encode("utf-8"), body, hashlib.sha256
83
+ ).hexdigest()
84
+ return hmac.compare_digest(expected, signature_header)
85
+
86
+
87
+ def send_notification(message):
88
+ """Send desktop notification via loki syslog."""
89
+ try:
90
+ subprocess.run(
91
+ ["loki", "syslog", message],
92
+ timeout=5,
93
+ capture_output=True,
94
+ )
95
+ except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
96
+ pass
97
+
98
+
99
+ def run_loki_command(args, dry_run=False):
100
+ """Run a loki command, or print it if dry_run is True."""
101
+ cmd = ["loki"] + args
102
+ if dry_run:
103
+ logging.info("[DRY-RUN] Would run: %s", " ".join(cmd))
104
+ return True
105
+ logging.info("Running: %s", " ".join(cmd))
106
+ try:
107
+ proc = subprocess.Popen(
108
+ cmd,
109
+ stdout=subprocess.PIPE,
110
+ stderr=subprocess.PIPE,
111
+ )
112
+ # Don't wait - detached execution
113
+ logging.info("Started process pid=%d", proc.pid)
114
+ return True
115
+ except (FileNotFoundError, OSError) as e:
116
+ logging.error("Failed to run %s: %s", " ".join(cmd), e)
117
+ return False
118
+
119
+
120
+ def handle_issues_event(payload, dry_run=False):
121
+ """Handle issues event: opened -> loki run <issue_number> --pr --detach."""
122
+ action = payload.get("action", "")
123
+ if action != "opened":
124
+ return None, "skipped (action=%s)" % action
125
+ issue = payload.get("issue", {})
126
+ issue_number = issue.get("number")
127
+ repo = payload.get("repository", {})
128
+ repo_full_name = repo.get("full_name", "")
129
+ if not issue_number:
130
+ return None, "skipped (no issue number)"
131
+ args = ["run", str(issue_number), "--pr", "--detach"]
132
+ if repo_full_name:
133
+ args = ["run", "%s#%s" % (repo_full_name, issue_number), "--pr", "--detach"]
134
+ summary = "issue #%s opened in %s" % (issue_number, repo_full_name)
135
+ success = run_loki_command(args, dry_run=dry_run)
136
+ status = "fired" if success else "error"
137
+ if success:
138
+ send_notification("Trigger fired: %s" % summary)
139
+ return summary, status
140
+
141
+
142
+ def handle_pull_request_event(payload, dry_run=False):
143
+ """Handle pull_request event: synchronize -> loki run <pr_number> --detach."""
144
+ action = payload.get("action", "")
145
+ if action != "synchronize":
146
+ return None, "skipped (action=%s)" % action
147
+ pr = payload.get("pull_request", {})
148
+ pr_number = pr.get("number")
149
+ repo = payload.get("repository", {})
150
+ repo_full_name = repo.get("full_name", "")
151
+ if not pr_number:
152
+ return None, "skipped (no PR number)"
153
+ args = ["run", str(pr_number), "--detach"]
154
+ if repo_full_name:
155
+ args = ["run", "%s#%s" % (repo_full_name, pr_number), "--detach"]
156
+ summary = "PR #%s synchronized in %s" % (pr_number, repo_full_name)
157
+ success = run_loki_command(args, dry_run=dry_run)
158
+ status = "fired" if success else "error"
159
+ if success:
160
+ send_notification("Trigger fired: %s" % summary)
161
+ return summary, status
162
+
163
+
164
+ def handle_workflow_run_event(payload, dry_run=False):
165
+ """Handle workflow_run event: completed+failure -> loki run with context."""
166
+ action = payload.get("action", "")
167
+ if action != "completed":
168
+ return None, "skipped (action=%s)" % action
169
+ wf = payload.get("workflow_run", {})
170
+ conclusion = wf.get("conclusion", "")
171
+ if conclusion != "failure":
172
+ return None, "skipped (conclusion=%s)" % conclusion
173
+ wf_name = wf.get("name", "unknown")
174
+ repo = payload.get("repository", {})
175
+ repo_full_name = repo.get("full_name", "")
176
+ summary = "workflow '%s' failed in %s" % (wf_name, repo_full_name)
177
+ # Run loki run with failure context note
178
+ args = ["run", "--detach"]
179
+ success = run_loki_command(args, dry_run=dry_run)
180
+ status = "fired" if success else "error"
181
+ if success:
182
+ send_notification("Trigger fired: CI failure - %s" % summary)
183
+ return summary, status
184
+
185
+
186
+ class WebhookHandler(http.server.BaseHTTPRequestHandler):
187
+ """HTTP request handler for GitHub webhooks."""
188
+
189
+ dry_run = False
190
+ secret = ""
191
+
192
+ def log_message(self, format, *args):
193
+ logging.info("%s - %s", self.address_string(), format % args)
194
+
195
+ def do_GET(self):
196
+ if self.path == "/health":
197
+ self._send_json(200, {"status": "ok", "service": "loki-trigger-server"})
198
+ elif self.path == "/status":
199
+ config = load_config()
200
+ self._send_json(200, {
201
+ "status": "running",
202
+ "dry_run": self.dry_run,
203
+ "port": config.get("port", 7373),
204
+ "enabled_events": config.get("enabled_events", []),
205
+ })
206
+ else:
207
+ self._send_json(404, {"error": "not found"})
208
+
209
+ def do_POST(self):
210
+ if self.path != "/webhook":
211
+ self._send_json(404, {"error": "not found"})
212
+ return
213
+
214
+ content_length = int(self.headers.get("Content-Length", 0))
215
+ body = self.rfile.read(content_length)
216
+
217
+ event_type = self.headers.get("X-GitHub-Event", "")
218
+ signature = self.headers.get("X-Hub-Signature-256", "")
219
+
220
+ if not validate_signature(self.secret, body, signature):
221
+ logging.warning("Invalid webhook signature")
222
+ self._send_json(401, {"error": "invalid signature"})
223
+ return
224
+
225
+ try:
226
+ payload = json.loads(body)
227
+ except json.JSONDecodeError:
228
+ self._send_json(400, {"error": "invalid JSON"})
229
+ return
230
+
231
+ action = payload.get("action", "")
232
+ summary = None
233
+ status = "unhandled"
234
+
235
+ if event_type == "issues":
236
+ summary, status = handle_issues_event(payload, dry_run=self.dry_run)
237
+ elif event_type == "pull_request":
238
+ summary, status = handle_pull_request_event(payload, dry_run=self.dry_run)
239
+ elif event_type == "workflow_run":
240
+ summary, status = handle_workflow_run_event(payload, dry_run=self.dry_run)
241
+ else:
242
+ status = "unsupported event: %s" % event_type
243
+
244
+ log_event(event_type, action, summary or "", status)
245
+ self._send_json(200, {"event": event_type, "action": action, "status": status})
246
+
247
+ def _send_json(self, code, data):
248
+ body = json.dumps(data).encode("utf-8")
249
+ self.send_response(code)
250
+ self.send_header("Content-Type", "application/json")
251
+ self.send_header("Content-Length", str(len(body)))
252
+ self.end_headers()
253
+ self.wfile.write(body)
254
+
255
+
256
+ def write_pid_file():
257
+ """Write PID to .loki/triggers/server.pid."""
258
+ pid_path = get_loki_dir() / "server.pid"
259
+ with open(pid_path, "w") as f:
260
+ f.write(str(os.getpid()))
261
+
262
+
263
+ def main():
264
+ parser = argparse.ArgumentParser(
265
+ description="loki-mode GitHub webhook trigger server"
266
+ )
267
+ parser.add_argument("--port", type=int, default=None, help="Port to listen on (default: 7373)")
268
+ parser.add_argument("--secret", default=None, help="GitHub webhook secret for HMAC validation")
269
+ parser.add_argument("--dry-run", action="store_true", help="Preview triggers without running loki")
270
+ args = parser.parse_args()
271
+
272
+ logging.basicConfig(
273
+ level=logging.INFO,
274
+ format="%(asctime)s [%(levelname)s] %(message)s",
275
+ datefmt="%Y-%m-%dT%H:%M:%SZ",
276
+ )
277
+
278
+ config = load_config()
279
+ port = args.port if args.port is not None else config.get("port", 7373)
280
+ secret = args.secret if args.secret is not None else config.get("secret", "")
281
+ dry_run = args.dry_run or config.get("dry_run", False)
282
+
283
+ # Update config with resolved values
284
+ config["port"] = port
285
+ config["secret"] = secret
286
+ config["dry_run"] = dry_run
287
+ save_config(config)
288
+
289
+ WebhookHandler.dry_run = dry_run
290
+ WebhookHandler.secret = secret
291
+
292
+ server = http.server.HTTPServer(("", port), WebhookHandler)
293
+ write_pid_file()
294
+
295
+ mode_label = " [DRY-RUN]" if dry_run else ""
296
+ logging.info("Loki trigger server starting on port %d%s", port, mode_label)
297
+ logging.info("Webhook endpoint: POST http://localhost:%d/webhook", port)
298
+ logging.info("Health check: GET http://localhost:%d/health", port)
299
+
300
+ try:
301
+ server.serve_forever()
302
+ except KeyboardInterrupt:
303
+ logging.info("Trigger server stopped.")
304
+ pid_path = get_loki_dir() / "server.pid"
305
+ pid_path.unlink(missing_ok=True)
306
+
307
+
308
+ if __name__ == "__main__":
309
+ main()
@@ -7,7 +7,7 @@ Modules:
7
7
  control: Session control API (start/stop/pause/resume)
8
8
  """
9
9
 
10
- __version__ = "6.16.1"
10
+ __version__ = "6.17.1"
11
11
 
12
12
  # Expose the control app for easy import
13
13
  try:
@@ -2,7 +2,7 @@
2
2
 
3
3
  The flagship product of [Autonomi](https://www.autonomi.dev/). Complete installation instructions for all platforms and use cases.
4
4
 
5
- **Version:** v6.16.1
5
+ **Version:** v6.17.1
6
6
 
7
7
  ---
8
8
 
package/mcp/__init__.py CHANGED
@@ -57,4 +57,4 @@ try:
57
57
  except ImportError:
58
58
  __all__ = ['mcp']
59
59
 
60
- __version__ = '6.16.1'
60
+ __version__ = '6.17.1'
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "loki-mode",
3
- "version": "6.16.1",
3
+ "version": "6.17.1",
4
4
  "description": "Loki Mode by Autonomi - Multi-agent autonomous startup system for Claude Code, Codex CLI, and Gemini CLI",
5
5
  "keywords": [
6
6
  "agent",