loki-mode 6.16.0 → 6.17.0

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.0
6
+ # Loki Mode v6.17.0
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.0 | [Autonomi](https://www.autonomi.dev/) flagship product | ~260 lines core**
270
+ **v6.17.0 | [Autonomi](https://www.autonomi.dev/) flagship product | ~260 lines core**
package/VERSION CHANGED
@@ -1 +1 @@
1
- 6.16.0
1
+ 6.17.0
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
  ;;
@@ -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.0"
10
+ __version__ = "6.17.0"
11
11
 
12
12
  # Expose the control app for easy import
13
13
  try:
@@ -5,6 +5,8 @@ Appends structured JSONL entries to ~/.loki/activity.jsonl with automatic
5
5
  rotation at 10MB. Provides query and session-diff capabilities.
6
6
  """
7
7
 
8
+ from __future__ import annotations
9
+
8
10
  import json
9
11
  import logging
10
12
  import os
@@ -7,6 +7,8 @@ and usage tracking. Builds on dashboard.auth for core token operations.
7
7
  Storage: Extends ~/.loki/dashboard/tokens.json with additional fields.
8
8
  """
9
9
 
10
+ from __future__ import annotations
11
+
10
12
  from datetime import datetime, timedelta, timezone
11
13
  from typing import Optional
12
14
 
@@ -7,6 +7,8 @@ Mount this router in server.py with:
7
7
  app.include_router(api_v2_router)
8
8
  """
9
9
 
10
+ from __future__ import annotations
11
+
10
12
  import csv
11
13
  import io
12
14
  import json
@@ -12,6 +12,8 @@ Syslog forwarding (optional):
12
12
  LOKI_AUDIT_SYSLOG_PROTO defaults to "udp" (also supports "tcp").
13
13
  """
14
14
 
15
+ from __future__ import annotations
16
+
15
17
  import hashlib
16
18
  import json
17
19
  import logging
package/dashboard/auth.py CHANGED
@@ -10,6 +10,8 @@ Supports enterprise SSO providers (Okta, Azure AD, Google Workspace).
10
10
  Token storage: ~/.loki/dashboard/tokens.json
11
11
  """
12
12
 
13
+ from __future__ import annotations
14
+
13
15
  import base64
14
16
  import hashlib
15
17
  import json
@@ -6,6 +6,8 @@ repeated task failures, excessive RARV cycles, verification failures,
6
6
  agent timeouts, and user corrections.
7
7
  """
8
8
 
9
+ from __future__ import annotations
10
+
9
11
  import hashlib
10
12
  import json
11
13
  import logging
@@ -6,6 +6,8 @@ Implements data models, MigrationPipeline, and phase gates for safe,
6
6
  incremental codebase migrations with checkpoint/rollback support.
7
7
  """
8
8
 
9
+ from __future__ import annotations
10
+
9
11
  import dataclasses
10
12
  import json
11
13
  import logging
@@ -4,6 +4,8 @@ SQLAlchemy models for Loki Mode Dashboard.
4
4
  Uses SQLAlchemy 2.0 style with async support.
5
5
  """
6
6
 
7
+ from __future__ import annotations
8
+
7
9
  from datetime import datetime
8
10
  from enum import Enum as PyEnum
9
11
  from typing import Optional
@@ -5,6 +5,8 @@ Uses failure patterns from FailureExtractor to generate improved prompt
5
5
  sections for agents. Stores versioned prompts with change tracking.
6
6
  """
7
7
 
8
+ from __future__ import annotations
9
+
8
10
  import hashlib
9
11
  import json
10
12
  import logging
@@ -5,6 +5,8 @@ Manages cross-project registration, discovery, and tracking.
5
5
  Projects are stored in ~/.loki/dashboard/projects.json
6
6
  """
7
7
 
8
+ from __future__ import annotations
9
+
8
10
  import json
9
11
  import os
10
12
  from datetime import datetime, timezone
@@ -5,6 +5,8 @@ Shells out to `npx @rigour-labs/cli` to run quality scans, parses JSON output,
5
5
  and maps findings to the Loki Mode quality gate format.
6
6
  """
7
7
 
8
+ from __future__ import annotations
9
+
8
10
  import json
9
11
  import logging
10
12
  import os
package/dashboard/runs.py CHANGED
@@ -6,6 +6,8 @@ of RARV execution runs. A "run" wraps a Session with run-specific
6
6
  operations and timeline event tracking.
7
7
  """
8
8
 
9
+ from __future__ import annotations
10
+
9
11
  import json
10
12
  from datetime import datetime, timezone
11
13
  from typing import Optional
@@ -4,6 +4,8 @@ FastAPI server for Loki Mode Dashboard.
4
4
  Provides REST API and WebSocket endpoints for dashboard functionality.
5
5
  """
6
6
 
7
+ from __future__ import annotations
8
+
7
9
  import asyncio
8
10
  import json
9
11
  import logging
@@ -9,6 +9,8 @@ Each tenant gets a unique slug (URL-safe identifier) auto-generated
9
9
  from the tenant name.
10
10
  """
11
11
 
12
+ from __future__ import annotations
13
+
12
14
  import json
13
15
  import re
14
16
  from datetime import datetime
@@ -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.0
5
+ **Version:** v6.17.0
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.0'
60
+ __version__ = '6.17.0'
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "loki-mode",
3
- "version": "6.16.0",
3
+ "version": "6.17.0",
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",