loki-mode 11.73.0 → 11.74.0
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/SKILL.md +2 -2
- package/VERSION +1 -1
- package/autonomy/company-seats.json +82 -0
- package/autonomy/loki +14 -4
- package/autonomy/trigger-schedule.py +414 -6
- package/completions/loki.bash +5 -0
- package/dashboard/__init__.py +1 -1
- package/docs/CLI-REFERENCE.md +1 -1
- package/docs/INSTALLATION.md +1 -1
- package/docs/v10/OPERATING-MODEL.md +6 -0
- package/loki-ts/dist/loki.js +2 -2
- package/mcp/__init__.py +1 -1
- package/package.json +2 -2
- package/plugins/loki-mode/.claude-plugin/plugin.json +1 -1
- package/tools/cockpit/index.html +2 -0
- package/tools/cockpit/server.py +55 -0
- package/web-app/dist/assets/{AdminPage-DLxU8cWs.js → AdminPage-tOzodPzi.js} +1 -1
- package/web-app/dist/assets/{Avatar-FpXoF8Iv.js → Avatar-gnHOTpn7.js} +1 -1
- package/web-app/dist/assets/{Badge-BpPOaQ1Z.js → Badge-D-zPIsIE.js} +1 -1
- package/web-app/dist/assets/{Button-BwM7fgQH.js → Button-Ba3teZuX.js} +1 -1
- package/web-app/dist/assets/{CockpitPage-nc-CvHy4.js → CockpitPage-DnB6jCFt.js} +1 -1
- package/web-app/dist/assets/{ComparePage-30XVTYmd.js → ComparePage-Dv5VR5dI.js} +1 -1
- package/web-app/dist/assets/{ErrorBoundary-CfKqjdpH.js → ErrorBoundary-D3PxG1EY.js} +1 -1
- package/web-app/dist/assets/{EvidenceReceiptPanel-BVIX0kAo.js → EvidenceReceiptPanel-CrpPxnCl.js} +1 -1
- package/web-app/dist/assets/{GitHubIssuesPanel-CDmHswpW.js → GitHubIssuesPanel-F7xTGZKO.js} +1 -1
- package/web-app/dist/assets/{GitHubPRsPanel-E-OqBH5f.js → GitHubPRsPanel-BYXG9iC7.js} +1 -1
- package/web-app/dist/assets/{HomePage-CESGv5fC.js → HomePage-CO9c8oIn.js} +2 -2
- package/web-app/dist/assets/{LoginPage-BY0y6aE6.js → LoginPage-CmN5NyRe.js} +1 -1
- package/web-app/dist/assets/{MagicPage-BpjToOBX.js → MagicPage-BPUMtPt8.js} +1 -1
- package/web-app/dist/assets/{MetricsPage-DpO52H9O.js → MetricsPage-Dm3GHAsm.js} +1 -1
- package/web-app/dist/assets/{NotFoundPage-BXU0NGlM.js → NotFoundPage-Boe4TCcQ.js} +1 -1
- package/web-app/dist/assets/{ProjectPage-CewcEfOG.js → ProjectPage-BFc_Uj5w.js} +3 -3
- package/web-app/dist/assets/{ProjectsPage-C7LZkpcR.js → ProjectsPage-DMQ5nIgH.js} +1 -1
- package/web-app/dist/assets/{SettingsPage-DHp1BCgG.js → SettingsPage-DDSWgjSO.js} +1 -1
- package/web-app/dist/assets/{ShowcasePage-C_E4yQad.js → ShowcasePage-C1zCzPa1.js} +1 -1
- package/web-app/dist/assets/{SystemSettingsPage-wK7KRqw1.js → SystemSettingsPage-D0CAotwF.js} +1 -1
- package/web-app/dist/assets/{TeamsPage-CiMcY4GR.js → TeamsPage-CPYm4yhm.js} +1 -1
- package/web-app/dist/assets/{TemplatesPage-pGUCVtD6.js → TemplatesPage-BdoBMm29.js} +1 -1
- package/web-app/dist/assets/{TerminalOutput-CvD75-gW.js → TerminalOutput-CuKnS9oh.js} +1 -1
- package/web-app/dist/assets/{activity-DSy_WjFp.js → activity-Cs5LdWlh.js} +1 -1
- package/web-app/dist/assets/{bell-BRHWRNQ-.js → bell-IueQl5bE.js} +1 -1
- package/web-app/dist/assets/{bot-CacHJXqJ.js → bot-Cpzd92QS.js} +1 -1
- package/web-app/dist/assets/{check-DcGs1awd.js → check-xIB1DeBr.js} +1 -1
- package/web-app/dist/assets/{chevron-left-BpKToVdV.js → chevron-left-B5b17KZc.js} +1 -1
- package/web-app/dist/assets/{circle-alert-Cvg68BPU.js → circle-alert-BfWbFPEh.js} +1 -1
- package/web-app/dist/assets/{clock-Bm-746fH.js → clock-By9_QIi9.js} +1 -1
- package/web-app/dist/assets/{cloud-DkpICGV1.js → cloud-DIoOyUKF.js} +1 -1
- package/web-app/dist/assets/{code-xml-Bnps1g8p.js → code-xml-DpKRt91V.js} +1 -1
- package/web-app/dist/assets/{database-DXpzjteu.js → database-pPFHh_ki.js} +1 -1
- package/web-app/dist/assets/{dollar-sign-B03JgYAV.js → dollar-sign-Cpg8N8aR.js} +1 -1
- package/web-app/dist/assets/{file-code-corner-BuEiz3XW.js → file-code-corner-B6KJxWC_.js} +1 -1
- package/web-app/dist/assets/{file-plus-DhM25BHK.js → file-plus-C2KqIxgl.js} +1 -1
- package/web-app/dist/assets/{globe-_Xv1tJB8.js → globe-Dfp84gVc.js} +1 -1
- package/web-app/dist/assets/{hammer-D9ZYHuG8.js → hammer-8Y1MYrke.js} +1 -1
- package/web-app/dist/assets/{index-BD-nW46P.js → index-Diz9R0Ad.js} +3 -3
- package/web-app/dist/assets/{layers-B5nOPH68.js → layers-Bl1-P7Mb.js} +1 -1
- package/web-app/dist/assets/{loader-circle-c1OVmB63.js → loader-circle-BBLKWE-I.js} +1 -1
- package/web-app/dist/assets/{lock-B5_SQpPS.js → lock-CjVUTsEb.js} +1 -1
- package/web-app/dist/assets/{package-Cx_KC4IW.js → package-D80urViP.js} +1 -1
- package/web-app/dist/assets/{plus-BCoCE3wz.js → plus-EBsGo-WE.js} +1 -1
- package/web-app/dist/assets/{refresh-cw-ComZ_-zw.js → refresh-cw-Cj4D7iA7.js} +1 -1
- package/web-app/dist/assets/{rotate-ccw-D-2b38Jn.js → rotate-ccw-D4fUvKp7.js} +1 -1
- package/web-app/dist/assets/{scroll-text-BIupqEqW.js → scroll-text-KPjNlar2.js} +1 -1
- package/web-app/dist/assets/{server-jYMEfnFP.js → server-BxSS3qLC.js} +1 -1
- package/web-app/dist/assets/{shield-alert-DQmUPlgL.js → shield-alert-kmsOLKh_.js} +1 -1
- package/web-app/dist/assets/{thumbs-up-CaMdkKBO.js → thumbs-up-BFA68HSV.js} +1 -1
- package/web-app/dist/assets/{trash-2-DUGxfddn.js → trash-2-DPSwmDYc.js} +1 -1
- package/web-app/dist/assets/{trending-down-DK-pYPoa.js → trending-down-C8cVBNwP.js} +1 -1
- package/web-app/dist/assets/{trending-up-BPhgmVjq.js → trending-up-BazAYgbt.js} +1 -1
- package/web-app/dist/assets/{upload-zGWNLWJm.js → upload-B14dm_MM.js} +1 -1
- package/web-app/dist/assets/{usePolling-CYApkja5.js → usePolling-ELsOlZfz.js} +1 -1
- package/web-app/dist/assets/{user-D7GR1bID.js → user-4bTC0Fne.js} +1 -1
- 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.
|
|
6
|
+
# Loki Mode v11.74.0
|
|
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.
|
|
479
|
+
**v11.74.0 | [Autonomi](https://www.autonomi.dev/) flagship product | core skill**
|
package/VERSION
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
11.
|
|
1
|
+
11.74.0
|
|
@@ -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
|
|
21330
|
-
[[ "${1:-}" == "--dry-run" ]] &&
|
|
21331
|
-
|
|
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]
|
|
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
|
-
|
|
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
|
-
|
|
189
|
-
|
|
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
|
-
|
|
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":
|
package/completions/loki.bash
CHANGED
|
@@ -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") )
|
package/dashboard/__init__.py
CHANGED
package/docs/CLI-REFERENCE.md
CHANGED
|
@@ -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.
|
|
3
|
+
Generated from `loki-ts/src/cli/registry.ts` by `scripts/generate-stale-zero.sh` for v11.74.0. 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 |
|
package/docs/INSTALLATION.md
CHANGED
|
@@ -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.
|
|
5
|
+
**Version:** v11.74.0
|
|
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
|
|
|
@@ -52,3 +52,9 @@ Spawned per slice, not standing seats:
|
|
|
52
52
|
- Every incident becomes a guard with a test (docs/v10/GUARDS.md, D26).
|
|
53
53
|
- The pulse block (scripts/v10-pulse.sh) is injected every turn. Act on the top VIOLATION first.
|
|
54
54
|
- Never kill by name or pattern, force-push, git add -A, or bypass branch protection.
|
|
55
|
+
|
|
56
|
+
## Always-on seats (headless)
|
|
57
|
+
- `loki trigger install` seeds one `seat <name>` schedule per .claude/agents/*.md (autonomy/company-seats.json) and installs the LaunchAgent dev.autonomi.loki-company, which runs `loki trigger daemon --loop`. `loki trigger uninstall` removes it. Linux prints a systemd user unit.
|
|
58
|
+
- A tick is `claude -p --agent <name> --dangerously-skip-permissions --output-format json` in the repo root, 20 min cap (LOKI_SEAT_TIMEOUT_SECS). The seat writes .loki/company/<name>/last.json and log.jsonl.
|
|
59
|
+
- Skips: .loki/company/PAUSE, 1-min load above 28, usage governor max engineers 0, a live PID in .loki/v10-leader (chief-of-staff and release-reliability-lead only), or a live per-seat lock. Only the tick's own process group is ever stopped.
|
|
60
|
+
- `loki trigger uninstall` does not stop ticks already running; `touch .loki/company/PAUSE` stops all seats.
|