@grifhinz/logics-manager 2.7.0 → 2.8.1

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.
@@ -44,6 +44,126 @@ DOC_FAMILIES = (
44
44
  )
45
45
 
46
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_PARENT_TIMEOUT_GRACE_SECONDS = 90
53
+ CDX_WRITABLE_MISSION_MIN_TIMEOUT_SECONDS = 600
54
+ CDX_MISSION_CATALOG = {
55
+ "full-audit": {
56
+ "id": "full-audit",
57
+ "title": "Full audit",
58
+ "description": "Audit the repository and optionally apply safe, validated fixes.",
59
+ "scope": "repository",
60
+ "requiresReleaseTag": False,
61
+ "requiresPlanConfirmation": False,
62
+ "supportsFileWrites": True,
63
+ "inputFields": [
64
+ {
65
+ "id": "directFixes",
66
+ "label": "Fix directly",
67
+ "type": "checkbox",
68
+ "required": False,
69
+ }
70
+ ],
71
+ },
72
+ "release-review": {
73
+ "id": "release-review",
74
+ "title": "Review since latest release",
75
+ "description": "Review changes since the latest release and optionally apply safe fixes.",
76
+ "scope": "latest-release",
77
+ "requiresReleaseTag": True,
78
+ "requiresPlanConfirmation": False,
79
+ "supportsFileWrites": True,
80
+ "inputFields": [
81
+ {
82
+ "id": "directFixes",
83
+ "label": "Fix directly",
84
+ "type": "checkbox",
85
+ "required": False,
86
+ }
87
+ ],
88
+ },
89
+ "corpus-ready": {
90
+ "id": "corpus-ready",
91
+ "title": "Prepare dev-ready corpus",
92
+ "description": "Produce a corpus plan for explicit deterministic application.",
93
+ "scope": "open-logics-workflow",
94
+ "requiresReleaseTag": False,
95
+ "requiresPlanConfirmation": True,
96
+ "supportsFileWrites": False,
97
+ },
98
+ "wish-to-request": {
99
+ "id": "wish-to-request",
100
+ "title": "Wish to request",
101
+ "description": "Create or draft a structured Logics request from a free-form wish.",
102
+ "scope": "request-draft",
103
+ "requiresReleaseTag": False,
104
+ "requiresPlanConfirmation": False,
105
+ "supportsFileWrites": True,
106
+ "inputFields": [
107
+ {
108
+ "id": "wishText",
109
+ "label": "Wish or intent",
110
+ "type": "textarea",
111
+ "placeholder": "Describe the workflow, feature, bug, or product intent to capture.",
112
+ "required": True,
113
+ }
114
+ ],
115
+ },
116
+ "pre-release": {
117
+ "id": "pre-release",
118
+ "title": "Guarded pre-release",
119
+ "description": "Prepare release metadata, changelog, validation, and fixes without tagging or publishing.",
120
+ "scope": "pre-release-report",
121
+ "requiresReleaseTag": False,
122
+ "requiresPlanConfirmation": False,
123
+ "supportsFileWrites": True,
124
+ "inputFields": [
125
+ {
126
+ "id": "releaseVersion",
127
+ "label": "Version",
128
+ "type": "text",
129
+ "placeholder": "vX.X.X",
130
+ "required": True,
131
+ "pattern": "^v\\d+\\.\\d+\\.\\d+$",
132
+ },
133
+ {
134
+ "id": "runFullValidation",
135
+ "label": "Run full validation and report fixes before pre-release",
136
+ "type": "checkbox",
137
+ "required": False,
138
+ },
139
+ ],
140
+ },
141
+ }
142
+ CDX_DEFAULT_MISSION_ID = "full-audit"
143
+ GIT_FILE_PREVIEW_MAX_BYTES = 30000
144
+ GIT_FILE_PREVIEW_MAX_CHARS = 20000
145
+ FILE_PREVIEW_MAX_BYTES = 300000
146
+ FILE_PREVIEW_MAX_CHARS = 200000
147
+ WORKSPACE_TREE_MAX_ENTRIES = 250
148
+ WORKSPACE_PREVIEW_MAX_BYTES = 30000
149
+ WORKSPACE_PREVIEW_MAX_CHARS = 20000
150
+ WORKSPACE_IGNORED_DIRS = {
151
+ ".git",
152
+ ".hg",
153
+ ".svn",
154
+ "__pycache__",
155
+ ".pytest_cache",
156
+ ".mypy_cache",
157
+ ".ruff_cache",
158
+ "node_modules",
159
+ "dist",
160
+ "build",
161
+ "coverage",
162
+ ".next",
163
+ ".turbo",
164
+ ".venv",
165
+ "venv",
166
+ }
47
167
  REPO_ROOT = Path(__file__).resolve().parents[1]
48
168
  PACKAGE_VIEWER_ASSETS_ROOT = Path(__file__).resolve().parent / "viewer_assets"
49
169
  VIEWER_ROOT = REPO_ROOT / "clients" / "viewer"
@@ -344,6 +464,7 @@ def viewer_data_payload(
344
464
  selected_id: str | None = None,
345
465
  *,
346
466
  auto_refresh_interval_seconds: int = 15,
467
+ auto_refresh_interval_forced: bool = False,
347
468
  projects: list[dict[str, Any]] | None = None,
348
469
  ) -> dict[str, Any]:
349
470
  capabilities = viewer_project_capabilities(repo_root)
@@ -359,6 +480,7 @@ def viewer_data_payload(
359
480
  "capabilities": capabilities,
360
481
  "projects": projects if projects is not None else viewer_project_registry(repo_root),
361
482
  "autoRefreshIntervalSeconds": auto_refresh_interval_seconds,
483
+ "autoRefreshIntervalForced": auto_refresh_interval_forced,
362
484
  "items": collect_viewer_items(repo_root),
363
485
  "updateInfo": get_update_info(_current_version()).to_payload(),
364
486
  "selectedId": selected_id,
@@ -513,9 +635,16 @@ def viewer_project_capabilities(
513
635
  else:
514
636
  cdx = _viewer_capability("missing", available=False, message="CDX executable is not available.")
515
637
  cdx_runs = _viewer_capability("missing", available=False, message="CDX is required before assistant runs can be tracked.")
638
+ workspace = _viewer_capability(
639
+ "ready" if repo_root.is_dir() else "missing",
640
+ available=repo_root.is_dir(),
641
+ message="Workspace root can be inspected." if repo_root.is_dir() else "Workspace root is unavailable.",
642
+ detail={"root": str(repo_root.resolve())} if repo_root.is_dir() else {},
643
+ )
516
644
 
517
645
  return {
518
646
  "logics": logics,
647
+ "workspace": workspace,
519
648
  "git": git,
520
649
  "ci": ci,
521
650
  "cdx": cdx,
@@ -553,6 +682,54 @@ def edit_doc_payload(repo_root: Path, rel_path: str, *, launcher: Any | None = N
553
682
  }
554
683
 
555
684
 
685
+ def _resolve_openable_file_path(repo_root: Path, file_path: str) -> Path:
686
+ raw_path = unquote(file_path).strip()
687
+ if not raw_path:
688
+ raise ValueError("Missing file path.")
689
+ candidate = Path(raw_path).expanduser()
690
+ if not candidate.is_absolute():
691
+ candidate = repo_root / raw_path.lstrip("/\\")
692
+ absolute = candidate.resolve()
693
+ if not absolute.is_file():
694
+ raise FileNotFoundError(str(candidate))
695
+ return absolute
696
+
697
+
698
+ def open_file_payload(repo_root: Path, file_path: str, *, launcher: Any | None = None) -> dict[str, str]:
699
+ absolute = _resolve_openable_file_path(repo_root, file_path)
700
+ command = _system_editor_command(absolute)
701
+ runner = launcher or subprocess.Popen
702
+ runner(command)
703
+ return {
704
+ "path": str(absolute),
705
+ "command": command[0],
706
+ }
707
+
708
+
709
+ def file_preview_payload(
710
+ repo_root: Path,
711
+ file_path: str,
712
+ *,
713
+ max_bytes: int = FILE_PREVIEW_MAX_BYTES,
714
+ max_chars: int = FILE_PREVIEW_MAX_CHARS,
715
+ ) -> dict[str, Any]:
716
+ absolute = _resolve_openable_file_path(repo_root, file_path)
717
+ raw = absolute.read_bytes()
718
+ truncated = len(raw) > max_bytes
719
+ if truncated:
720
+ raw = raw[-max_bytes:]
721
+ content = raw.decode("utf-8", errors="replace")
722
+ if len(content) > max_chars:
723
+ content = content[-max_chars:]
724
+ truncated = True
725
+ return {
726
+ "path": str(absolute),
727
+ "name": absolute.name,
728
+ "content": content,
729
+ "truncated": truncated,
730
+ }
731
+
732
+
556
733
  def open_repo_folder_payload(repo_root: Path, *, launcher: Any | None = None) -> dict[str, str]:
557
734
  root = repo_root.resolve()
558
735
  command = _system_editor_command(root)
@@ -584,6 +761,24 @@ def _run_read_only_cdx(repo_root: Path, args: list[str], *, runner: Any | None =
584
761
  return cdx_runner(command, cwd=repo_root, text=True, capture_output=True, timeout=5)
585
762
 
586
763
 
764
+ def _run_cdx_mission(repo_root: Path, args: list[str], *, timeout: int, runner: Any | None = None) -> subprocess.CompletedProcess[str]:
765
+ command = ["cdx", *args]
766
+ cdx_runner = runner or subprocess.run
767
+ return cdx_runner(command, cwd=repo_root, text=True, capture_output=True, timeout=timeout)
768
+
769
+
770
+ def _run_logics_flow(repo_root: Path, args: list[str], *, runner: Any | None = None) -> subprocess.CompletedProcess[str]:
771
+ command = ["logics-manager", "flow", *args]
772
+ flow_runner = runner or subprocess.run
773
+ return flow_runner(command, cwd=repo_root, text=True, capture_output=True, timeout=30)
774
+
775
+
776
+ def _run_logics_command(repo_root: Path, args: list[str], *, runner: Any | None = None) -> subprocess.CompletedProcess[str]:
777
+ command = ["logics-manager", *args]
778
+ logics_runner = runner or subprocess.run
779
+ return logics_runner(command, cwd=repo_root, text=True, capture_output=True, timeout=30)
780
+
781
+
587
782
  def _run_read_only_gh(repo_root: Path, args: list[str], *, runner: Any | None = None) -> subprocess.CompletedProcess[str]:
588
783
  command = ["gh", *args]
589
784
  gh_runner = runner or subprocess.run
@@ -711,7 +906,11 @@ def _parse_git_branch_line(line: str) -> dict[str, Any]:
711
906
  }
712
907
 
713
908
 
714
- def _parse_recent_git_commits(output: str) -> list[dict[str, str]]:
909
+ GIT_HISTORY_DISPLAY_LIMIT = 50
910
+ GIT_HISTORY_FETCH_LIMIT = GIT_HISTORY_DISPLAY_LIMIT + 1
911
+
912
+
913
+ def _parse_recent_git_commits(output: str, *, limit: int | None = None) -> list[dict[str, str]]:
715
914
  commits: list[dict[str, str]] = []
716
915
  for line in output.splitlines():
717
916
  parts = line.split("\x1f")
@@ -727,6 +926,8 @@ def _parse_recent_git_commits(output: str) -> list[dict[str, str]]:
727
926
  "refs": _sanitize_git_ref(refs),
728
927
  }
729
928
  )
929
+ if limit is not None and len(commits) >= limit:
930
+ break
730
931
  return commits
731
932
 
732
933
 
@@ -813,7 +1014,7 @@ def git_status_payload(repo_root: Path, *, runner: Any | None = None, which: Any
813
1014
  commit = _run_read_only_git(repo_root, ["log", "-1", "--pretty=format:%h %s"], runner=runner)
814
1015
  recent_commits = _run_read_only_git(
815
1016
  repo_root,
816
- ["log", "-50", "--date=short", "--pretty=format:%h%x1f%s%x1f%an%x1f%ad%x1f%D"],
1017
+ ["log", f"-{GIT_HISTORY_FETCH_LIMIT}", "--date=short", "--pretty=format:%h%x1f%s%x1f%an%x1f%ad%x1f%D"],
817
1018
  runner=runner,
818
1019
  )
819
1020
  unpushed = _git_unpushed_commit_count(repo_root, runner=runner)
@@ -840,6 +1041,7 @@ def git_status_payload(repo_root: Path, *, runner: Any | None = None, which: Any
840
1041
  counts = {key: len(value) for key, value in groups.items()}
841
1042
  uncommitted_files = _count_unique_git_status_paths(groups)
842
1043
  dirty = any(counts.values())
1044
+ parsed_recent_commits = _parse_recent_git_commits(recent_commits.stdout, limit=GIT_HISTORY_FETCH_LIMIT) if recent_commits.returncode == 0 else []
843
1045
  return {
844
1046
  "state": "ok",
845
1047
  **branch_info,
@@ -860,10 +1062,18 @@ def git_status_payload(repo_root: Path, *, runner: Any | None = None, which: Any
860
1062
  },
861
1063
  "groups": groups,
862
1064
  "latestCommit": (commit.stdout.strip() if commit.returncode == 0 else "")[:300],
863
- "recentCommits": _parse_recent_git_commits(recent_commits.stdout) if recent_commits.returncode == 0 else [],
1065
+ "recentCommits": parsed_recent_commits[:GIT_HISTORY_DISPLAY_LIMIT],
1066
+ "recentCommitsHasMore": len(parsed_recent_commits) > GIT_HISTORY_DISPLAY_LIMIT,
864
1067
  }
865
1068
 
866
1069
 
1070
+ def _normalize_git_file_path(rel_path: str) -> str | None:
1071
+ normalized = unquote(rel_path).replace("\\", "/").lstrip("/")
1072
+ if not normalized or normalized.startswith("~") or normalized.startswith("/") or ".." in normalized.split("/"):
1073
+ return None
1074
+ return normalized
1075
+
1076
+
867
1077
  def git_diff_payload(
868
1078
  repo_root: Path,
869
1079
  rel_path: str,
@@ -876,8 +1086,8 @@ def git_diff_payload(
876
1086
  git_which = which or shutil.which
877
1087
  if not git_which("git"):
878
1088
  return {"state": "unavailable", "message": "Git is not available on PATH."}
879
- normalized = unquote(rel_path).replace("\\", "/").lstrip("/")
880
- if not normalized or normalized.startswith("~") or normalized.startswith("/") or ".." in normalized.split("/"):
1089
+ normalized = _normalize_git_file_path(rel_path)
1090
+ if not normalized:
881
1091
  return {"state": "error", "message": "Unsafe Git path."}
882
1092
  try:
883
1093
  inside = _run_read_only_git(repo_root, ["rev-parse", "--is-inside-work-tree"], runner=runner)
@@ -912,6 +1122,233 @@ def git_diff_payload(
912
1122
  }
913
1123
 
914
1124
 
1125
+ def git_file_preview_payload(
1126
+ repo_root: Path,
1127
+ rel_path: str,
1128
+ *,
1129
+ max_bytes: int = GIT_FILE_PREVIEW_MAX_BYTES,
1130
+ max_chars: int = GIT_FILE_PREVIEW_MAX_CHARS,
1131
+ ) -> dict[str, Any]:
1132
+ normalized = _normalize_git_file_path(rel_path)
1133
+ if not normalized:
1134
+ return {"state": "error", "message": "Unsafe Git path."}
1135
+ target = (repo_root / normalized).resolve()
1136
+ try:
1137
+ target.relative_to(repo_root.resolve())
1138
+ except ValueError:
1139
+ return {"state": "error", "message": "Unsafe Git path."}
1140
+ if not target.exists() or not target.is_file():
1141
+ return {
1142
+ "state": "missing",
1143
+ "path": normalized,
1144
+ "message": "The current file is missing or deleted, so no file preview is available.",
1145
+ }
1146
+ try:
1147
+ size = target.stat().st_size
1148
+ except OSError as exc:
1149
+ return {"state": "error", "path": normalized, "message": f"Unable to inspect file: {exc}"}
1150
+ if size > max_bytes:
1151
+ return {
1152
+ "state": "oversized",
1153
+ "path": normalized,
1154
+ "size": size,
1155
+ "message": f"File preview is limited to {max_bytes} bytes; this file is {size} bytes.",
1156
+ }
1157
+ try:
1158
+ data = target.read_bytes()
1159
+ except OSError as exc:
1160
+ return {"state": "error", "path": normalized, "message": f"Unable to read file preview: {exc}"}
1161
+ if b"\x00" in data:
1162
+ return {
1163
+ "state": "unsupported",
1164
+ "path": normalized,
1165
+ "message": "Binary or unsupported file content cannot be previewed.",
1166
+ }
1167
+ try:
1168
+ content = data.decode("utf-8")
1169
+ except UnicodeDecodeError:
1170
+ return {
1171
+ "state": "unsupported",
1172
+ "path": normalized,
1173
+ "message": "Binary or unsupported file encoding cannot be previewed.",
1174
+ }
1175
+ content = content.replace("\r\n", "\n").replace("\r", "\n")
1176
+ truncated = len(content) > max_chars
1177
+ if truncated:
1178
+ content = content[:max_chars]
1179
+ return {
1180
+ "state": "ok",
1181
+ "path": normalized,
1182
+ "mode": "file-preview",
1183
+ "content": content,
1184
+ "truncated": truncated,
1185
+ "logicsType": _logics_doc_type(normalized),
1186
+ "message": "",
1187
+ }
1188
+
1189
+
1190
+ def _normalize_workspace_path(rel_path: str) -> str:
1191
+ normalized = unquote(rel_path or "").replace("\\", "/").strip()
1192
+ normalized = normalized.lstrip("/")
1193
+ if normalized in {"", "."}:
1194
+ return ""
1195
+ if normalized.startswith("~") or re.match(r"^[A-Za-z]:", normalized):
1196
+ raise ValueError("Unsafe workspace path.")
1197
+ parts = [part for part in normalized.split("/") if part not in {"", "."}]
1198
+ if any(part == ".." for part in parts):
1199
+ raise ValueError("Workspace path escapes root.")
1200
+ return "/".join(parts)
1201
+
1202
+
1203
+ def _resolve_workspace_path(repo_root: Path, rel_path: str) -> tuple[str, Path]:
1204
+ normalized = _normalize_workspace_path(rel_path)
1205
+ root = repo_root.resolve()
1206
+ target = (root / normalized).resolve()
1207
+ try:
1208
+ target.relative_to(root)
1209
+ except ValueError as exc:
1210
+ raise ValueError("Workspace path escapes root.") from exc
1211
+ return normalized, target
1212
+
1213
+
1214
+ def _workspace_entry_payload(root: Path, path: Path) -> dict[str, Any]:
1215
+ try:
1216
+ stat = path.stat()
1217
+ except OSError:
1218
+ stat = None
1219
+ rel_path = path.relative_to(root).as_posix()
1220
+ is_dir = path.is_dir()
1221
+ ignored = is_dir and path.name in WORKSPACE_IGNORED_DIRS
1222
+ return {
1223
+ "name": path.name or root.name,
1224
+ "path": rel_path,
1225
+ "kind": "directory" if is_dir else "file",
1226
+ "size": stat.st_size if stat else 0,
1227
+ "ignored": ignored,
1228
+ "childrenAvailable": is_dir and not ignored,
1229
+ }
1230
+
1231
+
1232
+ def workspace_tree_payload(
1233
+ repo_root: Path,
1234
+ rel_path: str = "",
1235
+ *,
1236
+ max_entries: int = WORKSPACE_TREE_MAX_ENTRIES,
1237
+ ) -> dict[str, Any]:
1238
+ normalized, target = _resolve_workspace_path(repo_root, rel_path)
1239
+ root = repo_root.resolve()
1240
+ if not target.exists():
1241
+ return {"state": "missing", "path": normalized, "message": "Workspace path does not exist."}
1242
+ if not target.is_dir():
1243
+ return {"state": "not-directory", "path": normalized, "message": "Workspace path is not a directory."}
1244
+ entries = []
1245
+ truncated = False
1246
+ try:
1247
+ children = sorted(target.iterdir(), key=lambda path: (not path.is_dir(), path.name.lower()))
1248
+ except OSError as exc:
1249
+ return {"state": "error", "path": normalized, "message": f"Unable to list workspace path: {exc}"}
1250
+ for child in children:
1251
+ if len(entries) >= max_entries:
1252
+ truncated = True
1253
+ break
1254
+ entries.append(_workspace_entry_payload(root, child))
1255
+ return {
1256
+ "state": "ok",
1257
+ "root": str(root),
1258
+ "path": normalized,
1259
+ "entries": entries,
1260
+ "truncated": truncated,
1261
+ "ignoredDirectories": sorted(WORKSPACE_IGNORED_DIRS),
1262
+ }
1263
+
1264
+
1265
+ def workspace_preview_payload(
1266
+ repo_root: Path,
1267
+ rel_path: str,
1268
+ *,
1269
+ max_bytes: int = WORKSPACE_PREVIEW_MAX_BYTES,
1270
+ max_chars: int = WORKSPACE_PREVIEW_MAX_CHARS,
1271
+ ) -> dict[str, Any]:
1272
+ normalized, target = _resolve_workspace_path(repo_root, rel_path)
1273
+ if not target.exists():
1274
+ return {"state": "missing", "path": normalized, "message": "Workspace path does not exist."}
1275
+ if target.is_dir():
1276
+ try:
1277
+ count = sum(1 for _ in target.iterdir())
1278
+ except OSError:
1279
+ count = 0
1280
+ return {
1281
+ "state": "directory",
1282
+ "path": normalized,
1283
+ "name": target.name or repo_root.resolve().name,
1284
+ "kind": "directory",
1285
+ "message": f"{count} item(s)",
1286
+ "childrenAvailable": target.name not in WORKSPACE_IGNORED_DIRS,
1287
+ }
1288
+ if not target.is_file():
1289
+ return {"state": "unsupported", "path": normalized, "message": "Workspace object cannot be previewed."}
1290
+ try:
1291
+ size = target.stat().st_size
1292
+ except OSError as exc:
1293
+ return {"state": "error", "path": normalized, "message": f"Unable to inspect file: {exc}"}
1294
+ if size > max_bytes:
1295
+ return {
1296
+ "state": "oversized",
1297
+ "path": normalized,
1298
+ "name": target.name,
1299
+ "size": size,
1300
+ "message": f"File preview is limited to {max_bytes} bytes; this file is {size} bytes.",
1301
+ }
1302
+ try:
1303
+ data = target.read_bytes()
1304
+ except OSError as exc:
1305
+ return {"state": "error", "path": normalized, "message": f"Unable to read file preview: {exc}"}
1306
+ content_type = mimetypes.guess_type(target.name)[0] or ""
1307
+ if content_type.startswith("image/"):
1308
+ return {
1309
+ "state": "image",
1310
+ "path": normalized,
1311
+ "name": target.name,
1312
+ "size": size,
1313
+ "contentType": content_type,
1314
+ "message": "Image preview is available from the workspace file endpoint.",
1315
+ }
1316
+ if b"\x00" in data:
1317
+ return {
1318
+ "state": "unsupported",
1319
+ "path": normalized,
1320
+ "name": target.name,
1321
+ "size": size,
1322
+ "message": "Binary or unsupported file content cannot be previewed.",
1323
+ }
1324
+ try:
1325
+ content = data.decode("utf-8")
1326
+ except UnicodeDecodeError:
1327
+ return {
1328
+ "state": "unsupported",
1329
+ "path": normalized,
1330
+ "name": target.name,
1331
+ "size": size,
1332
+ "message": "Binary or unsupported file encoding cannot be previewed.",
1333
+ }
1334
+ content = content.replace("\r\n", "\n").replace("\r", "\n")
1335
+ truncated = len(content) > max_chars
1336
+ if truncated:
1337
+ content = content[:max_chars]
1338
+ return {
1339
+ "state": "ok",
1340
+ "path": normalized,
1341
+ "name": target.name,
1342
+ "kind": "file",
1343
+ "size": size,
1344
+ "contentType": content_type or "text/plain",
1345
+ "content": content,
1346
+ "truncated": truncated,
1347
+ "logicsType": _logics_doc_type(normalized),
1348
+ "message": "",
1349
+ }
1350
+
1351
+
915
1352
  def _current_git_ci_context(repo_root: Path, *, runner: Any | None = None) -> dict[str, str]:
916
1353
  context = {"branch": "", "headSha": "", "subject": "", "author": ""}
917
1354
  commands = {
@@ -946,11 +1383,34 @@ def _ci_badge_state(status: str, conclusion: str) -> str:
946
1383
  return "unknown"
947
1384
 
948
1385
 
1386
+ def _is_active_ci_status(run: dict[str, Any]) -> bool:
1387
+ return str(run.get("status") or "").strip().lower() in {"queued", "in_progress", "waiting", "requested", "pending"}
1388
+
1389
+
1390
+ def _select_github_actions_run(runs: list[dict[str, Any]], head_sha: str) -> tuple[dict[str, Any], str]:
1391
+ ci_runs = [run for run in runs if str(run.get("name") or "").strip().lower() == "ci"]
1392
+ candidate_runs = ci_runs or runs
1393
+ head_runs = [run for run in candidate_runs if head_sha and str(run.get("head_sha") or "") == head_sha]
1394
+ active_head_run = next((run for run in head_runs if _is_active_ci_status(run)), None)
1395
+ if active_head_run is not None:
1396
+ return active_head_run, "head-active"
1397
+ if head_runs:
1398
+ head_state = _ci_badge_state(str(head_runs[0].get("status") or ""), str(head_runs[0].get("conclusion") or ""))
1399
+ if head_state in {"failing", "cancelled", "unknown"}:
1400
+ return head_runs[0], f"head-{head_state}"
1401
+ return head_runs[0], "head"
1402
+ active_branch_run = next((run for run in candidate_runs if _is_active_ci_status(run)), None)
1403
+ if active_branch_run is not None:
1404
+ return active_branch_run, "branch-active"
1405
+ return candidate_runs[0], "branch-latest"
1406
+
1407
+
949
1408
  def _parse_github_actions_run(run: dict[str, Any], *, match_source: str) -> dict[str, Any]:
950
1409
  status = str(run.get("status") or "")
951
1410
  conclusion = str(run.get("conclusion") or "")
952
1411
  commit = run.get("head_commit") if isinstance(run.get("head_commit"), dict) else {}
953
1412
  author = commit.get("author") if isinstance(commit.get("author"), dict) else {}
1413
+ commit_lines = str(commit.get("message") or run.get("display_title") or "").splitlines()
954
1414
  return {
955
1415
  "id": run.get("id"),
956
1416
  "name": str(run.get("name") or run.get("display_title") or "GitHub Actions"),
@@ -965,7 +1425,7 @@ def _parse_github_actions_run(run: dict[str, Any], *, match_source: str) -> dict
965
1425
  "createdAt": str(run.get("created_at") or ""),
966
1426
  "updatedAt": str(run.get("updated_at") or ""),
967
1427
  "runStartedAt": str(run.get("run_started_at") or ""),
968
- "commitMessage": str(commit.get("message") or run.get("display_title") or "").splitlines()[0][:240],
1428
+ "commitMessage": commit_lines[0][:240] if commit_lines else "",
969
1429
  "author": str(author.get("name") or ""),
970
1430
  "matchSource": match_source,
971
1431
  }
@@ -1021,7 +1481,7 @@ def ci_status_payload(repo_root: Path, *, git_runner: Any | None = None, gh_runn
1021
1481
  context = _current_git_ci_context(repo_root, runner=git_runner)
1022
1482
  branch = context.get("branch", "")
1023
1483
  head_sha = context.get("headSha", "")
1024
- endpoint = f"repos/{owner}/{repo}/actions/runs?per_page=10"
1484
+ endpoint = f"repos/{owner}/{repo}/actions/runs?per_page=30"
1025
1485
  if branch:
1026
1486
  endpoint = f"{endpoint}&branch={quote(branch, safe='')}"
1027
1487
  try:
@@ -1043,10 +1503,7 @@ def ci_status_payload(repo_root: Path, *, git_runner: Any | None = None, gh_runn
1043
1503
  if not runs:
1044
1504
  return {"state": "ok", "visible": True, "message": "No GitHub Actions runs found for the current branch.", "repositoryUrl": github_url, **context, "badgeState": "unknown", "run": None, "jobs": []}
1045
1505
 
1046
- selected = next((run for run in runs if head_sha and str(run.get("head_sha") or "") == head_sha), None)
1047
- match_source = "head" if selected else "branch-latest"
1048
- if selected is None:
1049
- selected = runs[0]
1506
+ selected, match_source = _select_github_actions_run(runs, head_sha)
1050
1507
  run_payload = _parse_github_actions_run(selected, match_source=match_source)
1051
1508
  jobs: list[dict[str, str]] = []
1052
1509
  run_id = run_payload.get("id")
@@ -1116,7 +1573,18 @@ def cdx_runs_payload(repo_root: Path, *, runner: Any | None = None, which: Any |
1116
1573
  runs = parsed.get("runs") if isinstance(parsed, dict) else None
1117
1574
  if not isinstance(runs, list):
1118
1575
  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)]}
1576
+ normalized_runs: list[dict[str, Any]] = []
1577
+ for run in runs:
1578
+ if not isinstance(run, dict):
1579
+ continue
1580
+ item = dict(run)
1581
+ status = str(item.get("status") or item.get("state") or "").strip().lower()
1582
+ if status == "stale" and not item.get("ended_at") and not item.get("endedAt"):
1583
+ item["status"] = "running"
1584
+ item["status_detail"] = "CDX still marks this run active; no end timestamp has been reported yet."
1585
+ item["raw_status"] = "stale"
1586
+ normalized_runs.append(item)
1587
+ return {"state": "ok", "message": "", "runs": normalized_runs}
1120
1588
 
1121
1589
 
1122
1590
  def cdx_run_report_payload(repo_root: Path, run_id: str, *, runner: Any | None = None, which: Any | None = None) -> dict[str, Any]:
@@ -1141,9 +1609,569 @@ def cdx_run_report_payload(repo_root: Path, run_id: str, *, runner: Any | None =
1141
1609
  report = parsed.get("report") if isinstance(parsed, dict) else None
1142
1610
  if not isinstance(report, dict):
1143
1611
  return {"state": "invalid-json", "message": "CDX run-report JSON must include a report object.", "report": None}
1612
+ merged_report = _merge_cdx_mission_output(report)
1613
+ if merged_report:
1614
+ report = merged_report
1144
1615
  return {"state": "ok", "message": "", "report": report}
1145
1616
 
1146
1617
 
1618
+ def cdx_mission_catalog_payload() -> dict[str, Any]:
1619
+ return {
1620
+ "missions": list(CDX_MISSION_CATALOG.values()),
1621
+ "strengths": list(CDX_MISSION_STRENGTHS.values()),
1622
+ "defaultMissionId": CDX_DEFAULT_MISSION_ID,
1623
+ "defaultStrengthId": "standard",
1624
+ }
1625
+
1626
+
1627
+ def _cdx_status_sessions(status_payload: dict[str, Any]) -> list[str]:
1628
+ status = status_payload.get("status") if isinstance(status_payload.get("status"), dict) else {}
1629
+ sessions = status.get("sessions") if isinstance(status.get("sessions"), list) else []
1630
+ ids: list[str] = []
1631
+ for session in sessions:
1632
+ if not isinstance(session, dict):
1633
+ continue
1634
+ session_id = str(session.get("id") or session.get("name") or "").strip()
1635
+ if session_id:
1636
+ ids.append(session_id)
1637
+ return ids
1638
+
1639
+
1640
+ def _normalize_cdx_session(value: Any, status_payload: dict[str, Any] | None = None) -> str:
1641
+ session = str(value or "").strip()
1642
+ if not re.match(r"^[A-Za-z0-9_.:@/-]{1,120}$", session):
1643
+ return ""
1644
+ if status_payload is None:
1645
+ return session
1646
+ known_sessions = _cdx_status_sessions(status_payload)
1647
+ if known_sessions and session not in known_sessions:
1648
+ return ""
1649
+ return session
1650
+
1651
+
1652
+ def _latest_release_tag(repo_root: Path, *, runner: Any | None = None, which: Any | None = None) -> str:
1653
+ git_which = which or shutil.which
1654
+ if not git_which("git"):
1655
+ return ""
1656
+ commands = [
1657
+ ["tag", "--sort=-version:refname", "--list", "v[0-9]*"],
1658
+ ["tag", "--sort=-version:refname", "--list", "[0-9]*"],
1659
+ ["describe", "--tags", "--abbrev=0"],
1660
+ ]
1661
+ for args in commands:
1662
+ try:
1663
+ result = _run_read_only_git(repo_root, args, runner=runner)
1664
+ except (OSError, subprocess.SubprocessError, subprocess.TimeoutExpired):
1665
+ continue
1666
+ if result.returncode != 0:
1667
+ continue
1668
+ tag = (result.stdout or "").strip().splitlines()[0] if (result.stdout or "").strip() else ""
1669
+ if tag:
1670
+ return tag[:200]
1671
+ return ""
1672
+
1673
+
1674
+ def _mission_text_input(body: dict[str, Any], key: str, *, max_chars: int = 4000) -> str:
1675
+ raw = str(body.get(key) or "").strip()
1676
+ normalized = re.sub(r"\s+", " ", raw)
1677
+ return normalized[:max_chars]
1678
+
1679
+
1680
+ def _mission_bool_input(body: dict[str, Any], key: str) -> bool:
1681
+ value = body.get(key)
1682
+ if isinstance(value, bool):
1683
+ return value
1684
+ if isinstance(value, str):
1685
+ return value.strip().lower() in {"1", "true", "yes", "on"}
1686
+ return False
1687
+
1688
+
1689
+ def _cdx_mission_prompt(
1690
+ mission_id: str,
1691
+ *,
1692
+ release_tag: str = "",
1693
+ wish_text: str = "",
1694
+ release_version: str = "",
1695
+ run_full_validation: bool = False,
1696
+ allow_file_writes: bool = False,
1697
+ direct_fixes: bool = False,
1698
+ commit_at_end: bool = False,
1699
+ ) -> str:
1700
+ write_guidance = (
1701
+ "File edits are allowed when they directly complete the selected mission mode. Keep changes scoped, run relevant validation, and report changed files."
1702
+ if allow_file_writes
1703
+ else "Do not modify files."
1704
+ )
1705
+ commit_guidance = (
1706
+ "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."
1707
+ if commit_at_end
1708
+ else "Do not create git commits."
1709
+ )
1710
+ if mission_id == "full-audit":
1711
+ if direct_fixes:
1712
+ 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."
1713
+ schema = "Return concise JSON with keys: summary, findings, directFixes, changedFiles, validationEvidence."
1714
+ elif allow_file_writes:
1715
+ 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."
1716
+ schema = "Return concise JSON with keys: summary, findings, recommendations, requestFiles, validationEvidence."
1717
+ else:
1718
+ action_guidance = "Report only; do not write corpus files, fix issues, or modify files."
1719
+ schema = "Return concise JSON with keys: summary, findings, recommendations."
1720
+ return "\n".join([
1721
+ "Run a full repository audit for this Logics Manager checkout.",
1722
+ "Focus on correctness bugs, workflow risks, missing validation, stale documentation, and test gaps.",
1723
+ write_guidance,
1724
+ action_guidance,
1725
+ commit_guidance,
1726
+ schema,
1727
+ ])
1728
+ if mission_id == "release-review":
1729
+ if direct_fixes:
1730
+ 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."
1731
+ schema = "Return concise JSON with keys: summary, findings, directFixes, changedFiles, validationEvidence."
1732
+ elif allow_file_writes:
1733
+ 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."
1734
+ schema = "Return concise JSON with keys: summary, findings, recommendations, requestFiles, validationEvidence."
1735
+ else:
1736
+ action_guidance = "Report only; do not update release files, write corpus files, fix issues, tag, push, publish, upload assets, or create GitHub releases."
1737
+ schema = "Return concise JSON with keys: summary, findings, recommendations."
1738
+ return "\n".join([
1739
+ f"Review repository changes since the latest release tag {release_tag}.",
1740
+ "Focus on regressions, incomplete release notes, migration risks, and missing tests.",
1741
+ write_guidance,
1742
+ action_guidance,
1743
+ commit_guidance,
1744
+ schema,
1745
+ ])
1746
+ if mission_id == "corpus-ready":
1747
+ return "\n".join([
1748
+ "Prepare the open Logics workflow corpus for development.",
1749
+ "Analyze requests, backlog items, tasks, docs, lint/audit state, and workflow consistency.",
1750
+ "Do not modify files directly. This mission is plan-first: return allowed actions for the viewer to apply explicitly.",
1751
+ "Do not run destructive commands.",
1752
+ "Return JSON only with this schema:",
1753
+ '{"summary":"...","actions":[{"type":"promote-request-to-backlog","target":"req_..."},{"type":"promote-backlog-to-task","target":"item_..."},{"type":"refresh-corpus-context","target":""}],"notes":["..."]}',
1754
+ "Allowed action types are exactly: promote-request-to-backlog, promote-backlog-to-task, refresh-corpus-context.",
1755
+ "Use only targets that exist in the repository. Omit actions that are not clearly justified.",
1756
+ ])
1757
+ if mission_id == "wish-to-request":
1758
+ request_guidance = (
1759
+ "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."
1760
+ if allow_file_writes
1761
+ else "Do not create the request file; return the request draft and generatedFiles preview only."
1762
+ )
1763
+ return "\n".join([
1764
+ "Turn the following user wish into a structured Logics request draft.",
1765
+ write_guidance,
1766
+ request_guidance,
1767
+ commit_guidance,
1768
+ "Do not promote backlog items and do not create tasks.",
1769
+ "Return JSON only with this schema:",
1770
+ '{"summary":"...","requestDraft":{"title":"...","needs":["..."],"context":["..."],"acceptanceCriteria":["AC1: ..."],"definitionOfReady":{"problemExplicit":true,"scopeBounded":true,"criteriaTestable":true,"risksListed":true},"references":["..."],"questions":["..."],"openAssumptions":["..."]},"generatedFiles":[]}',
1771
+ "If the wish is underspecified, include concrete questions and open assumptions instead of inventing details.",
1772
+ "User wish:",
1773
+ wish_text,
1774
+ ])
1775
+ if mission_id == "pre-release":
1776
+ 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."
1777
+ release_prep_guidance = (
1778
+ "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."
1779
+ if allow_file_writes
1780
+ else "Do not modify package versions, changelog files, create Git tags, push branches, publish packages, upload release assets, or create GitHub releases."
1781
+ )
1782
+ return "\n".join([
1783
+ f"Prepare a guarded pre-release for version {release_version}.",
1784
+ validation_mode,
1785
+ release_prep_guidance,
1786
+ write_guidance,
1787
+ commit_guidance,
1788
+ "Return JSON only with this schema:",
1789
+ '{"summary":"...","version":"vX.X.X","validationMode":"full|plan-only","validationEvidence":["..."],"actionableFixes":[{"title":"...","command":"...","risk":"..."}],"generatedFiles":[{"path":"...","purpose":"..."}],"releasePlan":["..."],"blocked":false}',
1790
+ ])
1791
+ raise ValueError("Unknown CDX mission.")
1792
+
1793
+
1794
+ def _cdx_mission_timeout(strength: dict[str, Any], *, allow_file_writes: bool = False, commit_at_end: bool = False) -> int:
1795
+ timeout = int(strength.get("timeout") or 180)
1796
+ if allow_file_writes or commit_at_end:
1797
+ return max(timeout, CDX_WRITABLE_MISSION_MIN_TIMEOUT_SECONDS)
1798
+ return timeout
1799
+
1800
+
1801
+ def _cdx_mission_command(
1802
+ repo_root: Path,
1803
+ mission_id: str,
1804
+ *,
1805
+ session: str,
1806
+ strength: dict[str, Any],
1807
+ release_tag: str = "",
1808
+ mission_inputs: dict[str, str] | None = None,
1809
+ allow_file_writes: bool = False,
1810
+ commit_at_end: bool = False,
1811
+ ) -> list[str]:
1812
+ mission_inputs = mission_inputs or {}
1813
+ prompt = _cdx_mission_prompt(
1814
+ mission_id,
1815
+ release_tag=release_tag,
1816
+ wish_text=mission_inputs.get("wishText", ""),
1817
+ release_version=mission_inputs.get("releaseVersion", ""),
1818
+ run_full_validation=mission_inputs.get("runFullValidation") == "true",
1819
+ allow_file_writes=allow_file_writes,
1820
+ direct_fixes=mission_inputs.get("directFixes") == "true",
1821
+ commit_at_end=commit_at_end,
1822
+ )
1823
+ timeout = _cdx_mission_timeout(strength, allow_file_writes=allow_file_writes, commit_at_end=commit_at_end)
1824
+ reasoning_effort = str(strength.get("reasoningEffort") or "medium")
1825
+ power = str(strength.get("power") or "medium")
1826
+ permission = "workspace-write" if allow_file_writes else "read-only"
1827
+ return [
1828
+ "run",
1829
+ session,
1830
+ "--cwd",
1831
+ str(repo_root),
1832
+ "--prompt",
1833
+ prompt,
1834
+ "--kind",
1835
+ "assistant",
1836
+ "--reasoning-effort",
1837
+ reasoning_effort,
1838
+ "--power",
1839
+ power,
1840
+ "--permission",
1841
+ permission,
1842
+ "--timeout-seconds",
1843
+ str(timeout),
1844
+ "--json",
1845
+ ]
1846
+
1847
+
1848
+ def _parse_json_from_text(text: str) -> dict[str, Any] | None:
1849
+ raw = text.strip()
1850
+ if not raw:
1851
+ return None
1852
+ jsonl_candidates: list[str] = []
1853
+ for line in reversed(raw.splitlines()):
1854
+ line = line.strip()
1855
+ if not line.startswith("{"):
1856
+ continue
1857
+ try:
1858
+ event = json.loads(line)
1859
+ except json.JSONDecodeError:
1860
+ continue
1861
+ if not isinstance(event, dict):
1862
+ continue
1863
+ item = event.get("item") if isinstance(event.get("item"), dict) else {}
1864
+ text_value = item.get("text") if item.get("type") == "agent_message" else event.get("text")
1865
+ if isinstance(text_value, str) and text_value.strip():
1866
+ jsonl_candidates.append(text_value.strip())
1867
+ candidates = [raw]
1868
+ candidates.extend(jsonl_candidates)
1869
+ fence_match = re.search(r"```(?:json)?\s*(.*?)```", raw, re.IGNORECASE | re.DOTALL)
1870
+ if fence_match:
1871
+ candidates.insert(0, fence_match.group(1).strip())
1872
+ decoder = json.JSONDecoder()
1873
+ fallback: dict[str, Any] | None = None
1874
+ for candidate in candidates:
1875
+ try:
1876
+ parsed = json.loads(candidate)
1877
+ if isinstance(parsed, dict):
1878
+ if any(key in parsed for key in ("actions", "summary", "findings", "recommendations")):
1879
+ return parsed
1880
+ fallback = fallback or parsed
1881
+ except json.JSONDecodeError:
1882
+ pass
1883
+ for index, char in enumerate(candidate):
1884
+ if char != "{":
1885
+ continue
1886
+ try:
1887
+ parsed, _end = decoder.raw_decode(candidate[index:])
1888
+ except json.JSONDecodeError:
1889
+ continue
1890
+ if isinstance(parsed, dict):
1891
+ if any(key in parsed for key in ("actions", "summary", "findings", "recommendations")):
1892
+ return parsed
1893
+ fallback = fallback or parsed
1894
+ return fallback
1895
+
1896
+
1897
+ def _read_cdx_output_path(parsed: dict[str, Any]) -> str:
1898
+ candidates = [
1899
+ parsed.get("stdout"),
1900
+ parsed.get("output"),
1901
+ ]
1902
+ artifacts = parsed.get("artifacts") if isinstance(parsed.get("artifacts"), dict) else {}
1903
+ candidates.extend([
1904
+ parsed.get("stdout_path"),
1905
+ parsed.get("stdoutPath"),
1906
+ artifacts.get("stdout_path"),
1907
+ artifacts.get("stdoutPath"),
1908
+ ])
1909
+ for candidate in candidates:
1910
+ if not isinstance(candidate, str) or not candidate.strip():
1911
+ continue
1912
+ value = candidate.strip()
1913
+ if "\n" in value or value.lstrip().startswith("{") or value.lstrip().startswith("```"):
1914
+ return value[:12000]
1915
+ path = Path(value).expanduser()
1916
+ if not path.is_file():
1917
+ continue
1918
+ try:
1919
+ with path.open("rb") as handle:
1920
+ size = path.stat().st_size
1921
+ if size > 60000:
1922
+ handle.seek(size - 60000)
1923
+ return handle.read(60000).decode("utf-8", errors="replace")
1924
+ except OSError:
1925
+ continue
1926
+ return ""
1927
+
1928
+
1929
+ def _merge_cdx_mission_output(parsed: Any) -> dict[str, Any] | None:
1930
+ if not isinstance(parsed, dict):
1931
+ return None
1932
+ merged = dict(parsed)
1933
+ embedded = _parse_json_from_text(_read_cdx_output_path(parsed))
1934
+ if embedded:
1935
+ merged["missionOutput"] = embedded
1936
+ if isinstance(embedded.get("actions"), list) and "actions" not in merged:
1937
+ merged["actions"] = embedded["actions"]
1938
+ if "summary" in embedded and "summary" not in merged:
1939
+ merged["summary"] = embedded["summary"]
1940
+ return merged
1941
+
1942
+
1943
+ def _extract_cdx_usage(parsed: Any) -> dict[str, Any]:
1944
+ if not isinstance(parsed, dict):
1945
+ return {"available": False, "message": "CDX did not return structured usage."}
1946
+ candidates = [
1947
+ parsed.get("usage"),
1948
+ parsed.get("tokenUsage"),
1949
+ parsed.get("tokens"),
1950
+ (parsed.get("run") or {}).get("usage") if isinstance(parsed.get("run"), dict) else None,
1951
+ (parsed.get("result") or {}).get("usage") if isinstance(parsed.get("result"), dict) else None,
1952
+ ]
1953
+ usage = next((candidate for candidate in candidates if isinstance(candidate, dict)), None)
1954
+ if usage is None:
1955
+ return {"available": False, "message": "Token usage was not exposed by CDX for this run."}
1956
+ input_tokens = usage.get("input_tokens", usage.get("inputTokens", usage.get("prompt_tokens", usage.get("promptTokens"))))
1957
+ output_tokens = usage.get("output_tokens", usage.get("outputTokens", usage.get("completion_tokens", usage.get("completionTokens"))))
1958
+ total_tokens = usage.get("total_tokens", usage.get("totalTokens"))
1959
+ if total_tokens is None and isinstance(input_tokens, int) and isinstance(output_tokens, int):
1960
+ total_tokens = input_tokens + output_tokens
1961
+ return {
1962
+ "available": True,
1963
+ "inputTokens": input_tokens,
1964
+ "outputTokens": output_tokens,
1965
+ "totalTokens": total_tokens,
1966
+ "raw": usage,
1967
+ }
1968
+
1969
+
1970
+ def _bounded_process_text(value: str, limit: int = 12000) -> str:
1971
+ text = value.strip()
1972
+ if len(text) <= limit:
1973
+ return text
1974
+ return f"{text[:limit]}\n... truncated ..."
1975
+
1976
+
1977
+ def cdx_mission_plan_payload(
1978
+ repo_root: Path,
1979
+ body: dict[str, Any],
1980
+ *,
1981
+ cdx_runner: Any | None = None,
1982
+ git_runner: Any | None = None,
1983
+ which: Any | None = None,
1984
+ ) -> dict[str, Any]:
1985
+ tool_which = which or shutil.which
1986
+ if not tool_which("cdx"):
1987
+ return {"state": "unavailable", "message": "CDX executable is not available on PATH.", "plan": None}
1988
+ mission_id = str(body.get("missionId") or CDX_DEFAULT_MISSION_ID)
1989
+ mission = CDX_MISSION_CATALOG.get(mission_id)
1990
+ if mission is None:
1991
+ return {"state": "error", "message": "Unknown CDX mission.", "plan": None}
1992
+ strength = str(body.get("strengthId") or "standard")
1993
+ strength_def = CDX_MISSION_STRENGTHS.get(strength)
1994
+ if strength_def is None:
1995
+ return {"state": "error", "message": "Unknown CDX mission strength.", "plan": None}
1996
+
1997
+ status_payload = cdx_status_payload(repo_root, runner=cdx_runner, which=which)
1998
+ session = _normalize_cdx_session(body.get("sessionId"), status_payload if status_payload.get("state") == "ok" else None)
1999
+ if not session:
2000
+ sessions = _cdx_status_sessions(status_payload)
2001
+ session = sessions[0] if sessions else ""
2002
+ if not session:
2003
+ return {"state": "error", "message": "No usable CDX session is available.", "plan": None, "status": status_payload}
2004
+
2005
+ release_tag = ""
2006
+ warnings: list[str] = []
2007
+ mission_inputs: dict[str, str] = {}
2008
+ if mission_id == "wish-to-request":
2009
+ wish_text = _mission_text_input(body, "wishText")
2010
+ if not wish_text:
2011
+ return {"state": "error", "message": "Enter a wish or intent before previewing this mission.", "plan": None, "catalog": cdx_mission_catalog_payload(), "status": status_payload}
2012
+ mission_inputs["wishText"] = wish_text
2013
+ if mission_id in {"full-audit", "release-review"}:
2014
+ mission_inputs["directFixes"] = "true" if _mission_bool_input(body, "directFixes") else "false"
2015
+ if mission_id == "pre-release":
2016
+ release_version = _mission_text_input(body, "releaseVersion", max_chars=40)
2017
+ if not re.fullmatch(r"v\d+\.\d+\.\d+", release_version):
2018
+ 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}
2019
+ mission_inputs["releaseVersion"] = release_version
2020
+ mission_inputs["runFullValidation"] = "true" if _mission_bool_input(body, "runFullValidation") else "false"
2021
+ if mission.get("requiresReleaseTag"):
2022
+ release_tag = _latest_release_tag(repo_root, runner=git_runner, which=which)
2023
+ if not release_tag:
2024
+ return {"state": "error", "message": "No release tag was found for this mission.", "plan": None, "status": status_payload}
2025
+ if status_payload.get("state") != "ok":
2026
+ warnings.append(str(status_payload.get("message") or "CDX status could not be confirmed."))
2027
+
2028
+ requested_file_writes = _mission_bool_input(body, "allowFileWrites")
2029
+ requested_commit_at_end = _mission_bool_input(body, "commitAtEnd")
2030
+ direct_fixes = mission_inputs.get("directFixes") == "true"
2031
+ supports_file_writes = bool(mission.get("supportsFileWrites", True))
2032
+ allow_file_writes = (requested_file_writes or direct_fixes) and supports_file_writes
2033
+ commit_at_end = requested_commit_at_end and allow_file_writes
2034
+ if requested_file_writes and not supports_file_writes:
2035
+ warnings.append("This mission is plan-first; direct CDX file writes are disabled. Use Apply allowed actions after CDX returns actions.")
2036
+ if requested_commit_at_end and not allow_file_writes:
2037
+ warnings.append("Commit-at-end was requested but direct file writes are disabled for this mission.")
2038
+ permission = "workspace-write" if allow_file_writes else "read-only"
2039
+ command = _cdx_mission_command(
2040
+ repo_root,
2041
+ mission_id,
2042
+ session=session,
2043
+ strength=strength_def,
2044
+ release_tag=release_tag,
2045
+ mission_inputs=mission_inputs,
2046
+ allow_file_writes=allow_file_writes,
2047
+ commit_at_end=commit_at_end,
2048
+ )
2049
+ plan = {
2050
+ "mission": mission,
2051
+ "missionId": mission_id,
2052
+ "sessionId": session,
2053
+ "strength": strength_def,
2054
+ "strengthId": strength,
2055
+ "missionInputs": mission_inputs,
2056
+ "scope": mission["scope"],
2057
+ "releaseTag": release_tag,
2058
+ "allowFileWrites": allow_file_writes,
2059
+ "requestedFileWrites": requested_file_writes,
2060
+ "commitAtEnd": commit_at_end,
2061
+ "requestedCommitAtEnd": requested_commit_at_end,
2062
+ "supportsFileWrites": supports_file_writes,
2063
+ "permission": permission,
2064
+ "timeoutSeconds": _cdx_mission_timeout(strength_def, allow_file_writes=allow_file_writes, commit_at_end=commit_at_end),
2065
+ "command": ["cdx", *command],
2066
+ "arguments": command,
2067
+ "warnings": warnings,
2068
+ "requiresConfirmation": bool(mission.get("requiresPlanConfirmation")),
2069
+ "canRun": True,
2070
+ }
2071
+ if mission_id == "corpus-ready":
2072
+ plan["allowedPlanActions"] = [
2073
+ "promote-request-to-backlog",
2074
+ "promote-backlog-to-task",
2075
+ "refresh-corpus-context",
2076
+ ]
2077
+ return {"state": "ok", "message": "", "plan": plan, "catalog": cdx_mission_catalog_payload(), "status": status_payload}
2078
+
2079
+
2080
+ def cdx_mission_run_payload(
2081
+ repo_root: Path,
2082
+ body: dict[str, Any],
2083
+ *,
2084
+ cdx_runner: Any | None = None,
2085
+ git_runner: Any | None = None,
2086
+ which: Any | None = None,
2087
+ ) -> dict[str, Any]:
2088
+ plan_payload = cdx_mission_plan_payload(repo_root, body, cdx_runner=cdx_runner, git_runner=git_runner, which=which)
2089
+ if plan_payload.get("state") != "ok":
2090
+ 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}
2091
+ plan = plan_payload["plan"]
2092
+ timeout = int(plan.get("timeoutSeconds") or plan["strength"].get("timeout") or 180)
2093
+ process_timeout = timeout + CDX_MISSION_PARENT_TIMEOUT_GRACE_SECONDS
2094
+ try:
2095
+ result = _run_cdx_mission(repo_root, list(plan["arguments"]), timeout=process_timeout, runner=cdx_runner)
2096
+ except subprocess.TimeoutExpired:
2097
+ return {"state": "timeout", "message": "CDX mission timed out.", "plan": plan, "run": None}
2098
+ except (OSError, subprocess.SubprocessError) as exc:
2099
+ return {"state": "error", "message": f"Unable to run CDX mission: {exc}", "plan": plan, "run": None}
2100
+
2101
+ parsed: Any = None
2102
+ if result.stdout.strip():
2103
+ try:
2104
+ parsed = json.loads(result.stdout)
2105
+ except json.JSONDecodeError:
2106
+ parsed = None
2107
+ parsed = _merge_cdx_mission_output(parsed)
2108
+ usage = _extract_cdx_usage(parsed)
2109
+ run_id = ""
2110
+ if isinstance(parsed, dict):
2111
+ run = parsed.get("run") if isinstance(parsed.get("run"), dict) else {}
2112
+ run_id = str(parsed.get("run_id") or parsed.get("runId") or run.get("run_id") or run.get("runId") or "")
2113
+ run_payload = {
2114
+ "returnCode": result.returncode,
2115
+ "runId": run_id,
2116
+ "stdout": _bounded_process_text(result.stdout or ""),
2117
+ "stderr": _bounded_process_text(result.stderr or ""),
2118
+ "parsed": parsed if isinstance(parsed, dict) else None,
2119
+ "usage": usage,
2120
+ }
2121
+ if result.returncode != 0:
2122
+ message = (result.stderr or result.stdout or "CDX mission failed.").strip().splitlines()[0]
2123
+ return {"state": "error", "message": message, "plan": plan, "run": run_payload}
2124
+ return {"state": "ok", "message": "", "plan": plan, "run": run_payload}
2125
+
2126
+
2127
+ def cdx_mission_apply_plan_payload(repo_root: Path, body: dict[str, Any], *, runner: Any | None = None, which: Any | None = None) -> dict[str, Any]:
2128
+ tool_which = which or shutil.which
2129
+ if not tool_which("logics-manager"):
2130
+ return {"state": "unavailable", "message": "logics-manager executable is not available on PATH.", "results": []}
2131
+ actions = body.get("actions") if isinstance(body.get("actions"), list) else []
2132
+ if not actions:
2133
+ return {"state": "error", "message": "No corpus plan actions were provided.", "results": []}
2134
+
2135
+ allowed: dict[str, list[str]] = {
2136
+ "promote-request-to-backlog": ["flow", "promote", "request-to-backlog"],
2137
+ "promote-backlog-to-task": ["flow", "promote", "backlog-to-task"],
2138
+ "refresh-corpus-context": ["sync", "refresh-mermaid-signatures"],
2139
+ }
2140
+ results: list[dict[str, Any]] = []
2141
+ for action in actions:
2142
+ if not isinstance(action, dict):
2143
+ return {"state": "error", "message": "Corpus plan actions must be objects.", "results": results}
2144
+ action_type = str(action.get("type") or "")
2145
+ command = allowed.get(action_type)
2146
+ if command is None:
2147
+ return {"state": "error", "message": f"Unsupported corpus plan action: {action_type}", "results": results}
2148
+ target = str(action.get("target") or "").strip()
2149
+ args = [*command]
2150
+ if target and action_type != "refresh-corpus-context":
2151
+ if not re.match(r"^[A-Za-z0-9_.:/-]{1,160}$", target):
2152
+ return {"state": "error", "message": "Invalid corpus plan action target.", "results": results}
2153
+ args.append(target)
2154
+ try:
2155
+ result = _run_logics_command(repo_root, args, runner=runner)
2156
+ except subprocess.TimeoutExpired:
2157
+ return {"state": "timeout", "message": "Logics corpus plan application timed out.", "results": results}
2158
+ except (OSError, subprocess.SubprocessError) as exc:
2159
+ return {"state": "error", "message": f"Unable to apply corpus plan action: {exc}", "results": results}
2160
+ item = {
2161
+ "type": action_type,
2162
+ "target": target,
2163
+ "command": ["logics-manager", *args],
2164
+ "returnCode": result.returncode,
2165
+ "stdout": _bounded_process_text(result.stdout or "", 4000),
2166
+ "stderr": _bounded_process_text(result.stderr or "", 4000),
2167
+ }
2168
+ results.append(item)
2169
+ if result.returncode != 0:
2170
+ message = (result.stderr or result.stdout or "Corpus plan action failed.").strip().splitlines()[0]
2171
+ return {"state": "error", "message": message, "results": results}
2172
+ return {"state": "ok", "message": "", "results": results}
2173
+
2174
+
1147
2175
  def _slugify_viewer_doc(text: str) -> str:
1148
2176
  slug = re.sub(r"[^a-z0-9]+", "_", text.lower()).strip("_")
1149
2177
  return slug[:80] or "cdx_code_review_findings"
@@ -1164,17 +2192,68 @@ def create_request_from_cdx_report(repo_root: Path, report_payload: dict[str, An
1164
2192
  report = report_payload.get("report") if isinstance(report_payload.get("report"), dict) else report_payload
1165
2193
  run = report.get("run") if isinstance(report.get("run"), dict) else {}
1166
2194
  task_report = report.get("task_report") if isinstance(report.get("task_report"), dict) else {}
2195
+ parsed = report.get("parsed") if isinstance(report.get("parsed"), dict) else {}
2196
+ mission_output = next(
2197
+ (
2198
+ candidate
2199
+ for candidate in (
2200
+ report.get("missionOutput"),
2201
+ report.get("mission_output"),
2202
+ parsed.get("missionOutput"),
2203
+ parsed.get("mission_output"),
2204
+ run.get("missionOutput"),
2205
+ run.get("mission_output"),
2206
+ task_report.get("missionOutput"),
2207
+ task_report.get("mission_output"),
2208
+ )
2209
+ if isinstance(candidate, dict)
2210
+ ),
2211
+ {},
2212
+ )
1167
2213
  run_id = str(run.get("run_id") or task_report.get("run_id") or "unknown")
2214
+ task_kind = str(task_report.get("kind") or run.get("kind") or "assistant")
1168
2215
  findings = task_report.get("findings") if isinstance(task_report.get("findings"), list) else []
1169
- title = f"Address CDX code review findings for {run_id}"
2216
+ if not findings and isinstance(mission_output.get("findings"), list):
2217
+ findings = mission_output["findings"]
2218
+ recommendations = mission_output.get("recommendations") if isinstance(mission_output.get("recommendations"), list) else []
2219
+ request_files = mission_output.get("requestFiles") if isinstance(mission_output.get("requestFiles"), list) else []
2220
+ actionable_fixes = mission_output.get("actionableFixes") if isinstance(mission_output.get("actionableFixes"), list) else []
2221
+ release_plan = mission_output.get("releasePlan") if isinstance(mission_output.get("releasePlan"), list) else []
2222
+ if task_kind == "code-review":
2223
+ title = f"Address CDX code review findings for {run_id}"
2224
+ theme = "Code review follow-up"
2225
+ need = f"Follow up on CDX code-review run `{run_id}`."
2226
+ elif task_kind == "full-audit":
2227
+ title = f"Address CDX audit findings for {run_id}"
2228
+ theme = "Audit follow-up"
2229
+ need = f"Follow up on CDX full-audit run `{run_id}`."
2230
+ else:
2231
+ title = f"Address CDX {task_kind} follow-up for {run_id}"
2232
+ theme = "CDX mission follow-up"
2233
+ need = f"Follow up on CDX `{task_kind}` run `{run_id}`."
1170
2234
  ref = _next_viewer_request_ref(repo_root, title)
1171
2235
  request_dir = repo_root / "logics" / "request"
1172
2236
  request_dir.mkdir(parents=True, exist_ok=True)
1173
2237
  rel_path = f"logics/request/{ref}.md"
1174
2238
  path = repo_root / rel_path
2239
+
2240
+ def _item_message(item: Any, fallback: str) -> str:
2241
+ if isinstance(item, dict):
2242
+ title_value = item.get("title") or item.get("message") or item.get("summary") or item.get("path") or fallback
2243
+ details = []
2244
+ if item.get("purpose"):
2245
+ details.append(f"purpose: {item['purpose']}")
2246
+ if item.get("command"):
2247
+ details.append(f"command: `{item['command']}`")
2248
+ if item.get("risk"):
2249
+ details.append(f"risk: {item['risk']}")
2250
+ return f"{title_value}" + (f" ({'; '.join(details)})" if details else "")
2251
+ return str(item or fallback)
2252
+
1175
2253
  finding_lines = []
1176
2254
  for index, finding in enumerate(findings, start=1):
1177
2255
  if not isinstance(finding, dict):
2256
+ finding_lines.append(f"- F{index}: {finding}")
1178
2257
  continue
1179
2258
  location = finding.get("path") or finding.get("file") or "unknown path"
1180
2259
  if finding.get("line"):
@@ -1184,21 +2263,36 @@ def create_request_from_cdx_report(repo_root: Path, report_payload: dict[str, An
1184
2263
  finding_lines.append(f"- F{index} [{severity}] `{location}`: {message}")
1185
2264
  if not finding_lines:
1186
2265
  finding_lines.append("- No structured findings were reported. Review the CDX artifacts linked below.")
2266
+ follow_up_lines = []
2267
+ for label, values in (
2268
+ ("Recommendation", recommendations),
2269
+ ("Request file", request_files),
2270
+ ("Actionable fix", actionable_fixes),
2271
+ ("Release plan", release_plan),
2272
+ ):
2273
+ for index, value in enumerate(values, start=1):
2274
+ follow_up_lines.append(f"- {label} {index}: {_item_message(value, label)}")
2275
+ if not follow_up_lines:
2276
+ follow_up_lines.append("- Review CDX output and split any actionable follow-up into tasks before implementation.")
2277
+ summary = task_report.get("summary") or mission_output.get("summary") or "No structured summary provided."
1187
2278
  text = "\n".join([
1188
2279
  f"## {ref} - {title}",
1189
2280
  "> Status: Draft",
1190
2281
  "> Understanding: 70%",
1191
2282
  "> Confidence: 70%",
1192
2283
  "> Complexity: Medium",
1193
- "> Theme: Code review follow-up",
2284
+ f"> Theme: {theme}",
1194
2285
  "",
1195
2286
  "# Needs",
1196
- f"- Follow up on CDX code-review run `{run_id}`.",
1197
- f"- Summary: {task_report.get('summary') or 'No structured summary provided.'}",
2287
+ f"- {need}",
2288
+ f"- Summary: {summary}",
1198
2289
  "",
1199
2290
  "# Findings",
1200
2291
  *finding_lines,
1201
2292
  "",
2293
+ "# Follow-up",
2294
+ *follow_up_lines,
2295
+ "",
1202
2296
  "# Traceability",
1203
2297
  f"- CDX run id: `{run_id}`",
1204
2298
  f"- Transcript: `{(report.get('artifacts') or {}).get('transcript_path') or ''}`",
@@ -1224,6 +2318,7 @@ class LogicsViewerServer(ThreadingHTTPServer):
1224
2318
  repo_root: Path,
1225
2319
  *,
1226
2320
  auto_refresh_interval_seconds: int = 15,
2321
+ auto_refresh_interval_forced: bool = False,
1227
2322
  ):
1228
2323
  self.launch_repo_root = repo_root.resolve()
1229
2324
  self.project_roots = discover_viewer_project_roots(self.launch_repo_root)
@@ -1231,6 +2326,7 @@ class LogicsViewerServer(ThreadingHTTPServer):
1231
2326
  self.active_project_id = _viewer_project_id(self.launch_repo_root)
1232
2327
  self.repo_root = self.launch_repo_root
1233
2328
  self.auto_refresh_interval_seconds = auto_refresh_interval_seconds
2329
+ self.auto_refresh_interval_forced = auto_refresh_interval_forced
1234
2330
  super().__init__(server_address, LogicsViewerRequestHandler)
1235
2331
 
1236
2332
  def project_registry_payload(self) -> list[dict[str, Any]]:
@@ -1241,6 +2337,7 @@ class LogicsViewerServer(ThreadingHTTPServer):
1241
2337
  self.repo_root,
1242
2338
  selected_id=selected_id,
1243
2339
  auto_refresh_interval_seconds=self.auto_refresh_interval_seconds,
2340
+ auto_refresh_interval_forced=self.auto_refresh_interval_forced,
1244
2341
  projects=self.project_registry_payload(),
1245
2342
  )
1246
2343
 
@@ -1362,6 +2459,37 @@ class LogicsViewerRequestHandler(BaseHTTPRequestHandler):
1362
2459
  cached = params.get("cached", [""])[0].lower() in {"1", "true", "yes"}
1363
2460
  self._send_json({"ok": True, "payload": git_diff_payload(self.server.repo_root, rel_path, cached=cached)})
1364
2461
  return
2462
+ if route == "/api/git-file-preview":
2463
+ params = parse_qs(parsed.query)
2464
+ rel_path = params.get("path", [""])[0]
2465
+ self._send_json({"ok": True, "payload": git_file_preview_payload(self.server.repo_root, rel_path)})
2466
+ return
2467
+ if route == "/api/workspace-tree":
2468
+ rel_path = parse_qs(parsed.query).get("path", [""])[0]
2469
+ try:
2470
+ self._send_json({"ok": True, "payload": workspace_tree_payload(self.server.repo_root, rel_path)})
2471
+ except ValueError as exc:
2472
+ self._send_error_json(HTTPStatus.FORBIDDEN, str(exc))
2473
+ return
2474
+ if route == "/api/workspace-preview":
2475
+ rel_path = parse_qs(parsed.query).get("path", [""])[0]
2476
+ try:
2477
+ self._send_json({"ok": True, "payload": workspace_preview_payload(self.server.repo_root, rel_path)})
2478
+ except ValueError as exc:
2479
+ self._send_error_json(HTTPStatus.FORBIDDEN, str(exc))
2480
+ return
2481
+ if route == "/api/workspace-file":
2482
+ rel_path = parse_qs(parsed.query).get("path", [""])[0]
2483
+ try:
2484
+ payload = workspace_preview_payload(self.server.repo_root, rel_path)
2485
+ if payload.get("state") != "image":
2486
+ self._send_error_json(HTTPStatus.BAD_REQUEST, "Workspace file is not an image preview.")
2487
+ return
2488
+ _normalized, absolute = _resolve_workspace_path(self.server.repo_root, rel_path)
2489
+ self._serve_file(absolute)
2490
+ except (FileNotFoundError, ValueError) as exc:
2491
+ self._send_error_json(HTTPStatus.NOT_FOUND, str(exc))
2492
+ return
1365
2493
  self._send_error_json(HTTPStatus.NOT_FOUND, "Not found")
1366
2494
 
1367
2495
  def do_POST(self) -> None:
@@ -1413,6 +2541,33 @@ class LogicsViewerRequestHandler(BaseHTTPRequestHandler):
1413
2541
  except OSError as exc:
1414
2542
  self._send_error_json(HTTPStatus.INTERNAL_SERVER_ERROR, str(exc))
1415
2543
  return
2544
+ if parsed.path == "/api/cdx-mission-plan":
2545
+ try:
2546
+ length = int(self.headers.get("Content-Length", "0") or "0")
2547
+ raw_body = self.rfile.read(length).decode("utf-8") if length > 0 else "{}"
2548
+ body = json.loads(raw_body or "{}")
2549
+ self._send_json({"ok": True, "payload": cdx_mission_plan_payload(self.server.repo_root, body)})
2550
+ except json.JSONDecodeError:
2551
+ self._send_error_json(HTTPStatus.BAD_REQUEST, "Invalid JSON body.")
2552
+ return
2553
+ if parsed.path == "/api/cdx-mission-run":
2554
+ try:
2555
+ length = int(self.headers.get("Content-Length", "0") or "0")
2556
+ raw_body = self.rfile.read(length).decode("utf-8") if length > 0 else "{}"
2557
+ body = json.loads(raw_body or "{}")
2558
+ self._send_json({"ok": True, "payload": cdx_mission_run_payload(self.server.repo_root, body)})
2559
+ except json.JSONDecodeError:
2560
+ self._send_error_json(HTTPStatus.BAD_REQUEST, "Invalid JSON body.")
2561
+ return
2562
+ if parsed.path == "/api/cdx-mission-apply-plan":
2563
+ try:
2564
+ length = int(self.headers.get("Content-Length", "0") or "0")
2565
+ raw_body = self.rfile.read(length).decode("utf-8") if length > 0 else "{}"
2566
+ body = json.loads(raw_body or "{}")
2567
+ self._send_json({"ok": True, "payload": cdx_mission_apply_plan_payload(self.server.repo_root, body)})
2568
+ except json.JSONDecodeError:
2569
+ self._send_error_json(HTTPStatus.BAD_REQUEST, "Invalid JSON body.")
2570
+ return
1416
2571
  if parsed.path == "/api/edit":
1417
2572
  rel_path = parse_qs(parsed.query).get("path", [""])[0]
1418
2573
  try:
@@ -1422,6 +2577,32 @@ class LogicsViewerRequestHandler(BaseHTTPRequestHandler):
1422
2577
  except OSError as exc:
1423
2578
  self._send_error_json(HTTPStatus.INTERNAL_SERVER_ERROR, str(exc))
1424
2579
  return
2580
+ if parsed.path == "/api/open-file":
2581
+ try:
2582
+ length = int(self.headers.get("Content-Length", "0") or "0")
2583
+ raw_body = self.rfile.read(length).decode("utf-8") if length > 0 else "{}"
2584
+ body = json.loads(raw_body or "{}")
2585
+ self._send_json({"ok": True, "payload": open_file_payload(self.server.repo_root, str(body.get("path", "")))})
2586
+ except json.JSONDecodeError:
2587
+ self._send_error_json(HTTPStatus.BAD_REQUEST, "Invalid JSON body.")
2588
+ except (FileNotFoundError, ValueError) as exc:
2589
+ self._send_error_json(HTTPStatus.NOT_FOUND, str(exc))
2590
+ except OSError as exc:
2591
+ self._send_error_json(HTTPStatus.INTERNAL_SERVER_ERROR, str(exc))
2592
+ return
2593
+ if parsed.path == "/api/file-preview":
2594
+ try:
2595
+ length = int(self.headers.get("Content-Length", "0") or "0")
2596
+ raw_body = self.rfile.read(length).decode("utf-8") if length > 0 else "{}"
2597
+ body = json.loads(raw_body or "{}")
2598
+ self._send_json({"ok": True, "payload": file_preview_payload(self.server.repo_root, str(body.get("path", "")))})
2599
+ except json.JSONDecodeError:
2600
+ self._send_error_json(HTTPStatus.BAD_REQUEST, "Invalid JSON body.")
2601
+ except (FileNotFoundError, ValueError) as exc:
2602
+ self._send_error_json(HTTPStatus.NOT_FOUND, str(exc))
2603
+ except OSError as exc:
2604
+ self._send_error_json(HTTPStatus.INTERNAL_SERVER_ERROR, str(exc))
2605
+ return
1425
2606
  if parsed.path == "/api/open-repo-folder":
1426
2607
  try:
1427
2608
  self._send_json({"ok": True, "payload": open_repo_folder_payload(self.server.repo_root)})
@@ -1437,11 +2618,13 @@ def create_viewer_server(
1437
2618
  port: int = 8765,
1438
2619
  *,
1439
2620
  auto_refresh_interval_seconds: int = 15,
2621
+ auto_refresh_interval_forced: bool = False,
1440
2622
  ) -> LogicsViewerServer:
1441
2623
  return LogicsViewerServer(
1442
2624
  (host, port),
1443
2625
  repo_root,
1444
2626
  auto_refresh_interval_seconds=auto_refresh_interval_seconds,
2627
+ auto_refresh_interval_forced=auto_refresh_interval_forced,
1445
2628
  )
1446
2629
 
1447
2630
 
@@ -1489,7 +2672,7 @@ def build_parser() -> argparse.ArgumentParser:
1489
2672
  parser.add_argument(
1490
2673
  "--refresh-interval",
1491
2674
  type=int,
1492
- default=15,
2675
+ default=None,
1493
2676
  help="Automatic refresh interval in seconds. Defaults to 15; positive intervals are allowed.",
1494
2677
  )
1495
2678
  parser.add_argument("--focus", help="Open the viewer focused on a workflow ref or repo-relative Logics Markdown path.")
@@ -1502,7 +2685,9 @@ def build_parser() -> argparse.ArgumentParser:
1502
2685
  def main(argv: list[str]) -> int:
1503
2686
  args = build_parser().parse_args(argv)
1504
2687
  repo_root = find_repo_root(Path.cwd())
1505
- if args.refresh_interval <= 0:
2688
+ refresh_interval_forced = args.refresh_interval is not None
2689
+ refresh_interval = args.refresh_interval if args.refresh_interval is not None else 15
2690
+ if refresh_interval <= 0:
1506
2691
  raise SystemExit("--refresh-interval must be a positive number of seconds.")
1507
2692
  if args.read and not args.focus:
1508
2693
  raise SystemExit("--read requires --focus.")
@@ -1514,7 +2699,8 @@ def main(argv: list[str]) -> int:
1514
2699
  repo_root,
1515
2700
  host=args.host,
1516
2701
  port=args.port,
1517
- auto_refresh_interval_seconds=args.refresh_interval,
2702
+ auto_refresh_interval_seconds=refresh_interval,
2703
+ auto_refresh_interval_forced=refresh_interval_forced,
1518
2704
  )
1519
2705
  host, port = server.server_address[:2]
1520
2706
  url = build_viewer_url(str(host), int(port), focus=focus, read=bool(args.read))
@@ -1526,7 +2712,7 @@ def main(argv: list[str]) -> int:
1526
2712
  focus=focus,
1527
2713
  network_url=network_url,
1528
2714
  bind_host=str(host),
1529
- auto_refresh_interval_seconds=args.refresh_interval,
2715
+ auto_refresh_interval_seconds=refresh_interval,
1530
2716
  ),
1531
2717
  flush=True,
1532
2718
  )