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.
Files changed (114) hide show
  1. package/.github/actions/issue-to-pr/action.yml +25 -11
  2. package/.github/workflows/loki-issue-to-pr.yml +21 -10
  3. package/README.md +138 -54
  4. package/SKILL.md +11 -8
  5. package/VERSION +1 -1
  6. package/autonomy/lib/workspace.py +290 -0
  7. package/autonomy/lib/worktree_prep.py +143 -0
  8. package/autonomy/loki +68 -5
  9. package/autonomy/run.sh +26 -1
  10. package/bin/loki +9 -1
  11. package/completions/_loki +1 -0
  12. package/completions/loki.bash +1 -2
  13. package/dashboard/__init__.py +1 -1
  14. package/docs/AUDIT-CHAIN-THREAT-MODEL.md +1 -1
  15. package/docs/DEMOS.md +11 -26
  16. package/docs/EVALUATING.md +2 -3
  17. package/docs/INSTALLATION.md +2 -2
  18. package/docs/TOOLS.md +1 -1
  19. package/docs/WANG-PRINCIPLES-PLAN.md +1 -1
  20. package/docs/WORKSPACES.md +29 -0
  21. package/docs/air-gapped.md +1 -1
  22. package/docs/alternative-installations.md +3 -1
  23. package/docs/control-plane-container.md +45 -0
  24. package/docs/dashboard-guide.md +2 -0
  25. package/docs/dev/architecture-reference.md +4 -0
  26. package/docs/dev/project-structure.md +2 -2
  27. package/docs/dev/release-checklist.md +1 -2
  28. package/docs/enterprise/architecture.md +1 -1
  29. package/docs/siem-integration.md +2 -2
  30. package/docs/slack.md +26 -0
  31. package/docs/v10/BOARD.md +26 -1
  32. package/docs/v10/CONTROL-PLANE.md +4 -0
  33. package/docs/v10/D61-SPEED.md +83 -0
  34. package/docs/v10/DECISIONS.md +33 -0
  35. package/docs/v10/DEPS.md +6 -2
  36. package/docs/v10/E2E-D65.md +87 -0
  37. package/docs/v10/GUIDE.md +63 -1
  38. package/docs/v10/HANDOFF-NEXT-WEEK.md +40 -0
  39. package/docs/v10/PROGRESS.md +13 -0
  40. package/docs/v10/ROADMAP-D63.md +35 -0
  41. package/loki-ts/dist/loki.js +593 -569
  42. package/mcp/__init__.py +1 -1
  43. package/mcp/server.py +76 -0
  44. package/mcp/tests/test_v10_tools.py +137 -0
  45. package/mcp/v10_tools.py +172 -0
  46. package/package.json +1 -1
  47. package/packages/control-plane/dist/server.js +56 -4
  48. package/packages/control-plane/src/server/answer.ts +33 -0
  49. package/packages/control-plane/src/server/app.ts +17 -1
  50. package/packages/control-plane/src/server/runs.ts +2 -0
  51. package/packages/control-plane/src/server/serve.ts +2 -2
  52. package/packages/control-plane/ui/dist/assets/index-53E32yrO.js +114 -0
  53. package/packages/control-plane/ui/dist/assets/{index-BH3LtKcG.css → index-CFfrqCx2.css} +1 -1
  54. package/packages/control-plane/ui/dist/index.html +2 -2
  55. package/plugins/loki-mode/.claude-plugin/plugin.json +1 -1
  56. package/web-app/dist/assets/{AdminPage-CH-bmw4l.js → AdminPage-DN_pe8vo.js} +1 -1
  57. package/web-app/dist/assets/{Avatar-Bos-ky_x.js → Avatar-BnQsr8z-.js} +1 -1
  58. package/web-app/dist/assets/{Badge-DNJyHg9Y.js → Badge-DzERiZn2.js} +1 -1
  59. package/web-app/dist/assets/{Button-KqD3Tt-I.js → Button-CaaoxQAt.js} +1 -1
  60. package/web-app/dist/assets/{CockpitPage-l6oTsRaL.js → CockpitPage-SXhnysJA.js} +1 -1
  61. package/web-app/dist/assets/{ComparePage-DN6uxXko.js → ComparePage-krxTCFJY.js} +1 -1
  62. package/web-app/dist/assets/{ErrorBoundary-CKQhnvU9.js → ErrorBoundary-BL96ESMc.js} +1 -1
  63. package/web-app/dist/assets/{EvidenceReceiptPanel-CzSIqLmy.js → EvidenceReceiptPanel-PUiGSpkB.js} +1 -1
  64. package/web-app/dist/assets/{GitHubIssuesPanel-YJUcDezn.js → GitHubIssuesPanel-C0hPs-P2.js} +1 -1
  65. package/web-app/dist/assets/{GitHubPRsPanel-Bv7xFvJN.js → GitHubPRsPanel-CLvqNUlu.js} +1 -1
  66. package/web-app/dist/assets/{HomePage-CJsxBbii.js → HomePage-X-sq_Rc9.js} +2 -2
  67. package/web-app/dist/assets/{LoginPage-D1V2Ag0s.js → LoginPage-DhQuwdua.js} +1 -1
  68. package/web-app/dist/assets/{MagicPage-BBVNMEr5.js → MagicPage-CP2ODjfn.js} +1 -1
  69. package/web-app/dist/assets/{MetricsPage-CtHM-IlA.js → MetricsPage-C6SYiQES.js} +1 -1
  70. package/web-app/dist/assets/{NotFoundPage-BBEEJLj5.js → NotFoundPage-CCDxxYc-.js} +1 -1
  71. package/web-app/dist/assets/{ProjectPage-BVRJoEaQ.js → ProjectPage-yDF--8FQ.js} +3 -3
  72. package/web-app/dist/assets/{ProjectsPage-DqaA9rUa.js → ProjectsPage-BBLspdLn.js} +1 -1
  73. package/web-app/dist/assets/{SettingsPage-B_RQV_vF.js → SettingsPage-C_sWbQRw.js} +1 -1
  74. package/web-app/dist/assets/{ShowcasePage-DqN-mrZ8.js → ShowcasePage-xCMuL0N8.js} +1 -1
  75. package/web-app/dist/assets/{SystemSettingsPage-D-CvuL-K.js → SystemSettingsPage-DnJKGCx7.js} +1 -1
  76. package/web-app/dist/assets/{TeamsPage-CzkkDcPj.js → TeamsPage-BBXdhrSs.js} +1 -1
  77. package/web-app/dist/assets/{TemplatesPage-Bbchk7YQ.js → TemplatesPage-AZ1Lr1rn.js} +1 -1
  78. package/web-app/dist/assets/{TerminalOutput-B32BAX--.js → TerminalOutput-CtIm9tXc.js} +1 -1
  79. package/web-app/dist/assets/{activity-C9vnEmhG.js → activity-Dk1FK25Q.js} +1 -1
  80. package/web-app/dist/assets/{bell-XsIhkyjq.js → bell-dhqXyzP4.js} +1 -1
  81. package/web-app/dist/assets/{bot-CvQhqBws.js → bot-CExHd4Kf.js} +1 -1
  82. package/web-app/dist/assets/{check-HmG8WcD-.js → check-D51zaA1a.js} +1 -1
  83. package/web-app/dist/assets/{chevron-left-C1KNgnbD.js → chevron-left-DEwnQzH1.js} +1 -1
  84. package/web-app/dist/assets/{circle-alert-DSmCvZju.js → circle-alert-C6U90Msk.js} +1 -1
  85. package/web-app/dist/assets/{clock-CPhE3RXd.js → clock-6mFbMzzH.js} +1 -1
  86. package/web-app/dist/assets/{cloud-29lwqocB.js → cloud-BrZZtE7F.js} +1 -1
  87. package/web-app/dist/assets/{code-xml-4jZUzbPt.js → code-xml-DTFzeepP.js} +1 -1
  88. package/web-app/dist/assets/{database-H0Ct2r-8.js → database-B5rEiTre.js} +1 -1
  89. package/web-app/dist/assets/{dollar-sign-DsXkO0yT.js → dollar-sign-bQs4rlS_.js} +1 -1
  90. package/web-app/dist/assets/{file-code-corner-BM8w_nvX.js → file-code-corner-Mo6e6qbf.js} +1 -1
  91. package/web-app/dist/assets/{file-plus-BWkYTJ4D.js → file-plus-DzHZ0jpP.js} +1 -1
  92. package/web-app/dist/assets/{globe-FOFDl_8Z.js → globe-DYN9UhTa.js} +1 -1
  93. package/web-app/dist/assets/{hammer-orm5gvky.js → hammer-bB0hSRcJ.js} +1 -1
  94. package/web-app/dist/assets/{index-B6juCuPX.js → index-CIH5CqZB.js} +3 -3
  95. package/web-app/dist/assets/{layers-DUQPkPAl.js → layers-qhFkY8Nu.js} +1 -1
  96. package/web-app/dist/assets/{loader-circle-Deq6bDar.js → loader-circle-BGBkaYH7.js} +1 -1
  97. package/web-app/dist/assets/{lock-CSzWTPRC.js → lock-DIcsNFum.js} +1 -1
  98. package/web-app/dist/assets/{package-CmACgZuF.js → package-BdIIbZhS.js} +1 -1
  99. package/web-app/dist/assets/{plus-BjPW5v3A.js → plus-Co1Bv8zT.js} +1 -1
  100. package/web-app/dist/assets/{refresh-cw-DhI1sHaj.js → refresh-cw-CGF4ABWH.js} +1 -1
  101. package/web-app/dist/assets/{rotate-ccw-Byi2ouWB.js → rotate-ccw-BX10Q79h.js} +1 -1
  102. package/web-app/dist/assets/{scroll-text-gq7bJ81H.js → scroll-text-CbXDlpGp.js} +1 -1
  103. package/web-app/dist/assets/{server-YbY5s1-X.js → server-CIhi6yUO.js} +1 -1
  104. package/web-app/dist/assets/{shield-alert-DhKjtwZt.js → shield-alert-DJ5fB6Dg.js} +1 -1
  105. package/web-app/dist/assets/{thumbs-up-D2X5JR-5.js → thumbs-up-BEft2T94.js} +1 -1
  106. package/web-app/dist/assets/{trash-2-BHAmMxTq.js → trash-2-CWGUPn7Y.js} +1 -1
  107. package/web-app/dist/assets/{trending-down-C41kQa-Q.js → trending-down-CjqstvxQ.js} +1 -1
  108. package/web-app/dist/assets/{trending-up-M-OK_9DC.js → trending-up-CTyLjMzE.js} +1 -1
  109. package/web-app/dist/assets/{upload-CarpPiGw.js → upload-BmRx-uv4.js} +1 -1
  110. package/web-app/dist/assets/{usePolling-rJ7rM7R0.js → usePolling-ChHkSJye.js} +1 -1
  111. package/web-app/dist/assets/{user-cBhWpZen.js → user-ZXh_f35z.js} +1 -1
  112. package/web-app/dist/index.html +1 -1
  113. package/docs/walkthrough/README.md +0 -44
  114. 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
- local _du="http://127.0.0.1:${LOKI_DASHBOARD_PORT:-57374}"
3763
- # stdout must stay unredirected: loki_open_url checks the TTY.
3764
- loki_open_url "$_du" 2>/dev/null &
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; then
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, remove it
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} http://127.0.0.1:${DASHBOARD_PORT}/"
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'