loki-mode 10.6.7 → 10.6.9

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 (113) hide show
  1. package/README.md +44 -9
  2. package/SKILL.md +2 -2
  3. package/VERSION +1 -1
  4. package/autonomy/lib/backlog.py +167 -36
  5. package/autonomy/lib/workspace.py +384 -0
  6. package/autonomy/lib/workspace_comment.py +91 -0
  7. package/autonomy/lib/worktree_prep.py +143 -0
  8. package/autonomy/loki +96 -3
  9. package/bin/loki +3 -3
  10. package/completions/_loki +2 -0
  11. package/completions/loki.bash +1 -2
  12. package/dashboard/__init__.py +1 -1
  13. package/dashboard/api_start.py +26 -1
  14. package/docs/INSTALLATION.md +1 -1
  15. package/docs/WANG-PRINCIPLES-PLAN.md +1 -1
  16. package/docs/WORKSPACES.md +36 -0
  17. package/docs/alternative-installations.md +3 -1
  18. package/docs/control-plane-container.md +50 -0
  19. package/docs/dashboard-guide.md +4 -0
  20. package/docs/dev/architecture-reference.md +4 -0
  21. package/docs/dev/project-structure.md +1 -1
  22. package/docs/exit-codes.md +1 -0
  23. package/docs/slack.md +34 -0
  24. package/docs/trackers.md +27 -0
  25. package/docs/v10/BOARD.md +74 -14
  26. package/docs/v10/CONTROL-PLANE.md +17 -6
  27. package/docs/v10/D51-PHASE-B.md +3 -1
  28. package/docs/v10/D61-SPEED.md +11 -2
  29. package/docs/v10/D63-CARDS.md +354 -0
  30. package/docs/v10/DECISIONS.md +63 -0
  31. package/docs/v10/DEPS.md +6 -2
  32. package/docs/v10/E2E-D65.md +87 -0
  33. package/docs/v10/HANDOFF-NEXT-WEEK.md +10 -4
  34. package/docs/v10/PROGRESS.md +63 -0
  35. package/docs/v10/ROADMAP-D63.md +35 -0
  36. package/loki-ts/dist/loki.js +612 -556
  37. package/mcp/__init__.py +1 -1
  38. package/mcp/server.py +76 -0
  39. package/mcp/tests/test_v10_tools.py +193 -0
  40. package/mcp/v10_tools.py +182 -0
  41. package/package.json +1 -1
  42. package/packages/control-plane/dist/server.js +131 -9
  43. package/packages/control-plane/drizzle/0001_group_id.sql +3 -0
  44. package/packages/control-plane/drizzle/meta/_journal.json +8 -1
  45. package/packages/control-plane/src/db/schema.ts +3 -0
  46. package/packages/control-plane/src/server/answer.ts +33 -0
  47. package/packages/control-plane/src/server/app.ts +22 -2
  48. package/packages/control-plane/src/server/auth.ts +51 -0
  49. package/packages/control-plane/src/server/runs.ts +6 -3
  50. package/packages/control-plane/src/server/serve.ts +13 -3
  51. package/packages/control-plane/src/shipper/discover.ts +29 -0
  52. package/packages/control-plane/ui/dist/assets/{index-BH3LtKcG.css → index-CFfrqCx2.css} +1 -1
  53. package/packages/control-plane/ui/dist/assets/index-Jt31nrK-.js +114 -0
  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-DyjnUcaP.js → AdminPage-CZKZLuWU.js} +1 -1
  57. package/web-app/dist/assets/{Avatar-CY0sQNao.js → Avatar-Dcy9kO2E.js} +1 -1
  58. package/web-app/dist/assets/{Badge-BmwTRu72.js → Badge-DUotUSyx.js} +1 -1
  59. package/web-app/dist/assets/{Button-BRf5aTlb.js → Button-CFz_zfMZ.js} +1 -1
  60. package/web-app/dist/assets/{CockpitPage-RR94HuxS.js → CockpitPage-BhRw_oiM.js} +1 -1
  61. package/web-app/dist/assets/{ComparePage-CFWAuPRO.js → ComparePage-DfnsRTTC.js} +1 -1
  62. package/web-app/dist/assets/{ErrorBoundary-4cDADrG-.js → ErrorBoundary-Dn0gUu3D.js} +1 -1
  63. package/web-app/dist/assets/{EvidenceReceiptPanel-ChPFx2eM.js → EvidenceReceiptPanel-Co-xi282.js} +1 -1
  64. package/web-app/dist/assets/{GitHubIssuesPanel-B0TAjyeW.js → GitHubIssuesPanel-CoCpfexq.js} +1 -1
  65. package/web-app/dist/assets/{GitHubPRsPanel-BbszEwW9.js → GitHubPRsPanel-Dg0Eg6F_.js} +1 -1
  66. package/web-app/dist/assets/{HomePage-DrBgzs1d.js → HomePage-BysxL4mV.js} +2 -2
  67. package/web-app/dist/assets/{LoginPage-Bu7b2DGz.js → LoginPage-CQ366p_d.js} +1 -1
  68. package/web-app/dist/assets/{MagicPage-BFEYp631.js → MagicPage-DD7YcbNq.js} +1 -1
  69. package/web-app/dist/assets/{MetricsPage-eIe2naVR.js → MetricsPage-BYz49xQj.js} +1 -1
  70. package/web-app/dist/assets/{NotFoundPage-BVWYkNt9.js → NotFoundPage-CKs09MeO.js} +1 -1
  71. package/web-app/dist/assets/{ProjectPage-zmxyu732.js → ProjectPage-Ci2BIA_D.js} +3 -3
  72. package/web-app/dist/assets/{ProjectsPage-Dq5NdCak.js → ProjectsPage-ClwMH0eu.js} +1 -1
  73. package/web-app/dist/assets/{SettingsPage-CCrJOMe0.js → SettingsPage-Cm0rCX-y.js} +1 -1
  74. package/web-app/dist/assets/{ShowcasePage-n-Ipzp01.js → ShowcasePage-CxvESt8X.js} +1 -1
  75. package/web-app/dist/assets/{SystemSettingsPage-CP_rjYXK.js → SystemSettingsPage-W_CGYPOY.js} +1 -1
  76. package/web-app/dist/assets/{TeamsPage-B_P9DepW.js → TeamsPage-feVvRvys.js} +1 -1
  77. package/web-app/dist/assets/{TemplatesPage-h-3A6PQT.js → TemplatesPage-C-pUWMmF.js} +1 -1
  78. package/web-app/dist/assets/{TerminalOutput-pD0nJ1BU.js → TerminalOutput-j5ibjKY3.js} +1 -1
  79. package/web-app/dist/assets/{activity-ChFwzT-d.js → activity-TTdVYMa_.js} +1 -1
  80. package/web-app/dist/assets/{bell-BX-eK8Gu.js → bell-DeoTFwpv.js} +1 -1
  81. package/web-app/dist/assets/{bot-fj5PkPtR.js → bot-B5SghxqU.js} +1 -1
  82. package/web-app/dist/assets/{check-BGAbHlvD.js → check-k8SzsdbE.js} +1 -1
  83. package/web-app/dist/assets/{chevron-left-BzFUxHgb.js → chevron-left-DHnEllFz.js} +1 -1
  84. package/web-app/dist/assets/{circle-alert-BQrg5DWl.js → circle-alert-DW_SLdcB.js} +1 -1
  85. package/web-app/dist/assets/{clock-CTu9lkVo.js → clock-Bmoaxy09.js} +1 -1
  86. package/web-app/dist/assets/{cloud-DrIENRiE.js → cloud-eg3tzXTh.js} +1 -1
  87. package/web-app/dist/assets/{code-xml-PaoVpfur.js → code-xml-VAfXihhD.js} +1 -1
  88. package/web-app/dist/assets/{database-DMmkAotn.js → database-CY3_-5aK.js} +1 -1
  89. package/web-app/dist/assets/{dollar-sign-B2LQdBoX.js → dollar-sign-DYC0OLlg.js} +1 -1
  90. package/web-app/dist/assets/{file-code-corner-Dfv1XXeR.js → file-code-corner-0X3SveKz.js} +1 -1
  91. package/web-app/dist/assets/{file-plus-D2Wzf2wd.js → file-plus-B_oh_ukl.js} +1 -1
  92. package/web-app/dist/assets/{globe-DxotVWt8.js → globe-F0tLA8WJ.js} +1 -1
  93. package/web-app/dist/assets/{hammer-Brfy5ZrJ.js → hammer-DrlvBmfW.js} +1 -1
  94. package/web-app/dist/assets/{index-BfhHMwi8.js → index-YihVaZAj.js} +3 -3
  95. package/web-app/dist/assets/{layers-DfxSWDvp.js → layers-CbJfn5UC.js} +1 -1
  96. package/web-app/dist/assets/{loader-circle-CxIrzNwx.js → loader-circle-CpmOggou.js} +1 -1
  97. package/web-app/dist/assets/{lock-m6nzhtpU.js → lock-BGJ6QWrI.js} +1 -1
  98. package/web-app/dist/assets/{package-ri9oE9Jc.js → package-BTmH4Xto.js} +1 -1
  99. package/web-app/dist/assets/{plus-DWeTAMVV.js → plus-mKQ7hz-8.js} +1 -1
  100. package/web-app/dist/assets/{refresh-cw-BGMSpaMh.js → refresh-cw-vPetpLsm.js} +1 -1
  101. package/web-app/dist/assets/{rotate-ccw-DD1xBD_b.js → rotate-ccw-DiJ8GFhj.js} +1 -1
  102. package/web-app/dist/assets/{scroll-text-BCCR_AN9.js → scroll-text-DpPQIlQR.js} +1 -1
  103. package/web-app/dist/assets/{server-CmMWPIjS.js → server-CiaV4drx.js} +1 -1
  104. package/web-app/dist/assets/{shield-alert-BZZmBVEJ.js → shield-alert-BaaQqyco.js} +1 -1
  105. package/web-app/dist/assets/{thumbs-up-OaxbNRXu.js → thumbs-up-2zNCKp0Q.js} +1 -1
  106. package/web-app/dist/assets/{trash-2-DmcSzTfr.js → trash-2-CiVDx5Co.js} +1 -1
  107. package/web-app/dist/assets/{trending-down-BmfXLykz.js → trending-down-tcxvw5BH.js} +1 -1
  108. package/web-app/dist/assets/{trending-up-Djec3jIq.js → trending-up-C2bv2_v_.js} +1 -1
  109. package/web-app/dist/assets/{upload-BN8dXIlF.js → upload-DS4VPUAp.js} +1 -1
  110. package/web-app/dist/assets/{usePolling-Cu4yi04I.js → usePolling-JggBcVln.js} +1 -1
  111. package/web-app/dist/assets/{user-IBI-k10o.js → user-CvavvOxM.js} +1 -1
  112. package/web-app/dist/index.html +1 -1
  113. package/packages/control-plane/ui/dist/assets/index-B6EiyukW.js +0 -114
@@ -0,0 +1,384 @@
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 show <name>
6
+ loki workspace run <name> <issue-ref>
7
+ loki workspace status [<run-id>]
8
+
9
+ On by default (LOKI_WORKSPACES=0 disables). Workspaces are defined in loki.yaml:
10
+
11
+ workspaces:
12
+ shop:
13
+ repos:
14
+ - {repo: acme/api, path: ~/src/api, setup: "npm ci"}
15
+ - {repo: acme/web, path: ~/src/web, after: [acme/api]}
16
+ integration: {command: "make e2e", timeout_s: 900}
17
+
18
+ Each repo gets its own worktree and engine run (continue-and-report: a failing
19
+ repo does not stop the others; dependents of a failed repo are SKIPPED). After
20
+ every repo has finished, the integration command runs once with each run branch
21
+ checked out, and evidence lands in
22
+ .loki/workspaces/<name>/<run-id>/integration.json. Integration evidence is not
23
+ part of the Seal.
24
+
25
+ Repos with no pending `after` run in parallel, at most `concurrency` at once
26
+ (workspace key or LOKI_WORKSPACE_CONCURRENCY, default 2). SIGINT and SIGTERM
27
+ kill each recorded child process group.
28
+
29
+ Exit: 0 all repos ok and integration not failed; 3 when the only non-ok
30
+ outcomes are budget stops (child exit 3); 1 otherwise; 2 usage/config.
31
+
32
+ Test seam: LOKI_WORKSPACE_LAUNCHER replaces the engine launcher (called with
33
+ the issue ref or task text, cwd = the repo worktree).
34
+ """
35
+ import datetime
36
+ import hashlib
37
+ import json
38
+ import os
39
+ import re
40
+ import signal
41
+ import subprocess
42
+ import sys
43
+ import threading
44
+ import time
45
+
46
+ HERE = os.path.dirname(os.path.abspath(__file__))
47
+ sys.path.insert(0, HERE)
48
+
49
+ REPO_ROOT = os.path.abspath(os.path.join(HERE, "..", ".."))
50
+ REPO_RE = re.compile(r"^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$")
51
+ REF_RE = re.compile(r"^([A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+)#(\d+)$")
52
+
53
+
54
+ def say(msg):
55
+ print(msg, flush=True)
56
+
57
+
58
+ BUDGET_EXIT = 3
59
+
60
+
61
+ def enabled(env=None):
62
+ """On by default; LOKI_WORKSPACES=0 disables."""
63
+ return (os.environ if env is None else env).get("LOKI_WORKSPACES") != "0"
64
+
65
+
66
+ def git(*args, cwd=None):
67
+ return subprocess.run(["git", *args], cwd=cwd, capture_output=True, text=True)
68
+
69
+
70
+ def load_workspaces(cfg=None):
71
+ """Return (workspaces, errors) from loki.yaml (or the given cfg mapping)."""
72
+ if cfg is None:
73
+ import loki_yaml
74
+ cfg, _path, errors = loki_yaml.load()
75
+ if errors:
76
+ return {}, errors
77
+ return cfg.get("workspaces") or {}, []
78
+
79
+
80
+ def order_repos(ws):
81
+ """Return repo entries ordered so every `after` predecessor comes first.
82
+
83
+ Raises ValueError on an unknown `after` entry, a duplicate repo or a cycle.
84
+ """
85
+ entries = list(ws.get("repos") or [])
86
+ names = [e.get("repo") for e in entries]
87
+ if len(set(names)) != len(names):
88
+ raise ValueError("duplicate repo in workspace")
89
+ for e in entries:
90
+ if not isinstance(e.get("repo"), str) or not REPO_RE.match(e["repo"]):
91
+ raise ValueError("repo must be owner/name, got %r" % (e.get("repo"),))
92
+ for dep in e.get("after") or []:
93
+ if dep not in names:
94
+ raise ValueError("%s: unknown after repo %s" % (e["repo"], dep))
95
+ done, ordered, remaining = set(), [], entries
96
+ while remaining:
97
+ ready = [e for e in remaining if all(d in done for d in e.get("after") or [])]
98
+ if not ready:
99
+ raise ValueError("cycle in `after`: " + ", ".join(e["repo"] for e in remaining))
100
+ for e in ready:
101
+ ordered.append(e)
102
+ done.add(e["repo"])
103
+ remaining = [e for e in remaining if e not in ready]
104
+ return ordered
105
+
106
+
107
+ def _slug(repo):
108
+ return repo.replace("/", "__")
109
+
110
+
111
+ def source_checkout(entry):
112
+ """Local checkout for a repo entry: its `path`, else clone into ~/.loki/repos/<slug>."""
113
+ if entry.get("path"):
114
+ return os.path.abspath(os.path.expanduser(entry["path"]))
115
+ dest = os.path.join(os.path.expanduser("~"), ".loki", "repos", _slug(entry["repo"]))
116
+ if not os.path.isdir(os.path.join(dest, ".git")):
117
+ os.makedirs(os.path.dirname(dest), exist_ok=True)
118
+ r = subprocess.run(["git", "clone", "https://github.com/%s.git" % entry["repo"], dest],
119
+ capture_output=True, text=True)
120
+ if r.returncode != 0:
121
+ raise RuntimeError("clone failed: " + (r.stderr.strip().splitlines() or ["git error"])[-1])
122
+ return dest
123
+
124
+
125
+ def prepare(source, dest, branch, setup):
126
+ """Make a worktree via worktree_prep when available, else a minimal `git worktree add`."""
127
+ try:
128
+ import worktree_prep
129
+ res = worktree_prep.prepare_worktree(source, dest, branch, setup=setup)
130
+ return res["path"]
131
+ except ImportError:
132
+ pass
133
+ os.makedirs(os.path.dirname(dest), exist_ok=True)
134
+ r = git("worktree", "add", "-B", branch, dest, "HEAD", cwd=source)
135
+ if r.returncode != 0:
136
+ raise RuntimeError("worktree: " + (r.stderr.strip().splitlines() or ["git error"])[-1])
137
+ if setup:
138
+ s = subprocess.run(setup, shell=True, cwd=dest, capture_output=True, text=True)
139
+ if s.returncode != 0:
140
+ raise RuntimeError("setup failed: " + (s.stderr.strip().splitlines() or ["no output"])[-1])
141
+ return dest
142
+
143
+
144
+ def task_for(entry, ref, siblings):
145
+ """(launcher argument, extra env) for a repo: the issue ref when it lives here, else task text."""
146
+ m = REF_RE.match(ref)
147
+ if m and m.group(1) == entry["repo"]:
148
+ return ref, {}
149
+ text = ("Implement the change described by %s for the repo %s. Sibling worktrees (read-only "
150
+ "context, do not edit): %s" % (ref, entry["repo"], ", ".join(siblings) or "none"))
151
+ return text, {"LOKI_E10_TASK_TEXT": text}
152
+
153
+
154
+ def run_integration(ws, run_dir, worktrees, heads):
155
+ """Run the integration command once; return the evidence dict."""
156
+ integ = ws.get("integration") or {}
157
+ cmd = integ.get("command")
158
+ ev = {"heads": heads, "exit_code": None, "log_sha256": None, "status": "not_configured",
159
+ "seal": False, "note": "integration evidence is not part of the Seal"}
160
+ if not cmd:
161
+ return ev
162
+ timeout = integ.get("timeout_s", 900)
163
+ env = dict(os.environ)
164
+ for repo, wt in worktrees.items():
165
+ env["LOKI_WS_DIR_" + re.sub(r"[^A-Za-z0-9]", "_", repo).upper()] = wt
166
+ log_path = os.path.join(run_dir, "integration.log")
167
+ with open(log_path, "wb") as lf:
168
+ p = subprocess.Popen(cmd, shell=True, cwd=run_dir, env=env, stdout=lf, stderr=subprocess.STDOUT,
169
+ stdin=subprocess.DEVNULL, start_new_session=True)
170
+ try:
171
+ rc = p.wait(timeout=timeout)
172
+ ev["status"] = "passed" if rc == 0 else "failed"
173
+ except subprocess.TimeoutExpired:
174
+ try:
175
+ os.killpg(p.pid, signal.SIGKILL)
176
+ except OSError:
177
+ pass
178
+ p.wait()
179
+ rc = 124
180
+ ev["status"] = "failed"
181
+ ev["note"] += "; timeout after %ss" % timeout
182
+ ev["exit_code"] = rc
183
+ with open(log_path, "rb") as f:
184
+ ev["log_sha256"] = hashlib.sha256(f.read()).hexdigest()
185
+ return ev
186
+
187
+
188
+ def run_workspace(name, ref, workspaces, base_dir=None, launcher=None):
189
+ """Run the workspace; return (exit_code, evidence_path_or_None)."""
190
+ ws = workspaces.get(name)
191
+ if ws is None:
192
+ say("workspace: unknown workspace %r (known: %s)" % (name, ", ".join(sorted(workspaces)) or "none"))
193
+ return 2, None
194
+ try:
195
+ ordered = order_repos(ws)
196
+ except ValueError as e:
197
+ say("workspace: %s: %s" % (name, e))
198
+ return 2, None
199
+ if not ordered:
200
+ say("workspace: %s has no repos" % name)
201
+ return 2, None
202
+
203
+ base_dir = base_dir or os.getcwd()
204
+ run_id = datetime.datetime.now(datetime.timezone.utc).strftime("%Y%m%dT%H%M%S%fZ")
205
+ run_dir = os.path.join(base_dir, ".loki", "workspaces", name, run_id)
206
+ os.makedirs(run_dir, exist_ok=True)
207
+ launcher = launcher or os.environ.get("LOKI_WORKSPACE_LAUNCHER") or os.path.join(REPO_ROOT, "bin", "loki")
208
+ child_env_base = dict(os.environ, LOKI_ENGINE="v10", LOKI_NO_BROWSER="1")
209
+
210
+ outcome, worktrees, heads, kind = {}, {}, {}, {}
211
+ running = {} # repo -> (Popen, log file handle)
212
+ try:
213
+ conc = max(1, int(os.environ.get("LOKI_WORKSPACE_CONCURRENCY") or ws.get("concurrency") or 2))
214
+ except (TypeError, ValueError):
215
+ conc = 2
216
+
217
+ def stop(*_):
218
+ for p, _lf in running.values():
219
+ try:
220
+ os.killpg(p.pid, signal.SIGTERM)
221
+ except OSError:
222
+ pass
223
+ sys.exit(130)
224
+ if threading.current_thread() is threading.main_thread():
225
+ signal.signal(signal.SIGTERM, stop)
226
+ signal.signal(signal.SIGINT, stop)
227
+
228
+ index = {e["repo"]: i for i, e in enumerate(ordered)}
229
+ pending = list(ordered)
230
+ while pending or running:
231
+ for entry in list(pending):
232
+ repo = entry["repo"]
233
+ deps = entry.get("after") or []
234
+ failed_dep = [d for d in deps if d in kind and kind[d] != "ok"]
235
+ if failed_dep:
236
+ pending.remove(entry)
237
+ outcome[repo] = "SKIPPED (predecessor failed: %s)" % ", ".join(failed_dep)
238
+ kind[repo] = "budget" if all(kind[d] == "budget" for d in failed_dep) else "fail"
239
+ say("workspace: %s %s" % (repo, outcome[repo]))
240
+ for entry in list(pending):
241
+ repo = entry["repo"]
242
+ if len(running) >= conc:
243
+ break
244
+ if not all(kind.get(d) == "ok" for d in entry.get("after") or []):
245
+ continue
246
+ pending.remove(entry)
247
+ try:
248
+ src = source_checkout(entry)
249
+ dest = os.path.join(run_dir, "worktrees", _slug(repo))
250
+ wt = prepare(src, dest, "loki/ws-%s-%d" % (run_id, index[repo]), entry.get("setup"))
251
+ except (RuntimeError, OSError) as e:
252
+ outcome[repo], kind[repo] = "FAILED: %s" % e, "fail"
253
+ say("workspace: %s %s" % (repo, outcome[repo]))
254
+ continue
255
+ worktrees[repo] = wt
256
+ siblings = [os.path.join(run_dir, "worktrees", _slug(o["repo"])) for o in ordered if o["repo"] != repo]
257
+ arg, extra = task_for(entry, ref, siblings)
258
+ lf = open(os.path.join(run_dir, "%s.log" % _slug(repo)), "w")
259
+ say("workspace: %s running" % repo)
260
+ p = subprocess.Popen([launcher, arg], cwd=wt, env=dict(child_env_base, **extra), stdout=lf,
261
+ stderr=subprocess.STDOUT, stdin=subprocess.DEVNULL, start_new_session=True)
262
+ running[repo] = (p, lf)
263
+ for repo, (p, lf) in list(running.items()):
264
+ rc = p.poll()
265
+ if rc is None:
266
+ continue
267
+ lf.close()
268
+ running.pop(repo)
269
+ if rc == 0:
270
+ outcome[repo], kind[repo] = "ok", "ok"
271
+ else:
272
+ outcome[repo] = "FAILED: exit %d" % rc
273
+ kind[repo] = "budget" if rc == BUDGET_EXIT else "fail"
274
+ say("workspace: %s %s" % (repo, outcome[repo]))
275
+ if running:
276
+ time.sleep(0.05)
277
+
278
+ for repo, wt in worktrees.items():
279
+ r = git("rev-parse", "HEAD", cwd=wt)
280
+ heads[repo] = r.stdout.strip() if r.returncode == 0 else None
281
+
282
+ ev = run_integration(ws, run_dir, worktrees, heads)
283
+ ev["outcomes"] = outcome
284
+ ev_path = os.path.join(run_dir, "integration.json")
285
+ tmp = ev_path + ".tmp"
286
+ with open(tmp, "w") as f:
287
+ json.dump(ev, f, indent=2, sort_keys=True)
288
+ os.replace(tmp, ev_path)
289
+
290
+ try:
291
+ import workspace_comment
292
+ workspace_comment.post_comments(name, run_id, run_dir, worktrees, ev, say)
293
+ except Exception as e: # commenting must never fail the run
294
+ say("workspace: commenting skipped (%s)" % type(e).__name__)
295
+
296
+ say("")
297
+ say("Summary: workspace %s (%s)" % (name, run_id))
298
+ for repo, res in outcome.items():
299
+ say("%-30s %s" % (repo, res))
300
+ say("%-30s %s" % ("integration", ev["status"]))
301
+ if ev["status"] == "failed" or any(k == "fail" for k in kind.values()):
302
+ return 1, ev_path
303
+ return (3 if any(k == "budget" for k in kind.values()) else 0), ev_path
304
+
305
+
306
+ def _runs(base_dir):
307
+ """Yield (workspace, run_id, evidence dict) for every recorded run, oldest first."""
308
+ root = os.path.join(base_dir, ".loki", "workspaces")
309
+ if not os.path.isdir(root):
310
+ return
311
+ for name in sorted(os.listdir(root)):
312
+ for run_id in sorted(os.listdir(os.path.join(root, name))):
313
+ path = os.path.join(root, name, run_id, "integration.json")
314
+ try:
315
+ with open(path) as f:
316
+ yield name, run_id, json.load(f)
317
+ except (OSError, ValueError):
318
+ continue
319
+
320
+
321
+ def status(base_dir, run_id=None, say=say):
322
+ """Print per-repo rows from integration.json for one run, or for every run."""
323
+ found = [r for r in _runs(base_dir) if run_id in (None, r[1])]
324
+ if not found:
325
+ say("workspace: no such run %s" % run_id if run_id else "no workspace runs recorded")
326
+ return 0 if run_id is None else 1
327
+ for name, rid, ev in found:
328
+ say("%s %s" % (name, rid))
329
+ for repo, res in sorted((ev.get("outcomes") or {}).items()):
330
+ say("%-30s %s" % (repo, res))
331
+ say("%-30s %s" % ("integration", ev.get("status")))
332
+ return 0
333
+
334
+
335
+ def show(name, workspaces, say=say):
336
+ ws = workspaces.get(name)
337
+ if ws is None:
338
+ say("workspace: unknown workspace %r (known: %s)" % (name, ", ".join(sorted(workspaces)) or "none"))
339
+ return 2
340
+ say("%s (concurrency %s)" % (name, ws.get("concurrency") or 2))
341
+ for e in ws.get("repos") or []:
342
+ extra = "; after: " + ", ".join(e["after"]) if e.get("after") else ""
343
+ say("%s path: %s%s" % (e.get("repo", "?"), e.get("path") or "(clone)", extra))
344
+ integ = ws.get("integration") or {}
345
+ say("integration: %s" % (integ.get("command") or "not configured"))
346
+ return 0
347
+
348
+
349
+ def main(argv):
350
+ if not enabled():
351
+ say("loki workspace is disabled (LOKI_WORKSPACES=0). Unset it to use workspaces.")
352
+ return 2
353
+ if not argv or argv[0] in ("-h", "--help", "help"):
354
+ say("Usage: loki workspace list | show <name> | run <name> <owner/repo#N | task text> | status [<run-id>]")
355
+ return 0 if argv else 2
356
+ try:
357
+ workspaces, errors = load_workspaces()
358
+ except Exception as e: # CannotCheck or parse failure
359
+ say("workspace: cannot read loki.yaml: %s" % e)
360
+ return 2
361
+ if errors:
362
+ for e in errors:
363
+ say("workspace: config error: %s" % e)
364
+ return 2
365
+ cmd = argv[0]
366
+ if cmd == "list":
367
+ if not workspaces:
368
+ say("no workspaces defined in loki.yaml")
369
+ for n, ws in sorted(workspaces.items()):
370
+ say("%s: %s" % (n, ", ".join(e.get("repo", "?") for e in ws.get("repos") or [])))
371
+ return 0
372
+ if cmd == "show" and len(argv) == 2:
373
+ return show(argv[1], workspaces)
374
+ if cmd == "status" and len(argv) <= 2:
375
+ return status(os.getcwd(), argv[1] if len(argv) == 2 else None)
376
+ if cmd == "run" and len(argv) >= 3:
377
+ rc, _ = run_workspace(argv[1], " ".join(argv[2:]), workspaces)
378
+ return rc
379
+ say("Usage: loki workspace list | show <name> | run <name> <issue-ref> | status [<run-id>]")
380
+ return 2
381
+
382
+
383
+ if __name__ == "__main__":
384
+ sys.exit(main(sys.argv[1:]))
@@ -0,0 +1,91 @@
1
+ """Post the workspace integration result as one PR comment per repo PR (D51-B11).
2
+
3
+ No LLM. Uses the `gh` CLI with the caller's own environment, so GH_TOKEN or
4
+ GITHUB_TOKEN is read by gh itself and never placed in argv, the comment body
5
+ or any log line. Commenting is best effort: it never raises and never changes
6
+ the run's exit code. Opt out with LOKI_WORKSPACE_COMMENT=0.
7
+ """
8
+ import os
9
+ import re
10
+ import subprocess
11
+ import tempfile
12
+
13
+ GH_TIMEOUT_S = 60
14
+
15
+
16
+ def verdict(ev):
17
+ """Map integration evidence to a label. Only a clean exit 0 reads as PASSED."""
18
+ status = ev.get("status")
19
+ if "timeout" in str(ev.get("note") or "").lower():
20
+ return "TIMEOUT"
21
+ if status == "passed" and ev.get("exit_code") == 0:
22
+ return "PASSED"
23
+ if status == "not_configured":
24
+ return "NOT CONFIGURED"
25
+ return "FAILED"
26
+
27
+
28
+ def render(ws_name, run_id, repo, ev):
29
+ heads = ev.get("heads") or {}
30
+ lines = ["Loki workspace `%s` run `%s`" % (ws_name, run_id), "",
31
+ "Integration: **%s** (exit code %s)" % (verdict(ev), ev.get("exit_code")), "",
32
+ "| Repo | Outcome | Head SHA |", "| --- | --- | --- |"]
33
+ outcomes = ev.get("outcomes") or {}
34
+ for r in sorted(heads.keys() | outcomes.keys()):
35
+ out = outcomes.get(r, "unknown")
36
+ out = "ok" if out == "ok" else str(out).replace("|", "/")
37
+ mark = " (this PR)" if r == repo else ""
38
+ lines.append("| %s%s | %s | %s |" % (r, mark, out, heads.get(r) or "none"))
39
+ if ev.get("log_sha256"):
40
+ lines += ["", "Integration log sha256: `%s`" % ev["log_sha256"]]
41
+ lines += ["", "Integration evidence is not part of the Seal."]
42
+ return "\n".join(lines) + "\n"
43
+
44
+
45
+ def _gh(args, cwd):
46
+ return subprocess.run(["gh"] + args, cwd=cwd, stdin=subprocess.DEVNULL, capture_output=True,
47
+ text=True, timeout=GH_TIMEOUT_S)
48
+
49
+
50
+ def _branch(wt):
51
+ r = subprocess.run(["git", "-C", wt, "rev-parse", "--abbrev-ref", "HEAD"], capture_output=True, text=True,
52
+ timeout=GH_TIMEOUT_S)
53
+ b = r.stdout.strip()
54
+ return b if r.returncode == 0 and b and b != "HEAD" else None
55
+
56
+
57
+ def post_comments(ws_name, run_id, run_dir, worktrees, ev, say):
58
+ """One comment per repo that has an open PR on its run branch. Never raises."""
59
+ if os.environ.get("LOKI_WORKSPACE_COMMENT") == "0":
60
+ return 0
61
+ posted = 0
62
+ try:
63
+ for repo, wt in sorted(worktrees.items()):
64
+ try:
65
+ branch = _branch(wt)
66
+ if not branch:
67
+ continue
68
+ r = _gh(["pr", "list", "--repo", repo, "--head", branch, "--state", "open",
69
+ "--json", "number", "--jq", ".[0].number"], wt)
70
+ num = r.stdout.strip()
71
+ if r.returncode != 0 or not re.fullmatch(r"\d+", num):
72
+ continue
73
+ fd, body = tempfile.mkstemp(prefix="comment-", suffix=".md", dir=run_dir) # mode 0600
74
+ try:
75
+ with os.fdopen(fd, "w") as f:
76
+ f.write(render(ws_name, run_id, repo, ev))
77
+ c = _gh(["pr", "comment", num, "--repo", repo, "--body-file", body], wt)
78
+ finally:
79
+ try:
80
+ os.unlink(body)
81
+ except OSError:
82
+ pass
83
+ if c.returncode == 0:
84
+ posted += 1
85
+ else:
86
+ say("workspace: comment on %s#%s failed (gh exit %d)" % (repo, num, c.returncode))
87
+ except (OSError, subprocess.SubprocessError, ValueError) as e:
88
+ say("workspace: comment for %s skipped (%s)" % (repo, type(e).__name__))
89
+ except Exception as e: # never fail the run because commenting failed
90
+ say("workspace: commenting skipped (%s)" % type(e).__name__)
91
+ return posted
@@ -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())