loki-mode 11.6.0 → 11.8.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +19 -2
- package/SKILL.md +2 -2
- package/VERSION +1 -1
- package/autonomy/lib/backlog.py +48 -4
- package/autonomy/lib/chat.sh +60 -3
- package/autonomy/lib/loki_yaml.py +8 -2
- package/autonomy/lib/workspace.py +267 -11
- package/autonomy/loki +315 -21
- package/autonomy/trigger-server.py +454 -4
- package/autonomy/verify.sh +9 -0
- package/completions/_loki +4 -0
- package/completions/loki.bash +20 -2
- package/dashboard/__init__.py +1 -1
- package/docs/CLI-REFERENCE.md +2 -1
- package/docs/DEPLOY-PLAN.md +17 -8
- package/docs/EVALUATING.md +8 -0
- package/docs/INSTALLATION.md +1 -1
- package/docs/WORKSPACES.md +15 -2
- package/docs/dev/architecture-reference.md +8 -0
- package/docs/sentry-intake.md +33 -0
- package/docs/v10/CLI-MODERN.md +1 -1
- package/docs/v10/D61-SPEED.md +12 -0
- package/docs/v10/PROGRESS.md +10 -0
- package/loki-ts/dist/loki.js +1055 -1004
- package/mcp/__init__.py +1 -1
- package/package.json +2 -2
- package/packages/control-plane/dist/server.js +789 -634
- package/plugins/loki-mode/.claude-plugin/plugin.json +1 -1
- package/schemas/loki-yaml.schema.json +5 -2
- package/src/policies/engine.js +41 -2
- package/src/policies/types.js +49 -0
- package/web-app/dist/assets/{AdminPage-DhXdJNQk.js → AdminPage-BnIg9ilA.js} +1 -1
- package/web-app/dist/assets/{Avatar-ixVj76fM.js → Avatar-0BTVsQ2A.js} +1 -1
- package/web-app/dist/assets/{Badge-BGMSp22s.js → Badge-Bbq8CXgn.js} +1 -1
- package/web-app/dist/assets/{Button-pYJc32tG.js → Button-CKD4n0ld.js} +1 -1
- package/web-app/dist/assets/{CockpitPage-C1JBBouA.js → CockpitPage-DJhLNLAz.js} +1 -1
- package/web-app/dist/assets/{ComparePage-qD0NV4RK.js → ComparePage-Di2akuII.js} +1 -1
- package/web-app/dist/assets/{ErrorBoundary-C-yxO58j.js → ErrorBoundary-9lhsYG-z.js} +1 -1
- package/web-app/dist/assets/{EvidenceReceiptPanel-ByPzuE1S.js → EvidenceReceiptPanel-CQDGXvDr.js} +1 -1
- package/web-app/dist/assets/{GitHubIssuesPanel-BNv_2BEJ.js → GitHubIssuesPanel-Y_w2k9Ej.js} +1 -1
- package/web-app/dist/assets/{GitHubPRsPanel-CuLZQ9CS.js → GitHubPRsPanel-pMSQedCv.js} +1 -1
- package/web-app/dist/assets/{HomePage-CS_FW8Je.js → HomePage-CIAkiGdt.js} +2 -2
- package/web-app/dist/assets/{LoginPage-DwiNf1Wm.js → LoginPage-BES4Pk6k.js} +1 -1
- package/web-app/dist/assets/{MagicPage-CmQybLPZ.js → MagicPage-B154fMhi.js} +1 -1
- package/web-app/dist/assets/{MetricsPage-D9ERdpv8.js → MetricsPage-BcNyaiUO.js} +1 -1
- package/web-app/dist/assets/{NotFoundPage-D9ccgdbF.js → NotFoundPage-D28FqYxG.js} +1 -1
- package/web-app/dist/assets/{ProjectPage-B9Jslmgv.js → ProjectPage-C_O3u8Jx.js} +3 -3
- package/web-app/dist/assets/{ProjectsPage-DnU99Go2.js → ProjectsPage-uqdahuPA.js} +1 -1
- package/web-app/dist/assets/{SettingsPage-DcTyx6wo.js → SettingsPage-DLvVA9OZ.js} +1 -1
- package/web-app/dist/assets/{ShowcasePage-jEY9ghSr.js → ShowcasePage-BAZP8vsm.js} +1 -1
- package/web-app/dist/assets/{SystemSettingsPage-CSGltW-8.js → SystemSettingsPage-DPYZkLWh.js} +1 -1
- package/web-app/dist/assets/{TeamsPage-gUuJXb9l.js → TeamsPage-BgSyb5Pd.js} +1 -1
- package/web-app/dist/assets/{TemplatesPage-37Zhe--v.js → TemplatesPage-D_Jh5VU1.js} +1 -1
- package/web-app/dist/assets/{TerminalOutput-B09hRwaR.js → TerminalOutput-4v8poDKb.js} +1 -1
- package/web-app/dist/assets/{activity-q_4MY9J0.js → activity-CE2b6kdw.js} +1 -1
- package/web-app/dist/assets/{bell-X8OC_PFP.js → bell-CxJDlLSR.js} +1 -1
- package/web-app/dist/assets/{bot-C-N1PUOD.js → bot-C2WuK4zw.js} +1 -1
- package/web-app/dist/assets/{check-B2c76Jmp.js → check-81epVldw.js} +1 -1
- package/web-app/dist/assets/{chevron-left-BF76YLNw.js → chevron-left-B6EXh_3C.js} +1 -1
- package/web-app/dist/assets/{circle-alert-AeFKBD8h.js → circle-alert-0iqoo-iy.js} +1 -1
- package/web-app/dist/assets/{clock-CiXRf-FH.js → clock-CFrBpNUp.js} +1 -1
- package/web-app/dist/assets/{cloud-DKQ8SRJ5.js → cloud-BBSFhUT_.js} +1 -1
- package/web-app/dist/assets/{code-xml-DEb6jXWP.js → code-xml-BCU2V1rq.js} +1 -1
- package/web-app/dist/assets/{database-6W7e-E9c.js → database-D2hvO52K.js} +1 -1
- package/web-app/dist/assets/{dollar-sign-Ds2GOuyW.js → dollar-sign-CXFA9b6T.js} +1 -1
- package/web-app/dist/assets/{file-code-corner-CZU0jg52.js → file-code-corner-CqTCEEfS.js} +1 -1
- package/web-app/dist/assets/{file-plus-DTVYnXCN.js → file-plus-DYFbkGbs.js} +1 -1
- package/web-app/dist/assets/{globe-0mUzKYDe.js → globe-DpYHLobk.js} +1 -1
- package/web-app/dist/assets/{hammer-BMHZg3YX.js → hammer-CZ97SEYo.js} +1 -1
- package/web-app/dist/assets/{index-BvOTvjax.js → index-DzswgpRo.js} +3 -3
- package/web-app/dist/assets/{layers-Df7TdcWB.js → layers-2Tq0edx4.js} +1 -1
- package/web-app/dist/assets/{loader-circle-kPsVfrSY.js → loader-circle-BFP4kUX-.js} +1 -1
- package/web-app/dist/assets/{lock-CXYcbEc5.js → lock-DkKdZH4d.js} +1 -1
- package/web-app/dist/assets/{package-Cqhua1ID.js → package-ChOGD8yf.js} +1 -1
- package/web-app/dist/assets/{plus-lseXhsn-.js → plus-hggB1o6b.js} +1 -1
- package/web-app/dist/assets/{refresh-cw-OZriI6gD.js → refresh-cw-DYwVLOeF.js} +1 -1
- package/web-app/dist/assets/{rotate-ccw-CKCfjPg8.js → rotate-ccw-CUdY506q.js} +1 -1
- package/web-app/dist/assets/{scroll-text-DoVyyfKb.js → scroll-text-Wvidz5uN.js} +1 -1
- package/web-app/dist/assets/{server-DGy7TflW.js → server-O11QmkCN.js} +1 -1
- package/web-app/dist/assets/{shield-alert-BCzRcMXi.js → shield-alert-DXXx27uV.js} +1 -1
- package/web-app/dist/assets/{thumbs-up-C_znJd5g.js → thumbs-up-DF8ivs3A.js} +1 -1
- package/web-app/dist/assets/{trash-2-BosCbeeA.js → trash-2-LA6PW0Nf.js} +1 -1
- package/web-app/dist/assets/{trending-down-CkR5zDkV.js → trending-down-Bw_vLKHT.js} +1 -1
- package/web-app/dist/assets/{trending-up-DpUIfOH4.js → trending-up-tX8motha.js} +1 -1
- package/web-app/dist/assets/{upload-CH9aKc8L.js → upload-CxtTWJd2.js} +1 -1
- package/web-app/dist/assets/{usePolling-pzxrHJeg.js → usePolling-D-P5Iofq.js} +1 -1
- package/web-app/dist/assets/{user-B7OHa-VQ.js → user-dKTcuUFq.js} +1 -1
- package/web-app/dist/index.html +1 -1
|
@@ -26,6 +26,12 @@ GitHub, the bearer token authenticates a human operator -- so holding one must
|
|
|
26
26
|
never grant the other. If no API token is configured the server still starts but
|
|
27
27
|
every /jobs request is rejected with 503 and an audit log line.
|
|
28
28
|
|
|
29
|
+
Self-healing entry point: POST /sentry accepts a Sentry integration webhook
|
|
30
|
+
authenticated by Sentry's own HMAC (sentry-hook-signature) under a THIRD,
|
|
31
|
+
separate secret (LOKI_SENTRY_WEBHOOK_SECRET or SENTRY_CLIENT_SECRET). A valid
|
|
32
|
+
alert enqueues `loki start "<normalized brief>" --pr --detach`: the output is a
|
|
33
|
+
receipt-gated PR only, never a deploy. No secret means the route answers 503.
|
|
34
|
+
|
|
29
35
|
Usage:
|
|
30
36
|
python3 autonomy/trigger-server.py [--port PORT] [--secret SECRET] [--dry-run]
|
|
31
37
|
[--workers N] [--queue-size N]
|
|
@@ -49,8 +55,10 @@ import subprocess
|
|
|
49
55
|
import sys
|
|
50
56
|
import threading
|
|
51
57
|
import time
|
|
58
|
+
import urllib.parse
|
|
52
59
|
from datetime import datetime
|
|
53
60
|
from pathlib import Path
|
|
61
|
+
from urllib.request import Request, urlopen
|
|
54
62
|
|
|
55
63
|
# Receipt attestation. Imported by path because trigger-server.py runs as a
|
|
56
64
|
# script from an arbitrary cwd, so a bare `import receipt_jwt` would depend on
|
|
@@ -398,6 +406,25 @@ OUTCOME_FAILED = JOB_STATUS_FAILED # launch failed, or exited non-zero
|
|
|
398
406
|
OUTCOME_UNKNOWN = JOB_STATUS_UNKNOWN # detached; exit code never observed
|
|
399
407
|
|
|
400
408
|
|
|
409
|
+
# Secrets this server holds that a spawned `loki start` must never inherit: a
|
|
410
|
+
# prompt-injected run could otherwise forge /sentry, /webhook or /jobs requests.
|
|
411
|
+
CHILD_ENV_DENYLIST = (
|
|
412
|
+
"GITHUB_WEBHOOK_SECRET",
|
|
413
|
+
"LOKI_API_TOKEN",
|
|
414
|
+
"LOKI_API_TOKEN_FILE",
|
|
415
|
+
"LOKI_SENTRY_WEBHOOK_SECRET",
|
|
416
|
+
"SENTRY_CLIENT_SECRET",
|
|
417
|
+
"SLACK_SIGNING_SECRET",
|
|
418
|
+
"SLACK_BOT_TOKEN",
|
|
419
|
+
"LINEAR_WEBHOOK_SECRET",
|
|
420
|
+
"LINEAR_API_KEY",
|
|
421
|
+
)
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
def _scoped_child_env():
|
|
425
|
+
return {k: v for k, v in os.environ.items() if k not in CHILD_ENV_DENYLIST}
|
|
426
|
+
|
|
427
|
+
|
|
401
428
|
def run_loki_outcome(args, dry_run=False):
|
|
402
429
|
"""Run a loki command and report WHICH of the three outcomes occurred.
|
|
403
430
|
|
|
@@ -414,6 +441,7 @@ def run_loki_outcome(args, dry_run=False):
|
|
|
414
441
|
cmd,
|
|
415
442
|
stdout=subprocess.PIPE,
|
|
416
443
|
stderr=subprocess.PIPE,
|
|
444
|
+
env=_scoped_child_env(),
|
|
417
445
|
)
|
|
418
446
|
except (FileNotFoundError, OSError) as e:
|
|
419
447
|
logging.error("Failed to launch %s: %s", " ".join(cmd), e)
|
|
@@ -577,6 +605,208 @@ def handle_job_event(payload, dry_run=False):
|
|
|
577
605
|
return summary, status
|
|
578
606
|
|
|
579
607
|
|
|
608
|
+
# Pseudo-event for a Sentry alert. Like JOB_EVENT it is NOT a GitHub event and is
|
|
609
|
+
# refused on /webhook: only /sentry, under its own HMAC secret, may enqueue it.
|
|
610
|
+
SENTRY_EVENT = "sentry_issue"
|
|
611
|
+
INTERNAL_EVENTS = (JOB_EVENT, SENTRY_EVENT)
|
|
612
|
+
|
|
613
|
+
# Sentry actions that mean "a new problem needs a fix". resolved/assigned/etc.
|
|
614
|
+
# are skipped so a state change never starts a build.
|
|
615
|
+
SENTRY_ACTIONS = ("triggered", "created")
|
|
616
|
+
SENTRY_BRIEF_MAX = 1500
|
|
617
|
+
ISSUE_PROVIDERS_SH = Path(__file__).resolve().parent / "issue-providers.sh"
|
|
618
|
+
|
|
619
|
+
|
|
620
|
+
def validate_sentry_signature(secret, body, signature_header):
|
|
621
|
+
"""Validate Sentry's `sentry-hook-signature` (hex HMAC-SHA256, no prefix).
|
|
622
|
+
|
|
623
|
+
Fails closed: no secret or no header is never valid.
|
|
624
|
+
"""
|
|
625
|
+
if not secret or not signature_header:
|
|
626
|
+
return False
|
|
627
|
+
expected = hmac.new(secret.encode("utf-8"), body, hashlib.sha256).hexdigest()
|
|
628
|
+
return constant_time_equals(expected, signature_header.strip().lower())
|
|
629
|
+
|
|
630
|
+
|
|
631
|
+
# Slack and Linear delegation. Same signed-body contract as
|
|
632
|
+
# loki-ts/src/contrib/slack_inbound.ts (signSlackBody/verifySlackSignature).
|
|
633
|
+
SIGNATURE_MAX_SKEW_S = 300
|
|
634
|
+
SLACK_REPLY_PREFIX = "https://hooks.slack.com/"
|
|
635
|
+
LINEAR_API_URL = "https://api.linear.app/graphql"
|
|
636
|
+
LINEAR_IDENTIFIER_RE = re.compile(r"^[A-Za-z][A-Za-z0-9]*-\d+$")
|
|
637
|
+
NOTIFY_TIMEOUT_SECONDS = 10
|
|
638
|
+
NOTIFY_TEXT_MAX = 1000
|
|
639
|
+
|
|
640
|
+
|
|
641
|
+
def sign_slack_body(secret, timestamp, body):
|
|
642
|
+
"""`v0=` + hex HMAC-SHA256 over `v0:<timestamp>:<raw body>` (bytes)."""
|
|
643
|
+
base = b"v0:" + str(timestamp).encode("utf-8") + b":" + body
|
|
644
|
+
return "v0=" + hmac.new(secret.encode("utf-8"), base, hashlib.sha256).hexdigest()
|
|
645
|
+
|
|
646
|
+
|
|
647
|
+
def verify_slack_signature(secret, timestamp, body, signature, now=None):
|
|
648
|
+
"""Fail closed: no secret, header, numeric timestamp or fresh window -> False."""
|
|
649
|
+
if not secret or not timestamp or not signature:
|
|
650
|
+
return False
|
|
651
|
+
if not re.fullmatch(r"[0-9]+", timestamp):
|
|
652
|
+
return False
|
|
653
|
+
now = time.time() if now is None else now
|
|
654
|
+
if abs(now - int(timestamp)) > SIGNATURE_MAX_SKEW_S:
|
|
655
|
+
return False
|
|
656
|
+
return constant_time_equals(sign_slack_body(secret, timestamp, body), signature)
|
|
657
|
+
|
|
658
|
+
|
|
659
|
+
def verify_linear_signature(secret, body, signature):
|
|
660
|
+
"""Linear-Signature: hex HMAC-SHA256 of the raw body. Freshness is checked
|
|
661
|
+
separately against the signed body's webhookTimestamp (linear_fresh)."""
|
|
662
|
+
if not secret or not signature:
|
|
663
|
+
return False
|
|
664
|
+
expected = hmac.new(secret.encode("utf-8"), body, hashlib.sha256).hexdigest()
|
|
665
|
+
return constant_time_equals(expected, signature.strip().lower())
|
|
666
|
+
|
|
667
|
+
|
|
668
|
+
def linear_fresh(payload, now=None):
|
|
669
|
+
ts_val = payload.get("webhookTimestamp")
|
|
670
|
+
if isinstance(ts_val, bool) or not isinstance(ts_val, (int, float)):
|
|
671
|
+
return False
|
|
672
|
+
secs = ts_val / 1000.0 if ts_val > 1e11 else float(ts_val)
|
|
673
|
+
now = time.time() if now is None else now
|
|
674
|
+
return abs(now - secs) <= SIGNATURE_MAX_SKEW_S
|
|
675
|
+
|
|
676
|
+
|
|
677
|
+
def resolve_chat_secrets(env, secret, api_token, sentry_secret):
|
|
678
|
+
"""Return (slack_secret, linear_secret), blanking any that equals another
|
|
679
|
+
credential: each authenticates a different party and MUST differ."""
|
|
680
|
+
slack = env.get("SLACK_SIGNING_SECRET", "")
|
|
681
|
+
linear = env.get("LINEAR_WEBHOOK_SECRET", "")
|
|
682
|
+
others = [x for x in (secret, api_token, sentry_secret) if x]
|
|
683
|
+
slack_bad = bool(slack) and any(
|
|
684
|
+
constant_time_equals(slack, o) for o in others + ([linear] if linear else []))
|
|
685
|
+
linear_bad = bool(linear) and any(
|
|
686
|
+
constant_time_equals(linear, o) for o in others + ([slack] if slack else []))
|
|
687
|
+
if slack_bad:
|
|
688
|
+
logging.error("The Slack secret is identical to another credential. "
|
|
689
|
+
"They MUST differ; disabling /slack.")
|
|
690
|
+
if linear_bad:
|
|
691
|
+
logging.error("The Linear secret is identical to another credential. "
|
|
692
|
+
"They MUST differ; disabling /linear.")
|
|
693
|
+
return ("" if slack_bad else slack), ("" if linear_bad else linear)
|
|
694
|
+
|
|
695
|
+
|
|
696
|
+
def _post_json(url, data, headers):
|
|
697
|
+
"""POST JSON with a bounded timeout. Raises on any failure; callers log."""
|
|
698
|
+
req = Request(url, data=json.dumps(data).encode("utf-8"),
|
|
699
|
+
headers=dict(headers, **{"Content-Type": "application/json"}),
|
|
700
|
+
method="POST")
|
|
701
|
+
with urlopen(req, timeout=NOTIFY_TIMEOUT_SECONDS) as resp:
|
|
702
|
+
resp.read()
|
|
703
|
+
|
|
704
|
+
|
|
705
|
+
def notify_job_complete(target, job_id, status, summary):
|
|
706
|
+
"""Send a completion summary to a reply target captured after verification.
|
|
707
|
+
|
|
708
|
+
Never raises: a notifier failure must not fail the job. The Slack URL is
|
|
709
|
+
re-checked here as defense in depth.
|
|
710
|
+
"""
|
|
711
|
+
if not isinstance(target, dict):
|
|
712
|
+
return
|
|
713
|
+
text = ("loki job %s finished: %s. %s" % (job_id, status, summary or "")).strip()
|
|
714
|
+
text = CONTROL_CHARS_RE.sub(" ", text)[:NOTIFY_TEXT_MAX]
|
|
715
|
+
try:
|
|
716
|
+
if target.get("kind") == "slack":
|
|
717
|
+
url = target.get("response_url", "")
|
|
718
|
+
if not (isinstance(url, str) and url.startswith(SLACK_REPLY_PREFIX)):
|
|
719
|
+
return
|
|
720
|
+
_post_json(url, {"response_type": "in_channel", "text": text}, {})
|
|
721
|
+
elif target.get("kind") == "linear":
|
|
722
|
+
key = os.environ.get("LINEAR_API_KEY", "")
|
|
723
|
+
issue_id = target.get("issue_id", "")
|
|
724
|
+
if not key or not isinstance(issue_id, str) or not issue_id:
|
|
725
|
+
return
|
|
726
|
+
_post_json(LINEAR_API_URL, {
|
|
727
|
+
"query": "mutation($i:String!,$b:String!){commentCreate(input:"
|
|
728
|
+
"{issueId:$i,body:$b}){success}}",
|
|
729
|
+
"variables": {"i": issue_id, "b": text},
|
|
730
|
+
}, {"Authorization": key})
|
|
731
|
+
except Exception as e: # notifier errors never fail a job
|
|
732
|
+
logging.warning("Completion notify for job %s failed: %s", job_id, e)
|
|
733
|
+
|
|
734
|
+
|
|
735
|
+
def normalize_sentry_payload(payload):
|
|
736
|
+
"""Run the existing normalize_sentry_json over a Sentry webhook payload.
|
|
737
|
+
|
|
738
|
+
Reuses autonomy/issue-providers.sh (single owner of the Sentry shape) via a
|
|
739
|
+
scoped-env bash call. Returns the normalized dict or None.
|
|
740
|
+
"""
|
|
741
|
+
data = payload.get("data")
|
|
742
|
+
if not isinstance(data, dict):
|
|
743
|
+
return None
|
|
744
|
+
raw = {k: data[k] for k in ("issue", "event") if isinstance(data.get(k), dict)}
|
|
745
|
+
if not raw:
|
|
746
|
+
return None
|
|
747
|
+
env = {"PATH": os.environ.get("PATH", "/usr/bin:/bin")}
|
|
748
|
+
ev = raw.get("event") or {}
|
|
749
|
+
env["_LOKI_SENTRY_URL"] = str(ev.get("web_url") or ev.get("url") or "")
|
|
750
|
+
try:
|
|
751
|
+
proc = subprocess.run(
|
|
752
|
+
["bash", "-c", 'source "$1" && normalize_sentry_json', "_",
|
|
753
|
+
str(ISSUE_PROVIDERS_SH)],
|
|
754
|
+
input=json.dumps(raw).encode("utf-8"), capture_output=True,
|
|
755
|
+
env=env, timeout=20,
|
|
756
|
+
)
|
|
757
|
+
except (OSError, subprocess.SubprocessError):
|
|
758
|
+
return None
|
|
759
|
+
if proc.returncode != 0:
|
|
760
|
+
return None
|
|
761
|
+
try:
|
|
762
|
+
out = json.loads(proc.stdout.decode("utf-8", errors="replace").strip().splitlines()[-1])
|
|
763
|
+
except (ValueError, IndexError):
|
|
764
|
+
return None
|
|
765
|
+
return out if isinstance(out, dict) else None
|
|
766
|
+
|
|
767
|
+
|
|
768
|
+
def build_sentry_brief(norm):
|
|
769
|
+
"""One-line, control-char-free brief that can never parse as a CLI flag."""
|
|
770
|
+
culprit = ""
|
|
771
|
+
for line in str(norm.get("body", "")).splitlines():
|
|
772
|
+
if line.startswith("- Culprit: "):
|
|
773
|
+
culprit = line[len("- Culprit: "):].strip()
|
|
774
|
+
break
|
|
775
|
+
parts = ["Fix Sentry issue %s: %s" % (norm.get("number", "?"), norm.get("title", ""))]
|
|
776
|
+
if culprit:
|
|
777
|
+
parts.append("(culprit %s)" % culprit)
|
|
778
|
+
if norm.get("url"):
|
|
779
|
+
parts.append("See %s." % norm["url"])
|
|
780
|
+
parts.append("Reproduce with a regression test, then fix the root cause.")
|
|
781
|
+
brief = CONTROL_CHARS_RE.sub(" ", " ".join(parts))
|
|
782
|
+
brief = " ".join(brief.split())
|
|
783
|
+
return brief.encode("utf-8")[:SENTRY_BRIEF_MAX].decode("utf-8", errors="ignore")
|
|
784
|
+
|
|
785
|
+
|
|
786
|
+
def handle_sentry_event(payload, dry_run=False):
|
|
787
|
+
"""Handle a verified Sentry alert: loki start <brief> --pr --detach.
|
|
788
|
+
|
|
789
|
+
PR only. Never passes a deploy argument: a fix still has to earn a VERIFIED
|
|
790
|
+
receipt, and shipping it stays a separate, gated human step.
|
|
791
|
+
"""
|
|
792
|
+
action = payload.get("action", "")
|
|
793
|
+
if action not in SENTRY_ACTIONS:
|
|
794
|
+
return None, "skipped (action=%s)" % action
|
|
795
|
+
norm = normalize_sentry_payload(payload)
|
|
796
|
+
if norm is None:
|
|
797
|
+
return None, "rejected (unparseable sentry payload)"
|
|
798
|
+
brief = build_sentry_brief(norm)
|
|
799
|
+
if not valid_spec(brief):
|
|
800
|
+
return None, "rejected (invalid brief)"
|
|
801
|
+
args = ["start", brief, "--pr", "--detach"]
|
|
802
|
+
summary = "sentry issue %s" % norm.get("number", "?")
|
|
803
|
+
success = run_loki_command(args, dry_run=dry_run)
|
|
804
|
+
status = "fired" if success else "error"
|
|
805
|
+
if success:
|
|
806
|
+
send_notification("Trigger fired: %s" % summary)
|
|
807
|
+
return summary, status
|
|
808
|
+
|
|
809
|
+
|
|
580
810
|
# Map event name -> handler. Keeps do_POST routing declarative. JOB_EVENT rides
|
|
581
811
|
# the same table (and therefore the same bounded queue and worker pool) but is
|
|
582
812
|
# explicitly refused on the /webhook path.
|
|
@@ -585,6 +815,7 @@ EVENT_HANDLERS = {
|
|
|
585
815
|
"pull_request": handle_pull_request_event,
|
|
586
816
|
"workflow_run": handle_workflow_run_event,
|
|
587
817
|
JOB_EVENT: handle_job_event,
|
|
818
|
+
SENTRY_EVENT: handle_sentry_event,
|
|
588
819
|
}
|
|
589
820
|
|
|
590
821
|
|
|
@@ -629,6 +860,9 @@ class Dispatcher:
|
|
|
629
860
|
self._job_max = max(1, job_history)
|
|
630
861
|
self._jobs = collections.OrderedDict()
|
|
631
862
|
self._jobs_lock = threading.Lock()
|
|
863
|
+
# Reply targets (Slack response_url / Linear issue) keyed by job_id,
|
|
864
|
+
# written only by the verified /slack and /linear handlers.
|
|
865
|
+
self._reply_targets = collections.OrderedDict()
|
|
632
866
|
# Proof-attribution bookkeeping. The proof pointer is global to the
|
|
633
867
|
# working directory, so a receipt only identifies a job when that job
|
|
634
868
|
# was the only build that could have written it. All three are updated
|
|
@@ -766,6 +1000,17 @@ class Dispatcher:
|
|
|
766
1000
|
if summary:
|
|
767
1001
|
entry["summary"] = summary
|
|
768
1002
|
|
|
1003
|
+
def set_reply_target(self, job_id, target):
|
|
1004
|
+
"""Store a verified reply target server side, bounded like _jobs."""
|
|
1005
|
+
with self._jobs_lock:
|
|
1006
|
+
self._reply_targets[job_id] = target
|
|
1007
|
+
while len(self._reply_targets) > self._job_max:
|
|
1008
|
+
self._reply_targets.popitem(last=False)
|
|
1009
|
+
|
|
1010
|
+
def pop_reply_target(self, job_id):
|
|
1011
|
+
with self._jobs_lock:
|
|
1012
|
+
return self._reply_targets.pop(job_id, None)
|
|
1013
|
+
|
|
769
1014
|
def get_job(self, job_id):
|
|
770
1015
|
"""Return a copy of the job record, or None if unknown/evicted.
|
|
771
1016
|
|
|
@@ -859,6 +1104,9 @@ class Dispatcher:
|
|
|
859
1104
|
)
|
|
860
1105
|
if job_id:
|
|
861
1106
|
self.record_job(job_id, status, summary or "")
|
|
1107
|
+
target = self.pop_reply_target(job_id)
|
|
1108
|
+
if target is not None:
|
|
1109
|
+
notify_job_complete(target, job_id, status, summary)
|
|
862
1110
|
finally:
|
|
863
1111
|
self.queue.task_done()
|
|
864
1112
|
|
|
@@ -875,6 +1123,12 @@ class WebhookHandler(http.server.BaseHTTPRequestHandler):
|
|
|
875
1123
|
# Bearer token for the /jobs API. MUST be a different secret from `secret`
|
|
876
1124
|
# above: one authenticates GitHub, the other authenticates a human.
|
|
877
1125
|
api_token = ""
|
|
1126
|
+
# Sentry integration client secret for /sentry. Distinct from both above.
|
|
1127
|
+
sentry_secret = ""
|
|
1128
|
+
# Slack signing secret (/slack) and Linear webhook secret (/linear), each
|
|
1129
|
+
# distinct from every other credential.
|
|
1130
|
+
slack_secret = ""
|
|
1131
|
+
linear_secret = ""
|
|
878
1132
|
dispatcher = None
|
|
879
1133
|
# Ed25519 key that attests receipts, and the retired public keys that keep
|
|
880
1134
|
# pre-rotation receipts verifiable. Both default to unconfigured, which
|
|
@@ -1155,8 +1409,19 @@ class WebhookHandler(http.server.BaseHTTPRequestHandler):
|
|
|
1155
1409
|
self._send_json(400, {"error": "invalid spec"})
|
|
1156
1410
|
return
|
|
1157
1411
|
|
|
1412
|
+
job_id = self._enqueue_job(spec.strip())
|
|
1413
|
+
if job_id is None:
|
|
1414
|
+
return
|
|
1415
|
+
self._send_json(202, {"id": job_id, "status": "queued"})
|
|
1416
|
+
|
|
1417
|
+
def _enqueue_job(self, spec, reply_target=None):
|
|
1418
|
+
"""Record and enqueue one validated job; shared by /jobs, /slack, /linear.
|
|
1419
|
+
|
|
1420
|
+
Returns the job id, or None after sending 429 when the queue is full.
|
|
1421
|
+
reply_target must come from an already-verified request.
|
|
1422
|
+
"""
|
|
1158
1423
|
job_id = secrets.token_urlsafe(12)
|
|
1159
|
-
job = {"spec": spec
|
|
1424
|
+
job = {"spec": spec, "job_id": job_id}
|
|
1160
1425
|
|
|
1161
1426
|
# Record BEFORE enqueueing. A worker can pick the job up the instant
|
|
1162
1427
|
# submit() returns and write "running"/"fired"; record_job is
|
|
@@ -1165,6 +1430,8 @@ class WebhookHandler(http.server.BaseHTTPRequestHandler):
|
|
|
1165
1430
|
# forever. On the 429 path below the record is unreachable (no id is
|
|
1166
1431
|
# handed out) and the store is bounded, so nothing leaks.
|
|
1167
1432
|
self.dispatcher.record_job(job_id, "queued", job["spec"])
|
|
1433
|
+
if reply_target is not None:
|
|
1434
|
+
self.dispatcher.set_reply_target(job_id, reply_target)
|
|
1168
1435
|
|
|
1169
1436
|
# Enforce the existing queue bound so a submit storm cannot fork
|
|
1170
1437
|
# unbounded builds. 429 (not a silent drop) tells the client to retry.
|
|
@@ -1172,18 +1439,164 @@ class WebhookHandler(http.server.BaseHTTPRequestHandler):
|
|
|
1172
1439
|
logging.warning(
|
|
1173
1440
|
"Queue full; shedding job submit from %s", self.address_string()
|
|
1174
1441
|
)
|
|
1442
|
+
self.dispatcher.pop_reply_target(job_id)
|
|
1175
1443
|
log_event(JOB_EVENT, "", "", "rejected (queue full)")
|
|
1176
1444
|
self._send_json(429, {"error": "server busy, retry later"})
|
|
1177
|
-
return
|
|
1445
|
+
return None
|
|
1178
1446
|
|
|
1179
1447
|
log_event(JOB_EVENT, "", job["spec"], "queued")
|
|
1448
|
+
return job_id
|
|
1449
|
+
|
|
1450
|
+
def _handle_slack(self):
|
|
1451
|
+
"""POST /slack -- signed slash command; enqueues a job, ephemeral reply."""
|
|
1452
|
+
body = self._read_body()
|
|
1453
|
+
if body is None:
|
|
1454
|
+
return
|
|
1455
|
+
if not self.slack_secret:
|
|
1456
|
+
log_event(JOB_EVENT, "", "", "rejected (no slack signing secret configured)")
|
|
1457
|
+
self._send_json(503, {"error": "SLACK_SIGNING_SECRET not configured"})
|
|
1458
|
+
return
|
|
1459
|
+
if not verify_slack_signature(
|
|
1460
|
+
self.slack_secret,
|
|
1461
|
+
self.headers.get("X-Slack-Request-Timestamp", ""),
|
|
1462
|
+
body, self.headers.get("X-Slack-Signature", "")):
|
|
1463
|
+
logging.warning("Invalid Slack signature from %s", self.address_string())
|
|
1464
|
+
log_event(JOB_EVENT, "", "", "rejected (invalid slack signature)")
|
|
1465
|
+
self._send_json(401, {"error": "invalid signature"})
|
|
1466
|
+
return
|
|
1467
|
+
# Verified from here on.
|
|
1468
|
+
ctype = (self.headers.get("Content-Type", "") or "").lower()
|
|
1469
|
+
if "json" in ctype:
|
|
1470
|
+
try:
|
|
1471
|
+
data = json.loads(body)
|
|
1472
|
+
except ValueError:
|
|
1473
|
+
self._send_json(400, {"error": "invalid JSON"})
|
|
1474
|
+
return
|
|
1475
|
+
if isinstance(data, dict) and data.get("type") == "url_verification":
|
|
1476
|
+
self._send_json(200, {"challenge": str(data.get("challenge", ""))})
|
|
1477
|
+
return
|
|
1478
|
+
self._send_json(400, {"error": "unsupported slack payload"})
|
|
1479
|
+
return
|
|
1480
|
+
form = urllib.parse.parse_qs(body.decode("utf-8", errors="replace"))
|
|
1481
|
+
text = (form.get("text") or [""])[0]
|
|
1482
|
+
if not valid_spec(text):
|
|
1483
|
+
log_event(JOB_EVENT, "", "", "rejected (invalid slack spec)")
|
|
1484
|
+
self._send_json(200, {"response_type": "ephemeral",
|
|
1485
|
+
"text": "Usage: /loki <spec>. Spec must be a "
|
|
1486
|
+
"one-line brief or ref, not starting with '-'."})
|
|
1487
|
+
return
|
|
1488
|
+
response_url = (form.get("response_url") or [""])[0]
|
|
1489
|
+
target = {"kind": "slack"}
|
|
1490
|
+
if response_url.startswith(SLACK_REPLY_PREFIX):
|
|
1491
|
+
target["response_url"] = response_url
|
|
1492
|
+
job_id = self._enqueue_job(text.strip(), target)
|
|
1493
|
+
if job_id is None:
|
|
1494
|
+
return
|
|
1495
|
+
self._send_json(200, {"response_type": "ephemeral",
|
|
1496
|
+
"text": "Queued loki job %s" % job_id})
|
|
1497
|
+
|
|
1498
|
+
def _handle_linear(self):
|
|
1499
|
+
"""POST /linear -- signed Linear issue webhook; enqueues linear:<identifier>."""
|
|
1500
|
+
body = self._read_body()
|
|
1501
|
+
if body is None:
|
|
1502
|
+
return
|
|
1503
|
+
if not self.linear_secret:
|
|
1504
|
+
log_event(JOB_EVENT, "", "", "rejected (no linear webhook secret configured)")
|
|
1505
|
+
self._send_json(503, {"error": "LINEAR_WEBHOOK_SECRET not configured"})
|
|
1506
|
+
return
|
|
1507
|
+
if not verify_linear_signature(
|
|
1508
|
+
self.linear_secret, body, self.headers.get("Linear-Signature", "")):
|
|
1509
|
+
logging.warning("Invalid Linear signature from %s", self.address_string())
|
|
1510
|
+
log_event(JOB_EVENT, "", "", "rejected (invalid linear signature)")
|
|
1511
|
+
self._send_json(401, {"error": "invalid signature"})
|
|
1512
|
+
return
|
|
1513
|
+
try:
|
|
1514
|
+
payload = json.loads(body)
|
|
1515
|
+
except ValueError:
|
|
1516
|
+
self._send_json(400, {"error": "invalid JSON"})
|
|
1517
|
+
return
|
|
1518
|
+
if not isinstance(payload, dict):
|
|
1519
|
+
self._send_json(400, {"error": "payload must be a JSON object"})
|
|
1520
|
+
return
|
|
1521
|
+
if not linear_fresh(payload):
|
|
1522
|
+
log_event(JOB_EVENT, "", "", "rejected (stale linear timestamp)")
|
|
1523
|
+
self._send_json(401, {"error": "stale or missing webhookTimestamp"})
|
|
1524
|
+
return
|
|
1525
|
+
data = payload.get("data")
|
|
1526
|
+
if (payload.get("type") != "Issue" or payload.get("action") != "create"
|
|
1527
|
+
or not isinstance(data, dict)):
|
|
1528
|
+
self._send_json(200, {"status": "skipped"})
|
|
1529
|
+
return
|
|
1530
|
+
ident = data.get("identifier")
|
|
1531
|
+
if not isinstance(ident, str) or not LINEAR_IDENTIFIER_RE.match(ident):
|
|
1532
|
+
self._send_json(400, {"error": "invalid issue identifier"})
|
|
1533
|
+
return
|
|
1534
|
+
if self.dispatcher.seen_delivery(self.headers.get("Linear-Delivery", "")):
|
|
1535
|
+
self._send_json(200, {"status": "duplicate"})
|
|
1536
|
+
return
|
|
1537
|
+
spec = "linear:%s" % ident
|
|
1538
|
+
if not valid_spec(spec):
|
|
1539
|
+
self._send_json(400, {"error": "invalid spec"})
|
|
1540
|
+
return
|
|
1541
|
+
issue_id = data.get("id")
|
|
1542
|
+
target = {"kind": "linear",
|
|
1543
|
+
"issue_id": issue_id if isinstance(issue_id, str) else ""}
|
|
1544
|
+
job_id = self._enqueue_job(spec, target)
|
|
1545
|
+
if job_id is None:
|
|
1546
|
+
return
|
|
1180
1547
|
self._send_json(202, {"id": job_id, "status": "queued"})
|
|
1181
1548
|
|
|
1549
|
+
def _handle_sentry(self):
|
|
1550
|
+
"""POST /sentry -- Sentry alert, HMAC-verified, enqueues a fix run."""
|
|
1551
|
+
body = self._read_body()
|
|
1552
|
+
if body is None:
|
|
1553
|
+
return
|
|
1554
|
+
event_type = SENTRY_EVENT
|
|
1555
|
+
if not self.sentry_secret:
|
|
1556
|
+
logging.warning("Rejecting /sentry from %s: no Sentry secret configured",
|
|
1557
|
+
self.address_string())
|
|
1558
|
+
log_event(event_type, "", "", "rejected (no sentry secret configured)")
|
|
1559
|
+
self._send_json(503, {"error": "sentry webhook secret not configured"})
|
|
1560
|
+
return
|
|
1561
|
+
sig = self.headers.get("sentry-hook-signature", "")
|
|
1562
|
+
if not validate_sentry_signature(self.sentry_secret, body, sig):
|
|
1563
|
+
logging.warning("Invalid Sentry signature from %s", self.address_string())
|
|
1564
|
+
log_event(event_type, "", "", "rejected (invalid signature)")
|
|
1565
|
+
self._send_json(401, {"error": "invalid signature"})
|
|
1566
|
+
return
|
|
1567
|
+
try:
|
|
1568
|
+
payload = json.loads(body)
|
|
1569
|
+
except json.JSONDecodeError:
|
|
1570
|
+
self._send_json(400, {"error": "invalid JSON"})
|
|
1571
|
+
return
|
|
1572
|
+
if not isinstance(payload, dict):
|
|
1573
|
+
self._send_json(400, {"error": "payload must be a JSON object"})
|
|
1574
|
+
return
|
|
1575
|
+
action = payload.get("action", "")
|
|
1576
|
+
if self.dispatcher.seen_delivery(self.headers.get("Request-ID", "")):
|
|
1577
|
+
log_event(event_type, action, "", "duplicate")
|
|
1578
|
+
self._send_json(200, {"event": event_type, "status": "duplicate"})
|
|
1579
|
+
return
|
|
1580
|
+
if not self.dispatcher.submit(event_type, payload):
|
|
1581
|
+
log_event(event_type, action, "", "rejected (queue full)")
|
|
1582
|
+
self._send_json(503, {"error": "server busy, retry later"})
|
|
1583
|
+
return
|
|
1584
|
+
self._send_json(202, {"event": event_type, "action": action, "status": "queued"})
|
|
1585
|
+
|
|
1182
1586
|
def do_POST(self):
|
|
1183
1587
|
path = self.path.split("?", 1)[0]
|
|
1184
1588
|
if path == "/jobs":
|
|
1185
1589
|
self._handle_job_submit()
|
|
1186
1590
|
return
|
|
1591
|
+
if path == "/sentry":
|
|
1592
|
+
self._handle_sentry()
|
|
1593
|
+
return
|
|
1594
|
+
if path == "/slack":
|
|
1595
|
+
self._handle_slack()
|
|
1596
|
+
return
|
|
1597
|
+
if path == "/linear":
|
|
1598
|
+
self._handle_linear()
|
|
1599
|
+
return
|
|
1187
1600
|
if path != "/webhook":
|
|
1188
1601
|
self._send_json(404, {"error": "not found"})
|
|
1189
1602
|
return
|
|
@@ -1201,10 +1614,10 @@ class WebhookHandler(http.server.BaseHTTPRequestHandler):
|
|
|
1201
1614
|
# never be reachable with the webhook HMAC. Without this, a holder of
|
|
1202
1615
|
# the webhook secret could POST X-GitHub-Event: loki_job and submit an
|
|
1203
1616
|
# arbitrary spec, defeating the separate-credential requirement.
|
|
1204
|
-
if event_type
|
|
1617
|
+
if event_type in INTERNAL_EVENTS:
|
|
1205
1618
|
logging.warning(
|
|
1206
1619
|
"Rejecting %s on /webhook from %s: submits require the /jobs "
|
|
1207
|
-
"API and its own bearer token",
|
|
1620
|
+
"API and its own bearer token (Sentry uses /sentry)",
|
|
1208
1621
|
JOB_EVENT,
|
|
1209
1622
|
self.address_string(),
|
|
1210
1623
|
)
|
|
@@ -1350,6 +1763,25 @@ def main():
|
|
|
1350
1763
|
)
|
|
1351
1764
|
api_token = ""
|
|
1352
1765
|
|
|
1766
|
+
# Sentry secret: env only (never argv). Must differ from the other two
|
|
1767
|
+
# credentials, since each authenticates a different party.
|
|
1768
|
+
sentry_secret = (os.environ.get("LOKI_SENTRY_WEBHOOK_SECRET")
|
|
1769
|
+
or os.environ.get("SENTRY_CLIENT_SECRET") or "")
|
|
1770
|
+
if sentry_secret and (
|
|
1771
|
+
(secret and constant_time_equals(sentry_secret, secret))
|
|
1772
|
+
or (api_token and constant_time_equals(sentry_secret, api_token))
|
|
1773
|
+
):
|
|
1774
|
+
logging.error(
|
|
1775
|
+
"The Sentry secret is identical to the webhook secret or API "
|
|
1776
|
+
"token. They MUST differ; disabling /sentry."
|
|
1777
|
+
)
|
|
1778
|
+
sentry_secret = ""
|
|
1779
|
+
|
|
1780
|
+
# Slack/Linear secrets: env only. Each must differ from every other
|
|
1781
|
+
# credential, since each authenticates a different party.
|
|
1782
|
+
slack_secret, linear_secret = resolve_chat_secrets(
|
|
1783
|
+
os.environ, secret, api_token, sentry_secret)
|
|
1784
|
+
|
|
1353
1785
|
# Persist resolved values, but never write the secret to disk.
|
|
1354
1786
|
config["port"] = port
|
|
1355
1787
|
config["dry_run"] = dry_run
|
|
@@ -1363,6 +1795,9 @@ def main():
|
|
|
1363
1795
|
WebhookHandler.dry_run = dry_run
|
|
1364
1796
|
WebhookHandler.secret = secret
|
|
1365
1797
|
WebhookHandler.api_token = api_token
|
|
1798
|
+
WebhookHandler.sentry_secret = sentry_secret
|
|
1799
|
+
WebhookHandler.slack_secret = slack_secret
|
|
1800
|
+
WebhookHandler.linear_secret = linear_secret
|
|
1366
1801
|
WebhookHandler.dispatcher = dispatcher
|
|
1367
1802
|
|
|
1368
1803
|
signing_key, signing_kid = load_signing_key(auto_generate=False)
|
|
@@ -1404,6 +1839,21 @@ def main():
|
|
|
1404
1839
|
"503. Set LOKI_API_TOKEN or LOKI_API_TOKEN_FILE to enable remote "
|
|
1405
1840
|
"submits."
|
|
1406
1841
|
)
|
|
1842
|
+
if not sentry_secret:
|
|
1843
|
+
logging.warning(
|
|
1844
|
+
"No Sentry secret configured: POST /sentry is rejected with 503. "
|
|
1845
|
+
"Set LOKI_SENTRY_WEBHOOK_SECRET or SENTRY_CLIENT_SECRET."
|
|
1846
|
+
)
|
|
1847
|
+
if not slack_secret:
|
|
1848
|
+
logging.warning(
|
|
1849
|
+
"No Slack signing secret configured: POST /slack is rejected with "
|
|
1850
|
+
"503. Set SLACK_SIGNING_SECRET."
|
|
1851
|
+
)
|
|
1852
|
+
if not linear_secret:
|
|
1853
|
+
logging.warning(
|
|
1854
|
+
"No Linear webhook secret configured: POST /linear is rejected with "
|
|
1855
|
+
"503. Set LINEAR_WEBHOOK_SECRET."
|
|
1856
|
+
)
|
|
1407
1857
|
if not secret:
|
|
1408
1858
|
logging.warning(
|
|
1409
1859
|
"No webhook secret configured: ALL webhooks will be rejected with "
|
package/autonomy/verify.sh
CHANGED
|
@@ -2584,6 +2584,15 @@ OPTIONS:
|
|
|
2584
2584
|
is exactly as without --hosted (fail-open, never a silent
|
|
2585
2585
|
pass). Without --hosted, output is byte-identical to prior
|
|
2586
2586
|
releases.
|
|
2587
|
+
--seal-composite <run-dir>
|
|
2588
|
+
Seal a workspace run into one signed composite receipt
|
|
2589
|
+
(composite.json). `loki verify <composite.json>` then
|
|
2590
|
+
re-checks it offline. Routed to the engine10 verifier.
|
|
2591
|
+
--max-age <DUR> With a receipt path: refuse a receipt whose signed issue
|
|
2592
|
+
time is older than DUR (Ns, Nm, Nh, Nd or seconds), has
|
|
2593
|
+
no signed issue time, or is more than 300s in the
|
|
2594
|
+
future. Exits 2 (UNCHECKED). Routed to the engine10
|
|
2595
|
+
verifier.
|
|
2587
2596
|
-h, --help Show this help.
|
|
2588
2597
|
|
|
2589
2598
|
GATES (deterministic, reproducible by construction):
|
package/completions/_loki
CHANGED
|
@@ -313,6 +313,10 @@ function _loki_start {
|
|
|
313
313
|
_arguments \
|
|
314
314
|
'--provider[AI provider]:provider name:(claude cline codex aider opencode)' \
|
|
315
315
|
'--parallel[Parallel mode]' \
|
|
316
|
+
'--workspace[Run a loki.yaml workspace for the brief]:workspace name:' \
|
|
317
|
+
'--deploy[Deploy after a VERIFIED run via loki deploy --execute]' \
|
|
318
|
+
'--health-url[With --deploy: health probe URL]:url:' \
|
|
319
|
+
'--auto-rollback[With --deploy: roll back on a failed health probe]' \
|
|
316
320
|
'--background[Background mode]' \
|
|
317
321
|
'--bg[Background mode]' \
|
|
318
322
|
'--simple[Force simple complexity]' \
|
package/completions/loki.bash
CHANGED
|
@@ -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 --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 --workspace --deploy --health-url --auto-rollback --help"
|
|
31
31
|
COMPREPLY=( $(compgen -W "${flags}" -- "$cur") )
|
|
32
32
|
return 0
|
|
33
33
|
fi
|
|
@@ -57,7 +57,11 @@ _loki_completion() {
|
|
|
57
57
|
;;
|
|
58
58
|
|
|
59
59
|
memory)
|
|
60
|
-
local memory_cmds="list show search stats export clear dedupe index timeline consolidate economics retrieve lessons learn forget episode pattern skill vectors help"
|
|
60
|
+
local memory_cmds="list show search stats export clear dedupe index timeline consolidate economics retrieve lessons learn forget episode pattern skill vectors brief help"
|
|
61
|
+
if [[ "$cur" == -* ]]; then
|
|
62
|
+
COMPREPLY=( $(compgen -W "--task --help" -- "$cur") )
|
|
63
|
+
return 0
|
|
64
|
+
fi
|
|
61
65
|
COMPREPLY=( $(compgen -W "${memory_cmds}" -- "$cur") )
|
|
62
66
|
;;
|
|
63
67
|
|
|
@@ -136,6 +140,20 @@ _loki_completion() {
|
|
|
136
140
|
fi
|
|
137
141
|
;;
|
|
138
142
|
|
|
143
|
+
deploy)
|
|
144
|
+
if [[ "$cur" == -* ]]; then
|
|
145
|
+
COMPREPLY=( $(compgen -W "--execute --health-url --auto-rollback --dir --no-clip --allow-unanchored --help" -- "$cur") )
|
|
146
|
+
return 0
|
|
147
|
+
fi
|
|
148
|
+
;;
|
|
149
|
+
|
|
150
|
+
verify)
|
|
151
|
+
if [[ "$cur" == -* ]]; then
|
|
152
|
+
COMPREPLY=( $(compgen -W "--base --out --block-on --no-llm --json --explain --check-fresh --max-age --hosted --seal-composite --help" -- "$cur") )
|
|
153
|
+
return 0
|
|
154
|
+
fi
|
|
155
|
+
;;
|
|
156
|
+
|
|
139
157
|
chat)
|
|
140
158
|
if [[ "$cur" == -* ]]; then
|
|
141
159
|
COMPREPLY=( $(compgen -W "--attach --tui --help" -- "$cur") )
|