loki-mode 10.6.6 → 10.6.8
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/.github/actions/issue-to-pr/action.yml +25 -11
- package/.github/workflows/loki-issue-to-pr.yml +21 -10
- package/README.md +138 -54
- package/SKILL.md +11 -8
- package/VERSION +1 -1
- package/autonomy/lib/workspace.py +290 -0
- package/autonomy/lib/worktree_prep.py +143 -0
- package/autonomy/loki +68 -5
- package/autonomy/run.sh +26 -1
- package/bin/loki +9 -1
- package/completions/_loki +1 -0
- package/completions/loki.bash +1 -2
- package/dashboard/__init__.py +1 -1
- package/docs/AUDIT-CHAIN-THREAT-MODEL.md +1 -1
- package/docs/DEMOS.md +11 -26
- package/docs/EVALUATING.md +2 -3
- package/docs/INSTALLATION.md +2 -2
- package/docs/TOOLS.md +1 -1
- package/docs/WANG-PRINCIPLES-PLAN.md +1 -1
- package/docs/WORKSPACES.md +29 -0
- package/docs/air-gapped.md +1 -1
- package/docs/alternative-installations.md +3 -1
- package/docs/control-plane-container.md +45 -0
- package/docs/dashboard-guide.md +2 -0
- package/docs/dev/architecture-reference.md +4 -0
- package/docs/dev/project-structure.md +2 -2
- package/docs/dev/release-checklist.md +1 -2
- package/docs/enterprise/architecture.md +1 -1
- package/docs/siem-integration.md +2 -2
- package/docs/slack.md +26 -0
- package/docs/v10/BOARD.md +26 -1
- package/docs/v10/CONTROL-PLANE.md +4 -0
- package/docs/v10/D61-SPEED.md +83 -0
- package/docs/v10/DECISIONS.md +33 -0
- package/docs/v10/DEPS.md +6 -2
- package/docs/v10/E2E-D65.md +87 -0
- package/docs/v10/GUIDE.md +63 -1
- package/docs/v10/HANDOFF-NEXT-WEEK.md +40 -0
- package/docs/v10/PROGRESS.md +13 -0
- package/docs/v10/ROADMAP-D63.md +35 -0
- package/loki-ts/dist/loki.js +593 -569
- package/mcp/__init__.py +1 -1
- package/mcp/server.py +76 -0
- package/mcp/tests/test_v10_tools.py +137 -0
- package/mcp/v10_tools.py +172 -0
- package/package.json +1 -1
- package/packages/control-plane/dist/server.js +56 -4
- package/packages/control-plane/src/server/answer.ts +33 -0
- package/packages/control-plane/src/server/app.ts +17 -1
- package/packages/control-plane/src/server/runs.ts +2 -0
- package/packages/control-plane/src/server/serve.ts +2 -2
- package/packages/control-plane/ui/dist/assets/index-53E32yrO.js +114 -0
- package/packages/control-plane/ui/dist/assets/{index-BH3LtKcG.css → index-CFfrqCx2.css} +1 -1
- package/packages/control-plane/ui/dist/index.html +2 -2
- package/plugins/loki-mode/.claude-plugin/plugin.json +1 -1
- package/web-app/dist/assets/{AdminPage-CH-bmw4l.js → AdminPage-DN_pe8vo.js} +1 -1
- package/web-app/dist/assets/{Avatar-Bos-ky_x.js → Avatar-BnQsr8z-.js} +1 -1
- package/web-app/dist/assets/{Badge-DNJyHg9Y.js → Badge-DzERiZn2.js} +1 -1
- package/web-app/dist/assets/{Button-KqD3Tt-I.js → Button-CaaoxQAt.js} +1 -1
- package/web-app/dist/assets/{CockpitPage-l6oTsRaL.js → CockpitPage-SXhnysJA.js} +1 -1
- package/web-app/dist/assets/{ComparePage-DN6uxXko.js → ComparePage-krxTCFJY.js} +1 -1
- package/web-app/dist/assets/{ErrorBoundary-CKQhnvU9.js → ErrorBoundary-BL96ESMc.js} +1 -1
- package/web-app/dist/assets/{EvidenceReceiptPanel-CzSIqLmy.js → EvidenceReceiptPanel-PUiGSpkB.js} +1 -1
- package/web-app/dist/assets/{GitHubIssuesPanel-YJUcDezn.js → GitHubIssuesPanel-C0hPs-P2.js} +1 -1
- package/web-app/dist/assets/{GitHubPRsPanel-Bv7xFvJN.js → GitHubPRsPanel-CLvqNUlu.js} +1 -1
- package/web-app/dist/assets/{HomePage-CJsxBbii.js → HomePage-X-sq_Rc9.js} +2 -2
- package/web-app/dist/assets/{LoginPage-D1V2Ag0s.js → LoginPage-DhQuwdua.js} +1 -1
- package/web-app/dist/assets/{MagicPage-BBVNMEr5.js → MagicPage-CP2ODjfn.js} +1 -1
- package/web-app/dist/assets/{MetricsPage-CtHM-IlA.js → MetricsPage-C6SYiQES.js} +1 -1
- package/web-app/dist/assets/{NotFoundPage-BBEEJLj5.js → NotFoundPage-CCDxxYc-.js} +1 -1
- package/web-app/dist/assets/{ProjectPage-BVRJoEaQ.js → ProjectPage-yDF--8FQ.js} +3 -3
- package/web-app/dist/assets/{ProjectsPage-DqaA9rUa.js → ProjectsPage-BBLspdLn.js} +1 -1
- package/web-app/dist/assets/{SettingsPage-B_RQV_vF.js → SettingsPage-C_sWbQRw.js} +1 -1
- package/web-app/dist/assets/{ShowcasePage-DqN-mrZ8.js → ShowcasePage-xCMuL0N8.js} +1 -1
- package/web-app/dist/assets/{SystemSettingsPage-D-CvuL-K.js → SystemSettingsPage-DnJKGCx7.js} +1 -1
- package/web-app/dist/assets/{TeamsPage-CzkkDcPj.js → TeamsPage-BBXdhrSs.js} +1 -1
- package/web-app/dist/assets/{TemplatesPage-Bbchk7YQ.js → TemplatesPage-AZ1Lr1rn.js} +1 -1
- package/web-app/dist/assets/{TerminalOutput-B32BAX--.js → TerminalOutput-CtIm9tXc.js} +1 -1
- package/web-app/dist/assets/{activity-C9vnEmhG.js → activity-Dk1FK25Q.js} +1 -1
- package/web-app/dist/assets/{bell-XsIhkyjq.js → bell-dhqXyzP4.js} +1 -1
- package/web-app/dist/assets/{bot-CvQhqBws.js → bot-CExHd4Kf.js} +1 -1
- package/web-app/dist/assets/{check-HmG8WcD-.js → check-D51zaA1a.js} +1 -1
- package/web-app/dist/assets/{chevron-left-C1KNgnbD.js → chevron-left-DEwnQzH1.js} +1 -1
- package/web-app/dist/assets/{circle-alert-DSmCvZju.js → circle-alert-C6U90Msk.js} +1 -1
- package/web-app/dist/assets/{clock-CPhE3RXd.js → clock-6mFbMzzH.js} +1 -1
- package/web-app/dist/assets/{cloud-29lwqocB.js → cloud-BrZZtE7F.js} +1 -1
- package/web-app/dist/assets/{code-xml-4jZUzbPt.js → code-xml-DTFzeepP.js} +1 -1
- package/web-app/dist/assets/{database-H0Ct2r-8.js → database-B5rEiTre.js} +1 -1
- package/web-app/dist/assets/{dollar-sign-DsXkO0yT.js → dollar-sign-bQs4rlS_.js} +1 -1
- package/web-app/dist/assets/{file-code-corner-BM8w_nvX.js → file-code-corner-Mo6e6qbf.js} +1 -1
- package/web-app/dist/assets/{file-plus-BWkYTJ4D.js → file-plus-DzHZ0jpP.js} +1 -1
- package/web-app/dist/assets/{globe-FOFDl_8Z.js → globe-DYN9UhTa.js} +1 -1
- package/web-app/dist/assets/{hammer-orm5gvky.js → hammer-bB0hSRcJ.js} +1 -1
- package/web-app/dist/assets/{index-B6juCuPX.js → index-CIH5CqZB.js} +3 -3
- package/web-app/dist/assets/{layers-DUQPkPAl.js → layers-qhFkY8Nu.js} +1 -1
- package/web-app/dist/assets/{loader-circle-Deq6bDar.js → loader-circle-BGBkaYH7.js} +1 -1
- package/web-app/dist/assets/{lock-CSzWTPRC.js → lock-DIcsNFum.js} +1 -1
- package/web-app/dist/assets/{package-CmACgZuF.js → package-BdIIbZhS.js} +1 -1
- package/web-app/dist/assets/{plus-BjPW5v3A.js → plus-Co1Bv8zT.js} +1 -1
- package/web-app/dist/assets/{refresh-cw-DhI1sHaj.js → refresh-cw-CGF4ABWH.js} +1 -1
- package/web-app/dist/assets/{rotate-ccw-Byi2ouWB.js → rotate-ccw-BX10Q79h.js} +1 -1
- package/web-app/dist/assets/{scroll-text-gq7bJ81H.js → scroll-text-CbXDlpGp.js} +1 -1
- package/web-app/dist/assets/{server-YbY5s1-X.js → server-CIhi6yUO.js} +1 -1
- package/web-app/dist/assets/{shield-alert-DhKjtwZt.js → shield-alert-DJ5fB6Dg.js} +1 -1
- package/web-app/dist/assets/{thumbs-up-D2X5JR-5.js → thumbs-up-BEft2T94.js} +1 -1
- package/web-app/dist/assets/{trash-2-BHAmMxTq.js → trash-2-CWGUPn7Y.js} +1 -1
- package/web-app/dist/assets/{trending-down-C41kQa-Q.js → trending-down-CjqstvxQ.js} +1 -1
- package/web-app/dist/assets/{trending-up-M-OK_9DC.js → trending-up-CTyLjMzE.js} +1 -1
- package/web-app/dist/assets/{upload-CarpPiGw.js → upload-BmRx-uv4.js} +1 -1
- package/web-app/dist/assets/{usePolling-rJ7rM7R0.js → usePolling-ChHkSJye.js} +1 -1
- package/web-app/dist/assets/{user-cBhWpZen.js → user-ZXh_f35z.js} +1 -1
- package/web-app/dist/index.html +1 -1
- package/docs/walkthrough/README.md +0 -44
- package/packages/control-plane/ui/dist/assets/index-B6EiyukW.js +0 -114
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""loki workspace: run one issue across several repos (D51 Phase B, D65).
|
|
3
|
+
|
|
4
|
+
loki workspace list
|
|
5
|
+
loki workspace run <name> <issue-ref>
|
|
6
|
+
|
|
7
|
+
Gated by LOKI_WORKSPACES=1. Workspaces are defined in loki.yaml:
|
|
8
|
+
|
|
9
|
+
workspaces:
|
|
10
|
+
shop:
|
|
11
|
+
repos:
|
|
12
|
+
- {repo: acme/api, path: ~/src/api, setup: "npm ci"}
|
|
13
|
+
- {repo: acme/web, path: ~/src/web, after: [acme/api]}
|
|
14
|
+
integration: {command: "make e2e", timeout_s: 900}
|
|
15
|
+
|
|
16
|
+
Each repo gets its own worktree and engine run (continue-and-report: a failing
|
|
17
|
+
repo does not stop the others; dependents of a failed repo are SKIPPED). After
|
|
18
|
+
every repo has finished, the integration command runs once with each run branch
|
|
19
|
+
checked out, and evidence lands in
|
|
20
|
+
.loki/workspaces/<name>/<run-id>/integration.json. Integration evidence is not
|
|
21
|
+
part of the Seal.
|
|
22
|
+
|
|
23
|
+
Exit: 0 all repos ok and integration not failed; 1 otherwise; 2 usage/config.
|
|
24
|
+
|
|
25
|
+
Test seam: LOKI_WORKSPACE_LAUNCHER replaces the engine launcher (called with
|
|
26
|
+
the issue ref or task text, cwd = the repo worktree).
|
|
27
|
+
"""
|
|
28
|
+
import datetime
|
|
29
|
+
import hashlib
|
|
30
|
+
import json
|
|
31
|
+
import os
|
|
32
|
+
import re
|
|
33
|
+
import signal
|
|
34
|
+
import subprocess
|
|
35
|
+
import sys
|
|
36
|
+
|
|
37
|
+
HERE = os.path.dirname(os.path.abspath(__file__))
|
|
38
|
+
sys.path.insert(0, HERE)
|
|
39
|
+
|
|
40
|
+
REPO_ROOT = os.path.abspath(os.path.join(HERE, "..", ".."))
|
|
41
|
+
REPO_RE = re.compile(r"^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$")
|
|
42
|
+
REF_RE = re.compile(r"^([A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+)#(\d+)$")
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def say(msg):
|
|
46
|
+
print(msg, flush=True)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def enabled(env=None):
|
|
50
|
+
return (os.environ if env is None else env).get("LOKI_WORKSPACES") == "1"
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def git(*args, cwd=None):
|
|
54
|
+
return subprocess.run(["git", *args], cwd=cwd, capture_output=True, text=True)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def load_workspaces(cfg=None):
|
|
58
|
+
"""Return (workspaces, errors) from loki.yaml (or the given cfg mapping)."""
|
|
59
|
+
if cfg is None:
|
|
60
|
+
import loki_yaml
|
|
61
|
+
cfg, _path, errors = loki_yaml.load()
|
|
62
|
+
if errors:
|
|
63
|
+
return {}, errors
|
|
64
|
+
return cfg.get("workspaces") or {}, []
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def order_repos(ws):
|
|
68
|
+
"""Return repo entries ordered so every `after` predecessor comes first.
|
|
69
|
+
|
|
70
|
+
Raises ValueError on an unknown `after` entry, a duplicate repo or a cycle.
|
|
71
|
+
"""
|
|
72
|
+
entries = list(ws.get("repos") or [])
|
|
73
|
+
names = [e.get("repo") for e in entries]
|
|
74
|
+
if len(set(names)) != len(names):
|
|
75
|
+
raise ValueError("duplicate repo in workspace")
|
|
76
|
+
for e in entries:
|
|
77
|
+
if not isinstance(e.get("repo"), str) or not REPO_RE.match(e["repo"]):
|
|
78
|
+
raise ValueError("repo must be owner/name, got %r" % (e.get("repo"),))
|
|
79
|
+
for dep in e.get("after") or []:
|
|
80
|
+
if dep not in names:
|
|
81
|
+
raise ValueError("%s: unknown after repo %s" % (e["repo"], dep))
|
|
82
|
+
done, ordered, remaining = set(), [], entries
|
|
83
|
+
while remaining:
|
|
84
|
+
ready = [e for e in remaining if all(d in done for d in e.get("after") or [])]
|
|
85
|
+
if not ready:
|
|
86
|
+
raise ValueError("cycle in `after`: " + ", ".join(e["repo"] for e in remaining))
|
|
87
|
+
for e in ready:
|
|
88
|
+
ordered.append(e)
|
|
89
|
+
done.add(e["repo"])
|
|
90
|
+
remaining = [e for e in remaining if e not in ready]
|
|
91
|
+
return ordered
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _slug(repo):
|
|
95
|
+
return repo.replace("/", "__")
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def source_checkout(entry):
|
|
99
|
+
"""Local checkout for a repo entry: its `path`, else clone into ~/.loki/repos/<slug>."""
|
|
100
|
+
if entry.get("path"):
|
|
101
|
+
return os.path.abspath(os.path.expanduser(entry["path"]))
|
|
102
|
+
dest = os.path.join(os.path.expanduser("~"), ".loki", "repos", _slug(entry["repo"]))
|
|
103
|
+
if not os.path.isdir(os.path.join(dest, ".git")):
|
|
104
|
+
os.makedirs(os.path.dirname(dest), exist_ok=True)
|
|
105
|
+
r = subprocess.run(["git", "clone", "https://github.com/%s.git" % entry["repo"], dest],
|
|
106
|
+
capture_output=True, text=True)
|
|
107
|
+
if r.returncode != 0:
|
|
108
|
+
raise RuntimeError("clone failed: " + (r.stderr.strip().splitlines() or ["git error"])[-1])
|
|
109
|
+
return dest
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def prepare(source, dest, branch, setup):
|
|
113
|
+
"""Make a worktree via worktree_prep when available, else a minimal `git worktree add`."""
|
|
114
|
+
try:
|
|
115
|
+
import worktree_prep
|
|
116
|
+
res = worktree_prep.prepare_worktree(source, dest, branch, setup=setup)
|
|
117
|
+
return res["path"]
|
|
118
|
+
except ImportError:
|
|
119
|
+
pass
|
|
120
|
+
os.makedirs(os.path.dirname(dest), exist_ok=True)
|
|
121
|
+
r = git("worktree", "add", "-B", branch, dest, "HEAD", cwd=source)
|
|
122
|
+
if r.returncode != 0:
|
|
123
|
+
raise RuntimeError("worktree: " + (r.stderr.strip().splitlines() or ["git error"])[-1])
|
|
124
|
+
if setup:
|
|
125
|
+
s = subprocess.run(setup, shell=True, cwd=dest, capture_output=True, text=True)
|
|
126
|
+
if s.returncode != 0:
|
|
127
|
+
raise RuntimeError("setup failed: " + (s.stderr.strip().splitlines() or ["no output"])[-1])
|
|
128
|
+
return dest
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def task_for(entry, ref, siblings):
|
|
132
|
+
"""(launcher argument, extra env) for a repo: the issue ref when it lives here, else task text."""
|
|
133
|
+
m = REF_RE.match(ref)
|
|
134
|
+
if m and m.group(1) == entry["repo"]:
|
|
135
|
+
return ref, {}
|
|
136
|
+
text = ("Implement the change described by %s for the repo %s. Sibling worktrees (read-only "
|
|
137
|
+
"context, do not edit): %s" % (ref, entry["repo"], ", ".join(siblings) or "none"))
|
|
138
|
+
return text, {"LOKI_E10_TASK_TEXT": text}
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def run_integration(ws, run_dir, worktrees, heads):
|
|
142
|
+
"""Run the integration command once; return the evidence dict."""
|
|
143
|
+
integ = ws.get("integration") or {}
|
|
144
|
+
cmd = integ.get("command")
|
|
145
|
+
ev = {"heads": heads, "exit_code": None, "log_sha256": None, "status": "not_configured",
|
|
146
|
+
"seal": False, "note": "integration evidence is not part of the Seal"}
|
|
147
|
+
if not cmd:
|
|
148
|
+
return ev
|
|
149
|
+
timeout = integ.get("timeout_s", 900)
|
|
150
|
+
env = dict(os.environ)
|
|
151
|
+
for repo, wt in worktrees.items():
|
|
152
|
+
env["LOKI_WS_DIR_" + re.sub(r"[^A-Za-z0-9]", "_", repo).upper()] = wt
|
|
153
|
+
log_path = os.path.join(run_dir, "integration.log")
|
|
154
|
+
with open(log_path, "wb") as lf:
|
|
155
|
+
p = subprocess.Popen(cmd, shell=True, cwd=run_dir, env=env, stdout=lf, stderr=subprocess.STDOUT,
|
|
156
|
+
stdin=subprocess.DEVNULL, start_new_session=True)
|
|
157
|
+
try:
|
|
158
|
+
rc = p.wait(timeout=timeout)
|
|
159
|
+
ev["status"] = "passed" if rc == 0 else "failed"
|
|
160
|
+
except subprocess.TimeoutExpired:
|
|
161
|
+
try:
|
|
162
|
+
os.killpg(p.pid, signal.SIGKILL)
|
|
163
|
+
except OSError:
|
|
164
|
+
pass
|
|
165
|
+
p.wait()
|
|
166
|
+
rc = 124
|
|
167
|
+
ev["status"] = "failed"
|
|
168
|
+
ev["note"] += "; timeout after %ss" % timeout
|
|
169
|
+
ev["exit_code"] = rc
|
|
170
|
+
with open(log_path, "rb") as f:
|
|
171
|
+
ev["log_sha256"] = hashlib.sha256(f.read()).hexdigest()
|
|
172
|
+
return ev
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def run_workspace(name, ref, workspaces, base_dir=None, launcher=None):
|
|
176
|
+
"""Run the workspace; return (exit_code, evidence_path_or_None)."""
|
|
177
|
+
ws = workspaces.get(name)
|
|
178
|
+
if ws is None:
|
|
179
|
+
say("workspace: unknown workspace %r (known: %s)" % (name, ", ".join(sorted(workspaces)) or "none"))
|
|
180
|
+
return 2, None
|
|
181
|
+
try:
|
|
182
|
+
ordered = order_repos(ws)
|
|
183
|
+
except ValueError as e:
|
|
184
|
+
say("workspace: %s: %s" % (name, e))
|
|
185
|
+
return 2, None
|
|
186
|
+
if not ordered:
|
|
187
|
+
say("workspace: %s has no repos" % name)
|
|
188
|
+
return 2, None
|
|
189
|
+
|
|
190
|
+
base_dir = base_dir or os.getcwd()
|
|
191
|
+
run_id = datetime.datetime.now(datetime.timezone.utc).strftime("%Y%m%dT%H%M%S%fZ")
|
|
192
|
+
run_dir = os.path.join(base_dir, ".loki", "workspaces", name, run_id)
|
|
193
|
+
os.makedirs(run_dir, exist_ok=True)
|
|
194
|
+
launcher = launcher or os.environ.get("LOKI_WORKSPACE_LAUNCHER") or os.path.join(REPO_ROOT, "bin", "loki")
|
|
195
|
+
child_env_base = dict(os.environ, LOKI_ENGINE="v10", LOKI_NO_BROWSER="1")
|
|
196
|
+
|
|
197
|
+
outcome, worktrees, heads = {}, {}, {}
|
|
198
|
+
running = {}
|
|
199
|
+
|
|
200
|
+
def stop(*_):
|
|
201
|
+
for p in running.values():
|
|
202
|
+
try:
|
|
203
|
+
os.killpg(p.pid, signal.SIGTERM)
|
|
204
|
+
except OSError:
|
|
205
|
+
pass
|
|
206
|
+
sys.exit(130)
|
|
207
|
+
signal.signal(signal.SIGTERM, stop)
|
|
208
|
+
|
|
209
|
+
for i, entry in enumerate(ordered):
|
|
210
|
+
repo = entry["repo"]
|
|
211
|
+
failed_dep = [d for d in entry.get("after") or [] if outcome.get(d) != "ok"]
|
|
212
|
+
if failed_dep:
|
|
213
|
+
outcome[repo] = "SKIPPED (predecessor failed: %s)" % ", ".join(failed_dep)
|
|
214
|
+
say("workspace: %s %s" % (repo, outcome[repo]))
|
|
215
|
+
continue
|
|
216
|
+
try:
|
|
217
|
+
src = source_checkout(entry)
|
|
218
|
+
dest = os.path.join(run_dir, "worktrees", _slug(repo))
|
|
219
|
+
wt = prepare(src, dest, "loki/ws-%s-%d" % (run_id, i), entry.get("setup"))
|
|
220
|
+
except (RuntimeError, OSError) as e:
|
|
221
|
+
outcome[repo] = "FAILED: %s" % e
|
|
222
|
+
say("workspace: %s %s" % (repo, outcome[repo]))
|
|
223
|
+
continue
|
|
224
|
+
worktrees[repo] = wt
|
|
225
|
+
siblings = [os.path.join(run_dir, "worktrees", _slug(o["repo"])) for o in ordered if o["repo"] != repo]
|
|
226
|
+
arg, extra = task_for(entry, ref, siblings)
|
|
227
|
+
log = os.path.join(run_dir, "%s.log" % _slug(repo))
|
|
228
|
+
say("workspace: %s running" % repo)
|
|
229
|
+
with open(log, "w") as lf:
|
|
230
|
+
p = subprocess.Popen([launcher, arg], cwd=wt, env=dict(child_env_base, **extra), stdout=lf,
|
|
231
|
+
stderr=subprocess.STDOUT, stdin=subprocess.DEVNULL, start_new_session=True)
|
|
232
|
+
running[repo] = p
|
|
233
|
+
rc = p.wait()
|
|
234
|
+
running.pop(repo, None)
|
|
235
|
+
outcome[repo] = "ok" if rc == 0 else "FAILED: exit %d" % rc
|
|
236
|
+
say("workspace: %s %s" % (repo, outcome[repo]))
|
|
237
|
+
|
|
238
|
+
for repo, wt in worktrees.items():
|
|
239
|
+
r = git("rev-parse", "HEAD", cwd=wt)
|
|
240
|
+
heads[repo] = r.stdout.strip() if r.returncode == 0 else None
|
|
241
|
+
|
|
242
|
+
ev = run_integration(ws, run_dir, worktrees, heads)
|
|
243
|
+
ev["outcomes"] = outcome
|
|
244
|
+
ev_path = os.path.join(run_dir, "integration.json")
|
|
245
|
+
tmp = ev_path + ".tmp"
|
|
246
|
+
with open(tmp, "w") as f:
|
|
247
|
+
json.dump(ev, f, indent=2, sort_keys=True)
|
|
248
|
+
os.replace(tmp, ev_path)
|
|
249
|
+
|
|
250
|
+
say("")
|
|
251
|
+
say("Summary: workspace %s (%s)" % (name, run_id))
|
|
252
|
+
for repo, res in outcome.items():
|
|
253
|
+
say("%-30s %s" % (repo, res))
|
|
254
|
+
say("%-30s %s" % ("integration", ev["status"]))
|
|
255
|
+
bad = any(v != "ok" for v in outcome.values()) or ev["status"] == "failed"
|
|
256
|
+
return (1 if bad else 0), ev_path
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def main(argv):
|
|
260
|
+
if not enabled():
|
|
261
|
+
say("loki workspace is disabled. Set LOKI_WORKSPACES=1 to use it.")
|
|
262
|
+
return 2
|
|
263
|
+
if not argv or argv[0] in ("-h", "--help", "help"):
|
|
264
|
+
say("Usage: loki workspace list | run <name> <owner/repo#N | task text>")
|
|
265
|
+
return 0 if argv else 2
|
|
266
|
+
try:
|
|
267
|
+
workspaces, errors = load_workspaces()
|
|
268
|
+
except Exception as e: # CannotCheck or parse failure
|
|
269
|
+
say("workspace: cannot read loki.yaml: %s" % e)
|
|
270
|
+
return 2
|
|
271
|
+
if errors:
|
|
272
|
+
for e in errors:
|
|
273
|
+
say("workspace: config error: %s" % e)
|
|
274
|
+
return 2
|
|
275
|
+
cmd = argv[0]
|
|
276
|
+
if cmd == "list":
|
|
277
|
+
if not workspaces:
|
|
278
|
+
say("no workspaces defined in loki.yaml")
|
|
279
|
+
for n, ws in sorted(workspaces.items()):
|
|
280
|
+
say("%s: %s" % (n, ", ".join(e.get("repo", "?") for e in ws.get("repos") or [])))
|
|
281
|
+
return 0
|
|
282
|
+
if cmd == "run" and len(argv) >= 3:
|
|
283
|
+
rc, _ = run_workspace(argv[1], " ".join(argv[2:]), workspaces)
|
|
284
|
+
return rc
|
|
285
|
+
say("Usage: loki workspace list | run <name> <issue-ref>")
|
|
286
|
+
return 2
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
if __name__ == "__main__":
|
|
290
|
+
sys.exit(main(sys.argv[1:]))
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Shared worktree substrate (D51 Phase B, D61 slice 7).
|
|
3
|
+
|
|
4
|
+
prepare_worktree() creates a git worktree for a run from a clean start point,
|
|
5
|
+
never carrying the source checkout's uncommitted edits, and prepares
|
|
6
|
+
dependencies (setup command, else copy-on-write copy of ignored dep dirs).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import fcntl
|
|
10
|
+
import json
|
|
11
|
+
import os
|
|
12
|
+
import re
|
|
13
|
+
import subprocess
|
|
14
|
+
import sys
|
|
15
|
+
|
|
16
|
+
DEP_DIRS = ("node_modules", ".venv", "venv", "vendor")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _git(repo, *args, check=True):
|
|
20
|
+
return subprocess.run(
|
|
21
|
+
["git", "-C", repo, *args], capture_output=True, text=True, check=check
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _slug(repo):
|
|
26
|
+
real = os.path.realpath(repo)
|
|
27
|
+
return re.sub(r"[^A-Za-z0-9._-]+", "-", real).strip("-")[-120:] or "repo"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _lock_path(repo):
|
|
31
|
+
d = os.path.join(os.path.expanduser("~"), ".loki", "repos")
|
|
32
|
+
os.makedirs(d, exist_ok=True)
|
|
33
|
+
return os.path.join(d, _slug(repo) + ".lock")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _start_point(repo, base):
|
|
37
|
+
if base:
|
|
38
|
+
return base
|
|
39
|
+
if _git(repo, "remote", check=False).stdout.strip():
|
|
40
|
+
_git(repo, "fetch", "--quiet", check=False)
|
|
41
|
+
for ref in ("origin/HEAD", "HEAD"):
|
|
42
|
+
probe = _git(repo, "rev-parse", "--verify", "--quiet", ref + "^{commit}", check=False)
|
|
43
|
+
if probe.returncode == 0:
|
|
44
|
+
return ref
|
|
45
|
+
raise RuntimeError("no start point: repository has no commits")
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _left_out_changes(repo):
|
|
49
|
+
out = _git(repo, "status", "--porcelain", check=False).stdout
|
|
50
|
+
return len([line for line in out.splitlines() if line.strip()])
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _cow_copy(src, dst):
|
|
54
|
+
if sys.platform == "darwin":
|
|
55
|
+
cmds = [["cp", "-c", "-R", src, dst]]
|
|
56
|
+
else:
|
|
57
|
+
cmds = [["cp", "-R", "--reflink=auto", src, dst]]
|
|
58
|
+
cmds.append(["cp", "-R", src, dst])
|
|
59
|
+
for cmd in cmds:
|
|
60
|
+
if os.path.lexists(dst):
|
|
61
|
+
subprocess.run(["rm", "-rf", dst], check=False)
|
|
62
|
+
if subprocess.run(cmd, capture_output=True).returncode == 0:
|
|
63
|
+
return True
|
|
64
|
+
return False
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _escapes_source(path):
|
|
68
|
+
"""True if a dep dir has an absolute symlink or an editable finder."""
|
|
69
|
+
for root, dirs, files in os.walk(path):
|
|
70
|
+
for name in dirs + files:
|
|
71
|
+
p = os.path.join(root, name)
|
|
72
|
+
if os.path.islink(p):
|
|
73
|
+
if os.path.isabs(os.readlink(p)):
|
|
74
|
+
return True
|
|
75
|
+
elif name.endswith(".pth") or name.startswith("__editable__"):
|
|
76
|
+
return True
|
|
77
|
+
return False
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _copy_deps(source, dest, start):
|
|
81
|
+
copied = 0
|
|
82
|
+
for name in DEP_DIRS:
|
|
83
|
+
s = os.path.join(source, name)
|
|
84
|
+
if not os.path.isdir(s) or os.path.islink(s):
|
|
85
|
+
continue
|
|
86
|
+
if _git(source, "check-ignore", "-q", name + "/", check=False).returncode != 0:
|
|
87
|
+
continue
|
|
88
|
+
if _git(source, "ls-tree", "-d", start, name, check=False).stdout.strip():
|
|
89
|
+
continue
|
|
90
|
+
if _escapes_source(s):
|
|
91
|
+
raise RuntimeError("deps: copy unsafe, declare setup")
|
|
92
|
+
if not _cow_copy(s, os.path.join(dest, name)):
|
|
93
|
+
raise RuntimeError("deps: copy failed, declare setup")
|
|
94
|
+
copied += 1
|
|
95
|
+
return copied
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def prepare_worktree(source_repo, dest, branch, setup=None, base=None):
|
|
99
|
+
source_repo = os.path.abspath(source_repo)
|
|
100
|
+
dest = os.path.abspath(dest)
|
|
101
|
+
lock = open(_lock_path(source_repo), "w")
|
|
102
|
+
try:
|
|
103
|
+
fcntl.flock(lock, fcntl.LOCK_EX)
|
|
104
|
+
left_out = _left_out_changes(source_repo)
|
|
105
|
+
start = _start_point(source_repo, base)
|
|
106
|
+
base_sha = _git(source_repo, "rev-parse", start + "^{commit}").stdout.strip()
|
|
107
|
+
_git(source_repo, "worktree", "add", "-b", branch, dest, base_sha)
|
|
108
|
+
finally:
|
|
109
|
+
fcntl.flock(lock, fcntl.LOCK_UN)
|
|
110
|
+
lock.close()
|
|
111
|
+
|
|
112
|
+
deps = "none"
|
|
113
|
+
if setup:
|
|
114
|
+
r = subprocess.run(setup, shell=True, cwd=dest)
|
|
115
|
+
if r.returncode != 0:
|
|
116
|
+
raise RuntimeError("deps: setup command failed (exit %d)" % r.returncode)
|
|
117
|
+
deps = "setup"
|
|
118
|
+
elif _copy_deps(source_repo, dest, base_sha):
|
|
119
|
+
deps = "cow" if sys.platform in ("darwin", "linux") else "copy"
|
|
120
|
+
return {"path": dest, "base_sha": base_sha, "deps": deps, "left_out_changes": left_out}
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def main(argv=None):
|
|
124
|
+
import argparse
|
|
125
|
+
|
|
126
|
+
p = argparse.ArgumentParser(description="Prepare an isolated git worktree")
|
|
127
|
+
p.add_argument("source")
|
|
128
|
+
p.add_argument("dest")
|
|
129
|
+
p.add_argument("branch")
|
|
130
|
+
p.add_argument("--setup", default=None)
|
|
131
|
+
p.add_argument("--base", default=None)
|
|
132
|
+
a = p.parse_args(argv)
|
|
133
|
+
try:
|
|
134
|
+
res = prepare_worktree(a.source, a.dest, a.branch, a.setup, a.base)
|
|
135
|
+
except (RuntimeError, subprocess.CalledProcessError) as e:
|
|
136
|
+
print(json.dumps({"error": str(e)}))
|
|
137
|
+
return 1
|
|
138
|
+
print(json.dumps(res))
|
|
139
|
+
return 0
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
if __name__ == "__main__":
|
|
143
|
+
sys.exit(main())
|
package/autonomy/loki
CHANGED
|
@@ -1630,6 +1630,7 @@ show_help() {
|
|
|
1630
1630
|
echo " start [SPEC] Start a build (PRD file, GitHub/GitLab/Jira/Azure issue, or no arg)"
|
|
1631
1631
|
echo " plan <PRD-file> Dry-run analysis: complexity, cost, execution plan"
|
|
1632
1632
|
echo " backlog <owner/repo> --all|--label X|--issues N,N Run every matching open issue, N in parallel"
|
|
1633
|
+
echo " workspace list | run <name> <ref> Multi-repo runs from loki.yaml workspaces (LOKI_WORKSPACES=1)"
|
|
1633
1634
|
echo " quickstart [idea] Guided first build: setup, idea, template, plan, go"
|
|
1634
1635
|
echo ""
|
|
1635
1636
|
echo "Session:"
|
|
@@ -1872,12 +1873,39 @@ for p in range(int(sys.argv[1]), int(sys.argv[1]) + 100):
|
|
|
1872
1873
|
PY
|
|
1873
1874
|
}
|
|
1874
1875
|
|
|
1876
|
+
# Fail-closed reuse check for a dashboard found via the ~/.loki/dashboard
|
|
1877
|
+
# registry or a busy port: reusable only if /health reports THIS package's
|
|
1878
|
+
# version AND realpath, and GET / is 200 text/html. Any missing field, curl
|
|
1879
|
+
# error or mismatch means "not ours". Mirrors _loki_dashboard_reusable in run.sh.
|
|
1880
|
+
_loki_dashboard_port_is_ours() {
|
|
1881
|
+
local port="$1" want want_pkg health probe
|
|
1882
|
+
case "$port" in ''|*[!0-9]*) return 1 ;; esac
|
|
1883
|
+
want="$(tr -d '[:space:]' <"$SKILL_DIR/VERSION" 2>/dev/null)" || return 1
|
|
1884
|
+
[ -n "$want" ] || return 1
|
|
1885
|
+
want_pkg="$(cd "$SKILL_DIR" 2>/dev/null && pwd -P)" || return 1
|
|
1886
|
+
health="$(curl -s --max-time 2 "http://127.0.0.1:${port}/health" 2>/dev/null)" || return 1
|
|
1887
|
+
case "$health" in
|
|
1888
|
+
*"\"version\":\"${want}\""* | *"\"version\": \"${want}\""*) ;;
|
|
1889
|
+
*) return 1 ;;
|
|
1890
|
+
esac
|
|
1891
|
+
case "$health" in
|
|
1892
|
+
*"\"package\":\"${want_pkg}\""* | *"\"package\": \"${want_pkg}\""*) ;;
|
|
1893
|
+
*) return 1 ;;
|
|
1894
|
+
esac
|
|
1895
|
+
probe="$(curl -s -o /dev/null -w '%{http_code} %{content_type}' --max-time 2 "http://127.0.0.1:${port}/" 2>/dev/null)" || return 1
|
|
1896
|
+
case "$probe" in "200 text/html"*) return 0 ;; esac
|
|
1897
|
+
return 1
|
|
1898
|
+
}
|
|
1899
|
+
|
|
1875
1900
|
cmd_ui() {
|
|
1876
1901
|
local port="" pid=""
|
|
1877
1902
|
if [ -f "$DASHBOARD_PID_FILE" ]; then
|
|
1878
1903
|
pid=$(cat "$DASHBOARD_PID_FILE" 2>/dev/null)
|
|
1879
1904
|
if [ -n "$pid" ] && kill -0 "$pid" 2>/dev/null; then
|
|
1880
1905
|
port=$(cat "${DASHBOARD_PID_DIR}/port" 2>/dev/null)
|
|
1906
|
+
# A registry entry whose live server is not this package's dashboard
|
|
1907
|
+
# (stale, foreign worktree, other version) is never reused.
|
|
1908
|
+
_loki_dashboard_port_is_ours "$port" || port=""
|
|
1881
1909
|
fi
|
|
1882
1910
|
fi
|
|
1883
1911
|
if [ -z "$port" ]; then
|
|
@@ -3759,9 +3787,18 @@ cmd_start() {
|
|
|
3759
3787
|
# + cockpit, honoring the card's "open dashboard" promise. The
|
|
3760
3788
|
# cockpit hint is printed by _loki_start_handoff for the Both path.
|
|
3761
3789
|
args+=("--bg")
|
|
3762
|
-
|
|
3763
|
-
#
|
|
3764
|
-
|
|
3790
|
+
# Open the URL the runner PUBLISHES for the port it actually bound
|
|
3791
|
+
# or reused (it walks past refused ports); never guess the default
|
|
3792
|
+
# 57374, which may be a foreign or stale dashboard. stdout must
|
|
3793
|
+
# stay unredirected: loki_open_url checks the TTY.
|
|
3794
|
+
local _dfile="${PWD}/.loki/dashboard/url"
|
|
3795
|
+
rm -f "$_dfile" 2>/dev/null
|
|
3796
|
+
(
|
|
3797
|
+
for _ in $(seq 1 120); do
|
|
3798
|
+
if [ -s "$_dfile" ]; then loki_open_url "$(head -1 "$_dfile")" 2>/dev/null; exit 0; fi
|
|
3799
|
+
sleep 0.5
|
|
3800
|
+
done
|
|
3801
|
+
) &
|
|
3765
3802
|
;;
|
|
3766
3803
|
launch-bg-watch)
|
|
3767
3804
|
# Watch (cockpit only) detaches without opening the browser.
|
|
@@ -7288,6 +7325,17 @@ _loki_dashboard_bind_is_safe() {
|
|
|
7288
7325
|
cmd_dashboard() {
|
|
7289
7326
|
local subcommand="${1:-}"
|
|
7290
7327
|
|
|
7328
|
+
# LOKI_CONTROL_DEFAULT=1 (off by default): open the Control Plane instead of the old dashboard.
|
|
7329
|
+
# The old dashboard stays available when the flag is unset, and via `loki dashboard stop|status|url`.
|
|
7330
|
+
if [ "${LOKI_CONTROL_DEFAULT:-0}" = "1" ]; then
|
|
7331
|
+
case "$subcommand" in
|
|
7332
|
+
""|start|open)
|
|
7333
|
+
echo "LOKI_CONTROL_DEFAULT=1: starting the Control Plane (unset it to use the old dashboard)"
|
|
7334
|
+
LOKI_CONTROL=1 exec "$0" control serve
|
|
7335
|
+
;;
|
|
7336
|
+
esac
|
|
7337
|
+
fi
|
|
7338
|
+
|
|
7291
7339
|
# If no subcommand provided, show help (backward compatible with old behavior)
|
|
7292
7340
|
if [ -z "$subcommand" ]; then
|
|
7293
7341
|
cmd_dashboard_help
|
|
@@ -7482,13 +7530,15 @@ cmd_dashboard_start() {
|
|
|
7482
7530
|
if [ -f "$DASHBOARD_PID_FILE" ]; then
|
|
7483
7531
|
local existing_pid
|
|
7484
7532
|
existing_pid=$(cat "$DASHBOARD_PID_FILE" 2>/dev/null)
|
|
7485
|
-
if [ -n "$existing_pid" ] && kill -0 "$existing_pid" 2>/dev/null
|
|
7533
|
+
if [ -n "$existing_pid" ] && kill -0 "$existing_pid" 2>/dev/null \
|
|
7534
|
+
&& _loki_dashboard_port_is_ours "$(cat "${DASHBOARD_PID_DIR}/port" 2>/dev/null)"; then
|
|
7486
7535
|
echo -e "${YELLOW}Dashboard server already running (PID: $existing_pid)${NC}"
|
|
7487
7536
|
echo ""
|
|
7488
7537
|
cmd_dashboard_status
|
|
7489
7538
|
exit 0
|
|
7490
7539
|
fi
|
|
7491
|
-
# Stale PID file,
|
|
7540
|
+
# Stale PID file, or a live server that is not this package's dashboard
|
|
7541
|
+
# (never killed here, just not reused): remove the registry entry.
|
|
7492
7542
|
rm -f "$DASHBOARD_PID_FILE"
|
|
7493
7543
|
fi
|
|
7494
7544
|
|
|
@@ -12871,6 +12921,16 @@ cmd_config_schema() {
|
|
|
12871
12921
|
loki_config_generate_schema
|
|
12872
12922
|
}
|
|
12873
12923
|
|
|
12924
|
+
# D65: multi-repo workspaces (D51 Phase B). Gated by LOKI_WORKSPACES=1.
|
|
12925
|
+
cmd_workspace() {
|
|
12926
|
+
if [ "${LOKI_WORKSPACES:-}" != "1" ]; then
|
|
12927
|
+
echo -e "${RED}loki workspace is disabled.${NC} Set LOKI_WORKSPACES=1 to use it." >&2
|
|
12928
|
+
return 2
|
|
12929
|
+
fi
|
|
12930
|
+
command -v python3 >/dev/null 2>&1 || { echo -e "${RED}loki workspace: python3 is required${NC}" >&2; return 2; }
|
|
12931
|
+
python3 "$_LOKI_SCRIPT_DIR/lib/workspace.py" "$@"
|
|
12932
|
+
}
|
|
12933
|
+
|
|
12874
12934
|
# D51: headless backlog runner. One v10 issue-mode run per open issue, each in
|
|
12875
12935
|
# its own worktree and branch, at most N in parallel (loki.yaml concurrency).
|
|
12876
12936
|
cmd_backlog() {
|
|
@@ -21884,6 +21944,9 @@ main() {
|
|
|
21884
21944
|
backlog)
|
|
21885
21945
|
cmd_backlog "$@"
|
|
21886
21946
|
;;
|
|
21947
|
+
workspace)
|
|
21948
|
+
cmd_workspace "$@"
|
|
21949
|
+
;;
|
|
21887
21950
|
config)
|
|
21888
21951
|
cmd_config "$@"
|
|
21889
21952
|
;;
|
package/autonomy/run.sh
CHANGED
|
@@ -19183,6 +19183,14 @@ _loki_dashboard_reusable() {
|
|
|
19183
19183
|
return 1
|
|
19184
19184
|
}
|
|
19185
19185
|
|
|
19186
|
+
# Publish the URL of the port actually bound or reused, so the launcher (bg
|
|
19187
|
+
# banner, browser opener) never guesses the default 57374 after start_dashboard
|
|
19188
|
+
# walked past a refused port.
|
|
19189
|
+
_loki_publish_dashboard_url() {
|
|
19190
|
+
mkdir -p .loki/dashboard 2>/dev/null || return 0
|
|
19191
|
+
printf '%s\n' "$1" > .loki/dashboard/url.tmp 2>/dev/null && mv -f .loki/dashboard/url.tmp .loki/dashboard/url 2>/dev/null || true
|
|
19192
|
+
}
|
|
19193
|
+
|
|
19186
19194
|
start_dashboard() {
|
|
19187
19195
|
loki_background_services_enabled || return 0
|
|
19188
19196
|
log_header "Starting Loki Dashboard"
|
|
@@ -19262,6 +19270,7 @@ start_dashboard() {
|
|
|
19262
19270
|
if [ "${DASHBOARD_REUSED:-0}" = "1" ]; then
|
|
19263
19271
|
export LOKI_DASHBOARD_PORT="$DASHBOARD_PORT"
|
|
19264
19272
|
log_info "Dashboard already live on port $DASHBOARD_PORT; reusing it."
|
|
19273
|
+
_loki_publish_dashboard_url "http://127.0.0.1:${DASHBOARD_PORT}/"
|
|
19265
19274
|
return 0
|
|
19266
19275
|
fi
|
|
19267
19276
|
|
|
@@ -19419,6 +19428,7 @@ start_dashboard() {
|
|
|
19419
19428
|
DASHBOARD_LAST_ALIVE=$(date +%s)
|
|
19420
19429
|
log_info "Dashboard started (PID: $DASHBOARD_PID)"
|
|
19421
19430
|
log_info "Dashboard: ${CYAN}${url_scheme}://127.0.0.1:$DASHBOARD_PORT/${NC}"
|
|
19431
|
+
_loki_publish_dashboard_url "${url_scheme}://127.0.0.1:${DASHBOARD_PORT}/"
|
|
19422
19432
|
|
|
19423
19433
|
# Auto-open the dashboard in the browser, but ONLY for an interactive
|
|
19424
19434
|
# foreground session. loki_open_url (lib/browser-open.sh) refuses on
|
|
@@ -28816,6 +28826,8 @@ main() {
|
|
|
28816
28826
|
fi
|
|
28817
28827
|
# Initialize .loki directory first
|
|
28818
28828
|
mkdir -p .loki/logs
|
|
28829
|
+
# A url left by an earlier run must never be mistaken for this child's.
|
|
28830
|
+
rm -f .loki/dashboard/url
|
|
28819
28831
|
|
|
28820
28832
|
local log_file=".loki/logs/background-$(date +%Y%m%d-%H%M%S).log"
|
|
28821
28833
|
local pid_file
|
|
@@ -28869,6 +28881,19 @@ main() {
|
|
|
28869
28881
|
echo "$bg_pid" > "$pid_file"
|
|
28870
28882
|
register_pid "$bg_pid" "background-session" "log=$log_file"
|
|
28871
28883
|
|
|
28884
|
+
# The child picks the dashboard port (it walks past refused ports), so
|
|
28885
|
+
# print the URL it publishes, never the requested default.
|
|
28886
|
+
local _bg_dash_line="disabled (LOKI_DASHBOARD=false)"
|
|
28887
|
+
if [ "$ENABLE_DASHBOARD" = "true" ] && loki_background_services_enabled; then
|
|
28888
|
+
_bg_dash_line="starting (run: loki dashboard url, or see $log_file)"
|
|
28889
|
+
local _bg_i
|
|
28890
|
+
for _bg_i in $(seq 1 60); do
|
|
28891
|
+
if [ -s .loki/dashboard/url ]; then _bg_dash_line="$(head -1 .loki/dashboard/url)"; break; fi
|
|
28892
|
+
kill -0 "$bg_pid" 2>/dev/null || break
|
|
28893
|
+
sleep 0.5
|
|
28894
|
+
done
|
|
28895
|
+
fi
|
|
28896
|
+
|
|
28872
28897
|
echo ""
|
|
28873
28898
|
echo -e "${GREEN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
|
|
28874
28899
|
echo -e "${GREEN} Loki Mode Running in Background${NC}"
|
|
@@ -28878,7 +28903,7 @@ main() {
|
|
|
28878
28903
|
echo -e " ${CYAN}Path:${NC} $project_path"
|
|
28879
28904
|
echo -e " ${CYAN}PID:${NC} $bg_pid"
|
|
28880
28905
|
echo -e " ${CYAN}Log:${NC} $log_file"
|
|
28881
|
-
echo -e " ${CYAN}Dashboard:${NC}
|
|
28906
|
+
echo -e " ${CYAN}Dashboard:${NC} ${_bg_dash_line}"
|
|
28882
28907
|
echo ""
|
|
28883
28908
|
echo -e "${YELLOW}Control Commands:${NC}"
|
|
28884
28909
|
echo -e " ${DIM}Pause:${NC} touch .loki/PAUSE"
|
package/bin/loki
CHANGED
|
@@ -350,6 +350,14 @@ case "${LOKI_ENGINE:-$_loki_engine_default}" in
|
|
|
350
350
|
*) _e10=1; _e10_args=(--no-pr "${@:2}") ;;
|
|
351
351
|
esac
|
|
352
352
|
;;
|
|
353
|
+
# D58: `loki start <issue ref|issue URL|"multi-word task">` takes the same v10 path as
|
|
354
|
+
# `loki <ref>`; any other start call (PRD file, flags first, one word) stays legacy.
|
|
355
|
+
start)
|
|
356
|
+
case "${2:-}" in
|
|
357
|
+
*/*\#[0-9]*|http*://*/issues/*|http*://*/browse/*|*" "*) _e10=1; _e10_args=("${@:2}") ;;
|
|
358
|
+
*) _e10=0 ;;
|
|
359
|
+
esac
|
|
360
|
+
;;
|
|
353
361
|
# http*://*/issues/* also covers GitLab's /-/issues/ URLs.
|
|
354
362
|
*/*\#[0-9]*|http*://*/issues/*|http*://*/browse/*) _e10=1 ;;
|
|
355
363
|
*" "*) _e10=1 ;;
|
|
@@ -559,7 +567,7 @@ fi
|
|
|
559
567
|
# Two-token routes (provider show/list, memory list/index) match on the first
|
|
560
568
|
# token only; the Bun dispatcher handles subcommand routing internally.
|
|
561
569
|
case "${1:-}" in
|
|
562
|
-
version|--version|-v|status|stats|doctor|provider|memory|rollback|internal|kpis|trust|proof|receipt|wiki|crash|control)
|
|
570
|
+
version|--version|-v|status|stats|doctor|provider|memory|rollback|internal|kpis|trust|proof|receipt|wiki|crash|control|contract)
|
|
563
571
|
# v7.5.2: rollback added (wires loki-ts/src/commands/rollback.ts).
|
|
564
572
|
# v7.5.3: internal added for autonomy/run.sh phase1-hooks calls.
|
|
565
573
|
# v7.5.28: kpis added (Phase K MVP: read-only KPI snapshot).
|
package/completions/_loki
CHANGED
|
@@ -207,6 +207,7 @@ function _loki_commands {
|
|
|
207
207
|
'otel:OpenTelemetry management'
|
|
208
208
|
'wiki:Wiki commands'
|
|
209
209
|
'control:Control plane (preview, LOKI_CONTROL=1)'
|
|
210
|
+
'workspace:Multi-project workspaces (preview, LOKI_WORKSPACES=1)'
|
|
210
211
|
'backlog:Run every matching open issue, N in parallel'
|
|
211
212
|
'explain:Explain Loki concepts'
|
|
212
213
|
'optimize:Optimization commands'
|