@grifhinz/logics-manager 2.2.0 → 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 (43) hide show
  1. package/README.md +60 -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 +20 -0
  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
@@ -9,6 +9,9 @@ from pathlib import Path
9
9
 
10
10
  from .audit import audit_payload
11
11
  from .cli_output import print_payload
12
+ from .flow_evidence import has_ac_proof as _has_ac_proof
13
+ from .flow_evidence import has_validation_evidence as _has_validation_evidence
14
+ from .flow_evidence import structured_validation_line as _structured_validation_line
12
15
  from .index import index_payload
13
16
  from .lint import expected_workflow_mermaid_signature, lint_payload
14
17
  from .path_utils import ensure_relative_to
@@ -974,19 +977,6 @@ def _section_has_checked_checkbox(text: str, heading: str) -> bool:
974
977
  return any("- [x]" in line.lower() for line in _section_lines(text.splitlines(), heading))
975
978
 
976
979
 
977
- def _has_validation_evidence(text: str) -> bool:
978
- for line in _section_lines(text.splitlines(), "Validation"):
979
- stripped = line.strip()
980
- if not stripped.startswith("- "):
981
- continue
982
- value = stripped[2:].strip().lower()
983
- if not value or value.startswith("run `") or value.startswith("run the "):
984
- continue
985
- if any(marker in value for marker in ("pass", "ok", "validated", "verification", "regression")):
986
- return True
987
- return False
988
-
989
-
990
980
  def _request_ac_ids(text: str) -> list[str]:
991
981
  ids: list[str] = []
992
982
  for line in _section_lines(text.splitlines(), "Acceptance criteria"):
@@ -996,11 +986,6 @@ def _request_ac_ids(text: str) -> list[str]:
996
986
  return ids
997
987
 
998
988
 
999
- def _has_ac_proof(text: str, ac_id: str) -> bool:
1000
- upper = text.upper()
1001
- return ac_id.upper() in upper and "proof:" in text.lower()
1002
-
1003
-
1004
989
  def _first_product_path(repo_root: Path, product_ref: str) -> Path | None:
1005
990
  path = repo_root / "logics" / "product" / f"{product_ref}.md"
1006
991
  return path if path.is_file() else None
@@ -1254,7 +1239,16 @@ def _request_ac_entries(request_path: Path) -> list[tuple[str, str]]:
1254
1239
  return entries
1255
1240
 
1256
1241
 
1257
- def repair_ac_traceability_payload(repo_root: Path, source: str, *, dry_run: bool) -> dict[str, object]:
1242
+ def _ac_traceability_entry(ac_id: str, target: str, text: str, proof: str | None, proof_source: str | None) -> str:
1243
+ if proof and proof.strip():
1244
+ rendered = f"request-{ac_id} -> {target}. Proof: {proof.strip()}"
1245
+ if proof_source and proof_source.strip():
1246
+ rendered += f" Source: `{proof_source.strip()}`"
1247
+ return rendered
1248
+ return f"request-{ac_id} -> {target}. Evidence needed: {text}"
1249
+
1250
+
1251
+ def repair_ac_traceability_payload(repo_root: Path, source: str, *, dry_run: bool, proof: str | None = None, proof_source: str | None = None) -> dict[str, object]:
1258
1252
  request_path = _resolve_workflow_source(repo_root, DOC_KINDS["request"], source)
1259
1253
  request_ref = request_path.stem
1260
1254
  ac_entries = _request_ac_entries(request_path)
@@ -1268,7 +1262,7 @@ def repair_ac_traceability_payload(repo_root: Path, source: str, *, dry_run: boo
1268
1262
  for item_path in linked_items:
1269
1263
  item_before = item_path.read_text(encoding="utf-8")
1270
1264
  item_missing = [
1271
- f"request-{ac_id} -> This backlog slice. Proof: {text}"
1265
+ _ac_traceability_entry(ac_id, "This backlog slice", text, proof, proof_source)
1272
1266
  for ac_id, text in ac_entries
1273
1267
  if not _has_ac_proof(item_before, ac_id)
1274
1268
  ]
@@ -1285,7 +1279,7 @@ def repair_ac_traceability_payload(repo_root: Path, source: str, *, dry_run: boo
1285
1279
  for task_path in sorted(linked_task_paths):
1286
1280
  task_before = task_path.read_text(encoding="utf-8")
1287
1281
  task_missing = [
1288
- f"request-{ac_id} -> This task. Proof: {text}"
1282
+ _ac_traceability_entry(ac_id, "This task", text, proof, proof_source)
1289
1283
  for ac_id, text in ac_entries
1290
1284
  if not _has_ac_proof(task_before, ac_id)
1291
1285
  ]
@@ -1296,6 +1290,8 @@ def repair_ac_traceability_payload(repo_root: Path, source: str, *, dry_run: boo
1296
1290
  "command": "repair",
1297
1291
  "kind": "ac-traceability",
1298
1292
  "source": request_path.relative_to(repo_root).as_posix(),
1293
+ "proof_recorded": bool(proof and proof.strip()),
1294
+ "proof_source": proof_source.strip() if proof_source and proof_source.strip() else None,
1299
1295
  "changed_files": sorted(path.as_posix() for path in changed_paths),
1300
1296
  "dry_run": dry_run,
1301
1297
  }
@@ -1423,7 +1419,7 @@ def _build_native_request_doc(repo_root: Path, planned_ref: str, title: str, arg
1423
1419
  references = [
1424
1420
  "`logics_manager/flow.py`",
1425
1421
  "`logics_manager/assist.py`",
1426
- "`python_tests/test_logics_manager_cli.py`",
1422
+ "`tests/python/test_logics_manager_cli.py`",
1427
1423
  ]
1428
1424
  content = "\n".join(
1429
1425
  [
@@ -1862,6 +1858,7 @@ def _build_native_product_brief(
1862
1858
  related_backlog = f"`{backlog_ref}`" if backlog_ref else "(none yet)"
1863
1859
  related_task = f"`{task_ref}`" if task_ref else "(none yet)"
1864
1860
  related_architecture = ", ".join(f"`{item}`" for item in architecture_refs) if architecture_refs else "(none yet)"
1861
+ signature_slug = _slugify(title) or "product-brief"
1865
1862
  content = "\n".join(
1866
1863
  [
1867
1864
  f"## {ref} - {title}",
@@ -1876,6 +1873,15 @@ def _build_native_product_brief(
1876
1873
  "# Overview",
1877
1874
  f"Logics should keep a single, predictable product surface for {title.lower()}.",
1878
1875
  "",
1876
+ "```mermaid",
1877
+ "%% logics-kind: product",
1878
+ f"%% logics-signature: product|{signature_slug}|generated",
1879
+ "flowchart TD",
1880
+ " Need[Product need] --> Scope[Scope and guardrails]",
1881
+ " Scope --> Decisions[Key decisions]",
1882
+ " Decisions --> Signals[Success signals]",
1883
+ "```",
1884
+ "",
1879
1885
  "# Goals",
1880
1886
  "- Keep the operator experience bounded and easy to reason about.",
1881
1887
  "- Preserve the CLI as the canonical workflow entrypoint.",
@@ -2235,24 +2241,30 @@ def build_parser() -> argparse.ArgumentParser:
2235
2241
 
2236
2242
  repair_gates = repair_sub.add_parser("gates", help="Check task and linked request gate checkboxes.")
2237
2243
  repair_gates.add_argument("source")
2244
+ repair_gates.add_argument("--verify-closeout")
2238
2245
  repair_gates.add_argument("--format", choices=("text", "json"), default="text")
2239
2246
  repair_gates.add_argument("--dry-run", action="store_true")
2240
2247
  repair_gates.set_defaults(func=cmd_repair_gates)
2241
2248
 
2242
2249
  repair_ac = repair_sub.add_parser("ac-traceability", help="Add missing AC traceability entries.")
2243
2250
  repair_ac.add_argument("source")
2251
+ repair_ac.add_argument("--proof")
2252
+ repair_ac.add_argument("--proof-source")
2253
+ repair_ac.add_argument("--verify-closeout")
2244
2254
  repair_ac.add_argument("--format", choices=("text", "json"), default="text")
2245
2255
  repair_ac.add_argument("--dry-run", action="store_true")
2246
2256
  repair_ac.set_defaults(func=cmd_repair_ac_traceability)
2247
2257
 
2248
2258
  repair_links = repair_sub.add_parser("links", help="Repair linked backlog/product references for a task.")
2249
2259
  repair_links.add_argument("source")
2260
+ repair_links.add_argument("--verify-closeout")
2250
2261
  repair_links.add_argument("--format", choices=("text", "json"), default="text")
2251
2262
  repair_links.add_argument("--dry-run", action="store_true")
2252
2263
  repair_links.set_defaults(func=cmd_repair_links)
2253
2264
 
2254
2265
  repair_mermaid = repair_sub.add_parser("mermaid", help="Insert or refresh workflow Mermaid signatures.")
2255
2266
  repair_mermaid.add_argument("--refs", nargs="+", required=True)
2267
+ repair_mermaid.add_argument("--verify-closeout")
2256
2268
  repair_mermaid.add_argument("--format", choices=("text", "json"), default="text")
2257
2269
  repair_mermaid.add_argument("--dry-run", action="store_true")
2258
2270
  repair_mermaid.set_defaults(func=cmd_repair_mermaid)
@@ -2260,6 +2272,9 @@ def build_parser() -> argparse.ArgumentParser:
2260
2272
  closeout_parser = sub.add_parser("closeout", help="Append validation, repair deterministic gaps, finish, and optionally validate/index.")
2261
2273
  closeout_parser.add_argument("source")
2262
2274
  closeout_parser.add_argument("--validation", action="append", default=[])
2275
+ closeout_parser.add_argument("--validation-command")
2276
+ closeout_parser.add_argument("--validation-result", default="passed")
2277
+ closeout_parser.add_argument("--validation-note")
2263
2278
  closeout_parser.add_argument("--index", action="store_true")
2264
2279
  closeout_parser.add_argument("--lint", action="store_true")
2265
2280
  closeout_parser.add_argument("--audit", action="store_true")
@@ -2601,30 +2616,73 @@ def _print_repair_payload(payload: dict[str, object], output_format: str) -> Non
2601
2616
  print(f"- {rel_path}")
2602
2617
 
2603
2618
 
2619
+ REPAIR_VERIFY_CODES = {
2620
+ "gates": {"task_gate_unchecked", "task_missing_done_gate", "request_dor_unchecked"},
2621
+ "ac-traceability": {"ac_missing_item_traceability", "ac_missing_task_traceability"},
2622
+ "links": {"backlog_missing_task_link", "companion_link_missing"},
2623
+ "mermaid": {"mermaid_signature_stale"},
2624
+ }
2625
+
2626
+
2627
+ def _repair_verify_snapshot(repo_root: Path, source: str | None, dry_run: bool) -> dict[str, str]:
2628
+ if dry_run or not source:
2629
+ return {}
2630
+ preflight = validate_closeout_payload(repo_root, source)
2631
+ return _snapshot_existing_files(repo_root, list(preflight.get("related_paths", [])))
2632
+
2633
+
2634
+ def _finalize_repair_verify(repo_root: Path, payload: dict[str, object], source: str | None, snapshot: dict[str, str]) -> dict[str, object]:
2635
+ if not source or payload.get("dry_run"):
2636
+ return payload
2637
+ preflight = validate_closeout_payload(repo_root, source)
2638
+ payload["preflight"] = preflight
2639
+ relevant_codes = REPAIR_VERIFY_CODES.get(str(payload.get("kind")), set())
2640
+ remaining_relevant = [issue for issue in preflight.get("issues", []) if issue.get("code") in relevant_codes]
2641
+ payload["rolled_back"] = False
2642
+ if remaining_relevant and snapshot:
2643
+ payload["attempted_changed_files"] = payload.get("changed_files", [])
2644
+ _restore_file_snapshot(repo_root, snapshot)
2645
+ payload["changed_files"] = []
2646
+ payload["rolled_back"] = True
2647
+ payload["rollback_reason"] = "repair verification left relevant closeout issues"
2648
+ payload["remaining_relevant_issues"] = remaining_relevant
2649
+ return payload
2650
+
2651
+
2604
2652
  def cmd_repair_gates(args: argparse.Namespace) -> dict[str, object]:
2605
2653
  repo_root = _find_repo_root(Path.cwd())
2654
+ verify_source = args.verify_closeout or args.source
2655
+ snapshot = _repair_verify_snapshot(repo_root, verify_source, args.dry_run)
2606
2656
  payload = repair_gates_payload(repo_root, args.source, dry_run=args.dry_run)
2657
+ payload = _finalize_repair_verify(repo_root, payload, verify_source, snapshot)
2607
2658
  _print_repair_payload(payload, args.format)
2608
2659
  return payload
2609
2660
 
2610
2661
 
2611
2662
  def cmd_repair_ac_traceability(args: argparse.Namespace) -> dict[str, object]:
2612
2663
  repo_root = _find_repo_root(Path.cwd())
2613
- payload = repair_ac_traceability_payload(repo_root, args.source, dry_run=args.dry_run)
2664
+ snapshot = _repair_verify_snapshot(repo_root, args.verify_closeout, args.dry_run)
2665
+ payload = repair_ac_traceability_payload(repo_root, args.source, dry_run=args.dry_run, proof=args.proof, proof_source=args.proof_source)
2666
+ payload = _finalize_repair_verify(repo_root, payload, args.verify_closeout, snapshot)
2614
2667
  _print_repair_payload(payload, args.format)
2615
2668
  return payload
2616
2669
 
2617
2670
 
2618
2671
  def cmd_repair_links(args: argparse.Namespace) -> dict[str, object]:
2619
2672
  repo_root = _find_repo_root(Path.cwd())
2673
+ verify_source = args.verify_closeout or args.source
2674
+ snapshot = _repair_verify_snapshot(repo_root, verify_source, args.dry_run)
2620
2675
  payload = repair_links_payload(repo_root, args.source, dry_run=args.dry_run)
2676
+ payload = _finalize_repair_verify(repo_root, payload, verify_source, snapshot)
2621
2677
  _print_repair_payload(payload, args.format)
2622
2678
  return payload
2623
2679
 
2624
2680
 
2625
2681
  def cmd_repair_mermaid(args: argparse.Namespace) -> dict[str, object]:
2626
2682
  repo_root = _find_repo_root(Path.cwd())
2683
+ snapshot = _repair_verify_snapshot(repo_root, args.verify_closeout, args.dry_run)
2627
2684
  payload = repair_mermaid_payload(repo_root, args.refs, dry_run=args.dry_run)
2685
+ payload = _finalize_repair_verify(repo_root, payload, args.verify_closeout, snapshot)
2628
2686
  _print_repair_payload(payload, args.format)
2629
2687
  return payload
2630
2688
 
@@ -2642,11 +2700,43 @@ def _closeout_refs(repo_root: Path, task_path: Path) -> list[str]:
2642
2700
  return sorted(refs)
2643
2701
 
2644
2702
 
2645
- def closeout_payload(repo_root: Path, source: str, *, validations: list[str], run_index: bool, run_lint: bool, run_audit: bool, dry_run: bool) -> dict[str, object]:
2703
+ def _snapshot_existing_files(repo_root: Path, rel_paths: list[str]) -> dict[str, str]:
2704
+ snapshot: dict[str, str] = {}
2705
+ for rel_path in rel_paths:
2706
+ path = repo_root / rel_path
2707
+ if path.is_file():
2708
+ snapshot[rel_path] = path.read_text(encoding="utf-8")
2709
+ return snapshot
2710
+
2711
+
2712
+ def _restore_file_snapshot(repo_root: Path, snapshot: dict[str, str]) -> None:
2713
+ for rel_path, content in snapshot.items():
2714
+ path = repo_root / rel_path
2715
+ path.write_text(content, encoding="utf-8")
2716
+
2717
+
2718
+ def closeout_payload(
2719
+ repo_root: Path,
2720
+ source: str,
2721
+ *,
2722
+ validations: list[str],
2723
+ run_index: bool,
2724
+ run_lint: bool,
2725
+ run_audit: bool,
2726
+ dry_run: bool,
2727
+ validation_command: str | None = None,
2728
+ validation_result: str = "passed",
2729
+ validation_note: str | None = None,
2730
+ ) -> dict[str, object]:
2646
2731
  task_path = _resolve_workflow_source(repo_root, DOC_KINDS["task"], source)
2647
2732
  task_ref = task_path.stem
2648
2733
  changed_files: set[str] = set()
2649
2734
  steps: list[dict[str, object]] = []
2735
+ initial_preflight = validate_closeout_payload(repo_root, task_ref)
2736
+ rollback_snapshot = {} if dry_run else _snapshot_existing_files(repo_root, list(initial_preflight.get("related_paths", [])))
2737
+
2738
+ if validation_command and validation_command.strip():
2739
+ validations = [*validations, _structured_validation_line(validation_command, validation_result, validation_note)]
2650
2740
 
2651
2741
  for validation in validations:
2652
2742
  if validation and validation.strip():
@@ -2678,12 +2768,20 @@ def closeout_payload(repo_root: Path, source: str, *, validations: list[str], ru
2678
2768
 
2679
2769
  preflight = validate_closeout_payload(repo_root, task_ref)
2680
2770
  if preflight["issues"]:
2771
+ rolled_back = False
2772
+ attempted_changed_files = sorted(changed_files)
2773
+ if rollback_snapshot:
2774
+ _restore_file_snapshot(repo_root, rollback_snapshot)
2775
+ changed_files.clear()
2776
+ rolled_back = True
2681
2777
  return {
2682
2778
  "command": "closeout",
2683
2779
  "ok": False,
2684
2780
  "source": task_path.relative_to(repo_root).as_posix(),
2685
2781
  "changed_files": sorted(changed_files),
2782
+ "attempted_changed_files": attempted_changed_files,
2686
2783
  "preflight": preflight,
2784
+ "rolled_back": rolled_back,
2687
2785
  "steps": steps,
2688
2786
  "dry_run": dry_run,
2689
2787
  }
@@ -2736,6 +2834,7 @@ def closeout_payload(repo_root: Path, source: str, *, validations: list[str], ru
2736
2834
  "lint": lint_result,
2737
2835
  "audit": audit_result,
2738
2836
  "steps": steps,
2837
+ "rolled_back": False,
2739
2838
  "dry_run": dry_run,
2740
2839
  }
2741
2840
 
@@ -2746,6 +2845,9 @@ def cmd_closeout(args: argparse.Namespace) -> dict[str, object]:
2746
2845
  repo_root,
2747
2846
  args.source,
2748
2847
  validations=args.validation or [],
2848
+ validation_command=args.validation_command,
2849
+ validation_result=args.validation_result,
2850
+ validation_note=args.validation_note,
2749
2851
  run_index=args.index,
2750
2852
  run_lint=args.lint,
2751
2853
  run_audit=args.audit,
@@ -0,0 +1,63 @@
1
+ from __future__ import annotations
2
+
3
+ from datetime import date
4
+ import re
5
+
6
+
7
+ def section_lines(lines: list[str], heading: str) -> list[str]:
8
+ start_idx = None
9
+ target = heading.strip().lower()
10
+ for idx, line in enumerate(lines):
11
+ if line.startswith("# ") and line[2:].strip().lower() == target:
12
+ start_idx = idx + 1
13
+ break
14
+ if start_idx is None:
15
+ return []
16
+ out: list[str] = []
17
+ for idx in range(start_idx, len(lines)):
18
+ line = lines[idx]
19
+ if line.startswith("# "):
20
+ break
21
+ out.append(line)
22
+ return out
23
+
24
+
25
+ def has_validation_evidence(text: str) -> bool:
26
+ concrete_ok_context = ("lint", "audit", "test", "pytest", "npm", "ci", "coverage", "smoke", "package")
27
+ invalid_markers = ("...", "todo", "tbd", "pending", "needs ", "need ", "not ok", "failed", "failure", "failing")
28
+ for line in section_lines(text.splitlines(), "Validation"):
29
+ stripped = line.strip()
30
+ if not stripped.startswith("- "):
31
+ continue
32
+ value = stripped[2:].strip().lower()
33
+ if not value or value.startswith("run `") or value.startswith("run the "):
34
+ continue
35
+ if any(marker in value for marker in invalid_markers):
36
+ continue
37
+ if "command:" in value and "result:" in value and ("date:" in value or "session:" in value):
38
+ result_match = re.search(r"\bresult:\s*([^|,;]+)", value)
39
+ result = result_match.group(1).strip() if result_match else ""
40
+ if result in {"pass", "passed", "ok", "success", "succeeded"}:
41
+ return True
42
+ if any(marker in value for marker in ("pass", "validated", "verified", "verification", "regression")):
43
+ return True
44
+ if "ok" in value and any(marker in value for marker in concrete_ok_context):
45
+ return True
46
+ return False
47
+
48
+
49
+ def has_ac_proof(text: str, ac_id: str) -> bool:
50
+ upper = text.upper()
51
+ return ac_id.upper() in upper and "proof:" in text.lower()
52
+
53
+
54
+ def structured_validation_line(command: str, result: str, note: str | None) -> str:
55
+ normalized_result = result.strip().lower() or "passed"
56
+ parts = [
57
+ f"command: `{command.strip()}`",
58
+ f"result: {normalized_result}",
59
+ f"date: {date.today().isoformat()}",
60
+ ]
61
+ if note and note.strip():
62
+ parts.append(f"note: {note.strip()}")
63
+ return " | ".join(parts)
@@ -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
+ )