loki-mode 11.73.0 → 11.74.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 (74) hide show
  1. package/SKILL.md +2 -2
  2. package/VERSION +1 -1
  3. package/autonomy/company-seats.json +82 -0
  4. package/autonomy/loki +14 -4
  5. package/autonomy/trigger-schedule.py +414 -6
  6. package/completions/loki.bash +5 -0
  7. package/dashboard/__init__.py +1 -1
  8. package/docs/CLI-REFERENCE.md +1 -1
  9. package/docs/INSTALLATION.md +1 -1
  10. package/docs/v10/FAILURE-CLASSES.md +7 -0
  11. package/docs/v10/OPERATING-MODEL.md +6 -0
  12. package/loki-ts/dist/loki.js +2 -2
  13. package/mcp/__init__.py +1 -1
  14. package/package.json +2 -2
  15. package/plugins/loki-mode/.claude-plugin/plugin.json +1 -1
  16. package/tools/cockpit/index.html +2 -0
  17. package/tools/cockpit/server.py +55 -0
  18. package/web-app/dist/assets/{AdminPage-DLxU8cWs.js → AdminPage-BJm9y_qE.js} +1 -1
  19. package/web-app/dist/assets/{Avatar-FpXoF8Iv.js → Avatar-EEiOX0Y0.js} +1 -1
  20. package/web-app/dist/assets/{Badge-BpPOaQ1Z.js → Badge-ZXM82z0d.js} +1 -1
  21. package/web-app/dist/assets/{Button-BwM7fgQH.js → Button-BTMAbQBv.js} +1 -1
  22. package/web-app/dist/assets/{CockpitPage-nc-CvHy4.js → CockpitPage-BaorxOYw.js} +1 -1
  23. package/web-app/dist/assets/{ComparePage-30XVTYmd.js → ComparePage-BxhA3QxZ.js} +1 -1
  24. package/web-app/dist/assets/{ErrorBoundary-CfKqjdpH.js → ErrorBoundary-DGIdCZdm.js} +1 -1
  25. package/web-app/dist/assets/{EvidenceReceiptPanel-BVIX0kAo.js → EvidenceReceiptPanel-OgHK5zmn.js} +1 -1
  26. package/web-app/dist/assets/{GitHubIssuesPanel-CDmHswpW.js → GitHubIssuesPanel-Bx89fXVx.js} +1 -1
  27. package/web-app/dist/assets/{GitHubPRsPanel-E-OqBH5f.js → GitHubPRsPanel-cuzSw_N3.js} +1 -1
  28. package/web-app/dist/assets/{HomePage-CESGv5fC.js → HomePage-B-XP9eTl.js} +2 -2
  29. package/web-app/dist/assets/{LoginPage-BY0y6aE6.js → LoginPage-DuNSWyVy.js} +1 -1
  30. package/web-app/dist/assets/{MagicPage-BpjToOBX.js → MagicPage-DEqUoT0l.js} +1 -1
  31. package/web-app/dist/assets/{MetricsPage-DpO52H9O.js → MetricsPage--rnXkkOt.js} +1 -1
  32. package/web-app/dist/assets/{NotFoundPage-BXU0NGlM.js → NotFoundPage-BLCJKK70.js} +1 -1
  33. package/web-app/dist/assets/{ProjectPage-CewcEfOG.js → ProjectPage-BtF37a4e.js} +3 -3
  34. package/web-app/dist/assets/{ProjectsPage-C7LZkpcR.js → ProjectsPage-BmRyPzwL.js} +1 -1
  35. package/web-app/dist/assets/{SettingsPage-DHp1BCgG.js → SettingsPage-Cg3fgOLx.js} +1 -1
  36. package/web-app/dist/assets/{ShowcasePage-C_E4yQad.js → ShowcasePage-CD25qTmg.js} +1 -1
  37. package/web-app/dist/assets/{SystemSettingsPage-wK7KRqw1.js → SystemSettingsPage-DNrCZvNp.js} +1 -1
  38. package/web-app/dist/assets/{TeamsPage-CiMcY4GR.js → TeamsPage-Bpe58D0B.js} +1 -1
  39. package/web-app/dist/assets/{TemplatesPage-pGUCVtD6.js → TemplatesPage-Cwl_bmhO.js} +1 -1
  40. package/web-app/dist/assets/{TerminalOutput-CvD75-gW.js → TerminalOutput-DYgZnII2.js} +1 -1
  41. package/web-app/dist/assets/{activity-DSy_WjFp.js → activity-CsVLJUZG.js} +1 -1
  42. package/web-app/dist/assets/{bell-BRHWRNQ-.js → bell-DCcGk9Ub.js} +1 -1
  43. package/web-app/dist/assets/{bot-CacHJXqJ.js → bot-BaAaPto-.js} +1 -1
  44. package/web-app/dist/assets/{check-DcGs1awd.js → check-Dy8Lpprr.js} +1 -1
  45. package/web-app/dist/assets/{chevron-left-BpKToVdV.js → chevron-left-CiX3YEuG.js} +1 -1
  46. package/web-app/dist/assets/{circle-alert-Cvg68BPU.js → circle-alert-Bm2AEdEx.js} +1 -1
  47. package/web-app/dist/assets/{clock-Bm-746fH.js → clock-D1nWLjJm.js} +1 -1
  48. package/web-app/dist/assets/{cloud-DkpICGV1.js → cloud-Bp-p2q6I.js} +1 -1
  49. package/web-app/dist/assets/{code-xml-Bnps1g8p.js → code-xml-Crt8tNsY.js} +1 -1
  50. package/web-app/dist/assets/{database-DXpzjteu.js → database-0lcGHtpv.js} +1 -1
  51. package/web-app/dist/assets/{dollar-sign-B03JgYAV.js → dollar-sign-cup3uE0C.js} +1 -1
  52. package/web-app/dist/assets/{file-code-corner-BuEiz3XW.js → file-code-corner-jPdCMfoJ.js} +1 -1
  53. package/web-app/dist/assets/{file-plus-DhM25BHK.js → file-plus-CncZcY9z.js} +1 -1
  54. package/web-app/dist/assets/{globe-_Xv1tJB8.js → globe-BvCLfBOs.js} +1 -1
  55. package/web-app/dist/assets/{hammer-D9ZYHuG8.js → hammer-CG7gCbza.js} +1 -1
  56. package/web-app/dist/assets/{index-BD-nW46P.js → index-C5sRHsGA.js} +3 -3
  57. package/web-app/dist/assets/{layers-B5nOPH68.js → layers-BPIeLcUT.js} +1 -1
  58. package/web-app/dist/assets/{loader-circle-c1OVmB63.js → loader-circle-wH9n2Xbt.js} +1 -1
  59. package/web-app/dist/assets/{lock-B5_SQpPS.js → lock-D_CuUhM7.js} +1 -1
  60. package/web-app/dist/assets/{package-Cx_KC4IW.js → package-137A8C6B.js} +1 -1
  61. package/web-app/dist/assets/{plus-BCoCE3wz.js → plus-QVF6Q3NZ.js} +1 -1
  62. package/web-app/dist/assets/{refresh-cw-ComZ_-zw.js → refresh-cw-Dq_1uZWi.js} +1 -1
  63. package/web-app/dist/assets/{rotate-ccw-D-2b38Jn.js → rotate-ccw-Dyzxf9m5.js} +1 -1
  64. package/web-app/dist/assets/{scroll-text-BIupqEqW.js → scroll-text-DrYmLoL4.js} +1 -1
  65. package/web-app/dist/assets/{server-jYMEfnFP.js → server-QWBqdl1e.js} +1 -1
  66. package/web-app/dist/assets/{shield-alert-DQmUPlgL.js → shield-alert-DOZLbVQY.js} +1 -1
  67. package/web-app/dist/assets/{thumbs-up-CaMdkKBO.js → thumbs-up-CUKmG90b.js} +1 -1
  68. package/web-app/dist/assets/{trash-2-DUGxfddn.js → trash-2-Cxx8CMS1.js} +1 -1
  69. package/web-app/dist/assets/{trending-down-DK-pYPoa.js → trending-down-CU5sNvAx.js} +1 -1
  70. package/web-app/dist/assets/{trending-up-BPhgmVjq.js → trending-up-BV8fTyhT.js} +1 -1
  71. package/web-app/dist/assets/{upload-zGWNLWJm.js → upload-DsSmwrGn.js} +1 -1
  72. package/web-app/dist/assets/{usePolling-CYApkja5.js → usePolling-MdyOyVKa.js} +1 -1
  73. package/web-app/dist/assets/{user-D7GR1bID.js → user-DblryVAt.js} +1 -1
  74. package/web-app/dist/index.html +1 -1
package/SKILL.md CHANGED
@@ -3,7 +3,7 @@ name: loki-mode
3
3
  description: Runs an autonomous software factory that knows what it is supposed to deliver and proves it did, with a signed receipt. Use when the user says Loki Mode or asks to build, fix or verify software autonomously.
4
4
  ---
5
5
 
6
- # Loki Mode v11.73.0
6
+ # Loki Mode v11.74.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
 
@@ -476,4 +476,4 @@ Implemented, planned and deprecated items, with the version each landed in: `ski
476
476
 
477
477
  ---
478
478
 
479
- **v11.73.0 | [Autonomi](https://www.autonomi.dev/) flagship product | core skill**
479
+ **v11.74.1 | [Autonomi](https://www.autonomi.dev/) flagship product | core skill**
package/VERSION CHANGED
@@ -1 +1 @@
1
- 11.73.0
1
+ 11.74.1
@@ -0,0 +1,82 @@
1
+ [
2
+ {
3
+ "name": "chief-of-staff",
4
+ "cron_expr": "1,16,31,46 * * * *",
5
+ "action": "seat chief-of-staff",
6
+ "args": [],
7
+ "enabled": true,
8
+ "last_run": null
9
+ },
10
+ {
11
+ "name": "release-reliability-lead",
12
+ "cron_expr": "4,19,34,49 * * * *",
13
+ "action": "seat release-reliability-lead",
14
+ "args": [],
15
+ "enabled": true,
16
+ "last_run": null
17
+ },
18
+ {
19
+ "name": "verification-scientist",
20
+ "cron_expr": "7,37 * * * *",
21
+ "action": "seat verification-scientist",
22
+ "args": [],
23
+ "enabled": true,
24
+ "last_run": null
25
+ },
26
+ {
27
+ "name": "platform-lead",
28
+ "cron_expr": "11,41 * * * *",
29
+ "action": "seat platform-lead",
30
+ "args": [],
31
+ "enabled": true,
32
+ "last_run": null
33
+ },
34
+ {
35
+ "name": "security-lead",
36
+ "cron_expr": "14,44 * * * *",
37
+ "action": "seat security-lead",
38
+ "args": [],
39
+ "enabled": true,
40
+ "last_run": null
41
+ },
42
+ {
43
+ "name": "cto",
44
+ "cron_expr": "22 * * * *",
45
+ "action": "seat cto",
46
+ "args": [],
47
+ "enabled": true,
48
+ "last_run": null
49
+ },
50
+ {
51
+ "name": "developer-experience-lead",
52
+ "cron_expr": "27 * * * *",
53
+ "action": "seat developer-experience-lead",
54
+ "args": [],
55
+ "enabled": true,
56
+ "last_run": null
57
+ },
58
+ {
59
+ "name": "product-growth-lead",
60
+ "cron_expr": "33 * * * *",
61
+ "action": "seat product-growth-lead",
62
+ "args": [],
63
+ "enabled": true,
64
+ "last_run": null
65
+ },
66
+ {
67
+ "name": "research-harness-scientist",
68
+ "cron_expr": "39 * * * *",
69
+ "action": "seat research-harness-scientist",
70
+ "args": [],
71
+ "enabled": true,
72
+ "last_run": null
73
+ },
74
+ {
75
+ "name": "research-outcomes-scientist",
76
+ "cron_expr": "52 * * * *",
77
+ "action": "seat research-outcomes-scientist",
78
+ "args": [],
79
+ "enabled": true,
80
+ "last_run": null
81
+ }
82
+ ]
package/autonomy/loki CHANGED
@@ -21326,9 +21326,15 @@ print(json.loads(p.read_text()).get('port', 7373) if p.exists() else 7373)
21326
21326
  python3 "$trigger_schedule_script" remove "$name"
21327
21327
  ;;
21328
21328
  daemon)
21329
- local dry_run_flag=""
21330
- [[ "${1:-}" == "--dry-run" ]] && dry_run_flag="--dry-run"
21331
- python3 "$trigger_schedule_script" daemon $dry_run_flag
21329
+ local daemon_flag=""
21330
+ [[ "${1:-}" == "--dry-run" ]] && daemon_flag="--dry-run"
21331
+ [[ "${1:-}" == "--loop" ]] && daemon_flag="--loop"
21332
+ python3 "$trigger_schedule_script" daemon $daemon_flag
21333
+ ;;
21334
+ install|uninstall)
21335
+ # Always-on company seats: LaunchAgent running the daemon loop
21336
+ LOKI_BIN="${LOKI_BIN:-$(readlink -f "${BASH_SOURCE[0]}")}" \
21337
+ python3 "$trigger_schedule_script" "$subcmd" "$@"
21332
21338
  ;;
21333
21339
  test)
21334
21340
  local event_type="${1:-issues}"
@@ -21439,7 +21445,11 @@ for line in sys.stdin:
21439
21445
  echo " add schedule <name> <cron_expr> <action> [args...]"
21440
21446
  echo " Add a cron-based schedule trigger"
21441
21447
  echo " remove <name> Remove a schedule trigger"
21442
- echo " daemon [--dry-run] Run one pass of schedule checks (call from cron)"
21448
+ echo " daemon [--dry-run|--loop]"
21449
+ echo " Run one pass of schedule checks (call from cron), or loop once a minute"
21450
+ echo " install [--no-load] Seed the ten company-seat schedules and install the always-on"
21451
+ echo " LaunchAgent (dev.autonomi.loki-company; macOS)"
21452
+ echo " uninstall [--no-load] Remove the always-on LaunchAgent"
21443
21453
  echo " test <event-type> [--repo owner/repo] [--issue NUM]"
21444
21454
  echo " Send a test webhook event (event-types: issues, pull_request, workflow_run)"
21445
21455
  echo " logs [--tail N] Show recent trigger event log (default: last 50)"
@@ -19,18 +19,30 @@ Supported actions:
19
19
  run <issue-ref> - Run loki run on an issue/ref
20
20
  status - Run loki status
21
21
  quality-review - Run loki review
22
+ seat <name> - One headless tick of company seat <name>
23
+ (.claude/agents/<name>.md), see tick_seat()
22
24
 
23
25
  Usage:
24
26
  python3 autonomy/trigger-schedule.py [--once] [--dry-run]
27
+ python3 autonomy/trigger-schedule.py daemon [--loop]
28
+ python3 autonomy/trigger-schedule.py tick <seat>
29
+ python3 autonomy/trigger-schedule.py seed | install | uninstall
25
30
  """
26
31
 
27
32
  import argparse
33
+ import fcntl
28
34
  import json
29
35
  import logging
30
36
  import os
37
+ import plistlib
38
+ import re
39
+ import shlex
40
+ import shutil
41
+ import signal
31
42
  import subprocess
32
43
  import sys
33
- from datetime import datetime, timezone
44
+ import time
45
+ from datetime import datetime, timedelta, timezone
34
46
  from pathlib import Path
35
47
 
36
48
 
@@ -183,10 +195,23 @@ def build_loki_args(schedule):
183
195
  return action.split() + list(args)
184
196
 
185
197
 
198
+ def build_seat_command(schedule):
199
+ """argv that runs one seat tick in a detached child of this script."""
200
+ seat = schedule.get("action", "").split(None, 1)[1].strip()
201
+ return [sys.executable, os.path.abspath(__file__), "tick", seat]
202
+
203
+
204
+ def is_seat_action(schedule):
205
+ return schedule.get("action", "").split(None, 1)[:1] == ["seat"] and \
206
+ len(schedule.get("action", "").split()) == 2
207
+
208
+
186
209
  def run_schedule(schedule, dry_run=False):
187
210
  """Execute a scheduled loki command."""
188
- loki_args = build_loki_args(schedule)
189
- cmd = ["loki"] + loki_args
211
+ if is_seat_action(schedule):
212
+ cmd = build_seat_command(schedule)
213
+ else:
214
+ cmd = ["loki"] + build_loki_args(schedule)
190
215
  name = schedule.get("name", "unnamed")
191
216
 
192
217
  if dry_run:
@@ -195,10 +220,12 @@ def run_schedule(schedule, dry_run=False):
195
220
 
196
221
  logging.info("Schedule '%s' firing: %s", name, " ".join(cmd))
197
222
  try:
223
+ extra = {"start_new_session": True} if is_seat_action(schedule) else {}
198
224
  proc = subprocess.Popen(
199
225
  cmd,
200
- stdout=subprocess.PIPE,
201
- stderr=subprocess.PIPE,
226
+ stdout=subprocess.DEVNULL if extra else subprocess.PIPE,
227
+ stderr=subprocess.DEVNULL if extra else subprocess.PIPE,
228
+ **extra
202
229
  )
203
230
  logging.info("Schedule '%s' started pid=%d", name, proc.pid)
204
231
  return True
@@ -207,6 +234,351 @@ def run_schedule(schedule, dry_run=False):
207
234
  return False
208
235
 
209
236
 
237
+ # ---------------------------------------------------------------------------
238
+ # Company seats: always-on headless ticks of .claude/agents/<name>.md
239
+ # ---------------------------------------------------------------------------
240
+
241
+ SEAT_NAME_RE = re.compile(r"^[a-z0-9][a-z0-9-]*$")
242
+ LEADER_SEATS = ("chief-of-staff", "release-reliability-lead")
243
+ DEFAULT_MAX_LOAD = 28.0
244
+ DEFAULT_SEAT_TIMEOUT_SECS = 1200
245
+ GOVERNOR_CACHE_SECS = 300
246
+ TERM_GRACE_SECS = 15
247
+ LAUNCHAGENT_LABEL = "dev.autonomi.loki-company"
248
+
249
+ SEAT_TICK_PROMPT = (
250
+ "Run one iteration of your standing loop from your agent file. "
251
+ "Read the pulse (bash scripts/v10-pulse.sh) and docs/v10/BOARD.md. "
252
+ "Act only within your decision rights. "
253
+ "Write a short outcome to .loki/company/{seat}/last.json "
254
+ "(fields: ts, seat, outcome, actions, evidence) and append one line to "
255
+ ".loki/company/{seat}/log.jsonl. Then stop."
256
+ )
257
+
258
+
259
+ def company_root():
260
+ return os.path.realpath(os.environ.get("LOKI_COMPANY_ROOT") or os.getcwd())
261
+
262
+
263
+ def _now_iso():
264
+ return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
265
+
266
+
267
+ def _pid_alive(pid):
268
+ try:
269
+ os.kill(pid, 0)
270
+ except ProcessLookupError:
271
+ return False
272
+ except PermissionError:
273
+ return True
274
+ except OSError:
275
+ return False
276
+ return True
277
+
278
+
279
+ def _read_pid(path):
280
+ """First run of digits on line 1 (the v10-leader / lock format), else None."""
281
+ try:
282
+ with open(path) as f:
283
+ m = re.match(r"\s*(\d+)", f.readline())
284
+ except OSError:
285
+ return None
286
+ return int(m.group(1)) if m else None
287
+
288
+
289
+ def acquire_seat_lock(lock_path):
290
+ """Per-seat flock on the lockfile; our PID is written into it for the cockpit.
291
+
292
+ Returns (file, holder_pid). The lock dies with the process, so a crashed
293
+ tick never leaves a stale lock. Caller closes the file to release.
294
+ """
295
+ f = open(lock_path, "a+")
296
+ try:
297
+ fcntl.flock(f, fcntl.LOCK_EX | fcntl.LOCK_NB)
298
+ except OSError:
299
+ f.seek(0)
300
+ m = re.match(r"\s*(\d+)", f.readline())
301
+ f.close()
302
+ return None, int(m.group(1)) if m else None
303
+ f.seek(0)
304
+ f.truncate()
305
+ f.write("%d\n" % os.getpid())
306
+ f.flush()
307
+ return f, os.getpid()
308
+
309
+
310
+ def governor_max_engineers():
311
+ """max_engineers_next_hour from the usage governor, or None if unknown.
312
+
313
+ Same source and key the pulse reads (scripts/usage-governor.py --json,
314
+ report["governor"]["max_engineers_next_hour"]). Cached for a few minutes
315
+ so ten seats do not each rescan the transcripts.
316
+ """
317
+ root = company_root()
318
+ cache = os.path.join(root, ".loki", "company", "governor-cache.json")
319
+ cmd_env = os.environ.get("LOKI_COMPANY_GOVERNOR_CMD") or os.environ.get("PULSE_GOVERNOR_CMD")
320
+ if not cmd_env:
321
+ cmd = [sys.executable, os.path.join(root, "scripts", "usage-governor.py"), "--json"]
322
+ if not os.path.exists(cmd[1]):
323
+ return None
324
+ else:
325
+ cmd = shlex.split(cmd_env)
326
+ try:
327
+ c = json.load(open(cache))
328
+ if cmd_env is None and time.time() - c["at"] < GOVERNOR_CACHE_SECS:
329
+ return c["max"]
330
+ except (OSError, ValueError, KeyError, TypeError):
331
+ pass
332
+ try:
333
+ out = subprocess.run(cmd, cwd=root, capture_output=True, text=True, timeout=90).stdout
334
+ val = (json.loads(out).get("governor") or {}).get("max_engineers_next_hour")
335
+ except (OSError, ValueError, AttributeError, subprocess.TimeoutExpired):
336
+ return None
337
+ if not isinstance(val, int):
338
+ return None
339
+ if cmd_env is None:
340
+ try:
341
+ with open(cache, "w") as f:
342
+ json.dump({"at": time.time(), "max": val}, f)
343
+ except OSError:
344
+ pass
345
+ return val
346
+
347
+
348
+ def _append_log(seat_dir, seat, outcome, detail=""):
349
+ entry = {"ts": _now_iso(), "seat": seat, "outcome": outcome}
350
+ if detail:
351
+ entry["detail"] = detail
352
+ with open(os.path.join(seat_dir, "log.jsonl"), "a") as f:
353
+ f.write(json.dumps(entry) + "\n")
354
+
355
+
356
+ def tick_seat(name):
357
+ """Run one headless tick of seat `name`. Returns (status, detail).
358
+
359
+ status is "ran", "skipped" or "error". Guards, in order: PAUSE file,
360
+ 1-min load average, live v10-leader (chief-of-staff and
361
+ release-reliability-lead only), per-seat PID lock, usage governor.
362
+ """
363
+ root = company_root()
364
+ if not SEAT_NAME_RE.match(name or "") or \
365
+ not os.path.isfile(os.path.join(root, ".claude", "agents", name + ".md")):
366
+ return "error", "unknown seat: %s" % name
367
+ cdir = os.path.join(root, ".loki", "company")
368
+ seat_dir = os.path.join(cdir, name)
369
+ os.makedirs(seat_dir, exist_ok=True)
370
+
371
+ def skip(why):
372
+ _append_log(seat_dir, name, "skipped", why)
373
+ return "skipped", why
374
+
375
+ if os.path.exists(os.path.join(cdir, "PAUSE")):
376
+ return skip("paused")
377
+ max_load = float(os.environ.get("LOKI_COMPANY_MAX_LOAD") or DEFAULT_MAX_LOAD)
378
+ load1 = os.getloadavg()[0]
379
+ if load1 > max_load:
380
+ return skip("load %.1f above %.1f" % (load1, max_load))
381
+ if name in LEADER_SEATS:
382
+ leader = _read_pid(os.path.join(root, ".loki", "v10-leader"))
383
+ if leader is not None and leader != os.getpid() and _pid_alive(leader):
384
+ return skip("live leader pid %d holds .loki/v10-leader" % leader)
385
+ lock, holder = acquire_seat_lock(os.path.join(seat_dir, "lock"))
386
+ if lock is None:
387
+ return skip("lock held by pid %s" % holder)
388
+ try:
389
+ if governor_max_engineers() == 0:
390
+ return skip("governor max engineers is 0")
391
+ return _run_claude_tick(root, name, seat_dir)
392
+ finally:
393
+ lock.truncate(0)
394
+ lock.close()
395
+
396
+
397
+ def _run_claude_tick(root, name, seat_dir):
398
+ timeout = float(os.environ.get("LOKI_SEAT_TIMEOUT_SECS") or DEFAULT_SEAT_TIMEOUT_SECS)
399
+ argv = [os.environ.get("LOKI_CLAUDE_BIN") or "claude", "-p", "--agent", name,
400
+ "--dangerously-skip-permissions", "--output-format", "json",
401
+ SEAT_TICK_PROMPT.format(seat=name)]
402
+ last = os.path.join(seat_dir, "last.json")
403
+ started = time.time()
404
+ try:
405
+ # Own session so a timeout can stop the tick's whole process group
406
+ # (claude spawns children) by the group id we created, never by name.
407
+ with open(os.path.join(seat_dir, "last.out"), "w") as out:
408
+ proc = subprocess.Popen(argv, cwd=root, stdin=subprocess.DEVNULL, stdout=out,
409
+ stderr=subprocess.STDOUT, start_new_session=True)
410
+ except OSError as e:
411
+ _append_log(seat_dir, name, "error", "cannot start claude: %s" % e)
412
+ return "error", "cannot start claude: %s" % e
413
+ outcome, detail = "ok", ""
414
+ try:
415
+ proc.wait(timeout=timeout)
416
+ if proc.returncode != 0:
417
+ outcome, detail = "error", "claude rc=%d" % proc.returncode
418
+ except subprocess.TimeoutExpired:
419
+ # TERM, grace, then ALWAYS KILL the group: a grandchild (bun test)
420
+ # can ignore TERM and outlive the leader.
421
+ try:
422
+ os.killpg(proc.pid, signal.SIGTERM)
423
+ proc.wait(timeout=TERM_GRACE_SECS)
424
+ except (ProcessLookupError, PermissionError, subprocess.TimeoutExpired):
425
+ pass
426
+ try:
427
+ os.killpg(proc.pid, signal.SIGKILL)
428
+ except (ProcessLookupError, PermissionError):
429
+ proc.kill() # not a group leader: at least stop the PID we started
430
+ proc.wait()
431
+ outcome, detail = "timeout", "exceeded %ds" % int(timeout)
432
+ fresh = os.path.exists(last) and os.path.getmtime(last) >= started - 1
433
+ if outcome != "ok" or not fresh:
434
+ if outcome == "ok":
435
+ outcome, detail = "no-report", "seat did not write last.json"
436
+ with open(last, "w") as f:
437
+ json.dump({"ts": _now_iso(), "seat": name, "outcome": outcome,
438
+ "actions": [], "evidence": detail}, f)
439
+ _append_log(seat_dir, name, outcome, detail)
440
+ return "ran", ("%s: %s" % (outcome, detail) if detail else outcome)
441
+
442
+
443
+ def default_seat_schedules():
444
+ """Shipped default cadence for the ten company seats."""
445
+ path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "company-seats.json")
446
+ with open(path) as f:
447
+ return json.load(f)
448
+
449
+
450
+ def seed_default_schedules():
451
+ """Add default seat schedules missing from schedules.json. Returns count added."""
452
+ schedules = load_schedules()
453
+ have = {s.get("name") for s in schedules}
454
+ added = 0
455
+ for d in default_seat_schedules():
456
+ if d["name"] not in have:
457
+ schedules.append(dict(d))
458
+ added += 1
459
+ if added:
460
+ save_schedules(schedules)
461
+ return added
462
+
463
+
464
+ def _fire_minutes(cron_expr, start, span_minutes):
465
+ t = start
466
+ for _ in range(span_minutes):
467
+ if cron_matches(cron_expr, t):
468
+ yield t
469
+ t += timedelta(minutes=1)
470
+
471
+
472
+ def interval_minutes(cron_expr):
473
+ """Smallest gap in minutes between consecutive fires (None if fewer than 2 in 8 days)."""
474
+ fires = list(_fire_minutes(cron_expr, datetime(2026, 1, 5, 0, 0), 8 * 1440))
475
+ gaps = [int((b - a).total_seconds() // 60) for a, b in zip(fires, fires[1:])]
476
+ return min(gaps) if gaps else None
477
+
478
+
479
+ def next_fire(cron_expr, after):
480
+ """First fire strictly after `after` (naive datetime), within 8 days, else None."""
481
+ start = after.replace(second=0, microsecond=0) + timedelta(minutes=1)
482
+ return next(_fire_minutes(cron_expr, start, 8 * 1440), None)
483
+
484
+
485
+ def launchagent_path(home):
486
+ return os.path.join(home, "Library", "LaunchAgents", LAUNCHAGENT_LABEL + ".plist")
487
+
488
+
489
+ def write_launchagent(home, repo, loki_bin):
490
+ """Write the LaunchAgent plist under `home`. Does not load it."""
491
+ logs = os.path.join(home, ".loki", "logs")
492
+ os.makedirs(logs, exist_ok=True)
493
+ plist = {
494
+ "Label": LAUNCHAGENT_LABEL,
495
+ "ProgramArguments": [loki_bin, "trigger", "daemon", "--loop"],
496
+ "WorkingDirectory": repo,
497
+ "EnvironmentVariables": {
498
+ "LOKI_COMPANY_ROOT": repo,
499
+ "PATH": os.environ.get("PATH", "/usr/local/bin:/usr/bin:/bin"),
500
+ "HOME": home,
501
+ },
502
+ "RunAtLoad": True,
503
+ "KeepAlive": True,
504
+ "ThrottleInterval": 30,
505
+ "StandardOutPath": os.path.join(logs, "company.out.log"),
506
+ "StandardErrorPath": os.path.join(logs, "company.err.log"),
507
+ }
508
+ path = launchagent_path(home)
509
+ os.makedirs(os.path.dirname(path), exist_ok=True)
510
+ with os.fdopen(os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600), "wb") as f:
511
+ plistlib.dump(plist, f)
512
+ os.chmod(path, 0o600)
513
+ return path
514
+
515
+
516
+ def remove_launchagent(home):
517
+ path = launchagent_path(home)
518
+ if not os.path.exists(path):
519
+ return False
520
+ os.unlink(path)
521
+ return True
522
+
523
+
524
+ def systemd_unit(repo, loki_bin):
525
+ return "\n".join([
526
+ "[Unit]",
527
+ "Description=Loki Mode company seats (schedule daemon)",
528
+ "",
529
+ "[Service]",
530
+ "WorkingDirectory=%s" % repo,
531
+ "Environment=LOKI_COMPANY_ROOT=%s" % repo,
532
+ "ExecStart=%s trigger daemon --loop" % loki_bin,
533
+ "Restart=always",
534
+ "RestartSec=30",
535
+ "",
536
+ "[Install]",
537
+ "WantedBy=default.target",
538
+ "",
539
+ ])
540
+
541
+
542
+ def _launchctl(*args):
543
+ return subprocess.run(["launchctl"] + list(args), capture_output=True, text=True)
544
+
545
+
546
+ def install_company(no_load=False):
547
+ home = os.path.expanduser("~")
548
+ repo = company_root()
549
+ loki_bin = os.environ.get("LOKI_BIN") or shutil.which("loki") or "loki"
550
+ added = seed_default_schedules()
551
+ print("Seeded %d default seat schedule(s) into .loki/triggers/schedules.json" % added)
552
+ if sys.platform != "darwin":
553
+ print("NOT SUPPORTED: launchd install is macOS only. Equivalent systemd user unit "
554
+ "(~/.config/systemd/user/loki-company.service):\n")
555
+ print(systemd_unit(repo, loki_bin))
556
+ return 0
557
+ path = write_launchagent(home, repo, loki_bin)
558
+ print("Wrote %s" % path)
559
+ if not no_load:
560
+ dom = "gui/%d" % os.getuid()
561
+ _launchctl("bootout", dom, path)
562
+ r = _launchctl("bootstrap", dom, path)
563
+ if r.returncode != 0:
564
+ print("launchctl bootstrap failed: %s" % (r.stderr.strip() or r.stdout.strip()))
565
+ return 1
566
+ print("Loaded %s" % LAUNCHAGENT_LABEL)
567
+ return 0
568
+
569
+
570
+ def uninstall_company(no_load=False):
571
+ home = os.path.expanduser("~")
572
+ if sys.platform != "darwin":
573
+ print("NOT SUPPORTED: nothing to remove (launchd is macOS only).")
574
+ return 0
575
+ path = launchagent_path(home)
576
+ if not no_load and os.path.exists(path):
577
+ _launchctl("bootout", "gui/%d" % os.getuid(), path)
578
+ print("Removed %s" % path if remove_launchagent(home) else "Not installed: %s" % path)
579
+ return 0
580
+
581
+
210
582
  def log_event(name, action_desc, status):
211
583
  """Log schedule fire to events.log."""
212
584
  log_path = get_loki_dir() / "events.log"
@@ -249,6 +621,20 @@ def run_daemon(dry_run=False, once=False):
249
621
  return True
250
622
 
251
623
 
624
+ def run_daemon_loop(max_passes=None):
625
+ """Always-on mode: one pass at the top of every minute (launchd KeepAlive)."""
626
+ passes = 0
627
+ while max_passes is None or passes < max_passes:
628
+ try:
629
+ run_daemon(once=True)
630
+ except Exception: # keep the always-on loop alive across a bad pass
631
+ logging.exception("daemon pass failed")
632
+ passes += 1
633
+ if max_passes is not None and passes >= max_passes:
634
+ break
635
+ time.sleep(60 - time.time() % 60)
636
+
637
+
252
638
  def list_schedules():
253
639
  """Print all configured schedules."""
254
640
  schedules = load_schedules()
@@ -321,6 +707,15 @@ def main():
321
707
  # daemon - run one pass
322
708
  daemon_parser = subparsers.add_parser("daemon", help="Run one pass of schedule checks")
323
709
  daemon_parser.add_argument("--dry-run", action="store_true", help="Preview without running")
710
+ daemon_parser.add_argument("--loop", action="store_true", help="Keep running, one pass per minute")
711
+ daemon_parser.add_argument("--max-passes", type=int, default=None, help=argparse.SUPPRESS)
712
+
713
+ tick_parser = subparsers.add_parser("tick", help="Run one headless tick of a company seat")
714
+ tick_parser.add_argument("seat")
715
+ subparsers.add_parser("seed", help="Add default company-seat schedules")
716
+ for cmd_name in ("install", "uninstall"):
717
+ ip = subparsers.add_parser(cmd_name, help="%s the always-on company LaunchAgent" % cmd_name.capitalize())
718
+ ip.add_argument("--no-load", action="store_true", help="Write/remove the plist without launchctl")
324
719
 
325
720
  # list
326
721
  subparsers.add_parser("list", help="List configured schedules")
@@ -347,7 +742,20 @@ def main():
347
742
 
348
743
  if args.command == "daemon" or args.command is None:
349
744
  dry_run = getattr(args, "dry_run", False)
350
- run_daemon(dry_run=dry_run, once=True)
745
+ if getattr(args, "loop", False):
746
+ run_daemon_loop(getattr(args, "max_passes", None))
747
+ else:
748
+ run_daemon(dry_run=dry_run, once=True)
749
+ elif args.command == "tick":
750
+ status, detail = tick_seat(args.seat)
751
+ print("%s: %s" % (status, detail))
752
+ sys.exit(1 if status == "error" else 0)
753
+ elif args.command == "seed":
754
+ print("Added %d default seat schedule(s)" % seed_default_schedules())
755
+ elif args.command == "install":
756
+ sys.exit(install_company(args.no_load))
757
+ elif args.command == "uninstall":
758
+ sys.exit(uninstall_company(args.no_load))
351
759
  elif args.command == "list":
352
760
  list_schedules()
353
761
  elif args.command == "add":
@@ -115,6 +115,11 @@ _loki_completion() {
115
115
  COMPREPLY=( $(compgen -W "${telemetry_cmds}" -- "$cur") )
116
116
  ;;
117
117
 
118
+ trigger)
119
+ local trigger_cmds="start stop status list add remove daemon install uninstall test logs help --dry-run --loop --no-load"
120
+ COMPREPLY=( $(compgen -W "${trigger_cmds}" -- "$cur") )
121
+ ;;
122
+
118
123
  agent)
119
124
  local agent_cmds="list info run start help"
120
125
  COMPREPLY=( $(compgen -W "${agent_cmds}" -- "$cur") )
@@ -7,6 +7,6 @@ Modules:
7
7
  control: Session control helpers (status, atomic writes, origin check)
8
8
  """
9
9
 
10
- __version__ = "11.73.0"
10
+ __version__ = "11.74.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.73.0. Do not edit by hand.
3
+ Generated from `loki-ts/src/cli/registry.ts` by `scripts/generate-stale-zero.sh` for v11.74.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.73.0
5
+ **Version:** v11.74.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
 
@@ -2016,3 +2016,10 @@ L0 review rule (ENGINE-LAWS.md L0): a fix that adds an `if` or a regex about the
2016
2016
  - Siblings swept: every MAIN_REF consumer (Main CI, unreleased-merge age, release cadence, moat baseline, unevidenced-claim scan, release-on-red) shares one resolved ref; the `gh --branch` streak call keeps the branch name `main`.
2017
2017
  - Mechanism: one resolution in scripts/v10-pulse.sh. PULSE_MAIN_REF unset and local main a strict ancestor of refs/remotes/origin/main: MAIN_REF becomes the remote-tracking ref and STALE_MAIN_REF fires with N. Diverged: STALE_MAIN_REF says diverged and the ref is unchanged. An explicit PULSE_MAIN_REF wins. No fetch.
2018
2018
  - Fixture: tests/test-v10-pulse.sh "RQ-98" (behind by 2, equal, diverged, explicit pin; two mutants).
2019
+
2020
+ ## FC-VERDICT-FROM-UNKNOWN-INPUT The pulse emitted `VIOLATION: TRAIN_LATE` at main 530b63296 off a merged-but-unreleased count (stale local tag v11.70.4) that the same run had already marked UNKNOWN against npm next 11.74.0
2021
+ - User-visible vs raw: user-visible. Main was fully released (`git log -1 main` = "release: v11.74.0"), yet the pulse told the operator to push a release train.
2022
+ - Law broken: a verdict is never computed from an input the same run already marked UNKNOWN; it reports UNKNOWN.
2023
+ - Siblings swept: every reader of `unreleased` in scripts/v10-pulse.sh: TRAIN_LATE (fixed, was gated only on `unreleased is None`), D44 green-unreleased RELEASE_CADENCE (fixed, same gap), release cadence D37 and merged-not-released stale (already gated, now via the shared helper).
2024
+ - Mechanism: one helper `_unreleased_count_unknown()` in scripts/v10-pulse.sh (check failed OR local tag disagrees with npm next); every check reading `unreleased` returns None (UNKNOWN) when it is true.
2025
+ - Fixture: tests/test-v10-pulse.sh "FC-VERDICT-FROM-UNKNOWN-INPUT" (tag v9.54.2 vs npm next 9.55.0, push 26 min old: UNKNOWN line, no TRAIN_LATE).