@grifhinz/logics-manager 2.2.0 → 2.3.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.
Files changed (43) hide show
  1. package/README.md +95 -1
  2. package/VERSION +1 -1
  3. package/clients/README.md +9 -0
  4. package/clients/shared-web/media/css/board.css +658 -0
  5. package/clients/shared-web/media/css/details.css +457 -0
  6. package/clients/shared-web/media/css/layout.css +123 -0
  7. package/clients/shared-web/media/css/toolbar.css +576 -0
  8. package/clients/shared-web/media/harnessApi.js +324 -0
  9. package/clients/shared-web/media/hostApi.js +213 -0
  10. package/clients/shared-web/media/hostApiContract.js +55 -0
  11. package/clients/shared-web/media/icon.png +0 -0
  12. package/clients/shared-web/media/layoutController.js +246 -0
  13. package/clients/shared-web/media/logics.svg +7 -0
  14. package/clients/shared-web/media/logicsModel.js +910 -0
  15. package/clients/shared-web/media/main.css +112 -0
  16. package/clients/shared-web/media/main.js +3 -0
  17. package/clients/shared-web/media/mainApp.js +1005 -0
  18. package/clients/shared-web/media/mainCore.js +604 -0
  19. package/clients/shared-web/media/mainInteractionHandlers.js +324 -0
  20. package/clients/shared-web/media/mainInteractions.js +378 -0
  21. package/clients/shared-web/media/renderBoard.js +3 -0
  22. package/clients/shared-web/media/renderBoardApp.js +1339 -0
  23. package/clients/shared-web/media/renderDetails.js +685 -0
  24. package/clients/shared-web/media/renderMarkdown.js +449 -0
  25. package/clients/shared-web/media/toolsPanelLayout.js +172 -0
  26. package/clients/shared-web/media/uiStatus.js +54 -0
  27. package/clients/shared-web/media/webviewChrome.js +405 -0
  28. package/clients/shared-web/media/webviewPersistence.js +116 -0
  29. package/clients/shared-web/media/webviewSelectors.js +491 -0
  30. package/clients/viewer/README.md +5 -0
  31. package/clients/viewer/browser-host.js +847 -0
  32. package/clients/viewer/index.html +237 -0
  33. package/clients/viewer/viewer.css +433 -0
  34. package/logics_manager/assist.py +9 -142
  35. package/logics_manager/assist_handoff.py +132 -0
  36. package/logics_manager/assist_surface.py +38 -0
  37. package/logics_manager/cli.py +78 -5
  38. package/logics_manager/flow.py +126 -24
  39. package/logics_manager/flow_evidence.py +63 -0
  40. package/logics_manager/update_check.py +138 -0
  41. package/logics_manager/viewer.py +533 -0
  42. package/package.json +12 -6
  43. package/pyproject.toml +1 -1
@@ -0,0 +1,533 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import json
5
+ import mimetypes
6
+ import os
7
+ import re
8
+ import subprocess
9
+ import sys
10
+ import webbrowser
11
+ from dataclasses import dataclass
12
+ from datetime import datetime
13
+ from http import HTTPStatus
14
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
15
+ from pathlib import Path
16
+ from typing import Any
17
+ from urllib.parse import parse_qs, quote, unquote, urlencode, urlparse
18
+
19
+ from .audit import audit_payload
20
+ from .config import find_repo_root
21
+ from .lint import lint_payload
22
+ from .update_check import get_update_info
23
+
24
+
25
+ @dataclass(frozen=True)
26
+ class ViewerDocFamily:
27
+ stage: str
28
+ directory: str
29
+ prefixes: tuple[str, ...]
30
+
31
+
32
+ DOC_FAMILIES = (
33
+ ViewerDocFamily("request", "logics/request", ("req_",)),
34
+ ViewerDocFamily("backlog", "logics/backlog", ("item_",)),
35
+ ViewerDocFamily("task", "logics/tasks", ("task_",)),
36
+ ViewerDocFamily("product", "logics/product", ("prod_",)),
37
+ ViewerDocFamily("architecture", "logics/architecture", ("adr_",)),
38
+ ViewerDocFamily("spec", "logics/specs", ("spec_", "req_")),
39
+ )
40
+
41
+ STAGE_ORDER = {family.stage: index for index, family in enumerate(DOC_FAMILIES)}
42
+ REPO_ROOT = Path(__file__).resolve().parents[1]
43
+ VIEWER_ROOT = REPO_ROOT / "clients" / "viewer"
44
+ SHARED_MEDIA_ROOT = REPO_ROOT / "clients" / "shared-web" / "media"
45
+ DIST_VENDOR_ROOT = REPO_ROOT / "dist" / "vendor"
46
+ NODE_MERMAID_ROOT = REPO_ROOT / "node_modules" / "mermaid" / "dist"
47
+
48
+
49
+ def _current_version() -> str:
50
+ try:
51
+ return (REPO_ROOT / "VERSION").read_text(encoding="utf-8").strip() or "0.0.0"
52
+ except OSError:
53
+ return "0.0.0"
54
+
55
+
56
+ def _read_text(path: Path) -> str:
57
+ return path.read_text(encoding="utf-8")
58
+
59
+
60
+ def _parse_title(lines: list[str], fallback: str) -> str:
61
+ for line in lines:
62
+ if not line.startswith("## "):
63
+ continue
64
+ raw = line[3:].strip()
65
+ match = re.match(r"^\S+\s*-\s*(.+)$", raw)
66
+ return (match.group(1) if match else raw).strip()
67
+ return fallback
68
+
69
+
70
+ def _parse_indicators(lines: list[str]) -> dict[str, str]:
71
+ indicators: dict[str, str] = {}
72
+ for line in lines:
73
+ if not line.startswith(">"):
74
+ continue
75
+ trimmed = re.sub(r"^>\s*", "", line).strip()
76
+ if ":" not in trimmed:
77
+ continue
78
+ key, value = trimmed.split(":", 1)
79
+ if key.strip() and value.strip():
80
+ indicators[key.strip()] = value.strip()
81
+ return indicators
82
+
83
+
84
+ def _extract_section_lines(content: str, section_title: str) -> list[str]:
85
+ expected = f"# {section_title}".lower()
86
+ collected: list[str] = []
87
+ in_section = False
88
+ for line in content.splitlines():
89
+ if line.strip().lower() == expected:
90
+ in_section = True
91
+ continue
92
+ if not in_section:
93
+ continue
94
+ if line.startswith("# "):
95
+ break
96
+ collected.append(line)
97
+ return collected
98
+
99
+
100
+ def _summary_entries(content: str, section_title: str, limit: int) -> list[str]:
101
+ entries: list[str] = []
102
+ for raw_line in _extract_section_lines(content, section_title):
103
+ line = raw_line.strip()
104
+ if not line or line.startswith("```") or line.startswith("%%") or re.fullmatch(r"-+", line):
105
+ continue
106
+ bullet = re.match(r"^[-*]\s+(.*)$", line)
107
+ value = bullet.group(1) if bullet else line
108
+ if not value.startswith("#"):
109
+ normalized = re.sub(r"\s+", " ", value.replace("> ", "")).strip()
110
+ if normalized and normalized.lower() not in {entry.lower() for entry in entries}:
111
+ entries.append(normalized)
112
+ if len(entries) >= limit:
113
+ break
114
+ return entries
115
+
116
+
117
+ def _build_summary_points(content: str, fallback_title: str) -> list[str]:
118
+ entries = [
119
+ *_summary_entries(content, "Needs", 2),
120
+ *_summary_entries(content, "Problem", 2),
121
+ *_summary_entries(content, "Context", 2),
122
+ *_summary_entries(content, "Scope", 2),
123
+ ]
124
+ deduped: list[str] = []
125
+ for entry in entries:
126
+ if entry.lower() not in {existing.lower() for existing in deduped}:
127
+ deduped.append(entry)
128
+ return deduped[:4] or [fallback_title]
129
+
130
+
131
+ def _collect_backticked_links(text: str) -> list[str]:
132
+ return [match.group(1) for match in re.finditer(r"`([^`]+)`", text) if match.group(1)]
133
+
134
+
135
+ def _normalize_ref(value: str) -> str:
136
+ normalized = value.replace("\\", "/").lstrip("./").strip()
137
+ if "/" in normalized:
138
+ return normalized
139
+ bare_name = normalized[:-3] if normalized.endswith(".md") else normalized
140
+ for family in DOC_FAMILIES:
141
+ if bare_name.startswith(family.prefixes):
142
+ return f"{family.directory}/{bare_name}.md"
143
+ return normalized
144
+
145
+
146
+ def normalize_viewer_focus_target(repo_root: Path, value: str) -> str:
147
+ raw = unquote(value).replace("\\", "/").strip()
148
+ if not raw:
149
+ raise ValueError("Focus target cannot be empty.")
150
+ if raw.startswith("~") or raw.startswith(("/", "\\")) or re.match(r"^[A-Za-z]:", raw):
151
+ raise ValueError("Focus target must be a workflow ref or repo-relative Logics path.")
152
+ parts = [part for part in raw.split("/") if part]
153
+ if any(part == ".." for part in parts):
154
+ raise ValueError("Focus target cannot contain path traversal.")
155
+ normalized = _normalize_ref(raw.lstrip("./")).lstrip("/")
156
+ if "/" not in raw and normalized == raw:
157
+ raise ValueError("Focus target must be a known workflow ref or repo-relative Logics path.")
158
+ if "/" in normalized:
159
+ absolute = (repo_root.resolve() / normalized).resolve()
160
+ root = repo_root.resolve()
161
+ if root != absolute and root not in absolute.parents:
162
+ raise ValueError("Focus target escapes repository root.")
163
+ allowed_prefixes = tuple(f"{family.directory}/" for family in DOC_FAMILIES)
164
+ if not normalized.startswith(allowed_prefixes) or not normalized.endswith(".md"):
165
+ raise ValueError("Focus target must point to a Logics Markdown document.")
166
+ return normalized
167
+
168
+
169
+ def build_viewer_url(host: str, port: int, *, focus: str | None = None, read: bool = False) -> str:
170
+ url = f"http://{host}:{port}"
171
+ query: dict[str, str] = {}
172
+ if focus:
173
+ query["focus"] = focus
174
+ if read:
175
+ query["read"] = "1"
176
+ if query:
177
+ url = f"{url}?{urlencode(query, quote_via=quote)}"
178
+ return url
179
+
180
+
181
+ def _section_links(content: str, section_title: str) -> list[str]:
182
+ links: list[str] = []
183
+ for line in _extract_section_lines(content, section_title):
184
+ if "(none yet)" in line:
185
+ continue
186
+ links.extend(_collect_backticked_links(line))
187
+ return sorted({_normalize_ref(link) for link in links})
188
+
189
+
190
+ def _indicator_links(lines: list[str], keys: set[str]) -> list[str]:
191
+ links: list[str] = []
192
+ for line in lines:
193
+ if not line.startswith(">"):
194
+ continue
195
+ trimmed = re.sub(r"^>\s*", "", line).strip()
196
+ if ":" not in trimmed:
197
+ continue
198
+ key, value = trimmed.split(":", 1)
199
+ if key.strip().lower() in keys:
200
+ links.extend(_collect_backticked_links(value))
201
+ return sorted({_normalize_ref(link) for link in links})
202
+
203
+
204
+ def _extract_references(content: str, lines: list[str]) -> list[dict[str, str]]:
205
+ references: list[dict[str, str]] = []
206
+ for label, pattern in (
207
+ ("Promoted from", re.compile(r"Promoted from `([^`]+)`", re.IGNORECASE)),
208
+ ("Derived from", re.compile(r"Derived from(?: [a-z][a-z ]+)? `([^`]+)`", re.IGNORECASE)),
209
+ ):
210
+ for match in pattern.finditer(content):
211
+ references.append({"kind": "from", "label": label, "path": _normalize_ref(match.group(1))})
212
+ for link in _section_links(content, "Backlog"):
213
+ references.append({"kind": "backlog", "label": "Backlog", "path": link})
214
+ manual_links = {
215
+ *_section_links(content, "References"),
216
+ *_indicator_links(lines, {"related request", "related backlog", "related task", "related architecture"}),
217
+ }
218
+ for link in sorted(manual_links):
219
+ references.append({"kind": "manual", "label": "Reference", "path": link})
220
+ return references
221
+
222
+
223
+ def _infer_stage(rel_path: str, doc_id: str) -> str:
224
+ normalized = rel_path.replace("\\", "/").lower()
225
+ for family in DOC_FAMILIES:
226
+ if normalized.startswith(f"{family.directory}/") or doc_id.startswith(family.prefixes):
227
+ return family.stage
228
+ return "request"
229
+
230
+
231
+ def _to_usage(rel_path: str, items_by_rel_path: dict[str, dict[str, Any]]) -> dict[str, str]:
232
+ normalized = _normalize_ref(rel_path)
233
+ matched = items_by_rel_path.get(normalized)
234
+ if matched:
235
+ return {
236
+ "id": str(matched["id"]),
237
+ "title": str(matched["title"]),
238
+ "stage": str(matched["stage"]),
239
+ "relPath": str(matched["relPath"]),
240
+ }
241
+ doc_id = Path(normalized).stem
242
+ return {
243
+ "id": doc_id or normalized,
244
+ "title": doc_id or normalized,
245
+ "stage": _infer_stage(normalized, doc_id),
246
+ "relPath": normalized,
247
+ }
248
+
249
+
250
+ def collect_viewer_items(repo_root: Path) -> list[dict[str, Any]]:
251
+ repo_root = repo_root.resolve()
252
+ items: list[dict[str, Any]] = []
253
+ promoted_sources: set[str] = set()
254
+ usage_map: dict[str, list[dict[str, str]]] = {}
255
+ manual_used_by: dict[str, list[str]] = {}
256
+
257
+ for family in DOC_FAMILIES:
258
+ directory = repo_root / family.directory
259
+ if not directory.is_dir():
260
+ continue
261
+ for path in sorted(directory.glob("*.md")):
262
+ if not path.name.startswith(family.prefixes):
263
+ continue
264
+ content = _read_text(path)
265
+ lines = content.splitlines()
266
+ rel_path = path.relative_to(repo_root).as_posix()
267
+ title = _parse_title(lines, path.stem)
268
+ references = _extract_references(content, lines)
269
+ manual_used_by[rel_path] = _section_links(content, "Used by")
270
+ for ref in references:
271
+ if ref["kind"] == "from":
272
+ promoted_sources.add(_normalize_ref(ref["path"]))
273
+ stat = path.stat()
274
+ items.append(
275
+ {
276
+ "id": path.stem,
277
+ "title": title,
278
+ "stage": family.stage,
279
+ "path": str(path),
280
+ "relPath": rel_path,
281
+ "filename": path.name,
282
+ "updatedAt": stat.st_mtime_ns,
283
+ "indicators": _parse_indicators(lines),
284
+ "summaryPoints": _build_summary_points(content, title),
285
+ "acceptanceCriteria": _summary_entries(content, "Acceptance criteria", 6),
286
+ "lineCount": len(lines),
287
+ "charCount": len(content),
288
+ "isPromoted": False,
289
+ "references": references,
290
+ "usedBy": [],
291
+ }
292
+ )
293
+
294
+ items_by_rel_path = {str(item["relPath"]): item for item in items}
295
+ for item in items:
296
+ rel_path = str(item["relPath"])
297
+ item["isPromoted"] = rel_path in promoted_sources
298
+ for ref in item["references"]:
299
+ target = _normalize_ref(str(ref["path"]))
300
+ if target in items_by_rel_path:
301
+ usage_map.setdefault(target, []).append(
302
+ {
303
+ "id": str(item["id"]),
304
+ "title": str(item["title"]),
305
+ "stage": str(item["stage"]),
306
+ "relPath": rel_path,
307
+ }
308
+ )
309
+
310
+ for item in items:
311
+ rel_path = str(item["relPath"])
312
+ usages = usage_map.get(rel_path, [])
313
+ for link in manual_used_by.get(rel_path, []):
314
+ usage = _to_usage(link, items_by_rel_path)
315
+ if not any(existing["relPath"] == usage["relPath"] for existing in usages):
316
+ usages.append(usage)
317
+ item["usedBy"] = sorted(usages, key=lambda usage: (STAGE_ORDER.get(usage["stage"], 99), usage["id"]))
318
+
319
+ items.sort(key=lambda item: (STAGE_ORDER.get(str(item["stage"]), 99), str(item["id"])))
320
+ for item in items:
321
+ item["updatedAt"] = datetime.fromtimestamp(Path(str(item["path"])).stat().st_mtime).isoformat()
322
+ return items
323
+
324
+
325
+ def viewer_data_payload(repo_root: Path, selected_id: str | None = None) -> dict[str, Any]:
326
+ return {
327
+ "root": str(repo_root.resolve()),
328
+ "items": collect_viewer_items(repo_root),
329
+ "updateInfo": get_update_info(_current_version()).to_payload(),
330
+ "selectedId": selected_id,
331
+ "changedPaths": [],
332
+ "canResetProjectRoot": False,
333
+ "canBootstrapLogics": False,
334
+ "bootstrapLogicsTitle": "Local viewer is read-only. Use the CLI to bootstrap Logics.",
335
+ "canLaunchCodex": False,
336
+ "canLaunchClaude": False,
337
+ "canRepairLogicsKit": False,
338
+ "canPublishRelease": False,
339
+ "shouldRecommendCheckEnvironment": False,
340
+ }
341
+
342
+
343
+ def read_doc_payload(repo_root: Path, rel_path: str) -> dict[str, Any]:
344
+ normalized, absolute = _resolve_repo_doc_path(repo_root, rel_path)
345
+ return {
346
+ "path": normalized,
347
+ "content": _read_text(absolute),
348
+ }
349
+
350
+
351
+ def _resolve_repo_doc_path(repo_root: Path, rel_path: str) -> tuple[str, Path]:
352
+ normalized = unquote(rel_path).replace("\\", "/").lstrip("/")
353
+ absolute = (repo_root / normalized).resolve()
354
+ root = repo_root.resolve()
355
+ if root != absolute and root not in absolute.parents:
356
+ raise ValueError("Document path escapes repository root.")
357
+ if not absolute.is_file():
358
+ raise FileNotFoundError(normalized)
359
+ return normalized, absolute
360
+
361
+
362
+ def edit_doc_payload(repo_root: Path, rel_path: str, *, launcher: Any | None = None) -> dict[str, str]:
363
+ normalized, absolute = _resolve_repo_doc_path(repo_root, rel_path)
364
+ command = _system_editor_command(absolute)
365
+ runner = launcher or subprocess.Popen
366
+ runner(command)
367
+ return {
368
+ "path": normalized,
369
+ "command": command[0],
370
+ }
371
+
372
+
373
+ def _system_editor_command(path: Path) -> list[str]:
374
+ if sys.platform == "darwin":
375
+ return ["open", str(path)]
376
+ if os.name == "nt":
377
+ return ["cmd", "/c", "start", "", str(path)]
378
+ return ["xdg-open", str(path)]
379
+
380
+
381
+ def _json_bytes(payload: Any) -> bytes:
382
+ return json.dumps(payload, indent=2, sort_keys=True).encode("utf-8")
383
+
384
+
385
+ class LogicsViewerServer(ThreadingHTTPServer):
386
+ def __init__(self, server_address: tuple[str, int], repo_root: Path):
387
+ self.repo_root = repo_root.resolve()
388
+ super().__init__(server_address, LogicsViewerRequestHandler)
389
+
390
+
391
+ class LogicsViewerRequestHandler(BaseHTTPRequestHandler):
392
+ server: LogicsViewerServer
393
+
394
+ def log_message(self, format: str, *args: object) -> None:
395
+ return
396
+
397
+ def _send_bytes(self, content: bytes, *, status: int = 200, content_type: str = "application/octet-stream") -> None:
398
+ self.send_response(status)
399
+ self.send_header("Content-Type", content_type)
400
+ self.send_header("Cache-Control", "no-store")
401
+ self.end_headers()
402
+ try:
403
+ self.wfile.write(content)
404
+ except (BrokenPipeError, ConnectionResetError):
405
+ return
406
+
407
+ def _send_json(self, payload: Any, *, status: int = 200) -> None:
408
+ self._send_bytes(_json_bytes(payload), status=status, content_type="application/json; charset=utf-8")
409
+
410
+ def _send_error_json(self, status: HTTPStatus, message: str) -> None:
411
+ self._send_json({"ok": False, "error": message}, status=status.value)
412
+
413
+ def _serve_file(self, path: Path) -> None:
414
+ if not path.is_file():
415
+ self._send_error_json(HTTPStatus.NOT_FOUND, "Not found")
416
+ return
417
+ content_type = mimetypes.guess_type(path.name)[0] or "application/octet-stream"
418
+ if content_type.startswith("text/") or path.suffix in {".js", ".css", ".html"}:
419
+ content_type = f"{content_type}; charset=utf-8"
420
+ self._send_bytes(path.read_bytes(), content_type=content_type)
421
+
422
+ def do_GET(self) -> None:
423
+ parsed = urlparse(self.path)
424
+ route = parsed.path
425
+ if route == "/":
426
+ self._serve_file(VIEWER_ROOT / "index.html")
427
+ return
428
+ if route == "/browser-host.js":
429
+ self._serve_file(VIEWER_ROOT / "browser-host.js")
430
+ return
431
+ if route == "/viewer.css":
432
+ self._serve_file(VIEWER_ROOT / "viewer.css")
433
+ return
434
+ if route == "/vendor/mermaid.min.js":
435
+ vendor_path = DIST_VENDOR_ROOT / "mermaid.min.js"
436
+ if not vendor_path.is_file():
437
+ vendor_path = NODE_MERMAID_ROOT / "mermaid.min.js"
438
+ self._serve_file(vendor_path)
439
+ return
440
+ if route.startswith("/media/"):
441
+ media_path = (SHARED_MEDIA_ROOT / route.removeprefix("/media/")).resolve()
442
+ if SHARED_MEDIA_ROOT.resolve() != media_path and SHARED_MEDIA_ROOT.resolve() not in media_path.parents:
443
+ self._send_error_json(HTTPStatus.NOT_FOUND, "Not found")
444
+ return
445
+ self._serve_file(media_path)
446
+ return
447
+ if route == "/api/items":
448
+ self._send_json({"ok": True, "payload": viewer_data_payload(self.server.repo_root)})
449
+ return
450
+ if route == "/api/doc":
451
+ rel_path = parse_qs(parsed.query).get("path", [""])[0]
452
+ try:
453
+ self._send_json({"ok": True, "document": read_doc_payload(self.server.repo_root, rel_path)})
454
+ except (FileNotFoundError, ValueError) as exc:
455
+ self._send_error_json(HTTPStatus.NOT_FOUND, str(exc))
456
+ return
457
+ if route == "/api/lint":
458
+ self._send_json({"ok": True, "payload": lint_payload(self.server.repo_root)})
459
+ return
460
+ if route == "/api/audit":
461
+ self._send_json({"ok": True, "payload": audit_payload(self.server.repo_root)})
462
+ return
463
+ self._send_error_json(HTTPStatus.NOT_FOUND, "Not found")
464
+
465
+ def do_POST(self) -> None:
466
+ parsed = urlparse(self.path)
467
+ if parsed.path == "/api/refresh":
468
+ self._send_json({"ok": True, "payload": viewer_data_payload(self.server.repo_root)})
469
+ return
470
+ if parsed.path == "/api/edit":
471
+ rel_path = parse_qs(parsed.query).get("path", [""])[0]
472
+ try:
473
+ self._send_json({"ok": True, "document": edit_doc_payload(self.server.repo_root, rel_path)})
474
+ except (FileNotFoundError, ValueError) as exc:
475
+ self._send_error_json(HTTPStatus.NOT_FOUND, str(exc))
476
+ except OSError as exc:
477
+ self._send_error_json(HTTPStatus.INTERNAL_SERVER_ERROR, str(exc))
478
+ return
479
+ self._send_error_json(HTTPStatus.NOT_FOUND, "Not found")
480
+
481
+
482
+ def create_viewer_server(repo_root: Path, host: str = "127.0.0.1", port: int = 8765) -> LogicsViewerServer:
483
+ return LogicsViewerServer((host, port), repo_root)
484
+
485
+
486
+ def render_start_status(url: str, repo_root: Path, *, focus: str | None = None) -> str:
487
+ lines = [
488
+ "Logics viewer running:",
489
+ url,
490
+ "",
491
+ f"Repo: {repo_root.name}",
492
+ "Mode: read-only",
493
+ "Bind: localhost",
494
+ ]
495
+ if focus:
496
+ lines.append(f"Focus: {focus}")
497
+ return "\n".join(lines)
498
+
499
+
500
+ def build_parser() -> argparse.ArgumentParser:
501
+ parser = argparse.ArgumentParser(prog="logics-manager view", description="Start the local read-only Logics browser viewer.")
502
+ parser.add_argument("--host", default="127.0.0.1", help="Bind host. Defaults to 127.0.0.1.")
503
+ parser.add_argument("--port", type=int, default=8765, help="Bind port. Use 0 to select an available port.")
504
+ parser.add_argument("--focus", help="Open the viewer focused on a workflow ref or repo-relative Logics Markdown path.")
505
+ parser.add_argument("--read", action="store_true", help="Open the focused item in the read preview. Requires --focus.")
506
+ parser.add_argument("--open", action="store_true", help="Open the viewer in the default browser.")
507
+ parser.add_argument("--no-open", action="store_true", help="Do not open the browser. This is the default.")
508
+ return parser
509
+
510
+
511
+ def main(argv: list[str]) -> int:
512
+ args = build_parser().parse_args(argv)
513
+ repo_root = find_repo_root(Path.cwd())
514
+ if args.read and not args.focus:
515
+ raise SystemExit("--read requires --focus.")
516
+ try:
517
+ focus = normalize_viewer_focus_target(repo_root, args.focus) if args.focus else None
518
+ except ValueError as exc:
519
+ raise SystemExit(str(exc)) from exc
520
+ server = create_viewer_server(repo_root, host=args.host, port=args.port)
521
+ host, port = server.server_address[:2]
522
+ url = build_viewer_url(str(host), int(port), focus=focus, read=bool(args.read))
523
+ print(render_start_status(url, repo_root, focus=focus), flush=True)
524
+ if args.open and not args.no_open:
525
+ webbrowser.open(url)
526
+
527
+ try:
528
+ server.serve_forever()
529
+ except KeyboardInterrupt:
530
+ return 0
531
+ finally:
532
+ server.server_close()
533
+ return 0
package/package.json CHANGED
@@ -2,9 +2,9 @@
2
2
  "name": "@grifhinz/logics-manager",
3
3
  "displayName": "Logics Orchestrator",
4
4
  "description": "Visual orchestration for Logics workflows inside VS Code.",
5
- "version": "2.2.0",
5
+ "version": "2.3.1",
6
6
  "publisher": "cdx-logics",
7
- "icon": "media/icon.png",
7
+ "icon": "clients/shared-web/media/icon.png",
8
8
  "repository": {
9
9
  "type": "git",
10
10
  "url": "git+https://github.com/AlexAgo83/logics-manager.git"
@@ -29,6 +29,11 @@
29
29
  "pyproject.toml",
30
30
  "scripts/logics-manager.py",
31
31
  "scripts/npm/",
32
+ "clients/shared-web/media/",
33
+ "clients/viewer/index.html",
34
+ "clients/viewer/browser-host.js",
35
+ "clients/viewer/viewer.css",
36
+ "dist/vendor/mermaid.min.js",
32
37
  "logics_manager/*.py",
33
38
  "logics_manager/**/*.py"
34
39
  ],
@@ -38,7 +43,7 @@
38
43
  {
39
44
  "id": "logics",
40
45
  "title": "Logics",
41
- "icon": "media/logics.svg"
46
+ "icon": "clients/shared-web/media/logics.svg"
42
47
  }
43
48
  ]
44
49
  },
@@ -47,7 +52,7 @@
47
52
  {
48
53
  "id": "logics.orchestrator",
49
54
  "name": "Orchestrator",
50
- "icon": "media/logics.svg",
55
+ "icon": "clients/shared-web/media/logics.svg",
51
56
  "type": "webview"
52
57
  }
53
58
  ]
@@ -127,7 +132,7 @@
127
132
  "test:watch": "vitest",
128
133
  "lint": "npm run lint:ts && npm run lint:es",
129
134
  "lint:ts": "tsc -p ./ --noEmit",
130
- "lint:es": "eslint src/**/*.ts",
135
+ "lint:es": "eslint clients/vscode/src/**/*.ts",
131
136
  "lint:logics": "node scripts/run-python.mjs -m logics_manager lint",
132
137
  "audit:logics": "node scripts/run-python.mjs -m logics_manager audit && node scripts/run-python.mjs -m logics_manager lint",
133
138
  "audit:logics:strict": "node scripts/run-python.mjs -m logics_manager audit --governance-profile strict && node scripts/run-python.mjs -m logics_manager lint --require-status",
@@ -136,9 +141,10 @@
136
141
  "ci:fast": "npm run compile && npm run lint && npm run test:coverage && npm run test:smoke && npm run lint:logics && npm run package:ci",
137
142
  "ci:check": "node scripts/ci-check.mjs",
138
143
  "dev": "npm run compile && code --extensionDevelopmentPath=.",
139
- "debug:webview": "node debug/webview/server.mjs",
144
+ "debug:webview": "node clients/viewer/debug-webview/server.mjs",
140
145
  "package:ci": "node scripts/build/package-ci.mjs",
141
146
  "package": "npm run compile && node scripts/build/package-release.mjs",
147
+ "package:npm": "node scripts/build/package-npm.mjs",
142
148
  "install:vsix": "npm run package && node scripts/build/install-vsix.mjs",
143
149
  "publish:npm": "node scripts/release/publish-npm.mjs",
144
150
  "release:changelog:resolve": "node scripts/release/resolve-release-changelog.mjs --validate-current",
package/pyproject.toml CHANGED
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "logics-manager"
7
- version = "2.2.0"
7
+ version = "2.3.1"
8
8
  description = "Canonical Logics CLI"
9
9
  requires-python = ">=3.10"
10
10