@grifhinz/logics-manager 2.6.0 → 2.8.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.
@@ -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
@@ -42,6 +44,104 @@ DOC_FAMILIES = (
42
44
  )
43
45
 
44
46
  STAGE_ORDER = {family.stage: index for index, family in enumerate(DOC_FAMILIES)}
47
+ CDX_MISSION_STRENGTHS = {
48
+ "standard": {"id": "standard", "label": "Standard", "timeout": 180, "reasoningEffort": "medium", "power": "medium"},
49
+ "deep": {"id": "deep", "label": "Deep", "timeout": 300, "reasoningEffort": "high", "power": "high"},
50
+ "max": {"id": "max", "label": "Max", "timeout": 600, "reasoningEffort": "high", "power": "high"},
51
+ }
52
+ CDX_MISSION_CATALOG = {
53
+ "full-audit": {
54
+ "id": "full-audit",
55
+ "title": "Full audit",
56
+ "description": "Audit the repository and optionally apply safe, validated fixes.",
57
+ "scope": "repository",
58
+ "requiresReleaseTag": False,
59
+ "requiresPlanConfirmation": False,
60
+ "supportsFileWrites": True,
61
+ "inputFields": [
62
+ {
63
+ "id": "directFixes",
64
+ "label": "Fix directly",
65
+ "type": "checkbox",
66
+ "required": False,
67
+ }
68
+ ],
69
+ },
70
+ "release-review": {
71
+ "id": "release-review",
72
+ "title": "Review since latest release",
73
+ "description": "Review changes since the latest release and optionally apply safe fixes.",
74
+ "scope": "latest-release",
75
+ "requiresReleaseTag": True,
76
+ "requiresPlanConfirmation": False,
77
+ "supportsFileWrites": True,
78
+ "inputFields": [
79
+ {
80
+ "id": "directFixes",
81
+ "label": "Fix directly",
82
+ "type": "checkbox",
83
+ "required": False,
84
+ }
85
+ ],
86
+ },
87
+ "corpus-ready": {
88
+ "id": "corpus-ready",
89
+ "title": "Prepare dev-ready corpus",
90
+ "description": "Produce a corpus plan for explicit deterministic application.",
91
+ "scope": "open-logics-workflow",
92
+ "requiresReleaseTag": False,
93
+ "requiresPlanConfirmation": True,
94
+ "supportsFileWrites": False,
95
+ },
96
+ "wish-to-request": {
97
+ "id": "wish-to-request",
98
+ "title": "Wish to request",
99
+ "description": "Create or draft a structured Logics request from a free-form wish.",
100
+ "scope": "request-draft",
101
+ "requiresReleaseTag": False,
102
+ "requiresPlanConfirmation": False,
103
+ "supportsFileWrites": True,
104
+ "inputFields": [
105
+ {
106
+ "id": "wishText",
107
+ "label": "Wish or intent",
108
+ "type": "textarea",
109
+ "placeholder": "Describe the workflow, feature, bug, or product intent to capture.",
110
+ "required": True,
111
+ }
112
+ ],
113
+ },
114
+ "pre-release": {
115
+ "id": "pre-release",
116
+ "title": "Guarded pre-release",
117
+ "description": "Prepare release metadata, changelog, validation, and fixes without tagging or publishing.",
118
+ "scope": "pre-release-report",
119
+ "requiresReleaseTag": False,
120
+ "requiresPlanConfirmation": False,
121
+ "supportsFileWrites": True,
122
+ "inputFields": [
123
+ {
124
+ "id": "releaseVersion",
125
+ "label": "Version",
126
+ "type": "text",
127
+ "placeholder": "vX.X.X",
128
+ "required": True,
129
+ "pattern": "^v\\d+\\.\\d+\\.\\d+$",
130
+ },
131
+ {
132
+ "id": "runFullValidation",
133
+ "label": "Run full validation and report fixes before pre-release",
134
+ "type": "checkbox",
135
+ "required": False,
136
+ },
137
+ ],
138
+ },
139
+ }
140
+ CDX_DEFAULT_MISSION_ID = "full-audit"
141
+ GIT_FILE_PREVIEW_MAX_BYTES = 30000
142
+ GIT_FILE_PREVIEW_MAX_CHARS = 20000
143
+ FILE_PREVIEW_MAX_BYTES = 300000
144
+ FILE_PREVIEW_MAX_CHARS = 200000
45
145
  REPO_ROOT = Path(__file__).resolve().parents[1]
46
146
  PACKAGE_VIEWER_ASSETS_ROOT = Path(__file__).resolve().parent / "viewer_assets"
47
147
  VIEWER_ROOT = REPO_ROOT / "clients" / "viewer"
@@ -342,22 +442,28 @@ def viewer_data_payload(
342
442
  selected_id: str | None = None,
343
443
  *,
344
444
  auto_refresh_interval_seconds: int = 15,
445
+ projects: list[dict[str, Any]] | None = None,
345
446
  ) -> dict[str, Any]:
447
+ capabilities = viewer_project_capabilities(repo_root)
448
+ active_root = repo_root.resolve()
449
+ has_logics = capabilities["logics"]["available"] is True
346
450
  return {
347
- "root": str(repo_root.resolve()),
348
- "repoName": repo_root.resolve().name,
451
+ "root": str(active_root),
452
+ "repoName": active_root.name,
349
453
  "repository": {
350
- "root": str(repo_root.resolve()),
454
+ "root": str(active_root),
351
455
  "githubUrl": github_repo_url(repo_root),
352
456
  },
457
+ "capabilities": capabilities,
458
+ "projects": projects if projects is not None else viewer_project_registry(repo_root),
353
459
  "autoRefreshIntervalSeconds": auto_refresh_interval_seconds,
354
460
  "items": collect_viewer_items(repo_root),
355
461
  "updateInfo": get_update_info(_current_version()).to_payload(),
356
462
  "selectedId": selected_id,
357
463
  "changedPaths": [],
358
464
  "canResetProjectRoot": False,
359
- "canBootstrapLogics": False,
360
- "bootstrapLogicsTitle": "Local viewer is read-only. Use the CLI to bootstrap Logics.",
465
+ "canBootstrapLogics": not has_logics,
466
+ "bootstrapLogicsTitle": "Bootstrap Logics in this project." if not has_logics else "Logics is already bootstrapped.",
361
467
  "canLaunchCodex": False,
362
468
  "canLaunchClaude": False,
363
469
  "canRepairLogicsKit": False,
@@ -366,6 +472,155 @@ def viewer_data_payload(
366
472
  }
367
473
 
368
474
 
475
+ def _viewer_project_id(repo_root: Path) -> str:
476
+ normalized = str(repo_root.resolve())
477
+ return hashlib.sha1(normalized.encode("utf-8")).hexdigest()[:12]
478
+
479
+
480
+ def _looks_like_viewer_project(path: Path) -> bool:
481
+ if not path.is_dir():
482
+ return False
483
+ return any((path / marker).exists() for marker in ("logics", ".git", "package.json", "pyproject.toml", "logics.yaml"))
484
+
485
+
486
+ def discover_viewer_project_roots(repo_root: Path, *, max_projects: int = 40) -> list[Path]:
487
+ active = repo_root.resolve()
488
+ candidates: list[Path] = [active]
489
+ parent = active.parent
490
+ try:
491
+ siblings = sorted(parent.iterdir(), key=lambda path: path.name.lower())
492
+ except OSError:
493
+ siblings = []
494
+ for sibling in siblings:
495
+ try:
496
+ resolved = sibling.resolve()
497
+ except OSError:
498
+ continue
499
+ if resolved == active or not _looks_like_viewer_project(resolved):
500
+ continue
501
+ candidates.append(resolved)
502
+ if len(candidates) >= max_projects:
503
+ break
504
+
505
+ unique: dict[str, Path] = {}
506
+ for candidate in candidates:
507
+ unique[str(candidate)] = candidate
508
+ return list(unique.values())
509
+
510
+
511
+ def viewer_project_entry(repo_root: Path, *, active_root: Path | None = None) -> dict[str, Any]:
512
+ root = repo_root.resolve()
513
+ active = active_root.resolve() if active_root else root
514
+ has_logics = (root / "logics").is_dir()
515
+ available = root.is_dir()
516
+ return {
517
+ "id": _viewer_project_id(root),
518
+ "name": root.name,
519
+ "root": str(root),
520
+ "active": root == active,
521
+ "available": available,
522
+ "hasLogics": has_logics,
523
+ "message": "Logics corpus found." if has_logics else "No Logics corpus found.",
524
+ }
525
+
526
+
527
+ def viewer_project_registry(repo_root: Path, *, project_roots: list[Path] | None = None) -> list[dict[str, Any]]:
528
+ active = repo_root.resolve()
529
+ roots = project_roots if project_roots is not None else discover_viewer_project_roots(active)
530
+ return [viewer_project_entry(root, active_root=active) for root in roots]
531
+
532
+
533
+ def _viewer_capability(state: str, *, available: bool, message: str, detail: dict[str, Any] | None = None) -> dict[str, Any]:
534
+ payload: dict[str, Any] = {
535
+ "state": state,
536
+ "available": available,
537
+ "message": message,
538
+ }
539
+ if detail:
540
+ payload["detail"] = detail
541
+ return payload
542
+
543
+
544
+ def _git_is_repository(repo_root: Path, *, runner: Any | None = None) -> bool | None:
545
+ try:
546
+ result = _run_read_only_git(repo_root, ["rev-parse", "--is-inside-work-tree"], runner=runner)
547
+ except (OSError, subprocess.SubprocessError):
548
+ return None
549
+ if result.returncode != 0:
550
+ return False
551
+ return result.stdout.strip().lower() == "true"
552
+
553
+
554
+ def viewer_project_capabilities(
555
+ repo_root: Path,
556
+ *,
557
+ git_runner: Any | None = None,
558
+ which: Any | None = None,
559
+ ) -> dict[str, Any]:
560
+ which_command = which or shutil.which
561
+ logics_dir = repo_root / "logics"
562
+ has_logics = logics_dir.is_dir()
563
+ git_path = which_command("git")
564
+ cdx_path = which_command("cdx")
565
+
566
+ if has_logics:
567
+ logics = _viewer_capability("ready", available=True, message="Logics corpus found.")
568
+ else:
569
+ logics = _viewer_capability("missing", available=False, message="No Logics corpus found.")
570
+
571
+ if not git_path:
572
+ git = _viewer_capability("unavailable", available=False, message="Git executable is not available.")
573
+ github_url = ""
574
+ has_workflows = False
575
+ else:
576
+ is_repo = _git_is_repository(repo_root, runner=git_runner)
577
+ if is_repo is True:
578
+ git = _viewer_capability("ready", available=True, message="Git repository detected.")
579
+ github_url = github_repo_url(repo_root, runner=git_runner, which=which_command)
580
+ has_workflows = _has_github_actions_workflows(repo_root)
581
+ elif is_repo is False:
582
+ git = _viewer_capability("missing", available=False, message="Project is not a Git repository.")
583
+ github_url = ""
584
+ has_workflows = False
585
+ else:
586
+ git = _viewer_capability("error", available=False, message="Unable to inspect Git repository state.")
587
+ github_url = ""
588
+ has_workflows = False
589
+
590
+ if not github_url:
591
+ ci = _viewer_capability("hidden", available=False, message="No GitHub remote detected for this project.")
592
+ elif not has_workflows:
593
+ ci = _viewer_capability("hidden", available=False, message="No GitHub Actions workflows detected for this project.")
594
+ elif not which_command("gh"):
595
+ ci = _viewer_capability("unavailable", available=False, message="GitHub CLI is not available.")
596
+ else:
597
+ ci = _viewer_capability(
598
+ "ready",
599
+ available=True,
600
+ message="GitHub Actions can be inspected.",
601
+ detail={"githubUrl": github_url},
602
+ )
603
+
604
+ if cdx_path:
605
+ cdx = _viewer_capability("ready", available=True, message="CDX executable detected.")
606
+ cdx_runs = _viewer_capability(
607
+ "unsupported",
608
+ available=False,
609
+ message="CDX assistant run registry is not available yet.",
610
+ )
611
+ else:
612
+ cdx = _viewer_capability("missing", available=False, message="CDX executable is not available.")
613
+ cdx_runs = _viewer_capability("missing", available=False, message="CDX is required before assistant runs can be tracked.")
614
+
615
+ return {
616
+ "logics": logics,
617
+ "git": git,
618
+ "ci": ci,
619
+ "cdx": cdx,
620
+ "cdxRuns": cdx_runs,
621
+ }
622
+
623
+
369
624
  def read_doc_payload(repo_root: Path, rel_path: str) -> dict[str, Any]:
370
625
  normalized, absolute = _resolve_repo_doc_path(repo_root, rel_path)
371
626
  return {
@@ -396,6 +651,54 @@ def edit_doc_payload(repo_root: Path, rel_path: str, *, launcher: Any | None = N
396
651
  }
397
652
 
398
653
 
654
+ def _resolve_openable_file_path(repo_root: Path, file_path: str) -> Path:
655
+ raw_path = unquote(file_path).strip()
656
+ if not raw_path:
657
+ raise ValueError("Missing file path.")
658
+ candidate = Path(raw_path).expanduser()
659
+ if not candidate.is_absolute():
660
+ candidate = repo_root / raw_path.lstrip("/\\")
661
+ absolute = candidate.resolve()
662
+ if not absolute.is_file():
663
+ raise FileNotFoundError(str(candidate))
664
+ return absolute
665
+
666
+
667
+ def open_file_payload(repo_root: Path, file_path: str, *, launcher: Any | None = None) -> dict[str, str]:
668
+ absolute = _resolve_openable_file_path(repo_root, file_path)
669
+ command = _system_editor_command(absolute)
670
+ runner = launcher or subprocess.Popen
671
+ runner(command)
672
+ return {
673
+ "path": str(absolute),
674
+ "command": command[0],
675
+ }
676
+
677
+
678
+ def file_preview_payload(
679
+ repo_root: Path,
680
+ file_path: str,
681
+ *,
682
+ max_bytes: int = FILE_PREVIEW_MAX_BYTES,
683
+ max_chars: int = FILE_PREVIEW_MAX_CHARS,
684
+ ) -> dict[str, Any]:
685
+ absolute = _resolve_openable_file_path(repo_root, file_path)
686
+ raw = absolute.read_bytes()
687
+ truncated = len(raw) > max_bytes
688
+ if truncated:
689
+ raw = raw[-max_bytes:]
690
+ content = raw.decode("utf-8", errors="replace")
691
+ if len(content) > max_chars:
692
+ content = content[-max_chars:]
693
+ truncated = True
694
+ return {
695
+ "path": str(absolute),
696
+ "name": absolute.name,
697
+ "content": content,
698
+ "truncated": truncated,
699
+ }
700
+
701
+
399
702
  def open_repo_folder_payload(repo_root: Path, *, launcher: Any | None = None) -> dict[str, str]:
400
703
  root = repo_root.resolve()
401
704
  command = _system_editor_command(root)
@@ -427,6 +730,24 @@ def _run_read_only_cdx(repo_root: Path, args: list[str], *, runner: Any | None =
427
730
  return cdx_runner(command, cwd=repo_root, text=True, capture_output=True, timeout=5)
428
731
 
429
732
 
733
+ def _run_cdx_mission(repo_root: Path, args: list[str], *, timeout: int, runner: Any | None = None) -> subprocess.CompletedProcess[str]:
734
+ command = ["cdx", *args]
735
+ cdx_runner = runner or subprocess.run
736
+ return cdx_runner(command, cwd=repo_root, text=True, capture_output=True, timeout=timeout)
737
+
738
+
739
+ def _run_logics_flow(repo_root: Path, args: list[str], *, runner: Any | None = None) -> subprocess.CompletedProcess[str]:
740
+ command = ["logics-manager", "flow", *args]
741
+ flow_runner = runner or subprocess.run
742
+ return flow_runner(command, cwd=repo_root, text=True, capture_output=True, timeout=30)
743
+
744
+
745
+ def _run_logics_command(repo_root: Path, args: list[str], *, runner: Any | None = None) -> subprocess.CompletedProcess[str]:
746
+ command = ["logics-manager", *args]
747
+ logics_runner = runner or subprocess.run
748
+ return logics_runner(command, cwd=repo_root, text=True, capture_output=True, timeout=30)
749
+
750
+
430
751
  def _run_read_only_gh(repo_root: Path, args: list[str], *, runner: Any | None = None) -> subprocess.CompletedProcess[str]:
431
752
  command = ["gh", *args]
432
753
  gh_runner = runner or subprocess.run
@@ -554,7 +875,11 @@ def _parse_git_branch_line(line: str) -> dict[str, Any]:
554
875
  }
555
876
 
556
877
 
557
- def _parse_recent_git_commits(output: str) -> list[dict[str, str]]:
878
+ GIT_HISTORY_DISPLAY_LIMIT = 50
879
+ GIT_HISTORY_FETCH_LIMIT = GIT_HISTORY_DISPLAY_LIMIT + 1
880
+
881
+
882
+ def _parse_recent_git_commits(output: str, *, limit: int | None = None) -> list[dict[str, str]]:
558
883
  commits: list[dict[str, str]] = []
559
884
  for line in output.splitlines():
560
885
  parts = line.split("\x1f")
@@ -570,6 +895,8 @@ def _parse_recent_git_commits(output: str) -> list[dict[str, str]]:
570
895
  "refs": _sanitize_git_ref(refs),
571
896
  }
572
897
  )
898
+ if limit is not None and len(commits) >= limit:
899
+ break
573
900
  return commits
574
901
 
575
902
 
@@ -656,7 +983,7 @@ def git_status_payload(repo_root: Path, *, runner: Any | None = None, which: Any
656
983
  commit = _run_read_only_git(repo_root, ["log", "-1", "--pretty=format:%h %s"], runner=runner)
657
984
  recent_commits = _run_read_only_git(
658
985
  repo_root,
659
- ["log", "-50", "--date=short", "--pretty=format:%h%x1f%s%x1f%an%x1f%ad%x1f%D"],
986
+ ["log", f"-{GIT_HISTORY_FETCH_LIMIT}", "--date=short", "--pretty=format:%h%x1f%s%x1f%an%x1f%ad%x1f%D"],
660
987
  runner=runner,
661
988
  )
662
989
  unpushed = _git_unpushed_commit_count(repo_root, runner=runner)
@@ -683,6 +1010,7 @@ def git_status_payload(repo_root: Path, *, runner: Any | None = None, which: Any
683
1010
  counts = {key: len(value) for key, value in groups.items()}
684
1011
  uncommitted_files = _count_unique_git_status_paths(groups)
685
1012
  dirty = any(counts.values())
1013
+ parsed_recent_commits = _parse_recent_git_commits(recent_commits.stdout, limit=GIT_HISTORY_FETCH_LIMIT) if recent_commits.returncode == 0 else []
686
1014
  return {
687
1015
  "state": "ok",
688
1016
  **branch_info,
@@ -703,10 +1031,18 @@ def git_status_payload(repo_root: Path, *, runner: Any | None = None, which: Any
703
1031
  },
704
1032
  "groups": groups,
705
1033
  "latestCommit": (commit.stdout.strip() if commit.returncode == 0 else "")[:300],
706
- "recentCommits": _parse_recent_git_commits(recent_commits.stdout) if recent_commits.returncode == 0 else [],
1034
+ "recentCommits": parsed_recent_commits[:GIT_HISTORY_DISPLAY_LIMIT],
1035
+ "recentCommitsHasMore": len(parsed_recent_commits) > GIT_HISTORY_DISPLAY_LIMIT,
707
1036
  }
708
1037
 
709
1038
 
1039
+ def _normalize_git_file_path(rel_path: str) -> str | None:
1040
+ normalized = unquote(rel_path).replace("\\", "/").lstrip("/")
1041
+ if not normalized or normalized.startswith("~") or normalized.startswith("/") or ".." in normalized.split("/"):
1042
+ return None
1043
+ return normalized
1044
+
1045
+
710
1046
  def git_diff_payload(
711
1047
  repo_root: Path,
712
1048
  rel_path: str,
@@ -719,8 +1055,8 @@ def git_diff_payload(
719
1055
  git_which = which or shutil.which
720
1056
  if not git_which("git"):
721
1057
  return {"state": "unavailable", "message": "Git is not available on PATH."}
722
- normalized = unquote(rel_path).replace("\\", "/").lstrip("/")
723
- if not normalized or normalized.startswith("~") or normalized.startswith("/") or ".." in normalized.split("/"):
1058
+ normalized = _normalize_git_file_path(rel_path)
1059
+ if not normalized:
724
1060
  return {"state": "error", "message": "Unsafe Git path."}
725
1061
  try:
726
1062
  inside = _run_read_only_git(repo_root, ["rev-parse", "--is-inside-work-tree"], runner=runner)
@@ -755,6 +1091,71 @@ def git_diff_payload(
755
1091
  }
756
1092
 
757
1093
 
1094
+ def git_file_preview_payload(
1095
+ repo_root: Path,
1096
+ rel_path: str,
1097
+ *,
1098
+ max_bytes: int = GIT_FILE_PREVIEW_MAX_BYTES,
1099
+ max_chars: int = GIT_FILE_PREVIEW_MAX_CHARS,
1100
+ ) -> dict[str, Any]:
1101
+ normalized = _normalize_git_file_path(rel_path)
1102
+ if not normalized:
1103
+ return {"state": "error", "message": "Unsafe Git path."}
1104
+ target = (repo_root / normalized).resolve()
1105
+ try:
1106
+ target.relative_to(repo_root.resolve())
1107
+ except ValueError:
1108
+ return {"state": "error", "message": "Unsafe Git path."}
1109
+ if not target.exists() or not target.is_file():
1110
+ return {
1111
+ "state": "missing",
1112
+ "path": normalized,
1113
+ "message": "The current file is missing or deleted, so no file preview is available.",
1114
+ }
1115
+ try:
1116
+ size = target.stat().st_size
1117
+ except OSError as exc:
1118
+ return {"state": "error", "path": normalized, "message": f"Unable to inspect file: {exc}"}
1119
+ if size > max_bytes:
1120
+ return {
1121
+ "state": "oversized",
1122
+ "path": normalized,
1123
+ "size": size,
1124
+ "message": f"File preview is limited to {max_bytes} bytes; this file is {size} bytes.",
1125
+ }
1126
+ try:
1127
+ data = target.read_bytes()
1128
+ except OSError as exc:
1129
+ return {"state": "error", "path": normalized, "message": f"Unable to read file preview: {exc}"}
1130
+ if b"\x00" in data:
1131
+ return {
1132
+ "state": "unsupported",
1133
+ "path": normalized,
1134
+ "message": "Binary or unsupported file content cannot be previewed.",
1135
+ }
1136
+ try:
1137
+ content = data.decode("utf-8")
1138
+ except UnicodeDecodeError:
1139
+ return {
1140
+ "state": "unsupported",
1141
+ "path": normalized,
1142
+ "message": "Binary or unsupported file encoding cannot be previewed.",
1143
+ }
1144
+ content = content.replace("\r\n", "\n").replace("\r", "\n")
1145
+ truncated = len(content) > max_chars
1146
+ if truncated:
1147
+ content = content[:max_chars]
1148
+ return {
1149
+ "state": "ok",
1150
+ "path": normalized,
1151
+ "mode": "file-preview",
1152
+ "content": content,
1153
+ "truncated": truncated,
1154
+ "logicsType": _logics_doc_type(normalized),
1155
+ "message": "",
1156
+ }
1157
+
1158
+
758
1159
  def _current_git_ci_context(repo_root: Path, *, runner: Any | None = None) -> dict[str, str]:
759
1160
  context = {"branch": "", "headSha": "", "subject": "", "author": ""}
760
1161
  commands = {
@@ -789,11 +1190,41 @@ def _ci_badge_state(status: str, conclusion: str) -> str:
789
1190
  return "unknown"
790
1191
 
791
1192
 
1193
+ def _is_active_ci_status(run: dict[str, Any]) -> bool:
1194
+ return str(run.get("status") or "").strip().lower() in {"queued", "in_progress", "waiting", "requested", "pending"}
1195
+
1196
+
1197
+ def _select_github_actions_run(runs: list[dict[str, Any]], head_sha: str) -> tuple[dict[str, Any], str]:
1198
+ head_runs = [run for run in runs if head_sha and str(run.get("head_sha") or "") == head_sha]
1199
+ active_head_run = next((run for run in head_runs if _is_active_ci_status(run)), None)
1200
+ if active_head_run is not None:
1201
+ return active_head_run, "head-active"
1202
+ failing_head_run = next((run for run in head_runs if _ci_badge_state(str(run.get("status") or ""), str(run.get("conclusion") or "")) == "failing"), None)
1203
+ if failing_head_run is not None:
1204
+ return failing_head_run, "head-failing"
1205
+ cancelled_head_run = next((run for run in head_runs if _ci_badge_state(str(run.get("status") or ""), str(run.get("conclusion") or "")) == "cancelled"), None)
1206
+ if cancelled_head_run is not None:
1207
+ return cancelled_head_run, "head-cancelled"
1208
+ unknown_head_run = next((run for run in head_runs if _ci_badge_state(str(run.get("status") or ""), str(run.get("conclusion") or "")) == "unknown"), None)
1209
+ if unknown_head_run is not None:
1210
+ return unknown_head_run, "head-unknown"
1211
+ if head_runs:
1212
+ return head_runs[0], "head"
1213
+ active_branch_run = next((run for run in runs if _is_active_ci_status(run)), None)
1214
+ if active_branch_run is not None:
1215
+ return active_branch_run, "branch-active"
1216
+ failing_branch_run = next((run for run in runs if _ci_badge_state(str(run.get("status") or ""), str(run.get("conclusion") or "")) == "failing"), None)
1217
+ if failing_branch_run is not None:
1218
+ return failing_branch_run, "branch-failing"
1219
+ return runs[0], "branch-latest"
1220
+
1221
+
792
1222
  def _parse_github_actions_run(run: dict[str, Any], *, match_source: str) -> dict[str, Any]:
793
1223
  status = str(run.get("status") or "")
794
1224
  conclusion = str(run.get("conclusion") or "")
795
1225
  commit = run.get("head_commit") if isinstance(run.get("head_commit"), dict) else {}
796
1226
  author = commit.get("author") if isinstance(commit.get("author"), dict) else {}
1227
+ commit_lines = str(commit.get("message") or run.get("display_title") or "").splitlines()
797
1228
  return {
798
1229
  "id": run.get("id"),
799
1230
  "name": str(run.get("name") or run.get("display_title") or "GitHub Actions"),
@@ -808,7 +1239,7 @@ def _parse_github_actions_run(run: dict[str, Any], *, match_source: str) -> dict
808
1239
  "createdAt": str(run.get("created_at") or ""),
809
1240
  "updatedAt": str(run.get("updated_at") or ""),
810
1241
  "runStartedAt": str(run.get("run_started_at") or ""),
811
- "commitMessage": str(commit.get("message") or run.get("display_title") or "").splitlines()[0][:240],
1242
+ "commitMessage": commit_lines[0][:240] if commit_lines else "",
812
1243
  "author": str(author.get("name") or ""),
813
1244
  "matchSource": match_source,
814
1245
  }
@@ -864,7 +1295,7 @@ def ci_status_payload(repo_root: Path, *, git_runner: Any | None = None, gh_runn
864
1295
  context = _current_git_ci_context(repo_root, runner=git_runner)
865
1296
  branch = context.get("branch", "")
866
1297
  head_sha = context.get("headSha", "")
867
- endpoint = f"repos/{owner}/{repo}/actions/runs?per_page=10"
1298
+ endpoint = f"repos/{owner}/{repo}/actions/runs?per_page=30"
868
1299
  if branch:
869
1300
  endpoint = f"{endpoint}&branch={quote(branch, safe='')}"
870
1301
  try:
@@ -886,10 +1317,7 @@ def ci_status_payload(repo_root: Path, *, git_runner: Any | None = None, gh_runn
886
1317
  if not runs:
887
1318
  return {"state": "ok", "visible": True, "message": "No GitHub Actions runs found for the current branch.", "repositoryUrl": github_url, **context, "badgeState": "unknown", "run": None, "jobs": []}
888
1319
 
889
- selected = next((run for run in runs if head_sha and str(run.get("head_sha") or "") == head_sha), None)
890
- match_source = "head" if selected else "branch-latest"
891
- if selected is None:
892
- selected = runs[0]
1320
+ selected, match_source = _select_github_actions_run(runs, head_sha)
893
1321
  run_payload = _parse_github_actions_run(selected, match_source=match_source)
894
1322
  jobs: list[dict[str, str]] = []
895
1323
  run_id = run_payload.get("id")
@@ -939,6 +1367,751 @@ def cdx_status_payload(repo_root: Path, *, runner: Any | None = None, which: Any
939
1367
  return {"state": "ok", "message": "", "status": parsed}
940
1368
 
941
1369
 
1370
+ def cdx_runs_payload(repo_root: Path, *, runner: Any | None = None, which: Any | None = None) -> dict[str, Any]:
1371
+ cdx_which = which or shutil.which
1372
+ if not cdx_which("cdx"):
1373
+ return {"state": "unavailable", "message": "CDX executable is not available on PATH.", "runs": []}
1374
+ try:
1375
+ result = _run_read_only_cdx(repo_root, ["runs", "--json"], runner=runner)
1376
+ except subprocess.TimeoutExpired:
1377
+ return {"state": "timeout", "message": "CDX runs timed out.", "runs": []}
1378
+ except (OSError, subprocess.SubprocessError) as exc:
1379
+ return {"state": "error", "message": f"Unable to run CDX runs: {exc}", "runs": []}
1380
+ if result.returncode != 0:
1381
+ message = (result.stderr or result.stdout or "CDX runs failed.").strip().splitlines()[0]
1382
+ return {"state": "error", "message": message, "runs": []}
1383
+ try:
1384
+ parsed = json.loads(result.stdout or "{}")
1385
+ except json.JSONDecodeError:
1386
+ return {"state": "invalid-json", "message": "CDX runs returned invalid JSON.", "runs": []}
1387
+ runs = parsed.get("runs") if isinstance(parsed, dict) else None
1388
+ if not isinstance(runs, list):
1389
+ return {"state": "invalid-json", "message": "CDX runs JSON must include a runs array.", "runs": []}
1390
+ normalized_runs: list[dict[str, Any]] = []
1391
+ for run in runs:
1392
+ if not isinstance(run, dict):
1393
+ continue
1394
+ item = dict(run)
1395
+ status = str(item.get("status") or item.get("state") or "").strip().lower()
1396
+ if status == "stale" and not item.get("ended_at") and not item.get("endedAt"):
1397
+ item["status"] = "running"
1398
+ item["status_detail"] = "CDX still marks this run active; no end timestamp has been reported yet."
1399
+ item["raw_status"] = "stale"
1400
+ normalized_runs.append(item)
1401
+ return {"state": "ok", "message": "", "runs": normalized_runs}
1402
+
1403
+
1404
+ def cdx_run_report_payload(repo_root: Path, run_id: str, *, runner: Any | None = None, which: Any | None = None) -> dict[str, Any]:
1405
+ cdx_which = which or shutil.which
1406
+ if not run_id:
1407
+ return {"state": "error", "message": "Missing CDX run id.", "report": None}
1408
+ if not cdx_which("cdx"):
1409
+ return {"state": "unavailable", "message": "CDX executable is not available on PATH.", "report": None}
1410
+ try:
1411
+ result = _run_read_only_cdx(repo_root, ["run-report", run_id, "--json"], runner=runner)
1412
+ except subprocess.TimeoutExpired:
1413
+ return {"state": "timeout", "message": "CDX run report timed out.", "report": None}
1414
+ except (OSError, subprocess.SubprocessError) as exc:
1415
+ return {"state": "error", "message": f"Unable to run CDX run-report: {exc}", "report": None}
1416
+ if result.returncode != 0:
1417
+ message = (result.stderr or result.stdout or "CDX run-report failed.").strip().splitlines()[0]
1418
+ return {"state": "error", "message": message, "report": None}
1419
+ try:
1420
+ parsed = json.loads(result.stdout or "{}")
1421
+ except json.JSONDecodeError:
1422
+ return {"state": "invalid-json", "message": "CDX run-report returned invalid JSON.", "report": None}
1423
+ report = parsed.get("report") if isinstance(parsed, dict) else None
1424
+ if not isinstance(report, dict):
1425
+ return {"state": "invalid-json", "message": "CDX run-report JSON must include a report object.", "report": None}
1426
+ merged_report = _merge_cdx_mission_output(report)
1427
+ if merged_report:
1428
+ report = merged_report
1429
+ return {"state": "ok", "message": "", "report": report}
1430
+
1431
+
1432
+ def cdx_mission_catalog_payload() -> dict[str, Any]:
1433
+ return {
1434
+ "missions": list(CDX_MISSION_CATALOG.values()),
1435
+ "strengths": list(CDX_MISSION_STRENGTHS.values()),
1436
+ "defaultMissionId": CDX_DEFAULT_MISSION_ID,
1437
+ "defaultStrengthId": "standard",
1438
+ }
1439
+
1440
+
1441
+ def _cdx_status_sessions(status_payload: dict[str, Any]) -> list[str]:
1442
+ status = status_payload.get("status") if isinstance(status_payload.get("status"), dict) else {}
1443
+ sessions = status.get("sessions") if isinstance(status.get("sessions"), list) else []
1444
+ ids: list[str] = []
1445
+ for session in sessions:
1446
+ if not isinstance(session, dict):
1447
+ continue
1448
+ session_id = str(session.get("id") or session.get("name") or "").strip()
1449
+ if session_id:
1450
+ ids.append(session_id)
1451
+ return ids
1452
+
1453
+
1454
+ def _normalize_cdx_session(value: Any, status_payload: dict[str, Any] | None = None) -> str:
1455
+ session = str(value or "").strip()
1456
+ if not re.match(r"^[A-Za-z0-9_.:@/-]{1,120}$", session):
1457
+ return ""
1458
+ if status_payload is None:
1459
+ return session
1460
+ known_sessions = _cdx_status_sessions(status_payload)
1461
+ if known_sessions and session not in known_sessions:
1462
+ return ""
1463
+ return session
1464
+
1465
+
1466
+ def _latest_release_tag(repo_root: Path, *, runner: Any | None = None, which: Any | None = None) -> str:
1467
+ git_which = which or shutil.which
1468
+ if not git_which("git"):
1469
+ return ""
1470
+ commands = [
1471
+ ["tag", "--sort=-version:refname", "--list", "v[0-9]*"],
1472
+ ["tag", "--sort=-version:refname", "--list", "[0-9]*"],
1473
+ ["describe", "--tags", "--abbrev=0"],
1474
+ ]
1475
+ for args in commands:
1476
+ try:
1477
+ result = _run_read_only_git(repo_root, args, runner=runner)
1478
+ except (OSError, subprocess.SubprocessError, subprocess.TimeoutExpired):
1479
+ continue
1480
+ if result.returncode != 0:
1481
+ continue
1482
+ tag = (result.stdout or "").strip().splitlines()[0] if (result.stdout or "").strip() else ""
1483
+ if tag:
1484
+ return tag[:200]
1485
+ return ""
1486
+
1487
+
1488
+ def _mission_text_input(body: dict[str, Any], key: str, *, max_chars: int = 4000) -> str:
1489
+ raw = str(body.get(key) or "").strip()
1490
+ normalized = re.sub(r"\s+", " ", raw)
1491
+ return normalized[:max_chars]
1492
+
1493
+
1494
+ def _mission_bool_input(body: dict[str, Any], key: str) -> bool:
1495
+ value = body.get(key)
1496
+ if isinstance(value, bool):
1497
+ return value
1498
+ if isinstance(value, str):
1499
+ return value.strip().lower() in {"1", "true", "yes", "on"}
1500
+ return False
1501
+
1502
+
1503
+ def _cdx_mission_prompt(
1504
+ mission_id: str,
1505
+ *,
1506
+ release_tag: str = "",
1507
+ wish_text: str = "",
1508
+ release_version: str = "",
1509
+ run_full_validation: bool = False,
1510
+ allow_file_writes: bool = False,
1511
+ direct_fixes: bool = False,
1512
+ commit_at_end: bool = False,
1513
+ ) -> str:
1514
+ write_guidance = (
1515
+ "File edits are allowed when they directly complete the selected mission mode. Keep changes scoped, run relevant validation, and report changed files."
1516
+ if allow_file_writes
1517
+ else "Do not modify files."
1518
+ )
1519
+ commit_guidance = (
1520
+ "At the end, if and only if files were added, deleted, or modified, create one scoped git commit that includes all mission changes. Do not push, tag, publish, upload assets, or create a GitHub release. Include the commit hash and message in the returned JSON when a commit is created."
1521
+ if commit_at_end
1522
+ else "Do not create git commits."
1523
+ )
1524
+ if mission_id == "full-audit":
1525
+ if direct_fixes:
1526
+ action_guidance = "Fix safe, scoped issues directly in repository files when you can validate them. Do not write a separate audit corpus/report artifact. Do not make broad refactors, release, tag, push, or publish."
1527
+ schema = "Return concise JSON with keys: summary, findings, directFixes, changedFiles, validationEvidence."
1528
+ elif allow_file_writes:
1529
+ action_guidance = "Create or update a bounded Logics request under logics/request/ for actionable full-audit follow-up. Do not write a separate audit corpus/report artifact. Do not directly modify product/source files to fix issues."
1530
+ schema = "Return concise JSON with keys: summary, findings, recommendations, requestFiles, validationEvidence."
1531
+ else:
1532
+ action_guidance = "Report only; do not write corpus files, fix issues, or modify files."
1533
+ schema = "Return concise JSON with keys: summary, findings, recommendations."
1534
+ return "\n".join([
1535
+ "Run a full repository audit for this Logics Manager checkout.",
1536
+ "Focus on correctness bugs, workflow risks, missing validation, stale documentation, and test gaps.",
1537
+ write_guidance,
1538
+ action_guidance,
1539
+ commit_guidance,
1540
+ schema,
1541
+ ])
1542
+ if mission_id == "release-review":
1543
+ if direct_fixes:
1544
+ action_guidance = "Fix safe, scoped release-readiness issues directly in repository files when you can validate them, such as stale documentation, missing release notes, or narrow test failures. Do not write a separate release-review corpus/report artifact. Do not bump versions unless explicitly requested, and do not tag, push, publish, upload assets, or create GitHub releases."
1545
+ schema = "Return concise JSON with keys: summary, findings, directFixes, changedFiles, validationEvidence."
1546
+ elif allow_file_writes:
1547
+ action_guidance = "Create or update a bounded Logics request under logics/request/ for actionable release-review follow-up. Do not write a separate release-review corpus/report artifact under logics/external. Do not directly modify product/source files to fix issues. Do not bump versions, tag, push, publish, upload assets, or create GitHub releases."
1548
+ schema = "Return concise JSON with keys: summary, findings, recommendations, requestFiles, validationEvidence."
1549
+ else:
1550
+ action_guidance = "Report only; do not update release files, write corpus files, fix issues, tag, push, publish, upload assets, or create GitHub releases."
1551
+ schema = "Return concise JSON with keys: summary, findings, recommendations."
1552
+ return "\n".join([
1553
+ f"Review repository changes since the latest release tag {release_tag}.",
1554
+ "Focus on regressions, incomplete release notes, migration risks, and missing tests.",
1555
+ write_guidance,
1556
+ action_guidance,
1557
+ commit_guidance,
1558
+ schema,
1559
+ ])
1560
+ if mission_id == "corpus-ready":
1561
+ return "\n".join([
1562
+ "Prepare the open Logics workflow corpus for development.",
1563
+ "Analyze requests, backlog items, tasks, docs, lint/audit state, and workflow consistency.",
1564
+ "Do not modify files directly. This mission is plan-first: return allowed actions for the viewer to apply explicitly.",
1565
+ "Do not run destructive commands.",
1566
+ "Return JSON only with this schema:",
1567
+ '{"summary":"...","actions":[{"type":"promote-request-to-backlog","target":"req_..."},{"type":"promote-backlog-to-task","target":"item_..."},{"type":"refresh-corpus-context","target":""}],"notes":["..."]}',
1568
+ "Allowed action types are exactly: promote-request-to-backlog, promote-backlog-to-task, refresh-corpus-context.",
1569
+ "Use only targets that exist in the repository. Omit actions that are not clearly justified.",
1570
+ ])
1571
+ if mission_id == "wish-to-request":
1572
+ request_guidance = (
1573
+ "Create the request draft file under logics/request/ using the next available req_ slug. Keep the file as a request draft only; do not promote backlog items and do not create tasks. Include the created path in generatedFiles."
1574
+ if allow_file_writes
1575
+ else "Do not create the request file; return the request draft and generatedFiles preview only."
1576
+ )
1577
+ return "\n".join([
1578
+ "Turn the following user wish into a structured Logics request draft.",
1579
+ write_guidance,
1580
+ request_guidance,
1581
+ commit_guidance,
1582
+ "Do not promote backlog items and do not create tasks.",
1583
+ "Return JSON only with this schema:",
1584
+ '{"summary":"...","requestDraft":{"title":"...","needs":["..."],"context":["..."],"acceptanceCriteria":["AC1: ..."],"definitionOfReady":{"problemExplicit":true,"scopeBounded":true,"criteriaTestable":true,"risksListed":true},"references":["..."],"questions":["..."],"openAssumptions":["..."]},"generatedFiles":[]}',
1585
+ "If the wish is underspecified, include concrete questions and open assumptions instead of inventing details.",
1586
+ "User wish:",
1587
+ wish_text,
1588
+ ])
1589
+ if mission_id == "pre-release":
1590
+ validation_mode = "Run the project-defined full validation path before finalizing the report, and include actionable fixes for any failures." if run_full_validation else "Do not run full validation; identify the validation commands that should be run before release."
1591
+ release_prep_guidance = (
1592
+ "Prepare release metadata files for the requested version when needed: update package.json, pyproject.toml, VERSION, and create or update the matching changelogs/CHANGELOGS_X_Y_Z.md. Do not create Git tags, push branches, publish packages, upload release assets, or create GitHub releases."
1593
+ if allow_file_writes
1594
+ else "Do not modify package versions, changelog files, create Git tags, push branches, publish packages, upload release assets, or create GitHub releases."
1595
+ )
1596
+ return "\n".join([
1597
+ f"Prepare a guarded pre-release for version {release_version}.",
1598
+ validation_mode,
1599
+ release_prep_guidance,
1600
+ write_guidance,
1601
+ commit_guidance,
1602
+ "Return JSON only with this schema:",
1603
+ '{"summary":"...","version":"vX.X.X","validationMode":"full|plan-only","validationEvidence":["..."],"actionableFixes":[{"title":"...","command":"...","risk":"..."}],"generatedFiles":[{"path":"...","purpose":"..."}],"releasePlan":["..."],"blocked":false}',
1604
+ ])
1605
+ raise ValueError("Unknown CDX mission.")
1606
+
1607
+
1608
+ def _cdx_mission_command(
1609
+ repo_root: Path,
1610
+ mission_id: str,
1611
+ *,
1612
+ session: str,
1613
+ strength: dict[str, Any],
1614
+ release_tag: str = "",
1615
+ mission_inputs: dict[str, str] | None = None,
1616
+ allow_file_writes: bool = False,
1617
+ commit_at_end: bool = False,
1618
+ ) -> list[str]:
1619
+ mission_inputs = mission_inputs or {}
1620
+ prompt = _cdx_mission_prompt(
1621
+ mission_id,
1622
+ release_tag=release_tag,
1623
+ wish_text=mission_inputs.get("wishText", ""),
1624
+ release_version=mission_inputs.get("releaseVersion", ""),
1625
+ run_full_validation=mission_inputs.get("runFullValidation") == "true",
1626
+ allow_file_writes=allow_file_writes,
1627
+ direct_fixes=mission_inputs.get("directFixes") == "true",
1628
+ commit_at_end=commit_at_end,
1629
+ )
1630
+ timeout = int(strength.get("timeout") or 180)
1631
+ reasoning_effort = str(strength.get("reasoningEffort") or "medium")
1632
+ power = str(strength.get("power") or "medium")
1633
+ permission = "workspace-write" if allow_file_writes else "read-only"
1634
+ return [
1635
+ "run",
1636
+ session,
1637
+ "--cwd",
1638
+ str(repo_root),
1639
+ "--prompt",
1640
+ prompt,
1641
+ "--kind",
1642
+ "assistant",
1643
+ "--reasoning-effort",
1644
+ reasoning_effort,
1645
+ "--power",
1646
+ power,
1647
+ "--permission",
1648
+ permission,
1649
+ "--timeout-seconds",
1650
+ str(timeout),
1651
+ "--json",
1652
+ ]
1653
+
1654
+
1655
+ def _parse_json_from_text(text: str) -> dict[str, Any] | None:
1656
+ raw = text.strip()
1657
+ if not raw:
1658
+ return None
1659
+ jsonl_candidates: list[str] = []
1660
+ for line in reversed(raw.splitlines()):
1661
+ line = line.strip()
1662
+ if not line.startswith("{"):
1663
+ continue
1664
+ try:
1665
+ event = json.loads(line)
1666
+ except json.JSONDecodeError:
1667
+ continue
1668
+ if not isinstance(event, dict):
1669
+ continue
1670
+ item = event.get("item") if isinstance(event.get("item"), dict) else {}
1671
+ text_value = item.get("text") if item.get("type") == "agent_message" else event.get("text")
1672
+ if isinstance(text_value, str) and text_value.strip():
1673
+ jsonl_candidates.append(text_value.strip())
1674
+ candidates = [raw]
1675
+ candidates.extend(jsonl_candidates)
1676
+ fence_match = re.search(r"```(?:json)?\s*(.*?)```", raw, re.IGNORECASE | re.DOTALL)
1677
+ if fence_match:
1678
+ candidates.insert(0, fence_match.group(1).strip())
1679
+ decoder = json.JSONDecoder()
1680
+ fallback: dict[str, Any] | None = None
1681
+ for candidate in candidates:
1682
+ try:
1683
+ parsed = json.loads(candidate)
1684
+ if isinstance(parsed, dict):
1685
+ if any(key in parsed for key in ("actions", "summary", "findings", "recommendations")):
1686
+ return parsed
1687
+ fallback = fallback or parsed
1688
+ except json.JSONDecodeError:
1689
+ pass
1690
+ for index, char in enumerate(candidate):
1691
+ if char != "{":
1692
+ continue
1693
+ try:
1694
+ parsed, _end = decoder.raw_decode(candidate[index:])
1695
+ except json.JSONDecodeError:
1696
+ continue
1697
+ if isinstance(parsed, dict):
1698
+ if any(key in parsed for key in ("actions", "summary", "findings", "recommendations")):
1699
+ return parsed
1700
+ fallback = fallback or parsed
1701
+ return fallback
1702
+
1703
+
1704
+ def _read_cdx_output_path(parsed: dict[str, Any]) -> str:
1705
+ candidates = [
1706
+ parsed.get("stdout"),
1707
+ parsed.get("output"),
1708
+ ]
1709
+ artifacts = parsed.get("artifacts") if isinstance(parsed.get("artifacts"), dict) else {}
1710
+ candidates.extend([
1711
+ parsed.get("stdout_path"),
1712
+ parsed.get("stdoutPath"),
1713
+ artifacts.get("stdout_path"),
1714
+ artifacts.get("stdoutPath"),
1715
+ ])
1716
+ for candidate in candidates:
1717
+ if not isinstance(candidate, str) or not candidate.strip():
1718
+ continue
1719
+ value = candidate.strip()
1720
+ if "\n" in value or value.lstrip().startswith("{") or value.lstrip().startswith("```"):
1721
+ return value[:12000]
1722
+ path = Path(value).expanduser()
1723
+ if not path.is_file():
1724
+ continue
1725
+ try:
1726
+ with path.open("rb") as handle:
1727
+ size = path.stat().st_size
1728
+ if size > 60000:
1729
+ handle.seek(size - 60000)
1730
+ return handle.read(60000).decode("utf-8", errors="replace")
1731
+ except OSError:
1732
+ continue
1733
+ return ""
1734
+
1735
+
1736
+ def _merge_cdx_mission_output(parsed: Any) -> dict[str, Any] | None:
1737
+ if not isinstance(parsed, dict):
1738
+ return None
1739
+ merged = dict(parsed)
1740
+ embedded = _parse_json_from_text(_read_cdx_output_path(parsed))
1741
+ if embedded:
1742
+ merged["missionOutput"] = embedded
1743
+ if isinstance(embedded.get("actions"), list) and "actions" not in merged:
1744
+ merged["actions"] = embedded["actions"]
1745
+ if "summary" in embedded and "summary" not in merged:
1746
+ merged["summary"] = embedded["summary"]
1747
+ return merged
1748
+
1749
+
1750
+ def _extract_cdx_usage(parsed: Any) -> dict[str, Any]:
1751
+ if not isinstance(parsed, dict):
1752
+ return {"available": False, "message": "CDX did not return structured usage."}
1753
+ candidates = [
1754
+ parsed.get("usage"),
1755
+ parsed.get("tokenUsage"),
1756
+ parsed.get("tokens"),
1757
+ (parsed.get("run") or {}).get("usage") if isinstance(parsed.get("run"), dict) else None,
1758
+ (parsed.get("result") or {}).get("usage") if isinstance(parsed.get("result"), dict) else None,
1759
+ ]
1760
+ usage = next((candidate for candidate in candidates if isinstance(candidate, dict)), None)
1761
+ if usage is None:
1762
+ return {"available": False, "message": "Token usage was not exposed by CDX for this run."}
1763
+ input_tokens = usage.get("input_tokens", usage.get("inputTokens", usage.get("prompt_tokens", usage.get("promptTokens"))))
1764
+ output_tokens = usage.get("output_tokens", usage.get("outputTokens", usage.get("completion_tokens", usage.get("completionTokens"))))
1765
+ total_tokens = usage.get("total_tokens", usage.get("totalTokens"))
1766
+ if total_tokens is None and isinstance(input_tokens, int) and isinstance(output_tokens, int):
1767
+ total_tokens = input_tokens + output_tokens
1768
+ return {
1769
+ "available": True,
1770
+ "inputTokens": input_tokens,
1771
+ "outputTokens": output_tokens,
1772
+ "totalTokens": total_tokens,
1773
+ "raw": usage,
1774
+ }
1775
+
1776
+
1777
+ def _bounded_process_text(value: str, limit: int = 12000) -> str:
1778
+ text = value.strip()
1779
+ if len(text) <= limit:
1780
+ return text
1781
+ return f"{text[:limit]}\n... truncated ..."
1782
+
1783
+
1784
+ def cdx_mission_plan_payload(
1785
+ repo_root: Path,
1786
+ body: dict[str, Any],
1787
+ *,
1788
+ cdx_runner: Any | None = None,
1789
+ git_runner: Any | None = None,
1790
+ which: Any | None = None,
1791
+ ) -> dict[str, Any]:
1792
+ tool_which = which or shutil.which
1793
+ if not tool_which("cdx"):
1794
+ return {"state": "unavailable", "message": "CDX executable is not available on PATH.", "plan": None}
1795
+ mission_id = str(body.get("missionId") or CDX_DEFAULT_MISSION_ID)
1796
+ mission = CDX_MISSION_CATALOG.get(mission_id)
1797
+ if mission is None:
1798
+ return {"state": "error", "message": "Unknown CDX mission.", "plan": None}
1799
+ strength = str(body.get("strengthId") or "standard")
1800
+ strength_def = CDX_MISSION_STRENGTHS.get(strength)
1801
+ if strength_def is None:
1802
+ return {"state": "error", "message": "Unknown CDX mission strength.", "plan": None}
1803
+
1804
+ status_payload = cdx_status_payload(repo_root, runner=cdx_runner, which=which)
1805
+ session = _normalize_cdx_session(body.get("sessionId"), status_payload if status_payload.get("state") == "ok" else None)
1806
+ if not session:
1807
+ sessions = _cdx_status_sessions(status_payload)
1808
+ session = sessions[0] if sessions else ""
1809
+ if not session:
1810
+ return {"state": "error", "message": "No usable CDX session is available.", "plan": None, "status": status_payload}
1811
+
1812
+ release_tag = ""
1813
+ warnings: list[str] = []
1814
+ mission_inputs: dict[str, str] = {}
1815
+ if mission_id == "wish-to-request":
1816
+ wish_text = _mission_text_input(body, "wishText")
1817
+ if not wish_text:
1818
+ return {"state": "error", "message": "Enter a wish or intent before previewing this mission.", "plan": None, "catalog": cdx_mission_catalog_payload(), "status": status_payload}
1819
+ mission_inputs["wishText"] = wish_text
1820
+ if mission_id in {"full-audit", "release-review"}:
1821
+ mission_inputs["directFixes"] = "true" if _mission_bool_input(body, "directFixes") else "false"
1822
+ if mission_id == "pre-release":
1823
+ release_version = _mission_text_input(body, "releaseVersion", max_chars=40)
1824
+ if not re.fullmatch(r"v\d+\.\d+\.\d+", release_version):
1825
+ return {"state": "error", "message": "Enter a semantic version in vX.X.X format before previewing this mission.", "plan": None, "catalog": cdx_mission_catalog_payload(), "status": status_payload}
1826
+ mission_inputs["releaseVersion"] = release_version
1827
+ mission_inputs["runFullValidation"] = "true" if _mission_bool_input(body, "runFullValidation") else "false"
1828
+ if mission.get("requiresReleaseTag"):
1829
+ release_tag = _latest_release_tag(repo_root, runner=git_runner, which=which)
1830
+ if not release_tag:
1831
+ return {"state": "error", "message": "No release tag was found for this mission.", "plan": None, "status": status_payload}
1832
+ if status_payload.get("state") != "ok":
1833
+ warnings.append(str(status_payload.get("message") or "CDX status could not be confirmed."))
1834
+
1835
+ requested_file_writes = _mission_bool_input(body, "allowFileWrites")
1836
+ requested_commit_at_end = _mission_bool_input(body, "commitAtEnd")
1837
+ direct_fixes = mission_inputs.get("directFixes") == "true"
1838
+ supports_file_writes = bool(mission.get("supportsFileWrites", True))
1839
+ allow_file_writes = (requested_file_writes or direct_fixes) and supports_file_writes
1840
+ commit_at_end = requested_commit_at_end and allow_file_writes
1841
+ if requested_file_writes and not supports_file_writes:
1842
+ warnings.append("This mission is plan-first; direct CDX file writes are disabled. Use Apply allowed actions after CDX returns actions.")
1843
+ if requested_commit_at_end and not allow_file_writes:
1844
+ warnings.append("Commit-at-end was requested but direct file writes are disabled for this mission.")
1845
+ permission = "workspace-write" if allow_file_writes else "read-only"
1846
+ command = _cdx_mission_command(
1847
+ repo_root,
1848
+ mission_id,
1849
+ session=session,
1850
+ strength=strength_def,
1851
+ release_tag=release_tag,
1852
+ mission_inputs=mission_inputs,
1853
+ allow_file_writes=allow_file_writes,
1854
+ commit_at_end=commit_at_end,
1855
+ )
1856
+ plan = {
1857
+ "mission": mission,
1858
+ "missionId": mission_id,
1859
+ "sessionId": session,
1860
+ "strength": strength_def,
1861
+ "strengthId": strength,
1862
+ "missionInputs": mission_inputs,
1863
+ "scope": mission["scope"],
1864
+ "releaseTag": release_tag,
1865
+ "allowFileWrites": allow_file_writes,
1866
+ "requestedFileWrites": requested_file_writes,
1867
+ "commitAtEnd": commit_at_end,
1868
+ "requestedCommitAtEnd": requested_commit_at_end,
1869
+ "supportsFileWrites": supports_file_writes,
1870
+ "permission": permission,
1871
+ "command": ["cdx", *command],
1872
+ "arguments": command,
1873
+ "warnings": warnings,
1874
+ "requiresConfirmation": bool(mission.get("requiresPlanConfirmation")),
1875
+ "canRun": True,
1876
+ }
1877
+ if mission_id == "corpus-ready":
1878
+ plan["allowedPlanActions"] = [
1879
+ "promote-request-to-backlog",
1880
+ "promote-backlog-to-task",
1881
+ "refresh-corpus-context",
1882
+ ]
1883
+ return {"state": "ok", "message": "", "plan": plan, "catalog": cdx_mission_catalog_payload(), "status": status_payload}
1884
+
1885
+
1886
+ def cdx_mission_run_payload(
1887
+ repo_root: Path,
1888
+ body: dict[str, Any],
1889
+ *,
1890
+ cdx_runner: Any | None = None,
1891
+ git_runner: Any | None = None,
1892
+ which: Any | None = None,
1893
+ ) -> dict[str, Any]:
1894
+ plan_payload = cdx_mission_plan_payload(repo_root, body, cdx_runner=cdx_runner, git_runner=git_runner, which=which)
1895
+ if plan_payload.get("state") != "ok":
1896
+ return {"state": plan_payload.get("state") or "error", "message": plan_payload.get("message") or "Unable to plan CDX mission.", "plan": plan_payload.get("plan"), "run": None}
1897
+ plan = plan_payload["plan"]
1898
+ timeout = int(plan["strength"].get("timeout") or 180)
1899
+ try:
1900
+ result = _run_cdx_mission(repo_root, list(plan["arguments"]), timeout=timeout, runner=cdx_runner)
1901
+ except subprocess.TimeoutExpired:
1902
+ return {"state": "timeout", "message": "CDX mission timed out.", "plan": plan, "run": None}
1903
+ except (OSError, subprocess.SubprocessError) as exc:
1904
+ return {"state": "error", "message": f"Unable to run CDX mission: {exc}", "plan": plan, "run": None}
1905
+
1906
+ parsed: Any = None
1907
+ if result.stdout.strip():
1908
+ try:
1909
+ parsed = json.loads(result.stdout)
1910
+ except json.JSONDecodeError:
1911
+ parsed = None
1912
+ parsed = _merge_cdx_mission_output(parsed)
1913
+ usage = _extract_cdx_usage(parsed)
1914
+ run_id = ""
1915
+ if isinstance(parsed, dict):
1916
+ run = parsed.get("run") if isinstance(parsed.get("run"), dict) else {}
1917
+ run_id = str(parsed.get("run_id") or parsed.get("runId") or run.get("run_id") or run.get("runId") or "")
1918
+ run_payload = {
1919
+ "returnCode": result.returncode,
1920
+ "runId": run_id,
1921
+ "stdout": _bounded_process_text(result.stdout or ""),
1922
+ "stderr": _bounded_process_text(result.stderr or ""),
1923
+ "parsed": parsed if isinstance(parsed, dict) else None,
1924
+ "usage": usage,
1925
+ }
1926
+ if result.returncode != 0:
1927
+ message = (result.stderr or result.stdout or "CDX mission failed.").strip().splitlines()[0]
1928
+ return {"state": "error", "message": message, "plan": plan, "run": run_payload}
1929
+ return {"state": "ok", "message": "", "plan": plan, "run": run_payload}
1930
+
1931
+
1932
+ def cdx_mission_apply_plan_payload(repo_root: Path, body: dict[str, Any], *, runner: Any | None = None, which: Any | None = None) -> dict[str, Any]:
1933
+ tool_which = which or shutil.which
1934
+ if not tool_which("logics-manager"):
1935
+ return {"state": "unavailable", "message": "logics-manager executable is not available on PATH.", "results": []}
1936
+ actions = body.get("actions") if isinstance(body.get("actions"), list) else []
1937
+ if not actions:
1938
+ return {"state": "error", "message": "No corpus plan actions were provided.", "results": []}
1939
+
1940
+ allowed: dict[str, list[str]] = {
1941
+ "promote-request-to-backlog": ["flow", "promote", "request-to-backlog"],
1942
+ "promote-backlog-to-task": ["flow", "promote", "backlog-to-task"],
1943
+ "refresh-corpus-context": ["sync", "refresh-mermaid-signatures"],
1944
+ }
1945
+ results: list[dict[str, Any]] = []
1946
+ for action in actions:
1947
+ if not isinstance(action, dict):
1948
+ return {"state": "error", "message": "Corpus plan actions must be objects.", "results": results}
1949
+ action_type = str(action.get("type") or "")
1950
+ command = allowed.get(action_type)
1951
+ if command is None:
1952
+ return {"state": "error", "message": f"Unsupported corpus plan action: {action_type}", "results": results}
1953
+ target = str(action.get("target") or "").strip()
1954
+ args = [*command]
1955
+ if target and action_type != "refresh-corpus-context":
1956
+ if not re.match(r"^[A-Za-z0-9_.:/-]{1,160}$", target):
1957
+ return {"state": "error", "message": "Invalid corpus plan action target.", "results": results}
1958
+ args.append(target)
1959
+ try:
1960
+ result = _run_logics_command(repo_root, args, runner=runner)
1961
+ except subprocess.TimeoutExpired:
1962
+ return {"state": "timeout", "message": "Logics corpus plan application timed out.", "results": results}
1963
+ except (OSError, subprocess.SubprocessError) as exc:
1964
+ return {"state": "error", "message": f"Unable to apply corpus plan action: {exc}", "results": results}
1965
+ item = {
1966
+ "type": action_type,
1967
+ "target": target,
1968
+ "command": ["logics-manager", *args],
1969
+ "returnCode": result.returncode,
1970
+ "stdout": _bounded_process_text(result.stdout or "", 4000),
1971
+ "stderr": _bounded_process_text(result.stderr or "", 4000),
1972
+ }
1973
+ results.append(item)
1974
+ if result.returncode != 0:
1975
+ message = (result.stderr or result.stdout or "Corpus plan action failed.").strip().splitlines()[0]
1976
+ return {"state": "error", "message": message, "results": results}
1977
+ return {"state": "ok", "message": "", "results": results}
1978
+
1979
+
1980
+ def _slugify_viewer_doc(text: str) -> str:
1981
+ slug = re.sub(r"[^a-z0-9]+", "_", text.lower()).strip("_")
1982
+ return slug[:80] or "cdx_code_review_findings"
1983
+
1984
+
1985
+ def _next_viewer_request_ref(repo_root: Path, title: str) -> str:
1986
+ request_dir = repo_root / "logics" / "request"
1987
+ highest = -1
1988
+ if request_dir.is_dir():
1989
+ for path in request_dir.glob("req_*.md"):
1990
+ match = re.match(r"^req_(\d{3})_", path.stem)
1991
+ if match:
1992
+ highest = max(highest, int(match.group(1)))
1993
+ return f"req_{highest + 1:03d}_{_slugify_viewer_doc(title)}"
1994
+
1995
+
1996
+ def create_request_from_cdx_report(repo_root: Path, report_payload: dict[str, Any]) -> dict[str, Any]:
1997
+ report = report_payload.get("report") if isinstance(report_payload.get("report"), dict) else report_payload
1998
+ run = report.get("run") if isinstance(report.get("run"), dict) else {}
1999
+ task_report = report.get("task_report") if isinstance(report.get("task_report"), dict) else {}
2000
+ parsed = report.get("parsed") if isinstance(report.get("parsed"), dict) else {}
2001
+ mission_output = next(
2002
+ (
2003
+ candidate
2004
+ for candidate in (
2005
+ report.get("missionOutput"),
2006
+ report.get("mission_output"),
2007
+ parsed.get("missionOutput"),
2008
+ parsed.get("mission_output"),
2009
+ run.get("missionOutput"),
2010
+ run.get("mission_output"),
2011
+ task_report.get("missionOutput"),
2012
+ task_report.get("mission_output"),
2013
+ )
2014
+ if isinstance(candidate, dict)
2015
+ ),
2016
+ {},
2017
+ )
2018
+ run_id = str(run.get("run_id") or task_report.get("run_id") or "unknown")
2019
+ task_kind = str(task_report.get("kind") or run.get("kind") or "assistant")
2020
+ findings = task_report.get("findings") if isinstance(task_report.get("findings"), list) else []
2021
+ if not findings and isinstance(mission_output.get("findings"), list):
2022
+ findings = mission_output["findings"]
2023
+ recommendations = mission_output.get("recommendations") if isinstance(mission_output.get("recommendations"), list) else []
2024
+ request_files = mission_output.get("requestFiles") if isinstance(mission_output.get("requestFiles"), list) else []
2025
+ actionable_fixes = mission_output.get("actionableFixes") if isinstance(mission_output.get("actionableFixes"), list) else []
2026
+ release_plan = mission_output.get("releasePlan") if isinstance(mission_output.get("releasePlan"), list) else []
2027
+ if task_kind == "code-review":
2028
+ title = f"Address CDX code review findings for {run_id}"
2029
+ theme = "Code review follow-up"
2030
+ need = f"Follow up on CDX code-review run `{run_id}`."
2031
+ elif task_kind == "full-audit":
2032
+ title = f"Address CDX audit findings for {run_id}"
2033
+ theme = "Audit follow-up"
2034
+ need = f"Follow up on CDX full-audit run `{run_id}`."
2035
+ else:
2036
+ title = f"Address CDX {task_kind} follow-up for {run_id}"
2037
+ theme = "CDX mission follow-up"
2038
+ need = f"Follow up on CDX `{task_kind}` run `{run_id}`."
2039
+ ref = _next_viewer_request_ref(repo_root, title)
2040
+ request_dir = repo_root / "logics" / "request"
2041
+ request_dir.mkdir(parents=True, exist_ok=True)
2042
+ rel_path = f"logics/request/{ref}.md"
2043
+ path = repo_root / rel_path
2044
+
2045
+ def _item_message(item: Any, fallback: str) -> str:
2046
+ if isinstance(item, dict):
2047
+ title_value = item.get("title") or item.get("message") or item.get("summary") or item.get("path") or fallback
2048
+ details = []
2049
+ if item.get("purpose"):
2050
+ details.append(f"purpose: {item['purpose']}")
2051
+ if item.get("command"):
2052
+ details.append(f"command: `{item['command']}`")
2053
+ if item.get("risk"):
2054
+ details.append(f"risk: {item['risk']}")
2055
+ return f"{title_value}" + (f" ({'; '.join(details)})" if details else "")
2056
+ return str(item or fallback)
2057
+
2058
+ finding_lines = []
2059
+ for index, finding in enumerate(findings, start=1):
2060
+ if not isinstance(finding, dict):
2061
+ finding_lines.append(f"- F{index}: {finding}")
2062
+ continue
2063
+ location = finding.get("path") or finding.get("file") or "unknown path"
2064
+ if finding.get("line"):
2065
+ location = f"{location}:{finding['line']}"
2066
+ severity = finding.get("severity") or "unknown"
2067
+ message = finding.get("message") or finding.get("title") or "Review finding"
2068
+ finding_lines.append(f"- F{index} [{severity}] `{location}`: {message}")
2069
+ if not finding_lines:
2070
+ finding_lines.append("- No structured findings were reported. Review the CDX artifacts linked below.")
2071
+ follow_up_lines = []
2072
+ for label, values in (
2073
+ ("Recommendation", recommendations),
2074
+ ("Request file", request_files),
2075
+ ("Actionable fix", actionable_fixes),
2076
+ ("Release plan", release_plan),
2077
+ ):
2078
+ for index, value in enumerate(values, start=1):
2079
+ follow_up_lines.append(f"- {label} {index}: {_item_message(value, label)}")
2080
+ if not follow_up_lines:
2081
+ follow_up_lines.append("- Review CDX output and split any actionable follow-up into tasks before implementation.")
2082
+ summary = task_report.get("summary") or mission_output.get("summary") or "No structured summary provided."
2083
+ text = "\n".join([
2084
+ f"## {ref} - {title}",
2085
+ "> Status: Draft",
2086
+ "> Understanding: 70%",
2087
+ "> Confidence: 70%",
2088
+ "> Complexity: Medium",
2089
+ f"> Theme: {theme}",
2090
+ "",
2091
+ "# Needs",
2092
+ f"- {need}",
2093
+ f"- Summary: {summary}",
2094
+ "",
2095
+ "# Findings",
2096
+ *finding_lines,
2097
+ "",
2098
+ "# Follow-up",
2099
+ *follow_up_lines,
2100
+ "",
2101
+ "# Traceability",
2102
+ f"- CDX run id: `{run_id}`",
2103
+ f"- Transcript: `{(report.get('artifacts') or {}).get('transcript_path') or ''}`",
2104
+ f"- Stdout: `{(report.get('artifacts') or {}).get('stdout_path') or ''}`",
2105
+ "",
2106
+ "# Acceptance Criteria",
2107
+ "- AC1: Each actionable finding is reviewed and either fixed, documented as not applicable, or split into follow-up work.",
2108
+ "- AC2: Validation evidence is added before closing this request.",
2109
+ "",
2110
+ ])
2111
+ path.write_text(text, encoding="utf-8")
2112
+ return {"id": ref, "path": rel_path, "title": title}
2113
+
2114
+
942
2115
  def _json_bytes(payload: Any) -> bytes:
943
2116
  return json.dumps(payload, indent=2, sort_keys=True).encode("utf-8")
944
2117
 
@@ -951,10 +2124,35 @@ class LogicsViewerServer(ThreadingHTTPServer):
951
2124
  *,
952
2125
  auto_refresh_interval_seconds: int = 15,
953
2126
  ):
954
- self.repo_root = repo_root.resolve()
2127
+ self.launch_repo_root = repo_root.resolve()
2128
+ self.project_roots = discover_viewer_project_roots(self.launch_repo_root)
2129
+ self.project_root_by_id = {_viewer_project_id(root): root.resolve() for root in self.project_roots}
2130
+ self.active_project_id = _viewer_project_id(self.launch_repo_root)
2131
+ self.repo_root = self.launch_repo_root
955
2132
  self.auto_refresh_interval_seconds = auto_refresh_interval_seconds
956
2133
  super().__init__(server_address, LogicsViewerRequestHandler)
957
2134
 
2135
+ def project_registry_payload(self) -> list[dict[str, Any]]:
2136
+ return viewer_project_registry(self.repo_root, project_roots=self.project_roots)
2137
+
2138
+ def viewer_payload(self, *, selected_id: str | None = None) -> dict[str, Any]:
2139
+ return viewer_data_payload(
2140
+ self.repo_root,
2141
+ selected_id=selected_id,
2142
+ auto_refresh_interval_seconds=self.auto_refresh_interval_seconds,
2143
+ projects=self.project_registry_payload(),
2144
+ )
2145
+
2146
+ def switch_project(self, project_id: str) -> dict[str, Any]:
2147
+ target = self.project_root_by_id.get(project_id)
2148
+ if target is None:
2149
+ raise ValueError("Unknown project id.")
2150
+ if not target.is_dir():
2151
+ raise FileNotFoundError(str(target))
2152
+ self.active_project_id = project_id
2153
+ self.repo_root = target
2154
+ return self.viewer_payload()
2155
+
958
2156
 
959
2157
  class LogicsViewerRequestHandler(BaseHTTPRequestHandler):
960
2158
  server: LogicsViewerServer
@@ -1018,13 +2216,13 @@ class LogicsViewerRequestHandler(BaseHTTPRequestHandler):
1018
2216
  self._send_json(
1019
2217
  {
1020
2218
  "ok": True,
1021
- "payload": viewer_data_payload(
1022
- self.server.repo_root,
1023
- auto_refresh_interval_seconds=self.server.auto_refresh_interval_seconds,
1024
- ),
2219
+ "payload": self.server.viewer_payload(),
1025
2220
  }
1026
2221
  )
1027
2222
  return
2223
+ if route == "/api/projects":
2224
+ self._send_json({"ok": True, "payload": {"projects": self.server.project_registry_payload()}})
2225
+ return
1028
2226
  if route == "/api/doc":
1029
2227
  rel_path = parse_qs(parsed.query).get("path", [""])[0]
1030
2228
  try:
@@ -1038,6 +2236,9 @@ class LogicsViewerRequestHandler(BaseHTTPRequestHandler):
1038
2236
  if route == "/api/audit":
1039
2237
  self._send_json({"ok": True, "payload": audit_payload(self.server.repo_root)})
1040
2238
  return
2239
+ if route == "/api/capabilities":
2240
+ self._send_json({"ok": True, "payload": viewer_project_capabilities(self.server.repo_root)})
2241
+ return
1041
2242
  if route == "/api/git-status":
1042
2243
  self._send_json({"ok": True, "payload": git_status_payload(self.server.repo_root)})
1043
2244
  return
@@ -1047,12 +2248,24 @@ class LogicsViewerRequestHandler(BaseHTTPRequestHandler):
1047
2248
  if route == "/api/cdx-status":
1048
2249
  self._send_json({"ok": True, "payload": cdx_status_payload(self.server.repo_root)})
1049
2250
  return
2251
+ if route == "/api/cdx-runs":
2252
+ self._send_json({"ok": True, "payload": cdx_runs_payload(self.server.repo_root)})
2253
+ return
2254
+ if route == "/api/cdx-run-report":
2255
+ run_id = parse_qs(parsed.query).get("runId", [""])[0]
2256
+ self._send_json({"ok": True, "payload": cdx_run_report_payload(self.server.repo_root, run_id)})
2257
+ return
1050
2258
  if route == "/api/git-diff":
1051
2259
  params = parse_qs(parsed.query)
1052
2260
  rel_path = params.get("path", [""])[0]
1053
2261
  cached = params.get("cached", [""])[0].lower() in {"1", "true", "yes"}
1054
2262
  self._send_json({"ok": True, "payload": git_diff_payload(self.server.repo_root, rel_path, cached=cached)})
1055
2263
  return
2264
+ if route == "/api/git-file-preview":
2265
+ params = parse_qs(parsed.query)
2266
+ rel_path = params.get("path", [""])[0]
2267
+ self._send_json({"ok": True, "payload": git_file_preview_payload(self.server.repo_root, rel_path)})
2268
+ return
1056
2269
  self._send_error_json(HTTPStatus.NOT_FOUND, "Not found")
1057
2270
 
1058
2271
  def do_POST(self) -> None:
@@ -1061,13 +2274,76 @@ class LogicsViewerRequestHandler(BaseHTTPRequestHandler):
1061
2274
  self._send_json(
1062
2275
  {
1063
2276
  "ok": True,
1064
- "payload": viewer_data_payload(
1065
- self.server.repo_root,
1066
- auto_refresh_interval_seconds=self.server.auto_refresh_interval_seconds,
1067
- ),
2277
+ "payload": self.server.viewer_payload(),
1068
2278
  }
1069
2279
  )
1070
2280
  return
2281
+ if parsed.path == "/api/switch-project":
2282
+ try:
2283
+ length = int(self.headers.get("Content-Length", "0") or "0")
2284
+ raw_body = self.rfile.read(length).decode("utf-8") if length > 0 else "{}"
2285
+ body = json.loads(raw_body or "{}")
2286
+ project_id = str(body.get("projectId") or "")
2287
+ self._send_json({"ok": True, "payload": self.server.switch_project(project_id)})
2288
+ except json.JSONDecodeError:
2289
+ self._send_error_json(HTTPStatus.BAD_REQUEST, "Invalid JSON body.")
2290
+ except ValueError as exc:
2291
+ self._send_error_json(HTTPStatus.FORBIDDEN, str(exc))
2292
+ except FileNotFoundError as exc:
2293
+ self._send_error_json(HTTPStatus.NOT_FOUND, str(exc))
2294
+ return
2295
+ if parsed.path == "/api/bootstrap-logics":
2296
+ try:
2297
+ bootstrap = bootstrap_payload(self.server.repo_root, check=False)
2298
+ self._send_json({"ok": True, "payload": self.server.viewer_payload(), "bootstrap": bootstrap})
2299
+ except SystemExit as exc:
2300
+ self._send_error_json(HTTPStatus.BAD_REQUEST, str(exc))
2301
+ except OSError as exc:
2302
+ self._send_error_json(HTTPStatus.INTERNAL_SERVER_ERROR, str(exc))
2303
+ return
2304
+ if parsed.path == "/api/cdx-report-request":
2305
+ try:
2306
+ length = int(self.headers.get("Content-Length", "0") or "0")
2307
+ raw_body = self.rfile.read(length).decode("utf-8") if length > 0 else "{}"
2308
+ body = json.loads(raw_body or "{}")
2309
+ report_payload = cdx_run_report_payload(self.server.repo_root, str(body.get("runId") or ""))
2310
+ if report_payload.get("state") != "ok":
2311
+ self._send_error_json(HTTPStatus.BAD_GATEWAY, str(report_payload.get("message") or "Unable to load CDX report."))
2312
+ return
2313
+ created = create_request_from_cdx_report(self.server.repo_root, report_payload)
2314
+ self._send_json({"ok": True, "created": created, "payload": self.server.viewer_payload(selected_id=created["id"])})
2315
+ except json.JSONDecodeError:
2316
+ self._send_error_json(HTTPStatus.BAD_REQUEST, "Invalid JSON body.")
2317
+ except OSError as exc:
2318
+ self._send_error_json(HTTPStatus.INTERNAL_SERVER_ERROR, str(exc))
2319
+ return
2320
+ if parsed.path == "/api/cdx-mission-plan":
2321
+ try:
2322
+ length = int(self.headers.get("Content-Length", "0") or "0")
2323
+ raw_body = self.rfile.read(length).decode("utf-8") if length > 0 else "{}"
2324
+ body = json.loads(raw_body or "{}")
2325
+ self._send_json({"ok": True, "payload": cdx_mission_plan_payload(self.server.repo_root, body)})
2326
+ except json.JSONDecodeError:
2327
+ self._send_error_json(HTTPStatus.BAD_REQUEST, "Invalid JSON body.")
2328
+ return
2329
+ if parsed.path == "/api/cdx-mission-run":
2330
+ try:
2331
+ length = int(self.headers.get("Content-Length", "0") or "0")
2332
+ raw_body = self.rfile.read(length).decode("utf-8") if length > 0 else "{}"
2333
+ body = json.loads(raw_body or "{}")
2334
+ self._send_json({"ok": True, "payload": cdx_mission_run_payload(self.server.repo_root, body)})
2335
+ except json.JSONDecodeError:
2336
+ self._send_error_json(HTTPStatus.BAD_REQUEST, "Invalid JSON body.")
2337
+ return
2338
+ if parsed.path == "/api/cdx-mission-apply-plan":
2339
+ try:
2340
+ length = int(self.headers.get("Content-Length", "0") or "0")
2341
+ raw_body = self.rfile.read(length).decode("utf-8") if length > 0 else "{}"
2342
+ body = json.loads(raw_body or "{}")
2343
+ self._send_json({"ok": True, "payload": cdx_mission_apply_plan_payload(self.server.repo_root, body)})
2344
+ except json.JSONDecodeError:
2345
+ self._send_error_json(HTTPStatus.BAD_REQUEST, "Invalid JSON body.")
2346
+ return
1071
2347
  if parsed.path == "/api/edit":
1072
2348
  rel_path = parse_qs(parsed.query).get("path", [""])[0]
1073
2349
  try:
@@ -1077,6 +2353,32 @@ class LogicsViewerRequestHandler(BaseHTTPRequestHandler):
1077
2353
  except OSError as exc:
1078
2354
  self._send_error_json(HTTPStatus.INTERNAL_SERVER_ERROR, str(exc))
1079
2355
  return
2356
+ if parsed.path == "/api/open-file":
2357
+ try:
2358
+ length = int(self.headers.get("Content-Length", "0") or "0")
2359
+ raw_body = self.rfile.read(length).decode("utf-8") if length > 0 else "{}"
2360
+ body = json.loads(raw_body or "{}")
2361
+ self._send_json({"ok": True, "payload": open_file_payload(self.server.repo_root, str(body.get("path", "")))})
2362
+ except json.JSONDecodeError:
2363
+ self._send_error_json(HTTPStatus.BAD_REQUEST, "Invalid JSON body.")
2364
+ except (FileNotFoundError, ValueError) as exc:
2365
+ self._send_error_json(HTTPStatus.NOT_FOUND, str(exc))
2366
+ except OSError as exc:
2367
+ self._send_error_json(HTTPStatus.INTERNAL_SERVER_ERROR, str(exc))
2368
+ return
2369
+ if parsed.path == "/api/file-preview":
2370
+ try:
2371
+ length = int(self.headers.get("Content-Length", "0") or "0")
2372
+ raw_body = self.rfile.read(length).decode("utf-8") if length > 0 else "{}"
2373
+ body = json.loads(raw_body or "{}")
2374
+ self._send_json({"ok": True, "payload": file_preview_payload(self.server.repo_root, str(body.get("path", "")))})
2375
+ except json.JSONDecodeError:
2376
+ self._send_error_json(HTTPStatus.BAD_REQUEST, "Invalid JSON body.")
2377
+ except (FileNotFoundError, ValueError) as exc:
2378
+ self._send_error_json(HTTPStatus.NOT_FOUND, str(exc))
2379
+ except OSError as exc:
2380
+ self._send_error_json(HTTPStatus.INTERNAL_SERVER_ERROR, str(exc))
2381
+ return
1080
2382
  if parsed.path == "/api/open-repo-folder":
1081
2383
  try:
1082
2384
  self._send_json({"ok": True, "payload": open_repo_folder_payload(self.server.repo_root)})