@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
@@ -11,9 +11,12 @@ import subprocess
11
11
  from shutil import which
12
12
  from typing import Any
13
13
 
14
+ from .assist_handoff import build_handoff as _build_handoff
15
+ from .assist_surface import build_changed_surface_summary as _build_changed_surface_summary
14
16
  from .config import ConfigError, find_repo_root, load_repo_config
15
17
  from .doctor import doctor_payload
16
18
  from .lint import lint_payload
19
+ from .path_utils import resolve_repo_config_path, resolve_repo_output_path
17
20
  from .termstyle import colorize_help
18
21
 
19
22
 
@@ -88,8 +91,9 @@ def _hybrid_measurement_log(config: dict[str, object]) -> str:
88
91
  return str(_get_nested(config, "hybrid_assist", "measurement_log", default=DEFAULT_HYBRID_MEASUREMENT_LOG))
89
92
 
90
93
 
91
- def _repo_path(repo_root: Path, value: str | None, default: str) -> Path:
92
- return (repo_root / (value or default)).resolve()
94
+ def _repo_path(repo_root: Path, value: str | None, default: str, *, label: str) -> Path:
95
+ resolved, _relative = resolve_repo_config_path(repo_root, value or default, label=label)
96
+ return resolved
93
97
 
94
98
 
95
99
  def _parse_package_version(repo_root: Path) -> str:
@@ -723,7 +727,7 @@ def _render_diff_risk_text(payload: dict[str, object]) -> str:
723
727
  def _summarize_commit_scope(changed_paths: list[str]) -> tuple[str, str]:
724
728
  if not changed_paths:
725
729
  return "root", "No changes detected; nothing to commit."
726
- if any(path.startswith("src/") for path in changed_paths):
730
+ if any(path.startswith("clients/vscode/src/") or path.startswith("clients/shared-web/media/") for path in changed_paths):
727
731
  return "plugin", "Plugin surface changes detected."
728
732
  if any(path.startswith("logics_manager/") for path in changed_paths):
729
733
  return "python-runtime", "Native Logics manager changes detected."
@@ -765,55 +769,20 @@ def _build_commit_plan(changed_paths: list[str]) -> dict[str, object]:
765
769
  }
766
770
 
767
771
 
768
- def _build_changed_surface_summary(changed_paths: list[str]) -> dict[str, object]:
769
- category_counter: Counter[str] = Counter()
770
- for path in changed_paths:
771
- normalized = path.replace("\\", "/")
772
- if normalized.startswith("src/"):
773
- category_counter["plugin"] += 1
774
- elif normalized.startswith("logics_manager/"):
775
- category_counter["python-runtime"] += 1
776
- elif normalized.startswith("logics/"):
777
- category_counter["workflow-docs"] += 1
778
- elif normalized.startswith("tests/") or "/tests/" in normalized or normalized.startswith("python_tests/"):
779
- category_counter["tests"] += 1
780
- elif normalized.endswith(".md"):
781
- category_counter["docs"] += 1
782
- else:
783
- category_counter["other"] += 1
784
- primary = category_counter.most_common(1)[0][0] if category_counter else "clean"
785
- summary = {
786
- "clean": "No changed surface was detected.",
787
- "plugin": "The plugin surface is the dominant change area.",
788
- "python-runtime": "The native Python runtime is the dominant change area.",
789
- "workflow-docs": "Workflow documentation is the dominant change area.",
790
- "tests": "Tests are the dominant change area.",
791
- "docs": "Markdown documentation is the dominant change area.",
792
- "other": "Mixed repository changes are present.",
793
- }.get(primary, "Mixed repository changes are present.")
794
- return {
795
- "summary": summary,
796
- "primary_category": primary,
797
- "counts": dict(sorted(category_counter.items())),
798
- "changed_paths": changed_paths,
799
- "review_recommended": primary not in {"clean", "docs"} and bool(changed_paths),
800
- }
801
-
802
-
803
772
  def _build_validation_checklist(changed_paths: list[str]) -> dict[str, object]:
804
773
  surface = _build_changed_surface_summary(changed_paths)
805
774
  checks: list[str] = [
806
- "Run `python3 -m pytest python_tests/test_logics_manager_cli.py -q`.",
775
+ "Run `python3 -m pytest tests/python/test_logics_manager_cli.py -q`.",
807
776
  "Run `python3 -m compileall logics_manager`.",
808
777
  "Run `npm run lint:logics`.",
809
778
  ]
810
- if any(path.startswith("src/") for path in changed_paths):
779
+ if any(path.startswith("clients/vscode/src/") or path.startswith("clients/shared-web/media/") for path in changed_paths):
811
780
  checks.append("Run the plugin test suite that exercises the VS Code entrypoints.")
812
781
  if any(path.startswith("logics_manager/") for path in changed_paths):
813
782
  checks.append("Smoke-test `python3 -m logics_manager --help` and the affected native subcommands.")
814
783
  if any(path.startswith("logics/") for path in changed_paths):
815
784
  checks.append("Run `python3 -m logics_manager lint --require-status` and inspect the workflow docs manually.")
816
- if any(path.startswith("tests/") or path.startswith("python_tests/") for path in changed_paths):
785
+ if any(path.startswith("tests/") or path.startswith("tests/python/") for path in changed_paths):
817
786
  checks.append("Run the focused affected tests before broad regression sweeps.")
818
787
  if not changed_paths:
819
788
  checks.append("No validation needed beyond a clean smoke check; there are no tracked changes.")
@@ -829,15 +798,15 @@ def _build_test_impact_summary(changed_paths: list[str]) -> dict[str, object]:
829
798
  categories = _build_changed_surface_summary(changed_paths)["counts"]
830
799
  recommended: list[str] = []
831
800
  if "python-runtime" in categories:
832
- recommended.append("python3 -m pytest python_tests/test_logics_manager_cli.py -q")
801
+ recommended.append("python3 -m pytest tests/python/test_logics_manager_cli.py -q")
833
802
  if "plugin" in categories:
834
803
  recommended.append("npm run lint")
835
804
  if "workflow-docs" in categories:
836
805
  recommended.append("npm run lint:logics")
837
806
  if "tests" in categories:
838
- recommended.append("python3 -m pytest python_tests/test_logics_manager_cli.py -q")
807
+ recommended.append("python3 -m pytest tests/python/test_logics_manager_cli.py -q")
839
808
  if not recommended:
840
- recommended.append("python3 -m pytest python_tests/test_logics_manager_cli.py -q")
809
+ recommended.append("python3 -m pytest tests/python/test_logics_manager_cli.py -q")
841
810
  return {
842
811
  "summary": "Recommended test order derived from the current change surface.",
843
812
  "categories": categories,
@@ -1346,6 +1315,12 @@ def build_parser() -> argparse.ArgumentParser:
1346
1315
  closure_summary.add_argument("--dry-run", action="store_true")
1347
1316
  closure_summary.set_defaults(func=cmd_closure_summary)
1348
1317
 
1318
+ handoff = sub.add_parser("handoff", help="Summarize commits, changed surfaces, Logics docs, validations, and next actions.")
1319
+ handoff.add_argument("--since", required=True)
1320
+ handoff.add_argument("--format", choices=("text", "json"), default="text")
1321
+ handoff.add_argument("--dry-run", action="store_true")
1322
+ handoff.set_defaults(func=cmd_handoff)
1323
+
1349
1324
  return parser
1350
1325
 
1351
1326
 
@@ -1417,11 +1392,15 @@ def _build_help() -> str:
1417
1392
  " closure-summary [ref]",
1418
1393
  " Summarize a delivered request, backlog item, or task.",
1419
1394
  " Flags: --format {text,json}, --dry-run",
1395
+ " handoff",
1396
+ " Summarize commits, changed surfaces, Logics docs, validations, and next actions.",
1397
+ " Flags: --since, --format {text,json}, --dry-run",
1420
1398
  "",
1421
1399
  "Examples:",
1422
1400
  " logics-manager assist runtime-status --format json",
1423
1401
  " logics-manager assist context request req_001_my_request --profile deep",
1424
1402
  " logics-manager assist request-draft --intent \"Improve onboarding\"",
1403
+ " logics-manager assist handoff --since HEAD~1",
1425
1404
  ]
1426
1405
  )
1427
1406
 
@@ -1527,6 +1506,21 @@ def _build_command_help(command: str) -> str:
1527
1506
  " --dry-run",
1528
1507
  ]
1529
1508
  )
1509
+ if command == "handoff":
1510
+ return "\n".join(
1511
+ [
1512
+ "Logics Assist Handoff",
1513
+ "Summarize commits, changed surfaces, Logics docs, validations, and next actions.",
1514
+ "",
1515
+ "Usage:",
1516
+ " logics-manager assist handoff --since <rev> [args...]",
1517
+ "",
1518
+ "Flags:",
1519
+ " --since",
1520
+ " --format {text,json}",
1521
+ " --dry-run",
1522
+ ]
1523
+ )
1530
1524
  if command == "roi-report":
1531
1525
  return "\n".join(
1532
1526
  [
@@ -2236,8 +2230,8 @@ def cmd_test_impact_summary(args: argparse.Namespace) -> dict[str, object]:
2236
2230
  def cmd_roi_report(args: argparse.Namespace) -> dict[str, object]:
2237
2231
  repo_root = find_repo_root(Path.cwd())
2238
2232
  config, config_path = load_repo_config(repo_root)
2239
- audit_log = _repo_path(repo_root, args.audit_log, _hybrid_audit_log(config))
2240
- measurement_log = _repo_path(repo_root, args.measurement_log, _hybrid_measurement_log(config))
2233
+ audit_log = _repo_path(repo_root, args.audit_log, _hybrid_audit_log(config), label="configured audit_log")
2234
+ measurement_log = _repo_path(repo_root, args.measurement_log, _hybrid_measurement_log(config), label="configured measurement_log")
2241
2235
  payload = _build_hybrid_roi_report(
2242
2236
  repo_root,
2243
2237
  audit_log=audit_log,
@@ -2251,13 +2245,16 @@ def cmd_roi_report(args: argparse.Namespace) -> dict[str, object]:
2251
2245
  payload["config_path"] = str(config_path.relative_to(repo_root)) if config_path is not None else None
2252
2246
 
2253
2247
  if args.out:
2254
- out_path = (repo_root / args.out).resolve()
2248
+ out_path, output_path = resolve_repo_output_path(repo_root, args.out)
2249
+ payload["output_path"] = output_path
2255
2250
  serialized = json.dumps(payload, indent=2, sort_keys=True) + "\n"
2256
2251
  if not args.dry_run:
2257
2252
  out_path.parent.mkdir(parents=True, exist_ok=True)
2258
2253
  out_path.write_text(serialized, encoding="utf-8")
2259
- print(f"Wrote {out_path.relative_to(repo_root)}")
2260
- payload["output_path"] = out_path.relative_to(repo_root).as_posix()
2254
+ if args.format == "json":
2255
+ print(json.dumps(payload, indent=2, sort_keys=True))
2256
+ else:
2257
+ print(f"Wrote {output_path}")
2261
2258
  elif args.format == "json":
2262
2259
  print(json.dumps(payload, indent=2, sort_keys=True))
2263
2260
  else:
@@ -2307,13 +2304,16 @@ def cmd_runtime_status(args: argparse.Namespace) -> dict[str, object]:
2307
2304
  }
2308
2305
 
2309
2306
  if args.out:
2310
- out_path = (repo_root / args.out).resolve()
2307
+ out_path, output_path = resolve_repo_output_path(repo_root, args.out)
2308
+ payload["output_path"] = output_path
2311
2309
  serialized = json.dumps(payload, indent=2, sort_keys=True) + "\n"
2312
2310
  if not args.dry_run:
2313
2311
  out_path.parent.mkdir(parents=True, exist_ok=True)
2314
2312
  out_path.write_text(serialized, encoding="utf-8")
2315
- print(f"Wrote {out_path.relative_to(repo_root)}")
2316
- payload["output_path"] = out_path.relative_to(repo_root).as_posix()
2313
+ if args.format == "json":
2314
+ print(json.dumps(payload, indent=2, sort_keys=True))
2315
+ else:
2316
+ print(f"Wrote {output_path}")
2317
2317
  elif args.format == "json":
2318
2318
  print(json.dumps(payload, indent=2, sort_keys=True))
2319
2319
  else:
@@ -2363,14 +2363,14 @@ def cmd_request_draft(args: argparse.Namespace) -> dict[str, object]:
2363
2363
  **_build_request_draft(repo_root, intent=args.intent),
2364
2364
  }
2365
2365
  if args.execution_mode == "execute":
2366
- out_path = repo_root / payload["path"]
2366
+ out_path, output_path = resolve_repo_output_path(repo_root, str(payload["path"]), label="output")
2367
2367
  if not args.dry_run:
2368
2368
  out_path.parent.mkdir(parents=True, exist_ok=True)
2369
2369
  out_path.write_text(payload["content"], encoding="utf-8")
2370
2370
  payload["written"] = True
2371
2371
  else:
2372
2372
  payload["written"] = False
2373
- payload["output_path"] = out_path.relative_to(repo_root).as_posix()
2373
+ payload["output_path"] = output_path
2374
2374
  else:
2375
2375
  payload["written"] = False
2376
2376
  if args.format == "json":
@@ -2409,14 +2409,14 @@ def cmd_spec_first_pass(args: argparse.Namespace) -> dict[str, object]:
2409
2409
  **_build_spec_first_pass(repo_root, args.ref),
2410
2410
  }
2411
2411
  if args.execution_mode == "execute":
2412
- out_path = repo_root / payload["path"]
2412
+ out_path, output_path = resolve_repo_output_path(repo_root, str(payload["path"]), label="output")
2413
2413
  if not args.dry_run:
2414
2414
  out_path.parent.mkdir(parents=True, exist_ok=True)
2415
2415
  out_path.write_text(payload["content"], encoding="utf-8")
2416
2416
  payload["written"] = True
2417
2417
  else:
2418
2418
  payload["written"] = False
2419
- payload["output_path"] = out_path.relative_to(repo_root).as_posix()
2419
+ payload["output_path"] = output_path
2420
2420
  else:
2421
2421
  payload["written"] = False
2422
2422
  if args.format == "json":
@@ -2455,16 +2455,16 @@ def cmd_backlog_groom(args: argparse.Namespace) -> dict[str, object]:
2455
2455
  **_build_backlog_groom(repo_root, args.ref),
2456
2456
  }
2457
2457
  if args.execution_mode == "execute":
2458
- out_path = repo_root / payload["path"]
2458
+ out_path, output_path = resolve_repo_output_path(repo_root, str(payload["path"]), label="output")
2459
2459
  if not args.dry_run:
2460
2460
  out_path.parent.mkdir(parents=True, exist_ok=True)
2461
2461
  out_path.write_text(payload["content"], encoding="utf-8")
2462
2462
  payload["written"] = True
2463
- request_path = repo_root / payload["request_path"]
2463
+ request_path, _request_output_path = resolve_repo_output_path(repo_root, str(payload["request_path"]), label="request_path")
2464
2464
  _append_section_bullets(request_path, "Backlog", [f"`{payload['ref']}`"], dry_run=False)
2465
2465
  else:
2466
2466
  payload["written"] = False
2467
- payload["output_path"] = out_path.relative_to(repo_root).as_posix()
2467
+ payload["output_path"] = output_path
2468
2468
  else:
2469
2469
  payload["written"] = False
2470
2470
  if args.format == "json":
@@ -2513,6 +2513,34 @@ def cmd_closure_summary(args: argparse.Namespace) -> dict[str, object]:
2513
2513
  return payload
2514
2514
 
2515
2515
 
2516
+ def cmd_handoff(args: argparse.Namespace) -> dict[str, object]:
2517
+ repo_root = find_repo_root(Path.cwd())
2518
+ config, config_path = load_repo_config(repo_root)
2519
+ payload = {
2520
+ "command": "assist",
2521
+ "kind": "handoff",
2522
+ "repo_root": repo_root.as_posix(),
2523
+ "config_path": str(config_path.relative_to(repo_root)) if config_path is not None else None,
2524
+ **_build_handoff(repo_root, args.since),
2525
+ }
2526
+ if args.format == "json":
2527
+ print(json.dumps(payload, indent=2, sort_keys=True))
2528
+ else:
2529
+ print(f"Handoff since {payload['since']}:")
2530
+ print(f"- commits: {payload['commit_count']}")
2531
+ print(f"- changed paths: {len(payload['changed_paths'])}")
2532
+ print(f"- primary surface: {payload['surface']['primary_category']}")
2533
+ for commit in payload["commits"][:8]:
2534
+ print(f"- commit: {commit['commit']} {commit['subject']}")
2535
+ for doc in payload["logics_docs"][:8]:
2536
+ print(f"- logics: {doc['ref']} [{doc['status']}] {doc['path']}")
2537
+ for validation in payload["validations"][:8]:
2538
+ print(f"- validation: {validation}")
2539
+ for action in payload["next_actions"]:
2540
+ print(f"- next: {action}")
2541
+ return payload
2542
+
2543
+
2516
2544
  def cmd_context(args: argparse.Namespace) -> dict[str, object]:
2517
2545
  repo_root = find_repo_root(Path.cwd())
2518
2546
  config, config_path = load_repo_config(repo_root)
@@ -2554,13 +2582,16 @@ def cmd_context(args: argparse.Namespace) -> dict[str, object]:
2554
2582
  }
2555
2583
 
2556
2584
  if args.out:
2557
- out_path = (repo_root / args.out).resolve()
2585
+ out_path, output_path = resolve_repo_output_path(repo_root, args.out)
2586
+ payload["output_path"] = output_path
2558
2587
  serialized = json.dumps(payload, indent=2, sort_keys=True) + "\n"
2559
2588
  if not args.dry_run:
2560
2589
  out_path.parent.mkdir(parents=True, exist_ok=True)
2561
2590
  out_path.write_text(serialized, encoding="utf-8")
2562
- print(f"Wrote {out_path.relative_to(repo_root)}")
2563
- payload["output_path"] = out_path.relative_to(repo_root).as_posix()
2591
+ if args.format == "json":
2592
+ print(json.dumps(payload, indent=2, sort_keys=True))
2593
+ else:
2594
+ print(f"Wrote {output_path}")
2564
2595
  elif args.format == "json":
2565
2596
  print(json.dumps(payload, indent=2, sort_keys=True))
2566
2597
  else:
@@ -2576,7 +2607,7 @@ def main(argv: list[str]) -> int:
2576
2607
  if not argv or argv[0] in HELP_FLAGS:
2577
2608
  _print_help(_build_help())
2578
2609
  return 0
2579
- if argv[0] in {"runtime-status", "context", "request-draft", "spec-first-pass", "backlog-groom", "closure-summary", "roi-report", "diff-risk", "commit-plan", "changed-surface-summary", "doc-consistency", "review-checklist", "validation-checklist", "validation-summary", "test-impact-summary", "claude-bridges", "claude-instructions", "next-step"} and len(argv) > 1 and argv[1] in HELP_FLAGS:
2610
+ if argv[0] in {"runtime-status", "context", "request-draft", "spec-first-pass", "backlog-groom", "closure-summary", "handoff", "roi-report", "diff-risk", "commit-plan", "changed-surface-summary", "doc-consistency", "review-checklist", "validation-checklist", "validation-summary", "test-impact-summary", "claude-bridges", "claude-instructions", "next-step"} and len(argv) > 1 and argv[1] in HELP_FLAGS:
2580
2611
  _print_help(_build_command_help(argv[0]))
2581
2612
  return 0
2582
2613
  parser = build_parser()
@@ -0,0 +1,132 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+ import subprocess
5
+
6
+ from .assist_surface import build_changed_surface_summary
7
+
8
+
9
+ def _git_lines(repo_root: Path, args: list[str]) -> list[str]:
10
+ try:
11
+ completed = subprocess.run(
12
+ ["git", *args],
13
+ cwd=repo_root,
14
+ stdout=subprocess.PIPE,
15
+ stderr=subprocess.DEVNULL,
16
+ text=True,
17
+ check=False,
18
+ )
19
+ except OSError:
20
+ return []
21
+ if completed.returncode != 0:
22
+ return []
23
+ return [line.strip() for line in completed.stdout.splitlines() if line.strip()]
24
+
25
+
26
+ def _git_range_changed_paths(repo_root: Path, since: str) -> list[str]:
27
+ return sorted(set(_git_lines(repo_root, ["diff", "--name-only", f"{since}..HEAD"])))
28
+
29
+
30
+ def _git_range_commits(repo_root: Path, since: str) -> list[dict[str, str]]:
31
+ commits: list[dict[str, str]] = []
32
+ for line in _git_lines(repo_root, ["log", "--oneline", f"{since}..HEAD"]):
33
+ commit, _, subject = line.partition(" ")
34
+ commits.append({"commit": commit, "subject": subject})
35
+ return commits
36
+
37
+
38
+ def _section_lines(lines: list[str], heading: str) -> list[str]:
39
+ start_idx = None
40
+ target = heading.strip().lower()
41
+ for idx, line in enumerate(lines):
42
+ if line.startswith("# ") and line[2:].strip().lower() == target:
43
+ start_idx = idx + 1
44
+ break
45
+ if start_idx is None:
46
+ return []
47
+ out: list[str] = []
48
+ for idx in range(start_idx, len(lines)):
49
+ line = lines[idx]
50
+ if line.startswith("# "):
51
+ break
52
+ out.append(line)
53
+ return out
54
+
55
+
56
+ def _doc_status(path: Path) -> str:
57
+ for line in path.read_text(encoding="utf-8").splitlines():
58
+ stripped = line.strip()
59
+ if stripped.startswith("> Status:"):
60
+ return stripped.split(":", 1)[1].strip()
61
+ return "Unknown"
62
+
63
+
64
+ def _doc_title_from_path(path: Path) -> str:
65
+ try:
66
+ lines = path.read_text(encoding="utf-8").splitlines()
67
+ except OSError:
68
+ return path.stem
69
+ for line in lines:
70
+ if line.startswith("## "):
71
+ payload = line.removeprefix("## ").strip()
72
+ if " - " in payload:
73
+ return payload.split(" - ", 1)[1].strip()
74
+ return payload
75
+ return path.stem
76
+
77
+
78
+ def _validation_lines_from_task(path: Path) -> list[str]:
79
+ try:
80
+ lines = path.read_text(encoding="utf-8").splitlines()
81
+ except OSError:
82
+ return []
83
+ values: list[str] = []
84
+ for line in _section_lines(lines, "Validation"):
85
+ stripped = line.strip()
86
+ if not stripped.startswith("- "):
87
+ continue
88
+ value = stripped[2:].strip()
89
+ if value and not value.lower().startswith("run `") and not value.lower().startswith("run the "):
90
+ values.append(value)
91
+ return values
92
+
93
+
94
+ def build_handoff(repo_root: Path, since: str) -> dict[str, object]:
95
+ changed_paths = _git_range_changed_paths(repo_root, since)
96
+ commits = _git_range_commits(repo_root, since)
97
+ surface = build_changed_surface_summary(changed_paths)
98
+ logics_docs: list[dict[str, object]] = []
99
+ validations: list[str] = []
100
+ for rel_path in changed_paths:
101
+ if not rel_path.startswith("logics/") or not rel_path.endswith(".md"):
102
+ continue
103
+ path = repo_root / rel_path
104
+ kind = path.parent.name
105
+ entry = {
106
+ "path": rel_path,
107
+ "ref": path.stem,
108
+ "kind": kind,
109
+ "title": _doc_title_from_path(path),
110
+ "status": _doc_status(path) if path.is_file() else "Unknown",
111
+ }
112
+ logics_docs.append(entry)
113
+ if kind == "tasks":
114
+ validations.extend(_validation_lines_from_task(path))
115
+ next_actions = [
116
+ "Run lint/audit if not already included in validation evidence.",
117
+ "Review changed files before committing or handing off.",
118
+ ]
119
+ if any(path.startswith("logics_manager/") for path in changed_paths):
120
+ next_actions.append("Run `PYTHONPATH=\"$PWD\" pytest tests/python -q` for Python CLI changes.")
121
+ if any(path.startswith("clients/vscode/src/") or path.startswith("clients/shared-web/media/") for path in changed_paths):
122
+ next_actions.append("Run the TypeScript/vitest checks for extension changes.")
123
+ return {
124
+ "since": since,
125
+ "commit_count": len(commits),
126
+ "commits": commits,
127
+ "changed_paths": changed_paths,
128
+ "surface": surface,
129
+ "logics_docs": logics_docs,
130
+ "validations": sorted(set(validations)),
131
+ "next_actions": next_actions,
132
+ }
@@ -0,0 +1,38 @@
1
+ from __future__ import annotations
2
+
3
+ from collections import Counter
4
+
5
+
6
+ def build_changed_surface_summary(changed_paths: list[str]) -> dict[str, object]:
7
+ category_counter: Counter[str] = Counter()
8
+ for path in changed_paths:
9
+ normalized = path.replace("\\", "/")
10
+ if normalized.startswith("clients/vscode/src/") or normalized.startswith("clients/shared-web/media/"):
11
+ category_counter["plugin"] += 1
12
+ elif normalized.startswith("logics_manager/"):
13
+ category_counter["python-runtime"] += 1
14
+ elif normalized.startswith("logics/"):
15
+ category_counter["workflow-docs"] += 1
16
+ elif normalized.startswith("tests/") or "/tests/" in normalized or normalized.startswith("tests/python/"):
17
+ category_counter["tests"] += 1
18
+ elif normalized.endswith(".md"):
19
+ category_counter["docs"] += 1
20
+ else:
21
+ category_counter["other"] += 1
22
+ primary = category_counter.most_common(1)[0][0] if category_counter else "clean"
23
+ summary = {
24
+ "clean": "No changed surface was detected.",
25
+ "plugin": "The plugin surface is the dominant change area.",
26
+ "python-runtime": "The native Python runtime is the dominant change area.",
27
+ "workflow-docs": "Workflow documentation is the dominant change area.",
28
+ "tests": "Tests are the dominant change area.",
29
+ "docs": "Markdown documentation is the dominant change area.",
30
+ "other": "Mixed repository changes are present.",
31
+ }.get(primary, "Mixed repository changes are present.")
32
+ return {
33
+ "summary": summary,
34
+ "primary_category": primary,
35
+ "counts": dict(sorted(category_counter.items())),
36
+ "changed_paths": changed_paths,
37
+ "review_recommended": primary not in {"clean", "docs"} and bool(changed_paths),
38
+ }