@grifhinz/logics-manager 2.1.2 → 2.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/README.md +106 -4
  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 +94 -63
  35. package/logics_manager/assist_handoff.py +132 -0
  36. package/logics_manager/assist_surface.py +38 -0
  37. package/logics_manager/cli.py +152 -12
  38. package/logics_manager/cli_output.py +18 -0
  39. package/logics_manager/flow.py +1360 -84
  40. package/logics_manager/flow_evidence.py +63 -0
  41. package/logics_manager/index.py +3 -7
  42. package/logics_manager/insights.py +418 -0
  43. package/logics_manager/mcp.py +50 -0
  44. package/logics_manager/path_utils.py +31 -0
  45. package/logics_manager/sync.py +24 -12
  46. package/logics_manager/update_check.py +138 -0
  47. package/logics_manager/viewer.py +533 -0
  48. package/package.json +12 -6
  49. package/pyproject.toml +1 -1
@@ -10,6 +10,7 @@ from pathlib import Path
10
10
 
11
11
  from .config import find_repo_root
12
12
  from .lint import expected_workflow_mermaid_signature
13
+ from .path_utils import resolve_repo_output_path
13
14
  from .termstyle import colorize_help
14
15
 
15
16
 
@@ -576,9 +577,10 @@ def append_workflow_note_payload(repo_root: Path, source: str, *, note_kind: str
576
577
  changed = False
577
578
  else:
578
579
  lines.insert(insert_at, bullet)
580
+ mermaid_signature_refreshed = False
579
581
  if changed and not dry_run:
580
582
  path.write_text("\n".join(lines).rstrip() + "\n", encoding="utf-8")
581
- refresh_workflow_mermaid_signature_file(path, kind, dry_run=False, repo_root=repo_root)
583
+ mermaid_signature_refreshed = refresh_workflow_mermaid_signature_file(path, kind, dry_run=False, repo_root=repo_root)
582
584
  return {
583
585
  "path": path.relative_to(repo_root).as_posix(),
584
586
  "ref": path.stem,
@@ -586,6 +588,7 @@ def append_workflow_note_payload(repo_root: Path, source: str, *, note_kind: str
586
588
  "section": section,
587
589
  "text": cleaned,
588
590
  "changed": changed,
591
+ "mermaid_signature_refreshed": mermaid_signature_refreshed,
589
592
  "dry_run": dry_run,
590
593
  }
591
594
 
@@ -689,7 +692,7 @@ def refresh_workflow_mermaid_signature_file(path: Path, kind: str, dry_run: bool
689
692
  return True
690
693
 
691
694
 
692
- def _close_eligible_requests(repo_root: Path, dry_run: bool) -> tuple[int, int]:
695
+ def _close_eligible_requests(repo_root: Path, dry_run: bool, *, quiet: bool = False) -> tuple[int, int]:
693
696
  request_dir = repo_root / DOC_KINDS["request"]["directory"]
694
697
  closed = 0
695
698
  scanned = 0
@@ -703,7 +706,8 @@ def _close_eligible_requests(repo_root: Path, dry_run: bool) -> tuple[int, int]:
703
706
  continue
704
707
  if all(_is_doc_done(item_path, "backlog") for item_path in linked_items):
705
708
  _close_doc(request_path, "request", dry_run)
706
- print(f"Auto-closed request {request_ref} (all linked backlog items are done).")
709
+ if not quiet:
710
+ print(f"Auto-closed request {request_ref} (all linked backlog items are done).")
707
711
  closed += 1
708
712
  return scanned, closed
709
713
 
@@ -1030,7 +1034,7 @@ def _print_help(text: str) -> None:
1030
1034
 
1031
1035
  def cmd_close_eligible_requests(args: argparse.Namespace) -> dict[str, object]:
1032
1036
  repo_root = _find_repo_root(Path.cwd())
1033
- scanned, closed = _close_eligible_requests(repo_root, args.dry_run)
1037
+ scanned, closed = _close_eligible_requests(repo_root, args.dry_run, quiet=args.format == "json")
1034
1038
  payload = {
1035
1039
  "command": "sync",
1036
1040
  "kind": "close-eligible-requests",
@@ -1161,6 +1165,8 @@ def cmd_append_note(args: argparse.Namespace) -> dict[str, object]:
1161
1165
  print(json.dumps(payload, indent=2, sort_keys=True))
1162
1166
  else:
1163
1167
  print(f"Appended {args.section} note to {payload['path']} (changed: {payload['changed']}).")
1168
+ if payload.get("mermaid_signature_refreshed"):
1169
+ print("- Mermaid signature refreshed.")
1164
1170
  return {"command": "sync", "kind": "append-note", "repo_root": repo_root.as_posix(), **payload}
1165
1171
 
1166
1172
 
@@ -1168,13 +1174,16 @@ def cmd_context_pack(args: argparse.Namespace) -> dict[str, object]:
1168
1174
  repo_root = _find_repo_root(Path.cwd())
1169
1175
  payload = _build_context_pack(repo_root, args.ref, mode=args.mode, profile=args.profile, config=None)
1170
1176
  if args.out:
1171
- out_path = (repo_root / args.out).resolve()
1177
+ out_path, output_path = resolve_repo_output_path(repo_root, args.out)
1172
1178
  serialized = json.dumps(payload, indent=2, sort_keys=True) + "\n"
1173
- out_path.parent.mkdir(parents=True, exist_ok=True)
1174
1179
  if not args.dry_run:
1180
+ out_path.parent.mkdir(parents=True, exist_ok=True)
1175
1181
  out_path.write_text(serialized, encoding="utf-8")
1176
- print(f"Wrote {out_path.relative_to(repo_root)}")
1177
- payload["output_path"] = out_path.relative_to(repo_root).as_posix()
1182
+ payload["output_path"] = output_path
1183
+ if args.format == "json":
1184
+ print(json.dumps(payload, indent=2, sort_keys=True))
1185
+ else:
1186
+ print(f"Wrote {output_path}")
1178
1187
  else:
1179
1188
  if args.format == "json":
1180
1189
  print(json.dumps(payload, indent=2, sort_keys=True))
@@ -1189,13 +1198,16 @@ def cmd_export_graph(args: argparse.Namespace) -> dict[str, object]:
1189
1198
  payload = _graph_payload(repo_root, config=None)
1190
1199
  payload["repo_root"] = repo_root.as_posix()
1191
1200
  if args.out:
1192
- out_path = (repo_root / args.out).resolve()
1201
+ out_path, output_path = resolve_repo_output_path(repo_root, args.out)
1193
1202
  serialized = json.dumps(payload, indent=2, sort_keys=True) + "\n"
1194
- out_path.parent.mkdir(parents=True, exist_ok=True)
1195
1203
  if not args.dry_run:
1204
+ out_path.parent.mkdir(parents=True, exist_ok=True)
1196
1205
  out_path.write_text(serialized, encoding="utf-8")
1197
- print(f"Wrote {out_path.relative_to(repo_root)}")
1198
- payload["output_path"] = out_path.relative_to(repo_root).as_posix()
1206
+ payload["output_path"] = output_path
1207
+ if args.format == "json":
1208
+ print(json.dumps(payload, indent=2, sort_keys=True))
1209
+ else:
1210
+ print(f"Wrote {output_path}")
1199
1211
  else:
1200
1212
  if args.format == "json":
1201
1213
  print(json.dumps(payload, indent=2, sort_keys=True))
@@ -0,0 +1,138 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ import json
5
+ import os
6
+ from pathlib import Path
7
+ import time
8
+ from typing import Any, Callable
9
+ from urllib.error import URLError
10
+ from urllib.request import urlopen
11
+
12
+
13
+ NPM_LATEST_URL = "https://registry.npmjs.org/@grifhinz%2Flogics-manager/latest"
14
+ DISABLE_ENV = "LOGICS_MANAGER_NO_UPDATE_CHECK"
15
+ CHECK_INTERVAL_SECONDS = 24 * 60 * 60
16
+
17
+
18
+ @dataclass(frozen=True)
19
+ class UpdateInfo:
20
+ current_version: str
21
+ latest_version: str | None
22
+ update_available: bool
23
+ checked_at: int | None
24
+ update_command: str
25
+ source: str
26
+
27
+ def to_payload(self) -> dict[str, Any]:
28
+ return {
29
+ "currentVersion": self.current_version,
30
+ "latestVersion": self.latest_version,
31
+ "updateAvailable": self.update_available,
32
+ "checkedAt": self.checked_at,
33
+ "updateCommand": self.update_command,
34
+ "source": self.source,
35
+ }
36
+
37
+
38
+ def _parse_version(value: str | None) -> tuple[int, int, int, str]:
39
+ raw = (value or "").strip().lstrip("v")
40
+ parts = raw.split(".", 3)
41
+ numeric: list[int] = []
42
+ suffix = ""
43
+ for index, part in enumerate(parts[:3]):
44
+ digits = ""
45
+ rest = ""
46
+ for char in part:
47
+ if char.isdigit() and not rest:
48
+ digits += char
49
+ else:
50
+ rest += char
51
+ numeric.append(int(digits or "0"))
52
+ if rest:
53
+ suffix = ".".join([rest, *parts[index + 1 :]])
54
+ break
55
+ while len(numeric) < 3:
56
+ numeric.append(0)
57
+ return numeric[0], numeric[1], numeric[2], suffix
58
+
59
+
60
+ def is_newer_version(latest: str | None, current: str | None) -> bool:
61
+ latest_tuple = _parse_version(latest)
62
+ current_tuple = _parse_version(current)
63
+ return latest_tuple[:3] > current_tuple[:3]
64
+
65
+
66
+ def update_cache_path() -> Path:
67
+ override = os.environ.get("LOGICS_MANAGER_UPDATE_CACHE")
68
+ if override:
69
+ return Path(override)
70
+ cache_root = os.environ.get("XDG_CACHE_HOME")
71
+ if cache_root:
72
+ return Path(cache_root) / "logics-manager" / "update-check.json"
73
+ return Path.home() / ".cache" / "logics-manager" / "update-check.json"
74
+
75
+
76
+ def _read_cache(path: Path, now: int) -> dict[str, Any] | None:
77
+ try:
78
+ payload = json.loads(path.read_text(encoding="utf-8"))
79
+ except (OSError, json.JSONDecodeError):
80
+ return None
81
+ checked_at = int(payload.get("checked_at") or 0)
82
+ if checked_at <= 0 or now - checked_at > CHECK_INTERVAL_SECONDS:
83
+ return None
84
+ return payload if isinstance(payload, dict) else None
85
+
86
+
87
+ def _write_cache(path: Path, payload: dict[str, Any]) -> None:
88
+ try:
89
+ path.parent.mkdir(parents=True, exist_ok=True)
90
+ path.write_text(json.dumps(payload, sort_keys=True), encoding="utf-8")
91
+ except OSError:
92
+ return
93
+
94
+
95
+ def fetch_latest_npm_version(*, timeout: float = 0.75, opener: Callable[..., Any] = urlopen) -> str | None:
96
+ try:
97
+ with opener(NPM_LATEST_URL, timeout=timeout) as response:
98
+ payload = json.loads(response.read().decode("utf-8"))
99
+ except (OSError, URLError, TimeoutError, json.JSONDecodeError, ValueError):
100
+ return None
101
+ version = payload.get("version") if isinstance(payload, dict) else None
102
+ return version.strip() if isinstance(version, str) and version.strip() else None
103
+
104
+
105
+ def get_update_info(
106
+ current_version: str,
107
+ *,
108
+ cache_path: Path | None = None,
109
+ now: int | None = None,
110
+ fetch_latest: Callable[[], str | None] | None = None,
111
+ ) -> UpdateInfo:
112
+ now_value = int(time.time() if now is None else now)
113
+ path = cache_path or update_cache_path()
114
+ cached = _read_cache(path, now_value)
115
+ latest = str(cached.get("latest_version") or "") if cached else ""
116
+ if not latest:
117
+ latest = (fetch_latest or fetch_latest_npm_version)() or ""
118
+ _write_cache(path, {"checked_at": now_value, "latest_version": latest})
119
+ return UpdateInfo(
120
+ current_version=current_version,
121
+ latest_version=latest or None,
122
+ update_available=is_newer_version(latest, current_version),
123
+ checked_at=now_value,
124
+ update_command="logics-manager self-update",
125
+ source="npm",
126
+ )
127
+
128
+
129
+ def get_update_notice(current_version: str) -> str | None:
130
+ if os.environ.get(DISABLE_ENV):
131
+ return None
132
+ info = get_update_info(current_version)
133
+ if not info.update_available or not info.latest_version:
134
+ return None
135
+ return (
136
+ f"logics-manager {info.latest_version} is available "
137
+ f"(current {info.current_version}). Run `{info.update_command}` to update."
138
+ )