@grifhinz/logics-manager 2.6.0 → 2.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/VERSION CHANGED
@@ -1 +1 @@
1
- 2.6.0
1
+ 2.7.0
@@ -13,6 +13,7 @@
13
13
  const connectionDetail = () => document.getElementById("viewer-connection-detail");
14
14
  const filterCount = () => document.getElementById("viewer-filter-count");
15
15
  const repoPill = () => document.getElementById("viewer-repo-pill");
16
+ const projectMenu = () => document.getElementById("viewer-project-menu");
16
17
  const repoGithubLink = () => document.getElementById("viewer-repo-github");
17
18
  const repoFolderButton = () => document.getElementById("viewer-repo-folder");
18
19
  const ciButton = () => document.getElementById("viewer-ci");
@@ -37,6 +38,8 @@
37
38
  let latestItems = [];
38
39
  let latestRepoRoot = "";
39
40
  let latestRepository = { root: "", githubUrl: "" };
41
+ let latestCapabilities = {};
42
+ let latestProjects = [];
40
43
  let latestMetaText = "Read-only local viewer";
41
44
  let autoRefreshIntervalMs = defaultAutoRefreshIntervalMs;
42
45
  let nextAutoRefreshAt = 0;
@@ -273,6 +276,7 @@
273
276
 
274
277
  function updateRepositoryIdentity(payload) {
275
278
  latestRepoRoot = String(payload.root || latestRepoRoot || "");
279
+ latestProjects = Array.isArray(payload.projects) ? payload.projects : latestProjects;
276
280
  const repository = payload.repository && typeof payload.repository === "object" ? payload.repository : {};
277
281
  latestRepository = {
278
282
  root: String(repository.root || latestRepoRoot || ""),
@@ -281,10 +285,178 @@
281
285
  const pill = repoPill();
282
286
  if (pill) {
283
287
  const repoName = String(payload.repoName || latestRepoRoot.split(/[\\/]/).filter(Boolean).pop() || "repository");
284
- pill.textContent = repoName;
288
+ const label = pill.querySelector("[data-viewer-project-label]");
289
+ if (label) {
290
+ label.textContent = repoName;
291
+ } else {
292
+ pill.textContent = repoName;
293
+ }
285
294
  pill.title = latestRepoRoot || repoName;
295
+ if ("disabled" in pill) {
296
+ pill.disabled = latestProjects.length <= 1;
297
+ }
298
+ pill.onclick = () => {
299
+ const menu = projectMenu();
300
+ setProjectMenuOpen(Boolean(menu?.hidden));
301
+ };
286
302
  }
287
303
  updateRepositoryShortcuts();
304
+ renderProjectMenu();
305
+ }
306
+
307
+ function projectStateLabel(project) {
308
+ if (project?.active) {
309
+ return "current";
310
+ }
311
+ if (project?.available === false) {
312
+ return "missing";
313
+ }
314
+ if (project?.hasLogics === false) {
315
+ return "no Logics";
316
+ }
317
+ return "available";
318
+ }
319
+
320
+ function renderProjectMenu() {
321
+ const menu = projectMenu();
322
+ if (!(menu instanceof HTMLElement)) {
323
+ return;
324
+ }
325
+ const projects = latestProjects.filter((project) => project && typeof project === "object");
326
+ menu.innerHTML = projects.map((project) => `
327
+ <button class="viewer-project-switcher__item${project.active ? " is-active" : ""}" type="button" role="menuitem" data-viewer-project-id="${escapeHtml(project.id || "")}" title="${escapeHtml(project.root || project.name || "")}">
328
+ <span class="viewer-project-switcher__item-name">${escapeHtml(project.name || "project")}</span>
329
+ <span class="viewer-project-switcher__item-state">${escapeHtml(projectStateLabel(project))}</span>
330
+ <span class="viewer-project-switcher__item-path">${escapeHtml(project.root || "")}</span>
331
+ </button>
332
+ `).join("");
333
+ }
334
+
335
+ function setProjectMenuOpen(open) {
336
+ const button = repoPill();
337
+ const menu = projectMenu();
338
+ if (!(button instanceof HTMLElement) || !(menu instanceof HTMLElement)) {
339
+ return;
340
+ }
341
+ const nextOpen = Boolean(open) && latestProjects.length > 1;
342
+ menu.hidden = !nextOpen;
343
+ button.setAttribute("aria-expanded", nextOpen ? "true" : "false");
344
+ }
345
+
346
+ async function switchViewerProject(projectId) {
347
+ if (!projectId) {
348
+ return;
349
+ }
350
+ const target = latestProjects.find((project) => project.id === projectId);
351
+ if (!target || target.active) {
352
+ setProjectMenuOpen(false);
353
+ return;
354
+ }
355
+ setProjectMenuOpen(false);
356
+ setMeta(`Switching to ${target.name || "project"}...`);
357
+ const response = await fetch("/api/switch-project", {
358
+ method: "POST",
359
+ headers: { "Content-Type": "application/json" },
360
+ body: JSON.stringify({ projectId })
361
+ });
362
+ const data = await response.json();
363
+ if (!response.ok || !data.ok) {
364
+ throw new Error(data.error || "Unable to switch project.");
365
+ }
366
+ latestGitBadgeCounts = { unpushedCommits: 0, uncommittedFiles: 0 };
367
+ latestCiStatus = { visible: false, badgeState: "unknown", message: "" };
368
+ updateMainGitBadges();
369
+ updateMainCiBadge(latestCiStatus);
370
+ updateMainCdxBadge(null);
371
+ const panel = documentPanel();
372
+ if (panel) {
373
+ panel.hidden = true;
374
+ }
375
+ postToApp(data.payload);
376
+ }
377
+
378
+ async function bootstrapLogicsProject() {
379
+ setMeta("Bootstrapping Logics...");
380
+ const response = await fetch("/api/bootstrap-logics", { method: "POST" });
381
+ const data = await response.json();
382
+ if (!response.ok || !data.ok) {
383
+ throw new Error(data.error || "Unable to bootstrap Logics.");
384
+ }
385
+ postToApp(data.payload);
386
+ const created = Array.isArray(data.bootstrap?.created_paths) ? data.bootstrap.created_paths.length : 0;
387
+ setMeta(created > 0 ? `Logics bootstrapped · ${created} paths created.` : "Logics bootstrap checked.");
388
+ }
389
+
390
+ function normalizeCapabilities(payload) {
391
+ const capabilities = payload?.capabilities && typeof payload.capabilities === "object" ? payload.capabilities : {};
392
+ return {
393
+ logics: capabilities.logics || { state: "ready", available: true, message: "" },
394
+ git: capabilities.git || { state: "ready", available: true, message: "" },
395
+ ci: capabilities.ci || { state: "ready", available: true, message: "" },
396
+ cdx: capabilities.cdx || { state: "ready", available: true, message: "" },
397
+ cdxRuns: capabilities.cdxRuns || { state: "unsupported", available: false, message: "" }
398
+ };
399
+ }
400
+
401
+ function capability(name) {
402
+ return latestCapabilities?.[name] || { state: "unknown", available: false, message: "" };
403
+ }
404
+
405
+ function isCapabilityAvailable(name) {
406
+ return capability(name).available === true;
407
+ }
408
+
409
+ function capabilityMessage(name, fallback) {
410
+ return String(capability(name).message || fallback || "");
411
+ }
412
+
413
+ function setButtonUnavailable(button, message) {
414
+ if (!(button instanceof HTMLElement) || !("disabled" in button)) {
415
+ return;
416
+ }
417
+ button.disabled = true;
418
+ button.setAttribute("aria-disabled", "true");
419
+ button.title = message;
420
+ }
421
+
422
+ function setButtonAvailable(button, title) {
423
+ if (!(button instanceof HTMLElement) || !("disabled" in button)) {
424
+ return;
425
+ }
426
+ button.disabled = false;
427
+ button.removeAttribute("aria-disabled");
428
+ button.title = title;
429
+ }
430
+
431
+ function updateCapabilityControls() {
432
+ const gitButton = document.getElementById("viewer-git");
433
+ if (gitButton instanceof HTMLElement) {
434
+ gitButton.hidden = !isCapabilityAvailable("git");
435
+ if (isCapabilityAvailable("git")) {
436
+ setButtonAvailable(gitButton, "Show Git status");
437
+ } else {
438
+ setButtonUnavailable(gitButton, capabilityMessage("git", "Git is not available for this project."));
439
+ }
440
+ }
441
+
442
+ const ci = ciButton();
443
+ if (ci instanceof HTMLElement) {
444
+ ci.hidden = !isCapabilityAvailable("ci");
445
+ if (isCapabilityAvailable("ci")) {
446
+ setButtonAvailable(ci, "Show GitHub Actions CI status");
447
+ } else {
448
+ setButtonUnavailable(ci, capabilityMessage("ci", "CI is not available for this project."));
449
+ }
450
+ }
451
+
452
+ const cdx = document.getElementById("viewer-cdx");
453
+ if (cdx instanceof HTMLElement) {
454
+ if (isCapabilityAvailable("cdx")) {
455
+ setButtonAvailable(cdx, "Show CDX status");
456
+ } else {
457
+ setButtonUnavailable(cdx, capabilityMessage("cdx", "CDX is not available for this project."));
458
+ }
459
+ }
288
460
  }
289
461
 
290
462
  function updateRepositoryShortcuts() {
@@ -430,6 +602,10 @@
430
602
  }
431
603
 
432
604
  async function refreshCiBadgeCounters() {
605
+ if (!isCapabilityAvailable("ci")) {
606
+ updateMainCiBadge({ visible: false, badgeState: "unknown", message: capabilityMessage("ci", "CI is not available for this project.") });
607
+ return;
608
+ }
433
609
  try {
434
610
  const response = await fetch("/api/ci-status");
435
611
  if (response.status === 404) {
@@ -476,7 +652,9 @@
476
652
  button.querySelector("[data-viewer-cdx-badge]")?.remove();
477
653
  const activeCount = activeCdxAssistantCountFromPayload(payload);
478
654
  if (activeCount <= 0) {
479
- button.title = "Show CDX status";
655
+ button.title = isCapabilityAvailable("cdx")
656
+ ? "Show CDX status"
657
+ : capabilityMessage("cdx", "CDX is not available for this project.");
480
658
  return;
481
659
  }
482
660
  const label = activeCount > 9 ? "9+" : String(activeCount);
@@ -486,6 +664,10 @@
486
664
  }
487
665
 
488
666
  async function refreshCdxBadgeCounters() {
667
+ if (!isCapabilityAvailable("cdx")) {
668
+ updateMainCdxBadge(null);
669
+ return;
670
+ }
489
671
  try {
490
672
  const response = await fetch("/api/cdx-status");
491
673
  if (response.status === 404) {
@@ -509,6 +691,11 @@
509
691
  }
510
692
 
511
693
  async function refreshGitBadgeCounters() {
694
+ if (!isCapabilityAvailable("git")) {
695
+ latestGitBadgeCounts = { unpushedCommits: 0, uncommittedFiles: 0 };
696
+ updateMainGitBadges();
697
+ return;
698
+ }
512
699
  try {
513
700
  const response = await fetch("/api/git-status");
514
701
  const data = await response.json();
@@ -586,13 +773,10 @@
586
773
  }, 0);
587
774
  }
588
775
 
589
- function applyFocusRequest(payload) {
590
- if (focusApplied) {
591
- return payload;
592
- }
776
+ function applyFocusRequest(payload, options = {}) {
593
777
  const request = focusRequest();
594
778
  if (!request.focus) {
595
- if (window.location.search.includes("focus=")) {
779
+ if (!focusApplied && !options.silent && window.location.search.includes("focus=")) {
596
780
  window.setTimeout(() => setMeta("Invalid focus target. Loaded corpus without changing selection."), 0);
597
781
  }
598
782
  focusApplied = true;
@@ -600,14 +784,20 @@
600
784
  }
601
785
  const item = findFocusItem(request.focus);
602
786
  if (!item) {
603
- window.setTimeout(() => setMeta(`Focus target not found: ${request.focus}`), 0);
787
+ if (!focusApplied && !options.silent) {
788
+ window.setTimeout(() => setMeta(`Focus target not found: ${request.focus}`), 0);
789
+ }
604
790
  focusApplied = true;
605
791
  return payload;
606
792
  }
793
+ const nextPayload = { ...payload, selectedId: item.id };
794
+ if (focusApplied) {
795
+ persistSelectedItem(item.id);
796
+ return nextPayload;
797
+ }
607
798
  viewerFilterState = { ...viewerFilterState, focus: "all", type: "all", status: "any", relation: "any", activity: "any" };
608
799
  persistSelectedItem(item.id);
609
800
  focusApplied = true;
610
- const nextPayload = { ...payload, selectedId: item.id };
611
801
  window.setTimeout(() => {
612
802
  revealFocusedCard(item);
613
803
  if (request.read) {
@@ -765,8 +955,10 @@
765
955
  updateRefreshIntervalControl();
766
956
  }
767
957
  updateRepositoryIdentity(payload);
958
+ latestCapabilities = normalizeCapabilities(payload);
959
+ updateCapabilityControls();
768
960
  const payloadWithActivity = { ...payload, items: latestItems };
769
- const nextPayload = options.silent ? payloadWithActivity : applyFocusRequest(payloadWithActivity);
961
+ const nextPayload = applyFocusRequest(payloadWithActivity, { silent: Boolean(options.silent) });
770
962
  window.dispatchEvent(new MessageEvent("message", { data: { type: "data", payload: nextPayload } }));
771
963
  const rootName = payload.root ? payload.root.split(/[\\/]/).filter(Boolean).pop() : "repository";
772
964
  if (!options.silent) {
@@ -840,6 +1032,12 @@
840
1032
  return Boolean(panel && !panel.hidden && title && title.textContent === "CDX status");
841
1033
  }
842
1034
 
1035
+ function isCdxRunsOpen() {
1036
+ const panel = documentPanel();
1037
+ const title = documentTitle();
1038
+ return Boolean(panel && !panel.hidden && title && title.textContent === "CDX runs");
1039
+ }
1040
+
843
1041
  function isCiStatusOpen() {
844
1042
  const panel = documentPanel();
845
1043
  const title = documentTitle();
@@ -854,6 +1052,8 @@
854
1052
  await showCiStatus({ silent: Boolean(options.silent) });
855
1053
  } else if (isCdxStatusOpen()) {
856
1054
  await showCdxStatus({ silent: Boolean(options.silent) });
1055
+ } else if (isCdxRunsOpen()) {
1056
+ await showCdxRuns({ silent: Boolean(options.silent) });
857
1057
  } else if (method === "POST") {
858
1058
  await refreshGitBadgeCounters();
859
1059
  }
@@ -1906,10 +2106,20 @@
1906
2106
  return rows || `<li class="viewer-cdx__empty">${escapeHtml(emptyText)}</li>`;
1907
2107
  }
1908
2108
 
2109
+ function renderCdxModeSwitcher(active) {
2110
+ return `
2111
+ <div class="viewer-cdx__modes" role="tablist" aria-label="CDX views">
2112
+ <button class="viewer-cdx__mode${active === "status" ? " is-active" : ""}" type="button" data-viewer-cdx-mode="status" aria-selected="${active === "status" ? "true" : "false"}">Status</button>
2113
+ <button class="viewer-cdx__mode${active === "runs" ? " is-active" : ""}" type="button" data-viewer-cdx-mode="runs" aria-selected="${active === "runs" ? "true" : "false"}">Runs</button>
2114
+ </div>
2115
+ `;
2116
+ }
2117
+
1909
2118
  function renderCdxStatus(payload) {
1910
2119
  if (!payload || payload.state !== "ok") {
1911
2120
  return `
1912
2121
  <div class="viewer-cdx">
2122
+ ${renderCdxModeSwitcher("status")}
1913
2123
  <div class="viewer-cdx__state">${escapeHtml(payload?.message || "CDX status is unavailable.")}</div>
1914
2124
  </div>
1915
2125
  `;
@@ -1945,6 +2155,7 @@
1945
2155
  `).join("");
1946
2156
  return `
1947
2157
  <div class="viewer-cdx">
2158
+ ${renderCdxModeSwitcher("status")}
1948
2159
  <div class="viewer-cdx__summary">${cards}</div>
1949
2160
  <div class="viewer-cdx__workspace">
1950
2161
  <div class="viewer-cdx__stack">
@@ -1972,7 +2183,87 @@
1972
2183
  `;
1973
2184
  }
1974
2185
 
2186
+ function renderCdxRuns(payload) {
2187
+ if (!payload || payload.state !== "ok") {
2188
+ return `
2189
+ <div class="viewer-cdx">
2190
+ ${renderCdxModeSwitcher("runs")}
2191
+ <div class="viewer-cdx__state">${escapeHtml(payload?.message || "CDX runs are unavailable.")}</div>
2192
+ </div>
2193
+ `;
2194
+ }
2195
+ const runs = Array.isArray(payload.runs) ? payload.runs : [];
2196
+ const rows = runs.map((run) => `
2197
+ <tr>
2198
+ <td><code>${escapeHtml(run.run_id || "-")}</code></td>
2199
+ <td>${renderCdxBadge(run.status || "unknown")}</td>
2200
+ <td>${escapeHtml(run.kind || "assistant")}</td>
2201
+ <td>${escapeHtml(run.session || "-")}</td>
2202
+ <td>${escapeHtml(run.cwd || "-")}</td>
2203
+ <td><button class="viewer-cdx__mode" type="button" data-viewer-cdx-report="${escapeHtml(run.run_id || "")}">Report</button></td>
2204
+ </tr>
2205
+ `).join("");
2206
+ return `
2207
+ <div class="viewer-cdx">
2208
+ ${renderCdxModeSwitcher("runs")}
2209
+ <section class="viewer-cdx__section">
2210
+ <div class="viewer-ci__heading"><h2>Assistant runs</h2><span>${escapeHtml(runs.length)} reported</span></div>
2211
+ <div class="viewer-cdx__table-wrap">
2212
+ <table class="viewer-cdx__table">
2213
+ <thead><tr><th>RUN</th><th>STATUS</th><th>KIND</th><th>SESSION</th><th>CWD</th><th>REPORT</th></tr></thead>
2214
+ <tbody>${rows || '<tr><td colspan="6" class="viewer-cdx__empty">No assistant runs reported.</td></tr>'}</tbody>
2215
+ </table>
2216
+ </div>
2217
+ </section>
2218
+ </div>
2219
+ `;
2220
+ }
2221
+
2222
+ function renderCdxReport(payload) {
2223
+ if (!payload || payload.state !== "ok" || !payload.report) {
2224
+ return `
2225
+ <div class="viewer-cdx">
2226
+ ${renderCdxModeSwitcher("runs")}
2227
+ <div class="viewer-cdx__state">${escapeHtml(payload?.message || "CDX run report is unavailable.")}</div>
2228
+ </div>
2229
+ `;
2230
+ }
2231
+ const report = payload.report || {};
2232
+ const run = report.run || {};
2233
+ const taskReport = report.task_report || {};
2234
+ const findings = Array.isArray(taskReport.findings) ? taskReport.findings : [];
2235
+ const findingRows = findings.map((finding, index) => {
2236
+ const location = [finding.path || finding.file || "", finding.line || ""].filter(Boolean).join(":") || "-";
2237
+ return `<li class="viewer-cdx__entity"><div class="viewer-cdx__entity-main"><div><strong>${escapeHtml(finding.message || finding.title || `Finding ${index + 1}`)}</strong><div class="viewer-cdx__meta">${escapeHtml(location)}</div></div>${renderCdxBadge(finding.severity || "unknown")}</div></li>`;
2238
+ }).join("");
2239
+ const canCreate = taskReport.kind === "code-review";
2240
+ return `
2241
+ <div class="viewer-cdx">
2242
+ ${renderCdxModeSwitcher("runs")}
2243
+ <section class="viewer-cdx__section">
2244
+ <div class="viewer-ci__heading"><h2>Run report</h2><span>${escapeHtml(run.status || "unknown")}</span></div>
2245
+ <ul class="viewer-cdx__list">
2246
+ <li class="viewer-cdx__row"><span>Run</span><strong>${escapeHtml(run.run_id || taskReport.run_id || "-")}</strong></li>
2247
+ <li class="viewer-cdx__row"><span>Kind</span><strong>${escapeHtml(taskReport.kind || run.kind || "assistant")}</strong></li>
2248
+ <li class="viewer-cdx__row"><span>Summary</span><strong>${escapeHtml(taskReport.summary || "No summary reported.")}</strong></li>
2249
+ </ul>
2250
+ ${canCreate ? `<button class="btn" type="button" data-viewer-cdx-create-request="${escapeHtml(run.run_id || taskReport.run_id || "")}">Create Logics request</button>` : ""}
2251
+ </section>
2252
+ <section class="viewer-cdx__section">
2253
+ <div class="viewer-ci__heading"><h2>Findings</h2><span>${escapeHtml(findings.length)} reported</span></div>
2254
+ <ul class="viewer-cdx__list">${findingRows || '<li class="viewer-cdx__empty">No structured findings reported.</li>'}</ul>
2255
+ </section>
2256
+ </div>
2257
+ `;
2258
+ }
2259
+
1975
2260
  async function showCdxStatus(options = {}) {
2261
+ if (!isCapabilityAvailable("cdx")) {
2262
+ const message = capabilityMessage("cdx", "CDX is not available for this project.");
2263
+ setDocument("CDX status", renderCdxStatus({ state: capability("cdx").state, message }));
2264
+ setMeta(message);
2265
+ return;
2266
+ }
1976
2267
  if (!options.silent) {
1977
2268
  setMeta("Checking CDX status...");
1978
2269
  }
@@ -1999,6 +2290,62 @@
1999
2290
  setMeta(options.silent ? "CDX status refreshed." : "CDX status loaded.");
2000
2291
  }
2001
2292
 
2293
+ async function showCdxRuns(options = {}) {
2294
+ if (!isCapabilityAvailable("cdx")) {
2295
+ const message = capabilityMessage("cdx", "CDX is not available for this project.");
2296
+ setDocument("CDX runs", renderCdxRuns({ state: capability("cdx").state, message }));
2297
+ setMeta(message);
2298
+ return;
2299
+ }
2300
+ if (!options.silent) {
2301
+ setMeta("Checking CDX runs...");
2302
+ }
2303
+ const response = await fetch("/api/cdx-runs");
2304
+ let data = {};
2305
+ try {
2306
+ data = await response.json();
2307
+ } catch {
2308
+ data = {};
2309
+ }
2310
+ if (!response.ok || !data.ok) {
2311
+ throw new Error(data.error || "Unable to load CDX runs.");
2312
+ }
2313
+ setDocument("CDX runs", renderCdxRuns(data.payload));
2314
+ setMeta(options.silent ? "CDX runs refreshed." : "CDX runs loaded.");
2315
+ }
2316
+
2317
+ async function showCdxReport(runId) {
2318
+ if (!runId) {
2319
+ return;
2320
+ }
2321
+ setMeta("Loading CDX report...");
2322
+ const response = await fetch(`/api/cdx-run-report?${new URLSearchParams({ runId }).toString()}`);
2323
+ const data = await response.json();
2324
+ if (!response.ok || !data.ok) {
2325
+ throw new Error(data.error || "Unable to load CDX report.");
2326
+ }
2327
+ setDocument("CDX run report", renderCdxReport(data.payload));
2328
+ setMeta("CDX report loaded.");
2329
+ }
2330
+
2331
+ async function createRequestFromCdxReport(runId) {
2332
+ if (!runId) {
2333
+ return;
2334
+ }
2335
+ setMeta("Creating Logics request from CDX report...");
2336
+ const response = await fetch("/api/cdx-report-request", {
2337
+ method: "POST",
2338
+ headers: { "Content-Type": "application/json" },
2339
+ body: JSON.stringify({ runId })
2340
+ });
2341
+ const data = await response.json();
2342
+ if (!response.ok || !data.ok) {
2343
+ throw new Error(data.error || "Unable to create Logics request.");
2344
+ }
2345
+ postToApp(data.payload);
2346
+ setMeta(`Created ${data.created?.id || "Logics request"} from CDX report.`);
2347
+ }
2348
+
2002
2349
  function renderCiBadge(value) {
2003
2350
  const tone = ciBadgeTone(value);
2004
2351
  return `<span class="viewer-ci__badge viewer-ci__badge--${escapeHtml(tone)}">${escapeHtml(ciBadgeLabel(value))}</span>`;
@@ -2068,6 +2415,12 @@
2068
2415
  }
2069
2416
 
2070
2417
  async function showCiStatus(options = {}) {
2418
+ if (!isCapabilityAvailable("ci")) {
2419
+ const message = capabilityMessage("ci", "CI is not available for this project.");
2420
+ setDocument("CI status", renderCiStatus({ visible: false, state: capability("ci").state, message }));
2421
+ setMeta(message);
2422
+ return;
2423
+ }
2071
2424
  if (!options.silent) {
2072
2425
  setMeta("Checking CI status...");
2073
2426
  }
@@ -2341,6 +2694,12 @@
2341
2694
 
2342
2695
  async function showGitStatus(options = {}) {
2343
2696
  const previous = options.preserve ? currentGitViewState() : { domain: "changes", path: "", cached: false };
2697
+ if (!isCapabilityAvailable("git")) {
2698
+ const message = capabilityMessage("git", "Git is not available for this project.");
2699
+ setDocument("Git status", renderGitStatus({ state: capability("git").state, message }));
2700
+ setMeta(message);
2701
+ return;
2702
+ }
2344
2703
  if (!options.silent) {
2345
2704
  setMeta("Checking Git status...");
2346
2705
  }
@@ -2388,6 +2747,10 @@
2388
2747
  refreshViewer("POST").catch((error) => setMeta(error.message));
2389
2748
  return;
2390
2749
  }
2750
+ if (message.type === "bootstrap-logics") {
2751
+ bootstrapLogicsProject().catch((error) => setMeta(error.message));
2752
+ return;
2753
+ }
2391
2754
  if (message.type === "open" || message.type === "read") {
2392
2755
  const item = latestItems.find((entry) => entry.id === message.id);
2393
2756
  showDocument(item).catch((error) => setMeta(error.message));
@@ -2480,6 +2843,10 @@
2480
2843
  document.getElementById("viewer-cdx")?.addEventListener("click", () => {
2481
2844
  showCdxStatus().catch((error) => setMeta(error.message));
2482
2845
  });
2846
+ repoPill()?.addEventListener("click", () => {
2847
+ const menu = projectMenu();
2848
+ setProjectMenuOpen(Boolean(menu?.hidden));
2849
+ });
2483
2850
  repoFolderButton()?.addEventListener("click", () => {
2484
2851
  openRepositoryFolder().catch((error) => setMeta(error.message));
2485
2852
  });
@@ -2518,6 +2885,38 @@
2518
2885
  const gitHistoryRevealTarget = event.target instanceof Element ? event.target.closest("[data-viewer-git-history-reveal]") : null;
2519
2886
  const gitDomainTarget = event.target instanceof Element ? event.target.closest(".viewer-git__domain[data-viewer-git-domain]") : null;
2520
2887
  const gitFileTarget = event.target instanceof Element ? event.target.closest("[data-viewer-git-file]") : null;
2888
+ const projectSwitcherTarget = event.target instanceof Element ? event.target.closest("#viewer-repo-pill") : null;
2889
+ const projectTarget = event.target instanceof Element ? event.target.closest("[data-viewer-project-id]") : null;
2890
+ const cdxModeTarget = event.target instanceof Element ? event.target.closest("[data-viewer-cdx-mode]") : null;
2891
+ const cdxReportTarget = event.target instanceof Element ? event.target.closest("[data-viewer-cdx-report]") : null;
2892
+ const cdxCreateRequestTarget = event.target instanceof Element ? event.target.closest("[data-viewer-cdx-create-request]") : null;
2893
+ if (cdxReportTarget instanceof HTMLElement) {
2894
+ showCdxReport(cdxReportTarget.getAttribute("data-viewer-cdx-report") || "").catch((error) => setMeta(error.message));
2895
+ return;
2896
+ }
2897
+ if (cdxCreateRequestTarget instanceof HTMLElement) {
2898
+ createRequestFromCdxReport(cdxCreateRequestTarget.getAttribute("data-viewer-cdx-create-request") || "").catch((error) => setMeta(error.message));
2899
+ return;
2900
+ }
2901
+ if (cdxModeTarget instanceof HTMLElement) {
2902
+ const mode = cdxModeTarget.getAttribute("data-viewer-cdx-mode") || "status";
2903
+ if (mode === "runs") {
2904
+ showCdxRuns().catch((error) => setMeta(error.message));
2905
+ } else {
2906
+ showCdxStatus().catch((error) => setMeta(error.message));
2907
+ }
2908
+ return;
2909
+ }
2910
+ if (projectSwitcherTarget instanceof HTMLElement) {
2911
+ const menu = projectMenu();
2912
+ setProjectMenuOpen(Boolean(menu?.hidden));
2913
+ return;
2914
+ }
2915
+ if (projectTarget instanceof HTMLElement) {
2916
+ event.preventDefault();
2917
+ switchViewerProject(projectTarget.getAttribute("data-viewer-project-id") || "").catch((error) => setMeta(error.message));
2918
+ return;
2919
+ }
2521
2920
  if (gitHistoryRevealTarget instanceof HTMLElement) {
2522
2921
  event.preventDefault();
2523
2922
  event.stopImmediatePropagation();
@@ -18,7 +18,13 @@
18
18
  <div>
19
19
  <div class="viewer-topbar__identity">
20
20
  <div class="viewer-topbar__title">Logics Viewer</div>
21
- <span class="viewer-topbar__repo" id="viewer-repo-pill" title="">repository</span>
21
+ <div class="viewer-project-switcher" id="viewer-project-switcher">
22
+ <button class="viewer-topbar__repo viewer-project-switcher__button" id="viewer-repo-pill" type="button" title="" aria-haspopup="menu" aria-expanded="false" aria-controls="viewer-project-menu">
23
+ <span data-viewer-project-label>repository</span>
24
+ <span class="viewer-project-switcher__chevron" aria-hidden="true">v</span>
25
+ </button>
26
+ <div class="viewer-project-switcher__menu" id="viewer-project-menu" role="menu" aria-label="Known projects" hidden></div>
27
+ </div>
22
28
  <span class="viewer-topbar__repo-actions" id="viewer-repo-actions" aria-label="Repository shortcuts">
23
29
  <a class="viewer-topbar__repo-action" id="viewer-repo-github" href="#" target="_blank" rel="noreferrer" title="Open GitHub repository" aria-label="Open GitHub repository" hidden>
24
30
  <svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M9 19c-4.2 1.2-4.2-2-6-2.4m12 4.4v-3.5c0-1 .1-1.4-.5-2 2-.2 4.1-1 4.1-4.6 0-1-.4-1.9-1-2.6.1-.3.4-1.3-.1-2.6 0 0-.8-.3-2.7 1a9.3 9.3 0 0 0-4.9 0c-1.8-1.3-2.7-1-2.7-1-.5 1.3-.2 2.3-.1 2.6a3.8 3.8 0 0 0-1 2.6c0 3.6 2.1 4.4 4.1 4.6-.5.5-.7 1-.7 1.9V21" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" /></svg>
@@ -31,6 +37,9 @@
31
37
  <div class="viewer-topbar__meta" id="viewer-meta">Read-only local viewer</div>
32
38
  </div>
33
39
  <div class="viewer-topbar__actions">
40
+ <button class="btn" id="viewer-git" type="button" title="Show Git status">Git</button>
41
+ <button class="btn" id="viewer-ci" type="button" title="Show GitHub Actions CI status" hidden>CI</button>
42
+ <button class="btn" id="viewer-cdx" type="button" title="Show CDX status">CDX</button>
34
43
  <div class="viewer-refresh-menu">
35
44
  <button class="btn" id="viewer-refresh-menu-button" type="button" title="Viewer settings" aria-haspopup="menu" aria-expanded="false" aria-controls="viewer-refresh-menu">Settings</button>
36
45
  <div class="viewer-refresh-menu__panel" id="viewer-refresh-menu" role="menu" aria-label="Viewer settings" hidden>
@@ -59,9 +68,6 @@
59
68
  </section>
60
69
  </div>
61
70
  </div>
62
- <button class="btn" id="viewer-git" type="button" title="Show Git status">Git</button>
63
- <button class="btn" id="viewer-ci" type="button" title="Show GitHub Actions CI status" hidden>CI</button>
64
- <button class="btn" id="viewer-cdx" type="button" title="Show CDX status">CDX</button>
65
71
  </div>
66
72
  </header>
67
73
 
@@ -37,6 +37,92 @@
37
37
  line-height: 1.3;
38
38
  }
39
39
 
40
+ .viewer-project-switcher {
41
+ position: relative;
42
+ min-width: 0;
43
+ }
44
+
45
+ .viewer-project-switcher__button {
46
+ display: inline-flex;
47
+ align-items: center;
48
+ gap: 6px;
49
+ cursor: pointer;
50
+ }
51
+
52
+ .viewer-project-switcher__button[disabled] {
53
+ cursor: default;
54
+ opacity: 0.75;
55
+ }
56
+
57
+ .viewer-project-switcher__chevron {
58
+ font-size: 9px;
59
+ opacity: 0.75;
60
+ }
61
+
62
+ .viewer-project-switcher__menu {
63
+ position: absolute;
64
+ z-index: 20;
65
+ top: calc(100% + 6px);
66
+ left: 0;
67
+ min-width: 260px;
68
+ max-width: min(420px, calc(100vw - 24px));
69
+ max-height: min(360px, calc(100vh - 96px));
70
+ overflow: auto;
71
+ padding: 6px;
72
+ border: 1px solid var(--vscode-panel-border, #333333);
73
+ border-radius: 6px;
74
+ background: var(--vscode-dropdown-background, var(--vscode-editorWidget-background, #202020));
75
+ box-shadow: 0 12px 28px rgb(0 0 0 / 28%);
76
+ }
77
+
78
+ .viewer-project-switcher__menu[hidden] {
79
+ display: none !important;
80
+ }
81
+
82
+ .viewer-project-switcher__item {
83
+ width: 100%;
84
+ display: grid;
85
+ grid-template-columns: minmax(0, 1fr) auto;
86
+ gap: 4px 10px;
87
+ padding: 7px 8px;
88
+ border: 0;
89
+ border-radius: 4px;
90
+ background: transparent;
91
+ color: var(--vscode-foreground, #d4d4d4);
92
+ text-align: left;
93
+ cursor: pointer;
94
+ }
95
+
96
+ .viewer-project-switcher__item:hover,
97
+ .viewer-project-switcher__item.is-active {
98
+ background: var(--vscode-list-hoverBackground, rgb(255 255 255 / 8%));
99
+ }
100
+
101
+ .viewer-project-switcher__item-name,
102
+ .viewer-project-switcher__item-state {
103
+ overflow: hidden;
104
+ text-overflow: ellipsis;
105
+ white-space: nowrap;
106
+ }
107
+
108
+ .viewer-project-switcher__item-name {
109
+ font-weight: 650;
110
+ }
111
+
112
+ .viewer-project-switcher__item-path {
113
+ grid-column: 1 / -1;
114
+ overflow: hidden;
115
+ text-overflow: ellipsis;
116
+ white-space: nowrap;
117
+ color: var(--vscode-descriptionForeground, #9da5b4);
118
+ font-size: 11px;
119
+ }
120
+
121
+ .viewer-project-switcher__item-state {
122
+ color: var(--vscode-descriptionForeground, #9da5b4);
123
+ font-size: 11px;
124
+ }
125
+
40
126
  .viewer-topbar__repo-actions {
41
127
  display: inline-flex;
42
128
  align-items: center;
@@ -529,6 +615,31 @@
529
615
  gap: 14px;
530
616
  }
531
617
 
618
+ .viewer-cdx__modes {
619
+ display: inline-flex;
620
+ align-items: center;
621
+ gap: 2px;
622
+ width: fit-content;
623
+ padding: 2px;
624
+ border: 1px solid var(--vscode-panel-border, #333333);
625
+ border-radius: 6px;
626
+ background: var(--vscode-editorWidget-background, #202020);
627
+ }
628
+
629
+ .viewer-cdx__mode {
630
+ border: 0;
631
+ border-radius: 4px;
632
+ padding: 5px 10px;
633
+ background: transparent;
634
+ color: var(--vscode-descriptionForeground, #9da5b4);
635
+ cursor: pointer;
636
+ }
637
+
638
+ .viewer-cdx__mode.is-active {
639
+ background: var(--vscode-button-secondaryBackground, #3a3d41);
640
+ color: var(--vscode-button-secondaryForeground, var(--vscode-foreground, #d4d4d4));
641
+ }
642
+
532
643
  .viewer-git__summary,
533
644
  .viewer-cdx__summary,
534
645
  .viewer-ci__summary,
@@ -336,6 +336,7 @@ def _build_spec_first_pass(repo_root: Path, backlog_ref: str) -> dict[str, objec
336
336
  [
337
337
  f"## {ref} - {spec_title}",
338
338
  f"> From version: {_parse_package_version(repo_root)}",
339
+ "> Status: Draft",
339
340
  "> Understanding: 90%",
340
341
  "> Confidence: 85%",
341
342
  "",
@@ -800,6 +800,8 @@ def _workflow_mermaid_block(kind: str, signature: str) -> list[str]:
800
800
 
801
801
 
802
802
  def _with_workflow_mermaid_overview(kind: str, content: str) -> str:
803
+ if kind == "task":
804
+ return content
803
805
  lines = content.rstrip().splitlines()
804
806
  signature = expected_workflow_mermaid_signature(kind, lines)
805
807
  if not signature:
@@ -995,6 +997,8 @@ def _mermaid_closeout_issue(path: Path, kind: str) -> str | None:
995
997
  text = path.read_text(encoding="utf-8")
996
998
  match = re.search(r"```mermaid\s*\n(.*?)\n```", text, flags=re.DOTALL)
997
999
  if match is None:
1000
+ if kind == "task":
1001
+ return None
998
1002
  return "missing Mermaid overview block"
999
1003
  signature_match = re.search(r"^\s*%%\s*logics-signature:\s*(.+?)\s*$", match.group(1), flags=re.MULTILINE)
1000
1004
  expected = expected_workflow_mermaid_signature(kind, text.splitlines())
@@ -1625,7 +1629,7 @@ def _build_native_task_doc(
1625
1629
  "",
1626
1630
  ]
1627
1631
  ).rstrip() + "\n"
1628
- return _with_workflow_mermaid_overview("task", content)
1632
+ return content
1629
1633
 
1630
1634
 
1631
1635
  def _extract_doc_title(path: Path) -> str:
@@ -2183,7 +2187,7 @@ def _build_native_task_from_backlog(
2183
2187
  "",
2184
2188
  ]
2185
2189
  ).rstrip() + "\n"
2186
- return ref, _with_workflow_mermaid_overview("task", content)
2190
+ return ref, content
2187
2191
 
2188
2192
 
2189
2193
  def build_parser() -> argparse.ArgumentParser:
@@ -15,18 +15,19 @@ from .config import find_repo_root
15
15
  @dataclass(frozen=True)
16
16
  class Kind:
17
17
  directory: str
18
- prefix: str
18
+ prefixes: tuple[str, ...]
19
19
  requires_progress: bool
20
20
  required_indicators: tuple[str, ...]
21
21
  allowed_statuses: tuple[str, ...]
22
22
 
23
23
 
24
24
  KINDS = {
25
- "request": Kind("logics/request", "req", False, ("From version", "Understanding", "Confidence"), ("Draft", "Ready", "In progress", "Blocked", "Done", "Obsolete", "Archived")),
26
- "backlog": Kind("logics/backlog", "item", True, ("From version", "Understanding", "Confidence", "Progress"), ("Draft", "Ready", "In progress", "Blocked", "Done", "Obsolete", "Archived")),
27
- "task": Kind("logics/tasks", "task", True, ("From version", "Understanding", "Confidence", "Progress"), ("Draft", "Ready", "In progress", "Blocked", "Done", "Obsolete", "Archived")),
28
- "product": Kind("logics/product", "prod", False, ("Date", "Status", "Related request", "Related backlog", "Related task", "Related architecture", "Reminder"), ("Draft", "Proposed", "Active", "Accepted", "Validated", "Rejected", "Superseded", "Settled", "Archived")),
29
- "architecture": Kind("logics/architecture", "adr", False, ("Date", "Status", "Drivers", "Related request", "Related backlog", "Related task", "Reminder"), ("Draft", "Proposed", "Accepted", "Validated", "Rejected", "Superseded", "Settled", "Archived")),
25
+ "request": Kind("logics/request", ("req",), False, ("From version", "Understanding", "Confidence"), ("Draft", "Ready", "In progress", "Blocked", "Done", "Obsolete", "Archived")),
26
+ "backlog": Kind("logics/backlog", ("item",), True, ("From version", "Understanding", "Confidence", "Progress"), ("Draft", "Ready", "In progress", "Blocked", "Done", "Obsolete", "Archived")),
27
+ "task": Kind("logics/tasks", ("task",), True, ("From version", "Understanding", "Confidence", "Progress"), ("Draft", "Ready", "In progress", "Blocked", "Done", "Obsolete", "Archived")),
28
+ "product": Kind("logics/product", ("prod",), False, ("Date", "Status", "Related request", "Related backlog", "Related task", "Related architecture", "Reminder"), ("Draft", "Proposed", "Active", "Accepted", "Validated", "Rejected", "Superseded", "Settled", "Archived")),
29
+ "architecture": Kind("logics/architecture", ("adr",), False, ("Date", "Status", "Drivers", "Related request", "Related backlog", "Related task", "Reminder"), ("Draft", "Proposed", "Accepted", "Validated", "Rejected", "Superseded", "Settled", "Archived")),
30
+ "spec": Kind("logics/specs", ("spec", "req"), False, ("From version", "Status", "Understanding", "Confidence"), ("Draft", "Ready", "In progress", "Done", "Validated", "Settled", "Archived")),
30
31
  }
31
32
 
32
33
  WORKFLOW_KINDS = {"request", "backlog", "task"}
@@ -503,7 +504,8 @@ def _lint_file(path: Path, kind_name: str, kind: Kind, require_status: bool, che
503
504
  issues: list[str] = []
504
505
  warnings: list[str] = []
505
506
  name = path.name
506
- if not re.match(rf"^{re.escape(kind.prefix)}_\d{{3}}_[a-z0-9_]+\.md$", name):
507
+ allowed_prefixes = "|".join(re.escape(prefix) for prefix in kind.prefixes)
508
+ if not re.match(rf"^({allowed_prefixes})_\d{{3}}_[a-z0-9_]+\.md$", name):
507
509
  issues.append(f"bad filename: {name}")
508
510
 
509
511
  lines = _read_lines(path)
@@ -557,6 +559,8 @@ def lint_payload(repo_root: Path, *, require_status: bool = False) -> dict[str,
557
559
  if not directory.is_dir():
558
560
  continue
559
561
  for path in sorted(directory.glob("*.md")):
562
+ if path.name == "README.md":
563
+ continue
560
564
  rel_path = path.relative_to(repo_root)
561
565
  issues, warnings = _lint_file(
562
566
  path,
@@ -1,6 +1,7 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import argparse
4
+ import hashlib
4
5
  import json
5
6
  import mimetypes
6
7
  import os
@@ -20,6 +21,7 @@ from typing import Any
20
21
  from urllib.parse import parse_qs, quote, unquote, urlencode, urlparse
21
22
 
22
23
  from .audit import audit_payload
24
+ from .bootstrap import bootstrap_payload
23
25
  from .config import find_repo_root
24
26
  from .lint import lint_payload
25
27
  from .update_check import get_update_info
@@ -342,22 +344,28 @@ def viewer_data_payload(
342
344
  selected_id: str | None = None,
343
345
  *,
344
346
  auto_refresh_interval_seconds: int = 15,
347
+ projects: list[dict[str, Any]] | None = None,
345
348
  ) -> dict[str, Any]:
349
+ capabilities = viewer_project_capabilities(repo_root)
350
+ active_root = repo_root.resolve()
351
+ has_logics = capabilities["logics"]["available"] is True
346
352
  return {
347
- "root": str(repo_root.resolve()),
348
- "repoName": repo_root.resolve().name,
353
+ "root": str(active_root),
354
+ "repoName": active_root.name,
349
355
  "repository": {
350
- "root": str(repo_root.resolve()),
356
+ "root": str(active_root),
351
357
  "githubUrl": github_repo_url(repo_root),
352
358
  },
359
+ "capabilities": capabilities,
360
+ "projects": projects if projects is not None else viewer_project_registry(repo_root),
353
361
  "autoRefreshIntervalSeconds": auto_refresh_interval_seconds,
354
362
  "items": collect_viewer_items(repo_root),
355
363
  "updateInfo": get_update_info(_current_version()).to_payload(),
356
364
  "selectedId": selected_id,
357
365
  "changedPaths": [],
358
366
  "canResetProjectRoot": False,
359
- "canBootstrapLogics": False,
360
- "bootstrapLogicsTitle": "Local viewer is read-only. Use the CLI to bootstrap Logics.",
367
+ "canBootstrapLogics": not has_logics,
368
+ "bootstrapLogicsTitle": "Bootstrap Logics in this project." if not has_logics else "Logics is already bootstrapped.",
361
369
  "canLaunchCodex": False,
362
370
  "canLaunchClaude": False,
363
371
  "canRepairLogicsKit": False,
@@ -366,6 +374,155 @@ def viewer_data_payload(
366
374
  }
367
375
 
368
376
 
377
+ def _viewer_project_id(repo_root: Path) -> str:
378
+ normalized = str(repo_root.resolve())
379
+ return hashlib.sha1(normalized.encode("utf-8")).hexdigest()[:12]
380
+
381
+
382
+ def _looks_like_viewer_project(path: Path) -> bool:
383
+ if not path.is_dir():
384
+ return False
385
+ return any((path / marker).exists() for marker in ("logics", ".git", "package.json", "pyproject.toml", "logics.yaml"))
386
+
387
+
388
+ def discover_viewer_project_roots(repo_root: Path, *, max_projects: int = 40) -> list[Path]:
389
+ active = repo_root.resolve()
390
+ candidates: list[Path] = [active]
391
+ parent = active.parent
392
+ try:
393
+ siblings = sorted(parent.iterdir(), key=lambda path: path.name.lower())
394
+ except OSError:
395
+ siblings = []
396
+ for sibling in siblings:
397
+ try:
398
+ resolved = sibling.resolve()
399
+ except OSError:
400
+ continue
401
+ if resolved == active or not _looks_like_viewer_project(resolved):
402
+ continue
403
+ candidates.append(resolved)
404
+ if len(candidates) >= max_projects:
405
+ break
406
+
407
+ unique: dict[str, Path] = {}
408
+ for candidate in candidates:
409
+ unique[str(candidate)] = candidate
410
+ return list(unique.values())
411
+
412
+
413
+ def viewer_project_entry(repo_root: Path, *, active_root: Path | None = None) -> dict[str, Any]:
414
+ root = repo_root.resolve()
415
+ active = active_root.resolve() if active_root else root
416
+ has_logics = (root / "logics").is_dir()
417
+ available = root.is_dir()
418
+ return {
419
+ "id": _viewer_project_id(root),
420
+ "name": root.name,
421
+ "root": str(root),
422
+ "active": root == active,
423
+ "available": available,
424
+ "hasLogics": has_logics,
425
+ "message": "Logics corpus found." if has_logics else "No Logics corpus found.",
426
+ }
427
+
428
+
429
+ def viewer_project_registry(repo_root: Path, *, project_roots: list[Path] | None = None) -> list[dict[str, Any]]:
430
+ active = repo_root.resolve()
431
+ roots = project_roots if project_roots is not None else discover_viewer_project_roots(active)
432
+ return [viewer_project_entry(root, active_root=active) for root in roots]
433
+
434
+
435
+ def _viewer_capability(state: str, *, available: bool, message: str, detail: dict[str, Any] | None = None) -> dict[str, Any]:
436
+ payload: dict[str, Any] = {
437
+ "state": state,
438
+ "available": available,
439
+ "message": message,
440
+ }
441
+ if detail:
442
+ payload["detail"] = detail
443
+ return payload
444
+
445
+
446
+ def _git_is_repository(repo_root: Path, *, runner: Any | None = None) -> bool | None:
447
+ try:
448
+ result = _run_read_only_git(repo_root, ["rev-parse", "--is-inside-work-tree"], runner=runner)
449
+ except (OSError, subprocess.SubprocessError):
450
+ return None
451
+ if result.returncode != 0:
452
+ return False
453
+ return result.stdout.strip().lower() == "true"
454
+
455
+
456
+ def viewer_project_capabilities(
457
+ repo_root: Path,
458
+ *,
459
+ git_runner: Any | None = None,
460
+ which: Any | None = None,
461
+ ) -> dict[str, Any]:
462
+ which_command = which or shutil.which
463
+ logics_dir = repo_root / "logics"
464
+ has_logics = logics_dir.is_dir()
465
+ git_path = which_command("git")
466
+ cdx_path = which_command("cdx")
467
+
468
+ if has_logics:
469
+ logics = _viewer_capability("ready", available=True, message="Logics corpus found.")
470
+ else:
471
+ logics = _viewer_capability("missing", available=False, message="No Logics corpus found.")
472
+
473
+ if not git_path:
474
+ git = _viewer_capability("unavailable", available=False, message="Git executable is not available.")
475
+ github_url = ""
476
+ has_workflows = False
477
+ else:
478
+ is_repo = _git_is_repository(repo_root, runner=git_runner)
479
+ if is_repo is True:
480
+ git = _viewer_capability("ready", available=True, message="Git repository detected.")
481
+ github_url = github_repo_url(repo_root, runner=git_runner, which=which_command)
482
+ has_workflows = _has_github_actions_workflows(repo_root)
483
+ elif is_repo is False:
484
+ git = _viewer_capability("missing", available=False, message="Project is not a Git repository.")
485
+ github_url = ""
486
+ has_workflows = False
487
+ else:
488
+ git = _viewer_capability("error", available=False, message="Unable to inspect Git repository state.")
489
+ github_url = ""
490
+ has_workflows = False
491
+
492
+ if not github_url:
493
+ ci = _viewer_capability("hidden", available=False, message="No GitHub remote detected for this project.")
494
+ elif not has_workflows:
495
+ ci = _viewer_capability("hidden", available=False, message="No GitHub Actions workflows detected for this project.")
496
+ elif not which_command("gh"):
497
+ ci = _viewer_capability("unavailable", available=False, message="GitHub CLI is not available.")
498
+ else:
499
+ ci = _viewer_capability(
500
+ "ready",
501
+ available=True,
502
+ message="GitHub Actions can be inspected.",
503
+ detail={"githubUrl": github_url},
504
+ )
505
+
506
+ if cdx_path:
507
+ cdx = _viewer_capability("ready", available=True, message="CDX executable detected.")
508
+ cdx_runs = _viewer_capability(
509
+ "unsupported",
510
+ available=False,
511
+ message="CDX assistant run registry is not available yet.",
512
+ )
513
+ else:
514
+ cdx = _viewer_capability("missing", available=False, message="CDX executable is not available.")
515
+ cdx_runs = _viewer_capability("missing", available=False, message="CDX is required before assistant runs can be tracked.")
516
+
517
+ return {
518
+ "logics": logics,
519
+ "git": git,
520
+ "ci": ci,
521
+ "cdx": cdx,
522
+ "cdxRuns": cdx_runs,
523
+ }
524
+
525
+
369
526
  def read_doc_payload(repo_root: Path, rel_path: str) -> dict[str, Any]:
370
527
  normalized, absolute = _resolve_repo_doc_path(repo_root, rel_path)
371
528
  return {
@@ -939,6 +1096,123 @@ def cdx_status_payload(repo_root: Path, *, runner: Any | None = None, which: Any
939
1096
  return {"state": "ok", "message": "", "status": parsed}
940
1097
 
941
1098
 
1099
+ def cdx_runs_payload(repo_root: Path, *, runner: Any | None = None, which: Any | None = None) -> dict[str, Any]:
1100
+ cdx_which = which or shutil.which
1101
+ if not cdx_which("cdx"):
1102
+ return {"state": "unavailable", "message": "CDX executable is not available on PATH.", "runs": []}
1103
+ try:
1104
+ result = _run_read_only_cdx(repo_root, ["runs", "--json"], runner=runner)
1105
+ except subprocess.TimeoutExpired:
1106
+ return {"state": "timeout", "message": "CDX runs timed out.", "runs": []}
1107
+ except (OSError, subprocess.SubprocessError) as exc:
1108
+ return {"state": "error", "message": f"Unable to run CDX runs: {exc}", "runs": []}
1109
+ if result.returncode != 0:
1110
+ message = (result.stderr or result.stdout or "CDX runs failed.").strip().splitlines()[0]
1111
+ return {"state": "error", "message": message, "runs": []}
1112
+ try:
1113
+ parsed = json.loads(result.stdout or "{}")
1114
+ except json.JSONDecodeError:
1115
+ return {"state": "invalid-json", "message": "CDX runs returned invalid JSON.", "runs": []}
1116
+ runs = parsed.get("runs") if isinstance(parsed, dict) else None
1117
+ if not isinstance(runs, list):
1118
+ return {"state": "invalid-json", "message": "CDX runs JSON must include a runs array.", "runs": []}
1119
+ return {"state": "ok", "message": "", "runs": [run for run in runs if isinstance(run, dict)]}
1120
+
1121
+
1122
+ def cdx_run_report_payload(repo_root: Path, run_id: str, *, runner: Any | None = None, which: Any | None = None) -> dict[str, Any]:
1123
+ cdx_which = which or shutil.which
1124
+ if not run_id:
1125
+ return {"state": "error", "message": "Missing CDX run id.", "report": None}
1126
+ if not cdx_which("cdx"):
1127
+ return {"state": "unavailable", "message": "CDX executable is not available on PATH.", "report": None}
1128
+ try:
1129
+ result = _run_read_only_cdx(repo_root, ["run-report", run_id, "--json"], runner=runner)
1130
+ except subprocess.TimeoutExpired:
1131
+ return {"state": "timeout", "message": "CDX run report timed out.", "report": None}
1132
+ except (OSError, subprocess.SubprocessError) as exc:
1133
+ return {"state": "error", "message": f"Unable to run CDX run-report: {exc}", "report": None}
1134
+ if result.returncode != 0:
1135
+ message = (result.stderr or result.stdout or "CDX run-report failed.").strip().splitlines()[0]
1136
+ return {"state": "error", "message": message, "report": None}
1137
+ try:
1138
+ parsed = json.loads(result.stdout or "{}")
1139
+ except json.JSONDecodeError:
1140
+ return {"state": "invalid-json", "message": "CDX run-report returned invalid JSON.", "report": None}
1141
+ report = parsed.get("report") if isinstance(parsed, dict) else None
1142
+ if not isinstance(report, dict):
1143
+ return {"state": "invalid-json", "message": "CDX run-report JSON must include a report object.", "report": None}
1144
+ return {"state": "ok", "message": "", "report": report}
1145
+
1146
+
1147
+ def _slugify_viewer_doc(text: str) -> str:
1148
+ slug = re.sub(r"[^a-z0-9]+", "_", text.lower()).strip("_")
1149
+ return slug[:80] or "cdx_code_review_findings"
1150
+
1151
+
1152
+ def _next_viewer_request_ref(repo_root: Path, title: str) -> str:
1153
+ request_dir = repo_root / "logics" / "request"
1154
+ highest = -1
1155
+ if request_dir.is_dir():
1156
+ for path in request_dir.glob("req_*.md"):
1157
+ match = re.match(r"^req_(\d{3})_", path.stem)
1158
+ if match:
1159
+ highest = max(highest, int(match.group(1)))
1160
+ return f"req_{highest + 1:03d}_{_slugify_viewer_doc(title)}"
1161
+
1162
+
1163
+ def create_request_from_cdx_report(repo_root: Path, report_payload: dict[str, Any]) -> dict[str, Any]:
1164
+ report = report_payload.get("report") if isinstance(report_payload.get("report"), dict) else report_payload
1165
+ run = report.get("run") if isinstance(report.get("run"), dict) else {}
1166
+ task_report = report.get("task_report") if isinstance(report.get("task_report"), dict) else {}
1167
+ run_id = str(run.get("run_id") or task_report.get("run_id") or "unknown")
1168
+ findings = task_report.get("findings") if isinstance(task_report.get("findings"), list) else []
1169
+ title = f"Address CDX code review findings for {run_id}"
1170
+ ref = _next_viewer_request_ref(repo_root, title)
1171
+ request_dir = repo_root / "logics" / "request"
1172
+ request_dir.mkdir(parents=True, exist_ok=True)
1173
+ rel_path = f"logics/request/{ref}.md"
1174
+ path = repo_root / rel_path
1175
+ finding_lines = []
1176
+ for index, finding in enumerate(findings, start=1):
1177
+ if not isinstance(finding, dict):
1178
+ continue
1179
+ location = finding.get("path") or finding.get("file") or "unknown path"
1180
+ if finding.get("line"):
1181
+ location = f"{location}:{finding['line']}"
1182
+ severity = finding.get("severity") or "unknown"
1183
+ message = finding.get("message") or finding.get("title") or "Review finding"
1184
+ finding_lines.append(f"- F{index} [{severity}] `{location}`: {message}")
1185
+ if not finding_lines:
1186
+ finding_lines.append("- No structured findings were reported. Review the CDX artifacts linked below.")
1187
+ text = "\n".join([
1188
+ f"## {ref} - {title}",
1189
+ "> Status: Draft",
1190
+ "> Understanding: 70%",
1191
+ "> Confidence: 70%",
1192
+ "> Complexity: Medium",
1193
+ "> Theme: Code review follow-up",
1194
+ "",
1195
+ "# Needs",
1196
+ f"- Follow up on CDX code-review run `{run_id}`.",
1197
+ f"- Summary: {task_report.get('summary') or 'No structured summary provided.'}",
1198
+ "",
1199
+ "# Findings",
1200
+ *finding_lines,
1201
+ "",
1202
+ "# Traceability",
1203
+ f"- CDX run id: `{run_id}`",
1204
+ f"- Transcript: `{(report.get('artifacts') or {}).get('transcript_path') or ''}`",
1205
+ f"- Stdout: `{(report.get('artifacts') or {}).get('stdout_path') or ''}`",
1206
+ "",
1207
+ "# Acceptance Criteria",
1208
+ "- AC1: Each actionable finding is reviewed and either fixed, documented as not applicable, or split into follow-up work.",
1209
+ "- AC2: Validation evidence is added before closing this request.",
1210
+ "",
1211
+ ])
1212
+ path.write_text(text, encoding="utf-8")
1213
+ return {"id": ref, "path": rel_path, "title": title}
1214
+
1215
+
942
1216
  def _json_bytes(payload: Any) -> bytes:
943
1217
  return json.dumps(payload, indent=2, sort_keys=True).encode("utf-8")
944
1218
 
@@ -951,10 +1225,35 @@ class LogicsViewerServer(ThreadingHTTPServer):
951
1225
  *,
952
1226
  auto_refresh_interval_seconds: int = 15,
953
1227
  ):
954
- self.repo_root = repo_root.resolve()
1228
+ self.launch_repo_root = repo_root.resolve()
1229
+ self.project_roots = discover_viewer_project_roots(self.launch_repo_root)
1230
+ self.project_root_by_id = {_viewer_project_id(root): root.resolve() for root in self.project_roots}
1231
+ self.active_project_id = _viewer_project_id(self.launch_repo_root)
1232
+ self.repo_root = self.launch_repo_root
955
1233
  self.auto_refresh_interval_seconds = auto_refresh_interval_seconds
956
1234
  super().__init__(server_address, LogicsViewerRequestHandler)
957
1235
 
1236
+ def project_registry_payload(self) -> list[dict[str, Any]]:
1237
+ return viewer_project_registry(self.repo_root, project_roots=self.project_roots)
1238
+
1239
+ def viewer_payload(self, *, selected_id: str | None = None) -> dict[str, Any]:
1240
+ return viewer_data_payload(
1241
+ self.repo_root,
1242
+ selected_id=selected_id,
1243
+ auto_refresh_interval_seconds=self.auto_refresh_interval_seconds,
1244
+ projects=self.project_registry_payload(),
1245
+ )
1246
+
1247
+ def switch_project(self, project_id: str) -> dict[str, Any]:
1248
+ target = self.project_root_by_id.get(project_id)
1249
+ if target is None:
1250
+ raise ValueError("Unknown project id.")
1251
+ if not target.is_dir():
1252
+ raise FileNotFoundError(str(target))
1253
+ self.active_project_id = project_id
1254
+ self.repo_root = target
1255
+ return self.viewer_payload()
1256
+
958
1257
 
959
1258
  class LogicsViewerRequestHandler(BaseHTTPRequestHandler):
960
1259
  server: LogicsViewerServer
@@ -1018,13 +1317,13 @@ class LogicsViewerRequestHandler(BaseHTTPRequestHandler):
1018
1317
  self._send_json(
1019
1318
  {
1020
1319
  "ok": True,
1021
- "payload": viewer_data_payload(
1022
- self.server.repo_root,
1023
- auto_refresh_interval_seconds=self.server.auto_refresh_interval_seconds,
1024
- ),
1320
+ "payload": self.server.viewer_payload(),
1025
1321
  }
1026
1322
  )
1027
1323
  return
1324
+ if route == "/api/projects":
1325
+ self._send_json({"ok": True, "payload": {"projects": self.server.project_registry_payload()}})
1326
+ return
1028
1327
  if route == "/api/doc":
1029
1328
  rel_path = parse_qs(parsed.query).get("path", [""])[0]
1030
1329
  try:
@@ -1038,6 +1337,9 @@ class LogicsViewerRequestHandler(BaseHTTPRequestHandler):
1038
1337
  if route == "/api/audit":
1039
1338
  self._send_json({"ok": True, "payload": audit_payload(self.server.repo_root)})
1040
1339
  return
1340
+ if route == "/api/capabilities":
1341
+ self._send_json({"ok": True, "payload": viewer_project_capabilities(self.server.repo_root)})
1342
+ return
1041
1343
  if route == "/api/git-status":
1042
1344
  self._send_json({"ok": True, "payload": git_status_payload(self.server.repo_root)})
1043
1345
  return
@@ -1047,6 +1349,13 @@ class LogicsViewerRequestHandler(BaseHTTPRequestHandler):
1047
1349
  if route == "/api/cdx-status":
1048
1350
  self._send_json({"ok": True, "payload": cdx_status_payload(self.server.repo_root)})
1049
1351
  return
1352
+ if route == "/api/cdx-runs":
1353
+ self._send_json({"ok": True, "payload": cdx_runs_payload(self.server.repo_root)})
1354
+ return
1355
+ if route == "/api/cdx-run-report":
1356
+ run_id = parse_qs(parsed.query).get("runId", [""])[0]
1357
+ self._send_json({"ok": True, "payload": cdx_run_report_payload(self.server.repo_root, run_id)})
1358
+ return
1050
1359
  if route == "/api/git-diff":
1051
1360
  params = parse_qs(parsed.query)
1052
1361
  rel_path = params.get("path", [""])[0]
@@ -1061,13 +1370,49 @@ class LogicsViewerRequestHandler(BaseHTTPRequestHandler):
1061
1370
  self._send_json(
1062
1371
  {
1063
1372
  "ok": True,
1064
- "payload": viewer_data_payload(
1065
- self.server.repo_root,
1066
- auto_refresh_interval_seconds=self.server.auto_refresh_interval_seconds,
1067
- ),
1373
+ "payload": self.server.viewer_payload(),
1068
1374
  }
1069
1375
  )
1070
1376
  return
1377
+ if parsed.path == "/api/switch-project":
1378
+ try:
1379
+ length = int(self.headers.get("Content-Length", "0") or "0")
1380
+ raw_body = self.rfile.read(length).decode("utf-8") if length > 0 else "{}"
1381
+ body = json.loads(raw_body or "{}")
1382
+ project_id = str(body.get("projectId") or "")
1383
+ self._send_json({"ok": True, "payload": self.server.switch_project(project_id)})
1384
+ except json.JSONDecodeError:
1385
+ self._send_error_json(HTTPStatus.BAD_REQUEST, "Invalid JSON body.")
1386
+ except ValueError as exc:
1387
+ self._send_error_json(HTTPStatus.FORBIDDEN, str(exc))
1388
+ except FileNotFoundError as exc:
1389
+ self._send_error_json(HTTPStatus.NOT_FOUND, str(exc))
1390
+ return
1391
+ if parsed.path == "/api/bootstrap-logics":
1392
+ try:
1393
+ bootstrap = bootstrap_payload(self.server.repo_root, check=False)
1394
+ self._send_json({"ok": True, "payload": self.server.viewer_payload(), "bootstrap": bootstrap})
1395
+ except SystemExit as exc:
1396
+ self._send_error_json(HTTPStatus.BAD_REQUEST, str(exc))
1397
+ except OSError as exc:
1398
+ self._send_error_json(HTTPStatus.INTERNAL_SERVER_ERROR, str(exc))
1399
+ return
1400
+ if parsed.path == "/api/cdx-report-request":
1401
+ try:
1402
+ length = int(self.headers.get("Content-Length", "0") or "0")
1403
+ raw_body = self.rfile.read(length).decode("utf-8") if length > 0 else "{}"
1404
+ body = json.loads(raw_body or "{}")
1405
+ report_payload = cdx_run_report_payload(self.server.repo_root, str(body.get("runId") or ""))
1406
+ if report_payload.get("state") != "ok":
1407
+ self._send_error_json(HTTPStatus.BAD_GATEWAY, str(report_payload.get("message") or "Unable to load CDX report."))
1408
+ return
1409
+ created = create_request_from_cdx_report(self.server.repo_root, report_payload)
1410
+ self._send_json({"ok": True, "created": created, "payload": self.server.viewer_payload(selected_id=created["id"])})
1411
+ except json.JSONDecodeError:
1412
+ self._send_error_json(HTTPStatus.BAD_REQUEST, "Invalid JSON body.")
1413
+ except OSError as exc:
1414
+ self._send_error_json(HTTPStatus.INTERNAL_SERVER_ERROR, str(exc))
1415
+ return
1071
1416
  if parsed.path == "/api/edit":
1072
1417
  rel_path = parse_qs(parsed.query).get("path", [""])[0]
1073
1418
  try:
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@grifhinz/logics-manager",
3
3
  "displayName": "Logics Orchestrator",
4
4
  "description": "Visual orchestration for Logics workflows inside VS Code.",
5
- "version": "2.6.0",
5
+ "version": "2.7.0",
6
6
  "publisher": "cdx-logics",
7
7
  "icon": "clients/shared-web/media/icon.png",
8
8
  "repository": {
package/pyproject.toml CHANGED
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "logics-manager"
7
- version = "2.6.0"
7
+ version = "2.7.0"
8
8
  description = "Canonical Logics CLI"
9
9
  requires-python = ">=3.10"
10
10