loki-mode 11.7.1 → 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.
Files changed (76) hide show
  1. package/SKILL.md +2 -2
  2. package/VERSION +1 -1
  3. package/autonomy/lib/backlog.py +37 -2
  4. package/autonomy/lib/chat.sh +3 -2
  5. package/autonomy/loki +1 -1
  6. package/autonomy/trigger-server.py +269 -2
  7. package/autonomy/verify.sh +5 -0
  8. package/completions/loki.bash +2 -2
  9. package/dashboard/__init__.py +1 -1
  10. package/docs/CLI-REFERENCE.md +1 -1
  11. package/docs/INSTALLATION.md +1 -1
  12. package/docs/sentry-intake.md +22 -0
  13. package/docs/v10/D61-SPEED.md +12 -0
  14. package/docs/v10/PROGRESS.md +10 -0
  15. package/loki-ts/dist/loki.js +895 -878
  16. package/mcp/__init__.py +1 -1
  17. package/package.json +2 -2
  18. package/packages/control-plane/dist/server.js +277 -271
  19. package/plugins/loki-mode/.claude-plugin/plugin.json +1 -1
  20. package/web-app/dist/assets/{AdminPage-S3BYeapm.js → AdminPage-BnIg9ilA.js} +1 -1
  21. package/web-app/dist/assets/{Avatar-C1rHwLgn.js → Avatar-0BTVsQ2A.js} +1 -1
  22. package/web-app/dist/assets/{Badge-CzlnN5aQ.js → Badge-Bbq8CXgn.js} +1 -1
  23. package/web-app/dist/assets/{Button-B1w5AeGC.js → Button-CKD4n0ld.js} +1 -1
  24. package/web-app/dist/assets/{CockpitPage-Dyeqjfkc.js → CockpitPage-DJhLNLAz.js} +1 -1
  25. package/web-app/dist/assets/{ComparePage-C6y2GkGR.js → ComparePage-Di2akuII.js} +1 -1
  26. package/web-app/dist/assets/{ErrorBoundary-Dg2fcGkR.js → ErrorBoundary-9lhsYG-z.js} +1 -1
  27. package/web-app/dist/assets/{EvidenceReceiptPanel-CxbyjvTj.js → EvidenceReceiptPanel-CQDGXvDr.js} +1 -1
  28. package/web-app/dist/assets/{GitHubIssuesPanel-Dpys1xDb.js → GitHubIssuesPanel-Y_w2k9Ej.js} +1 -1
  29. package/web-app/dist/assets/{GitHubPRsPanel-CRVjWoFl.js → GitHubPRsPanel-pMSQedCv.js} +1 -1
  30. package/web-app/dist/assets/{HomePage-BKnv-oYw.js → HomePage-CIAkiGdt.js} +2 -2
  31. package/web-app/dist/assets/{LoginPage-BGksmFRd.js → LoginPage-BES4Pk6k.js} +1 -1
  32. package/web-app/dist/assets/{MagicPage-OCvhp16M.js → MagicPage-B154fMhi.js} +1 -1
  33. package/web-app/dist/assets/{MetricsPage-CJOJpXWC.js → MetricsPage-BcNyaiUO.js} +1 -1
  34. package/web-app/dist/assets/{NotFoundPage-C04p3QvN.js → NotFoundPage-D28FqYxG.js} +1 -1
  35. package/web-app/dist/assets/{ProjectPage-BRB8txsY.js → ProjectPage-C_O3u8Jx.js} +3 -3
  36. package/web-app/dist/assets/{ProjectsPage-CoUYeQEh.js → ProjectsPage-uqdahuPA.js} +1 -1
  37. package/web-app/dist/assets/{SettingsPage-BkjmJGfM.js → SettingsPage-DLvVA9OZ.js} +1 -1
  38. package/web-app/dist/assets/{ShowcasePage-B4vU9TjQ.js → ShowcasePage-BAZP8vsm.js} +1 -1
  39. package/web-app/dist/assets/{SystemSettingsPage-Z2jiVsni.js → SystemSettingsPage-DPYZkLWh.js} +1 -1
  40. package/web-app/dist/assets/{TeamsPage-Ulm8SISq.js → TeamsPage-BgSyb5Pd.js} +1 -1
  41. package/web-app/dist/assets/{TemplatesPage-C_7MM6Ey.js → TemplatesPage-D_Jh5VU1.js} +1 -1
  42. package/web-app/dist/assets/{TerminalOutput-Cumd7Tgs.js → TerminalOutput-4v8poDKb.js} +1 -1
  43. package/web-app/dist/assets/{activity-CZAUkbiY.js → activity-CE2b6kdw.js} +1 -1
  44. package/web-app/dist/assets/{bell-BOvSXpEr.js → bell-CxJDlLSR.js} +1 -1
  45. package/web-app/dist/assets/{bot-DOTZxBYj.js → bot-C2WuK4zw.js} +1 -1
  46. package/web-app/dist/assets/{check-BxpE0B5u.js → check-81epVldw.js} +1 -1
  47. package/web-app/dist/assets/{chevron-left-D6KPvBOA.js → chevron-left-B6EXh_3C.js} +1 -1
  48. package/web-app/dist/assets/{circle-alert-Bajq86oe.js → circle-alert-0iqoo-iy.js} +1 -1
  49. package/web-app/dist/assets/{clock-CnlHAVJc.js → clock-CFrBpNUp.js} +1 -1
  50. package/web-app/dist/assets/{cloud-DjaymjwM.js → cloud-BBSFhUT_.js} +1 -1
  51. package/web-app/dist/assets/{code-xml-BADEw2Ld.js → code-xml-BCU2V1rq.js} +1 -1
  52. package/web-app/dist/assets/{database-B4aKb5Cq.js → database-D2hvO52K.js} +1 -1
  53. package/web-app/dist/assets/{dollar-sign-DrldXpja.js → dollar-sign-CXFA9b6T.js} +1 -1
  54. package/web-app/dist/assets/{file-code-corner-DVebDVR-.js → file-code-corner-CqTCEEfS.js} +1 -1
  55. package/web-app/dist/assets/{file-plus-BD-N_-Tl.js → file-plus-DYFbkGbs.js} +1 -1
  56. package/web-app/dist/assets/{globe-C7ieInC-.js → globe-DpYHLobk.js} +1 -1
  57. package/web-app/dist/assets/{hammer-BSugKIji.js → hammer-CZ97SEYo.js} +1 -1
  58. package/web-app/dist/assets/{index-nLQgfvOn.js → index-DzswgpRo.js} +3 -3
  59. package/web-app/dist/assets/{layers-CHXxU2on.js → layers-2Tq0edx4.js} +1 -1
  60. package/web-app/dist/assets/{loader-circle-BEEz0LbD.js → loader-circle-BFP4kUX-.js} +1 -1
  61. package/web-app/dist/assets/{lock-CzQGfcnE.js → lock-DkKdZH4d.js} +1 -1
  62. package/web-app/dist/assets/{package-CcHYQLGd.js → package-ChOGD8yf.js} +1 -1
  63. package/web-app/dist/assets/{plus-DdzgFEXn.js → plus-hggB1o6b.js} +1 -1
  64. package/web-app/dist/assets/{refresh-cw-7x0HbH9Y.js → refresh-cw-DYwVLOeF.js} +1 -1
  65. package/web-app/dist/assets/{rotate-ccw-DBahX1Sp.js → rotate-ccw-CUdY506q.js} +1 -1
  66. package/web-app/dist/assets/{scroll-text-BB8y8a9Z.js → scroll-text-Wvidz5uN.js} +1 -1
  67. package/web-app/dist/assets/{server-CfkbW2MH.js → server-O11QmkCN.js} +1 -1
  68. package/web-app/dist/assets/{shield-alert-DUgD8rBF.js → shield-alert-DXXx27uV.js} +1 -1
  69. package/web-app/dist/assets/{thumbs-up-DGoyY6QC.js → thumbs-up-DF8ivs3A.js} +1 -1
  70. package/web-app/dist/assets/{trash-2-B6xrSUMr.js → trash-2-LA6PW0Nf.js} +1 -1
  71. package/web-app/dist/assets/{trending-down-B6RGKTOx.js → trending-down-Bw_vLKHT.js} +1 -1
  72. package/web-app/dist/assets/{trending-up-B44DCrt1.js → trending-up-tX8motha.js} +1 -1
  73. package/web-app/dist/assets/{upload-4kWm33ZY.js → upload-CxtTWJd2.js} +1 -1
  74. package/web-app/dist/assets/{usePolling-zkUtRQG6.js → usePolling-D-P5Iofq.js} +1 -1
  75. package/web-app/dist/assets/{user-04cEVIe2.js → user-dKTcuUFq.js} +1 -1
  76. package/web-app/dist/index.html +1 -1
package/SKILL.md CHANGED
@@ -3,7 +3,7 @@ name: loki-mode
3
3
  description: An autonomous software factory that knows what it is supposed to deliver, and proves it did. Use when the user says Loki Mode or asks to build, fix or verify software autonomously.
4
4
  ---
5
5
 
6
- # Loki Mode v11.7.1
6
+ # Loki Mode v11.8.1
7
7
 
8
8
  **Current entry points (Loki 10 engine):** `loki "<task>"`, `loki owner/repo#N` and `loki quick "<task>"` run the Loki 10 engine and end with a signed receipt (`loki verify`). `loki backlog owner/repo --all` runs many issues in parallel. Guide: `docs/v10/GUIDE.md`. The skill instructions below, and `loki start`, describe the previous bash loop. PRD-file starts (`loki start ./prd.md`) and the opencode provider still run on it until FC38-SWEEP ports them; the legacy-engine command and the LOKI_ENGINE switch remain removed. Prefer `loki owner/repo#N` for issues.
9
9
 
@@ -483,4 +483,4 @@ See `CHANGELOG.md` entries [7.5.7], [7.5.8], [7.5.13] for the per-fix list and r
483
483
 
484
484
  ---
485
485
 
486
- **v11.7.1 | [Autonomi](https://www.autonomi.dev/) flagship product | core skill**
486
+ **v11.8.1 | [Autonomi](https://www.autonomi.dev/) flagship product | core skill**
package/VERSION CHANGED
@@ -1 +1 @@
1
- 11.7.1
1
+ 11.8.1
@@ -195,7 +195,8 @@ def load_dag(path, group):
195
195
  if task.lstrip().startswith("-"):
196
196
  raise ValueError("unit %s task text starts with '-' and would be read as an option" % uid)
197
197
  units.append({"n": n, "id": uid, "deps": deps[uid], "write_set": list(u.get("writeSet") or []),
198
- "task": task})
198
+ "task": task, "done": u.get("done") is True,
199
+ "spec": u["spec"] if isinstance(u.get("spec"), str) and os.path.isabs(u["spec"]) else ""})
199
200
  return group, units
200
201
 
201
202
 
@@ -313,7 +314,10 @@ def main(argv):
313
314
  "cmd": [launcher, u["task"], "--no-pr"],
314
315
  "deps": [key_of[d] for d in u["deps"]],
315
316
  "env": {"LOKI_GROUP_ID": group, "LOKI_UNIT_ID": u["id"], "LOKI_UNIT_N": str(u["n"]),
316
- "LOKI_UNIT_DEPS": ",".join(u["deps"]), "LOKI_UNIT_WRITE_SET": "\n".join(u["write_set"])}}
317
+ "LOKI_UNIT_DEPS": ",".join(u["deps"]), "LOKI_UNIT_WRITE_SET": "\n".join(u["write_set"])},
318
+ "done": u["done"]}
319
+ if u["spec"]: # frozen group acceptance for unit_mode (wall.ts)
320
+ spec[k]["env"].update(LOKI_UNIT_SPEC=u["spec"], LOKI_SPEED="1")
317
321
  state = {k: {"status": "queued", "ok": None, "cost": None, "ran": False} for k in spec}
318
322
  for k in state:
319
323
  say("backlog: %s queued" % spec[k]["label"])
@@ -331,6 +335,32 @@ def main(argv):
331
335
  signal.signal(signal.SIGTERM, stop_children)
332
336
  signal.signal(signal.SIGINT, stop_children)
333
337
 
338
+ def harvest(k, wt):
339
+ """Copy the newest unit receipt and events into the group dir before the worktree goes."""
340
+ gid = spec[k]["env"].get("LOKI_GROUP_ID")
341
+ uid = spec[k]["env"].get("LOKI_UNIT_ID")
342
+ if not (spec[k]["unit"] and gid and uid):
343
+ return
344
+ try:
345
+ runs = os.path.join(wt, ".loki", "runs")
346
+ cands = [os.path.join(runs, d) for d in os.listdir(runs)
347
+ if not os.path.islink(os.path.join(runs, d))
348
+ and os.path.isfile(os.path.join(runs, d, "receipt.json"))]
349
+ if not cands:
350
+ return
351
+ src_dir = max(cands, key=lambda d: os.path.getmtime(os.path.join(d, "receipt.json")))
352
+ dst_dir = os.path.join(top, ".loki", "groups", gid, "group", "units", uid)
353
+ os.makedirs(dst_dir, mode=0o700, exist_ok=True)
354
+ for name in ("receipt.json", "events.jsonl"):
355
+ src, dst = os.path.join(src_dir, name), os.path.join(dst_dir, name)
356
+ if os.path.islink(src) or os.path.islink(dst) or not os.path.isfile(src):
357
+ continue
358
+ tmp = dst + ".tmp%d" % os.getpid()
359
+ shutil.copyfile(src, tmp)
360
+ os.replace(tmp, dst)
361
+ except OSError:
362
+ pass
363
+
334
364
  def finish(k):
335
365
  nonlocal unmeasured
336
366
  p, log, wt = running.pop(k)
@@ -348,6 +378,7 @@ def main(argv):
348
378
  add_spend(usd)
349
379
  state[k].update(status=status, ok=ok, cost=usd)
350
380
  say("backlog: %s %s" % (spec[k]["label"], status))
381
+ harvest(k, wt)
351
382
  if ok:
352
383
  git("worktree", "remove", "--force", wt, cwd=top)
353
384
  if not spec[k]["unit"]: # a passing unit branch is kept for the integrator
@@ -414,6 +445,10 @@ def main(argv):
414
445
  k = next_ready()
415
446
  if k is None:
416
447
  break
448
+ if spec[k].get("done"):
449
+ state[k].update(status="VERIFIED (resumed)", ok=True)
450
+ say("backlog: %s VERIFIED (resumed)" % spec[k]["label"])
451
+ continue
417
452
  launch(k)
418
453
  if running:
419
454
  time.sleep(0.2)
@@ -47,7 +47,7 @@ Loki chat commands:
47
47
  /attach attach to the current or most recent loki start run in this repo
48
48
  /quick <task> loki quick
49
49
  /edit <change> apply one change (build mode for this turn)
50
- /verify loki verify
50
+ /verify [path] [--max-age 7d] loki verify (a receipt path, group receipt, or age limit)
51
51
  /receipt loki receipt
52
52
  /model <id> set model for this session
53
53
  /provider <name> switch provider (claude, codex, cline, aider, opencode)
@@ -351,7 +351,8 @@ Question: $line" || true
351
351
  /edit)
352
352
  [ -n "$rest" ] || { echo "usage: /edit <change>"; continue; }
353
353
  _chat_send build "$rest" ;;
354
- /verify) _chat_do "$loki" verify ;;
354
+ /verify) # shellcheck disable=SC2086
355
+ _chat_do "$loki" verify $rest ;;
355
356
  /receipt) _chat_do "$loki" receipt ;;
356
357
  /cost) _chat_do "$loki" cost ;;
357
358
  /undo) # shellcheck disable=SC2086
package/autonomy/loki CHANGED
@@ -18134,7 +18134,7 @@ cmd_verify() {
18134
18134
  # verified by the engine10 verifier directly; bin/loki sends a bare verify back here.
18135
18135
  for _pk_arg in "$@"; do
18136
18136
  case "$_pk_arg" in
18137
- composite.json|*/composite.json|--seal-composite|--seal-composite=*)
18137
+ composite.json|*/composite.json|--seal-composite|--seal-composite=*|--max-age|--max-age=*|.loki/groups/*/receipt.json|*/.loki/groups/*/receipt.json)
18138
18138
  _loki_bun_delegate engine10 verify "$@"
18139
18139
  return $? ;;
18140
18140
  esac
@@ -55,8 +55,10 @@ import subprocess
55
55
  import sys
56
56
  import threading
57
57
  import time
58
+ import urllib.parse
58
59
  from datetime import datetime
59
60
  from pathlib import Path
61
+ from urllib.request import Request, urlopen
60
62
 
61
63
  # Receipt attestation. Imported by path because trigger-server.py runs as a
62
64
  # script from an arbitrary cwd, so a bare `import receipt_jwt` would depend on
@@ -412,6 +414,10 @@ CHILD_ENV_DENYLIST = (
412
414
  "LOKI_API_TOKEN_FILE",
413
415
  "LOKI_SENTRY_WEBHOOK_SECRET",
414
416
  "SENTRY_CLIENT_SECRET",
417
+ "SLACK_SIGNING_SECRET",
418
+ "SLACK_BOT_TOKEN",
419
+ "LINEAR_WEBHOOK_SECRET",
420
+ "LINEAR_API_KEY",
415
421
  )
416
422
 
417
423
 
@@ -622,6 +628,110 @@ def validate_sentry_signature(secret, body, signature_header):
622
628
  return constant_time_equals(expected, signature_header.strip().lower())
623
629
 
624
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
+
625
735
  def normalize_sentry_payload(payload):
626
736
  """Run the existing normalize_sentry_json over a Sentry webhook payload.
627
737
 
@@ -750,6 +860,9 @@ class Dispatcher:
750
860
  self._job_max = max(1, job_history)
751
861
  self._jobs = collections.OrderedDict()
752
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()
753
866
  # Proof-attribution bookkeeping. The proof pointer is global to the
754
867
  # working directory, so a receipt only identifies a job when that job
755
868
  # was the only build that could have written it. All three are updated
@@ -887,6 +1000,17 @@ class Dispatcher:
887
1000
  if summary:
888
1001
  entry["summary"] = summary
889
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
+
890
1014
  def get_job(self, job_id):
891
1015
  """Return a copy of the job record, or None if unknown/evicted.
892
1016
 
@@ -980,6 +1104,9 @@ class Dispatcher:
980
1104
  )
981
1105
  if job_id:
982
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)
983
1110
  finally:
984
1111
  self.queue.task_done()
985
1112
 
@@ -998,6 +1125,10 @@ class WebhookHandler(http.server.BaseHTTPRequestHandler):
998
1125
  api_token = ""
999
1126
  # Sentry integration client secret for /sentry. Distinct from both above.
1000
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 = ""
1001
1132
  dispatcher = None
1002
1133
  # Ed25519 key that attests receipts, and the retired public keys that keep
1003
1134
  # pre-rotation receipts verifiable. Both default to unconfigured, which
@@ -1278,8 +1409,19 @@ class WebhookHandler(http.server.BaseHTTPRequestHandler):
1278
1409
  self._send_json(400, {"error": "invalid spec"})
1279
1410
  return
1280
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
+ """
1281
1423
  job_id = secrets.token_urlsafe(12)
1282
- job = {"spec": spec.strip(), "job_id": job_id}
1424
+ job = {"spec": spec, "job_id": job_id}
1283
1425
 
1284
1426
  # Record BEFORE enqueueing. A worker can pick the job up the instant
1285
1427
  # submit() returns and write "running"/"fired"; record_job is
@@ -1288,6 +1430,8 @@ class WebhookHandler(http.server.BaseHTTPRequestHandler):
1288
1430
  # forever. On the 429 path below the record is unreachable (no id is
1289
1431
  # handed out) and the store is bounded, so nothing leaks.
1290
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)
1291
1435
 
1292
1436
  # Enforce the existing queue bound so a submit storm cannot fork
1293
1437
  # unbounded builds. 429 (not a silent drop) tells the client to retry.
@@ -1295,11 +1439,111 @@ class WebhookHandler(http.server.BaseHTTPRequestHandler):
1295
1439
  logging.warning(
1296
1440
  "Queue full; shedding job submit from %s", self.address_string()
1297
1441
  )
1442
+ self.dispatcher.pop_reply_target(job_id)
1298
1443
  log_event(JOB_EVENT, "", "", "rejected (queue full)")
1299
1444
  self._send_json(429, {"error": "server busy, retry later"})
1300
- return
1445
+ return None
1301
1446
 
1302
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
1303
1547
  self._send_json(202, {"id": job_id, "status": "queued"})
1304
1548
 
1305
1549
  def _handle_sentry(self):
@@ -1347,6 +1591,12 @@ class WebhookHandler(http.server.BaseHTTPRequestHandler):
1347
1591
  if path == "/sentry":
1348
1592
  self._handle_sentry()
1349
1593
  return
1594
+ if path == "/slack":
1595
+ self._handle_slack()
1596
+ return
1597
+ if path == "/linear":
1598
+ self._handle_linear()
1599
+ return
1350
1600
  if path != "/webhook":
1351
1601
  self._send_json(404, {"error": "not found"})
1352
1602
  return
@@ -1527,6 +1777,11 @@ def main():
1527
1777
  )
1528
1778
  sentry_secret = ""
1529
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
+
1530
1785
  # Persist resolved values, but never write the secret to disk.
1531
1786
  config["port"] = port
1532
1787
  config["dry_run"] = dry_run
@@ -1541,6 +1796,8 @@ def main():
1541
1796
  WebhookHandler.secret = secret
1542
1797
  WebhookHandler.api_token = api_token
1543
1798
  WebhookHandler.sentry_secret = sentry_secret
1799
+ WebhookHandler.slack_secret = slack_secret
1800
+ WebhookHandler.linear_secret = linear_secret
1544
1801
  WebhookHandler.dispatcher = dispatcher
1545
1802
 
1546
1803
  signing_key, signing_kid = load_signing_key(auto_generate=False)
@@ -1587,6 +1844,16 @@ def main():
1587
1844
  "No Sentry secret configured: POST /sentry is rejected with 503. "
1588
1845
  "Set LOKI_SENTRY_WEBHOOK_SECRET or SENTRY_CLIENT_SECRET."
1589
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
+ )
1590
1857
  if not secret:
1591
1858
  logging.warning(
1592
1859
  "No webhook secret configured: ALL webhooks will be rejected with "
@@ -2588,6 +2588,11 @@ OPTIONS:
2588
2588
  Seal a workspace run into one signed composite receipt
2589
2589
  (composite.json). `loki verify <composite.json>` then
2590
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.
2591
2596
  -h, --help Show this help.
2592
2597
 
2593
2598
  GATES (deterministic, reproducible by construction):
@@ -142,14 +142,14 @@ _loki_completion() {
142
142
 
143
143
  deploy)
144
144
  if [[ "$cur" == -* ]]; then
145
- COMPREPLY=( $(compgen -W "--execute --health-url --auto-rollback --help" -- "$cur") )
145
+ COMPREPLY=( $(compgen -W "--execute --health-url --auto-rollback --dir --no-clip --allow-unanchored --help" -- "$cur") )
146
146
  return 0
147
147
  fi
148
148
  ;;
149
149
 
150
150
  verify)
151
151
  if [[ "$cur" == -* ]]; then
152
- COMPREPLY=( $(compgen -W "--base --out --block-on --no-llm --json --explain --check-fresh --hosted --seal-composite --help" -- "$cur") )
152
+ COMPREPLY=( $(compgen -W "--base --out --block-on --no-llm --json --explain --check-fresh --max-age --hosted --seal-composite --help" -- "$cur") )
153
153
  return 0
154
154
  fi
155
155
  ;;
@@ -7,6 +7,6 @@ Modules:
7
7
  control: Session control helpers (status, atomic writes, origin check)
8
8
  """
9
9
 
10
- __version__ = "11.7.1"
10
+ __version__ = "11.8.1"
11
11
 
12
12
  __all__ = ["__version__"]
@@ -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.7.1. Do not edit by hand.
3
+ Generated from `loki-ts/src/cli/registry.ts` by `scripts/generate-stale-zero.sh` for v11.8.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 |
@@ -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.7.1
5
+ **Version:** v11.8.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
 
@@ -22,3 +22,25 @@ The token is sent only to the Sentry host through a curl config on stdin, never
22
22
 
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
+
26
+ ## Slack and Linear delegation
27
+
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
+
30
+ Secrets are env only and must differ from each other, the GitHub webhook secret, the API token and the Sentry secret (an equal secret disables its route at startup). A missing secret answers 503 naming the variable, and the server logs a startup warning. None of them reach the `loki start` child environment.
31
+
32
+ Slack (`POST /slack`):
33
+
34
+ 1. Set `SLACK_SIGNING_SECRET`. Point a `/loki` slash command (and Events URL verification) at `/slack`.
35
+ 2. The signature is `v0=` + hex HMAC-SHA256 over `v0:<X-Slack-Request-Timestamp>:<raw body>`, window 300s, the same contract as `loki-ts/src/contrib/slack_inbound.ts`. Bad, stale or missing signature: 401, nothing queued.
36
+ 3. The command text is the spec (one line, not starting with `-`) and runs as `loki start <text> --detach`. Slack gets an ephemeral reply carrying the job id.
37
+ 4. On completion the summary is posted to the request's `response_url`, only if it starts with `https://hooks.slack.com/`. Any other host is never contacted.
38
+
39
+ Linear (`POST /linear`):
40
+
41
+ 1. Set `LINEAR_WEBHOOK_SECRET`. Point a Linear issue webhook at `/linear`.
42
+ 2. `Linear-Signature` is the hex HMAC-SHA256 of the raw body, and the signed `webhookTimestamp` must be within 300s. Otherwise 401.
43
+ 3. A created issue enqueues `loki start linear:<identifier> --detach` and answers 202 with the job id. Other events are skipped.
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
+
46
+ 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>`.
@@ -89,3 +89,15 @@ With `LOKI_SPEED=1` (default off), intake no longer waits on the cheap-model alr
89
89
  ## D82-FLAGS status: default on
90
90
 
91
91
  Per the D82 founder directive, `LOKI_SPEED` is on unless set to `0` (decomposer routing, warm engine on a unix socket, stage prefix, deferred already-done check, lean keyword fallback, unit mode when `LOKI_UNIT_SPEC` is set). `LOKI_SPEED=0` restores the previous behaviour byte for byte. The other D82 opt-outs are `LOKI_VISUAL_EVIDENCE=0`, `LOKI_CONTRACT=0`, `LOKI_SLACK_INBOUND=0` (Slack stays inert until its credential is configured), `LOKI_CONTROL=0` and `LOKI_WORKSPACES=0`. Test: `loki-ts/tests/features/d82_default_on.test.ts`.
92
+
93
+ ## Missions and group Seal (v11.8.0)
94
+
95
+ Missions: a spec with two or more milestones runs milestone by milestone. Format: `## Milestone N[: title]` headings (the body is everything up to the next milestone heading), or a top-level `milestones:` list of `- title` lines. Fewer than 2 milestones is not a mission and takes the normal path unchanged. Parsing is `parseMilestones` in `loki-ts/src/features/decompose.ts`; the loop is in `maybeRunGroup` (`loki-ts/src/features/speed/route.ts`), reached from `loki start` and from chat `/run` through the same supervisor call.
96
+
97
+ Each milestone prints `loki: milestone k/N: <title>`, is decomposed on its own, and runs as its own group with its own frozen contract and group Seal. One-unit groups and sequential providers are allowed for milestones (the normal fewer-than-2-units and slot fallbacks do not apply). The mission stops at the first non-zero result, prints `loki: milestone k/N failed (exit X); rerun the same loki start to resume`, and returns that code; later milestones never start. All milestones exit 0 returns 0.
98
+
99
+ Group exit codes: 0 = every unit VERIFIED and the group receipt VERIFIED; 4 = NOT PROVEN (integrated, but no checked group receipt); 1 = failures.
100
+
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
+
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`.
@@ -2337,3 +2337,13 @@ The swarm is stopped: .loki/V10-STOP exists, the loop has no wake-ups, and the c
2337
2337
  - 10.6.6 (456058bae): scope control, Control Plane v0, train-cycle fix, docs. All green. Published.
2338
2338
  - 10.6.7 (bfce1db12): D60 sprint, all gates, Release, Post-Release Smoke and Promote success. latest and next at 10.6.7.
2339
2339
  - Open at handoff: real-repo gate not wired into promote.yml and needs operator provider auth; no end-to-end exit 3 run of the cost cap; root action.yml still uses loki start --simple; about 43 worktrees under .claude/worktrees (cap 15); never push backup/train-67..70 or backup/main-pre-rebuild.
2340
+
2341
+ ## 2026-10-15T20:05Z (CoS) 11.7.1 published, v11.8.0 train started
2342
+ - 11.7.0 (8b22e5fba): Tests red on tests/test-help-changelog-drift.sh (three flags missing from help/completion) and tests/test-sigpipe-guard.sh (count 1743 over ceiling 1742). Not published.
2343
+ - 11.7.1 (a01a22740): Release and Tests completed/success (gh run watch log, runs 38794589410 and 38794589637); npm dist-tags next=11.7.1, latest=11.6.0.
2344
+ - v11.8.0 train (workflow wf_e6306e08-008): group Seal, group resume, multi-milestone missions, Slack and Linear delegation on the trigger server, receipt --max-age, deploy completion and pulse ID_RE fixes.
2345
+
2346
+ ## 2026-10-15T20:45Z (CoS) v11.8.0 failed CI, v11.8.1 fix-forward
2347
+ - v11.8.0 (b5191b999): Tests run 38800796458 and Release failed on line budgets (core engine10 5020, features 3190), the D66 fence (run_group.ts value-imported seal and verify_cmd), two unclassified checkouts in the L2 registry, macOS-first stat in tests/test-backlog-units.sh:162, and a `loki help verify` mismatch. Not published.
2348
+ - Fix 1d734e6ba (Opus HIGH review APPROVED, 162 pass 0 fail on five suites), merged as 1788db60b. The pulse ID_RE item (E-121) was not in the train and stays open.
2349
+ - Local guards on the merged tree, all rc=0: test-help-changelog-drift, test-sigpipe-guard, test-help-and-log-cap, test-backlog-units, structural-checks, test-ci-only-divergence, test-platform-infra; bun test budget, l2_destructive_registry, route_group, seal_group: 117 pass 0 fail.