loki-mode 11.8.1 → 11.10.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 (90) hide show
  1. package/SKILL.md +2 -2
  2. package/VERSION +1 -1
  3. package/autonomy/lib/backlog.py +15 -3
  4. package/autonomy/lib/workspace.py +95 -4
  5. package/autonomy/loki +189 -108
  6. package/autonomy/trigger-server.py +302 -4
  7. package/bin/loki +1 -1
  8. package/completions/_loki +34 -1
  9. package/completions/loki.bash +10 -3
  10. package/dashboard/__init__.py +1 -1
  11. package/docs/ARCHITECTURE.md +0 -1
  12. package/docs/CLI-REFERENCE.md +1 -1
  13. package/docs/COMPONENTS.md +1 -1
  14. package/docs/DEPLOY-PLAN.md +13 -3
  15. package/docs/INSTALLATION.md +1 -1
  16. package/docs/WORKSPACES.md +1 -1
  17. package/docs/dev/architecture-reference.md +1 -1
  18. package/docs/sentry-intake.md +15 -1
  19. package/docs/v10/D61-SPEED.md +2 -0
  20. package/docs/v10/PROGRESS.md +25 -1
  21. package/loki-ts/dist/loki.js +1016 -946
  22. package/mcp/__init__.py +1 -1
  23. package/package.json +2 -2
  24. package/packages/control-plane/dist/ask-tools-server.js +13 -4
  25. package/packages/control-plane/dist/server.js +457 -279
  26. package/packages/control-plane/src/server/diffstat.ts +10 -2
  27. package/packages/control-plane/src/server/legacy/routes-control.ts +8 -4
  28. package/packages/control-plane/src/server/routes/checkpoints.ts +11 -2
  29. package/packages/control-plane/ui/dist/assets/index-u8cd6TRQ.js +190 -0
  30. package/packages/control-plane/ui/dist/index.html +1 -1
  31. package/plugins/loki-mode/.claude-plugin/plugin.json +1 -1
  32. package/web-app/dist/assets/{AdminPage-BnIg9ilA.js → AdminPage-D3PgG7SH.js} +1 -1
  33. package/web-app/dist/assets/{Avatar-0BTVsQ2A.js → Avatar-Ct77hvlG.js} +1 -1
  34. package/web-app/dist/assets/{Badge-Bbq8CXgn.js → Badge-Cg_gZaJF.js} +1 -1
  35. package/web-app/dist/assets/{Button-CKD4n0ld.js → Button-i9r2G84n.js} +1 -1
  36. package/web-app/dist/assets/{CockpitPage-DJhLNLAz.js → CockpitPage-C3UyLXyp.js} +1 -1
  37. package/web-app/dist/assets/{ComparePage-Di2akuII.js → ComparePage-B1dXHga_.js} +1 -1
  38. package/web-app/dist/assets/{ErrorBoundary-9lhsYG-z.js → ErrorBoundary-CQ_xREw0.js} +1 -1
  39. package/web-app/dist/assets/{EvidenceReceiptPanel-CQDGXvDr.js → EvidenceReceiptPanel-DwUxd_Xi.js} +1 -1
  40. package/web-app/dist/assets/{GitHubIssuesPanel-Y_w2k9Ej.js → GitHubIssuesPanel-C0jAnuAd.js} +1 -1
  41. package/web-app/dist/assets/{GitHubPRsPanel-pMSQedCv.js → GitHubPRsPanel-B4pNXJMz.js} +1 -1
  42. package/web-app/dist/assets/{HomePage-CIAkiGdt.js → HomePage-BC9UTS1l.js} +2 -2
  43. package/web-app/dist/assets/{LoginPage-BES4Pk6k.js → LoginPage-CnpdwK8F.js} +1 -1
  44. package/web-app/dist/assets/{MagicPage-B154fMhi.js → MagicPage-Bj0lZuNp.js} +1 -1
  45. package/web-app/dist/assets/{MetricsPage-BcNyaiUO.js → MetricsPage-yyw3VnfX.js} +1 -1
  46. package/web-app/dist/assets/{NotFoundPage-D28FqYxG.js → NotFoundPage-LMxkJP15.js} +1 -1
  47. package/web-app/dist/assets/{ProjectPage-C_O3u8Jx.js → ProjectPage-2x0MRkNu.js} +3 -3
  48. package/web-app/dist/assets/{ProjectsPage-uqdahuPA.js → ProjectsPage-A8J8o1gj.js} +1 -1
  49. package/web-app/dist/assets/{SettingsPage-DLvVA9OZ.js → SettingsPage-fwvOVcRb.js} +1 -1
  50. package/web-app/dist/assets/{ShowcasePage-BAZP8vsm.js → ShowcasePage-DqI4sUwk.js} +1 -1
  51. package/web-app/dist/assets/{SystemSettingsPage-DPYZkLWh.js → SystemSettingsPage-CyytfNo_.js} +1 -1
  52. package/web-app/dist/assets/{TeamsPage-BgSyb5Pd.js → TeamsPage-BTTe76W1.js} +1 -1
  53. package/web-app/dist/assets/{TemplatesPage-D_Jh5VU1.js → TemplatesPage-CIp3OTlL.js} +1 -1
  54. package/web-app/dist/assets/{TerminalOutput-4v8poDKb.js → TerminalOutput-dX5L4ky-.js} +1 -1
  55. package/web-app/dist/assets/{activity-CE2b6kdw.js → activity-uHXr2AaF.js} +1 -1
  56. package/web-app/dist/assets/{bell-CxJDlLSR.js → bell-D6RbxRMo.js} +1 -1
  57. package/web-app/dist/assets/{bot-C2WuK4zw.js → bot-D31w2Mu6.js} +1 -1
  58. package/web-app/dist/assets/{check-81epVldw.js → check-CoKJksbr.js} +1 -1
  59. package/web-app/dist/assets/{chevron-left-B6EXh_3C.js → chevron-left-Bjx7oX7s.js} +1 -1
  60. package/web-app/dist/assets/{circle-alert-0iqoo-iy.js → circle-alert-DOAh8Jmi.js} +1 -1
  61. package/web-app/dist/assets/{clock-CFrBpNUp.js → clock-DgEXPNWH.js} +1 -1
  62. package/web-app/dist/assets/{cloud-BBSFhUT_.js → cloud-0dXJFzBv.js} +1 -1
  63. package/web-app/dist/assets/{code-xml-BCU2V1rq.js → code-xml-DytQ_bq7.js} +1 -1
  64. package/web-app/dist/assets/{database-D2hvO52K.js → database-Qyjryb72.js} +1 -1
  65. package/web-app/dist/assets/{dollar-sign-CXFA9b6T.js → dollar-sign-BzhTVy1e.js} +1 -1
  66. package/web-app/dist/assets/{file-code-corner-CqTCEEfS.js → file-code-corner-DTdSOALw.js} +1 -1
  67. package/web-app/dist/assets/{file-plus-DYFbkGbs.js → file-plus-VdjOG1RK.js} +1 -1
  68. package/web-app/dist/assets/{globe-DpYHLobk.js → globe-BhxNOfyM.js} +1 -1
  69. package/web-app/dist/assets/{hammer-CZ97SEYo.js → hammer-MQzrw8ee.js} +1 -1
  70. package/web-app/dist/assets/{index-DzswgpRo.js → index-DaxrMbHf.js} +3 -3
  71. package/web-app/dist/assets/{layers-2Tq0edx4.js → layers-C7nusTP-.js} +1 -1
  72. package/web-app/dist/assets/{loader-circle-BFP4kUX-.js → loader-circle-B1qBkgOY.js} +1 -1
  73. package/web-app/dist/assets/{lock-DkKdZH4d.js → lock-BdkOxK2O.js} +1 -1
  74. package/web-app/dist/assets/{package-ChOGD8yf.js → package-RVyK96Zn.js} +1 -1
  75. package/web-app/dist/assets/{plus-hggB1o6b.js → plus-ntmwFRrp.js} +1 -1
  76. package/web-app/dist/assets/{refresh-cw-DYwVLOeF.js → refresh-cw-BJ39b_PP.js} +1 -1
  77. package/web-app/dist/assets/{rotate-ccw-CUdY506q.js → rotate-ccw-uPx_-3vz.js} +1 -1
  78. package/web-app/dist/assets/{scroll-text-Wvidz5uN.js → scroll-text-D9qvXkj-.js} +1 -1
  79. package/web-app/dist/assets/{server-O11QmkCN.js → server-gs4xmD8Y.js} +1 -1
  80. package/web-app/dist/assets/{shield-alert-DXXx27uV.js → shield-alert-O2_YffGI.js} +1 -1
  81. package/web-app/dist/assets/{thumbs-up-DF8ivs3A.js → thumbs-up-B3RVBhQx.js} +1 -1
  82. package/web-app/dist/assets/{trash-2-LA6PW0Nf.js → trash-2-DEUt7qhq.js} +1 -1
  83. package/web-app/dist/assets/{trending-down-Bw_vLKHT.js → trending-down-oyTns4UZ.js} +1 -1
  84. package/web-app/dist/assets/{trending-up-tX8motha.js → trending-up-CdpZK0Oc.js} +1 -1
  85. package/web-app/dist/assets/{upload-CxtTWJd2.js → upload-D9sbjjC8.js} +1 -1
  86. package/web-app/dist/assets/{usePolling-D-P5Iofq.js → usePolling-6ognrnyV.js} +1 -1
  87. package/web-app/dist/assets/{user-dKTcuUFq.js → user-CBKc8QoI.js} +1 -1
  88. package/web-app/dist/index.html +1 -1
  89. package/memory/rag_injector.py +0 -161
  90. package/packages/control-plane/ui/dist/assets/index-DbLzr87u.js +0 -190
@@ -38,6 +38,7 @@ Usage:
38
38
  """
39
39
 
40
40
  import argparse
41
+ import base64
41
42
  import collections
42
43
  import hashlib
43
44
  import hmac
@@ -56,7 +57,7 @@ import sys
56
57
  import threading
57
58
  import time
58
59
  import urllib.parse
59
- from datetime import datetime
60
+ from datetime import datetime, timezone
60
61
  from pathlib import Path
61
62
  from urllib.request import Request, urlopen
62
63
 
@@ -418,6 +419,8 @@ CHILD_ENV_DENYLIST = (
418
419
  "SLACK_BOT_TOKEN",
419
420
  "LINEAR_WEBHOOK_SECRET",
420
421
  "LINEAR_API_KEY",
422
+ "JIRA_WEBHOOK_SECRET",
423
+ "TEAMS_SECURITY_TOKEN",
421
424
  )
422
425
 
423
426
 
@@ -583,6 +586,14 @@ def handle_workflow_run_event(payload, dry_run=False):
583
586
  return summary, status
584
587
 
585
588
 
589
+ def brief_start_args(brief):
590
+ """argv for a delegated PR run. `--brief` forces brief mode so the text is
591
+ never reclassified as a PRD path or issue URL, and `--pr --detach` are only
592
+ honored by `loki start` in brief mode because cmd_start routes them through
593
+ the worktree + PR path."""
594
+ return ["start", "--brief", brief, "--pr", "--detach"]
595
+
596
+
586
597
  def handle_job_event(payload, dry_run=False):
587
598
  """Handle a remotely-submitted job: loki start <spec> --detach.
588
599
 
@@ -594,7 +605,11 @@ def handle_job_event(payload, dry_run=False):
594
605
  spec = payload.get("spec")
595
606
  if not valid_spec(spec):
596
607
  return None, "rejected (invalid spec)"
597
- args = ["start", spec.strip(), "--detach"]
608
+ # Jira/Teams delegation sets pr=True: receipt-gated PR run, never a deploy.
609
+ if payload.get("pr") is True:
610
+ args = brief_start_args(spec.strip())
611
+ else:
612
+ args = ["start", spec.strip(), "--detach"]
598
613
  summary = "job %s: %s" % (payload.get("job_id", "?"), spec.strip())
599
614
  # A remote submitter gates CI on this, so report the BUILD's outcome, not
600
615
  # merely that a build was launched. "fired" (launched) and "passed" must
@@ -693,6 +708,167 @@ def resolve_chat_secrets(env, secret, api_token, sentry_secret):
693
708
  return ("" if slack_bad else slack), ("" if linear_bad else linear)
694
709
 
695
710
 
711
+ # Jira Cloud and Microsoft Teams delegation. Both enqueue a PR-only run.
712
+ JIRA_KEY_RE = re.compile(r"^[A-Z][A-Z0-9_]*-\d+$")
713
+ JIRA_EVENTS = ("jira:issue_created", "jira:issue_updated")
714
+ JIRA_TRIGGER_FIELDS = ("assignee", "labels")
715
+ DELEGATED_BRIEF_MAX = 1500
716
+ HTML_TAG_RE = re.compile(r"<[^>]*>")
717
+
718
+
719
+ def verify_jira_signature(secret, body, signature):
720
+ """X-Hub-Signature: `sha256=` + hex HMAC-SHA256 of the raw body. Jira sends
721
+ no timestamp, so replay is bounded by delivery-id dedup in the handler."""
722
+ if not secret or not signature:
723
+ return False
724
+ sig = signature.strip().lower()
725
+ if not sig.startswith("sha256="):
726
+ return False
727
+ expected = hmac.new(secret.encode("utf-8"), body, hashlib.sha256).hexdigest()
728
+ return constant_time_equals(expected, sig[len("sha256="):])
729
+
730
+
731
+ def sign_teams_body(token, body):
732
+ """Base64 HMAC-SHA256 of the raw body, keyed by the base64-decoded token."""
733
+ key = base64.b64decode(token, validate=True)
734
+ return base64.b64encode(hmac.new(key, body, hashlib.sha256).digest()).decode("ascii")
735
+
736
+
737
+ def verify_teams_signature(token, body, authorization):
738
+ """Authorization: `HMAC <base64>`. Fails closed on a bad token or header."""
739
+ if not token or not authorization:
740
+ return False
741
+ scheme, _, value = authorization.strip().partition(" ")
742
+ if scheme.upper() != "HMAC" or not value.strip():
743
+ return False
744
+ try:
745
+ expected = sign_teams_body(token, body)
746
+ except ValueError:
747
+ return False
748
+ return constant_time_equals(expected, value.strip())
749
+
750
+
751
+ def teams_dedup_key(activity):
752
+ """Dedup key for a Teams activity: activity ids are unique only within a
753
+ conversation, so scope by conversation id plus serviceUrl and tenant when
754
+ present. Empty string (never deduplicated) when the activity id is absent."""
755
+ act_id = activity.get("id")
756
+ if not isinstance(act_id, str) or not act_id:
757
+ return ""
758
+
759
+ def _s(v):
760
+ return v if isinstance(v, str) else ""
761
+
762
+ conv = activity.get("conversation")
763
+ conv = conv if isinstance(conv, dict) else {}
764
+ cdata = activity.get("channelData")
765
+ tenant = cdata.get("tenant") if isinstance(cdata, dict) else None
766
+ tenant_id = _s(tenant.get("id")) if isinstance(tenant, dict) else ""
767
+ tenant_id = tenant_id or _s(conv.get("tenantId"))
768
+ return "teams:" + json.dumps(
769
+ [_s(conv.get("id")), _s(activity.get("serviceUrl")), tenant_id, act_id])
770
+
771
+
772
+ def teams_fresh(activity, now=None):
773
+ """The activity's ISO-8601 `timestamp` must be within the skew window."""
774
+ raw = activity.get("timestamp")
775
+ if not isinstance(raw, str) or not raw:
776
+ return False
777
+ try:
778
+ dt = datetime.fromisoformat(raw.strip().replace("Z", "+00:00"))
779
+ except ValueError:
780
+ return False
781
+ if dt.tzinfo is None:
782
+ dt = dt.replace(tzinfo=timezone.utc)
783
+ now = time.time() if now is None else now
784
+ return abs(now - dt.timestamp()) <= SIGNATURE_MAX_SKEW_S
785
+
786
+
787
+ def _csv_set(value):
788
+ return frozenset(x.strip() for x in value.split(",") if x.strip())
789
+
790
+
791
+ def _one_line(text, limit=DELEGATED_BRIEF_MAX):
792
+ text = " ".join(CONTROL_CHARS_RE.sub(" ", str(text or "")).split())
793
+ return text.encode("utf-8")[:limit].decode("utf-8", errors="ignore")
794
+
795
+
796
+ def jira_trigger_matches(payload, assignees, labels):
797
+ """True when the issue is assigned to a configured user or carries a
798
+ configured label. An update only counts if it changed assignee or labels,
799
+ so unrelated edits never re-fire a run."""
800
+ issue = payload.get("issue")
801
+ fields = issue.get("fields") if isinstance(issue, dict) else None
802
+ if not isinstance(fields, dict):
803
+ return False
804
+ if payload.get("webhookEvent") == "jira:issue_updated":
805
+ items = (payload.get("changelog") or {}).get("items")
806
+ changed = ({i.get("field") for i in items if isinstance(i, dict)}
807
+ if isinstance(items, list) else set())
808
+ if not changed.intersection(JIRA_TRIGGER_FIELDS):
809
+ return False
810
+ who = fields.get("assignee")
811
+ ids = set()
812
+ if isinstance(who, dict):
813
+ for k in ("accountId", "emailAddress", "displayName"):
814
+ if isinstance(who.get(k), str):
815
+ ids.add(who[k])
816
+ if ids.intersection(assignees):
817
+ return True
818
+ have = fields.get("labels")
819
+ return isinstance(have, list) and bool(
820
+ {x for x in have if isinstance(x, str)}.intersection(labels))
821
+
822
+
823
+ def adf_text(node, out=None):
824
+ """Flatten an Atlassian Document Format tree to its text nodes."""
825
+ top = out is None
826
+ out = [] if top else out
827
+ if isinstance(node, dict):
828
+ if isinstance(node.get("text"), str):
829
+ out.append(node["text"])
830
+ for child in node.get("content") or []:
831
+ adf_text(child, out)
832
+ elif isinstance(node, list):
833
+ for child in node:
834
+ adf_text(child, out)
835
+ return " ".join(out) if top else None
836
+
837
+
838
+ def build_jira_brief(issue):
839
+ fields = issue.get("fields") or {}
840
+ desc = fields.get("description")
841
+ if isinstance(desc, dict):
842
+ desc = adf_text(desc)
843
+ parts = ["Resolve Jira issue %s: %s." % (issue.get("key"), fields.get("summary") or "")]
844
+ if isinstance(desc, str) and desc.strip():
845
+ parts.append(desc)
846
+ parts.append("Reproduce with a regression test, then fix the root cause.")
847
+ return _one_line(" ".join(parts))
848
+
849
+
850
+ def build_teams_brief(activity):
851
+ text = re.sub(r"<at>.*?</at>", " ", str(activity.get("text") or ""), flags=re.S)
852
+ return _one_line(HTML_TAG_RE.sub(" ", text))
853
+
854
+
855
+ def resolve_delegation_secrets(env, others):
856
+ """Return (jira_secret, teams_token), blanking any equal to another
857
+ credential in `others` or to each other."""
858
+ jira = env.get("JIRA_WEBHOOK_SECRET", "")
859
+ teams = env.get("TEAMS_SECURITY_TOKEN", "")
860
+ others = [x for x in others if x]
861
+ out = []
862
+ for name, val, peer in (("Jira", jira, teams), ("Teams", teams, jira)):
863
+ bad = bool(val) and any(constant_time_equals(val, o)
864
+ for o in others + ([peer] if peer else []))
865
+ if bad:
866
+ logging.error("The %s secret is identical to another credential. "
867
+ "They MUST differ; disabling its route.", name)
868
+ out.append("" if bad else val)
869
+ return tuple(out)
870
+
871
+
696
872
  def _post_json(url, data, headers):
697
873
  """POST JSON with a bounded timeout. Raises on any failure; callers log."""
698
874
  req = Request(url, data=json.dumps(data).encode("utf-8"),
@@ -798,7 +974,7 @@ def handle_sentry_event(payload, dry_run=False):
798
974
  brief = build_sentry_brief(norm)
799
975
  if not valid_spec(brief):
800
976
  return None, "rejected (invalid brief)"
801
- args = ["start", brief, "--pr", "--detach"]
977
+ args = brief_start_args(brief)
802
978
  summary = "sentry issue %s" % norm.get("number", "?")
803
979
  success = run_loki_command(args, dry_run=dry_run)
804
980
  status = "fired" if success else "error"
@@ -1129,6 +1305,12 @@ class WebhookHandler(http.server.BaseHTTPRequestHandler):
1129
1305
  # distinct from every other credential.
1130
1306
  slack_secret = ""
1131
1307
  linear_secret = ""
1308
+ # Jira webhook secret (/jira), Teams outgoing-webhook token (/teams), and
1309
+ # the Jira trigger filters (assignee ids/emails/names, labels).
1310
+ jira_secret = ""
1311
+ teams_secret = ""
1312
+ jira_assignees = frozenset()
1313
+ jira_labels = frozenset()
1132
1314
  dispatcher = None
1133
1315
  # Ed25519 key that attests receipts, and the retired public keys that keep
1134
1316
  # pre-rotation receipts verifiable. Both default to unconfigured, which
@@ -1414,7 +1596,7 @@ class WebhookHandler(http.server.BaseHTTPRequestHandler):
1414
1596
  return
1415
1597
  self._send_json(202, {"id": job_id, "status": "queued"})
1416
1598
 
1417
- def _enqueue_job(self, spec, reply_target=None):
1599
+ def _enqueue_job(self, spec, reply_target=None, pr=False):
1418
1600
  """Record and enqueue one validated job; shared by /jobs, /slack, /linear.
1419
1601
 
1420
1602
  Returns the job id, or None after sending 429 when the queue is full.
@@ -1422,6 +1604,8 @@ class WebhookHandler(http.server.BaseHTTPRequestHandler):
1422
1604
  """
1423
1605
  job_id = secrets.token_urlsafe(12)
1424
1606
  job = {"spec": spec, "job_id": job_id}
1607
+ if pr:
1608
+ job["pr"] = True
1425
1609
 
1426
1610
  # Record BEFORE enqueueing. A worker can pick the job up the instant
1427
1611
  # submit() returns and write "running"/"fired"; record_job is
@@ -1546,6 +1730,99 @@ class WebhookHandler(http.server.BaseHTTPRequestHandler):
1546
1730
  return
1547
1731
  self._send_json(202, {"id": job_id, "status": "queued"})
1548
1732
 
1733
+ def _handle_jira(self):
1734
+ """POST /jira -- signed Jira Cloud webhook; enqueues a PR-only run."""
1735
+ body = self._read_body()
1736
+ if body is None:
1737
+ return
1738
+ if not self.jira_secret:
1739
+ log_event(JOB_EVENT, "", "", "rejected (no jira webhook secret configured)")
1740
+ self._send_json(503, {"error": "JIRA_WEBHOOK_SECRET not configured"})
1741
+ return
1742
+ if not verify_jira_signature(
1743
+ self.jira_secret, body, self.headers.get("X-Hub-Signature", "")):
1744
+ logging.warning("Invalid Jira signature from %s", self.address_string())
1745
+ log_event(JOB_EVENT, "", "", "rejected (invalid jira signature)")
1746
+ self._send_json(401, {"error": "invalid signature"})
1747
+ return
1748
+ try:
1749
+ payload = json.loads(body)
1750
+ except ValueError:
1751
+ self._send_json(400, {"error": "invalid JSON"})
1752
+ return
1753
+ if not isinstance(payload, dict):
1754
+ self._send_json(400, {"error": "payload must be a JSON object"})
1755
+ return
1756
+ if (payload.get("webhookEvent") not in JIRA_EVENTS
1757
+ or not jira_trigger_matches(
1758
+ payload, self.jira_assignees, self.jira_labels)):
1759
+ self._send_json(200, {"status": "skipped"})
1760
+ return
1761
+ issue = payload["issue"]
1762
+ key = issue.get("key")
1763
+ if not isinstance(key, str) or not JIRA_KEY_RE.match(key):
1764
+ self._send_json(400, {"error": "invalid issue key"})
1765
+ return
1766
+ brief = build_jira_brief(issue)
1767
+ if not valid_spec(brief):
1768
+ self._send_json(400, {"error": "invalid brief"})
1769
+ return
1770
+ # Jira has no signed timestamp, and the delivery id header is not covered
1771
+ # by the HMAC, so also dedup on a hash of the signed body: a captured body
1772
+ # cannot be replayed by changing or dropping the header.
1773
+ dup_id = self.dispatcher.seen_delivery(
1774
+ self.headers.get("X-Atlassian-Webhook-Identifier", ""))
1775
+ dup_body = self.dispatcher.seen_delivery(
1776
+ "jira-body:" + hashlib.sha256(body).hexdigest())
1777
+ if dup_id or dup_body:
1778
+ self._send_json(200, {"status": "duplicate"})
1779
+ return
1780
+ job_id = self._enqueue_job(brief, pr=True)
1781
+ if job_id is None:
1782
+ return
1783
+ self._send_json(202, {"id": job_id, "status": "queued"})
1784
+
1785
+ def _handle_teams(self):
1786
+ """POST /teams -- Teams outgoing webhook; enqueues a PR-only run."""
1787
+ body = self._read_body()
1788
+ if body is None:
1789
+ return
1790
+ if not self.teams_secret:
1791
+ log_event(JOB_EVENT, "", "", "rejected (no teams security token configured)")
1792
+ self._send_json(503, {"error": "TEAMS_SECURITY_TOKEN not configured"})
1793
+ return
1794
+ if not verify_teams_signature(
1795
+ self.teams_secret, body, self.headers.get("Authorization", "")):
1796
+ logging.warning("Invalid Teams signature from %s", self.address_string())
1797
+ log_event(JOB_EVENT, "", "", "rejected (invalid teams signature)")
1798
+ self._send_json(401, {"error": "invalid signature"})
1799
+ return
1800
+ try:
1801
+ activity = json.loads(body)
1802
+ except ValueError:
1803
+ self._send_json(400, {"error": "invalid JSON"})
1804
+ return
1805
+ if not isinstance(activity, dict):
1806
+ self._send_json(400, {"error": "payload must be a JSON object"})
1807
+ return
1808
+ if not teams_fresh(activity):
1809
+ log_event(JOB_EVENT, "", "", "rejected (stale teams timestamp)")
1810
+ self._send_json(401, {"error": "stale or missing timestamp"})
1811
+ return
1812
+ brief = build_teams_brief(activity)
1813
+ if not valid_spec(brief):
1814
+ self._send_json(200, {"type": "message",
1815
+ "text": "Usage: @loki <brief>. One-line brief, "
1816
+ "not starting with '-'."})
1817
+ return
1818
+ if self.dispatcher.seen_delivery(teams_dedup_key(activity)):
1819
+ self._send_json(200, {"type": "message", "text": "Duplicate, already queued."})
1820
+ return
1821
+ job_id = self._enqueue_job(brief, pr=True)
1822
+ if job_id is None:
1823
+ return
1824
+ self._send_json(200, {"type": "message", "text": "Queued loki job %s" % job_id})
1825
+
1549
1826
  def _handle_sentry(self):
1550
1827
  """POST /sentry -- Sentry alert, HMAC-verified, enqueues a fix run."""
1551
1828
  body = self._read_body()
@@ -1597,6 +1874,12 @@ class WebhookHandler(http.server.BaseHTTPRequestHandler):
1597
1874
  if path == "/linear":
1598
1875
  self._handle_linear()
1599
1876
  return
1877
+ if path == "/jira":
1878
+ self._handle_jira()
1879
+ return
1880
+ if path == "/teams":
1881
+ self._handle_teams()
1882
+ return
1600
1883
  if path != "/webhook":
1601
1884
  self._send_json(404, {"error": "not found"})
1602
1885
  return
@@ -1782,6 +2065,11 @@ def main():
1782
2065
  slack_secret, linear_secret = resolve_chat_secrets(
1783
2066
  os.environ, secret, api_token, sentry_secret)
1784
2067
 
2068
+ jira_secret, teams_secret = resolve_delegation_secrets(
2069
+ os.environ, [secret, api_token, sentry_secret, slack_secret, linear_secret])
2070
+ WebhookHandler.jira_assignees = _csv_set(os.environ.get("JIRA_ASSIGNEES", ""))
2071
+ WebhookHandler.jira_labels = _csv_set(os.environ.get("JIRA_LABELS", ""))
2072
+
1785
2073
  # Persist resolved values, but never write the secret to disk.
1786
2074
  config["port"] = port
1787
2075
  config["dry_run"] = dry_run
@@ -1798,6 +2086,8 @@ def main():
1798
2086
  WebhookHandler.sentry_secret = sentry_secret
1799
2087
  WebhookHandler.slack_secret = slack_secret
1800
2088
  WebhookHandler.linear_secret = linear_secret
2089
+ WebhookHandler.jira_secret = jira_secret
2090
+ WebhookHandler.teams_secret = teams_secret
1801
2091
  WebhookHandler.dispatcher = dispatcher
1802
2092
 
1803
2093
  signing_key, signing_kid = load_signing_key(auto_generate=False)
@@ -1854,6 +2144,14 @@ def main():
1854
2144
  "No Linear webhook secret configured: POST /linear is rejected with "
1855
2145
  "503. Set LINEAR_WEBHOOK_SECRET."
1856
2146
  )
2147
+ if not jira_secret:
2148
+ logging.warning(
2149
+ "No Jira webhook secret configured: POST /jira is rejected with "
2150
+ "503. Set JIRA_WEBHOOK_SECRET.")
2151
+ if not teams_secret:
2152
+ logging.warning(
2153
+ "No Teams security token configured: POST /teams is rejected with "
2154
+ "503. Set TEAMS_SECURITY_TOKEN.")
1857
2155
  if not secret:
1858
2156
  logging.warning(
1859
2157
  "No webhook secret configured: ALL webhooks will be rejected with "
package/bin/loki CHANGED
@@ -794,7 +794,7 @@ fi
794
794
  # Two-token routes (provider show/list, memory list/index) match on the first
795
795
  # token only; the Bun dispatcher handles subcommand routing internally.
796
796
  case "${1:-}" in
797
- version|--version|-v|status|stats|doctor|provider|memory|rollback|internal|kpis|trust|proof|receipt|wiki|crash|control|contract|slack|queue|issues|answer|undo|verify-pr|completion|__complete)
797
+ version|--version|-v|status|stats|doctor|provider|memory|rollback|internal|kpis|estimate|trust|proof|receipt|wiki|crash|control|contract|slack|queue|issues|answer|undo|verify-pr|completion|__complete)
798
798
  # v7.5.2: rollback added (wires loki-ts/src/commands/rollback.ts).
799
799
  # v7.5.3: internal added for autonomy/run.sh phase1-hooks calls.
800
800
  # v7.5.28: kpis added (Phase K MVP: read-only KPI snapshot).
package/completions/_loki CHANGED
@@ -56,6 +56,13 @@ function _loki {
56
56
  agent)
57
57
  _loki_agent
58
58
  ;;
59
+ estimate)
60
+ _arguments \
61
+ '--json[Output machine-readable JSON]' \
62
+ '--model[Model whose rough prior to use]:model:' \
63
+ '--provider[Provider whose default model is used]:provider:' \
64
+ '--help[Show help]'
65
+ ;;
59
66
  status)
60
67
  _arguments \
61
68
  '--json[Output machine-readable JSON]' \
@@ -162,6 +169,27 @@ function _loki {
162
169
  code)
163
170
  _loki_code
164
171
  ;;
172
+ deploy)
173
+ _arguments \
174
+ '--dir[Project directory to scan]:directory:_directories' \
175
+ '--no-clip[Do not copy the command to the clipboard]' \
176
+ '--execute[Run the deploy command if the receipt gate passes]' \
177
+ '--canary[With --execute: staged rollout (Vercel promote, Fly canary)]' \
178
+ '--allow-unanchored[Proceed when the receipt records no baseline]' \
179
+ '--health-url[Health probe URL]:url:' \
180
+ '--auto-rollback[Roll back on a failed health probe]' \
181
+ '--help[Show help]'
182
+ ;;
183
+ verify)
184
+ _arguments \
185
+ '--pubkey[Check against this public key only]:key file:_files' \
186
+ '--sign-deploy[Sign a loki.deploy/1 deploy record in place]:record:_files' \
187
+ '--receipt[Cited receipt file for a deploy record]:receipt:_files' \
188
+ '--max-age[Reject a receipt older than DUR]:duration:' \
189
+ '--seal-composite[Sign a workspace composite]:run dir:_directories' \
190
+ '--help[Show help]' \
191
+ '*:receipt or deploy record:_files'
192
+ ;;
165
193
  esac
166
194
  ;;
167
195
  esac
@@ -231,7 +259,7 @@ function _loki_commands {
231
259
  'plan:Preview the build plan for a spec'
232
260
  'report:Reporting commands (cost, kpis, share)'
233
261
  'cost:Show cost of recent runs (--detail for cache + trend)'
234
- 'estimate:Project run cost (requires --iterations N)'
262
+ 'estimate:Estimate run cost from run history (--json, --model, --provider)'
235
263
  'kpis:Key performance indicators'
236
264
  'stats:Session statistics'
237
265
  'preview:Open the running app preview'
@@ -313,6 +341,11 @@ function _loki_start {
313
341
  _arguments \
314
342
  '--provider[AI provider]:provider name:(claude cline codex aider opencode)' \
315
343
  '--parallel[Parallel mode]' \
344
+ '--brief[Zero-config brief mode]:brief text:' \
345
+ '--worktree[Git worktree isolation]' \
346
+ '--pr[Worktree plus auto-create PR]' \
347
+ '--ship[Worktree plus PR plus auto-merge]' \
348
+ '--detach[Run in background (implies --worktree)]' \
316
349
  '--workspace[Run a loki.yaml workspace for the brief]:workspace name:' \
317
350
  '--deploy[Deploy after a VERIFIED run via loki deploy --execute]' \
318
351
  '--health-url[With --deploy: health probe URL]:url:' \
@@ -27,7 +27,7 @@ _loki_completion() {
27
27
 
28
28
  # If the word starts with a dash, show flags
29
29
  if [[ "$cur" == -* ]]; then
30
- local flags="--provider --max-iterations --parallel --background --bg --simple --complex --github --no-dashboard --sandbox --skip-memory --spec --spec-first --yes --budget --attempts --model --isolation --max-cost --no-pr --explain-route --workspace --deploy --health-url --auto-rollback --help"
30
+ local flags="--provider --max-iterations --parallel --background --bg --simple --complex --github --no-dashboard --sandbox --skip-memory --spec --spec-first --yes --budget --attempts --model --isolation --max-cost --no-pr --explain-route --brief --worktree --pr --ship --detach --workspace --deploy --health-url --auto-rollback --help"
31
31
  COMPREPLY=( $(compgen -W "${flags}" -- "$cur") )
32
32
  return 0
33
33
  fi
@@ -120,6 +120,13 @@ _loki_completion() {
120
120
  COMPREPLY=( $(compgen -W "${syslog_cmds}" -- "$cur") )
121
121
  ;;
122
122
 
123
+ estimate)
124
+ if [[ "$cur" == -* ]]; then
125
+ COMPREPLY=( $(compgen -W "--json --model --provider --help" -- "$cur") )
126
+ return 0
127
+ fi
128
+ ;;
129
+
123
130
  status)
124
131
  if [[ "$cur" == -* ]]; then
125
132
  COMPREPLY=( $(compgen -W "--json --help" -- "$cur") )
@@ -142,14 +149,14 @@ _loki_completion() {
142
149
 
143
150
  deploy)
144
151
  if [[ "$cur" == -* ]]; then
145
- COMPREPLY=( $(compgen -W "--execute --health-url --auto-rollback --dir --no-clip --allow-unanchored --help" -- "$cur") )
152
+ COMPREPLY=( $(compgen -W "--execute --canary --health-url --auto-rollback --dir --no-clip --allow-unanchored --help" -- "$cur") )
146
153
  return 0
147
154
  fi
148
155
  ;;
149
156
 
150
157
  verify)
151
158
  if [[ "$cur" == -* ]]; then
152
- COMPREPLY=( $(compgen -W "--base --out --block-on --no-llm --json --explain --check-fresh --max-age --hosted --seal-composite --help" -- "$cur") )
159
+ COMPREPLY=( $(compgen -W "--base --out --block-on --no-llm --json --explain --check-fresh --max-age --hosted --seal-composite --sign-deploy --receipt --pubkey --help" -- "$cur") )
153
160
  return 0
154
161
  fi
155
162
  ;;
@@ -7,6 +7,6 @@ Modules:
7
7
  control: Session control helpers (status, atomic writes, origin check)
8
8
  """
9
9
 
10
- __version__ = "11.8.1"
10
+ __version__ = "11.10.1"
11
11
 
12
12
  __all__ = ["__version__"]
@@ -209,7 +209,6 @@ memory/ Memory system (Python package, ~19 modules)
209
209
  embeddings.py Vector embeddings (optional)
210
210
  vector_index.py Vector search index
211
211
  cross_project.py Cross-project knowledge transfer
212
- rag_injector.py RAG context injection into prompts
213
212
  knowledge_graph.py Knowledge graph over memories
214
213
  layers/ Progressive-disclosure loading
215
214
 
@@ -1,6 +1,6 @@
1
1
  # CLI reference
2
2
 
3
- Generated from `loki-ts/src/cli/registry.ts` by `scripts/generate-stale-zero.sh` for v11.8.1. Do not edit by hand.
3
+ Generated from `loki-ts/src/cli/registry.ts` by `scripts/generate-stale-zero.sh` for v11.10.1. Do not edit by hand.
4
4
  Hidden and legacy-only commands are omitted; see `docs/v10/CLI-MODERN.md` for the full inventory.
5
5
 
6
6
  | Command | Description |
@@ -132,7 +132,7 @@ across projects.
132
132
  - `consolidation.py` - Episodic-to-semantic consolidation pipeline.
133
133
  - `token_economics.py` - Discovery vs read token accounting.
134
134
  - `embeddings.py`, `vector_index.py` - Optional embedding-based similarity.
135
- - `cross_project.py`, `knowledge_graph.py`, `rag_injector.py` - Cross-project
135
+ - `cross_project.py`, `knowledge_graph.py` - Cross-project
136
136
  recall, knowledge graph, and RAG prompt injection (v7.1.0+).
137
137
  - `app_graph.py`, `error_log.py`, `ingest.py`, `namespace.py`, `replay.py`,
138
138
  `unified_access.py` - Supporting modules.
@@ -7,9 +7,19 @@ clean tree, a destructive-op refusal and a policy permission (`.loki/policies.js
7
7
  deployed URL after the command. A non-2xx result is recorded as FAILED_HEALTH and exits non-zero;
8
8
  with `--auto-rollback` it also runs the provider's documented rollback verb (`vercel rollback`,
9
9
  `fly releases rollback <captured version>`; Netlify and Cloudflare print a manual note). With no
10
- URL, health is recorded as "unchecked", never healthy. The deploy record lives in `.loki/deploys`
11
- and is not part of the signed receipt. `loki start --deploy [--health-url U] [--auto-rollback]`
12
- chains the same gated command after a start. Not built: canary or staged rollout, and receipt-bound deploy records.
10
+ URL, health is recorded as "unchecked", never healthy. The deploy record in `.loki/deploys` is a
11
+ signed `loki.deploy/1` attestation bound to the receipt that authorized it (fields: receipt_sha256,
12
+ head_sha, target, command, health_status, health_url, rollback_attempted, rollback_exit,
13
+ started_at, finished_at). It is signed with the existing receipt signer (Ed25519, no new crypto)
14
+ and the receipt itself is unchanged. `loki verify <record.json>` re-checks it offline (also with
15
+ `--pubkey FILE`; `--receipt FILE` names the cited receipt when it is not beside the record). It
16
+ fails closed: a flipped byte is TAMPERED, an unsigned record or an unknown key is UNCHECKED, and a
17
+ cited receipt that is not VERIFIED is never reported as VERIFIED.
18
+ `loki deploy --execute --canary` is a staged rollout using only documented provider verbs: Vercel
19
+ deploys without `--prod`, health-checks the preview URL, then runs `vercel promote <url>` (a failed
20
+ probe never promotes); Fly runs `fly deploy --strategy canary`; Netlify and Cloudflare document no
21
+ staged path and refuse `--canary` with a sentence saying so.
22
+ `loki start --deploy [--health-url U] [--auto-rollback]` chains the same gated command after a start.
13
23
 
14
24
  Everything below is the original print-only design plan and is kept for history.
15
25
 
@@ -2,7 +2,7 @@
2
2
 
3
3
  Loki Mode, by [Autonomi](https://www.autonomi.dev/), turns a spec or an issue into a verified pull request with a receipt you can check. This page covers installing it, a first run, and the options around it.
4
4
 
5
- **Version:** v11.8.1
5
+ **Version:** v11.10.1
6
6
 
7
7
  **Engine note:** `loki start` is the primary command. `loki start`, `loki "<task>"`, `loki owner/repo#N` and `loki quick` all run the Loki 10 engine, whatever the flag order or input form (PRD file, issue ref, task or one-word brief). The previous loop runs only on `--engine legacy`, `LOKI_ENGINE=legacy`, or for one call when a flag Loki 10 cannot honor is present. See the [user guide](v10/GUIDE.md) and the [5-minute quickstart](QUICKSTART.md).
8
8
 
@@ -37,7 +37,7 @@ loki workspace status <run-id>
37
37
  - The repo that owns the issue ref gets the ref; other repos get a task text naming the ref and the sibling worktree paths as read-only context.
38
38
  - After all repos finish, the `integration` command runs once from the run directory with `LOKI_WS_DIR_<OWNER>_<REPO>` pointing at each worktree. A timeout counts as failed. With no integration configured the status is `not_configured`.
39
39
  - Evidence is written to `.loki/workspaces/<name>/<run-id>/integration.json`: head SHA per repo, exit code, log sha256. When every repo succeeded, integration did not fail and every child sealed a receipt, the run also seals a signed `composite.json` that binds integration.json and every child receipt (see "Verify a workspace run").
40
- - `loki workspace infer <name>` prints suggested `after:` edges read from package.json, go.mod and pyproject.toml dependencies on other listed repos. It is print-only and cycle-checked; it never edits loki.yaml. Manifests only: no OpenAPI or import inference.
40
+ - `loki workspace infer <name>` prints suggested `after:` edges read from package.json, go.mod and pyproject.toml dependencies on other listed repos. It also suggests an edge from (1) a source import (TS/JS import or require, Python import, Go import path) of a sibling's package or module name even when the manifest does not declare it, scanning at most 400 source files per repo, and (2) a client generator config (openapitools.json, openapi-generator*.yaml, orval.config.*) whose input spec path lies inside a sibling checkout. Each inferred edge prints its evidence as `file:line`. It is print-only and cycle-checked; it never edits loki.yaml.
41
41
  - `plan: true` (or `LOKI_WORKSPACE_PLAN=1`) makes one provider call that decomposes the brief per repo into `<run-dir>/plan.json`. A valid plan gives each listed repo its own task; an invalid one falls back to the generic text and prints the reason.
42
42
  - `integration: {per_stage: true}` also runs the integration command after each topological layer. A failing stage SKIPs every later layer, so a broken contract stops before downstream repos build on it.
43
43
  - Without `path`, a repo is cloned to `~/.loki/repos/owner__name`.
@@ -39,7 +39,7 @@ model explicitly rather than inherit the session model.
39
39
  - **Progressive Disclosure**: 3-layer loading (index, timeline, full details)
40
40
  - **Token Economics**: Discovery vs read token tracking
41
41
  - **Vector Search**: Optional embedding-based similarity (sentence-transformers)
42
- - **Cross-project + RAG injection**: `memory/cross_project.py`, `memory/rag_injector.py`, `memory/knowledge_graph.py` (added v7.x)
42
+ - **Cross-project index + knowledge graph**: `memory/cross_project.py`, `memory/knowledge_graph.py` (added v7.x)
43
43
  - **Managed memory client**: `memory/managed_memory/` -- gated on `LOKI_MANAGED_MEMORY=true`. See `skills/memory.md`.
44
44
  - **CLI**: `loki memory index|timeline|consolidate|economics|retrieve|episode|pattern|skill|vectors`
45
45
  - **API**: REST endpoints at `/api/memory/*`
@@ -23,7 +23,7 @@ The token is sent only to the Sentry host through a curl config on stdin, never
23
23
  The output is a receipt-gated PR only, never a deploy. Whether the PR opens is still decided by the policy file (`autonomy`).
24
24
 
25
25
 
26
- ## Slack and Linear delegation
26
+ ## Slack, Linear, Jira and Teams delegation
27
27
 
28
28
  `loki trigger` server setup for delegating a job from chat or a tracker. Both routes enqueue through the same job queue and runner as `POST /jobs`; a completion summary goes back to the origin. Nothing unauthenticated can start a job or choose a reply URL.
29
29
 
@@ -43,4 +43,18 @@ Linear (`POST /linear`):
43
43
  3. A created issue enqueues `loki start linear:<identifier> --detach` and answers 202 with the job id. Other events are skipped.
44
44
  4. If `LINEAR_API_KEY` is set, a comment with the result is posted on the issue through `https://api.linear.app/graphql`. Without it nothing is posted.
45
45
 
46
+ Jira (`POST /jira`):
47
+
48
+ 1. Set `JIRA_WEBHOOK_SECRET` (the secret on a Jira Cloud webhook) and point the webhook at `/jira`. Set `JIRA_ASSIGNEES` (comma-separated accountId, email or display name) and/or `JIRA_LABELS`. With neither set, every event is skipped.
49
+ 2. `X-Hub-Signature` is `sha256=` + hex HMAC-SHA256 of the raw body. Bad or missing signature: 401. Jira signs no timestamp and the `X-Atlassian-Webhook-Identifier` header is not covered by the HMAC, so replay is bounded two ways: a repeated delivery id, or a repeated signed body (sha256 of the raw body, so changing or dropping the header does not help), answers `duplicate` and queues nothing. A replay of a body Jira never sent twice still needs the secret to forge.
50
+ 3. `jira:issue_created`, or `jira:issue_updated` whose changelog touches assignee or labels, runs only when the issue is assigned to a configured user or has a configured label. The key, summary and description are folded into a one-line brief (1500 bytes max; an Atlassian Document Format description is flattened to its text) and run as `loki start --brief "<brief>" --pr --detach`. Answers 202 with the job id.
51
+
52
+ Teams (`POST /teams`):
53
+
54
+ 1. Set `TEAMS_SECURITY_TOKEN` (the base64 token Teams shows when you create the outgoing webhook) and point the webhook at `/teams`.
55
+ 2. `Authorization: HMAC <base64>` is the base64 HMAC-SHA256 of the raw body keyed by the base64-decoded token. Bad signature: 401. The activity `timestamp` must be within 300s; a stale or missing one is refused with 401.
56
+ 3. The message text (with `<at>` mentions and tags removed) is the brief and runs as `loki start --brief "<brief>" --pr --detach`. Teams gets a synchronous reply whose text starts with "Queued" and ends with the job id. A repeated activity id is not queued twice.
57
+
58
+ Jira and Teams runs are receipt-gated PR runs only: the server always passes `--pr` and never a deploy argument, so shipping stays a separate gated step. `--brief` forces brief mode, so text that ends in `.md` or contains an issue URL is never reclassified, and `loki start --brief ... --pr/--worktree/--detach` runs in an isolated worktree, detached, and opens a PR carrying the Evidence Receipt (the same machinery as `loki start <issue>`). Sentry alerts use the same form. Neither secret reaches the `loki start` child environment, and both must differ from every other credential. A missing secret answers 503 naming the variable.
59
+
46
60
  Reply targets are stored server side keyed by job id, captured only after verification. Notifier errors are logged and never fail the job. Poll status with `GET /jobs/<id>`.
@@ -101,3 +101,5 @@ Group exit codes: 0 = every unit VERIFIED and the group receipt VERIFIED; 4 = NO
101
101
  Resume: rerun the same `loki start`. Each milestone group is found again by the hash of its body, so finished milestones re-verify and return 0 fast and the run continues at the failed one. No extra state is kept. `LOKI_GROUP_RESUME=0` disables auto-resume and starts every group fresh. Check any group receipt with `loki verify .loki/groups/<gid>/receipt.json`.
102
102
 
103
103
  Test command precedence: `LOKI_GROUP_TEST_CMD` (whitespace split) wins over the repo's own test script. Test: `loki-ts/tests/engine10/route_group.test.ts`.
104
+
105
+ Fix units: when group integration stops on a merge conflict or a failing suite, one bounded fix unit runs through the same unit launcher (same scoped env, one slot) from the group branch, then integration restarts once. A conflict fix takes the conflicting unit's place; a suite fix goes last and receives the failing test names and output tail. Max one fix per conflict and 2 per group; `LOKI_GROUP_FIX_UNITS=0` restores stop-and-name. The suite, every unit receipt and the group Seal still decide the verdict, and a failed fix keeps the stop-and-name output (exit 1). A fix unit is stored in `dag.json` and resumes like any unit. Code: `loki-ts/src/features/speed/fix_units.ts`; tests: `loki-ts/tests/engine10/seal_group.test.ts`.