loki-mode 11.12.1 → 11.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.
Files changed (114) hide show
  1. package/README.md +2 -2
  2. package/SKILL.md +3 -3
  3. package/VERSION +1 -1
  4. package/autonomy/grill.sh +3 -1
  5. package/autonomy/hooks/validate-bash.sh +51 -13
  6. package/autonomy/lib/backlog.py +18 -3
  7. package/autonomy/lib/chat.sh +117 -7
  8. package/autonomy/lib/claude-flags.sh +20 -0
  9. package/autonomy/lib/claude_permissions.py +40 -0
  10. package/autonomy/lib/done-recognition.sh +8 -4
  11. package/autonomy/lib/engine10-push.sh +56 -7
  12. package/autonomy/lib/prd-enrich.sh +4 -2
  13. package/autonomy/lib/proof-generator.py +21 -0
  14. package/autonomy/lib/proof-verify.py +109 -1
  15. package/autonomy/lib/trust_trajectory.py +139 -0
  16. package/autonomy/lib/voter-agents.sh +3 -1
  17. package/autonomy/lib/workspace.py +2 -3
  18. package/autonomy/loki +427 -74
  19. package/autonomy/quickstart.sh +21 -2
  20. package/autonomy/run.sh +55 -21
  21. package/autonomy/trigger-server.py +461 -32
  22. package/bin/loki +4 -1
  23. package/completions/_loki +1 -1
  24. package/completions/loki.bash +10 -2
  25. package/dashboard/__init__.py +1 -1
  26. package/dashboard/api_start.py +2 -2
  27. package/dashboard/build_supervisor.py +2 -0
  28. package/docs/CLI-REFERENCE.md +6 -4
  29. package/docs/DEPLOYMENT.md +12 -5
  30. package/docs/EVALUATING.md +13 -0
  31. package/docs/INSTALLATION.md +2 -2
  32. package/docs/SIGNED-RECEIPTS.md +7 -0
  33. package/docs/WORKSPACES.md +2 -2
  34. package/docs/cost-controls.md +26 -0
  35. package/docs/merge-queue.md +19 -0
  36. package/docs/sentry-intake.md +4 -0
  37. package/docs/slack.md +2 -1
  38. package/docs/trackers.md +12 -1
  39. package/docs/v10/BOARD.md +148 -123
  40. package/docs/v10/CLI-MODERN.md +5 -5
  41. package/docs/v10/DECISIONS.md +2 -0
  42. package/docs/v10/ENGINE.md +2 -0
  43. package/docs/v10/FAILURE-CLASSES.md +18 -0
  44. package/docs/v10/MODERNIZE.md +32 -1
  45. package/docs/v10/PROGRESS.md +7 -0
  46. package/loki-ts/data/finding-schema.json +2 -1
  47. package/loki-ts/dist/loki.js +1222 -1128
  48. package/magic/core/debate.py +27 -1
  49. package/mcp/__init__.py +1 -1
  50. package/package.json +2 -2
  51. package/packages/control-plane/dist/server.js +5895 -5292
  52. package/plugins/loki-mode/.claude-plugin/plugin.json +1 -1
  53. package/providers/claude.sh +6 -3
  54. package/providers/model_catalog.json +6 -0
  55. package/schemas/loki-yaml.schema.json +5 -0
  56. package/src/policies/claude_permissions.js +114 -0
  57. package/web-app/dist/assets/{AdminPage-DuT74pf5.js → AdminPage-OVzN6eLm.js} +1 -1
  58. package/web-app/dist/assets/{Avatar-BtgidR5a.js → Avatar-hCCrxrCZ.js} +1 -1
  59. package/web-app/dist/assets/{Badge-D2nt8G_I.js → Badge-CDcpkhbJ.js} +1 -1
  60. package/web-app/dist/assets/{Button-BKl2jOyb.js → Button-8mwWlhEu.js} +1 -1
  61. package/web-app/dist/assets/{CockpitPage-DaWr0EUX.js → CockpitPage-D8cf3u22.js} +1 -1
  62. package/web-app/dist/assets/{ComparePage-aIiNhxLr.js → ComparePage-CIgkbDRY.js} +1 -1
  63. package/web-app/dist/assets/{ErrorBoundary-C1Q7rV23.js → ErrorBoundary-99kJH9g2.js} +1 -1
  64. package/web-app/dist/assets/{EvidenceReceiptPanel-C-C0-tOx.js → EvidenceReceiptPanel-C9fj32eS.js} +1 -1
  65. package/web-app/dist/assets/{GitHubIssuesPanel-l3IaXDag.js → GitHubIssuesPanel-De43YQ_C.js} +1 -1
  66. package/web-app/dist/assets/{GitHubPRsPanel-DweLBgr5.js → GitHubPRsPanel-7g7ZTnkx.js} +1 -1
  67. package/web-app/dist/assets/{HomePage-B_KK7AvY.js → HomePage-CcNA24ny.js} +2 -2
  68. package/web-app/dist/assets/{LoginPage-CKS-KLdH.js → LoginPage-BYjlb-IK.js} +1 -1
  69. package/web-app/dist/assets/{MagicPage-Cl6CEp8J.js → MagicPage-C0Dwt9Na.js} +1 -1
  70. package/web-app/dist/assets/{MetricsPage-n9CPUaX0.js → MetricsPage-jDGmdcgg.js} +1 -1
  71. package/web-app/dist/assets/{NotFoundPage-D85eWn70.js → NotFoundPage-C_ZO8tLE.js} +1 -1
  72. package/web-app/dist/assets/{ProjectPage-CDN7LpSo.js → ProjectPage-B5wnurfQ.js} +3 -3
  73. package/web-app/dist/assets/{ProjectsPage-D4SDmU4-.js → ProjectsPage--EyKt5-D.js} +1 -1
  74. package/web-app/dist/assets/{SettingsPage-Dby6GnKm.js → SettingsPage-cB0lnfti.js} +1 -1
  75. package/web-app/dist/assets/{ShowcasePage-CgHxeqoU.js → ShowcasePage-Dl16FDkK.js} +1 -1
  76. package/web-app/dist/assets/{SystemSettingsPage-BaewGHlc.js → SystemSettingsPage-BmAXd4V2.js} +1 -1
  77. package/web-app/dist/assets/{TeamsPage-905MJyke.js → TeamsPage-BFkWVOZe.js} +1 -1
  78. package/web-app/dist/assets/{TemplatesPage-DMrUHcvF.js → TemplatesPage-Bf2kmEUM.js} +1 -1
  79. package/web-app/dist/assets/{TerminalOutput-BsbEbBYX.js → TerminalOutput-BItv9Wa6.js} +1 -1
  80. package/web-app/dist/assets/{activity-BuaXhBsC.js → activity-Df2Awg6I.js} +1 -1
  81. package/web-app/dist/assets/{bell-BfEQ71OG.js → bell-D-1ztTsJ.js} +1 -1
  82. package/web-app/dist/assets/{bot-DOGeYewg.js → bot-A4qgZsKi.js} +1 -1
  83. package/web-app/dist/assets/{check-zbjPpb3E.js → check-BPfFpF0c.js} +1 -1
  84. package/web-app/dist/assets/{chevron-left-DWTOgfFt.js → chevron-left-CfSJvmDi.js} +1 -1
  85. package/web-app/dist/assets/{circle-alert-BEPKfRzR.js → circle-alert-wx2d1dzb.js} +1 -1
  86. package/web-app/dist/assets/{clock-C_fX3OHo.js → clock-pQMai6l-.js} +1 -1
  87. package/web-app/dist/assets/{cloud-DUXHygXQ.js → cloud-DWL470r6.js} +1 -1
  88. package/web-app/dist/assets/{code-xml-BEVl_9Hw.js → code-xml-BaEQpQLZ.js} +1 -1
  89. package/web-app/dist/assets/{database-_a22uh7l.js → database-h-N7WkQN.js} +1 -1
  90. package/web-app/dist/assets/{dollar-sign-l2en7xfg.js → dollar-sign-B5oYiD27.js} +1 -1
  91. package/web-app/dist/assets/{file-code-corner-DItKl6H4.js → file-code-corner-fbTtTy0o.js} +1 -1
  92. package/web-app/dist/assets/{file-plus-BSk7qFMA.js → file-plus-PaF6HMo8.js} +1 -1
  93. package/web-app/dist/assets/{globe-2TveFE5l.js → globe-Ct-GlWID.js} +1 -1
  94. package/web-app/dist/assets/{hammer-BU6OJlQj.js → hammer-C1oZFX51.js} +1 -1
  95. package/web-app/dist/assets/{index-DGnZXUYk.js → index-C2egrRhz.js} +3 -3
  96. package/web-app/dist/assets/{layers-DKw7MNuq.js → layers-Dt5ZK4v2.js} +1 -1
  97. package/web-app/dist/assets/{loader-circle-CoYfykzD.js → loader-circle-DrpMziic.js} +1 -1
  98. package/web-app/dist/assets/{lock-D9JzrXDL.js → lock-DOez8MmK.js} +1 -1
  99. package/web-app/dist/assets/{package-BZtqTIqH.js → package-ClGIkerr.js} +1 -1
  100. package/web-app/dist/assets/{plus-BGJfo8bV.js → plus-C004i9ua.js} +1 -1
  101. package/web-app/dist/assets/{refresh-cw-F6lowBgR.js → refresh-cw-Ddfk-7Sy.js} +1 -1
  102. package/web-app/dist/assets/{rotate-ccw-DHOK8dM4.js → rotate-ccw-Bu1KgDjS.js} +1 -1
  103. package/web-app/dist/assets/{scroll-text-CKcROoMV.js → scroll-text-3q1lW5pm.js} +1 -1
  104. package/web-app/dist/assets/{server-DbG8MDv6.js → server-pd6x4HW1.js} +1 -1
  105. package/web-app/dist/assets/{shield-alert-RNKNXdJT.js → shield-alert-B9jGJepT.js} +1 -1
  106. package/web-app/dist/assets/{thumbs-up-Yxp-UBxS.js → thumbs-up-Cdcc9nQf.js} +1 -1
  107. package/web-app/dist/assets/{trash-2-DY-eba1n.js → trash-2-foSojXHK.js} +1 -1
  108. package/web-app/dist/assets/{trending-down-CARiDB3t.js → trending-down-CTTiqlJ1.js} +1 -1
  109. package/web-app/dist/assets/{trending-up-brQqWbA3.js → trending-up-B_MgmRAx.js} +1 -1
  110. package/web-app/dist/assets/{upload-D6wRjKhz.js → upload-g6BfALzG.js} +1 -1
  111. package/web-app/dist/assets/{usePolling-BMrqNbAI.js → usePolling-DrHwvzuh.js} +1 -1
  112. package/web-app/dist/assets/{user-CK5-EXIj.js → user-COp75m7l.js} +1 -1
  113. package/web-app/dist/index.html +1 -1
  114. package/autonomy/lib/doctor-fix.py +0 -279
@@ -53,6 +53,7 @@ import signal
53
53
  import socket
54
54
  import socketserver
55
55
  import subprocess
56
+ import tempfile
56
57
  import sys
57
58
  import threading
58
59
  import time
@@ -187,6 +188,74 @@ def valid_spec(spec):
187
188
  return True
188
189
 
189
190
 
191
+ # M-23: POST /jobs kind "unit" (remote modernize unit). The schema is closed:
192
+ # any field outside UNIT_JOB_FIELDS is rejected, so no credential, env var or
193
+ # command can ride the payload. Credentials come from the worker's own secret
194
+ # mount, never from the job. Every value is bounded and dash-leading values are
195
+ # refused so none can be parsed as a CLI flag.
196
+ UNIT_JOB_KIND = "unit"
197
+ UNIT_JOB_FIELDS = frozenset(("kind", "repo", "ref", "unit", "mid", "target"))
198
+ UNIT_JOB_REQUIRED = ("repo", "ref", "unit")
199
+ UNIT_JOB_TARGETS = ("python3", "java21")
200
+ MAX_UNIT_REPO_LEN = 140
201
+ MAX_UNIT_REF_LEN = 200
202
+ # fullmatch + explicit ASCII classes: "$" would accept a trailing newline and
203
+ # "\d" would accept Unicode digits. Mirrors the TS REPO_RE/REF_RE/UNIT_RE/MID_RE.
204
+ UNIT_REPO_RE = re.compile(r"[A-Za-z0-9._][A-Za-z0-9._-]*/[A-Za-z0-9._-]+", re.ASCII)
205
+ UNIT_REF_RE = re.compile(r"[A-Za-z0-9._][A-Za-z0-9._/-]*", re.ASCII)
206
+ UNIT_ID_RE = re.compile(r"[A-Za-z0-9][A-Za-z0-9_.-]{0,127}", re.ASCII)
207
+ UNIT_MID_RE = re.compile(r"mod-[0-9]{8}T[0-9]{6}Z-[0-9a-f]{6}", re.ASCII)
208
+
209
+
210
+ def validate_unit_job(payload):
211
+ """Strictly validate a kind "unit" payload.
212
+
213
+ Returns (clean_dict, None) or (None, error). The clean dict holds only
214
+ known string fields, so downstream code never sees anything else.
215
+ """
216
+ if not isinstance(payload, dict):
217
+ return None, "payload must be a JSON object"
218
+ unknown = sorted(str(k) for k in payload if k not in UNIT_JOB_FIELDS)
219
+ if unknown:
220
+ return None, "unknown field(s): %s" % ", ".join(k[:40] for k in unknown[:5])
221
+ for key in UNIT_JOB_REQUIRED:
222
+ if key not in payload:
223
+ return None, "missing field: %s" % key
224
+ for key, value in payload.items():
225
+ if not isinstance(value, str):
226
+ return None, "field %s must be a string" % key
227
+ repo, ref, unit = payload["repo"], payload["ref"], payload["unit"]
228
+ if (len(repo) > MAX_UNIT_REPO_LEN or not UNIT_REPO_RE.fullmatch(repo)
229
+ or any(seg in (".", "..") for seg in repo.split("/"))):
230
+ return None, "invalid repo"
231
+ if (not ref or len(ref) > MAX_UNIT_REF_LEN or not UNIT_REF_RE.fullmatch(ref)
232
+ or ".." in ref or ref.endswith((".lock", "/", "."))):
233
+ return None, "invalid ref"
234
+ if not UNIT_ID_RE.fullmatch(unit):
235
+ return None, "invalid unit"
236
+ clean = {"kind": UNIT_JOB_KIND, "repo": repo, "ref": ref, "unit": unit}
237
+ if "mid" in payload:
238
+ if not UNIT_MID_RE.fullmatch(payload["mid"]):
239
+ return None, "invalid mid"
240
+ clean["mid"] = payload["mid"]
241
+ if "target" in payload:
242
+ if payload["target"] not in UNIT_JOB_TARGETS:
243
+ return None, "invalid target"
244
+ clean["target"] = payload["target"]
245
+ return clean, None
246
+
247
+
248
+ def unit_start_args(unit_job):
249
+ """argv for the modernize unit worker. One list, never a shell string."""
250
+ args = ["modernize", "unit", "--repo", unit_job["repo"],
251
+ "--ref", unit_job["ref"], "--unit", unit_job["unit"]]
252
+ if "mid" in unit_job:
253
+ args += ["--mid", unit_job["mid"]]
254
+ if "target" in unit_job:
255
+ args += ["--target", unit_job["target"]]
256
+ return args
257
+
258
+
190
259
  def constant_time_equals(a, b):
191
260
  """Constant-time string compare that never raises on odd input.
192
261
 
@@ -420,19 +489,25 @@ CHILD_ENV_DENYLIST = (
420
489
  "LINEAR_WEBHOOK_SECRET",
421
490
  "LINEAR_API_KEY",
422
491
  "JIRA_WEBHOOK_SECRET",
492
+ "JIRA_API_TOKEN",
423
493
  "TEAMS_SECURITY_TOKEN",
424
494
  )
425
495
 
426
496
 
427
- def _scoped_child_env():
428
- return {k: v for k, v in os.environ.items() if k not in CHILD_ENV_DENYLIST}
497
+ def _scoped_child_env(extra_env=None):
498
+ env = {k: v for k, v in os.environ.items() if k not in CHILD_ENV_DENYLIST}
499
+ # Caller values override inherited ones but can never restore a denied secret.
500
+ env.update({k: v for k, v in (extra_env or {}).items() if k not in CHILD_ENV_DENYLIST})
501
+ return env
429
502
 
430
503
 
431
- def run_loki_outcome(args, dry_run=False):
504
+ def run_loki_outcome(args, dry_run=False, stdout_sink=None, extra_env=None):
432
505
  """Run a loki command and report WHICH of the three outcomes occurred.
433
506
 
434
507
  Returns one of OUTCOME_PASSED / OUTCOME_FAILED / OUTCOME_UNKNOWN. This is
435
508
  the honest version of run_loki_command, which answers only "did it launch".
509
+ When stdout_sink is a list, the decoded stdout is appended to it on completion.
510
+ extra_env adds child env vars (e.g. LOKI_STEER_SOURCE) on top of the scoped env.
436
511
  """
437
512
  cmd = ["loki"] + args
438
513
  if dry_run:
@@ -444,14 +519,16 @@ def run_loki_outcome(args, dry_run=False):
444
519
  cmd,
445
520
  stdout=subprocess.PIPE,
446
521
  stderr=subprocess.PIPE,
447
- env=_scoped_child_env(),
522
+ env=_scoped_child_env(extra_env),
448
523
  )
449
524
  except (FileNotFoundError, OSError) as e:
450
525
  logging.error("Failed to launch %s: %s", " ".join(cmd), e)
451
526
  return OUTCOME_FAILED
452
527
 
453
528
  try:
454
- _, stderr = proc.communicate(timeout=DISPATCH_WAIT_SECONDS)
529
+ stdout, stderr = proc.communicate(timeout=DISPATCH_WAIT_SECONDS)
530
+ if stdout_sink is not None:
531
+ stdout_sink.append((stdout or b"").decode("utf-8", errors="replace"))
455
532
  except subprocess.TimeoutExpired:
456
533
  # Detached past the wait window. The reaper collects the child, but we
457
534
  # never see its exit code, so the outcome is genuinely UNKNOWN -- NOT a
@@ -485,7 +562,7 @@ def run_loki_outcome(args, dry_run=False):
485
562
  return OUTCOME_FAILED
486
563
 
487
564
 
488
- def run_loki_command(args, dry_run=False):
565
+ def run_loki_command(args, dry_run=False, stdout_sink=None):
489
566
  """Run a loki command synchronously and reap it; or print it if dry_run.
490
567
 
491
568
  Returns True if the command was launched and exited 0 (or backgrounded
@@ -501,17 +578,163 @@ def run_loki_command(args, dry_run=False):
501
578
  passed wants run_loki_outcome instead. Behaviour is unchanged: a detached
502
579
  dispatch (UNKNOWN) still reads as True here, exactly as before.
503
580
  """
504
- return run_loki_outcome(args, dry_run=dry_run) in (
581
+ return run_loki_outcome(args, dry_run=dry_run, stdout_sink=stdout_sink) in (
505
582
  OUTCOME_PASSED, OUTCOME_UNKNOWN,
506
583
  )
507
584
 
508
585
 
586
+ TRUSTED_ASSOCIATIONS = ("OWNER", "MEMBER", "COLLABORATOR") # same set as verify_pr.ts
587
+ STEER_NOTE_MAX = 1000
588
+
589
+
590
+ def _process_start_time(pid):
591
+ """`ps -o lstart=` for pid, whitespace-normalized like run_pid.ts processStartTime; None if gone."""
592
+ if not isinstance(pid, int) or pid <= 1:
593
+ return None
594
+ try:
595
+ out = subprocess.run(["ps", "-o", "lstart=", "-p", str(pid)], capture_output=True,
596
+ text=True, timeout=5, env={**os.environ, "LC_ALL": "C"}).stdout
597
+ except (OSError, subprocess.SubprocessError):
598
+ return None
599
+ return " ".join(out.split()) or None
600
+
601
+
602
+ def find_live_run(ref):
603
+ """Return the id of a live run for issue ref "owner/repo#N", else None.
604
+
605
+ Scans $LOKI_DIR (default .loki)/runs/*/events.jsonl for a run.started whose issue_ref matches and
606
+ whose run.pid names a live process. Mirrors runOwnerAlive: the recorded
607
+ start time and run_id must match, so a recycled pid does not read as live.
608
+ """
609
+ runs = Path(os.environ.get("LOKI_DIR") or ".loki") / "runs"
610
+ try:
611
+ dirs = sorted(runs.iterdir())
612
+ except OSError:
613
+ return None
614
+ for d in dirs:
615
+ try:
616
+ with open(d / "events.jsonl", "r", encoding="utf-8", errors="replace") as fh:
617
+ first = json.loads(fh.readline() or "{}")
618
+ if first.get("type") != "run.started" or (first.get("data") or {}).get("issue_ref") != ref:
619
+ continue
620
+ rec = json.loads((d / "run.pid").read_text())
621
+ pid = int(rec.get("pid", 0))
622
+ start = rec.get("start")
623
+ if pid > 1 and isinstance(start, str) and rec.get("run_id") == d.name \
624
+ and _process_start_time(pid) == " ".join(start.split()):
625
+ return d.name
626
+ except (OSError, ValueError, TypeError, AttributeError):
627
+ continue
628
+ return None
629
+
630
+
631
+ def steer_ack_text(rev):
632
+ """Ack line for a steer; byte-identical to the line `loki steer` prints (cmd_steer)."""
633
+ if rev is None:
634
+ return "Steer received: applies at next plan/implement stage."
635
+ return "Steer rev %s received: applies at next plan/implement stage." % rev
636
+
637
+
638
+ STEER_ACK_MARKER = "<!-- loki-steer-ack -->"
639
+ LOKI_COMMENT_MARKER = "<!-- loki-" # any Loki-authored comment (status projector, steer ack) never steers
640
+
641
+
642
+ def parse_steer_rev(stdout):
643
+ """Revision number from `loki steer` stdout (its ack line), else None. Never guesses."""
644
+ m = re.search(r"Steer rev (\d+) received", stdout or "") \
645
+ or re.search(r"Steering note \(rev (\d+)\)", stdout or "")
646
+ return int(m.group(1)) if m else None
647
+
648
+
649
+ def post_steer_ack(ref, repo_full_name, rev, dry_run=False):
650
+ """Post one ack comment on the issue through engine10-push.sh issue-comment. Best effort."""
651
+ text = steer_ack_text(rev)
652
+ if dry_run:
653
+ logging.info("[DRY-RUN] Would ack %s: %s", ref, text)
654
+ return True
655
+ script = Path(__file__).resolve().parent / "lib" / "engine10-push.sh"
656
+ body = None
657
+ try:
658
+ fd, body = tempfile.mkstemp(prefix="loki-steer-ack.", suffix=".md")
659
+ with os.fdopen(fd, "w", encoding="utf-8") as fh:
660
+ fh.write(STEER_ACK_MARKER + "\n" + text + "\n")
661
+ env = _scoped_child_env()
662
+ env["_LOKI_ORIGIN_PINNED"] = "1"
663
+ env["_LOKI_PINNED_ORIGIN"] = "https://github.com/%s.git" % repo_full_name
664
+ proc = subprocess.run(["bash", str(script), "issue-comment", ref, body],
665
+ capture_output=True, text=True, timeout=60, env=env)
666
+ if proc.returncode != 0:
667
+ logging.error("steer ack failed (%d): %s", proc.returncode, proc.stderr.strip())
668
+ return proc.returncode == 0
669
+ except (OSError, subprocess.SubprocessError) as e:
670
+ logging.error("steer ack failed: %s", e)
671
+ return False
672
+ finally:
673
+ if body:
674
+ try:
675
+ os.unlink(body)
676
+ except OSError:
677
+ pass
678
+
679
+
680
+ def handle_issue_comment_event(payload, dry_run=False):
681
+ """Handle issue_comment: an authorized new comment steers the issue's live run."""
682
+ action = payload.get("action", "")
683
+ if action != "created":
684
+ return None, "skipped (action=%s)" % action
685
+ comment = payload.get("comment") or {}
686
+ if comment.get("author_association") not in TRUSTED_ASSOCIATIONS:
687
+ return None, "skipped (comment author not authorized)"
688
+ user = comment.get("user") or {}
689
+ login = str(user.get("login", ""))
690
+ if user.get("type") == "Bot" or login.endswith("[bot]"):
691
+ return None, "skipped (bot comment)"
692
+ if LOKI_COMMENT_MARKER in str(comment.get("body", "")):
693
+ return None, "skipped (loki-authored comment)"
694
+ number = (payload.get("issue") or {}).get("number")
695
+ repo_full_name = (payload.get("repository") or {}).get("full_name", "")
696
+ if number is None or not valid_issue_number(number):
697
+ return None, "rejected (invalid issue number)"
698
+ if not valid_repo_full_name(repo_full_name):
699
+ return None, "rejected (invalid repository full_name)"
700
+ ref = "%s#%s" % (repo_full_name, number)
701
+ if find_live_run(ref) is None:
702
+ return None, "skipped (no live run for %s)" % ref
703
+ body = re.sub(r"[\x00-\x1f\x7f\s]+", " ", str(comment.get("body", ""))).strip()[:STEER_NOTE_MAX]
704
+ if not body:
705
+ return None, "skipped (empty comment)"
706
+ login = re.sub(r"[^A-Za-z0-9-]", "", login)[:39]
707
+ note = "GitHub comment from %s: %s" % (login or "unknown", body)
708
+ summary = "steer %s from comment" % ref
709
+ sink = []
710
+ # Provenance for the revision log: loki steer records actor= and source= per revision.
711
+ steer_env = {"LOKI_STEER_SOURCE": "github", "LOKI_STEER_ACTOR": login or "unknown"}
712
+ outcome = run_loki_outcome(["steer", note], dry_run=dry_run, stdout_sink=sink, extra_env=steer_env)
713
+ # Only a confirmed exit 0 proves the steer was written. A detached child
714
+ # (UNKNOWN) never reports its exit code, so acking would claim an unverified
715
+ # steer; report it as launched but post nothing.
716
+ if outcome == OUTCOME_UNKNOWN:
717
+ send_notification("Trigger fired: %s" % summary)
718
+ return summary, OUTCOME_UNKNOWN
719
+ success = outcome == OUTCOME_PASSED
720
+ if success:
721
+ send_notification("Trigger fired: %s" % summary)
722
+ rev = parse_steer_rev("".join(sink))
723
+ injection_off = os.environ.get("LOKI_PROMPT_INJECTION", "").lower() in ("0", "false")
724
+ if not injection_off:
725
+ post_steer_ack(ref, repo_full_name, rev, dry_run=dry_run)
726
+ return summary, "fired" if success else "error"
727
+
728
+
509
729
  def handle_issues_event(payload, dry_run=False):
510
730
  """Handle issues event: opened -> loki start <issue-ref> --pr --detach."""
511
731
  action = payload.get("action", "")
512
732
  if action != "opened":
513
733
  return None, "skipped (action=%s)" % action
514
734
  issue = payload.get("issue", {})
735
+ if (issue.get("author_association") not in TRUSTED_ASSOCIATIONS
736
+ and os.environ.get("LOKI_TRIGGER_ALLOW_ANY_ISSUE_AUTHOR", "") not in ("1", "true")):
737
+ return None, "skipped (issue author not authorized)"
515
738
  issue_number = issue.get("number")
516
739
  repo = payload.get("repository", {})
517
740
  repo_full_name = repo.get("full_name", "")
@@ -605,8 +828,15 @@ def handle_job_event(payload, dry_run=False):
605
828
  spec = payload.get("spec")
606
829
  if not valid_spec(spec):
607
830
  return None, "rejected (invalid spec)"
831
+ unit_job = payload.get("unit_job")
832
+ if unit_job is not None:
833
+ # Re-validated here so the handler is safe no matter who calls it.
834
+ clean, _err = validate_unit_job(unit_job)
835
+ if clean is None:
836
+ return None, "rejected (invalid unit job)"
837
+ args = unit_start_args(clean)
608
838
  # Jira/Teams delegation sets pr=True: receipt-gated PR run, never a deploy.
609
- if payload.get("pr") is True:
839
+ elif payload.get("pr") is True:
610
840
  args = brief_start_args(spec.strip())
611
841
  else:
612
842
  args = ["start", spec.strip(), "--detach"]
@@ -878,32 +1108,61 @@ def _post_json(url, data, headers):
878
1108
  resp.read()
879
1109
 
880
1110
 
1111
+ def _notify_slack(target, text):
1112
+ url = target.get("response_url", "")
1113
+ if not (isinstance(url, str) and url.startswith(SLACK_REPLY_PREFIX)):
1114
+ return
1115
+ _post_json(url, {"response_type": "in_channel", "text": text}, {})
1116
+
1117
+
1118
+ def _notify_linear(target, text):
1119
+ key = os.environ.get("LINEAR_API_KEY", "")
1120
+ issue_id = target.get("issue_id", "")
1121
+ if not key or not isinstance(issue_id, str) or not issue_id:
1122
+ return
1123
+ _post_json(LINEAR_API_URL, {
1124
+ "query": "mutation($i:String!,$b:String!){commentCreate(input:"
1125
+ "{issueId:$i,body:$b}){success}}",
1126
+ "variables": {"i": issue_id, "b": text},
1127
+ }, {"Authorization": key})
1128
+
1129
+
1130
+ def _notify_jira(target, text):
1131
+ """ADF comment on the issue. Site and credentials come from the operator's
1132
+ env only (never the payload); a non-https base URL is never contacted."""
1133
+ base = os.environ.get("JIRA_BASE_URL", "").rstrip("/")
1134
+ email = os.environ.get("JIRA_EMAIL", "")
1135
+ token = os.environ.get("JIRA_API_TOKEN", "")
1136
+ key = target.get("key", "")
1137
+ if not (base.startswith("https://") and email and token
1138
+ and isinstance(key, str) and JIRA_KEY_RE.match(key)):
1139
+ return
1140
+ auth = base64.b64encode(("%s:%s" % (email, token)).encode("utf-8")).decode("ascii")
1141
+ _post_json("%s/rest/api/3/issue/%s/comment" % (base, key), {
1142
+ "body": {"type": "doc", "version": 1, "content": [
1143
+ {"type": "paragraph", "content": [{"type": "text", "text": text}]}]},
1144
+ }, {"Authorization": "Basic " + auth})
1145
+
1146
+
1147
+ # Reply-target kind -> notifier. Sentry has no entry on purpose: it never replies.
1148
+ NOTIFIERS = {"slack": _notify_slack, "linear": _notify_linear, "jira": _notify_jira}
1149
+
1150
+
881
1151
  def notify_job_complete(target, job_id, status, summary):
882
1152
  """Send a completion summary to a reply target captured after verification.
883
1153
 
884
- Never raises: a notifier failure must not fail the job. The Slack URL is
885
- re-checked here as defense in depth.
1154
+ Never raises: a notifier failure must not fail the job. Each notifier
1155
+ re-checks its own destination as defense in depth.
886
1156
  """
887
1157
  if not isinstance(target, dict):
888
1158
  return
1159
+ notifier = NOTIFIERS.get(target.get("kind"))
1160
+ if notifier is None:
1161
+ return
889
1162
  text = ("loki job %s finished: %s. %s" % (job_id, status, summary or "")).strip()
890
1163
  text = CONTROL_CHARS_RE.sub(" ", text)[:NOTIFY_TEXT_MAX]
891
1164
  try:
892
- if target.get("kind") == "slack":
893
- url = target.get("response_url", "")
894
- if not (isinstance(url, str) and url.startswith(SLACK_REPLY_PREFIX)):
895
- return
896
- _post_json(url, {"response_type": "in_channel", "text": text}, {})
897
- elif target.get("kind") == "linear":
898
- key = os.environ.get("LINEAR_API_KEY", "")
899
- issue_id = target.get("issue_id", "")
900
- if not key or not isinstance(issue_id, str) or not issue_id:
901
- return
902
- _post_json(LINEAR_API_URL, {
903
- "query": "mutation($i:String!,$b:String!){commentCreate(input:"
904
- "{issueId:$i,body:$b}){success}}",
905
- "variables": {"i": issue_id, "b": text},
906
- }, {"Authorization": key})
1165
+ notifier(target, text)
907
1166
  except Exception as e: # notifier errors never fail a job
908
1167
  logging.warning("Completion notify for job %s failed: %s", job_id, e)
909
1168
 
@@ -941,7 +1200,64 @@ def normalize_sentry_payload(payload):
941
1200
  return out if isinstance(out, dict) else None
942
1201
 
943
1202
 
944
- def build_sentry_brief(norm):
1203
+ def _clip(value, limit=80):
1204
+ """Single-line, control-char-free, length-capped untrusted text."""
1205
+ text = " ".join(CONTROL_CHARS_RE.sub(" ", str(value or "")).split())
1206
+ return text.encode("utf-8")[:limit].decode("utf-8", errors="ignore")
1207
+
1208
+
1209
+ def _sentry_release_env(payload):
1210
+ ev = ((payload or {}).get("data") or {}).get("event")
1211
+ ev = ev if isinstance(ev, dict) else {}
1212
+ release = ev.get("release")
1213
+ if isinstance(release, dict):
1214
+ release = release.get("version")
1215
+ return _clip(release), _clip(ev.get("environment"), 40)
1216
+
1217
+
1218
+ def _last_deploy_context():
1219
+ """'last deploy <sha7> <finished_at> health=<status>' from the newest
1220
+ .loki/deploys record in the server's project dir, or ''."""
1221
+ try:
1222
+ ddir = os.path.join(os.getcwd(), ".loki", "deploys")
1223
+ names = sorted(n for n in os.listdir(ddir) if n.endswith(".json"))
1224
+ if not names:
1225
+ return ""
1226
+ with open(os.path.join(ddir, names[-1]), "r", encoding="utf-8") as fh:
1227
+ rec = json.load(fh)
1228
+ except (OSError, ValueError):
1229
+ return ""
1230
+ if not isinstance(rec, dict) or not rec.get("head_sha"):
1231
+ return ""
1232
+ return "last deploy %s %s health=%s" % (
1233
+ _clip(rec.get("head_sha"), 7), _clip(rec.get("finished_at"), 30),
1234
+ _clip(rec.get("health_status") or "unchecked", 20))
1235
+
1236
+
1237
+ def _culprit_owner(culprit):
1238
+ """'last change to <file> by <author> <sha7>' via read-only git log -1, or ''."""
1239
+ token = culprit.split(" ", 1)[0] if culprit else ""
1240
+ if not token or token.startswith("-") or ".." in token:
1241
+ return ""
1242
+ root = os.path.realpath(os.getcwd())
1243
+ full = os.path.realpath(os.path.join(root, token))
1244
+ if not full.startswith(root + os.sep) or not os.path.isfile(full):
1245
+ return ""
1246
+ try:
1247
+ proc = subprocess.run(
1248
+ ["git", "-C", root, "log", "-1", "--format=%an|%h", "--", full],
1249
+ capture_output=True, timeout=5,
1250
+ env={"PATH": os.environ.get("PATH", "/usr/bin:/bin")})
1251
+ except (OSError, subprocess.SubprocessError):
1252
+ return ""
1253
+ out = proc.stdout.decode("utf-8", errors="replace").strip()
1254
+ if proc.returncode != 0 or "|" not in out:
1255
+ return ""
1256
+ author, sha = out.rsplit("|", 1)
1257
+ return "last change to %s by %s %s" % (_clip(token, 60), _clip(author, 40), _clip(sha, 12))
1258
+
1259
+
1260
+ def build_sentry_brief(norm, payload=None):
945
1261
  """One-line, control-char-free brief that can never parse as a CLI flag."""
946
1262
  culprit = ""
947
1263
  for line in str(norm.get("body", "")).splitlines():
@@ -951,6 +1267,17 @@ def build_sentry_brief(norm):
951
1267
  parts = ["Fix Sentry issue %s: %s" % (norm.get("number", "?"), norm.get("title", ""))]
952
1268
  if culprit:
953
1269
  parts.append("(culprit %s)" % culprit)
1270
+ release, env_name = _sentry_release_env(payload)
1271
+ ctx = []
1272
+ if release:
1273
+ ctx.append("release %s" % release)
1274
+ if env_name:
1275
+ ctx.append("env %s" % env_name)
1276
+ for extra in (_last_deploy_context(), _culprit_owner(culprit)):
1277
+ if extra:
1278
+ ctx.append(extra)
1279
+ if ctx:
1280
+ parts.append("[%s]" % "; ".join(ctx))
954
1281
  if norm.get("url"):
955
1282
  parts.append("See %s." % norm["url"])
956
1283
  parts.append("Reproduce with a regression test, then fix the root cause.")
@@ -971,7 +1298,7 @@ def handle_sentry_event(payload, dry_run=False):
971
1298
  norm = normalize_sentry_payload(payload)
972
1299
  if norm is None:
973
1300
  return None, "rejected (unparseable sentry payload)"
974
- brief = build_sentry_brief(norm)
1301
+ brief = build_sentry_brief(norm, payload)
975
1302
  if not valid_spec(brief):
976
1303
  return None, "rejected (invalid brief)"
977
1304
  args = brief_start_args(brief)
@@ -988,6 +1315,7 @@ def handle_sentry_event(payload, dry_run=False):
988
1315
  # explicitly refused on the /webhook path.
989
1316
  EVENT_HANDLERS = {
990
1317
  "issues": handle_issues_event,
1318
+ "issue_comment": handle_issue_comment_event,
991
1319
  "pull_request": handle_pull_request_event,
992
1320
  "workflow_run": handle_workflow_run_event,
993
1321
  JOB_EVENT: handle_job_event,
@@ -1053,6 +1381,8 @@ class Dispatcher:
1053
1381
  self._dedup_max = max(1, dedup_size)
1054
1382
  self._seen_deliveries = collections.OrderedDict()
1055
1383
  self._dedup_lock = threading.Lock()
1384
+ self._seen_path = get_loki_dir() / "seen-deliveries.jsonl"
1385
+ self._load_seen()
1056
1386
  self._threads = []
1057
1387
  self._stop = threading.Event()
1058
1388
  for i in range(max(1, workers)):
@@ -1064,6 +1394,64 @@ class Dispatcher:
1064
1394
  t.start()
1065
1395
  self._threads.append(t)
1066
1396
 
1397
+ def _load_seen(self):
1398
+ """Reload the last dedup_max accepted keys so a restart keeps dedup."""
1399
+ try:
1400
+ lines = self._seen_path.read_text(encoding="utf-8").splitlines()
1401
+ except OSError:
1402
+ return
1403
+ keys = []
1404
+ for line in lines:
1405
+ try:
1406
+ k = json.loads(line).get("k")
1407
+ except (ValueError, AttributeError):
1408
+ continue
1409
+ if isinstance(k, str) and k:
1410
+ keys.append(k)
1411
+ keys = keys[-self._dedup_max:]
1412
+ for k in keys:
1413
+ self._seen_deliveries[k] = True
1414
+ if len(lines) > 2 * self._dedup_max:
1415
+ try:
1416
+ self._seen_path.write_text(
1417
+ "".join(json.dumps({"k": k}) + "\n" for k in keys), encoding="utf-8")
1418
+ except OSError:
1419
+ pass
1420
+
1421
+ def _remember(self, key):
1422
+ """Record key (caller holds _dedup_lock); append to the JSONL, best effort."""
1423
+ self._seen_deliveries[key] = True
1424
+ while len(self._seen_deliveries) > self._dedup_max:
1425
+ self._seen_deliveries.popitem(last=False)
1426
+ try:
1427
+ with open(self._seen_path, "a", encoding="utf-8") as fh:
1428
+ fh.write(json.dumps({"k": key}) + "\n")
1429
+ except OSError:
1430
+ pass
1431
+
1432
+ def seen_intake(self, event_type, payload):
1433
+ """True if the same repo#N + action + head sha was accepted and its run is live.
1434
+
1435
+ Catches a redelivery under a new delivery id or after a restart. Once the
1436
+ run is no longer live the same key may dispatch again. Only issues and
1437
+ pull_request events carry an intake key.
1438
+ """
1439
+ if event_type not in ("issues", "pull_request"):
1440
+ return False
1441
+ number = (payload.get("issue") or payload.get("pull_request") or {}).get("number")
1442
+ repo = (payload.get("repository") or {}).get("full_name", "")
1443
+ if number is None or not repo:
1444
+ return False
1445
+ sha = ((payload.get("pull_request") or {}).get("head") or {}).get("sha", "")
1446
+ key = "intake:%s:%s#%s:%s:%s" % (event_type, repo, number, payload.get("action", ""), sha)
1447
+ ref = "%s#%s" % (repo, number)
1448
+ with self._dedup_lock:
1449
+ if key in self._seen_deliveries and find_live_run(ref) is not None:
1450
+ return True
1451
+ if key not in self._seen_deliveries:
1452
+ self._remember(key)
1453
+ return False
1454
+
1067
1455
  def seen_delivery(self, delivery_id):
1068
1456
  """Return True if this delivery_id was already accepted (idempotency).
1069
1457
 
@@ -1082,9 +1470,7 @@ class Dispatcher:
1082
1470
  # Insertion order is the eviction policy; duplicates leave it
1083
1471
  # unchanged.
1084
1472
  return True
1085
- self._seen_deliveries[delivery_id] = True
1086
- while len(self._seen_deliveries) > self._dedup_max:
1087
- self._seen_deliveries.popitem(last=False)
1473
+ self._remember(delivery_id)
1088
1474
  return False
1089
1475
 
1090
1476
  def _begin_proof_window(self, job_id=None):
@@ -1585,6 +1971,10 @@ class WebhookHandler(http.server.BaseHTTPRequestHandler):
1585
1971
  self._send_json(400, {"error": "payload must be a JSON object"})
1586
1972
  return
1587
1973
 
1974
+ if payload.get("kind") == UNIT_JOB_KIND:
1975
+ self._handle_unit_submit(payload)
1976
+ return
1977
+
1588
1978
  spec = payload.get("spec")
1589
1979
  if not valid_spec(spec):
1590
1980
  log_event(JOB_EVENT, "", "", "rejected (invalid spec)")
@@ -1596,7 +1986,24 @@ class WebhookHandler(http.server.BaseHTTPRequestHandler):
1596
1986
  return
1597
1987
  self._send_json(202, {"id": job_id, "status": "queued"})
1598
1988
 
1599
- def _enqueue_job(self, spec, reply_target=None, pr=False):
1989
+ def _handle_unit_submit(self, payload):
1990
+ """POST /jobs {"kind": "unit", ...}; caller already authenticated."""
1991
+ clean, err = validate_unit_job(payload)
1992
+ if clean is None:
1993
+ log_event(JOB_EVENT, "", "", "rejected (invalid unit job)")
1994
+ self._send_json(400, {"error": err})
1995
+ return
1996
+ summary = "unit:%s@%s:%s" % (clean["repo"], clean["ref"], clean["unit"])
1997
+ if not valid_spec(summary):
1998
+ log_event(JOB_EVENT, "", "", "rejected (invalid unit job)")
1999
+ self._send_json(400, {"error": "invalid unit job"})
2000
+ return
2001
+ job_id = self._enqueue_job(summary, unit_job=clean)
2002
+ if job_id is None:
2003
+ return
2004
+ self._send_json(202, {"id": job_id, "status": "queued"})
2005
+
2006
+ def _enqueue_job(self, spec, reply_target=None, pr=False, unit_job=None):
1600
2007
  """Record and enqueue one validated job; shared by /jobs, /slack, /linear.
1601
2008
 
1602
2009
  Returns the job id, or None after sending 429 when the queue is full.
@@ -1606,6 +2013,8 @@ class WebhookHandler(http.server.BaseHTTPRequestHandler):
1606
2013
  job = {"spec": spec, "job_id": job_id}
1607
2014
  if pr:
1608
2015
  job["pr"] = True
2016
+ if unit_job is not None:
2017
+ job["unit_job"] = unit_job
1609
2018
 
1610
2019
  # Record BEFORE enqueueing. A worker can pick the job up the instant
1611
2020
  # submit() returns and write "running"/"fired"; record_job is
@@ -1662,6 +2071,13 @@ class WebhookHandler(http.server.BaseHTTPRequestHandler):
1662
2071
  self._send_json(400, {"error": "unsupported slack payload"})
1663
2072
  return
1664
2073
  form = urllib.parse.parse_qs(body.decode("utf-8", errors="replace"))
2074
+ allowed = {u.strip() for u in os.environ.get("LOKI_SLACK_ALLOWED_USERS", "").split(",") if u.strip()}
2075
+ user_id = (form.get("user_id") or [""])[0]
2076
+ if "*" not in allowed and user_id not in allowed:
2077
+ log_event(JOB_EVENT, "", "", "rejected (slack user not allowed)")
2078
+ self._send_json(200, {"response_type": "ephemeral",
2079
+ "text": "You are not allowed to start runs from Slack."})
2080
+ return
1665
2081
  text = (form.get("text") or [""])[0]
1666
2082
  if not valid_spec(text):
1667
2083
  log_event(JOB_EVENT, "", "", "rejected (invalid slack spec)")
@@ -1777,7 +2193,7 @@ class WebhookHandler(http.server.BaseHTTPRequestHandler):
1777
2193
  if dup_id or dup_body:
1778
2194
  self._send_json(200, {"status": "duplicate"})
1779
2195
  return
1780
- job_id = self._enqueue_job(brief, pr=True)
2196
+ job_id = self._enqueue_job(brief, {"kind": "jira", "key": key}, pr=True)
1781
2197
  if job_id is None:
1782
2198
  return
1783
2199
  self._send_json(202, {"id": job_id, "status": "queued"})
@@ -1962,6 +2378,14 @@ class WebhookHandler(http.server.BaseHTTPRequestHandler):
1962
2378
  )
1963
2379
  return
1964
2380
 
2381
+ if self.dispatcher.seen_intake(event_type, payload):
2382
+ log_event(event_type, action, "", "duplicate (live run for same intake key)")
2383
+ self._send_json(
2384
+ 200,
2385
+ {"event": event_type, "action": action, "status": "duplicate"},
2386
+ )
2387
+ return
2388
+
1965
2389
  # Hand off to the bounded worker queue so the listener never blocks.
1966
2390
  accepted = self.dispatcher.submit(event_type, payload)
1967
2391
  if not accepted:
@@ -2139,6 +2563,11 @@ def main():
2139
2563
  "No Slack signing secret configured: POST /slack is rejected with "
2140
2564
  "503. Set SLACK_SIGNING_SECRET."
2141
2565
  )
2566
+ elif not os.environ.get("LOKI_SLACK_ALLOWED_USERS", "").strip():
2567
+ logging.warning(
2568
+ "LOKI_SLACK_ALLOWED_USERS is not set: every /slack command is "
2569
+ "rejected. Set Slack user IDs, or * to allow anyone."
2570
+ )
2142
2571
  if not linear_secret:
2143
2572
  logging.warning(
2144
2573
  "No Linear webhook secret configured: POST /linear is rejected with "